MySQL TIME × 언어 타입 폭 불일치

운영시간 목록 API가 통째로 500이고, 프론트는 그걸 “선택 가능한 날짜가 없는 정상 달력”으로 그린다. 원인은 operation_hours의 to = 24:00:00 행 하나였다.

1 원인 행 찾기 → 2 값 감사 → 4 정규화 결정 → 데이터 핫픽스 → 5 TypeHandler → 6 쓰기 제약 순으로 백엔드를 고친다. 4 를 고르기 전에 3 정의역 확인을 본다. 5 → 6 은 읽기가 먼저다 — 쓰기부터 막으면 이미 들어 있는 행 때문에 여전히 500 이다. 7 결핍 상태 분리 렌더는 프론트에서 백엔드와 별개로 지금 바로 할 수 있다. 8 타입 폭 감사 → 스키마 재설계 검토는 예방이다.

1. 스택트레이스 최하단

DateTimeException: Invalid value for HourOfDay (valid values 0 - 23): 24
  at java.time.LocalTime.of
  at com.mysql.cj.result.LocalTimeValueFactory...

ValueFactory·TypeHandler·ResultSetImpl 프레임의 DateTimeException이면 매핑 실패다. 24·HourOfDay가 원인 행의 값 → 2의 조건. 로그에 cause 체인이 없으면 2부터 돌려 역으로 좁힌다.

2. 도메인 밖 값 감사 — 핫픽스 전에

SELECT owner_id, slot_type, `from`, `to`
FROM operation_hours
WHERE TIME_TO_SEC(`from`) >= 86400   -- 24시 이상
   OR TIME_TO_SEC(`to`)   >= 86400
   OR TIME_TO_SEC(`from`) <  0       -- MySQL TIME은 음수 허용
   OR `from` > `to`;                 -- 익일 걸침 (별개 문제)

문자열 비교는 '9:00:00' 같은 비제로패딩에서 틀리니 TIME_TO_SEC. from > to(야간 걸침)는 다른 요구라 따로 세서 별건으로 뺀다.

3. 정의역 대조 — 데이터가 더러운가, 타입이 좁은가

MySQL TIME은 -838:59:59 ~ 838:59:59인 duration 타입이라 24:00:00은 합법값이다.

언어/타입 정의역 TIME 전 범위
Java LocalTime 00:00 ~ 23:59:59.999999999 ✗
Java Duration 사실상 무제한 ✓
TS string 제약 없음 ✓ (검증 없음)
Python datetime.time 00:00 ~ 23:59:59.999999 ✗
Go time.Duration ±292년 ✓

기준선은 MySQL TIME 컬럼의 정의역 -838:59:59 ~ 838:59:59 다. 쓰는 쪽 string·Duration 은 이 범위를 전부 덮는다. 읽는 쪽 LocalTime·datetime.time 은 00:00 ~ 24:00 미만만 덮어서, 그 바깥 구간은 컬럼에 들어갈 수 있는데 읽으면 예외가 난다. 24:00:00 과 -01:00:00 이 그 구간에 있다 — 컬럼 안, LocalTime 밖. 넓은 쓰기 / 좁은 읽기면 시한폭탄이고 반대 방향이면 안전하다. 눈금은 비례가 아니다.

읽는 쪽 LocalTime이 좁은 것이다. 정타입 매핑은 Duration(Connector/J 8 rs.getObject(col, Duration.class)). zeroDateTimeBehavior는 DATE/DATETIME 전용이라 우회가 안 된다.

4. 정규화 후보를 슬롯 계산에 넣어 본다

UPDATE 전에 두 서비스의 슬롯 계산을 읽고 후보를 넣어 본다.

후보 결과 확인할 것
23:59:59 1초 손실. 무해한 편 슬롯 생성이 to를 exclusive로 쓰면 23:30 슬롯이 사라짐
00:00:00 from > to가 되어 범위 비교 전부 뒤집힘 익일 처리 분기 없으면 조용히 영업시간 0분
Duration으로 승격 손실 없음 하위 계산 로직 전부 수정

지금: to = 24:00:00 이라 영업 구간이 from 부터 24:00 까지이고 마지막 슬롯 23:30~24:00 이 있지만 LocalTime 이 못 읽는다. 23:59:59 로 바꾸면 1초가 줄어, 슬롯 생성이 to 를 exclusive 로 쓰면 23:30 슬롯이 들어가지 못한다. 00:00:00 으로 바꾸면 to 가 from 보다 앞이 되어 범위 비교가 전부 뒤집히고, 익일 분기가 없으면 예외 없이 영업시간 0분이 된다. Duration 으로 승격하면 손실 없이 24:00 까지 그대로지만 하위 계산 로직을 전부 고쳐야 한다. 눈금은 비례가 아니다.

00:00:00은 예외가 안 나서 지금보다 나쁘다 — 터지는 버그가 침묵하는 버그가 된다.

5. 읽기 방어 — TypeHandler

@MappedTypes(LocalTime.class)
public class SafeLocalTimeTypeHandler extends BaseTypeHandler<LocalTime> {
  @Override
  public LocalTime getNullableResult(ResultSet rs, String col) throws SQLException {
    String raw = rs.getString(col);
    if (raw == null) return null;
    Duration d = parseMysqlTime(raw);
    return d.compareTo(Duration.ofDays(1)) >= 0
        ? LocalTime.MAX.truncatedTo(ChronoUnit.SECONDS)  // 23:59:59
        : LocalTime.ofSecondOfDay(d.getSeconds());
  }
  // ...
}

6. 쓰기 제약 — 읽기 다음에

쓰기부터 막으면 이미 들어 있는 행 때문에 여전히 500이다. 2를 다시 돌려 0건인지 보고 건다.

ALTER TABLE operation_hours
  ADD CONSTRAINT chk_operation_hours_range
  CHECK (TIME_TO_SEC(`to`) <= 86340 AND TIME_TO_SEC(`from`) >= 0);

MySQL 8.0.16+. DDL이 성공하면 위반 행이 없다는 확인이기도 하다. 낮은 버전의 TypeORM validator는 배치·수동 SQL로 뚫린다.

7. 프론트 — 별개 티켓

useQuery 에러 → hours = undefined → 모든 날짜 null → 달력 통째로 비활성. isPending / isError / 빈 배열을 따로 그리고 실패엔 재시도 UI. 빈 배열은 “휴무”, 못 가져옴은 “다시 시도”다.

8. 예방 — 같은 폭탄 찾기

공유 테이블의 컬럼마다 스택별 매핑 타입을 DB 도메인 위에 겹친다(3의 그림). 스키마 파일끼리 비교하면 양쪽 다 time이라 안 잡힌다. 넓은 쓰기 / 좁은 읽기가 시한폭탄이다.

컬럼 타입 넓은 쪽 좁은 쪽
TIME string, Duration LocalTime, datetime.time
DECIMAL(20,0) BigDecimal, string JS number (2^53)
BIGINT int64 JS number
VARCHAR (utf8mb4) string 이모지 미대응 정규식 검증
DATE (0000-00-00) string LocalDate

장기 · 미해결

  • TIME 대신 자정 기준 분 SMALLINT(0~1440): 24:00 = 1440, 양 언어 모두 정수라 폭이 같고, CHECK (col BETWEEN 0 AND 1440). 별건 티켓.
  • 24:00:00이 어디서 들어왔나 — 어드민 UI면 validator로 충분, 배치면 DB 제약 필수.
  • 휴무 행의 from/to를 애초에 읽어야 하나 — 안 읽어도 되면 5보다 싸다.