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

안동민 개발노트

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

JDBC 리소스 수명주기

try-with-resources로 JDBC 수명을 관리하고 게시글 조회의 바인딩·커서·매핑을 검증합니다.

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

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

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

소유한 스코프가 역순 종료까지 책임져야 합니다.


JDBC 자원 종료 순서

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

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

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

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

import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

import javax.sql.DataSource;

public final class JdbcPostQuery {
    private static final String FIND_BY_MEMBER_AND_DATE = """
            select id, author_id, title, content, created_on, version
              from posts
             where author_id = ?
               and created_on between ? and ?
             order by created_on desc, id desc
            """;

    private final DataSource dataSource;

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

    public List<PostRow> find(
            long authorId,
            LocalDate from,
            LocalDate to
    ) throws SQLException {
        var rows = new ArrayList<PostRow>();
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(
                     FIND_BY_MEMBER_AND_DATE)) {
            statement.setLong(1, authorId);
            statement.setDate(2, Date.valueOf(from));
            statement.setDate(3, Date.valueOf(to));
            statement.setFetchSize(100);
            try (ResultSet result = statement.executeQuery()) {
                while (result.next()) {
                    rows.add(map(result));
                }
            }
        }
        return List.copyOf(rows);
    }

    private PostRow map(ResultSet result) throws SQLException {
        return new PostRow(
                result.getLong("id"),
                result.getLong("author_id"),
                result.getString("title"),
                result.getString("content"),
                result.getDate("created_on").toLocalDate(),
                result.getLong("version"));
    }
}

메서드는 ResultSet이나 지연 스트림을 반환하지 않고 연결이 열린 범위 안에서 불변 행으로 복사합니다.

데이터가 너무 커 즉시 목록으로 만들기 어렵다면 리포지토리 메서드가 호출자가 제공한 소비자를 연결 범위 안에서 호출하도록 하거나 페이지네이션·커서 API를 설계합니다.

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


PreparedStatement 구조 분리

authorId와 날짜를 문자열 연결로 SQL에 넣지 않습니다.

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

테이블·열·ORDER BY 방향 같은 SQL 식별자는 ?로 바인딩할 수 없으므로 허용 목록에서 완성된 조각을 선택합니다.

날짜는 java.sql.Date.valueOf(LocalDate) 또는 JDBC 4.2 setObject 지원을 드라이버별로 확인합니다.

타임스탬프와 시간대는 더 복잡합니다.

DB 열이 특정 시점을 나타내는지 로컬 벽시계 시간을 나타내는지 먼저 결정하고 OffsetDateTime, Instant, 시간대 변환 경계를 테스트합니다.

setFetchSize(100)은 드라이버에 대한 힌트이며 100행만 반환한다는 제한이 아닙니다.

실제 결과 제한은 SQL limit과 페이지네이션 커서로 정합니다.

일부 드라이버는 자동 커밋 설정과 가져오기 크기 조합에 따라 서버 측 커서 동작이 달라집니다.


ResultSet 커서

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

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

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

getIntgetLong은 SQL NULL을 0으로 반환할 수 있으므로 null 허용 열은 wasNull() 또는 getObject(label, Integer.class)를 사용합니다.

게시글의 content와 ID는 NOT NULL이며 본문은 getString으로 읽습니다.

널 허용 여부를 스키마, Java 타입, 매퍼에서 일치시킵니다.

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

import java.time.LocalDate;

public record PostRow(
        long id,
        long authorId,
        String title,
        String content,
        LocalDate createdOn,
        long version
) {
    public PostRow {
        if (id <= 0 || authorId <= 0) {
            throw new IllegalArgumentException("positive ids required");
        }
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title required");
        }
        if (content == null || content.isBlank()
                || content.length() > 5_000) {
            throw new IllegalArgumentException("invalid content");
        }
    }
}

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

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

