Process & Thread
프로세스와 스레드 (Process & Thread)
섹션 제목: “프로세스와 스레드 (Process & Thread)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”프로세스는 실행 중인 프로그램의 독립된 인스턴스이고, 스레드는 그 프로세스 안에서 실제로 명령을 실행하는 가장 작은 단위이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”서버를 운영하다 보면 “PM2 cluster mode로 몇 개의 프로세스를 띄워야 할까?”, “ECS 태스크를 2개로 늘렸는데 왜 CPU가 안 줄어들지?”, “Node.js가 싱글 스레드라는데 왜 Worker Thread를 쓰면 달라지지?” 같은 질문을 마주친다.
이 모든 질문의 답은 프로세스와 스레드가 어떻게 메모리를 쓰고, OS가 어떻게 이들을 관리하는지 알아야 나온다.
핵심 이유 세 가지:
- 장애 격리: 프로세스가 독립된 메모리를 가지기 때문에 하나가 죽어도 다른 프로세스가 살아있다. ECS 태스크 수 = 프로세스 수라는 사실이 이 원리에서 나온다.
- 성능 튜닝: 컨텍스트 스위칭 비용을 이해해야 스레드 수를 올바르게 설정할 수 있다.
- 메모리 누수 디버깅: Node.js에서 메모리 누수가 발생하면 어느 단위(프로세스/스레드)를 재시작해야 하는지 판단할 수 있다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. 프로세스 (Process)
섹션 제목: “3-1. 프로세스 (Process)”비유
식당에 비유하면, 프로세스는 하나의 독립된 식당이다. 주방(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에는 다음 정보가 담긴다.
- PID (Process ID): 프로세스 고유 번호
- 프로세스 상태: New, Ready, Running, Waiting, Terminated 중 하나
- 프로그램 카운터(PC): 다음에 실행할 명령어 주소
- CPU 레지스터 값: 컨텍스트 스위칭 시 저장/복원
- 메모리 관리 정보: 페이지 테이블, 메모리 경계
- 입출력 상태: 열린 파일 목록, I/O 요청 상태
프로세스 상태 전이
New → Ready → Running → Terminated ↑ ↓ Waiting (I/O 발생 시)- New: 프로세스가 막 생성된 상태
- Ready: CPU를 받을 준비 완료, 스케줄러 대기 중
- Running: CPU에서 실제 실행 중
- Waiting: I/O 완료나 이벤트를 기다리는 상태 (CPU 사용 안 함)
- Terminated: 실행 완료 또는 강제 종료
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}3-2. 스레드 (Thread)
섹션 제목: “3-2. 스레드 (Thread)”비유
같은 식당에서 일하는 여러 명의 직원이 스레드다. 주방(Heap), 메뉴판(Code), 냉장고(Data)는 함께 쓰지만, 각 직원은 자신만의 **작업 메모장(Stack)**을 가진다. 한 직원이 실수로 냉장고 재료를 버리면 모든 직원이 영향을 받는다.
원리
스레드는 프로세스 내부에서 실행되는 독립된 실행 흐름이다. 같은 프로세스의 스레드들은 Code, Data, Heap 영역을 공유하지만, Stack만 각자 독립적으로 가진다.
| 영역 | 공유 여부 | 이유 |
|---|---|---|
| Code | 공유 | 같은 프로그램 명령을 실행하므로 |
| Data | 공유 | 전역 변수를 같이 접근해야 하므로 |
| Heap | 공유 | 동적 할당 메모리를 스레드 간 공유 가능 |
| Stack | 독립 | 각 스레드의 함수 호출 흐름이 달라야 하므로 |
왜 스레드가 프로세스보다 가벼운가?
- 메모리 공유: 스레드 생성 시 새 메모리 공간을 할당하지 않는다. 프로세스 fork는 수 MB의 메모리 복사가 필요하지만, 스레드 생성은 Stack 공간(기본 수백 KB)만 추가된다.
- 컨텍스트 스위칭 비용: 같은 프로세스의 스레드 간 전환은 메모리 주소 공간이 동일하므로 TLB(Translation Lookaside Buffer) 플러시가 불필요하다. 프로세스 간 전환은 TLB 플러시가 필요해 수백 사이클이 추가 소요된다.
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가 동일하다는 것이 핵심이다. 스레드는 같은 프로세스 안에서 동작한다.
3-3. 컨텍스트 스위칭 (Context Switching)
섹션 제목: “3-3. 컨텍스트 스위칭 (Context Switching)”비유
책상에서 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)
3-4. 멀티프로세싱 vs 멀티스레딩
섹션 제목: “3-4. 멀티프로세싱 vs 멀티스레딩”비유
- 멀티프로세싱: 여러 개의 독립된 식당 체인점. 한 지점에 화재가 나도 다른 지점은 정상 영업한다. 단, 체인점 간 재료(데이터) 공유는 배달(IPC)을 통해서만 가능하다.
- 멀티스레딩: 하나의 식당에 여러 직원. 소통이 빠르고 재료를 바로 꺼내 쓸 수 있지만, 실수하면 다 같이 영향을 받는다.
비교표
| 항목 | 멀티프로세싱 | 멀티스레딩 |
|---|---|---|
| 메모리 격리 | 완전 격리 (독립 주소 공간) | 공유 (Heap, Data 공유) |
| 안정성 | 높음 (하나 죽어도 나머지 생존) | 낮음 (하나가 크래시하면 전체 영향) |
| 통신 비용 | 높음 (IPC: 파이프, 소켓, 공유 메모리) | 낮음 (직접 메모리 접근) |
| 생성 비용 | 높음 (메모리 복사, 파일 디스크립터 복제) | 낮음 (Stack만 추가) |
| CPU 활용 | 멀티 코어 풀 활용 가능 | 멀티 코어 활용 가능 (GIL 없는 경우) |
| 적합한 용도 | 장애 격리 필요 서비스, CPU 바운드 분리 | 빠른 통신이 필요한 병렬 처리 |
Node.js는 어디에 해당하나?
Node.js는 기본적으로 싱글 스레드 + 이벤트 루프 구조다. 단, 여러 방식으로 병렬 처리를 지원한다.
Node.js 병렬 처리 방법:├── cluster 모듈 → 멀티프로세싱 (별도 프로세스 fork)├── worker_threads → 멀티스레딩 (같은 프로세스 내 별도 스레드)├── child_process.fork() → 완전 격리 서브프로세스 (포트 공유 없음)└── K8s HPA → Pod 레벨 수평 스케일 (컨테이너 환경)언제 무엇을 선택하는가 — 선택 매트릭스
| 방식 | 선택 조건 | 주요 트레이드오프 |
|---|---|---|
| cluster | HTTP 포트 공유 + stateless + I/O 집약 서비스, 단일 서버에서 멀티코어 활용 | 프로세스별 독립 메모리 → 인메모리 캐시 공유 불가, Redis 필요 |
| worker_threads | CPU 집약 연산(암호화·이미지 처리·대용량 파싱), SharedArrayBuffer로 데이터 공유 필요 | 스레드 크래시 시 전체 프로세스 영향, 스레드 수 = CPU 코어 수 - 1 권장 |
| child_process.fork() | 신뢰할 수 없는 코드 실행, 완전 격리가 필요한 위험 작업, TCP 포트 공유 불필요 | cluster보다 생성 비용 동일하나 포트 공유 메커니즘 없음 |
| K8s HPA | 컨테이너 환경에서 트래픽에 따른 Pod 수 자동 조절 필요, 멀티 컨테이너/멀티 노드 스케일 | 스케일 아웃 지연(수십 초), 단일 서버 환경에서는 오버엔지니어링 |
결정 규칙: 단일 서버라면 cluster → worker_threads 순으로 고려한다. K8s 환경이라면 단일 컨테이너 내부에서는 worker_threads, Pod 간 스케일은 HPA로 분리한다.
코드 예시 — 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를 가진다 — 독립된 프로세스임을 확인할 수 있다.
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”PM2 cluster mode
섹션 제목: “PM2 cluster mode”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 같은 외부 저장소를 써야 하는 이유다.
ECS 태스크 수 = 프로세스 수
섹션 제목: “ECS 태스크 수 = 프로세스 수”AWS ECS에서 태스크(Task)는 하나의 컨테이너 인스턴스, 즉 하나의 프로세스다. 태스크 수를 2로 늘리면 2개의 독립된 프로세스가 서로 다른 요청을 처리한다. PM2 cluster와 달리 ECS 태스크 간에는 완전한 OS 레벨 격리가 보장된다.
CPU 바운드 작업에 Worker Thread 활용
섹션 제목: “CPU 바운드 작업에 Worker Thread 활용”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 수 초과의 두 가지 비용:
- 스케줄링 오버헤드: OS가 코어 수보다 많은 스레드를 교대로 실행 → context switching 급증 (vmstat
cs상승) - 메모리 낭비: 실행되지 않는 Worker도 스택(4 MB)과 V8 힙(최소 수 MB) 점유
I/O 바운드 작업은 Worker Thread가 아닌 이벤트 루프로 처리한다. Node.js 내장 비동기 I/O(libuv)가 이미 스레드 풀(기본 4개)로 처리하므로 추가 Worker Thread는 이득이 없다. (출처: Node.js worker_threads 공식 문서)
5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”BackOps 엔지니어 관점에서의 직접적 연결:
| 상황 | 프로세스/스레드 지식이 필요한 이유 |
|---|---|
| PM2 instances 수 결정 | CPU 수만큼 프로세스 fork → 컨텍스트 스위칭 오버헤드 최소화 |
| ECS 태스크 수 조정 | 태스크 = 프로세스 = 독립 메모리 → 인메모리 공유 불가 |
| Node.js OOM 발생 | Heap 메모리 누수 → 해당 프로세스만 재시작 (타 프로세스 영향 없음) |
| Nest.js 무거운 연산 | Worker Thread로 오프로드 → 메인 이벤트 루프 보호 |
| 서비스 장애 시 영향 범위 | 프로세스 단위 격리 이해 → 어느 범위까지 영향받는지 파악 |
실무 팁:
- NestJS에서 CPU 바운드 작업(암호화, 이미지 처리, 대용량 CSV 파싱)은
worker_threads로 분리하면 이벤트 루프가 막히지 않는다. - ECS에서 하나의 태스크가 메모리를 과다 사용하면 해당 태스크만 재시작된다. 다른 태스크는 영향 없다 — 프로세스 격리 덕분이다.
6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”프로세스 vs 스레드 vs 코루틴
섹션 제목: “프로세스 vs 스레드 vs 코루틴”| 항목 | 프로세스 | 스레드 | 코루틴 (async/await) |
|---|---|---|---|
| 생성 주체 | OS | OS | 언어 런타임 |
| 메모리 공간 | 독립 | 프로세스 내 공유 | 스레드 내 공유 |
| 선점 방식 | OS가 강제 전환 | OS가 강제 전환 | 개발자가 명시적 양보 (await) |
| 컨텍스트 스위칭 | 무거움 | 중간 | 가벼움 (OS 개입 없음) |
| Node.js에서 | cluster fork | worker_threads | async/await, Promise |
Node.js Event Loop vs Thread
섹션 제목: “Node.js Event Loop vs Thread”Node.js의 이벤트 루프는 싱글 스레드에서 동작하지만, libuv의 스레드 풀(Thread Pool, 기본 4개)이 파일 I/O, DNS, 암호화 등의 작업을 백그라운드에서 처리한다. I/O가 완료되면 콜백이 이벤트 루프로 전달된다.
[메인 스레드: 이벤트 루프] │ ├── fs.readFile() 호출 │ │ │ └→ [libuv 스레드 풀] (별도 스레드에서 파일 읽기) │ │ │ ←── 완료 콜백 전달 ──┘ │ └── 콜백 실행6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”트러블슈팅 1: Node.js 메모리 누수 — 프로세스가 계속 메모리를 먹는다
섹션 제목: “트러블슈팅 1: Node.js 메모리 누수 — 프로세스가 계속 메모리를 먹는다”증상
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)되지 않고 남아있다. 전형적 원인:
- 전역 변수나 클로저에 대용량 배열/객체가 쌓임
- EventEmitter에 리스너를 등록하고 해제하지 않음
- 캐시 객체에 만료 정책이 없어 무한 증가
해결 방법
# 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); // 반드시 해제 }}트러블슈팅 2: Worker Thread 통신 이슈 — postMessage가 느리거나 대용량 데이터 전송 시 복사 비용
섹션 제목: “트러블슈팅 2: Worker Thread 통신 이슈 — postMessage가 느리거나 대용량 데이터 전송 시 복사 비용”증상
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는 빈 상태가 됨}트러블슈팅 3: cluster 모드에서 세션/캐시가 공유되지 않는다
섹션 제목: “트러블슈팅 3: cluster 모드에서 세션/캐시가 공유되지 않는다”증상
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 환경에서 일관성이 보장된다.
트러블슈팅 4: 과도한 컨텍스트 스위칭으로 CPU 사용률이 높다
섹션 제목: “트러블슈팅 4: 과도한 컨텍스트 스위칭으로 CPU 사용률이 높다”증상
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 바운드 작업은 이벤트 루프로 처리하는 것이 최적이다.
트러블슈팅 5: ECS 태스크 메모리 격리 — 하나의 컨테이너가 다른 태스크에 영향
섹션 제목: “트러블슈팅 5: ECS 태스크 메모리 격리 — 하나의 컨테이너가 다른 태스크에 영향”증상
# 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 마이그레이션으로 근본 해결이 가능하다.
7. 체크리스트
섹션 제목: “7. 체크리스트”- 프로세스의 4개 메모리 영역(Code, Data, Heap, Stack)을 설명할 수 있다
- PCB가 무엇이고, OS가 PCB를 어떻게 사용하는지 설명할 수 있다
- 프로세스 상태 전이(New → Ready → Running → Waiting → Terminated)를 설명할 수 있다
- 스레드가 공유하는 것과 독립적으로 가지는 것을 구분할 수 있다
- 스레드 컨텍스트 스위칭이 프로세스 컨텍스트 스위칭보다 가벼운 이유를 설명할 수 있다
- Node.js cluster 모듈이 멀티프로세싱을 사용하는 이유를 설명할 수 있다
- Worker Thread와 cluster의 차이를 설명하고 언제 각각을 쓸지 말할 수 있다
- PM2 cluster 모드에서 세션/캐시가 공유되지 않는 이유를 설명할 수 있다
-
vmstat으로 컨텍스트 스위칭 횟수를 확인할 수 있다
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”- Process: 실행 중인 프로그램의 독립 인스턴스
- Thread: 프로세스 내 실행 단위, Stack만 독립
- PCB (Process Control Block): OS가 프로세스를 관리하는 자료구조
- Context Switching: CPU가 실행 중인 프로세스/스레드를 전환하는 작업
- TLB (Translation Lookaside Buffer): 가상→물리 메모리 주소 변환 캐시, 프로세스 전환 시 플러시됨
- Heap: 동적 할당 메모리, 스레드 간 공유, 메모리 누수 발생 지점
- Stack: 함수 호출 정보, 스레드별 독립
- IPC (Inter-Process Communication): 프로세스 간 통신 (파이프, 소켓, 공유 메모리)
- cluster module: Node.js에서 멀티프로세싱을 구현하는 내장 모듈
- worker_threads: Node.js에서 멀티스레딩을 구현하는 내장 모듈
- SharedArrayBuffer: 스레드 간 메모리 공유를 위한 객체
- libuv: Node.js의 비동기 I/O 라이브러리, 내부 스레드 풀 보유
8.3. 심화 개념
섹션 제목: “8.3. 심화 개념”Green Thread, Goroutine, 코루틴 — OS 스레드 vs 유저 스레드
섹션 제목: “Green Thread, Goroutine, 코루틴 — OS 스레드 vs 유저 스레드”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 — 프로세스 간 비동기 알림
섹션 제목: “Signal — 프로세스 간 비동기 알림”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을 구현해야 한다.
Zombie 프로세스
섹션 제목: “Zombie 프로세스”자식 프로세스가 종료됐는데 부모가 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);});8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 Node.js 공식 문서 — cluster 모듈 — Node.js cluster 모듈의 공식 API 레퍼런스. fork, IPC 이벤트 목록 포함 (입문)
- 📖 Node.js 공식 문서 — worker_threads 모듈 — Worker Thread 생성, SharedArrayBuffer, Atomics 사용법 공식 가이드 (중급)
- 📖 Worker Threads in Node.js: A Complete Guide — NodeSource — Worker Thread 풀 패턴, postMessage 최적화, SharedArrayBuffer 사용법까지 실무 중심 정리 (중급)
- 📖 Measuring Context Switching and Memory Overheads for Linux Threads — Eli Bendersky — 실험 데이터로 스레드/프로세스 컨텍스트 스위칭 비용을 직접 측정한 심층 분석 (중급)
- 📖 The Difference Between fork(), vfork(), exec() and clone() — Baeldung — Linux 프로세스 생성 4가지 방법의 차이를 비교표와 함께 정리 (입문)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실험 1: 프로세스 정보 확인
섹션 제목: “실험 1: 프로세스 정보 확인”# 현재 실행 중인 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 worker실험 2: Worker Thread vs 메인 스레드 PID 비교
섹션 제목: “실험 2: Worker Thread vs 메인 스레드 PID 비교”node -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 → 같은 프로세스!실험 3: cluster fork 후 PID 확인
섹션 제목: “실험 3: cluster fork 후 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 → 독립된 프로세스!실험 4: 컨텍스트 스위칭 모니터링
섹션 제목: “실험 4: 컨텍스트 스위칭 모니터링”# 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 값이 증가할수록 더 많은 컨텍스트 스위칭이 발생 중이다.
10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”프로세스는 독립된 식당(메모리 격리), 스레드는 같은 식당의 직원(Stack만 독립)이며, 컨텍스트 스위칭은 책상 위 과제를 바꾸는 비용이다 — Node.js cluster는 여러 식당을 여는 것이고, Worker Thread는 같은 식당에 직원을 더 두는 것이다.