Spring

[Spring] Full-Text Index를 활용한 DB 검색 성능 최적화 (2) - Spring 코드 구현, 성능 비교 테스트

J_Dev 2024. 11. 6. 17:06

서론

  • 데이터베이스 검색 성능 최적화: JPA와 Native Query 비교
  • 데이터베이스의 검색 성능을 최적화하기 위해, 동일한 데이터를 반환하는 코드를 JPA와 Full Text Index를 활용한 Native Query 두 가지 방식으로 코드를 작성하고 성능을 비교해 보았습니다.
  • 각각의 코드에서 페이지네이션 및 검색 조건을 동일하게 적용하여, 실제 프로젝트 환경과 유사한 조건에서 성능을 평가했습니다.

1. 비교 테스트용 코드 작성

1). JPA 코드

  • JPA에서는 JPQL을 사용하여 소설 제목(Novel)과 관련 메타 데이터(NovelMetaData), 작성자(Member) 테이블을 JOIN하여 데이터를 조회합니다.
  • LIKE 와 와일드카드로(% %)로 검색어를 바탕으로 레코드를 조회하고, 필요한 값만 List<Object[]> 형식으로 추출하여 크기를 반환하도록 설계했습니다.
import com.ham.netnovel.novel.Novel;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface NovelRepository extends JpaRepository<Novel, Long> {

    @Query("select n.id," +
            "nm.totalViews, " +
            "m.providerId " +
            "from  Novel n " +
            "left join n.novelMetaData nm " +
            "join n.author m " +
            "where n.title like  %:searchWord%")
    List<Object[]> findNovelsBy(
            @Param("searchWord") String searchWord,
            Pageable pageable);

}
 @Transactional(readOnly = true)
    public Integer findBySearchJpa(String searchWord,Pageable pageable){

        try {
            List<Object[]> result = novelTestRepository.findNovelsBy(searchWord, pageable);

            return result.size();


        } catch (Exception ex) {
            throw new RepositoryMethodException("메서드 에러" + ex + ex.getMessage());
        }

    }

2). Navtive Qeury 코드

  • Native Query EntityManager를 사용해 SQL을 직접 실행하고, MySQL의 MATCH ... AGAINST 구문으로 Full Text Index를 활용했습니다. - Full Text Index를 사용하는 방식으로 JPA 보다 조회 성능을 향상시켰습니다.
  • JOIN 조건과 반환되는 레코드는 JPA 코드와 동일하게 유지하였습니다.
    @Transactional(readOnly = true)
    public Integer findBySearchWordNative(String searchWord, Pageable pageable) {

        int pageSize = pageable.getPageSize();// 한 페이지에 표시할 항목 수
        int pageNumber = pageable.getPageNumber();//페이지 번호 변수에 할당
        int offset = pageNumber * pageSize; // 오프셋 계산, 변수에 할당


        //Native 쿼리문 생성, MySQL 문법 사용
        String queryStr =
                "SELECT n.id as novelId, " +
                        "n.title as novelTitle," +
                        "nm.total_views as totalViews, " +
                        "m.provider_id as providerId " +
                        "FROM novel n " +
                        "LEFT JOIN novel_meta_data nm ON n.id = nm.novel_id " +
                        "JOIN member m ON m.id = n.member_id " +
                        "WHERE MATCH(n.title) AGAINST(:searchWord IN BOOLEAN MODE) " +//index 사용
                        "LIMIT :limit OFFSET :offset";//페이지네이션

        try {
            //네이티브 쿼리 객체 생성
            Query query = entityManager.createNativeQuery(queryStr);

            //매개변수화된 쿼리, 쿼리문에 파라미터 할당
            query.setParameter("searchWord", searchWord);
            query.setParameter("offset", offset);
            query.setParameter("limit", pageSize);

            //쿼리문 실행후 반환된 결과 객체 생성
            List<Object[]> resultList = query.getResultList();

            //결과 배열 Size 반환
            return resultList.size();
        } catch (Exception ex) {
            throw new RepositoryMethodException("메서드 에러" + ex + ex.getMessage());
        }

    }
  • 테스트를 위한 전체 class 코드는 다음과 같습니다.
