JavaScript는 어떻게 싱글 스레드에서 비동기를 처리하는가?

Woody·2025년 11월 8일
0

Frontend

목록 보기
2/3

개요

JavaScript는 이벤트 루프로 비동기를 처리한다. 하지만 서버의 멀티스레드 이벤트 루프와는 다르다.

이 문서는 다음 질문에 답한다:

  • JavaScript가 싱글 스레드라는 것의 정확한 의미는?
  • 싱글 스레드인데 어떻게 비동기 처리가 가능한가?
  • 이벤트 루프는 정확히 무엇을 하는가?
  • 왜 무거운 작업이 화면을 멈추게 하는가?

이 문서를 읽고 나면:

  • JavaScript의 동작 원리를 깊이 있게 이해할 수 있다
  • 비동기 코드의 실행 순서를 정확히 예측할 수 있다
  • UI 블로킹 문제를 이해하고 해결할 수 있다

1. JavaScript는 정말 싱글 스레드인가?

싱글 스레드의 정확한 의미

"JavaScript는 싱글 스레드다"는 무슨 의미인가?

핵심: JavaScript 엔진(V8, SpiderMonkey 등)이 코드를 실행할 때 하나의 Call Stack만 사용한다.

function first() {
  console.log('1');
  second();
  console.log('3');
}

function second() {
  console.log('2');
}

first();
// 출력: 1 → 2 → 3

Call Stack 동작:

1. first() 호출 → Call Stack에 push
2. console.log('1') 실행
3. second() 호출 → Call Stack에 push
4. console.log('2') 실행
5. second() 종료 → Call Stack에서 pop
6. console.log('3') 실행
7. first() 종료 → Call Stack에서 pop

Call Stack이 하나면 한 번에 하나의 작업만 실행할 수 있다.

JavaScript 엔진 ≠ 브라우저

JavaScript 엔진과 브라우저는 다르다:

  • JavaScript 엔진: 싱글 스레드, Call Stack 하나
  • 브라우저: 멀티 스레드, 여러 작업을 동시 처리

브라우저는 JavaScript 엔진 외에도 다양한 스레드를 운영한다:

  • Timer Thread (setTimeout, setInterval)
  • Network Thread (fetch, XMLHttpRequest)
  • DOM Event Thread (클릭, 스크롤 등)
  • Rendering Thread

이 구분이 "싱글 스레드인데 비동기가 가능한 이유"의 핵심이다.


2. 이벤트 루프의 동작 원리

첫 번째 의문: 컨텍스트 스위칭?

다음 코드의 실행 순서를 예측해보자:

console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');

출력 결과:

1
3
2

왜 이런 순서로 출력될까? JavaScript가 1을 실행하다가 setTimeout으로 전환했다가 다시 3으로 돌아오는 "컨텍스트 스위칭"을 하는가?

답: 아니다.

컨텍스트 스위칭이라면 실행 중인 코드를 중단하고 다른 코드로 전환할 수 있어야 한다. 하지만 JavaScript는 현재 실행 중인 작업을 완전히 끝낸 후 다음 작업으로 넘어간다.

이것은 컨텍스트 스위칭이 아니라 작업 순서 관리다.

전체 구조 다이어그램

┌─────────────────────────────────────────────────┐
│          JavaScript 엔진 (싱글 스레드)            │
│                                                 │
│         ┌─────────────────────┐                 │
│         │    Call Stack       │                 │
│         │  (한 번에 하나만)     │                 │
│         └─────────────────────┘                 │
│                   ↕                             │
│                                                 │
└─────────────────────────────────────────────────┘
                    ↕
        ┌───────────────────────┐
        │    이벤트 루프          │
        │  (감시자 + 중개자)      │
        └───────────────────────┘
                    ↕
┌─────────────────────────────────────────────────┐
│              Queue System                       │
│  ┌──────────────────┐  ┌──────────────────┐    │
│  │ Microtask Queue  │  │   Task Queue     │    │
│  │  (높은 우선순위)   │  │  (낮은 우선순위)   │    │
│  └──────────────────┘  └──────────────────┘    │
└─────────────────────────────────────────────────┘
                    ↑
