본문 바로가기
트러블슈팅

[Spring JPA] 음반 검색 성능 최적화 2편 - FULLTEXT 인덱스의 진실과 데이터의 배신

by Rapil 2025. 10. 1.

"인덱스를 바꿨는데 왜 더 느려졌을까?" - 10초 → 50ms 성능 개선의 숨겨진 진실

1편 보러가기 - [Spring JPA] 음반 검색 기능 성능 최적화 트러블슈팅 - 초보자를 위한 완전 해부 1부


지난 이야기

1편에서 우리는 충격적인 사실을 발견했습니다.

  • LIKE '%검색어%' 패턴은 인덱스를 무력화시킨다
  • B-Tree 인덱스는 부분 문자열 검색에 부적합하다
  • OR 조건이 여러 개면 옵티마이저가 혼란스러워한다

현재 상황

  • 검색 시간: 10초 😱
  • 인덱스를 걸었지만 안 탐
  • 사용자 이탈 중...

자, 이제 어떻게 해결했는지 본격적으로 파헤쳐볼까요?


새로운 희망 - FULLTEXT 인덱스

"텍스트 검색 전용 인덱스가 있다고?"

네, 맞아요! MySQL에는 텍스트 검색에 특화된 FULLTEXT 인덱스라는 게 있어요.

 

B-Tree vs FULLTEXT - 뭐가 다를까?

  B-Tree 인덱스 (도서관 책꽂이 방식)
- 책을 가나다순으로 정렬해서 꽂아놓음
- "비틀즈"로 시작하는 책은 찾기 쉬움
- 근데 "중간에 비틀즈가 들어간 책"은? → 전부 뒤져야 함

  FULLTEXT 인덱스 (색인 카드 방식)
- 모든 단어를 카드로 만들어서 보관
- "비틀즈" 카드를 찾으면 → 그 단어가 들어간 모든 책을 바로 알 수 있음!

실제로 어떻게 작동할까요?

역색인(Inverted Index)의 마법

일반 데이터:
Row 1: "Abbey Road"
Row 2: "Let It Be"
Row 3: "Revolver"

↓ FULLTEXT 인덱스로 변환하면

역색인:
"Abbey" → [Row 1]
"Road" → [Row 1]
"Let" → [Row 2]
"It" → [Row 2]
"Be" → [Row 2]
"Revolver" → [Row 3]

 

"Let"을 찾으려면? 역색인에서 "Let" → [Row 2]를 바로 가져오면 끝!


FULLTEXT 인덱스 적용해보기

Step 1 - 인덱스 생성

-- 기존 B-Tree 인덱스는 이제 안녕~
DROP INDEX idx_album_title ON albums;
DROP INDEX idx_album_artist ON albums;
DROP INDEX idx_album_isbn ON albums;

-- FULLTEXT 인덱스 생성!
ALTER TABLE albums ADD FULLTEXT(title, artist, isbn);

 

Step 2 - Spring Data JPA 코드 작성

FULLTEXT 검색은 네이티브 쿼리를 사용해야 해요.

@Repository
public interface AlbumRepository extends JpaRepository<Album, Long> {

    @Query(value = "SELECT * FROM albums " +
                   "WHERE MATCH(title, artist, isbn) " +
                   "AGAINST(:keyword IN NATURAL LANGUAGE MODE) " +
                   "ORDER BY release_date DESC " +
                   "LIMIT :offset, :size",
           nativeQuery = true)
    List<Album> searchWithFulltext(
        @Param("keyword") String keyword,
        @Param("offset") int offset,
        @Param("size") int size
    );
}

 

 초보자를 위한 설명

  • MATCH(컬럼들): 어떤 컬럼에서 검색할지
  • AGAINST('검색어'): 무엇을 검색할지
  • IN NATURAL LANGUAGE MODE: 자연어 검색 모드 (일반적인 검색)

Step 3: 설레는 마음으로 테스트...

EXPLAIN SELECT * FROM albums
WHERE MATCH(title, artist, isbn) AGAINST('비틀즈')
ORDER BY release_date DESC
LIMIT 20;

 

