Chaerrot🥕

싱글 스레드 자바스크립트가 동시성을 처리하는 방법: 이벤트 루프 이해하기

2024-12-26
11
22

들어가며

10월 초부터 스터디를 통해 '모던 자바스크립트 딥다이브' 완독에 도전하고 있다.
비동기 프로그래밍 챕터에서 이벤트 루프에 대한 설명을 보고 좀 더 깊게 알아보고 싶어서 정리한 내용이다.

동시성(concurrency)

이벤트 루프에 대해 알아보기 전에, 자바스크립트의 동시성 개념에 대해 알 필요가 있다.
자바스크립트는 싱글 스레드 언어이다. 즉, 한 번에 하나의 태스크만 처리할 수 있다는 것을 의미한다.

그러나 브라우저를 사용할 때를 떠올려보면, HTTP 요청을 통해 다른 데이터를 가져오면서도 동시에 UI를 렌더링하거나, 사용자 입력 이벤트를 처리하는 등 많은 작업들이 동시에 처리되는 것처럼 보인다.
이러한 ‘동시에 실행되는 것처럼 보이는’ 작업들은 자바스크립트의 동시성을 통해 이루어진다.

이처럼, 동시성은 작업(task)들이 빠르게 전환하면서 실행되어, 동시에 실행되는 것처럼 보이는 것을 말한다.

자바스크립트는 동시성을 보장하기 위해 비동기 처리와 Non-blocking I/O 작업을 적극적으로 활용한다.

  • 비동기 처리: 작업이 끝날 때까지 기다리지 않고, 나중에 결과를 처리할 수 있는 방식.
  • Non-blocking I/O: 파일 읽기/쓰기, 네트워크 요청 등 I/O 작업을 수행하는 동안 메인 스레드가 멈추지 않고 다른 작업을 처리할 수 있게 한다. 이는 브라우저의 이벤트 루프와 Node.js의 libuv 라이브러리를 통해 이루어진다.

그렇다면, 이벤트 루프는 어떻게 자바스크립트의 동시성을 지원하는 것일까?

이벤트 루프가 동시성을 지원하는 방법

자바스크립트 엔진은 오로지 소스코드의 평가와 실행만을 담당한다.

실제로 비동기 작업들을 스케줄링하고 실행하는 것은 자바스크립트를 구동하는 런타임 환경(브라우저 또는 Node.js)의 역할이다.

Web API

브라우저는 자바스크립트 엔진과 별개로 Web API를 제공한다. Web API는 브라우저에서 제공하는 다양한 기능들의 집합이다.

  • DOM API: 문서 객체 조작 기능 (예: getElementById, addEventListener)
  • Timer API: 타이머 관련 기능 (예: setTimeout, setInterval)
  • AJAX/Fetch API: HTTP 요청 기능
  • Web Storage API: 로컬 저장소 관련 기능
  • WebSocket API: 실시간 양방향 통신 기능

이러한 Web API들은 자바스크립트 엔진의 메인 스레드와는 별개로 브라우저의 다른 스레드에서 실행된다.

즉, 자바스크립트 엔진은 싱글 스레드 방식으로 동작하지만, 이를 구동하는 환경인 브라우저는 멀티 스레드 방식으로 동작하기 때문에 동시성을 지원할 수 있다.

이벤트 루프의 동작 과정

이벤트 루프는 다음과 같은 과정을 반복한다.

이벤트 루프의 동작 과정이벤트 루프의 동작 과정

  1. 콜 스택 확인: 현재 실행 중인 작업(실행 컨텍스트)이 콜 스택에 있는지 반복해서 확인한다.
  2. 태스크 큐 확인: 콜 스택이 비어 있으면, 태스크 큐(그림의 Callback Queue)에 대기 중인 함수(콜백 함수, 이벤트 핸들러 등)가 있는지 확인한다.
  3. 작업 이동: 태스크 큐에 대기 중인 작업이 있다면, 이를 콜 스택으로 순차적으로 이동시켜(FIFO) 실행한다.

즉, 이벤트 루프는 ‘비동기 함수들을 적절한 시점에 실행시키는 관리자’라고 볼 수 있다.

setTimeout(cb, 0);

setTimeout 메서드를 사용한 예제를 보자.

console.log("Start");
 
setTimeout(() => {
  console.log("Timeout Callback");
}, 1000);
 
console.log("End");

