본문 바로가기

JDBC 리소스 수명 주기

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

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

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

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

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

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

Connection·Statement·ResultSet 획득 뒤 불변 PostRow 복사와 역순 종료가 자원 수명을 바꾼다.

Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

열린 cursor에서 값을 복사하고 자원은 역순으로 닫는다

Connection 안에서 PreparedStatement와 ResultSet을 열고, 현재 행을 PostRow로 복사합니다. cursor·구문·연결을 닫은 뒤 List.copyOf 결과만 반환합니다. 다음 페이지 cursor는 실제 반환한 마지막 행입니다.

열린 cursor에서 값을 복사하고 자원은 역순으로 닫는다Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.Connection · open 1 / close 3PreparedStatement · open 2 / close 2ResultSet · open 3 / close 1next() → typed getterPostRow로 현재 행 복사List<PostRow>List.copyOf
닫는 자원 안에서 값을 복사하여 자원 밖으로 반환한다원 desktop처럼 Connection 영역 안에 PreparedStatement, 그 안에 ResultSet이 있다. open은1/2/3, close는3/2/1이다. 유일한 복사 간선은 ResultSet에서 세 자원 밖의 List<PostRow>로 나간다. 반환된 리스트가 JDBC 자원을 소유하는 관계가 아니다.Connectionopen 1 / close 3PreparedStatementopen 2 / close 2ResultSetopen 3 / close 1next() → getterPostRow 값 복사List<PostRow>List.copyOf

OPEN / CLOSE

Connection → Statement → ResultSet

선언은 바깥 자원부터, 종료는 ResultSet → Statement → Connection 순서입니다. SQL·mapping 중 예외가 나도 이미 열린 자원은 정리합니다. 풀의 Connection.close는 일반적으로 물리 연결을 풀로 돌려줍니다.

SAFE OUTPUT

cursor와 lazy stream을 밖으로 보내지 않는다

현재 행만 getter로 읽고 PostRow 불변식을 검사합니다. 코드가 ResultSet 안에서 행을 모두 복사한 뒤 자원을 닫고 불변 페이지를 반환하므로 호출자가 닫힌 cursor를 읽지 않습니다.

BINDING / STRUCTURE

값은 ? · SQL 구조는 코드가 소유

회원·날짜·cursor·limit은 타입으로 바인딩합니다. 테이블·열·정렬 방향은 placeholder로 바인딩하지 않고 허용한 SQL을 선택합니다. fetchSize는 힌트이며 실제 행 수는 SQL limit으로 제한합니다.

KEYSET / LOOK AHEAD

pageSize + 1에서 실제 마지막 반환 행 선택

정렬은 published_on desc, id desc입니다. 한 행을 더 읽고 초과 행을 제거한 뒤 실제 반환한 마지막 행으로 next cursor를 만듭니다. 제거한 look-ahead 행을 cursor로 쓰면 안 됩니다.

ACTUAL H2 / HIKARI FIXTURE

45행을 20·20·5로 정확히 한 번

제공된 test는 next cursor=true·true·false, 총45행·중복 ID 없음·회원41·내림차순을 확인합니다. 매 페이지 뒤 active connection=0입니다. 다른 회원과 범위 밖 날짜를 섞는 oracle은 연습 범위입니다.

LARGE RESULTS / ADDITIONAL

운영 driver의 cursor 수명은 따로 검증

대용량은 page마다 새 자원 스코프 또는 연결이 열린 callback 안에서 소비합니다. query timeout·driver socket timeout·transaction timeout과 취소는 서로 다른 경계입니다. H2 fixture가 운영 driver의 스트리밍을 입증하지 않습니다.

Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.
  • 원 desktop처럼 Connection 영역 안에 PreparedStatement, 그 안에 ResultSet이 있다. open은 1/2/3, close는 3/2/1이다. 유일한 복사 간선은 ResultSet에서 세 자원 밖의 List<PostRow>로 나간다. 반환된 리스트가 JDBC 자원을 소유하는 관계가 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Connection 안에서 연 자원은 가장 안쪽부터 닫힌다
14원 desktop처럼 Connection 영역 안에 PreparedStatement, 그 안에 ResultSet이 있다. open은 1/2/3, close는 3/2/1이다. 유일한 복사 간선은 ResultSet에서 세 자원 밖의 List<PostRow>로 나간다. 반환된 리스트가 JDBC 자원을 소유하는 관계가 아니다.
39제공된 test는 next cursor=true·true·false, 총 45행·중복 ID 없음·회원 41·내림차순을 확인합니다. 매 페이지 뒤 active connection=0입니다. 다른 회원과 범위 밖 날짜를 섞는 oracle은 연습 범위입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
중첩 자원 열기

