Database

Spring에서 데이터소스 2개를 동시에 사용해 보았다

미스터머글 2022. 5. 9. 18:14
728x90
반응형

 

참고사항 : 본 실습환경은 스프링(Spring boot) + 코틀린(Kotlin) + MySQL + JPA이다.

 

 

"배달의 만족"이라는 회사가 있다고 가정해보자. (만족이다. 민족아니다.) 이 회사는 음식 배달을 서비스하는 회사이기에 여러 식당과 계약을 맺은 상태이다. 그래서 식당 DB가 있을 것이고 배달하는 분들의 데이터가 들어간 라이더 DB가 있을 것이다. 서로 다른 2개의 스키마를 하나의 서비스에서 사용하는 일이 생길 수 밖에 없다. 그럴 때 어떻게 해야할까?

 

본 실습의 2개 DB는 모두 MySQL이다.

 

먼저 모델과 환경변수를 세팅하자

당연히 모델은 2개로 분리가 될 것이다. 아래 2개의 Entity 모델이 바라볼 DB를 앞으로 세팅하게 될 것이다.

Restaurant.kt
Rider.kt

 

그리고 환경변수(yml 파일)는 아래와 같다. 이때 각각의 DB는 서로 다른 종류라도 가능하다. 예를 들어 하나는 MySQL을 사용했을 때 다른 하나는 h2나 오라클 등. 이렇게 서로 다른 종류로도 가능하다는 뜻이다. 물론 동일한 DB이지만 다른 URL을 가질 때도 이렇게 사용이 가능하다.

spring:
  datasource:
    restaurant:
      hikari:
        maximumPoolSize: 10
        poolName: HikariCP
      jdbc-url: [restaurant DB URL을 입력하자]
      username: [restaurant DB username 입력하자]
      password: [restaurant DB password를 입력하자]
      driver-class-name: com.mysql.cj.jdbc.Driver
    rider:
      hikari:
        maximumPoolSize: 10
        poolName: HikariCP
      jdbc-url: [rider DB URL을 입력하자]
      username: [rider DB username 입력하자]
      password: [rider DB password를 입력하자]
      driver-class-name: com.mysql.cj.jdbc.Driver

 

 

JPA 세팅을 해야한다

JPA를 사용했을 때 유의해야하는 것이 바로 영속성이다. 식당과 라이더 양 쪽 모두 수정해야하는 API가 있을 때 둘 모두가 반영이 되어야 하는데 에러로 인해 한쪽만 수정이 되는 일은 없어야 한다. 그래서 이런 일이 없도록 세팅을 할 필요가 있다.

 

먼저 restaurant DB쪽 Configuration를 먼저 세팅해보자.

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = ["com.baeman"],
    entityManagerFactoryRef = "entityManagerFactory",
    transactionManagerRef = "transactionManager"
)
class DefaultDataSourceConfig {
	// ...
}
  • EnableTransactionManagement
    • 트랜젝션 처리를 허용해주는 어노테이션. 트랜잭션 범위를 활성화
  • EnableJpaRepositories
    • JPA Repository들을 활성화하기 위한 애노테이션
    • JpaRepository에 대한 설정정보를 자동적으로 로딩하고 이 정보를 토대로 Repository 빈을 등록하는 역할
    • entityManagerFactoryRef
      • EntityManagerFactory를 바라볼 위치. 뒤에 추가 설명 참조
    • transactionManagerRef
      • TransactionManager를 바라볼 위치. 뒤에 추가 설명 참조

 

class DefaultDataSourceConfig {

    @Primary
    @Bean
    @ConfigurationProperties("spring.datasource.restaurant")
    fun dataSource(): DataSource {
        val dataSource = DataSourceBuilder.create().build()
        return dataSource
    }

