본문으로 건너뛰기

프론트엔드

TypeScript 비동기 제너레이터와 SSE 스트림 처리, 메인 스레드 양보

2026. 07. 16. 목요일 오후 9시 0분

이 블로그의 SSE 실습 글은 reader.read() 루프 안에 청크 파싱과 화면 갱신 코드가 함께 있는 클릭 핸들러를 씁니다. 파싱과 소비 코드가 하나로 묶여 있으면 파싱 로직만 따로 재사용하기 어렵습니다.

값을 여러 번 리턴하는 함수가 있으면 이 문제를 풀 수 있습니다. 자바스크립트에는 이런 용도에 맞는 문법이 이미 있습니다. 바로 비동기 제너레이터(async generator)입니다.

이번 포스트에서는 비동기 제너레이터가 어떤 메커니즘으로 값을 여러 번 내놓는지 분해해 보고, SSE 스트림 소비와 "렌더링을 막지 않는 무거운 계산"이라는 두 가지 실전 상황에 적용해 보겠습니다.


비동기 제너레이터: 여러 번 리턴하는 함수

한 번만 리턴하는 일반 함수

일반 함수는 return을 만나면 끝납니다. 결과를 여러 개 돌려주고 싶으면 배열에 전부 담아서 한 번에 리턴해야 합니다.

function getTokens(): string[] {
  // 세 개의 값을 돌려주고 싶어도, 배열에 담아 한 번에 리턴하는 수밖에 없다
  return ['첫 번째', '두 번째', '세 번째'];
}

전부 모아서 한 번에 주는 게 문제없는 경우도 많습니다. 하지만 "값이 준비되는 대로 하나씩 받아서 바로 처리하고 싶은" 상황이라면 이 구조로는 대응하기 어렵습니다. SSE 스트리밍이 그런 경우입니다. 마지막 토큰까지 기다렸다가 한 번에 받으면 스트리밍의 의미가 없습니다.

제너레이터는 값이 완성되는 대로 하나씩 내놓을 수 있는 함수입니다.

function*: 제너레이터 함수

비동기 제너레이터로 가기 전에, 동기 버전인 제너레이터 함수부터 보겠습니다. function 뒤에 별표(*)를 붙이고, return 대신 yield로 값을 내놓습니다.

function* getTokens() {
  yield '첫 번째'; // 값을 내놓고 멈춤. 함수가 끝난 게 아니다!
  yield '두 번째';
  yield '세 번째';
  return '끝'; // return도 가능하지만, 순회에는 포함되지 않는다
}
 
const gen = getTokens(); // 이 시점에는 함수 본문이 한 줄도 실행되지 않는다
 
console.log(gen.next()); // { value: '첫 번째', done: false }
console.log(gen.next()); // { value: '두 번째', done: false }
console.log(gen.next()); // { value: '세 번째', done: false }
console.log(gen.next()); // { value: '끝', done: true } ← return 값은 done: true와 함께 온다

일반 함수와 다른 점이 두 가지 보입니다.

  1. 호출해도 본문이 실행되지 않습니다. 대신 next() 메서드를 가진 제너레이터 객체가 반환됩니다.
  2. next()를 호출하면 다음 yield를 만날 때까지만 실행하고 멈춥니다. 값은 { value, done } 형태로 감싸져 나옵니다.

yield는 "값을 내놓고 일시 정지"입니다. 함수의 지역 변수와 실행 위치가 그대로 보존된 채 멈춰 있다가, 소비자가 next()를 다시 호출하면 멈췄던 위치부터 이어서 실행됩니다.

매번 next()를 부르는 건 번거로우니, 보통은 for...of로 순회합니다.

for (const token of getTokens()) {
  console.log(token); // '첫 번째', '두 번째', '세 번째'
}
// return 값인 '끝'은 done: true와 함께 오기 때문에 순회에 포함되지 않는다

async function*: Promise를 여러 번 내놓는 함수

SSE 토큰처럼 "기다려야 얻을 수 있는 값"을 하나씩 내놓으려면 다른 방법이 필요합니다. 동기 제너레이터로 표현하려면 Promise를 yield하고 소비자가 일일이 await하는 식으로 우회해야 합니다.

