
👋 들어가며
처음에는 검색을 정말 단순한 문제라고 생각했다.
그냥 검색어만 넣으면 결과가 쭉 나오면 되는 거 아닌가?
그런데 직접 구현하고, 부하 테스트까지 돌려 보니
생각보다 함께 고려해야 할 것들이 훨씬 많다는 걸 알게 됐다.
그리고 아마 앞으로도 계속 고민할 지점이 남아 있을 것 같다.
지난 몇 달 동안 검색 기능을 조금씩 만들고 고쳐 오면서 했던 생각들을
드디어 글로 정리하게 되었다.
🚀 우리 서버의 검색기능

봄봄 서비스에는 “내가 받은 아티클 안에서 검색하는 기능”이 있다.
처음 구현은 아주 단순했다.
WHERE title LIKE '%:keyword%'
OR content LIKE '%:keyword%'
즉, 제목이나 본문에 keyword가 어디에라도 포함되어 있으면 검색 결과에 나오도록 한 것이다.
문제는 이 단순함의 대가가 너무 컸다는 거다.
처음에는 괜찮았는데 데이터가 점점 쌓이다보니 일부 검색은 응답시간이 6초까지 나오게 되었다.
이미 페이지는 떴는데, 검색 버튼 한 번 눌렀을 뿐인데 6초를 기다려야 한다면 개발자인 나조차도 안 쓰고 싶다.

❓ 왜 `LIKE %keyword%`는 이렇게 느릴까?
핵심은 간단하다. LIKE '%keyword%'는 일반 B-Tree 인덱스를 제대로 못 탄다.
B-Tree 인덱스는 왼쪽부터 정렬된 값을 기준으로 “어디서부터 읽기 시작할지(start key)”를 잡고 내려가는 구조다.
LIKE abc% → abc로 시작하는 구간을 ['abc', 'abd')처럼 범위로 잡고 range scan이 가능하다.
LIKE %abc% → 앞에 %가 붙어 있어서, 어디서부터 읽어야 할지 시작 지점을 정할 수 없다.
시작 지점을 못 정하면 어떻게 될까?
결국 “그냥 다 훑어본다(풀 스캔)” 쪽으로 떨어진다.
데이터가 조금일 때는 티가 안 나지만, 뉴스레터가 쌓이고 회원이 늘어나면 이 방식은 데이터 양만큼 선형으로 느려지는 구조다.
속도만의 문제는 아니었다.
문제는 성능만이 아니었다.
뉴스·시사 도메인에서 사용자는 종종 이렇게 검색한다.
- "기준 금리 인하", "소비 심리"처럼 공백이 섞인 표현을 그대로 입력하거나
- 정확한 단어 대신 "금리 인하", "소비심리"처럼 조합을 조금 다르게 입력하거나
- 아예 "리쇼어링"이 아니라 "쇼어링", "연착륙"이 아니라 "착륙"처럼 단어 조각만 떠올리기도 한다.
예를 들어, 최근에 어떤 뉴스레터에서 이런 문장을 봤다고 해보자.
“이번 조사에서 소비 심리 회복이 뚜렷하게 나타났다.”
사용자가 나중에 이 아티클을 다시 찾고 싶을 때
꼭 "소비 심리 회복"을 정확히 입력하지는 않는다.
"소비 심리"만 치기도 하고
"소비심리"라고 붙여 쓰기도 하고
심지어 "심리 회복" 같은 애매한 조각만 떠올릴 수도 있다.
LIKE는 이런 상황에 꽤 까다롭다.
"소비 심리"로 검색하면 본문에 정확히 소비␣심리가 그대로 연속해서 들어 있어야 매칭되고
"소비심리"로 검색하면 소비심리라는 문자열이 통째로 있어야 찾을 수 있다.
결국 LIKE는 “이 문자열 조각이 이 모양 그대로 붙어 있는지” 를 보는 데는 강하지만,
“여러 단어를 토큰처럼 쪼개서, 공백·순서·위치가 조금 달라도 관련 문서를 유연하게 찾아주는 검색” 을 만들기에는 한계가 있다.
도메인 특성상 “부분 검색”은 포기하기 어렵다
여기에 도메인 특성이 얹힌다.
뉴스/시사 도메인에서는
- “리… 뭐더라? 리쇼… 리쇼어링?”
- “연… 뭐였지, 연착륙? 경착륙?”
처럼 앞·뒤가 흐릿한 상태에서 다시 찾는 일이 너무 많다.
머릿속에 남는 건 온전한 단어 전체가 아니라,
‘쇼어링’, ‘착륙’ 같은 단어 조각인 경우가 대부분이다.
예를 들어, 최근 통계청이 국가데이터처로 승격되었다.
사용자는 익숙하지 않는 표현이라"국가데이터처"를 기억하지 못하고
"국가데이터"까지만 치거나 "데이터처"만 칠 확률이 높다.
이때 관련 뉴스·뉴스레터가 나와야 사용자의 읽기 리듬을 끊지 않고 이어갈 수 있다.
그래서 우리 서비스에서는
“부분만 기억해도, 공백이 섞여 있어도 바로 찾아주는 검색”
을 단순한 편의 기능이 아니라,
뉴스/시사 도메인에 꽤 필수에 가까운 요구사항이라고 판단했다.
하지만 이 요구사항을 만족시키려고 LIKE '%keyword%'를 썼더니
- 실제 검색 속도는 6초까지 치솟았고,
- 최소한 1초 이하로는 줄여야 “써볼 만한 기능”이 된다는 점이 드러났다.
그래서 우리는
“부분 검색 + 공백이 섞인 여러 단어 검색 경험은 유지하면서,
LIKE보다 훨씬 빠르게 만들 수 있는 방법이 없을까?”
라는 질문을 들고,
그 다음 단계 설계를 시작했다.
📚 해결 방안 후보
1. 캐싱 계층 적용
자주 검색되는 키워드에 대해 검색 결과를 캐싱해 두는 방법도 고민했다.
쇼핑몰 검색처럼, 모두가 같은 상품 풀에서 `"아이폰"`, `"에어팟"` 같은 키워드를 반복해서 검색하는 서비스라면
한 번 계산한 검색 결과를 여러 사용자가 같이 재사용할 수 있어서 효과가 크다.
하지만 봄봄의 검색은 구조가 조금 다르다.
우리는 “전체 아카이브”가 아니라, 각 사용자가 받은 메일함 안에서만 검색한다.
같은 `"금리 인하"`라는 키워드를 검색해도 A 사용자가 받은 뉴스레터와 B 사용자가 받은 뉴스레터가 다르기 때문에 검색 결과도 완전히 달라진다.
즉, 캐시 키가 유저 수 × 키워드 수만큼 흩어지는 롱테일(long-tail) 구조라
“한 번 캐싱해 두면 여러 사람이 같이 쓰는” 전형적인 캐시 패턴이 아니다.
히트율이 낮을 가능성이 높다는 뜻이다.
정리하면, 우리 서비스는 “자주 반복되는 글로벌 검색 결과를 여러 유저가 함께 쓰는” 구조가 아니라,
“각자 받은 메일박스 안에서, 가끔씩, 제각각 다른 키워드를 검색하는” 구조다.
이런 특성에서는
캐시를 위한 비용과 복잡도에 비해 얻을 수 있는 이득이 크지 않다고 보고,
이번 검색 개선에서는 캐싱 계층 도입은 후보에서 제외했다.
2. 경량 검색 엔진 도입
두 번째로 고민했던 건 Meilisearch / Typesense 같은 경량 검색 엔진을 붙이는 것이었다.
결론만 말하면, 검색 품질과 성능만 놓고 보면 이게 가장 깔끔한 선택이다.
전담 검색 엔진을 쓰면 MySQL만 쓸 때보다 이런 점들이 좋아진다.
더 풍부한 검색 기능
prefix 검색, typo 허용, 필터 + 정렬 조합 등 다양한 쿼리 조합을 자연스럽게 지원한다.
더 나은 관련도 정렬(Relevance)
“어떤 결과가 더 그럴듯한가?”를 점수로 표현하고, 상위 몇 개만 빠르게 가져올 수 있다.
확장성
검색 트래픽이 늘어나면 검색 엔진 인스턴스를 수평 확장해서 대응하기 쉽다.
반대로, MySQL 한 대로 LIKE・기본 FULLTEXT만 쓰는 검색에는 이런 한계가 있다.
한국어에 약함
“리쇼어링 / 리-쇼어링 / 리 쇼어링”처럼 표기가 조금만 달라져도 MySQL 기본 기능만으로는 “같은 단어다”라고 이해시켜 주기가 어렵다.
동의어 처리 없음
“연착륙 / 소프트 랜딩”, “금리 인상 / 긴축”처럼 다른 단어지만 비슷한 맥락인 것들을 한꺼번에 묶어 주려면 직접 전처리·매핑 로직을 짜야 한다.
페이징 성능
OFFSET / LIMIT 기반 페이징은 데이터가 쌓일수록 뒤 페이지로 갈수록 점점 느려진다.
부분 단어 검색의 구조적 한계
N-gram 같은 기능을 활용해 부분 검색을 구현할 수는 있지만, 인덱스 용량과 버퍼 풀 사이즈 안에서 어느 시점에는 타협이 필요하다.
뉴스/시사 도메인은 “단어가 아니라 단어 조각으로 검색하고 싶은” 도메인이라
이런 고급 검색 기능이 있으면 확실히 품질을 더 끌어올릴 수 있다.
그럼에도 우리는 검색 엔진 도입을 이번에는 보류했다.
이유는 두 가지 정도로 정리할 수 있다.
1. 비용 문제
Meilisearch / Typesense를 쓰려면 MySQL과는 별도로 항상 떠 있는 인스턴스 1개 이상이 필요하다.
지금도 t4g.micro / t4g.micro 조합으로 인프라 비용을 아껴가면서 운영하고 있어서, 여기에 “검색 엔진 전용 인스턴스”까지 추가하는 건 현재 팀 상황에서는 부담이 컸다.
2. 우리 서비스에서 검색의 위치
봄봄에서 검색은 “하루에도 몇 번씩 누르는 메인 기능”이라기보다는,
“필요한 순간엔 반드시 잘 되어 있어야 하는 기능”에 가깝다.
예전에 훑어본 뉴스레터를 다시 찾아보거나,
구독을 고민하면서 “그때 그 메일 다시 보고 싶다” 싶은 순간에 주로 쓰인다.
사용 빈도는 높지 않지만, 한 번 검색할 때마다의 중요도는 꽤 높은 편이다.
그래서 이런 질문을 다시 꺼내 들었다.
“지금 단계에서, 전용 검색 엔진을 붙일 만큼 검색이 비즈니스의 한가운데에 와 있을까?”
내린 결론은 이거였다.
“아직은 아니다.”
정리하자면, 검색 엔진이 더 나은 해답인 건 분명하지만,
현재 트래픽과 팀 리소스, 서비스 단계까지 함께 놓고 보면
지금은 검색 엔진까지 도입하는 것이 투자 대비 과한 선택에 가깝다고 판단했다.
정리하면, 검색 엔진은 분명 더 좋은 해답이지만, 지금 팀의 리소스와 서비스 단계에서는 과투자에 가깝다고 판단했다.
3. MySQL 플러그인 사용
Mroonga는 MySQL에서 쓸 수 있는 스토리지 엔진 플러그인이다.
안쪽에는 Groonga라는 전문 검색 엔진이 들어 있고,
이걸 MySQL에서 바로 쓸 수 있도록 감싼 형태라고 보면 된다.
공식 설명을 한 줄로 정리하면 대략 이런 느낌이다.
“중국어·일본어·한국어를 포함해 모든 언어에서 빠른 전문 검색을 제공하는 MySQL용 스토리지 엔진
왜 한국어에 Mroonga가 잘 맞을까?
한국어 검색이 힘든 이유는 띄어쓰기 믿기 어렵고 조사, 어미, 합성어가 많고 외래어, 영문, 숫자까지 뒤섞이기 때문이다.
기본 InnoDB FULLTEXT는 영어 기준으로 설계되어 있어서,
중국어·일본어·한국어(CJK) 텍스트에는 잘 맞지 않는다는 게 늘 문제였다.
Mroonga/Groonga 쪽은 애초에 CJK 텍스트용 전문 검색 엔진이다.
N-gram 방식으로 “문자 조각” 기준 검색을 잘 지원하고
다양한 토크나이저(tokenizer)를 통해 언어 특성에 맞게 쪼개서 인덱싱해준다.
그래서 한국어에서도 쇼어링만 쳐도 리쇼어링, 프렌드쇼어링 같은 것들을 잘 찾아주고 "데이터처"만 쳐도 "국가데이터처" 관련 글을 잘 집어내고 인덱스 기반이라 속도도 MySQL 기본 LIKE, 기본 FULLTEXT보다 훨씬 빠른 편이다.
하지만 우리 상황에서는 사용불가능하다.
우리가 사용하는 DB는 RDS mysql이다
RDS mysql은 플러그인 사용이 불가능하다.

플러그인을 설치하는 데 필요한 모든 조건을 RDS가 막아놓았기 때문에 결과적으로 불가능한 것이다.
mysql 플러그인 설치에는 아래 3개가 반드시 필요하다.
1. OS에 .so파일 업로드(shared Library) -> RDS는 OS 접근 자체가 불가능
2. Mysql 플러그인을 활성화를 위해 `INSTALL PLUGIN mroonga SONAME 'ha_mroonga.so'` 해당 명령어 입력 필요 -> RDS는 SUPER 권한을 안 줌
3. 플러그인 설치 시 내부적으로 파일 읽기 작업이 발생하는데 RDS는 file 권한 역시 차단
그럼 aws에서는 이걸 왜 막을까?
AWS는 RDS를 다음과 같은 서비스로 정의함
“운영을 AWS가 대신 관리해주는 Managed Database 서비스.”
Managed DB에서 AWS가 책임지는 영역
- DB엔진 설치/업데이트/패치
- 백업/복구
- 모니터링
- 장애 자동 감지 / Failover
- 버전 호환성 유지
- 보안 패치 자동 적용
플러그인이 설치되면 이 모든 보장이 깨질 수 있음.
플러그인이 버전별 호환성을 망가뜨리거나,
업데이트할 때 죽어버리거나,
crash recovery에 간섭할 수 있음.
AWS는 이 리스크를 관리할 방법이 없기 때문에 일괄적으로 막는다.
정리해보면 한국어 검색 품질과 성능만 놓고 보면
Mroonga는 굉장히 매력적인 해법이지만,
“우리가 RDS MySQL을 쓰기로 한 인프라 선택” 때문에
현실적으로는 고려할 수 없는 옵션이었다.
4. Full-text Search
Full-text Search는 말 그대로 문서의 전체 텍스트를 대상으로 검색하는 기능이다.
문장/본문이 긴 컬럼을 통째로 훑는 대신,
먼저 텍스트를 분석해서 검색용 인덱스(역색인)를 만들어 두고
나중에 검색할 때는 이 인덱스를 통해 빠르게 후보를 찾는 방식으로 동작한다.
Full-text Search 동작 방법
1. 토큰화
문장을 단어 단위로 잘게 쪼갠다.
ex) "연착륙 전망 나오는 미국 경제"
-> ["연착륙", "전망", "나오는", "미국", "경제"]
2. 역색인(Inverted Index) 생성
각 단어가 어디에서 등장하는지 역으로 저장한다.
ex) "연착륙" -> [문서 3, 문서 10, 문서 25]
미국 -> [문서 1, 문서 3, 문서 8, 문서 25]
이런 구조를 역색인라고 부른다.
기존 B-Tree 인덱스가 PRIMARY KEY -> 행을 찾는 지도라면, 역색인은 단어 -> 이 단어가 들어간 문서들을 찾는 지도에 가깝다.
3. 검색 시 빠른 조회
사용자가 "연착륙 미국"을 검색하면, "연착륙"이 들어간 문서 목록과 "미국"이 들어간 문서 목록을 가져와서 교집합을 구하는 식으로, 해당 단어들이 포함된 문서를 바로 찾는다. 이 과정에서 테이블 전체를 풀 스캔할 필요가 없다.
4. 관련도 점수 계산(Scoring)
Full-text Search는 단순히 "포함 여부"만 보는게 아니라,
- 이 단어가 몇 번 나왔는지
- 제목에 있는지, 본문에 있는지
- 전체 텍스트 대비 비율은 어떤지
같은 것들을 이용해 관련도 점수(score)를 매긴다.
그리고 이 점수를 기준으로 ORDER BY score DESC 정렬을 해 주기 때문에,
사용자가 보기에도 "더 그럴듯한 결과"가 위로 올라온다.
다만 MySQL의 `FULLTEXT` 검색은 기본적으로
영어 및 공백 기반 언어에 잘 맞게 설계되어 있다.
공백·구두점을 기준으로 단어를 나누고 형태소 분석을 따로 해주지는 않는다.
그래서 한글 뉴스 본문처럼 띄어쓰기가 애매하거나 조사/어미/합성어가 많고 외래어, 영어, 숫자가 섞여 있는 텍스트에 대해서는
“그럭저럭 동작은 하지만, 한국어에 완전히 최적화된 검색이라고 보긴 어렵다.” 라는 한계가 있다.
이 빈 부분을 우리는 n그램 + FULLTEXT 혼합 전략으로 어느 정도 보완해 보려 했다.
우리가 선택한 방법
여러 가지 대안을 검토한 끝에,
우리는 RDS MySQL에서 제공하는 FULLTEXT Search(기본 파서 + ngram 테이블을 함께 쓰는 구조)를 선택했다.
이유는 아래와 같다.
1. 추가 인프라 비용이 들지 않는다.
Elasticsearch / Meilisearch / Typesense 같은 검색 전용 엔진을 새로 띄우지 않고, 현재 사용 중인 RDS MySQL 안에서 해결할 수 있다.
2. 기존 MySQL 운영/백업 체계를 그대로 활용할 수 있다.
검색 전용 테이블과 FULLTEXT 인덱스도 RDS 자동 백업/스냅샷에 같이 포함되기 때문에 장애 대응, 복구, 모니터링을 별도로 설계할 필요가 없다.
3. `LIKE '%keyword%'`와 비슷한 사용자 경험을 유지하면서, 인덱스 기반으로 훨씬 빠르게 동작한다.
사용자 입장에서는 여전히 “키워드를 치면 아티클이 나온다”는 흐름이 그대로이고,
부분 검색 + 공백이 섞인 여러 단어 검색도 어느 정도 받아준다.
대신 서버 입장에서는 테이블 풀 스캔이 아닌 FULLTEXT 인덱스(ngram + 기본 파서)를 사용하기 때문에
데이터가 쌓여도 검색 응답 시간이 훨씬 안정적으로 유지된다.
4. 관련도(relevance) 점수 기반 정렬을 통해, ‘더 그럴듯한 결과’를 위에 올릴 수 있다.
Full-text Search는 단순히 포함 여부만 판단하는 것이 아니라
키워드 등장 빈도, 위치 등을 기반으로 점수를 계산해 준다.
우리는 `ORDER BY score DESC, published_at DESC` 형태로 정렬해
“검색어와 더 잘 맞으면서도 최신에 가까운 아티클”이 상단에 노출되도록 했다.
🧮 관련도 점수 계산
관련도 점수 계산
MySQL MATCH() AGAINST()가 점수를 계산할 때 기본 아이디어는 언어 안 가린다.
- 어떤 '토큰(단어/조각)' 들이 문서에 있고
- 검색어에서 온 토큰과 얼마나 겹치고
- 문서에서 얼마나 자주 나오고
- 전체 길이에 비해 어느 정도 비율을 차지하는지
이런 걸 가지고 relevance scroe를 만든다.
이 로직은 영어/한글/숫자/기호 가리지 않고 "토큰 단위"로 동작한다.
토큰만 잘 뽑혀 있으면 점수는 무조건 나온다.
차이점의 핵심: “토큰이 뭐냐”가 언어별로 다르다
차이는 여기서 생긴다.
1. 영어 (기본 parser)
영어는 대략 이런 식으로 토큰이 생성된다.
- "Reshoring is coming back to the US"
→ ["reshoring", "coming", "back", "us"]
검색어가 "reshoring"이면
- "reshoring"이 포함된 문서만 고르고
- 등장 횟수/위치/문서 길이 기반으로 점수 계산
즉, “단어 = 의미 단위”에 상당히 가깝게 떨어진다.
그래서 관련도 점수가 “실제 의미 관련도”랑 비슷하게 느껴진다.
2. 한글 + 기본 parser
한글은 공백이 있을 땐 그럭저럭 괜찮다.
- "연착륙 전망 나오는 미국 경제"
→ ["연착륙", "전망", "나오는", "미국", "경제"]
이 정도면 "연착륙" 검색 시 점수가 꽤 그럴듯하게 나온다.
그런데
- "연착륙이냐 경착륙이냐" → 조사/어미까지 붙어서 한 덩어리
- ["연착륙이냐", "경착륙이냐"]
- "리쇼어링/리-쇼어링/리 쇼어링" → 표기 다 다름
여기서부터 사람이 보기엔 “다 같은 개념”인데 Full-text 입장에선 완전히 다른 토큰이라 관련도 점수가 허당처럼 느껴질 수 있다.
그래서 “영어에 비해 한글 관련도 별로다”라는 말이나온는 것이다.
3. 한글 + ngram parser
ngram 파서를 쓰면 토큰이 이렇게 바뀐다 (예: 2글자 n-gram)
- "리쇼어링" → ["리쇼", "쇼어", "어링"]
- "리 쇼어링" → 공백/기호 기준으로 나뉘면서도, 내부에서는 다시 2글자 단위로 쪼개짐
- "연착륙이냐 경착륙이냐"
→ 대충 ["연착", "착륙", "륙이", "이냐", "경착", "착륙", ...] 이런 느낌
검색어 "쇼어링" → ["쇼어", "어링"]
그럼 점수 계산은 이런 느낌으로 이다.
- 이 문서의 n-gram 리스트랑
- 검색어의 n-gram 리스트를 비교해서
- 얼마나 많이 겹치는지 + 전체 대비 비율로 점수 계산
그래서
- “리쇼어링” / “리-쇼어링” / “리 쇼어링”
- 전부 "쇼어", "어링" 같은 공통 n-gram을 공유
→ 관련도 점수가 “나름 그럴듯하게” 나온다
- 전부 "쇼어", "어링" 같은 공통 n-gram을 공유
- “연착륙”, “연착륙이냐”
- ["연착", "착륙", ...] 같은 공통 n-gram 공유
→ 이것도 꽤 잘 잡힌다
- ["연착", "착륙", ...] 같은 공통 n-gram 공유
하지만 이때의 관련도 점수는
“이 문서와 검색어가 같은 의미를 가지냐”가 아니라
“이 문서와 검색어가 같은 글자 조합을 많이 공유하냐” 이다.
다만 여기에는 한 가지 문제점이 있다.
MySQL Full-text Search를 쓰면 기본 파서(Buil-In Parser)는 공백과, 쉼표(,), 마침표(.) 같은 구분자 문자를 기준으로 단어의 시작과 끝을 판별해서 텍스트를 토큰으로 분리한다.
(공식 문서: The built-in FULLTEXT parser determines where words start and end by looking for certain delimiter characters…)
영어처럼 단어 사이에 항상 띄어쓰기가 있는 언어에서는 이 방식이 잘 맞는다.
문제는, 한국어는 띄어쓰시가 "단어 경계 = 의미 단위"와 거의 맞지 않는다는 점이다.
예를 들어 이런 문장이 있다고 하자
한국은행이 기준금리를 인상했다.
Built-in parser / Natural Language 모드에서는 다음처럼 토큰화된다.
["한국은행이", "기준금리를", "인상했다"]
그리고 Full-text Search는 이 토큰들을 기준으로 검색을 수행한다.
중요한 점은 기본 Natural Language 모드에서는 prefix / 부분 문자열 검색을 지원하지 않는다는 것이다.
그래서 매칭 동작은 대략 아래와 같다.
built-in parser + Natural Language 모드 (모두 기본값)
| 토큰 | 검색어 | 매칭 여부 | 이유 |
| 기준금리를 | 기준 | ❌ X | 단어 중간 substring → 미지원 |
| 기준금리를 | 기준금리 | ❌ X | prefix(접두어) 매칭 → 미지원 |
| 기준금리를 | 기준금리를 | ✅ O | 토큰 전체와 검색어가 완전히 일치할 때만 매칭 |
| 인상했다 | 인상 | ❌ X | 어근 수준 분석 없음, 부분 문자열 미지원 |
| 인상했다 | 인상했다 | ✅ O | 단어 전체가 동일할 때만 매칭 |
이렇게 단어 전체가 동일할 때만 매칭되기 때문에, 우리가 직관적으로 기대하는 것처럼
- "기준금리"로 검색했을 때 "기준금리를"이 자동으로 잡힌다거나
- "인상"으로 검색했을 때 "인상했다"가 함께 매칭된다거나
하는 "부분 검색" 경험을 주기 어렵다.
그래서 한국어에서는, 기본 Built-in parser + Natural Language 모드만으로는 한글 부분 검색 요구를 충족하기 힘들고,
이 때문에 ngram 파서를 고려하게 되었다.
한글의 부분 검색을 자연스럽게 지원하려면, Full-text Search의 파서를 기본값대신 'ngram' 파서로 바꾸는 게 더 잘맞는다.
ngram은 말 그대로 텍스트를 N글자 단위로 잘게 쪼개는 방식이다.
예를 들어 2-gram 기준으로 보면
"금리인상" -> ["금리", "리인", "인상"]
이렇게 잘게 쪼개 두면 사용자가 "금리 인상"을 검색했을 때 "금리", "인상" 이라는 조각이 겹치는 문장들을 찾을 수 있다.
즉, ngram 파서는 "단어 단위 의미 분석"이라기보다 "글자 조각이 얼마나 많이 겹치냐"를 기준으로 검색하는 방식이라고 볼 수 있다.
완벽한 한국어 형태소 분석 수준은 아니지만, 기본 full-text의 공백 기반 파서보다 우리가 기대한 "부분 검색" 경험에 훨씬 가깝고 여전히 Full-text 인덱스를 사용하기 때문에 성능도 충분히 확보할 수 있다.
그렇지만 여기서 고려해야할 부분이 또 있다.
바로 인덱스의 크기이다.
ngram 파서를 쓰면, 인덱스가 폭발적으로 늘어난다.
ngram은 텍스트를 n글자 단위로 잘게 쪼개기 때문에 같은 문자열이라도 토큰 개수가 기본 파서보다 훨씬 많아진다.
ngram으로 바꾸는 순간 동일한 문장을 훨씬 더 많은 토큰으로 나눠서 인덱스에 저장하게 된다.
서비스 전체로 보면 이 차이는 더 크게 누적된다.
토큰 수가 몇 배까지도 쉽게 뛰어오르고, 그만큼 인덱스 크기도 커진다.
인덱스가 크기가 단순히 디스크만 조금 더 쓰는 문제에서 끝나면 좋겠지만
실제로는 MySQL 입장에서는 꽤 많은 부담이 생긴다.
- 디스크 용량 압박
- Full-text 인덱스는 일반 인덱스보다도 덩치가 크다.
- ngram까지 쓰면 “본문 컬럼 + 인덱스”가 합쳐져서 테이블 용량이 훅 커진다.
- RDS처럼 디스크를 넉넉하게 잡기 힘든 환경에서는 꽤 부담된다.
- 버퍼 풀(메모리) 부담
- InnoDB는 인덱스 페이지도 버퍼 풀에 올려서 캐싱한다.
- 인덱스가 커질수록, 같은 메모리에서 더 많은 페이지를 돌려 써야 한다 → 캐시 히트율이 떨어짐.
- 그만큼 디스크 I/O가 늘고, 검색/쓰기 성능이 서서히 떨어진다.
- 쓰기(INSERT/UPDATE) 비용 증가
- 한 번 INSERT를 할 때마다, ngram 토큰 개수만큼 인덱스에 엔트리가 들어간다.
- “문자열 1개 → 인덱스 1번”이 아니라
“문자열 1개 → ngram 토큰 수만큼 여러 번 인덱스 갱신”이 일어나는 셈이다. - 뉴스레터처럼 글이 계속 쌓이는 서비스라면 이 쓰기 비용도 무시하기 어렵다.
결국, ngram은 검색 품질과 한글 부분 검색 경험을 개선해 주는 대신,
인덱스 크기와 자원 사용량이라는 꽤 큰 대가를 요구한다.
그래서 어디선가는 타협을 해야 한다.
우리는 먼저 기술적인 타협보다, 비즈니스적인 타협부터 살펴봤다.
우리 서비스의 구독자 대부분은 뉴스 분야를 구독하고 있다.
뉴스의 특성상, 어제 본 기사도 하루만 지나도 가치가 빠르게 떨어지고,
일주일 전 기사는 “검색해서 다시 읽을 만한 기사”의 비율이 훨씬 낮다.
그래서 이렇게 생각했다.
“모든 기간 데이터를 완벽한 한글 부분 검색으로 지원하기보다는,
사용자가 실제로 많이 찾는 ‘최근 뉴스’에만 ngram의 비용을 집중하자.”
구체적으로는 ngram 인덱스는 최근 5일 치 데이터에만 적용하고,
그 이후의 데이터는 기본 파서 기반 Full-text 인덱스로만 두는 방식을 택했다.
이렇게 하면, 사용자가 가장 자주 찾는 최신 뉴스는 ngram 덕분에 "부분 검색 경험"을 충분히 제공하고 상대적으로 조회가 적은 예전 뉴스들은 인덱스를 과하게 부풀리지 않고, 저장 공간,버퍼 풀 부담도 줄일 수 있다.
즉, 모든 데이터를 완벽하게 검색할 수 있게 하기 대신 자주 검색되는 구간에 비용을 집중하는 전략으로 타협
그렇다면 왜 최근 5일치인가?
사실 5일보다 7일이나 15일정도로 더 잡고 싶었지만 5일이 한계다.
MySQL(InnoDB)은 디스크에 있는 데이터를 바로 읽어 쓰지 않고,
버퍼 풀(Buffer Pool)이라는 메모리 캐시에 올려 두고 사용한다.
- 버퍼 풀이 넉넉하면 자주 쓰는 데이터, 인덱스가 메모리에 머물러서 빠르게 응답할 수 있지만
- 버퍼 풀이 작으면 디스크 I/O가 급격히 늘어나면서 전체 성능이 눈에 띄게 떨어진다.
우리가 사용하는 RDS MySQL 인스턴스는 t4g.micro이고, 메모리는 1GB이다.
AWS에서 이 구간의 innodb_buffer_pool_size 최대값을 384MB 정도로 제한하고 있어서 결국 Full-text / ngram 인덱스가 쓸 수 있는 메모리 예산도 그 안에서 해결해야 한다.
버퍼 풀(buffer pool)
1. 버퍼 풀??
MySQL(InnoDB)은 실제 데이터(테이블, 인덱스)를 디스크에 저장한다.
그런데 디스크에서 직접 읽고 쓰는 건 엄청 느리다.
그래서 InnoDB는 이렇게 행동한다.
1. 디스크에서 데이터를 읽을 때 한 페이지(보통 16KB) 단위로 가져와서 메모리(버퍼 풀)에 올려둔다.
2. 그 다음 같은 데이터를 또 읽을 읽이 생기면 디스크 말고 버퍼 풀(메모리)에서 바로 읽는다.
3. 데이터를 변경(insert/update/delete)할 때도 먼저 버퍼 풀에 있는 페이지를 수정하고 나중에 디스크에 천천히 반영한다.
즉, 버퍼풀은 자주 쓰는 데이터/인덱스를 메모리에 캐싱해 두는 큰 바구니라고 생각하면 된다.
2. 버퍼 풀 사이즈(buffer pool size)란?
값이 크면 더 많은 데이터/인덱스를 메모리에 올려둘 수 있어서 디스크를 덜 읽고 조회/쓰기 성능이 좋아진다. 다만 너무 크게 잡으면 OS나 다른 프로세스가 사용할 메모리가 부족해 질 수 있고 swap 걸리면 오히려 성능이 망가진다.
그래서 AWS RDS는 안전하게 40% ~50% 이하로 주고있다.
값이 작으면 캐시 미스가 자주 일어나고 이는 디스크 I/O가 폭증으로 이어지고 이로인해 쿼리 응답 시간이 기하급수적으로 느려질 수 있다.
왜 전체 아티클에 n그램을 못 걸고, ‘최근 5일’만 선택했을까?
먼저 우리가 서 있는 인프라부터 짚고 가야 한다.
우리가 사용하는 DB는 RDS MySQL db t4g.micro이고, 이 인스턴스의 RAM은 1GB다.
MySQL에서는 디스크에 있는 데이터를 메모리에 캐싱해 두기 위해 InnoDB 버퍼 풀이라는 공간을 사용한다.
AWS 공식 문서를 보면, RDS for MySQL에서는 MySQL 8.4 기준으로 기본적으로 `innodb_dedicated_server`를 켜두고,
“DB 인스턴스 메모리를 기준으로 `innodb_buffer_pool_size`를 자동 계산”하도록 되어 있다.

실제로 우리는 `innodb_buffer_pool_size`를 640MB로 설정해 봤는데,
SHOW VARIABLES로 확인해 보면 값이 402,653,184(≈ 384MB)로 잡혀 있었다.
설정을 더 크게 줘도, RDS가 인스턴스 메모리 비율에 맞게 384MB 정도로 깎아서 적용한 것이다.


정리하면, 이 인스턴스(t4g.micro)에서 우리가 실제로 쓸 수 있는 InnoDB 버퍼 풀은 대략 384MB 정도가 상한선이라고 보는 게 현실적이다. 그리고 이 384MB 안에서 검색 인덱스 + 나머지 모든 쿼리/데이터가 함께 살아야 한다.
“Ngram을 전체적용 한다면?”
처음에는 솔직히 이렇게 생각했다.
“그냥 전체 아티클에 ngram 인덱스를 걸어버리면
한글 부분 검색도 잘 되고, 품질은 제일 좋지 않을까?”
하지만 MySQL이 제공하는 ngram 풀텍스트 파서는,
문장을 고정 길이 n글자씩 잘게 쪼개서 인덱싱하는 구조라 토큰 수가 급격히 늘어난다.
전 기간 아티클 전체에 ngram 인덱스를 걸면 인덱스 용량이 폭발하고,
384MB짜리 버퍼 풀로는 자주 쓰이는 인덱스/데이터를 메모리에 유지하기가 사실상 불가능해진다.
그래서 전략을 이렇게 바꿨다.
“전체를 ngram으로 도배하는 건 욕심이고,
버퍼 풀 안에서 감당 가능한 범위만큼만 ngram을 쓰자.
나머지는 MySQL이 기본으로 제공하는 FULLTEXT 인덱스를 최대한 활용하자.”
그 결과 나온 게 바로,
- 최근 5일치 → n그램 FULLTEXT 인덱스
- 그 이후 전 기간 → MySQL 기본 FULLTEXT 인덱스(기본 파서)
라는 하이브리드 전략이다.
그렇다면 이 5일이라는 기간을 어떻게 잡았을까?
아티클·인덱스 용량 가정
버퍼 풀 안에서 ngram을 얼마나 쓸 수 있을지 보려면,
대략이라도 아티클 1건당 용량을 계산해 봐야 했다.
일단 우리 서비스가 현재 기준으로 대략 300명이니까 근시일 목표로하자면 아래와 같다.
- 회원 수: 500명
- 1인당 하루에 받는 뉴스레터 수: 4개
- ⇒ 서버 입장에서 하루에 받는 아티클 수
500명 × 4개 = 2,000개
각 아티클의 텍스트 크기는 대략 이렇게 잡았다.
- 원본 뉴스레터 본문(HTML 태그 포함): 8천자 ~ 1만 5천자
- HTML 태그 제거 후 실제 텍스트: 약 1,000자 정도로 가정
- 한글/영문 섞인 UTF-8 기준
- 한글 1글자 ≒ 3바이트
- 영문 1글자 ≒ 1바이트
- 보수적으로 한글 기준 3바이트로 계산
- 본문: 1,000자 × 3바이트 ≒ 3KB
- 제목: 보통 30자 안팎 → 30자 × 3바이트 ≒ 0.1KB
그래서 텍스트 자체만 보면 1건당 텍스트 용량 ≒ 3.1KB
여기에 n그램 인덱스까지 포함해서, 보수적으로 1건당 10KB 정도를 사용한다고 가정했다.
(즉, 10KB는 “인덱스만”이 아니라 “텍스트 + ngram 인덱스 전체”에 대한 근사치다.)
5일치 n그램 인덱스가 차지하는 메모리
이제 이 가정으로 실제 숫자를 계산해 보면
- 1건당: 10KB
- 하루 아티클 수: 2,000건
- 5일치 아티클 수: 2,000건 × 5일 = 10,000건
따라서,
- 10,000건 × 10KB = 100,000KB ≒ 97.7MB
즉, 최근 5일치 아티클에 대해 n그램 인덱스를 건다고 가정하면 약 98MB 정도의 메모리를 사용하게 된다.
우리가 줄 수 있는 InnoDB 버퍼 풀 최대값이 384MB이기 때문에,
- 98MB ≒ 버퍼 풀의 약 25%
정도로만 n그램 인덱스 + 텍스트 캐싱에 쓰고,
나머지 75%는 다른 테이블/쿼리/작업들이 사용할 수 있게 남겨두는 전략이다.
여기서 “버퍼 풀의 25%를 검색에 쓴다”는 말은
버퍼 풀을 파티션 나눠서 100MB를 검색 전용으로 락 걸어둔다는 뜻이 아니다.
단지,“최근 5일치 n그램 인덱스의 작업 세트가 대략 100MB 안에서 움직일 것 같다” 라고 추정한 값이고,
버퍼 풀 자체는 여전히 모든 워크로드가 함께 공유하는 캐시다.
그래서 검색은 어떻게 동작하냐? (n그램 + FULLTEXT 혼합 전략)
중요한 건, 우리는 단순히 “최근 5일만 빠르게 보이기 위해 n그램을 썼다” 에서 끝내고 싶었던 게 아니다.
사용자 입장에서는
- 띄어쓰기가 들어간 검색이든 ("항공 연착률")
- 단어 중간만 잘라 치는 검색이든 ("연착", "착률")
- 영어/숫자가 섞인 검색이든
가능한 한 다양한 형태의 검색이 “된다”는 경험을 주고 싶었다.
그래서 실제 검색 로직은 이렇게 동작한다.
- 최근 5일치 + ngram FULLTEXT 인덱스 검색
- 한글을 n자씩 잘라 인덱싱하기 때문에 연착, 착률 같은 부분 검색에 강하다.
- 검색어에 띄어쓰기가 있어도, 내부적으로는 n그램 토큰으로 잘게 쪼개져서
"항공 연착률" → 항공, 연착, 착률, … 같은 조합으로 매칭된다.
- 전 기간 + 기본 FULLTEXT 인덱스(기본 파서) 검색
- 원본 article 테이블에는 MySQL 기본 파서 기반의 FULLTEXT 인덱스도 걸려 있다.
- 이 인덱스는 공백을 기준으로 단어를 나누기 때문에 "항공 연착률"은 항공, 연착률 단위로 깔끔하게 처리된다.
- 덕분에 오래된 아티클까지 포함해서 전 기간 데이터를 단어 기준으로 검색할 수 있다.
- 두 결과를 합쳐서 하나의 검색 결과로 반환
- 동일한 아티클이 양쪽에서 모두 걸리면 하나로 합치고,
최근순 + 관련도 같은 기준으로 다시 정렬한다. - 사용자는 “지금 이 키워드를 검색했을 때 나올 수 있는 결과”를
n그램 + 기본 FULLTEXT를 모두 활용한 합산 결과로 보게 된다.
- 동일한 아티클이 양쪽에서 모두 걸리면 하나로 합치고,
이 구조 덕분에 사용자는
- “띄어쓰기를 어떻게 넣어야 하지…?”를 고민하지 않아도 되고,
- "연착률", "연착", "항공 연착률"처럼 조금씩 다르게 쳐도
그나마 합리적인 결과를 볼 수 있으며, - 최근 며칠치 아티클에 대해서는 부분 검색까지 포함해서 더 빠르고 풍부한 결과를 얻고,
- 오래된 아티클에 대해서도 기본적인 단어 기준 검색은 계속 가능하다.
“그런데 검색에 25%나 쓸 만큼 중요한가요?”
자연스럽게 나올 수 있는 질문이다.
“검색이 DAU 대비 엄청 자주 쓰이는 기능도 아닐 텐데,
메모리 25%나 쓸 만큼 가치가 있어?”
사용 빈도만 놓고 보면,
피드 스크롤이나 홈 화면 진입보다 검색은 분명 덜 쓰일 수 있다.
하지만 검색 버튼을 누르는 순간만 놓고 보면 얘기가 완전히 달라진다.
검색을 쓰는 상황은 대체로 이렇다.
- “예전에 봤던 그 뉴스레터 한 편을 꼭 다시 찾고 싶을 때”
- “며칠 전에 읽었던 글인데, 정확한 제목은 기억이 안 날 때”
- “특정 키워드로 관련된 아티클을 한 번에 모아보고 싶을 때”
이때 검색이 6초씩 걸리거나, 결과가 엉뚱하게 나온다면
사용자는 한 번에 이렇게 느낀다.
“어… 이 서비스에 내 뉴스레터를 계속 맡겨도 되나?”
검색은 “매일 자주 쓰는 기능”은 아닐 수 있지만,
“필요한 순간의 중요도가 매우 높은 기능”이다.
특히 봄봄처럼 “내 메일박스에 쌓여 있는 콘텐츠를 대신 관리해주는 서비스”라면,
“언제든지 다시 찾아볼 수 있다”는 감각이 서비스 신뢰와 직결된다고 생각했다.
그래서 우리는 이렇게 타협했다.
“검색 사용률은 낮지만, 검색이 필요한 순간의 중요도는 높다.
→ 그렇다면 검색을 완전히 포기하기보다는,
메모리 예산 안에서 ‘최근 5일’만이라도 확실히 빠르고,
다양한 형태의 검색을 받아줄 수 있게 만들자.”
여기서 25%라는 수치는 “검색이 너무 중요하니까 무조건 25%를 박제했다”가 아니라,
현재 인프라(t4g.micro + 384MB 버퍼 풀) 기준에서
“n그램 기간을 3일·5일·7일 중 어디쯤 두면 좋을까?”를 고민하면서 잡은
초기 가설값에 가깝다.
지금은 굉장히 보수적으로 잡았기 때문에 이보다 낮을 확률이 더 높다.
실제 운영에서는 버퍼 풀 히트율, 검색 사용량, 전체 쿼리 성능을 계속 보면서
- 5일을 3일로 줄이거나,
- 오히려 7일로 늘리거나,
하는 식으로 계속 조정할 계획이다.
왜 LIKE보다도 빠를까?
마지막으로, 이런 의문도 들 수 있다.
“n그램 한 번, 기본 FULLTEXT 한 번이면
쿼리를 두 번이나 치는 건데,
LIKE '%키워드%' 한 번보다 느린 거 아냐?”
하지만 구조를 뜯어 보면 완전히 다르다.
- LIKE '%키워드%'
- B-Tree 인덱스를 탈 수 없어서
사실상 테이블 전체를 훑는 것과 크게 다르지 않다. - 데이터가 쌓일수록 검색 시간이 행 개수에 비례해서 늘어난다.
- B-Tree 인덱스를 탈 수 없어서
- MATCH AGAINST (ngram / 기본 FULLTEXT)
- 미리 만들어 둔 역색인(토큰 → 아티클 id 목록) 에서
“이 토큰을 포함하는 아티클 id”만 빠르게 뽑는다. - 즉, 전체 N개 중에서 필요한 것들만 골라 읽는 구조라
데이터가 늘어나도 LIKE처럼 폭발적으로 느려지지 않는다.
- 미리 만들어 둔 역색인(토큰 → 아티클 id 목록) 에서
결국 우리가 선택한 것은,
“버퍼 풀이라는 한정된 자원 안에서,
전체를 n그램으로 도배하지 않으면서도
한글 부분 검색 + 띄어쓰기 포함 검색 + 전 기간 검색을
최대한 모두 만족시키기 위한
‘최근 5일 n그램 + 전 기간 FULLTEXT’ 혼합 전략”이다.
⚒️ 실제 적용 & 부하 테스트
말로만 “LIKE보다 빠르다”라고 할 수는 없으니,
실제 서비스를 기준으로 부하 테스트를 돌려봤다.
테스트 조건은 다음과 같다.
- 도구: k6
- 시나리오: `ramping-vus`
- 10 VU → 30 VU (2분)
- 30 VU → 60 VU (2분)
- 60 VU → 100 VU (2분)
- 이후 1분 동안 0 VU까지 감소
- 한 VU는 2초에 한 번씩 검색 수행
- 검색 키워드:
- `makeNaturalKoreanKeyword()`로 생성한 자연스러운 한글 조합*
- 예) `뜨거운 경제`, `뉴스 성장`, `콘텐츠 기록` 등
- k6 스크립트에서는 `endpoint:search` 태그를 달아서
검색 API만 따로 지표를 뽑을 수 있게 했다.
group('search-only', () => {
const keyword = makeNaturalKoreanKeyword();
const searchUrl = `${BASE}/api/v1/articles?keyword=${keyword}&limit=10`;
const res = http.get(searchUrl, {
headers,
cookies: COOKIE,
tags: { endpoint: 'search' },
});
check(res, { 'search 200': (r) => r.status === 200 });
warnSlow(res, 'search');
});
BEFORE: LIKE '%keyword%' 기반 검색
먼저 기존 구현이었던 LIKE '%keyword%' 기반 검색에 대해 같은 시나리오로 돌려봤다.

- http_req_duration{endpoint:search}
- 평균(avg): 약 4.03s
- p95: 약 10.34s
검색 한 번에 10초를 넘기는 구간이 적지 않게 존재했고,
사실상 “검색 버튼을 누르면 한참 기다려야 하는 서비스”에 가까운 상태였다.
AFTER: ngram+ FULLTEXT 혼합 검색
이후, 본문에서 설명한 것처럼
- 최근 5일치: n그램 FULLTEXT 인덱스
- 전 기간: 기본 FULLTEXT 인덱스
를 함께 사용하는 검색으로 교체한 뒤, 똑같은 조건으로 다시 부하 테스트를 돌렸다.

- http_req_duration{endpoint:search}
- 평균(avg): 약 698ms
- p95: 약 1.9s
정리하면,
- 평균 응답 시간: 4.03s → 0.69s (약 5.8배 개선)
- p95 기준: 10.34s → 1.9s (약 5.4배 개선)
으로, 단순 LIKE 검색에 비해 5배 이상 빨라진 것을 확인할 수 있었다.
아직 k6 threshold로 잡아둔 목표치(p95 < 350ms, p99 < 600ms)를 완전히 만족시키지는 못하지만,
t4g.micro + 384MB 버퍼 풀이라는 제약 안에서
“부분 검색 + 공백이 섞인 여러 단어 검색” 경험을 유지하면서
“테이블 풀 스캔 없이 인덱스 기반으로 동작하는 검색” 을 만들었다는 점에서 의미 있는 개선이라고 생각한다.
⚒️ 수정된 설계
이 글을 초안까지 다 쓰고, 잠들기 전에 친구랑 통화를 하면서 내가 짠 설계를 쭉 설명했다.
말로 풀어서 설명하다 보니, 듣는 사람보다 오히려 내가 더 많이 깨닫는 느낌이었다.
처음엔 “유저 500명 정도”를 가정하고 설계를 짰다.
하지만 전화를 끊고 나니 이런 생각이 들었다.
“근데… 정말 500명에서 끝날까?”
“유저가 5,000명, 1만 명이 되면 그때도 이 구조가 버틸까?”
냉정하게 말하면, 지금 구조로는 버틸 수 없다.
나는 항상 오버엔지니어링을 기피해 왔고,
유저 증가 속도를 보면서 대략 6개월 정도를 내다보고 설계를 했지만,
곰곰이 따져 보니 허점이 꽤 많아 보였다.
유저가 많아질수록 아티클은 기하급수적으로 쌓이고, 그때마다 인덱스 용량도 같이 커진다.
기본 FULLTEXT 인덱스는 Ngram FULLTEXT에 비해 훨씬 가볍긴 하지만,
그래도 10만 개, 100만 개, 1,000만 개까지 쌓인다고 생각하면
“이 정도면 괜찮겠지” 하고 넘길 수 있는 수준은 아니다.
메일이 새로 오거나 삭제될 때마다 인덱스도 같이 수정해야 한다.
데이터가 커질수록 이 비용은 눈덩이처럼 커진다.
문득, 이걸 기술로만 버티게 만드는 건 너무 근시안적인 선택이 아닐까 하는 생각이 들었다.
그래서 관점을 살짝 바꿔 보기로 했다.
“이걸 DB 튜닝으로만 버틸 게 아니라,
애초에 ‘우리가 어떤 서비스를 만들고 싶은지’에서부터 답을 찾을 수는 없을까?”
이때 예전에 팀원들끼리 했던 대화만 하고 그냥 넘어갔던게 떠올랐다.
그 당시 이런 이야기를 했던 기억이 있다.
“개인이 메일을 무제한으로 받아야 할까?”
“어느 정도에서 잘라주는 게 맞을까?”
네이버 메일처럼 일반 메일 서비스는 용량 단위로 제한을 둔다.
하지만 봄봄은 일반 메일이 아니라 “뉴스레터만 모으는 서비스”라서,
용량보다 “몇 개까지 보관해 줄지”가 훨씬 직관적이라고 느꼈다.
게다가 우리는 아직 수익도 발생하지 않고,
언제부터 어떤 방식으로 돈을 벌게 될지도 명확하지 않은 프로젝트다.
그런 상황에서 “무제한 보관”을 약속해 버리면
검색 이전에, 저장 자체가 비용 폭탄이 될 수밖에 없다.
그래서 이 문제는 “DB 성능 문제”이기 전에
“서비스가 어디까지를 책임질 것인지”에 대한 비즈니스 결정이라고 보기로 했다.
결국, 일정 개수에서 잘라야 한다는 결론에 도달했다.
그렇다면 몇 개가 적당할까?
뉴스레터를 실제로 사용해보며 대략적인 평균치를 잡아봤다.
한 사용자가 하루에 받는 뉴스레터를 4개 정도로 가정하면
한 달이면 120개, 4개월이면 약 480개다.
대부분의 사용자는 4개월 치 뉴스레터를 모두 다시 찾아볼 일은 거의 없다.
그래서 “4개월 치를 넉넉히 보관해 주는 정도면 초반엔 충분하겠다”는 기준을 세웠다.
4개월이면 충분히 습관이 형성되고 계속 우리 서비스를 이용할 것이라 판단했다.
여기서 500개라는 숫자가 나왔다.
너무 낮게 잡으면 처음부터 불편하고,
너무 높게 잡으면 나중에 줄여야 할 수도 있는데,
서비스 입장에서 “용량을 줄이는 경험”은 꽤나 최악의 경험이다.
반대로, 처음엔 보수적으로 잡아두고
서비스가 성장하면 “이제 1,000개까지 보관해 드릴게요”라고 올려주는 쪽이
훨씬 좋은 사용자 경험이라고 판단했다.
그래서 1유저 = 최대 500개 뉴스레터 보관이라는 정책을 정했다.
대략 아래와 같은 계산도 같이 했다.
한 뉴스레터를 평균 25KB 정도로 잡으면,
1유저 저장 용량 ≒ 25KB × 500개 ≒ 12.2MB
1,000명일 때 ≒ 12GB
5,000명일 때 ≒ 60GB
1만 명일 때 ≒ 120GB
대략 이 정도면 지금 인프라와 비용 구조 안에서
“감당 가능한 선”이라고 판단했다.
이렇게 해서 나온 “사용자당 500개” 정책은
단순히 DB를 살리기 위한 기술적 꼼수가 아니라,
우리가 감당할 수 있는 비용과
사용자에게 약속할 수 있는 서비스 범위를 함께 고려한
비즈니스적인 결정에 가깝다.
그리고 이 결정은 바로 검색 설계에도 영향을 줬다.
이 조건이 들어오자 전제가 다시 한 번 달라졌다.
memberId 인덱스를 타고 조회하면, 한 사용자 기준으로는 어차피 최대 500개까지만 남는다.
그렇다면 이 500개에 대해서는 LIKE '%keyword%'로 풀스캔을 하더라도,
예전에 전체 article 테이블을 대상으로 LIKE를 날렸을 때와는
성능 특성이 완전히 다르다고 볼 수 있었다.

그래서 먼저, 가장 단순한 구조부터 실험해 보기로 했다.
memberId로 범위를 500개 이내로 좁힌 뒤,
그 안에서 LIKE '%keyword%'로만 검색하는 방식이다.
이 구조로 k6 부하 테스트를 돌려 보았다.
가상 유저를 최대 100명까지 올리고 약 7,300건 정도의 검색 요청을 보냈을 때,

평균 응답 시간은 대략 800ms,
중앙값은 220ms 안쪽,
p95(95퍼센트 지점)는 약 1.6초 정도가 나왔다.
예전처럼 전체 article 테이블에 LIKE '%keyword%'를 날렸을 때
p95가 10초를 넘기던 것을 생각하면,
이 정도면 더 이상 “망했다” 수준의 수치는 아니었다.
게다가 실제 서비스에서 검색은 동시성이 아주 높지도 않고,
전체 트래픽에서 차지하는 비중도 크지 않다.
실제 사용자 환경에서는 이보다 더 여유 있는 숫자가 나오리라 기대할 수 있었다.
여기까지 보면 “그냥 LIKE만 써도 되겠다”는 결론을 내려도 이상할 건 없다.
하지만 처음 설계를 시작할 때 가졌던 생각은 여전히 같았다.
검색은 자주 쓰이지는 않더라도,
막상 사용했을 때는 “제대로” 동작해야 한다는 것이다.
그러기 위해서는 앞에서 이야기한 것처럼
어느 정도 이상의 부분 검색 품질을 유지해야 했고,
줄일 수 있는 속도는 최대한 줄이고 싶었다.
그래서 한 걸음만 더 나아가 보기로 했다.
최신 5일 이내의 아티클은 Ngram 기반 FULLTEXT 인덱스로 검색하고,
그 이후(5일 이전) 아티클은 memberId로 최대 500개까지 좁힌 뒤
LIKE '%keyword%'로 검색하는 혼합 구조를 다시 만들었다.
이때 explain으로 실제 실행 계획을 확인해 가며
인덱스를 타는 조건과 정렬 조건을 계속 바꿔보면서 쿼리 튜닝도 함께 진행했다.
구조를 바꾼 뒤, 동일하게 k6로 부하 테스트를 한 번 더 돌렸다.
마찬가지로 가상 유저를 최대 100명까지 올리고,
약 8,900건 정도의 검색 요청을 보냈을 때

