Spring Boot로 구축하는 회복력 있는(Resilient) 결제 시스템: 카드 결제부터 정기 결제, 장애 대응까지

이동휘·2025년 7월 1일
0

매일매일 블로그

목록 보기
38/49

"결제는 성공했는데, 유저는 실패했다고 느껴요."
"네트워크 오류로 중복 결제가 발생했어요."
"PG사 장애 때문에 우리 서비스까지 멈췄어요."

결제 시스템을 개발하다 보면 마주하게 되는 끔찍한 시나리오들입니다. 결제는 단순히 돈을 주고받는 기술적인 행위를 넘어, 사용자가 우리 서비스를 신뢰하는 기반입니다. 그 신뢰가 무너지는 순간, 사용자는 두 번 다시 돌아오지 않을지도 모릅니다.

이러한 문제를 방지하기 위해, 결제 시스템은 단순한 트랜잭션 처리기가 아니라, 예측 불가능한 장애 상황에서도 데이터 정합성을 유지하고 스스로 복구 가능한(Retry-safe) 회복력 있는 아키텍처로 설계되어야 합니다.

이 글에서는 단순히 결제 API를 연동하는 것을 넘어, Spring Boot, MySQL, Redis, 그리고 RabbitMQ/Kafka와 같은 기술 스택을 활용하여 실무에서 마주할 수 있는 문제들을 해결하고, 한 단계 높은 수준의 안정적인 결제 시스템을 구축하는 실용적인 노하우를 공유합니다.


회복력 있는 결제 시스템을 위한 아키텍처 설계

이 아키텍처는 단순한 결제 기능 구현을 넘어, 데이터의 정합성과 장애 대응 능력(Resilience)을 최우선으로 고려하여 설계되었습니다. 각 구성 요소는 명확한 책임을 가지며, 특히 PaymentIntent(결제 의도)Transaction(결제 시도)의 분리를 통해 어떤 상황에서도 결제 상태를 명확하게 추적하고 복구할 수 있는 기반을 마련합니다.

[Client (웹/앱)]
    │
    │ 1. 결제 요청 (ex. intent 생성 요청)
    ▼
[Backend (Spring Boot)]
    ├── 2. PaymentIntent 생성 (상태: REQUIRES_PAYMENT_ACTION)
    │       └─> MySQL에 저장
    ├── 3. Redis에 멱등성 키 저장 (paymentKey 등)
    ├── 4. Client에 intentId 전달
    │
    └── 5. PG사 결제 요청 (Toss Payments)
            └─> 사용자 카드 입력 및 승인 → Client → Backend로 콜백 전송
                  (paymentKey 포함)

          ▼
    ├── 6. Redis에서 멱등성 확인
    ├── 7. PaymentTransaction 생성 (상태: PROCESSING)
    │       └─> MySQL에 저장
    ├── 8. 토스페이먼츠 최종 승인 API 호출
    │
    ├── 9. 승인 성공 시
    │       └─> PaymentIntent + Transaction 상태를 SUCCEEDED로 MySQL 업데이트
    │
    ├── 10. 승인 실패 시
    │       └─> Transaction만 FAILED, Intent는 보존 (재시도 가능)
    │
    └── 11. 결제 성공 이벤트를 Message Queue로 발행
               └─> DLQ(Dead Letter Queue) 설정

          ▼
[Message Queue 구독 서비스들]
    ├── 알림 발송 서비스
    ├── 배송 시스템
    └── 재고 관리 시스템 등

각 구성 요소의 역할

① Client (웹/앱)

  • 사용자로부터 결제 요청을 시작하는 출발점입니다.
  • 백엔드로부터 결제에 필요한 정보(intentId 등)를 받아 PG사 결제 모듈(결제창)을 호출하고, 결제 결과를 백엔드에 최종적으로 전달하는 역할을 합니다.

② Backend (Spring Boot)

  • 시스템의 두뇌 역할을 하는 핵심 로직 서버입니다.
  • 모든 비즈니스 규칙(금액 검증, 상태 관리 등)을 수행하며, PaymentIntentTransaction을 생성하고 관리합니다.
  • 아래의 모든 구성 요소와 통신하며 전체 흐름을 조율합니다.

