2026. 5. 11.·base·
NodeJS 병목처리 - 워커스레드 vs 스케일아웃
싱글스레드의 한계를 돌파하자
webos 프로젝트 리팩토링을 진행 중 서버 부하 테스트를 하다가 한 가지 문제점을 발견했다. 트래픽 상승으로 window를 open하는 메소드를 점점 더 많이 호출하게 되는 경우, 전체 윈도우 목록을 가져와서 포커스 여부 상태를 false로 설정하는 부분이 반복되고, 로직 내 forEach문의 영향으로 API 처리 시간의 지연이 발생하고 있었다.
현재 상황인 단일 컨테이너(워커 스레드 X) 구조에서는 70 RPS 이상의 상황에선 CPU 점유율과 메모리 점유율이 아직 넉넉함에도 불구하고, p95 케이스에서 약 16초 간의 심각한 처리 지연이 발생하고 있었다. 싱글 스레드가 길어지는 반복문을 처리하면서(Blocking) 다음 작업을 처리하지 못해 지연이 누적되는 것으로 판단되었고, 이를 해결하기 위해 서버 CPU의 유휴 코어를 활용할 수 있는 두 가지 방법을 고려했다.
- 컨테이너를 하나로 유지한 채 워커스레드를 추가하는 방법 (멀티스레딩)
- 컨테이너를 두개로 늘리는 방법 (워커스레드는 추가 x) (멀티 프로세싱)
1. 컨테이너를 하나로 유지한 채 워커 스레드를 추가하는 방법
첫 번째 방식은 긴 반복문 요청이 들어오면 메인 스레드(Event Loop)는 해당 연산을 워커 스레드에게 위임하고 자기 자신은 바로 다음 요청을 처리할 수 있도록하는 Non-Blocking 전략이다.
이 방식은 Node.js의 worker_threads모듈을 사용해 메인 프로세스 내부에 워커 스레드를 생성해 별도의 CPU 코어에서 연산을 수행한 뒤 결과를 메인 스레드에게 반환하는 식으로 동작하는데 이 방식의 장점은 CPU 집약적인 작업이 수행되는 동안에도 메인 서버가 멈추지 않고 다른 가벼운 API 요청을 정상적으로 처리할 수 있다는 부분이다.
하지만 한계점도 존재한다. Node.js의 worker_threads는 자바나 C++ 같은 일반적인 멀티스레드 모델과 다르게 각 스레드가 독립적인 V8 엔진 인스턴스를 생성해 메모리의 힙영역을 공유하지 않는 격리 구조를 가진다. 따라서 메인 스레드가 워커 스레드에서 window 목록 데이터를 넘길 때, 메모리 주소만 넘기는 것이 불가능 하고 대신 구조적 복제 알고리즘을 통해서 데이터를 처음부터 끝까지 복사하여 전달해야 하므로, 데이터 크기가 클수록 오버헤드가 발생해버린다. 따라서 CPU 코어 수보다 더 많은 스레드를 생성하게 되면 컨텍스트 스위칭 과정에서 불필요한 자원 낭비로 이어지게 된다. (sharedArrayBuffer가 있긴하지만 사용이 까다로움, JSON 객체 저장 못하고 바이너리 값과 숫자 배열만 저장 가능, 메모리 주소 직접 계산해야함)
2. 컨테이너를 두 개로 늘리는 방법
두 번째 방식은 독립된 Node.js 런타임을 하나 더 띄워, 서버의 가용 코어 전체를 프로세스 단위로 점유하게 하는 병렬 처리 전략이다. OS 스케줄러에 의해 컨테이너 A는 1번 코어, 컨테이너 B는 2번 코어에 할당되고 로드밸런서가 트래픽을 분산시켜서 두 개의 컨테이너가 독립적, 병렬적으로 반복문 로직을 수행할 수가 있다.
첫 번째 방식과 다르게 스레드 간 통신이 없으므로 이때 발생하는 비용이 없고 컨테이너 하나가 죽어도 다른 컨테이너가 살아서 서비스를 유지할 수 있다는 장점이 있지만 아무래도 Node.js 런타임을 여러개 띄워야 하는 만큼 런타임 자체가 차지하는 메모리를 무시할 수 없게 된다.
그래서 어떤 방법?
단순히 CPU 코어를 늘리는 것(Scale-out)이 정답인지, 아니면 프로세스 내부를 쪼개는 것(Multi-threading)이 정답인지를 가르는 핵심 기준은 각 요청이 얼마나 독립적인가와 데이터 접근 비용(I/O)에 달려 있다.
또 컨테이너를 하나의 서버에서 늘리는지 아니면 새로운 서버를 띄우는지에 따라서도 갈리지만 이 부분은 고려하지 않겠다.
1. 요청이 독립적인 경우: 컨테이너 스케일 아웃 (Scale-out)
웹 서버 아키텍처에서 요청이 독립적(Stateless)이라는 말은, 요청을 처리하기 위해 이전 요청이 서버 메모리에 남긴 상태를 알 필요가 없다는 뜻이다. 이를 위해 요즘 대부분의 웹 서버는 Redis와 같은 외부 저장소에 상태 관리를 위임한다.
'컨테이너를 늘리면 메모리가 낭비된다'는 생각은 로컬 메모리에 상태를 저장할 때만 유효하다. Redis 등 외부 저장소에 상태를 저장하는 구조라면 컨테이너를 10개, 20개까지 늘려도 거대한 상태 데이터가 서버 메모리에 영구적으로 중복 적재되지 않는다. 각 컨테이너는 필요할 때만 Redis에서 데이터를 가져다 쓰고 버리기 때문에, 메모리 누수나 낭비 논란에서 자유롭다.
대신 로컬 변수를 읽는 것보다 Redis를 다녀오는 네트워크 I/O 비용과 가져온 데이터를 다시 객체로 변환하는 역직렬화 비용이 발생한다.
일반적인 비즈니스 로직에서는 1~2ms 수준의 Redis 네트워크 지연은 전체 응답 속도에 치명적이지 않다. 따라서 복잡한 스레드 동기화나 메모리 공유 구현 없이, 트래픽에 따라 무한 확장이 가능한 컨테이너 스케일 아웃 방식이 압도적으로 유리하다.
2. 독립성이 깨지거나 극한의 성능이 필요한 경우: 워커 스레드 (Worker Threads)
반면, 요청이 독립적이지 못하거나(Stateful), 외부 저장소를 거치는 미세한 비용조차 허용되지 않는 특수한 경우에는 워커 스레드를 사용해야 한다.
만약 webOS의 윈도우 목록 데이터가 수십 MB 이상으로 거대하거나 구조가 복잡하다고 가정해보자. 매 요청마다 Redis에서 데이터를 가져와 JSON.parse()를 수행하는 비용 자체가 CPU를 과도하게 점유하여 병목을 유발할 수 있다. 이때는 외부 저장소가 오히려 독이 된다.
이 I/O 비용을 없애려면 데이터를 서버 메모리(Heap)에 항상 들고 있어야 한다. 이때 컨테이너를 늘리면 거대한 데이터가 각 컨테이너 메모리에 중복 적재되어 물리적 메모리 부족을 초래한다.
이때 워커 스레드를 사용하면 메인 스레드와 워커들이 SharedArrayBuffer를 통해 하나의 메모리 영역을 공유할 수 있다. 데이터를 복사하거나 직렬화하는 비용 없이, 메모리 버스 속도로 데이터에 접근하여 병렬 처리가 가능해진다. (위에서 언급했듯 SharedArrayBuffer를 다루는 것은 무척 어렵다)
결국 이 설계의 핵심 질문은 내 로직이 Redis를 다녀오는 I/O 비용과 파싱 비용을 감당할 수 있는가? 이다.
- 감당 가능하다 (General Case): 대부분의 경우다. 컨테이너 확장이 정답이다. 구현이 쉽고, Redis를 통해 데이터 일관성을 보장받으며 유연하게 확장할 수 있다.
- 감당 불가능하다 (High Performance Case): 실시간성이 극도로 중요하거나 데이터가 너무 커서 로컬 메모리 접근이 필수적인 경우다. 이때는 워커 스레드와 공유 메모리 구조를 도입하여 I/O 오버헤드를 '0'으로 만들어야 한다.
Join the thread
Leave feedback, ask for clarification, or keep a focused discussion attached to this article.