    @Primary
    @Bean
    fun entityManagerFactory(
        builder: EntityManagerFactoryBuilder,
        @Qualifier("dataSource") dataSource: DataSource
    ): LocalContainerEntityManagerFactoryBean {
        return builder
            .dataSource(dataSource)
            .packages("com.baeman")
            .persistenceUnit("restaurant")
            .build()
    }

    @Primary
    @Bean
    fun transactionManager(
        @Qualifier("entityManagerFactory") entityManagerFactory: EntityManagerFactory
    ): PlatformTransactionManager {
        val transactionManager = JpaTransactionManager()
        transactionManager.entityManagerFactory = entityManagerFactory
        return transactionManager
    }
}
  • @Primary
    • 어떤 데이터소스 먼저 세팅할지를 지정하기 위함이다.
    • 뒤에 나오겠지만, 나머지 데이터소스는 자연스럽게 Secondary, 즉 두번째 우선순위를 가진다.
    • persistenceUnit
      • 영속성 관리 단위를 설정해줄 수 있다.
      • @EnableJpaRepositories에서 basePackage로 잡아줌 으로써, 해당 패키지에 있는 JpaRepository를 (여기서는) restaurant persistenceUnit으로 잡아주었다.
  • fun dataSource
    • 사용할 데이터소스를 생성한다.
    • 이때, 반드시 ConfigurationProperties에 데이터소스 환경변수를 넣어줘야 한다.
  • fun entityManagerFactory
    • 앞에서 언급한EntityManagerFactory를 생성하기 위한 함수
    • EntityManager 내부에 있는 영속성 컨텍스트에 의해 엔티티가 관리된다.
    • @Qualifier에는 앞에서 등장한 dataSource 함수명을 넣으면 된다.
  • fun transactionManager
    • 스프링 트랜잭션 처리의 중심이 되는 인터페이스 (출처)
    • PlatformTransactionManager
      • TransactionManager의 최상위 인터페이스 
      • PlatformTransactionManager에 각자의 DB에 해당되는 TransactionManager 클래스를 의존주입 해준다.

 

이렇게 하면 첫번째 데이터소스 준비는 끝났다. 이제 두번째 데이터소스를 세팅해보자. 대부분은 첫번째와 동일하다. @Primary가 빠지는데 이는 우선순위가 뒤로 밀리기 때문이다.

 

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = ["com.baeman.repository"],
    entityManagerFactoryRef = "riderEntityManagerFactory",
    transactionManagerRef = "riderTransactionManager"
)
class RiderDataSourceConfig {
    @Bean
    @ConfigurationProperties("spring.datasource.rider")
    fun riderDataSource(): DataSource {
        val dataSource = DataSourceBuilder.create().build()
        return dataSource
    }

    @Bean
    fun riderEntityManagerFactory(
        builder: EntityManagerFactoryBuilder,
        @Qualifier("riderDataSource") dataSource: DataSource
    ): LocalContainerEntityManagerFactoryBean {
        return builder
            .dataSource(dataSource)
            .packages("com.baeman.rider.entity")
            .persistenceUnit("rider")
            .build()
    }

    @Bean
    fun riderTransactionManager(
        @Qualifier("riderEntityManagerFactory") entityManagerFactory: EntityManagerFactory
    ): PlatformTransactionManager {
        val transactionManager = JpaTransactionManager()
        transactionManager.entityManagerFactory = entityManagerFactory
        return transactionManager
    }
}

 

 

 

중간에 집중력 떨어진 블로그 주인장 그리고 당신에게 보내는 응원 메세지

 

 

 

두 개의 서로 다른 트랜잭션 관리자를 연결(Chained)하자

DB 구성은 끝났다. 이제 동시에 트랜잭션을 수행해야 한다. 하나의 데이터베이스 작업이 실패하면 2개의 데이터베이스 모두 롤백을 해야된다. 단일 데이터베이스라면 "@Transactional 어노테이션으로 충분했겠지만 이제는 아니다. transactionManager(식당용), riderTransactionManager(라이더용). 이 2개의 트랜잭션 매니저를 연결해보자.

 

