# 취소와 부분 환불을 반영한 순매출 계산

> 결제 전 취소, 전액·부분 환불, 처리 중·실패 상태를 구분해 순매출을 계산합니다. 같은 환불의 상태 이력을 중복 차감하지 않는 방법과 기준일별 순매출을 합성 데이터·Python 예제로 재현합니다.

- 발행: 2026-09-06
- 주제: 마케팅 데이터 분석
- 발행처: 매드업 마케팅사업본부 AI 마케팅 개발실
- 웹 원문: https://madobi.madup.com/blog/net-revenue-cancellations-partial-refunds/

## 세 줄 요약

- 결제 전 취소는 성공 결제 원장에 매출을 만들지 않는다. 결제 후 전액 환불은 원래 결제와 환불을 각각 보존해야 총매출과 환불액을 함께 설명할 수 있다.
- 환불 상태가 여러 번 수집된 스냅샷은 금액을 모두 더하면 안 된다. 합성 예제의 스냅샷 합계는 163,000원이지만, 기준일까지 확보한 최신 상태에서 성공으로 분류된 환불은 125,000원이다.
- 합성 예제의 성공 결제 200,000원에서 성공 상태 환불 125,000원을 차감한 순매출은 75,000원이다. 처리 중 8,000원과 실패 5,000원은 별도 지표로 남긴다.

---
합성 결제 원장에는 200,000원이 있고, 환불 상태 기록의 금액을 모두 더하면
163,000원입니다. 그대로 빼면 순매출이 37,000원이 됩니다. 하지만 같은 환불의
진행 상태를 여러 번 기록한 행과 아직 처리 중인 요청까지 포함한 계산입니다.
이 예제의 차감 기준을 적용하면 환불액은 125,000원, 순매출은 75,000원입니다.

순매출은 총매출에서 환불을 빼는 계산보다 앞서 원천의 금액과 상태를 해석해야 합니다.
아래 예제는 단일 상점의 원화 결제와 환불을 단순화한 합성 데이터입니다. 결제기관의
실제 데이터나 고객 정보를 사용하지 않았습니다.

## 결제 전 취소와 결제 후 환불을 구분해야 하는 이유

결제를 완료하지 않은 50,000원 주문이 취소됐다면 성공 결제 원장에는 50,000원이
들어오지 않습니다. 여기서 취소액 50,000원을 환불처럼 차감하면 존재하지 않았던
매출에서 돈을 빼게 됩니다.

반대로 100,000원을 결제한 뒤 전액 환불한 주문은 총매출 100,000원과 환불
100,000원을 모두 설명할 수 있어야 합니다. 현재 주문 상태가 취소라는 이유로
원래 결제를 지운 뒤 환불까지 차감하면 결과가 잘못됩니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 272" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="합성 예제에서 미결제 취소 주문은 성공 결제 0원과 환불 0원이고, 결제 후 전액 환불 주문은 결제 100000원과 환불 100000원입니다. 두 순매출은 모두 0원이지만 거래 흐름은 다릅니다." style="width:100%; min-width:640px; height:auto; font-family:inherit">
  <rect width="720" height="272" rx="16" fill="#fffdf8"/>
  <text x="24" y="32" fill="#2a2d3e" font-size="16" font-weight="700">같은 취소 표시라도 결제 사실은 다를 수 있습니다</text>
  <rect x="24" y="56" width="326" height="160" rx="12" fill="#eef0f7"/>
  <text x="44" y="87" fill="#3e4a78" font-size="14">o404 · 50,000원 주문, 결제 전 취소</text>
  <text x="44" y="121" fill="#2a2d3e" font-size="16">성공 결제 0원 − 환불 0원</text>
  <text x="44" y="168" fill="#2a2d3e" font-size="26" font-weight="700">순매출 0원</text>
  <text x="44" y="194" fill="#5f6273" font-size="12">성공 결제 원장에 들어가지 않습니다.</text>
  <rect x="370" y="56" width="326" height="160" rx="12" fill="#fff1cd"/>
  <text x="390" y="87" fill="#684d13" font-size="14">o401 · 결제 후 전액 환불</text>
  <text x="390" y="121" fill="#2a2d3e" font-size="16">100,000원 − 100,000원</text>
  <text x="390" y="168" fill="#2a2d3e" font-size="26" font-weight="700">순매출 0원</text>
  <text x="390" y="194" fill="#5f6273" font-size="12">원래 결제와 환불을 모두 보존합니다.</text>
  <text x="24" y="247" fill="#5f6273" font-size="12">합성 데이터 · 현재 주문 상태만으로 과거 성공 결제를 지우지 않습니다.</text>
