ES 검색엔진을 활용한 상품 검색 — basic

Fitpet Developer
19 min readJan 10, 2023

--

Written by. Kyle

Fitpet Logo

안녕하세요.
반려동물 서비스를 개발하고 있는 Fitpet 회사의 Backend Engineer 탐색스쿼드
kyle이라고 합니다. 이번 포스팅에서는 저희 자사몰인 핏펫몰에서의 ES를 활용한 검색성능 개선에 관한 포스팅을 해보겠습니다.

목차

  1. ElasticSearch (ES) 소개
  2. 도입 이유
  3. 적용 사례 1
  4. 적용 사례 2
  5. 결론
  6. 참고 레퍼런스

01. ElasticSearch 소개

ElasticSearch Logo

ElasticSearch 란 무엇일까요?

결론부터 말하면 “검색엔진" 입니다.

뛰어난 검색 능력과 대규모 분산 시스템을 구축할 수 있는 다양한 기능들을 제공하지만, 설치 과정과 사용 방법은 비교적 쉽고 간편합니다. 기업에서 뿐만 아니라 학교, 개인을 위한 프로젝트에도 다양하게 사용이 가능합니다. 기존 관계 데이터베이스 시스템에서는 다루기 어려운 전문검색(Full Text Search) 기능과 점수 기반의 다양한 정확도 알고리즘, 실시간 분석 등의 구현이 가능합니다. 또한 다양한 플러그인들을 사용해 손쉽게 기능의 혹장이 가능하며 아마존 웹 서비스(AWS), 마이크로소프트 애저(MS Azure) 같은 클라우드 서비스 그리고 하둡(Hadoop) 플랫폼들과의 연동도 가능합니다.

ElasticSearch (이하 ES) 의 특징 5가지만 소개하면,

  • 오픈소스
  • 실시간 분석시스템
  • 전문(Full Text)검색 엔진
  • RESTFul API 를 지원
  • 멀티테넌시를 지원

이번 포스팅에서 ES 의 기본적인 내용을 자세히 다루지않으므로, ES 에 대한 소개는 아래에 참조블로그로 남겨놓겠습니다 🙇

02. 도입 이유

대부분의 초기 스타트업 서비스에서, 데이터베이스에 있는 데이터를 검색하게 되는 경우에 이와 같은 비슷한 쿼리를 호출하게 되는데,

SELECT * 
FROM products
WHERE name LIKE '%멍멍이%'

이 SQL문에 쓰이는 products 라는 테이블에 데이터가 많아지면 많아질수록 해당 쿼리의 속도는 느려질 수 밖에 없습니다. 해당 테이블에 name 컬럼에 index 를 걸어도, 해당 데이터가 10만, 100만, 1000만건으로 많아질수록 위 쿼리에 속도를 보장할 수 없게 됩니다.

위와 같은 이유로 저희 핏펫몰 서비스에서 상품을 검색할 때, 상품에 대한 데이터가 많아지다 보니 상품을 검색하는데에 소요되는 시간이 점차 늘어날 수 밖에 없었습니다. (대략 700~1000ms)

위와 같은 문제로 검색 속도에 대한 이슈가 점차 증가됨에 따라 속도개선이 필요해졌고, 그에 대한 해결책으로 ElasticSearch 와 같은 검색엔진을 활용하기로 결정하게 되었습니다.

ElasticSearch 가 검색시 빠른이유?

전통적인 관계형 DBMS 시스템에서는 데이터를 Row단위로 한 줄씩 저장시키게 되고, Table에 있는 데이터를 검색하기 위해서는 like 검색을 사용하기 때문에 검색해야 할 대상이 늘어나 시간도 오래 걸리고, 데이터를 모두 읽어야 하기 때문에 기본적으로 속도가 더 느립니다.

그에 비해, ElasticSearch 는 데이터를 저장할 때, 다음과 같이 역 인덱스(inverted index) 라는 구조로 만들어 저장시키게 됩니다.

역색인 구조

위 사진과 같이 데이터를 역색인 구조로 저장시키게 되고, “sky” 라는 단어를 검색하게 되면 2, 3번 도큐먼트로 바로 찾아가게 되므로 검색시 속도 저하 없이 빠른속도가 검색이 가능해지게 됩니다.

index, term, document, stopword, analyzer, token, keyword, …등 ES 에서 쓰이는 용어들에 대한 정보는 참조 레퍼런스에 따로 남겨둘테니, 어떤 의미로 쓰이는 지 용어에 대한 학습을 권장드립니다.

03. 적용 사례 1

위에서 ES 를 간단히 알아보고, 도입을 결정한 이유를 알았으니, 이제 실제 서비스에 ES 를 적용해야 하는 일만 남았습니다.

