문자열 비교의 함정 시리즈 · 1편

월 합계가 일 합계보다 작다. 쿼리는 멀쩡해 보이는데, 말일 하루치가 통째로 사라진다. 원인은 BETWEEN 한 줄에 숨어 있는 자릿수 불일치다. 이 글은 가상의 예제로 문제를 재현하고, 고치는 방법과 그 방법을 고르는 기준, 재발을 막는 검증 방법까지 정리한다.

예제의 테이블과 데이터는 모두 설명을 위해 만든 가상의 것이다. 아래 실행 결과는 MariaDB 10.11에서 직접 실행한 값이다.


요약

  • 증상: 10분 단위 테이블에서 구한 월 합계(300)가 일 단위 요약의 합계(600)보다 작다
  • 원인: yyyyMMddHHmmss 14자리 문자열 컬럼을 yyyyMMdd 8자리 값으로 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/30 00: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

+ Recent posts