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개, 사용하는거 0개, 남는거 1개, 기다리는거 0개 - JPA 로 Query 를 실행한다. 여기서 Connection 하나를 가져간다.
→ 총 개수 1개, 사용하는거 1개, 남는거 0개, 기다리는거 0개 - MyBatis 로 Query 를 실행한다. 여기서 Connection 하나를 가져가야 하는데 남아있는게 없어서 Queue 에 쌓이게 된다.
→ 총 개수 1개, 사용하는거 1개, 남는거 0개, 기다리는거 1개 - ...어찌됐든 MyBatis 로 Query 를 실행해야 하는데 JPA Hibernate 의 Connection Release 정책이 요청이 끝날 때라서 Connection 을 돌려주지 않는다. 이 상황에서 MyBatis 는 Connection 을 가져오려고 기다리고 있으므로 로직이 더 이상 진행되지 않는다.
→ 총 개수 1개, 사용하는거 1개, 남는거 0개, 기다리는거 1개 (계속...)
이렇게 하면 결국 Timeout 이 발생하고 정상적으로 동작되지 않는다.
DB Configuration 에서 HibernateJpaVendorAdapter 를 사용한다고 할 때, hibernate.connection.handling_mode 를 DELAYED_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 를 하면서 원하던 동작을 확인할 수 있다.
'프로그래밍 > Spring' 카테고리의 다른 글
[Spring] 라이브러리 등록 시 라이브러리를 외부에서 소스를 볼 수 있도록 하기 (0) | 2022.10.13 |
---|---|
[JPA] H2 DDL 초기 테스트 데이터 설정 (0) | 2022.10.01 |
[Spring] Kafka 이용 시 __TypeId__ 에 대하여 (0) | 2022.08.02 |
[Spring] @ModelAttribute 파라미터에서 사용 방법 및 원리 (0) | 2022.07.30 |
[Spring] JPA 에서 Oracle DB 사용할 때 DB 함수 사용하기 (0) | 2022.04.04 |