Published on

Spring Boot MSA Outbox 구현과 CDC 장애 대응

Authors

서로 다른 마이크로서비스가 각자의 DB를 소유하는 MSA에서, 한 서비스의 상태 변경을 다른 서비스로 전파하는 일은 생각보다 어렵습니다. HTTP 호출로 동기 연동을 걸면 장애 전파가 쉬워지고, DB 트랜잭션과 메시지 발행을 2PC로 묶으려 하면 복잡도와 운영 비용이 급격히 상승합니다.

Outbox 패턴은 이 딜레마를 현실적으로 해결합니다. 핵심은 “도메인 변경과 이벤트 기록을 같은 로컬 트랜잭션으로 묶고”, 이후 별도 프로세스가 Outbox를 읽어 브로커로 전달하는 것입니다. 그리고 CDC(Change Data Capture)는 이 Outbox 테이블을 읽어 Kafka 같은 스트림으로 흘려보내는 대표적인 구현 방식입니다.

이 글에서는 Spring Boot에서 Outbox를 어떻게 설계·구현하는지, 그리고 CDC가 멈추거나 재시작되는 장애 상황에서 어떤 실패 모드를 고려해야 하는지(유실, 중복, 순서, 지연)와 대응 체크리스트를 다룹니다.

왜 Outbox인가: 실패 모드부터 정리

1) DB 커밋은 됐는데 이벤트 발행이 실패

가장 흔한 케이스입니다. 주문이 생성되어 DB에는 존재하지만, 결제/배송 서비스는 이벤트를 못 받아 후속 처리가 멈춥니다.

2) 이벤트는 발행됐는데 DB 커밋이 롤백

반대로 이벤트가 먼저 나가버리면, 다운스트림이 존재하지 않는 주문을 처리하려고 하면서 보정 로직이 늘어납니다.

3) 재시도 과정에서 중복 발행

네트워크 타임아웃, 브로커 ACK 지연 등으로 “성공했는데 실패로 오인”하는 순간 중복 이벤트가 발생합니다.

Outbox는 1)과 2)를 구조적으로 제거합니다. 도메인 변경과 Outbox insert를 같은 트랜잭션으로 처리하면, 커밋된 변경은 반드시 Outbox에도 남습니다. 이후 발행은 재시도해도 되고, CDC가 멈춰도 Outbox에 이벤트가 쌓일 뿐 유실되지는 않습니다.

Outbox 테이블 설계: 스키마가 운영성을 좌우한다

Outbox는 단순히 JSON을 저장하는 테이블이 아닙니다. 장애 대응과 리플레이, 중복 제거, 관측 가능성을 위해 최소 필드가 필요합니다.

권장 스키마(예시)

  • id: 전역 유니크 ID(ULID/UUID)
  • aggregate_type: 예) Order
  • aggregate_id: 예) 주문 ID
  • event_type: 예) OrderCreated
  • payload: JSON 문자열
  • occurred_at: 도메인 이벤트 발생 시각
  • trace_id / correlation_id: 분산 추적용
  • schema_version: 페이로드 버전
  • published_at: 발행 완료 시각(폴링 기반일 때 유용)

CDC 기반(Debezium)이라면 published_at 없이도 가능하지만, 운영 중 “어디까지 발행됐는지”를 SQL로 빠르게 확인하려면 상태 컬럼이 큰 도움이 됩니다. 다만 CDC와 상태 업데이트를 섞으면 업데이트 트래픽이 늘고, CDC가 다시 그 업데이트를 이벤트로 읽는 문제(루프)를 만들 수 있으니 테이블 분리 또는 조건 필터링이 필요합니다.

Spring Boot에서 Outbox 구현: 트랜잭션 경계가 핵심

아래는 JPA를 사용해 주문 생성과 Outbox 기록을 한 트랜잭션에 묶는 예시입니다.

1) Entity 정의

@Entity
@Table(name = "outbox_event")
public class OutboxEvent {

