본문 바로가기

DataSource·커넥션 풀

HikariCP 연결 풀의 크기·획득 시간·상태 초기화를 연결 고갈 실험으로 확인하고 DB 처리 한도에 맞춥니다.

데이터베이스 연결에는 TCP 연결과 인증, 세션 초기화의 비용이 있어서 쿼리마다 새로 만들면 비쌉니다. 연결 풀은 미리 만들어 둔 제한된 수의 연결을 애플리케이션의 작업이 빌려 쓰게 합니다. 처리량을 무조건 늘려 주는 캐시가 아니라, DB가 동시에 감당할 수 있는 작업 수를 애플리케이션에 나눠 주는 배압(backpressure) 장치입니다.


DataSource와 구성 나누기

저장소가 DriverManager와 URL, 인증 정보를 직접 알면 환경과 풀 정책이 코드에 박힙니다. DataSource를 주입받으면 테스트와 운영 환경이 같은 JDBC 코드를 쓰면서 연결을 만드는 정책만 바꿀 수 있습니다. Spring Boot는 클래스 경로와 속성을 보고 HikariCP DataSource를 구성합니다.

src/main/java/board/jdbc/PostCountQuery.java
package board.jdbc;

import java.sql.SQLException;
import java.time.LocalDate;

import javax.sql.DataSource;

public final class PostCountQuery {
    private static final String SQL = """
            select count(*)
              from posts
             where member_id = ? and published_on = ?
            """;

    private final DataSource dataSource;

