
들어가며: 비동기 프로그래밍의 핵심, 프로미스 상태에 대한 오해와 진실
자바스크립트는 싱글 스레드로 동작하는 언어이며, 이는 한 번에 하나의 작업만을 처리할 수 있다는 것을 의미합니다. 하지만 웹 브라우저 환경에서는 서버와의 통신, 파일 읽기, 타이머 등 시간이 오래 걸리는 작업들이 비일비재합니다. 이러한 작업들을 메인 스레드에서 차단(blocking) 방식으로 처리한다면 사용자는 화면이 멈춘 것과 같은 불쾌한 경험을 하게 될 것입니다. 이를 해결하기 위해 자바스크립트는 비동기(Asynchronous) 처리 방식을 도입했습니다.
초기에는 비동기 처리를 위해 콜백(callback) 함수를 주로 사용했습니다. 하지만 비동기 작업이 중첩되거나 여러 개의 비동기 작업을 제어해야 할 때 코드의 가독성이 급격히 떨어지고 에러 처리가 어려워지는 '콜백 지옥(callback hell)' 문제가 발생했습니다. 이러한 콜백 지옥을 해결하고 비동기 코드를 더욱 읽기 쉽고 관리하기 편하게 만들기 위해 ES6(ECMAScript 2015)에서 등장한 것이 바로 프로미스(Promise)입니다.
프로미스는 비동기 작업의 최종 성공 또는 실패를 나타내는 객체입니다. 하지만 많은 개발자들이 프로미스의 개념은 이해하지만, 그 내부를 흐르는 상태(State)에 대해서는 깊이 있게 파악하지 못하는 경우가 많습니다. 프로미스의 상태는 비동기 작업의 수명 주기를 관리하고, 결과에 따라 실행될 코드를 결정하는 핵심입니다. 이 글에서는 프로미스의 세 가지 핵심 상태인 대기(Pending), 이행(Fulfilled), 거부(Rejected)에 대해 아주 상세하게 분석하고, 각 상태가 어떻게 트리거되고 전이되는지, 그리고 실무에서 발생할 수 있는 다양한 문제를 어떻게 상태를 활용하여 해결할 수 있는지에 대한 완벽한 가이드를 제공합니다. 단순히 상태의 정의를 나열하는 것을 넘어, 깊이 있는 이해와 실질적인 해결 능력을 기르는 데 초점을 맞추었습니다.
1. 프로미스의 핵심 구조와 상태의 중요성
프로미스 객체는 비동기 작업의 최종 결과(값 또는 에러)를 나타냅니다. 이 객체는 생성되는 시점부터 결과를 알 때까지 일정한 상태를 유지합니다. 프로미스의 상태는 변경 불가능(immutable)합니다. 즉, Pending에서 Fulfilled 또는 Rejected로 전이된 후에는 더 이상 다른 상태로 변경될 수 없으며, 결과 값이나 거부 이유도 바꿀 수 없습니다. 이러한 특징은 비동기 작업의 결과를 예측 가능하게 만들고, 코드를 더욱 안정적으로 유지하는 데 기여합니다.
프로미스를 생성할 때는 `new Promise()` 생성자 함수를 사용하며, 인자로 실행자(executor) 함수를 전달합니다. 이 실행자 함수는 두 개의 함수를 인자로 받는데, 바로 `resolve`와 `reject`입니다. 이 두 함수가 프로미스의 상태를 변경하는 트리거 역할을 합니다.
- resolve(value): 비동기 작업이 성공했을 때 호출하며, 프로미스의 상태를 Fulfilled로 변경하고 결과 값(`value`)을 전달합니다.
- reject(reason): 비동기 작업이 실패했을 때 호출하며, 프로미스의 상태를 Rejected로 변경하고 거부 이유(`reason`, 보통 에러 객체)를 전달합니다.
2. PENDING (대기) 상태: 비동기 작업의 시작점
프로미스가 생성된 직후, `resolve` 또는 `reject` 함수가 호출되기 전까지의 상태를 대기(Pending) 상태라고 합니다. 이 상태는 비동기 작업이 아직 완료되지 않았음을 나타냅니다.
PENDING 상태의 특징
- 프로미스가 생성되자마자 갖는 초기 상태입니다.
- 비동기 작업이 진행 중이거나 아직 시작되지 않았음을 의미합니다.
- 이 상태의 프로미스는 `.then()`, `.catch()`, `.finally()` 메서드를 통해 후속 처리 핸들러를 등록할 수 있습니다.
- PENDING 상태의 프로미스는 결과를 알 수 없으므로, 후속 처리 핸들러가 실행되지 않고 기다립니다.
실무에서 PENDING 상태를 직접 다루는 경우는 거의 없지만, 비동기 작업이 예상보다 오래 걸리거나 무한 대기에 빠질 때 이 상태를 이해하는 것이 디버깅에 큰 도움이 됩니다. 예를 들어, 네트워크 요청이 타임아웃되었거나 파일을 읽는 중 에러가 발생하여 `resolve`나 `reject`가 호출되지 않았다면 해당 프로미스는 영원히 PENDING 상태에 머물게 됩니다.
3. FULFILLED (이행) 상태: 성공적인 완료
비동기 작업이 성공적으로 완료되었음을 나타내는 상태를 이행(Fulfilled) 상태라고 합니다. 실행자 함수 내부에서 `resolve` 함수가 성공적으로 호출되었을 때 이 상태로 전이됩니다. 이때 `resolve`에 전달된 값은 프로미스의 결과 값이 됩니다.
FULFILLED 상태의 특징
- 비동기 작업이 성공했음을 의미합니다.
- `resolve()` 함수가 호출되었을 때 전이됩니다.
- 전이된 후에는 상태와 결과 값이 변경되지 않습니다.
- `.then()` 메서드의 첫 번째 인자로 전달된 콜백 함수(onFulfilled)가 실행됩니다.
- 성공 결과 값은 `.then()`의 콜백 함수에 첫 번째 인자로 전달됩니다.
4. REJECTED (거부) 상태: 실패와 에러 처리
비동기 작업이 실패했거나 에러가 발생했음을 나타내는 상태를 거부(Rejected) 상태라고 합니다. 실행자 함수 내부에서 `reject` 함수가 호출되거나, 실행자 함수 내에서 에러가 던져졌을 때 이 상태로 전이됩니다. 이때 `reject`에 전달된 이유는 프로미스의 거부 이유가 됩니다.
REJECTED 상태의 특징
- 비동기 작업이 실패했음을 의미합니다.
- `reject()` 함수가 호출되거나 에러가 발생했을 때 전이됩니다.
- 전이된 후에는 상태와 거부 이유가 변경되지 않습니다.
- `.catch()` 메서드의 콜백 함수(onRejected)가 실행됩니다.
- 거부 이유는 `.catch()`의 콜백 함수에 인자로 전달됩니다.
REJECTED 상태는 비동기 프로그래밍에서 에러를 우아하게 처리하는 데 핵심적입니다. 프로미스는 에러가 발생하면 거부 상태로 전이되고, 체이닝된 후속 처리 메서드 중 가장 가까운 `.catch()` 핸들러로 제어권을 넘깁니다. 이를 통해 동기 코드의 `try...catch` 블록과 유사하게 비동기 코드에서도 중앙 집중식 에러 처리를 가능하게 합니다.
5. 상태 전이(Transition)와 Settled 상태
프로미스는 PENDING 상태에서 FULFILLED 또는 REJECTED 상태로 한 번만 전이될 수 있습니다. FULFILLED 또는 REJECTED 상태가 된 프로미스는 결정된(Settled) 상태라고 부르기도 합니다. Settled 상태는 Pending 상태의 반대 개념이며, 더 이상 상태가 변경될 수 없음을 나타냅니다.
중요한 점은 프로미스는 한 번 Settled 상태가 되면 더 이상 `resolve`나 `reject`를 호출해도 아무런 효과가 없다는 것입니다. 예를 들어, `resolve`를 호출하여 FULFILLED 상태가 된 프로미스에서 이후에 `reject`를 호출하거나 다시 `resolve`를 호출해도 상태나 값은 변경되지 않습니다. 이러한 불변성은 비동기 작업의 결과를 신뢰할 수 있게 만듭니다.
6. 요약 및 핵심 차이 비교
프로미스의 세 가지 상태에 대해 명확하게 이해하고, 각 상태의 특징과 트리거, 그리고 실질적인 차이점을 요약하여 비교하면 다음과 같습니다. 이 표는 실무에서 비동기 코드를 작성하고 에러를 디버깅할 때 핵심적인 참고 자료가 될 것입니다.
| 상태 | 설명 | 트리거 (상태 전이 조건) | Settled 상태 여부 | 전달되는 값 | 후속 처리 메서드 | 실무 핵심 역할 |
|---|---|---|---|---|---|---|
| PENDING | 비동기 작업의 초기 상태이며, 아직 완료되지 않음 | `new Promise()` 생성 직후 | 아니오 | (없음) | `.then()`, `.catch()`, `.finally()` (대기) | 비동기 작업의 진행 중을 나타내며, 후속 처리를 예약함. 타임아웃 처리에 활용. |
| FULFILLED | 비동기 작업이 성공적으로 완료됨 | 실행자 함수 내에서 `resolve(value)` 호출 성공 | 예 | `value` (성공 결과) | `.then()`의 첫 번째 콜백 함수 | 성공적인 결과 값을 전달하고, 후속 비동기 작업을 체이닝하거나 결과를 화면에 표시함. |
| REJECTED | 비동기 작업이 실패하거나 에러가 발생함 | 실행자 함수 내에서 `reject(reason)` 호출 또는 에러 발생 | 예 | `reason` (거부 이유, 보통 Error 객체) | `.catch()` 콜백 함수 | 비동기 에러를 우아하게 포착하고 처리함. 사용자에게 에러 메시지를 표시하거나 대체 값을 제공함. |
7. 실무 개발자를 위한 고급 예제: 상태 관리와 해결 방법 7선
이론적인 이해를 넘어 실무에서 마주하는 다양한 비동기 문제를 프로미스의 상태를 활용하여 어떻게 해결할 수 있는지에 대한 핵심 예제를 제공합니다. 이 예제들은 실무에 바로 적용 가능하도록 작성되었으며, 각 예제에 대한 자세한 분석과 상태 변화를 설명합니다. 이 예제들을 마스터하면 비동기 프로그래밍 전문가로 한 단계 도약할 수 있을 것입니다.
Ex 1: 기본 상태 흐름 - 성공과 실패
가장 기본적인 프로미스 상태 전이와 값 전달을 보여줍니다.
// 성공 예제
const successPromise = new Promise((resolve, reject) => {
// 1초 후 성공
setTimeout(() => {
resolve("데이터 로드 성공!"); // PENDING -> FULFILLED, 값 전달
}, 1000);
});
// 실패 예제
const failurePromise = new Promise((resolve, reject) => {
// 1초 후 실패
setTimeout(() => {
reject(new Error("데이터 로드 실패: 네트워크 오류")); // PENDING -> REJECTED, 이유 전달
}, 1000);
});
// 후속 처리
successPromise
.then(data => console.log("성공:", data))
.catch(error => console.error("성공 예제 오류:", error.message));
failurePromise
.then(data => console.log("실패:", data))
.catch(error => console.error("실패:", error.message));
console.log("=== 비동기 작업 시작 ===");
console.log("successPromise 초기 상태:", successPromise); // PENDING
console.log("failurePromise 초기 상태:", failurePromise); // PENDING
분석 및 상태 변화: `setTimeout`을 사용하여 비동기 작업을 시뮬레이션합니다. 1초가 지난 후 `resolve`가 호출되면 `successPromise`는 PENDING에서 FULFILLED로 전이되고 결과 값을 `.then()`으로 넘깁니다. 반면, `reject`가 호출되면 `failurePromise`는 PENDING에서 REJECTED로 전이되고 거부 이유를 `.catch()`로 넘깁니다. `finally` 블록은 성공 여부와 상관없이 항상 실행됩니다.
Ex 2: 실행자 함수 내 에러 발생 시 상태 전이
`reject`를 호출하지 않더라도 실행자 함수 내에서 에러가 발생하면 자동으로 REJECTED 상태가 됩니다.
const errorInExecutorPromise = new Promise((resolve, reject) => {
// 에러 발생
throw new Error("코드 실행 중 예기치 않은 오류 발생!"); // PENDING -> REJECTED
// resolve 또는 reject는 실행되지 않음
});
errorInExecutorPromise
.then(data => console.log("성공 (에러 예제):", data))
.catch(error => console.error("실패 (에러 예제):", error.message)); // 에러를 포착함
console.log("=== 에러 발생 예제 시작 ===");
console.log("errorInExecutorPromise 초기 상태:", errorInExecutorPromise); // REJECTED
분석 및 상태 변화: 실행자 함수 내부에서 동기 에러(`throw new Error()`)가 발생했습니다. 이 경우 프로미스는 자동으로 PENDING에서 REJECTED 상태로 전이되며, 발생한 에러 객체가 거부 이유가 됩니다. 이처럼 프로미스는 실행자 함수 내의 에러까지도 캡처하여 안전하게 처리할 수 있게 해줍니다.
Ex 3: PENDING 상태의 타임아웃 처리
비동기 작업이 너무 오래 걸릴 경우 타임아웃을 구현하여 PENDING 상태를 강제로 REJECTED로 만듭니다.
function fetchWithTimeout(url, timeoutMs) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`요청 타임아웃: ${timeoutMs}ms 경과`)); // PENDING -> REJECTED (강제 실패)
}, timeoutMs);
// 실제 네트워크 요청 시뮬레이션
setTimeout(() => {
// 실제 네트워크 요청이 타임아웃보다 빨리 완료되었다면
clearTimeout(timeoutId); // 타임아웃 타이머를 제거하여 reject를 막음
resolve(`데이터 로드 성공 (URL: ${url})`); // PENDING -> FULFILLED (실제 성공)
}, Math.random() > 0.5 ? timeoutMs - 500 : timeoutMs + 500); // 랜덤하게 성공/실패 시뮬레이션
});
}
// 2초 타임아웃 요청
fetchWithTimeout("https://api.example.com/data", 2000)
.then(data => console.log("Ex 3 성공:", data))
.catch(error => console.error("Ex 3 타임아웃:", error.message));
console.log("=== 타임아웃 예제 시작 ===");
분석 및 상태 변화: `Promise` 생성자 내부에서 타임아웃 타이머(`setTimeout`)를 설정합니다. 비동기 작업이 타임아웃 시간 내에 완료되지 않으면 `reject`를 호출하여 프로미스를 REJECTED 상태로 만듭니다. 만약 비동기 작업이 제시간에 성공하면 `resolve`를 호출하고 `clearTimeout`으로 타임아웃 타이머를 제거합니다. 이를 통해 PENDING 상태가 무한히 지속되는 것을 방지하고 애플리케이션의 반응성을 유지할 수 있습니다.
Ex 4: 여러 비동기 작업을 체이닝할 때 상태 전이
`.then()` 콜백에서 새로운 프로미스를 반환하여 상태 전이를 이어갑니다.
// 데이터를 가져오고 가공하는 비동기 작업 체이닝
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => resolve({ id: 1, name: "Data 1" }), 1000); // 1초 후 FULFILLED
});
}
function processData(data) {
return new Promise((resolve) => {
setTimeout(() => resolve({ ...data, status: "processed" }), 1000); // 1초 후 FULFILLED
});
}
fetchData()
.then(data => {
console.log("=== [Ex 4] fetchData 완료, 상태:", data); // FULFILLED
return processData(data); // 새로운 프로미스를 반환하여 체이닝
})
.then(processedData => {
console.log("=== [Ex 4] processData 완료, 최종 결과:", processedData); // FULFILLED
})
.catch(error => console.error("=== [Ex 4] 에러 발생:", error.message)); // 어떤 단계에서든 에러 발생 시 처리
분석 및 상태 변화: 첫 번째 `.then()`의 콜백 함수는 `processData` 함수를 호출하여 새로운 프로미스를 반환합니다. 자바스크립트는 `.then()`에서 프로미스가 반환되면, 그 프로미스가 Settled 상태가 될 때까지 기다렸다가 다음 `.then()`으로 결과 값을 전달합니다. 이를 통해 여러 개의 비동기 작업을 순차적으로 실행하고, 상태 변화를 이어갈 수 있습니다. 마지막에 붙은 하나의 `.catch()`는 체인의 어떤 단계에서든 발생한 REJECTED 상태를 포착하여 처리합니다.
Ex 5: REJECTED 상태 복구 - 폴백(Fallback) 패턴
비동기 작업이 실패했을 때 에러를 표시하는 대신 대체 값을 제공하여 프로그램이 계속 실행되도록 합니다.
function getImportantData() {
return new Promise((resolve, reject) => {
// 일부러 실패를 유도
setTimeout(() => reject(new Error("중요 데이터 로드 실패")), 1000); // PENDING -> REJECTED
});
}
// 폴백 패턴
getImportantData()
.catch(error => {
console.warn(`[Ex 5] 데이터 로드 실패, 대체 값을 사용합니다. 에러: ${error.message}`);
// .catch()에서 대체 값을 반환하면 FULFILLED 상태로 변환됨
return { id: 0, name: "Default Data" }; // PENDING -> FULFILLED (복구)
})
.then(data => {
console.log("[Ex 5] 최종 데이터 사용:", data); // 성공적으로 대체 값을 사용
});
분석 및 상태 변화: `getImportantData`가 REJECTED 상태가 됩니다. 이 에러는 `.catch()`에서 포착됩니다. 중요한 것은 `.catch()` 콜백 함수에서 어떤 값을 반환하면, 그 반환된 값을 결과로 갖는 새로운 FULFILLED 상태의 프로미스를 생성한다는 점입니다. 이를 통해 실패한 비동기 작업을 성공적인 결과로 '복구'하고, 다음 `.then()`으로 제어권을 안전하게 넘길 수 있습니다. 이는 폴백 패턴을 구현하는 핵심 메커니즘입니다.
Ex 6: 여러 프로미스 상태 중 일부 실패 허용 - Promise.allSettled
`Promise.all`과 달리, 하나라도 실패해도 전체를 중단하지 않고 모든 프로미스의 Settled 상태를 확인합니다.
// 여러 이미지를 로드하는 시뮬레이션
const loadImage = (id, delayMs, shouldFail = false) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error(`이미지 ${id} 로드 실패`)); // REJECTED
} else {
resolve({ id, src: `image_${id}.png` }); // FULFILLED
}
}, delayMs);
});
};
const images = [
loadImage(1, 1000), // 성공
loadImage(2, 2000, true), // 실패
loadImage(3, 1500) // 성공
];
Promise.allSettled(images)
.then(results => {
console.log("=== [Ex 6] Promise.allSettled 완료 ===");
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`[성공] 이미지 ${result.value.id}: ${result.value.src}`);
} else if (result.status === "rejected") {
console.error(`[실패] 이미지 ${index + 1}: ${result.reason.message}`);
}
});
});
분석 및 상태 변화: `Promise.all`은 하나라도 REJECTED가 되면 즉시 전체 요청을 REJECTED로 만듭니다. 하지만 `Promise.allSettled`는 배열로 전달된 모든 프로미스가 Settled 상태가 될 때까지 기다립니다. 이후 각 프로미스의 상태(fulfilled 또는 rejected)와 값(value) 또는 거부 이유(reason)를 객체 배열로 반환합니다. 이를 통해 일부 비동기 작업이 실패하더라도 전체 요청을 중단하지 않고, 각 작업의 결과를 개별적으로 처리할 수 있습니다.
Ex 7: 네트워크 요청 재시도(Retry) 로직 구현
비동기 작업이 실패했을 때 특정 횟수만큼 재시도하여 성공을 도모합니다.
function fetchWithRetry(url, maxRetries, delayMs) {
let retries = 0;
return new Promise((resolve, reject) => {
const attemptFetch = () => {
fetch(url) // fetch는 기본적으로 Promise를 반환 (비동기)
.then(response => {
if (response.ok) {
return response.json(); // 성공 시 json 변환 (Promise 반환)
} else {
throw new Error(`서버 응답 오류 (상태코드: ${response.status})`);
}
})
.then(data => {
resolve(data); // 성공 시 상위 프로미스 FULFILLED
})
.catch(error => {
console.error(`요청 실패 (시도횟수: ${retries + 1}/${maxRetries}): ${error.message}`);
if (retries < maxRetries - 1) {
retries++;
setTimeout(attemptFetch, delayMs); // 실패 시 일정 시간 후 재시도
} else {
// 모든 재시도 실패 시 상위 프로미스 REJECTED
reject(new Error(`모든 재시도 실패. 마지막 에러: ${error.message}`));
}
});
};
attemptFetch(); // 첫 번째 시도
});
}
// 3번 재시도, 1초 간격 (일부러 존재하지 않는 URL 호출)
fetchWithRetry("https://jsonplaceholder.typicode.com/invalid-url", 3, 1000)
.then(data => console.log("[Ex 7] 재시도 성공:", data))
.catch(error => console.error("[Ex 7] 재시도 실패:", error.message));
분석 및 상태 변화: `new Promise()` 내부에 재시도 로직을 구현합니다. `attemptFetch` 함수는 네트워크 요청을 시도하고 실패하면 `.catch()`에서 재시도 횟수를 확인합니다. 재시도 횟수가 남아있다면 `setTimeout`을 사용하여 일정 시간 후 `attemptFetch`를 다시 호출합니다. 이 과정에서 상위 프로미스는 PENDING 상태를 유지합니다. 모든 재시도가 실패하면 상위 프로미스를 REJECTED 상태로 만듭니다. 이를 통해 일시적인 네트워크 오류를 극복하고 애플리케이션의 신뢰성을 높일 수 있습니다.
마무리: 프로미스 상태를 넘어서 - Async/Await의 역할
프로미스의 세 가지 상태(Pending, Fulfilled, Rejected)는 자바스크립트 비동기 프로그래밍을 이해하는 가장 핵심적인 기반입니다. 각 상태의 특징과 트리거, 그리고 전이 메커니즘을 명확히 이해해야만, 콜백 지옥을 해결하고 에러 처리를 우아하게 할 수 있습니다. 실무 예제 7선을 통해 다양한 문제 상황에서 상태를 활용하는 방법을 익혔을 것입니다. 프로미스의 상태 관리는 복잡한 비동기 워크플로우를 예측 가능하고 안정적으로 만드는 데 결정적인 역할을 합니다.
하지만 프로미스를 기반으로 하더라도 복잡한 비동기 체이닝 코드는 여전히 읽기 어렵고 관리가 힘들 수 있습니다. 이러한 문제를 해결하고 비동기 코드를 더욱 동기 코드처럼 읽고 쓸 수 있도록 ES2017(ES8)에서 등장한 것이 바로 Async/Await입니다. Async/Await는 프로미스를 더욱 사용하기 쉽게 만들어주는 문법적 설탕(syntactic sugar)일 뿐이며, 내부적으로는 여전히 프로미스를 기반으로 동작합니다. Async 함수는 항상 프로미스를 반환하며, Await 키워드는 프로미스가 Settled 상태가 될 때까지 기다립니다. 따라서 Async/Await를 더욱 깊이 있게 활용하기 위해서는 여전히 프로미스의 세 가지 상태와 동작 원리에 대한 완벽한 이해가 필수적입니다.
이 글을 통해 프로미스의 세 가지 상태에 대한 심도 있는 이해와 실질적인 해결 능력을 기르는 데 도움이 되었기를 바랍니다. 비동기 프로그래밍 전문가가 되기 위해 프로미스 상태를 완벽하게 마스터하고, 나아가 Async/Await까지 능숙하게 다룰 수 있도록 꾸준히 연습해 보시기 바랍니다.
참고 문헌 및 출처:
- MDN Web Docs
- ECMAScript Specification: ECMAScript Standard for Promise Objects
- You Don't Know JS: Async & Performance by Kyle Simpson
- JavaScript.info: Promises and Async/Await