sum 함수에 async를 더해봐요
async 한 줄로 결과가 한 틱 늦게 오는 이유를 짚어요.
평범한 sum 함수에 await 한 줄을 더하면 콘솔이 의외의 순서로 찍혀요. 같은 5 인데, 찍히는 시점이 한 박자 늦거든요. 처음엔 "기다릴 것도 없는데 뭘 기다린 거지" 싶었어요. 답은 Promise 가 값을 감싸는 방식, 그리고 await 가 그 값을 꺼내는 타이밍에 있어요.
Sum 하나에 await 한 줄을 더하면
먼저 동기 sum 과 asyncSum 을 나란히 놓고 볼게요. 본문 로직은 똑같이 두 수를 더해서 돌려주는 거예요.
function sum(a, b) {
return a + b;
}
async function asyncSum(a, b) {
return a + b;
}
console.log("before");
console.log(sum(2, 3)); // 동기, 즉시 5
console.log("after");
// before
// 5
// after
console.log("before");
asyncSum(2, 3).then(console.log); // 비동기, 5 가 나중
console.log("after");
// before
// after
// 5asyncSum 의 본문은 await 도 없어요. 더하기 한 번뿐이고요. 그런데도 결과 5 는 "after" 뒤에 찍혀요. body 가 동기인데 결과만 비동기로 흘러나오는 이 얄궂은 차이를 만든 게 async 키워드 한 글자예요.
Promise 는 값이 아니라 값의 자리
asyncSum(2, 3) 의 반환은 그냥 5 가 아니에요. Promise<number> 라는 객체예요. Promise 는 값을 직접 들고 있는 게 아니라 "이 자리에 곧 값이 들어올 거다" 라는 약속을 들고 있어요. 봉투에 비유하자면 봉투 안에 숫자가 든 게 아니라, "숫자가 채워질 봉투" 자체예요.
봉투의 상태는 셋이에요. pending 은 아직 비어 있는 상태, fulfilled 는 값이 채워진 상태, rejected 는 실패가 채워진 상태. fulfilled 또는 rejected 가 되면 그 봉투를 settled 라고 불러요.
여기서 한 가지 보장이 있어요. 이미 settled 인 Promise 라도 .then 콜백은 즉시 실행되지 않아요. 봉투가 이미 채워진 상태여도, 꺼내는 일은 한 사이클 뒤로 밀려요.
"This guarantees that promise actions are asynchronous." - MDN
설령 봉투 안에 값이 이미 들어 있어도, 꺼내는 건 다음 사이클 일이라는 뜻이에요. 동기 코드 도중에 끼어들지 않는다는 약속이죠.
Promise.resolve 와 async 가 값을 감싸는 두 길
값을 봉투에 넣는 길은 크게 두 가지예요. 하나는 명시적으로 Promise.resolve(value) 를 호출하는 것, 다른 하나는 async function 안에서 return value 하는 것.
Promise.resolve(5) 는 이미 fulfilled 상태인 Promise 를 만들어줘요. 그런데 인자가 thenable, 즉 .then 메서드를 가진 객체이면 살짝 다르게 동작해요. 그 thenable 의 .then 을 호출해서 상태를 그대로 흡수해요. 결과적으로 반환된 Promise 의 fulfillment 값은 절대 thenable 이 아니에요.
"If the resolve function receives another thenable object, it will be resolved again, so that the eventual fulfillment value of the promise will never be thenable." - MDN
Promise 안에 Promise 를 또 감싸도 자동으로 평탄화돼요. Promise.resolve(Promise.resolve(5)) 의 최종 값은 그냥 5 예요. 봉투를 두 겹 씌우려 해도 한 겹으로 펴주는 거죠.
async function 은 좀 더 조용한 길이에요. 본문이 return 5 라고만 적혀 있어도, 외부에서 받는 값은 Promise<number> 예요.
"Async functions always return a promise. If the return value of an async function is not explicitly a promise, it will be implicitly wrapped in a promise." - MDN
함수 시그니처에 async 한 단어를 붙이는 순간, 반환 타입이 통째로 Promise 로 갈아입혀져요. body 가 동기여도 상관없고요. 그래서 asyncSum(2, 3) 의 반환은 5 가 아니라 "곧 5 가 채워질 봉투" 예요.
이게 우리 첫 코드에서 5 가 "after" 뒤로 밀린 첫 번째 이유예요. body 가 동기 더하기 한 줄이어도, 반환은 Promise 라서 .then 으로만 꺼낼 수 있고, .then 콜백은 약속에 의해 비동기로 실행되거든요.
이미 정해진 결과여도 한 사이클 미뤄진다
이번엔 .then 대신 await 를 써볼게요. await 의 동작에는 잘 알려지지 않은 디테일이 하나 있어요. 피연산자가 Promise 가 아니어도, 일단 한 사이클 양보한다는 점이에요.
async function caller() {
console.log("await 직전");
const x = await 5; // Promise 도 아니고 그냥 숫자
console.log("await 직후", x);
}
caller();
console.log("호출 후");
// await 직전
// 호출 후
// await 직후 5await 5 는 그냥 숫자를 기다리는데 왜 "호출 후" 가 먼저 찍힐까요. await 는 비-thenable 값을 받으면 그것을 already-fulfilled Promise 로 감싼 뒤, 그 Promise 의 then 핸들러처럼 다음 microtask 로 자기 자신을 스케줄해요. 이미 결과가 정해진 봉투를 받았는데도 봉투 자체가 microtask 큐를 한 번 거치는 거예요.
"Even when the used promise is already fulfilled, the async function's execution still pauses until the next tick. In the meantime, the caller of the async function resumes execution." - MDN
await 는 항상 한 tick 을 양보해요. 이미 fulfilled 인 Promise 를 await 해도, 호출자가 먼저 재개되고 본인 코드는 다음 microtask 에서 이어져요.
마이크로태스크 큐가 task 큐와 어떻게 갈라지는지는 지난 글 에서 짚었어요. 그 우선순위 규칙 위에서, await 가 만든 "한 틱 양보" 가 결정적으로 작동해요.
wrapping 의 비용은 마이크로태스크 몇 개인가
그럼 await 한 번에 정확히 몇 microtask 가 들어가는 걸까요. 명세 기준으로는 생각보다 컸어요. V8 팀의 2018년 분석에 따르면, await 한 번 처리에 엔진이 추가 Promise 두 개를 만들고 microtask 큐를 최소 세 번 거쳤어요. 오른쪽 피연산자가 이미 Promise 인 경우에도요.
"for each await the engine has to create two additional promises (even if the right hand side is already a promise) and it needs at least three microtask queue ticks." - V8
await 표현 한 줄을 풀어내려고 엔진이 보이지 않는 곳에서 봉투 두 장과 줄서기 세 번을 추가했다는 얘기예요. 단순한 await 5 한 줄에서도요.
이후 ECMAScript 명세가 한 군데를 다듬으면서 그중 한 Promise 가 사라졌고, V8 도 추가 트릭을 썼어요. 결과적으로 이미 fulfilled 된 Promise 를 await 할 때 microtick 이 최소 셋에서 하나로 줄었어요. 같은 코드인데 엔진과 명세 양쪽이 협력해서 비용을 깎은 거죠.
물론 microtask 한 틱은 사람 눈으로 인지 가능한 시간이 아니에요. 다만 핫패스에서 await 가 수만 번 호출되는 코드라면 여전히 의미 있는 비용이고, 그래서 V8 가 굳이 깎은 거예요. "그냥 동기인데 async 로 감싸도 비슷하지 않을까" 라는 직관에는 항상 이 한 틱이 따라붙는다는 점만 기억해 두면 돼요.
그래서 언제 wrapping 이 정당한가
동기 결과를 굳이 Promise 로 감싸는 게 정당한 경우는 두 가지로 좁혀져요. 첫째는 인터페이스 통일성 이에요. consumer 가 항상 .then 또는 await 로 받는 자리라면, 동기 분기만 다르게 빠져나가게 두는 것보다 모두 Promise 로 통일하는 쪽이 호출 측 코드를 단순하게 만들어요. 둘째는 미래의 비동기 전환 이에요. 지금은 메모리에서 즉시 답이 나오지만 곧 캐시 미스 시 네트워크로 빠질 함수라면, 처음부터 async 시그니처를 잡아두는 편이 나중 마이그레이션 비용을 아껴요.
반대로 위 둘 중 어느 쪽도 아닌데 단순히 "비동기처럼 보이고 싶어서" 감쌌다면, 한 번 더 생각해 볼 만해요. consumer 입장에선 기다릴 게 없는 결과를 한 microtask 늦게 받게 되고, 디버깅 시에도 호출 순서가 한 단계 꼬여 보이거든요. 봉투를 씌우는 일은 생각보다 무료가 아니에요.
참고 자료
- MDN - Promise
Promise 의 세 가지 상태와 then 핸들러가 비동기로 실행된다는 보장
- MDN - Promise.resolve()
thenable 동화(assimilation)와 fulfillment value 가 절대 thenable 이 아니라는 규칙
- MDN - async function
async 함수가 항상 Promise 를 반환하고, 반환값이 Promise 가 아니면 암묵적으로 감싸진다는 규칙
- MDN - await
비-thenable 값에 대한 await 동작과 다음 tick 까지 실행 양보
- V8 - Faster async functions and promises
await 한 번의 microtask 비용(최소 셋에서 하나로)과 명세 변경 이력