[Spring] JPA Specification 사용하여 다중 조건 검색 구현하기 (criteria API)

kihongsi·2022년 3월 15일
5

spring

목록 보기
2/7

client가 server로부터 DB의 정보를 요청할 때, body에 여러가지 key를 넣어 보내는 경우가 있다.
이 때 필수 키가 아닌 경우, 아래와 같이 각각이 null인지 판단하여 조건문으로 구현해야 한다.

@RequiredArgsContructor
@RestController
public class LectureController {

    private final LectureRepository lectureRepository;

    @GetMapping("/api/lecture")
    public List<Post> getPostList(@RequestBody requestDTO) {
        if (requestDTO.year != null) {
            return lectureRepository.findByYear(requestDTO.year);
        } else if (requestDTO.term != null) {
            return lectureRepository.findByTerm(requestDTO.term);
        } else if (requestDTO.subject_nm != null) {
            return lectureRepository.findByLikesGreaterThan(requestDTO.subject_nm);
        }
        ...
    }
}

위 코드는 각 조건에 따라 실행하는 Repository 메소드를 추가로 작성해야 하기 때문에 코드가 지저분하다.
또한, 여러 조건을 동시 검색하는 기능을 구현하려면 해당 메소드를 따로 구현해주어야 한다.
따라서 조건이 추가될 수록 유지보수하기 어려운 코드가 된다.

이를 해결하기 위해 Jpa Specification을 도입하여 코드를 작성해보았다.

Criteria API

JPA Specificationcriteria API를 기반으로 만들어졌다.

JPA Criteria는 동적 쿼리를 사용하기 위한 JPA 라이브러리이다.

JPQL과 같이 Entity 조회를 기본으로 하며, 컴파일 시점에 에러를 확인할 수 있다.

JPQL은 문자열을 사용하여 쿼리를 정의하는 반면, Criteria는 자바 객체 인스턴스로 정의한다.

CriteriaBuilder criteraBuilder = entityManager.getCriteriaBuilder();

CriteriaQuery<Lecture> criteriaQuery = criteriaBuilder.createQuery(Lecture.class);
// 제너릭 형식으로 criteria query 인스턴스를 생성한다.

Root<Lecture> root = criteriaQuery.from(Lecture.class);
// root : 영속적 엔티티 표시

Predicate predicates = criteriaBuilder.equal(root.get("year"), "2022");
// entity의 "year" 필드가 "2022"인 요소 선택
criteriaQuery.where(predicates);
// Predicate은 criteriaBuilder로부터 생성되며 SQL의 WHERE절 역할을 한다.

TypedQuery<Lecture> lectureListQuery = entityManager.createQuery(criteriaQuery).setFirstResult(startRow).setMaxResults(pageSize);

List<Lecture> lectureList = lectureListQuery.getResultList();

return lectureList;

이렇게 criteria를 사용하여 type-safety하게 쿼리 조건을 생성할 수 있다.

And/Or Predicate

두 가지 조건을 And, 혹은 Or로 사용하고 싶을 경우, criteriaBuilder의 and/or predicates를 사용할 수 있다.

Predicate predicateForBlueColor = criteriaBuilder.equal(itemRoot.get("color"), "blue");
Predicate predicateForRedColor = criteriaBuilder.equal(itemRoot.get("color"), "red");
Predicate predicateForColor = criteriaBuilder.or(predicateForBlueColor, predicateForRedColor);

이렇게 작성하면 "color"가 "blue"인 엔티티와 "red"인 엔티티를 반환받을 수 있다.

Predicate predicateForGradeA = criteriaBuilder.equal(itemRoot.get("grade"), "A");
Predicate predicateForBlueColor = criteriaBuilder.equal(itemRoot.get("color"), "blue");
Predicate predicateForGrade = criteriaBuilder.and(predicateForGradeA, predicateForBlueColor);

이렇게 작성하면 "grade"가 "A"이고, "color"가 "blue"인 엔티티를 반환받을 수 있다.

JPA Specification

검색 조건을 추상화하기 위해 사용된다.

이를 사용하기 위해 Repository에서 JpaSpecificationExecutor를 상속받아야 한다.

@Repository
public interface LectureRepository extends JpaRepository<Lecture, Long>, JpaSpecificationExecutor<Lecture> {
	...
}

JpaSpecificationExecutor 인터페이스를 살펴보면 다음과 같다.

/**
 * Interface to allow execution of {@link Specification}s based on the JPA criteria API.
 *
 * @author Oliver Gierke
 * @author Christoph Strobl
 */
public interface JpaSpecificationExecutor<T> {

