실무를 진행하다 보면 하나의 프로젝트에서 여러 DB를 참조해 데이터를 CRUD해야 하는 상황이 생긴다. 보통 하나의 프로젝트에는 한 개의 DB만 연결하는데, 어떻게 다른 DB의 정보를 가져오고 조작할 수 있을까?

1. Multi Data Source

멀티 데이터 소스는 하나의 application에서 여러개의 DB(스키마)에 연결하는 것을 말합니다. 서로 다른 DBMS를 사용해도 되고 application 프로필 마다 설정할 수 도 있고, 같은 서버가 아니어도 됩니다. 이 문서에서는 두 개를 예시로 다루지만 이론상 n개의 DB를 연결할 수 있습니다.

2.QueryDsl

QueryDsl은 JPA가 기본적으로 제공 해 주는 CRUD메서드가 아닌 사용자가 임의로 쿼리문을 날릴 수 있는 nativeQuery 혹은 JPQL기능의 중간정도에 있는 SQL쿼리 생성 프레임워크입니다.

기본 JPA가 제공하는 메서드는 직관적이고, 미리 만들어주기 때문에 사용성이 좋지만 다양한 조건을 가진 쿼리문을 날릴 때는 메서드명이 무한정으로 길어지게 되고 복잡한 쿼리문을 날릴 수 없습니다.
이를 해결하고자 JPQL, Native Query를 사용하는데 문법적 오류나 오타 등에 취약합니다.

// nativeQuery의 예

@Query(value="select * from table", nativeQuery = true)
fun method() : MutableList<List<Any>>

반면 QueryDsl은 JPA의 사용성과 JPQL의 자유도를 섞어놓은 프레임워크이기 때문에

  • 문자가 아닌 쿼리로 코드를 작성함으로서 문법적 오류를 최소화
  • IDE 기능인 자동완성
  • 동적쿼리의 작성이 가능
  • 다른 Class와 연결해 새로운 메서드를 추출하거나 참조

등등의 장점이 있습니다.

서브쿼리를 작성할 수 없다는 단점이 있지만 서브쿼리가 필요한 메서드만 네이티브 쿼리로 작성하고, 나머지 비즈니스 로직은 모두 QueryDsl에서 작성할 수 있습니다.

3. QueryDslSupport

이처럼 편리한 QueryDsl을 사용할 수 있도록 해 주는 클래스가 QueryDslSupport입니다. dependency를 주입해서 사용할 수 있습니다. 아래 순서대로 작성해 나가면 됩니다.

4. 설정

#### 1. 모듈로 생성하는 방법 -> 4-2)를 참조하십시오.

이 방법은 Tomcat서버 기반으로 실행하거나(로컬에서 Run Application) WAR 파일로 빌드 할 경우만 해당됩니다. JAR파일로 빌드 할 경우 JAR 실행시 Primary가 아닌 DB정보와 QueryDsl 정보를 Bean으로 읽어들이지 못합니다.(22.07.08 발견) 추후 조치시 해결방법 기술하겠습니다.

먼저 멀티 데이터 소스 사용을 위해 한 개의 모듈을 추가로 생성 해 줍니다.

위치는 아무곳이나 원하는대로, 혹은 domain 모듈이 모여있는 디렉토리나 모듈 안에 Spring Initializer로 생성 해 주면 됩니다. 단, root Project가 설정 된 경로에는 가급적 생성하지 않습니다.

생성 후 위와 같은 디렉토리 구조로 생성 해 줍니다.

  • root Project에 멀티모듈로 삽입해야 하기 때문에 bundle.grade 이외의 모든 gradle 관련 클래스, 패키지 등등을 삭제 해 줍니다.

  • 보조 도메인은 많은 패키지나 디렉토리를 가질 이유가 없습니다. 엔티티를 정의하는 model과 비즈니스 로직을 처리하기 위한 repository 두 가지만 생성 해 줍니다.

다음은 root Project의 setting.gradle에 방금 만든 모듈을 include 해 줍니다.

