
[개인 프로젝트(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
이전 포스팅인 스프링부트 프로젝트 생성 에서 추가했던 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 파일을 다음과 같이 작성한다.
또한, 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
두 개의 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 커넥션 풀을 타입으로 갖는 데이터 소스를 생성하고 반환한다.
AbstaractRoutingDataSourceSpring에서 제공하는 추상 클래스로,다양한 데이터 소스 중에서 어떤 데이터 소스를 사용하여 데이터베이스에 연결할지 Lookup Key키를 기반으로 동적으로 결정할 수 있도록 하는 클래스
docs.spring.io - AbstractRoutingDataSource
RoutingDataSourceAbstractRoutingDataSource를 상속받아, 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;
}
}
다양한 데이터 소스 중에서 어떤 데이터 소스를 사용하여 데이터베이스에 연결할지 식별하는 Lookup Key를 DataSourceType Enum에 정의한다
package com.ourhours.server.global.config.database.postgresql;
public enum DataSourceType {
MAIN, STANDBY
}
위에서 작성한 RoutingDataSource 클래스의 객체를 생성하고, LookupKey에 따라 어떤 데이터 소스를 사용해 데이터베이스에 연결할지 Map 형태로 정의하여 Bean으로 등록한다.
다중 데이터 소스를 사용하는 환경에서 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
RoutingDataSource의 setTargetDataSources() 메서드에 인자로 전달될 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나중에 다른 프로젝트를 하면서 다시 Replication을 구성하게 될 때, 참고하기 위해 자세히 서술하다보니 생각보다 포스팅이 길어졌다.
데드라인이 존재하는 팀 프로젝트였다면, 이렇게 하나씩 뜯어보며 어떻게 동작하는 건지 공부하면서 코드를 작성하기엔 어려웠을 것이다.
그냥 그때그때 작성하고 금방 잊는 코드를 작성하던 때보다 이렇게 하나씩 정리하면서 코드를 작성하는 지금이 개발 시간이 훨씬 더 많이 소모되지만, 지금 당장 시간을 더 쓰는 대신 이렇게 쌓인 시간들이 나중에는 오히려 어려운 문제를 해결하는 방향 키가 되어주리라 믿는다!
이미 테스트 코드를 작성해 잘 동작하는 것을 확인하고 올린 코드이지만, 현재는 개발용 데이터베이스를 사용해 테스트를 하고 있다.
다음 포스팅에서는 TestContainers에 대해 자세히 알아보고, TestContainers를 사용해 테스트 환경을 구축하는 과정을 기록할 것이다!
그때,