Search

    Node.js 동작 원리 (libuv, 이벤트 루프)
    2025.07.15 23 min read

    Node.js 동작 원리 (libuv, 이벤트 루프)

    흔히 Node.js는 단일 스레드 기반의 논블로킹 비동기 방식으로 동작한다고 합니다. 풀어서 설명하자면, 하나의 흐름에서 각 작업이 완료될 때까지 대기하지 않고(논블로킹) 다음 작업을 처리하며 처리된 결과를 먼저 반환해준다는 것입니다.

    여기서 의문이 있습니다. 단일 스레드 특성상 한 번에 하나의 작업만 순차적으로 처리할 수 있는데 Node.js는 어떻게 비동기적으로 작업을 수행할 수 있다는 걸까요?

    핵심은 Node.js에 포함된 비동기 I/O 라이브러리 libuv와 이벤트 루프에 있습니다.


    Node.js 아키텍처

    Node.js의 아키텍처는 아래와 같이 구성됩니다.

    nodejs-architecture

    1. Node.js API

      개발자가 Node.js에서 사용할 수 있는 내장 모듈입니다. (fs, http, stream, path 등)

    2. Bindings

      자바스크립트 코드와 C++로 작성된 로우레벨 API 사이를 연결해 주는 역할을 합니다.

      JS에서 fs.readFile()을 호출하면 실제로 내부에서는 C++로 구현된 libuv의 uv_fs_read() 등을 호출하게 됩니다. 이 징검다리 역할을 해주는 것이 바로 바인딩입니다.

    3. C/C++ addons

      성능이 중요한 경우, 직접 C/C++로 작성된 모듈을 Node.js로 불러와 사용할 수 있습니다.

    4. V8 Engine

      Google에서 만든 JavaScript 실행 엔진으로 자바스크립트 코드를 컴파일하고 실행하는 역할을 합니다.

    5. libuv

      Node.js의 비동기 I/O 처리와 이벤트 루프를 담당하는 핵심 라이브러리입니다.

    6. c-areas(DNS)

      DNS를 처리하기 위한 라이브러리 입니다.

    7. http_parser, zlib, etc.

      Node.js에서 사용하는 내장 C 라이브러리들로 V8이나 자바스크립트가 직접 처리하기 어려운 로우레벨 작업을 수행합니다.

    여기서 핵심 요소인 libuv 라이브러리에 대해 잠깐 살펴보겠습니다.


    libuv

    libuv logo

    libuv는 Node.js에서 논블로킹 비동기 I/O 작업을 가능하게 해주는 C 기반의 라이브러리입니다. 주로 이벤트 루프를 관리하고 자체 스레드 풀(thread pool)을 구성해 시간이 오래 걸리는 작업을 백그라운드에서 병렬로 처리하는 역할을 합니다.

    덕분에 Node.js는 단일 스레드로 동작하면서도 파일 I/O와 같은 무거운 작업은 libuv(스레드 풀)에 위임하여 처리할 수 있습니다. 메인 스레드는 작업이 끝날 때까지 대기하지 않고 다음 작업을 계속 처리하므로 여러 작업을 동시에 처리하는 것처럼 빠르게 동작할 수 있으며 개발자 역시 복잡한 멀티스레드 로직을 신경 쓰지 않고 간단하고 직관적인 방식으로 코드를 작성할 수 있습니다.


    libuv diagram

    출처: libuv 공식 문서

    위 그림은 libuv를 구성하는 다이어그램입니다.

    1. Network I/O

      TCP, UDP 등 네트워크 관련 작업은 대부분 OS에서 논블로킹 시스템 콜을 지원하기 때문에 libuv는 이를 OS에 위임합니다.

    2. File I/O, DNS Ops, User Code

      해당 작업들은 일반적으로 CPU를 많이 사용하거나 OS에서 논블로킹을 지원하지 않습니다. libuv는 자체 스레드 풀에서 작업을 처리합니다.

    3. uv__io_t

      libuv에서 I/O 작업을 등록하고 추적하는 구조체입니다.

      어떤 리소스를 추적할지 정하고 해당 이벤트가 발생했을 때 어떤 콜백을 실행할지 관리합니다.

    4. epoll, kqueue, event ports, IOCP

      운영체제별 비동기 I/O 시스템 콜입니다. libuv는 OS를 감지하여 자동으로 해당 시스템 콜을 사용합니다.

    5. Thread Pool*

      백그라운드에서 동기 작업을 실행하고 완료되면 콜백을 이벤트 루프에 전달하여 실행합니다.


    libuv

    libuv는 모든 I/O 작업에 대해 스레드 풀을 사용하는 것은 아니고 운영체제의 비동기 처리 지원 여부에 따라 처리 방법을 다르게 결정합니다.

    • OS에서 논블로킹 I/O를 지원 O
      → libuv는 OS에 직접 I/O 요청을 맡기고 완료되면 이벤트 루프가 콜백을 처리합니다.
    • OS에서 논블로킹 I/O를 지원 X
      → libuv는 자체적으로 만든 스레드 풀(worker thread pool)을 사용해 작업을 처리합니다.

    아래 간단한 예시로 확인해 보겠습니다.

    1. 동기 작업

    sync

    sync 접미사가 붙은 node.js 메서드는 항상 메인 스레드에서 동기로 실행됩니다.

    const fs = require('fs');
    const startTime = Date.now();
    
    for (let i = 1; i <= 5; i++) {
      fs.readFileSync(`test${i}.txt`, 'utf-8');
      console.log(`#${i} test${i}.txt done: ${Date.now() - startTime}ms`);
    }

    sync test

    2. libuv 스레드 풀 사용

    libuv가 기본으로 생성하는 스레드 풀 크기는 4입니다. 환경 변수 UV_THREADPOOL_SIZE로 최대 128 크기만큼 늘릴 수 있습니다.

    thread pool

    pbkdf2는 대표적인 비동기 함수입니다. 스레드 풀 크기를 5로 설정한 후 실행하면 5개의 연산이 동시에 병렬로 처리되는 것을 확인할 수 있습니다.

    process.env.UV_THREADPOOL_SIZE = 5;
    
    const crypto = require('crypto');
    const startTime = Date.now();
    
    for (let i = 1; i <= 10; i++) {
      crypto.pbkdf2('pw', 'salt', 1000000, 64, 'sha512', () => {
        console.log(`#${i} done: ${Date.now() - startTime}ms`);
      });
    }

    thread pool test

    3. OS 비동기 작업

    OS async

    OS에 위임하여 처리할 때는 OS 내 자체 스레드에서 작업이 수행됩니다. UV_THREADPOOL_SIZE와 관계없이 모든 작업이 동시에 처리되는 것을 확인할 수 있습니다.

    process.env.UV_THREADPOOL_SIZE = 3;
    
    const fs = require('fs');
    const startTime = Date.now();
    
    for (let i = 1; i <= 6; i++) {
      const stream = fs.createReadStream(`test${i}.txt`, { encoding: 'utf-8' });
      stream.on('data', () => {});
      stream.on('end', () => {
        console.log(`#${i} test${i}.txt done: ${Date.now() - startTime}ms`);
      });
    }

    OS async test


    Event Loop

    이벤트 루프는 6단계로 이루어져 있으며 단계(phase)마다 큐를 가지고 있습니다.

    node main.js 명령어로 애플리케이션을 콘솔에서 실행시키면 Node.js는 이벤트 루프를 생성한 후 메인 모듈인 main.js를 실행합니다. 이후 이벤트 루프 내에서 실행할 작업이 있는지 확인하고 단계별로 큐에서 콜백을 꺼내 작업을 수행합니다.

    큐가 모두 비어 수행할 작업이 없다면 루프를 빠져나가고 프로세스를 종료합니다.

    event loop

    자바스크립트 실행은 idle, prepare 단계를 제외한 어느 단계에서나 실행될 수 있습니다. nextTickQueuemicroTaskQueue는 이벤트 루프의 구성 요소는 아니지만 해당 큐에 들어있는 작업들은 루프의 다음 단계에 진입하기 전에 실행되므로 가장 우선순위가 높습니다. (nextTickQueue > microTaskQueue)

    Timer Phase

    이벤트 루프의 첫 진입 단계이며, 해당 단계의 큐에서는 setTimeout이나 setInterval과 같은 타이머 콜백들을 실행합니다. 모든 타이머가 생성되자마자 큐에 들어가는 것은 아니고 별도로 최소 힙(min heap)에 저장하고 있다가 이미 실행할 시간이 지난 타이머들만 들어갑니다.

    최소 힙(min heap)
    • 최소 힙은 완전 이진 트리 형태의 자료구조로 항상 작은 값이 루트 노드에 위치합니다.
      (부모 노드 값 ≤ 자식 노드 값)
    • 항상 최솟값을 O(1) 시간 내 확인할 수 있습니다.

    등록된 타이머들은 최소 힙으로 구성되어 있으므로 실행할 시간이 가장 적게 남은 타이머가 루트가 될 것입니다. 이벤트 루프가 timer phase에 진입하면 최솟값부터 차례로 꺼내어 now - registeredTime ≥ delay를 만족하는 타이머들을 큐에 등록합니다.

    • now: 이벤트 루프가 timer phase에 진입한 시점의 시간
    • registeredTime: 타이머가 등록된 시점의 시간
    • delay: 타이머에 저장된 delay(setTimeout(() ⇒ {}, delay)에서의 delay)

    timer phase

    delay값이 각각 100, 200, 300, 400인 타이머 4개를 특정 시간 t에 등록하고 timer phase에 진입했을 때의 시간을 t+250 이라고 가정해봅시다.

    먼저 A부터 힙에서 꺼내 이미 실행할 시간이 지난 타이머인지 조건을 확인합니다. A는 타이머 실행 시점이 t+100이므로 콜백이 실행됩니다. B도 마찬가지로 조건이 성립되어 실행됩니다.

    반면 C는 실행 시점이 t+300이므로 조건을 만족하지 않습니다. 이 경우 페이즈는 바로 종료되고 다음 단계로 넘어가게 됩니다. C 이후부터는 C보다 항상 실행 시간이 긴 타이머들만 존재하므로 조건을 더 이상 검사하지 않습니다.


    핵심은, setTimeout(fn, delay)의 콜백이 정확하게 delay만큼의 시간이 지난 직후에 실행되는 것이 아니라 delay 이상이 지난 후 가능한 빠른 시점에 실행된다는 것입니다.

    Tip

    타이머를 실행하는 도중 시간이 흐르면 타이머가 계속 실행되는가?

    → 그렇지 않습니다. 현재 시각(now)은 timer phase 진입 시점에 한 번만 갱신됩니다. 이후 타이머를 실행하는 도중 시간이 흘러도 해당 시점을 기준으로 만료된 타이머들만 처리합니다.

    timer phase는 uv_run()uv__update_time()uv__run_timers() 순으로 호출됩니다.

    // src/unix/core.c
    int uv_run(uv_loop_t* loop, uv_run_mode mode) {
      ...
    
      while (r != 0 && loop->stop_flag == 0) {
        ...
        uv__update_time(loop); // 현재 시각 업데이트
        uv__run_timers(loop); // 등록된 타이머 중 실행할 타이머 확인 후 실행
        ...
      }
    }
    
    // src/unix/internal.h
    UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
      loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
    }
    
    // src/timer.c
    void uv__run_timers(uv_loop_t* loop) {
      struct heap_node* heap_node;
      uv_timer_t* handle;
      struct uv__queue* queue_node;
      struct uv__queue ready_queue;
    
      uv__queue_init(&ready_queue);
    
      for (;;) {
        heap_node = heap_min(timer_heap(loop)); // 최소 힙 루트 가져오기
        if (heap_node == NULL)
          break;
    
        handle = container_of(heap_node, uv_timer_t, node.heap);
        if (handle->timeout > loop->time) // 실행 시간이 지난 타이머가 아니면 종료
          break;
    
        uv_timer_stop(handle); // 타이머 제거
        uv__queue_insert_tail(&ready_queue, &handle->node.queue); // 준비 큐 삽입
      }
    
      while (!uv__queue_empty(&ready_queue)) {
        queue_node = uv__queue_head(&ready_queue);
        uv__queue_remove(queue_node); // 준비 큐에서 제거
        uv__queue_init(queue_node);
        handle = container_of(queue_node, uv_timer_t, node.queue);
    
        uv_timer_again(handle); // 반복 타이머 재등록
        handle->timer_cb(handle); // 콜백 실행
      }
    }

    위 코드를 살펴보면 루프 사이클 시작 시점에 uv__update_time()를 한 번만 호출하여 현재 시각을 갱신하는 것을 확인할 수 있습니다. uv__run_timers() 내에서는 해당 시점을 기준으로 만료된 타이머들을 처리합니다.

    타이머 A의 만료 시점이 100ms, 타이머 B의 만료 시점이 101ms, timer phase 진입 시간이 100ms라고 가정한다면, 타이머 A를 처리하면서 도중에 101ms가 되어도 현재 시각은 갱신되지 않기 때문에 B는 실행되지 않습니다.

    실행되어야 하는 타이머들의 콜백이 무한히 실행되는 것은 아니고 시스템 실행 한도(hard limit)에 도달하면 다음 단계로 넘어갑니다.

    pending callbacks phase

    pending phase는 이전 루프에서 실행되지 못하고 대기하던 시스템 I/O 콜백들을 실행하는 단계입니다. 해당 콜백들이 담긴 큐를 pending_queue라고 하며, 큐가 비거나 시스템 실행 한도에 도달할 때까지 대기 중인 콜백들을 실행합니다.

    static void uv__run_pending(uv_loop_t* loop) {
      struct uv__queue* q;
      struct uv__queue pq;
      uv__io_t* w;
    
      uv__queue_move(&loop->pending_queue, &pq);
    
      while (!uv__queue_empty(&pq)) {
        q = uv__queue_head(&pq);
        uv__queue_remove(q);
        uv__queue_init(q);
        w = uv__queue_data(q, uv__io_t, pending_queue); // 콜백 정보 가져오기
        w->cb(loop, w, POLLOUT); // 콜백 함수 호출
      }
    }

    idle, prepare phase

    idle과 prepare 단계는 Node.js의 내부적인 관리를 위해 존재합니다. idle phase는 매 틱(Tick)마다, prepare phase는 매 폴링(Polling) 직전에 실행됩니다.

    틱(Tick)

    이벤트 루프의 한 사이클(timers → pending → idle/prepare → poll → check → close)을 의미합니다. 문맥에 따라 phase 간 이동을 뜻하기도 합니다.(e.g. nextTick)

    poll phase

    이벤트 루프 내에서 가장 중요한 단계인 poll phase는 새로운 I/O 이벤트를 가져와 관련 콜백들을 실행합니다. 해당 콜백들이 담긴 큐는 watcher_queue이며 파일 읽기, 네트워크 요청, DNS 조회 등의 작업 등이 해당됩니다.

    watcher_queue에 수행할 콜백들이 존재한다면 큐가 비거나 시스템 실행 한도에 도달할 때까지 모든 콜백을 동기적으로 실행합니다.

    다른 phase들과 다른 점이 하나 있는데, 큐가 비어 있다면 곧바로 다음 단계로 이동하는 것이 아니라 조건에 따라 잠시 대기할지 다음 단계로 넘어갈지 확인한다는 것입니다.

    int uv_run(uv_loop_t* loop, uv_run_mode mode) {
      ...
      while (r != 0 && loop->stop_flag == 0) {
        ...
        timeout = 0;
        if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
          timeout = uv__backend_timeout(loop);
    
        uv__metrics_inc_loop_count(loop);
    
        uv__io_poll(loop, timeout);
        ...
      }
    }

    uv__backend_timeouttimeout 값을 계산하여 반환하는 함수입니다. 여기서 timeout이 0보다 크다면 새 I/O 작업이 들어올 때까지 해당 시간만큼 blocking 상태로 대기합니다. timer phase로 바로 이동하지 않는 이유는 이동한다고 하더라도 아직 타이머를 수행할 수 있는 시간이 되지 않았기 때문에 어차피 이벤트 루프를 한 번 더 돌아야 하기 때문입니다.

    timeout이 0이면 대기하지 않고 바로 반환(이벤트가 없으면 다음 단계로 이동)하고 -1이라면 이벤트가 있을 때까지 무기한 대기합니다.

    // src/unix/core.c
    static int uv__backend_timeout(const uv_loop_t* loop) {
      if (loop->stop_flag == 0 &&
          (uv__has_active_handles(loop) || uv__has_active_reqs(loop)) &&
          uv__queue_empty(&loop->pending_queue) &&
          uv__queue_empty(&loop->idle_handles) &&
          (loop->flags & UV_LOOP_REAP_CHILDREN) == 0 &&
          loop->closing_handles == NULL)
        return uv__next_timeout(loop); // 타이머를 기준으로 timeout 계산
      return 0; // 위 경우에 해당하지 않는 경우 루프를 다음 phase로 넘기기 위해 non-blocking로 반환
    }
    
    // src/timer.c
    int uv__next_timeout(const uv_loop_t* loop) {
      const struct heap_node* heap_node;
      const uv_timer_t* handle;
      uint64_t diff;
    
      heap_node = heap_min(timer_heap(loop));
      if (heap_node == NULL)
        return -1;
    
      handle = container_of(heap_node, uv_timer_t, node.heap);
      if (handle->timeout <= loop->time)
        return 0; // 실행 시각이 이미 지났거나 현재 시각과 같으면 non-blocking
    
      diff = handle->timeout - loop->time;
      if (diff > INT_MAX)
        diff = INT_MAX;
    
      return (int) diff; // 타이머 실행 시각까지 남은 시간 반환
    }

    check phase

    check phase는 setImmediate()로 등록된 콜백 함수들을 실행하는 단계입니다. 해당 단계 역시 큐가 비거나 시스템 실행 한도에 도달할 때까지 콜백을 실행합니다.


    setTimeout(), setImmediate() 비교

    setTimeout과 setImmediate는 I/O 사이클 내부에서 호출되는지 여부에 따라 실행 순서가 달라질 수 있습니다.

    예시로 아래와 같이 메인 스크립트 상에서 두 함수를 실행했을 때는 실행 결과가 매번 달라지게 됩니다.

    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));

    setTimeout은 timer phase, setImmediate는 check phase에서 실행됩니다. 각 phase에 진입하는 순서는 Node.js 내부 실행 흐름과 OS 스케줄링 타이밍에 따라 달라지므로 실행 순서가 보장되지 않는 것입니다.

    여러 번 실행시켜 보면 매번 다른 결과가 나오는 것을 확인할 수 있습니다.

    setTimeout vs setImmediate 1


    I/O 사이클 내부에서 두 함수를 호출하면 어떨까요?

    const fs = require('fs');
    fs.readFile(__filename, () => {
      setTimeout(() => console.log('timeout'), 0);
      setImmediate(() => console.log('immediate'));
    });

    이 경우 설정된 타이머 수와 관계없이 항상 setImmediatesetTimeout보다 먼저 실행됩니다. setImmediate는 I/O 작업이 처리되는 poll phase 다음 순서인 check phase에 등록되므로 I/O 콜백 이후에 실행되는 것이 보장되기 때문입니다.

    여러 번 실행해도 아래와 같이 항상 setImmediate 콜백이 먼저 실행됩니다.

    setTimeout vs setImmediate 2


    정리
    • I/O 작업 완료 직후 콜백을 실행하고 싶다면 setImmediate()를 사용합니다.
    • 일정 시간 동안 실행을 지연시켜야 할 때는 setTimeout()을 사용합니다.

    close callbacks phase

    socket.on(’close’, () ⇒ {})과 같은 close나 socket.destroy()와 같은 destory 이벤트 타입의 콜백이 처리되는 단계입니다. 해당 페이즈가 완료되면 이벤트 루프는 다음 루프에서 처리할 작업이 있는지 확인하고 다시 루프를 시작하거나 종료합니다.

    nextTickQueue, microTaskQueue

    nextTickQueuemicroTaskQueue는 libuv 라이브러리에 포함되어 있는 것이 아니라 Node.js에 포함되어 있기 때문에 이벤트 루프의 일부는 아닙니다. 두 큐는 각각 process.nextTick()의 콜백, resolve된 Promise의 콜백을 가지고 있으며 각 콜백 실행 직후에 실행됩니다. 시스템 실행 한도의 영향을 받지 않고 큐가 모두 비워질 때까지 처리됩니다.

    setTimeout(() => console.log('timeout1'));
    setTimeout(() => {
      console.log('timeout2 start');
      process.nextTick(() => console.log('nextTick'));
      Promise.resolve().then(() => console.log('microtask'));
      console.log('timeout2 end');
    }, 0);
    setTimeout(() => console.log('timeout3'));

    nextTick 1

    timeout2의 콜백이 모두 실행된 후 process.nextTick()Promise.then()이 실행되는 것을 확인할 수 있습니다. nextTick과 microTask의 순서를 변경해도 nextTick의 우선순위가 높기 때문에 출력 결과는 동일합니다.


    좀 더 복잡한 예시를 살펴보겠습니다.

    const fs = require('fs');
    
    setImmediate(() => console.log('immediate1'));
    setTimeout(() => console.log('timer1'));
    Promise.resolve().then(() => console.log('microtask'));
    
    fs.readFile(__filename, 'utf-8', () => {
      setTimeout(() => console.log('timer2'));
      process.nextTick(() => console.log('nextTick2'));
      setImmediate(() => console.log('immediate2'));
      console.log('read file');
    });
    
    process.nextTick(() => console.log('nextTick1'));
    console.log('End');

    nextTick 2

    출력 순서는 다음과 같습니다.

    1. [Sync]
      메인 동기 코드 “End”가 실행됩니다.
    2. [NextTick, MicroTask]
      우선순위가 높은 nextTick 콜백(”nextTick1”)이 먼저 실행되고 다음으로 promise 콜백(”microtask”)이 실행됩니다.
    3. [Timer Phase]
      첫 번째 타이머 “timer1”이 실행됩니다.
    4. [Check Phase]
      readFile 작업이 완료되지 않았기 때문에 check phase로 넘어가 “immediate1”가 먼저 실행됩니다.
    5. [Poll Phase]
      • readFile 내 콜백 “read file”이 실행됩니다.
      • process.nextTick, setImmediate 순서로 실행됩니다.
    6. [Timer Phase]
      두 번째 타이머 “timer2”가 실행됩니다.

    References

    Design overview - libuv documentation
    Node.js github
    NodeJS runtime environment: Libuv Library (Event loop, Thread pool)
    Comprehensive Guide to Libuv with Node.js Examples
    Libuv Thread Pool vs OS Threads in Node.js
    Node.js — The Node.js Event Loop
    [번역] 로우 레벨로 살펴보는 Node.js 이벤트 루프
    [stackoverflow] Does the nodejs (libuv) event loop execute all the callbacks in one phase (queue)…

    TAGS

    Node.js
    Event Loop