본문으로 건너뛰기

안동민 개발노트

본문 시작

JDBC 리소스 수명주기

Connection·PreparedStatement·ResultSet의 중첩 수명을 관리하고 typed binding·불변 행 매핑·키셋 페이지네이션을 실행으로 검증합니다.

JDBC 쿼리 한 번에는 연결, 구문, 결과 집합이 계층적으로 생성됩니다.

결과 집합은 독립 컬렉션이 아니라 구문과 연결이 열린 동안 드라이버 커서를 읽는 뷰입니다.

이 리소스를 메서드 밖으로 흘리거나 종료를 빠뜨리면 풀 연결이 고갈되고 DB 커서와 메모리가 쌓입니다.

소유한 스코프가 불변 값으로 복사하는 일과 역순 종료까지 책임져야 합니다.

Connection 안에서 연 자원은 가장 안쪽부터 닫힌다

NESTED CONTAINMENT · RESOURCE OWNERSHIP

Connection 안에서 연 자원은 가장 안쪽부터 닫힌다

Connection이 SQL 구문을 소유하고 구문이 cursor를 소유한다. 열린 cursor 안에서 row를 불변 값으로 복사한 뒤 ResultSet → PreparedStatement → Connection 순서로 닫아야 반환된 값만 resource scope 밖으로 나갈 수 있다.

JDBC Connection, PreparedStatement, ResultSet의 중첩 수명과 역순 종료 가장 바깥 Connection 안에 PreparedStatement가 있고 그 안에 ResultSet cursor가 있다. 세 자원은 바깥에서 안쪽으로 열리지만 ResultSet, PreparedStatement, Connection 순으로 닫힌다. cursor가 열린 동안 typed getter로 읽은 값만 불변 PostRow 목록으로 복사되어 바깥으로 반환된다. COPY VALUES OPEN 1 · CLOSE 3 Connection DB session · transaction context 마지막 close는 물리 연결 종료 또는 pool 반환 OPEN 2 · CLOSE 2 PreparedStatement 고정 SQL 구조 · typed binding · query timeout OPEN 3 · CLOSE 1 ResultSet next()가 true인 행만 읽기 label getter → Java type cursor 안에서 mapping 완료 SAFE OUTPUT List<PostRow> immutable copied values DECLARATION Connection → Statement → ResultSet CLOSE ResultSet → Statement → Connection
  1. OPEN 1 · CLOSE 3

    Connection이 전체 DB session을 소유한다

    가장 먼저 열고 모든 cursor와 statement가 끝난 뒤 마지막에 닫습니다.

  2. OPEN 2 · CLOSE 2

    PreparedStatement가 SQL 구조와 typed 값을 소유한다

    고정 SQL에 회원 ID·날짜·cursor 값을 binding하고 실행 시간을 제한합니다.

  3. OPEN 3 · CLOSE 1

    ResultSet cursor 안에서 mapping을 끝낸다

    next()가 참인 행만 label getter로 읽어 PostRow로 복사합니다.

  4. SAFE OUTPUT

    불변 값만 scope 밖으로 반환한다

    ResultSet, Statement, Connection을 역순으로 닫은 뒤 List.copyOf 결과만 남습니다.

큰 결과도 닫힌 ResultSet을 감싼 lazy stream으로 내보내지 않는다. 작은 결과는 즉시 복사하고, 큰 결과는 keyset page마다 새 resource scope를 연다.


JDBC 자원 종료 순서

try-with-resources 선언은 생성 순서의 역순으로 종료합니다.

Connection → PreparedStatement → ResultSet으로 열면 결과 집합, 구문, 연결 순으로 정리됩니다.

중간에서 SQL 예외가 나도 이미 열린 리소스는 닫힙니다.

아래 query는 결과를 PostRow로 모두 복사한 뒤에만 페이지를 반환합니다.

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

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import javax.sql.DataSource;