메인 모듈인 api의 bundle.gradle에도 보조 도메인 모듈을 implementation해 줍니다. 그래야 api가 보조 도메인을 참조할 수 있습니다.

2. bundle.gradle

QueryDsl은 다른 것 들과 마찬가지로 버전을탑니다. Gradle이나 인텔리제이, Spring등등의 버전이 서로 맞지 않으면 호환되지 않을 수 있습니다.

plugins {
  ...(생략)
    id("com.ewerk.gradle.plugins.querydsl") version "1.0.10"
    kotlin("kapt")
}

먼저 플러그인에 queryDslkapt 플러그인을 받아줍니다. kapt플러그인은 코틀린의 어노테이션 프로세서입니다. 또한 kapt가 없으면 queryDsl을 사용하기 위해 생성되는 QClass를 생성할 수 없습니다.

참고로 kapt를 사용하면 lombok의 어노테이션들을 사용할 수 없는데, 코틀린을 사용하면 롬복을 사용 할 필요가 없으니 무시하고 설치해도 됩니다. 롬복의 어노테이션이 제공하는 기능을 클래스 수준에서 해결할 수 있습니다.

dependencies {
    implementation("com.querydsl:querydsl-jpa")
    kapt("com.querydsl:querydsl-apt:4.4.0:jpa")
}

디펜던시도 두 개 추가 해 줍니다.

val generatedQuerydslDir = "$buildDir/generated/source/kapt/main"

querydsl {
    library = "com.querydsl:querydsl-apt"
    jpa = true
    querydslSourcesDir = generatedQuerydslDir
}

sourceSets["main"].withConvention(KotlinSourceSet::class) {
    kotlin.srcDir(generatedQuerydslDir)
}

마지막은 위에서 언급한 QClass를 생성하는 경로변수 generatedQuerydslDir, queryDsl 기타 설정을 적어줍니다.

여기까지의 bundle.gradle 설정은 queryDsl이 필요한 패키지or모듈에만 작성 해 주면 됩니다. 메인 도메인, 보조 도메인 모듈에만 작성해도 됩니다.

1 ~ 2번을 다 끝냈다면 gradle build를 실행 해 줍니다.

4-2) Domain 모듈 안에 새로운 디렉토리로 Model과 Repository를 구성하는 경우

기존에 작성했던 Model / Repository와 같은 형식으로 디렉토리만 추가해서 작성합니다. class 내부 코드와 로직이 예상되지 않는다면.. 5번을 참조하십시오!

3. db 프로필 설정

DB를 한 개만 연결했을 때

spring:
  datasource:
    url: jdbc:mysql://연결IP:포트/스키마 or DataBase?serverTimezone=Asia/Seoul&zeroDateTimeBehavior=convertToNull
    username: id
    password: pw
    driverClassName: com.mysql.cj.jdbc.Driver

이렇게 작성했다면, 두 개이상을 설정 할 때는

primary:
  datasource:
    url: jdbc:mysql://첫 번째 연결IP:포트/스키마 or DataBase?serverTimezone=Asia/Seoul&zeroDateTimeBehavior=convertToNull
    username: id
    password: pw
    driverClassName: com.mysql.cj.jdbc.Driver
secondary:
  datasource:
    url: jdbc:mysql://두 번째 연결IP:포트/스키마 or DataBase?serverTimezone=Asia/Seoul&zeroDateTimeBehavior=convertToNull
    username: id
    password: pw
    driverClassName: com.mysql.cj.jdbc.Driver

이렇게 primary, secondary 등등의 별칭 아래 datasource를 작성 해 줍니다.
spring: 설정 하위에 두지 않아도 됩니다. 추후 데이터 config 클래스에서 별칭을 따라가기 때문입니다.

각 application 프로필 마다 위처럼 설정 해 줍니다.

4. JPA(DataBase) Config

지금부터 설정하는 모든 Config 파일은 primary, secondary 각각 한 개씩 작성 해 줍니다. 3개 이상이라면 3개를 작성합니다.

PrimaryJpaConfig

