검색 엔지니어링수정 2026-08-07

검색 엔지니어링 Q&A — 한글 검색부터 클러스터 운영까지 17문답

초성·한영검색 구현, 자동완성 분석기 분리, 무결과 폴백, 샤드 설계, 힙 튜닝, 장애 복구까지 Elasticsearch 실무에서 반복해 부딪히는 질문을 한 곳에 모았다.

검색 시스템을 다루다 보면 같은 질문이 반복해서 돌아온다. "초성검색은 어떻게 만드나", "왜 이 문서가 위에 뜨나", "클러스터가 갑자기 쓰기를 거부하는데 어디부터 보나" 같은 것들이다. 이 글은 그 질문들을 한 곳에 모아 결론부터 답한다.

먼저 Elasticsearch의 뼈대를 세 줄로 요약해 두면 이후 답이 잘 붙는다.

  • 분석기 3단계: char filter → tokenizer(1개만) → token filter.
  • 색인 경로: 버퍼(+translog) → refresh(=Lucene flush, 이때부터 검색 가능) → flush(=Lucene commit, 디스크) → merge.
  • 검색 2단계(Query then Fetch): ① 전 샤드에 broadcast해 docID+score만 취합 → ② 선별된 문서만 내용을 fetch.
  • 역인덱스: 단어 → 문서. 정렬·집계 필드는 keyword(doc_values) 로 잡는다(text는 fielddata로 힙을 태운다).

각 주제의 전체 맥락은 Elasticsearch 아키텍처, 한글 검색 구현, 운영과 트러블슈팅에서 다룬다.

핵심 수치 정리

검색 설계에서 반복해 등장하는 숫자들이다. 값만 외우면 응용이 안 되므로 왜 그 값인지를 함께 둔다.

주제왜 그 값인가
한글 음절 수초성 19 × 중성 21 × 종성 28 = 11,172 (유니코드 AC00~D7A3)종성 28은 '종성 없음'을 포함한 수. 이 곱셈 구조 덕분에 음절 코드에서 초성을 역산할 수 있고, 그래서 초성검색이 성립한다
refresh_interval기본 1초, 대량 색인 시 5m~10m 또는 -1버퍼를 검색 가능한 세그먼트로 바꾸는 주기다. 1초가 NRT의 근거이고, 대량 색인 중 잦으면 작은 세그먼트가 폭증해 머지 부담이 커진다
자동완성 응답 목표100ms 이내타이핑 중 글자마다 호출되므로 왕복 지연이 그대로 체감된다. 사람이 "느리다"고 느끼기 시작하는 경계가 100ms대다
edge_ngram 적용 위치색인 분석기에만검색어까지 n-gram으로 쪼개면 의도하지 않은 문서까지 매칭된다
기본 유사도BM25 (ES 7.0+)TF-IDF에 문서 길이 보정(k1·b)과 TF 포화를 더한 것. 같은 단어를 도배한 문서가 상위를 먹는 것을 막는다
fuzziness AUTO02자 완전매칭 / 35자 거리 1 / 6자~ 거리 2짧은 단어에 편집거리를 허용하면 전혀 다른 단어가 매칭된다. 길이에 비례해 관용도를 올리는 설계다
마스터 후보 수홀수 3 또는 5, quorum = (n/2)+1짝수면 네트워크 분단 시 양쪽 다 정족수를 못 채워 정지하거나, 각자 마스터를 뽑아 데이터가 갈라진다
JVM 힙Xms=Xmx, 32GB 이하, 총 메모리의 50% 이하32GB를 넘으면 compressed oops가 꺼져 포인터가 커진다. 나머지 절반은 OS 페이지 캐시가 세그먼트를 캐싱해야 검색이 빠르다
디스크 watermarklow 85% / high 90% / flood_stage 95%85%에서 신규 샤드 할당을 멈추고, 90%에서 샤드를 재배치하고, 95%에서 인덱스를 read-only로 잠근다. 단계적으로 경고를 준다
샤드 크기검색 20GB / 로깅 50GB per shard샤드 하나가 곧 복구·재배치 단위다. 너무 크면 복구가 느리고, 너무 잘게 쪼개면 머지·스레드 오버헤드가 커진다
OS 커널nofile 65535, vm.max_map_count 262144, vm.swappiness 1운영 모드(특정 주소 바인딩) 부트스트랩 체크 항목이다. 하나라도 미달이면 노드가 아예 뜨지 않는다

Elasticsearch가 기본 제공하지 않아 직접 구현해야 하는 것

