2025-10-07 13:11
-
자바스크립트 프로미스는 비동기 작업을 깔끔하게 처리하기 위해 콜백 지옥을 대체하는 객체다.
-
프로미스는 ‘대기’, ‘이행’, ‘거부’라는 세 가지 상태를 가지며, 한 번 결정된 상태는 바뀌지 않는다.
-
then,catch,finally를 통해 비동기 작업의 성공, 실패, 완료 시점을 제어하며async/await문법의 기반이 된다.
자바스크립트 비동기의 구원자 프로미스 완벽 핸드북
자바스크립트는 본질적으로 ‘싱글 스레드(Single-threaded)’ 기반 언어다. 한 번에 하나의 작업만 처리할 수 있다는 의미다. 하지만 웹 애플리케이션에서는 데이터를 서버에서 가져오거나, 파일을 읽는 등 시간이 걸리는 작업이 필수적이다. 만약 이런 작업이 끝날 때까지 프로그램 전체가 멈춘다면 사용자는 끔찍한 경험을 하게 될 것이다.
이 문제를 해결하기 위해 자바스크립트는 ‘비동기(Asynchronous)’ 처리 모델을 사용한다. 시간이 걸리는 작업을 일단 다른 곳에 위임하고, 그 작업이 끝나면 알려달라고 요청하는 방식이다. 초기 자바스크립트에서는 이 ‘알림’을 받기 위해 ‘콜백 함수(Callback Function)‘를 사용했다. 하지만 비동기 작업이 여러 개 중첩되면 코드의 가독성이 급격히 떨어지는 ‘콜백 지옥(Callback Hell)‘이라는 문제가 발생했다.
이 콜백 지옥의 혼돈 속에서 질서를 부여하기 위해 등장한 개념이 바로 **프로미스(Promise)**다. 이 핸드북은 프로미스가 왜 탄생했고, 어떤 구조로 이루어져 있으며, 어떻게 사용해야 하는지에 대한 모든 것을 깊이 있게 다룬다.
1. 왜 프로미스가 탄생했는가 콜백 지옥 탈출기
프로미스를 이해하려면 그것이 해결하고자 했던 문제, 즉 콜백 지옥을 먼저 이해해야 한다.
1.1. 지옥의 서막 콜백 패턴
예를 들어, 사용자 정보를 가져오고, 그 정보로 게시글을 가져온 다음, 그 게시글에 달린 댓글을 가져오는 3단계 비동기 작업이 있다고 상상해보자. 콜백 함수를 사용하면 코드는 다음과 같은 형태가 된다.
JavaScript
getUser(userId, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
console.log(comments);
// 또 다른 작업이 있다면...
// 또 다른 작업이 있다면...
// 끝없이 깊어지는 들여쓰기
});
});
});
코드가 오른쪽으로 끝없이 파고드는 피라미드 형태, 이것이 바로 콜백 지옥(Callback Hell) 또는 **운명의 피라미드(Pyramid of Doom)**다. 이런 코드는 몇 가지 심각한 문제를 안고 있다.
-
가독성 저하: 코드의 흐름을 파악하기 매우 어렵다. 어떤 작업이 어떤 순서로 실행되는지 한눈에 들어오지 않는다.
-
에러 처리의 어려움: 각 콜백마다 개별적으로 에러를 처리해야 한다. 중앙에서 에러를 관리하기 어렵고, 코드가 누락되면 디버깅이 힘들어진다.
-
제어권 역전(Inversion of Control): 콜백 함수의 호출 시점과 횟수에 대한 제어권이 내가 작성한 코드가 아닌, 비동기 작업을 수행하는 외부 함수에 넘어간다. 이는 예측 불가능한 버그를 유발할 수 있다.
1.2. 프로미스의 등장 약속의 개념
프로미스는 이러한 문제들을 해결하기 위해 고안된 디자인 패턴이자 자바스크립트 ES6에 정식으로 추가된 객체다.
프로미스를 한마디로 정의하면 **“미래의 어떤 시점에 결과를 제공하겠다는 약속”**이다.
커피숍에서 주문하는 상황을 비유로 들어보자.
-
주문 (프로미스 생성): 당신이 카운터에서 커피를 주문하면, 점원은 “커피가 준비되면 진동벨을 울려드릴게요”라고 말하며 진동벨을 준다. 이 진동벨이 바로 프로미스다.
-
대기 (Pending 상태): 당신은 진동벨을 들고 자리에 앉아 다른 일을 할 수 있다. 커피가 만들어지는 동안 당신의 시간은 멈추지 않는다. 이것이 비동기 처리의 핵심이다.
-
결과 (Settled 상태):
-
성공 (Fulfilled): 잠시 후, 진동벨이 울린다. 당신은 카운터로 가서 약속된 커피를 받는다. 약속이 성공적으로 이행된 것이다.
-
실패 (Rejected): 만약 원두가 다 떨어졌다면, 점원은 당신을 불러 주문을 취소하고 환불해 줄 것이다. 약속이 실패한 것이다.
-
이처럼 프로미스는 비동기 작업의 ‘상태’를 관리하고, 그 결과(성공 또는 실패)를 처리할 수 있는 명확한 방법을 제공한다. 이를 통해 들여쓰기가 깊어지는 대신, 순차적인 코드 흐름을 만들 수 있다.
2. 프로미스의 핵심 구조 파헤치기
프로미스는 단순한 객체가 아니라, 명확한 상태와 규칙을 가진 하나의 작은 시스템이다.
2.1. 세 가지 상태
모든 프로미스 객체는 다음 세 가지 상태 중 하나를 가진다.
| 상태 (State) | 설명 | 비유 |
|---|---|---|
pending (대기) | 비동기 작업이 아직 완료되지 않은 초기 상태. | 진동벨이 아직 울리지 않은 상태. |
fulfilled (이행) | 비동기 작업이 성공적으로 완료되어 결과 값을 반환한 상태. | 진동벨이 울려 커피를 받은 상태. |
rejected (거부) | 비동기 작업이 실패하여 에러(이유)를 반환한 상태. | 원두가 떨어져 주문이 취소된 상태. |
가장 중요한 규칙: 프로미스는 pending 상태에서 fulfilled 또는 rejected 상태로 단 한 번만 변경될 수 있다. 한번 이행되거나 거부된 프로미스는 다시는 다른 상태로 돌아갈 수 없다. 이를 settled(처리됨) 상태라고 부른다.
2.2. 프로미스 객체 생성하기
프로미스는 new Promise() 생성자를 통해 만들어진다. 이 생성자는 **실행 함수(executor function)**라는 하나의 함수를 인자로 받는다.
JavaScript
const myPromise = new Promise((resolve, reject) => {
// 비동기 작업을 수행하는 코드
// 예: setTimeout, fetch, readFile 등
const success = true; // 작업 성공 여부를 가정
if (success) {
// 작업이 성공하면 resolve 함수를 호출
resolve("작업 성공! 결과 데이터");
} else {
// 작업이 실패하면 reject 함수를 호출
reject(new Error("작업 실패! 에러 메시지"));
}
});
-
실행 함수:
new Promise가 생성될 때 즉시 실행된다. -
resolve(value): 이 함수가 호출되면 프로미스는pending상태에서fulfilled상태로 변경된다. 인자로 전달된value는 프로미스의 결과 값이 된다. -
reject(error): 이 함수가 호출되면 프로미스는pending상태에서rejected상태로 변경된다. 인자로 전달된error는 프로미스가 실패한 이유가 된다.
3. 프로미스 사용법 정복하기
프로미스를 만드는 것만큼이나 중요한 것은 만들어진 프로미스의 결과를 ‘소비(consuming)‘하는 것이다.
3.1. 결과 값 받아오기 .then()
.then() 메서드는 프로미스가 **이행(fulfilled)**되었을 때 실행될 콜백 함수를 등록하는 역할을 한다.
JavaScript
myPromise.then((result) => {
// 프로미스가 성공적으로 이행되었을 때 실행되는 코드
console.log(result); // "작업 성공! 결과 데이터"
});
.then()은 두 개의 함수를 인자로 받을 수 있다. 첫 번째는 성공 콜백, 두 번째는 실패 콜백이다.
JavaScript
myPromise.then(
(result) => { console.log(result); },
(error) => { console.error(error); }
);
3.2. 에러 처리하기 .catch()
.catch() 메서드는 프로미스가 **거부(rejected)**되었을 때 실행될 콜백 함수를 등록한다. 이는 .then(null, rejectionCallback)과 동일하게 동작하는 가독성 좋은 축약형이다.
JavaScript
myPromise.catch((error) => {
// 프로미스가 거부되었을 때 실행되는 코드
console.error(error); // Error: "작업 실패! 에러 메시지"
});
에러 처리는 .catch()를 사용하는 것이 가독성과 일관성 측면에서 권장된다.
3.3. 성공/실패 여부와 상관없이 실행하기 .finally()
.finally() 메서드는 프로미스가 settled(이행 또는 거부) 상태가 되면 항상 실행될 콜백 함수를 등록한다. 로딩 스피너를 숨기는 등, 성공/실패 여부와 관계없이 항상 수행해야 하는 정리 작업에 유용하다.
JavaScript
myPromise
.then((result) => console.log(result))
.catch((error) => console.error(error))
.finally(() => {
console.log("프로미스 작업이 완료되었습니다.");
});
3.4. 프로미스의 꽃 프로미스 체이닝(Promise Chaining)
.then()과 .catch() 메서드는 항상 새로운 프로미스 객체를 반환한다. 이것이 프로미스의 가장 강력한 특징이며, 콜백 지옥을 해결하는 핵심 원리다.
반환된 프로미스는 이전 프로미스의 결과에 따라 상태가 결정된다.
-
.then()의 콜백 함수에서 값을 반환하면, 체인의 다음 프로미스는 그 값으로 이행된다. -
.then()의 콜백 함수에서 새로운 프로미스를 반환하면, 체인의 다음.then()은 그 새로운 프로미스가settled될 때까지 기다린다. -
에러가 발생하거나
.catch()가 실행되면, 에러가 처리된 후 체인은 계속 진행될 수 있다.
콜백 지옥 예제를 프로미스 체이닝으로 바꿔보자.
JavaScript
getUser(userId)
.then((user) => {
console.log("사용자 정보 획득:", user);
return getPosts(user.id); // 다음 작업(프로미스)을 반환
})
.then((posts) => {
console.log("게시글 목록 획득:", posts);
return getComments(posts[0].id); // 또 다른 프로미스 반환
})
.then((comments) => {
console.log("댓글 획득:", comments);
})
.catch((error) => {
// 체인 전체에서 발생하는 모든 에러를 여기서 한 번에 처리
console.error("오류 발생:", error);
})
.finally(() => {
console.log("모든 데이터 요청 작업 완료");
});
코드가 위에서 아래로 순차적으로 흐르는 것을 볼 수 있다. 들여쓰기가 깊어지지 않고, 에러 처리가 단일 .catch() 블록으로 통합되어 훨씬 깔끔하고 직관적이다.
4. 심화 탐구 프로미스, 더 깊게 이해하기
프로미스의 기본 사용법을 익혔다면, 이제 내부 동작 원리와 강력한 정적 메서드들을 탐구할 차례다.
4.1. 프로미스와 이벤트 루프 마이크로태스크 큐
자바스크립트 런타임 환경에는 **이벤트 루프(Event Loop)**라는 것이 있다. 이벤트 루프는 콜 스택(Call Stack)이 비었을 때 태스크 큐(Task Queue)에서 작업을 가져와 실행하는 역할을 한다.
여기서 중요한 점은 태스크 큐가 하나가 아니라는 것이다.
-
매크로태스크 큐 (Macrotask Queue, 또는 그냥 Task Queue):
setTimeout,setInterval, I/O 작업 등의 콜백이 들어간다. -
마이크로태스크 큐 (Microtask Queue): 프로미스의
.then(),.catch(),.finally()콜백과MutationObserver등이 들어간다.
이벤트 루프는 현재 실행 중인 스크립트가 끝나면, 마이크로태스크 큐에 있는 모든 작업을 먼저 처리한 뒤, 매크로태스크 큐에서 작업을 하나 가져와 처리한다.
이 우선순위 때문에 프로미스 콜백은 setTimeout 콜백보다 항상 먼저 실행된다.
JavaScript
console.log("스크립트 시작"); // 1
setTimeout(() => {
console.log("setTimeout 콜백"); // 4
}, 0);
Promise.resolve().then(() => {
console.log("프로미스 콜백"); // 3
});
console.log("스크립트 끝"); // 2
// 출력 순서:
// 스크립트 시작
// 스크립트 끝
// 프로미스 콜백
// setTimeout 콜백
setTimeout의 지연 시간이 0임에도 불구하고 프로미스 콜백이 먼저 실행되는 이유는 바로 이 마이크로태스크 큐의 우선순위 때문이다.
4.2. 정적 메서드 완전 정복
프로미스는 여러 개의 프로미스를 조합하여 더 복잡한 비동기 로직을 처리할 수 있는 강력한 정적 메서드들을 제공한다.
| 메서드 | 설명 | 반환 값 |
|---|---|---|
Promise.all(iterable) | 모든 프로미스가 이행될 때까지 기다린다. 하나라도 거부되면 즉시 거부된다. | 모든 프로미스의 결과 값을 담은 배열. |
Promise.race(iterable) | 가장 먼저 settled(이행 또는 거부)되는 프로미스의 결과/이유를 따른다. | 가장 먼저 처리된 프로미스의 결과 또는 이유. |
Promise.allSettled(iterable) | 모든 프로미스가 settled(이행 또는 거부)될 때까지 기다린다. | 각 프로미스의 상태(status)와 결과(value 또는 reason)를 담은 객체 배열. |
Promise.any(iterable) | 가장 먼저 이행되는 프로미스의 결과를 따른다. 모든 프로미스가 거부되면 AggregateError와 함께 거부된다. | 가장 먼저 이행된 프로미스의 결과. |
-
Promise.all: 여러 API를 동시에 호출하고, 모든 응답이 와야 다음 작업을 처리할 수 있을 때 유용하다. “전원 성공해야 성공”. -
Promise.race: 여러 소스 중 가장 빠른 응답을 사용하거나, 특정 시간 내에 응답이 오지 않으면 타임아웃 처리할 때 유용하다. “1등만 기억하는 세상”. -
Promise.allSettled: 여러 작업의 성공 여부와 관계없이 모든 작업의 결과를 확인하고 싶을 때 사용한다.all과 달리 실패가 있어도 중단되지 않는다. “결과가 어떻든 모두 끝까지 간다”. -
Promise.any: 여러 미러 서버 중 가장 빠른 서버로부터 데이터를 가져올 때 유용하다. “누구든 성공만 하면 된다”.
5. 프로미스를 넘어 async/await
ES2017(ES8)에서는 프로미스를 더욱 쉽게 사용할 수 있도록 async/await 문법이 도입되었다. 이는 프로미스 기반의 비동기 코드를 마치 동기 코드처럼 보이게 만드는 **문법적 설탕(Syntactic Sugar)**이다.
async 함수는 항상 프로미스를 반환하고, await 키워드는 프로미스가 settled될 때까지 함수의 실행을 일시 중지시킨다.
앞서 본 프로미스 체이닝 예제를 async/await으로 바꿔보자.
JavaScript
async function fetchUserData(userId) {
try {
const user = await getUser(userId);
console.log("사용자 정보 획득:", user);
const posts = await getPosts(user.id);
console.log("게시글 목록 획득:", posts);
const comments = await getComments(posts[0].id);
console.log("댓글 획득:", comments);
return comments;
} catch (error) {
console.error("오류 발생:", error);
} finally {
console.log("모든 데이터 요청 작업 완료");
}
}
fetchUserData(userId);
-
비동기 작업 앞에
await을 붙여 결과가 올 때까지 기다린다. -
에러 처리는 익숙한
try...catch구문을 사용한다. -
코드의 흐름이 위에서 아래로 자연스럽게 이어져 가독성이 극적으로 향상된다.
async/await은 내부적으로 프로미스를 사용하므로, 프로미스에 대한 깊은 이해는 async/await을 더 효과적으로 사용하는 데 필수적이다.
결론 프로미스는 현대 자바스크립트의 심장
프로미스는 자바스크립트 비동기 프로그래밍의 패러다임을 바꾼 혁신적인 개념이다. 콜백 지옥의 복잡성을 명확하고 순차적인 코드로 해결했으며, async/await이라는 더 발전된 패턴의 기반이 되었다.
웹 애플리케이션이 점점 더 복잡해지고 실시간 데이터 통신이 중요해지는 오늘날, 비동기 처리는 선택이 아닌 필수다. 프로미스의 상태, 체이닝, 그리고 이벤트 루프와의 상호작용을 깊이 이해하는 것은 모든 현대 자바스크립트 개발자가 갖추어야 할 핵심 역량이라 할 수 있다. 프로미스는 단순한 문법을 넘어, 비동기적 사고의 근간을 이루는 중요한 철학이다.