@Enumerated와 @Convert

김민창·2024년 8월 18일
0

trouble shooting

목록 보기
9/11
post-thumbnail

@Enumerated 어노테이션과 @Convert 어노테이션은 공존할 수 없는걸까

아니 분명히 달아줬다니깐요?

기존 존재하는 php 코드를 spring 으로 전환하는 업무가 최근에 꽤나 들어오는 편이다.

그러니깐 마이그레이션과 같은 데이터베이스 모델링부터 시작이 아닌, 기존 사용하던 데이터베이스를 사용하며 그대로 호환성을 맞추는 그런 업무들!

사내에서 기존에 사용하는 데이터베이스에서는 enum 타입으로 사용되는것들이 많았다.

간단히 @Enumerated를 달아볼까?

데이터베이스에서 Notnull 필드인걸 확인하고, 다음과 같이 @Enumerated(value = EnumType.STRING) 을 추가해줬다.

@Column(name = "type")
@Enumerated(value = EnumType.STRING)
private YorN type;

그리고 간단한 CRUD 테스트를 진행하니 enum 타입으로 변환을 할수 없다는 다음과 같은 에러가 발생한다...

nested exception is org.springframework.dao.InvalidDataAccessApiUsageException: No enum constant

확인해보니 데이터베이스의 enum 데이터타입이 enum('Y', 'N') 으로 선언되어있지만, 실제로는 빈 스트링("")으로 들어가있었음!!!

해당 이슈는 MYSQL 5.7에서 만나볼수있는 이슈로, enum에 맞지않는 값은 빈 스트링으로 들어간다고 한다 😠

참고 페이지


그럼 컨버터를 달아볼까?

이번에는 Converter 를 달아주도록 결심했다.
내가 사용하는 YorN enum에 대하여AttributeConverternull에 대한 방어코드도 추가하여 구현해주고 Entity는 다음과 같이 Converter 어노테이션을 추가해줬다

@Column(name = "type")
@Convert(converter = YorNConverter.class)
@Enumerated(value = EnumType.STRING)
private YorN type;

그리고 여전히 만나볼수있는 enum 타입으로 변환 못한다는 그에러...

nested exception is org.springframework.dao.InvalidDataAccessApiUsageException: No enum constant 

아니 컨버터 달아줬다니깐요? 😡


내부로직 구경하기

그래서 한번 들어가봤습니다.

닭이 먼저냐 달걀이 먼저냐

hibernate에서 얻어낸 value를 Entity 로 맵핑해주는 클래스가 BasicResultAssembler.java 라는것을 알게되었고, 해당 함수를 확인했을때 생겨난 이슈를 확인할 수 있었다.

/**
 * BasicResultAssembler.java
 */
@Override
public J assemble(
		RowProcessingState rowProcessingState,
		JdbcValuesSourceProcessingOptions options) {
    // 이놈
	final Object jdbcValue = extractRawValue( rowProcessingState );
  
	ResultsLogger.RESULTS_LOGGER.debugf( "Extracted JDBC value [%d] - [%s]", valuesArrayPosition, jdbcValue );
	...
    // converter는 여기 있어요
}

extractRawValue() 함수를 통해 엔티티에 삽입되어야 할 데이터를 변환하여 가지고 오게 되는데, @Enumerated 어노테이션이 선언된 필드들은 다음 BasicValueBinder.java 에서 최초에 hibernate 에서 JdbcType이 enum타입으로 등록되게 된다

/**
 * BasicValueBinder.java
 */ 
private void prepareBasicAttribute(String declaringClassName, XProperty attributeDescriptor, XClass attributeType) {
		
    ...
        
	final Enumerated enumeratedAnn = attributeDescriptor.getAnnotation( Enumerated.class );
	if ( enumeratedAnn != null ) {
		this.enumType = enumeratedAnn.value();
    }
    
    ...
    
}

