소상공인들의 당일 폐기 예정 식품의 할인 정보 공유 및 구매/판매 서비스
소상 공인들의 당일 폐기 예정 식품의 할인 정보 공유 및 구매/판매 서비스를 팀 프로젝트로 진행 중이다.
팀 프로젝트를 수행하던 중, 나는 전반적인 업체 관리 및 검색 파트를 맡게 되었다.
아래 내용은 검색을 위한 기능을 구현하던 중 성능 개선을 위해 내가 시도했던 방법들을 적어본다!
목차
0. 들어가기 앞서
1. Like문(No QueryDSL) VS Full Text 쿼리
2. Like문(feat. QueryDSL) VS Full Text 쿼리
3. 우리 서비스에서 MySQL Index 적용시 문제점
4. ElasticSearch 도입
우선 들어가기 앞서서, 우리 서비스의 검색 관련 시스템에 간략하게 소개해주려고 한다.
간단하게 말하면 검색시 조회되는 조건은
- 해당 검색어를 입력하는 유저의 위치에서 3km 내에 있는 업체만 보여준다.
- 업체는 '영업 중'인 상태인 경우만 보여준다.
- 검색어는 '업체 명' or '업체가 보유한 상품의 이름'의 경우 둘 중 하나라도 포함되는게 있으면 보여준다.
위의 3가지 조건을 만족해야 검색 리스트에 나오게 된다.
또한 검색 정렬 조건은
- 거리순 (가까운 순)
- 업체의 마감 임박순
- 해당 검색 상품의 할인률 순
이렇게 세가지를 가지게 된다.
여기서 눈치를 챈 사람도 있겠지만, 많이 상용화 되어있는 배달 어플, 식품 어플의 경우를 생각해보면 보통 업체명 전체를 검색하기 보단 업체에 포함된 키워드나, 상품의 이름을 검색하는 경우가 많다.
다시 말하면 검색어의 길이가 길지 않을 것이라고 판단했다.
이 점을 서비스에 대한 사전 지식으로 가지고 검색 성능 개선기를 보도록 하자.
(참조 : 성능 테스트를 할 때 기준 데이터의 갯수는 모두 5만개이다.)
1. Like문(No QueryDSL) VS Full Text 쿼리
1-1) 검색어가 짧을 때의 Like문
먼저 기본적으로 일명 쌩 SQL이라고 하는 Like문을 통하여 구현을 하였다.
우선 코드로 보면
public interface StoreRepository extends JpaRepository<Store, Long> {
Optional<Store> findByMemberId(Long memberId);
@Query("SELECT s FROM Store s JOIN FETCH s.member m WHERE m.providerId = :providerId")
Optional<Store> findByMemberProviderId(@Param("providerId") Long providerId);
@Query(value =
"""
SELECT s.*
FROM stores s
JOIN addresses a ON s.address_id = a.id
LEFT JOIN items i ON s.id = i.store_id
WHERE s.store_status = 'OPENED'
AND (6371 * ACOS(COS(RADIANS(:yCoordinate))
* COS(RADIANS(a.y_coordinate))
* COS(RADIANS(a.x_coordinate) - RADIANS(:xCoordinate))
+ SIN(RADIANS(:yCoordinate)) * SIN(RADIANS(a.y_coordinate)))) <= 3
AND (s.name LIKE %:keyword% OR i.name LIKE %:keyword%)
ORDER BY (6371 * ACOS(COS(RADIANS(:yCoordinate))
* COS(RADIANS(a.y_coordinate))
* COS(RADIANS(a.x_coordinate) - RADIANS(:xCoordinate))
+ SIN(RADIANS(:yCoordinate)) * SIN(RADIANS(a.y_coordinate)))) ASC, i.updated_at DESC
""", nativeQuery = true)
Slice<Store> findByDistanceWithin3Km(
@Param("xCoordinate") double xCoordinate,
@Param("yCoordinate") double yCoordinate,
@Param("keyword") String keyword,
Pageable pageable
);
@Query(value =
"""
SELECT s.*
FROM stores s
JOIN addresses a ON s.address_id = a.id
LEFT JOIN items i ON s.id = i.store_id
WHERE s.store_status = 'OPENED'
AND (6371 * ACOS(COS(RADIANS(:yCoordinate))
* COS(RADIANS(a.y_coordinate))
* COS(RADIANS(a.x_coordinate) - RADIANS(:xCoordinate))
+ SIN(RADIANS(:yCoordinate)) * SIN(RADIANS(a.y_coordinate)))) <= 3
AND (s.name LIKE %:keyword% OR i.name LIKE %:keyword%)
ORDER BY ABS(EXTRACT(HOUR FROM s.close_time) * 60 + EXTRACT(MINUTE FROM s.close_time) -
(EXTRACT(HOUR FROM CURRENT_TIME) * 60 + EXTRACT(MINUTE FROM CURRENT_TIME)))
""", nativeQuery = true)
Slice<Store> findByDeadLine(
@Param("xCoordinate") double xCoordinate,
@Param("yCoordinate") double yCoordinate,
@Param("keyword") String keyword,
Pageable pageable
);
@Query(value =
"""
SELECT s.*
FROM stores s
JOIN addresses a ON s.address_id = a.id
LEFT JOIN items i ON s.id = i.store_id
WHERE s.store_status = 'OPENED'
AND (6371 * ACOS(COS(RADIANS(:yCoordinate))
* COS(RADIANS(a.y_coordinate))
* COS(RADIANS(a.x_coordinate) - RADIANS(:xCoordinate))
+ SIN(RADIANS(:yCoordinate)) * SIN(RADIANS(a.y_coordinate)))) <= 3
AND (s.name LIKE %:keyword% OR i.name LIKE %:keyword%)
ORDER BY (i.original_price - i.discount_price) * 1.0 / i.original_price DESC, i.updated_at DESC
""", nativeQuery = true)
Slice<Store> findByDiscountRate(
@Param("xCoordinate") double xCoordinate,
@Param("yCoordinate") double yCoordinate,
@Param("keyword") String keyword,
Pageable pageable
);
}
딱 보아도 쿼리가 굉장히 복잡하다...
각각 거리순, 마감 임박순, 할인률 순을 나타낸 코드들이며, 거리는 각 좌표를 얻은 뒤 기준 위치와 비교하였는데 이 때 MySQL의 하버사인 공식을 사용하였다.
또 검색어를 포함시켜야 하므로 %keyword% 를 사용하였다.
이를 통한 5만건의 데이터를 기준으로 조회의 성능을 확인해 보자.
짧게 '떡볶이'라고 검색했을 경우 290ms의 시간이 걸리는 걸 볼 수 있다.
1-2) 검색어가 짧을 때의 MySQL FullText
검색어가 동일하게 '떡볶이'의 경우 오히려 FullText는 411ms가 걸리는 것을 볼 수 있다.
2-1) 검색어가 길 때의 Like문
동일한 코드로 검색어가 길 경우의 Like문을 보자.
비교적 긴 '떡볶이가 무지하게 맛있어서 환장하는 집'라고 검색했을 경우 278ms의 시간이 걸리는 걸 볼 수 있다.
Like문의 경우 검색어가 짧을 때와 비교했을 때 크게 차이가 없다!!
2-2) 검색어가 길 때의 FullText문
'떡볶이가 무지하게 맛있어서 환장하는 집'라고 검색했을 경우 FullText의 경우 192ms의 시간이 걸리는 걸 볼 수 있다.
Like문의 경우 검색어의 길이와 상관없이 큰 차이가 없었지만, FullText의 경우 검색어가 짧을 때와 검색어가 길 때 차이가 411ms -> 192ms로 차이가 났다.
이유가 뭘까 ?
- 전체 텍스트 검색의 복잡성: Full Text Search는 일반적으로 검색 알고리즘이 더 복잡하며, 텍스트 색인을 활용하여 결과를 더 정확하게 산출한다. 이는 데이터베이스가 추가적인 작업을 수행해야 하므로 LIKE 문보다 더 많은 리소스가 소모될 수 있다.
- 검색어의 길이: 검색어가 짧을수록 Full Text Search의 이점이 줄어든다. Full Text Search는 주로 긴 문장이나 단락에서 유용하며, 검색어의 길이가 짧을 경우 일반적인 LIKE 문이 더 효과적일 수 있다.
- 인덱스 구조의 차이: Full Text Search는 전용 인덱스 구조를 사용하며, 이는 일반적인 인덱스와는 다르다. 인덱스의 구조가 서로 다르기 때문에 Full Text Search가 항상 더 느리지는 않지만, 특정 상황에서는 그렇게 나타날 수 있다고 판단했다.
1차 결론 : 검색어가 길다면 Full Text가 유리하지만, 우리 서비스에서는 검색어의 길이가 길지 않을 것(업체명의 특정 키워드, 상품명 정도)이라고 판단하여 Full Text를 사용하는게 효율적이지 않다고 판단!
2. Like문(QueryDSL NoOffset) VS Full Text 쿼리
그렇다면 NativeQuery 때문에 코드도 지저분해졌는데, QueryDSL을 사용하도록 리팩토링하고 No Offset등을 적용한다면 Full Text와 차이가 있을까? 라는 호기심이 생겼다.
우선 리팩토링한 코드를 보자
@RequiredArgsConstructor
public class StoreRepositoryImpl implements StoreRepositoryCustom {
private final JPAQueryFactory queryFactory;
private QStore store = QStore.store;
private QItem item = QItem.item;
private QAddress address = QAddress.address;
private final static String HAVERSINE = "(6371 * ACOS(COS(RADIANS({0})) * COS(RADIANS({1}.yCoordinate)) * COS(RADIANS({1}.xCoordinate) - RADIANS({2})) + SIN(RADIANS({0})) * SIN(RADIANS({1}.yCoordinate))))";
private final static String DEADLINE = "ABS(EXTRACT(HOUR FROM {0}) * 60 + EXTRACT(MINUTE FROM {0}) - " +
"(EXTRACT(HOUR FROM CURRENT_TIME) * 60 + EXTRACT(MINUTE FROM CURRENT_TIME)))";
private final static String DISCOUNT_RATE = "(item.originalPrice - item.discountPrice) * 1.0 / item.originalPrice";
@Override
public Slice<Store> findByKeywordAndDistanceWithin3KmAndSortCondition(double xCoordinate, double yCoordinate, String keyword, String sortBy, Long cursor, Pageable pageable) {
BooleanExpression distancePredicate = getDistanceWithin3KmPredicate(xCoordinate, yCoordinate);
BooleanExpression keywordPredicate = getKeywordPredicate(keyword);
orderSpecifiers(xCoordinate, yCoordinate, sortBy);
List<Store> result = queryFactory
.selectFrom(store)
.join(store.address, address)
.leftJoin(item)
.on(store.eq(item.store)
.and(item.store.id.eq(store.id)))
.where(store.storeStatus.eq(StoreStatus.OPENED),
distancePredicate,
keywordPredicate,
ltStoreId(cursor))
.orderBy(orderSpecifiers(xCoordinate, yCoordinate, sortBy))
.limit(pageable.getPageSize() + 1)
.fetch().stream().distinct().collect(Collectors.toList());
return checkLastPage(pageable, result);
}
private OrderSpecifier[] orderSpecifiers(double xCoordinate, double yCoordinate, String sortBy) {
ListSortType sortType = ListSortType.findSortType(sortBy);
OrderSpecifier<?>[] orderSpecifiers;
switch (sortType) {
case DISTANCE:
orderSpecifiers = new OrderSpecifier[]{getDistanceByNear(xCoordinate, yCoordinate).asc(), item.updatedAt.desc()};
break;
case DISCOUNT_RATE:
orderSpecifiers = new OrderSpecifier[]{getBigDiscountRate().desc(), item.updatedAt.desc()};
break;
case DEADLINE:
orderSpecifiers = new OrderSpecifier[]{getDeadlineImminent().asc(), item.updatedAt.desc()};
break;
default:
orderSpecifiers = new OrderSpecifier[]{getDistanceByNear(xCoordinate, yCoordinate).asc(), item.updatedAt.desc()};
break;
}
return orderSpecifiers;
}
private BooleanExpression ltStoreId(Long storeId) {
if (storeId == null) {
return null;
}
return store.id.gt(storeId);
}
private BooleanTemplate getDistanceWithin3KmPredicate(double xCoordinate, double yCoordinate) {
return Expressions.booleanTemplate(HAVERSINE + " <= 3", yCoordinate, address, xCoordinate);
}
private BooleanExpression getKeywordPredicate(String keyword) {
BooleanExpression keywordPredicate = store.name.like("%" + keyword + "%")
.or(item.name.like("%" + keyword + "%"));
return keywordPredicate;
}
private Slice<Store> checkLastPage(Pageable pageable, List<Store> resultList) {
boolean hasNext = false;
if (resultList.size() > pageable.getPageSize()) {
hasNext = true;
resultList.remove(pageable.getPageSize());
}
return new SliceImpl<>(resultList, pageable, hasNext);
}
private NumberTemplate<Double> getDeadlineImminent() {
return Expressions.numberTemplate(Double.class, DEADLINE, store.closeTime);
}
private static NumberTemplate<Double> getBigDiscountRate() {
return Expressions.numberTemplate(Double.class, DISCOUNT_RATE);
}
private NumberTemplate<Double> getDistanceByNear(double xCoordinate, double yCoordinate) {
return Expressions.numberTemplate(Double.class, HAVERSINE, yCoordinate, address, xCoordinate);
}
}
성능 개선에 관한 후기이니 코드에 대한 설명을 생략하도록 하겠지만 큰 틀에서 보면
- 지저분 했던 JPA NativeQuery를 QueryDSL로 리팩토링하여 Query를 문자가 아닌 자바 코드로 작성함으로써 컴파일 시점에 문법 오류를 파악할 수 있도록 함
- No Offset과 Cursor 기반 페이지네이션을 이용해 페이징 성능 개선
의 변화점이 있다. 이렇게 리팩토링한 후 성능을 테스트해보자. 여기선 특이점이 발견되었다. 우선 성능을 보자!
1-1) Cursor ID가 null 일때 (처음 조회시)
첫 조회시에는 506ms가 나오는 것을 볼 수 있다.
1-2) 조회된 마지막값(Cursor Id) ID를 넘겨주었을 때 (ex : lastId=49000)
52ms로 어마어마 하게 성능이 개선된 것을 볼 수 있다.
처음 JPA NativeQuery를 사용하는 SQL의 Like문과 비교했을 때 290ms -> 52ms로 82%의 성능이 개선 되었다!
여기서 잠깐!
💡 NoOffset을 사용하는 간략 이유
- 페이징을 Offset 형태로 할 때에는 페이징 쿼리가 뒤로 갈수록 느려지는데 이유는 결국 앞에서 읽었던 행을 다시 읽어야 하기 때문이다.
- 예를 들어 offset 10000, limit 20 이라 하면 최종적으로 10,020개의 행을 읽어야 한다. (10,000부터 20개를 읽어야하니)
- 그리고 이 중 앞의 10,000 개 행을 버리게 된다. (실제 필요한건 마지막 20개뿐이니)
- 뒤로 갈수록 버리지만 읽어야 할 행의 개수가 많아 점점 뒤로 갈수록 느려지는 것이다.
- No Offset 방식은 바로 이 부분에서 조회 시작 부분을 인덱스로 빠르게 찾아 매번 첫 페이지만 읽도록 하는 방식이다.
- (클러스터 인덱스인 PK를 조회 시작 부분 조건문으로 사용했기 때문에 빠르게 조회된다.)
💡 Cursor 기반 페이지네이션 Last Id 사용 이유
- 클라이언트 단에서 현재 갖고있는 id값의 마지막 값을 보내주면 id값을 조건에 넣고 limit으로 원하는 만큼 땡겨오는 방식입니다.
- 이렇게 작성하면 offset 만큼의 데이터를 읽을 필요가 없게 됩니다.
- 이전에 조회된 결과를 한번에 건너뛸 수 있게 마지막 조회 결과의 ID 를 조건문에 사용하는 방식을 이용한다.
3. MySQL 인덱스 적용 (?)
결론부터 말하면 우리 서비스는 Index를 적용하기 애매하고 어렵다는게 결론이다.
그 이유는 우리 서비스는 Like %query% 절으로 와일드카드가 문자열의 양쪽에 사용되면 패턴 매칭이 불필요하게 복잡해지며, 인덱스가 타지 않아 인덱스를 주는 것이 큰 의미가 없어서 사용 불가하다.
그래서 내가 생각한 대안은 검색 엔진을 사용하자! 였고 그 대상으로 ElasticSearch를 적용시켜 보기로 했다.
3. 검색 엔진 ElasticSearch 적용
우선 엘라스틱 서치의 이점을 간략하게 보자.
- Full-text 검색 엔진: ElasticSearch는 데이터의 풀 텍스트 검색에 특화되어 있다. 검색어의 형태소 분석을 통해 정확한 검색 결과를 제공한다.
- 인덱싱 및 검색 최적화: ElasticSearch는 데이터를 색인화하여 빠른 검색을 가능하게 한다. 특히, 대용량의 데이터를 다룰 때 검색 성능이 우수하다.
- 형태소 분석 지원: 한글 검색의 경우 형태소 분석이 중요한데, ElasticSearch는 여러 언어에 대한 형태소 분석기를 제공하므로 한글 검색도 효과적으로 처리할 수 있다.
- 분산 아키텍처: ElasticSearch는 분산 환경에서 동작하는데, 여러 노드에 데이터를 분산 저장하고 병렬 처리를 통해 빠른 검색 속도를 제공한다.
- 실시간 검색: ElasticSearch는 실시간으로 데이터를 색인화하고 검색할 수 있어, 데이터의 업데이트나 신규 데이터 추가에 대해 빠르게 반영된다.
- RESTful API 제공: ElasticSearch는 RESTful API를 제공하여 쉽게 통합할 수 있다. 이는 다양한 프로그래밍 언어나 웹 프레임워크에서 활용할 수 있는 장점이 있다.
특히 나는 이러한 이점중에서도 3번 형태소 분석에 대해 흥미로움을 느꼈고 접목시켜 보고싶었다.
관련해서 마이그레이션한 코드는 진행중인 서비스인 딜라이트 깃허브에서 보실 수 있습니다. (아직 여러 리팩토링이 진행중이기에!)
코드는 잠깐 두고 (돌아가게만 해두고, 아직 지저분한 코드이기에..)
엘라스틱 서치를 활용해서 5만건의 데이터를 동일한 조건으로 조회 했을 때 380ms가 걸리는 것을 볼 수 있다.
음... 일단 MySQL Like문과는 성능이 비슷했다. 데이터의 갯수가 조금 더 늘어나면 유의미한 차이가 있을까 싶지만, 현재로썬 비슷해 보였다!
또 하나의 딜레마는 QueryDSL을 이용하여 No Offset을 이용한 것과는 성능 차이가 많이 났다.(비교했을 때 엘라스틱 서치가 생각보다 느리다)
그래서 둘 중 어떤 것을 사용하는 것이 나을지 고민을 했고 아직도 여러 자료를 보며 고민중이다.
그럼에도 불구하고 엘라스틱 서치는 형태소 분석기라는 검색에 최적화된 기능을 가지고 있고, 전문검색을 제공하여 검색어에 대해 더 높은 성능의 검색을 제공이 가능하기에 우선 ES로 마이그레이션 한 것을 유지한채 코드 레벨에서 성능을 개선할 수 있는 점을 찾아보려고 한다! (엘라스틱 서치에서의 페이징 처리, RDB <-> ES간의 데이터 동기화 등)
정리
이번에 처음으로 MySQL의 Like문부터 시작하여 차근 차근 한 단계씩 디벨롭하며 성능을 개선시키는 경험을 해보았다.
그간 개념적으로, 용어적으로만 알고 있던 기술들도 어느정도 알게 되었다.
특히 QueryDSL과 엘라스틱 서치를 사용해보며 굉장히 매력적으로 느꼈다. 또 엘라스틱 서치를 구현해보고 테스트해보며 "아 실제로 내가 사용하는 서비스에서도 검색 엔진을 접목시켜 내가 편하게 검색할 수 있는것이구나" 라는 것도 느끼게 되었다.
위에서 적었듯이 이제는 엘라스틱 서치로 구현한 검색 기능의 성능을 개선 시켜보는 것에 몰두하려고 한다. 추후 더 좋은 개선 후기가 있다면 또 포스팅 해보겠다!
'프로젝트' 카테고리의 다른 글
[SpringBoot] Bucket4j를 이용하여 간편하게 특정 API 트래픽 제한해보기 (3) | 2024.02.29 |
---|---|
[성능 개선기] 2. 스프링 부트에서 Caffeine Cache를 이용하여 95.08%의 Latency 개선해보기 (2) | 2024.02.14 |
[성능 개선기] 1. 스프링 부트에서 HTML 메타 데이터 파싱시 ParallelStream vs CompletableFuture (1) | 2024.02.07 |