통합과 배포 · 받아 오는 동안에도 원본은 바뀐다 · 이론
도는 동안 원본이 바뀐다
한 줄 요약
계속 바뀌는 원본을 페이지로 나눠 받을 때 자리를 번호(offset)로 잡으면 행이 조용히 사라지고, 자리를 마지막으로 본 값(cursor)으로 잡으면 사라지지 않는다. 그 값은 시각 하나가 아니라 (updated_at, id) 두 칸이어야 한다.
왜 이게 필요했나
"고객 목록을 매일 다 받아 옵니다" 라는 동기화가 있다. 60만 건을 1000건씩 600번에 나눠 받는다. 그런데 그 600번을 도는 동안 원본도 계속 바뀐다.
ORDER BY updated_at 로 정렬해 LIMIT 1000 OFFSET 10000 씩 받는다고 하자. 우리가 11번째 페이지를 받기 직전에, 이미 지나온 페이지에 있던 행 3건이 갱신되어 updated_at 이 지금 시각으로 바뀌었다. 정렬 순서에서 그 3건은 맨 뒤로 간다. 그러면 뒤에 있던 모든 행이 앞으로 세 칸씩 당겨진다. 오프셋 10000 은 원래 10000번째 행을 가리켰지만 이제 10003번째 행을 가리킨다. 그 사이의 3건은 아무도 읽지 않은 채 지나간다.
이 사고의 특징은 셋이다. 오류가 나지 않는다. 건수는 대충 맞는다. 매번 다른 행이 빠진다. 그래서 재현이 안 되고, 몇 달 뒤 "이 고객이 우리 쪽에 없는데요" 라는 신고로 돌아온다.
어떻게 동작하나
고치는 방법은 자리를 번호가 아니라 값으로 잡는 것이다. 마지막으로 읽은 행의 정렬 키를 기억하고, 다음 요청에서 "그 값보다 큰 것부터" 를 달라고 한다. 흔히 키셋 페이지네이션 또는 커서 페이지네이션이라고 부른다.
오프셋: "10000번째부터 1000개" ← 앞이 밀리면 가리키는 곳이 달라진다커서: "이 값보다 큰 것 1000개" ← 앞이 밀려도 이 값보다 큰 것은 그대로다여기서 두 번째 함정이 나온다. 정렬 키가 유일하지 않다는 것이다. updated_at 은 초 단위라 같은 값을 가진 행이 흔히 여럿이다. 커서를 updated_at 하나로만 잡으면 둘 중 하나가 된다.
WHERE updated_at > :last로 하면, 같은 시각을 가진 나머지 행들이 통째로 사라진다.WHERE updated_at >= :last로 하면, 이미 읽은 행을 다시 읽는다. 같은 시각의 행이 페이지 크기보다 많으면 영원히 같은 자리를 돈다.
그래서 커서는 유일해질 때까지 칸을 늘린다. 보통 (updated_at, id) 두 칸이면 충분하고, 비교도 두 칸을 함께 한다. (updated_at, id) > (:last_at, :last_id) 이다.
워터마크는 그 커서를 다음 실행까지 들고 가는 것이다. 중단됐을 때 처음부터 다시 받지 않으려면 워터마크가 디스크에 있어야 한다. 그리고 워터마크를 언제 저장하느냐가 중요하다. 받은 행을 처리하기 전에 저장하면 중단 시 그 페이지를 잃고, 처리한 뒤에 저장하면 중단 시 그 페이지를 다시 받는다. 잃는 것보다 다시 받는 것이 낫다 — 그래서 대부분의 동기화는 적어도 한 번(at-least-once) 으로 설계하고, 받는 쪽을 멱등하게 만든다.
그리고 이 방식에서는 중복이 정상이다. 우리가 지나온 행이 갱신되면 그 행은 커서 뒤로 와서 다시 잡힌다. 그것은 버그가 아니라 "그 사이에 바뀌었다" 는 사실 그대로다. 사본 쪽에서 id 를 열쇠로 덮어쓰면 결과는 옳다.
마지막은 대사(reconciliation) 다. 다 받았다고 믿지 말고 세어 본다. 원본의 건수와 사본의 건수, 그리고 양쪽에 다 있는 행의 값이 같은지까지 본다. 건수만 맞는 것은 맞는 것이 아니다 — 한 건이 빠지고 한 건이 중복되면 건수는 그대로다.
시각을 키로 쓸 때는 표기도 약속해야 한다. [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html)가 인터넷에서 쓰는 날짜·시각 표기를 정의한다. 문자열로 비교할 생각이라면 자리수와 시간대 표기가 모든 행에서 같아야 한다 — 2026-09-10T00:00:00Z 와 2026-09-10T00:00:00+00:00 은 같은 순간이지만 문자열 비교에서는 다르다. 페이지를 넘겨 주는 방식 자체는 [RFC 8288 의 Link 헤더](https://www.rfc-editor.org/rfc/rfc8288.html)로 next 를 주는 API 도 많으니, 붙기 전에 그쪽이 무엇을 주는지부터 확인한다.
현장에서 만나는 모습
첫째, "전체를 다시 받으면 되지 않나요" 가 자주 나온다. 데이터가 작으면 맞는 말이다. 다만 전체 재수집도 도는 동안 바뀌므로, 스냅샷 시점이 한 순간이 아니라 순회한 시간 전체에 걸쳐 있다는 점은 같다.
둘째, updated_at 을 안 갱신하는 원본이 있다. 어떤 필드를 고쳐도 시각이 그대로면 커서 방식은 그 변경을 영영 못 본다. 붙기 전에 "무엇을 바꾸면 이 시각이 바뀌는가" 를 반드시 묻는다. 삭제도 마찬가지다 — 행이 사라지면 커서로는 알 수 없으므로, 삭제를 알리는 방법이 따로 있어야 한다.
셋째, 시계가 뒤로 간다. 원본 서버 여러 대의 시계가 조금씩 다르면 나중에 쓴 행의 updated_at 이 앞선 행보다 작을 수 있다. 그러면 커서가 이미 지나온 자리라 영영 안 잡힌다. 그래서 워터마크를 약간 뒤로 물려서(예: 몇 초 전으로) 잡고 중복을 감수하는 구현이 많다.
넷째, 첫 실행이 가장 위험하다. 60만 건을 처음 받는 동안 원본은 계속 바뀐다. 첫 실행을 조용한 시간에 돌리고, 끝난 뒤 곧바로 한 번 더 돌려 그 사이의 변경을 따라잡는 것이 안전하다.
다음 실습에서 할 것
계속 바뀌는 원본 서버를 띄워 전체 60행의 스냅샷을 뜬다. 오프셋 방식으로 순회하면서 도중에 원본을 바꿔, 행이 빠지고 동시에 중복되는 것을 숫자로 확인한다. 그다음 (updated_at, id) 커서로 옮겨 같은 상황에서 누락이 0 이 되는 것을 본다. 워터마크를 파일에 남겨 두 번째 실행이 바뀐 것만 받아 오게 하고, 같은 시각을 가진 다섯 행의 경계를 페이지가 가르도록 만들어 커서가 견디는지 본다. 마지막으로 중단된 동기화를 이어받고, 원본과 사본을 대조해 값까지 맞는지 확인한다.