[Spring Data JPA] Specification로 검색 처리

이지연·2026년 1월 28일

검색 + 페이징을 같이 해야 할 때

게시글 목록 API는 보통 두 가지 요구사항이 같이 온다: 목록을 페이지 단위로 내려줘야 하고(page/size/sort), 검색 조건도 요청에 따라 붙었다 빠졌다 해야 한다(title/category/contents). Spring Data JPA에서는 이 조합을 Pageable(페이징 입력) + Specification(동적 WHERE 조건)으로 해결한다.

요청 URL 예시는 이런 식으로 정리된다.

  • 페이징만: GET /posts?page=0&size=5&sort=title,asc
  • 검색 + 페이징: GET /posts?page=0&size=5&sort=title,asc&title=hello&category=경제

컨트롤러: Pageable + 검색 DTO

컨트롤러에서는 Pageable과 검색 조건 DTO를 함께 받으면 된다. Pageable은 스프링이 쿼리 파라미터를 보고 자동으로 객체 바인딩을 해주고, 검색 조건은 @ModelAttribute로 DTO에 묶어서 받는 방식이 흔하다.

@GetMapping("/posts")
// 페이징 : /posts?page=0&size=5&sort=title,asc
// 검색+페이징 : /posts?page=0&size=5&sort=title,asc&title=hello&category=경제
public Page<PostListDto> postListDto(
        @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable,
        @ModelAttribute PostSearchDto postSearchDto
) {
    log.info("dto : {}", postSearchDto);
    return postService.findAll(pageable, postSearchDto);
}

@PageableDefault는 클라이언트가 size/sort를 안 보내도 기본 페이징 규칙을 잡아줄 수 있어서 목록 API에서 특히 자주 사용한다.


서비스: Specification으로 WHERE 조립

Specification의 핵심은 toPredicate(root, query, cb) 메서드에서 조건을 만들고, 최종적으로 Predicate 1개를 반환하는 것이다. 여기서 각 구성요소는 역할이 명확하다.

  • Root: 엔티티 필드 접근(예: root.get("title"))
  • CriteriaBuilder(cb): equal/like/and/or 같은 조건 생성
  • Predicate: 생성된 조건을 쿼리의 WHERE로 쓰기 위한 “조건 묶음 결과”

질문에 주신 방식처럼, List<Predicate>에 조건을 계속 쌓고 마지막에 cb.and(...)로 한 줄로 조립하는 패턴이 가장 흔하다.

@Transactional(readOnly = true)
public Page<PostListDto> findAll(Pageable pageable, PostSearchDto postSearchDto) {

    Specification<Post> postSpecification = new Specification<Post>() {
        @Override
        public Predicate toPredicate(Root<Post> root,
                                     CriteriaQuery<?> query,
                                     CriteriaBuilder criteriaBuilder) {

            List<Predicate> predicateList = new ArrayList<>();

            // 항상 적용되는 기본 조건
            predicateList.add(criteriaBuilder.equal(root.get("delYn"), "NO"));
            predicateList.add(criteriaBuilder.equal(root.get("appointment"), "NO"));

            // 요청에 따라 달라지는 동적 조건
            if (postSearchDto.getTitle() != null) {
                predicateList.add(criteriaBuilder.like(
                        root.get("title"),
                        "%" + postSearchDto.getTitle() + "%"
                ));
            } else if (postSearchDto.getContents() != null) {
                predicateList.add(criteriaBuilder.like(
                        root.get("contents"),
                        "%" + postSearchDto.getContents() + "%"
                ));
            } else if (postSearchDto.getCategory() != null) {
                predicateList.add(criteriaBuilder.equal(
                        root.get("category"),
                        postSearchDto.getCategory()
                ));
            }

            Predicate[] predicateArr = new Predicate[predicateList.size()];
            for (int i = 0; i < predicateArr.length; i++) {
                predicateArr[i] = predicateList.get(i);
            }

            // predicateList를 최종적으로 한 줄의 WHERE 조건으로 조립
            return criteriaBuilder.and(predicateArr);
        }
    };

    Page<Post> postList = postRepository.findAll(postSpecification, pageable);

    return postList.map(post -> PostListDto.fromEntity(post));
}

여기서 title/contents/categoryif ~ else if로 묶어둔 구조는 “세 조건 중 하나만 적용”되는 로직이라, 실제 요구사항이 “여러 조건을 동시에 적용(AND)”해야 한다면 else ifif로 바꿔서 모두 추가되도록 구성하면 된다.


리포지토리: Specification + Pageable 시그니처

Specification을 쓰려면 Repository가 JpaSpecificationExecutor를 구현(상속)해야 하고, 대표적으로 아래 시그니처의 findAll을 사용하게 된다.

Page<Post> findAll(Specification<Post> specification, Pageable pageable);

또한 이 메서드는 Spring Data JPA의 JpaSpecificationExecutor에 정의되어 있고, spec이 nullable(없으면 전체 조회)이며 pageable은 반드시 필요하다는 점도 명시돼 있다.


Page.map으로 Entity → DTO 변환

페이징 응답을 유지한 채로 엔티티를 DTO로 바꾸고 싶을 때 Page.map(...)을 쓰면 된다. Page.map은 요소 단위 변환을 적용하면서도 totalPages, totalElements 같은 페이지 메타 정보는 그대로 유지해준다.

그래서 아래처럼 서비스에서 postList.map(PostListDto::fromEntity) 패턴을 쓰면 “페이징 + DTO 응답”을 깔끔하게 만들 수 있다.

profile
Eazy하게

0개의 댓글