About→

← Studies

04. Event Loop

JavaScript는 어떻게 비동기 작업을 처리할까?

앞의 글에서 JavaScript 코드가 실행되는 과정을 살펴봤다.

핵심은 다음과 같았다.

text
JavaScript 코드
      ↓
Execution Context
      ↓
Call Stack
      ↓
코드 실행

그런데 실제 브라우저에서 JavaScript는 단순히 코드만 실행하지 않는다.

우리는 개발하면서 다음과 같은 코드를 자주 작성한다.

jsx
setTimeout(() => {
  console.log("Hello");
}, 1000);

또는

jsx
fetch("/api/users");

그리고 Promise도 사용한다.

jsx
Promise.resolve().then(() => {
  console.log("Hello");
});

이런 작업들은 지금까지 살펴본 Call Stack만으로는 설명하기 어렵다.

그렇다면 JavaScript는 비동기 작업을 어떻게 처리하는 것일까?

이것을 이해하기 위해 필요한 것이

text
Web API
↓
Task Queue
Microtask Queue
↓
Event Loop
↓
Call Stack

이다.

1. 먼저 중요한 오해부터

Event Loop를 공부하다 보면 이런 설명을 자주 접한다.

"JavaScript는 싱글 스레드이기 때문에 비동기 작업을 할 수 없다."

하지만 실제로는 그렇지 않다.

JavaScript의 일반적인 코드 실행은 하나의 Call Stack에서 처리되지만,

브라우저는 JavaScript 엔진 외에도 다양한 기능을 제공한다.

text
┌──────────────────────────┐
│         Browser          │
│                          │
│  ┌────────────────────┐  │
│  │ JavaScript Engine  │  │
│  │                    │  │
│  │   Call Stack       │  │
│  └────────────────────┘  │
│                          │
│  Web APIs                │
│  Timer / Network / DOM   │
│                          │
│  Task Queue              │
│  Microtask Queue         │
└──────────────────────────┘

즉,

JavaScript가 모든 비동기 작업을 직접 처리하는 것이 아니라, 브라우저가 제공하는 기능과 함께 동작한다.

라고 이해하는 것이 중요하다.

2. Web API란 무엇일까?

브라우저는 JavaScript가 사용할 수 있는 다양한 기능을 제공한다.

예를 들어

jsx
setTimeout(...)
fetch(...)
document.querySelector(...)
addEventListener(...)

등이 있다.

이 중 일부는 JavaScript 언어 자체가 제공하는 기능이 아니라 브라우저가 제공하는 Web API와 연결되어 있다.

대표적으로

text
Timer
Network
DOM
Event

등의 기능이 있다.

그래서

jsx
setTimeout(() => {
  console.log("Hello");
}, 1000);

를 실행한다고 해서 JavaScript 엔진이 1초 동안 기다리는 것이 아니다.

브라우저에게 타이머 작업을 맡기고 JavaScript는 다음 코드를 실행할 수 있다.

3. setTimeout은 어떻게 실행될까?

다음 코드를 보자.

jsx
console.log("A");

setTimeout(() => {
  console.log("B");
}, 1000);

console.log("C");

결과는

text
A
C
B

이다.

왜 그럴까?

순서대로 살펴보자.

① console.log("A")

Call Stack에서 바로 실행된다.

text
Call Stack

console.log("A")

결과:

text
A

실행이 끝나면 Call Stack에서 제거된다.

② setTimeout() 실행

이번에는 조금 다르다.

jsx
setTimeout(() => {
  console.log("B");
}, 1000);

setTimeout의 타이머 작업은 브라우저가 제공하는 기능을 이용한다.

개념적으로 보면

text
Call Stack
    ↓
setTimeout()
    ↓
Browser Timer

처럼 연결된다.

JavaScript는 타이머가 끝날 때까지 기다리지 않는다.

Call Stack은 다시 비워진다.

③ console.log("C")

JavaScript는 바로 다음 코드를 실행한다.

text
C

따라서 현재까지

text
A
C

가 출력된다.

④ 1초가 지나면

브라우저의 타이머가 완료되었다고 해서 callback이 즉시 실행되는 것은 아니다.

callback은 실행될 수 있도록 Queue에 들어간다.

개념적으로

text
Timer 완료
   ↓
Task Queue
   ↓
Event Loop

가 된다.

⑤ Call Stack이 비어 있으면

Event Loop가 실행 가능한 작업을 Call Stack으로 전달한다.

