enableDefaultTransactions를 활용한 불필요한 SET 쿼리 제거

서민정·2024년 1월 11일

Goal

  • 트랜잭션 전후로 발생하는 SET ~ 쿼리를 없애 쿼리를 최적화하기
    set session transaction read only
    SET autocommit=0
    SELECT 쿼리
    commit
    SET autocommit=1
    set session transaction read write
    왜 줄여야하나? 실제 호출해야할 쿼리는 SELECT 쿼리 하나인데, SELECT 쿼리 하나 당 불필요하게 추가로 5개의 쿼리가 호출됨 만약 1K의 SELECT 쿼리가 DB로 실행된다고 할 때, DB로 들어오는 요청은 실제로 6K가 되는 것이며 이로 인해 DB 서버의 리소스 사용량도 증가하게 됨 또한 애플리케이션 단에서도 DB와의 통신이 그만큼 더 늘어나는 것이므로, 하나의 쿼리를 실행하는 것보다 레이턴시가 더 증가함 따라서 이와 같이 불필요하게 수행되는 설정 쿼리들을 줄인다면, 애플리케이션 응답 속도도 더 높이고 DB 서버의 리소스 사용량도 줄일 수 있음. (일석이조의 효과!)

예시 코드

아래 설명은 아래 예시코드를 바탕으로 수행한 결과예요.

@Service
class MemberService(
    private val memberRepository: MemberRepository,
) {
    fun getMember(id: Long): Member? {
        // findByIdOrNull은 SimpleJpaRepository에서 제공해주는 기본 메서드를 사용
        return memberRepository.findByIdOrNull(id)
    }
}

interface MemberRepository : JpaRepository<Member, Long>

@Entity
class Member(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
    var name: String,
)

과정

  • local docker db에서 set query 발생을 확인하는 법
    1. MySQL 접속
    mysql> mysql -uroot -p
    
    2. 설정 변경
    mysql> set global general_log=ON
    
    3. 로그 파일 조회
    1) 로그 파일 위치(파일명) 확인
    mysql> show global variables like 'general_log_file';
    +------------------+---------------------------------------------+
    | Variable_name    | Value                                       |
    +------------------+---------------------------------------------+
    | general_log      | OFF                                         |
    | general_log_file | /usr/local/var/mysql_3306/Joanne.log |
    +------------------+---------------------------------------------+
    
    2) 쉘에서 파일 조회
    shell> tail -f /usr/local/var/mysql_3306/Joanne.log
    
    4. 테스트 종료 후 다시 설정 변경
    mysql> set global general_log=OFF
  • 참고

    SimpleJpaRepository

    @Repository
    @Transactional(readOnly = true)
    public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
    SimpleJpaRepository에는 자체적으로 @Transactional(readOnly = true 어노테이션이 붙어있어요. 해당 어노테이션은 트랜잭션을 읽기 전용으로 열게 돼요.
  • 빌드 시점

TransactionAspectSupport에서 TransactionAttributeSource를 세팅함.

이때 TransactionalRepositoryProxyPostProcessorenableDefaultTransactions 옵션을 @EnableJpaRepositories에서 설정해준 enableDefaultTransactions의 값으로 설정함.

@EnableJpaRepositories(
    // default 값은 true예요.
    enableDefaultTransactions = false,
)
  • 실제 쿼리 호출 시점

TransactionAspectSupportinvokeWithinTransaction 메서드 호출

해당 메서드 내부에서 transactionAttribute를 가져옴. (getTransactionAttribute)

AbstractFallbackTransactionAttributeSource 에 정의된 getTransactionAttribute 는 해당 메서드에 대해 정의된 트랜잭션을 cache 에서 가져오는데, 첫번째 호출의 경우 캐시에 등록된 값이 없기 때문에 직접 TransactionAttribute를 계산한다. (computeTransactionAttribute)

computeTransactionAttribute가 호출되면, RepositoryAnnotationTransactionAttributeSourcecomputeTransactionAttribute 가 호출되는데, 해당 메서드 내부를 보면 다음과 같다.

  • 전체 코드
    코드 라인 정렬 쉽게 하는법 좀 알려주실분...
            @Override
            @Nullable
    		protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
    
    			// Don't allow no-public methods as required.
    			if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
    				return null;
    			}
    
    			// Ignore CGLIB subclasses - introspect the actual user class.
    			Class<?> userClass = targetClass == null ? targetClass : ProxyUtils.getUserClass(targetClass);
    
    			// The method may be on an interface, but we need attributes from the target class.
    			// If the target class is null, the method will be unchanged.
    			Method specificMethod = ClassUtils.getMostSpecificMethod(method, userClass);
    
    			// If we are dealing with method with generic parameters, find the original method.
    			specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
    
    			TransactionAttribute txAtt = null;
    
    			if (specificMethod != method) {
    
    				// Fallback is to look at the original method.
    				txAtt = findTransactionAttribute(method);
    
    				if (txAtt != null) {
    					return txAtt;
    				}
    
    				// Last fallback is the class of the original method.
    				txAtt = findTransactionAttribute(method.getDeclaringClass());
    
    				if (txAtt != null || !enableDefaultTransactions) {
    					return txAtt;
    				}
    			}
    
    			// First try is the method in the target class.
    			txAtt = findTransactionAttribute(specificMethod);
    
    			if (txAtt != null) {
    				return txAtt;
    			}
    
    			// Second try is the transaction attribute on the target class.
    			txAtt = findTransactionAttribute(specificMethod.getDeclaringClass());
    
    			if (txAtt != null) {
    				return txAtt;
    			}
    
    			if (!enableDefaultTransactions) {
    				return null;
    			}
    
    			// Fallback to implementation class transaction settings of nothing found
    			// return findTransactionAttribute(method);
    			Method targetClassMethod = repositoryInformation.getTargetClassMethod(method);
    
    			if (targetClassMethod.equals(method)) {
    				return null;
    			}
    
    			txAtt = findTransactionAttribute(targetClassMethod);
    
    			if (txAtt != null) {
    				return txAtt;
    			}
    
    			txAtt = findTransactionAttribute(targetClassMethod.getDeclaringClass());
    
    			if (txAtt != null) {
    				return txAtt;
    			}
    
    			return null;
    		}

