Node.js Event Loop의 동작과 병목 판단
Node.js Event Loop가 왜 서버 동시성의 핵심인지, libuv와 Reactor 패턴을 중심으로 설명한다. CPU 블로킹, 실행 순서, Thread Pool 튜닝, Worker Threads 선택 기준까지 실무 진단 관점으로 정리한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
Node.js Event Loop를 이해해야 하는 이유는 단순히 내부 동작을 알기 위해서가 아니다. Nest.js 서버에서 응답이 느려지거나, 특정 요청 하나가 다른 요청까지 막거나, CPU를 많이 쓰는 작업 때문에 서버가 멈춘 것처럼 보일 때 원인을 설명하는 기준이 되기 때문이다. 문서는 느리다는 느낌을 측정 가능한 말로 바꾸라고 강조한다. Event Loop Lag가 100ms 이상이면 warning, 1초 이상이면 alert로 볼 수 있고, Event Loop Utilization은 0.85 이하를 목표로 삼는다. 이렇게 보면 p99 latency와 Event Loop Lag를 함께 놓고 의사결정할 수 있다.
- 02
Event Loop의 직관은 카페 바리스타 비유로 잡을 수 있다. Node.js라는 바리스타가 한 명뿐이어도, 원두를 가는 일처럼 기다림이 긴 I/O 작업은 libuv Thread Pool이나 운영체제에 맡겨두고 다른 주문을 받을 수 있다. 하지만 손님이 그 자리에서 수학 문제 1만 개를 풀어 달라고 하면 바리스타는 계산을 끝낼 때까지 멈춘다. 이때가 CPU 집약적 작업이 Event Loop를 막는 상황이다. I/O 대기는 다른 요청을 처리할 여지를 만들지만, 순수 계산은 메인 스레드 자체를 점유한다.
- 03
Node.js의 Event Loop는 Reactor 패턴의 구현체로 볼 수 있다. 연결마다 OS 스레드를 하나씩 두는 thread-per-connection 방식은 단순하지만, 스레드 스택 메모리와 컨텍스트 스위칭 비용 때문에 동시 연결이 늘수록 한계가 커진다. 문서는 Apache 방식의 한계와 C10K 문제를 짚고, Ryan Dahl이 Node.js를 발표하며 I/O를 다르게 해야 한다고 본 배경을 연결한다. Reactor 패턴은 하나의 스레드가 epoll, kqueue, IOCP 같은 이벤트 디멀티플렉서로 여러 I/O 소스를 감시하고, 준비된 이벤트만 핸들러에 넘기는 구조다.
- 04
이 사고 모델은 Node.js에만 갇히지 않는다. 문서는 Nginx, Redis, Go runtime에서도 유사한 원리가 반복된다고 설명한다. Node.js는 libuv Event Loop와 Thread Pool을 쓰고, Nginx는 Worker Process마다 Event Loop를 두며, Redis는 싱글 스레드 Event Loop와 ae 라이브러리를 사용한다. Go runtime은 Goroutine과 netpoller로 Reactor 원리를 런타임에 품는다. 새 시스템을 볼 때는 이벤트 루프가 있는지, 디멀티플렉서가 무엇인지, 블로킹 작업을 어떻게 분리하는지 확인하면 동시성 모델을 빠르게 파악할 수 있다.
- 05
Node.js의 메인 스레드는 하나지만 모든 일을 혼자 직접 처리하지는 않는다. 네트워크 I/O는 Linux의 epoll, macOS의 kqueue, Windows의 IOCP처럼 운영체제 커널이 비동기로 다뤄주므로 별도 스레드가 필요 없다. 반대로 파일 I/O는 대부분의 OS에서 진정한 비동기 인터페이스가 부족해 libuv가 Thread Pool의 스레드에서 blocking 방식으로 처리하고, 완료 이벤트를 메인 루프로 돌려준다. 이 차이 때문에 UV_THREADPOOL_SIZE 조정은 파일 I/O, DNS 조회, crypto 작업에는 의미가 있지만, 네트워크 I/O 위주 서버에는 효과가 거의 없을 수 있다.
- 06
Thread Pool은 늘린다고 항상 좋아지는 설정이 아니다. 기본 크기는 4이고 최대 1024까지 조정할 수 있지만, 파일 I/O, dns.lookup, crypto 작업이 같은 고정 크기 풀을 공유한다. 한 종류의 작업이 풀을 점유하면 다른 작업도 기다릴 수 있다. Cluster 모드에서는 프로세스마다 풀이 생기므로 8 프로세스에 128을 설정하면 총 1024 스레드가 된다. 문서의 판단 기준은 측정이다. 동일 부하 테스트에서 p99 latency나 Event Loop Lag p99가 통계적으로 유의미하게 좋아지지 않으면 롤백하고, 잘못 키운 풀은 스레드당 약 1MB 메모리 증가와 스케줄링 오버헤드로 돌아온다.
- 07
Event Loop는 여러 단계를 순서대로 순환하며 실행되고, 서버가 아무것도 안 하는 것처럼 보이는 대부분의 시간은 poll 단계에서 I/O 이벤트를 기다리는 시간이다. 실행 우선순위도 중요하다. 동기 코드가 먼저 실행되고, 그다음 process.nextTick, Promise 기반 Microtask, setTimeout 같은 Macrotask 순서로 이어진다. setImmediate와 setTimeout 0의 순서는 호출 위치에 따라 달라진다. 메인 모듈에서는 보장되지 않지만, I/O 콜백 내부에서는 poll 다음이 check 단계라 setImmediate가 먼저 실행된다. Node.js 20부터는 libuv 1.45.0 변경으로 타이머가 poll 이후에만 실행되므로 정확한 순서에 의존하는 코드는 특히 조심해야 한다.
- 08
CPU 블로킹은 코드 리뷰에서 조용히 숨어 있다가 프로덕션에서 크게 드러날 수 있다. 대용량 JSON.parse와 JSON.stringify는 동기 실행이고, Node.js 공식 가이드 벤치마크 기준 50MB JSON 문자열을 parse하면 1.3초, stringify는 0.7초 Event Loop를 점유한다. 복잡한 정규식은 ReDoS 패턴에서 무기한 블로킹될 수 있고, 대용량 Array.sort, map, reduce도 수십만 건에서는 동기 작업으로 서버 전체를 붙잡을 수 있다. fs.readFileSync, crypto.pbkdf2Sync, child_process.execSync, zlib.inflateSync 같은 Sync API도 서버 컨텍스트에서는 피해야 할 대상으로 다뤄진다.
- 09
async와 await을 썼다고 CPU 계산이 자동으로 비동기가 되는 것은 아니다. async와 await은 I/O 비동기 코드를 편하게 쓰는 문법이고, await 앞에서 무거운 동기 계산을 하면 그 부분은 그대로 Event Loop를 막는다. CPU 집약적 작업을 분리하려면 Worker Threads를 고려한다. Worker는 자체 V8 인스턴스와 Event Loop를 가지므로 메인 스레드를 방해하지 않는다. 다만 AppSignal 벤치마크처럼 30번째 피보나치 계산에서 Worker Pool은 약 4배 개선을 보였지만, 알고리즘을 재귀에서 행렬 지수법으로 바꾸면 약 360배 개선이 먼저 나왔다. 순서는 알고리즘 복잡도 점검, 이미 최적이면 Worker Pool, 둘을 합쳐 더 느리면 Worker 회수다.
- 10
실무 진단에서는 증상과 원인을 분리해서 본다. 특정 요청이 들어오면 서버 전체가 수 초간 멈추는 경우에는 대용량 JSON, 복잡한 정규식, 대용량 배열 처리, Sync API 같은 동기 블로킹을 의심한다. process.nextTick을 재귀적으로 과도하게 쓰면 I/O 콜백이 실행될 기회를 얻지 못해 I/O Starvation이 생길 수 있다. Event Loop Lag p99가 100ms를 넘고 API p99 latency도 SLA를 넘으면 진단보다 복구를 먼저 시작하고, monitorEventLoopDelay로 100ms 미만 회귀를 확인한 뒤 clinic Doctor나 Flame으로 근본 원인을 찾는다. 정리하면 Node.js는 I/O를 논블로킹으로 처리하지만, CPU 작업과 동기 API는 메인 스레드를 막는다. Event Loop를 본다는 것은 이 경계를 측정하고, 분리하고, 되돌릴 기준을 갖는다는 뜻이다.
같은 레이어