cluster
HTTP 포트를 공유하는 stateless I/O 서버를 여러 프로세스로 띄워 단일 서버의 멀티코어를 쓴다.
프로세스별 Heap이 분리되므로 세션과 캐시는 Redis 같은 외부 저장소로 뺀다.프로세스는 실행 중인 프로그램의 독립된 인스턴스이고, 스레드는 그 프로세스 안에서 실제로 명령을 실행하는 가장 작은 단위이다.
서버를 운영하다 보면 “PM2 cluster mode로 몇 개의 프로세스를 띄워야 할까?”, “ECS 태스크를 2개로 늘렸는데 왜 CPU가 안 줄어들지?”, “Node.js가 싱글 스레드라는데 왜 Worker Thread를 쓰면 달라지지?” 같은 질문을 마주친다.
이 모든 질문의 답은 프로세스와 스레드가 어떻게 메모리를 쓰고, OS가 어떻게 이들을 관리하는지 알아야 나온다.
핵심 이유 세 가지:
한계의 정량적 증거: 1960년대 초기 시분할 시스템(CTSS, 초기 Multics)은 사용자 프로그램들이 동일한 물리 주소 공간을 공유했다. 한 사용자의 포인터 버그가 커널 메모리나 다른 사용자의 데이터를 덮어쓰면 즉시 시스템 전체가 다운됐다 — 단일 프로그램 결함의 폭발 반경(blast radius)이 머신 전체였다. fork() 개념을 처음 제안한 Melvin Conway(1962)와 PDP-7에서 처음 구현한 Ken Thompson은 “프로세스마다 자기 주소 공간”이라는 격리를 OS의 핵심 추상화로 만들었다 (Fork system call — Wikipedia). 단, 초기 fork()는 가상 메모리 없이 구현됐고 — 3BSD에서 MMU 기반 가상 주소 공간이 들어온 뒤에야 격리가 하드웨어로 강제됐다.
해결 메커니즘 (이 토픽이 푸는 문제): 현재 Linux의 task_struct + MMU 페이지 테이블 + clone() 시스템 콜 조합은 그 진화의 결과다. CR3 레지스터 교체로 주소 공간이 통째로 바뀌고, MMU가 권한 위반(다른 프로세스 페이지에 쓰기)을 즉시 SIGSEGV로 차단한다. 이 격리 비용은 컨텍스트 스위칭 1~10μs 수준이고(아래 3-3 참조), 그 대가로 1960년대의 “한 프로그램이 시스템을 죽인다” 문제가 사라졌다. 이 토픽이 사라지면 무엇이 깨지는가: ECS 태스크 단위 OOMKill 격리, PM2 cluster worker 재시작 시 무중단 운영, Node.js 단일 워커 크래시가 다른 워커에 전파되지 않는다는 보장 — 모두 이 추상화에 의존한다.
prerequisites와의 연결:
linux-basics.md의 가상 메모리·MMU 개념은 본 토픽의 격리 메커니즘을 하드웨어로 강제하는 기반이다 (격리가 SW만으로 강제됐다면 우회 가능).task_struct는 fork()(독립 주소 공간)와 pthread_create()(공유 주소 공간)를 같은 자료구조로 표현하는 키 — 본문 3-1의 clone() 플래그 분기 결정에 그대로 쓰인다.비유
식당에 비유하면, 프로세스는 하나의 독립된 식당이다. 주방(CPU), 냉장고(메모리), 홀(파일 시스템)을 각자 가진다. 다른 식당의 냉장고를 함부로 열 수 없고, 한 식당에 불이 나도 옆 식당은 영업을 계속한다.
원리
프로세스는 OS가 실행 중인 프로그램에 할당하는 독립된 실행 환경이다.
왜 이렇게 동작하는가? 프로세스가 독립된 메모리 공간을 갖는 이유는 보안과 안정성 때문이다. 만약 모든 프로그램이 같은 메모리를 공유하면, 하나의 버그가 다른 프로그램의 데이터를 덮어쓸 수 있다. 1960년대 초기 OS는 실제로 이런 방식이었고, 하나의 프로그램 오류가 시스템 전체를 다운시켰다. 이 문제를 해결하기 위해 하드웨어(MMU)와 OS가 협력하여 각 프로세스에 독립된 가상 주소 공간을 제공하는 구조가 만들어졌다.
Linux에서의 프로세스 생성 — fork()와 Copy-on-Write: Linux에서 새 프로세스는 fork() 시스템 콜로 생성된다. 흥미로운 점은 fork()가 부모 프로세스의 메모리를 즉시 복사하지 않는다는 것이다. 대신 Copy-on-Write(CoW) 기법을 사용한다. 부모와 자식이 동일한 물리 메모리 페이지를 읽기 전용으로 공유하다가, 한쪽이 쓰기를 시도하면 그때서야 해당 페이지만 복사한다. 이 덕분에 fork()는 마이크로초 단위로 완료되며, 자식이 곧바로 exec()로 새 프로그램을 실행하면 메모리 복사가 전혀 발생하지 않는다.
📖 더 보기: Copy-on-Write: Why Linux Process Creation is Lightning Fast — Medium — fork()와 CoW의 동작 원리를 그림과 함께 설명
내부적으로 fork()와 clone()의 관계: Linux 커널 내부에서 프로세스 생성(fork())과 스레드 생성(pthread_create())은 모두 clone() 시스템 콜을 통해 구현된다. 차이점은 clone()에 전달하는 플래그뿐이다. fork()는 SIGCHLD 플래그만 설정하여 독립된 메모리 공간을 만들고, pthread_create()는 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND 등의 플래그로 메모리, 파일 시스템, 파일 디스크립터, 시그널 핸들러를 부모와 공유한다. 커널은 프로세스와 스레드를 구분하지 않고 모두 task_struct라는 동일한 자료구조로 관리한다.
// 커널 내부 (의사 코드) — fork()와 pthread_create()의 차이// fork(): 메모리 공간 독립clone(SIGCHLD);
// pthread_create(): 메모리 공간 공유clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD);📖 더 보기: Linux Process and Thread Creation: System Call Architecture — Substack — clone() 기반의 통합 프로세스/스레드 생성 아키텍처 상세 설명
메모리는 다음 4개 영역으로 나뉜다.
| 영역 | 역할 | 특징 |
|---|---|---|
| Code (Text) | 실행할 기계어 명령 저장 | 읽기 전용, 여러 프로세스가 공유 가능 |
| Data | 전역 변수, static 변수 | 프로그램 시작 시 할당, 종료 시 해제 |
| Heap | 동적 메모리 (malloc, new) | 런타임에 크기 변동, 누수 발생 지점 |
| Stack | 함수 호출 정보, 지역 변수 | LIFO 구조, 함수 호출/반환 시 자동 관리 |
PCB (Process Control Block)
OS는 각 프로세스를 PCB라는 자료구조로 관리한다. PCB에는 다음 정보가 담긴다.
프로세스 상태 전이
flowchart LR New["New: 생성"] --> Ready["Ready: 스케줄러 대기"] Ready --> Running["Running: CPU 실행"] Running --> Terminated["Terminated: 종료"] Running -->|"I/O 또는 이벤트 대기"| Waiting["Waiting: CPU 미사용"] Waiting -->|"I/O 완료"| Ready
Node.js에서 await fs.readFile()을 호출하면 해당 프로세스(스레드)는 Waiting 상태가 되고 CPU는 다른 작업을 처리한다. 이것이 Node.js의 논블로킹 I/O가 동작하는 원리다.
코드 예시 (프로세스 ID 확인)
console.log(`현재 프로세스 PID: ${process.pid}`);console.log(`부모 프로세스 PID: ${process.ppid}`);console.log(`메모리 사용량:`, process.memoryUsage());예상 출력:
현재 프로세스 PID: 12345부모 프로세스 PID: 11000메모리 사용량: { rss: 28311552, heapTotal: 5799936, heapUsed: 3876544, external: 1142342, arrayBuffers: 10515}비유
같은 식당에서 일하는 여러 명의 직원이 스레드다. 주방(Heap), 메뉴판(Code), 냉장고(Data)는 함께 쓰지만, 각 직원은 자신만의 **작업 메모장(Stack)**을 가진다. 한 직원이 실수로 냉장고 재료를 버리면 모든 직원이 영향을 받는다.
원리
스레드는 프로세스 내부에서 실행되는 독립된 실행 흐름이다. 같은 프로세스의 스레드들은 Code, Data, Heap 영역을 공유하지만, Stack만 각자 독립적으로 가진다.
| 영역 | 공유 여부 | 이유 |
|---|---|---|
| Code | 공유 | 같은 프로그램 명령을 실행하므로 |
| Data | 공유 | 전역 변수를 같이 접근해야 하므로 |
| Heap | 공유 | 동적 할당 메모리를 스레드 간 공유 가능 |
| Stack | 독립 | 각 스레드의 함수 호출 흐름이 달라야 하므로 |
왜 스레드가 프로세스보다 가벼운가?
TLB 플러시가 왜 비싼가? TLB는 가상 주소 → 물리 주소 변환 결과를 캐싱하는 하드웨어다. 프로세스가 바뀌면 가상 주소 공간이 완전히 달라지므로 TLB의 모든 항목이 무효화된다. 이후 새 프로세스가 메모리에 접근할 때마다 TLB 미스가 발생하여 페이지 테이블을 다시 조회해야 한다. 이 간접 비용(캐시 미스 연쇄)이 단순 레지스터 저장/복원보다 훨씬 크다. 멀티코어 환경에서는 TLB Shootdown이라는 추가 비용도 발생한다 — 한 코어에서 페이지 테이블을 수정하면 다른 코어의 TLB에도 이를 알려야 하므로 IPI(Inter-Processor Interrupt)가 발생한다.
코드 예시 (Node.js Worker Thread)
const { Worker, isMainThread, parentPort, workerData,} = require("worker_threads");
if (isMainThread) { // 메인 스레드 const worker = new Worker(__filename, { workerData: { input: 1000000 }, });
worker.on("message", (result) => { console.log(`[메인] Worker 결과: ${result}`); });
worker.on("exit", () => { console.log(`[메인] Worker 종료됨`); });
console.log(`[메인] PID: ${process.pid}, Worker 생성 완료`);} else { // Worker 스레드 (같은 PID를 공유함에 주목) console.log(`[Worker] PID: ${process.pid}`); // 메인과 동일한 PID const sum = Array.from({ length: workerData.input }, (_, i) => i).reduce( (a, b) => a + b, 0, ); parentPort.postMessage(sum);}예상 출력:
[메인] PID: 12345, Worker 생성 완료[Worker] PID: 12345 ← 메인과 동일한 프로세스![메인] Worker 결과: 499999500000[메인] Worker 종료됨PID가 동일하다는 것이 핵심이다. 스레드는 같은 프로세스 안에서 동작한다.
비유
책상에서 A 과제를 하다가 B 과제로 전환할 때를 생각하자. A 과제의 지금까지 한 내용, 어디까지 읽었는지, 쓴 메모를 모두 서랍에 넣고(PCB 저장), B 과제 서랍에서 꺼내 책상에 올려놓는다(PCB 복원). 이 치우고 올리는 시간 자체가 오버헤드다. 실제 공부는 안 하면서 책상 정리만 하는 셈이다.
원리 — PCB 저장/복원 과정
1. 현재 실행 중인 프로세스/스레드 인터럽트2. OS 커널이 현재 CPU 레지스터 상태를 PCB에 저장 - 프로그램 카운터 (어디까지 실행했는지) - 스택 포인터 - 범용 레지스터 (RAX, RBX, ...)3. 스케줄러가 다음 실행할 프로세스/스레드 선택4. 해당 PCB에서 저장된 상태를 CPU 레지스터에 복원5. 프로그램 카운터가 가리키는 위치에서 실행 재개프로세스 vs 스레드 컨텍스트 스위칭 비용 비교
| 항목 | 프로세스 간 전환 | 스레드 간 전환 (같은 프로세스) |
|---|---|---|
| 메모리 주소 공간 | 변경 필요 (CR3 레지스터 갱신) | 변경 불필요 |
| TLB 플러시 | 필요 (수백 사이클 추가 비용) | 불필요 |
| 캐시 무효화 | 심각 (전혀 다른 메모리 접근) | 상대적으로 적음 (Heap/Data 공유) |
| 저장할 상태 | PCB 전체 (메모리 맵 포함) | 스택, 레지스터만 |
| 평균 소요 시간 | ~수십 μs | ~수 μs |
실제 측정값: Linux에서 프로세스 컨텍스트 스위칭은 약 310μs, 스레드 간 전환은 약 0.53μs 수준이다. CPU 캐시 미스까지 고려하면 간접 비용이 더 크다.
코드 예시 — 컨텍스트 스위칭 간접 관찰
# vmstat으로 컨텍스트 스위칭 횟수 확인vmstat 1 5예상 출력:
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 2 0 0 1234560 12345 678901 0 0 0 0 520 834 5 2 93 0 0 1 0 0 1230000 12345 678901 0 0 0 0 498 912 8 3 89 0 0cs 열이 초당 컨텍스트 스위칭 횟수다. 해석 기준:
| cs 범위 (초당) | 상태 | 행동 |
|---|---|---|
| ~1,000 이하 | 정상 | 조치 불필요 |
| 1,000~10,000 | 주의 | 워크로드 특성 파악 (I/O 집약 서버는 허용 범위) |
| 10,000 이상 | 과도 | 스레드/프로세스 수 점검, pidstat -w 1로 원인 프로세스 특정 |
| 100,000 이상 | 심각 | 즉시 조치 필요, 컨텍스트 스위칭 자체가 병목 |
절대 임계값보다 추세가 중요하다. 트래픽 증가 없이 cs가 지속 상승하면 스레드/프로세스 누수를 의심한다. (참고: IBM Db2 Performance Guide — Diagnosing high context switch rate)
비유
비교표
| 항목 | 멀티프로세싱 | 멀티스레딩 |
|---|---|---|
| 메모리 격리 | 완전 격리 (독립 주소 공간) | 공유 (Heap, Data 공유) |
| 안정성 | 높음 (하나 죽어도 나머지 생존) | 낮음 (하나가 크래시하면 전체 영향) |
| 통신 비용 | 높음 (IPC: 파이프, 소켓, 공유 메모리) | 낮음 (직접 메모리 접근) |
| 생성 비용 | 높음 (메모리 복사, 파일 디스크립터 복제) | 낮음 (Stack만 추가) |
| CPU 활용 | 멀티 코어 풀 활용 가능 | 멀티 코어 활용 가능 (GIL 없는 경우) |
| 적합한 용도 | 장애 격리 필요 서비스, CPU 바운드 분리 | 빠른 통신이 필요한 병렬 처리 |
Node.js는 어디에 해당하나?
Node.js는 기본적으로 싱글 스레드 + 이벤트 루프 구조다. 단, 여러 방식으로 병렬 처리를 지원한다.
Node.js 병렬 처리 방법:├── cluster 모듈 → 멀티프로세싱 (별도 프로세스 fork)├── worker_threads → 멀티스레딩 (같은 프로세스 내 별도 스레드)├── child_process.fork() → 완전 격리 서브프로세스 (포트 공유 없음)└── K8s HPA → Pod 레벨 수평 스케일 (컨테이너 환경)언제 무엇을 선택하는가 — 선택 매트릭스
HTTP 포트를 공유하는 stateless I/O 서버를 여러 프로세스로 띄워 단일 서버의 멀티코어를 쓴다.
프로세스별 Heap이 분리되므로 세션과 캐시는 Redis 같은 외부 저장소로 뺀다.암호화, 이미지 처리, 대용량 파싱처럼 CPU를 오래 잡는 작업을 메인 이벤트 루프 밖으로 보낸다.
스레드 수는 보통 CPU 코어 수 - 1에서 시작하고, 공유 데이터는 SharedArrayBuffer 같은 선택지를 검토한다.신뢰할 수 없는 코드나 강한 장애 격리가 필요한 작업을 별도 프로세스로 분리한다.
TCP 포트 공유가 필요 없고, IPC 비용보다 격리가 더 중요할 때 선택한다.컨테이너 환경에서 Pod 수를 늘려 멀티 노드 수준으로 수평 확장한다.
스케일 아웃 지연과 클러스터 운영 비용을 감수할 만큼 트래픽 변동이 클 때 쓴다.결정 규칙: 단일 서버라면 cluster → worker_threads 순으로 고려한다. K8s 환경이라면 단일 컨테이너 내부에서는 worker_threads, Pod 간 스케일은 HPA로 분리한다.
퀴즈
힌트: 프로세스 격리는 장애 격리에는 좋지만 Heap 공유를 막는다.
cluster worker는 같은 포트를 나눠 쓰더라도 서로 다른 PID와 주소 공간을 가진 별도 프로세스다. 그래서 Map이나 세션 메모리는 공유되지 않으며 Redis 같은 외부 저장소가 필요하다.
코드 예시 — cluster 모듈 (멀티프로세싱)
const cluster = require("cluster");const http = require("http");const numCPUs = require("os").cpus().length;
if (cluster.isPrimary) { console.log(`Primary PID: ${process.pid}`); console.log(`CPU 수: ${numCPUs}개 → Worker 생성 시작`);
for (let i = 0; i < numCPUs; i++) { cluster.fork(); // 각각 독립된 프로세스 생성 }
cluster.on("exit", (worker, code, signal) => { console.log(`Worker ${worker.process.pid} 종료 → 재시작`); cluster.fork(); // 자동 재시작 });} else { // 각 Worker는 완전히 독립된 프로세스 http .createServer((req, res) => { res.end(`응답 from Worker PID: ${process.pid}`); }) .listen(3000);
console.log(`Worker PID: ${process.pid} 시작`);}예상 출력 (4코어 CPU):
Primary PID: 10000CPU 수: 4개 → Worker 생성 시작Worker PID: 10001 시작Worker PID: 10002 시작Worker PID: 10003 시작Worker PID: 10004 시작각 Worker는 서로 다른 PID를 가진다 — 독립된 프로세스임을 확인할 수 있다.
PM2는 Node.js 앱을 CPU 수만큼 프로세스로 복제하여 멀티코어를 활용한다.
# ecosystem.config.jsmodule.exports = { apps: [{ name: 'api-server', script: 'dist/main.js', instances: 'max', // CPU 수만큼 프로세스 생성 exec_mode: 'cluster', // cluster 모드 (멀티프로세싱) max_memory_restart: '500M', // 메모리 초과 시 프로세스 재시작 }]};instances: 'max'를 설정하면 PM2가 os.cpus().length만큼 프로세스를 fork한다. 이 때 각 프로세스는 독립된 메모리를 가지므로 인메모리 캐시는 공유되지 않는다. Redis 같은 외부 저장소를 써야 하는 이유다.
AWS ECS에서 태스크(Task)는 하나의 컨테이너 인스턴스, 즉 하나의 프로세스다. 태스크 수를 2로 늘리면 2개의 독립된 프로세스가 서로 다른 요청을 처리한다. PM2 cluster와 달리 ECS 태스크 간에는 완전한 OS 레벨 격리가 보장된다.
const { Worker, isMainThread, parentPort, workerData,} = require("worker_threads");const sharp = require("sharp");
if (isMainThread) { // NestJS 서비스에서 이미지 리사이징을 Worker에 위임 function resizeImage(imagePath) { return new Promise((resolve, reject) => { const worker = new Worker(__filename, { workerData: { imagePath } }); worker.on("message", resolve); worker.on("error", reject); }); } module.exports = { resizeImage };} else { // CPU 바운드 작업을 Worker Thread에서 처리 → 이벤트 루프 블로킹 방지 sharp(workerData.imagePath) .resize(300, 300) .toBuffer() .then((data) => parentPort.postMessage(data));}Worker Thread 수 선택 기준
Node.js 공식 문서는 “논리 코어보다 많은 Worker를 만들면 그 Worker는 진행을 못하고 스케줄링·메모리 비용만 발생한다”고 명시한다. 실무 권장:
const os = require("os");const cpuCount = os.cpus().length;
// CPU 바운드 작업: 코어 수 - 1 (메인 스레드 여유분 확보)const WORKER_COUNT = Math.max(1, cpuCount - 1);
// 왜 코어 수 - 1인가?// 메인 스레드(이벤트 루프)도 하나의 CPU를 사용한다.// Worker로 모든 코어를 채우면 이벤트 루프가 CPU를 경쟁하며// 요청 수신·응답 처리가 지연된다.각 Worker Thread의 기본 스택 크기는 4 MB (stackSizeMb 기본값, Node.js 공식 문서)이므로, Worker를 과도하게 생성하면 스택 메모리만으로도 수백 MB가 순식간에 소비된다. Worker 수 초과의 두 가지 비용:
cs 상승)I/O 바운드 작업은 Worker Thread가 아닌 이벤트 루프로 처리한다. Node.js 내장 비동기 I/O(libuv)가 이미 스레드 풀(기본 4개)로 처리하므로 추가 Worker Thread는 이득이 없다. (출처: Node.js worker_threads 공식 문서)
BackOps 엔지니어 관점에서의 직접적 연결:
| 상황 | 프로세스/스레드 지식이 필요한 이유 |
|---|---|
| PM2 instances 수 결정 | CPU 수만큼 프로세스 fork → 컨텍스트 스위칭 오버헤드 최소화 |
| ECS 태스크 수 조정 | 태스크 = 프로세스 = 독립 메모리 → 인메모리 공유 불가 |
| Node.js OOM 발생 | Heap 메모리 누수 → 해당 프로세스만 재시작 (타 프로세스 영향 없음) |
| Nest.js 무거운 연산 | Worker Thread로 오프로드 → 메인 이벤트 루프 보호 |
| 서비스 장애 시 영향 범위 | 프로세스 단위 격리 이해 → 어느 범위까지 영향받는지 파악 |
실무 팁:
worker_threads로 분리하면 이벤트 루프가 막히지 않는다.| 항목 | 프로세스 | 스레드 | 코루틴 (async/await) |
|---|---|---|---|
| 생성 주체 | OS | OS | 언어 런타임 |
| 메모리 공간 | 독립 | 프로세스 내 공유 | 스레드 내 공유 |
| 선점 방식 | OS가 강제 전환 | OS가 강제 전환 | 개발자가 명시적 양보 (await) |
| 컨텍스트 스위칭 | 무거움 | 중간 | 가벼움 (OS 개입 없음) |
| Node.js에서 | cluster fork | worker_threads | async/await, Promise |
Node.js의 이벤트 루프는 싱글 스레드에서 동작하지만, libuv의 스레드 풀(Thread Pool, 기본 4개)이 파일 I/O, DNS, 암호화 등의 작업을 백그라운드에서 처리한다. I/O가 완료되면 콜백이 이벤트 루프로 전달된다.
[메인 스레드: 이벤트 루프] │ ├── fs.readFile() 호출 │ │ │ └→ [libuv 스레드 풀] (별도 스레드에서 파일 읽기) │ │ │ ←── 완료 콜백 전달 ──┘ │ └── 콜백 실행증상
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory또는 process.memoryUsage().heapUsed가 요청이 없는데도 계속 증가한다.
heapUsed 추세 해석 기준
// 주기적 모니터링 스크립트 (30초 간격)setInterval(() => { const { heapUsed, heapTotal } = process.memoryUsage(); console.log({ heapUsedMB: (heapUsed / 1024 / 1024).toFixed(1), heapTotalMB: (heapTotal / 1024 / 1024).toFixed(1), utilization: ((heapUsed / heapTotal) * 100).toFixed(1) + "%", timestamp: new Date().toISOString(), });}, 30_000);| 패턴 | 판단 | 행동 |
|---|---|---|
| 요청 처리 후 증가 → GC 후 감소 | 정상 (GC 작동 중) | 조치 불필요 |
| 단조 증가가 1분 이상 지속 (트래픽 없음에도) | 누수 의심 | heapdump로 스냅샷 비교 |
| heapUsed / heapTotal > 85% 지속 | GC 압박 | --max-old-space-size 조정 또는 누수 조사 |
원인
Heap 메모리에 더 이상 필요 없는 객체가 가비지 컬렉션(GC)되지 않고 남아있다. 전형적 원인:
해결 방법
# 1. 즉시 조치: PM2로 메모리 임계치 초과 시 자동 재시작pm2 start app.js --max-memory-restart 500M
# 2. 진단: heapdump로 메모리 스냅샷 분석node --inspect app.js# Chrome DevTools → Memory 탭 → Heap Snapshot 비교// 3. 예방: EventEmitter 리스너 정리class MyService { constructor(emitter) { this.handler = (data) => this.process(data); emitter.on("data", this.handler); }
destroy(emitter) { emitter.off("data", this.handler); // 반드시 해제 }}증상
Worker Thread로 대용량 이미지 버퍼(수십 MB)를 postMessage로 전달할 때 처리 시간이 예상보다 훨씬 길다.
원인
postMessage는 기본적으로 데이터를 직렬화(structured clone)하여 복사한다. 100MB 버퍼를 보내면 100MB를 복사하는 비용이 발생한다. 이는 스레드가 Heap을 “공유”하더라도 직접 접근이 아닌 메시지 방식으로 통신하기 때문이다.
해결 방법
// SharedArrayBuffer 또는 Transferable Objects 사용const { Worker, isMainThread, parentPort, workerData,} = require("worker_threads");
if (isMainThread) { // 방법 1: SharedArrayBuffer — 복사 없이 메모리 공유 const shared = new SharedArrayBuffer(1024 * 1024); // 1MB 공유 메모리 const worker = new Worker(__filename, { workerData: { shared } });
// 방법 2: Transferable — 소유권 이전 (복사 없음, 원본은 사용 불가) const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB worker.postMessage({ buffer }, [buffer]); // [buffer]가 transfer list // 이후 buffer는 빈 상태가 됨}증상
PM2 cluster 모드로 4개 프로세스를 띄웠는데, 로그인 후 일부 요청에서 인증이 풀리거나 캐시 미스가 발생한다.
원인
cluster 모드의 각 Worker는 완전히 독립된 프로세스다. Worker 1에서 메모리에 저장한 세션이나 캐시는 Worker 2~4가 접근할 수 없다. 로드 밸런서가 같은 사용자의 요청을 다른 Worker에 전달하면 세션을 찾지 못한다.
해결 방법
// 인메모리 세션 대신 Redis 세션 저장소 사용const session = require("express-session");const RedisStore = require("connect-redis").default;const { createClient } = require("redis");
const redisClient = createClient({ url: process.env.REDIS_URL });await redisClient.connect();
app.use( session({ store: new RedisStore({ client: redisClient }), // 모든 Worker가 Redis 공유 secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, }),);캐시도 마찬가지다. 인메모리 Map이나 node-cache 대신 Redis를 사용해야 cluster 환경에서 일관성이 보장된다.
증상
top 또는 vmstat에서 cs (context switch) 수치가 비정상적으로 높고 (수만 회/초), CPU sy (system time) 비율이 높은데 실제 작업 처리량은 낮다.
원인
스레드/프로세스 수가 CPU 코어 수보다 과도하게 많을 때 발생한다. OS가 너무 자주 컨텍스트 스위칭을 해 실제 연산보다 스케줄링에 더 많은 시간을 쓴다.
해결 방법
# CPU 코어 수 확인nproc# 또는node -e "console.log(require('os').cpus().length)"
# PM2: instances를 CPU 수로 맞춤 (초과하지 않도록)pm2 start app.js -i max # CPU 수만큼만 생성
# 현재 컨텍스트 스위칭 모니터링vmstat 1 # cs 컬럼 확인pidstat -w 1 # 프로세스별 컨텍스트 스위칭 확인일반적으로 CPU 바운드 작업은 Worker 수 = CPU 코어 수, I/O 바운드 작업은 이벤트 루프로 처리하는 것이 최적이다.
증상
# ECS 서비스의 특정 태스크만 반복적으로 OOMKilled 되면서# 같은 인스턴스의 다른 태스크도 응답 지연 발생[ECS] Task abc123 stopped: Essential container in task exitedReason: OutOfMemory원인
ECS 태스크는 프로세스 수준의 격리(cgroups)를 제공하지만, 같은 EC2 인스턴스를 공유하는 태스크들은 물리 메모리를 경쟁한다. 하나의 태스크가 메모리 제한(memory)은 설정했지만 memoryReservation(소프트 제한)만 설정한 경우, 실제로는 인스턴스의 남은 메모리를 모두 사용할 수 있어 다른 태스크가 메모리 부족에 빠진다.
해결 방법
// ECS 태스크 정의에서 하드 리밋 반드시 설정{ "containerDefinitions": [ { "name": "api-server", "memory": 512, // 하드 리밋: 초과 시 OOMKill "memoryReservation": 256, // 소프트 리밋: 스케줄링용 예약 "environment": [ { "name": "NODE_OPTIONS", "value": "--max-old-space-size=400" // 컨테이너 제한보다 낮게 } ] } ]}Fargate를 사용하면 태스크 레벨에서 메모리가 완전 격리되므로, EC2 → Fargate 마이그레이션으로 근본 해결이 가능하다.
Node.js의 async/await는 코루틴(협력형 멀티태스킹) 이다. OS가 강제로 전환하는 것이 아니라 개발자가 await 지점에서 자발적으로 실행권을 반납한다.
| 종류 | 스케줄링 주체 | 예시 | 특징 |
|---|---|---|---|
| OS 스레드 (Native Thread) | OS 커널 | pthread, Node.js Worker | 멀티코어 병렬 실행 가능, 비용 큼 |
| Green Thread | 언어 런타임 | Java 21 Virtual Thread | OS 스레드보다 가볍지만 구버전 한계 |
| Goroutine | Go 런타임 | Go go func() | M:N 스케줄링, 수백만 개 생성 가능 |
| 코루틴 (async/await) | 이벤트 루프 | Node.js async/await | 싱글 스레드, I/O 대기 시 협력적 양보 |
// Node.js 코루틴: OS가 개입하지 않고 await 지점에서 자발적 양보async function handleRequest(req) { const data = await db.query(sql); // ← 여기서 이벤트 루프에 제어권 반환 // await 동안 다른 요청의 콜백이 실행될 수 있음 return process(data);}Go Goroutine과의 비교: Go는 M:N 스케줄링(M개의 Goroutine을 N개의 OS 스레드에 매핑)으로 CPU 집약 작업도 병렬로 처리한다. Node.js의 async/await는 I/O 대기를 효율적으로 처리하지만 CPU 집약 작업은 Worker Thread 없이는 블로킹된다. 이 차이가 Go가 CPU 집약 서비스에 강하고 Node.js가 I/O 집약 서비스에 강한 이유다.
Java 21 Virtual Thread: Java 21부터 도입된 Virtual Thread(Project Loom)는 Go Goroutine처럼 수백만 개를 생성할 수 있는 경량 스레드다. 기존 Java 스레드 API를 그대로 쓸 수 있어 이전 코드와 호환된다.
📖 더 보기: Node.js vs Go: Concurrency Model Comparison — Medium — Node.js 이벤트 루프와 Go Goroutine의 동시성 모델을 실제 벤치마크로 비교 (중급)
Signal은 OS가 프로세스에게 특정 이벤트를 알리는 비동기 메커니즘이다. kill -9 PID의 -9가 바로 시그널 번호(SIGKILL)다.
# 주요 Signal 목록kill -l # 모든 시그널 목록
# Signal 종류# SIGTERM (15): 정상 종료 요청 (프로세스가 처리 가능)# SIGKILL (9): 강제 종료 (프로세스가 무시 불가)# SIGINT (2): 터미널 Ctrl+C# SIGHUP (1): 터미널 연결 끊김, 설정 재로드 트리거로 사용# SIGUSR1 (10): 사용자 정의 시그널 (Node.js: Inspector 활성화)# SIGUSR2 (12): 사용자 정의 시그널 (PM2: graceful reload 트리거)// NestJS에서 Signal 처리 — Graceful Shutdownasync function bootstrap() { const app = await NestFactory.create(AppModule);
// SIGTERM 수신 시 (K8s Pod 종료, PM2 stop 등) process.on("SIGTERM", async () => { console.log("SIGTERM 수신 — graceful shutdown 시작"); await app.close(); // 진행 중인 요청 완료 후 종료 process.exit(0); });
// SIGINT 수신 시 (Ctrl+C) process.on("SIGINT", async () => { await app.close(); process.exit(0); });
await app.listen(3000);}K8s에서 SIGTERM이 중요한 이유: K8s가 Pod를 종료할 때 SIGTERM을 먼저 보내고 terminationGracePeriodSeconds(기본 30초) 후에 SIGKILL을 보낸다. Node.js가 SIGTERM을 처리하지 않으면 진행 중인 요청이 즉시 끊어진다. 반드시 SIGTERM 핸들러로 graceful shutdown을 구현해야 한다.
자식 프로세스가 종료됐는데 부모가 wait()를 호출하지 않으면, 자식의 PCB(종료 상태 정보)가 계속 메모리에 남는다. 이 상태를 좀비 프로세스라 한다.
# 좀비 프로세스 확인ps aux | grep 'Z'# USER PID %CPU %MEM STAT COMMAND# app 999 0.0 0.0 Z [defunct] ← 좀비 상태
# 좀비 프로세스가 많으면 부모 PID 확인ps -el | awk '$8 == "Z" {print $5}' # PPID 출력Node.js에서 좀비 방지: child_process.spawn()으로 자식 프로세스를 만들면 Node.js가 자동으로 wait() 처리를 한다. 단, detached: true로 분리한 프로세스는 수동으로 child.unref()를 호출해야 한다.
const { spawn } = require("child_process");
// 자식 프로세스 생성 및 종료 대기 (좀비 방지)const child = spawn("python3", ["script.py"]);
child.on("exit", (code) => { console.log(`자식 프로세스 종료: exitCode=${code}`); // Node.js가 내부적으로 wait() 처리 → 좀비 방지});
// 오류 처리 누락 시 ENOENT, EACCES 등이 unhandled 예외로 터질 수 있음child.on("error", (err) => { console.error("프로세스 생성 실패:", err.message);});# 현재 실행 중인 Node.js 프로세스 목록ps aux | grep node
# 특정 PID의 메모리 사용량 상세 확인cat /proc/<PID>/status | grep -E "VmRSS|VmHeap|Threads"예상 출력:
USER PID %CPU %MEM VSZ RSS TTY STAT COMMANDyoung 12345 0.5 1.2 123456 45678 pts/0 Sl+ node app.jsyoung 12346 0.3 1.1 123456 44000 pts/0 Sl+ node app.js ← PM2 cluster workernode -e "const { Worker, isMainThread, parentPort } = require('worker_threads');if (isMainThread) { console.log('메인 스레드 PID:', process.pid); new Worker(require('vm').Script, { eval: true, workerData: null }); // 간단하게 inline 실행 const w = new Worker(\` const { parentPort } = require('worker_threads'); parentPort.postMessage(process.pid); \`, { eval: true }); w.on('message', pid => console.log('Worker 스레드 PID:', pid));}"예상 출력:
메인 스레드 PID: 12345Worker 스레드 PID: 12345 ← 동일한 PID → 같은 프로세스!node -e "const cluster = require('cluster');if (cluster.isPrimary) { console.log('Primary PID:', process.pid); const w = cluster.fork(); w.on('message', msg => console.log('Worker PID:', msg));} else { process.send(process.pid);}"예상 출력:
Primary PID: 10000Worker PID: 10001 ← 다른 PID → 독립된 프로세스!# 1초마다 시스템 전체 컨텍스트 스위칭 횟수 출력vmstat 1 5예상 출력:
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 1 0 0 8123456 12345 2345678 0 0 0 1 523 834 5 2 93 0 0 2 0 0 8100000 12345 2345678 0 0 0 0 601 1203 12 4 84 0 0cs 값이 증가할수록 더 많은 컨텍스트 스위칭이 발생 중이다.
프로세스는 독립된 식당(메모리 격리), 스레드는 같은 식당의 직원(Stack만 독립)이며, 컨텍스트 스위칭은 책상 위 과제를 바꾸는 비용이다 — Node.js cluster는 여러 식당을 여는 것이고, Worker Thread는 같은 식당에 직원을 더 두는 것이다.