삭제 필터를 걸었는데 아무것도 안 걸러지고 있었다
논리 삭제 처리를 위해 작성한 쿼리 조건이 실제 데이터와 맞지 않아 수년간 무용지물이었던 경험을 통해, 코드 리뷰만으로는 알 수 없는 데이터의 실체를 확인하는 과정과 죽은 로직을 방치하지 않기 위한 현실적인 대응 방안을 공유합니다

몇 년 묵은 배치의 집계 쿼리를 이관할 수 있는지 보고 있었다. 삭제된 파일을 빼고 용량을 더하는 부분에 이런 조건이 있었다.
WHERE COALESCE(f.status, 'N') NOT IN ('Y', 'D')COALESCE 로 NULL 까지 막았고, 삭제를 뜻하는 상태 두 개를 모두 걸렀다. 꼼꼼하게 쓴 조건이다. 리뷰할 게 없어서 그냥 넘어갔다.
그러다 나중에 전혀 다른 이유로 상태 값 분포를 찍어볼 일이 생겼다.
SELECT COALESCE(status, '(null)') AS status,
(deleted_at IS NOT NULL) AS soft_deleted,
count(*)
FROM files
GROUP BY 1, 2
ORDER BY 3 DESC; status | soft_deleted | count
----------+--------------+--------
U | f | 758528
(null) | f | 232448
A | f | 185081
T | f | 46090
R | f | 16074
| f | 8973
Y 도 없고 D 도 없다. 한 건도. deleted_at 은 전 행이 NULL 이다. 운영 DB 두 곳에서 똑같았다.
그 WHERE 절은 몇 년째 아무것도 걸러내지 않고 있었다.
왜 안 잡히나
이 버그가 무서운 건 어느 단계에서도 걸리지 않는다는 점이다.
문법은 맞다. 컬럼도 실제로 존재한다. 인덱스도 정상적으로 탄다. 에러 로그도 안 남는다. 테스트를 짰다면 테스트 데이터에는 Y 와 D 를 넣었을 테니 통과했을 것이다. 코드 리뷰에서는 오히려 "삭제 처리를 신경 썼네" 하고 넘어간다.
조건이 죽어 있다는 사실은 운영 데이터를 직접 봐야만 드러난다. 그런데 집계 배치의 WHERE 절 하나 때문에 운영 DB 분포를 찍어보는 사람은 잘 없다.
원인은 보통 셋 중 하나다. 상태 코드 체계가 중간에 바뀌었는데 쿼리만 안 따라갔거나, 다른 테이블의 코드 체계를 보고 작성했거나, 애초에 삭제가 물리 삭제로 처리돼서 남을 행이 없거나. 내가 본 건 세 번째에 가까웠다. 논리 삭제를 염두에 두고 컬럼과 조건을 만들어뒀는데 실제 구현은 그냥 지우는 쪽으로 갔고, 아무도 그 사실을 쿼리에 반영하지 않았다.
지우면 안 되고, 두면 착각한다
그럼 죽은 조건이니까 지우면 되나. 그것도 곤란하다.
지우면 나중에 논리 삭제가 도입됐을 때 삭제된 파일이 조용히 집계에 섞인다. 그렇다고 그대로 두면 다음 사람도 나처럼 "삭제는 걸러지고 있구나" 하고 믿는다. 둘 다 나쁘다.
그래서 확인한 사실을 조건 옆에 박아뒀다.
-- status IN ('Y','D') 는 2026-09 실측 기준 0건.
-- 삭제가 물리 삭제로 처리되어 남는 행이 없음. 논리 삭제 도입 시 이 조건이 발효됨.
WHERE COALESCE(f.status, 'N') NOT IN ('Y', 'D')주석이 최선은 아니다. 제대로 하려면 상태 코드를 문자열 리터럴로 흩뿌리지 말고 한곳에 정의해두고, 그 정의가 실제 데이터와 맞는지 주기적으로 검사해야 한다. 체크 제약이나 enum 이 걸려 있으면 애초에 이런 표류가 생기지 않는다. 다만 남의 레거시 테이블에 제약을 새로 거는 건 다른 차원의 일이라, 당장은 주석이 현실적인 타협이었다.
필터가 살아 있는지 확인하는 한 줄
배포 전에 이것만 돌려봐도 된다.
SELECT count(*) FILTER (WHERE status IN ('Y', 'D')) AS filtered_out,
count(*) AS total
FROM files;filtered_out 이 0 이면 그 조건은 죽어 있다. 이유를 밝히기 전에는 손대지 않는 게 좋다.
남는 생각
코드 리뷰와 정적 분석은 "이 조건이 참이 되는 행이 실제로 존재하는가"를 검사하지 못한다. 컬럼이 스키마에 있다는 사실과 그 컬럼이 의미 있게 채워진다는 사실은 완전히 별개인데, 쿼리를 읽을 때는 자연스럽게 후자를 가정하게 된다.
오래된 코드를 이관하거나 재사용할 때 로직만 읽고 판단하면 이런 걸 못 잡는다. 대상 데이터에 직접 물어보는 단계를 한 번은 끼워 넣어야 한다. 쿼리는 거짓말을 하지 않지만, 쿼리를 읽는 우리는 데이터를 마음대로 상상한다.