배포 관문이 182ms 만에 통과했습니다 — 옛 프로세스의 헬스체크였습니다
9월 6일 오후, 사내 제안서 지식검색 도구를 배포했습니다. CI(GitHub Actions) 는 success 로 끝났고 실패 마커도 없었습니다. 그런데 운영 서버 로그 끝에는 ImportError 트레이스백이 찍혀 있었고, 준비 상태 헬스체크는 응답 자체가 없었습니다.
다른 작업 세션이 남긴 미완성 import 한 줄이 딸려 들어간 것이었습니다. 존재하지 않는 모듈을 부르는 한 줄이라 API 가 import 단계에서 죽었습니다. 그날은 수습만 했습니다. 이 글은 그 뒤에 한 「왜 관문이 못 막았나」의 기록입니다.
배포 스크립트에는 이미 준비 관문이 있었습니다. 최대 45초를 기다렸다가 안 뜨면 실패 마커를 남기고 exit 1 하는 함수입니다. 세 번에 걸쳐 강화한 상태이기도 했습니다. 관문이 없어서가 아니라 있는데 통과했다는 것을 설명해야 했습니다.
첫 번째 가설, 관문이 없었겠지
가장 먼저 의심한 것은 관문의 존재 자체였습니다. 사고 시점에는 관문이 아직 안 들어가 있었을 수 있습니다. 코드를 열어 보니 관문 함수가 있었고, git log -S 로 추적하니 9월 3일에 들어와 있었습니다. 사고보다 사흘 먼저입니다. 첫 번째 가설은 기각됐습니다.
남은 가설 하나를 계획에 적어 두었습니다. 재시작 스크립트가 옛 프로세스를 확실히 못 죽여서, 옛 것이 포트를 물고 200 을 줬을 것이라는 가설입니다.
두 번째 가설, 옛 프로세스를 못 죽였겠지
다음 날 재시작 스크립트를 읽었습니다. kill 뒤에 pgrep 으로 생존을 확인하고, 남아 있으면 exit 1 합니다. 이것도 8월 31일부터 있던 코드입니다. 죽이는 쪽은 멀쩡했습니다. 두 번째 가설도 기각됐습니다.
두 가설이 차례로 틀리고 나니 남는 것이 하나였습니다. 옛 프로세스가 답한 것이 맞다면, 문제는 「누가 답했나」가 아니라 「언제 물었나」입니다. 배포 로그의 단계별 시각과 운영 서버의 프로세스 기동 시각(ps -o lstart=)을 나란히 놓았습니다.
관문은 182ms 만에 통과하고 있었다
시각 | 일어난 일 |
|---|---|
01:12:51.003 | [2/5] API 재시작 시작 |
01:12:51.185 | [3/5] 화면 빌드로 진행 — 관문 통과까지 182ms |
01:12:52 | 실제 API 프로세스 기동 — 관문보다 1초 늦다 |
화면 쪽도 같았습니다. [4/5] 에서 [5/5] 통과까지 57ms 였습니다. Next.js 가 그 안에 뜰 수는 없습니다.
원인은 배포 스크립트의 구조에 있었습니다. 재시작을 detached 로 떼어 띄우고 즉시 다음 줄로 갑니다. CI 러너가 잡 종료 때 자식 프로세스를 죽이는 것을 피하려고 그렇게 만든 것입니다. 그 처방 자체는 맞았습니다.
그래서 관문이 첫 curl 을 칠 때 재시작 스크립트는 아직 설정을 확인하는 중입니다. 옛 프로세스는 그대로 포트를 듣고 있습니다. 멀쩡히 돌고 있으니 헬스체크에 200 을 줍니다. 관문은 그 200 을 보고 통과했습니다.

API 와 화면 두 관문 다 한 번도 막은 적이 없었습니다. 롤백 안전망도 마찬가지였습니다. 9월 4일의 마이그레이션 실패와 9월 6일의 ImportError 가 둘 다 이걸로 success 가 됐습니다. 로그 시각의 대조로 확정한 것이라 여기서 가설을 멈췄습니다.
세 번의 강화가 전부 맞았는데 살아남은 이유
8월 31일에는 프로세스 생존 확인을, 9월 4일에는 상태코드 검사를, 9월 5일에는 화면 2xx 검사를 붙였습니다. 셋 다 맞는 수정이었습니다. 그런데 셋 다 「무엇이든 응답하나」를 더 엄격하게 물었을 뿐입니다. 옛 프로세스가 살아 있는 한 이 질문은 늘 통과합니다.
실패할 수 없는 검사는 통과해도 정보가 없습니다. 새 프로세스가 죽어도, 옛 프로세스가 답하는 한 초록불입니다.
더 근본적인 이유는 아무도 관문을 돌려볼 수 없었다는 점입니다. 관문 함수가 배포 스크립트 안에 묻혀 있어서, 고친 뒤 확인하는 방법이 실제 배포뿐이었습니다. 정상 배포에서는 누가 답하든 초록불입니다. 「옛 프로세스를 보고 있다」는 사실이 끝까지 안 드러난 이유입니다.

