콘텐츠로 이동

Process & Thread

프로세스와 스레드 (Process & Thread)

섹션 제목: “프로세스와 스레드 (Process & Thread)”

프로세스는 독립된 주소 공간을 가진 실행 중인 프로그램이고, 스레드는 그 프로세스 안에서 CPU가 실제로 실행하는 흐름이다.

linux-basics.mdx에서 PID, ps, 시그널, 좀비 프로세스, 권한의 표면을 봤다면 이 문서는 한 단계 안쪽을 본다. 핵심 질문은 “왜 같은 서버의 작업들이 서로의 메모리를 마음대로 덮어쓰지 못하는가”, “왜 worker thread와 cluster의 장애 범위가 다른가”, “언제 공유 메모리 속도보다 격리를 우선해야 하는가”다.


서버를 운영하면 다음 질문이 바로 나온다.

  • PM2 cluster mode로 프로세스를 몇 개 띄워야 하는가?
  • Node.js가 싱글 스레드라는데 worker_threads를 쓰면 무엇이 달라지는가?
  • ECS task를 2개로 늘렸는데 왜 인메모리 세션이나 캐시가 공유되지 않는가?
  • 한 worker가 메모리 누수로 죽었을 때 다른 요청까지 함께 죽는가?
  • CPU 사용률은 높은데 처리량이 낮을 때 worker를 더 늘려야 하는가, 줄여야 하는가?

이 질문은 모두 같은 축으로 정리된다.

물어볼 질문잘못 이해했을 때 생기는 문제
주소 공간이 실행 단위가 메모리를 독립적으로 가지는가cluster worker 사이에 Map 캐시가 공유된다고 착각
공유 범위Code, Heap, 파일 디스크립터, 시그널 핸들러 중 무엇을 공유하는가worker thread끼리 안전하게 아무 객체나 공유한다고 착각
스케줄링 비용CPU가 실행 흐름을 바꿀 때 얼마를 잃는가코어 수보다 많은 worker를 만들어 context switching만 증가
장애 격리하나가 죽거나 메모리를 오염시키면 어디까지 번지는가신뢰할 수 없는 작업을 thread에 넣어 전체 프로세스 크래시

이 문서의 범위는 실행 단위와 격리다. 시스템 콜이 커널/유저 공간 경계를 어떻게 넘는지는 system-call-interrupt.mdx에서 더 깊게 다룬다. 여기서는 fork(), clone() 같은 이름을 “실행 단위를 만드는 입구” 정도로만 사용한다.


2.5. 선행 기술의 한계 - 왜 프로세스 추상화가 필요했나

섹션 제목: “2.5. 선행 기술의 한계 - 왜 프로세스 추상화가 필요했나”

가상 메모리와 하드웨어 보호가 충분히 자리 잡기 전에는 여러 실행 흐름이 같은 물리 메모리를 서로 망가뜨릴 위험이 컸다. 한 프로그램의 포인터 버그가 다른 사용자의 데이터나 커널 영역을 덮어쓰면 시스템 전체가 멈출 수 있었다. 문제는 “프로그램을 여러 개 실행한다”가 아니라 “여러 실행 흐름이 같은 메모리를 서로 망가뜨리지 못하게 막는다”였다.

현대 OS는 이 문제를 프로세스별 가상 주소 공간으로 푼다.

  • 가상 주소 공간: 프로세스가 보는 자기만의 메모리 지도다. 두 프로세스가 같은 주소 0x1000을 쓰더라도 실제 물리 메모리 위치는 다를 수 있다.
  • MMU(Memory Management Unit): CPU 안에서 가상 주소를 물리 주소로 바꾸고 접근 권한을 검사하는 하드웨어다. 현재 주소 공간에 매핑되지 않았거나 권한 없는 페이지를 읽고 쓰려 하면 접근 위반으로 막는다.
  • PCB(Process Control Block): 운영체제 교과서에서 말하는 “프로세스 관리 기록”이다. PID, 상태, 레지스터, 메모리 지도, 열린 파일 같은 실행 재개에 필요한 정보가 들어간다.
  • task_struct: Linux 커널이 실제로 각 실행 흐름을 관리할 때 쓰는 대표 구조체다. Linux에서는 프로세스와 스레드가 모두 task로 표현되고, 어떤 자원을 공유하느냐로 차이가 난다.
  • TLB(Translation Lookaside Buffer): 가상 주소 -> 물리 주소 변환 결과를 잠깐 저장하는 CPU 캐시다. 주소 공간이 바뀌면 이 캐시를 비우거나 구분해야 하므로 프로세스 전환 비용이 커진다.

