이 글은 MSA / 분산 시스템 에서 DB 변경 + 외부 시스템 알림원자적 + 신뢰성 있게 처리하는 Outbox 패턴비동기 연동진짜 구현실전 postmortem 4 건 으로 정리한다. transactional 보장 / Two-Phase Commit 함정 / CDC vs Polling 비교 / Triple Idempotency Stack4 layer 까지.

전 글들 (트래픽 폭증 대기열 + 동기/비동기 연동) 의 후속편. 고부하 / 비동기 / 신뢰성3 축완성 하는 마지막 layer.

읽고 가셔도 좋은 분:

  1. MSA 마이그레이션 중인 백엔드 — DB 변경 후 Kafka 발행원자성 어떻게 보장 하는지 모르는 사람
  2. 결제 / 정산 / 주문 시스템 개발자 — “이벤트 중복 발행” / “이벤트 누락” 사고 경험 있는 사람
  3. Debezium / Outbox / Saga차이가 *애매한 사람

TL;DR

DB 트랜잭션 + Kafka 발행동시에 *원자적 처리 하려는 순진한 시도반드시 깨짐Dual Write Problem. Transactional Outbox현실적 해법outbox_events 테이블 INSERTDB tx 안 에서 함께 commit, 별도 poller / CDCKafka 발행. 수신측 *idempotency 까지 합치면 Triple Idempotency StackOutbox event_id UNIQUE → processed_events PK → 비즈니스 키 UNIQUE3 단 방어.

한 그림으로:

[order-service]                              [settlement-service]
                                             
@Transactional                               
  payment.capture()                          
  outbox_events INSERT (PaymentCaptured)     
       ↓ (DB commit — 원자성 보장)            
       │                                     
       ▼                                     
[Poller (2초 주기)]                          
  status: PENDING → PUBLISHED                
       ↓                                     
       Kafka Topic: lemuel.payment.captured  
       ↓                                     
       ▼                                     
                                  [Consumer]
                                  processed_events (group_id, event_id) PK 검사
                                       ↓ 신규 이벤트만
                                  @Transactional
                                    settlement INSERT (payment_id UNIQUE)
                                    processed_events INSERT

0. 왜 *Dual Write 가 *반드시 깨지는가**

0.1 순진한 시도

@Service
public class OrderService {
    
    @Transactional
    public Order createOrder(OrderRequest req) {
        Order order = new Order(req);
        orderRepository.save(order);                    // 1. DB
        kafkaTemplate.send("orders", order.toEvent());  // 2. Kafka
        return order;
    }
}

한 번 보면 맞는 것 같다. 그런데 3 가지 시나리오 에서 *반드시 깨진다.

0.2 깨지는 시나리오 3 가지

시나리오 1 — Kafka 발행 후 *DB commit 실패

1. Kafka send 성공  → 이벤트 *외부로 나감*
2. DB tx rollback (예: deadlock, constraint violation)
3. → DB 에는 *order 없는데*, Kafka 에는 *주문 생성 이벤트*
4. → 정산 / 재고 / 알림 시스템이 *유령 주문* 처리

시나리오 2 — DB commit 후 *Kafka 발행 실패

1. DB commit 성공  → order 영구화
2. Kafka send 실패 (broker 다운 / 네트워크)
3. → DB 에는 *order 있는데*, Kafka 에는 *이벤트 없음*
4. → 후속 시스템이 *주문 모름 — 영원히 누락*

시나리오 3 — 둘 다 성공 처럼 보이는데 *부분 실패

1. DB commit 성공
2. Kafka send 의 *async ack 받기 전에 *JVM crash*
3. → DB 영구, 메시지는 *발행 됐는지 모름*
4. → 재시도 시 *중복 발행* 가능

0.3 *Two-Phase Commit (2PC) 의 *함정**

[Coordinator]
    ↓ phase 1 — prepare
[Resource 1 DB]   [Resource 2 Kafka]
    ↓ vote yes        ↓ vote yes
    ↓ phase 2 — commit
