MOMSOFT / 기술 블로그
QueryDSL로 동적 검색 조건을 안전하게 짜는 법
동적 조건의 기본 단위는 if 블록이 아니라 메서드입니다. null 규약 하나만 팀 규칙으로 고정하면, 조건이 스무 개로 늘어도 검색 코드는 무너지지 않습니다.
프로젝트 문의하기 →결론부터: 조건의 기본 단위를 메서드로 만들면 대부분 끝납니다
동적 검색 화면은 조건이 세 개일 때는 아무 방식으로 짜도 돌아가고, 열 개가 되면 어떤 방식으로 짰는지가 그대로 드러납니다. QueryDSL에서 이 차이를 만드는 지점은 BooleanBuilder냐 BooleanExpression이냐가 아니라, 조건 하나를 어디에 담아 두느냐입니다. if 블록 안에 담으면 조건 수만큼 메서드가 길어지고 재사용이 불가능해집니다. 조건 하나를 BooleanExpression을 반환하는 작은 메서드로 뽑아 두면, 조립부는 조건이 스무 개가 되어도 where(...) 한 줄의 목록으로 남습니다.
그리고 이 구조는 규약 하나에 기대고 있습니다. 조건이 없으면 null을 반환한다. QueryDSL의 where() 가변인자와 BooleanBuilder.and()는 null을 "조건 없음"으로 보고 조용히 건너뜁니다. 이 동작을 알고 쓰면 코드가 크게 줄고, 모르고 쓰면 조건이 사라진 이유를 한참 찾게 됩니다. 아래는 그 규약을 팀 규칙으로 못 박고, 그 위에 OR 그룹과 정렬, 페이징까지 얹는 순서입니다. 마지막에는 이 방식을 쓰지 말아야 할 경우를 따로 정리했습니다.
문자열 조립 쿼리가 실제로 못 하는 것
QueryDSL을 도입할지 판단하려면, 문자열로 SQL이나 JPQL을 조립하는 방식이 무엇을 못 하는지부터 정확히 봐야 합니다. 흔히 "타입 안전"이라는 한 단어로 요약되지만, 실무에서 비용이 발생하는 지점은 좀 더 구체적입니다.
- 컬럼·필드명 변경이 컴파일에 잡히지 않습니다. 엔티티 필드를 리네임하면 문자열 쿼리는 그대로 남아 있다가 런타임에 터집니다. 검색 화면처럼 조건 조합이 많은 코드는 특정 조합에서만 터지므로, 배포 후에 발견될 확률이 높습니다.
- 공백과
AND위치를 사람이 관리합니다.WHERE 1=1로 시작하는 관용구는 이 문제를 회피하려는 장치일 뿐, 조건이 늘어날수록 조립 순서와 괄호를 사람이 계속 검토해야 합니다. - 괄호 우선순위 실수가 조용히 통과합니다.
AND와OR가 섞이면 괄호 하나 차이로 결과 집합이 달라지는데, 문법 오류가 아니므로 쿼리는 정상 실행됩니다. 잘못된 데이터가 화면에 뿌려질 뿐입니다. - 사용자 입력을 문자열에 직접 붙일 여지가 생깁니다. 파라미터 바인딩을 쓰면 되지만, 정렬 컬럼이나
IN목록처럼 바인딩이 번거로운 자리에서 결국 붙이게 됩니다. 이 지점이 인젝션이 실제로 발생하는 자리입니다. - 조건을 재사용할 단위가 없습니다. 목록 조회와 엑셀 다운로드와 건수 집계가 같은 조건을 써야 하는데, 문자열 조립은 조건을 함수로 떼어내기가 어색합니다. 결국 세 군데에 같은 조건이 복사되고, 한 곳만 고쳐진 채로 남습니다.
QueryDSL은 이 다섯 가지를 컴파일 타임과 메서드 단위로 옮겨 놓습니다. 반대로 치르는 값도 분명한데, 그건 뒤에서 따로 다루겠습니다.
BooleanBuilder와 BooleanExpression은 대립 관계가 아닙니다
둘 중 무엇이 옳으냐는 질문은 대체로 잘못 놓인 질문입니다. 역할이 다릅니다.
BooleanBuilder는 "누적기"입니다
가변 객체이고, and()/or()를 호출할 때마다 자기 안에 조건을 쌓습니다. 자신도 Predicate이므로 완성한 뒤 where()에 그대로 넘길 수 있고, 인자로 null이 오면 무시합니다. 그래서 아래처럼 쓰게 됩니다.
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(cond.getName())) {
builder.and(member.name.contains(cond.getName()));
}
if (cond.getStatus() != null) {
builder.and(member.status.eq(cond.getStatus()));
}
if (cond.getJoinedFrom() != null) {
builder.and(member.joinedAt.goe(cond.getJoinedFrom()));
}
// ... 조건 수만큼 계속
동작에는 문제가 없습니다. 문제는 조건 하나가 메서드 안의 if 블록에 갇혀 있다는 것입니다. 같은 "이름 부분 일치" 조건이 필요한 다른 쿼리에서 이 블록을 가져다 쓸 방법이 없습니다. 조건이 열 개가 되면 이 메서드는 if 열 개짜리 벽이 되고, 리뷰어는 조건 하나를 확인하려고 전체를 읽어야 합니다.
BooleanExpression은 "값"입니다
불변이고, 메서드의 반환 타입이 될 수 있습니다. 이게 결정적인 차이입니다.
private BooleanExpression nameContains(String name) {
return StringUtils.hasText(name) ? member.name.contains(name) : null;
}
private BooleanExpression statusEq(MemberStatus status) {
return status != null ? member.status.eq(status) : null;
}
private BooleanExpression joinedBetween(LocalDate from, LocalDate to) {
if (from == null && to == null) return null;
if (from == null) return member.joinedAt.loe(to);
if (to == null) return member.joinedAt.goe(from);
return member.joinedAt.between(from, to);
}
그리고 조립부는 이렇게 남습니다.
queryFactory
.selectFrom(member)
.where(
nameContains(cond.getName()),
statusEq(cond.getStatus()),
joinedBetween(cond.getJoinedFrom(), cond.getJoinedTo())
)
.fetch();
where()는 가변인자를 받고, null인 인자는 건너뜁니다. 그래서 if가 한 줄도 남지 않습니다. 조건 하나는 이름이 붙은 메서드가 되었으므로 다른 쿼리에서도, 카운트 쿼리에서도 그대로 재사용됩니다.
정리하면 BooleanBuilder는 조건 목록 자체를 런타임에 구성해야 할 때(예: 사용자가 정의한 필터 트리를 순회하며 쌓는 경우) 쓰고, 조건이 미리 정해진 검색 화면은 BooleanExpression 메서드 조합으로 갑니다. 실무 검색의 대부분은 후자입니다. 두 방식을 한 메서드 안에서 섞지만 않으면 됩니다. 섞으면 "이 조건이 빌더에 들어갔나, 인자로 나갔나"를 매번 확인해야 합니다.
null 규약: 편한 만큼 함정도 정확히 여기에 있습니다
"조건 없으면 null"은 강력하지만, 네 군데에서 사고가 납니다.
1. 메서드 반환값에 바로 체이닝하면 NPE
// 위험: nameContains가 null을 반환하면 .and() 호출 자체가 NPE
where(nameContains(cond.getName()).and(statusEq(cond.getStatus())));
null을 인자로 넘기는 건 안전하지만, null인 값에 메서드를 호출하는 건 당연히 안전하지 않습니다. 그래서 조건들을 AND로 묶을 때는 체이닝하지 말고 where() 가변인자에 나열하는 것이 기본형입니다. 굳이 하나의 표현식으로 합쳐야 한다면 null을 건너뛰도록 만들어진 정적 헬퍼(allOf/anyOf 계열)를 쓰되, 사용 중인 QueryDSL 버전의 문서에서 null 처리 동작을 확인하고 쓰십시오.
2. "빈 값"의 정의를 팀이 합의하지 않으면 조건이 사라집니다
검색 폼에서 빈 입력은 보통 빈 문자열로 넘어옵니다. name != null만 검사하면 name = ""일 때 like '%%'가 붙고, 인덱스는 못 타면서 결과는 전체가 나옵니다. 반대로 컬렉션 조건에서 빈 리스트를 그대로 in()에 넘기면 "조건 없음"과 정반대로 아무것도 만족하지 못하는 조건이 됩니다. 구현체와 방언에 따라 결과 0건으로 처리되기도 하고 SQL 오류로 터지기도 하므로, 어느 쪽이든 애초에 넘기지 않는 것이 맞습니다. 규칙을 이렇게 고정하십시오.
- 문자열:
null과 공백 문자열은 모두 조건 없음으로 본다(StringUtils.hasText기준). - 컬렉션:
null과 빈 컬렉션은 조건 없음으로 본다. "빈 결과"를 의도한 경우라면 그 의도를 메서드 이름에 드러낸다. - 불리언 3상태(예: 사용/미사용/전체):
Boolean래퍼로 받고null이 전체를 뜻한다는 것을 DTO 주석이 아니라 타입으로 표현한다.
3. 조건이 통째로 빠져 전체 조회가 되는 사고
가장 위험한 실패 형태는 예외가 아니라 조건이 조용히 사라지는 것입니다. 파라미터 매핑 실수로 모든 조건이 null이 되면, 쿼리는 성공하고 테이블 전체를 긁어옵니다. 개발 DB에서는 눈치채지 못하고 운영에서 드러납니다. 방어는 단순합니다. 테넌트 ID, 삭제 여부, 소속 조직 같은 필수 조건은 동적 조건 목록에 넣지 말고 별도의 고정 where로 항상 붙이십시오. 동적 조건은 "좁히는 것"만 담당해야 합니다.
4. 데이터의 NULL과 조건의 null을 헷갈리는 것
규약의 null은 "조건 없음"이지만, 컬럼 값의 NULL은 SQL의 3값 논리를 따릅니다. 둘은 전혀 다른 이야기인데 같은 코드 안에 붙어 있어서 자주 섞입니다. 대표적으로 member.status.ne(WITHDRAWN)은 status가 NULL인 행을 결과에서 제외합니다. 비교 결과가 참도 거짓도 아닌 UNKNOWN이 되기 때문입니다. "탈퇴가 아닌 회원"을 뜻하려 했다면 status.isNull().or(status.ne(WITHDRAWN))처럼 명시해야 합니다. notIn도 같습니다. 부정 조건을 쓸 때는 그 컬럼이 nullable인지부터 확인하는 것을 리뷰 항목에 넣으십시오.
OR가 섞이는 순간이 진짜 분기점입니다
AND만 있는 검색은 where() 나열로 끝납니다. 문제는 통합 검색어처럼 여러 컬럼을 OR로 묶고, 그 묶음 전체를 다른 조건과 AND 해야 할 때입니다. 여기서 null 규약의 의미가 뒤집힙니다. AND에서 null은 "조건 없음"이라 안전하게 무시되지만, OR 안에서 한 항이 사라지면 결과 집합이 좁아집니다. 같은 "무시"인데 의미가 반대입니다. BooleanBuilder.or(null)도 마찬가지로 그 항을 조용히 버리므로, 빌더로 OR를 쌓는 코드는 특히 위험합니다.
그래서 원칙은 하나입니다. OR는 메서드 안에서 완결시키고, 밖으로 내보내지 마십시오.
private BooleanExpression keywordMatches(String keyword) {
if (!StringUtils.hasText(keyword)) return null; // 조건 자체가 없음
return member.name.contains(keyword)
.or(member.email.contains(keyword))
.or(member.phone.contains(keyword)); // OR 그룹은 여기서 닫힌다
}
이렇게 하면 조립부에서는 이 메서드가 그냥 하나의 조건입니다. where()의 인자들은 각각 하나의 표현식 단위로 AND 결합되므로, 사람이 괄호를 세는 일이 사라집니다. 반대로 OR 항을 조립부까지 끌고 나가면, 조건이 하나 추가될 때마다 누군가는 다시 괄호를 검토해야 합니다. 여기서 각 항이 null일 수 있다면 그 항 자체를 지우지 말고, 어떤 항이 없을 때 그룹 전체가 어떤 의미인지를 메서드 안에서 결정해 주십시오. 그 결정을 조립부로 미루는 순간 버그가 됩니다.
LIKE 조건을 쓸 때 하나 더 확인할 것이 있습니다. 사용자가 %나 _를 입력하면 그것이 와일드카드로 해석될 수 있습니다. QueryDSL의 contains 계열이 이스케이프를 넣는지는 사용하는 모듈과 방언에 따라 다르므로, 추측하지 말고 SQL 로그로 직접 확인하고 필요하면 이스케이프 문자를 지정한 like로 바꾸십시오. 확인 없이 넘어가면 "검색어에 %를 넣으면 전체가 나온다"는 문의로 돌아옵니다.
덧붙여 성능도 같이 봐야 합니다. 여러 컬럼을 contains(앞뒤 와일드카드 LIKE)로 OR 묶는 검색은 일반적인 B-tree 인덱스를 거의 쓰지 못합니다. 데이터가 적을 때는 문제가 없지만, 이 패턴이 커지면 그건 QueryDSL로 풀 문제가 아니라 검색 인프라로 옮길 문제라는 신호입니다. 이 판단은 마지막 절에서 다시 다룹니다.
조건이 늘어나도 무너지지 않는 구조
조건 수가 스무 개를 넘기 시작하면 메서드 조합만으로는 부족해집니다. 그때 필요한 건 새로운 기술이 아니라 파일을 나누는 기준입니다. 다음 네 층으로 분리하는 것을 권합니다.
| 층 | 책임 | 예시 |
|---|---|---|
| 검색 조건 DTO | 클라이언트 입력을 담는다. 검증은 여기서 끝낸다. | MemberSearchCond |
| Predicate 모음 | 필드 하나 = 메서드 하나. BooleanExpression 반환, 조건 없으면 null. | MemberPredicates |
| Repository 구현 | 조립·조인·프로젝션·페이징. 비즈니스 판단은 넣지 않는다. | MemberRepositoryImpl |
| 서비스 | 어떤 조건을 강제할지 결정한다(권한, 테넌트, 소프트 삭제). | MemberQueryService |
여기에 규칙 몇 가지를 덧붙입니다.
- Predicate 메서드는 정적(static)으로 두고 Q타입을 정적 import 하십시오. 상태를 갖지 않으므로 테스트하기 쉽고, 여러 리포지토리에서 공유됩니다. 같은 엔티티를 두 번 조인해야 하는 쿼리가 있다면 별칭이 충돌하므로, 그 경우에만 Q타입을 인자로 받는 형태로 바꾸십시오.
- 조인이 필요한 조건은 이름에 드러내십시오.
teamNameEq처럼 다른 엔티티를 건드리는 조건은, 조립부에서 해당 조인을 걸었는지 여부와 결합됩니다. 이 결합을 감추면 조건만 추가했다가 쿼리가 실패합니다. 조인 자체는 조립부의 책임으로 남겨 두는 편이 낫습니다. - 조회 결과는 엔티티가 아니라 프로젝션 DTO로 받으십시오. 검색 목록에 엔티티를 그대로 쓰면 필요 없는 컬럼과 지연 로딩이 따라옵니다.
Projections나@QueryProjection으로 필요한 필드만 뽑으면 count 쿼리 분리도 자연스럽게 따라옵니다. - 조건 메서드에는 단위 테스트를 붙이지 마십시오. 표현식 객체를 비교하는 테스트는 깨지기만 쉽고 의미가 없습니다. 대신 조합 몇 가지에 대한 통합 테스트(조건 전부 null, 하나만, 전부 채움)를 두는 편이 훨씬 값어치가 있습니다. 특히 "전부 null일 때 필수 조건이 남아 있는가"는 반드시 테스트로 고정하십시오.
정렬: 사용자 입력을 경로로 바꾸는 자리가 가장 위험합니다
검색 API는 대개 sort=createdAt,desc 같은 파라미터를 받습니다. 이걸 PathBuilder로 동적 변환하는 코드가 널리 퍼져 있는데, 그대로 쓰면 두 가지 문제가 생깁니다. 첫째, 존재하지 않는 필드명이 오면 런타임 예외가 나고 이건 클라이언트가 얼마든지 유발할 수 있습니다. 둘째, 엔티티의 모든 필드가 정렬 대상으로 노출됩니다. 인덱스가 없는 컬럼으로 정렬 요청이 들어오면 그대로 풀 정렬이 걸립니다.
해법은 화이트리스트입니다. 정렬 가능한 필드를 명시적으로 열거하십시오.
private static final Map<String, ComparableExpressionBase<?>> SORTABLE = Map.of(
"createdAt", member.createdAt,
"name", member.name,
"status", member.status
);
private List<OrderSpecifier<?>> toOrders(Sort sort) {
List<OrderSpecifier<?>> orders = new ArrayList<>();
for (Sort.Order o : sort) {
ComparableExpressionBase<?> path = SORTABLE.get(o.getProperty());
if (path == null) {
throw new IllegalArgumentException("정렬할 수 없는 필드입니다: " + o.getProperty());
}
orders.add(o.isAscending() ? path.asc() : path.desc());
}
orders.add(member.id.desc()); // 동점 처리용 고정 정렬
return orders;
}
// 사용: .orderBy(toOrders(pageable.getSort()).toArray(new OrderSpecifier[0]))
마지막에서 두 번째 줄을 특히 강조하고 싶습니다. 정렬 키가 유일하지 않으면 페이지 사이에서 행이 중복되거나 누락됩니다. createdAt이 같은 행이 여러 개인 상태에서 offset 기반 페이징을 하면, DB는 동점 행들 사이의 순서를 보장하지 않으므로 2페이지에 1페이지의 행이 다시 나올 수 있습니다. 재현이 어렵고, "가끔 목록이 이상하다"는 문의로만 들어옵니다. 정렬 목록 끝에 PK를 붙이는 한 줄이 이 문제를 없앱니다.
정렬 컬럼이 nullable이면 하나 더 결정해야 합니다. NULL을 앞에 둘지 뒤에 둘지는 DB마다 기본값이 다릅니다. 화면 순서가 DB 종류에 따라 달라지는 것을 원치 않는다면 nullsFirst()/nullsLast()로 명시하십시오. 다만 이 절이 실제 SQL로 어떻게 나가는지는 구현체와 방언에 따라 다르므로, 로그로 한 번 확인하고 확정하는 편이 안전합니다.
페이징과 count 쿼리 분리
결과와 전체 건수를 한 번에 주던 fetchResults(), fetchCount()는 QueryDSL 5 계열에서 더 이상 권장되지 않습니다. 이유가 중요합니다. 그 메서드들은 원본 쿼리를 기계적으로 count 쿼리로 변환하는데, 복잡한 쿼리에서는 그 변환이 정확하지 않거나 비효율적이기 때문입니다. 그래서 지금은 두 쿼리를 직접 쓰는 방식이 표준입니다.
List<MemberDto> content = queryFactory
.select(new QMemberDto(member.id, member.name, member.status))
.from(member)
.leftJoin(member.team, team)
.where(nameContains(c.getName()), statusEq(c.getStatus()))
.orderBy(toOrders(pageable.getSort()).toArray(new OrderSpecifier[0]))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.where(nameContains(c.getName()), statusEq(c.getStatus()));
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
분리해서 얻는 것이 세 가지입니다.
- count 쿼리에서 불필요한 조인을 뺄 수 있습니다. 위 예의
team조인은 화면 표시용이고 조건에 관여하지 않습니다. 조건에 쓰이지 않는 다대일·일대일left join은 행 수를 바꾸지 않으므로 count에서 빼도 결과가 같습니다. 반대로 일대다 조인이 걸려 있으면 조건에 쓰이든 아니든 행이 부풀기 때문에, 목록 쿼리와 count 쿼리의 건수 정의부터 서로 맞춰야 합니다(member.id.countDistinct()를 쓸지, 서브쿼리로 뺄지). 자동 변환은 이 판단을 대신해 주지 못합니다. - 정렬을 count에서 뺄 수 있습니다. 건수를 세는 데
order by는 필요 없습니다. PageableExecutionUtils가 count 실행 자체를 생략합니다. 첫 페이지인데 가져온 결과가 페이지 크기보다 작거나, 마지막 페이지로 판정되는 경우 count 쿼리를 아예 날리지 않습니다. 위 코드에서countQuery::fetchOne을 지연 실행 형태로 넘기는 것이 그래서 중요합니다. 미리fetchOne()을 호출해 결과 값을 넘기면 이 최적화가 통째로 사라집니다. 참고로 이 클래스의 패키지 경로는 Spring Data 버전에 따라 다른 곳에 있으니, 옛 예제의 import를 그대로 복사하지 말고 IDE로 찾아 넣으십시오.
한 걸음 더 나가면, 전체 건수가 정말 필요한지부터 물어야 합니다. 무한 스크롤이나 "더 보기" UI라면 총 페이지 수가 필요 없습니다. limit(size + 1)로 한 건 더 가져와 다음 페이지 존재 여부만 판단하는 Slice 방식이면 count 쿼리가 통째로 사라집니다.
그리고 데이터가 크게 늘어난 뒤에도 깊은 페이지가 실제로 조회된다면 offset은 한계에 부딪힙니다. offset은 건너뛸 행을 실제로 읽고 버리기 때문에, 뒤로 갈수록 느려지는 구조 자체가 바뀌지 않습니다. 그때는 마지막 행의 정렬 키를 기준으로 이어가는 커서(keyset) 페이징으로 옮겨야 합니다. 여기서 흔한 오해가 하나 있는데, (createdAt, id) < (?, ?) 같은 행 값 비교는 JPQL 문법에 없습니다. 지원 여부는 DB마다도 갈립니다. 네이티브 SQL로 내려가지 않는 한, 아래처럼 동등한 조건으로 펼쳐서 표현해야 합니다.
private BooleanExpression cursorAfter(LocalDateTime lastCreatedAt, Long lastId) {
if (lastCreatedAt == null || lastId == null) return null; // 첫 페이지
return member.createdAt.lt(lastCreatedAt)
.or(member.createdAt.eq(lastCreatedAt).and(member.id.lt(lastId)));
}
정렬이 createdAt desc, id desc일 때의 형태입니다. 정렬 방향을 바꾸면 부등호도 같이 바꿔야 하고, 그래서 커서 조건과 정렬 절은 반드시 같은 자리에서 함께 관리해야 합니다. 둘이 떨어져 있으면 한쪽만 수정되어 페이지가 조용히 어긋납니다. 이 조건 역시 OR를 포함하지만 메서드 안에서 닫혀 있으므로, 조립부 입장에서는 여전히 조건 하나입니다.
치르는 값
얻는 것만 적으면 광고입니다. QueryDSL을 넣으면 다음을 감수해야 합니다.
- 코드 생성 단계가 빌드에 끼어듭니다. Q타입은 애노테이션 프로세서가 생성합니다. 엔티티를 고치고 Q타입을 다시 생성하지 않으면 컴파일이 깨지고, IDE와 빌드 도구의 생성 경로가 어긋나면 "내 컴퓨터에서만 안 되는" 상황이 생깁니다. 특히 Spring Boot 3는 Jakarta EE 네임스페이스를 쓰므로 그에 맞는 아티팩트와 프로세서 설정이 필요하고, 이 설정 방식은 이전 자료와 다릅니다. 반드시 현재 사용하는 버전의 공식 문서 기준으로 잡으십시오. 인터넷의 오래된 설정 스니펫을 복사하는 것이 이 단계에서 가장 흔한 실패 원인입니다.
- 빌드 시간이 늘고, 클린 빌드 의존성이 생깁니다. 규모가 큰 프로젝트에서는 체감되는 수준입니다.
- 생성된 SQL이 한 겹 멀어집니다. 표현식으로 쓴 코드가 어떤 SQL이 되는지는 로그를 봐야 압니다. 실행계획 튜닝을 자주 하는 팀이라면 SQL 로깅을 처음부터 켜 두고, 느린 쿼리는 실제 SQL 기준으로 검토하는 습관이 필요합니다.
- 모르면 조용히 틀리는 지점이 있습니다. 문법 자체는 어렵지 않지만, 조인과 페치 조인, 프로젝션,
null처리 규약처럼 예외 없이 잘못된 결과만 남기는 자리들이 있습니다. 팀에 QueryDSL을 아는 사람이 한 명뿐이라면, 그 한 명이 병목이자 단일 장애점이 됩니다. - 인젝션이 완전히 사라지지는 않습니다. 일반 표현식은 파라미터 바인딩을 쓰므로 안전하지만, 템플릿 API로 SQL·JPQL 조각을 직접 쓰면서 사용자 입력을 문자열 연결로 붙이면 그대로 뚫립니다. 아래 같은 코드는 리뷰에서 반드시 걸러야 합니다.
// 절대 금지: 사용자 입력을 템플릿 문자열에 직접 연결
Expressions.booleanTemplate("lower({0}) like '%" + keyword + "%'", member.name);
템플릿을 쓰더라도 값은 반드시 바인딩 인자로 넘기십시오. 그리고 정렬 컬럼처럼 구조에 해당하는 입력은 바인딩이 불가능하므로 화이트리스트가 유일한 방어라는 점을 다시 기억하십시오.
도입 순서 체크리스트
- 검색 조건 DTO를 먼저 정의하고, "빈 값"의 의미(문자열 공백, 빈 컬렉션,
Booleannull)를 문서가 아니라 코드로 고정합니다. - 필수 조건(테넌트, 소프트 삭제, 권한 범위)을 동적 조건과 분리해 고정
where로 항상 붙입니다. - 필드 하나당
BooleanExpression메서드 하나를 만들고, 조건 없으면null을 반환합니다. - OR 그룹은 메서드 안에서 닫습니다. 조립부에는 AND 나열만 남깁니다.
- 부정 조건(
ne,notIn)이 걸린 컬럼이 nullable인지 확인하고,NULL행을 포함할지 명시합니다. - 목록은 프로젝션 DTO로 받고, count 쿼리를 별도로 작성해 불필요한 조인과 정렬을 제거합니다.
- 정렬은 화이트리스트로 제한하고, 마지막에 PK 정렬을 고정으로 붙입니다.
PageableExecutionUtils에 count를 지연 실행으로 넘깁니다. 총 건수가 필요 없는 화면이면Slice로 바꿉니다.- "조건 전부 비었을 때", "조건 하나만", "조건 전부 채움" 세 가지 통합 테스트를 만듭니다.
- SQL 로깅을 켜고, 대표 조합 두세 개의 실행계획을 실제로 확인합니다. 동적 조건은 조합마다 실행계획이 다릅니다.
마지막 항목을 빠뜨리는 경우가 많습니다. 동적 쿼리는 조건 조합의 수만큼 서로 다른 쿼리이고, 어떤 조합은 인덱스를 잘 타지만 어떤 조합은 전혀 못 탑니다. 실제 사용 로그를 보고 자주 쓰이는 조합 위주로 인덱스를 설계해야 합니다. 모든 조합을 인덱스로 커버하려는 시도는 인덱스 개수만 늘리고 쓰기 성능을 깎습니다. 어떤 조합이 자주 쓰이는지 모르겠다면, 그 로그부터 남기는 것이 인덱스를 추가하는 것보다 먼저입니다.
언제는 하지 말아야 하는가
다음 경우에는 QueryDSL을 도입하지 않거나, 도입했더라도 이 부분만은 다른 방식으로 푸는 것이 낫습니다.
조건이 두세 개로 고정되어 있고 앞으로도 그럴 때
Spring Data JPA의 메서드 이름 쿼리나 @Query 하나로 끝나는 검색에 코드 생성 단계와 학습 비용을 얹을 이유가 없습니다. "나중에 늘어날 것 같아서" 미리 넣는 결정은, 대개 늘어나지 않는 조건 두 개와 늘어난 빌드 시간만 남깁니다. 조건이 다섯 개를 넘고 조합이 실제로 요구되기 시작할 때 도입해도 늦지 않습니다.
본질이 전문 검색(full-text)일 때
여러 컬럼을 %키워드%로 OR 묶는 통합 검색은 QueryDSL로 표현은 되지만 해결은 되지 않습니다. 형태소 분석, 오타 보정, 관련도 순 정렬, 자동완성이 요구사항에 들어오는 순간 이건 일반 인덱스와 LIKE로 풀 문제가 아닙니다. DB가 제공하는 전문 검색 기능이나 별도 검색 엔진으로 옮기고, QueryDSL은 그 결과 ID 집합을 받아 상세 데이터를 조회하는 역할만 맡기는 편이 구조가 깨끗합니다.
대규모 집계·리포팅 쿼리일 때
윈도 함수, 복잡한 CTE, DB 고유 힌트를 써야 하는 통계 쿼리는 표현식으로 옮기는 순간 원본 SQL보다 읽기 어려워집니다. 이런 쿼리는 대체로 동적이지 않습니다. 조건이 고정되어 있다면 동적 쿼리 도구를 쓸 이유 자체가 없습니다. 네이티브 SQL로 두고 DB 튜닝 담당자가 그대로 읽을 수 있게 하십시오.
사용자가 조건 트리를 직접 조립하는 기능일 때
"필드 선택 + 연산자 선택 + 값 입력, 그룹 묶기, AND/OR 전환"을 사용자가 UI에서 만드는 기능이라면, 조건은 더 이상 개발자가 아는 목록이 아닙니다. 이때는 메서드 조합 대신 조건 트리를 표현하는 자체 모델을 정의하고, 그 모델을 순회하며 BooleanBuilder로 변환하는 방식이 맞습니다. 즉 이 글에서 권하는 구조를 그대로 적용하려 하면 오히려 어긋납니다. 이 경우 필드와 연산자 화이트리스트, 조건 개수 상한, 중첩 깊이 제한, 실행 타임아웃은 선택이 아니라 필수입니다. 그렇지 않으면 사용자가 임의로 무거운 쿼리를 만들어 DB를 세울 수 있습니다.
팀이 이미 다른 방식에 안정적으로 정착해 있을 때
MyBatis 동적 SQL로 수년간 잘 운영되고, 팀이 그 방식에 익숙하며, 실제로 겪는 문제가 리네임 사고나 조건 중복이 아니라면 굳이 바꿀 이유가 없습니다. 도구 교체는 그 자체로 리스크이고, 이전 기간 동안 두 방식이 공존하면서 조건 로직이 두 곳에 흩어지는 비용이 발생합니다. 바꿀 이유는 "요즘 이걸 쓴다"가 아니라, 지금 반복해서 나고 있는 특정한 사고여야 합니다. 그 사고를 먼저 이름 붙여 적어 보고, 이 글에서 설명한 메커니즘이 그 사고를 실제로 없애는지 확인한 다음에 결정하십시오. 없앨 수 없다면, 문제는 도구가 아니라 다른 곳에 있습니다.
같은 주제의 다른 글
맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.