본문 바로가기

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

next가 Promise를 돌려주는 비동기 이터러블의 규칙과 for await가 값을 하나씩 기다리는 순서를 출력으로 추적하고, 직접 만든 비동기 이터러블과 파일을 줄 단위로 읽는 Node.js 예제, 반복을 일찍 끝낼 때 정리 코드가 실행되는 순서와 반복자의 상태를 다시 쓸 때의 주의점을 다룹니다.

2편에서 본 일반 이터레이터는 next()를 부르면 그 자리에서 { value, done }을 돌려주었습니다. 그런데 파일을 줄 단위로 읽거나 데이터베이스에서 결과를 몇 개씩 받아 오는 일은, 다음 값이 준비될 때까지 기다려야 합니다. ES2018의 비동기 이터러블(async iterable)과 for await...of는 이렇게 하나씩 기다려야 하는 값의 흐름을, 일반 반복문처럼 읽히게 해 줍니다.

이 글은 비동기 이터러블의 규칙을 정리하고, for await가 값을 기다리고 처리하는 순서를 출력으로 따라갑니다. 이어서 비동기 이터러블을 직접 만드는 방법과 Node.js에서 파일을 줄 단위로 읽는 예를 보고, 반복을 일찍 끝낼 때 정리 코드가 언제 실행되는지 확인합니다.

비동기 이터러블의 규칙

비동기 이터러블은 [Symbol.asyncIterator]() 메서드로 비동기 이터레이터(async iterator)를 돌려주는 객체입니다. 비동기 이터레이터의 next()는 결과를 바로 돌려주지 않고, { value, done }으로 이행되는 Promise를 돌려줍니다. 타입스크립트는 이 모양을 AsyncIterable<T>와 AsyncIterator<T>로 나타내며, async function*이 돌려주는 객체는 둘을 모두 갖춘 AsyncGenerator입니다.

이 규칙만 지키면 제너레이터 없이도 비동기 이터러블을 만들 수 있습니다. 다음 객체는 next()를 부를 때마다 숫자를 하나씩, 두 개를 내보낸 뒤 끝납니다.

const twoNumbers: AsyncIterable<number> = {
  [Symbol.asyncIterator]() {
    let n = 0;
    return {
      async next(): Promise<IteratorResult<number>> {
        return n < 2 ? { value: n++, done: false } : { value: undefined, done: true };
      },
    };
  },
};

for await (const value of twoNumbers) {
  console.log(value);
}
0
1

for await는 시작할 때 [Symbol.asyncIterator]()를 불러 이터레이터를 얻고, next()가 돌려준 Promise를 기다려 done이 false이면 value로 본문을 실행합니다. done: true인 결과를 받으면 반복을 끝내며, 그 결과의 value는 본문에서 처리하지 않습니다. 이 예제는 파일의 최상위에서 for await를 쓰므로 ES 모듈이어야 합니다.

값을 기다리고 처리하는 순서

비동기 제너레이터로 만든 이터러블에서 생산과 소비가 어떤 순서로 번갈아 일어나는지 출력으로 확인해 봅니다. 생산 쪽 countUp은 값을 하나 만들 때마다 50ms를 기다리고, 소비 쪽 consume은 값을 받을 때마다 20ms를 기다립니다. 마지막 두 줄은 consume()을 await 없이 부른 뒤 곧바로 다음 줄로 넘어가므로, 메인 코드 계속이 출력의 어디에 끼어드는지도 함께 보세요.

const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

async function* countUp(limit: number): AsyncGenerator<number, void, undefined> {
  for (let count = 0; count < limit; count++) {
    console.log(`[생산] ${count} 준비`);
    await delay(50);
    yield count;
  }
  console.log("[생산] 끝");
}

async function consume(): Promise<void> {
  for await (const num of countUp(3)) {
    console.log(`[소비] ${num} 받음`);
    await delay(20);
  }
  console.log("[소비] 끝");
}

consume();
console.log("메인 코드 계속");
[생산] 0 준비
메인 코드 계속
[소비] 0 받음
[생산] 1 준비
[소비] 1 받음
[생산] 2 준비
[소비] 2 받음
[생산] 끝
[소비] 끝

consume()을 부르면 for await가 첫 next()를 부르고, 제너레이터의 본문은 첫 await까지 곧바로 실행되어 [생산] 0 준비를 출력합니다. 두 함수가 그 자리에서 기다리는 동안 호출한 쪽은 계속 진행해 메인 코드 계속을 출력하고, 0.05초 뒤 값 0이 소비하는 쪽으로 전달됩니다. 소비하는 쪽은 본문의 0.02초 대기가 끝난 뒤에야 다음 값을 요청하므로, 이 생산자는 미리 다음 값을 만들어 두지 않고 요청이 올 때만 하나씩 만듭니다.

