본문 바로가기

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: 자원 소유 없음반환 결과가 닫힌 자원을 붙들지 않습니다.

그림은 Connection 안에 PreparedStatement, 그 안에 ResultSet이 겹쳐 있는 모습을 보여 줍니다. 여는 순서는 1, 2, 3이고 닫는 순서는 3, 2, 1입니다. ResultSet에서 next()와 타입이 있는 getter로 현재 행을 읽어 불변 PostRow로 복사하고, 세 자원을 모두 닫은 뒤 List.copyOf의 결과만 바깥으로 돌려줍니다. 돌려준 목록은 JDBC 자원을 갖고 있지 않으며, 다음 페이지의 커서는 실제로 돌려준 마지막 행으로 만듭니다.


JDBC 자원을 닫는 순서

try-with-resources에 선언한 자원은 만든 순서의 역순으로 닫힙니다. Connection, PreparedStatement, ResultSet 순으로 열면 결과 집합, 구문, 연결 순으로 정리되며, 중간에 SQL 예외가 나도 이미 열린 자원은 닫힙니다. 아래 쿼리는 결과를 모두 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 21부터 List에 있는 removeLast()와 getLast()로, 조회한 pageSize + 1행 가운데 미리 읽은 한 행을 빼고 실제로 돌려줄 마지막 행으로 다음 커서를 만듭니다. ResultSet이나 지연 Stream을 돌려주지 않으므로, 호출한 쪽이 이미 닫힌 커서를 실수로 읽을 수 없습니다.


PreparedStatement는 값과 구조를 나눕니다

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

입력의 종류누가 정하나적용 방법
회원 ID, 날짜, 커서 ID외부의 값?에 JDBC 타입으로 바인딩
테이블, 열, 정렬 방향애플리케이션의 정책허용 목록에 있는 완성된 SQL 가운데 하나를 선택
WHERE와 ORDER BY의 구조저장소의 소스 코드고정된 SQL 텍스트 블록

드라이버는 자리 표시자에 바인딩한 값을 타입에 맞게 전달해 따옴표 오류와 주입을 막고, 구문의 실행 계획을 다시 쓸 가능성도 높입니다. 테이블이나 열, ORDER BY의 방향 같은 식별자는 ?로 바인딩할 수 없으므로, 외부 문자열을 그대로 이어 붙이지 않고 허용된 완성 SQL 가운데 하나를 고릅니다.

setFetchSize는 드라이버에 주는 가져오기 힌트일 뿐 결과 행 수의 제한이 아닙니다. 위 코드는 SQL의 limit ?로 실제 결과의 크기를 제한하고, 한 행을 더 읽어 다음 페이지가 있는지 판단합니다.


ResultSet의 커서와 불변 행

쿼리를 실행한 직후의 커서는 첫 행 앞에 있습니다. next()가 참인 동안 열을 읽고, 거짓이면 결과가 끝난 것입니다. 열 번호보다 명시적인 레이블을 쓰면 SELECT의 순서가 바뀌어도 덜 깨지지만, 별칭과 중복 이름을 관리해야 합니다. DB 제약과 JDBC getter, Java 타입의 null 허용과 범위를 같은 규칙으로 맞춥니다.

열DB의 규칙Java 매핑
ididentity, 양수long
member_id외래 키, 양수long
titleNOT NULL, 1~80String
contentNOT NULL, 1~720String
published_onNOT NULLLocalDate
client_request_idNOT NULL, 8~64검증된 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의 불변식을 어기면 매핑에서 바로 실패합니다. 데이터를 몰래 보정해 손상을 숨기지 않습니다.


키셋 커서

페이지네이션에서 offset이 커질수록 DB가 많은 행을 건너뛰어야 할 수 있습니다. OFFSET과 키셋 방식의 SQL 차이는 데이터베이스 교재의 정렬·제한·중복 제거에서 다룹니다. 조회 대상과 정렬 키가 페이지 사이에 바뀌지 않는다면, (published_on desc, id desc)의 마지막으로 돌려준 값을 커서로 써서 같은 날짜의 행도 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());
    }
}

다음 커서는 미리 읽은 행이 아니라 실제로 클라이언트에 돌려준 마지막 행에서 만듭니다. 그래야 다음 쿼리의 < 조건이 아직 돌려주지 않은 첫 행부터 이어집니다.

예를 들어 조건에 맞는 다섯 행이 (7월 14일, 9), (7월 14일, 7), (7월 13일, 8), (7월 13일, 6), (7월 12일, 10) 순으로 정렬되고 페이지 크기가 2라고 합시다. 첫 SQL은 한 행을 더 읽어 앞의 세 행을 얻지만 응답에는 앞의 두 행만 넣고, 다음 커서는 남겨 둔 (7월 13일, 8)이 아니라 마지막으로 돌려준 (7월 14일, 7)입니다.


자원 종료와 바인딩, 페이지네이션 검증

한 테스트가 실제 schema.sql로 초기화하고 45행을 세 페이지로 읽습니다. 페이지마다 Hikari의 활성 연결이 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 테스트는 저장소의 흐름을 빠르게 확인하지만, 운영 DB의 방언과 드라이버의 커서 동작을 대신하지는 않습니다.


큰 결과를 다루는 전략

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

전략알맞은 경우수명의 규칙
즉시 만든 불변 목록메모리에 들어가는 작은 결과커서를 닫기 전에 모두 복사
키셋 페이지네이션안정된 정렬 키가 있는 큰 결과페이지마다 새 쿼리와 새 연결 범위
범위가 정해진 소비자한 행씩 처리해야 하는 스트림연결이 열린 콜백 안에서만 소비
타임아웃과 취소느린 쿼리나 클라이언트의 취소취소한 뒤에도 ResultSet, Statement, Connection을 닫음