고친 것 셋
관문이 「새 프로세스가 응답하나」를 봅니다. 재시작 전에 포트를 듣는 pid 를 찍어 두고, 그 값이 바뀐 뒤에 헬스를 묻습니다. 새 프로세스가 import 에서 죽으면 포트가 영영 비어 시간 초과로 실패합니다. 그게 맞습니다. pid 전환은 로그에도 남겨 사람이 「옛 것이 답한 건 아닌가」를 안 묻게 했습니다.
서버를 건드리기 전에 임포트를 확인합니다. 0.5초짜리 검사입니다. 이 검사가 있었다면 9월 6일 사고는 여기서 걸렸을 것이고, 옛 프로세스를 손도 안 댔으므로 사이트가 아예 안 내려갔을 겁니다. 다만 앞으로 갈 때만 겁니다. 롤백 경로에 넣으면 되돌아가다 막혀 사이트가 내려간 채 끝납니다.
관문을 돌려볼 수 있게 만들었습니다. 배포 스크립트에 source 가드를 넣어 관문 함수만 꺼낼 수 있게 하고, 관문 테스트를 pytest 가 감싸 평소 테스트에 같이 걸리게 했습니다. 다음 강화가 실제로 먹는지 배포 없이 확인할 수 있습니다.

옛 판에서 실패하는 테스트만 무언가를 증명한다
관문 테스트 열 개를 새 판과 옛 판에 같이 돌렸습니다. 옛 판 7/10, 새 판 10/10 이었습니다. 옛 판에서 셋이 실제로 실패하는 것을 본 뒤에야 이 테스트가 이 버그를 본다고 말할 수 있었습니다. 옛 코드에서도 통과하는 테스트는 이 버그를 못 봅니다.
운영 배포에서도 관문이 실제로 기다리는지 봤습니다. API 는 pid 전환까지 4.2초, 화면은 1.1초를 기다렸습니다. 전에는 182ms 와 57ms 였습니다. 관문 통과가 느려진 것이 정상입니다.
재현은 안 했습니다. 운영 서버에 일부러 깨지는 import 를 넣고 배포하는 계획이었습니다. 로그 시각으로 원인이 확정된 뒤에는 운영을 깨는 재현이 얻는 것보다 위험이 컸습니다.
대신 「진짜 깨진 배포를 운영에서 막는 장면은 아직 없다」를 그대로 남겨 두었습니다. 다음 실패가 첫 실전입니다.
이 구조가 아닌 곳에서는
pid 전환 검사는 재시작이 새 프로세스를 띄운다는 전제입니다. 마스터 pid 를 유지하고 워커만 교체하는 graceful reload 구조에서는 pid 가 안 바뀌어 관문이 영원히 기다립니다. 그런 구조는 빌드 식별자나 버전 응답으로 「새 것인가」를 봅니다.
블루-그린·롤링 배포에서는 옛 프로세스가 응답하는 것이 정상입니다. 「옛 것이 답하면 안 된다」는 단일 인스턴스를 제자리에서 재시작할 때만 성립합니다.
사전 임포트 검사는 import 시점 오류만 잡습니다. 설정 누락·마이그레이션 실패·늦게 임포트되는 모듈은 못 잡습니다. 9월 4일의 마이그레이션 실패는 pid 관문 쪽이 잡습니다.
배포 관문을 점검할 때 보는 것
이 관문이 실패하려면 무엇이 달라야 하는지 한 문장으로 말할 수 있는가. 말할 수 없으면 실패할 수 없는 검사입니다.
관문이 보는 값이 재시작 전과 후를 구분하는가. 응답 유무와 상태코드는 구분하지 못하고, pid·빌드 식별자·버전은 구분합니다.
비동기로 떼어 놓은 단계를 동기 관문이 보고 있지 않은가. 떼어 놓은 이유가 맞아도 관문은 옛 상태를 봅니다.
관문을 배포 없이 돌려볼 수 있는가. 새 테스트를 옛 판에 돌려 실제로 실패하는 것을 봤는가.
마무리
관문의 질문이 바뀌었습니다. 「응답이 오나」에서 「재시작 전과 다른 pid 가 응답하나」로 바뀌었습니다. 옛 프로세스의 초록불은 이제 통과 조건이 아닙니다. 182ms 이던 관문 통과가 4.2초로 늘었습니다.
검사를 하나 세울 때 이제 먼저 묻습니다. 이 검사가 실패한다면 무엇이 달라 보이는가. 그 장면이 안 그려지면 그 검사는 통과해도 아무것도 말해 주지 않습니다.
연작 「실패할 수 없는 검사」
이 글은 다섯 편 중 1편입니다. 초록불·성공 표시·통과한 테스트가 실은 아무것도 검사하지 않던 사례를 이어서 다룹니다.