⚙️ 13. 제너레이터 & 이터레이터 — 게으른 계산이 왜 강력한가
📋 개요
이터레이터 프로토콜, 제너레이터 함수의 lazy evaluation, 무한 시퀀스, async 제너레이터를 실무 관점으로 정복합니다.
🎯 이 섹션을 읽고 나면:
- 이터레이터 프로토콜(
[Symbol.iterator])을 이해하고 커스텀 이터러블을 만든다.- 제너레이터 함수(
function*)의yield로 지연 평가(lazy evaluation)를 구현한다.async generator로 페이지네이션 스트리밍을 우아하게 처리한다.
📋 목차
- 📌 이 문서를 읽기 전에
- 🤔 1. 왜 알아야 하는가?
- 🔄 2. 이터레이터 프로토콜
- ⚙️ 3. 제너레이터 함수
- 🌐 4. async 제너레이터
- 💼 5. 실무 패턴
- 📝 마무리 퀴즈
- 🐣 영철이의 퇴근 일기
- 🔗 더 알아보기
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 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가 함수를 일시 중단한다는 개념도 신기했다. 함수가 "잠깐만, 이 값 먼저 줘. 나머지는 나중에"라고 하는 게 이제 머릿속에 그려진다.
💡 오늘의 교훈: "제너레이터는 무작정 모든 것을 미리 준비하는 대신, '지금 당장 필요한 것'에만 집중하는 게으름의 미학입니다. 대용량 데이터나 복잡한 비동기 흐름을 다룰 때, 이 게으름은 우리에게 놀라운 효율성을 가져다줍니다."
이번 주 JS 스터디 진짜 빡셌는데, 그만큼 많이 배웠다. 다음엔 클래스 심화인데, 지난번에 프로토타입 배웠으니까 이제 class가 다르게 보일 것 같다. 주말에 농구하고 충전하고 다음 주 파이팅이다!