bedaily.me
Backend2026년 6월 30일28분 읽기

Elasticsearch 핵심 정리 — 검색은 어떻게 빨라지는가

RDB의 LIKE '%키워드%'로는 한계가 명확하다. 풀스캔, 형태소 무시, 오타 취약, 연관도 없음. Elasticsearch가 이 문제를 어떻게 푸는지 — 클러스터 구조부터 역인덱스, 애널라이저, 한글 검색, 쿼리 DSL, 실전 프로젝트까지 핵심만 정리한다.

Elasticsearch검색엔진역인덱스NoriSpring Data Elasticsearch

Search Engine · Elasticsearch. RDB의 LIKE '%키워드%'로는 한계가 명확하다. 풀스캔, 형태소 무시, 오타 취약, 연관도 없음. Elasticsearch가 이 문제를 어떻게 푸는지 — 클러스터 구조부터 역인덱스, 애널라이저, 한글 검색, 쿼리 DSL, 실전 프로젝트까지 핵심만 정리한다.

기준 — Elasticsearch 7.x · Spring Data Elasticsearch.

목차

  1. Elasticsearch란 — 어디에 쓰나
  2. 클러스터 구조 — 노드 · 샤드 · 레플리카
  3. 데이터 구조 — 인덱스 / 도큐먼트 / 필드
  4. 역인덱스 — 검색이 빠른 진짜 이유
  5. 애널라이저 — 텍스트가 토큰이 되는 과정
  6. 한글 검색 — Nori Analyzer
  7. 매핑 — 데이터 타입과 text / keyword
  8. 쿼리 DSL — 자주 쓰는 검색들
  9. 실무 부가 기능 — 하이라이팅 · 정렬 · 자동완성
  10. 프로젝트 — 상품 검색 기능 구현 흐름
  11. 운영 — 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) — 샤드의 복제본. 원본(프라이머리) 장애에 대비하고, 읽기 부하를 분산한다. 프라이머리와 다른 노드에 둬야 의미가 있다.
clusterindex → 샤드 2개 (0·1)샤드 0 한 쌍샤드 1 한 쌍node 1P0샤드 0원본R1샤드 1복제본node 2R0샤드 0복제본P1샤드 1원본원본 (채움)복제본 (점선)같은 색 = 같은 샤드
P0·P1은 원본 샤드, R0·R1은 그 복제본. 같은 색(같은 샤드)의 원본·복제본을 서로 다른 노드에 둔다 → node 1이 죽어도 R0·P1로 0·1번 데이터가 모두 살아남는다

핵심 — 샤드 = 확장과 병렬 처리, 레플리카 = 가용성과 읽기 분산. 검색 요청이 오면 여러 샤드에서 동시에 찾고 결과를 합친다. 이게 대용량에서도 빠른 두 번째 이유다(첫 번째는 다음 섹션의 역인덱스).

03 · 데이터 구조 — 인덱스 / 도큐먼트 / 필드

Elasticsearch는 JSON 도큐먼트를 저장하고 검색한다. RDB와 1:1로 대응시키면 구조가 빨리 잡힌다.

  • 인덱스(Index) — 같은 성격의 도큐먼트를 모은 단위. RDB의 테이블에 해당한다.
  • 도큐먼트(Document) — 저장되는 데이터 한 건. RDB의 행(row), JSON 객체 하나다.
  • 필드(Field) — 도큐먼트 안의 key-value. RDB의 컬럼에 해당한다.
  • 매핑(Mapping) — 각 필드의 데이터 타입과 분석 방식을 정의한 스키마.
index : products= RDB 테이블document #1 = RDB 행(row)"name"갤럭시 버즈"price"189000"tags"["이어폰","무선"]필드 = RDB 컬럼document #2 …document #N …
인덱스 ⊃ 도큐먼트 ⊃ 필드. RDB의 테이블 / 행 / 컬럼에 1:1 대응

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)다.