[Resource 1 commit]  [Resource 2 commit]

이론적으로 가능. 실제로:

  • Kafka 는 XA 트랜잭션 미지원 (Kafka Transaction 은 Kafka 안 만)
  • 2PC 는 coordinator 가 *single point of failure
  • Blocking — coordinator 다운 시 모두 *대기
  • 마이크로서비스 시대에 비실용적 *처치

2PC 는 *2002 년 이후 *마이크로서비스 시대에 *거의 사라짐. Transactional Outbox현대적 대체.


1. Transactional Outbox — *원리**

1.1 핵심 아이디어

DB 변경 + 이벤트 발행2 개 트랜잭션 으로 분리. 첫 트랜잭션 에서 *DB 변경 + outbox INSERT 함께 commit (원자성). 두 번째 별도 프로세스outbox → Kafka 발행.

CREATE TABLE outbox_events (
    id              UUID PRIMARY KEY,
    event_id        VARCHAR(36) UNIQUE NOT NULL,    -- ★ UNIQUE 멱등성
    aggregate_type  VARCHAR(50)        NOT NULL,    -- 'Order', 'Payment' 등
    aggregate_id    VARCHAR(50)        NOT NULL,    -- '12345'
    event_type      VARCHAR(50)        NOT NULL,    -- 'PaymentCaptured'
    payload         JSONB              NOT NULL,
    status          VARCHAR(20)        NOT NULL DEFAULT 'PENDING',  -- PENDING / PUBLISHED / FAILED
    created_at      TIMESTAMP          NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at    TIMESTAMP,
    
    INDEX idx_outbox_status_created (status, created_at)
);

1.2 Producer 측 — *원자적 INSERT**

@Service
public class PaymentService {
    
    @Transactional
    public Payment capturePayment(Long orderId) {
        Payment payment = paymentRepository.findByOrderId(orderId);
        payment.capture();
        paymentRepository.save(payment);
        
        // ★ 같은 트랜잭션 안에서 outbox INSERT
        OutboxEvent event = OutboxEvent.builder()
            .eventId(UUID.randomUUID().toString())
            .aggregateType("Payment")
            .aggregateId(payment.getId().toString())
            .eventType("PaymentCaptured")
            .payload(toJson(payment))
            .status(PENDING)
            .build();
        outboxRepository.save(event);
        
        return payment;
    }
}

DB 가 *둘 다 commit 또는 둘 다 rollback. 원자성 100% 보장.

1.3 Poller — *별도 프로세스**

@Component
@RequiredArgsConstructor
public class OutboxPoller {
    
    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;
    
    /**
     * 2 초 주기로 PENDING 이벤트 조회 → Kafka 발행 → PUBLISHED 상태 갱신.
     * ShedLock 으로 *분산 환경 단일 실행* 보장 (HA).
     */
    @Scheduled(fixedDelay = 2000)
    @SchedulerLock(name = "outbox-poller", lockAtLeastFor = "PT2S")
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> pending = outboxRepository.findByStatusOrderByCreatedAt(
            PENDING, PageRequest.of(0, 100));
        
        for (OutboxEvent event : pending) {
            try {
                String topic = topicFor(event.getEventType());
                kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload())
                    .get(5, TimeUnit.SECONDS);   // *동기 ack 대기*
                
                event.setStatus(PUBLISHED);
                event.setPublishedAt(Instant.now());
                outboxRepository.save(event);
                
                meterRegistry.counter("outbox.published",
                    "type", event.getEventType()).increment();
            } catch (Exception e) {
                log.error("Outbox 발행 실패: {}", event.getEventId(), e);
                meterRegistry.counter("outbox.failed",
                    "type", event.getEventType()).increment();
                // *PENDING 상태 그대로* — 다음 poll 에서 재시도
                // 5 회 실패 시 DLQ 또는 alert
            }
        }
    }
}

1.4 멱등성 — *event_id UNIQUE**

