Java & Spring

Dive into @Transactional

ju_young 2024. 5. 17. 16:10
728x90

SimpleJpaRepository

먼저 Spring JPA에서 기본으로 구현된 SimpleJpaRepository 코드를 확인했다.

 

SimpleJpaRespository 는 전체 메소드에 @Transactional(readOnly = true) 를 적용하고 save, delete를 수행하는 메소드에서는 @Transactional 에 적용한 것을 확인했다. 일반적으로 조회할 때 사용하는 메소드 findById, findAll , 저장할 때 사용하는 메소드 saveSimpleJpaRepository 에서 구현된 메소드를 호출한다는 사실도 알게되었다. 따라서 직접 @Transactional을 추가/적용하지 않아도 Transaction이 걸리는 것을 로그를 출력하여 확인했다.

@Transactional을 직접 추가했을 경우와 하지 않았을 경우 로그 비교

아래는 @Transactional을 추가하지 않은 경우 의 JPA 로그이다.

[nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(1165081108<open>)] for JPA transaction
[nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@64362f08]
Hibernate: 
    insert 
    into
        wastes
        (created_at, modified_at, address, content, file_name, like_count, member_id, sell_status, title, transaction_at, view_count, waste_category, waste_price, waste_status) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
[nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
[nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(1165081108<open>)]
[nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(1165081108<open>)] after transaction

간단하게 정리하면 다음과 같다.

  1. Create new transaction
  2. Open new EntityManager
  3. Expose JPA transaction
  4. insert 쿼리 수행
  5. Initiating transaction commit
  6. Committing JPA transaction
  7. Closing JPA EntityManager

그리고 @Transactional을 추가한 경우의 JPA 로그이다.

[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [freshtrash.freshtrashbackend.service.WasteService.addWaste]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(826833692<open>)] for JPA transaction
[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@260b8450]
[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(826833692<open>)] for JPA transaction
[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
Hibernate: 
    insert 
    into
        wastes
        (created_at, modified_at, address, content, file_name, like_count, member_id, sell_status, title, transaction_at, view_count, waste_category, waste_price, waste_status) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
[nio-8080-exec-1] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.MariaDB106Dialect
[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(826833692<open>)]
[nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(826833692<open>)] after transaction

간단하게 정리하면 다음과 같습니다.

  1. Create new transaction
  2. Open new EntityManager
  3. Expose JPA transaction
  4. Found thread-bound EntityManager
  5. Participating in existing transaction
  6. insert 쿼리 수행
  7. Initiating transaction commit
  8. Committing JPA transaction
  9. Closing JPA EntityManager

@Transactional의 Propagation

위 로그를 통해 @Transactional 을 추가했을 경우 다음 두 가지가 추가로 실행되는 것을 확인할 수 있었다.

4. Found thread-bound EntityManager
5. Participating in existing transaction

그리고 @Transactional은 기본값으로 Propagation 전략이 PROPAGATION_REQUIRED , Isolation Level은 ISOLATION_DEFAULT (MariaDB 기준으로 READ_COMMITTED와 동일)으로 설정이 되어있다는 것을 확인할 수 있었다.

 

앞서 추가로 실행된 두 가지 과정은 @Transactional의 Propagation 기능에 의해 추가된 것이며, 직접 추가한 @Transactional 에 의해서 Transaction을 생성하고 SimpeJpaRepository에서 이미 추가된 @Transactional 이 생성된 Transaction이 있는지 찾아서 사용한다는 것도 알게되었습니다.

Found thread-bound EntityManager

구체적으로 코드에서는 아래처럼 ThreadLocal을 사용하여 thread-bound로 EntityManager를 저장하고 가져오는 것을 확인했다.

private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal("Transactional resources");

Participating in existing transaction

AbstractPlatformTransactionManager에서는 위 resources에서 EntityManager가 존재하면 가져와서 사용하는 것을 확인했다.

public final TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException {
    // ... 생략
    if (this.isExistingTransaction(transaction)) {
        return this.handleExistingTransaction(def, transaction, debugEnabled);
    }
    // ... 생략
}

쿼리 메소드를 직접 추가했을 경우

SimpleJpaRepository에서 구현되지 않은, 직업 추가한 쿼리 메소드를 실행했을 때 로그를 확인해보았다.

쿼리 메소드

현재 프로젝트에서 추가한 existsByIdAndMember_Id 쿼리 메소드를 기준으로 확인했다.

public interface WasteRepository extends JpaRepository<Waste, Long> {
    boolean existsByIdAndMember_Id(Long wasteId, Long memberId);
}

JPA 로그

[nio-8080-exec-1] tor$SharedEntityManagerInvocationHandler : Creating new EntityManager for shared EntityManager invocation
Hibernate: 
    select
        waste0_.id as col_0_0_ 
    from
        wastes waste0_ 
    inner join
        members member1_ 
            on waste0_.member_id=member1_.id 
    where
        waste0_.id=? 
        and member1_.id=? limit ?

이를 통해서 쿼리 메소드를 따로 추가했을 경우에는 @Transactional이 적용되지 않았기 때문에 Transaction이 생성되지 않는다는 사실을 확인할 수 있었다.

@Transactional의 read_only 옵션

@Transactional(read_only=true)를 적용했을 경우에도 마찬가지로 Transaction이 생성된다는 것을 확인했다. 그리고 read_only 옵션이 정확하게 어떤 역할을 하는 것인지 알고 싶어 구글링해보고 JDBC(MariaDB Driver)에 대한 로그를 찍어보았다.

JPA의 read_only 옵션

@Transactional에 read_only를 적용하지 않으면 엔티티가 영속성 컨텍스트(1차 캐시)에 저장되고, read_only를 적용하면 저장되지 않아 메모리를 절약할 수 있다는 내용을 알게되었다.

JDBC의 read_only 옵션

@Transactional(read_only=true)가 적용되었을 때 JDBC에 대한 로그를 찍어보았을 때 다음과 같았다.

[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.isValid(5) returned true
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.setReadOnly(true) returned 
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.getAutoCommit() returned true
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.setAutoCommit(false) returned
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. PreparedStatement.new PreparedStatement returned 

... 생략

[nio-8080-exec-1] log4jdbc.log4j2                          : 1. ResultSet.next() returned false
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. ResultSet.close() returned void
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. PreparedStatement.getMaxRows() returned 0
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. PreparedStatement.getQueryTimeout() returned 0
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. PreparedStatement.close() returned 
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.commit() returned 
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.setAutoCommit(true) returned 
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.setReadOnly(false) returned 
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.clearWarnings() returned 
[nio-8080-exec-1] log4jdbc.log4j2                          : 1. Connection.clearWarnings() returned 

readOnly 부분만 보았을 때 Connection.setReadOnly(true), Connection.setReadOnly(false)을 실행한다는 것을 확인할 수 있었는데, 실제로 왜 readOnly를 설정해주는 것인지 알고자 mariadb-connector-j 오픈소스를 찾아보았다.

mariadb-connector-j

Connection.java에서 setReadOnly 코드는 다음과 같이 구현되어 있었다.

@Override
public void setReadOnly(boolean readOnly) throws SQLException {
    lock.lock();
    try {
      if (this.readOnly != readOnly) {
        client.setReadOnly(readOnly);
      }
      this.readOnly = readOnly;
      getContext().addStateFlag(ConnectionState.STATE_READ_ONLY);
    } finally {
      lock.unlock();
    }
}

client는 구현체로 MultiPrimaryClient, MultiPrimaryReplicaClient, StandardClient가 존재했다. StandardClient , MultiPrimaryClient 에서 구현된 setReadOnly 메소드는 다음과 같이 아무런 로직없이 예외 처리만 수행하는 것을 확인할 수 있었다.

@Override
public void setReadOnly(boolean readOnly) throws SQLException {
    if (closed) {
      throw new SQLNonTransientConnectionException("Connection is closed", "08000", 1220);
    }
}

하지만 MultiPrimaryReplicaClient 에서는 readOnly 값에 따라 replicaClient, primaryClient를 설정해 주는 것을 확인했다.

구글링을 해보았을 때도 read_only 값에 따라 AWS의 Aurora DB를 사용했을 때 조회 전용 DB로 자동으로 라우팅을 해준다는 내용을 볼 수 있었다.

 

위 사실들을 바탕으로 read_only 옵션이 primary(읽기/쓰기) DB로 라우팅할 것인지 replica(읽기) DB로 라우팅할 것인지를 자동으로 판단해줄 수 있게해주는 옵션이라고 판단할 수 있었다.

Appendix

What is the difference between physical and logical transactions?

728x90