Published on

Kafka Exactly-Once 깨질 때 Outbox 패턴 구현

Authors

서로 다른 시스템 경계(애플리케이션 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 설정이 아니라 시스템 설계로 달성할 수 있습니다.