트랜잭셔널 아웃박스 패턴을 통해 안정적인 이벤트 발행 구조 설계하기

Transactional Outbox Pattern For Data Consistency In Event Driven Systems
이벤트 기반 시스템에서는 데이터베이스 업데이트와 메시지 발행 간의 원자성이 깨지며 데이터 정합성 문제가 발생할 수 있습니다. 이러한 불일치는 서비스 신뢰성을 저하시킬 수 있으며, 이를 해결하기 위해 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern)이 사용됩니다. 이 패턴은 데이터베이스와 메시지 브로커 간의 일관성을 확보하며, 데이터 유실이나 중복 발행 문제를 방지하는 역할을 합니다.
Concept of Transactional Outbox Pattern
트랜잭셔널 아웃박스 패턴은 단일 트랜잭션 내에서 데이터와 이벤트를 함께 관리하는 기법입니다. 데이터베이스에 업데이트된 내용이 메시지 브로커를 통해 정확히 전달되도록 보장하며, 이벤트 기반 아키텍처의 신뢰성을 높입니다. 이를 통해 각 서비스는 상호 의존성을 최소화하면서도 정확한 데이터 흐름을 유지할 수 있습니다.
마이크로서비스 및 이벤트 기반 아키텍처를 채택한 서비스 운영에서는 다음과 같은 문제들이 발생할 수 있습니다:
- 결제 실패 시 잘못된 알림 발송: 결제 처리 중 실패했음에도 불구하고 결제 성공 메시지가 발행되어 사용자가 결제가 완료된 것으로 오해할 수 있습니다.
- 메시지 발행 실패로 인한 중복 결제: 결제가 성공했음에도 불구하고 결제 성공 메시지가 발행되지 않아 사용자가 결제 완료 알림을 받지 못하고, 결제 상태를 확인하지 못한 채 다시 결제를 시도할 수 있습니다.
이 외에도 다양한 이벤트 처리 실패나 데이터 일관성 문제들이 발생할 수 있습니다. 이러한 문제를 해결하기 위해 트랜잭셔널 아웃박스 패턴이 사용됩니다. 이 패턴은 데이터베이스와 메시지 브로커 간의 일관성을 보장하며, 이벤트 발행과 처리 과정에서 발생할 수 있는 오류를 방지하여 시스템의 안정성과 신뢰성을 높입니다.
트랜잭셔널 아웃박스 패턴의 주요 프로세스는 다음과 같습니다.
- 트랜잭션 기반 이벤트 저장: 데이터베이스 업데이트와 함께 아웃박스 테이블에 이벤트를 기록하며, 이를 통해 단일 트랜잭션을 유지합니다.
- 이벤트 조회 및 발송: 스케줄링 또는 CDC(Change Data Capture)를 활용해 아웃박스 테이블에서 미처리 상태의 이벤트를 주기적으로 조회하고, 메시지 브로커(Kafka)로 발송합니다.
- 발송 후 상태 업데이트: 메시지 발송에 성공하면 아웃박스 테이블의 이벤트 상태를 변경하여 중복 발송을 방지합니다.
- 컨슈머의 이벤트 처리: 메시지를 구독한 서비스는 해당 이벤트를 기반으로 결제 프로세스 시작, 이메일 발송, 알림 전송 등 필요한 후속 작업을 수행합니다.
이 트랜잭셔널 아웃박스 패턴은 마이크로서비스 환경에서 데이터 정합성과 안정성을 보장하는 효과적인 방법입니다. 단일 트랜잭션 및 재시도 메커니즘을 통해 비동기 통신 환경에서의 다양한 문제를 예방할 수 있습니다.
Implementation of Transactional Outbox Pattern
이번에는 간단한 시나리오를 통해 트랜잭셔널 아웃박스 패턴을 단계별로 구현해 보겠습니다. 이 시나리오는 신규 사용자가 회원가입을 완료하면 웰컴 이메일이 자동으로 발송되는 과정을 다룹니다.
- 사용자 정보 저장: 사용자가 회원가입을 요청하면, 해당 정보를 데이터베이스에 저장합니다. 이 과정은 일반적인 트랜잭션을 통해 보장됩니다.
- 아웃박스 이벤트 저장: 사용자 등록이 완료되면, 동일한 트랜잭션 내에서 아웃박스 테이블에 이메일 발송을 위한 이벤트를 저장합니다. 이렇게 하면 데이터베이스에 커밋되는 시점에 이벤트도 함께 저장되어 데이터 일관성이 유지됩니다.
- 아웃박스 미완료 이벤트 조회: CDC(Change Data Capture) 또는 일정 주기로 실행되는 배치 프로세스가 아웃박스 테이블을 모니터링하며, 'PENDING' 상태의 이벤트를 탐지하고 조회합니다. CDC를 사용하면 데이터베이스의 변경 사항을 실시간으로 감지하여 즉각적으로 이벤트를 처리할 수 있습니다.
- 이벤트 메시지 발행 요청: 조회된 아웃박스 이벤트를 바탕으로, 메시지 브로커(Kafka, RabbitMQ 등)의 프로듀서(Producer)에 메시지 전송을 요청합니다.
- 아웃박스 이벤트 상태 업데이트: 메시지가 브로커에 정상적으로 전달되면, 해당 아웃박스 이벤트의 상태를 'COMPLETED'로 변경하여 중복 처리를 방지합니다.
- 이벤트 메시지 브로커로 이벤트 발행: 프로듀서(Producer)가 이메일 발송 요청 이벤트를 메시지 브로커에 발행하고, 구독 중인 시스템이 이를 수신할 수 있도록 합니다.
- 메시지 소비: 이메일 발송을 담당하는 서비스가 해당 토픽을 구독하고, 발행된 이벤트 메시지를 수신하여 필요한 처리를 진행합니다.
- 이메일 발송 요청: 수신된 이벤트 데이터를 바탕으로, 이메일 서비스에 실제 이메일 발송을 요청합니다.
- 이메일 발송 완료: 이메일 서비스가 최종적으로 사용자에게 웰컴 이메일을 발송하여, 온보딩 과정을 마무리합니다.
Outbox Table Schema
Outbox 테이블은 사용자의 데이터가 변경될 때 이벤트 정보를 저장하는 역할을 합니다. 이를 통해 데이터베이스와 메시지 브로커 간의 일관성을 유지하고 정합성을 보장합니다.
create table outbox
(
id bigint not null primary key,
aggregate_type varchar(255) not null,
aggregate_id bigint not null,
event_type varchar(255) not null,
payload json not null,
status varchar(50) default 'PENDING' not null,
retry_count int default 0 not null,
created_at datetime default current_timestamp not null,
updated_at datetime default current_timestamp not null on update current_timestamp
);
id
: 이벤트의 고유 식별자aggregate_type
: 이벤트가 속한 도메인 또는 엔티티 유형aggregate_id
: 관련 엔티티의 식별자event_type
: 이벤트 유형payload
: 실제 발행할 이벤트 메시지status
: 이벤트 처리 상태retry_count
: 이벤트 발송 재시도 횟수created_at
: 이벤트 생성 시각updated_at
: 이벤트 정보가 마지막으로 업데이트된 시각
Outbox Pattern in Action
구현 과정에서는 검증이나 암호화와 같은 복잡한 로직을 제외하고, 데이터 구조도 단순화하여 트랜잭셔널 아웃박스 패턴에 초점을 맞추었습니다. 우선, 회원 가입 요청을 처리하는 컨트롤러를 구현해 보겠습니다. 회원 가입 컨트롤러는 사용자가 API로 요청을 보내면, 사용자 정보 저장과 관련된 비즈니스 로직을 서비스 계층에 위임하며, 클라이언트에게 처리 결과를 반환하는 역할을 합니다.
@PostMapping("/signup")
public ResponseEntity<?> userSignupApi(
@RequestBody UserSignupRequest request) {
User user = userService.registerUser(request.username(), request.password());
return ResponseEntity
.ok(ApiResponse.success(user, "User registered successfully"));
}
사용자 정보는 HTTP 요청 본문에 JSON 형식으로 전달되며, POST 메서드를 통해 /users/signup
엔드포인트로 요청이 전송됩니다. 아래는 회원 가입을 위한 API 요청 샘플입니다:
POST /users/signup HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.8 (Macintosh; OS X/15.3.0) GCDHTTPRequest
Content-Length: 53
{"username":"ongs@gmail.com","password":"trasactionaloutbox"}
다음은 컨트롤러에서 위임된 처리를 수행하는 과정입니다. 이 단계는 아웃박스 패턴의 핵심으로, 사용자 등록 요청을 처리하며 비즈니스 로직에 따라 사용자 데이터를 생성하고 저장합니다. 동시에 아웃박스 이벤트를 생성하여 이벤트 발행을 준비하며, 이 모든 과정은 하나의 트랜잭션으로 묶여 데이터 정합성을 보장합니다:
@Transactional
public User registerUser(String username, String password) {
try {
User user = User.register(username, password);
user = userRepository.save(user);
Email email = Email.composeWelcomeEmail(user);
OutboxEvent event = OutboxEvent.createUserRegisteredEvent(user, email);
event = outboxEventRepository.save(event);
log.info("registerUser: Successfully registered user - userId={}, username={}, eventId={}",
user.getId(),
user.getUsername(),
event.getId());
return user;
} catch (Exception e) {
throw new RuntimeException("Failed to create outbox event for user registration", e);
}
}
만약 이 과정 중 하나라도 실패하면, 전체 트랜잭션이 롤백되어 사용자 데이터와 아웃박스 이벤트가 함께 저장되지 않습니다. 이를 통해, 정상적으로 처리되지 않은 데이터로 인해 메시지가 발행되는 문제를 방지하고, 데이터 정합성을 유지할 수 있습니다.
아웃박스 이벤트 테이블의 payload
에는 실제 발행할 메시지를 JSON 형식으로 담아두어 메시지 브로커로 메시지를 발행할 때 활용합니다. 이는 이메일, 알림, 결제 등 다양한 이벤트 유형을 포함할 수 있습니다. 이 필드는 후속 서비스가 이벤트를 식별하고 처리하는 데 필요한 핵심 데이터를 제공합니다.
{
"email": {
"id": 225941051972583586,
"to": "ongs@gmail.com",
"body": "Hello!\nThank you for registering with us. Enjoy your experience! 🎉\n\nBest,\nJin.\n",
"userId": 225941051955806370,
"subject": "[catsriding] Welcome to Our Service!"
},
"eventId": 225941051972649120
}
데이터베이스에 성공적으로 레코드를 추가하면, 시스템은 회원가입이 완료되었음을 의미하는 응답을 반환해 유저가 즉시 서비스를 이용할 수 있도록 합니다. 이는 트랜잭션이 정상적으로 완료되었음을 나타내며, 이후 이벤트 발송을 위한 후속 단계로 이어집니다.
사용자 회원가입이 완료되면 내부적으로 아웃박스 이벤트를 추적해 Kafka 메시지를 발행합니다. 이 추적 방식에는 일정 간격으로 데이터베이스를 폴링하거나, 데이터베이스 변경 사항을 감지하는 CDC(Change Data Capture) 방식 등이 있습니다. 여기서는 스케줄러를 통해 일정 간격으로 아웃박스 이벤트 테이블에서 미완료된 데이터를 조회하여 처리하는 방식으로 진행하였습니다.
@Scheduled(fixedRate = 5000)
public void dispatch() {
List<OutboxEvent> events = outboxEventRepository.findAllByPendingStatus("PENDING", 3);
for (OutboxEvent event : events) {
outboxEventProcessor.publishEvent(event);
}
}
스케줄러에서 미완료된 레코드를 모두 조회한 후 개별적으로 메시지 발행을 시작합니다. 이 단계는 아웃박스 패턴에서 중요한 부분으로, 이렇게 함으로써 데이터베이스와 메시지 브로커 간의 불일치 문제를 해소할 수 있습니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void publishEvent(OutboxEvent event) {
try {
outboxEventRepository.save(event.start());
String serializedPayload = objectMapper.writeValueAsString(event.getPayload());
outboxEventPublisher.publish("user-registration-events", serializedPayload);
log.info("process: Sent event: eventId={}, status={}", event.getId(), event.getStatus());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
또한, 메시지 발행에 실패할 경우 별도의 상태와 재시도 횟수를 기록해 실패한 이벤트가 추후 복구 및 재발송될 수 있도록 설계하여 시스템의 신뢰성을 높일 수도 있습니다.
Kafka 브로커로 메시지가 성공적으로 발행되면 해당 토픽을 구독하고 있던 Kafka 컨슈머가 메시지를 소비한 뒤, 이메일 서비스에 전달해 이메일 발송을 요청합니다. 이를 통해 서비스 간 결합도가 낮아지고, 트래픽 증가 시에도 확장성이 확보됩니다.
@KafkaListener(topics = "user-registration-events", groupId = "email-service")
public void consume(String message) {
try {
outboxEventConsumer.consume(message);
log.info("consume: Successfully consumed user registration event");
} catch (Exception e) {
log.error("consume: Failed to consume message", e);
}
}
이메일 서비스에서는 메시지를 다시 역직렬화하여 이메일 발송에 필요한 데이터를 추출한 후, 이를 활용해 이메일을 발송합니다. 다만, 이 과정은 아웃박스 패턴의 일부가 아니라, 아웃박스에서 발행된 이벤트를 기반으로 후속 서비스가 처리하는 단계입니다. 아웃박스 패턴은 발송 이벤트 자체에 대한 추적에 중점을 두며, 발송 이후의 비즈니스 로직은 별도의 소비자(Consumer) 서비스의 역할입니다.
참고로, 이메일 발송에는 Java Mail Sender와 Gmail SMTP 서버를 활용하였습니다.
@Transactional
public void sendEmail(String message) {
try {
OutboxPayload payload = objectMapper.readValue(message, OutboxPayload.class);
emailService.sendEmail(payload.email());
OutboxEvent event = outboxEventRepository.findById(payload.eventId());
event = outboxEventRepository.save(event.complete());
log.info("consume: Successfully consumed outbox event - eventId={}, status={}",
event.getId(),
event.getStatus());
} catch (JsonProcessingException e) {
log.error("consume: Failed to consume outbox event - message={}", message);
}
}
이제 모든 구현이 완료되었습니다. 신규 유저가 회원가입을 완료하면, 회원 정보는 데이터베이스에 저장되고, 그에 따른 아웃박스 이벤트가 PENDING
상태로 생성됩니다.
id | event_type | payload | status | retry_count |
---|---|---|---|---|
225941051972649122 | UserRegistered | {...} | PENDING | 0 |
이후 이벤트는 메시지 브로커를 통해 발행되고, 이벤트 상태가 COMPLETED
로 업데이트됩니다.
id | event_type | payload | status | retry_count |
---|---|---|---|---|
225941051972649122 | UserRegistered | {...} | COMPLETED | 0 |
마지막으로, 해당 메시지를 기반으로 메일 발송 서비스가 실행되어, 유저에게 이메일이 발송됩니다.
이러한 구조는 시스템의 확장성과 유지보수성을 향상시키고, 유연성과 효율성을 크게 개선하는 데 기여합니다.
Wrapping It Up
지금까지 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern)을 통해 데이터베이스와 메시지 브로커 간의 데이터 정합성 문제를 해결하는 방법을 살펴보았습니다. 회원가입부터 웰컴 이메일 발송까지의 시나리오를 예로 들어, 단일 트랜잭션 내에서 이벤트 발행의 일관성을 확보하는 과정을 코드와 함께 구현했습니다.
이 패턴은 데이터와 메시지 발행 간의 불일치 문제를 방지하며, 안정적이고 신뢰할 수 있는 방식으로 이벤트를 처리할 수 있음을 보여주었습니다. 다만, 현재 구현에서는 예외 상황에 대한 처리가 미비하여, 장애 발생 시 재시도 로직과 실패 보상 처리 등의 고도화가 필요합니다.
또한, 외부 서비스와 연동되는 비동기 프로세스(예: 결제 시스템)에서는 트랜잭셔널 아웃박스 패턴만으로는 충분하지 않을 수 있습니다. 복잡한 비즈니스 프로세스의 정합성을 보장하려면, 사가 패턴(Saga Pattern)과 같은 분산 트랜잭션 관리 기법을 함께 고려해야 합니다. 특히 사가 패턴은 각 로컬 트랜잭션 단계에서 아웃박스 패턴을 활용해 이벤트 발행의 일관성을 유지하는 데 유용합니다.
다음에는, 분산 시스템에서 프로세스 전체의 정합성을 보장하는 사가 패턴(Saga Pattern)에 대해 알아보겠습니다. 🎯
- Architecture