상황
DB를 2개를 쓰고, 하나를 primary db로 읽기+쓰기 작업을, 나머지 하나는 replica db로 읽기 작업만 하는 DB로 구성이 되었을 때, 매번 기능을 만들 때마다 읽기/쓰기를 구분해서 데이터소스를 가져오기보다, 어노테이션의 readOnly 속성으로 알아서 데이터소스를 구분하도록 하고 싶었다.
spring:
datasource:
hikari:
main:
driver-class-name: org.postgresql.Driver
jdbc-url: jdbc:postgresql://localhost:54326/sample
username: sample
password: 1234
sub:
driver-class-name: org.postgresql.Driver
jdbc-url: jdbc:postgresql://localhost:54327/sample
username: sample
password: 1234
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
show_sql: true # sql 로깅
format_sql: true # SQL 문 정렬하여 출력
highlight_sql: true # SQL 문 색 부여
use_sql_comments: true # 콘솔에 표시되는 쿼리문 위에 어떤 실행을 하려는지 HINT 표시
application.yml 설정은 위와 같다. spring.datasource.hikari의 main, sub로 나뉜 부분에서, jdbc-url의 포트 부분만 보면 된다.
primary는 54326, replica는 54327 포트라는 것만 기억해두면 된다.
해결
public enum DataSourceType {
READ_WRITE,
READ_ONLY
}
우선 데이터소스 타입을 읽기쓰기가 모두 가능한 READ_WRITE, 읽기만 가능한 READ_ONLY 2개를 가지는 열거형 타입을 만들었다.
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class TransactionRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? DataSourceType.READ_ONLY
: DataSourceType.READ_WRITE;
}
}
그다음, AbstractRoutingDataSource를 상속받는 커스텀 데이터소스를 만들고, determieCurrentLookupKey를 오버라이딩 해서, 현재 트랜잭션의 readOnly 여부에 따라 위의 열거형 타입을 다르게 해 주었다.
import com.zaxxer.hikari.HikariDataSource;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
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.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
@Configuration
public class DataSourceConfig {
/**
* 메인 DB 데이터소스 (읽기 + 쓰기)
*/
@Bean
@ConfigurationProperties(prefix = "spring.datasource.hikari.main")
public DataSource mainDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
/**
* 서브 DB 데이터소스 (읽기 전용)
*/
@Bean
@ConfigurationProperties(prefix = "spring.datasource.hikari.sub")
public DataSource subDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
/**
* 라우팅 데이터소스 - readOnly 속성에 따라 라우팅
*/
@Bean
@DependsOn({"mainDataSource", "subDataSource"})
public DataSource routingDataSource(
@Qualifier("mainDataSource") DataSource mainDataSource,
@Qualifier("subDataSource") DataSource subDataSource
) {
TransactionRoutingDataSource routingDataSource = new TransactionRoutingDataSource();
Map<Object, Object> dataSourceMap = Map.of(
DataSourceType.READ_WRITE, mainDataSource,
DataSourceType.READ_ONLY, subDataSource
);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(mainDataSource);
return routingDataSource;
}
/**
* 데이터소스 프록시 - 트랜잭션 진입 후 readOnly 속성에 따라 데이터소스 결정
*/
@Bean
@Primary
@DependsOn({"routingDataSource"})
public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
코드가 긴데, 우선 맨 위의 2개의 데이터소스는 application.yml 파일의 속성값을 가져와서 HikariDataSource를 만들어주는 코드다.
다음의 routingDataSource는 readOnly 유무에 따른 데이터소스를 라우팅하는 데이터소스다.
마지막은 스프링은 트랜잭션 진입 시 모든 데이터소스에 커넥션을 가져오고 이후에 데이터소스를 결정한다. 이러면 메인과 서브 모두 커넥션을 가져온다. (이에 대한 내용은 내 다른 글에서 확인할 수 있다.)
그래서 이를 실제 DB요청할 때 커넥션을 점유하도록 하고, 그 전에 데이터소스를 결정하도록 LazyConnectionDataSourceProxy로 감싸주었다.
import ex.multisource.domain.Product;
import ex.multisource.dto.ProductCreateReq;
import ex.multisource.repository.ProductRepository;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j(topic = "product-service")
@Service
@RequiredArgsConstructor
public class ProductService {
private final DataSource dataSource;
private final ProductRepository productRepository;
@Transactional(readOnly = true)
public Product getProduct(Long id) {
logDataSourceInfo();
return productRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Product not found"));
}
@Transactional
public Product saveProduct(ProductCreateReq request) {
logDataSourceInfo();
Product product = Product.create(request.name(), request.price());
return productRepository.save(product);
}
private void logDataSourceInfo() {
try (Connection connection = dataSource.getConnection()) {
log.info("Connected to database: {}", connection.getMetaData().getURL());
} catch (SQLException e) {
log.error("Failed to log DataSource information", e);
}
}
}
이제 테스트로 @Transactional 에 reradOnly에 따라 다른 DB로 요청되는지 확인해 보자.
커넥션의 요청 url로 확인할 거고, 위의 yml 파일에서 설정한 대로 54326이면 primary db, 54327이면 replica db이다.
INFO 23092 --- [multi-source-service] [nio-8080-exec-1] product-service : Connected to database: jdbc:postgresql://localhost:54326/sample
[Hibernate]
/* insert for
ex.multisource.domain.Product */insert
into
products (name, price)
values
(?, ?)
returning id
INFO 23092 --- [multi-source-service] [nio-8080-exec-3] product-service : Connected to database: jdbc:postgresql://localhost:54327/sample
[Hibernate]
select
p1_0.id,
p1_0.name,
p1_0.price
from
products p1_0
where
p1_0.id=?
첫 번째로 insert 요청(product 생성)은 jdbc:postgresql://localhost:54326/sample로 54326 포트로 primary db로 요청이 갔다.
두 번째로 select 요청(product 조회)은 jdbc:postgresql://localhost:54327/sample로 54327 포트로 replica db로 요청이 갔다.
잘 작동하는 것을 볼 수 있다!!
'TIL ✍️' 카테고리의 다른 글
TIL #124 : Spring Data Redis에서 Lua Script 사용하기 (1) | 2024.11.18 |
---|---|
TIL #123 : 배치 작업에는 꼭 정렬 하기 (0) | 2024.11.17 |
TIL #121 : 스프링 배치 5버전에서 멀티 dataSource 중 하나 지정하기 (0) | 2024.11.16 |
TIL #120 : 멀티 데이터소스 환경에서 이중 커넥션 점유 막기 (1) | 2024.11.15 |
TIL #119 : Jacoco 테스트 커버리지 항목에 롬복이 생성한 코드 무시하기 (0) | 2024.11.13 |