Spring 개발일지

[팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 생성 API 구현

김둘리 2026. 4. 30. 09:35

도메인 엔티티 구현이 끝나고 본격적으로 API를 구현했다. 단순해 보이는 결제 생성 API였는데 멱등성 처리와 동시성 문제까지 다루게 됐다.


1. 내부 API vs 외부 API

결제 생성 API를 구현하기 전에 내부 API와 외부 API의 차이를 정리했다.

외부 API는 클라이언트(앱/웹)에서 Gateway를 통해 호출하는 API다.

사용자 → 클라이언트 → Gateway → 결제 서비스

내부 API는 서버끼리만 통신하는 API다. 클라이언트는 절대 호출하면 안 된다.

Booking 서비스 → 결제 서비스 (Gateway 거치지 않음)

결제 생성은 Booking 서비스가 예매 확정 시 호출하는 내부 API라서 /internal/v1/payments로 설계했다. Gateway에 등록하지 않아 외부 접근을 원천 차단한다.

컨트롤러도 역할에 따라 분리했다.

// 외부 API — 클라이언트가 호출
@RequestMapping("/api/v1/payments")
public class PaymentController { }

// 내부 API — Booking 서비스만 호출
@RequestMapping("/internal/v1/payments")
public class PaymentInternalController { }

2. 구현 흐름

PaymentCreateRequest (presentation)
→ toCommand()
→ CreatePaymentCommand (application)
→ PaymentCommandService.createPayment()
→ Payment.create() (domain)
→ PaymentRepository.save()
→ PaymentResult (application)
→ from(Payment)
→ PaymentResponse (presentation)

orderId 생성

토스페이먼츠는 결제창을 띄울 때 orderId가 필수다. 토스 orderId 규칙은 다음과 같다.

  • 영문 대소문자, 숫자, 특수문자 -, _, =, ., @ 만 허용
  • 6자 이상 64자 이하

UUID에서 하이픈을 제거한 32자리로 생성했다.

java
String orderId = UUID.randomUUID().toString().replace("-", "");

3. 멱등성 처리 (1차 삽질)

처음 구현은 단순했다. 그런데 코드래빗 리뷰에서 Critical 지적이 왔다.

결제 생성 경로에 멱등성 보장이 없어 중복 결제가 생성될 수 있습니다. 현재 구현은 동일 bookingId 재시도 요청마다 새 결제 레코드를 생성합니다.

Booking 서비스가 네트워크 타임아웃으로 재시도하면 같은 예매에 결제가 2개 생성될 수 있었다.

orderId unique 제약으로는 부족하다

처음엔 orderId에 unique 제약이 있으니 괜찮다고 생각했는데 틀렸다.

1번 요청 → orderId: "abc123" → 저장 성공
2번 요청 (같은 bookingId) → orderId: "xyz789" (새로 생성) → 저장 성공 ← 중복 결제!

orderId는 매번 새로 생성되는 값이라 unique 제약이 있어도 중복 결제가 생긴다.

해결: bookingId unique + findByBookingId

java
// Payment 엔티티
@Column(name = "booking_id", nullable = false, unique = true)
private UUID bookingId;
 
java
// PaymentCommandService
@Transactional
public PaymentResult createPayment(CreatePaymentCommand command) {
    return paymentRepository.findByBookingId(command.bookingId())
            .map(PaymentResult::from)
            .orElseGet(() -> {
                String orderId = UUID.randomUUID().toString().replace("-", "");
                Payment payment = Payment.create(
                        command.bookingId(),
                        command.userId(),
                        orderId,
                        command.finalAmount()
                );
                Payment savedPayment = paymentRepository.save(payment);
                return PaymentResult.from(savedPayment);
            });
}

같은 bookingId로 요청이 오면 기존 결제를 반환하고 새로 생성하지 않는다.


4. Race Condition 처리 (2차 삽질)

멱등성 처리를 했더니 또 리뷰가 왔다.

동시 요청 시 유니크 제약 위반으로 500 에러 가능성 두 요청이 모두 findByBookingId() 검사를 통과한 후 동일한 bookingId로 save()를 시도하면, 한 요청은 성공하고 다른 요청은 DataIntegrityViolationException으로 실패합니다.

동시 요청 A, B 둘 다 findByBookingId() 통과 (둘 다 없다고 판단)
→ A save() 성공
→ B save() 시도 → bookingId unique 위반 → DataIntegrityViolationException 발생
→ 500 에러 반환

해결: DataIntegrityViolationException catch 후 재조회

 
 
java
@Transactional
public PaymentResult createPayment(CreatePaymentCommand command) {
    return paymentRepository.findByBookingId(command.bookingId())
            .map(PaymentResult::from)
            .orElseGet(() -> {
                String orderId = UUID.randomUUID().toString().replace("-", "");
                Payment payment = Payment.create(
                        command.bookingId(),
                        command.userId(),
                        orderId,
                        command.finalAmount()
                );
                try {
                    Payment savedPayment = paymentRepository.save(payment);
                    return PaymentResult.from(savedPayment);
                } catch (DataIntegrityViolationException e) {
                    // 동시성 충돌: unique(booking_id) 위반 시 기존 결제 반환
                    return paymentRepository.findByBookingId(command.bookingId())
                            .map(PaymentResult::from)
                            .orElseThrow(() -> e);
                }
            });
}

동시 요청 B가 DataIntegrityViolationException을 받으면 재조회해서 A가 저장한 결제를 반환한다. 500 에러 대신 정상 응답을 돌려줄 수 있다.


5. 내부 API 접근 제어 (후속 이슈)

코드래빗이 또 지적했다.

Spring Security가 없으며 내부 경로에 접근 제한이 코드 레벨에서 보장되지 않습니다.

현재는 Gateway에서 /internal/** 차단으로 외부 접근을 막을 예정이고, Keycloak + Spring Security 연동 시 코드 레벨 접근 제어도 추가할 예정이다. 그 전까지는 @ConditionalOnProperty로 엔드포인트 자체를 프로퍼티로 제어하는 방식을 후속 이슈로 등록했다.

 
 
java
@ConditionalOnProperty(prefix = "feature.internal-payments", name = "enabled", havingValue = "true")
@RestController
@RequestMapping("/internal/v1/payments")
public class PaymentInternalController { }
 
 
yaml
feature:
  internal-payments:
    enabled: true

6. 포스트맨 동작 확인

H2 인메모리 DB로 로컬 테스트를 진행했다.

 
 
yaml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
  flyway:
    enabled: false
  config:
    import: "optional:configserver:"
 
 
json
POST http://localhost:8085/internal/v1/payments

{
    "bookingId": "550e8400-e29b-41d4-a716-446655440000",
    "userId": "660e8400-e29b-41d4-a716-446655440001",
    "finalAmount": 50000
}
 
 
json
{
    "success": true,
    "code": "CREATED",
    "message": "리소스가 생성되었습니다",
    "data": {
        "paymentId": "e84af7b3-f3fa-4cc0-9557-c2408418eb95",
        "orderId": "d3999bd847f448f5880782a1a247edc9",
        "amount": 50000
    }
}

마무리

단순해 보이는 결제 생성 API였는데 멱등성과 동시성까지 다루게 됐다.

  • orderId unique만으로는 중복 결제를 막을 수 없다
  • bookingId unique + findByBookingId로 애플리케이션 레벨 멱등성을 보장한다
  • DataIntegrityViolationException catch로 동시 요청의 race condition을 처리한다

다음 편에서는 토스페이먼츠 ACL 구현과 결제 승인 API 삽질기를 다룰 예정이다.