[FitPass] 토스페이먼츠를 활용한 포인트 충전 시스템 구축기

김현정·2025년 6월 30일
0

토스페이먼츠를 활용한 포인트 충전 시스템 구축기

프로젝트 개요

FitPass는 피트니스 센터 예약 플랫폼으로, 사용자가 포인트를 통해 PT 세션을 예약할 수 있는 서비스이다. 기존에는 관리자가 수동으로 포인트를 충전해주는 방식이었지만, 사용자 경험 개선과 운영 효율성을 위해 자동화된 결제 시스템 도입을 결정했다.

기술적 의사결정

1. 결제 서비스 선택 : 토스 페이먼츠

선택 이유 :

  • 개발자 친화적 API : 직관적인 RESTful API와 상세한 문서 제공
  • 간편한 연동 : Payment Widget을 통한 빠른 프론트엔드 구현

2. 아키텍처 설계 결정

계층형 아키텍처 채택 :

Controller → Service → Repository → Entity
     ↓
Payment Client (토스페이먼츠 API)

도메인 분리 :

  • payment : 결제 관련 로직 (토스페이먼츠 연동)
  • point : 포인트 관리 로직 (기존 시스템)
    분리를 통해서 결제 로직과 포인트 로직의 책임을 명확히 구분하고, 향후 다른 결제 수단 추가시 확장성을 확보했다.

기대 효과

1. 사용자 사용 로직

  • Before: 관리자에게 수동 충전 요청 → 처리 대기 → 충전 완료 (평균 1-2일)
  • After: 실시간 자동 충전 완료 (평균 30초)

구현 방법

1. 엔티티

 @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true)
    private String orderId;
    
    @Column(unique = true)
    private String paymentKey;
    
    @Column(nullable = false)
    private String orderName;
    
    @Column(nullable = false)
    private Integer amount;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private PaymentStatus status;
    
    private String method;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
    
    @Column(name = "toss_order_id")
    private String tossOrderId;
    
    @Column(name = "failure_reason")
    private String failureReason;
    
    public void updatePaymentKey(String paymentKey) {
        this.paymentKey = paymentKey;
    }
    
    public void updateStatus(PaymentStatus status) {
        this.status = status;
    }
    
    public void updateMethod(String method) {
        this.method = method;
    }
    
    public void updateFailureReason(String failureReason) {
        this.failureReason = failureReason;
    }
  • order_id: 내부 주문 관리용 고유 ID
  • payment_key: 토스페이먼츠에서 제공하는 결제 고유 키
  • status: 결제 상태를 열거형으로 관리하여 타입 안전성 확보

2. 결제 설정 구성

@Configuration
@Getter
public class TossPaymentConfig {
    
    @Value("${toss.payments.test-client-key}")
    private String clientKey;
    
    @Value("${toss.payments.test-secret-key}")
    private String secretKey;
    
    @Value("${toss.payments.success-url}")
    private String successUrl;
    
    @Value("${toss.payments.fail-url}")
    private String failUrl;
    
    public static final String TOSS_API_URL = "https://api.tosspayments.com/v1/payments";
}
  • API 키는 환경변수로 관리
  • Secret Key는 서버에서만 사용하고 프론트엔드에 노출 금지

3. 토스페이먼츠 API 클라이언트 구현

@Component
@RequiredArgsConstructor
@Slf4j
public class TossPaymentClient {
    
    private final TossPaymentConfig config;
    private final WebClient webClient = WebClient.builder().build();
    private final ObjectMapper objectMapper = new ObjectMapper()
        .registerModule(new JavaTimeModule());
    
    public PaymentResponseDto confirmPayment(PaymentConfirmRequestDto request) {
        try {
            String auth = encodeSecretKey(config.getSecretKey());
            
            String response = webClient.post()
                .uri(TossPaymentConfig.TOSS_CONFIRM_URL)
                .header(HttpHeaders.AUTHORIZATION, "Basic " + auth)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .bodyValue(request)
                .retrieve()
                .bodyToMono(String.class)
                .block();
            
            return objectMapper.readValue(response, PaymentResponseDto.class);
            
        } catch (Exception e) {
            log.error("토스페이먼츠 결제 승인 실패", e);
            throw new RuntimeException("결제 승인 처리 중 오류가 발생했습니다", e);
        }
    }
    
    private String encodeSecretKey(String secretKey) {
        return Base64.getEncoder()
            .encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8));
    }
}
  • WebClient를 사용한 비동기 HTTP 통신
  • Basic Authentication을 위한 Base64 인코딩
  • JavaTimeModule 등록으로 LocalDateTime 직렬화 지원

4. 결제 서비스 로직

@Service
@RequiredArgsConstructor
@Transactional
public class PaymentService {
    
    // 결제 준비 - 주문 ID 생성 및 초기 상태 저장
    public PaymentUrlResponseDto preparePayment(Long userId, PaymentRequestDto request) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다"));
        
        String orderId = generateOrderId(); // UUID + timestamp 조합
        
        Payment payment = Payment.builder()
            .orderId(orderId)
            .orderName(request.orderName())
            .amount(request.amount())
            .status(PaymentStatus.PENDING)
            .user(user)
            .build();
        
