비동기 제너레이터

async function*와 for await...of로 비동기 데이터 스트림을 우아하게 처리하는 비동기 제너레이터의 동작 원리와 실전 패턴을 알아봅니다.

· 6 min read · PALDYN Team

지난 글에서 제너레이터로 실행을 일시 정지·재개하는 법을 배웠습니다. 이번에는 async function*for await...of를 결합한 비동기 제너레이터(Async Generator) 를 다룹니다. 동기 제너레이터가 값을 yield한다면, 비동기 제너레이터는 Promise를 await한 결과yield합니다.

동기 vs 비동기 제너레이터

// 동기 제너레이터 — next()는 { value, done }을 즉시 반환
function* sync() {
  yield 1;
  yield 2;
}
const s = sync();
s.next(); // { value: 1, done: false }

// 비동기 제너레이터 — next()는 Promise<{ value, done }>을 반환
async function* async_() {
  yield await fetch('/api/a').then(r => r.json());
  yield await fetch('/api/b').then(r => r.json());
}
const a = async_();
await a.next(); // Promise → { value: {...}, done: false }

비동기 제너레이터 안에서는 awaityield를 모두 쓸 수 있습니다. async function* 선언이 두 기능을 하나로 합칩니다.

비동기 제너레이터 동작 원리

for await…of

비동기 이터러블은 for await...of로 소비합니다. 동기 for...of[Symbol.iterator]()를 호출하듯, for await...of[Symbol.asyncIterator]()를 호출합니다.

async function* everySecond() {
  let count = 0;
  while (count < 5) {
    await new Promise(r => setTimeout(r, 1000));
    yield count++;
  }
}

// for await...of로 소비
async function main() {
  for await (const value of everySecond()) {
    console.log(value); // 0 (1초 후), 1 (2초 후), ...
  }
}

main();

루프 본문이 실행되는 동안 다음 next()는 호출되지 않습니다. 백프레셔(backpressure)가 자연스럽게 구현됩니다.

API 페이지네이션

비동기 제너레이터가 가장 빛나는 사례는 페이지네이션 API 스트리밍입니다. 전체 데이터를 한 번에 메모리에 올리지 않고 페이지 단위로 처리합니다.

async function* fetchAllPages(baseUrl) {
  let url = baseUrl;

  while (url) {
    const res  = await fetch(url);
    const data = await res.json();

    yield data.items;             // 한 페이지씩 반환
    url = data.nextPage ?? null;  // 다음 페이지 URL (없으면 종료)
  }
}

async function processAllPosts() {
  for await (const page of fetchAllPages('/api/posts')) {
    for (const post of page) {
      await saveToDatabase(post); // 한 페이지씩 처리
    }
  }
}

전체 데이터를 배열에 담지 않으므로 수백만 건의 데이터도 일정한 메모리로 처리할 수 있습니다.

비동기 제너레이터 페이지네이션 패턴

파일 청크 읽기 (Node.js)

Node.js Readable 스트림을 비동기 이터러블로 소비할 수 있습니다.

import { createReadStream } from 'fs';
import { createInterface } from 'readline';

async function* readLines(filepath) {
  const rl = createInterface({
    input: createReadStream(filepath),
    crlfDelay: Infinity,
  });

  for await (const line of rl) {
    yield line;
  }
}

// 대용량 로그 파일을 줄 단위로 처리
async function countErrors(filepath) {
  let count = 0;
  for await (const line of readLines(filepath)) {
    if (line.includes('ERROR')) count++;
  }
  return count;
}

Readable Stream → AsyncIterable

Web Streams API의 ReadableStream도 비동기 이터러블입니다.

async function* streamToLines(stream) {
  const reader  = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        if (buffer) yield buffer;
        break;
      }
      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop(); // 마지막 불완전한 줄은 보관
      for (const line of lines) yield line;
    }
  } finally {
    reader.releaseLock();
  }
}

// 서버에서 스트리밍 응답 처리
const res = await fetch('/api/stream');
for await (const line of streamToLines(res.body)) {
  console.log(line);
}

에러 처리

비동기 제너레이터에서도 try/finally로 자원을 해제하고 에러를 처리합니다.

async function* withCleanup() {
  const connection = await openDB();
  try {
    const rows = await connection.query('SELECT * FROM logs');
    for (const row of rows) {
      yield row;
    }
  } finally {
    await connection.close(); // for await...of가 break되거나 오류 발생 시에도 실행
  }
}

// break로 조기 종료해도 finally 실행됨
for await (const row of withCleanup()) {
  if (row.level === 'FATAL') break; // connection.close() 호출됨
}

for await...of에서 break하거나 예외가 발생하면 제너레이터의 return() 메서드가 호출되고 finally 블록이 실행됩니다.

AsyncIterator 프로토콜 직접 구현

제너레이터 없이 [Symbol.asyncIterator]()를 직접 구현할 수도 있습니다.

const asyncRange = {
  from: 1,
  to: 5,
  [Symbol.asyncIterator]() {
    let current = this.from;
    const last = this.to;
    return {
      async next() {
        await new Promise(r => setTimeout(r, 200));
        if (current <= last) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
};

for await (const n of asyncRange) {
  console.log(n); // 1~5 (200ms 간격)
}

ReadableStream 통합과 미래

Node.js 18+와 최신 브라우저에서 ReadableStream은 기본적으로 [Symbol.asyncIterator]를 구현합니다. fetch 응답의 body를 그대로 for await...of로 소비할 수 있어 비동기 제너레이터 패턴이 점점 표준화되고 있습니다.

비동기 제너레이터는 페이지네이션, 스트리밍, 이벤트 스트림 등 비동기 데이터 흐름을 명확하게 표현하는 강력한 도구입니다. 다음 글에서는 변수가 어디서 접근 가능한지를 결정하는 스코프 체인(Scope Chain) 을 살펴봅니다.


지난 글: 제너레이터 함수

다음 글: 스코프 체인


읽어주셔서 감사합니다. 😊