본문 바로가기
Spring

Jpa Multi-datasource 구성과 @Transactional

by 초보개발자96 2024. 3. 19.

Springboot 에 Jpa 를 설정할 때 개발자는 db 관련 정보만 적어주면 single-datasource 환경에 한하여 필요한 모든 구성을 자동(auto configuration)으로 설정해줍니다. 비지니스가 복잡해지면 multi-datasource 연결이 요구되어 Jpa 관련 설정을 직접 해야 하는 경우가 생길 수 있는데요, 이 글에서는 설정에 필요한 요소들과 multi-datasource 환경에서 @Transactional 이 어떤 커넥션을 획득하여 트랜잭션을 처리하는지 간단히 정리해보겠습니다.

 

DataSource

DataSource 는 db config 정보를 통해 커넥션을 생성하고 관리하는 객체입니다.

 

val config = HikariConfig().apply {
    jdbcUrl = "jdbc:h2:mem:testdb"
    username = "sa"
    password = ""
    maximumPoolSize = 100
}

val datasource = HikariDataSource(config)

 

위 코드는 HikariConfig 정보를 바탕으로 DataSource 를 생성하는 코드입니다. 참고로 HikariDataSource 는 생성될 때 어플리케이션에서 사용할 Connection pool 도 함께 생성합니다.

 

 

EntityManager 와 EntityManagerFactory

EntityManager 는 엔티티를 관리하는 객체로 엔티티를 저장/조회 등의 작업을 합니다. EntityManager 는 EntityManagerFactory 로 생성할 수 있는데요, EntityManagerFactory 는 Thread safe 하고 생성하는 비용도 크므로 일반적으로 어플리케이션에서 공유하여 사용합니다. 반면 EntityManager 는 생성비용도 저렴하고 Thread safe 하지 않으므로 일반적으로는 요청 당 하나씩 할당되도록 합니다. EntityManagerFactory 를 생성할 때는 아래처럼 어떤 DataSource 를 사용하여 커넥션을 획득할지를 명시해주어야 합니다. 

 

fun entityManagerFactory(
    dataSource: DataSrouce
): LocalContainerEntityManagerFactoryBean {
	return builder
        .dataSource(dataSource)
        .packages(BASE_PACKAGES)
        .persistenceUnit("entityManager")
        .build()
}

 

이렇게 생성된 EntityManagerFactory 로 생성한 EntityManager 는 당연히 EntityManagerFactory 를 생성할 때 설정한 DataSource 를 이용하여 커넥션을 획득하게 됩니다.

 

TransactionManager

Jpa, Jdbc 등 DB 를 접근할 수 있는 라이브러리가 다양한 만큼 각 라이브러리에서 트랜잭션을 처리하는 방식도 다양합니다. 스프링은 TransactionManager 라는 추상화 단계를 두어 이를 구현한 라이브러리들은 동일한 방법으로 트랜잭션을 처리할 수 있게 했습니다. Jpa 에서는 JpaTransactionManager 를 이용할 수 있습니다.

 

@Bean
fun transactionManager(
    @Qualifier("entityManagerFactory")
    entityManagerFactory: EntityManagerFactory
): PlatformTransactionManager {
    return JpaTransactionManager(entityManagerFactory)
}

 

위에서 언급한 객체를 직접 구현하면 auto-configuration 에 의존하지 않고 Jpa 를 사용할 수 있는 환경이 갖추어집니다. 아래는 위 객체를 모두 구현한 간단한 예시입니다.

 

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    entityManagerFactoryRef = "subEntityManagerFactory",
    transactionManagerRef = "subTransactionManager",
    basePackages = [SubConstants.BASE_PACKAGES],
)
class SubDatabaseConfig {
    @ConfigurationProperties(prefix = "spring.sub-datasource")
    @Bean("subDataSource")
    fun subDataSource(): DataSource {
        return DataSourceBuilder.create().type(HikariDataSource::class.java).build()
    }

    @Bean("subEntityManagerFactory")
    fun subEntityManagerFactory(
        builder: EntityManagerFactoryBuilder,
    ): LocalContainerEntityManagerFactoryBean {
        val build = builder
            .dataSource(subDataSource())
            .packages(SubConstants.BASE_PACKAGES)
            .persistenceUnit("subEntityManager")
            .build()

        return build
    }

    @Bean
    fun subTransactionManager(
        @Qualifier("subEntityManagerFactory")
        subEntityManagerFactory: EntityManagerFactory
    ): PlatformTransactionManager {
        return JpaTransactionManager(subEntityManagerFactory)
    }
}

 

Multi-datasource 에서의 @Transactional

어떤 클래스에 @Transactional 이 하나라도 붙어있으면 실제 객체가 아닌 프록시 객체가 스프링 컨테이너에 등록됩니다. 만약 @Transactional 이 붙은 메서드를 실행하면 트랜잭션 적용 대상임을 확인하고 트랜잭션 처리를 해주고, 붙어있지 않으면 기존 메서드만 실행해줍니다.

@Transactional 에는 어떤 TransactionManager 를 사용할건지 명시해줄 수 있는데요, EntityManagerFactory 가 하나라면 TransactionManager 도 하나이므로 별다른 신경을 쓰지 않아도 됩니다. 하지만 multi-datasource 인 경우에는 신경써야할 부분이 생길 수 있습니다.

 

 

