본문 바로가기

동적 SQL과 간편 삽입

고정 SQL 절과 이름 기반 값을 한 분기에서 조립하고, 리터럴 LIKE·키셋 조건·SimpleJdbcInsert의 좁은 책임을 실행 코드로 고정합니다.

동적 SQL은 사용자 문자열을 SQL 구조에 붙이는 기술이 아닙니다. 서버가 소유한 고정 절만 선택하고, 사용자 값은 모두 이름 기반 파라미터로 보냅니다.

고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다

검색 조건이 고정 SQL 절과 값 맵으로 분리된 뒤 BuiltQuery에 합류해 실행되므로 구성 상태가 달라진다.

DYNAMIC SQL · STRUCTURE + BOUND VALUES

동적 SQL은 고정 절과 바인딩 값의 조합이다

서버가 고른 SQL 절과 그 값을 같은 분기에서 추가합니다. 검색어가 SQL 구조나 정렬 방식을 바꾸지 않습니다.

고정 구조와 값 맵을 나누어 합친다여섯 노드·여섯 간선. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL구조·값맵→BuiltQuery→템플릿실행의 방향이다. 영역은 서버가 소유한 절과 SQL구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL구조에 연결하지 않는다. 두 layout의 분기·합류·영역이 같다.검증된 검색 조건서버의 고정 구조필요한 절 선택고정 SQL 구조이름 기반 값 맵BuiltQuery구조 + 값템플릿 실행
같은 분기와 합류를 모바일로 재배치한다여섯 노드·여섯 간선. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL구조·값맵→BuiltQuery→템플릿실행의 방향이다. 영역은 서버가 소유한 절과 SQL구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL구조에 연결하지 않는다. 두 layout의 분기·합류·영역이 같다.검증된 검색 조건고정 구조절 선택SQL 구조이름값 맵BuiltQuery구조 + 값템플릿 실행

검색 · PostSearch → BuiltQuery → 실행

  1. 항상 적용할 절

    member_id, 포함 날짜 범위, size + 1 제한을 추가합니다.

  2. 필요한 절과 값을 함께 추가

    keyword는 리터럴 LIKE, minimumCharacters는 길이 조건, after는 게시일·ID 배타 커서 조건입니다.

  3. 고정 순서로 실행

    BuiltQuery의 SQL과 이름 기반 값을 전달합니다. 정렬은 published_on DESC, id DESC로 고정합니다.

삽입 · 명령 → 일곱 쓰기 열 → ID

  1. 어댑터 내부 보조 도구

    SimpleJdbcInsert는 열 이름 반복을 줄이는 예제입니다. 별도 애플리케이션 포트가 아닙니다.

  2. 쓰기 열을 명시

    다섯 명령 필드와 생성 시각, 초기 버전만 매핑합니다. 생성 키 열은 id입니다.

  3. 양수 ID 하나만 허용

    키 누락과 비양수 키는 실패입니다. 실제 리포지토리가 저장 뒤 스냅샷 확인까지 담당합니다.

검색어의 %, _, 역슬래시는 데이터로 이스케이프합니다. 절 선택과 값 바인딩은 다른 책임입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다
6여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
검색 조건 분리

검증한 조건으로 고정 절 선택과 이름 기반 값 맵을 만듭니다.

구조: 서버 고정 절 · 값: 별도 이름 맵값 문자열을 SQL 구조에서 분리합니다.
BuiltQuery 합류

SQL 구조와 값 맵을 BuiltQuery로 합칩니다.

산출물: BuiltQuery · 구조: 고정 절 조합분리한 두 입력이 실행 계약으로 모입니다.
템플릿 실행

템플릿에 쿼리와 바인딩 값을 넘깁니다.

실행: template · 값: 파라미터 바인딩사용자 값은 SQL 구조 대신 바인딩에 쓰입니다.

고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다

검색 조건이 고정 SQL 절과 값 맵으로 분리된 뒤 BuiltQuery에 합류해 실행되므로 구성 상태가 달라진다.

DYNAMIC SQL · STRUCTURE + BOUND VALUES

동적 SQL은 고정 절과 바인딩 값의 조합이다

서버가 고른 SQL 절과 그 값을 같은 분기에서 추가합니다. 검색어가 SQL 구조나 정렬 방식을 바꾸지 않습니다.

고정 구조와 값 맵을 나누어 합친다여섯 노드·여섯 간선. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL구조·값맵→BuiltQuery→템플릿실행의 방향이다. 영역은 서버가 소유한 절과 SQL구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL구조에 연결하지 않는다. 두 layout의 분기·합류·영역이 같다.검증된 검색 조건서버의 고정 구조필요한 절 선택고정 SQL 구조이름 기반 값 맵BuiltQuery구조 + 값템플릿 실행
같은 분기와 합류를 모바일로 재배치한다여섯 노드·여섯 간선. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL구조·값맵→BuiltQuery→템플릿실행의 방향이다. 영역은 서버가 소유한 절과 SQL구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL구조에 연결하지 않는다. 두 layout의 분기·합류·영역이 같다.검증된 검색 조건고정 구조절 선택SQL 구조이름값 맵BuiltQuery구조 + 값템플릿 실행

