레플리케이션 환경에서의 동적 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 호출 시점

  1. @Transactional를 만나면, PlatformTransactionManager가 트랜잭션을 시작한다. 이때 트랜잭션의 속성(전파, 격리 수준, readOnly 등)이 TransactionStatus 형태로 생성됨
  1. 해당 정보는 TransactionSynchronizationManager(ThreadLocal)에 저장된다.
  1. 이후 흐름에서 실제 커넥션이 필요해지는 시점(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 를 사용하지 않았을 때 결과

notion image
  • DB에 아무런 작업을 하지 않았음에도 커넥션이 불필요하게 사용
 

2. LazyConnectionDataSourceProxy 를 사용했을때 결과

notion image
  • 커넥션이 꼭 필요한 시점이 되기까지 커넥션 지연시켜 잡는다.
 

커넥션을 가져오는 시점

[일반적인 흐름]

notion image
  1. 트랜잭션이 걸린 메서드가 CGLIB 프록시를 통해 호출되면, 그 순간부터 트랜잭션이 시작된다.
  1. 트랜잭션의 커밋·롤백을 담당하는 TransactionManager가 동작하고, 설정된 DataSource를 기반으로 DB 커넥션을 하나 만들어 사용한다.
  1. 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가 없는 기본 동작 흐름
  1. 트랜잭션이 시작 (@Transactional 진입 → 프록시 호출)
  1. TransactionSynchronizationManager가 DataSource에서 커넥션을 바로 가져온다.
  1. 그 이후에 트랜잭션 정보가 TransactionSynchronizationManager 에 묶인다.
 커넥션을 가져오는 시점에는 이미 “어떤 DataSource에서 커넥션을 가져올지”가 결정된 상태이고, TransactionSynchronizationManager가 읽는 readOnly 정보는 커넥션이 잡힌 뒤에나 의미를 갖는다.
 
⚠️ 왜 문제가 되는가?
 단순히 AbstractRoutingDataSource만 두고 determineCurrentLookupKey()에서 readOnly를 체크하려고 해도, 타이밍 역전 문제가 생긴다.
  • 커넥션을 얻는 시점 = 이미 라우팅이 끝난 시점
  • 라우팅할 때 참조해야 할 readOnly 정보는 “커넥션을 잡기 전에” 필요하지만, 실제로는 그 전에 커넥션을 잡아버림
 

해결: LazyConnectionDataSourceProxy 로 커넥션 획득 시점 지연

  1. 트랜잭션이 시작 (@Transactional 진입 → 프록시 호출)
  1. TransactionSynchronizationManager는 아직 실제 커넥션을 가져오지 않고, Lazy 프록시 기준으로만 트랜잭션을 묶음
  1. 트랜잭션 정보는 TransactionSynchronizationManager ThreadLocal에 이미 들어가 있는 상태
  1. 실제로 쿼리를 실행하기 위해 PlatformTransactionManager의 getConnection()이 호출
      • 이때 AbstractRoutingDataSource의 determineCurrentLookupKey()가 호출된다.
      • 이때는 TransactionSynchronizationManager에서 readOnly 값을 읽을 수 있고,
      • 그 값을 기준으로 "master" 또는 "slave"를 선택해서 해당 DataSource에서 커넥션을 가져온다.
 
즉, LazyConnectionDataSourceProxy가 하는 역할은
  • “커넥션을 언제 잡느냐”를 뒤로 미루는 것이고
  • 그 덕분에 라우팅 시점에 트랜잭션 readOnly 정보가 이미 세팅된 상태가 되어서
  • TransactionSynchronizationManager.isCurrentTransactionReadOnly() 결과를 이용해 정확하게 master/slave DataSource를 분기할 수 있게 되는 것.