← 문제로

19. auto-increment ID 를 연속이라 가정한 구간 배치의 누락 (C#)

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

해설 — auto-increment ID 를 연속이라 가정한 구간 배치의 누락 (C#)

난이도: 하

요약

배치가 id빈틈없이 연속이라 가정하고 [from, from+BatchSize] 구간으로 청크를 훑는다. 하지만 DB auto-increment 는 트랜잭션 롤백·IDENTITY 캐시(예: SQL Server 의 1000 단위 점프, 재시작 시 시퀀스 갭)·삭제 등으로 갭(구멍) 이 생긴다. 어떤 청크 구간이 통째로 비면 (B) 가 이를 "표의 끝"으로 오인해 루프를 종료하고, 그 갭 뒤에 있는 모든 캐릭터가 영영 보상을 못 받는다.

문제점

  • [잘못된 가정] auto-increment 연속성 가정 → 갭 구간을 끝으로 오인
    • 증상: (A) 구간 [lastId+1, lastId+1000] 안에 우연히 행이 하나도 없으면(그 범위 id 가 전부 롤백/캐시 점프로 비었음) rows.Count==0(B)break → 배치 종료. 실제로는 그 뒤(더 큰 id)에 미처리 캐릭터가 남아 있다.
    • 재현: 대량 가입 트랜잭션 일부 롤백, 서버 재시작으로 IDENTITY 시퀀스가 1000 단위 점프(SQL Server 기본), 또는 큰 삭제 블록. 갭 폭이 BatchSize 이상이면 확정적으로 재현.
    • 근본원인: (C)lastId += BatchSizeid 를 연속이라 가정하고 고정 폭으로 전진한다. 그리고 (B) 가 "빈 청크 = 데이터 끝"이라는 잘못된 종료 조건을 쓴다. auto-increment 는 유일·단조 증가만 보장하고 연속을 보장하지 않는다.

수정안

연속성을 가정하지 말고 키셋(keyset) 페이지네이션으로 "마지막으로 본 실제 id 이후"를 계속 따라간다. 종료는 "가져온 행이 없을 때"가 아니라 "LIMIT 미만으로 왔을 때"로 판정한다.

public async Task ProcessNewCharacters()
{
    long lastSeen = await LoadCheckpoint();
    while (true)
    {
        var rows = await _db.QueryAsync<Character>(
            "SELECT id, name FROM characters WHERE id > @after ORDER BY id LIMIT @n",
            new { after = lastSeen, n = BatchSize });          // 실제 다음 행들부터 N개

        if (rows.Count == 0) break;                            // 정말로 뒤에 아무것도 없음

        foreach (var c in rows) await GiveWelcomeReward(c);
        lastSeen = rows[^1].Id;                                // 마지막 '실제' id 로 전진
        await SaveCheckpoint(lastSeen);

        if (rows.Count < BatchSize) break;                     // 마지막 페이지
    }
}

키셋 방식은 갭이 있어도 id > lastSeen 이 항상 다음 실존 행을 찾으므로 구멍을 건너뛰며 끝까지 훑는다.

더 나은 설계

  • 처리 상태를 행에 표시: rewarded_at IS NULL 같은 플래그로 대상을 정의하면 id 순서·연속성과 무관하게 "아직 안 준 것"을 정확히 집는다. 멱등성(중복 지급 방지)도 자연히 얻는다.
  • 아웃박스/이벤트 기반: 캐릭터 생성 시 "웰컴 보상 필요" 작업을 아웃박스에 넣고 워커가 소비 — id 스캔 자체가 불필요.
  • 굳이 id 로 훑어야 하면 실제 최대 id(SELECT MAX(id))를 종료 기준으로 삼아 빈 청크에서 멈추지 않게 한다.

면접 포인트

  • "auto-increment 는 연속이 아니다" — 롤백·IDENTITY 캐시·시퀀스 점프·삭제로 갭이 생긴다. id 를 개수·순번·연속 커서로 쓰면 버그가 난다.
  • 빈 청크 = 끝은 구간 스캔의 함정. 키셋 페이지네이션(WHERE id > @last ORDER BY id LIMIT N)이 갭에 강하고 인덱스 효율도 좋다(OFFSET 대비).
  • 근본적으로는 "처리 여부"를 데이터로 표현(플래그/아웃박스)하는 편이 스캔 커서 방식보다 견고함을 설명할 수 있어야 한다.