이 패턴을 언어 차원에서 문법으로 지원하는 것이 async와 function*을 합친 비동기 제너레이터 함수입니다. 본문에서 await와 yield를 함께 쓸 수 있습니다.

// 매개변수 ms만큼 대기하는 함수 (외부 API의 지연시간을 시뮬레이션)
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
 
async function* streamTokens() {
  const tokens = ['안녕하세요, ', '저는 ', 'AI ', '어시스턴트입니다.'];
 
  for (const token of tokens) {
    await sleep(100); // 토큰이 준비되는 데 시간이 걸리는 상황
    yield token; // 준비된 토큰을 하나씩 내놓는다
  }
}

소비하는 쪽은 for와 of 사이에 await를 끼워 넣은 for await...of 문법을 사용합니다.

async function main() {
  for await (const token of streamTokens()) {
    console.log(token); // 100ms 간격으로 하나씩 출력된다
  }
}
 
main();

전부 모일 때까지 기다렸다가 배열로 받는 게 아니라, 토큰이 준비될 때마다 루프 본문이 한 번씩 실행됩니다.

제너레이터는 풀(pull) 방식으로 동작합니다. yield에서 멈춘 제너레이터는 소비자가 다음 값을 요청(next() 호출)하기 전까지 스스로 진행하지 않습니다. 로그를 심어서 확인해 보겠습니다. 로그의 '주방'은 제너레이터 쪽 출력이고, '손님'은 소비자 쪽 출력입니다.

async function* courseMeal() {
  console.log('주방: 전채 준비 시작');
  yield '전채';
  console.log('주방: 메인 준비 시작'); // next()가 다시 호출돼야 실행된다
  yield '메인';
}
 
async function main() {
  const gen = courseMeal();
 
  console.log(await gen.next()); // 주방: 전채 준비 시작 → { value: '전채', done: false }
  console.log('손님: 전채를 먹는 중...');
  console.log(await gen.next()); // 주방: 메인 준비 시작 → { value: '메인', done: false }
}
 
main();
주방: 전채 준비 시작
{ value: '전채', done: false }
손님: 전채를 먹는 중...
주방: 메인 준비 시작
{ value: '메인', done: false }

두 번째 gen.next()가 호출되기 전까지는 '주방: 메인 준비 시작'이 출력되지 않습니다. 소비자가 처리하는 속도에 맞춰 생산 속도가 조절되는 구조라서, 소비자가 감당 못 할 만큼 값이 쏟아지는 문제가 애초에 생기지 않습니다.

for await...of와 비동기 이터레이션 프로토콜

for await...of는 복잡해 보이지만, 실제로는 단순한 규약 위에서 동작합니다.

  • 비동기 제너레이터 함수를 호출하면 반환되는 객체에는 Symbol.asyncIterator 메서드가 있습니다. 이 규약을 따르는 객체를 비동기 이터러블이라고 부릅니다.
  • 그 객체의 next()는 { value, done }을 Promise로 감싸서 반환합니다.
  • for await...of는 반복마다 next()를 호출하고, 반환된 Promise를 await한 뒤, done이 true면 루프를 끝냅니다.

for await...of가 내부적으로 하는 일을 코드로 옮기면 대략 이렇습니다.

async function main() {
  // for await...of가 하는 일을 수동으로 재현한 코드
  const iterator = streamTokens()[Symbol.asyncIterator]();
 
  while (true) {
    const { value, done } = await iterator.next(); // 다음 값이 준비될 때까지 대기
 
    if (done) break; // 제너레이터가 끝나면 루프 종료
 
    console.log(value);
  }
}

이전 SSE 포스트에서 reader.read()를 while (true) 루프로 돌리며 done을 검사했던 코드, 기억하시나요? 구조가 완전히 똑같습니다. for await...of는 그 패턴을 언어 차원에서 문법으로 만들어 준 것뿐입니다.

TypeScript 타입: AsyncGenerator<T, TReturn, TNext>

