ElasticSearch 3화: 일레클 고객문의 API 개선기[최종화]

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

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

1. ElasticSearch Data Structure

ElasticSearch는 검색엔진이기도 하지만 데이터 저장소의 역할도 합니다. ElasticSearch 기본 데이터 구조는 흡사 NoSQL 데이터베이스와 유사하며, 각 데이터베이스와 대응되는 개념은 아래 표와 같습니다.

2. Document Modeling

데이터베이스에서 모델링은 매우 중요합니다. 효과적으로 데이터를 파악하고 전체적인 성능에 영향을 미치기 때문에 도메인과 시스템의 특성에 맞춰 모델링을 진행해야합니다. 일레클에서는 PostgreSQL을 메인 데이터베이스로 활용하고 있습니다. ElasticSearch는 관계를 중요하게 생각하는 RDBMS와는 달리 데이터 모델에 중점을 두고 있기에 외래키(FK)와 Join이 존재하지 않습니다. 따라서 데이터의 특성, 활용, 기존 RDBMS 구조 등을 고려하여 새롭게 모델링을 진행해야합니다. ElasticSearch에서 관계형 데이터베이스 모델링 방법은 크게 4가지가 있습니다.

일레클 서비스에서 사용하는 고객문의 테이블(Inquiry)의 주요 구성은 아래와 같습니다. 고객문의를 요청한 유저, 고객문의 유형, 라이딩, 바이크, 고객문의 담당자 등의 정보가 담겨 있습니다.

class Inquiry:
"""고객 문의 테이블"""
user = models.ForeignKey('User', related_name='inquiries', on_delete=models.SET_NULL, null=True)
category = models.SmallIntegerField(choices=Category, null=True)
related_riding = models.ForeignKey('Riding', related_name='inquiries', on_delete=models.SET_NULL, null=True)
related_bike = models.ForeignKey('Bike', related_name='inquiries', on_delete=models.SET_NULL, null=True)
question = models.TextField()
answer = models.TextField()
assignee = models.ForeignKey('User', related_name='assigned_inquiries', on_delete=models.SET_NULL, null=True, help_text='문의의 담당자')
handler = models.ForeignKey('User', related_name='receipted_inquiries', on_delete=models.SET_NULL, null=True, help_text='현장확인대기로 만든 관리자')
resolver = models.ForeignKey('User', related_name='resolved_inquiries', on_delete=models.SET_NULL, null=True, help_text='해결완료 상태로 만든 관리자')
extra = models.JSONField(default=dict, null=True, blank=True)

고객문의 데이터의 특성상 고객문의 요청 유저, 라이딩, 자전거, 고객문의 담당자, 현장관리 등 여러 테이블과 연결되어 있습니다. 따라서 관제 시스템에서 고객문의 데이터를 호출하면 수 많은 테이블에서 쿼리가 발생했습니다. 물론 django의 select_related, prefetch_related 를 활용한 eager loading를 진행했음에도 그 수가 워낙 많다보니 지연이 발생하곤 했습니다. 우리는 이 문제를, 운영 DB와 Sync가 맞춰진 ElasticSearch가 존재하고 관제 시스템에서 사용하는 데이터들을 기반으로 Denormalization 모델링을 채택한다면 발생하는 지연을 줄일 수 있을 것이라 생각했습니다.

결과로 되돌려주어야 하는 데이터가 많은 테이블과 연결되어 있기 때문에 우선 검색에 필요한 필드만 InquiryDocument로 구성하여 검색 최적화를 이루고자 했습니다. 즉, 검색에 쓰이는 필드만 ES에 색인하고 결과로 나온 도큐먼트의 id를 추출해 장고 쿼리셋에 다시 쿼리하는 방법을 채택하였습니다.

@registry.register_document
class InquiryDocument(Document):
id = fields.IntegerField()
user = fields.TextField(analyzer=phonenumber_tokenizer)
assignee = fields.KeywordField(null_value='NULL')
handler = fields.BooleanField()
resolver = fields.BooleanField()
maintenance_called = fields.BooleanField()
related_riding_bike_sn = fields.TextField(analyzer=sn_tokenizer)
riding_bike_sn = fields.TextField(analyzer=sn_tokenizer)
bike_sn = fields.TextField(analyzer=sn_tokenizer)

def prepare_user(self, instance):
return instance.user.phone if instance.user else None

def prepare_assignee(self, instance):
return instance.assignee.name if instance.assignee else None

def prepare_handler(self, instance):
return True if instance.handler else False

def prepare_resolver(self, instance):
return True if instance.resolver else False

def prepare_maintenance_called(self, instance):
try:
if instance.report:
if instance.report.maintenance:
if instance.report.maintenance.status == 0:
return True
except:
return False

def prepare_related_riding_bike_sn(self, instance):
return instance.related_riding.bike.sn if instance.related_riding else None

def prepare_riding_bike_sn(self, instance):
if instance.report:
if instance.report.riding:
return instance.report.riding.bike.sn
else:
return None