    @Id
    private String id; // ULID/UUID

    @Column(name = "aggregate_type", nullable = false)
    private String aggregateType;

    @Column(name = "aggregate_id", nullable = false)
    private String aggregateId;

    @Column(name = "event_type", nullable = false)
    private String eventType;

    @Lob
    @Column(name = "payload", nullable = false)
    private String payload;

    @Column(name = "occurred_at", nullable = false)
    private Instant occurredAt;

    @Column(name = "trace_id")
    private String traceId;

    @Column(name = "schema_version", nullable = false)
    private int schemaVersion;

    protected OutboxEvent() {}

    public OutboxEvent(
            String id,
            String aggregateType,
            String aggregateId,
            String eventType,
            String payload,
            Instant occurredAt,
            String traceId,
            int schemaVersion
    ) {
        this.id = id;
        this.aggregateType = aggregateType;
        this.aggregateId = aggregateId;
        this.eventType = eventType;
        this.payload = payload;
        this.occurredAt = occurredAt;
        this.traceId = traceId;
        this.schemaVersion = schemaVersion;
    }

    // getters
}

2) 도메인 서비스에서 함께 저장

@Service
public class OrderCommandService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxEventRepository;
    private final ObjectMapper objectMapper;

    public OrderCommandService(
            OrderRepository orderRepository,
            OutboxEventRepository outboxEventRepository,
            ObjectMapper objectMapper
    ) {
        this.orderRepository = orderRepository;
        this.outboxEventRepository = outboxEventRepository;
        this.objectMapper = objectMapper;
    }

    @Transactional
    public String createOrder(CreateOrderRequest req, String traceId) {
        Order order = Order.create(req);
        orderRepository.save(order);

        OrderCreatedEvent event = new OrderCreatedEvent(order.getId(), order.getTotalAmount());

        String payload;
        try {
            payload = objectMapper.writeValueAsString(event);
        } catch (JsonProcessingException e) {
            // 직렬화 실패는 이벤트 유실로 이어지므로 트랜잭션을 실패시키는 편이 낫다
            throw new IllegalStateException("failed to serialize event", e);
        }

        OutboxEvent outbox = new OutboxEvent(
                UlidCreator.getUlid().toString(),
                "Order",
                order.getId(),
                "OrderCreated",
                payload,
                Instant.now(),
                traceId,
                1
        );

        outboxEventRepository.save(outbox);
        return order.getId();
    }
}

여기서 중요한 점은 “Outbox insert가 실패하면 주문 생성도 실패”해야 한다는 것입니다. 그래야 정합성 모델이 단순해집니다.

발행 방식 2가지: 폴링 vs CDC

1) 폴링 퍼블리셔(애플리케이션이 직접 발행)

애플리케이션이 주기적으로 Outbox를 읽고 Kafka로 발행한 뒤 published_at을 업데이트합니다.

  • 장점: 구조가 단순하고 디버깅이 쉽습니다.
  • 단점: 배치 락, 동시성 제어, 재시도/중복 제거를 직접 구현해야 합니다.

폴링을 쓴다면 경쟁 조건을 피하기 위해 SELECT ... FOR UPDATE SKIP LOCKED 같은 패턴을 고려합니다. 단, DB 종류에 따라 지원 여부가 다릅니다.

2) CDC 기반(Debezium 등)

Debezium이 DB의 binlog를 읽어 Outbox insert를 Kafka 이벤트로 변환합니다.

  • 장점: 애플리케이션이 브로커 발행 책임에서 벗어나고, 처리량이 좋습니다.
  • 단점: CDC 커넥터 장애, 오프셋/스냅샷, 스키마 변경 등 운영 포인트가 늘어납니다.

이번 글의 초점은 2)에서 “CDC가 죽었을 때 어떻게 망가지고 어떻게 복구할지”입니다.

CDC 장애 시나리오와 대응: 유실보다 무서운 건 ‘조용한 정지’

