자바스크립트 중급 #3 이터레이터와 제너레이터

#2 비동기 입문 다음, 이번엔 자바스크립트의 또 다른 핵심 도구 — 이터레이터제너레이터를 정리합니다.

for...of는 어떻게 동작하는가? #

기초 #3 제어 흐름 에서 for...of로 배열을 순회했습니다.

for...of 다시 보기
for (const x of [1, 2, 3]) {
  console.log(x);
}

이게 배열에서만 동작하는 게 아닙니다. 자바스크립트는 객체가 일정한 약속(이터러블 프로토콜)을 따르면 모두 for...of로 순회할 수 있게 만들어 두었습니다.

이미 이터러블인 것들
for (const ch of 'hello') {       // 문자열
  console.log(ch);
}

for (const [k, v] of new Map([['a', 1], ['b', 2]])) {   // Map
  console.log(k, v);
}

for (const x of new Set([1, 2, 3])) {   // Set
  console.log(x);
}

배열, 문자열, Map, Set, 그리고 NodeList(document.querySelectorAll) 등이 이미 이터러블입니다. 모두 같은 약속을 따라요.

이터러블 프로토콜 #

객체가 이터러블이려면 다음 한 가지를 가지고 있어야 합니다.

Symbol.iterator 라는 키에 함수가 있어야 한다. 그 함수를 호출하면 next()를 가진 이터레이터를 반환해야 한다. next(){ value, done } 객체를 반환해야 한다.

직접 만들어 보면 이해가 빠릅니다.

이터러블 직접 만들기
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

Symbol.iterator가 핵심입니다. 키 위치에 [...]가 들어간 건 기초 #5의 계산된 속성 문법입니다. Symbol.iterator 라는 특별한 심볼을 키로 써요.

for...of는 내부적으로 이 약속을 따라 next()를 반복 호출하면서 donetrue가 될 때까지 값을 꺼내 갑니다.

Spread와 디스트럭처링도 이터러블 #

for...of 외에 spread와 배열 디스트럭처링도 이터러블 프로토콜을 사용합니다.

이터러블 활용
const arr = [...range];
// [1, 2, 3, 4, 5]

const [first, second] = range;
// first = 1, second = 2

Math.max(...range);   // 5
Array.from(range);    // [1, 2, 3, 4, 5]

이게 강력합니다 — 한 번 이터러블로 만들면 자바스크립트의 모든 시퀀스 도구를 그대로 쓸 수 있습니다.

제너레이터 — 이터러블을 짧게 만드는 문법 #

위에서 만든 range가 동작은 하지만, 이터러블 만드는 코드가 길고 복잡합니다. 자바스크립트에는 같은 일을 한 번에 해주는 문법이 있습니다 — 제너레이터.

제너레이터로 range
function* range(from, to) {
  for (let n = from; n <= to; n++) {
    yield n;
  }
}

for (const n of range(1, 5)) {
  console.log(n);
}
// 1, 2, 3, 4, 5

[...range(1, 5)];   // [1, 2, 3, 4, 5]

문법:

  • function* — 별표가 붙은 함수 → 제너레이터
  • yield — 한 값을 내어주고 멈춤
  • 다시 호출되면 yield 다음 줄부터 계속

제너레이터 함수를 호출하면 이터러블한 객체가 반환됩니다. for...of가 그 객체에서 값을 하나씩 꺼내 갑니다.

게으른 시퀀스 — 무한 시퀀스도 가능 #

제너레이터의 진짜 강점은 필요한 만큼만 만든다는 점입니다. 무한 시퀀스도 표현 가능합니다.

무한 자연수
function* naturals() {
  let n = 1;
  while (true) {
    yield n++;
  }
}

const gen = naturals();
gen.next();   // { value: 1, done: false }
gen.next();   // { value: 2, done: false }
gen.next();   // { value: 3, done: false }
// ... 영원히

while (true)가 무한 루프지만 멈추지 않습니다. next()를 부를 때마다 다음 값만 만듭니다. 호출 시점에만 계산 — 이게 게으름(lazy)의 의미입니다.

무한 시퀀스 잘라 쓰기 #

처음 N개만
function* take(iterable, n) {
  let count = 0;
  for (const x of iterable) {
    if (count >= n) return;
    yield x;
    count++;
  }
}

const first10 = [...take(naturals(), 10)];
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

이게 함수형 라이브러리들이 흔히 제공하는 take 함수의 정체입니다.

제너레이터의 다른 활용 #

1) 트리/그래프 순회 #

트리 순회
function* walk(node) {
  yield node.value;
  for (const child of node.children ?? []) {
    yield* walk(child);    // 다른 제너레이터 위임
  }
}

const tree = {
  value: 'root',
  children: [
    { value: 'a', children: [{ value: 'a1' }] },
    { value: 'b' },
  ],
};

for (const v of walk(tree)) {
  console.log(v);
}
// root, a, a1, b

yield*다른 이터러블의 값을 통째로 흘려보내는 문법. 재귀적인 순회를 깔끔하게 표현할 수 있습니다.

2) 단계별 진행하는 작업 #

다단계 작업
function* steps() {
  console.log('1단계 시작');
  yield;
  console.log('2단계 시작');
  yield;
  console.log('3단계 끝');
}

const s = steps();
s.next();   // 1단계 시작
s.next();   // 2단계 시작
s.next();   // 3단계 끝

호출자가 명시적으로 next()를 부르며 단계를 진행. 테스트나 시뮬레이션에서 종종 활용됩니다.

비동기 이터레이터 — for await...of #

비동기 데이터를 순회할 때 쓰는 변형. 스트리밍 데이터(파일, fetch 응답 chunk)를 다룰 때 만나요.

for await...of
async function readChunks(response) {
  for await (const chunk of response.body) {
    console.log(chunk);
  }
}

자세한 사용은 다음 #6 fetch API 에서 다룹니다.

Symbol이 무엇인가? #

Symbol.iterator가 갑자기 등장했는데, Symbol도 자바스크립트의 원시 타입 중 하나입니다. 유일한 식별자를 만들어주는 값입니다.

Symbol 기본
const s1 = Symbol('id');
const s2 = Symbol('id');

s1 === s2;   // false — 같은 설명이라도 다른 심볼

언어 차원의 키(Symbol.iterator, Symbol.asyncIterator 등)는 절대 충돌하지 않게 만들기 위해 Symbol로 정의되어 있습니다. 일반 코드에서는 자주 만들 일이 없지만, 라이브러리가 메타데이터를 객체에 붙일 때 충돌 없이 쓰는 경우가 종종 있습니다.

마무리 #

이번 글에서 정리한 내용:

  • for...of / spread / 디스트럭처링은 이터러블 프로토콜 위에서 동작
  • 이터러블이 되려면 Symbol.iteratornext()를 가진 이터레이터를 반환
  • 제너레이터(function*, yield)로 이터러블을 짧게 만들기
  • 게으른 시퀀스 — 무한 시퀀스도 표현 가능
  • yield*로 다른 이터러블 위임
  • for await...of로 비동기 이터러블 순회
  • Symbol은 충돌하지 않는 유일한 식별자

다음 글(#4 디스트럭처링과 spread/rest 깊이)에서는 기초에서 가볍게 다룬 디스트럭처링과 spread를 더 깊이 — 중첩 패턴, 매개변수 디스트럭처링, 동적 키 등 자주 만나는 실전 패턴들을 정리합니다.

X