[Springboot] 토스페이먼츠 구독 결제 - 장애 상황 대비하기 1탄 | Chaos Engineering 적용기

Hannana·2025년 10월 3일

최근 운영 중인 서비스에 구독 결제 기능을 도입했다.
토스페이먼츠 빌링키 기반 자동 결제 시스템으로
매월 정해진 시간에 자동으로 결제가 이루어지는 구조이다.

참고 자료

https://docs.tosspayments.com/resources/glossary/billing

우려되는 문제점

유저 당 고유의 빌링 키를 갖고 결제를 하기 때문에
트래픽은 사실 신경쓰지 않아도 되었으나,
한 가지 신경쓰이는 건 자동 결제가 이루어졌을 때
어떠한 변수 상황에서도 정합성이 잘 보장되느냐 였다.

이전에 금액 관련 서비스를 msa 구조에서 개발하며
데이터 정합성 이슈로 크게 골머리를 앓았던 적이 있는데
특히 서버 쪽에 문제가 생기거나 DB 트랜잭션이 롤백될 경우
최종적으로 금액이 누락되는 게 치명적이었기 때문에
실제 금액이 오고 가는 결제 서비스는 이런 일이 없게 해야 했다.

  • 원래 코드
// 1단계: 토스 결제
payResp = tossPaymentsClient.payWithBillingKey(...).block();

// 2단계: DB 저장
Subscription sub = Subscription.builder()...build();
          subscriptionRepository.save(sub);
return SubscriptionResponseDTO.from(sub);

지금은 이렇게 토스 결제를 마친 후 DB저장을 하도록 설정되어있다.
이 코드는 현재 문제 없이 잘 돌아간다.
게다가 일반적으로 토스 결제는 잘 이루어질테고 그게 서버로 잘 전달만 된다면 DB저장까지도 문제 없을 것이니..
그런데 위에 말했듯이 만약 모종의 이유로 DB 저장이 안되는 상황이 생긴다면 어떻게 될까?

이미 카드사의 인증을 마치고 PG사를 거쳐 결제가 완료되었는데,
서비스는 구독되지 못한 상태가 될 것이다.

(CS 폭발....살려주세요..)

이건 좀 위험하다는 생각이 들었다.

코드를 아무리 완벽하게 짰다고 해도,
안그래도 변수가 많은 운영 환경에서
최소한 예측 가능한 장애에 대해서는 서비스 안정성을 보장하기 위해
카오스 엔지니어링을 도입하기로 했다.


카오스 엔지니어링이란

일부러 장애를 일으켜 문제 없이 잘 동작하는지 테스트하는 기법이다.
직접 장애를 발생시켜보기 전까지는 발생할 오류를 그저 추측하는 것에 불과하지만, 문제 상황에 어떻게 동작하는 지를 체크해두면 장애 대응책을 마련할 수 있기 때문에 의미가 있다.

카오스 엔지니어링의 핵심 가치

"운영 환경에서 실험하라" (Experiment in Production)

Host Level, Application Level 등 엔지니어링을 적용할 단을 선택할 수 있는데 실제 운영 환경에서는 장애를 발생시키기 위해 DB나 서버를 실제로 건드리는 것이 어려우니, Application Level에서 DB 저장 실패를 시뮬레이션 해보기로 했다.

구체적인 성능 목표

30% 확률로 DB저장 실패가 일어나는 실제 운영 환경에서 100% 정합성을 유지한다.

즉, DB저장 실패가 이루어지면 토스API 결제도 취소가 되게 보상 로직을 구현할 것이다.

어떻게 시뮬레이션 할 것이냐

1) Spring Profile + AOP로 운영 코드 건드리지 않고 테스트 환경 만들기

처음에는 Service 코드에 직접 if (Math.random() < 0.3) throw new Exception()를 부여해서 테스트할까 생각했는데
그럼 운영 코드가 더러워지니 AOP를 사용해 환경을 분리하기로 했다.
@Aspect로 AOP임을 선언해주고 @Profile로 테스트 환경 선언.
그리고 @Around("execution(* ...Repository.save(..))")로 joint point에서 실제 실행 할 로직을 선언한다.

@Aspect
@Profile("chaos")
  public class ChaosDbFailureAspect {
      @Around("execution(* ...Repository.save(..))")
      public Object injectFailure(...) {
          if (Math.random() < failureRate) throw new Exception();
          return joinPoint.proceed();
      }
  }

