JavaScript

JavaScript[generator] - 제너레이터와 비동기 제너레이터

Chrysans 2025. 4. 9. 14:30
728x90
반응형

 

자바스크립트 제너레이터 함수 이미지

 

JavaScript[generator] - 제너레이터와 비동기 제너레이터 

 

소개: 왜 제너레이터를 알아야 할까요?

 

제너레이터는 단순한 함수가 아닙니다. 실행을 일시 중지했다가 재개할 수 있는 특별한 함수로, 복잡한 비동기 흐름 제어부터 메모리 효율적인 데이터 처리까지 다양한 상황에서 강력한 솔루션을 제공합니다.

이 글에서는 제너레이터의 기본 개념부터 실제 프로덕션 환경에서의 활용 패턴까지 실용적인 예제와 함께 살펴보겠습니다.

 


목차

  1. 제너레이터 기본 개념
  2. 제너레이터 활용 패턴
  3. 비동기 제너레이터 이해하기
  4. 실무 활용 사례
  5. 성능 고려사항과 최적화
  6. 결론

제너레이터 기본 개념

제너레이터란 무엇인가?

제너레이터는 함수의 실행을 중간에 멈추고 재개할 수 있는 특별한 함수입니다. 일반 함수와는 달리, 제너레이터는 yield 키워드를 사용해 값을 하나씩 반환하고 다음 호출 시 중단된 지점부터 다시 실행을 계속합니다.

// 기본적인 제너레이터 함수 선언
function* simpleGenerator() {
  console.log('시작');
  yield 1;                // 첫 번째 실행 중지 지점
  console.log('중간');
  yield 2;                // 두 번째 실행 중지 지점
  console.log('끝');
  return 3;               // 제너레이터 종료 및 반환값
}

// 제너레이터 객체 생성
const generator = simpleGenerator();

// next() 메서드로 실행 제어
console.log(generator.next()); // { value: 1, done: false } 출력 및 '시작' 로그
console.log(generator.next()); // { value: 2, done: false } 출력 및 '중간' 로그
console.log(generator.next()); // { value: 3, done: true } 출력 및 '끝' 로그
console.log(generator.next()); // { value: undefined, done: true }

제너레이터의 핵심 특징

  1. 실행 중지 및 재개: yield 키워드를 만나면 실행이 중지되고, next() 호출 시 재개됩니다.
  2. 상태 유지: 지역 변수와 실행 컨텍스트가 다음 호출까지 보존됩니다.
  3. 이터러블 프로토콜 구현: 제너레이터는 이터러블 객체를 반환하므로 for...of 루프 등에서 사용 가능합니다.
  4. 양방향 통신: next(value) 메서드를 통해 제너레이터에 값을 전달할 수 있습니다.

제너레이터 활용 패턴

1. 지연 평가 (Lazy Evaluation)

큰 데이터셋을 다룰 때 메모리 효율성을 높이는 패턴입니다.

// 무한 시퀀스 구현 (TypeScript)
function* infiniteSequence(): Generator<number, void, undefined> {
  let i = 0;
  while (true) {
    yield i++;
    // 무한 루프지만 yield로 실행이 중단되므로 
    // 실제로는 next()가 호출될 때만 다음 값이 계산됨
  }
}

// 사용 예
const numbers = infiniteSequence();
console.log(numbers.next().value); // 0
console.log(numbers.next().value); // 1
console.log(numbers.next().value); // 2

// 처음 10개 숫자만 처리하기
let count = 0;
for (const num of infiniteSequence()) {
  if (count >= 10) break;
  console.log(num);
  count++;
}

2. 커스텀 이터러블 객체 구현

컬렉션 타입을 쉽게 순회 가능하게 만드는 패턴입니다.

// 커스텀 범위 이터레이터 (TypeScript)
function* range(start: number, end: number, step: number = 1): Generator<number, void, undefined> {
  for (let i = start; i <= end; i += step) {
    yield i;
  }
}

// 사용 예
for (const num of range(1, 10, 2)) {
  console.log(num); // 1, 3, 5, 7, 9
}

// 배열로 변환
const rangeArray = [...range(1, 5)]; // [1, 2, 3, 4, 5]

3. 제너레이터 위임 (Delegation)

복잡한 제너레이터 조합을 간결하게 표현할 수 있는 패턴입니다.

function* generateAlphabets() {
  yield 'a';
  yield 'b';
  yield 'c';
}