그래서 값 세 개를 처리하는 데 드는 시간은 명목상 3×(50+20)ms, 곧 0.21초 남짓입니다. 생산과 소비를 겹쳐서 빠르게 하고 싶다면 다음 값을 미리 요청해 두는 별도의 구조가 필요하며, 모든 비동기 이터러블이 같은 방식으로 값을 미리 만들거나 쌓아 두는 것은 아닙니다.

파일을 줄 단위로 읽기

Node.js의 readline 모듈이 만드는 인터페이스는 비동기 이터러블이어서, 큰 파일도 한 번에 메모리에 올리지 않고 줄 단위로 읽을 수 있습니다. 이 예제는 Node.js의 모듈을 쓰므로 @types/node를 설치하고 "types": ["node"]를 적어야 합니다.

read-lines.ts
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

async function printLines(filePath: string): Promise<void> {
  const lines = createInterface({
    input: createReadStream(filePath),
    crlfDelay: Infinity,
  });
  let lineNumber = 1;
  for await (const line of lines) {
    console.log(`${lineNumber++}: ${line}`);
  }
}

await printLines("notes.txt");

notes.txt에 apple, banana, cherry 세 줄이 있다면 1: apple, 2: banana, 3: cherry가 출력됩니다. crlfDelay: Infinity는 윈도우의 줄바꿈 \r\n을 한 번의 줄바꿈으로 읽게 하는 옵션입니다. 다만 Node.js 문서에 따르면 입력 스트림에서 생긴 오류는 이 반복으로 전달되지 않으므로, 파일을 찾지 못하는 경우처럼 스트림의 오류는 error 이벤트로 따로 처리해야 합니다. 또 인터페이스는 만들어지는 순간부터 입력을 읽기 시작하므로, 만든 뒤 반복을 시작하기 전에 다른 비동기 작업을 기다리면 그사이의 줄을 놓칠 수 있습니다. 자세한 조건은 Node.js의 readline 안내에 있습니다.

반복을 일찍 끝낼 때

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

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 보강을 구분한다

위 그림처럼 for await의 본문에서 break나 return으로 빠져나오거나 예외가 생기면, 반복문은 이터레이터(그림의 반복자)의 return()을 부르고 그것이 끝나기를 기다린 뒤에 빠져나갑니다. 비동기 제너레이터라면 이 return()이 멈추어 있던 yield의 자리에서 finally를 실행하게 합니다. 실제로 파일이나 연결을 닫는 일은 이렇게 불린 정리 코드가 직접 해야 합니다.

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하면, 반복문은 제너레이터의 return()을 불러 finally로 들어가게 하고, finally 안의 await까지 끝나기를 기다립니다. 그래서 정리의 시작과 완료가 모두 기록된 뒤에야 루프 밖이 기록됩니다. 반면 첫 next()를 부르기도 전에 return()을 부르면 본문이 시작된 적이 없어 finally도 실행되지 않으므로, 자원을 실제로 여는 시점과 정리 코드를 거는 시점을 함께 맞추어야 합니다.

더 깊이: 범위를 벗어날 때 정리하는 await using

제너레이터의 finally는 그 제너레이터가 연 자원을 정리하는 방법입니다. 반복 밖에서 직접 연 자원에도 비슷한 장치가 있는데, TypeScript 5.2가 지원하기 시작한 명시적 자원 관리(explicit resource management)의 using과 await using 선언입니다. using으로 선언한 변수는 그 블록이 끝날 때, 또는 return이나 던져진 오류로 일찍 빠져나가기 바로 전에 Symbol.dispose 메서드가 불립니다. await using은 Symbol.asyncDispose를 찾아 그 정리가 끝나기를 기다리므로, 연결을 닫는 것처럼 비동기로 끝나는 정리에 씁니다. 여러 개를 선언하면 스택처럼 나중에 선언한 것부터 먼저 정리합니다.

본문에서 예외가 나면 정리를 마친 뒤 그 예외를 다시 던지고, 본문과 정리가 모두 예외를 던지면 두 오류를 함께 담은 SuppressedError가 던져집니다. 여러 자원과 임의의 정리 함수를 한데 모아야 할 때는 DisposableStack과 AsyncDisposableStack을 쓰며, 자원을 만든 직후에 defer로 정리 함수를 걸어 두라고 릴리스 노트는 권합니다. 다만 5.2가 나올 당시에는 대부분의 런타임이 이 기능을 직접 지원하지 않아 Symbol.dispose와 Symbol.asyncDispose 같은 값의 폴리필과 esnext.disposable 라이브러리 설정이 필요했으므로, 쓰기 전에 실행 환경의 지원 여부를 확인합니다.

