
왜 비동기 로직을 처리하기 위해 자바스크립트가 콜백 패턴을 사용해 왔는지와 콜백 패턴이 가진 한계를 알아봅니다.
과거의 자바스크립트(ECMAScript 6 전)는 비동기 로직을 처리하기 위해 콜백 패턴을 사용해 왔다. 콜백 패턴은 치명적인 단점이 존재했고, 이를 해결한 프로미스라는 비동기 처리 패턴이 등장 했다. 이 글에서는 콜백 패턴이 무엇이고, 왜 프로미스라는 패턴이 등장하게 됐는지를 알아보자.
💡 콜백 패턴을 이해하기 위해서는 자바스크립트가 어떻게 비동기 로직을 처리하는 지를 알아야 한다.
비동기 로직을 처리하는 방식에 대해 이해하기 위해서는 자바스크립트의 실행 환경에 대해서 알아야한다. 자바스크립트의 실행 환경(Node.js or 브라우저)은 자바스크립트가 멀티 쓰레드, Non-Blocking 언어처럼 구동할 수 있는 환경을 제공한다.
=> 단순하게 말하면 자바스크립트는 한 가지 작업 밖에 못하지만, 주변 환경이 여러 작업들을 도맡아 도와줘서, 멀티 작업을 하는 것처럼 보인다.
💡 자바스크립트는 싱글스레드, Blocking 언어이다. 멀티 쓰레드, Non-Blocking처럼 보이는 것은 자바스크립트가 실행되는 환경 덕이다.
브라우저나 Node.js 환경에서 동작하는 자바스크립트는 비동기 로직을 만나는 순간 해당 로직을 콜 스택에서 제외한다. 제외한 로직은 해당 로직을 이행할 수 있을 때, 콜 스택에 push 된다. 비동기 로직을 콜스택에서 제외한 후에는 바로 아래 코드가 순차적으로 실행된다. 이때 비동기 코드의 결과를 아래 코드에서 사용하고 있다면, 원하는 결과를 받아볼 수 없다.
비동기 함수란 ❓ 내부적으로 비동기적인 연산을 수행하는 함수를 의미한다.
let 소중한데이터;
setTimeout(()=>{
소중한데이터 = 'gold'
},0)
console.log(소중한데이터);
// undefined
setTimeout은 두 가지의 인자를 받는데, 하나는 콜백 함수이고, 다른 하나는 콜백 함수를 얼마 뒤에 실행시킬 지에 대한 지연시간 정보(ms)를 받는다.
💡setTimeout 함수에 사용될 수 있는 메개변수는 두 가지보다 많지만 중요한 사실은 아니다.
function setTimeout(handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]): number (+2 overloads)
지연시간이 0(ms)이기 때문에 콜백 함수가 바로 실행될 것이다. 해당 콜백 함수의 스코프에는 소중한데이터라는 변수가 없기 때문에 스코프 체이닝을 통해 상위 스코프에서 변수를 찾고, 해당 변수에 ‘gold’라는 값을 저장한다. 그 뒤에 console.log로 gold라는 값이 출력된 결과를 예상할 수 있다.
하지만 console.log에는 undefined이 찍힌다. 이는 앞에서 설명한 자바스크립트가 비동기 처리를 수행하는 방법과 관련있다. 자바스크립트는 비동기 처리가 필요한 경우에 해당 로직을 콜스택에서 제외 시킨 뒤에 해당 로직을 이행할 시점이 된다면 taskQueue에 push한다. event loop는 주기적으로 콜스택이 비었는지 체크하고, 비었다면 해당 taskQueue의 task를 콜스택에 올린다. 콜스택에 올라온 로직에 비동기 로직이 포함된다면 다시 콜스택에서 제외되는 과정을 거치게 된다.
💡이벤트 루퍼가 관리하는 Queue는 3 종류로 구성되고, 각 queue에는 우선순위가 있다. 우선순위를 부여한 이유는 경쟁상태에서 벗어나기 위함이다. (브라우저 기준)
- 프로미스의 콜백이 담기는 microTaskQueue
- requestAnimationFame의 콜백이 담기는 animation Frames
- 이벤트 핸들러와 setTimeout, setInterval 등의 webAPI와 연관된 콜백이 담기는 macroTaskQueue
실행 우선 순위는 1,2,3 이다.
const get =(url,성공함수,실패함수)=>{
const xhr = new XMLHttpRequest();
xhr.open('GET',url);
xhr.send();
xhr.onload = () => {
if(xhr.status ===200){
성공함수(JSON.parse(xhr.response));
} else {
실패함수(xhr.status);
}
}
}
해당 함수의 비동기 로직은 xhr.onload이다. onload 이벤트 헨들러는 xhr 객체에 onload Event가 발생하길 기다렸다, onload 이벤트가 발생하면 뒤의 로직을 실행한다. Event 핸들러도 당연히 콜스택이 비었을 때, 이벤트 루퍼가 콜스택에 올려줘야 한다.
💡 위의 내용을 종합해 비동기 로직이 콜백 패턴을 사용했던 이유를 알아보자
비동기 로직이 콜스택으로 돌아와 실행되는 시점에는 해당 로직이 실행되던 실행 컨텍스트가 없다. 비동기 로직의 수행 결과를 반영하고 싶어서 반영할 수 없다. (전역 빼고)
특정 함수의 반환값을 사용하기 위해서는 직접 호출해야한다. xhr.open 내부에서 결과를 리턴하고, 그 결과를 변수에 저장한다고 해도 사용할 수 없다.
setTimeout(() => console.log("a"), 0);
console.log("b");
(function () {
console.log("c");
setTimeout(() => {
console.log("d");
setTimeout(() => {
console.log("e");
setTimeout(() => console.log("f"), 0);
console.log("g");
}, 0);
}, 0);
console.log("h");
})();
console.log("i");
// b => c => h => i => a => d => e => g => f
콜백 패턴을 사용해서 비동기 로직의 결과를 토대로 이후 작업을 처리할 수 있다. 글의 시작에 밝혔듯이 콜백 패턴의 치명적인 단점이 존재하는데, 콜백 헬, Error 헨들링 순으로 알아보자.
비동기 처리한 결과를 토대로 비동기 처리를 이어가야 한다면 콜백 함수들이 길게 연결되기 시작하는데, 이를 콜백 지옥이라 한다. 주의할 점은 콜백 지옥은 개발자의 불편함이지 자바스크립트와는 무관하다. 콜백이 아무리 깊게 연결 되더라도 엔진은 불평 없이 정해진 임무를 정확히 수행할 것이다. 아래 예를 통해 콜백 헬이 어떻게 개발자를 괴롭힐 수 있는지 알아보자.
setTimeout(() => {
요리() // 장보기, 재로 손질, 요리
setTimeout(() => {
셋팅() // 식판에 음식 담기, 컵에 물 따르기, 셋팅 후 착석 시키기
setTimeout(() => {
식사보조() // 잘 먹는지 옆에서 지켜보기, 필요하다면 도와주기
setTimeout(() => {
정리() // 설거지, 양치 시키기
}, 정리_시간);
}, 식사_보조_시간);
}, 셋팅_시간);
}, 요리_시간);
위 코드의 문제점은 다음과 같다.
1. 가독성이 떨어진다.
2. 유연성이 떨어진다.
3. 디버깅이 매우 힘들다.
4. ⭐⭐ 에러처리가 힘들다. ⭐⭐
1,2,3은 모두 비슷한 얘기이다. depth가 깊어져 가독성이 떨어지고, 연결이 복잡해 어디서 에러가 났는지 확인하기 어렵다. 가장 큰 문제는 4번인데 아래에서 자세히 알아보자.
콜백 패턴은 Error를 catch하기 어렵다. 이는 앞에서 얘기했던 자바스크립트의 비동기 로직과 관련있다. 동기 코드와 비동기 코드를 비교하며 알아보자.
비동기 로직
try {
setTimeout(() => {throw new Error("반드시에러가발생하는코드")}, 0);
} catch (e) {
console.log(e.message);
}
동기 로직
function 동기처리함수(callback) {
callback()
}
try {
동기처리함수(() => {throw new Error("반드시 에러가 발생하는 코드")});
} catch (e) {
console.log(e.message);
}
// 반드시 에러가 발생하는 코드
비동기 로직과 동기 로직 모두 콜백함수에서 Error를 던지도록 되어 있다. try문 블록에서 Error가 던져지면 해당 Error를 catch 문의 변수로 전달되고, catch문 블록의 코드가 실행된다. 동기 로직은 문제 없이 Error를 catch하지만, 비동기 로직은 그렇지 못하다.
throw Error가 던져지는 쪽은 해당 함수의 호출자이다. throwing된 Error는 catch될 때까지 계속적으로 전파된다. 예시를 통해서 알아보자.
function 동기처리함수1(callback) {
동기처리함수2(callback);
}
function 동기처리함수2(callback) {
callback();
}
try {
동기처리함수1(() => {
throw new Error("반드시 에러가 발생하는 코드");
});
} catch (e) {
console.log(e.message);
}
// 반드시 에러가 발생하는 코드
동기처리함수1 안에 동기처리함수2가 해당 callback 함수를 전달 받고 동기처리함수2에서 해당 콜백이 실행되는 구조이다. 동기처리 함수 2의 콜백에서 던져진 Error는 아래와 같은 방향으로 전파된다.
callback ⇒ 동기처리함수 2 ⇒ 동기처리함수 1 ⇒ try 문 ⇒ catch 문
💡 throw Error는 return 없이도 최상단까지 전파된다는 점을 유의하자.
마치 브라우저의 Event 전파처럼..
일반적으로 최상단까지 전파하려면 return 을 반복적으로 작성할 필요가 있다.const result = (function() { return function() { return function() { return 3; }; }; })();
setTimeout(() => {throw new Error("반드시에러가발생하는코드")}, 0);
해당 코드가 호출되면 setTimeout의 실행 컨텍스트가 생성되고, 콜 스택에 푸시된다. setTimeout 안에 callback 함수는 비동기 처리가 필요하기 때문에 기다리지 않고, 타이머(실행환경)에 해당 콜백함수를 등록하게 된다. 등록 후 setTimeout은 종료되는데, 이 과정에서 콜스택에서 제거된다. 당연히 실행 컨텍스트도 제거 된다.
타이머(실행환경)가 완료되면 macroTaskQueue에 푸쉬되고, 이벤트 루프가 다시 콜 스택에 푸시하는 과정을 거치게 된다. 해당 콜백 함수가 콜스택에 도착했을 때는 setTimeout의 실행 컨텍스트가 없다. 그렇기에 에러를 던지더라도 에러를 캐치할 곳에 대한 정보가 없어, 아무도 받지 못 하는 상황이 벌어진다.
자바스크립트의 비동기 처리 로직 때문에 비동기 로직이 실행되는 시점에는 호출할 당시의 실행 컨텍스트가 없다. 또, 함수를 직접 호출하지 않는 이상 비동기 로직의 결과 값을 리턴 받을 수 없다. 이러한 이유로 콜백을 넘겨주는 방식의 패턴을 사용해서 비동기 로직의 결과 값을 처리해왔다. 하지만, 순차적으로 일어나는 비동기 로직을 수행할 때 발생하는 콜백 헬이나 Error 핸들링의 어려움 때문에 Promise 패턴이 등장했다.