text
Task Queue
    ↓
Event Loop
    ↓
Call Stack

그리고 callback이 실행된다.

text
B

결과적으로

text
A
C
B

가 된다.

4. 중요한 것은 "1초 뒤 실행"이 아니다

여기서 setTimeout을 이해할 때 흔히 하는 실수가 있다.

jsx
setTimeout(callback, 1000);

을

"정확히 1초 뒤 callback을 실행한다."

라고 생각하는 것이다.

정확히는 그렇지 않다.

1000ms는 callback이 실행되기 전까지 기다리는 최소 지연 시간에 가깝다.

예를 들어 Call Stack에서 아주 오래 걸리는 작업이 실행되고 있다고 해보자.

jsx
setTimeout(() => {
  console.log("Hello");
}, 0);

for (let i = 0; i < 10_000_000_000; i++) {
  // 매우 오래 걸리는 작업
}

setTimeout의 시간이 0이라고 해도 callback이 바로 실행될 수는 없다.

왜냐하면 Call Stack이 아직 작업 중이기 때문이다.

text
Call Stack

heavyWork()

이 끝나야 callback이 실행될 수 있다.

따라서

text
setTimeout
↓
"0초 후 실행"

이 아니라

text
setTimeout
↓
최소 지연 시간 이후 callback을 실행할 수 있는 상태
↓
Queue
↓
Call Stack이 비어야 실행

이라고 이해해야 한다.

5. Event Loop는 무엇을 하는가?

이제 Event Loop의 역할을 살펴보자.

Event Loop를 어렵게 생각할 필요는 없다.

핵심은

실행할 수 있는 작업이 있는지 확인하고, 조건이 맞으면 Call Stack에서 실행될 수 있도록 연결하는 역할

이라고 이해하면 된다.

개념적으로 보면

text
             ┌──────────────┐
             │  Call Stack  │
             └──────┬───────┘
                    │
                    │ 확인
                    ↓
             ┌──────────────┐
             │  Event Loop  │
             └──────┬───────┘
                    │
             ┌──────┴───────┐
             ↓              ↓
       Microtask Queue   Task Queue

다만 실제 브라우저의 이벤트 루프 동작은 이보다 훨씬 복잡하다.

특히 브라우저에서는 렌더링 타이밍까지 함께 고려해야 한다.

하지만 JavaScript의 비동기 실행 순서를 이해하기 위한 모델로는 이 구조가 유용하다.

6. Queue는 하나가 아니다

Event Loop를 이해할 때 가장 중요한 부분 중 하나다.

비동기 작업이 모두 같은 Queue에 들어가는 것이 아니다.

대표적으로 우리가 구분해야 하는 것은

text
Microtask Queue
Task Queue

다.

그리고 이 둘의 우선순위가 다르다.

7. Task Queue

Task Queue에는 대표적으로 다음과 같은 작업들이 들어갈 수 있다.

text
setTimeout
setInterval
일부 DOM 이벤트

예를 들어

jsx
setTimeout(() => {
  console.log("timeout");
}, 0);

callback은 일반적으로 task로 처리된다.

즉,

text
setTimeout
    ↓
Timer
    ↓
Task Queue
    ↓
Event Loop
    ↓
Call Stack

이라는 흐름으로 생각할 수 있다.

8. Microtask Queue

Microtask Queue에는 대표적으로 Promise의 후속 작업이 들어간다.

예를 들어

jsx
Promise.resolve().then(() => {
  console.log("Promise");
});

여기서 then()에 전달한 callback은 Microtask로 처리된다.

즉,

text
Promise
   ↓
then()
   ↓
Microtask Queue
   ↓
Event Loop
   ↓
Call Stack

으로 연결된다.

그리고 중요한 차이가 하나 있다.

일반적으로 현재 실행 중인 작업이 끝나면 다음 Task로 넘어가기 전에 Microtask를 먼저 처리한다.

이 차이 때문에 우리가 자주 보는 실행 순서가 만들어진다.

9. Promise가 setTimeout보다 먼저 실행되는 이유

다음 코드를 보자.

jsx
console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => {
  console.log("C");
});

console.log("D");

결과는

text
A
D
C
B

이다.

왜일까?

하나씩 살펴보자.

① A 실행

jsx
console.log("A");

동기 코드이기 때문에 바로 실행된다.

text
A

② setTimeout 등록

jsx
setTimeout(() => {
  console.log("B");
}, 0);

Timer에 작업이 등록된다.