반복자의 상태

비동기 이터러블이 상태를 어디에 두는지에 따라, 같은 객체를 다시 돌 때의 결과가 달라집니다. 다음 커서는 읽은 위치를 이터레이터가 아니라 객체 자신의 필드에 둡니다.

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;

  async *[Symbol.asyncIterator](): AsyncGenerator<DbRecord[]> {
    while (this.currentIndex < this.data.length) {
      const page = this.data.slice(this.currentIndex, this.currentIndex + 2);
      this.currentIndex += 2;
      yield page;
    }
  }
}

const cursor = new MockDbCursor();
for await (const page of cursor) {
  console.log(page.map((record) => record.id).join(","));
}
1,2
3,4

레코드를 두 개씩 묶어 한 페이지로 내보내므로 두 페이지가 출력됩니다. 이 커서는 메모리의 배열로 흉내 낸 것이어서, 실제 데이터베이스의 커서처럼 연결을 열고 닫거나 취소 신호를 받지는 않습니다.

정리

  • 비동기 이터러블은 [Symbol.asyncIterator]()로 이터레이터를 주고, 그 next()는 { value, done }으로 이행되는 Promise를 돌려줍니다.
  • for await는 next()의 결과를 하나씩 기다려 done이 false인 값만 처리하며, 본문이 끝나야 다음 값을 요청합니다.
  • 비동기 제너레이터는 요청이 올 때만 다음 값을 만들므로, 생산과 소비는 기본적으로 번갈아 일어납니다.
  • 본문에서 일찍 빠져나오면 반복문은 이터레이터의 return()을 부르고 정리가 끝나기를 기다리며, 실제 자원을 닫는 일은 정리 코드가 직접 합니다.
  • 읽은 위치 같은 상태를 객체의 필드에 두면 이터레이터를 새로 얻어도 상태가 이어지므로, 처음부터 다시 읽으려면 새 객체를 만듭니다.

연습 문제

1. 같은 커서를 다시 돌기

MockDbCursor에서 첫 페이지 1,2를 받은 직후 break하고, 같은 cursor로 for await를 한 번 더 돌면 무엇이 출력되나요? 처음부터 다시 읽으려면 어떻게 해야 하나요?

해설 보기

3,4 한 페이지만 출력됩니다. 커서는 첫 페이지를 내보내기 전에 객체의 필드 currentIndex를 2로 올려 두었고, 두 번째 반복은 새 제너레이터를 얻지만 같은 필드를 쓰기 때문입니다. return()이 첫 제너레이터를 닫았다고 필드가 0으로 돌아가지는 않으므로, 처음부터 읽으려면 new MockDbCursor()로 새 객체를 만들거나 위치를 이터레이터 안의 지역 변수로 옮깁니다.

2. break를 지운 정리

inspectClose에서 break;를 지우면 기록되는 순서는 어떻게 되나요?

해설 보기

값 10 / 값 20 / 값 30 / 정리 시작 / 정리 완료 / 루프 밖이 됩니다. 세 값을 모두 처리한 뒤 제너레이터의 반복문이 끝나 try를 벗어나면서 finally가 실행되고, 그 정리가 끝난 뒤에 done: true가 전달되어 반복문이 끝납니다. 일찍 끝내든 끝까지 돌든, 이미 들어간 try를 벗어날 때 finally가 실행된다는 점은 같습니다.

3. 다음 값을 미리 받지 않는 이유

countUp(3)의 소비 본문에서 await delay(20)을 await delay(200)으로 바꾸면, [생산] 1 준비는 [소비] 0 받음보다 얼마나 뒤에 출력되나요?

해설 보기

약 0.2초 뒤에 출력됩니다. 이 제너레이터는 소비하는 쪽이 다음 값을 요청할 때만 본문을 이어서 실행하는데, 요청은 본문의 0.2초 대기가 끝난 뒤에야 일어나기 때문입니다. 생산하는 쪽이 그사이에 다음 값을 미리 만들어 두지 않는다는 것이 당겨 오는 방식의 특징이며, 그 덕분에 소비가 느려도 값이 쌓이지 않습니다.

이것으로 11장의 비동기를 마칩니다. 다음 장의 순수 함수와 불변성에서는 같은 입력에 늘 같은 결과를 돌려주는 함수와, 값을 바꾸지 않고 새 값을 만드는 방식으로 코드를 짜는 함수형 프로그래밍을 다룹니다.