DOCUMENTSdoc 1맑은 가을 하늘doc 2가을 단풍doc 3맑은 호수분석INVERTED INDEXtermposting list맑은[ 1, 3 ]가을[ 1, 2 ]하늘[ 1 ]단풍[ 2 ]"가을" 검색 → posting list에서 즉시 [1, 2] 반환 (풀스캔 없음)
텍스트를 미리 단어로 쪼개 색인 → 검색 시점에는 단어로 도큐먼트를 바로 찾는다
  • 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)는 원본 텍스트를 역인덱스에 들어갈 토큰으로 가공하는 파이프라인이다. 세 단계를 순서대로 거친다.

input<b>The Cats</b>① CharacterFilterhtml_strip② Tokenizer공백·기준으로단어 분리③ TokenFilterlowercase, stop…cat(the 제거)
Character Filter → Tokenizer → Token Filter 순서로 동작. 토크나이저는 반드시 1개
  • Character Filter — 토큰화 전에 원문을 손본다. html_strip으로 <b> 태그 제거.
  • Tokenizer — 텍스트를 토큰으로 자르는 핵심 단계. 정확히 하나만 지정.
  • Token Filter — 잘린 토큰을 후처리. 여러 개를 순서대로 적용.

세 단계 모두 부품처럼 골라 조립한다. 개수 규칙만 다르다 — 캐릭터 필터는 0개 이상, 토크나이저는 정확히 1개(필수), 토큰 필터는 0개 이상.

캐릭터 필터 — 자르기 전에 원문 손질

토큰화 이전 단계라, 결과는 아직 토큰이 아니라 다듬어진 문자열이다.

html_strip<p>Hello</p> → Hello
mappingAT&T → ATandT
pattern_replace010-1234-5678 → 01012345678

html_strip은 태그 제거, mapping은 지정 문자 치환(& → and), pattern_replace는 정규식 치환(- 제거). 검색에 방해되는 문자를 색인 전에 정리한다.

토크나이저 — 어떻게 자를지 결정

토큰 필터가 아무리 좋아도, 애초에 어떻게 자르느냐(토크나이저)가 검색 범위를 좌우한다.

토크나이저자르는 방식
standard공백·문장부호 기준. 일반 텍스트의 기본
whitespace공백에서만 자른다 (기호·대소문자 유지)
lowercase문자가 아닌 곳에서 자르고 소문자화 (숫자·기호 버림)
keyword자르지 않고 입력 전체를 1토큰으로
nori_tokenizer한글 형태소 단위 (Nori 플러그인)
edge_ngram / ngram접두어·글자 조각으로 잘게 쪼갬 (자동완성용)

같은 입력을 토크나이저별로 어떻게 자르는지. (왼쪽 입력 → 오른쪽 토큰)

standardFast-Cars 2024 → Fast Cars 2024
whitespaceFast-Cars 2024 → Fast-Cars 2024
lowercaseFast-Cars 2024 → fast cars · 2024
keywordFast-Cars 2024 → Fast-Cars 2024
nori_tokenizer맑은 하늘 → 맑 은 하늘
edge_ngram카페 → 카 카페

토크나이저는 자르기만 할 뿐 소문자화하지 않는다(대소문자 통일은 lowercase 토큰 필터의 몫이다). standard는 하이픈·공백으로 자르고, whitespace는 공백에서만, lowercase는 문자 아닌 것 모두(숫자 2024는 버림)로 자르며 이 토크나이저만 예외적으로 소문자화까지 한다. keyword는 통째로 1토큰. (점선 = 버려진 토큰)

토큰 필터 — 자른 토큰을 다듬기

토큰화가 끝난 뒤 적용된다. 대소문자·어형·동의어 차이를 흡수해 "같은 뜻은 같은 토큰"으로 맞추는 단계다.

