엘라스틱 서치 검색 구현 (6) - 검색 기능 구현

오형상·2025년 1월 14일
0

Ficket

목록 보기
25/27

이번 글에서는 Elasticsearch를 활용한 검색 기능 구현 과정을 다룹니다.
주요 목표는 이벤트 검색자동완성 기능을 제공하는 API를 설계하고, Elasticsearch의 강력한 검색 및 필터링 기능을 활용하여 성능과 사용자 경험을 극대화하는 것입니다.

검색 기능 개요

검색 기능은 사용자가 입력한 조건에 맞는 데이터를 효율적으로 검색하거나, 입력 중인 키워드에 대한 자동완성 결과를 제공합니다.
아래는 주요 기능의 개요입니다:

  1. 자동완성 (Auto-Complete)

    • 사용자가 입력한 검색어를 기반으로 최대 5개의 관련 항목을 반환.
    • Elasticsearch의 match 쿼리를 활용.
  2. 이벤트 검색 (Event Search)

    • 다양한 필터(장르, 지역, 판매 상태, 날짜 범위 등)를 적용한 상세 검색.
    • 결과를 페이지네이션 및 정렬하여 반환.

기능 구현

1. 자동완성 API

자동완성은 사용자가 입력한 검색어에 대해 관련된 제목을 반환하는 기능입니다.
Elasticsearch의 match 쿼리를 사용하여 Title 필드에서 검색어와 일치하는 데이터를 찾습니다.

API 엔드포인트
@GetMapping("/auto-complete")
public Mono<List<AutoCompleteRes>> autoComplete(@RequestParam("query") String query) {
    return Mono.fromFuture(() -> CompletableFuture.supplyAsync(() -> searchService.autoComplete(query)));
}
서비스 로직
public List<AutoCompleteRes> autoComplete(String query) {
    try {
        SearchRequest searchRequest = new SearchRequest.Builder()
                .index(INDEX_NAME)
                .query(q -> q.match(m -> m.field("Title").query(query)))
                .size(5)
                .build();

        SearchResponse<AutoCompleteRes> response = elasticsearchClient.search(searchRequest, AutoCompleteRes.class);

        return response.hits().hits().stream()
                .map(Hit::source)
                .collect(Collectors.toList());
    } catch (Exception e) {
        log.error("자동완성 쿼리 실행 중 오류 발생: {}", e.getMessage(), e);
        return Collections.emptyList();
    }
}

핵심 포인트:

  • match 쿼리: 사용자가 입력한 검색어를 기반으로 Title 필드에서 일치하는 데이터를 찾습니다.
  • 최대 5개 결과 반환: size(5)로 결과 수를 제한하여 성능 최적화.

2. 이벤트 검색 API

이벤트 검색은 사용자가 선택한 조건에 따라 데이터를 필터링하고, 정렬 및 페이지네이션을 제공합니다.

API 엔드포인트
@GetMapping("/detail")
public Mono<SearchResult> searchByFilter(
        @RequestParam String title,
        @RequestParam(required = false) List<Genre> genreList,
        @RequestParam(required = false) List<Location> locationList,
        @RequestParam(required = false) String startDate,
        @RequestParam(required = false) String endDate,
        @RequestParam(required = false) List<SaleType> saleTypeList,
        @RequestParam(defaultValue = "SORT_BY_ACCURACY") SortBy sortBy,
        @RequestParam(defaultValue = "1") int pageNumber,
        @RequestParam(defaultValue = "20") int pageSize
) {
    return Mono.fromFuture(() -> CompletableFuture.supplyAsync(() -> searchService.searchEventsByFilter(
            title, genreList, locationList, saleTypeList, startDate, endDate, sortBy, pageNumber, pageSize
    )));
}
서비스 로직

검색 조건에 따라 Elasticsearch Bool Query를 생성하고, 필터를 조합하여 결과를 반환합니다.

public SearchResult searchEventsByFilter(String title, List<Genre> genreList, List<Location> locationList,
                                         List<SaleType> saleTypeList, String startDate, String endDate,
                                         SortBy sortBy, int pageNumber, int pageSize) {
    List<Query> mustQueries = new ArrayList<>();
    int from = (pageNumber - 1) * pageSize;

    // 장르 필터
    if (isNotEmpty(genreList)) {
        mustQueries.add(Query.of(q -> q.nested(n -> n
                .path("Genres")
                .query(query -> query.terms(t -> t
                        .field("Genres.Genre")
                        .terms(TermsQueryField.of(tq -> tq.value(toFieldValues(genreList)))))))));
    }

    // 제목 필터
    if (title != null) {
        mustQueries.add(Query.of(q -> q.match(m -> m.field("Title").query(title))));
    }

    // 지역 필터
    if (isNotEmpty(locationList)) {
        mustQueries.add(Query.of(q -> q.terms(t -> t
                .field("Location")
                .terms(TermsQueryField.of(tq -> tq.value(toFieldValues(locationList)))))));
    }

    // 날짜 필터
    if (startDate != null && endDate != null) {
        mustQueries.add(Query.of(q -> q.nested(n -> n
                .path("Schedules")
                .query(query -> query.range(r -> r
                        .field("Schedules.Schedule")
                        .gte(JsonData.of(startDate))
                        .lte(JsonData.of(endDate)))))));
    }

    // 판매 상태 필터
    if (isNotEmpty(saleTypeList)) {
        LocalDateTime now = LocalDateTime.now();
        List<Query> saleTypeQueries = saleTypeList.stream()
                .map(saleType -> createSaleTypeQuery(saleType, now))
                .toList();

        mustQueries.add(Query.of(q -> q.bool(b -> b.should(saleTypeQueries))));
    }

    // Bool Query 생성
    Query boolQuery = Query.of(q -> q.bool(b -> b.must(mustQueries)));

    // 검색 요청 생성
    SearchRequest.Builder searchRequestBuilder = new SearchRequest.Builder()
            .index(INDEX_NAME)
            .query(boolQuery)
            .from(from)
            .size(pageSize);

    // 정렬 추가
    if (sortBy != null) {
        if (sortBy.getNestedPath() != null) {
            searchRequestBuilder.sort(s -> s.field(f -> f
                    .field(sortBy.getField())
                    .order(sortBy.getOrder())
                    .nested(n -> n.path(sortBy.getNestedPath()))));
        } else {
            searchRequestBuilder.sort(s -> s.field(f -> f
                    .field(sortBy.getField())
                    .order(sortBy.getOrder())));
        }
    }

    try {
        SearchResponse<Event> response = elasticsearchClient.search(searchRequestBuilder.build(), Event.class);

        long totalSize = response.hits().total().value();
        int totalPages = (int) Math.ceil((double) totalSize / pageSize);

        List<Event> eventList = response.hits().hits().stream()
                .map(Hit::source)
                .toList();

        return new SearchResult(totalSize, totalPages, eventList);
    } catch (Exception e) {
        log.error("이벤트 검색 중 오류 발생: {}", e.getMessage(), e);
        return new SearchResult(0L, 0L, Collections.emptyList());
    }
}

핵심 포인트:

  • 필터링: 다양한 필터(장르, 지역, 판매 상태, 날짜 범위 등)를 조합하여 데이터 검색.
  • 정렬: 정확도, 마감 임박 순서 등 사용자가 선택한 기준에 따라 정렬.
  • 페이지네이션: fromsize를 사용해 특정 페이지의 데이터를 반환.

결과

자동 완성

이벤트 검색

0개의 댓글