HikariCP의 커넥션 관리

maro's avatar
Aug 31, 2024
HikariCP의 커넥션 관리
HikariCP의 maxLifetime 옵션에 대해 알아보고, 커넥션 누수와 HikariCP에서 커넥션 관리하는 방식에 대해 이해해보고자 합니다.
 
The minimum allowed value is 30000ms (30 seconds). Default: 1800000 (30 minutes)
HikariCP에서 maxLifetime의 기본값은 1800초(30분)이다.
 
SHOW GLOBAL VARIABLES LIKE 'wait_timeout'; // 600
사용 중인 데이터베이스에서 wait_timeout 값을 조회해보니 600초로 확인되었다.
여기서 maxLifetime는 HikariCP에서 유휴 커넥션을 유지하는 시간이고, wait_timeout는 데이터베이스에서 유휴 커넥션을 유지하는 값이다. 각각의 커넥션 유지 시간이 다른 데 이럴 땐 어떻게 동작할까?
 

커넥션 상태를 조회해보자.

SHOW PROCESSLIST;
현재 연결되어 있는 커넥션들을 확인할 수 있다.
notion image
  • 프라이빗 IP 10.0.6.193인 프로세스들을 보면 해당 애플리케이션의 HikariCP 사이즈는 10으로 설정되어 있기 때문에 커넥션이 10개가 뜨는 것을 볼 수 있다.
  • 위 DB의 wait_timeout은 600초로 설정되어있다.
  • Time 컬럼은 유휴 시간으로, DB 커넥션이 사용되지 않는다면 해당 커넥션의 Time 값은 갱신되지 않고 계속 올라갈 것이다.
notion image
  • 시간이 흘러 Time 값이 wait_timeout이 되었을 때 중간에 사용되었던 2개의 10.0.6.193 커넥션을 제외한 8개의 커넥션이 모두 사라진 것을 확인할 수 있다.
  • DB에서는 설정된 시간이 지나 연결을 닫았지만 HikariCP에서는 해당 연결 상태를 계속 체크하고 있는 것이 아니기 때문에 연결이 끊겼다는 사실을 알지 못한다.
notion image
  • 이후 HikariCP에서 끊어진 연결을 사용하려고 할 때, 또는 HikariCP의 maxLifeTime이 지나서 갱신하려고 할 때 해당 커넥션의 유효성을 체크한다. 그때 해당 커넥션이 끊겼다는 것을 확인하고 위와 같은 warn 레벨의 로그를 찍는다.
notion image
  • 중간에 살이있던 커넥션 2개 중 1개도 유휴 대기시간이 초과하여 끊겼기에 총 9개의 커넥션이 다시 되살아 났다.
 

커넥션 유효 체크 후, 커넥션 누수가 발생한다?

위 예시를 보아 커넥션이 사용 불가할 때는 HikariCP maximun pool size까지 커넥션이 새로 생성되는 것을 알 수 있다. 그런데 자료를 찾다보니 이 상황에서 커넥션이 누수가 발생한다는 글을 발견하여 확인해보았다.
우선 커넥션 누수는 더 이상 필요하지 않은 연결을 제대로 닫거나 해제하지 못하는 상황을 말한다. 여기서는 풀에서 커넥션을 가져갔다가 더 이상 사용하지 않음에도 제대로 반환하지 못하는 상황이다.
풀의 커넥션 상태를 로깅하여 확인해보자.
logging.level.com.zaxxer.hikari: trace
notion image
  • maximum-pool-size를 3으로 설정하여 실행하였다.
  • Pool stats (total=3, active=0, idle=3, waiting=0) 로 유휴 커넥션이 3개이다. 이 상태로 Database의 wait_timeout을 초과하여도 HikariCP에서는 확인하지 않기 때문에 여전히 유휴 커넥션 3개로 잡힌다.
  • 이후 커넥션 요청없이 max-lifetime에 다다르면 quietlyCloseConnection 메서드가 호출되면서 커넥션이 종료되고 새로운 커넥션을 맺는다.
notion image
  • 중간에 커넥션 요청이 들어왔을 때도 마찬가지이다.
  • 커넥션을 획득하려고 하나 커넥션이 죽어있기 때문에 HikariCP에 등록된 커넥션은 종료하고 새로운 커넥션을 연결한다.
  • 우선적으로 기존에 풀에 있는 커넥션들을 하나씩 꺼내서 확인해보기 때문에 결과적으로는 죽은 커넥션 전체를 새로 연결하게 되는 것이다.
 