Connection 안에서 PreparedStatement와 ResultSet을 엽니다.

열기: Connection→Statement→ResultSet안쪽 자원의 수명이 바깥에 포함됩니다.
불변 행 복사

현재 행 값을 불변 PostRow로 복사합니다.

산출물: PostRow · 소유: JDBC 자원과 분리반환할 값이 열린 ResultSet에서 독립합니다.
역순 close

ResultSet·Statement·Connection 순서로 닫습니다.

종료: 안쪽부터 · page·cursor: 자원 소유 없음반환 결과가 닫힌 자원을 붙들지 않습니다.

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

Connection·Statement·ResultSet 획득 뒤 불변 PostRow 복사와 역순 종료가 자원 수명을 바꾼다.

Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

열린 cursor에서 값을 복사하고 자원은 역순으로 닫는다

Connection 안에서 PreparedStatement와 ResultSet을 열고, 현재 행을 PostRow로 복사합니다. cursor·구문·연결을 닫은 뒤 List.copyOf 결과만 반환합니다. 다음 페이지 cursor는 실제 반환한 마지막 행입니다.

열린 cursor에서 값을 복사하고 자원은 역순으로 닫는다Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.Connection · open 1 / close 3PreparedStatement · open 2 / close 2ResultSet · open 3 / close 1next() → typed getterPostRow로 현재 행 복사List<PostRow>List.copyOf
닫는 자원 안에서 값을 복사하여 자원 밖으로 반환한다원 desktop처럼 Connection 영역 안에 PreparedStatement, 그 안에 ResultSet이 있다. open은1/2/3, close는3/2/1이다. 유일한 복사 간선은 ResultSet에서 세 자원 밖의 List<PostRow>로 나간다. 반환된 리스트가 JDBC 자원을 소유하는 관계가 아니다.Connectionopen 1 / close 3PreparedStatementopen 2 / close 2ResultSetopen 3 / close 1next() → getterPostRow 값 복사List<PostRow>List.copyOf

OPEN / CLOSE

Connection → Statement → ResultSet

선언은 바깥 자원부터, 종료는 ResultSet → Statement → Connection 순서입니다. SQL·mapping 중 예외가 나도 이미 열린 자원은 정리합니다. 풀의 Connection.close는 일반적으로 물리 연결을 풀로 돌려줍니다.

SAFE OUTPUT

cursor와 lazy stream을 밖으로 보내지 않는다

현재 행만 getter로 읽고 PostRow 불변식을 검사합니다. 코드가 ResultSet 안에서 행을 모두 복사한 뒤 자원을 닫고 불변 페이지를 반환하므로 호출자가 닫힌 cursor를 읽지 않습니다.

BINDING / STRUCTURE

값은 ? · SQL 구조는 코드가 소유

회원·날짜·cursor·limit은 타입으로 바인딩합니다. 테이블·열·정렬 방향은 placeholder로 바인딩하지 않고 허용한 SQL을 선택합니다. fetchSize는 힌트이며 실제 행 수는 SQL limit으로 제한합니다.

KEYSET / LOOK AHEAD

pageSize + 1에서 실제 마지막 반환 행 선택

정렬은 published_on desc, id desc입니다. 한 행을 더 읽고 초과 행을 제거한 뒤 실제 반환한 마지막 행으로 next cursor를 만듭니다. 제거한 look-ahead 행을 cursor로 쓰면 안 됩니다.

ACTUAL H2 / HIKARI FIXTURE

45행을 20·20·5로 정확히 한 번

제공된 test는 next cursor=true·true·false, 총45행·중복 ID 없음·회원41·내림차순을 확인합니다. 매 페이지 뒤 active connection=0입니다. 다른 회원과 범위 밖 날짜를 섞는 oracle은 연습 범위입니다.

LARGE RESULTS / ADDITIONAL

운영 driver의 cursor 수명은 따로 검증