한국어 검색에서 사용자가 당연하게 기대하는 기능 중 상당수는 Elasticsearch에 기본 기능이 없다. 상용 검색엔진에서는 규격서 한 줄로 주어지던 것들이라, 오픈소스로 넘어올 때 가장 먼저 부딪히는 벽이다.

기능ES 기본 제공직접 만들어야 하는 것
초성검색없음유니코드 자소분해 + 커스텀 플러그인(token filter)
한영검색(자판 오타)없음한/영 키맵 변환 필터를 색인·검색에 병행
자동완성부품만(edge_ngram·completion suggester)색인/검색 분석기 분리 + 초성·자모·영타·한타 멀티필드 설계
무결과 처리정책 없음단계적 폴백을 정책으로 설계

무결과 폴백 순서: 정타 → fuzzy(오타) → 동의어 확장 → 초성/한영 변환 → 인기·추천. 여기에 significant_terms(연관어 제안)와 큐레이션을 얹는다.

Q. 왜 DB의 LIKE 검색이 아니라 검색엔진을 쓰나

LIKE '%키워드%'는 인덱스를 타지 못해 풀스캔이 되고, 오타·동의어·형태소·랭킹을 표현할 수단이 아예 없기 때문이다.

요구DB LIKE검색엔진
부분일치·유사어·복합조건SQL로 표현이 어렵거나 불가분석기 + Query DSL로 조합
성능인덱스 미사용 → 풀스캔역인덱스로 term → 문서ID 즉시 조회
사용자 의도반영 수단 없음형태소·동의어·오타교정·랭킹으로 반영
결과 순서정렬 컬럼 기준BM25 점수 + function_score 비즈니스 로직

핵심은 세 가지를 역인덱스 + 분석기 + 랭킹으로 푼다는 점이다. DB는 "조건에 맞는 행"을 주지만 검색엔진은 "연관성 높은 순서"를 준다.

Q. 초성검색을 어떻게 구현하나

Elasticsearch에 초성 추출 기능이 없으므로, 유니코드 자소분해를 하는 커스텀 플러그인을 token filter 단계에 넣어 초성 필드를 따로 색인한다.

한글은 초성 19·중성 21·종성 28의 조합이라 음절 코드에서 초성을 역산할 수 있다.

음절코드 = (초성×21 + 중성)×28 + 종성 + 0xAC00

종성 = v % 28
중성 = ((v - 종성) / 28) % 21
초성 = (((v - 종성) / 28) - 중성) / 21
결정 지점선택이유
구현 위치token filter 단계tokenizer 단계에서 자소분해하면 그 뒤에 동의어·불용어 필터를 얹기 어렵다('스벅'→'스타벅스' 확장이 필요하다)
색인 구조원본 필드 + 초성 서브필드검색 시 multi_match로 함께 질의해 "ㅊㅅ" 입력에 "초성"이 매칭되게 한다
배포각 노드에 jar 설치 후 재시작플러그인은 ES·Lucene 버전에 종속이라 gradle.properties의 버전을 맞춰 빌드해야 한다

Q. 한영검색(자판 오타 교정)은 어떻게 처리하나

한/영 키맵 변환 필터로 만든 서브필드를 색인해 두고, 검색 시 원문 필드와 함께 multi_match로 질의한다. 자판 레이아웃 매핑 문제라 ES 기본 기능이 아니라 커스텀 필터 영역이다.

방향필터입력 → 변환
영타 → 한글eng2kor_filtertmxkqjrtm → 스타벅스
한타 → 영문kor2eng_filterㄴㅅㅁㄱㅠ… → starbucks

오타 대응 자체는 fuzzy·동의어·자소분해로 상당 부분 커버되지만, 자판 전환 실수는 편집거리로 잡히지 않는다. "dkssud"와 "안녕"은 문자열로는 완전히 다르기 때문에 별도 변환 경로가 필요하다.

Q. 자동완성에서 색인용과 검색용 분석기를 분리하는 이유

검색어까지 edge_ngram으로 쪼개면 다시 n-gram으로 분해되어 의도하지 않은 문서까지 매칭되기 때문이다. edge_ngram색인 분석기에만 적용한다.

위치분석기이유
색인edge_ngram 포함"커피"를 [커, 커피]로 확장해 저장
검색edge_ngram 제외입력을 그대로 매칭. 재확장하면 노이즈가 생긴다
"index_completion":  { "type":"custom", "tokenizer":"standard",
  "filter":["lowercase","trim","autocomplete_edge"] },   // edge_ngram 포함
