테스트를 돌렸더니 운영 DB에서 349건이 지워졌습니다
어떤 외식 프랜차이즈 ERP의 채권 잔액 화면에서 특정 연도 금액이 전부 0으로 나온다는 제보를 받았습니다. 외부 회계 SaaS에서 잔액을 수집해 보여주는 화면입니다. 수집이 멈춘 것도 아니고 화면이 비어 있는 것도 아니었습니다.
추적 끝에 나온 결론은 예상 밖이었습니다. 그 시각에 누군가 테스트를 실행했고, 그 테스트가 운영 DB에서 349건을 지웠습니다. 배포도 아니고 정기 수집도 아니고, 테스트 러너를 한 번 돌린 것이 전부였습니다.
데이터가 사라진 것이 0원으로 위장됐습니다
제보 내용은 "데이터가 없다"가 아니라 "금액이 전부 0이다"였습니다. 화면에는 매장 611행이 정상적으로 떴고 잔액 칸만 0이었습니다. 담당자 입장에서는 수집이 잘못 돌아 금액을 0으로 덮어썼다고 볼 수밖에 없는 화면이었습니다.
원인은 조회 쿼리에 있던 편의 로직이었습니다. 잔액 행이 없는 매장은 0원 행으로 합성해 보여주도록 만들어 둔 부분입니다. 화면 공백을 없애려고 넣은 로직이 데이터 유실을 "값이 0"으로 위장했습니다. 유실은 보통 빈 화면으로 드러나는데, 이 화면은 끝까지 정상처럼 보였습니다.

화면에 보이는 숫자와 DB 안에서 실제로 벌어지는 일이 어긋나는 경험은 이번이 처음이 아니었습니다. 조회 로직이 중간에서 무언가를 만들어내고 있으면 화면은 언제나 그럴듯하게 보입니다.
삭제한 사람을 찾는 대신 삭제 연산부터 특정했습니다
처음 든 질문은 "누가 실행했나"였습니다. 그런데 이 환경에는 삭제를 기록하는 로그가 하나도 없었습니다. 수집 실행 로그에 그 시각 기록이 없고, DB의 일반 로그와 바이너리 로그는 꺼져 있고, 성능 스키마를 볼 권한도 없었습니다.
그래서 질문을 바꿨습니다. 누가 했는지가 아니라 어떤 연산이 일어났는지를 먼저 확정하기로 했습니다. 남아 있는 흔적은 테이블 메타데이터의 갱신 시각과 행별 타임스탬프뿐이었습니다.
수집 실행 로그를 사고 시각 기준으로 훑었습니다. 실행 기록이 전무해서 큐 소비, 워커 스케줄 트리거, 관리 화면 수동 실행 세 경로를 모두 배제했습니다.
테이블 메타데이터의 최종 갱신 시각을 봤습니다. 정확히 사고 시각이었습니다. 그 시각에 이 테이블로 쓰기가 들어간 것은 확실했습니다.
같은 시각을 행별 갱신 타임스탬프로 다시 조회했습니다. 그 시각으로 갱신된 행은 0건이었습니다. INSERT였다면 새 행이 남고 UPDATE였다면 갱신 시각이 바뀌는데, 둘 다 없었습니다.
남는 연산은 하나였습니다. 쓰기는 있었고 남은 행은 없으니 순수 DELETE로 확정했습니다. 이 시점에 "수집이 금액을 0으로 덮었다"는 가설이 완전히 빠졌습니다.
실행 주체는 워커의 셸 히스토리에서 찾았습니다. 서비스명으로는 아무것도 걸리지 않았는데, 실제로 입력된 명령이 테스트 러너였습니다.
세 번째 단계가 이 규명의 핵심이었습니다. 삭제를 기록한 로그가 없어도, 쓰기 흔적은 있는데 그 시각의 행이 하나도 없다는 모순은 연산 종류를 하나로 좁힙니다. 갱신 시각은 테이블 단위로 남고 타임스탬프는 행 단위로 남기 때문에, 둘이 어긋나는 지점이 삭제를 가리킵니다.

