[개인 프로젝트(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 커넥션 풀을 타입으로 갖는 데이터 소스를 생성하고 반환한다.
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;
}
}
다양한 데이터 소스 중에서 어떤 데이터 소스를 사용하여 데이터베이스에 연결할지 식별하는 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를 사용해 테스트 환경을 구축하는 과정을 기록할 것이다!
그때,