프로덕션 로그에 가짜 행(synthetic rows)을 기록하는 동안 통과한 Python 테스트
요약
개발자가 작성한 테스트 스위트가 실제 프로덕션 로그 파일에 가짜 데이터를 기록하는 부작용(side effect)을 발견했습니다. 이 문제는 테스트가 검증하지 않은 외부 파일 쓰기 동작이 '진실'인 것처럼 오염시킬 수 있음을 보여줍니다. 글쓴이는 정확한 속성 지정(exact attribution)을 사용하여 가짜 행들만 선택적으로 제거하고, 실제 관측치들을 안전하게 보존하는 방법을 제시합니다.
핵심 포인트
- 테스트는 단언된 동작에 대해서만 검증하며, 외부 파일 쓰기 같은 부작용은 무시할 수 있다.
- 가짜 데이터를 삭제할 때는 데이터 손실 위험이 크므로 신중해야 한다.
- 정확한 속성 지정(exact attribution)을 활용하여 오염된 로그에서 가짜 행만을 안전하게 제거하는 방법을 사용했다.
원래 hexisteme notes에 게시됨.
제 댓글 파이프라인은 랜딩 관찰(landing observations)의 작은 프로덕션 로그를 유지합니다. 제가 올린 답글이 실제로 페이지에 나타났는지 확인할 때마다, 어떤 기사인지, 어떤 댓글인지, 답글이 도착했는지 여부, 그리고 몇 번째 시도였는지를 담은 JSON 한 줄을 추가합니다. 이전 노트인 I Rejected the Same dev.to Reliability Fix on One Path and Shipped It on Another에서는 답글 재시도 예산에 대한 향후 검토를 이 댓글 랜딩 관찰과 연결했습니다. 이것들은 실제 배달에 대한 증거가 되어야 합니다.
이 파이프라인을 수정하는 과정에서, 저는 전체 오프라인 테스트 스위트들이 이 로그 파일에 쓰기(writing)를 하고 있다는 것을 발견했습니다.
무슨 일이 일어났는가
watcher, autoreply 및 reply 스위트는 Python의 unittest를 사용합니다. 그중 일부 테스트들은 랜딩 확인을 수행합니다: 임시 HTTP 데이터를 observer에게 공급하고 그것이 무엇을 결정하는지 단언(assert)합니다. 가짜 데이터는 일시적이었습니다. 출력 경로는 그렇지 않았습니다. 이 테스트들에서, observer는 여전히 실제 landing-observations.jsonl에 쓰기를 했습니다.
첫 번째 전체 기준선 실행은 30개의 합성 행을 추가했고, 그 실행은 176개의 테스트가 'OK'라고 보고했습니다. 나중에 피처(fixtures)들이 격리되기 전의 전체 실행에서는 더 많은 행이 추가되었습니다. 최종 합계는 122개의 합성 행이었습니다. 중간 요약본에서는 이 숫자를 145개로 제시했었습니다. 타임스탬프와 대상을 기준으로 보존된 행들을 조정해 보니 122개가 나왔고, 이것이 제가 신뢰하는 숫자입니다.
통과했다는 것이 아무것도 알려주지 않은 이유
테스트가 통과한다는 것은 그 단언들이 성립할 때입니다. 이 단언들은 랜딩 확인이 무엇을 반환했는지에 관한 것이었습니다: 도착했는지 여부, 시도가 계산되었는지 여부. 그중 어느 것도 코드가 가는 도중에 다른 것을 건드렸는지 물어보지 않았습니다. 테스트의 샌드박스 외부 파일에 한 줄을 쓰는 것은 아무것도 실패시키지 않으며, 통과/실패는 심지어 그것을 기록하지도 않습니다.
이것이 전체 메커니즘입니다. 테스트 스위트들은 테스트한 동작에 대해서는 정확했고, 테스트하지 않은 부작용(side effect)에 대해서는 침묵했습니다. 그리고 그 부작용은 '진실'일 역할만 하는 파일에 기록되었습니다.
상황을 악화시키지 않으면서 정리하기
이러한 로그를 정리하는 빠르고 파괴적인 방법들이 있습니다: 파일을 잘라내거나(truncate), 테스트가 실행된 시간 범위의 모든 행을 삭제하거나, 더 오래된 백업본을 실시간 파일 위에 덮어쓰는 것입니다. 이들 각각은 실제 관측치들을 제거할 위험이 있습니다. 이는 같은 시간 범위에 기록되었거나, 백업이 생성된 이후에 기록된 것일 수 있습니다.
하지만 여기서는 정확한 속성(exact attribution)을 사용했습니다. 가짜 행들은 인식 가능한 고정 값(fixture values)을 가지고 있었고, 그들의 정확한 타임스탬프, 대상(targets), 그리고 각 조합이 나타난 횟수를 통해 일치시킬 수 있었습니다. 이 행들은 먼저 원래의 JSON 형태로 복구 백업에 보존되었습니다. 그런 다음 정확히 그 행들만 로그에서 제거했습니다. 기존의 623개 프로덕션 행은 그대로 유지되었고, 파일 크기는 97,094바이트로 끝났습니다.
만약 이 행들이 정확하게 속성 지정이 불가능했다면, 올바른 조치는 그들을 남겨두고 로그의 알려진 한계(known limitation)로서 오염을 기록하는 것이지, 추측해서 제거하는 것이 아니었을 것입니다.
해결책: 먼저 격리하고, 나중에 보호하기
주요 해결책은 격리(isolation)입니다. 이제 감시자(watcher)와 자동 응답 테스트는 관측 파일(observation file)을 임시 고정 위치(temporary fixture location)로 리디렉션하여, 테스트 대상 코드가 폐기 가능한 곳에 쓰도록 합니다. 응답 스위트의 도착 테스트(landing tests)는 감시자를 호출하는 대신 모킹(mocking)합니다. Python의 tempfile 라이브러리는 이러한 폐기 가능한 위치를 위한 표준 도구입니다. 패턴은 대략 다음과 같습니다 (개념적이며, 이름은 예시일 뿐 스위트의 실제 이름은 아닙니다):
class LandingCheckTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
...
만약 pytest를 사용한다면, tmp_path 고정값이 동일한 목적을 수행합니다. 이 스위트들은 unittest 기반이므로, 그것을 사용하지는 않습니다.
두 번째 부분은 처음부터 이를 잡아냈을 방어 장치(guard)입니다. watcher 스위트에는 테스트 전후의 실제 로그 바이트를 비교하는 teardown assertion이 있습니다. 외부 래퍼는 전체 스위트 실행과 공유 체크 주변에서도 동일한 비교를 수행합니다. 바이트 비교는 거칠지만, 그것이 요점입니다. 어떤 코드 경로가 쓸지 알 필요 없이, 파일이 변경되지 않았다는 것만 알면 됩니다.
사후의 방어 장치는 쓰기를 되돌릴 수 없습니다. 조용한 부작용(silent side effect)을 실패하는 실행으로 만듭니다. 격리(Isolation)야말로 쓰기를 막는 것입니다.
검증 (Validation)
수정 후, 223개의 watcher, autoreply 및 reply 테스트가 모두 통과했으며, 프로덕션 로그는 바이트 단위로 변경되지 않아 크기는 97,094바이트였습니다.
공유 오프라인 하네스(shared offline harness)는 다섯 가지 컴포넌트에 걸쳐 검사를 실행합니다. 이 하네스는 203개의 테스트를 통과했습니다 (watcher 140개, telemetry 42개, browser 7개, grammar 14개). 여기에 캐노니컬 코드가 존재하는 위치에 대한 별도의 확인이 추가되었습니다. 223개와 203개는 watcher 테스트를 포함하기 때문에 중복되며, 따라서 합산하지 않았습니다.
Claude Code, Codex 및 agy 모두 프로젝트 외부의 깨끗한 디렉토리에서 해당 공유 검사기를 실행했습니다. 이 검사기는 모델 호출을 하지 않으며 아무것도 게시하지 않습니다. 이것이 실행하는 모든 것은 오프라인이며 합성(synthetic)적이므로, 실제 브라우저 세션이 프로덕션에서 어떻게 작동하는지에 대해서는 아무것도 말해주지 않습니다.
현재 제가 확인하는 것 (What I check now)
바이트 방어 장치는 제가 이제 알게 된 하나의 파일을 커버합니다. 테스트가 쓸 수 있는 다른 경로는 찾지 못합니다. 따라서 실제 I/O를 건드리는 모든 스위트에 대해 제가 던지는 질문은 단순히 통과했는지 여부뿐만 아니라, 무엇을 썼고 어디에 썼는가입니다. 다른 결정들이 읽는 로그의 경우, 답은
더 많은 노트는 hexisteme.github.io/notes에서 확인하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기