
JavaScript 에서 비동기 처리는 중요한 주제입니다. 초기에는 비동기 작업을 처리하기 위해 콜백 함수가 주로 사용 되었습니다. 콜백 함수는 특정 작업이 완료된 후 실행할 동작을 정의하는 함수로, 간단한 비동기 작업에서는 유용하게 사용할 수 있었습니다.
콜백 함수에 대한 자세한 내용은 여기서 확인 할 수 있습니다.
그러나 여러 개의 비동기 작업을 순차적으로 실행하거나, 서로 의존적인 작업을 처리해야 하는 경우, 콜백 함수가 중첩되어 코드의 깊이가 깊어지는 콜백 헬(Callback Hell) 문제가 발생했습니다.
콜백 헬은 아래 그림처럼, 코드의 구조가 점점 복잡해지며 가독성을 크게 떨어뜨리는 현상을 말합니다.
이러한 중첩 구조는 코드의 흐름을 파악하기 어렵게 만들고, 각 콜백 함수마다 별도로 에러 처리를 작성해야 하기 때문에, 에러를 추적하기 어렵고 유지보수가 힘들다는 단점이 있습니다.
이 문제를 해결하기 위해 JavaScript 에서는 Promise 가 도입되었습니다.
Promise는 JavaScript 에서 비동기 처리를 관리하기 위해 사용되는 객체로, 그 이름처럼 비동기 작업의 결과를 제공하겠다는 "약속"을 의미합니다.
Promise 객체를 생성하려면 new 키워드와 Promise 생성자 함수를 사용하여 생성합니다.
이때 Promise 생성자 안에 두 개의 파라미터를 가진 콜벡 함수를 인자로 받게 됩니다.
첫번째 파라미터는 작업이 성공 했을때 성공을 알려주는 (resolve) 객체이며,
두번째 파라미터는 작업이 실패 했을때 실패를 알려주는 (reject) 객체입니다.
마지막으로 콜백 함수는 excutor 라고 부르며, 비동기 작업과 성공 또는 실패시 처리를 정의하는 역할을 합니다.
아래 코드를 보시죠
const PromiseA = new Promise((resolve, reject)) => {
// 비동기 작업을 정의
const data = fetch("url");
if(data){
resolve(data); // 요청이 성공했을 경우
}else{
reject("비동기 작업 실패"); // 요청이 실패했을 경우
}
})
위 코드에서 resolve 는 작업이 성공했을 때 결과 값을 전달하며 호출되고, reject 는 작업이 실패 했을때 에러 메세지를 호출하게 됩니다.
이렇게 만들어진 Promise 객체는 어떻게 사용할까요?
생성된 Promise 객체는 then(), catch(), finally() 등의 프로미스 핸들러를 통해 작업 결과를 처리 할 수있습니다.
아래 코드를 보시죠
PromiseA
.then((value) => { // 성공시 실행되는 코드
console.log("Data : ", value);
})
.catch((error) => { // 실패시 실행되는 코드
console.log("Error : ",error);
})
.finally(()=> { // 성공 여부와 상관 없이 실행되는 코드
});
비동기 작업이 성공하면 resolve(data) 가 호출되며, then()으로 이어져 콜백 함수에서 성공에 대한 추가 로직을 처리할 수 있습니다. 이때 resolve() 함수에 전달된 값은 then() 콜백 함수의 인자로 전달되어, 함수 내에서 처리 할 수 있습니다.
반대로 비동기 작업이 실패하면 reject(error)가 호출되며, catch()로 이어져 콜백 함수에서 실패에 대한 추가 로직을 처리합니다. reject() 함수에 전달된 값은 catch() 콜백 함수의 인자로 전달되어, 에러를 처리하거나 로그를 남길 수 있습니다.
마지막으로, 작업의 성공 여부와 관계없이 항상 실행되는 finally() 메서드가 호출됩니다.
프로미스 체이닝이란, 프로미스 핸들러를 연달아 연결하는 것을 말합니다.
아래 코드를 보시죠
function doSomething() {
return new Promise((resolve, reject) => {
resolve(100)
});
}
doSomething()
.then((value1) => { // 100
const data1 = value1 + 50;
return data1
})
.then((value2) => { // 150
const data2 = value2 + 50;
return data2
})
.then((value3) => { // 200
const data3 = value3 + 50;
return data3
})
.then((value4) => { // 250
console.log(value4); // 250 출력
})
doSomething() 함수를 호출하여 프로미스를 생성하고, then 메소드를 연달아 연결하여, 처리하는 것을 볼수 있습니다. 각 프로미스 핸들러들은 이전 프로미스의 값에 50을 더한 값을 반환하고, 마지막에 최종적으로 250으로 출력하게 됩니다.
이런식으로 체이닝이 가능한 이유는 then 핸들러에서 값을 리턴하면, 그 반환값은 자동으로 프로미스 객체로 감싸져 반환되기 때문입니다.
그럼 프로미스 체이닝 중간에 오류가 발생한다면 어떻게 처리해야할까요?
아래 코드를 보시죠
function doSomething(arg) {
return new Promise((resolve, reject) => {
resolve(arg)
});
}
doSomething('100A')
.then((value1) => {
const data1 = value1 + 50; // 숫자에 문자를 연산
if (isNaN(data1)) // data 의 값이 숫자가 아님
throw new Error('값이 넘버가 아닙니다')
return data1
})
.then((value2) => {
const data2 = value2 + 50;
return data2
})
.catch((err) => { // 에러 호출로 넘어감
console.error(err);
})
data 값이 숫자가 아니라 에러가 발생하는 것을 확인하실 수 있습니다.
이렇게 체이닝 중간에 오류가 발생하면, 제어권을 가장 가까운 catch 핸들러로 넘깁니다.
이후에 then 핸들러가 체이닝 되어있다면, 에러가 처리되고 가장 가까운 then 핸들러로 제어권이 넘어가 실행이 이어집니다.
new Promise((resolve, reject) => {
throw new Error("에러 발생!");
})
.catch(function(error) {
console.log("에러가 잘 처리되었습니다. 정상적으로 실행이 이어집니다.");
})
.then(() => {
console.log("다음 핸들러가 실행됩니다.")
})
.then(() => {
console.log("다음 핸들러가 또 실행됩니다.")
});
Promise 객체를 변수에 직접 할당해서 쓰는 방법도 있지만, 일반적으로 함수에 감싸서 사용하는 방식을 많이 사용합니다.
아래 코드를 보시죠
function PromiseA() {
return new Promise((resolve, reject) =>{
const data = fetch("url");
if(data){
resolve(data); // 요청이 성공했을 경우
}else{
reject("비동기 작업 실패"); // 요청이 실패했을 경우
}
}
PromiseA()
.then((value) => { // 성공시 실행되는 코드
console.log("Data : ", value);
})
.catch((error) => { // 실패시 실행되는 코드
console.log("Error : ",error);
})
.finally(()=> { // 성공 여부와 상관 없이 실행되는 코드
});
이렇게 함수를 통해 Promise 객체를 생성하면, 함수 호출을 통해 생성된 프로미스 객체를 반환 받아 사용할 수 있습니다.
이렇게 프로미스 객체를 함수로 만드는 이유는 다음 3가지 정도가 있습니다.
1. 재사용성 : 프로미스 객체가 필요할때 마다 호출하여 사용함으로써, 반복되는 비동기 작업을 효율적으로 처리 할 수 있습니다.
2. 가독성 : 프로미스 객체를 함수로 감싸면 비동기 작업의 정의와 사용을 분리 할 수 있어, 코드의 구조가 명확해져 가독성을 높일 수 있습니다.
3. 확장성 : 함수로 작성된 프로미스는 파라미터를 통해 동적으로 비동기 작업을 수행할 수 있습니다. 또한 여러 프로미스를 함수 형태로 연결하거나 조합하여 복잡한 비동기 로직을 구현 할 수 있습니다.
이러한 점 때문에 실무에서 프로미스 객체를 사용할 일이 있다면 함수로 감싸 사용합니다.
아래 코드를 보시죠
fetch("url")
.then((response) => response.json()) // 응답 객체에서 Json 데이터를 추출
.then((data) => console.log(data)); // Json 객체를 콘솔에 출력
이러한 방식은 특히 fetch() 메서드와 같은 네트워크 요청에서 자주 사용됩니다.
fetch() 메서드는 내부적으로 프로미스 객체를 반환하며, 이를 then()과 catch()로 처리하여 데이터를 가져오고 에러를 관리합니다.
Promise는 비동기 작업의 진행 상황을 표현하기 위해 pending, fulfilled, rejected 세 가지 상태(state)를 가집니다. 간단히 설명하자면, 프로미스가 비동기 작업을 처리하는 과정이라고 생각하시면 됩니다.
- Pending(대기) : 작업이 완료 되지 않은 상태 (처리 진행중)
- Fulfilled(이행) : 성공적으로 작업이 완료된 상태
- Rejected(거부) : 작업이 실패가 끝난 상태
대기(Pending) 상태는 말 그대로 아직 비동기 처리 로직이 완료되지 않은 상태입니다.
아래 코드를 보시죠
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("처리 완료")
}, 5000)
});
console.log(promise); // Pending (대기) 상태
프로미스 객체를 생성하고 생성한 프로미스 객체를 콘솔에 찍어 보면 프로미스 상태가 "pending" 으로 출력 되는 것을 보실 수 있습니다.

