티스토리 뷰
[Spring] @Transactional에서 첫 쿼리 실행까지(실제 Connection이 필요할 때까지) 커넥션 점유를 늦추는 LazyConnectionDataSourceProxy
망나니개발자 2024. 9. 10. 10:00
1. @Transactional에서 첫 쿼리 실행까지(실제 Connection이 필요할 때까지)
커넥션 점유를 늦추는 LazyConnectionDataSourceProxy
[ @Transactional의 동작 방식 ]
스프링에서 개발을 하다 보면 @Transactional 애노테이션을 활용하게 된다. @Transactional은 AOP(Aspect-Oriented Programming) 기반으로 데이터베이스 커넥션으로부터 트랜잭션 관련 기능을 지원하도록 도와준다. 스프링의 트랜잭션에 대한 자세한 내용은 여기 링크를 참고하도록 하자.
@Transactional 애노테이션을 선언하면, Transaction 처리를 위한 부가 기능(Advice) 클래스인 TransactionInterceptor 클래스에서 요청을 가로챈다. 그리고 JpaTransactionManager에서 트랜잭션을 시작한다.
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
...
try {
...
EntityManager em = txObject.getEntityManagerHolder().getEntityManager();
// Delegate to JpaDialect for actual transaction begin.
int timeoutToUse = determineTimeout(definition);
Object transactionData = getJpaDialect().beginTransaction(em,
new JpaTransactionDefinition(definition, timeoutToUse, txObject.isNewEntityManagerHolder()));
txObject.setTransactionData(transactionData);
txObject.setReadOnly(definition.isReadOnly());
위 코드의 beginTransaction에서 트랜잭션 시작을 위해 DataSource로부터 커넥션을 가져오게 된다.
스프링 부트는 기본적으로 HikariDataSource를 DataSource로 사용하므로, HikariDataSource의 getConnection에 도달하게 되고, HikariDataSource는 커넥션 풀로부터 커넥션을 찾아서 반환하게 된다.
@Override
public Connection getConnection() throws SQLException {
...
HikariPool result = pool;
if (result == null) {
...
}
return result.getConnection();
}
그리고 처리가 끝나면 데이터베이스 커넥션으로 commit을 날리게 된다. 해당 작업은 JpaTransactionManager의 doCommit 메서드에서 시작되어 EntityTransaction가 commit을 날리게 된다. 그리고 내부적으로 커넥션을 꺼내 커밋을 보내는 것이다.
@Override
protected void doCommit(DefaultTransactionStatus status) {
JpaTransactionObject txObject = (JpaTransactionObject) status.getTransaction();
if (status.isDebug()) {
logger.debug("Committing JPA transaction on EntityManager [" +
txObject.getEntityManagerHolder().getEntityManager() + "]");
}
try {
EntityTransaction tx = txObject.getEntityManagerHolder().getEntityManager().getTransaction();
tx.commit();
} catch (RollbackException ex) {
...
}
[ @Transactional의 동작 방식에 의한 문제점 ]
기존 @Transactional 동작의 문제는 너무 이른 시점(Eager)에 데이터베이스 커넥션을 가져온다는 점이다. 예를 들어 다음과 같은 서비스 클래스가 있다고 하자.
@Transactional
@AllArgsConstructor
public class LoginService {
private final NaverLoginPort naverLoginPort;
private final SaveLoginHistoryPort saveLoginHistoryPort;
public void login() {
saveLoginHistoryPort.save(naverLoginPort.login());
}
}
위의 클래스에서는 네이버 소셜 로그인 이후에 로그인 히스토리를 데이터베이스에 저장하고 있다. 문제는 네이버 소셜 로그인을 할 때에는 데이터베이스 커넥션이 필요하지 않음에도 불구하고, 너무 이른 시점에 데이터베이스 커넥션이 획득된다는 점이다.
만약 네이버 API 호출에 문제가 있어서, 쓰레드들이 대기하게 된다면 데이터베이스를 점유한 상태로 대기하게 되어 시스템에 심각한 문제를 초래할 수 있다. 커넥션 풀에 가용 가능한 데이터베이스 커넥션이 존재하지 않으면 다른 쓰레드들도 커넥션을 얻을 때까지 대기하게 되어 모든 요청이 밀릴 수 있기 때문이다.
따라서 데이터베이스 커넥션이 실제로 필요할 때, 즉 쿼리가 실질적으로 날아갈 때, 커넥션을 얻어오면 이러한 불필요한 리소스 낭비를 최적화할 수 있다.
[ LazyConnectionDataSourceProxy를 통해 개선하기 ]
스프링은 LazyConnectionDataSourceProxy 라는 클래스를 제공한다. LazyConnectionDataSourceProxy는 타겟 DataSource에 대한 프록시로서, Statement가 처음 생성될 때까지 실제 JDBC Connection을 커넥션 풀에서부터 불러오는 것을 지연(Lazy)시킨다. auto-commit, read-only, isolation-level과 같은 커넥션 초기화 속성들은 유지되어 실제 JDBC 커넥션을 가져올 때 적용된다.
따라서 어떠한 Statement도 생성되지 않는다면, commit이나 rollback 들은 무시되며, 이러한 처리를 위해 Connection 반환 요청이 왔을 때에도 실제 커넥션이 아닌 ConnectionProxy 인터페이스를 구현하여 래핑한 커넥션을 제공한다. 해당 설정을 적용하는 예시는 다음과 같다.
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import com.zaxxer.hikari.HikariDataSource;
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(basePackages = "com.example.virtualthread")
public class LazyDataSourceConfiguration {
@Bean
public HikariDataSource hikariDataSource(DataSourceProperties dataSourceProperties) {
return dataSourceProperties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
@Bean
@Primary
public LazyConnectionDataSourceProxy lazyConnectionDataSourceProxy(HikariDataSource hikariDataSource) {
return new LazyConnectionDataSourceProxy(hikariDataSource);
}
@Primary
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
@Qualifier("lazyConnectionDataSourceProxy") DataSource dataSource,
EntityManagerFactoryBuilder builder
) {
return builder.dataSource(dataSource)
.packages("com.example.virtualthread")
.persistenceUnit("main")
.build();
}
}
참고로 스프링 부트 3.2(스프링 6.1.2) 부터는 LazyConnectionDataSourceProxy 클래스에 read-only Datasource 속성도 추가되었다. 해당 속성은 read-only 트랜잭션 동안에 사용될 DataSource를 지정할 수 있다. 이를 통해 read-only로 설정된 트랜잭션 스프링 트랜잭션 범위 내에서 read-only DataSource로부터 Lazy 하게 DataSource를 얻어올 수 있다.
실서비스를 운영하면서 가장 장애가 많이 발생하는 지점은 당연컨데 데이터베이스이다. 데이터베이스는 파일 I/O 작업이 빈번하게 일어나며, 발생한 쓰기 작업에 대한 동기화가 즉각적으로 일어날 수 없어서 Master-Slave 구조로 구성되는 경우가 많다. 따라서 여러 개의 DataSource를 설정하고 쓰기 작업은 Master로, 읽기 작업은 Slave로 요청을 보내서 부하를 분산시키는 방법이 있는데, 이러한 요청 라우팅에 대해서도 추후에 살펴보도록 하자.