Table of contents
- 프로세스는 “살아 있는 프로그램”이다
- PID와 프로세스 트리
- ps — 스냅샷으로 보기
- top, htop — 실시간으로 관찰
- 시그널 — 프로세스에게 말을 걸다
- kill — 이름이 과격하지만 꼭 죽이는 건 아니다
- & — 백그라운드 실행
- jobs, fg, bg — 잡 제어
- nohup — 세션이 끊겨도 살아남게
- disown — 이미 띄운 프로세스를 살려보내기
- 프로세스 관련 실전 한 묶음
- 이번 편 정리
프로세스는 “살아 있는 프로그램”이다
디스크에 저장된 ls 바이너리 파일은 그냥 비트의 묶음일 뿐이다. 그 파일을 누군가 실행하는 순간, 메모리에 올라가고, CPU가 명령을 수행하고, 파일 디스크립터를 열고, 신호를 받는다. 이 “실행 중인 프로그램의 실체”를 프로세스(Process)라 부른다.
개발자가 매일 다루는 것들이 결국 프로세스다. java -jar app.jar로 띄운 스프링 애플리케이션도, nginx로 떠 있는 웹 서버도, ssh로 접속한 내 셸도 프로세스다. 문제는 이 프로세스들이 “어떻게 살고, 어떻게 죽고, 어떻게 백그라운드로 돌아가는지” 모른 채로 운영에 뛰어들면 엉뚱한 곳에서 막힌다는 것이다. 앱이 왜 종료 시그널을 무시하는지, CI 스크립트에서 띄운 프로세스가 왜 세션 종료와 함께 죽는지 — 이 편은 그런 당혹스러운 순간의 뒷면을 본다.
PID와 프로세스 트리
모든 프로세스는 고유한 PID(Process ID)를 받는다. PID 1은 시스템 부팅 시 가장 처음 뜨는 프로세스의 자리다. 요즘 배포판에선 systemd가 PID 1을 차지하고 있고, Docker 컨테이너 안에서는 사용자가 지정한 최초 실행 프로세스가 PID 1이 된다.
그리고 모든 프로세스(PID 1 제외)는 부모 프로세스를 갖는다. 부모 PID(PPID)를 따라 올라가다 보면 결국 PID 1에 닿는다. 그래서 리눅스의 프로세스는 거대한 트리 구조다.
flowchart TB
INIT["PID 1<br/>systemd"]
INIT --> SSHD["sshd<br/>PID 900"]
INIT --> NGINX["nginx (master)<br/>PID 1200"]
INIT --> DOCKERD["dockerd<br/>PID 1500"]
SSHD --> BASH["bash (내 셸)<br/>PID 3100"]
NGINX --> NW1["nginx (worker)<br/>PID 1201"]
NGINX --> NW2["nginx (worker)<br/>PID 1202"]
BASH --> VIM["vim<br/>PID 4200"]
BASH --> NODE["node app.js<br/>PID 4300"]
이 트리를 직접 눈으로 보려면 pstree를 쓴다.
pstree -p
# systemd(1)─┬─sshd(900)───bash(3100)─┬─vim(4200)
# │ └─node(4300)
# ├─nginx(1200)─┬─nginx(1201)
# │ └─nginx(1202)
# └─dockerd(1500)─...
자기 셸의 PID는 $$에, 부모 PID는 $PPID에 담겨 있다.
echo "현재 셸 PID: $$"
echo "부모 PID: $PPID"
부모 프로세스의 역할은 중요하다. 자식이 끝나면 부모가 종료 상태(exit status)를 수거해야 한다. 수거하지 않은 자식은 좀비(zombie) 상태로 남고, 부모가 먼저 죽은 자식은 고아(orphan)가 돼 PID 1이 입양한다. Docker에서 tini 같은 init 프로세스를 쓰는 이유가 바로 여기 있다 — 컨테이너에서 PID 1이 되는 앱이 좀비 수거 기능이 없으면 누수가 생긴다.
ps — 스냅샷으로 보기
지금 이 순간 돌고 있는 프로세스를 한 번에 훑는 도구가 ps(Process Status)다. 옵션이 역사적인 이유로 두 계열(BSD와 System V)이 섞여 있어서 살짝 혼란스럽다. 실무에서 기억할 조합은 몇 개로 충분하다.
# 내 셸에서 실행 중인 프로세스 (BSD 스타일, 가장 짧다)
ps
# 시스템 전체 프로세스를 장황하게 (BSD)
ps aux
# 시스템 전체 + 부모-자식 관계 표시 (System V)
ps -ef
# 특정 이름 검색
ps aux | grep nginx
ps aux의 열을 해독해두면 편하다.
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
alice 4300 1.2 0.5 512M 32M pts/0 S+ 10:30 0:03 node app.js
USER: 소유자PID: 프로세스 ID%CPU,%MEM: CPU/메모리 사용률VSZ: 가상 메모리 크기 (malloc으로 예약했지만 실제 안 쓸 수도 있다)RSS: 실제 물리 메모리 (Resident Set Size). 이 값이 진짜 쓰는 메모리TTY: 이 프로세스가 붙어 있는 터미널.?는 데몬처럼 터미널 없이 도는 프로세스STAT: 상태.R(실행중),S(sleep),D(인터럽트 불가 sleep),Z(좀비),T(중지) 등TIME: 누적 CPU 시간COMMAND: 실행된 명령
top, htop — 실시간으로 관찰
ps가 스냅샷이라면, top은 CCTV다. 1초마다 갱신되면서 CPU·메모리 상위 프로세스를 보여준다.
top
# q로 종료, k 누르면 특정 PID에 시그널 전송, M으로 메모리순 정렬, P로 CPU순 정렬
top은 모든 배포판에 기본 설치돼 있지만 UI가 투박하다. 색깔과 마우스 조작을 지원하는 htop을 설치해서 쓰는 게 일반적이다.
# Debian/Ubuntu
sudo apt install htop
# RHEL/CentOS
sudo dnf install htop
# macOS
brew install htop
htop
# F3 검색, F9 시그널 전송, F5 트리 뷰, F6 정렬
프로덕션 서버에서 “뭐가 CPU를 잡아먹는가?”를 파악할 때 가장 먼저 여는 창이 top이나 htop이다. 개발자가 로컬에서 돌려보는 것과는 차원이 다른 “살아 있는” 정보를 준다.
시그널 — 프로세스에게 말을 걸다
Linux가 프로세스에게 보내는 메시지가 시그널(signal)이다. 종류가 30개 넘지만 실무에서 자주 보는 건 대여섯 개 정도다.
flowchart LR
SENDER["보내는 쪽<br/>(사용자, 커널, 다른 프로세스)"]
SENDER -->|SIGTERM / SIGKILL / SIGHUP ...| RECEIVER["받는 프로세스"]
RECEIVER --> REACT{"어떻게 반응?"}
REACT --> DEFAULT["기본 동작<br/>(대개 종료)"]
REACT --> HANDLER["핸들러<br/>(정의된 경우 실행)"]
REACT --> IGNORE["무시<br/>(SIGKILL/SIGSTOP 제외)"]
자주 쓰이는 시그널
| 이름 | 번호 | 의미 | 핸들러 가능? |
|---|---|---|---|
SIGHUP | 1 | 터미널 연결 끊김. 데몬들은 “설정 다시 읽기” 신호로 재활용 | O |
SIGINT | 2 | 인터럽트. 터미널에서 Ctrl+C | O |
SIGQUIT | 3 | 종료 + 코어 덤프. Ctrl+\ | O |
SIGKILL | 9 | 즉시 강제 종료. 무시·가로채기 불가 | X |
SIGTERM | 15 | ”정상 종료해달라” 요청. kill의 기본값 | O |
SIGSTOP | 19 | 강제 일시정지. 무시 불가 | X |
SIGTSTP | 20 | 터미널에서 Ctrl+Z | O |
SIGCONT | 18 | 정지된 프로세스를 재개 | O |
SIGTERM vs SIGKILL. 이 둘의 차이는 DevOps를 하는 사람이라면 반드시 이해해야 한다.
SIGTERM(15): “정리할 시간을 줄 테니 스스로 내려와라”. 앱은 이 시그널을 받으면 열려있는 DB 커넥션을 닫고, 진행 중인 요청을 마무리하고, 임시 파일을 정리한 뒤 종료할 수 있다SIGKILL(9): “지금 당장 죽어라”. 앱에게는 아무 기회도 주지 않는다. 커널이 그 프로세스의 메모리를 회수하고 끝이다. 리소스 정리 코드가 돌 시간이 없으니 DB 락이 남거나 파일이 손상되는 일이 생길 수 있다
쿠버네티스도 Pod를 내릴 때 이 흐름을 따른다. 먼저 SIGTERM을 보내고, terminationGracePeriodSeconds(기본 30초)를 기다린 뒤에도 살아있으면 SIGKILL로 마무리한다. 그래서 Graceful shutdown을 지원하지 않는 앱은 쿠버네티스 환경에서 배포할 때마다 커넥션 에러를 남긴다.
kill — 이름이 과격하지만 꼭 죽이는 건 아니다
kill 명령의 이름은 역사적 사연이다. 초기 Unix에서 프로세스에게 보낼 수 있는 시그널이 대부분 “종료”였기 때문에 이 이름이 붙었다. 실제로는 아무 시그널이나 보낼 수 있는 도구다.
# 기본은 SIGTERM (15)
kill 4300
# 명시적으로 시그널 지정 (이름 또는 번호)
kill -TERM 4300
kill -15 4300
# 강제 종료 — 정말 필요할 때만
kill -KILL 4300
kill -9 4300
# 설정 재로드 (서비스에 따라 지원)
kill -HUP 4300
# 보낼 수 있는 모든 시그널 보기
kill -l
이름으로 한 번에 죽이고 싶으면 pkill 또는 killall을 쓴다. 단어 매칭이라 조심해서 써야 한다.
# 이름에 "node"가 들어간 모든 프로세스에 SIGTERM
pkill node
# 정확히 이름이 "node"인 것만
killall node
# 특정 사용자의 프로세스만
pkill -u alice
프로덕션에서 pkill -9 java 같은 걸 잘못 치면 여러 앱이 한꺼번에 죽는 대참사가 일어날 수 있다. 보내기 전에 pgrep -a node로 대상이 맞는지 확인하는 습관을 들인다.
pgrep -a node
# 4300 node app.js
# 4450 node worker.js
& — 백그라운드 실행
명령 뒤에 &를 붙이면 해당 명령을 백그라운드로 보낸다. 셸은 그 프로세스가 끝날 때까지 기다리지 않고 바로 다음 프롬프트를 돌려준다.
# 포그라운드 — 명령이 끝날 때까지 셸이 멈춰 있다
./long-task.sh
# 백그라운드 — 바로 프롬프트 복귀
./long-task.sh &
# [1] 4600
# ^ ^
# │ └─ PID
# └───── 잡(job) 번호
jobs, fg, bg — 잡 제어
같은 셸에서 띄운 백그라운드 프로세스들을 관리하는 내장 명령 세트가 jobs/fg/bg다.
# 실행
sleep 300 &
# [1] 4700
sleep 500 &
# [2] 4750
# 지금 셸의 백그라운드 잡 목록
jobs
# [1]- Running sleep 300 &
# [2]+ Running sleep 500 &
# 1번 잡을 포그라운드로 가져오기
fg %1
# 포그라운드에서 Ctrl+Z 누르면 SIGTSTP로 멈춘다
# 멈춘 걸 백그라운드에서 다시 실행시키려면
bg %1
잡 번호는 %n 문법으로 가리킨다. kill %1처럼 시그널도 잡 번호로 보낼 수 있다.
단, 이 세계는 셸 세션 안에서만 유효하다. 셸이 종료되면 잡 목록이 날아간다. 그래서 장기 작업은 다음 이야기처럼 다르게 접근해야 한다.
nohup — 세션이 끊겨도 살아남게
SSH로 접속해 ./deploy.sh &로 백그라운드에 작업을 던지고 SSH를 끊으면, 그 작업이 그대로 돌아갈 것 같지만 — 대개 죽는다. 왜 그럴까?
원인은 SIGHUP이다. SSH 연결이 끊기면 커널은 그 터미널에 연결된 자식 프로세스들에게 SIGHUP을 보낸다. 기본 동작이 “종료”이기 때문에 작업이 딸려 죽는다.
해결책이 nohup(NO HangUP)이다. 이 명령으로 감싸면 SIGHUP을 무시하도록 설정된다.
nohup ./deploy.sh > deploy.log 2>&1 &
# nohup: ignoring input and appending output to 'nohup.out'
nohup은 출력을 리디렉션하지 않으면 자동으로 nohup.out 파일로 쌓는다. 로그 파일을 지정하려면 위 예시처럼 > log.txt 2>&1로 직접 리디렉션한다. 여기서 2>&1은 “표준 에러(2)도 표준 출력(1)과 같은 곳으로 보내라”는 의미인데, 자세한 리디렉션은 4편에서 다룬다.
다만 nohup은 옛날 방식이다. 현대의 실무에선 세션 자체를 분리하는 도구를 더 많이 쓴다.
더 나은 대안: tmux, screen, systemd
- tmux / screen: 터미널 멀티플렉서. 세션을 만들어두고 거기서 작업을 실행한 뒤
detach하면, SSH가 끊겨도 세션은 살아있다. 다시 접속해서attach하면 그대로 이어서 볼 수 있다
# tmux 세션 만들기
tmux new -s deploy
# 세션 안에서 ./deploy.sh 실행
# Ctrl+b 누른 뒤 d — detach
# SSH 끊어도 안 죽음
# 다시 붙기
tmux attach -t deploy
- systemd service: 진짜 장기 서비스는
systemdunit으로 등록한다. 재부팅해도 자동 시작, 죽으면 재시작, 로그 통합까지 해준다. “서비스로 계속 떠 있어야 한다”면nohup이 아니라systemd가 정답이다
disown — 이미 띄운 프로세스를 살려보내기
깜빡하고 nohup 없이 &만 붙여서 프로세스를 띄웠다. 지금 SSH 끊으면 죽는다. 방법이 없을까?
있다. disown을 쓰면 셸의 잡 테이블에서 프로세스를 떼어내고, SIGHUP도 받지 않게 만든다.
./deploy.sh &
# [1] 4800
jobs
# [1]+ Running ./deploy.sh &
disown %1
# 또는 현재 모든 잡: disown -a
jobs
# (비어 있음 — 떼어짐)
# 이제 SSH 끊어도 4800은 살아남는다
프로세스 관련 실전 한 묶음
자주 쓰는 조합을 모아두자.
# 특정 포트를 누가 쓰고 있는지
sudo lsof -i :8080
sudo ss -ltnp | grep :8080 # 더 현대적
# 특정 프로세스가 연 파일
sudo lsof -p 4300
# 특정 PID의 상세 정보
cat /proc/4300/status | head
cat /proc/4300/cmdline | tr '\0' ' ' # 실행 당시 명령줄 전체
ls -l /proc/4300/cwd # 작업 디렉토리
ls -l /proc/4300/exe # 실행 파일 경로
# 메모리 과다 사용으로 OOM에 죽었는지 확인
dmesg | grep -i "out of memory"
# 또는 최근 로그
journalctl -k | grep -i oom
/proc/<PID>/ 아래 파일들은 1편에서 얘기했듯 커널이 동적으로 만들어주는 가상 파일이다. 모니터링 도구가 하는 일의 상당 부분이 여기서 값을 읽어 예쁘게 정리하는 것이다.
이번 편 정리
이 글에서 밟은 발자국.
- 프로세스는 PID와 부모 PID로 묶여 하나의 트리를 이룬다
ps aux,top,htop으로 지금 돌고 있는 녀석들을 관찰한다- 시그널은 프로세스에게 보내는 메시지다.
SIGTERM은 “정리하고 내려와”,SIGKILL은 “즉시 종료” &로 백그라운드,jobs/fg/bg로 잡 제어. 세션을 넘어 살리려면nohup또는tmux, 진짜 서비스는systemd- 포트 점유나 열린 파일을 확인할 땐
lsof,ss, 그리고/proc
다음 편에서는 리눅스가 50년 동안 유지해온 핵심 철학인 텍스트 처리와 파이프를 다룬다. grep·sed·awk와 |가 조합될 때 어떤 힘이 나오는지 들여다본다.

Loading comments...