def prepare_bike_sn(self, instance):
return instance.report.bike.sn if instance.report is not None and instance.report.bike else None

def get_queryset(self):
return super(InquiryDocument, self).get_queryset().select_related('user', 'assignee', 'handler', 'resolver',
'report', 'related_riding__bike', 'report__bike',
'report__maintenance', 'report__riding', 'report__riding__bike')

class Index:
name = 'inquiry_v1'
settings = {
'max_ngram_diff': 9
}

class Django:
model = Inquiry

관제시스템에서 고객문의 데이터를 검색할 때는 유저의 핸드폰 번호의 일부 혹은 자전거의 고유 번호의 검색을 합니다. Keyword 타입의 경우, 입력된 문자열을 하나의 토큰으로 저장하는 keyword analyzer가 기본으로 적용되어 있습니다. 따라서 exact value, 즉 완전 동일한 데이터에 대해서만 검색에 사용할 수 있습니다. 그렇기에 일반적으로 사용하던 일부 키워드로 진행하는 검색이 불가능합니다. 반면 Text 타입의 경우 문자열을 term 단위로 쪼개어 역색인 구조를 만드는 standard analyzer가 기본으로 적용되어 있습니다.

따라서 적절한 analyzer를 사용한다면 단어 혹은 일부만 검색해도 원하는 결과를 찾을 수 있습니다. 따라서 완전검색이 필요한 필드의 경우는 Keyword 필드를 사용했고, 부분검색이 필요한 필드에 대해서는 Text 필드를 채택했습니다. 예를 들어, assignee의 경우 ‘이승연’처럼 이름 전체를 입력하고 이름 전체가 검색되는 경우에만 결과가 반환되는 반면, user의 경우 ‘6351’처럼 전화번호의 일부만 입력해도 해당 번호가 포함된 결과가 모두 반환됩니다.

InquiryDocument 모델링 후 elasticsearch에 inquiry_v1 인덱스 생성 후 고객문의 데이터 700여개를 색인하였습니다.

3. Querying

# original code

def list(self, request, **kwargs):
query = Q()
sort = self.payload.get('sort', 'id')

if q := self.payload.get('q'):
if Helper.guessed_phone_number(q):
query &= Q(user__phone__contains=q)
elif Helper.guessed_sn(q):
query &= (
Q(related_riding__bike__sn=q) |
Q(report__riding__bike__sn=q) |
Q(report__bike__sn=q)
)
else:
query &= (Q(assignee__name__icontains=q) | Q(question__icontains=q))
self.queryset = Inquiry.objects.select_related(
'assignee', 'user', 'handler', 'resolver', 'report', 'report__maintenance',
'report__maintenance__vehicle', 'report__maintenance__caller',
'report__maintenance', 'report__bike', 'report__riding',
'report__staff', 'related_riding', 'related_bike',
).prefetch_related(
'photos',
'report__maintenance__stacks',
'report__maintenance__brokens',
'report__photos'
).filter(query).order_by(sort)

page = self.paginate_queryset(self.queryset)
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)py
# elasticsearch applied code

def list(self, request, **kwargs):
from elasticsearch_dsl import Q
query = Q()
sort = self.payload.get('sort', 'id')

if q := self.payload.get('q'):
if Helper.guessed_phone_number(q):
query &= Q('term', user=q)
elif Helper.guessed_sn(q):
query &= (Q('term', related_riding_bike_sn=q) | Q('term', riding_bike_sn=q) |Q('term', bike_sn=q))
else:
query &= (Q('term', assignee=q) | Q('term', question=q))

response = InquiryDocument.search().filter(query).scan() .....(1)
id_list = [s.id for s in response] .....(2)

self.queryset = Inquiry.objects.select_related(
'assignee', 'user', 'handler', 'resolver', 'report', 'report__maintenance',
'report__maintenance__vehicle', 'report__maintenance__caller',
'report__maintenance', 'report__bike', 'report__riding',
'report__staff', 'related_riding', 'related_bike',
).prefetch_related(
'photos',
'report__maintenance__stacks',
'report__maintenance__brokens',
'report__photos'
).filter(id__in=id_list).order_by(sort)

page = self.paginate_queryset(self.queryset)
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

관제웹에서 고객문의데이터에 대한 검색은 보통 전화번호, 기기번호, 고객문의 할당자에 대하여 이루어집니다. ES의 search 객체와 filter 기능을 사용해 ES에 데이터를 쿼리하였고 기존의 데이터와 동일하게 나옴을 확인하였습니다(1). 하지만 가장 중요한 속도에서 문제를 발견했습니다. ES를 적용한 검색에서 기존보다 1~2초 정도 더 소요하고 있었고, 이 속도저하는 ES에 질의하고 결과값에서 id를 추출하는 과정에서 온전히 발생하고 있었습니다(2).

drf 방식:

es 방식:

