날짜 범위 조회에서 하루가 새는 이유
BETWEEN의 경계 포함 규칙, 23:59:59 누락, 함수로 컬럼을 감싸 인덱스가 깨지는 문제, 타임존 경계까지 날짜 범위 조회의 함정을 정리한다.
기간별로 데이터를 뽑는 화면을 만들면 거의 매번 “마지막 날 데이터가 빠졌다”는 제보가 따라온다. 날짜 범위 조회는 단순해 보이지만 경계·시각·타임존·인덱스가 한꺼번에 얽히는 지뢰밭이다. 왜 하루가 새는지 정확히 짚는다.
BETWEEN은 양 끝을 포함한다 — 그런데 시각이 문제다
BETWEEN a AND b는 a <= x AND x <= b로 양 끝을 포함한다. 문제는 컬럼이 DATETIME/TIMESTAMP일 때다.
1
2
3
-- 9월 1일 ~ 9월 30일을 원했지만
SELECT * FROM orders
WHERE created_at BETWEEN '2025-09-01' AND '2025-09-30';
'2025-09-30'은 '2025-09-30 00:00:00'로 해석된다. 따라서 9월 30일 0시 1초부터 자정 직전까지의 주문이 전부 누락된다. 하루치가 통째로 사라지는 전형이다.
해결의 정석은 반열림 구간(half-open)이다. 끝 경계를 “다음 날 0시 미만”으로 잡는다.
1
2
3
SELECT * FROM orders
WHERE created_at >= '2025-09-01'
AND created_at < '2025-10-01'; -- 다음 날 0시 "미만"
이러면 9월 30일 23:59:59.999까지 모두 포함되고, 23:59:59로 끝을 박아 0.x초를 놓치는 실수도 없다. 시각 정밀도(밀리초·마이크로초)가 있는 컬럼에서 <= '...23:59:59'는 항상 끝자락을 흘린다.
컬럼을 함수로 감싸면 인덱스가 죽는다
다음은 의도는 맞지만 성능이 무너지는 안티패턴이다.
1
2
3
-- created_at에 인덱스가 있어도 못 쓴다
SELECT * FROM orders
WHERE DATE(created_at) = '2025-09-30';
B-Tree 인덱스는 created_at 원본 값으로 정렬돼 있다. DATE(created_at)처럼 컬럼을 함수로 감싸면 인덱스에 저장된 값과 비교 대상이 달라져 옵티마이저가 인덱스 범위 스캔을 못 하고 풀스캔으로 떨어진다(sargable하지 않다). 함수는 컬럼이 아니라 상수 쪽에만 적용해, 컬럼은 날것 그대로 비교 대상에 남겨야 한다. 위 반열림 구간이 바로 그 형태다.
타임존: 어디 기준의 하루인가
created_at이 UTC로 저장돼 있는데 사용자는 KST(+09:00) 기준의 “9월 30일”을 기대한다면, KST 9/30은 UTC로 9/29 15:00 ~ 9/30 15:00이다. 경계 문자열을 UTC로 변환해 넣어야 의도한 하루가 맞는다. 저장은 UTC로 통일하고, 경계 계산은 애플리케이션에서 사용자 타임존으로 한 뒤 UTC로 환산해 쿼리에 넘기는 방식이 가장 덜 헷갈린다.
운영 함정
- 로컬 타임존 의존 함수:
CURDATE(),NOW()는 DB 세션 타임존을 따른다. 서버 타임존이 바뀌거나 인스턴스마다 다르면 같은 쿼리가 다른 하루를 가리킨다. 세션 타임존을 고정하거나 애플리케이션에서 절대 시각을 넘긴다. - 서머타임(DST) 경계: DST가 있는 지역은 하루가 23시간 또는 25시간이 되는 날이 있다. “다음 날 0시”를 단순히 +24h로 계산하면 그날 경계가 틀어진다. 날짜 산술은 캘린더 단위(+1 day)로 한다.
핵심 요약
- 끝 경계는
<= 마지막날이 아니라< 다음날 0시(반열림 구간)로 잡아 시각 누락을 막는다. - 컬럼을 함수로 감싸지 말고 상수 쪽을 가공해야 인덱스가 산다.
- 저장은 UTC, 경계 계산은 사용자 타임존 — 하루의 정의를 명확히 한다.