학습 자료

비동기 — 이벤트 루프와 async/await


Article

13강에서 콜 스택은 한 번에 하나씩만 처리한다고 했다. 자바스크립트가 싱글 스레드라는 뜻이다.

그런데 이상하다. setTimeout으로 3초를 기다리는 동안 화면이 얼어붙지 않는다. 서버에서 데이터를 받아오는 동안에도 버튼이 눌린다. 한 번에 하나씩 처리한다면서 어떻게 그럴 수 있을까.

마지막 강의다. 1강의 런타임과 13강의 콜 스택이 여기서 하나로 이어진다.

먼저 이상한 출력부터

console.log("1번");

setTimeout(() => console.log("2번"), 0);

console.log("3번");
콘솔
  • 1번
  • 3번
  • 2번

0초를 기다리라고 했는데 2번이 마지막에 나온다. 코드는 위에서 아래로 실행된다고 배웠는데 순서가 어긋났다.

0은 "지금 당장"이 아니라 **"최소 0밀리초 뒤"**라는 뜻이다. 그리고 그 뒤에 실행되려면 조건이 하나 더 있다.

엔진 혼자 하는 일이 아니다

1강에서 setTimeout은 자바스크립트 문법이 아니라 런타임이 주는 기능이라고 했다. 그게 이 현상의 열쇠다.

비동기를 처리하는 세 부품
  • 브라우저 런타임
    • 자바스크립트 엔진한 번에 하나씩
      • 콜 스택실행 중인 함수가 쌓인다
    • Web API브라우저가 대신 기다려준다
      • setTimeout · fetch · 이벤트
    • 태스크 큐끝난 콜백이 줄을 선다
      • 이벤트 루프가 감시한다

기다리는 일은 엔진이 하지 않는다. 브라우저에 맡겨두고 엔진은 다음 줄로 넘어간다.

setTimeout(fn, 0) 이 지나는 길
  1. 01

    콜 스택에 올라간다

    setTimeout 이 호출된다.

  2. 02

    브라우저에 넘긴다

    "0밀리초 뒤에 이 함수를 큐에 넣어줘" 하고 부탁한 뒤 즉시 스택에서 내려온다.

  3. 03

    엔진은 계속 달린다

    기다리지 않는다. 3번 을 출력한다.

    console.log("3번");
  4. 04

    시간이 지나면 큐에 넣는다

    브라우저가 콜백을 태스크 큐에 넣는다. 아직 실행되지는 않는다.

  5. 05

    이벤트 루프가 옮긴다

    콜 스택이 완전히 빈 뒤에야 큐에서 하나를 꺼내 스택에 올린다. 이제야 2번 이 찍힌다.

이벤트 루프가 하는 일은 딱 하나다. 콜 스택이 비었는지 보고, 비었으면 큐에서 하나를 옮긴다.

시간으로 그리면 이렇다.

console.log 두 번과 setTimeout(0)
콜 스택
1번setTimeout 등록3번2번
Web API
타이머 (0ms)
태스크 큐
콜백 대기
실행 시작스택이 빔큐에서 꺼냄
스택이 비기 전까지 큐의 콜백은 절대 실행되지 않는다

콜백 지옥

비동기 작업을 순서대로 이어야 할 때가 있다. 옛날에는 콜백을 중첩했다.

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

깊어질수록 오른쪽으로 밀려나고, 에러 처리를 넣으면 더 나빠진다. 이걸 콜백 지옥이라 불렀다.

Promise — 나중에 올 값

Promise는 "지금은 없지만 나중에 값이 올 것"을 나타내는 객체다. 상태가 셋뿐이다.

Promise 의 세 상태
  1. pending아직 진행 중
  2. fulfilled성공 — 값이 있다
  3. rejected실패 — 이유가 있다

한 번 fulfilledrejected가 되면 다시는 바뀌지 않는다.

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve("완료"), 100);
});

promise
  .then(result => console.log(result))
  .catch(error => console.log("실패:", error));
콘솔
  • 완료

then은 성공했을 때, catch는 실패했을 때 실행된다. 그리고 then은 다시 Promise를 돌려주므로 이어붙일 수 있다.

getUser(1)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => console.log(comments))
  .catch(error => console.log("어디선가 실패:", error));

중첩이 사라지고 평평해졌다. 에러 처리도 catch 하나로 모인다. 어느 단계에서 실패하든 거기로 간다.

마이크로태스크 — Promise 가 새치기한다

태스크 큐가 하나가 아니다. Promise의 then더 우선하는 줄에 선다.

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");
콘솔
  • 1
  • 4
  • 3
  • 2

setTimeout을 먼저 썼는데 Promise가 먼저 나왔다.

두 개의 큐

마이크로태스크 큐