CDC 장애는 대개 다음 4가지로 나타납니다.

1) 커넥터 프로세스 다운(즉시 탐지 가능)

  • 증상: 컨슈머 지연이 급증, 신규 이벤트가 안 들어옴
  • 대응: 프로세스/파드 재시작, 커넥터 상태 확인

Kubernetes 환경이라면 CrashLoopBackOff로 반복 재시작되는지 먼저 봐야 합니다. 관련해서는 Kubernetes CrashLoopBackOff와 OOMKilled(ExitCode 137) 해결 같은 체크리스트가 그대로 도움이 됩니다.

2) 커넥터는 살아있는데 이벤트가 안 나옴(조용한 정지)

가장 위험합니다.

  • DB 권한 변경, binlog 설정 변경
  • 네트워크 단절로 인해 내부적으로 재시도만 하는 상태
  • Kafka 브로커 문제로 produce가 막힌 상태

이 경우 “프로세스가 살아있다”는 신호만으로는 부족합니다. 반드시 아래를 메트릭으로 봐야 합니다.

  • Outbox 테이블 적재량 증가 속도
  • Kafka 토픽의 최신 오프셋 증가 속도
  • Debezium 커넥터의 source lag

3) 재시작 후 중복 이벤트 증가(적어도 한 번 전달)

Debezium은 기본적으로 at-least-once 성격이므로, 장애 복구 후 중복 이벤트가 발생할 수 있습니다.

  • 대응 원칙: 다운스트림은 반드시 멱등(idempotent) 처리

가장 쉬운 멱등 키는 outbox_event.id 입니다. 컨슈머가 처리한 이벤트 ID를 별도 테이블에 기록하거나, 도메인 테이블에 “마지막 처리 이벤트 ID”를 저장해 중복을 걸러냅니다.

4) 오프셋 손상 또는 스냅샷 재수행(대량 리플레이)

커넥터 설정 오류나 오프셋 스토리지 장애로 스냅샷이 다시 돌면 과거 이벤트가 대량으로 재발행될 수 있습니다.

  • 대응: 이벤트에 occurred_atschema_version을 넣고, 컨슈머에서 허용 가능한 윈도우/버전을 검증
  • 운영: 커넥터 오프셋 백업과 변경 이력 관리

컨슈머 멱등 처리: “중복을 정상”으로 만드는 설계

다운스트림 서비스(예: 배송 서비스)가 OrderCreated를 받는다고 가정합니다.

처리 결과 테이블로 멱등 보장

CREATE TABLE inbox_processed_event (
  event_id VARCHAR(64) PRIMARY KEY,
  processed_at TIMESTAMP NOT NULL
);
@Service
public class ShippingEventHandler {

    private final ProcessedEventRepository processedEventRepository;
    private final ShipmentRepository shipmentRepository;

    public ShippingEventHandler(
            ProcessedEventRepository processedEventRepository,
            ShipmentRepository shipmentRepository
    ) {
        this.processedEventRepository = processedEventRepository;
        this.shipmentRepository = shipmentRepository;
    }

    @Transactional
    public void onOrderCreated(String eventId, OrderCreatedEvent event) {
        if (processedEventRepository.existsById(eventId)) {
            return;
        }

        shipmentRepository.save(Shipment.createFor(event.orderId()));
        processedEventRepository.save(new ProcessedEvent(eventId, Instant.now()));
    }
}

이 패턴은 단순하지만 효과가 큽니다. “중복이 와도 아무 일도 일어나지 않음”을 보장하면, CDC 재시작/리플레이가 더 이상 장애가 아니라 정상 동작이 됩니다.

Outbox 테이블 운영: 인덱스, 보관, 청소 전략

CDC를 쓰면 Outbox는 계속 쌓입니다. 무한정 쌓이면 인덱스/스토리지/백업 비용이 증가합니다.

