판단의 출발점

33,333원짜리 상품을 취소했는데 환불액이 20,333원으로 계산됩니다. 쿠폰이 적용된 묶음 주문에서 다른 상품을 먼저 취소하면 같은 상품의 환불액이 또 달라집니다. 이때 상품 가격만 비교해서는 오류인지 판단할 수 없습니다. 남겨 둔 상품의 할인 조건과 배송비가 어떻게 바뀌었는지까지 봐야 합니다.

부분 취소 금액은 취소 상품의 표시 가격이 아니라, 합의한 정책에 따라 취소 후 남아 있어야 할 금액과 현재 확정 잔액의 차이로 계산해야 합니다. 이를 위해 최초 판매 금액, 상품별 할인 배분, 배송비, 세금·조정 항목을 보존하고 성공한 취소만 누적해야 합니다. 같은 조건에서 같은 상품이 남았다면 취소 순서가 달라도 누적 환불액과 잔액은 같아야 합니다.

아래는 원화 일반 카드결제 한 건을 대상으로 한 기술 설계 예시입니다. 배송 전이며 카드사 즉시할인, 포인트, 복합 결제, 환전, 추가 청구, 분쟁·차지백은 없습니다. 할인 회수나 배송비 부담의 법적 정답을 제시하는 글이 아닙니다. 실제 서비스에서는 약관과 적용 법규를 검토해 정책을 먼저 확정해야 합니다.

상품 가격·할인·배송비를 한 숫자에 숨기지 않는다

환불 기준이 되는 상품 금액은 매입 원가가 아니라 고객에게 판매한 금액입니다. 주문 전체에 적용한 쿠폰도 총액만 저장하면 나중에 특정 상품과 수량에 얼마가 배분됐는지 재현하기 어렵습니다. 다음 구분을 주문이 확정될 때 함께 저장하는 설계를 권합니다.

항목보존할 내용부분 취소에서 확인할 질문
판매 금액상품·옵션·수량별 판매 단가와 통화어떤 판매 단위가 취소되는가?
할인제공 주체, 할인 규칙 버전, 대상, 상품·수량별 배분액남은 상품의 할인도 바뀌는가?
배송비배송 묶음별 최초 청구액과 무료 조건조건이 깨지거나 전부 취소되면 어떻게 바뀌는가?
세금·조정가격의 세금 포함 여부, 과세 구분, 조정 사유와 금액세금을 중복 가산하거나 다른 항목으로 감추지 않았는가?
결제승인 통화·금액, 결제 식별자, 성공 취소 내역실제 승인된 금액 안에서 얼마를 취소했는가?

판매자 쿠폰과 카드사 할인, 자체 적립금은 별도 항목으로 두는 편이 낫습니다. 누가 제공한 혜택인지와 어느 결제수단으로 되돌리는지를 잃지 않기 위해서입니다. 가격에 세금이 포함돼 있다면 세금 명세를 저장하더라도 청구 총액에 다시 더하지 않습니다. 복합 과세라면 환불 총액과 면세 부분도 구분해야 합니다. 토스페이먼츠는 해당 상점의 부분 취소 요청에서 taxFreeAmount를 별도로 다룹니다.[5]

이미 상거래 플랫폼을 사용한다면 자체 추정값보다 플랫폼이 확정한 배분 결과부터 확인합니다. Shopify의 DiscountApplication은 할인 규칙을, DiscountAllocation은 상품 또는 배송 항목에 실제 적용된 할인액을 구분합니다. 이 객체가 있다고 해서 자사 환불 정책까지 자동으로 결정되는 것은 아닙니다.[6]

배송도 상품 취소와 별도로 판단합니다. Shopify 관리자에서는 배송비 환불을 따로 선택하며 이미 청구된 배송비의 환불 가능액을 넘길 수 없습니다. 아래에서 설명할 무료배송 조건 재판정은 이 기능 설명을 옮긴 것이 아니라 별도로 정한 가상 상점의 정책입니다.[7]

서비스 작동 방식

주문에서 승인과 환불까지 상태를 연결 — 결제수단을 주문 흐름에 붙이고 응답 지연과 재시도, 취소와 환불의 예외를 검증합니다.

설계 예시: 66,666원 주문에 쿠폰 10,000원을 적용했다

상품 A·B·C를 하나씩 주문했다고 하자. 금액은 모두 원이며 판매 가격에는 해당 세금이 포함돼 있다고 가정합니다. 별도 세금 가산과 기타 조정은 없습니다.

