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))
)
)
);
}
}
hikari pool 의 로깅 레벨을 설정하여 Connection Pool 을 확인해보았다. 실제 API 를 6회 요청을 하고 로그를 확인해보면 HikariPool 의 active -> idle
로 전환되지 않았다.
logging:
level:
com.zaxxer.hikari: TRACE
com.zaxxer.hikari.HikariConfig: DEBUG
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();
}
}
}
DeferredQueryInvocationHandler
Proxy 생성해서 반환한다.DeferedQueryInvocationHandler
은 SharedEntityManager
에서 비트랜잭션 createQuery()
가 호출될 때 해당 쿼리 객체를 처리한다.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;
}
}
}
}
DeferredQueryInvocationHandler 의 invoke() 메서드를 확인해보면 queryTerminatingMethods
에 정의된 메서드 이름에서 iterate()
가 포함되어 있지 않다. 즉, transform() 메서드의 내부 호출인 iterate() 메서드는 엔티티 매니저를 닫히지 않아 Connection 을 반환하지 않는 문제였다.
간단히 스프링에서 제공하는 @Transactional 을 선언 시의 동작 방식을 보자. 트랜잭션을 수행하는데 여러 컴포넌트들이 사용되지만 핵심 컴포넌트는 TransactionInterceptor
이다. TransactionInterceptor
은 부모 클래스인 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");
}
}
}
실제 QueryDSL repository 에서도 관련 이슈에 대해 이야기하고 있지만 querydsl 가 아닌 것으로 판단하고 있다. 그 이유는 트랜잭션이 활성화되지 않을 때는 Connection 을 생성하는 주체는 Hibernate 이며 따라서 트랜잭션을 닫는 주체는 Hibernate 인 것으로 이야기하며 모든 메서드에 @Transactional
을 선언하는 것을 권장하고 있다.