icon
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이 비동기 프로그래밍의 주류가 되었지만, 제너레이터는 여전히 무한 시퀀스, 커스텀 이터레이터, 그리고 특정 비동기 라이브러리(예: Redux-Saga)의 기반으로 중요한 역할을 합니다. 타입스크립트는 이러한 제너레이터에 대한 강력한 타입 지원을 제공하여 더욱 안전하고 예측 가능한 코드를 작성할 수 있게 합니다.