1. Background

 

 이전 참여했던 프로젝트 중에 transform() 을 사용하는 로직에서 발생했던 내용 기록용 글이다.

  • 예시 코드의 프로젝트 환경은 Spring Boot 2.7.15, QueryDSL 5.0 이다.

 

(1) 샘플 코드를 확인해보자.

 문제는 querydsl 에서 제공하는 transform() 메서드를 사용하는 과정에서 connection leak 이 발생했다. connection pool 은 DB에 쿼리 요청이 완료된 connection 을 다시 회수하지만 반환되지 않는 문제가 발생하며 JDBC ConnectionException 을 반환하였다. 아래는 쿼리 예시이다. (실제 코드 대신 유사한 코드를 작성한 샘플이다.)

@Repository
@RequiredArgsConstructor
public class StudentRepositoryImpl implements StudentRepository {

    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) debug 설정을 통해 확인해보자.

추가적으로 Hikari Pool 의 active, idle 상태를 디버깅 설정을 통해 확인하였다.

쿼리 실행이 완료되었지만 반환하지 않는 connection

 

 

우선 결론부터 이야기하자면 transform() 메서드에서 @Transactional 이 선언되어 있지 않으면 connection leak 이 발생한다.

그 이유는 아래 QueryDSL 소스 코드를 통해 확인해보자.

 

 

2. QueryDSL 내부 코드를 확인해보자.

 

조금 더 세부적인 결론은 transform() 메서드에서 @Transactional 을 선언하지 않으면 EntityManager 를 닫히지 않아 발생하는 문제다. 이를 확인해보기 위해 transform() 메서드 내부를 확인해보자.

 

transform() 메서드는 내부적으로 iterate() 메서드를 호출하는데 queryHandler.iterate() 를 호출한다.

그리고 내부적으로 SharedEntityManagerCreator.invoke() 메서드를 호출하는데 로직을 요약하자면 아래와 같다.

 

 

(1) SharedEntityManagerCreator.invoke() 에서 EntityManager 를 닫혀야 한다.

  1. 현재 트랜잭션에 참여하고 있는 EntityManager 를 조회한다. (존재하지 않을 경우 새로운 EntityManager 를 생성한다.)
  2. EntityManager 의 메서드를 활용하기 위해서 DeferredQueryInvocationHandler Proxy 객체를 생성해서 반환한다.
  3. DeferedQueryInvocationHandler 은 SharedEntityManager에서 비트랜잭션 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;
					}
				}
			}
		}
	}
}

 

(2) queryTerminationMethods 에 메서드가 포함되어 있어야 EntityManager 가 닫힌다.

위 코드의 3번 주석을 확인해보면 queryTerminatingMethods 에 정의된 메서드 이름을 통해 엔티티 매니저를 닫는다. 한번 queryTerminatingMethods 를 확인해보자.

 

SharedEntityManagerCreator 의 static block 을 확인해보면 iterate() 메서드가 포함되어 있지 않다. 즉, 기본적으로는 transform() 메서드의 내부 호출 메서드인 iterate() 호출되면 EntityManager 를 닫는 메서드에 해당되지 않기 때문에 EntityManager 를 닫히지 않아 connection leak 을 야기할 수 있다는 것이다.

하지만 여기서 의문점이 남아있다. 그렇다면 왜 @Transactional 을 선언하면 EntityManager 가 닫히는 것일까?

 

 

 

3. @Transactional 을 통해 어떻게 EntityManager 를 닫을까?

(1) @Transactional 은 작업이 완료되면 기본적으로 EntityManager 를 닫는다.

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

 

(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");
		}
	}
}

 

 

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

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

 

 

Reference