비동기 이터레이터와 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가 될 때까지 이 과정이 반복됩니다.
비동기 이터레이터의 활용 사례
비동기 이터레이터는 다음과 같은 시나리오에서 매우 유용합니다.
파일 시스템 스트림 읽기: 대용량 파일을 청크(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();종료와 자원 소유권
ASYNC ITERATION · RESOURCE LIFECYCLE
비동기 반복은 소비와 자원 정리를 한 시간축으로 묶는다
for await...of는 next()를 순차 대기한다. 루프 본문의 break·예외로 끝나면 존재하는 return()을 호출한다. 아래 정리 경로는 반복자가 finally에서 소유 자원을 닫도록 구현한 경우다.
-
반복자 획득
소비자는
Symbol.asyncIterator로 프로토콜 객체를 얻고 한 번에 하나의next()만 기다립니다. -
값 전달
반복자는 소스 읽기를 대기한 뒤
{ value, done }를 돌려줍니다. 소비자는done: false인 값만 처리합니다. -
조기 종료
루프 본문의
break나 예외는 존재하는return()을 호출하고 그 결과를 기다립니다. -
자원 닫기
그림은 반복자의
finally에서 실제 소유자에게 close를 전달하도록 구현한 경우입니다.
취소 신호와 next() 거부는 자동 정리 보장이 아니다. 소스 오류·취소를 자원 소유자의 정리 경로에 별도로 연결해야 한다.
위 파일 예제에서 readline의 반복이 조기에 끝나면 인터페이스는 닫히지만, 입력 스트림 오류는 비동기 반복자로 전달되지 않습니다. 실제 파일 처리에는 입력 스트림의 오류 처리와 소유 자원 정리를 별도로 연결해야 합니다. 바깥 try/catch만으로 모든 스트림 오류를 처리한다고 해석하면 안 됩니다.
MockWebSocket과 MockDbCursor는 메모리 배열과 지연으로 순회를 설명합니다. 실제 연결·커서나 취소 신호를 만들지 않으므로, 그림의 외부 자원 해제를 구현한 예제는 아닙니다.
이것으로 11장 비동기 프로그래밍을 마칩니다.