public final class JdbcPostQuery {
    private static final String FIRST_PAGE = """
            select id, member_id, title, content, published_on,
                   client_request_id, created_at, version
              from posts
             where member_id = ?
               and published_on between ? and ?
             order by published_on desc, id desc
             limit ?
            """;
    private static final String NEXT_PAGE = """
            select id, member_id, title, content, published_on,
                   client_request_id, created_at, version
              from posts
             where member_id = ?
               and published_on between ? and ?
               and (published_on < ?
                    or (published_on = ? and id < ?))
             order by published_on desc, id desc
             limit ?
            """;

    private final DataSource dataSource;

    public JdbcPostQuery(DataSource dataSource) {
        this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
    }

    public Page findPage(
            long memberId,
            LocalDate from,
            LocalDate to,
            Optional<PostCursor> after,
            int pageSize
    ) throws SQLException {
        Objects.requireNonNull(from, "from");
        Objects.requireNonNull(to, "to");
        Objects.requireNonNull(after, "after");
        if (memberId <= 0 || from.isAfter(to)
                || pageSize < 1 || pageSize > 100) {
            throw new IllegalArgumentException("invalid query boundary");
        }

        var rows = new ArrayList<PostRow>();
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(
                     after.isPresent() ? NEXT_PAGE : FIRST_PAGE)) {
            bind(statement, memberId, from, to, after, pageSize + 1);
            statement.setFetchSize(pageSize + 1);
            statement.setQueryTimeout(2);
            try (ResultSet result = statement.executeQuery()) {
                while (result.next()) {
                    rows.add(map(result));
                }
            }
        }

        boolean hasMore = rows.size() > pageSize;
        if (hasMore) {
            rows.removeLast();
        }
        var copied = List.copyOf(rows);
        var nextCursor = hasMore
                ? Optional.of(PostCursor.from(copied.getLast()))
                : Optional.<PostCursor>empty();
        return new Page(copied, nextCursor);
    }

    private void bind(
            PreparedStatement statement,
            long memberId,
            LocalDate from,
            LocalDate to,
            Optional<PostCursor> after,
            int limit
    ) throws SQLException {
        statement.setLong(1, memberId);
        statement.setObject(2, from);
        statement.setObject(3, to);
        if (after.isPresent()) {
            var cursor = after.orElseThrow();
            statement.setObject(4, cursor.publishedOn());
            statement.setObject(5, cursor.publishedOn());
            statement.setLong(6, cursor.id());
            statement.setInt(7, limit);
        } else {
            statement.setInt(4, limit);
        }
    }

    private PostRow map(ResultSet result) throws SQLException {
        return new PostRow(
                result.getLong("id"),
                result.getLong("member_id"),
                result.getString("title"),
                result.getString("content"),
                result.getObject("published_on", LocalDate.class),
                result.getString("client_request_id"),
                result.getObject("created_at", OffsetDateTime.class)
                        .toInstant(),
                result.getLong("version"));
    }

    public record Page(
            List<PostRow> rows,
            Optional<PostCursor> nextCursor
    ) {
        public Page {
            rows = List.copyOf(rows);
            nextCursor = Objects.requireNonNull(
                    nextCursor, "nextCursor");
            if (rows.isEmpty() && nextCursor.isPresent()) {
                throw new IllegalArgumentException(
                        "empty page cannot have a next cursor");
            }
        }
    }
}

Java 25의 removeLast()getLast()는 조회된 pageSize + 1행 중 look-ahead 행을 제거하고 실제 마지막 반환 행으로 다음 커서를 만듭니다.

ResultSet이나 지연 Stream을 반환하지 않으므로 caller는 이미 닫힌 cursor를 실수로 읽을 수 없습니다.


PreparedStatement 구조 분리

값과 SQL 구조는 서로 다른 방식으로 다룹니다.

입력 종류안전한 소유자적용 방법
회원 ID·날짜·cursor ID외부 값?에 JDBC 타입으로 binding
table·column·정렬 방향애플리케이션 정책완성된 allowlist SQL 중 하나를 선택
WHERE·ORDER BY 구조repository source고정 SQL text block으로 소유

자리표시자는 드라이버가 타입이 지정된 값으로 전달해 인용 오류와 주입을 막고 구문 계획 재사용 가능성을 높입니다.

테이블·열·ORDER BY 방향 같은 식별자는 ?로 바인딩할 수 없습니다.

