8분

네 번 모두 테스트는 초록이었다

지금까지 쓴 네 편의 심층 분석에는 공통점이 있습니다. 있다고 믿었던 것이 실제로는 없었고, 그 사실을 알려 준 것은 매번 테스트가 아니라 운영이었습니다.

  • 회고
  • 테스트
  • 관측
  • 운영
실행자와 작업 종류, 시각이 기록된 OpenMake 감사 로그 화면
무엇이 실제로 일어났는지는 결국 기록이 말합니다. 네 번 모두, 기록을 확인하기 전까지는 아무도 몰랐습니다.

SHIPPED / EVIDENCE

이 글에서 다루는 것

대용량 업로드, MCP 격리, 실행 계측, 추론 백엔드 이전. 네 건의 작업을 다시 놓고 보면 실패의 모양이 서로 달랐지만 구조는 같았습니다. 코드는 의도대로 동작했고 테스트도 통과했는데, 우리가 있다고 믿은 보장은 그 자리에 없었습니다.

돌아본 사례
4건
발견 당시 테스트
모두 통과
격리·계측 실측값
0
발견 경로
전부 운영

01

같은 실패가 네 가지 모양으로 나왔다

네 편을 쓰고 나서야 눈에 들어온 것이 있습니다. 네 건 모두 버그를 고친 이야기가 아니었습니다. 코드는 대체로 작성된 대로 동작했고, 유닛 테스트도 회귀 테스트도 통과한 상태였습니다.

문제는 다른 층에 있었습니다. 우리가 이 코드로 무엇을 보장했다고 믿었는데, 그 보장이 실제로는 성립하지 않았습니다. 그리고 네 번 모두 그 사실을 알려 준 것은 테스트가 아니라 실제로 돌려 본 결과였습니다.

네 가지가 서로 다른 종류의 빈틈이었기 때문에, 하나씩 다시 세워 보면 테스트가 무엇을 증명하고 무엇을 증명하지 않는지가 조금 더 분명해집니다.

02

첫째, 우리가 정한 상한이 실제 상한이 아니었다

에이전트 작업의 파일 첨부에는 애플리케이션이 정한 크기 상한이 있었고 그 상한은 잘 검증돼 있었습니다. 그런데 공개 요청은 애플리케이션에 도착하기 전에 프록시를 지납니다. 엣지에서 100MB에 걸려 잘리면 우리 쪽 검증도 진행 표시도 시작되기 전에 실패합니다.

테스트는 우리 코드의 안쪽만 봅니다. 요청이 코드에 닿기 전에 죽는 경우는 테스트의 시야 밖입니다. 이건 코드가 틀린 것이 아니라, 시스템의 경계를 코드 경계와 같다고 착각한 것이었습니다.

드러난 방식도 코드 밖이었습니다. 실제 서비스 주소로 130MB 파일을 보내 보고, 저장된 결과의 해시가 원본과 같은지 확인하고 나서야 전체 경로가 증명됐습니다.

03

둘째, 폴백이 조용히 정상 경로가 되어 있었다

외부 MCP 서버를 OS 수준에서 격리하는 기능을 만들면서, 도구를 쓸 수 없는 환경에서는 원래 명령을 그대로 실행하고 경고만 남기도록 했습니다. 격리가 안 된다고 서비스를 죽이지 않겠다는 판단이었고, 그 자체는 합리적입니다.

고른 도구가 리눅스 전용이고 운영 호스트가 macOS라는 사실이 그 판단을 뒤집었습니다. 예외 상황을 위한 폴백이 모든 요청에서 발동하고 있었습니다. 코드도 있고 테스트도 통과했지만 실제 격리는 0이었습니다.

테스트가 검증한 것은 게이트가 조건에 따라 열리고 닫힌다는 사실이었습니다. 운영 환경에서 그 게이트가 항상 닫혀 있다는 것은 검증 대상이 아니었습니다. 이건 버그보다 나쁩니다. 설계대로 동작한 결과라 아무도 이상하다고 느끼지 않기 때문입니다.

04

셋째, 계측이 실제로 지나가지 않는 길에 붙어 있었다

도구 결과가 얼마나 잘리는지를 고치기 전에 먼저 재자고 계측을 넣었습니다. 그 계측 자체는 정확하게 동작했습니다. 다만 샌드박스 작업의 도구들은 계측을 붙여 둔 실행 경로를 지나가지 않습니다.

