프로세스 종료 코드(Exit Code) 정리

프로세스 종료 코드(Exit Code) 정리

CI 로그에서 exit code 137을 보고 “이게 뭐지?” 싶었던 적이 있는데요. Docker 컨테이너가 OOM으로 죽었을 때, Ctrl+C로 스크립트를 끊었을 때, 명령어 오타가 났을 때, 매번 다른 숫자가 떠도 잘 보지 않으면 의미를 잊기 쉽습니다. 사실 이 숫자들에는 나름의 규칙이 있고, 한 번만 정리해두면 다음에 로그를 볼 때 훨씬 빠르게 원인을 짚을 수 있습니다.

이번 글에서는 종료 코드가 만들어지는 원리부터 자주 마주치는 코드의 의미, 셸과 Node.js, Python에서 다루는 방법까지 정리해보겠습니다.

종료 코드란

프로세스는 종료될 때 부모 프로세스에게 0부터 255 사이의 정수 하나를 남깁니다. 이 숫자가 바로 종료 코드(exit code) 또는 종료 상태(exit status)인데요. 관례적으로 0은 성공, 0이 아닌 값은 실패를 의미합니다.

가장 직관적인 예시는 truefalse 명령어입니다.

true
echo $?
# 0

false
echo $?
# 1

$? 변수는 직전에 실행한 명령어의 종료 코드를 담고 있어서, 셸에서 종료 상태를 확인할 때 가장 많이 쓰는 방법입니다. CI 파이프라인이나 셸 스크립트의 if, &&, || 같은 분기도 결국 이 0/비-0 값을 보고 동작합니다.

0이 아닌 값은 왜 실패일까

처음 보면 거꾸로 느껴질 수도 있는데요. “성공이 1, 실패가 0이어야 자연스럽지 않나?” 싶지만, 종료 코드 입장에서는 그 반대가 더 합리적입니다.

성공의 모습은 보통 하나입니다. “할 일을 끝냈다”는 결과 한 가지인데요. 반면 실패는 수십 가지 모습으로 나타날 수 있습니다. 입력 형식이 잘못됐거나, 권한이 없거나, 네트워크가 끊겼거나 등등이죠. 이 다양한 원인을 서로 다른 숫자로 구분하려면 0이 아닌 값을 자유롭게 쓸 수 있어야 합니다. 그래서 0 하나를 성공에 고정해두고, 1부터 255까지를 실패의 종류별로 나눠 쓰는 관례가 자리 잡았습니다.

자주 보는 일반 종료 코드

대부분의 명령어는 비슷한 의미로 몇 가지 코드를 공통으로 사용합니다.

1은 가장 일반적인 실패 코드입니다. 별다른 분류 없이 “뭔가 잘못됐다”는 신호인데요. false 명령어가 대표적이고, grep이 매칭을 하나도 못 찾았을 때도 1을 반환합니다.

2는 셸이나 많은 CLI 도구에서 잘못된 사용법(misuse of shell builtins)에 쓰입니다. 예를 들어 grep은 매칭 실패가 1이지만, 정규식 문법이 깨졌을 때는 2를 반환합니다.

126은 파일을 찾았는데 실행 권한이 없을 때 나옵니다.

echo '#!/bin/bash' > /tmp/test_file
chmod -x /tmp/test_file
/tmp/test_file
echo $?
# 126

127은 명령어를 아예 찾지 못했을 때입니다. 오타나 PATH 설정 문제로 가장 자주 마주치는 코드인데요.

nonexistent_command
# bash: nonexistent_command: command not found
echo $?
# 127

126127을 헷갈리면 디버깅이 한참 돌아갈 수 있어서, “권한은 126, 못 찾으면 127”로 묶어서 외워두면 편합니다.

시그널로 죽었을 때: 128 + N 규칙

여기서부터가 진짜 핵심인데요. 프로세스가 자기 의지로 종료한 게 아니라 시그널을 받고 강제로 끝났을 때는 셸이 128 + 시그널 번호를 종료 코드로 만듭니다.

