MySQL TIME × 언어 타입 폭 불일치
언제 — MySQL TIME 컬럼을 언어 타입으로 역직렬화하다 예외가 났거나, 같은 컬럼을 두 스택이 읽고 있을 때. 장애 중이면 R1 부터, 예방이면 R8 부터.
레시피 단위로 독립적으로 쓸 수 있게 정리. 각 레시피는 언제 → 한다 → 함정 순.
워크드 예제: operation_hours에 to = 24:00:00 행 하나가 있어 운영시간 목록 API가 통째로 500. 프론트는 그 실패를 “선택 가능한 날짜가 하나도 없는 정상 달력”으로 렌더.
R1. 목록 API가 통째로 500인데 원인 행을 못 찾을 때
언제 — 단건은 되는데 목록만 실패. 서비스 로직이 아니라 역직렬화 단계에서 터진 정황.
한다
스택트레이스 최하단을 본다. DateTimeException / IllegalArgumentException이 ValueFactory, 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`; -- 익일 걸침 (별개 문제)
86400은 24: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보다 싼 수정이다.