"search_completion": { "type":"custom", "tokenizer":"standard",
  "filter":["lowercase","trim"] }                          // edge_ngram 제외

search_analyzer를 매핑에 명시하지 않으면 색인 분석기가 검색에도 그대로 쓰인다. 멀티필드를 쓸 때는 반드시 분리해서 지정한다.

Q. 자동완성 방식은 무엇을 기준으로 고르나

부분일치 범위·오타 허용·속도의 트레이드오프로 고른다. 앞부분만 매칭하면 되면 edge_ngram이 가장 가볍고, 중간 일치가 필요하면 색인 비용을 감수해야 한다.

방식원리prefix중간일치오타비고
ngram글자 단위 전부 분해OO토큰 폭발 주의
edge_ngram앞에서부터만 분해(cat→c,ca,cat)OX색인 토큰 적음
search_as_you_type전용 타입, _2gram/_3gram 자동생성OOXmulti_match+bool_prefix
completion suggesterFST를 메모리 적재OXO(fuzzy)최고속, weight·context 지원

한글은 여기에 더해 초성·자모·영타·한타 서브필드를 병렬 색인하고 multi_match(cross_fields)로 묶는 조합을 주로 쓴다. 다만 "아이폰" 한 단어에도 자소를 포함해 최대 7회 호출이 발생하므로, 클라이언트에서 debouncing·throttling으로 호출을 줄이는 것이 함께 가야 한다.

Q. Nori가 형태소를 이상하게 쪼갤 때 어디부터 보나

_analyze API로 실제 토큰을 먼저 확인하고, 원인에 따라 decompound_mode나 사용자사전을 조정한다. 추측으로 사전을 건드리면 부작용만 늘어난다.

증상원인처방
복합어가 통째로/과하게 분해됨decompound_mode 설정none(통째로) / discard(분해만) / mixed(둘 다) 중 선택
도메인 용어가 안 잡힘사전에 없는 신조어·상품명user_dictionary에 등록("싼타페TM" → "TM" 검색 가능)
영문 대문자가 분석 실패Nori가 대소문자를 구분 못 함(C++ 등)char filter 단계에서 소문자화
엉뚱한 위치에서 분해됨짧은 단어를 사전에 등록한 부작용'강남'을 등록하면 "강남콩"이 "강남+콩"으로 오분석된다. 등록어 길이를 신중히

근본 원인은 사전이다. Nori는 mecab-ko-dic(1998 세종 말뭉치) 기반이라 신조어·외래어에 취약하다. 사전을 지속 갱신하는 운영 프로세스가 설정 튜닝보다 중요하다.

Q. 동의어를 색인 시점과 검색 시점 중 언제 적용하나

사전이 안정적이면 색인 시(성능), 자주 바뀌면 검색 시(실시간) 로 나눈다.

색인 시 적용검색 시 적용
장점검색 성능↑(질의 때 확장 비용 없음)실시간 업데이트 가능
단점인덱스 커짐·term 통계 왜곡·Reindex 필요검색마다 확장 비용
적합표준 용어·브랜드명처럼 잘 안 바뀌는 사전이벤트·시즌 키워드처럼 자주 바뀌는 사전

검색 시 방식은 search_analyzersynonym(updateable: true, 파일 기반)을 두고 _reload_search_analyzers + _cache/clear?request=true를 호출하면 재색인 없이 반영된다. 동의어 표현은 동등관계 "A, B"(양방향)와 치환 "A => B"(단방향) 두 가지이며, synonym_graph가 권장된다.

Q. 검색 결과가 0건일 때 무엇을 보여주나

0건은 이탈로 직결되므로 단계적 폴백을 정책으로 설계해 "뭐라도" 보여준다. 엔진 기능이 아니라 정책 결정이라는 점이 핵심이다.

도식을 탭하면 확대해서 볼 수 있습니다

폴백 이전에 0건 자체를 줄이는 수단도 함께 쓴다. 동의어로 이표기를 정타에 매핑하고("횬대"→"현대"), significant_terms로 연관 검색어를 제안하고, 큐레이션으로 특정 결과를 노출한다. 다만 Promoted 큐레이션은 관련성 점수를 무시하므로 남용하면 검색 품질이 왜곡된다.

Q. 왜 이 문서가 검색 상위에 뜨는지 어떻게 설명하나

BM25 기본 점수 → function_score 가중 → explain으로 실측의 순서로 분해한다. 추정하지 않고 점수를 뜯어보는 것이 요령이다.

