Kuma's Curious Paradise
[이룸] 검색 기능 고도화 1 - 현재 상황 점검 및 전문 검색 기능 도입 본문
1. 현재 이룸은 어떻게 검색을 하고 있을까?
ChallengeService.java
public AllResponseDto getQueryChallenge(String query, int page, int size) {
try {
// Pageable 객체를 생성하여 페이징 처리
Pageable pageable = PageRequest.of(page, size);
// JPA Repository를 사용하여 페이징된 데이터를 가져옴
Page<Challenge> queryChallengesPage = challengeRepository.findByCategoryContainingOrTitleContainingOrDescriptionContaining(query, query, query, pageable);
// 가져온 페이지에서 ChallengeResponseDto로 변환
List<ChallengeResponseDto> queryChallengeResponseDtoList = queryChallengesPage.getContent().stream()
.map(challenge -> createChallengeResponseDto(challenge))
.collect(Collectors.toList());
// 페이징된 데이터와 메시지를 포함한 응답 생성
Page<ChallengeResponseDto> challengeResponseDtos = new PageImpl<>(queryChallengeResponseDtoList, pageable, queryChallengesPage.getTotalElements());
return new AllResponseDto(challengeResponseDtos, "키워드로 챌린지 조회 성공", HttpStatus.OK);
} catch (Exception e) {
return new AllResponseDto(null, "키워드로 챌린지 조회 중 오류 발생: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
1-1) Containing 대신 Like를 쓰면?
- 현재 이룸은 findByCategoryContainingOrTitleContainingOrDescriptionContaining(query, query, query, pageable); 이라는 매우 긴 메서드를 사용 중.
- Containing은 Spring JPA에서 제공하는 키워드로, 내부적으로 Like 연산자를 사용한다.
- 이 키워드를 사용하면 %query%와 같이 앞뒤로 %를 자동으로 붙여서 해당 문자열이 포함되어 있는지 검사한다.
- Like 연산자를 직접 사용할 경우 %query(해당 문자열로 끝나는), query%(해당 문자열로 시작하는)를 붙여서 구체적인 검색이 가능하다.
1-2) Containing이 성능 저하의 주범이 된다?
- %query% 형태의 like 사용은 책의 맨 앞부터 맨 끝까지 모든 단어를 확인한다.
- 색인(인덱스)을 사용하면 단어를 더 빠르게 찾을 수 있지만, %query% 형태로 단어가 중간에 포함된 경우, 색인은 도움을 줄 수 없다. 색인은 보통 단어의 시작을 기준으로 정렬되어 있기 때문. (책 뒤에 붙은 색인 페이지를 생각해 보라.)
- 따라서 %query% 형태로 검색하는 것은 많은 양의 데이터를 가지고 있을 때 매우매우매우(!!!!!!) 비효율적일 수 있다. 검색어를 찾기 위해 모든 데이터를 처음부터 끝까지 살펴봐야 하기 때문이다.
2. containing 대신 도입해 볼 수 있는 것
1. Full-Text Search (전문 검색)
- MySQL, PostgreSQL 등 대부분의 관계형 db는 내장된 전문 검색 기능을 제공. 이룸은 현재 MySQL 사용 중. 전문 검색은 텍스트 데이터 내에서 키워드를 빠르게 찾아내는 기능으로, %query%와 유사한 검색을 훨씬 빠르고 효율적으로 수행하도록 도와준다.
- MySQL에서 전문 검색은 MyISAM과 InnoDB 스토리지 엔진에서 지원. 주로 사용되는 InnoDB 엔진을 기준으로 도입 방법을 소개.
1-1 ) Full-Text 인덱스 생성 : 데이터베이스에서 Full-Text 인덱스를 생성한 다음, 이 인덱스를 사용하여 검색을 수행하는 쿼리를 Spring Data JPA 리포지토리에 추가해야 함. 즉, Challenge 엔티티의 category, title, description 필드에 Full-Text 인덱스를 생성해야 함.
(예시) CREATE FULLTEXT INDEX idx_name ON table_name(column_name);
(실제 사용) ALTER TABLE challenge ADD FULLTEXT(category, title, description)
- 이 SQL 명령어는 ALTER TABLE 구문을 사용하여 기존의 challenge 테이블에 Full-Text 인덱스를 추가. 이는 Inverted Index, 역인덱스 구조를 사용하는데, 키-값으로 이루어짐.
- 원래 인덱스는 그 안의 단어들을 나열하는 방식. 역 인덱스는 단어를 중심으로 어느 문서에 해당 단어가 존재하는지 보여줌.
1-2 ) 커스텀 리포지토리 메소드 추가 : Spring Data JPA에서는 기본적으로 Full-Text Search를 직접 지원하지 않기 때문에, 사용자 정의 쿼리를 사용하여 이 기능을 구현해야 함.
- 먼저 커스텀 레포지토리 인터페이스를 만든다. 이후 구현체를 만든다.
public interface ChallengeRepositoryCustom {
Page<Challenge> findBySearchTerm(String searchTerm, Pageable pageable);
}
public class ChallengeRepositoryCustomImpl implements ChallengeRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public Page<Challenge> findBySearchTerm(String searchTerm, Pageable pageable) {
String queryStr = "SELECT c.* FROM Challenge c WHERE MATCH(category, title, description) AGAINST (:searchTerm IN NATURAL LANGUAGE MODE)";
Query query = em.createNativeQuery(queryStr, Challenge.class);
query.setParameter("searchTerm", searchTerm);
// 페이징 처리
query.setFirstResult((int) pageable.getOffset());
query.setMaxResults(pageable.getPageSize());
List<Challenge> challenges = query.getResultList();
// 총 결과 수를 구하기 위한 쿼리 실행 (예시)
Query countQuery = em.createNativeQuery("SELECT COUNT(*) FROM Challenge c WHERE MATCH(category, title, description) AGAINST (:searchTerm IN NATURAL LANGUAGE MODE)");
countQuery.setParameter("searchTerm", searchTerm);
long total = (long) countQuery.getSingleResult();
return new PageImpl<>(challenges, pageable, total);
}
}
- 기존 ChallengeRepository 인터페이스에 커스텀한 인터페이스 연결
public interface ChallengeRepository extends JpaRepository<Challenge, Long>, ChallengeRepositoryCustom {
// 기존 메소드들...
}
1-3 ) 서비스 메소드 수정 : 기존의 getQueryChallenge 메소드에서 새로 구현한 findBySearchTerm 메소드를 사용하도록 수정.
public AllResponseDto getQueryChallenge(String query, int page, int size) {
try {
// Pageable 객체를 생성하여 페이징 처리
Pageable pageable = PageRequest.of(page, size);
// JPA Repository를 사용하여 페이징된 데이터를 가져옴
Page<Challenge> queryChallengesPage = challengeRepository.findBySearchTerm(query, pageable);
// 가져온 페이지에서 ChallengeResponseDto로 변환
List<ChallengeResponseDto> queryChallengeResponseDtoList = queryChallengesPage.getContent().stream()
.map(challenge -> createChallengeResponseDto(challenge))
.collect(Collectors.toList());
// 페이징된 데이터와 메시지를 포함한 응답 생성
Page<ChallengeResponseDto> challengeResponseDtos = new PageImpl<>(queryChallengeResponseDtoList, pageable, queryChallengesPage.getTotalElements());
return new AllResponseDto(challengeResponseDtos, "키워드로 챌린지 조회 성공", HttpStatus.OK);
} catch (Exception e) {
return new AllResponseDto(null, "키워드로 챌린지 조회 중 오류 발생: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
2. 인덱스를 활용한 LIKE 검색 최적화
- %query 또는 query%와 같이 검색어의 위치를 조정하여, 가능한 경우 인덱스를 활용할 수 있도록 함.
- %query 형태
LIKE '%apple*은 "pineapple", "green apple" 등 "apple"로 끝나는 모든 문자열을 반환됨. 문자열의 시작 부분이 지정되지 않기 때문에, 인덱스를 활용하지 못하고 풀 스캔해야 함. 성능 저하의 원인. - query% 형태
LIKE 'apple%'은 "apple", "applesauce", "apple pie" 등 "apple"로 시작하는 모든 문자열을 반환. 인덱스를 이용할 수 있기 때문에 빠르게 결과를 찾아낼 수 있음. 하지만 이룸은 해당하는 단어가 포함된 모든 챌린지를 찾아야 하기 때문에 %query% 형태를 사용해야 하고, 따라서 이렇게 검색 최적화를 하는 것은 적합하지 않음.
3. ElasticSearch, Apache Solr와 같은 검색 엔진 도입
- 복잡한 검색 요구사항을 충족하고, 대규모 데이터셋에서 빠르게 검색을 수행하고 싶다면 검색 엔진을 도입할 수 있음. 데이터의 인덱싱과 검색 과정을 효율적으로 처리함. 이룸 같은 경우, 챌린지 description 부분에 상당량의 텍스트가 삽입되며, 전문 검색 기능에서 좀 더 고도화한 검색 기능을 제공하고자 하므로 검색 엔진을 도입해보려 함.
- 네이버에서도 ElasticSearch를 사용하여 상품 검색 엔진을 개발(https://www.youtube.com/watch?v=fBfUr_8Pq8A)하였으며 kibana와 결합하여 모니터링에도 사용할 수 있으므로 ElasticSearch를 선택하였음. ElasticSearch 도입기는 다음 글에서 계속!
'이룸 프로젝트' 카테고리의 다른 글
[이룸] 도커Docker가 궁금해! - 가상 머신(VM) vs. 컨테이너(Container) (1) | 2024.04.10 |
---|---|
[이룸] 검색 기능 고도화 2 - 엘라스틱 서치란 무엇인가? + 설치하기 (0) | 2024.04.09 |
[이룸] 전역 에러 처리 리팩토링 (0) | 2024.03.27 |
[이룸] 채팅 내역 저장을 위한 db 선택 (0) | 2024.03.21 |
[이룸] Redis에는 어떻게 채팅과 리프레시 토큰이 저장되고 있을까? (1) | 2024.03.19 |