해결 메커니즘은 단순하다. 프로세스마다 주소 공간을 분리하고, 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() 플래그에 따라 공유될 수 있다

작은 확인 예시는 다음과 같다.

process-info.js
console.log("pid", process.pid);
console.log("ppid", process.ppid);
console.log(process.memoryUsage());

예상 출력:

pid 12345
ppid 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가 논리적으로 분리되고, 쓰기 시도는 페이지 권한과 주소 공간으로 통제된다는 점이다.

3-2. PCB와 task_struct - OS가 실행을 멈췄다가 다시 이어가는 방법

섹션 제목: “3-2. PCB와 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를 양보한다”는 상태 감각만 잡으면 충분하고, 커널로 요청이 넘어가는 세부 경로는 시스템 콜 문서에서 다룬다.

3-3. fork(), exec(), clone()과 Copy-on-Write

섹션 제목: “3-3. fork(), exec(), clone()과 Copy-on-Write”

Linux에서 새 실행 단위를 만드는 대표 흐름은 세 가지로 이해하면 된다.

이름학습용 의미결과
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의 이름에 가깝다.

3-4. 스레드 - 같은 주소 공간 안의 여러 실행 흐름

섹션 제목: “3-4. 스레드 - 같은 주소 공간 안의 여러 실행 흐름”

스레드는 프로세스 안에서 독립적으로 스케줄링되는 실행 흐름이다. 같은 프로세스의 스레드들은 Code, Data, Heap을 공유하고, 각자 Stack과 레지스터 상태를 가진다.

영역스레드 간 공유 여부결과
Code/Text공유같은 프로그램 코드를 실행한다
Data/Heap공유빠른 공유가 가능하지만 race condition과 메모리 오염 위험이 생긴다
Stack독립함수 호출 흐름과 지역 변수는 스레드별로 분리된다
파일 디스크립터대체로 공유한 스레드가 닫은 fd가 다른 스레드에 영향을 줄 수 있다
시그널 처리 설정대체로 공유프로세스 전체 종료 처리와 엮인다

스레드는 프로세스보다 가볍다. 새 주소 공간을 만들지 않고 Stack과 스케줄링 상태만 추가하면 되기 때문이다. 하지만 “가볍다”는 말은 “안전하다”와 다르다. Heap을 공유하므로 한 스레드가 native addon 버그나 잘못된 공유 메모리 접근으로 프로세스를 망가뜨리면 같은 프로세스의 다른 스레드도 함께 영향을 받는다.

Node.js worker_threads의 가장 작은 확인 예시는 PID다.

worker-pid.js
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 12345
worker pid 12345

같은 PID가 나온다는 것은 worker가 같은 프로세스 안의 다른 스레드라는 뜻이다.

반례: “스레드는 Heap을 공유하니 postMessage()도 항상 복사 없이 빠르다”는 말은 틀리다. Node.js Worker의 postMessage()는 기본적으로 structured clone으로 데이터를 복사한다. 100MB ArrayBuffer를 그냥 보내면 100MB 복사 비용이 생긴다. 복사를 피하려면 Transferable 객체로 소유권을 넘기거나, 정말 공유가 필요할 때 SharedArrayBufferAtomics를 조심해서 써야 한다. Transferable은 데이터를 복사하지 않고 소유권을 다른 thread로 넘기는 방식이고, Atomics는 여러 thread가 공유 메모리를 읽고 쓸 때 순서와 동기화를 맞추는 저수준 도구다. 빠르지만 실수하면 race condition을 만들기 쉬우므로 기본 선택지는 아니다.

