icon
11장 : 비동기 프로그래밍

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


이전 11.2절에서 일반 제너레이터 함수를 사용하여 이터러블 객체를 만들고 동기적으로 값을 순회하는 방법을 배웠습니다. 하지만 웹 애플리케이션이나 Node.js 환경에서는 파일 스트림 읽기, 웹소켓 메시지 수신, 데이터베이스 커서 처리와 같이 비동기적으로 발생하는 데이터 시퀀스를 순회해야 하는 경우가 많습니다.

이러한 비동기 데이터 스트림을 동기 코드처럼 for...of 루프와 유사한 방식으로 처리할 수 있도록 ES2018(ECMAScript 2018)에서 **비동기 이터레이터(Async Iterators)**와 for await...of 구문이 도입되었습니다. 타입스크립트는 이 기능을 완벽하게 지원하며, 강력한 타입 검사를 통해 비동기 스트림 처리 코드를 더욱 견고하게 만듭니다.


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

정의

  • 비동기 이터러블 (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 구문

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...");

동작 방식

  1. asyncNumberGenerator()를 호출하면 즉시 AsyncGenerator 객체가 반환됩니다.
  2. for await (const num of generator) 루프는 이 AsyncGenerator 객체의 [Symbol.asyncIterator]() 메서드를 호출하여 비동기 이터레이터를 얻습니다. (사실 async function*는 이미 그 자체로 비동기 이터레이터 겸 이터러블입니다.)
  3. 루프는 반복적으로 이터레이터의 next() 메서드를 호출합니다.
  4. next()는 Promise를 반환하고, for await...of는 이 Promise가 해결될 때까지 기다립니다.
  5. Promise가 { value, done } 객체로 resolve되면, valuenum 변수에 할당되고 루프 본문이 실행됩니다.
  6. donetrue가 될 때까지 이 과정이 반복됩니다.

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

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

  1. 파일 시스템 스트림 읽기: 대용량 파일을 청크(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');
  2. 웹소켓 메시지 처리: 실시간으로 수신되는 메시지를 순차적으로 처리할 때.

    // 가상의 웹소켓 클라이언트 (실제 구현은 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();
  3. 데이터베이스 커서: 대량의 쿼리 결과를 커서(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();

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

타입스크립트는 비동기 이터레이터를 위한 타입을 내장하고 있으며, 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는 비동기적으로 발생하는 데이터 스트림을 동기 코드처럼 간결하고 직관적인 방식으로 처리할 수 있게 해주는 강력한 기능입니다. 이는 Promise와 async/await이 단일 비동기 작업을 다루는 데 최적화된 반면, 비동기 이터레이터는 연속적인 비동기 값의 흐름을 다루는 데 강점을 가집니다. 타입스크립트의 타입 시스템과의 통합은 이러한 비동기 스트림 처리 코드를 더욱 안전하고 유지보수하기 쉽게 만듭니다.

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