PHP 에서 Java 로 작업을 진행하면서 기존에 작성했던 Query 문을 사용해야 할 필요가 생었겼다. 근데 한번에 효율적으로 데이터들을 불러오려고 작성된 Query 를 사용하려다보니 우선은 Query 문의 길이가 길었고, DBMS 의 고유 기능을 사용한 것이 많았다.

 

사내에서 작성된 Query 들을 되돌아보니 DB 테이블들이 상당히 많아 복합적으로 Join 을 하거나 서브쿼리가 많이 존재했고, Oracle 의 START WITH ... CONNECT BY ... 처럼 특수 기능을 사용하고 있거나, LOB 관련 함수 등 그저 JPA 만으로는 분명 해결하기 까다로운 이슈들이 있다고 생각했다. 난이도도 있고 QueryDSL 를 사용한다고 해도 다른 사람들이 유지보수를 할 때 과연 잘 할 수 있을까? 하는 생각에 어차피 Query 문을 직접 짜시는 분들이 많아서 좀 난이도를 낮추어 MyBatis 를 같이 사용하고, 복잡한 Query 는 여기에서 관리하면 어떨까 싶었다.

 

그래서 복잡한 Query는 MyBatis 로 해결하기 위해 JPA 와 MyBatis 를 동시에 사용하는 환경의 프로젝트를 구성하게 되었다. JPA 와 MyBatis 를 같이 Spring 프로젝트에서 구성하는 방법은 어렵지 않았다. 다만, 몇 가지 테스트를 했을 때 기본적으로 사용자 다수가 몰려오는 상황에서 Connection Release 가 안되어 마치 Deadlock 이  걸린 것처럼 Connection 을 오랫동안 물고 있을 수 있었고, Spring 에서 지원하는 @Transactional 의 일부 기능(propagation 등)을 사용할 수 없는 점이 존재했었다.

아무튼, 이 글에서 말하고자 하는 것은 JPA 와 MyBatis 를 같이 이용하면서 동시 요청을 했을 때 과도한 Connection 할당으로 아래와 생기는 경우가 발생하면서 Deadlock 이 걸렸던 경험을 말하려고 한다.

Connection is not available, request timed out after 30000ms

 

이 문제는 OSIV 설정을 해제하면 간단하게 해결할 수 있지만 OSIV를 모른다는 상황이라고 가정한다.

 

결론부터 말하면, 

  • MyBatis - 처음 Query 를 실행할 때 HikariCP 에서 Connection 하나를 가져오고 Query 를 질의한 다음에 바로 Release
  • Hibernate - 처음 Query 를 실행할 때 HikariCP 에서 Connection 하나를 가져오고 Query 를 질의한 다음에 바로 Release 를 하지 않고 현재의 요청이 끝나고 나면 Release

위의 차이로 인해 만약 Connection Pool 을 Hibernate 가 다 사용하고 있는 상태에서 그 다음에 MyBatis 가 Query 를 실행시키기 위해 Connection 을 가져가려고 할 때 위 오류가 발생할 수 있다.

 

 

원인을 분석해보자

 

Hibernate 의 코드들을 추적하다가 JdbcCoordinatorImpl.java 파일에서 Connection 에 대한 release 코드들을 살펴보는데 Transaction 이나 Statement 에 대해 후처리에서 Connection Release 가 전혀 작동하지 않았다.

@Override
public void afterTransaction() {
    transactionTimeOutInstant = -1;
    if ( getConnectionReleaseMode() == ConnectionReleaseMode.AFTER_STATEMENT ||
            getConnectionReleaseMode() == ConnectionReleaseMode.AFTER_TRANSACTION ||
            getConnectionReleaseMode() == ConnectionReleaseMode.BEFORE_TRANSACTION_COMPLETION ) {
        this.logicalConnection.afterTransaction();
    }
}

왜 그런가 하고 찾다가 https://jira.spring.io/si/jira.issueviews:issue-html/SPR-14393/SPR-14393.html 여기에서 이유를 찾아보니 Hibernate 5.2 에서 PhysicalConnectionHandlingMode 라는 모델을 도입했고, HibernateJpaVendorAdapter 또는 LocalSessionFactoryBuilder 를 사용하면 ConnectionReleaseMode 가 기본적으로 DELAYED_ACQUISITION_AND_HOLD 상태로 되도록 강제되어 있다고 한다.

 

나중에 다시 알아보니 Hibernate 5.2 버전 이상에서는 DELAYED_ACQUISITION_AND_RELEASE_AFTER_STATEMENT 를 기본값으로 사용하는데, Spring Boot 2.x 버전 이상에서는 DELAYED_ACQUISITION_AND_HOLD 로 고정하여 사용하고 있었다.

 

