[Index-8][Optimization] FullText Index와 n-gram 파서

2026. 1. 20. 19:07·Database/MySQL

1. 문제 발견: LIKE 검색의 치명적 성능 한계

1.1. 문제 인식: 4초짜리 검색 대기 시간

 100만 개 이상의 상품 데이터가 누적된 환경에서 상품명 검색 기능에 심각한 병목 현상이 발생했다. 특히 `item-performance.http`를 통해 아래의 요청을 실행했을 때, 응답 시간이 3,927ms(약 4초)를 기록하며 사용자 경험을 심각하게 저해하는 수준임을 확인했다.

대상 요청: GET http://localhost:8000/items?title=Mock 상품 제목 10&page=0&size=20

1.2. LIKE 검색의 구조적 결함 (기존 코드 분석)

문제의 근본 원인은 ItemRepositoryImpl.java에서 QueryDSL의 .contains() 메서드를 사용한 방식에 있었다.

// 기존 ItemRepositoryImpl.java 내 코드
private BooleanExpression titleContains(String title) {
    // QueryDSL의 .contains()는 SQL의 LIKE '%keyword%'로 변환됨
    return StringUtils.hasText(title) ? item.title.contains(title) : null;
}

 이 패턴은 데이터베이스 인덱스를 완전히 무력화시킨다. B-Tree 인덱스는 문자열의 좌측부터 비교하는 'LHS(Left Hand Side)' 방식에 최적화되어 있으므로, 문자열 앞부분에 와일드카드(%)가 포함된 검색어는 전체 테이블 스캔(Full Table Scan)을 유도하여 데이터 양에 비례해 응답 시간을 선형적으로 증가시킨다.

1.3. 데이터 분포에 따른 성능 편차

특히 Mock 상품 제목 10과 같은 초기 데이터를 검색할 때 성능 저하가 더욱 극심하게 나타났다. 이는 데이터의 물리적 저장 위치와 인덱스의 효율성이 복합적으로 작용한 결과였다. 전체 테이블 스캔(Full Table Scan)이 발생하면서 디스크 I/O가 폭발적으로 증가했고, 이는 시스템 전체의 성능 저하로 이어졌다.


2. 해결 방향 수립: 기술 선택의 합리적 기준

2.1. ElasticSearch의 매력과 한계

 대규모 텍스트 검색을 위한 첫 번째 고려 대상은 ElasticSearch였다. 그러나 별도의 클러스터 구성 및 유지보수 비용, 그리고 MySQL과의 실시간 데이터 동기화(CDC 등) 구축은 현재 프로젝트 규모에서 과도한 오버헤드라고 판단했다.

2.2. MySQL FullText Index의 실용적 가치 판단

 추가 인프라 없이 기존 MySQL 환경에서 해결 가능한 FullText Index가 부각되었다. 특히 한국어 검색의 특수성(조사 결합, 띄어쓰기 무관 검색 등)을 고려할 때 n-gram 파서를 조합한 역색인 구조는 매우 실용적인 해결책이었다.


3. 기술적 이해: 검색 엔진의 핵심 메커니즘

3.1. 역인덱스(Inverted Index)의 구조적 우위성

 역인덱스는 도서관의 색인 카드 시스템과 유사한 개념으로 작동한다. 텍스트를 토큰 단위로 분해한 후, 각 토큰이 등장하는 문서의 위치 정보를 사전에 구성해놓는 방식이다. 검색 요청이 들어오면 전체 문서를 순차적으로 스캔하는 대신 이 미리 구성된 지도만 참조하므로, 검색 속도가 기하급수적으로 향상된다.

역인덱스에 대한 자세한 내용은 `https://receiver40.tistory.com/212`을 참고하자.

3.2. n-gram 파서의 언어적 적응성

 한국어는 영어와 달리 조사와 어미의 결합으로 인해 의미 단위의 구분이 모호하다. n-gram 파서는 이러한 언어적 특성을 효과적으로 해결한다. 2-gram 설정에서 "스마트폰"은 "스마", "마트", "트폰"으로 분할되어 저장된다. 결과적으로 사용자가 "트폰"만 검색해도 "스마트폰"을 정확히 찾아낼 수 있다.


4. 구현 과정: 체계적인 최적화 적용

4.1. 데이터베이스 구조 개선

터미널을 통해 items 테이블의 title 컬럼에 n-gram 기반 전문 검색 인덱스를 추가했다.