외부 문자열을 그대로 이어 붙이지 않고 허용된 완성 SQL 중 하나를 선택합니다.

setFetchSize는 드라이버에 대한 가져오기 힌트이며 결과 행 수 제한이 아닙니다.

위 코드는 SQL limit ?로 실제 결과 크기를 제한하고 한 행을 더 읽어 다음 페이지 존재 여부를 판단합니다.


ResultSet 커서와 불변 행

쿼리 직후 cursor는 첫 행 앞에 있습니다.

next()true인 동안 열을 읽고 false면 결과가 끝납니다.

열 인덱스보다 명시적 label을 쓰면 SELECT 순서 변경에 덜 취약하지만 별칭과 중복 이름을 관리해야 합니다.

DB 제약, JDBC getter, Java 타입의 nullability와 범위를 같은 계약으로 유지합니다.

ColumnDB 계약Java mapping
ididentity·positivelong
member_idFK·positivelong
titleNOT NULL·1~80String
contentNOT NULL·1~720String
published_onNOT NULLLocalDate
client_request_idNOT NULL·8~64validated String
created_attimestamp with time zoneInstant
versionNOT NULL·0 이상long
src/main/java/board/jdbc/PostRow.java
package board.jdbc;

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

public record PostRow(
        long id,
        long memberId,
        String title,
        String content,
        LocalDate publishedOn,
        String clientRequestId,
        Instant createdAt,
        long version
) {
    public PostRow {
        if (id <= 0 || memberId <= 0 || version < 0) {
            throw new IllegalArgumentException(
                    "invalid post identity or version");
        }
        Objects.requireNonNull(publishedOn, "publishedOn");
        Objects.requireNonNull(createdAt, "createdAt");
        if (title == null || title.isBlank() || title.length() > 80) {
            throw new IllegalArgumentException("invalid title");
        }
        if (content == null || content.isBlank()
                || content.length() > 720) {
            throw new IllegalArgumentException("invalid content");
        }
        if (clientRequestId == null
                || !clientRequestId.matches("[A-Za-z0-9_-]{8,64}")) {
            throw new IllegalArgumentException(
                    "invalid clientRequestId");
        }
    }
}

DB 행이 Java 불변식을 어기면 mapping에서 즉시 실패합니다.

데이터를 조용히 보정해 손상을 숨기지 않습니다.


키셋 cursor

페이지네이션에서 offset이 커질수록 DB가 많은 행을 건너뛸 수 있습니다.

(published_on desc, id desc) 정렬에서 마지막으로 반환한 두 값을 다음 요청의 cursor로 사용하면 같은 날짜의 여러 행도 중복하거나 빠뜨리지 않습니다.

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

import java.time.LocalDate;
import java.util.Objects;

public record PostCursor(LocalDate publishedOn, long id) {
    public PostCursor {
        Objects.requireNonNull(publishedOn, "publishedOn");
        if (id <= 0) {
            throw new IllegalArgumentException("positive id required");
        }
    }

    public static PostCursor from(PostRow row) {
        Objects.requireNonNull(row, "row");
        return new PostCursor(row.publishedOn(), row.id());
    }
}

다음 cursor는 look-ahead 행이 아니라 실제로 client에 반환한 마지막 행에서 만듭니다.

그래야 다음 query의 < 조건이 아직 반환하지 않은 첫 행부터 이어집니다.


자원 종료·binding·pagination 검증

한 테스트가 실제 schema.sql을 초기화하고 45행을 세 페이지로 읽습니다.

매 페이지 뒤 Hikari active connection이 0인지 확인하므로 결과를 복사한 뒤 연결을 반환했다는 계약도 실행으로 남습니다.

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

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

