(이 작은 청년이 오늘도 제 마음을 찢었습니다...
이번에도 티켓팅에 실패했거든요)
Node.js를 접한지도 어언 2달...
'자바스크립트+NestJS 따위는 자프링에 대적할 게 못된다',
'도대체 함수를 왜 클래스 밖에서 선언할 수 있는지 모르겠다'며 지난 시간을 보내왔다.
그런데 최근 들어 typescript를 좀 더 꼼꼼하게 공부하는 과정에서
typescript의 매력을 점점 더 알아갈 것만 같다.
지난 번 이벤트루프에 관한 글을 쓰면서도 느꼈지만
Node.js의 단순, 강력함도 이젠 조금씩 내게 다가온다.
이번에는 지난번 이벤트 루프에 이어,
callback에서 Promise를 거쳐 async / await로 이어지는 3부작을 다룬다.
(난 그저 이 청년이 노래 부르는 걸 듣고 싶을 뿐인데...)
function releaseSong() {
let song = artist.compose()
artist.register(song)
}
어떤 아티스트(예를 들면 검정치마라든가, 예를 들면 조휴일이라든가...)가 곡을 발표하는
코드(not chord, code)가 다음과 같이 있다고 가정하자.
그리고 compose
가 비동기 함수라는 것도 가정하자.
그러면 compose
가 처리되는 동안 이벤트 루프는 releaseSong
다음의 로직을 실행할 수 있게될 것이다.
releaseSong()
listen()
팬은 listen
을 통해 신곡을 듣는다.
그런데 compose
가 비동기적으로 처리되기 때문에,
신곡이 register
되기 전에 listen
이 먼저 호출될 경우 팬은 신곡을 듣지 못하게 된다.
그래서 우리는 새롭게 등록된 신곡을 듣는다고 보장하고 싶다.
function releaseSong(callback) {
let song = artist.compose()
song.onRegister(callback)
artist.register(song)
}
releaseSong(listen)
onRegister
를 통해, 곡이 등록되었을 경우 callback을 호출하도록 미리 등록하는 것이다.
그러한 경우 compose
가 언제 실행을 마치든, listen
이 호출될 것을 보장할 수 있게 된다.
그런데 만약 아티스트가 내라는 곡은 내지 않고,
돌연 멤버 하나가 탈퇴한다든가 군대를 간다든가 육아에 집중한다면?
우리는 이러한 상황을 ‘에러’라고 한다.
그리고 우리는 아직 이 에러 상황을 처리할 코드를 작성하지 않았다.
function releaseSong(callback) {
let song = artist.compose()
song.onRegister = () => callback(null, song)
song.onError = () => callback(new Error(`song {song.title} will not be released`))
artist.register(song)
}
여기에서의 콜백은 성공했을 때와 실패했을 때를 모두 처리할 수 있도록 되어 있다.
요즘에는 아티스트가 앨범을 낼 때 한번에 full 앨범을 내지 않는다.
싱글을 2개 정도 내보고, 반응을 본 후에 비로소 full 앨범이 나온다.
그래서 싱글 A -> 싱글 B -> full 앨범
의 순서를 거치게 된다.
releaseSong(function(error, song) {
if (error) {
handle(error)
} else {
releaseSong(function(error, song) {
if (error) {
handle(error)
} else {
releaseSong(function(error, song) {
if (error) {
handle(error)
} else {
...
앨범을 낸다는 건 행복한 일이지만 이 코드의 가독성은 전혀 happy 하지 않다…
이처럼 순차적으로 진행해야 하는 콜백이 여러 개 끼게 되면서 가로로 늘어지는 경우를 callback hell이라고 한다.
이제 이걸 해결해볼 차례다.
(세상에서 가장 거짓된 Promise)
Promise는 producing code
와 consuming code
를 연결해주는 특별한 자바스크립트 객체다.
팬은 곡이 나오면 듣겠다는 약속을 해놓고, 아티스트가 시간이 얼마나 걸리든 곡을 쓰고 나면
promise는 그 곡을 팬에게 전달해주는 역할을 한다.
let promise = new Promise(function(resolve, reject) {
// executor
try {
let song = artist.compose()
artist.register(song)
resolve(song)
} catch (error) {
reject(error)
}
});
Promise 생성자가 받는 함수(releaseSong
)를 executor라고 한다.
Promise가 생성되면, executor는 자동으로 호출 및 실행된다.
resolve
& reject
는 자바스크립트가 자체적으로 제공하는 콜백이다.
executor의 실행이 완료되어 어떤 결과가 발생하면,
그 결과를 바탕으로 resolve
or reject
를 무조건 호출해야 한다.
예상대로 실행이 완료되었으면(= 곡 발표) resolve
를 호출하고,
예상치 못하게 에러가 발생했다면(= 발표 무기한 연기) reject
를 호출한다.
참고로, resolve
와 reject
중 하나라도 호출되는 경우 그 뒤의 resolve
와 reject
는 완전히 무시된다.
곡을 발표했다가 철회하는게 불가능한 것처럼.
promise는 state와 result라는 프로퍼티를 가진다.
executor 실행 전에는 pending
state와 undefined
result를 가진다.
그런데 resolve가 호출되면 fulfilled
state와 value
(결과) result를 가진다.
반대로 reject가 호출되면 rejected
state와 error
result를 가진다.
생각해보니 Promise를 설명하면서 지금까지 팬에 대한 언급이 없었다.
그럼 도대체 신곡이 발표되면 팬이 하고싶은 덕질들은 어디에 써넣어야 한단 말인가?
then
promise.then(
function(song) { listen(song) },
function(error) { tweet(error) }
);
then
은 2개의 파라미터를 받는다.
첫번째 파라미터는 resolve
가 호출되었을 때 실행될 함수다.
두번째 파라미터는 reject
가 호출되었을 때 실행될 함수다.
promise.then(
song => listen(song)
);
내가 만약 회피형이라서 곡을 내지 못한다는 소식 따위는 접하고 싶지 않다면,
즉 에러를 처리하고 싶지 않다면 위 코드처럼 resolve
상황에 대한 콜백만 명시할 수도 있다.
promise.catch(
error => tweet(error)
);
반대로 에러에만 관심이 있다면 catch
를 사용할 수 있다.
promise
.finally(() => artist.broadcastLive())
.then(result => listen(result))
try-catch에도 finally
가 있듯, promise에도 finally
가 있다.
finally
는 executor가 성공했든 실패했든 호출되는데, 이 때문에 주로 리소스를 닫아주는 데에 사용된다.
그리고 코드를 보면 알 수 있듯, finally
는 매개변수를 가지지 않는다.
결과에 상관없이 호출되어야 하기 때문이다.
let promise = releaseSong();
promise.then(
song => listen(song),
error => tweet(error)
);
promise.then(
song => stream(song)
);
이처럼 then
은 신곡이 나오면 팬이 하고싶은 것들을 여러 개 할 수 있게 해준다.
그런데 지금은 2번의 then
호출 사이에 순서를 보장받을 수 없다.
그러니까 듣지도 않고 스밍부터 돌리는 아이러니한 사태가 발생할 수 있는 것이다.
어떻게 하면
then
간의 순서를 보장받을 수 있을까?!
new Promise(releaseSong)
.then(function(song) {
song.time = listen(song)
return song
}).then(function(song) {
song.feelings = stream(song.time)
return song
}).then(function(song) {
song.comment = write(song.feelings)
return song
})
생각보다 간단하다.
then 간 chaining을 통해 구현할 수 있다.
게다가 위 코드처럼, song 객체의 프로퍼리를 앞의 then
에서 넣어서 넘겨주면
뒤의 then
에서 활용할 수 있다.
( await )
이제 Promise를 가장 쉽게 사용할 수 있는 syntax인 async / await를 톺아볼 차례다.
async function releaseSong() {
let song = artist.compose()
artist.register(song)
return song
}
async
키워드는 해당 함수가 Promise를 반환한다는 것을 보장해준다.
그런데 우리는 return song
하고있지 않은가?
async는 song이 자동으로 Promise로 래핑되어 나가게 해준다.
async function releaseSong() {
let song = artist.compose()
artist.register(song)
return Promise.resolve(song)
}
releaseSong().then(listen)
그래서 정확하게는 이렇게 나가게 되는 것이다.
let value = await promise;
syntax는 매우 간단하다.
Promise 인스턴스 앞에 await
키워드를 붙이기만 하면 된다.
let promise = new Promise((resolve, reject) => {
let song = artist.compose()
artist.register(song)
return Promise.resolve(song)
})
let song = await promise
listen(song)
함수(releaseSong
)의 실행은 song이 반환될 때까지 await promise
줄에서 중단된다.
그리고 Promise가 resolve를 호출하는 순간 재개된다.
함수 실행을 중단한다는 것은 무한루프에 걸린 것처럼
이벤트 루프가 아무것도 못하고 거기에 매여 있다는 뜻이 아니다.
우리가 저번 글에서 배웠듯, 이벤트 루프는 그동안 다른 작업을 진행하게 된다.
(웹 서버를 예로 들면 다른 API 요청을 처리한다든가)
function waitSong(song) {
let promise = Promise.resolve(song);
let result = await promise; // syntax error
}
async
가 붙지 않은 함수는 await
를 사용할 수 없다.
반대로 await
를 사용하려면, async
를 반드시 붙여야 한다.
그도 그럴 것이, async
가 붙지 않았다는 것은 동기적으로 실행된다는 것이고,
동기적으로 실행되는데 비동기적인 await
를 할 수가 없겠지.
then
chaining => await
let firstSingleSong = await releaseSong()
listen(song)
let secondSingleSong = await releaseSong()
listen(song)
let titleSong = await releaseSong()
listen(song)
이제는 then
chaining 없이 간단하게 await
를 호출해서 처리할 수 있다!
(위 코드는 내가 처음 써보는데, 이걸 반대로 chaining으로 적어보는 것도 좋은 연습이 될 거 같다)
try {
let song = await releaseSong()
listen(song)
} catch (error) {
tweet(error)
}
이젠 에러를 처리하는 것도 간단해졌다.
catch
를 사용할 필요 없이, await 호출부를 try로 감싸고 catch에서 에러를 받으면 그만이다.
(조휴일씨 노래하는 걸 한번만 듣게 해주세요)
처음에 회사 소스코드를 처음 들여다봤을 때
수많은 async와 await를 보고 그냥 나도 반사적으로 썼다. 다른 데서도 쓰니까.
그치만 그렇게 개발하지 않기로 다짐해왔건만
이라고 스스로를 다그쳐 이 글이라도 쓰게 된 것만 같다.
많이들 타의로 접하게 될 노드 앞에서 이 글이 조금이라도 도움이 되길 바란다.