Process 관리: 실행 중인 프로그램의 상태를 확인하고 제어하는 방법
프로세스(Process)는 실행 중인 프로그램의 인스턴스이며, 운영체제는 PID와 상태 정보로 이를 관리한다. 이 글에서는 프로세스의 생성과 종료 과정, 상태 전이, 시그널(Signal)을 설명하고, `ps`, `kill`, `jobs` 같은 리눅스 명령과 Python 코드로 직접 확인해 본다. 웹 서버 운영 중 마주치는 좀비 프로세스, 포트 점유, gra
By 송민성
1. 개념
프로세스(Process)는 디스크에 저장된 프로그램이 메모리에 적재되어 실행 중인 상태의 인스턴스다. 같은 프로그램을 두 번 실행하면 서로 다른 프로세스 두 개가 만들어진다.
리눅스 커널은 각 프로세스에 다음 정보를 할당하고 관리한다.
- PID(Process ID): 프로세스를 구분하는 고유 번호
- PPID(Parent PID): 자신을 생성한 부모 프로세스의 PID
- 독립된 가상 주소 공간: 코드, 데이터, 힙(heap), 스택(stack)
- 파일 디스크립터(file descriptor) 테이블: 열린 파일과 소켓 목록
- 상태(state), 우선순위, 소유 사용자(UID)
프로세스와 구분해야 할 개념이 스레드(Thread)다. 스레드는 프로세스 안에서 주소 공간을 공유하며 실행되는 단위이고, 프로세스끼리는 기본적으로 메모리를 공유하지 않는다.
2. 왜 사용하는가
운영체제는 CPU 하나에서 여러 프로그램을 번갈아 실행한다. 이때 프로그램마다 독립된 실행 단위가 있어야 다음이 가능하다.
- 격리(isolation): 한 프로세스가 비정상 종료되어도 다른 프로세스의 메모리는 손상되지 않는다.
- 자원 관리: CPU 시간, 메모리, 파일 핸들을 프로세스 단위로 할당하고 회수한다.
- 제어: 문제가 생긴 프로그램만 골라 중지하거나 종료할 수 있다.
개발자 입장에서 프로세스 관리는 서버가 멈췄을 때 원인을 찾고, 배포 시 안전하게 재시작하고, 리소스 누수를 추적하는 기본 도구다.
3. 동작 원리
3.1 프로세스 생성: fork와 exec
리눅스에서 새 프로세스는 보통 두 단계로 만들어진다.
fork(): 호출한 프로세스(부모)를 복제해 자식 프로세스를 만든다. 자식은 새로운 PID를 받는다.exec()계열: 자식 프로세스의 메모리 내용을 새 프로그램으로 교체한다.
셸에서 ls를 입력하면, 셸이 fork()로 자식을 만들고 자식이 exec()로 ls를 실행한다. 부모(셸)는 wait()로 자식의 종료를 기다린다.
3.2 프로세스 상태
ps의 STAT 열에서 확인할 수 있는 대표 상태는 다음과 같다.
| 코드 | 상태 | 의미 |
|---|---|---|
| R | Running/Runnable | 실행 중이거나 실행 대기 중 |
| S | Interruptible Sleep | 이벤트(I/O, 시그널 등)를 기다리는 중 |
| D | Uninterruptible Sleep | 보통 디스크 I/O 등을 기다리며 시그널로 깨울 수 없음 |
| T | Stopped | 중지됨 (예: SIGSTOP, Ctrl+Z) |
| Z | Zombie | 종료됐지만 부모가 종료 상태를 아직 회수하지 않음 |
3.3 좀비와 고아 프로세스
- 좀비 프로세스(zombie process): 자식이 종료됐지만 부모가
wait()로 종료 상태를 읽지 않아 프로세스 테이블 항목이 남아 있는 상태. 메모리는 대부분 해제되었고 PID와 종료 상태 정보만 남는다. - 고아 프로세스(orphan process): 부모가 먼저 종료된 자식. 일반적으로 init(PID 1, 보통 systemd)이나 subreaper 프로세스가 새 부모가 되어 종료 상태를 회수한다.
3.4 시그널(Signal)
시그널은 프로세스에 보내는 비동기 알림이다. 자주 쓰는 시그널은 다음과 같다.
| 시그널 | 번호(리눅스 x86) | 의미 | 처리 가능 여부 |
|---|---|---|---|
| SIGHUP | 1 | 터미널 연결 끊김, 설정 재로드 관례 | 가능 |
| SIGINT | 2 | Ctrl+C 인터럽트 | 가능 |
| SIGKILL | 9 | 강제 종료 | 처리·무시 불가 |
| SIGTERM | 15 | 정상 종료 요청 (kill 기본값) | 가능 |
| SIGSTOP | 19 | 실행 중지 | 처리·무시 불가 |
SIGTERM은 프로세스가 정리 작업(연결 종료, 파일 flush)을 한 뒤 종료할 기회를 준다. SIGKILL은 커널이 즉시 프로세스를 제거하므로 정리 작업이 불가능하다.
4. 코드 예제
4.1 셸에서 프로세스 확인과 제어
# 현재 셸의 PID와 부모 PID
echo "shell PID=$$ PPID=$PPID"
# 백그라운드로 sleep 실행 후 PID 확인
sleep 300 &
echo "background PID=$!"
# 특정 프로세스 상세 정보 (PID, 부모, 상태, 명령어)
ps -o pid,ppid,stat,etime,cmd -p $!
# 전체 프로세스를 트리 형태로 확인
ps -ef --forest | head -20
# 이름으로 검색
pgrep -a sleep
# 정상 종료 요청 (SIGTERM)
kill $!
# 종료 확인
sleep 1; ps -p $! || echo "종료됨"ps -ef --forest의 --forest는 GNU procps 옵션이라 리눅스에서 동작한다. macOS의 BSD ps에서는 지원되지 않는다.
4.2 포트를 점유한 프로세스 찾기
# 8080 포트를 사용하는 프로세스 확인
ss -ltnp 'sport = :8080'
# lsof 사용 (macOS에서도 가능)
lsof -i :80804.3 Python으로 fork, exec, wait 체험
import os
import sys
pid = os.fork()
if pid == 0:
# 자식 프로세스
print(f"[자식] PID={os.getpid()}, PPID={os.getppid()}")
os.execvp("echo", ["echo", "자식이 echo 프로그램으로 교체됨"])
sys.exit(1) # exec 성공 시 도달하지 않음
else:
# 부모 프로세스
print(f"[부모] PID={os.getpid()}, 자식 PID={pid}")
finished_pid, status = os.waitpid(pid, 0)
print(f"[부모] 자식 {finished_pid} 종료, exit code={os.waitstatus_to_exitcode(status)}")os.fork()는 Unix 계열(리눅스, macOS)에서만 동작하며, os.waitstatus_to_exitcode는 Python 3.9 이상에서 사용할 수 있다.
4.4 Python으로 SIGTERM 처리 (graceful shutdown)
import signal
import time
import os
running = True
def handle_sigterm(signum, frame):
global running
print(f"시그널 {signum} 수신: 정리 작업 후 종료합니다")
running = False
signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGINT, handle_sigterm)
print(f"PID={os.getpid()} 실행 중. 다른 터미널에서 'kill {os.getpid()}' 를 실행해 보세요.")
while running:
time.sleep(1)
print("리소스 정리 완료. 종료.")4.5 좀비 프로세스 만들어 보기
import os
import time
pid = os.fork()
if pid == 0:
os._exit(0) # 자식은 즉시 종료
else:
print(f"자식 PID={pid}. 30초 동안 wait()를 호출하지 않습니다.")
print(f"다른 터미널에서 'ps -o pid,stat,cmd -p {pid}' 로 Z 상태를 확인하세요.")
time.sleep(30)
os.waitpid(pid, 0) # 이제 회수
print("좀비 회수 완료")5. 시간 복잡도 또는 성능 특성
프로세스 관리에는 자료구조식 시간복잡도보다 비용의 성질을 이해하는 것이 중요하다.
- fork 비용: 현대 리눅스는 쓰기 시 복사(Copy-on-Write, COW)를 사용한다.
fork()시점에 메모리를 전부 복사하지 않고 페이지 테이블만 복제하며, 어느 쪽이든 페이지를 수정할 때 해당 페이지를 복사한다. 다만 메모리를 많이 쓰는 프로세스일수록 페이지 테이블 복제 비용은 커진다. - 컨텍스트 스위치(context switch): 프로세스 간 전환은 레지스터 저장·복원과 주소 공간 전환을 수반한다. 같은 프로세스 내 스레드 전환보다 일반적으로 비용이 크다.
- 프로세스 수 제한: 시스템 전체의 PID 최대값(
/proc/sys/kernel/pid_max)과 사용자별 제한(ulimit -u)이 있다.
6. 실무 사용 사례
- 배포와 재시작: 배포 도구나 컨테이너 런타임은 먼저
SIGTERM을 보내고 유예 시간 뒤에도 종료되지 않으면SIGKILL을 보낸다. 애플리케이션이SIGTERM을 처리해 진행 중인 요청을 마무리해야 요청 유실을 막는다. - 멀티 프로세스 서버: Nginx(워커 프로세스), Gunicorn, Node.js의
cluster모듈은 여러 프로세스로 CPU 코어를 활용한다. - 포트 충돌 해결:
EADDRINUSE오류가 나면lsof -i :포트또는ss -ltnp로 점유 프로세스를 찾아 정리한다. - 서비스 관리: systemd는 서비스를 프로세스 그룹(cgroup)으로 관리하며
systemctl status로 메인 PID와 자식 프로세스를 보여준다. - 컨테이너에서의 PID 1: 컨테이너 안의 첫 프로세스는 PID 1이 되어 고아 프로세스 회수와 시그널 처리 책임을 진다. 이를 위해
tini같은 초소형 init을 쓰기도 한다.
7. 주의할 점
- `kill -9`를 습관적으로 쓰지 않는다.
SIGKILL은 정리 작업 없이 프로세스를 끝내므로 임시 파일 잔존, DB 연결 비정상 종료, 데이터 손상 위험이 있다. 먼저SIGTERM을 보내고 기다린다. - 좀비는 `kill`로 제거되지 않는다. 이미 종료된 상태이므로 부모가
wait()를 호출하거나 부모를 종료시켜야 정리된다. - D 상태 프로세스는 `SIGKILL`로도 즉시 종료되지 않을 수 있다. 커널 I/O 대기가 끝나야 반응하며, 보통 스토리지나 NFS 문제를 의심한다.
- PID 재사용: PID는 종료 후 재사용될 수 있다. 오래된 PID 파일만 믿고
kill하면 엉뚱한 프로세스를 종료할 수 있다. - `pkill`과 `killall`의 패턴 범위: 이름 패턴이 넓으면 의도하지 않은 프로세스까지 종료된다. 먼저
pgrep -a로 대상을 확인한다. - `os.fork()`와 멀티스레드: 스레드가 있는 프로그램에서 fork하면 자식에는 호출한 스레드만 남아 락 상태가 꼬일 수 있다. 가능하면
subprocess를 사용한다.
8. 핵심 정리
- 프로세스는 실행 중인 프로그램이며 PID, 독립된 주소 공간, 상태를 가진다.
- 리눅스는
fork()로 복제하고exec()로 프로그램을 교체하는 방식으로 프로세스를 만든다. - 상태 코드(R, S, D, T, Z)를 읽으면 프로세스가 무엇을 기다리는지 짐작할 수 있다.
- 종료는
SIGTERM으로 먼저 요청하고, 응답이 없을 때만SIGKILL을 쓴다. - 좀비는 부모가
wait()로 회수해야 사라지며, 고아는 init이나 subreaper가 입양한다. - 운영에서는
ps,pgrep,ss/lsof,kill을 조합해 원인 추적과 안전한 종료를 수행한다.