[Springboot] 토스페이먼츠 구독 결제 - 거래 내역 추적, 환불 기능 구현하기 (거래 원장)

Hannana·2025년 10월 4일

구독 결제는 일반 결제와 달리 정기적으로 결제되는 로직이다.
나는 구독 결제 기능 개발을 담당하지 않았으나
기능이 완성되어 배포 직전 단계에
빌링 키 발급부터 구독까지 코드를 쭉 살펴보던 중
보완이 필요한 부분을 발견해 로직을 추가하게 되었다.

내가 느낀 문제 상황

현재 구조

BillingKey 테이블
-발급받은 빌링 키 정보를 사용자 별로 저장

Subscription 테이블
-카드 번호, 카드 종류, 유저 정보를 포함한 구독 정보들
-lastPaymentKey (마지막 결제만 결제 키 저장)

그러나 나는 이런 구조라면, 지난 결제를 환불할 수 없다는 사실을 알아차렸다.
고유의 결제 기록은 사라지고 매 결제 시 내역이 덮어씌워지니
구독 결제 히스토리를 영영 확인할 수 없게 된다.

2025-10 → paymentKey_001 (10,000원)
2025-11 → paymentKey_002 (10,000원)  ← lastPaymentKey 덮어씀
2025-12 → paymentKey_003 (10,000원)  ← lastPaymentKey 덮어씀
  • 고객은 본인이 결제한 내역을 한 눈에 확인하길 원할 것이다.
  • 환불, 회계 감사를 위해 결제 내역은 무조건 기록으로 남겨야 한다.

라는 생각이 들었다.

구독 결제 보완하기

목표

BillingKey (1) ─── (N) Subscription (1) ─── (N) Payment
                                                  ↑
                                              매월 결제 이력

한 명의 사용자가 여러 구독을 할 수 있고,
하나의 구독 당 여러 개의 결제 이력을 저장하도록 구조를 개선할 것이다.

구현 계획

토스 공식 문서

토스 페이먼츠의 자동 결제(구독) 공식 문서에 따라 API (POST) 응답 값을 보자.

이렇게 거래마다 고유한 paymentKey가 발급되는 걸 볼 수 있다.
이제 각 거래를 누적하는 원장 테이블의 구조는 아래와 같다.

 payments (
      id BIGINT PRIMARY KEY AUTO_INCREMENT,
      subscription_id BIGINT NOT NULL,  -- FK
      payment_key VARCHAR(100) NOT NULL UNIQUE,  -- 토스 paymentKey
      order_id VARCHAR(100) NOT NULL,
      amount DECIMAL(12,2) NOT NULL,
      status VARCHAR(20) NOT NULL,  -- DONE, CANCELED
      payment_method VARCHAR(20),  -- 빌링
      requested_at DATETIME,
      approved_at DATETIME,
      canceled_at DATETIME,
      cancel_reason VARCHAR(200),
      created_at DATETIME,
      updated_at DATETIME
  );

구현

  • Payment 엔티티 생성
@Entity
@Table(name = "payments", indexes = {
@Index(name = "idx_payment_key", columnList = "paymentKey"),
      @Index(name = "idx_subscription_id", columnList = "subscriptionId")
  })
  public class Payment {
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      private Long id;
      private Long subscriptionId;  // 구독 FK

      @Column(nullable = false, unique = true)
      private String paymentKey;  // 토스에서 발급한 고유 결제 키

      private String orderId;
      private String orderName;
      private BigDecimal amount;

      @Enumerated(EnumType.STRING)
      private PaymentStatus status;  // DONE, CANCELED, FAILED

      private String paymentMethod;
      private String customerKey;

      private LocalDateTime requestedAt;
      private LocalDateTime approvedAt;
      private LocalDateTime canceledAt;

      private String cancelReason;
      private String cancelRequestId;
  }

그리고 도메인 선언 안에 취소를 처리하는 헬퍼 메서드도 추가해준다.
보통 비즈니스 로직은 서비스 코드 안에 추가하는 것이 일반적이지만
DDD 규칙에 따라 도메인 안에 도메인의 서비스 코드를 추가해주자.

public void cancel(String cancelReason, String cancelRequestId) {
          this.status = PaymentStatus.CANCELED;
          this.cancelReason = cancelReason;
          this.cancelRequestId = cancelRequestId;
          this.canceledAt = LocalDateTime.now();
}
  • SubscriptionServiceImpl 수정
    구독 생성 시 Payment 원장에 기록하도록 구독 서비스 로직을 수정해야 한다.
