Sale Commerce 결제 승인 트랜잭션

임선구·2026년 8월 28일

스파르타

목록 보기
24/28

💳 외부 결제 API를 트랜잭션 안에서 호출하면 안 되는 이유 — 결제 승인 구조 개선

Sale Commerce 프로젝트에서 PortOne을 이용한 결제 승인 기능을 구현했다.

처음 결제 로직을 보면

"결제와 관련된 작업이니까 전부 하나의 @Transactional로 묶는 게 더 안전하지 않을까?"

라는 생각을 하기 쉽다.

하지만 외부 API 호출까지 DB 트랜잭션 안에 포함하면 오히려 DB Lock을 불필요하게 오래 유지할 수 있다는 문제가 있었다.

이번 글에서는 외부 결제 API와 DB 트랜잭션의 범위를 분리하고, 동시에 들어오는 결제 승인 요청까지 방어한 과정을 정리한다.


🔥 문제 상황

결제 승인에는 크게 두 가지 종류의 작업이 있었다.

1. PortOne에서 실제 결제 상태와 금액 확인

2. 우리 DB에서
   주문 상태 확인
   Payment 저장
   주문 상태 변경

만약 이 전체 과정을 하나의 트랜잭션으로 묶고 주문에 Row Lock을 획득한 상태에서 PortOne API를 호출한다면 다음과 같은 구조가 된다.

Transaction 시작
 ↓
주문 Row Lock 획득
 ↓
PortOne API 호출
 ↓
외부 응답 대기...
 ↓
Payment 저장
 ↓
주문 상태 변경
 ↓
Commit

문제는 외부 API의 응답시간을 우리가 제어할 수 없다는 것이다.

PortOne이 빠르게 응답할 수도 있지만 네트워크나 외부 시스템 상황에 따라 응답이 늦어질 수도 있다.

그 시간 동안 DB의 주문 Row Lock도 계속 유지된다.

같은 주문에 접근하려는 다른 요청은 PortOne의 응답과 아무런 관계가 없음에도 Lock이 풀릴 때까지 기다리게 된다.


✅ 외부 API 호출과 DB 트랜잭션 분리

그래서 전체 결제 승인 과정을 두 단계로 분리했다.

[트랜잭션 밖]

주문 1차 검증
 ↓
PortOne 결제 상태 조회
 ↓
결제 금액 검증


[짧은 DB 트랜잭션]

주문 Row Lock 획득
 ↓
주문 상태 재검증
 ↓
Payment 저장
 ↓
주문 상태 변경
 ↓
Commit

핵심은 외부 네트워크 응답을 기다리는 동안 DB Lock을 유지하지 않는 것이었다.


🤔 주문을 앞에서 확인했는데 왜 다시 확인할까?

PortOne을 호출하기 전에 주문이 결제 가능한 상태인지 이미 확인했다.

그런데 DB 트랜잭션 안에서 다시 한번 주문 상태를 확인했다.

왜 같은 검증을 두 번 했을까?

동시 요청 때문이다.

예를 들어 두 개의 요청이 거의 동시에 들어온다고 생각해보자.

Request A : 주문 확인 → 결제 가능
Request B : 주문 확인 → 결제 가능

Request A : PortOne 확인
Request B : PortOne 확인

Request A : Payment 저장
Request B : Payment 저장 ❌

PortOne을 호출하는 동안 다른 요청이 먼저 결제를 완료했을 수 있다.

따라서 실제 DB를 변경하는 시점에는 주문에 Row Lock을 획득한 뒤 현재 시점의 주문 상태를 다시 확인해야 했다.

즉 두 검증의 목적이 다르다.

외부 API 호출 전 검증
→ 잘못된 요청을 빠르게 차단하기 위한 검증

DB Lock 획득 후 검증
→ 동시 요청에서도 정합성을 보장하기 위한 최종 검증

🔐 DB Unique Constraint를 최종 방어선으로

애플리케이션 코드에서 중복 결제를 검사하더라도 예측하지 못한 동시 요청이 존재할 수 있다.

그래서 PortOne 결제 식별자에도 DB Unique Constraint를 적용했다.

최종적으로 중복 결제를 여러 단계에서 방어하도록 구성했다.

애플리케이션 검증
+
주문 Row Lock
+
DB Unique Constraint

🧪 동일 주문에 결제 요청 2건을 동시에 보내봤다

실제 동시성을 확인하기 위해 테스트를 작성했다.

조건은 다음과 같다.

동일한 주문

동시 결제 승인 요청 : 2건

각 요청의 PortOne Payment ID는 서로 다름

결과는 다음과 같았다.

결제 성공                : 1건
이미 결제 완료된 요청     : 1건

DB에 저장된 Payment      : 1건
최종 Order 상태          : PAID

동일 주문에 두 개의 결제 승인 요청이 동시에 발생해도 실제 Payment는 하나만 생성되는 것을 검증했다.


💡 이번 개선에서 배운 점

이전에는 @Transactional의 범위가 넓으면 넓을수록 데이터를 안전하게 보호할 수 있다고 생각하기 쉬웠다.

하지만 트랜잭션 내부에 외부 네트워크 호출이 들어가는 순간 문제가 달라진다.

트랜잭션은 무조건 크게 묶는 것이 아니라

정합성을 위해 반드시 함께 처리되어야 하는 DB 작업만 가능한 짧게 묶는 것

이 중요했다.

특히 외부 시스템이 포함된 기능에서는

외부 API 성공
≠
내부 DB 성공

이라는 점도 함께 고려해야 한다는 것을 배웠다.

이 경험은 이후 환불 기능을 설계하면서 외부 시스템과 내부 DB의 부분 실패를 고민하는 데에도 이어졌다.

profile
끝까지 가면 내가 다 이겨

0개의 댓글