javascript · high priority
Promise와 async/await 심층
3-state 기계 · thenable unwrap · microtask · combinator — "나중의 값"을 다루는 정석
학습 개요
탄생 배경
쉬운 설명
복잡한 개념을 실생활 비유로 설명합니다.
“피자 주문 · 영수증 · 완성 알림”
피자를 시키면 즉시 "영수증(Promise)" 을 받는다. 이 영수증은 나중에 "피자(fulfilled)" 나 "품절/오배송(rejected)" 중 하나로 단 한 번 확정된다. `.then` 은 "피자가 오면 이거 해줘", `.catch` 는 "문제 생기면 이거 해줘" 를 **미리 등록** 해두는 것. 영수증을 받고 "잠깐, 역시 피자 말고 치킨으로 바꿀게" 라고 해도 이미 발행된 영수증은 상태가 바뀌지 않는다 — 새 주문을 해야 한다.
핵심 개념
ECMA-262 사양은 모든 Promise 에 네 개의 내부 슬롯을 둔다 — [[PromiseState]]("pending"/"fulfilled"/"rejected"), [[PromiseResult]](확정값 또는 거절 이유), [[PromiseFulfillReactions]]/[[PromiseRejectReactions]](등록된 .then 핸들러들), [[PromiseIsHandled]](rejection 에 대한 핸들러가 붙었는지 추적 — unhandledrejection 이벤트용). 상태 전이는 **단방향·일회성** 이라 한 번 settle 된 Promise 를 다시 흔들 방법은 없다.
`[[PromiseIsHandled]]` 과 unhandledrejection
[[PromiseIsHandled]] 가 false 인 채로 rejection 이 settle 되면, 현재 microtask 가 전부 비워진 뒤 호스트 환경(브라우저 · Node) 이 unhandledrejection 이벤트를 발생시킨다. 이것이 ".catch 를 붙이지 않으면 콘솔에 경고가 찍히는" 메커니즘이다. 반대로 나중에 핸들러가 붙으면 rejectionhandled 이벤트로 "이미 처리됐음" 을 알린다.
실무 적용
어떤 상황에서 사용하는가
대시보드가 3개의 독립적인 API 데이터를 불러오는데, 하나가 500 을 반환하면 전체 화면이 흰 화면이 되는 버그가 있다.
어떻게 적용하는가
`Promise.all` 을 쓰면 하나만 실패해도 전체가 fail-fast 된다. `Promise.allSettled` 로 바꿔 각 응답을 `{status: "fulfilled" | "rejected"}` 로 받고, 개별 위젯 단위로 에러 UI(빈 상태/리트라이 버튼) 를 렌더하도록 분리한다. 더 나아가 critical vs non-critical 을 분류해서, critical 은 throw 하고 non-critical 은 silent logging 으로 Sentry 로만 보낸다.
흔한 실수와 안티패턴
- `forEach(async ...)` 로 병렬 실행해놓고 기다리지 않아 race / error loss 발생
- `await` 를 루프 안에 넣어 직렬 실행이 되어버림 — `Promise.all(map(...))` 이 올바른 병렬화
- 에러를 catch 하고 아무것도 안 해서 흐름은 계속되지만 값이 `undefined` 가 되어 뒤에서 터짐
- `Promise.race` 로 타임아웃을 구현했는데 승자가 아닌 쪽의 리소스 해제를 안 해 메모리 누수 (AbortController 조합 필요)
- top-level await 로 모듈 그래프 블로킹 — 전체 페이지 초기화가 늦어질 수 있음
흔한 오해
"Promise 는 실행을 지연시키는 객체다."
교정Promise 생성자 인자는 **즉시 동기로 실행** 된다. Promise 자체는 "나중에 확정될 값" 의 컨테이너일 뿐 작업을 지연시키지 않는다.
왜 중요new Promise((res) => { ... }) 의 콜백은 생성자 내부에서 즉시 호출된다. `fetch` 같은 실제 비동기는 호스트 API 가 비동기로 만드는 것이지 Promise 자체 때문이 아니다.
"await 는 스레드를 블로킹한다."
교정await 는 함수 본문을 state machine 으로 쪼개 "다음 조각" 을 microtask 로 등록할 뿐, 이벤트 루프는 자유롭다.
왜 중요싱글 스레드 JS 에서 진짜 블로킹이 일어나면 UI 가 멈춘다. await 는 suspend/resume 으로 다른 task 들이 계속 처리되도록 설계됐다.
"Promise.race 는 가장 빠른 성공을 반환한다."
교정가장 먼저 **settle** 된 것 — 성공이든 실패든 — 을 반환한다. 먼저 reject 되면 race 는 reject 로 settle.
왜 중요성공만 기다리는 동작이 필요하면 `Promise.any` 가 정답. race/any 의 이 구분이 실무 race-condition 버그의 단골 원인.
면접 질문
답변 방향 힌트
ECMA-262 의 `PromiseResolveThenableJob` 과 resolve vs reject 의 비대칭성을 먼저 그려보세요.
반드시 언급할 키워드
- `Promise.resolve(p)` 는 p 가 native Promise 면 그대로 반환 (같은 인스턴스)
- `new Promise((r)=>r(p))` 는 새 Promise 를 만들되 내부 resolve 가 p 를 흡수 → 값은 동일하지만 인스턴스는 다름
- `Promise.reject(p)` 는 p 를 흡수하지 않음 — Promise 자체가 rejection reason 이 됨
- thenable 도 동일한 규칙으로 흡수됨 (라이브러리 간 상호운용)
- 이 비대칭이 `Promise.reject(promise)` 를 catch 하면 이유로 Promise 가 나오는 이유
예상 꼬리 질문
- `Promise.resolve(thenable)` 가 호출되는 시점에 `thenable.then` 은 언제 실행되나요?
- jQuery 1.x 의 Deferred 는 왜 Promises/A+ 호환이 아니었나요?
자기 점검
스크롤 올리지 말고 답해보세요. Promise 의 세 상태와, settle 된 이후 상태를 다시 바꿀 수 있는 방법이 있나요?
기대 키워드
자주 하는 오해
"resolve 를 여러 번 부르면 값이 바뀐다" 고 오해하기 쉽습니다. 실제로는 첫 번째 resolve 이후의 호출은 사양에 따라 조용히 무시됩니다.
`forEach` 에 async 콜백을 넣으면 왜 위험한가요? 올바른 대안은?
기대 키워드
자주 하는 오해
async forEach 가 "병렬 실행" 이라 좋다고 오해하지만, 에러 전파와 완료 대기가 불가능해 실제 실행 완료 시점이 불분명해집니다.
`await` 뒤 코드가 실행되는 시점은 언제이며, 이를 eager 하게 동기처럼 쓰지 않는 이유는?
기대 키워드
자주 하는 오해
await 가 블로킹이라는 오해 — 실제로는 함수 실행을 중단하고 호출자에게 Promise 를 즉시 반환한 뒤, 결과 확정 시 microtask 로 재개됩니다.
학습 자료
- Promise — JavaScript | MDNPromise 의 모든 메서드 · 상태 · combinator 의 완전한 참조. 각 항목마다 실행 가능한 예시 포함.DocMDN Web Docs
- Promise Objects — ECMA-262내부 슬롯 · PromiseResolveThenableJob · PromiseCapability 까지 사양 수준의 정의. 심화 학습의 최종 소스.DocTC39
- Faster async functions and promisesV8 가 await 의 spurious tick 을 제거한 과정과 현재 구현. microtask 성능 개선 히스토리.BlogV8 Blog · Mathias Bynens · 2018
- Using Promises — JavaScript | MDN실무 패턴 · 안티패턴 · 에러 전파 · 체이닝 팁. 처음 보는 팀원에게 공유할 때 가장 먼저 권하는 문서.DocMDN Web Docs
- top-level await — TC39 ProposalESM 모듈 한정 top-level await 의 의미론 · variant A/B 논쟁 · 모듈 그래프 평가 순서까지.DocTC39