안동민 개발노트 아이콘

안동민 개발노트

11장 : 비동기 프로그래밍

제네레이터 함수

자바스크립트와 타입스크립트에서 제너레이터 함수(Generator Functions)는 일반 함수와 달리 함수의 실행을 일시 중지했다가 다시 시작할 수 있는 특별한 종류의 함수입니다. 이 기능은 특히 비동기 프로그래밍, 데이터 스트림 처리, 무한 시퀀스 생성 등에 매우 유용하게 사용될 수 있습니다.

제너레이터 함수는 function* 문법으로 선언하며, 함수 본문 내에서 yield 키워드를 사용하여 값을 반환합니다. yield 키워드는 함수의 실행을 잠시 멈추고 값을 호출자에게 전달하며, 다음 번에 함수가 재개될 때 yield 지점부터 다시 실행됩니다.


제너레이터 함수의 기본 동작

제너레이터 함수를 호출하면 즉시 실행되는 것이 아니라, 제너레이터 객체(Generator Object)를 반환합니다. 이 제너레이터 객체는 이터러블(Iterable)이자 이터레이터(Iterator)입니다. next() 메서드를 호출하여 제너레이터 함수의 실행을 제어하고, yield 키워드가 반환하는 값을 받을 수 있습니다.

기본 구조 예시
function* simpleGenerator(): Generator<number, string, boolean> {
  console.log("제너레이터 실행 시작");
  let inputFromNext: boolean = yield 1; // 첫 번째 yield

  console.log("첫 번째 yield 후 재개됨. 입력값:", inputFromNext);
  inputFromNext = yield 2; // 두 번째 yield

  console.log("두 번째 yield 후 재개됨. 입력값:", inputFromNext);
  return "모든 작업 완료!"; // 최종 반환 값
}

// 제너레이터 함수 호출 -> 제너레이터 객체 반환
const generator = simpleGenerator();

console.log("1. next() 호출 (첫 번째 yield 전)");
let result1 = generator.next(); // 제너레이터 실행 시작 -> yield 1 -> 일시 중지
console.log("result1:", result1); // { value: 1, done: false }

console.log("\n2. next(true) 호출 (첫 번째 yield 후 재개)");
let result2 = generator.next(true); // inputFromNext에 true가 할당됨 -> yield 2 -> 일시 중지
console.log("result2:", result2); // { value: 2, done: false }

console.log("\n3. next(false) 호출 (두 번째 yield 후 재개)");
let result3 = generator.next(false); // inputFromNext에 false가 할당됨 -> return "모든 작업 완료!"
console.log("result3:", result3); // { value: "모든 작업 완료!", done: true }

console.log("\n4. 다음 next() 호출 (이미 완료됨)");
let result4 = generator.next(); // 이미 done: true 이므로 value: undefined
console.log("result4:", result4); // { value: undefined, done: true }
Generator<YieldType, ReturnType, NextType> 타입 파라미터

타입스크립트에서 제너레이터 함수의 반환 타입인 Generator는 세 가지 제네릭 타입 파라미터를 가집니다.

  • YieldType (number): yield 키워드가 반환하는 값의 타입입니다.
  • ReturnType (string): 제너레이터 함수가 최종적으로 return하는 값의 타입입니다.
  • NextType (boolean): next() 메서드의 인자로 전달될 값의 타입입니다. 이는 yield 표현식의 결과 타입이 됩니다.

yield*

yield* 표현식은 다른 이터러블(다른 제너레이터, 배열, 문자열 등)로 제어권을 위임할 때 사용됩니다. 이는 여러 제너레이터를 조합하여 더 복잡한 시퀀스를 만들 때 유용합니다.

function* yieldStarExample(): Generator<number> {
  yield 1;
  yield* [2, 3, 4]; // 배열의 요소들을 하나씩 yield
  yield* anotherGenerator(); // 다른 제너레이터로 위임
  yield 7;
}

function* anotherGenerator(): Generator<number> {
  yield 5;
  yield 6;
}

const delegatedGenerator = yieldStarExample();

for (const value of delegatedGenerator) {
  console.log(value); // 1, 2, 3, 4, 5, 6, 7 순서로 출력
}

제너레이터 함수의 활용

제너레이터 함수는 다양한 상황에서 활용될 수 있습니다.

무한 시퀀스 생성