</svg>
<figcaption>순매출이 같아도 결제와 환불의 규모는 다릅니다. 두 값을 따로 남겨야 대사가 가능합니다.</figcaption>
</figure>

결제 API도 결제 전 취소와 성공 결제 후 환불을 구분합니다. Stripe는 완료 전 결제를
취소하는 경로와 성공한 결제의 전액·부분 환불을 구분해 안내합니다. 실제 연동에서는
사용 중인 결제 수단과 상태에 맞는 원장을 확인해야 합니다.
[Stripe 결제 취소와 환불 안내](https://docs.stripe.com/refunds)

## 부분 환불은 주문 번호와 환불 번호를 함께 봐야 한다

9월 2일에 결제한 합성 주문은 다음과 같습니다. 표의 상태 설명은 9월 5일 자정까지
확보한 정보를 기준으로 합니다. 금액은 단순화를 위해 같은 상품 금액 기준으로
맞췄으며 세금·배송비·수수료·환율을 별도로 계산하지 않습니다.

| 주문 | 결제 ID | 성공 결제 | 이후 확인한 내용 |
| --- | --- | ---: | --- |
| o401 | p401 | 100,000원 | 전액 환불 1건 |
| o402 | p402 | 60,000원 | 10,000원과 15,000원의 부분 환불 2건 |
| o403 | p403 | 40,000원 | 5,000원 환불 실패, 8,000원 환불 처리 중 |
| o404 | 없음 | 0원 | 주문 금액 50,000원, 결제 전 취소 |

o402에는 서로 다른 환불 r402와 r403이 있습니다. 두 건이 각각 완료됐으면
10,000원과 15,000원을 더한 25,000원이 환불액입니다. 주문 번호만 기준으로 한 행을
남기면 정상적인 두 번째 부분 환불을 잃습니다.

반대로 r402 하나의 상태가 여러 번 수집된 경우에는 각 행의 금액을 더하면 안 됩니다.
환불이 10,000원에서 20,000원으로 늘어난 것이 아니라, 같은 10,000원 요청의
상태를 다시 확인한 기록일 수 있습니다.

## 환불 상태 스냅샷을 더하면 왜 금액이 부풀어 오르나

환불 r402의 상태 기록은 세 행입니다. `recorded_at`은 원천과 대조해 상태를 확보한
시각이고, 금액은 각 스냅샷이 설명하는 환불 요청의 금액입니다.

| 환불 ID | 상태 확인 시각 | 상태 | 금액 | 기록 의미 |
| --- | --- | --- | ---: | --- |
| r402 | 9월 2일 13:00 | pending | 10,000원 | 처리 중 |
| r402 | 9월 3일 13:00 | succeeded | 10,000원 | 성공 상태 확인 |
| r402 | 9월 3일 13:00 | succeeded | 10,000원 | 같은 스냅샷 재수집 |

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 252" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="같은 환불 r402의 처리 중, 성공, 성공 스냅샷 재수집은 모두 10000원을 표시하지만 환불은 한 건 10000원입니다." style="width:100%; min-width:640px; height:auto; font-family:inherit">
  <rect width="720" height="252" rx="16" fill="#fffdf8"/>
  <text x="24" y="32" fill="#2a2d3e" font-size="16" font-weight="700">같은 환불의 상태 기록을 새 환불로 더하지 않습니다</text>
  <rect x="24" y="58" width="204" height="106" rx="12" fill="#eef0f7"/>
  <text x="42" y="90" fill="#3e4a78" font-size="14">r402 · 처리 중</text>
  <text x="42" y="132" fill="#2a2d3e" font-size="24" font-weight="700">10,000원</text>
  <path d="M234 112 H244 M238 106 L244 112 L238 118" fill="none" stroke="#3e4a78" stroke-width="2"/>
  <rect x="252" y="58" width="204" height="106" rx="12" fill="#fff1cd"/>
  <text x="270" y="90" fill="#684d13" font-size="14">r402 · 성공</text>
  <text x="270" y="132" fill="#2a2d3e" font-size="24" font-weight="700">10,000원</text>
  <path d="M462 112 H472 M466 106 L472 112 L466 118" fill="none" stroke="#3e4a78" stroke-width="2"/>
  <rect x="480" y="58" width="216" height="106" rx="12" fill="#fcebee"/>
  <text x="498" y="90" fill="#a52b35" font-size="14">r402 · 같은 기록 재수집</text>
  <text x="498" y="132" fill="#2a2d3e" font-size="24" font-weight="700">10,000원</text>
  <text x="24" y="205" fill="#2a2d3e" font-size="15">반영할 환불은 r402 한 건의 10,000원입니다.</text>
  <text x="24" y="231" fill="#5f6273" font-size="12">합성 스냅샷 · 별도 환불 r403의 15,000원은 추가로 반영합니다.</text>
</svg>
<figcaption>환불 ID가 다르면 별도 요청이고, 같은 ID의 여러 행은 상태 이력인지 확인해야 합니다.</figcaption>
</figure>

이 예제의 환불 스냅샷은 총 8행입니다. r401 한 행, r402 세 행, r403 한 행,
r404의 처리 중·실패 두 행, r405의 처리 중 한 행입니다. 원시 행의 금액 합계
163,000원은 이력과 재수집을 포함하므로 차감할 환불액으로 사용할 수 없습니다.

원천이 누적 환불액을 제공하는 경우도 구분해야 합니다. o402의 누적 환불액이
10,000원에서 25,000원으로 바뀐 두 스냅샷이라면, 둘을 더한 35,000원을 환불로
해석하면 안 됩니다. 건별 증분 원장에서는 10,000원과 15,000원을 더하고, 누적
스냅샷에서는 기준 시점의 25,000원을 읽는 식으로 데이터의 의미에 맞춰야 합니다.

## 처리 중이거나 실패한 환불은 어떻게 반영하나

환불 요청과 성공 상태를 같은 금액으로 취급하지 않습니다. Stripe의 Refund 객체에는
`pending`, `requires_action`, `succeeded`, `failed`, `canceled` 상태가 있으며,
실패한 환불의 잔액 조정 정보도 따로 제공합니다. 상태가 한 번 기록됐다는 사실만으로
환불이 항상 같은 상태로 유지된다고 가정해서는 안 됩니다.
[Stripe Refund 객체의 상태](https://docs.stripe.com/api/refunds/object)

예제는 기준일까지 확보한 환불 ID별 최신 상태가 `succeeded`인 금액만 순매출에서
차감하도록 정했습니다. 처리 중과 추가 조치 필요 상태는 대기 금액으로, 실패와
취소 상태는 별도로 모읍니다. 이는 분석에 사용할 상태 정책이며, 고객 계좌의 실제
입금이나 회계상 매출 인식까지 확인했다는 의미는 아닙니다.

| 환불 ID | 연결된 주문 | 기준일의 최신 상태 | 요청 금액 | 순매출 차감 |
| --- | --- | --- | ---: | ---: |
| r401 | o401 | succeeded | 100,000원 | 100,000원 |
| r402 | o402 | succeeded | 10,000원 | 10,000원 |
| r403 | o402 | succeeded | 15,000원 | 15,000원 |
| r404 | o403 | failed | 5,000원 | 0원 |
| r405 | o403 | pending | 8,000원 | 0원 |

따라서 성공 환불은 125,000원, 처리 중은 8,000원, 실패는 5,000원입니다. 처리 중
8,000원이 나중에 성공하면 순매출을 다시 계산해야 합니다. 실패한 요청도 재시도나
다른 처리 경로가 생길 수 있으므로 원본 이력을 지우지 않습니다.

## 같은 구매일의 순매출이 기준일마다 달라지는 이유

구매일을 고정해도 환불 정보를 얼마나 확보했는지에 따라 분석 값이 달라집니다.
9월 2일에 결제된 같은 주문 세 건을 기준일만 바꾸어 계산한 결과입니다. 모든 기준일은
한국 시간 자정입니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 284" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="합성 데이터의 9월 2일 구매 코호트 순매출은 9월 3일 자정 기준 200000원, 9월 4일 자정 기준 90000원, 9월 5일 자정 기준 75000원입니다." style="width:100%; min-width:640px; height:auto; font-family:inherit">
  <rect width="720" height="284" rx="16" fill="#fffdf8"/>
  <text x="24" y="32" fill="#2a2d3e" font-size="16" font-weight="700">같은 9월 2일 주문을 기준일만 바꿔 계산한 순매출</text>
  <text x="24" y="82" fill="#2a2d3e" font-size="14">9월 3일 자정</text>
  <rect x="172" y="61" width="350" height="31" rx="5" fill="#3e4a78"/>
  <text x="538" y="82" fill="#2a2d3e" font-size="14">200,000원</text>
  <text x="24" y="140" fill="#2a2d3e" font-size="14">9월 4일 자정</text>
  <rect x="172" y="119" width="157.5" height="31" rx="5" fill="#7380aa"/>
  <text x="346" y="140" fill="#2a2d3e" font-size="14">90,000원</text>
  <text x="24" y="198" fill="#2a2d3e" font-size="14">9월 5일 자정</text>
  <rect x="172" y="177" width="131.25" height="31" rx="5" fill="#c7303b"/>
  <text x="320" y="198" fill="#2a2d3e" font-size="14">75,000원</text>
  <text x="24" y="243" fill="#5f6273" font-size="13">새 구매일의 매출 추이가 아니라 같은 주문의 환불 반영 범위가 달라진 결과입니다.</text>
  <text x="24" y="265" fill="#5f6273" font-size="12">합성 데이터 · 막대 길이는 순매출에 비례 · 당시 확보한 스냅샷만 사용</text>
</svg>
<figcaption>구매일과 데이터 반영 기준일을 함께 기록해야 과거 보고서를 다시 계산할 수 있습니다.</figcaption>
</figure>

9월 3일 자정에는 r402의 처리 중 상태만 확인돼 성공 환불을 차감하지 않습니다.
9월 4일 자정에는 r401과 r402가 성공 상태여서 110,000원을 차감합니다. 9월 5일
자정에는 별도 부분 환불 r403의 15,000원이 추가돼 순매출이 75,000원이 됩니다.

과거 보고서를 만들면서 미래에 확보한 최신 상태까지 사용하면 당시 알고 있던 정보와
다른 결과가 됩니다. 먼저 기준일까지 확보한 스냅샷으로 범위를 제한하고, 그 안에서
환불 ID별 최신 상태를 골라야 합니다.

같은 날짜에 뽑은 리포트라도 오래된 구매 코호트와 최근 구매 코호트는 환불을 관측한
기간이 다릅니다. 최근 유입의 환불률이 낮다는 이유만으로 고객 품질이 더 좋다고
결론 내리기 전에 관측 기간을 맞춰야 합니다.

## Python에서 환불 ID별 최신 상태를 선택하는 방법

예제는 성공 결제 사실과 환불 상태 스냅샷을 별도 입력으로 받습니다. 현재 주문
상태는 결제를 제거하는 필터로 사용하지 않습니다. 환불은 원래 결제 ID에 연결하고,
환불 ID·금액·통화가 같은 요청이라는 조건을 확인합니다.

핵심 선택 순서는 다음과 같습니다. `recorded_at`은 원천과 대조해 확보한 스냅샷
시각이며, 단순히 웹훅이 늦게 도착한 시각으로 대체하면 안 됩니다.

```python
recorded_at = instant(row["recorded_at"])
if recorded_at > cutoff:
    continue

# 식별자·금액·상태와 같은 시각의 충돌을 검증한 뒤
if key not in latest or recorded_at > latest[key]["recorded_at"]:
    latest[key] = snapshot
```

웹훅이 순서 없이 도착할 수 있는 수집 구조에서는 원천의 버전이나 권위 있는 조회
결과를 통해 상태를 대사해야 합니다. 이 예제는 그런 대사를 마친 상태 스냅샷이라는
입력 계약을 둡니다. 실제 결제기관의 웹훅 처리기를 구현한 코드로 해석하지 않습니다.

환불 ID별 최신 상태를 고른 뒤에는 성공 상태의 서로 다른 환불들을 원래 결제별로
더합니다. 환불액이 원래 결제액을 넘으면 음수 순매출로 덮어 두지 않고 오류를 냅니다.
연결되지 않은 결제, 같은 ID·스냅샷 시각의 충돌, 환불 금액이나 결제 연결 변경,
정의하지 않은 상태도 확인 대상으로 남겨야 합니다.

```text
주문별 순매출
  = 원래 성공 결제액
    − 기준일까지 확보한 최신 상태가 성공인 별도 환불 건들의 합계
```

기준일인 9월 5일 자정의 주문별 결과는 다음과 같습니다.

| 주문 | 성공 결제 | 반영한 환불 | 순매출 |
| --- | ---: | ---: | ---: |
| o401 | 100,000원 | 100,000원 | 0원 |
| o402 | 60,000원 | 25,000원 | 35,000원 |
| o403 | 40,000원 | 0원 | 40,000원 |
| 합계 | 200,000원 | 125,000원 | 75,000원 |

결제 주문 수 3건과 전액 환불 주문 수 1건도 분리해서 표시합니다. 환불 후 매출이
남은 주문 수를 보고 싶다면 별도 지표로 정의해야 합니다. 취소·환불 처리 이후에도
‘주문 수’가 무엇을 뜻하는지 고정돼 있어야 다른 리포트와 비교할 수 있습니다.

## GA4 환불 이벤트와 결제 원장을 연결할 때 확인할 것

Google의 GA4 전자상거래 문서는 `refund` 이벤트에 원래 거래를 연결하는
`transaction_id`를 지정하고, 상품별 환불 지표가 필요하면 `item_id`와 `quantity`
등의 상품 정보를 포함하도록 안내합니다.
[GA4 구매·환불 측정 안내](https://developers.google.com/analytics/devguides/collection/ga4/ecommerce)

내부 환불 ID는 환불 요청을 고유화하고, GA4의 거래 ID는 원래 거래에 연결하는
역할로 구분해서 관리할 수 있습니다. 내부 원장에서 한 번만 차감됐다고 해서 모든
수집 경로의 이벤트가 자동으로 같은 방식으로 처리됐다고 가정하지는 않습니다.

금액 범위도 맞춰야 합니다. GA4 권장 이벤트 문서는 `refund`의 `value`를 상품별
가격과 수량의 합으로 정의하며 배송비와 세금을 포함하지 않도록 안내합니다.
결제 원장의 환불액에 배송비·세금이 포함됐다면 같은 금액처럼 바로 비교하기 전에
항목을 나눠 대조해야 합니다.
[GA4 refund 매개변수](https://developers.google.com/analytics/devguides/collection/ga4/reference/events)

## 합성 데이터로 순매출을 재현하는 방법

[취소·부분 환불 예제 ZIP](/downloads/marketing-metrics-04.zip)을 풀고 폴더 안에서 실행합니다.
Python 3.9 이상과 IANA 시간대 데이터가 필요하며 추가 Python 패키지는 사용하지
않습니다.

```sh
python3 analysis.py
python3 -m unittest
```

실행 결과는 세 기준일의 보고서를 출력합니다. 마지막 보고서의 성공 결제는
200,000원, 성공 환불은 125,000원, 순매출은 75,000원입니다. 원시 환불 상태 행의
합계 163,000원도 비교용으로 남겨 두었습니다.

테스트는 기준일 이후 상태를 사용하지 않는지, 별도 부분 환불을 모두 반영하는지,
재수집이나 입력 순서가 금액을 바꾸지 않는지 확인합니다. 같은 시각의 상태 충돌,
원래 결제를 넘는 환불, 알 수 없는 결제와 상태도 검사합니다. 나중에 실패 상태로
수정된 환불을 계속 성공으로 차감하지 않는 경우도 포함했습니다.

이 예제는 한 상점에서 주문당 성공 결제가 한 건인 원장에 적용합니다. 분할 결제,
여러 통화, 정산 수수료, 세무·회계상 매출 인식과 은행 입금 확인은 별도 모델링이
필요합니다. 여기서는 순매출을 재현하기 위한 분석 기준을 고정했습니다.

<img src="/madobi/chart.png" alt="결제와 환불 그래프를 확인하는 매도비" width="112" height="112" loading="lazy" style="display:block; margin:1.5rem auto; width:112px; height:112px" />

환불 반영 뒤 매출이 줄었다면 어떤 요청을 어느 상태에서 차감했는지 함께 확인해
보세요. 결제 사실, 별도 환불 건, 상태 이력과 기준일을 나눠 보관하면 같은 순매출을
다시 계산할 수 있습니다.

공식 문서는 2026년 9월 6일 확인했습니다.

마케팅 데이터 모델·품질·지표 정의 연재 4편입니다.
[이전 글: 매출 데이터의 날짜와 시간대를 맞추는 방법](/blog/revenue-date-timezone-boundaries/)

[다음 글: 광고비·부가세·수수료·환율을 같은 기준으로 맞추기](/blog/ad-cost-vat-fees-exchange-rates/)

🐹