한 줄 요약
이번 주는 법원경매 분석 서비스의 데이터 오염 두 건을 복구하면서 보냈다. 한 건은 같은 사건의 형제 물건이 첫 물건의 명세서를 물려받는 캐시 키 버그였고, 다른 한 건은 아파트 단지 첫 건물의 표제부를 저장해서 0세대가 뜨던 동 매칭 버그였다. 원인은 달랐는데 결론은 하나로 수렴했다. 그른 값을 자신 있게 보여주는 것이 아예 없는 것보다 나쁘고, 복구는 새 스크립트를 만드는 것보다 검증된 기존 파이프라인을 다시 돌리는 쪽이 안전하다는 것.
숫자로 정리하면 오염 의심 902건 중 677건을 백필로 복구했고, 형제 오염은 4,741행의 크롤 큐 상태를 되돌려 야간 배치가 재수집하며 스스로 치유되게 했다.
202호에 102호 명세서가 붙어 있었다
시작은 사용자 제보였다. 정릉동 5층 아파트 15개 호실이 한 사건으로 나온 매물인데, 상단 주소는 202호인데 매각물건명세서 전문이 102호 것이었다. 감정가도 이상했다. 표시된 194,000,000원은 1번 물건(102호)의 값이었고, 202호의 진짜 감정가와는 달랐다. 이상한 점은 입찰현황이었다. 최저입찰가와 입찰현황 금액이 서로 다른데, 파고들어보니 입찰현황이 오히려 자기 물건의 올바른 값이었고 오염된 최저입찰가가 충돌하고 있었다.
한 행에 네 물건의 데이터가 섞여 있었다. 주소는 4번 물건 것, 가격과 명세서는 1번 물건 것, 매각목적물 감정가는 또 다른 물건 것.
원인은 4겹이었다
DB에서 같은 사건의 형제 행을 직접 비교하고, git 이력을 파서 옛 코드를 복원하고, 파이프라인 코드를 정독해서 원인을 확정했다. 4겹이었다.
첫째, 법원 API 응답의 물건번호가 숫자로 오면 스키마 검증에서 전부 버려져서 물건 매칭이 전량 실패했다.
둘째, 매칭 실패 시 disposalGoods[0], 그러니까 첫 물건으로 폴백하는 코드가 무조건 덮어쓰기를 했다.
15개 행 전부가 1번 물건의 감정가와 최저가와 명세서를 받아 적은 것이 이 경로다.
셋째, 202호 주소가 5~15번 행에 반복되어 있던 것은 법원 공고 데이터 자체의 결함으로 추정한다.
내 파이프라인은 지번만으로는 저런 호실 주소를 만들 수 없다는 소거법 근거다.
확인된 사실이 아니라 추정이라는 점은 구분해둔다.
넷째, 현재도 살아있던 진짜 문제였는데, 물건 상세 캐시의 키가 사건번호 단위였다.
요청은 물건 단위로 하는데 캐시 조회는 사건번호로 하니까, 같은 사건의 후속 물건이 첫 물건의 매각목적물과 감정 요약을 물려받았다.
법원 API는 사건 단위로 응답하고 DB는 물건 단위로 upsert하는 이중 구조인데, 캐시 키가 데이터 단위와 일치하지 않으면 이런 일이 생긴다. 사건 캐시에 물건 단위 응답을 넣으면 형제가 오염된다.
활성 물건의 18퍼센트가 오염
형제 오염이 이 사건 하나인지 확인하니 아니었다. 오염 신호 합집합으로 4,444행, 778사건이었다. 진행 중인 물건 24,168건의 18.0%다.
이 과정에서 오탐 방지 검증을 하나 통과시켰다. 지분 공동매각은 형제 물건이 데이터를 공유하는 게 정상인 케이스인데, 형제 간 지분 값이 다른 경우가 0건이라서 복사 패턴과 정상 공유를 구분할 수 있었다.
SQL로 고치지 않고 큐만 되돌렸다
코드 수정은 테스트 454건 통과로 끝냈는데, 코드를 고친 것과 과거 데이터가 고쳐지는 것은 별개 문제였다. 공고 변동이 없으면 재수집을 스킵하는 구조라, 수정된 코드가 재수집 경로를 한 번도 안 탄 상태로 오염 데이터가 남아 있었다.
복구 방법 후보는 세 가지였다.
첫 번째, SQL로 오염 값을 직접 UPDATE한다. 기각했다. 파이프라인이 공고 변동 시 다시 덮어쓰는 구조라서, SQL로 고친 값이 다음 배치에서 원래 오염 경로로 되돌아올 수 있다. 데이터와 쓰기 주체가 충돌한다.
두 번째, 복구 전용 백필 스크립트를 새로 짠다. 이것도 기각했다. 오염이 애초에 본 파이프라인과 다른 경로의 로직 어긋남에서 생겼다. 복구용 별도 코드는 그것만의 버그 위험을 다시 만든다. 기존 파이프라인은 요청 게이트, 차단 자가회복, 원자적 저장 같은 검증된 안전장치를 이미 갖고 있다.
세 번째, 크롤 큐의 상태만 되돌린다. 이걸 골랐다. 크롤 아이템 테이블에는 올바른 물건별 값이 살아 있어서 복구 소스 역할을 한다. 완료 상태 스티커를 떼는 SQL 한 줄로 되돌리면 기존 야간 배치가 재수집하면서 알아서 고친다. 전체 재수집 4,275행과 가격만 갱신하는 빠른 경로 481행, 합쳐서 4,741행을 플립했다. 재수집이 끝나면 상태가 자동으로 완료로 돌아오니까 이 복구 트리거는 회복과 동시에 저절로 사라진다.
순서 조건이 하나 있었다. 구 코드가 살아있는 상태에서 플립하면 재오염이다. 배포가 끝난 뒤에 플립하는 순서를 지켰다.
첫 백필은 로컬 복제본에 가 있었다
표제부 오염 쪽 백필을 하고 완료 보고를 했는데, 교차검증에서 뒤집혔다.
첫 백필이 프로덕션이 아니라 db:pull로 만든 개발 복제본에 기록되어 있었다.
SSH 터널로 보이는 연결과 로컬 복제본의 포트가 같아 보여서 착각했다.
Cloud SQL 프록시라는 아예 다른 연결 경로로 직접 조회해서 세 가지로 확인했다. 백필했다는 필드가 프로덕션에서 원본값 그대로인 값 불일치, 프로덕션에 타임스탬프 변화가 없는 쓰기 흔적 부재, 변화가 로컬 복제본에만 있는 카디널리티 대조.
동일 로직을 진짜 프로덕션에 다시 실행해서 3단계 검증으로 통과시켰다. 이중 실행이 무해했던 이유는 백필 스크립트를 멱등하게 설계해뒀기 때문이다. 재실행해도 안전한 설계가 잘못된 대상에 실행하는 사고까지 흡수해줬다. 프로덕션 쓰기 보고는 내용을 검증하기 전에 연결 대상이 무엇인지부터 확인한다는 원칙을 남겼다.
지우는 것도 필드 단위로
표제부 백필은 902건 중 677건을 복구하고 111건이 남았다. 남은 111건을 주소 층수와 법원 공고 원문 대조로 4클래스로 분류했다. 층수가 정면으로 모순되는 명백 오염 30건, 동 매칭은 성공했는데 세대수만 0인 30건, 등기부상 업무시설이라 분류기가 오분류한 오피스텔형 44건, 애초에 의심 기준이 오탐이었던 정상 7건.
여기서 통째로 비우는 선택지를 기각하고 필드 단위 NULL을 택했다. 세대수만 0인 30건은 층수 정보가 정확하니까 세대수 필드만 지우고 층수는 살려야 한다. UI는 null이면 그 행을 렌더하지 않으니까 사용자에게 0이라는 그릇된 값을 보여주지 않게 되고, 0은 없음이 아니라 하나의 값으로 LLM 분석 프롬프트에 유입되니까 지우는 게 맞다. 최종적으로 60건만 필드 단위로 NULL 처리했다.
재오염 방지 게이트는 쓰기 경로에 붙였다
정제를 끝내고 나서 허점을 하나 발견했다. 층수 모순을 걸러내는 안전 게이트가 백필 스크립트에만 있고, 배포된 크롤러의 쓰기 경로에는 없었다. 이러면 재크롤하는 순간 같은 나쁜 값이 다시 기록된다. 일회성 스크립트에만 가드를 붙이면 프로덕션 쓰기 경로는 무방비라는 당연한 결론을 그제야 봤다.
층수 모순 게이트와 0세대 미기록 게이트를 크롤러 쓰기 경로에 배포했다.
배포 후 게이트 위반 후보 8건이 관측되어서 프로덕션을 검증했더니 0건이 신규 오염이었다.
전부 기존 행의 updated_at 갱신이 쿼리에 잡힌 것이었다.
updated_at 기반 쓰기 감시는 신규 쓰기와 기존 행 갱신을 구분해야 오탐이 없다는 교훈을 하나 더 얻었다.
차단당했다고 연타하지 않기
플립 재수집 런을 관찰하는 13시간 동안 법원 WAF에 2번 차단당했다. 전날 실패해서 새벽에 수동 재실행을 연타한 이력이 누적 2만 요청 이상으로 IP 평판을 악화시킨 것이 원인이었다. 차단 1회당 냉각 30분에 재스캔 2시간, 2.5시간이 추가로 붙는다. 재차단되면 즉시 재트리거하지 말고 30분 냉각 후 세션을 회전해서 처음부터 재스캔하는 게 정석이었다. 회복 후 2.5시간 무재차단으로 자가회복 루틴을 실전 검증까지 마쳤다.
남은 것들
오피스텔형 44건의 물건 유형 재분류가 남아 있다. 분류기가 건축물 용도와 등기부 주용도를 함께 보도록 개선하는 후속 작업이다. 법원 공고 자체의 주소 결함은 내 쪽에서 근본적으로 해결할 수 없는 영역이라, 15B 응답의 도로명주소로 교차 검증하는 선에서 방어한다. 다물건 오염의 재수집이 전부 끝났는지는 아직 최종 확인 단계라서, 복구 런의 완료 판정은 다음 관찰로 남겨뒀다.