async/await
HTTP 호출, DB 쿼리, 파일 읽기처럼 대부분 시간이 대기인 I/O 작업을 이벤트 루프에 맡긴다.
CPU 계산이 짧고, 요청 중 대기 시간이 긴 일반 API 서버의 기본값.프로세스는 독립된 주소 공간을 가진 실행 중인 프로그램이고, 스레드는 그 프로세스 안에서 CPU가 실제로 실행하는 흐름이다.
linux-basics.mdx에서 PID, ps, 시그널, 좀비 프로세스, 권한의 표면을 봤다면 이 문서는 한 단계 안쪽을 본다. 핵심 질문은 “왜 같은 서버의 작업들이 서로의 메모리를 마음대로 덮어쓰지 못하는가”, “왜 worker thread와 cluster의 장애 범위가 다른가”, “언제 공유 메모리 속도보다 격리를 우선해야 하는가”다.
서버를 운영하면 다음 질문이 바로 나온다.
worker_threads를 쓰면 무엇이 달라지는가?이 질문은 모두 같은 축으로 정리된다.
| 축 | 물어볼 질문 | 잘못 이해했을 때 생기는 문제 |
|---|---|---|
| 주소 공간 | 이 실행 단위가 메모리를 독립적으로 가지는가 | cluster worker 사이에 Map 캐시가 공유된다고 착각 |
| 공유 범위 | Code, Heap, 파일 디스크립터, 시그널 핸들러 중 무엇을 공유하는가 | worker thread끼리 안전하게 아무 객체나 공유한다고 착각 |
| 스케줄링 비용 | CPU가 실행 흐름을 바꿀 때 얼마를 잃는가 | 코어 수보다 많은 worker를 만들어 context switching만 증가 |
| 장애 격리 | 하나가 죽거나 메모리를 오염시키면 어디까지 번지는가 | 신뢰할 수 없는 작업을 thread에 넣어 전체 프로세스 크래시 |
이 문서의 범위는 실행 단위와 격리다. 시스템 콜이 커널/유저 공간 경계를 어떻게 넘는지는 system-call-interrupt.mdx에서 더 깊게 다룬다. 여기서는 fork(), clone() 같은 이름을 “실행 단위를 만드는 입구” 정도로만 사용한다.
가상 메모리와 하드웨어 보호가 충분히 자리 잡기 전에는 여러 실행 흐름이 같은 물리 메모리를 서로 망가뜨릴 위험이 컸다. 한 프로그램의 포인터 버그가 다른 사용자의 데이터나 커널 영역을 덮어쓰면 시스템 전체가 멈출 수 있었다. 문제는 “프로그램을 여러 개 실행한다”가 아니라 “여러 실행 흐름이 같은 메모리를 서로 망가뜨리지 못하게 막는다”였다.
현대 OS는 이 문제를 프로세스별 가상 주소 공간으로 푼다.
0x1000을 쓰더라도 실제 물리 메모리 위치는 다를 수 있다.task_struct: Linux 커널이 실제로 각 실행 흐름을 관리할 때 쓰는 대표 구조체다. Linux에서는 프로세스와 스레드가 모두 task로 표현되고, 어떤 자원을 공유하느냐로 차이가 난다.해결 메커니즘은 단순하다. 프로세스마다 주소 공간을 분리하고, CPU가 메모리에 접근할 때마다 MMU가 “이 프로세스가 이 페이지를 읽거나 써도 되는가”를 검사한다. 덕분에 한 프로세스가 잘못된 주소를 건드리면 보통 그 프로세스만 SIGSEGV로 죽고, 다른 프로세스의 Heap은 보호된다.
이 추상화가 깨지면 ECS task 단위 OOM 격리, PM2 cluster worker 재시작, Node.js worker crash 격리 같은 운영 판단이 전부 흐려진다. 그래서 프로세스와 스레드는 단순한 CS 용어가 아니라 장애 범위를 설계하는 단위다.
프로세스는 OS가 실행 중인 프로그램에 부여한 독립 실행 환경이다. linux-basics.mdx에서 본 PID는 이 실행 환경을 식별하는 번호이고, 이 문서에서 중요한 부분은 PID 뒤에 붙어 있는 독립 주소 공간이다.
하나의 Node.js 서버 프로세스를 실행하면 대략 다음 영역이 생긴다.
| 영역 | 무엇을 담는가 | 장애와 연결되는 지점 |
|---|---|---|
| Code/Text | 실행할 기계어 명령 | 읽기 전용이라 여러 프로세스가 같은 바이너리 페이지를 공유할 수 있다 |
| Data | 전역 변수, static 데이터 | 프로세스별로 독립된다 |
| Heap | 동적으로 할당되는 객체 | Node.js 메모리 누수와 가장 자주 연결된다 |
| Stack | 함수 호출 프레임과 지역 변수 | 스레드마다 독립된다 |
| 열린 파일/소켓 | 파일 디스크립터, 네트워크 연결 | fork() 시 복제되거나 clone() 플래그에 따라 공유될 수 있다 |
작은 확인 예시는 다음과 같다.
console.log("pid", process.pid);console.log("ppid", process.ppid);console.log(process.memoryUsage());예상 출력:
pid 12345ppid 11000{ rss: 28311552, heapTotal: 5799936, heapUsed: 3876544, external: 1142342, arrayBuffers: 10515}여기서 heapUsed는 V8 Heap 안에서 실제로 쓰는 JavaScript 객체 메모리이고, rss는 OS가 본 프로세스의 실제 상주 메모리(Resident Set Size)에 가깝다. heapUsed만 보고 컨테이너 OOM을 판단하면 native buffer, code page, stack, shared library 같은 영역을 놓칠 수 있다.
반례: “프로세스가 독립이면 메모리를 전혀 공유하지 않는다”는 말은 정확하지 않다. Code/Text처럼 읽기 전용인 페이지는 여러 프로세스가 같은 물리 페이지를 공유할 수 있다. 중요한 것은 쓰기 가능한 Heap/Data가 논리적으로 분리되고, 쓰기 시도는 페이지 권한과 주소 공간으로 통제된다는 점이다.
task_struct - OS가 실행을 멈췄다가 다시 이어가는 방법CPU는 한 순간에 제한된 수의 실행 흐름만 실제로 실행한다. 나머지는 “어디까지 실행했는지”를 저장해 두고 기다린다. 이 저장 기록을 교과서에서는 PCB라고 부르고, Linux 커널에서는 실행 흐름 하나를 task_struct 중심으로 관리한다.
PCB에 해당하는 정보는 다음 질문에 답해야 한다.
| 질문 | 저장되는 정보 |
|---|---|
| 누구인가 | PID, 부모 PID, 사용자/권한 정보 |
| 지금 무엇을 기다리는가 | Running, Ready, Sleeping, Zombie 같은 상태 |
| 어디서 다시 실행할 것인가 | 프로그램 카운터, 스택 포인터, CPU 레지스터 |
| 어떤 메모리를 볼 수 있는가 | 페이지 테이블, 메모리 맵 |
| 어떤 외부 자원을 잡고 있는가 | 열린 파일, 소켓, 시그널 핸들러, cgroup 정보 |
프로세스 상태 전이는 다음처럼 읽으면 된다.
flowchart LR New["New: 생성"] --> Ready["Ready: CPU 대기"] Ready --> Running["Running: CPU 실행"] Running --> Terminated["Terminated: 종료"] Running -->|"I/O 또는 이벤트 대기"| Waiting["Waiting: CPU 미사용"] Waiting -->|"I/O 완료"| Ready
Node.js에서 await fs.promises.readFile()을 호출하면 해당 async 함수의 continuation은 I/O 완료까지 멈추고, 파일 I/O는 커널/libuv 쪽으로 넘어간다. 이벤트 루프 전체가 멈추는 것이 아니라 다른 콜백과 요청을 실행할 수 있다. 이 문서에서는 “기다리는 동안 CPU를 양보한다”는 상태 감각만 잡으면 충분하고, 커널로 요청이 넘어가는 세부 경로는 시스템 콜 문서에서 다룬다.
fork(), exec(), clone()과 Copy-on-WriteLinux에서 새 실행 단위를 만드는 대표 흐름은 세 가지로 이해하면 된다.
| 이름 | 학습용 의미 | 결과 |
|---|---|---|
fork() | 현재 프로세스와 거의 같은 자식 프로세스를 만든다 | 부모와 자식은 다른 PID와 독립 주소 공간을 가진다 |
exec() | 현재 프로세스의 주소 공간을 새 프로그램으로 갈아끼운다 | PID는 유지될 수 있지만 실행할 코드와 메모리 내용은 바뀐다 |
clone() | 무엇을 공유할지 플래그로 고르는 Linux의 더 일반적인 생성 입구 | 플래그에 따라 프로세스처럼도, 스레드처럼도 동작한다 |
fork()가 매번 부모 메모리를 전부 복사하면 큰 서버 프로세스에서 새 worker를 만들 때 비용이 너무 크다. 그래서 Linux는 Copy-on-Write(CoW) 를 쓴다. 부모와 자식이 처음에는 같은 물리 페이지를 읽기 전용으로 가리키고, 어느 한쪽이 쓰기를 시도할 때 그 페이지만 복사한다.
작은 흐름으로 보면 이렇다.
1. 부모 프로세스 Heap에 100MB 데이터가 있다.2. fork()를 호출한다.3. 자식 프로세스가 생기지만 100MB를 즉시 복사하지 않는다.4. 부모와 자식은 같은 물리 페이지를 읽기 전용으로 공유한다.5. 자식이 그중 4KB 페이지 하나에 쓰기를 시도한다.6. 커널이 그 4KB 페이지만 복사하고, 자식은 복사본에 쓴다.이 메커니즘 덕분에 fork() 직후 exec()로 완전히 다른 프로그램을 실행하는 패턴이 빠르다. 곧 버릴 주소 공간을 통째로 복사하지 않기 때문이다.
Linux 내부 관점에서는 프로세스와 스레드가 모두 clone() 계열로 만들어진다. 차이는 공유 플래그다.
// 학습용 의사 코드
// fork(): 주소 공간을 분리한 자식 프로세스에 가깝다.clone(SIGCHLD);
// pthread_create(), Node.js worker thread에 가까운 방향:// 주소 공간, 파일 정보, 시그널 핸들러 등을 같은 프로세스 안에서 공유한다.clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD);여기서 중요한 판단은 “커널이 프로세스와 스레드를 완전히 다른 물건으로 보는가”가 아니다. Linux는 둘 다 task로 관리하고, 주소 공간과 자원 공유 범위가 어디까지인가가 실무 차이를 만든다.
학습할 때는 PID와 TID도 구분해 둔다. 사용자에게 보이는 프로세스 ID는 thread group의 대표 ID처럼 쓰이고, 각 스레드는 커널 내부에서 별도의 task/TID를 가진다. 그래서 worker_threads는 같은 프로세스 PID를 공유하지만, 커널 스케줄러 입장에서는 각각 스케줄 가능한 실행 흐름이다.
TID(Thread ID)는 커널이 스케줄링 대상으로 보는 개별 실행 흐름의 식별자다.
PID가 “이 프로세스 묶음”을 가리키는 이름이라면,
TID는 그 안에서 실제로 CPU를 받을 수 있는 각 thread의 이름에 가깝다.
스레드는 프로세스 안에서 독립적으로 스케줄링되는 실행 흐름이다. 같은 프로세스의 스레드들은 Code, Data, Heap을 공유하고, 각자 Stack과 레지스터 상태를 가진다.
| 영역 | 스레드 간 공유 여부 | 결과 |
|---|---|---|
| Code/Text | 공유 | 같은 프로그램 코드를 실행한다 |
| Data/Heap | 공유 | 빠른 공유가 가능하지만 race condition과 메모리 오염 위험이 생긴다 |
| Stack | 독립 | 함수 호출 흐름과 지역 변수는 스레드별로 분리된다 |
| 파일 디스크립터 | 대체로 공유 | 한 스레드가 닫은 fd가 다른 스레드에 영향을 줄 수 있다 |
| 시그널 처리 설정 | 대체로 공유 | 프로세스 전체 종료 처리와 엮인다 |
스레드는 프로세스보다 가볍다. 새 주소 공간을 만들지 않고 Stack과 스케줄링 상태만 추가하면 되기 때문이다. 하지만 “가볍다”는 말은 “안전하다”와 다르다. Heap을 공유하므로 한 스레드가 native addon 버그나 잘못된 공유 메모리 접근으로 프로세스를 망가뜨리면 같은 프로세스의 다른 스레드도 함께 영향을 받는다.
Node.js worker_threads의 가장 작은 확인 예시는 PID다.
const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThread) { console.log("main pid", process.pid); const worker = new Worker(__filename); worker.on("message", (pid) => console.log("worker pid", pid));} else { parentPort.postMessage(process.pid);}예상 출력:
main pid 12345worker pid 12345같은 PID가 나온다는 것은 worker가 같은 프로세스 안의 다른 스레드라는 뜻이다.
반례: “스레드는 Heap을 공유하니 postMessage()도 항상 복사 없이 빠르다”는 말은 틀리다. Node.js Worker의 postMessage()는 기본적으로 structured clone으로 데이터를 복사한다. 100MB ArrayBuffer를 그냥 보내면 100MB 복사 비용이 생긴다. 복사를 피하려면 Transferable 객체로 소유권을 넘기거나, 정말 공유가 필요할 때 SharedArrayBuffer와 Atomics를 조심해서 써야 한다.
Transferable은 데이터를 복사하지 않고 소유권을 다른 thread로 넘기는 방식이고,
Atomics는 여러 thread가 공유 메모리를 읽고 쓸 때 순서와 동기화를 맞추는 저수준 도구다.
빠르지만 실수하면 race condition을 만들기 쉬우므로 기본 선택지는 아니다.
// 복사 대신 소유권 이전const buffer = new ArrayBuffer(100 * 1024 * 1024);worker.postMessage({ buffer }, [buffer]);// 이후 메인 스레드의 buffer는 detached 상태가 된다.컨텍스트 스위칭은 CPU가 지금 실행 중인 프로세스나 스레드의 상태를 저장하고, 다음 실행 흐름의 상태를 복원하는 작업이다. 저장/복원 자체는 일을 처리하지 않으므로 너무 자주 발생하면 처리량이 떨어진다.
프로세스와 스레드 전환은 비용 구조가 다르다.
| 항목 | 프로세스 간 전환 | 같은 프로세스의 스레드 간 전환 |
|---|---|---|
| 레지스터 저장/복원 | 필요 | 필요 |
| Stack 전환 | 필요 | 필요 |
| 주소 공간 전환 | 필요 | 불필요 |
| TLB 영향 | 크다 | 상대적으로 작다 |
| 캐시 지역성 | 깨질 가능성이 크다 | 상대적으로 유지되기 쉽다 |
| 대략적 규모감 | 3~10μs 수준 사례가 흔하다 | 0.5~3μs 수준 사례가 흔하다 |
TLB가 중요한 이유는 주소 변환 캐시가 비면 메모리 접근마다 페이지 테이블을 다시 보러 가야 하기 때문이다. CPU는 빠른데 메모리는 상대적으로 느리므로, TLB miss와 cache miss가 연쇄되면 단순히 “레지스터 몇 개 저장”보다 훨씬 큰 비용으로 보인다.
관찰은 vmstat의 cs 열로 시작할 수 있다.
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 0해석은 절대값보다 추세가 중요하다.
cs 범위(초당) | 학습용 해석 | 먼저 볼 것 |
|---|---|---|
| ~1,000 이하 | 보통 문제 아님 | 조치보다 기준선으로 기록 |
| 1,000~10,000 | 워크로드에 따라 정상일 수 있음 | I/O 집약 서버인지 확인 |
| 10,000 이상 | 과도 가능성 | worker/process 수와 pidstat -w 1 확인 |
| 100,000 이상 | context switching 자체가 병목일 수 있음 | worker 수 축소, lock/대기 패턴 점검 |
반례: CPU가 100%라고 worker를 무조건 늘리면 좋아지지 않는다. CPU 바운드 작업에서 worker 수가 논리 코어 수를 크게 넘으면 실제 계산보다 스케줄링 비용이 늘어 처리량이 줄 수 있다.
Node.js 기본 실행 모델은 하나의 이벤트 루프가 JavaScript 콜백을 실행하고, 일부 파일 I/O, DNS, crypto 같은 작업은 libuv 스레드 풀을 이용하는 구조다. 그래서 I/O 대기가 많은 서버는 굳이 worker thread를 늘리지 않아도 동시에 많은 요청을 처리할 수 있다. 반대로 CPU를 오래 붙잡는 작업은 이벤트 루프를 막으므로 별도 실행 단위가 필요하다.
HTTP 호출, DB 쿼리, 파일 읽기처럼 대부분 시간이 대기인 I/O 작업을 이벤트 루프에 맡긴다.
CPU 계산이 짧고, 요청 중 대기 시간이 긴 일반 API 서버의 기본값.이미지 리사이징, 압축, 암호화, 대용량 파싱처럼 CPU를 오래 잡는 작업을 같은 프로세스 안의 다른 스레드로 보낸다.
작업 코드가 신뢰 가능하고, 프로세스 격리보다 데이터 전달 비용 절감과 이벤트 루프 보호가 중요할 때.HTTP 서버 프로세스를 여러 개 fork해 한 호스트의 멀티코어를 사용한다. worker별 주소 공간은 분리된다.
stateless API 서버를 단일 호스트에서 여러 프로세스로 띄우고, crash 격리와 재시작이 필요할 때.신뢰하기 어려운 코드나 실패 가능성이 큰 작업을 별도 프로세스로 떼어낸다.
공유 메모리 속도보다 crash/OOM/보안 격리가 더 중요할 때.추가로 컨테이너 환경에서는 ECS service desired count, Kubernetes HPA처럼 프로세스 바깥의 배치 단위를 늘릴 수 있다. 이 문서에서는 “더 강한 격리와 수평 확장”의 예로만 다루고, cgroups나 namespace의 세부 메커니즘은 별도 문서에서 다룬다. cgroup은 CPU와 메모리 같은 자원 사용량을 묶음 단위로 제한하는 Linux 기능이고, namespace는 프로세스가 보는 PID, 네트워크, 파일시스템 같은 세계를 분리해 보이게 하는 기능이다.
공유 메모리는 빠르지만, 항상 좋은 선택은 아니다. 다음 조건에서는 thread보다 process나 task 격리를 우선한다.
여기서 IPC(Inter-Process Communication)는 프로세스 간 통신을 뜻한다. 프로세스는 Heap을 공유하지 않으므로 pipe, socket, message channel, shared memory 같은 별도 경로로 데이터를 주고받는다.
pid, 로그, 메모리 한도, 재시작 정책을 실행 단위별로 보고 싶다.반대로 worker thread가 적합한 경우도 있다.
SharedArrayBuffer나 Transferable로 데이터 전달 비용을 통제할 수 있다.결정 규칙: 먼저 async I/O로 충분한지 확인한다. CPU가 병목이면 worker thread를 검토한다. crash/OOM/보안 격리가 더 중요하면 process나 container task로 분리한다. HTTP 서버 처리량을 한 호스트 안에서 늘리는 문제라면 cluster/PM2 cluster를 고려하고, 여러 호스트로 넓히는 문제라면 ECS/K8s의 service/pod 단위로 본다.
Node.js cluster는 primary 프로세스가 worker 프로세스를 fork하고, HTTP 연결을 worker들에게 분배한다. 같은 포트를 나눠 쓰더라도 worker는 서로 다른 PID와 주소 공간을 가진다.
const cluster = require("cluster");const http = require("http");const numCPUs = require("os").cpus().length;
if (cluster.isPrimary) { console.log("primary pid", process.pid);
for (let i = 0; i < numCPUs; i++) { cluster.fork(); }
cluster.on("exit", (worker) => { console.log("worker exit", worker.process.pid); cluster.fork(); });} else { http .createServer((req, res) => { res.end(`worker pid ${process.pid}`); }) .listen(3000);}예상 출력:
primary pid 10000worker pid 10001worker pid 10002worker pid 10003worker pid 10004여기서 가장 중요한 반례는 인메모리 세션이다. worker 10001의 Map에 저장한 로그인 세션은 worker 10002에서 보이지 않는다. 로드밸런싱 결과 다음 요청이 다른 worker로 가면 인증이 풀린 것처럼 보인다. cluster를 쓰는 HTTP 서버는 세션, rate limit counter, 캐시를 Redis 같은 외부 저장소로 빼는 것이 기본이다.
학습용으로 줄이면 코드는 이 정도의 경계만 보면 된다.
// worker Heap에 저장하지 말고, 프로세스 밖 저장소에 둔다.await redis.set(`session:${sessionId}`, JSON.stringify(user));const user = JSON.parse(await redis.get(`session:${sessionId}`));퀴즈
힌트: 포트 공유와 Heap 공유는 다른 문제다.
cluster worker는 같은 포트를 나눠 쓰더라도 서로 다른 PID와 주소 공간을 가진 별도 프로세스다. 그래서 Map이나 세션 메모리는 공유되지 않으며 Redis 같은 외부 저장소가 필요하다.
CPU 바운드 작업은 worker thread로 옮길 가치가 있다.
const { Worker, isMainThread, parentPort, workerData,} = require("worker_threads");
if (isMainThread) { function runCpuJob(input) { return new Promise((resolve, reject) => { const worker = new Worker(__filename, { workerData: input }); worker.on("message", resolve); worker.on("error", reject); worker.on("exit", (code) => { if (code !== 0) reject(new Error(`worker exit ${code}`)); }); }); }
runCpuJob(1_000_000).then(console.log);} else { let sum = 0; for (let i = 0; i < workerData; i++) sum += i; parentPort.postMessage(sum);}worker 수는 보통 논리 코어 수보다 많이 잡지 않는다. 메인 이벤트 루프도 CPU를 써야 하고, Node.js Worker의 기본 스택 크기는 stackSizeMb 기준 4MB다. worker 100개를 만들면 스택만으로도 약 400MB의 예약 비용이 생기고, V8 isolate와 메시지 큐 비용도 추가된다. 이 값은 예약 가능한 스택 공간의 규모감이고, 실제 RSS 증가는 런타임과 사용한 스택 깊이에 따라 달라질 수 있다.
const os = require("os");
const cpuCount = os.cpus().length;const WORKER_COUNT = Math.max(1, cpuCount - 1);I/O 바운드 작업은 다른 기준이다. DB 대기, HTTP 호출 대기, 파일 읽기 대기는 이벤트 루프와 libuv가 이미 잘 처리한다. 이 작업을 무조건 worker thread로 보내면 데이터 직렬화, 메시지 전달, 스케줄링 비용만 추가될 수 있다.
ECS task나 Kubernetes pod는 프로세스보다 바깥의 배포 단위지만, 학습 관점에서는 “더 강한 운영 격리를 가진 실행 단위”로 볼 수 있다. task 수를 늘리면 인메모리 상태가 자동 공유되지 않는다. 각 task는 자기 컨테이너/프로세스 묶음, 파일 시스템, 메모리 한도를 가진다.
다만 task나 pod는 프로세스 그 자체가 아니다. 하나의 task/pod 안에도 PID 1 메인 프로세스와 helper/child process가 함께 있을 수 있고, 그 묶음 전체에 배포, 네트워크, 자원 제한이 적용된다.
주의할 점은 격리가 무한하지 않다는 것이다. EC2 launch type에서 여러 task가 같은 인스턴스에 올라가면 물리 CPU와 메모리를 경쟁한다. 한 task가 OOMKilled 되어도 다른 task의 주소 공간을 직접 망가뜨리지는 않지만, CPU 지연은 먼저 정책에 의한 제한과 실제 스케줄러 경합으로 나눠야 한다.
cgroup quota throttling은 정해진 기간의 CPU 시간 예산을 소진한 cgroup을 다음 기간까지 멈추는 정책이다. 같은 호스트의 다른 task가 CPU를 거의 쓰지 않아 물리 코어가 놀고 있어도, 해당 cgroup이 자기 quota를 다 쓰면 throttling될 수 있다. 반면 run queue 경합은 같은 Linux 게스트에서 runnable 프로세스·스레드 수가 사용 가능한 코어보다 많아 실제로 CPU 차례를 기다리는 현상이다. quota가 없는 shares/weight는 이런 경합이 있을 때 CPU 배분 비율에 영향을 주지만, 단독으로 hard throttle을 만들지는 않는다.
이 두 현상을 CPU steal time과도 혼동하면 안 된다. steal time(vmstat의 st)은 실행 가능한 EC2 게스트의 vCPU가 하이퍼바이저에서 다른 게스트나 호스트 작업에 물리 CPU를 내주어 실행하지 못한 시간이다. 같은 게스트 안에서 한 ECS task가 다른 task보다 CPU를 더 쓴 사실 자체는 steal이 아니다. 따라서 quota throttling은 cgroup 정책을, run queue 증가는 같은 게스트의 CPU 경합을, st 상승은 별도의 가상화 계층 경쟁을 가리키는 서로 다른 신호로 해석한다. 메모리는 task memory hard limit, Node.js --max-old-space-size, service desired count를 함께 본다.
| 자원 경계 | 의미 | 실패 신호 |
|---|---|---|
| CPU cgroup quota | 경쟁 유무와 별개로 기간별 CPU 시간 예산을 넘은 cgroup을 제한한다 | cpu.stat의 nr_throttled, 지연 상승 |
| CPU run queue | runnable 작업이 사용 가능한 코어보다 많아 실제 CPU 순서를 다툰다 | runnable 대기 증가, tail latency 상승 |
| hypervisor steal | 게스트 vCPU가 물리 CPU를 배정받지 못한 별도 가상화 계층 대기 | vmstat의 st 상승 |
| memory hard limit | 초과하면 컨테이너 kill 기준이 되는 상한에 가깝다 | OOMKilled, task stopped reason |
| memory reservation | 스케줄러가 배치할 때 잡아두는 보장 용량에 가깝다 | 같은 호스트에 과밀 배치되어 지연 증가 |
트러블슈팅 명령어를 많이 외우는 것보다 “이 증상이 어떤 실행 단위 문제를 가리키는가”를 먼저 잡는 편이 좋다.
여기서 SIGTERM은 정리하고 종료하라는 요청이고, SIGKILL은 정리 기회 없이 제거하는 강제 종료다. graceful shutdown은 SIGTERM 이후 새 요청을 막고 in-flight request를 마친 뒤 연결을 닫는 흐름이다.
| 증상 | 의미 | 먼저 의심할 개념 |
|---|---|---|
heapUsed가 트래픽이 없어도 단조 증가 | 프로세스 Heap에 객체가 남아 GC되지 않는다 | 프로세스 단위 메모리 누수 |
| cluster에서 로그인 상태가 요청마다 흔들림 | worker별 Heap이 분리되어 있다 | 프로세스 주소 공간 격리 |
CPU sy가 높고 vmstat cs가 급증 | 실제 연산보다 스케줄링 비용이 커질 수 있다 | context switching 과다 |
| worker thread에 100MB buffer 전달이 느림 | structured clone 복사 비용이 크다 | thread 공유와 메시지 전달의 차이 |
| 특정 task만 OOMKilled | 격리 단위가 해당 task/process로 제한됐다 | process/container memory limit |
<defunct> 프로세스가 쌓임 | 부모가 자식 종료 상태를 수거하지 않았다 | zombie와 wait() |
| SIGTERM 후 요청이 중간에 끊김 | 종료 신호를 받고 in-flight 요청을 정리하지 못했다 | signal 처리와 graceful shutdown |
Node.js에서 누수는 보통 Heap에 객체가 계속 남는 형태로 보인다.
setInterval(() => { const { heapUsed, heapTotal, rss } = process.memoryUsage(); console.log({ heapUsedMB: (heapUsed / 1024 / 1024).toFixed(1), heapTotalMB: (heapTotal / 1024 / 1024).toFixed(1), rssMB: (rss / 1024 / 1024).toFixed(1), });}, 30_000);해석 기준:
| 패턴 | 판단 | 다음 행동 |
|---|---|---|
| 요청 처리 후 증가했다가 GC 후 감소 | 정상일 수 있음 | 기준선 기록 |
| 트래픽이 없는데 1분 이상 단조 증가 | 누수 의심 | heap snapshot 비교 |
heapUsed / heapTotal이 85% 이상으로 오래 유지 | GC 압박 | 누수 조사 또는 heap limit 조정 |
heapUsed는 낮은데 rss만 높음 | native buffer, stack, shared library 가능성 | process.memoryUsage() 항목 분리 확인 |
cluster worker 하나에서 누수가 나면 그 worker를 재시작해도 다른 worker 주소 공간은 유지된다. worker thread에서 native crash가 나면 같은 프로세스의 전체 서버가 죽을 수 있다. 이것이 격리와 공유 메모리 속도의 trade-off다.
Zombie와 signal의 첫 정의와 기본 명령은 linux-basics.mdx가 맡는다. 여기서는 실행 단위 관점만 연결한다.
자식 프로세스가 종료되었는데 부모가 wait() 계열로 종료 상태를 수거하지 않으면 커널은 최소한의 종료 정보만 남긴다. 이것이 zombie다. zombie는 이미 실행 중인 프로세스가 아니므로 kill -9 <zombie_pid>로 해결되지 않는다. 부모가 수거하거나, 부모가 죽어 PID 1에게 입양된 뒤 정리되어야 한다.
Node.js에서 child_process.spawn()을 쓸 때는 exit와 error를 모두 다룬다.
const { spawn } = require("child_process");
const child = spawn("python3", ["script.py"]);
child.on("exit", (code) => { console.log("child exit", code);});
child.on("error", (err) => { console.error("spawn failed", err.message);});SIGTERM/SIGKILL도 이 문서에서는 “종료가 어느 단위에 전달되는가”로 보면 된다. container stop, PM2 stop, Kubernetes pod 종료는 결국 프로세스에 종료 요청을 보내고, 정해진 시간 안에 끝나지 않으면 강제 종료로 넘어간다. graceful shutdown 구현 세부는 linux-basics.mdx의 signal 절과 배포 런타임 문서로 넘긴다.
| 항목 | 프로세스 | 스레드 | 코루틴/async |
|---|---|---|---|
| 생성/스케줄링 주체 | OS | OS | 언어 런타임/이벤트 루프 |
| 메모리 공간 | 독립 | 프로세스 안에서 공유 | 같은 스레드 안에서 공유 |
| CPU 전환 방식 | 선점형 | 선점형 | await 지점에서 협력적으로 양보 |
| 멀티코어 병렬 실행 | 가능 | 가능 | 자체로는 보통 불가 |
| 장애 격리 | 강함 | 약함 | 약함 |
| Node.js 예시 | cluster, child_process | worker_threads | async/await, Promise |
Node.js async/await는 OS 스레드를 새로 만드는 것이 아니다. await 지점에서 이벤트 루프에 제어권을 돌려주고, 나중에 완료된 콜백을 다시 실행한다. CPU를 오래 쓰는 루프 안에 await가 없으면 이벤트 루프는 막힌다.
Green thread, goroutine, virtual thread는 모두 “OS 스레드보다 많은 논리 실행 흐름을 런타임이 관리한다”는 방향의 개념이다. 다만 구현과 병렬성은 다르다.
| 종류 | 스케줄링 주체 | 예시 | 이 문서에서 기억할 점 |
|---|---|---|---|
| OS thread | OS 커널 | pthread, Node.js Worker | 멀티코어 병렬 실행 가능, 비용이 상대적으로 큼 |
| Goroutine | Go 런타임 | go func() | 런타임이 많은 goroutine을 적은 OS thread 위에 매핑 |
| Virtual Thread | JVM | Java 21 Project Loom | Java thread API로 많은 blocking-style 작업을 표현 |
| Coroutine | 이벤트 루프/런타임 | Node.js async/await | I/O 대기에 강하지만 CPU 병렬화는 별도 worker 필요 |
이 절의 목표는 다른 언어를 깊게 배우는 것이 아니다. “실행 흐름이 많다”와 “OS 스레드가 많다”는 말이 항상 같지 않다는 점을 구분하면 된다.
본문 결론을 이해하는 데 필수는 아니지만, 손으로 확인하고 싶을 때만 실행한다.
ps aux | grep nodecat /proc/<PID>/status | grep -E "VmRSS|VmData|VmStk|Threads"Threads가 1보다 크면 같은 PID 안에 여러 스레드가 있다는 뜻이다. VmRSS는 OS가 보는 상주 메모리이고, Node.js heapUsed와 일치하지 않는다.
node worker-pid.jsnode cluster-example.jsworker thread는 메인과 같은 PID를 출력하고, cluster worker는 서로 다른 PID를 출력한다. 이 차이가 Heap 공유 여부와 장애 격리 범위로 이어진다.
vmstat 1 5pidstat -w 1vmstat의 cs는 시스템 전체 context switch 수이고, pidstat -w는 프로세스별 voluntary/non-voluntary switch를 보는 데 쓴다. 숫자는 서버별 기준선이 다르므로 트래픽 변화 없이 갑자기 올라가는지를 본다.
// 각 worker의 Map은 서로 공유되지 않는다.const memoryCache = new Map();
memoryCache.set("session:1", "worker-local-value");이 값은 같은 worker로 다시 들어온 요청에서만 보인다. cluster, ECS task, pod 사이에서 세션을 공유하려면 Redis 같은 외부 저장소가 필요하다.
task_struct: Linux가 task를 관리하는 커널 구조체fork(): 독립 주소 공간을 가진 자식 프로세스를 만드는 전통적 호출clone(): 공유 범위를 플래그로 정하는 Linux의 생성 메커니즘프로세스는 독립 주소 공간으로 장애를 가두고, 스레드는 같은 주소 공간을 공유해 빠르게 협력한다. Node.js에서 cluster, worker thread, child process, ECS task를 고르는 일은 결국 “공유 속도”와 “장애 격리” 중 무엇이 더 중요한지 결정하는 일이다.