본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 데이터 접근 기술과 테스트

동적 SQL과 간편 삽입

게시판 검색 조건을 안전하게 조립하고 SimpleJdbcInsert의 메타데이터와 생성된 키 동작을 확인합니다.

검색 조건이 늘면 SQL 문자열 연결이 필요해 보입니다.

위험은 문자열 조립 자체가 아니라 사용자 입력이 SQL 구조에 들어가는 일과 파라미터·절 순서가 어긋나는 일입니다.

고정된 절을 코드가 선택하고 값은 이름 기반 파라미터로 바인딩하면 동적 쿼리도 주입 없이 읽을 수 있습니다.

단순 삽입은 SimpleJdbcInsert가 메타데이터를 이용해 반복을 줄일 수 있습니다.


검색 조건과 SQL 조각

클라이언트가 보낸 제목·본문 검색어, 날짜, 최소 글자 수는 값입니다.

열과 연산자, ORDER BY는 서버 코드가 허용 목록에서 선택하는 구조입니다.

쿼리 빌더는 각 절을 추가할 때 파라미터도 같은 분기에서 추가합니다.

src/main/java/board/jdbc/PostSearchSql.java
package board.jdbc;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;

public final class PostSearchSql {
    public BuiltQuery build(PostFilter filter) {
        var clauses = new ArrayList<String>();
        var parameters = new MapSqlParameterSource()
                .addValue("authorId", filter.authorId())
                .addValue("limit", filter.limit());
        clauses.add("author_id = :authorId");

        if (filter.keyword() != null && !filter.keyword().isBlank()) {
            clauses.add("(lower(title) like :keyword "
                    + "or lower(content) like :keyword)");
            parameters.addValue(
                    "keyword",
                    "%" + filter.keyword().strip().toLowerCase() + "%");
        }
        if (filter.from() != null) {
            clauses.add("created_on >= :fromDate");
            parameters.addValue("fromDate", filter.from());
        }
        if (filter.to() != null) {
            clauses.add("created_on <= :toDate");
            parameters.addValue("toDate", filter.to());
        }
        if (filter.minimumCharacters() != null) {
            clauses.add("char_length(content) >= :minimumCharacters");
            parameters.addValue(
                    "minimumCharacters", filter.minimumCharacters());
        }

        String orderBy = switch (filter.sort()) {
            case RECENT -> "created_on desc, id desc";
            case LONGEST -> "char_length(content) desc, id desc";
            case TITLE -> "title asc, id asc";
        };
        String sql = """
                select id as post_id, author_id, title,
                       content, created_on, version
                  from posts
                 where %s
                 order by %s
                 limit :limit
                """.formatted(String.join(" and ", clauses), orderBy);
        return new BuiltQuery(sql, parameters);
    }

    public record PostFilter(
            long authorId,
            String keyword,
            LocalDate from,
            LocalDate to,
            Integer minimumCharacters,
            Sort sort,
            int limit
    ) {
        public PostFilter {
            if (authorId <= 0 || sort == null || limit < 1 || limit > 100) {
                throw new IllegalArgumentException("invalid search filter");
            }
            if (from != null && to != null && from.isAfter(to)) {
                throw new IllegalArgumentException("invalid date range");
            }
        }
    }

    public enum Sort {
        RECENT, LONGEST, TITLE
    }

    public record BuiltQuery(
            String sql,
            MapSqlParameterSource parameters
    ) {
    }
}

formatted에 들어가는 두 값은 코드가 만든 절과 열거형 분기 결과입니다.

클라이언트 정렬 문자열을 직접 넣지 않습니다.

파라미터 소스는 변경 가능하므로 완성된 쿼리를 스레드 간 캐시하지 않고 한 요청 안에서 사용합니다.


동적 쿼리 실행 계획

선택적 조건 네 개면 최대 16개 SQL 형태가 생길 수 있습니다.

DB 계획 캐시가 각 형태를 관리하고 인덱스 선택도 달라집니다.

한 SQL에 (:title is null or title=:title)를 반복하면 형태는 하나지만 옵티마이저가 인덱스를 덜 활용할 수 있습니다.

실제 실행 계획과 카디널리티로 비교합니다.

와일드카드 검색은 사용자 텍스트를 %로 감싸기 전에 이스케이프 규칙을 정합니다.

“포함 검색”에서 사용자가 입력한 %_를 와일드카드로 허용할지 리터럴로 볼지 API 계약에 씁니다.

전체 텍스트 검색 요구가 커지면 B-트리와 소문자 변환 함수 조합을 무한히 늘리지 않고 데이터베이스 검색 인덱스나 전용 엔진을 평가합니다.


SimpleJdbcInsert 메타데이터

src/main/java/board/jdbc/SimplePostInsert.java
package board.jdbc;

import java.util.Map;

import javax.sql.DataSource;

import org.springframework.jdbc.core.simple.SimpleJdbcInsert;

public final class SimplePostInsert {
    private final SimpleJdbcInsert insert;

    public SimplePostInsert(DataSource dataSource) {
        this.insert = new SimpleJdbcInsert(dataSource)
                .withTableName("posts")
                .usingColumns(
                        "author_id",
                        "title",
                        "content",
                        "created_on",
                        "version")
                .usingGeneratedKeyColumns("id");
    }

    public long insert(NewPost draft) {
        Number key = insert.executeAndReturnKey(Map.of(
                "author_id", draft.authorId(),
                "title", draft.title(),
                "content", draft.content(),
                "created_on", draft.createdOn(),
                "version", 0L));
        long value = key.longValue();
        if (value <= 0) {
            throw new IllegalStateException("generated key was not positive");
        }
        return value;
    }
}