이행(Fulfilled) 상태는 말 그대로 비동기 로직이 성공적으로 완료된 상태 입니다.

콘솔을 다시 찍어보면, 위와 같이 이행(Fulfilled) 상태로 변한 것을 확인할 수 있습니다.
즉, 5초가 지나 resolve() 가 실행 되면서 상태가 대기(pending) 에서 이행(fulfilled)로 변경된 것입니다.

위에서 비동기 작업이 성공적으로 완료되어 이행(fulfiled) 상태가 됬다면, 반대로 비동기 작업이 실패하면 실패(rejected) 상태가 됩니다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
reject("처리 실패")
}, 5000)
});
console.log(promise);
5초 후에 콘솔을 다시 찍어보면, 아래와 같이 대기(pending) 상태에서, 실패(rejected)로 변경된 것을 확인 할 수 있습니다.

Promise는 생성자 함수 외에도 다양한 정적 메서드를 제공하여, 비동기 작업을 더욱 간편하게 처리할 수 있는 방법을 제공합니다. 정적 메서드는 특정 객체를 생성하지 않고도 바로 호출할 수 있어, 효율적이고 직관적으로 비동기 작업을 구현할 수 있습니다.
Promise.resolve() 는 프로미스 객체와 상관 없는 값을 이행(fulfilled) 상태의 프로미스 객체로 감싸 반환하는 메서드입니다.
아래 코드를 보시죠
// 프로미스 객체와 전혀 연관없는 함수
function getRandomNumber() {
const num = Math.floor(Math.random() * 10); // 0 ~ 9 사이의 정수
return num;
}
// Promise.resolve() 를 사용하여 프로미스 객체를 반환하는 함수
function getPromiseNumber() {
const num = getRandomNumber(); // 일반 값
return Promise.resolve(num); // 프로미스 객체
}
// 핸들러를 이용하여 프로미스 객체의 값을 처리하는 함수
getPromiseNumber()
.then((value) => {
console.log(`랜덤 숫자: ${value}`);
})
.catch((error) => {
console.error(error);
});
위와 마찬가지로 프로미스 객체와 전혀 상관 없는 값을 실패(rejected) 상태의 프로미스 객체로 감싸 반환하는 메서드 입니다.
// 주어진 사유로 거부되는 프로미스 생성
const p = Promise.reject(new Error('error'));
// 거부 사유를 출력
p.catch(error => console.error(error)); // Error: error
여러개의 프로미스 요소들을 한꺼번에 비동기 작업을 처리해야 할때 사용하는 정적 메소드입니다.
모든 프로미스 비동기 처리가 이행(fulfilled) 될때까지 기다려서, 모든 프로미스가 완료되면 그때 then 핸들러가 실행되는 형태로 보면 됩니다.
// 1. 서버 요청 API 프로미스 객체 생성 (fetch)
const api_1 = fetch("https://jsonplaceholder.typicode.com/users");
const api_2 = fetch("https://jsonplaceholder.typicode.com/users");
const api_3 = fetch("https://jsonplaceholder.typicode.com/users");
// 2. 프로미스 객체들을 묶어 배열로 구성
const promises = [api_1, api_2, api_3];
// 3. Promise.all() 메서드 인자로 프로미스 배열을 넣어, 모든 프로미스가 이행될 때까지 기다리고, 결과값을 출력
Promise.all(promises)
.then((results) => {
// results는 이행된 프로미스들의 값들을 담은 배열.
// results의 순서는 promises의 순서와 일치.
console.log(results); // [users1, users2, users3]
})
.catch((error) => {
// 어느 하나라도 프로미스가 거부되면 오류를 출력
console.error(error);
});
대표적으로 위의 코드와 같이 여러 개의 API 요청을 보내고 모든 응답을 받아야 하는 경우가 있습니다.
Promise.all() 메서드의 업그레이드 버전으로 주어진 모든 프로미스가 처리되면 모든 프로미스 각각의 상태와 값을 모아놓은 배열을 반환하는 메소드 입니다.
// 1초 후에 1을 반환하는 프로미스
const p1 = new Promise(resolve => setTimeout(() => resolve(1), 1000));
// 2초 후에 에러를 발생시키는 프로미스
const p2 = new Promise((resolve, reject) => setTimeout(() => reject(new Error('error')), 2000));
// 3초 후에 3을 반환하는 프로미스
const p3 = new Promise(resolve => setTimeout(() => resolve(3), 3000));
// 세 개의 프로미스의 상태와 값 또는 사유를 출력
Promise.allSettled([p1, p2, p3])
.then(result => console.log(result));

