자바스크립트는 비동기 처리를 위한 하나의 패턴으로 콜백 함수
를 사용한다. 하지만 콜백 패턴의 단점 때문에 ES6에서 또 다른 패턴인 Promise(프로미스)
가 도입되었다. 그럼 콜백 함수의 문제점은 무엇이었을까?
Promise
를 논하기 전에 비동기 함수
에 대해 먼저 알아보자.
비동기 함수
란 함수 내부에 비동기로 동작하는 코드를 포함한 함수를 말한다. 쉽게 말하면, 특정 라인의 코드가 끝날 때까지 기다리는 게 아닌 코드가 있다는 뜻이다.
비동기 함수를 호출하면 함수 내부의 비동기로 동작하는 코드가 완료되지 않았다 해도 기다리지 않고 즉시 종료된다.
위 문장의 뜻을 이해하기 위해 코드를 작성해보았다.
let currentStatus = 'close';
const openAfterSeconds = (seconds) => {
setTimeout(() => {
currentStatus = 'open';
}, seconds * 1000);
};
openAfterSeconds(5);
console.log(`We are ${currentStatus}!`);
특정 초 뒤에 currentStatus
의 값을 open
으로 재할당하는 openAfterSeconds
라는 함수가 있다. 현재 값인 close
를 5초 뒤에 open
으로 바꿔 출력하고자 openAfterSeconds
함수를 호출했다.
함수를 호출한 다음, 콘솔 창에 We are open이라는 문자열이 출력될 것이라 생각하였으나, 실제로 찍힌 로그는 그렇지 않았다.
We are open!
이 출력되지 않은 이유는 setTimeout 함수가 비동기 함수이기 때문이다.
그래서? 비동기 함수인게 뭐!
이제는 콜 스택
과 태스크 큐
에 대해 알아야할 때다.
setTimeout 함수는 비동기 함수이며, 비동기 함수는 함수 내부에 비동기로 동작하는 코드를 포함한 함수라고 했다. setTimeout이 비동기 함수인 이유는 바로 콜백 함수의 호출이 비동기적으로 동작하기 때문이다. 다시 말해, 콜백 함수가 실행되기까지(위 코드에서는 5초가 지나기까지) 대기하지 않는다.
따라서 We are open!
이 출력되지 않은 것이다.
이 흐름의 내부 동작을 좀 더 자세히 알아보자.
먼저 콜 스택
이란 소스코드 평가 과정에서 생성된 실행 컨텍스트가 추가되고 제거되는 스택 자료구조 즉, 실행 컨텍스트 스택
이다. 쉽게 말해서 당장 순서대로 실행 가능한 작업 목록 정도로 보면 될 것 같다.
콜 스택에 실행할 작업(정확히 말하면 실행 컨텍스트)들이 담겨있다.
다음으로 태스크 큐
란 비동기 함수의 콜백 함수 또는 이벤트 핸들러가 일시적으로 보관되는 영역이다.
이 정도로 간단하게만 짚고 위 코드의 시작점부터 다시 살펴보면, 현재 콜 스택
에는 전역 컨텍스트가 있고 태스크
큐
는 비어있는 상황이다.
함수를 실행하는 코드(openAfterSeconds(5);)를 만나면 함수 컨텍스트가 생기고 Web API인 타이머가 5초 카운트를 시작한다. 그리고 거의 동시에 콜 스택에서 openAfterSeconds 함수 컨텍스트는 pop된다.
→ 콜 스택에서 함수 컨텍스트가 제거되었다.
이 다음에 실행할 컨텍스트는 setTimeout의 콜백 함수의 컨텍스트가 아닌 콜 스택의 가장 상위 컨텍스트이다. 즉, 5초를 기다리지 않는다. 현재 콜 스택의 최상단 컨텍스트는 전역 컨텍스트이므로 ‘We are close’를 출력(console.log)하고 전역 컨텍스트도 콜 스택
에서 제거된다.
이어서 타이머도 종료되면 setTimeout의 콜백 함수가 태스크 큐
에 들어간다.
→ 전역 코드를 모두 실행하면 콜 스택이 비워진다. 그리고 web api의 타이머가 종료되면 콜백 함수는 태스크 큐로 이동한다.
태스크 큐에 들어간다는 것은 콜 스택이 비워지기만 하면 바로 실행될 수 있는 태스크임을 의미한다. 즉, 태스크 큐에 들어간 태스크는 콜 스택이 비워져야만 실행된다.
현재 콜 스택은 전역 컨텍스트가 pop되면서 비워졌고, 타이머가 종료되어 태스크 큐에 콜백 함수를 실행하는 태스크가 담겼다. 아직 살펴보지 않은 것 중에 이벤트 루프
라는 게 있는데, 이 이벤트 루프
는 콜 스택이 비워졌는지 감시하다가 비워지면 태스크 큐의 첫 번째 태스크(FIFO)를 콜 스택으로 이동시킨다.
→ 태스크 큐에 있던 콜백 함수가 이벤트 루프에 의해 콜 스택으로 옮겨졌다.
결론적으로, 비동기 함수(setTimeout) 내부의 비동기로 동작하는 코드(콜백 함수)는 태스크 큐에서 대기하다가 콜 스택이 비워질 때 비로소 실행될 수 있기 때문에 비동기 함수가 종료된 이후에 동작하게 된다.
비동기 함수 내부의 비동기로 동작하는 코드는 비동기 함수가 종료된 이후에 완료된다.
결국 콜백 함수는 currentStatus가 close인 상태로 로그를 찍은 다음에 실행되기 때문에, 비동기로 동작하는 코드의 처리 결과(close → open)를 사용하고 싶다면 비동기 함수 내부에서 실행해줘야 한다.
비동기 함수는 비동기 처리 결과를 외부에 반환할 수 없고, 상위 스코프의 변수에 할당할 수도 없기 때문에 비동기 처리 결과에 대한 후속 처리는 비동기 함수 내부에서 수행해야 한다.
코드를 다음과 같이 수정하고 실행시키면 5초 뒤에 We are open!
이 출력된다.
let currentStatus = 'close';
const openAfterSeconds = (seconds) => {
setTimeout(() => {
currentStatus = 'open';
console.log(`We are ${currentStatus}!`); // 비동기 처리 결과에 대한 후속 처리는 비동기 함수 내부에서!
}, seconds * 1000);
};
openAfterSeconds(5);
비동기 함수가 종료되면 그제서야 함수 내부에 비동기로 동작하는 코드가 실행될 수 있다고 했다. 그렇기 때문에 비동기 함수의 처리 결과에 대한 후속 처리는 비동기 내부에서 수행해야 한다.
이때, 비동기 함수 내부의 처리 결과에 대한 후속 처리를 하는 비동기 함수가, 처리한 처리 결과를 가지고 또 다른 작업을 해야한다면 콜백 함수의 호출이 중첩되어 복잡도가 높아진다. 이를 콜백 지옥(콜백 헬)
이라 한다.
let currentStatus = 'close';
const openAfterSeconds = (seconds) => {
try {
setTimeout(() => {
currentStatus = 'open';
throw new Error('잠시만요! 아직 open 못해요');
}, seconds * 1000);
} catch {
console.error('캐치한 에러: ', e);
}
};
openAfterSeconds(5);
console.log(`We are ${currentStatus}!`);
예시로 사용한 코드를 try-catch 문으로 변경해 보았다. setTimeout 함수는 5초 뒤에 currentStatus를 바꾸고 에러를 던진다.
코드를 실행시키면,
발생한 에러를 catch문에서 캐치하지 못한다.
이 이유는 setTimeout이 비동기 함수기 때문이다. setTimeout은 콜백 함수가 호출되는 것을 기다리지 않고 즉시 종료되어 콜 스택에서 제거된다. 이후 타이머가 만료되면 콜백 함수는 태스크 큐에 이동하고 이벤트 루프에 의해 콜 스택으로 들어와 실행되는데 이때 setTimeout 함수는 이미 콜 스택에서 제거된 상태다. 즉, setTimeout의 콜백 함수를 호출한 caller는 setTimeout이 아니라는 뜻이다.
에러는 항상 호출자 방향으로 전파된다. 즉, 콜 스택의 아래 방향으로 전파된다. 따라서 setTimeout의 콜백 함수가 던진 에러를 받으려면 콜 스택에 setTimeout 컨텍스트가 있어야 한다. 하지만 이미 콜 스택에서 제거되었기 때문에 setTimeout은 콜백 함수가 발생시킨 에러를 catch할 수 없다.
에러는 호출자 방향으로 전파되기 때문에, 콜 스택에서 자신보다 아래 있는 컨텍스트로만 에러가 전파된다.
콜백 패턴의 문제점인 콜백 헬과 에러 처리의 어려움을 해결하고자 ES6에서 새로 도입된 패턴이다.
Promise 생성자 함수
를 new
연산자와 함께 호출하면 프로미스 객체를 생성할 수 있다.
new Promise(...)
Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수를 인수로 전달 받는데, 이 콜백 함수를 executor 함수
라고 한다. 이 executor 함수는 resolve 함수
와 reject 함수
를 인수로 전달받는다.
new Promise((resolve, reject) => { ... });
Promise 생성자 함수가 인수로 전달받은 콜백 함수 즉, executor 함수 내부에서 비동기 처리를 수행한다. 이때 비동기 처리의 성공 여부에 따라 executor 함수가 인수로 전달받은 함수를 호출한다.
const promise = new Promise((resolve, reject) => {
if (/* 비동기 처리 성공 (ex. GET 요청 성공)*/) {
resolve('result');
} else { /* 비동기 처리 실패 */
reject('failure reason');
}
});
};
이때 resolve
와 reject
는 비동기 처리가 수행된 결과에 따라 프로미스의 상태를 변경하는 역할을 한다. 즉, resolve
와 reject
를 호출함으로써 비동기 처리 상태(프로미스의 상태)를 결정 짓는다.
프로미스 상태 | 비동기 처리 전 | resolve | reject |
---|---|---|---|
status | pending | ‘fulfilled’ | ‘rejected’ |
result | undefined | value | error |
프로미스는 비동기 처리 상태(status)뿐만 아니라 비동기 처리 결과(result)까지 갖게 된다.
const fullfilledPromise = new Promise((resolve) => resolve('success!'));
const rejectedPromise = new Promise((_, reject) => reject('fail!'));
위과 같이 처리 상태가 성공, 실패인 프로미스를 생성하고 출력해보면, 다음과 같이 비동기 처리 상태(status)와 결과(result)를 갖는 프로미스 객체가 출력된다.
결론적으로 프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체다.
프로미스의 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백 함수가 선택적으로 호출된다. 이때 후속 처리 메서드의 콜백 함수에 프로미스의 처리 결과가 인수로 전달된다.
후속 처리 메서드의 콜백 함수에는 프로미스의 처리 결과(status가 아닌 result)가 인수로 전달된다.
또한, 모든 후속 처리 메서드는 프로미스를 반환하며 비동기로 동작한다.
후속 처리 메서드는 프로미스를 반환한다.
then 메서드
는 비동기 처리 결과(result)를 이용해 후속 처리를 돕는 메서드다.
then 메서드는 두 개의 콜백 함수를 인수로 전달받는다.
첫 번째 콜백 함수
: 프로미스가 fulfilled 상태가 되면, 프로미스의 비동기 처리 결과(result)를 인수로 받아 호출된다. → 비동기 처리 성공과 관련new Promise((resolve, reject) => resolve('처리 성공'))
.then((value) => console.log(value)); // 처리 성공
두 번째 콜백 함수
: 프로미스가 rejected 상태가 되면, 프로미스의 에러를 인수로 받아 호출된다. → 비동기 처리 실패와 관련 → catch
와 비슷한 역할new Promise((resolve, reject) => reject(new Error('처리 실패')))
.then((error) => console.log(error)); // 처리 실패
then 메서드는 언제나 프로미스를 반환한다. then 메서드의 콜백 함수가 프로미스를 반환하면 프로미스를 그대로 반환하면 되지만, 만약 프로미스가 아닌 값을 반환하면 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성해 반환한다.
→ then
은 프로미스가 아닌 string 타입의 value1을 반환했지만, 암묵적으로 프로미스를 생성했기 때문에 then의 반환값은 결국 프로미스가 된다.
then 메서드는 언제나 프로미스를 반환한다.
then은 프로미스를 반환하기 때문에 체이닝
이 가능하다. 즉, then 메서드를 계속 이어붙여 사용할 수 있다.
catch 메서드
는 에러 처리를 위한 메서드다.
catch 메서드는 한 개의 콜백 함수를 인수로 전달받으며, 프로미스가 rejected 상태인 경우에만 콜백 함수가 호출된다.
catch 메서드는 then과 동일하게 동작한다.
→ catch 메서드는 then 메서드의 두 번째 콜백 함수를 실행하는 것과 동일하게 동작한다.
then과 동일하게 동작하기 때문에 catch 메서드도 동일하게 프로미스를 반환한다.
finally 메서드
는 프로미스의 상태와 상관없이 공통적으로 수행해야 할 처리 내용이 있는 경우를 위한 메서드다.
finally 메서드는 한 개의 콜백 함수를 인수로 전달받으며, 처리 상태와 상관 없이 콜백 함수를 무조건 한 번 호출한다.
finally 메서드도 프로미스를 반환한다.
Promise에 대해 간략히 정리해봤는데, 후속 처리 메서드만큼 중요한 것이 또 있다.
바로 프로미스의 정적 메서드
는 다음 글에서!