글을 쓰게 된 이유
YAPP 프로젝트를 진행하면서 제목을 통해 ‘모임 모집 글’을 검색하는 기능을 맡았습니다.
처음엔 기능 구현이 우선이었기 때문에 like를 통해 기능을 구현했습니다.
기능 구현을 모두 마무리하고, 리팩토링하는 과정에서 “만약에 게시글이 많아지고, 제목도 길어지게 되면 해당 쿼리는 엄청 오래 걸리지 않을까?”라는 생각이 들었습니다.
그래서 검색 성능 최적화를 하는 과정을 블로그에 적어보려고 합니다.
요구 사항
일반적인 검색과 같았습니다.
게시글에 제목이 있고, 사용자가 제목을 통해 검색하면 해당 단어가 포함되어있는 게시글을 조회하는 기능이었습니다.
초기 쿼리는 like %keyword% 로 날렸습니다.
테스트 상황
DB : MySQL, Elasticsearch
게시글의 데이터를 499998개 넣어주었습니다.
데이터의 절반에는 ‘산책’이라는 제목이 포함되어있습니다.
데이터 499998개가 조금 불편했지만, ES에서 데이터 499998개까지 넣을 수 있어서 MySQL의 데이터도 499998개로 맞춰주었습니다.
페이징 처리를 해주었고, 한 페이지에는 200개의 데이터를 조회하도록 했습니다.
대용량의 데이터를 조회할 때 모든 데이터를 메모리에 올리지 않기 때문에 위와 같이 상황을 가정했습니다.
인덱싱
먼저 인덱스 생각을 했습니다.
쿼리 실행 계획을 먼저 작성해 보고, 실제 쿼리를 통해서 시간 측정을 해보려 했습니다.
하지만 쿼리 실행 계획에서는 인덱스를 타지 않더군요,,
실패
일반적인 경우에는 인덱스를 타겠지만 쿼리가 %keyword%이기 때문에 인덱스가 타지 않습니다.
어떻게 보면 당연하죠.
숫자는 작은 수, 큰 수가 존재하고 문자열은 순서가 있습니다.
그리고 그 기준에 맞춰 정렬하여 인덱스를 만드는 것이지요.
하지만 해당 쿼리는 와일드카드가 앞에 존재하여 어느 문자열부터 찾아야 하는지 알 수가 없기 때문에 인덱스 의미가 없어지게 되고, 결국 풀 테이블 스캔을 합니다.
제일 처음 시간이 인덱스를 생성하기 전이고, 나머지는 인덱스를 생성한 후의 조회 시간이었습니다.
캐싱 때문에 테스트가 제대로 실행되지 않을 수 있어서 매번 쿼리파라미터 page를 다르게 주었습니다.
쿼리 실행 계획에서도 인덱스를 타지 않고, 시간에서도 유의미한 차이를 보이지 못했습니다.
그렇다면 어떻게 검색을 좀 더 빨리 할 수 있을까라는 생각이 들었습니다.
그러다 검색 엔진 Elasticsearch를 알게 되었고, 이를 한번 도입해 보자 라는 생각을 했습니다.
Elasticsearch 도입
Elasticsearch 7.15.2 버전을 설치하고, RestHighLevelClient를 사용하였습니다.
public void searchElasticsearch(final int page) {
final long startTime = System.currentTimeMillis();
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.wildcardQuery("title", "*산책*"))
.withPageable(PageRequest.of(page, 200));
NativeSearchQuery searchQuery = queryBuilder.build();
SearchHits<ClubDocument> searchHits = elasticsearchRestTemplate.search(
searchQuery,
ClubDocument.class,
IndexCoordinates.of("clubs")
);
final long endTime = System.currentTimeMillis();
log.info("Elasticsearch 검색 시간 : {}", (endTime - startTime));
}
ElasticsearchRestTemplate을 통해 산책이 포함되어 있는 Document들을 조회했습니다.
public void searchMySQL(final int page) {
final long startTime = System.currentTimeMillis();
final Page<Club> clubs = clubRepository.findByTitleContains("%산책%",
PageRequest.of(page, 200));
final long endTime = System.currentTimeMillis();
log.info("MySQL 검색 시간 : {}", (endTime - startTime));
}
MySQL에서도 또한 와일드카드를 통해 조회했습니다.
아래는 5번의 테스트를 통해 나온 시간입니다.
캐싱으로 인해 테스트의 신뢰성이 떨어질 수도 있어서 매번 page를 다르게 주었습니다.
단위는 ms입니다.
MySQL 1919
ES 434
MySQL 1285
ES 284
MySQL 1255
ES 455
MySQL 1284
ES 256
MySQL 1157
ES 160
이를 통해 약 4~5배의 시간이 줄어들었습니다.
해당 테스트도 좋지만 부하 테스트를 통해 여러 번의 요청일 경우에 평균 시간의 차이를 알고 싶었습니다.
부하 테스트
성능 테스트 툴은 JMeter를 사용했습니다.
200 명의 사용자가 총 10초 동안 2번씩 요청을 하는 것입니다.
즉, 10초동안 총 400개의 요청이 오는 상황입니다.
이때도 캐싱 때문에 테스트의 신뢰성이 떨어질 수도 있기 때문에 HTTP 캐시 및 매 요청마다 page 쿼리 파라미터를 랜덤으로 주었습니다.
성능 테스트를 한 후 아래는 시간을 적어놓은 로그들입니다.
약 28배의 효과가 있는 것을 알 수 있습니다.
결론
이번 최적화를 통해서 쿼리 실행 계획, 문자열의 앞에 와일드카드가 붙으면 왜 인덱스가 안 타는지, 부하 테스트 등 많은 것을 알 수 있었습니다.
처음 테스트에서 약 4~5배의 효과가 있어서 ES를 도입하는 것이 더 좋은 걸까?라는 생각을 했습니다.
새로운 DB를 도입하게 되는데, 관리나 데이터 싱크를 맞추는 게 더 힘들기 때문이죠.
하지만 부하 테스트를 통해 확인한 결과 10초 동안 400건의 요청이 올 경우에는 약 38배의 효과를 볼 수 있었습니다.
그래서 ES가 꽤 효과적이라는 생각이 들어서 도입하게 됐습니다.
'Spring Data' 카테고리의 다른 글
@NotNull vs @Column(nullable = false) (0) | 2022.10.22 |
---|---|
Find vs Get (0) | 2022.09.25 |
JpaRepository vs CrudRepository (0) | 2022.08.23 |
Spring Data JPA에서의 벌크 삭제 정리 (0) | 2022.07.12 |
Querydsl의 like vs contains (0) | 2022.05.22 |