승인과 저장 사이의 실패를 보상 거래로 복구한 결제 무결성 설계
결제 승인 뒤 데이터 불일치·중복 승인·저장 전 예외가 발생하면 자동 승인 취소로 복구하는 보상 거래 설계를 정리합니다.
외부 결제 승인이 성공했다고 해서 내부 결제 기록까지 반드시 성공하는 것은 아니다. 승인 직후 데이터 검증이나 데이터베이스 저장이 실패하면 고객에게는 결제가 발생했지만 서비스에는 결제 내역이 없는 상태가 남을 수 있다.
이 문제를 해결할 때 핵심은 결제 요청 전체를 하나의 데이터베이스 트랜잭션처럼 취급하는 것이 아니었다. 외부 PG 승인은 내부 트랜잭션으로 되돌릴 수 없기 때문에, 승인 이후 내부 처리가 실패하면 별도의 승인 취소를 실행하는 보상 거래가 필요했다.
실패 지점을 먼저 나눴다
승인 콜백 이후의 흐름을 다음처럼 구분했다.
flowchart LR
A[PG 승인 결과 수신] --> B{응답 데이터 검증}
B -- 실패 --> C[승인 취소 시도]
B -- 통과 --> D{중복 승인 확인}
D -- 다른 승인 번호 --> C
D -- 동일 승인 번호 --> E[기존 결제 결과 반환]
D -- 신규 승인 --> F[결제 내역 저장]
F -- 저장 전 실패 --> C
F -- 저장 완료 --> G[결제 완료]
G --> H{후속 처리 오류}
H -- 발생 --> I[저장된 결제 유지]아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.
승인 취소가 필요한 경우는 다음과 같았다.
- 승인 결과에서 필수 데이터가 누락된 경우
- 요청 금액과 승인 금액이 다른 경우
- 기존 결제와 다른 승인 번호가 중복으로 들어온 경우
- 결제 내역을 기록하기 전에 예외가 발생한 경우
반대로 결제 기록이 이미 저장된 뒤 후속 처리에서 오류가 발생했다면 승인 취소를 실행하지 않았다. 이 시점에 취소하면 내부에는 결제 내역이 남고 실제 승인은 취소되는 반대 방향의 불일치가 생길 수 있기 때문이다.
취소 조건을 하나의 함수로 모았다
승인 취소에는 가맹점, 결제 수단, 승인 번호와 사유가 필요했다. 여러 예외 분기에서 같은 요청을 반복하지 않도록 취소 로직을 하나의 함수로 모았다.
const allowedMethods = new Set(["methodA", "methodB"]);
const method = allowedMethods.has(requestedMethod) ? requestedMethod : "methodA";
let provider = await paymentProviderRepository.find(organizationId, method);
if (!provider && method !== "methodA") {
provider = await paymentProviderRepository.find(organizationId, "methodA");
}
자동 취소 대상 결제 수단은 화이트리스트로 제한했다. 허용된 결제 방식만 조회 조건에 사용하고, 그 밖의 값은 기본 방식으로 정규화했다. 선택한 결제 수단의 PG 정보가 없을 때도 기본 결제 설정을 한 번 더 확인했다. 외부 입력이 조회 조건에 그대로 들어가지 않도록 하는 방어이기도 했다.
동일 승인 번호 재호출은 취소하지 않았다
결제 콜백은 네트워크 재시도나 사용자의 새로고침으로 다시 들어올 수 있다. 이미 저장된 결제와 동일한 승인 번호라면 새로운 중복 결제가 아니라 같은 결과의 재전달로 판단했다.
동일 승인 번호에는 기존 영수증과 진행 상태를 반환하고, 다른 승인 번호가 같은 결제에 연결될 때만 새 승인을 취소했다. 이 구분이 없으면 정상적인 콜백 재시도까지 취소할 수 있다.
자동 복구가 실패하면 사람이 이어받을 수 있게 했다
보상 거래도 외부 API 호출이므로 실패할 수 있다. 취소 실패를 숨기지 않고 CRITICAL 로그로 남기고, 함수 결과를 false로 반환해 상위 흐름에서 수동 조치가 필요하다는 사실을 알 수 있게 했다.
이후 테스트는 다음 경계를 중심으로 보강했다.
- 승인 데이터 누락과 금액 불일치
- 동일 승인 번호와 다른 승인 번호의 중복 콜백
- 결제 저장 전 예외와 저장 후 예외
- 자동 취소 성공과 실패
- 자동 취소를 지원하지 않는 결제 수단
구현 당시 회귀 테스트에서는 신규 테스트 23개를 포함해 전체 62개가 통과했다. 테스트는 위의 실패 경로를 확인한 것이며, 외부 결제 시스템의 모든 장애를 재현한 결과는 아니다.
결과
결제 처리의 성공 조건을 단순히 “PG 승인 성공”으로 두지 않고 승인과 내부 기록이 함께 일치하는 상태로 정의했다. 저장 전에 실패하면 승인 취소로 되돌리고, 저장 이후의 오류는 결제를 유지하며, 취소마저 실패하면 높은 우선순도의 로그로 수동 복구 경로를 열었다.
외부 시스템이 포함된 결제에서는 완벽한 원자성을 만들 수 없다. 대신 실패 시점을 명확히 나누고, 각 시점에 맞는 보상 동작과 관측 수단을 준비하는 것이 데이터 무결성을 지키는 현실적인 방법이었다.