[개인 프로젝트(9)] 개발 환경 구축 - Springboot Multi DataSource 동적 라우팅 JPA

개발로그·2023년 11월 10일
0

개인 프로젝트

목록 보기
8/14
post-thumbnail

📌 이전 포스팅 보러가기


[개인 프로젝트(7)] 개발 환경 구축 - Docker Compose로 PostgreSQL Replication 구성
https://velog.io/@ieieie0419/%EA%B0%9C%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B87-%EA%B0%9C%EB%B0%9C-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95-Docker-Compose%EB%A1%9C-PostgreSQL-Replication-%EA%B5%AC%EC%84%B1







📌 application-dev.yml 파일 수정


1. Docker Compose Support 의존성 관련 설정

이전 포스팅인 스프링부트 프로젝트 생성 에서 추가했던 Docker Compose Support 의존성은 Docker Compose 명령을 Spring Boot 3에 위임할 수 있도록 하는 도구로,
Docker Compose 파일이 존재하는 것을 감지하고, 서비스에 연결하기 전에 자동으로 docker compose up 명령을 실행한다.

만약 서비스가 이미 실행 중이라면, 그 서비스에 연결한다.


별도의 설정을 하지 않으면, Docker Compose 파일이 존재하는 것을 감지하기 위해 기본적으로 docker-compose.yaml 파일을 찾는다.

개발 환경에서 어플리케이션을 실행할 때 방금 작성했던docker-compose-dev.yml 을 찾아 서비스에 연결하도록 설정하기 위해 application-dev.yml 파일을 다음과 같이 작성한다.



2. datasource 구성 정의

또한, docker-compose-dev.yml에서 구성한 main 데이터베이스와 standby 데이터베이스의 정보로 커넥션 풀을 생성하고
, 관리하기 위해 다음과 같이 datasource 구성을 정의한다.

spring:
  config:
    activate:
      on-profile: dev

  # 1. 
  docker:
    compose:
      file: docker-compose-dev.yml

  # 2. 
  datasource:
    main:
      hikari:
        driver-class-name: org.postgresql.Driver
        jdbc-url: jdbc:postgresql://localhost:5432/ourhour
        username: my_user
        password: my_password
    standby:
      hikari:
        driver-class-name: org.postgresql.Driver
        jdbc-url: jdbc:postgresql://localhost:5433/ourhour
        username: my_user
        password: my_password




📌 DataSourceConfiguration 파일 작성


✅ DataSourceConfiguration

두 개의 datasource를 구성하는 Java 설정 파일,
각 datasource는 HikariCP 커넥션 풀을 사용하여 PostgreSQL 데이터베이스에 연결한다.

package com.ourhours.server.global.config.database.postgresql;

import javax.sql.DataSource;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import com.zaxxer.hikari.HikariDataSource;

@Configuration
public class DataSourceConfiguration {

	public static final String MAIN_DATA_SOURCE = "MAIN_DATA_SOURCE";
	public static final String STANDBY_DATA_SOURCE = "STANDBY_DATA_SOURCE";

	@Bean(MAIN_DATA_SOURCE) // #1
	@ConfigurationProperties(prefix = "spring.datasource.main.hikari") // #2 
	public DataSource mainDataSource() { // #3
		return DataSourceBuilder
			.create()
			.type(HikariDataSource.class)
			.build();
	}

	@Bean(STANDBY_DATA_SOURCE) // # 1
	@ConfigurationProperties(prefix = "spring.datasource.standby.hikari") // #2
	public DataSource standbyDataSource() {  // #3
		return DataSourceBuilder
			.create()
			.type(HikariDataSource.class)
			.build();
	}

}


  • # 1
    Spring은 기본적으로 @Bean이 적용된 메소드의 이름을 Bean의 이름으로 사용한다.

    하지만, 가독성을 높이고 bean을 참조할 때 명시적으로 식별하기 위해 @Bean 어노테이션의 괄호 안에 이름을 명시했다.


  • # 2

    2-1. @ConfigurationProperties 어노테이션:
    설정 파일(application-dev.yml)의 값을 Java 클래스에 바인딩하기 위한 어노테이션이다.

    2-2. prefix 속성:
    이 설정이 어떤 프리픽스로 시작하는지 정의한다.

    2-3. 설정 바인딩 :
    spring.datasource.main.hikari 프리픽스로 시작하는 설정은 mainDataSource 메서드 내에서 사용되는 DataSource 빈의 속성으로 자동으로 바인딩된다.

  • # 3
    DataSourceBuilder 클래스를 사용하여 HikariCP 커넥션 풀을 타입으로 갖는 데이터 소스를 생성하고 반환한다.







