[Spring Boot] Multi-DataSource: 복수 DB 연결과 트랜잭션
정의
한 Spring Boot 앱이 여러 DB / 여러 스키마 에 접근. 패턴 세 가지:
- 물리적으로 분리된 DB (orders DB + analytics DB)
- Read replica (write → primary, read → replica)
- Multi-tenant (tenant 별 DB)
기본 자동 설정은 단일 DataSource 만 가정. 복수는 명시적 Bean 등록 + @Qualifier 또는 routing.
두 DB 분리 (가장 흔함)
spring:
datasource:
primary:
url: jdbc:postgresql://primary-db/orders
username: orders_user
password: ${PRIMARY_DB_PASSWORD}
hikari:
maximum-pool-size: 20
analytics:
url: jdbc:postgresql://analytics-db/analytics
username: analytics_user
password: ${ANALYTICS_DB_PASSWORD}
hikari:
maximum-pool-size: 10
@Configuration
public class DataSourceConfig {
@Bean @Primary
@ConfigurationProperties("spring.datasource.primary")
DataSourceProperties primaryProps() { return new DataSourceProperties(); }
@Bean @Primary
@ConfigurationProperties("spring.datasource.primary.hikari")
DataSource primaryDataSource(DataSourceProperties props) {
return props.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean
@ConfigurationProperties("spring.datasource.analytics")
DataSourceProperties analyticsProps() { return new DataSourceProperties(); }
@Bean
@ConfigurationProperties("spring.datasource.analytics.hikari")
@Qualifier("analytics")
DataSource analyticsDataSource(@Qualifier("analyticsProps") DataSourceProperties props) {
return props.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
}
@Primary Bean 이 자동 설정 (JPA, JdbcTemplate) 에 사용됨. @Qualifier 로 두 번째 선택.
JPA + 두 DB
@Configuration
@EnableJpaRepositories(
basePackages = "com.example.orders",
entityManagerFactoryRef = "primaryEmf",
transactionManagerRef = "primaryTm"
)
public class PrimaryJpaConfig {
@Bean @Primary
LocalContainerEntityManagerFactoryBean primaryEmf(
EntityManagerFactoryBuilder builder,
@Qualifier("primaryDataSource") DataSource ds) {
return builder.dataSource(ds)
.packages("com.example.orders")
.persistenceUnit("primary")
.build();
}
@Bean @Primary
PlatformTransactionManager primaryTm(@Qualifier("primaryEmf") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
@Configuration
@EnableJpaRepositories(
basePackages = "com.example.analytics",
entityManagerFactoryRef = "analyticsEmf",
transactionManagerRef = "analyticsTm"
)
public class AnalyticsJpaConfig {
@Bean
LocalContainerEntityManagerFactoryBean analyticsEmf(
EntityManagerFactoryBuilder builder,
@Qualifier("analyticsDataSource") DataSource ds) {
return builder.dataSource(ds)
.packages("com.example.analytics")
.persistenceUnit("analytics")
.build();
}
@Bean
PlatformTransactionManager analyticsTm(@Qualifier("analyticsEmf") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
각 entity 가 다른 패키지에 있어야. @Repository 도 각 패키지 안에.
package com.example.orders;
public interface OrderRepository extends JpaRepository<Order, Long> {}
package com.example.analytics;
public interface MetricRepository extends JpaRepository<Metric, Long> {}
서비스에서 사용
@Service
class ReportService {
private final OrderRepository orderRepo;
private final MetricRepository metricRepo;
@Transactional("primaryTm")
public void recordOrder(Order o) {
orderRepo.save(o);
}
@Transactional("analyticsTm")
public void recordMetric(Metric m) {
metricRepo.save(m);
}
}
@Transactional("name") 으로 사용할 TransactionManager 명시. 두 DB 동시 변경은 별도 메서드로 분리 (각자 트랜잭션).
Read Replica (AbstractRoutingDataSource)
쓰기: primary, 읽기: replica.
public enum DbRouting { WRITE, READ }
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? DbRouting.READ : DbRouting.WRITE;
}
}
@Configuration
public class RoutingConfig {
@Bean
DataSource routingDataSource(
@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("replicaDataSource") DataSource replica) {
ReadWriteRoutingDataSource routing = new ReadWriteRoutingDataSource();
Map<Object, Object> targets = new HashMap<>();
targets.put(DbRouting.WRITE, primary);
targets.put(DbRouting.READ, replica);
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(primary);
return routing;
}
@Bean
DataSource lazyDataSource(DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
LazyConnectionDataSourceProxy 가 중요: connection 획득을 첫 SQL 시점까지 지연. 그래야 @Transactional(readOnly=true) 가 readOnly flag 를 먼저 set 한 뒤 connection 가져옴.
@Service
class UserService {
@Transactional(readOnly = true)
public User findById(Long id) { // replica 사용
return repo.findById(id).orElseThrow();
}
@Transactional // primary 사용
public User create(UserDto dto) {
return repo.save(new User(dto));
}
}
분산 트랜잭션 (피하기)
두 DB 동시 commit / rollback 보장은 JTA 분산 트랜잭션 (XA). 복잡 + 성능 저하 + Atomikos 같은 별도 라이브러리. 거의 항상 피해야.
대신:
- Outbox 패턴: 한 DB 에 event 기록 → 별도 worker 가 다른 시스템에 publish
- Saga 패턴: 분리된 트랜잭션 + 보상 트랜잭션
- 양쪽 DB 가 같은 vendor 면 logical replication 검토
Multi-tenant 패턴
패턴 1: tenant per DB
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.current(); // ThreadLocal
}
}
filter / interceptor 에서 tenant 결정:
@Component
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) {
String tenant = req.getHeader("X-Tenant-Id");
TenantContext.set(tenant);
try { chain.doFilter(req, resp); }
finally { TenantContext.clear(); }
}
}
패턴 2: tenant per schema (Hibernate 멀티 테넌시)
spring:
jpa:
properties:
hibernate:
multiTenancy: SCHEMA
tenant_identifier_resolver: com.example.TenantResolver
같은 DB, 다른 schema. row-level 보다 격리 좋음.
패턴 3: row-level (Discriminator)
같은 schema, tenant_id 컬럼. 단순하지만 격리 약함.
HikariCP 튜닝 per DataSource
spring:
datasource:
primary:
hikari:
maximum-pool-size: 30
minimum-idle: 5
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
validation-timeout: 3000
크기 공식: pool_size = ((core_count × 2) + effective_spindle_count) (PostgreSQL 권장). 너무 큰 pool 은 thrashing.
함정과 베스트 프랙티스
@Primary명시 필수: 자동 설정이 어느 걸 쓸지 알아야 함@Transactional("name")으로 TM 선택: default 가transactionManager- 두 DB 동시 commit 보장 불가: outbox / saga 패턴
LazyConnectionDataSourceProxy: read replica 라우팅에 필수@Repository패키지 분리 (JPA): basePackages 가 충돌하면 안 됨- HikariCP pool size: per-DataSource 독립적으로 튜닝
- connection 누수 감지:
leak-detection-threshold운영 켜기 - Flyway/Liquibase 분리: 각 DB 별 migration 폴더
- 테스트는 Testcontainers: 진짜 분리 환경 검증
관련 위키
이 글의 용어 (6개)
- [JPA] PersistenceContext: 1차 캐시, dirty checking, flushspring
- 정의 PersistenceContext 는 가 관리하는 entity 의 메모리 저장소 + 변경 추적기. 트랜잭션 안에서만 살아 있다 ( 진입 시 생성, commit/rollbac…
- [Spring Boot] Database Initialization: schema.sql, Flyway, Liquibasespring
- 정의 DB 스키마 / 데이터를 application 시작 시점에 자동 설정하는 메커니즘. 세 가지 접근: 1. / (Spring Boot 내장, 단순) 2. Hibernate (…
- [Spring] JdbcTemplate, NamedParameterJdbcTemplatespring
- 정의 JdbcTemplate은 JPA 없이 raw SQL을 안전하게 다루는 Spring 표준. boilerplate 제거 (try-with-resources, exception …
- [Spring] RequestContextHolder, LocaleContextHolder: ThreadLocal 기반 요청 컨텍스트spring
- 정의 Spring 은 HTTP 요청 / 응답 / locale / 트랜잭션 / 인증 같은 요청 범위 데이터를 ThreadLocal 에 저장 해 전역 노출한다. 메서드 인자로 일일이…
- [Spring] Spring Data JPA: Repository, Query Methodsspring
- 정의 Spring Data JPA는 JPA(Hibernate) 위에 Repository 추상화를 얹어 boilerplate를 제거. 인터페이스만 정의하면 Spring이 구현체 자…
- [Spring] Transaction: @Transactional, propagation, isolationspring
- 정의 Spring 트랜잭션은 AOP 기반 선언형 트랜잭션 관리. 한 줄로 메서드 전체를 트랜잭션 범위로. JDBC, JPA, MongoDB 등 backend에 동일 API. 기본…
💬 댓글