General22분 읽기

[JavaScript] 싱글 스레드인데 어떻게 돌아가는 거야

29

싱글 스레드인데 어떻게 돌아가는 거야

목차

  1. Introduction
  2. 싱글 스레드라는 게 뭔데
  3. 비동기는 어떻게 가능한 건가
  4. 콜 스택 — 익숙한 듯 다른 개념
  5. Task Queue vs Microtask Queue
  6. HTML Spec의 이벤트 루프 Processing Model
  7. Node.js의 이벤트 루프
  8. 이벤트 루프가 블로킹되면 벌어지는 일
  9. Conclusion

1. Introduction

Topic

자바를 3년 넘게 써왔다.

Spring Boot 기반 백엔드 개발을 주로 했고, 멀티 스레드 환경에서의 동시성 제어나 ExecutorService를 활용한 비동기 처리도 나름 익숙하게 다뤘다.

그런데 이번에 가게 된 회사에서 JavaScript를 쓴다. 나는 기본적으로 자율도가 높은 JS같은 언어들을 싫어한다. 하지만 뭐 까라면 까야지... JS를 본격적으로 다뤄야 하는 상황이 됐다. 왜 싫어하냐고 물어보면 아래와 같은 짤 때문에.

이미지

와 정말 재밌어보인다 하하 너무 설레는걸?

그래도 자바를 한 짬으로 금방 익숙해질 것이라고 기대해본다...


Spring Boot 기반 백엔드 개발을 하면서 "동시에 여러 일을 처리하는 것"은 거의 기본값이었다. 대신 멀티스레드의 대가는 명확했다. 공유 자원, 레이스 컨디션, synchronized, Lock… 동시성을 '관리'하는 비용이 늘 따라왔다. Spring의 @Service, @Controller는 기본이 싱글톤이라 여러 요청/스레드가 같은 인스턴스를 공유한다. 그래서 Repository 같은 주입 의존성은 필드(final)로 재사용해도 되지만, 요청마다 달라지는 값을 멤버 변수에 저장하면 공유 상태가 되어 터지므로 로컬 변수/파라미터로만 다뤄야 했다.

JavaScript는 싱글 스레드 언어다.

여기서 바로 의문이 생겼다. 왜 굳이 싱글 스레드로 설계했을까? 그럼 "동시에 처리"는 어떻게 할까?

hljs javascript
console.log("1");

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

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

console.log("4");

JS 문법 자체가 아직 낯설어서, 이 코드의 출력 순서가 뭔지조차 감이 안 왔다. setTimeout이 뭔지, Promise.resolve().then()이 뭔지, 화살표 함수(=>)가 뭔지 하나하나 찾아봐야 했다.

찾아보니 출력이 1 → 4 → 3 → 2이다.

위에서 아래로 실행되지 않는걸 보니,

아, 이 코드는 위에서 아래로 순서대로만 실행되는 게 아니구나. 뭔가는 지금이 아니라 나중에 처리되도록 넘어가는 것 같다. 그럼 이 '나중에 실행'은 누가, 어떤 순서로 다시 실행시키는 거지?

그 규칙이 바로 이벤트 루프였다.

그래서 이번 기회에 zero부터 끝까지 파보기로 했다.

먼저 짚고 갈 점

⚠️ 이 글은 자바를 주로 써온 사람이 JS를 처음 공부하면서 기록한 내용이다.

자바에서의 동시성 모델과 비교하며 이해하려 했고, 직접 실험하고 스펙을 찾아보며 정리했지만 해석이 틀린 부분이 있을 수 있다. 특히 개인 추론이 들어간 부분은 별도로 표시해두었으니 비판적으로 읽어주길 바란다.

그리고 이 글에는 직접 돌려볼 수 있는 실험 코드가 꽤 많다. 읽기만 하지 말고, 복붙해서 돌려보길 강력히 권한다. 결과를 예측한 뒤 실행해보면 체감이 완전히 다르다.


2. 싱글 스레드라는 게 뭔데

자바와의 차이부터

자바에서는 동시에 여러 작업을 처리하고 싶으면 스레드를 만든다.

hljs java
ExecutorService executor = Executors.newFixedThreadPool(4);

for (int i = 0; i < 4; i++) {
    executor.submit(() -> {
        long sum = 0;
        for (long j = 0; j < 100_000_000; j++) sum += j;
        System.out.println("완료: " + sum);
    });
}

4개의 스레드가 동시에 돌아간다. 메인 스레드는 자기 할 일을 하고, 각 워커 스레드는 독립적으로 작업을 처리한다. synchronizedReentrantLock같은 도구로 동시성을 제어하는 것도 익숙하다.

JS는 이게 안 된다. 코드를 실행하는 스레드가 딱 하나다.

그러면 무거운 작업을 돌리는 동안 다른 건 아무것도 못 하는 건지 직접 확인해보고 싶었다.

[실험] 진짜 멈추는지 확인

hljs javascript
// 브라우저 콘솔에서 실행해보자
console.log("시작");

const start = Date.now();
while (Date.now() - start < 5000) {
  // 5초 동안 아무것도 하지 않고 CPU만 점유
}

console.log("5초 경과");

