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

안동민 개발노트

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

JDBC CRUD 결과 처리

PreparedStatement로 게시판 쓰기를 구현하고 생성 키·갱신 개수·고유 제약·SQLState를 확인합니다.

JDBC 변경 SQL은 실행됐다는 사실만으로 성공 의미가 완성되지 않습니다.

삽입은 생성된 ID, UPDATE와 삭제는 영향받은 행 수, 실패는 SQLState·공급자 코드·제약 조건 정보를 반환합니다.

리포지토리는 이 기술 신호를 saved, not found, version conflict, duplicate 같은 애플리케이션 의미로 바꾸되 잘 모르는 예외를 억지로 분류하지 않아야 합니다.


INSERT와 생성 키

ID 동일성 열을 쓰면 구문 생성 시 생성된 키 반환을 요청하고 삽입 성공 뒤 키를 읽습니다.

키가 없는데 임의로 0을 반환하지 않습니다.

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

import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;

import javax.sql.DataSource;

import java.time.LocalDate;

public final class JdbcPostCommandRepository {
    private static final String INSERT = """
            insert into posts(
                author_id, title, content, created_on, version)
            values (?, ?, ?, ?, 0)
            """;
    private static final String UPDATE = """
            update posts
               set title = ?, content = ?, version = version + 1
             where id = ? and author_id = ? and version = ?
            """;
    private static final String DELETE = """
            delete from posts
             where id = ? and author_id = ?
            """;

    private final DataSource dataSource;

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

    public long insert(NewPost post) throws SQLException {
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(
                     INSERT, Statement.RETURN_GENERATED_KEYS)) {
            statement.setLong(1, post.authorId());
            statement.setString(2, post.title());
            statement.setString(3, post.content());
            statement.setDate(4, Date.valueOf(post.createdOn()));
            int changed = statement.executeUpdate();
            if (changed != 1) {
                throw new SQLException(
                        "expected one inserted row, actual=" + changed);
            }
            try (var keys = statement.getGeneratedKeys()) {
                if (!keys.next()) {
                    throw new SQLException("generated key was not returned");
                }
                return keys.getLong(1);
            }
        }
    }

    public boolean update(ChangedPost post) throws SQLException {
        try (var connection = dataSource.getConnection();
             var statement = connection.prepareStatement(UPDATE)) {
            statement.setString(1, post.title());
            statement.setString(2, post.content());
            statement.setLong(3, post.id());
            statement.setLong(4, post.authorId());
            statement.setLong(5, post.expectedVersion());
            return statement.executeUpdate() == 1;
        }
    }

    public boolean delete(long id, long authorId) throws SQLException {
        try (var connection = dataSource.getConnection();
             var statement = connection.prepareStatement(DELETE)) {
            statement.setLong(1, id);
            statement.setLong(2, authorId);
            return statement.executeUpdate() == 1;
        }
    }
}
src/main/java/board/jdbc/NewPost.java
package board.jdbc;

import java.time.LocalDate;

public record NewPost(
        long authorId,
        String title,
        String content,
        LocalDate createdOn
) {
    public NewPost {
        if (authorId <= 0 || title == null || title.isBlank()) {
            throw new IllegalArgumentException("member and title required");
        }
        if (content == null || content.isBlank()
                || content.length() > 5_000 || createdOn == null) {
            throw new IllegalArgumentException("invalid post values");
        }
    }
}

갱신 개수가 0이면 ID가 없거나 소유자가 다르거나 버전이 충돌한 경우입니다.

보안을 위해 외부에는 모두 찾을 수 없음으로 보일 수 있지만 내부에서 버전 충돌을 구별해야 하면 존재·버전 조회를 같은 트랜잭션 안에서 수행합니다.

갱신 개수만으로 원인을 추측하지 않습니다.


낙관적 갱신

두 요청이 버전 3을 읽고 각각 수정하면 첫 UPDATE가 버전을 4로 올리고 두 번째의 where version=3은 0 행이 됩니다.

버전 조건이 없으면 두 번째 요청이 첫 변경을 조용히 덮습니다.

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

public record ChangedPost(
        long id,
        long authorId,
        String title,
        String content,
        long expectedVersion
) {
    public ChangedPost {
        if (id <= 0 || authorId <= 0 || expectedVersion < 0) {
            throw new IllegalArgumentException("invalid identity or version");
        }
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title required");
        }
        if (content == null || content.isBlank()
                || content.length() > 5_000) {
            throw new IllegalArgumentException("invalid content");
        }
    }
}

애플리케이션 서비스는 falsePostVersionConflictException 또는 정책상 찾을 수 없음으로 변환합니다.

리포지토리가 HTTP 상태를 알 필요는 없습니다.

버전을 응답 ETag로 노출했다면 If-Match와 연결해 오래된 클라이언트를 412 사전 조건 실패로 표현할 수도 있습니다.


SQLException 예외 연쇄

SQLException에는 SQLState, 공급자 오류 코드, 원인, getNextException() 체인이 있습니다.

메시지 문자열의 번역된 문구를 부분 문자열로 비교해 중복을 판단하지 않습니다.