TypeScript에서 비동기 제너레이터의 타입은 AsyncGenerator<T, TReturn, TNext>로 표기합니다. 타입 매개변수가 세 개나 되지만, 각각의 역할은 명확합니다.

async function* countdown(from: number): AsyncGenerator<number, string, void> {
  for (let i = from; i > 0; i--) {
    await sleep(1000);
    yield i; // T = number: yield로 내놓는 값의 타입
  }
  return '발사!'; // TReturn = string: return 값의 타입
}
  • T: yield로 내놓는 값의 타입. for await...of에서 받는 값이 이 타입으로 추론됩니다.
  • TReturn: return 값의 타입. done: true일 때의 value 타입입니다.
  • TNext: 소비자가 next(인자)로 제너레이터 안에 넣어주는 값의 타입입니다.

세 번째 매개변수 TNext가 낯설 수 있는데, 사실 yield는 값을 내놓기만 하는 게 아니라 받을 수도 있습니다. next()에 인자를 넘기면 그 값이 멈춰 있던 yield 표현식의 결과가 됩니다.

// 값을 받을 때마다 누적 합계를 돌려주는 제너레이터
async function* accumulator(): AsyncGenerator<number, void, number> {
  let total = 0;
 
  while (true) {
    const added = yield total; // next(인자)로 넘긴 값이 yield 표현식의 결과가 된다
    total += added;
  }
}
 
async function main() {
  const acc = accumulator();
 
  console.log(await acc.next()); // { value: 0, done: false } ← 첫 호출의 인자는 무시된다
  console.log(await acc.next(10)); // { value: 10, done: false }
  console.log(await acc.next(5)); // { value: 15, done: false }
}

대부분의 경우에는 값을 내놓기만 하므로 AsyncGenerator<string, void, void>처럼 뒤의 두 매개변수를 void로 두면 됩니다. 참고로 리턴 타입 표기를 생략해도 TypeScript가 본문을 보고 알아서 추론해 줍니다.


SSE 스트림을 비동기 제너레이터로 소비하기

기초를 다졌으니 실전으로 가겠습니다. 첫 번째 활용처는 서두에서 말한 SSE 스트림 소비입니다.

이전 SSE 포스트의 클라이언트 코드는 클릭 핸들러 안에서 reader.read() 루프, 청크 디코딩, data: 라인 파싱, 화면 갱신을 전부 처리했습니다. 이 중에서 "SSE 응답을 읽고 파싱해서 토큰을 만들어 내는" 부분만 비동기 제너레이터로 분리해 보겠습니다.

// libs/read-sse.ts
// SSE 응답을 파싱해서 토큰을 하나씩 yield하는 비동기 제너레이터
export async function* readSSE(url: string): AsyncGenerator<string, void, void> {
  const response = await fetch(url);
 
  // 타입 가드: body가 null이면 스트림을 읽을 수 없다
  if (!response.body) {
    throw new Error('응답 바디가 없습니다.');
  }
 
  const reader = response.body.getReader(); // 스트림을 청크 단위로 읽는 reader
  const decoder = new TextDecoder();
  let buffer = ''; // 청크 경계에서 잘린 메시지를 보관하는 버퍼
 
  try {
    while (true) {
      const { done, value } = await reader.read(); // 다음 청크가 도착할 때까지 대기
 
      if (done) break;
 
      // stream: true 옵션으로 청크 경계에서 잘린 멀티바이트 문자를 안전하게 처리
      buffer += decoder.decode(value, { stream: true });
 
      // 이 서버는 메시지를 빈 줄(\n\n)로 구분해 보낸다
      const messages = buffer.split('\n\n');
      // 마지막 조각은 아직 완성되지 않았을 수 있으므로 버퍼에 남겨 둔다
      buffer = messages.pop() ?? '';
 
      for (const message of messages) {
        // data 라인이 아니거나 종료 신호면 건너뛴다
        if (!message.startsWith('data: ') || message === 'data: [DONE]') continue;
 
        const { text } = JSON.parse(message.slice(6)) as { text: string };
        yield text; // 파싱이 끝난 토큰을 하나씩 내놓는다
      }
    }
  } finally {
    // 소비자가 루프를 중간에 벗어나도 여기가 실행되어 연결이 정리된다
    await reader.cancel();
  }
}

