본문으로 건너뛰기

안동민 개발노트

본문 시작

DataSource·커넥션 풀

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

데이터베이스 연결은 TCP 연결, 인증, 세션 초기화 비용이 있어 쿼리마다 새로 만들기 비쌉니다.

연결 풀은 미리 만든 제한된 연결을 애플리케이션 작업이 빌려 쓰게 합니다. 처리량을 무조건 늘리는 캐시가 아니라 DB가 동시에 감당할 작업 수를 애플리케이션에 배분하는 백프레셔 경계입니다.


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, 'member41@example.com', 'hash', '회원41',
                          true, 10000),
                          (42, 'member42@example.com', '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();
        }
    }
}

연결 풀 크기

요청 스레드가 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 설정 관계를 위반하면 시작 경고가 나므로 무시하지 않습니다.

변화는 활성·유휴·대기 메트릭을 보고 조정합니다.


연결 대기 타임아웃

풀이 모두 사용 중이면 다음 스레드는 연결이 반환되기를 기다립니다.

획득 시간이 너무 길면 웹 스레드가 함께 쌓입니다. 제한된 타임아웃으로 빠르게 실패시키고 업스트림 재시도와 대기열을 통제합니다.

요청이 제한된 연결 풀을 빌려 데이터베이스를 사용하고, 반환 시 상태를 초기화하며, 대기 한도를 넘으면 타임아웃되는 흐름

CONNECTION BUDGET

풀은 연결 예산을 배분하는 백프레셔 경계다

대기열 · borrow · DB 사용 · reset/return을 한 수명 주기로 읽습니다.

연결 풀의 요청 대기와 연결 반환 흐름 세 요청이 대기열에 들어가 제한된 풀 연결을 빌리고 데이터베이스 작업을 수행한다. 연결은 상태 초기화 뒤 풀로 돌아가며, connection timeout을 넘긴 요청은 빠르게 실패한다. 동시 요청 획득 대기열 HikariCP · max 2 데이터베이스 요청 A getConnection() 요청 B getConnection() 요청 C getConnection() 한정 대기 · timeout 선두 요청 다음 요청 대기 요청 timeout ≤ 2s 연결 풀 connection #1 connection #2 active + idle ≤ maximumPoolSize DB SQL · 잠금 · I/O same session borrow query reset + return 획득 타임아웃 대기 요청만 빠르게 실패 connectionTimeout 관찰할 메트릭 active · idle · awaiting
max 2 제한된 연결 예산
  1. 요청이 연결을 요구한다

    A·B·C가 getConnection()을 호출하고 반환을 제한 시간 동안 기다립니다.

  2. 풀이 가능한 연결을 빌려준다

    active + idle은 maximumPoolSize 이하라는 DB 예산을 지킵니다.

  3. 같은 세션에서 SQL을 실행한다

    쿼리와 잠금은 빌린 연결의 수명 안에 있습니다.

  4. 상태를 초기화해 반환한다

    close는 논리 연결의 대여를 끝내고 다음 요청이 쓸 수 있게 합니다.

  5. 대기 한도를 넘으면 실패한다

    connection timeout은 풀 고갈이 웹 스레드 전체로 번지는 것을 막습니다.

  • 요청·대기
  • 연결 사용
  • 초기화·반환
  • 타임아웃

핵심: 풀을 키우는 대신 연결 점유 시간을 줄이고 대기 타임아웃과 메트릭을 함께 운영합니다.

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 부하에 민감하므로 좁은 상한을 두지 않습니다. 핵심 oracle은 무한 대기하지 않고 일시적 획득 실패가 발생하며 점유 연결을 반환한 뒤 다시 획득할 수 있다는 점입니다.


누수 탐지와 메트릭

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);
        }
    }
}

JDBC Connection.close()에 활성 트랜잭션을 넘겼을 때의 결과는 인터페이스 수준에서 구현 정의입니다. Hikari의 정리 동작을 안전망으로 이해하되 애플리케이션의 정상 제어 흐름으로 사용하지 않습니다.


연습 문제

풀 최댓값이 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 트랜잭션에 저장하고, 워커가 커밋된 이벤트를 전송합니다. 원자성이 필요하다고 연결을 잡은 채 느린 네트워크를 기다리는 것은 분산 트랜잭션을 제공하지 않습니다.

다음 문서에서는 풀 연결 하나 안에서 커밋과 롤백이 만드는 원자성과 READ_COMMITTED 교차 세션 가시성을 두 DB 연결로 확인합니다. 장애 뒤 지속성은 그 관찰 범위에 포함하지 않습니다.