Part 1. 언어와 웹의 본질

자바스크립트의 비동기 처리와 이벤트 루프: Promise는 어떻게 동작하는가?

자바스크립트의 비동기 처리와 이벤트 루프: Promise는 어떻게 동작하는가?

자바스크립트는 싱글 스레드(Single Thread) 언어다. 이 말은 한 번에 하나의 작업만 처리할 수 있다는 뜻이다. 그런데 우리가 사용하는 웹 사이트는 그렇게 동작하지 않는다. 데이터를 가져오면서 동시에 스크롤을 내릴 수 있고, 이미지를 로딩하면서 버튼을 클릭할 수도 있다.

한 번에 하나만 한다면서, 어떻게 이 모든 일이 동시에 일어나는 것처럼 보일까?

그 비밀은 바로 자바스크립트 엔진 뒤에서 열심히 일하는 이벤트 루프(Event Loop)에 있다. 오늘은 리액트 개발자가 반드시 알아야 할 자바스크립트의 비동기 처리 원리를 파헤쳐 본다.

싱글 스레드의 딜레마

크리스가 쇼핑몰 대시보드를 만들고 있다고 가정해보자. 주문 목록을 서버에서 받아오는 코드를 작성했다.

// dashboard.js
const orders = fetchOrdersSync(); // 5초 걸림
render(orders);
showFooter();`

만약 자바스크립트가 모든 것을 동기적(Synchronous)으로 처리한다면 어떤 일이 벌어질까? fetchOrdersSync가 끝날 때까지 5초 동안 브라우저는 "멈춤" 상태가 된다. 클릭도, 스크롤도 되지 않는다. 사용자는 이 "먹통"인 화면을 보고 바로 떠나버릴 것이다.

이 문제를 해결하기 위해 자바스크립트는 비동기(Asynchronous) 방식을 채택했다. 오래 걸리는 일은 "일단 맡겨두고", 다음 코드부터 실행하는 것이다.

이벤트 루프와 친구들

이 비동기 처리를 이해하려면 자바스크립트 엔진이 돌아가는 구조를 알아야 한다. 크게 세 가지 요소가 협력한다.

  • Call Stack (호출 스택): 자바스크립트가 해야 할 일을 쌓아두는 곳이다. 여기 들어온 코드는 즉시 실행된다. 싱글 스레드이므로 스택은 딱 하나다.
  • Web APIs: 브라우저가 제공하는 API다. setTimeout, fetch, DOM Event 등이 여기 속한다. 시간이 걸리는 작업은 여기서 처리된다.
  • Task Queue (태스크 큐): Web API에서 처리가 끝난 작업들이 Call Stack으로 가기 위해 기다리는 대기실이다.
  • 그리고 이들 사이를 조율하는 교통정리 담당자가 바로 이벤트 루프(Event Loop)다.

    이벤트 루프의 역할: Call Stack이 비어있는지 계속 확인한다. 비어있다면 Task Queue에서 대기 중인 작업을 꺼내 Call Stack으로 옮긴다.

    실전 동작 원리: Promise vs setTimeout

    이제 코드로 확인해보자. 아래 코드는 어떤 순서로 출력될까?

    // async-quiz.js
    console.log('1. 시작');
    
    setTimeout(() => {
      console.log('2. 타임아웃');
    }, 0);
    
    Promise.resolve().then(() => {
      console.log('3. 프로미스');
    });
    
    console.log('4. 끝');

    직관적으로 보면 setTimeout의 시간이 0ms이니 바로 실행될 것 같고, Promise도 바로 해결되니 순서가 헷갈린다.

    결과는 다음과 같다.

    1. 시작
    4. 끝
    3. 프로미스
    2. 타임아웃

    PromisesetTimeout보다 먼저 실행될까?

    여기서 중요한 개념이 등장한다. Task Queue는 사실 하나가 아니다.

    마이크로태스크 큐(Microtask Queue)의 새치기

    자바스크립트는 우선순위가 다른 두 종류의 큐를 가지고 있다.

  • Macro Task Queue (일반 태스크 큐): setTimeout, setInterval 등이 들어간다.
  • Microtask Queue (마이크로태스크 큐): Promise, MutationObserver 등이 들어간다.
  • 규칙은 간단하다. 이벤트 루프는 Call Stack이 비면, 먼저 Microtask Queue를 확인한다. 여기에 있는 작업을 모두 처리해서 비운 뒤에야, Macro Task Queue에서 작업을 하나 가져온다.

    즉, PromisesetTimeout보다 우선순위가 높다. 일종의 VIP 대기열인 셈이다.


    리액트에서의 활용

    이 원리는 리액트 개발에 어떻게 적용될까?

    1. 상태 업데이트의 일괄 처리 (Batching)

    리액트 18의 자동 배칭(Automatic Batching)이나 비동기 상태 업데이트도 마이크로태스크의 개념과 유사하게 동작하여 불필요한 렌더링을 줄인다.

    2. 무거운 작업 피하기

    이벤트 루프가 멈추지 않게 하는 것이 중요하다.

    나쁜 예시:

    // Call Stack을 점유하여 브라우저를 멈추게 함
    const handleClick = () => {
      // 10억 번 반복하는 무거운 계산
      heavyCalculation(); 
      setCount(c => c + 1);
    };

    ✅ 좋은 예시:

    무거운 계산이 필요하다면 Web Worker를 사용하거나, 작업을 잘게 쪼개어 setTimeout으로 이벤트 루프에게 숨 쉴 틈을 줘야 한다.

    핵심 정리

    자바스크립트 비동기 처리의 핵심은 "기다리지 않음"과 "우선순위"다.

  • 싱글 스레드: 자바스크립트는 한 번에 하나의 일만 Call Stack에서 처리한다.
  • 이벤트 루프: Call Stack이 비면 큐에서 작업을 가져와 실행시킨다.
  • 우선순위: Promise (Microtask)는 setTimeout (Macro Task)보다 항상 먼저 실행된다.
  • 이제 비동기 코드를 작성할 때, 내 코드가 어느 큐에 줄을 서게 될지 상상해보자. 실행 순서가 보이기 시작할 것이다.


    🔗 참고 링크

  • EventLoop
  • Promise
  • setTimeout

  • 다음 글 예고:

    타입과 비동기를 정복했으니, 이제 리액트의 핵심 엔진으로 들어갈 차례다. 우리가 흔히 쓰는 useState. 단순히 값을 저장하는 것 같지만, 내부에는 클로저(Closure)라는 자바스크립트의 오래된 마법이 숨어있다.

    "useState가 값을 기억하는 원리" 편에서 계속된다.