서론
Promise가 뭔지 몰랐던 시절, 어떻게든 그 값을 꺼내보겠다고 머리를 굴려봤던 기억이 납니다. 프로미스를 제대로 이해하지 못하고 어떻게든 결과값만 꺼내고 싶어서 지금 생각해보면 정말 말도 안되는 방식을 썼었습니다.

이런 식으로 값을 꺼내왔는데, 다행히 스벨트 환경이었기 때문에 화면에 리턴이 정상적으로 표시되었습니다. 그렇다면 이런 결과값은 어떻게 써야 했던 걸까요?
사용 목적
자바스크립트에는 “비동기” 메서드가 존재합니다. 동기 메서드의 로직은 자바스크립트의 "단일 스레드(single-thread)"에서 작성한 순서대로 실행되지만 비동기 로직은 다 끝나지 않아도 기다리지 않고 다음으로 넘어갑니다. 그럼 자바스크립트에는 왜 비동기 메서드가 있는걸까요?
여기가 카페고, 커피를 30분 뒤에 받겠다는 사람이 있습니다.
음료제조("제나의 커피"); // 제나는 주문 후 커피를 받아갔습니다
//예약손님은 아이스티를 30분 뒤에 받고싶어합니다.
setTimeout(()=> 음료제조("예약손님의 아이스티"), 30분);
음료제조("급한손님의 찬물"); //급한 손님은 지금 찬물을 받고 싶어합니다.
setTimeout은 흔히 쓰는 비동기 메서드 중 하나입니다. 만약 setTimeout이 비동기 메서드가 아니라면 급한 손님의 찬물은 예약손님의 아이스티가 나올때까지 30분 넘는 시간을 기다려야 합니다.
하지만 setTimeout이 비동기 함수이기 때문에, 예약손님의 아이스티는 보류하고 급한 손님의 찬물을 먼저 내놓습니다. 그리고 30분이 지나면 예약손님의 아이스티를 만듭니다.
JS코드가 실행되는 순서
그럼 예약손님의 아이스티는 어디에 저장되어 있다가 제조하는걸까요?
주문을 처리하는 직원의 머릿속을 ‘이벤트 루프’라고 합니다. 이벤트 루프는 어떤 음료(코드)를 먼저 제조할지 결정하고 다음 제조할 음료의 순서를 결정합니다. 이벤트 루프가 어떤 방식으로 주문 우선 순위를 판단하는지는 잠시 뒤에 다시 설명하겠습니다.
지금 제조중인 음료와 다음에 처리할 주문은 ‘콜스택(Call Stack = 호출 스택)’ 이라는 보드에 적혀 있습니다.
(다만 콜스택은 LIFO (Last In First) 구조기 때문에 나중에 붙인 영수증(=실행할 함수 호출)을 먼저 처리합니다. 현실에서 이러면 먼저 주문한 손님들이 분노하겠죠)
나중에 음료를 받을 손님, 혹은 아직 무슨 음료를 주문할지 모르는 손님들은 마이크로 태스크 큐와 매크로 태스크 큐라는 대기 줄에서 기다리게 됩니다. 매크로 태스크 큐는 일반 손님을 위한 대기 줄이고, 마이크로 태스크 큐는 vip고객을 위한 줄입니다.

이벤트 루프는 아래 그림처럼 실행 우선순위를 결정합니다.


