- Published on
Kafka Exactly-Once 깨질 때 Outbox 패턴 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 시스템 경계(애플리케이션 DB 트랜잭션과 Kafka 프로듀서 전송)를 동시에 묶지 못하면, Kafka 설정만으로는 "정확히 한 번"이 깨질 수 있습니다. 특히 주문 생성 같은 핵심 도메인에서 이벤트를 발행할 때, 장애 타이밍에 따라 중복 발행 또는 유실이 현실적으로 발생합니다.
이 글에서는
- Kafka Exactly-Once(이하 EOS)가 보장하는 범위와 착각하기 쉬운 지점을 짚고
- 왜 DB 업데이트와 Kafka 발행을 한 번에 원자적으로 만들기 어려운지 설명한 뒤
- Outbox 패턴을 구현해 "DB 커밋된 사실"을 근거로 이벤트를 안정적으로 발행하는 방법
을 실제 코드 중심으로 정리합니다.
Kafka Exactly-Once가 보장하는 것과 보장하지 않는 것
Kafka에서 흔히 말하는 EOS는 보통 다음 조합을 의미합니다.
- 프로듀서 멱등성:
enable.idempotence=true - 트랜잭션 프로듀서:
transactional.id설정 후beginTransaction()및commitTransaction() - 컨슈머 측 read-process-write를 트랜잭션으로 묶는 패턴(consume-transform-produce)
여기서 핵심은 "Kafka 내부"에서의 원자성입니다.
- 같은
transactional.id로 같은 메시지를 여러 번 보내도 브로커가 중복을 제거해 "한 번만 기록"되게 할 수 있음 - 컨슈머 오프셋 커밋과 프로듀스 결과를 같은 Kafka 트랜잭션에 묶어 "처리-발행"을 원자적으로 만들 수 있음
하지만 많은 서비스에서 실제로 필요한 것은 다음입니다.
- DB에 주문 row를 저장
- 그 주문 생성 이벤트를 Kafka에 발행
이 두 작업은 서로 다른 트랜잭션 시스템(DB 트랜잭션 vs Kafka 트랜잭션)입니다. 2PC(2-phase commit) 같은 분산 트랜잭션을 쓰지 않는 이상, 둘을 완전한 원자 단위로 묶기는 어렵습니다(운영 복잡도와 장애 시나리오가 급격히 증가).
즉, Kafka EOS를 켰다고 해서 "DB 커밋과 Kafka 발행"이 자동으로 정확히 한 번이 되지 않습니다.
Exactly-Once가 깨지는 대표 시나리오
1) DB 커밋 성공, Kafka 발행 실패(또는 타임아웃)
- 애플리케이션이 DB 트랜잭션을 커밋
- Kafka
send()가 네트워크/브로커 문제로 실패하거나, 성공했는데 응답을 못 받아 타임아웃
이때 애플리케이션이 재시도하면
- 실제로는 Kafka에 이미 기록됐는데 재시도로 중복 발행이 되거나
- 실제로 기록이 안 됐는데 이벤트 유실이 되거나
둘 중 하나가 됩니다. 프로듀서 멱등성은 "같은 프로듀서 세션"과 "같은 시퀀스" 맥락에서만 잘 동작하며, 애플리케이션 재시작/프로세스 크래시/트랜잭션 경계 밖 재시도는 별도 설계가 필요합니다.
2) Kafka 발행 성공, DB 커밋 실패
- Kafka에 이벤트는 나갔는데
- DB 트랜잭션은 롤백
컨슈머는 "존재하지 않는 주문"에 대한 이벤트를 처리하게 됩니다. 결국 보상 트랜잭션, 데이터 정합성 깨짐, 재처리 비용이 발생합니다.
3) 컨슈머는 최소 1회(at-least-once) 기반으로 동작
컨슈머는 리밸런스, 재시작, 처리 중 예외 등으로 같은 메시지를 다시 받을 수 있습니다. 결국 다운스트림(메일 발송, 포인트 적립, 외부 API 호출)이 멱등하지 않으면 중복 부작용이 발생합니다.
정리하면, Kafka 설정으로 해결되는 부분과 애플리케이션/DB 설계로 해결해야 하는 부분이 섞여 있는데, 많은 장애는 후자에서 터집니다.
Outbox 패턴이 해결하는 핵심
Outbox 패턴은 "도메인 변경"과 "이벤트 발행 예약"을 같은 DB 트랜잭션으로 묶습니다.
- 주문 테이블 업데이트
- outbox 테이블에 이벤트 row 삽입
이 둘을 같은 DB 트랜잭션으로 커밋하면,
- DB가 커밋됐다면 outbox에도 반드시 이벤트가 존재
- DB가 롤백됐다면 outbox에도 이벤트가 없음
즉, "발행해야 할 이벤트 목록"을 DB가 단일 진실 소스로 보장해줍니다.
그 다음은 별도의 퍼블리셔(백그라운드 워커)가 outbox를 읽어 Kafka로 발행하고, 발행 성공 시 outbox 상태를 갱신합니다.
이때 중복 발행 가능성은 남지만, 중복을 제어할 수 있는 "키"와 "상태"가 명확해집니다.
구현 전략 2가지: Polling vs CDC
1) Polling Publisher(가장 단순, 범용)
- 일정 주기로 outbox에서 미발행 레코드를 조회
- Kafka로 발행
- 성공하면
SENT로 마킹
장점: 구현이 쉽고 DB만 있으면 됨 단점: 폴링 지연, DB 부하, 락 경합을 설계로 풀어야 함
2) CDC(Change Data Capture) 기반
- Debezium 같은 CDC로 outbox 테이블 변경을 캡처해 Kafka로 스트리밍
장점: 낮은 지연, 폴링 부하 감소 단점: 인프라 복잡도 증가, 운영 난이도 상승
이 글은 범용성이 높은 Polling 방식으로 설명합니다.
DB 스키마 예시(PostgreSQL)
outbox는 최소한 다음을 포함합니다.
- 이벤트 식별자(전역 유니크)
- aggregate 정보(주문 ID 등)
- 이벤트 타입
- payload(JSON)
- 상태(PENDING, SENT, FAILED)
- 재시도/에러 정보
create table outbox_event (
id uuid primary key,
aggregate_type varchar(50) not null,
aggregate_id varchar(100) not null,
event_type varchar(100) not null,
payload jsonb not null,
status varchar(20) not null default 'PENDING',
attempts int not null default 0,
last_error text,
created_at timestamptz not null default now(),
sent_at timestamptz
);
create index idx_outbox_pending_created
on outbox_event (status, created_at);
create unique index uq_outbox_dedup
on outbox_event (aggregate_type, aggregate_id, event_type, id);
uq_outbox_dedup은 예시이며, 실제 중복 기준은 도메인에 맞춰 설계합니다. 예를 들어 "주문 생성"은 주문 ID당 한 번만 나가야 한다면 (aggregate_type, aggregate_id, event_type)를 유니크로 잡는 방식도 고려할 수 있습니다.
트랜잭션 안에서 도메인 저장 + Outbox 기록
아래는 Spring Boot + JPA 스타일의 예시입니다(핵심은 "같은 DB 트랜잭션"입니다).
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
@Transactional
public Long createOrder(CreateOrderCommand cmd) {
Order order = new Order(cmd.userId(), cmd.items());
orderRepository.save(order);
OutboxEvent event = OutboxEvent.pending(
"Order",
String.valueOf(order.getId()),
"OrderCreated",
Map.of(
"orderId", order.getId(),
"userId", cmd.userId(),
"createdAt", Instant.now().toString()
)
);
outboxRepository.save(event);
return order.getId();
}
}
여기서 중요한 포인트:
- Kafka를 이 트랜잭션 안에서 직접 호출하지 않습니다.
- 이벤트 payload는 "컨슈머가 필요한 최소 데이터"를 담고, 변경 가능성이 큰 필드는 스키마 버전 전략을 둡니다.
Outbox Publisher(폴링 워커) 구현
여러 인스턴스가 동시에 퍼블리셔를 돌려도 안전해야 합니다. 흔한 방법은 SELECT ... FOR UPDATE SKIP LOCKED로 작업을 분배하는 것입니다.
-- 한 워커가 가져갈 작업을 잠그고(SKIP LOCKED로 경합 회피)
-- 오래된 순으로 N개 가져오는 예시
select *
from outbox_event
where status = 'PENDING'
order by created_at
for update skip locked
limit 100;
Spring에서의 의사 코드:
@Component
public class OutboxPublisher {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Scheduled(fixedDelayString = "${outbox.poll-interval-ms:500}")
public void publishBatch() {
// 1) PENDING을 잠그고 가져온다
List<OutboxEvent> batch = outboxRepository.lockNextPending(100);
for (OutboxEvent e : batch) {
try {
String key = e.getAggregateType() + ":" + e.getAggregateId();
String value = objectMapper.writeValueAsString(e.getPayload());
// 2) Kafka 발행
kafkaTemplate.send("order-events", key, value).get();
// 3) 발행 성공 처리
outboxRepository.markSent(e.getId(), Instant.now());
} catch (Exception ex) {
outboxRepository.markFailed(e.getId(), ex.getMessage());
}
}
}
}
실무적으로는 다음을 꼭 추가합니다.
send().get()처럼 동기 대기는 처리량을 낮출 수 있으니, 비동기 전송 후 콜백에서 상태 업데이트하거나 배치 전송 전략을 씁니다.- 실패를 무조건
FAILED로 두기보다attempts를 올리고 지수 백오프(next_attempt_at 컬럼)로 재시도합니다. - 영구 실패(dead letter) 정책을 둡니다.
중복 발행은 어떻게 막나: 컨슈머 멱등 + 이벤트 키
Outbox는 "유실"을 강하게 줄여주지만, "중복" 가능성은 남습니다.
예를 들어 퍼블리셔가 Kafka에 성공적으로 보냈는데, DB에 SENT 마킹 전에 프로세스가 죽으면 재시작 후 같은 outbox row를 다시 발행할 수 있습니다.
따라서 정답은 보통 다음 조합입니다.
- 프로듀서: 가능한 한 멱등하게(키 안정화, 재시도 정책)
- 컨슈머: 반드시 멱등 처리
컨슈머 멱등 처리 예시
컨슈머가 처리한 이벤트 ID를 DB에 저장하고, 이미 처리된 이벤트면 스킵합니다.
create table consumed_event (
event_id uuid primary key,
consumed_at timestamptz not null default now()
);
@Transactional
public void handleOrderCreated(ConsumedMessage msg) {
UUID eventId = msg.eventId();
if (consumedEventRepository.existsById(eventId)) {
return; // 이미 처리됨
}
// 부작용 처리: 포인트 적립, 메일 발송 요청 저장 등
rewardService.grant(msg.orderId(), msg.userId());
consumedEventRepository.save(new ConsumedEvent(eventId));
}
이 방식은 "부작용"이 DB 트랜잭션으로 보호될 때 특히 강력합니다.
운영에서 자주 놓치는 디테일
1) Outbox 테이블이 커지는 문제
SENT이벤트를 주기적으로 아카이브/삭제하는 잡이 필요합니다.- 삭제 기준은 보통
sent_at기준 N일 보관입니다.
2) 발행 순서 보장
- aggregate 단위 순서가 중요하면 Kafka 파티션 키를
aggregate_id로 고정합니다. - outbox 조회도
aggregate_id별로 순서를 지키려면 설계가 더 필요합니다(동일 aggregate 이벤트를 동시에 잡지 않게).
3) 배포/롤백 시 중복 처리
배포 도중 워커가 동시에 뜨거나 재시작이 반복되면 중복 가능성이 증가합니다. GitOps로 배포할 때 동기화 상태가 흔들리면 의도치 않은 재기동이 발생할 수 있으니, 배포 파이프라인과 컨트롤 플레인 상태를 안정적으로 관리하는 것이 중요합니다.
관련해서는 Argo CD Sync Failed - drift·Helm 값·RBAC 해결 글이 운영 관점에서 도움이 됩니다.
4) DB 커넥션 풀 고갈
폴링 워커가 과도한 동시성으로 DB를 압박하면 애플리케이션 본 트래픽이 커넥션을 못 잡고 장애로 번질 수 있습니다. HikariCP를 쓰는 Spring Boot라면 풀 사이즈, 타임아웃, 쿼리 효율을 함께 봐야 합니다.
이 주제는 Spring Boot 대규모 트래픽 HikariCP 고갈 진단·튜닝도 같이 참고하면 좋습니다.
테스트로 검증해야 할 장애 타이밍
Outbox는 "장애가 나도 결국 수렴"하는 설계입니다. 따라서 다음을 강제로 깨뜨려보는 테스트가 중요합니다.
- Kafka 전송 직후 프로세스 강제 종료(마킹 전)
- DB 커밋 직후 Kafka 장애(발행 불가)
- 퍼블리셔 중복 실행(다중 인스턴스)
- 컨슈머 리밸런스/재시작으로 동일 메시지 재수신
테스트 결과로 확인해야 하는 것은 단 하나입니다.
- 주문 row가 존재하면, 이벤트는 언젠가 반드시 발행된다
- 이벤트가 중복 발행돼도, 다운스트림 부작용은 한 번만 발생한다
정리
Kafka EOS는 강력하지만, 많은 서비스가 실제로 원하는 "DB 변경과 이벤트 발행"을 단일 원자 작업으로 만들어주지는 않습니다. 그 경계에서 생기는 유실/중복을 현실적으로 줄이려면 Outbox 패턴이 가장 검증된 해법입니다.
실전 적용 체크리스트는 다음과 같습니다.
- 도메인 저장과 outbox insert를 같은 DB 트랜잭션으로 묶기
- 퍼블리셔는
FOR UPDATE SKIP LOCKED등으로 안전한 작업 분배 - 발행 성공 마킹 전 크래시를 고려해 컨슈머 멱등 처리 필수
- outbox 청소, 재시도/백오프, DLQ 등 운영 정책 포함
이 조합을 갖추면 "정확히 한 번"이라는 표현을 Kafka 설정이 아니라 시스템 설계로 달성할 수 있습니다.