본문으로 건너뛰기

안동민 개발노트

본문 시작

동적 SQL과 간편 삽입

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

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

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

DYNAMIC SQL · FIXED STRUCTURE · BOUND VALUES

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

서버가 소유한 절만 선택하고 각 절의 값을 같은 분기에서 추가한다. 검색 와일드카드는 리터럴로 이스케이프하며 정렬과 키셋 의미는 호출자가 바꿀 수 없다.

PostSearch에서 고정 SQL과 이름 기반 파라미터를 만들고 실행하는 흐름 위쪽 조회 흐름은 검증된 PostSearch에서 필수 절과 세 선택 절을 조립해 BuiltQuery를 만들고 NamedParameterJdbcTemplate로 실행한다. 아래쪽 단순 삽입 흐름은 생성 명령을 일곱 고정 열에 매핑하고 양수 ID 하나만 결과로 허용한다. A · QUERY BUILD AND EXECUTION VALIDATED INPUT PostSearch owner · date · size REQUIRED 고정 필수 절 member · range · limit OPTIONAL · CLAUSE + VALUE keyword? literal LIKE minimum? char_length after? tuple < BOUND QUERY BuiltQuery SQL + named values EXECUTE Named JDBC posts · size + 1 fixed tuple order B · NARROW SIMPLE INSERT LESSON INPUT CreatePostCommand five fields + createdAt INTERNAL HELPER · NOT A PORT SimpleJdbcInsert seven explicit writable columns generated key column = id FAIL-CLOSED RESULT one positive ID missing or invalid → failure NO CALLER SQL FRAGMENT · NO CALLER SORT · NO SECOND REPOSITORY

QUERY FLOW

PostSearch → BuiltQuery → named execution

  1. 필수 절

    소유자, 포함 날짜 범위, size + 1 제한을 항상 추가합니다.

  2. 선택 절

    검색어·최소 길이·커서의 절과 값을 각각 같은 분기에서 추가합니다.

  3. 고정 실행

    리터럴 LIKE와 게시일·ID 내림차순으로 완성한 쿼리를 실행합니다.

SIMPLE INSERT

명령 → 일곱 열 → 양수 ID 하나

  1. 명시적 열

    다섯 명령 필드, 생성 시각, 초기 버전만 씁니다.

  2. 좁은 보조 도구

    SimpleJdbcInsert는 내부 생성 키 예제이며 포트가 아닙니다.

  3. 실패로 닫기

    키가 없거나 양수가 아니면 정상 결과로 바꾸지 않습니다.

동적이라는 말은 구조가 무제한이라는 뜻이 아니다. 고정 절과 바인딩 값의 조합만 허용하고, 단순 삽입 도구는 좁은 키 생성 책임에 머문다.


리터럴 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 '\\'를 명시해 데이터베이스가 같은 의미로 해석하게 합니다.


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) 튜플을 사용하므로 같은 날짜의 여러 행도 빠뜨리지 않습니다.


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