← 문제로

10. 메시지 큐 소비자의 오프셋 커밋 순서·멱등성·리밸런스 결함 (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 콜백·펜싱 토큰으로 어떻게 막는가.