Promise · queueMicrotask

  • 먼저 처리된다
  • 스택이 비면 전부 비울 때까지 처리
  • then, catch, await 이후

태스크 큐

setTimeout · 이벤트

  • 나중에 처리된다
  • 한 번에 하나만 꺼낸다
  • 그 사이에 화면이 그려진다

이벤트 루프의 정확한 순서는 이렇다. 스택이 비면 → 마이크로태스크를 전부 비우고 → 태스크 큐에서 하나를 꺼낸다.

async / await — Promise 를 동기처럼 쓴다

then 체인도 길어지면 읽기 어렵다. 마지막 문법이 나왔다.

async function load() {
  const user = await getUser(1);
  const posts = await getPosts(user.id);
  const comments = await getComments(posts[0].id);
  console.log(comments);
}

위에서 아래로 읽힌다. 비동기 코드인데 동기 코드처럼 보인다.

규칙은 둘뿐이다.

키워드하는 일주의
async함수가 Promise 를 반환하게 만든다함수 앞에만 붙인다
awaitPromise 가 끝날 때까지 기다린다async 함수 안에서만
async function getNumber() {
  return 42;
}

console.log(getNumber());
getNumber().then(n => console.log(n));
콘솔
  • Promise { 42 }
  • 42

async 함수는 무엇을 반환하든 Promise로 감싸서 돌려준다. 그래서 await이나 then으로 꺼내야 한다.

에러는 try/catch로 잡는다. 동기 코드와 똑같은 방식이다.

async function load() {
  try {
    const user = await getUser(1);
    console.log(user);
  } catch (error) {
    console.log("실패:", error.message);
  } finally {
    console.log("로딩 끝");
  }
}
const results = await Promise.all([getUser(1), getUser(2), getUser(3)]);

Promise.all은 전부 성공해야 성공하고, 하나라도 실패하면 즉시 실패한다. 실패해도 나머지 결과를 보고 싶다면 Promise.allSettled를 쓴다.

실제로 써본다

브라우저에서 서버 데이터를 받아오는 표준 방법이 fetch다. 1강에서 본 그 Web API다.

async function loadUser(id) {
  try {
    const response = await fetch(`https://api.example.com/users/${id}`);

    if (!response.ok) {
      throw new Error(`서버 응답 ${response.status}`);
    }

    const user = await response.json();
    return user;
  } catch (error) {
    console.log("불러오지 못했다:", error.message);
    return null;
  }
}

await이 두 번이다. 응답 헤더가 오는 것을 한 번, 본문을 다 읽는 것을 또 한 번 기다린다.

정리하면

  • 자바스크립트 엔진은 싱글 스레드지만, 기다리는 일은 런타임이 대신한다.
  • 이벤트 루프는 콜 스택이 빈 것을 확인하고 큐에서 콜백을 옮긴다.
  • setTimeout(fn, 0)은 즉시 실행이 아니라 지금 하던 일이 전부 끝난 뒤다.
  • Promise의 then마이크로태스크 큐에 들어가 setTimeout보다 먼저 처리된다.
  • async 함수는 항상 Promise를 반환하고, await은 그것이 끝나기를 기다린다.
  • 무관한 작업은 Promise.all동시에 보낸다.
  • fetch는 404를 실패로 보지 않는다. response.ok를 확인한다.

20강을 마치며

여기까지가 자바스크립트의 기초 문법이다. 처음 강의에서 "자바스크립트는 스스로 실행되지 않는다"고 시작했고, 마지막 강의에서 그 런타임이 비동기를 어떻게 처리하는지로 끝났다. 한 바퀴 돌아온 셈이다.

돌아보면 반복해서 나온 이야기가 몇 개 있다.

이 시리즈를 관통한 것들
  1. 01

    원시 타입과 참조 타입

    4강에서 시작해 15·17·18강까지 따라왔다. 복사할 때 무엇이 복사되는가.

  2. 02

    체인을 타고 위로 찾는다

    12강 스코프 체인과 19강 프로토타입 체인은 같은 모양이다. 없으면 위로.

  3. 03

    함수는 값이다

    11강에서 시작해 14강 클로저, 16강 콜백, 19강 this 까지 이어졌다.

  4. 04

    콜 스택은 하나다

    10강에서 맛보고 13강에서 이해하고 20강에서 완성했다.

문법을 다 알았다고 코드를 잘 쓰게 되지는 않는다. 다음은 직접 만들어보는 일이다. 여기서 배운 것을 손으로 쳐보고, 에러를 읽고, 왜 그런지 되짚어보면 된다. 2강에서 말했듯 에러는 가장 친절한 안내문이다.

고생했다.

비동기 — 이벤트 루프와 async/await — 디코드랩(DCODELAB)