function* generateNumbers() {
  yield 1;
  yield 2;
  yield 3;
}

function* combined() {
  // yield* 표현식은 다른 제너레이터에 위임
  yield* generateAlphabets();
  yield* generateNumbers();
}

const iterator = combined();
for (const value of iterator) {
  console.log(value); // 'a', 'b', 'c', 1, 2, 3 순서대로 출력
}

비동기 제너레이터 이해하기

ES2018에서 도입된 비동기 제너레이터는 비동기 작업과 제너레이터의 강력한 결합입니다.

기본 문법과 작동 방식

// 비동기 제너레이터 함수 선언
async function* asyncGenerator() {
  yield Promise.resolve(1);
  await new Promise(resolve => setTimeout(resolve, 1000));
  yield 2;
  yield Promise.resolve(3);
}

// 비동기 제너레이터 사용하기
(async () => {
  const generator = asyncGenerator();
  
  // for await...of 루프로 간편하게 소비
  for await (const value of generator) {
    console.log(value); // 1, 2, 3이 순차적으로 출력됨 (1초 지연 포함)
  }
  
  // 또는 next() 메서드를 수동으로 호출
  // const result1 = await generator.next();
  // console.log(result1.value); // 1
})();

스트림 처리와의 연계

비동기 제너레이터는 Node.js의 스트림이나 웹의 Fetch API 등과 연계하여 메모리 효율적인 데이터 처리를 구현할 수 있습니다.

// 웹 API를 통한 데이터 스트리밍 예제
async function* fetchCommitsAsStream(repo) {
  let page = 1;
  let hasMore = true;
  
  while (hasMore) {
    const response = await fetch(`https://api.github.com/repos/${repo}/commits?page=${page}`);
    const commits = await response.json();
    
    if (commits.length === 0) {
      hasMore = false;
    } else {
      for (const commit of commits) {
        yield {
          sha: commit.sha,
          message: commit.commit.message,
          author: commit.commit.author.name
        };
      }
      page++;
    }
  }
}

// 사용 예
(async () => {
  const commits = fetchCommitsAsStream('facebook/react');
  
  // 최대 10개 커밋만 처리
  let count = 0;
  for await (const commit of commits) {
    console.log(`${commit.sha.substring(0, 7)} - ${commit.message}`);
    
    if (++count >= 10) break;
  }
})();

실무 활용 사례

무한스크롤 처리

// 서버에서 페이지네이션된 데이터를 가져오는 함수
async function fetchPageData(page: number): Promise<{ 
  items: Product[], 
  hasMore: boolean 
}> {
  const response = await fetch(`/api/products?page=${page}&limit=20`);
  return response.json();
}

// 비동기 제너레이터를 사용한 무한 스크롤 데이터 로더
async function* createInfiniteLoader<T>() {
  let page = 1;
  let hasMore = true;
  
  while (hasMore) {
    const { items, hasMore: moreAvailable } = await fetchPageData(page);
    
    // 받아온 항목들을 하나씩 yield
    for (const item of items) {
      yield item;
    }
    
    hasMore = moreAvailable;
    page++;
  }
}

// React 컴포넌트에서 사용 예시 (간략화)
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(false);
  const loaderRef = useRef<HTMLDivElement>(null);
  const productsGenerator = useRef<AsyncGenerator<Product> | null>(null);
  
  // 컴포넌트 마운트 시 제너레이터 초기화 및 Intersection Observer 설정
  useEffect(() => {
    productsGenerator.current = createInfiniteLoader<Product>();
    loadMoreItems();
    
    const observer = new IntersectionObserver(
      entries => {
        // 관찰 대상이 화면에 보이면 추가 아이템 로드
        if (entries[0].isIntersecting && !loading) {
          loadMoreItems();
        }
      },
      { rootMargin: "100px" }
    );
    
    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }
    
    return () => observer.disconnect();
  }, []);
  
  // 추가 아이템 로드 함수
  const loadMoreItems = async () => {
    if (!productsGenerator.current || loading) return;
    
    setLoading(true);
    const newProducts: Product[] = [];
    
    try {
      // 한 번에 20개 항목만 로드
      for (let i = 0; i < 20; i++) {
        const result = await productsGenerator.current.next();
        if (result.done) break;
        newProducts.push(result.value);
      }
      
      setProducts(prev => [...prev, ...newProducts]);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
      {/* 이 div가 화면에 보이면 추가 로드 발생 */}
      <div ref={loaderRef} />
      {loading && <div>Loading...</div>}
    </div>
  );
}
  1. 비동기 제너레이터(createInfiniteLoader):
    • 페이지 번호를 내부적으로 관리
    • 각 페이지의 데이터를 가져와서 한 항목씩 yield
    • 모든 데이터를 한 번에 메모리에 로드하지 않고 필요할 때만 가져옴
  2. Intersection Observer:
    • 특정 요소(loaderRef)가 화면에 보이는지 감지
    • 요소가 화면에 보이면 추가 데이터 로드 트리거
  3. 제너레이터 상태 관리:
    • 제너레이터는 마지막으로 yield한 지점을 기억하고 있음
    • 다음 next() 호출 시 중단된 지점부터 실행 재개
    • 이전에 로드한 페이지를 다시 요청하지 않음

