프로세스와 스레드, 격리와 공유의 비용
프로세스와 스레드는 메모리 격리와 공유 방식이 다르고, 그 차이가 장애 격리, 성능 튜닝, 메모리 누수 대응으로 이어진다. Node.js의 cluster, worker_threads, PM2, ECS 선택도 결국 이 구조를 이해해야 판단할 수 있다.
Script Companion
오디오와 함께 스크립트 보기
- 01
프로세스와 스레드는 서버 운영 질문의 바닥에 놓인 개념이다. PM2 cluster mode로 몇 개의 프로세스를 띄울지, ECS 태스크를 늘렸는데 왜 CPU가 기대만큼 줄지 않는지, Node.js가 싱글 스레드라는데 Worker Thread를 쓰면 무엇이 달라지는지 같은 질문은 모두 메모리와 OS 관리 방식으로 돌아온다. 핵심은 세 가지다. 프로세스는 장애를 격리하고, 컨텍스트 스위칭 비용은 성능 튜닝의 기준이 되며, 메모리 누수는 어느 단위를 재시작해야 하는지 판단하게 만든다.
- 02
프로세스는 OS가 실행 중인 프로그램에 할당하는 독립된 실행 환경이다. 문서의 비유로는 하나의 독립된 식당에 가깝다. 각 식당은 자기 주방, 냉장고, 홀을 갖고 다른 식당의 냉장고를 마음대로 열 수 없다. 이 격리의 이유는 보안과 안정성이다. 모든 프로그램이 같은 메모리를 공유하면 하나의 버그가 다른 프로그램의 데이터를 덮어쓸 수 있고, 1960년대 초기 OS에서는 이런 문제가 시스템 전체 다운으로 이어졌다. 그래서 하드웨어 MMU와 OS가 협력해 각 프로세스에 독립된 가상 주소 공간을 제공한다.
- 03
Linux에서 새 프로세스는 fork() 시스템 콜로 만들어지지만, 부모 메모리를 즉시 통째로 복사하지 않는다. 여기서 Copy-on-Write, 줄여서 CoW가 중요하다. 부모와 자식은 같은 물리 메모리 페이지를 읽기 전용으로 공유하다가, 어느 한쪽이 쓰기를 시도할 때 해당 페이지만 복사한다. 그래서 fork()는 마이크로초 단위로 끝날 수 있고, 자식이 곧바로 exec()로 새 프로그램을 실행하면 실제 메모리 복사가 발생하지 않을 수 있다. Linux 커널 안에서는 fork()와 pthread_create() 모두 clone() 시스템 콜을 통해 구현되고, 플래그 차이로 독립과 공유 범위가 갈린다.
- 04
커널 관점에서 프로세스와 스레드는 완전히 다른 생물이 아니라 task_struct라는 같은 자료구조로 관리된다. fork()는 SIGCHLD 플래그만 설정해 독립된 메모리 공간을 만들고, pthread_create()는 CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND 같은 플래그로 메모리, 파일 시스템, 파일 디스크립터, 시그널 핸들러를 부모와 공유한다. 프로세스의 메모리는 Code, Data, Heap, Stack으로 나뉜다. Code는 실행 명령이고, Data는 전역 변수와 static 변수이며, Heap은 동적 메모리라 누수가 자주 드러나는 곳이고, Stack은 함수 호출과 지역 변수를 담는다.
- 05
OS는 각 프로세스를 PCB, 즉 Process Control Block으로 관리한다. PCB에는 PID, 프로세스 상태, 다음에 실행할 명령어 주소인 프로그램 카운터, CPU 레지스터 값, 페이지 테이블 같은 메모리 관리 정보, 열린 파일과 I/O 요청 상태가 들어간다. 프로세스 상태는 New, Ready, Running, Waiting, Terminated로 이동한다. Node.js에서 await fs.readFile()을 호출하면 해당 프로세스나 스레드는 I/O 완료를 기다리는 Waiting 상태가 되고, CPU는 다른 작업을 처리한다. 문서가 말하는 논블로킹 I/O의 핵심은 작업이 기다리는 동안 CPU를 붙잡아 두지 않는다는 점이다.
- 06
스레드는 프로세스 내부에서 실행되는 독립된 실행 흐름이다. 같은 식당 안의 여러 직원처럼 Code, Data, Heap은 함께 쓰지만, 각 직원이 자기 작업 메모장을 갖듯 Stack은 각자 가진다. 그래서 스레드는 프로세스보다 가볍다. 새 메모리 공간 전체를 만들지 않고 기본적으로 Stack 공간만 추가하면 되기 때문이다. 하지만 공유는 곧 위험이기도 하다. 한 스레드가 공유 Heap이나 Data에 잘못 접근하면 같은 프로세스 안의 다른 스레드도 영향을 받는다. 프로세스 격리가 안정성을 주는 대신 통신 비용을 만들고, 스레드 공유가 속도를 주는 대신 영향 범위를 넓히는 셈이다.
- 07
스레드 전환이 프로세스 전환보다 싼 이유는 메모리 주소 공간이 같기 때문이다. 같은 프로세스의 스레드끼리는 TLB, 즉 Translation Lookaside Buffer를 비울 필요가 없다. TLB는 가상 주소에서 물리 주소로 바꾼 결과를 캐싱하는 하드웨어인데, 프로세스가 바뀌면 가상 주소 공간이 달라져 항목이 무효화된다. 이후 메모리에 접근할 때마다 TLB 미스가 나고 페이지 테이블을 다시 조회해야 한다. 멀티코어에서는 TLB Shootdown도 비용을 키운다. 한 코어의 페이지 테이블 변경을 다른 코어 TLB에도 알려야 해서 IPI, Inter-Processor Interrupt가 발생한다.
- 08
컨텍스트 스위칭은 CPU가 실행 중인 프로세스나 스레드를 바꾸는 작업이다. 책상에서 A 과제를 치우고 B 과제를 꺼내는 시간처럼, 실제 연산은 하지 않지만 상태를 저장하고 복원하는 오버헤드가 든다. 프로세스 간 전환은 CR3 레지스터 갱신, TLB 플러시, 캐시 무효화, 메모리 맵을 포함한 PCB 저장이 필요하다. 같은 프로세스의 스레드 간 전환은 주로 스택과 레지스터 중심이라 더 가볍다. 문서의 실제 측정값은 Linux에서 프로세스 컨텍스트 스위칭이 약 3~10μs, 스레드 간 전환이 약 0.5~3μs 수준이다.
- 09
컨텍스트 스위칭은 절대 숫자보다 추세로 봐야 한다. vmstat의 cs 열은 초당 컨텍스트 스위칭 횟수를 보여준다. 문서 기준으로 초당 1,000 이하는 정상, 1,000~10,000은 주의, 10,000 이상은 과도, 100,000 이상은 심각한 상태다. 다만 I/O 집약 서버는 어느 정도 높은 값이 허용될 수 있다. 중요한 신호는 트래픽 증가 없이 cs가 계속 상승하는 경우다. 이때는 스레드나 프로세스 누수를 의심하고, pidstat -w 1 같은 방식으로 원인 프로세스를 특정해야 한다.
- 10
멀티프로세싱은 여러 독립 식당을 여는 방식이고, 멀티스레딩은 한 식당에 직원을 더 두는 방식이다. 멀티프로세싱은 메모리 격리가 강하고 하나가 죽어도 나머지가 살아남지만, IPC인 파이프, 소켓, 공유 메모리 같은 통신 비용이 든다. 멀티스레딩은 Heap과 Data를 직접 공유해 통신이 빠르지만, 하나의 크래시가 전체 프로세스에 영향을 줄 수 있다. Node.js는 기본적으로 싱글 스레드와 이벤트 루프 구조지만, cluster, worker_threads, child_process.fork(), K8s HPA 같은 선택지를 통해 병렬성과 격리를 조절한다.
- 11
Node.js에서 선택 기준은 작업 성격과 격리 요구로 나뉜다. cluster는 HTTP 포트 공유, stateless, I/O 집약 서비스에서 단일 서버의 멀티코어를 쓰기 좋지만, 프로세스별 독립 메모리 때문에 인메모리 캐시를 공유할 수 없어 Redis 같은 외부 저장소가 필요하다. worker_threads는 암호화, 이미지 처리, 대용량 파싱처럼 CPU 집약 연산을 메인 이벤트 루프 밖으로 보내는 데 맞고, SharedArrayBuffer로 데이터 공유가 필요할 때도 선택된다. child_process.fork()는 신뢰할 수 없는 코드나 완전 격리가 필요한 위험 작업에 맞고, K8s HPA는 Pod 수 자동 조절이 필요한 컨테이너 환경의 선택지다.
- 12
PM2 cluster mode는 Node.js 앱을 CPU 수만큼 프로세스로 복제해 멀티코어를 활용한다. instances: 'max'를 쓰면 PM2가 os.cpus().length만큼 프로세스를 fork하고, 각 프로세스는 독립 메모리를 가진다. 그래서 세션이나 캐시를 인메모리 Map, node-cache에만 두면 Worker마다 값이 달라질 수 있다. ECS에서는 태스크가 하나의 컨테이너 인스턴스, 즉 하나의 프로세스로 설명된다. 태스크 수를 2로 늘리면 두 독립 프로세스가 서로 다른 요청을 처리하며, PM2 cluster와 달리 ECS 태스크 사이에는 OS 레벨 격리가 보장된다.
- 13
Worker Thread는 CPU 바운드 작업에 신중하게 써야 한다. Node.js 공식 문서는 논리 코어보다 많은 Worker를 만들면 그 Worker는 진행하지 못하고 스케줄링과 메모리 비용만 발생한다고 설명한다. 문서의 실무 권장은 Worker 수를 CPU 코어 수 - 1 수준으로 보는 것이다. 각 Worker Thread의 기본 스택 크기는 4 MB이고, 실행되지 않는 Worker도 스택과 V8 힙을 점유한다. I/O 바운드 작업은 Worker Thread가 아니라 이벤트 루프로 처리하는 것이 맞다. Node.js 내장 비동기 I/O인 libuv가 이미 기본 4개의 내부 스레드 풀로 처리하기 때문이다.
- 14
트러블슈팅도 같은 원리로 읽을 수 있다. Node.js에서 process.memoryUsage().heapUsed가 요청이 없는데도 계속 증가하고, 단조 증가가 1분 이상 지속되면 누수를 의심한다. heapUsed / heapTotal이 85%를 넘는 상태가 지속되면 GC 압박으로 보고 --max-old-space-size 조정이나 누수 조사를 검토한다. Worker Thread에서 대용량 버퍼를 postMessage로 보내면 structured clone으로 복사되어 100MB 버퍼는 100MB 복사 비용을 낸다. cluster 모드에서 로그인 후 일부 요청의 인증이 풀리는 문제는 Worker별 메모리가 독립되어 세션이 공유되지 않는 것이 원인일 수 있다.
- 15
정리하면 프로세스는 독립된 메모리 공간을 가진 실행 단위이고, 스레드는 같은 프로세스 안에서 Stack만 독립적으로 갖는 실행 흐름이다. 컨텍스트 스위칭은 PCB와 레지스터, 메모리 주소 공간, TLB 같은 상태를 바꾸는 비용이며, 프로세스 전환은 스레드 전환보다 더 비싸다. Node.js cluster는 여러 프로세스를 여는 방식이고, Worker Thread는 같은 프로세스 안에 실행 흐름을 더 두는 방식이다. PM2, ECS, worker_threads, HPA 선택은 결국 격리, 공유, 통신 비용, 스케줄링 비용 중 무엇을 우선할지의 문제로 돌아온다.
같은 레이어