ElasticSearch 2화: ES 라이브러리 탐방기

이승연
elecle
Published in
31 min readJun 15, 2023

안녕하세요! 나인투원 백엔드 팀의 라일라와 쵸파입니다. 우당탕탕 ElasticSearch 탐색기를 공유합니다.

1. Django-ES 프로젝트 셋업

기본 설정

  • install해야 하는 패키지
  • 장고 프로젝트 셋업 과정
    - elasticsearch 프로젝트 만들기: django-admin startproject elasticsearch
    - apps 앱 만들기: python3 manage.py startapp apps
  • ES 도커 컴포즈 configuration
    - 서버실행: docker-compose up -d
# Dockerfile
FROM python:3.8-slim-buster
#any data will be sent to the terminal
ENV PYTHONUNBUFFERED=1
WORKDIR /elasticsearch_django
COPY requirements.txt requirements.txt
RUN apt-get update && apt-get -y install libpq-dev gcc
RUN pip3 install -r requirements.txt
COPY . .
# docker-compose.yml
version: "3.9"

services:
web:
build: .
command: >
sh -c "python manage.py makemigrations &&
python manage.py migrate &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- .:/elasticsearch_django
ports:
- "8000:8000"
depends_on:
- elasticsearch
networks:
- elastic
elasticsearch:
image: elasticsearch:7.14.0
volumes:
- ./data/elastic:/var/lib/elasticsearch/data
environment:
- discovery.type=single-node
ports:
- 9200:9200
networks:
- elastic

networks:
elastic:
driver: bridge

ES 검색 실험을 해보기 위해 기사의 제목과 카테고리를 필드로 가지고 있는 Article이라는 테이블을 생성했습니다. Category는 외래키로 가지고 있기 때문에 Category라는 테이블을 따로 생성했습니다. 더미 데이터로 카테고리 7가지와 기사제목 30개를 넣었습니다.

DRF를 활용하여 간단한 serializer와 view를 만들어보겠습니다.

# apps/serializers.py
class ArticleSerializer(serializers.ModelSerializer):
class Meta:
model = Article

fields = (
'id',
'title',
'category'
)
# apps/views.py
class ArticleView(viewsets.ModelViewSet):
serializer_class = ArticleSerializer

def get_queryset(self):
title = self.request.query_params.get("title")
category = self.request.query_params.get("category")

query = Q()

if title:
query &= Q(title=title)
if category:
query &= Q(category=category)

queryset = Article.objects.filter(query)
return queryset

2. ElasticSearch 라이브러리 적용

1. elasticsearch setting

settings.py에 ES 라이브러리를 등록하고 ES 호스트 정보를 입력합니다.

#elasticsearch/settings.py 

INSTALLED_APPS = [
'django_elasticsearch_dsl',
'django_elasticsearch_dsl_drf'
]


#elasticsearch
ELASTICSEARCH_DSL = {
'default': {
'hosts': 'elasticsearch:9200'
},
}

2. 색인을 위한 mapping 설정

데이터를 ES에 색인하기 위해 mapping 설정을 하겠습니다. mapping은 위 모델에서 정의한 스키마처럼 ES의 인덱스에 들어가는 데이터 타입을 정의하는 과정입니다. 사실 ES에서는 동적 매핑을 지원하기 때문에 미리 정의하지 않아도 ES가 가장 적합한 형태로 매핑을 생성합니다. 이 경우 미리 매핑을 하지 않아도 된다는 이점이 있지만 불필요한 필드가 생성되어 색인 성능을 저하시킬 수 있습니다. 이번 프로젝트에서는 저희가 직접 매핑을 정의하는 정적 매핑을 사용하여 색인하도록 하겠습니다. 매핑은 미리 정의한 db 모델과 동일할 필요가 없으며 검색에 용이한 형태로 정의하면 됩니다. 앱에 documents라는 파일을 생성하여 정의해보도록 하겠습니다. dsl 라이브러리와 django-dsl 라이브러리는 구현하는 방식이 살짝 다르기 때문에 각각 ArticleDslDocument와 AricleDjangoDslDocument로 따로 정의해보았습니다. drf_dsl 라이브러리는 django-dsl 라이브러리를 사용하여 매핑을 해야하기 때문에 따로 정의하지 않고 AricleDjangoDslDocument를 차용하겠습니다.

