기술

실패하지 않는 테스트가 운영 벡터 저장소에 유령 임베딩을 남기고 있었습니다

2026.09.2210분 읽기

테스트가 운영을 친 이야기를 이 블로그에 전에도 썼습니다. 테스트용 스키마 자동생성 설정 한 줄이 운영 배포에 실려 테이블을 통째로 지운 사고였습니다.

지난 편은 테스트가 운영 그래프의 노드 116개를 지운 사고였습니다. 둘 다 지우는 쪽이라 피해가 눈에 보였습니다. 이번 글은 또 다른 시스템에서 난 같은 계열의 사고인데, 이번에는 아무것도 지워지지 않았습니다. 대신 통과하는 테스트가 운영 벡터 저장소에 유령 임베딩을 쌓고 있었습니다. 에러가 없고 없어진 것도 없어서, 아무도 모르는 채로 지나갔습니다.


안건을 정리하다 나왔습니다

운영 이벤트를 과거 기록과 그래프로 잇는 내부 도구를 여러 차례에 걸쳐 작업하는 동안, 본 흐름과 상관없는데 이상하다 싶은 것들을 따로 적어뒀습니다. 본 작업이 일단락된 날 그 안건들만 모아 정리했습니다. 그중 하나가 테스트 파일이었습니다.

그 파일은 가짜 모듈을 파이썬의 모듈 레지스트리인 `sys.modules`에 꽂고, 대상 모듈을 레지스트리에서 빼낸 뒤 다시 임포트하는 패턴을 쓰고 있었습니다. 같은 파일에 7곳이었습니다. 테스트는 전부 통과하고 있었습니다.

# 옛 패턴 — 같은 파일에 7곳
monkeypatch.setitem(sys.modules, 'pkg.dep', fake_dep)   # 가짜 의존 모듈을 레지스트리에 꽂는다
monkeypatch.delitem(sys.modules, 'pkg.target')          # 대상 모듈을 레지스트리에서 뺀다
import pkg.target                                        # 다시 임포트 → 새 모듈 객체

# 테스트가 끝나면 sys.modules 는 원래대로 돌아간다
# pkg.target 속성은 새 객체를 그대로 든다 → 이름은 같은데 다른 객체

문제는 그 뒤에 도는 다른 테스트들이었습니다. 다른 테스트가 가짜를 주입하면 죽은 쪽에 꽂혔습니다. 진짜 코드는 주입 없이 그대로 실행돼 운영 벡터 저장소와 상용 임베딩 모델 API를 실제로 호출했습니다. 테스트 데이터로 만든 벡터, 즉 실제 이벤트에 대응하지 않는 유령 임베딩이 운영 저장소에 기록된 적이 있었습니다.


같은 이름인데 서로 다른 객체

왜 죽은 쪽에 꽂히는지는 pytest의 monkeypatch 같은 임시 치환 기능이 무엇을 되돌리는지에 달려 있습니다. 치환 컨텍스트를 빠져나올 때 되돌아가는 것은 모듈 레지스트리의 항목뿐입니다. 재임포트로 만들어진 모듈 객체를 부모 패키지가 속성으로 들고 있는데, 그 속성은 되돌아가지 않습니다.

결과적으로 모듈 하나에 객체가 둘이 됩니다. 레지스트리에 등록된 원래 모듈과, 패키지 속성으로 얻어지는 재임포트본입니다. 이름은 같은데 서로 다른 객체이고, 재임포트본은 아무도 실행하지 않는 고아 모듈입니다.

이 상태에서 다른 테스트가 패키지 속성 경로로 가짜를 주입하면 고아 모듈에 꽂힙니다. 반면 실제로 호출되는 함수는 원래 모듈의 것입니다. 원래 모듈에는 가짜가 꽂히지 않았으니 진짜 의존을 그대로 씁니다. 주입은 성공했고 테스트도 통과하는데, 실행되는 것은 주입되지 않은 진짜 코드입니다.

같은 이름표를 단 사람이 둘 있는 셈입니다. 대리인을 한쪽 옆에 세워뒀는데 심부름은 다른 쪽이 나갑니다. 대리인이 서 있는 것만 확인하면 아무 이상이 없어 보입니다. 심부름이 실제로 어디로 갔는지는 심부름을 받은 쪽의 장부를 열어야 보입니다.

실패하지 않는 테스트가 운영 벡터 저장소에 유령 임베딩을 남기고 있었습니다 그림 1


그 패턴을 쓴 근거는 이미 죽어 있었습니다

애초에 왜 재임포트까지 했는지 근거를 찾아봤습니다. 그 모듈을 임포트하면 임포트 체인이 DB와 벡터 저장소에 붙어버린다는 것이었습니다. 진짜 모듈을 로드하지 않으려고 가짜를 먼저 꽂고 다시 임포트하는 우회였습니다.

실측해보니 그 전제는 성립하지 않았습니다. 해당 모듈은 커넥션을 하나도 열지 않고 1.5초에 임포트됐습니다. 대상 함수도 의존 모듈을 함수 안에서 임포트하고 있어서, 모듈 속성만 바꾸면 격리는 충분했습니다. 한때는 사실이었을 근거가 코드가 바뀌는 사이 유통기한을 넘겼고, 우회만 남아 있었습니다. 우회 패턴에 왜 필요한지가 같이 적혀 있지 않으면 근거가 죽었는지도 알 수 없습니다.


뚜껑을 덮을 것인가, 패턴을 걷어낼 것인가

갈림길은 둘이었습니다.

A. 뒷정리를 붙인다

B. 패턴을 걷어낸다