이 패턴을 사용하면 사용자가 스크롤을 내릴 때 자연스럽게 추가 데이터가 로드되어 무한 스크롤을 구현할 수 있습니다. 제너레이터가 내부적으로 페이지 상태를 관리하므로 컴포넌트에서 별도로 페이지 번호를 관리할 필요가 없습니다.


성능 고려사항과 최적화

메모리 사용량

제너레이터의 가장 큰 장점 중 하나는 메모리 효율성입니다. 전체 데이터셋을 한 번에 메모리에 로드하는 대신, 필요한 항목만 생성하고 처리할 수 있습니다.

// 메모리 사용 비교

// 일반 배열 접근법 - 모든 데이터를 한 번에 메모리에 로드
function getMillionNumbers() {
  const numbers = [];
  for (let i = 0; i < 1000000; i++) {
    numbers.push(i);
  }
  return numbers;
}

// 제너레이터 접근법 - 필요할 때만 값을 생성
function* generateMillionNumbers() {
  for (let i = 0; i < 1000000; i++) {
    yield i;
  }
}

// 성능 테스트
console.time('Array Method');
const arr = getMillionNumbers();
let sum1 = 0;
for (let i = 0; i < 1000; i++) { // 처음 1000개만 사용
  sum1 += arr[i];
}
console.timeEnd('Array Method');

console.time('Generator Method');
const gen = generateMillionNumbers();
let sum2 = 0;
for (let i = 0; i < 1000; i++) {
  sum2 += gen.next().value;
}
console.timeEnd('Generator Method');

트러블슈팅: 일반적인 문제들

제너레이터 상태 재설정

제너레이터는 한 번 소비되면 재사용할 수 없습니다. 이유는 다음과 같습니다:

  1. 내부 상태 유지: 제너레이터는 실행 상태를 내부적으로 유지합니다. 값을 yield할 때마다 그 지점을 기억하고, 다음 next() 호출 시 정확히 그 지점부터 실행을 재개합니다.
  2. 일회성 이터레이터: 제너레이터 함수가 반환하는 제너레이터 객체는 일회성 이터레이터입니다. 모든 값을 소비하면(또는 중간에 중단되더라도) 그 이터레이터는 완료된 상태가 됩니다.
// 문제: 제너레이터를 재사용할 수 없음
const numbers = generateNumbers(); // 제너레이터 객체 생성
for (const n of numbers) { // 이 루프에서 값 소비
  console.log(n);
  if (n > 5) break; // 6까지 출력 후 중단
}


// 이미 소비되었으므로 아무것도 출력하지 않음
for (const n of numbers) {
  console.log('다시:', n); // 실행되지 않음  - 제너레이터가 이미 소비됨
}

// 해결책: 제너레이터 함수를 팩토리로 사용
function* generateNumbers() {
  let i = 0;
  while (i < 10) yield i++;
}

// 새 제너레이터 인스턴스 생성
// 첫 번째 루프에서 직접 제너레이터 함수를 호출하여 새 인스턴스 생성
for (const n of generateNumbers()) {
  console.log(n);
  if (n > 5) break;
}

// 다시 새 인스턴스 생성
// 두 번째 루프에서도 다시 제너레이터 함수를 호출하여 새 인스턴스 생성
for (const n of generateNumbers()) {
  console.log('다시:', n); // 정상 작동
}

 

왜 팩토리 패턴이 가능한가?

이 방식이 가능한 이유는:

  1. 함수 재실행: generateNumbers()를 호출할 때마다 함수가 처음부터 다시 실행됩니다.
  2. 초기 상태 재설정: 매번 let i = 0으로 카운터를 초기화합니다.
  3. 새 제너레이터 객체: 함수 호출은 매번 완전히 새로운 제너레이터 객체를 반환합니다.

 

