Transactional Outbox Pattern 도입기 2 - Event Outbox의 저장
Transactional Outbox Pattern 중 Event Outbox 저장 흐름

트랜잭션 아웃박스 패턴은 아래와 같은 처리 흐름을 가진다.
- 도메인 이벤트 발행 (내부 발행)
- 도메인 이벤트 리스너 - 이벤트 저장 (아웃박스 변환)
- 도메인 이벤트 리스너 - 이벤트 발행 (발행 후 아웃박스 상태 변경)
- 이벤트 폴러 - 아웃박스 조회 후 이벤트 발행
- 메시지 브로커
- 이벤트 소비자
트랜잭션 아웃박스 패턴의 구현은 변형되어 다양한 방식이 존재한다. Chris Richardson의 Microarchitecture - Pattern: Transactional outbox에 따르면, 이벤트 발행은 메시지 릴레이(Message Relay = Event Poller)가 담당한다.
그러나, 필자는 Spring Event의 @TransactionalEventListener를 통해서 트랜잭션 시점에 따른 이벤트 기록, 이벤트 외부 발행을 진행하여 이벤트 폴러 외에도 이벤트 외부 발행 기능을 담당하는 주체가 하나 더 존재한다. 이러한 구조를 선택한 이유는 향후 포스팅에서 서술한다.
이번 포스팅에서 집중할 부분은 ‘이벤트 저장’이다.
Spring Event를 통해서 내부 발행된 이벤트는 이벤트 저장용 리스너에 의해 Event Outbox로 변환되어 저장된다.
본론에 들어가기에 앞서 ‘이벤트 저장’ 로직의 흐름은 다음과 같다.
DomainEvent상위 추상 클래스 정의 및 Spring Event를 사용한 내부 발행- 각 비즈니스 로직 내 이벤트 발행 로직 적용 및 도메인 이벤트 구현
@TransactionalEventListener를 사용한 이벤트 기록용 리스너 정의- 이벤트 기록용 리스너에서 이벤트를 아웃박스 엔티티(
EventOutbox) 형태로 전환하여 저장
크게 위와 같은 흐름으로 진행되며 이를 적용하기 위해 여러 클래스들을 구현하였다.
본론에서 Event 클래스, Event Outbox 엔티티의 구조와 저장 흐름에 대해 설명하고자 한다.
DomainEvent 클래스와 Spring Event를 사용한 내부 이벤트 발행
Spring Modulith - Working with Application Events에서는 ApplicationEventPublisher을 통해서 이벤트를 발행하여 클래스 간 결합도를 낮출 수 있다고 명시되어 있다.
또한, ApplicationEventPublisher를 통해서 발행한 이벤트는 @EventListener 또는 @ApplicationModuleListener, @TransactionalEventListener를 통해서 이벤트 리스닝이 가능하다.
특히, @TransactionalEventListener는 트랜잭션 단계(Phase)에 따라 호출되는 이벤트 리스너로, 트랜잭션 단계는 아래와 같이 4가지 종류가 존재한다.
AFTER_COMMIT: 트랜잭션 커밋 후 (default)AFTER_COMPLETION: 트랜잭션 종류 후 (커밋/롤백에 상관없이)AFTER_ROLLBACK: 트랜잭션 롤백 후BEFORE_COMMIT: 트랜잭션 커밋 전
이벤트의 저장 단계에서 필요한 트랜잭션 단계(시점)는 커밋 전(BEFORE_COMMIT)이다.
트랜잭션이 커밋되기 전 이벤트(아웃박스)가 저장소에 기록되어야지만 향후 저장된 이벤트(아웃박스)를 조회하여 메시지 발행이 가능하다.
DomainEvent
DomainEvent는 특정 비즈니스 도메인에서 이벤트가 발생하였을 때 발행할 모든 이벤트 상위 추상 클래스이다.
ApplicationEventPublisher에서 발행한 이벤트는 리스너에서 인자로 명시한 클래스 타입에 따라 이벤트를 가져와 처리하게 된다. DomainEvent라는 상위 추상 클래스를 정의하여 모든 이벤트에 일관된 내부 발행 로직을 적용하고, 이벤트 리스너에서는 공통 로직은 DomainEvent, 개별 실행 로직은 구체적 타입을 명시하여 처리한다.
또한, 로그 기록과 같은 추적 작업에 DomainEvent에 대한 리스너를 추가하여 사용하는 등의 작업도 가능하다.
@Getter
public abstract class DomainEvent {
private final ZonedDateTime createdAt;
private final String eventId;
public DomainEvent() {
this.createdAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));
this.eventId = UlidCreator.getUlid().toString();
}
}
DomainEvent는 모든 클래스의 상위 추상 클래스로, 모든 클래스가 포함하고 있어야할 속성을 가진다.
이벤트 발행 시각을 나타내는 createdAt과 개별 이벤트 고유번호를 나타내는 eventId를 가진다.
이벤트마다 해당 이벤트가 유효한 시간이 다르며, 이벤트를 추적하기 위해서는 이벤트를 식별하기 위한 식별자가 필요하기 때문에 위와 같은 속성을 정의하였다.
createdAt 속성은 서버 환경에 따라 변하는 것을 방지하기 위하여 ZonedDateTime 타입을 사용하여 코드 내에 TimeZone(Asia/Seoul)을 명시하였다.
eventId는 이벤트를 식별하기 위한 고유번호로 충돌 방지와 효율적인 저장 방식을 고려하여 ULID를 사용하였다.
이벤트 식별자로 UUID대신 ULID를 사용한 이유는 이전 포스팅에서 정리하였듯 이벤트 아웃박스의 저장에 있어 성능적인 부분을 고려하였기 때문이다.
고유 식별자의 크기를 줄여 이벤트 아웃박스 테이블이 차지하는 페이지의 크기를 줄이고, Key가 시간에 따라 순차적으로 증가하는 양상을 보여 레코드 삽입 시 인덱스 트리 구조 갱신을 최소화하기 위해 ULID를 사용하였다.
이벤트 아웃박스는 모든 발생 이벤트들이 메시지 브로커로 발행되기 전 임시로 저장되기 때문에 다양한 도메인에서 많은 이벤트들이 발생하게 되어 이러한 성능적 개선점을 도입하게 되었다.
PasswordResetEvent
@Getter
public class PasswordResetEvent extends DomainEvent {
private final String email;
private final String code;
private final ZonedDateTime expiredAt;
public PasswordResetEvent(String email, String code, int expiration) {
super();
this.email = email;
this.code = code;
this.expiredAt = getCreatedAt().plusSeconds(expiration);
}
}
PasswordResetEvent는 비밀번호 초기화 요청을 나타내는 이벤트 클래스로 DomainEvent를 구현한 클래스이다. 트랜잭션 아웃박스 패턴을 적용하는 모든 이벤트 클래스들이 DomainEvent를 상속받아 구현한다.
DomainEvent에 속하는 공통 적용 속성을 제외하고, 비밀번호 초기화 이벤트에 맞는 속성 email, code(인증코드), expiredAt(만료 기한)을 가진다.
cf. 이벤트의 구조(속성)
이메일과 같은 부가로직 수행이 아닌, CQRS 환경이나 여러 모듈 간 데이터를 공유하여 저장하고 있는 MSA 환경일 경우 데이터베이스 갱신 작업 전파를 위한 트랜잭션 아웃박스 패턴을 구현하기도 한다.
실제로 MSA로 운영되는 서비스들의 기술블로그에서는 이러한 데이터 갱신 이벤트 전파의 목적으로 트랜잭션 아웃박스 패턴을 적용하는 사례도 보았다.
해당 경우에는 부가 로직 실행이 아닌 ‘갱신된 데이터의 변경사항을 확인’하는 것이 목적이기에 제로 페이로드(Zero Payload) 방식을 사용하기도 한다.
제로 페이로드 방식은 데이터가 갱신된 A 모듈에서는 엔티티의 주키(PK)만 담은 이벤트를 발행한다. 이후, 이벤트를 수신한 B 모듈에서는 갱신된 엔티티의 PK를 확인하고 해당 키를 통해 A 모듈에 엔티티 조회 요청을 보낸다. B 모듈은 갱신된 엔티티를 응답받아 데이터를 갱신하는 등의 작업을 수행한다.
단순히, 주키만 전달하는 것이 아닌 갱신된 엔티티의 필드나 이유 등을 나누고 이를 문서화하여 별도로 관리하여 필요에 맞게 유연하게 변경하여 사용하기도 한다.
비밀번호 초기화 요청 로직
@Service
@RequiredArgsConstructor
public class PasswordManagementService {
private final UserService userService;
private final PasswordCodeService passwordCodeService;
private final DomainEventPublisher eventPublisher;
@Value("${auth.expiration.password-reset}")
private int passwordResetExpiration;
/**
* 비밀번호 초기화 요청 메서드
*
* <p> 사용자 확인을 위한 확인용 코드 생성 및 저장
*
* <p> 해당 사용자에게 비밀번호 초기화 링크 메일 전송
*
* <p> 최종적으로 비밀번호 초기화 요청 이벤트 발행
*
* @param email 사용자 이메일
* @param username 사용자 아이디
* @throws CustomException AuthErrorType.MISMATCHED_EMAIL_OR_USERNAME - 이메일 또는 아이디가 불일치하는 경우
*/
@Transactional
public void requestPasswordReset(String email, String username) {
if (!userService.existsIncludeDeletedByEmailAndUsername(email, username)) {
throw new CustomException(AuthErrorType.MISMATCHED_EMAIL_OR_USERNAME);
}
if (passwordCodeService.existsCode(email)) {
throw new CustomException(AuthErrorType.PASSWORD_RESET_TIME_LIMIT);
}
String code = PasswordCodeGenerator.generate();
passwordCodeService.save(email, code, passwordResetExpiration);
// PasswordResetEvent를 생성해 발행
eventPublisher.publish(new PasswordResetEvent(email, code, passwordResetExpiration));
}
// ...
}
비밀번호 초기화 요청 로직에서는 비밀번호 초기화 이메일 전송 시, 비밀번호 초기화 가능한 링크와 인증번호를 전송하게 된다.
인증번호 생성이 완료된 후, 이메일을 보내기 위한 PasswordResetEvent를 생성해 발행하게 된다.
이벤트 기반 구조를 도입하여 메서드 응집도 및 비밀번호 초기화 요청 유즈케이스와 이메일 발행 로직간 강결합도를 줄일 수 있다.
DomainEventPublisher
@Component
@RequiredArgsConstructor
public class DomainEventPublisher {
private final ApplicationEventPublisher applicationEventPublisher;
public void publish(DomainEvent event) {
applicationEventPublisher.publishEvent(event);
}
}
DomainEventPublisher는 ApplicationEventPublisher를 감싼 클래스로 내부 이벤트 발행을 담당한다.
내부 이벤트 발행은 Spring Event의 Event 발행 구조를 의미한다.
EventOutbox
이벤트 아웃박스는 DomainEvent를 데이터베이스에 저장하기 위한 아웃박스로 변환한 엔티티를 의미한다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "event_outbox")
public class EventOutbox {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_id", nullable = false, length = 26)
private String eventId;
@Column(name = "event_type", nullable = false)
private String eventType;
@Column(nullable = false)
private String payload;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private EventOutboxStatus status;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "last_retried_at")
private LocalDateTime lastRetriedAt;
@Column(name = "fail_count")
private int failCount;
@Builder
public EventOutbox(String eventId, String eventType, String payload, LocalDateTime createdAt) {
this.eventId = eventId;
this.eventType = eventType;
this.payload = payload;
this.createdAt = createdAt;
this.status = EventOutboxStatus.WAITING;
}
public static EventOutbox fromEvent(DomainEvent event, String payload) {
return EventOutbox.builder()
.eventId(event.getEventId())
.eventType(event.getClass().getName())
.payload(payload)
.createdAt(event.getCreatedAt().toLocalDateTime())
.build();
}
}
이벤트 아웃박스의 각 필드에 대한 설명은 다음과 같다.
1. Long id
이벤트 아웃박스의 고유번호로 Long(BIGINT) 타입으로 저장된다.
주 키(Primary Key)의 경우 키-레코드 쌍으로 저장이되기 때문에 주키는 데이터베이스에서 생성하는 순차성을 완전히 보장하는 전략으로 선택하였다.
2. String eventId
이벤트를 식별하기 위한 고유번호로 ULID를 문자열 형태로 변환하여 저장한다.
Primary Key는 데이터베이스에서 키를 생성하는 IDENTITY 타입을 사용했기 때문에 애플리케이션 단에서 키를 알기 어렵다.
따라서, 애플리케이션 단계에서 별도의 키를 생성하여 이벤트를 추척할 수 있도록 하였다.
또한, eventId를 통한 빠른 조회가 가능하도록 eventId 컬럼을 통한 보조 인덱스에도 사용된다.
event_id컬럼에 대한 unique 설정을 통해 자동으로 인덱스를 생성해 사용할 수도 있다.
3. String eventType
이벤트의 클래스 타입을 나타내는 속성이다.
이벤트는 아웃박스 형태로 저장될 때, 이벤트 자체는 JSON 형태의 문자열로 직렬화되어 저장된다.
이후, 이벤트 아웃박스를 조회할 때 이벤트를 다시 복구하기 위해서 타입이 필요하다. 따라서, 이벤트의 클래스 타입을 저장한다.
적용된 외부 메시지브로커(RabbitMQ)는 JSON 형태의 문자열 메시지도 발행이 가능하기 때문에 실제로는 이벤트를 조회할 때 별도로 변환하는 과정은 존재하지 않으나, 이벤트 추적 및 향후 확장을 위하여 이벤트 클래스 타입을 동시에 저장한다.
4. String payload
실제로 도메인 이벤트 객체가 JSON 형태의 문자열로 직렬화되어 저장되는 컬럼이다.
해당 컬럼이 이벤트를 나타내는 핵심 페이로드이며, 이를 역직렬화하거나 문자열 그대로 발행하는 등의 작업에 사용된다.
5. EventOutboxStatus status
public enum EventOutboxStatus {
WAITING, PUBLISHED, FAILED
}
이벤트 아웃박스의 상태를 나타내는 클래스이다.
이벤트 발행 대기, 발행 성공, 발행 실패의 상태를 가진다.
필요에 따라 상태를 세분화할 수 있다.
6. LocalDateTime createdAt
이벤트의 생성 시간을 나타내는 컬럼이다.
DomainEvent 추상 클래스의 createdAt과 동일하다.
7. LocalDateTime lastRetriedAt
마지막으로 재시도한 시각을 나타내는 컬럼이다.
특정 이벤트의 경우에는 이벤트 자체의 유효기간이 존재하기도 하며, 향후 이벤트 추적을 위해서도 사용되는 컬럼이다.
8. int failCount
이벤트의 외부 발행 시도 실패 횟수를 나타내는 컬럼이다.
각 이벤트의 성격에 맞게 실패 횟수를 통한 발행 제어가 가능하다. 정확하게는 이벤트 폴러에서 특정 횟수 이상 실패한 이벤트는 조회하지 않도록하여 조회 부담을 줄인다.
DomainEventRecordListener
public interface DomainEventListener {
void handleEvent(DomainEvent event);
}
@Component
@RequiredArgsConstructor
public class DomainEventRecordListener implements DomainEventListener {
private final EventOutboxService eventOutboxService;
private final ObjectMapper objectMapper;
@Override
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleEvent(DomainEvent event) {
try {
String payload = objectMapper.writeValueAsString(event);
eventOutboxService.save(EventOutbox.fromEvent(event, payload));
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to parse Event - " + event.getEventId(), e);
}
}
}
DomainEventRecordListener는 이벤트를 아웃박스로 변환하여 event_outbox 테이블에 기록하기 위한 이벤트 리스너이다.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)를 사용하여 트랜잭션이 커밋되기 전에 이벤트가 저장되도록 한다.
부가로직이라 할지라도 이벤트가 아웃박스 형태로 저장이 되어야지만 이후 다른 모듈로 전달되어 부가 로직들을 실행할 수 있다. 따라서, 트랜잭션 커밋 이전 시점에 실행하여 핵심 비즈니스 로직과 이벤트 아웃박스 저장 로직 실행의 원자성을 보장한다.
각 이벤트는 ObjectMapper를 사용해 JSON 형태의 문자열로 변환되어 EventOutbox.payload 속성으로 저장된다.
이벤트 저장 흐름 정리