환경

  • AWS OpenSearch ( ElasticSearch 7.10 )
  • Python 3.8.6
  • Django ( + GraphQL )

3–1. 인덱스 설정

먼저. ES에 상품(product) 정보를 저장시킬 Index 가 필요합니다. 인덱스를 처음에 정의없이 바로 ES API 를 통해 데이터를 생성해도 되지만, 그렇게 되면 ES 가 자동으로 데이터 타입을 정의하고 매핑하기때문에 우리가 원하는 정확한 결과가 나오지 않을 가능성이 있습니다. 또한 인덱스를 정의할 때 분석기 & 필터 같은것들을 정의할 수 있기 때문에 인덱스는 가급적 초기에 직접 세팅하는것을 추천합니다.

# 인덱스 생성 API
PUT /{index_name}
{
# ES Shards, Replica, Analyzer, Filter, Tokenizer 등 세팅하는 부분
"settings": {...},
# Index Field (Column) 데이터 타입 정의
"mappings": {...}
}

ES 에 Index 를 생성할 때, 큰 틀은 위와 같습니다.
이제 저 큰 틀에서 필요한 옵션을 추가하여 index 를 구성하시면 됩니다.
아래는 index 생성 샘플 템플릿 입니다. 참고하시면 됩니다.

PUT /{index_name}
{
"settings": {
"index": {
# 인덱스 생성시 샤드, 레플리카 개수 정의
"number_of_shards": 3,
"number_of_replicas": 1,

# ngram setting
"max_gram_diff": "10",

# 분석기 정의
"analysis": {
"analyzer": {

"analyzer_test1": {
"type": "custom", # ES 에서 기본적으로 제공해주는 분석기 사용 가능
"char_filter": {},
"tokenizer": {},
"filter": {}, # token filter
},

}
},
# 캐릭터 필터
"char_filter": {
"char_filter_test1": {
"type": "...",

}
},
# 토크나이저
"tokenizer": {
"tokenizer_test1": {
"type": "custom", # standard, etc....
}
},
# 토큰 필터
"filter": {
"filter_test1": {
"type": "custom", # uppercase, etc....
}
},
},
},
"mappings": {
# 필드(컬럼) 정의
"properties": {

"field_name_01": {
"type": "text", # keyword
"index": false, # false 시 인덱싱 되지않음
},

"field_name_02": {
"type": "keyword", # text
},

"field_name_03": {
"type": "text",
"fields": {
"<multi_field_name>": {
"type": "text",
"analyzer": "...",
"search_analyzer": "...",
},
"<multi_field_name>": {
"type": "keyword",
"analyzer": "...",
"search_analyzer": "...",
}
}
}

},
},
}

인덱스를 생성할 때, 각 필드들마다 데이터타입, 사용할 분석기, 필터 등 정의할 것들이 매우 많습니다. 다만 이 모든 내용들을 다루기엔 내용이 너무 많아 이 포스팅에서는 제외하였습니다. 참조 레퍼런스 링크를 통해 추가적으로 모르는 부분들을 짚고 넘어가셔야 합니다.

3–2. ES 에 Query 하기

이제 Index를 생성하였으니, 인덱스에 데이터를 담은 후, 실제로 ES 에 Query 하여 내가 원하는 데이터를 받아오도록 하겠습니다.

기본적인 ES GET Query 입니다.
( Kibana의 dev_tools 에서 직접 요청하면서 눈으로 확인하셔야 좋습니다. )

GET /{index_name}}/_search
{
"explain": true,
"_source": "{field}",
"size": 20,
"aggs": {},
"sort": [],
"query": {}

}
  • explain : 쿼리한 결과를 설명해주는 옵션
  • _source : 쿼리결과에서 필요한 fields 들만 가져온다. (SELECT문 비슷)
  • size : 데이터를 20개만 리턴받는다.
  • aggs, sort : 집계와 정렬 부분에 대한 옵션입니다.
  • query : 제일 중요한 부분입니다. 우리가 원하는 결과를 얻기 위해서는 쿼리문을 서비스 요구사항에 맞게 잘 작성하여야 검색성능이 올라갑니다.

위 옵션들에 대한 설명들 또한 모두 다루기에 이 포스팅의 내용을 벗어나므로 간단히 설명하는점 양해부탁드립니다.

이제 실제로 ES 에 데이터 요청 쿼리를 날려보면 다음과 같습니다.
products 라는 Index에 잇츄라는 단어(Term)이 들어간 모든 Document를 조회하는 쿼리입니다 5개의 결과를 리턴받습니다.

  • request
GET /products/_search
{
"size": 5,
"_source": ["product_name"],
"query": {
"wildcard": {
"product_name": "*잇츄*"
}
}
}



