홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)

정기창·2026년 2월 26일

1편, 2편을 거치며 생긴 질문

1편에서 os.hostname() 한 줄이 JS → C++ → libuv → OS까지 내려가는 과정을 확인했습니다. 2편에서는 프로세스와 스레드가 무엇인지, OS가 프로그램을 어떻게 실행하는지 알아봤습니다.

그런데 하나 풀리지 않는 의문이 생겼습니다. Node.js는 싱글 스레드라고 합니다. 메인 스레드가 하나뿐인데, 파일도 읽고, 네트워크 요청도 보내고, 타이머도 돌리고 — 이걸 어떻게 동시에 처리하는 걸까요?

이 질문에 답하려면 두 가지를 이해해야 했습니다. 하나는 콜 스택과 이벤트 루프의 관계, 다른 하나는 libuv가 작업을 처리하는 방식입니다.

콜 스택과 이벤트 루프, 두 세계로 나뉩니다

Node.js에서 코드가 실행되는 공간은 크게 두 가지입니다.

  • 콜 스택(Call Stack) — 동기 코드가 실행되는 곳
  • 이벤트 루프(Event Loop) — 비동기 콜백이 대기하고 실행되는 곳

모든 함수가 이벤트 루프를 거치는 건 아닙니다. V8 엔진이 혼자 처리할 수 있는 것은 콜 스택에서 바로 실행되고, OS의 도움이 필요한 것만 이벤트 루프를 통해 처리됩니다.

const fs = require('fs');

console.log('A');                          // ① 동기 → 콜 스택에서 바로 실행

fs.readFile('/tmp/test.txt', (data) => {   // ② 비동기 → OS에 파일 읽기 위임
  console.log('B');                        //   완료되면 콜백이 이벤트 루프에 등록
});

console.log('C');                          // ③ 동기 → 콜 스택에서 바로 실행

// 출력: A → C → B

console.log('A')와 console.log('C')는 V8이 혼자 처리할 수 있는 연산입니다. 콜 스택에서 바로 실행되고 끝납니다. 하지만 fs.readFile()은 디스크에서 파일을 읽어야 하는 작업이라 V8 혼자서는 못 합니다. libuv를 통해 OS에 위임하고, 메인 스레드는 다음 줄로 넘어갑니다.

파일 읽기가 완료되면, 그때서야 콜백 함수(console.log('B'))가 이벤트 루프에 등록되어 실행됩니다. 그래서 출력 순서가 A → C → B가 됩니다.

핵심 규칙: 콜 스택이 비어야 이벤트 루프가 돕니다

이벤트 루프는 콜 스택이 완전히 비어야 동작합니다. 동기 코드가 실행 중인 동안에는 아무리 콜백이 준비되어 있어도 실행되지 않습니다.

setTimeout(() => console.log('타이머'), 0);  // 0ms로 설정해도
console.log('먼저');

// 출력: 먼저 → 타이머
// setTimeout(0)이어도 콜 스택이 비어야 실행됩니다

setTimeout의 시간을 0ms로 설정해도 console.log('먼저')가 먼저 실행됩니다. 콜 스택에 있는 동기 코드가 전부 끝나야 이벤트 루프가 타이머 콜백을 실행하기 때문입니다.

어떤 것이 콜 스택에서 바로 실행되는가

1편에서 확인했듯이, V8은 JavaScript 엔진이라 연산만 할 수 있습니다. 디스크를 읽거나, 네트워크 요청을 보내거나, 시간을 재는 건 V8이 못 합니다. 이 구분이 콜 스택과 이벤트 루프를 나누는 기준입니다.

V8이 혼자 할 수 있는 것 (콜 스택)OS 도움이 필요한 것 (이벤트 루프)
const x = 1 + 2fs.readFile() → 디스크
'hello'.toUpperCase()http.get() → 네트워크
JSON.parse('{...}')setTimeout() → 타이머
arr.map(x => x * 2)dns.lookup() → DNS 조회

왼쪽은 V8 엔진 안에서 완결되는 작업들입니다. 오른쪽은 V8 밖, 즉 운영체제의 도움이 필요한 작업들입니다. 이 오른쪽 작업들이 libuv를 거쳐 처리되고, 완료되면 이벤트 루프에서 콜백이 실행됩니다.

libuv는 작업을 두 가지 방식으로 처리합니다

1편에서 libuv가 OS와 대화하는 역할을 한다고 했습니다. 그런데 libuv가 모든 작업을 같은 방식으로 처리하는 건 아닙니다. 두 가지 경로가 있습니다.

