At-least-once/At-most-once/Exactly-once 비교, Kafka 트랜잭션과 Flink 체크포인트의 조합, 멱등성 설계, DLQ, 백프레셔, 장애 복구 전략까지 프로덕션 신뢰성을 학습합니다.
4장에서 세 가지 전달 보장 수준을 간략히 소개했습니다. 이번 장에서는 각 수준의 구현 원리와 트레이드오프를 깊이 분석합니다.
메시지를 처리하기 전에 오프셋을 커밋합니다. 처리 중 장애가 발생하면 해당 메시지는 다시 처리되지 않습니다.
1. 메시지 수신 (offset 10)
2. 오프셋 커밋 (offset 11) ← 처리 전에 커밋
3. 메시지 처리 ← 여기서 장애 발생!
4. 재시작 후 offset 11부터 읽기 → offset 10 메시지 유실적합한 경우: 일부 데이터 유실이 허용되는 메트릭 수집, 로그 처리
메시지를 처리한 후에 오프셋을 커밋합니다. 처리는 완료했지만 커밋 전에 장애가 발생하면, 재시작 후 동일 메시지를 다시 처리합니다.
1. 메시지 수신 (offset 10)
2. 메시지 처리 ← 성공
3. 오프셋 커밋 (offset 11) ← 여기서 장애 발생!
4. 재시작 후 offset 10부터 읽기 → offset 10 메시지 중복 처리적합한 경우: 중복이 허용되거나 멱등 처리가 가능한 경우 (대부분의 시스템)
메시지 처리와 오프셋 커밋을 원자적으로 수행합니다. 유실도 중복도 발생하지 않습니다.
엄밀히 말하면 "Exactly-once delivery"는 분산 시스템에서 불가능합니다. 실제로 구현되는 것은 "Effectively-once processing" — 내부적으로는 At-least-once 전달을 수행하되, 중복을 감지하고 제거하여 결과적으로 한 번만 처리된 것과 동일한 효과를 만들어냅니다.
진정한 Exactly-once는 파이프라인의 모든 단계에서 보장되어야 합니다.
Kafka 내부의 read-process-write 패턴에서는 4장에서 학습한 트랜잭셔널 프로듀서로 Exactly-once를 달성합니다. 읽기(오프셋 커밋)와 쓰기를 하나의 트랜잭션으로 묶습니다.
Flink가 Kafka에서 읽어 처리하고 다시 Kafka에 쓰는 경우, Flink의 체크포인트와 Kafka의 트랜잭션을 결합하여 Exactly-once를 달성합니다.
이 과정은 Two-Phase Commit(2PC, 2단계 커밋) 프로토콜의 변형입니다.
1단계 (Pre-commit): 체크포인트 배리어가 도달하면 각 오퍼레이터가 상태를 스냅샷하고, Kafka Sink는 트랜잭션을 pre-commit 상태로 둡니다.
2단계 (Commit): 모든 오퍼레이터의 스냅샷이 성공하면 JobManager가 체크포인트 완료를 알리고, Kafka Sink가 트랜잭션을 최종 커밋합니다.
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
// 체크포인트 활성화
env.enableCheckpointing(30_000, CheckpointingMode.EXACTLY_ONCE);
// Kafka Source (Exactly-once 읽기)
KafkaSource<String> source = KafkaSource.<String>builder()
.setBootstrapServers("kafka:9092")
.setTopics("orders")
.setGroupId("flink-processor")
.setStartingOffsets(OffsetsInitializer.committedOffsets())
.setValueOnlyDeserializer(new SimpleStringSchema())
.build();
// Kafka Sink (Exactly-once 쓰기)
KafkaSink<String> sink = KafkaSink.<String>builder()
.setBootstrapServers("kafka:9092")
.setRecordSerializer(
KafkaRecordSerializationSchema.builder()
.setTopic("orders-processed")
.setValueSerializationSchema(new SimpleStringSchema())
.build())
.setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE) // Exactly-once 보장
.setTransactionalIdPrefix("flink-orders") // 트랜잭션 ID 접두사
.build();
DataStream<String> stream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "Kafka");
stream.map(this::processOrder).sinkTo(sink);Kafka가 아닌 외부 시스템(데이터베이스, API 등)에 결과를 쓸 때는 추가적인 설계가 필요합니다.
방법 1: 멱등 싱크
외부 시스템이 멱등 쓰기를 지원하면, At-least-once 전달 + 멱등 싱크로 Effectively-once를 달성합니다.
// Elasticsearch: 문서 ID 기반 upsert → 자연스러운 멱등 쓰기
// Redis: SET 명령 → 동일 키에 대한 반복 쓰기는 멱등
// DB: INSERT ... ON CONFLICT DO UPDATE → 멱등 upsert방법 2: 트랜잭셔널 싱크 (2PC)
Flink는 일부 싱크에 대해 2PC 기반 Exactly-once를 지원합니다. TwoPhaseCommitSinkFunction을 구현하여, Flink 체크포인트와 외부 시스템의 트랜잭션을 연동합니다.
2PC 기반 트랜잭셔널 싱크는 외부 시스템이 트랜잭션을 지원해야 하며, 구현 복잡도가 높습니다. 가능하다면 멱등 싱크 방식을 우선 검토하세요. 대부분의 데이터베이스와 검색 엔진은 upsert를 지원하므로 멱등 설계가 가능합니다.
**Idempotency(멱등성)**은 동일한 작업을 여러 번 수행해도 결과가 한 번 수행한 것과 동일한 성질입니다. At-least-once 전달에서 중복 메시지를 안전하게 처리하기 위한 핵심 설계 원칙입니다.
1. 자연 키 기반 Upsert
-- PostgreSQL: 주문 ID를 기준으로 upsert
INSERT INTO order_summary (order_id, total_amount, status, updated_at)
VALUES ('O-001', 50000, 'completed', NOW())
ON CONFLICT (order_id)
DO UPDATE SET
total_amount = EXCLUDED.total_amount,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at;2. 이벤트 ID 기반 중복 방지
def process_event(event):
event_id = event["eventId"]
# 이미 처리된 이벤트인지 확인
if redis_client.sismember("processed_events", event_id):
return # 중복 → 무시
# 처리 수행
execute_business_logic(event)
# 처리 완료 기록 (TTL 설정으로 무한 증가 방지)
redis_client.sadd("processed_events", event_id)
redis_client.expire("processed_events", 86400) # 24시간 보존3. 버전 기반 낙관적 동시성 제어
-- 현재 버전보다 높은 경우에만 업데이트
UPDATE orders
SET status = 'shipped', version = 5
WHERE order_id = 'O-001' AND version = 4;
-- 영향받은 행이 0이면 이미 처리됨 (중복 또는 경합)멱등 설계의 핵심은 결정론적 식별자입니다. 이벤트 ID, 엔티티의 자연 키, 또는 이벤트 내용의 해시를 활용하여 동일한 처리를 식별할 수 있어야 합니다. 타임스탬프나 자동 증가 ID를 식별자로 사용하면 멱등성이 깨집니다.
**Backpressure(백프레셔)**는 하류 시스템이 상류 시스템의 데이터 생산 속도를 따라가지 못할 때 발생하는 현상입니다. 적절한 백프레셔 메커니즘이 없으면 메모리 초과, 데이터 유실, 시스템 장애로 이어집니다.
프로듀서: 10,000 msg/sec →
Kafka: 충분한 용량 →
처리 엔진: 8,000 msg/sec (병목) →
싱크: 5,000 msg/sec (더 큰 병목)
→ 처리 엔진과 싱크에서 백프레셔 발생
→ 처리되지 않은 메시지가 계속 쌓임Kafka 자체는 내장 백프레셔가 없습니다. 대신 컨슈머가 poll() 호출 빈도와 max.poll.records를 조절하여 소비 속도를 제어합니다. 처리가 느려지면 컨슈머 랙이 증가합니다.
Properties props = new Properties();
props.put("max.poll.records", "100"); // poll당 최대 레코드 수 제한
props.put("max.poll.interval.ms", "300000"); // 처리 시간 한도 (5분)
props.put("fetch.max.bytes", "1048576"); // fetch당 최대 바이트 (1MB)Flink는 크레딧 기반(credit-based) 백프레셔 메커니즘을 내장하고 있습니다. 하류 오퍼레이터의 버퍼가 가득 차면 상류 오퍼레이터에 "크레딧이 없다"고 알려, 데이터 전송을 자연스럽게 제한합니다.
Source → [buffer: 8/10] → Map → [buffer: 10/10 FULL] → Aggregate → Sink
↑
백프레셔 신호 역전파
Source의 전송 속도가 자동으로 감소4장에서 기본적인 DLQ 패턴을 소개했습니다. 여기서는 프로덕션 수준의 DLQ 운영을 다룹니다.
DLQ에 메시지가 도착하면 실패 유형에 따라 대응이 달라집니다.
| 유형 | 예시 | 대응 |
|---|---|---|
| 일시적 오류 | 네트워크 타임아웃, DB 과부하 | 지수 백오프 후 재시도 |
| 스키마 오류 | 잘못된 JSON, 필수 필드 누락 | 프로듀서 수정 후 재발행 |
| 비즈니스 규칙 위반 | 음수 금액, 유효하지 않은 상태 | 데이터 보정 후 재처리 |
| 독성 메시지 (Poison Pill) | 파싱 불가한 바이너리 | 폐기 (로그 보존) |
class DLQProcessor:
def __init__(self, max_retries=3, retry_delay_base=60):
self.max_retries = max_retries
self.retry_delay_base = retry_delay_base
def process_dlq_message(self, message):
retry_count = int(message.headers.get("retry-count", 0))
failure_reason = message.headers.get("failure-reason", "")
# 재시도 가능한 오류인지 판단
if self.is_retriable(failure_reason) and retry_count < self.max_retries:
delay = self.retry_delay_base * (2 ** retry_count) # 지수 백오프
self.schedule_retry(message, delay, retry_count + 1)
else:
# 재시도 불가 또는 한도 초과 → 영구 실패 큐로 이동
self.move_to_permanent_dlq(message)
self.alert_ops_team(message)
def is_retriable(self, reason):
retriable_patterns = [
"timeout", "connection refused",
"too many requests", "service unavailable"
]
return any(p in reason.lower() for p in retriable_patterns)DLQ 모니터링은 파이프라인 건강 상태의 핵심 지표입니다. DLQ 메시지 수의 급증은 업스트림의 문제(스키마 변경, 데이터 품질 저하)를 조기에 감지하는 신호입니다. 프로덕션에서는 DLQ 메시지 증가율에 대한 알림을 반드시 설정하세요.
| 장애 유형 | 영향 | 복구 전략 |
|---|---|---|
| 단일 브로커 장애 | 일부 파티션 리더 이전 | 자동 복구 (ISR 기반 리더 선출) |
| 전체 Kafka 클러스터 장애 | 모든 스트림 중단 | 이벤트 소스에서 재전송, 오프셋 기반 재개 |
| Flink TaskManager 장애 | 태스크 일부 중단 | 자동 재시작 + 체크포인트 복구 |
| Flink JobManager 장애 | 전체 작업 중단 | HA 설정(ZooKeeper/K8s) → 대기 JobManager 승격 |
| 컨슈머 그룹 전체 장애 | 소비 중단, 랙 증가 | 재시작 후 마지막 커밋 오프셋부터 재개 |
| 싱크 시스템 장애 | 결과 미반영 | 백프레셔 → 싱크 복구 후 자동 재개 |
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
// 고정 지연 재시작: 최대 3회, 10초 간격
env.setRestartStrategy(
RestartStrategies.fixedDelayRestart(3, Time.seconds(10)));
// 실패율 기반 재시작: 5분 내 3회 실패까지 허용
env.setRestartStrategy(
RestartStrategies.failureRateRestart(
3, // 허용 실패 횟수
Time.minutes(5), // 측정 기간
Time.seconds(10) // 재시작 간 대기
));
// 지수 백오프 재시작
env.setRestartStrategy(
RestartStrategies.exponentialDelayRestart(
Time.seconds(1), // 초기 대기
Time.minutes(5), // 최대 대기
2.0, // 배수
Time.hours(1), // 리셋 기간
0.1 // 지터
));장애로 인해 데이터를 재처리해야 하는 상황에 대비한 전략입니다.
1. 오프셋 리셋
- 특정 시점으로 오프셋을 되돌려 해당 시점부터 재처리
- 멱등 싱크 필수
2. Flink Savepoint
- Savepoint에서 복원하여 해당 시점부터 재처리
- 작업 수정 후 재배포에 유용
3. 새 컨슈머 그룹
- 새 그룹 ID로 처음부터 읽기
- 전체 재처리가 필요한 경우이번 장에서는 실시간 파이프라인의 신뢰성 확보 방법을 학습했습니다.
11장(마지막)에서는 프로덕션 모니터링과 운영을 다룹니다. 브로커/프로듀서/컨슈머의 핵심 메트릭, Prometheus/Grafana 대시보드 구성, 알림 설계, 용량 계획, 비용 최적화까지 실시간 파이프라인의 안정적 운영에 필요한 모든 것을 학습합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
브로커/프로듀서/컨슈머 핵심 메트릭, Prometheus/Grafana 대시보드, 알림 설계, 용량 계획, 비용 최적화, 실전 아키텍처까지 실시간 파이프라인의 프로덕션 운영을 학습합니다.
스키마 진화의 필요성, Confluent Schema Registry, Avro/Protobuf/JSON Schema 비교, 호환성 규칙, 데이터 계약 개념까지 스키마 관리 전략을 체계적으로 학습합니다.
CDC의 원리와 WAL 기반 변경 캡처, Debezium 아키텍처, PostgreSQL/MySQL CDC 실습, Flink CDC 3.6, 아웃박스 패턴, 이벤추얼 컨시스턴시까지 데이터 통합의 핵심을 학습합니다.