티스토리 뷰
[Spring] AttributeConverter registered multiple times 에러 (JPA, Hibernate)
망나니개발자 2024. 9. 17. 10:00
1. AttributeConverter registered multiple times 에러(JPA, Hibernate)
[ 문제 상황 공유 ]
문제가 발생했던 환경은 다음과 같다.
- Spring Boot 2.7
- Hibernate: 5.6.16.Final
- 통합테스트에서만 발생
그리고 문제가 생겼던 코드는 다음의 부분이였다. YearMonth 타입의 필드를 데이터베이스에 저장하기 위해 String 타입으로 변환하도록 컨버터를 사용하는 부분이다.
@Converter
class YearMonthConverter : AttributeConverter<YearMonth, String> {
override fun convertToDatabaseColumn(attribute: YearMonth?): String? {
return attribute?.format(formatter)
}
override fun convertToEntityAttribute(dbData: String?): YearMonth? {
return dbData?.let { YearMonth.parse(it) }
}
private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM")
}
@Entity
@Table(name = "school_meal_share")
class MemberEntity(
@Id
@GeneratedValue
val id: Long? = null,
@Convert(converter = YearMonthConverter::class)
@Column(name = "year_month")
val yearMonth: YearMonth,
)
발생했던 에러는 다음과 같으며, 에러 메세지를 보면 해당 컨버터가 여러 번 등록 된다는 것이다.
org.springframework.beans.factory.BeanCreationException:
Error creating bean with name 'entityManagerFactory' defined in class path resource [com/mangkyu/config/JpaConfiguration.class]:
Invocation of init method failed; nested exception is org.hibernate.AssertionFailure:
AttributeConverter class [class com.mangkyu.config.jpa.YearMonthConverter] registered multiple times
[ 문제 발생 원인 파악 ]
문제가 발생한 이유는 동일한 컨버터가 여러 번 등록되는 것이므로, 어떠한 코드로 인해 컨버터가 여러 번 등록되었는지 확인이 필요하다.
먼저 에러가 발생했던 코드는 하이버네이트(Hibernate)의 AttributeConverterManager라는 클래스 내부이다. 내부의 addConverter를 호출하는 과정에서 예외가 발생하였다.
public void addConverter(ConverterDescriptor descriptor) {
if ( attributeConverterDescriptorsByClass == null ) {
attributeConverterDescriptorsByClass = new ConcurrentHashMap<>();
}
final Object old = attributeConverterDescriptorsByClass.put(
descriptor.getAttributeConverterClass(),
descriptor
);
if ( old != null ) {
throw new AssertionFailure(
String.format(
Locale.ENGLISH,
"AttributeConverter class [%s] registered multiple times",
descriptor.getAttributeConverterClass()
)
);
}
}
해당 컨버터를 찾아서 등록하는 부분이 어디인지 살펴보니 AnnotationMetadataSourceProcessorImpl 이라는 클래스 내부의 categorizeAnnotatedClass 메서드 부분이였다. 해당 코드는 Converter 애노테이션이 붙어있는 클래스들의 경우 컨버터로 등록하도록 동작하고 있었다.
private void categorizeAnnotatedClass(Class annotatedClass, AttributeConverterManager attributeConverterManager, ClassLoaderService cls) {
final XClass xClass = reflectionManager.toXClass( annotatedClass );
// categorize it, based on assumption it does not fall into multiple categories
if ( xClass.isAnnotationPresent( Converter.class ) ) {
//noinspection unchecked
attributeConverterManager.addAttributeConverter( annotatedClass );
}
else if ( xClass.isAnnotationPresent( Entity.class )
|| xClass.isAnnotationPresent( MappedSuperclass.class ) ) {
xClasses.add( xClass );
}
else if ( xClass.isAnnotationPresent( Embeddable.class ) ) {
xClasses.add( xClass );
}
else {
log.debugf( "Encountered a non-categorized annotated class [%s]; ignoring", annotatedClass.getName() );
}
}
그렇다면 컨버터가 등록되는 또 다른 부분은 어디인지 한번 더 찾아볼 필요가 있다.
코드를 따라가다 보면 ScanningCoordinator라는 클래스의 applyScanResultsToManagedResources 메서드 내부에서 컨버터를 등록해주고 있음을 확인할 수 있다.
public void applyScanResultsToManagedResources(
ManagedResourcesImpl managedResources,
ScanResult scanResult,
BootstrapContext bootstrapContext,
XmlMappingBinderAccess xmlMappingBinderAccess) {
...
for ( ClassDescriptor classDescriptor : scanResult.getLocatedClasses() ) {
if ( classDescriptor.getCategorization() == ClassDescriptor.Categorization.CONVERTER ) {
// converter classes are safe to load because we never enhance them,
// and notice we use the ClassLoaderService specifically, not the temp ClassLoader (if any)
managedResources.addAttributeConverterDefinition(
AttributeConverterDefinition.from(
classLoaderService.<AttributeConverter>classForName( classDescriptor.getName() )
)
);
}
else if ( classDescriptor.getCategorization() == ClassDescriptor.Categorization.MODEL ) {
managedResources.addAnnotatedClassName( classDescriptor.getName() );
}
unresolvedListedClassNames.remove( classDescriptor.getName() );
}
...
}
해당 컨버터에 대한 정보를 들고 있는 scanResult를 파악하려면 EntityManager를 생성하기 위한 EntityManagerFactoryBuilderImpl 클래스 내부에서 메타데이터를 스캐닝하는 부분에서 부터 코드를 따라가야 한다. 그러면 메타데이터를 빌딩하는 MetadataBuildingProcess 클래스 내부에서 스캐닝을 처리하는 prepare 메서드에서 스캔이 시작됨을 확인할 수 있다.
public static ManagedResources prepare(
final MetadataSources sources,
final BootstrapContext bootstrapContext) {
final ManagedResourcesImpl managedResources = ManagedResourcesImpl.baseline( sources, bootstrapContext );
final ConfigurationService configService = bootstrapContext.getServiceRegistry().getService( ConfigurationService.class );
final boolean xmlMappingEnabled = configService.getSetting(
AvailableSettings.XML_MAPPING_ENABLED,
StandardConverters.BOOLEAN,
true
);
ScanningCoordinator.INSTANCE.coordinateScan(
managedResources,
bootstrapContext,
xmlMappingEnabled ? sources.getXmlMappingBinderAccess() : null
);
return managedResources;
}
그리고 찾아진 Entity, Converter와 같은 클래스 정보들은 ScanResultCollector를 통해 추가됨을 확인할 수 있다.
public void handleClass(ClassDescriptor classDescriptor, boolean rootUrl) {
if ( !isListedOrDetectable( classDescriptor.getName(), rootUrl ) ) {
return;
}
discoveredClasses.add( classDescriptor );
}
즉, 모종의 이유로 컨버터를 서로 다른 위치에서 2번 등록하려는 시도가 발생하였고, 이로 인해 에러가 발생한 것이다. 테스트가 아닌(문제가 없는) 프로덕션 코드에서는 AttributeConverterManager에서만 컨버터 클래스가 등록되었는데, 그렇다면 왜 테스트 코드에서는 ScanningCoordinator라는 클래스를 통해 컨버터가 또 찾아지고 등록되었는지 간단히만 살펴보도록 하자.
먼저 클래스 파일들을 스캐닝하여 가져오는 부분은 DefaultPersistenceUnitManager라는 클래스의 scanPackage 메서드 부분이다. resourcePatternResolver인 PathMatchingResourcePatternResolver를 통해 리소스 목록을 가져온다.
private void scanPackage(SpringPersistenceUnitInfo scannedUnit, String pkg) {
...
try {
String pattern = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
ClassUtils.convertClassNameToResourcePath(pkg) + CLASS_RESOURCE_PATTERN;
Resource[] resources = this.resourcePatternResolver.getResources(pattern);
...
}
catch (IOException ex) {
throw new PersistenceException("Failed to scan classpath for unlisted entity classes", ex);
}
}
그리고 문제가 되는 부분은 PathMatchingResourcePatternResolver 클래스 내부의 doFindAllClassPathResources 부분이다. 해당 부분은 클래스 로더로부터 읽어야 하는 URL 목록을 가져오는 것인데, 이때 테스트의 경우 패키징된 jar를 resourceUrls로 불러와서 문제가 되는 것이다.
protected Set<Resource> doFindAllClassPathResources(String path) throws IOException {
Set<Resource> result = new LinkedHashSet<>(16);
ClassLoader cl = getClassLoader();
Enumeration<URL> resourceUrls = (cl != null ? cl.getResources(path) : ClassLoader.getSystemResources(path));
while (resourceUrls.hasMoreElements()) {
URL url = resourceUrls.nextElement();
result.add(convertClassLoaderURL(url));
}
if (!StringUtils.hasLength(path)) {
// The above result is likely to be incomplete, i.e. only containing file system references.
// We need to have pointers to each of the jar files on the classpath as well...
addAllClassLoaderJarRoots(cl, result);
}
return result;
}
즉, 원래라면 불러오지 말아야 하는 패키징된 jar를 resourceUrls에 포함시키고, 해당 jar 내부에서까지 Converter와 Entity 등을 찾기 때문에 문제가 되는 것이다.
[ 문제 해결하기 ]
이러한 문제를 해결하는 방법은 @Converter 애노테이션을 제거하는 것이다. @Converter 애노테이션을 제거하더라도 엔티티를 등록하고 조회할 때, 메타데이터 형태로 컨버터 클래스에 대한 정보를 관리하여 컨버터를 통해 데이터를 컨버팅하고 있다. 따라서 @Converter를 제거하여 스캐닝을 받지 못하도록 하면 된다.
현재 별다른 수단이 없어 위와 같은 방법으로 해결했지만, Converter 애노테이션의 설명에서는 반드시 해당 애노테이션을 붙여줄 것을 권장하고 있다. 하지만 Hibernate 동작 상으로는 해당 애노테이션이 없어도 정상적으로 동작이 가능한데, 해당 애노테이션이 하이버네이트에서 갖는 의미에 대해서 보다 살펴볼 필요가 있을 것 같다.