구독 결제는 일반 결제와 달리 정기적으로 결제되는 로직이다.
나는 구독 결제 기능 개발을 담당하지 않았으나
기능이 완성되어 배포 직전 단계에
빌링 키 발급부터 구독까지 코드를 쭉 살펴보던 중
보완이 필요한 부분을 발견해 로직을 추가하게 되었다.
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
);
@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();
}
@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);
}
}
// 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);
}
@Data
@Builder
public class TossCancelRequestDTO {
private String cancelReason; // 필수
private Long cancelAmount; // 선택 (없으면 전액)
private String cancelRequestId; // 멱등성 보장용
}
토스 정기 결제 완료 후
구독 정보를 저장하고 연이어 불러 올 원장 업데이트 로직을 구현해주자.
@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로 기록해도 상관없지만 불필요한 쿼리 업데이트를 추가할 필요는 없어보인다.
@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이 되는 것을 확인할 수 있다.
이러면 이제 승인과 취소가 차례대로 기록되어
이후 감사나 추적에 활용할 수 있게 된다.