Elasticsearch 실전 가이드 | 검색·인덱싱·Aggregation·성능 최적화
이 글의 핵심
Elasticsearch로 강력한 검색 엔진을 구축하는 실전 가이드. 인덱싱, 쿼리, Aggregation, 분석기, 성능 최적화까지 실무 예제로 정리. Elasticsearch·Search·Full-Text Search 중심으로 설명합니다.
솔직히 말하면, Elasticsearch 쓸 때 “쿼리 문법”보다 매핑이랑 분석기(Analyzer)를 어떻게 잡느냐가 검색 품질을 갈랐어요. 쿼리는 나중에 갖다 붙이면 되는데, 인덱스 설계(필드 타입, 토큰나이저, sub-field)를 한번 잘못 박아 두면 reindex 루프에 들어가기 십상이에요. 그래서 제 결론은 이거예요. 한국어/일본어 검색이면 분석기 선택이 사실상 전부에 가깝다. 쿨하게 match 쿼리만 쓰는 건, 지도 없이 엑셀만 킨 거랑 비슷해요.
인덱스 설계 실수로 제일 아팠던 건, 상품명을 전부 text로만 넣고 “카테고리별 정렬·필터”를 terms에 걸 때였어요. 대시보드 팀이 “이 필드로 facet 해줘”라고 하면 category는 keyword가 맞는데, 첫 매핑에서 text에만 묶어 둔 거죠. fielddata 켤 생각을 하다가 눈이 아파지고, 결국 category.keyword sub-field 류로 고치는 수순을 밟게 됐어요. 늦게 깨닫는 순간 이미 수백 GB였고, reindex는 멈춰 보이는데 디스크만 도는 그 씁쓸함, 아시죠. 그날 이후로는 “필터/정렬/집계에 쓰는 값은 keyword 먼저”를 몸에 박아 뒀어요. 그리고 한글은 nori 쓰기로 결심한 뒤, 스테이징에서 _analyze로 토큰이 어떻게 쪼개지는지 스크린샷까지 남깁니다. 멋 없어 보여도, 그게 사고를 줄여요.
Elasticsearch가 뭐냐고 짧게 말하면, 역인덱스(inverted index) + 분산 샤딩으로 “LIKE에 지친 사람”을 구하는 도구예요. PostgreSQL에서 10초 걸리던 LIKE '%…%류가, 잘 꾸면 100ms 안쪽에 들어가는 쪽으로 감각이 바뀌고요. 오타 허용(fuzzy), 가중 멀티필드, 집계까지 한 통에 가져가는 게 포인트고요. 다만 “DB 대체”는 아니에요. 트랜잭션이랑 강한 일관성이 필요한 소스 오브 트루스는 RDB/문서DB에 두고, 검색/로그/분석용으로 쓰는 쪽이 덜 밤샘을 부릅니다.
로컬은 Docker면 충분해요. 보안/운영은 프로덕션에서 따로 잡는다는 전제하에, 일단 띄우는 용은 아래가 편해요.
# 실행 예제
docker run -d \
--name elasticsearch \
-p 9200:9200 \
-p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
docker.elastic.co/elasticsearch/elasticsearch:8.12.0
뜬 뒤 curl http://localhost:9200만 찍혀도 오케이예요.
인덱스는 “문서의 스키마 + 어떤 분석기로 쪼갤지”를 같이 박는 거라고 보면 돼요. 대충 products 예시는 이렇게 잡는 편이에요. (여기서 category는 keyword — 위에서 뼈맞았던 그거요.)
# 인덱스 생성
# 실행 예제
curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d'
{
"mappings": {
"properties": {
"name": { "type": "text" },
"description": { "type": "text" },
"price": { "type": "float" },
"category": { "type": "keyword" },
"tags": { "type": "keyword" },
"created_at": { "type": "date" }
}
}
}
'
문서는 단건으로 넣을 수도 있고, 벌크로 때려 넣는 게 실서비스에선 기본이에요.
# 단일 문서
curl -X POST "localhost:9200/products/_doc" -H 'Content-Type: application/json' -d'
{
"name": "Laptop",
"description": "High performance laptop",
"price": 1200.00,
"category": "Electronics",
"tags": ["laptop", "computer"],
"created_at": "2026-04-30"
}
'
# Bulk 추가
curl -X POST "localhost:9200/_bulk" -H 'Content-Type: application/json' -d'
{"index":{"_index":"products"}}
{"name":"Mouse","price":25.00,"category":"Electronics"}
{"index":{"_index":"products"}}
{"name":"Keyboard","price":75.00,"category":"Electronics"}
'
검색은 match부터 갑니다. 멀티필드에 가중 주는 multi_match는 거의 “기본 셋”이에요. bool로 필터/should 나누는 것도, RDB 쿼리 짜는 것보다 말이 짧아져서 좋고요. 오타는 fuzzy로 때우되, fuzziness는 AUTO로 늘려만 놓지 말고 UX 보면서 튜닝하세요. 너무 관대해지면 이상한 게 위로 뜹니다.
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"match": {
"name": "laptop"
}
}
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"multi_match": {
"query": "laptop",
"fields": ["name^2", "description"]
}
}
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"bool": {
"must": [
{ "match": { "category": "Electronics" } }
],
"filter": [
{ "range": { "price": { "gte": 100, "lte": 1000 } } }
],
"should": [
{ "match": { "tags": "laptop" } }
]
}
}
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"query": {
"fuzzy": {
"name": {
"value": "lapto",
"fuzziness": "AUTO"
}
}
}
}
'
집계(Aggregation)는 “DB에서 GROUP BY+통계”를 API 한 방에 끌고 온 느낌이에요. 대시보드/필터 UX 만들 때 terms, 가격은 stats나 histogram으로 끊고요. size: 0은 “힐만 가져올게, 문서 본문은 안 줘” 패턴.
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"size": 0,
"aggs": {
"categories": {
"terms": {
"field": "category"
}
}
}
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"size": 0,
"aggs": {
"price_stats": {
"stats": {
"field": "price"
}
}
}
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
"size": 0,
"aggs": {
"price_ranges": {
"histogram": {
"field": "price",
"interval": 100
}
}
}
}
'
Node 쪽이면 @elastic/elasticsearch 하나 깔고, 앱에서 쿼리 바디는 그냥 객체로 박으면 돼요. 제 스타일은 “검색/인덱싱/자동완성”을 함수로 쪼개고, 로깅에 took이랑 shard 실패만이라도 흘리는 것 — 나중에 “왜 느린지”가 잡혀요.
npm install @elastic/elasticsearch
import { Client } from '@elastic/elasticsearch';
const client = new Client({
node: 'http://localhost:9200',
});
// 검색
async function searchProducts(query: string) {
const result = await client.search({
index: 'products',
body: {
query: {
multi_match: {
query,
fields: ['name^2', 'description'],
},
},
},
});
return result.hits.hits.map((hit) => hit._source);
}
// 인덱싱
async function indexProduct(product: any) {
await client.index({
index: 'products',
body: product,
});
}
// 자동완성
async function autocomplete(prefix: string) {
const result = await client.search({
index: 'products',
body: {
query: {
match_phrase_prefix: {
name: prefix,
},
},
size: 5,
},
});
return result.hits.hits.map((hit) => hit._source.name);
}
한글 text는 nori 안 쓰면 “왜 ‘삼성전자’가 이상하게 잡히지?”로 시간을 태웁니다. 저는 플러그인 깔고, 커스텀 분석기 하나만 스테이징에서 먼저 박고 _analyze로 눈으로 확인해요. JSON에 필터는 문자열로 넣는 거 잊지 마세요(예: "lowercase").
# nori 플러그인 설치
bin/elasticsearch-plugin install analysis-nori
curl -X PUT "localhost:9200/products_kr" -H 'Content-Type: application/json' -d'
{
"settings": {
"analysis": {
"analyzer": {
"korean": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "korean"
}
}
}
}
'
성능 쪽은 말이 많은데, 제 기준에선 (1) bulk로 쓰기, (2) “실시간이 꼭 아니면” refresh_interval 늘리기, (3) 쿼리 캐시 켤지 말지는 실측이에요. “요청 캐시” 넣는 것도 케이스마다 갈려요. 인덱싱 파이프라인이 병목이면 먼저 bulk 배치 크기와 refresh부터 보는 편이에요.
// 대량 인덱싱
async function bulkIndex(products: any[]) {
const body = products.flatMap((doc) => [
{ index: { _index: 'products' } },
doc,
]);
await client.bulk({ body });
}
curl -X PUT "localhost:9200/products/_settings" -H 'Content-Type: application/json' -d'
{
"index": {
"requests.cache.enable": true
}
}
'
# 실시간 검색이 필요 없으면 interval 증가
curl -X PUT "localhost:9200/products/_settings" -H 'Content-Type: application/json' -d'
{
"index": {
"refresh_interval": "30s"
}
}
'
이 컨텍스트에서 운영 얘기를 “표”로 끊지 않을게요. 관측은 요청 ID랑 p95/p99, shard 실패, GC/큐가 한 화면에 있어야 하고, 안전은 입력/권한/감사 로그가 API 경로마다 일관돼야 해요. 신뢰는 “재시도”를 멱등한 곳에만 겁니다(그렇지 않으면 2배로 쓰인 데이터가 밤에 터져요). 성능은 캐시·배치·백프레스를 데이터 규모에 맞춰 다시 쓰는 거고, 배포는 인덱스 템플릿/mapping이 깨질 때 롤백 루트가 있어야 해요. 용량은 피크 때 디스크·FD·힙이 동시에 붉어지는지 봅니다. 스테이징은 RTT·동시성이 프로덕이랑 멀면 멀수록 재현이 망가져요.
문제 풀 때도 표 대신요. 간헐 실패는 레이스/타임아웃/외부 의존/DNS 쪽을 최소 재현으로 줄이고, 트레이스로 잡고요. 느리면 N+1·동기 I/O·락·캐시 미스·직렬화 비용을 프로파일로 하나씩 뗍니다. 메모리만 커지면 무제한 캐시, 리스너 누수, 버퍼, 커넥션 반납부터 의심. 빌드만 깨지면 환경·권한·lockfile. 로컬만 이상하면 시크릿/프로필/지역. 데이터 꼬이면 비멱등 재시도, 부분 쓰기, 캐시 무효 누락. 순서는 (1) 최소 재현 (2) 최근 변경 (3) 환경 차이 (4) 가설-관측 (5) 수정 후 부하/회귀.
면접이나 이력서로 가져가기 좋은 건, “RDB LIKE → ES로 전환, 지연 p95, 전환율, 인덱스 사고 한 번” 같은 숫자랑 사고예요. 공식 문서·트러블슈팅 루트는 Elasticsearch 문서, 그리고 팀 온콜에서 쓰는 런북 쪽이 더 도움 됩니다. 기술 면접 완벽 대비 가이드, 개발자 이력서·서류·면접 가이드는 여전히 곁에서만 보시고요.
마지막으로 궁금한 것만. ES vs PG 풀텍스트? ES가 기능·속도는 압승이에요. PG는 “간단 필터+간단 텍스트” 수준이면 굳이 클러스터 하나 더 안 띄우는 쪽이 정신 건강에 좋고요. 메모리? 씁니다. 최소 RAM 가이드는 팀마다 다르니 “프로덕 권고”로 보세요. 클러스터 필수? 아니에요, 로컬/소규모는 싱글이죠. 다만 프로덕은 3노드 이상이 이야기가 편해요. Kibana? ES 붙이는 눈(대시/탐색)이에요. 저는 로그/메트릭 둘 다 있으면 Kibana+Elastic Stack, 검색 앱이면 Kibana는 선택이에요.
MongoDB·Redis·PostgreSQL 고급 가이드랑은 같이 보면 “원장 vs 검색”이 정리가 잘 돼요. 이 글에서 언급한 키워드는 Elasticsearch, Search, Full-Text Search, Indexing, Analytics, ELK Stack 정도로 묶이면 돼요.