비동기 일을 순서대로 이어야 할 때 콜백을 겹쳐 쓰면 코드가 오른쪽으로 계단처럼 밀려난다. 첫 일이 끝난 콜백 안에서 둘째를 시작하고, 그 콜백 안에서 셋째를 시작하는 콜백 지옥이다. 이 지옥을 풀려고 나온 게 프로미스(Promise, 약속)다. 나는 처음엔 프로미스가 콜백을 예쁘게 감싼 껍데기인 줄 알았는데, 쓸수록 비동기 흐름을 값처럼 손에 쥐고 다루게 해주는 물건이라는 걸 알았다.
이번 편은 프로미스가 무엇을 약속하는지, 결과를 어떻게 꺼내는지, 여러 단계를 어떻게 이어 붙이는지, 에러를 어디서 잡는지를 코드를 돌려 실제 출력으로 확인한다. 특히 실행 순서가 헷갈리는 지점이 있어서, console.log가 찍히는 순서를 그대로 따라가는 데 공을 들였다.
프로미스는 미래 값의 약속이다. 프로미스는 지금은 없지만 나중에 생길 값을 담아두는 상자다. 서버 응답처럼 언제 올지 모르는 값을 이 상자에 미리 담아두고, 값이 도착하면 그때 꺼내 쓴다. 상자는 세 상태 중 하나다. 아직 결과가 안 나온 대기(pending, 진행 중), 성공해 값이 담긴 이행(fulfilled, 성공), 실패한 거부(rejected, 실패)다. 한번 이행이나 거부로 정해지면 다시는 안 바뀐다. 이 확정성 덕에 결과가 언제 오든 안심하고 붙잡아둘 수 있다.
then으로 값이 도착하면 받는다. 프로미스는 함수 하나를 넘겨 만든다. 그 함수는 resolve와 reject 두 개를 받는데, 성공하면 resolve에 값을 실어 부르고 실패하면 reject를 부른다. 이렇게 만든 프로미스에서 값을 꺼낼 땐 then에 콜백을 건네준다. 값이 도착하는 순간 그 콜백이 값을 받아 실행된다.
const p = new Promise((resolve) => {
setTimeout(() => resolve("데이터 도착"), 100);
});
console.log("요청 보냄");
p.then((result) => console.log("받음:", result));
console.log("다음 줄");
실행 결과다.
요청 보냄
다음 줄
받음: 데이터 도착
순서가 핵심이다. then을 위에 적었는데도 받음이 맨 나중에 나온다. then에 넘긴 콜백은 지금 실행되는 게 아니라, 프로미스가 이행될 때까지 맡겨두는 예약이기 때문이다. 예약만 걸어두고 자바스크립트는 기다리지 않고 다음 줄로 곧장 가서 다음 줄을 찍는다. 100밀리초 뒤 값이 도착하고, 그제야 맡아둔 콜백이 받음을 낸다. 콜백을 맡겨둔다는 감각은 그대로인데, 그 콜백을 프로미스 바깥에서 붙인다는 점이 달라졌다.
프로미스 콜백은 타이머보다 먼저 끼어든다. 여기서 초보가 반드시 한 번 걸려 넘어지는 순서 문제가 있다. 대기 시간이 똑같이 0인 setTimeout과 프로미스를 나란히 두면 어느 쪽이 먼저일까.
console.log("1 동기 시작");
setTimeout(() => console.log("2 타이머"), 0);
Promise.resolve().then(() => console.log("3 프로미스"));
console.log("4 동기 끝");
실행 결과다.
1 동기 시작
4 동기 끝
3 프로미스
2 타이머
동기 코드인 1과 4가 먼저 나오는 건 예상대로다. 그런데 나중에 적은 프로미스가 먼저 적은 타이머를 제치고 앞서 나온다. 이유는 대기줄이 두 종류이기 때문이다. 프로미스 콜백이 서는 줄은 마이크로태스크 큐(microtask queue, 우선 처리 대기줄)라는 별도의 빠른 줄이고, setTimeout 콜백이 서는 줄은 그보다 뒤에 처리되는 일반 대기줄이다. 동기 코드가 끝나면 이벤트 루프는 빠른 줄부터 싹 비운 다음에야 일반 줄로 넘어간다. 그래서 지연이 0이라도 프로미스가 항상 먼저다. 나는 이걸 모르고 타이머로 순서를 맞추려다, 프로미스가 자꾸 앞질러 나오는 걸 버그로 착각해 한참 헤맸다.
then을 이어 붙여 지옥을 편다. 프로미스의 진짜 힘은 여기서 나온다. then이 돌려주는 것도 또 프로미스라, then을 점으로 계속 이어 붙일 수 있다. 콜백 안으로 파고들던 계단을 세로로 쭉 편 모양이 된다. 앞 then에서 return한 값이 다음 then의 콜백으로 그대로 넘어간다.
Promise.resolve(1)
.then((n) => { console.log("첫째:", n); return n + 1; })
.then((n) => { console.log("둘째:", n); return n + 1; })
.then((n) => { console.log("셋째:", n); });
실행 결과다.
첫째: 1
둘째: 2
셋째: 3
값이 1에서 2, 3으로 한 단씩 넘어가며 위에서 아래로 순서대로 흐른다. 콜백 지옥이 오른쪽으로 밀려나던 걸 떠올리면, 같은 세 단계가 세로로 나란히 서니 읽기가 딴판이다. 로그인하고 그 결과로 프로필을 받고 다시 그걸로 글 목록을 받는 흐름을, 안으로 파고들지 않고 점 세 개로 이을 수 있다는 게 프로미스가 콜백 지옥을 푸는 방식이다.
에러는 체인 끝의 catch 하나로 몬다. 콜백 방식의 최악은 단계마다 실패를 따로 확인해야 한다는 거였다. 프로미스는 이걸 catch 하나로 정리한다. 체인 중간 어디서든 에러가 나면, 뒤의 then들을 전부 건너뛰고 곧장 catch로 흐른다.
Promise.resolve()
.then(() => { throw new Error("2단계 실패"); })
.then(() => console.log("이건 안 나옴"))
.catch((err) => console.log("잡음:", err.message))
.then(() => console.log("catch 뒤는 다시 실행"));
실행 결과다.
잡음: 2단계 실패
catch 뒤는 다시 실행
2단계에서 에러를 던지자 그 아래 이건 안 나옴 then은 통째로 뛰어넘고 catch가 잡았다. 열 단계짜리 체인이라도 catch 한 개면 중간의 어떤 실패든 다 받아낸다. 재밌는 건 catch 뒤에 then을 또 이으면 그건 다시 실행된다는 점이다. catch가 에러를 처리하고 나면 체인이 정상으로 되돌아오기 때문이다. 그러니 catch는 체인 맨 끝에 두는 게 보통이다.
여러 일을 한꺼번에 기다린다. 서로 상관없는 요청 세 개를 보낼 때, 하나씩 순서대로 기다리면 시간이 세 배로 든다. Promise.all은 여러 프로미스를 한 배열에 담아 동시에 출발시키고, 전부 이행되면 결과를 배열로 모아준다.
const wait = (ms, val) => new Promise((r) => setTimeout(() => r(val), ms));
console.time("걸린시간");
Promise.all([wait(100, "가"), wait(100, "나"), wait(100, "다")])
.then((results) => {
console.log("전부 도착:", results);
console.timeEnd("걸린시간");
});
실행 결과다.
전부 도착: [ '가', '나', '다' ]
걸린시간: 106ms
각 100밀리초짜리 일 셋을 합쳐도 300이 아니라 100 남짓에 끝났다. 셋을 한꺼번에 출발시켜 나란히 기다렸기 때문이다. 결과 배열의 순서는 도착한 순서가 아니라 넣은 순서 그대로라, 어느 게 어느 결과인지 헷갈릴 일이 없다. 다만 Promise.all은 하나라도 거부되면 즉시 전체가 거부되니, 일부 실패를 견뎌야 하면 Promise.allSettled(성공 실패 상관없이 전부 기다림)를 쓴다.
내가 return을 빠뜨려 날린 반나절. 체인을 처음 쓸 때 나는 then 안에서 다음으로 넘길 값에 return을 자꾸 빠뜨렸다. 앞 then에서 return을 안 하면 다음 then의 콜백에는 값 대신 undefined(값 없음)가 넘어온다. 코드는 에러 없이 멀쩡히 돌고 화면만 텅 비니, 원인이 값이 안 넘어간 거라는 걸 알아채기까지 오래 걸렸다. 프로미스 안에서 비동기 함수를 부를 때도 그 결과 프로미스를 return해야 체인이 그걸 기다린다. 안 그러면 아직 안 끝난 일을 두고 다음 단계가 먼저 달려가 버린다. then 안에서 다음으로 뭔가를 넘기려면 반드시 return한다는 습관을 들이고서야 이 삽질에서 벗어났다.
정리하면 프로미스는 미래에 생길 값을 담는 상자이고, 대기에서 이행이나 거부로 딱 한 번 확정된다. 결과는 then으로 받고, 여러 단계는 then을 이어 붙여 세로로 펴며, 에러는 체인 끝의 catch 하나로 몬다. 여럿을 동시에 기다릴 땐 Promise.all이다. 콜백을 상자 바깥에서 붙이고 체인으로 잇는 이 방식만 손에 익으면, 안으로 파고들던 지옥 없이 비동기 흐름을 위에서 아래로 읽을 수 있다. 프로미스는 값이지, 파묻히는 콜백이 아니다.