"인덱스를 바꿨는데 왜 더 느려졌을까?" - 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)
"하나로 안 되면 나눠서 하자!"
전략
- 각 컬럼별로 독립적으로 검색
- 결과를 애플리케이션에서 합치기
- 중복 제거
@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이 자동으로 계산해줌
전략
- 관련도 점수 높은 상위 1000개만 뽑기 (이미 정렬됨!)
- 그 1000개 중에서 발매일 순으로 재정렬
- 최종 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차 개선 결과

검색 시간: 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단계: 데이터 점검 (진짜 원인 발견!)
초보 개발자를 위한 실전 팁
꼭 기억하세요
- 인덱스가 만능은 아니다
- 데이터 특성에 따라 효과가 천차만별
- 실행 계획을 꼭 확인하자
- 테스트 데이터도 신경써야 한다
- 단순 반복 데이터 ❌
- 실제와 유사한 분포 ✅
- 성능 문제는 단계적으로 접근
- 한 번에 다 바꾸지 말기
- 하나씩 개선하면서 측정하기
실무에서 바로 쓸 수 있는 체크리스트
성능 최적화 전
- [ ] 실행 계획 확인했나? (EXPLAIN)
- [ ] 테스트 데이터가 실제와 비슷한가?
- [ ] 데이터 양이 운영 환경과 비슷한가?
- [ ] 인덱스 사용률을 확인했나?
성능 최적화 후
- [ ] 성능이 정말 개선됐나? (측정)
- [ ] 다른 쿼리에 영향은 없나?
- [ ] 메모리/CPU 사용량은 괜찮은가?
- [ ] 동시 사용자 테스트는 했나?
마치며
이번 여정을 통해 배운 가장 큰 교훈은
"기술도 중요하지만, 데이터를 이해하는 것이 더 중요하다"
처음에는 "더 좋은 인덱스를 쓰면 되겠지"라고 생각했어요. 하지만 진짜 문제는 데이터 자체였습니다.
여러분도 비슷한 상황이라면
- 실행 계획 확인하기 (EXPLAIN)
- 테스트 데이터 점검하기
- 실제 데이터 분포 확인하기
- 단계적으로 개선하기
더 공부하고 싶다면
관련 개념:
- Inverted Index (역색인)
- TF-IDF 알고리즘
- n-gram 토크나이저
- 데이터 카디널리티
- 인덱스 선택도
추천 실습:
- 작은 테이블로 FULLTEXT 연습하기
- EXPLAIN으로 실행 계획 분석하기
- 다양한 데이터로 성능 차이 확인하기
'트러블슈팅' 카테고리의 다른 글
| [Spring JPA] 음반 검색 기능 성능 최적화 트러블슈팅 - 초보자를 위한 완전 해부 1부 (0) | 2025.09.28 |
|---|