이 코드를 브라우저에서 실행하면, 5초 동안 페이지 전체가 얼어붙는다. 버튼을 클릭해도, 텍스트를 입력해도, 스크롤을 해도 아무 반응이 없다.

자바였으면 이런 일이 없었다. 무거운 작업은 별도 스레드에 던져놓고, 메인 스레드는 사용자 입력을 계속 처리했을 것이다.

JS는 그 유일한 스레드가 while 루프에 갇혀버려서, 다른 어떤 코드도 실행되지 못한다.

이게 싱글 스레드의 실체다.

그런데 이상한 점이 있다.

hljs javascript
console.log("시작");

setTimeout(() => {
  console.log("5초 경과");
}, 5000);

console.log("끝");

이미지

이 코드도 5초를 기다리긴 하지만, 페이지가 멈추지 않는다. (끝이 먼저 출력됨)

같은 5초인데 while 루프는 페이지를 멈추고, setTimeout은 멈추지 않는다.

싱글 스레드인데 어떻게 이런 차이가 생기는 건지. 여기서부터 파기 시작했다.


3. 비동기는 어떻게 가능한 건가

JS 엔진과 런타임은 다르다

런타임 = "내 코드가 실제로 실행되는 환경(프로그램 + 주변 시스템)" ex) 브라우저, Node.js(백엔드)

이벤트 루프를 찾아보면서 가장 먼저 알게 된 사실이 있다.

setTimeout은 JavaScript 문법이 아니다.

처음에 이게 무슨 소린가 싶었다. ECMAScript 스펙을 찾아봤는데 진짜 setTimeout이 없다. setTimeout은 브라우저(HTML spec)나 Node.js가 제공하는 API였다.

자바로 치면 Thread.sleep()이 자바 언어 문법이 아니라 JVM이 제공하는 기능인 것과 비슷하다고 이해했다. (정확한 비유는 아닐 수 있지만, 감 잡는 데는 도움이 됐다.)

정리하면 이렇다.

구분역할비유 (자바)
JS 엔진 (V8)코드 파싱, 컴파일, 실행JVM의 바이트코드 실행
런타임 (브라우저)setTimeout, fetch, DOM API 등 제공서블릿 컨테이너/런타임 환경
런타임 (Node.js)fs, http, 이벤트 기반 I/O 제공Netty 같은 비동기 I/O 프레임워크

"JavaScript는 싱글 스레드"라는 말의 정확한 의미는, JS 엔진의 코드 실행 스레드가 하나라는 뜻이다. 런타임 환경 자체는 내부적으로 여러 스레드를 사용한다.

setTimeout을 호출하면 자바스크립트가 직접 1초를 세면서 기다리는 게 아니다. 대신 브라우저(런타임)에게 "1초 뒤에 이 코드를 실행해줘"라고 맡겨두고, 자바스크립트는 바로 다음 줄을 계속 실행한다. 시간이 지나면 브라우저가 "시간 됐어"라고 알려주고, 그때서야 미뤄둔 코드가 실행된다.

자바에도 "이 작업은 지금 하지 말고, 다른 곳에 맡겨서 나중에 처리해줘"라는 방식이 있다. 예를 들어 스레드풀에 일을 맡기면, 메인 흐름은 그 일을 기다리지 않고 다음 줄로 넘어간다. setTimeout도 겉으로는 비슷하게 보인다. 지금 실행하지 않고 '나중에 실행할 일'로 등록해두기 때문이다. (다만 자바는 보통 다른 스레드가 실제로 일을 처리하고, JS는 메인 스레드가 나중에 다시 꺼내 실행한다는 점에서 방식은 다르다.)

그래서 이벤트 루프가 뭔데

setTimeout의 콜백은 브라우저가 타이머를 완료한 후 Task Queue라는 곳에 넣는다.

JS 엔진은 현재 실행 중인 코드가 모두 끝나면(= 콜 스택이 비면), Task Queue에서 대기 중인 콜백을 하나 꺼내서 실행한다.

이 과정을 반복하는 메커니즘이 이벤트 루프다.

text
1. 콜 스택에 있는 코드를 실행한다.
2. 콜 스택이 비었으면, Task Queue에서 하나 꺼내 콜 스택에 올린다.
3. 1번으로 돌아간다.

이전에 while 루프가 페이지를 멈춘 이유가 여기에 있다. while 루프가 실행되는 동안 콜 스택이 비어지질 않으니(동기), 이벤트 루프가 Task Queue에서 콜백을 가져올 수 없었던 것이다.

반면 setTimeout은 호출 즉시 콜 스택에서 빠지고, 실제 대기는 브라우저가 한다. 콜 스택이 비어있으니 이벤트 루프가 다른 작업(클릭 이벤트, 렌더링)도 처리할 수 있다.

자바의 동시성 모델과 근본적으로 다른 지점이 이것이다. 자바는 여러 스레드가 동시에 작업을 처리하지만, JS는 하나의 스레드가 작업을 번갈아가며 처리한다. 동시에 실행되는 게 아니라, 아주 빠르게 교대하는 것이다.


4. 콜 스택

자바에서 이미 봤던 것

3장에서 "콜 스택이 비면 Task Queue에서 꺼내온다"고 했다. 이 콜 스택이라는 게 정확히 뭔지 짚고 넘어가려 한다.

