timestamptz 함정 모음: PostgreSQL 시간 처리에서 틀리기 쉬운 것들

5 분 소요

기초 2편의 결론은 “시각은 timestamptz"였습니다. 그 결론은 유효하지만, timestamptz를 쓰기 시작한 뒤에도 밟는 함정들이 따로 있습니다. 이 글은 그 함정 모음입니다. 공통 원리 하나만 먼저 쥐고 가면 전부 풀립니다. timestamptz는 시간대를 저장하지 않습니다. UTC로 변환된 절대 시점을 저장하고, 보여 줄 때 세션의 TimeZone 설정으로 변환할 뿐입니다.

함정 1: “왜 9시간이 어긋나요"의 대부분은 세션 설정 #

같은 행을 조회했는데 어떤 환경에서는 18:00+09, 어떤 환경에서는 09:00+00으로 보입니다. 저장된 값이 다른 것이 아니라 세션의 TimeZone이 다른 것입니다(같은 절대 시점의 두 표기입니다). 접속 도구, 애플리케이션 드라이버, 서버 설정이 제각각의 TimeZone을 가질 수 있어서 “환경마다 값이 달라 보이는” 현상이 납니다. 정석은 저장·전송 계층은 UTC로 통일하고, 사람에게 보여 주는 표시 계층에서만 지역 시간대로 변환하는 것입니다. 애플리케이션 커넥션의 TimeZone을 UTC로 고정해 두면 이 계열의 혼란이 사라집니다.

함정 2: AT TIME ZONE은 타입에 따라 반대로 동작합니다 #

이 연산자가 시간 처리 버그의 최대 공급원입니다. 입력 타입에 따라 의미가 뒤집힙니다.

AT TIME ZONE의 이중성
-- timestamptz에 적용: "이 절대 시점을 그 시간대의 벽시계로 보여 줘" → timestamp 반환
SELECT now() AT TIME ZONE 'Asia/Seoul';          -- 2026-09-06 18:00:00 (타임존 없는 값)

-- timestamp에 적용: "이 벽시계 숫자를 그 시간대의 것으로 해석해 줘" → timestamptz 반환
SELECT TIMESTAMP '2026-09-06 18:00' AT TIME ZONE 'Asia/Seoul';  -- 2026-09-06 09:00:00+00

같은 구문이 한쪽에서는 “변환해서 표시”, 다른 쪽에서는 “해석해서 절대 시점화"입니다. 실무 규칙으로 외우는 것이 안전합니다. timestamptz → AT TIME ZONE → 벽시계(timestamp), 벽시계(timestamp) → AT TIME ZONE → 절대 시점(timestamptz). 두 번 적용하면 원래 타입으로 돌아오는 왕복 관계입니다. 그리고 'KST' 같은 약어보다 'Asia/Seoul' 같은 지역명을 쓰세요. 약어는 중의적이거나(같은 약어를 여러 시간대가 씁니다) 서머타임을 반영하지 못하는 경우가 있습니다.

함정 3: “일별 집계"의 자정은 어디의 자정인가 #

일별 집계의 자정 기준
-- 세션 TimeZone 기준의 "일"로 잘립니다: 환경 따라 결과가 달라질 수 있음
SELECT date_trunc('day', created_at), count(*) FROM orders GROUP BY 1;

-- 명시적으로 "서울 기준의 일"로 자르기
SELECT date_trunc('day', created_at, 'Asia/Seoul'), count(*) FROM orders GROUP BY 1;

일별 매출 같은 집계는 “하루"의 경계, 즉 어느 시간대의 자정으로 자르느냐에 따라 숫자가 달라집니다. date_trunc나 ::date 캐스팅을 timestamptz에 그냥 적용하면 세션 TimeZone 기준으로 잘리므로, 커넥션 설정에 따라 리포트 숫자가 달라지는 사고가 납니다. 집계 기준 시간대를 쿼리에 명시하는 것(date_trunc('day', col, 'Asia/Seoul'))이 정석입니다. 참고로 이렇게 컬럼을 가공한 값으로 필터·그룹핑하는 쿼리가 잦다면 식 인덱스 검토 대상이기도 합니다.

함정 4: 미래의 일정은 timestamptz만으로 부족합니다 #

과거의 이벤트 기록에는 timestamptz가 정답이지만, 미래의 약속은 다른 문제입니다. “2027년 3월 10일 오전 10시, 서울에서 회의"를 지금 UTC로 환산해 저장하면, 그 사이 시간대 규칙(서머타임 도입·폐지 등)이 바뀌는 순간 저장된 절대 시점이 약속과 어긋납니다. 사람의 약속은 “그 지역의 벽시계 10시"이지 특정 절대 시점이 아니기 때문입니다. 캘린더류의 정석은 벽시계 값(timestamp) + 시간대 이름(text, ‘Asia/Seoul’)을 따로 저장하고, 절대 시점이 필요할 때마다 그 시점의 최신 규칙으로 계산하는 것입니다. “과거 기록은 timestamptz, 미래 일정은 벽시계 + 지역명"으로 기억하면 됩니다.

함정 5: now()는 트랜잭션 시작 시각입니다 #

now()(= CURRENT_TIMESTAMP)는 트랜잭션 시작 시각을 돌려주며, 한 트랜잭션 안에서는 몇 번을 불러도 같은 값입니다. 대부분의 용도(created_at 기본값 등)에는 이것이 오히려 원하는 동작이지만, 긴 배치 트랜잭션 안에서 “각 단계의 실제 시각"을 찍고 싶다면 문장마다 실제 시각을 주는 clock_timestamp()를 써야 합니다. “긴 트랜잭션인데 로그 시각이 전부 같다"는 미스터리의 답이 이것입니다.

정리 #

  • 원리는 하나입니다. timestamptz는 UTC 절대 시점 저장 + 세션 TimeZone 표시 변환입니다. 어긋나 보이면 먼저 세션 설정을 의심하세요.
  • AT TIME ZONE은 타입에 따라 의미가 뒤집힙니다. timestamptz→벽시계, timestamp→절대 시점의 왕복으로 기억하고, 시간대는 약어 대신 지역명을 씁니다.
  • 일별 집계는 자정의 기준 시간대를 쿼리에 명시합니다. date_trunc(‘day’, col, ‘Asia/Seoul’) 형태가 정석입니다.
  • 과거 기록은 timestamptz, 미래 일정은 벽시계 + 시간대 이름 저장입니다. 시간대 규칙은 바뀝니다.
  • now()는 트랜잭션 시작 시각으로 고정입니다. 단계별 실측 시각은 clock_timestamp()입니다.
X