이번 글에서는 Elasticsearch를 활용한 검색 기능 구현 과정을 다룹니다.
주요 목표는 이벤트 검색과 자동완성 기능을 제공하는 API를 설계하고, Elasticsearch의 강력한 검색 및 필터링 기능을 활용하여 성능과 사용자 경험을 극대화하는 것입니다.
검색 기능은 사용자가 입력한 조건에 맞는 데이터를 효율적으로 검색하거나, 입력 중인 키워드에 대한 자동완성 결과를 제공합니다.
아래는 주요 기능의 개요입니다:
자동완성 (Auto-Complete)
match
쿼리를 활용.이벤트 검색 (Event Search)
자동완성은 사용자가 입력한 검색어에 대해 관련된 제목을 반환하는 기능입니다.
Elasticsearch의 match
쿼리를 사용하여 Title
필드에서 검색어와 일치하는 데이터를 찾습니다.
@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)
로 결과 수를 제한하여 성능 최적화.
이벤트 검색은 사용자가 선택한 조건에 따라 데이터를 필터링하고, 정렬 및 페이지네이션을 제공합니다.
@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());
}
}
핵심 포인트:
- 필터링: 다양한 필터(장르, 지역, 판매 상태, 날짜 범위 등)를 조합하여 데이터 검색.
- 정렬: 정확도, 마감 임박 순서 등 사용자가 선택한 기준에 따라 정렬.
- 페이지네이션:
from
과size
를 사용해 특정 페이지의 데이터를 반환.