import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.transaction.ChainedTransactionManager
import org.springframework.transaction.PlatformTransactionManager

@Configuration
class ChainedTransactionManagerConfig {

    @Bean(name = ["chainedTransactionManager"])
    fun chainedTransactionManager(
        @Qualifier("transactionManager") transactionManager: PlatformTransactionManager,
        @Qualifier("riderTransactionManager") riderTransactionManager: PlatformTransactionManager,
    ): PlatformTransactionManager {
        return ChainedTransactionManager(transactionManager, riderTransactionManager)
    }

}

 

앞에 등장했던 TransactionManager는 최상위 인터페이스인 PlatformTransactionManager을 사용했다. 그런데 여기서 새로운 TransactionManager가 등장한다. 이번 컨텐츠의 핵심인 ChainedTransactionManager이다.

 

  • 여러개의 트랜잭션 매니저를 하나로 묶어(Chain)사용하도록 지원하는 TransactionManager (출처)
  • 트랜잭션의 시작과 끝에서 연결된 트랜잭션들을 순차로 Start/Commit 시킴으로써 하나의 트랜잭션으로 실행되는것 처럼 동작

 

예시의 경우는 식당용인 TransactionManager와 라이더용인 riderTransactionManager를 연결하게 된다. 이제 이 트랜잭션 매니저를 활용해보자.

 

 

마지막은 역시 테스트

별도의 테스트 코드를 작성해도 되지만 이번에는 기존 서비스에 적용해서 테스트해보았다.

 

@Service
@Transactional(value = "chainedTransactionManager", rollbackFor = [Exception::class], readOnly = true)
class BaemanSampleService(
	...

 

  • value : 트랜잭션 매니저를 세팅한 bean 이름을 넣고
  • rollbackFor : rollback이 될 Throwable 리스트를 넣고
  • readOnly : 이번 파트와는 크게 상관있는 건 아니지만 @Transactional 어노테이션을 사용하지 않은 메소드는 읽기만 가능하도록 하였다.

 

이제 테스트용 메소드를 살펴보자.

 

@Transactional
fun updateSample() {
    val restaurant = restaurantRepository.findByIdOrNull(115)
    val rider = riderRepository.findByIdOrNull(7288)
    restaurant?.memo = Random.nextInt(100).toString()
    if (rider != null) {
        rider.memo = Random.nextInt(100).toString()
    } else {
        // Custom된 에러 클래스
        throw BaemanNotFoundError(errorMessage = "withdrawUser is null")
    }
}

 

restaurant에는 115번 ID가 존재한다. 그래서 memo 필드에 랜덤값이 들어갈 수 있다. 하지만 rider에는 7288번이 없다. 그래서 당연히 null이 되면서 Error가 뜨게 된다. 커스텀된 에러 클래스는 RuntimeException을 확장하여 만든 것이기에 rollbackFor에 들어간 Exception 클래스의 하위에 해당된다. 결국 rollback이 되어 restaurant도 memo에 값을 넣을 수 없게 된다.

 

 

 

 

 

그런데 약간 찝찝한거

ChainedTransactionManager는 현재 Deprecated되어 있다. 그래서 대안이 JtaTransactionManager이다. DataSource를 생성할 때 부터 트랜잭션을 전역적으로 관리가 가능하다. 그런데 이걸 사용하지 않은 이유는 JPA가 동시에 이용이 가능한지 확신이 없었기 때문. 현재 테스트하고 있는 환경이 오로지 JPA로 구성되어 있어서 JtaTransactionManager을 다룰려면 새로운 프로젝트를 만들어봐야 할 것 같다. JtaTransactionManager를 다루게 된다면 추후에 JPA와 함께 사용이 가능한지도 확인하여 블로그에 공유하겠다.

 

오늘은 여기까지~

 

 

참고자료
- (Spring)다중 DataSource 처리
- Spring transaction with multiple datasources

 

728x90
반응형