JDBC CRUD 결과 처리
canonical 게시글 명령을 JDBC INSERT에 직접 연결하고 생성 키·낙관적 버전·행 수·SQLState를 정확한 결과 계약으로 검증합니다.
JDBC 변경 SQL은 예외 없이 실행됐다는 사실만으로 성공 의미가 완성되지 않습니다.
INSERT는 영향받은 한 행과 생성된 ID, UPDATE와 DELETE는 행 수, 실패는 SQLState·공급자 코드·제약 조건 정보를 반환합니다.
리포지토리는 기술 신호를 좁은 애플리케이션 의미로 바꾸되 0행이나 모르는 예외를 임의로 분류하지 않아야 합니다.
FLOWCHART · MUTATION RESULT CONTRACT
JDBC 쓰기는 예외·행 수·생성 키 순서로 결과 의미를 좁힌다
예외가 있으면 SQLState와 원인을 먼저 보존한다. 예외가 없더라도 INSERT는 한 행과 한 generated key를 함께 확인하고, UPDATE·DELETE는 행 수를 해석한다. 특히 0행은 stale·missing·owner mismatch 중 무엇인지 아직 결정되지 않은 기술 신호다.
-
EXECUTE
PreparedStatement 변경을 실행한다
예외가 없다는 사실만으로 저장 성공을 선언하지 않습니다.
-
SQL EXCEPTION
SQLState와 원인을 먼저 보존한다
알려진 제약 위반만 좁게 변환하고 모르는 상태는 원본 cause와 함께 전파합니다.
-
INSERT
한 행과 한 양수 generated key를 함께 요구한다
0·2행 이상 또는 key 부재는 repository 불변식 위반으로 실패합니다.
-
UPDATE · DELETE
영향받은 행 수를 분류한다
- 1행: 변경 성공
- 0행: missing·owner mismatch·stale 중 아직 미분류
- 2행 이상: 한 행 변경 계약 위반
-
ZERO ROW
필요할 때만 같은 transaction에서 다시 읽는다
추가 조회 전에는 0행을 특정 HTTP 상태나 domain exception으로 추측하지 않습니다.
version predicate는 lost update를 0행으로 드러내지만 0행 하나만으로 stale을 증명하지는 않는다. 존재·소유자·현재 version을 구별하려면 같은 transaction의 추가 조회가 필요하다.
| 작업 | 성공 신호 | 별도 판단이 필요한 신호 |
|---|---|---|
| INSERT | 정확히 1행 + 양수 generated key | 0·2행 이상, key 부재 |
| UPDATE | 정확히 1행 | 0행은 missing·owner mismatch·stale 후보 |
| DELETE | 정확히 1행 | 0행은 missing·owner mismatch·stale 후보 |
| SQLException | 알려진 SQLState를 좁게 변환 | 모르는 상태는 원인을 보존해 전파 |
INSERT와 canonical command
저장 직전 값을 똑같은 모양의 별도 persistence record로 다시 감싸지 않습니다.
앞 장의 CreatePostUseCase.CreatePostCommand가 이미 회원 ID, 제목, 본문, 게시일, client request ID를 검증합니다.
repository는 그 명령과 service가 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);
}
}생성된 key가 없는데 0을 반환하거나 여러 key 중 첫 값만 고르면 repository 계약이 손상됩니다.
정확히 한 행과 정확히 한 양수 key를 함께 검증합니다.
낙관적 UPDATE
두 요청이 version 3을 읽고 각각 수정하면 첫 UPDATE가 version을 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 부재, 다른 회원, 낡은 version 중 하나일 수 있습니다.
내부에서 원인을 구별해야 한다면 존재·회원·version 조회를 같은 transaction 안에서 수행합니다.
리포지토리는 0행 하나만 보고 HTTP 상태나 domain exception을 추측하지 않습니다.
version을 포함한 DELETE
삭제도 읽었던 상태와 같은 version인지 확인해야 합니다.
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로 보일 수 있지만 service의 추가 조회와 repository 변경은 한 transaction에 있어야 합니다.
SQLException 예외 연쇄
SQLException에는 SQLState, 공급자 오류 코드, 원인, getNextException() 체인이 있습니다.
메시지의 번역된 문구를 부분 문자열로 비교해 중복을 판단하지 않습니다.
| 기술 신호 | 가능한 의미 | 안전한 처리 |
|---|---|---|
| unique violation | idempotency key 중복 | 제약 이름과 기존 payload를 확인한 뒤 replay·conflict 결정 |
| 0 update rows | missing·owner mismatch·stale | 같은 transaction의 추가 조회로만 구별 |
| unknown SQLState | DB·driver failure | 원본 원인을 보존해 상위로 전파 |
Spring의 예외 변환을 쓰면 공급자별 코드를 DataIntegrityViolationException, DuplicateKeyException 같은 일관된 계층으로 바꿀 수 있습니다.
원시 JDBC만 사용한다면 특정 데이터베이스의 SQLState를 공식 문서와 통합 테스트로 확인합니다.
로그에는 SQL template ID, SQLState, 공급자 코드, 제약 조건 이름을 남기되 비밀번호·게시글 본문 같은 파라미터를 출력하지 않습니다.
생성 key·version·idempotency 검증
테스트는 장 전체 schema.sql을 그대로 실행하고 별도 축약 테이블을 만들지 않습니다.
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, "member41@example.com");
statement.setString(3, "hash-41");
statement.setString(4, "회원 41");
statement.setLong(5, 42L);
statement.setString(6, "member42@example.com");
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 oracle은 이 fixture의 고유 제약 실행을 증명합니다.
production driver의 SQLState·제약 이름은 별도 container integration test에서 고정합니다.
batch 처리 결과
executeBatch는 왕복을 줄이지만 부분 실패의 제품 의미를 정해 주지 않습니다.
| 정책 | 적합한 요구 | 반드시 남길 결과 |
|---|---|---|
| 한 transaction | 부분 성공을 허용하지 않음 | 한 행 실패 시 전체 rollback |
| Savepoint 격리 | 일부 행 성공을 허용 | 행별 성공·실패 index와 안정 code |
| Update count 해석 | driver가 batch count를 반환 | SUCCESS_NO_INFO·EXECUTE_FAILED 구별 |
| Idempotency key | timeout 뒤 안전한 retry | 이미 반영된 행을 중복 생성하지 않음 |
자동 커밋 상태에서 batch를 실행하고 앞 행이 반영됐는지 추측하지 않습니다.
업무가 부분 성공을 허용하지 않으면 한 transaction으로 감싸고 어느 행이든 실패할 때 전체를 rollback합니다.
연습 문제
0행 UPDATE를 service에서 존재하지 않음, 다른 회원, 낡은 version으로 분류하세요.
현재 행 조회와 분류가 repository 변경과 같은 transaction 안에 있는지, 다른 회원 요청에는 현재 version을 노출하지 않는지 검증합니다.
다음 문서에서는 매 CRUD마다 물리 연결을 만드는 비용을 줄이는 DataSource와 연결 풀의 반환·고갈·검증 정책을 다룹니다.