Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

[이룸] 검색 기능 고도화 1 - 현재 상황 점검 및 전문 검색 기능 도입 본문

이룸 프로젝트

[이룸] 검색 기능 고도화 1 - 현재 상황 점검 및 전문 검색 기능 도입

쿠마냥 2024. 4. 3. 12:42

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 도입기는 다음 글에서 계속!