이 글은 법률 문서 서비스에 내용증명서 발송대행 결제 기능을 구현하면서 배운 커머스 핵심 개념을 정리한 글입니다.
들어가며
"내용증명서 발송대행" 상품을 결제할 수 있는 기능을 만들면서, 단순한 API 구현을 넘어 커머스 서비스가 돈을 안전하게 다루기 위해 반드시 알아야 할 개념들을 배웠다. 멱등성, 상태 머신, 트랜잭션 함정, 보안 취약점, 그리고 PG 추상화 아키텍처 — 이 5가지를 실제 코드와 함께 정리한다.
기술 스택은 Kotlin + Spring Boot 3.5 + TossPayments + JPA/PostgreSQL이며, 헥사고날 아키텍처(Ports & Adapters) 기반으로 구성했다.
1. 결제 승인의 멱등성 (Idempotent Payment)
커머스에서 가장 무서운 시나리오는 "결제는 됐는데 주문은 안 됐어요"다. 네트워크 타임아웃, 사용자의 새로고침, 모바일 연결 불안정 등으로 동일한 결제 승인 요청이 서버에 두 번 이상 도달하는 상황은 반드시 발생한다.
문제 상황
클라이언트 → POST /confirm → PG 승인 → 응답 타임아웃!
↓
클라이언트가 같은 요청을 재시도
↓
서버가 중복 요청을 처리하면?
→ 이중 결제 or 에러 응답
클라이언트가 응답을 받지 못하면 같은 요청을 다시 보낸다. 서버가 "이미 처리된 결제"를 인식하지 못하면 이중 결제나 에러 응답이 발생한다.
해결: 멱등 응답 패턴
핵심은 간단하다 — "이미 완료된 상태라면, 에러 대신 성공 응답을 그대로 다시 반환한다."
@Transactional
fun confirmPayment(orderId: UUID, paymentKey: String, amount: Long, userId: UUID): OrderConfirmResult {
val order = orderRepository.findById(orderId)
?: throw CoreException(ErrorType.ORDER_NOT_FOUND)
// 멱등성: 이미 PAID인 주문이면 그대로 반환
if (order.isPaid()) {
return OrderConfirmResult(order, null)
}
// 만료 확인 → 금액 검증 → PG 승인 → 상태 변경 ...
}
왜 에러를 던지면 안 될까? 클라이언트 입장에서는 첫 번째 요청이 성공했는지 알 수 없는 상태에서 재시도한 것이다. 에러를 받으면 "결제 실패"로 판단하고 다른 결제 수단을 시도하거나 고객센터에 문의하게 된다. 실제로는 이미 성공했으므로, 동일한 성공 응답을 돌려주는 것이 올바른 처리다.
관리자 취소에서의 멱등성
이 패턴은 결제 승인뿐 아니라 취소 API에서도 동일하게 적용된다.
fun cancelOrder(orderId: UUID, cancelReason: String): OrderEntity {
val order = orderRepository.findById(orderId)
?: throw CoreException(ErrorType.NOT_FOUND_DATA)
// 멱등성: 이미 취소된 주문이면 그대로 반환
if (order.orderStatus == OrderStatus.CANCELLED) {
return order
}
// PAID 상태인 경우에만 PG 결제 취소 호출
if (order.isPaid()) {
paymentGateway.cancel(paymentKey, cancelReason)
}
order.cancel(cancelReason)
// ...
}
PENDING 주문과 PAID 주문의 취소는 다르다. PENDING 주문은 아직 PG에 결제 승인이 되지 않은 상태이므로
paymentKey가 null이다. PG 취소 API를 호출하면 에러가 발생한다. 주문 상태에 따라 PG 호출 여부를 분기하는 것이 핵심이다.
주문 상태별 취소 동작
| 현재 상태 | 취소 요청 시 동작 | PG 호출 |
|---|---|---|
| PENDING | DB 상태만 CANCELLED로 변경 | X |
| PAID | PG 환불 후 DB 상태 변경 | O |
| CANCELLED | 멱등 응답 (그대로 반환) | X |
| COMPLETED | 예외 발생 (취소 불가) | X |
2. 주문 상태 머신 — 불변식을 코드로 강제하기
주문은 정해진 흐름 안에서만 상태가 전이되어야 한다. "PENDING에서 COMPLETED로 바로 갈 수 없다" 같은 규칙을 문서가 아닌 코드로 강제하는 것이 상태 머신(State Machine) 패턴의 핵심이다.
상태 전이 다이어그램
PENDING ──pay()──→ PAID ──complete()──→ COMPLETED
│ │
│──cancel()──→ CANCELLED ←──cancel()──│
require로 전이 규칙 강제
fun pay(paymentKey: String) {
require(orderStatus == OrderStatus.PENDING) {
"결제 가능한 상태가 아닙니다. 현재 상태: $orderStatus"
}
this.orderStatus = OrderStatus.PAID
this.paymentKey = paymentKey
this.paidAt = LocalDateTime.now()
}
fun complete() {
require(orderStatus == OrderStatus.PAID) {
"이행 완료 가능한 상태가 아닙니다. 현재 상태: $orderStatus"
}
this.orderStatus = OrderStatus.COMPLETED
}
fun cancel(reason: String) {
require(orderStatus == OrderStatus.PENDING || orderStatus == OrderStatus.PAID) {
"취소 가능한 상태가 아닙니다. 현재 상태: $orderStatus"
}
this.orderStatus = OrderStatus.CANCELLED
this.cancelReason = reason
this.cancelledAt = LocalDateTime.now()
}
상태 전이 로직이 엔티티 안에 캡슐화되어 있으므로, 어떤 서비스에서 호출하든 잘못된 전이는 IllegalArgumentException으로 즉시 차단된다. "COMPLETED인 주문을 다시 PAID로 바꾸는 버그"는 구조적으로 불가능하다.
만료 검증도 도메인 모델에
결제 대기 시간(30분)이 지난 주문은 더 이상 결제가 불가능해야 한다. 이 로직도 엔티티에 위치한다.
fun isExpired(): Boolean =
isPending() && createdAt.plusMinutes(30).isBefore(LocalDateTime.now())
서비스 레이어에서는 단순히 if (order.isExpired()) throw ...으로 호출한다. "만료 기준이 30분에서 1시간으로 바뀌면?" — 엔티티 한 곳만 수정하면 된다.
3. @Transactional 자기호출 함정
Spring의 @Transactional은 프록시 기반 AOP로 동작한다. 이 메커니즘의 가장 흔한 실수는 같은 클래스 내부에서 @Transactional 메서드를 호출하는 것이다.
프록시의 동작 원리
클라이언트 ──→ Proxy (트랜잭션 시작) ──→ Real Object
↑
Spring이 자동 생성한 래퍼
외부에서 호출하면 프록시를 거치므로 @Transactional이 적용된다. 하지만 내부 호출은 this를 통해 직접 호출하므로 프록시를 우회한다.
잘못된 코드 (트랜잭션 미적용)
class OrderConfirmService {
fun confirmPayment(...) {
// PG 승인 호출
paymentGateway.confirm(...)
// ❌ 내부 호출 → 프록시 우회!
this.processPaymentSuccess(...)
}
@Transactional // 이 어노테이션이 적용되지 않음!
protected fun processPaymentSuccess(...) {
order.pay(paymentKey)
orderRepository.save(order)
deliveryRequestRepository.save(...)
}
}
수정된 코드 (트랜잭션 적용)
class OrderConfirmService {
@Transactional
fun confirmPayment(...) {
// PG 승인 호출
paymentGateway.confirm(...)
// 같은 메서드 안에서 직접 처리
order.pay(paymentKey)
orderRepository.save(order)
deliveryRequestRepository.save(...)
}
// processPaymentSuccess 제거
}
실제 장애 시나리오: 트랜잭션 없이
order.pay()와deliveryRequestRepository.save()가 실행되면, 주문 상태는 PAID로 변경됐는데 발송대행 요청 생성에서 예외가 발생할 경우 결제는 됐지만 발송 접수는 안 된 불일치 상태가 된다. 트랜잭션이 걸려있어야 둘 다 롤백된다.
해결 방법 3가지
| 방법 | 설명 | 권장도 |
|---|---|---|
| 메서드 인라인 | 내부 메서드를 호출 메서드에 합치기 | 가장 간단 (권장) |
| 별도 서비스 분리 | 새로운 @Service 클래스로 분리하여 외부 호출로 만들기 | 복잡한 로직일 때 |
| self-injection | 자기 자신을 DI받아 프록시 통해 호출 | 비권장 (혼란 유발) |
4. IDOR 취약점과 소유권 검증
IDOR (Insecure Direct Object Reference)는 OWASP Top 10에 포함된 대표적인 웹 취약점이다. 커머스에서는 특히 위험하다 — 다른 사용자의 주문 내역, 결제 정보, 배송지를 볼 수 있기 때문이다.
취약한 코드
// orderId만 있으면 누구의 주문이든 조회 가능!
@GetMapping("/{orderId}")
fun getOrder(@PathVariable orderId: UUID): ApiResponse<OrderResponse> {
val order = orderQueryService.getOrder(orderId) // 소유권 검증 없음
return ApiResponse.success(OrderResponse.of(order))
}
공격자가 UUID를 추측하거나 브루트포스하면, 인증된 사용자라는 이유만으로 모든 주문에 접근할 수 있다.
수정된 코드
@GetMapping("/{orderId}")
fun getOrder(
@AuthenticationPrincipal user: UserDetail,
@PathVariable orderId: UUID,
): ApiResponse<OrderResponse> {
val order = orderQueryService.getOrder(orderId, user.userId)
return ApiResponse.success(OrderResponse.of(order))
}
// Service 레이어에서 소유권 검증
fun getOrder(orderId: UUID, userId: UUID): OrderEntity {
val order = orderRepository.findById(orderId)
?: throw CoreException(ErrorType.ORDER_NOT_FOUND)
if (order.userId != userId) {
throw CoreException(ErrorType.ACCESS_DENIED)
}
return order
}
커머스에서 IDOR이 특히 위험한 이유: 결제 승인 API에 소유권 검증이 없으면, 공격자가 다른 사용자의 PENDING 주문에 대해 자신의 결제 수단으로 승인을 시도할 수 있다. 단순 정보 유출을 넘어 비즈니스 로직 자체를 조작할 수 있는 심각한 취약점이다.
검증이 필요한 엔드포인트 체크리스트
| API | 검증 대상 | 검증 위치 |
|---|---|---|
POST /confirm |
결제 승인 요청자 = 주문 생성자 | OrderConfirmService |
GET /orders/{id} |
조회 요청자 = 주문 소유자 | OrderQueryService |
GET /deliveries/{id} |
조회 요청자 = 발송 요청자 | DeliveryQueryService |
관리자(Admin) API는 모든 주문을 조회/취소할 수 있어야 하므로 소유권 검증이 필요 없다. 대신 관리자 인증 자체가 더 강력해야 한다. 사용자 API와 관리자 API를 별도 모듈(core-api, core-admin)로 분리한 것도 이런 이유다.
5. PG사 교체 가능한 결제 아키텍처
오늘 토스페이먼츠를 쓰더라도, 내일 나이스페이먼츠나 KG이니시스로 바꿔야 할 수 있다. 수수료, 기능, 비즈니스 요구사항은 언제든 변한다. 포트/어댑터(Hexagonal) 패턴으로 PG사 교체를 구조적으로 가능하게 만들었다.
레이어 구조
┌─────────────────────────────────────────────────┐
│ Adapter Layer — OrderApi, DeliveryApi (REST) │
├─────────────────────────────────────────────────┤
│ Application Layer — OrderConfirmService │
├─────────────────────────────────────────────────┤
│ Port (인터페이스) — PaymentGateway │
├─────────────────────────────────────────────────┤
│ Infrastructure — TossPaymentsGateway (구현체) │
└─────────────────────────────────────────────────┘
Port: 결제 추상화 인터페이스
// support/payment/PaymentGateway.kt
interface PaymentGateway {
fun confirm(paymentKey: String, orderId: String, amount: Long): PaymentConfirmResult
fun cancel(paymentKey: String, cancelReason: String): PaymentCancelResult
}
비즈니스 로직(OrderConfirmService)은 이 인터페이스만 알고 있다. 토스인지 나이스인지 전혀 모른다.
Adapter: TossPayments 구현체
// support/payment/tosspayments/TossPaymentsGateway.kt
@Component
class TossPaymentsGateway(
private val properties: TossPaymentsProperties,
private val restClient: RestClient,
) : PaymentGateway {
override fun confirm(paymentKey: String, orderId: String, amount: Long): PaymentConfirmResult {
val response = restClient.post()
.uri("/v1/payments/confirm")
.headers { it.setBasicAuth(encodeSecretKey()) }
.body(mapOf(
"paymentKey" to paymentKey,
"orderId" to orderId,
"amount" to amount,
))
.retrieve()
// ...
}
}
PG사 교체 시 변경 범위
| 변경 대상 | 변경 필요? | 이유 |
|---|---|---|
| PaymentGateway 인터페이스 | X | 추상화 계층은 유지 |
| OrderConfirmService | X | 인터페이스만 의존 |
| NicePaymentsGateway (신규) | O — 새로 작성 | 새 PG 구현체 추가 |
| application.yml 설정 | O — 변경 | 새 PG 설정값 |
| TossPaymentsGateway | 삭제 또는 비활성화 | 더 이상 사용하지 않음 |
support/payment를 별도 Gradle 모듈로 분리했기 때문에, PG 구현체의 변경이core-api의 컴파일에 영향을 주지 않는다. 또한 PG 연동 테스트를 독립적으로 실행할 수 있어, CI 파이프라인에서도 효율적이다.
마치며
결제 시스템은 "동작하는 코드"와 "안전한 코드" 사이의 간극이 가장 큰 영역이다. 이번 구현을 통해 배운 핵심을 요약하면:
- 멱등성은 선택이 아닌 필수 — 네트워크는 신뢰할 수 없고, 클라이언트는 반드시 재시도한다.
- 상태 전이는 도메인 모델 안에서 — 서비스가 아닌 엔티티가 자신의 상태를 지켜야 한다.
- @Transactional의 프록시를 이해하라 — 자기호출은 트랜잭션을 무력화한다.
- 인증 ≠ 인가 — 로그인했다고 모든 리소스에 접근할 수 있는 것이 아니다.
- 외부 의존성은 반드시 추상화 — 오늘의 선택이 영원하지 않다.
참고
- TossPayments v2 연동 가이드 — 결제 승인/취소 API 상세 스펙
- OWASP IDOR Prevention Cheat Sheet — 소유권 검증 패턴 모음
- Patterns of Enterprise Application Architecture (Martin Fowler) — 트랜잭션 스크립트 vs 도메인 모델