김솔비 블로그
기술 블로그

Transactional Outbox Pattern을 활용한 메시지 유실 방지

8분 읽기

신규 기능으로 마일리지 시스템을 도입해야 하는 상황이 생겼다.

처음에는 서비스 로직 안에서 직접 마일리지를 적립하는 방식으로 구현하려고 했다. 예를 들어 회원가입을 하면 축하 마일리지를 곧바로 지급하는 구조다.

하지만 이 방식에는 치명적인 문제가 있다.

  • 회원가입 도중 오류가 발생하면 회원 정보는 저장되지 않았는데 마일리지는 이미 적립된 상태가 된다.
  • 반대로 마일리지 적립 도중 예외가 발생하면 회원가입 트랜잭션 전체가 롤백된다.

하나의 트랜잭션으로 묶어 처리할 수도 있지만, 마일리지는 본질적으로 '부가 기능'이기 때문에 메인 플로우와 얽히는 구조는 좋지 않다고 생각했다.

검토한 세 가지 방식

보편적으로 자주 쓰이는 방식을 조사한 뒤 세 가지를 검토했다.

1. 직접 호출 방식

기존에 내가 구현했던 방식이다. 서비스 레이어에서 바로 마일리지 적립을 수행한다. 간단하지만 결합도가 높고 예외 처리에 취약하다.

2. Spring ApplicationEventPublisher 기반 이벤트 처리

이벤트를 발행해 비즈니스 로직과 분리할 수 있다. 다만 리스너가 실패했을 때의 재처리 설계가 별도로 필요하다.

3. Kafka, RabbitMQ 기반 메시지 큐

대규모 트래픽과 분산 환경에 적합하지만, 초기 도입과 운영 복잡도가 있다. 지금 개발 중인 서비스는 대규모 트래픽이 예상되지 않아서 자칫 오버 엔지니어링으로 이어질 수 있다고 판단했다.

운영 규모와 여러 예외 상황을 고려해, 2번을 한층 발전시킨 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern) 으로 설계를 진행하기로 했다.

왜 이 방식을 선택했는가

마일리지 적립 로직을 메인 플로우와 완전히 분리해서, 적립이 실패해도 회원가입에는 영향이 없도록 했다. 또한 Outbox 테이블을 기반으로 재처리와 상태 추적이 가능하고, 마일리지 적립 로직은 REQUIRES_NEW 트랜잭션으로 실행되기 때문에 정상 플로우와 격리되어 처리된다.

결과적으로 안정성과 유연성을 동시에 확보할 수 있었다. 핵심 흐름인 회원가입은 항상 성공을 보장하고, 부가 기능인 마일리지는 비동기로 처리된다. 장애가 나더라도 재처리와 관리자 수단으로 복구할 수 있다.

예외 상황에도 유연하게

예외 대응은 다음과 같이 설계했다.

  • 이벤트 처리 중 실패하면 FAILED 상태로 표시하고 retryCount를 올려 최대 3회까지 자동 재시도한다.
  • 그 이상 실패하면 관리자 API로 강제 재처리할 수 있다.
  • 이후 Slack이나 이메일로 알림을 보낼 수 있도록 확장 여지를 열어 두었다.
  • 중복 적립을 막기 위해 tb_mileage_history 또는 tb_mileage_outbox에 유니크 키 제약이나 비즈니스 키 검사를 두었다.

전체 구성 흐름

1. 트랜잭션 안에서 핵심 도메인 처리

회원가입 시 UserEntity를 저장한 직후 ApplicationEventPublisher로 이벤트를 발행한다.

java
eventPublisher.publishEvent(new UserSignedUpEvent(savedUser.getUserId()));

2. @TransactionalEventListener (AFTER_COMMIT)

TransactionPhase.AFTER_COMMIT 설정으로, 메인 트랜잭션이 성공적으로 커밋된 뒤에만 실행된다. 이벤트 핸들러는 정책 테이블을 조회하고 Outbox 테이블에 이벤트 정보를 저장한다.