ArticleDslDocument

  • 인덱스 이름: article_dsl
  • 색인 기능: bulk_indexing 함수를 통해 모든 Article 객체를 순회하여 색인하는 기능을 만들었습니다. 해당 모델에도 인덱싱 함수를 추가해주었습니다.
  • elasticsearch에서는 외래키라는 개념이 존재하지 않습니다. 따라서 한 필드가 다른 모델을 참조하고 있는 경우 한 필드 안에 하위 필드를 넣는 object(객체) 타입의 값을 사용하여 구현합니다. 이번 프로젝트에서는 id와 title이라는 하위필드를 가지고 있는 category를 object로 구현했습니다.

AricleDjangoDslDocument

  • 인덱스 이름: article_django_dsl
  • 색인 기능: django-dsl 라이브러리 같은 경우 색인을 위한 강력한 커스텀 명령이 존재하여 따로 색인을 위한 함수를 만들지 않았습니다.
  • Django class를 통해 도큐먼트와 연관된 Article 모델을 등록합니다.
  • registry.register_document데코레이터를 통해 AricleDjangoDslDocument class를 등록합니다.

Nori 한글 형태소 분석기

  • 한글은 복합어, 합성어 등이 많아 하나의 단어도 여러 어간으로 분리해야 하는 경우가 많은 복잡한 언어입니다. 따라서 한글의 형태소 분석을 위해서는 한글 형태소 사전이 필수적인데요, 고맙게도 Elasticsearch 6.6 버전부터는 Nori(노리)라는 한글 형태소 분석기를 Elastic사에서 공식적으로 개발하여 지원하고 있습니다.
  • 기사 제목을 형태소 분석하기 위해 ‘nori_tokenizer’가 적용된 nori_analyzer를 정의해줍니다.
  • elasticsearch 서버에서 bin 디렉토리로 진입하여 elasticsearch-plugin install analysis-nori 명령 사용하여 노리 한글 형태소 분석기를 설치합니다.
# apps/documents.py

from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk

from elasticsearch_dsl import Document as dslDocument, Text, Object, Integer
from elasticsearch_dsl import analyzer
from elasticsearch_dsl.connections import connections

from django_elasticsearch_dsl import Document as djangoDocument, fields
from django_elasticsearch_dsl.registries import registry

from .models import Article


nori_analyzer = analyzer(
'nori_tokenizer',
tokenizer='nori_tokenizer',
)

class ArticleDslDocument(dslDocument):
title = Text(analyzer=nori_analyzer)
category = Object(properties={'id': Integer(), 'title': Text()})

class Index:
name = 'article_dsl'

def bulk_indexing():
connections.create_connection(hosts=['localhost'])
ArticleDslDocument.init(index='article_dsl')
es = Elasticsearch()
bulk(client=es, actions=(b.indexing() for b in Article.objects.select_related('category').all().iterator()))


@registry.register_document
class AricleDjangoDslDocument(djangoDocument):
title = fields.TextField(analyzer= nori_analyzer)
category = fields.ObjectField(attr='category', properties={'id': fields.IntegerField(), 'title': fields.TextField(attr='title', fields={'raw': fields.KeywordField()})})

class Index:
name = 'article_django_dsl'

class Django:
model = Article
# apps/models.py
class Article:
...
def indexing(self):
from apps.documents import ArticleDslDocument
obj = ArticleDslDocument(meta={'id': self.id},
category=model_to_dict(self.category, fields=['id', 'title']),
title=self.title)
obj.save(index='article_dsl')

return obj.to_dict(include_meta=True)

3. 색인

매핑 설정이 끝났으니 elasticsearch에 색인을 해볼까요?

  • dsl 라이브러리: documents에 정의되어 있는 bulk_indexing 함수를 호출
  • django_dsl 라이브러리: python manage.py search_index --rebuild 명령 실행

