# 매출 데이터의 날짜와 시간대를 맞추는 방법

> 한국 날짜와 UTC 날짜로 집계하면 주문 수는 같아도 매출이 달라질 수 있습니다. 자정 경계, GA4 시간 필드, Python 시간대 변환과 해외 캠페인의 23시간·25시간 하루를 합성 데이터와 검증 코드로 확인합니다.

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

## 세 줄 요약

- 날짜별 매출은 어느 시간대의 하루를 사용하는지 먼저 정해야 한다. 합성 예제에서는 한국 날짜와 UTC 날짜 모두 주문이 2건이지만 매출은 각각 50,000원과 70,000원이다.
- 하루의 주문은 해당 지역의 시작 자정 이상, 다음 날 자정 미만으로 집계할 수 있다. 날짜 경계를 UTC로 환산한 뒤 비교하면 마이크로초 단위의 마지막 주문과 다음 날 첫 주문을 구분할 수 있다.
- 해외 시간대의 달력상 하루가 항상 24시간인 것은 아니다. 예제 환경의 시간대 규칙으로 계산한 뉴욕의 2026년 3월 8일과 11월 1일은 각각 23시간과 25시간이다.

---
두 리포트 모두 9월 2일 주문이 2건입니다. 그런데 매출은 한쪽이 50,000원이고
다른 쪽이 70,000원입니다. 합계 오류를 찾기 전에 어느 주문이 포함됐는지 확인하면
차이를 설명할 수 있습니다. 한쪽은 한국 날짜, 다른 쪽은 UTC 날짜로 집계했습니다.

아래는 시간대와 날짜 경계를 확인하기 위한 합성 주문 4건입니다. 중복을 제거한
단일 상점의 정상 주문이며, 금액은 KRW입니다. `paid_at`은 이 예제에서 실제 결제
시각을 뜻합니다. GA4 내보내기의 시간 필드를 그대로 복제한 데이터가 아닙니다.

## 주문 수가 같아도 날짜별 매출이 다른 이유

| 주문 | UTC 결제 시각 | 한국 결제 시각 | 금액 |
| --- | --- | --- | ---: |
| o301 | 9월 1일 14:59:59.999999 | 9월 1일 23:59:59.999999 | 10,000원 |
| o302 | 9월 1일 15:00:00 | 9월 2일 00:00:00 | 20,000원 |
| o303 | 9월 2일 14:59:59.999999 | 9월 2일 23:59:59.999999 | 30,000원 |
| o304 | 9월 2일 15:00:00 | 9월 3일 00:00:00 | 40,000원 |

한국 날짜의 9월 2일에는 o302와 o303이 들어가서 50,000원이 됩니다. UTC 날짜의
9월 2일에는 o303과 o304가 들어가므로 70,000원입니다. 두 리포트 모두 주문은
2건입니다. 건수가 같다는 검증만으로는 집계 조건이 같다는 사실을 증명하지 못합니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 244" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="합성 주문에서 한국 날짜 9월 2일은 o302와 o303, 2건 50000원이고 UTC 날짜 9월 2일은 o303과 o304, 2건 70000원입니다." style="width:100%; min-width:640px; height:auto; font-family:inherit">
  <rect width="720" height="244" 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="136" rx="12" fill="#eef0f7"/>
  <text x="44" y="88" fill="#3e4a78" font-size="14">한국 날짜 · 9월 2일</text>
  <text x="44" y="132" fill="#2a2d3e" font-size="28" font-weight="700">2건 · 50,000원</text>
  <text x="44" y="166" fill="#5f6273" font-size="14">o302 20,000원 + o303 30,000원</text>
  <rect x="370" y="56" width="326" height="136" rx="12" fill="#fff1cd"/>
  <text x="390" y="88" fill="#684d13" font-size="14">UTC 날짜 · 9월 2일</text>
  <text x="390" y="132" fill="#2a2d3e" font-size="28" font-weight="700">2건 · 70,000원</text>
  <text x="390" y="166" fill="#5f6273" font-size="14">o303 30,000원 + o304 40,000원</text>
  <text x="24" y="225" fill="#5f6273" font-size="13">합성 데이터 · 실제 결제 순간은 같고 날짜를 정하는 기준이 다릅니다.</text>
</svg>
<figcaption>서로 다른 날짜 기준은 정상적인 보고 목적일 수 있습니다. 비교할 때는 기준을 맞춰야 합니다.</figcaption>
</figure>