위 과정을 통하여 트랜잭션 아웃박스 패턴 중 ‘이벤트 저장’ 로직에 관여하는 주요 클래스들에 대해 알아보았다.
저장 흐름에서 핵심은 Spring Event를 사용하여 트랜잭션이 커밋되기 전에 이벤트를 아웃박스 형태로 저장하는 것이다.
내부 이벤트를 발행하는 과정에서 DomainEvent 상위 추상 클래스를 적용하여 각 이벤트마다 별도의 기록용 리스너를 사용하는 것이 아니라 부모 타입인 DomainEvent를 리스닝하는 이벤트 리스너 하나만 정의하여 기록 로직을 통합하였다.
각 비즈니스 로직은 부가 로직을 직접 수행하는 것이 아닌 DomainEvent를 상속받은 이벤트 객체를 생성하여 내부로 발행하는 책임을 가지게 된다.
내부로 발행된 이벤트는 우선 트랜잭션 커밋 전에 EventOutbox 엔티티로 변환되어 저장소에 저장된다. 이때, @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 어노테이션을 통해 비즈니스 로직과 이벤트 아웃박스 저장을 원자적으로 실행한다.
마무리하며
해당 포스팅에서는 트랜잭션 아웃박스 패턴을 구현하는 중 ‘이벤트 저장’ 흐름에 대해 알아보았다.
다음 포스팅에서는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)을 사용하여 커밋이 완료된 후 이벤트를 발행하는 로직을 구현한 경험에 대해 서술해보고자 한다.