배경
업무를 하며 ForkJoinPool을 이용해 병렬 처리를 하는 작업 중에 DB IO가 발생하는 작업이 있어 커넥션 풀 사이즈 관리가 필요했다. 성능 최적화와 함께 안정적인 설계를 고민하며 Hikari Connection Pool의 기본 작동 원리부터 다시 살펴보았다. 본 글은 이를 정리한 글이다. HikariCp의 기초가 되는 내용인 컴포넌트와 프로세스에 대해서만 다루고, Thread와 Pool Size, 기타 테스트 들에 대해서는 다음 글에서 추가로 확인할 수 있다.
HikariCp의 기본 작동 원리를 알아보자
왜 ?
그 전에 왜라는 질문을 던져보고 싶다. 왜 Connection Pool이 필요할까?
사실 개발자 입장에서 커넥션 풀을 사용한다는 것은 복잡한 관리 포인트가 하나 더 늘어난다는 것이다. 생각해보면 커넥션 풀을 사용하면서 발생할 수 있는 문제들이 사용을 안할 때보다 많다. 다양한 설정을 조정해야 하며, 잘못 설정하면 성능 저하, 리소스 낭비, 심지어는 서비스 중단 같은 문제가 발생할 수 있다. 예를 들어, 풀의 크기를 너무 크게 설정하면 불필요한 리소스가 낭비되고, 너무 작게 설정하면 사용자 요청을 충분히 처리하지 못해 대기 시간이 길어질 수 있다.
개발자가 실수하기 쉬운 부분 중 하나는 커넥션을 제대로 반환하지 않는 것이다. 데이터베이스 작업을 마친 후 커넥션을 풀로 반환하는 것을 잊으면, 그 커넥션은 "누출"되어 사실상 사용할 수 없게 된다. 이러한 커넥션 누출이 반복되면, 결국 사용 가능한 커넥션이 없어져 자칫 하면 전체 시스템 장애로 이어질 수도 있다. (DB가 죽으면 내 서비스만 죽는 게 아니라 다 죽는 거니까).
또한, 커넥션 풀은 여러 사용자의 요청을 동시에 처리할 때 성능 이슈가 발생할 수 있는 "동시성 문제"에도 취약하다. 많은 요청이 동시에 처리될 때, 커넥션 풀이 이를 효과적으로 관리하지 못하면 시스템 전체의 성능이 저하될 수 있다.
그럼에도 불구하고 커넥션 풀이 필요한 이유는 무엇일까? 데이터베이스와의 연결을 매번 새로 시작하는 것보다 훨씬 효율적이기 때문이다. 거기에는 네트워크 IO 비용도 포함이다. TCP 비용은 영 비싼 커넥션이기 때문이다. 커넥션 풀을 사용하면, 이미 맺어진 연결을 재사용함으로써 데이터베이스 연결 시간을 크게 줄일 수 있는 것이 사실이다. 캐시랑 비슷한 관점에서, 도서관에서 책을 매번 서고에서 찾아오는 대신, 책상에 미리 준비해두고 필요할 때 바로 사용하는 것과 비슷한 것이다.
이처럼, 커넥션 풀은 연결의 재사용을 통해 애플리케이션의 반응 시간을 단축시키고, 데이터베이스 서버에 대한 부하를 줄이며, 전반적인 시스템 성능을 향상시킬 수 있게 해준다. 이 장점 하나의 가치가 여러가지 단점과 리스크를 감수할 가치를 갖기 때문에 사용된다.
Hikari CP의 핵심 컴포넌트
핵심 컴포넌트를 알아보자. HikariDataSource
, HikariConfig
, HikariPool
, 그리고 ProxyConnection
등이 있다.
📌 HikariDataSource
HikariCP의 데이터 소스 구현체로, 커넥션 풀과의 인터페이스 역할을 한다. 애플리케이션에서 데이터베이스 연결을 요청할 때 HikariDataSource
를 통해 이루어지며, 구성 설정(HikariConfig
)을 기반으로 커넥션 풀(HikariPool
)에서 연결을 관리한다.
📌 HikariConfig
HikariCP 설정을 관리하는 클래스. 최대 풀 사이즈, 커넥션 타임아웃, 유휴 연결 검사 주기 등 커넥션 풀의 동작 방식을 결정하는 다양한 설정 항목들을 다룬다. 이 설정들은 HikariDataSource
를 생성할 때 적용되어, 커넥션 풀의 성능과 효율성을 최적화하는 데 사용된다.
📌 HikariPool
실제로 커넥션을 관리하는 컴포넌트로, HikariConfig
에 정의된 설정에 따라 데이터베이스 커넥션을 생성, 관리, 폐기한다. 커넥션 요청이 들어오면 가용 커넥션을 제공하고, 사용이 끝난 커넥션을 재사용하기 위해 관리한다.
📌 ProxyConnection
HikariCP에서 제공하는 커넥션은 실제 데이터베이스 커넥션의 프록시로 동작한다. ProxyConnection
은 애플리케이션 코드가 데이터베이스와 상호작용할 때 중간에서 관리 역할을 하며, 커넥션의 올바른 반환을 보장하여 커넥션 풀의 효율성을 유지힌다.
📌 PoolBase
HikariPool
의 기반 클래스로, 데이터베이스 커넥션의 생성 및 폐기, 유효성 검사 등의 기본적인 작업을 수행한다. PoolBase
는 HikariPool
이 커넥션 관리 작업을 효율적으로 수행할 수 있도록 지원한다.
📌 ConcurrentConnectionBag
커넥션을 관리하는 핵심 자료 구조로, 멀티스레드 환경에서도 고성능을 유지할 수 있도록 설계되었다. ConcurrentConnectionBag
은 사용 가능한 커넥션의 풀을 관리하며, 커넥션의 대여 및 반환 시 동시성 문제를 효과적으로 해결하는 객체로, Hikari의 설계 원칙이 잘 드러나는 핵심 컴포넌트 중 하나라고 생각한다.
실제 구현 코드를 살펴보기에 앞서 먼저 전체 프로세스를 이해해보자.
주요 프로세스
프로세스는 일반 DB 사용에서의 요청과 크게 다르지 않다. 커넥션에 대한 요청이 들어오고 그에 대해 처리하는 과정이다. CP는 요청에 대해서 이를 생성하고, 사용하고, 반환하거나 관리하는 과정이 있을 뿐이다.
- 커넥션 요청: 애플리케이션에서
HikariDataSource.getConnection()
을 호출한다.Cp
는 먼저 유효한 커넥션을 검색한다. 사용 가능한 커넥션이 없다면, 새로운 커넥션을 생성하려고 시도한다. - 커넥션 생성: 새 커넥션을 생성한다. 생성 과정에서 데이터베이스와의 연결 설정과 유효성 검사가 수행되며, 검사 결과에 따라서 생성을 거절할 수도 있다.
- 커넥션 사용: 할당받은 연결을 사용하여 데이터베이스와 통신하는 과정이다.
- 커넥션 반환 및 관리: 사용이 끝난 커넥션은 반환된다. 주기적으로 커넥션의 상태를 검사하여, 유효 기간이 지난 커넥션을 폐기하고 필요에 따라 새로운 커넥션을 생성하여 풀의 상태를 유지한다.
주요 컴포넌트들이 포함된 시퀀스 다이어그램으로 살펴보면 다음과 같다.
주요 소스
주요 컴포넌트들의 구현 코드를 살펴보자. 먼저 ConcurrentConnectionBag이다.
ConcurrentConnectionBag
HikariCp에서 제일 중요한 객체 중 하나를 꼽으라면 바로 이 ConcurrentConnectionBag이 아닐까 싶다. 알고리즘의 내부 원리를 잘 드러낸다는 점에서 그렇다.
이 객체는 일종의 가방처럼 커넥션들을 담고 있는데, 여러 스레드(작업을 처리하는 단위)가 동시에 커넥션을 요청하거나 반납할 때 발생할 수 있는 문제들을 매우 효율적으로 처리할 수 있도록 해준다.
ConcurrentConnectionBag의 가장 큰 장점은 '동시성'과 '성능'이다. 여러 스레드가 동시에 접근해도, 커넥션을 안전하게 빌리고 반납할 수 있으며, 이 과정에서 발생할 수 있는 성능 저하를 최소화한다. 즉, 웹 애플리케이션이 많은 사용자의 요청을 동시에 빠르게 처리할 수 있도록 도와준다.
HikariCP의 ConcurrentConnectionBag은 내부적으로 CopyOnWriteArrayList
와 SynchronousQueue
를 사용하여 커넥션들을 관리한다. CopyOnWriteArrayList
는 커넥션들의 목록을 안전하게 관리하며, SynchronousQueue
는 커넥션의 대기열을 관리하는 데 사용된다.
private final CopyOnWriteArrayList<T> sharedList;
private final SynchronousQueue<T> handoffQueue;
sharedList
는 모든 커넥션을 저장하는 공유 리스트이다. CopyOnWriteArrayList
를 사용함으로써 동시성과 스레드 안전성을 확보한다. handoffQueue
는 커넥션이 부족할 때 스레드가 커넥션을 대기하며 사용하는 대기열이다. SynchronousQueue
는 생산자와 소비자 사이의 핸드오프를 위해 설계되었으며, 이 경우에는 사용 가능한 커넥션을 대기 중인 스레드에 전달하는 용도로 사용된다.
- 커넥션 요청과 반환: 애플리케이션 스레드가 데이터베이스 커넥션을 요청할 때,
ConcurrentBag
은 먼저 스레드 로컬 캐시에서 사용 가능한 커넥션을 검색한다. 이 캐시에 사용 가능한 커넥션이 없는 경우, 공유 리스트에서 사용하지 않는 커넥션을 검색하여 할당한다. 사용이 완료된 커넥션은 다시ConcurrentBag
에 반환되어 다음 사용을 위해 대기한다. - 동시성 제어:
ConcurrentBag
은CopyOnWriteArrayList
와SynchronousQueue
를 사용하여 커넥션의 안전한 추가, 삭제 및 할당을 관리한다.CopyOnWriteArrayList
는 읽기 작업이 많을 때 유리한 스레드-세이프한 리스트 구현체이며,SynchronousQueue
는 대기 중인 커넥션 요청과 사용 가능한 커넥션 간의 동기화를 제공한다.
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
이 메서드는 애플리케이션에서 커넥션을 빌릴 때 호출된다. 먼저 스레드-로컬 캐시에서 사용 가능한 커넥션을 찾는다. 만약 없다면, 공유 리스트(CopyOnWriteArrayList
)에서 검색하고, 여전히 사용 가능한 커넥션이 없을 경우 SynchronousQueue
를 통해 새 커넥션의 생성을 대기하거나 기존 커넥션이 반납되기를 기다린다.
구현을 살펴 보자.
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
// 먼저, 스레드-로컬 캐시에서 사용 가능한 커넥션을 검색.
final List<Object> list = threadList.get();
for (int i = list.size() - 1; i >= 0; i--) {
final Object entry = list.remove(i);
...
if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry; // 사용 가능한 커넥션을 찾으면 바로 반환.
}
}
...
}
예를 들어
```java
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
여기서 ConcurrentBagEntry
의 상태가 STATE_NOT_IN_USE
에서 STATE_IN_USE
로 안전하게 변경되는지 확인한다. compareAndSet 연산을 사용해서 여러 스레드가 동시에 같은 연산을 수행할 때, 오직 하나의 스레드만 상태를 변경할 수 있도록 보장한다.
comparAndSet을 타고 들어가보면... 다음과 같이
원자적 연산을 보장하는 코드로 구현되어 있는 것을 볼 수 있다.
설명에 따르면 이 메서드의 원자적 연산의 보장은 'volatile' 읽기 및 쓰기의 메모리 의미를 가지며, C11의 atomic_compare_exchange_strong
에 해당하는 동작을 함으로써 이루어진다. 원자적 연산의 특성상 하드웨어 수준에서의 제어와 함께 구현된다고 볼 수 있겠다.
사용된 커넥션은 어떻게 되는 것일까? 다음과 같은 requite 메서드를 반환된다. 반환은 획득의 반대 과정으로 진행된다.
public void requite(final T bagEntry)
커넥션 사용이 끝난 후, 이 메서드를 통해 커넥션을 풀에 반환한다. 반환된 커넥션은 다시 스레드-로컬 캐시 또는 공유 리스트에 추가되어, 다른 스레드가 즉시 사용할 수 있게 된다. 이 과정에서 waiters
(대기 중인 스레드의 수를 추적하는 AtomicInteger
)와 handoffQueue
(커넥션 대기열을 관리하는 SynchronousQueue
)의 상호작용이 중요한 역할을 한다.
PoolBase
또 한가지 살펴볼 객체는 PoolBase이다.
PoolBase
는 HikariCP에서 커넥션 풀을 초기화하고, 커넥션의 생명 주기를 관리하는 핵심 클래스이다. 이 클래스는 데이터베이스 커넥션을 생성, 검증, 종료하는 메서드를 제공한다. 특히, PoolBase
내에서는 커넥션을 생성할 때 설정된 타임아웃과 최대 풀 사이즈 같은 중요한 파라미터들을 검증하여 커넥션 풀의 건강 상태와 성능을 유지하는 데 기여한다.
PoolBase
의 주요 역할
- 커넥션 생성: 새로운 커넥션이 필요할 때,
PoolBase
는 데이터베이스와의 연결을 설정하고, 필요한 초기화 작업을 수행하여 커넥션을 생성한다. 효율성이 필요한 작업이다. - 커넥션 유효성 검사: 커넥션이 풀에 반환되기 전이나 빌려가기 전에,
PoolBase
는 해당 커넥션의 유효성을 검사한다. 이는validationTimeout
설정을 통해 구성되며, 유효하지 않은 커넥션은 폐기되고 새 커넥션이 생성된다. - 커넥션 초기화: 커넥션이 풀에 추가될 때,
PoolBase
는 커넥션에 대한 추가 설정을 적용할 수 있다. 예를 들어, 특정 SQL 명령을 실행하여 커넥션을 초기화하거나, 세션 상태를 구성하는 등의 작업이 여기에 해당한다.
PoolBase
의 구현에서는 DataSource
객체로부터 커넥션을 얻는 과정, 그리고 커넥션의 유효성을 검사하기 위한 isConnectionAlive
메서드 등이 중요한 역할을 한다. 이러한 메서드들은 HikariCP가 커넥션 풀의 성능과 안정성을 유지하는 데 핵심적인 기능을 제공한다.
protected final boolean isConnectionAlive(final Connection connection)
{
try {
return !connection.isClosed() && connection.isValid(validationTimeout);
} catch (SQLException e) {
// 예외 처리 로직
return false;
}
}
이 메서드는 주어진 Connection
객체가 여전히 유효한지 확인한다. 이 과정에서 connection.isClosed()
와 connection.isValid(validationTimeout)
메서드를 사용하여, 커넥션이 닫혀 있지 않고, 지정된 시간 내에 유효성 검사를 통과할 수 있는지 확인한다.
기타 메커니즘 및 소스
ProxyConnection과 커넥션 종료
HikariCP의 커넥션 종료는 독특하게 이루어지는 부분이 있다. 단순히 커넥션을 닫는 것이 아닌, 커넥션을 재사용하기 위한 준비 과정이다. 커넥션을 빌려 사용할 때 실제 JDBC Connection
객체 대신 ProxyConnection
객체를 통해 이루어진다.
ProxyConnection
은 실제 Connection
객체를 내부적으로 감싸고 있는 프록시 디자인 패턴을 적용한 구조로, 애플리케이션에서 close()
메서드 호출 시 실제 연결 종료 대신 HikariCP의 커넥션 풀로의 반환을 유도한다. 이러한 설계는 커넥션의 재사용을 촉진하여 연결의 생성 및 종료 과정에서 발생하는 오버헤드를 최소화하고, 연결 성능을 향상시키는 주요 메커니즘 중 하나이다.
@Override
public void close() throws SQLException {
// ProxyConnection의 close 메서드 구현 예시
if (this.isClosed.compareAndSet(false, true)) {
// 실제 커넥션을 풀로 반환하는 로직
pool.releaseConnection(this.realConnection);
}
}
close() 호출시 다음과 같은 과정으로 종료된다.
- 커넥션 상태 확인:
ProxyConnection
은 커넥션의 상태를 확인하여, 이미 닫힌 커넥션이 아닌지 검증한다. - 커넥션 풀로 반환: 커넥션이 아직 유효하다면,
handoffQueue
를 통해 커넥션 풀로 안전하게 반환한다. 이 과정에서 대기 중인 요청이 있으면, 반환된 커넥션을 즉시 그 요청에 할당한다. - 재사용 준비: 커넥션 풀에 반환된 커넥션은 다음 사용을 위해 준비 상태로 전환된다. 필요에 따라 일정 시간 후에 유효성 검사를 거쳐, 유효하지 않은 커넥션은 폐기하고 새로운 커넥션을 생성할 수 있다.
HandoffQueue의 역할
handoffQueue
는 커넥션의 반환과 새로운 요청 사이의 동기화를 담당하는 중요한 역할을 한다. 커넥션 사용이 끝난 후 close()
메서드가 호출되면, ProxyConnection
은 내부적으로 HikariPool
의 releaseConnection()
메서드를 통해 실제 커넥션을 풀로 반환한다. 이때, 대기 중인 커넥션 요청이 있다면 handoffQueue
를 통해 즉시 해당 스레드에게 커넥션을 전달할 수 있다. 커넥션의 공정한 할당과 빠른 응답 시간을 보장에 있어 중요하다.
public void releaseConnection(Connection connection) {
// 커넥션을 풀로 반환하고, 대기 중인 요청이 있다면 handoffQueue를 통해 전달하는 로직
if (!handoffQueue.offer(connection)) {
// 대기 중인 요청이 없을 경우, 커넥션을 풀에 추가
addConnection(connection);
}
}
커넥션 상태 모니터링
커넥션 풀의 성능과 안정성을 보장하기 위해서는 커넥션의 상태를 주기적으로 모니터링하고, 유효하지 않거나 사용하지 않는 커넥션을 풀에서 제거하는 로직이 필요하다. HikariCP는 이를 위해 HouseKeeper
라는 스케쥴된 태스크를 사용하여, 설정된 간격(housekeepingPeriodMs
)마다 커넥션 풀의 상태를 점검한다.
scheduledExecutor.scheduleWithFixedDelay(new HouseKeeper(), housekeepingPeriodMs, housekeepingPeriodMs, TimeUnit.MILLISECONDS);
HouseKeeper
태스크는 생각 보다 로깅 외에 다음과 같은 주요 작업들도 수행한다. 유효성 검증, 유휴 시간 검사, 유휴 커넥션 유지 등의 커넥션 관리 로직에도 관여한다.
스프링부트 로깅에서 자주 보이는 로깅인가? 맞다. 그 로깅의 원천이 바로 여기이다.
레퍼런스
- https://github.com/brettwooldridge/HikariCP
- https://medium.com/@rajchandak1993/understanding-hikaricps-connection-pooling-behaviour-467c5a1a1506
/files/3774129004148365063
- https://medium.com/@guptadiksha88/hikari-cp-efficient-database-connection-pooling-d458c0bdf7df
'Programming > Java, Spring' 카테고리의 다른 글
Java 병렬 처리 ForkJoinPool 기본 작동 원리 (0) | 2024.04.11 |
---|---|
간단한 코드 한 줄로 NPE (Null Pointer Exception) 방지하는 방법 (0) | 2024.02.18 |
스프링부트 junit 테스트 `No tests found for given includes:` 메시지 (0) | 2023.12.17 |
Webflux는 얼마나 빠를까? Spring Mvc Vs. Webflux 성능 비교 테스트 (0) | 2023.12.14 |
spring 단위 테스트에서 autowired와 mockbean의 차이 (feat. spybean) (0) | 2023.12.13 |