현재 30개 데이터로는 색인소요시간이 얼마 차이 나지 않지만 후에 3000여개 데이터로 다시 색인해보았을 때엔 bulk_indexing 함수보다 django_dsl 라이브러리의 커스텀 명령이 훨씬 빠르게 색인됨을 확인할 수 있었습니다.

전체 문서 검색

ES에 search API를 통해 전체 문서를 질의해보면 hits.total.value가 30으로 기사 30개 모두 정상적으로 색인 된 것을 알 수 있습니다.

endpoints:

response:

{
"took": 7,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 30,
"relation": "eq"
},
"max_score": 1.0,
"hits": [
{
"_index": "article_dsl",
"_type": "_doc",
"_id": "1",
"_score": 1.0,
"_source": {
"category": {
"id": 2,
"title": "경제"
},
"title": "2초에 49병 판매…하이트진로"
}
},
...
]
}
}

제목 필드 검색

색인된 기사 중 제목에 ‘49병’이 포함된 기사를 검색해보겠습니다. title 필드에 ‘2병’이라는 단어가 들어 있는 문서에 대한 검색을 요청했고 그 결과로 두 개의 문서가 검색되었음을 확인할 수 있습니다.

endpoints:

  • http://0.0.0.0:9200/article_django_dsl/_search?q=title:49병
  • http://0.0.0.0:9200/article_dsl/_search?q=title:49병

response:

{
"took": 18,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 3.108018,
"hits": [
{
"_index": "article_django_dsl",
"_type": "_doc",
"_id": "1",
"_score": 3.108018,
"_source": {
"title": "2초에 49병 판매…하이트진로",
"category": {
"id": 2,
"title": "경제"
}
}
},
{
"_index": "article_django_dsl",
"_type": "_doc",
"_id": "31",
"_score": 2.374001,
"_source": {
"title": "1초에 49병 팔린 ‘국민 소주’… 지난해 판매량 역대 최고\r",
"category": {
"id": 2,
"title": "경제"
}
}
}
]
}
}

카테고리 필드 검색

그럼 object 필드로 매핑된 카테고리에 대한 질의도 가능할까요? ES는 dot notation을 지원합니다. 쿼리 스트링으로 category.title이 ‘경제’인 문서에 대한 검색을 요청했고 그 결과 네 개의 문서가 검색되었음을 확인할 수 있습니다.

endpoints:

  • http://0.0.0.0:9200/article_django_dsl/_search?q=category.title:경제
  • http://0.0.0.0:9200/article_dsl/_search?q=category.title:경제

response:

{
"took": 7,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 4,
"relation": "eq"
},
"max_score": 2.071123,
"hits": [
{
"_index": "article_django_dsl",
"_type": "_doc",
"_id": "1",
"_score": 2.071123,
"_source": {
"title": "2초에 49병 판매…하이트진로",
"category": {
"id": 2,
"title": "경제"
}
}
},
{
"_index": "article_django_dsl",
"_type": "_doc",
"_id": "8",
"_score": 2.071123,
"_source": {
"title": "[기업] 참이슬 후레쉬 판매량 사상 최대...\"전년대비 9% 증가\"\r",
"category": {
"id": 2,
"title": "경제"
}
}
},
{
"_index": "article_django_dsl",
"_type": "_doc",
"_id": "30",
"_score": 2.071123,
"_source": {
"title": "\"킹달러 저문다\" 달러값 7개월 만에 최저",
"category": {
"id": 2,
"title": "경제"
}
}
},
{
"_index": "article_django_dsl",
"_type": "_doc",
"_id": "31",
"_score": 2.071123,
"_source": {
"title": "1초에 49병 팔린 ‘국민 소주’… 지난해 판매량 역대 최고\r",
"category": {
"id": 2,
"title": "경제"
}
}
}
]
}
}

3. Django 프로젝트에서 ES 질의하기

세 라이브러리를 django에 각각 녹여내는 작업을 해보겠습니다. 우선, views.py에 각 라이브러리를 활용할 뷰셋을 따로 선언하고 해당 뷰셋과 매핑되는 엔드포인트를 urls.py에 정의합니다.

