
목차
- 개요
- Transactional Outbox 패턴의 핵심 원리
- Spring Boot + JPA로 구현하기
- CDC 기반 Outbox 심화 — Debezium 연동
- 성능 트레이드오프와 대안 기술 비교
- 운영 환경 적용 시 고려사항
- 맺음말
개요
문제 배경
분산 시스템에서 데이터베이스 트랜잭션과 메시지 브로커 발행을 동시에 보장하는 일은 생각보다 훨씬 까다로운 문제입니다. 주문 서비스가 주문을 저장한 뒤 Kafka에 OrderCreated 이벤트를 발행한다고 가정할 때, DB 커밋은 성공했지만 Kafka 발행 직전에 애플리케이션이 다운된다면 해당 이벤트는 영원히 유실됩니다. Transactional Outbox 패턴은 이 문제를 데이터베이스의 트랜잭션 원자성을 활용해 해결하는 아키텍처 패턴으로, 현업에서 가장 널리 검증된 방식 중 하나입니다. 이 글에서는 패턴의 동작 원리부터 Spring Boot 기반 구현, Debezium을 활용한 CDC(Change Data Capture) 연동, 그리고 실제 운영 환경에서 마주치는 함정까지 깊이 있게 다룹니다.
기존 방식의 한계
이중 쓰기(Dual Write) 문제는 분산 시스템 설계에서 가장 빈번하게 마주치는 난제 중 하나입니다. 개발자들이 흔히 시도하는 첫 번째 접근법은 "DB 저장 → 이벤트 발행" 순서로 코드를 작성하는 것입니다. 이 방식은 표면적으로 단순해 보이지만, 두 작업 사이에 장애가 발생하면 데이터 불일치가 발생합니다. 반대로 "이벤트 발행 → DB 저장" 순서를 택한다고 해서 문제가 해결되는 것도 아닙니다. Kafka에는 메시지가 발행됐는데 DB 저장이 실패하면, 이번에는 반대 방향의 불일치가 생깁니다.
두 번째로 시도되는 방식은 분산 트랜잭션, 즉 2PC(Two-Phase Commit)입니다. 데이터베이스와 메시지 브로커를 하나의 트랜잭션 경계 안에 묶는 방법인데, 이론적으로는 원자성이 보장되지만 현실에서는 다음과 같은 문제가 있습니다. 우선 Kafka를 포함한 대부분의 모던 메시지 브로커는 XA 트랜잭션을 제대로 지원하지 않거나, 지원하더라도 심각한 성능 저하를 동반합니다. 또한 2PC 자체가 코디네이터 단일 장애점(SPOF)을 만들어 내며, 전체 시스템의 가용성을 낮추는 경향이 있습니다. 넷플릭스, 우버, 에어비앤비 등 대규모 분산 시스템을 운용하는 기업들이 2PC를 피하고 결과적 일관성(Eventual Consistency)을 채택한 데는 이런 이유가 있습니다.
세 번째 시도는 @TransactionalEventListener를 활용하는 방식입니다. Spring의 AFTER_COMMIT 단계에서 이벤트를 발행하면 DB 커밋 이후에 메시지가 나가므로 순서는 맞지만, 이 시점에도 애플리케이션이 죽으면 메시지는 유실됩니다. 재시도 로직을 추가한다 해도 상태를 영속적으로 저장하지 않으면 재시작 후 복구가 불가능합니다. Transactional Outbox 패턴은 이 세 가지 방식의 한계를 모두 극복하면서도 구현 복잡도를 합리적인 수준으로 유지하는 방법을 제시합니다.
Transactional Outbox 패턴의 핵심 원리
동작 원리
Transactional Outbox 패턴의 핵심 아이디어는 단순하면서도 강력합니다. 메시지 브로커에 직접 이벤트를 발행하는 대신, 동일한 데이터베이스 트랜잭션 내에 outbox 테이블에 이벤트 레코드를 함께 저장합니다. 이렇게 하면 비즈니스 데이터 변경과 이벤트 기록이 단일 ACID 트랜잭션으로 묶이므로, 둘 중 하나만 저장되는 불일치 상황은 원천적으로 발생하지 않습니다. 그 다음 별도의 메시지 릴레이(Message Relay) 프로세스가 outbox 테이블을 폴링하거나 변경 사항을 캡처해서 실제 메시지 브로커로 전달하고, 전달 완료 후 해당 레코드를 삭제하거나 처리 완료 상태로 마킹합니다.
이 패턴이 "at-least-once" 전달을 보장하는 이유는 릴레이가 발행에 성공하기 전까지는 절대 레코드를 삭제하지 않기 때문입니다. 릴레이 프로세스가 메시지를 브로커에 보냈지만 완료 응답을 받기 전에 장애가 발생하더라도, 재시작 후 동일 레코드를 다시 발행합니다. 따라서 중복 메시지 수신 가능성은 있고, 소비자 측에서 멱등성(Idempotency)을 보장해야 한다는 전제가 따릅니다. 이 트레이드오프는 패턴을 도입하기 전에 팀 내에서 명확히 공유되어야 합니다.
주요 구성 요소
패턴을 구성하는 요소는 크게 세 가지입니다. 첫째는 Outbox 테이블로, 발행할 이벤트의 페이로드, 대상 토픽, 집합체 타입, 이벤트 ID, 생성 시각, 처리 상태 등을 저장하는 영속 저장소입니다. 이벤트 ID는 UUID로 생성해 소비자 측 멱등성 처리에 활용됩니다. 둘째는 비즈니스 로직 레이어로, 도메인 객체 저장과 Outbox 레코드 삽입을 동일 트랜잭션으로 묶는 책임을 집니다. Spring @Transactional과 JPA를 사용하면 자연스럽게 이 구조를 구현할 수 있습니다. 셋째는 메시지 릴레이로, 미처리 Outbox 레코드를 주기적으로 조회하거나 DB 변경 로그를 실시간으로 읽어서 브로커에 발행하는 독립 컴포넌트입니다. 릴레이는 애플리케이션 내부의 스케줄러로 구현할 수도 있고, Debezium 같은 외부 CDC 도구로 분리할 수도 있습니다.
데이터 흐름
요청이 들어오면 다음 순서로 처리됩니다. ① HTTP 요청 또는 내부 커맨드가 서비스 레이어에 도달합니다. ② 서비스 레이어는 하나의 트랜잭션 안에서 도메인 엔티티를 저장하고, 동시에 outbox 테이블에 이벤트 레코드를 삽입합니다. ③ 트랜잭션이 성공적으로 커밋됩니다. ④ 메시지 릴레이가 status = 'PENDING'인 Outbox 레코드를 읽어 Kafka 등의 브로커로 발행합니다. ⑤ 발행 성공 후 해당 레코드를 PROCESSED 상태로 업데이트하거나 삭제합니다. 이 흐름에서 ③번 커밋이 성공한 이상, 언젠가는 ④-⑤가 완료된다는 보장이 생깁니다. 릴레이가 일시적으로 다운되더라도 Outbox 레코드는 DB에 안전하게 보관되어 있으므로 재시작 후 이어서 처리가 가능합니다.
Spring Boot + JPA로 구현하기
기본 설정
Outbox 패턴 구현에 필요한 의존성은 대부분의 Spring Boot 프로젝트에 이미 포함되어 있습니다. spring-boot-starter-data-jpa, spring-kafka, 그리고 데이터베이스 드라이버가 전부입니다. 별도의 라이브러리 없이 순수하게 구현하는 방식을 먼저 살펴본 뒤, 이후 섹션에서 Debezium을 활용한 고도화 방법을 다룹니다. 먼저 outbox_event 테이블의 엔티티를 정의하고, 주문 생성 시나리오를 기반으로 전체 흐름을 구현해 보겠습니다.
OutboxEvent 엔티티는 이벤트를 영속적으로 저장하는 핵심 구조체입니다. 이벤트 ID는 발행 단계에서 Kafka 메시지 키로 활용되며, 소비자가 event_id 기반으로 중복 처리를 방지할 수 있도록 합니다. aggregate_type과 aggregate_id는 어떤 도메인 객체에서 발생한 이벤트인지 추적하는 데 사용됩니다.
// OutboxEvent.java — Outbox 이벤트 엔티티
@Entity
@Table(name = "outbox_event")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class OutboxEvent {
@Id
@Column(columnDefinition = "VARCHAR(36)")
private String id; // UUID — 소비자 멱등성 키
@Column(nullable = false)
private String aggregateType; // 예: "Order"
@Column(nullable = false)
private String aggregateId; // 예: "order-12345"
@Column(nullable = false)
private String eventType; // 예: "OrderCreated"
@Column(nullable = false, columnDefinition = "TEXT")
private String payload; // JSON 직렬화된 이벤트 본문
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OutboxStatus status; // PENDING | PROCESSED | FAILED
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private LocalDateTime processedAt;
public static OutboxEvent create(
String aggregateType, String aggregateId,
String eventType, String payload) {
OutboxEvent event = new OutboxEvent();
event.id = UUID.randomUUID().toString();
event.aggregateType = aggregateType;
event.aggregateId = aggregateId;
event.eventType = eventType;
event.payload = payload;
event.status = OutboxStatus.PENDING;
event.createdAt = LocalDateTime.now();
return event;
}
public void markProcessed() {
this.status = OutboxStatus.PROCESSED;
this.processedAt = LocalDateTime.now();
}
public void markFailed() {
this.status = OutboxStatus.FAILED;
}
}-- DDL
CREATE TABLE outbox_event (
id VARCHAR(36) PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP
);
CREATE INDEX idx_outbox_status_created ON outbox_event (status, created_at);status와 created_at의 복합 인덱스는 릴레이가 PENDING 레코드를 빠르게 조회하기 위한 핵심 최적화입니다. 이 인덱스 없이 테이블이 수십만 건 이상으로 커지면 폴링 쿼리가 풀 스캔으로 전락해 심각한 성능 저하를 유발할 수 있습니다.
핵심 구현
서비스 레이어에서 도메인 저장과 Outbox 삽입을 동일 트랜잭션으로 묶는 부분이 패턴의 핵심입니다. 아래 코드는 주문 생성 시 OrderCreated 이벤트를 Outbox 테이블에 함께 저장하는 예시입니다.
// OrderService.java — 도메인 저장 + Outbox 삽입을 단일 트랜잭션으로 처리
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxEventRepository outboxRepository;
private final ObjectMapper objectMapper;
public Order createOrder(CreateOrderCommand command) {
// 1. 도메인 엔티티 저장
Order order = Order.create(command.getUserId(), command.getItems());
orderRepository.save(order);
// 2. 동일 트랜잭션 내에 Outbox 레코드 삽입
String payload = serialize(new OrderCreatedEvent(
order.getId(), order.getUserId(),
order.getTotalAmount(), order.getCreatedAt()));
OutboxEvent outboxEvent = OutboxEvent.create(
"Order",
order.getId().toString(),
"OrderCreated",
payload);
outboxRepository.save(outboxEvent);
// 3. 단일 커밋 — 둘 다 성공하거나 둘 다 롤백됨
return order;
}
private String serialize(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new IllegalStateException("이벤트 직렬화 실패", e);
}
}
}
// OutboxRelayService.java — 스케줄러 기반 메시지 릴레이
@Service
@RequiredArgsConstructor
@Slf4j
public class OutboxRelayService {
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 1000) // 1초 간격 폴링
@Transactional
public void relay() {
List<OutboxEvent> pendingEvents =
outboxRepository.findTop100ByStatusOrderByCreatedAtAsc(
OutboxStatus.PENDING);
for (OutboxEvent event : pendingEvents) {
try {
kafkaTemplate.send(
resolveTopicName(event.getEventType()),
event.getId(), // 파티션 키 = eventId
event.getPayload()
).get(5, TimeUnit.SECONDS); // 동기 확인
event.markProcessed();
} catch (Exception e) {
log.error("Outbox 릴레이 실패 eventId={}", event.getId(), e);
event.markFailed();
}
}
}
private String resolveTopicName(String eventType) {
return switch (eventType) {
case "OrderCreated" -> "order.created";
case "OrderCancelled" -> "order.cancelled";
default -> throw new IllegalArgumentException(
"알 수 없는 이벤트 타입: " + eventType);
};
}
}[INFO ] 2026-03-25 10:00:01 - OutboxRelayService - relay() 시작, PENDING 건수=3
[INFO ] 2026-03-25 10:00:01 - 이벤트 발행 완료 eventId=f3a1..., topic=order.created
[INFO ] 2026-03-25 10:00:01 - 이벤트 발행 완료 eventId=9c2b..., topic=order.created
[INFO ] 2026-03-25 10:00:01 - 이벤트 발행 완료 eventId=7d4e..., topic=order.created
[INFO ] 2026-03-25 10:00:02 - relay() 완료, 처리 건수=3kafkaTemplate.send(...).get(5, TimeUnit.SECONDS)를 사용해 동기 방식으로 발행 결과를 확인하는 부분에 주목할 필요가 있습니다. 비동기 발행으로 구현할 경우 콜백 처리가 복잡해지고, 결과를 확인하기 전에 markProcessed()가 호출될 위험이 있습니다. 처리량이 중요하지 않은 릴레이에서는 동기 확인이 안전성 측면에서 유리합니다.
테스트 및 검증
Outbox 패턴의 신뢰성은 통합 테스트로 검증해야 합니다. 단위 테스트만으로는 트랜잭션 경계와 DB 커밋 동작을 검증하기 어렵습니다. Testcontainers를 활용해 실제 PostgreSQL과 Kafka 컨테이너를 띄우고, 애플리케이션이 비정상 종료되는 시나리오를 시뮬레이션하는 방식이 권장됩니다. 검증 포인트는 세 가지입니다. 첫째, 서비스 메서드 호출 후 DB에 Order와 OutboxEvent가 함께 저장됐는지 확인합니다. 둘째, 릴레이 실행 후 Kafka 토픽에 메시지가 발행됐는지 확인합니다. 셋째, Kafka 브로커 연결이 끊긴 상태에서 릴레이를 실행했을 때 Outbox 레코드가 FAILED 상태로 남아 있고 나중에 재처리될 수 있는지 확인합니다.
CDC 기반 Outbox 심화 — Debezium 연동
CDC를 선택하는 이유
앞서 구현한 스케줄러 기반 폴링 방식은 간단하지만 본질적인 한계가 있습니다. 폴링 주기 동안의 지연(Latency)이 발생하고, 폴링 쿼리 자체가 DB에 부하를 줍니다. 또한 애플리케이션 인스턴스가 여러 개로 수평 확장될 경우, 동일한 Outbox 레코드를 두 인스턴스가 동시에 처리하는 동시성 문제가 발생할 수 있습니다. 물론 SELECT ... FOR UPDATE SKIP LOCKED 쿼리로 이 문제를 완화할 수 있지만, 구현 복잡도가 높아집니다.
CDC(Change Data Capture) 기반 방식은 애플리케이션 레이어가 아닌 데이터베이스의 트랜잭션 로그(Write-Ahead Log, WAL)를 직접 읽어 변경 사항을 캡처합니다. Debezium은 PostgreSQL의 WAL, MySQL의 Binlog 등을 실시간으로 읽어 Kafka로 스트리밍하는 오픈소스 CDC 플랫폼입니다. 이 방식을 사용하면 애플리케이션 코드에서 릴레이 로직을 완전히 제거할 수 있고, DB 변경이 발생하는 즉시 이벤트가 흐르므로 레이턴시가 대폭 줄어듭니다.
Debezium 커넥터 설정
Debezium은 Kafka Connect 플러그인으로 동작합니다. 아래는 PostgreSQL Debezium 커넥터 설정 예시입니다. table.include.list를 public.outbox_event로 제한해서 다른 테이블의 변경 사항은 캡처하지 않도록 구성하는 것이 중요합니다.
// debezium-outbox-connector.json
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "app_user",
"database.password": "${file:/kafka/secrets/db.properties:password}",
"database.dbname": "commerce_db",
"database.server.name": "commerce",
"table.include.list": "public.outbox_event",
"plugin.name": "pgoutput",
"transforms": "outbox",
"transforms.outbox.type":
"io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.id": "id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.type": "event_type",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.route.topic.replacement":
"outbox.event.${routedByValue}",
"tombstones.on.delete": "false"
}
}# 커넥터 등록
$ curl -X POST http://kafka-connect:8083/connectors \
-H "Content-Type: application/json" \
-d @debezium-outbox-connector.json
# 등록 결과
{"name":"outbox-connector","config":{...},"tasks":[],"type":"source"}
# 커넥터 상태 확인
$ curl http://kafka-connect:8083/connectors/outbox-connector/status
{
"name": "outbox-connector",
"connector": {"state": "RUNNING", "worker_id": "kafka-connect:8083"},
"tasks": [{"id": 0, "state": "RUNNING", "worker_id": "kafka-connect:8083"}]
}Debezium의 EventRouter SMT(Single Message Transform)가 aggregate_type 값을 기반으로 자동으로 토픽을 라우팅하므로, outbox_event 테이블에 aggregate_type = "Order"인 레코드가 삽입되면 outbox.event.Order 토픽으로 자동 발행됩니다. 애플리케이션 코드에서 토픽 이름을 하드코딩하거나 라우팅 로직을 관리할 필요가 없어지므로 유지보수성이 크게 향상됩니다. 다만 이 방식을 사용하면 Debezium과 Kafka Connect 인프라가 추가로 필요하므로, 팀의 운영 역량과 인프라 비용을 함께 고려해야 합니다.
아키텍처 비교
폴링 방식과 CDC 방식은 각각의 적합한 상황이 있습니다. 폴링 방식은 인프라 추가 없이 빠르게 도입할 수 있고, 소규모 서비스에 적합합니다. 반면 CDC 방식은 초기 설정 비용이 높지만 레이턴시가 낮고 DB 부하가 적으며, 여러 서비스에 걸쳐 일관된 이벤트 스트리밍 파이프라인을 구성할 때 강점이 있습니다. 특히 MSA 환경에서 도메인 이벤트 스트리밍을 표준화하려는 경우, Debezium + Kafka의 조합은 매우 강력한 선택지입니다.
성능 트레이드오프와 대안 기술 비교
성능 특성
Transactional Outbox 패턴은 도입 비용이 없지 않습니다. 비즈니스 트랜잭션마다 outbox_event 테이블에 INSERT가 추가되므로, 쓰기 처리량이 대략 2배가 됩니다. 단일 트랜잭션 내에서 두 개의 INSERT가 발생하기 때문에 트랜잭션 시간이 다소 길어지고, 이는 커넥션 풀 점유 시간 증가로 이어질 수 있습니다. 실제 프로젝트에서 측정해 보면, 초당 수천 건의 주문이 발생하는 서비스에서 outbox_event 테이블은 빠르게 수십만 건 이상으로 증가합니다. 따라서 처리된 레코드를 주기적으로 아카이빙하거나 삭제하는 정리 작업이 필수입니다. 처리 완료 후 일정 기간(예: 7일) 동안은 감사 목적으로 보관하다가 배치로 삭제하는 방식이 일반적입니다.
폴링 방식의 경우, 폴링 주기와 배치 크기의 조합이 레이턴시와 DB 부하 사이의 균형을 결정합니다. 배치 크기를 100으로 설정하고 1초 간격으로 폴링할 경우, 최악의 경우 약 1초의 이벤트 지연이 발생합니다. 이것이 허용 가능한지는 비즈니스 요구사항에 따라 다릅니다. 결제 완료 후 재고 차감 이벤트처럼 레이턴시에 민감한 흐름이라면 CDC 방식이 더 적합합니다.
대안 기술과 비교
Saga 패턴은 Transactional Outbox와 함께 언급되는 경우가 많지만, 두 패턴은 해결하는 문제의 레벨이 다릅니다. Saga는 여러 서비스에 걸친 긴 비즈니스 트랜잭션을 보상 트랜잭션(Compensating Transaction)으로 관리하는 패턴이며, Transactional Outbox는 단일 서비스 내에서 DB 저장과 이벤트 발행의 원자성을 보장하는 패턴입니다. 실제 프로젝트에서는 두 패턴을 함께 사용하는 경우가 많습니다. Saga 오케스트레이터가 명령을 보낼 때 Outbox 패턴을 사용해 메시지 발행의 신뢰성을 보장하는 방식입니다.
Kafka Streams나 Event Sourcing과의 비교도 중요합니다. Event Sourcing은 도메인 상태 자체를 이벤트 스트림으로 저장하므로, 별도의 Outbox 테이블 없이 이벤트 저장소가 곧 진실의 원천(Source of Truth)이 됩니다. 개념적으로 더 일관성 있는 모델이지만, 진입 장벽이 높고 CQRS 아키텍처와 함께 도입해야 하므로 기존 시스템을 전환하기가 상당히 어렵습니다. Transactional Outbox는 기존 관계형 DB 기반 시스템에 점진적으로 도입할 수 있다는 점에서 현실적인 선택지입니다.
어떤 상황에서 선택할 것인가
다음 기준을 충족하는 시스템이라면 Transactional Outbox 패턴을 적극적으로 검토할 만합니다. ① 이벤트 유실이 비즈니스에 직접적인 손실을 초래하는 경우(결제, 재고, 배송 등). ② 현재 "DB 저장 → 이벤트 발행" 코드로 운영 중인데 간헐적 이벤트 유실 이슈가 발생하는 경우. ③ 이미 Kafka나 RabbitMQ 등의 메시지 브로커를 사용하고 있어 인프라 전환 비용이 낮은 경우. 반면 이벤트 발행이 best-effort(발행 실패해도 무방)한 시스템이거나, 데이터 흐름 자체가 단순한 내부 서비스라면 굳이 이 패턴의 복잡도를 감수할 필요는 없습니다.
운영 환경 적용 시 고려사항
흔한 실수와 함정
Outbox 패턴을 처음 도입할 때 가장 자주 발생하는 실수는 릴레이의 동시성 문제를 과소평가하는 것입니다. 애플리케이션을 수평 확장하면 여러 인스턴스가 동시에 릴레이를 실행하게 됩니다. 동일한 Outbox 레코드를 두 인스턴스가 동시에 읽어 중복 발행하는 상황이 발생할 수 있습니다. 이를 방지하는 가장 확실한 방법은 SELECT ... FOR UPDATE SKIP LOCKED 쿼리를 사용하는 것입니다. Spring Data JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE)와 QueryHints를 조합해 구현할 수 있습니다. 또는 릴레이를 단일 인스턴스로 제한하기 위해 Spring의 ShedLock 라이브러리를 사용한 분산 락도 효과적입니다.
두 번째로 흔한 실수는 Outbox 테이블을 무한정 증가시키는 것입니다. PROCESSED 상태의 레코드가 정리되지 않으면 테이블이 수백만 건으로 불어나 인덱스 효율이 떨어지고, 폴링 쿼리 성능이 저하됩니다. 배치 잡이나 DB 스케줄러를 활용해 일정 기간 이상 된 처리 완료 레코드를 주기적으로 삭제해야 합니다. PostgreSQL의 파티셔닝 기능을 created_at 기준으로 적용하면 오래된 파티션을 통째로 드롭하는 방식으로 효율적인 정리가 가능합니다.
세 번째는 페이로드 크기 관리입니다. 이벤트 페이로드에 불필요하게 큰 데이터를 담으면 Outbox 테이블이 빠르게 비대해지고, Kafka 메시지 크기 제한에도 걸릴 수 있습니다. 일반적으로 이벤트에는 변경된 엔티티의 전체 상태 대신 핵심 식별자와 변경 요약만 담는 것이 권장됩니다. 소비자가 상세 정보가 필요하다면 이벤트를 받은 후 API를 호출해 조회하는 방식(Event-Carried State Transfer의 경량 버전)을 사용합니다.
모니터링과 디버깅
운영 환경에서 반드시 모니터링해야 할 지표는 다음과 같습니다. 첫째, PENDING 상태 레코드 수입니다. 이 값이 지속적으로 증가한다면 릴레이가 제대로 동작하지 않고 있다는 신호입니다. Prometheus + Grafana를 사용한다면 이 메트릭을 커스텀 게이지로 등록하고, 임계값 초과 시 Slack 알림을 발송하는 Alert Rule을 설정하는 것이 좋습니다. 둘째, FAILED 상태 레코드 수입니다. 발행 실패가 누적되고 있다면 Kafka 브로커 연결 문제나 직렬화 오류를 의심해야 합니다. 셋째, 릴레이 처리 레이턴시입니다. Outbox 레코드가 생성된 시각(created_at)과 처리된 시각(processed_at)의 차이를 히스토그램으로 기록하면 레이턴시 추이를 파악할 수 있습니다.
FAILED 상태 레코드는 자동 재시도 로직을 구성할 때 주의가 필요합니다. 단순히 FAILED를 PENDING으로 되돌리는 방식은 영구 실패(예: 잘못된 페이로드로 인한 직렬화 오류) 케이스에서 무한 루프를 만들 수 있습니다. 재시도 횟수(retry_count)와 최대 재시도 한계를 함께 관리하고, 한계를 초과한 레코드는 별도 알림 채널로 에스컬레이션하는 방식이 안전합니다.
확장 및 마이그레이션
기존 서비스에 Transactional Outbox를 점진적으로 도입하는 방법도 있습니다. 모든 이벤트를 한꺼번에 전환하는 대신, 가장 중요도가 높은 이벤트 타입 하나(예: OrderCreated)부터 시작하는 것이 안전합니다. 기존 직접 발행 코드와 Outbox 기록을 잠시 병행 운영하면서 Outbox 레코드가 정상적으로 생성되고 처리되는지 확인한 뒤, 충분한 검증이 이루어지면 기존 직접 발행 코드를 제거하는 순서로 진행합니다. 마이크로서비스 환경에서 여러 서비스가 동시에 Outbox를 도입할 때는 각 서비스의 스키마와 릴레이 로직을 공통 라이브러리로 모듈화하면 중복 구현을 줄일 수 있습니다. 단, 공통 라이브러리가 각 서비스의 기술 스택 선택을 제약하지 않도록 인터페이스 레벨의 추상화를 유지해야 합니다.
데이터 볼륨이 극단적으로 커지는 경우(일 수백만 건 이상)라면, Outbox 테이블을 메인 OLTP DB가 아닌 별도의 경량 DB(예: Redis Streams, 또는 Outbox 전용 DB)로 분리하는 아키텍처도 검토해 볼 수 있습니다. 다만 이 경우에는 비즈니스 트랜잭션과의 원자성이 다시 깨지므로, 분리 전에 충분한 트레이드오프 검토가 필요합니다.
맺음말
핵심 요약
Transactional Outbox 패턴은 분산 시스템에서 이중 쓰기 문제를 해결하기 위한 검증된 아키텍처 패턴입니다. 핵심 원리는 "이벤트 발행을 브로커에 직접 하지 말고, 동일 DB 트랜잭션으로 기록한 뒤 별도 릴레이가 전달하게 하라"는 것입니다. 이를 통해 at-least-once 이벤트 전달 보장을 DB의 ACID 특성만으로 달성할 수 있으며, 2PC처럼 복잡한 분산 트랜잭션 프로토콜 없이도 신뢰성 있는 이벤트 흐름을 만들 수 있습니다. 구현 방식은 스케줄러 기반 폴링부터 Debezium CDC까지 다양하며, 팀의 인프라 역량과 레이턴시 요구사항에 따라 선택합니다.
적용 판단 기준
이 패턴이 적합한 상황은 명확합니다. 이벤트 유실이 직접적인 비즈니스 손실로 이어지는 결제, 주문, 재고 처리 등의 도메인에서는 강력히 권장됩니다. 반면 단순한 알림 이벤트나 통계 수집처럼 유실이 허용되는 케이스, 혹은 이미 Event Sourcing 기반으로 설계된 시스템에서는 이 패턴이 중복될 수 있습니다. 도입을 결정했다면 소비자 측 멱등성 보장을 반드시 함께 설계해야 하며, 이 부분을 빠뜨리면 중복 메시지로 인한 비즈니스 오류가 발생할 수 있습니다. 특히 Outbox 패턴은 MSA 환경에서 Saga 오케스트레이션 패턴과 결합할 때 시너지가 크므로, 두 패턴을 함께 학습하는 것이 도움이 됩니다.
다음 단계
Transactional Outbox 패턴을 깊이 이해했다면, 자연스럽게 다음 주제들로 학습을 확장할 수 있습니다. Debezium 공식 문서(https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html)에서 EventRouter SMT의 세부 설정을 탐구하면 CDC 기반 구현을 더욱 정교하게 다듬을 수 있습니다. 또한 Axon Framework는 Outbox 패턴과 Event Sourcing, CQRS를 통합적으로 지원하는 Java 프레임워크로, 복잡한 도메인 이벤트 아키텍처를 체계적으로 구성할 때 참고할 만한 선택지입니다. 소비자 측 멱등성 보장 방법으로는 처리된 event_id를 별도 테이블에 저장하거나 Redis를 활용한 중복 감지 패턴이 많이 사용되므로, 이 부분도 함께 학습하면 전체 이벤트 파이프라인의 신뢰성을 완성도 있게 확보할 수 있습니다. 분산 시스템에서 결과적 일관성을 받아들이고 각 레이어에서 신뢰성을 쌓아가는 접근 방식은, 복잡한 분산 환경에서도 안정적인 서비스를 만드는 핵심 역량입니다.
'프로그래밍 PROGRAMMING > 아키텍쳐' 카테고리의 다른 글
| Strangler Fig 패턴으로 레거시 모놀리스 전환하기 (0) | 2026.04.04 |
|---|---|
| 이벤트 소싱 패턴 실전 적용하기 (0) | 2026.04.03 |
| [SPRING/JAVA] Spring Boot 멀티모듈 프로젝트 구조 설계하기 (0) | 2026.04.02 |
| Event-Driven Architecture 적용하기 (0) | 2026.03.31 |
| DDD Aggregate 경계 설계 전략 (0) | 2026.03.30 |