SpringBoot Mutliple-datasource could not initiate AttributeConverter 삽질

서민정·2023년 7월 23일

실제 사내에서 운영 중인 프로젝트에 적용했기 때문에, DB 관련 설정은 따로 하지 않았다. Spring Boot 관련 애플리케이션 설정만 했다.
local 환경에서의 설정을 예시로 가져오자면 다음과 같이 설정했다.

spring:
  jpa:
    properties:
      hibernate:
        show_sql: false
        generate-ddl: false
        format_sql: false
        physical_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
        jdbc:
          lob:
            non_contextual_creation: true # createClob() 메서드를 구현하지 않았다는 hibernate의 에러 로그를 보여주지 않기 위함.
        hbm2ddl:
          auto: none # 로컬에서 create-ddl이 필요할 경우 create로 변경
  datasource:
    url: jdbc:mysql://localhost:3307/
    username:
    password: 
    replica:
      url: jdbc:mysql://localhost:3308/
      username: 
      password:

실제로 DB 서버가 Writer DB, Reader DB로 나눠진 경우는 프로덕션 환경 뿐이었기 때문에, 제대로 잘 적용됐는지는 프로덕션 환경에 배포한 뒤 CPU Usage를 확인했다. 물론 로컬에서도 Read Query의 경우 Reader DB로 가는지도 로그를 통해 확인했다.

위처럼 적용하면 JPA와 관련된 설정이 먹히지 않는다. 커스텀한 Configuration 파일을 작성해 Hibernate에서 제공해주는 옵션을 임의로 박아야한다.

@ConstructorBinding
@ConfigurationProperties(prefix = "spring.datasource")
data class ReplicationDataSourceProperties(
    val url: String = "",
    val username: String = "",
    val password: String = "",
    val replica: Replica,
) {

    data class Replica(
        val url: String = "",
        val username: String = "",
        val password: String = "",
    )
}

디폴트 생성자를 주기 위해 기본값을 설정했다.

@EnableConfigurationProperties(ReplicationDataSourceProperties::class, HibernateProperties::class)
@Configuration
class ReplicationDataSourceConfig(
    private val dataSourceProperties: ReplicationDataSourceProperties,
    private val jpaProperties: JpaProperties,
    private val hibernateProperties: HibernateProperties,
) {
    @Bean
    fun routingDataSource(): DataSource {
        val sourceDataSource = createDataSource(
            url = dataSourceProperties.url,
            username = dataSourceProperties.username,
            password = dataSourceProperties.password,
        )
        val dataSources = mutableMapOf<Any, Any>()
        dataSources[SOURCE] = sourceDataSource

        val replica = dataSourceProperties.replica
        val replicaDataSource = createDataSource(
            url = replica.url,
            username = replica.username,
            password = replica.password,
        )
        dataSources[REPLICA] = replicaDataSource

        val replicationRoutingDataSource = ReplicationRoutingDataSource()
        replicationRoutingDataSource.setDefaultTargetDataSource(sourceDataSource)
        replicationRoutingDataSource.setTargetDataSources(dataSources)
        return replicationRoutingDataSource
    }

    fun createDataSource(
        url: String,
        username: String,
        password: String,
    ): DataSource {
        return DataSourceBuilder
            .create()
            .url(url)
            .username(username)
            .password(password)
            .build()
    }

    @Bean
    fun dataSource(): DataSource {
        return LazyConnectionDataSourceProxy(routingDataSource())
    }

    @Primary
    @Bean(name = ["entityManagerFactory"])
    fun entityManagerFactory(
        beanFactory: ConfigurableListableBeanFactory
    ): LocalContainerEntityManagerFactoryBean {
        val entityManagerFactoryBuilder: EntityManagerFactoryBuilder =
            createEntityManagerFactoryBuilder(jpaProperties)
        val localContainerEntityManagerFactoryBean =
            entityManagerFactoryBuilder.dataSource(dataSource())
                .properties(hibernateProperties.determineHibernateProperties(jpaProperties.properties, HibernateSettings()))
                .packages("com.daangn.business.platform.*")
                .build()

        localContainerEntityManagerFactoryBean.jpaPropertyMap[AvailableSettings.BEAN_CONTAINER] =
            SpringBeanContainer(beanFactory)
        return localContainerEntityManagerFactoryBean
    }

    private fun createEntityManagerFactoryBuilder(jpaProperties: JpaProperties): EntityManagerFactoryBuilder {
        val vendorAdapter: JpaVendorAdapter = HibernateJpaVendorAdapter()
        return EntityManagerFactoryBuilder(vendorAdapter, jpaProperties.properties, null)
    }

    // JPA에서 사용할 TransactionManager 설정
    @Bean
    fun transactionManager(entityManagerFactory: EntityManagerFactory): PlatformTransactionManager? {
        val tm = JpaTransactionManager()
        tm.entityManagerFactory = entityManagerFactory
        return tm
    }
}

위는 Hibernate 설정을 적용한 엔티티 매니저 팩토리를 만드는 설정과 routing할 수 있는 각각의 dataSource를 생성해주는 설정 파일이다.

