callback -> Promise -> async / await

konu·2025년 1월 9일
0

Node.js

목록 보기
2/2
post-thumbnail

(이 작은 청년이 오늘도 제 마음을 찢었습니다...
이번에도 티켓팅에 실패했거든요)

0. 배경

Node.js를 접한지도 어언 2달...

'자바스크립트+NestJS 따위는 자프링에 대적할 게 못된다',
'도대체 함수를 왜 클래스 밖에서 선언할 수 있는지 모르겠다'며 지난 시간을 보내왔다.

그런데 최근 들어 typescript를 좀 더 꼼꼼하게 공부하는 과정에서
typescript의 매력을 점점 더 알아갈 것만 같다.

지난 번 이벤트루프에 관한 글을 쓰면서도 느꼈지만
Node.js의 단순, 강력함도 이젠 조금씩 내게 다가온다.

이번에는 지난번 이벤트 루프에 이어,
callback에서 Promise를 거쳐 async / await로 이어지는 3부작을 다룬다.

 

(난 그저 이 청년이 노래 부르는 걸 듣고 싶을 뿐인데...)

 

1. callback

콜백은 왜 필요한가?

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)
}

여기에서의 콜백은 성공했을 때와 실패했을 때를 모두 처리할 수 있도록 되어 있다.

 

callback hell

요즘에는 아티스트가 앨범을 낼 때 한번에 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)

 

2. Promise

Promise? 약속?

Promiseproducing codeconsuming code를 연결해주는 특별한 자바스크립트 객체다.

팬은 곡이 나오면 듣겠다는 약속을 해놓고, 아티스트가 시간이 얼마나 걸리든 곡을 쓰고 나면
promise는 그 곡을 팬에게 전달해주는 역할을 한다.

 

resolve & reject

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를 호출한다.

참고로, resolvereject 중 하나라도 호출되는 경우 그 뒤의 resolvereject는 완전히 무시된다.
곡을 발표했다가 철회하는게 불가능한 것처럼.

 

Promise의 상태

promise는 stateresult라는 프로퍼티를 가진다.
executor 실행 전에는 pending state와 undefined result를 가진다.

그런데 resolve가 호출되면 fulfilled state와 value(결과) result를 가진다.
반대로 reject가 호출되면 rejected state와 error result를 가진다.

 

consumers

생각해보니 Promise를 설명하면서 지금까지 팬에 대한 언급이 없었다.
그럼 도대체 신곡이 발표되면 팬이 하고싶은 덕질들은 어디에 써넣어야 한단 말인가?

then

promise.then(
  function(song) { listen(song) },
  function(error) { tweet(error) }
);

then은 2개의 파라미터를 받는다.

첫번째 파라미터는 resolve가 호출되었을 때 실행될 함수다.
두번째 파라미터는 reject가 호출되었을 때 실행될 함수다.

 

 

promise.then(
  song => listen(song)
);

내가 만약 회피형이라서 곡을 내지 못한다는 소식 따위는 접하고 싶지 않다면,
즉 에러를 처리하고 싶지 않다면 위 코드처럼 resolve 상황에 대한 콜백만 명시할 수도 있다.

 

catch

promise.catch(
  error => tweet(error)
);

반대로 에러에만 관심이 있다면 catch를 사용할 수 있다.

 

finally

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 간의 순서를 보장받을 수 있을까?!

 

chaining

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 )

 

3. async / await

이제 Promise를 가장 쉽게 사용할 수 있는 syntax인 async / await를 톺아볼 차례다.

async function

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)

그래서 정확하게는 이렇게 나가게 되는 것이다.

 

await

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에서 에러를 받으면 그만이다.

 

(조휴일씨 노래하는 걸 한번만 듣게 해주세요)

 

4. 결론

처음에 회사 소스코드를 처음 들여다봤을 때
수많은 async와 await를 보고 그냥 나도 반사적으로 썼다. 다른 데서도 쓰니까.

그치만 그렇게 개발하지 않기로 다짐해왔건만이라고 스스로를 다그쳐 이 글이라도 쓰게 된 것만 같다.
많이들 타의로 접하게 될 노드 앞에서 이 글이 조금이라도 도움이 되길 바란다.

profile
日日是好日

0개의 댓글