위 코드는 다음과 같이 실행된다.

  1. 1행은 즉시 실행되어 ‘Start’를 출력한다.
  2. setTimeout은 비동기 작업으로, 자바스크립트 엔진은 이 작업을 Web api에게 위임한다.
  3. 7행이 실행되어 ‘End’를 출력한다.
  4. Web api는 해당 비동기 작업을 수행하고, 콜백 함수를 이벤트 루프를 통해 태스크 큐에 넘겨준다.
  5. 1초 후, 태스크 큐에 있던 콜백 함수가 콜 스택으로 이동하여 실행되고, ‘Timeout Callback’을 출력한다.

만약 delay를 0초로 바꾸면 출력 결과가 달라질까?

console.log("Start");
 
setTimeout(() => {
  console.log("Timeout Callback");
}, 0);
 
console.log("End");

출력 결과는 달라지지 않는다.
그 이유는 delay가 0초라고 해도, setTimeout의 콜백 함수는 콜 스택이 비워진 이후에야 실행될 수 있기 때문이다.

그렇다면, setTimeout의 인자로 넘겨주는 delay는 정확한 시간일까? 그렇지 않다.
만약 delay만큼의 시간이 지나고 setTimeout의 콜백 함수가 태스크 큐에 넘어갔더라도, 콜 스택에 매우 많은 양의 함수가 쌓여있다면 그 함수들의 실행이 모두 완료될 때까지(콜 스택이 비워질 때까지) 실행이 지연될 수 있기 때문이다.

따라서, setTimeout의 인자로 넘겨주는 delay는 콜백 함수의 실행을 지연시킬 최소 시간이라고 할 수 있다.

매크로태스크와 마이크로태스크

이벤트 루프에서 처리되는 ‘작업’에는 두 가지 종류가 있다.

  1. 매크로태스크(macrotask)
    • 주로 큰 작업 단위를 의미
    • 일반적으로 setTimeout, setInterval, I/O 작업, 네트워크 요청 등의 비동기 작업이 포함된다.
  2. 마이크로태스크(microtask)
    • 매크로태스크보다 우선순위가 더 높고 작은 작업 단위
    • Promise.then(), Promise.catch(), async/await에서의 코드

매크로태스크와 마이크로태스크는 각각 매크로태스크 큐, 마이크로태스크 큐에서 관리된다.

이벤트 루프는 콜 스택이 비면, 먼저 마이크로태스크 큐에서 대기하고 있는 모든 함수를 가져와 실행한다.
이후 마이크로태스크 큐가 비면, 매크로태스크 큐에서 대기하고 있는 함수를 가져와 실행한다.

이처럼 마이크로태스크는 다른 매크로태스크가 실행되기 전에 처리된다.

이런 처리순서가 아주 중요한 이유는 (마우스 좌표 변경이나 네트워크 통신에 의한 데이터 변경 같이 애플리케이션 환경에 변화를 주는 작업에 영향을 받지 않고) 모든 마이크로태스크를 동일한 환경에서 처리할 수 있기 때문이다.

(출처: 모던 JavaScript 튜토리얼)

코드와 함께 실행 흐름을 정리해보면 다음과 같다.

console.log("Start");
 
setTimeout(() => {
  console.log("setTimeout");
}, 0);
 
Promise.resolve()
  .then(() => {
    console.log("Promise 1");
  })
  .then(() => {
    console.log("Promise 2");
  });
 
console.log("End");
  1. 동기 코드 처리
    • ‘Start’ 출력
    • 'End’ 출력
  2. 마이크로태스크 처리
    • ‘Promise 1’ 출력
    • ‘Promise 2’ 출력
  3. 매크로태스크 처리
    • ‘setTimeout’ 출력

마치며

막연히 ‘setTimeout을 사용하면 함수 호출을 지연할 수 있다’ 정도로만 알고 있었는데, 그 과정에 대해 자세하게 알고 나니 전체적인 흐름이 눈에 그려졌다. 단순히 책을 읽고 마는 것이 아니라 궁금한 부분을 더 찾아보고 정리하면서 학습하니 훨씬 더 재미있게 느껴지기도 했다.
브라우저의 렌더링 과정 또한 이벤트 루프와 관련이 있다고 하는데, 아직 이 부분까지는 자세히 보지 못해서 나중에 한번 더 보고 업데이트할 예정이다.

레퍼런스