# apps/views.py
class ArticleView(viewsets.ModelViewSet):
serializer_class = ArticleSerializer

def get_queryset(self):
title = self.request.query_params.get("title")
category = self.request.query_params.get("category")

query = Q()

if title:
query &= Q(title=title)
if category:
query &= Q(category=category)

queryset = Article.objects.filter(query)
return queryset

class ArticleDSLView(viewsets.ModelViewSet):
pass

class ArticleDJANGOdslView(viewsets.ModelViewSet):
pass

class ArticleDRFdslView(viewsets.ModelViewSet):
pass
# elasticsearch/urls.py

from rest_framework import routers

from apps.views import ArticleDocumentView, ArticleView, ArticleESView

router = routers.SimpleRouter(trailing_slash=False)

router.register(r'articles', ArticleView, basename='article')
router.register(r'articles-dsl', ArticleDSLView, basename='article')
router.register(r'articles-django-dsl', ArticleDJANGOdslView, basename='article')
router.register(r'articles-drf-dsl', ArticleDRFdslView, basename='article')

urlpatterns = [
]

urlpatterns += router.urls

차례차례 viewset을 보겠습니다.

dsl 라이브러리용 viewset과 serializer

# apps/views.py
from elasticsearch_dsl import Search

class ArticleDSLView(viewsets.ModelViewSet):
serializer_class = ArticleDSLSerializer

def get_queryset(self):
title = self.request.query_params.get("title")
category = self.request.query_params.get("category")

s = []
if title:
s = Search(index='article_dsl').query('match_phrase', title=title).execute()

elif category:
s = Search(index='article_dsl').query('match_phrase', category__title=category).execute()

data = []
if s:
for hit in s:
data.append({
"id": hit.id,
"title": hit.title,
"category": {'id': hit.category.id,
'title': hit.category.title}})
else:
data = [
{"id": None,
"title": None,
"category": {'id': None,
'title': None}}]

return data


# serializers.py

class ArticleDSLSerializer(serializers.ModelSerializer):
category = fields.DictField()
class Meta:
model = Article

fields = (
'id',
'title',
'category'
)
  • dsl 라이브러리에서 지원하는 Search object를 활용하면 queries, fileters, aggregations 등의 ES 기능을 이용할 수 있습니다.이번 프로젝트에서는 queries를 활용해보겠습니다.
  • Search(index='article_dsl').query('match', title=title).execute()
    - (index='article_dsl'): search obect를 통해 article_dsl 인덱스에서만 검색할 수 있도록 범위에 제약을 줍니다
    - query('match', title=title): 파라미터로 들어온 title 문자열이 포함된 제목을 가진 기사를 질의합니다.
    - .execute(): ES에 요청을 보냅니다.
  • search 객체를 통해 ES로 요청을 보내 반환된 데이터를 serializer에서 해석할 수 있는 dict형태로 변환하는 과정을 거칩니다.
    - category는 dict형태를 띄고 있기 때문에 serializer에서도 DictField를 사용해 category 필드를 재정의합니다.

django-dsl 라이브러리용 viewset과 serializer

# apps/views.py
from elasticsearch_dsl import Search

class ArticleDSLView(viewsets.ModelViewSet):
serializer_class = ArticleDSLSerializer

def get_queryset(self):
title = self.request.query_params.get("title")
category = self.request.query_params.get("category")

s = []
if title:
s = Search(index='article_dsl').query('match_phrase', title=title).execute()

elif category:
s = Search(index='article_dsl').query('match_phrase', category__title=category).execute()

data = []
if s:
for hit in s:
data.append({
"id": hit.id,
"title": hit.title,
"category": {'id': hit.category.id,
'title': hit.category.title}})
else:
data = [
{"id": None,
"title": None,
"category": {'id': None,
'title': None}}]

return data


# serializers.py

class ArticleDSLSerializer(serializers.ModelSerializer):
category = fields.DictField()
class Meta:
model = Article

