버그는 가끔 아주 뻔뻔하다.

“나는 중복이 아니야. 그냥 한 번 더 나온 것뿐이야.”

이번에 만난 버그가 딱 그랬다. Query Audit 목록에서 같은 로그가 다시 보였다. 데이터베이스에 같은 로그가 두 번 저장된 걸까? 프론트엔드가 같은 페이지를 두 번 붙인 걸까? 아니면 사용자가 스크롤을 너무 열심히 해서 화면이 데자뷔를 본 걸까?

처음 화면만 보면 단순해 보인다.

900001
900002
900003
900001
900002
900003
900004

같은 번호가 한 번 더 나타난다. 귀신이 아니라 버그다.

처음 의심한 범인: “DB에 두 번 들어간 거 아냐?”

중복 목록 버그를 보면 가장 먼저 떠오르는 범인은 저장소다.

“혹시 로그가 실제로 두 번 쌓였나?”

하지만 이 케이스는 달랐다. 원본 로그 테이블을 보면 같은 로그가 두 번 저장된 것이 아니었다. DB에는 한 번만 있었다. 그런데 UI에서는 다시 보였다.

이 말은 곧, 문제의 진짜 무대가 “저장”이 아니라 “읽기”라는 뜻이다.

특히 Query Audit은 많은 로그를 한 번에 전부 가져오지 않는다. 화면은 무한 스크롤처럼 동작한다. 먼저 최신 로그를 조금 보여주고, 사용자가 아래로 내려가면 다음 묶음을 가져온다.

여기서 중요한 소품이 하나 등장한다.

바로 커서(cursor)다.

커서는 책갈피다

커서를 어렵게 생각할 필요는 없다.

책을 읽다가 잠깐 덮을 때 끼워두는 책갈피와 같다.

여기까지 읽었음!
다음에는 이 지점 다음부터 읽기!

무한 스크롤도 비슷하다.

첫 요청이 최신 로그 1~10번을 읽었다면, 다음 요청은 “10번 이후부터 주세요”라고 말해야 한다. 이때 백엔드가 프론트엔드에 넘기는 “다음에 어디서부터 읽을지”가 커서다.

그런데 이번 버그에서는 책갈피가 살짝 이상한 곳에 꽂혔다.

이미 읽은 장의 끝이 아니라, 이미 읽은 장의 시작 쪽을 다시 가리킨 것이다.

그래서 다음 페이지를 열면 이렇게 된다.

1장 읽음
책갈피 꽂음
다음 페이지 요청
어라? 다시 1장부터 읽음

사용자 입장에서는 “아까 본 로그가 또 나오네?”가 된다.

왜 하필 필터를 걸었을 때 잘 보였을까?

이 문제는 아무 때나 크게 터지는 버그가 아니었다. 필터가 걸린 Query Audit 조회에서 특히 잘 보였다.

예를 들어 이름, 연결 이름, 특정 조건 같은 필터를 걸면 전체 시간 범위에서 해당 조건에 맞는 로그만 찾아야 한다. 로그가 촘촘하면 한 번에 어느 정도 결과가 나온다. 하지만 로그가 드문드문 있으면 백엔드는 넓은 시간대를 조금씩 훑어야 한다.

여기서 QueryPie의 StepByStepScanner가 등장한다.

이름 그대로다.

한 번에 거대한 기간을 다 뒤지는 대신, 시간 구간을 잘라서 단계적으로 훑는다.

최근 1시간 훑기
그다음 1시간 훑기
그다음 1시간 훑기
...

이 방식은 합리적이다. 하지만 현실의 서버에는 시간이 있다. 검색이 너무 오래 걸리면 요청 하나에서 무한정 기다릴 수 없다. 그래서 일정 시간이 지나면 “여기까지 찾은 결과를 먼저 돌려주고, 다음 요청에서 이어서 찾자”라는 timeout branch가 필요하다.

이번 버그는 바로 이 timeout branch 안에 숨어 있었다.

버그의 핵심: 책갈피가 창문의 위쪽을 가리켰다

시간 구간을 이렇게 생각해보자.

[to]   12:00  ← 최신 쪽
       11:00
       10:00
[from] 09:00  ← 오래된 쪽

이 구간을 이미 훑었다면, 다음 요청은 09:00보다 더 오래된 쪽으로 내려가야 한다.

그런데 기존 로직은 timeout 상황에서 다음 커서를 to 쪽에 꽂았다.

즉, 이미 읽은 시간창의 위쪽을 다시 가리킨 셈이다.

읽은 구간: 09:00 ~ 12:00
잘못된 다음 책갈피: 12:00
다음 요청: 12:00부터 다시 훑기
결과: 같은 로그가 또 나옴

이제 UI의 데자뷔가 설명된다.

프론트엔드는 백엔드가 준 hasNextnextCursor를 믿고 다음 페이지를 붙인다. 프론트엔드가 나쁜 짓을 한 게 아니다. 백엔드가 “다음은 여기야”라고 알려준 위치가 이미 읽은 창문 안이었다.

