테스트 격리를 두 번 놓쳤더니, 둘 다 에러 없이 통과했습니다
계측 코드를 붙이고 테스트를 한 번 돌린 뒤 git status 를 열었습니다. 수정된 파일 목록에 누계 DB 파일이 올라와 있었습니다. 테스트는 전부 통과한 뒤였습니다.
그 파일은 LLM 호출마다 실제 토큰 수를 쌓으려고 만든 실측 표입니다. 테스트가 거기에 행을 쓰고 있었다는 뜻입니다. 이 글은 그 계측과 한도 폴백을 붙이는 동안 테스트 쪽에서 두 번 데인 기록입니다. 두 번 다 코드가 틀린 것이 아니라 테스트가 운영과 같은 길을 탄 것이 문제였습니다. 그리고 두 번 다 에러 없이 통과했습니다.
계측을 붙인 이유
사내 제안서 지식검색 도구는 구독형 LLM 창구를 REST 로 씁니다. 한도가 왜 차는지를 한동안 글자 수로 추정하고 있었습니다. 확인해 보니 응답 헤더에 한도 잔량이, 완료 이벤트의 usage 에 실제 토큰 수가 매 호출 실려 오고 있었습니다. 추정할 이유가 없었습니다.
그래서 전송 함수 한 곳에서 헤더와 usage 를 주워 기능별로 누계 DB 에 쌓게 했습니다. 한도 초과(429)는 타입 있는 예외로 올렸습니다. 한도가 차면 별도 키의 다른 모델로 한 번 더 시도하는 폴백도 같이 붙였습니다. 문제는 그 뒤에 있었습니다.
사건 1 — 테스트가 실측 표에 가짜 행을 넣었다
계측은 호출 한 번에 누계 DB 파일에 한 행을 씁니다. 테스트가 그 경로를 그대로 탔습니다. 테스트 실행 한 번이 가짜 토큰 수를 실측 표에 넣은 것입니다.
처음 눈에 띈 것은 git status 가 더러워진 것이었습니다. 나쁜 쪽은 그게 아닙니다. 재려고 만든 표에 가짜가 들어가면 재는 것 자체가 뜻을 잃습니다. 그 표로 어느 기능이 한도를 먹는지 판단하려던 참이었습니다. 모르고 며칠 돌렸다면 표를 통째로 버렸을 겁니다.
잡았다고 생각했는데 전체 실행에서만 안 먹었다
격리는 pytest 의 conftest 에 autouse 픽스처를 두는 흔한 방식으로 했습니다. 설정 객체의 DB 경로를 임시 경로로 패치하는 것입니다. 그 테스트 파일만 돌리면 통과했고, 저장소에 파일도 생기지 않았습니다.
전체를 돌리자 파일이 다시 생겼습니다. 원인은 다른 테스트 파일에 있었습니다. 설정 테스트가 importlib.reload 로 설정 모듈을 재적재하면서 설정 객체를 새 객체로 갈아끼우고 있었습니다. 제 패치는 옛 객체에 남아 있고, 계측은 새 객체를 봅니다. 그래서 단독 실행은 통과하고 전체 실행에서만 조용히 안 먹었습니다. 에러는 한 줄도 없었습니다.

(A) 설정 객체를 패치 | (B) 계측 모듈의 덮어쓰기 변수를 잡음 | |
|---|---|---|
흔한가 | 가장 흔한 방식 | 한 줄 더 만들어야 한다 |
reload 가 있으면 | 순서에 따라 깨진다 — 재적재된 객체는 패치를 모른다 | 산다 — 재적재 대상이 아니다 |
깨졌을 때 신호 | 없다. 통과한다 | — |
해결은 잡는 자리를 옮기는 것이었습니다. 계측 모듈 안에 재적재와 무관한 모듈 수준의 덮어쓰기 변수를 하나 두고, 픽스처가 그것만 잡습니다. 설정 모듈이 몇 번 reload 되든 계측 모듈은 그 대상이 아닙니다. (A) 가 틀린 방식은 아닙니다. reload 하는 테스트가 하나라도 생기면 틀린 방식이 되는데, 그게 생겨도 아무 신호가 없다는 것이 문제입니다.
더 부끄러운 쪽은 따로 있습니다. 같은 함정을 파이프라인 테스트 파일이 이미 주석으로 적어 두고 있었습니다. 저는 그 파일을 열지 않았고, 같은 곳에 또 빠졌습니다. 주석은 그 파일을 여는 사람에게만 닿습니다. 규칙을 적는 것으로는 안 막히고, 빠질 자리를 없애야 막힙니다.
격리 확인도 틀렸다
격리가 됐는지를 git status 로 봤습니다. 깨끗했습니다. 그런데 바로 직전에 누계 DB 파일을 .gitignore 에 넣은 뒤였습니다. 안 보이는 것이 당연했습니다. 그 화면은 파일이 안 생겼다는 것을 증명하지 못합니다. 파일이 실제로 존재하는지로 다시 확인했습니다.
「없다」를 확인하려면 없음을 직접 보여주는 신호가 필요합니다. 파일 존재 여부, 행 수, 호출 카운터 같은 것입니다. 무언가에 가려진 채 조용한 화면은 「아직 안 봤다」와 「없다」를 구분하지 못합니다. 같은 날 배포 확인에서도 두 번 더 같은 종류의 신호를 골랐습니다.