한 청크에 여러 메시지가 담겨 오거나, 메시지 하나가 두 청크에 걸쳐 잘려 오는 경우까지 버퍼로 처리하는 로직입니다. 분리하는 김에 원래 코드에 없던 처리도 보강했습니다. 이전 포스트의 코드는 메시지 하나가 두 청크에 걸쳐 잘려 오면 JSON.parse가 실패할 수 있었는데, buffer와 stream: true 옵션으로 그 경우까지 처리했습니다. try...finally도 그중 하나입니다. 소비자가 for await...of를 break나 예외로 중간에 벗어나면 제너레이터의 return()이 호출되어 멈춰 있던 yield 위치에서 함수가 종료됩니다. 이때도 finally는 실행되므로 SSE 연결이 열린 채 방치되지 않습니다. "생성 중단" 버튼처럼 스트림을 도중에 끊는 기능을 생각하면 필요한 처리입니다.

참고로 이 파서는 이전 포스트 서버의 형식(data: + JSON + \n\n)에 맞춘 단순화 버전입니다. SSE 스펙상 줄 끝은 \r\n도 허용되고 data: 뒤의 공백도 선택 사항이라, 다른 서버에 붙일 때는 split(/\r?\n\r?\n/) 같은 보강이 필요합니다.

버퍼 처리 같은 파싱 로직이 전부 제너레이터 안에 있으므로, 소비하는 쪽 코드는 이렇게 줄어듭니다.

// app/page.tsx
const onClick = async () => {
  // 토큰이 도착할 때마다 루프가 한 번씩 돌면서 화면을 갱신한다
  for await (const token of readSSE('/api')) {
    setMessage((prev) => prev + token);
  }
};

이전 포스트의 클릭 핸들러와 비교해 보면 차이가 분명합니다. getReader, TextDecoder, split('\n\n') 같은 저수준 코드가 전부 사라지고, "토큰을 하나씩 받아서 화면에 붙인다"라는 의도만 남았습니다. 파싱 로직은 이제 어느 화면에서든 for await...of 한 줄로 재사용할 수 있고, 파싱만 떼어내서 테스트하기도 쉬워졌습니다.

앞서 본 풀 방식의 성질은 여기서도 유지됩니다. 물론 생산자가 원격 서버라서 서버가 보내는 원시 데이터 자체는 읽기 전까지 스트림 내부 버퍼에 쌓입니다. 하지만 파싱을 소비 속도에 맞춰 미루기 때문에 파싱된 토큰이 처리도 못 한 채 쌓이는 상황은 구조적으로 발생하지 않습니다.

콜백을 넘기는 방식(readSSE(url, onToken))으로도 분리 자체는 가능합니다. 다만 그 경우 흐름의 주도권이 생산자 쪽으로 넘어갑니다. 제너레이터를 쓰면 소비자가 for await...of로 흐름을 제어하므로, 조건에 따라 break로 중단하거나 토큰 처리 사이에 다른 로직을 자유롭게 끼워 넣을 수 있습니다.


무거운 계산, 렌더링을 막지 않고 처리하기

두 번째 활용처는 조금 의외의 영역입니다. 시간이 오래 걸리는 무거운 계산을 화면 멈춤 없이 처리하는 데에도 비동기 제너레이터를 쓸 수 있습니다.

긴 루프가 화면을 멈추는 이유

먼저 문제 상황부터 재현하겠습니다. 10만 개의 데이터에 무거운 계산을 돌리는 코드입니다.

// 예시용 무거운 계산 (실제로는 이미지 필터링, 대량 데이터 변환 등이 여기 해당)
function heavyCalculation(item: number): number {
  let result = item;
  for (let i = 0; i < 10_000; i++) {
    result = Math.sqrt(result * result + i); // CPU를 사용하는 반복 계산
  }
  return result;
}
 
const items = Array.from({ length: 100_000 }, (_, i) => i); // 10만 개의 데이터
 
