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 상태를 디버깅 설정을 통해 확인하였다.
우선 결론부터 이야기하자면 transform() 메서드에서 @Transactional 이 선언되어 있지 않으면 connection leak 이 발생한다.
그 이유는 아래 QueryDSL 소스 코드를 통해 확인해보자.
2. QueryDSL 내부 코드를 확인해보자.
조금 더 세부적인 결론은 transform() 메서드에서 @Transactional 을 선언하지 않으면 EntityManager 를 닫히지 않아 발생하는 문제다. 이를 확인해보기 위해 transform() 메서드 내부를 확인해보자.
transform() 메서드는 내부적으로 iterate() 메서드를 호출하는데 queryHandler.iterate() 를 호출한다.
그리고 내부적으로 SharedEntityManagerCreator.invoke() 메서드를 호출하는데 로직을 요약하자면 아래와 같다.
(1) SharedEntityManagerCreator.invoke() 에서 EntityManager 를 닫혀야 한다.
- 현재 트랜잭션에 참여하고 있는 EntityManager 를 조회한다. (존재하지 않을 경우 새로운 EntityManager 를 생성한다.)
- EntityManager 의 메서드를 활용하기 위해서 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;
}
}
}
}
}
}
(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
- https://colin-d.medium.com/querydsl-%EC%97%90%EC%84%9C-db-connection-leak-%EC%9D%B4%EC%8A%88-40d426fd4337
- https://github.com/spring-projects/spring-framework/blob/5.3.x/spring-orm/src/main/java/org/springframework/orm/jpa/SharedEntityManagerCreator.java
- https://github.com/querydsl/querydsl/issues/3089
'spring > trouble shooting' 카테고리의 다른 글
Spring6.1 부터 변경된 Parameter Name Retention (0) | 2025.03.11 |
---|---|
커스텀 어노테이션 기반 AOP 를 @Transactional 보다 나중에 실행시키기 (1) | 2025.01.27 |
테스트에서 @Sql 로 테스트 데이터가 들어가지 않았던 이유 (with. custom TestExecutionListener) (1) | 2024.01.24 |