https://pear-c.tistory.com/125
위 프로젝트를 토대로, 불편했던 점들이나 아쉬웠던 점들을 하나씩 메꿔가며 공부해보려 한다.
그 중, 첫 번째로 결제 프로세스를 먼저 뜯어 고쳤다.
배경 - 왜 리팩토링 했나?
결제 기능 구현 후, 정상적으로 동작하는 것을 확인한 뒤로는 신경을 쓰지 않았던게 패착이었다.
Toss Payments API 를 연동한 단순 결제는 정상적으로 이루어졌는데, 결제 완료 후 회원이 사용한 포인트를 차감/적립 하는 과정에서, timeout 이 발생하면, 결제가 정상적으로 완료되지 않는 상황이 발생했다.
해당 원인을 찾으며 디버깅 해보던 중, 아래 77줄에서 timeout이 발생하여 다음 코드로 진행되지 않는 것을 확인했다.
해당 코드는 결제 승인 시에, 주문 상태를 [결제 대기 → 결제 완료] 로 업데이트 요청을 보내는 역할인데, 내부적으로 주문 서비스 → 회원 서비스로 [사용된 포인트 차감] 요청도 처리하고 있던 것이다.
본 프로젝트는 Spring Cloud 기반 MSA 환경인데, 내부 서비스는 FeignClient 즉, 모든 요청이 동기 처리로 진행된다.
따라서, 위 과정에서도 결제 → 주문 상태 업데이트 → 회원 조회 → 포인트 차감 의 요청이 모든 응답이 올 때까지 대기하다 보니, timeout 이 발생한 것이라고 유추해볼 수 있었다.
문제 현상
-
payment-service
→order-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에서는 동기 호출 최소화와 비동기 이벤트가 안정성의 핵심.
- 멱등성·관측성·재처리 설계가 있어야 “한 번의 오류”가 신뢰 저하로 번지지 않는다.
- 작은 설계 전환이 사용자 경험과 운영 효율을 크게 바꾼다.