블로그

Promise vs Async/Await

ES

무신사 AI 루키 2차 코테를 진행하면서 동시성 제어가 요구사항으로 존재했다. JS에서 동시성 제어를 하기 위해 락을 AI와 함께 구현했는데, 구현된 코드를 보면서 Promise가 어떻게 동작하는지 잘 모른다고 느껴 이전에 몇 번 읽어본 ECMAscript 스펙을 오랜만에 다시 읽어봤다.

기존에는 항상 async/await만 사용했기에, ‘Promise가 완전 대체될 수 있는 거 아니야?’라고 생각했고 결론적으로 이는 완전 틀렸다.

Promise

Promise는 단일 쓰레드로 작동하는 JS에서 IO와 같이 시간이 오래 걸리는 작업이 실행될 때, 프로그램이 계속 이어서 실행할 수 있도록 하는 객체이다. Promise가 없다면 IO 또는 CPU bound인 작업 때문에 메인 쓰레드가 그대로 프리징되는 끔찍한 상황이 발생할 것이다.

Promise.prototype.constructor

우리가 프로미스를 생성할 때 가장 흔하게 사용하는 문법은 다음과 같다.

new Promise(resolve => { 
	resolve("Done!")
})

이때, Promise의 인자로 전달되는 함수를 executor라고 하며, 새로운 Promise 객체를 생성함과 동시에 스택에서 실행된다.

헷갈렸던 부분 중 하나인데, JS에서는 콜백함수를 3가지로 구분해서 다룬다.

  • Array.map이나 Array.foreach처럼 콜백함수를 스택에서 바로 실행
  • Promise.prototype.then 처럼 콜백함수를 microtask queue에 전달
  • setTimeout처럼 콜백함수를 task queue에 전달

Promise의 생성자에 전달되는 executor가 바로 첫 번째 경우에 해당하며, 스택에서 즉시 실행된다. 따라서 위의 예제의 경우에는 프로미스 객체를 생성하면서 프로미스의 상태를 관리하는 내부 슬롯인 [[PromiseState]] 의 값이 FULFILLED가 된다.

Promise가 생성될 때, [[PromiseFulfillReactions]][[PromiseRejectReaction]]이라는 내부 슬롯이 생기는데, 이 두 슬롯에 Promise.prototype.then을 통해 이 프로미스 객체가 settled 됐을 때 실행해야 할 함수들을 저장하고, 프로미스가 settled 됐을 때, 적절한 슬롯에 해당하는 값을 microtask queue에 집어넣는다. 이걸 이용해 우린 프로미스의 상태에 따라 다른 리액션을 적용할 수 있다.

Promise.prototype.then

프로미스에서 가장 중요한 내장 함수인 then이다. 이 함수는 크게 세 가지 기능을 한다.

  1. 결과로 반환할 다음 프로미스 생성
  2. 현재 프로미스의 상태에 따라 resolve/reject 시 실행할 리액션을 등록해놓거나 즉시 실행
  3. 1.에서 생성했던 다음 프로미스 반환

다음 예제와 함께 구체적으로 어떤 역할을 하는지 살펴보자.

new Promise(resolve => { 
	resolve("Done!")
}).then(v => console.log(v))

new Promise()라는 생성자 함수에 의해 첫 번째 프로미스 인스턴스가 생성된다. 이 예시에서는 프로미스 인스턴스가 생성된 이후, executor로 전달된 resolve => resolve("Done!") 화살표 함수가 바로 execution context stack에서 실행되며 이로 인해 첫 번째 프로미스 인스턴스의 [[PromiseState]]FULFILLED가 된다.

이후 then이 실행될 때, 앞서 설명했듯 결과로 반환할 두 번째 프로미스 인스턴스를 생성한다. 구체적으로, 두 번째 프로미스의 rejectresolve에 대한 정보를 첫 번째 프로미스에게 전달해 첫 번째 프로미스가 두 번째 프로미스의 상태를 결정할 수 있게 된다.

지금의 경우에는 then이 실행될 때, 이미 첫 번째 프로미스의 상태가 PENDING이 아니기 때문에, [[PromiseFulfillReactions]]에 등록하는 과정 없어 즉시 리액션이 job queue(브라우저나 노드에서는 microtask queue)에 등록되며, 첫번째 프로미스 인스턴스의 executor가 정상적으로 종료됐기 때문에, 첫 번째 프로미스의 결과를 전달받아 핸들러인 console.log("Done!")이 실행된다.

두 번째 프로미스 객체는 FULFILLED인 상태로 then이 종료될때 반환된다. 이를 이용해 우리는 다음과 같은 코드도 작성할 수 있다.

new Promise(resolve => // 첫 번째 프로미스
	resolve(2)
).then(v => // 두 번째 프로미스
	v * 2
).then(v => // 세 번째 프로미스, [[PromiseResult]]는 사용되지 않음
	console.log(v) // 4
)

한 번씩 체이닝을 할 때마다, 마치 재귀함수를 호출하듯 프로미스 객체들이 연결된다.

Promise.prototype.catch

그 다음으로 살펴볼 건 보통 then과 함께 사용되는 catch이다. catch는 정상 흐름에서 벗어나는 동작에 대한 핸들러를 등록하기 위해 사용된다.

new Promise((resolve, reject) => { 
	reject("Hmm..")
}).catch(e => console.error(new Error(e)))

catch또한 프로미스의 프로토타입에 정의돼 있는데, 이것 역시 then이 새로운 프로미스 객체를 반환하기 때문이다.

사실 catch는 이전에 살펴봤던 then을 이용해 작동한다. then은 인자를 최대 두 개 까지 전달할 수 있는데, 각 인자가 순서대로 이전 프로미스의 FULFILLEDREJECTED에 대한 핸들러가 된다.

따라서 실질적으로 catchthen의 래퍼함수이다.

new Promise((resolve, reject) => { 
	reject("Hmm..")
}).then(undefined, e => console.error(new Error(e)))

Promise.prototype.finally

마지막으로 살펴볼 finally는 스펙이 굉장히 특이하다. catch처럼 then을 사용해 동작하는데, finally에 전달되는 executor function을 FULFILLEDREJECTED 모두 작동하도록 각 경우에 대해 랩핑을 한 다음 then에게 전달하는 방식이다.

사실상 가장 많이 사용되는 세 가지 함수가 모두 then 하나를 토대로 작동한다는 얘기다.

Async/Await