1. 개요

비동기 처리는 콜백 → Promise → async/await 순으로 겉모습만 바뀐 게 아니다. 각 단계가 실제로 무슨 문제를 풀었는지 보면 async/await를 그냥 “예쁜 문법”으로만 알고 쓰다가 실수하는 걸 줄일 수 있다. await가 실행을 “멈추는” 것처럼 보이지만 실제로 스레드를 막지 않는다는 것도 짚는다.


2. 핵심 내용

2-1. 콜백 지옥

비동기 작업이 끝나면 콜백을 호출하는 방식은 작업을 순서대로 여러 개 엮을 때 중첩이 깊어진다.

getUser(id, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      console.log(comments);
    }, onError);
  }, onError);
}, onError);

에러 처리도 콜백마다 따로 넘겨야 한다. 순서를 바꾸거나 하나를 건너뛰려면 코드 구조 자체를 다시 짜야 한다.

2-2. Promise 가 푼 것: 체이닝과 에러 전파

Promise 는 “나중에 값이 온다”를 하나의 객체로 표현한다. .then으로 이어 붙이면 중첩 대신 평평한 체인이 된다.

getUser(id)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => console.log(comments))
  .catch((err) => console.error(err));

에러는 체인 어디서 터지든 가장 가까운 .catch로 전파된다. 콜백마다 에러 인자를 따로 챙길 필요가 없어졌다.

“나중에 값이 온다”를 객체 하나로 공유하는 이 성질은 스크립트 로딩에도 그대로 쓰인다. 위젯이 여러 개 떠도 외부 스크립트 태그는 한 번만 만들고, 그 로딩 결과를 모듈 수준 Promise 하나로 여러 호출부가 함께 기다리게 하는 패턴이 Turnstile 위젯 실전 체크리스트에 나온다.

2-3. async/await 가 푼 것: 동기 코드처럼 읽기

Promise 체이닝도 콜백보다는 낫지만 여전히 .then 함수 안에 로직을 쪼개 넣어야 한다. async/await는 Promise 체인을 동기 코드처럼 위에서 아래로 읽히게 해준다.

async function loadComments(id) {
  try {
    const user = await getUser(id);
    const posts = await getPosts(user.id);
    const comments = await getComments(posts[0].id);
    return comments;
  } catch (err) {
    console.error(err);
  }
}

try/catch로 동기 코드와 똑같이 에러를 잡을 수 있다. 사실 이건 Promise 위에 얹은 문법일 뿐이다. async 함수는 항상 Promise를 반환하고, await는 .then을 걸어주는 것과 같다.

2-4. await 가 실제로 하는 일: 스레드를 막지 않는다

await를 보면 “그 줄에서 멈춘다”고 생각하기 쉽지만, 정확히는 그 함수의 나머지 실행을 마이크로태스크로 미루고 콜스택에서 빠져나온다. 스레드는 비어서 다른 코드(다른 이벤트 핸들러, 다른 타이머)가 그 사이에 실행될 수 있다. Node.js 서버가 await db.query(...)를 하는 동안에도 다른 요청을 처리할 수 있는 이유가 이거다. 언어 차원에서 스레드를 새로 만들거나 블로킹하는 게 아니다.

async function f() {
  console.log("start");
  await Promise.resolve();
  console.log("end");
}
f();
console.log("sync");
// 출력: start, sync, end

await 이후 코드(end)는 지금 실행 중인 동기 코드(sync)보다 늦게 돈다. 서버 응답을 받아 JSON 봉투를 벗기는 코드를 짤 때도 이 순서를 착각하면 실수가 난다 — fetch(...).then(r => r.json())에서 .json()도 별도의 Promise라는 걸 깜빡하면 언래핑이 한 박자 어긋난다. 실제로 이 종류의 실수가 어떻게 장애로 이어졌는지는 응답 봉투는 어디서 벗기나에 정리해뒀다.


3. 마무리

요약

  • 콜백은 중첩과 에러 전파가 문제였다, Promise는 체이닝으로 그걸 평평하게 만들었다
  • async/await는 Promise 체인을 동기 코드처럼 읽히게 하는 문법이다
  • async 함수는 항상 Promise를 반환한다
  • await는 스레드를 막지 않는다, 나머지 실행을 마이크로태스크로 미루고 콜스택을 비운다

다음은 동등 비교와 삼중 등호 이다.