JDBC 리소스 수명주기
Connection·PreparedStatement·ResultSet의 중첩 수명을 관리하고 typed binding·불변 행 매핑·키셋 페이지네이션을 실행으로 검증합니다.
JDBC 쿼리 한 번에는 연결, 구문, 결과 집합이 계층적으로 생성됩니다.
결과 집합은 독립 컬렉션이 아니라 구문과 연결이 열린 동안 드라이버 커서를 읽는 뷰입니다.
이 리소스를 메서드 밖으로 흘리거나 종료를 빠뜨리면 풀 연결이 고갈되고 DB 커서와 메모리가 쌓입니다.
소유한 스코프가 불변 값으로 복사하는 일과 역순 종료까지 책임져야 합니다.
NESTED CONTAINMENT · RESOURCE OWNERSHIP
Connection 안에서 연 자원은 가장 안쪽부터 닫힌다
Connection이 SQL 구문을 소유하고 구문이 cursor를
소유한다. 열린 cursor 안에서 row를 불변 값으로 복사한 뒤
ResultSet → PreparedStatement → Connection 순서로
닫아야 반환된 값만 resource scope 밖으로 나갈 수 있다.
-
OPEN 1 · CLOSE 3
Connection이 전체 DB session을 소유한다
가장 먼저 열고 모든 cursor와 statement가 끝난 뒤 마지막에 닫습니다.
-
OPEN 2 · CLOSE 2
PreparedStatement가 SQL 구조와 typed 값을 소유한다
고정 SQL에 회원 ID·날짜·cursor 값을 binding하고 실행 시간을 제한합니다.
-
OPEN 3 · CLOSE 1
ResultSet cursor 안에서 mapping을 끝낸다
next()가 참인 행만 label getter로 읽어PostRow로 복사합니다. -
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로 모두 복사한 뒤에만 페이지를 반환합니다.
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와 범위를 같은 계약으로 유지합니다.
| Column | DB 계약 | Java mapping |
|---|---|---|
id | identity·positive | long |
member_id | FK·positive | long |
title | NOT NULL·1~80 | String |
content | NOT NULL·1~720 | String |
published_on | NOT NULL | LocalDate |
client_request_id | NOT NULL·8~64 | validated String |
created_at | timestamp with time zone | Instant |
version | NOT NULL·0 이상 | long |
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로 사용하면 같은 날짜의 여러 행도 중복하거나 빠뜨리지 않습니다.
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인지 확인하므로 결과를 복사한 뒤 연결을 반환했다는 계약도 실행으로 남습니다.
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 | 한 행씩 처리해야 하는 stream | connection이 열린 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 예외를 업무 의미로 해석합니다.