본문 바로가기
[JAVA]/JPA

JPA Specification을 이용하여 다중 조건 검색 로직을 구현해보자.

by 황원용 2024. 1. 31.
728x90

🤔 다중 조건 검색이란?

<다나와>

  • 다중 조건 검색이란 카테고리 검색, 상세 검색 등과 같이 쇼핑몰 등에서 여러 필터 조건으로 검색된 결과를 축소시켜 사용자가 원하는 결괏값만을 도출하기 위해 사용한다.
  • 이를 어떻게 구현할 수 있을까 고민해보면 한 가지 방법으로 귀결되는데 바로 '동적 쿼리'이다.
  • 사용자가 원하는 필터링에 맞춰 조회 쿼리를 날려주면 되는 것인데 결국 매 요청마다 쿼리문이 달라지게 된다는 이야기이므로 Jpa에서 제공하는 기본 메서드와 같이 정해진 쿼리에 value만 다르게 보내는 것과는 다른 방법을 사용해야 한다.

 

📜 테스트에 사용될 View

  • member 테이블과 team 테이블에서 추출하여 만든 view이다.
  • view나 테이블 구조에 대해 자세히 알고 싶다면 이 글을 참고하자.

 

💡 대표적인 방법 세 가지

문자열 쿼리 만들기

String jpql = "select * from TestView v"; // JPQL 기본 쿼리

if (memberName != null) {
    jpql += " where v.memberName = :memberName"; // 동적으로 조건 추가
}

// EntityManager를 사용하여 JPQL 쿼리 실행
TypedQuery<TestView> query = entityManager.createQuery(jpql, View.class);

if (memberName != null) {
    query.setParameter("memberName", memberName); // 동적으로 파라미터 설정
}

List<TestView> results = query.getResultList();
  • 말 그대로 문자열로 쿼리를 만들어내어 @query 애너테이션에 싣는 것이다.
  • jpql을 예로 든다면 위와 비슷한 로직이 나오는데 직관적이지만 연산이 추가되거나 복잡해질수록 코드가 길어지고 노가다 작업이 심해지고 문자열을 다루기 때문에 실수에 쉽게 노출되며 유지보수에도 어려움이 있다.

 

Querydsl

  • Querydsl은 자바 기반의 동적 쿼리를 작성하기 위한 라이브러리로 SQL과 유사한 문법을 사용하여 쿼리를 작성할 수 있다.
  • 컴파일 시점에 타입 체크가 가능하고 가독성이 좋으며 IDE의 지원을 받을 수 있다.
  • 나의 경우에는 현재 사내에서 사용하지 않고 외부 모듈로 별도의 의존성 추가가 필요하다는 점에서 배제했다.
  • 하지만 언젠가 필요에 의해 Querydsl이 도입된다면 적극적으로 리팩토링에 임할 생각이다.

 

CreteriaQuery & JPA Specification

CreteriaQuery

  • CriteriaQuery는 JPA에서 사용되는 쿼리 작성을 위한 인터페이스이다.
  • CriteriaQuery를 사용하면 SQL 쿼리를 직접 작성하는 대신 코드로 쿼리를 작성할 수 있다.
    • 객체 지향적인 방식으로 쿼리를 작성할 수 있다. 
  • CriteriaQuery는 CriteriaBuilder를 사용하여 생성하고 select, from, where, orderBy 등의 메서드를 사용하여 쿼리의 반환 타입, 조건, 정렬 등을 설정한다. 

JPA Specification

  • Spring Data JPA에서는 Specification이라는 인터페이스를 제공한다.
    • 따라서 Querydsl처럼 별도의 세팅이 필요없다.
  • CriteriaQuery를 사용하여 동적인 쿼리를 작성하는데 CriteriaQuery를 더 간편하게 작성할 수 있게 도와준다.
  • Specification을 사용하면 동적 쿼리를 작성하기 위한 조건을 메서드로 정의하고 이를 조합하여 다양한 검색 조건을 처리할 수 있다.
  • 코드의 재사용성과 가독성을 높일 수 있으며 Spring Data JPA가 제공하는 기능을 활용할 수 있다.
  • 치명적인 단점이 하나 있는데 원하는 컬럼이나 연산결과를 조회할 수 있는 기능을 제공하지 않는다. 따라서 컬럼 전체를 전부 가져와야하기 때문에 따로 필요한 컬럼이나 연산 결과만을 가져오도록 view와 함께 사용하는 것이 좋아 보인다.
    • 일반 테이블에 사용하게 되면 사용하지 않는 컬럼까지 모두 가져와야 한다.
  • 위의 내용을 토대로 나는 JPA Specification을 사용하기로 결정했다.

 

