# 구매 이벤트 중복을 제거할 때 transaction_id부터 확인하는 이유

> 구매 이벤트의 전송 ID와 주문 ID를 구분하고, 정상 재구매를 지우지 않으면서 중복·값 충돌·ID 누락을 처리합니다. GA4 웹·앱의 차이, Google Ads의 조건과 합성 데이터 8행을 검증하는 Python 예제를 담았습니다.

- 발행: 2026-09-06
- 주제: 마케팅 데이터 분석
- 발행처: 매드업 마케팅사업본부 AI 마케팅 개발실
- 웹 원문: https://madobi.madup.com/blog/purchase-event-deduplication-transaction-id/

## 세 줄 요약

- 구매 이벤트의 전송 ID와 거래 ID는 구분해야 한다. 같은 거래가 여러 경로로 전송될 수 있고, 같은 고객이 같은 금액으로 별도 주문을 할 수도 있다.
- Google은 GA4의 transaction_id 기반 구매 중복 제거가 웹 스트림에 적용되며 앱 스트림에는 적용되지 않는다고 안내한다. Google Ads의 중복 제거도 같은 전환 액션과 거래 ID라는 조건을 확인해야 한다.
- 합성 예제의 8행은 검증된 주문 3건, 중복 2행, 확인 보류 3행으로 나뉜다. 검증된 주문의 130,000원은 보류 건을 해결하기 전까지 전체 매출로 사용할 수 없다.

---
결제를 마친 고객이 완료 화면을 새로고침했습니다. 같은 구매 이벤트가 다시 전송됐고,
서버에서도 결제 완료 기록을 보냈습니다. 화면에서는 한 번 산 상품인데 수집 로그에는
여러 줄이 남을 수 있습니다. 반대로 같은 고객이 같은 금액으로 두 번 주문한 기록은
실제로 두 건일 수 있습니다.

중복 제거는 어느 행을 지울지 결정하는 작업입니다. 기준을 넓게 잡으면 정상 매출이
사라지고, 좁게 잡으면 중복이 남습니다. [앞선 지표 정의 예제](/blog/marketing-metrics-definition-ga4-mmp/)에서
주문 수와 구매자 수를 구분했다면, 여기서는 거래를 식별하는 키와 값이 충돌할 때의
처리를 확인할 수 있습니다.

## 전송 ID와 주문 ID는 무엇이 다른가

전송 ID는 수집한 이벤트를 구분하고, 주문 ID는 사업상 거래를 구분합니다. 한 거래를
브라우저와 서버가 각각 알리면 서로 다른 전송 ID를 가질 수 있습니다. 같은 메시지를
재전송할 때 기존 전송 ID를 유지하는 설계도 가능합니다. 어느 쪽이든 전송 ID가
고유하다는 사실만으로 주문도 고유하다고 판단할 수는 없습니다.

다음은 자체 수집 로그를 단순화한 합성 예제입니다. 특정 플랫폼의 실제 내보내기
스키마나 자동 중복 제거 결과를 모사한 데이터는 아닙니다. 모든 행의 결제 시각은
같고 통화는 KRW입니다. 시각과 금액만으로 거래를 구분하기 어려운 상황을 넣었습니다.

| 행 | 상점 | 주문 ID | 고객 | 금액 | 전송 ID | 수집 경로 |
| --- | --- | --- | --- | ---: | --- | --- |
| p1 | A | 100 | u1 | 50,000원 | ev1 | 웹 |
| p2 | A | 100 | u1 | 50,000원 | ev1 | 재전송 |
| p3 | A | 100 | u1 | 50,000원 | ev2 | 서버 |
| p4 | A | 101 | u1 | 50,000원 | ev3 | 웹 |
| p5 | B | 100 | u2 | 30,000원 | ev4 | 웹 |
| p6 | A | 102 | u3 | 40,000원 | ev5 | 웹 |
| p7 | A | 102 | u3 | 45,000원 | ev6 | 서버 |
| p8 | A | 없음 | u4 | 20,000원 | ev7 | 웹 |