결과는...?

type ALL 😱
rows 100,000 😱
Extra Using filesort 😱

 

뭐라고요? 여전히 Full Table Scan이라고요?! 왜...? 왜??

 


첫 번째 좌절 - 인덱스를 안 타네요?

왜 이런 일이?

FULLTEXT 인덱스를 만들었는데도 MySQL이 안 쓰는 이유

 

복합 FULLTEXT 인덱스의 함정

-- 이렇게 만들면
FULLTEXT(title, artist, isbn)

-- MySQL은 이렇게 이해해요:
"모든 컬럼을 하나의 긴 텍스트로 합쳐서 검색"
= "Abbey Road | Beatles | ISBN123"

-- 문제는?
- 각 컬럼의 특성을 살리지 못함
- 선택도가 낮아서 "차라리 전체 스캔이 낫겠다" 판단

해결책 - 분할 정복(Divide and Conquer)

"하나로 안 되면 나눠서 하자!"

 

전략

  1. 각 컬럼별로 독립적으로 검색
  2. 결과를 애플리케이션에서 합치기
  3. 중복 제거
@Repository
public interface AlbumRepository extends JpaRepository<Album, Long> {

    // 제목에서 검색
    @Query(value = "SELECT * FROM albums " +
                   "WHERE MATCH(title) AGAINST(:keyword) " +
                   "LIMIT :size",
           nativeQuery = true)
    List<Album> searchByTitle(@Param("keyword") String keyword, 
                              @Param("size") int size);

    // 아티스트에서 검색  
    @Query(value = "SELECT * FROM albums " +
                   "WHERE MATCH(artist) AGAINST(:keyword) " +
                   "LIMIT :size",
           nativeQuery = true)
    List<Album> searchByArtist(@Param("keyword") String keyword,
                               @Param("size") int size);

    // ISBN에서 검색
    @Query(value = "SELECT * FROM albums " +
                   "WHERE MATCH(isbn) AGAINST(:keyword) " +
                   "LIMIT :size",
           nativeQuery = true)
    List<Album> searchByIsbn(@Param("keyword") String keyword,
                            @Param("size") int size);
}
@Service
public class AlbumService {

    public List<AlbumDto> searchAlbums(String keyword, int maxResults) {
        // 중복 없이 저장하기 위한 Set (순서 유지됨)
        Set<Album> uniqueAlbums = new LinkedHashSet<>();

        // 각각 검색해서 합치기
        albumRepository.searchByTitle(keyword, maxResults)
            .forEach(uniqueAlbums::add);
            
        albumRepository.searchByArtist(keyword, maxResults)
            .forEach(uniqueAlbums::add);
            
        albumRepository.searchByIsbn(keyword, maxResults)
            .forEach(uniqueAlbums::add);

        // DTO로 변환해서 리턴
        return uniqueAlbums.stream()
            .limit(maxResults)
            .map(this::convertToDto)
            .collect(Collectors.toList());
    }
}

1차 개선 결과

검색 시간: 10초 → 5.2초 (50% 개선!)

 

축하합니다! 드디어 인덱스를 타기 시작했어요!

하지만... 여전히 5초는 너무 느려요. 더 빠르게 할 수 있을까요?

 


두 번째 최적화 - 관련도 점수 활용하기

문제 - 정렬 때문에 느리다

아직도 Using filesort가 발생하고 있어요. 100,000건을 정렬하려니 느릴 수밖에...

아이디어 - "꼭 전부 정렬해야 할까?"

FULLTEXT 검색의 숨겨진 기능 - 관련도 점수(Relevance Score)

 

관련도 점수란?

  • "이 문서가 검색어와 얼마나 관련있는가"를 숫자로 표현
  • 점수가 높을수록 더 관련성이 높음
  • MySQL이 자동으로 계산해줌

 

전략

  1. 관련도 점수 높은 상위 1000개만 뽑기 (이미 정렬됨!)
  2. 그 1000개 중에서 발매일 순으로 재정렬
  3. 최종 20개만 반환
