JdbcTemplate
JdbcTemplate과 NamedParameterJdbcTemplate으로 공통 애플리케이션 포트를 구현하고, 생성 키·행 매핑·예외 경계를 실제 스키마에서 검증합니다.
JdbcTemplate은 연결·statement·result set을 열고 닫는 반복 작업을 처리하고 Spring 예외 변환을 적용합니다. 그러나 SQL, 파라미터, 행 매핑, 결과 의미는 여전히 어댑터가 명시해야 합니다.
앞 장에서 배운 연결 소유권도 유지합니다. 트랜잭션 매니저와 템플릿이 같은 DataSource의 트랜잭션 연결을 사용할 때, 이 어댑터의 SQL은 그 경계에 참여합니다. 템플릿을 쓴다는 이유만으로 다른 DataSource의 연결까지 같은 트랜잭션이 되거나, INSERT 뒤의 계약 검사 실패가 이미 커밋된 SQL을 취소하는 것은 아닙니다.
JdbcTemplate은 반복 lifecycle을 닫고 포트 의미를 지킨다
명령과 조회를 같은 출발점의 독립 비교 슬롯으로 고정하고 각 경로의 입력·SQL·계약 검사 또는 매핑·닫힌 결과라는 네 실제 상태를 추적합니다. INSERT 뒤 검사 실패가 항상 SQL rollback이라는 보장은 없으며 두 경로 사이에 실행 간선을 만들지 않습니다.
SPRING JDBC · RESPONSIBILITY BOUNDARIES
템플릿은 자원을, 어댑터는 SQL 의미를 맡는다
JdbcTemplate은 반복 자원 처리와 Spring 예외 변환을 맡습니다. 정확한 SQL·키·행 수·스냅샷은 어댑터가 검증합니다.
명령 경로 · PostRepository → posts
- 값을 바인딩한다
생성 명령의 다섯 필드, 서비스가 만든 시각, 초기 버전의 일곱 열을 씁니다.
- 키와 행 수를 확인한다
create는 양수 키 정확히 하나를 받고 실제 저장된 행을 다시 읽습니다. update/delete는 1행 true, 0행 false입니다.
- 실패 의미를 번역한다
요청 키 중복만 좁은 애플리케이션 예외로 바꿉니다. 다른 저장·매핑 실패는 원인을 보존합니다.
조회 경로 · PostQuery → 불변 결과
- 고정 SQL + 이름 기반 값
소유자·날짜·키셋 조건을 NamedParameterJdbcTemplate에 전달합니다.
- 전체 행을 명시적으로 매핑
RowMapper가 여덟 열을 PostSnapshot으로 복사합니다. 손상된 값을 보정해 숨기지 않습니다.
- 닫힌 결과를 반환
단건은 0행 또는 1행을 확인합니다. 페이지는 size + 1행을 읽어 반환 목록과 다음 커서를 만듭니다.
템플릿: 자원 lifecycle · 어댑터: SQL / 매핑 / 사실 확인 · 사용 사례: 트랜잭션 정책
SQL 실행과 행 매핑을 맡아도 어댑터가 별도 트랜잭션을 시작하거나 커밋하지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 서로 독립인 명령과 조회 두 경로이며 각각 네 노드와 세 순서 간선이다. 명령은 입력→바인딩과 SQL→키·행 수 검사→결과, 조회는 조건→고정 SQL과 값→여덟 열 매핑→닫힌 결과다. 둘을 한 처리 순서로 연결하지 않는다. 각 단계의 실패 번역과 사용 사례의 트랜잭션 정책은 기존 본문 패널에서 구분한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | JdbcTemplate은 반복 lifecycle을 닫고 포트 의미를 지킨다 |
| 7 | 서로 독립인 명령과 조회 두 경로이며 각각 네 노드와 세 순서 간선이다. 명령은 입력→바인딩과 SQL→키·행 수 검사→결과, 조회는 조건→고정 SQL과 값→여덟 열 매핑→닫힌 결과다. 둘을 한 처리 순서로 연결하지 않는다. 각 단계의 실패 번역과 사용 사례의 트랜잭션 정책은 기존 본문 패널에서 구분한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 독립 두 경로의 입력 명령과 조회는 서로 다른 사용 사례이며 동일 요청의 연속 실행이 아닙니다. | 명령: 입력 · 조회: 조회 조건 · 사례: 독립 | 두 경로의 출발 자료를 각 슬롯에 고정합니다. |
| 바인딩·SQL 실행 명령은 값 바인딩과 SQL을 수행하고 조회는 고정 SQL과 조건 값을 사용합니다. | 명령: 바인딩·SQL · 조회: 고정 SQL·값 · TX: 사용 사례 소유 | 각 슬롯이 자신의 SQL 실행 상태로 진행합니다. |
| 계약 검사와 행 매핑 명령은 키·행 수를 검사하고 조회는 여덟 열을 PostSnapshot으로 매핑합니다. | 명령: 키·행 수 검사 · 조회: 여덟 열 매핑 · 오류 경계: JDBC 실패는 Spring 번역, 계약·매핑 실패는 원인 보존 | SQL 결과가 포트가 약속한 의미로 해석됩니다. |
| 닫힌 포트 결과 템플릿 lifecycle이 닫힌 뒤 명령·조회가 각 결과를 반환합니다. | 명령: 결과 · 조회: 닫힌 결과 · 검사 실패: 자동 rollback 보장 없음 | 각 독립 사용 사례가 자원이 열린 상태의 결과 대신 포트 결과로 끝납니다. |
JdbcTemplate은 반복 lifecycle을 닫고 포트 의미를 지킨다
명령과 조회를 같은 출발점의 독립 비교 슬롯으로 고정하고 각 경로의 입력·SQL·계약 검사 또는 매핑·닫힌 결과라는 네 실제 상태를 추적합니다. INSERT 뒤 검사 실패가 항상 SQL rollback이라는 보장은 없으며 두 경로 사이에 실행 간선을 만들지 않습니다.
SPRING JDBC · RESPONSIBILITY BOUNDARIES
템플릿은 자원을, 어댑터는 SQL 의미를 맡는다
JdbcTemplate은 반복 자원 처리와 Spring 예외 변환을 맡습니다. 정확한 SQL·키·행 수·스냅샷은 어댑터가 검증합니다.
명령 경로 · PostRepository → posts
- 값을 바인딩한다
생성 명령의 다섯 필드, 서비스가 만든 시각, 초기 버전의 일곱 열을 씁니다.
- 키와 행 수를 확인한다
create는 양수 키 정확히 하나를 받고 실제 저장된 행을 다시 읽습니다. update/delete는 1행 true, 0행 false입니다.
- 실패 의미를 번역한다
요청 키 중복만 좁은 애플리케이션 예외로 바꿉니다. 다른 저장·매핑 실패는 원인을 보존합니다.
조회 경로 · PostQuery → 불변 결과
- 고정 SQL + 이름 기반 값
소유자·날짜·키셋 조건을 NamedParameterJdbcTemplate에 전달합니다.
- 전체 행을 명시적으로 매핑
RowMapper가 여덟 열을 PostSnapshot으로 복사합니다. 손상된 값을 보정해 숨기지 않습니다.
- 닫힌 결과를 반환
단건은 0행 또는 1행을 확인합니다. 페이지는 size + 1행을 읽어 반환 목록과 다음 커서를 만듭니다.
템플릿: 자원 lifecycle · 어댑터: SQL / 매핑 / 사실 확인 · 사용 사례: 트랜잭션 정책
SQL 실행과 행 매핑을 맡아도 어댑터가 별도 트랜잭션을 시작하거나 커밋하지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 서로 독립인 명령과 조회 두 경로이며 각각 네 노드와 세 순서 간선이다. 명령은 입력→바인딩과 SQL→키·행 수 검사→결과, 조회는 조건→고정 SQL과 값→여덟 열 매핑→닫힌 결과다. 둘을 한 처리 순서로 연결하지 않는다. 각 단계의 실패 번역과 사용 사례의 트랜잭션 정책은 기존 본문 패널에서 구분한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | JdbcTemplate은 반복 lifecycle을 닫고 포트 의미를 지킨다 |
| 7 | 서로 독립인 명령과 조회 두 경로이며 각각 네 노드와 세 순서 간선이다. 명령은 입력→바인딩과 SQL→키·행 수 검사→결과, 조회는 조건→고정 SQL과 값→여덟 열 매핑→닫힌 결과다. 둘을 한 처리 순서로 연결하지 않는다. 각 단계의 실패 번역과 사용 사례의 트랜잭션 정책은 기존 본문 패널에서 구분한다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 독립 두 경로의 입력 명령과 조회는 서로 다른 사용 사례이며 동일 요청의 연속 실행이 아닙니다. | 명령: 입력 · 조회: 조회 조건 · 사례: 독립 | 두 경로의 출발 자료를 각 슬롯에 고정합니다. |
| 바인딩·SQL 실행 명령은 값 바인딩과 SQL을 수행하고 조회는 고정 SQL과 조건 값을 사용합니다. | 명령: 바인딩·SQL · 조회: 고정 SQL·값 · TX: 사용 사례 소유 | 각 슬롯이 자신의 SQL 실행 상태로 진행합니다. |
| 계약 검사와 행 매핑 명령은 키·행 수를 검사하고 조회는 여덟 열을 PostSnapshot으로 매핑합니다. | 명령: 키·행 수 검사 · 조회: 여덟 열 매핑 · 오류 경계: JDBC 실패는 Spring 번역, 계약·매핑 실패는 원인 보존 | SQL 결과가 포트가 약속한 의미로 해석됩니다. |
| 닫힌 포트 결과 템플릿 lifecycle이 닫힌 뒤 명령·조회가 각 결과를 반환합니다. | 명령: 결과 · 조회: 닫힌 결과 · 검사 실패: 자동 rollback 보장 없음 | 각 독립 사용 사례가 자원이 열린 상태의 결과 대신 포트 결과로 끝납니다. |
스냅샷 전용 행 매퍼
행 매퍼는 어댑터 패키지 안에서만 보이고 공개 포트의 PostSnapshot을 직접 만듭니다. 별도 JDBC 행 타입이나 public nested record를 만들지 않습니다.
package board.jdbc;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import board.application.PostSnapshot;
import org.springframework.jdbc.core.RowMapper;
final class PostRowMapper implements RowMapper<PostSnapshot> {
@Override
public PostSnapshot mapRow(ResultSet result, int rowNumber)
throws SQLException {
return new PostSnapshot(
result.getLong("id"),
result.getLong("member_id"),
result.getString("title"),
result.getString("content"),
result.getObject("published_on", LocalDate.class),
result.getString("client_request_id"),
result.getObject("created_at", OffsetDateTime.class)
.toInstant(),
result.getLong("version"));
}
}열 누락이나 손상된 값은 생성자 검증에서 즉시 드러납니다. 매퍼가 값을 trim하거나 기본값으로 바꾸어 데이터 손상을 숨기지 않습니다.
명령 포트 구현
삽입 SQL은 생성 명령의 다섯 필드, 서비스가 만든 시각, 초기 버전만 기록합니다. GeneratedKeyHolder의 목록과 열 개수를 모두 확인해 정확히 한 개의 양수 키만 받습니다.
package board.jdbc;
import java.sql.PreparedStatement;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Objects;
import board.application.ChangedPost;
import board.application.DeletePostCommand;
import board.application.DuplicatePostRequestException;
import board.application.PostPersistenceException;
import board.application.PostRepository;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
public final class JdbcTemplatePostRepository implements PostRepository {
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 JdbcTemplate jdbc;
private final JdbcTemplatePostQuery query;
public JdbcTemplatePostRepository(
JdbcTemplate jdbc,
JdbcTemplatePostQuery query
) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
this.query = Objects.requireNonNull(query, "query");
}
@Override
public PostSnapshot create(CreatePostCommand command, Instant createdAt) {
Objects.requireNonNull(command, "command");
Objects.requireNonNull(createdAt, "createdAt");
var keys = new GeneratedKeyHolder();
try {
int changed = jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
INSERT, new String[] {"id"});
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));
return statement;
}, keys);
if (changed != 1) {
throw failure("create changed " + changed + " rows");
}
long id = exactPositiveKey(keys);
PostSnapshot persisted = query.find(id, command.memberId())
.orElseThrow(() -> failure(
"created row was not visible in the transaction"));
PostSnapshot expected = new PostSnapshot(
id,
command.memberId(),
command.title(),
command.content(),
command.publishedOn(),
command.clientRequestId(),
createdAt,
0L);
if (!persisted.equals(expected)) {
throw failure("created snapshot did not match the command");
}
return persisted;
} catch (DuplicateKeyException exception) {
throw new DuplicatePostRequestException(
command.memberId(), command.clientRequestId(), exception);
} catch (PostPersistenceException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new PostPersistenceException("post create failed", exception);
}
}
@Override
public boolean update(ChangedPost command) {
Objects.requireNonNull(command, "command");
try {
int changed = jdbc.update(
UPDATE,
command.title(),
command.content(),
command.id(),
command.memberId(),
command.expectedVersion());
return oneOrZero(changed, "update");
} catch (PostPersistenceException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new PostPersistenceException("post update failed", exception);
}
}
@Override
public boolean delete(DeletePostCommand command) {
Objects.requireNonNull(command, "command");
try {
int changed = jdbc.update(
DELETE,
command.postId(),
command.memberId(),
command.expectedVersion());
return oneOrZero(changed, "delete");
} catch (PostPersistenceException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new PostPersistenceException("post delete failed", exception);
}
}
private long exactPositiveKey(GeneratedKeyHolder keys) {
var keyList = keys.getKeyList();
if (keyList.size() != 1) {
throw failure("create did not return exactly one key row");
}
Map<String, Object> keyRow = keyList.getFirst();
if (keyRow.size() != 1) {
throw failure("create did not return exactly one key column");
}
Object value = keyRow.values().iterator().next();
if (!(value instanceof Number number) || number.longValue() <= 0) {
throw failure("create did not return a positive key");
}
return number.longValue();
}
private boolean oneOrZero(int changed, String operation) {
if (changed == 1) {
return true;
}
if (changed == 0) {
return false;
}
throw failure(operation + " changed more than one row");
}
private PostPersistenceException failure(String message) {
return new PostPersistenceException(
message, new IllegalStateException(message));
}
}create는 현재 트랜잭션에서 삽입 직후 다시 읽어 실제 지속된 스냅샷을 반환합니다. 생성 키 누락·비양수·다중 키, 비정상 행 수, 스냅샷 불일치는 모두 실패로 닫힙니다.
DuplicateKeyException을 좁은 요청 키 예외로 바꿀 수 있는 이유는 이 삽입에서 호출자가 공급하는 유일 키가 (member_id, client_request_id) 하나뿐이기 때문입니다. 외래 키나 길이 제약 같은 다른 무결성 실패는 일반 영속성 예외로 남습니다.
조회 포트 구현
단건 조회도 목록 크기를 확인합니다. 0행은 Optional.empty(), 1행은 스냅샷, 2행 이상은 손상입니다. 페이지 조회는 다음 문서의 고정 SQL 빌더를 사용합니다.
package board.jdbc;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;
import board.application.PostCursor;
import board.application.PostPage;
import board.application.PostPersistenceException;
import board.application.PostQuery;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.PostSummary;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
public final class JdbcTemplatePostQuery implements PostQuery {
private static final String COLUMNS = """
id, member_id, title, content, published_on,
client_request_id, created_at, version
""";
private final NamedParameterJdbcTemplate jdbc;
private final PostSearchSql searchSql;
private final PostRowMapper mapper = new PostRowMapper();
public JdbcTemplatePostQuery(
NamedParameterJdbcTemplate jdbc,
PostSearchSql searchSql
) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
this.searchSql = Objects.requireNonNull(searchSql, "searchSql");
}
@Override
public Optional<PostSnapshot> find(long postId, long memberId) {
requirePositive(postId, "postId");
requirePositive(memberId, "memberId");
return translated("find", () -> one(jdbc.query(
"select " + COLUMNS + " from posts "
+ "where id = :postId and member_id = :memberId",
new MapSqlParameterSource()
.addValue("postId", postId)
.addValue("memberId", memberId),
mapper)));
}
@Override
public Optional<PostSnapshot> findByIdempotencyKey(
long memberId,
String clientRequestId
) {
requirePositive(memberId, "memberId");
requireRequestId(clientRequestId);
return translated("findByIdempotencyKey", () -> one(jdbc.query(
"select " + COLUMNS + " from posts "
+ "where member_id = :memberId "
+ "and client_request_id = :clientRequestId",
new MapSqlParameterSource()
.addValue("memberId", memberId)
.addValue("clientRequestId", clientRequestId),
mapper)));
}
@Override
public PostPage findPage(PostSearch search) {
Objects.requireNonNull(search, "search");
return translated("findPage", () -> {
PostSearchSql.BuiltQuery built = searchSql.build(search);
var rows = new ArrayList<>(jdbc.query(
built.sql(), built.parameters(), mapper));
int maximum = search.size() + 1;
if (rows.size() > maximum) {
throw failure("search returned more than the bounded limit");
}
boolean hasMore = rows.size() == maximum;
if (hasMore) {
rows.removeLast();
}
List<PostSnapshot> items = List.copyOf(rows);
Optional<PostCursor> nextCursor = hasMore
? Optional.of(PostCursor.from(items.getLast()))
: Optional.empty();
return new PostPage(items, nextCursor);
});
}
@Override
public PostSummary summary(long memberId) {
requirePositive(memberId, "memberId");
return translated("summary", () -> jdbc.queryForObject(
"""
select count(*) as post_count,
coalesce(sum(char_length(content)), 0)
as total_characters
from posts
where member_id = :memberId
""",
new MapSqlParameterSource("memberId", memberId),
(result, rowNumber) -> new PostSummary(
memberId,
result.getLong("post_count"),
result.getLong("total_characters"))));
}
private Optional<PostSnapshot> one(List<PostSnapshot> rows) {
if (rows.isEmpty()) {
return Optional.empty();
}
if (rows.size() == 1) {
return Optional.of(rows.getFirst());
}
throw failure("single-row query returned " + rows.size() + " rows");
}
private <T> T translated(String operation, Supplier<T> action) {
try {
T value = action.get();
if (value == null) {
throw failure(operation + " returned null");
}
return value;
} catch (PostPersistenceException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new PostPersistenceException(
"post " + operation + " failed", exception);
}
}
private void requirePositive(long value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " must be positive");
}
}
private void requireRequestId(String value) {
if (value == null || !value.matches("[A-Za-z0-9_-]{8,64}")) {
throw new IllegalArgumentException("invalid clientRequestId");
}
}
private PostPersistenceException failure(String message) {
return new PostPersistenceException(
message, new IllegalStateException(message));
}
}공개 메서드는 검증을 먼저 끝낸 뒤 Spring·매핑 예외를 애플리케이션 예외로 번역합니다. 잘못된 ID와 요청 키는 DB에 접근하기 전에 IllegalArgumentException으로 거부됩니다.
어댑터 내부 보고서 타입
특정 SQL 보고서가 제목별 집계를 필요로 해도 애플리케이션의 PostSummary를 바꾸지 않습니다. 다음 타입은 JDBC 패키지 안에서만 사용할 수 있습니다.
package board.jdbc;
record TitlePostSummary(
String title,
long postCount,
long totalCharacters
) {
TitlePostSummary {
if (title == null || title.isBlank() || title.length() > 80) {
throw new IllegalArgumentException("invalid title");
}
if (postCount < 0 || totalCharacters < 0) {
throw new IllegalArgumentException("negative aggregate");
}
}
}어댑터 전용 투영은 포트 반환 타입을 대체하거나 다른 저장 기술로 복사하지 않습니다.
실제 스키마 통합 검증
테스트는 축약 테이블을 만들지 않습니다. 클래스마다 고유한 H2 PostgreSQL-mode DB에 공유 schema.sql을 적용하고 같은 JdbcTemplate에서 명령·조회 구현을 조립합니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
import board.application.ChangedPost;
import board.application.DuplicatePostRequestException;
import board.application.PostSearch;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
class JdbcTemplateRepositoryTest {
private JdbcTemplate jdbc;
private JdbcTemplatePostQuery query;
private JdbcTemplatePostRepository repository;
@BeforeEach
void setUp() {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:jdbc_"
+ UUID.randomUUID().toString().replace("-", "")
+ ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
dataSource.setUser("sa");
dataSource.setPassword("");
new ResourceDatabasePopulator(new ClassPathResource("schema.sql"))
.execute(dataSource);
jdbc = new JdbcTemplate(dataSource);
query = new JdbcTemplatePostQuery(
new NamedParameterJdbcTemplate(jdbc), new PostSearchSql());
repository = new JdbcTemplatePostRepository(jdbc, query);
insertMember(41L);
}
@Test
void create_find_update가_전체_snapshot과_version을_보존한다() {
Instant createdAt = Instant.parse("2026-08-21T09:00:00Z");
var command = new CreatePostCommand(
41L,
"JdbcTemplate",
"공유 스키마에 저장하는 본문",
LocalDate.parse("2026-08-21"),
"jdbc_req1");
var created = repository.create(command, createdAt);
boolean updated = repository.update(new ChangedPost(
created.id(), 41L, "updated", "수정된 본문", 0L));
var found = query.find(created.id(), 41L).orElseThrow();
assertThat(created.version()).isZero();
assertThat(created.createdAt()).isEqualTo(createdAt);
assertThat(updated).isTrue();
assertThat(found.title()).isEqualTo("updated");
assertThat(found.version()).isEqualTo(1L);
}
@Test
void 같은_요청_키만_DuplicatePostRequestException으로_번역한다() {
var command = new CreatePostCommand(
41L,
"idempotency",
"첫 행만 유지할 본문",
LocalDate.parse("2026-08-21"),
"jdbc_req2");
repository.create(command, Instant.parse("2026-08-21T09:00:00Z"));
assertThatExceptionOfType(DuplicatePostRequestException.class)
.isThrownBy(() -> repository.create(
command, Instant.parse("2026-08-21T09:00:01Z")))
.satisfies(exception -> {
assertThat(exception.memberId()).isEqualTo(41L);
assertThat(exception.clientRequestId())
.isEqualTo("jdbc_req2");
assertThat(exception.getCause()).isNotNull();
});
assertThat(query.findByIdempotencyKey(41L, "jdbc_req2"))
.isPresent();
}
@Test
void percent는_와일드카드가_아니라_리터럴_검색_값이다() {
repository.create(new CreatePostCommand(
41L, "100% exact", "검색 본문",
LocalDate.parse("2026-08-21"), "jdbc_req3"),
Instant.parse("2026-08-21T09:00:00Z"));
repository.create(new CreatePostCommand(
41L, "100 percent", "다른 본문",
LocalDate.parse("2026-08-21"), "jdbc_req4"),
Instant.parse("2026-08-21T09:00:01Z"));
var page = query.findPage(new PostSearch(
41L,
"100%",
LocalDate.parse("2026-08-21"),
LocalDate.parse("2026-08-21"),
null,
Optional.empty(),
20));
assertThat(page.items())
.extracting(item -> item.title())
.containsExactly("100% exact");
}
private void insertMember(long memberId) {
jdbc.update("""
insert into members(
id, email, password_hash, name,
active, daily_character_limit)
values (?, ?, ?, ?, true, 10000)
""",
memberId,
"member" + memberId + "@example.test",
"hash",
"member-" + memberId);
}
}이 테스트는 생성 키, 전체 행 매핑, 버전 증가, 좁은 중복 번역, 리터럴 LIKE를 실제 실행합니다. 더 넓은 45행 순회와 소유자·삭제 의미는 앞 문서의 공통 계약 테스트를 세 어댑터에 동일하게 적용해 검증합니다.
행 수 검증이 빠졌을 때의 결과를 판단하기
코드의 UPDATE가 반환한 행 수를 1, 0, 2로 바꾼 세 경우를 생각해 보세요. 공개 포트가 반환하거나 던져야 할 결과는 각각 무엇인가요? count > 0만 반환하는 구현이 왜 공통 계약을 통과하면 안 되는지도 설명하세요. 실제 SQL에서 ID는 기본 키이므로 2행은 정상 데이터의 예측값이 아니라 검증 분기를 확인하려는 가정입니다.
정상 결과와 계약 위반의 풀이
1행은 true, 0행은 false, 2행은 PostPersistenceException으로 처리하는 영속성 실패입니다. count > 0은 2행도 정상 수정으로 숨기므로 “정확히 한 행” 계약을 잃습니다. 0행의 원인은 행 수만으로 분류하지 않습니다.
생성 키 검사 실패도 같은 방식으로 정상 반환을 막습니다. 하지만 예외를 던졌다는 사실과 DB에 변경이 남았는지는 서로 다른 질문입니다. 호출을 감싼 실제 트랜잭션과 자동 커밋 상태를 함께 확인해야 하며, 이 연습에서는 DB를 실행하지 않았습니다.
다음 문서에서는 PostSearch를 고정 SQL 절과 이름 기반 값으로 조립하고, 단순 삽입 보조 도구의 한계를 분리합니다.