Nodejs

Node.js 의 개념과 동작원리

sihanni 2025. 5. 27. 15:40

1. Node.js 과 철학

"Node.js는 JavaScript 런타임 환경이다."

런타임 환경이란 프로그래밍 언어가 동작하는 데 필요한 엔진, API, 라이브러리, 실행 환경 전체를 의미한다.

JavaScript는 과거에는 웹 브라우저에서만 동작하는 프로그래밍 언어였지만 구글의 V8 JS *엔진이 나오면서 JS의 실행 속도가 서버 사이드에서도 사용할 수 있는 수준으로 비약적으로 향상되었다. 그렇다고 원래 JS의 서버사이드 프레임 워크가 없었던 것은 아니라고 한다. 하지만 V8 엔진이 나오기 전까지 그 성능이 미흡했다고 한다.

 

JS의 단일 스레드 + 이벤트 루프와 OS의 비동기 I/O를 직접 결합하는 방식으로 설계되었다.

그 결과, 네트워크 요청, 파일 읽기/쓰기, DB 쿼리와 같이 시간이 오래 걸리는 I/O 작업을 운영체제 (libuv 라이브러리)가 처리하고, JS 엔진은 콜백(또는 Promise/async-await)만 빠르게 실행하는 논블로킹(Non-blocking) 구조를 갖추게 되었다.

이러한 구조 덕분에 Node.js는 높은 동시성 처리 성능과 빠른 응답 속도를 제공하며, 특히 실시간 서비스, 채팅 서버, 스트리밍 서버 등에서 널리 쓰이게 되었다.

 

Node.js의 철학

  • 비동기 (Asynchronous) & 논블로킹 (Non-blocking) I/O
    • I/O 작업(ex. 파일 읽기(fs.readFile()), 네트워크 요청 등)을 할 때, 작업이 끝날 때까지 기다리지 않고 다음 작업을 계속 수행
  • 단일 스레드 기반 이벤트 루프 (Single-threaded Event Loop)
    • 하나의 스레드로 많은 클라이언트 요청을 처리한다.
  • 모듈화 (Modularity)와 간결한 핵심 (Core Minimalism)
    • 핵심 기능만 제공하고 나머지는 모듈로 분리한다.
    • 가볍고 유연한 구조 유지를 지향한다.
    • ex. express, Socket.io 등과 같이 Node.js에 내장되어 있지 않은 것은 패키지 매니저(npm, yarn)으로 별도 설치
  • 에러 처리 패턴
    • Node.js의 비동기 구조에서는 에러 처리를 위한 방식도 중요하다.  
      초기에는 콜백 함수에서 `error-first callback` 패턴을 사용했지만, 이후 Promise와 async/await의 등장으로 더 직관적인 에러 처리와 흐름 제어가 가능해졌다.

 

이 Node.js를 조금 더 자세히 알아보자.

 

2. 핵심 구성요소

Node.js는 각 계층이 하단에 있는 API를 사용하는 구조이다.

 

 

Node.js Application (사용자 JS,TS 코드)

  • 개발자가 작성한 JS 또는 TS 코드
  • libuv로 부터 전달받은 callback 함수 실행

Node.js API (http, fs, crypto, buffer...)

  • Node.js가 제공하는 내장 모듈 API
  • C++로 구현된 네이티브 모듈과 JS 표준 라이브러리 코드가 합쳐져 동작한다.
  • 예: fs.readFile() 을 호출하면...
    • fs 모듈이 바인딩을 통해 libuv에 비동기 파일 읽기를 요청함.

Node.js Binding / Node.js Standard Library / C++ Addons

 

  • Node.js Binding
    • JavaScript와 C++ 네이티브 코드를 이어주는 접착제 역할.
    • 예: V8과 OS 함수를 연결해주는 C++ 코드.
    • C++ 바인딩 기능으로 자바스크립트에서 libuv의 API도 사용하는 것이다.
  • Node.js Standard Library
    • 순수 JavaScript로 작성된 표준 라이브러리 (events, path 등).
  • C++ Addons
    • 직접 만든 C++ 모듈을 Node.js에서 불러와 실행할 수 있는 확장 기능.
    • 예: 고성능 연산을 위해 C++로 작성한 이미지 처리 라이브러리를 Node.js에서 사용.