@Service
  public class SubscriptionServiceImpl implements SubscriptionService {
      private final PaymentRepository paymentRepository;

      @Override
      @Transactional
      public SubscriptionResponseDTO create(SubscriptionRequestDTO request) {
          // 1. 빌링키 조회
          BillingKey billingKey = billingKeyService.getBillingKey(...);

          // 2. 토스 결제 API 호출
          TossPaymentResponseDTO payResp = tossPaymentsClient
              .payWithBillingKey(billingKey.getBillingKey(), tossBillingReq)
              .block();

          // 3. Subscription 저장
          Subscription savedSub = subscriptionRepository.save(sub);

          // 4. Payment 원장에 기록
          if (payResp != null) {
              Payment payment = Payment.builder()
                  .subscriptionId(savedSub.getId())
                  .paymentKey(payResp.getPaymentKey())
                  .orderId(payResp.getOrderId())
                  .orderName(payResp.getOrderName())
                  .amount(request.getAmount())
                  .status(Payment.PaymentStatus.DONE)
                  .paymentMethod(payResp.getMethod())
                  .customerKey(request.getCustomerKey())
                  .requestedAt(LocalDateTime.parse(payResp.getRequestedAt()))
                  .approvedAt(LocalDateTime.parse(payResp.getApprovedAt()))
                  .build();
              paymentRepository.save(payment);
          }

          return SubscriptionResponseDTO.from(savedSub);
      }
  }
  • 결제 취소 API 1) 토스 클라이언트에 취소 요청 추가
    토스 공식 문서에 따라 cancelPayment 메서드를 TossPaymentsClient에 추가했다.
 // TossPaymentsClient
  public Mono<TossPaymentResponseDTO> cancelPayment(String paymentKey, TossCancelRequestDTO request) {
      return webClient.post()
          .uri(baseUrl + "/v1/payments/" + paymentKey + "/cancel")
          .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeSecretKey())
          .bodyValue(request)
          .retrieve()
          .bodyToMono(TossPaymentResponseDTO.class);
  }
  • 결제 취소 API 2) 취소 요청 DTO 생성
    토스 API 스펙에 맞춰 작성하면 된다.
  @Data
  @Builder
  public class TossCancelRequestDTO {
      private String cancelReason;  // 필수
      private Long cancelAmount;    // 선택 (없으면 전액)
      private String cancelRequestId;  // 멱등성 보장용
  }
  • PaymentService 추가 + 취소 로직 구현

토스 정기 결제 완료 후
구독 정보를 저장하고 연이어 불러 올 원장 업데이트 로직을 구현해주자.


  @Service
  public class PaymentServiceImpl implements PaymentService {

      @Override
      @Transactional
      public PaymentResponseDTO cancelPayment(String paymentKey, PaymentCancelRequestDTO request) {
          // 1. Payment 조회
          Payment payment = paymentRepository.findByPaymentKey(paymentKey)
              .orElseThrow(() -> new IllegalArgumentException("Payment not found"));

          // 이미 취소된 경우 체크
          if (payment.getStatus() == Payment.PaymentStatus.CANCELED) {
              return PaymentResponseDTO.from(payment);
          }

          // 2. 토스 API 결제 취소
          String cancelRequestId = UUID.randomUUID().toString();
          TossCancelRequestDTO tossCancelReq = TossCancelRequestDTO.builder()
              .cancelReason(request.getCancelReason())
              .cancelAmount(request.getCancelAmount())
              .cancelRequestId(cancelRequestId)
              .build();

          TossPaymentResponseDTO tossResp = tossPaymentsClient
              .cancelPayment(paymentKey, tossCancelReq)
              .block();

          // 3. Payment 원장에 취소 기록
          payment.cancel(request.getCancelReason(), cancelRequestId);
          Payment savedPayment = paymentRepository.save(payment);

          return PaymentResponseDTO.from(savedPayment);
      }
  }

여기서 포인트는 PaymentKey가 고유한 성질을 이용해
원장 테이블에서 먼저 조회하고, 중복으로 기록되는 걸 방지했다.
upsert로 기록해도 상관없지만 불필요한 쿼리 업데이트를 추가할 필요는 없어보인다.

  • API 구현
  @RestController
  @RequestMapping("/api/billing/payments")
  public class PaymentController {

      // 결제 취소
      @PostMapping("/{paymentKey}/cancel")
      public ResponseEntity<PaymentResponseDTO> cancelPayment(
              @PathVariable String paymentKey,
              @RequestBody PaymentCancelRequestDTO request) {
          return ResponseEntity.ok(paymentService.cancelPayment(paymentKey, request));
      }

      // 결제 조회
      @GetMapping("/{paymentKey}")
      public ResponseEntity<PaymentResponseDTO> getPayment(@PathVariable String paymentKey) {
          return ResponseEntity.ok(paymentService.getPayment(paymentKey));
      }

      // 구독의 결제 이력 조회
      @GetMapping("/subscription/{subscriptionId}")
      public ResponseEntity<List<PaymentResponseDTO>> getPaymentsBySubscription(
              @PathVariable Long subscriptionId) {
          return ResponseEntity.ok(paymentService.getPaymentsBySubscriptionId(subscriptionId));
      }
  }

핵심은 토스에서 발급한 paymentKey를 기준으로 Payment 원장을 관리하고
취소 시에는 cancelRequestId를 함께 기록하는 것.

만약 같은 cancelRequestId로 토스에 요청하면 토스는 "이미 처리된 요청"으로 판단하고 중복 취소를 막아준다.
이걸 DB에 기록하는 이유는, 어떤 취소 요청으로 인해 취소되었는지 추적하기 위함이다. 로그를 찍으면 중복 취소 요청이 있었는 지도 확인이 가능하다.

이로써
결제 금액-취소 금액=0 을 보장하고 거래 추적을 명확히 할 수 있게 도와줄 것이다.

결과

차례로 승인 결제 거래 내역, 취소 거래 내역이 기록된 모습이다.
취소 거래가 어떤 거래를 가리키는 지 PaymentKey를 기록하고
각 금액의 차가 0이 되는 것을 확인할 수 있다.

이러면 이제 승인과 취소가 차례대로 기록되어
이후 감사나 추적에 활용할 수 있게 된다.

profile
(구) https://hansjour.tistory.com/ 이사옴. 성장하는 하루를 쌓아가는 블로그

0개의 댓글