HikariCP에서 버전에 따라 커넥션 유효성을 체크하는 메서드 이름이 다르다.
public Connection getConnection(final long hardTimeout) throws SQLException { suspendResumeLock.acquire(); final var startTime = currentTime(); try { var timeout = hardTimeout; do { var poolEntry = connectionBag.borrow(timeout, MILLISECONDS); if (poolEntry == null) { break; // We timed out... break and throw exception } final var now = currentTime(); if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > aliveBypassWindowMs && isConnectionDead(poolEntry.connection))) { closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE); timeout = hardTimeout - elapsedMillis(startTime); } else { metricsTracker.recordBorrowStats(poolEntry, startTime); return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry)); } } while (timeout > 0L); metricsTracker.recordBorrowTimeoutStats(startTime); throw createTimeoutException(startTime); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new SQLException(poolName + " - Interrupted during connection acquisition", e); } finally { suspendResumeLock.release(); } }
커넥션 요청이 들어오면 유효성 체크를 하는데 여기서 커넥션이 죽었다면 결과적으로 SQLTransientConnectionException을 발생시킨다. 이에 따라 커넥션 종료와 재생성 로직이 실행되도록 예외 처리가 되어있기에 커넥션 누수가 발생하지 않는 것이다.
 

정말 커넥션 누수가 발생한다면?

다음은 커넥션 누수가 발생했을 때 일어날 수 있는 일들이다. 이를 통해 커넥션 누수를 진단해볼 수 있다.
 
Connection is not available
커넥션을 무한정 생성하도록 하는 것은 리소스 관리 측면에서 좋지 못하다. 그래서 보통은 최대 커넥션 개수를 지정해놓고 사용한다. 이때 커넥션 누수가 발생한다면 스레드는 아무리 기다려도 커넥션을 획득하지 못할 것이다. 그래서 잘 구성된 커넥션 풀은 스레드가 계속 블록 상태가 되지 않도록 한다.
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
HikariCP에서는 스레드가 일정 시간 동안 커넥션을 얻지 못하고 대기 상태에 있다면 SQLException을 발생시킨다. 기본 설정은 30000ms이며, connectionTimeout 설정을 통해 변경할 수 있다.
 
Active 연결의 증가
활성 연결이 평소와 다르게 급격하게 늘어난다면 커넥션이 올바르게 반환되지 않는 상황일 수 있다.
/actuator/metrics/hikaricp.connections
스프링에서는 actuator를 통해 hikariCP의 커넥션 지표를 확인할 수 있다.
Pool stats (total=10, active=10, idle=0, waiting=4)
위에서 설정했던 HikariCP 로깅을 통해서도 확인할 수 있다. 활성 커넥션이 종료되지 않고, 대기 상태의 요청이 늘어난다면 커넥션 누수를 의심해볼 수 있다.
 
HikariCP 누수 감지
⏳leakDetectionThreshold
This property controls the amount of time that a connection can be out of the pool before a message is logged indicating a possible connection leak. A value of 0 means leak detection is disabled. Lowest acceptable value for enabling leak detection is 2000 (2 seconds).
Default: 0
지정된 시간 안에 커넥션이 풀로 돌아오지 않는다면 커넥션 누수 감지 로그를 볼 수 있다. 커넥션이 호출되었을 때 로깅 작업이 예약되고, 커넥션이 종료될 때는 예약했던 작업을 취소한다. 지정 시간 내에 커넥션이 반환되지 않는다면 로그를 찍는다.
java.lang.Exception: Apparent connection leak detected at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128) ~[HikariCP-5.0.1.jar:na] at org.springframework.jdbc.datasource.DataSourceUtils.fetchConnection(DataSourceUtils.java:160) ~[spring-jdbc-6.0.13.jar:6.0.13]
이때 stack trace를 같이 찍어주기 때문에 누수의 정확한 위치를 찾는데 매우 유용하다. 다만, 정말 의도에서 벗어난 커넥션 누수인지는 알 수 없고 단지 지정된 시간 초과 여부만 판단하기 때문에 애플리케이션 상황에 따라 시간을 잘 정해야 한다.
 

커넥션은 왜 갱신할까?

그럼 위와 같은 Connection 갱신 기능은 왜 있는 것일까? 사실 커넥션을 교체하는데에 비용이 든다는 것을 생각한다면 한 번 커넥션을 맺어놓고 계속 사용하는 것이 좋아보인다. 실제로 이전 tomcat-jdbc에서는 커넥션을 갱신하지 않고, 커넥션이 주기적으로 살아있는지 확인하여 커넥션이 죽었을 때만 재연결하도록 설정할 수 있었다.
하지만 HikariCP는 커넥션 활성화 확인 기능은 제공하지 않는다. HikariCP의 Issues 탭에는 이와 관련한 답변이 있다.
Having said that, it is generally not a great idea to keep individual database connections open for hours. Databases and drivers both track connections internally with sessions, and both have historically been sources of memory leaks. Allowing drivers and the database itself to release session-associated state periodically improves overall stability. The cost of replacing a retired connection, due to exceeding idle or lifetime limits, is typically measured in double-digit milliseconds. HikariCP Github Issue
요약하면, 연결을 몇 시간씩 열어두는 것은 메모리 누수의 원인이 될 수 있으며 연결 갱신에는 수십 밀리초밖에 걸리지 않기 때문에 성능상에도 거의 영향을 주지 않는다고 한다. 따라서 커넥션을 주기적으로 갱신하는 것이 더 안정적으로 커넥션을 관리하는 방법이라는 것이다.
 
Share article

maro