서로 다른 DataSource 가 서로 다른 Repository 를 이용하는 경우

바라 보는 데이터베이스가 완전히 다른 구성일 경우는 간단합니다. 예를 들어, 한 어플리케이션에서 payment db 와 account db 를 사용해야 한다면 각각의 db 정보를 매핑할 Entity 와 Repository 객체도 달라집니다. 따라서 단순히 설정을 따로 만들고 @Transactional 에 필요한 TransactionManager 를 넣어주면 됩니다.

 

두 db 를 하나의 트랜잭션으로 묶어 처리하는 것은 고려하지 않습니다.

 

@EnableJpaRepositories(
    entityManagerFactoryRef = "accountEntityManagerFactory",
    transactionManagerRef = "accountTransactionManager",
    basePackages = [BASE_PACKAGES],
)
class AccountDatabaseConfig {
}

@EnableJpaRepositories(
    entityManagerFactoryRef = "paymentEntityManagerFactory",
    transactionManagerRef = "masterTransactionManager",
    basePackages = [BASE_PACKAGES],
)
class AccountDatabaseConfig {
}

class AccountService(
  private val accountRepository
){

    @Transactional("accountTransactionManager")
    fun refresh() {
        accountRepository.findOne()?.let {
	        accountEntity.refresh()
        }
    }
}

 

repository 는 @EnableJpaRepositories 에 설정된 EntityManagerFactory 로 생성한 EntityManager 를 사용하고, @Transactional 의 transactionManager 옵션을 이용하면 정상적으로 트랜잭션 처리가 됩니다. 

반면 아래는 동일한 DataSource 가 아니므로 트랜잭션 처리가 되지 않습니다.

 

@Transactional("paymentTransactionManager")
fun refresh() {
    accountRepository.findOne()?.let {
	    accountEntity.refresh()
    }
}

 

multi-datasoruce 인 경우 디폴트로 적용할 DataSource, TransactionManager, EntityManagerFactory 에 @Primary 를 주어야 하고, transactionManager 옵션이 없다면 @Primary 가 적용된 TransactionManager 를 사용합니다.

 

서로 다른 DataSource 가 같은 Repository 를 이용하는 경우

master/slave 를 나누어 주는 경우처럼 DataSource 는 다르지만 같은 Repository 를 사용하는 경우가 있습니다. 특정 비지니스 로직은 CUD 없이 read-only 로만 구성되어 slave db 를 이용할 수도 있는데, @EnableJpaRepositories 에는 하나의 EntityManagerFactory 만 설정할 수 있고 보통은 master 의 EntityManagerFactory 가 설정됩니다.

이런 경우를 위해 사용하는 시점에 원하는 DataSource 를 선택할 수 있게 설정해야 하는데, LazyConnectionDataSourceProxy 를 이용하면 커넥션 획득을 지연시켜 실제 쿼리를 수행할 때 커넥션을 획득할 수 있습니다.

 

@Bean("routingDataSource")
fun routingDataSource(
    @Qualifier("masterDataSource")
    masterDataSource: DataSource,
    @Qualifier("slaveDataSource")
    slaveDataSource: DataSource
): DataSource {
    return DynamicRoutingDataSource().apply {
        setTargetDataSources(mapOf("master" to masterDataSource, "slave" to slaveDataSource))
        setDefaultTargetDataSource(masterDataSource)
    }
}

@Primary
@Bean
fun lazyDataSource(routingDataSource: DataSource): DataSource = LazyConnectionDataSourceProxy(routingDataSource)

 

위에서 설정한 lazyDataSource 를 EntityManagerFactory 를 생성할 때 넣어주면, 기존의 DataSource 를 먼저 획득하여 원하는 커넥션을 얻을 수 없는 방식과 달리 DataSource proxy 을 획득하고 실제 커넥션 획득 시점을 미루어 필요한 커넥션을 얻을 수 있게 됩니다.

 

 

 

참고로, master/slave replication 와 같이 자주 사용되는 기능의 경우 편의를 위해 드라이버가 기본적으로 제공해주고 있을 수도 있습니다. 예를 들어, aws aurora db cluster 는 primary(writer) db 를 사용할 수 없을 때 replica 중 하나를 primary db 로 승격시키는 방법으로 failover 기능을 제공합니다. aws-advanced-jdbc-wrapper 는 mysql-connector-j 같은 기존 드라이버의 기능을 확장하여 failover 기능을 호환해줍니다.

각 DB 벤더는 자신의 DB 에 맞게 JDBC 인터페이스를 구현하고 자신만의 고유한 기능을 추가한 드라이버를 제공하므로, 현재 사용하는 드라이버의 Docs 를 잘 살펴보면 좋을 것 같습니다.

 

GitHub - awslabs/aws-advanced-jdbc-wrapper: The Amazon Web Services JDBC Driver has been redesigned as an advanced JDBC wrapper.

The Amazon Web Services JDBC Driver has been redesigned as an advanced JDBC wrapper. This wrapper is complementary to and extends the functionality of an existing JDBC driver to help an application...

github.com