무엇이 점수를 만드나확인·조정 방법
기본 유사도BM25 — TF(빈도) × IDF(희소성) + 문서 길이 보정explain: true로 항목별 분해
비즈니스 가중function_scoredecay(최신·거리), field_value_factor(평점·판매량)score_mode·boost_mode로 결합 방식 지정
왜곡 보정단어 도배로 TF가 부풀 때scripted similarity로 TF 영향 축소(예: 1/sqrt(docLen))

분산 환경에서는 점수가 로컬 샤드에서 계산된다는 점도 알아둘 만하다. IDF는 원래 전역 정보가 필요하지만 전역 계산은 비싸서 하지 않고, 데이터가 고르게 분산돼 있으면 로컬 IDF도 거의 같아 문제가 없다. 정확한 전역 점수가 필요하면 ?search_type=dfs_query_then_fetch를 쓴다.

Q. 클러스터가 갑자기 쓰기를 거부할 때 어디부터 보나

디스크 watermark의 flood_stage(95%)를 1순위로 의심한다. _cat/allocation으로 노드별 디스크 사용률부터 본다.

임계설정ES가 하는 일
85%...disk.watermark.low새 샤드 할당 중단(신규 인덱스는 예외)
90%...disk.watermark.high해당 노드의 샤드를 다른 노드로 재배치
95%...disk.watermark.flood_stage인덱스를 read-only로 잠금

복구는 디스크를 확보한 뒤 index.blocks.read_only_allow_delete를 해제해야 풀린다. 자동으로 풀리지 않는다는 점이 함정이다. 근본 대책은 ILM으로 오래된 인덱스를 warm/cold로 내리거나 삭제해 hot 노드 용량을 관리하는 것이다.

Q. yellow와 red는 무엇이 다르고 어떻게 대응하나

yellow는 replica 문제라 서비스가 정상이고, red는 primary 손실이라 일부 데이터에 접근이 안 된다. 상태를 결정하는 것은 대부분 샤드다.

상태의미서비스 영향흔한 원인
🟢 GreenPrimary·Replica 모두 정상 할당없음
🟡 YellowReplica가 비정상(Primary는 정상)검색·색인 정상노드 부족·디스크 부족·설정 오류
🔴 RedPrimary가 비정상일부 데이터 접근 불가위 원인 + 레플리카 없는 샤드 손실

red 복구는 ① 문제 인덱스 확인 → ② 노드 종료가 원인이면 재시작 → ③ 스냅샷 복구 → ④ 재색인 순으로 접근한다. 애초에 red를 막는 선제 조치는 replica를 최소 1개 두고 SLM으로 스냅샷을 상시 걸어 두는 것이다.

Q. 마스터 후보 노드를 몇 대로 두나

홀수, 최소 3대다. quorum이 (후보수/2)+1이라 홀수여야 네트워크 분단 시 다수파만 생존하고 소수파는 스스로 멈춘다.

마스터후보 수quorum네트워크 분단 시
2 (짝수)2양쪽 다 정족수 미달 → 전체 정지 (위험)
3 (홀수)2다수파만 생존 (안전)
4 (짝수)3위험
5 (홀수)3안전

운영 중 원칙이 두 가지 더 있다. 한 번에 절반 이상 노드를 빼지 않는다(quorum이 깨지면 클러스터 전체가 unavailable). 그리고 최초 구성 시 cluster.initial_master_nodes에 후보를 모두 넣되 구성 완료 후에는 제거한다.

Q. 인덱스 샤드 개수는 어떻게 정하나

정답은 없고 샤드당 용량 상한 + 노드 수 균형으로 산정한다. 과소보다 과다 샤드가 더 위험하다는 것이 실무 감각이다.

기준값·규칙근거
샤드당 크기검색용 20GB / 로깅용 50GB샤드가 복구·재배치 단위라 너무 크면 복구가 느리다
primary 수예상 총량 ÷ 샤드당 상한생성 후 변경 불가라 초기 산정이 중요하다
replica 수1로 시작색인이 느려지면 줄이고, 검색 처리량이 부족하면 늘린다
증가 대응새 인덱스로 분리(rollover)샤드를 잘게 쪼개면 머지·네트워크·스레드 오버헤드로 오히려 검색이 느려진다

primary 개수를 나중에 못 바꾸는 이유는 라우팅 규칙(hash(_id) % primary_shards) 때문이다. 개수가 바뀌면 전 문서의 위치가 어긋나므로 재색인해야 한다.

Q. 힙을 32GB 넘게 주면 안 되는 이유

