Replay와 재처리: offset reset, DLQ, poison message, idempotent sink
재처리는 기능이 아니라 운영 절차다
스트리밍 플랫폼을 운영하다 보면 "어제 13시부터 15시까지 데이터를 다시 흘려 주세요"라는 요청이 반드시 온다. 원인은 다양하다. 컨슈머 버그로 일부 이벤트가 잘못 적재됐을 수도 있고, downstream DB 장애 때문에 처리가 실패했을 수도 있고, schema 변경을 놓쳐 특정 메시지만 계속 실패했을 수도 있다.
이때 단순히 consumer group offset을 과거로 되돌리면 해결될 것처럼 보인다. 하지만 재처리는 같은 이벤트를 한 번 더 처리하는 행위다. 이미 외부 DB에 쓴 행, 이미 보낸 알림, 이미 생성한 파일, 이미 증가시킨 카운터가 있다면 중복 부작용이 생긴다. 그래서 replay는 Kafka 명령 하나가 아니라, offset, DLQ, poison message, idempotent sink를 함께 설계한 운영 절차로 봐야 한다.
이 장에서는 스트리밍 플랫폼에서 재처리를 안전하게 수행하기 위한 판단 기준과 실행 순서를 정리한다.
1. Replay가 필요한 대표 상황
| 상황 | 증상 | 안전한 접근 |
|---|---|---|
| 컨슈머 코드 버그 | 처리 결과가 잘못 계산됨 | 버그 수정 후 영향 구간만 replay |
| downstream 장애 | 처리 실패 또는 timeout 증가 | sink 복구 후 미처리 구간 replay |
| schema/validation 문제 | 특정 메시지가 계속 실패 | DLQ 격리 후 schema/데이터 수정 |
| 상태 저장소 재구축 | 집계 state가 깨짐 | 별도 group 또는 state bootstrap |
| 신규 파생 데이터 생성 | 과거 이벤트로 새 테이블 채움 | 새 consumer group으로 backfill |
핵심 질문은 "Kafka에 데이터가 남아 있는가?"가 아니다. Kafka retention 안에 데이터가 있어도 sink가 idempotent하지 않으면 replay는 장애 복구가 아니라 데이터 오염이 될 수 있다.
2. Offset reset은 어디까지나 읽기 위치 변경이다
Kafka consumer group은 각 topic-partition별로 다음에 읽을 offset을 저장한다. committed offset은 "마지막으로 성공한 메시지"가 아니라 다음에 읽을 위치다. 예를 들어 offset 25까지 처리했다면 보통 26을 commit한다. 컨슈머가 재시작하거나 rebalance가 발생하면 새 컨슈머는 이 committed offset부터 다시 읽는다.
auto.offset.reset은 매번 replay를 수행하는 설정이 아니다. 이 설정은 초기 offset이 없거나, 저장된 offset이 retention 때문에 더 이상 유효하지 않을 때 어디서 시작할지를 정한다.
earliest: 현재 topic에 남아 있는 가장 오래된 메시지부터 읽는다.latest: 구독 시점 이후 새로 들어오는 메시지부터 읽는다.none: 유효한 offset이 없으면 오류를 내고 명시적으로 처리하게 한다.
운영 중인 group의 위치를 의도적으로 바꾸려면 kafka-consumer-groups --reset-offsets 같은 관리 작업이나 애플리케이션의 seek() 계열 API를 사용한다. 이 작업은 강력하지만 위험하다. offset reset은 Kafka의 읽기 포인터만 바꾸며, 이미 외부 시스템에 반영된 부작용은 되돌리지 않는다.
시간 구간 식별
retention 안에 있는가?
흡수할 수 있는가?
3. Replay 방식 세 가지
3.1 같은 consumer group의 offset을 되돌리기
가장 직접적인 방식이다. 운영 group의 offset을 특정 timestamp, 특정 offset, 또는 earliest/latest로 변경한다. 장점은 기존 파이프라인을 그대로 사용한다는 점이다. 단점은 실수했을 때 운영 처리 흐름을 바로 흔든다는 점이다.
적합한 경우:
- 컨슈머를 중지할 수 있고, 영향 범위가 명확하다.
- sink가 upsert, unique key, processed-event table 등으로 중복을 흡수한다.
- replay 중 새 이벤트 처리 지연을 감수할 수 있다.
주의할 점:
- consumer group이 실행 중이면 reset 결과가 예측하기 어렵다. 보통 컨슈머를 멈춘 뒤 수행한다.
- timestamp 기반 reset은 각 파티션에서 해당 시각 이후의 첫 offset으로 이동한다. 이벤트 시간(event time)이 아니라 Kafka log append 시간 기준으로 이해해야 한다.
- replay 구간이 retention과 compaction 정책의 영향을 받는다.
3.2 별도 consumer group으로 backfill하기
운영 group은 그대로 두고, 새 group ID로 과거 데이터를 읽어 별도 경로에 적재한다. 신규 파생 테이블 생성, state 재구축, 대량 검증 작업에 적합하다.
장점:
- 운영 group의 offset을 건드리지 않는다.
- 속도 제한, 병렬도, 임시 sink를 독립적으로 조정할 수 있다.
- 결과를 검증한 뒤 운영 테이블에 merge할 수 있다.
단점:
- 같은 이벤트를 두 경로가 동시에 읽으므로 downstream 부하가 증가한다.
- backfill 결과와 실시간 처리 결과를 합치는 기준이 필요하다.
3.3 DLQ에서 선별 재처리하기
특정 메시지만 실패했다면 전체 구간 replay보다 DLQ 재처리가 낫다. DLQ는 처리 실패 메시지를 격리하는 topic이다. 원본 key/value뿐 아니라 원본 topic, partition, offset, 실패 원인, 예외 클래스, retry 횟수, 발생 시각 같은 metadata를 함께 저장해야 나중에 안전하게 복구할 수 있다.
DLQ는 쓰레기통이 아니다. 운영자가 원인을 분석하고, 수정 가능한 메시지는 재처리하며, 버려야 할 메시지는 근거를 남기고 폐기하는 격리 구역이다.
4. Poison message: 파티션 하나를 멈추는 작은 메시지
poison message는 처리할 때마다 같은 방식으로 실패하는 메시지다. 예를 들어 JSON이 깨졌거나, Schema Registry에 없는 schema ID를 참조하거나, 필수 필드가 비어 있거나, 비즈니스 로직이 절대 받아들일 수 없는 상태를 담고 있을 수 있다.
이 메시지를 단순 retry로만 처리하면 같은 partition에서 뒤의 정상 메시지까지 막힌다. Kafka는 partition 안에서 순서를 보장하기 때문에, 컨슈머가 실패 메시지에서 계속 죽고 offset을 commit하지 못하면 다음 offset으로 진행하지 못한다.
올바른 처리는 실패 유형을 나누는 것이다.
- 일시 오류: 네트워크 timeout, 일시적인 DB connection 부족, rate limit처럼 시간이 지나면 회복될 수 있는 오류다. 제한된 retry와 backoff를 적용한다.
- 영구 오류: schema 불일치, validation 실패, 비즈니스 규칙 위반처럼 같은 입력으로 계속 실패하는 오류다. DLQ로 격리하고 offset을 전진시킨다.
- 시스템 오류: 코드 버그, 배포 오류, 인증 설정 오류처럼 모든 메시지가 실패할 수 있는 오류다. DLQ로 밀어 넣기보다 컨슈머를 멈추고 배포를 롤백해야 할 수 있다.
중요한 순서는 DLQ write 성공 → source offset commit이다. 반대로 offset을 먼저 commit하고 DLQ write가 실패하면 메시지는 사라진다. DLQ write 후 commit 전에 컨슈머가 죽으면 같은 메시지를 다시 DLQ에 쓸 수 있으므로, DLQ 자체도 idempotent key를 가져야 한다. 보통 sourceTopic-sourcePartition-sourceOffset 조합을 dedup key로 쓴다.
5. Idempotent sink 없이는 안전한 replay가 없다
Kafka 내부에서는 idempotent producer와 transaction으로 중복을 줄일 수 있다. 하지만 컨슈머가 외부 DB, 검색엔진, object storage, 외부 API에 쓰는 순간 Kafka의 exactly-once 보장은 끝난다. replay를 운영 기능으로 만들려면 sink가 같은 이벤트를 여러 번 받아도 최종 결과가 같아야 한다.
실무에서 자주 쓰는 패턴은 다음과 같다.
5.1 자연키 또는 event_id 기반 upsert
이벤트마다 안정적인 event_id가 있고 sink 테이블에 unique constraint를 둔다. insert가 이미 존재하면 update 또는 no-op 처리한다.
INSERT INTO payment_events (event_id, order_id, amount, event_time)
VALUES (?, ?, ?, ?)
ON CONFLICT (event_id) DO UPDATE
SET amount = EXCLUDED.amount,
event_time = EXCLUDED.event_time;이 방식은 이벤트가 "같은 ID면 같은 의미"라는 계약을 전제로 한다. producer가 retry 때마다 새 ID를 만들면 dedup이 깨진다.
5.2 processed-event table
외부 부작용을 수행하기 전에 processed_events(event_id) 같은 테이블을 확인하고, 같은 DB transaction 안에서 처리 결과와 processed marker를 함께 기록한다.
BEGIN;
INSERT INTO processed_events(event_id) VALUES (?)
ON CONFLICT DO NOTHING;
-- insert가 실제로 성공한 경우에만 비즈니스 변경 수행
UPDATE account_balance SET amount = amount + ? WHERE account_id = ?;
COMMIT;계좌 잔액 증가처럼 덧셈 부작용이 있는 작업은 특히 이 패턴이 중요하다. 같은 메시지를 두 번 처리하면 값이 두 번 증가하기 때문이다.
5.3 version, offset, watermark 기반 조건부 쓰기
집계 결과나 최신 상태 테이블에는 source offset, event version, event time watermark를 함께 저장하고 더 오래된 이벤트가 새 값을 덮어쓰지 못하게 한다.
UPDATE device_state
SET status = ?, source_offset = ?
WHERE device_id = ?
AND source_offset < ?;단, offset은 partition 안에서만 순서가 있다. 여러 partition을 합치는 상태라면 event time, sequence number, domain version 같은 별도 순서 기준이 필요하다.
6. DLQ 재처리 런북
DLQ에 메시지가 쌓였을 때 바로 원본 topic으로 되돌리는 것은 위험하다. poison message가 다시 운영 컨슈머를 멈출 수 있기 때문이다. 재처리는 다음 순서로 진행하는 것이 안전하다.
- 분류: 오류 원인을 schema, validation, downstream, code bug, unknown으로 나눈다.
- 샘플링: 같은 오류가 대량인지, 특정 producer나 schema version에 집중되는지 확인한다.
- 수정: 컨슈머 코드, schema compatibility, 데이터 보정, downstream 용량 중 무엇을 고칠지 결정한다.
- 재처리 경로 선택: 원본 topic 재주입, 별도 retry topic, 전용 reprocessor, 수동 보정 중 하나를 고른다.
- idempotency 확인: sink unique key, processed-event table, merge 조건을 확인한다.
- 소량 리허설: 몇 건만 재처리하고 결과와 metrics를 확인한다.
- 배치 재처리: rate limit을 두고 진행하며 DLQ 잔량, 실패율, sink 오류를 본다.
- 종료 기록: 폐기한 메시지와 재처리한 메시지 수, 원인, 재발 방지 조치를 남긴다.
DLQ topic에는 retention 정책도 필요하다. 너무 짧으면 분석 전에 사라지고, 너무 길면 민감 데이터와 실패 payload가 오래 남는다. 개인정보가 포함될 수 있다면 masking, 접근 제어, audit log까지 운영 기준에 포함해야 한다.
7. 운영 체크리스트
Replay를 시작하기 전에 다음을 확인한다.
- [ ] 대상 topic, partition, offset/time range가 명확한가?
- [ ] replay할 원본 데이터가 Kafka retention 안에 남아 있는가?
- [ ] 운영 consumer group을 멈출지, 별도 group으로 처리할지 결정했는가?
- [ ] sink가 event_id, unique key, processed-event table, conditional update 중 하나로 idempotent한가?
- [ ] DLQ write와 source offset commit 순서가 안전한가?
- [ ] poison message를 retry할지, skip할지, DLQ로 보낼지 기준이 있는가?
- [ ] replay 중 downstream 부하와 rate limit을 통제할 수 있는가?
- [ ] replay 전후 row count, checksum, business metric 검증 방법이 있는가?
- [ ] 실패 시 offset과 sink를 어디까지 되돌릴지 rollback plan이 있는가?
마무리
Replay는 스트리밍 플랫폼의 강점이다. 로그가 남아 있고 consumer group offset을 조정할 수 있기 때문에 과거 이벤트를 다시 읽을 수 있다. 하지만 replay가 안전하려면 읽기 위치보다 쓰기 결과의 중복 안전성이 먼저다.
운영 관점에서 좋은 스트리밍 파이프라인은 다음을 만족한다.
- offset reset 없이도 신규 backfill을 수행할 수 있다.
- poison message 하나가 partition 전체를 영구적으로 막지 않는다.
- DLQ가 원인 분석과 재처리 metadata를 충분히 가진다.
- sink가 같은 이벤트를 여러 번 받아도 결과가 깨지지 않는다.
- replay 실행 전후 검증 기준이 runbook으로 남아 있다.
즉, replay는 "다시 읽기"가 아니라 "다시 읽어도 안전하게 쓰기"다.
References
- Apache Kafka Documentation, Consumer Config
auto.offset.reset: https://kafka.apache.org/documentation/#consumerconfigs_auto.offset.reset - Apache Kafka Documentation, Basic Operations: consumer groups and offset management: https://kafka.apache.org/documentation/#basic_ops_consumer_group
- Apache Kafka Documentation, Message Delivery Semantics: https://kafka.apache.org/documentation/#semantics
- Confluent Documentation, Kafka Consumer Design: Consumers, Consumer Groups, and Offsets: https://docs.confluent.io/kafka/design/consumer-design.html
- Confluent, Apache Kafka Dead Letter Queue: A Comprehensive Guide: https://www.confluent.io/learn/kafka-dead-letter-queue/
- Confluent, Kafka Auto Offset Reset: Understanding Configuration & Best Practices: https://www.confluent.io/learn/kafka-auto-reset/
- Conduktor, Dead Letter Topics: Handling Poison Pills: https://www.conduktor.io/blog/dead-letter-topics-handling-poison-pills