NodeJS의 libuv 역할

Node.js 비동기 처리의 핵심, libuv! 이벤트 루프의 단계(Phase)별 제어 흐름과 Thread Pool 작동 원리에 대해 알아보자

August 24, 2025

앞서 왜 자바스크립트가 싱글 스레드이고, 어떻게 멀티 스레드처럼 지원하는지 알아보았다. 오래 걸리는 작업은 브라우저에 위임하는게 핵심이었는데, 서버용 JS 환경인 node.js에서는 어떻게 동작할까? 이번 포스팅에서 좀더 자세하게 알아보도록 하겠다.

브라우저와 node.js에서 이벤트 루프 동작 방식이 다르다.

  • 브라우저 → 브라우저 내장 이벤트 루프
  • Node.js → libuv가 이벤트 루프 구현

코드 레벨에서는 순차적으로 실행되는 것처럼 보이지만, 실제로 Node.js 환경에서 libuv 스레드풀, V8 GC, 이벤트 루프 등이 내부에서 별도로 동작하고 있다.

Node.js 구성 요소

Node.js 프로세스
├── Node.js Core Library      ← JS로 작성된 표준 모듈 (fs, http, path 등)
├── Node.js Bindings          ← C/C++ ↔ JS 연결 브릿지
├── V8 Engine                 ← JS 코드 실행 (싱글 스레드)
└── libuv                     ← 이벤트 루프 + 비동기 I/O + 스레드풀

Node.js는 런타임 언어이고, 크게 내장 라이브러리, V8 엔진, 이벤트 루프(libuv)로 구성되어 있다.

이 구성 요소 중 Node.js가 하나의 스레드로 여러 작업을 블로킹 없이 수행할 수 있게 하는 핵심 요소는 libuv 라이브러리 덕분이다.

libuv란?

C언어 기반의 비동기 I/O 라이브러리

libuv는 비동기 I/O 작업, 이벤트 처리, 동시성 및 기타 시스템 관련 기능에 대한 크로스 플랫폼 지원을 제공하는 라이브러리이다.

운영체제들의 커널(운영체제의 핵심부로 컴퓨터 자원들을 관리하는 역할)을 추상화해 다양한 운영 체제에서 일관되게 작동한다.

Node.js 에서의 libuv가 하는 일

자바스크립트 엔진은 내부적으로 비동기 처리를 할 수 없다. 때문에 비동기로 처리되는 코드를 만날 경우 libuv 라이브러리를 통해 비동기 작업을 처리하게 된다.

libuv에 비동기 작업을 요청하면 libuv는 uv_io 를 통해 커널이 지원하는지 확인하고, 지원한다면 커널에 요청해 받은 응답을 전달한다.

1️⃣ 이벤트 루프 역할

libuv는 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식을 따르고 있다. (이벤트 기반 비동기 처리)

만약 여러 이벤트가 동시에 발생했을 때 어떤 순서로 콜백 함수를 호출할지를 이벤트 루프가 판단해 실행하게 된다.

Phase별 동작

  • Timer
  • Pending I/O Callbacks
  • Idle, Prepare
  • Poll
  • Check
  • Close Callbacks

목록의 순서대로 호출되며, 각각의 단계는 자신만의 큐를 가지고 있다. 작업을 등록하면 유형에 맞게 각각 큐에 등록이 된다.

Phase별 동작 이해

먼저 페이즈에서 페이즈로 넘어가는걸 Tick 이라고 정의한다.

Timer

루프의 시작을 알리는 페이즈.

주로 setTimeout, setInterval에 등록된 콜백을 관리한다. 여기서는 만료된 setTimeout/setInterval 콜백을 그 자리에서 바로 실행한다.

Timer 콜백은 min-heap 자료구조 기반으로 작성되어있다.

min-heap
완전이진트리(complete binary tree)의 일종으로, 최솟값을 빠르게 찾아내기 위한 자료구조. 이를 통해 이벤트 루프가 가장 짧은 지연으로 타이머를 효율적으로 검색할 수 있도록 보장받는다.