무엇을 하나

컨텍스트를 나올 때 패키지 속성까지 손으로 되돌린다

재임포트를 없애고 모듈 속성만 교체한다

손이 드는 정도

적다

7곳을 다시 쓴다

남는 것

같은 이름 다른 객체라는 지뢰는 그대로

지뢰 자체가 없어진다

성립 조건

없다

임포트가 커넥션을 열지 않아야 한다

B를 골랐습니다. A는 패턴을 유지한 채 뚜껑만 덮는 안이라, 다음에 누가 비슷한 컨텍스트를 하나 더 만들면 같은 지뢰가 다시 생깁니다. B에는 성립 조건이 있었는데, 그 조건을 실측으로 확인했으니 걸릴 것이 없었습니다.

# 걷어낸 뒤 — 재임포트 없이 모듈 속성만 교체한다
monkeypatch.setattr(pkg.dep, 'client', fake_client)

다만 B만으로는 부족했습니다. 7곳을 걷어내도 누군가 옛 패턴을 다시 쓰는 것은 막지 못합니다. 그래서 옛 패턴을 재현하면 실제로 실패하는 감시 테스트를 함께 넣었습니다. 옛 패턴을 넣으면 빨간불이 켜지는 것까지 확인했습니다. 전체 회귀는 668건이 통과했습니다.

실패하지 않는 테스트가 운영 벡터 저장소에 유령 임베딩을 남기고 있었습니다 그림 2


같은 날 오후에 두 건이 더 나왔습니다

오전 정리를 끝내고 오후 작업으로 넘어갔는데, 거기서 같은 계열이 또 나왔습니다. 더는 쓰이지 않는 함수를 패치하고 있던 테스트 2건이 실제 그래프 DB를 치고 있었습니다. 패치 대상이 어긋난 채 테스트는 초록이었고, 실물은 그대로 호출되고 있었습니다.

이쯤 되면 한 번의 실수가 아니라 종류입니다. 설정 한 줄이 원인인 적도 있었고, 이번에는 패치가 고아 모듈에 꽂힌 것과 죽은 함수에 꽂힌 것이 하루에 둘 나왔습니다. 원인의 모양은 전부 다른데 결과는 하나입니다. 테스트가 초록인 채로 운영 저장소를 칩니다.

모양이 다른 것이 하나 더 있습니다. 지난 편까지는 지우는 쪽이라 발견됐습니다. 이번 편은 쌓는 쪽이라 아무도 몰랐습니다. 없어진 데이터는 누군가 찾다가 없다고 말하지만, 더해진 유령 데이터는 아무도 없다고 말하지 않습니다. 조용한 것은 피해가 없어서가 아니라 관측 지표가 없어서입니다.

개별 정정으로는 이 반복이 끝나지 않는다고 봤습니다. 그래서 테스트가 운영 저장소에 닿는 경로 자체를 공용 테스트 설정에서 구조적으로 막는 안을 다음 안건으로 남겼습니다.

실패하지 않는 테스트가 운영 벡터 저장소에 유령 임베딩을 남기고 있었습니다 그림 3


실물을 치느냐가 아니라 칠 작정이었느냐

운영을 못 치게 막는 것이 항상 옳지는 않습니다. 외부 의존을 실제로 치는 통합 테스트는 필요합니다. 이번 문제는 통합 테스트가 있어서가 아니라, 단위 테스트가 자기도 모르게 통합 테스트가 되어 있어서 생겼습니다. 경계는 실물을 치느냐가 아니라 칠 작정이었느냐에 긋는 편이 맞습니다.

테스트가 초록이니 격리됐다는 추론도 성립하지 않습니다. 목과 스텁을 쓰는 어느 언어에서든 패치 대상이 어긋나면 테스트는 통과하면서 실물을 칩니다. 격리는 패치가 잘 꽂혔는지로 확인하는 것이 아니라 네트워크 경계에서 강제하는 편이 안전합니다.


점검 목록

단위 테스트가 도는 동안 열리는 네트워크 커넥션이 0개인지 확인한 적이 있는가

목과 스텁이 꽂힌 객체와 코드가 실제로 실행하는 객체가 같은 객체인가

테스트에 든 우회 패턴에 이유가 적혀 있고, 그 이유가 지금도 성립하는가

운영 저장소에 테스트 데이터가 섞였을 때 알아챌 지표가 있는가

정정한 뒤에 옛 패턴을 넣으면 실패하는 테스트가 남아 있는가


남긴 것은 고친 값이 아니라 검사입니다

이날 남긴 자산은 걷어낸 7곳이 아닙니다. 옛 패턴을 재현하면 실패하는 감시 테스트 하나입니다. 걷어낸 자리는 시간이 지나면 잊히고, 감시 테스트는 다음 사람이 같은 패턴을 쓰는 순간 빨간불로 답합니다.

에러를 내는 버그는 언젠가 잡힙니다. 조용히 성공하는 결함은 사람이 못 잡습니다. 설정 한 줄, 지워진 노드, 유령 임베딩은 다른 자리에서 다른 모양으로 났지만 초록불을 믿었다는 점은 같았습니다. 초록불이 거짓말할 수 있다는 전제로 기계가 잡게 장치를 붙이는 것이 이 계열을 끝내는 방법입니다.


연작 「초록불이 거짓말한다」

이 글은 다섯 편 중 2편입니다. 테스트가 전부 통과했는데 실제로는 아무것도 지켜주지 않았거나 운영을 부순 사례를 이어서 다룹니다.

#테스트격리#목과스텁#모듈임포트#벡터저장소#조용한실패#감시테스트#pytest