2025-09-22 23:35
-
자바스크립트 제너레이터는 함수의 실행을 중간에 멈추고 원하는 시점에 다시 시작할 수 있는 특별한 함수다.
-
yield키워드를 통해 실행을 멈추고 값을 반환하며,next()메서드로 멈춘 지점부터 실행을 재개한다. -
제너레이터는 비동기 처리를 동기 코드처럼 작성하게 해주어
async/await문법의 기반이 되었고, 이터레이터, 상태 관리 등 다방면에 활용된다.
자바스크립트 제너레이터 완벽 정복 핸드북 비동기 프로그래밍의 숨은 보석
자바스크립트로 비동기 프로그래밍을 하다 보면 콜백 지옥이나 프로미스 체이닝의 복잡함에 부딪히곤 한다. 이때 async/await는 마치 마법처럼 코드를 간결하게 만들어준다. 하지만 이 마법의 뿌리를 거슬러 올라가면 ES6에서 등장한 **제너레이터(Generator)**라는 숨은 보석을 발견하게 된다. 제너레이터는 단순히 async/await의 전신이 아니다. 자바스크립트의 실행 흐름을 자유자재로 제어할 수 있는 강력한 도구이며, 그 원리를 이해하면 자바스크립트를 한 차원 더 깊게 바라볼 수 있게 된다.
이 핸드북은 제너레이터가 왜 탄생했는지부터 시작하여, 그 구조와 작동 원리, 실전 사용법, 그리고 async/await와의 관계까지 모든 것을 상세하게 파헤친다. 제너레이터라는 강력한 무기를 당신의 것으로 만들어보자.
1. 제너레이터의 탄생 왜 필요했을까?
모든 기술의 등장은 ‘필요’라는 어머니에게서 태어난다. 제너레이터 역시 기존 자바스크립트가 가진 몇 가지 근본적인 한계를 해결하기 위해 탄생했다.
함수의 독재적 실행권
일반적인 자바스크립트 함수는 ‘Run-to-Completion’ 모델을 따른다. 일단 함수가 호출되면, 그 함수의 코드는 중간에 멈춤 없이 끝까지 실행되어야만 제어권을 호출자에게 반환한다. 우리는 함수 중간에 잠시 멈춰서 다른 작업을 하다가 다시 돌아와 나머지 코드를 실행할 수 없었다.
이는 특히 비동기 작업을 처리할 때 큰 불편함을 야기했다.
JavaScript
function taskA() {
console.log('A 작업 시작');
// 비동기 작업 (e.g., API 요청)
setTimeout(() => {
console.log('A 작업 끝');
taskB(); // A가 끝나야 B를 실행할 수 있음
}, 1000);
}
function taskB() {
console.log('B 작업 시작');
}
taskA();
위 코드에서 taskA와 taskB는 분리되어 있으며, 실행 순서를 보장하기 위해 taskA의 콜백 함수 안에서 taskB를 호출해야만 했다. 이런 구조가 여러 개 겹치면 그 유명한 **콜백 지옥(Callback Hell)**이 펼쳐진다.
프로미스(Promise)가 등장하여 콜백 지옥을 어느 정도 해결했지만, 여전히 .then() 체이닝으로 코드의 깊이가 깊어지고 가독성이 떨어지는 문제는 남아있었다. 개발자들은 생각했다. “함수 실행을 내가 원하는 시점에 잠시 멈췄다가, 원하는 데이터를 가지고 다시 시작할 수는 없을까?”
이터레이션의 새로운 요구
배열이나 문자열처럼 순회 가능한 자료 구조를 **이터러블(Iterable)**이라고 한다. ES6에서는 이터레이션 프로토콜이 정립되면서 for...of 루프나 스프레드 문법(...)을 통해 커스텀 객체도 순회할 수 있게 되었다. 하지만 이터레이터를 직접 구현하는 과정은 꽤 번거로웠다.
JavaScript
const myIterable = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { done: false, value: current++ };
} else {
return { done: true };
}
}
};
}
};
for (const value of myIterable) {
console.log(value); // 1, 2, 3, 4, 5
}
상태(current, last)를 외부에서 관리하고, next() 메서드가 매번 { value, done } 객체를 반환하는 정형화된 코드를 작성해야 했다. “이런 반복적인 작업을 더 우아하게 처리할 방법은 없을까?”
이 두 가지 고민, 즉 **‘함수 실행 제어’**와 **‘간결한 이터레이터 생성’**에 대한 해답으로 제너레이터가 등장했다.
2. 제너레이터란 무엇인가? 핵심 개념 파헤치기
**제너레이터(Generator)**는 **‘실행을 시작하고 멈출 수 있는 함수(Pausable Function)‘**이자, **‘이터레이터(Iterator)를 반환하는 함수’**다.
마치 DVD 플레이어와 같다. 일반 함수가 영화를 처음부터 끝까지 한번에 상영하는 것이라면, 제너레이터는 우리가 원할 때 재생(▶️)하고, 일시정지(⏸️)하며, 멈춘 지점부터 다시 재생할 수 있는 제어권을 가진 DVD 플레이어와 같다.
제너레이터의 핵심 구성 요소는 다음과 같다.
| 용어 | 설명 | 비유 |
|---|---|---|
제너레이터 함수 (function*) | function 키워드 뒤에 별표(*)를 붙여 선언. | 특별한 기능이 내장된 DVD 플레이어 |
yield 키워드 | 함수의 실행을 멈추고 값을 외부로 전달(반환)한다. return과 비슷하지만, 함수를 종료시키지 않는다. | DVD의 ‘일시정지(⏸️)’ 버튼 |
| 제너레이터 객체 | 제너레이터 함수를 호출했을 때 반환되는 객체. next() 메서드를 가지고 있다. | DVD 플레이어의 리모컨 |
next() 메서드 | 제너레이터의 실행을 재개한다. 멈춰있던 yield 부분부터 다음 yield나 return을 만날 때까지 실행된다. | 리모컨의 ‘재생(▶️)’ 버튼 |
기본 문법과 동작
가장 간단한 제너레이터 예제를 통해 눈으로 확인해보자.
JavaScript
// 1. 제너레이터 함수 선언
function* generatorFunction() {
console.log('첫 번째 실행');
yield 1; // 여기서 멈춤
console.log('두 번째 실행');
yield 2; // 여기서 멈춤
console.log('세 번째 실행');
return 3; // 여기서 종료
}
// 2. 제너레이터 객체 생성
const generator = generatorFunction();
// 3. next() 메서드 호출
console.log(generator.next());
// 출력:
// 첫 번째 실행
// { value: 1, done: false }
console.log(generator.next());
// 출력:
// 두 번째 실행
// { value: 2, done: false }
console.log(generator.next());
// 출력:
// 세 번째 실행
// { value: 3, done: true }
console.log(generator.next());
// 출력:
// { value: undefined, done: true }
동작 순서를 자세히 살펴보자.
-
generatorFunction()을 호출했지만, 함수 내부의console.log는 실행되지 않는다. 대신 제어권을 가진 제너레이터 객체(generator)만 반환된다. -
첫 번째
generator.next()를 호출하자, 함수가 실행되기 시작하여 ‘첫 번째 실행’이 출력되고,yield 1을 만나는 순간 실행을 멈춘다. 그리고{ value: 1, done: false }객체를 반환한다.value는yield뒤의 값,done은 함수가 끝났는지를 나타내는 플래그다. -
두 번째
generator.next()를 호출하면, 멈췄던 바로 그yield 1다음 줄부터 실행을 재개한다. ‘두 번째 실행’이 출력되고yield 2에서 다시 멈춘다. 그리고{ value: 2, done: false }를 반환한다. -
세 번째
generator.next()호출 시,return 3을 만나 함수가 종료된다.return값은value에 담기고, 함수가 종료되었으므로done은true가 된다. -
이미 종료된 제너레이터에
next()를 다시 호출하면, 항상{ value: undefined, done: true }를 반환한다.
이처럼 제너레이터는 함수 코드 블록을 여러 개의 실행 조각으로 나누고, next() 호출을 통해 각 조각을 순차적으로 실행할 수 있는 강력한 제어 메커니즘을 제공한다.
3. 제너레이터 구조와 작동 원리
제너레이터의 진정한 힘은 yield와 next()를 통한 양방향 통신에서 드러난다. 제너레이터는 값을 밖으로 내보낼(yield) 수 있을 뿐만 아니라, 밖에서 안으로 값을 받아올 수도 있다.
next(value)를 통한 값 주입
next() 메서드는 인자를 받을 수 있다. 이 인자는 제너레이터 내부에서 이전 yield 표현식의 반환값이 된다. 말이 조금 어려우니 코드로 살펴보자.
JavaScript
function* dialogue() {
const name = yield '이름이 무엇인가요?'; // 1. 멈춤
console.log(`반갑습니다, ${name}님.`);
const hobby = yield `${name}님의 취미는 무엇인가요?`; // 2. 멈춤
console.log(`${hobby}은(는) 멋진 취미네요.`);
return '대화 종료';
}
const convo = dialogue();
// 첫 번째 next()는 값을 주입할 '지난 yield'가 없으므로 인자가 무의미하다.
const question1 = convo.next().value;
console.log(question1); // '이름이 무엇인가요?'
// 두 번째 next()에 '제미니'를 주입한다.
const question2 = convo.next('제미니').value;
// (내부) name 변수에 '제미니'가 할당됨
// (내부) '반갑습니다, 제미니님.' 출력
console.log(question2); // '제미니님의 취미는 무엇인가요?'
// 세 번째 next()에 '코딩'을 주입한다.
const final = convo.next('코딩').value;
// (내부) hobby 변수에 '코딩'이 할당됨
// (내부) '코딩은(는) 멋진 취미네요.' 출력
console.log(final); // '대화 종료'
이 양방향 통신은 매우 중요하다. 제너레이터 외부의 코드(호출자)가 제너레이터 내부의 흐름에 영향을 줄 수 있게 되기 때문이다. 이는 비동기 작업의 결과를 제너레이터에 전달하여 다음 작업을 진행하게 하는 핵심 원리가 된다.
제너레이터의 생명주기
제너레이터 객체는 세 가지 상태를 가진다.
-
Suspended Start: 제너레이터 함수가 막 호출되어 제너레이터 객체가 생성된 직후의 상태. 코드는 아직 실행되지 않았다.
-
Suspended Yield:
yield표현식을 만나 실행이 중단된 상태.next()가 호출되기를 기다린다. -
Closed: 제너레이터 함수 내부에서
return문이 실행되거나, 에러가 발생했거나, 외부에서generator.return()이 호출되어 실행이 완전히 종료된 상태.
이러한 상태 전이를 통해 제너레이터는 자신의 실행 컨텍스트(스코프, 변수 등)를 yield를 만날 때마다 저장해두었다가 next() 호출 시 복원하여 실행을 이어간다.
4. 실전! 제너레이터 사용법 A to Z
이론을 알았으니 이제 제너레이터를 실제 코드에서 어떻게 활용할 수 있는지 알아보자.
무한 이터레이터 만들기
제너레이터는 상태를 내부적으로 유지하기 때문에 무한한 데이터 스트림을 만드는 데 매우 유용하다. 예를 들어, 무한히 증가하는 ID를 생성하는 제너레이터를 만들어보자.
JavaScript
function* createIdGenerator() {
let id = 1;
while (true) {
yield id++;
}
}
const idMaker = createIdGenerator();
console.log(idMaker.next().value); // 1
console.log(idMaker.next().value); // 2
console.log(idMaker.next().value); // 3
// ... 필요할 때마다 새로운 ID를 무한정 생성할 수 있다.
while(true) 루프를 사용했지만 프로그램이 멈추지 않는다. yield에서 실행이 멈추기 때문이다. 이는 필요한 만큼만 데이터를 생성하므로 메모리 측면에서도 매우 효율적이다.
yield*를 이용한 제너레이터 위임
yield* 표현식은 다른 제너레이터나 이터러블 객체에게 실행을 위임한다. 즉, 위임받은 제너레이터가 값을 모두 소진할 때까지 yield*에서 실행이 계속된다.
JavaScript
function* generatorA() {
yield 'A1';
yield 'A2';
}
function* generatorB() {
yield 'B1';
yield* generatorA(); // generatorA에게 실행을 위임
yield 'B2';
}
const genB = generatorB();
for (const val of genB) {
console.log(val);
}
// 출력:
// B1
// A1
// A2
// B2
yield*는 여러 제너레이터를 조합하여 복잡한 순차 작업을 처리하거나, 배열과 같은 기존 이터러블의 값을 제너레이터 흐름 중간에 끼워 넣을 때 유용하게 사용된다.
에러 처리와 종료
제너레이터는 외부에서 에러를 주입하거나(throw()), 실행을 강제로 종료(return())할 수도 있다.
-
generator.throw(error): 제너레이터 내부에서 에러를 발생시킨다.try...catch문으로 잡을 수 있다. -
generator.return(value): 제너레이터를 즉시 종료시키고,{ value, done: true }를 반환한다.finally블록이 있다면 실행된다.
JavaScript
function* errorAndFinally() {
try {
yield 1;
yield 2;
} catch (e) {
console.log('에러를 잡았습니다:', e);
} finally {
console.log('제너레이터가 종료됩니다.');
}
yield 3; // 실행되지 않음
}
const gen = errorAndFinally();
console.log(gen.next()); // { value: 1, done: false }
// 1. 에러 주입
// console.log(gen.throw(new Error('테스트 에러')));
// 출력:
// 에러를 잡았습니다: Error: 테스트 에러
// 제너레이터가 종료됩니다.
// { value: undefined, done: true }
// 2. 강제 종료
console.log(gen.return('강제 리턴'));
// 출력:
// 제너레이터가 종료됩니다.
// { value: '강제 리턴', done: true }
이러한 제어 메커니즘은 비동기 작업 중 발생한 에러를 제너레이터 내부에서 처리하거나, 특정 조건에서 작업을 깔끔하게 정리하고 싶을 때 유용하다.
5. 제너레이터 심화 탐구: 비동기 처리의 진화
제너레이터의 가장 중요한 역사적 의의는 바로 비동기 코드를 동기적인 방식으로 작성할 수 있는 길을 열었다는 점이다.
제너레이터와 비동기 처리의 만남
yield가 프로미스를 반환하고, next()가 그 프로미스가 완료되었을 때 그 결과값을 주입해준다면 어떨까? 이것이 제너레이터 기반 비동기 처리의 핵심 아이디어다.
이러한 로직을 자동으로 처리해주는 함수를 **제너레이터 실행기(Runner)**라고 부른다.
JavaScript
function getUser(id) {
return new Promise(resolve => setTimeout(() => resolve({ id, name: 'User ' + id }), 500));
}
// 제너레이터 기반 비동기 로직
function* fetchUsers() {
try {
const user1 = yield getUser(1); // 멈춤 -> getUser(1) 프로미스가 resolve될 때까지
console.log(user1);
const user2 = yield getUser(2); // 멈춤 -> getUser(2) 프로미스가 resolve될 때까지
console.log(user2);
} catch(e) {
console.error(e);
}
}
// 간단한 제너레이터 실행기(Runner)
function run(generator) {
const gen = generator();
function handleNext(yielded) {
if (yielded.done) return Promise.resolve(yielded.value);
// Promise.resolve()로 감싸서 프로미스가 아닌 값도 처리
return Promise.resolve(yielded.value).then(
res => handleNext(gen.next(res)), // 성공 시 결과값을 next()에 주입
err => handleNext(gen.throw(err)) // 실패 시 throw()로 에러 주입
);
}
return handleNext(gen.next());
}
// 실행
run(fetchUsers);
// 0.5초 후 { id: 1, name: 'User 1' } 출력
// 0.5초 후 { id: 2, name: 'User 2' } 출력
fetchUsers 함수를 보라. yield 키워드를 제외하면 마치 동기 코드처럼 자연스럽게 위에서 아래로 흐른다. 비동기 작업의 완료를 기다리고 그 결과를 변수에 할당하는 로직이 직관적으로 표현된다. run 함수가 바로 yield와 next() 사이의 더러운 작업을 대신 처리해주는 것이다.
async/await의 아버지
눈썰미가 좋다면 위 fetchUsers 제너레이터가 async/await 문법과 놀랍도록 닮았다는 것을 눈치챘을 것이다.
JavaScript
// 제너레이터 기반
function* fetchUsers() {
const user1 = yield getUser(1);
const user2 = yield getUser(2);
}
// async/await 기반
async function fetchUsersAsync() {
const user1 = await getUser(1);
const user2 = await getUser(2);
}
그렇다. **async/await는 사실 제너레이터와 프로미스를 기반으로 만들어진 문법적 설탕(Syntactic Sugar)**이다. async 함수는 제너레이터 함수와 같은 역할을, await 키워드는 yield와 같은 역할을 한다. 그리고 자바스크립트 엔진이 위에서 우리가 직접 만들었던 run 함수와 같은 제너레이터 실행기를 내장하여 자동으로 처리해주는 것이다.
제너레이터를 이해하면 async/await가 단순한 마법이 아니라, 자바스크립트의 실행 컨텍스트를 제어하는 제너레이터의 원리 위에 세워진 잘 설계된 추상화임을 깨닫게 된다.
또 다른 활용: Redux-Saga
제너레이터는 async/await로 대체되는 흐름 속에서도 여전히 그 가치를 빛내고 있다. 대표적인 예가 리액트 상태 관리 라이브러리 redux-saga다.
redux-saga는 제너레이터를 사용하여 API 요청, 스토어 상태 접근 등 애플리케이션의 **사이드 이펙트(Side Effect)**를 관리한다. 제너레이터의 ‘실행을 멈출 수 있는’ 특성을 이용해 액션이 디스패치되기를 기다렸다가(yield take('ACTION_TYPE')), 특정 비동기 작업을 수행하고(yield call(api)) 그 결과를 새로운 액션으로 디스패치하는(yield put({ type: 'SUCCESS' })) 복잡한 비동기 흐름을 테스트하기 쉬운 동기적인 코드로 작성할 수 있게 해준다.
6. 제너레이터, 언제 사용해야 할까?
async/await가 비동기 처리의 대세가 된 지금, 제너레이터를 직접 사용할 일은 많지 않을 수 있다. 하지만 여전히 제너레이터가 빛을 발하는 영역은 존재한다.
👍 장점 및 추천 사용 사례
-
커스텀 이터레이터를 쉽게 만들 때: 복잡한 로직을 가진 순회 객체를 만들어야 할 때, 제너레이터는 상태 관리의 번거로움을 없애고 코드를 매우 간결하게 만들어준다.
-
지연 평가(Lazy Evaluation) 및 메모리 효율성: 무한 수열이나 대용량 데이터 스트림처럼 모든 데이터를 미리 메모리에 올릴 수 없거나 그럴 필요가 없을 때, 제너레이터는 필요한 시점에 필요한 데이터만 생성하여 메모리를 효율적으로 사용할 수 있다.
-
복잡한 비동기 흐름 제어:
redux-saga의 예처럼, 단순한 비동기 요청-응답을 넘어 여러 액션이나 이벤트가 얽힌 복잡한 비동기 워크플로우를 제어해야 할 때 제너레이터는 강력한 도구가 될 수 있다.
👎 단점 및 고려사항
-
상대적으로 낮은 가독성:
async/await에 익숙한 개발자에게function*,yield,next()는 다소 생소하고 번거롭게 느껴질 수 있다. -
제너레이터 실행기 필요: 비동기 처리를 위해서는 직접 실행기를 만들거나
co와 같은 라이브러리를 사용해야 한다. (물론async/await는 이 과정이 내장되어 있다)
결론적으로, 일반적인 웹 애플리케이션의 비동기 API 호출 등은 async/await를 사용하는 것이 더 명확하고 효율적이다. 하지만 프레임워크나 라이브러리 수준에서 저수준의 비동기 흐름을 제어하거나, 특수한 데이터 스트림을 다뤄야 할 때는 제너레이터가 여전히 강력하고 유효한 선택지다.
7. 결론: 제너레이터를 이해한다는 것
자바스크립트 제너레이터는 단순히 ES6에 추가된 하나의 기능이 아니다. 이는 자바스크립트가 함수의 실행 흐름을 어떻게 바라보고 제어하는지에 대한 패러다임의 전환을 의미한다.
제너레이터를 깊이 이해한다는 것은,
-
자바스크립트의 이벤트 루프와 실행 컨텍스트에 대한 이해를 높이는 것.
-
우리가 매일 사용하는
async/await의 내부 동작 원리를 꿰뚫어 보는 것. -
이터레이션 프로토콜의 핵심을 파악하고 데이터의 흐름을 직접 디자인할 수 있는 능력을 갖추는 것을 의미한다.
당장 제너레이터로 코드를 작성할 일이 없더라도, 이 핸드북을 통해 제너레이터의 작동 원리를 한번쯤 탐험해보는 것은 모든 자바스크립트 개발자에게 깊이 있는 통찰력과 더 넓은 문제 해결의 시야를 제공해 줄 것이다. 제너레이터는 과거의 유물이 아니라, 현재와 미래의 자바스크립트를 떠받치고 있는 견고한 주춧돌 중 하나다.