-- DB 가 *발행자 중복 발행* 차단
INSERT INTO outbox_events (event_id, ...) VALUES (?, ...);
-- 동일 event_id 두 번째 INSERT 시 *UNIQUE constraint violation*

Producer 측 의 *재시도 안전성 보장.


2. Consumer 측 — *Inbox 패턴**

2.1 processed_events 테이블

CREATE TABLE processed_events (
    consumer_group  VARCHAR(100) NOT NULL,
    event_id        VARCHAR(36)  NOT NULL,
    processed_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    PRIMARY KEY (consumer_group, event_id)
);

2.2 Consumer 코드

@KafkaListener(topics = "lemuel.payment.captured", groupId = "settlement-service")
public void handlePaymentCaptured(ConsumerRecord<String, String> record) {
    String eventId = extractEventId(record.value());
    
    // ★ 같은 트랜잭션 안에서 *중복 검사 + 비즈니스 처리 + processed_events INSERT*
    settlementService.processPaymentCaptured(eventId, record.value());
}

@Service
public class SettlementService {
    
    @Transactional
    public void processPaymentCaptured(String eventId, String payload) {
        // 1. 중복 검사
        if (processedEventsRepository.exists("settlement-service", eventId)) {
            log.info("이미 처리된 이벤트: {}", eventId);
            return;   // *멱등 — 그냥 무시*
        }
        
        // 2. 비즈니스 처리
        PaymentCaptured event = parsePayload(payload);
        Settlement settlement = Settlement.fromPayment(event);
        settlementRepository.save(settlement);
        
        // 3. processed_events INSERT — *같은 tx*
        processedEventsRepository.save(
            new ProcessedEvent("settlement-service", eventId, Instant.now()));
    }
}

Consumer 측 의 *중복 처리 차단. at-least-once 메시징exactly-once 효과 로 변환.


3. Triple Idempotency Stack — *3 단 방어**

Producer + Consumer 의 *각자의 멱등성 만으로 부족할 수 있음. 비즈니스 키 UNIQUE마지막 layer.

3.1 3 layer 설명

[L1] outbox_events.event_id UNIQUE
     ↓ 발행자 단계 중복 차단
     ↓ Producer 재시도 안전
     
[L2] processed_events (consumer_group, event_id) PK
     ↓ 수신자 단계 중복 차단
     ↓ Consumer 재실행 안전
     
[L3] settlements.payment_id UNIQUE  (비즈니스 키)
     ↓ 비즈니스 무결성 강제
     ↓ 운영 미스 / 마이그레이션 사고 마지막 안전망

3.2 왜 *3 layer 필요한가

각 layer 가 *서로 다른 *실패 모드 를 막는다*.

  • L1 — 발행자의 *중복 발행 (재시도 / 네트워크 timeout)
  • L2 — 수신자의 *중복 처리 (Consumer rebalance / replay)
  • L3 — 비즈니스 키 *오염 (수동 보정 / 마이그레이션 실수 / 다른 service 의 잘못된 호출)

하나가 뚫려도 *다음 layer 가 막음. 과잉 엔지니어링 아님각자의 root cause 가 다름.


4. CDC (Change Data Capture) — *Polling 의 대안**

4.1 CDC 의 *원리**

[Source DB — PostgreSQL]
    ↓ WAL (Write-Ahead Log)
[Debezium Connector]
    ↓ logical replication
[Kafka Topic — db_changes.public.orders]
    ↓
[Consumer]

DB 의 *변경 로그 (WAL / binlog) 를 *직접 읽음. 애플리케이션 코드 변경 X.

4.2 Outbox + CDC 조합 (★ 권장)

[Producer 서비스]
  @Transactional
    DB 변경
    outbox_events INSERT
       ↓ commit
[PostgreSQL WAL]
       ↓
[Debezium]   ← *poller 대신* WAL 직접 읽음
       ↓
[Kafka Topic]

장점:

  • Poller 없어도 됨 (DB → Debezium 직접)
  • 수 ms latency (poll 2초 vs WAL 즉시)
  • DB 부하 적음 (poll 의 주기 SELECT 없음)

