아무튼, 쓰기
왜 내 readOnly는 슬레이브로 가지 않는가? 본문
개요
@Transactional(readOnly=true) 어노테이션을 사용해 마스터/슬레이브 DB로 자동 라우팅하는 기능이 LazyConnectionDataSourceProxy 없이 제대로 동작하지 않는 이유와 해결책을 정리한 내용입니다.
문제 상황(readOnly=true여도 마스터 DB로 접속)
AbstractRoutingDataSource를 구현해 TransactionSynchronizationManager.isCurrentTransactionReadOnly() 값으로 DB를 분기 처리해도, readOnly=true로 설정된 트랜잭션이 슬레이브 DB가 아닌 마스터 DB로 잘못 연결되고 있었습니다.
왜 readonly가 주입이 안되는가? (Spring 트랜잭션의 동작 순서)
이 문제의 원인은 Spring의 트랜잭션 처리 순서가 커넥션 획득 이후 트랜잭션 정보 동기화로 설계되어 있기 때문입니다.
AbstractRoutingDataSource를 상속받아 마스터/슬레이브 라우팅을 구현하는 일반적인 코드는 다음과 같습니다.
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 이 시점에 isCurrentTransactionReadOnly()를 호출하면...
return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master";
}
}
determineCurrentLookupKey()는 DataSource.getConnection()이 호출될 때 실행됩니다. 문제는 이 메서드가 호출되는 시점에, readOnly 정보가 아직 ThreadLocal에 반영되지 않았다는 것입니다.
실제 Spring Framework의 트랜잭션 시작 코드인 AbstractPlatformTransactionManager.startTransaction()를 살펴보겠습니다.
// AbstractPlatformTransactionManager.startTransaction()
private TransactionStatus startTransaction(TransactionDefinition definition, Object transaction,
boolean nested, boolean debugEnabled, @Nullable SuspendedResourcesHolder suspendedResources) {
// ... (생략)
try {
doBegin(transaction, definition); // ← 1. 여기서 커넥션 획득!
}
catch (RuntimeException | Error ex) {
// ... (생략)
}
prepareSynchronization(status, definition); // ← 2. 여기서 readOnly를 ThreadLocal에 저장
// ... (생략)
return status;
}
보시다시피 doBegin()(트랜잭션 시작 및 커넥션 획득)이 prepareSynchronization()(트랜잭션 정보 동기화)보다 먼저 호출됩니다.
readOnly 정보는 prepareSynchronization() 메서드 내부에서 ThreadLocal에 저장됩니다.
// AbstractPlatformTransactionManager.prepareSynchronization()
protected void prepareSynchronization(DefaultTransactionStatus status, TransactionDefinition definition) {
if (status.isNewSynchronization()) {
// ... (생략)
TransactionSynchronizationManager.setCurrentTransactionReadOnly(definition.isReadOnly()); // ← 2-1. 바로 여기!
// ... (생략)
}
}
반면, 커넥션 획득은 doBegin() 내부에서 일어납니다. DataSourceTransactionManager의 doBegin()을 보면 다음과 같습니다.
// DataSourceTransactionManager.doBegin()
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
// ... (생략)
try {
if (!txObject.hasConnectionHolder() ||
txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
Connection newCon = obtainDataSource().getConnection(); // ← 1-1. 여기서 커넥션 획득!
// ... (생략)
txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
}
// ... (생략)
}
// ... (생략)
}
obtainDataSource().getConnection()이 호출되는 시점에 AbstractRoutingDataSource의 determineCurrentLookupKey()가 실행됩니다. 하지만 이 시점은 prepareSynchronization()이 호출되기 전이므로, TransactionSynchronizationManager.isCurrentTransactionReadOnly()는 항상 false (정확히는 null을 확인하므로)를 반환합니다.
// TransactionSynchronizationManager.isCurrentTransactionReadOnly()
public static boolean isCurrentTransactionReadOnly() {
// 이 ThreadLocal 변수는 prepareSynchronization()에서만 설정됨
return (currentTransactionReadOnly.get() != null);
}
결국 커넥션을 획득하는 시점(doBegin)에는 readOnly 정보가 없고, readOnly 정보가 저장되는 시점(prepareSynchronization)에는 이미 커넥션 획득이 끝났기 때문에 readOnly=true임에도 불구하고 항상 마스터(기본) DataSource로 연결되는 문제가 발생합니다.
해결책 1: TransactionExecutionListener 활용 (Spring 6.0+)
Spring은 6.0부터 TransactionExecutionListener로 beforeBegin에서 미리 저장하는 기능을 제공했습니다.
@Override
public void beforeBegin(TransactionExecution transaction) {
TransactionSynchronizationManager.setCurrentTransactionReadOnly(transaction.isReadOnly());
TransactionSynchronizationManager.setActualTransactionActive(true);
}
이렇게 하면 doBegin() 시점에 이미 readOnly 정보가 ThreadLocal에 저장되어 라우팅이 올바르게 동작합니다.
해결책 2: LazyConnectionDataSourceProxy 사용 (권장)
LazyConnectionDataSourceProxy는 실제 커넥션 획득을 지연시켜서 해결합니다.
// LazyConnectionDataSourceProxy.getConnection()
@Override
public Connection getConnection() throws SQLException {
checkDefaultConnectionProperties();
return (Connection) Proxy.newProxyInstance(
ConnectionProxy.class.getClassLoader(),
new Class<?>[] {ConnectionProxy.class},
new LazyConnectionInvocationHandler()); // ← Proxy 반환 (실제 커넥션 X)
}
핵심은 LazyConnectionInvocationHandler가 실제 SQL이 실행될 때 getTargetConnection()을 호출하는 것입니다.
// LazyConnectionInvocationHandler.getTargetConnection()
private Connection getTargetConnection(Method operation) throws SQLException {
if (this.target == null) {
// ... (생략)
// Fetch physical Connection from DataSource.
DataSource dataSource = getDataSourceToUse(); // ← SQL 실행 시점에 DataSource 선택!
this.target = (this.username != null ?
dataSource.getConnection(this.username, this.password) :
dataSource.getConnection());
// ... (생략)
}
return this.target;
}
private DataSource getDataSourceToUse() {
return (this.readOnly && readOnlyDataSource != null ?
readOnlyDataSource : obtainTargetDataSource()); // ← readOnly 상태 확인
}
LazyConnectionDataSourceProxy를 사용하면:
- doBegin()에서는 Proxy 객체만 반환됩니다. (실제 커넥션 X)
- prepareSynchronization()이 실행되어 readOnly 정보가 ThreadLocal에 저장됩니다.
- 이후 실제 SQL 실행 시점(예: Statement 생성 시)에 getTargetConnection()이 호출됩니다.
- 이때는 이미 readOnly 정보가 ThreadLocal에 있으므로 isCurrentTransactionReadOnly()가 올바르게 동작하여 정확한 DataSource(master/slave)로 라우팅됩니다.
마무리하며: 그럼 항상 Proxy를 쓰는 것이 좋을까?
이번 분석을 마치며 이런 의문이 들었습니다. 트랜잭션 내에서 쿼리 실행 후 외부 API를 호출하느라 커넥션을 불필요하게 오래 점유하는 문제도 이 프록시로 간단히 해결될 수 있을 것 같은데? 평소에도 LazyConnectionDataSourceProxy를 기본으로 사용하면 어떨까?
결론은 평상시에 사용하지 않기로 결론내렸습니다. LazyConnectionDataSourceProxy라는 편리한 안전장치만 믿고 개발자가 커넥션 관리의 복잡성을 인지하지 못하게 될 수 있고, 결국 커넥션 점유 시간을 신경 쓰지 않게 되어, 커넥션을 길게 물고 가는 코드가 탄생할 위험이 있기 때문입니다. 일반적인 상황에서의 커넥션 점유 문제는 개발자가 서비스 로직을 명확히 이해하고, 코드 레벨에서 트랜잭션을 명시적으로 분리하는 것으로 해결해야 한다고 생각합니다.
프레임워크가 제공하는 편리함에 기대기보다, 명시적으로 개발자가 관리하는 것이 더 견고한 애플리케이션을 만드는 길이라고 다시 한번 생각하게 되었습니다.
'스프링' 카테고리의 다른 글
| Spring Batch로 187초 → 6.65초: 28배 성능 개선 전과정 (0) | 2025.12.04 |
|---|---|
| 실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기 (0) | 2025.11.28 |
| [디프만, 밥토리] Hibernate 벡터, "묻고 double[]로 가!" 가 아니라.. float[]로 가! (0) | 2025.11.07 |
| 100명 동시 요청 25분 → 1분, Gemini API 병목 96% 개선기 (feat. Virtual Thread의 함정) (0) | 2025.11.06 |
| @Transactional에서 try-catch를 썼는데 500 에러? (0) | 2025.11.06 |