운영에서는 문제가 된 기본 키와 제약 조건을 민감도에 맞게 로그하고 마이그레이션 또는 데이터 복구로 해결합니다.


자원 종료·쿼리 순서

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

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

import java.sql.Date;
import java.time.LocalDate;

import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;

class JdbcPostQueryTest {
    @Test
    void 조회는_row를_복사한_뒤_connection을_반환한다()
            throws Exception {
        var dataSource = new JdbcDataSource();
        dataSource.setURL("jdbc:h2:mem:lifetime;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        try (var connection = dataSource.getConnection();
             var statement = connection.createStatement()) {
            statement.execute("""
                    create table posts(
                      id bigint primary key,
                      author_id bigint not null,
                      title varchar(80) not null,
                      content varchar(5000) not null,
                      created_on date not null,
                      version bigint not null)
                    """);
            try (var insert = connection.prepareStatement(
                    "insert into posts values(?,?,?,?,?,?)")) {
                insert.setLong(1, 1L);
                insert.setLong(2, 41L);
                insert.setString(3, "JDBC");
                insert.setString(4, "JDBC 본문을 저장합니다.");
                insert.setDate(5, Date.valueOf("2026-07-14"));
                insert.setLong(6, 0L);
                insert.executeUpdate();
            }
        }

        var rows = new JdbcPostQuery(dataSource).find(
                41L,
                LocalDate.parse("2026-07-01"),
                LocalDate.parse("2026-07-31"));

        assertThat(rows).containsExactly(new PostRow(
                1L, 41L, "JDBC", "JDBC 본문을 저장합니다.",
                LocalDate.parse("2026-07-14"), 0L));
    }
}
resource scope 실행 결과
rows read = 1
first title = JDBC
result set closed = true
prepared statement closed = true
connection returned = true
test = PASSED

H2 데이터 소스는 풀이 아니므로 종료는 물리 연결을 닫습니다.

풀 환경에서는 프록시 연결의 종료가 내부 물리 연결을 풀에 반환합니다.

둘 다 리포지토리가 종료를 호출해야 한다는 계약은 같습니다.


대용량 결과 제한

try-with-resources 구문은 완료 뒤 정리하지만 무한히 느린 쿼리를 자동 취소하지 않습니다.

구문 쿼리 타임아웃, 드라이버 소켓 타임아웃, 트랜잭션 타임아웃을 계층별로 설정합니다.

클라이언트가 연결을 끊었을 때 DB 쿼리를 취소할 수 있는지도 프레임워크와 드라이버에 따라 확인합니다.

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

(created_on, id) 키셋 커서를 사용하면 마지막으로 본 키 뒤를 안정적으로 조회할 수 있습니다.

정렬 키가 고유하지 않으면 행이 중복되거나 빠지므로 ID를 최종 비교 기준으로 포함합니다.


연습 문제

회원의 게시글을 (created_on desc, id desc) 기준으로 20개씩 키셋 페이지네이션하세요.

마지막 날짜와 ID를 파라미터로 바인딩하고 같은 날짜 행이 여러 개여도 중복·누락이 없는지 45개 데이터로 테스트합니다.

해설 보기

첫 페이지는 커서 조건 없이 제한 21을 조회해 다음 페이지 여부를 판단합니다.

다음 페이지는 튜플 비교를 지원하는 DB이면 (created_on, id) < (?, ?)를, 그렇지 않으면 날짜와 ID 조건을 명시적으로 풀어 씁니다.

package board.jdbc;

import java.time.LocalDate;

public record PostCursor(LocalDate createdOn, long id) {
    public PostCursor {
        if (createdOn == null || id <= 0) {
            throw new IllegalArgumentException("valid cursor required");
        }
    }
}

동일 날짜 25개와 앞뒤 날짜 행을 섞고 모든 페이지 ID를 집합에 모읍니다.

전체 45개가 정확히 한 번씩 나타나는지, 마지막 페이지의 다음 커서가 없는지 확인합니다.

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