무사한 함수와 당한 함수의 코드 차이
같은 크롤러가 다루는 데이터가 두 종류였습니다. 원장 데이터와 잔액 데이터입니다. 같은 실행에서 원장은 멀쩡했고 잔액만 349건이 사라졌습니다. 이 비대칭이 원인을 특정한 결정적 단서였습니다.
비교 항목 | 원장 저장 함수 (무사) | 잔액 저장 함수 (유실) |
|---|---|---|
빈 입력 처리 | 첫 줄에서 조기 반환 | 가드 없음, 그대로 진행 |
갱신 방식 | delete-insert | delete-insert |
빈 응답이 왔을 때 | DB 무접촉 | DELETE 실행 후 INSERT 0건 |
결과 | 데이터 유지 | 해당 구간 전체 소멸 |
두 함수의 갱신 방식은 똑같이 delete-insert였습니다. 차이는 첫 줄의 조기 반환 한 줄뿐이었습니다. 그 한 줄이 있는 쪽은 스텁이 빈 응답을 줬을 때 DB에 접속조차 하지 않았고, 없는 쪽은 기수와 계정 단위로 DELETE를 먼저 실행한 뒤 넣을 것이 없어 그대로 끝났습니다.
사고 체인은 네 단이었습니다
테스트의 API 스텁이 빈 응답을 반환했습니다. 외부 호출을 막으려고 넣은 정상적인 스텁이었습니다.
저장 함수가 빈 응답에도 DB에 접속해 기수·계정 단위로 DELETE를 먼저 실행했습니다.
크롤러 환경 설정의 DB 호스트가 운영을 가리키고 있었고, 테스트 설정에는 격리 장치가 없었습니다.
해당 연도 3계정이 삭제되자 합계가 0이 되면서 루프가 중단됐습니다. 그 덕에 이전 연도는 손대지 않은 상태로 남았습니다.
안전장치 하나만 살아 있었어도 막혔을 사고입니다. 세 개가 동시에 없었습니다. 테스트가 실 DB에 붙을 수 있었고, 저장 함수가 빈 입력을 걸러내지 않았고, 갱신 방식이 삭제를 포함했습니다. 루프 중단이라는 우연 하나가 피해 범위를 한 해로 묶어줬습니다.
범인 찾기 대신 경로를 세 겹으로 막았습니다
선택지는 두 개였습니다. 누가 실행했는지 끝까지 파는 쪽과, 삭제가 가능했던 경로 자체를 막는 쪽입니다. 앞쪽은 로그가 없어 답이 안 나오고, 답이 나와도 재발을 못 막습니다. 뒤쪽을 택했습니다.
테스트 설정에서 DB 커넥터를 자동 차단했습니다. 특정 모듈만 대체하면 그 이름을 임포트해 쓰는 다른 모듈로 샙니다. 실 DB가 필요한 테스트만 표식으로 허용하게 했고, 이 차단이 다른 플랫폼 테스트의 실 DB 조회까지 같이 잡아냈습니다.
delete-insert를 upsert와 차집합 정리로 바꿨습니다. 이번에 받은 목록에 없는 키만 정리하고, 0건이면 DB에 접속하지 않습니다.
응답이 배열이 아닐 때 조용히 빈 배열로 바꾸던 처리를 예외로 변경했습니다. 외부 서비스가 단일 세션이라 담당자가 접속 중이면 실제로 발생하는 경로였습니다.
워커 로그에 회전을 넣었습니다. 파일을 옮기는 방식은 열린 파일 핸들이 따라가 새 파일이 빈 채로 남으므로, 복사 후 비우는 방식을 택했습니다.
차집합 정리에는 함정이 하나 있었습니다. 정리 기준을 "갱신 시각이 이번 실행보다 오래된 행"으로 잡으면 멀쩡한 행이 지워집니다. 값이 같으면 UPDATE가 생략돼 갱신 시각이 그대로 남는 DB에서는, 이번에 안 들어온 행과 값이 안 바뀐 행이 구분되지 않습니다. 기준은 시각이 아니라 이번 응답의 키 집합으로 잡았습니다.

