frontend/javascript

🟨 2-27. 비동기 코드의 병목 해결 — Event Loop, Promise, Web API의 완전한 협력 구조

mirabo01 2025. 11. 7. 08:57

1. 비동기란 무엇인가?

비동기(Asynchronous)란
“코드가 순서대로 끝나지 않아도 다음 작업이 실행되는 구조” 를 말한다.

자바스크립트는 싱글 스레드(single thread) 언어이지만,
브라우저의 Web API, Task Queue, Microtask Queue 덕분에
마치 병렬처럼 작동한다.

🧠 핵심 개념

  • JS는 하나의 실행 스레드만 가진다.
  • 하지만 Web API(브라우저)가 백그라운드에서 일을 대신 처리한다.
  • JS는 그 결과를 큐(Queue)로 받아 순서대로 실행한다.

2. 자바스크립트는 싱글 스레드, 브라우저는 멀티 스레드

많은 입문자들이 혼동하는 부분이다.
자바스크립트 자체는 싱글 스레드이지만,
브라우저는 멀티 스레드 구조로 동작한다 👇

구성 요소 역할

JS 엔진(V8) 코드 실행 (싱글 스레드)
Web API 타이머, 이벤트, 네트워크, DOM 등 별도 스레드
Task Queue 완료된 작업의 콜백을 보관
Event Loop 큐를 감시하며 실행 순서 조율

즉,
“자바스크립트는 일을 시키고, 브라우저는 대신 처리하고, 결과만 다시 받는 구조.”


3. 비동기 코드 흐름의 실제 동작

console.log("A");

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

console.log("C");

실행 결과 👇

A
C
B

이유를 순서대로 보면 이해가 된다.

1️⃣ JS 엔진이 console.log("A") 실행
2️⃣ setTimeout()은 Web API에게 콜백 위임
3️⃣ JS는 다음 코드(C)를 바로 실행
4️⃣ Web API는 0초 뒤 콜백을 Task Queue에 등록
5️⃣ Event Loop가 Stack이 비는 순간 Queue의 콜백(B) 실행


4. 브라우저 내부 흐름 시각화

JS Thread
 ├── Call Stack: 실행 중인 코드
 ├── Microtask Queue: Promise 등 우선순위 높은 작업
 ├── Task Queue: setTimeout, I/O 콜백
 └── Event Loop: 스택 비면 큐에서 꺼내 실행

Web API Thread
 ├── Timer
 ├── Network Request
 ├── DOM Event Handler
 └── Web Worker

✅ 브라우저는 JS의 요청을 Web API로 넘기고,
✅ JS는 그동안 멈추지 않고 다른 코드를 계속 실행한다.


5. Microtask vs Task의 차이

비동기 작업에도 “우선순위”가 존재한다.

구분 예시 실행 시점 특징

Microtask Promise.then, queueMicrotask Stack 종료 직후 더 빠름
Task setTimeout, setInterval, fetch 다음 Loop 주기 느림

예시 👇

console.log("1");

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

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

console.log("4");

결과 👇

1
4
3 (Microtask)
2 (Task)

6. 병목이 생기는 이유

비동기 코드가 많아질수록 큐의 순서와 우선순위가 꼬이는 현상이 발생한다.

setTimeout(() => console.log("timeout 1"), 0);
Promise.resolve().then(() => console.log("promise 1"));
setTimeout(() => console.log("timeout 2"), 0);
Promise.resolve().then(() => console.log("promise 2"));

결과 👇

promise 1  
promise 2  
timeout 1  
timeout 2

✅ Promise가 먼저 쌓이기 때문에 Task보다 먼저 실행
➡ 비동기 코드가 많을수록 순서 제어가 어려워짐
➡ “병목”이 아니라 “우선순위 혼잡”으로 느려짐


7. 병목을 해결하는 3가지 전략

(1) Web Worker 사용 — CPU 부하 분산

자바스크립트는 싱글 스레드이므로,
무거운 연산은 Web Worker로 분리해야 한다.

// main.js
const worker = new Worker("worker.js");
worker.postMessage({ n: 1000000000 });
worker.onmessage = e => console.log("결과:", e.data);

// worker.js
onmessage = e => {
  let sum = 0;
  for (let i = 0; i < e.data.n; i++) sum += i;
  postMessage(sum);
};

✅ Worker는 별도의 스레드에서 동작하므로
UI 프리징 없이 백그라운드 계산 가능.


(2) requestIdleCallback 활용 — 브라우저가 한가할 때 실행

requestIdleCallback(() => {
  console.log("브라우저가 한가할 때 실행됨");
});

✅ 렌더링 중단 없이 실행
✅ React 18의 Concurrent Mode가 내부적으로 이 메커니즘을 활용


(3) Debounce & Throttle — 이벤트 폭주 제어

마우스, 스크롤, 리사이즈 이벤트는 초당 수백 번 발생할 수 있다.

Debounce (마지막 이벤트만 실행)

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

Throttle (일정 간격마다 실행)

function throttle(fn, delay) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= delay) {
      last = now;
      fn(...args);
    }
  };
}

✅ 브라우저의 Task Queue 부담을 획기적으로 줄이는 핵심 패턴이다.


8. async/await 내부의 진실

async/await은 문법적으로 깔끔하지만,
내부적으로는 Promise + Microtask Queue를 사용한다.

async function test() {
  console.log("1");
  await Promise.resolve();
  console.log("2");
}
test();
console.log("3");

결과 👇

1
3
2

✅ await 뒤의 코드는 Microtask Queue에 등록되어
현재 Stack이 끝난 후 실행된다.


9. 병목 제거 실전 예시

async function fetchData() {
  const res = await fetch("/api/data");
  const json = await res.json();
  render(json);
}

function render(data) {
  data.forEach(item => {
    const el = document.createElement("div");
    el.textContent = item.name;
    document.body.appendChild(el);
  });
}

❌ 문제: render()에서 수천 개 DOM 추가 → Reflow 폭발

✅ 개선

function render(data) {
  const frag = document.createDocumentFragment();
  data.forEach(item => {
    const el = document.createElement("div");
    el.textContent = item.name;
    frag.appendChild(el);
  });
  document.body.appendChild(frag);
}

✅ DOM 변경 횟수를 1회로 줄여
Task Queue와 Repaint 병목을 동시에 해결.


10. 병목 진단 도구

도구 용도

Performance 탭 스크립트 실행 순서, FPS 확인
Memory 탭 이벤트 루프 대기 중 객체 확인
Network 탭 fetch 대기 병목 탐지
Web Vitals LCP, FID, CLS 등 UX 지표 측정

✅ 특히 Performance 탭에서 “Main Thread” 구간이 길면,
JS 병목으로 인한 렌더링 지연 가능성이 높다.


11. 최적화 정리

문제 해결법

비동기 순서 꼬임 async/await 통일
타이머 중첩 clearInterval / clearTimeout
이벤트 폭주 debounce, throttle
렌더링 지연 DocumentFragment / requestIdleCallback
CPU 부하 Web Worker 분리
Promise 과다 Microtask 정리 및 체인 최소화

12. 마무리

비동기 병목을 해결한다는 건,
단순히 “코드를 빠르게 만든다”가 아니라
JS 엔진, 브라우저, Web API가 서로 협력하는 리듬을 이해하는 것이다.

“비동기 최적화는 순서 제어가 아니라,
브라우저와 자바스크립트의 타이밍을 조율하는 일이다.”