일부만을 이용하여 설명에 덧붙여보자면,

  • enableDefaultTransactions = false 일 때
    • computeTransactionAttribute의 method 인자로 CrudRepository.findById가 호출됨.

      // TransactionalRepositoryProxyPostProcessor.java
      
      			TransactionAttribute txAtt = null;
      
      			if (specificMethod != method) {
      
      				// 여기서의 method는 CrudRepository.findById
      				// 해당 메서드에는 @Transactional 어노테이션이 없어 여전히 txAtt는 null
      				txAtt = findTransactionAttribute(method);
      
      				if (txAtt != null) {
      					return txAtt;
      				}
      
      				// *Last fallback is the class of the original method.*
      				txAtt = findTransactionAttribute(method.getDeclaringClass());
      
      				// txAtt가 null이지만, enableDefaultTransactions를 false로 주었기 때문에
      				// 해당 조건문의 결과값이 true가 되어 txAtt는 null을 반환하게 된다.
      				if (txAtt != null || !enableDefaultTransactions) {
      					return txAtt;
      				}
      			}
      ...

      위 compute를 호출했던 호출부로 다시 돌아가서, NULL_TRANSACTION_ATTRIBUTE를 담고, 트랜잭션 없이 쿼리를 수행한다.

      // AbstractFallbackTransactionAttributeSource.java
      
      TransactionAttribute txAttr = computeTransactionAttribute(method, targetClass);
      // Put it in the cache.
      
      // computeTransactionAttribute의 결과값이 null이기 때문에,
      if (txAttr == null) {
      	// attributeCache에 NULL TRANSACTION을 넣고, 
        // SimpleJpaRepository에 정의된 @Transactional(readOnly=true) 옵션이 먹히지 않은 채로
        // 트랜잭션 없이 쿼리가 수행된다. 
      	this.attributeCache.put(cacheKey, NULL_TRANSACTION_ATTRIBUTE);
      }
  • enableDefaultTransactions = true 일 때
    • computeTransactionAttribute의 method 인자로 CrudRepository.findById가 호출되고, SimpleJpaRepository까지 확인하기 때문에 Transaction이 readOnly로 동작하게 된다.
      // TransactionalRepositoryProxyPostProcessor.java
      			TransactionAttribute txAtt = null;
      
      			if (specificMethod != method) {
      
      				// 여기서의 method는 CrudRepository.findById
      				// 해당 메서드에는 @Transactional 어노테이션이 없어 여전히 txAtt는 null
      				txAtt = findTransactionAttribute(method);
      
      				if (txAtt != null) {
      					return txAtt;
      				}
      
      				// *Last fallback is the class of the original method.*
      				txAtt = findTransactionAttribute(method.getDeclaringClass());
      
      				// txAtt가 null이고, enableDefaultTransactions를 true로 주었기 때문에
      				// 해당 조건문을 통과하지 못하고 아래로 내려간다.
      				if (txAtt != null || !enableDefaultTransactions) {
      					return txAtt;
      				}
      			}
      			
      			// specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod); 
      			// 위 함수를 통해 구해진 메서드로서 현재는 SimpleJpaRepository.findById가 된다.
            // findById 메서드 자체에는 Transaction이 명시되어있지 않기 때문에 txAtt = null
      			txAtt = findTransactionAttribute(specificMethod);
      
      			if (txAtt != null) {
      				return txAtt;
      			}
      
      			// Class인 SimpleJpaRepository에는 @Transactional(readOnly=true)가 붙어있음.
      			txAtt = findTransactionAttribute(specificMethod.getDeclaringClass());
      
      			// 반환된 txAtt는 PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
      			if (txAtt != null) {
      				return txAtt;
      			}
      
      			if (!enableDefaultTransactions) {
      				return null;
      			}
      ...
      위 compute를 호출했던 호출부로 다시 돌아가서, PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly를 담고, 별도로 트랜잭션을 명시해주지 않았더라도 SimpleJpaRepository의 메서드를 사용한다면트랜잭션과 함께 쿼리를 수행하게 된다.

결론

enableDefaultTransactions=false로 주고 실행하게되면 SimpleJpaRepository에 정의된 기본 메서드를 사용할 때에도 트랜잭션 없이 쿼리를 수행하게 되어 불필요한 SET.. 등을 수행하지 않을 수 있다.

주의해야할 점

enableDefaultTransactions=false로 주게 되면 SimpleJpaRepository에 정의된 메서드를 사용하더라도 트랜잭션 없이 사용하기 때문에 영속성 컨텍스트를 활용할 수 없음.

트랜잭션이 필요한 모든 구간에 명시적으로 Transactional 어노테이션 등을 사용해주어야함.

profile
Server Engineer

1개의 댓글

comment-user-thumbnail
2024년 2월 14일

아주 유용하군

답글 달기