비동기 제너레이터와 일반 제너레이터 혼합

// 안티패턴: 종료 조건 없는 무한 제너레이터를 위험하게 사용
function* infiniteGenerator() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

// 위험: 무한 루프
const values = [...infiniteGenerator()]; // 메모리 초과 오류 발생

// 올바른 사용법: 명시적 제한
const gen = infiniteGenerator();
const firstTen = Array.from({ length: 10 }, () => gen.next().value);

전문 용어 설명

  • 이터러블(Iterable): Symbol.iterator 메서드를 구현한 객체입니다. 이 메서드는 이터레이터를 반환하며, for...of 루프, 스프레드 연산자(...), 구조 분해 할당 등에서 사용할 수 있습니다. 배열, 문자열, Map, Set 등이 기본적인 이터러블입니다.
  • 이터레이터(Iterator): next() 메서드를 가진 객체로, 호출될 때마다 { value, done } 형태의 객체를 반환합니다. value는 현재 값이고, done은 시퀀스가 끝났는지를 나타내는 불리언 값입니다.
  • 제너레이터 함수(Generator Function): function* 문법으로 선언되는 특별한 함수입니다. 일반 함수와 달리 실행을 중간에 중지하고 재개할 수 있으며, 호출 시 제너레이터 객체(이터레이터이자 이터러블)를 반환합니다.
  • yield: 제너레이터 함수 내에서 값을 반환하면서 실행을 일시 중지시키는 키워드입니다. 제너레이터의 next()가 다시 호출될 때 중단된 지점에서 실행이 재개됩니다.
  • yield*: 다른 제너레이터나 이터러블에 제어를 위임하는 표현식입니다. 이를 통해 한 제너레이터가 다른 제너레이터나 이터러블의 모든 값을 순차적으로 yield할 수 있습니다.
function* example1() {
  yield [1, 2, 3];  // [1, 2, 3] 배열 자체를 하나의 값으로 반환
}

function* example2() {
  yield* [1, 2, 3];  // 1, 2, 3을 각각 개별적으로 순차 반환
}

// 사용 예
for (const val of example1()) {
  console.log(val);  // [1, 2, 3] 한 번만 출력됨
}

for (const val of example2()) {
  console.log(val);  // 1, 2, 3이 각각 순서대로 출력됨
}


yield*는 이터러블 객체의 각 요소를 개별적으로 yield 하도록 제어를 위임하는 것입니다. 
마치 다음과 같이 작성한 것과 동일한 효과입니다:

function* example2Manual() {
  for (const item of [1, 2, 3]) {
    yield item;
  }
}
  • 비동기 제너레이터(Async Generator): async function* 문법으로 선언된 함수로, 비동기 작업을 yield로 처리할 수 있습니다.
  • for await...of: 비동기 이터러블을 순회하기 위한 루프 구문입니다.
  • 팩토리로 사용한다 / 팩토리 패턴 :  제너레이터 함수 자체를 호출하여 매번 새로운 제너레이터 객체를 생성하는 것을 의미합니다. 이는 디자인 패턴에서의 팩토리 패턴과 유사합니다 - 팩토리는 새로운 객체 인스턴스를 생성하는 함수입니다.
  • 컬렉션 타입 : 컬렉션 타입은 여러 데이터 요소들을 하나로 묶어서 관리하는 자료구조를 말합니다. JavaScript에서 기본적으로 제공하는 컬렉션 타입에는:

    배열(Array)
    객체(Object)
    Map
    Set
    WeakMap
    WeakSet

    등이 있습니다. 이들은 여러 값을 담고 관리하는 자료구조입니다.

결론 및 더 공부할 자료

    1. MDN Web Docs - 제너레이터
    2. JavaScript Info - 제너레이터

 

 

제너레이터

 

ko.javascript.info

 

 

Generator - JavaScript | MDN

Generator 객체는 generator function 으로부터 반환되며, 반복 가능한 프로토콜과 반복자 프로토콜을 모두 준수합니다.

developer.mozilla.org

 

제너레이터 자체를 많이 사용해보거나 접해보지 못해서 조금은 생소하지만 해당 내용을 찾아보면서 실무에 적절히 적용하면 더 나은 코드를 만들 수 있을 것 같다는 생각이 들었다. 

 

728x90
반응형