UTC로 저장했는데 일별 집계가 안 맞는다 — 시간대 경계 처리
2026-02-01 · 2 min read
$ DATE(now() AT TIME ZONE 'Asia/Seoul')
2026-02-01 — UTC로는 01-31
저장은 UTC. 여기까진 다 안다
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- PostgresTIMESTAMP(타임존 없음)가 아니라 TIMESTAMPTZ를 쓴다.
전자는 "2026-02-01 09:00"만 저장하고 어느 시간대인지는 안 적는다.
서버 시간대가 바뀌거나 다른 리전에 인스턴스가 뜨면 그 순간 의미가 달라진다.
여기까지는 대부분 맞게 한다. 사고는 그다음에 난다.
"오늘 매출"의 경계
KST는 UTC+9다. 한국 시각 2월 1일 하루는 UTC로 이렇다.
KST 2026-02-01 00:00 = UTC 2026-01-31 15:00
KST 2026-02-01 23:59 = UTC 2026-02-01 14:59
즉 한국의 하루는 UTC 날짜 두 개에 걸쳐 있다. 그래서 이건 틀렸다.
-- 틀림: UTC 기준 날짜로 자름. 한국 시각 00:00~09:00 매출이 전날로 빠진다
SELECT DATE(created_at), SUM(amount_minor) FROM payments GROUP BY 1;시간대를 변환한 뒤에 잘라야 한다.
SELECT DATE(created_at AT TIME ZONE 'Asia/Seoul') AS d, SUM(amount_minor)
FROM payments GROUP BY 1 ORDER BY 1;'Asia/Seoul'이라고 쓰지 '+09:00'이라고 쓰지 않는다.
고정 오프셋은 서머타임이 있는 지역에서 틀린다. 한국은 지금 DST가 없지만,
같은 코드가 다른 나라에 쓰이는 순간 조용히 틀린 값을 낸다.
IANA 시간대 이름을 쓰는 습관이 이 문제를 통째로 없앤다.
인덱스가 안 먹는 문제
위 쿼리는 컬럼에 함수를 씌워서 created_at 인덱스를 못 탄다.
범위 조회는 반대로 쓴다. 컬럼은 그대로 두고 경계값을 계산해서 넣는다.
SELECT SUM(amount_minor) FROM payments
WHERE created_at >= '2026-02-01 00:00+09'
AND created_at < '2026-02-02 00:00+09'; -- 미만. BETWEEN 쓰지 않는다BETWEEN을 쓰면 끝점이 포함되어 23:59:59.999에 들어온 건이 양쪽 날짜에
중복 집계된다. 반열린 구간 [시작, 끝) 이 기본이다.
집계를 자주 돌린다면 표현식 인덱스를 만든다.
CREATE INDEX ON payments ((DATE(created_at AT TIME ZONE 'Asia/Seoul')));날짜에 시각을 붙이지 않는다
생일, 결제 예정일, 정산 기준일 같은 값은 시점이 아니라 날짜다.
여기에 TIMESTAMPTZ를 쓰면 하루가 밀린다.
DB 저장: 2026-02-01T00:00:00Z
KST로 표시: 2026-02-01 09:00 → 날짜만 잘라도 02-01 (운 좋게 맞음)
UTC-5로 표시: 2026-01-31 19:00 → 01-31. 하루 밀렸다
날짜에는 DATE 타입을 쓴다. 시간대 변환을 아예 안 하니까 밀릴 일이 없다.
"어차피 한국만 쓰는데"라고 넘기면, 나중에 서버 리전을 옮기거나
사용자 시간대를 지원할 때 전부 다시 봐야 한다.
| 값의 성격 | 타입 | 예 |
|---|---|---|
| 실제로 일어난 시점 | TIMESTAMPTZ | 결제 완료 시각, 로그 |
| 달력상의 날짜 | DATE | 정산 기준일, 생년월일 |
| 벽시계 시각(장소 고정) | TIME + 시간대 컬럼 | 매장 오픈 시각 |
프론트로 보낼 때
API는 오프셋이 붙은 ISO 8601로 내보낸다.
{ "paid_at": "2026-02-01T12:30:00+09:00" }"2026-02-01 12:30:00"처럼 오프셋 없이 보내면 받는 쪽이 로컬 시간으로
해석할지 UTC로 해석할지 제각각이다. JS new Date()의 파싱 규칙이
문자열 형태에 따라 달라진다는 걸 모르고 쓰면 여기서 정확히 9시간이 어긋난다.
검증
def test_daily_boundary():
"""KST 자정 직후 결제가 그날 집계에 들어가야 한다"""
pay(at="2026-02-01T00:05:00+09:00", amount=1000) # UTC로는 01-31
pay(at="2026-02-01T23:55:00+09:00", amount=2000) # UTC로는 02-01
assert daily_total("2026-02-01") == 3000UTC 날짜로 자르는 구현이면 첫 건이 빠져서 실패한다. 한 줄짜리 테스트인데 이 버그는 눈으로 절대 안 잡힌다.
한 줄 요약
저장은
TIMESTAMPTZ, 경계는 IANA 이름으로 변환, 날짜는DATE, 구간은 반열린[시작, 끝).