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

colin.jang
7 min readJan 10, 2021

--

Elasticsearch에 색인하는 배치 서버에 신규 기능을 추가하고 있던 중 코드 작성을 완료하여 테스트를 시작한 후 계속해서 JDBC ConnectionException이 발생하였다.

뭐지..?

해당 서버는 Spring Boot 버전 1.4.6과
querydsl 4.1.4버전을 사용하고 있다.

바쁜 사람들을 위해 결론부터 요약하자면
querydsl 에서 result handling을 위해 transfrom() 사용시
트랜잭션 내부에서 실행되지 않는다면 해당 db connection은 해제되지 않는다.

이후부터는 해당 이슈에 대해 어떻게 접근하고 해결했는지에 대해 써보려고 한다.

해당 스레드가 DB connection pool로 부터 connection을 얻지 못해서 exception이 발생하고 있었다.
해당 배치 작업은 DB에 insert는 없고 select로만 이루어져 있는데 왜 exception이 발생하는지 곰곰히 생각해보다가 우선 HikariCP의 log를 추가해서 살펴보기로 했다.
추가로 작업 대상 범위를 줄여서 작업은 5분만에 완료되도록 설정했다.

properties에 설정들을 추가.
logging.level.com.zaxxer.hikari=TRACE
logging.level.com.zaxxer.hikari.HikariConfig=DEBUG
connection pool에는 6개의 connection이 생성되어있다.
작업이 완료된 후에 connection pool stats.

로그를 살펴보니 신규 기능이 추가된 이후에는 connection을 추가로 사용하고 작업이 완료된 이후에도 사용된 connection은 해제되지 않았다.

Querydsl Result Handling

querydsl에서는 쿼리 결과 반환을 다양하게 처리할 수 있도록 지원하고 있다.
Result handling 문서에 잘 나와있다. 기존 배치 작업에는 fetch 메소드만을 사용하고 있었는데 신규기능에는 transform 메소드를 이용하여 쿼리결과를 grouping하여 Map으로 반환하는 기능이 추가되었다.

// fetch
query.fech();
// transform
query.transform(groupBy(Q.id).as(set(Q.name)));

아무래도 의심되는 부분은 transform이다.
기존에는 transform을 사용하는 쿼리가 없었지만 신규 기능에는 transform을 사용하고 있었다. 하지만 fetch에서는 connection이 정확히 반환되는데 왜 transform에서는 connection이 반환되지 않을까? 둘의 차이를 살펴보자.

transform()과 fetch()의 차이

tranform메소드와 fetch 메소드를 따라가보자.

AbstractJPAQuery.java

transform 내부에서는 iterate를 호출하고 있기 때문에 iterate와 fetch에 대해 보도록 하자.

  • 공통적으로 createQuery() 호출
  • iterate는 queryHandler.iterate() 반환
    fetch는 getResultList(query) 반환 => 중요!!

기존 배치 작업은 트랜잭션 내부에서 실행되지 않고 있어서 쿼리를 실행할 때마다 새로운 EntityManager가 생성되는 형식이다.
(EntityManager 는 thread-safe 하지 않기 때문에 thread 간 공유를 하면 안된다. 관련 주제를 다룬 글들이 많이 있으므로 관심있다면 찾아보는 것을 추천한다.)

SharedEntityManagerCreator.java 내부에 SharedEntityManagerInvoationHandler class를 살펴보자.

SharedEntityManagerCreator:253

현재 트랜잭션에 참여하고 있다면 target은 null이 아니고 참여하고 있지 않다면 target은 null이 된다. 위에서 해당 배치 작업은 transaction 내부에서 실행되지 않고 있기 때문에 target은 null이 된다.
코드를 좀 더 살펴보면

SharedEntityManagerCreator:286

target이 null 이기 때문에 target에는 새로운 EntityManger가 생성되고 isNewEm은 true가 되고난 후에 DeferredQueryInvocationHandler Proxy를 return한다.

SharedEntityManagerCreator:336

DefferedQueryInvocationHandler의 주석을 살펴보면 SharedEntityManager에서 비트랜잭션 createQuery가 호출될 때 해당 쿼리 객체를 처리한다고 쓰여 있다.

반환된 proxy에서는 method를 실행하고 난 후에 method name이 미리 정의해둔 queryTerminationMethod name일 경우에는 entityManager를 close 시킨다.
그렇다면 queryTerminationMethod에는 무엇이 있을까?

queryTermationMethod

위 세가지의 method가 아닐 경우에는 entityManager는 close되지 않는다.
위에서 fetch와 transform의 차이를 다시 살펴보자.

AbstractJPAQuery.java

transaction 내부에서 실행되지 않더라도 fetch의 경우에는 getResultList를 호출함으로써 entityManager가 close되지만 transform의 경우에는 queryTerminationMethod가 실행되지 않아 entityManager가 close되지 않는다.

원인을 알았으니 해결해보자.
해당 transform이 실행되는 method에 @Transactional annotaion을 붙여 기존의 트랜잭션에 참여하거나 트랜잭션이 없다면 새로 트랜잭션을 시작해서 항상 transaction 내부에서 실행되도록 수정하였다.

작업이 완료된 후에 pool 상태.

작업이 완료된 후에 로그를 보면 connection이 적절히 반환되어 active connection이 0인 것을 확인할 수 있다.

--

--