멀티 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를 사용할 때는 EntityManagerFactory와 TransactionManager도 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.exclude로DataSourceAutoConfiguration을 제외해야 충돌을 방지한다.
정리
AbstractRoutingDataSource는 Spring에서 멀티 DataSource를 투명하게 전환하는 핵심 추상화다. Read/Write 분리에는 @Transactional(readOnly=true) + LazyConnectionDataSourceProxy 조합이 가장 깔끔하고, 멀티테넌트에는 HTTP 헤더에서 테넌트를 추출해 ThreadLocal로 라우팅하는 패턴이 표준이다. AOP의 @Order와 LazyProxy를 빠뜨리면 라우팅이 동작하지 않으니 주의하라.