결제 시스템 (Payment System) - fitpassTeam/fitpass GitHub Wiki

결제 시스템 (Payment System)

시스템 개요

FitPass의 결제 시스템은 토스페이먼츠와 연동하여 안전하고 신뢰할 수 있는 결제 서비스를 제공합니다. 포인트 충전을 위한 결제 처리와 결제 실패/취소에 대한 완벽한 처리 로직을 포함합니다.

핵심 특징

  • 토스페이먼츠 연동: 국내 대표 PG사와의 안전한 결제
  • 실시간 결제: 즉시 결제 승인 및 포인트 충전
  • 완벽한 예외처리: 결제 실패/취소 시나리오 완벽 대응
  • 결제 이력 관리: 모든 결제 내역 추적 및 관리
  • 자동 환불: 결제 취소 시 포인트 자동 차감

결제 상태 관리

상태 설명 발생 시점 다음 상태
PENDING 결제 대기 결제 요청 생성 시 CONFIRMED, FAILED
CONFIRMED 결제 완료 토스 결제 승인 시 CANCELLED
FAILED 결제 실패 결제 승인 실패 시 -
CANCELLED 결제 취소 관리자/사용자 취소 시 -

결제 생명주기

image

결제 프로세스

1단계 : 결제 준비

image

결제 준비 로직

@Transactional
public PaymentUrlResponseDto preparePayment(Long userId, Integer amount, String orderName) {
    User user = userRepository.findByIdOrElseThrow(userId);
    
    // 고유한 주문 ID 생성 (UUID + timestamp)
    String orderId = generateOrderId();
    
    // 결제 정보 저장 (PENDING 상태)
    Payment payment = Payment.builder()
        .orderId(orderId)
        .orderName(orderName)
        .amount(amount)
        .status(PaymentStatus.PENDING)
        .user(user)
        .build();
    
    paymentRepository.save(payment);
    
    return new PaymentUrlResponseDto(
        orderId, amount, orderName, user.getEmail(), user.getName(),
        tossPaymentConfig.getSuccessUrl(), tossPaymentConfig.getFailUrl()
    );
}

2단계 : 결제 승인

image

결제 승인 로직

@Transactional
public PaymentResponseDto confirmPayment(String paymentKey, String orderId, Integer amount) {
    // 1. 주문 정보 조회
    Payment payment = paymentRepository.findByOrderIdOrElseThrow(orderId);
    
    // 2. 결제 금액 검증
    if (!payment.getAmount().equals(amount)) {
        throw new BaseException(ExceptionCode.PAYMENT_AMOUNT_MISMATCH);
    }
    
    try {
        // 3. 토스페이먼츠 결제 승인 요청
        PaymentResponseDto tossResponse = tossPaymentClient.confirmPayment(
            paymentKey, orderId, amount);
        
        // 4. 결제 정보 업데이트
        payment.updatePaymentKey(paymentKey);
        payment.updateStatus(PaymentStatus.CONFIRMED);
        payment.updateMethod(tossResponse.method());
        
        // 5. 포인트 충전
        pointService.chargePoint(
            payment.getUser().getId(), 
            payment.getAmount(), 
            "토스페이먼츠 충전 - " + payment.getOrderName()
        );
        
        return tossResponse;
        
    } catch (Exception e) {
        payment.updateStatus(PaymentStatus.FAILED);
        payment.updateFailureReason(e.getMessage());
        throw new BaseException(ExceptionCode.TOSS_PAYMENT_CONFIRM_FAILED);
    }
}

토스페이먼츠 API 연동

API 클라이언트 구조

@Component
public class TossPaymentClient {
    
    // 결제 승인
    public PaymentResponseDto confirmPayment(String paymentKey, String orderId, Integer amount);
    
    // 결제 조회
    public PaymentResponseDto getPayment(String paymentKey);
    
    // 결제 취소
    public PaymentResponseDto cancelPayment(String paymentKey, String cancelReason);
}

인증 처리

private String encodeSecretKey(String secretKey) {
    return Base64.getEncoder()
        .encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8));
}

HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + auth);
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

API 엔드 포인트

기능 HTTP Method URL 설명
결제 승인 POST /v1/payments/confirm 결제 승인 처리
결제 조회 GET /v1/payments/{paymentKey} 결제 상태 조회
결제 취소 POST /v1/payments/{paymentKey}/cancel 결제 취소 처리

결제 실패 처리

실패 시나리오

  • 네트워크 오류: 토스페이먼츠 API 통신 실패
  • 결제 거부: 카드사/은행 결제 거부
  • 잔액 부족: 사용자 계좌 잔액 부족
  • 카드 문제: 카드 유효성 문제

실패 처리 로직

@Transactional
public void failPayment(String orderId, String failureReason) {
    Payment payment = paymentRepository.findByOrderIdOrElseThrow(orderId);
    
    payment.updateStatus(PaymentStatus.FAILED);
    payment.updateFailureReason(failureReason);
    
    log.info("결제 실패 처리 완료 - orderId: {}, reason: {}", orderId, failureReason);
}

결제 취소 시스템

취소 가능 조건

  • 결제 상태가 CONFIRMED인 경우
  • paymentKey가 존재하는 경우
  • 토스페이먼츠에서 취소 가능한 경우

취소 프로세스

image