콜 스택이라는 단어 자체는 낯설지 않다. 자바에서 예외가 터지면 스택 트레이스를 보면서 "어디서 호출됐는지"를 추적하는데, 그게 바로 콜 스택의 상태를 출력한 것이다.

hljs java
Exception in thread "main" java.lang.NullPointerException
    at com.example.Service.process(Service.java:42)
    at com.example.Controller.handle(Controller.java:15)
    at com.example.Main.main(Main.java:8)

아래에서 위로 읽으면 함수 호출 순서가 보인다. mainhandleprocess. JS에서도 동일하다. 함수가 호출되면 콜 스택에 쌓이고, 끝나면 빠진다.

다만, JS에서 콜 스택의 의미는 자바에서보다 훨씬 크다.

자바에서는 콜 스택이 꽉 차면 StackOverflowError가 터지는 정도의 의미였지, "콜 스택이 비어있어야 다음 작업을 처리할 수 있다"는 제약은 없었다. 다른 스레드가 알아서 처리하니까.

JS에서는 유일한 실행 스레드의 콜 스택이 비어있지 않으면, 이벤트 루프 전체가 멈춘다. 2장의 while 루프가 페이지를 멈춘 이유도, 3장에서 setTimeout 콜백이 밀린 이유도 전부 "콜 스택이 안 비어서"였다.

자바에서 콜 스택은 디버깅 도구에 가까웠다면, JS에서는 시스템 전체의 흐름을 좌우하는 핵심 구조인 셈이다.

콜 스택에 쌓이는 것의 정체가 뭘까

그렇다면 콜 스택에 쌓이는 것은 정확히 무엇인가?

자바에서 메서드가 호출되면 JVM이 스택 프레임을 만든다. 그 안에 지역 변수, 매개변수, 반환 주소 같은 정보가 담긴다.

JS 엔진도 비슷하게, 함수 호출마다 실행 컨텍스트라는 환경을 생성해서 콜 스택에 push한다.

실행 컨텍스트에는 다음 정보가 담긴다.

  • Variable Environment: 변수, 함수 선언 등
  • Lexical Environment: 스코프 체인 정보
  • this 바인딩

자바의 스택 프레임과 역할은 비슷한데, 담고 있는 게 좀 많다는 인상을 받았다. 특히 this 바인딩이 호출 방식에 따라 동적으로 결정된다는 건 자바와 꽤 달라서 당황스러웠는데, 이건 별도 주제라 넘어간다.

⚠️ Lexical Environment의 상세 구조는 클로저를 다룰 때 깊게 봐야 할 영역이라, 여기선 "각 함수 호출마다 독립된 실행 환경이 만들어진다"는 수준까지만 다룬다.

중요한 건, 이 실행 컨텍스트가 콜 스택에서 모두 pop되어야 — 즉, 실행 중인 함수가 전부 끝나야 — 이벤트 루프가 다음 작업을 가져올 수 있다는 것이다.


5. Task Queue vs Microtask Queue

큐가 하나가 아니었다

3장에서 "콜 스택이 비면 Task Queue에서 꺼내온다"고 설명했다.

근데 이것만으로는 Introduction에서 본 코드의 출력 순서를 설명할 수 없다.

hljs javascript
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// 출력: 1 → 4 → 3 → 2

setTimeoutPromise.then 모두 "나중에 실행"인데, 왜 Promise.then이 먼저 찍히는가?

Task Queue만 있다면 둘 다 같은 큐에 들어갈 테고, 먼저 등록된 setTimeout이 먼저 나와야 할 것이다. 근데 결과는 반대다.

큐가 두 종류이기 때문이다.

구분Task Queue (Macrotask Queue)Microtask Queue
들어가는 것setTimeout, setInterval, I/O, UI 이벤트Promise.then/catch/finally, queueMicrotask, MutationObserver
처리 방식한 번에 하나만 꺼내서 실행전부 비울 때까지 실행
실행 시점이벤트 루프의 각 iteration 시작각 Task가 끝난 직후

이벤트 루프를 좀 더 정확히 쓰면 이렇다.

text
1. Task Queue에서 Task 하나를 꺼내 실행한다.
2. Microtask Queue에 있는 것을 전부 실행한다.
3. (필요하면) 렌더링한다.
4. 1번으로 돌아간다.

"전부"라는 단어가 핵심이다. Task는 한 번에 하나만, Microtask는 큐가 빌 때까지 전부.

현재 실행 중인 동기 코드가 하나의 Task다. 이 Task가 끝나면(콜 스택이 비면), 다음 Task를 꺼내기 전에 Microtask Queue를 먼저 전부 비운다. 그래서 Promise.then(Microtask)이 setTimeout(Task)보다 먼저 실행되는 것이다.

자바에는 이런 구분이 없다. CompletableFutureScheduledExecutorService든 전부 스레드풀에서 처리되니, 실행 순서는 스레드 스케줄러에 달려있지 큐의 종류에 달려있지 않다. JS만의 독특한 메커니즘이었다.

[실험] 실행 순서 퀴즈

두 종류의 큐가 어떻게 상호작용하는지, 직접 예측하고 돌려보면서 체감해보자.

Level 1: 기본

hljs javascript
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
정답 확인

A → D → C → B

