⚙️ 13. 제너레이터 & 이터레이터 — 게으른 계산이 왜 강력한가

2026년 4월 30일 수정됨

📋 개요

이터레이터 프로토콜, 제너레이터 함수의 lazy evaluation, 무한 시퀀스, async 제너레이터를 실무 관점으로 정복합니다.

🎯 이 섹션을 읽고 나면:

  • 이터레이터 프로토콜([Symbol.iterator])을 이해하고 커스텀 이터러블을 만든다.
  • 제너레이터 함수(function*)의 yield로 지연 평가(lazy evaluation)를 구현한다.
  • async generator로 페이지네이션 스트리밍을 우아하게 처리한다.

📋 목차


📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 20분(전체) / 핵심 파트만: 12분

🗺️ 이 문서의 흐름
[이터레이터 프로토콜] → [제너레이터 기본] → [무한 시퀀스] → [async 제너레이터]

🎯 이 문서를 다 읽으면 할 수 있는 것

  • for...of가 내부적으로 어떻게 동작하는지 설명한다.
  • 무한 페이지네이션을 제너레이터로 구현한다.
  • 대용량 데이터를 메모리에 한 번에 올리지 않고 스트리밍 처리한다.

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

🐣 영철: "영호 님, 전체 게시글 통계를 내야 하는데요. 게시글이 수만 개라 전부 불러오면 메모리 사용량이 크게 늘 것 같아요. 그렇다고 페이지마다 요청하는 코드를 따로 짜려면 복잡해지고... 좋은 방법이 없을까요?"

🦁 영호: "딱 제너레이터가 필요한 상황이야. async 제너레이터로 페이지네이션을 감싸면, 마치 모든 데이터가 있는 것처럼 for await...of로 순회할 수 있어. 메모리에는 한 페이지만 올라와 있고, 다음 게시글이 필요할 때만 API를 날려. 게으른(Lazy) 계산이 왜 강력한지 보여줄게."


🤔 1. 왜 알아야 하는가?

오프닝에서 영철이가 말한 것처럼 — 게시글 10만 개를 한꺼번에 메모리에 올리면 작업 큐와 힙 사용량이 빠르게 커진다. 영호 리드 님이 "게으른 계산이 왜 강력한지 보여줄게"라고 했던 이유가 바로 이것이다. 제너레이터는 "필요할 때만 계산하는" (Lazy Evaluation) 패턴을 JS에서 구현하는 도구다.

  • 무한 시퀀스를 메모리 효율적으로 처리
  • 대용량 데이터를 스트리밍 방식으로 처리
  • 비동기 데이터를 동기 코드처럼 순회
  • Redux-Saga 같은 라이브러리의 내부 구현 기반

🔄 2. 이터레이터 프로토콜

영철이가 "근데 for...of 는 그냥 되잖아요. 그게 어떻게 동작하는 건가요?"라고 궁금해했을 때, 영호 리드 님이 화이트보드에 정리해준 내용입니다. 우리가 무심코 쓰는 순회 문법의 밑바닥에는 이런 약속(프로토콜)이 흐르고 있습니다.

// 이터러블 프로토콜: [Symbol.iterator]() 메서드를 가진 객체
// 이터레이터 프로토콜: { value, done }을 반환하는 next() 메서드를 가진 객체
 
// 배열, 문자열, Map, Set 등은 이미 이터러블
const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator](); // 이터레이터 가져오기
 
iterator.next(); // { value: 1, done: false }
iterator.next(); // { value: 2, done: false }
iterator.next(); // { value: 3, done: false }
iterator.next(); // { value: undefined, done: true }
 
// for...of는 내부적으로 이 과정을 자동화한 것
for (const val of [1, 2, 3]) {
  console.log(val); // 1, 2, 3
}
 
// 커스텀 이터러블 만들기
function range(start, end) {
  return {
    [Symbol.iterator]() {
      let current = start;
      return {
        next() {
          if (current <= end) {
            return { value: current++, done: false };
          }
          return { value: undefined, done: true };
        },
      };
    },
  };
}
 
for (const num of range(1, 5)) {
  console.log(num); // 1, 2, 3, 4, 5
}
 
console.log([...range(1, 5)]); // [1, 2, 3, 4, 5]

⚙️ 3. 제너레이터 함수

복잡한 이터레이터를 훨씬 간결하고 우아하게 설계할 수 있게 도와주는 특별한 함수입니다.

기본 문법과 동작

// function* — 제너레이터 함수 선언
function* simpleGenerator() {
  console.log("A");
  yield 1; // ← 여기서 일시 중단, 1을 반환
  console.log("B");
  yield 2; // ← 다시 일시 중단, 2를 반환
  console.log("C");
  return 3; // ← 함수 종료, { value: 3, done: true }
}
 
const gen = simpleGenerator(); // 함수 실행 안 됨!
 
gen.next(); // "A" 출력, { value: 1, done: false }
gen.next(); // "B" 출력, { value: 2, done: false }
gen.next(); // "C" 출력, { value: 3, done: true }
gen.next(); // { value: undefined, done: true }
 