fields = (
'id',
'title',
'category'
)
  • dsl 라이브러리에서 지원하는 Search object를 활용하면 queries, fileters, aggregations 등의 ES 기능을 이용할 수 있습니다.이번 프로젝트에서는 queries를 활용해보겠습니다.
  • Search(index='article_dsl').query('match', title=title).execute()
  • (index='article_dsl'): search obect를 통해 article_dsl 인덱스에서만 검색할 수 있도록 범위에 제약을 줍니다
  • query('match', title=title): 파라미터로 들어온 title 문자열이 포함된 제목을 가진 기사를 질의합니다.
  • .execute(): ES에 요청을 보냅니다.
  • search 객체를 통해 ES로 요청을 보내 반환된 데이터를 serializer에서 해석할 수 있는 dict형태로 변환하는 과정을 거칩니다.
  • category는 dict형태를 띄고 있기 때문에 serializer에서도 DictField를 사용해 category 필드를 재정의합니다.

django-dsl 라이브러리용 viewset과 serializer

# apps/views.py

class ArticleDJANGOdslView(viewsets.ModelViewSet):
serializer_class = ArticleDJANGOSerializer

def get_queryset(self):
title = self.request.query_params.get("title")
category = self.request.query_params.get("category")
qs = Article.objects.all()
if title:
s = AricleDjangoDslDocument.search().query("match_phrase", title=title)
qs = s.to_queryset()
elif category:
s = AricleDjangoDslDocument.search().query("match_phrase", category__title=category)
qs = s.to_queryset()
return qs


#serializers.py

class ArticleDJANGOSerializer(serializers.ModelSerializer):
class Meta:
model = Article

fields = (
'id',
'title',
'category'
)
depth = 2
  • 미리 정의해둔 AricleDjangoDslDocument에서 search 객체 호출 후 dsl 라이브러리와 동일하게 질의합니다.
  • to_queryset() 메서드를 사용하여 ES 질의 결과를 바로 queryset의 형태로 반환할 수 있습니다. dsl 라이브러리에서처럼 반환된 데이터를 손수 dict 형태로 변환하지 않아도 된다는 이점을 발견했습니다. 하나의 단점이 있다면, to_queryset() 메서드는 데이터베이스에 대한 직접 쿼리를 한번 실행하기 때문에 불필요한 db 질의가 생길 수 있습니다.

drf-dsl 라이브러리용 viewset과 serializer

# apps/views.py
from django_elasticsearch_dsl_drf.viewsets import DocumentViewSet
from django_elasticsearch_dsl_drf.filter_backends import SearchFilterBackend

class ArticleDRFdslView(DocumentViewSet):
document = AricleDjangoDslDocument
serializer_class = ArticleDRFSerializer

filter_backends = [
SearchFilterBackend
]

search_fields = ('title', 'category')

# serializers.py
from django_elasticsearch_dsl_drf.serializers import DocumentSerializer

class ArticleDRFSerializer(DocumentSerializer):
class Meta:
document = AricleDjangoDslDocument

fields = (
'id',
'title',
'category'
)
  • drf-dsl 라이브러리의 강력한 DocumentViewSet과 DocumentSerializer를 상속받으면 따로 ES 질의 레이어를 구현할 필요가 없습니다. viewset에서 filter_backends와 search_fields를 정의하고 serializer에서 document를 서브클래싱하면 자동으로 ES에 대한 질의가 이루어집니다.
  • 하지만, 커스텀 viewset과 serializer가 많은 일레클의 코드베이스에서는:
    - 새로운 serializer와 viewset을 상속받는 클래스 정의를 하기에 다소 큰 개발 공수가 들고,
    - 다중 상속을 하기에는 리스크가 따른다는 단점이 있습니다.

요청해보기

  • Endpoints:
    - 제목에 ‘벤츠’가 포함된 기사 검색
    - dsl: http://127.0.0.1:8000/articles-dsl?title=벤츠
    - django-dsl: http://127.0.0.1:8000/articles-django-dsl?title=벤츠
    -drf-dsl: http://127.0.0.1:8000/articles-drf-dsl?title=벤츠
  • Response
