SpringBoot Mutliple-datasource could not initiate AttributeConverter 삽질

서민정·2023년 7월 23일
0

실제 사내에서 운영 중인 프로젝트에 적용했기 때문에, 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일

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

답글 달기