상품판매 금액최초 쿠폰 배분할인 후 상품 금액
A33,3335,00028,333
B22,2223,33318,889
C11,1111,6679,444
합계66,66610,00056,666

쿠폰과 배송에는 다음 정책을 적용합니다. 이는 계산을 설명하기 위한 선택이며 특정 PG나 상거래 플랫폼의 기본 정책이 아닙니다.

정책 항목이 예시에서 선택한 규칙
쿠폰 주체와 배분판매자 부담 10,000원 쿠폰을 최초 판매 금액 비율로 배분하고 저장합니다.
부분 취소 후 쿠폰남은 상품의 할인 전 합계가 50,000원 이상이면 그 상품들의 최초 배분액만 유지합니다. 취소 상품 몫은 재배분하지 않습니다. 50,000원 미만이면 남은 상품의 할인은 0원입니다.
배송비남은 상품의 할인 전 합계가 50,000원 이상이면 0원, 상품이 있고 그 미만이면 주문 전체에 3,000원입니다.
마지막 상품까지 취소배송 전이고 다른 비용이 없으므로 배송비도 0원으로 바꿉니다.

최초 승인액은 66,666 - 10,000 + 0 = 56,666원입니다. 이후 배송비가 3,000원으로 바뀌더라도 카드를 새로 승인하는 것이 아닙니다. 이 예시에서는 기존 결제 잔액 중 보유할 금액의 구성이 바뀝니다. 따라서 운영 화면에도 상품 환불, 할인 변경, 배송비 변경을 구분해 보여줘야 합니다.

1원의 배분도 처음에 결정한다

비례 배분의 정확한 값은 A 5,000원, B 3,333과 1/3원, C 1,666과 2/3원입니다. 우선 각각 원 단위로 내림하면 9,999원입니다. 남은 1원은 소수 잔여가 가장 큰 C에 배정합니다. 잔여가 같을 때는 고정된 상품·판매 단위 식별자 순으로 정합니다. 취소 요청이 들어온 순서를 사용하지 않습니다.

이것은 최대 잔여 방식으로 정한 이 예시의 배분 규칙입니다. 다른 규칙을 선택할 수도 있지만 배분 합계는 원래 쿠폰액과 같아야 하고 재계산해도 같은 결과가 나와야 합니다. 동일 상품을 여러 개 팔았다면 수량 중 일부만 취소해도 재현할 수 있도록 판매 단위별 배분이나 동등한 결정 규칙이 필요합니다.

내부 계산은 이 원화 예시에서 정수로 처리합니다. 소수 가격이나 비율을 다룬다면 정확한 십진수·분수 연산 후 정해진 시점에 단위 변환을 합니다. API 직렬화 규칙은 별개입니다. 토스페이먼츠 취소 API의 cancelAmount 형식은 number이므로 이 예시의 9,444원은 숫자 9444로 전송합니다.[1]

다른 통화와 API에 이 표현을 그대로 복사하면 안 됩니다. 예를 들어 Stripe는 통화의 최소 단위로 금액을 받으므로 10 USD는 1000, 10 JPY는 10으로 표현합니다. 일부 통화에는 별도 예외도 있습니다. 내부 금액에는 통화·단위를 붙이고 PG별 변환을 한 경계에서 수행하는 편이 안전합니다.[8]

취소할 상품의 가격보다 ‘남길 금액’을 먼저 계산한다

이 예시에서 남은 상품 집합을 S라고 하면 다음과 같이 계산할 수 있습니다.

보유금액 V(S)
  = 남은 상품 판매 금액 합계
  - 정책상 유지할 상품별 할인 합계
  + 정책상 배송비

이번 취소 요청액
  = 직전까지 확인된 결제 잔액 - V(취소 후 남은 상품)

여기서 직전 잔액은 이전 취소가 확정되고 내부 기록과 PG가 맞는 상태의 값입니다. 처리 중인 취소가 남아 있거나 PG 관리자에서 별도 취소한 사실을 발견했다면, 그 차이를 먼저 해결해야 합니다.

C를 먼저 취소하면

A와 B의 판매 금액 합계는 55,555원입니다. 쿠폰 조건을 충족하므로 최초 배분액 8,333원을 유지하고 배송비는 0원입니다.

남길 금액 = 55,555 - 8,333 = 47,222원
C 취소액  = 56,666 - 47,222 = 9,444원

이어서 B를 취소하면 A만 남습니다. 할인 전 합계가 50,000원 아래로 내려가 A의 할인 5,000원이 없어지고 배송비 3,000원이 생깁니다.

