JdbcTemplate
JdbcTemplate 기반 게시판 리포지토리를 구현하며 행 매핑·생성 키·목록 바인딩·자원 관리와 예외 변환을 검증합니다.
JdbcTemplate은 SQL을 대신 설계해 주지 않습니다.
연결 획득·릴리스, 구문 실행, 결과 커서 반복, SQLException 변환 같은 반복을 맡고 애플리케이션은 SQL, 파라미터, 행 매핑에 집중하게 합니다.
이름 기반 파라미터는 긴 쿼리에서 인덱스 순서 오류를 줄이지만 최종적으로 준비된 구문의 값 바인딩을 사용합니다.
RowMapper 투영 복원
매퍼를 리포지토리 안의 순수 함수로 두고 N+1 DB 조회를 수행하지 않습니다.
열 별칭을 명시해 테이블 구조와 Java 이름의 매핑을 눈에 보이게 합니다.
package board.jdbc;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.springframework.jdbc.core.RowMapper;
public final class PostRowMapper implements RowMapper<PostRecord> {
@Override
public PostRecord mapRow(ResultSet result, int rowNum)
throws SQLException {
return new PostRecord(
result.getLong("post_id"),
result.getLong("author_id"),
result.getString("title"),
result.getString("content"),
result.getObject("created_on", java.time.LocalDate.class),
result.getLong("version"));
}
public record PostRecord(
long id,
long authorId,
String title,
String content,
java.time.LocalDate createdOn,
long version
) {
}
}BeanPropertyRowMapper는 편리하지만 리플렉션 이름 지정과 세터에 의존하고 누락 열이 조용히 기본값이 될 수 있습니다.
핵심 애그리거트는 명시적 매퍼로 열과 생성자를 맞추고 보고서 DTO처럼 단순한 곳에서 관례 매퍼를 평가합니다.
JdbcTemplate 실행 흐름
package board.jdbc;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.Optional;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
public final class JdbcTemplatePostRepository {
private static final String COLUMNS = """
id as post_id, author_id, title,
content, created_on, version
""";
private final JdbcTemplate jdbc;
private final PostRowMapper mapper = new PostRowMapper();
public JdbcTemplatePostRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public long insert(NewPost draft) {
var keys = new GeneratedKeyHolder();
int changed = jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"""
insert into posts(
author_id, title, content, created_on, version)
values (?, ?, ?, ?, 0)
""",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, draft.authorId());
statement.setString(2, draft.title());
statement.setString(3, draft.content());
statement.setObject(4, draft.createdOn());
return statement;
}, keys);
if (changed != 1 || keys.getKey() == null) {
throw new IllegalStateException("insert did not return one key");
}
return keys.getKey().longValue();
}
public Optional<PostRowMapper.PostRecord> findById(long id) {
return jdbc.query(
"select " + COLUMNS + " from posts where id = ?",
mapper,
id).stream().findFirst();
}
public void update(
long id,
long authorId,
long expectedVersion,
String title,
String content
) {
int changed = jdbc.update("""
update posts
set title = ?, content = ?, version = version + 1
where id = ? and author_id = ? and version = ?
""", title, content, id, authorId, expectedVersion);
if (changed != 1) {
throw new OptimisticLockingFailureException(
"post was changed or not found");
}
}
}queryForObject는 0행과 2행에서 각각 예외를 던집니다.
“없음”이 정상인 ID 조회에서는 목록이나 스트림을 선택형으로 바꾸되, 기본 키인데 2행이 나오는 데이터 손상을 숨기지 않습니다.
예제의 목록은 스키마 기본 키가 유일성을 보장합니다.
문자열 COLUMNS는 내부 상수 조합이며 사용자 입력이 아닙니다.
SQL 조각을 조립할 때도 신뢰할 수 있는 출처를 분명히 합니다.
이름 기반 파라미터
package board.jdbc;
import java.time.LocalDate;
import java.util.List;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
public final class PostSearchQuery {
private final NamedParameterJdbcTemplate jdbc;
private final PostRowMapper mapper = new PostRowMapper();
public PostSearchQuery(NamedParameterJdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public List<PostRowMapper.PostRecord> findByTitles(
long authorId,
LocalDate from,
LocalDate to,
List<String> titles
) {
if (titles.isEmpty()) {
return List.of();
}
var parameters = new MapSqlParameterSource()
.addValue("authorId", authorId)
.addValue("from", from)
.addValue("to", to)
.addValue("titles", List.copyOf(titles));
return jdbc.query("""
select id as post_id, author_id, title,
content, created_on, version
from posts
where author_id = :authorId
and created_on between :from and :to
and title in (:titles)
order by created_on desc, id desc
""", parameters, mapper);
}
}빈 IN 컬렉션이 in ()으로 바뀌면 DB 구문 오류가 날 수 있어 규칙에 따라 즉시 빈 결과를 반환합니다.
수천 개 제목을 IN에 넣지 않고 임시 테이블, 조인, 배치 쿼리를 검토합니다.
컬렉션 크기 제한은 API 검증과 쿼리 계층 모두에 둡니다.
이름 기반 파라미터도 테이블 이름과 정렬 방향은 바인딩하지 못합니다.
정렬 키 열거형을 SQL 조각 허용 목록에 매핑합니다.
클라이언트의 sort=title desc; drop table 문자열을 그대로 붙이지 않습니다.
Spring 예외 변환
JdbcTemplate은 SQLException을 DataAccessException 계층으로 번역합니다.
DuplicateKeyException, DataIntegrityViolationException, TransientDataAccessException 등을 예외 처리할 수 있지만 모든 무결성 위반이 중복은 아닙니다.
어댑터가 제약 조건을 좁게 확인해 도메인 예외로 변환합니다.
package board.jdbc;
import org.springframework.dao.DuplicateKeyException;
public final class TranslatedPostStore {
private final JdbcTemplatePostRepository repository;
public TranslatedPostStore(
JdbcTemplatePostRepository repository
) {
this.repository = repository;
}
public long create(NewPost draft) {
try {
return repository.insert(draft);
} catch (DuplicateKeyException exception) {
throw new DuplicatePostException(
draft.authorId(),
draft.createdOn(),
draft.title(),
exception);
}
}
}
final class DuplicatePostException extends RuntimeException {
DuplicatePostException(
long authorId,
java.time.LocalDate createdOn,
String title,
DuplicateKeyException cause
) {
super("duplicate post for member " + authorId
+ " on " + createdOn + " title " + title, cause);
}
}같은 테이블에 멱등성 키와 (member,date,title) 두 고유 제약 조건이 있다면 예외 타입만으로 어느 충돌인지 모릅니다.
스키마 제약 조건 이름과 데이터베이스-구체적인 변환기를 계약 테스트로 확인하거나 삽입 전략을 분리합니다.
내장 DB 통합 검증
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.LocalDate;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
class JdbcTemplateRepositoryTest {
@Test
void 생성_조회_낡은_version_실패가_같은_SQL_contract를_따른다() {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:jdbctemplate;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
var jdbc = new JdbcTemplate(dataSource);
jdbc.execute("""
create table posts(
id bigint generated by default as identity primary key,
author_id bigint not null,
title varchar(80) not null,
content varchar(5000) not null,
created_on date not null,
version bigint not null)
""");
var repository = new JdbcTemplatePostRepository(jdbc);
long id = repository.insert(new NewPost(
41L, "JdbcTemplate", "JdbcTemplate로 저장한 게시글 본문",
LocalDate.parse("2026-07-14")));
var found = repository.findById(id).orElseThrow();
repository.update(
id, 41L, 0L,
"JdbcTemplate 수정",
"수정된 게시글 본문");
assertThat(id).isPositive();
assertThat(found.content())
.isEqualTo("JdbcTemplate로 저장한 게시글 본문");
assertThatThrownBy(() -> repository.update(
id, 41L, 0L,
"오래된 수정 요청",
"낡은 버전으로 덮어쓰려는 본문"))
.isInstanceOf(OptimisticLockingFailureException.class);
}
}generated id > 0 = true
find by id content = JdbcTemplate로 저장한 게시글 본문
first version update = 1 row
stale version update = 0 rows
translated conflict = OptimisticLockingFailureException
resources closed by template = true연습 문제
주간 게시글을 제목별로 묶고 본문 총 글자 수가 큰 순서로 반환하는 쿼리 포트를 NamedParameterJdbcTemplate로 구현하세요.
시작·종료 날짜와 최소 총 글자 수를 이름 기반 파라미터로 받고 동점 정렬을 제목으로 고정하세요.
빈 결과와 10개 제한을 테스트합니다.
해설 보기
애그리거트 쿼리는 도메인 엔티티를 복원하지 않고 투영 레코드로 바로 매핑합니다.
순서가 결정적이도록 두 번째 키를 둡니다.
package board.query;
public record TitlePostSummary(
String title,
long totalCharacters,
long postCount
) {
public TitlePostSummary {
if (title == null || title.isBlank()
|| totalCharacters < 0 || postCount < 0) {
throw new IllegalArgumentException("invalid summary");
}
}
}SQL은 group by title having sum(char_length(content)) >= :minimum order by total_characters desc, title asc limit 10으로 구성합니다.
별칭을 매퍼 레이블과 맞추고 운영 환경 DB의 제한 문법을 통합 테스트로 확인합니다.
다음 문서에서는 선택적 필터가 늘어날 때 동적 SQL을 안전하게 조립하고 SimpleJdbcInsert의 메타데이터 편의가 어디까지 유효한지 다룹니다.