import com.ham.netnovel.common.exception.RepositoryMethodException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Repository
public class NovelSearchTest {

    private final EntityManager entityManager;

    private final NovelTestRepository novelTestRepository;

    public NovelSearchTest(EntityManager entityManager, NovelTestRepository novelTestRepository) {
        this.entityManager = entityManager;
        this.novelTestRepository = novelTestRepository;
    }
    @Transactional(readOnly = true)
    public Integer findBySearchWordNative(String searchWord, Pageable pageable) {

        int pageSize = pageable.getPageSize();// 한 페이지에 표시할 항목 수
        int pageNumber = pageable.getPageNumber();//페이지 번호 변수에 할당
        int offset = pageNumber * pageSize; // 오프셋 계산, 변수에 할당


        //Native 쿼리문 생성, MySQL 문법 사용
        String queryStr =
                "SELECT n.id as novelId, " +
                        "n.title as novelTitle," +
                        "nm.total_views as totalViews, " +
                        "m.provider_id as providerId " +
                        "FROM novel n " +
                        "LEFT JOIN novel_meta_data nm ON n.id = nm.novel_id " +
                        "JOIN member m ON m.id = n.member_id " +
                        "WHERE MATCH(n.title) AGAINST(:searchWord IN BOOLEAN MODE) " +//index 사용
                        "LIMIT :limit OFFSET :offset";//페이지네이션

        try {
            //네이티브 쿼리 객체 생성
            Query query = entityManager.createNativeQuery(queryStr);

            //매개변수화된 쿼리, 쿼리문에 파라미터 할당
            query.setParameter("searchWord", searchWord);
            query.setParameter("offset", offset);
            query.setParameter("limit", pageSize);

            //쿼리문 실행후 반환된 결과 객체 생성
            List<Object[]> resultList = query.getResultList();

            //결과 배열 Size 반환
            return resultList.size();
        } catch (Exception ex) {
            throw new RepositoryMethodException("메서드 에러" + ex + ex.getMessage());
        }

    }
    @Transactional(readOnly = true)
    public Integer findBySearchJpa(String searchWord,Pageable pageable){

        try {
            List<Object[]> result = novelTestRepository.findNovelsBy(searchWord, pageable);

            return result.size();


        } catch (Exception ex) {
            throw new RepositoryMethodException("findBySearchWord 메서드 에러" + ex + ex.getMessage());
        }

    }
}

2. 성능 비교 테스트

1). 테스트 클래스 작성

  • 정확한 비교를 위해, 테스트는 동일한 검색어로 10,000번 반복 검색하여 평균 응답 시간 을 측정하였습니다.
  • 정확한 측정을 위해 나노초(ns) 단위로 측정하였으며, 밀리초(ms)로 변환하여 비교하였습니다.
  • 각 메서드가 반환한 레코드 수가 일치하는지 검증하여 정확성을 확보하였습니다.
  • Full Text Index에서 가장 많은 레코드에서 포함된(409개) 단어
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
class NovelTest {


    private final NovelSearchTest novelSearchTest;

    @Autowired
    NovelTest(NovelSearchTest novelSearchTest) {
        this.novelSearchTest = novelSearchTest;
    }


    @Test
    @Transactional
    public void testQueryPerformanceNative() {


        Pageable pageRequest = PageRequest.of(0, 1000);//페이지네이션 정보 설정

        long totalExecutionTime = 0;//실행시간 초기화
        int iterations = 10000;//반복횟수 설정
        for (int i = 0; i < iterations; i++) {
            long startTime = System.nanoTime();//시작시간, ns단위 설정
            Integer result = novelSearchTest.findBySearchWordNative("마음의소리", pageRequest);
            long endTime = System.nanoTime();// 완료시간, ns 단위 설정
            totalExecutionTime += (endTime - startTime);//실행시간 계산 ns 단위
            System.out.println(result);//찾아온 레코드 수 출력
        }
        System.out.println("Average execution time for Native Query: " + (totalExecutionTime / iterations) + " ns");


    }