    public PostCountQuery(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public int count(long memberId, LocalDate publishedOn)
            throws SQLException {
        try (var connection = dataSource.getConnection();
             var statement = connection.prepareStatement(SQL)) {
            statement.setLong(1, memberId);
            statement.setObject(2, publishedOn);
            try (var result = statement.executeQuery()) {
                if (!result.next()) {
                    throw new SQLException("count row missing");
                }
                return result.getInt(1);
            }
        }
    }
}

풀의 Connection은 대개 논리적인 프록시이며, close()는 물리 소켓을 닫는 대신 지금의 대여를 끝내고 연결을 풀에 돌려줍니다. 그래서 풀을 써도 try-with-resources를 그대로 씁니다. 풀이 알아서 회수해 준다고 생각해 닫지 않으면, 활성 연결이 최대치에 이릅니다.

src/test/java/board/jdbc/PostCountQueryTest.java
package board.jdbc;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDate;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;

class PostCountQueryTest {
    @Test
    void query가_정확한_count를_읽고_connection을_반환한다()
            throws Exception {
        var config = new HikariConfig();
        config.setJdbcUrl(
                "jdbc:h2:mem:post_count;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setMaximumPoolSize(1);
        config.setMinimumIdle(0);

        try (var dataSource = new HikariDataSource(config)) {
            new ResourceDatabasePopulator(
                    new ClassPathResource("schema.sql")).execute(dataSource);
            try (var connection = dataSource.getConnection();
                 var statement = connection.createStatement()) {
                statement.execute("""
                        insert into members(
                          id, email, password_hash, name,
                          active, daily_character_limit)
                        values (
                          41, '[email protected]', 'hash', '회원41',
                          true, 10000),
                          (42, '[email protected]', 'hash', '회원42',
                          true, 10000)
                        """);
                statement.execute("""
                        insert into posts(
                          member_id, title, content, published_on,
                          client_request_id, created_at, version)
                        values
                          (41, 'A', '본문 A', date '2026-08-29',
                           'count_req_A1', timestamp with time zone
                           '2026-08-29 00:00:00+00', 0),
                          (41, 'B', '본문 B', date '2026-08-29',
                           'count_req_B2', timestamp with time zone
                           '2026-08-29 00:00:01+00', 0),
                          (42, 'C', '본문 C', date '2026-08-29',
                           'count_req_C3', timestamp with time zone
                           '2026-08-29 00:00:02+00', 0)
                        """);
            }

            var query = new PostCountQuery(dataSource);

            assertThat(query.count(
                    41L, LocalDate.of(2026, 8, 29))).isEqualTo(2);
            assertThat(query.count(
                    41L, LocalDate.of(2026, 8, 30))).isZero();
            assertThat(dataSource.getHikariPoolMXBean()
                    .getActiveConnections()).isZero();
        }
    }
}

기대값 2는 회원 41이 2026년 8월 29일에 쓴 A와 B이며, 회원 42의 C와 다른 날짜의 조회는 세지 않습니다. 풀의 최대 크기를 1로 정했으므로, 첫 count 호출이 연결을 돌려주지 않았다면 두 번째 호출은 연결을 얻지 못하고 기다리다 실패합니다. 두 호출이 모두 성공하고 마지막 단언에서 활성 연결이 0이라면, try-with-resources가 매번 연결을 풀에 돌려줬다는 뜻입니다.


연결 풀의 크기

요청 스레드가 200개라고 연결도 200개를 만들지는 않습니다. DB의 CPU와 디스크 지연, 다른 애플리케이션의 연결, 쿼리의 종류와 목표 지연 시간을 함께 봅니다. 너무 큰 풀은 DB의 컨텍스트 전환과 잠금 경합을 늘리고, 장애가 났을 때 더 많은 쿼리를 한꺼번에 몰아넣습니다.

이 장의 application.properties 하나는 앞에서 구성한 H2의 PostgreSQL 호환 모드와 다음 Hikari 정책을 함께 씁니다.

속성예제 값정하는 기준
maximum-pool-size12DB가 이 애플리케이션에 나눠 준 동시 연결의 예산
minimum-idle2트래픽이 적을 때 유지할 준비된 연결 수
connection-timeout2000ms요청이 연결을 기다릴 최대 시간
validation-timeout1000ms연결 검사의 상한, 획득 시간보다 짧게
max-lifetime1740000msDB나 네트워크가 강제로 끊기 전에 교체
keepalive-time120000ms수명보다 짧은 유휴 연결의 확인 주기

예제의 숫자를 그대로 복사하지 말고, 인프라의 유휴 타임아웃과 DB의 연결 예산을 확인합니다. HikariCP의 설정 사이의 관계를 어기면 시작할 때 경고가 나오므로 무시하지 않으며, 바꿀 때는 활성·유휴·대기 메트릭을 보고 조정합니다.

더 깊이: 아무것도 설정하지 않으면 풀은 어떻게 동작할까

Spring Boot는 spring.datasource.hikari.* 속성을 HikariCP의 설정에 그대로 연결하므로, 속성을 비워 두면 HikariCP 자체의 기본값이 적용됩니다. HikariCP의 README에 따르면 maximumPoolSize는 10, 연결을 기다리는 connectionTimeout은 30초, maxLifetime은 30분, keepaliveTime은 2분이고, 누수 탐지(leakDetectionThreshold)는 0이어서 꺼져 있습니다. 획득 대기가 30초라는 것은, 풀이 바닥난 순간부터 웹 요청이 실패하기까지 요청 스레드 하나를 30초 동안 붙잡을 수 있다는 뜻입니다. 이 글의 예제가 connection-timeout을 2초로 줄인 이유가 여기에 있습니다.

minimumIdle의 기본값은 maximumPoolSize와 같습니다. 따로 정하지 않으면 HikariCP는 최대 크기만큼의 연결을 계속 유지하는 고정 크기 풀로 동작하며, README도 이 값을 정하지 말고 고정 크기 풀로 쓰기를 권합니다. 예제처럼 minimum-idle을 최대보다 작게 두면 그때부터 idleTimeout(기본 10분)이 의미를 가져, 남는 유휴 연결이 정리되고 부하가 오면 다시 만들어집니다. 연결을 새로 여는 지연을 없애는 쪽과 DB에 쥐고 있는 연결 수를 줄이는 쪽 가운데 어느 것이 중요한지에 따라 고릅니다.


연결 대기 타임아웃

풀의 연결이 모두 쓰이고 있으면 다음 스레드는 연결이 돌아오기를 기다립니다. 기다리는 시간이 너무 길면 웹 스레드도 함께 쌓이므로, 제한된 타임아웃으로 빨리 실패시키고 상위의 재시도와 대기열을 통제합니다.

연결 풀은 제한된 연결 예산으로 요청에 백프레셔를 건다

연결 1개 점유·다음 획득 timeout·held close·재획득 성공의 실제 풀 상태를 보여 준다.

연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

풀의 연결 1개를 점유하면 다음 획득은 제한 시간 뒤 실패한다

실제 고갈 fixture는 maximumPoolSize=1, connectionTimeout=250ms입니다. 점유한 연결을 반환한 뒤 다시 빌릴 수 있습니다. 이 획득 timeout은 이미 실행 중인 SQL을 취소하는 설정이 아닙니다.

풀의 연결 1개를 점유하면 다음 획득은 제한 시간 뒤 실패한다연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.풀 max 1held 점유다음 획득 대기timeout 250ms일시적 획득 실패held.close()다시 획득isValid(1) = true
점유 중의 실패와 반환 뒤 재획득을 구분한다원 desktop 두 흐름 유지: 풀max1 held점유→다음획득대기 timeout250ms→일시적획득실패. held.close→다시획득→isValid1true. 그림의 두 흐름 사이를 단일 진행 화살표로 합치지 않으며 원 fixture는 하나를점유한 뒤 실패하고 반환 후복구하는순서다.풀 max 1held 점유다음 획득 대기timeout 250ms일시적 획득 실패held.close()다시 획득isValid(1) = true

POOL / BACKPRESSURE

동시에 빌리는 연결 예산을 제한

repository는 DataSource를 주입받아 연결 생성 정책과 분리됩니다. 풀의 close는 대여를 끝내고 재사용 가능한 물리 연결을 돌려줍니다. FIFO 순서나 요청 스레드 수만큼의 연결을 보장하지 않습니다.

ACTUAL EXHAUSTION FIXTURE

max 1 · active 1 · timeout 250ms

제공된 test는 SQLTransientConnectionException과 200ms 이상·2,000ms 미만 경과, 실패 뒤 awaiting=0을 확인합니다. 점유 연결을 finally에서 닫은 뒤 다시 획득해 isValid(1)=true, 종료 후 active=0을 확인합니다.

ACTUAL QUERY FIXTURE

회원·날짜를 바인딩하고 연결 반환

공유 schema에 회원41의 해당 날짜2행과 회원42의1행을 넣습니다. 회원41의 count=2, 다음 날짜 count=0, query 뒤 active connection=0을 확인합니다.

ACTUAL RESET FIXTURE

같은 H2 session의 autoCommit=true

max1/minIdle1 풀에서 첫 session의 autoCommit을 false로 바꾸고 명시적으로 rollback·close합니다. 다시 빌린 session ID가 같고 autoCommit=true인지 확인합니다. 모든 vendor 세션 변수·임시 테이블 초기화의 증거는 아닙니다.

APPLICATION CONFIG / DIFFERENT VALUES

운영 설정 예제와 고갈 fixture를 구분

본문 application.properties의 예제는 max12/minIdle2, 획득2,000ms/검증1,000ms입니다. 실제 고갈 실험의 max1/250ms와 다릅니다. DB 예산·메트릭·점유 시간을 보고 환경에 맞게 결정합니다.

COMPLETION / ADDITIONAL

반환 전에 commit 또는 rollback을 소유

활성 transaction을 Connection.close에 넘겼을 때 JDBC 결과는 구현 정의입니다. 누수 로그가 연결을 강제로 안전하게 반환하지 않습니다. 외부 I/O 분리와 outbox worker는 추가 설계이며 이 reset test에 포함되지 않습니다.

연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.
  • 원래 가로 도표의 두 흐름을 유지한다. 풀 max 1 held 점유→다음 획득 대기 timeout 250 ms→일시적 획득 실패. held.close→다시 획득→isValid 1 true. 그림의 두 흐름 사이를 단일 진행 화살표로 합치지 않으며 원래 fixture는 하나를 점유한 뒤 실패하고 반환 후 복구하는 순서다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1연결 풀은 제한된 연결 예산으로 요청에 백프레셔를 건다
15원래 가로 도표의 두 흐름을 유지한다. 풀 max 1 held 점유→다음 획득 대기 timeout 250 ms→일시적 획득 실패. held.close→다시 획득→isValid 1 true. 그림의 두 흐름 사이를 단일 진행 화살표로 합치지 않으며 원래 fixture는 하나를 점유한 뒤 실패하고 반환 후 복구하는 순서다.
21제공된 test는 SQLTransientConnectionException과 200 ms 이상·2,000 ms 미만 경과, 실패 뒤 awaiting=0을 확인합니다. 점유 연결을 finally에서 닫은 뒤 다시 획득해 isValid(1)=true, 종료 후 active=0을 확인합니다.
24공유 schema에 회원 41의 해당 날짜 2행과 회원 42의 1행을 넣습니다. 회원 41의 count=2, 다음 날짜 count=0, query 뒤 active connection=0을 확인합니다.
27max 1/minIdle 1 풀에서 첫 session의 autoCommit을 false로 바꾸고 명시적으로 rollback·close합니다. 다시 빌린 session ID가 같고 autoCommit=true인지 확인합니다. 모든 vendor 세션 변수·임시 테이블 초기화의 증거는 아닙니다.
30본문 application.properties의 예제는 max 12/minIdle 2, 획득 2,000 ms/검증 1,000 ms입니다. 실제 고갈 실험의 max 1/250 ms와 다릅니다. DB 예산·메트릭·점유 시간을 보고 환경에 맞게 결정합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
연결 하나를 보유

capacity 1인 pool의 연결을 held가 유지합니다.

capacity: 1 · held: 연결 보유 · 빈 연결: 0유일한 연결이 사용 중 상태가 됩니다.
추가 획득 timeout

연결을 반환하지 않은 동안 다음 getConnection이 실패합니다.

추가 획득: SQLTransientConnectionException · held: 계속 보유대기한 획득이 실패로 끝납니다.
반환 뒤 재획득

held.close 뒤 다시 getConnection을 호출하면 유효한 연결을 얻습니다.

held: close · 재획득: 유효 연결 · pool: 반환 후 사용 가능연결 반환이 새 획득을 가능하게 합니다.

연결 풀은 제한된 연결 예산으로 요청에 백프레셔를 건다

연결 1개 점유·다음 획득 timeout·held close·재획득 성공의 실제 풀 상태를 보여 준다.

연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

풀의 연결 1개를 점유하면 다음 획득은 제한 시간 뒤 실패한다

실제 고갈 fixture는 maximumPoolSize=1, connectionTimeout=250ms입니다. 점유한 연결을 반환한 뒤 다시 빌릴 수 있습니다. 이 획득 timeout은 이미 실행 중인 SQL을 취소하는 설정이 아닙니다.

풀의 연결 1개를 점유하면 다음 획득은 제한 시간 뒤 실패한다연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.풀 max 1held 점유다음 획득 대기timeout 250ms일시적 획득 실패held.close()다시 획득isValid(1) = true
점유 중의 실패와 반환 뒤 재획득을 구분한다원 desktop 두 흐름 유지: 풀max1 held점유→다음획득대기 timeout250ms→일시적획득실패. held.close→다시획득→isValid1true. 그림의 두 흐름 사이를 단일 진행 화살표로 합치지 않으며 원 fixture는 하나를점유한 뒤 실패하고 반환 후복구하는순서다.풀 max 1held 점유다음 획득 대기timeout 250ms일시적 획득 실패held.close()다시 획득isValid(1) = true

POOL / BACKPRESSURE

동시에 빌리는 연결 예산을 제한

repository는 DataSource를 주입받아 연결 생성 정책과 분리됩니다. 풀의 close는 대여를 끝내고 재사용 가능한 물리 연결을 돌려줍니다. FIFO 순서나 요청 스레드 수만큼의 연결을 보장하지 않습니다.

ACTUAL EXHAUSTION FIXTURE

max 1 · active 1 · timeout 250ms

제공된 test는 SQLTransientConnectionException과 200ms 이상·2,000ms 미만 경과, 실패 뒤 awaiting=0을 확인합니다. 점유 연결을 finally에서 닫은 뒤 다시 획득해 isValid(1)=true, 종료 후 active=0을 확인합니다.

ACTUAL QUERY FIXTURE

회원·날짜를 바인딩하고 연결 반환

공유 schema에 회원41의 해당 날짜2행과 회원42의1행을 넣습니다. 회원41의 count=2, 다음 날짜 count=0, query 뒤 active connection=0을 확인합니다.

ACTUAL RESET FIXTURE

같은 H2 session의 autoCommit=true

max1/minIdle1 풀에서 첫 session의 autoCommit을 false로 바꾸고 명시적으로 rollback·close합니다. 다시 빌린 session ID가 같고 autoCommit=true인지 확인합니다. 모든 vendor 세션 변수·임시 테이블 초기화의 증거는 아닙니다.

APPLICATION CONFIG / DIFFERENT VALUES

운영 설정 예제와 고갈 fixture를 구분

본문 application.properties의 예제는 max12/minIdle2, 획득2,000ms/검증1,000ms입니다. 실제 고갈 실험의 max1/250ms와 다릅니다. DB 예산·메트릭·점유 시간을 보고 환경에 맞게 결정합니다.

COMPLETION / ADDITIONAL

반환 전에 commit 또는 rollback을 소유

활성 transaction을 Connection.close에 넘겼을 때 JDBC 결과는 구현 정의입니다. 누수 로그가 연결을 강제로 안전하게 반환하지 않습니다. 외부 I/O 분리와 outbox worker는 추가 설계이며 이 reset test에 포함되지 않습니다.

연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 연결 하나의 풀에서 held connection을 유지하면 다음 getConnection이 SQLTransientConnectionException으로 실패한다. held를 close한 뒤 다시 획득하면 유효한 연결을 얻는다. 별도 reset fixture는 같은 H2 session의 autoCommit 복원을 확인한다.
  • 원래 가로 도표의 두 흐름을 유지한다. 풀 max 1 held 점유→다음 획득 대기 timeout 250 ms→일시적 획득 실패. held.close→다시 획득→isValid 1 true. 그림의 두 흐름 사이를 단일 진행 화살표로 합치지 않으며 원래 fixture는 하나를 점유한 뒤 실패하고 반환 후 복구하는 순서다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1연결 풀은 제한된 연결 예산으로 요청에 백프레셔를 건다
15원래 가로 도표의 두 흐름을 유지한다. 풀 max 1 held 점유→다음 획득 대기 timeout 250 ms→일시적 획득 실패. held.close→다시 획득→isValid 1 true. 그림의 두 흐름 사이를 단일 진행 화살표로 합치지 않으며 원래 fixture는 하나를 점유한 뒤 실패하고 반환 후 복구하는 순서다.
21제공된 test는 SQLTransientConnectionException과 200 ms 이상·2,000 ms 미만 경과, 실패 뒤 awaiting=0을 확인합니다. 점유 연결을 finally에서 닫은 뒤 다시 획득해 isValid(1)=true, 종료 후 active=0을 확인합니다.
24공유 schema에 회원 41의 해당 날짜 2행과 회원 42의 1행을 넣습니다. 회원 41의 count=2, 다음 날짜 count=0, query 뒤 active connection=0을 확인합니다.
27max 1/minIdle 1 풀에서 첫 session의 autoCommit을 false로 바꾸고 명시적으로 rollback·close합니다. 다시 빌린 session ID가 같고 autoCommit=true인지 확인합니다. 모든 vendor 세션 변수·임시 테이블 초기화의 증거는 아닙니다.
30본문 application.properties의 예제는 max 12/minIdle 2, 획득 2,000 ms/검증 1,000 ms입니다. 실제 고갈 실험의 max 1/250 ms와 다릅니다. DB 예산·메트릭·점유 시간을 보고 환경에 맞게 결정합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
연결 하나를 보유

capacity 1인 pool의 연결을 held가 유지합니다.

capacity: 1 · held: 연결 보유 · 빈 연결: 0유일한 연결이 사용 중 상태가 됩니다.
추가 획득 timeout

연결을 반환하지 않은 동안 다음 getConnection이 실패합니다.

추가 획득: SQLTransientConnectionException · held: 계속 보유대기한 획득이 실패로 끝납니다.
반환 뒤 재획득

held.close 뒤 다시 getConnection을 호출하면 유효한 연결을 얻습니다.

held: close · 재획득: 유효 연결 · pool: 반환 후 사용 가능연결 반환이 새 획득을 가능하게 합니다.

그림의 위쪽은 최대 1개인 풀에서 held가 연결을 쥐고 있는 동안, 다음 획득이 250ms를 기다리다 일시적인 획득 실패로 끝나는 흐름입니다. 아래쪽은 held.close()로 연결을 돌려준 뒤 다시 빌리면 isValid(1)이 참인 연결을 얻는 흐름입니다. 두 흐름은 하나의 진행 화살표로 이어진 것이 아니며, 예제는 하나를 쥐고 실패한 뒤 돌려주고 회복하는 순서로 실행됩니다. 이 획득 타임아웃은 이미 실행 중인 SQL을 취소하는 설정이 아닙니다.

src/test/java/board/jdbc/PoolExhaustionTest.java
package board.jdbc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.sql.Connection;
import java.sql.SQLTransientConnectionException;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;

class PoolExhaustionTest {
    @Test
    void 최대_연결이_사용_중이면_timeout으로_실패하고_반환_뒤_회복한다()
            throws Exception {
        var config = new HikariConfig();
        config.setJdbcUrl(
                "jdbc:h2:mem:pool_exhaustion;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setMaximumPoolSize(1);
        config.setMinimumIdle(0);
        config.setConnectionTimeout(250);

        try (var dataSource = new HikariDataSource(config)) {
            Connection held = dataSource.getConnection();
            try {
                assertThat(dataSource.getHikariPoolMXBean()
                        .getActiveConnections()).isEqualTo(1);
                long started = System.nanoTime();

                assertThrows(
                        SQLTransientConnectionException.class,
                        dataSource::getConnection);

                long elapsedMillis =
                        (System.nanoTime() - started) / 1_000_000;
                assertThat(elapsedMillis)
                        .isGreaterThanOrEqualTo(200)
                        .isLessThan(2_000);
                assertThat(dataSource.getHikariPoolMXBean()
                        .getThreadsAwaitingConnection()).isZero();
            } finally {
                held.close();
            }

            try (var recovered = dataSource.getConnection()) {
                assertThat(recovered.isValid(1)).isTrue();
            }
            assertThat(dataSource.getHikariPoolMXBean()
                    .getActiveConnections()).isZero();
        }
    }
}

벽시계로 시간을 검증하는 것은 CI의 부하에 민감하므로 상한을 좁게 두지 않습니다. 핵심은 무한히 기다리지 않고 일시적인 획득 실패가 나며, 쥐고 있던 연결을 돌려준 뒤에는 다시 얻을 수 있다는 점입니다.


누수 탐지와 메트릭

Hikari의 누수 탐지 임계값보다 오래 연결을 쥐고 있으면, 연결을 얻은 곳의 스택을 로그에 남길 수 있습니다. 긴 정상 트랜잭션도 누수처럼 보일 수 있으므로, 업무의 목표 시간보다 높게 둡니다.

누수 로그가 연결을 강제로 안전하게 돌려주지는 않습니다. 활성 연결이 최댓값에 닿고 대기가 늘어난다면 느린 SQL과 잠금 대기, 트랜잭션 안의 외부 호출을 먼저 확인합니다.

신호가능한 원인다음에 확인할 것
활성이 최댓값긴 쿼리나 트랜잭션느린 SQL과 잠금 대기
대기 증가용량 초과요청 비율과 획득 타임아웃
유휴가 최댓값지나치게 큰 풀DB의 연결 예산
검증 실패네트워크나 DB 재시작수명과 연결 유지 정책

풀만 키우면 DB의 부담이 커질 수 있습니다. JDBC 4 드라이버라면 별도의 검증 쿼리보다 Connection.isValid()를 먼저 씁니다.


연결 상태 초기화

연결을 풀에 돌려줄 때 자동 커밋과 읽기 전용, 격리 수준, 카탈로그 같은 상태가 다음 사용자에게 새어 나가지 않아야 합니다. Hikari의 프록시는 바뀐 JDBC 상태를 풀의 기본값으로 되돌립니다. 하지만 공급자의 세션 변수나 임시 테이블까지 되돌린다고 일반화하지는 않으며, 트랜잭션을 연 쪽이 닫기 전에 커밋이나 롤백을 명시해야 합니다.

src/test/java/board/jdbc/PoolStateResetTest.java
package board.jdbc;

import static org.assertj.core.api.Assertions.assertThat;

import java.sql.Connection;
import java.sql.SQLException;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;

class PoolStateResetTest {
    @Test
    void 같은_물리_session을_다시_빌려도_autoCommit은_복원된다()
            throws Exception {
        var config = new HikariConfig();
        config.setJdbcUrl(
                "jdbc:h2:mem:pool_reset;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setMaximumPoolSize(1);
        config.setMinimumIdle(1);

        try (var dataSource = new HikariDataSource(config)) {
            long firstSession;
            try (Connection first = dataSource.getConnection()) {
                firstSession = sessionId(first);
                first.setAutoCommit(false);
                first.rollback();
            }

            try (Connection second = dataSource.getConnection()) {
                assertThat(sessionId(second)).isEqualTo(firstSession);
                assertThat(second.getAutoCommit()).isTrue();
            }
        }
    }

    private long sessionId(Connection connection) throws SQLException {
        try (var statement = connection.createStatement();
             var result = statement.executeQuery("select session_id()")) {
            result.next();
            return result.getLong(1);
        }
    }
}

진행 중인 트랜잭션이 있는 채로 Connection.close()를 부르면 어떻게 되는지는 JDBC 인터페이스 수준에서 구현에 맡겨져 있습니다. Hikari의 정리 동작은 안전망으로 이해하되, 애플리케이션의 정상적인 제어 흐름으로 쓰지 않습니다.

더 깊이: 풀 연결의 close() 안에서 일어나는 일

HikariCP 7.0.2의 ProxyConnection.close()는 연결을 풀에 돌려주기 전에 세 가지를 합니다. 먼저 이 연결로 만들고 닫지 않은 Statement를 모두 닫습니다. 다음으로 자동 커밋이 꺼져 있고 마지막 커밋이나 롤백 뒤에 실행된 구문이 있으면, 물리 연결에 rollback()을 호출합니다. 마지막으로 대여 중에 바뀐 읽기 전용, 자동 커밋, 격리 수준, 카탈로그, 스키마, 네트워크 타임아웃 가운데 풀의 기본값과 다른 것을 되돌립니다.

그래서 HikariCP에서는 커밋을 빠뜨린 채 연결을 닫으면, 그 변경이 다음 사용자에게 넘어가지 않는 대신 조용히 롤백됩니다. 테스트에서 저장이 사라졌는데 예외가 없다면 이 경로를 의심할 수 있습니다. 반대로 되돌리는 목록에 없는 상태, 예를 들어 SQL로 바꾼 세션 변수나 임시 테이블은 그대로 남아 다음에 같은 물리 연결을 빌린 작업이 보게 됩니다. 세션 수준의 설정이 꼭 필요하다면 연결을 빌린 쪽이 쓰고 나서 직접 원래대로 돌려놓아야 합니다.


정리

