배치 크롤러의 GCP Batch 잡 4개가 전부 SUCCEEDED로 끝났는데 이상했다. 60개 법원 중 54개는 스킵인데 6개 법원만 매번 처음부터 다시 크롤하고 있었다. 잡 소요 시간은 1시간 16분에서 4시간 7분까지 제각각이었다. 결론부터 말하면 죽은 사건이 완주 마킹을 막고 있었고 500을 사건 소멸 판정으로 바꾸는 3층 분류 체계를 넣어 루프를 끊었다. 같은 원인으로 하루 법원 요청 932~1,667건이 반복 낭비되던 것도 같이 사라진다.
증상: 성공한 잡이 매번 같은 일을 다시 한다
크롤러는 법원별로 문서를 수집하는데 문서 수집에 실패가 하나라도 있으면 해당 법원에 완주 마킹을 하지 않는 설계다. 서류 회수 실패를 다음 실행이 이어받도록 하려는 의도였다. 문제는 마킹이 안 된 법원은 4시간 뒤에도 처음부터 다시 크롤한다는 점이었다.
로그를 나열해보니 패턴이 명확했다. 00
잡은 60법원 전체를 순회했고 6법원이 미완주로 남았다. 08, 12, 16 잡은 54법원을 스킵하고 그 6법원만 재크롤했다. 재크롤에 34로그를 더 파보니 매번 정확히 “실패=10”이 찍혀 있었다. 10연속 실패 시 남은 행을 생략하는 서킷브레이커의 임계값과 정확히 일치했다. 실패가 수십 개가 아니라 정확히 10개라는 건, 진짜 실패 원인은 그보다 적고 카운터만 한계까지 차운다는 뜻이다.
원인을 추적하니 송달내역 API였다. 특정 사건 약 48건에서 3회 재시도 후에도 500이 돌아오고 있었다. 기일 기준으로 D-0~4일 사건은 아직 데이터가 입력 전일 수 있어서 대기로 분류되는데 D-5를 넘긴 사건의 500은 진짜 실패로 남는다. 이 사건들이 매번 실패 카운터를 채우고 서킷브레이커를 가동시키고 완주 마킹을 막고 4시간 뒤 재크롤을 유발하는 인과 사슬이었다.
500의 정체: 죽은 사건을 조회하고 있었다
500이 진짜 서버 장애인지 확인해야 했다. 배치 로그의 마스킹된 사건번호를 프로덕션 DB에서 해석해 실패 49사건을 특정했다. 실제 법원 사이트 세션으로 프로브를 돌렸다. 프로덕션과 동일한 속도 제한을 지키면서 총 71회를 요청했다.
결과는 결정적이었다. 실패 49건은 49건 전부 동일한 198바이트짜리 500, 평균 60ms에 돌아왔다. 과부하나 타임아웃이면 이렇게 빠를 수 없고 서버가 즉시 거부하고 있다는 신호다. 같은 세션에서 같은 법원, 같은 기일대의 다른 사건 11건은 전부 정상 응답이었다. 세션, IP, 요청 형식 어느 쪽 문제도 아니었다.
결정타는 교차 확인이었다. 실패 7건을 샘플로 경매사건검색 API에 조회하면 전원 “조회 되는 사건번호 정보가 없습니다” 응답이 돌아왔다. 컨트롤로 넣은 정상 사건은 5~8KB 데이터가 온다. 즉 사건 자체가 법원 검색에서 삭제된 상태고 500은 죽은 레코드를 조회할 때 서버 내부에서 터지는 예외의 증상이었다. 법원 내부 로직은 비공개라 이 부분은 추정이지만 두 엔드포인트 교차 실측으로 500과 소멸의 인과는 확정했다.
죽은 사건들의 DB 이력에도 특징이 있었다. 42/49건이 같은 기일에 몰려 있었고 대부분 유찰 이력을 가진 사건이었다. 일괄 취하나 기일 정리로 정리된 사건들로 보인다. 전원의 갱신 시각이 며칠 전에 동결돼 있었는데 이후 사이트에서 사라져서 재수집도 취소 처리도 못 받은 상태였다.
증폭 장치도 하나 있었다. 송달내역은 사건 단위로 조회하는데 19개 물건을 가진 대형 사건이 실패하면 실패 카운터를 19번 채운다. 서킷브레이커가 즉시 가동되는 구조였다.
취하인지 기일취소인지는 어떻게 구분하나
여기서 설계 문제가 하나 남는다. 사건이 소멸했다고 해도 운명은 두 갈래다. 취하로 종결된 사건은 끝이지만 기일이 취소되고 추후 지정을 기다리는 사건은 재공고되면 살아난다. 이 둘을 구분 못 하면 죽은 사건을 전부 종결 처리해야 하니 재공고 매물을 잃는다.
첫 시도는 사건 존재 여부로 판별하는 API였는데 여기엔 신호가 없었다. 취하 종결 사건과 기일 취소 후 재지정 대기 사건을 조회하면 둘 다 동일하게 “조회되는 사건 없음”이 돌아온다. 운명이 정반대인데 구분이 안 된다.
분류기를 찾았던 곳은 매각공고 상세 응답의 정정/취소 이력 필드다. 이 필드는 사건번호 단위로 기록되고 자유 텍스트가 분류를 담당한다. 원문을 보면 “부동산임차권 기일 취하/취소”처럼 취하를 명시하는 경우와 “변경 후 추후지정”처럼 재지정 대기를 뜻하는 경우가 나뉜다. 전자는 종결, 후자는 기일취소 상태로 분기하면 된다.
타이밍 갭이 하나 있는데 사건이 검색에서 제거되는 게 먼저고 정정 이력 발행은 며칠 늦게 온다. 장례식은 끝났는데 사망진단서가 발급 대기인 셈이다. 실제로 관측된 49건 중 정정 이력이 있는 건 0건이었다. 그래서 3층으로 설계했다. 1층에서 사건 부재만 확인되면 즉시 재시도 루프부터 종결한다. 2층에서 며칠 내 정정 이력이 등장하면 취하인지 추후지정인지 확정 분류한다. 3층은 시한 폴백으로, 기일이 지나도록 무변동이면 기존 은퇴 경로로 자연 폐기한다.
기존 집계 컬럼을 쓰면 안 된다는 함정도 발견했다. 공고 단위 취소 총계 컬럼은 사건 단위 보증이 안 된다. 대구 공고 취소 6건 중 진짜 취하는 1건이고 5건은 추후지정이었다. 분류는 반드시 사건별 이력 항목으로 해야 한다.
완주 마킹을 완화하는 대신 원인을 제거했다
수정 방향은 두 가지였다.
안 B는 완주 마킹 조건을 완화하는 거였다. 루프는 끊기지만 죽은 사건이 고스트 매물로 서비스에 남고 죽은 사건 수에 법원 수를 곱한 만큼 매일 500 재시도가 계속된다. 증상 완화일 뿐이었다.
안 A는 소멸 판정을 앞세우는 거였다. 500이 뜬 사건을 생존 확인 API에 1회 조회해서 비어있으면 소멸로 확정한다. 소멸 사건은 실패 계수에서 제외되니 서킷브레이커가 안 돌고 완주 마킹 불변식은 그대로 살아난다. 실패가 0이 되면 마킹이 자연 복구되는 구조라 완주 조건 자체는 건드리지 않았다. “B는 증상 완화, A는 원인 제거”라는 판단으로 안 A를 골랐다.
기대 효과는 실측 수치로 계산했다.
후속 슬롯 소요가 75110분에서 10분 수준으로 줄고 하루 법원 요청 수백 건과 사건 스캔 9321,667건이 거의 0에 수렴한다.
웹에서 “진행중”으로 남아 있던 죽은 49건의 고스트 매물 노출도 사라진다.
오탐 방지 장치도 세 개 넣었다. 빈 응답이 일시적일 수 있으니 연속 2회 확인 시에만 상태를 전환한다. 봉투 구조 자체가 없는 이형 응답은 오판 위험이 있어 소멸로 취급하지 않는 방향으로 보수적으로 둔다. 기타 5xx와 네트워크 오류는 기존 실패 경로를 유지한다. 죽었다고 잘못 판정하는 비용이 살았다고 잘못 판정하는 비용보다 크기 때문이다.
상태 하나를 추가할 때 같이 봐야 하는 것들
구현은 도메인 enum을 늘리는 대신 저장 전용 상수 하나를 추가하는 걸로 시작했다. 상태 enum을 5값으로 확장하면 매핑 계약 전체를 바꾸는 일이라 4값 동결을 유지했다. 상태 문자열은 기존 매핑 체계를 그대로 쓴다.
영향 범위를 실측하니 세 곳이었다. 웹 기본 필터는 제외 방식이라 새 상태를 제외 집합에 넣지 않으면 목록에 노출된다. 정정 이력 대조 로직은 이미 사건번호 키를 계산하는 경로가 있어서 분류를 거기에 붙였다. 업서트 부활 게이트는 매각완료 한정이라 재공고 부활은 통과한다.
읽기 경로의 폴백 동작이 배포 순서를 결정했다. 미지 상태를 기본값으로 해석하는 읽기 코드가 있어서 쓰기와 읽기를 함께 배포해야 한다. 더 치명적인 건 DB였다. 상태 컬럼의 CHECK 제약이 새 상태값을 거부한다. 제약 확장 SQL을 먼저 실행하고 배치와 웹을 배포해야 하고 순서가 어긋나면 첫 소멸 업데이트에서 제약 위반으로 크롤 전체가 죽는다. 지금은 커밋까지 완료된 상태고 제약 확장과 배포가 남아 있다.
부활 경로는 코드로 검증해뒀다. 재공고되면 업서트가 기존 행을 자연스럽게 덮어쓴다. 취하 사건은 같은 사건번호로 재공고되지 않는다는 도메인 판단이라 부활 게이트에 포함시키지 않았다. 기일취소 상태는 게이트를 통과해 재공고 시 진행중으로 돌아온다.
돌아보면
이 문제의 특징은 어느 지점 하나 봐도 정상이라는 거였다. 스킵 로직은 정상적으로 54법원을 스킵했고 서킷브레이커는 설계대로 10연속 실패에 작동했고 완주 마킹 조건은 의도대로 실패를 이어받았다. 각각 올바른 동작이 모여서 4시간마다 같은 일을 반복하는 루프가 됐다.
그래서 해법도 어딘가를 고치는 게 아니라 분류를 바꾸는 거였다. 500을 장애로 볼 때는 전부 정상적으로 실패하지만 소멸로 보는 순간 실패도 아니고 재시도도 아닌 데이터 상태가 된다. 상태 코드를 설계 당시의 가정으로 읽지 말고 증거로 다시 읽어야 한다는 교훈은 이번에 또 한 번 확인했다.
남은 과제는 명확하다. 배포 순서를 지켜 프로덕션에 반영하고 같은 6개 법원에 잠재 재발 대상이 더 있는지 확인하는 것이다. 같은 조건의 사건이 784건 더 존재한다는 것까지는 봐뒀다.