7. OFFSET 페이지네이션의 스큐 (동시 변경 중 누락/중복 지급)
난이도 중내 리뷰 · C#
내 리뷰 · 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 제약/처리 로그로 정확히-한-번을 보장.
해설 · C++
해설 — OFFSET 페이지네이션의 스큐 (동시 변경 중 누락/중복 지급)
난이도: 중
요약
배치는 page_by_offset(offset, limit)로 테이블을 훑으며 (B)에서 읽은 즉시 보상을 지급한다. offset은 "지금 정렬 결과에서 n번째"라는 상대 위치일 뿐이라, 페이지 사이에 행이 삽입/삭제되면 윈도우가 밀려 일부 유저가 건너뛰어지거나(누락) 다시 등장(중복 지급) 한다.
문제점
- [불안정 페이지네이션 / OFFSET 스큐]
- 증상: 일부 유저 누락, 일부 이중 지급.
- 재현(삭제→누락): 1페이지(
[0,500)) 처리 후 그 구간 유저가erase_id로 빠지면 원래 인덱스 500 행이 499로 당겨진다. 다음 호출은offset=500부터 읽어 그 행을 건너뛴다. - 재현(앞쪽 삽입→중복):
insert_front로 앞에 행이 들어오면 모든 뒤 행 인덱스가 +1 밀려, 이미 처리한 행이 다음 페이지로 들어와 다시 읽힌다. - 근본 원인
(A):offset은 절대 식별자가 아니라 현재 컨테이너의 순번이다. 페이지 간에 컨테이너가 바뀌면 같은 행을 가리키지 않는다(여러 쿼리에 걸친 안정 스냅샷 부재).
- [불안정 정렬 키]
created_at은 동률 시 상대 순서를 보장하지 않는다. 같은 값이 페이지 경계에 걸리면 누락/중복.
- [비멱등 처리]
(B)grant는 호출마다 발송한다. 배치 재시작/재시도가 있으면 이중 지급.
수정안
키셋(seek) 페이지네이션으로 마지막으로 본 절대 키(id) 이후를 읽는다.
// 저장소: id 로 정렬되어 있고, "after_id 초과 첫 limit 개"를 반환.
std::vector<User> page_after(std::int64_t after_id, int limit) {
std::lock_guard<std::mutex> lk(mu_);
std::vector<User> out;
// rows_ 가 id 오름차순이라고 가정(아니면 인덱스/정렬 보장 필요).
for (const auto& u : rows_) {
if (u.id <= after_id) continue;
out.push_back(u);
if ((int)out.size() == limit) break;
}
return out;
}
void RewardBatch::run() {
constexpr int kPageSize = 500;
std::int64_t last_id = -1; // 이미 처리한 마지막 PK
for (;;) {
auto page = repo_.page_after(last_id, kPageSize);
if (page.empty()) break;
for (const auto& u : page) {
if (u.eligible) reward_.grant_once(batch_id_, u.id); // 멱등 지급
last_id = u.id; // 키 기준 전진(스큐와 무관)
}
}
}
핵심:
- 불변·유일 키(id)로 정렬 +
id > last_id로 전진하면, 중간 insert/erase에도 "이미 본 키 이후"라는 기준은 흔들리지 않는다. - 멱등 지급:
grant_once(batch_id, user_id)는(batch_id,user_id)유일성으로 중복을 차단(예: 처리 로그 테이블 UNIQUE). 재시작에도 한 번만.
더 나은 설계
- 스냅샷 일관성 읽기: 한 트랜잭션의
REPEATABLE READ/MVCC 스냅샷으로 일관된 뷰를 읽는다. 단 대량 데이터를 한 커서로 오래 잡으면 언두 누적/롱 트랜잭션 문제. 보통 키셋+멱등이 운영상 안전. - 대상 동결: 시작 시 대상 id 목록을 별도 테이블/큐에 적재하고 키셋으로 소비하며 완료 체크 → 이후 가입/탈퇴와 분리.
- 트레이드오프: 키셋은 임의 점프 불가(다음/이전만), 정렬이 키에 묶임. 스냅샷 테이블은 적재 비용. 그러나 대량 일괄 작업의 정확성엔 사실상 필수.
면접 포인트
offset/limit페이징은 데이터가 정지해 있을 때만 안전하다 — 키셋(seek) 페이징이 동시 변경에 강한 이유.- 정렬 키는 유일·불변이어야 안정적(타이브레이크로 PK 추가).
created_at단독 정렬의 위험. - 페이징 정확성과 별개로 처리는 멱등이어야 정확히-한-번 — UNIQUE 제약/처리 로그.