본문 바로가기
별밤 일지/개발

[JPA] 검색 메소드 수정하기 - JPA Specification

by 별밤 에디터 2 2023. 12. 7.

전 편에서 언급했던 것처럼 이번 글에서는 비효율적인 검색 로직을 개선해보려고 한다.

 

 

[JPA] 검색 메소드 수정하기 - N+1 문제

장기 프로젝트 별 헤는 밤에 있는 문제들을 하나씩 해결해보는 시간! 구경가기 👇 별 헤는 밤: 밤하늘, 별자리, 여행정보와 날씨예보까지 - Google Play 앱 오늘부터 별잘알! 오늘밤 별자리 정보, 날

starsufers.tistory.com

 

기존 코드

더보기

매개변수

  1. Filter
    • areaCodeList - 지역변수
    • hashTagIdList - 해쉬태그 리스트
  2. searchKey - 검색어
// 생략 //
public List<SearchParams1> getObservationWithFilter(Filter filter, String searchKey) {
        List<Long> areaCodeList = filter.getAreaCodeList();
        List<Long> hashTagIdList= filter.getHashTagIdList();    //필터 해쉬태그 리스트

        List<SearchParams1> resultParams = new ArrayList<>();   //최종결과 param 리스트
        List<Long> hashtagResult = new ArrayList<>();   //해쉬태그 결과
        List<Long> filterIdList = new ArrayList<>();    //필터결과(해쉬태그, 지도 포함)id 리스트
        List<Observation> searchResult = new ArrayList<>(); //필터+검색어 결과 리스트


        if (!hashTagIdList.isEmpty()) {
            for(Long hashTagId : hashTagIdList){
                List<ObserveHashTag> observeHashTags = observeHashTagRepository.findByHashTagId(hashTagId);
                for (ObserveHashTag observeHashTag : observeHashTags) {
                    Long observationId = observeHashTag.getObservationId();
                    if (!hashtagResult.contains(observationId)) { //관광지 중복 제거
                        hashtagResult.add(observationId);
                    }
                }
            }
        }

        if (!areaCodeList.isEmpty()) {
            for (Long areaCode : areaCodeList) {
                List<Observation> observationList = observationRepository.findByAreaCode(areaCode);
                if (hashTagIdList.isEmpty()) {
                    //해쉬태그 없으면 지역결과 전부추가
                    for (Observation observation : observationList) {
                        filterIdList.add(observation.getObservationId());
                    }
                } else {
                    //해쉬태그 있으면 필터 중첩
                    for (Observation observation : observationList) {
                        Long observationId = observation.getObservationId();
                        if (hashtagResult.contains(observationId)) {
                            //해시태그결과에서 지역 있으면 filter최종결과에 포함
                            filterIdList.add(observationId);
                        }
                    }
                }
            }
        } else {
            //area 비어있으면
            filterIdList = hashtagResult;
        }

        if (searchKey != null) {
            List<Observation> keyResult = new ArrayList<>();    //검색결과 받아올 리스트
            searchResult = observationRepository.findByObservationNameContainingOrOutlineContaining(searchKey, searchKey);
            keyResult = observationRepository.findByObservationNameContainingOrOutlineContaining(searchKey, searchKey);

            if (!hashTagIdList.isEmpty() || !areaCodeList.isEmpty()) {
                //필터 받은게 없으면 그냥 검색결과 전달, 있으면 중첩 검색
                for (Observation observation : keyResult) {
                    //전체 검색어 결과 돌면서
                    if (!filterIdList.contains(observation.getObservationId())) {
                        //필터결과에 검색어 결과 없으면 필터+검색어검색결과에서 삭제
                        searchResult.remove(observation);
                    }
                }
            }
        } else {
            for(Long p : filterIdList)
                searchResult.add(getObservation(p));
        }


        //결과 param에 넣음
        for(Observation observation : searchResult){

            if(observation.getObservationId()==999)
                continue;
            SearchParams1 searchParams1 = new SearchParams1();
            searchParams1.setItemId(observation.getObservationId());
            searchParams1.setTitle(observation.getObservationName());
            //주소를 두단어까지 줄임
            String address = observation.getAddress();
            int i = address.indexOf(' ');
            if (i != -1){
                int j = address.indexOf(' ', i+1);
                if(j != -1){
                    searchParams1.setAddress(observation.getAddress().substring(0, j));
                } else{
                    searchParams1.setAddress(observation.getAddress());
                }
            } else{
                searchParams1.setAddress(observation.getAddress());
            }
//            searchParams1.setAddress(observation.getAddress());
            searchParams1.setLatitude(observation.getLatitude());
            searchParams1.setLongitude(observation.getLongitude());
            searchParams1.setIntro(observation.getIntro());
            searchParams1.setContentType(observation.getObserveType());
            searchParams1.setLight(observation.getLight());
            if (!observeImageRepository.findByObservationId(observation.getObservationId()).isEmpty()) {
                ObserveImage observeImage = observeImageRepository.findByObservationId(observation.getObservationId()).get(0);
                searchParams1.setThumbnail(observeImage.getImage());
            } else {
                searchParams1.setThumbnail(null);
            }
            List<ObserveHashTag> hashTagList = observeHashTagRepository.findByObservationId(observation.getObservationId());
            List<String> hashTagNames = new ArrayList<>();
            int k = 0;
            for (ObserveHashTag hashTag : hashTagList){
                if(k>2)
                    break;
                hashTagNames.add(hashTag.getHashTagName());
                k++;
            }
            searchParams1.setHashTagNames(hashTagNames);

            resultParams.add(searchParams1);
        }
        return resultParams;
    }

 

