문자열 비교의 함정 시리즈 · 1편
월 합계가 일 합계보다 작다. 쿼리는 멀쩡해 보이는데, 말일 하루치가 통째로 사라진다. 원인은
BETWEEN한 줄에 숨어 있는 자릿수 불일치다. 이 글은 가상의 예제로 문제를 재현하고, 고치는 방법과 그 방법을 고르는 기준, 재발을 막는 검증 방법까지 정리한다.
예제의 테이블과 데이터는 모두 설명을 위해 만든 가상의 것이다. 아래 실행 결과는 MariaDB 10.11에서 직접 실행한 값이다.
요약
- 증상: 10분 단위 테이블에서 구한 월 합계(300)가 일 단위 요약의 합계(600)보다 작다
- 원인:
yyyyMMddHHmmss14자리 문자열 컬럼을yyyyMMdd8자리 값으로BETWEEN비교하면, 앞 8자리가 같을 때 더 긴 문자열이 더 커서 말일 전체가 범위 밖이 된다 - 해결: 상한을 "말일"이 아니라 "다음 달 1일(미포함)"로 두는 반열린 구간(
>= from AND < to)을 쓴다 - 검증: 서로 다른 계층(10분 → 일 → 월)의 합계를 대조하는 쿼리를 두면 같은 종류의 누락을 바로 잡을 수 있다
1. 상황 설정
이벤트를 10분 단위로 집계한 테이블과, 이를 일 단위로 요약한 테이블이 있다고 하자. 날짜는 문자열 컬럼에 yyyyMMddHHmmss 형식으로 저장한다.
CREATE TABLE event_10min (
slot CHAR(14) NOT NULL COMMENT 'yyyyMMddHHmmss',
cnt INT NOT NULL,
PRIMARY KEY (slot)
);
-- 4월 29일 마지막 슬롯, 4월 30일 슬롯 3개, 5월 1일 첫 슬롯 등 (건수는 모두 100)
INSERT INTO event_10min VALUES
('20260401000000', 100), ('20260415120000', 100), ('20260429235000', 100),
('20260430000000', 100), ('20260430120000', 100), ('20260430235000', 100),
('20260501000000', 100);
-- 일 단위 요약 (day 는 8자리)
CREATE TABLE event_daily (day CHAR(8) NOT NULL, cnt INT NOT NULL, PRIMARY KEY (day));
INSERT INTO event_daily
SELECT LEFT(slot, 8), SUM(cnt) FROM event_10min GROUP BY LEFT(slot, 8);
4월 한 달의 합계를 구하는 쿼리를 이렇게 썼다.
2. 증상: 월 합계가 일 합계보다 작다
-- 10분 단위 테이블에서 4월 합계
SELECT SUM(cnt) AS april_total FROM event_10min
WHERE slot BETWEEN '20260401' AND '20260430';
+-------------+
| april_total |
+-------------+
| 300 |
+-------------+
-- 일 단위 요약에서 4월 합계
SELECT SUM(cnt) AS daily_sum FROM event_daily
WHERE day BETWEEN '20260401' AND '20260430';
+-----------+
| daily_sum |
+-----------+
| 600 |
+-----------+
같은 4월인데 300과 600이다. 두 숫자 가운데 어느 쪽이 맞는지는, 일 단위 요약의 일별 값을 직접 합쳐 보면 확인할 수 있다(4월에 100건짜리 슬롯이 6개 있으니 600이 맞다).
여기서 중요한 단서가 하나 있다.
일 단위 요약은 맞고, 10분 단위 테이블에서 구한 값만 틀리다. 그리고 모자란 양(300)은 정확히 하루치(4/30의 슬롯 3개)다.
3. 원인: 자릿수가 다른 문자열의 비교
문자열 비교는 앞에서부터 한 글자씩 본다. 앞부분이 같으면 더 긴 문자열이 더 크다.
SELECT '20260430000000' <= '20260430' AS a,
'20260429235000' <= '20260430' AS b;
+---+---+
| a | b |
+---+---+
| 0 | 1 |
+---+---+
'20260429235000' <= '20260430'→ 4/29 행은 상한보다 작으니 포함'20260430000000' <= '20260430'→ 4/3000:00:00조차 앞 8자리가 같고 더 길어서 상한보다 크다 → 범위 밖
BETWEEN이 "상한을 포함한다"는 사실은 두 값의 형식이 같을 때만 의미가 있다. 상한이 8자리 '20260430'이면 4/30의 14자리 값은 하나도 들어오지 못한다.

