서비스 간 통신 방식 고민 - ekdan38/HotDealService GitHub Wiki
-
RestTemplate
- Http 요청 작성
- 직렬화, 역직렬화 고려
- 유지 보수가 불편
-
FeignClient
- 선언형 인터페이스 기반
- 응답 매핑 자동 처리
- 가독성이 좋고 유지보수 용이
결론 : 선언형 인터페이스 기반이고 직관적인 FeignClient 선택
동기 처리
- FeignClient를 사용하여 처리 가능
- ex) OrderService -> HotdealService(재고 점유 요청)
비동기 처리
- 결제 결과에 따른 서비스 간 연쇄 통신을 비동기 처리
- Kafka와 같은 메시지 큐 사용을 검토했으나, 다음가 같은 이유로 FeignClient 선택
Kafka 미도입 이유
- 인프라 추가 구성, 복잡도 증가
- 현재 규모의 시스템에서 FeignClient를 이벤트 기반으로 처리하면 충분할 것이라 판단
주문 흐름 처리에서 비동기로 처리할 수 있는 로직(결제 결과에 따른 서비스들간의 연쇄적 통신)에서 Kafka 도입을 고민했지만, 다음과 같은 이유로 Feign 사용
최종 선택 Outbox 패턴 + FeignClient
- 이벤트를 Outbox 테이블에 저장
- 스케쥴러가 주기적으로 Outbox 조회 후 이벤트 발행 (status = IN_PROGRESS)
- 각 이벤트는 중복된 작업 처리 방지 및 비동기 처리
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
- 결과에 대한 응답, CircuitBreaker + Retry로 인한 실패 재시도 환경 구성 및 횟수 기록
[주문 생성]
-
OrderService -> HotDealService (재고 점유 요청, 동기)
-
OrderService -> PaymentService (결제 생성 요청, Outbox 기반 비동기)
[결제 처리]
-
PaymentService -> OrderService (결제 결과 반영, Outbox 기반 비동기)
-
OrderService -> HotDealService (재고 확정 처리, Outbox 기반 비동기)
-
OrderService -> UserService (장바구니 정리, Outbox 기반 비동기)
1. Order 처리 및 Outbox 생성
@Transactional
public OrderUpdateResponseDto updateOrderAndDelivery(OrderUpdateRequestDto requestDto) {
// 1. order 조회 (delivery fetch join) 및 검증
Order order = getOrderWithDeliveryAndValidate(requestDto);
// 2. 결제 성공 실패 처리(requestDto 로 결제 성공 유무)
// 결제 성공 처리
if (requestDto.isSuccess()){
// order Status 변경
order.updateToPaymentSuccess(LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
// user Cart 정리 outbox
UserCartOutbox userCartOutbox = UserCartOutbox.create(order.getId(), order.getUserId());
userCartOutboxEventRepository.save(userCartOutbox);
}
// 주문의 결제 실패 처리
else order.updateToPaymentFailed();
// 결제 처리 결과에 따른 재고 반영 outbox
StockConfirmOutbox stockConfirmOutbox = StockConfirmOutbox.create(order.getId(), order.getUserId(), order.getStatus());
stockConfirmOutboxRepository.save(stockConfirmOutbox);
return new OrderUpdateResponseDto(true, false);
}
2. 재고 반영 Outbox 스케쥴러
@Component
@RequiredArgsConstructor
@Transactional
@Slf4j(topic = "[StockConfirmOutboxRetryScheduler]")
public class StockConfirmScheduler {
private final StockConfirmOutboxRepository outboxRepository;
private final ApplicationEventPublisher eventPublisher;
@Scheduled(fixedDelay = 3_000) // 3초 주기
@SchedulerLock(
name = "StockConfirmScheduler",
lockAtMostFor = "10m",
lockAtLeastFor = "30s"
)
public void publishOutbox(){
// 1. outbox DB 조회(오래된 데이터부터 가져와서 순서 보장)
List<OutboxStatus> statuses = List.of(OutboxStatus.FAILED, OutboxStatus.PENDING);
List<StockConfirmOutbox> foundOutboxes = outboxRepository.findByOutboxStatusInOrderByIdAsc(statuses);
// 2. 조회된 outbox FeignClient 이벤트 발행(비동기 처리)
if(!foundOutboxes.isEmpty()){
log.info("재고 최종 반영 outbox {} 건 진행중", foundOutboxes.size());
for (StockConfirmOutbox outbox : foundOutboxes) {
// Status = IN_PROGRESS, tryCount++ 처리;
outbox.updateToInProgress();
// 이벤트 발행
eventPublisher.publishEvent(outbox);
}
}
}
}
3. 재고 반영 이벤트 처리
@Component
@RequiredArgsConstructor
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Slf4j(topic = "[StockConfirmEventListener]")
public class StockConfirmEventListener {
private final Resilience4JHotDealServiceClient hotDealServiceClient;
private final StockConfirmOutboxRepository outboxEventRepository;
// order Status 변경 후 hotdealService Feign 호출 이벤트
@Async("EventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleStockConfirmOutboxEvent(StockConfirmOutbox outbox) {
Long userId = outbox.getUserId();
String orderId = outbox.getOrderId();
OrderStatus orderStatus = outbox.getOrderStatus();
try{
// 1. OrderStatus 에 따라 재고 반영
boolean orderSuccess = orderStatus.equals(OrderStatus.PAID);
// 2. FeignClient RequestDto 생성
StockFinalizeRequestDto requestDto = new StockFinalizeRequestDto(orderId, orderSuccess);
// 3. FeignClient 호출
StockFinalizeResponseDto responseDto = hotDealServiceClient.finalizeStockReservation(requestDto);
// 4. 재고 최종 반영 성공이면, Outbox Status = sent
if(responseDto.isSuccess()) {
log.info("재고 최종 반영 성공. userId = {}, orderId = {}", userId, orderId);
outbox.updateToSent();
}
// 5. 재고 최종 반영 실패(circuitBreaker, Retry Fallback -> 재시도 대상)
else if(responseDto.isRetriable()){
log.error("재고 최종 반영 실패(CircuitBreaker OR Retry Fallback 발생). userId = {}, orderId = {}", userId, orderId);
outbox.updateToFailed();
}
}
// 6. OrderException 이면, 비지니스 로직상 의도된 예외이기 때문에 Outbox Status = skip 처리,
// 다시 예외 던져서 GlobalHandler 로 예외 응답 처리
catch (OrderException e){
log.error("재고 최종 반영 실패. 비지니스 로직상 예외 발생. userId = {}, orderId = {}", userId, orderId);
outbox.updateToSkip();
// 다시 예외 던져서 GlobalHandler 로 예외 응답 처리
throw e;
}
// 7. 예기치 못한 예외
catch (Exception e){
log.error("알 수 없는 오류 발생. userId = {}, orderId = {}, errorMessage = {}",
userId, orderId, e.getMessage());
outbox.updateToFailed();
}
// 8. 상태 변경된 Outbox 저장
outboxEventRepository.save(outbox);
}
}