Java Stream만 돌려주고 onClose에 기대면, 호출한 쪽이 닫지 않을 수 있습니다. 쿼리 타임아웃과 드라이버의 소켓 타임아웃, 트랜잭션 타임아웃은 서로 다른 계층이므로 각각 테스트합니다.

더 깊이: 드라이버는 큰 결과를 정말 나눠 가져올까

ResultSet이 커서처럼 보인다고 해서 드라이버가 행을 조금씩 가져온다는 보장은 없습니다. PostgreSQL JDBC 드라이버 문서에 따르면 이 드라이버는 기본적으로 쿼리 결과 전체를 한 번에 모읍니다. 서버 쪽 커서로 나눠 가져오려면 연결이 자동 커밋 모드가 아니어야 하고, 구문이 TYPE_FORWARD_ONLY로 만들어져야 하며, 쿼리가 세미콜론으로 이은 여러 문장이 아니라 하나여야 하고, 가져오기 크기가 0보다 커야 합니다. 조건 하나라도 어긋나면 드라이버는 오류 없이 결과 전체를 메모리에 올리는 방식으로 돌아갑니다. 자동 커밋으로 실행한 큰 조회에서 setFetchSize(500)을 주었는데도 메모리가 치솟는다면 이 조건부터 확인합니다.

MySQL Connector/J는 규칙이 또 다릅니다. Connector/J 문서는 결과 집합을 기본적으로 모두 받아 메모리에 저장한다고 설명합니다. 한 행씩 받으려면 앞으로만 읽는 읽기 전용 구문에 setFetchSize(Integer.MIN_VALUE)라는 특별한 값을 주거나, 연결 속성 useCursorFetch=true를 켠 뒤 원하는 가져오기 크기를 줍니다. 앞의 방식으로 스트리밍할 때는 결과를 끝까지 읽거나 닫기 전에는 같은 연결로 다른 쿼리를 보낼 수 없고, 문장이 끝나지 않았으므로 그 문장이 잡은 잠금도 그때까지 풀리지 않습니다. 이 글처럼 limit으로 페이지를 나누고 결과를 바로 복사해 닫으면, 드라이버마다 다른 이 규칙에 기대지 않아도 됩니다.


정리

  • try-with-resources로 연 Connection, PreparedStatement, ResultSet은 역순으로 닫히며, 예외가 나도 이미 연 자원은 정리됩니다.
  • 결과는 커서가 열린 동안 불변 PostRow로 모두 복사한 뒤 돌려주고, ResultSet이나 지연 Stream을 밖으로 내보내지 않습니다.
  • 값은 ?에 타입으로 바인딩하고, 테이블과 열, 정렬 방향 같은 구조는 허용 목록의 완성된 SQL에서 고릅니다.
  • setFetchSize는 힌트일 뿐이므로 행 수는 limit으로 제한하고, 한 행을 더 읽어 다음 페이지가 있는지 판단합니다.
  • 키셋 커서는 미리 읽은 행이 아니라 실제로 돌려준 마지막 행으로 만들며, 정렬 키를 둘로 두어 같은 날짜의 행도 구분합니다.

연습 문제

1. 다음 두 페이지 구하기

본문의 다섯 행 예에서 행과 정렬 키는 끝까지 바뀌지 않습니다. 두 번째와 세 번째 응답의 행, 두 번째 커서, 각 페이지의 hasNext를 구하세요. 첫 커서를 남겨 둔 행으로 잘못 잡으면 어떤 행이 빠지는지도 설명하세요.

해설 보기

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

첫 커서를 (7월 13일, 8)로 잡으면 다음 < 조건이 그 행 자체를 빼 버립니다. ID 8은 첫 응답에도 없었으므로 결국 빠집니다. 반대로 마지막으로 돌려준 행을 커서로 삼으면 아직 돌려주지 않은 행부터 이어집니다. 이 다섯 행은 정렬 규칙을 계산하려고 가정한 입력이며, 45행을 쓰는 H2 테스트와는 별개입니다.

2. 다른 회원과 범위 밖 날짜 섞기

지금의 키셋 쿼리 테스트에 회원 42의 행과 조회 범위 밖 날짜의 행을 섞으려고 합니다. 회원 41의 45행만 정확히 한 번씩 돌아오고, 다른 회원과 범위 밖 날짜는 결과에 들어오지 않는지 확인하려면 테스트에 무엇을 더해야 할까요?

해설 보기

seed에서 회원 42의 행 몇 개와, 회원 41이지만 조회 범위의 앞뒤 날짜에 있는 행을 함께 넣습니다. 세 페이지를 모두 읽은 뒤 돌아온 ID의 집합이 회원 41의 범위 안 45행의 ID 집합과 정확히 같은지, 중복이 없는지, 모든 행의 memberId가 41이고 publishedOn이 범위 안인지를 각각 검사합니다. 섞어 넣은 행의 ID가 결과에 하나도 없는지도 따로 확인하면, 조건이 빠졌을 때 어떤 행이 새어 들어왔는지 바로 알 수 있습니다. 페이지마다 활성 연결이 0인지 확인하는 기존 검사는 그대로 둡니다.

다음 글 JDBC CRUD 결과 처리에서는 같은 자원 범위에서 삽입과 갱신, 삭제를 구현하고, 영향받은 행 수와 SQL 예외를 업무의 의미로 해석합니다.