콜백 헬
비동기 함수들이 이렇게 각각의 대기 줄에서 처리되다 보면, “언제 이 작업이 끝났는지”, “끝난 뒤에 어떤 작업을 이어서 해야 하는지”를 원하는 대로 관리하는 게 점점 어려워집니다. 특히 서버에서 데이터를 받아오는 것처럼 완료 시간을 예측할 수 없는 작업이라면 더 혼란스러워집니다.
또 비동기 함수는 처리 결과값을 바로 반환하지 않고, 상위 스코프의 변수에 할당할 수도 없습니다. 받아온 결과는 태스크 큐에서 대기하다가 콜스택의 모든 내용이 처리된 이후에 호출될테니까요. 이러니 저러니 머리를 굴려봐도 상위 스코프의 변수가 사용되는 시점이 값 할당보다 나중일 수는 없습니다. 따라서 모든 처리 결과에 대한 작업을 비동기 함수 내부에서 수행해야 합니다.
이런 순서 문제를 해결하다가 콜백 헬이 발생합니다.
예시를 들기 위해 햄버거를 만들기 위해 각각의 재료를 외부에서 받아오는 `request(재료이름, 정상_후속처리, 에러_후속처리)`가 비동기 함수라고 가정합니다.
request("빵", (빵1) => {
request("패티", (패티1) => {
request("빵", (빵2) => {
console.log("햄버거 완성!");
}, (err3) => {
console.log("빵 에러 발생!", err3);
});
}, (err2) => {
console.log("패티 에러 발생!", err2);
});
}, (err1) => {
console.log("빵 에러 발생!", err1);
});
징그럽네요. 지금은 빵 사이에 패티만 있지만 재료가 추가된다면, 혹은 받아온 재료를 굽거나 자르기라도 한다면 지금보다 생김새는 더 괴이해지고 지금 읽고 있는 request의 코드가 도대체 어떤 리턴 안에 속해있는지도 캐치하기 어려워집니다. 여기서 Promise가 등장합니다.
Promise 생성법 / 구조
Promise는 비동기 작업의 완료/실패 상태를 명확하게 관리하고 그 이후의 동작을 체계적으로 연결하기 위해 만들어진 도구입니다.
Promise는 아래처럼 선언할 수 있습니다.
const 새로운_프로미스 = new Promise((resolve, reject) => { // resolve, reject 대신 다른 이름을 써도 되지만 굳이..굳이..입니다
if( 성공 ) {
resolve("성공!이라고 하거나 완료된 값을 리턴");
} else { //비동기 작업이 실패한 경우
reject("실패! 라고 하거나 에러 메시지를 리턴");
};
});
//프로미스 사용
새로운_프로미스
.then( 성공값 => console.log(성공값))
.catch( 에러메시지 => console.log(에러메시지)) ;
생성한 Promise는 아래와 같이 구성되어 있습니다.
Promise
├── PromiseStatus : 비동기 처리가 어떻게 진행되고 있는지
│ ├── pending : 아직 결과가 나오지 않아 기다리는 상태
│ └── settled : 비동기 메서드가 완료
│ ├── fulfilled : 비동기 메서드가 잘 완료되었습니다. → resolve 함수를 호출
│ └── rejected : 비동기 메서드가 실패로 끝났습니다. → reject함수를 호출
└── PromiseValue : 비동기 처리 결과 정보. 상태가 rejected(실패)라면 에러 메시지가 담깁니다.
이런 구조 덕분에 `request("요청재료", 정상_후속처리, 에러_후속처리)` 형태에서 `request("요청재료")` 로 간단하게 호출하고 결과도 아래처럼 읽기 쉽게 작성할 수 있습니다.
request("빵")
.then(빵1 => 빵1 + request("패티") )
.then(빵1패티1 => 빵1패티1 + request("빵") )
.then( 빵1패티1빵1 => console.log("햄버거완성~!") )
.catch(err => console.log("에러 발생!");)
여기까지만 해도 아까의 햄버거 코드보다 훨씬 깔끔합니다. 각각의 request함수 중 에러가 발생한다면 reject로 리턴해줄 테고, 마지막의 catch()에서 에러를 처리해 줄 수 있습니다.
그런데 각각의 재료요청 외에도 처리해야 할 코드가 많다면 결국 `.then.catch` 로직은 아래로 길어질거고, `.then`이 반복되면서 Promise가 결과에 따라 분기로 나뉘어진다면 들여쓰기가 많아지고 읽기 더 힘들어질겁니다.
이때 async, await을 활용하면 더 깔끔하게 코드를 작성할 수 있습니다. 코드가 늘어나도 보기 깔끔한지 확인하기 위해 내용을 좀 더 세분화해서 써보겠습니다.
async function 햄버거만들기() {
try {
const 빵1 = await request("빵"); // 빵1에 값 할당
const 패티 = await request("패티"); // 패티 원재료
const 구운패티 = await 굽기(패티); // 패티 굽기
const 빵2 = await request("빵");
return 빵1 + 구운패티 + 빵2; // 햄버거 완성
} catch (err) {
console.error("재료 수급 중 에러 발생:", err);
return "대체메시지";
}
}
각각의 비동기 메서드가 연달아 작성되지 않기 때문에 명확하게 구분이 되고 깔끔한 형태입니다.
활용편 _ 응용 메서드
이런 Promise를 더 다양한 방법으로 사용할 수 있는 all, allSettled, race, any를 소개합니다.
all
처리해야 할 비동기 처리를 모두 병렬로 처리하고, 모든 요청이 성공할 때까지 기다립니다.
다만 만약 하나라도 rejected 상태가 되면 나머지 요청의 결과는 기다리지 않고 종료합니다. (중간에 rejected된 결과가 있더라도 모든 결과를 기다려야 한다면 allSettled를 써야합니다)
- input : Promise 배열
- output
- 모두 성공했다면 => `[ㅁ결과, ㄴ결과, ㅇ결과]`
- 하나라도 rejected되었다면 => catch에서 처리할 에러(rejected된 프로미스의 결과)
// promise가 ㅁ, ㄴ, ㅇ 세개가 있다고 하고, 이 셋이 모두 완료되면 진행하고 싶은 경우
const all의_결과 = Promise.all([ㅁ(), ㄴ(), ㅇ()]);
rejected가 있는 상황까지 가정해서 제대로 코드를 작성한다면 아래처럼 쓸 수 있습니다.
Promise.all([ㅁ(), ㄴ(), ㅇ()])
.then(result => {
console.log("성공한_값들의_배열:", result);
})
.catch(error => {
console.log("reject된 Promise 반환:", error.message);
});
allSettled
모든 Promise가 처리 완료(settled) 될 때까지 기다렸다가 처리 결과값을 상태와 함께 반환합니다.
`Promise.allSettled([ㅁ(), ㄴ(), ㅇ()])`처럼 씁니다.
- input : Promise배열
- output : 아래와 같은 배열
[
{ status : "fulfilled" , value : 프로미스결과}, // 성공한 Promise
{ status : "rejected", reason : 에러메시지 } // 실패한 Promise
]
결과 배열을 status로 filter하면 성공한 것들과 실패한 것들의 후속 처리가 가능합니다.
race
가장 먼저 완료한 결과가 하나라도 있으면 종료하고 싶은 경우 사용합니다. 그 결과가 실패든 성공이든 무조건 먼저 끝나면 됩니다. 가장 먼저 “성공”한 Promise를 리턴받고 싶다면 any를 사용하면 됩니다.
- input : 프로미스 배열
- output : 가장 먼저 완료된 Promise
Promise.race([
fetchA(), // 3초 후 성공
fetchB(), // 1초 후 실패
])
// 1초 후 → 실패(B)의 리턴된 Promise
any
가장 먼저 성공(fulfilled)한 프로미스의 처리 결과를 resolve하는 새로운 프로미스를 반환합니다. 모든 프로미스가 실패해야 reject 프로미스를 반환합니다.
- input : 프로미스 배열
- output : Promise
- resolve : 가장 먼저 성공한 프로미스의 결과
- rejected : 모든 프로미스가 실패한 경우
Promise.any([
fetchA(), // 3초 후 성공
fetchB(), // 1초 후 실패
])
// 3초 후 → 성공(A)의 결과를 담은 Promise
주의사항
Promise를 쓰면서 몇가지 주의해야 할 점이 있습니다. 포스팅 예제 코드를 작성하면서도 할 뻔했던 실수이니 반드시 알아두세요.
1. new Promise를 async로 생성 금지
.then 내부에서 async/await를 사용하는 것은 전혀 문제가 없지만, 아래 코드처럼 Promise를 만들 때 executor(실행자) 함수에 async를 붙이는 것은 피해야 합니다.
new Promise(async (resolve, reject) => { // ❌
const result = await something(); // Promise의 executor가 판단하기 어려움
resolve(result);
});
Promise의 executor는 다음과 같은 특징을 가집니다.
- Promise가 생성되는 순간 즉시 동기적으로 실행
- 내부에서 발생한 에러는 자동으로 reject로 전달
async가 붙은 함수도 무조건 Promise를 반환합니다. 따라서 new Promise를 만들기 위해 쓴 async의 Promise는 내부의 await에서 발생한 에러를 캐치할 수 없고, “resolve/reject 타이밍 관리”라는 executor의 핵심 역할이 깨집니다. 따라서 Promise 내부에서 비동기 메서드의 결과를 받아야 한다면 아래처럼 쓰는 것이 적절합니다.
const good = new Promise((resolve, reject) =>{
어떤비동기()
.then(resolve)
.catch(reject)
});
2. try catch 지양
Promise에서 try/catch를 쓰지 않더라도, 에러는 rejected로 알아서 리턴됩니다. 단순히 catch를 rejected로 연결시키기 위함이라면 try catch는 불필요합니다.
3. Promise를 반환하는 API 결과를 다시 Promise로 감싸지 말 것
2번과 유사한 맥락입니다. fetch처럼 이미 Promise를 반환하는 함수를 감싸서 new Promise로 만들면 코드만 늘어날 테니까요.
4. resolve/ reject는 첫 번째 호출만 유효
resolve와 reject는 Promise의 return과도 같습니다. 따라서 여러 번 호출하더라도 처음에 만난 resolve와 reject만이 의미를 가집니다.
비동기 코드를 다루다 보면 처음엔 헷갈리는 부분이 정말 많지만, 개념을 하나씩 정확히 짚고 나면 코드가 훨씬 단순하고 예측 가능해집니다. 특히 Promise와 async/await는 ‘비동기는 복잡하다’는 고정관념을 없애주는 도구이기도 합니다. Promise를 리턴하는 메서드를 처리하는 방법을 알아두고, 비동기 중첩을 적절한 Promise로 처리하면 긴 코드도 훨씬 깔끔하게 익혀두면 훨씬 쉽게 원하는 결과를 만들 수 있을 거예요.
그럼 모두 행복한 비동기 자바스크립트 생활 되세요!

'자바스크립트 > 바닐라 JS' 카테고리의 다른 글
| 재귀랄, 재귀함수가 뭐야? (10) | 2025.06.27 |
|---|---|
| [시간복잡성] indexOf(), hash변환 후 값 추출 (0) | 2024.03.07 |
| [바닐라 js] 주민번호 <->생년월일, 성별 변환 (0) | 2023.01.14 |
| 폼 유효성 검사 - 모듈로 초간단하게 작성 (0) | 2023.01.14 |
| 날짜, 시간 관련 자바스크립트 함수들 (0) | 2023.01.14 |