Spring의 예외 변환을 쓰면 공급자별 코드를 DataIntegrityViolationException, DuplicateKeyException 같은 일관된 계층으로 바꿀 수 있으며 ch10에서 다룹니다.

원시 JDBC만 사용한다면 특정 데이터베이스의 고유 위반 SQLState를 공식 문서와 통합 테스트로 확인합니다.

PostgreSQL은 클래스 23 무결성 제약 조건 위반 아래 세부 상태를 제공하지만 다른 DB와 H2가 같다고 가정하지 않습니다.

알 수 없는 SQLException은 원본을 원인로 보존해 위로 전달합니다.

로그에는 SQL 템플릿 ID, SQLState, 공급자 코드, 제약 조건 이름을 남기되 결합된 비밀번호와 개인 데이터를 출력하지 않습니다.

SQL 전체를 디버그 로그에 항상 남기면 게시글 제목이나 본문이 파라미터 로그로 새어 나갈 수 있습니다.


생성 키·버전·고유 제약 검증

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

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

import java.sql.SQLException;
import java.time.LocalDate;

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

class JdbcCrudContractTest {
    private JdbcPostCommandRepository repository;

    @BeforeEach
    void setUp() throws Exception {
        var dataSource = new JdbcDataSource();
        dataSource.setURL("jdbc:h2:mem:crud;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        try (var connection = dataSource.getConnection();
             var statement = connection.createStatement()) {
            statement.execute("drop table if exists posts");
            statement.execute("""
                    create table posts(
                      id bigint generated by default as identity 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,
                      constraint uq_post unique(
                        author_id, created_on, title))
                    """);
        }
        repository = new JdbcPostCommandRepository(dataSource);
    }

    @Test
    void 생성_key와_낡은_version과_unique_constraint를_구별한다()
            throws Exception {
        var draft = new NewPost(
                41L, "JDBC", "JDBC 본문을 저장합니다.",
                LocalDate.parse("2026-07-14"));
        long id = repository.insert(draft);

        boolean first = repository.update(new ChangedPost(
                id, 41L, "JDBC", "첫 번째 수정 본문", 0L));
        boolean stale = repository.update(new ChangedPost(
                id, 41L, "JDBC", "오래된 버전의 수정 본문", 0L));

        assertThat(id).isPositive();
        assertThat(first).isTrue();
        assertThat(stale).isFalse();
        assertThatThrownBy(() -> repository.insert(draft))
                .isInstanceOf(SQLException.class);
    }
}
CRUD 실행 결과
generated id > 0 = true
update expected version 0 = 1 row
second update expected version 0 = 0 rows
duplicate insert = SQLException
lost update prevented = true

테스트는 중복 SQLState까지 고정하려면 실제 운영 환경 데이터베이스 컨테이너에서 별도로 실행합니다.

H2 테스트는 리포지토리 흐름을 빠르게 확인하지만 공급자 변환 규칙을 대신하지 않습니다.


배치 처리 결과

addBatchexecuteBatch는 네트워크 왕복을 줄일 수 있지만 중간 행 실패 시 앞 행이 커밋됐는지는 자동 커밋과 드라이버 동작에 따라 달라집니다.

트랜잭션 안에서 실행하고 반환된 개수 배열의 SUCCESS_NO_INFO, EXECUTE_FAILED도 처리합니다.

대량 가져오기에서 한 행 실패를 전체 롤백할지, 저장점 이후 해당 행만 격리할지 제품 정책을 정합니다.

오류 행을 건너뛰면서 성공이라고만 반환하면 사용자는 일부 데이터가 빠진 사실을 모릅니다.

성공·실패 인덱스와 안정적 코드를 보고서로 제공합니다.


연습 문제

회원이 소유한 게시글 하나를 버전과 함께 삭제하세요.

존재하지 않음, 다른 회원, 오래된 버전을 외부에는 어떤 상태로 구분할지 정하고, 리포지토리의 갱신 개수와 서비스의 추가 조회가 같은 트랜잭션 안에 있도록 만드세요.

해설 보기

SQL은 delete where id=? and author_id=? and version=?로 한 행만 지웁니다.

개수가 0이면 서비스가 현재 행을 조회해 존재·책임 주체·버전을 분류할 수 있지만 리소스 열거 공격을 막기 위해 외부에는 404로 통일할 수 있습니다.

package board.application;

public record DeletePostCommand(
        long postId,
        long authorId,
        long expectedVersion
) {
    public DeletePostCommand {
        if (postId <= 0 || authorId <= 0 || expectedVersion < 0) {
            throw new IllegalArgumentException("invalid delete command");
        }
    }
}

동시 테스트는 한 트랜잭션이 버전을 올린 뒤 오래된 삭제가 0 행인지 확인합니다.

다른 회원 요청에서 현재 버전을 응답에 노출하지 않습니다.

다음 문서에서는 매 CRUD마다 물리 연결을 만드는 비용을 줄이는 DataSource와 연결 풀의 반환·고갈·검증 정책을 다룹니다.