MySQL TIME × 언어 타입 폭 불일치

언제 — MySQL TIME 컬럼을 언어 타입으로 역직렬화하다 예외가 났거나, 같은 컬럼을 두 스택이 읽고 있을 때. 장애 중이면 R1 부터, 예방이면 R8 부터.

레시피 단위로 독립적으로 쓸 수 있게 정리. 각 레시피는 언제 → 한다 → 함정 순.

워크드 예제: operation_hoursto = 24:00:00 행 하나가 있어 운영시간 목록 API가 통째로 500. 프론트는 그 실패를 “선택 가능한 날짜가 하나도 없는 정상 달력”으로 렌더.


R1. 목록 API가 통째로 500인데 원인 행을 못 찾을 때

언제 — 단건은 되는데 목록만 실패. 서비스 로직이 아니라 역직렬화 단계에서 터진 정황.

한다

스택트레이스 최하단을 본다. DateTimeException / IllegalArgumentExceptionValueFactory, TypeHandler, ResultSetImpl 프레임에서 나오면 매핑 실패다.

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

메시지의 숫자(24)와 필드명(HourOfDay)이 곧 원인 행의 값이다. 이걸로 R2의 감사 쿼리 조건을 만든다.

함정 — 앱 로그에 500 Internal Server Error만 남고 원인 예외가 삼켜지는 경우가 흔하다. 글로벌 예외 핸들러가 cause 체인을 안 찍으면 여기서 막힌다. 그때는 R2를 먼저 돌려서 역으로 후보를 좁힌다.


R2. 컬럼 도메인 밖 값 감사

언제 — 특정 행이 의심되지만 그 행만의 예외인지, 이미 퍼진 패턴인지 모를 때. 핫픽스보다 먼저 돌린다.