사건 2 — 폴백을 붙이자 테스트가 돈을 썼다
한도가 차면 다른 키의 모델로 한 번 더 시도하는 폴백을 붙였습니다. 한도 초과 시나리오를 타는 테스트가 있었는데, 폴백 쪽 기능을 가짜로 막지 않았습니다. 그 테스트가 네트워크를 타고 실제 제미나이를 호출했고, 실제 크레딧을 썼습니다.
그 크레딧은 엿새 전에 말라서 문서 색인이 멈춘 적이 있는 바로 그 크레딧입니다. 테스트는 통과했습니다. 실패한 것이 아니라 진짜 답을 받아 온 것이라 오히려 더 잘 통과했습니다.
폴백 경로가 에러 없이 잘못 도는 것은 이번이 처음이 아닙니다. 정상 경로보다 덜 밟히는 길이라 문제가 로그에 남지 않습니다.
폴백 기능도 테스트에서 가짜로 막았습니다. 한도 경로 테스트가 이제 네트워크를 타지 않습니다. 같은 날 후속 기준으로 엔진 테스트는 739건입니다. 그중 하나라도 돈을 쓰면 아무도 전체를 돌리지 않게 됩니다. 안 돌리는 테스트는 없는 테스트입니다.

테스트는 가짜로, 검증은 실물로
두 사건에서 원인을 찾는 데 든 시간은 각각 짧았습니다. 비용이 큰 쪽은 모르고 며칠 돌렸을 때입니다. 실측 표 오염은 되돌리기 어렵고, 크레딧은 회수가 안 됩니다.
그래서 순서를 바꿨습니다. 계측(파일·DB 쓰기), 폴백(네트워크·과금), 알림(발송)처럼 부수 효과가 밖으로 나가는 코드는 기능보다 격리를 먼저 세웁니다. 순서를 지키지 않으면 첫 테스트 실행이 곧 사고입니다. 외부 창구는 테스트에서 기본 차단이고, 실물 검증은 명시적 스위치로만 켭니다. 「네트워크 테스트 금지」가 아니라 「기본이 차단」이 규칙입니다.
실제 창구를 치는 것이 목적인 통합 테스트는 예외입니다. 다만 기본 실행에서 빠지고 스위치로만 돕니다. 그래야 「돈 쓰는 테스트」가 아니라 「돈 쓰기로 하고 돌린 검증」이 됩니다. 같은 날 계측에 빠진 경로를 메울 때는 실제 창구로 한 번 돌려 확인했습니다. 테스트와 검증은 역할이 다릅니다. 섞이면 어느 쪽도 믿을 수 없습니다.
반대 방향의 유혹도 있었습니다. 테스트가 빈 값을 넘겨 터지자 함수를 관대하게 무르고 싶어졌습니다. 그러면 전부 잘못 분류된 채 조용히 통과합니다. 통과시키는 수단이 격리가 아니라 관문 완화라면 그것은 격리가 아닙니다.
테스트가 진짜 데이터에 손을 댄 것은 이번이 처음이 아닙니다. 그때는 운영 DB 였고, 이번에는 실측 표와 크레딧이었습니다.
점검할 것
테스트가 쓰는 파일·DB 경로가 운영 경로와 다른가. 설정값이 아니라 파일 존재로 확인했는가
외부 창구(LLM·결제·발송)가 테스트에서 기본 차단인가. 폴백 경로까지 막았는가
격리 패치가 잡는 대상이 재적재·재생성돼도 살아남는 자리인가
「없다」를 확인하는 신호가 없음을 직접 보여주는가. 가려진 화면이나 조용한 로그가 아닌가
실물 검증은 명시적 스위치로만 켜지는가
통과가 검사가 되려면
두 사건 모두 초록불이었습니다. 「테스트가 정말 밖으로 나가지 않았다면 무엇이 달라 보였을까」를 먼저 적어야 했습니다. 저장소에 파일이 생기지 않고, 폴백 호출 카운터가 0이고, 크레딧 잔량이 돌리기 전과 같아야 합니다. 그 셋 중 하나도 안 본 통과는 아직 검사가 아닙니다.
연작 「실패할 수 없는 검사」
이 글은 다섯 편 중 3편입니다. 초록불·성공 표시·통과한 테스트가 실은 아무것도 검사하지 않던 사례를 이어서 다룹니다.