
분산 시스템에서 데이터베이스 쓰기와 메시지 브로커 발행을 동시에 처리하려 할 때, 두 작업을 하나의 트랜잭션으로 묶을 수 없어 이벤트가 유실되거나 중복 발행되는 문제가 발생합니다. Outbox 패턴(Transactional Outbox Pattern)은 이 문제를 해결하는 검증된 아키텍처 설계로, 도메인 이벤트를 동일한 DB 트랜잭션 내의 outbox 테이블에 먼저 저장한 뒤, 별도 프로세스가 이를 읽어 메시지 브로커로 전달합니다. 이 글에서는 Spring Boot와 PostgreSQL 환경에서 Outbox 패턴을 단계별로 적용하는 방법을 다룹니다.
Outbox 패턴이 필요한 이유
주문 서비스에서 주문을 저장하고 OrderCreated 이벤트를 Kafka로 발행하는 흔한 시나리오를 떠올려 보겠습니다.
// 문제가 있는 기존 코드
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // DB 저장 성공
kafkaTemplate.send("orders", event); // 여기서 실패하면?
}kafkaTemplate.send() 호출 직전에 네트워크 장애가 발생하거나, Kafka 브로커가 일시적으로 응답하지 않으면 DB에는 주문이 저장됐지만 이벤트는 발행되지 않습니다. @Transactional은 DB 트랜잭션만 보장하며, Kafka 발행은 이 범위 밖에 있기 때문입니다.
반대로 발행 후 DB 롤백이 발생하면 이번엔 이벤트가 중복 발행됩니다. 두 외부 시스템을 원자적으로 다루려면 분산 트랜잭션(2PC) 이 필요하지만, 이는 성능 저하와 운영 복잡성을 크게 높입니다.
Outbox 테이블 설계와 도메인 이벤트 저장
해결의 핵심은 간단합니다. 이벤트를 메시지 브로커로 직접 보내는 대신, 같은 DB 트랜잭션 안에서 outbox 테이블에 INSERT 합니다.
먼저 outbox 테이블을 생성합니다.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(100) NOT NULL, -- 예: 'Order'
aggregate_id VARCHAR(100) NOT NULL, -- 예: 주문 ID
event_type VARCHAR(100) NOT NULL, -- 예: 'OrderCreated'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ -- NULL이면 미발행
);이제 주문 생성 로직에서 이벤트를 outbox 테이블에 함께 저장합니다.
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
OutboxEvent outboxEvent = OutboxEvent.builder()
.aggregateType("Order")
.aggregateId(order.getId().toString())
.eventType("OrderCreated")
.payload(toJson(new OrderCreatedEvent(order))) // JSON 직렬화
.build();
outboxEventRepository.save(outboxEvent);
// Kafka 호출 없음 — 트랜잭션 범위 내에서 완결
}DB 저장과 outbox 레코드 INSERT가 하나의 트랜잭션이므로, 둘 중 하나라도 실패하면 함께 롤백됩니다. 이벤트 유실 가능성이 구조적으로 차단됩니다.
Relay 프로세스: Outbox → Kafka 발행
저장된 이벤트를 실제로 발행하는 역할은 Message Relay가 담당합니다. Relay는 주기적으로 published_at IS NULL인 레코드를 조회해 Kafka로 전송한 뒤 published_at을 업데이트합니다.
@Component
@RequiredArgsConstructor
public class OutboxMessageRelay {
private final OutboxEventRepository repo;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 1000) // 1초 간격 폴링
@Transactional
public void relay() {
List<OutboxEvent> pending = repo.findTop100ByPublishedAtIsNullOrderByCreatedAtAsc();
for (OutboxEvent event : pending) {
kafkaTemplate.send(
event.getAggregateType().toLowerCase() + "-events", // 토픽명
event.getAggregateId(),
event.getPayload()
);
event.markPublished(); // published_at = now()
}
// 루프 완료 후 트랜잭션 커밋
}
}주의: Relay가 Kafka 전송 직후,
published_at업데이트 전에 장애가 발생하면 동일 이벤트가 재발행될 수 있습니다. 이는 at-least-once 의미론으로, 소비자 측에서 멱등성(idempotency)을 보장해야 합니다. 이벤트 ID를 기준으로 중복 처리를 걸러내는 방식이 일반적입니다.
폴링 방식 vs CDC 방식
폴링 기반 Relay는 구현이 간단하지만, DB에 주기적인 SELECT 부하를 줍니다. 높은 처리량이 요구되는 운영 환경에서는 CDC(Change Data Capture) 방식이 더 효율적입니다.
Debezium을 사용하면 PostgreSQL의 WAL(Write-Ahead Log)을 직접 읽어 outbox 테이블 변경분을 Kafka로 스트리밍할 수 있습니다. DB 폴링 없이 실시간에 가까운 이벤트 발행이 가능하며, Debezium은 Outbox Event Router SMT(Single Message Transformation)를 공식 지원해 별도 Relay 코드 없이 outbox 테이블을 자동으로 처리합니다.
| 폴링(Scheduled) | 구현 단순, 외부 의존성 없음 | DB 부하, 지연 발생 가능 |
| CDC(Debezium) | 실시간, DB 부하 최소화 | 인프라 복잡도 증가 |
소규모 서비스 또는 메시지 처리량이 낮다면 폴링으로 시작하고, 처리량이 늘어날 때 CDC로 전환하는 점진적 접근이 현실적입니다.
맺음말
Outbox 패턴은 DB 트랜잭션의 원자성을 활용해 분산 시스템의 이벤트 유실 문제를 설계 수준에서 제거합니다. 핵심은 세 가지입니다.
- 도메인 저장과 이벤트 저장을 반드시 같은 트랜잭션으로 묶을 것
- 발행 책임은 별도 Relay 컴포넌트에 위임할 것
- 소비자 측 멱등성으로 at-least-once 특성을 보완할 것
이벤트 소싱(Event Sourcing), CQRS와 함께 사용할 때 Outbox 패턴의 효과가 극대화됩니다. 또한 Debezium 공식 문서의 PostgreSQL 커넥터와 Outbox Event Router를 함께 검토해 보면 CDC 기반 전환 시 구체적인 설정 방법을 확인할 수 있습니다. 이벤트 기반 아키텍처를 신뢰성 있게 구축하는 첫 번째 단계로 Outbox 패턴은 충분히 도입 가치가 있습니다.
'프로그래밍 PROGRAMMING > 아키텍쳐' 카테고리의 다른 글
| Event-Driven Architecture 적용하기 (0) | 2026.03.31 |
|---|---|
| DDD Aggregate 경계 설계 전략 (0) | 2026.03.30 |
| Event Sourcing 아키텍처 패턴 적용하기 (0) | 2026.03.23 |
| BFF 패턴으로 API 게이트웨이 설계하기 (0) | 2026.03.19 |
| 모듈러 모놀리스 아키텍처 적용하기 (0) | 2026.03.18 |