근래에 자바스크립트 프로그래밍의 패러다임에 큰 변화를 준 명세는 바로 Generator 명세다. 올해 2월 미국 샌프란시스코에서 열렸던 FORWARDJS4 콘퍼런스에서 유명 패널들이 무대에서 ES6와 ES7에 대한 이야기를 주고받는 시간이 있었다. (https://javascriptair.com/episodes/2016-02-10/)
이야기 중간에 패널들이 자신이 가장 좋아하는 명세가 무엇인지 이야기 했는데 이때 자바스크립트 강사로 유명한 Kyle Simpson은 Generator를 가장 좋아하는 명세로 선택했다. 비동기 프로그래밍의 패러다임을 바꾸었다는 이유였다. 다른 패널에 비해 강한 확신과 함께 길게 설명했는데 그때는 그저 ‘그 정도인가?’ 하고 말았다.
하지만 지금 진행하는 프로젝트에 Generator를 적용해 보니, 순수 스크립트 또는 콜백 방식으로 구현해야 한다면 정말 어떻게 해야 할 지 감이 잡히지 않을 정도의 효율을 보인다. 이런 명세를 두고 여태까지 난 무엇을 하고 있었나 하고 느낄 정도다.
이 글에서는 Generator의 기본적인 명세를 설명하기보다는 Generator의 특징과 실무 활용 방안에 대해 다룬다. 1부에서는 기본적인 속성과 이를 다루기 위한 Runner에 대해 살펴볼 것이다. 그 후 2부에서는 실제 프로젝트의 요구사항을 토대로 Callback, Promise, Generator로 코드를 작성후 비교분석 해 보고, 오류처리, 응용 사례, 적용방법에 대해 다룰 예정이다.
혹시 Generator의 기본 명세가 궁금한 독자는 아래의 문서를 참고 바란다.
Generator는 실행 도중 일시 정지가 가능한 함수다. ES6에서 처음 소개되었고 2016년 7월 31일 현재 safari를 제외한 모던 브라우저에서 모두 지원하는 기능이다.
Generator가 있기 전엔 자바스크립트 프로세스를 일시 정지시킬 수 있는 유일한 방법은 alert, confirm, prompt를 사용하는 것이었다. 하지만 이 방법은 사용자가 시스템 대화 상자에 응답하지 않는 한 프로세스를 계속 이어나갈 수 없다.
하지만 Generator는 일시 정지와 재실행을 스크립트로 제어할 수 있다. Generator함수를 실행하면 그 즉시 next 메서드를 가진 iterator가 반환된다. 반환된 iterator의 next 메서드를 실행하면 yield 키워드를 만나기 전까지의 코드를 실행하고 일시 정지된다. 다시 next를 실행하면 그다음 yield 키워드 전까지의 코드를 실행하는데 이 과정을 함수가 종료될 때 까지 반복할 수 있다. 추가로 yield, next가 실행될 때 서로 데이터도 주고받을 수 있다.
이 특징 덕분에 Lazy Evaluation 등 많은 패턴을 손쉽게 구현할 수 있게 되었지만 역시 가장 큰 변화는 바로 비동기 코드를 동기 코드처럼 작성할 수 있게 되었다는 점이다.
Generator 함수 내부의 모든 yield 키워드 개수에 맞게 next를 일일이 만들어 직접 실행하는 방법으로도 제어할 수 있다. 하지만 일반적으로 이렇게 사용하지 않는다. 반환되는 iterator에 대해 종료될 때까지 자동으로 next를 실행하는 객체를 만들어 사용한다.
이 객체가 바로 Runner다. 이 Runner에 기능을 조금씩 붙여 Go, Clojurescript의 CSP를 흉내내거나, Coroutine이라는 이름을 붙여 사용하기도 한다. 하지만 기본적인 원리는 단순하다. iterator 종료시까지 지속해서 next를 실행하는 것이다.
간단하게 숫자가 yielding 되면 두 배로 반환하는 Runner를 만들어 보자
function run(gen) {
const iter = gen();
(function iterate({ value, done }) {
if (done) {
return value;
}
if (typeof value === "number") {
iterate(iter.next(value * 2));
} else {
iterate(iter.next(value));
}
})(iter.next());
}
위의 러너는 다음의 Generator를 처리할 수 있다.
function* twoTimesNumber() {
console.log(yield 10);
console.log(yield "hello");
}
run(twoTimesNumber);
// 20
// 'hello'
run(twoTimesNumber)를 실행하면 프로세스의 제어권이 Runner에게 제어권이 넘어간다. Runner는 iterator가 종료될 때까지 모든 yielding을 처리하는데, 만약 number 타입의 값을 만나면 구현했던 Runner의 동작 '숫자가 yielding 되면 2배' 를 수행하게 된다.
앞서 설명했던 Generator와 Runner로는 '비동기 코드를 동기 코드처럼 쓸 수 있다' 라는 이야기를 설명할 수 없다. 이 내용을 설명하려면 'Promise를 yielding 하면 결과가 올 때까지 기다렸다가 반환하는 Runner'를 만들어야 한다.
function run(gen) {
const iter = gen();
(function iterate({ value, done }) {
if (done) {
return value;
}
if (value.constructor === Promise) {
value.then(data => iterate(iter.next(data)));
} else {
iterate(iter.next(value));
}
})(iter.next());
}
위의 Runner는 아래의 Generator를 처리할 수 있다.
function* getLatestData() {
var promise = new Promise(resolve => {
$.get({url: '/api/latest-data', success: function(data) {
// Resolve Promise
resolve(data);
}}
});
const data = yield promise;
console.log(data);
}
run(getLatestData);
// log data
jQuery의 Promise는 네이티브 Promise가 아니므로 안타깝게도 비동기 코드의 느낌을 완전히 걷어낼 수 없다. 하지만 비동기 통신을 하는 API자체에서 네이티브 Promise를 반환하면 어떨까?
function* getLatestData() {
const data = yield axios.get("/api/latest-data");
console.log(data);
}
run(getLatestData);
// log data
Axios는 Promise based HTTP client다. axios.get 메서드는 첫 번째 인자의 url로 GET 요청을 보내고 결과를 받을 수 있는 Promise를 반환한다.
이 네이티브 Promise를 yielding 하면, Runner가 이를 받아 Promise가 resolve 될 때 next 메서드에 값을 실어서 실행한다.
결과적으로 getLatestData는 정상적으로 동작할 수 있다. 콜백 패턴과 한번 비교해 보자.
function getLatestData() {
$.get({
url: "/api/latest-data",
success: function(data) {
console.log(data);
}
});
}
getLatestData();
// log data
예제 코드는 한 건의 비동기 통신을 수행하고 있다. 서비스 요구사항을 만족하기 위해 두 건의 API 통신을 순차적으로 수행해야 한다면 어떨까? API 1번을 호출하고 응답을 받은 후 API 2번 요청을 수행해야 한다고 가정한다.
콜백 패턴
function getData() {
// API 1
$.get({
url: "/api/a",
success: function(data) {
console.log("API1 done");
// API 2
$.get({
url: "/api/b",
success: function(data2) {
console.log("API2 done");
// Result
console.log(data2);
}
});
}
});
}
getData();
// API1 done
// API2 done
// log data
Runner 패턴
function* getData() {
// API 1
let data = yield axios.get("/api/a");
console.log("API1 done");
// API 2
data = yield axios.get("/api/b");
console.log("API2 done");
// Result
console.log(data);
}
run(getData);
// API1 done
// API2 done
// log data
Generator와 Runner를 사용하긴 했지만, 확실히 비동기 코드를 동기 코드 작성하듯이 구현했다.
이렇게 간편하게 사용할 수 있는 기능을 왜 iterator를 반환해서 next로 제어해야 하도록 만들었을까? 이런 요구사항을 반영해 ES7 명세에는 async, await이라는 키워드가 추가되었다. stage-3 제안 상태의 스펙으로 * 대신 async로 선언된 함수에서 await에 Promise가 오면 앞서 소개한 Promise Runner와 똑같이 동작한다.
async function getData() {
const data = await axios.get("/api/a");
}
Generator는 Low Level API다. Promise 외에 다른 값에 대해 처리할 수도 있고, 앞서 설명했던 것 처럼 비용이 있는 리소스를 읽는 코드를 필요한 시점에 실행하는 Lazy evalution에도 응용할 수 있다. 처음에 Generator를 접했을 때는 async/await보다 쓸모없게 느껴졌지만, 지금은 Runner와 조합으로 조금 더 선호하는 패턴이 되었다. (모던 브라우저에서 지원되는 점도 한몫했다. async/await은 현재 Microsoft Edge 최신 버전에서만 지원된다)