갱신 경로를 바꿀 때는 새 경로가 옛 경로와 같은 결과를 내는지 먼저 대조했습니다. 이전에 수집 워커의 집계 경계를 옮길 때도 구·신 결과를 8천 행 단위로 맞춰 차이가 0인 것을 확인한 뒤에 옛 경로를 지웠습니다.
유실분은 다음 수집이 되돌렸습니다
349건은 수동으로 복구하지 않았습니다. upsert로 바뀐 뒤 다음 정기 수집이 같은 키로 다시 채워 넣었습니다. 삭제 구간이 없어진 덕분에 복구가 별도 작업이 아니라 정상 동작의 일부가 됐습니다. 덤으로 원본에서 이미 사라진 유령 행도 차집합 정리에 함께 걸려 나갔습니다.
오래된 주석이 같은 날 같은 오판을 만들었습니다
코드는 upsert로 바뀌었는데 주석은 delete-insert 설명 그대로 남아 있었습니다. 그 주석을 읽고 갱신 방식을 잘못 판단하는 일이 같은 날 한 번 더 발생했습니다. 정정하면서 무엇이 해결되고 무엇이 남았는지를 나눠 적었습니다.
upsert 전환이 없앤 것은 빈 응답에 구간을 통째로 삭제하는 문제입니다. 과거 시점을 재수집하면 최신 값이 과거 값으로 덮이는 문제는 그대로 남았습니다. 키에 기준일이 없어 같은 행을 덮기 때문이고, 이것은 키 설계 문제라 별건입니다.
같은 사고가 나기 쉬운 조건
테스트의 DB 격리는 커넥터 레벨에서 막는 편이 안전합니다. 특정 모듈만 대체하는 방식은 그 이름을 복사해 쓰는 다른 모듈로 샙니다.
delete-insert는 입력이 비었을 때가 가장 위험합니다. 지우고 다시 넣는다는 말은 다시 넣을 것이 있을 때만 성립합니다.
삭제 추적이 안 되는 환경에서도 연산 종류는 좁힐 수 있습니다. 테이블 단위 갱신 시각과 행 단위 타임스탬프의 부재를 대조하는 방법입니다.
무사한 쪽과 당한 쪽의 코드 차이가 가장 빠른 단서입니다. 같은 실행에서 결과가 갈렸다면 차이는 대개 몇 줄 안에 있습니다.
조용한 기본값 변환은 사고를 숨깁니다. 예상과 다른 응답을 빈 배열로 바꾸면 외부 장애가 데이터 없음으로 위장돼 하류로 흘러갑니다.
점검 항목
테스트 실행 시 DB 커넥터가 자동 차단되는지, 실 DB 접속을 허용한 테스트가 몇 개인지 세어봤는지
저장 함수 첫 줄에 빈 입력이면 DB를 건드리지 않고 반환하는 가드가 있는지
갱신 방식에 DELETE 구간이 있는지, 중간에 실패하면 데이터가 사라지는지
차집합 정리 기준이 갱신 시각이 아니라 이번 응답의 키 집합인지
외부 응답이 예상 형태가 아닐 때 예외로 드러나는지, 조용히 빈 값으로 바뀌는지
이 사고에서 가장 오래 걸린 일은 원인 파악이 아니라 무슨 연산이었는지 확정하는 단계였습니다. 로그가 없으면 추적이 불가능하다고 봤지만, 남아 있는 시각 정보 두 개의 어긋남만으로 연산은 특정됐습니다. 삭제를 기록하지 않는 시스템을 운영하고 있다면, 무사한 쪽과 당한 쪽을 나란히 놓고 코드를 비교하는 편이 로그를 뒤지는 것보다 빨랐습니다.