Process: 실행 중인 프로그램과 독립된 메모리 공간
프로세스는 운영체제가 프로그램을 실행하기 위해 할당하는 독립적인 자원 단위로, 각자 별도의 메모리 주소 공간과 커널 자료구조(PCB)를 가진다. 프로세스 간 전환(컨텍스트 스위칭)과 생성 비용은 스레드보다 크지만, 격리성 덕분에 안정성이 높다. 이 글에서는 프로세스의 상태 전이, 생성 방식(fork/exec), 컨텍스트 스위칭 비용을 코드와 함께 다룬다.
1. 개념
프로세스(Process)는 운영체제로부터 메모리, CPU 시간, 파일 디스크립터 같은 자원을 할당받아 실행 중인 프로그램의 인스턴스다.
디스크에 저장된 실행 파일 자체는 "프로그램"이고, 그 프로그램이 메모리에 적재되어 실행되는 순간부터 "프로세스"라고 부른다. 같은 프로그램을 두 번 실행하면 두 개의 서로 다른 프로세스가 생성되며, 각각 독립된 가상 메모리 주소 공간을 가진다.
커널은 각 프로세스를 관리하기 위해 PCB(Process Control Block)라는 자료구조를 유지한다. PCB에는 다음 정보가 들어있다.
- PID(Process ID), PPID(부모 프로세스 ID)
- 프로세스 상태(생성, 준비, 실행, 대기, 종료)
- 프로그램 카운터(PC), 레지스터 값
- 메모리 관리 정보(페이지 테이블 포인터)
- 열린 파일 디스크립터 목록
- CPU 스케줄링 우선순위
2. 왜 사용하는가
단일 CPU로 여러 프로그램을 동시에 실행하는 것처럼 보이게 하려면(멀티태스킹) 실행 흐름을 논리적으로 분리할 단위가 필요하다. 프로세스는 이 단위를 제공하면서 동시에 다음 목적을 달성한다.
- 격리(Isolation): 한 프로세스가 잘못된 메모리 접근을 해도 다른 프로세스의 메모리 공간을 침범하지 않는다. MMU(Memory Management Unit)가 가상 주소를 물리 주소로 변환하면서 이를 보장한다.
- 자원 회계(Accounting): CPU 사용 시간, 메모리 사용량을 프로세스 단위로 측정하고 제한할 수 있다.
- 장애 격리: 웹 서버의 워커 프로세스 하나가 크래시해도 다른 워커는 영향을 받지 않는다.
스레드(Thread)는 이 격리를 포기하고 메모리를 공유하는 대신 생성 비용과 전환 비용을 낮춘 실행 단위다. 프로세스와 스레드의 선택은 격리성과 성능 사이의 트레이드오프다.
3. 동작 원리
프로세스 상태 전이
프로세스는 생애주기 동안 다음 상태를 오간다.
New(생성) → Ready(준비) → Running(실행) → Terminated(종료)
↑ ↓
└──── Waiting(대기) ←┘- New: 프로세스가 생성되고 PCB가 초기화되는 중
- Ready: CPU만 배정받으면 바로 실행 가능한 상태, 스케줄러의 큐에서 대기
- Running: 실제로 CPU를 점유하고 명령어를 수행 중
- Waiting(Blocked): I/O 완료, 락 획득 등 이벤트를 기다리는 상태
- Terminated: 실행이 끝났지만 부모가 종료 상태를 회수(wait)하기 전까지 PCB가 남아있는 상태(좀비)
컨텍스트 스위칭
CPU가 한 프로세스에서 다른 프로세스로 전환될 때 커널은 다음을 수행한다.
- 현재 프로세스의 레지스터, 프로그램 카운터를 PCB에 저장
- 다음 프로세스의 PCB에서 레지스터 값을 복원
- MMU의 페이지 테이블을 교체(TLB 플러시 발생 가능)
- 새 프로세스를 Running 상태로 전환
이 과정은 순수 오버헤드이며, 프로세스 간 전환은 스레드 간 전환보다 비용이 크다. 스레드는 같은 주소 공간을 공유하므로 페이지 테이블 교체와 TLB 플러시가 필요 없기 때문이다.
fork와 exec
유닉스 계열에서 새 프로세스를 만드는 표준 방식은 fork()와 exec()의 조합이다.
fork(): 호출한 프로세스를 복제해 자식 프로세스를 만든다. 부모의 메모리를 그대로 복사한 것처럼 동작하지만, 실제로는 카피 온 라이트(Copy-on-Write)로 페이지를 공유하다가 쓰기가 발생할 때만 복사한다.exec(): 현재 프로세스의 메모리 이미지를 다른 실행 파일로 완전히 교체한다. PID는 유지되지만 코드, 데이터, 힙, 스택이 모두 새 프로그램 것으로 바뀐다.
셸이 명령어를 실행할 때는 보통 fork로 자식을 만든 뒤, 자식에서 exec로 원하는 프로그램을 적재하는 순서를 따른다.
4. 코드 예제
유닉스 fork()로 자식 프로세스 생성 (Python, 리눅스/macOS 전용)
import os
import time
pid = os.fork()
if pid == 0:
# 자식 프로세스 블록
print(f"자식 프로세스 실행: PID={os.getpid()}, 부모 PID={os.getppid()}")
time.sleep(1)
os._exit(0) # 자식은 _exit로 즉시 종료 (버퍼 flush 등 부모와 공유 회피)
else:
# 부모 프로세스 블록
print(f"부모 프로세스: 생성한 자식 PID={pid}")
child_pid, status = os.waitpid(pid, 0) # 좀비 방지를 위한 회수
print(f"자식 종료 확인: PID={child_pid}, 상태={status}")subprocess로 새 프로그램 실행 (fork+exec를 감싼 고수준 API)
import subprocess
result = subprocess.run(
["ls", "-l", "/tmp"],
capture_output=True,
text=True,
timeout=5,
)
print("반환 코드:", result.returncode)
print("표준 출력:", result.stdout[:200])프로세스 상태 확인 (shell)
# 현재 프로세스 목록과 상태 코드(S: sleeping, R: running, Z: zombie 등) 확인
ps -eo pid,ppid,stat,cmd | head -n 10
# 특정 프로세스의 PCB에 해당하는 정보 확인 (리눅스)
cat /proc/$$/status | grep -E "State|Pid|PPid"5. 시간 복잡도 또는 성능 특성
프로세스 자체는 알고리즘이 아니므로 시간복잡도 표기는 적절하지 않지만, 비교 가능한 비용 특성은 다음과 같다.
- 컨텍스트 스위칭 비용: 순수 레지스터 저장/복원 자체는 마이크로초 이하로 빠르지만, TLB 미스와 캐시 무효화까지 포함하면 수 마이크로초에서 수십 마이크로초까지 걸릴 수 있다. 정확한 수치는 CPU 아키텍처, 캐시 크기, 커널 스케줄러 구현에 따라 달라지므로 고정된 숫자로 단정하기 어렵다.
- 프로세스 생성 비용: fork()는 Copy-on-Write 덕분에 메모리 전체를 즉시 복사하지 않아 스레드 생성보다는 느리지만 과거보다 훨씬 가볍다. 다만 페이지 테이블 자체를 새로 구성해야 하므로 스레드 생성(pthread_create)보다는 여전히 비용이 크다.
- 스레드와의 비교: 스레드는 주소 공간을 공유하므로 컨텍스트 스위칭 시 페이지 테이블 교체가 불필요해 프로세스 전환보다 가볍다. 정확한 배수는 시스템마다 다르므로 벤치마크로 직접 측정하는 것이 정확하다.
6. 실무 사용 사례
- Nginx의 워커 프로세스 모델: 마스터 프로세스가 여러 워커 프로세스를 fork로 생성하고, 각 워커가 독립적으로 요청을 처리한다. 워커 하나가 크래시해도 마스터가 새 워커를 재생성해 서비스 전체 중단을 막는다.
- Gunicorn(Python WSGI 서버)의 pre-fork 모델: 마스터 프로세스가 애플리케이션을 로드한 뒤 fork로 여러 워커를 생성해, 각 워커가 독립된 메모리에서 요청을 처리하면서 CPU 코어를 병렬로 활용한다.
- Node.js의 cluster 모듈: Node.js는 싱글 스레드 이벤트 루프이므로 멀티코어를 활용하려면 cluster 모듈로 여러 프로세스를 fork해 포트를 공유시킨다.
- 컨테이너 격리: Docker 컨테이너는 리눅스 네임스페이스와 cgroups로 프로세스를 격리한 것으로, 컨테이너 하나는 결국 특별히 격리된 프로세스(그룹)다.
7. 주의할 점
- 좀비 프로세스(Zombie Process): 자식이 종료됐지만 부모가
wait()으로 종료 상태를 회수하지 않으면 PCB가 남아 좀비 상태로 유지된다. 좀비가 누적되면 PID 자원이 고갈될 수 있다. - 고아 프로세스(Orphan Process): 부모가 자식보다 먼저 종료되면 자식은 init(PID 1) 프로세스에게 입양되어 계속 실행된다. 의도치 않은 백그라운드 프로세스가 남는 원인이 될 수 있다.
- fork bomb:
fork()를 무한 반복 호출하면 프로세스 테이블이 가득 차 시스템 전체가 응답 불능 상태에 빠질 수 있다. 운영 환경에서는ulimit -u나 cgroups로 프로세스 수를 제한해야 한다. - fork 후 멀티스레드 프로그램의 위험성: 멀티스레드 프로세스에서 fork를 호출하면 자식 프로세스에는 fork를 호출한 스레드만 복제되고 다른 스레드는 사라진다. 뮤텍스를 다른 스레드가 잠근 상태로 fork되면 자식에서 데드락이 발생할 수 있다.
- os._exit vs sys.exit: Python에서 fork된 자식 프로세스는 반드시
os._exit()으로 종료해야 한다. 일반exit()나sys.exit()은 부모와 공유된 표준 출력 버퍼를 이중으로 flush하는 등의 문제를 일으킬 수 있다.
8. 핵심 정리
프로세스는 독립된 메모리 공간과 커널 자원을 가진 실행 단위이며, PCB를 통해 상태와 컨텍스트가 관리된다. New-Ready-Running-Waiting-Terminated로 이어지는 상태 전이와 컨텍스트 스위칭이 멀티태스킹의 근간을 이루고, fork/exec 조합이 유닉스 계열의 표준 프로세스 생성 방식이다. 격리성이 필요하면 프로세스, 빠른 통신과 낮은 전환 비용이 필요하면 스레드를 선택하는 것이 기본 원칙이며, 좀비/고아 프로세스와 fork bomb 같은 운영 리스크를 항상 염두에 두어야 한다.