6. GENERATOR, PROMISE, ASYNC/AWAIT
GENERATOR - 여태껏 iterator를 반환해주는 편리한 용법 정도로만 바라봤다.
그런데 이번 시간엔 iterator를 반환해주는 걸로 보지 않고 제어문을 중간에 멈출 수 있는 능력을 가진 실행기의 일종으로 볼 것이다.
BREAKING BLOCK
프로그램을 중도에 멈췄다가 다시 실행할 수 있다.
프로그램을 적재한 순간 실행이 종료될 때까지 멈출 수 없는 건데, 제너레이터는 서스팬션이라는 기능으로 중간에 제어문을 멈출 수 있다.
이렇게 중간에 멈출 수 있는 기능을 언어가 갖기 위해선 언어가 직접 명령어를 메모리에 적재하지 않고 한번 더 감싸서 이 감싼걸 실행시킬지 말지를 정해야 된다.
자바스크립트는 명령어 한줄 한줄을 레코드로 만들어 적재한다.
이 레코드를 실행할지 말지를 보는 거다.
여러분들은 ‘문’을 사용해 코드를 짰지만 자바스크립트는 이런 ‘문’을 마치 객체처럼 만들어서 중간에 실행할지 말지를 정하는 것이다.
이러한 언어들은 여러분들이 딱히 ‘객체’를 안 만들어도 ‘문’ 수준에서 이런 기능이 다 된다.
이러한 기능을 제공하지 않는 언어라면 여러분들이 직접 ‘문’을 ‘객체’처럼 만든 다음에 실행할지 말지를 결정해야 된다.
이 패턴이 바로 커맨드 패턴이다.
디자인 패턴 중에 커맨트 패턴이라고 있는데, 커맨드 패턴은 ‘문’을 하나하나의 객체로 만들어서 어디까지 실행할지, 되돌릴지, 그만할지, 더 할지를 결정해줄 수 있다.
그래서 커맨드 패턴을 사용하면 undo나 redo가 되는 이유가 바로 이거다.
원래 ‘문’은 한 번 실행하면 끝인데, 커멘드 패턴으로 만들면 undo, redo, 매크로 등등을 만들 수 있다.
‘문’을 ‘객체’화 시켰기 때문.
자바스크립트, 파이썬, C# 같은 서스팬션 기능을 제공해주는 언어들은 어떻게 이를 제공해 줄까?
내부적으로 커멘드 객체로 ‘문’을 다 바꿨기 때문에 가능한 일이다.
// 아래 제너레이터는 무한히 루프문을 돌지만
// next를 호출할 때 한 번씩만 돌기 때문에
// 아래 식은 프레임을 다운시킬 일이 없다.
// 원래 아래 while 문은 무한으로 도니까 timeout 걸려서 죽어야 하는데 yield로 그 순간 프레임을 멈출 수 있다.
// 우리가 배운 프로그래밍 상식상 불가능한 일
const infinity = (function*() {
let i = 0;
while(true) yield i++;
})();
console.log(infinity.next());
console.log(infinity.next());
이런 불가능한 것이 가능한 이유는 중간에 yield
로 제어문을 멈출 수 있기 때문이다.
yield를 이용하면 블록을 중간에 끊어주는 효과가 발생한다.
블로킹 : FLOW에서 제어권을 넘기지 않는 것(CPU 먹통)
그런데 제너레이터를 활용하면 CPU에게 제어권을 다시 넘겨주고 멈출 수 있다.
이 성질을 이용해보자.
BLOCKING EVASION
Time slicing manual
// 한번에 3개씩 처리 - timeout의 위험성을 줄이고 있다
const looper = (n, f, slice = 3) => {
let limit = 0, i = 0;
const runner = _ => {
while(i < n) {
if (limit++ < slice) f(i++);
else {
limit = 0;
requestAnimationFrame(runner);
break;
}
}
}
requestAnimationFrame(runner);
}
looper(10, console.log);
위 식을 제너레이터로 바꿔보자.
// 차이점 - 기존 식에선 다음 프레임으로 함수를 넘겼는데, 제너레이터는 yield로 중지시켰다.
const loop = function*(n, f, slice = 3) {
let limit = 0, i = 0;
while(i < n) {
if (limit++ < slice) f(i++);
else {
limit = 0;
yield;
}
}
}
// 이런 제너레이터를 실행하는 실행기 필요
// 이 실행기가 처리하는 것을 보면, iter(iterator)를 받아서
// iterator 객체의 done이 true인지 false인지 판단 후, false이면 다음 프레임에 runner를 실행시키도록 했다.
// 즉, 이 실행기는 iterator를 받아서 iterator를 requestAnimationFrame으로 실행시켜버린다.
const executor = iter => {
const runner = _ => {
if (!iter.next().done)
requestAnimationFrame(runner);
};
requestAnimationFrame(runner);
}
executor(loop(10, console.log));
위에서 실행기는 루프가 어떻게 일어나는지 알지 못한다.
그 이유는 루프관련 내용은 다 loop 함수에 몰려있기 때문이다.
반면 제너레이터로 바꾸기 식을 보면 루프를 어떻게 돌리는지에 대한 로직도 있고 루프를 돌릴지 말지에 대한 판단도 같이 갖고 있다.
즉, 섞여있다는 것이다. 그 말은 재활용 할 수 없다는 뜻이다.
또 다른 차이점 - 자율변수와 지역변수
제너레이터에선i
,limit
가 지역변수로 선언되어 있다.
제너레이터에선 복잡한 자율변수나 스코프를 인식하지 않아도 된다.
제너레이터 이전 식 - 지역변수 처럼 보이지만runner
라는 함수를 만들 때,자율변수
로 되어있는 스코프 바깥에 있는 변수들을 캡쳐한다.(클로져)
이런 식으로 되어있는 코드는 상태관리를 할 때 보다 더 복잡한 생각을 해야된다.
이렇다 보니까 매번runner
함수를 만들어야 된다.
지역변수를 캡쳐하기 위해서runner
함수를 만들어야 되기 때문.
제너레이터를 활용하니까 코드가 훨씬 직관적으로 변했다.
제너레이터를 통해 루프 추상화 성공. 실행기와 분리.
진짜 실행기와 분리되었는지 볼까?
실행기를 setInterval
로 갈아치워도 loop
함수는 한글자도 수정 안해도 된다.
루프를 도는 로직과 실행기를 도는 로직을 완전히 분리하면 이런 효과를 볼 수 있다는 것이다.
우리가 추구했던 바가 뭐다?
루프를 돌리는 조건이나 상태에 관한 것은 추상화하고 얘를 어떻게 루프돌릴지(스프레드, Rest, 해체, for…of 등등..)
이런 것들은 바깥에서 결정하도록 하자.
이럴려면 루프 추상화를 이뤄야된다.
const loop = function*(n, f, slice = 3) {
let limit = 0, i = 0;
while(i < n) {
if (limit++ < slice) f(i++);
else {
limit = 0;
yield;
}
}
}
const executor = iter => {
const runner = _ => {
if (!iter.next().done)
requestAnimationFrame(runner);
};
requestAnimationFrame(runner);
}
executor(loop(10, console.log));
const loop = function*(n, f, slice = 3) {
let limit = 0, i = 0;
while(i < n) {
if (limit++ < slice) f(i++);
else {
limit = 0;
yield;
}
}
}
const executor = iter => {
const id = setInterval(_ => {
if (iter.next().done) clearInterval(id);
}, 10);
}
executor(loop(10, console.log));
실행기를 동기적으로 돌리던 비동기적으로 돌리던 어차피 로직을 도는건 loop 함수에 추상화되어 있기 때문에, 실행기는 얼마든지 교체할 수 있다.
실제로 이렇게 만들어야 사용성이 높아진다.
원격지에서 loop 함수를 따로 돌려서 제어할 수도 있기 때문이다.
GENERATOR + ASYNC + EXECUTOR
제너레이터가 멈출 수 있다는 사실과 next
를 통해 다음 턴으로 들어간다는 사실을 알았으니까 이번엔 비동기와 결합해보자.
// 아래 함수는 세 개의 인자를 받았다.
// 제너레이터는 코루틴이기 때문에 값을 여러번 주고 받을 수 있다.
const profile = function*(end, next, r) {
// member.php에 r(row id)를 보냈더니 next를 통해 값을 받아 userid 상수에 저장했다.
// yield로 잠시 멈춤
const userid = yield $.post('member.php', {r}, next);
// detail.php에 userid값을 보내고 next를 통해 값을 받아와 added 변수에 저장했다.
let added = yield $.post('detail.php', {userid}, next);
added = added.split(',');
end({userid, nick:added[0], thumb:added[1]});
};
// 여기서 질문
// $.post는 비동기인데 어떻게 순서대로 진행된거지?
// 그 해답은 next인자 값으로 전달되는 executor 함수(실행기)에 있다.
const executor = (end, gene, ...arg) => {
// 제너레이터 : iterator 객체 next 호출할 때 값을 보낼 수 있다.
// 아래 iterator는 그 아래 generator를 호출해서 받아온 것이다.
const next = v => iter.next(v);
const iter = gene(end, next, ...arg);
iter.next();
};
executor(console.log, profile, 123);
위 소스 복습
- 제너레이터 함수를 호출하면 이터레이터 객체 반환
- 이 이터레이터 객체에서 next 함수를 호출해야 값 반환
- profile 함수 첫 번째줄 부터 yield 에 걸렸으니 그 아래론 못 내려간다.
- 그 줄을 보면 $.post 메서드로 바로 비동기로 넘겨버렸다. 그럼 이 비동기는 언제 완료될까?
- next 인자로 받아온 콜백 함수가 실행되고 완료되면 완료된다.
- next 함수가 실행될 때 v 인자로 뭘 받아? 로딩한 response text를 받는다. 이 값을 next로 넘겨준다.
- 그리고 next 인자로 받아온 이터레이터가 v 인자를 받아 next메서드를 실행시켜 다음으로 넘겨주면 그것이 yield의 결과값이 된다.
그래서 비동기이지만 iterator next로 v 값이 들어오는 시점 jQuery가 onload가 된 시점, success가 된 시점에 next의 값이 userid로 들어오게 된다.
그래야지 비로소 해당 yield
가 풀리고 다음이 진행되는 것이다.
그런데 또 바로 다음 yield
에 걸리고 만다. (added)
그럼 같은 원리에 의해 added 변수에 v 값이 담기게 된다.
실행기가 제너레이터를 쓰는 코드는 복잡할 수 있으나.. profile 함수를 한번 보자.
// 비동기인데 콜백 지옥이 없다.
// 비동기인데 마치 동기처럼 소스를 작성했다.
// 실행기에서 힘든 일을 다 처리하기 때문에 제너레이터 안의 소스는 깨끗하다. 어떻게 깨끗하다?
// 동기 로직처럼 보인다. 이해하기 편하다. 널뛰기를 하지 않아.
// 실행기는 널뛰기를 할지언정 profile은 널뛰기를 하지 않는다.
const profile = function*(end, next, r) {
const userid = yield $.post('member.php', {r}, next);
let added = yield $.post('detail.php', {userid}, next);
added = added.split(',');
end({userid, nick:added[0], thumb:added[1]});
};
const executor = (end, gene, ...arg) => {
const next = v => iter.next(v);
const iter = gene(end, next, ...arg);
iter.next();
};
이렇게 비동기 로직을 동기 로직처럼 보이게 하기 위해 profile과 executor를 나눈 것이다.
우리가 원하는 코드는 profile에다 기술하고, 밑에 실행기가 힘든 일을 처리해라. 라는 것.
여러번 입력되고 여러번 값을 넘긴다.
여러번 추적되고 next 함수에 값을 넘긴다.
이것을 우리는 제너레이트의 코루틴 패턴이라고 부른다.
실제 이거를 구현한 ‘CO’ 라이브러리가 굉장히 유명하다.
PROMISE - PASSIVE ASYNC CONTROLL
콜백과 프로미스의 차이점 - 콜백이 무엇인지를 우선 이해할 필요가 있다.
콜백과 프로미스는 철학적으로 굉장히 다르다.
콜백은 보낼 수는 있지만 언제 올지는 모른다.
$.post
를 보내고나면 그 다음 end
가 언제올지 어떻게 알 수 있을까? 모른다.
그럼 우리는 콜백에게만 의존할 수 밖에 없게된다.
$.post
이후 모든 일든은 콜백 안에서만 일어나야 한다.
그럼 $.post
를 기준으로 밑에 있는 애들은 전멸하는 거잖아? 언제부터 일어날지도 모르니까.
콜백이 언제 완료되는지를 모르기 때문에 콜백 하나 걸고 두 개를 걸면, 두 개의 경합을 따질 수가 없다.
그렇기 때문에 콜백 안에 콜백을 사용하게 된다.
이렇게하면 이제부터 우리는 콜백의 노예.. 콜백이 빨리 들어올 수도 있고 늦게 들어올 수도 있다.
콜백이 늦게 들어온다고 한다면 프로그램이 죽어버린다. 유일한 방법은 timeout을 거는 수밖에..
이렇게 콜백 지옥이 시작된다.
$.post(url, data, e => {
// 언제 올까
})
왜 언제가 중요한가?
// 아래 식들.. 우리가 기대로한 순서에 의해 실행될까?
// 처음 $.post가 실행돼서 result에 v값이 담긴 후에 두번째 $.post가 실행될까?
// 아니다. 이렇게 될 수도있고 아닐 수도 있다. 저걸 100% 보장할 수 없다. 아마 거의 안될 것이다.
let result;
$.post(url1, data1, v => {
result = v;
});
$.post(url2, data2, v => {
result.nick = v.nick;
report(result);
})
그래서 액티브 어싱크 컨트롤을 하게 된다.
ACTIVE ASYNC CONTROLL
프라미스의 장점
프라미스는 then을 호출해야 결과를 얻는다. 콜백은 결과를 안 받고 싶지만 안 받을 수 있나?
무조건 콜백 함수가 실행되면 결과물이 리턴된다.
왜 콜백 지옥이라고 하냐면, 콜백이 값이 들어오는 걸 결정하기 때문이다. 여러분들이 제어권을 완전히 상실하기 때문이다.
그런데 프라미스는 프라미스를 발동하고난 다음에 then
을 호출하지 않으면 아무 일도 안 일어난다.
이에 반해 콜백은 콜백이 완료되자마자 무조건 호출이 된다. 나에게 제어권이 없다.
then
을 호출하는 시점에 프로미스 안의 비동기로직이 이미 해소되었을 때가 있고 안되었을 때가 있을 것이다.
그럼 내가 then
을 호출한 시점에 프로미스는 어떤 기능을 하게 되냐면, 만약 then
이 호출된 시점에 비동기가 완료되었어.
그럼 then
을 실행해.
그런데 내가 then
을 호출했지만 아직 내부에서 비동기가 해소가 안됐어.
그러면 대기했다가 완료되면 호출한다.
이렇게하면 내가 비동기에 끌려다니는 것이 아니라 내가 원할 때 호출할 수 있다는 것이다.
콜백은 LOAD되면 나의 제어권을 떠난다. 무조건 실행.
프로미스는 미친척하고 setTimeout 으로 then을 걸어도 된다는 것이다.
setTimeout이 아니라 유저가 클릭할때까지 안보여줄 수도 있는 것이다.
미리 로딩은 하지만 숨기기 좋다는 것이다.
이는 preload
를 만들기 좋다.
우리는 click 했을 때 then을 호출한다만 알고 있으면 된다.
프로미스를 사용 안하면 이걸 콜백함수에 다 구현해줘야된다.
then
을 호출했지만 아직 비동기로직이 완료되지 않았다면 아직은 패시브 ASYNC 상태.
그래도 비동기 로직이 완료되면 then
호출.
그래, then
으로 제어할 수 있게 되었다는 게 어디야.
비동기인데 제어권을 우리가 어떻게 갖지? 라는 연구에서 출발해 프로미스가 등장함.
$.post 를 콜백으로 받는 순간 우리는 모든 제어권을 포기하고 콜백함수가 하고싶은 대로 해. 라는 게 되는 거다.
하지만 이 통신을 콜백이 아닌 Fetch
함수로 하면, ‘너는 url에 대한 데이터를 준비해, 하지만 준비만해. 내가 then
으로 호출하면 그때 나와.’라는 것이다.
- 콜백은 제어 X : 내가 의도한대로 모든게 다 로딩된 후에 이게 나왔으면 좋겠는데, 그러지도 않은데 먼저 나온다거나 그럼.
- 프로미스는 제어 O :
then
으로 모든게 다 로딩이 끝난 후에 나오도록 제어 가능.
보통 회사들 코드를 보면 이런식으로 짜놓은 코드가 많다.
아 우리회사 A가 로딩되는데 2초걸리니까 B는 3초 걸어놔야겠다.
…
그러다가 망이 느린곳 가서 A가 로딩이 2초만에 안돼!
그럼 다 깨지는 것.
이를 Promise
가 한번에 해결할 수 있다는 것이다. A가 몇초만에 로딩되는지는 신경쓰지마. 그냥 넌 then
만 호출해. 그러면 안정적인 소스를 짤 수 있어.
ACTIVE ASYNC CONTROLL
프라미스는 then
을 호출해야 결과를 얻는다.
let result;
const promise = new Promise(r => $.post(url1, data1, r));
promise.then(v => {
result = v;
})
const promise1 = new Promise(r => $.post(url1, data1, r));
const promise2 = new Promise(r => $.post(url2, data2, r));
// 다중 통신에 대한 제어권을 확실하게 확보했다.
// 이래서 promise만이 promise.all / promise.race 같은게 가능해지는 것이다.
// then을 호출하기 전까지 기다리니깐 이런게 가능한 것이다.
// promise.all : 다 완료될 때까지 기다려준다.
// promise.race : 빨리된 애들만 내보내고 나머진 취소..가 아니라 then을 호출하지 않은 것 뿐
promise1.then(result => {
promise2.then(v => {
result.nick = v.nick;
report(result);
})
})
제어권을 가져왔기 때문에 Active Async Controll이 될 수 있는 것이다.
GENERATOR + PROMISE
그러면 우리는 이제 제너레이터와 프라미스를 합칠 수 있습니다.
찰떡 궁합이다. 아까 콜백시점에서 제너레이터에서 next
를 호출했었는데, 지금은? then
을 호출하면 된다.
// 아까 콜백 기반을 프로미스 기반으로 바꾼 소스
// 아래 profile 함수는 Promise 객체를 사용해서 예시를 보여드릴려고 아래처럼 쓴거고
// 원래 Promise 객체가 기반인 API를 사용하면 된다. - Fetch 같은 거
// 여러분이 기존 콜백들을 프로미스화 시키고 싶다면 아래처럼 기존 콜백을 프로미스로 감싸기만해도 된다.
const profile = function*(end, r) {
const userid = yield new Promise(res => $.post('member.php', {r}, res));
let added = yield new Promise(res => $.post('detail.php', {userid}, res));
end({userid, nick: added[0], thumb: added[1]});
}
const executor = (gene, end, ...arg) => {
const iter = gene(end, ...arg);
const next = ({value, done}) => {
if (!done) value.then(v => next(iter.next(v)));
};
next(iter.next());
}
executor(profile, console.log, 123);
후방 제어권을 원하면 프로미스를 쓰면 되고, 그런거 없이 알아서 되라~ 이러면 콜백쓰면 되고.
그런데 웬만하면 프로미스가… 좋은 듯.
비동기는 시간의 흐름을 제어하는 것과 같고 시간의 흐름을 제어하기위해선 콜백함수가지고는 안된다. 프로미스를 써야된다.
프로미스를 안 써도 되는 경우는 천재 개발자일 경우? 콜백함수를 기가막히게 너무 잘짜. 이러면 모르겠지만..
ASYNC / AWAIT - ES7 (C#에서 온 기능)
HTML5 - Canvas - 맥에서 들어왔다.
자바스크립트 ES5 - 야후 한창 잘 나갈 때, 더글라스 크락포드? 이사람꺼 밀어줬댔나?
여튼 뭐 그때그때 자기네꺼 넣고싶은거 막 덕지덕지 넣었다는 소리 여튼 이런 이유로 자바스크립트 공부가 어려운 거임. 여러 언어의 패러다임이 공존하고 있기 때문.
C#에선 Async / Await를 활용해서 비동기를 동기처럼 보이고 싶어한다.
어? 아까 우리가 구현한 제너레이터랑 똑같네? 라는 생각들지?
AWAIT PROMISE
const profile = function*(end, r) {
const userid = yield new Promise(res => $.post('member.php', {r}, res));
let added = yield new Promise(res => $.post('detail.php', {userid}, res));
added = added.split(',');
end({userid, nick: added[0], thumb: added[1]});
}
const executor = (gene, end, ...arg) => {
const iter = gene(end, ...arg);
const next = ({value, done}) => {
if (!done) value.then(v => next(iter.next(v)));
};
next(iter.next());
}
executor(profile, console.log, 123);
프로미스 부분과 then을 호출하는 부분을 만약에 언어 엔진에서 내장한다면? 이라고 생각해보자.
// 함수 앞에 async라는 키워드를 붙이고 yield 자리에 await라는 키워드를 쓰면
// 눈에는 안 보이지만 async를 위한 executor가 있어서 그 executor가 then을 대신 호출해준다고 생각해보자.
// 그것이 await의 뜻이라 생각해보자.
// 여기서 알 수 있는 점 : 위의 제너레이터 코드와 아래 async / await 코드 100% 일치한다.
const profile = async function(end, r){
const userid = await new Promise(res => $.post('member.php', {r}, res));
let added = await new Promise(res => $.post('detail.php', {userid}, res));
added = added.split(',');
end({userid, nick:added[0], thumb:added[1]});
};
profile(console.log, 123);
async / await에서 await 뒤에는 프로미스 객체가 온다.
이 말은 executor를 언어에서 내장한 것이다.
우리가 짜기 힘들어하는 executor를 내장한 것.
프로미스를 쓰면서 제너레이터도 쓰려니까 executor도 너가 만들어야되고 yield까지 신경써야되니 힘들지?
next에 값 넘기는 것도 힘들고?
그러니까 async / await 써. 그걸 내가 자동화해줄게.
이것이 바로 async / await의 정체이다.
async / await = 중간에 멈출 수 있는 서스팬션 기능 + promise then을 내가 원할 때 호출할 수 있는 기능
그런데 이런 async / await도 아래와 같은 단점이 있다.
그래서 ES8에 ASYNC GENERATOR가 나온 것이다.
ASYNC인데 GENERATOR 기능도 한번에 갖고 있다.
ASYNC ITERATOR 스펙 하에 ASYNC for … of도 생겼고, ASYNC GENERATOR도 생겼다.
ASYNC GENERATOR 메소드도 생겼다.
이것이 ASYNC ITERATOR 스펙이다.
꼭 익혀야할 스펙 중에 하나다. ‘표준’이기 때문.