그림 1. 상한값이 8자리라서 4/30의 14자리 값이 전부 범위 밖으로 밀려난다.
어느 날이 빠졌는지는 다음 쿼리로 바로 찾을 수 있다.
SELECT LEFT(slot, 8) AS day, SUM(cnt) AS cnt
FROM event_10min
WHERE LEFT(slot, 8) BETWEEN '20260401' AND '20260430' -- 날짜만 잘라서 비교
AND NOT (slot BETWEEN '20260401' AND '20260430') -- 원래 조건에서는 빠지는 행
GROUP BY LEFT(slot, 8);
+----------+------+
| day | cnt |
+----------+------+
| 20260430 | 300 |
+----------+------+
4. 해결: 같은 자릿수의 반열린 구간
상한을 "말일"이 아니라 "다음 달 1일의 시작(미포함)"으로 바꾼다.
SELECT SUM(cnt) AS april_total FROM event_10min
WHERE slot >= '20260401000000' AND slot < '20260501000000';
+-------------+
| april_total |
+-------------+
| 600 |
+-------------+
참고로 8자리 값으로 반열린 구간을 써도 접두어 비교 덕분에 600이 나온다(slot >= '20260401' AND slot < '20260501'). 하지만 형식을 맞춰 쓰는 편이 읽는 사람도, 나중에 형식이 바뀔 때도 안전하다.
애플리케이션에서는 경계를 이렇게 계산한다.
static final DateTimeFormatter DAY = DateTimeFormatter.BASIC_ISO_DATE; // yyyyMMdd
record Range(String from, String toExclusive) {}
static Range monthRange(YearMonth month) {
String from = month.atDay(1).format(DAY) + "000000"; // 20260401000000
String to = month.plusMonths(1).atDay(1).format(DAY) + "000000"; // 20260501000000
return new Range(from, to);
}
경계가 까다로운 연말과 윤년 2월도 같은 코드로 처리된다.
2026-04 → from=20260401000000, toExclusive=20260501000000
2026-12 → from=20261201000000, toExclusive=20270101000000
2028-02 → from=20280201000000, toExclusive=20280301000000
왜 하필 반열린 구간인가
- 포함/미포함이 명확하다. "말일 23:59:59를 포함할까, 초 이하는?" 같은 고민이 사라진다.
- 구간이 겹치지도 비지도 않는다. 4월의 상한(미포함)이 5월의 하한(포함)과 정확히 같아서, 월을 이어 붙여도 누락·중복이 생기지 않는다.
- 상한 계산이 쉽다. 말일 대신
plusMonths(1).atDay(1)만 쓰면 되니 28/29/30/31일을 따질 필요가 없다.
5. 다른 해결 선택지와 비교
| 방법 | 장점 | 단점 / 주의 |
|---|---|---|
| 같은 자릿수 + 반열린 구간 (권장) | 스키마 변경 없이 바로 적용, 인덱스 활용 가능 | 문자열 형식에 계속 의존한다 |
LEFT(slot, 8) BETWEEN ... |
쿼리가 직관적이다 | 컬럼에 함수를 씌우면 일반적으로 인덱스를 쓰지 못한다 |
BETWEEN '...000000' AND '...235959' |
형식은 맞는다 | 초 이하(밀리초) 정밀도로 바뀌면 다시 누락되고, 상한이 하드코딩된다 |
컬럼 타입을 DATETIME으로 변경 |
날짜 비교가 타입 수준에서 안전하다 | 데이터 이관과 모든 호출부의 변경이 필요하다 |
운영 중인 시스템에서 당장 고쳐야 한다면 첫 번째 방법이 변경 범위가 가장 작다. 새로 설계한다면 처음부터 날짜 타입을 쓰는 게 가장 좋다.
6. 재발을 막는 검증
이런 버그는 쿼리가 에러 없이 정상 동작해서 눈에 띄지 않는다. 사람이 이상하다고 말하기 전에 잡으려면 서로 다른 계층의 합계를 대조하는 쿼리가 있어야 한다.
-- 일 단위 요약 vs 10분 단위 원본 (월 기준)
SELECT d.day_sum, m.min_sum, d.day_sum - m.min_sum AS diff
FROM (SELECT SUM(cnt) AS day_sum FROM event_daily
WHERE day >= '20260401' AND day < '20260501') d,
(SELECT SUM(cnt) AS min_sum FROM event_10min
WHERE slot >= '20260401000000' AND slot < '20260501000000') m;
-- diff 가 0 이 아니면 어느 계층에서 새고 있다는 신호
경계 계산 함수는 단위 테스트로 고정해 둔다. 말일, 연말, 윤년이 테스트 케이스의 핵심이다.
@Test
void monthRange_handlesYearEndAndLeapYear() {
assertEquals(new Range("20261201000000", "20270101000000"), monthRange(YearMonth.of(2026, 12)));
assertEquals(new Range("20280201000000", "20280301000000"), monthRange(YearMonth.of(2028, 2)));
}
마무리
BETWEEN은 읽기 쉬워서 안심하게 되지만, 비교되는 두 값의 형식이 다르면 "범위"라는 직관이 깨진다. 문자열은 비교되는 순간에 의미가 정해진다. 다음 편에서는 같은 맥락으로, 대소문자를 구분하지 않는 DB와 구분하는 Java 사이에서 숫자가 사라지는 문제를 다룬다.
'Backend > DB | SQL' 카테고리의 다른 글
| 문자열 비교의 함정 시리즈 2편 (0) | 2026.10.20 |
|---|