2) mock server로 토스 API 대신하기

토스 결제 API는 100% 동작한다는 가정 하에 mock서버를 구성했다.
실제로 카드 등록-결제 과정을 거치는 것이 아니라,
결제가 200 응답이 되었을 때 그 후 로직에 대해 테스트하는 것이다.

  • 실제 코드
@Component
  @Profile("!chaos")  // chaos가 아닐 때
  public class TossPaymentsClient {
      // 실제 토스 API 호출
      public Mono<TossPaymentResponseDTO> payWithBillingKey(...) {
          return webClient.post()
  • 테스트를 위한 코드(추가)
 @Component
  @Profile("chaos")  // chaos일 때만
  public class TossPaymentsClient {
      public Mono<TossPaymentResponseDTO> payWithBillingKey(...) {
          // 실제 API 호출 없이 성공 응답만 반환
          TossPaymentResponseDTO response = new TossPaymentResponseDTO();
          response.setPaymentKey("mock_payment_" + UUID.randomUUID());
          response.setStatus("DONE");
          return Mono.just(response);
      }
  }

3) 카오스 환경에 환불 로직 추가하기

 } catch (Exception e) {
              //DB 저장 실패 시 자동 환불
              log.error("DB 저장 실패 billingKey: {}", payResp.getPaymentKey());

              if (예외 상황 분기 처리) {
                  try {
                      // 자동 환불 시도
                      TossCancelRequestDTO cancelReq = TossCancelRequestDTO.builder()
                          .cancelReason("DB 저장 실패로 인한 자동 환불")
                          .build();

                      tossPaymentsClient.cancelPayment(payResp.getPaymentKey(), cancelReq)
                          .block();

                      log.info("자동 환불 성공: {}", payResp.getPaymentKey());

                  } catch (Exception refundError) {
                      log.error("자동 환불 실패, 수동 처리 필요: {}",
                          payResp.getPaymentKey(), refundError);
                  }
              }

              throw e;
          }

DB저장에 실패하면 이렇게 catch를 타고 예외 케이스에 따라 환불과 같은 보상 로직을 실행하게 된다.

4) 환경 설정 분리하기

보상 로직이 포함 된 코드를 따로 작성하고 @Profile을 이용해서 특정 프로필일 때만 그에 적절한 설정파일을 읽도록 유도했다.

설정 파일명은 application-{profile}.properties로 한다.

  • application-chaos-before.yml

  spring:
    config:
      activate:
        on-profile: chaos-before

  chaos:
    database:
      failure-rate: 0.3  # 30% 실패
    auto-refund:
      enabled: false  # 자동 환불 비활성화
  • application-chaos-after.yml
spring:
    config:
      activate:
        on-profile: chaos-after

  chaos:
    database:
      failure-rate: 0.3  # 30% 실패
    auto-refund:
      enabled: true  # 자동 환불 활성화

이렇게 별도의 환경 설정을 구성해주고
실행할 때 spring.profiles.active 속성에 값을 지정하여 돌리면 된다.
환불 로직 온/오프만 환경 설정으로 구분해두면
중복되는 코드를 만들지 않아도 같은 서비스 코드를 활용해 다른 케이스를 테스트할 수 있게 된다.

추가한 프로젝트 구조도

 기존 코드 (토스 API활용한 실제 운영 코드)
  ├── service/SubscriptionServiceImpl.java  
  └── client/TossPaymentsClient.java

  Chaos 전용 (mock 서버 활용한 코드)
  ├── chaos/
  │   ├── TossPaymentsClient.java        
  │   │                                    
  │   │
  │   ├── ChaosDbFailureAspect.java        
  │   │                                     
  │   │
  │   └── ChaosSubscriptionService.java    
  │                                         
  └── dto/toss/
      └── TossCancelRequestDTO.java        

기존 Subscription 코드에는 @Profile("!chaos")을 붙여줘야 테스트 환경의 빈이 생성되지 않는다. 사실 별 차이는 없다고 하지만 운영 코드 입장에선 불필요한 것은 분리해버리기..

나머지는 2탄에서 진행~

profile
성장하는 하루를 쌓아가는 블로그

0개의 댓글