누락 0건을 전수로 검증한 PR이, 주석 한 줄로 분석을 세 시간 멈췄다
26만 건 소급 재생성을 위해 락·롤링 배포·누락 행까지 따진 PR이었다. 마지막에 넣은 정렬 변경의 주석 안 치환 자리 하나가 SQL을 깨뜨렸고, 테스트 네 개는 전부 통과했다
공시 요약 서비스의 요약 26만 건을 새로 만드는 작업을 배포했다. 누락 행을 전수로 세고 마이그레이션 락까지 실측한 PR이었다. 그런데 배포 직후부터 새 공시에 요약이 하나도 붙지 않았다. 원인은 Claude가 SQL 주석 안에 넣은 치환 자리 하나였다. 이번 화는 AI가 낸 장애를 AI가 고친 이야기다.
하려던 것
전날 요약 모델을 haiku에서 sonnet으로 올렸다. 공급계약 요약 1,360건을 재무 데이터와 맞춰 보니 9.2%가 금액 자릿수를 한 칸씩 밀고 있었다. 원 단위를 억 단위로 옮기다 틀린 것이다.
모델을 올려도 바뀌는 건 앞으로 들어올 공시뿐이다. 그래서 이미 만든 요약도 다시 만들기로 했다. 대상은 26만 1,404건이었다.
어떻게 시켰나
PR은 꼼꼼했다. Codex 리뷰에서 심각도 P1 지적이 연달아 나왔고, 하나씩 고쳤다.
- 제약 조건 교체와 7만 행 UPDATE를 한 파일에 몰아 넣으면 조회가 16.7초 멈춘다. 그래서 세 파일로 나눴다.
- 롤링 배포 중 구 인스턴스가 옛 프롬프트로 제출한 잡이 영구히 빠지는 경로를 막았다.
- 모델 이름 목록으로 대상을 고르던 방식을 버렸다. 그 방식으로는 249건이 빠졌다. 기준을 “이미 완료된 요약은 전부 구버전”으로 뒤집고, 전 행을 분류해서 누락 0을 확인했다.
머지 직전에 내가 하나를 물었다. 새 모델로 안 된 것들이 최신 공시부터 처리되게 돼 있냐고. 아니었다. 재분석은 최신 공시부터 넣는데, 작업을 집는 쿼리는 오래된 것부터 집고 있었다. “정렬도 지금 고쳐서 올려”라고 했고, 정렬 키를 작업 풀마다 따로 두는 커밋이 마지막에 얹혔다. 12시 56분에 배포했다.
어디서 막혔나
Go에서 SQL 문자열을 Sprintf로 조립하고 있었다. 정렬 키를 설명하는 주석에 치환 자리 %[4]s를 넣었는데, 거기 들어가는 값이 두 줄짜리였다.
-- ── 마지막 두 키는 풀마다 다르다 (CASE WHEN status = 'PENDING' THEN created_at ...,
rcept_no). ──────── ← 여기부터 SQL
-- 주석은 줄 끝에서 끝난다. 둘째 줄은 주석 밖으로 빠져나와 SQL이 됐고, 남은 닫는 괄호가 문법을 깨뜨렸다. 작업을 집는 쿼리가 1분마다 실패했다. 공시는 정상으로 들어왔지만 요약이 안 붙었고, 홈은 요약 있는 공시만 보여 준다. 그래서 화면은 멈춘 것처럼 보였다.
테스트는 네 개 다 통과했다. 생성된 SQL에 특정 문자열이 들어 있는지만 봤기 때문이다. 깨진 SQL에도 그 문자열은 다 들어 있었다.
배포 35분 뒤에 신호가 한 번 있었다. 나는 다른 걸 묻던 참이었고, 그 김에 Claude가 새 프롬프트가 먹었는지 확인했다. 결과는 “아직 0건, PENDING 6건”이었다. 마지막 완료 시각도 표에 있었다. 그런데 Claude는 이걸 “아직 완료 전”으로 읽고 다음 주제로 넘어갔다. 들어오는 쪽만 보고, 나가는 쪽은 보지 않은 셈이다.
어떻게 풀었나
두 시간 뒤 내가 먼저 알아챘다.
왜 11시 36분 이후로 새 공시가 안 올라오는 것 같은데, 확인 좀
첫 조회도 틀렸다. 시간대를 거꾸로 변환해서 적재가 멈춘 것처럼 나왔다. 바로잡고 보니 적재는 정상이었고, 분석만 3시간 24분째 완료가 없었다. PENDING이 88건 쌓여 있었다. 로그의 claim 실패를 따라가자 syntax error at or near "."가 나왔다. 여기서 Claude가 처음으로 “제 PR이 원인입니다”라고 말했다.
고치는 동안에도 우당탕탕은 이어졌다. 검증 명령을 큰따옴표로 감싸서 셸이 $1~$7을 먹었다. 로컬 변경을 되돌리는 명령으로 방금 고친 것까지 날려서 다시 했다.
수정 자체는 간단했다. 주석에서 치환 자리를 빼고 말로 설명했다. 실제 Postgres 16에 쿼리 네 변종을 PREPARE로 태워 문법을 확인했다. 회귀 테스트도 하나 붙였다. 주석 줄을 지운 뒤 괄호 수가 맞는지 보는 테스트다. 깨진 버전은 닫는 괄호가 하나 남아서 여기에 걸린다. 15시 49분에 머지했고, 몇 분 뒤 PENDING이 88 → 58로 줄어드는 걸 확인했다.
배운 점
- 문자열로 조립하는 SQL의 주석에는 치환 자리를 두지 않는다. 한 줄 값만 들어온다는 보장이 없다.
- 생성된 SQL은 문자열이 아니라 SQL로 검증한다. “이 조각이 들어 있나”를 보는 테스트는 문법이 깨져도 통과한다. 실제 DB에
PREPARE를 한 번 태우는 게 가장 싸다. - 큰 PR의 마지막 커밋이 가장 덜 검증된다. 리뷰 여러 바퀴를 거친 본체보다, 머지 직전에 급하게 얹은 수정을 따로 의심한다. 급하게 시킨 쪽이 나여도 마찬가지다.
- 배포 확인은 들어오는 쪽이 아니라 나가는 쪽으로 한다. “PENDING이 있다”는 정상일 수도 장애일 수도 있다. “배포 뒤 완료가 1건이라도 있나”는 한 번에 가려진다.
- 사람이 화면에서 느낀 이상이 모니터링보다 빨랐다. 이 경로에는 “완료가 N분째 0건” 알림이 없었다.