개요
개발 중인 서비스에서 가게명 검색으로 한계가 있어, 통합 검색을 구현하게 돼서 이 과정에서 발생한 트러블 슈팅 및 개선 과정을 소개하고자 합니다.
통합 검색 구현
1차 구현
처음 구현은 DB의 LIKE로 검색을 구현해 봤습니다. 결과는 100만 건 데이터 기준으로 5초가 소요되는 것을 확인할 수 있었습니다.
2차 구현
2차 구현은 1차 구현의 속도 문제를 해결하기 위해 검색 컬럼에 인덱스를 적용하였습니다. 결과는 다음과 같았습니다.
1차와 같이 5초가 소요됐는데 LIKE의 "%text%"검색의 경우 인덱스를 적용하지 않는 것을 알았고, "text%" 검색의 경우 정상적으로 인덱스를 활용해서 빠르게 검색이 됐습니다. 하지만 "text%"로 검색을 할 경우 검색에 매우 제한적이기에 적용할 수 없었습니다.
3차
어떻게 해결하야 하나, 여러 해결책을 찾아본 결과 MySQL에서 지원해 주는 Full-Text Search(전문 검색) 기능으로 구현해 봤습니다.
@Query(value = "SELECT * FROM store " +
"WHERE MATCH(business_name, full_location_address, full_road_name_address, business_type) AGAINST(:keyword IN NATURAL LANGUAGE MODE)", nativeQuery = true)
List<Store> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);
위의 코드에서는 Built-In-Parser를 이용해서 구분자로 키워드를 추출했습니다.
속도는 빨랐지만, 전문 검색은 분리된 토큰과 검색 키워드가 일치하는 경우에만 결과에 포함되어 통합 검색 시 검색 정확도가 낮았습니다. 단순 검색에는 괜찮을 수도 있지만, 한국어 형태소 기능도 없을뿐더러 구현하려는 통합 검색에는 맞지 않는 방식이었습니다.
4차
DB 검색으로는 한계가 있다고 생각하여, 배워서 적용할 기술들 중에 엘라스틱 서치를 사용하기로 했습니다.
엘라스틱 서치로 기대할 수 있는 이점은 다음과 같았습니다.
- 역색인 구조로 검색 속도가 매우 빠름
- 복잡한 검색 쿼리를 지원
- 키바나로 시각화 가능
- 한국어 형태소 지원
- 문서화 Good
엘라스틱 서치로 DB에서 검색했던 방식대로 구현을 해보았습니다. mult-match 쿼리를 사용했습니다.
GET stores/_search
{
"size": 30,
"query": {
"multi_match": {
"query": "서울 초밥",
"fields": ["business_name", "business_type", "menu", "fullLocationAddress", "fullRoadNameAddress"]
}
}
}
30건을 조회했을 때 평균적으로 29ms가 나왔습니다. 쿼리를 정말 간단하게 작성하다 보니 검색 정확도가 똑같이 낮은 문제가 있었습니다. 다음은 이를 개선한 과정을 소개하겠습니다.
검색 정확도 개선
1차 개선
서울 초밥이라고 검색한 사용자는 서울에 있는 초밥을 파는 가게를 생각하고 검색했을 것입니다. 하지만, 현재 score가 높은 1, 2, 3번을 살펴보면 경남, 충남, 경기도 인 것을 볼 수 있습니다. 심지어 score가 제일 높은 첫 번째 식당은 초밥이라는 메뉴조차 없었습니다.
이를 해결하기 위해 처음 생각해 낸 것은 사용자가 주소, 식당 이름을 많이 검색할 것이라고 생각이 들어 주소와, 식당 필드에 가중치를 주는 것이었습니다.
GET new_stores/_search
{
"size": 30,
"query": {
"multi_match": {
"query": "서울 초밥",
"fields": ["business_name^4", "business_type", "menu^2", "fullLocationAddress^3", "fullRoadNameAddress^3"]
}
}
}
위와 같이 가중치를 주었지만, 검색 결과는 크게 달라지지 않았습니다.
2차 개선
더 개선하기 위해 생각해 낸 방식은 다음과 같습니다.
- 키워드를 분석해서 필드마다 가중치를 다르게 준다.
- 키워드를 분석해서 bool쿼리로 처리한다.
이 중 2번 방식을 택했습니다. 왜냐하면 사용자 입장에서 검색했을 때 기대하는 결과는 내가 입력한 위치에 ~를 파는 가게라고 생각했습니다. 1번 방식으로 했을 때 가중치를 다르게 준다 해도 무조건 내가 입력한 도시와 구가 포함된다는 확신은 얻을 수 없다고 생각했습니다.
자세한 구현 방식은 다음과 같습니다.
- 키워드에서 도시, 구, 메뉴 이름을 추출해서 도시와, 구, 메뉴 등을 알아낸다.
- bool 쿼리를 사용해서 도시, 구, 메뉴 등이 무조건 포함되도록 한다.(must쿼리 사용)
- 필터를 거친 후 가게명, 메뉴 필드에 match쿼리를 사용한다.
- 만약, 내가 입력한 도시와, 구, 메뉴가 없다면 가게명, 메뉴로 검색한다.
- keyword타입의 가게명 필드에 match해서 boost값을 메뉴보다 높게 준다.
여기까지 구현했을 때 여러 필드에 쿼리를 하면 성능에 안좋다는 피드백을 들었습니다. 이를 해결하기 위해 엘라스틱 서치의 copy_to 기능을 사용해서 통합된 필드를 만들어 쿼리를 요청할 필드 수를 줄였습니다.
이어지는 문제..
계속해서 쿼리를 수정하고 검색을 어떻게 해야 사용자가 원하는 검색 결과를 나올지 고민하고 수정하고 반복하다보니 이도저도 아니게 진행되는 것 같았습니다. 프로젝트에서 검색에 관한 비즈니스 요구사항이 없었기에 더욱 더 헷갈렸던 것 같습니다. 따라서, 기준을 정하고 참고해서 이를 바탕으로 수정하기로 했습니다. 제가 기준으로 삼은 서비스는 캐치 테이블의 검색 서비스 였습니다.
- 소스를 검색하면 이름에 소스가 들어간 가게들이 검색 된다
- 서울 소스검색하면 필터에 서울이 체크되고 결과는 이상하게 나온다.
- 서울 에치세는 검색이 안되는데 서울 에치세로소스는 검색이 된다. 서울 에치세로소스스스도 검색이 된다.
- 서울에치세로소스도 검색하면 검색이 되고 서울에 필터도 걸린다.
- 서울없이 에치세로소스스스는 결과가 안나온다.
- 에치세로소스스는 결과가 나온다.
- 에치세로소는 결과가 안나온다.
- 서울 낙시는 필터에 서울이 체크가 된다.
- 실제로 존재하는 서울 낙업을 검색 해보면 서울에 필터 체크가 안된다.
검색 결과를 토대로 어떻게 구현 됐을 지 유추를 해봤습니다.
- 먼저 사용자가 키워드를 입력한다.
- term 쿼리로 정확히 키워드랑 가게명이 일치하는 가게를 검색
- 만약에 일치하는 가게가 없다면 통합 검색으로 진행
- 통합 검색은 copy_to로 만든 통합 필드에 쿼리
- Operator를 AND로 설정
- 여기서 카테고리와 일치하는 단어가 있으면 체크해서 프론트에게 같이 보내줘서 웹사이트에서 해당 카테고리를 체크해준다.
고려해볼 점은 제가 유추한 위 방식은 최악의 경우 검색을 두 번 요청한다는 것 입니다.
3차 개선
위에서 계속 생각했던 백엔드에서 키워드를 분석해서 프론트에 필터링된 카테고리를 넘겨주는 불필요한 작업은 없애고, 프론트에서 카테고리 필터를 받아오도록 변경하였습니다.
현재 검색 로직은 다음과 같습니다.
- bool의 must / prefix 쿼리로 가게 이름을 검색한다.
- 만약 request에 카테고리 필터가 있다면 bool 쿼리에 filter를 사용해서 district_category.keyword 에 terms쿼리로 카테고리가 하나라도 일치해야 검색 결과에 포함된다.
- 만약 검색 결과가 있다면 그대로 반환해준다.
- 검색 결과가 없다면 통합검색을 진행한다.
- 통합검색도 bool쿼리를 이용하고 filter 부분은 위 방식과 똑같은데 must에서 가게 이름이 아닌 full_text필드에서 찾는다 + AND operator를 사용했다.
고려해볼점에서도 적었지만, 역시 두번의 쿼리를 요청하는 것은 불필요하다고 생각했습니다. 따라서 가게명 검색은 없애고 바로 통합 검색을 하게 하였습니다.
서울복집을 검색하면서 쿼리를 튜닝하였습니다.
테스트 케이스 - 1
- 서울복집을 검색했을 때 서울복집이라는 가게들이 최상위에 나와야한다.
- 경상남도 서울복집을 검색했을 땐 경상남도에 있는 서울복집이 나와야한다.
기존 쿼리
boolBuilder.must(m -> m
.match(x -> x
.field("full_text")
.query(search.keyword())
.operator(Operator.And)));
서울 복집 검색 결과
경상남도 서울복집 검색 결과
첫번째 결과는 원하는 결과가 나오지 않았습니다.
두번째 결과는 원하는 결과가 나왔습니다.
첫번째에서 생긴 문제를 해결하기위해 should쿼리를 사용해서 가게명 필드에 가중치를 줬습니다.
개선 쿼리
boolBuilder.must(m -> m
.match(x -> x
.field("full_text")
.query(search.keyword())
.operator(Operator.And)))
.boost(2f)
.should(m -> m
.match(x -> x
.field("title")
.query(search.keyword())
.boost(4f)));
테스트 케이스 - 2
- 별 이라고 검색하면 별이라는 가게가 나와야 한다.
- 대구 별 이라고 검색하면 대구에 있는 별이라는 가게가 나와야 한다.
기존 쿼리
boolBuilder.must(m->m.match(x-> x.field("full_text").query(search.keyword()).operator(Operator.And))).boost(2f)
.should(m->m.match(x->x.field("title").query(search.keyword()).boost(4f)));
별 검색 결과
대구 별 검색 결과
별을 검색했을 때는 원하는 결과와 다르게 별샵별이라는 가게가 1등으로 나왔고, 대구 별을 검색했을 때는 원하는 결과와 비슷하게 나왔습니다.
기존 should의 match쿼리에서 term쿼리로 변경하였습니다.
boolBuilder.must(m -> m
.match(x -> x
.field("full_text")
.query(search.keyword())
.operator(Operator.And)))
.boost(2f)
.should(m -> m
.term(x -> x
.field("title")
.value(search.keyword())
.boost(4f)));
별 검색 결과
대구 별 검색 결과
다시 별을 검색했을 때 이전 쿼리와 다른게 없었고, 대구 별을 검색했을 때 원하는 결과와 달랐습니다. 그래서 match와 term을 합쳐보기로 했습니다.
개선 쿼리
boolBuilder = new BoolQuery.Builder()
.must(m -> m.match(t -> t
.field("full_text")
.query(search.keyword())
.operator(Operator.And)))
.should(s -> s.match(t -> t
.field("title")
.query(search.keyword())
.boost(1.5f)))
.should(s -> s.term(t -> t
.field("title.keyword")
.value(search.keyword())
.boost(2.0f)))
별 검색 결과
대구 별 검색 결과
이렇게 개선을 해보니 제가 원하는 결과에 맞게 검색 결과들이 나왔습니다.
다른 테스트 케이스들도 확인했습니다.
서울 용산 버거 검색 결과
서울 용산 이태원버거
마지막
이제 대부분 개발하면서 메뉴를 추가할 때 엘라스틱 서치 도큐먼트에도 저장이 되도록 했습니다. 따라서 메뉴 검색도 잘 되는지 검사했습니다.
처음부터 메뉴 또한 copy_to로 통합 필드에 저장을 해주었기 때문에 따로 쿼리를 수정하지는 않았습니다.
현재 엘라스틱 서치에는 아래와 같이 저장돼있습니다.
아메리카노 검색 결과
아이스 아메리카노 검색 결과
두 검색 모두에서 아이스 아메리카노 메뉴를 포함한 미친곱창이라는 가게가 결과에 포함되는 것을 확인하여, 메뉴 검색이 정상적으로 작동함을 확인할 수 있었습니다.
트러블 슈팅 및 개선 결과
엘라스틱 서치를 사용함으로 써 DB 검색에 비해 높은 검색 정확도와 빠른 속도를 챙길 수 있게 됐습니다.
또한, 사용자는 메뉴, 가게명, 주소 등을 이용하여 통합 검색을 할 수 있게 됐습니다.
'부트캠프 > Dev' 카테고리의 다른 글
최종 프로젝트 중간점검 (0) | 2024.11.04 |
---|---|
Spring 슬랙 알림 기능 구현하기 (2) | 2024.10.17 |
최종 전.. 팀 프로젝트 시작 (1) | 2024.10.15 |
스프링으로 RabbitMQ를 사용해보자 (1) | 2024.09.27 |
Rabbit Mq (3) | 2024.09.27 |