개요
@Transactional(readOnly = true)를 붙이면 조회는 Slave로, 쓰기는 Master로 나가도록 DataSource를 라우팅하는 패턴은 널리 쓰입니다. 그런데 이 라우팅을 AbstractRoutingDataSource 만으로 구현하면 읽기 트랜잭션이 항상 Master로 가버리는 현상을 만나게 됩니다.
원인은 Spring 트랜잭션 매니저가 "커넥션을 빌리는 시점"과 "readOnly 플래그를 세팅하는 시점"의 순서에 있고, 이를 교정하는 장치가LazyConnectionDataSourceProxy입니다.
Spring Framework + HikariCP + MyBatis 조합을 기준으로 설명하겠습니다.
2. 배경 — Master/Slave 읽기 분리란
쓰기 부하와 읽기 부하를 분리하기 위해, 하나의 논리적 DB를 물리적으로 나눕니다.
- Master 서버:
INSERT / UPDATE / DELETE등 쓰기를 담당합니다. - Slave 서버: Master의 데이터를 복제(replication)받아
SELECT읽기만 담당합니다.
애플리케이션은 "이 쿼리가 읽기냐 쓰기냐"에 따라 커넥션을 적절한 서버로 보내야 합니다.
Spring에서는 이 분기 신호로 @Transactional의 readOnly 속성을 재활용하는 방식이 관행처럼 쓰입니다.
@Transactional(readOnly = true) // → Slave 로 가길 기대
public List<Metric> selectKpi() { ... }
@Transactional // readOnly 기본 false → Master 로 가길 기대
public int updateKpi(Metric m) { ... }
문제는 이 "기대"가 그냥은 성립하지 않는다는 점입니다.
3. 등장 계층과 래핑 순서
Master/Slave를 나눠 쓰는 DataSource는 단순 구조(DataSource → TransactionManager)에 두 계층이
더 얹힙니다. 래핑 순서가 이 글 전체의 핵심이므로 먼저 못 박아 둡니다.
DataSourceTransactionManager
│ (생성자로 아래 LazyProxy 를 주입받음)
▼
LazyConnectionDataSourceProxy ← 가장 바깥. "커넥션 획득을 미루는" 지연 계층
│
▼
RoutingDataSource ← readOnly 를 보고 master/slave 키를 고르는 분기 계층
│
┌────┴────┐
▼ ▼
Master 풀 Slave 풀 ← 실제 HikariCP 풀 (각각 독립된 TCP 커넥션 묶음)
설정 코드로 옮기면 다음과 같습니다. (이름은 설명을 위해 단순화한 예시입니다.)
// 1) 분기 계층: readOnly 를 보고 키를 고른다
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? DbType.SLAVE
: DbType.MASTER;
}
}
@Configuration
public class ServiceADBConfig {
@Bean
public DataSource routingDataSource(DataSource masterDataSource,
DataSource slaveDataSource) {
RoutingDataSource routing = new RoutingDataSource();
Map<Object, Object> targets = new HashMap<>();
targets.put(DbType.MASTER, masterDataSource);
targets.put(DbType.SLAVE, slaveDataSource);
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(masterDataSource); // 키를 못 정하면 master
return routing;
}
// 2) 지연 계층: RoutingDataSource 를 "바깥에서" 감싼다 ← 이 순서가 중요
@Bean
public DataSource dataSource(DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
// 3) 트랜잭션 매니저는 LazyProxy 를 받는다 (RoutingDataSource 를 직접 받지 않는다)
@Bean
public PlatformTransactionManager serviceATransactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
핵심: LazyProxy가 RoutingDataSource보다 바깥(트랜잭션 매니저 쪽)에 있어야 합니다.
왜 그래야 하는지가 이 글의 나머지 내용입니다. 이 순서가 뒤바뀌면(예: TM이 RoutingDataSource를
직접 받으면) 라우팅은 무조건 Master로 고정됩니다. 그 이유를 아래에서 단계적으로 풉니다.
4. RoutingDataSource는 정확히 "무엇을, 언제" 보고 결정하는가
AbstractRoutingDataSource.getConnection()은 호출될 때마다 아래 순서를 밟습니다.
// AbstractRoutingDataSource (스프링 내부, 개념적으로 단순화)
public Connection getConnection() {
return determineTargetDataSource().getConnection();
}
protected DataSource determineTargetDataSource() {
Object lookupKey = determineCurrentLookupKey(); // ← 우리가 오버라이드한 메서드
DataSource ds = this.resolvedDataSources.get(lookupKey);
if (ds == null) ds = this.resolvedDefaultDataSource; // 못 찾으면 default(master)
return ds;
}
여기서 두 가지가 확정됩니다.
- 판단 재료는 오직
determineCurrentLookupKey()의 반환값입니다. 우리 구현에서는TransactionSynchronizationManager.isCurrentTransactionReadOnly()한 가지에 의존합니다. - 판단 시점은
getConnection()이 호출되는 바로 그 순간입니다. 이 순간 readOnly 플래그가
ThreadLocal에 없으면(=false), 무조건 Master 키가 나옵니다.
즉 "readOnly가 언제 ThreadLocal에 세팅되느냐"와 "getConnection이 언제 호출되느냐"의 선후
관계가 라우팅 정확성을 좌우합니다.
5. 함정 — getTransaction() 내부는 두 단계로 나뉜다
AbstractPlatformTransactionManager.getTransaction()이 새 트랜잭션을 시작할 때, 내부는 개념적으로 아래 두 단계를 이 순서로 실행합니다.
getTransaction()
│
├─ (a) doBegin(transaction, definition) ← DataSourceTransactionManager 구현
│ Connection con = dataSource.getConnection(); ★ 커넥션을 여기서 빌린다
│ con.setAutoCommit(false);
│ // readOnly/isolation 을 커넥션에 반영 (prepareConnectionForTransaction)
│ ConnectionHolder 를 ThreadLocal 에 바인딩
│
└─ (b) prepareSynchronization(status, definition) ← AbstractPlatformTransactionManager
TransactionSynchronizationManager.setActualTransactionActive(true);
TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(...);
TransactionSynchronizationManager.setCurrentTransactionReadOnly(
definition.isReadOnly()); ★ readOnly 플래그를 여기서 세팅한다
TransactionSynchronizationManager.setCurrentTransactionName(...);
함정은 명확합니다. (a)가 (b)보다 먼저입니다.
- (a)
doBegin()에서 커넥션을 빌리는 순간,isCurrentTransactionReadOnly()는 아직 false입니다. (b)가 실행되기 전이니까요. - (b)
prepareSynchronization()에 와서야 비로소 readOnly 플래그가 ThreadLocal에 세팅됩니다.
정확성 참고:
doBegin()안에서DataSourceUtils.prepareConnectionForTransaction()이
물리 커넥션에con.setReadOnly(true)를 호출하기는 합니다. 하지만 이것은 JDBC 커넥션
객체의 속성일 뿐, RoutingDataSource가 참조하는TransactionSynchronizationManager의
ThreadLocal 플래그와는 별개입니다. 라우팅이 읽는 것은 후자이고, 후자는 (b)에서
세팅됩니다. 둘을 혼동하면 "커넥션에 readOnly를 넣었는데 왜 라우팅이 안 되지?"라는
착각에 빠지기 쉽습니다.
정리하면, 라우팅에 필요한 재료(readOnly ThreadLocal)는 (b)에서 준비되는데, 커넥션 획득은 (a)에서 일어난다 — 이 순서 어긋남이 모든 문제의 뿌리입니다.
6. LazyProxy 없이 — 왜 항상 Master로 가는가
TM이 RoutingDataSource를 직접 물고 있다고 가정해봅니다(TM → RoutingDataSource → 풀).selectKpi()(readOnly=true)를 호출하면 다음 타임라인이 펼쳐집니다.
시각 동작 isCurrentTransactionReadOnly()
──── ───────────────────────────────────────────── ──────────────────────────────
t0 getTransaction() 진입 false (초기값)
t1 (a) doBegin() → routingDataSource.getConnection()
└─ determineCurrentLookupKey() 실행!
isCurrentTransactionReadOnly() 조회 → false ← 아직 (b) 전!
→ MASTER 키 반환
└─ Master 풀에서 진짜 커넥션 획득 😱
t2 (b) prepareSynchronization()
setCurrentTransactionReadOnly(true) true ← 이제야 세팅
t3 첫 SQL 실행 (SELECT)
이미 t1에서 Master 커넥션을 잡아버림 → Master 로 SELECT
──── ───────────────────────────────────────────── ──────────────────────────────
결과: readOnly=true 인데도 Master. Slave 는 영영 선택되지 않는다.
핵심은 판단이 t1(커넥션 획득)에 일어나는데, 판단 재료는 t2(readOnly 세팅)에 준비된다는 것입니다. 시점이 한 칸 어긋나 있어, readOnly=true로 아무리 정성껏 선언해도 Slave 분기는 죽어 있습니다. Slave 서버는 놀고 있고 읽기 부하는 전부 Master로 쏠립니다.
7. LazyProxy 적용 — 어떻게 순서가 교정되는가
이번엔 올바른 래핑(TM → LazyProxy → RoutingDataSource → 풀)입니다.
시각 동작 isCurrentTransactionReadOnly()
──── ───────────────────────────────────────────── ──────────────────────────────
t0 getTransaction() 진입 false
t1 (a) doBegin() → lazyProxy.getConnection()
└─ 진짜로 안 빌린다. 속 빈 "프록시 Connection" 만 반환
(RoutingDataSource.getConnection() 은 아직 호출 안 됨 → 라우팅 보류)
└─ con.setAutoCommit(false)
→ 진짜 커넥션이 없으니 프록시에 "나중에 적용할 값"으로 기록만 함
└─ 프록시 ConnectionHolder 를 ThreadLocal 에 바인딩
t2 (b) prepareSynchronization()
setCurrentTransactionReadOnly(true) true ← 세팅 완료 ✅
t3 첫 SQL 실행 (SELECT)
프록시가 "이제 진짜 커넥션이 필요하다" 판단
└─ target(RoutingDataSource).getConnection() 을 이 순간 호출!
└─ determineCurrentLookupKey() 실행!
isCurrentTransactionReadOnly() → true ← (b)에서 세팅됨 ✅
→ SLAVE 키 반환
└─ Slave 풀에서 진짜 커넥션 획득 🎯
└─ t1 에서 기록해둔 setAutoCommit(false)/readOnly 를 진짜 커넥션에 재생(replay)
──── ───────────────────────────────────────────── ──────────────────────────────
결과: readOnly=true → 정확히 SLAVE.
한 줄로: LazyProxy는 커넥션 획득을 t1 → t3(첫 SQL)로 미룹니다. 그 사이 t2에서 readOnly가 세팅되므로, 라우팅 판단이 재료가 준비된 뒤에 일어나게 됩니다.
LazyProxy는 트랜잭션 "기능"을 추가하는 게 아니라 획득 시점을 지연시켜 순서를 교정하는 장치입니다.
8. 자주하는 실수
8-1. readOnly 메서드가 쿼리를 안 하면 커넥션도 안 빌린다 (부가 효과)
LazyProxy의 원래 목적은 라우팅이 아니라 "안 쓸 커넥션은 빌리지 말자"입니다. 아래처럼 @Transactional은 걸렸지만 분기 때문에 실제 쿼리를 한 번도 안 날리는 경우, 커넥션은 아예 빌려지지 않습니다.
@Transactional(readOnly = true, transactionManager = "serviceATransactionManager")
public List<Metric> selectKpiIfEnabled(boolean enabled) {
if (!enabled) {
return Collections.emptyList(); // ← SQL 없음 → 진짜 커넥션 획득 0회
}
return metricMapper.selectKpi();
}
LazyProxy가 없다면 doBegin()에서 무조건 커넥션을 빌렸다 반납했을 것입니다. 라우팅 정확성은 이 최적화가 덤으로 얻어준 효과라는 점을 기억하면, 왜 스프링이 이 방식을 택했는지가 자연스럽게 이해됩니다.
8-2. 전파(Propagation) 함정 — 쓰기 트랜잭션 안에서의 readOnly 조회는 Slave로 안 간다
가장 자주, 그리고 가장 조용히 발생하는 함정입니다. 극단적인 예시라서, 이런 일을 없을 것 같지만, 트랜재션 내의 트랜잭션을 묶는 코드라고 생각해봅시다.
기본 전파 속성은 REQUIRED이므로, 이미 트랜잭션이 열린 상태에서 호출된 @Transactional 메서드는 새 트랜잭션을 시작하지 않고 기존 트랜잭션에 참여합니다.
@Transactional(transactionManager = "serviceATransactionManager") // readOnly=false → Master
public void updateAll() {
List<Metric> kpis = metricRepository.selectKpi(); // readOnly=true 지만...
for (Metric kpi : kpis) {
metricRepository.updateKpi(kpi);
}
}
- 바깥
updateAll()이 먼저 트랜잭션을 시작하고, 첫 SQL 시점에 Master 커넥션을 확보해 ThreadLocal에 바인딩합니다. - 안쪽
selectKpi()는readOnly=true지만, 새 트랜잭션을 시작하지 않고 바깥 트랜잭션에 참여합니다. 새doBegin()도, 새getConnection()도 없습니다. - 따라서
selectKpi()의 SELECT도 이미 바인딩된 Master 커넥션으로 나갑니다. Slave로 가지 않습니다.
"조회 메서드에 readOnly를 붙였는데 왜 Slave로 안 가지?"의 대부분은 이 구조입니다. 바깥 트랜잭션이 이미 라우팅을 확정해버렸기 때문입니다. Slave로 보내고 싶다면 조회를 바깥 트랜잭션 밖에서 수행하거나, 별도 전파 속성(예: REQUIRES_NEW)으로 독립 트랜잭션을 열어야 합니다. (다만 REQUIRES_NEW는 커넥션을 하나 더 점유하므로 신중히 사용해야 합니다.)
9. "이렇게 복잡할 것 없이, 그냥 readOnly를 먼저 세팅하면 안 되나?"
LazyProxy라는 우회 대신, doBegin() 앞에서 readOnly를 미리 세팅하면 간단해 보입니다. 하지만 Spring이 그렇게 하지 않은 데는 분명한 설계 이유가 있습니다.
대전제. 트랜잭션을 "시작한다"는 것은 물리적으로 커넥션 위에서 autoCommit을 끄는 행위입니다.
트랜잭션은 커넥션 안에서만 존재하므로, doBegin()에서 커넥션 획득과 트랜잭션 시작은 떼어낼 수 없습니다. "커넥션 없이 트랜잭션 먼저 시작"은 성립하지 않습니다.
이유 1 — begin 이후 세팅은 의도된 것.
Spring은 prepareSynchronization()에서 readOnly - isolation - 트랜잭션 이름 - actualTransactionActive를 한 묶음으로 세팅합니다. 이 값들은 "지금 활성화된 트랜잭션"을 서술하기 때문입니다. 만약 begin 앞에서 readOnly를 박아뒀는데 getConnection()이 실패하면(풀 고갈·DB 다운), 시작도 못 한 트랜잭션의 플래그가 스레드에 오염된 채 남습니다. 현재 구조는 "begin 성공 뒤에만 상태를 세팅"하므로 실패 시 되돌릴 게 없습니다.
이유 2 — readOnly ThreadLocal은 원래 라우팅용이 아니다.
isCurrentTransactionReadOnly()는 본래 ORM 힌트로 설계됐습니다(예: 읽기 전용이면 flush 생략). 이 원래 소비자들은 값을 트랜잭션 중간(SQL 실행 무렵)에 읽으므로 "begin 이후 세팅"이어도 문제가 없었습니다. Master/Slave 라우팅은 이 힌트를 몰래 갖다 쓴(piggyback) 것이라, 유독 getConnection 시점에 값이 필요한 별종입니다. Spring이 라우팅을 염두에 두고 순서를 짠 것이 아닙니다.
이유 3 — 관심사 분리.
DataSourceTransactionManager는 아무 DataSource나 받아 동작하는 범용 인프라입니다. 그냥 getConnection()만 부를 뿐, 대상이 단순 Hikari인지 RoutingDataSource 인지 알지도, 알아서도 안 됩니다. "readOnly 먼저 읽고 라우팅"을 코어에 넣으면 TM이 특정 라우팅 전략에 결합되고, 이 순서에 의존하는 확장점의 계약이 깨집니다.
이유 4 — 재사용.
LazyConnectionDataSourceProxy의 원래 목적("안 쓸 커넥션은 빌리지 말자")이 가진 "커넥션을 최대한 늦게 획득한다"는 성질이 우연히 라우팅 순서 문제도 공짜로 해결합니다. Spring은 새 꼼수를 발명한 게 아니라 이미 검증된 지연 메커니즘을 재사용한 것입니다
10. 마치며
Master/Slave 읽기 분리가 LazyConnectionDataSourceProxy 위에서만 정확히 도는 이유는
결국 하나의 순서 문제로 요약됩니다.
- 라우팅 판단(readOnly 조회)은
getConnection()시점에 일어나는데, - readOnly 세팅은
doBegin()이후(prepareSynchronization)에 일어난다. - LazyProxy가
getConnection()의 실제 효력을 첫 SQL 시점으로 미뤄 이 순서를 뒤집는다.
이 구조를 이해하면, LazyProxy는 "라우팅을 위한 마법 장치"가 아니라 범용 지연 최적화가 라우팅 순서 문제까지 겸사겸사 해결하는 조합이라는 것을 알 수 있습니다. 그리고 라우팅이 어긋날 때 어디를 봐야 하는지(래핑 순서·전파·프록시 경유)도 함께 손에 잡힙니다.