fos-blog/study
01 / 홈02 / 카테고리
01 / 홈02 / 카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 커머스/F&B 도메인 설계 면접 대비 — 슬롯 경험을 주문·결제·쿠폰·매장 상태 설계로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
    • stock-notes 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] CJ푸드빌 디지털 채널 Back-end 개발자 직무 분석
    • [초안] CJ푸드빌 디지털 채널 Back-end 면접 답변집 — 슬롯 도메인 경험을 커머스/F&B 설계로 번역하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/database/[초안] OpenSearch 검색 품질을 좌…
db

[초안] OpenSearch 검색 품질을 좌우하는 analyzer 구조: nori, ngram, tokenizer, token filter 제대로 이해하기

검색 기능은 백엔드 엔지니어가 피할 수 없는 과제 중 하나다. 상품 검색, 로그 검색, 자동완성, 오타 보정, 다국어 처리 같은 요구사항이 쌓이다 보면 결국 Elasticsearch 혹은 OpenSearch 같은 검색 엔진을 도입하게 되고, 그 순간부터 "왜 이 키워드로는 안 나오지?", "왜 부분 일치가 이상하게 동작하지?", "왜 한글은 조사까지 걸리지...

2026.05.07·10 min read·3 views

왜 이 주제를 공부해야 하는가

검색 기능은 백엔드 엔지니어가 피할 수 없는 과제 중 하나다. 상품 검색, 로그 검색, 자동완성, 오타 보정, 다국어 처리 같은 요구사항이 쌓이다 보면 결국 Elasticsearch 혹은 OpenSearch 같은 검색 엔진을 도입하게 되고, 그 순간부터 "왜 이 키워드로는 안 나오지?", "왜 부분 일치가 이상하게 동작하지?", "왜 한글은 조사까지 걸리지?" 같은 질문이 쏟아진다.

이 모든 질문의 답은 결국 한 곳으로 귀결된다. analyzer가 문자열을 어떻게 토큰으로 쪼개고 정규화하는가. analyzer를 이해하지 못한 채로 OpenSearch를 도입하면, 인덱스에 이상한 토큰이 들어가 있고 쿼리 시점에는 다른 토큰이 생성되어 매칭 자체가 실패하는 버그를 잡는 데 며칠을 쓰게 된다. 반대로 analyzer 구성 요소(character filter, tokenizer, token filter)와 주요 플러그인(nori, ngram)의 동작 방식을 제대로 잡아두면, 검색 품질 이슈의 80%는 색인/쿼리 설정 수준에서 해결된다.

시니어 백엔드 관점에서는 한 단계 더 들어간다. nori를 쓰면 정확도는 올라가지만 인덱스 크기와 색인 시간이 늘어나고, ngram을 쓰면 부분 일치가 가능해지지만 인덱스가 폭발한다. 이 trade-off를 이해하고, 요구사항에 맞춰 analyzer 파이프라인을 설계할 수 있어야 한다. 이 문서는 그 설계 감각을 잡기 위한 실전 가이드다.

OpenSearch analyzer의 기본 구조

OpenSearch의 analyzer는 세 단계 파이프라인이다.

plaintext
입력 텍스트
   │
   ▼
[character filter] ── 원문 자체를 변형 (HTML 제거, 문자 치환 등)
   │
   ▼
[tokenizer] ─────── 문자열을 토큰(term) 단위로 분리
   │
   ▼
[token filter] ──── 토큰을 정규화, 확장, 제거
   │
   ▼
 인덱스에 저장되는 term

핵심은 tokenizer는 반드시 하나, character filter와 token filter는 0개 이상이라는 점이다. 많은 사람이 "analyzer = nori"라고 착각하지만, 실제로는 "nori_tokenizer + 여러 token filter의 조합"이 가장 현실적인 analyzer다.

예를 들어 검색 쿼리 "올리브영 매장 영업시간"이 들어왔을 때, standard analyzer는 공백으로만 자르기 때문에 [올리브영, 매장, 영업시간] 같은 토큰이 만들어진다. 문제는 사용자가 "올리브영매장"처럼 붙여 쓰면 하나의 토큰 올리브영매장이 되어버려 색인된 올리브영과 매칭되지 않는다. 한국어 검색에서 nori나 ngram이 필요한 이유가 여기에 있다.

nori 플러그인: 한국어 형태소 분석기

