generator란 generator함수에서 리턴되는 객체이며, iterable protocol과 iterator protocl를 준수합니다
처음 마주하면 당황하는 MDN문서의 정의다.
다행히도 모던 자바스크립트 딥 다이브를 스터디했기에 generator함수, iterable protocl, iterator protocol
에 대해서 배운 적이 있다.
간단하게 설명하자면, generator 함수는 함수 실행의 제어권을 양도할수 있으며, 함수의 상태를 주고받을 수있다.
iterable protocol은 Symbol.iterator
를 프로퍼티 키로 사용한 메서드가 존재하고, 호출하면 iterator protocol을 준수한 iterator을 반환한다.
itrator protocl은 next
메서드와 done:boolean,value:any
를 소유하고 있어야 한다.
또한 next
메서드를 호출하면 그 다음 value
를 가져오고 마지막 value
라면 done :true
가 되어야 한다.
이렇게 설명하면 말만 길어진다. 자세히 보자
//generator 객체를 생성하는 generator 함수다.
function* myGenerator() {
//yield를 만나면 멈추고 우측에 적혀진 값을 next메서드에서 반환하는 value에 던져준다.
yield 1;
yield 2;
yield 3;
}
//이제 mg는 generator객체가 되었다.
const mg = myGenerator();
//iterator proctocol을 준수한 next()메서드를 사용한다.
console.log(mg.next()); //{value : 1, done:false}
console.log(mg.next()); //{value : 2, done:false}
console.log(mg.next()); //{value : 3, done:false}
console.log(mg.next()); //{value : undefined, done:true}
generator함수로 생성된 generator객체는 iterator protocol뿐만이 아니라 iterable protocol도 준수하기에, 아래와 같은 행위도 가능하다.
...
let mg = myGenerator();
console.log([...mg]);
mg = myGenerator();
for (const value of mg) {
console.log(value);
}
참고로 generator객체는 첫번째 콘솔로그의 spread연산자로 인하여 next()
메서드를 끝까지 모두 호출한 상황이다. 따라서 mg
변수에 generator함수로 새로운 객체를 할당해주지 않는다면, 더이상 순회하지 못한다.
//한 번만 할당하였다.
const mg = myGenerator();
//이부분에서 이미 done은 true가 되어있다.
console.log([...mg]);
//이부분은 실행되지 못함
for (const value of mg) {
console.log(value);
}
출처는 t39 ECMAScript 공식문서 :
https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-iteration
공식문서의 설명에 따르면 이렇다.
즉, iterable protocol을 지켰다면, iterator protocol을 지킨 셈이다.
그러나 iterator protocol을 지킨 다 해도 iterable protocol은 지킨 게 아니다. => 역이 성립하지 않는 관계다.
그렇기에 아래처럼 iterator protocol을 준수한 메서드를 그냥 만든다면, iterator protocl을 준수한게 된다.
class CustomIterator {
constructor(data) {
this.data = data;
this.currentIndex = 0;
}
next() {
if (this.currentIndex < this.data.length) {
return { value: this.data[this.currentIndex++], done: false };
} else {
return { value: undefined, done: true };
}
}
}
const iterator = new CustomIterator([1, 2, 3]);
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 }
쓸데없는 얘기지만 궁금했으니!
화살표 함수는 generator 함수로 사용할 수 없다. 이는 분명한 사실이지만, 비슷한 기능을 하는 함수를 제작할수 있다.
//값을 계속해서 더하는 generator함수다.
function* myGenerator(value) {
while (true) {
yield ++value;
}
}
//generator함수로 생성된 mg는 정확히 말하자면, generator 객체이다.
const mg = myGenerator(0);
console.log(mg.next());
console.log(mg.next());
console.log(mg.next()); // 3
//함수를 리턴하는 화살표 함수다. 하지만, JS에서 함수는 일급 객체이다. 값으로 할당이 가능하다.
const myArrowGenerator = (value) => () => ++value;
//함수는 할당하는 모습. 이때 value는 클로져로 인하여 계속 기억된다.
const mag = myArrowGenerator(0);
console.log(mag());
console.log(mag());
console.log(mag());
그렇다면, generator 함수로 생성할때 받아온 파라미터, 내부 변수들은 클로져인가?
만약 맞다면, 결국 iterable protocl을 준수한 클로져를 만든 문법적 설탕인걸까? 아직은 잘 모르겠다. 조사해보며 알아가보자!
기술은 결국 필요에 의해 생긴다. 특히 인기가 많은 언어에 추가된 기능(ES6)이라면, 많은 개발자들이 공통적인 문제를 겪고 있었을 터이다.
지금까지 배운 내용을 기반으로 추측해보자
여기서 1번, 3번을 이용하여 본래 비동기처리를 하던 작업들을 동기처럼보이게 코드를 구현하는게 포인트다.
왜 그래야 할까?
비동기처리는 길어질수록 보기 힘들고 디버깅이 힘들다. 특히 인간은 하나를 처리해야만 다음 줄이 처리되는동기적사고방식에 익숙하다.
하지만 비동기는 언제 어떻게 코드가 들어올지 예측하기 쉽지 않다. 물론 Promise 체이닝을 걸면 그나마 볼만하지만....
그래도 편의성을 놓지는 못했는지 비동기를 동기처럼 처리하기위한 방법이 ES2017에서 도입되었다. (async/await)
사실 ES2017이전에도 비동기를 동기처럼 처리하기위한 방법이 존재했다.
바로 generator + promise를 이용한 방법이다!
코드로 보자.
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data fetched successfully");
}, 1000);
});
}
fetchData()
.then(data => {
console.log(data);
return "Processing data";
})
.then(processedData => {
console.log(processedData);
})
.catch(error => {
console.error("Error:", error);
});
위 코드는 단순히 Promise와 체이닝을 이용한 비동기 처리다. 코드수가 짧아 이해하기 전혀 어렵지 않다. then, then, catch
.
이를 generator를 이용하여 동기작업으로 바꾸어보면?
const runGenerator = (generator) => {
const iterator = generator();
function iterate(iteration) {
if (iteration.done) {
return iteration.value;
}
const promise = iteration.value;
return promise.then(result => {
return iterate(iterator.next(result));
}).catch(error => {
iterator.throw(error);
});
}
return iterate(iterator.next());
}
(runGenerator(function* fetchDataGenerator(){
try {
const data = yield fetchData();
console.log(data);
return "Processing data";
} catch (error) {
console.error("Error:", error);
}
})()); //IIFE로 즉시실행하여 스코프 생성
코드가 좀 더 길어졌다 왜냐면 generator함수에서 반환받은 generator객체의 iterator를 계속 실행해줄 헬퍼 함수가 필요하기 때문이다.
만약 헬퍼함수의 구현체가 이미 제작되어있고, 개발자는 모른채 사용한다고 해보면...
(runGenerator(function* fetchData(){
try {
const data = yield fetchData();
console.log(data);
return "Processing data";
} catch (error) {
console.error("Error:", error);
}
})());
와! 훨씬 짧아졌다. 특히 then, catch
등의 체이닝이 사라져 코드가 훨씬 간결해졌다.
그런데 잠깐, 어디서 많이 본 패턴 아닌가?
runGenerator
헬퍼함수는 async
와 유사해 보이고 yield
는 마치 await
같이 생겼다.
실제로 과거 버전과 호환시켜주는 컴파일러인 Babel에서는 async/await
을 이렇게 구현한다.
출처 : https://babeljs.io/docs/babel-plugin-transform-async-to-generator
결국 async/await
을 이용하여 비동기를 동기적으로 보이게하는 방법은 사실 Promise + generator를 이용한 방법이었다.
코드가 많이 길다!
const MY_PROMISE_STATE = {
pending: "pending",
fulfilled: "fulfilled",
rejected: "rejected",
};
class MyPromise {
#executor;
#state = MY_PROMISE_STATE.pending;
#value = undefined;
#onFulFilledFunctions = [];
#onRejectedFunctions = [];
constructor(executor) {
this.#executor = executor;
this.#init();
}
#setState(state, value) {
queueMicrotask(() => {
this.#state = state;
this.#value = value;
if (value instanceof MyPromise) {
value.then(this.#resolve.bind(this), this.#reject.bind(this));
return;
}
if (this.#state === MY_PROMISE_STATE.fulfilled) {
this.#onFulFilledFunctions.forEach((fn) => fn(this.#value));
}
if (this.#state === MY_PROMISE_STATE.rejected) {
this.#onRejectedFunctions.forEach((fn) => fn(this.#value));
}
});
}
#resolve(value) {
this.#setState(MY_PROMISE_STATE.fulfilled, value);
}
#reject(err) {
this.#setState(MY_PROMISE_STATE.rejected, err);
}
#init() {
try {
this.#executor(this.#resolve.bind(this), this.#reject.bind(this));
} catch (err) {
this.#reject(err);
}
}
then(onFulFilled, onRejected) {
return new MyPromise((resolve, reject) => {
this.#onFulFilledFunctions.push((value) => {
if (!onFulFilled) {
resolve(value);
return;
}
try {
resolve(onFulFilled(value));
} catch (err) {
reject(err);
}
});
this.#onRejectedFunctions.push((value) => {
if (!onRejected) {
reject(value);
return;
}
try {
resolve(onRejected(value));
} catch (err) {
reject(err);
}
});
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
finally(onFinally) {
return this.then(
(value) => {
onFinally();
return value;
},
(value) => {
onFinally();
throw value;
}
);
}
static resolve(value) {
if (value instanceof MyPromise) return value;
return new MyPromise((resolve) => resolve(value));
}
static reject(value) {
if (value instanceof MyPromise) return value;
return new MyPromise((_, reject) => reject(value));
}
static all(promises) {
return new MyPromise((resolve, reject) => {
let count = promises.length;
const returnArr = [];
promises.forEach((ps, i) => {
if (ps instanceof MyPromise) {
ps.then((value) => {
returnArr[i] = value;
count--;
!count && resolve(returnArr);
}).catch(reject);
} else {
returnArr[i] = ps;
count--;
!count && resolve(returnArr);
}
});
});
}
static race(promises) {
return new MyPromise((resolve, reject) => {
let settled = false;
promises.forEach((ps) => {
MyPromise.resolve(ps)
.then((value) => {
if (!settled) {
resolve(value);
settled = true;
}
})
.catch((err) => {
if (!settled) {
reject(err);
settled = true;
}
});
});
});
}
}
function* fetchDataGenerator() {
try {
//시간을 측정하기위한 타이머!
console.time();
const data = yield fetchData();
console.timeEnd();
console.log(data);
return "Processing data";
} catch (error) {
console.error("Error:", error);
}
}
function fetchData() {
return new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve("3seconds after Data fetched successfully");
}, 3000);
});
}
function runGenerator(generator) {
const iterator = generator();
function iterate(iteration) {
if (iteration.done) {
return iteration.value;
}
const promise = iteration.value;
return promise
.then((result) => {
return iterate(iterator.next(result));
})
.catch((error) => {
iterator.throw(error);
});
}
return iterate(iterator.next());
}
runGenerator(fetchDataGenerator);
잘 작동하는구만!!
지금까지 generator에대해 잘 배웠다. 왔다갔다하며 코드를 실행하는게 마치 프로세스 or 스레드간 컨텍스트 스위칭이 발생하는 것처럼 보였다.
프로세스나 스레드는 PCB,TCB에 프로세스의 상태, 스케쥴링, 프로그램 카운터 등을 저장해준다. 그렇기에 교환이 이루어져도 정상적으로 작업을 이어갈 수 있다.
그렇다면, generator도 PCB,TCB와 마찬가지로 현재 함수의 호출 스택, 진행 상태, 기타 정보를 보관해야하지 않을까?
당연하다! 내부슬롯을 이용하여 여러 정보를 저장한다.
[[GeneratorState]]
: "suspended-Start", "suspended-Yield", "executing", "completed"중 하나의 값을 가진다.[[GeneratorContext]]
: 제너레이터를 실행할때 사용하는 실행 컨텍스트를 저장한다.[[GeneratorBrand]]
: 제너레이터 객체임을 식별하는데 사용된다. 즉 엔진보고 나 제너레이터요하는 것이다.확실히 본인이 실행중인 컨텍스트를 알아야하고 외부에서 generator객체를 이용하여 통신하려면, 상태가 존재해야한다.
여담이지만, 번외2에서 클로져얘기를 했는데
클로져얘기가 나오긴한다. 다만 추상 클로져라는 단어를 검색해도 잘 안나와서...이부분은 미제호기심으로 잠깐 남겨두겠다.
지금까지 그냥 비동기처리 하면 async/await
을 사용해왔다.
내부적으로 어떻게 구현되어있는지 몰라도 사용하는데 지장은 없지만...단순히 한두줄짜리 비동기처리가 아니라, 실무에서 사용하는 수준의 비동기처리를 마주하면 결국 내부 구현을 이해한 사람이 훨씬 잘 배우게 되어있다!
그리고 언어에 존재하지만 쓰지않던 generator
에 대해서 잘 알게되었다.
마지막으로 await
키워드는 blocking을 일으키는줄 알았는데, 단순히 동기처럼 보이게 한다는 걸 알게되었다.
다음에는 NextJs나 테스트코드에 대해서 다뤄보겠습니다!
작성된 글에 잘못된 정보나 잘못 이해한 부분이 있다면 언제든지 댓글로 남겨주세요! ☺