Pending Callbacks

이전 루프에서 처리 못한 콜백을 실행하는 페이즈.

각 페이즈는 모든 작업을 실행하지 않고 일정량만 수행 후 다음 페이즈로 넘어가기 때문에 처리하지 못한 작업(pending_queue)을 수행한다.

Idle, Prepare

이 페이즈는 매 틱마다 수행된다. Node.js 의 내부 관리를 위해 사용된다.

Poll

I/O 관련 콜백을 처리하는 페이즈.

setTimeout/setInterval 콜백을 제외한 I/O 관련 콜백을 처리하는 단계다. 타이머 콜백은 항상 Timer 단계에서만 실행된다.

해당 페이즈가 검사하는 큐 이름은 watcher_queue이며, 비동기 작업이 완료되었을 때 순서를 보장하기 위해 사용한다.

큐가 비어있지 않다면 작업을 순차적으로 처리하고, 비어있다면 다음 페이즈로 즉시 넘어가지 않고 일정 시간 대기한다.

대기 시간은 Timer 단계에 등록된 가장 가까운 타이머 만료 시간을 기준으로 결정된다. 만료 시간이 됐다면 루프를 Timer 단계로 전환하고, 아직 남아있다면 그때까지 I/O 이벤트를 기다린다.

Check

setImmediate 로 등록된 콜백을 관리하기 위한 페이즈이다.

Close Callbacks

socket.on('close', cb)

코드와 같이 close, destory 이벤트 타입 콜백을 처리하는 단계이다.

2️⃣ Thread Pool

위에서 말했다싶이 libuv에 파일 읽기와 같은 비동기 작업을 요청하면 uv_io 를 통해 커널이 지원하는지 확인한다. 만약 지원한다면 libuv가 uv_io를 통해 커널에게 비동기적으로 요청하지만, 아니라면 자신만의 워커 스레드가 담긴 스레드 풀을 사용한다.

왜 스레드풀이 필요한가?

Node.js는 싱글 스레드이다. JS 코드가 실행되는 메인 스레드는 하나이다. 그런데 파일 읽기처럼 시간이 걸리는 작업을 메인 스레드에서 직접 하면 그동안 아무것도 못한다.

그래서 libuv는 두 가지 방법으로 이 문제를 해결한다.

방법 1 — 커널에 맡기기 (uv_io)

운영체제 커널이 비동기 I/O를 직접 지원하는 경우이다.

JS 코드 → libuv → 커널 ("이 파일 읽어줘, 끝나면 알려줘")
                         ↓ 커널이 알아서 처리
메인 스레드는 다른 작업 계속 진행
                         ↓ 완료되면
커널 → libuv → Poll Phase → 콜백 실행

네트워크 소켓 I/O가 대표적이다. 커널이 직접 비동기로 처리해주기 때문에 별도 스레드가 필요 없다.

방법 2 — 스레드풀 사용

커널이 비동기를 지원하지 않는 경우이다.

파일 시스템(fs)이 대표적이다. 파일 읽기는 운영체제마다 비동기 지원이 달라서 libuv가 직접 처리한다.

JS 코드 → libuv → 스레드풀의 워커 스레드에게 위임
                   ("너가 이 파일 읽어줘")
메인 스레드는 다른 작업 계속 진행
                   ↓ 워커 스레드가 동기적으로 파일 읽기
                   ↓ 완료되면 결과를 이벤트 루프에 전달
Poll Phase → 콜백 실행

Thread Pool에서 처리되는 작업들은 다음과 같다.

  • file system : fs.FSWatcher()와 synchronous fs 제외
  • DNS : dns.lookup(), dns.lookupService()
  • Crypto : crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(),
  • Zlib : synchronous API 제외

libuv는 기본적으로 4개의 스레드를 가지는 스레드풀을 생성해서 4가지 작업을 동시에 처리할 수 있다. 환경변수를 통해 스레드를 128개까지 늘릴 수 있다.