음악 스트리밍 파이프라인 만들기 - 04. Kafka 컨슈머 멱등성, 중복은 버그가 아니라 전제다
문제
재생 이벤트(초당 수백 건)를 소비해 곡별 재생수를 집계한다. 재생수는 카운터다 — 두 번 더하면 그냥 틀리고, 틀렸다는 사실조차 알기 어렵다.
그리고 Kafka 는 at-least-once 다. 리밸런싱, 오프셋 커밋 전 종료, 운영자의 오프셋 리셋,
max.poll.interval.ms 초과 — 컨슈머는 정상 동작 중에도 같은 메시지를 다시 받는다.
중복 소비는 장애가 아니라 전제 조건이다.
흔한 오해부터
”enable.idempotence=true 켰으니 안전하다”는 말이 자주 나온다. 아니다.
| 설정 | 막는 것 | 못 막는 것 |
|---|---|---|
| 프로듀서 멱등성 | 프로듀서 재시도로 인한 브로커 측 중복 저장 | 컨슈머 재소비 |
| Kafka 트랜잭션(EOS) | Kafka→Kafka 경로의 중복 | 외부 DB 쓰기 |
컨슈머가 DB 에 쓰는 순간, Kafka 가 주는 보장은 끝난다. 멱등성은 우리 몫이다.
대안 비교와 선택
| 대안 | 판단 |
|---|---|
| A. eventId UNIQUE + 신규분만 집계 | ✅ 채택 |
| B. 오프셋을 DB 에 저장, 집계와 한 트랜잭션 | 이론상 가장 정확하지만 기각 |
| C. Kafka EOS | 외부 DB 라 적용 불가 (오해 방지용으로 문서에 남김) |
| D. 중복 허용 + 주기 재집계 | 규모가 커지면 A 와 조합 재검토 |
B 를 기각한 결정타는 성능이 아니라 검증 가능성이었다. A 는 중복을 일부러
만들어(오프셋 되감기) 테스트로 증명할 수 있다. B 는 리밸런싱 시나리오 재현이 훨씬
어려워 “아마 맞을 것”에 기대게 된다. 덤으로 B 는 kafka-consumer-groups.sh 로
lag 을 보는 표준 운영 도구를 포기해야 한다.
채택한 알고리즘
1. 배치 내부 중복 제거 (LinkedHashMap)
2. 기처리 eventId 선조회 SELECT ... WHERE event_id IN (...)
3. 신규분만 원본 적재 INSERT IGNORE batch
4. 트랙별 델타 합산 후 UPSERT play_count = play_count + VALUES(...)- INSERT IGNORE 의 반환값으로 판정하지 않는다 — 02편의
rewriteBatchedStatements때문에 affected rows 가-2로 뭉개질 수 있다. 집계 정확성이 드라이버 설정에 의존하는 것은 수용 불가. 선조회가 SELECT 한 번 비싸지만 결정적이다. - 커밋 순서는 DB 커밋 → 오프셋 커밋. 사이에서 죽으면 재소비되지만 멱등이 흡수한다. 반대 순서면 이벤트가 조용히 유실된다. at-least-once + 멱등 = 사실상 exactly-once.
- 파티션 키 = trackId. 같은 트랙이 항상 같은 파티션 → 같은 카운터 행을 두 컨슈머가 동시에 때리는 상황이 구조적으로 없다. 대가는 인기곡 파티션 스큐고, 시뮬레이터에 Zipf 분포를 넣어 그 스큐를 일부러 재현했다.
TTL 은 설정값이 아니라 안전성 조건이다
원본 이벤트는 초당 500건이면 하루 4,300만 행이라 TTL 로 지워야 한다. 그런데 원본 행이 곧 멱등성 판정 근거다 — 지운 뒤 도착한 중복은 신규로 집계된다.
TTL(play_event) > retention.ms(play-events)브로커가 이미 지운 메시지는 재소비될 수 없으므로, 이 부등식이 성립하면 중복이 도착할 수 있는 기간 동안 판정 근거가 반드시 남아 있다. 처음엔 문서에 “두 값을 같이 움직여야 한다”고 적었는데, 사람이 기억해야 하는 제약은 언젠가 깨진다. 그래서 기동 시점에 검증해 어기면 애플리케이션이 뜨지 않게 만들었다(fail fast).
증명 — 멱등성은 깨뜨려서
AdminClient.alterConsumerGroupOffsets 로 오프셋을 0으로 되감아 120건을 전량
재소비시키고 카운터 불변을 단언했다. 핵심은 duplicate 메트릭을 함께 단언한 것 —
이게 없으면 “재소비가 안 일어나서 그대로”인 경우와 구분되지 않는다.
테스트가 실제로 문제를 만들었는지부터 확인해야 다음 단언이 의미를 갖는다.
덤으로 얻은 교훈: @BeforeEach 에서 DB 를 비워도 토픽의 메시지는 남는다.
오프셋을 되감으면 다른 테스트가 발행한 이벤트까지 살아난다. 이벤트 기반 시스템에서
“DB 정리 = 깨끗한 상태”는 성립하지 않는다 — 운영에서도 똑같다.
상세: ADR-0003