2026. 6. 22. 22:35ㆍFrontend/JavaScript
Promise와 async/await 깊이 이해하기 1/4
들어가며
Promise는 흔히 “나중에 값을 받기 위한 객체”라고 설명된다.
이 설명은 입문 단계에서는 유용하지만, 다음 질문에는 충분히 답하지 못한다.
- resolve()를 호출했는데도 왜 Promise가 여전히 pending일 수 있는가?
- then()은 왜 항상 새로운 Promise를 반환하는가?
- Promise 안에 Promise를 반환해도 왜 중첩되지 않는가?
- Promise와 thenable은 무엇이 다른가?
- 이미 만들어진 Promise를 다시 기다리면 작업도 다시 실행되는가?
이 질문들에 답하려면 Promise를 단순한 비동기 문법이 아니라 다음 세 가지 관점에서 바라봐야 한다.
Promise = 단일 할당 상태 기계
+ 미래 결과를 관찰하는 프로토콜
+ 후속 계산을 연결하는 구조
Promise라는 개념은 JavaScript에서 처음 등장한 것이 아니다. Liskov와 Shrira는 1988년 논문에서 비동기 호출의 결과와 예외를 나중에 안전하게 다룰 수 있는 언어적 구조로 Promise를 연구했다. 현대 JavaScript Promise와 완전히 같은 모델은 아니지만, 미래의 결과를 값처럼 다룬다는 핵심 발상은 같은 계보에 있다.
1. Promise는 비동기 작업이 아니다
가장 먼저 구분해야 할 것은 작업과 작업의 결과다.
비동기 작업
= 네트워크 요청, 타이머, 파일 읽기, 사용자 이벤트 등
Promise
= 해당 작업의 최종 결과를 관찰하기 위한 객체
다음 코드를 보자.
const request = fetch("/api/users");
fetch() 호출은 네트워크 작업을 시작하고 Promise를 반환한다.
반환된 request는 네트워크 작업 자체가 아니다. 요청의 최종 성공 또는 실패를 관찰할 수 있는 핸들에 가깝다.
const firstResult = await request;
const secondResult = await request;
console.log(firstResult === secondResult);
같은 Promise를 두 번 await한다고 네트워크 요청이 두 번 실행되지는 않는다. 동일한 결과를 두 번 관찰할 뿐이다.
작업을 다시 실행하려면 Promise가 아니라 Promise를 만드는 함수가 필요하다.
const request = () => fetch("/api/users");
await request();
await request();
두 구조는 의미가 다르다.
Promise<Response>
= 이미 생성된 결과 객체
() => Promise<Response>
= 새로운 작업을 시작할 수 있는 작업 기술
이 차이는 재시도, 지연 실행, 동시성 제한에서 중요하다.
다음 코드는 올바른 재시도 구조가 아니다.
retry(fetch("/api/users"));
fetch()는 retry()가 호출되기 전에 이미 시작되었다.
올바른 구조는 다음과 같다.
retry(() => fetch("/api/users"));
이제 retry는 실패할 때마다 새로운 작업을 시작할 수 있다.
2. Promise executor는 동기적으로 실행된다
Promise를 생성하면 executor 함수가 전달된다.
const promise = new Promise((resolve, reject) => {
// executor
});
executor 내부 코드는 자동으로 비동기가 되지 않는다.
console.log("A");
const promise = new Promise((resolve) => {
console.log("B");
resolve(1);
});
promise.then(() => {
console.log("D");
});
console.log("C");
출력 결과는 다음과 같다.
A
B
C
D
Promise 생성자는 executor를 생성 즉시 동기적으로 호출한다.
반면 then()에 등록된 handler는 현재 호출 스택에서 실행되지 않는다. Promise가 이미 fulfilled된 상태여도 후속 반응은 Job으로 예약된다.
new Promise(executor)
↓
executor는 즉시 실행
promise.then(handler)
↓
handler는 후속 Job으로 실행
ECMAScript 명세도 Promise 생성 과정에서 executor를 바로 호출하고, Promise의 fulfillment와 rejection 반응은 별도의 Promise Reaction Job으로 실행하도록 정의한다.
따라서 다음 설명은 정확하지 않다.
new Promise를 사용하면 코드가 비동기로 실행된다.
더 정확한 설명은 다음과 같다.
Promise executor는 동기적으로 실행된다.
Promise의 후속 반응은 비동기 Job으로 실행된다.
3. Promise는 단일 할당 상태 기계다
Promise를 추상적인 상태 기계로 표현해보자.
P = ⟨state, result, fulfillReactions, rejectReactions, handled⟩
각 요소는 다음을 의미한다.
state
= pending | fulfilled | rejected
result
= fulfillment value 또는 rejection reason
fulfillReactions
= 성공 시 실행할 후속 반응 목록
rejectReactions
= 실패 시 실행할 후속 반응 목록
handled
= rejection handler가 연결되었는지 나타내는 정보
상태 전이는 다음 두 방향만 가능하다.
pending ── fulfill(value) ──▶ fulfilled(value)
pending ── reject(reason) ──▶ rejected(reason)
한 번 완료된 Promise는 다시 바뀌지 않는다.
fulfilled ──X──▶ rejected
rejected ──X──▶ fulfilled
다음 코드에서 첫 번째 호출만 유효하다.
const promise = new Promise((resolve, reject) => {
resolve(1);
reject(new Error("failure"));
resolve(2);
});
console.log(await promise); // 1
Promise는 최초의 유효한 결정만 채택한다.
resolve(1)
→ Promise의 운명 결정
reject(...)
→ 무시
resolve(2)
→ 무시
이 특성 때문에 Promise를 single-assignment cell, 즉 단 한 번만 값이 결정되는 셀로 볼 수 있다.
ECMAScript Promise는 내부적으로 상태, 결과, fulfillment 반응 목록, rejection 반응 목록, 처리 여부 등을 관리하며, 완료된 이후 다시 상태가 바뀌지 않는다.
4. fulfilled, rejected, settled, resolved는 다르다
Promise를 이해할 때 가장 혼동하기 쉬운 개념이 resolved다.
먼저 settled는 다음 두 상태를 가리킨다.
fulfilled 또는 rejected
즉, 더 이상 pending이 아닌 상태다.
반면 resolved는 더 넓다.
1. fulfilled되었거나
2. rejected되었거나
3. 다른 Promise의 최종 상태를 따르도록 운명이 고정된 상태
따라서 Promise는 resolved이면서 pending일 수 있다.
다음 예제를 보자.
let resolveInner;
const inner = new Promise((resolve) => {
resolveInner = resolve;
});
let resolveOuter;
const outer = new Promise((resolve) => {
resolveOuter = resolve;
});
resolveOuter(inner);
resolveOuter(100);
outer.then(console.log);
setTimeout(() => {
resolveInner("done");
}, 1000);
출력은 1초 후 다음과 같다.
done
resolveOuter(inner)가 호출되는 순간 outer는 inner의 최종 상태를 따르도록 결정된다.
그러나 inner는 아직 pending이다.
outer
= resolved
= pending
이후 호출한 다음 코드는 영향을 주지 못한다.
resolveOuter(100);
이미 outer의 운명이 inner를 따르도록 결정되었기 때문이다.
시간 흐름을 표현하면 다음과 같다.
t0
outer: pending, unresolved
inner: pending
t1
resolveOuter(inner)
outer: pending, resolved
inner: pending
t2
resolveOuter(100)
이미 resolved이므로 무시
t3
resolveInner("done")
inner: fulfilled("done")
outer: fulfilled("done")
ECMAScript 명세 역시 resolved Promise가 pending 상태일 수 있다고 명시한다. 다른 Promise의 상태를 따르도록 고정된 Promise는 아직 최종 값이 없더라도 이미 resolved다.
5. resolve는 값을 저장하는 함수가 아니다
resolve(value)는 Promise 내부에 값을 단순히 저장하는 함수처럼 보인다.
하지만 실제 의미는 다음에 가깝다.
주어진 값이 일반 값인지, Promise인지, thenable인지 검사한 뒤 그 값의 최종 상태를 현재 Promise가 채택하도록 한다.
개념적인 resolution algorithm은 다음과 같다.
resolve(x)
1. 이미 resolve 또는 reject가 호출되었다면 종료한다.
2. x가 현재 Promise 자신이라면 TypeError로 reject한다.
3. x가 객체나 함수가 아니라면 x로 fulfill한다.
4. x.then을 읽는다.
5. then을 읽는 과정에서 예외가 발생하면 reject한다.
6. then이 함수가 아니라면 x 자체로 fulfill한다.
7. then이 함수라면 별도의 Job에서 호출한다.
8. then이 전달한 결과를 같은 규칙으로 다시 해석한다.
이 과정 때문에 Promise는 중첩 결과를 자동으로 평탄화한다.
const promise = Promise.resolve(
Promise.resolve(
Promise.resolve(42),
),
);
console.log(await promise); // 42
최종 타입은 개념적으로 다음과 같다.
Promise<number>
다음처럼 끝없이 중첩된 구조가 아니다.
Promise<Promise<Promise<number>>>
6. thenable assimilation
Promise와 비슷하게 동작하지만 실제 Promise 인스턴스가 아닌 객체를 thenable이라고 부른다.
const thenable = {
then(resolve) {
resolve(42);
},
};
const value = await Promise.resolve(thenable);
console.log(value); // 42
Promise.resolve()는 단순히 객체를 fulfillment 값으로 저장하지 않는다.
객체의 then 프로퍼티가 callable이면 해당 객체의 최종 상태를 채택하려 한다.
객체에 호출 가능한 then이 존재한다
→ Promise처럼 동작할 가능성이 있는 객체로 취급
이 구조 덕분에 서로 다른 Promise 구현과 라이브러리가 상호 운용될 수 있다.
그러나 thenable assimilation은 외부 사용자 코드를 실행하는 지점이기도 하다.
const thenable = {
get then() {
console.log("then getter");
return (resolve) => {
console.log("then called");
resolve(1);
};
},
};
console.log("A");
const promise = Promise.resolve(thenable);
console.log("B");
await promise;
실행 순서는 다음과 같다.
A
then getter
B
then called
then 프로퍼티를 읽는 작업은 현재 흐름에서 일어난다.
읽어온 then 함수를 호출하는 작업은 별도의 Promise Job으로 예약된다.
또한 getter 자체가 예외를 던질 수 있다.
const thenable = {
get then() {
throw new Error("getter failed");
},
};
const promise = Promise.resolve(thenable);
promise.catch(console.error);
이 경우 반환된 Promise는 getter에서 발생한 오류로 reject된다.
ECMAScript의 Promise resolution procedure는 객체의 then을 읽고, callable이면 별도의 Thenable Job을 통해 호출하며, 자기 자신으로 resolve하려는 경우에는 TypeError로 거부하도록 정의한다.
7. 악의적이거나 잘못 구현된 thenable
thenable은 여러 번 resolve와 reject를 호출할 수도 있다.
const hostileThenable = {
then(resolve, reject) {
resolve(1);
reject(new Error("late rejection"));
resolve(2);
throw new Error("late throw");
},
};
const value = await Promise.resolve(hostileThenable);
console.log(value); // 1
최종 결과는 1이다.
첫 번째 호출이 Promise의 운명을 결정한 이후 나머지 호출은 상태를 바꾸지 못한다.
resolve(1)
→ 최종 운명 결정
reject(...)
→ 무시
resolve(2)
→ 무시
throw
→ 이미 결정되었으므로 최종 결과를 바꾸지 못함
이 규칙은 잘못 구현된 thenable이 Promise를 여러 번 결정하는 것을 막는다.
그러나 실행된 부수 효과까지 되돌려주는 것은 아니다.
const thenable = {
then(resolve) {
chargePayment();
resolve("success");
sendEmail();
},
};
Promise는 한 번만 완료되지만 chargePayment()와 sendEmail()은 모두 실행된다.
Promise의 단일 할당 규칙은 결과의 일관성을 보호한다.
다음 항목은 보호하지 않는다.
부수 효과의 원자성
트랜잭션
롤백
중복 실행 방지
8. then은 결과를 꺼내는 함수가 아니다
다음 코드를 보자.
const nextPromise = promise.then(
onFulfilled,
onRejected,
);
then()은 원본 Promise를 변경하지 않는다.
원본 Promise에 후속 반응을 등록하고 새로운 Promise를 반환한다.
타입 수준에서 단순화하면 다음과 같이 표현할 수 있다.
then:
Promise<A>
× (A → B | PromiseLike<B>)
× (unknown → B | PromiseLike<B>)
→ Promise<B>
새 Promise의 결과는 handler가 무엇을 하느냐에 따라 결정된다.
handler가 일반 값 y를 반환
→ 새 Promise는 y로 fulfill
handler가 Promise를 반환
→ 새 Promise는 해당 Promise의 상태를 채택
handler가 예외를 던짐
→ 새 Promise는 해당 오류로 reject
예제를 보자.
const result = Promise.resolve(1)
.then((value) => value + 1)
.then((value) => Promise.resolve(value + 1))
.then((value) => {
if (value === 3) {
throw new Error("stopped");
}
return value;
});
result.catch((error) => {
console.log(error.message); // stopped
});
각 then()은 하나의 새로운 계산 단계를 만든다.
Promise 1
↓ handler
Promise 2
↓ handler
Promise 3
↓ handler
Promise 4
Promise 체인은 값이 담긴 상자를 순서대로 여는 구조라기보다, 결과와 오류가 흐르는 비동기 계산 그래프에 가깝다.
9. handler가 없을 때의 전파 규칙
다음 코드에는 fulfillment handler가 없다.
const nextPromise = promise.then();
원본 Promise가 fulfill되면 값이 다음 Promise로 그대로 전달된다.
개념적으로 다음 함수가 들어간 것과 같다.
(value) => value
rejection handler가 없으면 오류도 다음 Promise로 전달된다.
개념적으로는 다음과 같다.
(error) => {
throw error;
}
따라서 다음 체인의 오류는 마지막 catch()까지 전파된다.
fetchData()
.then(parseData)
.then(normalizeData)
.then(renderData)
.catch(handleError);
어느 단계에서든 오류가 발생하면 handler가 없는 구간을 통과해 아래로 전달된다.
10. then의 두 번째 인자가 놓치는 오류
다음 코드는 미묘한 차이를 가진다.
sourcePromise.then(
(value) => parse(value),
(error) => recover(error),
);
두 번째 인자인 recover는 sourcePromise 자체가 reject된 경우에만 실행된다.
첫 번째 handler인 parse() 내부에서 발생한 오류는 잡지 못한다.
Promise.resolve("{ invalid json")
.then(
(value) => JSON.parse(value),
(error) => {
console.log("여기서는 잡히지 않는다.");
},
)
.catch((error) => {
console.log("여기에서 잡힌다.");
});
이유는 두 handler가 같은 원본 Promise에 연결된 형제 반응이기 때문이다.
┌─ onFulfilled
sourcePromise ───┤
└─ onRejected
onFulfilled가 던진 오류는 원본 Promise의 실패가 아니다.
onFulfilled가 생성한 다음 Promise의 실패다.
sourcePromise fulfilled
↓
onFulfilled 실행
↓ throw
nextPromise rejected
따라서 성공 handler 내부 오류까지 잡으려면 하류에 catch()를 둬야 한다.
sourcePromise
.then((value) => parse(value))
.catch((error) => recover(error));
11. Promise를 이해하는 핵심 모델
Promise는 다음과 같이 이해할 수 있다.
Promise는 작업이 아니다.
Promise는 작업 결과를 관찰하는 객체다.
Promise는 단 한 번 결정된다.
완료된 이후 상태는 바뀌지 않는다.
resolve는 값을 저장하지 않는다.
값 또는 thenable의 최종 운명을 채택한다.
then은 원본 Promise를 수정하지 않는다.
후속 계산을 나타내는 새로운 Promise를 만든다.
Promise 체인은 값의 배열이 아니다.
결과와 오류가 흐르는 방향성 계산 그래프다.
이 모델을 이해하면 이후에 다룰 async/await, 마이크로태스크, 동시성 조합자, 취소 구조도 자연스럽게 연결된다.
마무리
Promise는 단순히 “나중에 값을 주는 객체”가 아니다.
더 정확히 말하면 Promise는 다음을 하나의 언어 구조로 만든다.
미래의 결과
결과의 성공과 실패
결과에 연결된 후속 계산
다른 비동기 결과와의 결합
Promise를 단일 할당 상태 기계로 이해하면 다음 현상들이 하나의 원리로 설명된다.
resolve를 여러 번 호출해도 첫 호출만 유효한 이유
다른 pending Promise로 resolve할 수 있는 이유
thenable이 자동으로 흡수되는 이유
중첩 Promise가 평탄화되는 이유
then이 항상 새 Promise를 반환하는 이유
오류가 체인 아래로 전파되는 이유
Promise는 비동기를 실행하는 엔진이 아니다.
Promise는 비동기 계산의 결과와 후속 관계를 프로그램 안에서 값으로 표현하는 프로토콜이다.
참고 자료
- Liskov, Shrira, Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems.
- ECMA-262, Promise Objects 및 Promise Resolution Procedure.
'Frontend > JavaScript' 카테고리의 다른 글
| Promise 동시성 설계: 순차 실행, 조합자, Critical Path (0) | 2026.06.24 |
|---|---|
| async/await의 실행 원리: Job, 마이크로태스크, 실행 컨텍스트 (0) | 2026.06.23 |
| JavaScript 클로저 완전 정복: 함수는 어떻게 자신이 태어난 환경을 기억하는가 (0) | 2026.06.20 |
| [JS] 값(value)에 의한 전달과 참조(reference)에 의한 전달 (0) | 2023.01.03 |
| [JS] 자바스크립트의 깊은 복사와 얕은 복사 (0) | 2023.01.02 |