③ Toss Payments (PG사)

  • 실제 카드사, 은행과 통신하며 금융 거래를 처리하는 외부 결제 전문 기관입니다.
  • 백엔드는 billingKey, paymentKey 등 PG사가 제공하는 키를 통해 안전하게 결제를 요청하고 결과를 받습니다.

④ MySQL (영구 저장소)

  • 시스템의 신뢰할 수 있는 단일 진실 공급원(Single Source of Truth)입니다.
  • 주문, 사용자 정보, 그리고 가장 중요한 PaymentIntent, Transaction, Subscription의 모든 상태 변화가 이곳에 영구적으로 기록됩니다. 데이터베이스 트랜잭션을 통해 데이터의 원자성을 보장합니다.

⑤ Redis (임시 저장소 / 분산 락)

  • 멱등성(Idempotency) 보장의 핵심 역할을 수행합니다.
  • 사용자가 네트워크 오류로 결제 버튼을 두 번 누르거나, PG사로부터 콜백이 중복으로 오는 경우를 방지합니다. 백엔드는 결제 승인 처리 전, Redis에 paymentKey와 같은 고유한 값으로 락(Lock)을 설정하여 동일한 요청이 동시에 처리되는 것을 막습니다.

⑥ Message Queue (RabbitMQ / Kafka)

  • 시스템 간의 결합도를 낮추는(Decoupling) 중요한 역할을 합니다.
  • 결제 승인이 성공적으로 완료되면, 백엔드는 "결제가 성공했다"는 이벤트만 메시지 큐에 발행합니다. 이후의 작업(알림 발송, 배송 시스템 연동, 재고 감소 등)은 해당 메시지를 구독하는 별도의 서비스들이 비동기적으로 처리합니다. 이를 통해 결제 API의 응답 시간을 단축하고, 후속 처리 시스템의 장애가 결제 자체에 영향을 주지 않도록 격리합니다.
  • *DLQ(Dead-Letter-Queue)**는 비동기 처리 중 실패한 메시지를 보관하는 안전망으로, 이벤트 유실을 방지하고 추후 원인 분석 및 재처리를 가능하게 합니다.

핵심 결제 흐름 (Flow)

  1. 결제 요청: 사용자가 Client에서 '결제하기'를 누르면, 백엔드에 결제 의도(PaymentIntent) 생성을 요청합니다.
  2. 의도 생성 및 검증: 백엔드는 MySQL에서 주문 정보를 조회해 금액 등을 검증한 뒤, PaymentIntentREQUIRES_PAYMENT_ACTION(결제 필요) 상태로 생성하고 DB에 저장합니다.
  3. 결제 정보 전달: 백엔드는 생성된 intentId를 Client에 전달합니다.
  4. PG사 연동: Client는 intentId와 함께 토스페이먼츠 결제 모듈을 호출하고, 사용자는 카드 정보 등을 입력합니다.
  5. 승인 요청: 결제 모듈에서의 처리가 끝나면, 토스페이먼츠는 Client를 거쳐 백엔드의 콜백 API를 paymentKey와 함께 호출합니다.
  6. 결제 시도 처리:
    • 백엔드는 가장 먼저 Redis를 통해 해당 paymentKey가 이미 처리 중인지 멱등성을 확인합니다.
    • 이상이 없으면, 이 시도를 기록하기 위해 MySQL에 PaymentTransactionPROCESSING(처리 중) 상태로 생성합니다.
    • 이후 토스페이먼츠의 최종 승인 API를 호출하여 실제 돈이 오가는 거래를 확정합니다.
  7. 상태 업데이트:
    • 성공 시: TransactionPaymentIntent의 상태를 모두 SUCCEEDED로 MySQL에 업데이트합니다.
    • 실패 시: Transaction의 상태만 FAILED로 업데이트하여, PaymentIntent는 재시도 가능한 상태로 남겨둡니다.
  8. 비동기 이벤트 발행: 결제가 최종 성공하면, 백엔드는 결제 완료 이벤트를 메시지 큐로 발행합니다.
  9. 후속 조치: 알림 서비스나 배송 서비스 등 다른 시스템이 큐에 담긴 메시지를 가져가 비동기적으로 다음 할 일을 처리합니다.