V8 Engine & libuv

 

  • V8 엔진
    • Google이 만든 고성능 오픈 소스 JavaScript 엔진 
    • JavaScript 코드를 기계어로 JIT 컴파일해 실행.
    • Node.js가 JS를 빠르게 실행할 수 있는 핵심.
  • libuv
    • Node.js의 비동기 I/O를 담당.
    • 이벤트 루프, 스레드 풀, 네트워크·파일 시스템 I/O 처리.
    • 예: fs.readFile()을 호출하면, libuv가 스레드풀에서 비동기적으로 파일 읽기를 처리한 뒤 읽은 데이터 버퍼를 V8로 전달하여 콜백을 실행

C-Ares / llhttp(Http Parser) / OpenSSL / zlib

 

  • C-Ares
    • 비동기 DNS 쿼리 처리 라이브러리.
  • llhttp (Http Parser)
    • llhttp는 HTTP 프로토콜을 파싱하는 경량 C 라이브러리로, Node.js의 핵심 HTTP 파서이다.
    • HTTP 요청/응답 메시지를 파싱.
    • 예: HTTP 헤더를 분리하고 메서드/URL 추출.
  • OpenSSL
    • TLS/SSL 암호화, 인증서 검증 등 보안 기능 제공.
  • zlib
    • Gzip/Deflate 압축·해제 라이브러리.

Operating System

  • 최종적으로 모든 요청이 운영체제의 커널 API로 전달됨.
  • 파일 읽기, 네트워크 전송, 메모리 할당 등은 OS가 수행.
  • Node.js는 OS 위에서 동작하는 것이다.

3.  동작 원리