패치는 작았다. 하지만 방향은 중요했다

패치의 핵심은 한 줄짜리 방향 전환에 가깝다.

기존에는 timeout 후 더 읽을 내부 페이지가 없을 때 다음 커서를 현재 시간창의 위쪽인 to에 두었다.

고친 뒤에는 이미 소비한 시간창의 아래쪽인 from보다 아주 살짝 이전으로 보낸다.

before: next cursor = current window.to
 after: next cursor = current window.from - 1 microsecond

여기서 “1 microsecond”가 귀엽지만 중요하다.

왜 그냥 from이 아닐까?

시간 범위 처리는 보통 경계가 까다롭다. 어떤 코드는 from <= t < to처럼 반쯤 열린 구간을 쓰고, 어떤 조회는 같은 timestamp 묶음을 안전하게 다루기 위해 위쪽 경계를 아주 살짝 넓힌다.

그런 상황에서 커서를 정확히 from에 두면 경계에 있는 로그가 다음 조회에 다시 걸릴 수 있다.

그래서 책갈피를 이렇게 꽂는다.

“이 창문은 다 읽었어.
다음에는 이 창문 아래쪽보다 1마이크로초 더 오래된 곳부터 시작해.”

작은 숫자지만 의미는 분명하다.

다시는 이미 읽은 창문으로 돌아가지 말자.

마지막 창문에서는 “더 있어요”라고 말하면 안 된다

한 가지 더 있었다.

이미 원래 검색 범위의 가장 오래된 지점까지 내려왔는데도 timeout이 발생하면 어떻게 해야 할까?

이때는 더 볼 창문이 없다.

그런데 여기서 계속 hasMore=true를 주면 프론트엔드는 성실하게 또 요청한다. 착한 프론트엔드가 무한 스크롤 노동자가 된다.

그래서 guard를 추가했다.

현재 창문의 from이 원래 검색 시작 지점보다 더 오래되지 않았다면
→ 더 이상 남은 시간창 없음
→ hasMore = false

이건 사용자에게 이렇게 말하는 것과 같다.

“여기까지가 진짜 끝입니다. 다음 장 없습니다.”

재현은 실제 화면에서 확인했다

이런 버그는 테스트만 통과했다고 끝내면 조금 찜찜하다.

왜냐하면 사용자가 겪은 것은 코드가 아니라 화면이기 때문이다.

그래서 로컬 QueryPie runtime을 띄우고, Query Audit 화면에 실제 재현용 로그를 넣었다. 그다음 임시로 패치 전 로직을 되돌린 상태에서 화면을 열었다.

여기서 재미있는 질문이 하나 생긴다.

“그런데 timeout은 어떻게 재현하지?”

실제 서버에서 매번 5초 넘게 걸리는 조회를 기다리며 재현하면 너무 흔들린다. 오늘은 느리고, 내일은 빠르고, 커피 한 잔 마시고 오면 또 다르게 나온다. 버그 재현은 야생동물 관찰이 아니라 실험이어야 한다.

그래서 두 가지 장치를 썼다.

테스트에서는 mock repository가 첫 partial scan 때 일부러 잠깐 자도록 했다.

첫 번째 partial scan
→ MAX_TOTAL_QUERY_TIME_NANOS보다 조금 더 sleep
→ timeout branch 진입

실제 기준은 5초다. 테스트에서는 그보다 살짝 더 오래 걸리게 만들어서 “검색이 오래 걸린 상황”을 안정적으로 만들었다.

반면 실제 QueryPie UI 캡처에서는 반대로 했다. 로컬 재현 runtime에서만 timeout 기준을 아주 작게, 1나노초로 낮췄다.

이건 제품 패치가 아니다. 사진 찍기 위한 조명 같은 것이다. 평소에는 방을 어둡게 쓰지 않지만, 증거 사진을 찍을 때는 조명을 켠다. 여기서는 timeout branch를 확실히 비추기 위해 기준값을 낮췄다.

재현 세트는 이렇게 생겼다.

실제 Query Audit 로그 fixture 적재
→ cursor를 패치 전 버그 상태로 되돌림
→ 로컬에서만 timeout 기준을 1ns로 낮춤
→ 실제 QueryPie UI 접속
→ Name = Admin 필터 적용
→ timeout branch 강제 발생
→ 같은 시간창을 다시 읽는 모습 캡처

캡처가 끝난 뒤에는 당연히 원래 상태로 되돌렸다. timeout 기준은 다시 5초로, cursor는 패치된 로직으로 복원했다.

필터를 걸자 실제 UI에서 같은 row가 다시 나타났다.

900001, 900002, 900003,
900001, 900002, 900003,
900004 ...

좋다. 아니, 좋지는 않지만 좋다.

버그가 진짜 화면에서 재현됐다. 이제 우리는 유령을 잡은 게 아니라, 발자국을 찍었다.

그다음 패치를 다시 적용하고 같은 테스트를 돌렸다. 결과는 통과했다.