사전 준비 및 환경 설정:

  • 토스페이먼츠: 개발자 센터 가입 및 테스트 연동 키(클라이언트 키, 시크릿 키) 발급.
  • Spring Boot 프로젝트 설정:
    • build.gradle 의존성 추가: Spring Web, Spring Data JPA, Redis, RabbitMQ/Kafka 등
    • application.yml 설정: DB, Redis, 메시지 큐 접속 정보 및 토스페이먼츠 API 키 등록 (환경 변수, Vault 등 Secret 관리 방법 강조)
  • DB 스키마(ERD): orders(주문), payment_intents(결제 의도), payment_transactions(결제 시도), subscriptions(구독) 등 핵심 테이블 설계.

Part 1: 일반 카드 결제 구현: 실패해도 괜찮아!

우리는 결제 실패를 '시스템 오류'가 아닌, '목표 달성을 위한 여러 시도 중 하나'로 자연스럽게 관리할 수 있는 모델을 만듭니다.

Step 1: 결제 의도(PaymentIntent) 생성

사용자가 '결제하기' 버튼을 누르는 순간, 실제 PG사 연동을 시작하기 전에 우리 서버는 먼저 사용자의 '결제 의도'를 기록해야 합니다.

1. 도메인 모델 정의 (Entity)

// PaymentIntent의 상태를 나타내는 Enum
public enum PaymentStatus {
    REQUIRES_PAYMENT_ACTION, // 결제 대기 중
    SUCCEEDED,               // 결제 성공
    FAILED;                  // 결제 의도 자체가 실패 (거의 사용 안 함)
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PaymentIntent {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String intentId; // "pi_" + UUID, 클라이언트와 통신에 사용될 고유 ID

    private Long orderId;

    @Column(precision = 19, scale = 4) // 금액은 항상 오차가 없는 BigDecimal 사용
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    private PaymentStatus status;

    // 하나의 결제 의도에 여러 결제 시도(Transaction)가 있을 수 있음
    @OneToMany(mappedBy = "paymentIntent", cascade = CascadeType.ALL)
    private List<PaymentTransaction> transactions = new ArrayList<>();

    // ... 빌더 및 상태 변경 메서드 ...
}

2. API 및 비즈니스 로직: 서버 사이드 검증 및 저장

프론트엔드에서 결제 시작 요청을 받으면, PaymentService는 다음과 같은 핵심 로직을 수행합니다.

// PaymentService.java
@Transactional
public PaymentIntentResponse createPaymentIntent(PaymentIntentCreateRequest request) {
    // 1. 주문 정보 조회 및 금액 서버사이드 검증 (매우 중요!)
    Order order = orderRepository.findById(request.getOrderId())
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문입니다."));

    if (!order.getAmount().equals(request.getAmount())) {
        throw new IllegalArgumentException("주문 금액이 일치하지 않습니다.");
    }

    // 2. PaymentIntent 객체 생성 및 PENDING(REQUIRES_PAYMENT_ACTION) 상태로 저장
    PaymentIntent paymentIntent = PaymentIntent.builder()
            .orderId(order.getId())
            .amount(order.getAmount())
            .build();
    paymentIntentRepository.save(paymentIntent);

    // 3. 프론트엔드에 결제 위젯 초기화에 필요한 정보 전달
    return PaymentIntentResponse.builder()
            .clientKey(tossClientKey)
            .intentId(paymentIntent.getIntentId())
            // ... 기타 정보
            .build();
}

이 단계의 핵심은 서버 사이드 금액 검증상태 기록입니다. 우리는 이제 intentId를 통해 "어떤 사용자가 어떤 주문에 대해 결제를 시작했다"는 사실을 안정적으로 추적할 수 있습니다.

Step 2: 결제 시도(Transaction) 처리 및 승인

프론트엔드에서 결제가 완료되면, PG사는 우리 서버의 콜백 URL로 paymentKey 등의 정보를 보내줍니다. 이제 이 정보를 바탕으로 실제 승인, 즉 Transaction을 처리할 차례입니다.

1. 도메인 모델 정의 (Entity)

public enum TransactionStatus {
    PROCESSING, // 처리 중
    SUCCEEDED,  // 성공
    FAILED;     // 실패
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PaymentTransaction {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String paymentKey; // PG사가 발급하는 고유 키

    @Enumerated(EnumType.STRING)
    private TransactionStatus status;
    
    private String failureCode;
    private String failureMessage;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "payment_intent_id")
    private PaymentIntent paymentIntent;
    
