Ray Book
JavaScript Deep Dive

이터레이터와 제너레이터

Symbol.iterator부터 제너레이터까지, 이터레이터 프로토콜의 동작 원리와 지연 평가를 시각화합니다

javascriptiteratorgeneratorsymbollazy-evaluation

이터러블과 이터레이터

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 } 형태의 객체를 반환하는 객체

코드
1const arr = [1, 2, 3];
2const iter = arr[Symbol.iterator]();
3 
4iter.next(); // { value: 1, done: false }
5iter.next(); // { value: 2, done: false }
6iter.next(); // { value: 3, done: false }
7iter.next(); // { value: undefined, done: true }
이터레이터 상태
시퀀스
1
2
3
end
next() 반환값
아직 next() 호출 전
배열의 [Symbol.iterator]() 메서드를 호출하면 이터레이터 객체를 반환합니다. 이터레이터의 커서는 첫 번째 요소 앞에 위치합니다.

내장 이터러블

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
}
코드
1function* gen() {
2 console.log("시작");
3 yield 10;
4 console.log("재개");
5 yield 20;
6 console.log("끝");
7 return 30;
8}
9 
10const g = gen();
11g.next(); // { value: 10, done: false }
12g.next(); // { value: 20, done: false }
13g.next(); // { value: 30, done: true }
이터레이터 상태
시퀀스
10
20
30
end
next() 반환값
아직 next() 호출 전
제너레이터 상태suspended
gen()을 호출하면 제너레이터 객체가 반환되지만, 함수 본문은 아직 실행되지 않습니다. 제너레이터는 suspended 상태로 시작합니다.

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.asyncIteratorasync 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...ofnext()를 호출할 때마다 다음 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비동기 이터러블 순회 문법