// 복사 대신 소유권 이전
const buffer = new ArrayBuffer(100 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]);
// 이후 메인 스레드의 buffer는 detached 상태가 된다.

3-5. 컨텍스트 스위칭 - 실행 흐름을 바꾸는 비용

섹션 제목: “3-5. 컨텍스트 스위칭 - 실행 흐름을 바꾸는 비용”

컨텍스트 스위칭은 CPU가 지금 실행 중인 프로세스나 스레드의 상태를 저장하고, 다음 실행 흐름의 상태를 복원하는 작업이다. 저장/복원 자체는 일을 처리하지 않으므로 너무 자주 발생하면 처리량이 떨어진다.

프로세스와 스레드 전환은 비용 구조가 다르다.

항목프로세스 간 전환같은 프로세스의 스레드 간 전환
레지스터 저장/복원필요필요
Stack 전환필요필요
주소 공간 전환필요불필요
TLB 영향크다상대적으로 작다
캐시 지역성깨질 가능성이 크다상대적으로 유지되기 쉽다
대략적 규모감3~10μs 수준 사례가 흔하다0.5~3μs 수준 사례가 흔하다

TLB가 중요한 이유는 주소 변환 캐시가 비면 메모리 접근마다 페이지 테이블을 다시 보러 가야 하기 때문이다. CPU는 빠른데 메모리는 상대적으로 느리므로, TLB miss와 cache miss가 연쇄되면 단순히 “레지스터 몇 개 저장”보다 훨씬 큰 비용으로 보인다.

관찰은 vmstatcs 열로 시작할 수 있다.

Terminal window
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 수가 논리 코어 수를 크게 넘으면 실제 계산보다 스케줄링 비용이 늘어 처리량이 줄 수 있다.


4. Node.js에서 어떤 실행 단위를 선택할까

섹션 제목: “4. Node.js에서 어떤 실행 단위를 선택할까”

Node.js 기본 실행 모델은 하나의 이벤트 루프가 JavaScript 콜백을 실행하고, 일부 파일 I/O, DNS, crypto 같은 작업은 libuv 스레드 풀을 이용하는 구조다. 그래서 I/O 대기가 많은 서버는 굳이 worker thread를 늘리지 않아도 동시에 많은 요청을 처리할 수 있다. 반대로 CPU를 오래 붙잡는 작업은 이벤트 루프를 막으므로 별도 실행 단위가 필요하다.

프로세스, 스레드, worker thread, cluster 선택 기준

async/await

HTTP 호출, DB 쿼리, 파일 읽기처럼 대부분 시간이 대기인 I/O 작업을 이벤트 루프에 맡긴다.

CPU 계산이 짧고, 요청 중 대기 시간이 긴 일반 API 서버의 기본값.

worker_threads

이미지 리사이징, 압축, 암호화, 대용량 파싱처럼 CPU를 오래 잡는 작업을 같은 프로세스 안의 다른 스레드로 보낸다.

작업 코드가 신뢰 가능하고, 프로세스 격리보다 데이터 전달 비용 절감과 이벤트 루프 보호가 중요할 때.

cluster/PM2 cluster

HTTP 서버 프로세스를 여러 개 fork해 한 호스트의 멀티코어를 사용한다. worker별 주소 공간은 분리된다.

stateless API 서버를 단일 호스트에서 여러 프로세스로 띄우고, crash 격리와 재시작이 필요할 때.

child_process.fork()

신뢰하기 어려운 코드나 실패 가능성이 큰 작업을 별도 프로세스로 떼어낸다.

공유 메모리 속도보다 crash/OOM/보안 격리가 더 중요할 때.

추가로 컨테이너 환경에서는 ECS service desired count, Kubernetes HPA처럼 프로세스 바깥의 배치 단위를 늘릴 수 있다. 이 문서에서는 “더 강한 격리와 수평 확장”의 예로만 다루고, cgroups나 namespace의 세부 메커니즘은 별도 문서에서 다룬다. cgroup은 CPU와 메모리 같은 자원 사용량을 묶음 단위로 제한하는 Linux 기능이고, namespace는 프로세스가 보는 PID, 네트워크, 파일시스템 같은 세계를 분리해 보이게 하는 기능이다.