동기 코드(A, D) 먼저. Microtask(C). 마지막으로 Task(B).

Level 2: Promise 체이닝

hljs javascript
console.log("A");

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

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

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

console.log("F");
정답 확인

A → F → C → E → D → B

이 부분에서 처음 틀렸다. C와 E의 순서는 맞혔는데, D의 위치를 잘못 예측했다.

.then 체이닝에서 첫 번째 .then의 콜백(C)이 실행되면, 그 반환값으로 새 Promise가 resolve되고, 두 번째 .then의 콜백(D)이 Microtask Queue에 새로 추가된다.

이 시점에 Microtask Queue에는 이미 E가 들어가 있다. D는 E 뒤에 줄을 서게 된다.

자바의 CompletableFuture.thenRun().thenRun() 체이닝과는 동작이 좀 다르다. 자바에서는 체이닝된 작업이 순서대로 동일 스레드(또는 같은 스레드풀)에서 연속 실행되지만, JS에서는 각 .then 콜백이 Microtask Queue에 개별적으로 들어가서 다른 Microtask와 섞일 수 있다.

Level 3: Microtask 안에서 Microtask 생성

hljs javascript
console.log("A");

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

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

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

console.log("F");
정답 확인

A → F → C → E → D → B

C의 콜백 안에서 새 Microtask(D)를 생성한다. 이 D는 현재 진행 중인 "Microtask 전부 비우기" 사이클에 포함된다.

C 실행 → D가 Microtask Queue에 추가됨 → E 실행 → D 실행 → Microtask Queue가 비었으므로 다음 Task(B) 실행.

핵심은 Microtask 안에서 생성된 Microtask도 현재 사이클에서 처리된다는 점이다.

Level 4: async/await

hljs javascript
async function foo() {
  console.log("A");
  await Promise.resolve();
  console.log("B");
}

console.log("C");
foo();
console.log("D");

setTimeout(() => console.log("E"), 0);
Promise.resolve().then(() => console.log("F"));
정답 확인

C → A → D → B → F → E

async 함수에서 await 이전까지는 동기적으로 실행된다. foo()를 호출하면 console.log("A")가 즉시 찍힌다.

await Promise.resolve()를 만나면, 그 이후의 코드(console.log("B"))는 Microtask로 변환된다. 사실상 아래와 동일하다.

hljs javascript
function foo() {
  console.log("A");
  Promise.resolve().then(() => {
    console.log("B");
  });
}

await가 "여기서 잠깐 멈추고, 나머지는 Microtask로 스케줄링해줘"라는 의미인 셈이다.

자바의 CompletableFuture에서 .thenRunAsync()로 후속 작업을 등록하는 것과 개념적으로 비슷하지만, JS에서는 이게 같은 스레드 안에서 일어나는 점이 결정적 차이다.

[실험] Microtask가 Microtask를 끝없이 낳으면?

Level 3에서 "Microtask 안에서 생성된 Microtask도 현재 사이클에서 처리된다"고 했다.

그러면 Microtask가 계속 Microtask를 생성하면 어떻게 될까? 이벤트 루프가 "전부 비울 때까지" 실행한다고 했는데, 영원히 비워지지 않으면?

⚠️ 이 코드를 실행하면 브라우저 탭이 멈춘다. 개발자 도구에서만 실행하고, 탭을 닫을 준비를 해두자.

hljs javascript
// ⚠️ 위험한 코드
function microtaskBomb() {
  let count = 0;
  
  function loop() {
    count++;
    if (count % 100000 === 0) {
      console.log(`microtask ${count}번째 실행`);
    }
    queueMicrotask(loop); // microtask가 계속 microtask를 생성
  }

  setTimeout(() => console.log("이 메시지는 절대 안 보인다"), 0);
  queueMicrotask(loop);
}

// microtaskBomb(); // 실행 시 탭 멈춤

예상대로, 비울 때마다 새로 채워지니 영원히 못 비운다.

이벤트 루프가 Microtask Queue를 비우는 데 갇혀서, 다음 단계(Task Queue 확인, 렌더링)로 영원히 넘어가지 못한다. setTimeout 콜백은 Task Queue에서 대기하지만, 꺼내질 기회 자체가 오지 않는다.

이 현상을 starvation(기아 상태)이라고 한다.

자바에서 한 스레드가 synchronized 블록을 오래 잡고 있으면 다른 스레드가 굶는 것과 비슷한 현상인데, JS에서는 스레드가 하나뿐이라 시스템 전체가 멈춰버린다. 더 치명적이다.


6. HTML Spec의 이벤트 루프 Processing Model

스펙을 읽어야 하는 이유

5장까지의 모델로는 아직 설명이 안 되는 것들이 있었다.

requestAnimationFrame은 Task인가 Microtask인가? 화면 렌더링은 매 Task마다 이루어지는가?

이것들의 정확한 답을 알려면 결국 1차 소스를 봐야 한다.

HTML Living Standard — Event Loop Processing Model

처음 열었을 때 솔직히 읽기 상당히 힘들었다. 근데 5장까지 직접 실험하면서 배운 내용이 있으니, 의외로 읽히기 시작했다.

Processing Model의 핵심 단계

스펙을 단순화하면 이벤트 루프의 한 cycle은 다음과 같다.

