
1. 비동기 프로그래밍의 서막: 왜 우리는 기다림을 관리해야 하는가?
모던 웹 애플리케이션에서 자바스크립트는 단순한 UI 조작을 넘어 서버와의 데이터 통신, 대규모 파일 처리, 복잡한 애니메이션 연산 등 무거운 작업들을 수행합니다. 자바스크립트는 태생적으로 '싱글 스레드(Single-Thread)' 언어입니다. 즉, 한 번에 하나의 작업만을 처리할 수 있는 호출 스택(Call Stack)을 가지고 있습니다.
만약 서버에서 수백 메가바이트의 데이터를 가져오는 동기(Synchronous)적인 코드가 실행된다면, 데이터를 모두 다운로드할 때까지 브라우저는 멈춰버리게 됩니다. 이를 블로킹(Blocking) 현상이라고 합니다. 이러한 치명적인 문제를 피하고 사용자 경험(UX)을 유지하기 위해 자바스크립트는 비동기(Asynchronous) 처리 방식을 도입했습니다. 특정 작업의 완료를 기다리지 않고 다음 코드를 먼저 실행하며, 작업이 끝나면 특정 로직을 수행하도록 예약하는 방식입니다.
2. 프로미스(Promise)의 등장 배경: 콜백 지옥과 제어의 역전
초기 자바스크립트 생태계에서 비동기 처리를 위한 유일한 방법은 콜백 함수(Callback Function)였습니다. A 작업이 끝난 후 B 작업을 실행하기 위해 A 함수의 인자로 B 함수를 넘겨주는 방식입니다. 단순한 로직에서는 문제가 없었지만, 서비스가 고도화되면서 두 가지 치명적인 문제가 발생했습니다.
2.1. 콜백 지옥 (Callback Hell / Pyramid of Doom)
사용자 인증을 하고, 그 정보로 게시글을 가져오고, 게시글에 달린 댓글을 가져오고, 그 댓글의 작성자 프로필 이미지를 가져오는 일련의 순차적인 비동기 작업이 있다고 가정해 봅시다. 콜백 방식으로 이를 구현하면 코드가 우측으로 깊게 파고들어가는 '멸망의 피라미드(Pyramid of Doom)'가 형성됩니다. 이는 코드의 가독성을 심각하게 훼손하며 유지보수를 불가능하게 만듭니다.
2.2. 제어의 역전 (Inversion of Control)과 신뢰성 문제
더 심각한 문제는 제어의 역전(IoC)입니다. 외부 라이브러리의 비동기 함수에 우리의 콜백 함수를 넘겨줄 때, 우리는 그 라이브러리가 우리의 콜백을 '정확히 한 번만', '성공했을 때만', '적절한 에러와 함께' 호출해 줄 것이라고 맹신해야 합니다. 만약 외부 라이브러리의 버그로 인해 콜백이 두 번 호출되거나 아예 호출되지 않는다면 애플리케이션은 치명적인 오류를 겪게 됩니다. 즉, 실행 제어권을 제3자에게 넘기게 되는 셈입니다.
3. 프로미스(Promise)란 무엇인가?
프로미스는 "미래의 어떤 시점에 결과를 제공하겠다는 약속"을 나타내는 자바스크립트 객체입니다. 비동기 작업이 성공했는지, 실패했는지, 아니면 아직 진행 중인지에 대한 상태(State)와 결과값(Value)을 캡슐화하여 가지고 있습니다.
프로미스를 사용하면 콜백을 외부 함수에 넘겨주는 대신, 외부 함수가 우리에게 Promise 객체를 반환하게 됩니다. 우리는 반환받은 Promise 객체의 .then()이나 .catch() 메서드를 사용해 직접 후속 처리를 연결합니다. 이로써 잃어버렸던 '제어권'을 다시 우리 코드로 가져오게 되는 것입니다.
프로미스의 3가지 상태(States)
- Pending (대기): 비동기 처리가 아직 수행되지 않은 초기 상태입니다.
- Fulfilled (이행/성공): 비동기 처리가 성공적으로 완료되어 프로미스가 결과값을 반환한 상태입니다.
- Rejected (거부/실패): 비동기 처리가 실패하거나 오류가 발생한 상태입니다.
4. 기존 방식과의 차이 요약 (Callback vs Promise vs Async/Await)
자바스크립트 비동기 처리의 발전 과정인 콜백, 프로미스, 그리고 최신 문법인 Async/Await의 핵심적인 7가지 차이점을 아래 표를 통해 한눈에 비교해 보겠습니다.
| 비교 항목 | 콜백 함수 (Callback) | 프로미스 (Promise) | Async / Await (ES8) |
|---|---|---|---|
| 1. 가독성 및 코드 구조 | 중첩될수록 들여쓰기가 깊어짐 (콜백 지옥 발생) | .then()을 활용한 체이닝으로 평면적 구조 유지 |
동기식 코드처럼 위에서 아래로 읽히는 최상의 가독성 |
| 2. 제어권 (Control) | 호출되는 함수에게 제어권을 위임 (제어의 역전 발생) | 비동기 작업의 결과를 객체로 반환받아 호출자가 제어 | 프로미스 기반 위에서 동작하며, 제어권이 완벽히 보장됨 |
| 3. 에러 처리 방법 | 각 콜백마다 에러 처리 로직을 중복해서 작성해야 함 | 체인 끝에 .catch() 하나로 모든 과정의 에러 통합 처리 |
전통적인 try...catch 블록을 사용하여 동기/비동기 에러 통합 |
| 4. 병렬 처리 로직 | 카운터 변수 등을 외부에서 관리하며 매우 복잡하게 구현 | Promise.all(), Promise.race() 등으로 깔끔하게 해결 |
프로미스 내장 메서드와 결합하여 직관적으로 작성 가능 |
| 5. 값의 반환 여부 | 결과를 단순히 다른 함수로 전달할 뿐, 반환(return) 불가 | 비동기 결과를 담은 'Promise 객체' 자체를 반환 가능 | 결과 값을 변수에 직접 할당 가능 (해석된 결과값 반환) |
| 6. 상태(State)의 유무 | 상태 개념 없음. 단발성 함수 호출 | Pending, Fulfilled, Rejected 상태를 객체 내부에 저장 | 내부적으로 프로미스의 상태 머신을 그대로 사용 |
| 7. 코드 디버깅 | 스택 트레이스(Stack Trace)가 끊겨 오류 추적이 매우 어려움 | 에러 발생 지점을 비교적 명확히 추적 가능 | 동기식 디버깅 툴을 그대로 사용할 수 있어 가장 용이함 |
5. 개발자가 실무에 바로 적용 가능한 프로미스 Example 7선
이론적인 이해를 넘어, 실제 프론트엔드 및 백엔드(Node.js) 실무 환경에서 프로미스가 어떻게 활용되는지 복사하여 바로 테스트해 볼 수 있는 7가지 핵심 예제를 제공합니다.
실무 예제 1: 기본 API 통신 및 데이터 가공 (fetch API 활용)
가장 기본적으로 서버로부터 JSON 데이터를 가져와 파싱하는 과정입니다. fetch() 함수는 기본적으로 Promise 객체를 반환합니다.
// 예제 1: 가짜 API(JSONPlaceholder)를 활용한 사용자 데이터 요청
function getUserData(userId) {
// fetch는 Promise를 반환합니다.
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then(response => {
// HTTP 상태 코드가 200번대가 아닐 경우 에러를 발생시킵니다.
if (!response.ok) {
throw new Error('네트워크 응답에 문제가 있습니다.');
}
// response.json() 역시 Promise를 반환하므로 체이닝이 가능합니다.
return response.json();
})
.then(data => {
console.log("=== [예제 1] 사용자 데이터 조회 성공 ===");
console.log(`이름: ${data.name}, 이메일: ${data.email}`);
})
.catch(error => {
console.error("=== [예제 1] 에러 발생 ===", error.message);
});
}
getUserData(1);
실무 예제 2: 콜백 지옥을 해결하는 프로미스 체이닝 (순차적 데이터 로드)
A 작업 결과를 받아 B 작업을 하고, B 작업 결과로 C 작업을 하는 경우입니다. .then() 내부에서 새로운 Promise를 반환하면 꼬리를 무는 체이닝이 완성됩니다.
// 예제 2: 사용자 조회 -> 작성한 게시물 조회 순차적 실행
function getUserAndPosts(userId) {
let userName = "";
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then(res => res.json())
.then(user => {
userName = user.name;
console.log(`1. 사용자(${userName}) 정보 로드 완료.`);
// 다음 then으로 넘기기 위해 새로운 fetch(Promise)를 반환합니다.
return fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`);
})
.then(res => res.json())
.then(posts => {
console.log(`2. ${userName}님이 작성한 게시물 ${posts.length}개 로드 완료.`);
console.log("첫 번째 게시물 제목:", posts[0].title);
})
.catch(error => {
console.error("데이터 로드 중 에러가 발생했습니다:", error);
})
.finally(() => {
// 성공/실패 여부와 상관없이 무조건 마지막에 실행됩니다. (로딩 스피너 종료 등에 사용)
console.log("=== [예제 2] 모든 프로세스 종료 ===");
});
}
getUserAndPosts(1);
실무 예제 3: 대규모 데이터 병렬 처리 (Promise.all)
서로 연관이 없는 여러 개의 API를 호출할 때, 순차적으로 호출하면 시간이 오래 걸립니다. Promise.all을 사용하면 비동기 작업들을 동시에 병렬로 실행하여 응답 속도를 극대화할 수 있습니다.
// 예제 3: 여러 API를 동시에 호출하여 화면 렌더링 속도 개선
function fetchMultipleData() {
const p1 = fetch('https://jsonplaceholder.typicode.com/users/1').then(res => res.json());
const p2 = fetch('https://jsonplaceholder.typicode.com/posts/1').then(res => res.json());
const p3 = fetch('https://jsonplaceholder.typicode.com/todos/1').then(res => res.json());
console.log("병렬 데이터 요청 시작...");
const startTime = Date.now();
// 배열로 전달된 모든 Promise가 성공할 때까지 기다립니다.
Promise.all([p1, p2, p3])
.then(([userData, postData, todoData]) => {
const endTime = Date.now();
console.log(`=== [예제 3] 병렬 처리 완료 (소요시간: ${endTime - startTime}ms) ===`);
console.log("사용자:", userData.name);
console.log("게시물:", postData.title);
console.log("할일:", todoData.title);
})
.catch(error => {
// 단 하나라도 실패하면 전체가 즉시 Rejected 상태가 됩니다.
console.error("병렬 처리 중 하나 이상의 요청이 실패했습니다.", error);
});
}
fetchMultipleData();
실무 예제 4: API 요청 타임아웃 구현 (Promise.race)
서버가 응답하지 않아 브라우저가 무한정 대기하는 것을 막기 위해 타임아웃(Timeout) 기능을 구현할 수 있습니다. Promise.race는 배열에 담긴 Promise 중 가장 먼저 상태가 변경된(Fulfilled 혹은 Rejected) 것의 결과를 반환합니다.
// 예제 4: fetch API에 3초 타임아웃 제한 걸기
function fetchWithTimeout(url, timeoutMs) {
const fetchPromise = fetch(url).then(res => res.json());
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`${timeoutMs}ms 초과: 응답 시간 초과로 요청을 취소합니다.`));
}, timeoutMs);
});
// fetch와 timeout 중 먼저 끝나는 것을 선택합니다.
return Promise.race([fetchPromise, timeoutPromise]);
}
fetchWithTimeout('https://jsonplaceholder.typicode.com/photos', 3000)
.then(data => {
console.log("=== [예제 4] 제한 시간 내에 데이터를 성공적으로 받아왔습니다. ===");
console.log(`데이터 개수: ${data.length}`);
})
.catch(error => {
console.error("=== [예제 4] 타임아웃 발생 ===", error.message);
});
실무 예제 5: 레거시 콜백 API의 프로미스화 (Promisification)
오래된 라이브러리나 setTimeout처럼 콜백 기반으로 동작하는 함수들을 최신 비동기 문법과 섞어 쓰기 위해 프로미스로 래핑(Wrapping)하는 실무 기술입니다.
// 예제 5: setTimeout을 Promise 기반으로 감싸기 (delay 함수 생성)
function delay(ms) {
// new Promise를 생성하며 내부에 콜백 로직을 작성합니다.
return new Promise((resolve) => {
setTimeout(() => {
resolve(`${ms}ms 대기 완료`);
}, ms);
});
}
console.log("타이머 시작...");
delay(2000)
.then(message => {
console.log(`=== [예제 5] ${message} ===`);
return delay(1000); // 연속 체이닝 가능
})
.then(message => {
console.log(`=== [예제 5] 추가 ${message} ===`);
});
실무 예제 6: 일부 실패를 허용하는 안정적인 병렬 처리 (Promise.allSettled)
여러 개의 이미지를 로드할 때 한 장의 이미지가 깨졌다고 해서 전체 로딩을 멈출 수는 없습니다. ES2020에 도입된 Promise.allSettled는 실패 여부와 상관없이 모든 Promise가 끝날 때까지 기다린 후 각 성공/실패 상태를 배열로 반환합니다.
// 예제 6: 성공과 실패가 섞여있는 다중 요청 처리
const request1 = Promise.resolve("CDN 1: 이미지 로드 성공");
const request2 = Promise.reject(new Error("CDN 2: 서버 점검 중"));
const request3 = Promise.resolve("CDN 3: 이미지 로드 성공");
Promise.allSettled([request1, request2, request3])
.then(results => {
console.log("=== [예제 6] 모든 요청 완료 (allSettled) ===");
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`[요청 ${index + 1} 성공]: ${result.value}`);
} else {
console.error(`[요청 ${index + 1} 실패]: ${result.reason.message} - 대체 이미지를 출력합니다.`);
}
});
});
실무 예제 7: 에러 복구 및 대체 값 제공 (Fallback mechanism)
.catch() 블록 내부에서 단순히 에러를 로그로 찍고 끝내는 것이 아니라, 캐시된 데이터나 기본값(Default Value)을 다시 반환함으로써 애플리케이션이 멈추지 않고 유연하게 동작하도록 만드는 고급 기법입니다.
// 예제 7: 메인 서버 통신 실패 시 로컬 임시 데이터를 반환하는 복구 전략
function fetchImportantConfig() {
// 의도적으로 존재하지 않는 URL을 호출하여 에러를 유발합니다.
return fetch('https://invalid-url-for-test.com/config')
.then(res => res.json())
.catch(error => {
console.warn("메인 서버 접속 실패. 폴백(Fallback) 캐시 데이터를 사용합니다.");
// catch 내부에서 정상적인 객체를 반환하여 다음 then이 정상 실행되도록 복구합니다.
return {
theme: "dark",
version: "1.0.0",
source: "local-cache"
};
});
}
fetchImportantConfig()
.then(config => {
console.log("=== [예제 7] 설정 파일 로드 완료 ===");
console.log(`적용된 테마: ${config.theme}, 데이터 출처: ${config.source}`);
// 에러가 났음에도 불구하고 안전하게 프로그램이 흘러갑니다.
});
6. 심화: 마이크로태스크 큐(Microtask Queue)와 이벤트 루프
프로미스를 진정으로 마스터하기 위해서는 자바스크립트 엔진 내부의 이벤트 루프(Event Loop) 동작 방식을 이해해야 합니다. 프로미스의 .then()이나 .catch()의 콜백 함수는 일반적인 비동기 콜백(예: setTimeout)과 다른 큐에 적재됩니다.
자바스크립트 엔진에는 두 가지 큐가 존재합니다.
- 매크로태스크 큐 (Macrotask Queue):
setTimeout,setInterval, UI 렌더링 등의 콜백이 들어갑니다. - 마이크로태스크 큐 (Microtask Queue): Promise의 후속 처리 메서드,
MutationObserver등이 들어갑니다.
중요한 사실은 이벤트 루프가 태스크를 가져올 때, 항상 마이크로태스크 큐를 매크로태스크 큐보다 우선순위로 두고 완전히 비운다는 것입니다. 따라서 동일한 시간에 setTimeout과 Promise가 동시에 완료되더라도, Promise의 .then()이 무조건 먼저 실행됩니다. 이러한 동작 원리를 숙지해야 실무에서 복잡한 비동기 경합 조건(Race Condition) 버그를 방지할 수 있습니다.
7. 결론
초창기 자바스크립트는 척박한 비동기 환경 속에서 콜백 함수에만 의존해야 했습니다. 그러나 프로미스(Promise)의 탄생으로 개발자들은 비동기 작업을 일관된 인터페이스를 가진 '값(Value)'으로 다룰 수 있게 되었고, 에러 처리와 흐름 제어를 획기적으로 개선할 수 있었습니다. 현대의 웹 개발, 특히 SPA(Single Page Application) 환경인 React, Vue 등에서 프로미스에 대한 깊은 이해는 선택이 아닌 필수입니다. 더 나아가 ES8에 도입된 async/await 역시 내부적으로는 프로미스를 기반으로 동작하는 문법적 설탕(Syntactic Sugar)일 뿐입니다. 따라서 겉보기 문법에 얽매이기보다, 본 문서에서 다룬 프로미스의 동작 원리와 상태 모델을 완벽히 이해하는 것이 핵심적인 개발 역량을 기르는 지름길이 될 것입니다.
참고 문헌 및 출처:
- MDN Web Docs
- ECMAScript 2015 Language Specification (ES6): Promise Objects
- "You Don't Know JS: Async & Performance" - Kyle Simpson 지음