자바스크립트 Promise를 상태 흐름과 실행 순서로 이해하기
빠른 답
- Promise는 비동기 작업의 성공 값이나 실패 이유를 나중에 전달하기 위한 객체입니다.
- 상태는
pending에서fulfilled또는rejected로 한 번만 바뀌며, 이후 다시 바뀌지 않습니다. then,catch,finally콜백은 현재 동기 코드가 끝난 뒤 마이크로태스크로 실행됩니다.- 복잡한 비동기 흐름은 긴
then체인보다async/await와 좁은 범위의try/catch로 나누면 읽기 쉬워집니다.
목차
시간 흐름으로 이해하기
Promise를 “비동기니까 전부 나중에 실행되는 코드”로만 이해하면 콘솔 출력 순서가 자주 헷갈립니다. Promise 생성자 안의 executor는 동기적으로 실행됩니다. 나중에 실행되는 쪽은 Promise의 결과를 관찰하는 then, catch, finally 콜백입니다.
이 차이를 구분하면 상태 전환, 에러 전파, Promise.all 같은 조합 메서드의 동작을 더 차분하게 따라갈 수 있습니다.
흐름으로 보기
이 흐름에서 중요한 지점은 “작업이 시작되는 시점”과 “결과를 받는 콜백이 실행되는 시점”이 다르다는 점입니다. Promise를 만들면 executor는 바로 실행되지만, then으로 등록한 콜백은 현재 콜 스택이 비워진 뒤 실행됩니다.
또한 한 번 결과가 정해진 Promise는 다시 다른 상태로 바뀌지 않습니다. 이 성질 덕분에 같은 Promise를 여러 곳에서 관찰해도 결과 자체는 흔들리지 않습니다.
값, 상태, 오류를 나눠 보기
Promise를 이해할 때는 값, 상태, 오류를 분리해서 보는 편이 좋습니다.
- 값:
resolve(value)로 전달되는 성공 결과입니다. - 상태: Promise가 현재 어떤 단계에 있는지를 나타냅니다.
pending,fulfilled,rejected가 있습니다. - 오류 또는 실패 이유:
reject(reason)으로 전달되거나then내부에서throw된 값입니다.
예를 들어 resolve(10)은 “성공 값이 10인 fulfilled Promise”를 만든다는 뜻입니다. 10 자체가 상태는 아닙니다. 반대로 reject(new Error("fail"))은 “실패 이유가 Error 객체인 rejected Promise”를 만든다는 뜻입니다.
실패 이유는 문자열일 수도 있지만, 실제 디버깅에서는 Error 객체를 쓰는 쪽이 메시지와 스택 정보를 함께 남기기 좋습니다. reject("fail")처럼 문자열만 넘기면 어디서 문제가 시작됐는지 추적하기 어려워질 수 있습니다.
Promise 생성자는 언제 실행될까
아래 예제는 executor, then, setTimeout의 실행 순서를 함께 보여줍니다. 브라우저 콘솔에 그대로 붙여 넣거나, Node.js에서 promise-order.js 파일로 저장한 뒤 node promise-order.js로 실행해 볼 수 있습니다.
console.log("1. 동기 시작");
setTimeout(() => {
console.log("5. setTimeout");
}, 0);
const promise = new Promise((resolve) => {
console.log("2. executor 실행");
resolve("성공 값");
});
promise.then((value) => {
console.log("4. then 실행:", value);
});
console.log("3. 동기 끝");
출력은 보통 다음 순서로 나타납니다.
1. 동기 시작
2. executor 실행
3. 동기 끝
4. then 실행: 성공 값
5. setTimeout
executor 실행이 동기 끝보다 먼저 찍히는 이유는 Promise 생성자 안의 함수가 즉시 실행되기 때문입니다. 반면 then 실행은 resolve가 이미 호출됐더라도 현재 동기 코드가 모두 끝난 뒤 마이크로태스크로 처리됩니다.
setTimeout(..., 0)은 “지금 즉시 실행”이 아니라 타이머 작업으로 예약한다는 의미에 가깝습니다. 일반적인 이벤트 루프 흐름에서는 마이크로태스크인 Promise 콜백이 타이머 콜백보다 먼저 처리됩니다.
상태는 한 번만 결정된다
Promise는 처음에는 pending 상태입니다. 이후 resolve가 먼저 호출되면 fulfilled, reject가 먼저 호출되면 rejected가 됩니다. 둘 중 하나로 결정된 상태를 통틀어 settled 상태라고 부릅니다.
아래 코드에서는 resolve 뒤에 reject가 호출되지만, 이미 결과가 정해졌기 때문에 catch는 실행되지 않습니다.
const promise = new Promise((resolve, reject) => {
resolve("첫 번째 성공 값");
reject(new Error("나중에 발생한 실패"));
});
promise
.then((value) => {
console.log("then:", value);
})
.catch((error) => {
console.log("catch:", error.message);
});
// 출력
// then: 첫 번째 성공 값
이 예제에서 reject 코드가 존재한다는 사실보다 먼저 호출된 resolve가 더 중요합니다. Promise는 첫 번째 결정만 받아들이고, 이후의 resolve나 reject 호출은 상태를 바꾸지 않습니다.
이 성질은 비동기 코드에서 특히 중요합니다. 네트워크 응답, 타임아웃, 사용자 취소 같은 여러 이벤트가 얽혀 있어도 하나의 Promise 결과는 한 번만 정해집니다.
에러 전파와 catch의 의미
Promise 체인 안에서 발생한 예외는 다음 catch로 전파됩니다. then 안에서 throw된 오류도 Promise의 실패 흐름으로 바뀌어 처리됩니다.
Promise.resolve({ id: "user-1", plan: "free" })
.then((user) => {
console.log("권한 확인:", user.id);
if (user.plan === "free") {
throw new Error("유료 기능을 사용할 수 없습니다.");
}
return `${user.id}-report`;
})
.then((report) => {
console.log("리포트 생성:", report);
})
.catch((error) => {
console.log("에러 처리:", error.message);
});
// 출력
// 권한 확인: user-1
// 에러 처리: 유료 기능을 사용할 수 없습니다.
두 번째 then이 실행되지 않는 이유는 앞 단계에서 예외가 발생했기 때문입니다. Promise 체인은 중간 단계에서 실패하면 가장 가까운 다음 catch로 이동합니다.
다만 모든 실패를 마지막 catch 하나에만 모으면 오류의 의미가 흐려질 수 있습니다. 사용자 조회 실패, 권한 확인 실패, 저장 실패처럼 성격이 다른 오류라면 메시지를 보강하거나 처리 범위를 나누는 편이 원인을 찾기 쉽습니다.
여러 작업을 조합할 때의 차이
Promise는 여러 비동기 작업을 함께 다룰 때도 자주 사용됩니다. 이때 Promise.all과 Promise.allSettled의 차이를 구분하면 실패를 어떻게 다룰지 정하기 쉽습니다.
Promise.all은 모든 작업이 성공해야 성공합니다. 하나라도 실패하면 전체 Promise는 rejected가 됩니다. 여러 데이터가 모두 있어야 다음 단계로 갈 수 있는 경우에 맞습니다.
Promise.allSettled는 각 작업의 성공과 실패를 모두 결과 배열로 돌려줍니다. 일부 실패가 있어도 가능한 결과를 모아서 보여줘야 하는 화면, 리포트, 배치 작업에서 유용합니다.
const profile = Promise.resolve({ name: "Mina" });
const orders = Promise.resolve(["order-1", "order-2"]);
const point = Promise.reject(new Error("포인트 서버 응답 없음"));
Promise.all([profile, orders, point]).catch((error) => {
console.log("Promise.all 실패:", error.message);
});
Promise.allSettled([profile, orders, point]).then((result) => {
console.log(
"Promise.allSettled 상태:",
result.map((item) => item.status)
);
});
// 출력 예
// Promise.all 실패: 포인트 서버 응답 없음
// Promise.allSettled 상태: [ 'fulfilled', 'fulfilled', 'rejected' ]
여기서 Promise.all은 포인트 조회 실패 때문에 전체 실패로 끝납니다. 반면 Promise.allSettled는 프로필, 주문, 포인트 요청의 상태를 각각 남깁니다. 결과를 전부 버릴지, 성공한 일부라도 사용할지는 메서드 선택에서 드러납니다.
then 체인과 async await
async/await는 Promise를 없애는 문법이 아닙니다. Promise 기반 흐름을 동기 코드와 비슷한 모양으로 읽게 해주는 문법입니다.
짧은 변환이나 단순한 연결은 then 체인으로도 충분히 읽을 수 있습니다. 하지만 조건 분기, 반복, 여러 단계의 에러 처리가 섞이면 체인이 길어지고 실패 지점이 잘 보이지 않을 수 있습니다.
await는 Promise가 settled될 때까지 해당 async 함수의 다음 줄 진행을 기다립니다. 이때 rejected Promise는 예외처럼 동작하므로 try/catch에서 다룰 수 있습니다. 자바스크립트 전체 실행이 멈추는 것은 아니고, 해당 async 함수의 흐름만 잠시 멈춘다고 보는 편이 정확합니다.
디버깅할 때 자주 보는 신호
Promise 관련 문제를 볼 때는 로그 순서를 먼저 확인하는 것이 도움이 됩니다. 특히 다음 세 가지가 섞이면 예상과 다른 순서로 출력되는 것처럼 보일 수 있습니다.
- 동기 코드
- Promise 마이크로태스크
- 타이머나 입출력 콜백
앞의 실행 예제처럼 then 로그가 setTimeout보다 먼저 찍힌다면, Promise 콜백이 마이크로태스크로 처리되고 있다는 신호입니다. 반대로 then 자체가 찍히지 않는다면 Promise가 아직 pending에 머물러 있거나, 앞 단계에서 rejected가 되었는데 catch 위치가 다를 가능성을 확인해 볼 수 있습니다.
또 하나의 흔한 신호는 처리되지 않은 rejection입니다. 실패한 Promise를 아무도 catch하지 않으면 런타임에서 unhandled rejection 관련 경고나 오류를 볼 수 있습니다. 비동기 작업이 실패할 수 있는 곳에는 실패 이유를 관찰하는 경로를 남겨 두어야 로그가 끊기지 않습니다.
흔한 오해
resolve와 return은 다릅니다. Promise executor 안에서 return "값"을 한다고 Promise가 그 값으로 fulfilled 되지는 않습니다. Promise의 결과를 정하려면 resolve("값") 또는 reject(reason)을 호출해야 합니다.
then을 붙인다고 원래 Promise의 상태가 계속 바뀌는 것도 아닙니다. then은 새로운 Promise를 반환합니다. 그래서 체인에서는 이전 단계의 반환값이 다음 단계의 입력이 되고, 이전 단계의 예외가 다음 catch로 이어집니다.
또한 Promise는 비동기 작업을 동기 작업으로 바꾸지 않습니다. 비동기 결과를 값처럼 연결하고 관찰할 수 있게 해주는 객체입니다. 이 차이를 놓치면 resolve를 호출했는데 왜 바로 다음 줄에서 결과를 못 쓰는지, setTimeout보다 then이 왜 먼저 찍히는지 계속 헷갈리게 됩니다.
Promise를 상태, 값, 오류의 흐름으로 나눠 보면 동작 방식이 훨씬 선명해집니다. 생성자는 즉시 실행되고, 결과는 한 번만 결정되며, 관찰 콜백은 현재 동기 코드가 끝난 뒤 실행됩니다.
원문 참고
https://www.maeil-mail.kr/question/65
댓글
댓글 쓰기