@Query(value = 
    "SELECT * FROM (" +
    "  SELECT *, " +
    "         MATCH(title) AGAINST(:keyword) as relevance " +
    "  FROM albums " +
    "  WHERE MATCH(title) AGAINST(:keyword) " +
    "  ORDER BY relevance DESC " +  // 관련도 순으로 먼저!
    "  LIMIT 1000 " +                 // 상위 1000개만
    ") AS relevant_albums " +
    "ORDER BY release_date DESC " +  // 그 중에서 최신순
    "LIMIT :size",
    nativeQuery = true)
List<Album> searchByTitleOptimized(
    @Param("keyword") String keyword,
    @Param("size") int size
);

 

왜 이게 빠를까요?

 

전체 정렬 vs 2단계 정렬

❌ 전체 정렬 (느림)
100,000건 전체를 발매일순 정렬 → 20개 추출

✅ 2단계 정렬 (빠름)
100,000건 중 관련도 높은 1000개 추출 (FULLTEXT 인덱스 활용)
→ 1000개만 발매일순 정렬 → 20개 추출

 

2차 개선 결과

2차 개선 후 쿼리 속도

검색 시간: 5초 → 4.4초 (14% 개선)

좋아요! 계속 빨라지고 있어요. 하지만 아직도 4초는 느려요... 😢

 


충격적인 발견 - 진짜 문제는 데이터였다!

뭔가 이상하다는 느낌

여러 최적화를 했는데도 4초대... 뭔가 근본적인 문제가 있는 것 같았어요.

그때 문득 이런 생각이 들었어요  "우리 테스트 데이터... 제대로 만든 거 맞나?"

테스트 데이터 확인하기

// 우리가 만든 Mock 데이터 생성 코드
for (int i = 0; i < 100000; i++) {
    Album album = new Album();
    album.setTitle("Sample Album Title " + i);
    album.setArtist("Sample Artist Name " + i);
    album.setIsbn("Sample-ISBN-" + i);
    // ...
}

 

문제 발견!

모든 데이터가 "Sample"로 시작하고 있었어요!

이게 왜 문제일까요?

n-gram 파서의 작동 원리

MySQL FULLTEXT는 텍스트를 작은 조각(n-gram)으로 나눠서 저장해요.

 

예시 - "Sample Album"을 2-gram으로 나누면

"Sample Album"
↓
["Sa", "am", "mp", "pl", "le", "e ", " A", "Al", "lb", "bu", "um"]

 

문제의 핵심

우리 데이터에서:
- "Sa" → 100,000개 문서 모두에 존재! 😱
- "am" → 100,000개 문서 모두에 존재! 😱
- "mp" → 100,000개 문서 모두에 존재! 😱

MySQL의 생각:
"이 토큰들로는 구별이 안 되네? 
 그냥 전체 스캔하는 게 낫겠다..."

 

이해하기 쉽게 비유하자면

좋은 인덱스 = 주민등록번호

  • 각자 고유한 번호 → 빠르게 찾기 가능

나쁜 인덱스 = 성별

  • 남/여 두 가지뿐 → 전체의 50%를 확인해야 함
  • 차라리 전체를 보는 게 나음

우리 데이터는 모든 값이 "Sample"로 시작 = 인덱스 무용지물!


최종 해결 - 현실적인 데이터로!

데이터 다시 만들기

// 개선된 Mock 데이터 생성
String[] prefixes = {
    "Classic", "Rock", "Pop", "Jazz", 
    "Blues", "Electronic", "Folk", "Metal"
};

String[] artists = {
    "Beatles", "Mozart", "Miles Davis", 
    "Radiohead", "Bob Dylan", "Pink Floyd"
};

for (int i = 0; i < 1000; i++) {
    Album album = new Album();
    // 다양한 접두사 사용!
    album.setTitle(prefixes[i % prefixes.length] + " Album " + i);
    album.setArtist(artists[i % artists.length] + " Vol." + i);
    album.setIsbn("ISBN-" + (1000000 + i));
    // ...
}

