9.5 실전 고수 팁 — Master-Slave (Read/Write) 멀티 데이터베이스 라우팅 및 대용량 배치 처리 기초
스프링 부트의 자동 설정(Auto Configuration)을 사용하지 않고 직접 자바 코드로 DataSource를 설정해야 하거나, 한 애플리케이션에서 두 개 이상의 데이터베이스에 접속해야 하는 경우의 설정 방법을 알아봅니다.
1. 자바 코드로 DataSource 직접 설정
특정 라이브러리와의 호환성이나 런타임에 동적으로 설정을 변경해야 할 때 자바 설정 클래스를 사용합니다.
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.hikari") // yml의 설정을 바인딩
public DataSource dataSource() {
// DataSourceBuilder를 사용하여 직접 생성
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
}
2. 멀티 데이터베이스 (Multi DataSource) 설정
실무에서는 서비스 규모가 커짐에 따라 트래픽 분산이나 도메인 분리를 위해 다중 데이터베이스를 구성하는 경우가 많습니다. 가장 대표적인 실무 활용 패턴 두 가지를 소개합니다.
2.1 [실전 패턴 1] 도메인/모듈별 데이터베이스 분리 (User DB / Order DB)
회원 데이터는 MySQL, 주문 데이터는 PostgreSQL에 저장하는 등 도메인(패키지)별로 다른 데이터베이스를 사용할 때 적용하는 방식입니다. Spring Data JPA를 사용한다면 EntityManagerFactory와 TransactionManager를 패키지별로 각각 구성해야 합니다.
1) application.yml 설정
spring:
# 기본 DataSource 자동 설정 제외
autoconfigure:
exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
app:
datasource:
user:
jdbc-url: jdbc:mysql://localhost:3306/user_db
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
order:
jdbc-url: jdbc:postgresql://localhost:5432/order_db
username: user
password: password
driver-class-name: org.postgresql.Driver
💡 DataSourceAutoConfiguration을 제외하는 이유
스프링 부트는 클래스패스에 데이터베이스 드라이버(
mysql-connector-j등)가 존재하면 기본적으로 단일DataSource를 자동으로 구성하려고 시도합니다. 그러나 멀티 데이터베이스 환경에서는 개발자가 2개 이상의 DataSource를 직접 세밀하게(JPA EntityManager, TransactionManager 등) 빈(Bean)으로 등록하여 사용해야 합니다.따라서 스프링 부트의 단일 DB 기준 기본 자동 설정이 개입하여 의도치 않은 기본 DataSource 빈을 생성하거나 충돌을 일으키지 못하도록
exclude속성을 통해 자동 설정을 명시적으로 제외하는 것입니다.
2) User 데이터베이스 설정 (Primary)
com.example.user 패키지 하위의 Repository와 Entity는 User DB를 바라보도록 설정합니다.
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "com.example.user.repository",
entityManagerFactoryRef = "userEntityManagerFactory",
transactionManagerRef = "userTransactionManager"
)
public class UserDataSourceConfig {
@Primary
@Bean(name = "userDataSource")
@ConfigurationProperties(prefix = "app.datasource.user")
public DataSource userDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Primary
@Bean(name = "userEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean userEntityManagerFactory(
EntityManagerFactoryBuilder builder,
@Qualifier("userDataSource") DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages("com.example.user.entity")
.persistenceUnit("user")
.build();
}
@Primary
@Bean(name = "userTransactionManager")
public PlatformTransactionManager userTransactionManager(
@Qualifier("userEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
3) Order 데이터베이스 설정 (Secondary)
com.example.order 패키지 하위의 Repository와 Entity는 Order DB를 바라보도록 설정합니다.
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "com.example.order.repository",
entityManagerFactoryRef = "orderEntityManagerFactory",
transactionManagerRef = "orderTransactionManager"
)
public class OrderDataSourceConfig {
@Bean(name = "orderDataSource")
@ConfigurationProperties(prefix = "app.datasource.order")
public DataSource orderDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean(name = "orderEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean orderEntityManagerFactory(
EntityManagerFactoryBuilder builder,
@Qualifier("orderDataSource") DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages("com.example.order.entity")
.persistenceUnit("order")
.build();
}
@Bean(name = "orderTransactionManager")
public PlatformTransactionManager orderTransactionManager(
@Qualifier("orderEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
💡 MyBatis를 사용하는 경우
EntityManagerFactory와JpaTransactionManager대신,SqlSessionFactory와DataSourceTransactionManager를 등록하고@MapperScan으로 패키지를 지정합니다.@Configuration
@MapperScan(
basePackages = "com.example.user.mapper",
sqlSessionFactoryRef = "userSqlSessionFactory"
)
public class UserMyBatisConfig {
@Primary
@Bean(name = "userSqlSessionFactory")
public SqlSessionFactory userSqlSessionFactory(@Qualifier("userDataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/user/*.xml"));
return sessionFactory.getObject();
}
@Primary
@Bean(name = "userTransactionManager")
public PlatformTransactionManager userTransactionManager(@Qualifier("userDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
// Order 도메인용 MyBatis 설정 (Secondary)
@Configuration
@MapperScan(
basePackages = "com.example.order.mapper",
sqlSessionFactoryRef = "orderSqlSessionFactory"
)
public class OrderMyBatisConfig {
@Bean(name = "orderSqlSessionFactory")
public SqlSessionFactory orderSqlSessionFactory(@Qualifier("orderDataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/order/*.xml"));
return sessionFactory.getObject();
}
@Bean(name = "orderTransactionManager")
public PlatformTransactionManager orderTransactionManager(@Qualifier("orderDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
4) 실제 코드 사용 예시
// User 도메인 서비스 (Primary Transaction Manager 자동 적용)
@Service
@RequiredArgsConstructor
public class UserService {
// basePackages = "com.example.user.repository" 하위에 있는 Repository
private final UserRepository userRepository;
@Transactional // 자동으로 기본(Primary)인 userTransactionManager가 적용됩니다.
public User createUser(User user) {
return userRepository.save(user);
}
}
// Order 도메인 서비스 (Secondary Transaction Manager 명시적 지정)
@Service
@RequiredArgsConstructor
public class OrderService {
// basePackages = "com.example.order.repository" 하위에 있는 Repository
private final OrderRepository orderRepository;
@Transactional("orderTransactionManager") // 명시적으로 Order DB의 트랜잭션을 지정합니다.
public Order createOrder(Order order) {
return orderRepository.save(order);
}
}
2.2 [실전 패턴 2] 마스터(Master) / 레플리카(Replica) 분리 설정
웹 서비스의 부하를 줄이기 위해 Insert/Update/Delete 등의 쓰기 작업은 Master DB로, Select 관련 읽기 작업은 Replica(Slave) DB로 라우팅하는 구성입니다. AbstractRoutingDataSource를 사용하여 구현할 수 있습니다.
1) application.yml 설정
spring:
autoconfigure:
exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
app:
datasource:
master:
jdbc-url: jdbc:mysql://master-db:3306/main_db
username: admin
password: master_password
replica:
jdbc-url: jdbc:mysql://replica-db:3306/main_db
username: admin
password: replica_password
2) RoutingDataSource 구현
현재 실행 중인 트랜잭션이 읽기 전용인지 여부를 확인하여 식별자를 반환합니다.
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// @Transactional(readOnly = true) 인 경우 replica, 아니면 master
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return isReadOnly ? "replica" : "master";
}
}
3) DataSource 구성 및 Proxy 설정
@Configuration
public class RoutingDataSourceConfig {
@Bean(name = "masterDataSource")
@ConfigurationProperties(prefix = "app.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean(name = "replicaDataSource")
@ConfigurationProperties(prefix = "app.datasource.replica")
public DataSource replicaDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean(name = "routingDataSource")
public DataSource routingDataSource(
@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("replicaDataSource") DataSource replicaDataSource) {
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", masterDataSource);
dataSourceMap.put("replica", replicaDataSource);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(masterDataSource);
return routingDataSource;
}
/**
* JPA를 사용할 때 트랜잭션 진입 시점에 커넥션을 미리 가져오지 않고,
* 실제 쿼리가 실행될 때 커넥션을 동적으로 가져오도록 Proxy로 감싸줍니다.
*/
@Primary
@Bean(name = "dataSource")
public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
💡 MyBatis에서의 Master/Replica 분기 동작 확인 MyBatis 설정 시에도 트랜잭션 매니저(
DataSourceTransactionManager등)가 프록시 처리된dataSource빈을 바라보게 하면, 서비스 계층에서의 동작 방식은 완벽하게 JPA와 동일합니다. 즉,@Transactional(readOnly = true)속성만으로 레플리카(Slave)에 자동 연결됩니다. 단, 실 동작 시점까지 커넥션 획득을 늦추기 위해 위 코드처럼LazyConnectionDataSourceProxy세팅이 반드시 가장 바깥에 있어야 합니다.
4) 실제 코드 사용 예시
비즈니스 로직(Service 클래스)에서 @Transactional(readOnly = true) 옵션 하나만으로 읽기 전용 쿼리가 자동으로 레플리카(Replica) DB로 분기됩니다. 개발자는 인프라를 신경 쓰지 않고 비즈니스 코어에 집중할 수 있습니다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
// Create, Update, Delete: 기본 @Transactional -> Master DB로 자동 라우팅
@Transactional
public Product saveProduct(Product product) {
return productRepository.save(product);
}
// Read: @Transactional(readOnly = true) -> Replica(Slave) DB로 자동 라우팅
@Transactional(readOnly = true)
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
}
3. 주의사항 및 실무 팁
- @Primary 필수: 빈 타입이 중복될 때 스프링이 어떤 것을 기본으로 주입할지 명시해야 에러가 발생하지 않습니다.
- ConfigurationProperties 주의: 스프링 부트 2.x 이상에서 Hikari 설정을 직접 바인딩할 때는
url대신jdbc-url프로퍼티 명칭을 사용해야 하는 경우가 많습니다. - 트랜잭션 관리자: 멀티 DB를 사용할 경우 트랜잭션 관리자(
PlatformTransactionManager)도 각각 빈으로 등록하여 필요한 곳에 지정해서 사용해야 합니다. (@Transactional("mainTransactionManager"))
4. 대용량 데이터 일괄 삽입 (Bulk/Batch Insert) 성능 최적화
수만 건 이상의 대용량 데이터를 한 번에 DB에 넣어야 할 때, 단순한 반복문(for 루프)으로 insert를 호출하면 네트워크 레벨의 왕복(Network Round-Trip)과 트랜잭션 오버헤드로 인해 심각한 성능 저하가 발생합니다. 이때는 반드시 배치(Batch) 처리를 도입해야 합니다.
4.1 가장 빠르고 확실한 방법: JdbcTemplate batchUpdate
JPA를 메인으로 사용하더라도, 대량 Insert 성능만큼은 JdbcTemplate이나 MyBatis의 Batch 모드를 사용하는 것이 실무 표준(Best Practice)에 가깝습니다.
@Repository
@RequiredArgsConstructor
public class BulkInsertRepository {
private final JdbcTemplate jdbcTemplate;
@Transactional
public void saveAllInBatch(List<User> userList) {
String sql = "INSERT INTO users (name, email, created_at) VALUES (?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
User user = userList.get(i);
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.setTimestamp(3, Timestamp.valueOf(user.getCreatedAt())); // java.sql.Timestamp 변환 필요
}
@Override
public int getBatchSize() {
// 한 번에 DB로 전송할 묶음(Chunk)의 크기
// 보통 1000~5000 단위로 잘라서(Paging/Partitioning) 전달하는 것이 메모리 관리에 좋습니다.
return userList.size();
}
});
}
}
4.2 JPA를 꼭 사용해야 한다면? (성능 튜닝 필수 세팅)
만약 JPA의 saveAll()을 그대로 써야 한다면 다음 3가지 조건이 모두 충족 되어야만 백그라운드에서 진짜 Bulk Insert 쿼리가 동작합니다.
- DB URL 옵션 추가: MySQL의 경우 반드시
rewriteBatchedStatements=true파라미터를 추가해야 쿼리가 재작성되어 묶여서 전송됩니다. - 배치 사이즈 설정:
application.yml에spring.jpa.properties.hibernate.jdbc.batch_size=1000등 적절한 값을 세팅해야 합니다. GenerationType.IDENTITY금지: 엔티티의 ID 생성 전략이IDENTITY(Auto Increment)이면 쿼리를 묶을 수 없어 1건씩 즉각 발생시킵니다.SEQUENCE나TABLE전략, 혹은 개발자가 ID를 수동 할당(@Id만 사용)해야 합니다.
💡 실무 Pro Tip: 위 3번째 조건인
IDENTITY전략 사용 불가 제약이 치명적이기 때문에(MySQL의 기본 전략), 최신 실무 프로젝트에서는 보통 메인 로직은 JPA로 구현하더라도 벌크 인서트 로직만JdbcTemplate이나 MyBatis를 추가로 활용 하여 섞어 쓰는(Polyglot Persistence Layer) 아키텍처를 많이 선택합니다.