Spring Multi-DataSource 라우팅

멀티 DataSource가 필요한 경우

하나의 Spring 애플리케이션에서 여러 데이터베이스에 접근해야 하는 상황은 흔하다. Read/Write 분리(Primary-Replica), 멀티테넌트 아키텍처, 레거시 DB 통합 등이 대표적이다. Spring Boot는 기본적으로 단일 DataSource를 자동 구성하지만, AbstractRoutingDataSource를 사용하면 런타임에 동적으로 DataSource를 전환할 수 있다.

AbstractRoutingDataSource 원리

AbstractRoutingDataSource는 여러 DataSource를 Map으로 보유하고, determineCurrentLookupKey() 메서드의 반환값으로 어떤 DataSource를 사용할지 결정한다.

// 1. DataSource 키 정의
public enum DataSourceType {
    PRIMARY, REPLICA
}

// 2. ThreadLocal로 현재 컨텍스트 관리
public class DataSourceContext {
    private static final ThreadLocal<DataSourceType> CONTEXT =
        ThreadLocal.withInitial(() -> DataSourceType.PRIMARY);

    public static void set(DataSourceType type) { CONTEXT.set(type); }
    public static DataSourceType get() { return CONTEXT.get(); }
    public static void clear() { CONTEXT.remove(); }
}

// 3. RoutingDataSource 구현
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContext.get();
    }
}

Read/Write 분리 구성

# application.yml
spring:
  datasource:
    primary:
      url: jdbc:mysql://primary-db:3306/myapp
      username: app_writer
      password: ${DB_PRIMARY_PASSWORD}
      hikari:
        maximum-pool-size: 20
        connection-timeout: 3000
    replica:
      url: jdbc:mysql://replica-db:3306/myapp
      username: app_reader
      password: ${DB_REPLICA_PASSWORD}
      hikari:
        maximum-pool-size: 30      # 읽기 트래픽이 더 많으므로
        connection-timeout: 3000
@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.primary.hikari")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create()
            .url(primaryUrl).username(primaryUser).password(primaryPw)
            .type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties("spring.datasource.replica.hikari")
    public DataSource replicaDataSource() {
        return DataSourceBuilder.create()
            .url(replicaUrl).username(replicaUser).password(replicaPw)
            .type(HikariDataSource.class).build();
    }

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

        RoutingDataSource routing = new RoutingDataSource();
        Map<Object, Object> targetDataSources = Map.of(
            DataSourceType.PRIMARY, primary,
            DataSourceType.REPLICA, replica
        );
        routing.setTargetDataSources(targetDataSources);
        routing.setDefaultTargetDataSource(primary);
        return routing;
    }

    @Bean
    public DataSourceTransactionManager transactionManager(
            @Qualifier("routingDataSource") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

AOP로 자동 라우팅

매번 DataSourceContext.set()을 호출하는 것은 번거롭다. 커스텀 어노테이션 + AOP로 자동화할 수 있다.

// 커스텀 어노테이션
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {}

// AOP Aspect
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)  // 트랜잭션 AOP보다 먼저 실행
public class DataSourceRoutingAspect {

    @Around("@annotation(readOnly)")
    public Object routeToReplica(ProceedingJoinPoint pjp, ReadOnly readOnly) throws Throwable {
        try {
            DataSourceContext.set(DataSourceType.REPLICA);
            return pjp.proceed();
        } finally {
            DataSourceContext.clear();
        }
    }
}

// 사용
@Service
public class UserService {

    @ReadOnly  // → Replica DB로 라우팅
    @Transactional(readOnly = true)
    public List<User> findAll() {
        return userRepository.findAll();
    }

    @Transactional  // → Primary DB (기본)
    public User create(CreateUserDto dto) {
        return userRepository.save(new User(dto));
    }
}

@Order(Ordered.HIGHEST_PRECEDENCE)가 핵심이다. DataSource 라우팅 AOP가 Spring Transaction AOP보다 먼저 실행되어야 트랜잭션 시작 전에 올바른 DataSource가 선택된다.

@Transactional(readOnly=true) 자동 라우팅

어노테이션 없이 @Transactional(readOnly = true)만으로 자동 라우팅하는 방법도 있다.

