게시글 목록 API는 보통 두 가지 요구사항이 같이 온다: 목록을 페이지 단위로 내려줘야 하고(page/size/sort), 검색 조건도 요청에 따라 붙었다 빠졌다 해야 한다(title/category/contents). Spring Data JPA에서는 이 조합을 Pageable(페이징 입력) + Specification(동적 WHERE 조건)으로 해결한다.
요청 URL 예시는 이런 식으로 정리된다.
GET /posts?page=0&size=5&sort=title,ascGET /posts?page=0&size=5&sort=title,asc&title=hello&category=경제컨트롤러에서는 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의 핵심은 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/category를 if ~ else if로 묶어둔 구조는 “세 조건 중 하나만 적용”되는 로직이라, 실제 요구사항이 “여러 조건을 동시에 적용(AND)”해야 한다면 else if를 if로 바꿔서 모두 추가되도록 구성하면 된다.
Specification을 쓰려면 Repository가 JpaSpecificationExecutor를 구현(상속)해야 하고, 대표적으로 아래 시그니처의 findAll을 사용하게 된다.
Page<Post> findAll(Specification<Post> specification, Pageable pageable);
또한 이 메서드는 Spring Data JPA의 JpaSpecificationExecutor에 정의되어 있고, spec이 nullable(없으면 전체 조회)이며 pageable은 반드시 필요하다는 점도 명시돼 있다.
페이징 응답을 유지한 채로 엔티티를 DTO로 바꾸고 싶을 때 Page.map(...)을 쓰면 된다. Page.map은 요소 단위 변환을 적용하면서도 totalPages, totalElements 같은 페이지 메타 정보는 그대로 유지해준다.
그래서 아래처럼 서비스에서 postList.map(PostListDto::fromEntity) 패턴을 쓰면 “페이징 + DTO 응답”을 깔끔하게 만들 수 있다.