    @Test
    @Transactional
    public void testQueryPerformanceJpa() {
        Pageable pageRequest = PageRequest.of(0, 1000);//페이지네이션 정보 설정

        long totalExecutionTime = 0;//실행시간 초기화
        int iterations = 10000;//반복횟수 설정
        for (int i = 0; i < iterations; i++) {
            long startTime = System.nanoTime();//시작시간, ns단위 설정
            Integer result = novelSearchTest.findBySearchJpa("마음의소리", pageRequest);
            long endTime = System.nanoTime();// 완료시간, ns 단위 설정
            totalExecutionTime += (endTime - startTime);//실행시간 계산 ns 단위
            System.out.println(result);//찾아온 레코드 수 출력
        }
        System.out.println("Average execution time for JPA Query: " + (totalExecutionTime / iterations) + " ns");
    }
}

2) 검색어 선정

  • 검색어는 다양한 조건과 상황에서 검색 성능을 평가할 수 있도록 여러 기준을 적용하여 선정했습니다. 아래는 각 검색어를 선정한 이유와 해당 단어의 특성입니다.
  1. 묘약
  • 정렬 기준: 성능 테스트에서는 검색 결과가 내림차순으로 정렬되므로, 내림차순에서 중간쯤인 'ㅁ'을 기준으로 선택했습니다.(영어 숫자 포함)
  • 단일 결과 레코드: Full Text Index 내에서 해당 단어를 포함한 레코드는 단 1개로, 단일 결과 레코드 를 대상으로 성능을 평가하는 데 적합합니다. 이를 통해 소수의 레코드가 포함된 경우, 인덱스가 성능에 미치는 영향을 테스트할 수 있었습니다.
  1. 마음의소리
  • 긴 문자열 테스트: 긴 문자열 검색의 성능을 평가하기 위해 선택했습니다.여러 단어로 구성된 검색어는 더 복잡한 쿼리 조건을 만들어내며, 검색 최적화 여부가 성능에 얼마나 영향을 미치는지 평가할 수 있습니다.
  1. 사랑
  • 대량 레코드 검색: Full Text Index에서 가장 많은 레코드(409개) 에서 포함된 단어로, 데이터베이스에서 대량의 결과를 반환하는 조건을 테스트하기에 적합합니다. 대량의 레코드를 대상으로 검색할 때 JPA와 Native Query 간 성능 차이를 비교하기 위한 용도로 사용했습니다.

3) 테스트 결과

  • 테스트 결과, 단일 레코드 검색 시 Native Query는 JPA보다 약 90% 이상 빠른 응답 시간을 기록했습니다.
  • 다수의 레코드 조회에서도 Native Query는 JPA보다 약 60% 더 빠르게 응답했습니다. 이는 MySQL Full Text Index를 통한 검색 최적화가 성능 향상에 큰 기여를 했기 때문입니다.

3. 결론

  • 이번 테스트를 통해, MySQL의 Full Text Index를 사용한 Native Query가 대용량 데이터베이스에서 JPA Query보다 훨씬 효율적임을 확인할 수 있었습니다.
  • 특히, 대량 데이터 조회나 복잡한 검색 조건이 필요한 경우에는 Native Query를 통한 최적화로 드라마틱한 성능 향상을 얻을수 있음을 알게되었씁니다.
  • 하지만 코드의 유지보수성과 Spring Data JPA의 이점을 고려할 때, PK, 숫자등을 이용한 검색, 기본적인 CRUD 작업에는 Spring Data JPA를 적용하는게 유리합니다.