Elasticsearch 없이 검색 만들기: MySQL FULLTEXT 인덱스와 ngram 파서


검색이 필요해졌다
커뮤니티를 운영하다 보면 글이 쌓인다. 블로그도 올라오고, 자유게시판도 돌아가고, Q&A도 쌓이고. 어느 순간 "예전에 누가 Spring Security 관련 글 올렸는데..." 하면서 스크롤을 한참 내려야 하는 상황이 된다.
헤더에 검색창 하나 달고, 키워드 넣으면 관련 글이 쭉 나오면 된다. 간단해 보인다.
그런데 "검색"이라는 단어가 나오면 개발자 머릿속에 자동으로 떠오르는 게 있다. Elasticsearch. 나도 처음엔 당연히 그쪽부터 알아봤다.
Elasticsearch, 쓰고 싶은데 비싸다
Elasticsearch는 좋은 도구다. 역색인 기반 전문 검색, 형태소 분석, 유사도 순위 정렬, 자동완성까지. 검색에 관해서는 거의 완벽하다.
문제는 돈이다.
Elastic Cloud 요금 (2026년 기준) ┌──────────────┬────────────────┬─────────────────────┐ │ 플랜 │ 월 비용 │ 포함 사항 │ ├──────────────┼────────────────┼─────────────────────┤ │ Standard │ $95~/월 │ 8GB RAM, 240GB 스토리지│ │ Gold │ $109~/월 │ + 비즈니스 지원 │ │ Platinum │ $125~/월 │ + ML, 고급 보안 │ │ Serverless │ 사용량 과금 │ 최소 $16.40~/월 │ └──────────────┴────────────────┴─────────────────────┘
Serverless 옵션이 생기면서 최소 비용은 내려갔지만, 한국어 검색을 제대로 하려면 nori 형태소 분석기 설정이 필요하고, 인덱스 관리도 해야 한다. MySQL에서 Elasticsearch로 데이터 동기화하는 파이프라인도 별도로 만들어야 하고.
개인이 운영하는 커뮤니티에 월 $95를 검색 인프라에? GCP Cloud SQL 비용만으로도 지갑이 아픈데, 솔직히 엄두가 안 났다.
GCP에도 검색 서비스가 있긴 하다
Google Cloud에 Vertex AI Search(구 Enterprise Search)라는 게 있다. 2023년에 정식 출시된 서비스로, 데이터를 넣으면 AI 기반으로 검색해준다.
Vertex AI Search 요금 ┌─────────────────┬─────────────────────────────┐ │ 항목 │ 비용 │ ├─────────────────┼─────────────────────────────┤ │ 쿼리 │ $2.50 / 1,000건 │ │ 무료 제공 │ 월 10,000건 │ │ 문서 저장 │ 문서 수에 따라 별도 │ │ 기타 │ 임베딩, 랭킹 등 추가 과금 │ └─────────────────┴─────────────────────────────┘
월 10,000건 무료라면 소규모 사이트에는 충분해 보인다. 근데 실제로 쓰려고 하니까 걸리는 게 있었다.
검색을 위해 별도 서비스에 데이터를 넣고, API를 호출하고, 결과를 다시 우리 DB와 매핑해야 한다. 글이 하나 올라올 때마다 Vertex AI Search에도 동기화해야 하는데, 이 파이프라인을 만들고 관리하는 게 제법 일이다. 거기다 외부 API를 한 번 더 거치니까 응답 시간이 늘어난다. 소규모 사이트에서는 MySQL 직접 쿼리가 오히려 빠를 수 있다.
무료 10,000건도 생각보다 넉넉하지 않다. 검색봇이 돌거나, 프리페치가 걸리거나, 자동완성을 붙이면 금방 넘긴다. 그때부터는 1,000건당 $2.50.
Vertex AI Search는 시맨틱 검색이 필요하거나, 검색이 서비스의 핵심인 경우에 맞는 도구다. 커뮤니티 글 검색 정도에는 과하다.
MySQL에 이미 검색 엔진이 들어 있다
사실 MySQL 5.6부터 InnoDB에서 FULLTEXT INDEX를 쓸 수 있다. 그리고 5.7.6부터는 ngram 파서가 내장되어 있다. 이걸 모르고 한참 고민했다.
한국어, 중국어, 일본어 같은 언어는 영어처럼 단어 사이에 공백이 명확하지 않다. "데이터베이스설계"라는 문자열에서 "데이터"와 "설계"를 찾으려면 단순 공백 분리로는 안 된다. ngram 파서가 이걸 해결해준다.
ngram 파서의 동작 원리
ngram은 텍스트를 N글자씩 잘라서 토큰을 만드는 방식이다. MySQL의 기본 ngram 크기는 2(bigram).
원본: "스프링부트" bigram 토큰화: "스프" "프링" "링부" "부트" 검색어 "스프링"이 들어오면: "스프" "프링" → 두 토큰 모두 매칭 → 히트! 검색어 "부트캠프"가 들어오면: "부트" "트캠" "캠프" → "부트"만 매칭 → 부분 히트
영어의 형태소 분석기와 달리, ngram은 언어 구조를 이해하지 않는다. 그냥 기계적으로 N글자씩 자를 뿐이다. 그래서 가끔 엉뚱한 결과가 나오기도 하는데, 별도 형태소 분석기 없이 한국어 검색이 된다는 게 핵심이다. 추가 인프라 비용이 0원이라는 점도 크다.
FULLTEXT INDEX 생성
ALTER TABLE unified_post ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram;
이 한 줄이면 된다. 기존 테이블에 인덱스만 추가하면 되니까, 별도 서비스를 띄우거나 데이터를 복제할 필요가 없다.
인덱스 생성에 걸리는 시간은 데이터 양에 따라 다르다. 수만 건 정도면 몇 초, 수십만 건이면 수 분 정도. 테이블에 LOCK이 걸리므로 운영 중이라면 트래픽이 적은 시간에 돌리는 게 좋다.
Boolean Mode AND 검색
FULLTEXT INDEX를 만들었으면 MATCH ... AGAINST 구문으로 검색한다.
-- "스프링"과 "시큐리티"가 모두 포함된 글 찾기 SELECT id, title FROM unified_post WHERE MATCH(title, content) AGAINST('+스프링 +시큐리티' IN BOOLEAN MODE) ORDER BY created_at DESC;
Boolean Mode에서 +는 "반드시 포함"을 뜻한다. +스프링 +시큐리티면 두 키워드가 모두 있어야 하니까 AND 검색이 된다.
Boolean Mode 연산자 ┌──────┬─────────────────────────────────┐ │ 연산 │ 의미 │ ├──────┼─────────────────────────────────┤ │ + │ 반드시 포함 (AND) │ │ - │ 반드시 제외 (NOT) │ │ 없음 │ 있으면 가산점 (OR) │ │ * │ 와일드카드 (접두사 검색) │ │ "" │ 정확한 구문 일치 │ │ > │ 관련도 가중치 증가 │ │ < │ 관련도 가중치 감소 │ └──────┴─────────────────────────────────┘
Natural Language Mode도 있지만, AND/OR/NOT을 직접 제어할 수 있는 Boolean Mode가 사용자 검색에는 더 맞다.
실제 구현: 코드 수준의 이야기
검색 쿼리 구조
검색 API의 전체 흐름은 이렇게 생겼다.
사용자 입력: "claude code agent" ↓ 키워드 분리: ["claude", "code", "agent"] ↓ FULLTEXT 쿼리: MATCH AGAINST('+claude +code +agent' IN BOOLEAN MODE) ↓ 결과 없으면? → LIKE 폴백: title LIKE '%claude%' AND LIKE '%code%' AND ... ↓ Entity 로딩 (JOIN FETCH) → DTO 변환 → 캐시 저장 → 응답
FULLTEXT에서 LIKE로 폴백하는 이유가 있다. ngram 토큰 크기(기본 2)보다 짧은 1글자 키워드는 FULLTEXT로 못 찾는다. 데이터가 적은 초기에는 FULLTEXT 인덱스가 빈 결과를 줄 수도 있고. 그래서 "FULLTEXT가 빈 손으로 돌아오면 LIKE로 한 번 더 시도"하는 안전장치를 넣어뒀다.
// 검색어를 Boolean Mode 표현식으로 변환 String fulltextExpr = keywords.stream() .map(k -> "+" + k) .collect(Collectors.joining(" ")); // "claude code agent" → "+claude +code +agent"
2단계 쿼리: ID 먼저, Entity 나중에
고민이 하나 있었다. Native SQL로 FULLTEXT 검색을 하면 ID 목록만 나온다. 그런데 응답에는 게시판 이름, Q&A 확장 정보 같은 것도 필요하다. JPA Entity를 JOIN FETCH로 가져와야 한다.
Step 1: Native SQL → ID 목록 (FULLTEXT 검색) Step 2: JPQL + JOIN FETCH → Entity 로딩 (ID IN :postIds)
FULLTEXT INDEX는 Native SQL에서만 쓸 수 있고, JOIN FETCH는 JPQL에서만 된다. 각자 잘하는 걸 맡긴 셈이다.
성능: 2.3초가 나왔을 때
처음 검색을 구현하고 돌려봤더니 응답 시간이 2.3초였다. 로컬에서 GCP Cloud SQL까지 네트워크를 타는 환경이긴 하지만, 검색에 2초는 좀 아니다.
프로파일링해보니 LIKE 쿼리 자체는 0.097초. 나머지 2초는 어디서 나온 거지?
범인은 DTO 변환 과정의 추가 DB 쿼리 3개였다.
검색 결과 → DTO 변환 시 추가 쿼리: 1. 썸네일 이미지 → unified_file 테이블 조회 (0.5초) 2. 최신 댓글 시간 → unified_comment 테이블 조회 (0.5초) 3. 작성자 레벨 → user 테이블 조회 (0.3초)
피드에서는 이 정보들이 필요하지만, 검색 결과에서 "새 댓글" 뱃지나 작성자 레벨이 꼭 있어야 할까? 없어도 된다. 그래서 검색 전용 경량 변환 메서드를 따로 만들었다.
// 기존: convertToFeedItemsWithExtensions → 추가 쿼리 3개 // 검색용: convertToFeedItemsLight → 추가 쿼리 0개 // - 썸네일: 파일 테이블 대신 본문에서 첫 이미지 URL 추출 // - hasNewComment: false 고정 (댓글 테이블 쿼리 생략) // - authorLevel: null (유저 테이블 쿼리 생략)
여기에 Caffeine 캐시를 얹었다. 같은 검색어로 반복 요청이 오면 DB를 안 탄다.
최적화 결과 ┌────────────────┬─────────┬──────────┐ │ 상태 │ 응답시간 │ DB 쿼리 │ ├────────────────┼─────────┼──────────┤ │ 최적화 전 │ 2.3초 │ 7+개 │ │ 최적화 후 (콜드)│ 0.6~1초 │ 3개 │ │ 캐시 히트 │ 0.004초 │ 0개 │ └────────────────┴─────────┴──────────┘
캐시 TTL은 30초. 검색 결과가 실시간일 필요는 없고, 30초 정도 지연은 아무도 신경 안 쓴다.
언제 갈아타야 하나
MySQL FULLTEXT가 만능은 아니다. 한계가 분명하다.
MySQL FULLTEXT가 괜찮은 경우 ┌──────────────────────────────────────┐ │ - 데이터가 수십만 건 이하 │ │ - 검색이 서비스의 보조 기능 │ │ - 비용을 최소화해야 함 │ │ - 이미 MySQL을 쓰고 있음 │ │ - 형태소 분석 수준의 정교함이 불필요 │ └──────────────────────────────────────┘ Elasticsearch가 필요한 경우 ┌──────────────────────────────────────┐ │ - 데이터가 수백만 건 이상 │ │ - 자동완성, 오타 교정, 유사어 검색 │ │ - 검색 결과 관련도 순위가 중요 │ │ - 형태소 분석 기반 정교한 한국어 검색 │ │ - 검색 트래픽이 높음 (초당 수백 건+) │ │ - 로그 분석, 실시간 집계 등 부가 기능 │ └──────────────────────────────────────┘
내가 생각하는 전환 시점은 세 가지다.
"검색해도 안 나와요"라는 피드백이 반복되면 ngram의 한계에 부딪힌 거다. 이때 형태소 분석기(nori)가 필요하다. 자동완성이나 "이 글과 비슷한 글" 같은 걸 넣어야 한다면 MySQL로는 어렵다. 그리고 데이터가 50만 건을 넘기면 FULLTEXT INDEX도 느려지기 시작한다. 이중 하나라도 해당되면 Elasticsearch를 고민할 때다.
지금은 글이 수천 건이고, 검색은 보조 기능이다. 아직 그때가 아니다.
ngram 파서의 함정들
실제로 쓰면서 알게 된 것들이 몇 가지 있다.
토큰 크기(ngram_token_size) 설정
기본값은 2다. 이 값을 바꾸면 인덱스를 다시 만들어야 한다.
ngram_token_size = 1 → 모든 글자 매칭 → 인덱스 거대, 노이즈 많음 ngram_token_size = 2 → 적당한 균형 (기본값, 권장) ngram_token_size = 3 → 짧은 단어 검색 불가, 정확도 높음
2가 무난하다. 1로 내리면 "자"를 검색했을 때 "자바", "자동", "자유" 전부 나온다. 써보면 안다.
최소 검색어 길이
innodb_ft_min_token_size의 기본값은 3인데, ngram 파서를 쓰면 ngram_token_size가 우선한다. ngram_token_size=2이면 2글자부터 검색이 된다. 1글자는 노이즈가 심하니까 서비스 레벨에서 "2글자 이상" 제한을 거는 게 낫다.
stopword 처리
MySQL FULLTEXT에는 기본 stopword 목록이 있다. 영어에서 "the", "is", "at" 같은 단어를 무시하는 건데, ngram 모드에서는 이게 의도치 않은 결과를 만들 수 있다. 한국어 텍스트에 영어가 섞여 있을 때 (기술 블로그에서는 흔한 일이다) 주의가 필요하다.
-- stopword 목록 확인 SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD;
마무리
검색 구현의 선택지를 한 줄로 정리하면 이렇다.
비용: MySQL FULLTEXT < Vertex AI Search << Elasticsearch 정교함: MySQL FULLTEXT << Vertex AI Search < Elasticsearch 구현 난이도: MySQL FULLTEXT < Elasticsearch << Vertex AI Search 운영 부담: MySQL FULLTEXT <<< Elasticsearch < Vertex AI Search
이미 MySQL을 쓰고 있고, 검색이 보조 기능이고, 비용을 아끼고 싶으면 FULLTEXT 인덱스부터 시작하면 된다. 인덱스 하나 만들고 MATCH AGAINST 쿼리를 쓰면 끝이다.
"나중에 필요할 것 같으니까 미리 Elasticsearch를 세팅해두자"라는 생각이 들 수 있다. 나도 잠깐 그랬다. 근데 쓰지 않는 인프라에 월 $95를 내는 건 아무리 생각해도 아깝다. 데이터가 MySQL에 있으니까 나중에 Elasticsearch로 옮기는 건 어렵지 않고, 그 시점이면 검색 요구사항도 훨씬 구체적으로 정리되어 있을 거다. 미리 고민할 필요 없다.






댓글
댓글을 작성하려면 이 필요합니다.