Menu
Playground에서 시도하기

자바스크립트 이터레이터와 제너레이터 완벽 정리

자바스크립트 이터레이터 프로토콜의 동작 원리부터, 내 객체를 직접 iterable하게 만드는 방법, 그리고 제너레이터 함수로 이 모든 걸 훨씬 간단히 처리하는 법까지 정리했습니다.

이터레이터 프로토콜

자바스크립트에서 자주 쓰는 기능들 — for...of, 스프레드(...), 구조 분해, Array.from, Promise.all — 은 모두 하나의 공통된 구조를 기반으로 동작합니다. 바로 이터레이터 프로토콜이죠. 이 개념만 제대로 잡아두면, 위 기능들이 사실은 같은 원리의 변주라는 게 눈에 보이기 시작합니다.

이터레이터란 { value, done } 형태의 객체를 반환하는 next() 메서드를 가진 객체를 말합니다:

index.js
Output
Click Run to see the output here.

next()를 반복해서 호출하면 됩니다. 호출할 때마다 다음 값과 완료 플래그가 함께 반환되고, 완료true가 되는 순간 시퀀스가 끝납니다. 이터레이터 프로토콜은 이게 전부입니다 — 메서드 하나와 불리언 값 하나면 끝이죠.

iterable과 iterator의 차이

여기에 짝을 이루는 또 다른 개념이 있습니다. 바로 iterable인데요, 이터레이터를 만들어 낼 줄 아는 모든 것을 말합니다. 이 능력은 Symbol.iterator라는 특별한 키에 저장된 메서드를 통해 제공됩니다.

index.js
Output
Click Run to see the output here.

배열은 이터러블(iterable)입니다. numbers[Symbol.iterator]()를 호출하면 새로운 이터레이터가 반환되죠. 문자열, Map, Set, arguments 역시 모두 이터러블이며, 바로 그 이유 때문에 이들 모두에서 for...of가 동작하는 겁니다.

여기서 이 구분이 중요합니다. 이터러블(iterable) 은 컬렉션 그 자체이고, 이터레이터(iterator) 는 그 안을 훑어가는 커서라고 보면 됩니다. 하나의 이터러블에서 독립적인 커서를 원하는 만큼 얼마든지 꺼내 쓸 수 있습니다.

for...of가 동작하는 원리

for...of는 사실 이터레이터 프로토콜 위에 얹힌 문법적 설탕(syntactic sugar)일 뿐입니다. 내부적으로는 Symbol.iterator를 호출한 다음, 완료true가 될 때까지 next()를 반복해서 부르는 구조죠:

index.js
Output
Click Run to see the output here.

스프레드와 구조 분해 할당도 결국 같은 일을 합니다. 이터레이터가 끝날 때까지 쭉 돌면서 값을 꺼내는 거죠:

index.js
Output
Click Run to see the output here.

Symbol.iterator만 구현해두면, 직접 만든 객체도 위에서 소개한 모든 기능을 공짜로 누릴 수 있습니다.

커스텀 iterable 만들기

start부터 end까지의 숫자를 뱉어내는 range 객체를 한번 만들어 보겠습니다.

index.js
Output
Click Run to see the output here.

몇 가지 짚고 넘어갈 점이 있습니다:

  • [Symbol.iterator]()계산된 메서드 이름(computed method name) 을 사용합니다. 키는 심볼 그 자체이지 "Symbol.iterator"라는 문자열이 아닙니다.
  • [Symbol.iterator]()를 호출할 때마다 각자 고유한 current를 가진 새로운 이터레이터가 반환됩니다. 덕분에 range를 "다 써버리지" 않고 두 번 반복할 수 있는 거죠.
  • 반환된 이터레이터에는 next()만 있으면 됩니다. 그게 전부예요.

동작은 하지만 코드가 장황합니다. 훨씬 더 좋은 방법이 있어요.

제너레이터 등장