기존 ObservationService 코드를 보면 다음과 같이 구성되어 있다.

  1. 각 해시태그를 통한 관측지 조회 → 해시태그 개수만큼의 쿼리 호출
  2. 해시태그 결과로 나온 관측지 중복 제거 → 결과 개수만큼의 비교 연산
  3. 각 지역 태그를 통한 관측지 검색 → 지역태그 개수만큼의 쿼리 호출
  4. 해시태그 결과와 지역결과 조합 → 두 리스트 개수만큼의 비교 연산
  5. 검색어, 제목 비교로 관측지 조회 → like 조회 쿼리 호출
  6. 검색어, 개요 비교로 관측지 조회 → like 조회 쿼리 호출
  7. 4번의 결과와 5, 6의 결과 조합 → 두 리스트 개수만큼 비교 연산

이처럼 7단계의 과정을 거쳐서 결과를 출력하니 굉장히 오래 걸릴 수 밖에 없다.

검색 로직 개선을 위해 JPA Specification을 사용해서 다음과 같이 변경해보려 한다.

  • 불필요하게 여러 번 호출하는 쿼리를 1개의 쿼리로 통합
  • 필터, 검색어 유무에 따른 동적 쿼리 생성

 

JPA Specification

Specification은 특정 기준에 따라 검색을 수행하므로 동적 쿼리를 생성하는데 각 조건을 Specification으로 만들어 JPA의 findAll에 적용하여 다양한 조건을 사용할 수 있다.

지금 상황처럼 검색 시 각 필터의 유무, 검색어의 유무와 같이 여러 가지 조건에 따라 쿼리가 달라질 때 유용하게 사용이 가능하다.

 

적용방법

  • ObservationRepository
public interface ObservationRepository extends JpaRepository<Observation, Long>, JpaSpecificationExecutor<Observation>{
}

JpaRepository인터페이스에 JpaSpecification 상속

 

 

  • Specification 정의

Specification을 사용할 때는 기본적으로 각 엔티티에 대해 Specification을 생성하기 위해 toPredicate 함수를 override한다.

Predicate toPredicate(Root<Observation> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder)

  • Root<T> root : 쿼리의 루트 엔티티, root.get(”속성이름”)을 통해 값에 접근할 수 있다. (root가 엔티티라고 생각하면 됨)
  • CriteriaQuery<?> query : 쿼리 자체를 정의하는 인터페이스, subquery, group by 등을 조작할 때 쓰인다.
  • CriteriaBuilder : 쿼리의 조건을 생성할 때 쓰인다. equals, and, or 등의 조건을 조작할 때 많이 쓰인다.

여러 가지 조건을 추가하기 위해서 List 형태로 predicate를 추가하고 이 리스트를 Criteria builder를 통해 변환하여 하나의 Predicate를 반환하는 형식으로 정의한다.

 

[예시코드]

public static Specification<Observation> likeSearchKeyAndInFilter(String searchKey, List<Long> hashtagIds, List<Long> areaCodes) {
        return new Specification<Observation>() {
            @Override
            public Predicate toPredicate(Root<Observation> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
                List<Predicate> predicates = new ArrayList<>();
								predicates.add(criteriaBuilder.exists(hashTagSubquery));
								predicates.add(criteriaBuilder.and(root.get("areaCode").in(areaCodes)));
								final Predicate[] predicateArray = new Predicate[predicates.size()];
                return criteriaBuilder.and(predicates.toArray(predicateArray));
            }
        };
    }

 

 

  • Specification 사용

Specification은 JpaRepository의 findall을 사용할 때 조건으로 넣어주면 된다.

이때 조건마다 다른 Specification을 적용하면 동적으로 조건을 추가해 줄 수 있다.

//Specification으로 조건 동적생성
Specification<Observation> spec = Specification
				.where(ObservationSpecification.likeSearchKeyAndInFilter(searchKey, hashTagIdList, areaCodeList));
searchResult= observationRepository.findAll(spec,pageable);

 

 

최종 코드

