DB 시간이 9시간씩 어긋난다 - PostgreSQL 타임존(UTC와 KST) 완전 정리
개발하다 보면 누구나 한 번은 겪는다. 분명 밤 9시에 글을 썼는데 화면에는 낮 12시로 찍혀 있다. 방문자 통계를 날짜별로 뽑았더니 새벽에 방문자가 몰려 있다. 로그 시각을 보고 "이 시간에 무슨 일이 있었지?" 하고 한참 찾았는데 알고 보니 9시간 전 일이었다.
원인은 거의 항상 같다. DB는 UTC로 시간을 다루는데, 사람과 화면은 한국 시간(KST, UTC+9)을 기대하기 때문이다. 이 글에서는 PostgreSQL이 시간을 어떻게 저장하고 보여주는지, 그리고 어디서 9시간이 생기고 어떻게 막는지 정리한다. 예제는 전부 직접 운영 중인 PostgreSQL 16에 직접 실행한 결과다.
먼저 확인할 것 - 내 DB는 어느 시간대인가
=> SHOW timezone;
TimeZone
----------
UTC
=> SELECT now(), localtimestamp;
now | localtimestamp
-------------------------------+----------------------------
2026-10-04 12:18:27.362756+00 | 2026-10-04 12:18:27.362756
이 쿼리를 실행한 실제 시각은 한국 시간으로 밤 9시 18분이었다. 서버(호스트 OS)의 시간대는 분명 Asia/Seoul인데, DB는 UTC 기준인 12시 18분을 돌려줬다.
이유는 DB가 컨테이너 안에서 돌고 있기 때문이다. PostgreSQL은 처음 데이터 디렉터리를 만들 때(initdb) 실행 환경의 시간대를 기본값으로 저장한다. 컨테이너 안에는 보통 시간대 설정이 없어서 UTC로 초기화된다. 호스트의 시간대와는 아무 상관이 없다. 쿠버네티스나 Docker로 DB를 띄웠다면 거의 확실히 UTC라고 보면 된다. 클라우드 관리형 DB(RDS 등)도 기본값이 UTC인 경우가 대부분이다.
UTC가 기본인 건 나쁜 게 아니다
오히려 권장되는 설정이다. 서버가 여러 지역에 있거나, 해외 사용자가 생기거나, 서머타임이 있는 나라의 데이터를 다룰 때 기준 시각이 하나로 통일돼 있어야 꼬이지 않는다. 문제는 UTC 자체가 아니라, UTC로 저장된 값을 사람에게 보여줄 때 변환을 빼먹는 것이다.
핵심 - timestamp와 timestamptz는 완전히 다르다
PostgreSQL의 날짜·시간 타입은 이름이 비슷해서 헷갈리지만, 동작은 완전히 다르다.
timestamp(= timestamp without time zone) — 시간대 정보가 없는 "벽시계 숫자"다.2026-10-04 12:18이라고 저장하면 그게 서울 시각인지 런던 시각인지 DB는 모른다. 그냥 숫자 그대로 보관하고 그대로 보여준다.timestamptz(= timestamp with time zone) — 이름과 달리 시간대를 저장하지 않는다. 입력받은 시각을 UTC로 바꿔서 저장하고, 꺼낼 때 현재 세션의 시간대로 바꿔서 보여준다. 즉 "절대적인 한 순간"을 저장하는 타입이다.
말로는 와닿지 않으니 직접 확인해보자. 같은 순간(now())을 두 타입에 각각 넣고, 세션 시간대만 바꿔서 다시 조회한다.
=> CREATE TEMP TABLE t (ts timestamp, tstz timestamptz);
=> INSERT INTO t VALUES (now(), now());
=> SELECT * FROM t; -- 세션 시간대: UTC
ts | tstz
----------------------------+-------------------------------
2026-10-04 12:18:27.448438 | 2026-10-04 12:18:27.448438+00
=> SET timezone = 'Asia/Seoul';
=> SELECT * FROM t; -- 세션 시간대: Asia/Seoul
ts | tstz
----------------------------+-------------------------------
2026-10-04 12:18:27.448438 | 2026-10-04 21:18:27.448438+09
timestamptz는 세션 시간대에 맞춰 21시 18분 +09로 정확하게 바뀌었다. 반면 timestamp는 12시 18분 그대로다. 저장할 때 UTC 기준 숫자가 들어갔고, 그게 몇 시 기준인지 모르니 바꿔줄 방법이 없는 것이다. 9시간 오차의 대부분이 여기서 생긴다.
입력할 때도 마찬가지다. 시간대를 붙여서 넣어도 timestamp는 그걸 조용히 버린다.
=> SELECT '2026-10-04 08:30:00+09'::timestamptz;
timestamptz
------------------------
2026-10-03 23:30:00+00 -- 한국 시간 08:30 = UTC 23:30 (정확)
=> SELECT '2026-10-04 08:30:00+09'::timestamp;
timestamp
---------------------
2026-10-04 08:30:00 -- +09가 무시됨. 이게 UTC인지 KST인지 이제 아무도 모른다
AT TIME ZONE - 가장 헷갈리는 연산자
이미 timestamp로 저장된 데이터를 한국 시간으로 보려면 AT TIME ZONE을 쓴다. 그런데 이 연산자는 입력 타입에 따라 반대로 동작한다.
timestamp AT TIME ZONE 'X'→ "이 숫자는 X 시간대 기준이다"라고 해석해서 timestamptz로 바꾼다timestamptz AT TIME ZONE 'X'→ 이 순간을 X 시간대의 벽시계로 읽어서 timestamp로 바꾼다
=> SELECT pg_typeof(ts AT TIME ZONE 'UTC'), pg_typeof(tstz AT TIME ZONE 'Asia/Seoul') FROM t;
pg_typeof | pg_typeof
--------------------------+-----------------------------
timestamp with time zone | timestamp without time zone
그래서 UTC로 저장된 timestamp 컬럼을 한국 시간으로 보려면 두 번 써야 한다. 한 번만 쓰면 엉뚱한 결과가 나온다.
=> SET timezone = 'Asia/Seoul';
=> SELECT ts,
ts AT TIME ZONE 'Asia/Seoul' AS wrong_once,
ts AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Seoul' AS right_twice
FROM t;
ts | wrong_once | right_twice
----------------------------+-------------------------------+----------------------------
2026-10-04 12:18:27.448438 | 2026-10-04 12:18:27.448438+09 | 2026-10-04 21:18:27.448438
한 번만 쓰면
ts AT TIME ZONE 'Asia/Seoul'은 "12:18은 서울 시각이다"라고 해석해버린다. 실제로는 UTC 12:18(서울 21:18)인데 서울 12:18로 붙박이가 되어 오히려 9시간이 더 틀어진 값이 된다. 화면에는 +09까지 붙어 나와서 맞는 것처럼 보이니 더 위험하다.
두 번 쓰면
첫 번째 AT TIME ZONE 'UTC'가 "이 숫자는 UTC 기준"이라고 알려줘서 정확한 순간(timestamptz)이 되고, 두 번째 AT TIME ZONE 'Asia/Seoul'이 그 순간을 서울 벽시계로 읽어준다. 결과는 정확히 21:18이다.
가장 조용한 버그 - 날짜별 집계가 틀어진다
시각이 9시간 틀리면 그나마 눈에 띈다. 정말 무서운 건 날짜 단위 집계다. 한국 시간 오전 0시~9시 사이의 데이터는 UTC로는 전날이기 때문이다.
=> SELECT '2026-10-03 23:30:00'::timestamp::date AS utc_date,
('2026-10-03 23:30:00'::timestamp AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Seoul')::date AS kst_date;
utc_date | kst_date
------------+------------
2026-10-03 | 2026-10-04
실제 서비스 데이터로 확인해봤다. 우리 사이트의 요청 로그 테이블(request_log)은 created_at이 timestamp without time zone이고 UTC로 쌓인다. 10월 2일과 3일의 일별 요청 수를 두 가지 방식으로 뽑아봤다.
-- UTC 날짜 기준 (변환 없이 ::date)
utc_day | count
------------+-------
2026-10-02 | 420
2026-10-03 | 370
-- 한국 날짜 기준 (UTC → KST 변환 후 ::date)
kst_day | count
------------+-------
2026-10-02 | 225
2026-10-03 | 564
같은 "10월 2일"인데 420건과 225건, 거의 두 배 차이다. 변환 없이 만든 통계를 보고 "2일에 방문자가 많았네"라고 판단했다면 완전히 틀린 결론이다. 그리고 이 숫자들은 각각 그럴듯해 보이기 때문에 틀린 줄도 모르고 넘어가기 쉽다.
WHERE로 기간을 거를 때도 같은 함정이 있다. "한국 날짜로 10월 2일 하루치"를 원한다면 UTC 기준으로는 10월 1일 15:00 ~ 10월 2일 15:00을 잡아야 한다.
-- 한국 날짜 10/2 하루치 (인덱스도 그대로 탄다)
WHERE created_at >= '2026-10-01 15:00' AND created_at < '2026-10-02 15:00'
WHERE (created_at AT TIME ZONE ...)::date = '2026-10-02'처럼 컬럼 쪽을 변환하면 결과는 맞지만 created_at 인덱스를 못 탄다. 데이터가 많다면 위처럼 비교할 상수 쪽을 UTC로 바꾸는 게 좋다.
timestamptz 컬럼이라면 PostgreSQL 12부터 시간대를 지정해서 날짜를 자를 수도 있다.
=> SELECT date_trunc('day', now(), 'Asia/Seoul');
date_trunc
------------------------
2026-10-03 15:00:00+00 -- = 한국 시간 10월 4일 0시
서머타임 - 한국에선 없지만 알아둬야 하는 이유
한국은 서머타임이 없어서 KST는 항상 UTC+9다. 그래서 "그냥 9시간 더하면 되지"라는 코드가 꽤 오래 문제없이 돌아간다. 하지만 해외 시간대를 다루는 순간 이 가정이 깨진다. 미국 동부 시간대에서 서머타임이 시작되는 날(2026년 3월 8일)을 넘겨 계산해보면 이렇다.
=> SET timezone = 'America/New_York';
=> SELECT '2026-03-07 12:00'::timestamptz AS start,
'2026-03-07 12:00'::timestamptz + interval '1 day' AS plus_1day,
'2026-03-07 12:00'::timestamptz + interval '24 hours' AS plus_24h;
start | plus_1day | plus_24h
------------------------+------------------------+------------------------
2026-03-07 12:00:00-05 | 2026-03-08 12:00:00-04 | 2026-03-08 13:00:00-04
1 day를 더하면 "다음 날 같은 시각"(낮 12시)이 되고, 24 hours를 더하면 실제로 24시간 뒤인 오후 1시가 된다. 그날 하루가 23시간이기 때문이다. timestamptz와 지역 이름(America/New_York)을 쓰면 PostgreSQL이 이런 규칙을 알아서 계산해준다. 반대로 + interval '9 hours'나 '+09' 같은 고정 오프셋으로 처리하면 이 정보가 전부 사라진다. 시간대는 오프셋이 아니라 이름(Asia/Seoul)으로 지정하는 습관을 들이자.
그래서 어떻게 해야 하나 - 상황별 처방
새로 만드는 테이블이라면
- 시각 컬럼은 무조건
timestamptz를 쓴다. PostgreSQL 공식 위키의 "하지 말아야 할 것(Don't Do This)" 문서에도timestamp(without time zone)로 실제 시각을 저장하지 말라고 나와 있다. - DB 서버 시간대는 UTC 그대로 둔다.
- 보여줄 때만 변환한다. 애플리케이션에서 사용자 시간대로 바꾸거나, 접속할 때 세션 시간대를 지정한다.
-- 컬럼 정의
created_at timestamptz NOT NULL DEFAULT now()
-- 특정 사용자(롤)의 기본 세션 시간대만 한국으로
ALTER ROLE report_user SET timezone = 'Asia/Seoul';
-- 또는 접속 문자열에서 (libpq 계열)
postgresql://user@host/db?options=-c%20timezone%3DAsia/Seoul
ALTER DATABASE ... SET timezone으로 DB 전체 기본값을 바꿀 수도 있지만, 이렇게 하면 now()를 timestamp 컬럼에 넣는 기존 코드가 갑자기 KST 숫자를 저장하기 시작해서 UTC 데이터와 KST 데이터가 한 컬럼에 섞인다. 이미 데이터가 쌓인 시스템이라면 신중해야 한다.
이미 timestamp(UTC)로 쌓여 있다면
우리 사이트가 딱 이 경우다. 요청 로그와 방문 기록이 전부 timestamp without time zone에 UTC로 쌓여 있다. 선택지는 두 가지다.
- 컬럼 타입을 바꾸기 — "기존 값은 UTC였다"고 명시하면서 한 번에 변환할 수 있다. 다만 테이블을 다시 쓰는 작업이라 큰 테이블에서는 잠금 시간이 길어진다.
ALTER TABLE request_log
ALTER COLUMN created_at TYPE timestamptz
USING created_at AT TIME ZONE 'UTC';
- 규칙을 정하고 지키기 — 지금처럼 "이 컬럼들은 UTC다"라고 못 박아두고, 조회할 때마다
AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Seoul'을 쓴다. 애플리케이션에서 표시할 때+9시간을 하는 방식도 한국만 대상이라면 동작은 한다. 다만 규칙을 모르는 사람(또는 몇 달 뒤의 나)이 변환 없이 쿼리하는 순간 위의 집계 버그가 다시 생긴다.
개인적으로는 데이터가 작을 때 timestamptz로 옮기는 게 장기적으로 이득이라고 본다. 실제로 이 블로그의 요청 로그를 분석하면서 처음에 변환을 빼먹어 "왜 새벽에 구글봇이 몰려 있지?" 하고 한참 헤맨 적이 있다. 그 뒤로 시각이 들어간 쿼리를 보기 전에는 DB 시간대부터 확인한다는 습관을 들였다.
애플리케이션 쪽에서 주의할 점
- Python(psycopg) —
timestamptz는 시간대 정보가 붙은(aware)datetime으로,timestamp는 시간대 정보가 없는(naive)datetime으로 돌아온다. naive 값을datetime.now()(서버 로컬 시각)와 비교하면 9시간 차이가 그대로 버그가 된다.datetime.now(timezone.utc)처럼 항상 aware 값으로 다루자. - JavaScript — 서버가
2026-10-04T12:18:27Z처럼Z(또는+00:00)가 붙은 ISO 문자열을 주면 브라우저가 사용자 시간대로 알아서 바꿔준다. 오프셋이 없는 문자열을 주면 브라우저마다 해석이 달라질 수 있다. - 로그인 잠금, 만료 시각처럼 로직에 쓰는 시각 — 표시용이 아니라 비교용이라면 DB와 앱이 같은 기준(UTC)으로 비교하는지 반드시 확인하자. 화면에 보이는 시각이 틀리면 불편한 정도지만, 잠금 해제 시각이 9시간 틀리면 장애가 된다.
체크리스트
SHOW timezone;— DB 세션 시간대 확인 (컨테이너라면 거의 UTC)\d 테이블명— 시각 컬럼이timestamp인지timestamptz인지 확인timestamp컬럼이라면, 그 숫자가 어느 시간대 기준인지 문서화하기- 한국 시간으로 보려면
col AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Seoul'(두 번!) - 날짜별 집계(
::date,date_trunc)를 하기 전에 시간대 변환이 먼저인지 확인 - 기간 조건은 상수 쪽을 UTC로 바꿔서 인덱스를 살리기
- 시간대는
+09가 아니라Asia/Seoul처럼 이름으로 - 새 테이블은
timestamptz, DB는 UTC, 변환은 보여줄 때만
마무리
타임존 버그는 에러가 나지 않는다는 점에서 고약하다. 쿼리는 성공하고, 숫자는 그럴듯하고, 화면도 멀쩡해 보인다. 그래서 "9시간"이라는 숫자를 보는 순간 바로 타임존을 의심하는 감각이 중요하다. 정리하면 저장은 UTC(timestamptz)로, 변환은 보여줄 때 한 번만, 시간대는 이름으로. 이 세 가지만 지켜도 대부분의 시간 관련 버그는 피할 수 있다.
이 글의 예제는 PostgreSQL 16 기준이다. date_trunc의 시간대 인자는 12 이상에서만 쓸 수 있고, 그 외 내용은 대부분의 버전에서 동일하게 동작한다. 자세한 내용은 PostgreSQL 공식 문서의 "Date/Time Types" 항목을 참고하시길.
댓글 0
글에 대한 질문이나 오류 제보는 문의하기로 보내 주세요.