19. auto-increment ID 를 연속이라 가정한 구간 배치의 누락 (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 += BatchSize로 id 를 연속이라 가정하고 고정 폭으로 전진한다. 그리고(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 대비). - 근본적으로는 "처리 여부"를 데이터로 표현(플래그/아웃박스)하는 편이 스캔 커서 방식보다 견고함을 설명할 수 있어야 한다.
해설 — auto-increment ID 를 연속이라 가정한 구간 배치의 누락 (C++)
난이도: 하
요약
배치가 id 를 빈틈없이 연속이라 가정하고 (lastId, lastId+BatchSize] 구간으로 청크를 훑는다. auto-increment 는 트랜잭션 롤백·시퀀스 캐시 점프·재시작·삭제로 갭이 생기는데, 어떤 청크 구간이 통째로 비면 (B) 가 이를 "표의 끝"으로 오인해 종료하고, 갭 뒤의 모든 캐릭터가 영영 보상을 못 받는다. (언어 무관한 데이터 접근 로직 결함으로 C++ 배치에서도 동일.)
문제점
- [잘못된 가정] auto-increment 연속성 가정 → 갭 구간을 끝으로 오인
- 증상:
(A)구간(lastId, lastId+1000]안에 행이 하나도 없으면rows.empty()→(B)break→ 종료. 실제로는 더 큰 id 에 미처리 행이 남아 있다. - 재현: 대량 가입 일부 롤백, 재시작 시 시퀀스 캐시 점프(수백~수천 갭), 큰 삭제 블록. 갭 폭이 BatchSize 이상이면 확정 재현.
- 근본원인:
(C)lastId += BatchSize로 id 를 연속 가정하며 고정 폭 전진하고,(B)가 "빈 청크 = 데이터 끝"이라는 잘못된 종료 조건을 쓴다. auto-increment 는 유일·단조 증가만 보장하고 연속은 보장하지 않는다.
- 증상:
수정안
연속성 가정을 버리고 키셋 페이지네이션으로 "마지막으로 본 실제 id 이후 N개"를 따라간다. 종료는 "빈 결과" 또는 "LIMIT 미만"으로 판정한다.
// IDb 에 keyset 조회 추가: id > after 인 행을 id 오름차순 최대 n개
// virtual std::vector<Character> queryAfter(int64_t after, int n) = 0;
void processNewCharacters() {
static constexpr int BatchSize = 1000;
int64_t lastSeen = loadCheckpoint();
while (true) {
std::vector<Character> rows = db_.queryAfter(lastSeen, BatchSize); // 실제 다음 행들
if (rows.empty()) break; // 정말로 끝
for (const auto& c : rows) giveWelcomeReward(c);
lastSeen = rows.back().id; // 마지막 '실제' id
saveCheckpoint(lastSeen);
if ((int)rows.size() < BatchSize) break; // 마지막 페이지
}
}
id > lastSeen 은 갭이 있어도 항상 다음 실존 행을 찾으므로 구멍을 건너뛰며 끝까지 훑는다.
더 나은 설계
- 처리 상태를 행에 표시:
rewarded_at IS NULL대상 정의로 id 순서·연속성과 무관하게 "아직 안 준 것"을 정확히 집고 멱등성도 확보. - 아웃박스/이벤트 기반: 캐릭터 생성 시 보상 작업을 아웃박스에 넣고 워커가 소비 — id 스캔 불필요.
- 굳이 id 스캔이면
MAX(id)를 종료 기준으로 삼아 빈 청크에서 멈추지 않게 한다.
면접 포인트
- auto-increment 는 연속이 아니다 — 롤백·시퀀스 캐시·삭제로 갭이 생긴다. id 를 개수·순번·연속 커서로 오용하면 버그.
- 빈 청크 = 끝은 구간 스캔의 함정. 키셋 페이지네이션이 갭에 강하고 OFFSET 보다 인덱스 효율도 좋다.
- 근본적으로 "처리 여부"를 데이터로 표현(플래그/아웃박스)하는 편이 스캔 커서보다 견고함을 설명할 수 있어야 한다.