블로그 서버를 만들고 있었고, 검색 기능도 함께 붙이고 있었다.
데이터가 적을 때는 검색이 잘 됬는데, 게시글을 늘려보니까 문제가 생겼다.
초기에는 DB에서 바로 검색했었다.
SELECT *
FROM post
WHERE title LIKE CONCAT('%', :keyword, '%')
OR content LIKE CONCAT('%', :keyword, '%')
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset;
데이터가 커질수록 검색 응답시간이 급격히 늘어났고, 40초 이상 걸리는 구간도 나왔다.

GET /api/v1/search/posts?...LIKE '%keyword%'ORDER BY created_at DESC LIMIT/OFFSET데이터가 적었을때는 병목이 잘 보이지 않았다. 그래서 데이터 규모를 많이 크게 해서 확인해보았다.
이 글은 위 조건에서 검색 병목을 관측하고, 여러가지 시도를 해보며 개선을 한 과정을 정리했다.
같은 조건에서 검색 방식을 바꿨을 때 숫자로 비교했다.
검색 API (초기: MySQL 검색, 이후: ES 검색 API)
키워드 분포: 한글/영문 혼합
페이징 분포: offset=0, 1~200, 201~1000, 1001+
Locust
failure rate(실패율)
p95 / p99 응답시간
RPS 유지 여부
timeout 발생 여부
DB(MySQL)
EXPLAIN / EXPLAIN ANALYZE 결과
rows examined
filesort 여부
performance_schema / 상태 변수
서버/앱
access log 응답시간
HikariCP active / idle / pending
Tomcat thread / connection 메트릭
LIKE '%keyword%'쿼리를 확인했다.
MySQL 공식문서의 B-Tree 인덱스 특성 설명에 따르면, LIKE 비교에서 인덱스는 상수 문자열이며 선행 와일드카드가 없는 경우에 사용할 수 있다. 반대로 LIKE '%Patrick%'처럼 선행 와일드카드가 있는 경우는 인덱스를 사용하지 않는 예시로 제시된다.
관련 문서: https://dev.mysql.com/doc/refman/8.4/en/index-btree-hash.html
그런데 %keyword%는 앞에 와일드카드가 있어서 시작점을 모른다. 그래서 B-Tree인덱스로는 범위 탐색에 불리하다.
데이터가 커질수록 선형적으로 비싸지는 구조라고 생각했다.
EXPLAIN
SELECT
p.post_id,
p.title,
p.created_at
FROM post p
WHERE p.post_status = 'PUBLISHED'
AND p.deleted_at IS NULL
AND p.title LIKE CONCAT('%', '검색', '%')
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 1000;

EXPLAIN ANALYZE
SELECT p.post_id, p.title, p.created_at
FROM post p
WHERE p.post_status = 'PUBLISHED'
AND p.deleted_at IS NULL
AND p.title LIKE CONCAT('%', '검색', '%')
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 1000;
-> Limit: 20 row(s) (cost=1.21e+6 rows=20) (actual time=6763..6763 rows=20 loops=1)
-> Sort: p.created_at DESC, p.post_id DESC, limit input to 20 row(s) per chunk (cost=1.21e+6 rows=10.9e+6) (actual time=6763..6763 rows=20 loops=1)
-> Filte...
여기에서 확인한 것은
확인한 결과 쿼리의 비용이 너무 크다고 생각했다.
infix LIKE (%keyword%)
Prefix LIKE (keyword%)
MySQL Full-Text (기본)
MySQL Full-Text + ngram
Elasticsearch (전용 검색엔진)
일단 부분 포함 검색을 하고 싶어서 먼저 infix LIKE를 테스트 해보았다
MySQL 공식문서의 B-Tree 인덱스 특성 설명에 따르면, LIKE 비교에서 인덱스는 상수 문자열이며 선행 와일드카드가 없는 경우에 사용할 수 있다. 반대로 LIKE '%Patrick%'처럼 선행 와일드카드가 있는 경우는 인덱스를 사용하지 않는 예시로 제시된다.
https://dev.mysql.com/doc/refman/8.4/en/index-btree-hash.html
나는 LIKE '%keyword%'에 ORDER BY + OFFSET까지 붙으면서 비용이 엄청 커진 상황인것 같다.
타임 아웃을 90초로 늘려서 테스트 했다. 그 결과 infix 검색에서 60~70초대 응답시간도 확인됐다.
GET /search/posts/infix (offset=0)
GET /search/posts/infix (offset=1001+)
대비 실험으로 prefix 검색도 같은 조건에서 확인했다.
EXPLAIN
SELECT
p.post_id,
p.title,
p.created_at
FROM post p
WHERE p.post_status = 'PUBLISHED'
AND p.deleted_at IS NULL
AND p.title LIKE CONCAT('검색', '%')
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 1000;

MySQL 공식문서 기준으로 B-Tree 인덱스는 LIKE 'abc%'처럼 선행 와일드카드가 없는 상수 문자열일 때 인덱스를 사용할 수 있다.
그런데 내 실험에는 아래처럼 나왔다.
type = ALL
key = NULL
rows = 10,852,380
Extra = Using where; Using filesort
나는 풀스캔 + filesort가 발생했다.
좀 더 찾아본 결과 아래 이유도 고려해야 한다고 한다.
EXPLAIN ANALYZE
SELECT
p.post_id,
p.title,
p.created_at
FROM post p
WHERE p.post_status = 'PUBLISHED'
AND p.deleted_at IS NULL
AND p.title LIKE CONCAT('검색', '%')
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 0;
-> Limit: 20 row(s) (cost=1.21e+6 rows=20) (actual time=5339..5339 rows=20 loops=1)
-> Sort: p.created_at DESC, p.post_id DESC, limit input to 20 row(s) per chunk (cost=1.21e+6 rows=10.9e+6) (actual time=5339..5339 rows=20 loops=1)
-> Filte...
EXPLAIN ANALYZE에서도 prefix 쿼리는 가볍지 않았다.
prefix로 바꿨다고 해도 현재 스키마/실행계획에서는 대용량 스캔 + 정렬 비용이 아직 너무 크다는 것을 확인했다.
Locust 결과에서도 prefix가 기대만큼 빠르지 않았다.

