비동기 이터레이터와 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 구문
비동기 반복은 여러 Promise를 한꺼번에 던지는 것이 아니라 생산 속도와 소비 속도를 맞춰 순차 처리한다.
- next 요청
for await가 iterator.next를 순서대로 호출한다.
- pending 대기
Promise가 해결될 때까지 루프 본문이 멈춘다.
- yield value
준비된 값을 하나씩 처리한다.
- done true
더 이상 값이 없으면 반복을 종료한다.
| 구분 | 의미 | 판단 |
|---|---|---|
| 생산자 | async function* | 늦게 도착하는 값을 yield |
| 소비자 | for await...of | 순서를 보존하며 처리 |
| 정리 | return/throw/finally | 중간 실패와 자원 해제를 표현 |
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되면, value는 num 변수에 할당되고 루프 본문이 실행됩니다.
done이 true가 될 때까지 이 과정이 반복됩니다.
비동기 이터레이터의 활용 사례
비동기 이터레이터는 다음과 같은 시나리오에서 매우 유용합니다.
파일 시스템 스트림 읽기: 대용량 파일을 청크(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();파일, 웹소켓, DB 커서는 모양은 달라도 next() 가 Promise를 돌려주고 for await 가 값을 하나씩 기다린다는 점이 같다.
- next Promise
다음 값이 아직 없으면 대기 상태 자체가 계약이 된다.
- for await
루프 변수는 AsyncIterable<T> 의 T로 추론된다.
- Backpressure
소비 속도가 생산자에게 압력으로 전달되도록 설계한다.
- Cleanup
중단과 예외가 자원 해제로 이어지는지 확인한다.
| 대상 | 생산 값 | 소비 방식 | 설계 포인트 |
|---|---|---|---|
| 파일 스트림 | AsyncGenerator<string> 로 줄 단위 문자열을 낸다. | 파일 전체를 메모리에 올리지 않고 준비된 줄만 처리한다. | 읽기 오류와 파일 닫기를 반복 종료와 같이 본다. |
| 웹소켓 | AsyncIterable<Message> 가 도착 순서대로 메시지를 준다. | 루프 안에서 메시지를 처리하고 필요하면 중간에 멈춘다. | 느린 소비자가 연결 버퍼를 밀어 올리지 않게 한다. |
| DB 커서 | AsyncGenerator<Row[]> 가 페이지 단위 결과를 낸다. | 대량 결과를 작은 묶음으로 순차 처리한다. | 커서 닫기, 트랜잭션 범위, 취소 신호를 함께 둔다. |
| 작업 큐 | 비동기 작업 결과나 진행 이벤트를 순서대로 노출한다. | 처리량을 루프 본문 속도로 자연스럽게 제한한다. | 재시도, 실패 전파, 종료 신호를 타입에 드러낸다. |
타입스크립트와 비동기 이터레이터
타입스크립트는 비동기 이터레이터를 위한 타입을 내장하고 있으며, 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를 적용하기 전에 확인할 생산자, 소비자, 종료 조건의 계약을 정리합니다.
값의 타입만 맞아도 생산자 정리, 소비자 중단, 오류 전파가 빠지면 스트림 코드는 오래 버티기 어렵습니다.
- producer생산자는 다음 값을 약속
next() 가 Promise로 IteratorResult를 돌려주고, 완료 시 done을 명확히 보냅니다. · AsyncIterator
- consumer소비자는 순서대로 대기
루프 본문은 각 값을 처리한 뒤 다음 요청으로 넘어가므로 backpressure가 자연스럽습니다. · for await...of
- cleanup중단 시 정리를 보장
break, throw, 연결 종료 같은 상황에서 파일 핸들러나 구독을 닫아야 합니다. · return 또는 finally
비동기 스트림 처리 요약
비동기 이터레이터와 for await...of는 비동기 데이터 스트림을 동기 코드처럼 간결하고 직관적으로 처리할 수 있게 해주는 강력한 기능입니다.
Promise와 async/await이 단일 비동기 작업에 최적화된 반면, 비동기 이터레이터는 연속적인 비동기 값의 흐름을 다루는 데 강점을 가집니다.
타입스크립트의 타입 시스템과 결합하면 이러한 비동기 스트림 처리 코드를 더욱 안전하고 유지보수하기 쉽게 만들 수 있습니다.
운영 코드에서는 타입 추론만큼이나 루프가 멈추는 조건, 생산자 정리, 오류 전파가 중요합니다.
다음 체크리스트는 for await...of를 실제 스트림 처리에 붙이기 전에 확인할 지점을 정리합니다.
비동기 값의 타입이 맞아도 생산자 중단, 오류 전파, 리소스 정리 계약이 없으면 장시간 실행 흐름에서 누수가 생긴다.
- 생산자 속도
source API, 파일, 큐가 값을 얼마나 자주 밀어내는지 확인하고 소비자가 따라갈 수 있는 단위로 끊는다. backpressure 기준
- 타입 변환 경계
adapter AsyncIterable<T> 로 감싸기 전에 원본 오류와 완료 신호를 같은 형태로 맞춘다. next 결과 정규화
- 소비자 중단
루프 조건 만족, 사용자 취소, 라우트 이동처럼 루프가 일찍 끝나는 경우를 먼저 테스트한다. break와 throw 경로
- 리소스 반환
cleanup 구독, 파일 핸들, 타이머, 네트워크 연결은 finally 또는 return() 으로 닫는다. 누수 방지
이것으로 11장 비동기 프로그래밍을 마칩니다.
비동기 프로그래밍은 현대 웹 및 서버 개발의 핵심이며, Promise, async/await, 제너레이터, 그리고 비동기 이터레이터는 이러한 복잡성을 효과적으로 관리하기 위한 필수적인 도구들입니다.
아래 다이어그램은 for await...of를 운영 코드에 붙일 때 생산자 정리, 루프 중단, 오류 전파를 함께 점검합니다.
연속 비동기 값을 동기 루프처럼 읽더라도 생산자 종료, 중단, 오류 전파는 명시적으로 점검해야 합니다.
- 비동기 이터러블 생산
Producer 스트림, 페이지네이션, 메시지 큐가 `AsyncIterable<T>` 계약으로 다음 값을 비동기적으로 넘깁니다. AsyncIterable<T>
- 값을 하나씩 대기
반복 `for await`은 다음 값이 준비될 때까지 기다리며 처리 순서를 단순하게 만듭니다.
- 중단 조건 명시
Cancel 사용자 취소, timeout, break 조건을 정해 무한 대기와 불필요한 처리를 막습니다.
- 생산자 정리
Cleanup finally나 return 처리로 열린 연결과 파일 핸들을 닫는 경로를 보장합니다.
아래 다이어그램은 비동기 이터러블, async iterator, for await...of, 중단 처리를 연결해 실행 흐름을 점검합니다.
한 iteration의 next() 와 본문 처리가 끝난 뒤 다음 값을 요청한다. 이 pull 순서는 임의의 push 소스 버퍼까지 자동 제어하지는 않는다.
- 1iterator 획득
· GET iterator 획득 Symbol.asyncIterator 또는 sync iterable
- 2next()
· NEXT next() Promise<IteratorResult<T>>
- 3결과 대기
· AWAIT 결과 대기 done:false 면 value 바인딩
- 4본문 처리
· BODY 본문 처리 본문 await 뒤 다음 next 호출
- 5종료·중단
· CLOSE 종료·중단 done:true 또는 return()
- 6타입 계약
AsyncIterable<T> 가 next 결과를 검사한다.
- 7런타임 계약
Promise · Symbol.asyncIterator 가 필요하다.
- 8정리 계약
중단 시 return() Promise를 기다리며 자원 해제는 구현이 맡는다.
- 9처리가 직렬
PACE 처리가 직렬 병렬성이 필요하면 제한된 동시성 정책을 별도로 둔다.
- 10자원이 남음
CLEANUP 자원이 남음 generator finally 나 custom return 에서 close한다.
- 11reject·throw 혼재
ERROR reject·throw 혼재 소스 next rejection과 소비 본문 오류를 관측 지점에서 분리한다.
비동기 이터레이터와 for await...of를 실제 코드에 적용할 때는 생산자, 소비자, 중단 조건, 정리 기준을 먼저 나눕니다.
특히 루프가 중간에 끊길 때 네트워크 연결, 파일 핸들, 스트림 구독이 어디에서 해제되는지까지 확인해야 운영 코드에서 안전합니다.