- Published on
Kafka Saga에서 중복처리·역보상 막는 Outbox
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
분산 트랜잭션을 Kafka Saga로 풀면, “결국 일관성”을 얻는 대신 중복 처리와 역보상(보상 이벤트가 더 늦게 도착한 정방향 이벤트를 이기는 현상) 같은 운영 이슈를 거의 필연적으로 마주칩니다. 특히 네트워크 재시도, 컨슈머 리밸런스, 프로듀서 타임아웃, DB 커밋과 메시지 발행의 분리 같은 요소가 겹치면 같은 이벤트가 여러 번 실행되거나, 보상이 먼저 적용된 뒤 정방향이 뒤늦게 적용되어 상태가 뒤집히는 문제가 생깁니다.
이 글은 Kafka Saga에서 Outbox 패턴을 어떻게 적용해야 중복처리·역보상을 구조적으로 줄일 수 있는지, 그리고 “Outbox만 붙이면 끝”이 아닌 컨슈머 측 상태머신/멱등성/순서 전략까지 포함해 정리합니다.
참고로 운영에서 캐시/상태 불일치 디버깅 감각은 분산 이벤트에서도 그대로 도움이 됩니다. 예를 들어 Next.js 캐시 꼬임을 추적하는 방식처럼 원인-결과를 분해하는 습관이 중요합니다: Next.js 14 App Router 캐시 꼬임 해결법
Kafka Saga에서 실제로 터지는 문제들
1) 중복 처리(At-least-once의 대가)
Kafka는 기본적으로 at-least-once를 쉽게 달성합니다. 하지만 이는 “메시지가 중복으로 도착할 수 있다”는 뜻이고, Saga에서 중복은 단순히 로그가 두 번 찍히는 문제가 아니라 결제 중복, 재고 중복 차감, 포인트 중복 적립 같은 금전적 사고로 연결됩니다.
중복의 흔한 원인:
- 프로듀서가 타임아웃 후 재시도했지만 실제로는 브로커에 기록됨
- 컨슈머가 처리 후 커밋 전에 죽어서 재처리됨
- 컨슈머 리밸런스로 파티션이 이동하며 재처리됨
2) 역보상(Compensation reordering)
Saga는 실패 시 보상 트랜잭션을 발행합니다. 그런데 이벤트 순서가 뒤틀리면 다음이 가능합니다.
- 정방향 이벤트
ReserveInventory가 느리게 도착 - 실패로 인해
ReleaseInventory(보상) 이벤트가 먼저 도착해 재고를 “풀어줌” - 이후 정방향이 도착해 다시 “묶어버림”
결과적으로 최종 상태가 실패한 주문인데 재고는 예약된 상태로 남습니다. 이게 역보상입니다.
3) DB 커밋과 Kafka 발행의 원자성 문제
서비스가 DB에 상태를 저장하고 Kafka로 이벤트를 발행할 때, 두 작업이 서로 다른 시스템에 걸쳐 있어 원자적으로 묶기 어렵습니다.
- DB는 커밋됐는데 이벤트 발행이 실패하면 다운스트림이 상태 변화를 모름
- 이벤트는 발행됐는데 DB는 롤백되면 다운스트림이 “존재하지 않는 상태”를 처리함
이 영역을 제대로 잡는 것이 Outbox의 핵심 역할입니다.
Outbox 패턴이 해결하는 것과, 해결하지 못하는 것
Outbox 패턴은 “DB 트랜잭션 안에서 이벤트를 Outbox 테이블에 같이 기록”하고, 별도 퍼블리셔가 Outbox를 읽어 Kafka에 발행하는 방식입니다.
Outbox가 직접 해결하는 것
- DB 상태 변경과 이벤트 기록의 원자성 확보: 같은 트랜잭션으로 묶음
- 서비스 장애/재시도에도 이벤트 유실 감소
- 발행 재시도 로직을 애플리케이션 흐름에서 분리
Outbox만으로는 부족한 것
- Kafka 컨슈머 중복 처리 자체는 여전히 발생 가능
- 보상/정방향의 순서 뒤틀림은 토픽/키 설계와 컨슈머 로직이 필요
- Outbox 퍼블리셔의 “중복 발행” 가능성도 설계해야 함
즉, Outbox는 유실 방지 + 원자성의 축이고, 중복 처리/역보상 방지는 Outbox를 기반으로 한 “멱등성/순서/상태머신” 조합으로 완성됩니다.
설계 원칙: 중복처리·역보상 방지용 Outbox 체크리스트
1) 이벤트에 반드시 들어가야 할 필드
이벤트 스키마에 아래를 포함시키면, 컨슈머가 멱등성과 순서를 판단할 수 있습니다.
eventId: 전역 유니크 ID (UUID)aggregateId: Saga의 기준 키 (예:orderId)eventType: 예:PAYMENT_AUTHORIZED,PAYMENT_COMPENSATEDsequence: aggregate 단위 증가 시퀀스(또는 버전)causationId: 이 이벤트를 유발한 이벤트 IDcorrelationId: Saga 전체 트레이싱 IDoccurredAt: 생성 시각
핵심은 aggregateId 와 sequence 입니다. 역보상은 결국 “같은 aggregate에서 이벤트 순서가 뒤집히는 문제”이므로, 컨슈머가 최소한 마지막 처리 시퀀스를 기억하고 비교할 수 있어야 합니다.
2) Kafka 토픽 키는 aggregateId로 고정
Kafka에서 순서 보장은 “파티션 내부”에서만 성립합니다. 따라서 같은 주문(orderId)의 이벤트는 반드시 같은 파티션으로 가야 합니다.
- 토픽 키:
orderId - 파티션 수는 확장 가능성을 고려해 초기부터 여유 있게
이렇게 하면 “한 주문”에 대한 정방향/보상 이벤트가 파티션 내에서 순서대로 쌓일 가능성이 커집니다. 다만 재시도/중복 자체는 남으므로 컨슈머 멱등성은 여전히 필요합니다.
3) Outbox 테이블에 aggregateId + sequence를 저장
Outbox는 단순 이벤트 로그가 아니라, 정방향/보상 모두를 포함한 상태 전이 기록입니다.
권장 컬럼:
id(PK)event_id(unique)aggregate_idsequenceevent_typepayload(JSON)status(PENDING,PUBLISHED,FAILED)created_at,published_at
event_id에 유니크 인덱스를 걸어 중복 insert 자체를 막는 것이 1차 방어선입니다.
4) 퍼블리셔는 “최소 1번 발행”을 전제로 멱등하게
Outbox 퍼블리셔는 장애/재시작 시 같은 이벤트를 다시 발행할 수 있습니다. 따라서 다음 중 하나를 조합합니다.
- Kafka 프로듀서
enable.idempotence=true(브로커/클러스터 설정 포함) - Outbox
status업데이트를 신중히(발행 성공 후 업데이트) - 가능하면
event_id를 Kafka 메시지 키가 아니라 헤더에 넣고, 컨슈머에서 dedup
프로듀서 멱등성은 “같은 프로듀서 세션”에서 강합니다. 하지만 운영에서는 프로세스 재시작이 잦으므로, 컨슈머 dedup까지 가야 완성됩니다.
구현 예시: 트랜잭션 + Outbox 기록
아래는 주문 서비스에서 결제 승인 이벤트를 Outbox에 함께 기록하는 예시입니다. (언어는 Kotlin 스타일로 작성)
data class OutboxEvent(
val eventId: String,
val aggregateId: String,
val sequence: Long,
val eventType: String,
val payloadJson: String,
val status: String = "PENDING"
)
@Transactional
fun authorizePayment(orderId: String, amount: Long) {
val order = orderRepository.findByIdForUpdate(orderId)
require(order.status == OrderStatus.PAYMENT_PENDING)
// 1) 비즈니스 상태 변경
order.status = OrderStatus.PAYMENT_AUTHORIZED
order.version += 1
orderRepository.save(order)
// 2) 같은 트랜잭션에서 Outbox 적재
val event = OutboxEvent(
eventId = UUID.randomUUID().toString(),
aggregateId = orderId,
sequence = order.version, // aggregate version을 sequence로 사용
eventType = "PAYMENT_AUTHORIZED",
payloadJson = json.encodeToString(mapOf("orderId" to orderId, "amount" to amount))
)
outboxRepository.insert(event) // event_id unique
}
포인트:
order.version을 aggregate 시퀀스로 재사용하면 구현이 단순해집니다.- DB 락(
findByIdForUpdate)으로 같은 주문에 대한 동시 갱신을 직렬화하면 sequence가 안정적입니다.
Outbox 퍼블리셔(폴링) 구현 예시
폴링 기반은 단순하고 DB 부하를 예측하기 쉽습니다. 대신 지연이 생길 수 있어 배치 크기/주기를 튜닝합니다.
fun publishLoop() {
while (true) {
val batch = outboxRepository.findPending(limit = 200)
batch.forEach { e ->
try {
kafkaProducer.send(
topic = "order-events",
key = e.aggregateId,
value = e.payloadJson,
headers = mapOf(
"eventId" to e.eventId,
"eventType" to e.eventType,
"sequence" to e.sequence.toString()
)
)
outboxRepository.markPublished(e.eventId)
} catch (ex: Exception) {
outboxRepository.markFailed(e.eventId, ex.message ?: "unknown")
}
}
Thread.sleep(200)
}
}
주의점:
markPublished는 “send 성공” 이후에만 해야 합니다.send가 비동기라면 flush/ack를 어떻게 기다릴지 결정해야 합니다.- 실패 이벤트를
FAILED로만 두면 영구 정체될 수 있으니 재시도 정책(횟수/백오프/데드레터)을 추가하세요.
컨슈머에서 중복처리 막기: Inbox(Processed Events) 테이블
가장 확실한 방법은 컨슈머가 처리한 eventId를 저장하는 것입니다. 흔히 Inbox 패턴 또는 Processed Events 테이블로 부릅니다.
핵심 아이디어
- 이벤트 처리 트랜잭션 안에서
eventId를 “처리됨”으로 기록(유니크)- 비즈니스 상태 변경
- 같은
eventId가 다시 오면 유니크 제약에 걸리므로 아무 일도 하지 않음
CREATE TABLE processed_event (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
sequence BIGINT NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_processed_event_agg ON processed_event(aggregate_id);
컨슈머 처리 로직(의사코드):
@Transactional
fun onMessage(msg: KafkaMessage) {
val eventId = msg.headers["eventId"]
val aggregateId = msg.key
val sequence = msg.headers["sequence"].toLong()
// 1) eventId dedup (유니크 insert)
val inserted = processedEventRepository.tryInsert(eventId, aggregateId, sequence)
if (!inserted) return // duplicate
// 2) 순서 체크는 아래 섹션에서 추가
applyBusinessChange(msg)
}
이 방식은 DB 쓰기가 늘지만, 금전/재고 같은 도메인에서는 비용을 지불할 가치가 큽니다.
역보상 막기: 컨슈머 상태머신 + 시퀀스 가드
중복은 eventId로 막고, 역보상은 “aggregate의 마지막 적용 시퀀스”로 막는 접근이 효과적입니다.
1) aggregate별 last_applied_sequence 저장
주문/결제/재고 등 각 aggregate 테이블에 다음을 둡니다.
last_event_sequence
그리고 이벤트 적용 시:
- 들어온
sequence가last_event_sequence이하이면 무시(이미 더 최신 상태 적용됨) - 크면 적용 후
last_event_sequence갱신
@Transactional
fun applyWithSequenceGuard(orderId: String, incomingSeq: Long, change: () -> Unit) {
val order = orderRepository.findByIdForUpdate(orderId)
if (incomingSeq <= order.lastEventSequence) {
return // stale event: 역보상/지연 이벤트 방어
}
change()
order.lastEventSequence = incomingSeq
orderRepository.save(order)
}
이 가드가 있으면, 보상 이벤트가 먼저 적용되어 lastEventSequence가 올라간 상태에서 정방향 이벤트가 늦게 도착하더라도 sequence가 낮으면 무시됩니다. 반대로 정방향이 먼저 적용된 뒤 보상이 늦게 오면, 보상 sequence가 더 크도록 설계해야 합니다.
2) “보상 이벤트의 sequence”를 어떻게 정할까
여기서 흔한 함정이 있습니다.
- 정방향은
version이 증가하니 sequence가 자연스럽게 커짐 - 보상은 “실패 처리”로 별도 상태 전이이므로, 반드시 정방향보다 큰 sequence가 되도록 만들어야 함
권장 방식:
- 보상도 aggregate 상태 전이로 보고 같은 테이블 version을 올린 뒤 Outbox에 기록
- 즉, 보상은 “되돌리기”가 아니라 “새로운 상태로 전이”입니다
예:
PAYMENT_AUTHORIZEDsequence 10- 실패 감지 후
PAYMENT_COMPENSATEDsequence 11
이렇게 하면 순서가 뒤집혀 도착해도 sequence 가드로 최종 상태가 안정됩니다.
3) 상태머신으로 불가능한 전이를 차단
sequence만으로는 “동일 sequence 내 의미 충돌” 같은 문제를 완전히 막기 어렵습니다. 따라서 도메인 상태머신을 두고 전이를 검증하세요.
예: 결제 상태
PENDING->AUTHORIZED->CAPTURED- 실패 시
AUTHORIZED->COMPENSATED COMPENSATED->CAPTURED는 금지
이 검증은 컨슈머/프로듀서 양쪽에 있어도 좋지만, 최소한 “상태를 최종 기록하는 곳”에는 있어야 합니다.
토픽 설계: 정방향/보상을 같은 스트림에 둘 것인가
역보상 관점에서는 “같은 aggregate의 이벤트가 한 파티션에서 순서대로”가 가장 중요합니다.
- 정방향과 보상을 같은 토픽에 두면 파티션 순서 보장에 유리
- 토픽을 분리하면(예:
order-events와order-compensations) 순서가 더 쉽게 깨짐
따라서 특별한 이유가 없다면:
- 하나의
order-events토픽 eventType으로 정방향/보상을 구분- 키는
orderId
이 구성이 운영 난이도를 낮춥니다.
운영에서 자주 겪는 장애 포인트와 진단 팁
1) 컨슈머 리밸런스 후 중복 급증
리밸런스 자체는 정상 동작이지만, 처리량이 부족해 lag이 쌓이면 재처리/타임아웃이 연쇄적으로 발생합니다.
- 컨슈머 처리 시간을 줄이거나
- 파티션 수/컨슈머 수를 조정하거나
- 외부 API 호출을 분리(비동기)하는 식으로 병목을 줄이세요.
2) Outbox 폴러가 멈춰서 이벤트가 “DB에만 존재”
Outbox 테이블의 PENDING가 쌓이면 다운스트림은 아무것도 모릅니다.
PENDING건수, oldest age를 메트릭으로 뽑아 알람- 퍼블리셔 프로세스 healthcheck에 “최근 발행 시각” 포함
컨테이너 환경에서 이런 이슈는 이미지/시크릿/권한 문제로도 시작됩니다. 원인 분해 방식은 아래 글과 유사하게 체크리스트화하는 게 좋습니다: Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트
3) 캐시/조회 모델이 stale해 보여서 “보상이 안 됐다”로 오해
Saga는 결국 일관성이므로, 조회 모델(캐시, CQRS read model)이 늦게 반영되면 장애처럼 보입니다.
- 이벤트 처리 지연(lag)
- read model 업데이트 지연
- 캐시 TTL/무효화 실패
이건 웹에서도 흔한 문제입니다. “상태는 바뀌었는데 화면은 안 바뀌는” 류의 디버깅 감각은 다음 글이 도움이 됩니다: Next.js 14 RSC 캐시 꼬임과 stale 데이터 해결법
실전 결론: Outbox는 시작이고, 완성은 3종 세트
Kafka Saga에서 중복처리·역보상을 실질적으로 막으려면 다음 3가지를 세트로 가져가야 합니다.
Outbox(프로듀서 측 원자성)
- DB 상태 변경과 이벤트 기록을 한 트랜잭션으로
- 퍼블리셔는 재시도 가능하게
Inbox/Processed Events(컨슈머 측 멱등성)
eventId기반 dedup을 DB 유니크로 강제
Sequence 가드 + 상태머신(역보상/순서 뒤틀림 방어)
aggregateId기준sequence증가last_applied_sequence로 stale 이벤트 무시- 불가능한 상태 전이 차단
이 조합을 적용하면 “중복은 어쩔 수 없다”가 아니라, 중복이 와도 안전한 시스템으로 바뀝니다. Saga를 운영 가능한 수준으로 끌어올리는 데 Outbox는 필수지만, 그 자체가 종착점은 아니라는 점이 핵심입니다.