본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : JDBC와 트랜잭션

DataSource·커넥션 풀

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

데이터베이스 연결은 TCP와 인증, 세션 초기화 비용이 있어 쿼리마다 새 물리 연결을 만드는 방식은 비쌉니다.

연결 풀은 미리 만든 제한된 연결을 애플리케이션 스레드가 빌려 쓰게 합니다.

처리량을 무조건 늘리는 캐시가 아니라 DB가 동시에 감당할 수 있는 작업 수를 애플리케이션에 배분하는 백프레셔 경계입니다.


DataSource와 구성 분리

리포지토리가 DriverManager와 URL·인증 정보를 직접 알면 환경과 풀 정책이 코드에 박힙니다.

DataSource를 주입받으면 테스트는 H2, 운영 환경은 HikariCP와 PostgreSQL을 사용할 수 있습니다.

Spring Boot는 클래스 경로와 속성을 바탕으로 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 author_id = ? and created_on = ?
            """;

    private final DataSource dataSource;

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

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

풀의 Connection은 프록시입니다.

close()는 대개 소켓을 닫지 않고 트랜잭션 상태를 정리해 풀에 반환합니다.

그래서 try-with-resources 구문을 그대로 사용해야 합니다.

“풀이 알아서 회수한다”며 종료를 호출하지 않으면 활성 연결이 최대치에 도달합니다.


연결 풀 크기

요청 스레드가 200개라고 연결도 200개 만들지는 않습니다.

DB CPU, 디스크 지연 시간, 다른 애플리케이션의 연결, 쿼리 종류와 목표 지연 시간을 함께 봅니다.

너무 큰 풀은 DB의 컨텍스트 전환과 잠금 경합을 늘리고 장애 때 더 많은 쿼리를 몰아넣습니다.

src/main/resources/application.properties
spring.datasource.hikari.pool-name=board-pool
spring.datasource.hikari.maximum-pool-size=12
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=2000
spring.datasource.hikari.validation-timeout=1000
spring.datasource.hikari.max-lifetime=1740000
spring.datasource.hikari.keepalive-time=120000

max-lifetime은 DB나 네트워크가 연결을 강제로 끊는 시간보다 짧게 두어 풀이 먼저 교체하도록 합니다.

예시 숫자를 그대로 복사하지 않고 인프라 유휴 타임아웃을 확인합니다.

HikariCP는 설정 관계에 제약이 있으므로 시작 경고를 무시하지 않습니다.

최소 유휴 연결 수를 최댓값과 같게 두면 고정 크기 풀처럼 빠른 반응을 얻을 수 있지만 유휴 연결 비용이 있습니다.

트래픽과 DB 연결 예산이 작은 서비스라면 낮은 최솟값으로 시작합니다.

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


연결 대기 타임아웃

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

획득 타임아웃이 없거나 너무 길면 웹 스레드가 쌓여 애플리케이션 전체가 응답하지 않습니다.

제한된 타임아웃으로 빠르게 실패시키고 업스트림 재시도와 대기열을 통제합니다.

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

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

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;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setMaximumPoolSize(1);
        config.setMinimumIdle(0);
        config.setConnectionTimeout(250);

        try (var dataSource = new HikariDataSource(config);
             var held = dataSource.getConnection()) {
            long started = System.nanoTime();

            assertThatThrownBy(dataSource::getConnection)
                    .isInstanceOf(SQLTransientConnectionException.class);

            long elapsedMillis =
                    (System.nanoTime() - started) / 1_000_000;
            assertThat(elapsedMillis).isGreaterThanOrEqualTo(200);
            assertThat(dataSource.getHikariPoolMXBean()
                    .getThreadsAwaitingConnection()).isZero();
        }
    }
}
pool 고갈 실행 결과
maximum pool size = 1
active connections while held = 1
second acquisition = SQLTransientConnectionException
wait bounded near 250ms = true
waiting threads after failure = 0

벽시계 검증은 CI 부하에 민감하므로 상한을 너무 좁게 고정하지 않습니다.

핵심은 무한 대기하지 않고 일시적 획득 실패가 발생하며 점유한 연결을 종료한 뒤 다시 획득할 수 있다는 점입니다.


누수 탐지의 한계

Hikari 누수 탐지 임계값을 넘게 연결을 보유하면 획득 스택을 로그할 수 있습니다.

긴 정상 트랜잭션도 누수처럼 보일 수 있으므로 쿼리와 트랜잭션 목표 시간보다 합리적으로 높게 둡니다.

누수 로그가 연결을 강제로 안전하게 반환해 주지는 않습니다.

풀 메트릭은 활성, 유휴, 대기, 최댓값, 획득 지속 시간을 함께 봅니다.

활성 연결이 최댓값에 도달하고 대기가 계속 늘면 느린 SQL, 잠금 대기, 외부 호출을 트랜잭션 안에서 수행하는지 먼저 확인합니다.

풀만 키우면 DB 부담이 커질 수 있습니다.

신호가능한 원인다음 확인
활성=최댓값긴 쿼리·트랜잭션느린 쿼리·잠금 대기
대기 증가용량 초과요청 비율·타임아웃
유휴=최댓값과도한 풀DB 연결 예산
검증 실패네트워크·DB 재시작수명·연결 유지

상태 확인이 매 요청마다 검증 쿼리를 실행하면 DB 부하가 됩니다.

풀은 연결을 빌릴 때 드라이버 API 또는 설정된 쿼리로 연결 유효성을 확인할 수 있습니다.

업무 쿼리 실패와 연결 자체 실패를 구별합니다.


연결 상태 초기화

연결을 풀에 돌려줄 때 자동 커밋, 읽기 전용, 격리, 카탈로그 같은 상태가 다음 사용자에게 새지 않아야 합니다.

풀이 알려진 상태는 초기화하지만 세션 변수와 임시 테이블 같은 공급자 상태는 애플리케이션 책임일 수 있습니다.

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

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

import java.sql.Connection;

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

class PoolStateResetTest {
    @Test
    void 반환된_connection의_autoCommit이_다음_대여에서_복원된다()
            throws Exception {
        var config = new HikariConfig();
        config.setJdbcUrl("jdbc:h2:mem:reset;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setMaximumPoolSize(1);

        try (var dataSource = new HikariDataSource(config)) {
            try (Connection first = dataSource.getConnection()) {
                first.setAutoCommit(false);
                first.rollback();
            }
            try (Connection second = dataSource.getConnection()) {
                assertThat(second.getAutoCommit()).isTrue();
            }
        }
    }
}
pool state reset 결과
first borrow autoCommit changed = false
rollback before close = executed
proxy close returned connection = true
second borrow autoCommit = true
state leak = false

애플리케이션 코드가 트랜잭션을 열고 롤백 없이 종료해도 풀이 정리할 수 있지만 그 동작에 기대지 않습니다.

트랜잭션 책임 주체가 커밋 또는 롤백을 명시하고 finally에서 반환해야 실패 원인을 분명히 알 수 있습니다.


연습 문제

풀 최댓값이 2일 때 두 트랜잭션이 외부 HTTP 호출을 기다리며 연결을 점유하고 세 번째 요청이 타임아웃나는 상황을 재현하세요.

외부 호출을 트랜잭션 밖으로 옮긴 설계와 비교해 활성·대기·지연 시간 변화를 설명합니다.

해설 보기

DB 변경 전 외부 데이터를 먼저 읽을 수 있는지, 트랜잭션 결과 뒤 아웃박스로 호출할 수 있는지 분류합니다.

원자성이 필요하다고 연결을 잡은 채 느린 네트워크를 기다리는 것은 분산 트랜잭션을 제공하지 않습니다.

package board.application;

public record PendingNotification(
        long postId,
        String type,
        String payload
) {
    public PendingNotification {
        if (postId <= 0 || type == null || type.isBlank()) {
            throw new IllegalArgumentException("valid event required");
        }
    }
}

게시글 행과 아웃박스 행을 같은 DB 트랜잭션에 저장하고 워커가 커밋된 이벤트를 전송합니다.

테스트는 HTTP 지연 중에도 DB 연결이 반환되어 다른 조회가 성공하는지 확인합니다.

다음 문서에서는 풀 연결 하나 안에서 커밋과 롤백이 어떤 원자성·격리·지속성 경계를 만드는지 ACID와 DB 세션 관점으로 확인합니다.