본문 바로가기

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

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

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

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

이런 비동기 스트림을 동기 코드처럼 다룰 수 있도록 ES2018에서 비동기 이터레이터(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...");
동작 방식

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

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

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

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

Promise가 { value, done } 객체로 resolve되고 done이 false이면, value를 num에 할당하고 루프 본문을 실행합니다. done: true인 마지막 결과의 값은 본문에서 처리하지 않습니다.

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

생산자의 다음 요청은 이번 루프 본문의 200ms 대기가 끝난 뒤에야 시작됩니다. 그래서 원래 예제의 다섯 항목은 각각 생산 대기 500ms와 소비 대기 200ms를 거칩니다. 명목 대기 합은 5 × (500 + 200) = 3500ms이며 실제 시간에는 실행 지연이 더해집니다. 이 생산자는 별도 prefetch를 하지 않지만, 모든 async iterable이 같은 내부 버퍼 정책을 가진다는 뜻은 아닙니다.


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

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

파일 시스템 스트림 읽기: 대용량 파일을 청크(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();

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

타입스크립트는 비동기 이터레이터를 위한 타입을 내장하고 있으며, 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();

종료와 자원 소유권

비동기 반복은 소비와 자원 정리를 한 시간축으로 묶는다 — 상태 추적

next의 대기와 조기 종료 return/finally 완료를 구분한다. 소비자/iterator/자원 소유자의 상태로 해제 완료까지 표현할 가치가 있다.

소비자·반복자·소스 소유자의 3 lifeline에서 반복자 획득, next, 읽기 결과, 항목 전달, 본문 조기 종료, 구현된 close와 완료를 교환한다.

ASYNC ITERATION / RESOURCE LIFECYCLE

비동기 반복의 값 전달과 조기 종료 정리를 읽는다

for await는 next 결과를 순서대로 기다려 done: false의 값만 처리합니다. 루프 본문의 break·예외는 존재하는 return을 호출하고 기다립니다. 실제 자원을 닫는 동작은 반복자가 구현해야 합니다.

비동기 반복의 값 전달과 조기 종료 정리를 읽는다소비자·반복자·소스 소유자의 3 lifeline에서 반복자 획득, next, 읽기 결과, 항목 전달, 본문 조기 종료, 구현된 close와 완료를 교환한다.소비자for await반복자Async Iterator소스 소유자읽기·정리asyncIterator() 획득await next()비동기 읽기읽기 결과{ value, done: false }본문 종료 → return()구현된 finally → close정리 완료
순차 항목 전달 뒤 본문 조기 종료를 정리한다소비자·반복자·소스의 세 lifeline에서 asyncIterator 획득, await next, 읽기 요청과 결과, value·done false 항목 전달, 본문의 break 또는 예외에 따른 return, 구현된 finally와 close, 정리 완료를 시간 순서로 교환한다. 원래 close 경로를 구현한 반복자의 예이며 next 자체 거부의 자동 정리 그림이 아니다.소비자루프본문반복자nextreturn소유자읽기정리반복자획득awaitnext()비동기읽기읽기결과valuedone:false본문 종료return()finallyclose 구현정리완료

ITERATOR

프로토콜 객체를 얻는다

Symbol.asyncIterator를 먼저 사용하며 없으면 동기 iterable도 순회할 수 있습니다. 교재 async function* 객체는 비동기 iterator 겸 iterable입니다.

NEXT RESULT

항목을 순차 소비한다

next Promise의 done: false 값은 본문에서 처리합니다. done: true의 최종 return 값은 항목으로 처리하지 않습니다.

EARLY EXIT

본문 조기 종료는 return 대기

break·본문 예외로 반복을 닫을 때 존재하는 return을 호출합니다. 소유 자원 해제는 구현된 finally/close에 달려 있습니다.

SOURCE ERRORS

next 거부·취소는 별도 연결

next가 거부되거나 외부 signal이 취소되었다는 사실만으로 모든 자원의 자동 정리가 보장되지는 않습니다. 소스 오류와 정리 경로를 연결합니다.

LESSON LIMIT

파일·Mock 예제를 구별

readline 입력 스트림의 오류 처리는 별도입니다. MockWebSocket/MockDbCursor는 배열과 지연만 사용하며 실제 연결·커서·취소를 구현하지 않습니다.

위 close 시퀀스는 해당 정리 경로를 구현한 반복자의 예입니다. 본문 Mock을 실제 자원 해제 구현으로 해석하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
next 대기consumer: Promise 대기 / 자원: 열림consumer의 next가 소유자의 read 결과를 기다린다async iteration은 각 결과의 대기 계약을 가진다
value 처리iterator: 다음 지점 대기 / 자원: 계속 소유done:false의 value를 처리하고 다음 next를 요청할 수 있다값 한 개 사용이 자원 종료를 뜻하지 않는다
조기 종료 기다림consumer: 종료 중→완료 / 자원: cleanup 완료break/return 경로에서 iterator return/finally의 해제를 기다린다mock socket/file 예제 범위와 실제 오류 forwarding 보강을 구분한다

비동기 반복은 소비와 자원 정리를 한 시간축으로 묶는다 — 상태 추적

next의 대기와 조기 종료 return/finally 완료를 구분한다. 소비자/iterator/자원 소유자의 상태로 해제 완료까지 표현할 가치가 있다.

소비자·반복자·소스 소유자의 3 lifeline에서 반복자 획득, next, 읽기 결과, 항목 전달, 본문 조기 종료, 구현된 close와 완료를 교환한다.

ASYNC ITERATION / RESOURCE LIFECYCLE

비동기 반복의 값 전달과 조기 종료 정리를 읽는다

for await는 next 결과를 순서대로 기다려 done: false의 값만 처리합니다. 루프 본문의 break·예외는 존재하는 return을 호출하고 기다립니다. 실제 자원을 닫는 동작은 반복자가 구현해야 합니다.

비동기 반복의 값 전달과 조기 종료 정리를 읽는다소비자·반복자·소스 소유자의 3 lifeline에서 반복자 획득, next, 읽기 결과, 항목 전달, 본문 조기 종료, 구현된 close와 완료를 교환한다.소비자for await반복자Async Iterator소스 소유자읽기·정리asyncIterator() 획득await next()비동기 읽기읽기 결과{ value, done: false }본문 종료 → return()구현된 finally → close정리 완료
순차 항목 전달 뒤 본문 조기 종료를 정리한다소비자·반복자·소스의 세 lifeline에서 asyncIterator 획득, await next, 읽기 요청과 결과, value·done false 항목 전달, 본문의 break 또는 예외에 따른 return, 구현된 finally와 close, 정리 완료를 시간 순서로 교환한다. 원래 close 경로를 구현한 반복자의 예이며 next 자체 거부의 자동 정리 그림이 아니다.소비자루프본문반복자nextreturn소유자읽기정리반복자획득awaitnext()비동기읽기읽기결과valuedone:false본문 종료return()finallyclose 구현정리완료

ITERATOR

프로토콜 객체를 얻는다

Symbol.asyncIterator를 먼저 사용하며 없으면 동기 iterable도 순회할 수 있습니다. 교재 async function* 객체는 비동기 iterator 겸 iterable입니다.

NEXT RESULT

항목을 순차 소비한다

next Promise의 done: false 값은 본문에서 처리합니다. done: true의 최종 return 값은 항목으로 처리하지 않습니다.

EARLY EXIT

본문 조기 종료는 return 대기

break·본문 예외로 반복을 닫을 때 존재하는 return을 호출합니다. 소유 자원 해제는 구현된 finally/close에 달려 있습니다.

SOURCE ERRORS

next 거부·취소는 별도 연결

next가 거부되거나 외부 signal이 취소되었다는 사실만으로 모든 자원의 자동 정리가 보장되지는 않습니다. 소스 오류와 정리 경로를 연결합니다.

LESSON LIMIT

파일·Mock 예제를 구별

readline 입력 스트림의 오류 처리는 별도입니다. MockWebSocket/MockDbCursor는 배열과 지연만 사용하며 실제 연결·커서·취소를 구현하지 않습니다.

위 close 시퀀스는 해당 정리 경로를 구현한 반복자의 예입니다. 본문 Mock을 실제 자원 해제 구현으로 해석하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
next 대기consumer: Promise 대기 / 자원: 열림consumer의 next가 소유자의 read 결과를 기다린다async iteration은 각 결과의 대기 계약을 가진다
value 처리iterator: 다음 지점 대기 / 자원: 계속 소유done:false의 value를 처리하고 다음 next를 요청할 수 있다값 한 개 사용이 자원 종료를 뜻하지 않는다
조기 종료 기다림consumer: 종료 중→완료 / 자원: cleanup 완료break/return 경로에서 iterator return/finally의 해제를 기다린다mock socket/file 예제 범위와 실제 오류 forwarding 보강을 구분한다

위 파일 예제에서 readline의 반복이 조기에 끝나면 인터페이스는 닫히지만, 입력 스트림 오류는 비동기 반복자로 전달되지 않습니다. 실제 파일 처리에는 입력 스트림의 오류 처리와 소유 자원 정리를 별도로 연결해야 합니다. 바깥 try/catch만으로 모든 스트림 오류를 처리한다고 해석하면 안 됩니다.

MockWebSocket과 MockDbCursor는 메모리 배열과 지연으로 순회를 설명합니다. 실제 연결·커서나 취소 신호를 만들지 않으므로, 그림의 외부 자원 해제를 구현한 예제는 아닙니다.

조기 종료 때 기다리는 정리 작업

다음은 파일이나 소켓을 열지 않는 별도 실습입니다. 세 항목을 생산하는 객체를 만들고 정리 콜백을 finally에 연결합니다. 실제 자원 API의 close를 흉내 내어, 루프를 빠져나가기 전에 비동기 정리를 기다리는 순서만 확인합니다.

async function* ownedValues(
  onClose: () => Promise<void>
): AsyncGenerator<number, void, unknown> {
  try {
    for (const value of [10, 20, 30]) {
      yield value;
    }
  } finally {
    await onClose();
  }
}

async function inspectClose(): Promise<void> {
  const events: string[] = [];
  const values = ownedValues(async () => {
    events.push("정리 시작");
    await Promise.resolve();
    events.push("정리 완료");
  });
  for await (const value of values) {
    events.push(`값 ${value}`);
    break;
  }
  events.push("루프 밖");
  console.log(events.join(" / "));
}

inspectClose();

출력은 값 10 / 정리 시작 / 정리 완료 / 루프 밖입니다. break는 중단된 async generator의 return을 통해 finally로 들어가고, finally의 await까지 마친 뒤 루프 밖으로 이어집니다. 첫 next 전에 객체만 만들었다가 return을 호출하면 본문에 아직 진입하지 않아 이 finally가 실행되지 않는 경우도 구별해야 합니다. 자원을 실제로 여는 시점과 정리 함수의 연결 시점을 함께 정하는 이유입니다.

실제 파일 예제에 적용하려면 입력 스트림의 오류를 잡는 경로와 자신이 소유한 스트림·인터페이스를 닫는 경로를 모두 설계해야 합니다. Node.js의 readline 비동기 반복 안내는 입력 스트림 오류 전달과 인터페이스 생성 직후의 읽기 시점에 관한 제한도 설명합니다. 위 메모리 실습을 완성된 파일 처리기로 해석하지 않습니다.

같은 객체를 다시 순회하면 어디에서 시작할까?

원래 MockDbCursor에서 첫 페이지 [1, 2]를 받은 직후 break하고, 새 인스턴스를 만들지 않은 채 같은 cursor를 두 번째 for await로 순회한다고 가정합니다. 두 번째 순회의 페이지와 레코드 수를 예측하고, 처음부터 다시 읽으려면 무엇을 바꾸어야 하는지 설명해 보세요. 이어 ownedValues 실습에서 break를 지우면 값과 정리·루프 밖의 순서가 어떻게 달라지는지 적어 보세요.

인스턴스 상태와 반복자 상태 확인

MockDbCursor는 첫 페이지를 yield하기 전에 객체 필드 currentIndex를 2로 올립니다. 두 번째 순회는 새 generator 객체를 얻어도 같은 cursor 필드를 사용하므로 [3, 4] 두 레코드가 든 한 페이지만 얻습니다. 원래 예제에서 처음부터 읽으려면 새 MockDbCursor 인스턴스를 만듭니다. return이 첫 generator를 닫았다는 사실만으로 필드가 0으로 초기화되지는 않습니다.

break를 지우면 값 10·20·30을 모두 처리한 다음 정리 시작·정리 완료·루프 밖 순서입니다. 조기 종료든 정상 소진이든 이미 진입한 try를 나갈 때 finally가 실행됩니다. 실제 DB 커서의 재사용·취소 정책은 이 배열 Mock에서 증명하지 않습니다.

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