DB Connection Pool
매번 DB Connection을 맺는데는 비용이 들기 때문에 이를 효율적으로 관리하기 위해 Pool을 만들어놓고 미리 Connection을 맺어놓는다. 그리고 필요할때마다 꺼내서 쓰는데 이를 DB connection Pool(DBCP)이라고 한다.
Spring Boot에서는 2.0 버전부터 기본적으로 Connection pooling을 HikariCP로 제공한다. 그 전에는 Tomcat JDBC Connection Pool를 Default로 사용하였다.
(HikariCP가 성능이 월등히 좋다고 함. Git에 benchmark 돌렸다고 표도 있다)
HikariCP 깃헙 주소
https://github.com/brettwooldridge/HikariCP
HikariCP는 JDBC DataSource의 구현체이고 HikariCP에서 JDBC를 사용해서 데이터베이스와 연결하고 그 연결을 관리한다.
그렇다면 DataSource란?
- 물리적인 데이터베이스에 연결하기 위한 팩토리. 데이터소스는 커넥션을 얻기 위해 사용한다고 보면됨.
- 자바에서 데이터소스는 javax.sql.DataSource 인터페이스를 구현함.
- 데이터 소스를 통해 얻을 수 있는 것: 커넥션 객체, 커넥션 풀에서 사용가능한 커넥션, 분산 트랜잭션과 커넥션 풀에서 사용가능한 커넥션
아래와 같이 설정할 수 있다. 물리적 DB와 연결을 위한 기본 정보를 설정하게 된다.

