AbstractRoutingDataSource에서 Transactional readonly값 false만 리턴하는 오류 해결
문제 상황
connection 경로를 결정하는 AbstractRoutingDataSource에서 현재 실행중인 트랜잭션의 readonly 값을 읽어와
readonly=true 이면 slave db로 경로를 결정하고, readonly=false이면 master db로 경로를 결정하는데
AbstractRoutingDataSource의 구현체에서 TransactionSynchronizationManager.isCurrentTransactionReadOnly()를
호출하면 false만 리턴이됨
문제 원인
PlatformTransactionManager가 커넥션 경로를 먼저 설정하고 이후에 트랜잭션 상태를 설정함.
따라서 커넥션 경로를 설정하는 시점에 트랜잭션 상태가 정의되어있지 않아 default로 false만 반환함
해결법
커넥션 경로를 설정하기 전에 트랜잭션 상태를 전달 받을 수 있는 콜백 객체에서 미리 트랜잭션 상태를 설정
@Component
public class TransactionDefSaveExecutionListener implements TransactionExecutionListener {
public void beforeBegin(@NotNull TransactionExecution transaction) {
DefaultTransactionStatus defaultTxStatus = (DefaultTransactionStatus)transaction;
TransactionSynchronizationManager.setCurrentTransactionReadOnly(defaultTxStatus.isReadOnly());
TransactionSynchronizationManager.setActualTransactionActive(true);
}
}
소스 분석
AbstractPlatformTransactionManager - startTransaction 메서드
private TransactionStatus startTransaction(TransactionDefinition definition, Object transaction, boolean nested, boolean debugEnabled, @Nullable SuspendedResourcesHolder suspendedResources) {
boolean newSynchronization = this.getTransactionSynchronization() != 2;
DefaultTransactionStatus status = this.newTransactionStatus(definition, transaction, true, newSynchronization, nested, debugEnabled, suspendedResources);
this.transactionExecutionListeners.forEach((listener) -> {
listener.beforeBegin(status);
});
try {
this.doBegin(transaction, definition);
} catch (Error | RuntimeException var9) {
this.transactionExecutionListeners.forEach((listener) -> {
listener.afterBegin(status, var9);
});
throw var9;
}
this.prepareSynchronization(status, definition);
this.transactionExecutionListeners.forEach((listener) -> {
listener.afterBegin(status, (Throwable)null);
});
return status;
}
위 소스의 빨간색 글씨로 된 소스에 주목하자.
- listener.beforeBegin(status) : 커넥션 경로 설정 전 트랜잭션 상태를 받을 수 있는 콜백 객체
- this.doBegin(transaction, definition) : 커넥션 경로 설정
- this.prepareSynchronization(status, definition) : 스레드 로컬에 트랜잭션 상태 저장
위와 같이 커넥션 경로가 먼저 설정 된 이후에 트랜잭션 상태가 설정된다.
따라서 아무런 설정없이 트랜잭션 상태를 통한 커넥션 경로 설정은 스프링의 기본동작으로는 제어할 수 없다.
허나 커넥션 경로 설정이 이뤄지기 전에도 트랜잭션 상태는 메서드의 파라미터로 계속 해서 전달된다.
스레드 로컬에 저장되는 시점이 커넥션 경로 설정 이후일 뿐이다.
그래서 커넥션 경로 설정 이전에 트랜잭션 상태를 전달 받는 TransactionExecutionListener(콜백 객체)를 스프링 빈으로 등록하고 해당 빈의 beforeBegin(status) 메서드에서 트랜잭션 상태를 미리 지정한다면 트랜잭션 상태에 따른 커넥션 경로 설정이 가능해진다.
코드를 디버깅하며 깊이있게 알아보자.
[AbstractRoutingDataSource]
determinTargetDataSource() -> determineCurrentLookupKey()
determineCurrentLookupKey() 메서드 반환값이 커넥션의 경로를 결정한다.
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
Object lookupKey = this.determineCurrentLookupKey();
DataSource dataSource = (DataSource)this.resolvedDataSources.get(lookupKey);
if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
dataSource = this.resolvedDefaultDataSource;
}
if (dataSource == null) {
throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
} else {
return dataSource;
}
}
@Nullable
protected abstract Object determineCurrentLookupKey();
나의 경우 아래와 같이 읽기작업은 slave와 쓰기작업은 master로 설정하였다.
public class MasterSlaveRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 트랜잭션 작업인지 여부
boolean isTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
// readonly 트랜잭션인지 여부
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly()
if (!isTransactionActive || isReadOnly) {
return "slave";
} else {
return "master";
}
}
}
반환 값은 application.yml에 미리 설정한 datasource의 키값이어야한다.
허나 이 소스에서
TransactionSynchronizationManager.isActualTransactionActive() 와 TransactionSynchronizationManager.isCurrentTransactionReadOnly() 가 false만 반환한다.
이유는 isActualTransactionActive 메서드와 isCurrentTransactionReadOnly 메서드는 스레드 로컬에 저장된 트랜잭션 상태를 가져오는 트랜잭션 상태가 스레드 로컬에 저장되는 시점은 이보다 이후에 실행되는 prepareSynchronization() 메서드에 의해서다.
public abstract class TransactionSynchronizationManager {
...
private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status");
private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal("Actual transaction active");
...
}
이에 대한 해결 방법이 첫번째 빨간 글씨로 표현한
this.transactionExecutionListeners.forEach((listener) -> { listener.beforeBegin(status); });
부분이다.
이 소스코드가 해결법이 될 수 있는 이유는 beforeBegin이라는 메서드에 status를 넣어주고 있기 때문이다.
우리가 필요한 해결법은 doBegin에 의해 AbstractRoutingDatasource가 호출될 때 현재 진행 중인 트랜잭션 상태를 가져와야하는 것이다.
스프링이 스레드 로컬 공간을 사용하는 TransactionSynchronizationManager에 트랜잭션 상태를 저장해주기 전에는
트랜잭션 상태는 메서드 파라미터로만 전달된다.
위와 같이 최초에 reflection을 통해 전달받은 타겟 메서드와 클래스 정보를 통해 트랜잭션 상태를 생성하고
그 이후 AbstractPlatformTransactionManager의 startTransaction 메서드로 전달되기까지
메서드의 파라미터로만 전달되고 다른 곳에 저장시켜주는 곳이 없다.
별도의 공간인 스레드 로컬에 저장시켜주는 로직이 AbstractRoutingDatasource 보다 이후에 실행되니
마땅히 참조할 만한 저장 공간이 없는 것이다.
별도의 참조할만한 객체는 없지만
this.transactionExecutionListeners.forEach((listener) -> { listener.beforeBegin(status); });
를 통해 콜백 객체의 메서드 파라미터에 트랜잭션 상태를 전달해주고 있다.
이 콜백 객체를 사용한다면 스프링 디폴트 프로세스를 그대로 따라갈 수 있다.
따라서 아래와 같은 해결법이 나오게 된 것이다.
@Component
public class TransactionDefSaveExecutionListener implements TransactionExecutionListener {
public void beforeBegin(@NotNull TransactionExecution transaction) {
DefaultTransactionStatus defaultTxStatus = (DefaultTransactionStatus)transaction;
TransactionSynchronizationManager.setCurrentTransactionReadOnly(defaultTxStatus.isReadOnly());
TransactionSynchronizationManager.setActualTransactionActive(true);
}
}
과거에 토비의 스프링이라는 책을 읽으며 커넥션을 메서드 파라미터로 계속해서 공유하는 패턴에서 스레드 로컬 공간에 저장하여 접근하는 방식으로 지저분한 코드를 없애 나갔던 것을 봤던 기억이있다.
그래서 그런지 이번에 트러블 슈팅을 하며 트랜잭션 상태는 사용자마다 고유하게 갖고 있어야하므로 메서드 파라미터로 지속적으로 전달 되고 어느 시점에 분명 스레드 로컬 공간에 저장하는 로직이 있을 거라고 판단하고 디버깅을 하니 굉장히 수월하게 해결하였다.
다행스럽게 콜백 객체를 통해 상태를 넘겨주는 로직까지 스프링에서 제공해주고 있으니 해결법 또한 단순하게 처리할 수 있었다.