이미지 출처 : https://wikidocs.net/223219

  1. 애플리케이션에서 요청이 발생하면 V8엔진은 자바스크립트 코드로 된 요청을 기계어로 변경한다.
    • V8은 인터프리터와 JIT 최적화 컴파일러(TurboFan)로 바이트코드 -> 기계어를 만든다.
    • I/O는 Node/libuv에서 담당한다.
  2. V8엔진은 이벤트루프로 libuv를 사용하고 전달된 요청을 libuv 내부 이벤트 큐에 추가한다.
    • 이벤트 루프는 Node가 libuv 를 통해 제공한다.
    • libuv가 OS 비동기 기능 (epoll, kqueue, IOCP 등)에 등록을 하고, 준비 완료 통지를 받아 각 루프 단계(phase)에서 콜백을 큐에 올려 실행하는 구조
  3. 이벤트 큐에 쌓인 요청은 이벤트 루프에 전달되어 운영체제 커널에 비동기 처리를 맡긴다.
    • 일반적으로는 처리 요청을 큐에 쌓기보다 소켓,타이머,FS 작업을 등록하고 루프의 poll/prepare/check 등 단계에서 준비된 것의 콜백을 실행한다.
    • 네트워크 I/O는 논블로킹 소켓 + 커널 준비 통지가 핵심이다.
  4. 운영체제 내부적으로 비동기 처리가 힘든 경우 (DB작업, DNS 룩업, 파일 처리 등)는 워커 스레드에서 처리한다.
    • 파일 시스템 : libuv 스레드풀 사용
    • DNS : dns.lookup()은 getaddrinfo : 스레드풀, dns.resolve() : 네트워크 질의(소켓 -> 커널 이벤트로처리
    • DB 작업 : Node 코어 차원에서 DB 작업을 스레드풀로 돌리는 것이 아니라, DB 드라이버가 자체적으로 논블로킹 소켓/ 내부 스레드/ 네이티브 바인딩을 사용한다. 그래서 DB는 항상 워커 스레드는 오해다.
  5. 운영체제의 커널 또는 워커 스레드가 완료한 작업은 다시 이벤트 루프로 전달된다.
    • 완료된 작업은 해당 phase의 콜백 큐에 작업이 스케줄되고, 메인 스레드(이벤트 루프)가 콜백을 실행한다.
  6. 이벤트 루프에서는 콜백으로 전달된 요청에 대한 완료 처리를 하고 넘긴다.
    • 루프 단계(timers → pending → idle/prepare → poll → check → close callbacks)가 있고, 각 단계마다 처리하는 콜백 큐가 분리된다.
  7. 완료 처리된 응답을 Node.js 애플리케이션으로 전달한다.

Node.js의 프로세스는 이벤트 루프에 사용하는 싱글 스레드 하나와 비동기 처리를 지원하는 스레드 풀로 구성되어 있는 것이다.

  • 메인 JS 실행 + 이벤트 루프는 메인 스레드 1개
  • libuv 스레드풀 (기본 4개, UV_THREADPOOL_SIZE로 조정) 존재
  • 워커 스레드는 별도 JS 스레드(각자 이벤트 루프)가 생기는 것이고 멀티 코어 활용은 클러스터 또는 워커스레드로 확장한다.

4. 이벤트 디멀티플렉서와 이벤트 루프

Node.js의 동작원리의 핵심은 이벤트 디멀티플렉서와 이벤트 루프로 생각된다.

이 두 요소는 libuv라는 C 라이브러리를 통해 구현되며, Node.js의 비동기 I/O 성능의 기반이 된다.

Node.js 요청 처리 흐름
1. JS 코드에서 비동기 작업 요청
2. libuv가 작업을 접수하고 처리 경로를 결정함
2-a. 네트워크 I/O 등 비동기 가능 작업은 OS 커널의 이벤트 디멀티플렉서(epoll, kqueue, IOCP)에 등록
2-b. 파일 I/O, DNS 조회 등 커널에서 순수 비동기 처리 어려운 작업은 libuv 워커 스레드풀에 위임
3. 작업 완료 
3-a. 디멀티플렉서에서 준비가 된 신호를 주면 libuv에서 해당 결과를 가져와 콜백을 큐에 등록
3-b. 워커 스레드에서 워커가 완료 후 콜백을 큐에 등록
4. 이벤트 루프가 각 Phase 별 큐를 돌며 콜백을 실행
5. V8이 JS 콜백을 실행하여 응답을 반환

 

이벤트 디멀티플렉서

  • 위치: 커널 레벨
  • 역할: I/O 준비 상태를 감시하고, 준비 완료 시 libuv에 알려줌
  • 중요 포인트: 직접 콜백을 실행하지 않음. 오직 “준비됨” 신호만 제공
  • 플랫폼별 구현:
    • Linux → epoll
    • macOS/BSD → kqueue
    • Windows → IOCP
  • libuv는 이 커널 이벤트를 poll 단계에서 확인하고, 해당 작업을 콜백 큐에 등록
  • 이벤트 디멀티플렉서로 위임되는 작업
    • 주로 네트워크 I/O, OS 이벤트 대기처럼 논블로킹 방식으로 즉시 제어권이 돌아오는 작업들
      • DB 작업도 DB와 TCP 소켓을 통해 서버와 통신하는 것이므로 드라이버가 내부적으로 이벤트 디멀티플렉서에 소켓을 등록해 데이터 준비 이벤트를 기다린다.
      • HTTP API 호출과 같은 외부 API 요청(fetch, axios)은 내부적으로 TCP 소켓/HTTPS를 사용하므로 네트워크 I/O 이벤트 기반 처리
      • 웹소켓 통신도 메시지가 도착할 때 까지 소켓을 이벤트 디멀티플렉서에 등록해두고, 도착 시 이벤트 루프가 콜백 실행
      • 타이머 기반 (setTimeout, setInterval) : 커널 이벤트는 아니지만, 이벤트 루프가 자체 타이머 큐에서 관리

libuv 워커 스레드 풀

  • 위임되는 작업
    • 주로 동기적으로 오래 걸릴 수 있는 블로킹 작업을 비동기화하기 위해 백그라운드 스레드에서 실행 
      • 파일 처리 (fs 모듈) : fs.readFile, fs.writeFile 등은 디스크 I/O라 커널에서 비동기 알림을 못주는 경우가 많아 libuv 스레드풀에서 대신 실행하고 끝나면 이벤트 루프로 콜백 전달함. (대용량 파일 버퍼 읽기/쓰기, 디렉토리 탐색 등)
      • DNS 조회 
      • 이미지/영상 인코딩, 압축 해제, 암호화
      • 사용자 정의 블로킹 연산 : (CSV 파일 파싱, 해시 계산 등과 같이 CPU를 오래 쓰는 작업)

이벤트 루프(libuv)

  • 역할: 콜백이 등록된 큐를 단계별(phase)로 순회하며 실행
    Node.js 메인 스레드의 이벤트 루프는 한 턴에서 아래 phase 순서로 돈다. 각 phase에는 자기 전용 큐가 있으며, 루프가 해당 phase에 도착하면 그 큐의 콜백들을 규칙에 따라 처리한다.
    (콜백 큐는 하나가 아니라 phase별로 따로 존재하는데, 여기에 더해 microtask 큐(promise 등)와 process.nextTick 큐가 별도로 있고, 이 둘은 각 콜백 실행 뒤에 우선 소진된다.)
  • 단계(phase 큐):
    1. timers — setTimeout, setInterval 완료, 만기된 콜백들이 대기하는 타이머 전용 큐
    2. pending callbacks — 일부 시스템 작업 완료 후 실행
    3. idle, prepare — 내부 전용 단계
    4. poll — I/O 이벤트 대기 및 처리 (디멀티플렉서 이벤트 수집)
      • 핵심 I/O phase. 소켓 읽기/쓰기 준비 완료 같은 커널 통지 기반 콜백이 여기에 들어온다.
      • 또한 상황에 따라 이벤트 대기를 하거나, 큐가 비었고 setImmediate가 있으면 곧바로 다음 phase로 넘어간다.
    5. check — setImmediate 콜백들이 대기하는 별도 전용 큐
    6. close callbacks — socket.on('close') 등의 이벤트 콜백 등
setTimeout(..., 0)과 setImmediate(...) 가 “둘 다 곧바로 실행”처럼 보이지만 서로 큐가 다르고 phase도 다르다.
→ I/O 상황에 따라 순서가 달라질 수 있음
(일반적으로 I/O 직후엔 setImmediate 가 먼저 실행되는 패턴이 일반적이다.).
  • 마이크로태스크(microtask):
    process.nextTick(), Promise.then()은 콜백 하나가 끝날 때마다 process.nextTick을 전부 처리한 뒤 Microtask(Promise 등)를 전부 처리한다.
    • process.nextTick 큐 — Node 전용, 가장 우선순위 높음, 콜백 끝날 때마다 즉시 비워지는 큐
    • Microtask 큐 — 표준 Promise(then/catch/finally), queueMicrotask 등 (process.nextTick 다음 순서로 비움)
    • nextTick -> microtask는 콜백이 끝날 때 마다 비운다.
콜백 1개 실행 후 벌어지는 일
(아래 순서는 “콜백 하나”를 꺼내 실행할 때마다 반복된다.)
1. 현재 phase의 자기 큐에서 콜백 하나 실행
2. 해당 콜백이 끝나면 process.nextTick 큐를 전부 소진
3. 그 다음 Microtask(프로미스) 큐를 전부 소진
4. 같은 phase 큐에 남은 콜백이 있으면 계속 1)로, 없으면 다음 phase로 이동

