Connection Pool 과 쿼리 개수의 관계
개요
해당 포스팅은 오디고(ODEEGO) 프로젝트에 관한 글입니다.
해당 프로젝트에서 저는 사용자의 출발역들을 통해 중간역을 찾는 API를 맡았습니다.
이번 포스팅에서는 해당 API의 부하 테스트를 진행하던 도중 발생한 에러와 이에 대한 생각 및 해결 과정을 정리할 예정입니다.
부하테스트
저는 제가 담당한 API의 호출 빈도를 초당 700명 정도로 가정했습니다.JMeter
를 통해 요청을 보내 부하테스트를 해보겠습니다.
Thread Group
초당 700명으로 가정하여 다음과 같이 구성했습니다.
요청 데이터
결과
에러율은 1.00%, 평균 약 45000ms로 확인되었습니다.
원인 분석
에러 로그
에러 로그를 살펴보면, Connection 이 부족하여 해당 문제가 발생했다는 것을 알 수 있습니다.
Caused by: org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection at org.hibernate.exception.internal.SQLExceptionTypeDelegate.convert(SQLExceptionTypeDelegate.java:48) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:37) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:113) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:99) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:111) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.getPhysicalConnection(LogicalConnectionManagedImpl.java:138) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.internal.SessionImpl.connection(SessionImpl.java:516) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.springframework.orm.jpa.vendor.HibernateJpaDialect.beginTransaction(HibernateJpaDialect.java:152) ~[spring-orm-5.3.25.jar:5.3.25] at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:421) ~[spring-orm-5.3.25.jar:5.3.25] ... 59 common frames omitted Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30006ms. at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696) ~[HikariCP-4.0.3.jar:na] at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197) ~[HikariCP-4.0.3.jar:na] at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:162) ~[HikariCP-4.0.3.jar:na] at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128) ~[HikariCP-4.0.3.jar:na] at org.hibernate.engine.jdbc.connections.internal.DatasourceConnectionProviderImpl.getConnection(DatasourceConnectionProviderImpl.java:122) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.internal.NonContextualJdbcConnectionAccess.obtainConnection(NonContextualJdbcConnectionAccess.java:38) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:108) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final] ... 63 common frames omitted
무엇이 문제일까?
문제는 바로 아래 코드에서 발견할 수 있었습니다.
아래 코드에 대해 간단히 설명드리면, 경로 상의 역들을 역 이름으로 조회하는 서비스 코드입니다.
public List<PathInfo> findAllByStarts(List<String> startNames) {
return startNames.stream()
.map(startName -> pathRepository.findByStartStation(startName).stream().findAny().get())
.toList();
}
그렇다면 위의 코드에서 어떤 부분이 문제가 되어 Connection이 부족하다는 에러가 나왔을까요?
위의 코드를 보면 startNames 안에 있는 이름 하나마다 select 쿼리가 발생하고 있습니다.
이에 따라 조회해야하는 역의 개수가 늘어갈 수록 쿼리의 수가 선형적으로 증가하게 됩니다.
따라서 다수의 사용자에 의해 요청이 발생하면, Connection이 반환되는 속도보다 Connection을 획득하려는 속도가 더 빠른 경우에 에러가 발생할 수 있습니다.
어떻게 해결할 수 있을까?
Connection 부족을 해결하면 해당 문제를 해결할 수 있습니다.
실제 실현 방법으로는 다음과 같은 방법이 있습니다.
- Connection 수 증가
- 쿼리 발생 감소
Connection 수 증가
먼저 Connection 수를 증가시키면 해당 문제를 해결할 수 있습니다.
spring:
datasource:
hikari:
maximum-pool-size: 100
일단 100개 정도로 했지만, HikariCP wiki를 살펴보면 다음과 같은 공식으로 사이즈를 구할 수 있습니다.pool size = Tn * (Cm - 1) + 1
(Tn: 스레드 개수, Cm: 각 스레드에서 필요한 Connection 개수)
위와 같이 에러가 발생하지 않거나 줄어들었습니다.
하지만, Connection 개수를 늘리는 것은 가장 나중에 시도하고 싶은 방법입니다.
그 이유로는 해당 서비스를 배포할 당시에 AWS 프리티어를 사용했기 때문에 메모리를 더 많이 사용하기가 힘든 상황이었습니다.
쿼리 발생 감소
해당 방법은 쿼리의 수 자체를 감소시키는 방법입니다.
기존 코드의 문제점으로 쿼리가 비교적 많이 발생한다는 것이 있었는데, 이를 압축하여 해결할 수 있습니다.
public List<PathInfo> findAllByStarts(List<String> startNames) {
return pathRepository.findAllByStartStationIn(startNames);
}
서비스 코드에서 위와 같이 호출하고, 아래와 같이 Repository를 작성하였습니다.
@Query("""
select new podo.odeego.domain.path.dto.PathInfo(p.startStation, p.endStation, p.requiredTime, p.stations) from Path p where p.startStation in :startNames """)
List<PathInfo> findAllByStartStationIn(
@Param("startNames") List<String> startName
);
해당 방법으로 기존에 역 하나 당 쿼리 하나가 발생하는 것에 비교하여 경로 하나에 쿼리 하나로 쿼리를 대폭 감소시킬 수 있었습니다. (경로가 1호선 소요산-아산 이라면 중간에 역이 30개 이상입니다.)
위와 같이 에러가 없어졌습니다.
마무리
평소에 생각하지 못했던 부분에서 에러가 터지는 것을 발견하고 이에 대해 앞으로 고려하여 코딩해야 겠다는 생각이 들었습니다.
해당 코드는 링크를 통해 확인할 수 있습니다.