남길 금액 = 33,333 + 3,000 = 36,333원
B 취소액  = 47,222 - 36,333 = 10,889원
누적 취소 = 9,444 + 10,889 = 20,333원

B의 최초 할인 후 금액 18,889원을 그대로 돌려주면, 남아 있는 A의 할인 변경 5,000원과 배송비 변경 3,000원을 놓치게 됩니다.

B를 먼저 취소하면

A와 C의 판매 금액 합계는 44,444원입니다. 처음 부분 취소하는 시점에 이미 쿠폰 조건을 잃고 배송비가 발생합니다.

남길 금액 = 44,444 + 3,000 = 47,444원
B 취소액  = 56,666 - 47,444 = 9,222원

이어서 C 취소 후 남길 금액 = 33,333 + 3,000 = 36,333원
C 취소액  = 47,444 - 36,333 = 11,111원
누적 취소 = 9,222 + 11,111 = 20,333원

두 순서에서 B와 C의 개별 환불액은 다릅니다. 그러나 A만 남았을 때 누적 취소액은 모두 20,333원, 잔액은 모두 36,333원입니다. 비교해야 할 것은 상품 하나의 고정 환불액이 아니라 최종 조건이 같을 때의 누적 결과입니다. 배송 진행이나 정책 버전까지 달라졌다면 같은 조건의 비교가 아니므로 그 변화도 기록해야 합니다.

이 계산법은 어떤 정책에서도 무조건 작동하는 환불 공식은 아닙니다. 가령 49,900원과 100원 상품에 10,000원 쿠폰을 적용해 40,000원을 받았는데, 100원 상품을 취소하면서 쿠폰을 없애고 배송비까지 더하면 남길 금액은 52,900원입니다. 계산 결과는 음수인 -12,900원입니다. 이를 0원으로 덮거나 음수 취소를 보내서는 안 됩니다. 자동 환불 경로를 멈추고 할인 유지 등 정책 조정이나 별도 추가 결제가 필요한지 검토해야 합니다. 이 수치 역시 법적 청구 권리를 뜻하지 않습니다. PG에 보낼 금액은 양수이고 확인된 잔액 이하여야 한다는 사전 조건도 검사합니다. 계산 결과 자체가 0원인 정당한 업무 취소는 금액 오류와 구분해 별도 경로로 처리합니다.

주문 상태와 PG 잔액을 연결하는 불변 조건

승인된 원 결제 한 건에서 일반 취소만 발생하는 범위를 다음과 같이 정의합니다.

P = 최초 승인액
C = 중복 제거한 성공 취소 금액의 누계
B = PG에서 확인한 취소 가능 잔액

P = C + B
0 ≤ C ≤ P
0 ≤ B ≤ P

토스페이먼츠에서는 totalAmount가 최초 금액, balanceAmount가 취소 가능 잔액입니다. 개별 취소의 cancelStatus가 DONE인지를 확인합니다. 이 글의 일반 카드결제 범위에서는 성공 취소가 일부면 PARTIAL_CANCELED, 전부면 CANCELED 상태와 금액을 함께 검사합니다.[1]

내부에 넣을 검사 규칙은 더 엄격해야 합니다. 성공 기록의 합과 PG 잔액이 맞아도, 잘못된 상품을 취소했거나 배송비를 중복 차감했을 수 있기 때문입니다. 처리가 끝나 내부와 PG가 동기화된 시점에는 B = V(남은 상품)도 만족해야 합니다. 처리 중에는 확정 잔액과 취소 후 목표 금액을 별도 필드로 두고, 차이가 해결됐다고 표시하지 않습니다.

REQUESTED, PROCESSING, UNKNOWN, FAILED 같은 내부 요청 상태의 금액은 성공 누계에 더하지 않습니다. 이 이름들은 여기서 제안하는 내부 상태이며 토스페이먼츠의 취소 상태 목록을 옮긴 것이 아닙니다. 요청 횟수나 HTTP 재시도 횟수도 취소 횟수가 아닙니다.

취소 거래는 거래별 식별자로 보존합니다. 토스페이먼츠의 lastTransactionKey는 마지막 거래를 가리키므로, 이 값 하나를 덮어 쓰는 방식으로는 이전 부분 취소를 보존할 수 없습니다. 개별 transactionKey와 금액을 함께 기록하고 동일 거래의 재수신은 한 번만 반영합니다.[4]