예를 들어 Ctrl+C는 SIGINT(시그널 번호 2)를 보내는데, 이때 종료 코드는 128 + 2 = 130이 됩니다. kill 기본값인 SIGTERM(15)은 128 + 15 = 143, 강제 종료인 SIGKILL(9)은 128 + 9 = 137이 되는 거죠.

실제로 확인해보면 이렇습니다.

시그널별 종료 코드
(sleep 10) & PID=$!
kill -INT $PID; wait $PID; echo "INT: $?"
# INT: 130

(sleep 10) & PID=$!
kill -TERM $PID; wait $PID; echo "TERM: $?"
# TERM: 143

(sleep 10) & PID=$!
kill -KILL $PID; wait $PID; echo "KILL: $?"
# KILL: 137

이 규칙을 알고 있으면 로그에서 137을 봤을 때 곧바로 “아, SIGKILL 받고 죽었구나”라고 짚을 수 있는데요. SIGKILL은 보통 두 가지 상황에서 떨어집니다. 우선 사용자가 kill -9를 직접 날린 경우가 있고, 더 흔한 건 리눅스 커널의 OOM killer가 메모리 부족으로 프로세스를 잡아간 경우입니다. Docker나 Kubernetes에서 컨테이너가 메모리 한도를 넘기면 정확히 이 137이 찍히는데, CI에서 “왜 갑자기 빌드가 죽지?” 싶을 때 의심해볼 첫 번째 후보입니다.

130도 자주 보이는데요. Ctrl+C로 직접 끊었거나, 부모 프로세스가 SIGINT를 전파했거나, 터미널이 닫히면서 같이 끊긴 경우입니다.

143은 Kubernetes가 파드를 정상 종료시킬 때, systemd가 서비스를 중지할 때, Docker가 docker stop으로 컨테이너에 SIGTERM을 보낼 때 자주 등장합니다. 이건 보통 의도된 종료라 무서워할 필요는 없습니다.

자주 마주치는 코드 한눈에 정리

지금까지 본 코드를 모아두면 이렇습니다.

  • 0 — 성공
  • 1 — 일반적인 실패
  • 2 — 잘못된 사용법(인자 오류, 문법 오류)
  • 126 — 파일은 있지만 실행 권한 없음
  • 127 — 명령어를 찾을 수 없음(PATH 문제, 오타)
  • 130 — SIGINT(2)로 종료, 보통 Ctrl+C
  • 137 — SIGKILL(9)로 종료, OOM killer가 가장 흔한 원인
  • 139 — SIGSEGV(11)로 종료, 세그멘테이션 폴트
  • 143 — SIGTERM(15)로 종료, 정상적인 종료 요청

128 + N 규칙만 기억하면 표를 외울 필요는 없는데요. 시그널 번호는 kill -l로 언제든 확인할 수 있습니다.

kill -l
# HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM ...

이 목록에서 INT는 두 번째니까 2, KILL은 아홉 번째니까 9, TERM은 열다섯 번째니까 15입니다. macOS와 Linux가 시그널 번호를 거의 동일하게 쓰기 때문에 한 번만 익혀두면 두 환경에서 모두 통합니다.

왜 256이 아니라 255까지일까

종료 코드는 8비트 부호 없는 정수, 즉 0~255 범위만 쓸 수 있습니다. POSIX의 wait() 호출이 종료 상태를 16비트 정수에 인코딩하면서 하위 8비트만 종료 코드용으로 잡아두기 때문인데요. 그래서 exit(256)을 호출해도 실제로는 256 % 256 = 0이 되어 성공으로 잡히고, exit(-1)255로 보입니다.

node -e 'process.exit(256)'
echo $?
# 0

node -e 'process.exit(-1)'
echo $?
# 255

