서비스 간 통신 방식 고민 - ekdan38/HotDealService GitHub Wiki

1. 서비스 간 통신 방식을 설계하면서 RestTemplate 과 FeignClient 중 어떤 방식이 적합할까?

  • RestTemplate
    • Http 요청 작성
    • 직렬화, 역직렬화 고려
    • 유지 보수가 불편
  • FeignClient
    • 선언형 인터페이스 기반
    • 응답 매핑 자동 처리
    • 가독성이 좋고 유지보수 용이

결론 : 선언형 인터페이스 기반이고 직관적인 FeignClient 선택


2. 동기, 비동기 처리

동기 처리

  • 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로 인한 실패 재시도 환경 구성 및 횟수 기록

3. 주문 처리 흐름 예시

[주문 생성]

  1. OrderService -> HotDealService (재고 점유 요청, 동기)

  2. OrderService -> PaymentService (결제 생성 요청, Outbox 기반 비동기)

[결제 처리]

  1. PaymentService -> OrderService (결제 결과 반영, Outbox 기반 비동기)

  2. OrderService -> HotDealService (재고 확정 처리, Outbox 기반 비동기)

  3. OrderService -> UserService (장바구니 정리, Outbox 기반 비동기)


4. 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);
    }
}
⚠️ **GitHub.com Fallback** ⚠️