제너레이터 함수function*로 선언합니다(별표에 주목하세요). 이 함수는 끝까지 쭉 실행되는 대신, yield 표현식에서 잠시 멈췄다가 나중에 다시 이어서 실행할 수 있습니다. 제너레이터 함수를 호출해도 함수 본문이 바로 실행되지는 않고, 이터레이터이자 iterable인 제너레이터 객체를 돌려줍니다:

index.js
Output
Click Run to see the output here.

next()를 한 번 호출할 때마다 함수 본문이 yield를 만날 때까지 실행되고, 거기서 잠시 멈춘 뒤 { value, done: false }를 돌려줍니다. 함수가 끝까지 실행되면 { value: undefined, done: true }가 반환되죠.

그리고 제너레이터는 그 자체로 iterable이기 때문에, 앞 절에서 다뤘던 기능들과 그대로 어울려 동작합니다:

index.js
Output
Click Run to see the output here.

제너레이터로 range 다시 만들기

앞에서 봤던 장황한 버전과 아래 코드를 비교해 보자:

index.js
Output
Click Run to see the output here.

끝이에요. [Symbol.iterator] 앞에 붙은 *가 이 메서드를 제너레이터로 만들어 줍니다. yield i 한 줄이 손으로 짜던 이터레이터 객체 전체를 대신하죠. next도, 완료도, off-by-one 걱정도 없어요. 그냥 평범한 반복문에서 push 대신 yield를 쓰는 것뿐입니다.

자바스크립트 제너레이터가 존재하는 이유가 바로 이겁니다. "이터레이터를 직접 구현하기"를 "yield하는 함수 하나 쓰기"로 바꿔 주거든요.

yield vs return

yield는 일시정지, return은 종료입니다. yield는 원하는 만큼 여러 번 써도 되고, 제너레이터는 멈췄던 자리에서 다시 이어서 실행됩니다:

index.js
Output
Click Run to see the output here.

제너레이터 안에서 return을 쓰면, 그 순간 호출 결과가 { value: "done", done: true } 형태로 나옵니다. 그런데 for...of나 스프레드 연산자는 이 반환값을 무시합니다. 완료false인 항목만 소비하거든요. 그래서 return value로 마지막 값을 루프에 슬쩍 끼워 넣으려고 하면 안 됩니다. 그냥 건너뛰어져요.

지연 평가와 무한 시퀀스

제너레이터는 값을 필요할 때마다 하나씩 만들어 냅니다. 덕분에 배열로는 도저히 표현할 수 없는 시퀀스도 다룰 수 있죠.

index.js
Output
Click Run to see the output here.

반복문이 말 그대로 while (true)인데도 프로그램이 멈춥니다. 제너레이터는 누군가 다음 값을 요청할 때만 한 스텝씩 진행되기 때문이죠. 앞에서 N개만 꺼내 쓰고 멈추면, 나머지 코드는 아예 실행되지 않습니다:

index.js
Output
Click Run to see the output here.

take 자체도 또 다른 제너레이터를 감싸는 제너레이터다. 이렇게 제너레이터끼리 조합해서 쓸 수 있다는 점이 큰 매력이다. 작은 조각들이 각자 한 가지 일만 담당하는 식이다.

yield*로 다른 제너레이터에 위임하기

어떤 제너레이터가 다른 iterable의 값을 전부 그대로 내보내야 한다면, yield*를 써서 위임할 수 있다:

index.js
Output
Click Run to see the output here.

yield*는 배열, Set, 다른 제너레이터 등 어떤 iterable과도 함께 쓸 수 있으며, 각 항목을 하나씩 차례로 내보냅니다. 스프레드 연산자의 이터레이터 버전이라고 생각하면 됩니다.

비동기 제너레이터 간단히 살펴보기

async function*으로 선언한 제너레이터는 시간이 걸리는 값도 yield할 수 있습니다. API 스트리밍을 받거나 파일을 청크 단위로 읽어올 때 유용하죠. 이렇게 만든 제너레이터는 for await...of로 소비합니다:

async function* paginate(url) {
  let next = url;
  while (next) {
    const res = await fetch(next);
    const page = await res.json();
    for (const item of page.items) yield item;
    next = page.nextUrl;
  }
}

