실패했는데 종료 코드는 0 이었다 · 종료 코드 0 은 약속이다 · 이론
종료 코드 0 은 약속이다
한 줄 요약
운영 도구는 결과를 종료 코드로 말하고, 사람에게 하는 말은 표준 오류로, 기계가 읽을 결과는 표준 출력으로 내보낸다. 이 세 통로를 섞는 순간 스크립트는 cron 과 파이프라인 안에서 조용히 거짓말을 한다.
왜 이게 필요했나
새벽 세 시, 백업 디렉터리를 검사하는 스크립트가 돌았다. 백업은 도착하지 않았는데 다음 단계는 그대로 진행됐다. 스크립트가 print("backup missing!") 을 찍고 그냥 끝났기 때문이다. 파이썬은 예외 없이 끝난 프로그램에 종료 코드 0 을 준다. cron 도 CI 도 그 0 만 본다. 화면에 찍힌 문장은 아무도 읽지 않았다.
이 코스가 Python 을 다루는 이유는 채용 시장이 그것을 가장 많이 요구하기 때문이다. 2026-09-11 에 Greenhouse 13개 보드와 We Work Remotely 를 집계한 결과(엔지니어링 직함 959건), Python 은 445건(46.4%)으로 모든 기술 키워드 가운데 1위였고, 한국 6개 회사만 따로 세도 131건(46.1%)으로 1위였다. 그런데 공고가 원하는 것은 문법이 아니다. "운영 자동화 스크립트 작성", "내부 도구 개발" 같은 문장 뒤에 있는 것은 남이 믿고 돌릴 수 있는 프로그램을 만드는 규율이다. 그 규율의 첫 번째가 종료 코드다.
어떻게 동작하나
[sys.exit()](https://docs.python.org/3/library/sys.html#sys.exit) 문서는 관례를 이렇게 적는다 — 정수 0 은 정상 종료, 0 이 아닌 값은 비정상 종료이며, 유닉스 프로그램은 명령줄 문법 오류에 2, 그 밖의 오류에 1 을 쓴다. 정수가 아닌 객체(예: 문자열)를 넘기면 그 객체가 표준 오류에 찍히고 종료 코드는 1 이 된다. 그래서 이 코스의 도구는 세 가지 약속을 지킨다.
| 종료 코드 | 뜻 | 누가 처리하나 |
| --- | --- | --- |
| 0 | 검사 통과 | 다음 단계가 진행한다 |
| 1 | 검사 실패(도구는 정상, 대상이 문제) | 알림·재시도 정책이 판단한다 |
| 2 | 도구 오류(잘못된 인자, 없는 경로) | 사람이 도구를 고쳐야 한다 |
이 표가 grep 의 약속(일치 0, 불일치 1, 오류 2)과 같은 모양인 것은 우연이 아니다. [argparse](https://docs.python.org/3/library/argparse.html) 도 같은 관례를 따른다 — ArgumentParser.error() 는 사용법 메시지를 표준 오류에 찍고 종료 코드 2 로 끝낸다. 인자를 손으로 파싱하면 이 약속을 매번 다시 구현해야 하고, 대개 빠뜨린다.
로그는 [logging HOWTO](https://docs.python.org/3/howto/logging.html) 가 정한 대로 동작한다. 아무 설정이 없으면 기본 수준은 WARNING 이고, 목적지는 표준 오류(sys.stderr)이며, 기본 형식은 심각도:로거 이름:메시지 다. basicConfig(level=..., format=...) 로 수준과 형식을 바꾼다. 여기서 중요한 것은 목적지가 표준 오류라는 사실이다. print() 는 표준 출력으로 간다. 그래서 진단 메시지를 print() 로 찍으면 tool | jq 같은 파이프라인이 깨지고, 결과를 logging 으로 찍으면 결과가 파이프에 도착하지 않는다.
import argparse, logging, syslog = logging.getLogger("dircheck")def main(argv=None) -> int: p = argparse.ArgumentParser(prog="dircheck") p.add_argument("path") p.add_argument("-v", "--verbose", action="store_true") args = p.parse_args(argv) # 잘못된 인자면 여기서 2 로 끝난다 logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)s %(name)s: %(message)s") log.debug("checking %s", args.path) # 표준 오류 print("files=3 ok=true") # 표준 출력 — 기계가 읽는다 return 0 # 종료 코드는 main 이 돌려준다if __name__ == "__main__": sys.exit(main())마지막 두 줄이 이 코스가 강조하는 모양이다. main(argv) 가 정수를 돌려주고, sys.exit() 는 파일 맨 아래에서 한 번만 부른다. 그래야 다음 모듈에서 이 도구를 import 해 시험할 수 있다 — 모듈을 불러오는 순간 프로그램이 실행되어 버리면 시험은 불가능하다.
예외는 어떻게 되나. 잡지 않은 예외는 트레이스백을 표준 오류에 찍고 종료 코드 1 로 끝난다. 종료 코드만 보면 "검사 실패" 와 구별이 안 된다. 그래서 도구 오류(없는 경로, 권한 없음)는 OSError 를 잡아 메시지 한 줄 + 종료 코드 2 로 바꿔 준다. 트레이스백은 도구를 만든 사람에게는 정보지만, 새벽에 알림을 받은 사람에게는 소음이다.
현장에서 만나는 모습
가장 흔한 사고는 "성공처럼 끝나는 실패" 다. 파이프라인 중간에 python3 check.py || true 를 넣어 둔 경우, 검사 도구가 종료 코드를 안 돌려주는 경우, try: ... except Exception: print(e) 로 모든 예외를 삼키고 0 으로 끝나는 경우가 전부 같은 결과를 낸다. 두 번째로 흔한 것은 표준 출력 오염이다. 도구가 JSON 을 내는데 중간에 "connecting..." 같은 진행 메시지가 표준 출력에 섞여 json.loads 가 깨진다. 셋째는 -v 를 붙였을 때만 나오는 정보가 없어서, 장애 때 "다시 돌려 보면서 print 를 추가" 해야 하는 도구다. 로그 수준은 처음 만들 때 넣지 않으면 영영 없다.
다음 실습에서 할 것
백업 디렉터리 검사 도구 dircheck.py 를 처음부터 만든다. --help 가 되는 뼈대에서 시작해, 0·1·2 종료 코드 약속을 넣고, 도구 오류에서 트레이스백 대신 한 줄 메시지를 내고, -v 로 DEBUG 로그를 표준 오류에만 흘리고, --json 으로 기계가 읽을 결과를 표준 출력에 낸다. 마지막에는 main(argv) 가 정수를 돌려주는 모양으로 다듬어, 임포트해도 실행되지 않는 도구로 만든다.