대용량은 page마다 새 자원 스코프 또는 연결이 열린 callback 안에서 소비합니다. query timeout·driver socket timeout·transaction timeout과 취소는 서로 다른 경계입니다. H2 fixture가 운영 driver의 스트리밍을 입증하지 않습니다.

Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Connection, PreparedStatement, ResultSet이 중첩된 수명을 가진다. ResultSet에서 현재 행의 값을 불변 PostRow로 복사하고 ResultSet, PreparedStatement, Connection 순서로 닫는다. 반환한 페이지와 다음 cursor는 JDBC 자원을 소유하지 않는다.
  • 원 desktop처럼 Connection 영역 안에 PreparedStatement, 그 안에 ResultSet이 있다. open은 1/2/3, close는 3/2/1이다. 유일한 복사 간선은 ResultSet에서 세 자원 밖의 List<PostRow>로 나간다. 반환된 리스트가 JDBC 자원을 소유하는 관계가 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Connection 안에서 연 자원은 가장 안쪽부터 닫힌다
14원 desktop처럼 Connection 영역 안에 PreparedStatement, 그 안에 ResultSet이 있다. open은 1/2/3, close는 3/2/1이다. 유일한 복사 간선은 ResultSet에서 세 자원 밖의 List<PostRow>로 나간다. 반환된 리스트가 JDBC 자원을 소유하는 관계가 아니다.
39제공된 test는 next cursor=true·true·false, 총 45행·중복 ID 없음·회원 41·내림차순을 확인합니다. 매 페이지 뒤 active connection=0입니다. 다른 회원과 범위 밖 날짜를 섞는 oracle은 연습 범위입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
중첩 자원 열기

Connection 안에서 PreparedStatement와 ResultSet을 엽니다.

열기: Connection→Statement→ResultSet안쪽 자원의 수명이 바깥에 포함됩니다.
불변 행 복사

현재 행 값을 불변 PostRow로 복사합니다.

산출물: PostRow · 소유: JDBC 자원과 분리반환할 값이 열린 ResultSet에서 독립합니다.
역순 close

ResultSet·Statement·Connection 순서로 닫습니다.

종료: 안쪽부터 · page·cursor: 자원 소유 없음반환 결과가 닫힌 자원을 붙들지 않습니다.

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로 쓰면, 같은 날짜의 행도 ID로 순서를 구분해 이어 읽을 수 있습니다. 각 페이지가 새 연결에서 실행되는 이 예제는 여러 요청을 하나의 고정 스냅샷으로 묶지는 않습니다. 도중에 게시일 변경·추가·삭제가 일어나면 그 변경이 페이지에 미치는 영향은 별도의 조회 정책으로 정해야 합니다.

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의 < 조건이 아직 반환하지 않은 첫 행부터 이어집니다.

예시 조회 조건에 맞는 다섯 행의 정렬 결과가 (7월 14일, 9), (7월 14일, 7), (7월 13일, 8), (7월 13일, 6), (7월 12일, 10)이고 페이지 크기가 2라고 합시다. 첫 SQL은 한 행을 더 읽어 앞의 세 행을 얻지만, 응답에는 앞의 두 행만 넣습니다. 다음 cursor는 보류한 (7월 13일, 8)이 아니라 마지막 반환 행 (7월 14일, 7)입니다.

다음 두 페이지를 직접 구하기 행과 정렬 키는 끝까지 변하지 않습니다. 두 번째·세 번째 응답 행, 두 번째 cursor, 각 페이지의 hasNext를 구하세요. 첫 cursor를 보류 행으로 잘못 잡으면 어떤 행이 누락되는지도 설명하세요.

정렬의 두 키와 한 행 미리 읽기의 풀이

두 번째 SQL은 첫 cursor보다 날짜가 작거나, 날짜가 같으면서 ID가 작은 행을 읽습니다. 응답은 (7월 13일, 8), (7월 13일, 6)이고 보류 행은 (7월 12일, 10)입니다. 두 번째 cursor는 (7월 13일, 6), hasNext는 true입니다. 세 번째 응답에는 (7월 12일, 10)만 남아 hasNext=false입니다. 첫 페이지도 보류 행이 있으므로 hasNext=true입니다.

첫 cursor를 (7월 13일, 8)로 잡으면 다음 < 조건이 그 행 자체를 제외합니다. ID 8은 첫 응답에도 없었으므로 누락됩니다. 반대로 마지막 반환 행을 cursor로 삼으면 아직 반환하지 않은 행부터 이어집니다. 이 다섯 행은 정렬 규칙을 계산하기 위한 가정 입력이며 아래 45행 H2 테스트와 별개의 사례입니다.


자원 종료·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, "[email protected]");
            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 예외를 업무 의미로 해석합니다.