32GB를 넘으면 JVM의 compressed ordinary object pointer가 꺼져 포인터가 커지고 오히려 효율이 떨어지기 때문이다. 메모리를 더 줬는데 성능이 나빠지는 구간이 생긴다.

원칙이유
Xms = Xmx동일하게 고정힙 리사이징으로 인한 GC 변동을 없앤다
상한32GB 이하초과 시 compressed oops 상실 → 포인터 크기 증가
비율총 메모리의 50% 이하나머지 절반은 OS 페이지 캐시가 세그먼트 파일을 캐싱해야 검색이 빠르다

ES 성능이 파일시스템 캐시에 크게 의존한다는 점이 세 번째 규칙의 근거다. 힙을 키우는 것이 곧 성능 향상이 아니다.

Q. 대량 색인에는 어떤 튜닝을 하나

색인 중에는 refresh를 늦추고 replica를 끄고, 끝나면 되돌린다. 색인 내부 동작(버퍼 → refresh → 세그먼트 → merge)을 이해하면 자연스럽게 나오는 처방이다.

항목색인 중
refresh_interval5m~10m 또는 -1refresh는 버퍼를 세그먼트로 만드는 작업이다. 잦으면 작은 세그먼트가 폭증해 머지 부담이 커진다
number_of_replicas0 → 완료 후 복구replica가 있으면 같은 문서를 복제본에도 색인해 부하가 배가된다
Bulk 크기100→200→400 두 배씩 벤치429가 나기 직전 크기가 최적점이다. 요청당 수십MB 이하로 유지한다
indices.memory.index_buffer_size기본 힙 10%에서 상향버퍼가 가득 차면 강제 flush가 발생한다
하드웨어SSD/NVMe색인은 disk I/O 의존도가 높다

refresh_interval: -1로 두면 색인해도 검색 결과가 0건이라는 점에 주의한다. 되돌리는 것을 잊으면 "데이터를 넣었는데 검색이 안 된다"는 장애로 나타난다.

Q. 롤링 배포와 블루그린 배포는 언제 무엇을 고르나

자원을 아끼며 무중단이면 롤링, 즉시 전환·빠른 롤백이 필요하고 자원이 2배 있으면 블루그린이다. 메이저 업그레이드는 하위호환이 없어 중단 배포와 짝이 된다.

방식다운타임자원언제
중단(Restart)있음1배Major 업그레이드, 무중단이 불필요한 서비스
롤링(Rolling)없음1배점진 배포·자원 최적화, 마이너 업그레이드
블루그린(Blue/Green)없음2배즉시 전환·빠른 롤백이 필요할 때

롤링 시에는 index.unassigned.node_left.delayed_timeout(기본 1m)을 5m 정도로 늘린다. 그러지 않으면 노드가 잠깐 빠질 때마다 샤드가 대량으로 재할당되어 불필요한 부하가 생긴다.

Q. 상용 검색엔진에서 Elasticsearch로 넘어오면 운영상 무엇이 달라지나

벤더가 대신 지던 결정을 전부 직접 지게 된다. 기능 목록은 비슷해 보여도 의사결정의 양과 종류가 완전히 달라진다.

영역상용엔진Elasticsearch
한글 처리벤더 규격서·사전 제공분석기 설계·Nori 사전 운영을 직접
초성·한영·자동완성규격 내 지원커스텀 플러그인 빌드·배포까지 직접
인프라벤더 권장 구성커널 튜닝(FD·swap·vm.max_map_count)부터 직접
샤드·용량벤더 산정샤드 크기·마스터 정족수·watermark를 직접 설계
장애 대응벤더 지원yellow/red 판별·스냅샷 복구를 직접

특히 OS 설정은 놓치면 노드가 아예 뜨지 않는 형태로 나타나므로, 운영 체크리스트를 문서로 남겨 두는 것이 전환 초기의 실질적인 대비책이다. 부담이 커지는 만큼 검색엔진을 블랙박스가 아니라 내부까지 이해하고 튜닝할 수 있게 된다는 것이 반대급부다.

용어 정리

용어
역인덱스단어 → 문서 목록. 검색이 빠른 이유
세그먼트불변 색인 파일 단위. merge로 정리된다
NoriElasticsearch 공식 한글 형태소 분석기
edge n-gram앞에서부터 만드는 부분 토큰(자동완성용)
BM25기본 유사도(빈도 × 희소성 + 길이 보정)
quorum마스터 선출 정족수 (n/2)+1
watermark디스크 사용률 임계선(85/90/95%)
doc_values정렬·집계용 컬럼형 자료구조(디스크)