[
{
"id": 23,
"title": "벤츠, 마이바흐 S클래스 한정판 24대 판매… 3억1781만원",
"category": {
"id": 6,
"title": "세계"
}
},
{
"id": 3,
"title": "2022년 수입차 28만3435대 팔렸다…왕좌는 7년째 ‘벤츠’가 차지\r",
"category": {
"id": 6,
"title": "세계"
}
}
]
  • 카테고리가 ‘사회’인 기사 검색
    - dsl: http://127.0.0.1:8000/articles-dsl?category=사회
    - django-dsl: http://127.0.0.1:8000/articles-django-dsl?category=사회
    - drf-dsl: http://127.0.0.1:8000/articles-drf-dsl?category=사회
[
{
"id": 6,
"title": "SPC 안전경영위, 고용부 감독 조치결과 현장점검 실시",
"category": {
"id": 3,
"title": "사회"
}
},
{
"id": 11,
"title": "‘극단적 선택’ 땐 보험금 못받는데...대법원 “지급하라” 왜\r",
"category": {
"id": 3,
"title": "사회"
}
},
{
"id": 13,
"title": "“윗집 너무 시끄러워요”…관리소장이 연락처 알려줘도 될까\r",
"category": {
"id": 3,
"title": "사회"
}
},
{
"id": 15,
"title": "가혹행위에 극단 선택 군인 대법 \"사망보험금 지급하라\"\r",
"category": {
"id": 3,
"title": "사회"
}
},
{
"id": 26,
"title": "얼굴 가리고 포토라인 선 이기영 “살인 죄송",
"category": {
"id": 3,
"title": "사회"
}
},
{
"id": 27,
"title": "연금특위 “보험료·소득대체율 인상 필요”…더 내고 더 받나\r",
"category": {
"id": 3,
"title": "사회"
}
}
]

질의한 내용에 따라 올바른 정보가 반환됨을 알 수 있습니다.

4. 결론

여러 라이브러리를 탐구한 결과 다음과 같은 특징들을 발견할 수 있었습니다:

  • dsl 라이브러리를 사용했을 때에는 색인할 인덱스마다 bulk_indexing 함수를 직접 짜야 합니다. django_dsl 라이브러리의 커스텀 명령을 활용하면 이 과정을 생략할 수 있으며, 색인에 소요되는 시간 또한 감소합니다.
  • ES로 질의시 elasticsearch response 객체로 오는 데이터를 django에 맞는 형태로 변환하는 과정이 필요합니다. django_dsl 라이브러리에서 제공하는 to_queryset() 메서드를 사용하여 ES 질의 결과를 바로 queryset의 형태로 반환할 수 있습니다.
  • drf-dsl 라이브러리는 프로젝트 초기부터 활용할 경우 ES간의 질의 레이어를 따로 구상하지 않아도 된다는 장점이 있으나 DocumentViewset과 DocumentSerializer를 상속받아 사용해야 한다는 제약이 있습니다. 커스텀 viewset과 serializer가 많은 일레클의 코드베이스에서는 큰 제약이라고 볼 수 있습니다.

위의 내용을 표로 정리하면 다음과 같습니다:

정리하자면, 1️⃣ 색인을 위한 강력한 커스텀 명령 2️⃣ 일레클의 기존 코드베이스와의 적절한 연동방법 3️⃣ django 쿼리셋과 어우러지는 데이터 변환 방식이라는 세가지 강점이 django_dsl 라이브러리를 활용했을 때 두드러지게 나타납니다. 이러한 이유로 일레클 백엔드팀은 django-elasticsearch-dsl 라이브러리를 채택하여 사용하게 되었습니다.

참고문헌

https://esbook.kimjmin.net/06-text-analysis/6.1-indexing-data

기초부터 다지는 ElasticSearch 운영 노하우: 기본 개념부터 클러스터 구축, 실무 활용 시나리오까지 (박상헌, 강진우)

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-ngram-tokenizer.html

https://testdriven.io/blog/django-drf-elasticsearch/#database-models

--

--