본문 바로가기

프로젝트/여행지 오픈 API

[Elasticsearch] 네트워크 오버헤드 효율 비교 및 개선(쿼리 변경)

목차

     

    개요

     

    기존 프로젝트에서 우리는 오타를 보정하기 위해 검색어와 가장 유사한 검색어들을 차등적으로 리스트의 형태로 제공했다.

    이 과정에서 Ngram과 Fuzzy의 분석 결과를 요청하고, 이를 다시 합산하여 가장 스코어가 높은 순서대로 유사하다는 결론을 내렸다.

     

        //통합 제목 검색 : 제목 일치 or (Fuzzy + ngram)
        @GetMapping("/title/aggregate-search")
        public List<ResponseRestaurantDto> searchTitleComprehensive(@RequestParam("title") String title,
                                                                    @RequestParam("maxResults") int maxResults,
                                                                    @RequestParam("fuzziness") int fuzziness,
                                                                    @RequestParam("fuzzyPrimary") boolean fuzzyPrimary)
        {
            try {
                List<ResponseRestaurantDto> responseRestaurantDtoList = restaurantServiceBasic.searchExactRestaurantName(title, maxResults);
                if (responseRestaurantDtoList.size() != 0) {
                    return responseRestaurantDtoList;
                } //1. 일치 제목 검색이 있다면 이를 리스트에 추가 후 리턴
                return restaurantTitleService.searchFuzzyAndNgram(title, maxResults, fuzziness, fuzzyPrimary); //2. 결과가 없다면 두 검색 진행 후 유사도별로 정렬
            } catch (Exception e) {
                log.error("[ERR LOG] {}", e.getMessage());
                throw new CommonException(ExceptionType.RESTAURANT_AGGREGATE_TITLE_SEARCH_FAIL);
            }
        }

     

     

    스코어를 합산한 네이밍 결과값을 토대로, 최종적인 결과를 가져오는 과정에서 should 구문을 활용하여 네트워크 오버헤드를 최대한 줄여보는 로직을 작성하였다.

     

    당연히 여러 번의 동일 API 요청보다는, 한 번에 모든 요청을 보내고 이를 후처리하는 과정이 더욱 효율적일 것이다.

    이를 모의로 전송하고, 결과를 받아오기까지 걸리는 시간을 측정해보았다.

     

    코드

    API 반복 요청을 통한 코드

        public List<ResponseRestaurantDto> searchFuzzyAndNgramTest(String title, int maxResults, int fuzziness, boolean fuzzyPrimary) {
            //maxResult가 적으면 적은 List셋에 고만고만한 값만 나와 유의미한 검색 결과가 나오지 않음. 가장 큰 이슈였다.
            int searchCount = Math.max(maxResults, 40); //최소 40개로 검색 결과를 보장 : 10개 정도라면 비슷한 값만 나올 수도 있음.
            int fuzzyWeight, ngramWeight; //가중치 여부 : fuzzyPrimary에 따라 다름
            if(fuzzyPrimary) { //가중치 부여
                fuzzyWeight = 7;
                ngramWeight = 3;
            } else {
                fuzzyWeight = 3;
                ngramWeight = 7;
            }
    
            List<ResponseRestaurantDto> fuzzyList = searchTitleUseFuzzyDto(title, searchCount, fuzziness);
            List<ResponseRestaurantDto> ngramList = searchTitleUseNgramDto(title, searchCount);
    
    
            //두 리스트를 합치고 스코어가 있다면 가중
            HashMap<String, Float> alladdHashMap = new HashMap<>();
            //1. fuzzy 삽입
            for (ResponseRestaurantDto dto : fuzzyList) {
                alladdHashMap.put(dto.getRestaurantName(), (dto.getScore() * fuzzyWeight));
            }
            //2. ngram 삽입, fuzzy와 동일한 지명이 있다면 스코어 가중
            for (ResponseRestaurantDto dto : ngramList) {
                if(alladdHashMap.containsKey(dto.getRestaurantName())) {
                    alladdHashMap.put(dto.getRestaurantName(),
                            alladdHashMap.get(dto.getRestaurantName()) + (dto.getScore() * ngramWeight));
                } else alladdHashMap.put(dto.getRestaurantName(), (dto.getScore() * ngramWeight));
            }
    
            //스트림을 통해 해시맵 내림차순 정렬 후 리스트화
            List<String> nameList = alladdHashMap.entrySet()
                    .stream() //HashMap을 스트림으로 변환
                    .sorted(Map.Entry.<String, Float>comparingByValue().reversed()) //value를 기준으로 내림차순 정렬
                    .limit(maxResults)
                    .map(Map.Entry::getKey)
                    .collect(Collectors.toList());
    
            List<ResponseRestaurantDto> queryResult = new ArrayList<>();
    
            // 각 제목에 대한 개별 검색
            for (String name : nameList) {
    
                NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                        .withQuery(QueryBuilders.termQuery("food_name.keyword", name))
                        .withPageable(PageRequest.of(0, maxResults))
                        .build();
    
                // 각 검색 결과를 저장
                queryResult.addAll(toolsForRestauantService.getListBySearchHits(elasticsearchRestTemplate.search(searchQuery, Restaurant.class)));
            }
            return queryResult;
        }

     

     

    Bool - Should를 사용해 모든 요청값을 가져오고, 이후 서버에서 후처리하는 로직

        public List<ResponseRestaurantDto> searchFuzzyAndNgram(String title, int maxResults, int fuzziness, boolean fuzzyPrimary) {
            //maxResult가 적으면 적은 List셋에 고만고만한 값만 나와 유의미한 검색 결과가 나오지 않음. 가장 큰 이슈였다.
            int searchCount = Math.max(maxResults, 40); //최소 40개로 검색 결과를 보장 : 10개 정도라면 비슷한 값만 나올 수도 있음.
            int fuzzyWeight, ngramWeight; //가중치 여부 : fuzzyPrimary에 따라 다름
            if(fuzzyPrimary) { //가중치 부여
                fuzzyWeight = 7;
                ngramWeight = 3;
            } else {
                fuzzyWeight = 3;
                ngramWeight = 7;
            }
    
            List<ResponseRestaurantDto> fuzzyList = searchTitleUseFuzzyDto(title, searchCount, fuzziness);
            List<ResponseRestaurantDto> ngramList = searchTitleUseNgramDto(title, searchCount);
    
    
            //두 리스트를 합치고 스코어가 있다면 가중
            HashMap<String, Float> alladdHashMap = new HashMap<>();
            //1. fuzzy 삽입
            for (ResponseRestaurantDto dto : fuzzyList) {
                alladdHashMap.put(dto.getRestaurantName(), (dto.getScore() * fuzzyWeight));
            }
            //2. ngram 삽입, fuzzy와 동일한 지명이 있다면 스코어 가중
            for (ResponseRestaurantDto dto : ngramList) {
                if(alladdHashMap.containsKey(dto.getRestaurantName())) {
                    alladdHashMap.put(dto.getRestaurantName(),
                            alladdHashMap.get(dto.getRestaurantName()) + (dto.getScore() * ngramWeight));
                } else alladdHashMap.put(dto.getRestaurantName(), (dto.getScore() * ngramWeight));
            }
    
            //스트림을 통해 해시맵 내림차순 정렬 후 리스트화
            List<String> nameList = alladdHashMap.entrySet()
                    .stream() //HashMap을 스트림으로 변환
                    .sorted(Map.Entry.<String, Float>comparingByValue().reversed()) //value를 기준으로 내림차순 정렬
                    .limit(maxResults)
                    .map(Map.Entry::getKey)
                    .collect(Collectors.toList());
    
            //추려진 값들을 should를 사용하여 동시적으로 조회 (리스트 내부에 있는 제목의 검색 결과는 리턴됨)
            BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
            for (String name : nameList) {
                boolQueryBuilder.should(QueryBuilders.termQuery("food_name.keyword", name));
            }
            NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                    .withQuery(boolQueryBuilder)
                    .withPageable(PageRequest.of(0, maxResults)) // maxResults만큼 결과
                    .build();
    
            List<ResponseRestaurantDto> queryResult = toolsForRestauantService.getListBySearchHits(elasticsearchRestTemplate.search(searchQuery, Restaurant.class));
    
            //최종 합산 결과 저장, 이전 스코어 순서대로 재정렬
            List<ResponseRestaurantDto> results = new ArrayList<>();
            for (String name : nameList) {
                queryResult.stream()  // queryResult 리스트를 스트림으로 변환
                        .filter(dto -> dto.getRestaurantName().equals(name)) // 현재 이름과 일치하는 ResponseRestaurantDto 객체 필터링
                        .findFirst() // 필터링된 스트림에서 첫 번째 요소 찾기 (일치하는 첫 번째 객체)
                        .ifPresent(results::add); // 일치하는 객체가 존재하면 sortedResults 리스트에 추가
            }
    
            return results;
        }

     

     

    실제 요청 시간 테스트

     

    Bool - should 개선 로직(count = 10)

    bool - should를 사용하여 검색 결과를 한번에 가져오는 로직, 76ms

     

    기존 API 반복 요청 로직(count = 10)

     

    반복문을 사용하여 단일 API의 호출을 전부 가져오는 로직, 330ms

     

     

     

    10회의 경우 최대 4배 정도의 시간 차이가 나는 것을 확인할 수 있다.

    물론 이 값은 요청 회수가 커질수록 유의미하게 차이난다.

    우리 서비스에서 최대한 제공할 수 있는 count인 100의 경우를 살펴보자.

     

     

    100개 요청의 경우

     

    100개 요청, 124ms
    100개 요청, 1495ms

     

    이 정도면 사실상 API 역할을 할 수 없는 요청 속도이다.

    한번의 요청으로 모든 결과값을 얻고 이를 서버 단에서 비즈니스 로직으로 다시 분류하는 과정을 거쳐, 최대 9~10배의 시간적 효율을 얻을 수 있었다.