배열은 for...of로 순회할 수 있는데, 일반 객체({})는 왜 안 될까요?

for...of가 동작하려면 객체가 이터러블 프로토콜 을 구현해야 합니다. 배열, 문자열, Map, Set은 이 프로토콜을 기본 구현하지만, 일반 객체는 구현하지 않습니다.

이터레이션 프로토콜 — 두 가지 규약

자바스크립트에는 반복과 관련된 두 가지 프로토콜이 있습니다.

1. 이터러블 프로토콜 (Iterable Protocol)

객체가 Symbol.iterator 메서드를 가지고 있으면 이터러블 입니다. 이 메서드는 이터레이터 를 반환해야 합니다.

2. 이터레이터 프로토콜 (Iterator Protocol)

next() 메서드를 가진 객체가 이터레이터 입니다. next(){ value, done } 형태의 객체를 반환합니다.

JS
// 배열은 이터러블이다
const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator](); // 이터레이터 획득

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

for...of의 동작 원리

for...of는 내부적으로 이 프로토콜을 사용합니다.

JS
// 이 코드는
for (const item of [1, 2, 3]) {
  console.log(item);
}

// 대략 이렇게 동작합니다
const iterable = [1, 2, 3];
const iterator = iterable[Symbol.iterator]();
let result = iterator.next();

while (!result.done) {
  console.log(result.value);
  result = iterator.next();
}

내장 이터러블 목록

  • Array
  • String
  • Map
  • Set
  • TypedArray
  • arguments
  • NodeList

일반 객체({})는 이터러블이 아닙니다. 그래서 for...of로 순회할 수 없습니다.

JS
const obj = { a: 1, b: 2 };
// for (const item of obj) {} // TypeError: obj is not iterable

// 객체를 순회하려면
for (const key of Object.keys(obj)) {
  console.log(key, obj[key]);
}

커스텀 이터러블 만들기

Symbol.iterator를 직접 구현하면 어떤 객체든 이터러블로 만들 수 있습니다.

JS
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 num of range) {
  console.log(num); // 1, 2, 3, 4, 5
}

// 스프레드 연산자도 이터러블에서 동작
console.log([...range]); // [1, 2, 3, 4, 5]

// 구조 분해 할당도
const [first, second] = range;
console.log(first, second); // 1, 2

이터러블 프로토콜을 구현하면 for...of, 스프레드 연산자(...), 구조 분해 할당, Array.from() 등에서 모두 사용할 수 있습니다.

제너레이터 — 이터레이터를 쉽게 만드는 방법

커스텀 이터러블을 직접 구현하면 코드가 길어집니다. 제너레이터 함수를 쓰면 훨씬 간결합니다.

JS
function* rangeGenerator(from, to) {
  for (let i = from; i <= to; i++) {
    yield i;
  }
}

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

제너레이터 함수의 핵심

  • function* 키워드로 선언
  • yield로 값을 하나씩 반환하며 실행을 일시 중지
  • 호출하면 제너레이터 객체(이터레이터)를 반환
  • next()를 호출할 때마다 다음 yield까지 실행
JS
function* greet() {
  console.log('시작');
  yield '안녕';
  console.log('중간');
  yield '하세요';
  console.log('끝');
  return '완료';
}

const gen = greet();
// 여기서는 아무것도 실행되지 않음

console.log(gen.next());
// "시작" 출력
// { value: '안녕', done: false }

console.log(gen.next());
// "중간" 출력
// { value: '하세요', done: false }

console.log(gen.next());
// "끝" 출력
// { value: '완료', done: true }

yield로 값 전달받기

next()에 인자를 넘기면 yield 표현식의 결과값이 됩니다.

JS
function* calculator() {
  const a = yield '첫 번째 숫자를 입력하세요';
  const b = yield '두 번째 숫자를 입력하세요';
  return a + b;
}

const calc = calculator();
console.log(calc.next());      // { value: '첫 번째 숫자를 입력하세요', done: false }
console.log(calc.next(10));    // { value: '두 번째 숫자를 입력하세요', done: false }
console.log(calc.next(20));    // { value: 30, done: true }

첫 번째 next() 호출의 인자는 무시됩니다. yield 표현식이 아직 없기 때문입니다.

yield* — 위임

yield*는 다른 이터러블에 반복을 위임합니다.

JS
function* inner() {
  yield 'a';
  yield 'b';
}

function* outer() {
  yield 1;
  yield* inner(); // inner 제너레이터에 위임
  yield 2;
}

console.log([...outer()]); // [1, 'a', 'b', 2]

재귀적 데이터 구조를 순회할 때 특히 유용합니다.

JS
// 트리 구조를 평탄하게 순회
function* flattenTree(node) {
  yield node.value;
  if (node.children) {
    for (const child of node.children) {
      yield* flattenTree(child);
    }
  }
}

