자바스크립트 이터레이터와 제너레이터 - for of의 동작 원리와 지연 평가
이 단원에서 배우는 것
17단원에서 실행 순서를, 18단원에서 메모리를 봤다. 두 단원의 공통 주제는 "한꺼번에 다 하지 않기"였다. 이번 단원은 그 사고방식을 데이터 처리에 적용한다. 참가자 3만 명의 배열을 filter().map().slice()로 이어 붙이면 중간 배열이 두 개 만들어지고, 앞의 다섯 명만 필요해도 3만 명을 다 훑는다. 이터레이터와 제너레이터는 이 낭비를 없애는 언어 차원의 장치다.
for...of가 내부적으로 어떤 메서드를 어떤 순서로 호출하는지 설명하고, 직접 이터러블 객체를 만든다.- 제너레이터로 지연 평가 파이프라인을 구성해 중간 배열 없이 필요한 만큼만 계산한다.
- 비동기 제너레이터와
for await...of로 커서 기반 API 페이지네이션을 한 줄짜리 반복문으로 소비한다.
기준은 ES2018에 들어간 비동기 이터레이터까지다. Node.js 20 이상과 최신 브라우저에서 모두 동작한다. 2024년 이후 브라우저에 들어간 이터레이터 헬퍼(Iterator.prototype.map 등)도 마지막에 짚는다.
왜 필요한가
대시보드에서 "미납 참가자 상위 5명"을 뽑는 코드를 쓴다고 하자.
const top5 = members
.filter((m) => !m.paid) // 3만 개를 훑어 새 배열 생성
.sort((a, b) => b.sessions - a.sessions)
.slice(0, 5); // 결국 5개만 쓴다
sort가 있으니 전부 봐야 하는 것은 맞다. 그런데 조건이 "미납자 중 아무나 5명"으로 바뀌면 얘기가 달라진다. 그래도 filter는 3만 개를 다 돌고 새 배열을 만든다. 다섯 개를 찾은 시점에 멈출 방법이 배열 메서드에는 없다.
더 곤란한 쪽은 데이터가 배열이 아닐 때다. 출석 기록 API가 커서 기반 페이지네이션이면 이렇게 된다.
let cursor = null;
const all = [];
do {
const res = await fetch(`/api/attendance?cursor=${cursor ?? ""}`);
const page = await res.json();
all.push(...page.items);
cursor = page.nextCursor;
} while (cursor !== null);
for (const record of all) { /* ... */ }
이 코드에는 두 가지 문제가 있다. 첫째, 전부 받을 때까지 아무것도 처리하지 못한다. 둘째, "페이지를 넘기는 방법"과 "기록으로 무엇을 할지"가 한 함수에 뒤엉켜서 재사용도 테스트도 어렵다. 이터레이터는 이 둘을 분리하는 표준 인터페이스다.
문법과 예제
for...of 가 실제로 하는 일
for...of는 배열 전용 문법이 아니다. 다음 절차를 따르는 약속(프로토콜)일 뿐이다.
- 대상의
[Symbol.iterator]()를 호출해 이터레이터를 얻는다. - 이터레이터의
next()를 호출한다. 반환값은{ value, done }형태의 객체다. done이false인 동안value를 변수에 담고 본문을 실행한다.- 반복문이
break,return, 예외로 중간에 끝나면 이터레이터의return()을 호출한다.
4번이 중요하다. 이 규칙 덕분에 반복 중간에 빠져나가도 자원 정리가 실행된다. 손으로 돌려 보면 이렇다.
const it = ["김민수", "이서연"][Symbol.iterator]();
console.log(it.next()); // { value: '김민수', done: false }
console.log(it.next()); // { value: '이서연', done: false }
console.log(it.next()); // { value: undefined, done: true }
그래서 Symbol.iterator만 구현하면 어떤 객체든 for...of, 스프레드, 구조 분해의 대상이 된다.
const roster = {
members: [
{ id: 1, name: "김민수", sessions: 3, paid: true },
{ id: 2, name: "이서연", sessions: 0, paid: false },
],
[Symbol.iterator]() {
let i = 0;
const list = this.members;
return {
next: () => (i < list.length
? { value: list[i++], done: false }
: { value: undefined, done: true }),
[Symbol.iterator]() { return this; },
};
},
};
for (const m of roster) console.log(m.name);
const [first] = roster;
console.log(first.name); // 김민수
반환 객체에 [Symbol.iterator]() { return this; }를 넣은 이유는, 이터레이터 자신도 이터러블이어야 for...of에 직접 넣을 수 있기 때문이다. 이 규칙을 지키지 않은 이터레이터는 스프레드에서 조용히 실패한다.
제너레이터 — 같은 것을 짧게
위 코드는 제너레이터로 네 줄이 된다.
const roster = {
members: [
{ id: 1, name: "김민수", sessions: 3, paid: true },
{ id: 2, name: "이서연", sessions: 0, paid: false },
],
*[Symbol.iterator]() {
for (const m of this.members) yield m;
},
};
function*로 선언한 함수는 호출해도 본문이 실행되지 않는다. 이터레이터 객체를 돌려줄 뿐이다. next()를 부를 때마다 다음 yield까지 실행하고 그 자리에서 멈춘 채 지역 변수를 그대로 보존한다. 17단원의 await가 함수를 중간에 자르던 것과 같은 메커니즘이고, 실제로 async 함수는 제너레이터 위에 얹어 구현할 수 있다.
지연 평가 파이프라인
function* lazyFilter(iterable, predicate) {
for (const item of iterable) {
if (predicate(item)) yield item;
}
}
function* lazyMap(iterable, fn) {
for (const item of iterable) yield fn(item);
}
function* take(iterable, n) {
if (n <= 0) return;
let count = 0;
for (const item of iterable) {
yield item;
if (++count >= n) return;
}
}
const unpaidNames = take(
lazyMap(lazyFilter(members, (m) => !m.paid), (m) => m.name),
5,
);
console.log([...unpaidNames]);
[...unpaidNames]를 실행하기 전까지 아무 계산도 일어나지 않는다. 실행하면 take가 lazyMap에게 하나 달라고 하고, lazyMap이 lazyFilter에게, lazyFilter가 members에게 하나씩 요청한다. 다섯 개를 채우는 순간 take가 return하고 전체가 멈춘다. 미납자가 앞쪽에 몰려 있으면 3만 명 중 열 명만 검사하고 끝난다. 중간 배열도 하나도 만들지 않는다.
배열 메서드 버전과 비교하면 이렇다.
| 배열 메서드 체인 | 제너레이터 파이프라인 | |
|---|---|---|
| 중간 배열 | 단계마다 하나씩 생성 | 없음 |
| 조기 종료 | 불가(전부 순회) | 가능 |
| 무한 시퀀스 | 불가 | 가능 |
| 단건 처리 속도 | 빠르다 | next 호출 비용만큼 느리다 |
| 디버깅 | 중간값을 찍어 보기 쉽다 | 스택이 깊어 추적이 어렵다 |
마지막 두 줄이 트레이드오프다. 항목 수가 적고 전부 처리해야 한다면 배열 메서드가 더 빠르고 읽기도 쉽다. 제너레이터가 이기는 조건은 "데이터가 크다", "일부만 필요하다", "끝을 모른다" 중 하나 이상이 성립할 때다.
무한 시퀀스
function* sessionNumbers(start = 1) {
let n = start;
while (true) yield n++; // 끝나지 않는다
}
const labels = [...take(lazyMap(sessionNumbers(), (n) => `${n}회차`), 3)];
console.log(labels); // [ '1회차', '2회차', '3회차' ]
무한 반복문을 안전하게 쓸 수 있는 이유는 소비하는 쪽이 요청할 때만 한 바퀴 돌기 때문이다. 다만 [...sessionNumbers()]처럼 통째로 펼치면 브라우저가 멈춘다. 무한 이터러블에는 반드시 take 같은 제한이 붙어야 한다.
중간에 끊길 때의 정리
function* readAttendanceFile(handle) {
try {
yield "1행";
yield "2행";
yield "3행";
} finally {
console.log("파일 닫기"); // break 로 빠져나가도 실행된다
}
}
for (const line of readAttendanceFile()) {
if (line === "2행") break;
}
// 출력: 파일 닫기
break 시점에 for...of가 이터레이터의 return()을 호출하고, 제너레이터는 그것을 멈춘 yield 자리에서의 return문처럼 처리한다. 그래서 finally가 실행된다. 파일 핸들, DB 커서, 구독 해제 같은 정리를 finally에 두면 소비하는 쪽이 어떻게 끝내든 안전하다. 손으로 만든 이터레이터에서는 이 return()을 직접 구현해야 같은 보장을 얻는다.
비동기 제너레이터 — 페이지네이션
앞의 문제 코드를 다시 쓴다. 페이지를 넘기는 방법과 처리 로직이 완전히 분리된다.
async function* fetchAttendance(studyId, { signal } = {}) {
let cursor = null;
do {
const query = new URLSearchParams({ studyId });
if (cursor) query.set("cursor", cursor);
const res = await fetch(`/api/attendance?${query}`, { signal });
if (!res.ok) throw new Error(`출석 조회 실패: ${res.status}`);
const page = await res.json();
yield* page.items; // 배열의 각 항목을 하나씩 내보낸다
cursor = page.nextCursor;
} while (cursor !== null);
}
// 소비하는 쪽
const controller = new AbortController();
let absent = 0;
for await (const record of fetchAttendance(42, { signal: controller.signal })) {
if (!record.attended) absent++;
if (absent >= 10) break; // 열 명 찾으면 더 이상 요청하지 않는다
}
yield*는 다른 이터러블의 값을 하나씩 대신 내보낸다. for await...of는 next()가 돌려준 프로미스를 기다린 뒤 value를 꺼낸다. 핵심은 break다. 열 명을 찾은 순간 반복이 끝나고 다음 페이지 요청은 아예 발생하지 않는다. 앞의 코드는 전체를 다 받은 뒤에야 세기 시작했다. 18단원에서 본 AbortController를 함께 넘기면 화면을 떠날 때 진행 중인 요청까지 취소된다.
제너레이터에 값 넣기
function* attendanceLogger() {
const records = [];
while (true) {
const record = yield records.length; // next(인자)의 인자가 여기로 들어온다
if (record === undefined) continue;
records.push(record);
}
}
const logger = attendanceLogger();
logger.next(); // 첫 next 는 시작만 시킨다. 인자는 버려진다
console.log(logger.next({ id: 1 }).value); // 1
console.log(logger.next({ id: 2 }).value); // 2
yield는 값을 내보내는 동시에 다음 next()의 인자를 받는 자리가 된다. 첫 번째 next()의 인자가 버려지는 것은 아직 멈춰 있는 yield가 없기 때문이다. 이 양방향 통신이 async 함수 구현의 원리이기도 하다. 다만 실무 코드에서 이 형태를 자주 쓰지는 않는다. 읽는 사람이 흐름을 따라가기 어렵기 때문이다.
이터레이터 헬퍼
// Chrome 122+, Node.js 22+ 에서 동작한다
const names = sessionNumbers()
.map((n) => `${n}회차`)
.take(3)
.toArray();
console.log(names); // [ '1회차', '2회차', '3회차' ]
위에서 직접 만든 lazyMap, take가 언어에 들어왔다. map, filter, take, drop, flatMap, reduce, toArray가 있고 전부 지연 평가다. 다만 배포 대상에 구형 브라우저가 포함되면 아직 폴리필이 필요하다. 사내 도구처럼 환경을 통제할 수 있으면 바로 써도 된다.
실무에서 자주 틀리는 것
1. 제너레이터 결과를 두 번 순회한다
const unpaid = lazyFilter(members, (m) => !m.paid);
console.log([...unpaid].length); // 120
console.log([...unpaid].length); // 0 ← 두 번째는 비어 있다
제너레이터가 반환한 것은 일회용 이터레이터다. 한 번 소진되면 그다음 next()는 항상 done: true다. 배열은 순회해도 그대로 남으므로 습관이 옮겨 오면서 자주 데인다. 증상이 "두 번째 표가 비어 있다"라서 데이터 문제로 오해하기 쉽다. 두 번 이상 순회해야 하면 const list = [...unpaid]로 한 번 굳히거나, 앞의 roster처럼 호출될 때마다 새 이터레이터를 만드는 객체로 감싼다.
2. 무한 이터러블에 스프레드나 구조 분해를 쓴다
const [a, b] = sessionNumbers(); // 안전. 두 개만 꺼낸다
const all = [...sessionNumbers()]; // 탭이 멈춘다
const arr = Array.from(sessionNumbers()); // 역시 멈춘다
구조 분해는 필요한 개수만 꺼내고 return()을 호출하므로 안전하지만, 스프레드와 Array.from은 done이 될 때까지 계속 당긴다. 무한 루프가 마이크로태스크가 아니라 동기 코드라서 17단원에서 본 것처럼 탭 전체가 얼어붙고 개발자 도구도 반응하지 않는다.
3. try/finally 없이 자원을 잡는다
// 나쁜 예 — break 로 나가면 구독이 남는다
async function* watchAttendance(studyId) {
const socket = new WebSocket(`wss://example.com/attendance/${studyId}`);
const queue = createQueue(socket);
while (true) yield await queue.pop();
}
소비하는 쪽이 break하면 제너레이터는 그 자리에서 종료되지만 socket은 열린 채 남는다. 18단원의 누수 패턴이 그대로 재현된다. try { ... } finally { socket.close(); }로 감싸야 어떤 방식으로 끝나든 닫힌다. 비동기 제너레이터에서는 소비 쪽이 반복을 포기했는지 알 방법이 return()뿐이므로, 정리를 finally에 두는 것이 유일한 안전장치다.
4. 성능이 좋아질 거라 믿고 전부 제너레이터로 바꾼다
// 100개짜리 배열 — 이 변환은 손해다
const names = [...lazyMap(members, (m) => m.name)];
const names2 = members.map((m) => m.name); // 더 빠르고 읽기도 쉽다
next() 호출마다 결과 객체가 하나씩 만들어지고 함수 프레임을 재개하는 비용이 든다. 항목이 적으면 이 오버헤드가 절약분보다 크다. 게다가 제너레이터 안에서 예외가 나면 스택 트레이스가 next 호출 지점으로 잡혀 원인 파악이 어려워진다. 바꾸기 전에 performance.now()로 재 본다. 근거 없이 "지연 평가가 빠르다"고 말하면 안 된다.
5. 비동기 제너레이터에서 오류 재시도를 넣다가 무한 루프를 만든다
// 나쁜 예 — 서버가 계속 500이면 영원히 돈다
while (cursor !== null) {
try {
const page = await loadPage(cursor);
yield* page.items;
cursor = page.nextCursor;
} catch {
continue; // 즉시 재시도
}
}
재시도 자체는 필요하지만 횟수 상한과 대기 시간이 없으면 서버가 죽었을 때 초당 수천 건을 던지는 클라이언트가 된다. 시도 횟수를 세고 실패마다 대기 시간을 늘린 뒤(지수 백오프) 상한을 넘으면 예외를 던져야 한다. 또한 cursor가 갱신되지 않는 응답을 서버가 돌려주면 같은 페이지를 무한히 받는데, 이때는 이전 커서와 같은 값이 오는지 검사해 끊는다.
프로토콜의 정확한 정의는 MDN의 반복 프로토콜 문서에 있다.
스스로 확인하기
- 다음 코드의 출력과 그 이유를 쓰라.
function* g() { yield 1; yield 2; yield 3; } const it = g(); console.log([...it]); console.log([...it]); take(lazyFilter(members, fn), 3)에서members가 3만 개이고 조건을 만족하는 첫 세 개가 인덱스 0, 1, 2에 있다면fn은 몇 번 호출되는가? 같은 일을members.filter(fn).slice(0, 3)으로 하면 몇 번인가?- 커서 기반 API에서 조건을 만족하는 첫 항목만 필요하다. 비동기 제너레이터를 쓸 때와 전체를 받아
find를 쓸 때의 차이를 네트워크 요청 수 관점에서 설명하라.
정답
- 첫 줄은
[1, 2, 3], 둘째 줄은[]다.g()가 반환한 이터레이터는 일회용이라 첫 번째 스프레드에서done: true가 될 때까지 모두 소진된다. 두 번째 스프레드는 이미 종료된 이터레이터에next()를 부르므로 즉시done: true를 받아 빈 배열이 된다. 두 번 순회하려면[...g()]처럼 매번 새로 호출하거나 결과를 배열에 담아 둔다. - 제너레이터 파이프라인에서는 3번이다.
take가 세 번째 값을 받은 직후return하므로 네 번째 항목은 검사되지 않는다.filter().slice()는filter가 전체를 순회하므로 30000번 호출되고, 조건을 만족한 항목 전부를 담은 중간 배열까지 만든 뒤 앞의 세 개만 잘라 낸다. - 비동기 제너레이터는 소비 쪽이
break하는 순간 다음 페이지 요청을 시작하지 않는다. 조건을 만족하는 항목이 첫 페이지에 있으면 요청은 1회로 끝난다. 전체를 받아find를 쓰면nextCursor가null이 될 때까지 모든 페이지를 받아야 하므로 전체 페이지 수만큼 요청이 발생하고, 그 데이터를 전부 메모리에 쌓는다. 응답 시간과 메모리 모두 페이지 수에 비례한다.