같은 날, 두 저장소

6월 22일에 고친 버그 두 개를 나란히 놓고서야 같은 얼굴인 걸 알았습니다. 하나는 청약 공고 수집기(Python)였습니다. 청약홈 OpenAPI를 page=1, perPage=100으로 한 번만 호출하고 끝냈는데, 공고가 100건을 넘으면 101번째부터는 조용히 사라졌습니다. 다른 하나는 청첩장 관리자 대시보드(TypeScript)였습니다. 방문·행동 집계 쿼리가 .limit(5000) 단발 조회였는데, PostgREST 서버가 max-rows 기본값 1,000에서 응답을 잘랐습니다. 헬스체크 실측으로는 30일 분석 이벤트 8,900여 행 중 1,000행만 집계되고 있었습니다.

도메인도, 언어도, 데이터 소스도 다릅니다. 공유하는 코드도 없습니다. 그런데 결함의 모양이 정확히 같았습니다. 한 번 조회하고, 상한을 넘긴 나머지를 에러 없이 버린다. 같은 날 다른 저장소에서 같은 자리가 비어 있었습니다.

무관한 자동화 여섯 개가 같은 자리에서 무너졌다에서 공간축 재발을 다뤘습니다. 그때의 결함 클래스는 원자적 쓰기·dry-run의 read-only 보장·per-item 격리였습니다. 이번엔 거기 없던 새 클래스가 같은 축으로 또 튀어나왔습니다 — 페이지네이션 truncation입니다.

truncation이 유독 위험한 이유

대부분의 버그는 무언가를 던집니다. 500이 나거나, 예외가 올라오거나, 타임아웃이 납니다. 그래서 모니터링이 잡습니다. truncation은 그렇지 않습니다. 서버는 200을 주고, 1,000행은 진짜 유효한 데이터이며, 코드는 정상적으로 끝납니다. "부분 응답"은 에러가 아니라 정상적인 성공으로 집계됩니다. 잘렸다는 신호가 응답 어디에도 없습니다.

그래서 이 결함은 도입된 시점이 아니라 한참 뒤에 발견됩니다. 데이터가 상한 미만일 때는 멀쩡히 돌다가, 트래픽이 상한을 넘는 순간부터 조용히 일부만 세기 시작합니다. 대시보드는 여전히 숫자를 보여주고, 그 숫자는 그럴듯합니다. 다만 틀렸을 뿐입니다. 예식일처럼 트래픽이 몰리는 날일수록 더 많이 잘리고, 하필 그날 수치가 가장 중요합니다.

절단점 바깥에 있던 죽은 가드

더 뼈아픈 건 대시보드에 truncation을 감지하라고 둔 가드가 이미 있었다는 점입니다. rows.length >= 5000이면 "잘렸을 수 있음"으로 표시하는 코드였습니다. 그런데 서버가 1,000행에서 먼저 자르니, 받은 행 수가 5,000에 닿을 길이 애초에 없었습니다. 가드의 임계가 실제 절단점보다 위에 있어서, 정의돼 있어도 영원히 발화하지 않는 죽은 안전장치였던 것입니다.

이건 정의만 되고 호출자가 0건이던 재시도와 같은 계열의, 그러나 다른 모양입니다. 그건 "안 돈다"였고, 이건 "돌 수 있는 좌표 밖에 있다"입니다. 침묵을 막으라고 둔 가드가, 침묵하는 실패가 일어나는 지점과 다른 좌표계에 살고 있었습니다. 가드는 클라이언트가 요청한 상한(5,000)을 보고 있었고, 실패는 서버가 정한 상한(1,000)에서 일어났습니다.

시간축 재발 — 한 곳만 고치고 복제를 남겼다

대시보드의 이 버그는 사실 그 저장소에서 처음 본 얼굴도 아니었습니다. 12일 전, 같은 결함을 텔레그램 일일 브리핑에서 먼저 고쳤습니다. 그때도 .limit() 단발 조회가 서버 max-rows에 잘려, 전일 대비 수치가 이미 오염돼 있었습니다. 페이지네이션으로 고쳤고, 닫혔다고 생각했습니다.