callback은 나중에 Task로 실행될 수 있다.

③ Promise 등록

jsx
Promise.resolve().then(() => {
  console.log("C");
});

Promise의 then callback은 Microtask Queue에 들어갈 작업이 된다.

text
Microtask Queue

C

④ D 실행

아직 현재 JavaScript 코드가 끝나지 않았다.

따라서 다음 코드가 먼저 실행된다.

jsx
console.log("D");

결과:

text
A
D

⑤ 현재 작업이 끝난다

이제 현재 JavaScript 코드의 실행이 끝났다.

Queue에 작업이 있다.

text
Microtask Queue
→ C

Task Queue
→ B

이때 Microtask를 먼저 처리한다.

text
C

⑥ 그다음 Task

Microtask가 처리된 후 Task Queue의 작업을 실행할 수 있다.

text
B

최종 결과:

text
A
D
C
B

이다.

10. Microtask가 중요한 이유

Microtask는 단순히 Promise 때문에 존재하는 개념이 아니다.

React를 비롯한 현대 JavaScript 애플리케이션에서도 Promise 기반 비동기 코드가 매우 많이 사용된다.

예를 들어

jsx
fetch("/api/users")
  .then((response) => response.json())
  .then((users) => {
    console.log(users);
  });

처럼 네트워크 요청 이후의 처리도 Promise와 연결된다.

또한

jsx
queueMicrotask(() => {
  console.log("microtask");
});

처럼 직접 Microtask를 등록할 수도 있다.

따라서

text
Promise
↓
Microtask

의 관계를 이해해두면 이후 React의 비동기 처리나 데이터 fetching을 이해할 때도 도움이 된다.

11. Microtask를 너무 많이 만들면?

Microtask가 Task보다 먼저 처리된다는 것은 반대로 문제가 될 수도 있다.

예를 들어 Microtask를 계속 추가하면

jsx
function run() {
  queueMicrotask(() => {
    run();
  });
}

run();

처럼 Microtask가 계속 생성될 수 있다.

개념적으로

text
Microtask
 ↓
Microtask 추가
 ↓
Microtask
 ↓
Microtask 추가
 ↓
Microtask
 ↓
...

가 된다.

브라우저는 현재 작업 이후 Microtask를 처리하는 과정도 중요하게 다루기 때문에, Microtask가 과도하게 이어지면 다른 작업이나 렌더링이 지연될 수 있다.

즉,

Microtask가 무조건 빠르기 때문에 좋은 것은 아니다.

12. Event Loop가 비동기 작업을 직접 처리하는 것은 아니다

여기서 또 하나의 오해를 정리할 필요가 있다.

Event Loop가

text
네트워크 요청
타이머
파일 처리

같은 비동기 작업을 직접 처리하는 것은 아니다.

Event Loop의 핵심 역할은 실행 순서를 조정하는 것이다.

예를 들어

text
JavaScript
   ↓
Web API에 작업 요청
   ↓
브라우저가 작업 처리
   ↓
완료된 callback이 Queue에 들어감
   ↓
Event Loop가 실행 가능한 시점을 확인
   ↓
Call Stack에서 실행

이다.

따라서 역할을 나누면 이해하기 쉽다.

text
JavaScript Engine
→ JavaScript 실행

Web API
→ 브라우저 기능 제공

Queue
→ 실행할 callback 대기

Event Loop
→ 실행 가능한 작업을 연결

Call Stack
→ 실제 JavaScript 코드 실행

13. fetch는 어떻게 동작할까?

이번에는 실무에서 훨씬 자주 사용하는 fetch를 보자.

jsx
console.log("A");

fetch("/api/users")
  .then((response) => response.json())
  .then((users) => {
    console.log(users);
  });

console.log("B");

대략적인 흐름은 다음과 같다.

text
console.log("A")
      ↓
A 출력

fetch()
      ↓
브라우저의 네트워크 기능에 작업 요청

JavaScript는 계속 실행
      ↓
console.log("B")
      ↓
B 출력

네트워크 응답 도착
      ↓
Promise 상태 변경
      ↓
then()의 후속 작업이 Microtask로 처리될 수 있음
      ↓
Call Stack에서 실행
      ↓
users 출력

그래서 일반적으로

text
A
B
users

순서가 된다.

물론 실제 실행에서는 네트워크 응답 시점과 다른 작업의 존재 등에 따라 달라질 수 있다.