GET /search/posts/prefix (offset=200~1000)
prefix LIKE는 B-Tree 인덱스를 탈 수 있는 형태가 맞다.
근데 내 실험 조건에서는 prefix도 EXPLAIN상 풀스캔/파일정렬로 나왔고, 부하 테스트에서도 병목이 해소되지 않았다.
그래서 prefix는 비교 기준점으로만 해보고, 이후 실험은 Full-Text / ngram / Elasticsearch로 넘어갔다.
LIKE '%keyword%' 병목을 확인한 뒤, 다음으로 시도한 것은 MySQL Full-Text 검색이었다.
목표는 단순했다.
ALTER TABLE post
ADD FULLTEXT INDEX ft_title_content (title, content);
SELECT post_id, title
FROM post
WHERE MATCH(title, content)
AGAINST (:keyword IN NATURAL LANGUAGE MODE)
ORDER BY created_at DESC
LIMIT 20;
초기 가설은 이랬다.
먼저 브라우저에서 키워드를 직접 넣어 호출해보니,키워드에 따라 시간이 크게 달랐다.
spring 검색: 약 13.75초

redis 검색: 약 8.98초

explain 검색: 약 1.20초

query 검색: 약 1.84초

Full-Text를 붙였다고 해서 일괄적으로 빨라지는 게 아니었다.
키워드에 따라 결과 건수(선택도), 토큰화 방식, 정렬 비용이 달라지면서 응답시간 편차가 나오는 것 같았다.


Locust로 같은 엔드포인트를 부하 테스트했을 때도 비슷한 패턴이 나왔다.
응답시간 분포가 넓었다. 키워드에 따라 편차가 있었다.
초기 테스트 스크립트에는 키워드 풀 갱신용 /posts 요청이 포함되어 있었는데, 이 요청 자체도 느려서 aggregate 수치를 제대로 나오지 않게 할 수 있던 것 같다.
그래서 이후로는 합계만 보지 않고, 검색 엔드포인트 row를 따로 보는 방식으로 정리해야겠다고 생각했다.
동일 표에서 Full-Text 검색 API는 아래 수준이었다
GET /search/posts/fulltext (offset=0)GET /search/posts/fulltext (offset=1~200)GET /search/posts/fulltext (offset=201~1000)GET /search/posts/fulltext (offset=1001+)LIKE '%keyword%'보다 개선된 구간이 있더라도, 서비스 응답시간 관점에서 봤을 때 여전히 수 초~수십 초가 나오고 있었다. 내 목표에서는 아직 충분하지 않았다고 생각했다.
이 실험에서 확인한 건 단순히 느리다/빠르다가 아니었다.
ORDER BY created_at DESC + LIMIT/OFFSET가 붙으면서 정렬 비용이 계속 커짐기본 Full-Text의 다음 후보는 ngram 파서였다.
한글 검색에서 찾아보니 많이 보여서 직접 해보고 싶었다.
한글 부분 검색 기대치에는 더 맞을 수 있다. 대신 인덱스/성능 비용이 늘어날 수 있다고 한다.
ALTER TABLE post
ADD FULLTEXT INDEX ft_title_content_ngram (title, content) WITH PARSER ngram;
SELECT post_id, title
FROM post
WHERE MATCH(title, content)
AGAINST (:keyword IN NATURAL LANGUAGE MODE)
LIMIT 20;
브라우저에서 FULL-TEXT-BOOLEAN 테스트를 해보니, 한글 키워드에서도 결과가 나오긴 하지만 편차가 매우 컸다. 어떤 키워드는 잘나오기도 했지만 아예 검색이 안되기도 하고, ngram없을때보다 더 느리게 나오기도 했다.
테스, 테 계열: 약 2.47초 ~ 3.45초

query: 약 12.33초

추적: 약 2.40초

쿼리: 약 55.68초

같은 모드에서도 키워드 선택도와 토큰 분해 결과에 따라 시간 편차가 매우 큰 걸 확인할 수 있었다.


Locust에서도 NGRAM_KEYWORDS (한글 + 영어 키워드)로 분리해서 테스트했는데, 응답시간 분포가 더 거칠게 나왔다.
Locust 설정의 read timeout(90초)로 설정을 해놨기 때문에 90초동안 못받고 실패한 것이다.

Boolean 모드도 같이 테스트했지만, 키워드/offset에 따라 편차가 컸다.
GET /search/posts/fulltext-boolean (offset=0)
GET /search/posts/fulltext-boolean (offset=1~200)
오히려 키워드와 데이터 분포에 따라 극단적인 지연/타임아웃이 계속 발생했다.
이번 실험에서 내 환경 기준으로 확인한 점은 다음과 같다.
ngram은 검색 품질 개선에는 유효할 가능성이 있었지만, 내 조건에서는 성능 안정성이 생각보다 나오지 않았다.
그래서 다음 단계로 Elasticsearch를 붙여 비교하게 되었다.
이번 실험에서 확인한 핵심은 아래였다.
그래서 다음 글에서는 Elasticsearch를 실제로 도입한 과정, DB데이터를 ES에 적재한 경험, 테스트 결과를 정리하려고 한다.
개선 과정은 다음 글에 정리하겠습니다.