    // ... 빌더 및 상태 변경 메서드 ...
}

2. 비즈니스 로직: 멱등성, 검증, 승인, 상태 전이

결제 승인 로직은 결제 시스템의 심장부이며, 가장 방어적으로 코드를 작성해야 합니다.

// PaymentService.java
@Transactional
public PaymentConfirmationResponse confirmPayment(String paymentKey, String intentId, Long amount) {
    // 1. PaymentIntent 조회 및 검증
    PaymentIntent paymentIntent = paymentIntentRepository.findByIntentId(intentId)
            .orElseThrow(() -> new IllegalArgumentException("결제 정보가 존재하지 않습니다."));

    // 2. 멱등성(Idempotency) 체크: 이미 처리된 paymentKey인지 확인
    if (transactionRepository.existsByPaymentKey(paymentKey)) {
        // 이미 성공적으로 처리된 요청이라면, 기존 정보를 바탕으로 성공 응답을 다시 내려준다.
        return createSuccessResponse(paymentIntent); 
    }

    // 3. 결제 시도(Transaction) 생성 및 'PROCESSING' 상태로 초기 저장 (방어적 상태 기록)
    PaymentTransaction transaction = createAndSaveTransaction(paymentKey, paymentIntent, new BigDecimal(amount));

    // 4. 최종 금액 검증 (가장 중요!)
    if (!paymentIntent.getAmount().equals(new BigDecimal(amount))) {
        transaction.markAsFailed("INVALID_AMOUNT", "결제 금액이 일치하지 않습니다.");
        throw new IllegalStateException("결제 금액 위변조 시도");
    }

    try {
        // 5. PG사(토스페이먼츠)에 최종 결제 승인 요청
        TossPaymentConfirmationResult result = tossPaymentsClient.confirmPayment(paymentKey, intentId, amount);

        // 6. [상태 전이] 성공: Transaction과 PaymentIntent의 상태를 SUCCEEDED로 변경
        transaction.markAsSuccess();
        paymentIntent.succeed();
        
        // 7. 비동기 후처리 이벤트 발행 (메시지 큐)
        eventPublisher.publish(new PaymentSucceededEvent(paymentIntent.getOrderId()));

        return createSuccessResponse(paymentIntent);

    } catch (Exception e) {
        // 8. [상태 전이] 실패: Transaction 상태만 FAILED로 변경
        // PaymentIntent의 상태는 그대로 두어, 사용자가 재시도할 수 있게 한다.
        transaction.markAsFailed(e.getCode(), e.getMessage());
        throw new RuntimeException("결제 승인에 실패했습니다.", e);
    }
}

이 로직의 핵심은 멱등성 보장, 방어적인 상태 기록, 그리고 명확한 상태 전이입니다. 실패하면 TransactionFAILED가 되고, PaymentIntent는 그대로 REQUIRES_PAYMENT_ACTION 상태를 유지합니다. 이 덕분에 시스템은 "결제는 실패했지만, 여전히 결제해야 할 의도는 살아있다"는 것을 명확히 인지하고 사용자에게 재시도를 유도할 수 있습니다.

🤔 꼬리 질문: 멱등성 보장을 위해 Redis 분산 락(Distributed Lock)을 사용하는 것과, DB에 paymentKey에 대한 UNIQUE 제약 조건을 거는 것의 장단점은 각각 무엇일까요? 두 가지를 함께 사용하는 것은 어떤 이점이 있을까요?


Part 2: 정기 결제 구현: 상태 머신으로 똑똑하게 관리하기

정기 결제는 한 번으로 끝나지 않는 긴 생명주기(Lifecycle)를 가집니다. 우리는 상태 머신(State Machine) 개념을 도입하여 이 복잡한 과정을 안정적으로 추적하고 관리합니다.

Step 1: 첫 결제 및 구독(Subscription) 생성

정기 결제의 시작은 일반 결제와 유사하지만, 첫 결제 성공 시 PG사로부터 billingKey를 발급받아 Subscription 엔터티를 생성하는 로직이 추가됩니다.

// Subscription의 상태를 정의하는 Enum (상태 머신의 상태들)
public enum SubscriptionStatus {
    ACTIVE,             // 활성 (정상 결제)
    IN_GRACE_PERIOD,    // 유예 기간 (결제 실패, 재시도 중)
    CANCELED            // 취소됨
}

@Entity
public class Subscription {
    // ...
    @Column(unique = true, nullable = false)
    private String billingKey;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private SubscriptionStatus status;