// 이 한 줄이 실행되는 동안 화면 전체가 얼어붙는다
const results = items.map(heavyCalculation);

이 map이 도는 몇 초 동안 버튼 클릭도, CSS 애니메이션도, 스크롤도 전부 멈춥니다. 이유는 두 가지 규칙으로 설명됩니다.

  • 하나는 이벤트 루프 포스트에서 다룬 대로, 동기 작업이 끝나기 전에는 콜스택에 아무것도 끼어들 수 없다는 것입니다. 위 map은 하나의 거대한 동기 작업으로, 끝날 때까지 콜스택을 점유합니다.
  • 다른 하나는 이 글에서 새로 다루는 규칙입니다. 브라우저의 렌더링(스타일 계산, 레이아웃, 페인트)은 태스크와 태스크 사이에서만 일어날 수 있습니다. 태스크가 끝나지 않으니 렌더링이 실행되지 못합니다.

해결 방법은 작업을 몇백 개씩 끊어 처리하고, 그 사이마다 잠깐 멈춰 렌더링과 이벤트 처리에 기회를 주는 것입니다. 작업을 쪼개서 중간중간 멈추는 것은 제너레이터가 하는 일과 같습니다.

await만으로는 양보되지 않는 이유

async 함수로 만들고 중간에 await를 넣으면 해결된다고 생각하기 쉽습니다. 하지만 await Promise.resolve()처럼 마이크로태스크로 이어지는 await만 넣으면 화면은 계속 멈춥니다.

// 주의: 이렇게 하면 화면은 여전히 얼어붙는다
async function processAll(items: number[]): Promise<number[]> {
  const results: number[] = [];
 
  for (const [index, item] of items.entries()) {
    results.push(heavyCalculation(item));
 
    if (index % 1_000 === 0) {
      await Promise.resolve(); // 마이크로태스크 경계일 뿐, 렌더링은 끼어들 수 없다
    }
  }
 
  return results;
}

await Promise.resolve()는 함수를 잠시 멈추긴 하지만, 이어지는 코드를 마이크로태스크 큐에 넣습니다. 이벤트 루프 포스트에서 본 규칙을 떠올려 보면, 이벤트 루프는 마이크로태스크 큐를 빌 때까지 전부 처리한 후에야 다음으로 넘어갑니다. 렌더링이 끼어들 수 있는 태스크 경계에 도달하기 전에 마이크로태스크로 등록된 다음 청크가 곧바로 실행되어 버리므로, 화면은 여전히 멈춰 있습니다.

그렇다면 이전 SSE 포스트의 await reader.read()는 왜 화면을 멈추지 않았는지 궁금할 수 있습니다. 그 경우는 실제로 기다릴 일이 있는, 즉 아직 pending인 Promise를 await했기 때문입니다. 기다리는 동안 현재 태스크가 끝나므로 그 사이에 렌더링이 돌 수 있습니다. 문제가 되는 건 위 코드처럼 이미 이행된 Promise를 await할 때입니다. 이때는 마이크로태스크로 곧장 이어져 태스크 경계가 생기지 않습니다.

같은 이유로, for await...of로 소비한다는 사실만으로는 렌더링에 양보가 되지 않습니다. 각 반복이 만드는 것도 마이크로태스크 경계일 뿐이기 때문입니다. 양보는 반드시 태스크(매크로태스크) 경계로 해야 합니다. 가장 간단한 도구가 setTimeout(0)입니다.

// 태스크 경계를 만들어 이벤트 루프에 양보하는 함수
function yieldToEventLoop(): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, 0); // 매크로태스크 경계 → 이 사이에 렌더링이 끼어들 수 있다
  });
}

setTimeout의 콜백은 매크로태스크 큐로 들어가므로, 콜백이 실행되기 전에 현재 태스크가 끝납니다. 브라우저는 태스크와 태스크 사이의 이 틈에 밀려 있던 렌더링과 사용자 입력을 처리할 수 있습니다.

