[ElasticSearch] ElasticSearch Native Query 최신 버전 작성
엘라스틱 서치 쿼리 작성 문제
스프링부트 최신버전과 함께 spring data elasticsearch 라이브러리도 최신 버전을 사용하고 있지만,
최신버전에서 새롭게 사용되는 쿼리 문법에 대한 래퍼런스가 부족하여 구현하는데 어려움이 있었다.
버전에 따른 문법 변화에 영향을 덜 받기 위해 @Query으로 메서드를 구현하는 방법을 생각하였으나,
현재 시스템의 구현하려는 검색 엔진에 많은 조건문, 필터링이 추가됨에 따라 동적 쿼리 구현이 필요하다고 판단하였다.
따라서 Native Query 최신 버전에 대해 공식 문서와 라이브러리를 뜯어보며 작성하게 되었다.
버전 환경
개발 환경으로는 SpringBoot 3.4.2버전과, 엘라스틱 서치와 연동을 위해 spring-boot-starter-data-elasticsearch 3.4.2버전을 사용하고 있다.
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch:3.4.2'
spring-boot-starter-data-elasticsearch 라이브러리 내부에 Native Query를 작성하기 위해 필요한
spring data elasticsearch^5.4.2 라이브러리가 존재한다.
공식문서 예시
스프링 공식문서에서는 아래 코드와 같이 Native Query 예시를 제공해준다.
Query query = NativeQuery.builder()
.withAggregation("lastNames", Aggregation.of(a -> a
.terms(ta -> ta.field("lastName").size(10))))
.withQuery(q -> q
.match(m -> m
.field("firstName")
.query(firstName)
)
)
.withPageable(pageable)
.build();
SearchHits<Person> searchHits = operations.search(query, Person.class);
https://docs.spring.io/spring-data/elasticsearch/reference/elasticsearch/template.html
전체적인 흐름은 아래와 같다.
- Query Builder를 통해 Query 객체 생성
- ElasticSearchOperation을 통하여 search엔드포인트로 요청을 보낸다.
- 정의해둔 Document 클래스를 통하여 응답 값을 파싱한다.
쿼리 분석
Native Query를 통하여 변환할 쿼리는 아래와 같다.
약의 이름, 색상, 모양 필터 정보를 가지고 맞는 제약 데이터를 검색하는 쿼리이다.
충족해야할 요건은 아래와 같다.
- 입력한 약 이름을 포함하는 약 리스트 출력
- 입력된 색상 리스트에 포함된 약 출력
- 입력된 모양 리스트에 포함된 약 출력
약 이름은 TEXT필드로 토큰화되어 저장되어 있기 때문에, match 쿼리를 통해 검색한다.
색상 리스트와 모양 리스트 검색의 경우 리스트 중 하나만 만족하면 되기 때문에 OR 연산자가 적용되는 should절에 배치하였다.
should절은 데이터를 조건에 따라 필터링 하는 역할이 아니라 만족하면 관련성 점수를 높여 상위에 배치시켜준다. 하지만 현재 시스템에서는 조건에 만족하지 않는 것은 출력할 필요가 없기 때문에 minimum_should_match를 1로 지정해주었다.
should절 하나에 색상 쿼리와 모양 쿼리를 같이 넣으면, minimum_should_match가 같이 적용되기 때문에, 원하는 결과가 나오지 않을 수 있다.
따라서 각각의 should절을 상위의 bool쿼리로 나누어 must절에 배치하였다.
참고
같은 라인에 Must절이 존재한다면 minimum_should_match의 default값은 1이고, 존재하지 않는다면 default값은 0이다.
GET medicine_index/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"itemName": "아스피린"
}
},
{
"bool": {
"should": [
{ "term": { "color": "하양" } },
{ "term": { "color": "노랑" } },
{ "term": { "color": "파랑" } }
],
"minimum_should_match": 1
}
},
{
"bool": {
"should": [
{ "term": { "shape": "원형" } },
{ "term": { "shape": "삼각형" } }
],
"minimum_should_match": 1
}
}
]
}
},
"size": 10
}
쿼리 변환
이제 본격적으로 Native Query를 통해 동적 쿼리를 구현하였다.
기존에 간단한 검색 구현을 위해 사용한 ElasticRepository 인터페이스 외에 NativeQuery를 정의할 인터페이스를 구현하였다.
인터페이스를 정의하여 틀을 정해놓고, 로직 수정의 필요성에 따라 유연하게 변경하기 위함
public interface MedicineSearchCustomRepository {
List<MedicineDocument> findMedicineBySearching(String itemName, List<String> colors, List<String> shape, Pageable pageable);
}
아래는 인터페이스를 상속한 쿼리 구현 클래스이다.
필터링 조건에 따라 쿼리를 동적으로 변환시킬 수 있게 각 조건에 If문을 추가하였다.
코드가 복잡해 보이지 않게, 전체 코드를 인라인으로 한번에 작성하기 보단, 나눠서 분기 처리 하였다.
@Repository
@AllArgsConstructor
public class MedicineSearchCustomRepositoryImpl implements MedicineSearchCustomRepository {
private final ElasticsearchOperations elasticsearchOperations;
@Override
public List<MedicineDocument> findMedicineBySearching(String itemName, List<String> colors, List<String> shapes, Pageable pageable) {
Query boolQuery = QueryBuilders.bool(boolQueryBuilder -> {
if (itemName != null && !itemName.isEmpty()) {
boolQueryBuilder.must(QueryBuilder -> QueryBuilder.match(matchQueryBuilder -> matchQueryBuilder
.field("itemName")
.query(itemName)));
}
if (colors != null && !colors.isEmpty()) {
Query colorQuery = QueryBuilders.bool(colorBool ->
colorBool.should(colors.stream()
.map(color -> QueryBuilders.term(termQueryBuilder -> termQueryBuilder
.field("color")
.value(color)))
.toList()
).minimumShouldMatch("1")
);
boolQueryBuilder.must(colorQuery);
}
// Shapes 관련 조건을 별도의 boolQuery로 처리 (최소 1개 만족)
if (shapes != null && !shapes.isEmpty()) {
Query shapeQuery = QueryBuilders.bool(shapeBool ->
shapeBool.should(shapes.stream()
.map(shape -> QueryBuilders.term(termQueryBuilder -> termQueryBuilder
.field("shape")
.value(shape)))
.toList()
).minimumShouldMatch("1")
);
boolQueryBuilder.must(shapeQuery);
}
return boolQueryBuilder;
});
NativeQuery nativeQuery = NativeQuery.builder()
.withQuery(boolQuery)
.withPageable(pageable)
.build()
;
SearchHits<MedicineDocument> searchHits=elasticsearchOperations.search(nativeQuery, MedicineDocument.class);
return searchHits.getSearchHits()
.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
}
}