Promise.all() 메서드의 반대 버전으로, Promise.all() 이 주어진 모든 프로미스가 모두 완료해야만 결과를 도출한다면, Promise.any() 는 주어진 모든 프로미스 중 하나라도 완료되면 바로 반환하는 정적 메서드입니다.
const promise1 = new Promise((resolve, reject) => {
setTimeout(() => {
reject("promise1 failed");
}, 3000);
});
const promise2 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("promise2 succeeded");
}, 2000);
});
const promise3 = new Promise((resolve, reject) => {
setTimeout(() => {
reject("promise3 failed");
}, 1000);
});
const promise4 = new Promise((resolve, reject) => {
setTimeout(() => {
reject("promise4 failed");
}, 4000);
});
// promise1, promise2, promise3은 각각 3초, 2초, 1초 후에 거부되거나 이행
Promise.any([promise1, promise2, promise3])
.then((value) => {
console.log(value); // "promise2 succeeded"
})
.catch((error) => {
console.error(error);
});
// 모두 실패하는 경우
Promise.any([promise1, promise3, promise4])
.then((value) => {
console.log(value);
})
.catch((error) => {
console.error(error); // AggregateError: All promises were rejected
console.error(error.errors); // ["promise3 failed", "promise1 failed", "promise4 failed"]
});
위 코드에서 Promise.any() 메서드의 결과로 promise2 의 처리가 가장 먼저 처리되고, 첫번쨰로 이행(fulfilled) 된 프로미스만을 취급하기 때문에 나머지 promise1 과 promise2 는 거부되는 것을 보실 수있습니다. 그리고 요청된 모든 프로미스가 거부(rejected) 되면, AggregateError 거부 프로미스를 반환합니다.
Promise.race() 는 Promise.any() 와 같이 여러 개의 프로미스 중 가장 먼저 처리된 프로미스의 결과값을 반환하는 정적 메소드입니다.
차이점으로는 race() 메서드는 이행(Fufilled), 실패(rejected) 여부 상관없이 무조건 처리가 끝난 프로미스 결과를 반환하는 것에 있습니다.
https://ko.javascript.info/promise-basics
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise
https://joshua1988.github.io/web-development/javascript/promise-for-beginners/#%EC%98%81%EC%83%81%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EA%B8%B0
https://inpa.tistory.com/entry/JS-%F0%9F%93%9A-%EB%B9%84%EB%8F%99%EA%B8%B0%EC%B2%98%EB%A6%AC-Promise