그래서 PhysicalConnectionHandlingMode 에 대한 모델을 찾아보니 https://docs.jboss.org/hibernate/orm/5.2/javadocs/org/hibernate/resource/jdbc/spi/PhysicalConnectionHandlingMode.html 여기에서 4개의 상태를 찾을 수 있었다.

상태 설명
DELAYED_ACQUISITION_AND_HOLD 연결이 필요할 때 맺어지고, 세션이 끝날 때까지 붙잡고 있음
DELAYED_ACQUISITION_AND_RELEASE_AFTER_STATEMENT 연결이 필요할 때 맺어지고, 각 SQL 문이 실행된 후 Release 됨
DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION 연결이 필요할 때 맺어지고, 각 트랜잭션이 완료된 후에 Release 됨
IMMEDIATE_ACQUISITION_AND_HOLD 세션이 열렸을 때 바로 맺어지고, 세션이 끝날 때까지 붙잡고 있음

여기서 필요했던 것은 DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION 이 상태였고, Hibernate 설정을 이 상태로 맞추면 Query 를 실행하고자 할 때 필요한 경우에만 Connection 을 가져갈 수 있게 된다.


직접 테스트를 해봤다.

 

먼저, HikariCP 의 Connection Pool Release 정보를 알 수 있도록 application.yml (또는 application.properties) 에서 아래와 같은 logging 을 추가한다.

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

그 다음으로, HikariCP 의 Pool 을 1개로 설정한다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 1

로직에서 JPA 를 이용한 Query 를 먼저 실행하게 한 다음에, MyBatis 를 사용한 Query 를 실행하도록 작성한다.

@Service
public class TestServiceImpl implements TestService {
    private final MybatisRepo mybatisRepo;
    private final JpaRepo jpaRepo;

    public TestServiceImpl(MybatisRepo mybatisRepo,
                             JpaRepo jpaRepo) {
        this.mybatisRepo = mybatisRepo;
        this.jpaRepo = jpaRepo;
    }

    public void test() {
        // JPA
        jpaRepo.somethingRun();
        // MyBatis
        mybatisRepo.somethingRun();
    }
}

로직을 실행하면 아래 순서로 동작한다.

  1. 시작
    → 총 개수 1개, 사용하는거 0개, 남는거 1개, 기다리는거 0개
  2. JPA 로 Query 를 실행한다. 여기서 Connection 하나를 가져간다.
    → 총 개수 1개, 사용하는거 1개, 남는거 0개, 기다리는거 0개
  3. MyBatis 로 Query 를 실행한다. 여기서 Connection 하나를 가져가야 하는데 남아있는게 없어서 Queue 에 쌓이게 된다.
    → 총 개수 1개, 사용하는거 1개, 남는거 0개, 기다리는거 1개
  4. ...어찌됐든 MyBatis 로 Query 를 실행해야 하는데 JPA Hibernate 의 Connection Release 정책이 요청이 끝날 때라서 Connection 을 돌려주지 않는다. 이 상황에서 MyBatis 는 Connection 을 가져오려고 기다리고 있으므로 로직이 더 이상 진행되지 않는다.
    → 총 개수 1개, 사용하는거 1개, 남는거 0개, 기다리는거 1개 (계속...)

이렇게 하면 결국 Timeout 이 발생하고 정상적으로 동작되지 않는다.


DB Configuration 에서 HibernateJpaVendorAdapter 를 사용한다고 할 때, hibernate.connection.handling_modeDELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION 으로 설정되도록 속성을 추가해준다.

@Primary
@Bean(name = "mainDbEntityManagerFactory")
public EntityManagerFactory mainDbEntityManagerFactory() {
    Properties jpaProps = new Properties();
    // 트랜잭션 후 반납되도록 해야 connection 낭비를 줄일 수 있음
    // Ref. https://jira.spring.io/si/jira.issueviews:issue-html/SPR-14393/SPR-14393.html
    jpaProps.setProperty("hibernate.connection.handling_mode", "DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION");

    LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
    entityManagerFactoryBean.setDataSource(dataSource);
    entityManagerFactoryBean.setPersistenceUnitName("oracleEntity");
    entityManagerFactoryBean.setPackagesToScan("kr.pe.karsei.entity");
    entityManagerFactoryBean.setJpaVendorAdapter(mainDbJpaVendorAdapter());
    entityManagerFactoryBean.setJpaProperties(jpaProps);
    entityManagerFactoryBean.afterPropertiesSet();
    
    return entityManagerFactoryBean.getObject();
}

이렇게 하면 각 트랜잭션 또는 각 SQL 문이 끝날 때마다 Connection Release 를 하면서 원하던 동작을 확인할 수 있다.