nori는 Elasticsearch/OpenSearch 공식에서 제공하는 한국어 형태소 분석 플러그인이다. 내부적으로는 Lucene의 KoreanAnalyzer를 래핑하고 있으며, 사전 기반(mecab-ko-dic 유래)으로 단어를 분석한다.

nori의 구성 요소

nori 플러그인은 설치 후 세 가지를 제공한다.

  • nori_tokenizer — 한국어 문장을 형태소 단위로 분리
  • nori_part_of_speech token filter — 특정 품사(조사, 어미 등)를 제거
  • nori_readingform token filter — 한자를 한글 독음으로 변환

nori_tokenizer에는 decompound_mode라는 중요한 옵션이 있다.

  • none — 복합명사를 분리하지 않는다. 백두산 → [백두산]
  • discard — 복합명사를 분리한 토큰만 남긴다. 백두산 → [백두, 산]
  • mixed — 원본 복합명사 + 분리된 구성 요소 모두 인덱싱. 백두산 → [백두산, 백두, 산]

검색 서비스 관점에서는 mixed가 흔히 선택된다. 사용자가 전체 단어로 검색하든 일부 단어로 검색하든 매칭되기 때문이다. 다만 인덱스 크기는 커진다.

nori 설정 예시

json
PUT /products
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_nori_tokenizer": {
          "type": "nori_tokenizer",
          "decompound_mode": "mixed",
          "user_dictionary_rules": [
            "올리브영",
            "맥세이프",
            "멀티비타민민"
          ]
        }
      },
      "analyzer": {
        "korean_analyzer": {
          "type": "custom",
          "tokenizer": "my_nori_tokenizer",
          "filter": [
            "lowercase",
            "nori_part_of_speech_filter",
            "nori_readingform"
          ]
        }
      },
      "filter": {
        "nori_part_of_speech_filter": {
          "type": "nori_part_of_speech",
          "stoptags": [
            "E", "IC", "J", "MAG", "MAJ", "MM",
            "SP", "SSC", "SSO", "SC", "SE",
            "XPN", "XSA", "XSN", "XSV",
            "UNA", "NA", "VSV"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "korean_analyzer"
      }
    }
  }
}

여기서 주목할 점은 사용자 사전(user_dictionary_rules) 이다. 신조어, 브랜드명, 내부 도메인 용어는 기본 사전에 없기 때문에 nori가 엉뚱하게 쪼갠다. 예를 들어 "맥세이프"는 기본 사전에 없으면 [맥, 세이프]로 쪼개질 수 있다. 실제 서비스에서는 상품명, 브랜드, 카테고리를 사용자 사전으로 꾸준히 관리해야 한다.

stoptags는 제거할 품사 태그다. J(조사), E(어미), MAG(일반 부사) 같은 것들은 검색 매칭에 도움이 안 되기 때문에 걷어낸다. 이 리스트는 Lucene 문서에 전부 정의되어 있으니 프로젝트마다 조정하면 된다.

ngram tokenizer: 부분 일치와 자동완성

ngram은 문자열을 n글자 단위로 잘라 모든 부분 문자열을 토큰화한다.

  • min_gram: 2, max_gram: 3인 ngram으로 "검색"을 분석하면 [검, 검색, 색] 수준에서 토큰이 나온다(경계 설정에 따라 다르다).
  • "올리브영"을 min_gram: 2, max_gram: 3으로 넣으면 [올리, 올리브, 리브, 리브영, 브영]이 된다.

이 덕분에 "리브영"으로 검색해도 "올리브영"이 매칭된다. 즉, 부분 일치(wildcard 없는 LIKE '%xxx%'와 유사한 UX)를 구현할 수 있다.

ngram vs edge_ngram

두 가지는 자주 혼동되지만 용도가 다르다.

  • ngram — 모든 위치의 부분 문자열. "olive" → [ol, oli, liv, ive, ...]
  • edge_ngram — 앞에서부터 자르는 부분 문자열. "olive" → [o, ol, oli, oliv, olive]

자동완성 기능에는 거의 항상 edge_ngram이 정답이다. 사용자가 "올"을 입력하면 "올리브영", "올영"이 떠야 하지만, "리브"만 입력했는데 "올리브영"이 떠서는 자동완성 UX가 어색해진다.

색인 시점 vs 검색 시점 analyzer

