Spring READ 관련 API에서 @Transactional(readOnly = true)는 필수인가?

kindof

·

2023. 11. 10. 22:26

Spring에서는 @Transactional(readOnly = true) 옵션을 제공합니다.

 

실제로 많은 사람들이 쓴 글들을 보면 조회 관련 API에서 @Transactional(readOnly = true) 옵션을 사용하면 성능 상 이점을 얻을 수 있다고 말하는데요.

 

이번 글에서는 @Transactional(readOnly = true)을 사용할 때 얻을 수 있는 이점에 대해 [1] 실제 소스 코드를 보며 분석해보고, [2] 그렇다면 과연 단점은 없는지에 대해서도 확인해보려고 합니다.

 

 

1. @Transactional(readOnly = true) 동작 방식과 성능 상 이점

먼저 Transactional 인터페이스에 기재되어 있는 readOnly() 옵션에 대한 설명을 읽어보겠습니다.

 

@Transactional > readOnly()

 

크게 두 가지 포인트를 확인할 수 있습니다.

 

[1] read-only 작업에 대해 런타임 최적화를 제공한다.

[2] transaction subsystem에 대한 hint를 제공한다.

 

좀 더 자세한 이해를 위해 문서에 기재된 대로 TransactionSynchronizationManager.isCurrentTransactionReadOnly() 부분을 확인해보겠습니다.

 

TransactionSynchronizationManager.isCurrentTransactionReadOnly()

 

해당 메서드의 설명에도 크게 두 가지 포인트가 있습니다.

 

[1] readOnly 값을 beforeCommit Callback의 인자로 받아오고, 커밋 시점에 변경 감지(change detection)을 억제한다.

[2] 커밋 전에 ReadOnly 여부를 확인하여 Hibernate 세션의 Flush 모드를 'FlushMode.MANUAL'로 설정하는 데 사용될 수 있다.

 

이제 실제로 TransactionManager의 동작 부분을 확인해보겠습니다. JPA가 사용하는 JpaTransactionManager는 아래와 같은 계층 구조를 가집니다.

TransactionManager
  - PlatformTransactionManager
    - AbstractPlatformTransactionManager
      - JpaTransactionManager

 

 

JpaTransactionManager의 doBegin() 메서드의 일부입니다.

@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
   
    // .. 생략
   
      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());
      
    // .. 생략

Object transactionData = getJpaDialect().beginTransaction(...)라는 코드가 보입니다.

 

Hibernate 구현체를 사용하는 상황에서 HibernateJpaDialect는 DefaultJpaDialect를 상속하고, DefaultJpaDialect는 JpaDialect 인터페이스를 구현합니다.

JpaDialect

 

그리고 HibernateJpaDialect 구현체는 beginTransaction(...) 메서드를 아래와 같이 오버라이딩하고 있습니다.

@Override
public Object beginTransaction(EntityManager entityManager, TransactionDefinition definition)
      throws PersistenceException, SQLException, TransactionException {

   // .. 생략
   
   // Standard JPA transaction begin call for full JPA context setup...
   entityManager.getTransaction().begin();

   // Adapt flush mode and store previous isolation level, if any.
   FlushMode previousFlushMode = prepareFlushMode(session, definition.isReadOnly());
   
   
   // .. 생략
   
   
}

 

그리고 prepareFlushMode() 메서드를 보면 아래와 같이 readOnly 값이 true일 때 FlushMode를 MANUAL로 설정한다는 것을 확인할 수 있습니다.

 

prepareFlushMode

 

이로써 Transactional(readOnly = true)가 Flush 모드를 MANUAL로 설정한다는 것이 코드로 검증되었습니다.

 

그렇다면 FlushMode가 MANUAL로 설정되어 있으면 어떤 성능 상 장점이 있을까요?

 

 

JPA의 Dirty Checking(더티 체킹)은 엔티티 객체의 상태 변화를 감지하고, 이를 자동으로 데이터베이스에 반영하는 메커니즘입니다. Dirty Checking은 영속성 컨텍스트(Persistence Context) 내에서 엔티티의 변경 상태를 추적하고, 트랜잭션이 커밋되는 시점에 변경된 내용을 데이터베이스에 자동으로 반영하는데요.

 

