본문 바로가기

카테고리 없음

[Spring boot] Transactional Outbox Pattern 으로 mileage 적립 구현해보기

반응형

전체 구성 흐름

1. 트랜잭션 내 핵심 도메인 처리 (회원가입 / 주문 저장 등)

  • 예시: 회원가입 시 UserEntity를 저장한 직후
  • 이후 ApplicationEventPublisher.publishEvent(...)로 이벤트 발행
eventPublisher.publishEvent(new UserSignedUpEvent(savedUser.getUserId()));

 

2.@TransactionalEventListener (AFTER_COMMIT)

  • TransactionPhase.AFTER_COMMIT 설정으로, 메인 트랜잭션이 성공적으로 커밋된 후 실행됨
  • 이벤트 핸들러는 정책 테이블을 조회하고, Outbox 테이블에 이벤트 정보를 저장
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleUserSignedUp(UserSignedUpEvent event) {
    // 정책 조회 및 Outbox 저장
}

 

3. Outbox 테이블 구조

필드명 설명

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

 

4. Outbox Processor (Scheduler 기반)

  • 일정 주기로 PENDING 또는 FAILED 상태의 이벤트를 조회
  • 정책 기반으로 마일리지 적립 처리 후, SUCCESS 혹은 FAILED로 상태 갱신
@Scheduled(fixedDelay = 60000)
public void processOutboxEvents() {
    // outbox 테이블에서 PENDING or FAILED 상태 조회 후 earnMileage 호출
}

 

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

  • REQUIRES_NEW 트랜잭션으로 독립 실행
  • 마일리지 적립 이력 저장 후 총합 업데이트
@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, Email, 관리자 대시보드 알림 연동 필요

 

결론

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분마다 한 번씩 체크하며 처리”하는 느낌이다. 

반응형