@Configuration // 설정파일 어노테이션
@EnableTransactionManagement // 트랜잭션 범위를 활성화 해 주는 어노테이션
@EnableJpaRepositories( // 특정 repository 패키지를 찾아 어떤 트랜잭션에 어떤 repo를 연결할 지 설정 해 주는 어노테이션
    entityManagerFactoryRef = "primaryEntityManagerFactory",
    transactionManagerRef = "primaryTransactionManager",
    basePackages = ["kr.co....repository"]) // repository 경로
class PrimaryJpaConfig () {

    @Primary // 반드시!! Primary 어노테이션을 달아줘야 합니다. 아래에서 추가로 설명합니다.
    @Bean // 모두 Bean으로 등록해 Spring에서 AutoWired 될 수 있도록 해 줍시다.
    @ConfigurationProperties("primary.datasource") // primary라는 별칭 아래의 datasource를 찾아가게 됩니다.
    fun primaryDataSourceProperties(): DataSourceProperties? {
        return DataSourceProperties()
    }

    @Primary
    @Bean
    @ConfigurationProperties("primary.datasource.configuration") // primary 별칭 아래의 DB 설정을 참조할 수 있게 해 줍니다.
    fun primaryDataSource(@Qualifier("primaryDataSourceProperties") dataSourceProperties: DataSourceProperties): DataSource? {
        return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource::class.java).build()
    }

    @Primary
    @Bean
    fun primaryEntityManagerFactory( // EMF를 생성합니다.
        builder: EntityManagerFactoryBuilder,
        @Qualifier("primaryDataSource") dataSource: DataSource?
    ): LocalContainerEntityManagerFactoryBean? {
        return builder
            .dataSource(dataSource)
            .packages("kr.co...domain") // 도메인 패키지 경로
            .persistenceUnit("primaryEntityManager") // EM을 생성합니다.
            .build()
    }

    @Primary
    @Bean
    fun primaryTransactionManager( // 트랜잭션 매니저를 생성합니다.
    @Qualifier("primaryEntityManagerFactory") entityManagerFactory: EntityManagerFactory?): PlatformTransactionManager? {
        return JpaTransactionManager(entityManagerFactory!!)
    }

}

기본적인 JPA의 EMF, EM 등을 설정하는 것 과 같은데 Primary 어노테이션을 꼭 달아줘야 합니다.

Primary 어노테이션은 여러개의 중복되는 Bean들 중에서 어떤 Bean을 먼저 적용할 지 우선순위를 알려주는 어노테이션 입니다. 멀티 데이터소스 설정에서는 DB를 여러개 설정하기 때문에, 이에 따른 EMF, EM이 각 DB마다 다르게 연결되어야 하므로 메인이 되는 DB Config에 Primary를 달아줍니다.

Qualifier 어노테이션은 Primary와 다르게 우선순위가 아닌, 스프링 컨테이너가 여러개의 Bean을 찾았을 때 추가정보를 제공 해 줍니다.

요약하면

  • 먼저 컨테이너에 올리고 싶은 Bean에는 Primary
  • 여러 Bean들 중 무언가를 특정하고 싶을 때는 @Qualifier("찾는이름")을 씁니다.

따라서 Secondary Config 부터는 Primary 어노테이션과 각 Bean의 이름을 제외하고는 모든 구성이 똑같습니다.

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    entityManagerFactoryRef = "secondaryEntityManagerFactory",
    transactionManagerRef = "secondaryTransactionManager",
    basePackages = ["kr.co...subdomain"]
)
class SecondaryJpaConfig() {

    @Bean
    @ConfigurationProperties("secondary.datasource")
    fun secondaryDataSourceProperties(): DataSourceProperties? {
        return DataSourceProperties()
    }

