Sale Commerce PortOne 환불 부분 실패

임선구·2026년 8월 28일

스파르타

목록 보기
26/28

💸 돈은 환불됐는데 우리 DB는 실패했다면? — PortOne 환불 부분 실패 다루기

Sale Commerce에서 환불 기능을 개발하면서 가장 어려웠던 부분은 PortOne 환불 API를 호출하는 것 자체가 아니었다.

진짜 고민은 다음 상황이었다.

PortOne에서는 실제 환불에 성공했는데
이후 우리 서버의 주문 상태 변경이나 재고 복구가 실패하면 어떻게 해야 할까?

외부 PG와 우리 MySQL DB를 하나의 데이터베이스 트랜잭션으로 묶을 수는 없다.

이번 글에서는 외부 환불 성공과 내부 처리를 단계별로 분리해 부분 실패가 발생해도 현재 상태를 추적할 수 있도록 개선한 과정을 정리한다.


🔥 외부 API와 DB는 함께 Rollback되지 않는다

일반적인 DB 작업이라면 하나의 트랜잭션 안에서 여러 작업을 수행하다가 하나가 실패하면 전체 작업을 Rollback할 수 있다.

하지만 외부 PG 환불은 다르다.

PortOne 환불 성공 ✅
        ↓
우리 DB 처리 실패 ❌

우리 DB 트랜잭션을 Rollback한다고 해서 이미 PortOne을 통해 사용자에게 반환된 돈이 다시 자동으로 결제되는 것은 아니다.

즉 다음과 같은 상황이 발생할 수 있다.

실제 PG 상태
→ 환불 완료

우리 DB
→ 환불 처리 실패

외부 시스템의 상태와 내부 DB의 상태가 서로 달라질 수 있는 것이다.


❌ 하나의 흐름으로만 처리하면?

환불을 다음과 같은 하나의 흐름으로만 구현했다고 생각해보자.

환불 요청
 ↓
PortOne 환불
 ↓
주문 상태 변경
 ↓
특가상품 재고 복구
 ↓
환불 완료 저장

PortOne 환불까지는 성공했다.

그런데 이후 재고 복구 과정에서 예외가 발생했다.

그러면 실제 사용자의 돈은 이미 환불됐지만 DB에는 최종 환불 완료가 기록되지 않을 수 있다.

사용자
→ 실제 환불 완료

PortOne
→ 환불 완료

우리 DB
→ 내부 처리 실패

운영자가 우리 DB만 확인한다면

"이 환불이 실제 PG에서도 성공했던 건가?"

를 판단하기 어려울 수 있다.


✅ 외부 환불 성공 사실을 먼저 확정

그래서 환불 흐름을 단계별로 나눴다.

1. 환불 요청 상태 저장

2. PortOne 환불 API 호출

3. 외부 환불 성공 정보 별도 저장

4. 주문 상태 변경
   특가상품 재고 복구

5. 내부 처리까지 성공하면 최종 환불 완료

가장 중요하게 본 것은 3번 단계였다.

PortOne에서 실제 환불이 성공했다면 이후 내부 처리에서 문제가 발생하더라도

"PG 환불 자체는 이미 성공했다."

는 사실만큼은 반드시 DB에 남아 있어야 한다고 판단했다.

그래서 PortOne 성공 결과를 별도의 트랜잭션으로 먼저 저장하도록 했다.


📌 어떤 정보를 먼저 저장했나?

PortOne 환불 성공 직후 다음 3개의 정보를 DB에 기록했다.

PortOne 취소 ID

PortOne 취소 상태

실제 취소 성공 시각

환불 상태도 단순히

환불 중
→ 환불 완료

두 단계로만 두지 않았다.

외부 환불 성공과 내부 처리 완료를 구분할 수 있도록 중간 상태를 두었다.

환불 요청

   ↓

PG 환불 성공

   ↓

내부 주문·재고 처리

   ↓

최종 환불 완료

덕분에 이후 주문 상태 변경이나 재고 복구에 실패하더라도 DB를 통해

"돈 자체는 이미 환불된 건이다."

라는 사실을 확인할 수 있다.


🚨 PortOne 자체가 실패했다면?

반대로 PortOne API 호출 자체가 실패했다면 실제 돈은 반환되지 않았다.

이 상황은

PG 환불 성공 → 내부 처리 실패

와 완전히 다르다.

그래서 외부 환불 자체가 실패한 경우에는 환불 실패 상태를 기록하고 주문을 기존 결제 완료 상태로 복구하도록 처리했다.

즉 시스템에서 구분해야 할 상황은 크게 다음과 같았다.

1. 환불 요청이 생성됨

2. PG 환불 자체가 실패함

3. PG 환불은 성공했지만 내부 처리가 남아 있음

4. PG 환불 + 내부 처리 모두 완료됨

🤔 왜 try-catch 하나로 끝내지 않았을까?

전체 환불 코드를 하나의 Service에 넣고 예외를 잡는 방식으로도 기능 자체는 구현할 수 있다.

하지만 외부 API와 내부 DB는 실패의 의미 자체가 다르다.

PortOne 실패
→ 실제 돈이 반환되지 않음

PortOne 성공 + DB 실패
→ 실제 돈은 이미 반환됨

이 둘을 같은 실패로 처리하면 운영 중 실제 상황을 파악하기 어려워진다.

그래서

환불 요청 저장

외부 환불 성공 기록

내부 처리 완료

외부 실패 처리

과정을 단계별 Transaction Service로 분리했다.

Facade에서는 전체 환불 흐름을 조율하도록 구성했다.


💡 이번 구현에서 배운 점

외부 시스템과 내부 DB가 함께 등장하는 기능에서는 단순한 DB Transaction만으로 모든 정합성을 해결할 수 없다.

특히 결제나 환불처럼 실제 돈의 이동이 발생하는 기능에서는

외부 요청은 실제로 성공했는가?

우리 DB에는 어디까지 반영됐는가?

처리 도중 실패했다면 현재 상태를 판단할 근거가 남아 있는가?

가 매우 중요했다.

이번 경험을 통해

실패 자체를 완전히 없애는 것만이 안정적인 시스템은 아니다.

라는 점을 배웠다.

실패가 발생하더라도 어디까지 성공했고 어디서 실패했는지를 확인할 수 있도록 상태를 남기는 것 역시 시스템 안정성을 위해 중요하다는 것을 알게 됐다.

profile
끝까지 가면 내가 다 이겨

0개의 댓글