java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleUserSignedUp(UserSignedUpEvent event) { // 정책 조회 및 Outbox 저장 }

커밋 이후에 Outbox를 저장하는 이 방식에는 한계가 있다. 뒤의 돌아보며: 지금 구조의 한계에서 다시 다룬다.

3. Outbox 테이블 구조

필드명설명
idOutbox 이벤트 고유 ID
userId대상 사용자 ID
policyId적용된 마일리지 정책 ID
eventType이벤트 타입 (회원가입 등)
status상태값 (PENDING, SUCCESS, FAILED)
retryCount재시도 횟수
createdAt생성 시각
lastAttemptAt마지막 시도 시각

4. Outbox Processor (스케줄러)

일정 주기로 PENDING 또는 FAILED 상태의 이벤트를 조회한다. 정책에 따라 마일리지를 적립한 뒤 SUCCESS 또는 FAILED로 상태를 갱신한다.

java
@Scheduled(fixedDelay = 60000) public void processOutboxEvents() { // outbox 테이블에서 PENDING 또는 FAILED 상태 조회 후 earnMileage 호출 }

5. 마일리지 적립 (History + Total)

REQUIRES_NEW 트랜잭션으로 독립 실행되며, 마일리지 적립 이력을 저장하고 총합을 갱신한다.

java
@Transactional(propagation = Propagation.REQUIRES_NEW) public void earnSignupMileage(...) { // tb_mileage_history insert // tb_mileage_total update }

유의사항 및 장애 대응

Outbox가 저장되지 않는 경우가 있다. @TransactionalEventListener가 다른 트랜잭션 컨텍스트에서 동작하지 않을 수 있기 때문이다. 이를 해결하려면 Listener를 별도 빈으로 관리하고, 필요하면 flush() 사용을 고려해야 한다. 단, flush() 호출 시점에 따라 트랜잭션 커밋 전에 DB에 반영될 수 있으니 성능에 주의해야 한다.

또한 @Async + AFTER_COMMIT 조합은 예상치 못한 비동기 이슈를 일으킬 수 있으므로, 설정은 최소한으로 두고 동시성 테스트를 꼭 해야 한다.

재시도와 장애 대응으로는 재시도 횟수를 MAX_RETRY = 3으로 제한하고, 실패 기록은 MileageOutboxStatus.FAILED 상태로 유지한다. 이후 Slack, 이메일, 관리자 대시보드 알림을 연동해 모니터링을 강화할 예정이다.

돌아보며: 지금 구조의 한계

글을 정리하면서 다시 보니, 지금 구조에는 짚고 넘어가야 할 빈틈이 하나 있다.

위의 2단계에서는 Outbox 저장을 AFTER_COMMIT 리스너에서 하고 있다. 즉 회원가입 트랜잭션이 이미 커밋된 뒤, 별도의 작업으로 Outbox를 저장한다. 그래서 아래 '어떻게 작동하는가'에서 말하는 "Outbox 기록이 본 트랜잭션에 포함된다"는 조건을 엄밀히는 만족하지 못한다.

회원 저장 → 커밋 완료 → (이 사이에 서버가 죽거나 DB 오류가 나면?) → Outbox 저장

커밋과 Outbox 저장 사이에서 장애가 나면 회원은 가입됐는데 마일리지 이벤트는 사라진다. 스케줄러가 재처리할 기록 자체가 없으니 재시도도 할 수 없다.

Outbox 패턴의 핵심인 '같은 트랜잭션에 기록'을 지키려면, Outbox 저장을 커밋 전, 같은 트랜잭션 안으로 옮기면 된다.

java
// 커밋 직전, 회원가입과 같은 트랜잭션 안에서 Outbox 저장 @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handleUserSignedUp(UserSignedUpEvent event) { mileageOutboxRepository.save(MileageOutbox.pending(event.userId(), policyId)); }

이렇게 하면 회원 저장과 Outbox 저장이 함께 커밋되거나 함께 롤백된다. 실제 적립은 지금처럼 스케줄러가 REQUIRES_NEW 트랜잭션으로 처리하니, 마일리지 적립이 실패해도 회원가입에는 여전히 영향이 없다.

결론

Transactional Outbox Pattern으로 업무 로직과 이벤트 처리의 책임을 분리해 트랜잭션 일관성을 확보하고, 마일리지 적립의 안정성과 확장성을 높였다. 비동기 처리 덕분에 이벤트 처리 구조도 유연하게 가져갈 수 있었다.

그럼 왜 Transactional Outbox Pattern을 쓰는가?

위의 이유만으로는 Outbox 패턴을 채택한 의도를 다 설명하기 부족한 것 같아, 조금 더 적어 보려고 한다.

먼저 트랜잭션의 정의부터 짚고 넘어가자. 트랜잭션의 가장 큰 특징은 일관성(Consistency)이다.

이게 왜 중요하냐면, 서비스 로직(회원가입, 주문 생성 등)과 외부 시스템 연동·비동기 작업(마일리지 적립 등)은 같은 트랜잭션으로 처리할 수 없기 때문이다. 서비스 로직은 성공했는데 마일리지 적립이 실패한다면 데이터 불일치가 생긴다.

그래서 "마일리지를 적립해 줘야 한다"는 의도를 로그처럼 기록해 두는 것이 핵심이다.

어떻게 작동하는가?

Outbox 테이블을 별도로 만들어 DB가 일종의 큐 역할을 하게 한다. 중요한 점은 Outbox에 레코드를 남기는 작업이 본 트랜잭션에 포함된다는 것이다.

트랜잭션이 커밋되면 Outbox 테이블에도 데이터가 남는다. 트랜잭션이 롤백되면 Outbox 기록도 남지 않는다. → 일관성 보장

스케줄러나 백그라운드 프로세스가 일정 주기로 Outbox 테이블을 읽어 처리한다. 상태값(PENDING → SUCCESS / FAILED)으로 관리하며, 마일리지 적립 같은 작업은 별도 트랜잭션(REQUIRES_NEW)으로 처리한다. 실패하면 retryCount를 올리고, 로그 기록과 알림 연동도 가능하다.

정리하면

Transactional Outbox Pattern은 "내가 뭔가 해야 한다는 약속"을 DB에 안전하게 기록해 두고, 실제 행동은 나중에 별도의 안정적인 환경에서 처리하는 것이다.

마치 할 일(To-do)을 노트에 적어 두고, 1분마다 한 번씩 확인하며 처리하는 느낌이다.

참고 자료