인덱스

  • occurred_at 인덱스: 기간별 청소/조회
  • aggregate_id 인덱스: 특정 주문의 이벤트 추적
  • event_type 인덱스: 장애 시 필터링

보관/청소

  • 보관 기간을 정합니다(예: 7일 또는 30일).
  • 청소는 배치로 DELETE 하되, 대량 삭제는 락과 부하를 유발할 수 있어 청크 단위로 나눕니다.
DELETE FROM outbox_event
WHERE occurred_at < NOW() - INTERVAL 30 DAY
LIMIT 10000;

MySQL을 쓴다면 대량 삭제/인덱스 경합이 데드락이나 락 대기 증가로 이어질 수 있습니다. 이때는 인덱스 설계와 트랜잭션 크기, 배치 주기를 같이 봐야 하며, 데드락 분석은 MySQL InnoDB 데드락 원인 추적 - SHOW ENGINE부터 인덱스까지에서 소개한 방식으로 접근하면 빠릅니다.

CDC 장애 대응 체크리스트(운영 관점)

1) 탐지: “커넥터 생존”이 아니라 “흐름”을 본다

  • Outbox 적재량이 증가하는데 Kafka 토픽 오프셋이 증가하지 않으면 CDC 정지
  • 컨슈머 lag만 보면, 상류에서 멈춘 건지 하류가 느린 건지 구분이 안 됩니다

2) 복구: 재시작 전에 리플레이 영향 평가

  • 재시작 시 중복 이벤트가 나올 수 있음을 전제로 공지/대응
  • 다운스트림 멱등이 없다면, 먼저 멱등부터 보강

3) 리소스: 파일 디스크립터/네트워크 소켓 고갈

CDC 커넥터나 Kafka Connect가 Too many open files로 불안정해지는 경우가 있습니다. 시스템 레벨 튜닝은 리눅스 Too many open files 해결 - ulimit·systemd·Nginx 같은 가이드를 참고해 상한과 서비스 유닛 설정을 함께 점검하세요.

4) 장애 후 검증: “유실 없음”을 SQL로 확인

  • 특정 시간 구간의 Outbox row 수와 Kafka 토픽의 증가량을 대조
  • 샘플링으로 특정 aggregate_id의 이벤트가 모두 흘러갔는지 확인

이벤트 스키마 버저닝: 장기 운영에서 반드시 부딪힌다

Outbox payload는 시간이 지나면 바뀝니다.

  • 필드 추가: 하위 호환 가능
  • 필드 이름 변경/삭제: 하위 호환 깨짐

따라서 schema_version을 넣고, 컨슈머는 버전에 따라 파서를 분기하거나, 최소한 “모르는 버전은 DLQ로 보내고 알람”이 필요합니다.

Kafka를 쓴다면 DLQ 토픽을 별도로 두고, 역직렬화 실패나 비즈니스 검증 실패를 분리 수집하세요.

정리: Outbox는 구현보다 ‘장애 모델링’이 본질

  • Outbox 패턴은 DB 변경과 이벤트 기록을 한 트랜잭션으로 묶어 유실을 막습니다.
  • CDC는 발행을 외부화해 처리량과 단순성을 얻지만, 커넥터 장애·오프셋·리플레이 같은 운영 리스크가 생깁니다.
  • CDC 장애를 진짜 장애로 만들지 않으려면 다운스트림 멱등이 필수입니다. outbox_event.id 기반의 처리 이력 테이블은 가장 실용적인 해법입니다.
  • Outbox 테이블은 인덱스/청소/보관 정책까지 포함해 설계해야 하며, 대량 삭제나 락 경합은 DB 장애로 이어질 수 있습니다.

MSA에서 “정합성 있는 비동기 이벤트”는 기능 구현보다 운영 내구성이 성패를 가릅니다. Outbox와 CDC를 도입할 때는 정상 흐름뿐 아니라, 멈춤·재시작·중복·리플레이가 모두 ‘예상된 동작’이 되도록 설계를 마무리하는 것이 핵심입니다.