이쯤에서 검색과 필터에 필요한 정보만 InquiryDocument에 색인하지 말고 serializer에서 처리되는 모든 데이터를 ES에 색인하여 데이터 직렬화의 시간소모를 줄여야 하는지 고민이 되었습니다. 하지만 해당 방법을 사용해도 데이터를 추출하여 클라이언트가 받기에 용이한 형태로 파싱해야 하는 작업이 추가되고, 데이터를 추출하는 과정에서 동일한 latency가 발생할 것이기 때문에 의미가 없는 작업이라는 결론이 나왔습니다.

ES와 애플리케이션 간 데이터 파싱의 Best practice를 찾아 헤맸습니다. 하지만 모든 문헌은 ES 쿼리 결과에서 필요한 데이터를 직접 추출하여 파싱하라는 조언을 하고 있었습니다.

https://elasticsearch-dsl.readthedocs.io/en/latest/search_dsl.html?highlight=scroll#result

ES 질의 성능을 높이기 위한 방법은 shards 개수 조절, InquiryDocument 모델링 개선, analyzer 적용 방식 개선 등이 있습니다. 하지만 기존 느리다고 생각했던 DRF 방식이 500 ms 정도의 준수한 성능을 보이고 있어 ES 질의 성능 개선을 위한 리소스 투입이 부적절하다고 판단했습니다. ES 리서치가 한창 진행되던 2022년 12월에 RDS Aurora for PostgreSQL 스케일업 [db.r6g.4xlarge → db.r6g.8xlarge] 작업이 있었고 ECS Fargate 최소 task 수도 늘린 것이 성능 증대의 이유라고 생각됩니다.

4. 결론

이번 ElasticSearch 도입 프로젝트에서는 크게 다음과 같은 작업을 수행했습니다:

  • ElasticSearch 전반에 대한 스터디
  • 기존 django(drf) 프로젝트와 부드럽게 융화될 수 있는 ES 라이브러리 탐색
  • 고객문의 API에 ES 도입한 미니 프로젝트 시행
    - 도큐먼트 모델링
    - 질의

결론적으로, 일레클의 고객문의 검색 성능 개선기는 수많은 삽질과 고민 끝에 기존 방법을 유지하는 쪽으로 결정되었습니다.

기존 방법을 유지하는 근본적인 이유는 느리다고 생각했던 고객문의 API가 생각보다 준수한 성능을 보이고 있었고 (ES 리서치 기간 중 인프라 스케일업이 이루어졌음) ES를 거칠게 도입해보았을 때 오히려 성능이 악화되는 현상을 포착했기 때문입니다. 모델링과 질의과정에서 개선의 여지가 있는지 여러가지 실험을 해보았으나 그만큼의 리소스가 필요하지 않다라는 결론에 도달하게 되었습니다.

한달 반 정도를 ES에 쏟아부었지만 상용환경에 도입할 수 있을만한 성과를 내지 못한 점은 매우 아쉽습니다. 하지만 미래 일레클 서비스가 더 고도화되어 실제로 ES가 필요한 상황이 생겼을 때 여러가지 설정과 모델링/질의 기법에 대한 허들을 확실하게 제거했습니다. 또한, ES 도입 시 어떤 문제가 생길 수 있는지 학습하게 된 계기가 되었습니다.

5. 소감

쵸파

맨 처음 “검색 성능 고도화” 라는 큰 주제를 맡게 되었을 때 무작정 엘라스틱 서치를 떠올렸고 그렇게 한 달 정도의 리서치와 실험을 진행했습니다. 지금 생각해보면 쿼리(Query)와 검색(Search)의 차이를 제대로 구분하지 못한채 우리 시스템에 무작정 적용하려고 했던 것이 아쉽습니다. 그럼에도 한 달 반 동안 어떻게 ElasticSearch가 높은 성능을 보이는지, 기본적인 ELK 스택, 검색엔진과 쿼리엔진의 차이 등을 알게되는 시간이었습니다. 그 외에도 관제웹 검색 기능 고도화를 위해 CS팀과의 미팅을 주도적으로 진행해보기도 했고, 그 과정에서 CS팀의 니즈(검색 성능 보다는 관제웹 대시보드, 기기 검색의 성능 이슈)를 파악하기도 했습니다. 무엇보다 이후에 ElasticSearch 도입을 고민하는 상황에서 우리에게 ElasticSearch가 적절한지 판단 할 수 있는 조금의 기준이 생긴 것이 지난 프로젝트의 가장 큰 유산이 아닐까 생각합니다 ! 한 달 반 동안 주도적으로 힘써주시고, 리딩해주신 라일라 감사합니다 ! 🙇‍♂️

라일라

한달 반 정도 ES 리서치와 미니 프로젝트를 진행해보았는데요, 우리 서비스에 적용해 볼만하다는 결과를 도출해내지 못하여 아쉽습니다. 다음에는 도큐먼트 모델링을 더 정교하게 진행하여 성능을 개선해보려고 합니다. 새로운 기술스택에 발을 담궈보는 즐거운 경험이었고 입사 후 첫 프로젝트를 함께 진행한 쵸파와도 호흡이 잘 맞아 행복했습니다 😊

--

--