취소 처리 로직

@Transactional
public PaymentResponseDto cancelPayment(String orderId, String cancelReason) {
    // 1. 주문 정보 조회
    Payment payment = paymentRepository.findByOrderIdOrElseThrow(orderId);
    
    // 2. 취소 가능한 상태인지 확인
    if (payment.getStatus() != PaymentStatus.CONFIRMED) {
        throw new BaseException(ExceptionCode.PAYMENT_NOT_CANCELLABLE);
    }
    
    try {
        // 3. 토스페이먼츠 결제 취소 요청
        PaymentResponseDto tossResponse = tossPaymentClient.cancelPayment(
            payment.getPaymentKey(), cancelReason);
        
        // 4. 결제 정보 업데이트
        payment.updateStatus(PaymentStatus.CANCELLED);
        payment.updateFailureReason(cancelReason);
        
        // 5. 포인트 차감 (충전했던 포인트 되돌리기)
        pointService.usePoint(
            payment.getUser().getId(), 
            payment.getAmount(), 
            "결제 취소로 인한 포인트 차감 - " + payment.getOrderName()
        );
        
        return tossResponse;
        
    } catch (Exception e) {
        throw new BaseException(ExceptionCode.TOSS_PAYMENT_CANCEL_FAILED);
    }
}

결제 이력 관리

저장 정보

@Entity
public class Payment extends BaseEntity {
    private String orderId;        // 주문 ID (유니크)
    private String paymentKey;     // 토스 결제 키
    private String orderName;      // 주문명
    private Integer amount;        // 결제 금액
    private PaymentStatus status;  // 결제 상태
    private String method;         // 결제 수단
    private User user;             // 결제 사용자
    private String failureReason;  // 실패 사유
}

이력 조회

@Transactional(readOnly = true)
public List<Payment> getPaymentHistory(Long userId) {
    return paymentRepository.findByUserIdOrderByCreatedAtDesc(userId);
}

결제보안

결제 검증

// 결제 금액 검증
if (!payment.getAmount().equals(amount)) {
    throw new BaseException(ExceptionCode.PAYMENT_AMOUNT_MISMATCH);
}

// 결제 상태 검증
if (payment.getStatus() != PaymentStatus.PENDING) {
    throw new BaseException(ExceptionCode.INVALID_PAYMENT_STATUS);
}

암호화 처리

  • API 키 Base64 인코딩
  • HTTPS 통신 강제
  • 민감 정보 로깅 방지

멱등성 보장

  • 동일한 orderId로 중복 요청 방지
  • 토스페이먼츠 측 멱등성 활용

API 명세

결제 관리

Method Endpoint 설명 권한
POST /api/payments/prepare 결제 준비 USER
POST /api/payments/confirm 결제 승인 USER
POST /api/payments/fail 결제 실패 처리 USER

결제 조회

Method Endpoint 설명 권한
GET /api/payments/history 결제 내역 조회 USER
GET /api/payments/status/{paymentKey} 결제 상태 조회 USER

결제 취소

Method Endpoint 설명 권한
POST /api/payments/cancel/{orderId} 결제 취소 USER, ADMIN

요청/응답 예시

결제 준비 요청

{
  "amount": 50000,
  "orderName": "포인트 충전"
}

결제 준비 응답

{
  "statusCode": 200,
  "message": "결제 준비가 완료되었습니다.",
  "data": {
    "orderId": "ORDER_20250702143022_a1b2c3d4",
    "amount": 50000,
    "orderName": "포인트 충전",
    "customerEmail": "[email protected]",
    "customerName": "김피트",
    "successUrl": "https://fitpass.com/payment/success",
    "failUrl": "https://fitpass.com/payment/fail"
  }
}

결제 승인 요청

{
  "paymentKey": "5zJ4xY7m0kODnyRpQWGrN2xqGlNvLrKwv1M9ENjbeoPaZdL6",
  "orderId": "ORDER_20250702143022_a1b2c3d4",
  "amount": 50000
}

결제 승인 응답

{
  "statusCode": 200,
  "message": "결제가 성공적으로 완료되었습니다.",
  "data": {
    "paymentKey": "5zJ4xY7m0kODnyRpQWGrN2xqGlNvLrKwv1M9ENjbeoPaZdL6",
    "orderId": "ORDER_20250702143022_a1b2c3d4",
    "orderName": "포인트 충전",
    "method": "카드",
    "totalAmount": 50000,
    "status": "DONE",
    "requestedAt": "2025-07-02T14:30:22",
    "approvedAt": "2025-07-02T14:30:25"
  }
}

비즈니스 규칙

결제 제약사항

  • 최소 결제 금액: 1,000원
  • 최대 결제 금액: 1,000,000원
  • 결제 단위: 1,000원 단위
  • 일일 결제 한도: 3,000,000원

취소 정책

  • 취소 가능 기간: 결제 완료 후 30일
  • 부분 취소: 지원하지 않음 (전액 취소만)
  • 취소 수수료: 없음
  • 취소 처리 시간: 즉시 처리

환불 정책

  • 포인트 환불: 즉시 차감
  • 현금 환불: 토스페이먼츠 정책 따름
  • 카드 환불: 영업일 기준 3-5일
  • 계좌 환불: 영업일
⚠️ **GitHub.com Fallback** ⚠️