text
1. Task Queue에서 실행 가능한 Task를 하나 꺼내 실행한다.
   (Task Queue가 비어있으면 2번으로 점프)

2. Microtask checkpoint 수행
   → Microtask Queue가 빌 때까지 전부 실행

3. 렌더링이 필요한지 판단한다.
   (브라우저가 "지금 렌더링해야겠다"고 판단한 경우에만 진행)

   3-1. requestAnimationFrame 콜백 실행
   3-2. 스타일 재계산 (Recalculate Style)
   3-3. 레이아웃 (Layout)
   3-4. 페인트 (Paint)

4. 1번으로 돌아간다.

5장에서 설명한 "Task → Microtask → ..." 뒤에 렌더링 단계가 붙어있었다.

핵심은 3번의 **"렌더링이 필요한지 판단한다"**는 부분이다. 매 Task마다 렌더링하는 게 아니라, 브라우저가 "화면을 갱신할 타이밍이다"라고 판단했을 때만 이루어진다. 보통은 모니터 주사율(60Hz라면 약 16.67ms 간격)에 맞춰서 판단한다.

requestAnimationFrame(rAF)의 정체

rAF는 Task도 Microtask도 아니다.

처음에는 둘 중 하나일 거라 생각했는데, 스펙을 보니 렌더링 단계 직전에 실행되는 별도의 콜백이었다. 렌더링 파이프라인의 일부로 정의되어 있다.

"애니메이션은 requestAnimationFrame으로 하라"는 조언의 기술적 근거가 여기에 있다. rAF는 브라우저가 "지금 화면을 그릴 거야"라고 결정한 바로 그 시점 직전에 호출되므로, DOM 변경을 렌더링 타이밍에 정확히 맞출 수 있다.

[실험] Task vs Microtask vs rAF 실행 순서

3가지가 섞여 있을 때 실제로 어떤 순서로 실행되는지 확인해봤다.

hljs javascript
console.log("1: 동기");

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

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

requestAnimationFrame(() => console.log("4: rAF"));

console.log("5: 동기");

내 환경에서의 출력: 1 → 5 → 3 → 2 → 4

동기 → Microtask → Task → rAF 순서. 스펙에서 본 Processing Model과 일치한다.

rAF가 setTimeout보다 늦게 실행된 이유는, rAF가 다음 렌더링 타이밍까지 기다려야 하기 때문이다. setTimeout(fn, 0)은 다음 Task cycle에서 바로 실행될 수 있지만, rAF는 브라우저가 렌더링을 결정한 시점에야 실행된다.

⚠️ 다만, 이 순서가 항상 보장되지는 않는다. 브라우저가 렌더링 타이밍을 어떻게 잡느냐에 따라, 여러 번 반복 실행하면 간헐적으로 rAF와 setTimeout 순서가 바뀌는 걸 관찰할 수 있다.

[실험] setTimeout 애니메이션 vs rAF 애니메이션

rAF가 렌더링 직전에 실행된다는 게 실제 화면에서 어떤 차이를 만드는지 눈으로 확인해보고 싶었다.

hljs html
<!-- 파일로 저장해서 브라우저에서 열어보자 -->
<!DOCTYPE html>
<html>
<body>
  <div id="box1" style="width:50px; height:50px; background:red; position:absolute; top:50px;"></div>
  <div id="box2" style="width:50px; height:50px; background:blue; position:absolute; top:120px;"></div>
  <p style="position:absolute; top:10px;">빨간색: setTimeout(16ms) / 파란색: rAF</p>

  <script>
    const box1 = document.getElementById("box1");
    const box2 = document.getElementById("box2");
    let pos1 = 0, pos2 = 0;

    function animateWithTimeout() {
      pos1 += 2;
      box1.style.left = pos1 + "px";
      if (pos1 < 600) setTimeout(animateWithTimeout, 16);
    }

    function animateWithRAF() {
      pos2 += 2;
      box2.style.left = pos2 + "px";
      if (pos2 < 600) requestAnimationFrame(animateWithRAF);
    }

    animateWithTimeout();
    animateWithRAF();
  </script>
</body>
</html>

직접 돌려보면 빨간 박스(setTimeout)가 미세하게 떨리는 느낌을 볼 수 있다. 파란 박스(rAF)는 상대적으로 부드럽다.

setTimeout(fn, 16)은 정확히 16ms가 보장되지 않고, 렌더링 타이밍과도 어긋날 수 있다. rAF는 렌더링 직전에 호출되므로 "DOM 변경 → 렌더링" 사이의 갭이 최소화된다.

이론으로만 들었을 때는 "뭐 거기서 거기 아닌가?" 싶었는데, 직접 보니까 차이가 체감됐다.

[실험] 이벤트 핸들러는 어떤 큐로 들어가는가

JS로 UI를 다루게 될 텐데, 클릭 같은 이벤트가 이벤트 루프에서 어떻게 처리되는지도 궁금했다.

hljs html
<button id="btn">클릭</button>
<script>
  document.getElementById("btn").addEventListener("click", () => {
    console.log("1: 핸들러 시작");

    setTimeout(() => console.log("2: setTimeout"), 0);
    Promise.resolve().then(() => console.log("3: Promise"));
    requestAnimationFrame(() => console.log("4: rAF"));

    console.log("5: 핸들러 끝");
  });
