레플리케이션 환경에서의 동적 DataSource 라우팅
레플리케이션 환경에서는 트랜잭션의 속성(ReadOnly 여부 등)에 따라 Master / Replica(DataSource)를 동적으로 선택해야 한다. 이를 위해 스프링에서는 동적 라우팅을 위한
AbstractRoutingDataSource를 제공한다.🔖 AbstractRoutingDataSource ?
AbstractRoutingDataSource는 DataSource 인터페이스의 구현체로,
여러 DataSource를 하나로 묶고, 런타임에 실제 사용할 DataSource를 결정하는 역할을 한다.
ReadOnly 트랜잭션 여부에 따라 Master / Slave를 분기하는 예시이다.
public class RoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); logger.info("현재 트랜잭션이 ReadOnly인가? : {}", isReadOnly); return isReadOnly ? "slave" : "master"; } }
- determineCurrentLookupKey()는 실제 사용할 DataSource를 결정하기 위한 Key를 반환 ⇒ 해당 Key는 targetDataSources에 등록된 Map의 Key와 매칭된다.
- 트랜잭션의 ReadOnly 여부는
TransactionSynchronizationManager를 통해 조회한다.
AbstractRoutingDataSource 호출 시점
- @Transactional를 만나면, PlatformTransactionManager가 트랜잭션을 시작한다. 이때 트랜잭션의 속성(전파, 격리 수준, readOnly 등)이 TransactionStatus 형태로 생성됨
- 해당 정보는 TransactionSynchronizationManager(ThreadLocal)에 저장된다.
- 이후 흐름에서 실제 커넥션이 필요해지는 시점(DB 접근)에 도달하게 되고,
DataSourceUtils.getConnection()이 호출된다.
[3]에서 스프링에 등록된 DataSource가 AbstractRoutingDataSource일 경우,
커넥션을 생성하기 전에 어떤 DataSource를 사용할지 먼저 결정해야하므로, determineCurrentLookupKey()가 호출된다.
즉, AbstractRoutingDataSource는 커넥션을 획득하는 시점에서 바로 라우팅 판단을 수행한다.
TransactionSynchronizationManager를 통한 트랜잭션 속성 조회
TransactionSynchronizationManager.isCurrentTransactionReadOnly()
- TransactionSynchronizationManager ThreadLocal에 저장해둔 TransactionStatus 정보 중 ReadOnly 여부를 조회
- 해당 Key 값에 따라 master 또는 slave를 반환하게 되고, 이를 기반으로 실제 DataSource 확정
🔖 RoutingDataSource Bean 설정
@Bean public DataSource routingDataSource( @Qualifier("master") DataSource masterDataSource, @Qualifier("slave") DataSource slaveDataSource) { RoutingDataSource routingDataSource = new RoutingDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", masterDataSource); targetDataSources.put("slave", slaveDataSource); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(masterDataSource); return routingDataSource; }
- isCurrentTransactionReadOnly()를 통해 결정된DataSoruce를 RoutingDataSource 스프링 빈으로 등록해준다. ⇒ PlatformTransactionManager에서 내부적으로 이 빈을 통해 커넥션을 생성하게 된다.
이후의 과정들은 단일 DB 인스턴스 환경과 동일하지만,
LazyConnectionDataSourceProxy를 통해 커넥션 획득 시점을 지연시켜 AbstractRoutingDataSource가 정상적으로 트랜잭션 ReadOnly 상태 값을 읽어올 수 있게 할 것이다. LazyConnectionDataSourceProxy
스프링은 트랜잭션에 진입하는 순간, 설정된 DataSource로부터 커넥션을 획득한다.
⇒ 실제로는 DB를 사용하지 않더라도 커넥션이 불필요하게 점유되는 문제가 생길 수 있다.
- 캐시(Ehcache — Java EE 범용 캐싱)를 사용하는 경우 : 실제로 캐시에서 조회만 하고 DB에 접근하지 않는데도, 불필요하게 커넥션을 점유
- JPA 1차 캐시(영속성 컨텍스트)에만 의존하는 경우 : 이미 로딩된 엔티티를 1차 캐시에서만 조회해도 트랜잭션에 들어가는 순간 커넥션을 확보하기에, 불필요하게 커넥션을 점유
- 외부 서비스 호출 이후에만 DB를 쓰는 경우 : 외부 응답을 기다리는 동안에도 커넥션이 점유되어 커넥션 풀 자원 낭비
- 멀티 DataSource 환경에서의 라우팅 문제 : 트랜잭션 진입 시점에 이미 특정 DataSource에서 커넥션을 가져와 버리기에, 트랜잭션 로직에 따라 DataSource를 분기하기 어렵다.
@Transactional @Service public class LazyService { private final DataSource dataSource; public void lazy() { // 호출 시점에 스프링 트랜잭션 시작 HikariPoolMXBean hikariPool; if(datasource instanceof LazyConnectionDataSourceProxy) { hikariPool = ((HikariDataSource) (LazyConnectionDataSourceProxy) dataSource).getTargetDataSource()).getHikariPoolMXBean(); } else { hikariPool = ((HikariDataSource) dataSource).getHikariPoolMXBean(); } log.info("TotalConnection = {}", hikariPool.getTotalConnections()); log.info("ActiveConnection = {}", hikariPool.getActiveConnections()); log.info("IdleConnection = {}", hikariPool.getIdleConnections()); } }
lazy()는 DBCP의 DataSource 타입에 따라 분기 처리를 한다.
실제 DB와 통신하며 쿼리를 날리지 않고, HikariCP 커넥션 풀 상태만 로그로 확인한다.DataSource 타입에 따른 분기
- HikariDataSource인 경우 (Lazy 프록시가 없는 경우)
: 트랜잭션 진입과 동시에 PlatformTransactionManager 구현체
getConnection()를 호출하여 커넥션 1개가 active로 잡힘
- LazyConnectionDataSourceProxy인 경우
: 트랜잭션이 시작해도 아직 실제
HikariDataSource에서 커넥션을 가져오지 않음 ⇒ active 커넥션 수 증가하지 않음
HikariCP 기반 커넥션 풀 + LazyConnectionDataSourceProxy 조합 구현
@Component public class LazyProxy { @Bean public DataSource lazyDataSource(final DataSourceProperties dataSourceProperties) { final HikariConfig hikariConfig = new HikariConfig(); hikariConfig.setJdbcUrl(dataSourceProperties.getUrl()); hikariConfig.setDriverClassName(dataSourceProperties.getDriverClassName()); hikariConfig.setUsername(dataSourceProperties.getUsername()); hikariConfig.setPassword(dataSourceProperties.getPassword()); return new LazyConnectionDataSourceProxy(new HikarDataSource(hikariConfig)); } }
- 실제 DB 커넥션을 관리할 HikariDataSource를 만든다.
- 그 위를 LazyConnectionDataSourceProxy로 감싸서, 스프링 컨테이너에는 Lazy 프록시 DataSource 를 빈으로 등록한다.
1. LazyConnectionDataSourceProxy 를 사용하지 않았을 때 결과

- DB에 아무런 작업을 하지 않았음에도 커넥션이 불필요하게 사용
2. LazyConnectionDataSourceProxy 를 사용했을때 결과

- 커넥션이 꼭 필요한 시점이 되기까지 커넥션 지연시켜 잡는다.
커넥션을 가져오는 시점
[일반적인 흐름]

- 트랜잭션이 걸린 메서드가
CGLIB 프록시를 통해 호출되면, 그 순간부터 트랜잭션이 시작된다.
- 트랜잭션의 커밋·롤백을 담당하는 TransactionManager가 동작하고, 설정된 DataSource를 기반으로 DB 커넥션을 하나 만들어 사용한다.
- TransactionSynchronizationManager가 현재 트랜잭션과 커넥션 정보를 등록해, 동일 트랜잭션 내에서 동일 커넥션을 쓰도록 동기화를 보장한다.
[3] 시점에서 이미 DataSource도 확정됐고 커넥션까지 생성이 끝난 뒤이다.
하지만 LazyConnectionDataSourceProxy 를 사용하면 흐름이 달라진다. 트랜잭션은 평소처럼 시작되지만, 실제로 DB에 쿼리를 날리기 전까지는 커넥션을 가져오지 않는다.
따라서, 정말로 DB와 통신해야 하는 순간까지 커넥션 점유를 미루어, 트랜잭션은 열려 있어도 커넥션 풀 자원은 불필요하게 묶어 두지 않게 된다.
Multi DataSource 환경에서 LazyProxy 의 역할
// AbstractRoutingDataSource : Multi DataSource 환경에서 여러 DataSource 를 묶고 분기해줄 때 사용 public class RoutingDataSource extends AbstractRoutingDataSource { // 여러 datasource 중에서 실제로 사용될 DataSource 를 결정하는 역할 // 현재 트랜잭션의 속성에 따라 targetDataSourceMap 의 조회 Key 를 결정하기위해 AbstractRoutingDataSource 를 상속받아서 determineCurrentLookupKey 를 구현 @Override protected Object determineCurrentLookupKey() { boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); logger.info("현재 트랜잭션 속성이 ReadOnly인가?:" + isReadOnly); return isReadOnly ? "slave" : "master"; } }
- AbstractRoutingDataSource를 상속한 RoutingDataSource가 있고, determineCurrentLookupKey()에서 현재 트랜잭션의 readOnly 여부를 보고 "master" 또는 "slave"를 리턴해 DataSource를 선택한다.
- 이때 readOnly 여부는 TransactionSynchronizationManager.isCurrentTransactionReadOnly()로 가져온다.
문제 1 : LazyConnectionDataSourceProxy를 써도 커넥션이 바로 안 잡히는 경우
1. JpaTransactionManager#doBegin() — 트랜잭션 시작 시점
@Override protected void doBegin(Object transaction, TransactionDefinition definition) { EntityManager em = txObject.getEntityManagerHolder().getEntityManager(); int timeoutToUse = determineTimeout(definition); Object transactionData = getJpaDialect().beginTransaction( // <- 문제가 되는 지점 em, new JpaTransactionDefinition(definition, timeoutToUse, txObject.isNewEntityManagerHolder()) ); txObject.setTransactionData(transactionData); txObject.setReadOnly(definition.isReadOnly()); }
doBegin()이 호출되며 트랜잭션이 시작되고, EntityManager가 트랜잭션 컨텍스트에 바인딩된다. 이후, JPA에 트랜잭션 시작을 요청(EntityTransaction.begin())한다.
하지만, 이 시점에서 DataSouce로부터 JDBC 커넥션 획득이 이루어지진 않는다. beginTransaction()은 트랜잭션 시작이지, 커넥션 획득이 아니다.
커넥션 획득 시점은 Hibernate 전략에 달려 있다. 트랜잭션 시작 시점에 커넥션을 잡게 할 수도 있고,
SQL 쿼리 실행이나 Flush() 시점에 획득하게 할 수도 있다.
doCommit() — JDBC 커밋 시점
@Override protected void doCommit(DefaultTransactionStatus status) { JpaTransactionObject txObject = (JpaTransactionObject) status.getTransaction(); EntityTransaction tx = txObject.getEntityManagerHolder().getEntityManager().getTransaction(); tx.commit(); }
JDBC 획득 → SQL 실행 및 Flush() → [doCommit → tx.commit()] → JDBC → 커넥션 반환
트랜잭션이 정상 종료되면
JpaTransactionManager#doCommit()이 호출된다. tx.commit()을 호출하면 ‘JPA 트랜잭션’이 커밋된다.
- DB 커넥션의 경우는 내부적으로 Hibernate가 JDBC 커밋을 수행한다.
문제 2 : LazyConnectionDataSourceProxy 없이 라우팅만 쓰는 경우
Lazy가 없는 기본 동작 흐름
- 트랜잭션이 시작 (@Transactional 진입 → 프록시 호출)
- TransactionSynchronizationManager가 DataSource에서 커넥션을 바로 가져온다.
- 그 이후에 트랜잭션 정보가 TransactionSynchronizationManager 에 묶인다.
커넥션을 가져오는 시점에는 이미 “어떤 DataSource에서 커넥션을 가져올지”가 결정된 상태이고, TransactionSynchronizationManager가 읽는 readOnly 정보는 커넥션이 잡힌 뒤에나 의미를 갖는다.
⚠️ 왜 문제가 되는가?
단순히 AbstractRoutingDataSource만 두고
determineCurrentLookupKey()에서 readOnly를 체크하려고 해도, 타이밍 역전 문제가 생긴다.- 커넥션을 얻는 시점 = 이미 라우팅이 끝난 시점
- 라우팅할 때 참조해야 할 readOnly 정보는 “커넥션을 잡기 전에” 필요하지만, 실제로는 그 전에 커넥션을 잡아버림
해결: LazyConnectionDataSourceProxy 로 커넥션 획득 시점 지연
- 트랜잭션이 시작 (@Transactional 진입 → 프록시 호출)
- TransactionSynchronizationManager는 아직 실제 커넥션을 가져오지 않고, Lazy 프록시 기준으로만 트랜잭션을 묶음
- 트랜잭션 정보는 TransactionSynchronizationManager ThreadLocal에 이미 들어가 있는 상태
- 실제로 쿼리를 실행하기 위해 PlatformTransactionManager의 getConnection()이 호출
- 이때 AbstractRoutingDataSource의
determineCurrentLookupKey()가 호출된다. - 이때는
TransactionSynchronizationManager에서 readOnly 값을 읽을 수 있고, - 그 값을 기준으로
"master"또는"slave"를 선택해서 해당 DataSource에서 커넥션을 가져온다.
즉, LazyConnectionDataSourceProxy가 하는 역할은
- “커넥션을 언제 잡느냐”를 뒤로 미루는 것이고
- 그 덕분에 라우팅 시점에 트랜잭션 readOnly 정보가 이미 세팅된 상태가 되어서
TransactionSynchronizationManager.isCurrentTransactionReadOnly()결과를 이용해 정확하게 master/slave DataSource를 분기할 수 있게 되는 것.