    private LocalDate nextPaymentDate;
    // ...
}

Step 2: 스케줄러를 이용한 상태 머신 기반 자동 결제

매일 정해진 시간에 Spring Scheduler(@Scheduled)가 동작하여, 결제일이 도래한 구독 건들을 찾아 자동으로 결제를 시도합니다.

// SubscriptionService.java
@Transactional
public void processSubscriptionPayment(Subscription subscription) {
    // 모든 자동 결제 '시도' 역시 PaymentIntent와 Transaction으로 기록하여 추적
    PaymentIntent intent = createPaymentIntentForSubscription(subscription);
    PaymentTransaction transaction = createTransactionForIntent(intent);

    try {
        // 1. 빌링키로 결제 승인 요청
        tossPaymentsClient.authorizeBillingKey(...);

        // 2. [상태 전이] 성공 시
        transaction.markAsSuccess();
        intent.succeed();
        subscription.renew(); // 상태를 ACTIVE로 유지하고, 다음 결제일을 갱신

    } catch (PaymentException e) {
        // 3. [상태 전이] 실패 시
        transaction.markAsFailed(e.getCode(), e.getMessage());
        intent.fail();

        // 현재 구독 상태에 따라 다음 상태를 결정 (상태 머신 로직)
        if (subscription.getStatus() == SubscriptionStatus.ACTIVE) {
            subscription.enterGracePeriod(); // 첫 실패 시 '유예 기간'으로 변경
            // 사용자에게 결제 실패 안내 알림 발송
        } else if (subscription.getStatus() == SubscriptionStatus.IN_GRACE_PERIOD) {
            subscription.cancel(); // 유예 기간 중에도 실패 시 최종 '취소'
            // 사용자에게 구독 해지 안내 알림 발송
        }
    }
}

이 설계의 장점은 명확성자동화입니다. 결제 실패는 더 이상 개발자가 개입해야 할 '장애'가 아닙니다. 시스템이 정해진 규칙(유예 기간 부여 → 재시도 → 취소)에 따라 스스로 처리하는 '예상된 시나리오'가 됩니다.

🤔 꼬리 질문: 정기 결제 재시도 로직에서, 매번 동일한 시간에 재시도하는 것보다 점차 재시도 간격을 늘리는 방식(Exponential Backoff)을 적용하면 어떤 이점이 있을까요?


Part 3: 시스템을 단단하게 만드는 기술들

결제 기능 구현은 시작일 뿐입니다. 진짜 프로덕션 레벨의 시스템은 예측 불가능한 장애와 데이터 불일치 상황에서도 스스로를 보호하고 복구할 수 있어야 합니다.

비동기 처리를 위한 메시지 큐 심화 (RabbitMQ/Kafka)

결제 성공 후 후속 처리를 위해 메시지 큐를 사용하는 것은 좋은 시작입니다. 하지만 만약 후속 처리를 담당하는 컨슈머(Consumer) 서비스가 다운되면 어떻게 될까요? 이를 방지하기 위해 TTLDLQ를 도입해야 합니다.