그렇다면 JDBC란?
- 클라이언트가 관계형 데이터베이스에 접근하는 방법을 정의한 어플리케이션 프로그래밍 API
- 데이터베이스와 커넥션을 만들고, SQL 작업을 위한 인터페이스를 제공하고 결과를 처리함
- JDBC API는 데이터베이스와 연결하기 위해 JDBC 드라이버를 사용함
- JDBC API는 쿼리 실행 이전과 이후에 많은 코드를 작성(커넥션, statement, 연결 및 해제)해야 하는 단점이 있음
-> JdbcTemplate을 사용함으로써 간략하게 작성할 수 있게 됨
🎈JDBC로 했을 때
import java.sql.*;
public class Example {
public static void main(String[] args) {
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
// 1. JDBC driver 로드
Class.forName("com.mysql.cj.jdbc.Driver");
// 2. Connection 객체 생성
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/databaseName", "username", "password");
// 3. Statement 객체 생성
stmt = conn.createStatement();
// 4. Query 수행
rs = stmt.executeQuery("SELECT * FROM tableName");
// 5. ResultSet 객체로부터 데이터 저장
while(rs.next()) {
// Retrieve by column name
int id = rs.getInt("id");
String name = rs.getString("name");
// Display values
System.out.println("ID: " + id + ", Name: " + name);
}
} catch(SQLException se) {
se.printStackTrace();
} catch(Exception e) {
e.printStackTrace();
} finally {
// 6. 리소스 해제
try {
if(rs != null) rs.close();
if(stmt != null) stmt.close();
if(conn != null) conn.close();
} catch(SQLException se) {
se.printStackTrace();
}
}
}
}
🎈 JDBC Template으로 했을 때
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
public UserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<User> findAllUsers() {
String sql = "SELECT * FROM User";
return jdbcTemplate.query(sql, (resultSet, rowNum) -> new User(
resultSet.getLong("id"),
resultSet.getString("name"),
resultSet.getString("email")
));
}
}
-> 요즘은 더 개선된 Mybatis, JPA을 많이 쓴다.
HikariCP 설정값으로 Connection Pool 관리하기
Connection Pool을 관리할 수 있는 HikariCP 설정값에 대해 알아보자.
(출처: https://cheese10yun.github.io/mysql-connection-pool-timeout-1/)
설정 항목 | 설명 | Default값 |
maximum-pool-size | 커넥션 풀에서 유지할 수 있는 최대 커넥션 수입니다. 이 수치를 초과하는 요청은 대기 상태로 들어갑니다. | 10 |
minimum-idle | 풀에서 유지할 유휴 커넥션의 최소 개수입니다. 유휴 커넥션이 이 수치 이하로 떨어지면 새로운 커넥션이 생성됩니다. | maximum-pool-size 값과 동일 |
connection-timeout | 커넥션을 가져오기 위해 스레드가 대기할 수 있는 최대 시간입니다. 이 시간이 초과되면 예외가 발생합니다. | 30,000ms (30초) |
max-lifetime | 커넥션이 유지될 수 있는 최대 시간입니다. 이 시간이 지나면 커넥션은 폐기되고 새 커넥션으로 교체됩니다. | 1,800,000ms (30분) |
idle-timeout | 유휴 상태의 커넥션이 풀에서 유지될 수 있는 최대 시간입니다. 이 시간이 지나면 유휴 커넥션이 풀에서 제거됩니다. | 600,000ms (10분) |
leak-detection-threshold | 지정된 시간(밀리초) 동안 사용되지 않은 커넥션을 감지하는 데 사용됩니다. 이 시간이 지나면 커넥션 리크(leak)를 의심하고 경고를 남깁니다. | 0 (비활성화) |
pool-name | 커넥션 풀의 이름을 지정합니다. 기본적으로 HikariCP는 자동으로 이름을 생성하지만, 필요에 따라 지정할 수 있습니다. | 자동 생성된 이름 |
auto-commit | 새 커넥션이 자동 커밋 모드로 시작할지를 결정합니다. 각 쿼리 후 자동으로 커밋됩니다. | true |
validation-timeout | 커넥션이 유효한지 검증할 때 사용할 최대 시간입니다. 이 시간이 초과되면 커넥션은 유효하지 않다고 판단하고 폐기됩니다. | 5,000ms (5초) |
read-only | 커넥션이 읽기 전용 모드에서 작동할지를 결정합니다. | false |
isolate-internal-queries | 내부 쿼리(예: 커넥션 풀의 유지 관리 쿼리)가 애플리케이션의 쿼리와 격리되는지를 설정합니다. | false |
allow-pool-suspension | 커넥션 풀의 일시 정지 기능을 활성화합니다. 이 설정이 활성화되면 풀을 일시 정지하거나 다시 시작할 수 있습니다. | false |
initialization-fail-timeout | 풀을 시작할 때 초기화에 실패하는 경우를 대비한 타임아웃 시간입니다. 이 시간이 지나면 예외가 발생합니다. | 1초 (1,000ms) |
application.yml에 아래와 같이 작성해주게 된다.
spring:
datasource:
url: url...
username: root
password: pw
hikari:
maximum-pool-size:100 #최대 pool 크기
minimum-idle: 10 #최소 유휴 pool 크기
idle-timeout: 600000 #연결위한 최대 유휴 시간
max-lifetime: 1800000 #반납된 커넥션의 최대 수명
그렇다면 적절한 Connection Pool의 크기는 뭘까?
널리 사용하고 있는 Connection Pool 사이즈 공식을 확인해보자. 아래 공식에서 시작해서 각자 application 상황에 맞게 테스트를 통해 pool size를 최적화 시켜야 한다고 말하고 있다.
About Pool Sizing
光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP
github.com
1. Thread Pool 크기 < Connection Pool 크기
2. 적절한 connection 크기 = ((core_count) * 2) + effective_spindle_count)
> core_count: 현재 사용하는 서버 환경에서의 CPU 개수
> effective_spindle_count: DB 서버가 관리할 수 있는 동시 I/O 요청 수 (Disk 관련)
✅ 1번
Thread Pool은 Connection Pool과 비슷한 개념으로 Connection이 아닌 Thread를 미리 일정 개수만큼 만들어 놓고 사용하는 Pool 이다. 자바에서는 Thread를 운영체제의 자원으로 사용하기 때문에 우리가 Thread를 무한정으로 만들면 자원고갈로 서버가 다운될 위험이 있다. 따라서 Thread 개수를 관리하기 위한 것이 Thread Pool이다.
즉, Thread는 Connection을 사용하는 주체이기 때문에 Thread Pool 크기보다 Connection Pool 크기가 작으면 Thread가 Connection을 기다려야하는 상황이 있을 수 있다. 따라서 Thread Pool 크기 < Connection Pool 크기로 설정하는 것이 권장된다.
✅ 2번
Core_count: 전제 조건은 CPU 코어 1개는 한번에 하나의 Thread만 실행할 수 있다는 것이다. 만약 CPU Core 1개, Connection이 1개인 상황에서 Thread가 Connection 1개를 가져와서 대기상태에 있게 되면 Core가 다른 Thread를 실행하더라도 Connection이 이미 첫번째 Thread에 의해 점유중이기 때문에 Core는 놀게 된다. 그러나 운영체제의 기본 조건이 CPU는 놀게 하지 않는다! 이기 때문에 이는 비효율적인 상황으로 판단된다. 따라서 경험적으로 CPU Core 개수의 2배 정도가 적절한 Connection 크기라고 권장된다.
Effective_spindle_count: DB가 동시에 처리할 수 있는 요청 수(Disk) 인데, 너무 많은 Connection이 있다면 병목 현상이 발생하게 된다. 따라서 이정도 Connection이 있다면 병목 현상 없이 감당할 수 있는 적절한 Connection 수를 맞추기 위한 값이다.
<Connection Pool 관련 기술 아티클>
Connection Pool과 이를 관리하는 HikariCP의 설정값에 대해 알아봤다. 관련된 기술 아티클을 간단하게 정리해보자.
1. HikariCP의 설정을 최적화하여 TPS(Transactions Per Second) 변화에 유연하게 대응하기
(출처: https://cheese10yun.github.io/mysql-connection-pool-timeout-1/)
해당 글 저자는 TPS변화에 따른 커넥션 풀의 동작을 테스트 해보고 있다.
1. TPS가 낮은 경우: 즉, 트래픽이 적은 경우 HikariCP는 최소 커넥션(minimum-idle)만 유지하여 리소스 사용을 최적화
2. TPS가 높아질 경우: TPS가 증가하여 커넥션이 필요한 상황이 되면, HikariCP는 최대 커넥션(maximum-pool-size)까지 확장하여 대량의 요청을 처리할 수 있게 함.
3. TPS가 다시 낮아지는 경우: TPS가 다시 낮아지면 hikariCP는 idle-timeout에 따라 불필요한 커넥션을 풀에서 제거하고, minimum-idle만 유지하여 리소스를 절약
좀 더 자세한 모니터링 이미지로 알아보자.
TPS가 높아지는 상황이라면?
기본 설정은 다음과 같다.
spring:
datasource:
hikari:
minimum-idle: 10 // 최소 유휴 커넥션 수. TPS가 낮을 때 리소스 절약 가능
maximum-pool-size: 10 // 커넥션 풀의 최대 크기. TPS가 높아질 때 최대 10개까지 생성
idle-timeout: 30000 // 지정된 시간(밀리초) 동안 유휴 상태인 커넥션이 있을 경우 풀에서 제거
connection-timeout: 20000 // 커넥션을 얻기 위해 대기하는 최대 시간. 이 시간안에 커넥션을 확보하지 못하면 예외가 발생
- Total Requests per Second
- RPS (초록색 라인): 초당 요청 처리량
- Failures (빨간색 라인): 실패한 요청
- TPS가 증가하여 초당 12 요청 수준에 도달했을 때 실패한 요청이 발생. Connection Pool이 최대 용량인 10에 도달해서 더 이상 추가 요청을 처리하지 못하는 상황을 의미
- 이후 TPS는 유지되지만 실패한 요청이 지속적으로 발생하면서 Connectino Pool의 제한에 따른 성능 저하가 보임. 이후에 TPS는 응답 지연으로 인해 더 이상 올라가지 않음
- Response Times
- TPS가 증가함에 따라 응답시간이 증가하는 모습을 보임
- 커넥션 풀이 가득 차서 새로운 요청이 대기 상태로 전환되었기 때문이며, 트래픽 증가와 함께 시스템의 성능 한계에 도달했음을 보여줌
- Number of Users
- 사용자 수가 점진적으로 증가하며 약 300명 이상일 때부터 시스템은 커넥션 풀이 한계에 도달하며 그 이후로 성능 저하가 본격적으로 발생함
- Connection Pool 크기를 초과하는 사용자 요청은 실패하거나 긴 대기 시간을 초래하며, 이는 응답 시간 증가와 TPS 유지의 원인이 됨
어떻게 해결하면 좋을까?
- maximum-pool-size 증가: TPS 수요 충족시키기 위해서 maximum-pool-size값을 10 이상으로 설정해서 Connection Pool이 더 많은 요청을 처리할 수 있도록 한다.
- 모니터링 및 지속적인 튜닝
반대로 TPS 감소 상황에서는?
spring:
datasource:
hikari:
maximum-pool-size: 200 # 최대 커넥션 수
minimum-idle: 10 # 최소 유휴 커넥션 수
max-lifetime: 300000 # 커넥션이 유지될 최대 시간 (밀리초)
idle-timeout: 250000 # 유휴 커넥션이 유지될 최대 시간 (밀리초)
위와 같은 설정에서 TPS가 감소하고 있는 상황이라고 가정해보자.
- Total Requests per Second (RPS)
- TPS가 낮아지면서 초당 요청 처리량이 약 5 수준으로 안정화 됨
- 요청 실패가 없고 모든 요청이 성공적으로 처리됨
- 이는 트래픽이 줄면서 커넥션 풀이 유휴 상태로 돌아가고 있음을 의미
- Response Times
- 응답 시간 모두 약 1,000ms 내외로 일정하게 유지되고 잇음
- 변동이 크지 않고 안정적인 수준을 보여, TPS가 낮아진 상황에도 일관된 성능을 제공
- Number of Users
- 테스트에서 사용자 수가 10명으로 일정하게 유지되고 있으며, TPS가 낮은 상태로 안정화됨
이 테스트에서는 TPS가 줄면서 totalConnections도 함께 감소하며 리소스가 효율적으로 관리되고 있는 모습을 보여준다. minimum-idle 설정을 통해 connection pool이 최소 10개의 유휴 커넥션을 유지하고, idleTiemout이 설정된 시간 동안 유휴 상태인 커넥션을 자동으로 해제하여 불필요한 자원 낭비를 방지한다. 또한 maxLifetime 설정으로 각 커넥션의 유지 시간을 제한하여 일정 시간이 지나면 커넥션이 새롭게 갱신된도록 한다.
해당 아티클을 통해 HikariCP를 사용한 Connection Pool 설정이 트래픽 변화에 유연하게 대응할 수 있고, 성능 최적화를 위한 중요한 도구임을 알 수 있다.
2. HikariCP와 Dead Lock - 우아한 형제들 기술블로그
(출처: https://techblog.woowahan.com/2664/)
아래와 같은 오류를 발견한다고 해보자.
o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null
o.h.engine.jdbc.spi.SqlExceptionHelper : hikari-pool-1 – Connection is not available, request timed out after 30000ms.
org.hibernate.exception.JDBCConnectionException: unable to obtain isolated JDBC connection
Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
- HikariCP maximum pool size
- DB에 insert하고자 하는 전체 Thread Count
- 하나의 Task에서 동시에 필요한 Connection 수
-> 위 3개의 상관관계에 따라 발생한 이슈라고 한다. 왜인지 아래에서 간단하게 요약해본다.
장애의 단서
1. 약 30초 주기로 발생하는 에러 로그
SQL Error: 0, SQLState: null
HikariPool-1 - Connection is not available, request timed out after 30000ms.
2. HikariCP HouseKeeper가 찍어주는 pool stats 로그
HikariPool-1 - Timeout failure stats (total=10, active=10, idle=0, waiting=16)
3. Message Queue Consumer Thread 갯수 = 16개
4. HikariCP maximum pool size 갯수 = 10개 (default로 사용하고 있었습니다.)
5. 장애 시간 대 네트워크 이슈 없음
결론적으로 아래와 같은 문제가 발생했던 것
❌ Thread간 Connection을 차지 하기 위한 Race Condition 상태가 된 것
❌ 메시지 1개를 저장하는데 한 Transaction에서 동시에 Connection 2개를 사용하면서 hikariCP에 대한 Thread간 데드락이 발생
HikariCP에서 Connection을 어떻게 할당받고 반납하길래 이런 현상이 일어난 걸까?
큰 틀은 이런식으로 되어있을 것이다.
Connection connection = null;
PreparedStatement preparedStatement = null
try {
connection = hikariDataSource.getConnection();
preparedStatement = connection.preparedStatement(sql);
preparedStatement.executeQuery();
} catch(Throwable e) {
throw new RuntimeException(e);
} finally {
if (preparedStatement != null) {
preparedStatement.close();
}
if (connection != null) {
connection.close(); // 여기서 connection pool에 반납됩니다.
}
}
HikariPool에서의 getConnection() 로직
HikariPool 클래스 내부에 getConnection() 메서드를 살펴보자. hikariPool.getConnection()을 하면 concurrentBag.borrow() 라는 메서드로 사용가능한 Connection을 리턴하도록 되어있다. (HikariCP은 내부적으로 ConcurrentBag이라는 구조체를 이용해서 Connection을 관리하고 있다.)
borrow() 메서드를 타고 들어가보면 크게 4가지 파트로 나뉜다.
- Try the thread-local list first
현재 Thread가 이전에 사용한 Connection 정보가 있는지 확인한다. 이전에 사용한 Connection list 중에 현재 사용가능한(idle) 상태의 Connection이 있다면 해당 connection (bagEntry)를 리턴하고, 아니라면 2번으로 넘어간다. - Otherwise, scan the shared list
Hikari Pool 전체 Connection 중 현재 사용가능한(idle) 상태의 Connection이 있는지 확인한다. 있다면 해당 connection (bagEntry)를 리턴하고, 없다면 3번으로 넘어간다. - then poll the handoff queue
concurrentBag.handOffQueue에 사용가능한 Connection이 있는지 확인한다. 있다면 해당 connection (bagEntry)를 리턴하고, 없다면 3번을 반복한다. - do while문
Timeout 시간이 지났다면 null을 리턴한다.
이를 Flow chart로 작성하면 다음과 같다.
HikariCP에서의 Connection.close()의 로직
정상적으로 transaction이 commit되거나 에러로 인해 rollback이 호출되면 connection.close()가 호출되어 connection이 반납된다. connection.close() -> concurrentBag.requite() 가 실행된다.
requite() 메서드를 타고 들어가보면 다음과 같은 로직이 있다.
- bagEntry.setState(STATE_NOT_IN_USE);
connection을 idle connection으로 상태를 바꾼다. - for (... waiters.get()...)
handOffQueue에서 connection을 받길 원하는 다른 thread가 있는지 확인한다. 있다면 handOffQueue에 Connection을 삽입한다. 이후에 3번으로 넘어간다. - threadLocalEntries
사용한 connection 정보를 threadLocalList에 등록하여 다음에 또 connection을 요청하는 경우 borrow에서 활용할 수 있도록 한다.
이를 Flow chart로 작성하면 다음과 같다.
그래서 Dead Lock이 왜 발생했는데?
MQ의 consumer thread 갯수보다 hikariCP의 maximum pool size를 충분하게 설정하지 못해서 Dead Lock이 발생했다. 아래와 같이 조건이 있다고 가정해보자.
- Thread Count: 1개
- HikariCP Maximum pool size: 1개
- 하나의 Task에서 동시에 요구되는 Connection 갯수: 2개!!
- Thread가 Repository.save(entity) 라는 insert query를 실행하기 위해 Transaction을 시작합니다.
- Root Transaction용 Connection을 하나 가져옵니다. (PoolStats : total=1, active=1, idle=0, waiting=0)
- Transaction을 시작하였고 Repository.save를 하기 위해 Connection이 하나 더 필요하다고 가정해보겠습니다.
- Thread-1은 Hikari Pool에 Connection을 요청합니다.
- 위의 3단계 절차대로, 현재 자기 Thread의 방문내역을 살펴봅니다.
아직 방문내역이 등록된 게 없습니다. - 전체 Hikari Pool에서 idle상태의 Connection을 스캔합니다.
Pool Size는 1개이고 1개 있던 Connection은 Thread-1에서 이미 사용중입니다. - 마지막으로 handOffQueue에서 누군가 반납한 Connection이 있길 기대하며 30초 동안 기다립니다.
하나 있던 Connection을 자기 자신이 사용하고 있기 때문에 자기 자신이 반납하지 않는 이상 사용할 Connection이 없습니다.
(PoolStats : total=1, active=1, idle=0, waiting=1) - 결국 30초가 지나고 Connection Timeout이 발생하고
hikari-pool-1 - Connection is not available, request timed out after 30000ms. 에러 발생
- 위의 3단계 절차대로, 현재 자기 Thread의 방문내역을 살펴봅니다.
- SQLTransientConnectionException으로 인해 Sub Transaction이 Rollback 됩니다.
- Sub Transaction의 Rollback으로 인해 Root Transaction이 rollbackOnly = true가 되며 Root Transaction이 롤백 됩니다. JpaTransactionManager를 사용한다고 가정하겠습니다.
(우아한형제들 기술블로그 – 응? 이게 왜 롤백되는거지? 참고) - Rollback 됨과 동시에 Root Transaction용 Connection은 다시 Pool에 반납됩니다.
(PoolStats : total=1, active=0, idle=1, waiting=0)
정리하자면, Connection Pool에 connection이 총 1개 있었는데 Thread-1이 한개를 가져다가 쓰고 있는 상태에서 또 다른 Connection을 또 요청하는 경우 결국 자기 자기자신이 사용중인 connection을 반납하지 않는 이상 사용할 connection이 없기 때문에 Connection Timeout이 발생하고 hikari-pool-1 - Connection is not available, request timed out after 30000ms. 가 발생하는 것이다.
이를 해결하기 위해 우리는 Max Pool Size를 다음과 같이 조정하면 Dead Lock을 피할 수 있다고 한다.
- Tn : 전체 Thread 갯수
- Cm : 하나의 Task에서 동시에 필요한 Connection 수
이 방식은 최적의 pool 사이즈는 아니지만 DeadLock을 피하기 위한 최소한의 Pool Size라고 한다. 말그대로 최소한의 Pool Size이기 때문에 우아한 형제들 기술블로그 작성자는 위의 공식을 확장하여 사용하는 방향으로 장애에 대한 후속처리를 진행했다고 한다.
Reference
https://escapefromcoding.tistory.com/713
https://techblog.woowahan.com/2664/
https://cheese10yun.github.io/mysql-connection-pool-timeout-1/
'TIL' 카테고리의 다른 글
헥사고날 아키텍처 Hexagonal Architecture 란? (0) | 2025.01.17 |
---|---|
웹 어플리케이션에서의 동시성 제어 (feat. Lock) (5) | 2024.11.28 |
MSA 환경에서 서비스간 데이터 정합성 맞추기 (SAGA Pattern, Outbox Pattern) (0) | 2024.10.07 |
Kafka Consumer의 commit을 제어하는 enable.auto.commit과 AckMode (0) | 2024.08.20 |
Servlet부터 Filter와 Interceptor까지 (0) | 2024.07.24 |