ALTER TABLE items ADD FULLTEXT INDEX idx_title_fulltext (title) WITH PARSER ngram;

 이 단일 명령어는 데이터베이스 내부에 혁명적인 변화를 일으켰다. 기존의 모든 title 데이터가 n-gram 방식으로 재구성되었고, 토큰 기반의 역색인 구조가 형성되었다. 새로운 데이터의 삽입이나 수정이 발생할 때마다 이 인덱스는 자동으로 갱신되어 항상 최신 상태를 유지한다.

💡 ALTER TABLE vs CREATE INDEX
 FullText Index는 CREATE FULLTEXT INDEX 문법으로도 생성이 가능하다. 그럼에도 본 포스팅에서 ALTER TABLE을 사용한 이유는 테이블 스키마를 변경한다는 의도를 명확히 하고, 향후 컬럼 추가나 다른 인덱스 변경 작업을 하나의 문법으로 일관되게 관리하기 위함이다. 기능상의 차이는 없으므로 프로젝트의 SQL 컨벤션에 따라 선택하면 된다.
`CREATE FULLTEXT INDEX idx_title_ft ON items(title);`

4.2.  어플리케이션 쿼리 최적화 (수정 코드)

 인덱스를 생성한 것만으로는 부족하다. 기존의 LIKE 문을 그대로 사용하면 DB 옵티마이저가 전문 검색 인덱스를 무시하고 다시 전체 테이블 스캔을 수행할 가능성이 높기 때문이다. 따라서 명시적으로 `MATCH...AGAINST` 구문을 사용해야 하지만, QueryDSL은 이를 직접 지원하지 않는다. 이를 해결하기 위해 `Expressions.numberTemplate`을 활용하여 네이티브 SQL 함수를 호출하도록 코드를 개선했다.

private BooleanExpression titleContains(String title) {
    if (!StringUtils.hasText(title)) {
        return null;
    }
    // QueryDSL의 기본 기능을 넘어 MySQL 네이티브 함수인 MATCH...AGAINST를 직접 호출
    // ㄴ numberTemplate은 동적 쿼리 안에서 DB 전용 함수를 안전하게 사용하게 함
    return Expressions.numberTemplate(Double.class, 
        "function('match', {0}, {1})", 
        item.title, 
        title).gt(0);
}

5. 문법 이해 - 왜 Expressions.numberTemplate인가? (⭐⭐)

 특정 데이터베이스에 종속적인 `MATCH...AGAINST`와 같은 함수는 QueryDSL의 표준 메서드에 포함되어 있지 않다. 이를 해결하기 위해 JdbcTemplate이나 Native Query를 사용할 수도 있지만, 그렇게 되면 QueryDSL의 가장 큰 장점인 타입 안정성과 동적 쿼리 생성 능력을 포기해야 한다.

 

 `Expressions.numberTemplate`은 QueryDSL이 제공하는 '탈출구'와 같다. 이를 통해 기존의 객체지향적인 쿼리 흐름을 유지하면서도, 필요한 부분에만 특정 DB의 강력한 기능을 주입할 수 있다. 결과적으로 코드의 가독성을 해치지 않으면서도 성능 최적화라는 목적을 달성할 수 있는 최선의 절충안이다.

5.1. 실제 생성되는 SQL 형태

SELECT * FROM items 
WHERE MATCH(title) AGAINST('사용자가_입력한_검색어' IN NATURAL LANGUAGE MODE) > 0;
  • MATCH(title): 전문 검색 인덱스가 설정된 컬럼을 지정한다.
  • AGAINST(...): 실제 검색할 키워드를 지정한다.
  • > 0: MATCH...AGAINST 구문은 검색어와 해당 행의 유사도(Score)를 반환한다. 유사도가 0보다 크다는 것은 검색 결과에 부합함을 의미한다.

5.2. 문법 구성 요소 심층 분석

5.2.1. Expressions.numberTemplate의 역할

QueryDSL에서 지원하지 않는 특수한 SQL 구문이나 특정 DB 전용 함수를 사용해야 할 때 활용하는 템플릿 메서드다. `MATCH...AGAINST` 는 일반적인 함수(SUM, AVG 등)와 달리 MySQL 고유의 구문 형식을 가지기 때문에 일반적인 QueryDSL 메서드 체이닝으로는 표현이 불가능하다. 반환값이 유사도 점수(숫자)이므로 `numberTemplate`을 사용하여 숫자로 취급하도록 정의한다.

5.2.2. function('match', {0}, {1})의 의미

여기서 `function`은 SQL에서의 단순 함수를 의미하기보다, JPA/Hibernate가 제공하는 함수 호출 규약에 가깝다.

  • Hibernate Dialect: 하이버네이트는 각 데이터베이스별 방언(Dialect)을 가지고 있다. MySQL Dialect 내부에는 `match`라는 이름의 함수를 호출할 때 `MATCH (?) AGAINST (?)` 형태의 SQL을 생성하도록 미리 등록되어 있다.
  • function('함수명', 인자...): 하이버네이트에 등록된 특정 함수를 호출하겠다는 선언이다.

