Kafka 마스터 — Transactional Outbox Pattern

2026-05-03확률과 통계 마스터 노트

카프카 심화편 (9~15편) 7편 (마지막). DB 트랜잭션과 Kafka 발행의 원자성 문제, Transactional Outbox 패턴이 같은 DB 트랜잭션 안에서 비즈니스 데이터 + outbox 테이블 동시 저장으로 푸는 방식, 폴링 메커니즘으로 outbox 테이블에서 읽어 Kafka 발행, at-least-once 보장과 멱등성 결합, OutboxMapper로 직렬화·역직렬화, CDC(Debezium) 비교까지 — 시리즈 마무리.

이 글은 카프카 마스터 노트 시리즈의 마지막 열다섯 번째 편입니다. 1~6편이 SCS·Saga 패턴이었다면, 이번엔 그 모든 패턴이 부딪히는 근본 문제 — DB와 Kafka의 원자성.

DB는 ACID, Kafka는 별도 시스템. 둘에 동시 쓰기는 분산 트랜잭션이지만 비싸고 불안정. Transactional Outbox가 이걸 우아하게 풉니다. 이 시리즈의 마지막 답.

처음 Outbox가 어렵게 느껴지는 이유

처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, "DB와 Kafka 원자성"이 왜 문제인지 직관적이지 않습니다. 둘째, "어차피 폴링하는 거면 직접 발행과 뭐가 다르냐" 같은 의문이 듭니다.

해결법은 한 가지예요. "우체통에 일단 넣고 우체부가 가져감" 비유로 묶는 것. 직접 발행 = 직접 우체국까지 뛰어감(DB와 Kafka 둘 다 책임). Outbox = 회사 우체통(DB)에 넣음 → 우체부(폴러)가 알아서 우체국(Kafka)에 가져감. 한 번에 한 가지만 책임.

DB + Kafka 원자성 문제

단순 패턴 — 둘 다 직접

@Transactional
public void createOrder(OrderRequest req) {
    Order order = orderRepo.save(toEntity(req));   // DB
    kafkaTemplate.send("order-events", toEvent(order));  // Kafka
}

문제 시나리오:

1. DB 저장 OK
2. Kafka 발행 실패 (네트워크 끊김)
3. @Transactional이 ROLLBACK
4. DB 롤백됐는데, Kafka 메시지는?
   → 이미 발행됐을 수도 있고 안 됐을 수도 있음

또는 반대:

1. DB 저장 OK
2. Kafka 발행 OK
3. 이후 다른 처리에서 실패 → @Transactional ROLLBACK
4. DB 롤백, Kafka 메시지는 그대로 → 일관성 깨짐

여기서 정말 중요한 시험 함정 — @Transactional + Kafka 발행 = 부분 실패 위험. DB는 트랜잭션, Kafka는 별개 시스템. 같은 트랜잭션에 묶을 수 없음.

2PC는 답이 아니다

이론적 해결 — 2-Phase Commit (XA 트랜잭션):

Coordinator → DB PREPARE / Kafka PREPARE
            → 둘 다 OK → COMMIT
            → 하나라도 실패 → ROLLBACK

문제:

  • Kafka는 2PC 미지원 (대부분 분산 시스템도)
  • 느림 (모든 노드 대기)
  • Coordinator 장애 = 무한 대기

여기서 정말 중요한 시험 함정 — Kafka는 2PC 미지원. Saga·Outbox 같은 패턴이 등장한 이유.

Outbox Pattern — 핵심 아이디어

Step 1 (트랜잭션 안):
  - 비즈니스 데이터 저장 (orders 테이블)
  - 이벤트를 outbox 테이블에 저장
  - 둘 다 같은 DB → 같은 트랜잭션 → ACID 보장

Step 2 (별도 프로세스):
  - 폴러가 outbox 테이블 조회
  - Kafka에 발행
  - 발행 성공 시 outbox 레코드 삭제

DB 한 번만 쓰면 끝. Kafka 발행은 비동기 별도 프로세스가.

구현

1. Outbox 테이블

