14.4 제너레이터
12장에서 설명했듯 제너레이터는 함수와 호출자 사이의 양방향 통신을 가능하게 합니다.
제너레이터는 원래 동기적인 성격을 가졌지만, 프로미스와 결합하면 비동기 코드를 효율적으로 관리할 수 있습니다.
비동기 코드에서 가장 어려운 부분이 무엇일까요?
동기적인 코드에 비해 만들기가 어렵다는 점입니다.
어려운 문제를 해결해야 할 때 우리는 대개 동기적으로 생각합니다.
1단계, 2단계, 3단계 하는 식으로 말입니다.
하지만 이렇게 하면 성능 문제가 있습니다.
비동기 코드는 성능 문제를 해결하기 위해 등장했습니다.
비동기 코드의 난해함은 젖혀놓고 성능 개선만 누릴 수 있다면 정말 좋지 않을까요?
제너레이터를 사용하면 일정 부분 가능해집니다.
앞에서 잠시 언급한 콜백 헬을 다시 살펴봅시다.
파일 세 개를 읽고 1분간 기다린 다음 그 내용을 합쳐서 네 번째 파일에 쓰는 문제였습니다.
사람은 대개 이런 식으로 하려고 합니다.
dataA = read contents of 'a.txt'
dataB = read contents of 'b.txt'
dataC = read contents of 'c.txt'
wait 60 seconds
write dataA + dataB + dataC
제너레이터를 사용하면 이런 자연스러운 발상과 비슷한 코드를 작성할 수 있습니다.
물론 제너레이터를 쓴다고 바로 되는 것은 아니고, 선행 작업이 약간 필요하긴 합니다.
가장 먼저 할 일은 노드의 오류 우선 콜백을 프로미스로 바꾸는 겁니다.
이 기능을 nfcall(Node function call) 함수로 만들겠습니다.
function nfcall(f, ...args) {
return new Promise(function(resolve, reject) {
f.call(null, ...args, function(err, ...args) {
if(err) return reject(err);
resolve(args.length < 2 ? args[0] : args);
})
})
}
NOTE_
이 함수는 Q 프로미스 라이브러리https://github.com/kriskowal/q의 nfcall 메서드를 참고해 만들었고 같은 이름을 붙였습니다.
이런 기능이 필요하다면 Q 라이브러리를 쓰는 걸 고려할 만 합니다.
Q 라이브러리에는 이 메서드 뿐만 아니라 프로미스에 관련된 좋은 메서드가 많이 있습니다.
이제 콜백을 받는 노드 스타일 메서드를 모두 프로미스로 바꿀 수 있습니다.
setTimeout
을 써야 하는데, setTimeout은 노드보다 먼저 나왔고 오류 우선 콜백의 패턴을 따르지 않습니다.
그러니 같은 기능을 가진 ptimeout(promise timeout)
함수를 새로 만듭니다.
function ptimeout(delay) {
return new Promise(function(resolve, reject) {
setTimeout(resolve, delay)
})
}
다음에 필요한 것은 제너레이터 실행기입니다.
제너레이터는 원래 동기적입니다.
하지만 제너레이터는 호출자와 통신할 수 있으므로 제너레이터와의 통신을 관리하고 비동기적 호출을 처리하는 함수를 만들 수 있습니다.
이런 역할을 할 함수 grun(generator run)
을 만들겠습니다.
function grun(g) {
const it = g();
(function iterate(val) {
const x = it.next(val);
if (!x.done) {
if (x.value instanceof Promise) {
x.value.then(iterate).catch(err => it.throw(err));
} else {
// 세번째 이후 매개변수 : 타이머가 만료되고 func에 전달되는 추가적인 매개변수들입니다.
setTimeout(iterate, 0, x.value);
}
}
})();
}
NOTE_
grun은 카일 심슨(Kyle Simpson)이 제너레이터에 관해 쓴 글http://davidwalsh.name/es6-generators에 있는 runGenerator를 기초로 만들었습니다.
이 책의 보충 자료가 필요하다면 카일 심슨의 글을 읽어보길 권합니다.
grun은 기초적인 제너레이터 재귀 실행기입니다.
grun에 제너레이터 함수를 넘기면 해당 함수가 실행됩니다.
6장에서 배웠듯, yield로 값을 넘긴 제너레이터는 이터레이터에서 next를 호출할 때까지 기다립니다.
grun은 그 과정을 재귀적으로 반복합니다.
이터레이터에서 프로미스를 반환하면 grun은 프로미스가 완료될 때까지 기다린 다음 이터레이터 실행을 재개합니다.
이터레이터가 값을 반환하면 이터레이터 실행을 즉시 재개합니다.
grun에서 iterate를 바로 호출하지 않고 setTimeout을 거친 이유는 효율성 때문입니다.
자바스크립트 엔진은 재귀 호출을 비동기적으로 실행할 때 메모리를 좀 더 빨리 회수합니다.
너무 복잡하다거나, 이런 걸 한다고 과연 삶이 단순해질까 의아할 수도 있겠지만, 어려운 부분은 다 끝났습니다.
nfcall은 과거의 방법인 노드 오류 우선 콜백을 현재의 방법인 프로미스에 적용시키고, grun은 미래의 기능을 현재로 가져옵니다.
ES7에서 도입하리라 예상되는 await 키워드는 grun과 거의 같은 기능을 지원하며 더 자연스러운 문법을 제공할 겁니다.
어려운 부분은 다 끝냈으니, 편리하게 사용하는 방법을 알아볼 차례입니다.
이 장에서 언급한 ‘사람이라면 대개 이렇게 생각했을’ 방법을 그대로 사용할 수 있습니다.
const fs = require('fs');
function nfcall(f, ...args) {
return new Promise(function(resolve, reject) {
f.call(null, ...args, function(err, ...args) {
if(err) return reject(err);
resolve(args.length < 2 ? args[0] : args);
})
})
}
function ptimeout(delay) {
return new Promise(function(resolve, reject) {
setTimeout(resolve, delay)
})
}
function grun(g) {
const it = g();
(function iterate(val) {
const x = it.next(val);
if (!x.done) {
if (x.value instanceof Promise) {
x.value.then(iterate).catch(err => it.throw(err));
} else {
// 세번째 이후 매개변수 : 타이머가 만료되고 func에 전달되는 추가적인 매개변수들입니다.
setTimeout(iterate, 0, x.value);
}
}
})();
}
function* theFuntureIsNow() {
const dataA = yield nfcall(fs.readFile, 'a.txt');
const dataB = yield nfcall(fs.readFile, 'b.txt');
const dataC = yield nfcall(fs.readFile, 'c.txt');
yield ptimeout(60*1000);
yield nfcall(fs.writeFile, 'd.txt', dataA + dataB + dataC);
}
grun(theFuntureIsNow);
콜백 헬보다는 훨씬 낫고, 프로미스 하나만 쓸 때보다 더 단순합니다.
사람이 생각하는 것과 거의 같은 방법으로 동작합니다.
실행 또한 간단합니다.