14. 비동기라고 해서 JavaScript가 동시에 실행되는 것은 아니다

여기서 매우 중요한 개념이 있다.

jsx
setTimeout(() => {
  console.log("A");
}, 1000);

console.log("B");

를 보면

text
A와 B가 동시에 실행된다

라고 생각할 수도 있다.

하지만 JavaScript의 일반적인 실행 자체가 동시에 두 개의 코드를 실행하는 것은 아니다.

실제로는

text
B 실행
 ↓
현재 코드 종료
 ↓
나중에 A callback 실행

이다.

즉,

비동기(Asynchronous)는 "여러 JavaScript 코드가 동시에 실행된다"는 의미가 아니다.

비동기는 현재 작업을 기다리지 않고 다음 작업을 진행할 수 있도록 작업의 완료와 실행을 분리하는 방식으로 이해하는 것이 좋다.

15. 그렇다면 JavaScript는 정말 싱글 스레드인가?

여기서 다시 처음의 질문으로 돌아가 보자.

JavaScript의 일반적인 코드 실행은 하나의 Call Stack에서 이루어진다.

그래서 흔히

JavaScript는 싱글 스레드다.

라고 표현한다.

하지만 브라우저 전체를 보면 이야기가 달라진다.

브라우저는 JavaScript 엔진 하나만 존재하는 환경이 아니다.

브라우저는 네트워크, 렌더링, 타이머, 이벤트 등 다양한 작업을 처리하기 위한 여러 구성 요소와 스레드를 사용한다.

그래서 더 정확하게 이해하면

text
JavaScript 코드 실행
→ 하나의 실행 흐름에서 처리

Browser
→ 여러 기능과 실행 주체를 활용

이라고 볼 수 있다.

이 차이가 바로 다음 글에서 다룰 Web Worker와도 연결된다.

16. 그렇다면 무거운 JavaScript는 왜 화면을 멈추게 할까?

이제 앞에서 배운 브라우저 렌더링과 연결해보자.

브라우저는 화면을 계속 업데이트해야 한다.

그런데 Main Thread에서 JavaScript가 아주 오래 실행되고 있다면

jsx
for (let i = 0; i < 10_000_000_000; i++) {
  // heavy work
}

Call Stack이 계속 점유될 수 있다.

그러면 사용자 입력이나 화면 업데이트를 처리할 기회가 늦어질 수 있다.

개념적으로 보면

text
Main Thread

JavaScript
████████████████████████████
            ↓
      계속 실행 중

사용자 클릭
            ↓
       처리 지연

화면 업데이트
            ↓
       처리 지연

이것이 우리가 실제 웹 애플리케이션에서 경험하는

text
버튼이 안 눌림
스크롤이 버벅임
화면이 멈춘 것처럼 보임

같은 현상으로 이어질 수 있다.

17. 여기서 Web Worker가 등장한다

그렇다면 무거운 JavaScript 작업을 Main Thread에서 실행하지 않으면 어떻게 될까?

이 질문에서 등장하는 것이 Web Worker다.

Web Worker는 별도의 실행 환경에서 JavaScript를 실행할 수 있도록 해준다.

개념적으로 보면

text
                Browser
                   │
        ┌──────────┴──────────┐
        ↓                     ↓
   Main Thread           Worker
        │                     │
   UI / JavaScript       무거운 계산
        │                     │
        └────── message ──────┘

이렇게 생각할 수 있다.

예를 들어

text
10,000개의 파일 파싱
대용량 데이터 계산
이미지 처리
복잡한 연산

같은 작업을 Worker로 옮기면 Main Thread가 해당 작업을 직접 수행하는 것을 피할 수 있다.

하지만 Worker가 모든 것을 해결해주는 것은 아니다.

Worker는 DOM에 직접 접근할 수 없고, Main Thread와 데이터를 주고받는 비용도 존재한다.

그래서

"무거운 작업은 무조건 Worker로 보내면 된다."

가 아니라

Main Thread를 오래 점유하는 계산 작업인지, Worker로 분리했을 때 통신 비용보다 얻는 이점이 큰지

를 판단해야 한다.

이 부분은 다음 글에서 자세하게 다룰 것이다.

18. 지금까지의 전체 흐름

이제 JavaScript 실행과 Event Loop를 하나로 연결해보자.

text
JavaScript 코드
       ↓
Execution Context
       ↓
Call Stack
       ↓
┌─────────────────────────┐
│ 동기 코드 실행           │
└─────────────────────────┘
       ↓