setTimeout은 중첩 깊이가 5를 넘으면 최소 지연이 4ms로 강제되는 클램핑 규칙이 있어서, 양보를 자주 하면 이 4ms가 누적될 수 있습니다. 더 정밀한 제어가 필요하다면 클램핑이 없는 MessageChannel이나, 이 용도를 위해 설계된 scheduler.yield()(2026년 기준 Chrome/Edge 129+, Firefox 142+ 지원, Safari 미지원)를 사용할 수 있습니다. 이 글에서는 가장 보편적인 setTimeout 방식으로 진행합니다.

청크 단위로 처리하는 비동기 제너레이터

지금까지 필요한 요소를 모두 살펴봤습니다. 작업을 청크로 쪼개는 제너레이터와, 청크 사이마다 태스크 경계로 양보하는 yieldToEventLoop를 조합하면 됩니다.

// 작업을 청크 단위로 쪼개 처리하고, 청크 사이마다 이벤트 루프에 양보하는 비동기 제너레이터
async function* processInChunks<T, R>(
  items: T[],
  processItem: (item: T) => R,
  chunkSize = 200,
): AsyncGenerator<R[], void, void> {
  for (let start = 0; start < items.length; start += chunkSize) {
    const chunk = items.slice(start, start + chunkSize); // 이번에 처리할 청크
    const results = chunk.map(processItem); // 청크 하나는 동기적으로 처리한다
 
    yield results; // 청크 처리 결과를 내놓는다
 
    await yieldToEventLoop(); // 다음 청크로 넘어가기 전, 렌더링에 기회를 준다
  }
}

소비하는 쪽 코드입니다. 같은 스레드에서 돌기 때문에 진행률 표시 같은 DOM 갱신을 루프 안에서 자유롭게 할 수 있습니다.

// 진행률을 화면에 표시하는 함수
function updateProgress(percent: number): void {
  const bar = document.querySelector<HTMLElement>('#progress');
 
  if (bar) {
    bar.style.width = `${percent}%`; // DOM 접근 — 웹 워커에서는 불가능한 일
  }
}
 
async function runHeavyJob() {
  const results: number[] = [];
 
  for await (const chunkResults of processInChunks(items, heavyCalculation)) {
    results.push(...chunkResults); // 청크 결과를 모은다
    updateProgress((results.length / items.length) * 100); // 중간중간 진행률 갱신
  }
 
  console.log('완료:', results.length);
}

이렇게 바꾸면 계산이 도는 동안에도 진행률 바가 부드럽게 차오르고, 버튼 클릭도 정상적으로 동작합니다. 청크 하나(200건)를 처리하는 데 걸리는 시간만큼만 콜스택을 점유하고 곧바로 양보하기 때문에, 렌더링이 몇 초씩 밀리는 일이 사라집니다.

chunkSize는 조절 포인트입니다. 60fps 기준으로 한 프레임의 예산은 약 16ms이므로, 청크 하나의 처리 시간이 그 안팎이 되도록 잡으면 체감이 매끄럽습니다. heavyCalculation을 직접 측정해 보니 1,000건짜리 청크 하나가 약 50ms로 예산을 넘었습니다(측정값은 기기마다 다릅니다). 그래서 기본값을 200으로 잡았습니다. 너무 작게 잡으면 양보 오버헤드(태스크 전환)가 커지고, 너무 크게 잡으면 다시 화면이 끊기니, 실제 데이터로 측정해 보고 정하는 걸 권합니다.


공정한 비교: 웹 워커 vs 비동기 제너레이터 양보

무거운 계산 이야기가 나오면 빠질 수 없는 게 웹 워커(Web Worker)입니다. 사실 "메인 스레드를 지킨다"는 목적만 보면 웹 워커가 더 확실한 방법입니다. 두 방식을 과장 없이 비교해 보겠습니다.

관점웹 워커비동기 제너레이터 + 양보
실행 위치별도 스레드 (병렬 실행)메인 스레드 (협조적 양보)
메인 스레드 CPU 부담거의 없음총 계산량은 그대로, 잘게 나눠 실행할 뿐
DOM API 접근불가능가능
데이터 전달postMessage 구조화 복제 비용 (대용량이면 부담)없음 (같은 메모리 공간)
도입 비용별도 파일 + 메시지 프로토콜 설계함수 하나

