Spring Boot 멀티 DataSource — 읽기/쓰기 분리부터 AbstractRoutingDataSource까지

Spring Boot에서 두 개 이상의 DataSource를 구성하는 패턴을 다룹니다. @Primary 기반 수동 분리, 별도 JPA 설정, AbstractRoutingDataSource로 @Transactional(readOnly)에 따른 자동 DB 라우팅 구현까지 단계별로 설명합니다.

· 5 min read · PALDYN Team

지난 글에서 Spring Boot가 단일 DataSource를 자동 구성하는 과정을 살펴봤습니다. 실제 서비스에서는 Primary DB(쓰기)와 Replica DB(읽기 전용)를 분리하거나, 서로 다른 도메인의 DB를 별도로 연결해야 하는 경우가 많습니다. 이번 글에서는 멀티 DataSource를 구성하는 세 가지 패턴과 AbstractRoutingDataSource를 이용한 자동 라우팅 구현을 다룹니다.

Auto-configuration 비활성화

DataSource를 두 개 이상 직접 정의하려면 먼저 Spring Boot의 DataSource Auto-configuration을 꺼야 합니다.

@SpringBootApplication(exclude = {
    DataSourceAutoConfiguration.class,
    DataSourceTransactionManagerAutoConfiguration.class,
    HibernateJpaAutoConfiguration.class
})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

또는 application.yml에서 선택적으로 제외할 수도 있습니다.

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

패턴 1 — @Primary 기반 수동 분리

가장 단순한 방법입니다. 두 DataSource를 Bean으로 등록하고 기본 Bean에 @Primary를 붙입니다.

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.primary")
    public DataSourceProperties primaryDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    public DataSource primaryDataSource(
            @Qualifier("primaryDataSourceProperties")
            DataSourceProperties props) {
        return props.initializeDataSourceBuilder()
                    .type(HikariDataSource.class)
                    .build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.replica")
    public DataSourceProperties replicaDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    public DataSource replicaDataSource(
            @Qualifier("replicaDataSourceProperties")
            DataSourceProperties props) {
        return props.initializeDataSourceBuilder()
                    .type(HikariDataSource.class)
                    .build();
    }
}
app:
  datasource:
    primary:
      url: jdbc:mysql://primary-host:3306/mydb
      username: writer
      password: ${PRIMARY_DB_PASSWORD}
      hikari:
        maximum-pool-size: 10
    replica:
      url: jdbc:mysql://replica-host:3306/mydb
      username: reader
      password: ${REPLICA_DB_PASSWORD}
      hikari:
        maximum-pool-size: 20

멀티 DataSource 아키텍처

패턴 2 — 별도 JPA 설정 (다른 스키마/DB)

두 DataSource가 완전히 다른 DB(예: 주문 DB + 결제 DB)를 가리킬 때는 JPA 설정도 분리합니다.

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = "com.example.order.repository",
    entityManagerFactoryRef = "orderEntityManagerFactory",
    transactionManagerRef = "orderTransactionManager"
)
public class OrderDataSourceConfig {

    @Bean
    @Primary
    public LocalContainerEntityManagerFactoryBean orderEntityManagerFactory(
            @Qualifier("primaryDataSource") DataSource dataSource,
            JpaVendorAdapter jpaVendorAdapter) {
        LocalContainerEntityManagerFactoryBean em =
            new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.order.domain");
        em.setJpaVendorAdapter(jpaVendorAdapter);
        return em;
    }

    @Bean
    @Primary
    public PlatformTransactionManager orderTransactionManager(
            @Qualifier("orderEntityManagerFactory")
            EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

결제 DataSource도 동일한 구조로 PaymentDataSourceConfig를 만들고, @EnableJpaRepositoriesbasePackagescom.example.payment.repository로 지정합니다.

패턴 3 — AbstractRoutingDataSource (자동 라우팅)

가장 우아한 방법입니다. @Transactional(readOnly = true) 어노테이션 하나만 보고 자동으로 Replica를 선택합니다.

AbstractRoutingDataSource 자동 라우팅

RoutingDataSource 구현

public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        // 현재 트랜잭션의 readOnly 여부를 확인
        boolean isReadOnly =
            TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        return isReadOnly ? "replica" : "primary";
    }
}

Bean 등록

@Configuration
public class RoutingDataSourceConfig {

    @Bean
    public DataSource routingDataSource(
            @Qualifier("primaryDataSource") DataSource primary,
            @Qualifier("replicaDataSource") DataSource replica) {

        Map<Object, Object> targets = new HashMap<>();
        targets.put("primary", primary);
        targets.put("replica", replica);

        RoutingDataSource routing = new RoutingDataSource();
        routing.setTargetDataSources(targets);
        routing.setDefaultTargetDataSource(primary);  // 기본값: primary
        return routing;
    }

    // LazyConnectionDataSourceProxy로 트랜잭션 시작 후 실제 커넥션 획득
    @Bean
    @Primary
    public DataSource dataSource(
            @Qualifier("routingDataSource") DataSource routing) {
        return new LazyConnectionDataSourceProxy(routing);
    }
}

LazyConnectionDataSourceProxy가 중요합니다. 이것을 감싸지 않으면 determineCurrentLookupKey()가 트랜잭션 시작 전에 호출되어 readOnly 플래그를 읽지 못합니다.

서비스 레이어 사용

@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;

    // readOnly=true → RoutingDataSource가 Replica 선택
    @Transactional(readOnly = true)
    public List<Order> findAllOrders() {
        return orderRepository.findAll();
    }

    // readOnly=false (기본) → Primary 선택
    public Order createOrder(OrderRequest request) {
        return orderRepository.save(Order.from(request));
    }
}

주의사항

항목내용
복제 지연Replica에는 최신 데이터가 없을 수 있음. 직후 조회 필요 시 readOnly = false 사용
트랜잭션 경계@Transactional 진입 시점에 DataSource 결정. 중간 변경 불가
LazyConnectionDataSourceProxy반드시 사용. 없으면 readOnly 플래그 인식 실패
분산 트랜잭션두 DataSource에 걸친 원자적 트랜잭션은 XA 트랜잭션 또는 Saga 패턴 필요

정리

패턴적합한 상황
@Primary 수동 분리특정 레이어가 명시적으로 Replica를 주입받는 경우
별도 JPA 설정완전히 다른 스키마/DB를 가진 도메인 분리
AbstractRoutingDataSource@Transactional(readOnly) 기반 투명한 읽기/쓰기 분리

세 번째 패턴이 가장 투명하고 비즈니스 코드에 대한 침투성이 가장 낮습니다. @Transactional(readOnly = true)를 습관처럼 붙이는 것만으로 읽기 부하가 Replica로 분산됩니다.


지난 글: Spring Boot DataSource Auto-configuration 파헤치기

다음 글: Spring Boot Flyway & Liquibase — DB 마이그레이션 자동화


읽어주셔서 감사합니다. 😊