context 기반의 실제 Datasource를 동적으로 정의하는 방법으로 AbstractRouting Datasource를 사용하려한다. 모니터링 관련 개발을 하는데 Postgres는 Database마다 독립된 결과가 나오는 모니터링 쿼리가 존재했다. 그래서 Database마다 커넥션을 독립적으로 맺어야 하는 상황이라
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
AbstractRoutingDatasource 라우팅할 실제 Datasource정보(일반적으로 Context라 부름)가 필요하다. Context는 모든 객체가 될 수 있지만 Enum을 사용한다.
public enum ClientDatabase {
AGENS, MYSQL
}
context holer는 현재 context를 ThreadLocal에 저장하는 역할이다. 추가적으로 set, get, clear 메소드는 static으로 선언한다. AbstractRoutingDatasource는 Context에 해당하는 ContextHolder를 찾고(quey란 단어), 찾아낸 ContextHolder로 실제 Datasource를 가져온다.
context가 현재 실행중인 스레드에 바인딩 될 수 있도록 ThreadLocal 사용하는게 정말 중요하다 .
It's essential to take this approach so that behavior is reliable when data access logic spans multiple data sources and uses transactions
데이터 접근 로직이 여러 datasource, 트랜잭션은 사용할 때 신뢰할 수 있는 접근방법이다 정도로 의역
package com.example.abstractroutingdatasource;
import com.example.abstractroutingdatasource.config.ClientDatabase;
import org.springframework.util.Assert;
public class ClientDatabaseContextHolder {
private static ThreadLocal<ClientDatabase> CONTEXT = new ThreadLocal<>();
public static void set(ClientDatabase clientDatabase) {
Assert.notNull(clientDatabase, "clientDatabase cannot be null");
CONTEXT.set(clientDatabase);
}
public static ClientDatabase getClientDatabase() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
Spring의 AbstractRoutingDatasource를 상속하여ClientDataSourceRouter를 정의한다. 적절한 key를 return하고 ClientDatabaseContext를 찾고(query) determineCurrentLookupKey라는 메소드를 오버라이딩해야 한다.
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class ClientDataSourceRouter extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return ClientDatabaseContextHolder.getClientDatabase();
}
}
AbstractRoutingDatasource의 환경을 설정하기 위해서는 Datasource객체에 대한 context map이 필요하다. context set이 없을 경우를 대비한 default Datasource도 정의해야 한다. 우리가 사용하는 Datasource는 어디에서나 가져올 수 있지만 일반적으로는 런타임시에 생성되거나 JNDI를 사용하여 조회된다.
라고 나와있지만 코드가 전부 있지 않고 빠져있다.
여기서부터는 저의 개인적인 스타일로 작성합니다.
import com.example.abstractroutingdatasource.ClientDataSourceRouter;
import com.example.abstractroutingdatasource.ClientDatasource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class RoutingTestConfiguration {
@Bean
public ClientDatasource clientDatasource(){
System.out.println("RoutingTestConfiguration clientDatasource 생성자");
return new ClientDatasource(getclientDatasSource());
}
@Bean
public DataSource getclientDatasSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
DataSource agensDatasource = agensDatasource();
DataSource mysqlDatasource = mysqlDatasource();
targetDataSources.put(ClientDatabase.AGENS, agensDatasource);
targetDataSources.put(ClientDatabase.MYSQL, mysqlDatasource);
ClientDataSourceRouter clientDataSourceRouter = new ClientDataSourceRouter();
clientDataSourceRouter.setTargetDataSources(targetDataSources);
clientDataSourceRouter.setDefaultTargetDataSource(agensDatasource);
return clientDataSourceRouter;
}
@Bean
@ConfigurationProperties("datasource.agens")
public DataSourceProperties agensDatasourProperties(){
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("datasource.mysql")
public DataSourceProperties mysqlDatasourProperties(){
return new DataSourceProperties();
}
@Bean
public DataSource agensDatasource() {
return agensDatasourProperties()
.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
@Bean
public DataSource mysqlDatasource() {
return mysqlDatasourProperties()
.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
위와 같이 작성하였다. 코드를 설명하자면
@Bean
public ClientDatasource clientDatasource(){
System.out.println("RoutingTestConfiguration clientDatasource 생성자");
return new ClientDatasource(getclientDatasSource());
}
RoutingTestConfiguration에서 생성한 Datasource를 사용하고 싶어서 ClientDatasource란 class를 만들었고 해당클래스의 생성자를 통해 전달하였다. ClientDatasource는 7. 에서 소개하겠다
Bean
public DataSource getclientDatasSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
DataSource agensDatasource = agensDatasource();
DataSource mysqlDatasource = ageDatasource();
targetDataSources.put(ClientDatabase.AGENS, agensDatasource);
targetDataSources.put(ClientDatabase.MYSQL, mysqlDatasource);
ClientDataSourceRouter clientDataSourceRouter = new ClientDataSourceRouter();
clientDataSourceRouter.setTargetDataSources(targetDataSources);
clientDataSourceRouter.setDefaultTargetDataSource(agensDatasource);
return clientDataSourceRouter;
}
Map객체에 DB 정보를 담고 enum ClientDatabase에서 선언한 AGENS, MYSQL을요 Map의 key값으로 사용한다.
ClientDataSourceRouter 객체를 생성하고 setTargetDataSources메소드를 이용하여 등록한다.
datasource:
agens:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/데이타베이스
username: 아이디
password: 패스워드
mysql:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/데이타베이스
username: 아이디
password: 패스워드
나머지 부분은 위의 application.yml에서 선언한 환경설정 내용을 가져와 set하고 Datasource를 생성하는 부분이다.
Datasource객체를 다른 객체에서 사용하기위해 만들었다.
import com.example.abstractroutingdatasource.config.ClientDatabase;
import lombok.NoArgsConstructor;
import javax.sql.DataSource;
@NoArgsConstructor
public class ClientDatasource {
public static DataSource dataSource;
public ClientDatasource(DataSource dataSource){
//System.out.println("ClientDatasource 생성자");
this.dataSource = dataSource;
}
public static DataSource getDatasource(ClientDatabase clientDatabase) {
ClientDatabaseContextHolder.set(clientDatabase);
return dataSource;
}
}
RoutingTestConfiguration 클래스에서 전달받은 Datasource를 JVM이 내려가기 전까지 계속 사용하고 싶었기 때문에 Static으로 선언하고 생성자에서 전달받았다. 그리고 getDatasource 메소드로 해당 datasource를 전달받는데 전달받기 전에 ClientDatabaseContextHolder.set 메소드에 위에서 Map객체에 put할 때 사용한 ClientDatabase enum값을 파라미터로 전달하여 해당하는 Datasource를 set하고 객체를 반납한다
import com.example.abstractroutingdatasource.ClientDatasource;
import com.example.abstractroutingdatasource.MonitoringService;
import com.example.abstractroutingdatasource.config.ClientDatabase;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource;
import java.sql.SQLException;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class MainController {
private final MonitoringService monitoringService;
@GetMapping("/datasource/{dbName}")
public ResponseEntity<?> getData(@PathVariable String dbName) throws SQLException {
Object result = null;
DataSource dataSource = null;
if("agens".equals(dbName)){
//System.out.println("MainController getData dbName : "+dbName);
dataSource = ClientDatasource.getDatasource(ClientDatabase.AGENS);
result = monitoringService.getData(dataSource);
}else{
dataSource = ClientDatasource.getDatasource(ClientDatabase.MYSQL);
result = monitoringService.getData(dataSource);
}
return ResponseEntity.ok(result);
}
@PathVariable에 원하는 db명을 넣고 그 값에 따라 해당하는 dataSource를 가져오고 그 dataSource를 MonitoringService객체 전달한다.
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@Service
public class MonitoringService {
public Object getData(DataSource dataSource) throws SQLException {
System.out.println("BoardService getData");
Connection con = null;
Statement st = null;
List<HashMap<Object, String>> resultList = new ArrayList<>();
ResultSet rs = null;
try{
con = dataSource.getConnection();
st = con.createStatement();
rs = st.executeQuery("SELECT id, title, content FROM tb_board");
while (rs.next()){
HashMap<Object, String> obj = new HashMap<>();
obj.put("id", rs.getString("id"));
obj.put("title", rs.getString("title"));
obj.put("content", rs.getString("content"));
resultList.add(obj);
}
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
rs.close();
st.close();
con.close();
}
return resultList;
}
}
Service 객체에서는 전달받은 dataSource로 connection을 생성하여 해당하는 쿼리를 수행, 결과를 반납한다
위와 같은 결과를 볼 수 있다면 성공이다.
처음 이 기술을 보게된게 모니터링 기능을 구현하기위해서이다. postgres는 특성상 database영역이 독립적이여서 connection을 그때그때마다 교체하면서 모니터링 쿼리를 수행하고 이력을 쌓아야 했다. 그리고 connection 관리를 직접 하지 않고 HikariPool을 사용하고 싶었다.
위의 spring 로그를 보면 service에서 dataSource가 교체될 때마다 determineCurrentLookupKey라는 재정의 메소드가 호출되고 그에 따라서 HikariPool의 번호가 바뀌면서 다른 pool에서 가져오는 것을 알고 있다. Hikari pool의 성능은 이미 검증돼있기 때문에 따로 설명할 필요는 없을 거 같다.
혹시나 오류가 있다면 얘기 바랍니다
code
github - AbstractRouteDatasource
reference
https://www.baeldung.com/spring-abstract-routing-data-source/