CREATE TABLE order_outbox (
    id BIGSERIAL PRIMARY KEY,
    aggregate_type VARCHAR NOT NULL,    -- "Order"
    aggregate_id VARCHAR NOT NULL,      -- order_id
    event_type VARCHAR NOT NULL,        -- "OrderCreated"
    payload BYTEA NOT NULL,             -- 직렬화된 이벤트
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_outbox_created ON order_outbox(created_at);

2. 비즈니스 트랜잭션 안 outbox 저장

@Service
@Transactional
public class OrderService {
    @Autowired
    private OrderRepository orderRepo;
    @Autowired
    private OutboxRepository outboxRepo;
    @Autowired
    private OutboxMapper mapper;

    public void createOrder(OrderRequest req) {
        // 1. 비즈니스 데이터
        Order order = orderRepo.save(toEntity(req));

        // 2. Outbox에 이벤트 저장 (같은 트랜잭션)
        OrderEvent event = new OrderCreated(order.getId(), order.getAmount(), ...);
        outboxRepo.save(new Outbox(
            "Order",
            order.getId(),
            "OrderCreated",
            mapper.serialize(event)
        ));

        // 트랜잭션 commit 시 둘 다 영속, 실패 시 둘 다 롤백
    }
}

여기서 정말 중요한 시험 함정 — 두 테이블 모두 같은 DB, 같은 트랜잭션. ACID 보장. Kafka는 일단 신경 X.

3. 폴러 (별도 프로세스)

@Service
public class OutboxPoller {
    @Autowired
    private OutboxRepository outboxRepo;
    @Autowired
    private StreamBridge streamBridge;
    @Autowired
    private OutboxMapper mapper;

    @Scheduled(fixedDelay = 1000)   // 1초마다
    public void poll() {
        List<Outbox> pending = outboxRepo.findAllByOrderByCreatedAtAsc(PageRequest.of(0, 100));

        for (Outbox out : pending) {
            try {
                Object event = mapper.deserialize(out.getPayload(), out.getEventType());
                String topic = topicFor(out.getAggregateType());

                streamBridge.send(topic, event);

                // 발행 성공 → 삭제
                outboxRepo.delete(out);
            } catch (Exception e) {
                log.error("Publish failed, retry next cycle", e);
                // 다음 폴링에서 재시도
            }
        }
    }
}

폴링 간격 = 처리 지연과 부하 균형. 1초가 일반적.

4. OutboxMapper — 직렬화

@Component
public class OutboxMapper {
    @Autowired
    private ObjectMapper objectMapper;

    public byte[] serialize(Object event) {
        try {
            return objectMapper.writeValueAsBytes(event);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    public Object deserialize(byte[] data, String type) {
        try {
            Class<?> clazz = Class.forName(type);
            return objectMapper.readValue(data, clazz);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

At-Least-Once + 멱등성

폴러 다운 → 재시작 → 재발행

1. outbox 저장 (DB)
2. 폴러가 발행 시작
3. 발행 도중 폴러 다운 (이미 발행됐을 수도)
4. 재시작 → 같은 outbox 레코드 다시 발행 → 중복!

해결 — 컨슈머 측 멱등 처리:

@Transactional
public void onOrderCreated(OrderCreated event) {
    // 멱등 검사
    if (paymentRepo.existsByOrderId(event.orderId())) {
        return;
    }
    // 처리
    ...
}

여기서 정말 중요한 시험 함정 — Outbox = at-least-once + 멱등 = 사실상 exactly-once. 7편(1~8편)에서 본 패턴 그대로. Kafka Transaction 없이도 가능.

운영 고려사항

1. Outbox 테이블 정리

폴링 후 삭제 = 테이블 작게 유지.

2. 인덱스

created_at 인덱스로 폴링 빠르게.

3. 폴링 간격

fixedDelay = 100   # 매우 빠름, DB 부하 ↑
fixedDelay = 1000  # 일반적, 1초 지연
fixedDelay = 5000  # 가벼운 부하, 5초 지연

4. 배치 크기

PageRequest.of(0, 100)   # 한 번에 100개

너무 크면 한 번 처리 시간 ↑. 균형.

5. 동시성

여러 인스턴스가 폴링 시 같은 레코드 두 번 발행 위험. SELECT FOR UPDATE SKIP LOCKED 또는 한 인스턴스만 폴링 보장.

SELECT * FROM order_outbox
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;

여러 폴러가 안전하게 분산 처리.

CDC (Change Data Capture) — 대안

Debezium 같은 CDC 도구로 DB 변경 자동 감지 → Kafka 발행:

DB → CDC → Kafka

장점 — 폴링 불필요, 거의 실시간. 단점 — 도구 운영 부담·DB 의존도 ↑.

여기서 시험 함정이 하나 있어요. CDC vs Outbox. CDC는 모든 변경 자동 → 비즈니스와 무관한 변경도 발행. Outbox는 명시적으로 발행할 이벤트만. 의도가 다름.

코레오·오케스 + Outbox

5·6편의 Saga 패턴 모두 Outbox와 결합:

@Transactional
public void onOrderCreated(OrderCreated event) {
    // 결제 처리
    payment.deduct(event.amount());
    paymentRepo.save(...);

    // Outbox에 이벤트 저장 (직접 발행 X)
    outboxRepo.save(new Outbox(
        "Payment", event.orderId(), "PaymentDeducted", ...
    ));

    // 트랜잭션 commit 시 둘 다 영속
}

이래서 4편에서 "Reactive Binder는 트랜잭션 미지원 → Outbox 필요"라 한 것.

시리즈 마무리 — 15편 종합

1편부터 7편까지의 흐름:

주제 한 줄
1 SCS 기초 Binder/Binding, Supplier/Consumer/Function
2 StreamBridge 동적 라우팅, 콘텐츠 기반
3 Fan-Out/In 1→N 분배, N→1 집계
4 SCS Tips content-type, DLT, typeId, Native
5 Saga 코레오 이벤트 기반 협력, 도메인 이벤트
6 Saga 오케스 중앙 지시, 명령/응답 페어
7 Outbox DB+Kafka 원자성, 시리즈 마무리

기초편(18편) + 이 시리즈(915편)까지 Kafka 마이크로서비스의 거의 모든 핵심 패턴 정리.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 7편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • DB + Kafka 원자성 문제 = @Transactional + 발행 부분 실패 위험
  • Kafka는 2PC 미지원 → 다른 패턴 필요
  • Outbox Pattern = 같은 DB 트랜잭션에 비즈니스 + outbox 테이블
  • DB 한 번만 쓰면 끝 (ACID 보장)
  • 폴러가 outbox 테이블 → Kafka 발행 → 삭제
  • Outbox 테이블 — id·aggregate_type·aggregate_id·event_type·payload·created_at
  • 비즈니스 트랜잭션 안 outbox 저장 (같은 @Transactional)
  • 폴러 — @Scheduled 또는 별도 프로세스
  • 1초 간격 일반적
  • 발행 성공 → 삭제
  • At-least-once + 멱등 = 사실상 exactly-once
  • 컨슈머 측 멱등 처리 필수
  • 운영 — 인덱스(created_at) / 배치 크기 / 동시성(SKIP LOCKED)
  • SELECT FOR UPDATE SKIP LOCKED = 여러 폴러 안전 분산
  • CDC (Debezium) = 대안
  • 폴링 X, 거의 실시간
  • 모든 변경 발행 (비즈니스와 무관한 것도)
  • Outbox = 명시적, CDC = 자동
  • Saga + Outbox 결합 = 표준 마이크로서비스 패턴
  • Reactive Binder의 트랜잭션 미지원 해결책

시리즈 다른 편 (시리즈 마지막)

공식 문서: Microservices Patterns — Transactional Outbox / Debezium Outbox Event Router 에서 더 깊이.

Kafka 마스터 노트는 기초편(18편) 8편 + Spring Cloud Stream·Saga편(915편) 7편, 총 15편으로 마무리. 두 시리즈 흐름이 머리에 남으면 이벤트 드리븐 마이크로서비스의 거의 모든 운영 패턴을 손에 잡고 시작할 토대가 됩니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!