JDBC CRUD 결과 처리
canonical 게시글 명령을 JDBC INSERT에 직접 연결하고 생성 키·낙관적 버전·행 수·SQLState를 정확한 결과 계약으로 검증합니다.
JDBC의 변경 SQL은 예외 없이 실행되었다는 것만으로 성공의 의미가 완성되지 않습니다. INSERT는 영향받은 한 행과 생성된 ID를, UPDATE와 DELETE는 행 수를, 실패는 SQLState와 공급자 코드, 제약 조건 정보를 돌려줍니다. 저장소는 이 기술적인 신호를 좁은 애플리케이션 의미로 바꾸되, 0행이나 모르는 예외를 마음대로 분류하지 않아야 합니다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
JDBC 변경 결과는 행 수와 key를 함께 해석한다
INSERT는 정확히 한 행과 한 양수 key를 요구합니다. UPDATE·DELETE는 1행이면 true, 0행이면 false이며 원인을 아직 분류하지 않습니다. SQL 예외도 이 raw repository는 그대로 전파합니다.
INSERT / GENERATED KEY
한 행·한 양수 key 계약
canonical CreatePostCommand와 한 번 정한 createdAt을 직접 바인딩합니다. changed!=1, key 없음, 비양수 key 또는 복수 key이면 SQLException으로 실패합니다. ID=0 같은 가짜 성공을 반환하지 않습니다.
UPDATE / DELETE
회원과 읽은 version을 WHERE에 포함
UPDATE는 version을1 올립니다. actual H2 fixture는 version0 update 성공, 같은 version0의 두 번째 update·delete 실패, version1 delete 성공을 확인합니다. 이것은 순차 검사이며 실제 동시 요청 검사가 아닙니다.
ZERO ROW / ADDITIONAL
false 하나로 stale을 단정하지 않는다
0행은 ID 부재·다른 회원·오래된 version의 후보입니다. 필요한 경우 같은 transaction에서 추가 조회로 구분합니다. 현재 raw repository에는 그 분류나 HTTP 상태 변환 구현이 없습니다.
RAW SQL EXCEPTION
23505를 확인하지만 변환기는 없다
제공된 duplicate fixture는 회원41의 같은 client key에 대한 H2 SQLState=23505를 확인합니다. raw repository는 예외를 그대로 던집니다. 특정 제약·payload를 확인한 replay/conflict 변환과 운영 driver oracle은 별도 범위입니다.
ONE-ROW INVARIANT
예외 없이 실행됐어도 행 수를 검사
UPDATE·DELETE는 1 또는0 외의 행 수를 계약 위반으로 거부합니다. INSERT fixture는 저장한 member·title·content·date·key·createdAt·version0도 직접 조회해 비교합니다.
BATCH POLICY / ADDITIONAL
batch count와 원자성은 다른 계약
한 transaction, savepoint, SUCCESS_NO_INFO·EXECUTE_FAILED 해석과 안전한 재시도는 업무 정책입니다. 이 CRUD fixture가 batch rollback이나 production DB의 예외 체인을 검증하지는 않습니다.
INSERT 실행 뒤 행 수 1과 한 양수 generated key를 검증해 ID를 반환한다. version을 조건으로 사용하는 UPDATE·DELETE는 1행 true, 0행 false를 반환한다. 0행 원인과 SQLState의 애플리케이션 의미는 별도의 서비스 판단이다.
그림의 위쪽은 INSERT를 실행한 뒤 영향받은 행이 1이고 양수인 생성 키가 하나인지 검증해 ID를 돌려주는 흐름입니다. 아래쪽은 ID와 회원, 버전을 조건으로 쓰는 UPDATE와 DELETE가 1행이면 참, 0행이면 거짓을 돌려주는 별도의 흐름이며, 0행의 원인은 아직 분류하지 않습니다. 0행의 원인과 SQLState가 애플리케이션에서 무슨 뜻인지는 서비스가 따로 판단합니다.
| 작업 | 성공 신호 | 따로 판단해야 하는 신호 |
|---|---|---|
| INSERT | 정확히 1행과 양수인 생성 키 | 0행이나 2행 이상, 키 없음 |
| UPDATE | 정확히 1행 | 0행은 없음, 다른 소유자, 오래된 버전의 후보 |
| DELETE | 정확히 1행 | 0행은 없음, 다른 소유자, 오래된 버전의 후보 |
| SQLException | 알려진 SQLState만 좁게 변환 | 모르는 상태는 원인을 보존해 전파 |
INSERT와 기준 명령
저장 직전의 값을 똑같은 모양의 별도 영속성 레코드로 다시 감싸지 않습니다. 앞 장의 CreatePostUseCase.CreatePostCommand가 이미 회원 ID와 제목, 본문, 게시일, 클라이언트 요청 ID를 검증했으므로, 저장소는 그 명령과 서비스가 Clock에서 한 번 만든 Instant를 그대로 받습니다.
package board.jdbc;
import static board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Objects;
import javax.sql.DataSource;
import board.application.DeletePostCommand;
public final class JdbcPostCommandRepository {
private static final String INSERT = """
insert into posts(
member_id, title, content, published_on,
client_request_id, created_at, version)
values (?, ?, ?, ?, ?, ?, 0)
""";
private static final String UPDATE = """
update posts
set title = ?, content = ?, version = version + 1
where id = ? and member_id = ? and version = ?
""";
private static final String DELETE = """
delete from posts
where id = ? and member_id = ? and version = ?
""";
private final DataSource dataSource;
public JdbcPostCommandRepository(DataSource dataSource) {
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
}
public long insert(CreatePostCommand command, Instant createdAt)
throws SQLException {
Objects.requireNonNull(command, "command");
Objects.requireNonNull(createdAt, "createdAt");
try (var connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(
INSERT, Statement.RETURN_GENERATED_KEYS)) {
statement.setLong(1, command.memberId());
statement.setString(2, command.title());
statement.setString(3, command.content());
statement.setObject(4, command.publishedOn());
statement.setString(5, command.clientRequestId());
statement.setObject(6, OffsetDateTime.ofInstant(
createdAt, ZoneOffset.UTC));
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");
}
long id = keys.getLong(1);
if (id <= 0 || keys.next()) {
throw new SQLException(
"expected exactly one positive generated key");
}
return id;
}
}
}
public boolean update(ChangedPost post) throws SQLException {
Objects.requireNonNull(post, "post");
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.memberId());
statement.setLong(5, post.expectedVersion());
return oneOrZero(statement.executeUpdate(), "update");
}
}
public boolean delete(DeletePostCommand command) throws SQLException {
Objects.requireNonNull(command, "command");
try (var connection = dataSource.getConnection();
var statement = connection.prepareStatement(DELETE)) {
statement.setLong(1, command.postId());
statement.setLong(2, command.memberId());
statement.setLong(3, command.expectedVersion());
return oneOrZero(statement.executeUpdate(), "delete");
}
}
private boolean oneOrZero(int changed, String operation)
throws SQLException {
if (changed == 1) {
return true;
}
if (changed == 0) {
return false;
}
throw new SQLException(
operation + " changed more than one row: " + changed);
}
}생성된 키가 없는데 0을 돌려주거나, 키가 여럿인데 첫 값만 고르면 저장소의 규칙이 깨집니다. 정확히 한 행과 정확히 하나의 양수 키를 함께 검증합니다.
이 검사가 실패했다고 앞의 INSERT가 반드시 취소된 것은 아닙니다. 새 JDBC 연결의 기본값인 자동 커밋 상태에서는 DML 구문이 끝날 때 커밋되며, 위 저장소는 여러 작업을 묶는 트랜잭션을 시작하지 않습니다. 행을 삽입한 뒤 생성 키를 검사하다 예외가 나면, DB에 반영되었는지와 애플리케이션이 성공을 돌려주었는지를 따로 확인해야 합니다. 여러 변경을 원자적으로 묶어야 할 때 같은 연결의 트랜잭션을 쓰는 방법은 수동 JDBC 트랜잭션에서 다룹니다. Java Connection의 자동 커밋 규칙에 따라, 오류 응답을 받은 뒤 새 요청 키로 바로 INSERT를 다시 시도하지 않습니다. 바뀌지 않는 요청 키로 기존 행과 원래 내용을 확인하는 정책이 필요합니다.
낙관적 UPDATE
두 요청이 버전 3을 읽고 각각 고치면 첫 UPDATE가 버전을 4로 올립니다. 그러면 두 번째 요청의 where version = 3은 0행이 되어, 앞의 수정을 조용히 덮어쓰는 갱신 손실(lost update)을 만들지 않습니다.
package board.jdbc;
public record ChangedPost(
long id,
long memberId,
String title,
String content,
long expectedVersion
) {
public ChangedPost {
if (id <= 0 || memberId <= 0 || expectedVersion < 0) {
throw new IllegalArgumentException(
"invalid identity or version");
}
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");
}
}
}갱신된 행이 0이라는 것은 ID가 없거나, 다른 회원의 것이거나, 버전이 오래되었다는 뜻일 수 있습니다. 안에서 원인을 구별해야 한다면 존재와 회원, 버전의 조회를 같은 트랜잭션 안에서 하되, 추가 조회 사이의 동시 변경을 막을지 허용할지 잠금과 격리 정책도 정합니다. 예를 들어 READ_COMMITTED에서는 같은 트랜잭션이라고 모든 구문이 같은 과거 상태를 보는 것이 아니므로, 변경 뒤에 읽은 현재 행이 실패했을 때의 상태와 반드시 같다고 가정하지 않습니다. 저장소는 0행 하나만 보고 HTTP 상태나 도메인 예외를 추측하지 않습니다.
버전을 포함한 DELETE
삭제도 읽었던 상태와 버전이 같은지 확인해야 합니다. ID와 회원만 조건으로 쓰면, 다른 요청이 내용을 바꾼 뒤에도 오래된 화면에서 보낸 삭제가 성공할 수 있습니다.
package board.application;
public record DeletePostCommand(
long postId,
long memberId,
long expectedVersion
) {
public DeletePostCommand {
if (postId <= 0 || memberId <= 0 || expectedVersion < 0) {
throw new IllegalArgumentException(
"invalid delete command");
}
}
}바깥 응답에서는 게시글이 없거나 요청한 회원의 것이 아닌 경우를 모두 404로 나타낼 수 있지만, 서비스의 추가 조회와 저장소의 변경은 한 트랜잭션에 있어야 합니다.
SQLException의 예외 연쇄
SQLException에는 SQLState와 공급자 오류 코드, 원인, getNextException() 체인이 있습니다. 번역된 메시지 문구를 부분 문자열로 비교해 중복을 판단하지 않습니다.
| 기술적 신호 | 뜻할 수 있는 것 | 안전한 처리 |
|---|---|---|
| 유일성 위반 | 멱등성 키의 중복 | 제약 이름과 기존 내용을 확인한 뒤 재생할지 충돌로 볼지 결정 |
| 갱신된 행 0 | 없음, 다른 소유자, 오래된 버전 | 같은 트랜잭션의 추가 조회로만 구별 |
| 모르는 SQLState | DB나 드라이버의 실패 | 원래 원인을 보존해 위로 전파 |
Spring의 예외 변환을 쓰면 공급자마다 다른 코드를 DataIntegrityViolationException, DuplicateKeyException 같은 일관된 계층으로 바꿀 수 있습니다. 원시 JDBC만 쓴다면 특정 데이터베이스의 SQLState를 공식 문서와 통합 테스트로 확인합니다. 로그에는 SQL 템플릿 ID와 SQLState, 공급자 코드, 제약 조건 이름을 남기되, 비밀번호나 게시글 본문 같은 파라미터는 출력하지 않습니다.
생성 키와 버전, 멱등성의 검증
테스트는 장 전체의 schema.sql을 그대로 실행하며, 따로 줄인 테이블을 만들지 않습니다. setUp()은 테스트마다 H2 메모리 DB의 객체를 모두 지운 뒤 ResourceDatabasePopulator로 스키마를 다시 만들고, 게시글의 작성자가 될 회원 41과 42를 넣습니다. 그래서 각 테스트는 앞 테스트가 남긴 행이나 생성 키의 값에 기대지 않고 같은 상태에서 시작합니다.
package board.jdbc;
import static board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import board.application.DeletePostCommand;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
class JdbcCrudContractTest {
private static final Instant CREATED_AT =
Instant.parse("2026-07-14T01:02:03Z");
private DriverManagerDataSource dataSource;
private JdbcPostCommandRepository repository;
@BeforeEach
void setUp() throws Exception {
dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:crud;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
"sa",
"");
try (var connection = dataSource.getConnection();
var statement = connection.createStatement()) {
statement.execute("drop all objects");
}
new ResourceDatabasePopulator(
new ClassPathResource("schema.sql"))
.execute(dataSource);
try (var connection = dataSource.getConnection();
var statement = connection.prepareStatement("""
insert into members(
id, email, password_hash, name)
values (?, ?, ?, ?), (?, ?, ?, ?)
""")) {
statement.setLong(1, 41L);
statement.setString(2, "[email protected]");
statement.setString(3, "hash-41");
statement.setString(4, "회원 41");
statement.setLong(5, 42L);
statement.setString(6, "[email protected]");
statement.setString(7, "hash-42");
statement.setString(8, "회원 42");
assertThat(statement.executeUpdate()).isEqualTo(2);
}
repository = new JdbcPostCommandRepository(dataSource);
}
@Test
void insert는_command와_createdAt을_보존하고_key를_반환한다()
throws Exception {
var command = command(41L, "request-0001", "JDBC 본문");
long id = repository.insert(command, CREATED_AT);
assertThat(id).isPositive();
try (var connection = dataSource.getConnection();
var statement = connection.prepareStatement("""
select member_id, title, content, published_on,
client_request_id, created_at, version
from posts
where id = ?
""")) {
statement.setLong(1, id);
try (var result = statement.executeQuery()) {
assertThat(result.next()).isTrue();
assertThat(result.getLong("member_id")).isEqualTo(41L);
assertThat(result.getString("title")).isEqualTo("JDBC");
assertThat(result.getString("content"))
.isEqualTo("JDBC 본문");
assertThat(result.getObject(
"published_on", LocalDate.class))
.isEqualTo(LocalDate.of(2026, 7, 14));
assertThat(result.getString("client_request_id"))
.isEqualTo("request-0001");
assertThat(result.getObject(
"created_at", OffsetDateTime.class).toInstant())
.isEqualTo(CREATED_AT);
assertThat(result.getLong("version")).isZero();
assertThat(result.next()).isFalse();
}
}
}
@Test
void update와_delete는_expectedVersion을_조건으로_사용한다()
throws Exception {
long id = repository.insert(
command(41L, "request-0002", "원본"), CREATED_AT);
boolean first = repository.update(new ChangedPost(
id, 41L, "JDBC 수정", "첫 번째 수정", 0L));
boolean staleUpdate = repository.update(new ChangedPost(
id, 41L, "JDBC 수정", "오래된 수정", 0L));
boolean staleDelete = repository.delete(
new DeletePostCommand(id, 41L, 0L));
boolean currentDelete = repository.delete(
new DeletePostCommand(id, 41L, 1L));
assertThat(first).isTrue();
assertThat(staleUpdate).isFalse();
assertThat(staleDelete).isFalse();
assertThat(currentDelete).isTrue();
}
@Test
void 같은_member와_clientRequestId는_SQLState_23505로_실패한다()
throws Exception {
var first = command(41L, "request-0003", "첫 payload");
long firstId = repository.insert(first, CREATED_AT);
long otherMemberId = repository.insert(
command(42L, "request-0003", "다른 회원 payload"),
CREATED_AT);
var thrown = catchThrowable(
() -> repository.insert(first, CREATED_AT));
assertThat(firstId).isPositive();
assertThat(otherMemberId).isPositive().isNotEqualTo(firstId);
assertThat(thrown).isInstanceOf(SQLException.class);
assertThat(((SQLException) thrown).getSQLState())
.isEqualTo("23505");
}
private CreatePostCommand command(
long memberId,
String requestId,
String content
) {
return new CreatePostCommand(
memberId,
"JDBC",
content,
LocalDate.of(2026, 7, 14),
requestId);
}
}H2의 23505는 이 예제에서 유일성 제약이 실제로 동작했다는 것을 보여 줍니다. 운영 드라이버의 SQLState와 제약 이름은 별도의 컨테이너 통합 테스트에서 고정합니다.
배치 처리의 결과
executeBatch는 왕복 횟수를 줄여 주지만, 일부가 실패했을 때 제품이 어떻게 동작해야 하는지는 정해 주지 않습니다.
| 정책 | 알맞은 요구 | 반드시 남길 결과 |
|---|---|---|
| 하나의 트랜잭션 | 일부만 성공하는 것을 허용하지 않음 | 한 행이 실패하면 전체를 롤백 |
| 저장점(savepoint)으로 격리 | 일부 행의 성공을 허용 | 행별 성공·실패 순번과 바뀌지 않는 코드 |
| 갱신 수 해석 | 드라이버가 배치의 행 수를 돌려줌 | SUCCESS_NO_INFO와 EXECUTE_FAILED의 구별 |
| 멱등성 키 | 타임아웃 뒤의 안전한 재시도 | 이미 반영된 행을 중복해서 만들지 않음 |
자동 커밋 상태에서 배치를 실행하고, 앞쪽 행이 반영되었는지 추측하지 않습니다. 업무가 일부만 성공하는 것을 허용하지 않는다면 하나의 트랜잭션으로 감싸고, 어느 행이든 실패하면 전체를 롤백합니다.
더 깊이: 배치가 중간에 실패하면 갱신 수 배열은 어떻게 생길까
JDBC 명세는 배치의 한 명령이 실패했을 때 드라이버가 나머지 명령을 계속 실행할지 정하지 않았습니다. Statement.executeBatch의 API 문서는 드라이버가 계속할 수도 멈출 수도 있다고 하면서, 대신 한 DBMS에 대해서는 언제나 계속하거나 언제나 멈추는 식으로 일관되어야 한다고만 정합니다. 그래서 BatchUpdateException.getUpdateCounts()가 돌려주는 배열의 모양이 드라이버에 따라 다릅니다. 멈추는 드라이버라면 배열에는 실패 전에 성공한 명령의 갱신 수만 들어 있고, 계속하는 드라이버라면 배치의 명령 수만큼 원소가 있으며 실패한 자리에 EXECUTE_FAILED가 들어갑니다.
예를 들어 다섯 행을 넣는 배치에서 세 번째 행이 유일성 제약에 걸렸다고 합시다. 멈추는 드라이버의 배열은 [1, 1]처럼 길이가 2이고, 계속하는 드라이버의 배열은 세 번째 원소만 EXECUTE_FAILED인 길이 5의 배열이 됩니다. 앞의 경우에는 네 번째와 다섯 번째 행이 실행되지 않았다는 것도 배열의 길이로만 알 수 있습니다. 또 성공한 원소가 실제 행 수가 아니라 SUCCESS_NO_INFO일 수도 있으므로, 실패한 순번을 찾을 때는 배열의 길이와 세 가지 값을 모두 처리해야 합니다. 자동 커밋 상태라면 그 앞의 행은 이미 커밋되었을 수 있으니, 이 배열은 무엇을 다시 보낼지 정하는 입력이지 롤백을 대신하지 않습니다.
정리
- INSERT는 정확히 한 행과 하나의 양수 생성 키를 함께 확인하며, 키가 없을 때 0이나 첫 값을 돌려주지 않습니다.
- 자동 커밋 상태에서는 키 검사가 실패해도 행이 이미 커밋되었을 수 있으므로, 같은 요청 키로 기존 행을 확인한 뒤 재시도합니다.
- UPDATE와 DELETE는 읽었던 버전을 조건에 넣어 갱신 손실을 막고, 0행의 원인은 같은 트랜잭션의 추가 조회로만 구별합니다.
SQLException은 메시지 문구가 아니라 SQLState와 공급자 코드, 제약 이름으로 판단하며, 모르는 상태는 원인을 보존해 전파합니다.- 배치는 일부 실패의 의미를 정해 주지 않으므로, 트랜잭션과 저장점, 갱신 수 해석, 멱등성 키 가운데 업무에 맞는 정책을 고릅니다.
연습 문제
1. 0행 UPDATE를 서비스에서 분류하기
0행인 UPDATE를 서비스에서 게시글 없음과 다른 회원, 오래된 버전으로 나누려고 합니다. 현재 행의 조회와 분류가 저장소의 변경과 같은 트랜잭션 안에 있는지, 다른 회원의 요청에는 현재 버전을 알려 주지 않는지 어떻게 검증하나요?
해설 보기
서비스 메서드 하나를 트랜잭션으로 묶고, 그 안에서 UPDATE가 0행이면 같은 연결로 id의 현재 행을 조회합니다. 행이 없으면 없음, 회원이 다르면 다른 소유자, 회원은 같은데 버전이 다르면 오래된 버전으로 분류합니다. 다른 회원의 경우에는 존재를 숨기려고 없음과 같은 결과로 바꾸고, 응답에 현재 버전을 넣지 않습니다.
테스트에서는 세 경우를 각각 만들어 분류 결과를 확인하고, 조회와 UPDATE가 같은 연결에서 실행되었는지를 연결 기록이나 트랜잭션 동기화로 검사합니다. 다른 회원의 응답 본문에 버전 값이 없는지도 확인합니다. 격리 수준에 따라 추가 조회 사이에 다른 변경이 끼어들 수 있으므로, 필요하다면 SELECT ... FOR UPDATE 같은 잠금 정책도 함께 정합니다.
2. 반환 실패와 저장 실패 구분하기
새 연결의 autoCommit=true를 그대로 둔 INSERT가 구문이 끝날 때 커밋되었다고 가정합니다. 그 뒤의 생성 키 검사에서 키가 없다는 예외가 났습니다. 호출한 쪽이 오류를 받았으니 DB에는 0행이라고 답할 수 있나요? 같은 논리적 요청을 새 clientRequestId로 보내는 선택과 원래 키로 확인하는 선택을 비교하세요.
해설 보기
이 조건에서는 행이 이미 커밋되었으므로, 오류 응답만 보고 0행이라고 판단할 수 없습니다. 새 키로 보내면 새 논리적 요청으로 취급되어 또 삽입될 수 있습니다. 원래 회원과 요청 키의 행을 조회해 내용이 같은지 확인한 뒤, 재생 응답을 줄지 충돌로 볼지 결정합니다. 이것은 생성 키를 돌려주지 않는 드라이버를 실제로 실행한 결과가 아니라, 저장 결과와 반환 규칙을 나누어 생각하기 위한 가정입니다.
다음 글 DataSource·커넥션 풀에서는 CRUD마다 물리 연결을 만드는 비용을 줄이는 DataSource와 연결 풀의 반환과 고갈, 검증 정책을 다룹니다.