JdbcTemplate
JdbcTemplate과 NamedParameterJdbcTemplate으로 공통 애플리케이션 포트를 구현하고, 생성 키·행 매핑·예외 경계를 실제 스키마에서 검증합니다.
JdbcTemplate은 연결과 구문, 결과 집합을 열고 닫는 반복 작업을 대신하고 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 보장 없음 | 각 독립 사용 사례가 자원이 열린 상태의 결과 대신 포트 결과로 끝납니다. |
그림은 서로 독립된 두 경로를 보여 줍니다. 명령 경로는 입력을 바인딩해 SQL을 실행하고, 생성 키와 행 수를 확인한 뒤 스냅샷이나 결과를 돌려줍니다. 조회 경로는 검색 조건을 고정된 SQL과 이름 기반 값으로 만들고, 여덟 개의 열을 PostSnapshot으로 매핑해 닫힌 결과를 돌려줍니다. 두 경로는 한 요청이 이어서 실행하는 순서가 아니며, 템플릿은 자원의 수명 주기를, 어댑터는 SQL과 매핑, 사실 확인을, 사용 사례는 트랜잭션 정책을 맡습니다.
스냅샷만 만드는 행 매퍼
행 매퍼는 어댑터 패키지 안에서만 보이며, 공개 포트의 PostSnapshot을 직접 만듭니다. 따로 JDBC 행 타입이나 공개 중첩 레코드를 만들지 않습니다.
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");
}
}
}어댑터 전용의 투영(projection)은 포트의 반환 타입을 대신하거나 다른 저장 기술로 복사되지 않습니다.
실제 스키마로 하는 통합 검증
테스트는 줄인 테이블을 만들지 않습니다. 클래스마다 고유한 H2 PostgreSQL 모드 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행 순회와 소유자, 삭제의 의미는 앞 글의 공통 테스트로 검증합니다.
PostPersistenceContract는 추상 클래스라서 그대로는 실행되지 않으므로, src/test/java/board/jdbc/에 이 클래스를 상속한 구체 테스트 클래스를 하나 둡니다. 위 setUp에서 마지막 insertMember(41L) 호출을 뺀 나머지를 resetDatabase()로 옮기고, insertMember는 protected 재정의로 옮기며, repository()와 query()는 조립한 두 어댑터를 돌려주게 합니다. 회원 41과 99는 공통 테스트가 직접 넣으므로 resetDatabase()에서 넣으면 기본 키가 겹칩니다.
package board.jdbc;
import java.util.UUID;
import board.application.PostQuery;
import board.application.PostRepository;
import board.contract.PostPersistenceContract;
import org.h2.jdbcx.JdbcDataSource;
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 JdbcTemplatePostContractTest extends PostPersistenceContract {
private JdbcTemplate jdbc;
private JdbcTemplatePostQuery query;
private JdbcTemplatePostRepository repository;
@Override
protected PostRepository repository() {
return repository;
}
@Override
protected PostQuery query() {
return query;
}
@Override
protected void resetDatabase() {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:jdbc_contract_"
+ 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);
}
@Override
protected 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);
}
}공통 테스트의 @BeforeEach가 메서드마다 resetDatabase()를 부르므로, 메서드마다 새 이름의 H2에 스키마를 적용하고 두 어댑터를 다시 조립합니다. 이 클래스는 트랜잭션을 열지 않아 SQL이 문장마다 자동 커밋되지만, 앞 메서드가 남긴 행은 이전 DB에 있으므로 다음 메서드에 보이지 않습니다. MyBatis와 JPA 어댑터의 구체 클래스는 MyBatis SQL 매핑과 Spring Data JPA와 Querydsl에서 더합니다.
더 깊이: 같은 템플릿 위의 다른 API, JdbcClient
Spring Framework 6.1부터는 JdbcClient라는 API가 하나 더 있습니다. Spring 레퍼런스는 이것을 JdbcTemplate의 위치 기반 파라미터 구문과 NamedParameterJdbcTemplate의 이름 기반 파라미터 구문을, 메서드를 이어 부르는 하나의 API로 묶은 것이라고 설명합니다. JdbcClient.create는 DataSource뿐 아니라 기존 JdbcTemplate이나 NamedParameterJdbcTemplate도 받아 그 설정을 그대로 다시 씁니다. Spring Boot 4.1.2의 JdbcClientAutoConfiguration도 NamedParameterJdbcTemplate 빈이 하나뿐이고 JdbcClient 빈이 없을 때, 그 템플릿을 감싼 JdbcClient를 등록합니다. 그래서 JdbcClient로 바꾸어도 SQL은 같은 템플릿을 거쳐 실행되며, 트랜잭션 연결에 참여하는 방식과 예외 변환은 이 글에서 본 것과 같습니다.
달라지는 것은 매핑을 적는 방법입니다. query(Class)에 대상 클래스를 넘기면 행 매퍼를 직접 쓰지 않아도 레코드의 생성자나 빈 속성으로 열을 옮기고, paramSource에 레코드를 넘기면 그 속성 이름이 이름 기반 파라미터가 됩니다. 다만 레퍼런스는 JdbcClient를 단순화한 파사드로 소개하며, 배치 삽입이나 저장 프로시저 호출 같은 기능은 SimpleJdbcInsert, SimpleJdbcCall이나 JdbcTemplate을 직접 쓰라고 안내합니다. 열 이름과 생성자 매개변수의 대응을 암묵적인 규칙에 맡길지, 이 글의 PostRowMapper처럼 열 하나하나를 코드에 드러낼지는 매핑 실패를 어디서 발견하고 싶은지에 따라 고릅니다.
정리
JdbcTemplate은 자원의 수명 주기와 예외 변환을 맡고, SQL과 파라미터, 행 매핑, 결과의 의미는 어댑터가 명시합니다.- 템플릿은 관리자와 같은
DataSource의 트랜잭션에 참여할 뿐, 다른DataSource까지 묶거나 커밋된 SQL을 취소하지 않습니다. - 행 매퍼는 공개
PostSnapshot을 직접 만들고, 손상된 값을 보정하지 않고 생성자 검증으로 드러냅니다. - 삽입은 정확히 하나의 양수 키를 확인한 뒤 다시 읽어 저장된 스냅샷을 돌려주며, 요청 키의 중복만 좁은 예외로 바꿉니다.
- 어댑터 전용 보고서 타입은 패키지 안에만 두고 포트의 반환 타입을 대신하지 않습니다.
연습 문제
1. 행 수 검사가 빠졌을 때
코드의 UPDATE가 돌려준 행 수를 1, 0, 2로 바꾼 세 경우를 생각해 봅니다. 공개 포트가 돌려주거나 던져야 할 결과는 각각 무엇인가요? count > 0만 돌려주는 구현이 왜 공통 테스트를 통과하면 안 되는지도 설명하세요. 실제 SQL에서 ID는 기본 키이므로, 2행은 정상 데이터에서 나올 값이 아니라 검증 분기를 확인하려는 가정입니다.
해설 보기
1행은 true, 0행은 false, 2행은 PostPersistenceException으로 처리하는 영속성의 실패입니다. count > 0은 2행도 정상적인 수정으로 숨기므로, 정확히 한 행이어야 한다는 규칙을 잃습니다. 0행의 원인은 행 수만으로 분류하지 않습니다.
생성 키의 검사 실패도 같은 방식으로 정상 반환을 막습니다. 하지만 예외를 던졌다는 것과 DB에 변경이 남았는지는 서로 다른 질문이므로, 호출을 감싼 실제 트랜잭션과 자동 커밋 상태를 함께 확인해야 합니다. 이 답은 DB를 실행하지 않고 코드의 분기를 따라간 것입니다.
2. 어댑터 전용 투영을 포트로 올리고 싶을 때
어느 날 화면에서도 제목별 집계를 보여 달라는 요구가 생겨, TitlePostSummary를 PostQuery의 반환 타입으로 바로 쓰고 싶어졌습니다. 그대로 올리면 무엇이 문제이고, 어떻게 바꾸는 편이 좋을까요?
해설 보기
TitlePostSummary는 JDBC 패키지 안의 특정 SQL에 맞춘 타입이므로, 포트로 올리면 MyBatis와 JPA 어댑터도 그 타입과 SQL의 모양에 묶이고 공통 테스트도 다시 짜야 합니다. 대신 애플리케이션 계층에 화면이 필요로 하는 값만 담은 새 조회 결과 타입을 정의하고, PostQuery에 그 메서드를 더합니다. 세 어댑터는 각자의 방식으로 그 타입을 만들고, 공통 테스트에 제목별 집계의 기대 결과를 더해 모든 어댑터가 같은 값을 돌려주는지 확인합니다.
다음 글 동적 SQL과 간편 삽입에서는 PostSearch를 고정된 SQL 절과 이름 기반 값으로 조립하고, 간단한 삽입 보조 도구의 한계를 나눠 봅니다.