사건번호 충돌은 생일 역설이었고 버그는 열쇠에 있었다

26년 08월 22일

오염인 줄 알았던 행의 진실

법원경매 권리분석 프로젝트에서 지오코딩 오염을 정리하던 중 지역 일관성 가드를 뚫고 돌아온 이상한 행 하나를 추적했다.

주소 필드는 인천을 가리키는데 물건 객체 필드는 부산을 가리키고 있었다.

같은 행에 지역이 두 개 공존하는 구조다.

처음에는 그동안 정리하던 지오코딩 오염의 연장선이라고 생각했다.

PNU는 물건 객체와 정확히 일치했고 토지 데이터는 법원 원문에 충실한 상태였다.

즉 잘못 끼워진 것은 주소 텍스트 필드 하나였다.

원문 API를 다시 조회해서 진실을 확인했다.

사건번호가 같은 형태의 번호가 두 법원에 각각 실존했다.

오염이 아니라 크롤러의 식별자 결함이었다.

생일 역설

왜 다른 법원에 같은 사건번호가 존재하는가.

타경 번호는 법원마다 독립적으로 채번한다.

전국 60개 법원이 연간 수만 건의 사건 번호를 각자 발급하면 우연한 충돌은 통계적으로 자연스럽게 발생한다.

방에 23명만 있어도 생일이 겹칠 확률이 절반을 넘는다는 그 원리다.

실제로 측정해보니 두 개 이상 법원에 걸치는 사건번호가 1,756건이었다.

여기까지는 세상의 원리일 뿐 버그가 아니다.

우리의 버그는 번호가 충돌한 것이 아니라 충돌을 구분할 열쇠를 들고 다니지 않은 것이었다.

크롤러의 upsert가 법원 코드 없이 사건번호와 물건번호만으로 매칭을 하고 있었다.

그래서 서로 다른 법원의 별개 사건이 같은 행으로 병합됐다.

병합 흔적은 933행이었다.

가격이 증거였다

병합된 933행이 정말 별개 사건인지 증명할 방법이 필요했다.

법원별 최저 입찰가를 대조해봤다.

933행 중 932행에서 가격이 불일치했다.

사실상 전부 별개 사건의 병합이라는 뜻이다.

같은 사건이면 최저가가 같아야 하기 때문이다.

이 숫자 하나로 별개 사건이라는 결론이 났고 동시에 복구 판별 키도 결정됐다.

가격이 다르면 지금 행에 어느 법원의 데이터가 박혀 있는지 역산할 수 있다.

흥미로웠던 건 크롤 아이템 원본 테이블이었다.

처음부터 법원 코드를 포함한 복합 키로 정확하게 설계되어 있었다.

망가진 지도와 정상 지도가 같은 DB에 공존하고 있었으니 복구 재료는 이미 안에 있었다.

5단계 분리 수술

수술은 다섯 단계로 진행했다.

프로덕션을 덤프해서 격리된 로컬 DB에 복원하고 로컬에서 수술을 완주한 뒤 실제 크롤로 검증하고 마지막에 프로덕션을 통째로 교체하는 순서다.

로컬 수술에서는 사건번호에 법원 코드를 합쳐 3키 식별 체계를 만들고 933개 병합 행을 분리했다.

911행은 가격 매칭으로 어느 법원 소속인지 판별했다.

나머지 22행은 판별이 모호해서 추측으로 배선하지 않고 별도 마킹 후 법원 원문을 다시 조회해 정본으로 복원했다.

추측 배선 금지가 원칙이었다.

프로덕션 교체 전에 로컬에서 크롤러를 실주행해서 신규 유입되는 물건이 전부 깨끗한지도 확인했다.

최종적으로 프로덕션 17,425행에서 오염 클래스 전부 0을 달성했다.

법원 코드 3키에 UNIQUE 제약까지 걸어서 같은 병합이 구조적으로 재발할 수 없게 만들었다.

조회가 비어있다는 보고의 함정

수술 중에 빈 주소 838건을 회수하려고 재조회 작업을 함께 돌렸다.

여기서 또 다른 버그가 나왔다.

법원 응답의 물건 일련번호가 숫자 리터럴로 오는데 느슨한 문자열 파서가 예외를 던지지 않고 조용히 null로 드롭하고 있었다.

물건 매칭이 15건 중 0건 성공하는 현상으로 나타났다.

키가 없어서 그런 건 아니었다.

값의 타입이 다른 문제라서 grep으로는 잡히지 않는 클래스였다.

원본 응답 덤프와 매핑 결과를 나란히 놓고 봐야만 보이는 종류의 버그다.

파서를 숫자와 문자열을 모두 받도록 고치니 매칭이 5건 중 5건으로 반전됐고 실배치에서 838건 중 837건의 주소를 회수했다.

느슨한 타입 파서는 실패를 예외가 아닌 null로 만들어서 매칭 체인의 상류에서 소리 없이 전파된다.

경계 테스트에 숫자로 오는 시퀀스 케이스를 넣어야 한다는 교훈을 남겼다.

같은 날 고친 다른 무한 루프

같은 날 에이전트 검증 루프에서 검증자 디스패치가 무한 정체되는 버그도 고쳤다.

원인을 뜯어보니 검증자를 한 명씩 따로 호출하는 형태가 승인 게이트를 우회하고 있었다.

게이트를 통과하지 못한 호출은 검증 판정을 수집하는 환경 변수를 상속받지 못했다.

매 라운드 수집 결과가 0이었고 루프는 끝없이 돌았다.

판정을 등록하는 도구는 지시된 정확한 디스패치 형식에서만 활성화된다는 것도 확인했다.

도구가 보이지 않으면 내 호출 형식부터 지시문과 대조해봐야 한다는 교훈이다.

게이트를 순수 함수로 다시 짜서 우회 경로를 원천 차단하고 회귀 테스트 23개를 모두 통과시켰다.

남는 생각

이번 작업을 관통한 원리는 하나였다.

외부 시스템의 자연키는 발급 주체 스코프 안에서만 유니크한 경우가 흔하다.

전역 유니크를 가정하고 upsert 키를 설계하기 전에 실측으로 검증해야 한다.

행 내 필드 간 불일치는 파생 오염보다 상류 식별자 결함을 가리키는 경우가 많다.

그리고 비어있는 조회 결과를 보고 원인을 단정하기 전에 원본 덤프를 먼저 봐야 한다.

17,425행의 데이터가 오염 0건 상태로 안착했으니 이제 관찰만 남았다.