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

난이도 중 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// 시나리오: 운영 도구의 대량 보상 지급 배치(예: 점검 보상, 이벤트 일괄 지급).
// 조건에 맞는 모든 유저에게 보상을 한 번씩 지급한다. 유저가 수백만이라 한 번에 다 읽지 못하므로
// 페이지 단위(LIMIT/OFFSET)로 끊어 읽으며 처리한다.
// 배치가 도는 수십 분 동안에도 서비스는 살아 있어 신규 가입(INSERT)·탈퇴(DELETE)가 계속 일어난다.
//
// 요구사항:
//  - 대상 유저는 "정확히 한 번씩" 보상을 받는다(누락도 중복도 없어야 한다).
//  - 배치 중 발생하는 동시 INSERT/DELETE 에도 위 보장이 유지돼야 한다.
//
// 과제: (A)(B) 를 중심으로, 페이징이 도는 동안 테이블이 변할 때
//       어떤 유저가 누락되거나 중복 지급될 수 있는지(실행 시나리오)와
//       페이징/지급 양쪽의 수정안을 제시하라.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

public sealed class User { public long Id; public DateTime CreatedAt; public bool Eligible; }

public interface IUserRepo
{
    // SELECT * FROM users ORDER BY created_at LIMIT @limit OFFSET @offset
    Task<List<User>> PageByOffsetAsync(int offset, int limit);
}

public interface IRewardService
{
    Task GrantAsync(long userId);   // 우편으로 보상 1건 발송
}

public sealed class RewardBatch
{
    private readonly IUserRepo _repo;
    private readonly IRewardService _reward;
    public RewardBatch(IUserRepo repo, IRewardService reward) { _repo = repo; _reward = reward; }

    public async Task RunAsync()
    {
        const int PageSize = 500;
        int offset = 0;                                              // (A)
        while (true)
        {
            // created_at 순으로 정렬해 offset~offset+limit 구간을 읽는다.
            var page = await _repo.PageByOffsetAsync(offset, PageSize);
            if (page.Count == 0) break;

            foreach (var u in page)
            {
                if (u.Eligible)
                    await _reward.GrantAsync(u.Id);                 // (B) 읽은 즉시 지급
            }

            offset += PageSize;                                     // 다음 페이지
        }
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.