---
type: note
kind: Recipe
title: MySQL TIME × 언어 타입 폭 불일치
description: TIME 컬럼을 읽는 쪽 타입이 컬럼 도메인보다 좁아서 생기는 장애의 진단·정규화·방어 레시피.
tags: ['mysql', 'java', 'mybatis', 'typeorm', 'type-design', 'error-handling']
status: release
ctime: 2026-08-31
mtime: 2026-09-10
generated: { by: human:cbcruk, at: 2026-08-31T00:00:00Z }
---

**언제** — 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. 컬럼 도메인 밖 값 감사

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

**한다**

```sql
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`)를 이 한 곳에 가둔다.

```java
@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+:

```sql
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보다 싼 수정이다.
