Engineering
Generator in Practice
2016년 8월 9일
원문에서 보기 ↗
[1부] 기본 속성과 Runner
목차
서론 기본 속성 Runner Promise Runner 결론
서론
근래에 자바스크립트 프로그래밍의 패러다임에 큰 변화를 준 명세는 바로 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의 기본 명세가 궁금한 독자는 아래의 문서를 참고 바란다.
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/function\* (MDN - 기본 문법 설명)
- http://www.2ality.com/2015/03/es6-generators.html (2ality - 심화)
기본 속성
Generator는 실행 도중 일시 정지가 가능한 함수다. ES6에서 처음 소개되었고 2016년 7월 31일 현재 safari를 제외한 모던 브라우저에서 모두 지원하는 기능이다.
Generator가 있기 전엔 자바스크립트 프로세스를 일시 정지시킬 수 있는 유일한 방법은 alert, confirm, prompt를 사용하는 것이었다. 하지만 이 방법은 사용자가 시스템 대화 상자에 응답하지 않는 한 프로세스를 계속 이어나갈 수 없다.
하지만 Generator는 일시 정지와 재실행을 스크립트로 제어할 수 있다. Generator함수를 실행하면 그 즉시 next 메서드를 가진 iterator가 반환된다. 반환된 iterator의 next 메서드를 실행하면 yield 키워드를 만나기 전까지의 코드를 실행하고 일시 정지된다. 다시 next를 실행하면 그 다음 yield 키워드 전까지의 코드를 실행하는데 이 과정을 함수가 종료될 때까지 반복할 수 있다. 추가로 yield, next가 실행될 때 서로 데이터도 주고받을 수 있다.
이 특징 덕분에 Lazy Evaluation 등 많은 패턴을 손쉽게 구현할 수 있게 되었지만 역시 가장 큰 변화는 바로 비동기 코드를 동기 코드처럼 작성할 수 있게 되었다는 점이다.
Runner
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배'를 수행하게 된다.
Promise Runner
앞서 설명했던 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 evaluation에도 응용할 수 있다. 처음에 Generator를 접했을 때는 async/await보다 쓸모 없게 느껴졌지만, 지금은 Runner와 조합으로 조금 더 선호하는 패턴이 되었다. (모던 브라우저에서 지원되는 점도 한몫 했다. async/await은 현재 Microsoft Edge 최신 버전에서만 지원된다.)
[2부] 실무 예제
목차
요구사항과 API목록 분석 Callback Promise Generator 오류 처리 사용해 보기 결론
요구사항과 API목록
토스트 드라이브 프로젝트에서 실제 구현해야 했던 명세 중 Generator 적용 효율이 제일 높았던 명세를 조금 수정했다. 파일 또는 폴더를 선택한 후 다른 폴더로 이동할 때의 명세다.
다음은 웹 기반 파일 시스템 구현 프로젝트의 일부 기능이다.
파일 목록에서 선택한 다수의 파일 및 폴더를 다른 폴더로 이동할 수 있다.
이때, 이동 대상 폴더에 이름이 같은 파일 및 폴더가 이미 존재할 경우 모든 건에 대해서 사용자에게 확인한다.
예를 들어 ‘새 폴더’라는 이름이 중복될 경우, ‘이동하려는 폴더에 이름이 중복된 폴더가 있습니다.
[새 폴더(1)]이라는 이름으로 변경 후 이동하시겠습니까?’라는 문구의 확인용 디자인 레이어를 띄운다.
'확인'을 누르면 그 이름으로 이동시킨다. 이때 '이후의 모든 항목에 적용'
체크박스를 누르면 이후 중복 건은 모두 '확인' 처리한다.
'취소'를 누르면 그 파일에 대해 취소한다. 마찬가지로 '이후의 모든 항목에 적용'
체크박스를 누르면 이후 중복 건을 모두 '취소' 처리한다.
사용자가 모든 중복 건에 대해 확인하면 모든 건에 대해 이동 처리한다.
서버에서 제공하는 API 목록
GET '/api/check-duplication'
[이름 중복 확인]
- 특정 폴더의 id와 생성할 이름을 전달
- 중복되는 경우 사용 가능한 새 이름과 함께 409 응답
- 가능한 경우 200 응답
POST '/api/move'
[파일 및 폴더 이동]
- 이동 대상 파일 및 폴더의 id 목록과 이동 대상 폴더의 id 전달
- 오류가 없으면 모든 이동을 처리 후 200 응답
파일 및 폴더 이동 API에서 중복을 함께 처리하는 것이 이상적이지만, 이 글에서 중요한 부분은 아니므로 당장은 두 개의 API를 사용해야 한다고 가정한다.
또 실제 API는 이동의 결과를 웹 소켓을 통해 클라이언트에게 알리지만, 설명을 쉽게 하려고 단일 HTTP 요청, 응답을 이용하는 것으로 가정한다.
분석
먼저 현재 선택된 파일과 폴더(이하 '개체' 라 함)의 이름과 이동 대상 폴더의 id를 조회해 [이름 중복 확인] API를 호출해야 한다. 200 응답을 받으면 바로 [파일 및 폴더 이동] API를 호출하면 되지만 중복 오류가 응답할 경우 모든 중복에 대해 사용자 응답을 수집해야 한다.
모든 이름 중복 건에 대해 사용자의 응답을 수집할 때 window.confirm을 사용하면 프로세스가 멈추기 때문에 모든 항목에 대해 수월하게 사용자의 선택을 수집할 수 있다.
하지만 '이후 모든 항목에 적용'이라는 체크박스 기능을 지원해야 하므로 사용할 수 없다. 결국, 별도의 '디자인 레이어'를 구현해야 한다. 이 '디자인 레이어'에서 사용자가 응답하기까지 기다리는 행위도 일종의 비동기 처리이므로 구현하기 까다로운 부분에 속한다.
정리하면 '이름 중복 확인', '중복되는 모든 항목의 사용자 응답 수집', '개체 이동'의 비동기 처리를 순차적으로 수행해야 한다.
Callback
요구사항의 '디자인 레이어'를 콜백 패턴으로 구현하면 다음과 같다. jsConfirm은 원래 메시지를 받아서 보여주어야 하지만. 중요한 것이 아니므로 처리했다고 가정하자.
<div id="layer">
<input type="checkbox" id="checkbox" />
<button type="button" id="ok">ok</button>
<button type="button" id="cancel">cancel</button>
</div>
<script>
const layer = $('#layer');
const checkbox = $('#checkbox');
/**
* @param {string} msg - confirm 에 출력할 메시지 내용
* @param {function} cb - callback 함수
*/
function jsConfirm(msg, cb = () => {}) {
checkbox
.prop('checked', false);
layer
.show()
.on('click', ev => {
const target = $(ev.target);
const checked = checkbox.prop('checked');
if (target.is('#ok')) {
layer.hide().off();
cb({confirmed: true, checked});
} else if (target.is('#cancel')) {
layer.hide().off();
cb({confirmed: false, checked});
}
});
}
</script>
그 다음은 중복 건에 대해 반복적으로 사용자의 확인을 쌓아야 한다. 먼저 이름 중복 데이터의 Mock을 준비한다. 이 객체는 [중복 확인] API의 응답이라고 가정할 객체이다.
// [이름 중복 확인] API의 응답 데이터 (요청한 이름, 사용가능한 이름 쌍)
const dupList = [
['foo', 'foo(2)'],
['bar', 'bar(2)'],
['baz', 'baz(2)']
];
단순히 사용자의 응답을 쌓기 위해서는 콜백을 재귀적으로 구성하면 된다. 하지만 콜백을 중첩을 직접 작성하는 것은 하드 코딩이기 때문에 동적인 중복 항목 리스트에 대응할 수 없다. 따라서 재귀를 사용한다.
// 이 변수에 확인 항목을 모은다
let resolved = [];
function resolveDuplicates(idx) {
const item = dupList[idx];
jsConfirm(`msg${idx}`, ({confirmed, checked}) => {
if (confirmed) {
// A '확인'
resolved.push(item);
}
idx += 1;
if (checked) {
if (confirmed) {
// B '이후 모든 항목에 적용', '확인'
resolved = [...resolved, ...dupList.slice(idx)];
} else {
// C '이후 모든 항목에 적용', '취소'
}
return;
}
if (!dupList[idx]) {
// D 모든 항목 완료
return;
}
resolveDuplicates(idx);
});
}
resolveDuplicates(0);
아직 API 호출 처리는 시작조차 하지 않았다. [중복 확인] API를 먼저 호출한 후의 결과에 따라 resolveDuplicates를 실행해야 하므로 위의 코드는 이미 depth가 증가한 상태일 것이다.
또 코드의 A, B, C, D 부분에서도 API 호출을 위한 콜백이 추가될 것이다. 함수를 추출하는 방법 등의 리팩토링을 한다고 해도 콜백 패턴으로는 한계가 있다. 만들어지는 코드는 알아보기 어렵고 유지 보수하기 어렵다.
Promise
콜백 중첩 문제는 오래 전부터 등장한 Promise를 이용하면 어느 정도 해결할 수 있다. Promise란 미래의 어떤 값을 받을 수 있는 객체이다. Kyle Simpson은 햄버거를 주문하고 받는 대기표로 비유했는데 웃기지만 정확한 비유다. Promise는 어떤 값 하나를 받을 수 있는 것을 보장하는 인터페이스이다.
'디자인 레이어'의 확인, 취소도 이 Promise를 사용할 수 있다. '디자인 레이어'가 Promise를 반환하도록 구현해 보자. 이 과업은 이 글의 끝에 Generator를 사용한 코드를 작성하기 위한 밑거름이기도 하다.
/**
* @param {string} msg - confirm 에 출력할 메시지 내용
* @returns {Promise}
*/
function jsConfirm(msg) {
// Return Promise
return new Promise((resolve, reject) => {
checkbox
.prop('checked', false);
layer
.show()
.on('click', ev => {
const target = $(ev.target);
const checked = checkbox.prop('checked')
if (target.is('#ok')) {
layer.hide().off();
resolve({confirmed: true, checked});
} else if (target.is('#cancel')) {
layer.hide().off();
resolve({confirmed: false, checked});
}
});
});
}
jsConfirm은 이제 Promise 객체를 반환한다. 사용자의 응답을 객체로 만들어 resolve를 실행하고 있다. Promise의 기본 스펙에 궁금한 독자는 아래 링크를 확인 바란다.
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise (기본 스펙)
- http://www.2ality.com/2014/09/es6-promises-foundations.html (심화)
이를 바탕으로 중복항목 체크를 Promise로 리팩토링 해 보자. 목록에 대해 순차적으로 jsConfirm을 호출해야 하므로 Promise를 이어 붙이는 방법으로 구현한다. 이 방법은 배열의 요소에 대해서 Promise를 순차 처리할 때 유용하다.
let resolved = [];
dupList.reduce((promise, item, idx) => {
return promise.then(() => {
return jsConfirm().then(({confirmed, checked}) => {
if (confirmed) {
// A '확인'
resolved.push(item);
}
idx += 1;
if (checked) {
if (confirmed) {
// B '이후 모든 항목에 적용', '확인'
resolved = [...resolved, ...dupList.slice(idx)];
} else {
// C '이후 모든 항목에 적용', '취소'
}
// Promise 체인을 완전히 탈출하기 위함
throw new Error('checked');
}
});
});
}, Promise.resolve()).then(() => {
// D 모든 항목 완료
console.log(resolved);
});
전보다 훨씬 보기 쉬워졌는가? 개인적으로 크게 달라진 점은 없어 보인다. 재귀 코드가 없어지고 모든 항목의 완료 처리를 맨 마지막 then 체인에서 할 수 있다는 것과, catch를 사용할 수 있게 되었단 점 정도이다. 이 catch는 Promise 체인의 어느 곳에서라도 발생한 오류가 모이므로 사용자 오류, XHR오류 등등을 한 곳에서 처리할 수 있게 된 것이다.
사실 그리 알아보기 쉬운 편은 아니다. Promise 자체에 이해가 없는 개발자에게는 더더욱 그럴 것이고 무엇보다도 앞서 이야기했던 '비동기 코드를 동기 코드처럼 쓸 수 있다'라는 이야기를 설명할 수 없다.
Generator 명세가 나오기 전까지만 해도 이 방법이 최선이었지만 지금은 아니다. 리팩토링해 보자.
Generator
1부에서 사용했던 Promise Runner와 이전 장에서 구현했던 Promise base jsConfirm을 그대로 사용한다.
run(function*() {
let resolved = [];
for (let i = 0, len = dupList.length; i < len; i += 1) {
const item = dupList[i];
const {confirmed, checked} = yield jsConfirm();
if (confirmed) {
// A 확인
resolved.push(item);
}
if (checked) {
if (confirmed) {
// B '이후 모든 항목에 적용', '확인'
resolved = [...resolved, ...dupList.slice(i + 1)];
} else {
// C '이후 모든 항목에 적용', '취소'
}
break;
}
}
// D 모든 항목 완료
console.log(resolved);
});
Promise Runner 함수 덕분에 jsConfirm가 반환하는 Promise의 응답이 올 때까지 일시 정지할 수 있게 되었다. 그 덕분에 일반적인 for 반복문으로도 비동기 처리를 할 수 있게 되었다.
코드의 주석 표기는 A, B, C, D로 나뉘어 있지만, 결과적으로 D로 수렴하기 때문에 [파일 및 폴더 이동] API는 D 위치에서 일괄적으로 요청할 수 있다. 계속해서 API 호출까지 구현해 본다.
function* moveObjects(destId, targetIdList) {
// [이름 중복 확인] API 호출
const dupList = yield axios.get('/api/check-duplicate?dest...');
// [중복되는 모든항목의 사용자 응답 수집]
let resolved = [];
for (let i = 0, len = dupList.length; i < len; i += 1) {
const item = dupList[i];
const {confirmed, checked} = yield jsConfirm();
if (confirmed) {
// A 확인
resolved.push(item);
}
if (checked) {
if (confirmed) {
resolved = [...resolved, ...dupList.slice(i + 1)];
}
break;
}
}
// [개체 이동] API 호출
yield axios.post('/api/move', {resolved, /* request data */});
}
오류 처리
요구사항을 moveObjects Generator 함수 하나로 구현했다. Callback, Promise보다 훨씬 간단하게 구현할 수 있었다. 오류 처리는 어떻게 할까? 그냥 try...catch를 사용하면 된다. 실행을 보장하는 finally는 덤이다.
function* moveObjects(destId, targetIdList) {
try {
/* 구현 */
} catch (err) {
// API 호출 오류 처리
// 기타 오류 처리
} finally {
// 필요의 경우 구현 (React의 경우 이전 상태로 복원 등...)
}
}
사실 위의 에러 처리를 하려면 run 코드는 약간 수정이 필요하다. 1부에서 소개했던 Promise 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)))
.catch(err => iter.throw(err)); // 여기가 추가된 코드
} else {
iterate(iter.next(value));
}
})(iter.next());
}
에러 처리 코드는 같은 스코프 코드까지만 유효하다. 비동기 코드는 현재 스코프보다 이후 스코프 또는 프레임에서 실행되기 때문에 비동기 코드 바깥에서 에러를 잡으려면 직접 전달해주어야 한다.
try{
jQuery.ajax({
success: () => {
//여기서 오류가 발생해도
}
});
} catch (err) {
// 여기서 잡을 수 없다.
}
run 내부의 iterate 실행 타이밍과 Promise 처리 코드가 그 예다. 그래서 Promise의 오류를 직접 Iterator의 throw로 전달하도록 수정한 것이다. 이제 오류 처리를 포함해 Generator를 다룰 수 있게 되었다.
사용해 보기
서비스 지원 범위가 Chrome, Firefox 최신 버전이면 바로 사용할 수 있다. 하지만 그렇지 않을 경우 트랜스파일러를 사용해야 한다. 8/8일 현재 Generator를 es5기반 코드로 변환해주는 트랜스파일러는 Facebook 의 regenerator가 유일하다. 이 regenerator는 runtime과 transpiler로 구성되어 있는데 runtime은 압축 시 1KB 미만이므로 부담이 없다.
이미 서버에 많은 자바스크립트 코드가 있고 이 중 Generator를 적용하고 싶은 코드가 있다고 가정하자.
// 서비스 코드
<script>
// 네임스페이스 설정
window.ne = {toastDrive: {}};
</script>
<script src="src/js/generators.js"></script>
<script>
ne.toastDrive.logList(['a', 'b']);
</script>
아래 generators.src.js 는 트랜스파일되기 전 Generator 함수들이 모여있는 파일이다.
// src/js/generators.src.js
function run(gen) {/* runner */}
ne.toastDrive.logList = function(dupList) {
run(function*() {
for (let item of dupList) {
console.log(item);
yield item;
}
});
};
이제 regenerator를 이용해 이 파일을 트랜스파일한다.
// regenerator 설치
npm install -g regenerator
// --include-runtime을 통해 의존 모듈을 포함하여 트랜스파일
regenerator --include-runtime src/js/generators.src.js > src/js/generators.js
이렇게 하면 기존 코드를 유지하면서도 필요한 부분에만 Generator를 사용할 수 있다. 저 regenerator실행 스크립트를 배포 시 자동으로 실행하도록 하면 수동으로 배포 전 실행하지 않아도 된다.
결론
Generator를 사용하면 '비동기 코드를 동기 코드처럼' 작성할 수 있다. 복잡한 비동기 흐름을 알아보기 쉽게 작성할 수 있고 이는 흐름이 복잡하면 복잡할수록 빛을 발한다.
Promise, Generator, BabelJS 의 개념과 사용법을 익혀야 하지만. 충분히 그럴만한 가치가 있다. 만약 조금 더 욕심이 있다면 이 글에서 간략하게 다루었던 Callback, Promise, Generator 순서의 패턴의 등장 배경과 단점, 다음 방법이 가져다 주는 이점을 파악한다면 어떤 JavaScript 요구사항도 어렵지 않게 해결할 수 있게 될 것이다.