import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Optional;

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 JdbcPostQueryTest {
    @Test
    void keyset은_45행을_한번씩_읽고_connection을_반환한다()
            throws Exception {
        var config = new HikariConfig();
        config.setJdbcUrl(
                "jdbc:h2:mem:lifetime;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);
            seed(dataSource);

            var query = new JdbcPostQuery(dataSource);
            var all = new ArrayList<PostRow>();
            var pageSizes = new ArrayList<Integer>();
            var cursorStates = new ArrayList<Boolean>();
            Optional<PostCursor> cursor = Optional.empty();
            do {
                var page = query.findPage(
                        41L,
                        LocalDate.parse("2026-07-01"),
                        LocalDate.parse("2026-07-31"),
                        cursor,
                        20);
                all.addAll(page.rows());
                pageSizes.add(page.rows().size());
                cursorStates.add(page.nextCursor().isPresent());
                cursor = page.nextCursor();
                assertThat(dataSource.getHikariPoolMXBean()
                        .getActiveConnections()).isZero();
            } while (cursor.isPresent());

            assertThat(pageSizes).containsExactly(20, 20, 5);
            assertThat(cursorStates).containsExactly(true, true, false);
            assertThat(all).hasSize(45);
            assertThat(all).extracting(PostRow::id)
                    .doesNotHaveDuplicates();
            assertThat(all).allSatisfy(row -> {
                assertThat(row.memberId()).isEqualTo(41L);
                assertThat(row.clientRequestId())
                        .startsWith("request-");
                assertThat(row.version()).isZero();
            });
            for (int index = 1; index < all.size(); index++) {
                var previous = all.get(index - 1);
                var current = all.get(index);
                assertThat(previous.publishedOn().isAfter(
                        current.publishedOn())
                        || previous.publishedOn().equals(
                                current.publishedOn())
                        && previous.id() > current.id()).isTrue();
            }
        }
    }

    private void seed(HikariDataSource dataSource) throws Exception {
        try (var connection = dataSource.getConnection();
             var member = connection.prepareStatement("""
                     insert into members(
                         id, email, password_hash, name)
                     values (?, ?, ?, ?)
                     """);
             var post = connection.prepareStatement("""
                     insert into posts(
                         member_id, title, content, published_on,
                         client_request_id, created_at)
                     values (?, ?, ?, ?, ?, ?)
                     """)) {
            member.setLong(1, 41L);
            member.setString(2, "member41@example.com");
            member.setString(3, "hash");
            member.setString(4, "회원 41");
            member.executeUpdate();

            for (int index = 1; index <= 45; index++) {
                post.setLong(1, 41L);
                post.setString(2, "JDBC " + index);
                post.setString(3, "본문 " + index);
                post.setObject(4, LocalDate.of(2026, 7, 14)
                        .minusDays((index - 1) / 15));
                post.setString(5, "request-%04d".formatted(index));
                post.setObject(6, OffsetDateTime.ofInstant(
                        Instant.parse("2026-07-14T00:00:00Z")
                                .plusSeconds(index),
                        ZoneOffset.UTC));
                post.addBatch();
            }
            assertThat(post.executeBatch()).hasSize(45);
        }
    }
}

H2 test는 repository 흐름을 빠르게 확인하지만 production dialect와 driver의 cursor 동작을 대신하지 않습니다.


대용량 결과 전략

결과 크기와 소비 방식에 따라 자원 수명 전략을 명시적으로 고릅니다.

전략적합한 경우수명 계약
Eager immutable list메모리에 맞는 작은 결과cursor를 닫기 전에 모두 복사
Keyset pagination안정된 정렬 키가 있는 큰 결과page마다 새 query와 새 connection scope
Scoped consumer한 행씩 처리해야 하는 streamconnection이 열린 callback 안에서만 소비
Timeout·cancel느린 query나 client 취소취소 뒤에도 ResultSet·Statement·Connection 종료

Java Stream만 반환하고 onClose에 기대면 caller가 종료하지 않을 수 있습니다.

쿼리 timeout, driver socket timeout, transaction timeout은 서로 다른 계층이므로 각각 테스트합니다.


연습 문제

현재 keyset query에 회원 42의 행과 조회 범위 밖 날짜를 섞으세요.

회원 41의 45행만 정확히 한 번씩 반환하고 다른 회원·날짜는 결과에 들어오지 않는지 같은 실행 테스트에 oracle을 추가합니다.

다음 문서에서는 같은 자원 스코프에서 삽입·갱신·삭제를 구현하고 행 수와 SQL 예외를 업무 의미로 해석합니다.