"어제 매출이 왜 대시보드랑 엑셀이랑 달라요?" 이 질문을 받으면 저는 숫자보다 시간부터 확인해요. 결론부터 말하면 시각은 UTC로 저장·계산하고 화면에 보여줄 때만 현지 시간으로 바꾸는 게 원칙이에요. 한국 시간(KST)은 UTC보다 9시간 빨라서, UTC 기준 "어제"는 한국 시간으로 어제 오전 9시부터 오늘 오전 9시까지예요. 그래서 아침 9시 전 주문이 다른 날짜로 넘어가 집계가 어긋나요.
타임존 문제는 예외를 던지며 터지지 않고 "숫자가 좀 안 맞는" 모양으로 조용히 나타나서 더 귀찮아요. 이번 글에서는 이 어긋남을 실제 변환으로 보여 주고, PostgreSQL·MySQL의 날짜 타입이 어떻게 다르게 동작하는지, 그리고 날짜만 있는 값·서머타임 반복 일정 같은 함정을 Python으로 확인하며 정리했어요.
핵심 요약
저장과 계산은 UTC, 표시는 현지 시간, 사용자 입력은 받는 즉시 UTC로 변환하는 게 기본 원칙이에요.
KST가 UTC보다 9시간 빨라서, UTC 날짜로 집계하면 한국 아침 9시 전 주문이 전날로 묶여요.
PostgreSQL timestamptz는 UTC로 정규화해 저장하고, MySQL TIMESTAMP는 2038년 한계가 있어요. DATETIME은 변환 없이 값 그대로 저장해요.
생년월일 같은 날짜는 DATE로, 서머타임이 있는 반복 일정은 현지 시각 + 타임존 이름으로 저장하세요.
기본 원칙: 저장은 UTC, 표시는 현지 시간
실무에서 널리 쓰는 원칙은 단순해요.
- 저장과 계산은 UTC로 한다.
- 화면에 보여줄 때만 사용자의 타임존으로 바꾼다.
- 사용자가 입력한 시간은 받자마자 UTC로 바꿔서 저장한다.
같은 UTC 한 순간(2025-10-05 00:00 UTC)이 도시마다 몇 시로, 심지어 며칠로 보이는지 계산해 봤어요.
막대가 0 아래(음수)면 그 도시는 아직 전날이라는 뜻이에요. 같은 순간인데 날짜가 다르니, 저장을 현지 시간으로 하면 어느 기준인지 알 수 없어져요. 그래서 기준점 하나(UTC)로 저장하는 거예요.
한국에서만 서비스하는데 굳이 UTC로 저장해야 하냐는 질문을 자주 받아요. 한국은 현재 서머타임이 없어서 KST로만 저장해도 당장은 문제가 잘 안 생겨요. 그래도 UTC를 권하는 이유는 이래요.
- 클라우드 서버, 로그 시스템, 외부 API 대부분이 UTC를 기본으로 써요. 섞이는 순간 헷갈려요.
- 해외 사용자나 해외 결제 연동이 생기면 그때 바꾸기가 훨씬 비싸요.
- 저장값에 타임존 정보가 없으면, 몇 년 뒤 그 데이터를 보는 사람은 이게 KST인지 UTC인지 알 방법이 없어요.
내부 저장은 UTC로 하되 숫자(Unix 타임스탬프)로 할지 날짜 타입으로 할지는 상황에 따라요. 타임스탬프 숫자 자체의 개념은 Unix 타임스탬프와 2038년 문제에 정리했어요.
일별 집계가 어긋나는 이유 — 직접 변환해 보기
KST 9시간 차이가 집계에 어떻게 영향을 주는지 실제 변환으로 보면 명확해요.
import datetime as dt
from zoneinfo import ZoneInfo
kst, utc = ZoneInfo('Asia/Seoul'), dt.timezone.utc
for h, m in [(8, 30), (10, 30)]:
k = dt.datetime(2025, 10, 5, h, m, tzinfo=kst)
u = k.astimezone(utc)
print(f'KST {k:%m-%d %H:%M} → UTC {u:%m-%d %H:%M}')
# KST 10-05 08:30 → UTC 10-04 23:30 ← UTC로는 전날!
# KST 10-05 10:30 → UTC 10-05 01:30
한국 시간 10월 5일 아침 8시 30분 주문은 UTC로는 10월 4일이에요. 그래서 DATE(created_at) 을 UTC 기준으로 묶으면, 한국 사람이 보기엔 분명 5일 아침인 주문이 4일 매출로 집계돼요. 한국 기준 일별 집계를 하려면 날짜를 자르기 전에 KST로 변환해야 해요.
-- PostgreSQL: UTC 저장값을 KST로 바꾼 뒤 날짜로 묶기
SELECT (created_at AT TIME ZONE 'Asia/Seoul')::date AS day, SUM(amount)
FROM orders GROUP BY day;
-- MySQL
SELECT DATE(CONVERT_TZ(created_at, '+00:00', '+09:00')) AS day, SUM(amount)
FROM orders GROUP BY day;
대시보드와 엑셀의 숫자가 다른 사건의 범인은 거의 이거예요. 한쪽은 UTC 자정, 한쪽은 KST 자정으로 하루를 잘랐던 거죠.
DB 타입 선택이 절반이다
DB마다 날짜 타입의 동작이 달라서, 이걸 모르고 쓰면 원칙을 지켜도 꼬여요.
| DB · 타입 | 저장 동작 | 조회 동작 | 범위 |
|---|---|---|---|
PostgreSQL timestamptz | 입력을 UTC로 변환해 저장 | 세션 타임존으로 표시 | 기원전~294276년 |
PostgreSQL timestamp | 타임존 정보 없이 값 그대로 | 변환 없음 | 동일 |
MySQL TIMESTAMP | 세션 타임존→UTC로 변환 저장 | 세션 타임존으로 표시 | 1970~2038년 |
MySQL DATETIME | 변환 없이 값 그대로 | 변환 없음 | 1000~9999년 |
PostgreSQL에서는 특별한 이유가 없으면 timestamptz 를 쓰는 게 일반적인 권장이에요. 이름 때문에 "타임존을 저장하는 타입"이라고 오해하기 쉬운데, 실제로는 타임존을 저장하지 않고 UTC로 정규화해서 저장해요. 조회할 때 세션 타임존으로 보여 줄 뿐이에요.
MySQL은 더 조심해야 해요. TIMESTAMP 는 자동으로 UTC 변환을 해 주지만 2038년 한계가 있어요(이유는 2038년 문제 참고). 만기일처럼 먼 미래를 담을 컬럼은 DATETIME 을 써야 하는데, DATETIME 은 변환을 안 해 주니 "이 컬럼엔 UTC 값만 넣는다"는 규칙을 팀이 지켜야 해요. DB가 대신 지켜 주지 않거든요.
자주 부딪히는 함정들
1. 서버·DB 세션 타임존 설정
서버 OS나 DB 세션 타임존이 KST로 설정돼 있으면 now() 가 KST 값을 내요. 컨테이너는 보통 UTC인데 운영 DB는 KST인 식으로 섞이면, 같은 코드가 환경마다 9시간 다른 값을 만들어요. 서버와 DB 세션은 UTC로 통일하는 게 마음 편해요. MySQL이면 SET time_zone = '+00:00', 애플리케이션 연결 설정에서 타임존을 UTC로 고정하세요.
2. 날짜만 있는 값은 DATE로
생년월일, 공휴일, 계약 시작일 같은 "날짜"는 특정 순간이 아니에요. 이걸 UTC 자정 타임스탬프로 저장하면 KST로 바꿀 땐 괜찮지만, 미국 시간대로 바꾸면 하루 전날이 돼요. 생일이 하루 밀리는 버그의 전형이에요. 날짜만 의미 있는 값은 DATE 타입으로 저장하고, 시간대 변환을 아예 하지 마세요.
3. 서머타임이 있는 미래 반복 일정
"매주 월요일 현지 시각 오전 10시 알림"처럼 현지 시각 기준 반복 일정을 UTC 하나로 고정하면, 서머타임이 있는 지역에서 시간이 밀려요. 뉴욕으로 확인해 봤어요.
ny = ZoneInfo('America/New_York')
fixed = dt.datetime(2025, 3, 1, 15, 0, tzinfo=utc) # 겨울엔 EST 10:00 = 15:00 UTC
for date in ['2025-03-01', '2025-03-15']: # 3/9에 서머타임 시작
base = dt.datetime.fromisoformat(date + 'T15:00').replace(tzinfo=utc)
print(date, base.astimezone(ny).strftime('%H:%M'))
# 2025-03-01 10:00 ← 의도한 시각
# 2025-03-15 11:00 ← 서머타임으로 1시간 밀림!
15:00 UTC는 겨울엔 뉴욕 10시지만 서머타임이 시작되면 11시가 돼요. 이런 건 UTC로 고정하지 말고 현지 시각(10:00)과 타임존 이름(America/New_York)을 함께 저장하고, 알림을 보낼 때마다 그 시점의 규칙으로 UTC를 계산해야 해요. 한국은 서머타임이 없어서 이 문제가 안 생기지만, 해외 사용자가 생기면 바로 부딪혀요.
4. 오프셋과 타임존 이름은 다르다
+09:00 은 그 순간의 오프셋이고, Asia/Seoul 은 과거·미래의 서머타임 규칙까지 담은 이름이에요. 과거 특정 시점을 기록하는 거라면 오프셋으로 충분하지만, 미래 일정이나 서머타임 지역이라면 타임존 이름(IANA tz database)을 저장해야 규칙 변경에도 안전해요.
확인하고 주고받는 법
로그에 찍힌 타임스탬프가 한국 시간으로 몇 시인지 헷갈릴 땐 타임스탬프 변환기에 넣어 UTC와 KST를 나란히 보면 금방 정리돼요. API 응답에 시간을 문자열로 줄 땐 오프셋을 꼭 붙이세요.
2026-09-28T03:00:00Z ← UTC (Z는 +00:00)
2026-09-28T12:00:00+09:00 ← KST, 같은 순간
2026-09-28 12:00:00 ← 오프셋 없음, 받는 쪽이 각자 해석 → 사고의 씨앗
오프셋 없는 시간 문자열은 받는 쪽이 제멋대로 해석하게 되니, 그게 바로 "숫자가 좀 안 맞는" 문제의 출발점이에요. ISO 8601에 오프셋을 붙이는 습관 하나로 많은 버그가 사라져요. 날짜 간격 계산은 날짜 차이 계산기로 확인할 수 있어요.
자주 묻는 질문
한국에서만 서비스하는데도 UTC로 저장해야 하나요?
꼭 그래야 하는 건 아니지만 권장해요. 클라우드 서버·로그·외부 API가 대부분 UTC라 섞이면 헷갈리고, 나중에 해외 사용자나 결제 연동이 생기면 저장 방식을 바꾸는 비용이 커요. 저장은 UTC로 하고 화면에서 KST로 바꾸는 습관을 처음부터 들이는 게 안전해요.
PostgreSQL timestamptz는 타임존을 저장하나요?
아니요. 이름과 달리 타임존 정보를 저장하지 않고 입력값을 UTC로 변환해 저장해요. 조회할 때 세션 타임존으로 보여 줄 뿐이에요. 어느 타임존에서 입력했는지 기록해야 한다면 타임존 이름을 별도 컬럼에 저장하세요.
일별 매출 집계가 대시보드마다 다른 이유는 무엇인가요?
한쪽은 UTC 자정, 다른 쪽은 KST 자정으로 하루를 잘랐기 때문이에요. KST가 9시간 빨라서 한국 아침 9시 전 주문이 UTC로는 전날로 묶여요. 한국 기준 집계라면 날짜를 자르기 전에 AT TIME ZONE 'Asia/Seoul' 이나 CONVERT_TZ 로 KST로 바꾸세요.
생년월일은 어떤 타입으로 저장해야 하나요?
DATE 타입으로 저장하고 시간대 변환을 하지 마세요. 생년월일은 특정 순간이 아니라 날짜라서, UTC 자정 타임스탬프로 저장하면 다른 시간대로 바꿀 때 하루 밀릴 수 있어요.
미래 회의 시간은 UTC로 저장하면 되나요?
서머타임이 없는 지역이면 괜찮지만, 서머타임이 있는 지역의 현지 시각 반복 일정은 UTC로 고정하면 시간이 밀려요. 현지 시각과 타임존 이름(예: America/New_York)을 함께 저장하고, 실행할 때마다 그 시점 규칙으로 UTC를 계산하세요.
정리
타임존은 "저장은 UTC, 표시는 현지 시간" 한 문장이 핵심이에요. KST 9시간 차이 때문에 집계가 어긋나니 날짜를 자르기 전에 변환하고, DB 타입(PostgreSQL timestamptz, MySQL DATETIME의 2038년 안전성)을 정확히 알고 고르세요. 날짜만 있는 값은 DATE로, 서머타임 반복 일정은 타임존 이름과 함께 저장하고, API에는 오프셋을 꼭 붙이면 대부분의 함정을 피할 수 있어요.
시각 확인은 타임스탬프 변환기, 초·밀리초 혼동은 초와 밀리초 단위 버그에서 이어서 볼 수 있어요.