필터역할언제 쓰나
lowercase대소문자 통일. "Apple"과 "apple"을 같은 토큰으로거의 항상. 대소문자 구분 없는 검색의 기본
stop불용어 제거. 색인 크기↓, 노이즈↓영문 본문 검색 (한글엔 효과 적음)
stemmer어형을 어간으로 환원. 시제·복수형 흡수영문에서 running/runs를 run으로 함께 잡을 때
synonym동의어를 같은 토큰으로 묶음노트북↔랩탑처럼 다른 표현을 함께 검색

각 필터를 통과하면 토큰이 이렇게 바뀐다. (왼쪽 입력 → 오른쪽 결과)

lowercaseQuick BROWN → quick brown
stopa quick fox → quick fox
stemmerrunning jumps → run jump
synonymlaptop → laptop notebook (추가)

stemmer·stop은 토큰을 줄이고, synonym은 토큰을 늘린다(동의어 추가). 그래서 notebook으로 색인된 문서를 "laptop"으로도 찾게 된다.

기본 제공 애널라이저

애널라이저동작언제 쓰나
standard공백·문장부호로 자르고 소문자화. 숫자는 유지기본값. 특별한 요구 없으면 대부분 이걸로
simple문자가 아닌 것 모두 기준으로 자르고 소문자화 (숫자·기호 버림)숫자·기호가 의미 없는 순수 텍스트
whitespace공백으로만 자른다. 소문자화·기호 제거 안 함 (원형 보존)대소문자·기호를 그대로 살려야 할 때
stopsimple처럼 자르고 소문자화 + 불용어(a, the, or…) 제거영문에서 흔한 불용어 노이즈를 걷어낼 때
keyword자르지 않고 통째로 하나의 토큰. 분석 없이 원문 그대로정확 일치·정렬·집계 (보통 keyword 타입으로 대체)

같은 문장을 애널라이저별로 넣으면 토큰이 이렇게 갈린다.

입력  "The Cats & 3 Dogs"
standardthe cats 3 dogs
simplethe cats dogs · 3
whitespaceThe Cats & 3 Dogs
stopcats dogs · the 3
keywordThe Cats & 3 Dogs

standard는 숫자(3)를 남기지만 simple·stop은 버린다. whitespace만 대소문자·기호(&)를 보존한다. stop은 불용어 the까지 뺀다.

기본 애널라이저 = 부품들의 프리셋

위 애널라이저들은 결국 아래 부품 조합을 미리 묶어둔 것이다. 프리셋이 안 맞으면 이 조합을 직접 바꿔 커스텀으로 만든다.

애널라이저char filtertokenizertoken filter
standard없음standardlowercase
simple없음lowercase없음
whitespace없음whitespace없음
stop없음lowercasestop
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 애널라이저는 공백 기준으로만 자른다. 영어는 단어가 공백으로 나뉘니 잘 되지만, 한글은 조사가 단어에 붙어 다녀서 제대로 검색되지 않는다. "가을이"를 통째로 색인하면 "가을"로 검색해도 안 잡힌다.

원문 "가을이 깊었다"standard analyzer가을이깊었다"가을" 검색 →매칭 실패 ✕조사가 붙은 채로 색인됨nori analyzer가을깊이 · 었다조사·어미 제거"가을" 검색 →매칭 성공 ✓형태소 단위로 분해
Nori는 형태소 분석기. 조사·어미를 분리해 한글이 의도대로 검색되게 한다
  • Nori — Elasticsearch 공식 한글 형태소 분석 플러그인. 명사·동사 어간을 뽑고 조사·어미를 분리.
  • 설치 — analysis-nori 플러그인을 별도 설치. (Elastic Cloud·AWS OpenSearch는 기본 제공)
  • 한·영 혼용 — 한 필드에 한글·영어가 섞이면, 멀티 필드나 적절한 애널라이저 조합으로 양쪽 다 검색되게 설계.

nori도 부품들의 프리셋

nori 역시 한글용 부품을 미리 묶은 것이다. 5장 프리셋 표와 같은 구조다.

애널라이저char filtertokenizertoken filter
nori없음nori_tokenizernori_part_of_speech, nori_readingform, lowercase

