빌드노트
PostgreSQL AT TIME ZONE을 붙였는데 시간이 반대로 바뀌는 이유
같은 AT TIME ZONE을 붙여도 timestamp와 timestamptz는 변환 방향과 결과 타입이 다르다. PostgreSQL 18.6의 UTC·서울 연결에서 타입·표시·epoch를 비교하고, 현지 시각 해석과 특정 지역 표시를 구분한다.
PostgreSQL에서 같은 AT TIME ZONE 'Asia/Seoul'을 붙였는데 어떤 값은 12시에서 3시로, 다른 값은 12시에서 21시로 바뀐다면 입력 타입부터 확인한다. timestamp는 주어진 날짜·시각을 해당 지역의 시각으로 해석하고, timestamptz는 이미 정해진 순간을 해당 지역의 날짜·시각으로 표현한다. 같은 문법이 입력 타입에 따라 서로 다른 일을 한다.
기존 쿼리에서는 변환 직전 식을 pg_typeof(...)로 감싸 본다. 예를 들어 occurred_at AT TIME ZONE 'Asia/Seoul'을 진단한다면 SELECT 목록에 pg_typeof(occurred_at)과 current_setting('TimeZone')을 추가한다. 왼쪽에 occurred_at::timestamp가 있다면 pg_typeof(occurred_at::timestamp)를 봐야 한다. 원래 열과 연산자에 들어가는 식의 타입이 다를 수 있다.
입력 타입을 확인한 뒤 저장 의미를 묻는다
pg_typeof가 반환하는 긴 이름을 기준으로 구분한다. PostgreSQL에서 timestamp는 timestamp without time zone이고, timestamptz는 timestamp with time zone의 별칭이다.
timestamp without time zone입력 →timestamp with time zone결과. 지정한 지역에서 그 날짜·시계가 가리키는 순간을 구한다.timestamp with time zone입력 →timestamp without time zone결과. 그 순간의 지정 지역 날짜·시계를 구한다. 결과에는 시간대 정보가 없다.
이 두 방향은 PostgreSQL 18의 AT TIME ZONE 문서 (새 탭에서 열림)에 명시돼 있다. 첫 방향의 지역은 원본 값을 어느 지역 시각으로 기록했는지에 따라 정한다. timestamp 열 이름에 utc가 들어 있다는 이유만으로 판단하지 말고, 값을 만든 코드와 저장 규칙을 확인한다. 원래 지역을 모르면 그 숫자만으로 특정 순간을 복원할 수 없다.
또한 timestamptz는 입력에 쓴 Asia/Seoul이라는 이름을 보존하지 않는다. 내부에는 UTC로 환산한 시각을 저장하고, 출력할 때 연결의 TimeZone을 적용한다. 원래 지역 이름까지 필요하다면 별도로 보관해야 한다. 날짜·시간 타입의 저장 및 출력 규칙 (새 탭에서 열림)
서울 정오를 해석하는 것과 UTC 정오를 표시하는 것
두 입력을 따로 만들어 비교했다. 첫 입력은 생산자가 서울 현지 시각으로 기록했다는 규칙이 있는 2026-01-15 12:00:00이다. 두 번째 입력은 오프셋까지 명시한 2026-01-15 12:00:00+00, 즉 UTC 정오다. 둘은 처음부터 9시간 차이 나는 서로 다른 순간을 뜻하도록 만든 합성 값이다. 실제 출처를 모르는 데이터에 서울을 임의로 붙인 실험이 아니다.
직접 비교하려면 PostgreSQL 실습 DB에 새 SQL 연결을 열고 다음을 실행한다. 테이블은 필요 없다. 두 SELECT는 각각 입력 타입, 변환 후 타입, 변환 결과를 보여 준다. SET LOCAL은 이 트랜잭션에만 적용되고 마지막 ROLLBACK에서 끝난다. SET LOCAL의 범위 (새 탭에서 열림)
BEGIN;
SET LOCAL TIME ZONE 'UTC';
SET LOCAL DateStyle = 'ISO, YMD';
WITH sample(v) AS (VALUES (TIMESTAMP '2026-01-15 12:00:00'))
SELECT pg_typeof(v) AS input_type,
pg_typeof(v AT TIME ZONE 'Asia/Seoul') AS result_type,
v AT TIME ZONE 'Asia/Seoul' AS result
FROM sample;
WITH sample(v) AS (VALUES (TIMESTAMPTZ '2026-01-15 12:00:00+00'))
SELECT pg_typeof(v) AS input_type,
pg_typeof(v AT TIME ZONE 'Asia/Seoul') AS result_type,
v AT TIME ZONE 'Asia/Seoul' AS result
FROM sample;
ROLLBACK;
전용 컨테이너의 PostgreSQL 18.6에서 확인한 두 SELECT의 결과다. 표시 형식은 CSV로 옮겼다.
input_type,result_type,result
timestamp without time zone,timestamp with time zone,2026-01-15 03:00:00+00
input_type,result_type,result
timestamp with time zone,timestamp without time zone,2026-01-15 21:00:00
첫 결과의 3시는 서울 정오를 UTC 연결에서 출력한 값이다. 서울의 정오는 UTC로 03시이므로, 03:00:00+00이 나왔다. AT TIME ZONE 'Asia/Seoul'의 결과가 반드시 서울 시계로 출력되는 것은 아니다. 이 방향의 결과는 timestamptz이므로 최종 출력은 연결 시간대를 따른다.
두 번째는 UTC 정오가 서울에서 몇 시인지 구해 21시를 반환했다. 결과 타입이 timestamp without time zone이라 +09가 붙지 않는다. 이 결과만 따로 전달하면 서울 시각이라는 맥락은 값 자체에 남지 않는다.
서울 연결에서도 비교하려면 또 다른 새 연결에서 위 블록의 SET LOCAL TIME ZONE 'UTC'만 SET LOCAL TIME ZONE 'Asia/Seoul'로 바꿔 실행한다. 별도의 서울 연결에서 실제로 확인한 결과는 첫 SELECT가 2026-01-15 12:00:00+09, 두 번째가 2026-01-15 21:00:00이었다. 두 SELECT의 반환 타입은 앞과 같았다. 첫 결과의 표시만 바뀐 것이다.
epoch를 비교할 때도 타입을 놓치지 않는다
서울 정오를 해석한 첫 결과의 epoch 초는 UTC·서울 연결 모두 1768446000.000000이었다. 03:00+00과 12:00+09가 같은 순간임을 확인한 관찰이다. 반면 두 번째 입력인 UTC 정오의 epoch는 1768478400.000000이었다.
여기서 timestamp 결과에 바로 extract(epoch FROM ...)를 적용해 순간이 바뀌었다고 판단하면 안 된다. EXTRACT의 epoch 정의 (새 탭에서 열림)에 따르면 timestamptz의 epoch는 UTC 기준 경과 초이고, timestamp의 epoch는 시간대·일광 절약 시간 규칙을 적용하지 않은 명목상의 초다. 실제 서울 표시 결과 2026-01-15 21:00:00에서 추출한 값은 1768510800.000000이었다. 이 숫자는 원래 순간의 epoch가 아니다.
다음 SELECT는 UTC 정오를 서울 시각으로 표현한 뒤, 그 결과를 다시 서울 시각으로 해석했을 때 원래 순간으로 돌아오는지 확인한다. 오프셋 있는 입력과 지역을 모두 명시해 연결의 기본 시간대에 기대지 않는다.
WITH sample(v) AS (VALUES (TIMESTAMPTZ '2026-01-15 12:00:00+00'))
SELECT extract(epoch FROM v) AS original_epoch,
extract(epoch FROM v AT TIME ZONE 'Asia/Seoul') AS nominal_epoch,
extract(epoch FROM
(v AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul'
) AS restored_epoch
FROM sample;
두 연결에서 모두 같은 세 값이 나왔다. 가운데 값과 양옆 값의 의미가 다르다는 점이 핵심이다.
original_epoch,nominal_epoch,restored_epoch
1768478400.000000,1768510800.000000,1768478400.000000
지역을 다시 지정하지 않고 (occurred_at AT TIME ZONE 'Asia/Seoul')::timestamptz로 캐스팅하면 연결의 시간대가 해석 기준이 된다. 같은 합성 입력으로 비교했을 때 UTC 연결은 서울의 21시를 UTC 21시로 읽어 원래 값과 달라졌고, 서울 연결에서만 원래 순간과 같았다. 표시용 timestamp를 다시 저장하거나 다른 타입과 비교한다면 그 단계의 해석도 확인해야 한다.
실제 쿼리에서는 timestamp의 현지 시각을 특정 순간으로 만들려는지, timestamptz의 순간을 특정 지역 시각으로 보여 주려는지 먼저 정한다. 출력이 기대와 다르면 변환 직전 타입, 원본의 저장 규칙, 같은 연결의 TimeZone을 다시 대조한다. 리터럴을 쓸 때도 TIMESTAMP '2026-01-15 12:00:00+00'은 timestamp이며 +00이 무시된다. 이 글의 UTC 순간 입력처럼 TIMESTAMPTZ를 명시해야 한다. 이 규칙도 앞의 날짜·시간 타입 문서에서 확인할 수 있다.
실험 범위는 PostgreSQL 18.6, 고정 겨울 날짜, UTC·Asia/Seoul로 명시한 새 연결이다. 본문의 짧은 SQL도 같은 조건에서 별도로 실행했다. 일광 절약 시간 전환 때 중복되거나 존재하지 않는 현지 시각, 애플리케이션 드라이버와 ORM의 추가 변환은 확인하지 않았다.