const tree = {
  value: 1,
  children: [
    { value: 2, children: [{ value: 4 }, { value: 5 }] },
    { value: 3 },
  ],
};

console.log([...flattenTree(tree)]); // [1, 2, 4, 5, 3]

무한 시퀀스

제너레이터는 필요한 만큼만 계산하므로 무한 시퀀스를 표현할 수 있습니다.

JS
// 무한 피보나치 수열
function* fibonacci() {
  let prev = 0;
  let curr = 1;
  while (true) {
    yield curr;
    [prev, curr] = [curr, prev + curr];
  }
}

// 처음 10개만 가져오기
function take(n, iterable) {
  const result = [];
  for (const item of iterable) {
    result.push(item);
    if (result.length >= n) break;
  }
  return result;
}

console.log(take(10, fibonacci()));
// [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
JS
// 무한 ID 생성기
function* idGenerator(prefix = 'id') {
  let id = 1;
  while (true) {
    yield `${prefix}_${id++}`;
  }
}

const nextId = idGenerator('user');
console.log(nextId.next().value); // "user_1"
console.log(nextId.next().value); // "user_2"

무한 루프지만 yield에서 멈추기 때문에 메모리 문제가 없습니다. lazy evaluation의 좋은 예시입니다.

제너레이터의 return()과 throw()

제너레이터는 next() 외에 return()throw() 메서드도 있습니다.

JS
function* gen() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    console.log('정리 작업');
  }
}

const g = gen();
console.log(g.next());    // { value: 1, done: false }
console.log(g.return(99)); // "정리 작업" 출력, { value: 99, done: true }
console.log(g.next());    // { value: undefined, done: true }

return()을 호출하면 제너레이터가 종료됩니다. finally 블록이 있으면 실행됩니다.

JS
const g2 = gen();
g2.next();
g2.throw(new Error('외부 에러')); // 제너레이터 내부에서 에러 발생

Async Generator — 비동기 반복

비동기 데이터 스트림을 다룰 때 async generator를 사용합니다.

JS
async function* fetchPages(url) {
  let page = 1;
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.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);
    console.log(`${items.length}개 로딩됨`);
  }
  return allItems;
}

async 이터러블 프로토콜

JS
const asyncIterable = {
  [Symbol.asyncIterator]() {
    let i = 0;
    return {
      async next() {
        if (i < 3) {
          // 비동기 작업을 시뮬레이션
          await new Promise(resolve => setTimeout(resolve, 100));
          return { value: i++, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
};

for await (const value of asyncIterable) {
  console.log(value); // 0, 1, 2 (각각 100ms 간격)
}

Symbol.asyncIteratorfor await...of는 비동기 이터레이션을 위한 프로토콜입니다.

실무에서의 활용

페이지네이션 자동화

JS
async function* paginate(fetchFn) {
  let cursor = null;
  while (true) {
    const { data, nextCursor } = await fetchFn(cursor);
    yield* data; // 각 항목을 하나씩 yield
    if (!nextCursor) return;
    cursor = nextCursor;
  }
}

스트림 처리 파이프라인

JS
function* filter(iterable, predicate) {
  for (const item of iterable) {
    if (predicate(item)) yield item;
  }
}

function* map(iterable, transform) {
  for (const item of iterable) {
    yield transform(item);
  }
}

// 파이프라인 조합
const numbers = rangeGenerator(1, 100);
const evenSquares = map(
  filter(numbers, n => n % 2 === 0),
  n => n * n
);

console.log(take(5, evenSquares)); // [4, 16, 36, 64, 100]

주의할 점

제너레이터의 첫 번째 next() 인자는 무시된다

gen.next(value)에서 value는 이전 yield 표현식의 결과값이 됩니다. 첫 번째 next() 호출 시에는 아직 yield가 없으므로 인자가 무시됩니다. 이 동작을 모르면 값 전달 로직에서 버그가 생깁니다.

무한 제너레이터에서 break/return 없이 스프레드하면 위험

[...infiniteGenerator()]처럼 무한 제너레이터를 스프레드하면 무한 루프에 빠집니다. 무한 시퀀스는 반드시 take() 같은 제한 함수와 함께 사용해야 합니다.

for...of에서 return()이 호출되는 시점

for...of 루프에서 breakthrow로 조기 종료하면, 제너레이터의 return() 메서드가 자동으로 호출됩니다. finally 블록이 있으면 이때 실행되므로, 리소스 정리 코드를 finally에 넣을 수 있습니다.

정리

항목설명
이터러블Symbol.iterator 메서드를 가진 객체
이터레이터next() 메서드를 가진 객체, { value, done } 반환
for...of내부적으로 이터레이터 프로토콜 사용
제너레이터function*로 선언, yield로 실행 중지/재개
yield*다른 이터러블에 반복을 위임
async generatorasync function* + for await...of로 비동기 스트림 처리
댓글 로딩 중...