그리고 이 케이스는 세 가지를 같이 확인했다.

  1. timeout 후 다음 페이지에서 첫 페이지 로그가 반복되지 않는가?
  2. 시간 경계에 딱 걸친 로그가 빠지거나 반복되지 않는가?
  3. 마지막 시간창에 도착했을 때 hasMore=false로 끝나는가?

단순히 “중복이 사라졌어요”가 아니라, “안 빠지고, 안 반복되고, 끝에서는 끝난다고 말한다”까지 확인했다.

그럼 빠지는 로그는 없을까?

중복을 고치면 꼭 따라오는 질문이 있다.

“좋아, 같은 row가 두 번 보이는 건 막았어. 그런데 혹시 너무 멀리 건너뛰어서 안 보이는 row가 생기면?”

이 질문은 좋은 질문이다. pagination 버그에서는 중복과 누락이 쌍둥이처럼 같이 다닌다. 한쪽을 세게 막다가 다른 쪽을 만들 수 있다.

이번 패치가 비교적 안전한 이유는 timeout 상황을 두 갈래로 나누기 때문이다.

첫 번째는 현재 시간창 안에 아직 더 읽을 row가 있는 경우다. 이때는 “이 시간창은 끝났어”라고 말하지 않는다. repository가 만든 내부 cursor를 그대로 사용해 같은 시간창 안에서 이어 읽는다.

현재 시간창 안에 아직 남은 row가 있음
→ 같은 시간창 내부 cursor로 계속 읽기
→ 창문을 통째로 건너뛰지 않음

두 번째는 현재 시간창을 정말 다 읽은 경우다. 이때만 cursor를 시간창의 아래쪽 경계보다 1마이크로초 이전으로 보낸다.

읽은 창문: 12:00 → 08:00
다음 시작점: 08:00 - 1µs

이 말은 08:00 row를 버린다는 뜻이 아니다. 그 row는 방금 읽은 창문 안에 이미 포함되어 있다. 다음 요청은 08:00을 다시 물지 않고, 그보다 오래된 구간으로 내려간다.

이미 본 경계 row: 다시 조회하지 않음
아직 안 본 이전 row: 다음 요청에서 조회함

그래서 이번 수정은 “중복을 막기 위해 대충 한 칸 스킵”한 것이 아니라, “현재 창문을 다 읽었는지 확인한 뒤에만 다음 창문으로 이동”하는 방식이다.

물론 시간 기반 cursor에는 늘 조심할 점이 남는다. 같은 timestamp에 row가 아주 많이 몰리면 시간 하나만으로는 장기적으로 부족할 수 있다. 그 수준까지 더 단단하게 만들려면 언젠가는 executedAt + idexecutedAt + seq 같은 보조 정렬키를 cursor에 넣는 편이 더 좋다.

하지만 이번 timeout 버그의 직접 원인과 패치 범위만 놓고 보면, 결론은 이렇게 말할 수 있다.

이번 패치는 이미 읽은 시간창을 다시 읽는 문제를 막는다.
아직 읽지 않은 시간창을 건너뛰는 방식은 아니다.

이 차이가 중요하다. 좋은 책갈피는 같은 페이지로 돌아가지도 않고, 다음 페이지를 찢어버리지도 않는다.

이번 버그가 알려준 것

이번 일의 교훈은 꽤 단순하다.

무한 스크롤 버그는 화면에 보이지만, 원인은 종종 책갈피에 있다.

프론트엔드가 같은 row를 두 번 붙였다고 해서 무조건 프론트엔드 문제는 아니다. DB에 같은 row가 보인다고 해서 무조건 저장 문제도 아니다. 페이지네이션에서는 “다음 시작점”이 진짜 범인일 때가 많다.

특히 시간 기반 cursor는 더 조심해야 한다.

시간은 선처럼 보이지만, 실제 코드는 경계로 동작한다.

from 포함?
to 포함?
같은 timestamp 여러 개는?
다음 cursor는 현재 row인가, 바로 이전인가?
timeout 중간 반환은 어디까지 소비한 것으로 볼 것인가?

이 질문에 대한 답이 하나라도 어긋나면, 화면은 같은 페이지를 다시 읽는다.

내가 좋아한 포인트

개인적으로 이번 패치에서 가장 마음에 든 부분은 “거대한 해결책”을 쓰지 않았다는 점이다.

프론트엔드에서 dedupe를 넣을 수도 있었다. SQL을 크게 바꿀 수도 있었다. 스캐너 구조를 갈아엎을 수도 있었다.

하지만 진짜 문제는 작고 정확했다.

책갈피가 잘못 꽂혔다.

그래서 책갈피를 올바른 위치로 옮겼다.

그게 좋은 패치다.

버그를 멋지게 이기는 방법은 가끔 엄청난 무기를 꺼내는 게 아니라, 책을 어디까지 읽었는지 정직하게 기억하는 것이다.

다음에 무한 스크롤에서 같은 데이터가 반복된다면 이렇게 물어보면 좋겠다.

“혹시 우리는 이미 읽은 페이지에 책갈피를 다시 꽂고 있는 건 아닐까?”