┌─────────────────────────────────────────────────┐
│      브라우저 Web APIs (멀티 스레드)              │
│                                                 │
│  ┌─────────────┐  ┌──────────────┐             │
│  │ Timer Thread│  │Network Thread│             │
│  └─────────────┘  └──────────────┘             │
│  ┌─────────────┐  ┌──────────────┐             │
│  │ DOM Thread  │  │Render Thread │             │
│  └─────────────┘  └──────────────┘             │
└─────────────────────────────────────────────────┘

이벤트 루프의 정확한 역할

이벤트 루프는 "스케줄러"가 아니라 "감시자(Watcher) + 중개자(Mediator)"다.

이벤트 루프가 하는 일:
1. 무한 반복(Loop)하면서 Call Stack을 계속 감시
2. Call Stack이 비었는지 확인
3. 비었으면 → Queue를 확인 (Microtask Queue 먼저, 그 다음 Task Queue)
4. Queue에서 작업을 꺼내 Call Stack에 넣음
5. 1번으로 돌아가기

의사 코드로 표현하면:

while (true) {
  if (callStack.isEmpty()) {
    // Microtask Queue를 전부 비울 때까지 실행
    while (!microtaskQueue.isEmpty()) {
      const task = microtaskQueue.dequeue();
      callStack.push(task);
      task.execute();
    }
    
    // Task Queue에서 하나만 실행
    if (!taskQueue.isEmpty()) {
      const task = taskQueue.dequeue();
      callStack.push(task);
      task.execute();
    }
  }
}

3. Queue 시스템: Task vs Microtask

우선순위가 있는 두 개의 Queue

JavaScript에는 두 종류의 Queue가 있다:

  1. Microtask Queue (높은 우선순위)

    • Promise의 then/catch/finally
    • queueMicrotask()
    • MutationObserver
  2. Task Queue (낮은 우선순위)

    • setTimeout, setInterval
    • DOM 이벤트 (클릭, 스크롤 등)
    • requestAnimationFrame (특수한 Task)

실행 순서 예제

console.log('1');

setTimeout(() => console.log('Task 1'), 0);
setTimeout(() => console.log('Task 2'), 0);

Promise.resolve().then(() => console.log('Micro 1'));
Promise.resolve().then(() => console.log('Micro 2'));

console.log('2');

출력 결과:

1
2
Micro 1
Micro 2
Task 1
Task 2

실행 흐름:

1. console.log('1') → Call Stack에서 즉시 실행
2. setTimeout 실행 → 브라우저가 콜백을 Task Queue에 등록
3. setTimeout 실행 → 브라우저가 콜백을 Task Queue에 등록
4. Promise.then 실행 → 콜백을 Microtask Queue에 등록
5. Promise.then 실행 → 콜백을 Microtask Queue에 등록
6. console.log('2') → Call Stack에서 즉시 실행
7. Call Stack 비워짐! ← 이벤트 루프 작동 시작
8. Microtask Queue 확인 → Micro 1, Micro 2 전부 실행
9. Task Queue 확인 → Task 1만 실행
10. Call Stack 비워짐
11. Microtask Queue 확인 → 비어있음
12. Task Queue 확인 → Task 2 실행

중요한 차이점

Microtask Queue: 한 번 확인하면 큐가 빌 때까지 전부 실행

Task Queue: 한 번에 하나만 실행하고 다시 Microtask 확인

다이어그램으로 표현하면:

┌─────────────────────────────────────────────┐
│         이벤트 루프 실행 흐름                  │
└─────────────────────────────────────────────┘
                    ↓
        ┌───────────────────────┐
        │ Call Stack 비었는가?   │
        └───────────────────────┘
                    ↓ YES
        ┌───────────────────────┐
        │ Microtask Queue 확인   │
        │   (전부 실행)          │
        └───────────────────────┘
                    ↓
        ┌───────────────────────┐
        │ Task Queue 확인        │
        │   (하나만 실행)         │
        └───────────────────────┘
                    ↓
        ┌───────────────────────┐
        │  렌더링 기회            │
        └───────────────────────┘
                    ↓
           (다시 처음으로)

