pile·
백엔드·네이버페이네이버페이·

Node.js가 싱글스레드 서버라는 미신(feat. Node.js의 대용량 데이터 처리)

네이버페이 백엔드 개발자가 '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 스케일아웃 사례로 이해하려는 백엔드 개발자에게 유용하다.
네이버페이
네이버페이 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 백엔드·github-engGitHub Engineering·

    조기 종료를 없애야 벡터화된다 — 메모리 속도 소스 코드 케이스 폴딩

    GitHub의 코드 검색 엔진 Blackbird는 480TB 이상의 소스 코드를 인덱싱하기 전 모든 바이트에 case folding을 적용한다. 이 글은 Rust로 구현한 case folding을 메모리 대역폭 한계(45+ GiB/s)까지 끌어올린 두 가지 반직관적 최적화를 상세히 다룬다. 핵심은 루프 조기 종료(break) 제거로 LLVM 벡터화를 유도하고, UTF-8을 디코딩하지 않고 바이트 공간 산술만으로 fold를 수행하는 것이다.

    #rust#unicode#simd+2
  2. 백엔드·여기어때 (GC컴퍼니)여기어때 (GC컴퍼니)·

    트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 — 전시 아키텍처 개선기 (2/3)

    여기어때 전시개발팀이 숙소 상세(PDP) API를 해부한 결과, 코드상으로는 DB 호출 3번처럼 보이던 요청이 실제로는 MongoDB $lookup 체인으로 컬렉션을 19회 접근하는 구조였다. 이 트랜잭션 스크립트 방식의 핵심 문제는 "aggregation이 I/O를 가린다"는 점으로, 독립적인 쿼리 10개가 단일 파이프라인에 직렬화되어 병렬화 기회를 잃고, 가격 때문에 거의 안 바뀌는 이미지까지 매 요청마다 읽어야 하는 읽기 증폭이 발생했다. V3에서는 "조회 시점 조립"을 "쓰기 시점 사전 조립"으로 전환하고, 화면별로 복제되던 가격 계산 로직을 goodsprice 단일 모듈로 수렴했다. 4개 API(PLP/PDP/RDP/ILP)의 반복 마이그레이션은 Claude Code skill로 절차를 고정하고 쉐도잉 + 동일성 검증으로 안전망을 마련하는 방식으로 진행됐다.

    #architecture#migration#caching+2