먼저 웹 워커의 확실한 강점입니다. 계산이 아예 다른 스레드에서 돌기 때문에 메인 스레드는 완전히 자유롭습니다. 제너레이터 방식은 어디까지나 메인 스레드가 일을 "잘게 나눠서" 하는 것이라 CPU 사용 총량이 줄지 않습니다. 또 항목 하나의 계산 자체가 수십 ms를 넘는다면 청크를 아무리 잘게 쪼개도 그 한 건이 도는 동안에는 프레임이 밀립니다. 이런 작업이라면 웹 워커가 정답입니다.

대신 웹 워커에는 뚜렷한 제약이 있습니다.

  • DOM API에 접근할 수 없습니다. 계산 중간중간 화면을 갱신해야 한다면, 매번 postMessage로 메인 스레드에 결과를 넘겨서 처리해야 합니다.
  • postMessage로 주고받는 데이터는 구조화 복제(structured clone)를 거칩니다. 크기가 큰 객체라면 복제 비용 자체가 부담이 될 수 있습니다. (ArrayBuffer 같은 일부 타입은 Transferable로 복사 없이 소유권만 넘길 수 있긴 합니다.)
  • 워커 파일 분리, 메시지 타입 설계, 에러 전파 처리 등 도입 비용이 제법 듭니다.

정리하면 기준은 다음과 같습니다.

  • 수 초 이상 걸리는 순수 계산이고, 중간 결과로 DOM을 만질 필요가 없다 → 웹 워커. 메인 스레드를 확실하게 지키는 방법입니다.
  • 작업이 자연스럽게 쪼개지고, 중간중간 DOM 갱신(진행률, 부분 렌더링)이 필요하거나, 워커를 도입할 정도의 규모는 아니다 → 비동기 제너레이터 + 양보. 함수 하나로 끝나는 가벼운 해법입니다.

둘은 경쟁 관계라기보다 쓰임새가 다른 도구에 가깝습니다. 상황에 맞는 쪽을 골라 쓰면 됩니다.


정리

  • 제너레이터 함수(function*)는 yield로 값을 여러 번 내놓을 수 있는 함수입니다. 호출 시 본문이 실행되는 대신 제너레이터 객체가 반환되고, next()가 호출될 때마다 다음 yield까지 실행됩니다.
  • 비동기 제너레이터(async function*)는 여기에 await를 더한 것으로, "기다려야 얻을 수 있는 값"을 하나씩 내놓을 수 있습니다. 소비는 for await...of로 합니다.
  • 제너레이터는 풀 방식입니다. yield에서 멈춘 뒤 소비자가 next()를 호출해야 이어서 실행되므로, 소비 속도에 생산 속도가 자연스럽게 맞춰집니다.
  • TypeScript 타입은 AsyncGenerator<T, TReturn, TNext>로, 각각 yield 값, return 값, next()로 넣어주는 값의 타입입니다.
  • SSE 소비에 적용하면 파싱 로직을 제너레이터 안에 두고, 소비자는 for await...of로 토큰만 받아 쓰는 구조가 됩니다.
  • 무거운 계산은 청크로 쪼개고 사이사이에 양보하면 렌더링을 막지 않고 처리할 수 있습니다. 단, 양보는 await Promise.resolve() 같은 마이크로태스크가 아니라 setTimeout(0) 같은 태스크 경계로 해야 합니다. 렌더링은 태스크 사이에서만 일어나기 때문입니다.
  • 웹 워커는 별도 스레드에서 실행되어 메인 스레드를 확실히 지키지만 DOM 접근이 안 되고 도입 비용이 있습니다. 제너레이터 양보는 가볍고 DOM을 만질 수 있지만 CPU 총량은 그대로입니다. 작업의 성격에 맞춰 고르면 됩니다.

"함수는 한 번만 리턴한다"는 당연한 상식에 별표 하나(*)로 예외를 만드는 문법이라, 처음엔 낯설지만 한번 손에 익으면 스트리밍류 코드가 눈에 띄게 단정해집니다. 긴 글 읽어주셔서 감사합니다!