본문 바로가기

프로젝트/여행지 오픈 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배의 시간적 효율을 얻을 수 있었다.