📌 RoutingDataSource 파일 작성


AbstaractRoutingDataSource

Spring에서 제공하는 추상 클래스로,다양한 데이터 소스 중에서 어떤 데이터 소스를 사용하여 데이터베이스에 연결할지 Lookup Key키를 기반으로 동적으로 결정할 수 있도록 하는 클래스

docs.spring.io - AbstractRoutingDataSource





RoutingDataSource

AbstractRoutingDataSource를 상속받아, determineCurrentLookupKey() 추상 메소드가 현재 트랜잭션이 읽기 전용이라면 DataSourceType.STANDBY를, 읽기/쓰기가 전용이라면 DataSourceType.MAIN 를 반환하도록 구현한다.

이 반환 값은 다양한 데이터 소스 중에서 어떤 데이터 소스를 사용하여 데이터베이스에 연결할지 식별하는 Lookup Key가 된다.

package com.ourhours.server.global.config.database.postgresql;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public class RoutingDataSource extends AbstractRoutingDataSource {

	@Override
	protected Object determineCurrentLookupKey() {
		return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? DataSourceType.STANDBY :
			DataSourceType.MAIN;
	}
}




✅ DataSourceType

다양한 데이터 소스 중에서 어떤 데이터 소스를 사용하여 데이터베이스에 연결할지 식별하는 Lookup Key를 DataSourceType Enum에 정의한다

package com.ourhours.server.global.config.database.postgresql;

public enum DataSourceType {
	MAIN, STANDBY
}






📌 3. RoutingDataSourceConfiguration 파일 작성


✅ RoutingDataSourceConfiguration

  1. 위에서 작성한 RoutingDataSource 클래스의 객체를 생성하고, LookupKey에 따라 어떤 데이터 소스를 사용해 데이터베이스에 연결할지 Map 형태로 정의하여 Bean으로 등록한다.

  2. 다중 데이터 소스를 사용하는 환경에서 JPA를 설정 및 관리하기 위한 구성을 작성한다.

package com.ourhours.server.global.config.database.postgresql;

import static com.ourhours.server.global.config.database.postgresql.DataSourceConfiguration.*;

import java.util.HashMap;
import java.util.Map;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;

@EnableJpaRepositories(  // # 1
	basePackages = {"com.ourhours.server.domain.sample.repository"},
	entityManagerFactoryRef = "entityManagerFactory",
	transactionManagerRef = "transactionManager"
)
@Configuration
public class RoutingDataSourceConfiguration {

	private final String ROUTING_DATA_SOURCE = "ROUTING_DATA_SOURCE";
	private final String DATA_SOURCE = "DATA_SOURCE";

	@Bean(ROUTING_DATA_SOURCE)
	public DataSource routingDataSource(
		@Qualifier(MAIN_DATA_SOURCE) final DataSource mainDataSource,  // # 2
		@Qualifier(STANDBY_DATA_SOURCE) final DataSource standbyDataSource) {

		// # 3
		RoutingDataSource routingDataSource = new RoutingDataSource();

		// # 4
		Map<Object, Object> dataSourceMap = new HashMap<>();
		dataSourceMap.put(DataSourceType.MAIN, mainDataSource);
		dataSourceMap.put(DataSourceType.STANDBY, standbyDataSource);

		// # 5
		routingDataSource.setTargetDataSources(dataSourceMap);
		// # 6
		routingDataSource.setDefaultTargetDataSource(mainDataSource);

		return routingDataSource;
	}

	@Bean(DATA_SOURCE)
	public DataSource dataSource( // # 7
		@Qualifier(ROUTING_DATA_SOURCE) DataSource routingDataSource) {
		return new LazyConnectionDataSourceProxy(routingDataSource);
	}

	@Bean("entityManagerFactory")
	public LocalContainerEntityManagerFactoryBean entityManagerFactory( // # 8
		@Qualifier(DATA_SOURCE) DataSource dataSource) {
		// # 9
		LocalContainerEntityManagerFactoryBean entityManagerFactory
			= new LocalContainerEntityManagerFactoryBean();
		entityManagerFactory.setDataSource(dataSource);

		entityManagerFactory.setPackagesToScan("com.ourhours.server.domain.sample.domain.entity");

		entityManagerFactory.setJpaVendorAdapter(this.jpaVendorAdapter());

		entityManagerFactory.setPersistenceUnitName("entityManager");
		return entityManagerFactory;
	}