ngram을 설계할 때 가장 많이 하는 실수가 색인 analyzer와 검색 analyzer를 동일하게 쓰는 것이다.

  • 색인 시점: 문서의 "올리브영"을 [올, 올리, 올리브, 올리브영]로 쪼개 저장
  • 검색 시점 사용자가 "올리"라고 입력했을 때 또 edge_ngram을 적용하면 [올, 올리]로 쪼개져 두 토큰 모두 매칭을 시도한다.

만약 검색 시점에도 edge_ngram을 쓰면, "올리"가 [올, 올리]로 확장되고, 이 중 "올"은 전혀 다른 상품("올영세일"의 올 부분)과도 매칭되어 정확도가 급락한다. 그래서 일반적으로 다음 패턴을 쓴다.

json
"mappings": {
  "properties": {
    "name": {
      "type": "text",
      "analyzer": "autocomplete_index_analyzer",
      "search_analyzer": "standard"
    }
  }
}

색인은 edge_ngram으로 넓게 쪼개고, 검색은 standard나 keyword로 그대로 넣는다. 이 비대칭이 성능과 정확도 양쪽의 핵심이다.

bad vs improved 예제

Bad: 맹목적으로 nori만 적용하기

json
{
  "settings": {
    "analysis": {
      "analyzer": {
        "default": {
          "type": "nori"
        }
      }
    }
  }
}

이 설정의 문제는 세 가지다.

  1. decompound_mode가 기본값(discard)이라 원본 단어가 사라진다. "올리브영"이 사전에 없거나 다르게 분해되면 "올리브영"이라는 완전한 토큰이 인덱스에 아예 없다.
  2. 불용 품사 필터가 없어 조사·어미가 그대로 들어간다. "검색했다"가 [검색, 하, 였, 다] 같은 토큰으로 쪼개져 저장된다.
  3. 브랜드/상품 사전 관리가 없어 신조어마다 결과가 깨진다.

Improved: 역할별 analyzer 분리