    /**
     * Returns a single entity matching the given {@link Specification} or {@link Optional#empty()} if none found.
     *
     * @param spec can be {@literal null}.
     * @return never {@literal null}.
     * @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one entity found.
     */
    Optional<T> findOne(@Nullable Specification<T> spec);

    /**
     * Returns all entities matching the given {@link Specification}.
     *
     * @param spec can be {@literal null}.
     * @return never {@literal null}.
     */
    List<T> findAll(@Nullable Specification<T> spec);

    /**
     * Returns a {@link Page} of entities matching the given {@link Specification}.
     *
     * @param spec can be {@literal null}.
     * @param pageable must not be {@literal null}.
     * @return never {@literal null}.
     */
    Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable);

    /**
     * Returns all entities matching the given {@link Specification} and {@link Sort}.
     *
     * @param spec can be {@literal null}.
     * @param sort must not be {@literal null}.
     * @return never {@literal null}.
     */
    List<T> findAll(@Nullable Specification<T> spec, Sort sort);

    /**
     * Returns the number of instances that the given {@link Specification} will return.
     *
     * @param spec the {@link Specification} to count instances for. Can be {@literal null}.
     * @return the number of instances.
     */
    long count(@Nullable Specification<T> spec);
}

위의 메소드를 상속받아 사용할 수 있고, 매개변수로 Specification 객체를 넣어주면 된다.

Specification

public interface Specification<T> {
  Predicate toPredicate(Root<T> root, CriteriaQuery query, CriteriaBuilder cb);
}

Specification 인터페이스는 이렇게 구현되어 있다.

Specification 명세를 정의하고 조건쿼리를 생성하기 위해 Specification 인터페이스의 toPredicate() 메소드를 구현해야 한다.

public CustomerSpecifications {
  public static Specification<Customer> customerHasBirthday() {
    return new Specification<Customer> {
      public Predicate toPredicate(Root<T> root, CriteriaQuery query, CriteriaBuilder cb) {
        return cb.equal(root.get(Customer_.birthday), today);
      }
    };
  }
}

메소드 안에 root, query, criteriabuilder를 매개변수로 받고, Predicate 객체를 반환하는 함수를 작성하면 된다.

이후 검색 비즈니스 로직을 실행하는 부분에서 Repository findAll 메소드의 파라미터로 리턴받는 Specification을 넣어주면 된다.

나의 경우, 다중 필터링을 위해 검색 조건 key와 value를 Map 형태로 만들어 반복문으로 적용해주었다.

// 조건들을 Map 형태로 저장하는 메소드
public List<LectureDTO> findLectures(LectureDTO lectureDTO){
		Map<String, Object> searchKeys = new HashMap<>();

		if (lectureDTO.getYear() != null) searchKeys.put("year", lectureDTO.getYear());
		if (lectureDTO.getTerm() != null) searchKeys.put("term", lectureDTO.getTerm());
		if (lectureDTO.getSub_dept() != null) searchKeys.put("subDept", lectureDTO.getSub_dept());
		if (lectureDTO.getSubject_div() != null) searchKeys.put("subjectDiv", lectureDTO.getSubject_div());
		if (lectureDTO.getSubject_no() != null) searchKeys.put("subjectNo", lectureDTO.getSubject_no());
        // ... 키가 존재하면 Map에 넣어줌

		return lectureRepository.findAll(LectureSpecification.searchLecture(searchKeys))
				.stream().map(l -> new LectureDTO((Lecture) l))
				.collect(Collectors.toList());
}

LectureSpecification class에 toPredicate()의 구현체인 searchLecture 메소드를 작성해주었다.

public class LectureSpecification {
	public static Specification<Lecture> searchLecture(Map<String, Object> searchKey){
		return ((root, query, criteriaBuilder) -> {
			List<Predicate> predicates = new ArrayList<>();
			for(String key : searchKey.keySet()){
				predicates.add(criteriaBuilder.equal(root.get(key), searchKey.get(key)));
			}
			return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
		});
	}
}

해당 메소드에서는 for문을 사용해 모든 조건을 위에서 알아본 And Predicates로 쿼리 객체를 생성한다.

결과

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/lecture")
public class LectureSearchController {
	private final LectureService lectureService;

	@GetMapping("")
	public List<LectureDTO> findAll(@RequestBody(required = false) LectureDTO lectureDTO){
		if (lectureDTO == null) return lectureService.findAll();
		return lectureService.findLectures(lectureDTO);
	}
}

Controller단에서 위에서 구현한 메소드를 GetMapping을 사용해 호출한다.


원하는 검색 key를 body에 담아 요청을 전송해보았다.

필터링 된 결과값이 잘 반환되었다.



참고문헌

Advanced Spring Data JPA - Specifications and Querydsl
[JPA]JPA Criteria & Specification
Spring Data Specification

0개의 댓글