https://pear-c.tistory.com/125

위 프로젝트를 토대로, 불편했던 점들이나 아쉬웠던 점들을 하나씩 메꿔가며 공부해보려 한다.


그 중, 첫 번째로 결제 프로세스를 먼저 뜯어 고쳤다.

배경 - 왜 리팩토링 했나?

결제 기능 구현 후, 정상적으로 동작하는 것을 확인한 뒤로는 신경을 쓰지 않았던게 패착이었다.

Toss Payments API 를 연동한 단순 결제는 정상적으로 이루어졌는데, 결제 완료 후 회원이 사용한 포인트를 차감/적립 하는 과정에서, timeout 이 발생하면, 결제가 정상적으로 완료되지 않는 상황이 발생했다.

해당 원인을 찾으며 디버깅 해보던 중, 아래 77줄에서 timeout이 발생하여 다음 코드로 진행되지 않는 것을 확인했다.

해당 코드는 결제 승인 시에, 주문 상태를 [결제 대기 결제 완료] 로 업데이트 요청을 보내는 역할인데, 내부적으로 주문 서비스 회원 서비스로 [사용된 포인트 차감] 요청도 처리하고 있던 것이다.

본 프로젝트는 Spring Cloud 기반 MSA 환경인데, 내부 서비스는 FeignClient 즉, 모든 요청이 동기 처리로 진행된다.

따라서, 위 과정에서도 결제 주문 상태 업데이트 회원 조회 포인트 차감 의 요청이 모든 응답이 올 때까지 대기하다 보니, timeout 이 발생한 것이라고 유추해볼 수 있었다.


문제 현상

  • payment-serviceorder-service 결제 완료 콜백은 성공했지만,
    order-service에서 user-service 호출이 ReadTimeout으로 터지며 전체 트랜잭션 흐름이 막힘.

  • 장애가 연쇄 전파되고, 재시도 중복 호출로 포인트 중복/불일치 위험이 커짐.

  • 운영 로그 패턴: feign.RetryableException: Read timed out + 주문 상태는 변경됐는데 포인트 반영 지연.

원인 분석

  • 강결합 구조: 동기 호출 한 번 실패가 상위 흐름까지 중단.

  • 오류 격리 부재: user-service 이슈가 order-service SLO에 직접 영향.

  • 멱등성/리트라이 전략 부족: 재시도 시 중복 반영 가능성.

  • 관측성 부족: 어디서 병목인지 추적이 느림.


1차 대응 (임시)

  • Feign 타임아웃 상향, 예외 캐치로 결제 흐름은 우선 통과:
  • 한계: 지연/장애 전파 및 데이터 최종일관성 문제는 여전.

근본 해결 (비동기 이벤트 전환)

  • RabbitMQ 도입: order-service는 결제 완료 시 포인트 차감 이벤트 발행, user-service소비하여 처리.

  • 효과:

    • 장애 격리: user-service 이슈가 order 응답 시간에 영향 X
    • 재처리 용이: 큐 기반 재시도/보류/모니터링 가능
    • 결합도 감소: 서비스 독립 배포/스케일링

구현 요약

이벤트 스키마

@Getter  
@NoArgsConstructor  
@AllArgsConstructor  
public class PointUsedEvent {  
    private Long memberId;  
    private BigDecimal usedPoint;  
}

order-service (발행)

@Configuration  
public class RabbitPointConfig {  
    public static final String POINT_EXCHANGE = "point.exchange";  
  
    @Bean  
    public TopicExchange pointExchange() {  
        return new TopicExchange(POINT_EXCHANGE);  
    }  
  
    @Bean  
    public Jackson2JsonMessageConverter pointJacksonConverter() {  
        return new Jackson2JsonMessageConverter();  
    }  
  
    @Bean  
    public RabbitTemplate pointRabbitTemplate(ConnectionFactory connectionFactory,  
                                              Jackson2JsonMessageConverter pointJacksonConverter) {  
        RabbitTemplate template = new RabbitTemplate(connectionFactory);  
        template.setMessageConverter(pointJacksonConverter);  
        return template;  
    }  
}
@Slf4j  
@Service  
@RequiredArgsConstructor  
public class PointEventPublisher {  
    private final RabbitTemplate rabbitTemplate;  
    private static final String EXCHANGE = "point.exchange";  
    private static final String ROUTING_KEY = "point.used";  
  
    public void sendPointUsedEvent(PointUsedEvent event) {  
        log.info("📤 포인트 사용 이벤트 발행 - memberId={}, usedPoint={}", event.getMemberId(), event.getUsedPoint());  
        rabbitTemplate.convertAndSend(EXCHANGE, ROUTING_KEY, event);  
    }  
}

발행 조건: 결제 완료 상태 && usedPoint > 0

user-service (소비)

@Configuration  
public class RabbitPointConfig {  
    public static final String POINT_EXCHANGE = "point.exchange";  
    public static final String POINT_QUEUE = "point.used.queue";  
    public static final String POINT_ROUTING_KEY = "point.used";  
  
    @Bean  
    public TopicExchange pointExchange() {  
        return new TopicExchange(POINT_EXCHANGE);  
    }  
  
    @Bean  
    public Queue pointUsedQueue() {  
        return new Queue(POINT_QUEUE);  
    }  
  
    @Bean  
    public Binding pointBinding() {  
        return BindingBuilder.bind(pointUsedQueue()).to(pointExchange()).with(POINT_ROUTING_KEY);  
    }  
  
    @Bean(name = "pointJacksonConverter")  
    public Jackson2JsonMessageConverter pointJacksonConverter() {  
        return new Jackson2JsonMessageConverter();  
    }  
  
    @Bean(name = "pointListenerContainerFactory")  
    public SimpleRabbitListenerContainerFactory pointListenerContainerFactory(  
            ConnectionFactory connectionFactory,  
            @Qualifier("pointJacksonConverter") Jackson2JsonMessageConverter pointJacksonConverter) {  
  
        SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();  
        factory.setConnectionFactory(connectionFactory);  
        factory.setMessageConverter(pointJacksonConverter);  
        return factory;  
    }  
}
@Slf4j  
@Component  
@RequiredArgsConstructor  
public class PointEventConsumer {  
  
    private final PointManager pointManager;  
  
    @RabbitListener(queues = RabbitPointConfig.POINT_QUEUE,  
            containerFactory = "pointListenerContainerFactory")  
    public void handlePointUsedEvent(PointUsedEvent event) {  
        log.info("📨 PointUsedEvent 수신 - memberId={}, usedPoint={}", event.getMemberId(), event.getUsedPoint());  
  
        UsedPointRequest request = UsedPointRequest.builder()  
                        .memberId(event.getMemberId())  
                        .usedPoint(event.getUsedPoint())  
                        .build();  
  
        pointManager.processUsedPoint(request);  
    }  
}

결과

  • 결제 응답 시간 안정화, 장애 격리 달성.
  • 포인트 처리 실패 시에도 주문 흐름 지속, 사후 재처리 가능.
  • 재현→원인→영구 해결까지 트러블슈팅 루프 확립.

배운 점

  • MSA에서는 동기 호출 최소화비동기 이벤트가 안정성의 핵심.
  • 멱등성·관측성·재처리 설계가 있어야 “한 번의 오류”가 신뢰 저하로 번지지 않는다.
  • 작은 설계 전환이 사용자 경험과 운영 효율을 크게 바꾼다.