검색 조건이 이름, 나이, 팀, 등급 등 여러 개인데, 사용자가 어떤 조건을 입력할지 모른다면 쿼리를 어떻게 만들어야 할까요?

동적 쿼리는 JPA에서 가장 까다로운 주제 중 하나입니다. Spring Data JPA는 Specification 패턴을 통해 Criteria API를 감싸서 동적 쿼리를 구성할 수 있도록 지원합니다.

개념 정의

Specification 은 JPA Criteria API를 기반으로 검색 조건을 독립된 객체 로 캡슐화하는 패턴입니다. 각 조건을 Specification으로 만들고, and(), or()로 조합하여 동적 쿼리를 구성합니다.

JAVA
// Specification 인터페이스
public interface Specification<T> {
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}

설정

Repository에 JpaSpecificationExecutor 추가

JAVA
public interface MemberRepository extends JpaRepository<Member, Long>,
    JpaSpecificationExecutor<Member> {
}

JpaSpecificationExecutor가 제공하는 메서드는 다음과 같습니다.

JAVA
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을 반환하면 해당 조건이 자동으로 무시됩니다.

JAVA
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 절 조건도 같은 패턴으로 작성합니다.

JAVA
    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);
        };
    }
}

조합하여 사용

JAVA
@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 조건

JAVA
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());
}

중첩 조건

JAVA
// (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가 완성됩니다.

JAVA
@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

항목SpecificationQueryDSL
기반Criteria API자체 DSL
타입 안전성필드명 문자열 사용Q클래스로 완전한 타입 안전
가독성cb.greaterThan(root.get("age"), ...)member.age.gt(...)
추가 의존성없음 (Spring Data 내장)QueryDSL 라이브러리 필요
학습 비용Criteria API 이해 필요QueryDSL 문법 학습 필요
동적 쿼리Specification 조합BooleanExpression 조합

코드 비교

JAVA
// 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으로 충분
복잡한 서브쿼리, ProjectionQueryDSL
팀원 모두 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 도입을 검토하는 것이 좋습니다.

정리

항목설명
SpecificationCriteria API를 감싸서 동적 쿼리 조건을 객체로 캡슐화
설정JpaSpecificationExecutor를 Repository에 추가
조건 조합and()/or()로 Specification 조합, null 반환 시 조건 무시
장점추가 의존성 없이 Spring Data JPA만으로 사용 가능
한계필드명 문자열 사용, 복잡한 쿼리에서는 QueryDSL이 더 적합
댓글 로딩 중...