더보기
public static Specification<Observation> likeSearchKeyAndInFilter(String searchKey, List<Long> hashtagIds, List<Long> areaCodes) {
        return new Specification<Observation>() {
            @Override
            public Predicate toPredicate(Root<Observation> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
                List<Predicate> predicates = new ArrayList<>();
                if (searchKey!=null && !searchKey.isEmpty()) {
                    Predicate predicateForObservationName = criteriaBuilder
                            .like(root.get("observationName"), "%" + searchKey + "%");  //관측지 이름에서 검색어 like 조회
                    Predicate predicateForOutline = criteriaBuilder
                            .like(root.get("outline"), "%" + searchKey + "%");  //관측지 개요에서 검색어 like 조회

                    predicates.add(criteriaBuilder.or(predicateForObservationName, predicateForOutline));   //and로 조건 두개 병합합
                }
                if (hashtagIds!=null && !hashtagIds.isEmpty()) {
                    Subquery<ObserveHashTag> hashTagSubquery = query.subquery(ObserveHashTag.class);
                    Root<ObserveHashTag> observeHashTag = hashTagSubquery.from(ObserveHashTag.class);
                    hashTagSubquery.select(observeHashTag).where(criteriaBuilder
                                    .equal(observeHashTag.get("observationId"), root.get("observationId")), //ObservehashTag테이블과 observation테이블 조인조외
                            criteriaBuilder.and(observeHashTag.get("hashTagId").in(hashtagIds))); // 인자로 받은 해쉬태그리스트에 포함되는지 확인

                    predicates.add(criteriaBuilder.exists(hashTagSubquery));
                }
                if (areaCodes!=null  && !areaCodes.isEmpty()) {
                    predicates.add(criteriaBuilder.and(root.get("areaCode").in(areaCodes)));
                }

                final Predicate[] predicateArray = new Predicate[predicates.size()];
                return criteriaBuilder.and(predicates.toArray(predicateArray));
            }
        };
    }

 

 

최종적으로 완성된 코드는 위와 같다. 해당 Specification을 적용해 실행시켜 보면 다음과 같은 쿼리가 생성된다.

select observatio0_.observation_id as observat1_12_, observatio0_.address as address2_12_, observatio0_.area_code as area_cod3_12_, observatio0_.closed_day as closed_d4_12_, observatio0_.course_order as course_o5_12_, observatio0_.guide as guide6_12_, observatio0_.intro as intro7_12_, observatio0_.latitude as latitude8_12_, observatio0_.light as light9_12_, observatio0_.link as link10_12_, observatio0_.longitude as longitu11_12_, observatio0_.nature as nature12_12_, observatio0_.observation_name as observa13_12_, observatio0_.observe_type as observe14_12_, observatio0_.operating_hour as operati15_12_, observatio0_.outline as outline16_12_, observatio0_.parking as parking17_12_, observatio0_.phone_number as phone_n18_12_, observatio0_.reserve as reserve19_12_, observatio0_.saved as saved20_12_ 
from observation observatio0_ 
where (observatio0_.observation_name like ? or observatio0_.outline like ?) 
and (exists (select observehas1_.observe_hash_tag_list_id from observe_hash_tag observehas1_ where observehas1_.observation_id=observatio0_.observation_id and (observehas1_.hash_tag_id in (1))))
 and (observatio0_.area_code in (1)) 
order by observatio0_.observation_id 
asc 
limit ?

 

기존에 여러 번 호출되었던 쿼리들이 한 개로 정리되었고 Specification을 통해 정의되었던 조건들이 추가된 것을 확인할 수 있다.

기존 코드와 효율성을 비교했을 때는 다음과 같다.

  • 기존 코드 소요 시간 : time is 212ms
  • 수정 후 소요 시간 : time is 96ms

데이터양이 약 500개 정도일 때를 기준으로 테스트했기 때문에 전체적인 소요 시간은 둘 다 적지만 수정 후에 약1/2로 시간이 줄어든 것을 확인할 수 있다.

 

개인적인 생각

Specification은 상대적으로 많이 쓰이는 기술은 아니다. 사용해 보며 느낀 점은 일단 어렵다는 것이었다. 기존에 있는 Java 지식만으로 직관적인 구현이 가능하기 보다는 Specification에 관한 공부가 필요하다는 느낌이다. (같은 이유에서 팀원들과 함께 쓰기에는 더욱이 좋은 기술은 아닌 것 같다.)

그러나 동적으로 조건을 생성할 수 있다는 점에서 가치가 있다. 예를 들어 검색 조건이 매번 추가된다고 가정하면 JPA에서는 매번 새로운 쿼리를 생성해야 하겠지만 Specification은 findall에 적용하는 조건만 선택적으로 적용하면 된다.

 

과거로 돌아가 다시 검색 로직을 수정하게 된다면 Specification을 사용하지는 않을 것 같다. QueryDsl 같은 더 간단하고 이해하기 쉬운 기술들이 있기 때문이다. 당시에는 조건문을 바탕으로 동적으로 조건을 생성할 수 있다는 점에서 Specification을 선택했는데 구현하면서 복잡해서 살짝 후회한 기억이 있다.

 

결국 이번에 프로젝트에 QueryDsl을 적용했다. 다음 시간에는 QueryDsl 적용기에 대해 작성할 예정이다.

 


<별 헤는 밤> 구경가기 👇

 

별 헤는 밤: 밤하늘, 별자리, 여행정보와 날씨예보까지 - Google Play 앱

오늘부터 별잘알! 오늘밤 별자리 정보, 날씨·광공해·월령까지 고려한 '관측적합도', 주변 관측지 검색으로 누구나 쉽게 밤하늘을 즐겨보세요.

play.google.com