- Published on
Kafka Saga 중복·역보상 버그, Outbox로 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
마이크로서비스에서 분산 트랜잭션을 피하려고 Saga를 선택하면, 결국 메시징(대개 Kafka)과 함께 운영하게 됩니다. 문제는 여기서부터입니다. Kafka는 기본적으로 at-least-once 전달이 흔하고(특히 컨슈머 재시도, 리밸런스, 타임아웃 상황), Saga는 여러 단계의 상태 전이를 갖기 때문에 중복처리와 역보상(보상 이벤트가 정방향 이벤트보다 먼저 적용되는 현상) 이 결합되면 데이터가 깨지기 쉽습니다.
이 글에서는 Kafka Saga에서 실제로 자주 발생하는 버그 패턴을 짚고, Outbox 패턴을 기반으로 “DB 상태 변경과 이벤트 발행”을 하나의 원자적 단위로 묶어 중복·역보상에 강한 설계로 바꾸는 접근을 설명합니다.
운영 환경에서 이런 문제는 종종 리플리카 지연, 재시도 폭주와 함께 나타납니다. 데이터 일관성 이슈를 추적하다 보면 인프라 병목이 같이 보이기도 하니, 필요하면 MySQL 8.0 리플리카 지연 5원인과 Redis 튜닝도 함께 확인해보세요.
Kafka Saga에서 중복처리·역보상이 생기는 이유
1) 중복처리: at-least-once + 재시도 + 커밋 타이밍
Kafka 컨슈머는 보통 다음 중 하나로 운영됩니다.
- 처리 후 오프셋 커밋(수동 커밋)
- 자동 커밋(권장하지 않음)
어떤 방식이든 네트워크 지연, 컨슈머 다운, 리밸런스가 발생하면 같은 메시지가 다시 소비될 수 있습니다. Saga 단계에서 ReserveInventory 같은 이벤트가 두 번 적용되면 재고가 두 번 깎이거나, 결제가 두 번 승인되는 식의 사고로 이어집니다.
2) 역보상: 이벤트 순서 뒤틀림과 “발행-저장” 분리
역보상은 보통 이런 조합에서 터집니다.
- 서비스 A가 DB 업데이트 후 Kafka로 이벤트 발행
- 발행은 성공했는데 DB 트랜잭션이 롤백되거나 반대로 DB는 커밋됐는데 발행이 실패
- 또는 서로 다른 토픽/파티션, 재시도, 지연으로 인해 보상 이벤트가 먼저 도착
특히 다음과 같은 코드가 흔한 함정입니다.
- DB 업데이트 트랜잭션과 Kafka
send()가 분리되어 있음 - 실패 시 재시도 로직이 “부분 성공”을 고려하지 않음
결과적으로 정방향 이벤트는 유실되고 보상 이벤트만 남거나, 혹은 보상 이벤트가 먼저 적용되어 상태가 뒤집힙니다.
재현 시나리오: 결제-주문 Saga에서 터지는 전형적 사고
예를 들어 주문 생성 흐름이 아래와 같다고 해봅시다.
OrderCreated발행- 결제 서비스가
PaymentAuthorized발행 - 재고 서비스가
InventoryReserved발행 - 완료
실패 시에는 PaymentCanceled, InventoryReleased 같은 보상 이벤트가 발행됩니다.
여기서 다음이 발생할 수 있습니다.
- 결제 서비스가 DB에 결제 승인 기록을 남긴 뒤 Kafka 발행을 시도
- Kafka 발행이 타임아웃으로 실패했다고 판단되어 재시도
- 실제로는 첫 발행이 성공해서
PaymentAuthorized가 2번 소비됨 - 재고가 2번 예약되거나, 주문 상태가
PAID로 2번 전이
또는 더 악질적으로,
- 결제 승인 DB 커밋은 성공
- Kafka 발행은 실패(또는 유실)
- 오케스트레이터는 타임아웃으로 “결제 실패”로 판단하고
PaymentCanceled를 발행 - 이후 지연된
PaymentAuthorized가 도착
이때 주문은 이미 취소인데 결제가 승인되는 역보상 상태가 됩니다.
핵심 원칙: Saga는 “메시지 멱등성 + 상태 전이 단방향”이 기본
Outbox로 가기 전에, 반드시 아래 원칙을 같이 가져가야 합니다.
- 모든 컨슈머 핸들러는 멱등해야 함
- 상태 전이는 단방향(모노토닉)으로 설계
- 이벤트는 고유 ID(보통
eventId)를 가져야 함 - 비즈니스 키 단위의 중복 방지(예:
orderId+eventType)가 필요
Outbox는 1번과 4번을 구현하기 쉬운 구조를 만들어줍니다. 하지만 Outbox만으로 멱등이 자동으로 보장되지는 않습니다.
Outbox 패턴으로 “DB 커밋과 이벤트 발행”을 묶기
Outbox의 목표는 단순합니다.
- 비즈니스 DB 트랜잭션 안에서
- 도메인 상태 변경
- Outbox 테이블에 발행할 이벤트를 함께 기록
- 별도 퍼블리셔가 Outbox를 읽어 Kafka로 발행
즉, “DB는 커밋됐는데 이벤트가 안 나감”과 “이벤트는 나갔는데 DB는 롤백”의 괴리를 줄입니다.
Outbox 테이블 설계 예시
아래는 MySQL 기준의 최소 예시입니다.
CREATE TABLE outbox_event (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_id CHAR(36) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'NEW',
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
published_at TIMESTAMP(6) NULL,
UNIQUE KEY uk_event_id (event_id),
KEY idx_status_created (status, created_at)
);
포인트는 다음입니다.
event_id유니크: 퍼블리셔 재시도 시 중복 발행 방지에 유리status인덱스: 폴링 퍼블리셔가 빠르게NEW만 읽게aggregate_id: 소비 측 멱등/정합성 키로 사용 가능
서비스 로직: 도메인 변경과 Outbox 기록을 같은 트랜잭션으로
Spring Boot + JPA 예시입니다.
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final OutboxRepository outboxRepository;
@Transactional
public void authorize(String orderId, long amount) {
Payment payment = paymentRepository.save(Payment.authorized(orderId, amount));
OutboxEvent evt = OutboxEvent.newEvent(
"Payment",
orderId,
"PaymentAuthorized",
java.util.UUID.randomUUID().toString(),
"{\"orderId\":\"" + orderId + "\",\"amount\":" + amount + "}"
);
outboxRepository.save(evt);
}
}
이렇게 하면 최소한 DB 커밋이 되면 이벤트도 Outbox에 남고, DB가 롤백되면 이벤트도 같이 사라집니다.
Outbox 퍼블리셔: 폴링 방식으로 안정적으로 발행
CDC(Debezium) 기반도 가능하지만, 구현 난이도와 운영 복잡도가 올라갑니다. 여기서는 가장 단순한 폴링 퍼블리셔를 예로 들겠습니다.
폴링 + 배치 + 락(또는 SKIP LOCKED)로 중복 퍼블리시 방지
MySQL 8에서는 FOR UPDATE SKIP LOCKED를 활용할 수 있습니다.
SELECT *
FROM outbox_event
WHERE status = 'NEW'
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
퍼블리셔는 트랜잭션으로 묶어서
NEW를 가져와 잠금- Kafka로 발행
- 성공한 건을
PUBLISHED로 업데이트
를 수행합니다.
퍼블리셔 의사 코드
@Scheduled(fixedDelayString = "${outbox.publisher.delay-ms:200}")
public void publishLoop() {
List<OutboxEvent> batch = outboxRepository.lockNextBatch(100);
for (OutboxEvent e : batch) {
try {
// key를 aggregate_id로 두면 동일 aggregate는 같은 파티션으로 가기 쉬움
kafkaTemplate.send("payment-events", e.getAggregateId(), e.getPayload());
outboxRepository.markPublished(e.getId());
} catch (Exception ex) {
// 실패는 NEW로 남겨 재시도. 단, 무한 재시도 방지 위해 retry_count, next_retry_at 권장
outboxRepository.markFailedTemporarily(e.getId(), ex.getMessage());
}
}
}
주의할 점은 send()가 “브로커에 기록 완료”까지 동기 확인하지 않으면 애매해질 수 있다는 겁니다. 운영에서는 acks=all과 enable.idempotence=true 같은 프로듀서 설정을 함께 고려하세요.
컨슈머 멱등성: 중복처리의 마지막 방어선
Outbox는 발행 측 원자성을 올리지만, Kafka 특성상 중복 소비 가능성은 남습니다. 따라서 컨슈머는 반드시 멱등해야 합니다.
방법 A: event_id 기반 처리 이력 테이블
CREATE TABLE inbox_dedup (
event_id CHAR(36) PRIMARY KEY,
processed_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
);
컨슈머는 처리 전에 event_id를 삽입하고, 이미 있으면 스킵합니다.
@Transactional
public void handlePaymentAuthorized(String eventId, String orderId) {
boolean inserted = inboxRepository.tryInsert(eventId);
if (!inserted) return; // duplicate
orderRepository.markPaid(orderId);
}
여기서 tryInsert는 유니크 키 충돌을 이용해 구현합니다.
방법 B: 상태 전이 조건을 SQL로 강제(모노토닉 업데이트)
주문 상태가 CREATED에서만 PAID로 갈 수 있게 조건을 걸면, 중복 이벤트가 와도 두 번째 업데이트는 0 rows가 됩니다.
UPDATE orders
SET status = 'PAID'
WHERE order_id = ? AND status = 'CREATED';
이 방식은 간단하지만, “이벤트별로 정확히 한 번 처리”의 증거가 남지 않으므로 감사/추적이 필요하면 A와 병행하는 편이 안전합니다.
역보상 버그를 막는 설계: 보상은 ‘선행 조건’이 있어야 한다
역보상은 단순히 순서가 꼬이는 문제가 아니라, 상태 머신을 허술하게 만든 문제인 경우가 많습니다.
1) 보상 이벤트 처리에도 상태 조건을 둔다
예를 들어 PaymentCanceled는 PAID 상태에서만 적용되게 만들면, 결제가 아직 승인되지 않았는데 취소가 먼저 와도 아무 일도 일어나지 않습니다.
UPDATE orders
SET status = 'CANCELED'
WHERE order_id = ? AND status IN ('PAID', 'CREATED');
여기서 중요한 건 “어떤 상태에서 어떤 보상이 유효한가”를 명시하는 것입니다.
2) Saga 단계별로 step 또는 version을 둔다
이벤트에 step(예: 10, 20, 30)이나 version을 넣고, 소비 측에서 현재 버전보다 낮은 이벤트는 무시하도록 만들면 역순 도착의 피해를 줄일 수 있습니다.
단, 이 방식은 설계가 복잡해질 수 있으니, 먼저 상태 전이 조건과 멱등을 탄탄히 하고 도입하는 것을 권합니다.
Outbox를 도입하면 무엇이 달라지나
좋아지는 점
- DB 커밋과 이벤트 발행의 결합도가 올라가 “유실”과 “팬텀 발행”이 크게 감소
- 재시도/장애 상황에서도 Outbox가 버퍼 역할을 해 트래픽 급증을 흡수
- 운영 중 장애 분석이 쉬워짐(Outbox에 이벤트가 남아 원인 추적 가능)
여전히 남는 과제
- 컨슈머 멱등성은 필수(Outbox만으로는 부족)
- Outbox 폴링 지연으로 인해 이벤트 발행이 약간 늦어질 수 있음
- Outbox 테이블이 커지므로 보관 정책 필요(예:
PUBLISHED는 7일 후 삭제)
Outbox가 누적되면 DB 부하가 증가하고, 그 여파로 리플리카 지연이나 타임아웃이 겹치기도 합니다. 이런 증상이 보이면 앞서 언급한 MySQL 8.0 리플리카 지연 5원인과 Redis 튜닝에서 진단 포인트를 같이 점검하는 게 좋습니다.
운영 체크리스트: 실전에서 꼭 보는 설정과 지표
프로듀서
enable.idempotence=trueacks=allretries충분히delivery.timeout.ms와request.timeout.ms정합성
컨슈머
- 수동 커밋 사용, 처리 성공 후 커밋
- 재시도는 “무한 루프”가 아니라 DLQ 또는 백오프
- 멱등 키(
event_id) 저장 또는 상태 전이 조건 업데이트
Outbox 퍼블리셔
- 배치 크기와 주기 튜닝
- 실패 이벤트 재시도 정책:
retry_count,next_retry_at,last_error - 다중 인스턴스 실행 시
SKIP LOCKED또는 분산 락
관측 지표
- Outbox
NEW적체량, 최대 대기 시간 - 퍼블리셔 발행 실패율
- 컨슈머 중복 감지 카운트(유니크 충돌 수)
빌드/배포 파이프라인이 불안정하면 장애 대응이 늦어집니다. 배포 중 에이전트가 종종 죽는 환경이라면 Jenkins 에이전트 오프라인 원인 6가지와 복구도 함께 점검해두면 좋습니다.
마무리: Outbox는 “정합성의 기본기”를 강제하는 장치
Kafka Saga에서 중복처리와 역보상은 “운이 나빠서”가 아니라, 재시도와 부분 실패가 정상인 분산 환경에서 당연히 나타나는 현상입니다. Outbox 패턴은 이벤트 발행을 DB 트랜잭션과 결합해 이 문제를 구조적으로 완화하고, 컨슈머 멱등성 및 상태 전이 조건과 함께 적용하면 실무에서 가장 흔한 데이터 깨짐을 상당 부분 제거할 수 있습니다.
정리하면 다음 조합이 가장 현실적인 해법입니다.
- 발행 측: 도메인 변경 + Outbox 기록을 단일 트랜잭션으로
- 전달 측: Outbox 퍼블리셔로 재시도 가능한 안정적 발행
- 소비 측:
event_id기반 멱등 또는 상태 전이 조건으로 중복 무력화 - 보상 처리: 선행 조건을 둬 역보상 무해화
이 4가지를 갖추면, Kafka 기반 Saga를 “돌아가는 수준”이 아니라 “장애 상황에서도 데이터가 버티는 수준”으로 끌어올릴 수 있습니다.