여기서 주의해야할 곳이 entityManagerFactory 를 생성하는 부분이다. 대부분의 multiple-datasources 를 적용하는 블로그나 공식 문서 등을 찾아봐도 LocalContainerEntityManagerFactoryBean 을 생성할 때 hibernate properties를 따로 주입해주지 않는다.
아래처럼…

// hibernate properties를 따로 주입해주지 않는 예시
@Primary
@Bean(name = ["entityManagerFactory"])
fun entityManagerFactory(
    beanFactory: ConfigurableListableBeanFactory
): LocalContainerEntityManagerFactoryBean {
    val entityManagerFactoryBuilder: EntityManagerFactoryBuilder =
        createEntityManagerFactoryBuilder(jpaProperties)
    val localContainerEntityManagerFactoryBean =
        entityManagerFactoryBuilder.dataSource(dataSource())
            .packages("com.daangn.business.platform.*")
            .build()

    return localContainerEntityManagerFactoryBean
}

그러나, 만약 코드에서 AttributeConverter 를 사용한다면 거의 99%의 블로그에서 제시하는 대로 LocalContainerEntityManagerFactoryBean 을 생성한다면 무조건 다음과 같은 에러를 만날 것이다.

org.hibernate.AnnotationException: Unable to instantiate AttributeConverter

해당 에러로 검색하면 이 블로그 글을 가장 먼저 만나볼 수 있다.

https://brunch.co.kr/@purpledev/33

이 블로그에서 제시하는 해결책은 LocalContainerEntityManagerFactoryBean 을 통해 entityManager 생성 시 bean을 주입받을 수 있도록 BEAN_CONTAINER 속성을 jpaPropertyMap 에 주입하는 것이다.

@Primary
@Bean(name = ["entityManagerFactory"])
fun entityManagerFactory(
    beanFactory: ConfigurableListableBeanFactory
): LocalContainerEntityManagerFactoryBean {
    val entityManagerFactoryBuilder: EntityManagerFactoryBuilder =
        createEntityManagerFactoryBuilder(jpaProperties)
    val localContainerEntityManagerFactoryBean =
        entityManagerFactoryBuilder.dataSource(dataSource())
            .packages("com.daangn.business.platform.*")
            .build()
    // 추가된 부분
    localContainerEntityManagerFactoryBean.jpaPropertyMap[AvailableSettings.BEAN_CONTAINER] = SpringBeanContainer(beanFactory)

    return localContainerEntityManagerFactoryBean
}

하지만 난 이렇게 해도 AttributeConverterDefinition 에서 AttributeConverter 를 초기화할 수 없다는 에러가 다시 발생했다.

org.hibernate.cfg.AttributeConverterDefinition.instantiateAttributeConverter(AttributeConverterDefinition.java:60)

해당 클래스는 Deprecated 된 클래스인데, 왜 저 코드라인을 실행하는지 의문이었다. 브레이크 포인트를 걸고 기존과 달라진 부분을 찾아보았다.


첫번째로 발견한 부분은 Scanner 였다. 
1도 이해가 안되지만 Scanner docs 를 참고하면 아래처럼 설명돼있다.
Persistence 단위 내부의 클래스, 패키지 및 리소스를 검색할 수 있도록 Hibernate에 대한 Contract를 정의합니다.

Replication을 적용하기 전에는 즉 SingleDataSource의 경우에는 JpaProperties와 HibernateProperties가 함께 적용된다. 이 때 HibernateProperties에 설정된 DISABLED_SCANNER_CLASS가 자동으로 설정된다.

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(HibernateProperties.class)
@ConditionalOnSingleCandidate(DataSource.class)
class HibernateJpaConfiguration extends JpaBaseConfiguration {
}

하지만 지금처럼 멀티소스인 경우에는 해당 HibernateJpaConfiguration이 자동으로 설정되지 않기 때문에 HibernateProperties가 먹히지 않고, Scanner Class가 Disabled가 아닌 다른 스캐너로 먹힌다.
다른 스캐너 + ScanSettings가 null이기 때문에 적절한 Scan 행위를 하지 못하고, 따라서 AttributeConverter의 생성자를 찾지 못해 init 을 하지 못하는 것!

결론

아무튼 말이 길었는데 결론적으로 위에서 언급한 대로 설정해주면 제대로 동작한다.

@Primary
@Bean(name = ["entityManagerFactory"])
fun entityManagerFactory(
    beanFactory: ConfigurableListableBeanFactory
): LocalContainerEntityManagerFactoryBean {
    val entityManagerFactoryBuilder: EntityManagerFactoryBuilder =
        createEntityManagerFactoryBuilder(jpaProperties)
    val localContainerEntityManagerFactoryBean =
        entityManagerFactoryBuilder.dataSource(dataSource())
            // 여기가 핵심 1
            .properties(hibernateProperties.determineHibernateProperties(jpaProperties.properties, HibernateSettings()))
            .packages("com.daangn.business.platform.*")
            .build()

    // 여기가 핵심 2
    localContainerEntityManagerFactoryBean.jpaPropertyMap[AvailableSettings.BEAN_CONTAINER] =
        SpringBeanContainer(beanFactory)
    return localContainerEntityManagerFactoryBean
}
profile
Server Engineer

1개의 댓글

comment-user-thumbnail
2023년 7월 23일

공감하며 읽었습니다. 좋은 글 감사드립니다.

답글 달기