타임스탬프와 타임존 이슈
타임스탬프와 타임존 이슈 (Timestamp Issues)
SQL에서 타임존을 다루는 일은 때때로 상당히 헷갈릴 수 있어요. 예를 들어 날짜 범위로 필터링할 때 누군가는 아래처럼 쿼리를 시도할지도 몰라요:
출처: 공식문서
SET timezone = 'America/Los_Angeles';
CREATE TABLE times AS
FROM range('2025-08-30'::TIMESTAMPTZ, '2025-08-31'::TIMESTAMPTZ, INTERVAL 1 HOUR) tbl(t);
FROM times WHERE t <= '2025-08-30';
┌──────────────────────────┐
│ t │
│ timestamp with time zone │
├──────────────────────────┤
│ 2025-08-30 00:00:00-07 │
└──────────────────────────┘
그런데 타임존을 바꾸면 쿼리 결과가 달라져요:
SET timezone = 'HST';
FROM times WHERE t <= '2025-08-30';
┌──────────────────────────┐
│ t │
│ timestamp with time zone │
├──────────────────────────┤
│ 2025-08-29 21:00:00-10 │
│ 2025-08-29 22:00:00-10 │
│ 2025-08-29 23:00:00-10 │
│ 2025-08-30 00:00:00-10 │
└──────────────────────────┘
또는 더 심해질 수도 있어요:
SET timezone = 'America/New_York';
FROM times WHERE t <= '2025-08-30';
┌──────────────────────────┐
│ t │
│ timestamp with time zone │
├──────────────────────────┤
│ 0 rows │
└──────────────────────────┘
왜 이런 일이 생길까
이런 헷갈리는 결과는 DATE를 TIMESTAMP WITH TIME ZONE으로 변환하는 SQL 캐스팅 규칙 때문이에요.
이 캐스팅은 날짜를 현재 타임존의 자정으로 승격시키는 데 필요해요.
일반적으로, 현재 타임존을 표시용으로 쓰거나 다른 시간 범주화(temporal binning) 작업에 쓸 필요가 없다면, 시간 데이터에는 그냥 TIMESTAMP를 쓰는 게 좋아요.
그러면 이런 헷갈리는 문제를 피할 수 있고, 산술 연산도 대체로 더 빨라져요.
ICU 타임존 라이브러리
DuckDB는 타임존 지원을 위해 International Components for Unicode 시간 라이브러리를 사용해요. 이 라이브러리는 2037년 이후의 서머타임(daylight savings time) 지원을 포함해 몇 가지 장점이 있어요. (참고: Pandas는 그 해 이후에 대해 잘못된 결과를 줘요.)
ICU를 쓰는 단점은 성능이 아주 좋지 않다는 거예요. 이에 대한 해결책 중 하나는 모델링 대상이 되는 타임스탬프용 캘린더 테이블(calendar table)을 만드는 거예요. 예를 들어 애플리케이션이 2100년까지 시간 단위로 전력 수요·공급을 모델링한다면, 캘린더 테이블을 아래처럼 만들 수 있어요:
SET timezone = 'Europe/Amsterdam';
CREATE OR REPLACE TABLE hourly AS
SELECT
ts,
year::SMALLINT AS year,
month::TINYINT AS month,
day::TINYINT AS day,
hour::TINYINT AS hour,
FROM (
SELECT ts, unnest(date_part(['year', 'month', 'day', 'hour',], ts))
FROM generate_series(
'2020-01-01'::DATE::TIMESTAMPTZ,
'2100-01-01'::DATE::TIMESTAMPTZ,
INTERVAL 1 HOUR) tbl(ts)
) parts;
그러면 이 ~70만 행짜리 테이블을 어떤 타임스탬프 컬럼과도 조인해서, 해당 타임존의 시간 범주(bin) 값을 빠르게 얻을 수 있어요.
내부 캐스팅은 필수는 아니지만, date_part가 모든 파트에 대해 64비트 정수를 반환하므로 테이블을 더 작게 만들어줘요.
여기서 모든 파트를 date_part에 대한 단 한 번의 호출로 추출하고 있다는 점을 눈여겨보세요.
이 파트-리스트 버전의 함수는 파트를 하나씩 뽑는 것보다 빨라요. 내부의 binning 계산이 모든 파트를 계산하기 때문에, 리스트에 있는 것들을 골라내면 느린 ICU 함수를 중복 호출하지 않아도 되거든요.
또한 이전 섹션의 DATE 캐스팅 규칙을 활용해 캘린더를 모델 도메인에 맞춰 경계 지울 수 있다는 점도 알아두세요.
BETWEEN 연산자 주의
SQL로 시간 분석을 할 때 또 하나 미묘한 문제는 BETWEEN 연산자예요.
시간 분석은 거의 항상 반개구간(half-open) binning 구간을 써서 끝부분에서 겹침을 피해요.
아쉽게도 BETWEEN 연산자는 양쪽 끝이 모두 닫힌(closed-closed) 구간이에요:
x BETWEEN begin AND end
-- 이렇게 확장됨
begin <= x AND x <= end
-- 이게 아님
begin <= x AND x < end
이 문제를 피하려면 BETWEEN 대신 비교 경계를 명시적으로 쓰는 게 좋아요.