Skip to content

콜백의 진짜 문제는 무엇이고, 프로미스는 뭘 해결했나 ​

이벤트 루프를 이해했다면 답할 수 있는 문제부터.

javascript
let g = 0;

setTimeout(() => { g = 100; }, 0);
console.log(g); // ?

답은 0이다. 콜백은 현재 코드가 다 끝난 뒤에야 실행되니, 비동기 처리의 결과는 반환값으로 받을 수도, 변수에 담아 쓸 수도 없다. 결과가 필요한 후속 처리를 전부 콜백 안으로 밀어 넣는 수밖에 없다.

그런데 후속 처리가 또 비동기라면? 콜백 안에 콜백이 들어간다. "post를 받아서 → 그 userId로 user를 받아서 → …"처럼 의존 관계가 이어지면 이렇게 된다.

javascript
get('/step1', a => {
  get(`/step2/${a}`, b => {
    get(`/step3/${b}`, c => {
      get(`/step4/${c}`, d => {
        console.log(d);
      });
    });
  });
});

콜백 헬(callback hell)이다. 예전에 공부하면서 알게 됐는데, 사실 들여쓰기는 부차적인 문제다.

진짜 문제는 에러가 샌다는 것 ​

콜백 방식의 치명타는 이쪽이다.

javascript
try {
  setTimeout(() => { throw new Error('Error!'); }, 1000);
} catch (e) {
  console.error('캐치한 에러', e); // 영원히 실행되지 않는다
}

에러가 절대 잡히지 않는다. 이벤트 루프 글의 지식으로 설명하면 — 콜백이 실행되는 시점에 try 블록은 이미 콜 스택에서 사라진 지 오래다. 콜백은 태스크 큐를 거쳐 완전히 새로운 실행 흐름에서 시작되므로, 그 안에서 던진 에러를 바깥의 try/catch가 잡을 길이 없다. 콜백 헬에서는 단계마다 에러 콜백을 따로 받는 식으로 수습해야 하는데, 4단계 중첩이면 에러 처리도 4벌이다.

한 가지 더, 콜백은 제어권을 넘기는 방식이다. 내 후속 처리를 남의 함수에 인자로 맡기면, 그 함수가 콜백을 한 번 부를지 두 번 부를지 안 부를지 보장할 방법이 없다.

Promise: 미래의 결과를 담는 객체 ​

ES6의 프로미스는 발상을 뒤집는다. 후속 처리를 넘기는 대신, 비동기 처리의 미래 결과를 담을 객체를 먼저 돌려준다.

javascript
const promiseGet = url => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.send();

    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.response)); // 성공 — 결과를 담는다
      } else {
        reject(new Error(xhr.status));     // 실패 — 이유를 담는다
      }
    };
  });
};

const promise = promiseGet('/posts/1'); // 결과를 "담을 그릇"이 즉시 반환된다

(참고로 이 예제는 상태 코드 실패만 처리한다. 네트워크 자체가 끊기면 onload가 아예 호출되지 않아 프로미스가 영원히 대기 상태로 남으므로, 실전에서는 onerror에서도 reject해야 한다.)

이 객체는 세 가지 상태 중 하나에 있다.

Promise객체는 3개의 상태(pending, fulfilled, rejected)가 있다.

이 구조가 콜백의 문제들을 어떻게 푸는지가 핵심이다. 결과가 객체에 저장되므로 값처럼 다룰 수 있다 — 반환할 수 있고, 변수에 담을 수 있고, 나중에 then을 걸어도 저장된 결과를 받는다. 상태는 한 번 정해지면 불변이라 콜백이 두 번 불리는 사고도 없다.

에러는 체인을 타고 흐른다 ​

then은 항상 새 프로미스를 반환한다. 콜백이 값을 반환하면 그 값으로 fulfilled된 프로미스가, 프로미스를 반환하면 그 프로미스를 그대로 따르는 프로미스가, 에러를 던지면 rejected 프로미스가 나온다. 그래서 중첩 대신 평평하게 이어 쓸 수 있다.

javascript
promiseGet('/posts/1')
  .then(post => promiseGet(`/users/${post.userId}`)) // 프로미스를 반환하면 이어진다
  .then(user => console.log(user.name))
  .catch(err => console.error(err)); // 위 어디서 실패해도 여기로 온다

콜백 헬과 비교해 보면 들여쓰기보다 중요한 변화가 보인다. 어느 단계에서 실패하든 rejection이 체인을 타고 내려와 마지막 catch 한 곳에 모인다. 에러 처리 4벌이 1벌이 됐다. catch(fn)는 사실 then(undefined, fn)의 축약인데, 두 번째 인자 방식과 달리 자기 앞 then 콜백에서 던진 에러까지 잡아주므로 체인 끝의 catch가 관례가 됐다. 성공/실패와 무관하게 실행할 마무리는 finally에 건다.

덧붙이면, 이 콜백들이 실행되는 곳이 지난 글의 마이크로태스크 큐다. 프로미스가 이미 settled여도 then 콜백은 동기로 실행되지 않고 반드시 큐를 거친다.


프로미스는 콜백을 없앤 게 아니다. then에 넘기는 것도 여전히 콜백이니까. 바뀐 것은 비동기 결과가 "값"이 됐다는 것이다 — 담을 수 있고, 돌려줄 수 있고, 이어 붙일 수 있고, 에러를 한곳에 모을 수 있는.

그래도 아쉬움은 남는다. then 체인은 여전히 "다음에 할 일"을 콜백으로 쓰는 문법이라, 동기 코드처럼 위에서 아래로 읽히지는 않는다. 이걸 동기 코드의 모양으로 되돌려 준 것이 async/await이다. → async/await은 어떻게 동작하나