# RDBMS (ex. mySQL) 와 같은 기능
SELECT product_name FROM products WHERE name LIKE '%잇츄%' LIMIT 5

위 예시 말고도 엄청나게 많은 쿼리방법이 있으므로 서비스 요구사항에 맞게끔 설계할 수 있는 능력이 중요합니다.

  • response
{
"took": 42,
"timed_out": false,
"_shards": {
"total": 2,
"successful": 2,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 20,
"relation": "eq"
},
"max_score": 1,
"hits": [
{
"_type": "_doc",
"_id": "1000002516",
"_score": 1,
"_source": {
"product_name": "it 더잇츄러스 대용량 (20개입)"
}
},
{
"_type": "_doc",
"_id": "1000003022",
"_score": 1,
"_source": {
"product_name": "it 잇츄 디즈니 (딸기/바나나/블루베리)"
}
},
{
"_type": "_doc",
"_id": "1000003084",
"_score": 1,
"_source": {
"product_name": "it 더잇츄러스 필링 (바나나/산양유/스피루리나/피넛버터)"
}
},
{
"_type": "_doc",
"_id": "1000003292",
"_score": 1,
"_source": {
"product_name": "it 더 잇츄 M (일반8개입/대용량34개입/특대용량57개입)"
}
},
{
"_type": "_doc",
"_id": "1000004014",
"_score": 1,
"_source": {
"product_name": "it 더 잇츄 말랑츄 (100g)"
}
}
]
}
}

위 응답 데이터중 _score 값을 눈여겨 보아야 합니다. 지금은 모두 1 로 동일하지만 query를 어떻게 작성하냐에 따라서 스코어값이 바뀌게 되고, 그에 따라 데이터가 스코어에 의해 정렬되어 내려오게 됩니다.

결국, 이 스코어값을 자신이 서비스하고 있는 요구사항에 맞게 잘 조절하는 능력을 길러야 검색결과의 정확도가 올라갑니다. 즉 검색품질이 좋아진다고 할 수 있습니다.

이 Score 를 계산하는 ElasticSearcsh 만의 알고리즘으로 BM25 라는 알고리즘이 있으나 이 또한 깊게 들어가면 포스팅의 내용을 벗어나므로 관심 있으신분들은 따로 시간내어 학습하시면 좋을 내용입니다.

04. 적용 사례 2

요구사항

  • 핏펫몰 내에서 특정 키워드 검색 시, 품절인 상품은 아래로 가도록 변경요청
  • ex) “로얄캐닌 인도어” 이라고 검색시, 품절인 상품은 하단에 배치하나,
    검색어가 포함된 제품이 검색결과 가장 맨하단에 내려가면 안된다.
  • 같은 “로얄캐닌 인도어” 상품들 중에 품절인 상품들만 하단에 표시
개선 전, 검색결과 화면

위와 같이 자사 서비스내에서 “로얄캐닌 인도어" 라는 검색어로 검색을 했을 때의 결과화면입니다. 검색한 상품명들이 검색에는 잘 노출되는것을 확인할 수 있습니다.

하지만 위 사진에서 보시는바와 같이 품절인 상품들이 가장 맨 상단에 노출되는 문제가 있었습니다. 지금부터 위 요구사항에 맞게끔 Query 내용을 수정하여 Score 값을 조정해보도록 하겠습니다.

먼저 products Index 에는 상품이름을 저장하는 name 과 상품의 현재 품절 여부를 표시하는 is_real_sold_out 이라는 field 가 존재합니다. 그럼 여기서 생각해 볼 구현 방법은 아래와 같습니다.

  • 상품 이름에 “로얄캐닌 인도어" 라는 검색어가 들어가야 한다.
  • 품절이 아닌 상품인 경우에는 검색결과에서 score 가 더 높게 나와야 한다.

위 2가지 조건을 가지고 ES Query문을 작성해보겠습니다.

  • request
GET /products/_search
{
"_source":[
"product_name",
"is_real_sold_out"
],
"query":{
"bool":{
"should":[
{
"match": {
"product_name.standard": {
"query":"로얄캐닌 인도어",
"boost":1
}
}
},
{
"match": {
"is_real_sold_out": {
"query": false,
"boost": 30
}
}
}
],
"minimum_should_match": 1
}
}
}

위 쿼리문에서 is_real_sold_out 이라는 field가 false 인 상품에 대해서는 부스트(boost) 점수를 30 으로 설정한 것을 볼 수 있습니다. 위 쿼리로 인해 품절이 아닌 상품에는 score가 올라가기 때문에 데이터가 상위에 노출되는것을 확인할 수 있습니다.

