"결제는 성공했는데, 유저는 실패했다고 느껴요."
"네트워크 오류로 중복 결제가 발생했어요."
"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)
PaymentIntent
와 Transaction
을 생성하고 관리합니다.③ Toss Payments (PG사)
billingKey
, paymentKey
등 PG사가 제공하는 키를 통해 안전하게 결제를 요청하고 결과를 받습니다.④ MySQL (영구 저장소)
PaymentIntent
, Transaction
, Subscription
의 모든 상태 변화가 이곳에 영구적으로 기록됩니다. 데이터베이스 트랜잭션을 통해 데이터의 원자성을 보장합니다.⑤ Redis (임시 저장소 / 분산 락)
paymentKey
와 같은 고유한 값으로 락(Lock)을 설정하여 동일한 요청이 동시에 처리되는 것을 막습니다.⑥ Message Queue (RabbitMQ / Kafka)
PaymentIntent
) 생성을 요청합니다.PaymentIntent
를 REQUIRES_PAYMENT_ACTION
(결제 필요) 상태로 생성하고 DB에 저장합니다.intentId
를 Client에 전달합니다.intentId
와 함께 토스페이먼츠 결제 모듈을 호출하고, 사용자는 카드 정보 등을 입력합니다.paymentKey
와 함께 호출합니다.paymentKey
가 이미 처리 중인지 멱등성을 확인합니다.PaymentTransaction
을 PROCESSING
(처리 중) 상태로 생성합니다.Transaction
과 PaymentIntent
의 상태를 모두 SUCCEEDED
로 MySQL에 업데이트합니다.Transaction
의 상태만 FAILED
로 업데이트하여, PaymentIntent
는 재시도 가능한 상태로 남겨둡니다.사전 준비 및 환경 설정:
build.gradle
의존성 추가: Spring Web, Spring Data JPA, Redis, RabbitMQ/Kafka 등application.yml
설정: DB, Redis, 메시지 큐 접속 정보 및 토스페이먼츠 API 키 등록 (환경 변수, Vault 등 Secret 관리 방법 강조)orders
(주문), payment_intents
(결제 의도), payment_transactions
(결제 시도), subscriptions
(구독) 등 핵심 테이블 설계.우리는 결제 실패를 '시스템 오류'가 아닌, '목표 달성을 위한 여러 시도 중 하나'로 자연스럽게 관리할 수 있는 모델을 만듭니다.
사용자가 '결제하기' 버튼을 누르는 순간, 실제 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
를 통해 "어떤 사용자가 어떤 주문에 대해 결제를 시작했다"는 사실을 안정적으로 추적할 수 있습니다.
프론트엔드에서 결제가 완료되면, 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);
}
}
이 로직의 핵심은 멱등성 보장, 방어적인 상태 기록, 그리고 명확한 상태 전이입니다. 실패하면 Transaction
만 FAILED
가 되고, PaymentIntent
는 그대로 REQUIRES_PAYMENT_ACTION
상태를 유지합니다. 이 덕분에 시스템은 "결제는 실패했지만, 여전히 결제해야 할 의도는 살아있다"는 것을 명확히 인지하고 사용자에게 재시도를 유도할 수 있습니다.
🤔 꼬리 질문: 멱등성 보장을 위해 Redis 분산 락(Distributed Lock)을 사용하는 것과, DB에
paymentKey
에 대한 UNIQUE 제약 조건을 거는 것의 장단점은 각각 무엇일까요? 두 가지를 함께 사용하는 것은 어떤 이점이 있을까요?
정기 결제는 한 번으로 끝나지 않는 긴 생명주기(Lifecycle)를 가집니다. 우리는 상태 머신(State Machine) 개념을 도입하여 이 복잡한 과정을 안정적으로 추적하고 관리합니다.
정기 결제의 시작은 일반 결제와 유사하지만, 첫 결제 성공 시 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;
// ...
}
매일 정해진 시간에 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)을 적용하면 어떤 이점이 있을까요?
결제 기능 구현은 시작일 뿐입니다. 진짜 프로덕션 레벨의 시스템은 예측 불가능한 장애와 데이터 불일치 상황에서도 스스로를 보호하고 복구할 수 있어야 합니다.
결제 성공 후 후속 처리를 위해 메시지 큐를 사용하는 것은 좋은 시작입니다. 하지만 만약 후속 처리를 담당하는 컨슈머(Consumer) 서비스가 다운되면 어떻게 될까요? 이를 방지하기 위해 TTL과 DLQ를 도입해야 합니다.
이 두 가지를 통해 컨슈머 장애가 전체 시스템의 장애로 번지는 '연쇄 실패'를 효과적으로 막을 수 있습니다.
"서버 CPU 사용량 90% 돌파!"와 같은 알람보다, 사용자 관점에서 중요한 지표를 기준으로 시스템을 감시해야 합니다. 이것이 바로 SLO(서비스 수준 목표) 기반 모니터링입니다.
이를 가능하게 하는 것이 바로 Observability(관측 가능성)의 세 기둥입니다.
[intent_id: pi_xxx]
결제 승인 성공과 같이 특정 이벤트에 대한 상세한 텍스트 기록.이 세 가지를 연동하여, SLO 기반 알람을 통해 문제를 인지하고, 로그와 트레이스에서 해당 intent_id
를 검색하여 문제의 근본 원인을 수 분 내에 찾아내는 강력한 디버깅 환경을 구축할 수 있습니다.
우리 시스템의 데이터와 PG사의 실제 정산 데이터가 1원이라도 틀리면 큰 문제가 됩니다. 따라서 최종적인 데이터 정합성을 보장하는 장치가 반드시 필요합니다.
웹훅으로 실시간 변경을 처리하고, 폴링으로 하루에 한 번씩 전체 데이터를 검증하는 두 가지 방식을 함께 사용하면 데이터 정합성을 거의 완벽에 가깝게 유지할 수 있습니다.
지금까지 다룬 상태 기반 설계, 멱등성, 비동기 처리(DLQ 포함), SLO 기반 모니터링, 정산 동기화와 같은 기술들은 단순히 기능을 구현하는 것을 넘어, 예상치 못한 상황에서도 스스로를 지키고, 문제가 발생했을 때 빠르고 정확하게 원인을 찾아 해결할 수 있는, 진정으로 '회복력 있는(Resilient) 결제 시스템'을 완성하는 핵심 요소들입니다.
이 글이 여러분의 결제 시스템 설계 여정에 깊이 있는 통찰과 실질적인 가이드를 제공했기를 바랍니다. 감사합니다.