for await (const item of paginate("/api/users")) {
  console.log(item);
}

이 코드 조각은 실제 엔드포인트가 필요하기 때문에 여기서 바로 실행할 수는 없지만, 이런 형태가 존재한다는 것 정도는 알아두면 좋습니다. 일반 제너레이터를 이해하고 나면, 비동기 제너레이터는 거기에 await만 적절히 뿌려놓은 것이라고 보면 됩니다.

제너레이터를 언제 써야 할까?

다음과 같은 상황이라면 제너레이터가 딱입니다.

  • 시퀀스가 무한하거나 무한할 수도 있는 경우 — ID, 타임스탬프, 재시도 대기 시간 같은 것들이 대표적입니다.
  • 모든 값을 만들어내는 비용이 비싼데, 소비자 쪽에서 도중에 멈출 수도 있는 경우.
  • 커스텀 객체에 Symbol.iterator를 직접 구현할 때. { next() } 객체를 손으로 짜는 것보다 거의 항상 짧게 끝납니다.
  • 중간 배열을 만들지 않고 take, filter, map 같은 스트리밍 변환을 조합하고 싶을 때.

반대로 데이터가 이미 메모리에 올라와 있고 크기도 작다면 그냥 일반 배열을 쓰는 게 낫습니다. 제너레이터도 공짜는 아니거든요. 함수를 일시 중단하고 재개하는 내부 구조에는 오버헤드가 있고, 제너레이터를 거치는 스택 트레이스는 읽기가 더 까다로울 수 있습니다.

다음: 심볼

Symbol.iterator는 대부분의 사람들이 가장 먼저 만나게 되는 심볼이지만, 결코 유일한 심볼은 아닙니다. 심볼은 바로 이런 확장 포인트를 위해 설계된 원시 타입입니다 — 일반 프로퍼티 이름과 충돌하지 않으면서 언어 자체나 여러분의 코드가 객체에 훅을 걸 수 있게 해주는 고유한 키죠. 이 내용은 다음 페이지에서 이어집니다.

자주 묻는 질문

자바스크립트에서 iterable과 iterator는 뭐가 다른가요?

iterable은 Symbol.iterator 메서드를 가지고 있고, 이걸 호출하면 iterator를 돌려주는 객체예요. 반면 iterator는 실제로 값을 뽑아내는 주체인데, next() 메서드를 호출하면 { value, done } 형태의 객체를 반환하죠. 배열, 문자열, Map, Set은 모두 iterable이고, 이 객체들의 Symbol.iterator를 호출하면 하나씩 돌릴 수 있는 iterator가 나옵니다.

제너레이터 함수(generator function)란 무엇인가요?

function* 키워드로 선언하고 yield로 값을 하나씩 게으르게(lazy) 내보내는 함수입니다. 일반 함수와 달리 호출해도 바로 본문이 실행되지 않고, iterator이자 iterable인 제너레이터 객체를 돌려줘요. next()를 부를 때마다 다음 yield까지 실행되고 거기서 멈춰서 값을 반환합니다.

제너레이터에서 yield와 return은 어떻게 다른가요?

yield는 값을 내보내면서 잠깐 일시정지하는 거라, 다음 next() 호출 때 멈췄던 위치에서 다시 이어서 실행됩니다. 반면 return은 제너레이터를 완전히 끝내버려요. done: true가 되고 그 뒤로는 어떤 값도 나오지 않습니다. 즉, yield는 여러 번 써도 되지만 return은 의미 있게는 한 번뿐이에요.

배열 대신 제너레이터를 써야 할 때는 언제인가요?

시퀀스가 무한하거나, 계산 비용이 크거나, 값 중 일부만 필요한 경우에 적합합니다. 제너레이터는 필요할 때마다 값을 하나씩 생성하기 때문에, 끝없이 이어지는 ID 스트림이나 페이지네이션 API 결과 같은 걸 미리 전부 만들어두지 않고도 표현할 수 있어요. 반대로 이미 작고 고정된 배열이 있다면 그냥 배열을 쓰는 게 낫습니다.

Coddy로 코딩 배우기

시작하기