SQL과 쿼리
날짜와 시간, 윈도우 함수 (toStartOf, 시간대, WITH FILL, 창 함수)
DateTime과 DateTime64의 시간대 동작, 시간 버킷 함수, 빈 구간을 채우는 WITH FILL, 그리고 윈도우 함수로 이동 평균과 세션화를 구현하는 방법을 정리합니다. 한국 시간(Asia/Seoul) 기준 보고서를 만들 때 틀리는 지점을 중심으로.
시간 타입 고르기
| 타입 | 범위와 정밀도 | 크기 | 쓸 때 |
|---|---|---|---|
Date | 1970 ~ 2149, 일 | 2바이트 | 파티션 키, 일별 집계 |
Date32 | 1900 ~ 2299, 일 | 4바이트 | 과거 데이터 |
DateTime | 1970 ~ 2106, 초 | 4바이트 | 대부분의 이벤트 시각 |
DateTime64(3) | 1900 ~ 2299, 밀리초(정밀도 0~9) | 8바이트 | 지연시간 분석, 순서가 중요한 로그 |
Time, Time64 | 하루 안의 시각, 또는 길이 | 영업 시간, 소요 시간 (최근 버전) |
DateTime은 UTC 기준 유닉스 초를 저장하고, 시간대는 컬럼 메타데이터입니다. 저장 값은 시간대와 무관하게 같고, 보여 줄 때와toDate같은 분해 함수에서만 시간대가 쓰입니다.- 컬럼에 시간대를 적지 않으면 서버 시간대(
timezone설정)를 따릅니다. 서버는 보통 UTC이므로 한국 기준 날짜가 필요하면 컬럼에 시간대를 명시하는 편이 안전합니다. DateTime64의 정밀도는 필요한 만큼만. 밀리초면 3, 마이크로초면 6. 정밀도가 높을수록 압축이 나빠집니다.
CREATE TABLE events
(
ts DateTime64(3, 'Asia/Seoul'),
event_date Date DEFAULT toDate(ts), -- 한국 기준 날짜
user_id UInt64,
kind LowCardinality(String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (kind, ts);
시간대 다루기
SELECT
now() AS server_now, -- 서버 시간대
toTimeZone(now(), 'Asia/Seoul') AS kst, -- 같은 순간, 표시만 KST
toDateTime('2026-10-21 09:00:00', 'Asia/Seoul') AS parsed_kst,
toDate(now(), 'Asia/Seoul') AS kst_date, -- 분해 함수에 시간대 넘기기
toUnixTimestamp(now()) AS epoch;toTimeZone은 값을 바꾸지 않고 표시 시간대만 바꿉니다.- 문자열을 파싱할 때 시간대를 넘기지 않으면 서버 시간대로 해석됩니다. 클라이언트가 KST 문자열을 보내면
toDateTime(s, 'Asia/Seoul')로 받습니다. - 세션 단위로 바꾸려면
SET session_timezone = 'Asia/Seoul'. 시간대가 없는 리터럴과 분해 함수가 이 값을 따릅니다. - 날짜 경계 비교는
event_date컬럼(파티션 키)과ts둘 다 거는 편이 파티션 가지치기와 정확성을 함께 얻습니다. 정렬 키, 기본 키, 파티션
주의
toDate(ts)의 결과는 컬럼 시간대에 따라 다릅니다. UTC 컬럼이면 한국 밤 9시 이후 이벤트가 다음 날로 넘어가지 않습니다. 보고서 날짜가 하루씩 틀리면 거의 이 문제입니다.
시간 버킷 함수
| 함수 | 결과 |
|---|---|
toStartOfMinute, toStartOfHour, toStartOfDay | 그 단위의 시작 |
toStartOfFiveMinutes, toStartOfFifteenMinutes | 고정 간격 |
toStartOfInterval(ts, INTERVAL 10 MINUTE) | 임의 간격 (가장 범용) |
toStartOfWeek(d, 1) | 월요일 시작 주 (두 번째 인자 1), toMonday도 같음 |
toStartOfMonth, toStartOfQuarter, toStartOfYear | 월, 분기, 연 |
toYYYYMM, toYYYYMMDD | 정수형 월, 일 (파티션 키에 흔함) |
date_trunc('hour', ts) | 표준 SQL 스타일 |
toLastDayOfMonth, toDayOfWeek, toHour | 분해 |
SELECT
toStartOfInterval(ts, INTERVAL 15 MINUTE) AS bucket,
count() AS c,
uniq(user_id) AS u
FROM events
WHERE ts >= now() - INTERVAL 6 HOUR
GROUP BY bucket
ORDER BY bucket;
날짜 연산과 변환
SELECT
ts + INTERVAL 3 DAY AS plus3,
addMonths(ts, 1) AS next_month,
dateDiff('day', toDate('2026-01-01'), toDate(ts)) AS days_since_ny,
age('month', toDate('2025-03-15'), toDate(ts)) AS full_months,
formatDateTime(ts, '%Y-%m-%d %H:%i') AS pretty,
parseDateTimeBestEffort('21 Oct 2026 09:00 +09:00') AS best_effort,
parseDateTime('2026/10/21', '%Y/%m/%d') AS strict_parse
FROM events LIMIT 1;dateDiff는 단위 경계를 넘은 횟수,age는 꽉 찬 단위 수입니다. 생일 계산에는age.parseDateTimeBestEffort는 다양한 모양을 받아 주지만 느립니다. 포맷이 정해져 있으면parseDateTime(MySQL 스타일)또는parseDateTimeInJodaSyntax.- 밀리초 유닉스 시각은
fromUnixTimestamp64Milli(ms), 반대는toUnixTimestamp64Milli(ts). - 26.9부터
DateTime에Time값을 더하고 빼는 연산이 가능해져 "영업 시작 시각 + 근무 길이" 같은 계산이 자연스러워졌습니다.
빈 구간 채우기: WITH FILL
시계열 그래프에서 이벤트가 없는 시각은 결과 행 자체가 없어 선이 끊깁니다. ORDER BY ... WITH FILL로 빈 버킷을 만들고 INTERPOLATE로 값을 정합니다.
SELECT
toStartOfHour(ts) AS h,
count() AS c
FROM events
WHERE ts >= toStartOfDay(now()) - INTERVAL 1 DAY
GROUP BY h
ORDER BY h WITH FILL
FROM toStartOfDay(now()) - INTERVAL 1 DAY
TO toStartOfHour(now()) + INTERVAL 1 HOUR
STEP INTERVAL 1 HOUR;- 채워진 행의 집계값은 기본값(0)입니다. 이전 값으로 이어 가려면
INTERPOLATE (c AS c), 다른 식도 가능합니다. - 여러 정렬 키가 있으면 각각에
WITH FILL을 붙일 수 있고, 그룹마다 채우려면WITH FILL ... STALENESS같은 옵션을 최근 버전이 제공합니다. - 결과가 수만 행을 넘는 WITH FILL은 메모리를 많이 쓰지 않지만, 그래프 도구 쪽에서 "빈 구간을 0으로" 옵션이 있다면 그쪽이 더 단순합니다.
윈도우 함수
OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN ...) 문법을 지원합니다. 집계 함수 대부분과 전용 함수(row_number, rank, dense_rank, ntile, lagInFrame, leadInFrame, first_value, last_value, nth_value, percent_rank)를 쓸 수 있습니다.
-- 7일 이동 평균과 전일 대비
WITH daily AS
(
SELECT toDate(ts) AS d, count() AS c
FROM events GROUP BY d
)
SELECT
d, c,
avg(c) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7,
c - lagInFrame(c, 1) OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS diff_prev,
sum(c) OVER (ORDER BY d) AS running_total
FROM daily
ORDER BY d;lagInFrame과leadInFrame은 프레임 안에서 동작합니다. 프레임을 적지 않으면 기본 프레임(RANGE UNBOUNDED PRECEDING)이라 lag 결과가 기대와 다릅니다. 위처럼ROWS BETWEEN 1 PRECEDING AND CURRENT ROW를 꼭 적습니다.- 같은 창을 여러 번 쓰면
WINDOW w AS (PARTITION BY ... ORDER BY ...)로 이름을 붙여 재사용합니다. RANGE BETWEEN INTERVAL 1 HOUR PRECEDING AND CURRENT ROW처럼 시간 범위 프레임도 됩니다.- 옛 함수
neighbor,runningDifference,runningAccumulate는 블록 단위로 동작해 결과가 불안정하므로 윈도우 함수로 바꿉니다.
세션화 예제
30분 이상 간격이 벌어지면 새 세션으로 보는 전형적인 패턴입니다.
WITH ordered AS
(
SELECT user_id, ts,
lagInFrame(ts, 1) OVER (PARTITION BY user_id ORDER BY ts
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_ts
FROM events
WHERE ts >= today() - 1
),
flagged AS
(
SELECT *, if(prev_ts = toDateTime64(0, 3) OR ts - prev_ts > 1800, 1, 0) AS new_session
FROM ordered
)
SELECT user_id, ts,
sum(new_session) OVER (PARTITION BY user_id ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_no
FROM flagged
ORDER BY user_id, ts;팁 사용자별 "첫 이벤트와 마지막 이벤트"는 윈도우 함수보다
min(ts),max(ts),argMin,argMax집계가 훨씬 가볍습니다. 행 수를 유지해야 할 때만 윈도우 함수를 씁니다. 집계 요령은 집계 함수와 컴비네이터에서 이어집니다.
윈도우 함수 성능
- 파티션 키가 정렬 키 접두어와 같으면 정렬 비용이 줄어듭니다.
optimize_read_in_order가 켜져 있는지 확인합니다. - 큰 파티션(한 사용자에 수백만 행)은 메모리에 통째로 올라갑니다. 기간을 먼저 자르는 WHERE가 필수입니다.
- 윈도우 함수 결과를 다시 GROUP BY 하는 쿼리는 서브쿼리나 CTE로 단계를 나눠 쓰면 읽기 쉽고 플랜도 단순해집니다. 쿼리 최적화
시계열 전용 기능 메모
TimeSeries테이블 엔진과 PromQL 쿼리는 Prometheus 데이터를 ClickHouse에 직접 저장하고 조회하는 기능으로, 2026년 현재 Exp 단계이며 ClickHouse Cloud에서는 PrPr로 제공됩니다. 관측성 용도라면 ClickStack과 chDB의 ClickStack이 먼저입니다.- 다운샘플링은
toStartOfInterval+ GROUP BY를 머티리얼라이즈드 뷰로 만들어 두는 방식이 표준입니다. 머티리얼라이즈드 뷰 - 보존 기간은
TTL event_date + INTERVAL 90 DAY로 자동 삭제합니다. TTL, 디스크 티어링