비동기 작업 발생
       ↓
Web API
       ↓
작업 완료
       ↓
┌─────────────────────────┐
│ Microtask Queue /       │
│ Task Queue              │
└─────────────────────────┘
       ↓
Event Loop
       ↓
Call Stack이 실행 가능한지 확인
       ↓
Call Stack
       ↓
callback 실행

여기에서 가장 중요한 관계는 다음과 같다.

text
Call Stack
→ 지금 실행하고 있는 JavaScript

Web API
→ 브라우저가 제공하는 비동기 기능

Microtask Queue
→ Promise 후속 작업 등

Task Queue
→ Timer, 이벤트 등 task

Event Loop
→ Queue의 작업이 실행될 수 있도록 연결

19. 실제 코드를 다시 분석해보자

이제 처음 봤던 코드를 다시 보자.

jsx
console.log("1");

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

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

console.log("4");

실행 과정을 직접 따라가면 된다.

현재 JavaScript 실행

text
console.log("1")

출력:

text
1

setTimeout

text
Timer 등록

callback은 Task로 처리될 수 있도록 준비된다.

Promise

text
then callback
↓
Microtask Queue

다음 동기 코드

text
console.log("4")

출력:

text
4

현재 작업 종료.

이제 Queue를 확인한다.

text
Microtask Queue
→ 3

Task Queue
→ 2

Microtask 먼저 실행.

text
3

그다음 Task.

text
2

최종 결과:

text
1
4
3
2

이제 이 결과를 단순히 외우는 것이 아니라,

text
동기 코드
↓
Microtask
↓
Task

라는 흐름으로 설명할 수 있어야 한다.

20. 브라우저 렌더링과 Event Loop의 관계

앞의 글에서 브라우저 렌더링을 살펴봤다.

text
DOM
↓
Render Tree
↓
Layout
↓
Paint
↓
Composite

그리고 이번 글에서는 JavaScript 실행을 살펴봤다.

text
Call Stack
↓
Web API
↓
Queue
↓
Event Loop

이 둘은 서로 완전히 별개의 개념이 아니다.

JavaScript가 DOM을 변경하면 브라우저가 다시 화면을 업데이트해야 할 수 있다.

예를 들어

jsx
button.addEventListener("click", () => {
  element.textContent = "Hello";
});

사용자가 버튼을 클릭하면

text
사용자 이벤트
    ↓
Task
    ↓
Call Stack
    ↓
JavaScript 실행
    ↓
DOM 변경
    ↓
브라우저가 필요한 렌더링 작업 수행
    ↓
화면 업데이트

와 같은 흐름으로 연결된다.

따라서 우리가 처음부터 만들었던 전체 지도는 점점 이렇게 연결된다.

text
URL
 ↓
Network
 ↓
HTML / CSS / JS
 ↓
Browser Rendering
 ↓
JavaScript Execution
 ↓
Event Loop
 ↓
DOM 변경
 ↓
Browser Rendering

그리고 React를 사용하면 그 사이에 React의 동작이 들어오게 된다.

마무리

이번 글에서 가장 중요한 것은 Event Loop 자체를 외우는 것이 아니다.

다음 관계를 이해하는 것이 핵심이다.

text
JavaScript
    ↓
Call Stack
    ↓
비동기 작업
    ↓
Web API
    ↓
Queue
    ↓
Event Loop
    ↓
Call Stack

그리고 Queue는 크게

text
Microtask Queue
Task Queue

로 구분해서 생각할 수 있다.

특히 Promise의 후속 작업은 Microtask로 처리되고, setTimeout 같은 작업은 Task로 처리되기 때문에 다음과 같은 결과가 만들어진다.

jsx
console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => {
  console.log("C");
});

console.log("D");
text
A
D
C
B

이제 여기까지 이해했다면 한 가지 문제가 남는다.

JavaScript가 하나의 실행 흐름에서 동작한다면, 대용량 데이터 처리나 복잡한 계산처럼 몇 초씩 걸리는 작업은 어떻게 처리해야 할까?

이 질문에서 다음 개념인 Web Worker가 등장한다.

다음 글에서는

text
Main Thread
↓
무거운 JavaScript
↓
UI가 멈추는 이유
↓
Web Worker
↓
postMessage
↓
Message Event
↓
Structured Clone / Transferable
↓
Worker를 언제 사용해야 하는가

를 실제 프론트엔드 개발 상황과 연결해서 살펴본다.