본문 바로가기
TYLER SONGBlog
← Blog index
CS학습7 min read

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

리눅스에서 새 프로세스는 보통 두 단계로 만들어진다.

  1. fork(): 호출한 프로세스(부모)를 복제해 자식 프로세스를 만든다. 자식은 새로운 PID를 받는다.
  2. exec() 계열: 자식 프로세스의 메모리 내용을 새 프로그램으로 교체한다.

셸에서 ls를 입력하면, 셸이 fork()로 자식을 만들고 자식이 exec()로 ls를 실행한다. 부모(셸)는 wait()로 자식의 종료를 기다린다.

3.2 프로세스 상태

ps의 STAT 열에서 확인할 수 있는 대표 상태는 다음과 같다.

코드상태의미
RRunning/Runnable실행 중이거나 실행 대기 중
SInterruptible Sleep이벤트(I/O, 시그널 등)를 기다리는 중
DUninterruptible Sleep보통 디스크 I/O 등을 기다리며 시그널로 깨울 수 없음
TStopped중지됨 (예: SIGSTOP, Ctrl+Z)
ZZombie종료됐지만 부모가 종료 상태를 아직 회수하지 않음

3.3 좀비와 고아 프로세스

  • 좀비 프로세스(zombie process): 자식이 종료됐지만 부모가 wait()로 종료 상태를 읽지 않아 프로세스 테이블 항목이 남아 있는 상태. 메모리는 대부분 해제되었고 PID와 종료 상태 정보만 남는다.
  • 고아 프로세스(orphan process): 부모가 먼저 종료된 자식. 일반적으로 init(PID 1, 보통 systemd)이나 subreaper 프로세스가 새 부모가 되어 종료 상태를 회수한다.

3.4 시그널(Signal)

시그널은 프로세스에 보내는 비동기 알림이다. 자주 쓰는 시그널은 다음과 같다.

시그널번호(리눅스 x86)의미처리 가능 여부
SIGHUP1터미널 연결 끊김, 설정 재로드 관례가능
SIGINT2Ctrl+C 인터럽트가능
SIGKILL9강제 종료처리·무시 불가
SIGTERM15정상 종료 요청 (kill 기본값)가능
SIGSTOP19실행 중지처리·무시 불가

SIGTERM은 프로세스가 정리 작업(연결 종료, 파일 flush)을 한 뒤 종료할 기회를 준다. SIGKILL은 커널이 즉시 프로세스를 제거하므로 정리 작업이 불가능하다.

4. 코드 예제

4.1 셸에서 프로세스 확인과 제어

shell
# 현재 셸의 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 포트를 점유한 프로세스 찾기

shell
# 8080 포트를 사용하는 프로세스 확인 ss -ltnp 'sport = :8080' # lsof 사용 (macOS에서도 가능) lsof -i :8080

4.3 Python으로 fork, exec, wait 체험

python
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)

python
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 좀비 프로세스 만들어 보기

python
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을 조합해 원인 추적과 안전한 종료를 수행한다.