</script>

사용자가 직접 클릭하면: 1 → 5 → 3 → 2 → 4

이벤트 핸들러 자체가 하나의 Task로 실행된다. 핸들러 내부의 동기 코드가 먼저, 그다음 Microtask, 그다음 다음 cycle의 Task, rAF 순. 이전에 배운 규칙이 그대로 적용된다.

재미있는 건 스크립트에서 btn.click()을 호출하면 동기적으로 핸들러가 실행된다는 점이다. 사용자 클릭은 Task Queue를 통해 비동기로 실행되지만, 프로그래밍적 클릭은 즉시 콜 스택에 올라간다. 같은 핸들러인데 호출 방식에 따라 동작이 달라진다.

(⚠️ 이 부분은 브라우저마다 미묘한 차이가 있을 수 있다.)


7. Node.js의 이벤트 루프

브라우저와 같은 듯 다른

회사에서 Node.js도 쓸 가능성이 있어서, Node.js의 이벤트 루프도 같이 정리했다.

같은 V8 엔진을 쓰는데, 이벤트 루프 구현이 다르다.

브라우저는 HTML spec에 따라 구현하고, Node.js는 libuv라는 C 라이브러리를 통해 구현한다.

자바로 비유하면, 같은 JVM 위에서 Tomcat(서블릿 컨테이너)을 쓰느냐 Netty(비동기 I/O 프레임워크)를 쓰느냐에 따라 요청 처리 모델이 달라지는 것과 비슷한 느낌이었다. (정확한 비유는 아니지만.)

Node.js 이벤트 루프의 6가지 Phase

text
   ┌───────────────────────────┐
┌─>│         timers            │ ← setTimeout, setInterval 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │ ← 이전 iteration에서 지연된 I/O 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │ ← 내부 전용
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll             │ ← 새 I/O 이벤트 검색 및 콜백 실행
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          check             │ ← setImmediate 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │ ← close 이벤트 콜백
│  └───────────────────────────┘

출처: Node.js 공식 문서 — The Node.js Event Loop

브라우저의 "Task → Microtask → 렌더링" 모델보다 단계가 세분화되어 있다.

각 phase 사이마다 Microtask Queue를 비운다. (⚠️ Node.js v11 이전에는 phase가 끝난 후에만 microtask를 처리했다. v11부터 브라우저와 동일하게 각 콜백 사이마다 처리하도록 변경되었으니, 버전에 따라 동작이 다를 수 있다.)

[실험] setImmediate vs setTimeout(fn, 0) — 비결정성

Node.js에는 브라우저에 없는 setImmediate가 있다. 둘 다 "가능한 빨리 실행해줘"인데, 차이가 있는지 궁금해서 실험해봤다.

hljs javascript
// Node.js에서 실행
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));

이걸 여러 번 반복 실행하면 순서가 바뀐다. 어떤 때는 setTimeout이 먼저, 어떤 때는 setImmediate가 먼저.

같은 코드인데 결과가 비결정적이라니, 꽤 당황스러웠다.

이유는, Node.js가 시작될 때 setTimeout(fn, 0)의 타이머가 등록되는 시점과 이벤트 루프가 timers phase에 진입하는 시점 사이에 미세한 시간 차이가 존재하기 때문이다. 이 차이가 시스템 상태에 따라 달라진다.

자바의 ScheduledExecutorService에서도 아주 작은 delay를 주면 실행 순서가 비결정적일 수 있지만, 그건 스레드 스케줄링 때문이다. JS에서는 스레드가 하나인데도 비결정적인 게 좀 신기했다. 이벤트 루프의 phase 진입 타이밍이라는 전혀 다른 요인이 작용하고 있었다.

근데 I/O 콜백 안에서는 항상 setImmediate가 먼저다.

hljs javascript
const fs = require("fs");

fs.readFile(__filename, () => {
  setTimeout(() => console.log("setTimeout"), 0);
  setImmediate(() => console.log("setImmediate"));
});
// 항상: setImmediate → setTimeout

I/O 콜백은 poll phase에서 실행된다. poll 다음은 check phase(setImmediate)이고, timers phase는 다음 loop iteration에서야 도달한다. 그래서 I/O 콜백 내부에서는 항상 setImmediate가 먼저.

6-phase 구조를 알고 나니 이 결과가 자연스럽게 이해됐다.

process.nextTick의 특수성

setImmediate를 찾아보다가 process.nextTick이라는 것도 같이 나왔다. Microtask보다도 먼저 실행된다는 설명이 있어서 진짜인지 확인해봤다.

hljs javascript
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("nextTick"));

출력: nextTick → Promise → setTimeout/setImmediate

process.nextTick은 Microtask보다도 먼저 실행된다.

"process.nextTick() is not technically part of the event loop." — Node.js 공식 문서

어떤 phase든, 현재 작업이 끝나면 nextTickQueue를 먼저 비우고, 그다음 Microtask Queue를 비우고, 그다음 다음 phase로 넘어간다.

5장에서 Microtask starvation을 봤는데, nextTick으로도 동일한 현상이 발생한다. process.nextTick이 재귀적으로 자기 자신을 호출하면, 이벤트 루프가 다음 phase로 영원히 넘어가지 못한다.

