Node.js 이벤트 루프 아키텍처 — 단일 스레드 런타임이 대규모 동시성을 처리하는 방법
출처: Dev.to
Node.js를 처음 다루기 시작했을 때, 한 가지가 마음에 들지 않았습니다.
“단일 스레드 시스템이 어떻게 동시에 수천 개의 요청을 처리할 수 있지?”
모순된 것처럼 보였지만, 공식 문서를 통해 이벤트 루프를 제대로 이해하고 나니 모든 것이 맞아떨어졌습니다.
이 글은 깊이를 잃지 않으면서 가능한 한 가장 간단하게 설명하려는 시도입니다.
Node.js는 흔히 “단일 스레드”라고 불리지만, 그 말은 불완전합니다.
- JavaScript 실행은 하나의 메인 스레드에서 이루어집니다.
- 하지만 Node.js 자체는 한 번에 하나의 작업만 수행한다는 제한이 없습니다.
Node.js는 다음을 활용합니다.
- OS 커널
- 백그라운드 스레드(libuv)
- 비동기 I/O
→ 따라서 올바른 표현은 다음과 같습니다.
Node.js는 JavaScript 실행은 단일 스레드이지만, I/O 처리는 다중 시스템으로 동작한다.
이 구분이 핵심입니다.
Node.js는 작업이 끝날 때까지 기다리지 않습니다.
다음과 같은 흐름을 따릅니다.
- 요청을 받는다.
- 작업(DB 호출, 파일 읽기, API 호출 등)을 시작한다.
- 기다리지 않는다.
- 다음 요청으로 넘어간다.
- 결과가 준비되면 나중에 다시 처리한다.
이를 논블로킹 I/O라고 합니다.
→ 공식 문서 인용:
상상해 보세요:
당신은 웨이터(= 이벤트 루프)이고, 주방은 OS/백그라운드 워커입니다.
- 주문을 받고 → 주방에 전달 → 완성된 요리를 서빙한다.
- 직접 요리를 만들거나, 한 주문이 끝날 때까지 대기하지 않는다.
이것이 바로 Node.js가 확장성을 갖는 방식입니다.
이벤트 루프는 단순한 큐가 아니라 단계(phase) 로 구성됩니다.
- Timers –
setTimeout(),setInterval()콜백 실행 - Pending Callbacks – 시스템 레벨 콜백 처리(TCP 오류 등)
- Idle / Prepare – 내부용, 직접 다루지는 않음
- Poll Phase (가장 중요) – 새로운 I/O 이벤트 수집, I/O 콜백 실행, 할 일이 없으면 대기
- Check Phase –
setImmediate()콜백 실행 - Close Callbacks – 정리 콜백 실행(
socket.on('close')등)
┌───────────────────────────┐
│ timers │
└─────────────┬─────────────┘
│
v
┌───────────────────────────┐
┌─>│ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ timers │
└───────────────────────────┘
→ 루프는 위 단계들을 계속 순환합니다.
여기서 마법이 일어나며,
- 들어오는 요청은 여기서 처리되고,
- 완료된 비동기 작업은 여기로 돌아옵니다.
아무 작업도 대기 중이지 않다면 Node는 효율적으로 대기합니다.
→ 그래서 Node.js는 CPU 사이클을 낭비하지 않고 높은 확장성을 유지합니다.
이 점은 대부분 간과되는 중요한 디테일입니다.
process.nextTick() vs setImmediate()
-
process.nextTick()- 현재 함수가 끝난 직후 바로 실행
- 이벤트 루프가 계속 진행되기 전에 실행 → 남용하면 I/O를 차단할 수 있음
-
setImmediate()- 다음 반복(iteration)의 check phase에 실행
- 더 안전하고 예측 가능
→ 공식 문서 권장: 실제 상황에서는 setImmediate()를 우선 사용.
이제 핵심 질문을 살펴봅시다.
- 각 요청마다 새로운 스레드가 생성 → 메모리 부담, 컨텍스트 스위칭 오버헤드
- 단일 스레드가 모든 요청을 처리 → 요청당 스레드 생성 없음, 비동기 콜백 사용
예를 들어 1,000명의 사용자가 서버에 접근하면 Node.js는:
- 모든 요청을 등록하고 비동기 작업을 시작한다.
- 이벤트 루프는 자유롭게 유지된다.
- 응답이 돌아오면 콜백을 큐에 넣고, 이벤트 루프가 차례대로 실행한다.
→ 결과: 낮은 자원 사용량으로 높은 동시성 달성.
Node.js가 빛을 발하는 경우
- DB 쿼리
- 외부 API 호출
- 파일 시스템 작업
- 스트리밍
- 실시간 애플리케이션(채팅, 소켓)
하지만 적합하지 않은 경우
- 무거운 CPU 연산
- 큰 동기 루프
왜냐하면 이벤트 루프를 블로킹하면 전체 서버가 멈추기 때문입니다.
while (true) {}
→ 서버 전체가 정지합니다.
process.nextTick()을 남용하면 이벤트 루프가 굶주림 상태가 되어 I/O 실행이 차단됩니다.
fs.readFileSync()와 같은 동기식 API는 프로덕션에서는 피해야 합니다.
Node.js는 프로세스당 단일 스레드이지만, 다음 방법으로 확장할 수 있습니다.
- Cluster 모듈
- Worker Threads
- 로드 밸런서
→ 이를 통해 멀티코어 활용 및 수평 확장이 가능합니다.
정리
공식 Node.js 문서를 읽고 실제로 앱을 만들어 보면, 저는 이렇게 생각합니다.
- Node.js는 한 번에 모든 일을 하려는 것이 아니라 절대 블로킹하지 않으려 한다.
- 이벤트 루프는 똑똑한 코디네이터다:
- 준비된 작업을 실행하고,
- 기다리는 작업은 건너뛰며,
- 시스템을 계속 움직이게 한다.
→ 그래서 Node.js는 수천 개의 동시 요청을 처리할 수 있다 – 병렬 실행 덕분이 아니라 효율적인 스케줄링과 논블로킹 설계 덕분이다.
한 줄로 요약하자면:
Node.js는 작업을 빠르게 수행하기 때문이 아니라, 불필요하게 기다리지 않는 것이 뛰어나기 때문에 확장한다.
이 사고방식이 잡히면 Node.js 아키텍처의 모든 것이 이해가 됩니다.