검색 API를 만들다 보면 “값이 들어온 조건만 적용하고, 비어 있는 조건은 무시”해야 하는 경우가 많다. @Query에서 이를 처리한 방법을 정리한다.
작성 당시 환경은 다음과 같다. 최신 버전에서 달라진 부분은 글 마지막에 따로 정리했다.
| 항목 | 버전 |
|---|---|
| Spring Boot | 2.7.x |
| Spring Data JPA | 2.7.x |
| Hibernate | 5.6.x |
상황
고객 목록을 이름과 이메일로 검색하는 API가 있다. 두 조건 모두 선택 사항이다.
GET /customers?name=kim&email=kim@test.com → 이름, 이메일 둘 다 일치
GET /customers?name=kim → 이름만 일치
GET /customers → 전체 조회
처음에는 다음과 같이 작성했다.
@Query("SELECT c FROM Customer c WHERE c.name = :name AND c.email = :email")
List<Customer> findCustomerByNameAndEmail(@Param("name") String name,
@Param("email") String email);
name만 넘기고 email은 null로 호출하면, 결과가 한 건도 나오지 않는다.
원인: SQL에서 null은 = 로 비교할 수 없다
email이 null이면 실행되는 SQL은 다음과 같다.
SELECT * FROM customer WHERE name = 'kim' AND email = NULL
SQL에서 NULL은 “값을 모름”을 뜻한다. 그래서 NULL과의 비교는 참도 거짓도 아닌 **UNKNOWN**이 된다.
| 식 | 결과 |
|---|---|
'a' = 'a' |
TRUE |
'a' = NULL |
UNKNOWN |
NULL = NULL |
UNKNOWN |
NULL IS NULL |
TRUE |
WHERE 절은 결과가 TRUE인 행만 반환하므로, email = NULL이 들어가는 순간 어떤 행도 조건을 통과하지 못한다. 개발자는 “email 조건은 빼 줘”라는 의미로 null을 넘겼지만, DB는 “email이 (모르는 값)과 같은 행”을 찾은 셈이다.
해결: :param is null or ... 패턴
각 조건 앞에 “파라미터가 null이면 통과”라는 분기를 붙인다.
@Query("SELECT c FROM Customer c"
+ " WHERE (:name is null or c.name = :name)"
+ " AND (:email is null or c.email = :email)")
List<Customer> findCustomerByNameAndEmail(@Param("name") String name,
@Param("email") String email);
파라미터 값에 따라 각 조건은 다음과 같이 계산된다.
:email 값 |
조건식 | 결과 |
|---|---|---|
null |
(NULL is null or email = NULL) |
TRUE or UNKNOWN → TRUE (조건 무시) |
'kim@test.com' |
('kim@test.com' is null or email = 'kim@test.com') |
FALSE or (비교 결과) → email 비교만 남음 |
OR는 한쪽이 TRUE면 전체가 TRUE가 된다. 그래서 파라미터가 null이면 그 조건은 항상 참이 되어 사실상 사라지고, 값이 있으면 원래의 비교 조건만 남는다.
호출 예시
// 이름, 이메일 모두 일치
repository.findCustomerByNameAndEmail("kim", "kim@test.com");
// 이름만 일치 (이메일 조건 무시)
repository.findCustomerByNameAndEmail("kim", null);
// 전체 조회
repository.findCustomerByNameAndEmail(null, null);
Hibernate가 실제로 만드는 SQL은 다음과 같다. 같은 파라미터가 두 번씩 바인딩된다.
select customer0_.id as id1_0_, customer0_.email as email2_0_, customer0_.name as name3_0_
from customer customer0_
where (? is null or customer0_.name=?)
and (? is null or customer0_.email=?)
주의할 점
빈 문자열은 null이 아니다
?name=처럼 값 없이 파라미터만 오면 컨트롤러에는 null이 아니라 ""(빈 문자열)이 들어올 수 있다. 그러면 '' is null이 FALSE가 되어 name = '' 조건으로 검색된다. 쿼리를 호출하기 전에 빈 값을 null로 바꿔 주는 것이 안전하다.
String name = StringUtils.hasText(request.getName()) ? request.getName() : null;
메서드 이름 쿼리에서는 null이 IS NULL로 바뀐다
@Query 없이 메서드 이름으로 만드는 쿼리는 동작이 다르다.
List<Customer> findByNameAndEmail(String name, String email);
repository.findByNameAndEmail("kim", null);
// where name = 'kim' and email is null
null을 넘기면 조건이 무시되는 것이 아니라, email이 null인 행을 찾는 IS NULL 조건이 된다. “조건 무시”가 필요하면 위의 @Query 패턴이나 아래 대안을 써야 한다.
인덱스를 제대로 못 탈 수 있다
(? is null or name = ?)는 하나의 쿼리가 “조건 있음/없음” 두 경우를 모두 처리해야 한다. DB 종류와 버전에 따라 실행 계획을 한 번 만들어 재사용하는 과정에서 인덱스를 활용하지 못하고 전체 스캔으로 처리되는 경우가 있다. 데이터가 많은 테이블이라면 실행 계획(EXPLAIN)을 확인하는 것이 좋다.
PostgreSQL에서는 타입 오류가 날 수 있다
PostgreSQL은 null로 바인딩된 파라미터의 타입을 추론하지 못해 could not determine data type of parameter $1 같은 오류를 내는 경우가 있다. 이때는 파라미터에 타입을 명시(cast)하거나 아래 대안을 사용한다.
대안: 조건이 많아질 때
조건이 두세 개를 넘어가면 is null or 패턴은 쿼리가 길어지고 읽기 어려워진다. 이럴 때는 조건을 코드로 조립하는 방식이 낫다. 값이 있는 조건만 SQL에 들어가므로 위의 인덱스 문제도 생기지 않는다.
Specification
Spring Data JPA에 기본으로 포함되어 있어 별도 설정이 필요 없다.
public interface CustomerRepository
extends JpaRepository<Customer, Long>, JpaSpecificationExecutor<Customer> {
}
public class CustomerSpecs {
public static Specification<Customer> nameEquals(String name) {
// null을 반환하면 해당 조건은 무시된다
return (root, query, cb) ->
name == null ? null : cb.equal(root.get("name"), name);
}
public static Specification<Customer> emailEquals(String email) {
return (root, query, cb) ->
email == null ? null : cb.equal(root.get("email"), email);
}
}
List<Customer> result = repository.findAll(
Specification.where(CustomerSpecs.nameEquals(name))
.and(CustomerSpecs.emailEquals(email)));
// name만 있으면 → where name = ?
QueryDSL
설정이 필요하지만, 타입 안전하게 동적 쿼리를 작성할 수 있어 실무에서 많이 쓴다.
public List<Customer> search(String name, String email) {
return queryFactory
.selectFrom(customer)
.where(nameEq(name), emailEq(email)) // null인 조건은 무시된다
.fetch();
}
private BooleanExpression nameEq(String name) {
return name != null ? customer.name.eq(name) : null;
}
private BooleanExpression emailEq(String email) {
return email != null ? customer.email.eq(email) : null;
}
| 방식 | 장점 | 단점 |
|---|---|---|
:param is null or |
쿼리 하나로 끝남, 추가 설정 없음 | 조건이 많으면 읽기 어려움, 실행 계획 문제 가능 |
| Specification | 기본 포함, 조건 재사용 가능 | 문자열로 필드명 지정("name"), 코드가 장황함 |
| QueryDSL | 타입 안전, 가독성 좋음 | 의존성·빌드 설정 필요 |
Spring Boot 3 / Hibernate 6에서 달라진 점
:param is null or ... 패턴의 JPQL은 그대로 동작한다. 바뀌는 부분은 주변 설정이다.
- 패키지 이름 —
javax.persistence.*가jakarta.persistence.*로 바뀌었다. 엔티티와 Specification 코드의 import를 변경한다. - QueryDSL 의존성 — Jakarta용 classifier를 붙여야 한다.
// Spring Boot 2.x
implementation 'com.querydsl:querydsl-jpa:5.0.0'
// Spring Boot 3.x
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'