📜 View

@Getter
@Entity
@Table(name = "test_view")
@Immutable // 읽기 전용 엔티티임을 명시
public class View {

    @Id
    private Long memberId;

    private String playerName;

    private int age;

    @Enumerated(EnumType.STRING)
    private Sex sex;

    private String teamName;

    private String city;
}
  • 뷰와 매핑된 엔티티 클래스이다.
  • 일반 클래스에 테스트하는 것과 어떠한 차이도 없다.

 

📜 ViewRepository

public interface ViewRepository extends JpaRepository<View, Long>, JpaSpecificationExecutor<View> {

}
  • JPA Specification 사용을 위해 JpaSpecificationExecutor<View>의 상속을 추가한다.

 

📜 ViewSpecification

public class ViewSpecification {

    public static Specification<View> likePlayerName(String playerName) {
        return (root, query, CriteriaBuilder) -> CriteriaBuilder.like(root.get("playerName"), "%" + playerName + "%");
    }

    public static Specification<View> likeTeamName(String teamName) {
        return (root, query, CriteriaBuilder) -> CriteriaBuilder.like(root.get("teamName"), "%" + teamName + "%");
    }

    public static Specification<View> rangeAge(int min,  int max) {
        return (root, query, CriteriaBuilder) -> CriteriaBuilder.between(root.get("age"), min, max);
    }

    public static Specification<View> equalsSex(Sex sex) {
        return (root, query, CriteriaBuilder) -> CriteriaBuilder.equal(root.get("sex"), String.valueOf(sex));
    }

    public static Specification<View> equalsCity(String city) {
        return (root, query, CriteriaBuilder) -> CriteriaBuilder.equal(root.get("city"), city);
    }
}
  • 맨 위 likePlayerName() 메서드 위주로 설명해 보겠다.
    • 나머지 방식도 거의 비슷하다.
    • Enum 타입의 경우 equalsSex() 메서드와 같이 스트링 형식으로 변환해 줘야 에러가 발생하지 않는다.
  • public static Specification<View> likePlayerName(String playerName):
    • 이 메서드는 playerName에 대한 부분 일치 검색을 수행하는 Specification 객체를 반환한다.
    • View는 검색 대상 엔티티 클래스이다.
  • (root, query, CriteriaBuilder) -> CriteriaBuilder.like(root.get("playerName"), "%" + playerName + "%"):
    • 이 부분은 Specification의 실제 구현을 정의하는 람다 표현식이다.
    • root는 쿼리의 루트 엔티티를 나타내는 Root 객체이다.
    • query는 CriteriaQuery 객체로 쿼리 작성에 사용된다.
    • CriteriaBuilder는 CriteriaQuery를 생성하고 쿼리를 작성하는 데 사용되는 빌더 객체이다.
    • CriteriaBuilder.like() 메서드를 사용하여 부분 일치 검색을 생성합니다.
      • 다른 메서드를 보면 알겠지만 equal(), between() 등 SQL 조건문에 해당하는 여러 메서드를 제공한다.
    • root.get("playerName")는 root 엔티티에서 "playerName" 속성을 가져온다.
    • CriteriaBuilder.like() 메서드의 첫 번째 매개변수로 해당 속성을 전달하고, 두 번째 매개변수로 검색어를 전달하여 부분 일치 검색을 수행한다.
      • "%" + playerName + "%"는 검색어 앞뒤에 "%"를 추가하여 부분 일치 검색을 수행한다.
  • 이렇게 작성된 코드로 서비스단에서 likePlayerName 메서드를 호출하여 playerName에 대한 부분 일치 검색을 수행할 수 있다.
  • 이 메서드는 Specification 객체를 반환하므로 해당 Specification을 JPA Specification을 지원하는 쿼리 메서드에 전달하여 검색을 수행한다.

 

📜 기타 메서드

// 연령대 그룹 검색
public static Specification<View> containsAgeGroup(String[] ageGroup) {
        return (root, query, CriteriaBuilder) -> {
            List<Predicate> predicates = new ArrayList<>();
            for (String age : ageGroup) {
                predicates.add(CriteriaBuilder.equal(root.get("ageGroup"), age));
            }
            return CriteriaBuilder.or(predicates.toArray(new Predicate[0]));
        };
    }

// 생년월일 기간 검색
public static Specification<FilterMember> betweenBirth(LocalDateTime startDate, LocalDateTime endDate) {
    return (root, query, CriteriaBuilder) -> CriteriaBuilder.between(root.get("birth"), startDate, endDate);
}
  • 실제로 테스트에서는 사용하지 않았지만 어느 연령대에 포함되는지 확인할 때, 생년월일 기간으로 검색할 때는 위와 같은 방식으로 사용하면 된다.

 

