Elasticsearch 핵심 정리 — 검색은 어떻게 빨라지는가
RDB의 LIKE '%키워드%'로는 한계가 명확하다. 풀스캔, 형태소 무시, 오타 취약, 연관도 없음. Elasticsearch가 이 문제를 어떻게 푸는지 — 클러스터 구조부터 역인덱스, 애널라이저, 한글 검색, 쿼리 DSL, 실전 프로젝트까지 핵심만 정리한다.
Search Engine · Elasticsearch. RDB의 LIKE '%키워드%'로는 한계가 명확하다. 풀스캔, 형태소 무시, 오타 취약, 연관도 없음. Elasticsearch가 이 문제를 어떻게 푸는지 — 클러스터 구조부터 역인덱스, 애널라이저, 한글 검색, 쿼리 DSL, 실전 프로젝트까지 핵심만 정리한다.
기준 — Elasticsearch 7.x · Spring Data Elasticsearch.
목차
- Elasticsearch란 — 어디에 쓰나
- 클러스터 구조 — 노드 · 샤드 · 레플리카
- 데이터 구조 — 인덱스 / 도큐먼트 / 필드
- 역인덱스 — 검색이 빠른 진짜 이유
- 애널라이저 — 텍스트가 토큰이 되는 과정
- 한글 검색 — Nori Analyzer
- 매핑 — 데이터 타입과 text / keyword
- 쿼리 DSL — 자주 쓰는 검색들
- 실무 부가 기능 — 하이라이팅 · 정렬 · 자동완성
- 프로젝트 — 상품 검색 기능 구현 흐름
- 운영 — Spring Boot 연동과 Elastic Cloud
01 · Elasticsearch란 — 어디에 쓰나
Elasticsearch는 Apache Lucene 위에 만들어진 분산 검색·분석 엔진이다. JSON 도큐먼트를 저장하고, REST API로 검색한다. 단순 저장소가 아니라 "대량 텍스트를 빠르게 검색하고 집계하는 것"에 특화돼 있다.
왜 RDB로는 부족한가
- 풀스캔 —
LIKE '%키워드%'는 인덱스를 못 타고 모든 행을 훑는다. 데이터가 늘수록 느려진다. - 형태소 무시 — "가을이"로 색인된 값을 "가을"로 못 찾는다. 단어를 쪼개는 개념이 없다.
- 연관도 없음 — "얼마나 잘 맞는지" 점수가 없다. 검색 결과를 관련도순으로 줄 수 없다.
- 확장 한계 — 대량 텍스트 검색을 수평 확장으로 분산하기 어렵다.
주요 활용 사례
- 전문 검색 — 상품 검색, 문서·게시글 검색, 자동완성. 쿠팡·깃허브 코드 검색 같은 곳.
- 로그·메트릭 분석 — 서버 로그를 쌓고 실시간으로 집계·모니터링. 가장 흔한 쓰임새.
- ELK 스택 — Elasticsearch(저장·검색) + Logstash / Beats(수집) + Kibana(시각화). 로그 분석의 사실상 표준 조합.
실습·관리 도구로는 Kibana의 Dev Tools를 쓴다. REST API를 GUI에서 바로 날려보고 결과를 확인할 수 있어 학습·디버깅이 편하다.
02 · 클러스터 구조 — 노드 · 샤드 · 레플리카
Elasticsearch가 "분산" 엔진인 이유가 여기 있다. 데이터를 조각내 여러 서버에 흩어 두고, 검색을 병렬로 처리한다. 네 가지 용어만 잡으면 된다.
- 클러스터(Cluster) — 함께 동작하는 노드들의 묶음. 하나의 검색 시스템 전체.
- 노드(Node) — Elasticsearch 인스턴스 하나. 보통 서버 한 대.
- 샤드(Shard) — 인덱스를 나눈 조각. 각 조각은 그 자체로 혼자서 검색이 되는 작은 독립 검색 엔진이다(내부적으로 샤드 하나 = Lucene 인덱스 하나). 조각을 여러 노드에 흩어 두고 한 번의 검색을 동시에 처리해 합치므로, 수평 확장과 병렬 검색이 가능하다.
- 레플리카(Replica) — 샤드의 복제본. 원본(프라이머리) 장애에 대비하고, 읽기 부하를 분산한다. 프라이머리와 다른 노드에 둬야 의미가 있다.
핵심 — 샤드 = 확장과 병렬 처리, 레플리카 = 가용성과 읽기 분산. 검색 요청이 오면 여러 샤드에서 동시에 찾고 결과를 합친다. 이게 대용량에서도 빠른 두 번째 이유다(첫 번째는 다음 섹션의 역인덱스).
03 · 데이터 구조 — 인덱스 / 도큐먼트 / 필드
Elasticsearch는 JSON 도큐먼트를 저장하고 검색한다. RDB와 1:1로 대응시키면 구조가 빨리 잡힌다.
- 인덱스(Index) — 같은 성격의 도큐먼트를 모은 단위. RDB의 테이블에 해당한다.
- 도큐먼트(Document) — 저장되는 데이터 한 건. RDB의 행(row), JSON 객체 하나다.
- 필드(Field) — 도큐먼트 안의 key-value. RDB의 컬럼에 해당한다.
- 매핑(Mapping) — 각 필드의 데이터 타입과 분석 방식을 정의한 스키마.
Kibana — REST API를 날리는 콘솔
Elasticsearch는 모든 조작을 HTTP REST API로 한다. Kibana는 그 Elasticsearch에 붙는 웹 UI로, 데이터 시각화·클러스터 관리·검색을 GUI로 할 수 있다. 그중 Dev Tools 메뉴는 API 요청을 직접 입력하고 응답을 확인하는 콘솔이다. curl로 일일이 칠 필요 없이, 자동완성·정렬을 받으며 쿼리를 바로 실행할 수 있어 학습·디버깅에 쓴다.
도큐먼트 단건 CRUD는 ID 기반이다. HTTP 메서드와 엔드포인트의 조합으로 끝난다.
- 생성·색인 —
PUT products/_doc/1· 본문(JSON)을 보내 ID 1번으로 저장. 같은 ID로 다시 보내면 통째로 덮어쓴다. ID를 자동 생성하려면POST products/_doc. - 조회(Read) —
GET products/_doc/1· ID로 단건 조회. 조건으로 여러 건을 찾는 건 ID가 아니라_search로 한다 (아래). - 수정(Update) —
POST products/_update/1·doc안에 바꿀 필드만 보내는 부분 수정. - 삭제(Delete) —
DELETE products/_doc/1· ID로 단건 삭제.
PUT products/_doc/1 — 생성 / 색인
{
"name": "갤럭시 버즈",
"price": 189000,
"tags": ["이어폰", "무선"]
}POST products/_update/1 — 가격만 수정
{
"doc": { "price": 179000 } // 나머지 필드는 그대로
}
// GET products/_doc/1 → 단건 조회
// DELETE products/_doc/1 → 단건 삭제조건으로 다루기 — _search / _delete_by_query
위 네 가지는 모두 ID를 알아야 쓴다. GET /_doc/{id}·DELETE /_doc/{id}는 필드 조건을 받지 못한다. "가격이 20만원 이하인 문서"처럼 조건으로 여러 건을 다룰 땐 별도 엔드포인트를 쓴다.
_search— 조건 조회. 본문query에 검색 조건을 넣는다 (쿼리 종류는 8장에서)._delete_by_query— 조건에 맞는 문서를 일괄 삭제._update_by_query— 조건에 맞는 문서를 일괄 수정.
POST products/_delete_by_query — 조건 삭제
{
"query": { "range": { "price": { "lte": 200000 } } }
}
// GET products/_search 에 같은 query를 넣으면 "조건 조회"
// 운영 데이터는 _search로 대상부터 확인한 뒤 삭제하는 게 안전왜 나눠 둘까 — ID 조회는 어느 샤드에 있는지 바로 계산해 문서를 꺼내는 직접 lookup이라 빠르다. 필드 조건은 역인덱스를 타고 여러 샤드를 검색해야 하므로, 비용 구조가 다른
_search계열로 분리돼 있다.
그 밖에 자주 쓰는 API
_bulk— 색인·수정·삭제를 한 요청에 묶어 대량 처리. 건건이 호출하지 않는다._count— 조건에 맞는 문서 개수만 빠르게 센다._mapping/_settings— 인덱스의 매핑·설정을 조회하거나 정의한다._cat/*—_cat/indices·_cat/health등 클러스터 상태를 사람이 읽기 좋은 표로 보여준다._analyze— 텍스트가 어떤 토큰으로 쪼개지는지 확인한다 (5장)._reindex— 한 인덱스의 데이터를 다른 인덱스로 복사. 매핑·타입 변경 시 쓴다.
위 API 사용 예시 — Kibana Dev Tools
POST _bulk // 대량 색인·삭제 (NDJSON, 한 줄에 하나씩)
{ "index": { "_index": "products", "_id": "1" } }
{ "name": "갤럭시 버즈", "price": 189000 }
{ "delete": { "_index": "products", "_id": "2" } }
GET products/_count // 문서 개수만 빠르게
GET products/_settings // 샤드·레플리카 수 등 인덱스 설정
GET _cat/indices?v // 인덱스 목록을 표로 (?v = 헤더 표시)
POST _reindex // 인덱스 통째로 복사 (매핑 변경 시)
{ "source": { "index": "products" },
"dest": { "index": "products_v2" } }주의 — 매핑은 한 번 정의되면 기존 필드 타입을 변경할 수 없다. 타입을 바꾸려면 새 인덱스를 만들고
_reindex로 옮겨야 한다. 운영 인덱스는 처음 설계가 중요하다.
04 · 역인덱스 — 검색이 빠른 진짜 이유
RDB는 WHERE content LIKE '%검색%'에서 본문을 처음부터 끝까지 훑는다. Elasticsearch는 반대다. 저장 시점에 텍스트를 단어(term)로 쪼개, "이 단어가 어느 도큐먼트에 있는가"를 거꾸로 색인해 둔다. 이게 역인덱스(Inverted Index)다.
- RDB — 검색마다 본문 스캔. 데이터 양에 비례해 느려진다.
- Elasticsearch — 색인 때 미리 단어 분해. 검색은 단어 → 도큐먼트 lookup이라 빠르다.
- 트레이드오프 — 색인 시점에 분석 비용·저장 공간이 든다. "쓰기를 미리 치르고 읽기를 싸게 만든다".
단어 순서가 바뀌어도 검색된다
역인덱스는 단어별로 색인하므로 어순을 따지지 않는다. "맑은 가을"로 검색하든 "가을 맑은"으로 검색하든, 두 단어가 든 도큐먼트를 똑같이 찾아낸다. RDB의 LIKE로는 불가능한 동작이다.
연관도 점수 (_score)
본문에서 키워드를 찾는 전문 검색은 결과를 "맞음/안 맞음"으로만 주지 않는다. 각 문서에 얼마나 잘 맞는지를 나타내는 숫자(_score)를 매기고, 점수 높은 순으로 정렬해서 돌려준다. 즉 검색 결과의 상위 노출이 곧 점수 순이다. (절대값보다, 같은 검색 안에서의 상대적 크기가 의미 있다.)
이 점수를 계산하는 기본 공식이 BM25다(7.x 기본 랭킹 함수). 복잡해 보이지만 보는 건 세 가지뿐이다.
직관 — 점수가 높아지는 조건은 셋. 찾는 단어가 그 문서에 자주 나오고(TF), 전체에서 희귀하며(IDF), 문서가 짧을수록
_score가 올라간다.
- TF (Term Frequency) — 검색어가 그 문서 안에 몇 번 나오는가. 많을수록 관련 높다고 보고 점수↑. 단 무한정 오르진 않는다 — 10번이 5번의 2배 점수는 아니고, 일정 수준에서 완만해진다(saturation).
- IDF (Inverse Document Frequency) — 그 단어가 전체 문서에서 얼마나 희귀한가. 희귀할수록 변별력이 커서 점수↑. "the"는 거의 모든 문서에 있어 기여가 거의 없고, "Elasticsearch"는 드물어 기여가 크다.
- 문서 길이 보정 — 같은 횟수라면 짧은 문서가 더 관련 높다고 본다. 긴 문서는 단어가 많아 우연히 포함될 확률이 높으므로 점수를 깎는다.
예) "무선 이어폰" 검색 → 제목에 "무선 이어폰"이 그대로 들어간 짧은 상품명이, 긴 설명문 어딘가에 "이어폰"만 한 번 있는 문서보다 높은 점수를 받는다. 그리고 "무선"이 흔하고 "이어폰"이 상대적으로 드물다면, 점수는 "이어폰"이 주도한다.
활용 — 기본 정렬이 _score 내림차순이라, 그대로 쓰면 "관련도순"이다. 여기에 "인기 상품·특정 브랜드를 위로" 같은 요구를 얹으려면 점수를 인위적으로 조정한다 — should(매칭 시 가산점), boost(필드·쿼리에 가중치), function_score(평점·좋아요 같은 숫자를 점수에 곱함). 자세한 건 8장에서.
05 · 애널라이저 — 텍스트가 토큰이 되는 과정
애널라이저(Analyzer)는 원본 텍스트를 역인덱스에 들어갈 토큰으로 가공하는 파이프라인이다. 세 단계를 순서대로 거친다.
- Character Filter — 토큰화 전에 원문을 손본다.
html_strip으로<b>태그 제거. - Tokenizer — 텍스트를 토큰으로 자르는 핵심 단계. 정확히 하나만 지정.
- Token Filter — 잘린 토큰을 후처리. 여러 개를 순서대로 적용.
세 단계 모두 부품처럼 골라 조립한다. 개수 규칙만 다르다 — 캐릭터 필터는 0개 이상, 토크나이저는 정확히 1개(필수), 토큰 필터는 0개 이상.
캐릭터 필터 — 자르기 전에 원문 손질
토큰화 이전 단계라, 결과는 아직 토큰이 아니라 다듬어진 문자열이다.
html_strip은 태그 제거, mapping은 지정 문자 치환(& → and), pattern_replace는 정규식 치환(- 제거). 검색에 방해되는 문자를 색인 전에 정리한다.
토크나이저 — 어떻게 자를지 결정
토큰 필터가 아무리 좋아도, 애초에 어떻게 자르느냐(토크나이저)가 검색 범위를 좌우한다.
| 토크나이저 | 자르는 방식 |
|---|---|
| standard | 공백·문장부호 기준. 일반 텍스트의 기본 |
| whitespace | 공백에서만 자른다 (기호·대소문자 유지) |
| lowercase | 문자가 아닌 곳에서 자르고 소문자화 (숫자·기호 버림) |
| keyword | 자르지 않고 입력 전체를 1토큰으로 |
| nori_tokenizer | 한글 형태소 단위 (Nori 플러그인) |
| edge_ngram / ngram | 접두어·글자 조각으로 잘게 쪼갬 (자동완성용) |
같은 입력을 토크나이저별로 어떻게 자르는지. (왼쪽 입력 → 오른쪽 토큰)
토크나이저는 자르기만 할 뿐 소문자화하지 않는다(대소문자 통일은 lowercase 토큰 필터의 몫이다). standard는 하이픈·공백으로 자르고, whitespace는 공백에서만, lowercase는 문자 아닌 것 모두(숫자 2024는 버림)로 자르며 이 토크나이저만 예외적으로 소문자화까지 한다. keyword는 통째로 1토큰. (점선 = 버려진 토큰)
토큰 필터 — 자른 토큰을 다듬기
토큰화가 끝난 뒤 적용된다. 대소문자·어형·동의어 차이를 흡수해 "같은 뜻은 같은 토큰"으로 맞추는 단계다.
| 필터 | 역할 | 언제 쓰나 |
|---|---|---|
| lowercase | 대소문자 통일. "Apple"과 "apple"을 같은 토큰으로 | 거의 항상. 대소문자 구분 없는 검색의 기본 |
| stop | 불용어 제거. 색인 크기↓, 노이즈↓ | 영문 본문 검색 (한글엔 효과 적음) |
| stemmer | 어형을 어간으로 환원. 시제·복수형 흡수 | 영문에서 running/runs를 run으로 함께 잡을 때 |
| synonym | 동의어를 같은 토큰으로 묶음 | 노트북↔랩탑처럼 다른 표현을 함께 검색 |
각 필터를 통과하면 토큰이 이렇게 바뀐다. (왼쪽 입력 → 오른쪽 결과)
stemmer·stop은 토큰을 줄이고, synonym은 토큰을 늘린다(동의어 추가). 그래서 notebook으로 색인된 문서를 "laptop"으로도 찾게 된다.
기본 제공 애널라이저
| 애널라이저 | 동작 | 언제 쓰나 |
|---|---|---|
| standard | 공백·문장부호로 자르고 소문자화. 숫자는 유지 | 기본값. 특별한 요구 없으면 대부분 이걸로 |
| simple | 문자가 아닌 것 모두 기준으로 자르고 소문자화 (숫자·기호 버림) | 숫자·기호가 의미 없는 순수 텍스트 |
| whitespace | 공백으로만 자른다. 소문자화·기호 제거 안 함 (원형 보존) | 대소문자·기호를 그대로 살려야 할 때 |
| stop | simple처럼 자르고 소문자화 + 불용어(a, the, or…) 제거 | 영문에서 흔한 불용어 노이즈를 걷어낼 때 |
| keyword | 자르지 않고 통째로 하나의 토큰. 분석 없이 원문 그대로 | 정확 일치·정렬·집계 (보통 keyword 타입으로 대체) |
같은 문장을 애널라이저별로 넣으면 토큰이 이렇게 갈린다.
standard는 숫자(3)를 남기지만 simple·stop은 버린다. whitespace만 대소문자·기호(&)를 보존한다. stop은 불용어 the까지 뺀다.
기본 애널라이저 = 부품들의 프리셋
위 애널라이저들은 결국 아래 부품 조합을 미리 묶어둔 것이다. 프리셋이 안 맞으면 이 조합을 직접 바꿔 커스텀으로 만든다.
| 애널라이저 | char filter | tokenizer | token filter |
|---|---|---|---|
| standard | 없음 | standard | lowercase |
| simple | 없음 | lowercase | 없음 |
| whitespace | 없음 | whitespace | 없음 |
| stop | 없음 | lowercase | stop |
| keyword | 없음 | keyword | 없음 |
캐릭터 필터는 선택 사항(0개 이상)이라 대부분의 기본 애널라이저는 쓰지 않는다 — 원문을 그대로 토크나이저에 넘긴다. html_strip 같은 게 필요하면 커스텀 애널라이저에서 직접 추가해야 한다. 한편 simple·stop이 숫자를 버린 건 lowercase 토크나이저(문자만 남김) 때문이고, stop이 불용어까지 없앤 건 거기에 stop 토큰 필터가 더해져서다.
중요 — 색인 때 쓴 애널라이저와 검색 때 쓰는 애널라이저가 같아야 결과가 맞는다. 둘 다
lowercase를 거쳐야 "Apple" 검색이 "apple" 색인어와 매칭된다.
애널라이저는 언제·어디서 지정하나
애널라이저는 따로 "지금 분석해" 하고 호출하는 게 아니라, 인덱스를 만들 때 필드 매핑에 박아두는 설정이다. 뒤 7장에서 볼 "analyzer": "nori"가 바로 그것이다. 한 번 지정하면 그 필드의 색인·검색에 자동으로 적용된다.
- 언제 정하나 — 인덱스 생성 시, 필드별 매핑에 지정한다. 명시하지 않으면 기본값
standard가 적용된다(한글은nori를 명시해야 함). - 색인 시점 — 그 필드에 값을 저장하면, 지정된 애널라이저가 자동으로 토큰을 쪼개 역인덱스에 넣는다.
- 검색 시점 — 같은 필드를 검색하면 같은 애널라이저로 검색어도 분석한 뒤 역인덱스와 대조한다. (그래서 색인용·검색용이 같아야 한다)
흐름은 인덱스 생성(매핑에 애널라이저 지정) → 색인(자동 분석) → 검색(같은 분석 후 대조). standard·nori 같은 기본 제공 애널라이저는 매핑에 이름만 쓰면 되고, 필터를 직접 조합한 커스텀 애널라이저는 settings.analysis에서 만들어 이름을 붙인 뒤 매핑에서 참조한다.
PUT products — 부품별 정의 → 애널라이저로 묶기 → 필드에 적용
{
"settings": { "analysis": {
"char_filter": { // ① 캐릭터 필터 정의
"amp_to_and": { "type": "mapping", "mappings": ["& => and"] }
},
"tokenizer": { // ② 토크나이저 정의
"my_edge": { "type": "edge_ngram", "min_gram": 1, "max_gram": 10 }
},
"filter": { // ③ 토큰 필터 정의
"my_syn": { "type": "synonym", "synonyms": ["노트북, 랩탑"] }
},
"analyzer": { // ④ 위 부품을 묶어 애널라이저 정의
"my_analyzer": {
"type": "custom",
"char_filter": ["html_strip", "amp_to_and"], // 내장 + 커스텀 혼용
"tokenizer": "my_edge",
"filter": ["lowercase", "my_syn"]
}
}
} },
"mappings": { "properties": { // ⑤ 필드에 애널라이저 적용
"name": { "type": "text", "analyzer": "my_analyzer" }
} }
}html_strip·lowercase처럼 내장 부품은 이름만 쓰면 되고, 설정이 필요한 커스텀 부품(amp_to_and·my_edge·my_syn)은 settings.analysis에서 먼저 정의한 뒤 애널라이저의 char_filter·tokenizer·filter에서 이름으로 참조한다. 애널라이저 자체는 mappings에서 필드에 붙인다. (위 조합은 각 부품의 위치를 보여주기 위한 예시다)
토큰이 실제로 어떻게 쪼개지는지는 _analyze API로 확인한다. 매핑 잡기 전에 반드시 찍어보는 습관이 좋다.
POST _analyze
{
"analyzer": "standard",
"text": "The Quick Brown Foxes"
}
// → [the, quick, brown, foxes] (소문자화 + 공백 분리)06 · 한글 검색 — Nori Analyzer
standard 애널라이저는 공백 기준으로만 자른다. 영어는 단어가 공백으로 나뉘니 잘 되지만, 한글은 조사가 단어에 붙어 다녀서 제대로 검색되지 않는다. "가을이"를 통째로 색인하면 "가을"로 검색해도 안 잡힌다.
- Nori — Elasticsearch 공식 한글 형태소 분석 플러그인. 명사·동사 어간을 뽑고 조사·어미를 분리.
- 설치 —
analysis-nori플러그인을 별도 설치. (Elastic Cloud·AWS OpenSearch는 기본 제공) - 한·영 혼용 — 한 필드에 한글·영어가 섞이면, 멀티 필드나 적절한 애널라이저 조합으로 양쪽 다 검색되게 설계.
nori도 부품들의 프리셋
nori 역시 한글용 부품을 미리 묶은 것이다. 5장 프리셋 표와 같은 구조다.
| 애널라이저 | char filter | tokenizer | token filter |
|---|---|---|---|
| nori | 없음 | nori_tokenizer | nori_part_of_speech, nori_readingform, lowercase |
각 부품이 하는 일. (왼쪽 입력 → 오른쪽 결과)
nori_tokenizer가 형태소로 쪼개면(조사·어미까지 토큰으로), nori_part_of_speech가 조사(이)·어미(었·다)를 버린다. nori_readingform은 한자를 한글 발음으로, lowercase는 섞인 영문을 소문자로 통일한다. 조사를 남기거나 복합명사 분해 방식(decompound_mode)을 바꾸고 싶으면 이 부품들을 커스텀으로 조합한다.
07 · 매핑 — 데이터 타입과 text / keyword
매핑은 필드별 데이터 타입을 정의한다. 타입에 따라 분석 여부·검색 방식·정렬 가능 여부가 갈린다.
주요 데이터 타입
| 타입 | 용도 |
|---|---|
| text | 전문 검색용 문자열. 애널라이저로 분석됨 (본문·상품명) |
| keyword | 정확 일치·정렬·집계용 문자열. 분석 안 함 (상태·카테고리·이메일) |
| long / integer | 정수 |
| double / float | 실수 |
| date | 날짜·시간. range 쿼리·정렬에 사용 |
| boolean | true / false |
| object | 중첩 JSON 객체 (내부적으로 평탄화) |
| nested | 배열 안 객체를 독립적으로 검색해야 할 때 |
text와 keyword는 다르다
가장 자주 하는 실수가 이 둘의 혼동이다. 같은 문자열도 타입에 따라 토큰이 완전히 달라진다.
- text — 분석한다. 일부 단어로 찾는 전문 검색용.
- keyword — 분석 안 한다. 정확 일치·정렬·집계·필터용.
- Multi Field — 한 필드에 두 타입 동시.
name은 text로 검색,name.keyword로 정렬·집계. - 매핑 특징 —
null허용, 별도 array 타입 없이 모든 필드가 배열을 받는다.
PUT products/_mapping — multi field
{
"properties": {
"name": {
"type": "text",
"analyzer": "nori",
"fields": {
"keyword": { "type": "keyword" } // name.keyword
}
}
}
}08 · 쿼리 DSL — 자주 쓰는 검색들
실무 검색의 90%는 아래 조합으로 끝난다. 먼저 "분석 여부"로 두 갈래를 나누는 게 출발점이다.
- match — 검색어를 애널라이저로 분석한 뒤 매칭. 전문 검색 기본. text 필드용.
- term / terms — 검색어를 분석 없이 정확히 일치. keyword·숫자·상태값용.
흔한 함정 — text 필드에
term으로 "Samsung Galaxy"를 넣으면 결과가 안 나온다. text는 소문자 토큰(samsung,galaxy)으로 색인됐는데 term은 원문 그대로 비교하기 때문. text엔 match, keyword엔 term이 기본 원칙.
bool — 조건 조합의 핵심
GET products/_search — bool 조합
{ "query": { "bool": {
"must": [{ "match": { "name": "무선 이어폰" } }],
"filter": [{ "range": { "price": { "lte": 200000 } } }],
"must_not":[{ "term": { "status": "품절" } }],
"should": [{ "term": { "brand": "삼성" } }]
} } }
// 무선 이어폰 + 20만원 이하 + 품절 제외 + 삼성이면 상위로나머지 자주 쓰는 쿼리
| 쿼리 | 쓰는 상황 |
|---|---|
| range | 숫자·날짜 범위. gte / lte / gt / lt로 가격·기간 필터링 |
| fuzziness | 오타 허용. 편집 거리 기준으로 "samsng"도 "samsung"으로 매칭 |
| multi_match | 여러 필드를 한 번에. 제목·본문에서 동시에 키워드 검색 |
특정 조건을 상위로 — 점수 튜닝
"평점 높고 좋아요 많은 글을 위로" 같은 요구는 점수를 직접 조정해 푼다.
- should + boost — 특정 조건에 가중치를 줘서 매칭되면
_score를 올린다. 인기 브랜드·카테고리를 띄울 때. - function_score — 평점·좋아요 같은 숫자 필드를 점수에 곱한다. "관련도 × 인기도"로 정렬할 때.
09 · 실무 부가 기능 — 하이라이팅 · 정렬 · 자동완성
- highlight — 매칭된 부분을
<em>태그로 감싸 반환. 결과에서 키워드 강조 표시. - pagination —
from/size로 페이지 분할. 깊은 페이지는search_after가 안전. - sorting —
sort로 정렬. text는 정렬 불가 →.keyword나 숫자 필드 기준. - 자동완성 — 일부만 입력해도 추천.
edge_ngram으로 접두어를 미리 색인하거나search_as_you_type타입 사용.
자동완성 — edge_ngram의 원리
입력 도중에도 결과가 나오게 하려면, 단어를 앞에서부터 잘라 여러 접두어로 미리 색인한다. "갤럭시"를 색인하면 갤, 갤럭, 갤럭시가 토큰으로 들어가, "갤"만 쳐도 매칭된다.
10 · 프로젝트 — 상품 검색 기능 구현 흐름
지금까지의 개념을 쿠팡 같은 상품 검색에 합치면 이렇게 된다. 핵심은 기능 하나하나가 앞서 본 쿼리·매핑의 조립이라는 점이다.
구현 순서
- ① 요구사항 — 상품명 검색 + 필터(가격·카테고리) + 자동완성 + 결과 하이라이팅·정렬.
- ② 인프라 설계 — 원본은 MySQL, 검색은 Elasticsearch, 그 사이를 Spring Boot가 연결.
- ③ 매핑 정의 — 상품명은 nori text + keyword 멀티필드, 가격은 long, 카테고리·상태는 keyword, 자동완성용 필드는 edge_ngram.
- ④ 분석 검증 — 색인 전에
_analyze로 한글이 의도대로 쪼개지는지 확인. - ⑤ 검색 쿼리 조립 — 아래처럼 한 요청에 기능을 합친다.
GET products/_search — 상품 검색 종합
{
"query": { "bool": {
"must": [{ "multi_match": {
"query": "무선 이어폰",
"fields": ["name", "desc"] } }],
"filter": [{ "term": { "category": "음향기기" } },
{ "range": { "price": { "lte": 300000 } } }],
"must_not":[{ "term": { "status": "품절" } }]
} },
"highlight": { "fields": { "name": {} } },
"sort": [{ "_score": "desc" }, { "price": "asc" }],
"from": 0, "size": 20
}적용 전략
- 점수 vs 필터 — 검색어 관련도는
must(점수 O), 가격·카테고리 같은 단순 조건은filter(점수 X, 캐싱)로 분리. - 정렬 우선순위 — 1차
_score, 동점이면 가격순. 인기도까지 넣으려면 function_score. - 자동완성은 별도 인덱스/필드 — 검색 본 쿼리와 분리해 edge_ngram 필드로 가볍게 처리.
11 · 운영 — Spring Boot 연동과 Elastic Cloud
Spring Data Elasticsearch를 쓰면 인덱스를 도큐먼트 클래스로 매핑하고 리포지토리로 CRUD를 한다. JPA를 써봤다면 구조가 익숙하다.
Product.java
@Document(indexName = "products")
public class Product {
@Id private String id;
@Field(type = FieldType.Text, analyzer = "nori")
private String name;
@Field(type = FieldType.Long)
private Long price;
}데이터 동기화 — 가장 중요한 부분
Elasticsearch는 원본 저장소가 아니라 읽기 전용 검색 인덱스로 두는 게 정석이다. 원본은 MySQL에 두고, 데이터가 바뀔 때 ES에도 반영한다.
직접 구축 vs 매니지드
- Elastic Cloud — Elastic 본사 매니지드 서비스. 최신 기능·Nori 플러그인을 바로 사용.
- AWS OpenSearch — Elasticsearch에서 갈라진 AWS 포크. AWS 생태계 통합에 유리, Nori도 제공.
- 현업 — 직접 클러스터를 운영하기보다 매니지드를 쓰는 경우가 많다. 노드 관리·스케일링 부담을 줄이려고.
비용 주의 — Elastic Cloud는 클러스터가 떠 있는 동안 과금된다. 실습·테스트 후에는 디플로이먼트를 반드시 삭제(리소스 정리)해야 불필요한 요금이 안 나간다.
— 검색은 결국 "어떻게 쪼개서 색인하느냐"의 문제다.
클러스터가 데이터를 분산하고, 역인덱스가 단어로 찾고, 애널라이저가 쪼개는 규칙을 정한다. text/keyword 구분과 분석기 선택부터 정확히 잡으면 나머지 쿼리는 조립일 뿐이다.
주간 기술 뉴스레터
Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.