본문으로 건너뛰기

안동민 개발노트

본문 시작

비동기 이터레이터와 for-await-of

AsyncIterable의 next가 Promise를 반환하는 구조를 이해하고 for await로 네트워크·파일 스트림 값을 순차 처리합니다.

이전 11장 2절에서 일반 제너레이터로 이터러블 객체를 만들고 동기적으로 순회하는 방법을 배웠습니다.

하지만 웹/Node.js 환경에서는 파일 스트림, 웹소켓 메시지, DB 커서처럼 비동기 데이터 시퀀스를 순회해야 하는 경우가 더 많습니다.

이런 비동기 스트림을 동기 코드처럼 다룰 수 있도록 ES2018에서 비동기 이터레이터(Async Iterators)for await...of 구문이 도입됐습니다.

타입스크립트는 이 기능을 완전히 지원하며, 강력한 타입 검사로 스트림 처리 코드를 더 견고하게 만들어 줍니다.

HTML 다이어그램: /docs/typescript/ch11/ch11-4/1.html

비동기 이터러블과 비동기 이터레이터

정의
  • 비동기 이터러블 (Async Iterable): Symbol.asyncIterator 메서드를 구현한 객체입니다. 이 메서드는 비동기 이터레이터(Async Iterator)를 반환합니다.
  • 비동기 이터레이터 (Async Iterator): next() 메서드가 Promise를 반환하는 객체입니다. 이 Promise는 { value: T, done: boolean } 형태의 객체로 resolve됩니다.

설명: 일반 이터러블(Symbol.iterator를 구현)이 next() 메서드에서 직접 { value, done } 객체를 반환하는 반면, 비동기 이터러블은 next() 메서드가 Promise를 반환합니다.

그리고 그 Promise가 { value, done } 객체로 resolve됩니다.

여기서 value는 다음 비동기 데이터이고, done은 스트림 종료 여부를 나타냅니다.


for await...of 구문

HTML 다이어그램: /docs/typescript/ch11/ch11-4/2.html

for await...of 루프는 비동기 이터러블을 순회하기 위한 전용 구문입니다.

일반 for...of 루프가 동기 이터러블을 순회하듯이, for await...of는 비동기적으로 생성되는 값을 기다리며 순회합니다.

기본 구조 예시
// 비동기 제너레이터 함수 (async function*)를 사용하여 비동기 이터러블 구현
async function* asyncNumberGenerator(): AsyncGenerator<number, void, undefined> {
  let count = 0;
  while (count < 5) {
    console.log(`[Generator] Yielding ${count} after 500ms...`);
    await new Promise(resolve => setTimeout(resolve, 500)); // 비동기 작업 대기
    yield count++;
  }
  console.log("[Generator] Done yielding numbers.");
}

async function processAsyncStream(): Promise<void> {
  console.log("Starting async stream processing...");
  const generator = asyncNumberGenerator(); // 비동기 이터러블 (async generator object) 반환

  for await (const num of generator) { // for await...of 루프 사용
    console.log(`[Consumer] Received: ${num}`);
    await new Promise(resolve => setTimeout(resolve, 200)); // 각 항목 처리에도 비동기 작업 시뮬레이션
  }
  console.log("Async stream processing finished.");
}

processAsyncStream();
console.log("Main program continues immediately...");
동작 방식

asyncNumberGenerator()를 호출하면 즉시 AsyncGenerator 객체가 반환됩니다.

for await (const num of generator) 루프는 이 AsyncGenerator 객체의 [Symbol.asyncIterator]() 메서드를 호출하여 비동기 이터레이터를 얻습니다. (사실 async function*는 이미 그 자체로 비동기 이터레이터 겸 이터러블입니다.)

루프는 반복적으로 이터레이터의 next() 메서드를 호출합니다.

next()는 Promise를 반환하고, for await...of는 이 Promise가 해결될 때까지 기다립니다.

Promise가 { value, done } 객체로 resolve되면, valuenum 변수에 할당되고 루프 본문이 실행됩니다.

donetrue가 될 때까지 이 과정이 반복됩니다.


비동기 이터레이터의 활용 사례

비동기 이터레이터는 다음과 같은 시나리오에서 매우 유용합니다.

