Spring에서 @Transactional(readOnly = true)는 DirtyChecking 모드를 Manual 모드로 바꿔줘 성능 최적화를 위해 사용된다고 알려져 있지만, 오히려 불필요한 JDBC 호출로 인해 성능이 저하될 수 있다. 이 글은 Elastic APM을 통해 확인한 실제 호출 로그를 바탕으로 readOnly 트랜잭션이 성능에 어떤 영향을 주는지 분석하고, 실무적으로 더 나은 대안을 제시한다.
아는 사람만 아는 @Transactional의 비밀
다음 API는 @Transactional(readOnly = true)로 선언된 Service 메소드를 호출한다. Elastic APM을 통해 추적한 결과, 해당 API는 단 2개의 SELECT 쿼리만 실행함에도 불구하고, 여러 번 JDBC 호출이 발생하는 것을 확인했다.
그렇다면 과연 어떤 동작들이 포함되어 있는 것일까?
set_option
이는 JPA에서 @Transactional을 사용하면서 트랜잭션을 관리하기 위해 내부적으로 실행되는 설정(set_option) 때문이다. 이 과정에서 다음과 같은 JDBC 호출이 자동으로 발생하며, 이들은 성능에도 영향을 줄 수 있다.
1. Set transaction access mode 'read-only'
@Transactional(readOnly = true)는 JDBC 레벨에서 Connection을 read-only로 설정하도록 만든다. 실제 구현 레벨에서는 아래와 같은 코드를 호출한다.
Connection.setReadOnly(true)
이 설정은 MySQL, PostgreSQL 등 일부 DB에서 실행 계획 최적화에 도움이 될 수 있지만 대부분의 JDBC 드라이버는 이 설정이 필수는 아니며, 추가적인 네트워크 왕복을 유발할 수도 있다.
2. autocommit
Spring에서는 트랜잭션을 시작할 때 JPA의 FlushMode도 함께 설정된다. 기본값이 FlushModeType.AUTO로 지정되어 있으며, 이 설정은 트랜잭션 commit 시점이나 JPQL 실행 시 flush를 자동으로 발생시키는 설정이다.
public interface Session extends SharedSessionContract, EntityManager {
/**
* Set the current {@link FlushModeType JPA flush mode} for this session.
* <p>
* <em>Flushing</em> is the process of synchronizing the underlying persistent
* store with persistable state held in memory. The current flush mode determines
* when the session is automatically flushed.
*
* @param flushMode the new {@link FlushModeType}
*
* @see #setHibernateFlushMode(FlushMode) for additional options
*/
@Override
void setFlushMode(FlushModeType flushMode);
}
Session.setHibernateFlushMode(FlushMode.AUTO)
3. Rollback
일반적으로 Rollback은 예외 발생 시 실행된다고 생각하기 쉽지만, DB 트랜잭션은 명시적인 commit 또는 rollback으로만 종료되기 때문에 예외가 발생하지 않아도 Rollback을 호출할 수도 있다. 예를 들어, @Transactional(readOnly = true)이 적용된 경우, Spring은 변경사항이 없다고 판단하고 명시적으로 rollback()을 호출하여 트랜잭션을 종료한다.
이는 실제 DB에는 아무런 변경이 없더라도, JDBC 드라이버 수준에서 rollback 요청이 발생한다는 의미이며, 역시 불필요한 비용이 될 수 있다.
@Transactional 제거 시의 변화
앞서 소개한 API에서 @Transactional(readOnly = true)을 제거하고 실행해봤다. Set transaction access mode 'read-only', autocommit, Rollback 등의 호출이 발생하지 않게 되었다. 그 결과, API 응답 속도가 절반 가까이 줄어든 것을 확인할 수 있었다.
흔히 @Transactional(readOnly = true)가 dirtyChecking 모드를 Manual모드로 바꿔 줄 수 있기 때문에 application 성능 개선에 도움이 된다고 알려져 있지만, 실제로는 아예 트랜잭션 자체를 제거하는 것이 오히려 더 큰 성능 향상을 가져왔다.
Replication 환경에서는 주의!
단순히 성능을 위해 @Transactional을 제거하는 접근은 DB Replication을 환경에서는 문제가 될 수 있다. DB의 처리량을 증가시키고, 고가용성을 확보하기 위해 Replication을 구성한 시스템에서는 보통 AbstractRoutingDataSource을 사용하여 트랜잭션이 읽기 전용인지 여부를 따져 Primary 또는 Replica DB로 요청을 분기한다.
final AbstractRoutingDataSource dataSourceRouter =
new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? REPLICA_DATASOURCE_KEY
: PRIMARY_DATASOURCE_KEY;
}
};
이처럼 TransactionSynchronizationManager.isCurrentTransactionReadOnly()를 통해 분기를 처리하기 때문에, 트랜잭션 자체를 제거하면 읽기 요청이 모두 Master로 몰리는 부작용이 발생할 수 있다. 이는 Replica 부하 분산이 깨지고, 전체 시스템 성능에 악영향을 줄 수 있다.
이러한 문제를 해결하기 위해, 카카오페이 테크 블로그에서는 다음과 같은 형태의 커스텀 어노테이션을 제안했다.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public @interface ReadOnlyTransactional {}
SUPPORTS 전파 수준은 메서드가 트랜잭션 없이 단독으로 호출되면 트랜잭션을 생성하지 않으며, 상위에 트랜잭션이 있을 경우에는 그 트랜잭션에 참여한다.
즉, @ReadOnlyTransactional만 적용된 메소드에서는 JPA 트랜잭션이 시작되지 않아 setReadOnly, rollback 등 불필요한 JDBC 호출을 피할 수 있고, 동시에 AbstractRoutingDataSource 기반의 Replica 라우팅 로직도 정상 동작하게 된다.
반면, 상위 계층에서 이미 @Transactional(REQUIRED) 트랜잭션이 시작된 경우에는 @ReadOnlyTransactional은 영향을 주지 않기 때문에, 기존 트랜잭션 구조에 유연하게 섞어 사용할 수 있는 장점도 있다.
Lazy Loading을 사용할 때도 주의!
만약 API에서 Lazy Loading을 사용하고 있는데, @Transactional(readOnly = true)를 제거한다면 무슨 일이 벌어질까? API를 호출할 때마다 아래 에러가 발생할 것이다.
org.hibernate.LazyInitializationException: could not initialize proxy - no Session
Spring에서 트랜잭션을 시작하지 않으면 JPA는 영속성 컨텍스트를 사용할 수 없기 때문에 Lazy Loading 또한 사용할 수 없게 된다. 기술적인 이유로 어쩔 수 없이 Lazy Loading을 사용해야 하는 API라면 @Transactional을 붙여 영속성 컨텍스트를 사용할 수 있는 환경을 만들어야 한다.
결론
@Transactional(readOnly = true)는 흔히 읽기 성능 최적화를 위해 사용하는 설정이라고 알려져 있지만, 실제로는 JDBC 수준에서 Set readOnly, AutoCommit, Rollback 등 불필요한 호출이 추가적으로 발생할 수 있다는 사실을 파악했다.
또한 @ReadOnlyTransactional과 같이 propagation을 SUPPORTS로 설정한 어노테이션을 도입하는 방법을 제시하여 트랜잭션의 오버헤드는 줄이되, Replica 라우팅 로직은 유지하는 방법을 알아봤다.
여기서 중요한 것은, 서비스의 트랜잭션 전략을 일률적으로 가져가지 않고, API의 목적과 시스템 구조에 따라 유연하게 적용하는 것이다. 불필요한 @Transactional 사용이 오히려 성능 병목이 될 수 있다는 점을 기억하고, 필요에 따라 readOnly 분리 전략을 고민해보는 것이 실무에서의 핵심이다.
참고자료
- JPA Transactional 잘 알고 쓰고 계신가요?, 카카오 pay tech 블로그
- Spring @Transactional read-only mode rollback behavior, stack overflow