  • TTL (Time-To-Live): 메시지의 '유효기간'입니다. 메시지가 큐에서 너무 오래 대기하면 자동으로 제거하거나 다른 곳으로 보내, 처리 지연이 시스템 전체에 영향을 주는 것을 막습니다.
  • DLQ (Dead-Letter-Queue): '죽은 메시지 보관소'입니다. 처리 중 반복적으로 실패하거나 TTL이 만료된 메시지들이 자동으로 모이는 곳입니다. 개발자는 DLQ에 쌓인 메시지를 분석하고 원인을 해결한 뒤 수동으로 재처리할 기회를 얻게 됩니다. 어떤 이벤트도 흔적 없이 사라지지 않게 하는 안전장치입니다.

이 두 가지를 통해 컨슈머 장애가 전체 시스템의 장애로 번지는 '연쇄 실패'를 효과적으로 막을 수 있습니다.

SLO 기반 모니터링 및 Observability 구축

"서버 CPU 사용량 90% 돌파!"와 같은 알람보다, 사용자 관점에서 중요한 지표를 기준으로 시스템을 감시해야 합니다. 이것이 바로 SLO(서비스 수준 목표) 기반 모니터링입니다.

  • 나쁜 알람 👎: "CPU 사용량 90% 이상"
  • 좋은 알람 👍: "최근 5분간 결제 성공률이 99.9% 미만으로 떨어졌다." 또는 "결제 승인 API의 P99 응답시간이 2초를 초과했다."

이를 가능하게 하는 것이 바로 Observability(관측 가능성)의 세 기둥입니다.

  1. Metrics (지표): 결제 성공 건수, API 응답 시간 등 집계된 수치 데이터. (e.g., Prometheus, Micrometer)
  2. Logs (로그): [intent_id: pi_xxx] 결제 승인 성공과 같이 특정 이벤트에 대한 상세한 텍스트 기록.
  3. Traces (추적): 하나의 사용자 요청이 여러 마이크로서비스를 거치는 전체 여정을 시각화한 데이터. (e.g., OpenTelemetry, Zipkin)

이 세 가지를 연동하여, SLO 기반 알람을 통해 문제를 인지하고, 로그와 트레이스에서 해당 intent_id를 검색하여 문제의 근본 원인을 수 분 내에 찾아내는 강력한 디버깅 환경을 구축할 수 있습니다.

최종 정합성을 위한 환불 및 정산 동기화

우리 시스템의 데이터와 PG사의 실제 정산 데이터가 1원이라도 틀리면 큰 문제가 됩니다. 따라서 최종적인 데이터 정합성을 보장하는 장치가 반드시 필요합니다.

  • 웹훅 (Webhook) - Proactive 방식: PG사에서 특정 이벤트(결제 취소, 입금 등)가 발생했을 때, PG사가 먼저 우리 서버의 특정 API 엔드포인트로 알림을 보내주는 방식입니다. 실시간 변경 사항을 대부분 처리할 수 있습니다.
  • 폴링 (Polling) - Reactive 방식: 우리 서버가 주기적으로(예: 매일 새벽) PG사 API를 호출하여 어제의 거래 내역과 우리 DB의 데이터를 비교하고 불일치를 찾아내는 방식입니다. 웹훅으로 놓칠 수 있는 모든 예외 상황에 대한 최종 검증 장치 역할을 합니다.

웹훅으로 실시간 변경을 처리하고, 폴링으로 하루에 한 번씩 전체 데이터를 검증하는 두 가지 방식을 함께 사용하면 데이터 정합성을 거의 완벽에 가깝게 유지할 수 있습니다.


결론: 회복 가능한 시스템을 향하여

지금까지 다룬 상태 기반 설계, 멱등성, 비동기 처리(DLQ 포함), SLO 기반 모니터링, 정산 동기화와 같은 기술들은 단순히 기능을 구현하는 것을 넘어, 예상치 못한 상황에서도 스스로를 지키고, 문제가 발생했을 때 빠르고 정확하게 원인을 찾아 해결할 수 있는, 진정으로 '회복력 있는(Resilient) 결제 시스템'을 완성하는 핵심 요소들입니다.

이 글이 여러분의 결제 시스템 설계 여정에 깊이 있는 통찰과 실질적인 가이드를 제공했기를 바랍니다. 감사합니다.

0개의 댓글