// for...of로 순회 (return 값은 포함 안 됨)
for (const val of simpleGenerator()) {
  console.log(val); // 1, 2 (return 3은 포함 안 됨)
}

핵심 개념: yield는 함수를 일시 중단 시키고, next()가 호출될 때 다음 yield까지 실행을 재개한다.

무한 시퀀스

우리가 상상만 하던 '끝이 없는 배열처럼 다루는 흐름'을 제너레이터는 가능케 합니다. 메모리를 가득 채우지 않고도 필요한 만큼만 값을 생성하는 원리를 확인해 보세요.

// 메모리에 무한 배열을 올릴 수는 없지만, 무한 제너레이터는 가능!
function* infiniteId() {
  let id = 1;
  while (true) {
    yield id++;
    // while(true)지만 yield에서 멈추므로 무한 루프가 아님
  }
}
 
const idGenerator = infiniteId();
idGenerator.next().value; // 1
idGenerator.next().value; // 2
idGenerator.next().value; // 3
// 필요할 때만 다음 값을 계산 → 메모리 효율적
 
// 처음 5개만 가져오기
function take(iterable, n) {
  const result = [];
  for (const val of iterable) {
    result.push(val);
    if (result.length >= n) break;
  }
  return result;
}
 
take(infiniteId(), 5); // [1, 2, 3, 4, 5]
 
// 영수네 커뮤니티 — 게시글 ID 자동 생성기
const postIdGen = infiniteId();
const newPost1 = { id: postIdGen.next().value, title: "첫 글" }; // id: 1
const newPost2 = { id: postIdGen.next().value, title: "두 번째" }; // id: 2

제너레이터에 값 전달

제너레이터는 단순히 값을 밖으로 내보내기만 하는 게 아닙니다. next(value)를 통해 외부에서 데이터를 주입받아 대화를 나눌 수도 있죠.

function* accumulator() {
  let total = 0;
  while (true) {
    const value = yield total; // yield는 외부에서 주입된 값을 반환
    if (value === null) break;
    total += value;
  }
}
 
const acc = accumulator();
acc.next();    // 시작, { value: 0, done: false }
acc.next(10);  // 10 주입, { value: 10, done: false }
acc.next(20);  // 20 주입, { value: 30, done: false }
acc.next(5);   // 5 주입, { value: 35, done: false }
acc.next(null); // 종료 신호, { value: undefined, done: true }

🌐 4. async 제너레이터

영호 리드 님이 약속했던 "마치 모든 데이터가 이미 있는 것처럼 부드럽게 순회하는" 패턴입니다. 네트워크 요청과 제너레이터가 만나면 어떤 시너지가 나는지 살펴보겠습니다.

// 영수네 커뮤니티 — 전체 게시글 페이지네이션 처리
 
async function* fetchAllPosts(pageSize = 20) {
  let page = 1;
  let hasMore = true;
 
  while (hasMore) {
    const response = await fetch(
      `/api/posts?page=${page}&size=${pageSize}`
    );
    const { posts, total, currentPage } = await response.json();
 
    for (const post of posts) {
      yield post; // 한 번에 하나씩 yield
    }
 
    hasMore = currentPage * pageSize < total;
    page++;
  }
}
 
// 사용 — 전체 게시글을 스트리밍으로 처리
async function buildPostIndex() {
  const index = new Map();
 
  for await (const post of fetchAllPosts()) {
    // 한 번에 하나의 게시글만 메모리에 올려 처리
    index.set(post.id, {
      title: post.title,
      tags: post.tags,
    });
  }
 
  return index;
}
 
// 통계 처리
async function getPostStats() {
  let count = 0;
  let totalViews = 0;
  const tagFrequency = new Map();
 
  for await (const post of fetchAllPosts(50)) {
    count++;
    totalViews += post.viewCount;
 
    for (const tag of post.tags) {
      tagFrequency.set(tag, (tagFrequency.get(tag) ?? 0) + 1);
    }
  }
 
  return { count, totalViews, avgViews: totalViews / count, tagFrequency };
}

💼 5. 실무 패턴

영수 PM님이 "수천 명의 유저에게 알림을 보내야 하는데, 한꺼번에 요청하면 서버가 힘들 것 같아요"라고 걱정할 때, 영호 리드 님이 제시한 '배치 처리' 패턴입니다.

// 패턴 1: yield* — 다른 이터러블에 위임
function* combined() {
  yield* [1, 2, 3]; // 배열의 각 원소를 yield
  yield* "hello";   // 문자열의 각 문자를 yield
  yield 99;
}
 
[...combined()]; // [1, 2, 3, "h", "e", "l", "l", "o", 99]
 
// 패턴 2: 트리 순회 (재귀적 구조)
function* traverse(node) {
  yield node.value;
  for (const child of node.children ?? []) {
    yield* traverse(child); // 재귀적 위임
  }
}
 
const tree = {
  value: "root",
  children: [
    { value: "a", children: [{ value: "a1" }, { value: "a2" }] },
    { value: "b", children: [{ value: "b1" }] },
  ],
};
 
[...traverse(tree)]; // ["root", "a", "a1", "a2", "b", "b1"]
 
