Frontend 26
비동기 코드, 혼란을 넘어 명확함으로
코드는 위에서 아래로 실행된다고 배웠는데 setTimeout, Promise, await가 나오면 실행 순서가 갑자기 흔들려 보인다.
근거 · 교안 p188-p198
1장: 코드, 예상과 다르게 흘러갈 때: 비동기의 첫 만남
솔라의 손가락이 노트북 트랙패드 위에서 멈췄다. 화면에는 몇 줄 안 되는 코드가 떠 있었지만, 그 결과는 솔라가 아는 모든 상식을 비웃는 듯했다. 코드는 위에서 아래로, 한 줄씩 순서대로 실행되는 것 아니었던가? 이것이 솔라가 처음 코딩을 배울 때부터 단단히 붙들고 있던, 세상의 유일한 규칙이었다.
하지만 지금 콘솔 창에 찍힌 결과는 그 규칙을 배신하고 있었다.
// 솔라가 실행한 코드
console.log("주문 시작");
setTimeout(function() {
console.log("2초 후: 커피 만들기 완료");
}, 2000);
console.log("다음 손님 주문 받기");
// 실제 출력 결과
주문 시작
다음 손님 주문 받기
2초 후: 커피 만들기 완료
“말이 안 돼.”
혼잣말과 함께 솔라는 의자 등받이에 몸을 기댔다. 분명 ‘커피 만들기 완료’가 중간에 있어야 했다. 2초를 기다린 다음에. 하지만 코드는 그 줄을 건너뛰고 마지막 줄을 먼저 실행해 버린 뒤, 한참 후에야 건너뛰었던 줄을 마지못해 실행한 것처럼 보였다. 마치 멋대로 순서를 바꾸는 변덕쟁이 같았다.
“순서가 왜 이래? 무작위로 실행되는 건가?”
그때, 소파에서 조용히 책을 읽고 있던 루나가 솔라 쪽으로 시선을 돌렸다. 솔라의 혼잣말에 담긴 진득한 혼란을 감지한 것이다. 루나는 책을 덮고 조용히 일어나 솔라의 옆으로 다가왔다.
“무엇이 무작위 같아?”
“언니, 이것 좀 봐.”
솔라가 모니터를 가리켰다.
“코드가 내 생각대로 안 움직여. ‘주문 시작’을 출력하고, 2초를 기다렸다가 ‘커피 만들기 완료’를 출력하고, 그다음에 ‘다음 손님’으로 넘어가야 하잖아. 그런데 2초를 기다리는 애를 그냥 무시하고 다음으로 넘어가 버렸어. 왜 기다리질 않는 거야?”
솔라의 목소리에는 코드에 대한 배신감마저 묻어났다. 자신의 확고한 모델이 눈앞에서 깨지는 순간이었다.
루나는 잠시 화면을 들여다보더니, 말없이 솔라의 의자를 돌려 자신을 보게 했다. 그리고는 아주 간단한 질문을 던졌다.
“네가 카페 바리스타라고 생각해 봐. 손님에게 2분 걸리는 커피를 주문받았어. 그럼 너는 커피 머신 앞에서 2분 동안 아무것도 안 하고 멀뚱히 서서 기다릴 거야, 아니면 그사이에 다른 손님의 주문을 받을 거야?”
“다른 손님 주문을 받아야지. 2분 동안 가만히 서 있으면 줄이 엄청 길어질 텐데.”
솔라는 너무나 당연하다는 듯이 대답했다.
“바로 그거야.”
루나가 짧게 답하고 다시 모니터를 가리켰다.
“여기서 setTimeout은 ‘2초가 걸리는 커피 만들기’라는 오래 걸리는 작업이야. 자바스크립트는 효율적인 바리스타와 같아서, 그 작업이 끝날 때까지 멍하니 기다리지 않아. 일단 ‘커피 만들기’라는 주문을 다른 곳(백그라운드)에 맡겨두고, 바로 다음 줄로 넘어가서 할 수 있는 일, 즉 ‘다음 손님 주문 받기’를 먼저 처리하는 거지.”
솔라의 눈이 동그래졌다. ‘무작위’나 ‘오류’라고 생각했던 코드의 동작이, 루나의 비유 한마디에 ‘효율적인 전략’으로 보이기 시작했다. 코드가 게으르거나 변덕스러워서 순서를 건너뛴 게 아니었다. 전체 흐름이 멈추는 것을 막기 위해, 기다릴 필요가 없는 작업을 먼저 처리한 것이었다.
“아… 그럼 저 setTimeout은 ‘2초 동안 여기서 멈춰!’가 아니라, ‘이 작업은 2초 뒤에 해줘. 나는 먼저 갈게!’라는 뜻이구나.”
솔라는 다시 자신의 코드를 찬찬히 뜯어보았다. 이제는 그 흐름이 보였다.
console.log("주문 시작");: 바리스타가 일을 시작한다.setTimeout(..., 2000);: 2초가 걸리는 커피 제조를 기계에 맡기고 타이머를 맞춘다. 바리스타는 바로 자리를 뜬다.console.log("다음 손님 주문 받기");: 커피가 만들어지는 동안 다른 손님의 주문을 받는다.- (2초 후) : 타이머가 울리자, 기계가 커피가 완성되었다고 알린다.
console.log("2초 후: 커피 만들기 완료");가 실행된다.
처음의 혼란은 사라지고 명확한 질서가 보였다. 비동기는 코드를 무작위로 만드는 마법이 아니라, 시간이 걸리는 작업을 ‘기다리지 않고’ 프로그램의 흐름을 유지하는 약속이었던 것이다.
“이제 ‘비동기는 시간이 걸리는 작업을 기다리지 않고 다음 줄로 넘어간다’는 말이 이해가 돼.”
솔라가 중얼거렸다. 이전에는 혼란스럽기만 했던 문장이 이제는 자신이 방금 목격한 현상을 정확히 설명하는 문장으로 다가왔다. 오래 걸리는 작업 때문에 프로그램 전체가 멈춰버리는 ‘흐름 중단’을 피하려는 영리한 방법이었다.
하지만 새로운 그림이 그려지자, 그림의 비어 있는 부분이 눈에 들어왔다.
“알겠어. 기다리지 않고 넘어가는 건 좋은데… 만약 다음 작업이 방금 맡긴 그 커피에 의존하면 어떡해? ‘커피 만들기 완료’가 되어야만 ‘손님에게 커피 전달하기’를 할 수 있잖아. 저렇게 따로 놀면, 커피가 언제 완성될 줄 알고 다음 일을 하지? 완성된 결과를 어떻게 가져와서 쓰는 거지?”
솔라의 손가락은 다시 키보드 위에 올라갔지만, 다음 코드를 입력하지는 못했다. 예상치 못한 실행 순서의 미스터리는 풀렸지만, 이제는 그 분리된 작업의 결과를 어떻게 다시 합칠 수 있는지에 대한 새로운 질문이 떠오르고 있었다.
2장: 미래의 약속: Promise로 비동기 결과 다루기
솔라의 노트북 화면에는 코드가 몇 줄 더 늘어나 있었다. 어제의 혼란을 해결한 뒤, 솔라는 곧장 다음 문제에 부딪혔다. ‘손님에게 커피 전달하기’라는 마지막 단계를 추가해야 했다. 일단 해결책을 찾기는 했다. setTimeout 함수 안에 다음 작업을 위한 코드를 집어넣는 것이었다.
console.log("주문 시작");
setTimeout(function() {
console.log("2초 후: 커피 만들기 완료");
// 커피가 완성된 후에 할 일
console.log("손님에게 커피 전달하기");
}, 2000);
console.log("다음 손님 주문 받기");
코드는 의도대로 동작했다. ‘커피 만들기 완료’가 출력된 직후 ‘손님에게 커피 전달하기’가 나타났다. 하지만 솔라는 썩 만족스럽지 않은 표정으로 턱을 괴었다. 해결은 됐지만, 어딘가 찜찜했다.
“만약에… 커피를 전달한 다음에, 손님에게 ‘맛있게 드세요’라고 인사하는 작업이 추가되면 어떡하지? 그럼 이 안으로 또 코드가 들어가야 하잖아. 그러다 실수가 생기면? 오류는 어디서 잡지? 계속 이렇게 안으로, 안으로 파고들어가야 하나?”
작업이 추가될수록 코드가 끝없이 안쪽으로 파고드는 ‘들여쓰기 괴물’이 머릿속에 그려졌다. 솔라의 중얼거림을 들은 루나가 다가와 솔라의 화면을 들여다봤다. 루나는 솔라의 고민이 담긴 코드 블록을 잠시 보더니, 말없이 주방으로 향했다. 잠시 후, 루나는 작은 메모지 한 장을 들고 돌아왔다.
메모지에는 이렇게 쓰여 있었다. [따뜻한 라떼 한 잔, 2분 후 찾아가세요.]
루나는 그 메모지를 솔라의 노트북 옆에 가만히 내려놓았다.
“이게 네가 주문한 커피라고 생각해 봐. 지금 당장 커피를 받은 게 아니지. 대신 넌 뭘 받았지?”
“음… 커피 교환권? 아니면 주문 영수증 같은 거?”
솔라가 메모지를 가리키며 대답했다.
“맞아. 커피 그 자체가 아니라, ‘잠시 후 커피가 완성될 것’이라는, 혹은 ‘문제가 생겨서 커피를 못 줄 수도 있다’는 결과가 담길 ‘약속 증서’를 받은 거야.”
루나는 그 ‘약속 증서’라는 단어에 힘을 주어 말했다.
“자바스크립트의 Promise가 바로 그 약속 증서야. 비동기 작업이 요청되면, 자바스크립트는 결과물 대신 Promise라는 객체를 먼저 던져줘. 그 객체는 일종의 상자 같은 거야. 처음엔 비어있지. 이걸 ‘대기중(pending)’ 상태라고 불러.”
루나는 솔라의 코드에서 setTimeout 부분을 손가락으로 가리켰다.
“그러다 네가 맡긴 작업, 즉 커피 만들기가 성공적으로 끝나면, 약속이 이행된 거지. Promise 상자 안에는 따뜻한 커피, 즉 결과값이 채워져. 이게 ‘이행됨(fulfilled)’ 상태야. 만약 원두가 다 떨어져서 커피를 만들 수 없다면? 약속이 거절된 거고, 상자 안에는 ‘커피를 만들 수 없어요’라는 실패 이유가 담기겠지. ‘거절됨(rejected)’ 상태로 말이야.”
솔라의 눈이 반짝였다. 함수 안에 함수를 계속 집어넣는 대신, ‘약속 증서’라는 객체를 주고받는다는 발상이 훨씬 깔끔하게 느껴졌다. 결과가 나올 때까지 하염없이 기다리거나, 복잡한 구조 속에 갇힐 필요가 없었다. 약속 증서만 손에 쥐고 있으면, 나중에 결과가 어떻게 됐는지 확인만 하면 되니까.
“그럼 내 코드를 그 ‘약속 증서’를 사용하는 방식으로 바꿔볼 수 있을까?”
“직접 해봐. ‘커피 만들기’ 작업을 Promise라는 상자에 담아보는 거야.”
솔라는 루나의 안내에 따라 코드를 수정하기 시작했다. 먼저 setTimeout으로 비동기 상황을 흉내 내던 부분을 Promise 객체를 생성해서 감쌌다.
// '커피 만들기'라는 비동기 작업을 Promise로 만들기
const makeCoffee = new Promise((resolve, reject) => {
// 2초가 걸리는 작업 시뮬레이션
setTimeout(() => {
// 50% 확률로 커피 만들기 실패 시뮬레이션
if (Math.random() < 0.5) {
reject("죄송합니다, 원두가 다 떨어졌어요.");
} else {
resolve("커피 만들기 완료");
}
}, 2000);
});
new Promise는 실행되자마자 내부에 있는 함수를 실행했다. setTimeout이 다시 등장했지만, 이번엔 역할이 달랐다. 2초 후에 성공을 알리는 resolve나 실패를 알리는 reject 둘 중 하나를 호출하는 역할이었다. makeCoffee라는 변수에는 이제 커피 자체가 아닌, ‘커피가 곧 나올 것이라는 약속’이 담겼다.
“좋아, 약속 증서는 받았어. 이제 이 증서를 어떻게 사용하지? 커피가 완성됐을 때 ‘손님에게 전달하기’를, 실패했을 때 ‘손님에게 사과하기’를 하고 싶은데.”
“약속 증서에는 두 가지 버튼이 있다고 상상해 봐. ‘성공 시 할 일’을 등록하는 .then() 버튼과 ‘실패 시 할 일’을 등록하는 .catch() 버튼.”
루나의 말에 솔라는 즉시 코드를 이어 나갔다.
console.log("주문 시작");
makeCoffee
.then((result) => {
console.log(result); // Promise의 resolve가 전달한 값: "커피 만들기 완료"
console.log("손님에게 커피를 전달합니다.");
})
.catch((error) => {
console.error(error); // Promise의 reject가 전달한 값: "죄송합니다, 원두가 다 떨어졌어요."
console.log("주문을 취소하고 환불해드립니다.");
});
console.log("다음 손님 주문 받기");
코드를 실행하자, 결과는 두 가지 중 하나로 나타났다. 성공하면 순서대로 커피 완료와 전달 메시지가 떴고, 실패하면 원두가 떨어졌다는 오류와 환불 메시지가 떴다. 어느 쪽이든 맨 아래의 ‘다음 손님 주문 받기’는 2초를 기다리지 않고 먼저 실행되었다.
솔라는 자신의 코드를 보며 만족스러운 미소를 지었다. 비동기 작업을 수행하는 부분과 그 결과를 처리하는 부분이 명확하게 분리되었다. 이제 작업이 백 개가 추가되어도 안으로 파고드는 들여쓰기 괴물을 만날 일은 없을 것 같았다. .then()으로 계속 약속을 이어가면 되니까.
‘Promise는 pending, fulfilled, rejected 상태를 가진다.’ 책에서 봤던 무미건조한 문장이 이제 살아있는 시스템으로 보였다. 약속이 시작되고(pending), 성공적으로 이행되거나(fulfilled), 혹은 어쩔 수 없이 거절되는(rejected) 일련의 흐름을 관리하는 아주 세련된 방법이었다.
“훨씬 깔끔하다. 이제 비동기 작업의 결과를 어떻게 다뤄야 할지 알겠어. 약속을 하고, 그 약속의 결과를 .then()이나 .catch()로 받으면 되는 거네.”
솔라는 자신감이 붙었다. 그럼 만약 커피를 만들고, 그 커피에 시럽을 추가하는 작업(이것도 1초가 걸리는 비동기 작업이라고 가정하자)이 이어진다면?
“음, 첫 번째 then에서 시럽을 추가하는 새로운 Promise를 만들고… 그걸 다시 then으로 받으면 되겠다.”
솔라는 중얼거리며 키보드를 두드렸다. 코드가 조금씩 길어지고 있었다. .then()이 또 다른 .then()을 부르는 구조가 만들어졌다. 분명 콜백 지옥보다는 훨씬 나았지만, 여전히 체인이 길어지자 살짝 복잡해 보였다.
“이게 최선인가? 약속을 계속 이어가는 건 좋은데… 이걸 좀 더 동기 코드처럼, 그냥 위에서 아래로 읽히게 쓸 수는 없을까?”
3장: 동기처럼 읽는 비동기: async/await와 에러 처리
솔라의 화면에는 코드가 어제보다 더 길게 늘어져 있었다. 커피를 만드는 Promise에 이어, 시럽을 추가하는 또 다른 Promise를 연결한 참이었다. 첫 번째 .then 블록이 두 번째 .then 블록을 호출하는 계단식 구조였다.
// 커피를 만들고 시럽을 추가하는 Promise 체인
makeCoffee()
.then(result => {
console.log(result);
return addSyrup(result); // 시럽을 추가하는 새로운 Promise를 반환
})
.then(finalResult => {
console.log(finalResult);
console.log("손님에게 모든 것이 완료된 커피를 전달합니다.");
})
.catch(error => {
console.error(error);
});
코드는 정상적으로 작동했지만, 솔라의 미간은 펴질 줄 몰랐다. 이 계단식 구조는 비동기 작업의 순서는 보장했지만, 코드를 읽는 흐름까지 동기 코드처럼 자연스럽게 만들지는 못했다. 오류 처리 역시 맨 마지막 .catch 한 곳에 묶여 있어, 어느 계단에서 발을 헛디뎠는지 한눈에 파악하기도 까다로워 보였다.
솔라의 답답함 섞인 한숨을 들은 루나는 소파에서 일어나, 솔라가 뚫어져라 쳐다보고 있는 코드 옆에 섰다. 루나는 .then()으로 이어진 두 개의 블록을 손가락으로 가리켰다.
“약속 증서를 받고, ‘성공하면 이걸 해줘’라고 등록하는 방식 대신, 그냥 약속의 결과가 나올 때까지 그 자리에서 잠시 ‘기다렸다가’ 다음 줄로 넘어가면 어떨까?”
“기다린다고? 그럼 프로그램이 멈추는 거 아니었어? 비동기를 쓰는 이유가 안 기다리려고 하는 거였잖아.”
솔라가 고개를 갸웃하며 반문했다. 처음 비동기를 만났을 때의 혼란이 다시 떠오르는 듯했다.
“맞아. 메인 흐름을 멈추지는 않아.”
루나는 솔라의 코드 아래에 새로운 함수를 작성하기 시작했다. function 키워드 앞에 async라는 낯선 단어를 붙였다.
“이 async는 마법 같은 표시야. 이 함수 안에서는 특별한 규칙, 즉 ‘기다림’을 허용하겠다는 선언이지. 그리고 그 기다림을 실행하는 명령이 바로 await야.”
루나는 솔라의 Promise 체인 코드를 async/await 구문으로 바꾸는 시범을 보였다.
async function serveCoffee() {
const coffeeResult = await makeCoffee(); // makeCoffee Promise가 끝날 때까지 기다린다.
console.log(coffeeResult);
const finalResult = await addSyrup(coffeeResult); // addSyrup Promise가 끝날 때까지 기다린다.
console.log(finalResult);
console.log("손님에게 모든 것이 완료된 커피를 전달합니다.");
}
솔라의 눈이 커졌다. .then()과 콜백 함수들이 사라지고, 코드가 위에서 아래로 순서대로 실행되는 익숙한 모습으로 돌아와 있었다. 마치 비동기 코드가 아닌 것처럼 보였다.
“잠깐만, 그럼 Promise는 이제 필요 없는 거야? async/await가 훨씬 좋은데?”
이건 Promise와는 완전히 다른 새로운 방식이라고, 솔라는 생각했다.
“아니, 잘 봐. await가 뭘 기다리고 있지?”
루나의 질문에 솔라는 코드를 다시 들여다봤다. await 키워드 바로 뒤에는 makeCoffee()와 addSyrup() 함수 호출이 있었다. 그 함수들은 바로 솔라가 조금 전까지 다루던 Promise 객체를 반환하는 함수들이었다.
“아… Promise를 기다리는 거구나.”
깨달음이 스쳤다. async/await는 Promise를 대체하는 기술이 아니었다. Promise라는 ‘약속 증서’를 더 읽기 쉽고 쓰기 편하게 다루기 위한 세련된 문법이었던 것이다. await는 약속 증서의 결과가 나올 때까지 함수의 실행을 잠시 멈추고 기다렸다가, 결과가 나오면(‘이행됨(fulfilled)’ 상태가 되면) 그 값을 꺼내 다음 코드를 실행했다.
“그럼 만약에 약속이 거절되면? 원두가 떨어지면 어떡하지? .catch()가 없잖아.”
“동기 코드에서 오류가 발생했을 때 보통 어떻게 처리하지?”
루나의 질문에 솔라는 잠시 생각하다 답했다. “try...catch 구문을 써.”
“바로 그거야.”
솔라는 곧장 자신의 async 함수를 수정했다. Promise가 실패할 가능성이 있는 await 호출 전체를 try 블록으로 감싸고, 그 아래 catch 블록을 추가했다.
async function serveCoffee() {
try {
const coffeeResult = await makeCoffee();
console.log(coffeeResult);
const finalResult = await addSyrup(coffeeResult);
console.log(finalResult);
console.log("손님에게 모든 것이 완료된 커피를 전달합니다.");
} catch (error) {
console.error(error); // makeCoffee나 addSyrup에서 reject가 발생하면 여기서 잡힌다.
console.log("주문을 취소하고 환불해드립니다.");
}
}
코드를 실행하고 makeCoffee 함수가 의도적으로 실패(reject)하도록 만들자, catch 블록 안의 오류 메시지가 정확히 출력되었다. 성공의 흐름과 실패의 흐름이 명확하게 분리되었다. 코드는 위에서 아래로 흐르고, 오류는 익숙한 try/catch로 처리된다. 비동기 코드의 복잡함이 사라지고, 명확성과 예측 가능성만 남았다.
‘async/await는 Promise 완료를 기다리고, try/catch는 실패를 처리한다.’
이제 이 문장은 솔라에게 너무나 당연한 사실로 다가왔다. await가 Promise를 기다린다는 것, 그리고 그 과정의 예외는 try/catch로 잡는다는 것. 분리되어 있던 개념들이 하나의 완성된 그림으로 맞춰졌다.
솔라는 깊은숨을 내쉬었다. 지난 며칠간 자신을 괴롭혔던 비동기라는 거대한 안개가 걷히는 기분이었다. 처음의 막막함은 사라지고, 이제는 손에 세 가지 도구가 들려 있었다. ‘흐름 중단 회피’를 알려준 setTimeout, ‘미래의 약속’을 관리하게 해준 Promise, 그리고 그 모든 흐름을 명쾌하게 제어하는 async/await.
문득, 이 모든 것을 배우기 시작하게 한 첫 문장이 떠올랐다. ‘서버와 대화하려면 시간이 걸리는 작업을 기다리는 법을 알아야 한다.’
솔라는 실제 웹 애플리케이션에서 서버 데이터를 가져오는 예제 코드를 찾아 화면에 띄웠다. 이전 같았으면 외계어처럼 보였을 코드였다.
async function fetchUser(userId) {
try {
const response = await fetch(`https://api.myapp.com/users/${userId}`);
if (!response.ok) {
throw new Error("네트워크 응답이 올바르지 않습니다.");
}
const userData = await response.json();
console.log("사용자 정보:", userData);
} catch (error) {
console.error("사용자 정보를 가져오는 데 실패했습니다:", error);
}
}
하지만 이제는 달랐다. 솔라는 코드를 차분히 읽어 내려갔다.
“async function… 이 함수는 비동기 작업을 다루겠구나. try 블록 안에서 await fetch… fetch가 서버에 요청을 보내는 Promise고, 응답이 올 때까지 여기서 기다리겠지. 응답이 실패하면 에러를 던지고 catch에서 잡힐 거고… 성공하면 await response.json(). 이것도 응답 데이터를 파싱하는 Promise니까 기다렸다가 userData에 담고, 마지막에 출력. 완벽해.”
혼잣말을 마친 솔라의 입가에 옅은 미소가 번졌다. 더 이상 비동기 코드는 예측 불가능한 혼돈이 아니었다. 시간이 걸리는 작업을 우아하게 기다리고, 그 성공과 실패까지 명확하게 다룰 수 있는 잘 짜인 약속의 세계였다. 솔라는 이제 그 세계의 언어를 이해할 수 있게 되었다.