HomeBlog
Back to Blog

Contents

    Copyright 2025. Kwangmin. All rights reserved.
    2025년 6월 26일조회수:

    Generator로 상태흐름 제어하기

    프론트엔드나 백엔드에서 순차적인 작업이 필요할 때, 우리는 보통 async/await이나 상태 변수로 흐름을 제어합니다. 하지만, 이런 방식은 때로는 중복되고 복잡하며 상태가 외부에 노출되기 쉽습니다

    제너레이터 함수란?

    제너레이터 함수는 function* 키워드로 정의하며, yield 키워드로 값을 순차적으로 반환합니다. 일반 함수와 달리 한 번에 끝나지 않고, 호출될 때마다 멈췄다가 다시 실행됩니다.

    function* countUp() {
      let i = 1;
      while (true) {
        yield i++;
      }
    }
     
    const counter = countUp();
    console.log(counter.next().value); // 1
    console.log(counter.next().value); // 2

    제너레이터 함수의 특징

    제너레이터는 단순한 반복 처리를 넘어서, 로직의 흐름 제어와 상태 관리를 유연하게 처리할 수 있도록 도와줍니다. 몇 가지 핵심적인 장점을 정리해보면 다음과 같습니다

    1. 상태 폐쇄성이 뛰어납니다. 제너레이터 함수 내에서 정의된 변수들은 외부에서 직접 접근하거나 변경할 수 없습니다. 이는 스코프 외부에서의 접근을 방지하고, 예측 가능한 동작을 보장합니다. 마치 캡슐화된 객체처럼 상태를 안전하게 유지할 수 있습니다.

    2. 순차적 실행 흐름을 자연스럽게 표현할 수 있습니다. yield 키워드를 통해 원하는 시점에 제어권을 넘기고, 다시 돌아와 이어서 실행할 수 있어, 복잡한 로직도 단순하게 표현할 수 있습니다.

    3. 일방향성 흐름을 보장합니다. next()로 호출되는 시점마다 내부 상태가 변화하므로, 실행 순서를 보장할 수 있습니다. 이전 상태로 되돌아가지 않으며, 제어 흐름을 안정적으로 유지할 수 있습니다.

    4. 사이드이펙트가 적은 퓨어한 코드 작성이 가능합니다. 각 단계를 독립적으로 구성할 수 있고, 외부 상태를 사용하지 않도록 설계하면 테스트 및 유지보수도 쉬워집니다.

    아마 폐쇄성 관점에서 제네레이터를 봤을때 클로저(closure) 와 제너레이터의 상태 관리가 비슷하다고 느꼈는데요, 둘 다 함수 내부 상태를 보존하는 특성이 있지만, 제너레이터는 클로저보다 더 명시적이며 제어 흐름을 직접 다룰 수 있다는 점에서 차별화됩니다. 클로저는 상태 유지에 초점이 맞춰진 반면, 제너레이터는 "상태 유지 + 흐름 제어"라는 두 가지 측면을 함께 다룰 수 있는 도구라고 볼 수 있는것 같습니다.


    간단한 예제와 활용도

    function* countUp() {
      let i = 1;
      while (true) {
        yield i++;
      }
    }
     
    const counter = countUp();
    console.log(counter.next().value); // 1
    console.log(counter.next().value); // 2
      function* ex() {
        console.log('A');
        yield 1;         // 여기서 멈춤
        console.log('B');
        yield 2;         // 또 멈춤
        console.log('C');
        return 3;        // 끝남
      }
     
      const gen = ex();
     
      console.log(gen.next()); // A 출력 → { value: 1, done: false }
      console.log(gen.next()); // B 출력 → { value: 2, done: false }
      console.log(gen.next()); // C 출력 → { value: 3, done: true }

    function 앞에 별표(*)가 꼭 붙어야 제너레이터 함수로 인식합니다. yield는 값을 하나씩 내보내고 제너레이터 함수의 실행을 일시정지합니다. next()를 호출할 때마다 내부 로직이 실행되며 yield된 값이 반환되고, 내부의 i 상태는 외부에서 수정할 수 없습니다.

    일반 함수와 제너레이터 함수를 실행방식, 반환 값, 상태 유지 관점에서 비교해보자면 다음과 같습니다. 일반 함수는 호출을 한번 시작하면 끝까지 한번에 실행되지만, 제너레이터 함수는 yield 지점에서 멈추고 호출을 여러번 할 수 있습니다. 반환값의 같은 경우에도 일반함수는 하나의 값을 반환하지만, 제너레이터 함수는 여러 개의 값 하나씩 나눠서 반환할 수 있습니다. 마지막으로 상태유지 관점에서는 일반 함수는 호출 끝나면 끝이지만, 제너레이터 함수는 상태를 기억합니다.

    추가로 제너레이터 함수로 만든 객체는 자동으로 Symbol.iterator 메서드를 구현한 이터러블이 됩니다. 즉, 배열처럼 for...of, 전개 연산자(...) 등 반복 가능한 모든 문법에 사용할 수 있습니다.

    제네레이터의 심화 예제

    function* subGenerator() {
      yield 'a';
      yield 'b';
    }
     
    function* mainGenerator() {
      yield 1;
      yield* subGenerator(); // 여기서 위임
      yield 2;
    }
     
    for (const val of mainGenerator()) {
      console.log(val); // 1, 'a', 'b', 2
    }

    yield*는 **다른 이터러블(제너레이터 포함)**을 내부에서 순회하면서 값을 자동으로 yield 해줍니다.

    function* dialog() {
      const name = yield "이름이 뭐니?";
      yield `안녕, ${name}!`;
    }
     
    const g = dialog();
     
    console.log(g.next());         // { value: '이름이 뭐니?', done: false }
    console.log(g.next('광민'));   // { value: '안녕, 광민!', done: false }
    function* ex() {
      const a = yield 'Ask A'; // → next(1)
      const b = yield 'Ask B'; // → next(2)
      return a + b;
    }
     
    const g = ex();
    console.log(g.next());      // 'Ask A'
    console.log(g.next(1));   // 'Ask B'
    console.log(g.next(2));   // return 3

    바깥에서 값을 제너레이터 "안으로 주입"하는 방식 으로 .next()에 값을 넣으면 그 값은 마지막 yield 표현식의 결과로 들어갑니다. 두 번째 next('민수')는 const name = yield 구문에서 yield의 반환값이 됩니다.

    return을 통한 종료

    function* calc() {
      yield 1;
      return 999;
      yield 2; // 실행 안됨
    }
     
    const g = calc();
     
    console.log(g.next()); // { value: 1, done: false }
    console.log(g.next()); // { value: 999, done: true }
    console.log(g.next()); // { value: undefined, done: true }

    제너레이터 안에서 return을 호출하면 즉시 종료되며, 해당 값은 .next() 결과의 value로 나오고 done: true 상태가 됩니다.


    제네레이터의 폐쇄적 상태 활용

    다음은 제너레이터의 폐쇄적 상태 관리와 일방향성을 보여주는 예시입니다. 이 예시는 제너레이터 내부의 counter 변수는 외부에서 접근하거나 변경할 수 없으며, 오직 제너레이터를 통해서만 순차적으로 값이 증가하는 모습을 보여줍니다.

    function* pageCounter() {
      // counter 변수는 제너레이터 내부에서만 관리됩니다.
      let counter = 0;
      while (true) {
        counter++;
        yield counter;
      }
    }
     
    // 제너레이터 인스턴스를 생성합니다.
    const counterGen = pageCounter();
     
    // 외부에서는 counterGen.next()를 통해서만 값을 얻을 수 있습니다.
    console.log(counterGen.next().value); // 1
    console.log(counterGen.next().value); // 2
    console.log(counterGen.next().value); // 3
     
    // ---------------------------------------------------
    // 다른 제너레이터 인스턴스를 생성하여 상태의 독립성을 확인할 수 있습니다.
    const anotherCounter = pageCounter();
    console.log(anotherCounter.next().value); // 1 (다른 독립적인 상태)
    console.log(anotherCounter.next().value); // 2
     
    // ---------------------------------------------------
    // 외부에서 제너레이터 내부의 counter 값을 직접 수정할 방법은 없습니다.
    // 즉, counter는 제너레이터 스코프 내에 '폐쇄'되어 있어 외부 영향 없이 안전하게 관리됩니다.

    counter 변수는 제너레이터 함수 내에만 존재하므로 외부에서 직접 변경하거나 접근할 수 없고 next() 메서드를 호출할 때마다 제너레이터 내부에서 순차적으로 증가한 값을 안전하게 반환하므로 일방향 흐름을 보장하게 됩니다. 또한 각 제너레이터 인스턴스는 자신만의 상태를 가지므로, 여러 인스턴스가 동시에 사용되어도 서로 간섭하지 않습니다. 이러한 방식으로 제너레이터를 사용하면, 값의 순서나 상태를 외부 영향을 받지 않고 안전하게 관리할 수 있으며, 순차적이고 일방향적인 데이터 흐름을 보장할 수 있습니다.

    비동기 작업을 제네레이터로 순서를 보장하면서 제어하기

    1. 무한 스크롤 페이지네이션 예시

    async function* fetchPaginatedData() {
      let page = 1;
      while (true) {
        const res = await fetch(`/api/data?page=${page}`);
        const data = await res.json();
        if (data.length === 0) break;
     
        for (const item of data) {
          yield item;
        }
     
        page++;
      }
    }
     
    (async () => {
      for await (const item of fetchPaginatedData()) {
        console.log(item);
      }
    })();

    API를 한 번에 다 가져오지 않고, 한 페이지씩 비동기 순회하면서 가져오는 방식입니다.

    2. 비동기 작업을 제네레이터로 순서를 보장하면서 제어하기

    백엔드에 API 요청을 보낼때 순서를 보장하면서 제어하고 싶다면 제너레이터를 사용할 수 있습니다. 간단한 예시로 회원가입 플로우를 제너레이터로 구현하기전에, 기존에 async/await 방식으로 구현한 코드를 보자면 다음과 같습니다.

    async function handleSignup(email, password) {
      try {
        const isExist = await checkAlreadyExist(email);
        if (isExist) throw new Error("이미 존재함");
     
        const registered = await registerUser(email, password);
        if (!registered.success) throw new Error("등록 실패");
     
        const loggedIn = await loginUser(email, password);
        return loggedIn;
      } catch (error) {
        console.error("회원가입 에러:", error.message);
        throw error;
      }
    }

    사실 이방법을 저는 가장 주로 사용해왔고 가장 먼저 떠올랐을 방법입니다. 하지만 이 방법은 비동기 작업이 많아지면 가독성이 떨어지고 코드가 복잡해질 수 있습니다. async/await와 try/catch를 사용하는 기존 방식은 간결하다는 장점이 있지만 모든 로직이 하나의 try 블록에 몰려 있어, 각 단계별로 어떤 에러가 발생했는지 명확하게 구분하기 어렵고, 특정 단계에서 실패했을 경우 이후 단계로의 전이를 막거나, 상태를 되돌리기가 번거로운것 같습니다. 또한 단계 간 흐름이 명확하게 분리되지 않아 재사용이 어려워 보입니다.

    이런 문제를 해결하기 위해 제너레이터를 사용할 수 있습니다.

    function* signupFlow(email, password) {
      yield { name: 'checkExist', task: checkAlreadyExist(email) };
      yield { name: 'register', task: registerUser(email, password) };
      yield { name: 'login', task: loginUser(email, password) };
    }
     
     
    async function runSignup(email, password) {
      const flow = signupFlow(email, password);
      let step = flow.next();
     
      while (!step.done) {
        const { name, task } = step.value;
        try {
          await task;
        } catch (err) {
          console.error(`🚨 [${name}] 에러:`, err.message);
          break;
        }
        step = flow.next();
      }
    }

    제너레이터를 활용하면 각 단계를 분리하여 의도된 흐름 제어와 명확한 에러 처리가 가능합니다. 각 단계별 yield로 구분되어 로직이 명확하게 드러나고 실패한 단계의 이름이나 맥락을 쉽게 추적 가능하며 흐름을 재시도하거나 건너뛰는 등의 확장이 쉬워 보입니다.

    뭐

    제네레이터의 활용을 어떻게 하면 좋을까

    아무래도 async/await이 더 직관적이고 익숙한 상황속에 막상 실무에서 어떤 함수 및 로직을 제네레이터로 구현해야 할지 딱 떠오르지 않습니다. 제네레이터를 사용하면 비동기 작업을 순서대로 제어하고 에러 처리를 명확하게 할 수 있습니다. 아주 단순한 비동기 흐름과 상태 전이보다는 이벤트 기반으로 움직이는 경우는 제네레이터를 사용하면 코드의 가독성만 떨어지는 것 같습니다. 하지만 제네레이터를 사용하면 코드가 복잡해지고 가독성이 떨어질 수 있습니다.

    단계별 흐름이 명확하고, 흐름을 중간에 멈추거나 재개하거나 분기하고 싶을 때 제너레이터를 활용해보면 좋을것 같습니다. 하지만 단순한 UI 반응이나 단일 요청 흐름에는 오히려 과하죠.

    흐름 중에 "복잡한 순서 제어"나 "단계적 상태 전이"가 있는 게 있다면, 그것부터 제너레이터로 리팩토링해보면 좋을것 같습니다.

    Tags
    javascript
    generator
    선언적 절차적 프로그래밍 그리고 추상화
    useSyncExternalStore 훅의 트릭으로 클라이언트 컴포넌트 감지하기

    댓글 로딩 중...

    조회수:0