📜 ViewController

@Slf4j
@RestController
@RequiredArgsConstructor
public class SearchController {

    private final ViewRepository viewRepository;
    
 	@GetMapping("/views")
    public ResponseEntity<?> getSearchResults(
            @RequestParam(value = "playerName", required = false) String playerName,
            @RequestParam(value = "teamName", required = false) String teamName,
            @RequestParam(value = "minAge", required = false) Integer minAge,
            @RequestParam(value = "maxAge", required = false) Integer maxAge,
            @RequestParam(value = "sex", required = false) Sex sex,
            @RequestParam(value = "city", required = false) String city,
            Pageable pageable
    ) {

        Specification<View> spec = (root, query, criteriaBuilder) -> null;

        if (playerName != null) {
            spec = spec.and(ViewSpecification.likePlayerName(playerName));
        }

        if (teamName != null) {
            spec = spec.and(ViewSpecification.likeTeamName(teamName));
        }

        if (minAge != null) {
            spec = spec.and(ViewSpecification.rangeAge(minAge, maxAge));
        }

        if (sex != null) {
            spec = spec.and(ViewSpecification.equalsSex(sex));
        }

        if (city != null) {
            spec = spec.and(ViewSpecification.equalsCity(city));
        }

        Page<View> response = viewRepository.findAll(spec, pageable);

        return new ResponseEntity<>(response, HttpStatus.OK);
    }
}
  • 간단한 테스트를 위해 컨트롤러에 서비스 로직을 추가했다. 
  • @RequestParam 애너테이션을 이용해 필요한 값을 받는다.
  • required의 deault는 true인데 false로 설정하게 되면 해당 요청 매개변수가 필수가 아니라는 의미이다.
    • 조건 검색에 포함되지 않을 수 있으므로 추가해줘야 한다.
  • Specification<View> spec = (root, query, criteriaBuilder) -> null;
    • spec 변수는 Specification<View> 타입의 객체를 가리키는 변수이다.
    • 초기화를 위해 빈 Specification을 할당한다.
      • 어떤 조건 없이 전체 조회도 가능해야 하기 때문이다.
    • 다음으로 if 문들을 통해 각각의 매개변수(playerName, teamName, minAge, sex, city)의 값이 null이 아닌지 확인하고 해당하는 ViewSpecification을 spec에 추가한다.
    • and 메서드를 사용하여 spec에 매개변수 조건을 추가한다.
  • 마지막으로 viewRepository.findAll(spec, pageable)을 호출하여 spec에 해당하는 동적인 쿼리를 실행하고 결과를 Page<View> 타입의 변수인 response에 넣는다.
    • pageable은 페이지네이션 정보를 담고 있는 객체로, 조회 결과를 페이지 단위로 반환할 수 있도록 도와준다.
    • 위에 파라미터에 pageable 하나만 추가해도 파라미터 값으로 page, size, sort에 대한 정보를 기입받을 수 있다.
    • 자세한 건 페이징 처리에 대해 검색해 보자.

 

🧪 PostMan으로 테스트해보기

  • 위와 같이 파라미터에 값을 추가하고 빼보면서 특정 조건 별로 검색 결과가 다르게 나옴을 확인할 수 있었다.

 

📜 전체 조회 시 Json Response 예시

{
    "content": [
        {
            "memberId": 1,
            "playerName": "느그두",
            "age": 39,
            "sex": "MAN",
            "teamName": "토트넘",
            "city": "런던"
        },
        {
            "memberId": 2,
            "playerName": "벤제마",
            "age": 23,
            "sex": "WOMAN",
            "teamName": "토트넘",
            "city": "런던"
        },
        
        ...
    ]
    "pageable": {
        "pageNumber": 0,
        "pageSize": 20,
        "sort": {
            "empty": true,
            "sorted": false,
            "unsorted": true
        },
        "offset": 0,
        "paged": true,
        "unpaged": false
    },
    "last": false,
    "totalPages": 2,
    "totalElements": 40,
    "first": true,
    "size": 20,
    "number": 0,
    "sort": {
        "empty": true,
        "sorted": false,
        "unsorted": true
    },
    "numberOfElements": 20,
    "empty": false
}

 

 

 

참고

뤼튼

https://dev-setung.tistory.com/20

https://velog.io/@sierra9707/TIP-JPA에서-동적-쿼리를-처리하는-방법

https://bsssss.tistory.com/1280

https://groti.tistory.com/49

https://itecnote.com/tecnote/java-spring-data-jpa-specification-to-select-specific-columns/

728x90