경로 1: 스레드 풀 — libuv가 직접 처리

파일 I/O처럼 OS가 비동기 API를 잘 지원하지 않는 작업은, libuv가 자체 스레드 풀을 사용해서 처리합니다. 2편에서 다뤘듯이, 스레드 풀은 미리 만들어둔 스레드를 재사용하는 패턴입니다. libuv는 기본 4개 워커 스레드를 미리 생성해두고, 작업이 들어오면 빈 워커에 할당합니다.

fs.readFile()
  → node_file.cc (C++ 바인딩)
  → uv_fs_read()
  → uv__work_submit()   ← 스레드 풀에 작업 등록

내부적으로 uv__work_submit()이라는 함수가 호출되면, 그 작업은 스레드 풀로 들어갑니다.

경로 2: OS 비동기 API — OS에 맡기고 기다림

네트워크 소켓처럼 OS가 비동기로 잘 처리할 수 있는 작업은, libuv가 직접 하지 않고 OS의 감시 메커니즘에 등록만 합니다.

net.connect()
  → tcp_wrap.cc (C++ 바인딩)
  → uv_tcp_connect()
  → uv__io_start()      ← OS 감시 목록에 등록

이 경우 uv__io_start()가 호출되고, OS가 제공하는 메커니즘(Linux의 epoll, macOS의 kqueue)이 이벤트를 감시합니다. uv__io_start() 자체는 Unix 플랫폼 공통 코드(core.c)에 정의되어 있고, 실제 폴링 방식만 플랫폼별로 다릅니다.

왜 두 가지로 나뉘는가

이유는 간단합니다. OS가 비동기를 잘 지원하는 영역이 있고, 못 하는 영역이 있기 때문입니다.

libuv 스레드 풀OS 비동기 API
fs.* (파일 I/O)TCP/UDP 소켓
dns.lookup()파이프 (프로세스 간 통신)
crypto.pbkdf2()시그널 (SIGINT 등)
zlib 압축자식 프로세스 이벤트

OS 비동기 API를 쓰는 쪽은 OS가 "이벤트 발생"을 효율적으로 알려줄 수 있는 것들입니다. 네트워크 데이터 도착, 시그널 수신 같은 건 OS가 감시하고 알려주는 게 자연스럽습니다. 반면 파일 I/O는 대부분의 OS에서 비동기 API가 잘 갖춰져 있지 않아서, libuv가 스레드 풀로 직접 처리합니다.

전체 흐름

Node.js 코드 실행
    │
    ├─ V8이 처리 가능 → 콜 스택에서 바로 실행 (이벤트 루프 X)
    │   예: console.log(), JSON.parse(), 연산, 배열 처리
    │
    └─ V8이 못 함 → libuv에 위임
                      │
                      ├─ 스레드 풀 (파일, DNS lookup, 암호화, 압축)
                      │   → uv__work_submit()
                      │
                      └─ OS 비동기 API (네트워크, 파이프, 시그널)
                          → uv__io_start()
                      
                      → 완료되면 이벤트 루프 6개 페이즈에서 콜백 실행

스레드 풀이든 OS 비동기든, 작업이 완료되면 콜백이 이벤트 루프에 등록됩니다. 그러면 이벤트 루프의 6개 페이즈 중 해당하는 페이즈에서 콜백이 실행됩니다.

여기서 자연스럽게 드는 질문이 있습니다. "6개 페이즈가 뭔데?"

이벤트 루프의 정체: uv_run() 안의 while 루프

"이벤트 루프"라는 이름이 추상적으로 들릴 수 있는데, 실체는 의외로 단순합니다. libuv의 uv_run() 함수 안에 있는 while 루프 하나가 곧 이벤트 루프입니다.

📎 deps/uv/src/unix/core.c#L427 — uv_run() 함수 (427~492줄)

이 while 루프가 한 바퀴 돌 때마다, 정해진 순서대로 6개의 C 함수를 호출합니다. 각 함수가 하나의 "페이즈"입니다.

while (루프가 살아있고 && stop_flag == 0) {
    uv__run_pending()          // ① Pending Callbacks
    uv__run_idle()             // ② Idle
    uv__run_prepare()          // ③ Prepare
    uv__io_poll()              // ④ Poll
    uv__run_check()            // ⑤ Check
    uv__run_closing_handles()  // ⑥ Close Callbacks
    uv__run_timers()           // ⑦ Timers
}