4-2. 격리가 공유 메모리 속도보다 중요한 경우

섹션 제목: “4-2. 격리가 공유 메모리 속도보다 중요한 경우”

공유 메모리는 빠르지만, 항상 좋은 선택은 아니다. 다음 조건에서는 thread보다 process나 task 격리를 우선한다.

여기서 IPC(Inter-Process Communication)는 프로세스 간 통신을 뜻한다. 프로세스는 Heap을 공유하지 않으므로 pipe, socket, message channel, shared memory 같은 별도 경로로 데이터를 주고받는다.

  • 작업 코드가 신뢰하기 어렵거나 native addon, 외부 바이너리, 사용자 업로드 처리처럼 crash 가능성이 높다.
  • 메모리 누수가 예상되고, 누수 범위를 worker 하나로 제한하고 싶다.
  • tenant나 job 사이에 데이터가 섞이면 안 된다.
  • 장애 시 재시작 단위가 명확해야 한다.
  • CPU 바운드 작업의 입력/출력이 작아 IPC 비용이 크지 않다.
  • 운영자가 pid, 로그, 메모리 한도, 재시작 정책을 실행 단위별로 보고 싶다.

반대로 worker thread가 적합한 경우도 있다.

  • 같은 프로세스 안에서 큰 데이터를 자주 다뤄서 복사 비용이 병목이다.
  • 작업 코드가 신뢰 가능하고, crash가 전체 프로세스에 미치는 위험을 받아들일 수 있다.
  • SharedArrayBuffer나 Transferable로 데이터 전달 비용을 통제할 수 있다.
  • 이벤트 루프를 막는 CPU 작업을 빠르게 떼어내는 것이 목표다.

결정 규칙: 먼저 async I/O로 충분한지 확인한다. CPU가 병목이면 worker thread를 검토한다. crash/OOM/보안 격리가 더 중요하면 process나 container task로 분리한다. HTTP 서버 처리량을 한 호스트 안에서 늘리는 문제라면 cluster/PM2 cluster를 고려하고, 여러 호스트로 넓히는 문제라면 ECS/K8s의 service/pod 단위로 본다.

4-3. cluster는 같은 포트를 쓰지만 같은 메모리를 쓰지 않는다

섹션 제목: “4-3. cluster는 같은 포트를 쓰지만 같은 메모리를 쓰지 않는다”

Node.js cluster는 primary 프로세스가 worker 프로세스를 fork하고, HTTP 연결을 worker들에게 분배한다. 같은 포트를 나눠 쓰더라도 worker는 서로 다른 PID와 주소 공간을 가진다.

