네이버페이 백엔드 개발자가 'Node.js는 싱글스레드냐 멀티스레드냐'라는 흔한 질문을 실무 관점에서 다시 뜯어본다. 결론은 둘 다 맞다다. JS 코드와 이벤트 루프는 하나의 메인 스레드(V8)에서 돌지만, 오래 걸리는 I/O는 libuv의 워커 스레드가 병렬로 처리한다. 후반부에서는 이 구조의 CPU 약점과, 네이버페이 타임라인의 60억 건 마이그레이션을 k8s 스케일아웃으로 해결한 사례를 정리한다.
핵심 포인트- JS 실행과 이벤트 루프는 단일 메인 스레드에서 돌지만, 파일·DB·HTTP 같은 I/O는 C 라이브러리 libuv의 워커 스레드가 병렬 처리한다.
- 콜백은 메인 스레드에서 순차 실행돼 개발자는 mutex·race condition·deadlock 없이 코드를 짠다. 워커 스레드가 JS 상태·메모리에 직접 접근하지 않기 때문.
- 약점은 CPU 연산이다. 대용량 JSON 파싱·인코딩·암복호화는 libuv에 못 넘기고 메인 스레드가 혼자 처리해 다른 요청이 전부 밀린다.
- 500만 건 JSON 파싱은 2~3분간 서버를 조용히 멈추게 한다. Spring은 스레드풀로 같은 작업을 10~20초에 대응한다.
- 실무 해결책은 스케일아웃이다. 가볍고 컨테이너 단위로 쪼개지는 Node.js를 k8s에서 여러 개 띄워 멀티스레드 서버처럼 운용한다.
상세 정리- 도입 논쟁: 순수 싱글스레드라면 외부 요청 10초 대기 동안 멈춰야 하지만 Node.js는 그 사이 다른 요청을 처리한다. 그래서 "싱글스레드"라는 답은 반만 맞다.
- 내부 구조: 메인 스레드가 오래 걸리는 작업을 직접 안 하고 libuv에 위임하며, 워커 스레드가 처리하는 동안 이벤트 루프는 계속 돈다.
- 콜백 실행: I/O가 끝나면 결과 콜백이 이벤트 큐에 쌓이고, call stack이 비었을 때 V8이 그 콜백을 실행한다. 작업은 병렬, 콜백은 순차다.
- 멀티스레드 근거: v12부터 worker_threads 모듈이 정식 도입돼 공유 메모리·메시지 기반 명시적 멀티스레드도 가능하다. 단 필자는 싱글스레드 철학을 선호해 잘 안 쓴다.
- 이벤트 큐는 단순 FIFO가 아니라 macro·micro·next-tick 큐로 나뉘고 phase별 규칙이 다르지만 이 글의 초점은 아니다.
- CPU 약점 실증: 이미지·비디오 인코딩, 대용량 정렬·필터, 암복호화는 기다리는 I/O가 아니라 계산이라 libuv로 못 넘긴다.
- 60억 건 배경: 네이버페이 타임라인 서버(구매 내역 조회)는 Node.js 기반인데, 2022년 리뉴얼로 5년치 데이터를 재적재해야 했다. Kafka엔 2주치뿐이라 Hive에서 뽑았다.
- 파이프라인: Hive HQL로 join·필터 추출 → Node.js가 JSON 파싱·컨버팅 → MongoDB bulk insert. writeConcern·chunk 사이즈·인덱스 시점을 함께 고민했다.
- 병목 진단: MongoDB CPU는 3% 미만인데 Node.js Pod가 1020~1030m로 코어 하나를 풀로 써 파싱하고 있었다. 한 대로 돌리면 25일 걸리는 일정이 나왔다.
- 스케일아웃 적용: 마이그레이션 서버 이미지를 k8s에 배포하고 Deployment·HPA만 손봐 변수 하나로 5개든 25개든 병렬 서버를 확장했다.
- 리소스 특성: 일반 API 서버는 구동 25MB·운영 500~600MB로 Spring(JVM 수백 MB, 운영 3~4GB)보다 가볍다. 단 대용량 마이그레이션 특수 상황에선 Node.js도 6~8GB까지 썼고, 코어 하나만 쓰므로 Pod당 CPU 1200m를 할당했다.
- 결과: 서버를 25개까지 띄우자 병목이 Node.js에서 MongoDB write로 넘어갔고 60억 건을 무사히 처리했다. 같은 구조가 API 서버에도 적용돼 클러스터 차원에서 멀티스레드 서버처럼 초당 수백 요청을 받는다.
왜 읽나Node.js 런타임 구조와 CPU 병목을 실제 대용량 처리·k8s 스케일아웃 사례로 이해하려는 백엔드 개발자에게 유용하다.