각 부품이 하는 일. (왼쪽 입력 → 오른쪽 결과)

nori_tokenizer가을이 깊었다 → 가을 이 깊 었 다
nori_part_of_speech가을 이 깊 었 다 → 가을 깊 · 이 었 다
nori_readingform漢字 → 한자
lowercaseiPhone → iphone

nori_tokenizer가 형태소로 쪼개면(조사·어미까지 토큰으로), nori_part_of_speech가 조사(이)·어미(었·다)를 버린다. nori_readingform은 한자를 한글 발음으로, lowercase는 섞인 영문을 소문자로 통일한다. 조사를 남기거나 복합명사 분해 방식(decompound_mode)을 바꾸고 싶으면 이 부품들을 커스텀으로 조합한다.

07 · 매핑 — 데이터 타입과 text / keyword

매핑은 필드별 데이터 타입을 정의한다. 타입에 따라 분석 여부·검색 방식·정렬 가능 여부가 갈린다.

주요 데이터 타입

타입용도
text전문 검색용 문자열. 애널라이저로 분석됨 (본문·상품명)
keyword정확 일치·정렬·집계용 문자열. 분석 안 함 (상태·카테고리·이메일)
long / integer정수
double / float실수
date날짜·시간. range 쿼리·정렬에 사용
booleantrue / false
object중첩 JSON 객체 (내부적으로 평탄화)
nested배열 안 객체를 독립적으로 검색해야 할 때

text와 keyword는 다르다

가장 자주 하는 실수가 이 둘의 혼동이다. 같은 문자열도 타입에 따라 토큰이 완전히 달라진다.

색인할 값 "Samsung Galaxy Buds"text애널라이저 거침 → 분석되어 토큰화samsunggalaxybuds✓ 전문 검색 (일부 단어로 매칭)✕ 정렬·집계 부적합keyword분석 안 함 → 통째로 하나의 토큰Samsung Galaxy Buds✓ 정확히 일치·정렬·집계·필터✕ 부분 단어 검색 안 됨
같은 문자열도 타입에 따라 토큰이 달라진다. 용도에 맞게 골라야 한다
  • 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 — 조건 조합의 핵심

boolmust반드시 만족 (AND)점수에 반영 → 연관도 영향 Oshould만족하면 가산점 (OR)점수 ↑ → 상위 노출 유도filter반드시 만족하되 점수 계산 X캐싱됨 → 빠름. 범위·상태 필터에must_not만족하는 도큐먼트 제외점수 계산 X
점수가 필요 없는 조건은 filter로 빼면 캐싱되어 더 빠르다

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에도 반영한다.

MySQL원본 (write)동기화insert / update / delete 반영Elasticsearch검색 인덱스 (read)검색 요청SpringSearch API
쓰기는 MySQL, 검색은 Elasticsearch. 둘 사이 동기화가 설계의 핵심

직접 구축 vs 매니지드

  • Elastic Cloud — Elastic 본사 매니지드 서비스. 최신 기능·Nori 플러그인을 바로 사용.
  • AWS OpenSearch — Elasticsearch에서 갈라진 AWS 포크. AWS 생태계 통합에 유리, Nori도 제공.
  • 현업 — 직접 클러스터를 운영하기보다 매니지드를 쓰는 경우가 많다. 노드 관리·스케일링 부담을 줄이려고.

비용 주의 — Elastic Cloud는 클러스터가 떠 있는 동안 과금된다. 실습·테스트 후에는 디플로이먼트를 반드시 삭제(리소스 정리)해야 불필요한 요금이 안 나간다.


— 검색은 결국 "어떻게 쪼개서 색인하느냐"의 문제다.

클러스터가 데이터를 분산하고, 역인덱스가 단어로 찾고, 애널라이저가 쪼개는 규칙을 정한다. text/keyword 구분과 분석기 선택부터 정확히 잡으면 나머지 쿼리는 조립일 뿐이다.

주간 기술 뉴스레터

Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.