한다

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`;                 -- 익일 걸침 (별개 문제)

8640024:00:00의 초 환산. TIME_TO_SEC을 쓰는 이유는 문자열 비교로 '24:00:00' > '23:59:59'를 판정하면 '9:00:00' 같은 비제로패딩 값에서 틀리기 때문.

함정from > to 행이 이미 있으면 그건 “야간 영업 걸침”이라는 다른 도메인 요구다. 같은 핫픽스로 뭉개면 안 된다. 건수를 따로 세서 별건으로 분리한다.


R3. MySQL TIME의 실제 정의역 확인

언제 — 읽는 쪽 타입을 고르기 전. 그리고 “이건 더러운 데이터다”라고 결론 내리기 전.

한다

MySQL TIME-838:59:59 ~ 838:59:59. 시각(time-of-day)이 아니라 경과 시간(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년

Connector/J 8은 rs.getObject(col, Duration.class)를 지원한다. MySQL TIME의 정타입 매핑은 LocalTime이 아니라 Duration이다.

함정zeroDateTimeBehavior 같은 JDBC 관용 옵션은 DATE/DATETIME 전용이라 TIME 범위 초과엔 안 걸린다. 커넥션 옵션으로 우회하려다 시간 날린다.


R4. 24:00:00을 무엇으로 정규화할지 결정

언제 — 데이터 핫픽스 직전. 그냥 UPDATE 치기 전에 슬롯 계산 로직을 먼저 읽어야 한다.

한다

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

00:00:00은 예외가 안 나기 때문에 지금보다 나쁘다. 터지는 버그가 침묵하는 버그로 바뀌는 것.

함정 — 두 서비스의 슬롯 계산을 양쪽 다 확인해야 한다. 한쪽만 보고 정규화하면 영업시간 표시가 어긋난다.


R5. TypeHandler로 역직렬화 방어

언제 — 재발을 막아야 하는데 Duration 전면 교체는 비용이 클 때. 재발 방지의 핵심 레시피.

한다

rs.getString()으로 받아서 직접 파싱하는 핸들러를 만들고 @MappedTypes(LocalTime.class)로 등록한다. 손실 정규화(24:00:00 → 23:59:59)를 이 한 곳에 가둔다.

@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());
  }
  // ...
}

함정

  • 정규화 지점이 여기 하나여야 의미가 있다. 서비스 계층에도 clamp 로직이 흩어지면 어디서 잘렸는지 추적 불가.
  • clamp가 일어날 때 WARN 로그를 남긴다. 안 그러면 이상 데이터가 조용히 계속 들어와도 아무도 모른다.
  • 음수 TIME(-01:00:00)도 같이 처리해야 한다. R2에서 걸린 게 없어도 컬럼이 허용하는 값이다.

R6. 쓰기 경로 막기

언제 — R5로 읽기가 안전해진 뒤. 순서 주의 — 쓰기부터 막으면 이미 들어있는 행 때문에 여전히 500이다.

한다

MySQL 8.0.16+:

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

버전이 낮으면 TypeORM validator(@Max 대신 커스텀 validator, time 컬럼은 string이라 범위 데코레이터가 안 먹는다).

함정 — 애플리케이션 validator는 배치·어드민 직접 UPDATE·수동 SQL 경로로 전부 뚫린다. DB 제약이 가능하면 DB에서 막는다. CHECK 추가 전에 R2를 다시 돌려야 한다 — 위반 행이 남아 있으면 DDL이 실패한다.


R7. 결핍 상태를 분리해서 렌더

언제 — 백엔드가 500인데 화면이 “정상적으로 비어 보일” 때. 백엔드 수정과 별개 티켓으로 잡는다.

한다

지금 구조는 세 상태가 전부 null로 수렴한다.

useQuery 에러 처리 없음 → hours = undefined
→ getHoursForDate(undefined, ...) → 항상 null
→ 전 날짜 선택 불가 → 달력 통째로 비활성
→ 슬롯 섹션은 선택된 날짜가 없으면 null → 안내 문구조차 없음

isPending / isError / data.length === 0을 각각 다른 렌더로 분기한다. 최소한 실패는 재시도 UI가 나와야 한다.

함정 — “빈 배열”과 “못 가져옴”을 같은 empty state로 묶는 게 가장 흔한 실수다. 전자는 정상, 후자는 장애다. 사용자에게 주는 행동 지시가 다르다(“휴무입니다” vs “다시 시도”).


R8. 다국어 스택 타입 폭 감사

언제 — 같은 DB를 두 개 이상의 언어/ORM이 읽을 때. 장애 나기 전에 한 번.

한다

공유 테이블의 컬럼마다 각 스택의 매핑 타입을 적고 정의역 폭을 비교한다. 넓은 쪽이 쓸 수 있는 값이 좁은 쪽에서 예외가 되는 조합을 찾는다.

컬럼:   TIME              (-838:59:59 ~ 838:59:59)
쓰는 쪽: string            (제약 없음)          ← 넓음
읽는 쪽: LocalTime         (00:00 ~ 23:59:59)   ← 좁음

이 방향(넓은 쓰기 / 좁은 읽기)이면 시한폭탄이다. 반대 방향이면 안전하다.

같은 패턴이 나오는 다른 조합:

컬럼 타입 넓은 쪽 좁은 쪽
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이라고 써 있기 때문. DB 컬럼 도메인을 기준선으로 놓고 각 언어 타입을 그 위에 겹쳐야 폭 차이가 보인다.


장기: 스키마로 문제를 없애기

TIME 대신 자정 기준 분 단위 SMALLINT(0~1440):

  • 24:00 = 1440이 자연스럽게 표현됨
  • 양 언어 모두 number / int로 읽어서 타입 폭이 동일
  • 범위 제약이 CHECK (col BETWEEN 0 AND 1440)로 단순
  • 익일 걸침은 > 1440으로 명시 표현 가능

마이그레이션 비용이 크므로 별건 티켓. R1~R7이 끝난 뒤 검토.


실행 순서

R2 (감사)  →  R4 (정규화 결정)  →  데이터 핫픽스

R5 (TypeHandler)  →  R6 (쓰기 제약)        [백엔드 티켓]
R7 (프론트 에러 표면화)                     [지금 바로 가능]
R8 (타입 폭 감사)  →  스키마 재설계 검토    [예방]

R7만 프론트에서 독립적으로 진행 가능. 나머지는 백엔드에 넘겨야 한다.

미해결

  • 24:00:00이 어느 경로로 들어왔나. 어드민 UI면 R6의 validator로 충분하고, 배치면 DB 제약이 필수다.
  • 휴무 행의 from/to를 애초에 읽어야 하는가. 안 읽어도 되면 매핑 범위 조정이 R5보다 싼 수정이다.