
이번 주차는 저번주에 작성했던 Redis를 이용한 실시간 집계처리에 대하여 트랜젝션이 완료된 이후에 정합성이 보장되도록 집계하는 것과 외부 Mock API를 동시에 호출하여 주요 로직과 분리를 보장하는 내용을 실제로 구현하는 것이었습니다.
또한, 분산 트렌잭션을 적용해야하는 상황을 위한 이벤트 기반 Saga 패턴을 설계해보는 과정이었습니다.
주문 생성 시 핵심 트랜잭션과 부가기능(집계, 알림 등)을 분리하여 안정성과 가독성 확보
OrderFacade 내에서 트랜잭션 단위로 처리OrderCompletedEvent) 발행TopProduct) 와 외부 집계(Mock External) 가 동시에 동작하도록 구현OrderFacade.placeOrder()OrderCompletedEvent 발행TopProductEventHandlerOrderCompletedEvent 수신OrderExternalEventHandler(Mock External)OrderCompletedEvent 수신Redis 저장소(OrderExternalRedisRepository)를 사용public record OrderCompletedEvent(Long orderId, Long userId, List<OrderLineSummary> lines) {
}
@Component
@RequiredArgsConstructor
public class TopProductEventHandler {
private final TopProductService topProductService;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCompletedEvent event) {
topProductService.recordOrdersAsync(event.orderId(), event.lines());
}
}
AFTER_COMMIT 에 의해 트랜젝션이 커밋 되고 나서 이벤트 동작으로 구현OrderCompletedEvent를 수집하여 TopProductService에 집계 요청@Service
@RequiredArgsConstructor
public class TopProductService {
/* ... */
@Async
public void recordOrdersAsync(Long orderId, List<OrderLineSummary> lines) {
if (redisRepository.isAlreadyIssued(orderId)) {
return;
}
try {
redisRepository.recordOrders(lines.stream().map(TopProductMapper::toRecord).toList());
redisRepository.markIssued(orderId);
} catch (Exception ignored) {
}
}
/* ... */
}
TopProductRedisRepository.java
@Repository
@RequiredArgsConstructor
public class TopProductRedisRepository {
/* ... */
private static final int TTL_DAYS = 4;
private static final String PRODUCT_RANKING_PREFIX = "RANKING:PRODUCT:";
private static final String ISSUED_ORDER_SET = PRODUCT_RANKING_PREFIX + "ISSUED";
private String getDailyKey(LocalDate date) {
return PRODUCT_RANKING_PREFIX + date.format(FORMATTER);
}
public boolean isAlreadyIssued(Long orderId) {
return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(ISSUED_ORDER_SET, orderId.toString()));
}
public void markIssued(Long orderId) {
redisTemplate.opsForSet().add(ISSUED_ORDER_SET, orderId.toString());
redisTemplate.expire(ISSUED_ORDER_SET, Duration.ofDays(TTL_DAYS));
}
public void recordOrders(List<TopProductRecord> items) {
String key = getDailyKey(LocalDate.now());
for (TopProductRecord item : items) {
redisTemplate.opsForZSet().incrementScore(key, item.productId(), item.soldQty());
}
redisTemplate.expire(key, Duration.ofDays(TTL_DAYS));
}
/* ... */
}
isAlreadyIssued: SET 자료구조의 중복 발급 확인 함수markIssued: 상품별 수량 증가 후 완료된 주문 기록, TTL은 집계함수와 동일하게 설정recordOrders: 상품 배열 기반으로 날짜별 판매량 을 ZSET에 기록OrderExternalEventHandler.java
OrderExternalEventHandler 외부 Mock API 핸들러: 호출 결과를 Redis에 기록
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderExternalEventHandler {
private final OrderExternalRedisRepository redisRepository;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCompletedEvent event) {
log.info("[MOCK-EXTERNAL] 주문 완료 이벤트 전송: orderId={}, items={}",
event.orderId(), event.lines().size());
mockSendToExternalSystem(event);
}
private void mockSendToExternalSystem(OrderCompletedEvent event) {
log.debug("[MOCK-EXTERNAL] 외부 MOCK 이벤트 송신 - orderId={}, userId={}",
event.orderId(), event.userId());
redisRepository.recordSent(event.orderId(), event.userId());
}
}
OrderExternalRedisRepository.java
@Repository
@RequiredArgsConstructor
public class OrderExternalRedisRepository {
private final StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "EXTERNAL:ORDER:";
public void recordSent(Long orderId, Long userId) {
String key = KEY_PREFIX + orderId;
String value = "sentAt=" + LocalDateTime.now() + ", userId=" + userId;
redisTemplate.opsForList().rightPush(key, value);
}
public Long countRecords(Long orderId) {
return redisTemplate.opsForList().size(KEY_PREFIX + orderId);
}
public void clear(Long orderId) {
redisTemplate.delete(KEY_PREFIX + orderId);
}
}
Orchestration: Orchestrator가 모든 흐름을 관리
Orchestrator가 과도한 책임을 가지며, 확장성/유연성이 떨어질 수 있음Choreography: 각 도메인이 이벤트를 발행/구독하며 Saga를 진행
분산 환경을 고려한 도메인별 책임의 트랙젠션 로직 설계
현재 비즈니스 로직 중 다수의 도메인이 모두 연계되어 처리되는 상태를 단일 트랜젝션에서 관리한다면 일관성의 보장은 쉽지만, 확장성이 떨어진다.
분산 트
Order는 생성되었지만 Product에서 재고 차감 실패Coupon 사용 처리 후 Balance 차감 실패 → 쿠폰만 소모된 상태StockReservedEvent보다 CouponUsedEvent가 먼저 도착하거나, 중복 전달되는 경우Command)는 즉시 반영되었으나, 조회(Query)는 이벤트 동기화 지연으로 잠시 다른 상태 보일 수 있음Balance 차감 실패 시 → 이미 차감된 재고/쿠폰을 원복해야 함OrderCommandService가 사용자 요청을 받아 Order 엔티티를 OrderStatus.DRAFT로 생성OrderSagaRepository에 초기 상태를 기록한 뒤 OrderRequestedEvent 발행DRAFT 상태로 주문을 커밋/저장하고, 이를 자체 OrderDraftedEvent 감지하여 Saga 저장소에 저장OrderSagaRepository에 저장하는 타이밍이 어려웠습니다.PENDING 앞에 상태를 하나 더 둬서 다른 도메인이 호출할 Saga 저장소가 확실히 존재하도록 단계를 나눴습니다.PENDING으로 다른 도메인에 전이sequenceDiagram
participant Order
participant Saga
participant Product
participant Coupon
participant Balance
Order->>Saga: OrderDraftedEvent
Saga->>Order: OrderRequestedEvent
Saga->>Product: OrderRequestedEvent
Saga->>Coupon: OrderRequestedEvent
Product-->>Saga: StockReservedEvent
Coupon-->>Saga: CouponUsedEvent
Saga->>Balance: OrderCalculatedEvent
Balance-->>Saga: BalanceDeductedEvent
Saga->>Order: OrderCompletedEvent
OrderRequestedEvent 발행
Product 도메인: 재고 확인 및 차감 시도 StockReservedEvent, PriceQuotedEventStockReserveFailedEvent 발행Coupon 도메인: 쿠폰이 있으면 유효성 확인 및 사용 처리 CouponUsedEvent 발행CouponUseFailedEvent 발행CouponSkippedEvent 발행 (혹은 0원으로 성공 처리)Balance 도메인: 총액 및 차감액을 바탕으로 잔액 차감PriceQuotedEvent, CouponUsedEvent/CouponSkippedEvent)를 기반으로 최종 결제 금액 계산OrderSagaHandler가 발행한 OrderCalculatedEvent에 의해 잔액 차감 수행BalanceDeductedEvent 발행BalanceDeductionFailedEvent 발행OrderSagaHandler: 위 이벤트를 구독하여 Saga 상태를 갱신
Product → StockReservedEventPriceQuotedEventCoupon → CouponUsedEvent CouponSkippedEventBalance → BalanceDeductedEvent 수집OrderCalculatedEvent 발행 (StockReservedEvent, CouponUsedEvent 수집 시)Balance → BalanceDeductedEvent 수집OrderCompletedEvent 발행OrderEventHandler → OrderStatus.PAID 상태 변경 stateDiagram-v2
[*] --> Draft
Draft --> Pending: OrderRequested
Pending --> Paid: BalanceDeducted
Pending --> Failed: StockReserveFailed / CouponUseFailed / BalanceDeductionFailed
Failed --> Restored: CompensationHandlers
Restored --> Cancelled
Product: StockReserveFailedEvent → 이미 차감된 재고 복구(트랜젝션에 의한 롤백), 쿠폰 사용 원복Coupon: CouponUseFailedEvent → 쿠폰 사용 상태 되돌림(트랜젝션에 의한 롤백), 상품 재고 원복Balance: BalanceDeductionFailedEvent → 차감된 금액 환불(트랜젝션에 의한 롤백), 쿠폰 사용 원복, 상품 재고 원복orderId로 SagaState를 조회해 해당 도메인이 실제로 SUCCESS였던 경우에만 복구@DistributedLock 적용productId 기준 → reserveStock / restoreStockcouponId 기준 → useCoupon / restoreCouponuserId 기준 → deductBalance / restoreBalanceCommand 부분만 사용발급된 Saga의 현재 상태를 추적하기 위한 저장소, JPA 혹은 Redis에 구현 가능
모든 도메인이 이벤트 기반으로 움직이기 때문에, 영속성을 강하게 가져가기 위해 JPA 기반으로 구현
OrderSagaState: 필요 상태 정보
orderId: 최초 DRAFT의 상태의 주문 정보 PKuserId: 사용자 IDitems: 사용자가 요청한 item 배열productId: 상품 번호quantity: 상품 수량couponId: 쿠폰 사용 시 쿠폰 번호SUCCESS, FAILED, CANCELED, RESTOREDproductReserved: 상품 재고 차감 수행, 총액 반환 여부couponApplied: 쿠폰 사용 여부balanceCharged: 잔액 차감 여부subTotalAmount: 상품 총액 원가discountAmount: 쿠폰 할인액totalAmount: 최종 금액OrderSagaRepository: Saga 상태 저장소
OrderSagaEventStatus (enum: PENDING, SUCCESS, FAILED, CANCELED, RESTORED)
OrderSagaState: 주문 Saga 상태값
주요 코드@Entity
@Table(name = "order_saga_state")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class OrderSagaState {
@Id
private Long orderId; // 주문 ID (Order 엔티티 PK와 동일)
private Long userId;
@ElementCollection
@CollectionTable(name = "order_saga_items", joinColumns = @JoinColumn(name = "order_id"))
private List<OrderSagaItem> items;
private Long couponId;
// 각 도메인 상태
@Enumerated(EnumType.STRING)
private OrderSagaEventStatus productReserved;
@Enumerated(EnumType.STRING)
private OrderSagaEventStatus couponApplied;
@Enumerated(EnumType.STRING)
private OrderSagaEventStatus balanceCharged;
// 금액 정보
private Integer subTotalAmount;
private Integer discountAmount;
private Integer totalAmount;
public void markProductReservedSuccess(int subTotalAmount) {
this.productReserved = OrderSagaEventStatus.SUCCESS;
this.subTotalAmount = subTotalAmount;
}
public void markCouponAppliedSuccess(int discountAmount) {
this.couponApplied = OrderSagaEventStatus.SUCCESS;
this.discountAmount = discountAmount;
}
public void markBalanceChargedSuccess(int totalAmount) {
this.balanceCharged = OrderSagaEventStatus.SUCCESS;
this.totalAmount = totalAmount;
}
public void markFailedDomain(String domain) {
switch (domain) {
case "PRODUCT" -> this.productReserved = OrderSagaEventStatus.FAILED;
case "COUPON" -> this.couponApplied = OrderSagaEventStatus.FAILED;
case "BALANCE" -> this.balanceCharged = OrderSagaEventStatus.FAILED;
}
}
public boolean isReadyForCalculation() {
return productReserved == OrderSagaEventStatus.SUCCESS
&& couponApplied == OrderSagaEventStatus.SUCCESS
&& subTotalAmount != null
&& discountAmount != null;
}
}
OrderDraftedEvent: 초안 생성됨 이벤트, 자기 참조용OrderRequestedEvent: 주문 요청됨 이벤트, 각 도메인 참조용OrderCalculatedEvent: [추가] 상품 총액과 쿠폰 차감액이 성공적으로 반환되었을 때, OrderSagaHandler.tryTriggerOrderCalculated에 의해 발행 OrderCompletedEvent: 주문 완료 이벤트OrderFailedEvent: 주문 실패 이벤트StockReservedEvent: 재고 차감 완료 이벤트PriceQuotedEvent: 주문 총액 이벤트(재고 차감 이벤트와 통합 가능) - 제외StockReserveFailedEvent: 재고 차감 실패 이벤트 CouponUsedEvent: 쿠폰 사용 이벤트 + 할인액 반환CouponSkippedEvent: 쿠폰 미사용 이벤트(쿠폰 0원 사용 이벤트로 도 가능 할 것으로 보임) - 제외CouponUseFailedEvent: 쿠폰 사용 실패 이벤트BalanceDeductedEvent: 잔액 차감 이벤트BalanceDeductionFailedEvent: 잔액 차감 실패 이벤트OrderSagaHandler: Saga의 상태 관리 메타 데이터 및 부가 정보 관리
주요 코드@Slf4j
@Component
@RequiredArgsConstructor
public class OrderSagaHandler {
private final ApplicationEventPublisher publisher;
private final OrderSagaRepository sagaRepository;
// 주문 조안 생성 시 감지 후 저장
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderDraftedEvent event) {
// 초안 저장 시 초기 Saga 상태 저장
OrderSagaState sagaState = OrderSagaState.builder()
.orderId(event.orderId())
.userId(event.userId())
.items(event.items())
.couponId(event.couponId())
.productReserved(OrderSagaEventStatus.PENDING)
.couponApplied(OrderSagaEventStatus.PENDING)
.balanceCharged(OrderSagaEventStatus.PENDING)
.build();
sagaRepository.save(sagaState);
log.info("[SAGA] Drafted order={}, user={} saga initialized", event.orderId(), event.userId());
// Saga 저장소 생성 후 각 도메인별 주문 요청 이벤트 발행
publisher.publishEvent(
new OrderRequestedEvent(event.orderId(), event.userId(), event.items(), event.couponId())
);
}
private void tryTriggerOrderCalculated(OrderSagaState saga) {
if (saga.isReadyForCalculation()) {
int total = saga.getSubTotalAmount() - saga.getDiscountAmount();
publisher.publishEvent(new OrderCalculatedEvent(
saga.getOrderId(),
saga.getUserId(),
total,
saga.getItems(),
saga.getCouponId()
));
log.info("[SAGA] order={} calculation prepared → total={}", saga.getOrderId(), total);
}
}
// 재고 차감 상태 업데이트
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(StockReservedEvent event) {
sagaRepository.findById(event.orderId()).ifPresent(saga -> {
saga.markProductReservedSuccess(event.subTotalAmount());
sagaRepository.save(saga);
log.info("[SAGA] order={} product reserved success, subtotal={}", event.orderId(), event.subTotalAmount());
tryTriggerOrderCalculated(saga);
});
}
// 쿠폰 사용 상태 업데이트
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(CouponUsedEvent event) {
sagaRepository.findById(event.orderId()).ifPresent(saga -> {
saga.markCouponAppliedSuccess(event.discountAmount());
sagaRepository.save(saga);
log.info("[SAGA] order={} coupon used success, discount={}", event.orderId(), event.discountAmount());
tryTriggerOrderCalculated(saga);
});
}
// 잔액 차감 완료 상태 업데이트
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(BalanceDeductedEvent event) {
sagaRepository.findById(event.orderId()).ifPresent(saga -> {
saga.markBalanceChargedSuccess(event.totalAmount());
sagaRepository.save(saga);
log.info("[SAGA] order={} balance deducted success, total={}", event.orderId(), event.totalAmount());
});
}
// 상품 재고 차감 실패 상태 업데이트
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(StockReserveFailedEvent event) {
sagaRepository.findById(event.orderId()).ifPresent(saga -> {
saga.markFailedDomain("PRODUCT");
sagaRepository.save(saga);
log.warn("[SAGA] order={} product reserve failed, reason={}", event.orderId(), event.reason());
});
}
// 쿠폰 사용 실패 상태 업데이트
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(CouponUseFailedEvent event) {
sagaRepository.findById(event.orderId()).ifPresent(saga -> {
saga.markFailedDomain("COUPON");
sagaRepository.save(saga);
log.warn("[SAGA] order={} coupon use failed, reason={}", event.orderId(), event.reason());
});
}
// 잔액 차감 실패 상태 업데이트
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(BalanceDeductionFailedEvent event) {
sagaRepository.findById(event.orderId()).ifPresent(saga -> {
saga.markFailedDomain("BALANCE");
sagaRepository.save(saga);
log.warn("[SAGA] order={} balance deduction failed, reason={}", event.orderId(), event.reason());
});
}
}
flowchart TD
A[OrderCommandService] -->|OrderDraftedEvent| B[OrderSagaHandler]
B -->|OrderRequestedEvent| C[ProductCommandService]
B -->|OrderRequestedEvent| D[CouponCommandService]
C -->|StockReservedEvent| B
D -->|CouponUsedEvent| B
B -->|OrderCalculatedEvent| E[BalanceCommandService]
E -->|BalanceDeductedEvent| B
B -->|OrderCompletedEvent| F[외부 핸들러]