Node.js 공식 문서에서도 process.nextTick의 재귀적 사용을 경고하고, 대안으로 setImmediate를 권장한다. setImmediate는 check phase에서 실행되므로 다른 phase가 기아 상태에 빠지지 않는다.


8. 이벤트 루프가 블로킹되면 벌어지는 일

이제 왜 중요한지 알겠다

2장에서 while 루프로 브라우저를 멈춘 적이 있다. 당시에는 "싱글 스레드라서 그렇다" 정도로 이해했지만, 6장까지 온 지금은 정확히 무슨 일이 벌어지는지 설명할 수 있다.

콜 스택에서 오래 걸리는 작업이 실행되면, 이벤트 루프가 다음 cycle로 넘어가지 못한다. Task Queue의 클릭 이벤트, 타이머 콜백을 가져올 수 없고, 렌더링 단계에도 도달하지 못한다.

자바에서는 메인 스레드가 바빠도 다른 스레드가 사용자 입력을 받을 수 있지만(Swing의 EDT가 별도로 존재하듯), JS에서는 스레드가 하나뿐이라 전부 멈춘다.

그렇다면 실제로 CPU를 많이 쓰는 작업이 필요할 때는 어떻게 해야 하는가? JS에서 쓸 수 있는 두 가지 방법을 실험해봤다.

[실험] 해결책 1 — 작업 분할 (Time Slicing)

큰 작업을 작은 조각으로 나누어, 각 조각 사이에 이벤트 루프가 다른 작업을 처리할 기회를 주는 방법이다.

hljs javascript
// 블로킹 방식: 한 번에 전부 처리
function heavyTaskBlocking(total) {
  console.time("블로킹");
  let sum = 0;
  for (let i = 0; i < total; i++) sum += i;
  console.timeEnd("블로킹");
  return sum;
}

// Time Slicing 방식: 조각조각 나누어 처리
function heavyTaskSliced(total, chunkSize) {
  return new Promise(resolve => {
    let sum = 0;
    let processed = 0;
    
    console.time("Time Slicing");
    
    function processChunk() {
      const end = Math.min(processed + chunkSize, total);
      for (let i = processed; i < end; i++) sum += i;
      processed = end;

      if (processed < total) {
        setTimeout(processChunk, 0); // 이벤트 루프에게 틈을 준다
      } else {
        console.timeEnd("Time Slicing");
        resolve(sum);
      }
    }
    
    processChunk();
  });
}

setTimeout(processChunk, 0)이 핵심이다. 한 조각을 처리한 후, 나머지를 "다음 Task"로 등록한다. 그 사이에 이벤트 루프가 클릭 이벤트나 렌더링을 처리할 수 있게 된다.

Time Slicing의 총 실행 시간은 블로킹보다 느리다. setTimeout으로 제어권을 넘기는 오버헤드가 있으니까.

하지만 UX 관점에서는 압도적으로 낫다. 사용자가 "앱이 죽었나?"라고 생각하지 않는다.

[실험] 해결책 2 — Web Worker

Time Slicing은 여전히 메인 스레드에서 실행된다. 진짜로 별도 스레드에서 작업을 돌리려면 Web Worker를 써야 한다.

hljs javascript
// worker.js
self.addEventListener("message", (e) => {
  const { count } = e.data;
  let sum = 0;
  for (let i = 0; i < count; i++) sum += i;
  self.postMessage({ sum });
});
hljs javascript
// main.js
const worker = new Worker("worker.js");

worker.addEventListener("message", (e) => {
  console.log(`Worker 결과: ${e.data.sum}`);
});

console.log("Worker에게 작업 전달");
worker.postMessage({ count: 500_000_000 });
console.log("메인 스레드는 자유롭다");
// 이 사이에 클릭, 타이핑, 렌더링 모두 정상 동작

Worker가 무거운 연산을 하는 동안 메인 스레드는 완전히 자유롭다.

여기서 혼란이 왔다. "어라, 그럼 JS도 멀티 스레드인 거 아닌가?"

맞다, Worker는 별도 스레드에서 실행된다. 근데 메인 스레드와 격리된 환경이다. DOM에 접근할 수 없고, 메인 스레드와 변수를 공유하지 않으며, 오직 postMessage로만 통신한다.

자바의 멀티 스레드와 비교하면 차이가 확연하다. 자바에서는 여러 스레드가 같은 힙 메모리를 공유하니까 synchronized, volatile, ConcurrentHashMap 같은 동시성 도구가 필요하다. JS의 Web Worker는 애초에 메모리를 공유하지 않으므로 Race Condition이 구조적으로 차단된다.

"JavaScript는 싱글 스레드"라는 말은 메인 스레드에서의 코드 실행 흐름이 하나라는 뜻이었다. Worker를 만들면 별도 스레드가 생기지만, 그건 격리된 세계다.

[실험] 세 방식 종합 비교

직접 비교해봐야 체감이 되니, 세 가지를 나란히 돌려봤다.

