데이터에도 Git Bisect를 적용하기 — 과거 데이터 상태를 이진 탐색하는 방법
데이터가 언제부터 잘못됐는지 찾기 위해 데이터베이스를 특정 시점으로 되돌려 조회하고, 그 결과를 이진 탐색하는 도구를 소개합니다. Neon의 시점별 브랜치를 이용해 12분 30초의 문제 구간을 6회, 14.2초 만에 47초 범위로 좁혔지만, 보존 기간과 단조성 가정이 핵심 제약입니다.

본문
매출 수치가 재무 시스템과 맞지 않지만 언제부터 문제가 생겼는지 모르는 상황은 데이터 운영에서 자주 발생합니다. 테이블의 created_at을 정렬하거나 배포 기록과 Slack 대화를 훑는 방식은 원인을 추정할 수는 있어도, 문제가 시작된 정확한 시점을 체계적으로 찾지는 못합니다. 코드에서는 이미 Git의 git bisect가 정상적으로 동작하던 커밋과 문제가 생긴 커밋 사이를 이진 탐색해 원인 커밋을 찾아줍니다. 이 글은 같은 방식을 데이터베이스의 시간축에 적용합니다.
핵심 아이디어: 특정 시점의 데이터베이스를 조회합니다
데이터에 대한 bisect에는 백업이 아니라 parent_timestamp가 필요합니다. 데이터베이스를 임의의 과거 시점 상태로 브랜치하고, 그 브랜치에서 판정용 쿼리 하나를 실행한 뒤 브랜치를 삭제합니다. 일반적으로 특정 시점의 데이터베이스를 확인하려면 백업을 복원해야 하므로 작업 단위가 커지지만, Neon에서는 storage와 compute가 분리되어 있고 storage 계층이 보존 기간 동안의 WAL을 이미 유지합니다. 따라서 과거 상태를 복사하는 대신 해당 시점의 상태를 재구성해 연결 문자열을 제공할 수 있습니다.
Neon의 API에서는 프로젝트와 부모 브랜치를 지정하고 parent_timestamp를 넣어 시점별 브랜치를 생성합니다. 이 글에서 구현한 pg-timemachine은 이 기능을 이용하며, Neon에서 parent_timestamp가 일급 API로 제공되기 때문에 Neon을 대상으로 합니다. 다만 과거 상태를 저렴하게 재구성할 수 있는 다른 시스템에도 같은 아이디어를 옮길 수 있다고 설명합니다. 사용 환경은 Node 20 이상과 Neon API key입니다.
probe 비용과 이진 탐색의 효과
도구를 만들기 전에 한 번의 probe가 실제로 얼마나 걸리는지를 측정했습니다. eu-central-1의 Neon 프로젝트에서 Postgres 18을 사용하고, 약 45ms의 왕복 지연을 가진 클라이언트에서 한 시간 전 상태로 브랜치를 만드는 데 843ms가 걸렸습니다. 생성한 브랜치에 연결하고 쿼리를 실행하는 데는 837ms가 걸렸으며, 전체 probe 비용은 약 1.7초였습니다. 당시 데이터베이스의 now() 값과 PostgreSQL 버전도 확인해 시점 브랜치가 기대한 상태를 제공하는지 검증했습니다.
24시간 범위를 1분 정밀도로 조사하면 이진 탐색에는 약 13회의 probe가 필요하므로 대략 20초가 걸립니다. 같은 범위를 분 단위로 순차 스캔하면 1,440회의 probe가 필요하고 약 40분이 걸립니다. 글에서는 이 차이가 이 아이디어가 실제 도구가 될 수 있는지를 결정한다고 설명합니다. probe가 1.7초라면 사용할 만하지만, 30초라면 실용적인 디버깅 도구가 아니라 아이디어를 소개하는 글에 그쳤을 것이라는 의미입니다.
판정 쿼리와 실제 실행 결과
사용 방법은 프로젝트와 브랜치를 지정하고, 데이터가 정상일 때의 기대값과 탐색 범위를 함께 전달하는 방식입니다. 예를 들어 select count(*) from orders where total_cents < 0 쿼리로 음수 결제 금액의 개수를 세고, 정상 상태의 기대값을 0으로 지정할 수 있습니다. 쿼리는 정확히 한 행과 한 열만 반환해야 합니다. 여러 행이나 여러 열을 허용하면 도구가 임의로 값을 선택해 모호한 판정을 내릴 수 있고, 이 경우 실패하는 대신 확신에 찬 잘못된 결과를 반환할 수 있기 때문입니다.
검증을 위해 주문을 분당 20건씩 기록하는 시나리오를 만들고, 8분이 지난 시점에 loyalty discount에 하한값을 적용하지 않는 잘못된 배포가 시작되도록 했습니다. 그 결과 주문 280건 중 24건이 음수 금액을 갖게 됐습니다. 음수 행은 전체의 일부에 불과하고 각각은 일반적인 행처럼 보이기 때문에 코드 리뷰나 간단한 smoke test에서 드러나지 않으며, 월말 재무 내보내기에서야 문제가 보일 수 있는 상황입니다.
실제 첫 번째 잘못된 행이 생성된 시점은 2026-09-17T10:24:08.719Z였습니다. 탐색 범위는 10:16:30부터 10:29:00까지인 12분 30초였고, 1분 정밀도에서 약 6회의 probe가 예상됐습니다. 시작점에서는 음수 행이 0개였고, 끝점에서는 20개였습니다. 이후 중간 시점들을 조회한 결과 10:22:45에는 0개, 10:25:52.500에는 8개, 10:24:18.750에는 4개, 10:23:31.875에는 0개가 나왔습니다.
최종 결과는 마지막 정상 시점 10:23:31.875와 최초 비정상 시점 10:24:18.750 사이의 47초 범위였습니다. 실제 정답인 10:24:08.719는 이 범위 안에 있었습니다. 총 6회의 probe가 14.2초에 끝났으며, 12분 30초의 범위를 47초로 줄였습니다. 각 값인 0, 20, 0, 8, 4, 0은 실제로 약 2초 동안 존재했다가 삭제된 시점별 데이터베이스에서 얻은 결과입니다.
가장 중요한 제약: history retention
이 방식은 데이터베이스의 history retention window보다 오래된 시점을 조회할 수 없습니다. Neon의 기본 보존 기간은 대부분의 플랜에서 24시간이며, 프로젝트의 history_retention_seconds 설정으로 확인합니다. 예를 들어 매출 수치가 9일 전에 깨졌는데 데이터 보존 기간이 하루라면, 이 primitive를 사용하는 어떤 도구도 해당 질문에 답할 수 없습니다. 필요한 정보 자체가 이미 사라졌기 때문입니다.
도구는 첫 probe를 실행하기 전에 프로젝트의 보존 기간을 확인하고, 조회 범위가 복원 가능한 시점보다 오래됐으면 즉시 거부합니다. 예시에서는 프로젝트가 1일의 기록만 보존하므로 복원 가능한 가장 이른 시점이 2026-09-16T10:35:50.191Z인데, 사용자가 2026-09-10부터 조회하려고 하면 범위가 이미 사라졌다고 알리고 다음부터 history_retention_seconds를 늘리라고 안내합니다. 글은 retention을 장애가 발생한 뒤가 아니라 미리 올려야 한다고 강조합니다. Neon에서는 유료 플랜에서 프로젝트별로 최대 30일까지 설정할 수 있으며, 버그가 배포된 뒤 누군가 발견하기까지의 간격을 기준으로 정해야 한다고 설명합니다.
이진 탐색이 틀릴 수 있는 두 가지 경우
git bisect와 마찬가지로 이 방법은 탐색 범위 안에서 정상·비정상 판정이 한 번만 바뀐다고 가정합니다. 첫 번째 문제는 탐색 시작점부터 이미 비정상인 경우입니다. 범위의 모든 중간점이 비정상이면 순진한 이진 탐색은 가장 이른 시점으로 수렴하고, 실제로는 그보다 전에 발생한 문제를 정확한 전환 시점인 것처럼 보고할 수 있습니다. 이를 막기 위해 검색 전에 시작점과 끝점을 먼저 probe합니다. 시작점이 이미 비정상이면 bad_at_start 결과를 반환하고, 문제가 범위보다 앞서 발생했다고 알립니다.
두 번째 문제는 값이 한 번 깨진 뒤 복구됐다가 다시 깨지는 경우입니다. 예를 들어 10시에 문제가 발생하고 11시에 수정된 다음 14시에 다시 깨졌다면, 이진 탐색은 중간점의 배치에 따라 전환 중 하나만 찾아내고 나머지는 말하지 않을 수 있습니다. 이 문제를 이진 탐색 자체만으로 해결할 수는 없습니다. 전체 전환을 확인하려면 선형 탐색이 필요하기 때문입니다.
대신 check 명령으로 먼저 표본을 수집해 단조성을 점검합니다. 여러 시점의 결과를 확인해 정상에서 비정상으로의 변화가 한 번 이하인지 검사하고, 단조적이면 bisect 결과를 의미 있는 것으로 간주합니다. 단조적이지 않으면 도구가 그렇게 알리고 관찰된 전환을 표시합니다. 글의 예시에서는 8회의 표본 조회에 약 14초가 걸렸고, 정상 상태에서 비정상 상태로의 변화가 한 번뿐이라는 결과가 나왔습니다.
시점 조회, 테스트, 정리
같은 primitive는 bisect 외에도 특정 시점의 상태를 직접 비교하는 at 명령에 사용할 수 있습니다. 10:22:00의 주문 수와 총매출은 각각 120건, 617,078센트였고, 10:29:00에는 260건, 1,277,680센트였습니다. 주문 140건이 추가되는 동안 660,602센트가 늘었지만, 평균 주문 금액은 5,142에서 4,719로 약 8% 낮아졌습니다. 전체 매출선은 계속 상승하기 때문에 대시보드에서 즉시 드러나지 않을 수 있는 변화입니다.
bisect 로직 자체는 데이터베이스나 HTTP를 알지 못하고 probe callback만 받도록 분리했습니다. 덕분에 37개 테스트를 실제 브랜치를 만들지 않고 1초 이내에 실행할 수 있습니다. 특히 시작점부터 비정상인 경우와 여러 번 전환되는 경우처럼 잘못된 답을 내기 쉬운 조건을 빠르고 안정적으로 테스트할 수 있습니다.
운영 측면에서는 정리와 timeout도 중요합니다. probe가 정상적으로 반환되거나 예외를 던질 때는 finally에서 브랜치를 삭제하지만, Ctrl-C로 프로세스를 종료하면 브랜치가 남을 수 있습니다. 남은 브랜치는 누군가 발견할 때까지 compute 비용을 발생시킬 수 있으므로 signal handler로 활성 브랜치를 추적해 제거하고, 누락된 브랜치를 정리하는 sweep 명령도 제공합니다. 연결과 쿼리에는 timeout을 설정하고, 서버 측 statement_timeout으로 느린 쿼리도 취소해야 합니다. 그렇지 않으면 연결할 수 없는 compute나 종료되지 않는 쿼리 하나가 전체 탐색을 멈추게 됩니다.
실무에서의 결론
이 방식의 핵심은 검색 알고리즘 자체가 아니라 과거 상태를 저렴하게 조회할 수 있는 데이터베이스 기능입니다. 따라서 가장 먼저 준비해야 할 것은 충분한 history retention입니다. 다음으로 실제 장애를 찾는 데 사용할 판정 쿼리를 데이터 품질 검사로 작성하고, 가능하다면 정기적으로 실행해 문제가 발생한 시점을 미리 감지하는 것이 좋습니다. 또한 탐색 범위 안에서 여러 번 배포된 데이터라면 bisect 전에 monotonicity를 확인해야 합니다.
이 도구가 알려주는 것은 무엇이 아니라 언제입니다. 하지만 특정 timestamp를 확보하면 해당 시점의 배포, 마이그레이션, cron 작업을 확인할 수 있으므로 원인 분석의 범위를 크게 줄일 수 있습니다. 글의 결론은 12분 30초의 문제 구간을 14초 만에 47초로 좁힐 수 있었으며, 실제 한계는 이진 탐색이 아니라 얼마나 많은 과거 기록을 보존했는지에 있다는 것입니다.
원문: dev.to / 번역·요약: Trawling