이터러블과 이터레이터
JavaScript에서 for...of, 스프레드 연산자(...), 구조 분해 할당이 동작하려면 대상이 이터러블 (iterable) 이어야 합니다. 이터러블이란 [Symbol.iterator]() 메서드를 가진 객체입니다.
const arr = [1, 2, 3];
// for...of는 내부적으로 이렇게 동작합니다.
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 }두 가지 프로토콜이 있습니다.
이터러블 프로토콜 , [Symbol.iterator]() 메서드를 가지고, 이터레이터를 반환하는 객체
이터레이터 프로토콜 , next() 메서드를 가지고, { value, done } 형태의 객체를 반환하는 객체
내장 이터러블
JavaScript의 많은 내장 객체가 이터러블 프로토콜을 구현합니다.
// Array
for (const item of [1, 2, 3]) { /* 1, 2, 3 */ }
// String, 문자 단위로 순회
for (const char of "Hello") { /* "H", "e", "l", "l", "o" */ }
// Map, [key, value] 쌍으로 순회
const map = new Map([["a", 1], ["b", 2]]);
for (const [key, value] of map) { /* ["a",1], ["b",2] */ }
// Set, 값으로 순회
for (const value of new Set([1, 2, 3])) { /* 1, 2, 3 */ }
// arguments 객체
function example() {
for (const arg of arguments) { /* ... */ }
}
// NodeList (DOM)
// for (const el of document.querySelectorAll("div")) { /* ... */ }for...of 내부 동작
for...of는 이터레이터 프로토콜의 편의 문법입니다. 내부적으로는 다음과 같이 동작합니다.
const iterable = [10, 20, 30];
// 이것은
for (const value of iterable) {
console.log(value);
}
// 내부적으로 이것과 같습니다
const iter = iterable[Symbol.iterator]();
let result = iter.next();
while (!result.done) {
const value = result.value;
console.log(value);
result = iter.next();
}커스텀 이터러블 만들기
[Symbol.iterator]() 메서드를 구현하면 어떤 객체든 이터러블로 만들 수 있습니다.
const range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
},
};
},
};
for (const n of range) {
console.log(n); // 1, 2, 3, 4, 5
}
console.log([...range]); // [1, 2, 3, 4, 5]제너레이터
제너레이터 함수는 이터레이터를 쉽게 만드는 문법 입니다. function*으로 선언하고, yield로 값을 하나씩 내보냅니다.
function* count(from, to) {
for (let i = from; i <= to; i++) {
yield i;
}
}
const iter = count(1, 3);
iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: 3, done: false }
iter.next(); // { value: undefined, done: true }제너레이터 함수를 호출하면 제너레이터 객체 를 반환합니다. 이 객체는 이터러블이면서 동시에 이터레이터입니다, [Symbol.iterator]()가 자기 자신을 반환합니다.
const gen = count(1, 3);
gen[Symbol.iterator]() === gen; // true
// 따라서 for...of에 직접 사용 가능
for (const n of count(1, 5)) {
console.log(n); // 1, 2, 3, 4, 5
}yield와 실행 흐름
제너레이터의 핵심은 실행이 일시 정지되고 재개 된다는 점입니다. yield를 만나면 현재 상태를 보존한 채 정지하고, next()를 호출하면 멈췄던 곳에서 재개합니다.
function* conversation() {
const name = yield "이름이 뭐예요?";
const age = yield `${name}님, 나이가 어떻게 되세요?`;
return `${name}님은 ${age}살이시군요!`;
}
const talk = conversation();
talk.next(); // { value: "이름이 뭐예요?", done: false }
talk.next("Alice"); // { value: "Alice님, 나이가 어떻게 되세요?", done: false }
talk.next(30); // { value: "Alice님은 30살이시군요!", done: true }next(value)의 인자는 이전 yield 표현식의 반환값이 됩니다.
지연 평가
제너레이터는 값을 미리 계산하지 않고, 요청할 때마다 하나씩 생성합니다. 이것을 지연 평가 (lazy evaluation) 라고 합니다.
// 무한 시퀀스, 메모리를 차지하지 않음
function* fibonacci() {
let a = 0, b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
// 필요한 만큼만 가져옴
const fib = fibonacci();
fib.next().value; // 0
fib.next().value; // 1
fib.next().value; // 1
fib.next().value; // 2
fib.next().value; // 3
// 처음 10개만 배열로
function take(gen, n) {
const result = [];
for (const value of gen) {
result.push(value);
if (result.length >= n) break;
}
return result;
}
take(fibonacci(), 10); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]배열로 무한 피보나치 수열을 만드는 것은 불가능하지만, 제너레이터는 가능합니다. 메모리 사용량은 항상 O(1)입니다.
yield*로 위임하기
yield*는 다른 이터러블이나 제너레이터에 순회를 위임합니다.
function* inner() {
yield "a";
yield "b";
}
function* outer() {
yield 1;
yield* inner(); // inner 제너레이터에 위임
yield 2;
}
console.log([...outer()]); // [1, "a", "b", 2]이터러블이면 무엇이든 위임 가능합니다.
function* flatten(arr) {
for (const item of arr) {
if (Array.isArray(item)) {
yield* flatten(item); // 재귀 위임
} else {
yield item;
}
}
}
console.log([...flatten([1, [2, [3, 4]], 5])]); // [1, 2, 3, 4, 5]비동기 이터레이터와 for await...of
비동기 데이터 소스를 순회하려면 비동기 이터레이터 를 사용합니다. Symbol.asyncIterator와 async function*으로 구현합니다.
async function* fetchPages(baseUrl) {
let page = 1;
while (true) {
const res = await fetch(`${baseUrl}?page=${page}`);
const data = await res.json();
if (data.items.length === 0) return;
yield data.items;
page++;
}
}
// for await...of로 비동기 순회
async function getAllItems() {
const allItems = [];
for await (const items of fetchPages("/api/products")) {
allItems.push(...items);
}
return allItems;
}for await...of는 동기 이터러블도 소비할 수 있습니다. Promise를 yield하면 자동으로 await합니다.
function* asyncTasks() {
yield fetch("/api/user");
yield fetch("/api/orders");
yield fetch("/api/settings");
}
async function runAll() {
for await (const response of asyncTasks()) {
console.log(response.status); // 각 Promise가 resolve된 후 실행
}
}**주의:**이 패턴에서 세 fetch는 순차적으로 실행됩니다.
for await...of가next()를 호출할 때마다 다음yield가 실행되므로, 이전 fetch가 완료된 후 다음 fetch가 시작됩니다. 동시 실행이 필요하면Promise.all을 사용하세요.
실전에서의 제너레이터
제너레이터는 라이브러리 내부에서도 널리 활용됩니다. Redux-Saga는 제너레이터를 사용하여 Redux 앱의 비동기 사이드 이펙트 (API 호출, 데이터 캐싱 등) 를 관리합니다. yield로 이펙트를 선언적으로 기술하면, Saga 미들웨어가 이를 해석하고 실행합니다, 덕분에 복잡한 비동기 흐름을 동기 코드처럼 읽을 수 있고, 제너레이터를 단계별로 순회하며 테스트하기도 쉽습니다. 초기의 Koa.js (v1) 도 미들웨어 시스템에 제너레이터를 사용했지만, v2부터는 async/await 기반으로 전환되었습니다.
정리
| 개념 | 핵심 |
|---|---|
| 이터러블 | [Symbol.iterator]() 메서드를 가진 객체 |
| 이터레이터 | next() → { value, done } 을 반환하는 객체 |
| for...of | 이터레이터 프로토콜의 편의 문법 |
| 제너레이터 | function* + yield로 이터레이터를 쉽게 생성 |
| 지연 평가 | 값을 미리 계산하지 않고 요청 시 생성 |
| yield* | 다른 이터러블/제너레이터에 순회 위임 |
| 비동기 이터레이터 | Symbol.asyncIterator + async function* |
| for await...of | 비동기 이터러블 순회 문법 |