hljs html
<!DOCTYPE html>
<html>
<body>
  <button onclick="testBlocking()">1. 블로킹</button>
  <button onclick="testSlicing()">2. Time Slicing</button>
  <button onclick="testWorker()">3. Web Worker</button>
  <button onclick="console.log('UI 살아있음: ' + Date.now())">반응 테스트</button>
  <pre id="log"></pre>

  <script>
    const COUNT = 300_000_000;
    const log = (msg) => document.getElementById("log").textContent += msg + "\n";

    function testBlocking() {
      log("--- 블로킹 시작 (UI 멈춤 예상) ---");
      const start = performance.now();
      let sum = 0;
      for (let i = 0; i < COUNT; i++) sum += i;
      log(`블로킹 완료: ${(performance.now() - start).toFixed(0)}ms`);
    }

    function testSlicing() {
      log("--- Time Slicing 시작 (UI 반응 가능) ---");
      const start = performance.now();
      let sum = 0, idx = 0;
      
      function chunk() {
        const end = Math.min(idx + 5_000_000, COUNT);
        for (; idx < end; idx++) sum += idx;
        if (idx < COUNT) {
          setTimeout(chunk, 0);
        } else {
          log(`Time Slicing 완료: ${(performance.now() - start).toFixed(0)}ms`);
        }
      }
      chunk();
    }

    function testWorker() {
      log("--- Web Worker 시작 (UI 반응 가능) ---");
      const start = performance.now();
      
      const blob = new Blob([`
        self.onmessage = (e) => {
          let sum = 0;
          for (let i = 0; i < e.data; i++) sum += i;
          self.postMessage(sum);
        };
      `], { type: "application/javascript" });
      
      const worker = new Worker(URL.createObjectURL(blob));
      worker.postMessage(COUNT);
      worker.onmessage = (e) => {
        log(`Web Worker 완료: ${(performance.now() - start).toFixed(0)}ms`);
        worker.terminate();
      };
    }
  </script>
</body>
</html>

내 환경에서의 대략적인 결과:

방식처리 시간UI 반응성
블로킹~800ms❌ 완전히 멈춤
Time Slicing~1500ms✅ 반응 가능
Web Worker~800ms✅ 반응 가능

Web Worker가 블로킹과 동일한 속도이면서 UI도 멈추지 않는다.

다만 Worker 생성 비용, postMessage의 직렬화/역직렬화 오버헤드가 있어서, 가벼운 작업이면 오히려 손해다. 자바에서 스레드풀을 만드는 비용이 있는 것처럼.

SharedArrayBuffer — 맛보기만

⚠️ 깊게 다루지 않는다. "이런 게 있다" 수준.

SharedArrayBuffer를 쓰면 Worker 간에 메모리를 공유할 수 있다. 이 순간, 자바에서 익숙하게 다뤘던 Race Condition, 동기화 문제가 JS 세계에도 발을 들인다. Atomics API로 동기화를 하게 되는데, 이건 별도 주제다.


9. Conclusion

자바에서 온 사람이 알게 된 것들

자바를 쓸 때는 동시성 문제를 "어떤 스레드가 어떤 자원에 접근하는가"로 생각했다. synchronized 블록, Lock, AtomicInteger 같은 도구로 공유 자원을 보호하는 게 핵심이었다.

JS의 동시성 모델은 근본적으로 달랐다.

스레드가 하나이기 때문에, 공유 자원 경쟁 문제는 (Web Worker의 SharedArrayBuffer를 제외하면) 원천적으로 존재하지 않는다. 대신 **"이 코드가 언제 실행되는가"**가 핵심이었다. 같은 스레드 안에서 Task Queue, Microtask Queue, rAF 콜백이 어떤 순서로 실행되는지를 이해하는 게 JS 동시성의 본질이었다.

정리하면:

  • 싱글 스레드라는 말은 JS 엔진의 코드 실행 흐름이 하나라는 뜻이다. 런타임은 내부적으로 여러 스레드를 쓴다.
  • 콜 스택이 비어야 이벤트 루프가 다음 작업을 가져온다. 이게 블로킹의 정체다.
  • Task Queue와 Microtask Queue는 별개다. Microtask가 먼저 처리되며, Microtask가 Microtask를 낳으면 starvation이 발생한다.
  • requestAnimationFrame은 Task도 Microtask도 아닌 렌더링 파이프라인의 일부다.
  • Node.js는 브라우저와 다른 6-phase 이벤트 루프를 가지고 있다.
  • Web Worker는 별도 스레드지만 메인 스레드와 격리되어, 자바의 멀티 스레드와는 다른 모델이다.

남은 질문들

글을 쓰면서 해결 못 한 것들이 있다. 나중에 답을 찾으면 업데이트할 예정이다.

  • queueMicrotaskPromise.resolve().then의 실행 순서가 스펙에서 보장되는 것인지, 구현에 의존하는 것인지 아직 확인하지 못했다.
  • 브라우저가 "렌더링이 필요하다"고 판단하는 정확한 기준이 뭔지. 스펙에는 "if there is a rendering opportunity"라고만 되어 있는데, 각 브라우저의 구체적인 heuristic은 어떻게 다른지.
  • Node.js에서 setImmediatesetTimeout(fn, 0)의 비결정성이 정확히 어떤 조건에서 발생하는지. libuv 소스 레벨까지 내려가봐야 할 것 같다.

Reference

Comments 1

0/500
te
test19d ago

test123

인기 글