        paymentRepository.save(payment);
        
        return new PaymentUrlResponseDto(
            orderId, request.amount(), request.orderName(),
            user.getEmail(), user.getName(),
            config.getSuccessUrl(), config.getFailUrl()
        );
    }
    
    // 결제 승인 처리
    public PaymentResponseDto confirmPayment(PaymentConfirmRequestDto request) {
        Payment payment = paymentRepository.findByOrderId(request.orderId())
            .orElseThrow(() -> new RuntimeException("주문 정보를 찾을 수 없습니다"));
        
        // 금액 검증 - 프론트엔드에서 전송된 금액과 DB 저장 금액 비교
        if (!payment.getAmount().equals(request.amount())) {
            throw new RuntimeException("결제 금액이 일치하지 않습니다");
        }
        
        try {
            // 토스페이먼츠 결제 승인 요청
            PaymentResponseDto tossResponse = tossPaymentClient.confirmPayment(request);
            
            // 결제 정보 업데이트
            payment.updatePaymentKey(request.paymentKey());
            payment.updateStatus(PaymentStatus.CONFIRMED);
            payment.updateMethod(tossResponse.method());
            
            // 포인트 충전 - 기존 포인트 시스템과 연동
            pointService.chargePoint(
                payment.getUser().getId(), 
                payment.getAmount(), 
                "토스페이먼츠 충전 - " + payment.getOrderName()
            );
            
            return tossResponse;
            
        } catch (Exception e) {
            payment.updateStatus(PaymentStatus.FAILED);
            payment.updateFailureReason(e.getMessage());
            throw new RuntimeException("결제 승인에 실패했습니다: " + e.getMessage());
        }
    }
}

상세 로직 설명

1. 결제 플로우

2. 주문ID 생성 로직

private String generateOrderId() {
    String timestamp = LocalDateTime.now()
        .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
    String uuid = UUID.randomUUID().toString()
        .replace("-", "").substring(0, 8);
    return "ORDER_" + timestamp + "_" + uuid;
}
  • 생성 예시: ORDER_20241229143022_a1b2c3d4

설계 이유:

  • 고유성 보장: UUID로 충돌 방지
  • 시간 정보 포함: 디버깅 및 분석 시 유용
  • 제한된 길이: 로그 가독성 고려

결제 상태 관리

public enum PaymentStatus {
    PENDING,        // 결제 대기 (주문 생성됨)
    CONFIRMED,      // 결제 승인 완료
    FAILED,         // 결제 실패
    CANCELLED,      // 결제 취소
    REFUNDED        // 환불 완료
}

4. 금액 검증 로직

// 1차 검증: 프론트엔드에서 최소 금액 체크
@Min(value = 1000, message = "최소 충전 금액은 1,000원입니다")
private Integer amount;

// 2차 검증: 백엔드에서 저장된 금액과 비교
if (!payment.getAmount().equals(request.amount())) {
    throw new RuntimeException("결제 금액이 일치하지 않습니다");
}

// 3차 검증: 토스페이먼츠에서 실제 결제된 금액 확인
PaymentResponseDto tossResponse = tossPaymentClient.confirmPayment(request);
// tossResponse.amount()와 비교 검증

5. 트랜잭션 관리

@Transactional
public PaymentResponseDto confirmPayment(PaymentConfirmRequestDto request) {
    // 1. 결제 정보 조회 및 검증
    // 2. 토스페이먼츠 API 호출
    // 3. 결제 상태 업데이트
    // 4. 포인트 충전 처리
    
    // 모든 작업이 성공해야 커밋, 하나라도 실패하면 롤백
}

트랜잭션 범위:

  • 결제 상태 업데이트와 포인트 충전을 하나의 트랜잭션으로 처리
  • 일관성 보장: 결제는 성공했지만 포인트 충전이 실패하는 상황 방지

모니터링 및 로깅

1. 결제 성공률 추적

@Component
public class PaymentMetrics {
    
    private final MeterRegistry meterRegistry;
    
    public void recordPaymentSuccess() {
        Counter.builder("payment.success")
            .register(meterRegistry)
            .increment();
    }
    
    public void recordPaymentFailure(String reason) {
        Counter.builder("payment.failure")
            .tag("reason", reason)
            .register(meterRegistry)
            .increment();
    }
}

2. 상세 로깅 설정

@Slf4j
public class PaymentService {
    
    public PaymentResponseDto confirmPayment(PaymentConfirmRequestDto request) {
        log.info("결제 승인 시작 - orderId: {}, amount: {}", 
                 request.orderId(), request.amount());
        
        try {
            // 결제 처리 로직
            log.info("결제 승인 완료 - orderId: {}", request.orderId());
            return response;
        } catch (Exception e) {
            log.error("결제 승인 실패 - orderId: {}, error: {}", 
                      request.orderId(), e.getMessage(), e);
            throw e;
        }
    }
}

마무리

  • 외부 API 연동 시 고려사항
  • 결제 시스템의 보안 요구사항
  • 트랜잭션 처리의 중요성
  • 확장 가능한 아키텍처 설계

0개의 댓글