기술 블로그 글 리뷰
코루틴이란 말, 많이 들었지만 자바로 프로그래밍을 배운 개발자로서 쉽게 이해하기 힘든 개념이에요. 사실 동기, 비동기라는 용어도 헷갈립니다.
요청과 동시에 응답을 받으면 동기(sync)라고 하는데, 이 표현은 개념적 표현이고 실제로는 요청과 '동시에' 응답을 받지는 않죠. 요청을 한 뒤 응답을 받을 때까지 caller의 흐름은 멈추고(blocking) 응답을 받아야 다음 흐름이 이어집니다. 하지만 코드상으로는 '멈추는 걸' 표현하지 않기 때문에 동기 프로그래밍이라고 하는 것 같습니다. 비동기는 반대로 요청을 하고 응답이 바로 올 것을 기대하지 않습니다. 특정 작업을 요청하고 요청 결과는 콜백 패턴이나 프로미스 패턴을 통해서 처리합니다(non-blocking). 비동기 코드를 보면 코드 흐름대로 계산이 진행되지 않기 때문에 비동기라고 표현을 하고요.
코루틴이란 개념을 이해하기 위해서는 이 동기와 비동기의 차이점을 명확히 이해하고 비동기 작업을 우리가 알아보고 쉬운 동기 프로그래밍처럼 표현하는 방법들부터 차례차례 알아봐야 합니다.
여러 블로그 글들을 읽었는데요. 김정환님 블로그의 '제네레이터와 프라미스를 이용한 비동기 처리' 글을 통해 이 개념들을 정리해보려고 합니다.
http://jeonghwan-kim.github.io/2016/12/15/coroutine.html
작가: 김정환님, 우아한 형제들에 재직 중이시고 인프런에서 자바스크립트 강의도 하고 계시네요.
우선 가장 기본적인 콜백 스타일의 비동기 코드입니다.
const getId = cb => {
setTimeout(() => cb(1), 1)
};
const getNameById = (id, cb) => {
setTimeout(() => cb('chris'), 1)
};
getId(id => {
getNameById(id, (name => {
console.log({id, name})
}));
});
콜백을 통해서 비동기로 이뤄지는 작업들에 대한 후처리 작업들을 표현했습니다.
하지만 이 패턴은 비동기 작업을 동기성 흐름으로 만들려는 의도가 연속되면 콜백이 계속 중첩되는 콜백 지옥을 낳았죠.
이를 해결하기 위해서 Promise 패턴이 나왔습니다.
const getId = () => new Promise(resolve => {
setTimeout(() => resolve(1), 1);
});
const getNameById = id => new Promise(resolve => {
setTimeout(() => resolve('chris'), 1);
});
getId().then(id => {
getNameById(id).then(name => {
console.log({id, name})
});
});
콜백 함수를 감싸는 Promise 객체를 통해서 코드 가독성을 높였습니다. 하지만 단순히 콜백을 감싼 래퍼 객체는 코드를 조금 보기 편하게 바꿔줬다 뿐이지 연속되는 비동기 작업은 아래와 같이 파이프를 통해서 처리할 수밖에 없었습니다.
Promise.resolve({})
.then(obj => getId(obj))
.then(obj => getNameById(obj))
.then(obj => console.log(obj));
제너레이터는 일반적인 형태의 함수가 아닙니다.
일반함수(서브루틴)은 caller가 함수를 call하면, callee 함수는 콜스택에서 작업을 처리하고 작업 처리 후에는 콜스택에서 사라집니다. 콜스택을 나간 함수를 다시 호출한다면 처음부터 다시 실행되겠죠.
반면 제너레이터는 callee 함수의 진입점을 지정할 수 있습니다. 해당 컨텍스트를 저장하고 반환하는 특수한 구조를 갖기 때문입니다.
caller와 callee가 콜스택에 들어가 수행되고 서로 호출하는 구조가 제너레이터입니다.
function* gen () {
const id = yield getId();
const name = yield getNameById(id);
console.log({id, name});
}
const g = gen();
g.next().value.then(id => {
g.next(id).value.then(name => {
g.next(name);
})
})
1. 메인 함수에서 g.next()를 호출하면 콜스택에 제너레이터(gen) 함수가 들어간다.
2. 제너레이터에서 getId() 까지 함수가 실행되고 프로미스가 나온다. 그리고 yield 키워드는 이 프로미스를 감싸 {value: Promise, done: false} 객체를 만들어 메인 함수로 반환한다.
3. 메인함수에서는 래핑된 Promise를 then을 이용해 처리하고 얻은 id 값을 다시 제너레이터 함수에 넘기는 next() 함수를 실행한다.
4. 제너레이터는 메인함수로부터 전달받은 id 값을 id에 저장하고 다음 getNameById(id) 함수를 실행한다. 역시 이는 프로미스를 반환하고 yield 키워드는 {value: Promise, done: false} 객체를 메인함수로 반환한다.
5. 메인함수는 전달받은 프로므시를 해결하고 name 값을 얻는다. 그리고 마지막으로 name 값을 넘기면서 제너레이터를 호출한다.
6. 제너레이터는 받은 name 값을 name 상수에 저장한 뒤, id, name을 출력한다.
이 흐름을 읽으면 메인 함수가 제어권을 서로 주거니 받거니하는 모습이 그려집니다. 메인함수, 제너레이터가 각자 컨텐스트를 유지하면서 서로 호출하는 구조를 가지기 때문에 가능합니다.
next(main) → promise(gen) → yield(gen) → then(main) → // id 획득
next(main) → promise(gen) → yield(gen) → then(main) → // name 획득
console.log(gen)
이렇게 메인루틴과 서브루틴이 서로 호출하는 구조를 코루틴(coroutine)이라고 부릅니다.
제너레이터를 코루틴 관점에서 코드를 까보면 아래와 같습니다.
const co = gen => new Promise(resolve => {
const g = gen();
const onFulfilled = res => {
const ret = g.next(res);
next(ret);
}
const next = ret => {
if (ret.done) return resolve(ret.value);
return ret.value.then(onFulfilled)
}
onFulfilled();
});
co(gen).then(user => console.log(user));
위 함수 구조를 통해서 상호 재귀 모습을 확인할 수 있었는데요, co() 함수 내에서 onFulfilled()와 next() 함수가 서로 호출하는 모습을 보이듯, 제너레이터의 yield와 메인 함수의 next는 서로 호출하면서 코루틴을 구현합니다.
"실행-정지-실행-정지"로 보이는 코루틴 특성을 '동기적으로 동작'이라는 특성으로 정리할 수 있는데요. 비동기 코드를 동기적으로 작성하는데 도움을 줄 수 있다고합니다. 이는 코드가 작성된 형태는 동기적이나 실행되는 방식은 비동기라는 의미겠죠!?
코루틴 좀 이해해볼까~에서 출발해서 제너레이터 구현 원리까지 보게됐는데요. 비동기로 동작할 수 밖에 없는 상호 작용들을 동기적으로 표현하기 위한 노력들을 공부하고 이해할 수 있었습니다.