코드로 보는 QueryDSL transform() 에서 @Transactional 사용해야 이유

cooper·2023년 9월 13일
0
post-thumbnail

[1] 1:N DTO 변환 transform() 쿼리 문제 : Connection Leak

QueryDSL 메서드를 통해서 1:N 연관관계를 조회한 데이터를 transform() 메서드를 통해 일괄적으로 DTO 로 변환하여 조회하는 로직을 작성했지만 Connection Leak 이 발생했던 문제가 있었다. 아래가 문제의 쿼리이다.

@RequiredArgsConstructor
public class StudentRepositoryImpl implements StudentRepositoryCustom {

    private final JPAQueryFactory jpaQueryFactory;

	//...
    
    @Override
    public List<StudentLookupResponse> findAllByStudentIds(List<Long> studentIds) {
        return jpaQueryFactory.select(student)
                .from(student)
                .leftJoin(award).on(student.id.eq(award.student.id))
                .where(student.id.in(studentIds))
                .transform(groupBy(student.id)
                        .list(Projections.constructor(StudentLookupResponse.class,
                                student.id,
                                student.name,
                                student.tagName,
                                GroupBy.list(
                                        Projections.constructor(AwardLookupResponse.class,
                                                award.id,
                                                award.name))
                                )
                        )
                );
    }
}

[2] transform() 메서드 동작 후, ConnectionLeak 확인

hikari pool 의 로깅 레벨을 설정하여 Connection Pool 을 확인해보았다. 실제 API 를 6회 요청을 하고 로그를 확인해보면 HikariPool 의 active -> idle 로 전환되지 않았다.

logging:
  level:
    com.zaxxer.hikari: TRACE
    com.zaxxer.hikari.HikariConfig: DEBUG


[3] @Transactional 을 선언하지 않으면 transform() 사용시 EntityManager 는 닫히지 않는다!

source : Querydsl에서 transform 사용시에 DB connection leak 이슈

(1) transform() 메서드 내부 확인하기

  1. iterate(), fetch() 메서드 두가지를 비교해보자.
    1. 공통점
      • createQuery() 호출
    2. 차이점
      • iterate() -> queryHandler.iterate() 반환
      • fetch -> getResultList(query) 반환
package com.querydsl.jpa.impl;

public abstract class AbstractJPAQuery<T, Q extends AbstractJPAQuery<T, Q>> extends JPAQueryBase<T, Q> {

    @Override
    public CloseableIterator<T> iterate() {
        try {
            Query query = createQuery();
            return queryHandler.iterate(query, projection);
        } finally {
            reset();
        }
    }

    @Override
    @SuppressWarnings("unchecked")
    public List<T> fetch() {
        try {
            Query query = createQuery();
            return (List<T>) getResultList(query);
        } finally {
            reset();
        }
    }
}

(2) SharedEntityManagerCreator 의 invoke() 메서드를 확인하자.

  1. 현재 트랜잭션에 참여하고 있는 EntityMaager 를 조회한다. 존재하지 않을 경우 새로운 EntityManager 를 생성한다.
  2. 현재 생성된 EntityManager 의 메서드를 활용하기 위해서 DeferredQueryInvocationHandler Proxy 생성해서 반환한다.
  3. DeferedQueryInvocationHandlerSharedEntityManager에서 비트랜잭션 createQuery()가 호출될 때 해당 쿼리 객체를 처리한다.
  4. DeferedQueryInvocationHandler proxy의 invoke() 메서드를 실행하고 queryTerminationMethod 미리 정의해둔 method name일 경우에는 entityManager를 종료한다.
public abstract class SharedEntityManagerCreator {

    private static class SharedEntityManagerInvocationHandler implements InvocationHandler, Serializable {
    
		@Override
		@Nullable
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
	        // (1) 트랜잭션 참여하면 target 값을 반환하지 않는다.
			EntityManager target = EntityManagerFactoryUtils.doGetTransactionalEntityManager(
					this.targetFactory, this.properties, this.synchronizedWithTransaction);

	        // ...
			try {
				Object result = method.invoke(target, args);
				if (result instanceof Query) {
					Query query = (Query) result;
					if (isNewEm) {
						Class<?>[] ifcs = cachedQueryInterfaces.computeIfAbsent(query.getClass(), key ->
								ClassUtils.getAllInterfacesForClass(key, this.proxyClassLoader));
                                
                        // (2) DeferredQueryInvocationHandler 생성해서 다른 곳에서 호출
						result = Proxy.newProxyInstance(this.proxyClassLoader, ifcs,
								new DeferredQueryInvocationHandler(query, target)); 
						isNewEm = false;
					}
					else {
						EntityManagerFactoryUtils.applyTransactionTimeout(query, this.targetFactory);
					}
				}
				return result;
			}
			catch (InvocationTargetException ex) {
				throw ex.getTargetException();
			}
			finally {
				if (isNewEm) {
					EntityManagerFactoryUtils.closeEntityManager(target);
				}
			}
		}

	private static class DeferredQueryInvocationHandler implements InvocationHandler {

		@Override
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
			// ...