검색 · PostSearch → BuiltQuery → 실행

  1. 항상 적용할 절

    member_id, 포함 날짜 범위, size + 1 제한을 추가합니다.

  2. 필요한 절과 값을 함께 추가

    keyword는 리터럴 LIKE, minimumCharacters는 길이 조건, after는 게시일·ID 배타 커서 조건입니다.

  3. 고정 순서로 실행

    BuiltQuery의 SQL과 이름 기반 값을 전달합니다. 정렬은 published_on DESC, id DESC로 고정합니다.

삽입 · 명령 → 일곱 쓰기 열 → ID

  1. 어댑터 내부 보조 도구

    SimpleJdbcInsert는 열 이름 반복을 줄이는 예제입니다. 별도 애플리케이션 포트가 아닙니다.

  2. 쓰기 열을 명시

    다섯 명령 필드와 생성 시각, 초기 버전만 매핑합니다. 생성 키 열은 id입니다.

  3. 양수 ID 하나만 허용

    키 누락과 비양수 키는 실패입니다. 실제 리포지토리가 저장 뒤 스냅샷 확인까지 담당합니다.

검색어의 %, _, 역슬래시는 데이터로 이스케이프합니다. 절 선택과 값 바인딩은 다른 책임입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다
6여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
검색 조건 분리

검증한 조건으로 고정 절 선택과 이름 기반 값 맵을 만듭니다.

구조: 서버 고정 절 · 값: 별도 이름 맵값 문자열을 SQL 구조에서 분리합니다.
BuiltQuery 합류

SQL 구조와 값 맵을 BuiltQuery로 합칩니다.

산출물: BuiltQuery · 구조: 고정 절 조합분리한 두 입력이 실행 계약으로 모입니다.
템플릿 실행

템플릿에 쿼리와 바인딩 값을 넘깁니다.

실행: template · 값: 파라미터 바인딩사용자 값은 SQL 구조 대신 바인딩에 쓰입니다.

리터럴 LIKE 패턴

포함 검색의 %와 _는 호출자가 보낸 데이터입니다. 역슬래시 자체를 먼저 이스케이프한 뒤 두 와일드카드를 이스케이프하고, 대소문자 비교를 위해 Locale.ROOT로 변환합니다.

src/main/java/board/jdbc/LikePattern.java
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 '\\'를 명시해 데이터베이스가 같은 의미로 해석하게 합니다.

예시 검색어 A_B%는 소문자화 뒤 a_b%, 리터럴 이스케이프 뒤 a\_b\%, 포함 검색을 위한 바깥 % 추가 뒤 %a\_b\%%가 됩니다. 첫째와 마지막 %는 포함 검색의 와일드카드이고, 역슬래시가 앞에 붙은 _와 %는 찾으려는 원문 문자입니다. 이 결과도 SQL 문자열에 직접 붙이지 않고 :keyword의 값으로 바인딩합니다.


SQL 절과 파라미터를 함께 조립하기

필수 소유자·날짜 절은 항상 존재합니다. 검색어, 최소 본문 길이, 다음 커서는 각각 한 분기에서 SQL 절과 값을 동시에 추가합니다. 정렬은 호출자 선택 없이 고정됩니다.

src/main/java/board/jdbc/PostSearchSql.java
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) 튜플을 사용하므로 같은 날짜의 행도 빠뜨리지 않습니다. 페이지 사이의 삽입·삭제·날짜 수정까지 하나의 과거 스냅샷으로 고정하는 보장은 아닙니다.

배타 커서와 한 행 더 읽기의 계산 고정된 대상 집합에서 마지막으로 반환한 튜플이 (2026-08-20, 77)이고 날짜·ID 내림차순이라고 합시다. (2026-08-20, 76), (2026-08-20, 78), (2026-08-19, 90), (2026-08-21, 1) 중 다음 페이지 조건을 만족하는 행을 먼저 골라 보세요. 이어 페이지 크기 2에 다음 행이 세 개 발견됐을 때 반환 목록과 다음 커서는 어느 행을 기준으로 만드는지 설명하세요.

튜플 순서와 lookahead의 풀이

같은 날짜에서는 ID가 77보다 작은 76만 통과합니다. 더 이른 8월 19일의 ID 90은 ID가 커도 날짜가 먼저 조건을 만족합니다. 같은 날짜의 78과 더 늦은 8월 21일의 1은 통과하지 않습니다.

세 행은 고정 순서대로 첫 두 행만 반환하고, 세 번째 행은 다음 페이지가 있다는 확인에만 씁니다. 다음 커서는 마지막으로 반환한 두 번째 행의 튜플입니다. 세 번째 행을 커서로 보내면 다음 요청의 배타 조건에서 아직 반환하지 않은 그 행을 건너뜁니다. 이는 대상·정렬 키가 변하지 않는 조건의 계산입니다.


SimpleJdbcInsert의 좁은 역할

단순 삽입 도구는 열 이름 반복을 줄일 수 있지만 또 하나의 리포지토리가 아닙니다. 다음 package-private 보조 클래스는 생성 명령의 일곱 쓰기 열과 양수 키 확인만 보여 줍니다.

src/main/java/board/jdbc/SimplePostInsert.java
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 통합 테스트와 세 어댑터 공통 계약이 담당합니다.

src/test/java/board/jdbc/PostSearchSqlTest.java
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 테스트의 빠른 피드백과 운영 데이터베이스 차이를 구분합니다.