마지막 상품까지 없어진 경우에도 잔액을 무조건 0으로 만들지는 않습니다. 이 예시는 배송 전이고 남길 비용이 없어서 V(빈 주문)=0입니다. 이미 발생한 비용을 남기는 다른 정책이라면 상품 상태와 결제 상태를 구분해야 합니다. 또한 PG 취소 성공과 고객 카드·계좌에 환급이 표시되는 시점은 같지 않을 수 있으므로, 고객 안내도 둘을 구분합니다.[2]

서비스 작동 방식

거래 이상에서 대응과 결제사 협의까지 — 증상별 확인 순서로 거래 상태를 확인하고 장애와 환불, 반복 문제를 같은 운영 절차로 처리합니다.

재시도할 때 금액을 다시 결정하지 않는다

취소를 실행하기 전에 취소 작업 식별자, 대상 판매 단위, 정책 버전, 계산 전 잔액, 계산 후 목표 금액, 확정 요청액을 저장하는 설계를 권합니다. 같은 작업이 다시 들어오면 새 계산과 새 취소를 만드는 대신 기존 결과를 돌려줍니다. 같은 식별자인데 금액이나 대상이 다르면 잘못된 재사용으로 거절합니다.

외부 요청에는 작업별 멱등키를 사용합니다. 토스페이먼츠의 Idempotency-Key 유효기간은 최초 사용일부터 15일이며 요청이 아직 처리 중이면 409 IDEMPOTENT_REQUEST_PROCESSING이 반환될 수 있습니다. 오류를 받았다는 이유로 키를 바꿔 같은 취소를 보내지 말라는 주의도 있습니다.[3]

따라서 타임아웃은 ‘취소 실패 확정’이 아니라 ‘결과 미확정’으로 취급합니다. 유효기간 안의 동일 요청 재시도와 결제 재조회를 통해 결과를 확인합니다. 키와 요청 본문뿐 아니라 API 키·주소·메서드가 동일 요청의 범위에 들어간다는 점도 고려합니다. 유효기간이 지나거나 인증 정보가 바뀌었다면 새 키로 밀어붙이지 말고 이전 실행 여부부터 확인합니다.[3]

부분 취소와 전체 취소도 한 결제의 잔액을 공유합니다. 이 예시에서는 결제별로 미확정 취소 작업을 하나만 허용하고 고객 화면·운영자 화면·재시도 작업이 같은 실행 경로를 사용하도록 합니다. 데이터베이스의 짧은 트랜잭션으로 실행권과 버전을 확보하고 네트워크 호출 중에는 장시간 잠금을 잡지 않습니다. 작업자가 재시작해도 실행권과 미확정 작업이 사라지지 않아야 합니다.

‘전체 취소’ 요청의 의미도 저장해야 합니다. 앞선 부분 취소가 확정된 뒤 남은 전부를 취소하는 작업이라면, 확인된 최신 잔액으로 실행액을 확정합니다. 토스페이먼츠는 cancelAmount를 생략하면 전액 취소하므로, 금액을 고정한 작업에서는 필드를 빠뜨리는 실수를 막아야 합니다. isPartialCancelable=false인 결제는 이 부분 취소 경로로 실행하지 않습니다.[2][1]

PG는 성공했는데 내부 저장이 실패했다면

같은 금액을 새 작업으로 다시 취소하지 않습니다. 미확정 기록을 유지한 채 동일 작업의 응답을 복구하거나 결제를 재조회해 취소 거래와 잔액을 확인합니다. 확인된 PG 거래를 중복 방지 제약 아래 한 번만 기록하고 내부 성공 누계와 주문의 환불 표시를 함께 반영합니다. 후속 알림은 확정 기록에 연결해 재전송 가능하게 만듭니다.

멱등 재시도로 돌려받는 것은 최초 요청의 응답일 수 있습니다. 이 응답은 해당 작업의 결과를 확인하는 데 쓰되, 그 뒤 다른 취소가 반영된 최신 잔액을 덮어쓰는 데 사용하지 않습니다. 내부 작업 버전과 확인 시점을 비교하고 필요한 경우 현재 결제 상태를 다시 조회하는 방식을 권합니다.[3]

운영자가 PG 관리자에서 별도로 취소한 내역이 섞일 수도 있습니다. 취소 금액이 같다는 이유만으로 특정 상품의 요청과 연결하면 안 됩니다. 이전에 저장한 거래 식별자, 작업 기록, 전후 거래 집합 등으로 연결을 입증하지 못하면 운영자 확인으로 넘깁니다. PG 잔액만 맞추려고 상품 상태를 임의로 변경하지 않습니다.

