COMMAND PATH
PostRepository → JdbcTemplate → posts
- 값 바인딩
생성 명령과 서비스 시각을 일곱 쓰기 열에 그대로 전달합니다.
- 사실 확인
정확히 한 양수 키와 update/delete의 0행 또는 1행만 허용합니다.
- 예외 경계
요청 키 중복만 좁게 번역하고 다른 저장 실패는 원인을 보존합니다.
안동민 개발노트
JdbcTemplate과 NamedParameterJdbcTemplate로 공통 애플리케이션 포트를 구현하고, 생성 키·행 매핑·예외 경계를 실제 스키마에서 검증합니다.
JdbcTemplate은 연결·statement·result set의 반복 lifecycle을 닫고 Spring 예외 변환을 적용합니다. 그러나 SQL, 파라미터, 행 매핑, 결과 의미는 여전히 어댑터가 명시해야 합니다.
SPRING JDBC · COMMAND AND QUERY ADAPTERS
템플릿은 JDBC 자원과 예외 변환을 맡는다. 어댑터는 정확한 SQL,
단일 양수 생성 키, 행 수 사실, 전체 PostSnapshot 매핑을
애플리케이션 계약에 맞춘다.
COMMAND PATH
생성 명령과 서비스 시각을 일곱 쓰기 열에 그대로 전달합니다.
정확히 한 양수 키와 update/delete의 0행 또는 1행만 허용합니다.
요청 키 중복만 좁게 번역하고 다른 저장 실패는 원인을 보존합니다.
QUERY PATH
소유자·날짜·키셋 절과 모든 값을 이름 기반 파라미터로 묶습니다.
여덟 canonical 열을 하나의 PostSnapshot으로 복사합니다.
열린 result set 대신 선택형 스냅샷이나 size + 1 페이지를 반환합니다.
템플릿이 반복 코드를 줄여도 SQL과 결과 의미는 사라지지 않는다. 포트 테스트는 자원 관리가 아니라 관찰 가능한 저장 계약을 검증한다.
행 매퍼는 어댑터 패키지 안에서만 보이고 공개 포트의 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행 순회와 소유자·삭제 의미는 앞 문서의 공통 계약이 세 어댑터에 동일하게 적용합니다.
다음 문서에서는 PostSearch를 고정 SQL 절과 이름 기반 값으로 조립하고, 단순 삽입 보조 도구의 한계를 분리합니다.