가격 재계산은 이벤트 도메인에서 자주 놓치기 쉬운 부분입니다. 이벤트가 바뀌면 가격도 바뀌어야 하지만, 가격 계산 자체가 실패했다고 해서 이벤트 저장까지 실패시키는 것이 항상 좋은 선택은 아닙니다.

문제

이벤트 가격은 단일 값이 아니었습니다.

이벤트가 생성되거나 수정될 때마다 파생 가격인 EventPrice와 적용 할인 이력인 AppliedDiscount를 갱신해야 했습니다.

가장 단순한 방법은 이벤트 저장 트랜잭션 안에서 가격까지 바로 계산하는 것입니다.

transaction {
  save event
  recalculate event price
  save event price
}

하지만 이 방식은 가격 재계산 실패가 이벤트 저장 실패로 이어집니다. 가격 정책이나 외부 조건이 복잡해질수록 이벤트 생성/수정 사용자 경험이 가격 계산 로직에 강하게 묶입니다.

선택한 구조

이벤트 저장과 가격 재계산을 분리하기 위해 Outbox를 사용했습니다.

transaction {
  save event
  save outbox message
}

after commit {
  publish application event
  recalculate price async
  mark outbox completed
}

fallback {
  SQS listener reads message
  recalculate price
  mark outbox completed
}

이 구조의 목적은 두 가지입니다.

첫째, 이벤트 저장 트랜잭션 안에서는 “가격 재계산이 필요하다”는 사실만 안전하게 기록합니다.
둘째, 실제 가격 재계산은 commit 이후 비동기로 처리하고, 실패하더라도 재처리 경로를 둡니다.

왜 after-commit인가

이벤트가 아직 commit되지 않았는데 가격 재계산 worker가 먼저 읽으면 문제가 생길 수 있습니다. 그래서 트랜잭션 커밋 이후에만 async listener가 동작하도록 했습니다.

Spring에서는 transaction phase를 AFTER_COMMIT으로 두어 이 순서를 보장할 수 있습니다.

1. Event 저장
2. Outbox 저장
3. Transaction commit
4. After-commit listener 실행
5. 가격 재계산

가격 재계산은 별도 transaction으로 실행했습니다. 이렇게 하면 이벤트 저장 트랜잭션과 재계산 트랜잭션의 실패 범위가 분리됩니다.

SQS 재처리 경로

after-commit listener는 빠른 처리를 위한 경로입니다. 하지만 서버가 중간에 내려가거나 listener 실행 중 장애가 발생하면 outbox가 남을 수 있습니다. 이를 위해 SQS listener를 두었습니다.

중요한 점은 재처리 가능한 실패와 재처리해도 성공하지 않는 메시지를 구분하는 것입니다.

예를 들어 payload 자체가 잘못된 poison message라면 계속 재시도해도 성공하지 않습니다. 이런 메시지는 명시적으로 삭제하거나 별도 실패 상태로 보내야 합니다. 반대로 일시적인 DB 연결 문제나 외부 의존성 문제라면 재처리 가치가 있습니다.

가격 계산 구조

가격 정책은 데코레이터 패턴으로 분리했습니다.

BasePriceCalculator
  -> EventDiscountDecorator
  -> PositionDiscountDecorator
  -> MaxDiscountRateLimitDecorator

각 정책이 독립적으로 조합되도록 만들면, 새로운 할인 정책이 들어와도 기존 계산 흐름을 크게 흔들지 않을 수 있습니다.

특히 최대 할인율 제한은 도메인 정책에 따라 달라질 수 있습니다. 의료 이벤트, 비의료 이벤트, 기업 고객 정책이 각각 달라질 수 있기 때문에 정책을 if문 덩어리로 한 곳에 몰아넣는 대신 조합 가능한 계산 단위로 나누었습니다.

운영 효과

이 설계는 트래픽을 많이 처리했다는 지표보다, 장애 전파 경로를 줄였다는 점에서 의미가 있습니다.

비동기 처리는 “나중에 하자”가 아니라 “실패를 어떻게 볼 것인가”의 문제입니다. Outbox는 그 실패를 데이터로 남기는 가장 현실적인 방법 중 하나였습니다.

더 개선한다면

다음 단계에서는 outbox를 운영자가 관측 가능한 reliability layer로 키울 수 있습니다.

비동기 처리는 구현보다 운영이 어렵습니다. 그래서 메시지가 결국 처리되는지, 얼마나 오래 밀리는지, 어떤 메시지가 계속 실패하는지를 볼 수 있어야 합니다.