자체 대시보드의 날짜 기준은 어느 쪽이든 정할 수 있습니다. 다만 매체 보고서,
주문 원장, 앱 분석에서 사용하는 날짜와 비교할 때는 시간대를 명시해야 합니다.
서버가 UTC를 쓴다는 이유만으로 마케터가 보는 일별 리포트까지 UTC 기준으로
해석되도록 두면 경계 근처의 차이가 반복됩니다.

## 하루의 시작과 끝을 어떻게 지정하나

한국 시간의 9월 2일은 9월 2일 00:00 이상, 9월 3일 00:00 미만으로 잡습니다.
끝 시각을 포함하지 않는 구간입니다. 수식에서 `[시작, 끝)`처럼 적는 반개구간을
사용하면 다음 날 자정의 주문이 두 날짜에 동시에 들어가지 않습니다.

```text
한국 날짜 2026-09-02
  [2026-09-02 00:00:00+09:00, 2026-09-03 00:00:00+09:00)

같은 구간을 UTC로 표현
  [2026-09-01 15:00:00+00:00, 2026-09-02 15:00:00+00:00)
```

마지막 시각을 23:59:59로 두면 o303의 23:59:59.999999를 놓칠 수 있습니다.
저장 정밀도가 밀리초인지 마이크로초인지에 따라 ‘마지막으로 가능한 시각’을
계산하는 방식보다 다음 날의 시작을 제외하는 방식이 명확합니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="한국 날짜의 시작 자정에 결제된 o302는 포함하고 다음 날 시작 자정의 o304는 제외합니다. 시작 직전 o301은 제외하고 끝 직전 o303은 포함합니다." style="width:100%; min-width:640px; height:auto; font-family:inherit">
  <rect width="720" height="250" rx="16" fill="#fffdf8"/>
  <text x="24" y="32" fill="#2a2d3e" font-size="16" font-weight="700">시작은 포함하고 다음 날의 시작은 제외합니다</text>
  <rect x="220" y="78" width="380" height="78" rx="10" fill="#eef0f7"/>
  <path d="M64 117 H658" stroke="#8991aa" stroke-width="2"/>
  <path d="M220 68 V166 M600 68 V166" stroke="#3e4a78" stroke-width="2"/>
  <circle cx="110" cy="117" r="8" fill="#fffdf8" stroke="#a52b35" stroke-width="2"/>
  <circle cx="220" cy="117" r="8" fill="#3e4a78"/>
  <circle cx="500" cy="117" r="8" fill="#3e4a78"/>
  <circle cx="600" cy="117" r="8" fill="#fffdf8" stroke="#a52b35" stroke-width="2"/>
  <text x="72" y="65" fill="#a52b35" font-size="13">o301 제외</text>
  <text x="182" y="65" fill="#3e4a78" font-size="13">o302 포함</text>
  <text x="462" y="65" fill="#3e4a78" font-size="13">o303 포함</text>
  <text x="562" y="65" fill="#a52b35" font-size="13">o304 제외</text>
  <text x="150" y="194" fill="#2a2d3e" font-size="14">9월 2일 00:00</text>
  <text x="527" y="194" fill="#2a2d3e" font-size="14">9월 3일 00:00</text>
  <text x="24" y="229" fill="#5f6273" font-size="12">합성 예제의 경계 주변을 확대한 개념도입니다. 점 사이 간격은 시간에 비례하지 않습니다.</text>
</svg>
<figcaption>인접한 날짜를 합쳤을 때 각 주문이 정확히 한 번 포함되는지도 검사합니다.</figcaption>
</figure>

예제에서 한국 날짜의 9월 1일·2일·3일 매출은 각각 10,000원·50,000원·40,000원입니다.
합계 100,000원이 원본 네 주문의 합계와 같고, 주문 ID도 중복 없이 모두 포함됩니다.
이처럼 기간을 나눈 뒤 다시 합쳐 보는 검증은 경계의 누락과 이중 집계를 찾는 데
도움이 됩니다.

## Python에서 시간대를 바꾸는 방법

이미 UTC 시각이 있다면 `astimezone`으로 같은 순간을 한국 시각으로 표현합니다.
시간대 이름을 바꿔 붙이는 `replace`와는 동작이 다릅니다.

```python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

source = datetime(2026, 9, 1, 15, tzinfo=timezone.utc)
print(source.astimezone(ZoneInfo("Asia/Seoul")).isoformat())
# 2026-09-02T00:00:00+09:00

print(source.replace(tzinfo=ZoneInfo("Asia/Seoul")).isoformat())
# 2026-09-01T15:00:00+09:00
```