제너레이터는 done: false 상태를 계속 유지하며 무한한 시퀀스를 생성할 수 있습니다.

function* idGenerator(): Generator<number> {
  let id = 0;
  while (true) {
    yield id++;
  }
}

const ids = idGenerator();
console.log(ids.next().value); // 0
console.log(ids.next().value); // 1
console.log(ids.next().value); // 2
// 필요한 만큼 무한히 ID를 생성할 수 있습니다.

비동기 작업 관리

과거 async/await이 도입되기 전에는 제너레이터를 사용하여 비동기 코드를 동기 코드처럼 보이게 하는 패턴(co-routine 또는 thunk/saga)이 널리 사용되었습니다. 예를 들어, redux-saga 같은 라이브러리가 제너레이터를 기반으로 비동기 로직을 처리합니다.

// Promise를 반환하는 더미 비동기 함수
function asyncOperation(value: number): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`비동기 작업 완료: ${value}`);
      resolve(`결과_${value}`);
    }, 1000);
  });
}

// 제너레이터를 이용한 비동기 흐름 제어 (async/await과 유사한 역할)
function* asyncFlow(): Generator<Promise<string>, string, string> {
  console.log("Async 흐름 시작");
  const result1 = yield asyncOperation(1);
  console.log("첫 번째 비동기 작업 결과:", result1);

  const result2 = yield asyncOperation(2);
  console.log("두 번째 비동기 작업 결과:", result2);

  return "모든 비동기 작업 완료!";
}

// 제너레이터를 실행하는 러너 함수 (간단한 구현)
function runGenerator(generatorFunc: Generator<Promise<string>, string, string>): Promise<string> {
  const generator = generatorFunc;

  return new Promise((resolve, reject) => {
    function step(nextValue?: string) {
      let result;
      try {
        result = generator.next(nextValue);
      } catch (e) {
        return reject(e);
      }

      if (result.done) {
        return resolve(result.value);
      }

      // yield 된 Promise가 완료될 때까지 기다리고, 그 결과로 다음 next()를 호출
      Promise.resolve(result.value).then(
        (res) => {
          step(res);
        },
        (err) => {
          generator.throw(err); // Promise 실패 시 제너레이터에 오류 던지기
        }
      );
    }
    step(); // 첫 번째 실행
  });
}

// 비동기 흐름 실행
runGenerator(asyncFlow())
  .then((finalResult) => console.log("최종 결과:", finalResult))
  .catch((error) => console.error("오류 발생:", error));

이 예시는 async/await이 Promise와 제너레이터를 기반으로 어떻게 동작하는지 이해하는 데 도움이 됩니다.

async/await은 사실상 이러한 러너 함수(co-routine runner)를 언어 레벨에서 제공하는 문법적 설탕(Syntactic Sugar)입니다.

현대 타입스크립트에서는 대부분 async/await을 선호하지만, 특정 상황(예: 복잡한 상태 관리 라이브러리)에서는 제너레이터가 여전히 유용합니다.

이터러블 구현

제너레이터는 사용자 정의 이터러블 객체를 쉽게 구현하는 데 사용될 수 있습니다.

class MyCollection<T> {
  private items: T[];

  constructor(items: T[]) {
    this.items = items;
  }

  // Symbol.iterator 메서드를 제너레이터 함수로 구현
  *[Symbol.iterator](): Generator<T> {
    for (const item of this.items) {
      yield item;
    }
  }
}

const myNums = new MyCollection([10, 20, 30]);

for (const num of myNums) {
  console.log(num); // 10, 20, 30 순서로 출력
}

제너레이터 메서드

제너레이터 객체는 next() 외에 두 가지 추가 메서드를 가집니다.

  • return(value?: any): 제너레이터의 실행을 강제로 종료하고, 주어진 valuevalue 속성으로, done: true를 포함하는 객체를 반환합니다.
  • throw(exception: any): 제너레이터 내부로 오류를 던집니다. 이는 제너레이터 함수 내의 try...catch 블록에서 잡을 수 있습니다.
function* errorHandlingGenerator(): Generator<number> {
  try {
    yield 1;
    yield 2;
    console.log("이 부분은 실행되지 않음");
    yield 3;
  } catch (e: any) {
    console.error("제너레이터 내부에서 오류 포착:", e.message);
  } finally {
    console.log("제너레이터 정리 작업 (finally)");
  }
  return 4; // return 호출 시 이 값은 무시될 수 있음
}