  • 연결 풀은 미리 만든 제한된 연결을 빌려 주는 배압 장치이며, 저장소는 DataSource를 주입받아 연결 정책과 나뉩니다.
  • 풀의 close()는 연결을 풀에 돌려주는 일이므로, 풀을 써도 try-with-resources로 반드시 닫습니다.
  • 풀의 크기는 요청 스레드 수가 아니라 DB가 감당할 수 있는 동시 연결의 예산으로 정하고, 메트릭을 보며 조정합니다.
  • 연결 획득 타임아웃으로 빨리 실패시키며, 이것은 이미 실행 중인 SQL을 취소하는 설정이 아닙니다.
  • Hikari는 JDBC 상태를 되돌려 주지만 세션 변수까지 보장하지 않으므로, 트랜잭션은 닫기 전에 커밋이나 롤백으로 끝냅니다.

연습 문제

1. 연결을 쥔 채 외부 호출을 기다리면

풀의 최댓값이 2일 때, 두 트랜잭션이 외부 HTTP 호출을 기다리며 연결을 쥐고 있고 세 번째 요청이 타임아웃되는 상황을 재현하려고 합니다. 외부 호출을 트랜잭션 밖으로 옮긴 설계와 비교해 활성 연결과 대기, 지연 시간이 어떻게 달라지는지 설명하세요.

해설 보기

DB를 바꾸기 전에 외부 데이터를 먼저 읽을 수 있는지, 아니면 커밋할 DB 상태와 전송할 의도를 함께 저장한 뒤 워커로 보낼 수 있는지 나눠 봅니다.

src/main/java/board/application/PostCreatedEvent.java
package board.application;

import java.time.Instant;
import java.util.Objects;

public record PostCreatedEvent(
        long postId,
        long memberId,
        Instant occurredAt,
        String clientRequestId
) {
    public PostCreatedEvent {
        if (postId <= 0) {
            throw new IllegalArgumentException("postId must be positive");
        }
        if (memberId <= 0) {
            throw new IllegalArgumentException("memberId must be positive");
        }
        Objects.requireNonNull(occurredAt, "occurredAt");
        if (clientRequestId == null
                || !clientRequestId.matches("[A-Za-z0-9_-]{8,64}")) {
            throw new IllegalArgumentException("invalid clientRequestId");
        }
    }
}

게시글 행과, PostCreatedEvent를 나타내는 아웃박스 행을 같은 DB 트랜잭션에 저장하고, 워커가 커밋된 이벤트를 전송합니다. 외부 호출을 트랜잭션 밖으로 옮기면 연결을 쥐는 시간이 SQL을 실행하는 동안으로 줄어들어, 활성 연결이 최댓값에 닿는 일과 대기가 줄고 세 번째 요청도 연결을 얻을 수 있습니다. 원자성이 필요하다고 연결을 쥔 채 느린 네트워크를 기다리는 것은 분산 트랜잭션을 제공하지 않습니다.

2. 실행 전에 연결을 쥐는 시간 계산하기

풀에는 연결이 2개뿐이고, A와 B는 0ms에 하나씩 빌려 1,000ms까지 돌려주지 않습니다. C는 100ms에 연결을 요청하며 대기 한도는 250ms입니다. 새 연결의 생성이나 다른 반환, 재시도는 없다고 가정합니다. C가 실패하는 시각과 그때의 활성 연결 수, 대기 요청 수를 구하세요. A가 200ms에 돌려준다면 C가 연결을 얻을 수 있는지도 판단하세요.

해설 보기

첫 조건에서 C의 대기 마감은 100 + 250 = 350ms입니다. 그 전에 돌아오는 연결이 없으므로 C는 연결을 얻지 못합니다. 실패 직전에는 활성 연결 2개와 대기 1개이고, C가 실패해 대기에서 빠진 뒤에는 활성 2개, 대기 0개입니다. A가 200ms에 돌려주는 경우에는 C가 기다린 100ms가 한도보다 짧으므로 그 연결을 얻을 수 있습니다. A, B, C만 있으므로 다른 대기 요청의 우선순위는 생각하지 않습니다.

실제 HikariCP 테스트의 경과 시간은 스케줄링과 실행 환경의 영향을 받습니다. 위의 시각은 연결을 쥐는 조건과 대기 조건을 계산한 모델일 뿐, 정확히 350ms가 측정된다는 뜻은 아닙니다.

다음 글 트랜잭션과 ACID에서는 풀의 연결 하나 안에서 커밋과 롤백이 만드는 원자성과, READ_COMMITTED에서 다른 세션이 무엇을 보는지를 두 개의 DB 연결로 확인합니다. 장애 뒤의 지속성은 그 범위에 넣지 않습니다.