위 쿼리문에서 bool, should, match 와 같은 키워드들이 중요한 키워드이긴 하나, 각 예약어들이 의미하는 기능들을 모두 나열하려면 이 포스팅의 주제와 벗어나게 되므로 꼭 한번은 공식 도큐먼트를 통해 확인하는것을 추천드립니다.

  • response
{
"took":16,
"timed_out":false,
"_shards":{
"total":2,
"successful":2,
"skipped":0,
"failed":0
},
"hits":{
"total":{
"value":10000,
"relation":"gte"
},
"max_score":14.933101,
"hits":[
{
"_type":"_doc",
"_id":"1000002114",
"_score":14.933101,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 캣 인도어 (85g)"
}
},
{
"_type":"_doc",
"_id":"1000001552",
"_score":14.003006,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 캣 인도어 7+ (3.5kg)"
}
},
{
"_type":"_doc",
"_id":"1000002113",
"_score":13.750164,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 캣 인도어 +7 (85g)"
}
},
{
"_type":"_doc",
"_id":"1000013632",
"_score":13.20252,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 독 미니 인도어 시니어 (1.5kg)"
}
},
{
"_type":"_doc",
"_id":"1000013633",
"_score":13.20252,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 독 미니 인도어 시니어 (3kg)"
}
},
{
"_type":"_doc",
"_id":"1000002108",
"_score":12.954848,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 독 미니 인도어 어덜트 (500g)"
}
},
{
"_type":"_doc",
"_id":"1000013619",
"_score":12.954848,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 독 미니 인도어 어덜트 (8.7kg)"
}
},
{
"_type":"_doc",
"_id":"1000011629",
"_score":12.506313,
"_source":{
"is_real_sold_out":false,
"name":"로얄캐닌 캣 인도어 인 젤리 파우치 85g"
}
},
{
"_type":"_doc",
"_id":"1000002112",
"_score":12.306109,
"_source":{
"is_real_sold_out":true,
"name":"로얄캐닌 캣 인도어 (400g)"
}
},
{
"_type":"_doc",
"_id":"1000009716",
"_score":12.306109,
"_source":{
"is_real_sold_out":true,
"name":"로얄캐닌 캣 인도어 (1.2kg)"
}
}
]
}
}

<Kibana Dev Tools 쿼리 화면>

개선후, 결과

개선 후, 검색결과 화면

개선 후 결과화면을 보면 위 요구사항대로

  • 핏펫몰 내에서 특정 키워드 검색 시, 품절인 상품은 아래로 가도록 변경요청
  • ex) “로얄캐닌 인도어” 이라고 검색시, 품절인 상품은 하단에 배치하나,
    검색어가 포함된 제품이 검색결과 가장 맨하단에 내려가면 안된다.
  • 같은 “로얄캐닌 인도어” 상품들 중에 품절인 상품들만 하단에 표시

제일 처음 정의한 요구사항대로 검색결과에 순위가 변동되었음을 확인할 수 있습니다.
이처럼 ES 를 활용하면 복잡한 비즈니스 요구사항대로 원하는 검색결과를 가져올 수 있으며, 나아가 속도까지 챙길수 있는 이점이 있습니다.

05. 결론

RDBMS

  • 약 200~300ms 소요
  • 데이터 테이블 크기가 크면 클수록 소요되는 시간 증가
  • 정렬시, 단순 Column 기준으로 밖에 못함
  • 품절인 상품들을 기준으로 한다면, 품절상품은 가장 맨하단으로밖에 배치못함
SELECT name FROM products_product WHERE name LIKE '%잇츄%' LIMIT 5;

ElasticSearch

  • 약 50ms 내외 소요
  • 인덱스 크기가 커져도 Inverted Index 데이터구조로 인한 검색성능 저하 미미
  • 정렬시, Score 값을 세밀하게 조절할 수 있으므로 원하는 서비스 요구사항을 맞출 수 있음
GET /products/_search
{
"size": 5,
"_source": ["product_name"],
"query": {
"wildcard": {
"product_name": "*잇츄*"
}
}
}

ElasticSearch 는 직접 온프레미스로 설치하여도 무방하지만,
저희는 AWS 에서 제공하는 OpenSearch 라는 Service를 이용하였습니다.

금번 포스팅에서는 깊게 다루지 못하였지만 Score를 향샹시키는 많은 쿼리방법들이 있습니다. 관련해서 참조 레퍼런스에 남겨둘테니 참고하시면 좋을것 같습니다.

첫 사내 기술블로그 포스팅을 마칩니다. 👏👏👏
ES Basic에 해당하는 내용이므로 추후 더욱 심화된 내용으로 뵙겠습니다!

company: jg.kang@fitpet.co.kr
email: renine94.dev@gmail.com
github: https://www.github.com/renine94
blog: https://renine94.github.io

--

--