	private JpaVendorAdapter jpaVendorAdapter() {
		HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
		hibernateJpaVendorAdapter.setGenerateDdl(false);
		hibernateJpaVendorAdapter.setShowSql(false);
		hibernateJpaVendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
		return hibernateJpaVendorAdapter;
	}

	@Bean("transactionManager")
	public PlatformTransactionManager platformTransactionManager( // # 10
		@Qualifier("entityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory) {
		JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
		jpaTransactionManager.setEntityManagerFactory(entityManagerFactory.getObject());
		return jpaTransactionManager;
	}

}


  • # 1
    Spring Data JPA를 활성화하고, 지정된 패키지에서 JPA 저장소 인터페이스를 찾아 빈으로 등록한다.

    또한, 지정된 JPA 엔터티 매니저 팩토리와 트랜잭션 매니저를 사용하여 데이터베이스 트랜잭션을 관리한다.

  • # 2

    • @Qualifier(): 동일한 타입(DataSource)의 Bean 이 여러 개 있을 때 어떤 Bean을 주입할 지 지정한다.

  • # 3
    위에서 작성한 RoutingDataSource 클래스의 객체를 생성한다.


  • # 4
    RoutingDataSourcesetTargetDataSources() 메서드에 인자로 전달될 Map 객체를 생성한다.

    이때 키는 식별자(LookupKey), 값은 데이터 소스이다.

    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
    		this.targetDataSources = targetDataSources;
    	}

  • # 5
    4번에서 생성한 Map 객체를 setTargetDataSources() 메서드에 인자로 전달하여 실제 사용 가능한 데이터 소스들을 등록한다.

  • # 6
    기본적으로 사용할 데이터 소스를 지정한다.

  • # 7
    ROUTING_DATA_SOURCE를 감싸는 LazyConnectionDataSourceProxy를 생성하여 반환한다.

    • LazyConnectionDataSourceProxy
      : 커넥션을 지연 생성하기 때문에 트랜잭션 내에서 실제로 데이터베이스 작업이 발생하는 시점에 커넥션을 획득한다.
  • # 8
    @Qualifier로 #7번에서 생성한 Bean을 인자로 주입받는다.

    즉, AbstractRoutingDataSource를 상속하여 트랜잭션이 ReadOnly일 때는 STANDBY를, 그 외에는 MAIN을 LookUpKey로 사용하도록 설정한

    RoutingDataSource 클래스를 객체로 생성해 LookUpKey가 STANDBY일때는 읽기 전용 데이터베이스의 데이터 소스를, MAIN일 때는 메인 데이터베이스의 데이터 소스를 사용하도록 구성하여 컨테이너에 등록한
    ROUTING_DATA_SOURCE Bean을

    LazyConnectionDataSourceProxy에 감싸서 컨테이너에 등록한 DATA_SOURCE Bean을 인자로 주입받는 것이다.

    (글로 쓰니 매우 복잡해보인다 ㅋㅋㅋ..)


  • # 9
    LocalContainerEntityManagerFactoryBean 클래스의 객체를 생성한다.

    • LocalContainerEntityManagerFactoryBean
      : JPA를 사용하여 엔터티 매니저 팩토리를 생성하고 구성하기 위한 Bean 클래스로, JPA 엔티티 매니저를 생성하고, 데이터 소스와의 연동, 엔터티 스캔, JPA 속성 설정 등을 수행한다.




📌 결론


나중에 다른 프로젝트를 하면서 다시 Replication을 구성하게 될 때, 참고하기 위해 자세히 서술하다보니 생각보다 포스팅이 길어졌다.

데드라인이 존재하는 팀 프로젝트였다면, 이렇게 하나씩 뜯어보며 어떻게 동작하는 건지 공부하면서 코드를 작성하기엔 어려웠을 것이다.

그냥 그때그때 작성하고 금방 잊는 코드를 작성하던 때보다 이렇게 하나씩 정리하면서 코드를 작성하는 지금이 개발 시간이 훨씬 더 많이 소모되지만, 지금 당장 시간을 더 쓰는 대신 이렇게 쌓인 시간들이 나중에는 오히려 어려운 문제를 해결하는 방향 키가 되어주리라 믿는다!



이미 테스트 코드를 작성해 잘 동작하는 것을 확인하고 올린 코드이지만, 현재는 개발용 데이터베이스를 사용해 테스트를 하고 있다.

다음 포스팅에서는 TestContainers에 대해 자세히 알아보고, TestContainers를 사용해 테스트 환경을 구축하는 과정을 기록할 것이다!

그때,

0개의 댓글