QUERY FLOW
PostSearch → BuiltQuery → named execution
- 필수 절
소유자, 포함 날짜 범위, size + 1 제한을 항상 추가합니다.
- 선택 절
검색어·최소 길이·커서의 절과 값을 각각 같은 분기에서 추가합니다.
- 고정 실행
리터럴 LIKE와 게시일·ID 내림차순으로 완성한 쿼리를 실행합니다.
안동민 개발노트
고정 SQL 절과 이름 기반 값을 한 분기에서 조립하고, 리터럴 LIKE·키셋 조건·SimpleJdbcInsert의 좁은 책임을 실행 코드로 고정합니다.
동적 SQL은 사용자 문자열을 SQL 구조에 붙이는 기술이 아닙니다. 서버가 소유한 고정 절만 선택하고, 사용자 값은 모두 이름 기반 파라미터로 보냅니다.
DYNAMIC SQL · FIXED STRUCTURE · BOUND VALUES
서버가 소유한 절만 선택하고 각 절의 값을 같은 분기에서 추가한다. 검색 와일드카드는 리터럴로 이스케이프하며 정렬과 키셋 의미는 호출자가 바꿀 수 없다.
QUERY FLOW
소유자, 포함 날짜 범위, size + 1 제한을 항상 추가합니다.
검색어·최소 길이·커서의 절과 값을 각각 같은 분기에서 추가합니다.
리터럴 LIKE와 게시일·ID 내림차순으로 완성한 쿼리를 실행합니다.
SIMPLE INSERT
다섯 명령 필드, 생성 시각, 초기 버전만 씁니다.
SimpleJdbcInsert는 내부 생성 키 예제이며 포트가 아닙니다.
키가 없거나 양수가 아니면 정상 결과로 바꾸지 않습니다.
동적이라는 말은 구조가 무제한이라는 뜻이 아니다. 고정 절과 바인딩 값의 조합만 허용하고, 단순 삽입 도구는 좁은 키 생성 책임에 머문다.
포함 검색의 %와 _는 호출자가 보낸 데이터입니다. 역슬래시 자체를 먼저 이스케이프한 뒤 두 와일드카드를 이스케이프하고, 대소문자 비교를 위해 Locale.ROOT로 변환합니다.
package board.jdbc;
import java.util.Locale;
import java.util.Objects;
final class LikePattern {
private LikePattern() {
}
static String containsLiteral(String input) {
Objects.requireNonNull(input, "input");
if (input.isBlank()) {
throw new IllegalArgumentException("search text is blank");
}
String escaped = input.toLowerCase(Locale.ROOT)
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
return "%" + escaped + "%";
}
}검색 값의 변환은 어댑터 내부 책임입니다. 원래 PostSearch를 수정하지 않으며, SQL은 ESCAPE '\\'를 명시해 데이터베이스가 같은 의미로 해석하게 합니다.
필수 소유자·날짜 절은 항상 존재합니다. 검색어, 최소 본문 길이, 다음 커서는 각각 한 분기에서 SQL 절과 값을 동시에 추가합니다. 정렬은 호출자 선택 없이 고정됩니다.
package board.jdbc;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import board.application.PostSearch;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
public final class PostSearchSql {
public BuiltQuery build(PostSearch search) {
Objects.requireNonNull(search, "search");
var clauses = new ArrayList<String>();
var parameters = new MapSqlParameterSource()
.addValue("memberId", search.memberId())
.addValue("from", search.from())
.addValue("to", search.to())
.addValue("limit", search.size() + 1);
clauses.add("member_id = :memberId");
clauses.add("published_on between :from and :to");
if (search.keyword() != null) {
clauses.add("(lower(title) like :keyword escape '\\' "
+ "or lower(content) like :keyword escape '\\')");
parameters.addValue(
"keyword", LikePattern.containsLiteral(search.keyword()));
}
if (search.minimumCharacters() != null) {
clauses.add("char_length(content) >= :minimumCharacters");
parameters.addValue(
"minimumCharacters", search.minimumCharacters());
}
search.after().ifPresent(cursor -> {
clauses.add("(published_on < :cursorDate "
+ "or (published_on = :cursorDate and id < :cursorId))");
parameters
.addValue("cursorDate", cursor.publishedOn())
.addValue("cursorId", cursor.id());
});
String sql = """
select id, member_id, title, content, published_on,
client_request_id, created_at, version
from posts
where %s
order by published_on desc, id desc
limit :limit
""".formatted(String.join("\n and ", clauses));
return new BuiltQuery(sql, parameters);
}
public record BuiltQuery(
String sql,
MapSqlParameterSource parameters
) {
public BuiltQuery {
Objects.requireNonNull(sql, "sql");
Objects.requireNonNull(parameters, "parameters");
}
}
}limit에는 size + 1을 넣습니다. 호출자가 요청한 수보다 한 행 더 읽었을 때만 다음 커서를 만들 수 있습니다. 정렬과 배타 커서 조건이 같은 (published_on, id) 튜플을 사용하므로 같은 날짜의 여러 행도 빠뜨리지 않습니다.
단순 삽입 도구는 열 이름 반복을 줄일 수 있지만 또 하나의 리포지토리가 아닙니다. 다음 package-private 보조 클래스는 생성 명령의 일곱 쓰기 열과 양수 키 확인만 보여 줍니다.
package board.jdbc;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Objects;
import javax.sql.DataSource;
import board.application.DuplicatePostRequestException;
import board.application.PostPersistenceException;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
final class SimplePostInsert {
private final SimpleJdbcInsert insert;
SimplePostInsert(DataSource dataSource) {
this.insert = new SimpleJdbcInsert(
Objects.requireNonNull(dataSource, "dataSource"))
.withTableName("posts")
.usingColumns(
"member_id",
"title",
"content",
"published_on",
"client_request_id",
"created_at",
"version")
.usingGeneratedKeyColumns("id");
}
long insert(CreatePostCommand command, Instant createdAt) {
Objects.requireNonNull(command, "command");
Objects.requireNonNull(createdAt, "createdAt");
var parameters = new MapSqlParameterSource()
.addValue("member_id", command.memberId())
.addValue("title", command.title())
.addValue("content", command.content())
.addValue("published_on", command.publishedOn())
.addValue("client_request_id", command.clientRequestId())
.addValue("created_at", OffsetDateTime.ofInstant(
createdAt, ZoneOffset.UTC))
.addValue("version", 0L);
try {
Number key = insert.executeAndReturnKey(parameters);
if (key == null) {
throw failure("SimpleJdbcInsert did not return a key");
}
long id = key.longValue();
if (id <= 0) {
throw failure("SimpleJdbcInsert returned a non-positive key");
}
return id;
} catch (DuplicateKeyException exception) {
throw new DuplicatePostRequestException(
command.memberId(), command.clientRequestId(), exception);
} catch (PostPersistenceException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new PostPersistenceException(
"SimpleJdbcInsert failed", exception);
}
}
private PostPersistenceException failure(String message) {
return new PostPersistenceException(
message, new IllegalStateException(message));
}
}사용 사례는 이 보조 클래스를 직접 보지 않습니다. 실제 명령 포트는 앞 문서의 JdbcTemplatePostRepository 하나이며, 생성 뒤 전체 스냅샷 확인과 update/delete 계약도 그 구현에 모입니다.
빌더 테스트는 선택한 절과 이름 기반 값이 함께 나타나는지 확인합니다. 실제 SQL 실행과 리터럴 매칭은 JDBC 통합 테스트와 세 어댑터 공통 계약이 담당합니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import java.util.Optional;
import board.application.PostCursor;
import board.application.PostSearch;
import org.junit.jupiter.api.Test;
class PostSearchSqlTest {
private static final LocalDate FROM = LocalDate.parse("2026-08-01");
private static final LocalDate TO = LocalDate.parse("2026-08-31");
private final PostSearchSql builder = new PostSearchSql();
@Test
void 필수_소유자와_날짜_정렬_limit은_항상_고정된다() {
var query = builder.build(new PostSearch(
41L, null, FROM, TO, null, Optional.empty(), 20));
assertThat(query.sql())
.contains("member_id = :memberId")
.contains("published_on between :from and :to")
.contains("order by published_on desc, id desc")
.contains("limit :limit")
.doesNotContain(":keyword", ":minimumCharacters", ":cursorId");
assertThat(query.parameters().getValue("memberId")).isEqualTo(41L);
assertThat(query.parameters().getValue("limit")).isEqualTo(21);
}
@Test
void percent_underscore_역슬래시는_순서대로_이스케이프된다() {
var query = builder.build(new PostSearch(
41L,
"100%_\\path",
FROM,
TO,
12,
Optional.empty(),
30));
assertThat(query.sql())
.contains("lower(title) like :keyword escape '\\'")
.contains("lower(content) like :keyword escape '\\'")
.contains("char_length(content) >= :minimumCharacters");
assertThat(query.parameters().getValue("keyword"))
.isEqualTo("%100\\%\\_\\\\path%");
assertThat(query.parameters().getValue("minimumCharacters"))
.isEqualTo(12);
}
@Test
void 다음_page는_날짜와_ID의_배타_튜플을_함께_사용한다() {
var cursor = new PostCursor(LocalDate.parse("2026-08-20"), 77L);
var query = builder.build(new PostSearch(
41L, "Spring", FROM, TO,
null, Optional.of(cursor), 10));
assertThat(query.sql())
.contains("published_on < :cursorDate")
.contains("published_on = :cursorDate and id < :cursorId")
.contains("order by published_on desc, id desc");
assertThat(query.parameters().getValue("cursorDate"))
.isEqualTo(cursor.publishedOn());
assertThat(query.parameters().getValue("cursorId"))
.isEqualTo(cursor.id());
assertThat(query.parameters().getValue("limit")).isEqualTo(11);
}
}문자열 테스트만으로 DB 방언을 증명했다고 말하지 않습니다. JdbcTemplateRepositoryTest는 공유 스키마에서 %가 리터럴인 실제 결과를 확인하고, 공통 계약은 _, 역슬래시, 공격 형태 문자열까지 각 어댑터에 반복합니다.
다음 문서에서는 내장 DB 테스트의 빠른 피드백과 운영 데이터베이스 차이를 구분합니다.