단점:

  • Debezium 운영 부담 (Kafka Connect cluster)
  • 운영 복잡도 증가

4.3 Polling vs CDC 선택

조건 권장
단순 시스템, 팀 작음 Polling (Outbox + Scheduled)
대용량, latency 민감 CDC (Debezium)
운영 인력 적음 Polling
Kafka Connect 운영 가능 CDC
MSA 10+ 서비스 CDC (재사용)

5. 실전 Postmortem 4 건

Case 1 — Outbox 도입 전의 *Dual Write 사고

증상: 주문 1만 건 / 일3-5 건Kafka 누락후속 시스템 동기화 실패.

원인:

@Transactional
public void createOrder(...) {
    orderRepo.save(order);            // DB 성공
    kafkaTemplate.send(...);          // ★ 가끔 실패 (broker timeout)
    // → DB 에 주문 있는데, Kafka 에 없음
}

해결: Transactional Outbox 도입. 이후 *6 개월 *누락 0.

Case 2 — Poller 중복 실행 — *분산 환경**

증상: 같은 이벤트가 Kafka 에 *2 번 발행.

원인: Poller 가 *2 인스턴스 *동시 실행. PENDING 이벤트를 *둘 다 가져옴.

해결: ShedLock 으로 *분산 단일 실행 보장*.

@Scheduled(fixedDelay = 2000)
@SchedulerLock(name = "outbox-poller", lockAtLeastFor = "PT2S")
public void publishPendingEvents() { ... }

또는 DB SELECT FOR UPDATE SKIP LOCKED 패턴:

SELECT * FROM outbox_events 
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;  -- ★ 다른 트랜잭션이 잠근 row 는 *건너 뜀*

Case 3 — Consumer 중복 처리 — *Rebalance**

증상: Kafka Consumer rebalance 후 같은 이벤트 2 번 처리. settlement *중복 row.

원인: processed_events 검사 누락 / commit 타이밍 문제.

해결:

  1. processed_events PK 검사 추가
  2. L3 (settlements.payment_id UNIQUE) 제약 추가
  3. Triple Idempotency Stack 완성

Case 4 — Outbox 테이블 *수억 건 적체DB 성능 저하**

증상: 6 개월 후 outbox_events 테이블 수억 건. INSERT 성능 저하.

원인: PUBLISHED 이벤트 정리 X. 무한 증가.

해결:

-- *7 일 지난 *PUBLISHED 이벤트 정리*
DELETE FROM outbox_events 
WHERE status = 'PUBLISHED' 
  AND published_at < NOW() - INTERVAL '7 days';
@Scheduled(cron = "0 0 3 * * *")   // 매일 새벽 3시
public void cleanupOldEvents() {
    outboxRepository.deleteOldPublishedEvents(Instant.now().minus(7, ChronoUnit.DAYS));
}

6. 고급 — *Saga 패턴**

MSA 의 *여러 서비스에 걸친 트랜잭션Outbox 만으로 부족. Saga 패턴분산 트랜잭션 의 *현실적 해법.

6.1 Choreography Saga — *이벤트 기반**

[Order Service]
  OrderCreated 이벤트 발행 (Outbox)
       ↓
[Payment Service]
  PaymentRequested → Payment 처리
  PaymentCaptured / PaymentFailed 발행
       ↓
[Inventory Service]
  StockReserved / StockReservationFailed
       ↓
[Order Service]
  OrderConfirmed / OrderCancelled (보상)

6.2 Orchestration Saga — *코디네이터**

[Order Saga Orchestrator]
  1. Payment Service 호출
     ↓ 실패 시 ↓
  2. Inventory Service 호출
     ↓ 실패 시 ↓
  3. Shipping Service 호출
     ↓ 실패 시 ↓
  4. 완료
  
  각 단계 실패 시 *역방향 보상 (compensation)*