cluster-example.js
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 10000
worker pid 10001
worker pid 10002
worker pid 10003
worker 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}`));

퀴즈

PM2 cluster에서 인메모리 세션이 공유되지 않는 이유는?

힌트: 포트 공유와 Heap 공유는 다른 문제다.

정답 보기

cluster worker는 같은 포트를 나눠 쓰더라도 서로 다른 PID와 주소 공간을 가진 별도 프로세스다. 그래서 Map이나 세션 메모리는 공유되지 않으며 Redis 같은 외부 저장소가 필요하다.

4-4. worker thread는 이벤트 루프 보호용이지 I/O 병렬화 만능 도구가 아니다

섹션 제목: “4-4. worker thread는 이벤트 루프 보호용이지 I/O 병렬화 만능 도구가 아니다”

CPU 바운드 작업은 worker thread로 옮길 가치가 있다.

cpu-worker.js
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로 보내면 데이터 직렬화, 메시지 전달, 스케줄링 비용만 추가될 수 있다.

4-5. ECS task 격리는 실패 범위를 줄이지만 호스트 경쟁을 없애지는 않는다

섹션 제목: “4-5. ECS task 격리는 실패 범위를 줄이지만 호스트 경쟁을 없애지는 않는다”

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(vmstatst)은 실행 가능한 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.statnr_throttled, 지연 상승
CPU run queuerunnable 작업이 사용 가능한 코어보다 많아 실제 CPU 순서를 다툰다runnable 대기 증가, tail latency 상승
hypervisor steal게스트 vCPU가 물리 CPU를 배정받지 못한 별도 가상화 계층 대기vmstatst 상승
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

5-1. 메모리 누수는 어느 단위를 재시작할지 묻는다

섹션 제목: “5-1. 메모리 누수는 어느 단위를 재시작할지 묻는다”

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다.

5-2. Zombie와 signal은 짧게 상기한다

섹션 제목: “5-2. Zombie와 signal은 짧게 상기한다”

Zombie와 signal의 첫 정의와 기본 명령은 linux-basics.mdx가 맡는다. 여기서는 실행 단위 관점만 연결한다.

자식 프로세스가 종료되었는데 부모가 wait() 계열로 종료 상태를 수거하지 않으면 커널은 최소한의 종료 정보만 남긴다. 이것이 zombie다. zombie는 이미 실행 중인 프로세스가 아니므로 kill -9 <zombie_pid>로 해결되지 않는다. 부모가 수거하거나, 부모가 죽어 PID 1에게 입양된 뒤 정리되어야 한다.

Node.js에서 child_process.spawn()을 쓸 때는 exiterror를 모두 다룬다.

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 절과 배포 런타임 문서로 넘긴다.


6-1. 프로세스 vs 스레드 vs 코루틴

섹션 제목: “6-1. 프로세스 vs 스레드 vs 코루틴”
항목프로세스스레드코루틴/async
생성/스케줄링 주체OSOS언어 런타임/이벤트 루프
메모리 공간독립프로세스 안에서 공유같은 스레드 안에서 공유
CPU 전환 방식선점형선점형await 지점에서 협력적으로 양보
멀티코어 병렬 실행가능가능자체로는 보통 불가
장애 격리강함약함약함
Node.js 예시cluster, child_processworker_threadsasync/await, Promise

Node.js async/await는 OS 스레드를 새로 만드는 것이 아니다. await 지점에서 이벤트 루프에 제어권을 돌려주고, 나중에 완료된 콜백을 다시 실행한다. CPU를 오래 쓰는 루프 안에 await가 없으면 이벤트 루프는 막힌다.

6-2. Green thread, goroutine, Java virtual thread

섹션 제목: “6-2. Green thread, goroutine, Java virtual thread”

Green thread, goroutine, virtual thread는 모두 “OS 스레드보다 많은 논리 실행 흐름을 런타임이 관리한다”는 방향의 개념이다. 다만 구현과 병렬성은 다르다.

종류스케줄링 주체예시이 문서에서 기억할 점
OS threadOS 커널pthread, Node.js Worker멀티코어 병렬 실행 가능, 비용이 상대적으로 큼
GoroutineGo 런타임go func()런타임이 많은 goroutine을 적은 OS thread 위에 매핑
Virtual ThreadJVMJava 21 Project LoomJava thread API로 많은 blocking-style 작업을 표현
Coroutine이벤트 루프/런타임Node.js async/awaitI/O 대기에 강하지만 CPU 병렬화는 별도 worker 필요

이 절의 목표는 다른 언어를 깊게 배우는 것이 아니다. “실행 흐름이 많다”와 “OS 스레드가 많다”는 말이 항상 같지 않다는 점을 구분하면 된다.


본문 결론을 이해하는 데 필수는 아니지만, 손으로 확인하고 싶을 때만 실행한다.

프로세스와 thread 수 확인
Terminal window
ps aux | grep node
cat /proc/<PID>/status | grep -E "VmRSS|VmData|VmStk|Threads"

Threads가 1보다 크면 같은 PID 안에 여러 스레드가 있다는 뜻이다. VmRSS는 OS가 보는 상주 메모리이고, Node.js heapUsed와 일치하지 않는다.

Worker thread와 cluster PID 비교
Terminal window
node worker-pid.js
node cluster-example.js

worker thread는 메인과 같은 PID를 출력하고, cluster worker는 서로 다른 PID를 출력한다. 이 차이가 Heap 공유 여부와 장애 격리 범위로 이어진다.

컨텍스트 스위칭 관찰
Terminal window
vmstat 1 5
pidstat -w 1

vmstatcs는 시스템 전체 context switch 수이고, pidstat -w는 프로세스별 voluntary/non-voluntary switch를 보는 데 쓴다. 숫자는 서버별 기준선이 다르므로 트래픽 변화 없이 갑자기 올라가는지를 본다.

cluster 인메모리 캐시 반례 확인
// 각 worker의 Map은 서로 공유되지 않는다.
const memoryCache = new Map();
memoryCache.set("session:1", "worker-local-value");

이 값은 같은 worker로 다시 들어온 요청에서만 보인다. cluster, ECS task, pod 사이에서 세션을 공유하려면 Redis 같은 외부 저장소가 필요하다.


Process & Thread 복습 체크

  • 프로세스가 독립 주소 공간을 가진다는 말을 메모리 격리 관점에서 설명할 수 있다.
  • PCB와 Linux task_struct를 '실행을 멈췄다가 다시 이어가기 위한 기록'으로 설명할 수 있다.
  • MMU와 TLB가 프로세스 격리와 context switching 비용에 왜 등장하는지 말할 수 있다.
  • fork(), exec(), clone(), Copy-on-Write의 관계를 학습용 수준에서 설명할 수 있다.
  • 스레드가 공유하는 영역(Code/Data/Heap)과 독립으로 가지는 영역(Stack)을 구분할 수 있다.
  • worker_threads와 cluster가 PID, 메모리 공유, 장애 격리 면에서 어떻게 다른지 설명할 수 있다.
  • CPU 바운드 작업, I/O 바운드 작업, 신뢰할 수 없는 작업에 서로 다른 실행 단위를 고를 수 있다.
  • 격리가 공유 메모리 속도보다 중요한 상황을 예로 들 수 있다.
  • `vmstat`의 `cs` 상승을 context switching 비용과 연결해 해석할 수 있다.
  • zombie, SIGTERM, OOMKilled를 실행 단위의 실패 신호로 짧게 설명할 수 있다.

  • Process: 독립 주소 공간을 가진 실행 중인 프로그램
  • Thread: 프로세스 안에서 스케줄링되는 실행 흐름
  • Address Space: 프로세스가 보는 자기만의 가상 메모리 지도
  • MMU: 가상 주소 변환과 접근 권한 검사를 수행하는 하드웨어
  • PCB: OS가 프로세스 실행 상태를 저장하는 관리 기록
  • task_struct: Linux가 task를 관리하는 커널 구조체
  • Copy-on-Write: 쓰기 전까지 물리 페이지 복사를 미루는 최적화
  • fork(): 독립 주소 공간을 가진 자식 프로세스를 만드는 전통적 호출
  • clone(): 공유 범위를 플래그로 정하는 Linux의 생성 메커니즘
  • TLB: 주소 변환 결과를 캐싱하는 CPU 캐시
  • Context Switching: CPU 실행 흐름을 바꾸기 위해 상태를 저장/복원하는 작업
  • IPC: 프로세스 간 통신. 파이프, 소켓, 메시지, 공유 메모리 등이 있다
  • cluster: Node.js에서 여러 프로세스로 HTTP 서버를 확장하는 모듈
  • worker_threads: Node.js에서 같은 프로세스 안에 Worker 스레드를 만드는 모듈
  • SharedArrayBuffer: 스레드 간 공유 메모리를 표현하는 JavaScript 객체
  • libuv: Node.js의 이벤트 루프와 비동기 I/O 기반 라이브러리


프로세스는 독립 주소 공간으로 장애를 가두고, 스레드는 같은 주소 공간을 공유해 빠르게 협력한다. Node.js에서 cluster, worker thread, child process, ECS task를 고르는 일은 결국 “공유 속도”와 “장애 격리” 중 무엇이 더 중요한지 결정하는 일이다.