변경 사항:

  • 데이터 개수: 100,000건 → 1,000건 (현실적인 규모)
  • 데이터 다양성: "Sample" 하나 → 여러 접두사
  • 실제와 유사: 다양한 장르와 아티스트

최종 결과 발표!

┌─────────────────────────────────────────┐
│  검색 시간: 4.3초 → 50ms                │
│  개선율: 86배 빠름! (99.5% 개선) 🎉    │
└─────────────────────────────────────────┘

 

실행 계획도 완벽!

type fulltext ✅ 인덱스 사용!
rows 45 ✅ 필요한 것만!
Extra - ✅ Filesort 없음!

 


전체 여정 총정리

 

개선 단계별 성능 변화

 

😱 초기 LIKE '%검색어%' 10초 -
😊 1차 FULLTEXT + OR 분리 5초 50%
😀 2차 관련도 점수 활용 4.3초 14%
🎉 최종 데이터 특성 개선 50ms 99.5%

 

핵심 교훈

 

1. 인덱스만으로는 부족하다

인덱스 = 도구
데이터 = 재료

좋은 도구 + 나쁜 재료 = 나쁜 결과
좋은 도구 + 좋은 재료 = 좋은 결과!

 

2. 테스트 데이터의 중요성

 

❌ 나쁜 테스트 데이터

"Sample 1", "Sample 2", "Sample 3" ...
// 실제 운영과 전혀 다름!

 

✅ 좋은 테스트 데이터

"Rock Album 1", "Jazz Collection", "Pop Hits" ...
// 실제 운영과 비슷한 분포

 

3. 단계적 접근의 중요성

1단계: 문제 발견 (LIKE 검색 느림)
2단계: 인덱스 변경 (FULLTEXT)
3단계: 쿼리 최적화 (OR 분리, 관련도 활용)
4단계: 데이터 점검 (진짜 원인 발견!)

 

 


초보 개발자를 위한 실전 팁

꼭 기억하세요

  1. 인덱스가 만능은 아니다
    • 데이터 특성에 따라 효과가 천차만별
    • 실행 계획을 꼭 확인하자
  2. 테스트 데이터도 신경써야 한다
    • 단순 반복 데이터 ❌
    • 실제와 유사한 분포 ✅
  3. 성능 문제는 단계적으로 접근
    • 한 번에 다 바꾸지 말기
    • 하나씩 개선하면서 측정하기

실무에서 바로 쓸 수 있는 체크리스트

성능 최적화 전

  • [ ] 실행 계획 확인했나? (EXPLAIN)
  • [ ] 테스트 데이터가 실제와 비슷한가?
  • [ ] 데이터 양이 운영 환경과 비슷한가?
  • [ ] 인덱스 사용률을 확인했나?

성능 최적화 후

  • [ ] 성능이 정말 개선됐나? (측정)
  • [ ] 다른 쿼리에 영향은 없나?
  • [ ] 메모리/CPU 사용량은 괜찮은가?
  • [ ] 동시 사용자 테스트는 했나?

마치며

이번 여정을 통해 배운 가장 큰 교훈은

 

"기술도 중요하지만, 데이터를 이해하는 것이 더 중요하다"

 

처음에는 "더 좋은 인덱스를 쓰면 되겠지"라고 생각했어요. 하지만 진짜 문제는 데이터 자체였습니다.

 

여러분도 비슷한 상황이라면

  1. 실행 계획 확인하기 (EXPLAIN)
  2. 테스트 데이터 점검하기
  3. 실제 데이터 분포 확인하기
  4. 단계적으로 개선하기

 

더 공부하고 싶다면

관련 개념:

  • Inverted Index (역색인)
  • TF-IDF 알고리즘
  • n-gram 토크나이저
  • 데이터 카디널리티
  • 인덱스 선택도

추천 실습:

  1. 작은 테이블로 FULLTEXT 연습하기
  2. EXPLAIN으로 실행 계획 분석하기
  3. 다양한 데이터로 성능 차이 확인하기