FlushMode가 MANUAL인 경우, 트랜잭션이 커밋되거나 flush() 메서드가 호출될 때까지는 영속성 컨텍스트의 변경 내용이 데이터베이스에 반영되지 않습니다.

따라서 @Transactional(readOnly = true)를 사용하면 트랜잭션이 읽기 전용이 되고, FlushMode가 MANUAL로 설정되어 더티 체킹에 의한 자동적인 데이터베이스 반영이 일어나지 않게 됩니다. 이 상태에서는 명시적으로 flush()를 호출하지 않는 이상 트랜잭션이 끝날 때까지 데이터베이스에 변경 내용이 반영되지 않습니다.

 

즉, @Transactional(readOnly = true) 옵션을 사용하면 Dirty Checking을 생략함으로써 엔티티에 대한 Snapshot을 관리하는 메모리를 절감할 수 있고 DB에 쓰기 작업을 발생시키지 않게 됩니다.

 

 

 

2. 주의해야 할 점

JpaTransactionManager 내부에서 Connection을 반납하는 코드는 아래와 같습니다.

@Override
protected void doCleanupAfterCompletion(Object transaction) {
   JpaTransactionObject txObject = (JpaTransactionObject) transaction;

   // .. 생략

   // Remove the JDBC connection holder from the thread, if exposed.
   if (getDataSource() != null && txObject.hasConnectionHolder()) {
      TransactionSynchronizationManager.unbindResource(getDataSource());
      ConnectionHandle conHandle = txObject.getConnectionHolder().getConnectionHandle();
      if (conHandle != null) {
         try {
            getJpaDialect().releaseJdbcConnection(conHandle,
                  txObject.getEntityManagerHolder().getEntityManager());
                  
    // .. 생략

 

그리고 이 doCleanUpAfterCompletion(...) 메서드는 AbstractPlatformTransactionManager#processCommit() 메서드의 마지막 finally 구문에서 실행되는데요.

private void processCommit(DefaultTransactionStatus status) throws TransactionException {
   try {
      
      // .. 생략

   }
   finally {
      cleanupAfterCompletion(status);
   }

 

즉, 트랜잭션에서 커넥션이 반환되는 시점은 commit() 이후 시점이라는 뜻입니다.

 

한편, @Transactional은 AOP 기반으로 하기 때문에 Target 메서드의 로직이 모두 종료된 후 commit() 로직이 실행되는데요.

 

이 사실을 종합해보면, @Transactional(readOnly = true) 옵션을 사용하면 해당 조회 메서드가 모두 종료된 이후 DB Connection이 끊어지게 된다는 뜻이고 반대로 해석하면 DB 조회 이후 Connection이 사용되지 않는 상황에서도 자원이 반납되지 않는다는 것입니다.

 

이를 실험해보기 위해 아래와 같은 코드를 작성하고 호출해보겠습니다.

# controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/")
public class TransactionTestController {

    private final TransactionTestService testService;

    @GetMapping("/members")
    public ResponseEntity<?> getAllStudent() throws SQLException, InterruptedException {

        List<MemberDto> allStudents = testService.getAllMembers();

        return new ResponseEntity<>(allStudents, HttpStatus.OK);

    }
}

# Service
@Service
@RequiredArgsConstructor
public class TransactionTestService {

    private final DataSource dataSource;
    private final TransactionTestRepository repository;

    @Transactional(readOnly = true)
    public List<MemberDto> getAllMembers() throws InterruptedException {
        printDataSourceInfo();

        System.out.println("================ Start ================");
        List<MemberDto> memberDtos = repository.findAll()
                .stream()
                .map(MemberDto::new)
                .collect(Collectors.toList());
        System.out.println("================ End ================");

        printDataSourceInfo();
        return memberDtos;
    }

    private void printDataSourceInfo() throws InterruptedException {

        Thread.sleep(1000);

        if (dataSource instanceof HikariDataSource hikariDataSource) {
            HikariPoolMXBean poolMXBean = hikariDataSource.getHikariPoolMXBean();

            int totalConnections = poolMXBean.getTotalConnections();
            int idleConnections = poolMXBean.getIdleConnections();
            int activeConnections = totalConnections - idleConnections;

            System.out.println("totalConnections = " + totalConnections);
            System.out.println("idleConnections = " + idleConnections);
            System.out.println("activeConnections = " + activeConnections);
        }
    }
}

 

호출 결과, 아래와 같은 로그를 확인할 수 있습니다.

totalConnections = 10
idleConnections = 9
activeConnections = 1
================ Start ================
Hibernate: 
    select
        m1_0.id,
        m1_0.name 
    from
        member m1_0
================ End ================
totalConnections = 10
idleConnections = 9
activeConnections = 1

DB 조회가 끝난 이후에 동기적으로 실행된 Connection 조회 정보에서 activeConnections = 1, 즉 메서드의 종료 직전까지 Connection이 반환되지 않았다는 것을 볼 수 있습니다.

 

이에 따라, DB 조회 이후 시간이 꽤 소요되는 비즈니스 로직이 있다면 해당 API에서 Connection을 점유하고 있기 때문에 Connection Pool의 운영에 문제가 생길 수 있다는 것을 예측해볼 수 있습니다.

 

3. @Transactional 없이 spring.jpa.open-in-view : true 일 때

위에서 @Transactional을 사용했을 때 메서드의 끝까지 커넥션이 잡혀있는 문제에 대해 설명했었습니다.

 

이와 대조하기 위해, @Transactional을 사용하지 않고 위와 같은 동일한 실험을 해볼 수 있는데요. 하지만 직접 실험을 해보면 아시겠지만 @Transactional을 사용하지 않아도 메서드 종료 전까지 커넥션 풀은 반납되지 않습니다.

 

이유는 바로 spring.jpa.open-in-view 옵션이 true로 자동 설정되어 있기 때문입니다.

 

이 옵션은 JPA를 사용할 때 DB 커넥션 시작 시점부터 API 응답이 끝날 때까지 영속성 컨텍스트와 DB 커넥션을 유지하도록 만드는데요.

 

JPA에서는 지연 로딩을 많이 사용하기 때문에 Service 레이어를 벗어난 곳에서도 DB와의 커넥션이 필요하기 때문입니다.

 

다만, 이 역시 DB 커넥션 리소스를 @Transactional을 사용할 때와 비슷하게 가져가기 때문에 Connection Pool 운영에 문제가 생길 수 있게 됩니다.

 

더 자세한 내용은 아래 글을 읽어보시면 도움이 될 것 같습니다.

 

 

Spring Boot의 open-in-view, 그 위험성에 대하여.

실제 서버 장애 해결과정을 중심으로

medium.com

 

따라서, 이 글에 한정해서 테스트를 하실 때는 spring.jpa-open-in-view 설정을 false로 바꾼 뒤 테스트해보시길 바랍니다.

spring:
  jpa:
    open-in-view: false

 

4. 정리 

이번 글에서는 내부 코드를 따라가보면서 Spring @Transactional(readOnly = true) 옵션의 동작 방식을 이해해봤습니다.

 

@Transactional(readOnly = true) 옵션 사용 시 FlushMode가 Manual이 되고, 엔티티에 대한 Dirty Checking을 생략하게 되어 성능 상 이점을 가져다줄 수 있었습니다.

 

하지만 @Transactional 자체가 AOP Proxy를 기반으로 하기 때문에 타겟 메서드의 종료 전까지 DB Connection을 점유하게 되어 발생할 수 있는 Connection Pool 문제도 있었습니다.

 

따라서, @Transactional(readOnly = true) 옵션은 대개의 조회 API에서 적절히 사용하면 좋겠지만 한 번쯤은 해당 API의 내부 로직을 들여다보고 주의하여 사용하면 좋을 것 같습니다.

 

4. Reference

https://medium.com/@jkha7371/is-transactional-readonly-true-a-silver-bullet-1dbf130c97f8