그래서 숫자가 0이었습니다. 도구 결과 스텝은 두 건 남아 있는데 기록 시도는 한 번도 없었습니다. 0은 아무 일도 없었다는 뜻으로도 읽히고 아무것도 못 봤다는 뜻으로도 읽히는데, 이 둘을 구분할 방법이 대시보드에는 없었습니다.

이건 앞의 두 가지와 성격이 조금 다릅니다. 여기서 잘못된 것은 보장이 아니라 관측이었습니다. 관측이 틀리면 그 위에서 내리는 모든 판단이 함께 틀립니다.

05

넷째, 기계적 변환이 패턴 밖의 것을 남겼다

추론 백엔드를 옮길 때 호출부 100여 곳을 코드모드로 한 번에 이전했습니다. 설정 객체를 거쳐 읽는 형태는 전부 잡혔습니다. 환경변수를 직접 읽는 곳은 형태가 달라 그대로 남았습니다.

남은 코드는 컴파일도 되고 테스트도 통과합니다. 옛 백엔드 전용 경로를 부르고 그 응답 형식을 가정하는 코드가 조용히 살아 있어도, 그 경로를 실제로 타 보기 전까지는 아무 신호가 없습니다.

일괄 치환은 강력하지만 무엇을 놓쳤는지는 알려 주지 않습니다. 그날 저녁에 남은 것들을 따로 찾아 나선 것이 이 작업에서 제일 중요한 단계였습니다.

06

네 번 모두 같은 방식으로 드러났다

발견 경로를 나란히 놓으면 패턴이 선명합니다. 130MB 파일을 실제 서비스로 보내 봤고, 운영 호스트가 어떤 운영체제인지 확인했고, 브라우저로 채팅에서 작업까지 한 번 통과시켜 봤고, 코드모드를 돌린 뒤 남은 참조를 직접 뒤졌습니다.

전부 저비용입니다. 며칠 걸리는 조사가 아니라 한 번 실제로 돌려 보는 일이었습니다. 그런데 네 건 모두 그 한 번을 하기 전까지는 아무도 몰랐습니다.

테스트가 쓸모없다는 이야기가 아닙니다. 네 건 모두 테스트는 자기 일을 했습니다. 회귀를 막았고, 리팩터링을 안전하게 만들었고, 규칙이 깨지면 알려 줬습니다. 다만 테스트가 증명하는 것은 우리가 검증하겠다고 정한 것뿐이고, 여기서 틀린 것은 매번 그 정의 자체였습니다.

07

그래서 바꾼 것

세 가지 습관이 이 네 번을 거치며 자리 잡았습니다.

첫째, 조용한 폴백을 없앴습니다. 지금은 격리가 적용된 서버가 로그에 나열되고, 적용되지 않은 서버는 설정에 그렇게 적혀 있습니다. 상태가 눈에 보이지 않으면 그 상태가 잘못돼 있어도 알 수 없습니다.

둘째, 모른다는 것을 데이터로 남깁니다. 실행 스텝을 계획 노드에 귀속시킬 때 알 수 없는 것은 추정하지 않고 비워 둡니다. 값을 지어내면 그 지표를 근거로 쓰는 순간 판단이 오염됩니다.

셋째, 남기는 것에는 이유와 기한을 적습니다. 데이터베이스 제약에 묶여 못 바꾼 식별자도, 위험을 알면서 미룬 버전 고정도 커밋에 그렇게 적혀 있습니다. 그래서 하나는 열사흘 뒤에 정리됐고 다른 하나는 아직 남아 있다는 사실을 지금도 압니다.

08

이 회고가 잡지 못하는 것

위의 세 가지 습관도 사후 대응입니다. 네 번 다 문제가 생긴 뒤에 배운 것이고, 다음 빈틈이 이 셋 중 하나의 모양일 것이라는 보장은 없습니다.

지금도 열려 있는 것들이 있습니다. 업로드 프로토콜은 여전히 완료 단계에서 파일 체크섬을 받지 않아 바이트 동일성을 프로토콜 자체로는 증명하지 못합니다. MCP 서버 두 개는 호스트 설치 바이너리에 의존해 비격리로 돌고, 패키지 버전 고정은 한꺼번에 깨질 위험 때문에 미뤄 둔 상태입니다.

이것들을 적어 두는 이유는 해결했다고 말하지 않기 위해서입니다. 네 번의 공통점이 그것이었습니다. 문제는 우리가 모르는 곳이 아니라, 안다고 믿었던 곳에 있었습니다.

근거 자료

근거 자료

개발일지로 돌아가기