10. 메시지 큐 소비자의 오프셋 커밋 순서·멱등성·리밸런스 결함 (C#)
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 메시지 큐 소비자의 오프셋 커밋 순서·멱등성·리밸런스 결함 (C#)
난이도: 상
요약
(1) 처리 후 배치 오프셋을 한 번에 커밋하는데(at-least-once), 처리는 비멱등이라 커밋 전 크래시·재기동·재할당 시 배치가 재전달되어 이미 지급된 재화가 다시 지급된다. (2) 배치 중간 이벤트가 실패하면 오프셋이 커밋되지 않아 앞부분까지 통째로 재처리된다. (3) 리밸런스 콜백이 없어, 파티션이 다른 소비자로 넘어가는 순간 두 소비자가 같은 범위를 동시 처리한다. 근본은 "멱등 소비(중복 제거)"가 없다는 것.
문제점
- [비멱등 처리 + 재전달] (B)
GrantReward(e)→_wallet[e.PlayerId] = bal + e.Amount;- 증상: 같은
EventId가 두 번 전달되면 두 번 더해진다. 메시지 큐는 실질적으로 at-least-once(재시도·재할당·커밋 유실 시 재전달)라, 비멱등 처리는 곧 이중 지급. - 재현:
Poll이 100건을 지급한 뒤CommitOffset직전에 프로세스 크래시 → 재기동 시 같은 100건 재fetch → 전부 다시 지급. - 근본원인: 처리 멱등성(EventId 기반 dedup) 부재.
EventId필드가 있는데도 쓰지 않는다.
- 증상: 같은
- [배치 오프셋 일괄 커밋 / 부분 실패] (A)
_mq.CommitOffset(_partition, batch.last+1)- 증상: 50번째 이벤트에서 예외가 나면 커밋이 안 되고, 재시도 시 1~49가 다시 지급된다. 반대로 처리 전에 커밋했다면(at-most-once) 크래시 시 유실. 어느 쪽이든 "정확히 한 번"이 깨진다.
- 근본원인: 오프셋 진행 단위와 처리 원자성이 어긋남 + 멱등성 부재.
- [리밸런스 미처리] (A)(B) 공통
- 증상: 컨슈머 그룹 재할당 시 이 소비자가 파티션 소유를 잃었는데도 in-flight 배치를 계속 처리하고, 새 소유자도 커밋되지 않은 오프셋부터 처리 → 동시 이중 처리. 커밋을 남의 파티션에 하려다 거부/충돌.
- 근본원인:
OnPartitionsRevoked/OnPartitionsAssigned훅에서 in-flight 중단·오프셋 플러시 부재.
수정안
멱등 소비(dedup 테이블) + 처리·오프셋을 한 트랜잭션으로(또는 dedup 으로 재전달 흡수) + 리밸런스 콜백에서 중단·커밋.
// 1) 멱등 소비: 처리한 EventId 를 (원자적으로) 기록하고, 이미 있으면 건너뜀.
private bool TryMarkProcessed(string eventId) // DB UNIQUE(eventId) INSERT 또는 SETNX
=> _dedup.Add(eventId); // 이미 있으면 false
private void GrantReward(RewardEvent e)
{
// 지급과 dedup 마킹을 같은 트랜잭션에서 원자적으로
if (!TryMarkProcessed(e.EventId)) return; // (B) 재전달 흡수 → 정확히 한 번 효과
_wallet.TryGetValue(e.PlayerId, out long bal);
_wallet[e.PlayerId] = bal + e.Amount;
}
public void Poll(CancellationToken groupToken)
{
var batch = _mq.Fetch(_partition, _offset, 100);
foreach (var e in batch)
{
if (groupToken.IsCancellationRequested) return; // (A) 리밸런스 시 즉시 중단
GrantReward(e); // 멱등이라 재처리 안전
_offset = e.Offset + 1;
_mq.CommitOffset(_partition, _offset); // 항목 단위 진행(또는 주기 커밋)
}
}
// 리밸런스 훅
public void OnPartitionsRevoked() { /* in-flight 중단 + 마지막 오프셋 플러시 */ }
더 나은 설계
- 멱등 키가 핵심: at-least-once 를 받아들이고 소비 측 dedup(EventId UNIQUE) 으로 "정확히 한 번 효과(effectively-once)"를 만든다. 정확히 한 번 전달은 분산에서 사실상 불가.
- 트랜잭셔널 처리: 지급 + dedup 마킹 + 오프셋 저장을 같은 DB 트랜잭션에 넣으면(오프셋을 애플리케이션 DB에 저장) 커밋 원자성이 보장된다(
persistence_data/problem6아웃박스와 대칭). - 리밸런스 안전: revoked 콜백에서 진행 오프셋을 커밋하고 in-flight 를 취소. 파티션 소유 토큰(펜싱)으로 옛 소유자의 늦은 커밋을 거부(
distributed_systems/problem6). - 파티션 키 = 엔티티 키: 같은 플레이어 이벤트가 한 파티션에 순서대로 오도록 키를 잡아 순서 역전 방지.
- DLQ + 재시도 상한: 독성 메시지(항상 실패)는 배치를 막지 말고 dead-letter 로 격리.
면접 포인트
- "정확히 한 번 전달"은 불가 → at-least-once + 멱등 소비로 effectively-once 를 만드는 전형.
- 오프셋 커밋을 처리 전에 하면 유실(at-most-once), 후에 하면 중복(at-least-once) — 멱등성이 없으면 어느 쪽도 안전하지 않은 이유.
- 리밸런스가 왜 이중 처리를 만들고, revoked 콜백·펜싱 토큰으로 어떻게 막는가.
해설 · C++
해설 — 메시지 큐 소비자의 오프셋 커밋 순서·멱등성·리밸런스 결함 (C++)
난이도: 상
요약
(1) 처리 후 배치 오프셋을 일괄 커밋(at-least-once)하는데 처리는 비멱등이라, 커밋 전 크래시·재기동·재할당 시 배치가 재전달되어 이중 지급된다. (2) 배치 중간 실패 시 앞부분까지 재처리. (3) 리밸런스 콜백이 없어 파티션 이관 순간 두 소비자가 같은 범위를 동시 처리하고, C++ 에선 여기에 더해 두 소비자 스레드가 같은 wallet_ 맵을 동기화 없이 갱신해 데이터 레이스(UB) 가 겹친다.
문제점
- [비멱등 처리 + 재전달] (B)
(*wallet_)[e.playerId] += e.amount;- 증상: 같은
eventId재전달 시 두 번 더해진다. MQ 는 실질 at-least-once(재시도/재할당/커밋 유실)라 비멱등 처리 = 이중 지급. - 재현: 100건 지급 후
commitOffset직전 크래시 → 재기동 시 같은 100건 재fetch → 전부 재지급. - 근본원인:
eventId기반 dedup(멱등 소비) 부재. 필드는 있는데 안 쓴다.
- 증상: 같은
- [배치 일괄 커밋 / 부분 실패] (A)
mq_->commitOffset(partition_, batch.back().offset+1)- 증상: 중간에서 예외/실패면 커밋 안 됨 → 재시도 시 앞부분 재지급. 처리 전 커밋(at-most-once)이면 크래시 시 유실. 멱등성 없이는 어느 쪽도 "정확히 한 번" 아님.
- [리밸런스 미처리 + 공유 맵 레이스] (A)(B) 공통
- 증상: 재할당 시 파티션 소유를 잃고도 in-flight 배치를 계속 처리. 새 소유자도 미커밋 오프셋부터 처리 → 동시 이중 처리. 게다가
std::unordered_map을 두 스레드가 동시operator[](삽입 시 rehash) 하면 UB(순회 중 무효화·힙 손상). - 근본원인: revoked/assigned 훅에서 in-flight 중단·플러시 부재 + 공유 상태 동기화 부재.
- 증상: 재할당 시 파티션 소유를 잃고도 in-flight 배치를 계속 처리. 새 소유자도 미커밋 오프셋부터 처리 → 동시 이중 처리. 게다가
수정안
멱등 소비(dedup) + 처리·dedup·오프셋을 원자적으로 + 리밸런스 콜백에서 중단 + 공유 맵 보호.
#include <mutex>
#include <unordered_set>
class RewardConsumer {
public:
void poll(const std::atomic<bool>& revoked) {
auto batch = mq_->fetch(partition_, offset_, 100);
for (const auto& e : batch) {
if (revoked.load(std::memory_order_acquire)) return; // (A) 리밸런스 즉시 중단
grantReward(e); // 멱등 → 재처리 안전
offset_ = e.offset + 1;
mq_->commitOffset(partition_, offset_); // 항목 단위(또는 주기) 커밋
}
}
private:
void grantReward(const RewardEvent& e) {
std::lock_guard<std::mutex> lk(walletMtx_);
if (!processed_.insert(e.eventId).second) return; // (B) 이미 처리 → 흡수(dedup)
(*wallet_)[e.playerId] += e.amount; // 지급 + dedup 을 함께(원자)
}
std::mutex walletMtx_;
std::unordered_set<std::string> processed_; // 실서비스: DB UNIQUE(eventId)/Redis SETNX
// ... mq_, partition_, offset_, wallet_
};
실제로는
processed_를 프로세스 메모리가 아니라 영속 dedup(DB UNIQUE(eventId) / Redis SETNX) 에 두고, 지급·dedup·오프셋을 같은 트랜잭션에 넣어야 재기동에도 멱등이 유지된다.
더 나은 설계
- at-least-once + 멱등 소비 = effectively-once: 정확히 한 번 전달은 불가. 소비 측 dedup(eventId UNIQUE)이 표준 해법.
- 트랜잭셔널 오프셋: 오프셋을 애플리케이션 DB 에 지급·dedup 과 함께 커밋(외부 오프셋 저장) → 커밋 원자성.
- 리밸런스 안전 + 펜싱: revoked 콜백에서 오프셋 플러시·in-flight 취소, 파티션 소유 토큰(단조 epoch)으로 옛 소유자 커밋 거부(
distributed_systems/problem6). - 파티션 키=엔티티 키: 같은 플레이어 이벤트를 한 파티션에 순서대로. DLQ로 독성 메시지 격리(배치 막힘 방지).
- 공유 상태: 파티션-소비자 1:1 소유로 설계하면
wallet_동시 접근 자체가 없어진다(락보다 소유 분할이 우선).
면접 포인트
- "정확히 한 번"은 전달이 아니라 효과로 달성 — at-least-once + 멱등 dedup.
- 오프셋 커밋 시점(처리 전/후)의 유실 vs 중복 트레이드오프와 멱등성의 역할.
- C++ 관점: 리밸런스로 두 소비자가 공유 맵을 만지면 UB — 소유 분할/락으로 차단.