Specification과 동적 쿼리 — 검색 조건이 유동적일 때 어떻게 할까
검색 조건이 이름, 나이, 팀, 등급 등 여러 개인데, 사용자가 어떤 조건을 입력할지 모른다면 쿼리를 어떻게 만들어야 할까요?
동적 쿼리는 JPA에서 가장 까다로운 주제 중 하나입니다. Spring Data JPA는 Specification 패턴을 통해 Criteria API를 감싸서 동적 쿼리를 구성할 수 있도록 지원합니다.
개념 정의
Specification 은 JPA Criteria API를 기반으로 검색 조건을 독립된 객체 로 캡슐화하는 패턴입니다. 각 조건을 Specification으로 만들고, and(), or()로 조합하여 동적 쿼리를 구성합니다.
// Specification 인터페이스
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
설정
Repository에 JpaSpecificationExecutor 추가
public interface MemberRepository extends JpaRepository<Member, Long>,
JpaSpecificationExecutor<Member> {
}
JpaSpecificationExecutor가 제공하는 메서드는 다음과 같습니다.
Optional<T> findOne(Specification<T> spec);
List<T> findAll(Specification<T> spec);
Page<T> findAll(Specification<T> spec, Pageable pageable);
List<T> findAll(Specification<T> spec, Sort sort);
long count(Specification<T> spec);
boolean exists(Specification<T> spec);
Specification 작성
기본 구조
각 검색 조건을 독립된 메서드로 정의합니다. null을 반환하면 해당 조건이 자동으로 무시됩니다.
public class MemberSpec {
public static Specification<Member> nameContains(String name) {
return (root, query, cb) -> {
if (name == null || name.isBlank()) return null;
return cb.like(root.get("name"), "%" + name + "%");
};
}
public static Specification<Member> ageGreaterThan(Integer age) {
return (root, query, cb) -> {
if (age == null) return null;
return cb.greaterThan(root.get("age"), age);
};
}
조인이 필요한 조건이나 IN 절 조건도 같은 패턴으로 작성합니다.
public static Specification<Member> teamNameEquals(String teamName) {
return (root, query, cb) -> {
if (teamName == null || teamName.isBlank()) return null;
Join<Member, Team> team = root.join("team", JoinType.LEFT);
return cb.equal(team.get("name"), teamName);
};
}
public static Specification<Member> gradeIn(List<String> grades) {
return (root, query, cb) -> {
if (grades == null || grades.isEmpty()) return null;
return root.get("grade").in(grades);
};
}
}
조합하여 사용
@Service
@RequiredArgsConstructor
public class MemberSearchService {
private final MemberRepository memberRepository;
public Page<Member> search(MemberSearchCondition cond, Pageable pageable) {
Specification<Member> spec = Specification
.where(MemberSpec.nameContains(cond.getName()))
.and(MemberSpec.ageGreaterThan(cond.getMinAge()))
.and(MemberSpec.teamNameEquals(cond.getTeamName()))
.and(MemberSpec.gradeIn(cond.getGrades()));
return memberRepository.findAll(spec, pageable);
}
}
null을 반환하는 Specification은 자동으로 조건에서 제외되므로, 입력되지 않은 검색 조건은 무시됩니다.
복잡한 조건 조합
OR 조건
public Page<Member> searchWithOr(String name, String email) {
Specification<Member> spec = Specification
.where(MemberSpec.nameContains(name))
.or(MemberSpec.emailContains(email));
return memberRepository.findAll(spec, Pageable.unpaged());
}
중첩 조건
// (name LIKE ? AND age > ?) OR (grade = 'VIP')
Specification<Member> nameAndAge = Specification
.where(MemberSpec.nameContains("심"))
.and(MemberSpec.ageGreaterThan(20));
Specification<Member> vip = MemberSpec.gradeEquals("VIP");
Specification<Member> combined = nameAndAge.or(vip);
실전 예제: 검색 API
컨트롤러에서 요청 파라미터를 받아 Specification을 조합하면 동적 검색 API가 완성됩니다.
@GetMapping("/api/members")
public Page<MemberDto> searchMembers(
@RequestParam(required = false) String name,
@RequestParam(required = false) Integer minAge,
@RequestParam(required = false) Integer maxAge,
@RequestParam(required = false) String teamName,
Pageable pageable) {
Specification<Member> spec = Specification
.where(MemberSpec.nameContains(name))
.and(MemberSpec.ageGreaterThan(minAge))
.and(MemberSpec.ageLessThan(maxAge))
.and(MemberSpec.teamNameEquals(teamName));
return memberRepository.findAll(spec, pageable).map(MemberDto::from);
}
Specification vs QueryDSL
| 항목 | Specification | QueryDSL |
|---|---|---|
| 기반 | Criteria API | 자체 DSL |
| 타입 안전성 | 필드명 문자열 사용 | Q클래스로 완전한 타입 안전 |
| 가독성 | cb.greaterThan(root.get("age"), ...) | member.age.gt(...) |
| 추가 의존성 | 없음 (Spring Data 내장) | QueryDSL 라이브러리 필요 |
| 학습 비용 | Criteria API 이해 필요 | QueryDSL 문법 학습 필요 |
| 동적 쿼리 | Specification 조합 | BooleanExpression 조합 |
코드 비교
// Specification
public static Specification<Member> ageGreaterThan(Integer age) {
return (root, query, cb) -> {
if (age == null) return null;
return cb.greaterThan(root.get("age"), age);
};
}
// QueryDSL
private BooleanExpression ageGt(Integer age) {
return age != null ? member.age.gt(age) : null;
}
QueryDSL이 더 간결하고 가독성이 좋습니다. 하지만 Specification은 별도 라이브러리 없이 Spring Data JPA만으로 사용할 수 있다는 장점이 있습니다.
언제 무엇을 사용할까
| 상황 | 추천 |
|---|---|
| 프로젝트에 QueryDSL이 이미 있음 | QueryDSL |
| 추가 의존성 없이 동적 쿼리가 필요 | Specification |
| 간단한 조건 조합 (2~3개) | Specification으로 충분 |
| 복잡한 서브쿼리, Projection | QueryDSL |
| 팀원 모두 Criteria API에 익숙 | Specification |
Specification은 간단한 동적 쿼리에는 충분하지만, 쿼리가 복잡해지면 Criteria API의 장황함이 걸림돌이 됩니다.
주의할 점
필드명이 문자열이라 리팩토링에 취약하다
Specification은 root.get("name") 처럼 ** 필드명을 문자열로 지정 **합니다. 엔티티 필드명을 변경하면 컴파일 에러 없이 런타임에 예외가 발생합니다. QueryDSL의 Q클래스와 달리 타입 안전성이 없기 때문입니다.
조인이 필요한 조건에서 중복 조인이 발생할 수 있다
여러 Specification에서 같은 테이블을 조인하면 ** 조인이 중복 **으로 생성될 수 있습니다. root.join()을 각 Specification에서 독립적으로 호출하기 때문입니다. 조인 결과를 공유하려면 별도의 처리가 필요합니다.
Criteria API의 장황함이 유지보수를 어렵게 만든다
cb.greaterThan(root.get("age"), age)와 같은 Criteria API 코드는 QueryDSL의 member.age.gt(age)에 비해 가독성이 떨어집니다. 조건이 많아질수록 코드 양이 급격히 늘어나므로, 프로젝트 규모가 커진다면 QueryDSL 도입을 검토하는 것이 좋습니다.
정리
| 항목 | 설명 |
|---|---|
| Specification | Criteria API를 감싸서 동적 쿼리 조건을 객체로 캡슐화 |
| 설정 | JpaSpecificationExecutor를 Repository에 추가 |
| 조건 조합 | and()/or()로 Specification 조합, null 반환 시 조건 무시 |
| 장점 | 추가 의존성 없이 Spring Data JPA만으로 사용 가능 |
| 한계 | 필드명 문자열 사용, 복잡한 쿼리에서는 QueryDSL이 더 적합 |