운영자에게는 주문·결제·취소 작업 식별자, 정책 버전, 기대 금액, 확인된 PG 거래와 잔액, 오류 분류, 마지막 확인 시각을 전달합니다. 시크릿 키나 전체 카드·계좌 정보는 넣지 않습니다. 정해 둔 재시도 한도나 확인 기한을 넘긴 작업, 금액 불일치, 연결할 수 없는 외부 취소는 자동 실행을 보류합니다.

취소 순서를 바꿔 검산하고 장애 상황은 별도로 시험한다

앞의 주문을 가능한 여섯 순서로 끝까지 취소하면 다음과 같습니다. 로컬 계산 코드로 검산한 설계 예시이며 실제 PG 요청 결과가 아닙니다. 표의 1·2·3차 금액은 각 순서에 해당하는 취소 요청액입니다.

취소 순서1차 취소2차 취소3차 취소성공 누계최종 잔액
A → B → C20,33322,22214,11156,6660
A → C → B20,33311,11125,22256,6660
B → A → C9,22233,33314,11156,6660
B → C → A9,22211,11136,33356,6660
C → A → B9,44422,00025,22256,6660
C → B → A9,44410,88936,33356,6660

모든 단계에서 최초 승인액은 성공 취소 누계와 잔액의 합과 같았습니다. 그러나 숫자 계산이 맞는다는 사실만으로 결제 연동이 검증된 것은 아닙니다. 구현 후에는 다음 기대 결과를 허가된 테스트 환경에서 별도로 확인해야 합니다.

검증 상황기대 결과
C만 한 번 취소취소액 9,444원, 잔액 47,222원이며 A·B가 남습니다.
같은 취소 요청을 중복 제출동일 작업으로 인식해 PG 실행 효과와 내부 성공 반영이 한 번만 생깁니다.
성공 거래를 재조회·재수신이미 기록한 거래 식별자는 성공 누계에 다시 더하지 않습니다.
오래된 취소 응답이 늦게 도착작업 성공은 중복 제거해 기록하되, 이전 응답의 잔액으로 최신 주문·결제 상태를 되돌리지 않습니다.
부분 취소와 전체 취소가 경합하나씩 확정합니다. C 취소 후 남은 전체 취소라면 9,444원과 47,222원입니다. 전체 취소가 먼저 확정됐다면 뒤의 부분 취소는 실행하지 않습니다.
타임아웃 또는 처리 중 응답성공 누계를 늘리지 않고 미확정 금액을 보존합니다. 같은 잔액을 사용하는 새 작업은 대기시킵니다.
PG 성공 후 내부 반영 실패신규 취소 없이 성공 거래를 복구해 한 번만 반영합니다.
다른 상품을 같은 금액으로 취소금액 일치만으로 요청을 연결하지 않습니다. 대상과 작업·거래의 연결을 확인합니다.
반올림 잔여 또는 음수 환불저장한 배분 규칙으로 재현합니다. 설명되지 않는 차이나 추가 청구가 필요한 상태를 마지막 상품에 몰아넣지 않습니다.

마지막 취소에서 확인된 잔액을 모두 환불하는 것은 이 예시의 전액 반환 정책을 완성하는 것입니다. 앞선 계산 오류를 없애는 보정 장치가 아닙니다. 최초 배분에서 발생한 최소 단위 잔여는 배분 규칙으로 해결하고 이후 발생한 설명되지 않는 잔액 차이는 예외로 남겨야 합니다.

정책을 바꿀 때는 정상 취소만 다시 실행하지 말고 조건 경계, 취소 순서, 재시도, 미확정 상태를 함께 회귀 범위로 정합니다. 선정한 테스트와 제외 근거를 기록할 때는 변경별 회귀 테스트 범위 결정 워크북을 활용할 수 있습니다.

부분 취소를 안정적으로 만드는 출발점은 새 도구가 아니라 설명 가능한 금액 정책과 보존된 거래 기록입니다. 단일 PG와 단순한 주문이라면 기존 주문 시스템 안에서 정책 버전, 배분값, 작업별 멱등 처리, 성공 거래 기록부터 갖출 수 있습니다. 복합 결제나 외부 운영자 취소가 섞이면 결제수단별 반환과 예외 처리 범위를 추가로 설계해야 합니다.

정책 합의 이후 구현과 검증의 역할을 나누기 어렵다면 IXC의 PG 연동·결제 시스템 구축 서비스에서 관련 지원 범위를 확인할 수 있습니다.