usingColumns()를 명시하면 새 null 허용 열이 추가됐을 때 메타데이터가 자동으로 모든 열을 삽입하는 뜻밖의 동작을 줄입니다.

스키마, 카탈로그, 인용 식별자가 필요한 데이터베이스는 구성합니다.

시작 또는 첫 실행의 메타데이터 조회와 권한도 확인합니다.

SimpleJdbcInsert 인스턴스는 구성 뒤 재사용합니다.

요청마다 새로 만들면 메타데이터 컴파일 비용과 캐시 이점을 잃습니다.

마이그레이션 중 순차 배포에서 이전 애플리케이션과 새 스키마가 함께 동작하는 호환 순서를 설계합니다.


생성 편의와 SQL 가시성

삽입이 단순하고 생성된 키만 필요하면 SimpleJdbcInsert가 간결합니다.

on conflict, returning 여러 열, 공급자 힌트, CTE가 필요하면 명시적 SQL이 더 읽기 쉽습니다.

추상화를 우회하는 콜백을 계속 추가하는 것보다 해당 쿼리만 원시 JdbcTemplate로 둡니다.

요구권장 도구이유
단순 행 + 키SimpleJdbcInsert열 매핑 반복 감소
공급자별 업서트명시적 SQL충돌 의미 가시성
대량 삽입배치 갱신네트워크 왕복 제어
여러 반환 열DB 지원 SQL + 매퍼결과 형태 명시

추상화 수준을 리포지토리 전체에서 하나로 강제할 필요는 없습니다.

트랜잭션 참여와 예외 변환 계약만 같다면 쿼리별로 가장 읽기 쉬운 Spring JDBC 도구를 사용합니다.


쿼리 형태 테스트

src/test/java/board/jdbc/PostSearchSqlTest.java
package board.jdbc;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDate;

import org.junit.jupiter.api.Test;

class PostSearchSqlTest {
    private final PostSearchSql builder = new PostSearchSql();

    @Test
    void 제목과_본문_검색어와_날짜_조건은_named_parameter와_함께_추가된다() {
        var filter = new PostSearchSql.PostFilter(
                41L,
                "Spring",
                LocalDate.parse("2026-07-01"),
                LocalDate.parse("2026-07-31"),
                null,
                PostSearchSql.Sort.RECENT,
                20);

        var query = builder.build(filter);

        assertThat(query.sql())
                .contains("author_id = :authorId")
                .contains("lower(title) like :keyword")
                .contains("lower(content) like :keyword")
                .contains("created_on >= :fromDate")
                .contains("created_on <= :toDate")
                .contains("order by created_on desc, id desc")
                .doesNotContain("minimumCharacters");
        assertThat(query.parameters().getValue("keyword"))
                .isEqualTo("%spring%");
        assertThat(query.parameters().hasValue("minimumCharacters"))
                .isFalse();
    }
}
동적 SQL 조립 결과
required member clause = present
title/body keyword clause + parameter = present
date range clauses + parameters = present
minimum clause + parameter = absent
sort fragment source = enum allowlist
raw client SQL fragment = absent

문자열 검증만으로 SQL 문법과 결과를 보장하지 않으므로 리포지토리 통합 테스트도 실행합니다.

빌더 단위 테스트는 조건과 파라미터 차이를 빠르게 찾고 DB 테스트는 방언·인덱스·결과 순서 지정을 확인합니다.

잘못된 선택도 성공 결과와 나란히 관찰합니다.

정렬 문자열은 열거형 변환에서 거부하고, 이름 기반 파라미터가 절과 함께 추가되지 않은 회귀는 실행 전에 파라미터 조회 실패로 드러나야 합니다.

생성된 키 열을 잘못 지정한 SimpleJdbcInsert는 성공 ID를 만들지 못합니다.

동적 SQL·SimpleJdbcInsert 실패 관찰
sort = "recent; delete" -> enum conversion rejected
clause present, named value absent -> parameter lookup failure
generated key column = unknown_id -> key retrieval failure
rows committed after each failure = 0
silent fallback to unfiltered query = false

연습 문제

제목·본문 부분 검색에 리터럴 %_를 지원하세요.

이스케이프 문자를 SQL에 명시하고 사용자 텍스트를 변환하는 순수 함수를 작성한 뒤 100%, a_b, 역슬래시가 포함된 입력을 실제 H2와 운영 DB에서 테스트합니다.

해설 보기

이스케이프 순서는 이스케이프 문자 자체를 먼저 처리한 뒤 와일드카드를 처리합니다.

SQL은 lower(title) like lower(:pattern) escape '\\'처럼 이스케이프를 명시하되 Java와 SQL 리터럴 이스케이프를 실제 실행으로 확인합니다.

package board.jdbc;

public final class LikePattern {
    public String containsLiteral(String input) {
        if (input == null || input.isBlank()) {
            throw new IllegalArgumentException("search text required");
        }
        String escaped = input
                .replace("\\", "\\\\")
                .replace("%", "\\%")
                .replace("_", "\\_");
        return "%" + escaped + "%";
    }
}

테스트 테이블에 100% coverage, a_b, acb를 넣고 리터럴 검색이 정확한 행만 찾는지 확인합니다.

데이터베이스별 이스케이프 리터럴 차이는 통합 테스트로 고정합니다.

다음 문서에서는 내장 데이터베이스 테스트의 속도와 운영 환경 방언 차이, 트랜잭션 롤백 격리와 커밋 후 동작을 균형 있게 검증합니다.