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++
// 시나리오: 운영 도구의 대량 보상 지급 배치(예: 점검 보상, 이벤트 일괄 지급).
// 조건에 맞는 모든 유저에게 보상을 한 번씩 지급한다. 유저가 매우 많아 한 번에 다 읽지 못하므로
// 페이지 단위(limit/offset)로 끊어 읽으며 처리한다.
// 배치가 도는 동안에도 서비스는 살아 있어 신규 가입(insert)·탈퇴(erase)가 계속 일어난다.
//
// 요구사항:
// - 대상 유저는 "정확히 한 번씩" 보상을 받는다(누락도 중복도 없어야 한다).
// - 배치 중 발생하는 동시 insert/erase 에도 위 보장이 유지돼야 한다.
//
// 과제: (A)(B) 를 중심으로, 페이징이 도는 동안 테이블이 변할 때
// 어떤 유저가 누락되거나 중복 지급될 수 있는지와 페이징/지급 양쪽의 수정안을 제시하라.
#include <vector>
#include <mutex>
#include <cstdint>
#include <cstdio>
struct User {
std::int64_t id;
std::int64_t created_at; // 정렬 키(밀리초)
bool eligible;
};
// 동시 접근되는 "users 테이블"을 흉내 낸 저장소.
class UserRepo {
public:
// created_at 순으로 정렬돼 있다고 가정. [offset, offset+limit) 구간을 복사해 반환.
std::vector<User> page_by_offset(int offset, int limit) {
std::lock_guard<std::mutex> lk(mu_);
std::vector<User> out;
for (int i = offset; i < offset + limit && i < (int)rows_.size(); ++i)
out.push_back(rows_[i]);
return out;
}
// 서비스가 만드는 동시 변경(가입/탈퇴).
void insert_front(User u) { std::lock_guard<std::mutex> lk(mu_); rows_.insert(rows_.begin(), u); }
void erase_id(std::int64_t id) {
std::lock_guard<std::mutex> lk(mu_);
for (auto it = rows_.begin(); it != rows_.end(); ++it)
if (it->id == id) { rows_.erase(it); return; }
}
void seed(std::vector<User> v) { std::lock_guard<std::mutex> lk(mu_); rows_ = std::move(v); }
private:
std::mutex mu_;
std::vector<User> rows_;
};
class RewardService {
public:
void grant(std::int64_t /*user_id*/) { /* 우편 보상 1건 발송 */ }
};
class RewardBatch {
public:
RewardBatch(UserRepo& repo, RewardService& reward) : repo_(repo), reward_(reward) {}
void run() {
constexpr int kPageSize = 500;
int offset = 0; // (A)
for (;;) {
auto page = repo_.page_by_offset(offset, kPageSize);
if (page.empty()) break;
for (const auto& u : page)
if (u.eligible) reward_.grant(u.id); // (B) 읽은 즉시 지급
offset += kPageSize; // 다음 페이지
}
}
private:
UserRepo& repo_;
RewardService& reward_;
};
int main() {
UserRepo repo;
std::vector<User> seed;
for (std::int64_t i = 0; i < 2000; ++i) seed.push_back({i, i, true});
repo.seed(std::move(seed));
RewardService reward;
RewardBatch batch(repo, reward);
batch.run(); // 실제로는 동시에 insert_front/erase_id 가 일어난다
std::printf("batch done\n");
return 0;
} 내 리뷰 · C#
내 답안 · 자동 저장
작성 후 위 해설 보기에서 모범 해설과 대조하세요.
내 리뷰 · C++
내 답안 · 자동 저장
작성 후 위 해설 보기에서 모범 해설과 대조하세요.