신입 백엔드 개발자로 입사한 지 벌써 3개월이 지났다. 3개월의 수습 기간 끝에 정직원 전환이 된 기념으로, 수습 때 진행했던 프로젝트에 대한 회고록을 작성해보려 한다.
Like 함수 → Elasticsearch로 검색 성능을 개선하자
기존의 회사 어드민 페이지의 데이터를 검색하는 방법은 SQL문의 Like
연산자를 아용하여, 사용자가 특정 검색 키워드를 입력하면 해당 키워드를 포함하거나 일치하는 일치하는 모든 레코드(데이터)를 출력하고 있었다.
// Like문 예시
// 상품명이 '멍멍이' 텍스트가 포함되어있는 모든 데이터를 출력
SELECT *
FROM products
WHERE name LIKE '%멍멍이%'
데이터베이스 시스템에 있는 데이터를 Row(행) 단위로 한 줄씩 저장시키게 되고 like
검색을 사용하게 되면, 테이블의 첫 행부터 마지막 행까지 전체 데이터를 탐색하면서 데이터를 찾아내야 한다. 따라서 탐색해야 될 데이터들이 많아지게 되면 시간도 오래 걸리고 데이터를 모두 읽어야 하기 때문에 속도가 더 느릴 수 있다. 더욱이, 시간이 흐를수록 어드민 페이지에 데이터가 계속해서 쌓이는 구조 상, 앞으로 데이터를 검색하는 데 소요되는 시간이 점차 길어질 것이라고 예측되어, 이 문제를 해결하고자 했다.
※ ELK(=Elasticsearch+Logstash+Kibana) : 데이터 분석에 필요한 모든 유형의 데이터를 실시간으로 검색, 분석, 시각화할 수 있는 데이터 분석 플랫폼
Elasticsearch는 어떤 특정한 정보를 찾으려 할 때 키워드를 중심으로 저장되기 때문에 빠른 검색을 자랑한다.
Elasticsearch는 데이터를 저장할 때, 위와 같이 역 인덱스(inverted index) 라는 구조로 만들어 데이터를 저장한다. 이 때 여기서 특정 문장의 단어를 분석하여 key-value 형식의 JSON으로 변환하여 저장한다. 각각 저장된 문서를 'document'라고 한다. 이를 통해 특정 단어가 어느 위치에 있는지를 파악할 수 있다.
예를 들어 sky
라는 단어를 검색하게 되면, 전체 데이터를 모두 읽을 필요 없이 2, 3번 document로 바로 찾아가게 되므로 빠른 속도로 검색 결과들을 찾아낼 수 있다.
※ 반면에, like 연산자를 활용한 텍스트 검색은 테이블의 모든 데이터에 순차적으로 접근하면서 찾고자 하는 문자열(sky)이 있는지 일일히 확인해야한다.
1) 사례 1 - [Fitpet Mall] ES 검색엔진을 활용한 상품 검색
SELECT * FROM products WHERE name LIKE ‘%멍멍이%’
→ 상품에 대한 데이터가 많아지다 보니 해당 쿼리의 속도가 느려져 상품을 검색하는 데 소요되는 시간이 점차 늘어나는 문제 발생 → 속도 개선 필요2) 사례 2 - [NHN Cloud] 엘라스틱 서치를 이용한 상품 검색 엔진 개발 일지
3) 사례 3 - [크몽] 크몽 검색 기능 개선기
BEFORE
AFTER
Docker compose를 활용해 Elasticsearch와 Logstash 서버 환경을 구축했는데, 자세한 내용 및 구현 코드는 아래 포스팅에서 정리하였다.
↓
Docker Compose를 활용한 Elasticsearch, Logstash 개발 환경 구축
npm install @elastic/elasticsearch@8.13.0
import { Client } from '@elastic/elasticsearch';
export const ELASTICSEARCH_CONFIG = new Client({
node: 'http://es01:9200',
maxRetries: 5,
requestTimeout: 60000,
sniffOnStart: true,
});
elasticsearch를 연결하고,
const dataInfo = await ELASTICSEARCH_CONFIG.search({
index: "인덱스명",
// 오프셋 페이지네이션
from: (pageNo - 1) * perPage,
size: perPage,
query: {
bool: {
must: mustQueries,
must_not: mustNotQueries,
filter: filterQueries,
},
},
});
node.js의 search api에서 검색이 되도록 쿼리 검색 로직을 구현하였다.
Elasticsearch는 must, must_all, term, match, match_phrase 등의 다양한 쿼리를 활용하여 여러 조건으로 검색할 수 있기 때문에, 빠른 검색 속도 뿐만 아니라 사용자가 원하는 결과를 정확히 도출할 수 있는 것도 큰 장점이다.
(참고)
초심자들을 위한 Elastic Search 시작하기 (with NodeJS)
검색 속도는 향상시키면서, 기존의 어드민 페이지의 검색 결과와 거의 동일한 결과를 얻기 위해 처음 고안한 방법은 wildcard을 활용하는 것이었다.
그러나 wildcard 방식은 SQL의 "Like" 연산자와 유사하여, 위 이미지와 같이 286ms, 평균 약 250ms 정도의 응답 시간이 나와 기존 방식과 유사한 성능을 띄었다.
따라서 wildcard 대신, LIKE 검색과 유사하게 색인할 수 있는 ngram을 적용한 커스텀 tokenizer을 적용해보았고, 테스트 해본 결과 13ms의 응답 시간을 기록하여 wildcard 방식 대비 95.36% 정도 감소했다.
(참고)
https://findstar.pe.kr/2018/07/14/elasticsearch-wildcard-to-ngram/
30명의 유저가 1초만에 2번 반복해서 에러가 발생해도 계속 요청을 보내도록 설정 후 테스트 진행
<prod 서버 - ELK 미적용>
<test 서버 - ELK 적용>
✅ 동일한 DB환경에서 운영 서버(ELK 미적용)과 테스트 서버(ELK 적용)에 대해 응답 시간을 비교한 결과, 운영 서버의 평균 응답 시간은 37ms, 테스트 서버의 평균 응답 시간은 19ms로 차이가 있음을 확인. (검색 결과는 동일하게 나오도록 설정)
✅ 점점 데이터가 쌓이더라도, 데이터 양에 상관없이 예전과 같이 짧은 응답 시간을 유지할 수 있게 될 것
1. 띄어쓰기 및 대소문자 구분없이 검색이 가능하여 사용자 편의성 증대
(이미지는 보안 상 첨부X)
"analyzer": {
"my_ngram_analyzer": {
"tokenizer": "my_ngram_tokenizer",
"filter": ["lowercase"]
}
},
{
"analysis": {
"char_filter": {
"whitespace_remove": {
"type": "pattern_replace",
"pattern": "\\s+",
"replacement": ""
}
},
"analyzer": {
"custom_analyzer": {
"type": "custom",
"tokenizer": "whitespace",
"filter": ["lowercase"],
"char_filter": ["whitespace_remove"]
}
}
}
}
2. 검색어와 관련도 높은 순으로 정렬하여 정확한 검색 결과 제공
Logstash에서는, 최근 폴링한 시점을 기록하여 새로 추가된 데이터를 트래킹하여 엘라스틱 서치에 적재한다. 하지만 DB에서 데이터가 삭제되었을 때, 해당 데이터를 트래킹하여 엘라스틱 서치에도 삭제되도록하는 트래킹 방법을 따로 지원하지는 않고 있다.
삭제 임시 보관함에 들어간 데이터들은 30일 이후 일괄적으로 삭제된다.
삭제 임시 보관함에 데이터가 Insert될 경우, dataAvail 필드에는 insert된 날짜의 timestamp가 찍히고 있다(delete api). 즉, delete 요청(삭제 임시 보관함 insert) 요청이 들어왔을 때 직히는 날짜
여기서 3가지 방법을 생각해보았다.
파이프라인에 dataAvail에 찍힌 날짜가 현재 난ㄹ짜에서 30일 이전일 경우 자동으로 삭제
→ DB와의 동기화가 제대로 되지 않는 문제가 발생할 수 있음
삭제 임시 보관함에 들어가는 데이터의 수가 그리 많지 않아, 따로 자동 삭제 기능을 추가하지 않고 나중에 일괄적으로 수동으로 삭제하는 방법
간편성을 고려했을 때 1번이 나아보여, "1번"을 채택했다.
구현 과정
// repository.ts
/* 삭제 대상 Q&A 리스트를 삭제(Hard-Delete)하는 요청 */
async deleteDataList(idxList: number[]): Promise<void> {
await this.dataModel
.createQueryBuilder()
.withDeleted()
.delete()
.from(DataEntity)
.where('dataIdx IN (:...idxList)', { idxList })
.execute();
}
-----------------------------------------------
// service.ts
await dataService.deleteDataList(idxList)
await ELASTICSEARCH_CONFIG.deleteByQuery({
index: 'main-test01',
body: {
query: {
terms: {
_id: idxList,
},
},
},
});
service 로직에서, DB에 데이터가 삭제된 후 곧바로 엘라스틱 서치에서 해당 데이터들을 삭제하는 프로세스로 구현했다.
하지만 위 코드를 구현 중 문제가 발생하였다.
Promise.all
로 구현 중, 어느 한쪽의 Promise에 InterneException 에러를 강제로 발생시키면, 다른 한쪽에서는 삭제가 정상적으로 이루어지는 이슈const deleteFromDBPromise = this.dataSchedulerRepository.deleteDataList(idxList);
const deleteFromElasticPromise = this.dataSchedulerRepository.deleteDataListFromElastic(idxList);
const [isDeletedFromDB, isDeletedFromElastic] = await Promise.all([
deleteFromDBPromise.catch((err) => {
throw new BadRequestException('DB에서 삭제하는 중 에러 발생: ' + err.message);
}),
deleteFromElasticPromise.catch((err) => {
throw new BadRequestException('Elastic에서 삭제하는 중 에러 발생: ' + err.message);
}),
]);
if (!isDeletedFromDB || !isDeletedFromElastic) {
// 하나라도 False가 나올 경우
throw new BadRequestException('data 삭제가 정상적으로 이뤄지지 않았습니다');
}
➡ "트랜잭션"이 필요해보임
// repository.ts
/* 삭제 대상 Q&A 리스트를 삭제(Hard-Delete)하는 요청 */
async deleteDataList(idxList: number[]): Promise<void> {
return this.dataModel.manager.transaction(async (manager) => {
await manager
.createQueryBuilder()
.withDeleted()
.delete()
.from(DataEntity)
.where('dataIdx IN (:...idxList)', { idxList })
.execute();
await ELASTICSEARCH_CONFIG.deleteByQuery({
index: 'main-test01',
body: {
query: {
terms: {
_id: idxList,
},
},
},
});
// throw new InternalServerErrorException(); 🍀
});
}
deleteQnaList()
함수 안에 DB에서 데이터를 삭제하는 로직과 Elasticsearch에 데이터를 삭제하는 로직을 포함✔ "역시 프로젝트의 최종 보스는 기술 스택 세팅"
ELasticsearch와 Logstash 세팅(파이프라인 포함)부분이 제일 어려웠던 것 같다. 2~3시간 걸린다고 생각했는데, 1주일이 넘게 걸렸다. Volume 이슈, 설정 이슈 등 자잘한 이슈들이 많이 있었다
✔ 이슈 관리 & 코드 리뷰
사이드 프로젝트를 했을 때와 다르게, 이슈 및 마일스톤 관리와 일관된 커밋 메세지 작성에 더욱 신경을 썼다. 특히 팀원들에게 현재 내가 어떤 이슈를 진행 중인지 공유하기 위해 Feature, Modify, HotFix, Deploy, Setting 등 새 작업을 시작할 때 꼭 이슈를 등록하였다. 이슈 관리는 협업 과정에서 매우 중요한 일 중 하나라고 생각한다.
그리고 PR을 올릴 때 반영하고자 하는 브랜치명, 관련 이슈 목록, 특이사항 등을 포함한 PR 템플릿을 함께 추가하여 리뷰어(Reviewer)가 변경된 코드를 쉽게 파악할 수 있도록 했다.
무엇보다 코드리뷰가 굉장히 도움이 많이 되었다. 심플하고 사소한 코드라도 "왜 사용했지?" 라는 질문을 계속 던져주게 함으로써 코드의 목적과 사용 의도를 명확히 이해하고 더 나은 해결책을 찾을 수 있게 도와주셨다. 👍
✔ Commit 하기 전, 코드가 잘 변경되었는지, 테스트로 사용한 주석을 잘 해제했는지 등 꼼꼼하게 살피기
✔ 로컬 환경에서 개발 → 테스트 서버로 배포하기 까지의 과정을 경험
실시간으로 바로 검색이 되어야 한다
schedule => "*/5 * * * * *"
단일(single) 노드 → 다중(multi) 노드
es01:
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
environment:
- node.name=es01
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es02,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- ./elastic/es01/data:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
-
es02:
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- ./elastic/es02/data:/usr/share/elasticsearch/data
networks:
-
es03:
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- ./elastic/es03/data:/usr/share/elasticsearch/data
networks:
-
검색 품질 고도화
Elasticsearch을 활용한 추가 검색 기능 구현
Kibana 대시보드 개발
References
https://velog.io/@alli-eunbi/Elastic-Search-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-with-NodeJS
https://findstar.pe.kr/2018/07/14/elasticsearch-wildcard-to-ngram/
https://wonyong-jang.github.io/elk/2021/02/08/ELK-Elastic-Search4.html