    @Bean
    @ConfigurationProperties("spring.datasource.configuration")
    fun secondaryDataSource(@Qualifier("secondaryDataSourceProperties") dataSourceProperties: DataSourceProperties): DataSource? {
        return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource::class.java).build()
    }

    @Bean
    fun secondaryEntityManagerFactory(
        builder: EntityManagerFactoryBuilder,
        @Qualifier("secondaryDataSource") dataSource: DataSource?
    ): LocalContainerEntityManagerFactoryBean? {
        return builder
            .dataSource(dataSource)
            .packages("kr.co...subDomain")
            .persistenceUnit("secondaryEntityManager")
            .build()
    }

    @Bean
    fun secondaryTransactionManager(
    @Qualifier("secondaryEntityManagerFactory") entityManagerFactory: EntityManagerFactory?): PlatformTransactionManager? {
        return JpaTransactionManager(entityManagerFactory!!)
    }

}

4. QueryDslSupper Config

QueryDslSupport도 JPA 설정처럼 메인, 보조 설정을 각각 해 주어야 합니다.

DB가 한 개일 때는

class MemberRepositoryImpl : QueryDslSupport(Member::class.java), MemberRepositoryCustom
/*
* 코틀린에서 콜론(:)은 리턴타입(클래스)이고 쉼표(,)로 다른 class를 상속받을 수 있다
* 이처럼 클래스에서 두 개를 열거하는 것은 콜론 뒤에 확장할 클래스,
* 쉼표 뒤에는 구현할 인터페이스를 뜻한다.
*/ 

이렇게 바로 사용하면 됐었지만 QueryDslSupport Java Class의 내부를 보면

@Repository
public abstract class QuerydslRepositorySupport {

	private final PathBuilder<?> builder;

	private @Nullable EntityManager entityManager;
	private @Nullable Querydsl querydsl;
	
	public QuerydslRepositorySupport(Class<?> domainClass) {

		Assert.notNull(domainClass, "Domain class must not be null!");
		this.builder = new PathBuilderFactory().create(domainClass);
	}

	@Autowired
	public void setEntityManager(EntityManager entityManager) {

		Assert.notNull(entityManager, "EntityManager must not be null!");
		this.querydsl = new Querydsl(entityManager, builder);
		this.entityManager = entityManager;
	}
	...(생략)

setEntityManager가 EM을 받아 queryDsl을 사용할 수 있도록 해 주는걸 확인할 수 있습니다.

따라서 DB가 한 개일 때는 EM도 한 개 이므로 별다른 설정 없이 QueryDslSupport자체를 리턴 클래스로 사용하면 되지만, 두 개 이상일 때는 이 QueryDslSupport클래스를 Custom해서 엔티티 매니저를 변경 해 줘야 합니다.
그래야 메인 DB를 사용할 때는 메인 EM을, 보조 DB를 사용 할 때는 보조 EM을 호출해 사용할 수 있기 때문입니다.

PrimaryQueryDslSupport

@Repository // 모든 비즈니스 로직이 이 클래스를 거쳐가기 때문에 필수로 달아줍니다.
abstract class PrimaryQueryDslSupport(domainClass: Class<*>) : QuerydslRepositorySupport(domainClass) {
// QuerydslRepositorySupport는 도메인 클래스를 받아 로직을 처리하기 때문에 모든 Entity path를 받을 수 있는 Class<*>를 작성 해 준다.

    private var queryFactory: JPAQueryFactory by Delegates.notNull()

    @PersistenceContext(unitName = "primaryEntityManager")
    override fun setEntityManager(entityManager: EntityManager) {
        super.setEntityManager(entityManager)
        this.queryFactory = JPAQueryFactory(entityManager)
    }

}

PrimaryQueryDslSupport를 abstract로 선언하고 QuerydslRepositorySupport를 리턴클래스로 정의해 줍니다. 이렇게 되면 setEntityManager를 오버라이드 해서 우리가 원하는 EM을 설정 해 줄 수가 있게 됩니다.

PersistenceContext 어노테이션에 위에서 설정한 EM의 이름을 적어주면,
이 PrimaryQueryDslSupport를 리턴 클래스로 하는 모든 Repository는 메인 EM을 통해 트랜잭션을 수행하게 됩니다.

private var queryFactory: JPAQueryFactory by Delegates.notNull()

이 부분은 delegate를 통해 JPAQueryFactory를 이용, QueryDsl의 기능을 확장하는데 사용합니다. 필수 설정은 아니지만 queryFactory를 위임하게 되면 원래 from~ 절로만 사용해야 했던 QueryDsl의 SQL문을

override fun findUseFrom(targetAmount: BigDecimal): List<Payment> {
        return from(qPayment)
            .where(qPayment.amount.gt(targetAmount))
            .fetch()
    }