public class TransactionRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        boolean isReadOnly = TransactionSynchronizationManager
            .isCurrentTransactionReadOnly();
        return isReadOnly ? DataSourceType.REPLICA : DataSourceType.PRIMARY;
    }
}

// LazyConnectionDataSourceProxy로 감싸야 정상 동작
@Bean
@Primary
public DataSource dataSource(
        @Qualifier("routingDataSource") DataSource routingDataSource) {
    return new LazyConnectionDataSourceProxy(routingDataSource);
}

LazyConnectionDataSourceProxy실제 SQL 실행 시점까지 커넥션 획득을 지연한다. 이것 없이는 트랜잭션 시작 시점에 이미 커넥션이 결정되어 readOnly 플래그가 반영되지 않는다.

멀티테넌트 DataSource 라우팅

// 테넌트별 DataSource 동적 등록
@Component
public class TenantDataSourceManager {
    private final Map<String, DataSource> tenantDataSources = new ConcurrentHashMap<>();
    private final RoutingDataSource routingDataSource;

    public void addTenant(String tenantId, String url, String user, String pw) {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(url);
        ds.setUsername(user);
        ds.setPassword(pw);
        ds.setMaximumPoolSize(10);

        tenantDataSources.put(tenantId, ds);

        // 런타임에 DataSource 맵 갱신
        Map<Object, Object> newTargets = new HashMap<>(tenantDataSources);
        routingDataSource.setTargetDataSources(newTargets);
        routingDataSource.afterPropertiesSet();  // 변경 적용
    }

    public void removeTenant(String tenantId) {
        DataSource ds = tenantDataSources.remove(tenantId);
        if (ds instanceof HikariDataSource hikari) {
            hikari.close();  // 커넥션 풀 해제
        }
    }
}

// HTTP 요청에서 테넌트 추출 → ThreadLocal 설정
@Component
public class TenantFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String tenantId = ((HttpServletRequest) req).getHeader("X-Tenant-Id");
        TenantContext.set(tenantId);
        try {
            chain.doFilter(req, res);
        } finally {
            TenantContext.clear();
        }
    }
}

JPA와 멀티 DataSource

JPA를 사용할 때는 EntityManagerFactoryTransactionManager도 DataSource별로 분리해야 한다.

// 완전 분리 패턴: 서로 다른 스키마의 DB
@Configuration
@EnableJpaRepositories(
    basePackages = "com.example.order.repository",
    entityManagerFactoryRef = "orderEntityManager",
    transactionManagerRef = "orderTransactionManager"
)
public class OrderDataSourceConfig {

    @Bean
    public LocalContainerEntityManagerFactoryBean orderEntityManager(
            @Qualifier("orderDataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.order.entity");
        em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        return em;
    }

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

모니터링과 주의사항

  • 커넥션 풀 모니터링 — 각 DataSource의 HikariCP 메트릭을 Micrometer로 수집한다. hikaricp_connections_active, hikaricp_connections_pending 지표가 핵심이다.
  • Replication Lag — Replica는 Primary보다 데이터가 지연될 수 있다. 쓰기 직후 읽기는 반드시 Primary에서 수행한다.
  • ThreadLocal 누수 방지DataSourceContext.clear()를 반드시 finally 블록에서 호출한다. 스레드 풀 환경에서 이전 요청의 컨텍스트가 남으면 잘못된 DB로 쿼리가 나간다.
  • 분산 트랜잭션 회피 — 두 DataSource에 걸친 트랜잭션은 XA가 필요하다. 가능하면 하나의 트랜잭션에서 하나의 DataSource만 사용하도록 설계한다.
  • Spring Boot 자동 구성 비활성화 — 멀티 DataSource 사용 시 spring.autoconfigure.excludeDataSourceAutoConfiguration을 제외해야 충돌을 방지한다.

정리

AbstractRoutingDataSource는 Spring에서 멀티 DataSource를 투명하게 전환하는 핵심 추상화다. Read/Write 분리에는 @Transactional(readOnly=true) + LazyConnectionDataSourceProxy 조합이 가장 깔끔하고, 멀티테넌트에는 HTTP 헤더에서 테넌트를 추출해 ThreadLocal로 라우팅하는 패턴이 표준이다. AOP의 @Order와 LazyProxy를 빠뜨리면 라우팅이 동작하지 않으니 주의하라.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux