이 글은 누구를 위한 것인가
- DB LIKE 검색의 성능 한계를 극복하려는 팀
- 한국어 형태소 분석과 자동완성을 구현하려는 개발자
- 카테고리/브랜드/가격대 패싯 필터를 빠르게 제공하려는 팀
들어가며
LIKE '%노트북%'은 풀테이블 스캔이다. 상품이 10만 개를 넘으면 느려진다. Elasticsearch는 역인덱스로 밀리초 단위 검색, 형태소 분석으로 "노트북" → "노트", "북" 분리, 집계(aggregation)로 패싯 필터를 제공한다.
이 글은 bluefoxdev.kr의 Elasticsearch 상품 검색 가이드 를 참고하여 작성했습니다.
1. Elasticsearch 인덱스 설계
[인덱스 구조]
products 인덱스
├── name (text, 한국어 분석기)
├── name.suggest (completion, 자동완성)
├── name.keyword (keyword, 정렬/집계)
├── description (text)
├── category (keyword, 패싯)
├── brand (keyword, 패싯)
├── price (double, 범위 필터/정렬)
├── stock (integer, 재고 필터)
├── rating (float, 정렬)
└── tags (keyword array)
[한국어 분석기]
nori_tokenizer: 형태소 분석
nori_part_of_speech: 품사 필터 (조사 제거)
synonym_filter: 동의어 사전
(노트북 = 랩탑 = laptop)
[관련성 점수 커스터마이징]
function_score:
- 판매량 boost (field_value_factor)
- 재고 있는 상품 우선
- 최신 상품 decay 함수
2. Elasticsearch 검색 구현
import { Client } from '@elastic/elasticsearch';
const es = new Client({ node: process.env.ELASTICSEARCH_URL });
// 상품 검색 (멀티 필드 + 패싯)
async function searchProducts(params: {
query: string;
filters: { category?: string; brand?: string; minPrice?: number; maxPrice?: number };
sort: 'relevance' | 'price_asc' | 'price_desc' | 'rating';
page: number;
size: number;
}) {
const must: any[] = [];
const filter: any[] = [{ term: { isPublished: true } }];
if (params.query) {
must.push({
function_score: {
query: {
multi_match: {
query: params.query,
fields: ['name^3', 'name.english^2', 'tags^1.5', 'description'],
type: 'best_fields',
fuzziness: 'AUTO',
},
},
functions: [
{ field_value_factor: { field: 'salesCount', modifier: 'log1p', factor: 0.5 } },
{ filter: { range: { stock: { gt: 0 } } }, weight: 1.2 },
],
score_mode: 'multiply',
},
});
}
if (params.filters.category) filter.push({ term: { 'category.keyword': params.filters.category } });
if (params.filters.brand) filter.push({ term: { 'brand.keyword': params.filters.brand } });
if (params.filters.minPrice || params.filters.maxPrice) {
filter.push({ range: { price: { gte: params.filters.minPrice, lte: params.filters.maxPrice } } });
}
const sortMap: Record<string, any> = {
relevance: ['_score'],
price_asc: [{ price: 'asc' }],
price_desc: [{ price: 'desc' }],
rating: [{ rating: 'desc' }, '_score'],
};
const result = await es.search({
index: 'products',
body: {
query: { bool: { must: must.length ? must : [{ match_all: {} }], filter } },
sort: sortMap[params.sort],
from: (params.page - 1) * params.size,
size: params.size,
aggs: {
categories: { terms: { field: 'category.keyword', size: 20 } },
brands: { terms: { field: 'brand.keyword', size: 20 } },
price_ranges: {
range: {
field: 'price',
ranges: [
{ to: 10000 }, { from: 10000, to: 50000 },
{ from: 50000, to: 100000 }, { from: 100000 },
],
},
},
avg_rating: { avg: { field: 'rating' } },
},
highlight: {
fields: { name: {}, description: { fragment_size: 150 } },
},
},
});
return {
total: (result.hits.total as any).value,
products: result.hits.hits.map(h => ({ ...h._source, highlight: h.highlight })),
facets: {
categories: result.aggregations?.categories,
brands: result.aggregations?.brands,
priceRanges: result.aggregations?.price_ranges,
},
};
}
// 자동완성
async function autocomplete(query: string) {
const result = await es.search({
index: 'products',
body: {
suggest: {
product_suggest: {
prefix: query,
completion: { field: 'name.suggest', size: 10, skip_duplicates: true },
},
},
},
});
return result.suggest?.product_suggest?.[0]?.options?.map((o: any) => ({
text: o.text,
productId: o._source?.id,
})) ?? [];
}
// 상품 색인
async function indexProduct(product: any) {
await es.index({
index: 'products',
id: product.id,
body: {
...product,
'name.suggest': { input: [product.name, ...product.tags], weight: product.salesCount },
},
});
}
마무리
Elasticsearch 상품 검색의 핵심은 nori 형태소 분석기와 function_score다. nori로 한국어를 올바르게 토크나이즈하고, function_score로 판매량과 재고를 관련성 점수에 반영한다. 패싯 필터는 aggs(집계)로 구현하며, 검색 결과와 함께 한 번의 쿼리로 카테고리/브랜드/가격대 분포를 반환한다.