LIKE '%검색%'을 버리고 OpenSearch를 뜯어봤다
RDB의 LIKE 검색은 왜 한계에 부딪히는가. 역색인, Analyzer, BM25, dis_max까지 — OpenSearch가 검색어 하나를 처리하는 전체 흐름을 백엔드 개발자의 눈으로 따라간다.
회사 코드를 노출할 수 없으니, 이 글에서는 예시를 모두 이해하기 쉬운 도메인으로 변경했습니다.
실무에서 검색 시스템에 OpenSearch를 적용하면서 내부 동작을 공부하고 있다. 처음에는 "검색 빠르게 해주는 DB 아닌가?" 정도로 생각했다. 아니었다.
결론부터 말하면 — OpenSearch는 빠른 DB가 아니라, 검색을 위해 데이터를 미리 분석해서 역색인(inverted index)을 만들어두는 검색 엔진이다. 이 한 문장을 이해하는 데 꽤 오래 걸렸고, 이해하고 나니 mapping, analyzer, BM25, boost 같은 개념들이 전부 한 줄로 꿰어졌다.
이 글은 그 흐름을 처음부터 따라간다. MySQL 같은 RDB는 써봤지만 검색 엔진의 원리는 잘 모르는 백엔드 개발자를 대상으로 한다.
1. 시작은 LIKE 검색이다
검 색 기능을 처음 만들 때 대부분 이렇게 시작한다.
SELECT *FROM documentsWHERE title LIKE '%개발 요청%' OR creator_name LIKE '%개발 요청%' OR description LIKE '%개발 요청%';데이터가 적을 때는 잘 돌아간다. 문제는 데이터가 쌓이면서 시작된다.
첫째, 느리다. LIKE '%keyword%'처럼 앞에 와일드카드가 붙으면 B-Tree 인덱스를 탈 수 없다. B-Tree는 값의 앞부분부터 정렬해서 찾아가는 구조인데, "중간에 이 문자열이 포함된 row"는 정렬 순서로 찾을 방법이 없다. 결국 풀 스캔이다. row가 백만 개면 백만 번 문자열 비교를 한다. (B-Tree가 왜 그렇게 동작하는지는 Real MySQL 8.0 정리 (1)에 정리해뒀다.)
둘째, 검색 품질이 없다. LIKE는 포함/미포함만 판단한다.
- "개발요청"으로 검색하면 "개발 요청"이 안 나온다. 띄어쓰기 하나에 실패한다.
- "요청 개발"처럼 어순이 바뀌어도 실패한다.
- 결과가 100건 나오면 어떤 게 더 관련 있는 문서인지 순위를 매길 수 없다.
성능은 인덱스 튜닝으로 어떻게든 버틴다 쳐도, 품질 문제는 RDB 구조 안에서 해결이 안 된다. 애초에 RDB는 "정확한 값을 저장하고 꺼내는 것"에 최적화된 시스템이지, "사람이 입력한 애매한 검색어에 가장 관련 있는 문서를 찾아주는 것"을 위해 설계되지 않았다.
OpenSearch 는 이 문제를 정면으로 푼다. 데이터를 저장하는 시점에 미리 검색을 위한 형태로 분석해두는 것이다. 그 구조를 하나씩 따라가 보자.
2. Document와 Field
OpenSearch에서 데이터의 단위는 document다. RDB의 row에 대응한다.
{ "id": 1, "title": "개발 요청서", "creator_name": "김태호", "description": "검색 시스템 개발 요청"}| RDB | OpenSearch |
|---|---|
| Table | Index |
| Row | Document |
| Column | Field |
여기서 하나 짚고 갈 것이 있다. 검색은 document 전체를 하나의 문자열로 뭉쳐서 훑는 게 아니다. 검색 대상으로 지정된 각 field에 대해 쿼리가 수행된다. "title에서 찾을 것인가, description에서 찾을 것인가, 둘 다인가"가 검색 쿼리의 일부라는 뜻이다.
이게 왜 중요하냐면, 뒤에서 나올 boost("title 매칭은 description 매칭보다 5배 중요하다")나 multi-field 같은 개념이 전부 "field 단위로 검색한다"는 전제 위에 서 있기 때문이다.
3. Mapping — field를 어떻게 저장하고 검색할 것인가
RDB에 스키마가 있듯 OpenSearch에는 mapping이 있다. 각 field를 어떤 타입으로 저장하고, 어떻게 검색 가능하게 만들지 정의한다.
{ "mappings": { "properties": { "title": { "type": "text" }, "id": { "type": "long" } } }}여기서 가장 중요한 구분이 text와 keyword다.
- text — analyzer를 거쳐 잘게 쪼개진다. 전문 검색(full-text search)용이다.
- keyword — 분석하지 않고 원본 값 그대로 저장한다. 완전 일치(exact match), 정렬, 집계(aggregation)용이다.
"개발 요청서"라는 값이 text field에 들어가면 개발, 요청서 같은 조각으로 쪼개져서 색인되고, keyword field에 들어가면 "개발 요청서" 통째로 하나의 값으로 색인된다.
그래서 같은 검색어라도 결과가 다르다. keyword field에 "개발"로 검색하면 아무것도 안 나온다. 저장된 값은 "개발 요청서"라는 통짜 문자열 하나뿐이고, "개발"과 완전 일치하지 않기 때문이다.
4. Analyzer — 문자열이 token이 되기까지
text field의 핵심이 analyzer다. 다음 문자열이 저장된다고 하자.
검색 시스템 개발 요청이 문자열은 색인되기 전에 analyzer를 통과한다. analyzer는 세 단계 파이프라인이다.
이 조각 하나하나가 token이다. 검색은 이 token 단위로 이루어진다.
언어마다 쓰는 analyzer가 다르다. 영어는 공백 기준으로 자르고 소문자화하는 standard analyzer로도 어느 정도 되지만, 한국어는 그렇게 안 된다. "검색합니다"를 공백으로 자르면 검색합니다 통짜 token이 되고, "검색"으로는 못 찾는다. 그래서 한국어에는 Nori 같은 형태소 분석기를 쓴다. Nori는 "검색합니다"를 검색 + 하 + ㅂ니다로 분해해서 어간을 뽑아낸다.
여기서 헷갈리기 쉬운 것 하나. 분석된 token은 검색용 색인에만 쓰인다. 원본 document는 _source에 그대로 보존된다. 검색 결과로 받는 JSON이 멀쩡한 원본인 이유다. 색인과 원본은 목적이 다르다 — 색인은 찾기 위한 자료구조고, _source는 보여주기 위한 데이터다.
5. Multi-field — 하나의 값, 여러 개의 색인
그런데 실무에서는 하나의 field를 한 가지 방식으로만 검색하지 않는다. title 하나를 놓고도 요구사항이 이렇게 갈라진다.
- 완전 일치로 찾고 싶다
- 한국어 형태소로 찾고 싶다
- 영어가 섞여 있으면 영어로도 찾고 싶다
ㄱㅂㅇㅊ같은 초성으로도 찾고 싶다- "발 요청"처럼 단어 중간 부분 문자열로도 찾고 싶다
analyzer는 field당 하나다. 그럼 어떻게 하나? 같은 원본 값을 여러 개의 subfield로 각각 다르게 색인한다. 이게 multi-field다.
title (원본: "개발 요청서")├── title.keyword → "개발 요청서" (완전 일치)├── title.korean → [개발, 요청서] (Nori 형태소)├── title.english → [...] (영어 analyzer)├── title.chosung → [ㄱㅂ ㅇㅊㅅ] (초성 변환)├── title.korean_ngram → [개발, 발요, 요청, ...] (부분 문자열)└── title.english_ngram → [...]document를 넣는 쪽에서는 title: "개발 요청서" 하나만 넣는다. OpenSearch가 색인 시점에 이 값을 subfield 개수만큼 각각의 analyzer에 통과시켜 따로따로 색인한다.
저장 공간을 더 쓰는 대신 검색의 자유도를 산다. "어떤 검색을 지원할 것인가"를 색인 설계 시점에 미리 결정해야 한다는 뜻이기도 하다. 이게 RDB와 가장 크게 다른 사고방식이었다. RDB는 일단 저장해두고 쿼리로 어떻게든 해보지만, 검색 엔진은 색인을 설계하는 순간 검색 능력이 결정된다.
6. 역색인 — 검색 엔진 성능의 핵심
이제 이 글에서 가장 중요한 자료구조가 나온다. token들은 어디에 어떻게 저장되는가?
RDB의 LIKE 검색은 모든 document를 순회하면서 내용에 keyword가 포함되어 있는지 하나씩 검사한다. document가 늘어날수록 선형으로 느려진다. 검색 엔진은 방향을 뒤집는다 — 색인 시점에 "token → 그 token을 가진 document 목록" 표를 만들어 둔다.
이게 역색인(inverted index)이다. "문서 → 단어들"이 아니라 "단어 → 문서들"이라서 역(inverted)이다. 책 맨 뒤의 찾아보기(index)와 정확히 같은 구조다. "트랜잭션이 나오는 페이지"를 찾으려고 책을 첫 장부터 읽는 사람은 없다. 찾아보기에서 "트랜잭션 → p.132, p.201"을 보고 바로 펼친다.
"개발"을 검색하면 모든 document를 읽는 게 아니라 역색인에서 개발 항목 하나를 찾아 [doc1, doc3, doc7]을 바로 얻는다. document가 백만 개든 천만 개든, 후보를 찾는 비용은 거의 변하지 않는다.
앞의 모든 개념이 여기로 수렴한다. analyzer가 필요한 이유는 역색인의 key인 token을 만들기 위해서고, multi-field는 하나의 값으로 여러 벌의 역색인을 만드는 것이다.
7. 검색어도 분석된다
여기서 자연스러운 의문이 생긴다. 역색인의 key는 token인데, 사용자가 입력한 검색어 "개발 요청"은 문장이다. 이걸 어떻게 매칭하나?
답은 단순하다. 검색어도 같은 analyzer를 통과한다.
검색어: "개발 요청" ↓ analyzer["개발", "요청"] ↓역색인 조회: 개발 → [doc1, doc3, doc7] 요청 → [doc1, doc4, doc7]색인할 때와 검색할 때, 같은 분석기로 같은 형태의 token을 만들어야 매칭이 성립한다. 색인은 Nori로 하고 검색어는 standard로 분석하면 token이 어긋나서 검색이 안 된다. 실무에서 "분명 데이터가 있는데 검색이 안 돼요"의 상당수가 이 불일치다.
전체 흐름을 그리면 이렇다. 이 글에서 이 그림 하나만 기억해도 된다.
색인과 검색이 같은 파이프라인의 양 끝에서 만난다. OpenSearch의 거의 모든 개념은 이 그림 어딘가에 꽂힌다.
8. match와 multi_match
이제 실제 쿼리를 보자. 한 field를 검색하는 가장 기본 쿼리가 match다.
{ "query": { "match": { "title": "개발 요청" } }}이 쿼리가 하는 일이 정확히 7장의 Search time 흐름이다. "개발 요청"을 title field의 analyzer로 분석하고, 나온 token들로 역색인을 조회한다.
여러 field를 한 번에 검색하려면 multi_match를 쓴다.
{ "query": { "multi_match": { "query": "개발 요청", "fields": ["title", "uid", "creator_name", "description"] } }}글 맨 앞의 LIKE ... OR LIKE ... OR LIKE ... SQL과 겉모습은 같은 일을 한다. 하지만 내부는 다르다. field별로 각각 역색인을 조회하고, field별 매칭 결과를 종합해서 document마다 relevance score를 계산한다.
이 "종합하는 방식"에 type이 있다. 대표적인 둘만 보면:
- best_fields (기본값) — 여러 field 중 가장 잘 맞은 field 하나의 점수를 중심으로 평가한다. "개발 요청"이 title에 통째로 매칭된 문서가, 여러 field에 조각조각 걸친 문서보다 높게 나온다.
- cross_fields — 여러 field를 하나의 논리적인 검색 공간처럼 취급한다. 검색어 token들이 field에 나뉘어 있어도 좋게 평가한다. "김태호 개발"처럼 creator_name의 token과 title의 token이 섞인 검색어에 맞다.
9. BM25 — 점수는 어디서 오는가
방금부터 계속 score라는 말이 나왔다. OpenSearch는 포함/미포함만 판단하지 않는다. 매칭된 document마다 얼마나 관련 있는지를 점수(_score)로 계산하고, 그 순서로 결과를 정렬한다. LIKE 검색에는 없던 능력이다.
기본 알고리즘이 BM25다. 수식은 생략하고 직관만 잡자. 점수가 올라가는 대표적인 이유는 이렇다.
- 검색어 token이 document에 등장한다 — 기본 전제.
- 그 token이 전체 문서에서 희귀하다 — "개발"은 개발 문서 시스템에 수만 번 나오니 변별력이 없다. "김태호"는 몇 건 안 나오니 매칭되면 강한 신호다. 희귀한 token일수록 점수가 크다. (IDF — 역문서빈도)
- 그 document 안에서 token이 자주 등장한다 — 같은 "검색"이라도 한 번 스친 문서보다 여러 번 다룬 문서가 높다. 단, 무한히 오르지 않고 포화된다. 100번 나온다고 10번 나온 것의 10배가 되진 않는다. (TF 포화)
- field가 짧다 — 10단어짜리 title에서 매칭된 것과 1,000단어짜리 description에서 매칭된 것은 밀도가 다르다. 짧은 field에서의 매칭이 보정을 받는다.
- boost가 걸려 있다 — 바로 다음 장.
정리하면 BM25는 "이 단어가 이 문서를 얼마나 특징짓는가"를 수치화한다. 이 수치가 있어야 결과 100건 중 무엇을 맨 위에 놓을지 결정할 수 있다. 검색 엔진이 '조회'가 아니라 '순위 매기기' 시스템인 이유다.