면접에서 '자바스크립트에서 비동기 코드를 작성하려면 어떤 방법을 써야 하나요?' 라는 질문을 받았다고 합시다. 그러면 우리는 '비동기 코드를 작성하기 위해선 콜백(callback), 프로미스(promise, 약속), 어싱크 함수(async function)을 활용할 수 있습니다.'라고 답할 수 있겠죠.
그러면 곧바로 꼬리 질문이 이어집니다. '세 가지나 되네요. 근데 콜백만으론 부족한가요? 왜 프로미스와 어싱크 함수까지 필요한가요? 이들을 왜 쓰는거죠?' 라고요. 우리는(이미 잘 알고 있어서 아닌 분들도 계시겠지만) 멘붕에 빠지고 맙니다. 우리 대부분은 그저 편리하니까 의식하지 않고 써왔거든요!
이 글은 프로미스와 어싱크 함수를 잘 알고 쓰고, 잘 설명하기 위한 여정의 첫 번째 글입니다.
우리 자바스크립트 선배 개발자분들께서는 프로미스 이전 오랜 시간 동안 콜백만을 사용해 비동기 코드를 작성해왔습니다. 그러나 이 콜백만을 사용하는 콜백 체계는 소위 말하는 콜백 지옥(callback hell) 라는 문제점을 내포하고 있었고 콜백 지옥은 많은 고통을 유발했죠.
보기만 해도 머리가 아파지는 콜백의 무한한 중첩을 볼 수 있습니다. 이것만 봐도 일이 잘못되고 있구나 생각이 드는 데는 충분하죠. 하지만, 이 멸망의 피라미드(pyramid of doom)는 지옥의 겉모습일 뿐 지옥의 진정한 모습은 좀 더 복잡한 모습을 갖고 있습니다.
이미 위의 코드처럼 콜백의 무한한 중첩으로도 코드의 맥락과 흐름 파악에 어려움을 겪을 수도 있습니다. 프로그램의 다른 부분과 다르게 이렇게 무책임하게 깊어지는 코드 뎁스를 눈으로 따라가다 보면, 개발자는 무척 피곤할 것이고. 콜백을 따라가다 이전 콜백에 무슨 일이 일어났는지 잊어버리는 일도 흔할 겁니다.
하지만 위의 코드는 콜백 로직이 최선의 결과로 작성했을 때를 가정한 코드입니다. 모든 자바스크립트 파일이 콜백 호출에 따라 성공적으로 불러와지는 경우 말이죠. 그러나 우리 프로그램은 런타임 시 온갖 잡음이 발생합니다. 가장 쉽게 생각할 수 있는 예로, 네트워크 상태가 좋지 못해서 몇몇 자바스크립트 파일을 불러오지 못하는 경우가 있겠죠. 우린 이런 경우 어떻게 하나요? 그냥 놔두지 않고 예외 처리를 하게 됩니다.
자, 위의 코드의 각 콜백 뎁스에 예외 처리 로직을 넣는다고 상상해봅시다. 지옥의 진정한 모습이 얼핏 보이는 것 같나요?
// 위 코드에 단순한 에러처리 로직을 넣으며 뎁스도 줄이고 단순화한 코드
doA(function(valueA, errorA) {
if (errorA) {
doInErrorA(errorA);
return;
}
doB(valueA, function(valueB, errorB) {
if (errorB) {
doInErrorB(errorB);
return;
}
doD(valueB, function(valueD, errorD) {
if (errorD) {
doInErrorD(errorD);
return;
}
doE(valueD, function(valueE, errorE) {
if (errorE) {
doInErrorE(errorE);
return;
}
// ... Do somethings
});
});
});
doC(valueA);
});
doF()
제가 작성한 코드지만 어떻게 작동할 지 저도 잘 모르겠습니다. 어떤 순서로 작동할까요? 모든 에러 처리 함수 외에 모든 함수가 비동기 함수이고 에러가 발생하지 않는다면 doA → doF → doB → doC → doD → doE 이겠죠?... 눈을 이리저리 굴리면서 코드 베이스를 널뛰기하면서 생각해낸 순서지만 맞는지 잘 모르겠습니다.
심지어 에러가 빵빵 난다면? 사실 동기 함수가 몇몇 섞여 있다면? 콜백을 받는다고 비동기라는 보장은 없으니까요. 내부 구조를 모르는 서드파티 라이브러리 함수를 사용했는데, 콜백을 받지만, 사실은 동기함수였던 겁니다.
예상하는 게 가능할까요? 이렇듯 콜백의 경우 코드 진행이 순차적이지 못해 코드의 컨텍스트, 흐름 파악에 어려움이 있을 수 있습니다.
어떤 A라는 함수에 콜백을 넘기면 콜백에 대한 제어권은 호출부(여기선 메인 프로그램)을 떠나 A 함수에게 넘어가게 됩니다. 즉, 콜백의 호출 방식과 호출 여부 등은 오로지 A 함수가 어떻게 하느냐에 달려있다는 말입니다. 이를 제어의 역전(IoC, Inversion of Control) 이라고 합니다. 여기서 쓰인 IoC는 싱글톤 IoC Container를 활용하는 유용한 디자인패턴과는 결이 다릅니다.
// 메인 프로그램에서 doA 호출!
doA();
// 메인 프로그램에서 ajax 호출!
// doB는 ajax 함수가 알아서 호출하겠지 뭐!(제어권을 ajax 함수로 넘김)
ajax("https://zum.com/", doB);
// 메인 프로그램에서 doC 호출!
doC();
ajax함수를 앞으로 함수 A라 부릅시다. 그렇담 함수 A의 작동에 따라 콜백 호출되는 경우의 수는 대표적으로 다음과 같습니다.
만약 함수 A가 개발자 본인이 만들었고 본인만 쓰고있 다면, 그나마 나을 겁니다. 함수 A의 콜백 호출에 문제가 생기면 직접 디버깅하고 테스팅하면 되니까요.
이 문제는 서드파티 라이브러리와 같은 다른 개발자에 의해 배포된 함수를 사용할 때 문제가 됩니다. 함수 A가 만들어진 히스토리를 정확히 모르는 상태에선 그저 잘 작동할 것이라 믿으며 사용하게 됩니다.
개발 중, 유지 운영 중에 함수 A가 운이 좋게도 개발자가 의도한 대로 잘 작동해주면 문제가 없지만, 특정 조건 하에 오작동한다면 심각한 버그를 일으키는 셈입니다. 콜백이 적절히 호출될 거라는 믿음이 깨지는 순간이죠.
깨진 믿음을 봉합하기 위해 함수 A 외부에서 콜백이 잘못 호출되는 경우를 모두 예외 처리 해줍니다. 이제 함수 A 말고도 해당 라이브러리의 함수 B, C, D ... 들도 믿지 못합니다. 예외 처리 해줍시다.
코드 복잡도가 올라가는 소리가 들리시나요? 코드에 대한 믿음이 사라져버리면 이를 대비하기 위한 방어 로직을 수없이 짜 넣어야 합니다. 그리고 이러한 로직들은 보통 재사용이 어렵습니다. 결국 개발자는 많은 수의, 파편화된 방어 로직을 감당해야 합니다.
앞서 언급된 문제점을 해결하기 위해 ES6부터 프로미스(promise)가 도입되었습니다. 살펴본 것처럼 콜백 체계엔 코드의 순차성(sequentiality), 믿음성(trustability)가 결여되는 중대한 결함이 있습니다. 이것이 콜백 지옥의 구체적인 모습입니다. 프로미스는 이 순차성, 믿음성 결여를 해결하는 방향으로 개발되었습니다.
콜백 체계에선 어떤 함수 A에게 콜백 함수를 넘기면 콜백 함수의 제어권은 호출부에서 함수 A에게 넘어갑니다. 이런 상황에선 함수 A가 정상적으로 콜백 함수를 호출해주길 바라는 수밖에 없습니다. 주도권이 없는것이지요. 그러면 주도권(제어권)을 다시 찾아오면 되지요! 이를 제어의 되역전이라고 합니다.
// 위의 콜백 코드에 프로미스를 적용하여 수정
new Promise((resolve, reject) => {
resolve(doA());
})
.then(
(valueA) => {
doC();
return doB(valueA);
},
(errorA) => {
doInErrorA(errorA);
}
)
.then(
(valueB) => {
return doD(valueB);
},
(errorB) => {
doInErrorB(errorB);
}
)
.then(
(valueD) => {
return doE(valueD);
},
(errorD) => {
doInErrorD(errorD);
}
)
.then(
(valueE) => {
// ... Do somethings
},
(errorE) => {
doInErrorE(errorE);
}
);
doF();
프로미스 문법을 사용하면 호출부로 일단 프로미스 인스턴스가 반환됩니다. 이 프로미스 인스턴스는 비동기 요청의 귀결에 대한 일종의 수신기 역할을 합니다.
프로미스 인스턴스는 내부적으로 뭔가 시간이 제법 걸리는 일을 하고(비동기 요청은 으레 그런 법이죠. 이때는 대기(pending) 상태에 있습니다.) 결국 이행(fulfilled)하거나 거부(rejected)로 귀결됩니다. 그러면 프로미스 인스턴스에 체이닝되어있는 then
은 이를 수신하고 걸려있는 로직을 비동기적으로 수행합니다.
그리고 then
내에서 return
시 반환 값을 래핑(wrapping)한 프로미스 인스턴스가 반환되게 됩니다. 그러면 다시 이 프로미스 인스턴스에 또 다른 then
을 체이닝할 수 있게 되어 비동기 로직의 파이프를 구성할 수 있게 됩니다.
프로미스 인스턴스가 호출부로의 수신기 역할을 하면서, 인스턴스가 귀결되길 기다리다가 귀결되면 후속 로직을 then
을 통해 호출부에서 수행할 수 있게 되었습니다. 깊게 중첩된 콜백들에게 빼앗겼던 제어권을 호출부로 되찾아 온 겁니다.
프로미스는 앞서 언급한 콜백의 믿음성 문제를 해결할 수 있도록 설계되었습니다.
같은 작업인데도 콜백이 어떨 때는 동기적으로, 어떨 때는 비동기적으로 호출되고 종결되어 경합 조건(race condition)에 이르는 경우가 있습니다. 아까도 말한 것처럼, 콜백을 받는다고 무조건 비동기 함수처럼 작동하리란 보장은 없습니다!
function result(data) {
console.log(a);
}
var a = 0;
ajax("https://zum.com/", result);
a++;
콘솔의 결과는 0 (동기적 콜백 호출)일까요, 1(비동기적 콜백 호출)일까요? 이런 경우, 비동기 호출처럼 작동하게끔 통일하여 로직을 예상 가능하도록 가져가는 것이 좋습니다.
프로미스는 복잡한 관용코드(boilerplate code)없이 로직을 비동기적으로 가져갈 수 있게끔 설계되었습니다.
// 즉시 값(69)를 넣어 바로 이루어져도 then에 걸린 로직은 비동기적으로 호출된다.
new Promise(function(resolve) {
resolve(69);
}).then((value) => {
console.log(value);
});
console.log("call");
// call
// 69
// 즉, 항상 비동기적으로 호출되므로 경합조건을 예방
var a = 0;
new Promise(function(resolve) {
resolve(ajax("https://zum.com/"));
}).then((data) => {
console.log(a);
});
a++;
// 항상 a의 값은 1
프로미스는 알아서 경합조건을 예방합니다.
어떤 콜백이 너무 늦게 호출되는 경우 이 콜백보다 더 늦게 호출되기로 의도된 콜백이 먼저 호출되는 경우가 있습니다.
그러나 프로미스 인스턴스의 경우 귀결되면 then
으로 등록된 콜백들이 다음 비동기 기회가 찾아왔을 때 등록된 순서대로 실행됩니다.
// p는 프로미스 인스턴스
p.then(function() {
p.then(function() {
console.log("C");
});
console.log("A");
});
p.then(function() {
console.log("B");
});
//A B C
위의 경우 "B" 를 로깅하는 콜백이 너무 늦게 호출되더라도 "C"를 로깅하는 콜백이 먼저 앞서는 경우(콘솔 결과 A C B)는 없습니다.
만약 로직이 잘못되었거나, 네트워크의 상태가 좋지 않다는 등의 이유로 비동기 로직이 종결되지 않아 프로미스가 귀결되지 않는 경우가 있습니다. 이 경우 then
에 걸려있는 콜백이 호출되지 않습니다.
이 경우 경합(race)를 추상화한 Promise.race
를 사용한 프로미스 타임아웃 패턴을 이용해 해결할 수 있습니다.
// 프로미스를 타임아웃시키는 유틸리티
function timeoutPromise(delay) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
reject("타임아웃!");
}, delay);
});
}
// foo()도 프로미스를 반환하고, timeoutPromise(3000) 3초 후 반환되는 프로미스를 반환한다
// foo()의 프로미스가 3초 안에 귀결되면 Promise.race의 프로미스는 이행으로 귀결
// 3초 후 timeoutPromise(3000)의 프로미스가 귀결되면 Promise.race의 프로미스는 거부로 귀결
Promise.race([foo(), timeoutPromise(3000)]).then(
function() {
// foo()의 프로미스가 제 시간에 이행으로 귀결되었다!
},
function(err) {
// foo()의 프로미스가 거부로 귀결되거나 제 시간에 귀결되지 못해
// 타임아웃 프로미스가 먼저 거부로 귀결되었다.
}
);
프로미스는 이행이든 거부든 최초 단 한 번만 귀결됩니다. 어떤 이유로 프로미스 코드가 여러 번 이행, 거부로 귀결되려고 들면 최초의 귀결만 취하고 나머지는 조용히 무시합니다.
딱 한 번만 귀결되기 때문에 then
에 등록된 콜백 또한 각각 한 번 씩만 호출됩니다.
// 1초마다 value 값을 1씩 증가시키고 value를 귀결시키고자 시도하는 프로미스
const promise = new Promise(function(resolve, reject) {
let value = 0;
setInterval(function() {
value++;
console.log(value, "value in Promise");
resolve(value);
}, 1000);
});
// 프로미스의 귀결 값을 1초마다 로깅한다.
setInterval(function() {
promise.then(function(value) {
console.log(value, "value in then callback");
});
}, 1000);
// 그러나 프로미스 귀결 값은 최초의 귀결 이후 바뀌지 않는다.
// 1 value in Promise
// 1 value in then callback
// 2 value in Promise
// 1 value in then callback
// 3 value in Promise
// 1 value in then callback
// 4 value in Promise
// 1 value in then callback
// 5 value in Promise
// 1 value in then callback
// ......
콜백식 코드는 약속 잡기도 복잡하고 당일 파투 내기도 하는 미덥지 않은 친구 같은 코드라고 한다면, 프로미스 식 코드는 순차성(sequentiality), 믿음성(trustability) 을 보장하는 믿음직한 친구 같은 코드라고 할 수 있습니다. 그러므로 적극적으로 활용하는 것이 좋습니다!
이 글을 통해 앞으로 더 많은 자바스크립트 개발자 동료 여러분들이 프로미스를 왜 사용하는지 잘 아는 상태에서 프로미스를 적절하게 사용하여 자바스크립트 비동기 코드를 더욱 효과적으로 계획/관리하기룰 바랍니다.
다음 글에는 간단한 프로미스를 직접 구현해보며 이 마법 같은 프로미스가 어떻게 작동하는지 연구해보도록 하겠습니다.
의견이 있으시면 댓글로 달아주시고, 좋게 읽으셨다면 하트를 눌러주세요. 댓글과 하트는 제게 힘이 됩니다!
줌인터넷에서 블로그 스터디를 운영하고 있습니다.
안녕하세요
먼저 좋은 글 너무 감사합니다 :)
다름이 아니라 간단한 건데 제가 헷갈리는 거 같아서 질문 남깁니다...ㅠㅠ
본문의 내용 중에
프로미스 인스턴스는 내부적으로 뭔가 시간이 제법 걸리는 일을 하고(비동기 요청은 으레 그런 법이죠. 이때는 대기(pending) 상태에 있습니다.) 결국 이행(fulfilled)하거나 거부(rejected)로 귀결됩니다. 그러면 프로미스 인스턴스에 체이닝되어있는 then 은 이를 수신하고 걸려있는 로직을 비동기적으로 수행합니다.
위의 부분에서 then은 이를 수신하고 걸려있는 로직을 비동기적으로 수행한다고 나와있는데 이행 또는 거부가 걸렸을 때 then이 수행되고 또 then이 수행되고 이런식으로 순차적으로 수행되는 거 같은데 비동기적으로 수행된다는 뜻은 예시처럼
"call"이 먼저 출력되고 "69"가 출력되는 거처럼 추후 출력되는 부분(then에 걸린 로직)을 비동기적으로 수행된다고 하는 게 맞나요?
제가 동기적이라는 단어를 순차적으로 생각해서인지 then에 의해서 먼가 동기적으로 수행된다고 생각하는 거 같아서 헷갈리네요.
질문이 깔끔하지 못해 이해가 되실 지 모르겠는데 혹시 제가 잘못 이해한 부분이 있으면 설명해주시면 감사하겠습니다.
항상 좋은 글 감사합니다!