비동기 — 이벤트 루프와 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 · 이벤트
- 태스크 큐
- 이벤트 루프가 감시한다
- 자바스크립트 엔진
기다리는 일은 엔진이 하지 않는다. 브라우저에 맡겨두고 엔진은 다음 줄로 넘어간다.
- 01
콜 스택에 올라간다
setTimeout이 호출된다. - 02
브라우저에 넘긴다
"0밀리초 뒤에 이 함수를 큐에 넣어줘" 하고 부탁한 뒤 즉시 스택에서 내려온다.
- 03
엔진은 계속 달린다
기다리지 않는다.
3번을 출력한다.console.log("3번"); - 04
시간이 지나면 큐에 넣는다
브라우저가 콜백을 태스크 큐에 넣는다. 아직 실행되지는 않는다.
- 05
이벤트 루프가 옮긴다
콜 스택이 완전히 빈 뒤에야 큐에서 하나를 꺼내 스택에 올린다. 이제야
2번이 찍힌다.
이벤트 루프가 하는 일은 딱 하나다. 콜 스택이 비었는지 보고, 비었으면 큐에서 하나를 옮긴다.
시간으로 그리면 이렇다.
콜백 지옥
비동기 작업을 순서대로 이어야 할 때가 있다. 옛날에는 콜백을 중첩했다.
getUser(1, user => {
getPosts(user.id, posts => {
getComments(posts[0].id, comments => {
console.log(comments);
});
});
});
깊어질수록 오른쪽으로 밀려나고, 에러 처리를 넣으면 더 나빠진다. 이걸 콜백 지옥이라 불렀다.
Promise — 나중에 올 값
Promise는 "지금은 없지만 나중에 값이 올 것"을 나타내는 객체다. 상태가 셋뿐이다.
- pending아직 진행 중
- fulfilled성공 — 값이 있다
- rejected실패 — 이유가 있다
한 번 fulfilled나 rejected가 되면 다시는 바뀌지 않는다.
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 를 반환하게 만든다 | 함수 앞에만 붙인다 |
| await | Promise 가 끝날 때까지 기다린다 | 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강을 마치며
여기까지가 자바스크립트의 기초 문법이다. 처음 강의에서 "자바스크립트는 스스로 실행되지 않는다"고 시작했고, 마지막 강의에서 그 런타임이 비동기를 어떻게 처리하는지로 끝났다. 한 바퀴 돌아온 셈이다.
돌아보면 반복해서 나온 이야기가 몇 개 있다.
- 01
원시 타입과 참조 타입
4강에서 시작해 15·17·18강까지 따라왔다. 복사할 때 무엇이 복사되는가.
- 02
체인을 타고 위로 찾는다
12강 스코프 체인과 19강 프로토타입 체인은 같은 모양이다. 없으면 위로.
- 03
함수는 값이다
11강에서 시작해 14강 클로저, 16강 콜백, 19강 this 까지 이어졌다.
- 04
콜 스택은 하나다
10강에서 맛보고 13강에서 이해하고 20강에서 완성했다.
문법을 다 알았다고 코드를 잘 쓰게 되지는 않는다. 다음은 직접 만들어보는 일이다. 여기서 배운 것을 손으로 쳐보고, 에러를 읽고, 왜 그런지 되짚어보면 된다. 2강에서 말했듯 에러는 가장 친절한 안내문이다.
고생했다.