검색
Elastichsearch의 검색은 크게 용어 수준 쿼리와 전체 텍스트 쿼리로 구분된다. 전체 텍스트 쿼리는 용어 수준 쿼리와 달리 분석이 되기 때문에 정확한 일치를 위해서 사용되지 않으며 검색어를 포함하는 값을 찾는데 사용된다. 이러한 특성으로 인덱싱에서 분석이 되지 않는 keyword 필드에 전체 텍스트 쿼리를 사용하면 안된다. 즉, 전체 텍스트 쿼리는 비정형화된(unstructured) 텍스트 값을 검색하는데 사용된다. 예를 들면, 블로그 포스트, 이메일, 채팅 등이 있다.
쿼리 빌더
검색 API 마다 쿼리를 작성하는 것은 중복되는 코드가 많아져서 비효율적이다. 구현한 쿼리는 총 3개로 match 쿼리, multi_match 쿼리 그리고 bool 쿼리인데 bool 쿼리는 복합(compound) 쿼리이기 때문에 사실상 match 쿼리와 multi_match 쿼리만 구현하면 된다. match 쿼리는 가장 널리 사용되는 전체 텍스트 쿼리로 명시된 검색어를 포함하는 필드를 가지는 하나 이상의 도큐먼트와 일치한다. 검색어는 분석되고 결과는 해당 필드의 역인덱스에서 조회된다. multi_match 쿼리는 2개 이상의 필드에서 검색어를 포함하는 적어도 1개의 도큐먼트와 일치한다. 도큐먼트가 여러 개 존재하면 동점 결정자(tie breaker) 매개변수를 사용해서 특정 도큐먼트의 적절성 점수(relevance score)을 증가시킬 수 있다. 코드를 보면 필드 배열의 크기에 따라 match 쿼리와 multi_match 쿼리로 구분한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | private static Query buildQuery(PostSearchParam postSearchParam) { List<String> fields; String term; if (postSearchParam == null) { return null; } fields = postSearchParam.getFields(); term = postSearchParam.getTerm(); if (CollectionUtils.isEmpty(fields)) { return null; } // multi_match query if (fields.size() > 1) { return new MultiMatchQuery.Builder() .query(term) .type(TextQueryType.CrossFields) .operator(Operator.And) .fields(fields) .build() ._toQuery(); } // match query return fields.stream() .findFirst() .map((field) -> new MatchQuery.Builder() .query(term) .operator(Operator.And) .field(field) .build()) .orElse(null) ._toQuery(); } | cs |
검색 요청
쿼리 빌더를 사용해서 이제 검색 요청 객체를 만들 수 있다. 검색 요청 객체는 쿼리를 사용해서 결과를 도출하는 객체로 요청에 정의된 쿼리와 일치하는 검색 결과를 반환한다. 검색 요청 객체를 사용해서 단순 검색뿐만 from() 메서드와 size() 메서드로 페이지네이션을 그리고 SortOpions 클래스로 정렬을 구현할 수 있다. postFilter() 메서드는 쿼리 실행 후 도큐먼트가 관련성 점수에 매겨진 후 결과를 필터링하는데 사용하는 메서드로 날짜 범위, 특정 필드 값 여과와 같이 추가 필터를 적용하는 경우에 사용한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | public static SearchRequest buildSearchRequest(String index, PostSearchParam postSearchParam) { try { SearchRequest.Builder searchRequestBuilder; searchRequestBuilder = new SearchRequest.Builder() .index(index) .postFilter(buildQuery(postSearchParam)) .from(calculateFrom(postSearchParam.getPageNo(), postSearchParam.getPageSize())) .size(postSearchParam.getPageSize()); if (postSearchParam.getSortBy() != null) { SortOptions sortOptions = new SortOptions.Builder() .field(fn -> fn.field(postSearchParam.getSortBy()) .order(postSearchParam.getSortOrder() != null ? SortOrder.valueOf(postSearchParam.getSortOrder()) : SortOrder.Asc)) .build(); searchRequestBuilder.sort(sortOptions); } return searchRequestBuilder.build(); } catch (Exception exception) { exception.printStackTrace(); return null; } } | cs |
제네릭 래퍼 클래스
검색을 하려면 MySQL에 저장되는 데이터를 변환해서 Elasticsearch에 삽입해야 하고 수정이나 삭제가 발생하면 데이터 일관성을 위해서 Elasticsearch에도 반드시 이를 적용해야 한다. Spring Boot의 ElasticsearchOperations 인터페이스를 단순히 사용하면 되지만 보통은 여기에 제네릭을 적용한 래퍼(wrapper)를 구현하면 편하게 사용할 수 있다. 코드에서 알 수 있듯이 타입 매개변수는 아이디 필드를 가지는 도큐먼트만 가능하도록 제약을 둔다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | public abstract class CoreDoc { @Id private Long id; public Long getId() { return id; } public void setId(Long id) { this.id = id; } } @Component public class ElasticsearchOperationsUtil<T extends CoreDoc> { private ElasticsearchOperations elasticsearchOperations; public ElasticsearchOperationsUtil(ElasticsearchOperations elasticsearchOperations) { this.elasticsearchOperations = elasticsearchOperations; } public void create(T doc) { elasticsearchOperations.save(doc); } public T find(String id, Class<T> clazz) { return elasticsearchOperations.get(id, clazz); } public void delete(T doc) { elasticsearchOperations.delete(doc); } public void update(T doc) { elasticsearchOperations.update(doc); } } | cs |
오류
쿼리 빌더, 검색 요청, 래퍼 클래스까지 다 만들고 API를 테스트했는데 결과가 나오지 않았다... 만들어진 쿼리에 문제가 있다고 생각하여 쿼리를 출력한 다음 Command Prompt에서 cURL로 요청을 보냈는데 정상적으로 쿼리가 작동했다. 그래서 try-catch문으로 오류를 잡아서 확인했는데 응답 상태코드는 200인데 응답을 복호화(decode)할 수 없다는 오류가 발생했다. 검색을 통해서 찾아본 결과 ObjectNode 자료형으로 원시(raw) JSON으로 응답을 받고 매퍼를 사용해서 원하는 자료형으로 변형해야 한다는 글을 읽고 ObjectNode로 바꿨는데 이번에는 원하는 결과가 나왔다! 사실 아직도 오류에 대한 원인을 모르는데 나중에 알아볼 것이다. 매퍼를 만드는 것은 크게 어렵지 않았다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | private Page<PostDoc> performSearch(SearchRequest searchRequest, Pageable pageable) { HitsMetadata<ObjectNode> hitsMetadata; List<Hit<ObjectNode>> objectNodeHits; List<ObjectNode> objectNodes; List<PostDoc> posts; SearchResponse<ObjectNode> searchResponse; long total; TotalHits totalHits; if (searchRequest == null) { return new PageImpl<PostDoc>(Collections.emptyList(), pageable, 0); } try { searchResponse = elasticsearchClient.search(searchRequest, ObjectNode.class); hitsMetadata = searchResponse.hits(); totalHits = hitsMetadata.total(); objectNodeHits = hitsMetadata.hits(); objectNodes = objectNodeHits.stream() .map(objectNodeHit -> objectNodeHit.source()) .collect(Collectors.toList()); posts = objectNodes.stream() .map(objectNode -> PostObjectNodeMapper.fromObjectNode(objectNode)) .collect(Collectors.toList()); total = totalHits.value(); return new PageImpl<PostDoc>(posts, pageable, total); } catch (Exception exception) { System.out.println(exception); return new PageImpl<PostDoc>(Collections.emptyList(), pageable, 0); } } public class PostObjectNodeMapper { public static PostDoc fromObjectNode(ObjectNode objectNode) { PostDoc postDoc = new PostDoc(); postDoc.setCategoryName((objectNode.get("category_name").asText())); postDoc.setContent(objectNode.get("content").asText()); postDoc.setCreatedAt(LocalDate.parse(objectNode.get("created_at").asText())); postDoc.setId(objectNode.get("id").asLong()); postDoc.setTitle(objectNode.get("title").asText()); return postDoc; } } | cs |
bool 쿼리
앞서 말한 것처럼 bool 쿼리는 복합 쿼리로 2개 이상의 쿼리를 감싸서 결과를 생성한다. 앞서 살펴본 match 쿼리와 multi_match 쿼리는 독립적이어서 리프(leaf) 쿼리라고 부른다. bool 쿼리는 must, filter, must_not 그리고 should 총 4가지의 발생 유형(occurrence type)을 가지는데 간략하게 살펴보면 must는 쿼리절이 반드시 일치하고 적절성 점수에 기여한다. must_not은 쿼리절이 반드시 일치하면 안되며 반대이며 적절성 점수에 영향을 주지 않는다. 성능 향상을 위해서 쿼리 절이 캐시에 저장된다. should는 반드시 일치해야 하는 것은 아니며 적절성 점수에 기여하기 위해 주로 사용한다. 마지막으로 filter는 must처럼 반드시 일치해야 하지만 적절성 점수에 영향을 주지 않는다. 프로젝트에서는 must 발생 유형을 사용하면서 시작 날짜와 종료 날짜 사이라는 날짜 범위를 추가했다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | public static SearchRequest buildSearchRequest(String index, PostSearchParam postSearchParam, Date startDate, Date endDate) { try { Query searchQuery, dateQuery; Query boolQuery; searchQuery = buildQuery(postSearchParam); dateQuery = buildQuery("created_at", startDate, endDate); boolQuery = new BoolQuery.Builder() .must(searchQuery) .must(dateQuery) .build() ._toQuery(); SearchRequest.Builder searchRequestBuilder; searchRequestBuilder = new SearchRequest.Builder() .index(index) .postFilter(boolQuery) .from(calculateFrom(postSearchParam.getPageNo(), postSearchParam.getPageSize())) .size(postSearchParam.getPageSize()); if (postSearchParam.getSortBy() != null) { SortOptions sortOptions = new SortOptions.Builder() .field(fn -> fn.field(postSearchParam.getSortBy()) .order(postSearchParam.getSortOrder() != null ? SortOrder.valueOf(postSearchParam.getSortOrder()) : SortOrder.Asc)) .build(); searchRequestBuilder.sort(sortOptions); } return searchRequestBuilder.build(); } catch (Exception exception) { exception.printStackTrace(); return null; } } | cs |
페이지네이션
목록의 경우 페이지네이션은 필수적이다. 사실 Pageable 인터페이스와 TotalHits 자료형의 값을 사용하면 원하는 결과는 쉽게 얻을 수 있다. 오류 문단의 코드에서 이를 확인할 수 있다.
update: 2024.10.20
댓글 없음:
댓글 쓰기