위험한 패턴: Microtask Starvation

function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('Microtask');
    recursiveMicrotask(); // 계속 Microtask 추가
  });
}

recursiveMicrotask();

setTimeout(() => console.log('나는 영원히 실행 안됨'), 0);

문제:

  • Microtask가 무한히 실행됨
  • Task Queue의 작업은 영원히 실행되지 않음 (Starvation)
  • 브라우저 렌더링도 멈춤
  • 화면이 얼어붙음

실제 프로젝트 예시:

// React에서 발생할 수 있는 무한 루프
useEffect(() => {
  Promise.resolve().then(() => {
    setState(prev => prev + 1);
    // state 변경 → useEffect 재실행 → 무한 루프
  });
}, [state]);

4. 비동기 처리의 비밀: Web APIs

setTimeout의 타이머는 누가 재는가?

console.log('1');
setTimeout(() => console.log('2'), 1000);
console.log('3');

1초 타이머는 누가 재는가? JavaScript 엔진이 Call Stack에서 기다리는가?

답: 아니다. 브라우저의 Timer Thread가 타이머를 잰다.

역할 분담 구조

setTimeout(() => console.log('완료'), 1000);
console.log('다음 코드');

동작 과정:

  1. setTimeout 실행

    • JavaScript가 브라우저에 타이머 시작 요청
    • 브라우저의 Timer Thread가 타이머 시작
    • JavaScript는 바로 다음 코드로 진행
  2. console.log('다음 코드') 즉시 실행

    • JavaScript는 기다리지 않음
  3. 1초 후 (Timer Thread에서)

    • Timer Thread가 1초 완료 감지
    • 콜백을 Task Queue에 넣음
  4. Call Stack이 비면

    • 이벤트 루프가 콜백을 Call Stack에 넣어 실행

fetch도 동일한 방식:

fetch('https://api.example.com/data')
  .then(response => console.log(response));

console.log('요청 보냄');
  • Network Thread가 HTTP 요청 처리
  • JavaScript는 다음 코드 실행
  • 응답 도착 시 콜백을 Microtask Queue에 등록

핵심: JavaScript 엔진은 코드 실행만 담당한다. 시간이 걸리는 작업은 브라우저의 다른 스레드가 처리한다.


5. 렌더링과 이벤트 루프

렌더링은 언제 일어나는가?

브라우저 렌더링(화면 그리기)은 Task 사이에 일어난다:

Task 1 실행
  ↓
Microtask Queue 전부 실행
  ↓
[렌더링 기회] ← 브라우저가 필요시 화면 업데이트
  ↓
Task 2 실행
  ↓
Microtask Queue 전부 실행
  ↓
[렌더링 기회]

렌더링 타이밍 다이어그램:

┌──────────────────────────────────────────────┐
│            시간 흐름 →                         │
└──────────────────────────────────────────────┘

[Task 1]─[Micro]─[🎨 Render]─[Task 2]─[Micro]─[🎨 Render]

Task: Call Stack이 점유됨 (렌더링 불가)
Micro: 여전히 Call Stack 점유됨 (렌더링 불가)
Render: Call Stack 비어있음 (렌더링 가능)

UI 블로킹 문제

button.addEventListener('click', () => {
  box.style.transform = 'translateX(100px)';
  
  // 무거운 작업 (3초)
  let sum = 0;
  for (let i = 0; i < 3000000000; i++) {
    sum += i;
  }
  
  console.log('작업 끝');
});

문제 상황:

  1. 버튼 클릭 → 이벤트 핸들러가 Call Stack에 올라감
  2. box.style.transform 실행 → DOM 수정 (아직 화면에 안 그려짐)
  3. for 루프 3초 동안 실행 → Call Stack 점유 중
  4. 이 시간 동안:
    • 렌더링 불가 (Call Stack이 비지 않음)
    • 다른 클릭 이벤트도 대기
    • 스크롤도 안됨
    • 화면이 얼어붙음
  5. 3초 후 작업 끝 → Call Stack 비워짐
  6. 이제야 렌더링 → box가 움직인 게 보임