			try {
            	// ...
			}
			catch (InvocationTargetException ex) {
				throw ex.getTargetException();
			}
			finally {
            	// (3) queryTerminatingMethods 포함되면 EntityManager 를 닫는다.
				if (queryTerminatingMethods.contains(method.getName())) { 
					if (this.outputParameters != null && this.target instanceof StoredProcedureQuery) {
						StoredProcedureQuery storedProc = (StoredProcedureQuery) this.target;
						for (Map.Entry<Object, Object> entry : this.outputParameters.entrySet()) {
							try {
								Object key = entry.getKey();
								if (key instanceof Integer) {
									entry.setValue(storedProc.getOutputParameterValue((Integer) key));
								}
								else {
									entry.setValue(storedProc.getOutputParameterValue(key.toString()));
								}
							}
							catch (IllegalArgumentException ex) {
								entry.setValue(ex);
							}
						}
					}
					EntityManagerFactoryUtils.closeEntityManager(this.entityManager);
					this.entityManager = null;
				}
			}
		}
}

(3) queryTerminationMethod 포함되야 EntityManager 닫힌다.

DeferredQueryInvocationHandler 의 invoke() 메서드를 확인해보면 queryTerminatingMethods 에 정의된 메서드 이름에서 iterate() 가 포함되어 있지 않다. 즉, transform() 메서드의 내부 호출인 iterate() 메서드는 엔티티 매니저를 닫히지 않아 Connection 을 반환하지 않는 문제였다.


[4] @Transactional 은 작업이 완료되면 EntityManager 를 닫는다.

(1) 트랜잭션 핵심 컴포넌트 : TransactionInterceptor

간단히 스프링에서 제공하는 @Transactional 을 선언 시의 동작 방식을 보자. 트랜잭션을 수행하는데 여러 컴포넌트들이 사용되지만 핵심 컴포넌트는 TransactionInterceptor 이다. TransactionInterceptor 은 부모 클래스인 AbstractPlatformTransactionManagerinvokeWithinTransaction() 메서드를 호출하며 전체 트랜잭션이 동작한다.


(2) AbstractPlatformTransactionManager. invokeWithinTransaction() 내부에 핵심이 있다.

AbstractPlatformTransactionManager 의 invokeWithinTransaction() 를 내부 로직의cleanupAfterCompletion()) 메서드를 확인해보자. 해당 메서드는 트랜잭션이 완료(commit or rollback) 시점에 ThreadLocal 의 트랜잭션 정보를 반환하면서 엔티티 매니저를 닫는다. EntityManager 를 닫으면서 사용한 Connection 을 ConnectionPool 에 반환하기 때문에 @Transactional 어노테이션을 선언하면 Connection 을 반환한다. 그러므로 transform() 메서드의 connection leak 을 방지하기 위해서는 꼭 @Transactional 을 선언해서 사용하도록 해야 한다.

public abstract class AbstractPlatformTransactionManager implements PlatformTransactionManager, Serializable {

	private void processCommit(DefaultTransactionStatus status) throws TransactionException {
		try {
			try {
                // ...                                
				else if (status.isNewTransaction()) {
                	// ...
					doCommit(status); // 커밋하기 (구현체: JpaTransactionManager)
				}
	            // ...                
			}
		}
		finally {
        	// 트랜잭션 정보 제거
			cleanupAfterCompletion(status);
		}
	}

	private void cleanupAfterCompletion(DefaultTransactionStatus status) {
		status.setCompleted();
		if (status.isNewSynchronization()) {
			TransactionSynchronizationManager.clear();
		}
		if (status.isNewTransaction()) {
			doCleanupAfterCompletion(status.getTransaction()); // 여기 들어가면 EntityManager 다는 로직 있음.
		}
		if (status.getSuspendedResources() != null) {
			if (status.isDebug()) {
				logger.debug("Resuming suspended transaction after completion of inner transaction");
			}
			Object transaction = (status.hasTransaction() ? status.getTransaction() : null);
			resume(transaction, (SuspendedResourcesHolder) status.getSuspendedResources());
		}
	}
    
	@Override
	protected void doCleanupAfterCompletion(Object transaction) {
    	// ...
        
		if (txObject.isNewEntityManagerHolder()) {
			EntityManager em = txObject.getEntityManagerHolder().getEntityManager();
			if (logger.isDebugEnabled()) {
				logger.debug("Closing JPA EntityManager [" + em + "] after transaction");
			}
			EntityManagerFactoryUtils.closeEntityManager(em); // EntityManager 닫기!
		}
		else {
			logger.debug("Not closing pre-bound JPA EntityManager after transaction");
		}
	}


}

(3) (번외) QueryDSL? Hibernate? 에서 해결할 문제인가?

[Querydsl repo] Connection leak when using FetchableQueryBase#transform outside of a transaction #3089

실제 QueryDSL repository 에서도 관련 이슈에 대해 이야기하고 있지만 querydsl 가 아닌 것으로 판단하고 있다. 그 이유는 트랜잭션이 활성화되지 않을 때는 Connection 을 생성하는 주체는 Hibernate 이며 따라서 트랜잭션을 닫는 주체는 Hibernate 인 것으로 이야기하며 모든 메서드에 @Transactional 을 선언하는 것을 권장하고 있다.


[5] References

profile
막연함을 명료함으로 만드는 공간 😃

0개의 댓글