json
{
  "settings": {
    "analysis": {
      "analyzer": {
        "korean_search": {
          "type": "custom",
          "tokenizer": "nori_tokenizer_mixed",
          "filter": ["lowercase", "ko_pos_filter"]
        },
        "autocomplete_index": {
          "type": "custom",
          "tokenizer": "edge_ngram_tokenizer",
          "filter": ["lowercase"]
        },
        "autocomplete_search": {
          "type": "custom",
          "tokenizer": "keyword",
          "filter": ["lowercase"]
        }
      },
      "tokenizer": {
        "nori_tokenizer_mixed": {
          "type": "nori_tokenizer",
          "decompound_mode": "mixed"
        },
        "edge_ngram_tokenizer": {
          "type": "edge_ngram",
          "min_gram": 1,
          "max_gram": 15,
          "token_chars": ["letter", "digit"]
        }
      },
      "filter": {
        "ko_pos_filter": {
          "type": "nori_part_of_speech",
          "stoptags": ["E", "J", "MAG", "SP"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "korean_search"
      },
      "name_autocomplete": {
        "type": "text",
        "analyzer": "autocomplete_index",
        "search_analyzer": "autocomplete_search"
      }
    }
  }
}

핵심은 한 필드에 여러 analyzer를 쓰지 않고, 목적별로 필드를 분리한다는 점이다. name은 정확도 중심, name_autocomplete은 부분 일치 중심으로 가고, 애플리케이션에서 용도에 맞게 쿼리한다.

로컬 연습 환경

도커로 OpenSearch를 띄우고 nori 플러그인을 설치한다.

bash
# docker-compose.yml
version: "3"
services:
  opensearch:
    image: opensearchproject/opensearch:2.13.0
    environment:
      - discovery.type=single-node
      - DISABLE_SECURITY_PLUGIN=true
      - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
    ports:
      - "9200:9200"
    volumes:
      - ./data:/usr/share/opensearch/data

컨테이너에 들어가 nori 플러그인을 설치한다.

bash
docker exec -it <container> bash
./bin/opensearch-plugin install analysis-nori
exit
docker restart <container>

실행 가능한 예제

_analyze API로 analyzer의 결과를 직접 확인할 수 있다. 이 API는 평소 디버깅에서 가장 많이 쓰는 도구다.

예제 1: nori decompound_mode 비교

bash
curl -X POST "localhost:9200/_analyze" -H 'Content-Type: application/json' -d'
{
  "tokenizer": {
    "type": "nori_tokenizer",
    "decompound_mode": "mixed"
  },
  "text": "대한민국헌법재판소"
}'

mixed에서는 [대한민국헌법재판소, 대한민국, 헌법, 재판소, ...]처럼 원본과 분해본이 모두 나온다. discard로 바꾸면 원본이 사라진다.

예제 2: edge_ngram 자동완성 확인

bash
curl -X POST "localhost:9200/_analyze" -H 'Content-Type: application/json' -d'
{
  "tokenizer": {
    "type": "edge_ngram",
    "min_gram": 1,
    "max_gram": 10,
    "token_chars": ["letter", "digit"]
  },
  "text": "올리브영"
}'

결과로 [올, 올리, 올리브, 올리브영]이 나오면 색인이 제대로 되는지 쉽게 눈으로 확인할 수 있다.

예제 3: 사용자 사전 적용 후 비교

사전 없는 경우와 있는 경우의 토큰을 _analyze로 각각 실행해 비교한다. 사전에 "맥세이프"를 추가하기 전에는 [맥, 세이프]로 쪼개지지만, 추가 후에는 [맥세이프] 하나로 유지된다. 이 차이가 실제 검색 품질에서 눈에 보일 만큼 크다는 점을 직접 확인하는 것이 중요하다.

예제 4: 비대칭 analyzer로 자동완성 끝까지 검증

색인 후 실제 검색을 날려 본다.

bash
curl -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "query": {
    "match": {
      "name_autocomplete": {
        "query": "올리",
        "operator": "and"
      }
    }
  }
}'

검색어 "올리"가 standard로 분석되어 그대로 들어가고, 색인된 edge_ngram 토큰 중 "올리"와 정확히 일치하는 항목이 걸린다. 만약 검색 시에도 edge_ngram을 쓰고 있다면 정확도가 떨어진다는 점을 직접 비교해 본다.

흔한 오해와 실패 패턴

  • 오해: "nori를 쓰면 자동으로 검색 품질이 좋아진다." 사용자 사전 관리, 품사 필터 튜닝 없이 넣으면 오히려 엉망이 된다. 운영 중 검색 로그를 보면서 사전 관리는 꾸준히 해야 한다.
  • 오해: "ngram과 edge_ngram은 같은 것이다." 용도가 완전히 다르다. 자동완성에는 edge_ngram, 중간 부분 일치(한정적으로)에는 ngram이다.
  • 실패 패턴: 색인/검색 analyzer를 동일하게 두기. ngram류에서 가장 빈번한 버그다. search_analyzer를 명시하지 않아 검색 정확도가 무너지는 경우가 매우 많다.
  • 실패 패턴: min_gram을 1로 두기. 인덱스 크기가 수 배로 커지고 아무 단어나 매칭되기 시작한다. 자동완성이라도 보통 2부터 시작하는 것을 고려한다.
  • 실패 패턴: analyzer를 바꾸고 reindex를 안 하기. analyzer 변경은 새로 색인된 문서에만 적용된다. 기존 문서는 예전 토큰을 가지고 있기 때문에, analyzer 변경 후에는 새 인덱스를 만들고 alias 스왑으로 이관하는 패턴이 정석이다.

설계 관점에서의 trade-off

실무에서는 단일 analyzer로 모든 요구사항을 만족시킬 수 없다. 보통 다음처럼 간다.

  • 정확한 의미 검색: nori + 품사 필터 + 사용자 사전
  • 부분 일치/오타 관대 검색: ngram 또는 nori + synonym filter
  • 자동완성: edge_ngram (색인) + keyword/standard (검색)
  • 다국어 상품명: 필드 분리 후 각 언어별 analyzer

필드를 분리하는 설계가 핵심이다. multi_fields를 써서 name, name.autocomplete, name.raw 같이 한 소스를 여러 형태로 색인하면, 쿼리 레이어에서 가중치만 조정해도 깔끔하게 검색 품질을 튜닝할 수 있다.

인덱스 크기와 색인 속도도 반드시 같이 본다. edge_ngram min_gram: 1, max_gram: 20은 이론적으로는 가능하지만, 실제로는 인덱스가 몇 배로 불어나 색인 처리량이 눈에 띄게 떨어진다. 자동완성은 보통 min_gram: 2, max_gram: 10~15 범위에서 타협한다.

면접 답변 프레임

면접에서 "OpenSearch analyzer를 어떻게 설계하셨나요?"라는 질문이 나오면 다음 구조로 답하는 게 안정적이다.

  1. 문제 정의 먼저: "상품 검색에서 한글 부분 일치와 자동완성이 모두 필요했다. 단일 필드·단일 analyzer로는 정확도와 재현율을 동시에 만족시킬 수 없었다."
  2. 구성 요소로 분해: "analyzer는 character filter → tokenizer → token filter 파이프라인이고, 한국어는 nori_tokenizer, 자동완성은 edge_ngram tokenizer로 분리했다."
  3. 선택 근거: "정확도 중심 필드에는 nori + part_of_speech filter로 조사/어미를 제거했고, 자동완성 필드에는 edge_ngram을 색인 analyzer로, standard를 search analyzer로 둬 비대칭 구성을 했다."
  4. 운영 관점 언급: "브랜드명·신조어는 user_dictionary로 관리하고, analyzer 변경 시에는 새 인덱스 + alias 스왑으로 무중단 reindex했다."
  5. 숫자로 마무리: "그 결과 검색 누락 케이스를 약 X% 줄였고, 자동완성 latency도 Y ms 이내로 유지됐다."

즉답보다는 "왜 이런 구성이 필요했는지"를 문제 정의부터 풀어가는 게 시니어다운 답변이다. 인덱스 크기, 색인 속도, reindex 전략까지 자연스럽게 언급할 수 있으면 운영 경험이 있는 엔지니어라는 인상을 준다.

관련 예상 질문

  • nori의 decompound_mode 세 가지 차이를 설명하라. 어떤 상황에서 어떤 모드를 쓰는가.
  • edge_ngram과 ngram의 차이, 그리고 각각의 대표적인 유스케이스는.
  • 색인 analyzer와 검색 analyzer를 다르게 가져가는 이유는.
  • 사용자 사전은 왜 필요하고, 운영상 어떤 리스크가 있나.
  • analyzer를 변경할 때 기존 문서에는 왜 바로 반영되지 않는가. 어떻게 처리하는가.
  • min_gram을 너무 작게 잡으면 어떤 문제가 생기는가.
  • 특정 쿼리에서 기대한 결과가 나오지 않을 때 어떤 순서로 디버깅하는가. (힌트: _analyze, explain, profile API)

체크리스트

  • character filter, tokenizer, token filter의 역할과 순서를 그림 없이 설명할 수 있다.
  • nori_tokenizer의 decompound_mode 세 가지를 상황별로 고를 수 있다.
  • nori_part_of_speech의 stoptags 의미를 이해하고 필요한 품사를 고를 수 있다.
  • 사용자 사전을 추가하고 _analyze로 토큰 변화를 검증할 수 있다.
  • edge_ngram과 ngram을 용도에 맞춰 고르고, min_gram/max_gram의 영향을 설명할 수 있다.
  • 색인 analyzer와 검색 analyzer를 비대칭으로 설정하는 이유를 설명할 수 있다.
  • analyzer 변경 시 reindex + alias 스왑 흐름을 실제로 구성해 본 적이 있다.
  • _analyze API로 실제 토큰을 확인하고 검색 버그를 추적해 본 경험이 있다.
  • 인덱스 크기와 색인 속도 관점에서 ngram 설정을 튜닝할 수 있다.
  • 면접에서 "문제 정의 → 구성 요소 분해 → 선택 근거 → 운영 관점 → 결과" 순으로 답변 가능한 사례 하나가 준비되어 있다.
on this page
  • 01왜 이 주제를 공부해야 하는가
  • 02OpenSearch analyzer의 기본 구조
  • 03nori 플러그인: 한국어 형태소 분석기
  • nori의 구성 요소
  • nori 설정 예시
  • 04ngram tokenizer: 부분 일치와 자동완성
  • ngram vs edge_ngram
  • 색인 시점 vs 검색 시점 analyzer
  • 05bad vs improved 예제
  • Bad: 맹목적으로 nori만 적용하기
  • Improved: 역할별 analyzer 분리
  • 06로컬 연습 환경
  • 07실행 가능한 예제
  • 예제 1: nori decompound_mode 비교
  • 예제 2: edge_ngram 자동완성 확인
  • 예제 3: 사용자 사전 적용 후 비교
  • 예제 4: 비대칭 analyzer로 자동완성 끝까지 검증
  • 08흔한 오해와 실패 패턴
  • 09설계 관점에서의 trade-off
  • 10면접 답변 프레임
  • 11관련 예상 질문
  • 12체크리스트

댓글 (0)