p1과 p2는 동일 메시지의 반복이고, p3은 같은 거래를 다른 경로에서 보낸 기록입니다.
전송 ID만 고유화하면 p3이 남습니다. 반면 고객·금액·시각으로 고유화하면 실제 두 번째
주문인 p4가 p1과 함께 묶여 사라질 수 있습니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 276" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="합성 예제의 p1 p2 p3은 상점 A의 주문 100 한 건이고, p4는 같은 고객과 금액이어도 주문 101로 별도 거래입니다." style="width:100%; min-width:640px; height:auto; font-family:inherit">
  <rect width="720" height="276" rx="16" fill="#fffdf8"/>
  <text x="24" y="32" fill="#2a2d3e" font-size="16" font-weight="700">같은 거래의 반복과 정상적인 두 번째 주문</text>
  <rect x="24" y="55" width="282" height="116" rx="12" fill="#eef0f7"/>
  <text x="44" y="85" fill="#3e4a78" font-size="14">p1 웹 · p2 재전송 · p3 서버</text>
  <text x="44" y="119" fill="#2a2d3e" font-size="16">고객 u1 · 각 행 50,000원</text>
  <text x="44" y="148" fill="#5f6273" font-size="13">전송 ID는 ev1과 ev2로 다릅니다.</text>
  <path d="M322 111 H376 M368 103 L376 111 L368 119" fill="none" stroke="#3e4a78" stroke-width="2"/>
  <rect x="394" y="55" width="302" height="116" rx="12" fill="#fff1cd"/>
  <text x="416" y="88" fill="#684d13" font-size="14">상점 A · 주문 100</text>
  <text x="416" y="130" fill="#2a2d3e" font-size="25" font-weight="700">1건 · 50,000원</text>
  <rect x="24" y="190" width="672" height="58" rx="10" fill="#fcebee"/>
  <text x="44" y="224" fill="#2a2d3e" font-size="15">p4는 같은 u1·50,000원이어도 주문 101이므로 별도 1건입니다.</text>
</svg>
<figcaption>같은 고객·금액·시각이라는 조건은 거래 식별자를 대신하지 못합니다.</figcaption>
</figure>

## 주문 ID 하나만으로 중복을 제거해도 되나

주문 번호가 유일한 범위를 먼저 확인해야 합니다. 예제에서는 상점 A와 B가 모두
100번 주문을 발급했습니다. 주문 ID만 키로 쓰면 정상적인 B의 30,000원 주문이
A의 주문과 섞입니다. 이 데이터에서는 `(상점 ID, 주문 ID)` 쌍이 거래를 구분합니다.

연결하는 시스템마다 번호 형식이 다를 수도 있습니다. 한쪽에서 앞에 0을 붙이고
다른 쪽에서 숫자로 바꾸거나, 접두어를 임의로 제거하면 매칭 결과가 달라집니다.
키의 정규화 규칙은 원천 시스템의 계약을 확인한 뒤 정해야 합니다. 비슷하게 보인다는
이유로 문자를 지우는 방식은 피하는 편이 좋습니다.

거래 ID에는 고객의 이메일이나 전화번호를 직접 넣지 않습니다. 거래를 구분하는 데
필요한 식별자와 개인을 식별하는 정보는 분리해서 설계해야 합니다.

## GA4 웹과 앱의 transaction_id 처리는 같은가

