← 문제로

7. OFFSET 페이지네이션의 스큐 (동시 변경 중 누락/중복 지급)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — OFFSET 페이지네이션의 스큐 (동시 변경 중 누락/중복 지급)

난이도: 중

요약

배치는 LIMIT/OFFSET으로 테이블 전체를 훑으면서 (B)에서 읽은 즉시 보상을 지급한다. 그런데 OFFSET은 "지금 이 순간의 정렬 결과에서 n번째"라는 상대 위치일 뿐이다. 페이지 사이에 행이 삽입/삭제되면 윈도우가 밀려 일부 유저가 건너뛰어지거나(누락) 다시 등장(중복 지급) 한다. 지급(GrantAsync)이 비멱등이라 중복은 그대로 이중 지급이 된다.

문제점

  • [불안정 페이지네이션 / OFFSET 스큐]
    • 증상: 일부 유저는 보상을 못 받고, 일부는 두 번 받는다.
    • 재현 조건(삭제로 인한 누락): 1페이지(offset 0~499)를 처리한 직후 그 구간의 유저 한 명이 탈퇴(DELETE)하면, 원래 offset 500이던 유저가 offset 499로 당겨진다. 다음 쿼리는 offset 500부터 읽으므로 그 유저는 영영 건너뛴다.
    • 재현 조건(삽입으로 인한 중복): 정렬 키보다 앞쪽에 신규 행이 INSERT 되면 모든 뒤 행의 위치가 +1 밀려, 이미 처리한 유저가 다음 페이지 범위로 들어와 다시 읽힌다.
    • 근본 원인 (A): OFFSET은 절대 식별자가 아니라 "현재 결과 집합의 순번"이다. 결과 집합이 페이지 간에 바뀌면 같은 행을 가리키지 않는다. 즉 여러 쿼리에 걸친 안정 스냅샷이 없다.
  • [불안정 정렬 키]
    • ORDER BY created_at은 동일 시각(타이브레이크 없음) 행들의 상대 순서를 보장하지 않는다. 같은 created_at이 페이지 경계에 걸리면 그 자체로 누락/중복이 난다.
  • [비멱등 처리]
    • (B) GrantAsync는 호출 때마다 우편을 보낸다. 페이징이 완벽해도, 배치 재시작/재시도가 있으면 이중 지급이 발생한다.

수정안

키셋(seek) 페이지네이션으로 마지막으로 본 절대 키 이후를 읽고, 지급은 멱등 키로 보호한다.

public interface IUserRepo
{
    // SELECT * FROM users WHERE id > @afterId ORDER BY id LIMIT @limit  (id = PK, 유일·불변)
    Task<List<User>> PageAfterAsync(long afterId, int limit);
}

public async Task RunAsync()
{
    const int PageSize = 500;
    long lastId = 0;                       // 절대 위치(이미 처리한 마지막 PK)
    while (true)
    {
        var page = await _repo.PageAfterAsync(lastId, PageSize);
        if (page.Count == 0) break;

        foreach (var u in page)
        {
            if (u.Eligible)
                // 멱등 지급: (batchId, userId) UNIQUE 로 중복 차단
                await _reward.GrantOnceAsync(batchId, u.Id);
            lastId = u.Id;                 // 키 기준 전진(스큐와 무관)
        }
    }
}

핵심:

  • 불변·유일 키(PK)로 정렬하고 WHERE id > @lastId로 전진하면, 중간에 INSERT/DELETE가 일어나도 "이미 본 키 이후"라는 기준은 흔들리지 않는다.
  • 멱등 지급: reward_log(batch_id, user_id)에 UNIQUE 제약을 두고 INSERT가 성공할 때만 지급(또는 INSERT ... ON CONFLICT DO NOTHING). 재시작/재시도에도 한 번만 지급된다.

더 나은 설계

  • 스냅샷 일관성 읽기: 한 트랜잭션의 REPEATABLE READ/MVCC 스냅샷에서 전부 읽으면 변경과 무관한 일관된 뷰를 얻는다. 다만 수백만 행을 한 트랜잭션·한 커서로 오래 잡으면 언두 누적/롱 트랜잭션 문제가 생긴다. 그래서 보통은 키셋 페이징 + 멱등이 운영상 안전하다.
  • 대상 동결(스냅샷 테이블): 배치 시작 시 대상 user_id 목록을 별도 테이블/큐에 한 번 적재하고, 그 목록을 키셋으로 소비하며 처리 완료를 체크한다. 이후 가입/탈퇴와 완전히 분리된다.
  • 트레이드오프: 키셋은 임의 페이지 점프가 안 되고(다음/이전만) 정렬이 PK에 묶인다. 스냅샷 테이블은 추가 저장·적재 비용이 든다. 그러나 대량 일괄 작업의 정확성에는 둘 중 하나가 사실상 필수다.

면접 포인트

  • "LIMIT/OFFSET 페이징은 데이터가 변하지 않을 때만 안전하다"는 점, 그리고 키셋(seek) 페이징이 왜 동시 변경에 강한지 설명할 수 있는가.
  • 정렬 키는 유일·불변이어야 안정적이다(타이브레이크로 PK 추가). created_at 단독 정렬의 위험.
  • 페이징 정확성과 별개로 "처리"는 멱등해야 한다 — UNIQUE 제약/처리 로그로 정확히-한-번을 보장.