Promise의 전반적인 개념과 Promise의 상태와 값을 사용할 수 있는 이유에 대해 정리해보았습니다.
Promise는 ES6에서 자바스크립트의 비동기 처리를 위해 도입한 패턴입니다. 전통적인 콜백 패턴의 두 가지 문제점인 “콜백 헬로 인한 가독성 저하”와 “에러 처리의 한계”를 보완하였고, 비동기 처리 시점을 명확하게 표현할 수 있다는 장점을 가집니다.
어떤 식으로 보완이 되었는지 이해하기 위해 먼저 기존 콜백 패턴의 문제점을 살펴보겠습니다.
1. 비동기 처리를 위한 콜백 패턴의 단점
1.1 콜백 헬
1.1.1 콜백 패턴의 특징
비동기 함수란 함수 내부에 비동기로 동작하는 코드를 포함한 함수로, setTimeout은 대표적인 비동기 함수입니다. 콜백함수의 호출이 비동기적으로 동작하기 때문입니다.
비동기적으로 동작한다는 것은 비동기 함수를 호출했을 때, 함수 내부의 비동기로 동작하는 코드가 완료되는 것을 기다리지 않고 종료된다는 것을 의미합니다. 따라서 비동기로 동작하는 코드의 결과를 외부로 반환하거나 상위스코프의 변수에 할당하는 것이 불가능합니다. 아래 예시를 살펴보겠습니다.
let g = 0;
setTimeout(() => { g = 100; }, 0);
console.log(g); // 0
setTimeout 함수를 호출하면 콜백 함수를 호출 스케줄링한 다음, 타이머 id를 반환하고 즉시 종료됩니다. 이후 함수 내부의 콜백 함수가 호출됩니다. 즉, console.log(g)는 g에 100을 할당하는 콜백함수보다 무조건 먼저 호출된다는 것입니다. 그래서 콘솔 출력값은 0이 되는 것입니다. 콘솔이 찍히고 나서 콜백 함수가 실행될테니까요.
여기서 호출 스케줄링이란 콜백 함수를 지정된 시간 이후에 태스크 큐에 추가하는 것을 말하며, 그 전까지 콜백 함수는 이벤트 루프가 제어하는 타이머 시스템(Bacground Timer List)에 위치합니다. 지정된 시간 이후에 이벤트 루프가 타이머 목록에서 해당 콜백을 꺼내서 태스크 큐에 추가하는 식으로 동작합니다. 타이머 id는 타이머 시스템에서 타이머들을 관리할 때 사용되는 식별자로, 태스크 큐에 추가하거나 clearTimeout 함수를 통해 해당 타이머를 삭제할 때 활용됩니다.
1.1.2 실행 컨텍스트로 살펴보기
앞서 설명한 내용을 실행 컨텍스트에 대한 관점을 보충하여 설명해보겠습니다. 비동기 함수인 setTimeout 함수가 호출되면 함수 코드를 평가하는 과정에서 setTimeout 함수의 실행 컨텍스트가 생성되고 콜 스택(실행 컨텍스트 스택)에 푸시됩니다. setTimeout가 종료되면 콜 스택에서 setTimeout 함수의 실행 컨텍스트가 팝되고, console.log가 호출됩니다. 그러면서 console.log의 실행 컨텍스트가 콜스택에 푸시되고, 출력되며 팝됩니다.
따라서 만약 비동기 함수의 결과를 반영하고 싶다면 후속 처리는 아래 코드처럼 비동기 함수 내부에서 수행해야 합니다. 단, 출력되는 순서는 이전에 말했던 것처럼 콜백 함수가 후순위입니다.
let g = 0;
setTimeout(() => {
g = 100;
console.log(g);
}, 0);
console.log(g);
/* 출력값
0
100
*/
그리고 만약 콜백 내부에서 처리해야 하는 로직이 비동기 함수를 다루면서 콜백 패턴을 여러번 써야 한다면 코드의 가독성이 심각하게 나빠지게 되며, 이를 콜백 헬(callback hell)이라 합니다.
const cbHell = () => {
setTimeout(() => {
console.log("first");
setTimeout(() => {
console.log("second");
setTimeout(() => {
console.log("third");
}, 1000);
}, 1000);
}, 1000);
};
cbHell();
1.2 에러 처리 한계
콜백 함수의 단점 중 하나는 에러 처리가 어렵다는 것입니다. 아래 try…catch 문으로 구성된 코드를 실행해보면 1초 후에 에러가 발생하지만 캐치하지 못합니다.
try {
setTimeout(() => {
throw new Error("Error");
}, 1000);
} catch (e) {
console.error("캐치한 에러", e);
}
왜 에러를 캐치하지 못하는지 실행 컨텍스트를 통해 살펴보겠습니다. 에러는 호출자(caller) 방향으로 전달됩니다. 즉, 실행 중인 실행 컨텍스트 이전에 푸시된 실행 컨텍스트의 방향으로 전달됩니다. 그런데 콜백 함수가 실행 중인 실행 컨텍스트일 때, setTimeout 함수는 이미 콜 스택에서 제거된 이후입니다. 그래서 catch 블록에서 캐치되지 않는 것입니다.
2. Promise 패턴을 통한 비동기 처리
2.1 Promise의 생성
Promise는 ES6에서 도입된 표준 빌트인 객체입니다. Promise 생성자 함수를 new 연산자와 함께 호출하면 프로미스(Promise 객체)를 생성합니다. Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수를 인수로 전달받는데 이 콜백 함수는 resolve와 reject 함수를 인수로 전달받습니다.
Promise 생성자 함수는 인수로 전달받은 콜백 함수 내부에서 비동기 처리를 수행합니다. 비동기 처리가 성공하면 콜백 함수가 인수로 전달받은 resolve 함수를 호출하고, 실패하면 reject 함수를 호출합니다.
// 프로미스 생성
const promise = new Promise((resolve, reject) => {
// Promise 함수의 콜백 함수 내부에서 비동기 처리를 수행한다.
if (
/* 비동기 처리 성공 */
) {
resolve("result");
} else {
/* 비동기 처리 실패 */
reject("failure reason");
}
});
2.1.1 프로미스의 상태 정보
프로미스의 상태 | 정보 의미 | 상태 변경 조건 |
pending | 비동기 처리가 아직 수행되지 않은 상태 | 프로미스가 생성된 직후 기본 상태 |
fulfilled | 비동기 처리가 수행된 상태(성공) | resolve 함수 호출 |
rejected | 비동기 처리가 수행된 상태(실패) | reject 함수 호출 |
프로미스는 비동기 처리가 어떻게 진행되고 있는지를 나타내는 상태 정보를 갖습니다. 기본적으로 pending 상태이며, 이후 비동기 처리의 결과에 따라 상태가 변경됩니다. 성공했을 때는 fulfilled, 실패했을 때는 rejected라는 상태로 변경됩니다. pending 상태가 아닌 fulfilled, rejected 상태로 비동기 처리가 수행된 상태를“settled 상태라고 하며, settled 상태가 되면 다시 pending 상태가 될 수 없습니다.
또한 프로미스는 상태와 더불어 처리 결과(result)도 가지고 있습니다. pending 상태의 결과는 undefined, fulfilled 상태의 결과는 value, rejected 상태의 결과는 error를 가집니다.
2.2 프로미스의 후속 메서드
프로미스의 비동기 처리 상태가 변화하면 이에 따른 후속 처리를 해야 합니다. 이를 위해 프로미스는 후속 처리 메서드 3가지 then, catch, finally 를 제공합니다. 변화된 처리 상태에 따라 후속 처리 메서드에 인수로 전달한 콜백함수가 선택적으로 호출됩니다. 이때 후속 처리 메서드의 콜백 함수에 프로미스의 처리 결과가 인수로 전달됩니다. 모든 후속 처리 메서드는 프로미스를 반환하며, 비동기로 동작합니다. 메서드를 하나씩 살펴보겠습니다.
2.2.1 Promise.prototype.then
then 메서드는 두 개의 콜백 함수를 인수로 전달받습니다. 첫 번째 콜백 함수는 비동기 처리가 성공했을 때(fulfilled 상태, resolve 함수 호출) 호출되고, 두 번째 콜백 함수는 비동기 처리가 실패했을 때(rejected 상태, reject 함수가 호출된 상태) 호출됩니다.
then 메서드는 언제나 프로미스를 반환합니다. then 메서드의 콜백 함수가 프로미스를 반환하면 그 프로미스를 그대로 반환하고, 만약 콜백 함수가 프로미스가 아닌 값을 반환하면 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성하여 반환합니다.
// fulfilled
new Promise(resolve => resolve('fulfilled'))
.then(v => console.log(v), e => console.error(e)); // fulfilled
// rejected
new Promise((_, reject) => reject(new Error('rejected')))
.then(v => console.log(v), e => console.error(e)); // Error: rejected
2.2.2 Promise.prototype.catch
catch 메서드는 한 개의 콜백 함수를 인수로 전달받습니다. 이 콜백 함수는 프로미스가 rejected 상태인 경우에만 호출됩니다. catch 메서드는 then(undefined, onRejected)과 동일하게 동작하므로, catch 메서드 역시 언제나 프로미스를 반환합니다.
// rejected
new Promise((_, reject) => reject(new Error('rejected')))
.catch(e => console.log(e)); // Error: rejected
// rejected
new Promise((_, reject) => reject(new Error('rejected')))
.then(undefined, e => console.log(e)); // Error: rejected
2.2.3 Promise.prototype.finally
finally 메서드는 한 개의 콜백 함수를 인수로 전달받습니다. finally 메서드의 콜백 함수는 성공(fulfilled) 또는 실패(rejected)와 상관없이 무조건 한 번 호출됩니다. 그래서 프로미스의 상태와 무관하게 처리할 내용이 있을 때 유용합니다. 그리고 역시나 언제나 프로미스를 반환합니다.
new Promise(() => {})
.finally(() => console.log('finally')); // finally
2.3 프로미스의 에러 처리
에러 처리는 then 메서드의 두 번째 콜백 함수나 catch 메서드를 사용해서 처리합니다. 그러나 then을 사용할 경우, 첫 번째 콜백 함수에서 발생한 에러를 캐치하지 못한다는 점과 코드가 복잡해져서 가독성이 떨어지므로 catch를 통한 에러 처리가 권장됩니다.
new Promise((res, rej) => {
const random = Math.random();
if (random < 0.5) rej("Failure");
res("Success");
})
.then((res) => console.log("Resolved:", res))
.catch((err) => console.error("Rejected:", err));
2.4 프로미스 체이닝
then, catch, finally 후속 처리 메서드는 항상 프로미스를 반환하므로 연속적으로 호출이 가능하며, 이를 프로미스 체이닝(promise chaining)이라고 합니다. 만약 후속 처리 메서드의 콜백 함수가 프로미스가 아닌 값을 반환하더라도 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성해서 반환합니다.
2.5 프로미스의 정적 메서드
Promise는 주로 생성자 함수로 사용되지만 객체로써 5가지 정적 메서드를 가지고 있습니다.
2.5.1 Promise.resolve/ Promise.reject
Promise.resolve와 Promise.reject 메서드는 이미 존재하는 값을 감싸서 프로미스를 생성하기 위해 사용합니다. Promise.resolve 메서드는 인수로 전달받은 값을 resolve하는 프로미스를 생성하고, Promise.reject 메서드는 인수로 전달받은 값을 reject하는 프로미스를 생성합니다.
// 배열을 resolve하는 프로미스를 생성
const resolvedPromise = Promise.resolve([1, 2, 3]);
resolvedPromise.then(console.log); // [1, 2, 3]
// 에러 객체를 reject하는 프로미스를 생성
const rejectedPromise = Promise.reject(new Error('Error!'));
rejectedPromise.catch(console.log); // Error: Error!
위 예제는 프로미스 생성자를 사용하는 아래 코드와 동일합니다.
const resolvedPromise = new Promise(resolve => resolve([1, 2, 3]));
resolvedPromise.then(console.log); // [1, 2, 3]
const rejectedPromise = new Promise((_, reject) => reject(new Error('Error!')));
rejectedPromise.catch(console.log); // Error: Error!
2.5.2 Promise.all
Promise.all 메서드는 여러 개의 비동기 처리를 모두 병렬(parallel) 처리할 때 사용합니다. 프로미스를 배열로 전달하고, 전달 받은 배열의 모든 프로미스가 모두 filfilled 상태가 되면 종료합니다. 모든 프로미스가 fulfilled 상태가 되면 reolve 된 처리 결과를 모두 배열에 저장해서 새로운 프로미스를 반환합니다. 이때 resolve 된 순서가 다르더라도 첫 번째 프로미스부터 차례대로 저장하기 때문에 처리 순서가 보장됩니다.
만약 배열의 모든 프로미스 중 하나라도 rejected 상태가 되면 나머지 프로미스가 fulfilled 상태가 되는 것을 기다리지 않고 즉시 종료합니다. 더불어 인수로 받은 이터러블 요소가 프로미스가 아니라면 Promise.resovle 메서드를 통해 프로미스로 래핑합니다.
Promise.all([
1, // Promise.resolve(1)
2, // Promise.resolve(2)
3, // Promise.resolve(3)
])
.then(console.log) // [1, 2, 3]
.catch(console.log);
2.5.3 Promise.race
Promise.race 메서드는 Promise.all 메서드와 동일하게 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받습니다. 대신, 배열의 모든 프로미스 중 가장 먼저 fulfilled 상태가 된 프로미스의 처리 결과를 resolve하는 새로운 프로미스를 반환합니다. 아례 예시를 보면, 타이머가 가장 짧은 세 번째 프로미스의 결과가 반환되는 것을 확인할 수 있습니다.
rejected 상태가 되면 Promise.all 메서드와 동일하게 reject하는 프로미스를 즉시 반환합니다.
Promise.race([
new Promise(resolve => setTimeout(() => resolve(1), 3000)),
new Promise(resolve => setTimeout(() => resolve(2), 2000)),
new Promise(resolve => setTimeout(() => resolve(3), 1000))
])
.then(console.log) // 3
.catch(console.log);
2.5.4 Promise.allSettled
Promise.allSettled 메서드는 이터러블의 요소인 프로미스 모두가 settled 상태가 되면 처리 결과를 배열로 반환합니다. fulfilled 상태는 값과 함께, rejected 상태는 이유와 함께 반환합니다.
[
{status: "fulfilled", value: 1},
{status: "rejected", reason: Error: Error! at <anonymous>:3:60}
]
2.6 마이크로태스크 큐
프로미스의 후속 처리 메서드의 콜백 함수는 일반적인 콜백 함수가 쌓이는 태스크 큐보다 우선순위가 높은 마이크로태스트 큐에 쌓입니다. 그래서 콜 스택이 비었을 때, 마이크로태스크 큐에서 대기하고 있는 함수부터 가져와서 실행하고, 마이크로태스크 큐가 비었을 때 태스크 큐에서 대기하고 있는 함수를 가져와서 실행합니다.
Promise의 상태와 값을 사용할 수 있는 이유
콜백함수는 실행이 끝나면 실행 컨텍스트가 팝되면서 결과를 저장할 수가 없지만, Promise는 실행 컨텐스트가 사라져도 상태와 결과값을 저장할 수 있습니다. 그래서 후속 처리 메서드를 통해 결과를 받아서 처리할 수 있죠. 그런데 이게 어떻게 가능한 걸까요?
Promise가 실행 컨텍스트가 사라진 후에도 값을 유지할 수 있는 이유는 메모리에 프로미스의 상태와 결과값을 저장하기 때문입니다. 이를 이해하기 위해 메모리 저장에 대해 좀 더 알아보겠습니다.
힙 메모리(Heap Memory) vs. 스택 메모리(Stack Memory) 차이
자바스크립트에서 메모리 관리 방식은 크게 두 가지가 있습니다. 원시 타입을 저장하는 스택 메모리와 객체 타입을 저장하는 힙 메모리입니다. 각각의 특징을 먼저 살펴보면,
스택 메모리
LIFO 방식으로 저장되며, 함수가 실행될 때만 유지됩니다. 또한 정해진 크기의 데이터인 기본 자료형 값을 저장하기 때문에 접근 속도가 빠릅니다.
- 기본 자료형(Primitive Type): number, string, boolean, null, undefined, symbol 등이 저장
- 함수 실행 시 생성되는 지역 변수, 매개변수, 실행 컨텍스트 등이 저장
- 함수 실행이 끝나면 자동으로 제거
- 콜 스택(Call Stack)에서 관리
힙 메모리
동적 할당 방식으로 저장되며, 명시적으로 제거되지 않으면 유지됩니다. 그리고 객체 타입의 값을 저장하기에 데이터의 크기가 자유롭고, 그래서 접근 속도가 느립니다.
- 객체(Object), 배열(Array), 함수(Function) 같은 참조 자료형(Reference Type)을 저장
- 메모리 동적 할당(Dynamic Allocation) 방식으로 저장
- 실행 컨텍스트가 종료되어도 가비지 컬렉터(Garbage Collector)가 제거하기 전까지 유지
Promise는 힙 메모리에 저장되기 때문에 스택 메모리에서 관리되는 일반적인 변수와 다르게 함수가 종료된 후에도 메모리에 남아 있을 수 있습니다. 덕분에 상태와 값을 유지할 수 있고, 콜 스택이 비었을 때 이벤트 루프에 의해 마이크로태스크 큐에서 대기하고 있던 프로미스의 후속처리 메서드의 콜백 함수를 가져와서 실행합니다. 이때, 콜백 함수는 힙 메모리에 저장된 프로미스 객체의 상태와 결과 값을 참조하여 처리합니다.
예시 코드와 함께 그 과정을 순서대로 살펴보겠습니다.
console.log("Start");
const promise = new Promise((resolve) => {
setTimeout(() => {
console.log("Resolving...");
resolve(2025);
}, 2000);
});
promise.then((value) => console.log("Year:", value));
console.log("End");
- console.log("Start") 실행 → Start 가 출력됩니다.
- new Promise() 실행 → 인수로 있는 즉시 실행 함수(Executor 함수)가 동작합니다.
- resolve는 아직 호출되지 않았습니다.
- setTimeout() 이 실행되면서 2초 후에 실행 될 비동기 작업이 Node API(백그라운드)에 등록됩니다.
- console.log("End”) 실행 → End 가 출력됩니다.
- 콜 스택은 모든 동기 코드의 실행을 완료했습니다.
- 백그라운드에 등록된 setTimeout() 내부의 함수는 아직 실행되지 않았습니다.
2초후
- setTimeout() 이 완료되고 콜백을 실행합니다.
- Resolving...이 출력됩니다.
- resolve(2025)가 실행되면서 프로미스 객체의 상태가 fulfilled로 변경됩니다.
- 이벤트 루프가 후속 처리 메서드 then에 등록된 콜백을 마이크로태스크 큐에 추가합니다.
- promise.then() 이 실행됩니다.
- resolve가 실행되었기 때문에 fulfilled 상태에 실행되는 then()의 콜백이 실행됩니다.
- Year: 2025가 출력됩니다.
참고자료
- 모던 자바스크립트 Deep Dive, 45장. 프로미스
'Language > JavaScript' 카테고리의 다른 글
async/await - 실행 컨텍스트로 이해하기 (0) | 2025.02.20 |
---|---|
제너레이터 - 이터러블/ 이터레이터 (0) | 2025.02.20 |
실행 컨텍스트 뿌시기 part2 (0) | 2023.08.27 |
실행컨텍스트 뿌시기. part1 (0) | 2023.08.25 |
자바스크립트 일반 (0) | 2023.06.24 |