Google은 같은 `transaction_id`를 가진 구매를 중복 제거한다고 안내하면서,
이 기능이 웹 스트림에서 수집한 데이터에 적용되고 앱 스트림에는 적용되지 않는다고
명시합니다. 거래마다 고유한 값을 사용해야 하며 빈 문자열을 보내지 않도록
안내합니다. [GA4 거래 ID 중복 제거 조건](https://support.google.com/analytics/answer/12313109?hl=en)

웹에서 확인한 결과를 앱의 구매 이벤트나 MMP 내보내기에 그대로 기대하면 안 됩니다.
수집 경로가 바뀌면 식별자와 처리 규칙을 다시 확인해야 합니다.

Google Ads도 조건을 구분해야 합니다. 공식 문서는 같은 전환 액션에서 같은 거래 ID가
전달되면 중복 전환을 식별한다고 설명합니다. 서로 다른 전환 액션의 숫자를 합치는
작업까지 자동으로 해결된다는 의미로 읽을 수는 없습니다.
[Google Ads 거래 ID 안내](https://support.google.com/google-ads/answer/6386790?hl=en)

## 같은 주문의 금액이 다르면 무엇을 남겨야 하나

p6과 p7은 상점 A의 같은 102번 주문이지만 금액이 40,000원과 45,000원으로
다릅니다. 더 큰 값을 남기면 45,000원, 먼저 도착한 값을 남기면 도착 순서에 따라
40,000원 또는 45,000원이 됩니다. 어느 규칙도 데이터만 보고 정답이라고 할 수
없습니다.

부분 취소 전후 상태일 수도 있고, 한쪽에 배송비가 포함됐을 수도 있습니다. 상품 금액과
주문 금액을 섞었거나 다른 주문을 잘못 연결했을 가능성도 확인해야 합니다. 주문
원장에서 무엇을 확정 값으로 사용하는지, 수정 이력을 어떤 순서로 반영하는지
정해져 있어야 하나의 값을 선택할 수 있습니다.

예제는 해당 그룹 전체를 확인 보류로 분류합니다. 주문 ID가 없는 p8도 보류합니다.
이 행들을 매출 0원으로 바꿔 버리는 대신 원본과 사유를 함께 남깁니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 276" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="합성 로그 8행 335000원은 검증된 주문 3건 130000원, 중복 2행 100000원, 확인 보류 3행 105000원으로 분류됩니다. 보류액은 실제 추가 매출이 아닙니다." style="width:100%; min-width:640px; height:auto; font-family:inherit">
  <rect width="720" height="276" rx="16" fill="#fffdf8"/>
  <text x="24" y="32" fill="#2a2d3e" font-size="16" font-weight="700">원본의 모든 행을 어느 쪽에 넣었는지 확인합니다</text>
  <text x="24" y="64" fill="#5f6273" font-size="14">합성 로그 8행 · 행 금액 합계 335,000원</text>
  <rect x="24" y="87" width="210" height="126" rx="12" fill="#eef0f7"/>
  <text x="42" y="117" fill="#3e4a78" font-size="14">검증된 주문 3건</text>
  <text x="42" y="158" fill="#2a2d3e" font-size="25" font-weight="700">130,000원</text>
  <text x="42" y="187" fill="#5f6273" font-size="12">A-100 · A-101 · B-100</text>
  <rect x="254" y="87" width="210" height="126" rx="12" fill="#fff1cd"/>
  <text x="272" y="117" fill="#684d13" font-size="14">중복으로 분류한 2행</text>
  <text x="272" y="158" fill="#2a2d3e" font-size="25" font-weight="700">100,000원</text>
  <text x="272" y="187" fill="#5f6273" font-size="12">A-100의 반복 기록</text>
  <rect x="484" y="87" width="212" height="126" rx="12" fill="#fcebee"/>
  <text x="502" y="117" fill="#a52b35" font-size="14">확인 보류 3행</text>
  <text x="502" y="158" fill="#2a2d3e" font-size="25" font-weight="700">105,000원</text>
  <text x="502" y="187" fill="#5f6273" font-size="12">금액 충돌 2행 · ID 누락 1행</text>
  <text x="24" y="244" fill="#5f6273" font-size="13">보류액은 원시 행의 합계입니다. 실제 추가 매출로 합산하지 않습니다.</text>
</svg>
<figcaption>검증된 130,000원도 보류 건을 해결하기 전에는 전체 매출로 사용할 수 없습니다.</figcaption>
</figure>

보류 행이 많은 채널은 검증된 매출 소계가 상대적으로 낮아질 수 있습니다. 따라서
이 소계로 채널 성과를 바로 순위 매기면 수집 품질 차이를 성과 차이로 해석할
위험이 있습니다. 집계 값 옆에 보류 행 수와 사유를 함께 두고, 의사결정에 영향을
줄 정도라면 원천 데이터 확인을 먼저 해야 합니다.

## Python에서 거래를 고유화하는 기준

예제는 상점·거래 ID가 같은 행을 묶고, 고객·통화·금액·실제 결제 시각이 모두 같을
때만 한 주문으로 확정합니다. 여기서 결제 시각은 이벤트가 서버에 도착한 시각과
다릅니다. 입력의 `occurred_at`은 변경되지 않는 실제 결제 시각이라는 전제를 둡니다.

핵심 분기는 다음과 같습니다. 전체 코드는 입력 검증과 사유 기록을 함께 포함합니다.

```python
for key in sorted(groups):
    group = groups[key]
    canonical = group[0][1]
    if any(item[1] != canonical for item in group[1:]):
        quarantine.extend(
            {"record": dict(item[0]), "reason": "conflicting_order"}
            for item in group
        )
        continue
    orders.append(canonical)
    duplicates.extend(
        {"record": dict(item[0]), "reason": "same_order_same_values"}
        for item in group[1:]
    )
```

값이 같은 그룹에서는 어느 행을 대표로 선택해도 확정 주문의 사업상 값이 같습니다.
값이 다른 그룹에서는 대표를 고르지 않습니다. 그래서 입력 순서가 바뀌어도 먼저
도착한 값을 우연히 정답으로 채택하는 문제가 생기지 않습니다.

잘못된 통화·금액·시각은 집계를 중단하고, 식별자가 없는 행은 보류합니다. 이 예제는
원시 데이터를 한 번에 읽는 배치 처리입니다. 여러 서버가 동시에 같은 거래를
저장할 때의 잠금, 영속 상태, 전송 ID 충돌 검사까지 구현한 운영 시스템은 아닙니다.

## 같은 데이터를 다시 처리해도 주문 수가 유지되는가

같은 입력을 한 번 더 처리해도 확정 주문 목록이 바뀌지 않는 성질을 멱등성이라고
부릅니다. 로그 파일이 다시 도착하거나 과거 데이터를 재집계할 때 필요한 기준입니다.
예제의 검증에서는 단순히 ‘중복이 줄었다’는 결과보다 다음 성질을 확인합니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 272" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="합성 데이터 원본, 원본을 두 번 이어 붙인 입력, 순서를 뒤집은 입력 모두 검증된 주문 3건 130000원을 유지합니다. 수신 행과 보류 행의 개수는 별도로 집계합니다." 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="304" height="42" rx="8" fill="#eef0f7"/>
  <text x="44" y="83" fill="#2a2d3e" font-size="14">원본 8행</text>
  <rect x="24" y="112" width="304" height="42" rx="8" fill="#eef0f7"/>
  <text x="44" y="139" fill="#2a2d3e" font-size="14">원본을 두 번 이어 붙인 16행</text>
  <rect x="24" y="168" width="304" height="42" rx="8" fill="#eef0f7"/>
  <text x="44" y="195" fill="#2a2d3e" font-size="14">도착 순서를 뒤집은 8행</text>
  <path d="M344 77 H364 V133 H394 M344 133 H394 M344 189 H364 V133 M386 125 L394 133 L386 141" fill="none" stroke="#3e4a78" stroke-width="2"/>
  <rect x="412" y="77" width="284" height="112" rx="12" fill="#fff1cd"/>
  <text x="434" y="109" fill="#684d13" font-size="14">동일한 검증 주문 목록</text>
  <text x="434" y="150" fill="#2a2d3e" font-size="24" font-weight="700">3건 · 130,000원</text>
  <text x="24" y="245" fill="#5f6273" font-size="13">합성 예제 · 수신·중복·보류 행 수는 입력 횟수에 따라 달라질 수 있습니다.</text>
</svg>
<figcaption>재처리 안정성과 수신 횟수에 대한 운영 기록은 구분해서 검증합니다.</figcaption>
</figure>

수식으로는 검증된 거래 목록을 만드는 연산을 F라고 할 때 `F(F(E)) = F(E)`를
확인할 수 있습니다. 이미 고유화한 거래를 다시 고유화해도 결과가 같아야 합니다.
원본을 두 번 이어 붙이거나 입력 순서를 뒤집었을 때도 검증된 거래 목록이 같은지
별도로 검사합니다. 보류 건이 존재한다는 사실까지 사라져서는 안 되므로 원시 수신
행 수와 보류 이력은 따로 남깁니다.

[구매 중복 분류 예제 ZIP](/downloads/marketing-metrics-02.zip)을 풀고 폴더 안에서 실행할 수 있습니다.
Python 3.9 이상과 표준 라이브러리만 사용합니다.

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

출력의 `summary`에서는 8행·335,000원이 주문 3건·130,000원, 중복 2행·100,000원,
보류 3행·105,000원으로 나뉜 것을 확인할 수 있습니다. 각 목록에는 확정한 거래나
분류한 원본과 사유가 남습니다. 테스트는 정상 재구매 보존, 상점별 ID 범위,
충돌 그룹 전체 보류, ID 누락, 재전송과 순서 변경, 멱등성까지 확인합니다.

## 태그 수집은 어떻게 검증해야 하나

수집 태그를 확인할 때는 테스트 거래마다 실제 거래 ID가 전달되는지 보고,
같은 거래를 다시 보냈을 때와 새로운 거래를 보냈을 때를 나눠 확인합니다.
Google은 웹 전자상거래 검증에 DebugView를 안내하며, 같은 거래 ID를 재사용하면
두 번째 이벤트의 값이 달라도 무시될 수 있다고 설명합니다. 테스트에서 새 거래를
확인하려고 이전 ID를 계속 쓰면 정상적인 중복 제거를 수집 실패로 오해할 수 있습니다.
[GA4 전자상거래 수집 검증 안내](https://developers.google.com/analytics/devguides/collection/ga4/validate-ecommerce)

완료 화면 새로고침, 브라우저·서버 동시 전송, 정상적인 두 번째 결제, 취소·수정
주문을 각각 점검하면 문제가 생기는 조건을 구분할 수 있습니다. 이 글의 Python
검증은 합성 로그의 분류에 대한 결과이며 실제 광고 계정이나 수집 태그를 변경한
기록은 아닙니다.

<img src="/madobi/thinking.png" alt="주문 기록을 생각하는 매도비" width="112" height="112" loading="lazy" style="display:block; margin:1.5rem auto; width:112px; height:112px" />

원본의 행이 줄어든 만큼 설명도 남아야 합니다. 어떤 주문이 하나로 합쳐졌는지,
정상 주문이 보존됐는지, 어떤 값이 아직 결정되지 않았는지까지 확인해야 중복 제거
결과를 리포트에 사용할 수 있습니다.

공식 문서는 2026년 9월 6일 확인했습니다. 플랫폼별 적용 조건은 현재 사용하는
제품·스트림·전환 액션의 문서와 설정을 함께 확인해야 합니다.

마케팅 데이터 모델·품질·지표 정의 연재 2편입니다.
[이전 글: GA4·MMP·주문 매출이 다를 때 먼저 맞춰야 할 지표 정의](/blog/marketing-metrics-definition-ga4-mmp/)

[다음 글: 매출 데이터의 날짜와 시간대를 맞추는 방법](/blog/revenue-date-timezone-boundaries/)

🐹