*기아(starvation) 주의: process.nextTick 안에서 또 process.nextTick을 계속 넣으면, 항상 2)에서 소진하느라 메인 루프가 다음 phase로 못 넘어갈 수 있음. Promise도 과도하면 유사 문제가 생길 수 있으나, 우선순위는 nextTick이 더 높아 더 위험합니다.

 

Phase 큐에 쌓이는 대표 함수 설명

Microtask process.nextTick(), Promise.then() 콜백 하나가 끝날 때마다 실행
Timers setTimeout(), setInterval() 타이머 완료된 순서대로 실행
Poll fs.readFile(), net, http 등 대부분의 I/O
Check setImmediate() poll 이후 바로 실행
Close socket.on('close') 종료 콜백

 

실행 우선 순위는 아래와 같다.

process.nextTick → Microtask → 현재 phase의 다음 콜백 → (phase 종료) → 다음 phase 순환(timers → pending → poll → check → close)

 

리액터 패턴

비동기 이벤트 처리를 위한 디자인 패턴으로, 하나의 스레드 또는 이벤트 루프가 이벤트를 기다리고, 해당 이벤트가 발생하면 적절한 콜백 핸들러를 호출하는 구조

 

Node.js에서는 libuv가 커널의 이벤트 디멀티플렉서와 협력하여 리액터 패턴을 구현

  • 이벤트 감시(reactor) → 콜백 큐잉 → 이벤트 루프가 처리(handler 실행)

5. 이벤트 루프에서 말하는 이벤트의 범주란?

