다중 DB를 연결하게 된 이유
장비 전문 업체에서 일하다보니, 백오피스는 여러 장비를 관리해야 되는 이슈가 있었습니다.
같은 제품의 장비 별 DB 연동을 하려면 소스 자체 내에서 DB를 여러개 붙는 방법도 있지만, 연결해야 할 DB 개수만큼의 보일러플레이트 코드
가 생겨났고, 장비의 개수가 늘어나면 대처할 방법이 없었습니다.
AbstractRoutingDatasource로 다중 DB 연결
이를 해결하기 위해, 다중 DB 연결을 알아보던 중 AbstractRoutingDataSource 를 발견하게 되었고, 이를 통해 여러가지 이슈를 해결할 수 있었습니다.
이번 포스팅에서는 AbstractRoutingDataSource
의 정의와 사례, Spring Boot + JPA에서 구현한 내용 대해 정리해보았습니다.
DataSource
는 DB와 커넥션 정보를 담고 있는 객체입니다. Spring JDBC는 이런 DataSource를 확장한 몇 가지 기능을 제공하는데 그 중 하나 AbstractRoutingDataSource
입니다.
AbstractRoutingDataSource
는 다양한 DataSource를 담아두고, key로 DataSource를 선택(routing)해서 사용할 수 있습니다. AbstractRoutingDataSource는 등록된 DataSource에 대해서 같은 쿼리를 날리기 때문에 동일한 스키마를 가지고 있어야 합니다.
이 포스팅에서 다루는 주제는 아니지만, 성능 최적화를 위해 Master
와 Replica
DB를 두어 읽기와 쓰기를 분리한 사례가 있습니다.
2021년도에는 흔하게 보이지 않았던 사례지만, 포스팅을 작성하는 지금 시점에서는 관련 포스팅을 흔하게 찾아볼 수 있습니다. 성능 최적화가 최다 관심사로 떠오르고 있다는 반증이지 않나 싶습니다.😢
읽기 전용 복제본의 사용을 촉진하는 CQRS 아키텍처 패턴이 있다고 합니다.
CQRS
CQRS (Command Query Responsibility Segregation)
is an architectural pattern that separates the concerns of updating (commands) and reading (queries) data in a software system.It divides the application’s logic and data models into distinct components, optimising each for their specific tasks, resulting in improved scalability and maintainability. In layman’s term it says read and non read operations should be performed on separate databases.
DB 구조
구현 내용
- 시스템 DB의 장비 테이블에서 얻은 장비 목록을 바탕으로 장비별 DB에 연결합니다.
- 장비별로 도서 목록을 확인합니다.
구현 내용에 맞추어 아래와 같이 패키지 및 자바 파일을 구성하였습니다.
차례대로 구현해보도록 하겠습니다.
먼저 장비 정보를 저장할 System DB를 설정합니다.
시스템 DB는 변하지 않기 때문에 환경 설정 파일에서 기술해놓았습니다.
spring:
datasource:
system:
url: jdbc:postgresql://localhost:5432/system
username: postgres
password: qwe123!@#
jpa:
hibernate:
ddl-auto: create
// # 1. @Configuration 어노테이션을 이용하여 Bean 등록
@Configuration
// # 2. JPA 설정
@EnableTransactionManagement
@EnableJpaRepositories(
// # 2.1 JPA Repository 설정
basePackages = "com.example.dao.system.repository",
// # 2.2 JPA Entity Manager 설정 - Entity 설정
entityManagerFactoryRef = "primaryEntityManager",
// # 2.3 Transaction 설정
transactionManagerRef = "primaryTransactionManager"
)
public class SystemDBConfiguration {
// # 3. 환경설정 파일을 이용해 DataSource Properties를 생성합니다.
@Bean
@ConfigurationProperties("spring.datasource.system")
public DataSourceProperties systemDataSourceProperties() {
return new DataSourceProperties();
}
// # 4. System DB DataSource Bean 생성
@Bean
public DataSource systemDataSource() {
return systemDataSourceProperties()
.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
// # 5. Entity Manager 설정
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean primaryEntityManager() {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
// # 5.1 DataSource 지정
em.setDataSource(systemDataSource());
// # 5.2 Entity를 기술할 패키지 설정
em.setPackagesToScan("com.example.dao.system.entity");
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
vendorAdapter.setShowSql(true);
vendorAdapter.setGenerateDdl(true);
em.setJpaVendorAdapter(vendorAdapter);
Map<String, Object> prop = new HashMap<>();
prop.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
prop.put("hibernate.hbm2ddl.auto", "create");
prop.put("hibernate.format_sql", true);
em.setJpaPropertyMap(prop);
return em;
}
// # 6. Transaction Manager 설정
@Bean
@Primary
public PlatformTransactionManager primaryTransactionManager() {
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(primaryEntityManager().getObject());
return transactionManager;
}
}
장비 DB 정보를 저장할 엔티티를 생성하고, Repository를 생성합니다.
com.example.dao.system.entity
패키지 아래 Entity를 생성합니다.
@Builder
@Data
@AllArgsConstructor
@RequiredArgsConstructor
@Entity(name = "tb_device")
@SequenceGenerator(name = "DEVICE_SEQ_GENERATOR", sequenceName = "DEVICE_SEQ", allocationSize = 1)
public class Device {
@Id
@Column(name = "rec_key")
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "DEVICE_SEQ_GENERATOR")
private Integer recKey;
@Column(length = 20)
private String name;
@Builder.Default
private String url = "jdbc:postgresql://127.0.0.1:5432/Device";
@Builder.Default
private String userName = "postgres";
@Builder.Default
private String password = "qwe123!@#";
}
com.example.dao.system.repository
패키지 아래 Repository Interface를 생성합니다.
public interface DeviceRepository extends CrudRepository<Device, Long> {
List<Device> findAll();
@Override
Optional<Device> findById(Long id);
}
public class DeviceDBContextHolder {
private static final ThreadLocal<Integer> contextHolder = new ThreadLocal<>();
public static void setType(Integer dbType) {
contextHolder.set(dbType);
}
public static Integer getType() {
return contextHolder.get();
}
public static void clear() {
contextHolder.remove();
}
}
해당 클래스는 일종의 전략 패턴(Strategy Pattern)을 구현한 클래스입니다. 이 전략 패턴을 이용하여 데이터베이스를 선택할 수 있도록 코드를 구성하고, 선택한 데이터베이스 정보를 쓰레드 내에서 공유할 수 있도록 구현합니다.
전략 패턴
전략 패턴은 프로그램 수행 중에 알고리즘을 선택할 수 있도록 만든 패턴입니다. 전략을 수행하는 주체를
Context
라고 하고, 이를 변경할 수 있는 함수를Holder
라고 합니다.
TheadLocal
ThreadLocal 클래스는 자바 1.2 버전부터 지원한 오래된 클래스입니다.
ThreadLocal은 쓰레드 내에서 유지되는 일종의 지역변수를 의미하며, 멀티 쓰레드 환경에서는 각 쓰레드마다 독립적인 변수를 갖고 있습니다.
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DeviceDBContextHolder.getType();
}
}
AbstractRoutingDatasource
는 추상 (abstract) 클래스로 객체를 바로 생성할 수 없기 때문에 상속받는 클래스를 구현해야합니다. 또한 determineCurrentLookupKey()
추상 메소드이므로 반드시 오버라이딩해야 합니다.
AbstractRoutingDatasource를 상속 받는 RoutingDataSource 클래스를 구현합니다. determineCurrentLookupKey()
메소드를 오버라이딩하여, 구현한 ContextHolder 클래스의 변수를 꺼내옵니다. static 메소드 이므로 객체 생성 없이 사용할 수 있습니다.
이렇게 구현하면, ContextHolder의 변수 값이 변경될 때 데이터베이스가 변경되는 효과를 얻을 수 있습니다.
장비별로 데이터베이스에 연결하여 RoutingDataSource에 등록합니다.
// # 1. @Configuration - Bean 등록
@Configuration
// # 2. 롬복으로 자동 빈 주입
@AllArgsConstructor
// # 3. JPA 연동
@EnableTransactionManagement
@EnableJpaRepositories(
// # 3.1 Repository로 사용할 패키지명을 입력
basePackages = "com.example.dao.device.repository",
// # 3.2 JPA Entity Manager 등록
entityManagerFactoryRef = "secondaryEntityManager",
// # 3.3 Transaction Manager 등록
transactionManagerRef = "secondaryTransactionManager"
)
@Slf4j
public class DeviceDBConfiguration {
private final DeviceRepository deviceRepository;
// # 4. AbstractRoutingDataSource 객체 생성
@Bean
public AbstractRoutingDataSource deviceDataSource() {
AbstractRoutingDataSource dataSource = new RoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
List<Device> devices = deviceRepository.findAll();
if (devices.isEmpty()) {
devices.add(Device.builder()
.name("테스트1")
.url("jdbc:postgresql://localhost:5432/device1")
.build());
devices.add(Device.builder()
.name("테스트2")
.url("jdbc:postgresql://localhost:5432/device2")
.build());
deviceRepository.saveAll(devices);
}
log.info("* DB 연결");
devices.forEach(device -> {
log.info("device: {}", device);
// # 4.1 각 장비별 DB의 DataSource 생성 및 등록
targetDataSources.put(device.getRecKey(), createDataSource(device));
});
// # 4.2 생성한 DB 목록을 AbstractRoutingDataSource에 연동
dataSource.setTargetDataSources(targetDataSources);
// #4.3 Default로 사용할 Datasource 등록
// 이 과정이 없으면 Exception 발생 - java.lang.IllegalStateException
// Cannot determine target DataSource for lookup key [null]
dataSource.setDefaultTargetDataSource(targetDataSources.get(devices.get(0).getRecKey()));
return dataSource;
}
public DataSource createDataSource(Device device) {
return DataSourceBuilder.create()
.url(device.getUrl())
.driverClassName("org.postgresql.Driver")
.username(device.getUserName())
.password(device.getPassword())
.type(HikariDataSource.class)
.build();
}
// # 5. Entity Manager 등록
@Bean
public LocalContainerEntityManagerFactoryBean secondaryEntityManager(
AbstractRoutingDataSource deviceDataSource) {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
// # 5.1 앞서 생성한 DataSource를 등록합니다.
em.setDataSource(deviceDataSource);
em.setPackagesToScan("com.example.dao.device.entity");
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
vendorAdapter.setShowSql(true);
vendorAdapter.setGenerateDdl(true);
em.setJpaVendorAdapter(vendorAdapter);
Map<String, Object> prop = new HashMap<>();
prop.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
prop.put("hibernate.hbm2ddl.auto", "create");
prop.put("hibernate.format_sql", true);
em.setJpaPropertyMap(prop);
return em;
}
// # 6. Transaction Manager 등록
@Bean
public PlatformTransactionManager secondaryTransactionManager (
LocalContainerEntityManagerFactoryBean secondaryEntityManager) {
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(secondaryEntityManager.getObject());
return transactionManager;
}
}
장비별로 사용할 도서 정보의 Entity
와 Repository
를 구현합니다.
@Data
@Entity(name = "tb_book")
@SequenceGenerator(name = "BOOK_SEQ_GENERATOR", sequenceName = "BOOK_SEQ", allocationSize = 1)
public class Book {
@Id
@Column(name = "rec_key")
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "BOOK_SEQ_GENERATOR")
private Integer recKey;
@Column(name = "title", length = 100)
@Comment("제목")
private String title;
@Column(name = "author", length = 100)
@Comment("저자")
private String author;
@Column(name="publisher", length = 100)
@Comment("출판사")
private String publisher;
@Column(name="isbn", length = 20)
@Comment("ISBN")
private String isbn;
@Column(name = "classNo", length = 10)
@Comment("십진분류")
private String classNo;
@Column(length = 10)
@Comment("상태")
private String state;
}
@Repository
public interface BookRepository extends CrudRepository<Book, Long> {
@Override
List<Book> findAll();
}
JPA의 기본 Repository인 CrudRepository
인터페이스를 상속받아 구현합니다.
Request를 처리할 Controller
와 Service
를 구현합니다.
도서 목록을 가져오는 API를 개발한 예시입니다.
@RestController
@RequiredArgsConstructor
@RequestMapping(path ="/api")
public class BookController {
private final BookService bookService;
@GetMapping("/books/{db}")
public ResponseEntity<?> getBookList(@PathVariable Integer db){
DeviceDBContextHolder.setType(db);
return ResponseEntity.ok(bookService.getBookList());
}
}
Service 인터페이스
public interface BookService {
List<Book> getBookList();
}
Service 구현자
@Service
@RequiredArgsConstructor
public class BookServiceImpl implements BookService {
private final BookRepository bookRepository;
@Override
public List<Book> getBookList() {
log.info("선택한 DB: {}", DeviceDBContextHolder.getType());
return bookRepository.findAll();
}
}
각각 DB에 데이터를 넣고 테스트를 해봅니다.
# device 1
INSERT INTO tb_book(title, author, publisher, publish_year, isbn)
VALUES ('지적 대화를 위한 넓고 얕은 지식. 1, 현실 편 - 역사·경제·정치·사회·윤리', '채사장 지음', '웨일북', 2020, '9791190313186');
INSERT INTO tb_book(title, author, publisher, publish_year, isbn)
VALUES ('서점 일기 : 세상 끝 서점을 비추는 365가지 그림자', '숀 비텔 지음 ; 김마림 옮김음', '여름언덕', 2021, '9791155100936');
INSERT INTO tb_book(title, author, publisher, publish_year, isbn)
VALUES ('오늘부터 개발자 : 비전공자를 위한 개발자 취업 입문 개론', '김병욱 지음', '천그루숲', 2021, '99791188348909');
# device 2
INSERT INTO tb_book(title, author, publisher, publish_year, isbn)
VALUES ('딱 1분만 읽어봐 : 너무 재밌고 유익하고 신박하다!', '1분만 지음', '메이트북스', 2022, '9791160023862');
INSERT INTO tb_book(title, author, publisher, publish_year, isbn)
VALUES ('AI는 인문학을 먹고 산다 : 인문학으로 인공지능 시대를 주도하라', '한지우 지음', '미디어숲', 2021, '9791158741303');
INSERT INTO tb_book(title, author, publisher, publish_year, isbn)
VALUES ('미드나잇 라이브러리', '매트 헤이그 지음 ; 노진선 옮김', '인플루엔셜', 2021, '9791191056556');
장비 1 테스트 화면
장비 2 테스트 화면
처음 접하면서
신입 시절, 다중 DB 연결을 접했을 때는 막막했던 감정이 들었습니다.
이슈를 해결하기 위해 수많은 검색 끝에 AbstractRoutingDataSource
를 접하면서, Spring 프레임워크는 굉장히 많은 기능을 제공하고 애플리케이션 개발자는 잘 활용하면 된다는 것을 몸소 느끼게 되었던 것 같습니다.
포스팅하면서
코드를 분석하면서 기존에 사용할 때보다 구현 원리에 대해 좀 더 깊이 있는 인사이트를 얻게 되었고, Reference를 찾아보면서 이 클래스로 CQRS 패턴을 구현하여 애플리케이션의 성능을 높일 수 있다는 것을 알게 되었습니다. 나중에 적용해볼 수 있으면 좋을 것 같습니다.😊
여러모로 의미있는 포스팅이었습니다. 읽어주셔서 감사합니다.😍