값을 추출할때 JdbcType 별로 다르게 맵핑하여 가지고 오기 때문에, 데이터베이스값이 enum에 맞지 않다면 추출할때 부터 enum타입으로 선언되어있으면 converter 와는 무방하게 에러가 발생한다

타입별 컨버터의 실제 구현은 다음과 같은 차이가 존재한다

  • EnumJavaType 컨버터
/*
 * EnumJavaType.java
 */
@Override
public <X> T wrap(X value, WrapperOptions options) {
	...
	else if ( value instanceof String ) {
		return fromName( (String) value );
	}
    ...
}
        
public T fromName(String relationalForm) {
	if ( relationalForm == null ) {
		return null;
	}
    // 에러 잘나게 생긴녀석
	return Enum.valueOf( getJavaTypeClass(), relationalForm.trim() );
}
  • StringJavaType 컨버터
public <X> String wrap(X value, WrapperOptions options) {
	...
    // String 이라면 String 반환. 아주 군더더기 없는 녀석
	if (value instanceof String) {
		return (String) value;
	}
    ...
}

이번 섹터 제목이 닭이 먼저냐 달걀이 먼저냐 라고 지었던것도, 컬럼에 @Enumerated 어노테이션을 추가해준것 만으로, converter 를 추가한것은 아무런 도움이 안된다는 뜻에서 정해봤다(설명이 필요한 드립은 실패한 드립)


컨버터 여기 뒤에있어요

BasicResultAssembler.java 에서의 assemble() 메서드를 대량 생략했었는데 전체 구현부는 다음과 같다.

컨버터가 존재한다면 비로소 우리가 구현한 AttributeConverter 컨버터를 사용하게 된다.

/**
 * BasicResultAssembler.java
 */
@Override
public J assemble(
		RowProcessingState rowProcessingState,
		JdbcValuesSourceProcessingOptions options) {
	final Object jdbcValue = extractRawValue( rowProcessingState );

	ResultsLogger.RESULTS_LOGGER.debugf( "Extracted JDBC value [%d] - [%s]", valuesArrayPosition, jdbcValue );

	if ( valueConverter != null ) {
		if ( jdbcValue != null ) {
			// the raw value type should be the converter's relational-JTD
			if ( ! valueConverter.getRelationalJavaType().getJavaTypeClass().isInstance( jdbcValue ) ) {
				throw new HibernateException(
						String.format(
								Locale.ROOT,
								"Expecting raw JDBC value of type `%s`, but found `%s` : [%s]",
								valueConverter.getRelationalJavaType().getTypeName(),
								jdbcValue.getClass().getName(),
								jdbcValue
						)
				);
			}
		}

		// 이놈
		return (J) ( (BasicValueConverter) valueConverter ).toDomainValue( jdbcValue );
	}

	return (J) jdbcValue;
}

그러면 어떻게 하죠

결론... 이라고하기엔 좀 그렇지만 데이터베이스에 값이 꼬여있는 경우가 아니라면 @Enumerated로도 커버가 가능하며, 굳이굳이 둘다 혼용을 한다면, 빈 문자열또한 어플리케이션 로직에 추가하여 Converter로 정상값으로 변환시켜주자.


환경
Spring Boot 3.3.2
hibernate 6.5.2

profile
개발자 팡이

3개의 댓글

comment-user-thumbnail
2024년 8월 30일

안녕하세요! 저도 이러한 부류의 프로젝트를 진행했었는데, Domain 클래스와 Entity 클래스를 나눠 Entity 클래스에서는 대부분 String으로 폭넓게 받아 값 오류를 줄여주고 Domain 클래스로 맵핑해줄 때 Enum등으로 타입을 제한하고 예외처리를 해주는 방식으로 개발을 했었습니다! (애초에 헥사고날 아키텍쳐이긴 했음)! 바.. 아니 팡이님은 이 문제를 어떻게 처리하셨나요??

2개의 답글