항목 Choreography Orchestration
결합도 낮음 (이벤트만) 중 (orchestrator 의존)
디버깅 어려움 (분산) 쉬움 (중앙)
추가 인프라 없음 orchestrator 서비스
복잡한 흐름 어려움 쉬움

6.3 Outbox + Saga 조합

@Service
class OrderSagaOrchestrator(
    private val outbox: OutboxRepository
) {
    
    @Transactional
    fun startOrderSaga(order: Order) {
        // 1. Saga 상태 DB 저장
        val sagaState = SagaState(order.id, STARTED)
        sagaRepo.save(sagaState)
        
        // 2. 첫 단계 명령 — Outbox 로 발행
        outbox.save(OutboxEvent(
            eventType = "PaymentRequested",
            payload = toJson(PaymentCommand(order.id, order.amount))
        ))
    }
    
    @KafkaListener(topics = ["payment.captured"])
    @Transactional
    fun onPaymentCaptured(event: PaymentCaptured) {
        val saga = sagaRepo.findByOrderId(event.orderId)
        saga.advance(PAYMENT_COMPLETED)
        
        // 다음 단계 명령
        outbox.save(OutboxEvent(
            eventType = "StockReservationRequested",
            payload = toJson(StockReservationCommand(event.orderId))
        ))
    }
}

7. 모니터링 — *Outbox 의 *4 가지 메트릭**

// Micrometer 메트릭
Counter outboxPublished = meterRegistry.counter("outbox.published");
Counter outboxFailed = meterRegistry.counter("outbox.failed");
Gauge outboxPending = meterRegistry.gauge("outbox.pending", 
    Tags.empty(), outboxRepository, r -> r.countByStatus(PENDING));
Timer outboxPublishDuration = meterRegistry.timer("outbox.publish.duration");

Grafana 대시보드 query:

# 발행 성공률
rate(outbox_published_total[5m]) / 
  (rate(outbox_published_total[5m]) + rate(outbox_failed_total[5m]))

# PENDING 적체 (*이게 증가하면 위험 신호*)
outbox_pending

# 발행 평균 latency
histogram_quantile(0.95, rate(outbox_publish_duration_bucket[5m]))

알람:

- alert: OutboxPendingHigh
  expr: outbox_pending > 1000
  for: 5m
  annotations:
    summary: "Outbox PENDING 적체  Poller 정지 또는 Kafka 다운 의심"

8. 함정 6 가지 — *Postmortem 압축**

8.1 Outbox INSERT 누락

// ❌ — 비즈니스 로직 후 Outbox 안 함
@Transactional
public void capturePayment(...) {
    payment.capture();
    paymentRepo.save(payment);
    // outbox INSERT 빠짐 → 후속 시스템 알림 X
}

코드 리뷰 + 단위 테스트 *필수.

8.2 Outbox 와 *비즈니스 로직 *다른 트랜잭션**

// ❌ — 두 개 다른 트랜잭션
@Transactional
public void capturePayment(...) {
    payment.capture();
    paymentRepo.save(payment);
}

// 호출 분리
public void publishEvent(...) {
    outboxRepository.save(...);   // ★ 다른 tx — 원자성 깨짐
}

반드시 같은 메서드 + 같은 tx.

8.3 Poller 가 *너무 자주 실행 — *DB 부하**

@Scheduled(fixedDelay = 100)   // ❌ 100ms 마다 — DB 부하 폭주

2-5 초 권장. latency 가 *민감 하면 CDC (Debezium).

8.4 Outbox 발행 *순서 보장 누락

// ❌ — created_at 순서 미보장
outboxRepository.findByStatus(PENDING);  // 순서 없음

// ✅
outboxRepository.findByStatusOrderByCreatedAt(PENDING, ...);

이벤트 순서중요한 비즈니스 (정산, 거래) 에서 치명적.

8.5 Kafka 발행 후 *outbox 상태 갱신 실패**

// 시나리오:
// 1. Kafka send 성공
// 2. outbox.setStatus(PUBLISHED)
// 3. JVM crash
// → 다음 poll 에서 *재발행* (중복!)

