선요약 : ElasticSearchClient를 이용한 es 커스텀 쿼리를 작성하여 프로젝트에 적용하여 20배 이상의 성능 향상을 이뤄냈습니다. 그러나 ES client를 사용하는데 최신 버전이 기존의 레퍼런스와 달라 공식 문서를 많이 참고했습니다.
# 현재 ES 스펙
ES를 적용한 시점 기준, 프로젝트에서 사용한 spring data es의 버전은 5.1.2, elastic client는 8.7.1이었다. 해당 버전의 ES client는 쿼리 자체를 stream api형식으로 지원해서.. 기존에 java에서 ES를 사용할 때 대부분 사용하던 HighLevelRestClient를 사용할 수 없었다.(아예 deprecated라고 뜨며 사용할 수 없었음)
그리고 현재 recipe document 구조를 간단히 살펴보자. 코드보단 결과 Json을 보여주는 것이 더 직관적일 것 같아 캡쳐본을 가져왔다. 내부 데이터는 부하 테스트 과정중 생성한 랜덤한 정보이므로 크게 관련이 없다..
id(레시피 자체 id), authorid(작성자 id), title을 기본 field로 가지고, recipeInfo(기본적인 레시피 정보)와 recipeStatistics(레시피 통계 정보)를 nested field로, 그리고 원래 JPA에서는 일대다 관계를 가지는 steps, ingredients, tags 필드가 존재한다. 엘라스틱 서치는 NoSQL이므로 테이블 간 관계로써 데이터를 보관하지 않는다. 때문에 한 번에 조회되는 데이터 단위는 이처럼 한 document 안에 넣어두는 것이 기본이다.
절대로 공식 문서를 참고했어야 했다. 기존의 HighLevelRestClient에서는 자바에서 널리 사용되는 queryDSL과 마찬가지로 각 항목들을 연쇄해서 질의하면 됐지만.. es 버전 8 이상부턴 스트림 기반 쿼리빌더를 통해 쿼리를 작성해야 했다. 게다가 방금 json을 봤으면 알겠지만 nested field의 경우 또 specific한 문법에 맞춰 쿼리를 작성해야해서 좀 곤란했다.
필요한 쿼리는 다음과 같다. 이전 글에 JPQL로 작성했던 검색 쿼리와 내용은 같다.
- 레시피 제목(title)과 레시피 정보 항목(recipeInfo)의 일치 여부에 따른 레시피 검색
- 주어진 태그를 모두 포함하는 레시피 검색
# 특정 필드 + Nested 필드에 대한 And Match 쿼리
공식문서에서 ElasticSerachClient를 사용하는 api 사용법, nested field의 match 쿼리를 날리는 방법, 여러 개의 쿼리가 엮일 때 어떻게 구성하면 좋을지 열심히 공부한 끝에 쿼리를 구성할 수 있었다.
문자열로 된 document의 각 항목을 검색하기 위해 es의 MatchQuery를 사용한다. inverted indexing을 통해 저장된 document를 인덱싱된 단어로 검색할 수 있게 해준다.
그리고 recipeInfo 필드 하에 있는 nested field를 검색하기 위해 NestedQuery를 사용한다. nest하는 상위 필드를 path에, "."으로 구분되는 하위 필드를 field에 설정한 후 MatchQuery를 똑같이 작성한다.
각 항목에 대한 MatchQuery를 "and"조건으로 검색하기 위해 각 bool 쿼리를 리스트로 만든 뒤 must로 묶는다. 그리고 must 쿼리를 만족하는 document를 response로 받으면 쿼리 구성은 끝이다...
백문이 불여일견. findByRecipeInfo() 쿼리를 보자..
/**
* 메소드 인자로 주어진 항목과 일치하는 Recipe를 반환한다.
* title이 빈 문자열이거나, enum type이 UNDEFINED라면 검색 조건에 포함하지 않는다.
*/
public List<RecipeDocument> findByRecipeInfo(
String title, CookingTime cookingTime, Difficulty difficulty, Serving serving
) {
// title이 빈칸이거나, recipeInfo 항목 타입이 UNDEFIEND이면 검색 조건에서 제외
List<Query> infoQueries = new ArrayList<>();
infoQueries.add(recipeQueryHelper.buildTitleQuery(title));
infoQueries.add(recipeQueryHelper.buildRecipeInfoQuery(
"recipeInfo.cookingTime", cookingTime.name()));
infoQueries.add(recipeQueryHelper.buildRecipeInfoQuery(
"recipeInfo.difficulty", difficulty.name()));
infoQueries.add(recipeQueryHelper.buildRecipeInfoQuery(
"recipeInfo.serving", serving.name()));
try {
SearchResponse<RecipeDocument> response = esClient.search(
s -> s.index("recipe")
.query(q -> q.bool(
b -> b.must(infoQueries))),
RecipeDocument.class);
return response.hits().hits().stream().map(Hit::source).toList();
} catch (IOException e) {
throw new EsClientException("recipe");
}
}
각 항목에 대한 bool 쿼리를 RecipeQueryHelper라는 컴포넌트에서 작성하는데, 모든 항목에 대한 각 쿼리를 하나의 메소드 안에서 작성하면 항목이 늘어날 때마다 메소드 길이가 길어질 위험이 있어 확장성에 좋지 않다고 판단한 결과다. RecipeQueryHelper 컴포넌트의 내부 메소드는 아래와 같다.
/**
* title 문자열에 대한 match query를 반환한다.
* 인자로 주어진 title이 빈 문자열일 경우 조건에 포함하지 않는다.
*/
public Query buildTitleQuery(String title) {
BoolQuery.Builder boolQuery = QueryBuilders.bool();
if (title != "") {
return boolQuery.must(MatchQuery.of(q ->
q.field("title").query(title))._toQuery()).build()._toQuery();
} else {
return boolQuery.build()._toQuery();
}
}
/**
* RecipeInfo 항목에 대한 match query를 반환한다.
* 검색하고자 하는 nested fieldName과 enum name을 인자로 받는다.
* 인자로 들어온 enum value name이 UNDEFINED일 경우 조건에 포함하지 않는다.
*/
public Query buildRecipeInfoQuery(String fieldName, String name) {
BoolQuery.Builder boolQuery = QueryBuilders.bool();
if (name != "UNDEFINED") {
NestedQuery.Builder nestedQuery =
QueryBuilders.nested().path("recipeInfo").query(
q -> q.match(
s -> s.field(fieldName).query(name)));
boolQuery.must(b -> b.nested(nestedQuery.build()));
}
return boolQuery.build()._toQuery();
}
# 필드에 Document 검색
이젠 주어진 태그를 모두 포함하는 레시피에 대한 검색이다. 기존의 JPQL보다 훨씬 직관적으로 구성할 수 있다. findByTagNames() 메소드 인자로 검색하고자 하는 태그 이름 리스트를 받는다. 각 태그 이름 리스트에 대한 MatchQuery를 빌드한 후, 앞서 했던 must를 통한 각 조건을 and로 묶어주는 빌딩을 한다.
/**
* 인자로 주어진 tagName List의 tag가 "전부" 포함된 RecipeDocument를 반환한다.
* @param tagNames
* @return
*/
public List<RecipeDocument> findByTagNames(List<TagName> tagNames) {
List<Query> tagQueries = tagNames.stream().map(tagName ->
TermQuery.of(q -> q.field("tags")
.value(tagName.name()))._toQuery())
.toList();
try {
SearchResponse<RecipeDocument> response = esClient.search(
s -> s.index("recipe").query(
q -> q.bool(
b -> b.must(tagQueries))),
RecipeDocument.class
);
return response.hits().hits().stream().map(Hit::source).toList();
} catch (IOException e) {
throw new EsClientException("recipe");
}
}
# 성능 비교
인덱스에 60만개의 document가 저장되어 있었다. 기존 MySQL + JPQL을 통해 특정 단어를 포함한 document를 검색해보았다.
이번엔 ES를 이용해서 같은 단어를 포함한 document를 검색해보았다.
이정도일줄은 몰랐는데... 많이 놀랐다. 사실 title에 대한 인덱스를 걸지 않은 탓도 크겠지만.. 약 25배의 성능 향상을 이뤄낼 수 있었다.