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행 + 양수 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 계약이 손상됩니다.
이 검사가 실패했다고 앞선 INSERT가 반드시 취소된 것은 아닙니다. 새 JDBC 연결의 기본 자동 커밋 상태에서는 DML 구문이 완료될 때 커밋되며, 아래 저장소는 여러 작업을 묶는 트랜잭션을 시작하지 않습니다. 행 삽입 뒤 생성 키 계약을 검사하다 예외가 나면 DB 반영 여부와 애플리케이션 반환 성공 여부를 따로 확인해야 합니다. 여러 변경의 원자성이 필요할 때 같은 연결의 트랜잭션을 사용하는 방법은 뒤의 수동 트랜잭션 절에서 다룹니다.
Java Connection의 자동 커밋 계약을 기준으로, 오류 응답 뒤 새 요청 키로 즉시 INSERT를 재시도하지 않습니다. 안정된 요청 키로 기존 행과 원래 payload를 확인하는 정책이 필요합니다.
정확히 한 행과 정확히 한 양수 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 안에서 수행하되, 추가 조회 사이의 동시 변경을 막거나 허용하는 잠금·격리 정책도 정합니다. 예를 들어 READ_COMMITTED에서는 같은 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, "[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 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을 노출하지 않는지 검증합니다.
반환 실패와 저장 실패를 구분하기
새 연결의 autoCommit=true를 유지한 INSERT가 구문 완료 시 커밋됐다고 가정합니다. 뒤의 생성 키 검사에서 키가 없다는 예외가 났습니다. 호출자가 오류를 받았으니 DB에 0행이라고 답할 수 있나요? 같은 논리 요청을 새 clientRequestId로 보내는 선택과 원래 키로 확인하는 선택을 비교하세요.
이미 반영된 행과 요청 동일성의 풀이
이 조건에서는 행이 이미 커밋됐으므로 오류 응답을 0행의 증거로 쓸 수 없습니다. 새 키는 새 논리 요청으로 취급되어 또 삽입될 수 있습니다. 원래 회원·요청 키의 행을 조회하고 payload가 같은지 확인한 뒤 재현 응답 또는 충돌을 결정합니다. 생성 키를 반환하지 않은 드라이버를 이 환경에서 실행한 결과가 아니라, 저장 결과와 반환 계약을 분리하기 위한 가정 사례입니다.
다음 문서에서는 매 CRUD마다 물리 연결을 만드는 비용을 줄이는 DataSource와 연결 풀의 반환·고갈·검증 정책을 다룹니다.