파일 시스템 스트림 읽기: 대용량 파일을 청크(chunk) 단위로 비동기적으로 읽을 때.

import * as fs from 'fs';
import * as readline from 'readline';

async function* readLinesFromFile(filePath: string): AsyncGenerator<string> {
  const fileStream = fs.createReadStream(filePath);
  const rl = readline.createInterface({
    input: fileStream,
    crlfDelay: Infinity,
  });

  for await (const line of rl) {
    yield line;
  }
}

async function processLargeFile(filePath: string): Promise<void> {
  console.log(`Processing file: ${filePath}`);
  try {
    let lineNumber = 1;
    for await (const line of readLinesFromFile(filePath)) {
      console.log(`Line ${lineNumber++}: ${line}`);
      // await new Promise(resolve => setTimeout(resolve, 10)); // 각 라인 처리의 비동기 시뮬레이션
    }
    console.log("Finished processing file.");
  } catch (error) {
    console.error("Error processing file:", error);
  }
}

// 예시 사용: 'my_large_file.txt' 라는 파일이 있다고 가정
// Node.js 환경에서 실행 가능
// require('fs').writeFileSync('my_large_file.txt', 'Line 1\nLine 2\nLine 3\n');
// processLargeFile('my_large_file.txt');

웹소켓 메시지 처리: 실시간으로 수신되는 메시지를 순차적으로 처리할 때.

// 가상의 웹소켓 클라이언트 (실제 구현은 ws 라이브러리 등 사용)
class MockWebSocket {
  private messages: string[] = ['Hello', 'World', 'Async', 'Iterator', 'Done!'];
  private index = 0;

  async *[Symbol.asyncIterator](): AsyncGenerator<string> {
    while (this.index < this.messages.length) {
      await new Promise(resolve => setTimeout(resolve, 300)); // 메시지 수신 대기 시뮬레이션
      const message = this.messages[this.index++];
      console.log(`[WebSocket] Sending: ${message}`);
      yield message;
    }
  }
}

async function processWebSocketMessages(): Promise<void> {
  console.log("Connecting to WebSocket...");
  const ws = new MockWebSocket(); // 실제로는 new WebSocket(...)
  for await (const msg of ws) {
    console.log(`[App] Received: ${msg}`);
    if (msg === 'Done!') {
      break; // 특정 메시지 수신 시 종료
    }
  }
  console.log("WebSocket message processing finished.");
}

processWebSocketMessages();

데이터베이스 커서: 대량의 쿼리 결과를 커서(Cursor) 방식으로 비동기적으로 가져올 때.

// 가상의 데이터베이스 커서
interface DbRecord {
  id: number;
  name: string;
}

class MockDbCursor {
  private data: DbRecord[] = [
    { id: 1, name: 'Item A' },
    { id: 2, name: 'Item B' },
    { id: 3, name: 'Item C' },
    { id: 4, name: 'Item D' },
  ];
  private currentIndex = 0;
  private pageSize = 2; // 페이지당 가져올 레코드 수

  async *[Symbol.asyncIterator](): AsyncGenerator<DbRecord[]> {
    while (this.currentIndex < this.data.length) {
      const page = this.data.slice(this.currentIndex, this.currentIndex + this.pageSize);
      this.currentIndex += this.pageSize;
      await new Promise(resolve => setTimeout(resolve, 700)); // DB 쿼리 지연 시뮬레이션
      console.log(`[DB Cursor] Yielding page with ${page.length} records.`);
      yield page; // 레코드 묶음을 페이지 단위로 yield
    }
  }
}

async function processDatabaseRecords(): Promise<void> {
  console.log("Fetching database records...");
  const cursor = new MockDbCursor();

  for await (const page of cursor) {
    console.log(`[App] Processing new page of records (${page.length} items):`);
    for (const record of page) {
      console.log(`  - Record: ${record.id}, ${record.name}`);
    }
    await new Promise(resolve => setTimeout(resolve, 300)); // 각 페이지 처리의 비동기 시뮬레이션
  }
  console.log("Finished processing all database records.");
}

processDatabaseRecords();
HTML 다이어그램: /docs/typescript/ch11/ch11-4/3.html

