FitPass는 피트니스 센터 예약 플랫폼으로, 사용자가 포인트를 통해 PT 세션을 예약할 수 있는 서비스이다. 기존에는 관리자가 수동으로 포인트를 충전해주는 방식이었지만, 사용자 경험 개선과 운영 효율성을 위해 자동화된 결제 시스템 도입을 결정했다.
Controller → Service → Repository → Entity
↓
Payment Client (토스페이먼츠 API)
@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;
}
@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";
}
@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));
}
}
@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());
}
}
}
private String generateOrderId() {
String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String uuid = UUID.randomUUID().toString()
.replace("-", "").substring(0, 8);
return "ORDER_" + timestamp + "_" + uuid;
}
설계 이유:
public enum PaymentStatus {
PENDING, // 결제 대기 (주문 생성됨)
CONFIRMED, // 결제 승인 완료
FAILED, // 결제 실패
CANCELLED, // 결제 취소
REFUNDED // 환불 완료
}
// 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()와 비교 검증
@Transactional
public PaymentResponseDto confirmPayment(PaymentConfirmRequestDto request) {
// 1. 결제 정보 조회 및 검증
// 2. 토스페이먼츠 API 호출
// 3. 결제 상태 업데이트
// 4. 포인트 충전 처리
// 모든 작업이 성공해야 커밋, 하나라도 실패하면 롤백
}
트랜잭션 범위:
@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();
}
}
@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;
}
}
}