큰 의미는 없지만, “종료 코드로 의미 있는 숫자를 넣어야지”라며 큰 값을 쓰면 의도와 다르게 0으로 둔갑할 수 있다는 점은 알아두면 좋습니다. 실용적으로는 1~125 범위에서 쓰고, 126 이상은 셸과 시그널이 예약해둔 영역이라고 생각하면 편합니다.

Node.js와 Python에서 종료 코드 다루기

언어별로 종료 코드를 명시적으로 지정하는 방법도 정리해두면 좋은데요.

Node.js에서는 process.exit(code)로 즉시 종료하거나, process.exitCode에 값을 할당해두고 이벤트 루프가 자연스럽게 비워질 때 종료하도록 할 수 있습니다.

if (config.invalid) {
  process.exitCode = 2;
  return;
}

process.exit(0);

process.exit()은 진행 중인 비동기 작업을 끊어버리기 때문에 가급적 exitCode로 표시만 해두고 자연스럽게 끝내는 쪽이 안전합니다.

Python에서는 sys.exit(code)를 가장 많이 쓰는데요. 정수를 넣으면 그대로 종료 코드가 되고, 문자열을 넣으면 stderr에 출력한 후 종료 코드 1로 끝납니다.

import sys

if not args.input:
    sys.exit("입력 파일이 필요합니다")  # stderr 출력 후 exit code 1

sys.exit(0)

두 언어 모두 처리되지 않은 예외가 발생하면 자동으로 1로 종료됩니다. 그래서 스크립트를 짤 때 굳이 모든 에러를 try/catch로 감쌀 필요는 없는데, 셸 입장에서는 어차피 비-0이 떨어지기 때문입니다.

셸에서 종료 코드 활용하기

셸 스크립트에서 종료 코드를 다루는 패턴 몇 가지를 보면, 우선 &&||로 조건부 실행이 가능합니다.

bun run build && bun run deploy
# build가 0으로 끝나야 deploy 실행

bun run test || echo "테스트 실패"
# test가 0이 아니면 메시지 출력

if 문으로 분기할 때는 명령어 자체를 조건으로 쓸 수 있습니다.

if grep -q "ERROR" /var/log/app.log; then
  echo "에러 발견"
fi

grep -q는 매칭만 확인하고 출력은 하지 않는데, 매칭이 있으면 0, 없으면 1을 반환하기 때문에 자연스럽게 if와 어울립니다.

여러 명령을 파이프로 연결할 때는 주의해야 합니다. $?는 마지막 명령어의 종료 코드만 보여주는데요. 중간 명령어가 실패해도 마지막이 성공하면 0이 떨어지기 때문입니다.

cat /nonexistent | sort | head
echo $?
# 0  (cat은 실패했지만 head는 성공)

이런 경우에는 set -o pipefail을 켜두면 파이프 안에서 가장 먼저 실패한 종료 코드를 살려둘 수 있습니다. CI 스크립트 맨 앞에 set -euo pipefail을 적어두는 관례도 여기서 나옵니다.

PIPESTATUS 배열로 각 단계의 종료 코드를 모두 확인할 수도 있습니다.

cat /nonexistent | sort | head
echo "${PIPESTATUS[@]}"
# 1 0 0

리다이렉션 사용법이나 Bash 파라미터 확장과 함께 익혀두면 셸 스크립트를 훨씬 자신감 있게 쓸 수 있습니다.

마치며

종료 코드는 처음에는 그냥 숫자로만 보이지만, 한 번 규칙을 익혀두면 로그에서 가장 빠르게 원인을 짚을 수 있는 단서가 됩니다. 128 + N 공식 하나로 시그널 종료를 거의 다 해석할 수 있고, 126/127/130/137/143만 기억해도 일상에서 보는 코드의 90%는 커버됩니다.

다음에 exit code 137이나 exit code 143을 마주치면 당황하지 않고 “메모리 부족이군” 또는 “정상 종료 요청이군” 하고 바로 다음 디버깅 단계로 넘어갈 수 있을 텐데요. 더 자세한 내용은 GNU Bash 매뉴얼의 Exit Status 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord