본문 바로가기
inpilot.dev

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()   -- Postgres

TIMESTAMP(타임존 없음)가 아니라 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") == 3000

UTC 날짜로 자르는 구현이면 첫 건이 빠져서 실패한다. 한 줄짜리 테스트인데 이 버그는 눈으로 절대 안 잡힌다.

한 줄 요약

저장은 TIMESTAMPTZ, 경계는 IANA 이름으로 변환, 날짜는 DATE, 구간은 반열린 [시작, 끝).

새 글이 올라오면 받아보기

스팸 없이, 새 글이 올라올 때만 보내드려요.

댓글

댓글은 giscus 설정 후 표시됩니다. (docs/SETUP-features.md 참고)