Elasticsearch로 이커머스 상품 검색 구축: 자동완성부터 패싯 필터까지

이커머스

Elasticsearch상품 검색자동완성패싯 필터검색 엔진

이 글은 누구를 위한 것인가

  • 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(집계)로 구현하며, 검색 결과와 함께 한 번의 쿼리로 카테고리/브랜드/가격대 분포를 반환한다.