두 번째 결과는 날짜와 시계 숫자를 그대로 두고 시간대만 바꿨습니다. UTC로 되돌리면
원본보다 9시간 이른 순간입니다. Python 공식 문서도 `astimezone`은 같은 UTC 순간을
유지하고, `replace`는 날짜·시각의 조정 없이 속성을 바꾸는 동작으로 설명합니다.
[Python의 시간대 변환 설명](https://docs.python.org/3.11/library/datetime.html#datetime.datetime.astimezone)

시간대 없는 `2026-09-02 00:00:00` 같은 원본은 출처가 어떤 기준으로 기록했는지
먼저 확인해야 합니다. 이미 한국 시각이라는 계약이 있다면 그 기준을 붙일 수 있지만,
임의로 UTC라고 가정하면 다른 순간을 만듭니다. 재현 예제는 오프셋 없는 결제 시각을
오류로 처리합니다.

## GA4의 event_date와 event_timestamp는 어떻게 읽나

GA4 내보내기 스키마는 `event_date`를 등록된 시간대 기준의 기록 날짜로,
`event_timestamp`를 Google Analytics가 이벤트를 받은 시각의 UTC 마이크로초 값으로
설명합니다. 주문 원장의 `paid_at`과 같은 순간을 뜻하는지는 별도로 확인해야 합니다.
열 이름에 time이 들어 있다는 이유만으로 결제 시각을 대신하게 둘 수는 없습니다.
[GA4 BigQuery 내보내기 스키마](https://support.google.com/analytics/answer/7029846?hl=en)

BigQuery에서 UTC 마이크로초 값을 날짜로 바꿀 때는 단위와 시간대를 모두 드러낼 수
있습니다. 다음은 열 표현식의 예시입니다.

```sql
DATE(TIMESTAMP_MICROS(event_timestamp), 'Asia/Seoul')
```

`TIMESTAMP_MICROS`는 UTC 기준 epoch 마이크로초를 시각으로 해석하고, `DATE`의
두 번째 인자는 날짜를 읽을 시간대를 지정합니다. 초·밀리초·마이크로초를 혼동하면
문법에 맞는 코드라도 전혀 다른 날짜가 나올 수 있습니다.
[BigQuery TIMESTAMP_MICROS](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp_micros),
[BigQuery DATE](https://cloud.google.com/bigquery/docs/reference/standard-sql/date_functions#date)

이 표현식만으로 GA4 보고서의 날짜와 모든 경우에 일치한다고 단정할 수는 없습니다.
시간 필드가 가리키는 시점, 속성 설정, 보고 지표와 원천의 범위를 함께 확인해야
합니다. 여기서는 공식 문서에 표현식을 대조했으며 클라우드 쿼리를 직접 실행한
결과로 제시하지 않습니다.

## 해외 캠페인의 하루는 항상 24시간인가

현지 달력의 하루와 실제 경과 시간이 달라질 수 있습니다. 예제 환경의 시간대
데이터로 뉴욕의 2026년 3월 8일을 계산하면 하루가 23시간이고, 11월 1일은
25시간입니다. 일광절약시간으로 UTC 오프셋이 바뀌기 때문입니다.

<figure style="overflow-x:auto">
<svg viewBox="0 0 720 276" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="뉴욕의 2026년 3월 8일은 UTC 3월 8일 05시부터 9일 04시까지 23시간, 11월 1일은 UTC 11월 1일 04시부터 2일 05시까지 25시간입니다." 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="56" width="326" height="154" rx="12" fill="#eef0f7"/>
  <text x="44" y="86" fill="#3e4a78" font-size="14">뉴욕 · 2026년 3월 8일</text>
  <text x="44" y="130" fill="#2a2d3e" font-size="30" font-weight="700">23시간</text>
  <text x="44" y="164" fill="#5f6273" font-size="13">UTC 3월 8일 05:00부터</text>
  <text x="44" y="187" fill="#5f6273" font-size="13">UTC 3월 9일 04:00까지</text>
  <rect x="370" y="56" width="326" height="154" rx="12" fill="#fff1cd"/>
  <text x="390" y="86" fill="#684d13" font-size="14">뉴욕 · 2026년 11월 1일</text>
  <text x="390" y="130" fill="#2a2d3e" font-size="30" font-weight="700">25시간</text>
  <text x="390" y="164" fill="#5f6273" font-size="13">UTC 11월 1일 04:00부터</text>
  <text x="390" y="187" fill="#5f6273" font-size="13">UTC 11월 2일 05:00까지</text>
  <text x="24" y="244" fill="#5f6273" font-size="12">America/New_York · Python zoneinfo와 시스템 tzdata 2026c로 날짜 경계 계산</text>
</svg>
<figcaption>시작 자정에 24시간을 더하는 대신, 다음 현지 날짜의 시작을 별도로 구합니다.</figcaption>
</figure>

11월 1일에는 현지의 01:30이 두 번 나타납니다. `01:30-04:00`과 `01:30-05:00`은
시계 숫자가 같지만 다른 순간입니다. 오프셋을 지우고 날짜·시각 문자열만 사용하면
두 주문을 같은 시점으로 오해할 수 있습니다. Python의 `zoneinfo`는 이런 반복
구간을 구분하는 `fold`를 지원하며, UTC에서 변환할 때 해당 구분을 적용합니다.
[Python zoneinfo와 반복되는 시각](https://docs.python.org/3.11/library/zoneinfo.html)

경과 시간을 계산할 때도 주의가 필요합니다. Python에서 같은 `tzinfo`를 가진 두
datetime을 바로 빼면 시간대 조정 없이 벽시계 기준의 차이를 계산합니다. 이 예제는
양쪽 날짜 경계를 UTC로 바꾼 뒤 차이를 구해 23시간과 25시간을 확인했습니다.
[Python datetime 뺄셈 규칙](https://docs.python.org/3.11/library/datetime.html#datetime-objects)

시간당 구매 수나 초당 요청 수처럼 경과 시간이 분모에 들어가는 지표라면 이 차이가
계산에 영향을 줍니다. 반면 현지 날짜별 매출을 보고 싶다는 업무 정의가 있다면
23시간·25시간인 날도 그 달력 날짜로 묶습니다. 모든 일별 지표를 일괄적으로
24시간에 맞춰 보정하는 방식은 목적에 맞지 않을 수 있습니다.

## 같은 결과를 직접 재현하는 방법

[날짜·시간대 예제 ZIP](/downloads/marketing-metrics-03.zip)을 풀고 폴더 안에서 실행합니다.
Python 3.9 이상과 IANA 시간대 데이터가 필요하며, 검증에는 Python 3.11.7과 시스템
tzdata 2026c를 사용했습니다.

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

출력에는 한국 날짜의 2건·50,000원과 UTC 날짜의 2건·70,000원이 각각 포함됩니다.
뉴욕의 두 날짜는 거래 데이터 없이 날짜 경계의 경과 시간만 계산합니다. 테스트는
시작·끝 자정, 마이크로초 경계, 인접 날짜의 무중복 분할, 반복되는 현지 시각,
오프셋 없는 입력, 마이크로초 단위 변환 등을 확인합니다.

예제의 핵심 집계 조건은 다음과 같습니다. `start`와 `end`는 해당 지역의 날짜
경계를 UTC로 환산한 값입니다.

```python
if start <= utc_instant(row["paid_at"]) < end:
    selected.append(row)
```

시간대 규칙에는 역사적인 날짜 변경도 있습니다. 예제는 존재하지 않거나 두 가지로
해석되는 자정을 발견하면 별도 정책이 필요하다는 오류를 냅니다. 모든 지역의
역사적 변경을 임의의 기준으로 보정하는 코드는 아닙니다. 원본 시각과 적용한
시간대 데이터 버전을 보관하면 규칙이 바뀌었을 때 재현 범위를 확인할 수 있습니다.

<img src="/madobi/reading.png" alt="데이터 기록을 읽는 매도비" width="112" height="112" loading="lazy" style="display:block; margin:1.5rem auto; width:112px; height:112px" />

날짜별 리포트에는 기준 시간대, 사용한 시각 필드, 시작·끝의 포함 규칙을 함께
남겨 보세요. 집계 건수가 같더라도 실제 주문 목록까지 맞춰 보면 경계에서 생긴
매출 차이를 놓치지 않을 수 있습니다.

공식 문서와 실행 환경은 2026년 9월 6일 확인했습니다.

마케팅 데이터 모델·품질·지표 정의 연재 3편입니다.
[이전 글: 구매 이벤트 중복을 제거할 때 transaction_id부터 확인하는 이유](/blog/purchase-event-deduplication-transaction-id/)

[다음 글: 취소와 부분 환불을 반영한 순매출 계산](/blog/net-revenue-cancellations-partial-refunds/)

🐹