왜 이런 문제가 발생하는가?

  • Call Stack이 비어야 렌더링 가능
  • 무거운 작업이 Call Stack을 3초 동안 점유
  • 렌더링은 Task 사이에만 발생

6. 실전: 무거운 작업 처리하기

문제 해결 전략

핵심 아이디어: "하나의 큰 Task" → "여러 개의 작은 Task로 나누기"

Task 사이에 렌더링이 발생하므로, 작업을 여러 Task로 분할한다.

해결 방법 1: setTimeout으로 작업 나누기

button.addEventListener('click', () => {
  box.style.transform = 'translateX(100px)';
  
  let i = 0;
  const total = 3000000000;
  const chunkSize = 10000000; // 1000만 번씩
  
  function doChunk() {
    const end = Math.min(i + chunkSize, total);
    
    // 청크 단위로 실행
    for (; i < end; i++) {}
    
    if (i < total) {
      setTimeout(doChunk, 0); // 다음 Task로 미루기
    } else {
      console.log('작업 끝');
    }
  }
  
  doChunk();
});

개선 효과:

Before (하나의 큰 Task):

[────── 3초 작업 ──────][렌더링] ← 3초 후에야 화면 반응

After (여러 작은 Task):

[작업][렌더링][작업][렌더링][작업][렌더링]... ← 즉시 반응

장점:

  • 화면이 즉시 반응
  • 사용자가 다른 작업 가능
  • 추가 클릭 처리 가능

단점:

  • setTimeout(fn, 0)은 최소 4ms 지연

해결 방법 2: requestAnimationFrame

애니메이션 작업에 사용:

function doWork(deadline) {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && hasMoreWork()) {
    // 작업 수행
    performWork();
  }
  
  if (hasMoreWork()) {
    requestAnimationFrame(doWork);
  }
}

requestAnimationFrame(doWork);

특징:

  • 렌더링 직전에 실행
  • 60fps에 맞춰 스케줄링
  • 애니메이션과 동기화

해결 방법 3: requestIdleCallback

긴급하지 않은 작업에 사용:

requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && hasMoreWork()) {
    performWork();
  }
  
  if (hasMoreWork()) {
    requestIdleCallback(doWork);
  }
}, { timeout: 2000 }); // 최대 2초 안에는 실행

특징:

  • 브라우저가 한가할 때 실행
  • 사용자 경험에 영향 최소화
  • timeout으로 최대 대기 시간 보장

API 선택 가이드

용도API이유
애니메이션requestAnimationFrame렌더링과 동기화, 60fps 보장
긴급하지 않은 작업requestIdleCallback유휴 시간 활용, UX 영향 최소
무거운 계산 분할setTimeout간단하고 범용적
우선순위 지정 필요scheduler.postTask()최신 API, 세밀한 제어

7. 정리

핵심 개념 요약

1. JavaScript 엔진 vs 브라우저

  • JavaScript 엔진: 싱글 스레드, Call Stack 하나
  • 브라우저: 멀티 스레드, Web APIs 제공

2. 이벤트 루프

  • 역할: Call Stack 감시 + Queue와 Call Stack 중개
  • 동작: Call Stack 비면 → Microtask 전부 실행 → Task 하나 실행 → 반복

3. Queue 시스템

  • Microtask Queue: 우선순위 높음, 전부 실행
  • Task Queue: 우선순위 낮음, 하나씩 실행

4. 렌더링

  • Task 사이에만 발생
  • Call Stack이 점유되면 렌더링 불가 → UI 블로킹

5. 해결 방법

  • 무거운 작업을 여러 Task로 분할
  • 적절한 API 선택 (setTimeout, rAF, rIC)

참고 자료

profile
프론트엔드 개발자로 살아가기

0개의 댓글