만재송
[JavaScript] 제너레이터 (Generator) 본문
콜백 지옥 이라니....
간단한(?) 커피 주문 시스템 코드가 있다고 하자. 이 코드는 굉장히 비 효율적 이라서, 핸드폰 번호를 알아야 아이디를 알 수 있고, 아이디를 알아야 이메일을 알 수 있고, 이메일을 알아야 이름을 알 수 있고, 이름을 알아야만 주문을 할 수 있다. 작성하면 아래와 같다.
function getId(phoneNumber) { /* … */ }
function getEmail(id) { /* … */ }
function getName(email) { /* … */ }
function order(name, menu) { /* … */ }
function orderCoffee(phoneNumber) {
const id = getId(phoneNumber);
const email = getEmail(id);
const name = getName(email);
const result = order(name, 'coffee');
return result;
}
하지만 만약에 각각에 데이터들을 외부에 있는 서버에서 받아와야 한다고 가정해보자. 위의 코드로 데이터를 받아오면 데이터를 비동기적으로 받아오기 때문에 각각에 변수에 데이터가 안담길 수 있다(필자도 비동기 때문에 오류를 많이 일으켰다....) . 그래서! 콜백을 이용하여 비동기 방식으로 코드를 작성해야한다.
function getId(phoneNumber, callback) { /* … */ }
function getEmail(id, callback) { /* … */ }
function getName(email, callback) { /* … */ }
function order(name, menu, callback) { /* … */ }
function orderCoffee(phoneNumber, callback) {
getId(phoneNumber, function(id) {
getEmail(id, function(email) {
getName(email, function(name) {
order(name, 'coffee', function(result) {
callback(result);
});
});
});
});
}
정말 딱봐도 콜백 지옥이다. 이러한 콜백은 단순히 들여쓰기와 가독성의 문제도 있지만, 콜백으로 넘겨줌으로 써 this가 바뀐다는 점이다. 그래서 현재 this의 데이터를 변경해주고 싶을때는 this를 변수에 담거나 해당 콜백을 바인딩 해줘야한다. 이로 인해 프로그램이 더 예측하기 어렵게 되고 에러가 발생하기 쉽게 되며, 디버깅 또한 만만치 않게 된다.
콜백지옥을 해결하기 위해 JavaScript ES6 부터는 Promise라는 기능이 있지만 그 방법은 추후에 업데이트 하고 이번에는 제너레이터 (Generator) 라는 방식으로 비동기 같지만 비동기 아닌 비동기 코드를 구현해 보겠다!
제너레이터란?
제너레이터는 JavaScript ES6 부터 제공되는 기능이다. 일반적인 함수는 매 실행 마다 같은 흐름으로 모든 코드를 실행하지만, 제너레이터는 실행중간에서 값을 반환할 수 있고, 다른 작업을 처리한 후에 다시 그 위치에서 코드를 시작할 수 있다.
아래 예시에서 제너레이터 함수와 일반 함수의 차이점을 설명하겠다.
function normal_func() {
var str = "Hello normal_func";
console.log(str);
return str;
}
function* generator_func() {
var str = "Hello generator_func";
console.log(str);
return str;
}
var a = normal_func(); // "Hello normal_func"
var b = generator_func(); // ???
두 함수 모두다 console.log 로 str를 프린트하고 리턴하는 코드이다. 딱 봐도 normal_func를 실행하면 "Hello normal_func" 가 프린트되고 변수 a 에 str 값이 할당된다. 그러나, generator_func 함수를 호출하면 console.log 도 호출되지 않고 b 변수에 str 값도 할당되지 않는다. 이게 무슨 말도안되는 소리인가???
제너레이터는 (*) 키워드를 사용하여 구현할 수 있고, 코드를 순서대로 실행하고 리턴값을 반환하는 일반함수와는 다르게 제너레이터 함수를 실행하면 제너레이터 객체를 생성한다.
함수가 무슨 new도 안하고 객체를 생성한다는 말도안되는 소리냐고 하는데 실행해보면 진짜다.
그럼 도대체 제너레이터 함수는 어떻게 사용하는 것인지 제너레이터와 같이사용하는 키워드와 함께 설명하겠다.
yield 와 next()
function* gen() {
console.log("첫 next");
yield 1;
console.log("두번 째 next");
yield 2;
console.log("세번 째 next");
yield 3;
console.log("네번 째 next");
}
var g = gen(); // 제너레이터 객체 반환
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: 3, done: false}
console.log(g.next()); // {value: undefined, done: true}
제너레이터에서 가장 많이 쓰이는 키워드가 yield와 next()이다. 제러네이터 함수를 실행하면 next() 메서드를 사용할 수 있다. 메서드를 호출하면 제너레이터 함수를 yield를 만날때 까지 순차적으로 실행하게된다. 이 때 yield는 오른쪽에 있는 값을 객체로 리턴하게 된다. {value: 리턴값, done: boolean} 형식으로 말이다. 그래서 위의 코드를 실행하면 아래와 같이 결과가 출력된다.
첫 next
{value: 1, done: false}
두번 째 next
{value: 2, done: false}
세번 째 next
{value: 3, done: false}
네번 째 next
{value: undefined, done: true}
이해가 안갈수도 있으니 위의 코드를 순차적으로 설명하면,
- gen 제너레이터 함수를 실행한 결과인 제너레이터 객체를 변수 g에 저장한다.
- g.next()를 실행한다. g.next()는 gen 함수를 yield를 만날때까지 순차적으로 실행한다. 첫번째 g.next()는 gen 함수를 순차적으로 실행하여 console.log("첫 next") 를 실행하고, yield 1; 을 만나 종료하게 된다. 이 때 yield는 오른쪽에 있는 값을 객체로 리턴하게 된다. 값은 {value: 1, done: false} 이다. value 프로퍼티는 리턴값을 의미하고 done 프로퍼티는 함수의 return 에 도달하게 되면 true를 호출하고 끝나지 않았으면 false를 호출한다. 첫 g.next()를 실행했을 때 함수가 아직 끝나지 않아서 done 프로퍼티의 값은 false가 된다.
- 두번 째 g.next()를 실행한다. 두번째 next는 처음 yield가 끝난시점에서 출발하여 다음 yield를 만날때까지 함수를 실행하게 된다. console.log("두번 째 next"); 를실행하고 다음 yield를 만나 {value: 2, done: false}를 호출한다. 아직 함수가 끝나지 않았기 때문에 done 프로퍼티는 false를 호출한다.
- 세번 째 g.next()를 실행한다. 세번째 next는 두번째 next가 종료한 시점인 yield 2; 부터 시작하여 다음 yield를 만날때 까지 실행한다. console.log("세번 째 next"); 를실행하고 다음 yield를 만나 {value: 3, done: false}를 호출한다.
- 네번 째 g.next()를 실행한다. 네번째 next는 세번째 next가 종료한 시점부터 출발한다. console.log("세번 째 next"); 를 실행하고 함수의 끝부분에 도달했기 때문에 {value: undefined, done: true} 가 호출된다.
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
}, f, y, t, g;
return g = { // __generator 함수가 반환하는 값
next: verb(0),
"throw": verb(1),
"return": verb(2)
},
typeof Symbol === "function" && (g[Symbol.iterator] = function () {
return this;
}), g;
function verb(n) { // next() 메서드를 사용하면 호출된다.
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (_) try {
if (f = 1, y && (t = y[op[0] & 2 ? "return" : op[0] ? "throw" : "next"])
&& !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [0, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return {value: op[1], done: false};
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1])
&& (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return {value: op[0] ? op[1] : void 0, done: true};
}
};
function gen() {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
console.log("첫 next");
return [4 /*yield*/, 1];
case 1:
_a.sent();
console.log("두번 째 next");
return [4 /*yield*/, 2];
case 2:
_a.sent();
console.log("세번 째 next");
return [4 /*yield*/, 3];
case 3:
_a.sent();
console.log("네번 째 next");
return [2 /*return*/];
}
});
}
var g = gen(); // 제너레이터 객체 반환
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: 3, done: false}
console.log(g.next()); // {value: undefined, done: true}
- get 함수를 호출하면 __generator 함수를 호출한 return 값을 반환한다. __generator 함수는 g 객체를 반환하는데 next, throw, return 프로퍼티를 반환한다. 반환한 g 객체는 글로벌 변수 g에 넣는다.
- g.next()를 실행하여 next 프로퍼티를 호출한다. next 프로퍼티는 verb(0) 함수를 호출하는데 verb 함수는 step 함수를 실행한 결과를 반환한다. 처음 step 함수의 매개변수 op 에는 [0, undefined] 가들어가게 된다. 왜냐하면 n 값은 verb(0) 을 호출하여 0이 들어가게되고 next()에 매개변수를 넘겨주지 않아 undefined가 들어가게 된다.
- while 문을 돌고 switch 문을 만나 op[0] 을 확인한다. op[0] 은 0이기 때문에 t 변수에 op를 넣고 break 한다.
- 아래에 op = body.call(thisArg, _); 에 의해 __generator 함수에서 두번째인자로 넘겨준 콜백함수를 실행한다. 콜백함수는 gen 함수에 정의되어있다.
- switch 문을 만나 _a.label 을 확인한다. _a.label의 값은 0이라서 console.log("첫번 째 next"); 를 실행하고 [4, 1] 변수를 반환한다.
- 반환한 값은 op 변수에 담기게 된다.
- while 문이 끝나지 않았으니 다시 switch 문을 만나 op[0] 을확인한다. 이번에 op[0]은 4이기 때문에 case 4 에 들어간다. _.label의 값을 1증가시키고 {value: op[1], done: false} 객체를 반환한다. 이떄 op[1]은 1이므로 {value: 4, done: false} 를 반환한다.
- 두번 째 세번 째 next() 도 위와 같이 실행된다.
- 마지막 g.next()는 [2, undefined]를 op 변수에 전달해준다. switch 에서 case 2는 없으므로 default 로 들어가게 된다. (여기서부터 잘모르겠지만...) default 의 첫번째 if 를 보면 op[0] === 2 인지 확인하고 맞으면 _ 객체를 0 으로 변경하고 break 한다. while 에서 _ 가 0 이므로 false 가되어 while 문을 나가게 되고 {value: op[0] ? op[1] : void 0, done: true} 를 반환한다. value 프로퍼티의 값은 op[0]이 값이 존재하므로 op[1]의 값을 가지게 되는데 op[1]이 undefined 이므로 {value: undefined, done: true} 를 호출하게 된다.
next() 는 매개변수를 가질수도 있다. 매개변수를 가지게 되면 기능이 조금 달라진다. 아래의 예시를 보자.
function* gen() {
var bar = yield 'foo';
console.log(bar); // bar
}
var g = gen();
console.log(g.next()); // {value: 'foo', done: false}
console.log(g.next('bar'));
yield와 next()를 배웠으면 당연히 변수 bar 에는 {value: 'foo', done: false} 가 들어가야 하지 않냐는 생각이지만 전혀 아니다. 일단 위의 코드를 실행하면 아래와 같다.
{value: "foo", done: false}
bar
{value: undefined, done: true}
왜 bar 변수에 문자열 "bar" 가 들어가있지???? 하는데 그 이유는 next('bar') 때문이다. 보통 next()를 실행하면 yield가 next()에게 값을 반환하는 방식이었다. 하지만 next에 매개변수가 들어가게되면 반대로 next가 yield 에게 값을 주게 된다. 즉, 두번째 next()는 매개변수 "bar"를 yield에게 넘겨주에 bar변수에 문자열 "foo" 대신 "bar"가 들어가게 된다.
중간에 return을 만나면 어떻게 되지??
next() 메서드는 yield를 만날 때까지 내부의 함수를 실행한다고 했다. 그럼 중간에 return을 삽입하면 아래에 있는 yield까지 도달을 할수있을까?
function* gen() {
yield 1;
return 1.5;
yield 2;
}
var g = gen();
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 1.5, done: true}
console.log(g.next()) // {value: undefined, done: true}
정답은 실행이 되지 않는다 이다. next() 메서드를 실행하여 return을 만나게 되면 그자리에서 리턴값과 done이 true가 되어 제너레이터 함수를 종료한다.
yield *
제너레이터 키워드 (*) 는 yield 에도 사용 할 수있다. 그럼 yield도 제너레이터가 되는건가?? 라고 생각하는데 뭐, 비슷한 말이기도 하다.
function* gen1() {
yield 1;
yield 2;
}
function* gen2() {
// yield* 가 gfn1 을 위임한다.
yield* gen1();
yield 3;
}
var g = gen2();
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: 3, done: false}
console.log(g.next()); // {value: undefined, done: true}
위의 코드에서 gen2의 함수에서 yield* gen1(); 이 새로운 특징이다. yield*는 제너레이터를 이중으로 실행할 때 사용할 수 있다. 처음 g.next()를 실행하면 yield*는 gen1 제너레이터 함수를 위임하게 되고 gen1 함수를 실행한다. gen1 함수에서 yield 1을 만나 value 값이 1이 호출이 되는 것이다.
이처럼 제너레이터 함수를 다중으로 사용할 때는 yield* 키워드를 사용하여 중첩으로 실행할 수 있다.
return()과 throw()
제너레이터 객체는 next() 말고도 return()과 throw()를 지원한다. 아래 예시를 보자
function* gen() {
yield 1;
yield 2;
yield 3;
}
var g = gen();
console.log(g.next()); // {value: 1, done: false}
console.log(g.return(123)); // {value: 123, done: true}
var g2 = gen();
console.log(g2.next()); // {value: 1, done: false}
console.log(g2.throw("error 호출")); // 에러 호출, 제너레이터 종료
return() 메서드는 해당 매개변수를 value에 담고 강제로 제너레이터 함수를 종료한다. 위의 예시를 보면 g.return(123)으로 인해 done이 true가 되어 제너레이터 함수를 종료했다.
throw() 메서드는 해당 매개변수를 메시지로 에러를 호출한다. throw 메서드도 return 메서드와 같이 제너레이터 함수를 강제종료한다.
다시 콜백지옥 소환
function getId(phoneNumber, callback) { /* … */ }
function getEmail(id, callback) { /* … */ }
function getName(email, callback) { /* … */ }
function order(name, menu, callback) { /* … */ }
function orderCoffee(phoneNumber, callback) {
getId(phoneNumber, function(id) {
getEmail(id, function(email) {
getName(email, function(name) {
order(name, 'coffee', function(result) {
callback(result);
});
});
});
});
}
이제 위의 콜백 지옥을 제너레이터를 활용하여 비동기코드를 비동기인 듯 비동기 아닌 비동기 같은 코드로 만들어보자! 위에서도 설명했듯이 제너레이터는 함수를 실행도중에 멈출수 있는 좋은 기능이다. 그럼 서버에 데이터를 얻어올 때까지 제너레이터에서 제어를 해주면 아주좋게 되지 않을까?
일단 먼저 orderCoffee 함수에 제너레이터 (*) 를 추가하고 yield를 추가하자. 변경하면 아래와 같은 코드가 될 것이다.
function* orderCoffee(phoneNumber) {
const id = yield getId(phoneNumber);
const email = yield getEmail(id);
const name = yield getName(email);
const result = yield order(name, 'coffee');
return result;
}
와우! 한층 간결해졌고 원하는대로 잘동작 할것만 같다. 이제 데이터로드가 완료되었을 때 next() 메서드에 매개변수를 전달해서 변수에 담아주기만 하면 된다.
const iterator = orderCoffee('010-1234-1234');
iterator.next();
function getId(phoneNumber) {
// …
iterator.next(result);
}
function getEmail(id) {
// …
iterator.next(result);
}
function getName(email) {
// …
iterator.next(result);
}
function order(name, menu) {
// …
iterator.next(result);
}
마지막 order 함수의 next를 통하여 result 값을 안전하게 받아올수가 있다. 이렇게하면 콜백지옥의 비동기 코드에서 동기적이지만 비동기같은 기능을 수행할 수 있는 좋은 코드를 작성할 수 있다.
제너레이터를 마치며
지금까지 자바스크립트에서 제너레이터를 사용하여 프로그래밍을 하는 방법에 대해 알아보았다. 제너레이터를 사용하면 비동기 코드를 마치 동기식 코드를 작성하는 것처럼 작성할 수 있다. 서버에서 대용량의 데이터를 받아와서 순차적으로 처리하고 싶을때 사용하면 유용할 것 같다. 실제 사용해보면 알겠지만, 복잡한 비동기 코드를 다룰 때 이를 활용하면 이전과 비교할 수 없이 편하게 코드를 작성할 수 있을 것이다.
참고:
- http://meetup.toast.com/posts/73
- https://basarat.gitbooks.io/typescript/content/docs/generators.html
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Generator
'프로그래밍 > JavaScript, TypeScript' 카테고리의 다른 글
[JavaScript] Chrome V8 엔진 (0) | 2018.03.11 |
---|---|
[JavaScript, TypeScript] 코드 보안 (1) | 2018.01.14 |