A. 태스크(매크로태스크) 범주

  • Node.js(libuv 페이즈 기준)
    • timers: setTimeout, setInterval 만기 콜백
    • pending callbacks: 일부 시스템 콜백(에러, DNS 등 OS 의존)
    • poll: 소켓/파일 I/O 완료 콜백(읽기/쓰기 등), 이벤트 도착 대기
    • check: setImmediate 콜백
    • close callbacks: 소켓/핸들 close 계열
    • 시그널/프로세스 이벤트: SIGINT 등(내부적으로 pending에 해당)
    • 프로세스간/워커 통신: child_process, worker_threads의 메시지 수신
  • 브라우저
    • 타이머: setTimeout/Interval
    • 사용자 입력/네트워크/MessageChannel
    • 렌더 이전 훅: requestAnimationFrame(특수 타임슬롯)
    • 페이지/DOM 관련 각종 태스크

B. 마이크로태스크 범주

  • 공통: Promise.then/catch/finally, queueMicrotask
  • 브라우저 추가: MutationObserver
  • Node 특수: process.nextTick은 마이크로태스크보다도 먼저 비우는 전용 큐
    (남발 시 기아(starvation) 유발 위험)

C. “이벤트”가 아닌 것처럼 보이지만 오해 잦은 것

  • **EventEmitter의 .emit()**은 즉시(동기) 리스너를 호출
    별도 큐에 들어가 “나중에” 실행되는 게 아니라, 바로 지금 실행
    (비동기처럼 보이게 하려면 개발자가 직접 타이머/setImmediate/Promise로 큐잉해야 한다.)
  • beforeExit/exit 같은 프로세스 생명주기 이벤트는 루프 종료 인접 타이밍에서 호출되며, 일반 태스크 큐와 동일하지 않다.

D. 순서·우선순위 체감 규칙

  • “콜백 하나 끝날 때마다” → process.nextTick 전부마이크로태스크 전부 → 다음 콜백.
  • Node에서 setImmediate는 check 단계, setTimeout(0)은 timers 단계
    → 파일 I/O 직후엔 보통 setImmediate가 먼저, 그 외 상황에선 타이머가 먼저일 수 있다.

6. 가비지 컬렉터(GC)와의 관계

  • GC의 주체는 V8 이며 보통 메인 스레드에서 일시 정지를 유발하므로, 한 순간 이벤트 루프가 멈춘 것 처럼 느껴질 수 있다.
  • 콜백 실행 사이 혹은 루프 단계들의 사이의 안전 지점에서 실행되는 경우가 많다.
  • 메모리 압력(많이 할당되거나 해제되는)이나 오래된 세대로 승격된 객체가 많을 때 메이저 GC가 길어질 수 있어서 P95 지연 증가로 체감된다.
  • Node에서 GC를 스케쥴링하진 않지만, 루프가 바쁠수록(콜백이 계속 이어짐) 적절한 GC타이밍이 밀리거나 길어지는 체감이 생길 수 있다.

* 엔진

사용자가 작성한 코드를 실행하는 프로그램을 말한다.

엔전은 파서, 컴파일러, 인터프리터, 가비지 컬렉터, 콜 스택, 힙으로 구성되어 있다.

 

* epoll

  • 특징: 리눅스 커널에서 제공하며, 소켓의 I/O 이벤트를 감지
  • 동작 방식: 특정 소켓의 I/O가 가능할 때 이벤트를 알려주므로, 애플리케이션은 해당 소켓에서 읽기 또는 쓰기 작업을 시도
  • 모델: 논블로킹(non-blocking) 모델에 기반

* IOCP

  • 특징: 윈도우 운영체제에서 사용되며, 비동기(asynchronous) I/O 모델을 지원
  • 동작 방식: I/O 작업이 완료된 후 그 사실을 알림. 즉, I/O 요청을 보낸 후 작업이 완료될 때까지 기다리지 않고 다른 작업을 수행할 수 있으며, 작업 완료 후 시스템으로부터 완료 사실을 통보받는다.
  • 모델: 비동기(asynchronous) 모델에 기반하며, CPU 자원을 효율적으로 사용하도록 돕는다.

'Nodejs' 카테고리의 다른 글

[Node.js] 비동기 작업 개수 제한 "p-limit"  (0) 2026.01.29