    override fun findUseSelectFrom(targetAmount: BigDecimal): List<Payment> {
        return selectFrom(qPayment)
            .where(qPayment.amount.gt(targetAmount))
            .fetch()
    }

    override fun findUseSelect(targetAmount: BigDecimal): List<Long> {
        return select(qPayment.id)
            .from(qPayment)
            .where(qPayment.amount.gt(targetAmount))
            .fetch()
    }

참조: https://minkukjo.github.io/study/docs/spring/jpa/kotlin-jpa-guide/

이렇게 커스텀 해서 사용 할 수가 있게 됩니다. JPAQueryFactory자체가 JPQL을 기반으로 쿼리문을 날릴 수 있게 해 주는 프레임워크이기 때문입니다.

@Repository
abstract class SecondaryQueryDslSupport(domainClass: Class<*>) : QuerydslRepositorySupport(domainClass) {

    private var queryFactory: JPAQueryFactory by Delegates.notNull()

    @PersistenceContext(unitName = "secondaryEntityManager")
    override fun setEntityManager(entityManager: EntityManager) {
        super.setEntityManager(entityManager)
        this.queryFactory = JPAQueryFactory(entityManager)
    }

}

SecondaryQueryDslSupport도 똑같이 설정하되 영속성 컨텍스트의 이름만 구분해서 적어줍니다.

5. 실제 Repository에 적용

(main) MemberRepository

class MemberRepositoryImpl : PrimaryQueryDslSupport(Member::class.java), MemberRepositoryCustom {
// ::class.java 는 코틀린의 KClass를 Java클래스로 컴파일 해 주는 문법입니다.

    private val qMember = QMember.member

    override fun getByMemberId(memberId: String): Member {
        return from(qMember)
            .where(
                qMember.deleted.isFalse,
                qMember.memberId.eq(memberId)
            )
            .fetchOne() ?: throw RuntimeException("요청하신 사용자는 존재하지 않습니다.")
            // 코틀린의 ?: 기호는 elvis 문법으로, 좌측이 null을 return하면 우측을 return해 줍니다. 삼항연산자가 아닙니다.
    }

이렇게 메인 Repo에는 PrimaryQueryDslSupport를 리턴 클래스로 하고 Member class를 담아줍니다. 아래는 QueryDsl 문법을 참조해 원하는 로직을 적어줍시다.

(sub) SubMemberRepository

class SubMemberRepositoryImpl: SecondaryQueryDslSupport(SubMember::class.java), SubMemberRepositoryCustom {

    private val qSubMember =  QSubMember.subMember

    override fun someMethods(): Member {
        return from(qSubMember)
            .where(
                some logic
            )
            .fetchOne()
    }
}

메인 Repo와 다른점은 리턴 클래스가 SecondaryQueryDslSupport라는 점 밖에 없습니다.

이 다음부터는 Service단에서 원하는 Repository를 불러와 사용하면 됩니다. 이미 Repo단에서 트랜잭션을 분리하고 있기 때문에 Service부터는 메인과 보조를 나눌 필요가 없습니다. 다만 조금 더 명시적으로 사용하려면 하나의 Service안에 메인, 보조 디렉터리를 구분하는 것이 권장됩니다.

참조: https://programmer-chocho.tistory.com/78
https://stackoverflow.com/questions/44975605/spring-boot-data-jpa-multiple-datasources-entitymanagerfactory-error

profile
웹 개발자(FE / BE) anna입니다.

0개의 댓글

Powered by GraphCDN, the GraphQL CDN