http_req_duration 기준 평균은 약 260ms,
중앙값은 약 85ms,
p90은 약 510ms,
p95는 약 740ms가 나왔다.
LIKE만 썼을 때와 비교하면,
평균은 800ms에서 260ms 수준으로,
중앙값은 220ms에서 85ms로,
p95도 1.6초에서 0.74초 정도로 줄었다.
즉 “사용자당 500개 제한 + 최신 5일 Ngram + 나머지 LIKE” 구조가
단순히 LIKE만 쓰는 구조보다 응답 시간이 전반적으로 훨씬 안정적이고,
꼬리 구간을 제외한 대부분 요청에서 체감 속도를 꽤 끌어올려 주었다.
결국 지금 구조는
당장 눈앞의 성능 문제만 보는 것도 아니고,
그렇다고 먼 미래의 막연한 트래픽을 위해 과하게 투자하는 것도 아닌,
그 중간 어딘가에서 찾아낸 타협점에 가깝다.
서비스가 더 커지고, 검색이 정말 비즈니스의 중심 기능이 되는 순간이 온다면
아마 또 한 번 설계를 갈아엎게 될 것이다.
예를 들어 “최신 1년만 풀텍스트 인덱싱”,
“기간 선택을 더 적극적으로 강제”,
혹은 “전용 검색 엔진 도입” 같은 선택지를
다시 꺼내 들게 될지도 모른다.
하지만 적어도 지금 시점에서 이 선택은,
서비스의 성장 가능성과 현재 인프라의 한계를 동시에 바라보며
여러 밤을 고민하고, 실제 부하 테스트 결과까지 확인한 뒤에 내린 결정이라는 점이 중요하다고 생각한다.
지금 이 글에 적힌 설계는 그런 고민의 흔적을 기록해 둔 하나의 스냅샷에 가깝다.
📔 정리하며
처음에는 LIKE '%keyword%' 하나로 부분 검색과 공백이 섞인 검색을 모두 해결해 보려 했다가
인덱스를 타지 못하면서 p95가 6초를 넘어가는 검색을 얻었다.
그다음에는 캐시를 얹을지, 별도 검색 엔진(Meilisearch, Typesense)을 쓸지,
MySQL 플러그인을 붙일 수 있을지 등 여러 후보를 검토했다.
하지만 RDS MySQL(t4g.micro, 1GB RAM)이라는 인프라 제약과
검색이 “매일 수백 번 쓰이는 메인 기능”은 아니라는 서비스 특성을 함께 놓고 봤을 때
지금 단계에서 전용 검색 엔진을 도입하는 건 과투자라고 판단했다.
그래서 “주어진 MySQL 안에서, 부분 검색과 공백이 섞인 검색 경험을 최대한 유지하면서
LIKE보다 훨씬 빠르게 만들 수 있는 방법”을 찾는 쪽으로 방향을 틀었고,
그 결과가 “최근 5일 Ngram + 이후 LIKE '%keyword%'” 혼합 전략이었다.
부하 테스트 기준으로 보면,
p95 기준 10.34초에서 1.9초,
평균 응답 시간도 4초대에서 0.6~0.7초대로 줄었다.
“검색 버튼을 누르고 기다리기 싫은 서비스”에서
적어도 “한 번쯤은 써볼 만한 검색” 정도까지는 끌어올렸다고 느낀다.
아직 완벽과는 거리가 멀다.
Ngram 기간(5일)을 3일이나 7일로 바꿔 본다든지,
쿼리 튜닝, 인덱스 재설계, 인스턴스 스펙 업그레이드 후 재측정 같은 실험 여지는 여전히 많다.
그래도 이번에 느낀 건,
검색은 멋진 엔진을 쓰는지보다
우리 서비스와 인프라가 감당할 수 있는 선에서 어떤 타협을 했는지가 더 중요하다는 점이었다.
언젠가 정말 검색이 비즈니스의 한가운데로 들어오는 시점이 오면,
그때는 전용 검색 엔진을 도입하는 글을 또 한 편 쓰게 되지 않을까 싶다. 😊
📚 Reference
'서비스 운영 일지 > 봄봄' 카테고리의 다른 글
| 봄봄에서 서드파티 라이브러리를 대하는 방법 (1) | 2025.11.22 |
|---|---|
| 저장이 왜 안되는 거지? (0) | 2025.11.15 |