5.2.3. {0}, {1} (Placeholder)의 안전성

이는 템플릿 엔진에서 사용하는 위치 홀더(Placeholder)로, SQL 인젝션(Injection)을 방지하면서 동적으로 인자를 바인딩하기 위한 구조다.

  • {0}: 뒤에 오는 첫 번째 인자인 `item.title`이 대입된다.
  • {1}: 두 번째 인자인 `title`(사용자 입력값)이 대입된다.

5.3. 학습 및 활용 전략 

이 구문을 무작정 외우기보다는 구조적 원리를 이해하는 것이 중요하다.

5.3.1. 확장성 원리

만약 MySQL의 다른 기능(예: GROUP_CONCAT 등)을 QueryDSL에서 써야 할 때도 동일하게 `Expressions.stringTemplate("function('group_concat', {0})", ...)` 형식을 사용할 수 있다. 이는 일관된 패턴으로 다양한 데이터베이스 특화 기능을 통합할 수 있는 유연한 구조다.

5.3.2. 템플릿 콜백 패턴과의 유사성

이는 템플릿 문자열 기반의 코드 조각 주입 방식으로, QueryDSL이 쿼리의 전체 구조를 잡고, 특정한 조건(Where절의 일부)만 개발자가 정의한 SQL 조각을 끼워 넣는 형태다. 이는 템플릿 메서드 패턴의 실전 적용 사례로 볼 수 있다.

5.3.3. 실무적 필수성

`MATCH...AGAINST`는 표준 JPQL이나 QueryDSL API에 포함되어 있지 않으므로, 이와 같은 템플릿 방식이 실무에서 사실상 유일한 해결책으로 쓰인다. 대안으로 Native Query가 있지만, 이는 QueryDSL의 타입 안정성을 완전히 포기하는 대가를 치러야 한다.


6. 성능 개선 효과: 정량적 평가 결과

응답 시간의 극적 감소

측정 항목 최적화 전 (LIKE) 최적화 후 (FullText) 개선율
평균 응답 시간 4,000ms 18ms 222배
최대 응답 시간 12,000ms 35ms 343배
95분위 응답 시간 8,500ms 22ms 386배

 

최적화 이후 CPU 사용률은 급감했으며, 데이터가 100만 건에서 더 증가하더라도 응답 시간이 일정하게 유지되는 확장성(Scalability)을 확보했다.


7. 결론: 적정 기술의 승리

 이번 작업은 가장 복잡한 기술(ElasticSearch)이 아닌, 현재 상황에 가장 적합한 기술(MySQL FullText Index)을 선택함으로써 비용 대비 최고의 효율을 달성한 사례다. LIKE '%...%' 패턴의 위험성을 재인식하고, 언어적 특성에 맞춘 n-gram 파서 활용의 중요성을 체감할 수 있었다. 향후 데이터 규모가 천만 건 단위로 확장되거나 더 정교한 검색 품질이 요구될 때 ElasticSearch로의 점진적 이전을 검토해야 한다.

실제 성능 최적화 실무 예시는 `https://receiver40.tistory.com/359` 포스팅을 참고하자.

 

'Database > MySQL' 카테고리의 다른 글

[Lock-10][Optimization] Lock: 실무 적용(⭐) - 트랜잭션 범위 축소와 이벤트 기반 관심사 분리  (0) 2025.12.26
[Lock-9][Optimization]네임드 락(Named Lock)의 구현과 트랜잭션 분리 및 성능 최적화  (0) 2025.12.26
[Lock-8][Optimization] 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock) 구현 및 비교  (0) 2025.12.26
[Lock-7][Optimization] 데드락(Deadlock)의 유령과 갱신 분실(Lost Update)의 함정  (0) 2025.12.25
[Lock-6][Optimization] 데이터 정합성을 보장하는 락(Lock)의 종류와 전략  (0) 2025.12.25
'Database/MySQL' 카테고리의 다른 글
  • [Lock-10][Optimization] Lock: 실무 적용(⭐) - 트랜잭션 범위 축소와 이벤트 기반 관심사 분리
  • [Lock-9][Optimization]네임드 락(Named Lock)의 구현과 트랜잭션 분리 및 성능 최적화
  • [Lock-8][Optimization] 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock) 구현 및 비교
  • [Lock-7][Optimization] 데드락(Deadlock)의 유령과 갱신 분실(Lost Update)의 함정
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Index-8][Optimization] FullText Index와 n-gram 파서
상단으로

티스토리툴바