문제는 브리핑과 대시보드가 같은 쿼리를 각자 복제해서 쓰고 있었다는 것입니다. 한 곳을 고칠 때 복제본을 남기면, 그 복제가 다음 재발의 씨앗이 됩니다. 12일 뒤 헬스체크가 대시보드 쪽 잔존분을 잡았습니다. 같은 결함이 같은 저장소 안에서, 복제 경로를 타고 시간 간격을 두고 다시 터진 시간축 재발입니다.

그래서 두 번째 수정은 페이지네이션을 또 한 벌 짜는 대신, 공용 헬퍼 하나로 추출하고 브리핑의 복제본까지 그 헬퍼로 흡수했습니다. 패치를 하나 더 바르는 게 아니라, 복제 경로 자체를 없애 재발 계열을 제거하는 쪽입니다. 같은 버그를 N번째 고칠 땐 "이 fix가 들어갈 자리가 여기 말고 또 어디 있나"가 진짜 질문입니다.

"가득 찬 페이지" 가정을 버린다

페이지네이션으로 고칠 때도 함정이 하나 더 있었습니다. 흔한 종료 조건은 "받은 행이 페이지 크기보다 적으면 마지막 페이지"입니다. 그런데 서버 max-rows가 페이지 크기보다 작으면(여기선 1,000 < 요청 크기), 첫 페이지부터 "덜 찬 페이지"로 보여 한 바퀴 만에 멈춰 버립니다 — truncation을 고치려던 코드가 같은 방식으로 다시 자르는 것입니다.

그래서 종료 조건을 "페이지가 가득 찼는가"가 아니라 **"빈 페이지가 나왔는가"**로 바꾸고, 매번 실제로 받은 행 수만큼만 다음 오프셋을 전진시켰습니다. 이러면 서버 상한이 페이지 크기보다 낮아도 절단되지 않습니다. 이 불변식은 텔레그램 브리핑을 고칠 때 독립적인 리뷰 렌즈 두 개가 같은 결론으로 수렴해 찾아낸 것이고, 청약 수집기 쪽도 결국 같은 모양으로 — 짧은 페이지가 나올 때까지 돌고, 안전 상한(10페이지)에 닿으면 로그로 경고 — 정착했습니다.

메커니즘은 달라도 증상은 같다

공간축으로 묶인 두 사례의 원인은 사실 서로 다릅니다. 청약 수집기는 perPage를 100으로 명시한 단발 호출이었고, 대시보드는 PostgREST의 전역 max-rows라는 보이지 않는 서버 설정이었습니다. 한쪽은 내가 쓴 숫자, 다른 쪽은 인프라가 정한 숫자입니다. 그런데 증상은 똑같았습니다 — 상한을 넘긴 데이터가 에러 없이 사라진다.

이게 결함 클래스로 묶이는 이유입니다. "단발 조회 + 암묵적 상한"이라는 구조는 그 상한이 내 코드에 있든 서버 설정에 있든 같은 침묵을 만듭니다. 그러니 처방도 출처를 가리지 않습니다. 외부에서 목록을 받아오는 모든 자리에 같은 질문을 던지면 됩니다 — "이 응답에 상한이 있나? 있다면 넘쳤을 때 그걸 알 방법이 있나?" 답이 "끝까지 페이지를 넘긴다"와 "상한에 닿으면 시끄럽게 운다" 둘 다 아니라면, 그 자리는 언젠가 조용히 일부만 세기 시작할 자리입니다.

목록을 한 번에 가져오는 코드는, 그 목록이 짧을 때만 옳습니다. 길어질 수 있는 목록을 단발로 조회하는 순간, truncation은 버그가 아니라 예약된 미래입니다.

관련: /essays/same-defect-class-across-repos, /notes/recurring-trap-to-checklist, /notes/dead-retry-bounded-executor, /logs#2026-06-18-dead-safeguards, /logs#2026-06-07-healthcheck-six-bots