// 패턴 3: 배치 처리
async function* batchProcess(items, batchSize) {
  for (let i = 0; i < items.length; i += batchSize) {
    yield items.slice(i, i + batchSize);
  }
}
 
async function sendNotifications(userIds) {
  for await (const batch of batchProcess(userIds, 50)) {
    await Promise.all(batch.map((id) => sendNotification(id)));
    console.log(`${batch.length}명에게 알림 전송 완료`);
    await sleep(100); // 과부하 방지
  }
}

📝 마무리 퀴즈

Q1. 제너레이터 함수와 일반 함수의 가장 큰 차이는?

정답: 제너레이터는 yield로 실행을 일시 중단 하고 재개할 수 있으며, 호출 시 즉시 실행되지 않고 이터레이터 객체를 반환한다.

💡 상세 해설:

  • 일반 함수: 호출 → 실행 → 반환 (한 번에 끝남)
  • 제너레이터: 호출 → 이터레이터 반환 (실행 안 됨) → next() 호출마다 다음 yield까지 실행
  • 이 "중단/재개" 메커니즘으로 지연 평가(Lazy Evaluation) 가 가능해짐
  • 📌 핵심 기억법: "제너레이터는 게으른 함수 다. next()로 '이제 한 발짝 더 가'라고 해야 움직인다."

Q2. for...of가 내부적으로 어떻게 동작하는지 이터레이터 프로토콜로 설명하라.

정답: for...of는 대상의 [Symbol.iterator]()를 호출해 이터레이터를 얻고, done: true가 될 때까지 next()를 반복 호출한다.

💡 상세 해설:

// for...of의 내부 동작 (의사 코드)
const iterable = [1, 2, 3];
const iterator = iterable[Symbol.iterator]();
 
let result = iterator.next();
while (!result.done) {
  // 루프 바디 실행
  console.log(result.value);
  result = iterator.next();
}
// 이것이 for (const val of iterable) { console.log(val); }와 동일
  • 커스텀 클래스에 [Symbol.iterator]() 메서드를 구현하면 for...of, 스프레드, 구조분해에서 모두 사용 가능
  • 📌 핵심 기억법: "[Symbol.iterator]를 구현한 것은 뭐든 for...of로 순회할 수 있다. Map, Set, Generator 모두 이 프로토콜을 따른다."

Q3. 영철이의 테스트 타임 — 실무 딜레마

영수 PM이 새 요구사항을 전달했다: "게시글 전체를 분석해서 태그 통계를 내야 하는데, 게시글이 10만 개라 전부 메모리에 올리면 처리 시간이 길어지고 메모리 사용량도 커질 수 있어요. 어떻게 처리할까요?"

정답: async 제너레이터로 페이지 단위 스트리밍 처리. 한 번에 한 페이지(수십 건)만 메모리에 올려 처리한다.

💡 상세 해설:

async function* streamAllPosts(pageSize = 100) {
  let page = 1;
  while (true) {
    const { posts, hasMore } = await fetchPosts({ page, pageSize });
    for (const post of posts) yield post;
    if (!hasMore) break;
    page++;
  }
}
 
async function analyzeTagStats() {
  const tagStats = new Map();
 
  for await (const post of streamAllPosts()) {
    for (const tag of post.tags) {
      tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1);
    }
    // 메모리에는 현재 게시글 1개만 올라와 있음
    // 10만 개 처리해도 메모리는 일정
  }
 
  return tagStats;
}
  • 한 번에 전체를 await Promise.all(fetchAllPosts())로 처리하면 10만 개가 한꺼번에 메모리 점유
  • async 제너레이터 + for await...of스트리밍 방식 → 메모리 사용량이 페이지 크기에 고정
  • 📌 핵심 기억법: "대용량 데이터는 한 번에 다 가져오지 마라. async 제너레이터로 페이지 단위 흐름을 만들어라."

🐣 영철이의 퇴근 일기

제너레이터... 솔직히 처음 들었을 때 '이게 언제 쓰임?' 싶었는데, 오늘 async 제너레이터로 페이지네이션 스트리밍 코드 보고 나서 완전히 설득됐다. 10만 건 데이터를 한 번에 올리는 대신, 필요할 때마다 한 페이지씩 흘리는 게 진짜 우아하다.

yield가 함수를 일시 중단한다는 개념도 신기했다. 함수가 "잠깐만, 이 값 먼저 줘. 나머지는 나중에"라고 하는 게 이제 머릿속에 그려진다.

💡 오늘의 교훈: "제너레이터는 무작정 모든 것을 미리 준비하는 대신, '지금 당장 필요한 것'에만 집중하는 게으름의 미학입니다. 대용량 데이터나 복잡한 비동기 흐름을 다룰 때, 이 게으름은 우리에게 놀라운 효율성을 가져다줍니다."

내일은 게시글 통계 배치 작업을 async 제너레이터로 감싸는 작은 실험을 해보려고 한다. API 페이지네이션을 호출부에 흩뿌리지 않고 for await...of 하나로 읽히게 만들면, 메모리 사용량과 코드 가독성을 함께 잡을 수 있을 것 같다.


🔗 더 알아보기