Consumer 측 *processed_events 검사방어선. L2 멱등성의 *진짜 가치.

8.6 Schema 진화 — *오래된 이벤트 형식**

서비스 v1.0 — payload v1 발행
서비스 v2.0 — payload v2 로 변경
        ↓
Consumer v1.0 — v2 payload 파싱 실패 ★

event_version 필드 명시. backward compatible 변경만 허용. breaking change 시 *새 topic.


9. 마무리 — *Outbox 의 *진짜 의미**

9.1 Outbox 는 *MSA 의 *기본 인프라**

Outbox 패턴 없이 *DB + Kafka 같이 쓰는 MSA = *반드시 사고 *언젠가 발생. Outbox 가 *MSA 의 *교통 신호등없으면 *교차로 *충돌 *불가피.

9.2 Triple Idempotency 의 *각자의 책임**

L1 / L2 / L3 가 *서로 다른 *실패 모드 를 담당. *각자가 *독립적인 보안 layer과잉 아닌 *정확한 분업. settlement 시스템 의 *실 운영 6 개월 0 사고진짜 비결.

9.3 CDC 의 *2026 년 위치**

Debezium / MaxwellCDC 가 *대규모 MSA 표준 으로 자리잡음. 애플리케이션 코드 변경 없이 *DB 변경을 *Kafka 로 자동 발행. Outbox poller 의 *DB 부하 부담 회피. 팀 규모 + 운영 역량 에 따라 선택.

9.4 이력서 변환 hook

“분산 트랜잭션 / Outbox / 비동기 연동 경험” 한 줄에:

  • Dual Write Problem 의 3 가지 깨지는 시나리오
  • Transactional Outbox 의 원자성 보장 메커니즘
  • Polling vs CDC (Debezium) 의 trade-off
  • Triple Idempotency Stack (L1/L2/L3) 의 각자의 책임
  • Saga (Choreography vs Orchestration) 의 현실적 선택
  • 실전 postmortem 4 건 의 진단 + 해결
  • 함정 6 가지 Postmortem

4 단 깊이 면접 답변 모두 준비.


부록 — Spring Boot + Outbox *최소 셋업**

// 1. 의존성
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.kafka:spring-kafka")
    implementation("net.javacrumbs.shedlock:shedlock-spring:6.0.0")
}

// 2. OutboxEvent 엔티티
@Entity
@Table(name = "outbox_events")
data class OutboxEvent(
    @Id @GeneratedValue val id: UUID = UUID.randomUUID(),
    @Column(unique = true) val eventId: String = UUID.randomUUID().toString(),
    val aggregateType: String,
    val aggregateId: String,
    val eventType: String,
    @Column(columnDefinition = "jsonb") val payload: String,
    @Enumerated(EnumType.STRING) var status: Status = PENDING,
    val createdAt: Instant = Instant.now(),
    var publishedAt: Instant? = null,
)

// 3. Poller
@Component
class OutboxPoller(
    private val outboxRepo: OutboxEventRepository,
    private val kafkaTemplate: KafkaTemplate<String, String>,
) {
    @Scheduled(fixedDelay = 2000)
    @SchedulerLock(name = "outbox-poller", lockAtLeastFor = "PT2S")
    @Transactional
    fun publish() {
        outboxRepo.findByStatusOrderByCreatedAt(PENDING, PageRequest.of(0, 100))
            .forEach { event ->
                runCatching {
                    kafkaTemplate.send(event.eventType, event.aggregateId, event.payload)
                        .get(5, TimeUnit.SECONDS)
                    event.status = PUBLISHED
                    event.publishedAt = Instant.now()
                }.onFailure {
                    log.error("Failed to publish ${event.eventId}", it)
                }
            }
    }
}

다음 글: Saga 패턴의 *실전 구현 — *Choreography vs Orchestration각자의 *디버깅 / 운영 / 보상 트랜잭션실제 코드 비교.