const errGen = errorHandlingGenerator();
console.log(errGen.next()); // { value: 1, done: false }
console.log(errGen.throw(new Error("외부에서 오류 주입!"))); // { value: 4, done: true } (finally 후 return 값)
console.log(errGen.next()); // { value: undefined, done: true } (이미 종료됨)

async 제너레이터

타입스크립트 2.3부터는 async function* 문법을 사용하여 비동기 제너레이터를 만들 수 있습니다. 이는 async/await과 제너레이터의 조합으로, 비동기 데이터 스트림을 처리하는 데 매우 유용합니다. async 제너레이터는 AsyncIterable을 반환하며, for await...of 루프를 사용하여 비동기적으로 yield된 값을 순회할 수 있습니다.

async function fetchUserStream(): Promise<AsyncGenerator<string>> {
  async function* userStreamGenerator(): AsyncGenerator<string> {
    const users = ["Alice", "Bob", "Charlie"];
    for (let i = 0; i < users.length; i++) {
      console.log(`[Async Gen] Fetching user ${i + 1}...`);
      await new Promise(resolve => setTimeout(resolve, 500)); // 비동기 작업 시뮬레이션
      yield users[i];
    }
    console.log("[Async Gen] User stream finished.");
  }
  return userStreamGenerator();
}

async function processUserStream(): Promise<void> {
  console.log("Processing user stream...");
  const users = await fetchUserStream();

  for await (const user of users) { // for await...of 사용
    console.log(`Processed user: ${user}`);
    // 여기서 각 사용자에 대한 비동기 처리도 가능
    await new Promise(resolve => setTimeout(resolve, 200));
  }
  console.log("Finished processing all users.");
}

processUserStream();

async function*for await...of는 대량의 데이터를 비동기적으로 스트리밍 방식으로 처리하거나, 실시간 이벤트 스트림을 다룰 때 매우 강력한 패턴입니다.

제너레이터를 실제 코드에 적용할 때는 일반 함수보다 "호출자가 값을 당겨 가는 구조"가 문제를 더 단순하게 만드는지 확인해야 합니다. 아래 보드는 일반 제너레이터, 위임, 비동기 제너레이터를 선택할 때 확인할 기준을 한 화면에 모읍니다.

이때 중요한 판단 기준은 값을 언제 만들고, 누가 다음 값을 요청하며, 중간 실패를 어디서 정리할 것인가입니다. 일반 제너레이터, 비동기 제너레이터, async/await은 모두 흐름 제어 도구이지만, 소비 방식과 타입 경계가 다르므로 목적에 맞게 선택해야 합니다.


제너레이터 함수는 함수 실행 흐름 제어, 이터러블 프로토콜 구현, 비동기 작업 관리에 유연성을 제공하는 강력한 기능입니다.

async/await이 비동기 프로그래밍의 주류가 되었지만, 제너레이터는 무한 시퀀스, 커스텀 이터레이터, 특정 비동기 라이브러리(예: Redux-Saga)의 기반으로 여전히 중요한 역할을 합니다.

타입스크립트는 제너레이터에 대한 강력한 타입 지원을 제공해 더 안전하고 예측 가능한 코드를 작성하도록 돕습니다.

제너레이터의 제어면은 아래처럼 yield, next, return, throw를 분리하면 더 쉽게 읽을 수 있습니다.

아래 다이어그램은 Generator<Yield, Return, Next> 타입 매개변수를 호출 방향별로 분리해 보여줍니다.

아래 다이어그램은 제네레이터 함수에서 제너레이터 함수의 기본 동작와 yield가 책임 경계와 실행 흐름을 어떻게 바꾸는지 정리합니다.

아래 다이어그램은 제네레이터 함수를 실제 코드에 적용하기 전에 책임 경계, 타입 계약, 검증 신호를 확인합니다.

아래 다이어그램은 제네레이터 함수에서 책임 경계, 실행 흐름, 테스트 신호를 확인합니다.

아래 다이어그램은 제네레이터 함수를 마무리하며 입력 조건, 실행 경로, 실패 처리 질문을 정리합니다.

아래 다이어그램은 제너레이터 함수를 yield 값, next 입력, return 종료, throw 정리 기준으로 나누어 실제 적용 전에 확인할 질문을 좁힙니다.