MySQL TIME × 언어 타입 폭 불일치
운영시간 목록 API가 통째로 500이고, 프론트는 그걸 “선택 가능한 날짜가 없는 정상 달력”으로 그린다. 원인은 operation_hours의 to = 24:00:00 행 하나였다.
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년 | ✓ |
읽는 쪽 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으로 승격 |
손실 없음 | 하위 계산 로직 전부 수정 |
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보다 싸다.