타입스크립트와 비동기 이터레이터

타입스크립트는 비동기 이터레이터를 위한 타입을 내장하고 있으며, async function* 문법을 사용할 때 이를 자동으로 추론합니다.

  • AsyncIterable<T>: [Symbol.asyncIterator](): AsyncIterator<T> 메서드를 구현한 객체의 타입입니다.
  • AsyncIterator<T>: next(): Promise<IteratorResult<T>> 메서드를 구현한 객체의 타입입니다.
  • AsyncGenerator<YieldType, ReturnType, NextType>: async function*가 반환하는 제너레이터 객체의 타입입니다. 일반 Generator와 유사하게 yield 값, return 값, next() 인자의 타입을 명시할 수 있습니다.

타입스크립트를 사용하면 for await...of 루프 내에서 변수의 타입이 정확하게 추론되므로, 개발자가 예상치 못한 타입 오류를 방지할 수 있습니다.

// 이전 예시의 async function* asyncNumberGenerator(): AsyncGenerator<number, void, undefined>
// 여기서 <number>는 yield 되는 값의 타입, <void>는 최종 return 값의 타입,
// <undefined>는 next()에 전달할 수 있는 값의 타입 (없으므로 undefined)

async function consumeStringStream(): Promise<void> {
    async function* stringStream(): AsyncGenerator<string> {
        yield "Hello";
        yield "TypeScript";
        yield "Async";
        yield "Iterators";
    }

    for await (const item of stringStream()) {
        console.log(item.length); // item은 string 타입으로 추론되므로 .length 접근 가능
        // item.toFixed(); // Error: 'string' 형식에 'toFixed' 속성이 없습니다. (타입 안전성 보장)
    }
}

consumeStringStream();

비동기 이터레이터 코드는 타입이 맞더라도 종료와 정리를 빠뜨리면 실서비스에서 문제가 됩니다.

아래 보드는 for await...of를 적용하기 전에 확인할 생산자, 소비자, 종료 조건의 계약을 정리합니다.

HTML 다이어그램: /docs/typescript/ch11/ch11-4/4.html

비동기 스트림 처리 요약

비동기 이터레이터와 for await...of는 비동기 데이터 스트림을 동기 코드처럼 간결하고 직관적으로 처리할 수 있게 해주는 강력한 기능입니다.

Promise와 async/await이 단일 비동기 작업에 최적화된 반면, 비동기 이터레이터는 연속적인 비동기 값의 흐름을 다루는 데 강점을 가집니다.

타입스크립트의 타입 시스템과 결합하면 이러한 비동기 스트림 처리 코드를 더욱 안전하고 유지보수하기 쉽게 만들 수 있습니다.

운영 코드에서는 타입 추론만큼이나 루프가 멈추는 조건, 생산자 정리, 오류 전파가 중요합니다.

다음 체크리스트는 for await...of를 실제 스트림 처리에 붙이기 전에 확인할 지점을 정리합니다.

HTML 다이어그램: /docs/typescript/ch11/ch11-4/5.html

이것으로 11장 비동기 프로그래밍을 마칩니다.

비동기 프로그래밍은 현대 웹 및 서버 개발의 핵심이며, Promise, async/await, 제너레이터, 그리고 비동기 이터레이터는 이러한 복잡성을 효과적으로 관리하기 위한 필수적인 도구들입니다.

아래 다이어그램은 for await...of를 운영 코드에 붙일 때 생산자 정리, 루프 중단, 오류 전파를 함께 점검합니다.

HTML 다이어그램: /docs/typescript/ch11/ch11-4/6.html

아래 다이어그램은 비동기 이터러블, async iterator, for await...of, 중단 처리를 연결해 실행 흐름을 점검합니다.

HTML 다이어그램: /docs/typescript/ch11/ch11-4/7.html

비동기 이터레이터와 for await...of를 실제 코드에 적용할 때는 생산자, 소비자, 중단 조건, 정리 기준을 먼저 나눕니다.

특히 루프가 중간에 끊길 때 네트워크 연결, 파일 핸들, 스트림 구독이 어디에서 해제되는지까지 확인해야 운영 코드에서 안전합니다.