소스코드상으로 Timers가 루프의 맨 끝에 있는 게 눈에 띕니다. 보통 다이어그램에서는 Timers를 맨 위에 그리는데, 순환 구조라 어디가 시작인지는 사실 의미가 없습니다. Node.js 공식 문서가 Timers를 첫 번째로 설명하면서 그게 관례가 된 것뿐입니다.

6개 페이즈 한눈에 보기

   ┌───────────────────────────┐
┌─>│         Timers            │  setTimeout, setInterval 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     Pending Callbacks     │  이전 루프에서 미뤄진 시스템 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       Idle, Prepare       │  libuv 내부 전용
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          Poll             │  I/O 콜백 실행 + 새 I/O 대기
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          Check            │  setImmediate 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      Close Callbacks      │  socket.on('close') 등
│  └─────────────┬─────────────┘
└─────────────────┘

6개 페이즈를 개발자 관점에서 분류하면 이렇습니다.

구분페이즈개발자가 직접 관여
개발자가 직접 등록Timers, Poll, ChecksetTimeout, fs.readFile 콜백, setImmediate
개발자 코드에서 간접 발생Pending, CloseTCP 에러 콜백, socket.destroy() 후 close 콜백
완전 내부 전용Idle, Prepare개발자가 관여할 방법 없음

여기서 가장 중요한 건 Poll 페이즈입니다. 대부분의 I/O 콜백이 여기서 실행되고, 할 일이 없으면 이 페이즈에서 대기하면서 새 I/O 이벤트를 기다립니다. Node.js 서버의 실제 일은 거의 다 Poll 페이즈에서 일어난다고 봐도 됩니다.

6개 페이즈는 위임한 작업 자체를 실행하는 곳이 아닙니다. 실제 작업(파일 읽기, 네트워크 통신)은 libuv 스레드 풀이나 OS가 처리하고, 6개 페이즈는 그 결과(콜백)를 어떤 순서로 실행할지 정하는 구조입니다.

정리하면

"싱글 스레드인데 어떻게 동시에 처리하나요?"라는 질문에 이제 이렇게 답할 수 있을 것 같습니다.

  • V8이 혼자 처리할 수 있는 건 콜 스택에서 바로 실행됩니다. 이벤트 루프를 거치지 않습니다.
  • OS의 도움이 필요한 I/O 작업은 libuv에 위임합니다. libuv는 스레드 풀(uv__work_submit)이나 OS 비동기 API(uv__io_start) 두 가지 경로로 작업을 처리합니다.
  • 작업이 완료되면 콜백이 이벤트 루프의 6개 페이즈에서 정해진 순서대로 실행됩니다. 이벤트 루프의 실체는 libuv uv_run() 안의 while 루프입니다.
  • 메인 스레드는 하나지만, 무거운 일은 libuv와 OS가 대신하고 메인 스레드는 콜백을 실행하는 역할에 집중합니다. 그래서 싱글 스레드여도 동시에 여러 작업을 처리하는 것처럼 동작할 수 있습니다.

이번 편에서는 6개 페이즈의 존재와 역할을 개요 수준으로 확인했습니다. 각 페이즈가 구체적으로 어떻게 동작하는지 — Poll 페이즈는 왜 가장 오래 머무는지, setTimeout(0)과 setImmediate의 실행 순서가 왜 달라지는지, process.nextTick은 어디에서 끼어드는지 — 는 다음 편에서 소스코드와 함께 다루겠습니다.

Node.js이벤트 루프콜 스택libuv스레드 풀면접 준비

관련 글

이벤트 루프의 6개 페이즈 — 콜백은 어떤 순서로 실행되는가 (4편)

이벤트 루프의 6개 페이즈를 하나씩 살펴봅니다. 타이머 힙의 동작 방식, Poll이 메인 무대인 이유, setTimeout(0)과 setImmediate의 순서가 달라지는 원리, 그리고 process.nextTick과 Promise가 매 페이즈 사이에 끼어드는 구조까지 정리합니다.

관련도 94%

프로세스, 스레드, 메모리 — Node.js를 이해하기 위한 OS 기초 (2편)

Node.js의 libuv 스레드 풀을 이해하려면 OS 기초가 필요합니다. 프로세스와 스레드의 차이, CPU 코어와 소프트웨어 스레드의 관계, 메모리 동적 할당까지 — Node.js 동시성의 전제 지식을 정리합니다.

관련도 93%

Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)

면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.

관련도 93%