들어가며
Oracle의 DATE 타입은 사실 년-월-일 + 시:분:초까지 저장할 수 있지만, MyBatis는 기본적으로 LocalDateTime ↔ DATE 매핑을 지원하지 않습니다.
처음에는 DB 컬럼 타입을 VARCHAR로 바꿀까, 혹은 Java에서 String으로 관리하고 SQL에서 TO_DATE로 변환할까 생각했습니다.
하지만 이런 방식은 DB와 Java의 타입을 억지로 다르게 가져가는 것이고, 결국 일관성이 깨지고 유지보수성이 떨어질 수 있습니다.
반대로, DB의 컬럼 타입과 Java의 타입을 최대한 동일하게 가져가면 코드의 가독성과 예측 가능성이 높아집니다.
또한 변환 책임을 한 곳에 모으면 관심사 분리 원칙을 지킬 수 있어 관리도 편리해집니다.
그래서 이 글에서는 JPA의 @Converter와 유사한 개념인 MyBatis TypeHandler를 활용하여 문제를 해결하는 방법을 소개하겠습니다.
잘못된 접근과 그 한계
❌ 도메인에 SQL 타입 침투
public class Order {
private Long id;
private java.sql.Timestamp createdAt; // 비즈니스 도메인에 SQL 타입
}→ 도메인 모델에 JDBC 타입이 들어오면서 일관성 깨짐
❌ SQL에 포맷/변환 로직 삽입
<select id="find" resultType="Order">
SELECT ID, TO_CHAR(CREATED_AT, 'YYYY-MM-DD HH24:MI:SS') AS CREATED_AT
FROM ORDERS
</select>→ SQL에 변환이 섞여 재사용성과 가독성 저하
❌ 서비스 단에서 매번 파싱
String createdAtStr = rs.getString("CREATED_AT");
LocalDateTime createdAt = LocalDateTime.parse(createdAtStr, formatter);→ 반복되는 파싱 코드, 유지보수 시 에러 위험 증가
TypeHandler로 개선하기
위 문제를 해결하는 가장 깔끔한 방법은 TypeHandler를 도입하는 것입니다.
도메인은 LocalDateTime만 유지하고, 변환은 TypeHandler가 전담합니다.
@MappedJdbcTypes({JdbcType.DATE, JdbcType.TIMESTAMP}) // Oracle DATE, TIMESTAMP 모두 처리
@MappedTypes(LocalDateTime.class) // JAVA 타입
public class LocalDateTimeTypeHandler extends BaseTypeHandler<LocalDateTime> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
LocalDateTime parameter, JdbcType jdbcType) throws SQLException {
// Oracle DATE도 Timestamp로 바인딩해야 시분초 유지
ps.setTimestamp(i, Timestamp.valueOf(parameter));
}
@Override
public LocalDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException {
Timestamp ts = rs.getTimestamp(columnName);
return ts == null ? null : ts.toLocalDateTime();
}
// columnIndex, CallableStatement도 동일하게 처리
}이 방식의 이점
- 도메인 모델은 오직 LocalDateTime만 다룬다
→ JDBC 타입(Timestamp)이 침투하지 않아 도메인이 깔끔해진다. - 변환 로직은 Handler 한 곳에만 집중된다
→ SQL, 서비스, 매퍼 곳곳에 흩어져 있던 포맷팅/파싱 코드가 사라진다. - 일관성과 관심사 분리를 모두 만족한다
→ DB 타입과 Java 타입을 동일하게 가져가면서, 변환 책임을 전담 클래스에 모아둘 수 있다.
응용 사례 (실무 확장 포인트)
TypeHandler는 날짜 변환 외에도 여러 상황에 응용할 수 있습니다.
- 포맷팅
→ DB에는 yyyyMMddHHmmss 문자열로 저장
→ Java에서는 LocalDateTime - 암호화/복호화
→ DB에는 암호문 저장
→ Java에서는 평문 사용 - Enum ↔ 코드 값
→ DB에는 "NEW", "PAID", "CANCELLED" 코드 저장
→ Java에서는 OrderStatus.NEW/PAID/CANCELLED - JSON ↔ DTO 직렬화
→ DB에는 JSON 문자열 저장
→ Java에서는 DTO/VO 객체 그대로 활용
마무리
변환 및 저장 로직이 여기저기 흩어져 있으면, 프로젝트 초반에는 문제를 잘 느끼지 못할 수 있습니다.
하지만 시간이 지나 담당자가 퇴사하거나 초기 히스토리가 사라지면, 이후 개발자는 로직을 파악하는 데 불필요한 시간을 쓰게 되고, 수정 과정에서 불안감까지 커집니다. 저 역시 실무에서 이런 상황을 여러 번 경험하면서 “저장 및 변환 로직은 반드시 한 곳에서 담당해야 한다”는 원칙을 갖게 되었습니다.
실무에서 JPA만 사용하다가 MyBatis로 프로젝트를 진행하면서 느낀 점은, 두 기술이 철학과 구현 방식은 다르지만 근본적인 기능과 보조 기능들은 결국 비슷한 개념으로 존재한다는 것이었습니다. JPA의 @Converter와 MyBatis의 TypeHandler가 대표적인 예입니다. 즉, 기술 스택이 달라도 변환 책임을 한 곳에 모아 관리하는 철학은 동일하게 적용된다는 사실을 다시 한 번 확인할 수 있었습니다.
'DB' 카테고리의 다른 글
| CaseBuilder 대신 BooleanBuilder를 써야 했던 이유(Feat: querydsl) (2) | 2025.07.27 |
|---|---|
| Querydsl 다중 where 조건 만들기 (1) | 2025.04.23 |
| EXISTS 연산자에 대해서(Feat: querydsl) (1) | 2025.04.13 |
| [JPA] 엔티티 값을 변환하여 저장, 조회하기(Feat: @Converter) (0) | 2025.03.10 |
| ORA-01502: 인덱스 인덱스 이름 또는 인덱스 분할영역은 사용할 수 없는 상태입니다. (0) | 2025.02.18 |