서론
Java Enum 타입과 RDB의 enum 데이터 타입
여러 프로젝트에서 `Y`, `N` 같이 특정 값들을 가지면서 해당 그룹이 잘 변하지 않으면 Enum 타입으로 설계하곤 한다.
RDB에 이런 Enum 성격을 갖는 값들을 저장할 땐 데이터 타입을 `char`, `varchar`, `enum`을 사용한다. (MySQL, MariaDB, PostgreSQL 등)
Mybatis EnumTypeHandler
MyBatis에선 Java의 Enum 타입을 멤버 변수 이름 그대로 매핑해 주는 `EnumTypeHandler`를 기본 TypeHandler로 사용하는데, 모종의 이유(레거시 프로젝트 등)로 DB에는 소문자나, 다른 값으로 저장해야 할 때가 있다.
이때 Java Enum 타입의 멤버 변수는 상수이기 때문에 공식 문서 (link)에 따라 대문자로 작성하는데, RDB에 저장할 땐 소문자로 저장하는 컨벤션이 있다면 RDB로부터 읽거나 쓸 때 멤버 변수와 매핑해 줄 커스텀 TypeHandler를 작성하고, `mybatis-config.xml`에 등록해야 한다.
만약, Enum 타입의 멤버 변수 이름과 RDB 레코드 값이 불일치하는 경우가 빈번하다면, 일일이 커스텀 TypeHandler를 작성해야 하는 수고로움은 물론 보일러 플레이트 코드를 담는 파일이 계속해서 추가되고, 관리 포인트가 증가한다.
// UserStatus Enum - DB에는 소문자로 저장되야 함
public enum UserStatus {
ACTIVE,
INACTIVE,
SUSPENDED
}
// UserStatus용 TypeHandler
public class UserStatusTypeHandler extends BaseTypeHandler<UserStatus> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, UserStatus parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter.name().toLowerCase());
}
@Override
public UserStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return rs.wasNull() ? null : getUserStatusByValue(value);
}
// ... 나머지 메서드들도 비슷한 패턴
private UserStatus getUserStatusByValue(String value) {
if (value == null) return null;
return Arrays.stream(UserStatus.values())
.filter(status -> status.name().toLowerCase().equals(value))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown value: " + value));
}
}
문제점
- 매번 동일한 패턴의 TypeHandler 작성 (Enum.name().toLowerCase() 변환)
- mybatis-config.xml에 수동으로 등록 필요
- 코드 중복 발생
이렇게 생성된 TypeHandler들은 본인 뿐만아니라 동료 개발자들도 파악해야 할 맥락이 커져 피로해질 것 이다.
이 문제점들을 개선해보자.
해결 방법: Generic TypeHandler와 자동 등록
이 문제를 해결하기 위해 두 가지 접근 방식을 사용했다. (이펙티브 자바 아이템 29, 아이템 41의 적절한 예시인 것 같다.)
- Generic TypeHandler 작성: 공통된 패턴을 추상화한다.
- TypeHandler 자동 등록: 애너테이션 기반으로 클래스를 찾을 수 있는 Spring의 클래스 패스 스캐닝을 활용한다. (`ClassPathScanningCandidateComponentProvider`)
1단계: 마커 애너테이션 생성
자동 등록 대상을 식별할 마커 애너테이션을 작성한다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LowerCaseEnum {
}
2단계: Generic TypeHandler 구현
제네릭을 활용해 재사용 가능한 TypeHandler를 구현한다.
public class LowerCaseEnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {
private final Class<E> type;
public LowerCaseEnumTypeHandler(Class<E> type) {
if (type == null) {
throw new IllegalArgumentException("Type argument cannot be null");
}
this.type = type;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {
// Enum 값을 소문자로 변환하여 PreparedStatement에 설정
ps.setString(i, parameter.name().toLowerCase());
}
@Override
public E getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return rs.wasNull() ? null : getEnumByValue(value);
}
@Override
public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String value = rs.getString(columnIndex);
return rs.wasNull() ? null : getEnumByValue(value);
}
@Override
public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String value = cs.getString(columnIndex);
return cs.wasNull() ? null : getEnumByValue(value);
}
private E getEnumByValue(String value) {
if (value == null) {
return null;
}
return Arrays.stream(type.getEnumConstants())
.filter(e -> e.name().toLowerCase().equals(value))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"No enum constant " + type.getCanonicalName() + " with value " + value));
}
}
3단계: 자동 등록 Configuration 구현
Spring의 `ClassPathScanningCandidateComponentProvider`를 활용해 자동 등록 로직을 구현했다.
자동 등록은 `@PostConstruct` 애너테이션을 활용해 `SqlSessionFactory` Bean이 완전히 초기화된 이후로 설정해야 한다.
잠시 클래스 패스 스캐닝에 대해 정리하면,
- `ClassPathScanningCandidateComponentProvider`의 `addIncludeFilter()`로 클래스 필터를 추가한다.
- `AnnotationTypeFilter`는 특정 애너테이션이 붙은 클래스만 필터링하도록 한다.
- `ClassPathScanningCandidateComponentProvider`의 `findCandidateComponents({스캐닝 위치})`를 통해 필터링한 결과를 `Set<BeanDefinition>`으로 반환한다. (`BeanDefinition`은 Spring IoC 컨테이너가 Bean을 생성하기 위해 필요한 메타데이터를 담고 있는 인터페이스다.)
@Slf4j
@Configuration
@RequiredArgsConstructor
public class EnumTypeHandlerAutoRegisterConfig {
private static final String BASE_PACKAGE = "com.example";
private final SqlSessionFactory sqlSessionFactory;
@PostConstruct
public void registerEnumTypeHandlers() {
TypeHandlerRegistry typeHandlerRegistry = sqlSessionFactory.getConfiguration().getTypeHandlerRegistry();
// LowerCaseEnum 애너테이션이 적용된 enum 등록
registerLowerCaseEnums(typeHandlerRegistry);
}
/**
* LowerCaseEnum 애너테이션이 적용된 enum을 등록
*/
private void registerLowerCaseEnums(TypeHandlerRegistry typeHandlerRegistry) {
ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
provider.addIncludeFilter(new AnnotationTypeFilter(LowerCaseEnum.class));
Set<BeanDefinition> beanDefinitions = provider.findCandidateComponents(BASE_PACKAGE);
for (BeanDefinition beanDefinition : beanDefinitions) {
try {
Class<?> clazz = Class.forName(beanDefinition.getBeanClassName());
if (clazz.isEnum()) {
registerLowerCaseEnumTypeHandler(clazz, typeHandlerRegistry);
}
} catch (ClassNotFoundException e) {
log.error("ClassNotFoundException : {}", e.getMessage());
}
}
}
/**
* Enum 클래스에 대한 LowerCaseEnumTypeHandler를 타입 안전하게 등록
*
* @param enumClass 등록할 Enum 클래스
* @param registry MyBatis TypeHandlerRegistry
* @param <E> Enum 타입
*/
@SuppressWarnings("unchecked")
private <E extends Enum<E>> void registerLowerCaseEnumTypeHandler(Class<?> enumClass, TypeHandlerRegistry registry) {
assert enumClass.isEnum() : "enum class가 아닙니다.";
// 여기서 타입 안전을 보장하면서 캐스팅
Class<E> typedEnumClass = (Class<E>) enumClass;
// LowerCaseEnumTypeHandler에 등록
registry.register(typedEnumClass, new LowerCaseEnumTypeHandler<>(typedEnumClass));
log.info("LowerCaseEnumTypeHandler registered for: {}", typedEnumClass.getName());
}
}
4단계: 실제 사용
이제 Enum을 간단하게 정의하고 애너테이션만 붙이면 된다.
@LowerCaseEnum
public enum UserStatus {
ACTIVE, // DB에는 "active"로 저장
INACTIVE, // DB에는 "inactive"로 저장
SUSPENDED // DB에는 "suspended"로 저장
}
@LowerCaseEnum
public enum OrderStatus {
PENDING,
CONFIRMED,
SHIPPED,
DELIVERED,
CANCELLED
}
마무리
개선 전엔 Enum 멤버 변수를 DB에 소문자로 저장해야 할 때 매번 새로운 TypeHandler 클래스 작성해야 했다. 그리고 이 클래스는 약 50줄의 보일러플레이트 코드를 유발한다. 이 핸들러를 작성하면 `mybatis-config.xml`에 수동 등록까지 해줘야 하는데, 여기서 휴먼 에러가 발생할 수도 있다.
개선 후엔 마커용 애너테이션 하나만 추가하는 것으로 Java 코드와 DB 값을 자동으로 매핑할 수 있게 되었다.
자동 등록 설정으로 `mybatis-config.xml` 설정 파일을 관리하지 않아도 되었고, 매번 50 줄의 중복 코드들을 제거할 수 있게 되었다.
참고
- https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html
- https://mybatis.org/mybatis-3/configuration.html#typeHandlers
- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.html
- https://dev-seonghun.medium.com/java-spring-특정-인터페이스를-구현한-클래스-찾기-cb8c38a586eb
- https://www.baeldung.com/java-scan-annotations-runtime
'Spring' 카테고리의 다른 글
[Mybatis] 로컬 캐시 문제: 프로시저 호출 시 발생하는 캐시 이슈 (3) | 2025.07.21 |
---|---|
Spring Event Deep Dive (2) | 2025.01.16 |
[Spring] MockMvc 사용 시 Page 인터페이스의 직렬화 문제 (0) | 2024.12.31 |
[Spring] 회원탈퇴 시 Kakao OAuth2 연결끊기: REST API로 연결끊기 (OpenFeign) (2) | 2024.10.30 |
[Spring] 직렬화/역직렬화 시 'is' prefix 가 안붙는 이유 (2) | 2024.10.30 |