Elasticsearch 유의사항 — refresh 지연·매핑 폭발·deep pagination

ES는 개발 환경에서 잘 돌아간다. 문서 몇 천 건 넣고 검색하면 다 잘 된다. 문제는 트래픽이 붙고 데이터가 쌓이는 운영에서 터진다. 방금 등록한 게 검색에 안 나오고, 클러스터가 갑자기 불안정해지고, 페이징이 깊어지면 메모리가 폭발한다.

대부분은 ES가 RDB가 아니라는 걸 잊어서 생긴다. 트랜잭션도, 즉시 일관성도, 자유로운 스키마도 없다. 자주 밟는 함정을 ‘왜 생기나 → 증상 → 어떻게 피하나’로 정리한다.


refresh 지연 — “방금 저장했는데 검색에 없어요”

왜 생기나. ES는 색인 즉시 검색에 반영하지 않는다. 인메모리 버퍼에 모았다가 refresh(기본 1초)마다 세그먼트로 만들어야 검색에 보인다. 이게 “준실시간(near real-time)“의 정체다.

→ 세그먼트와 refresh의 내부 동작: Elasticsearch 동작 원리 — 역색인부터 세그먼트까지

증상. 저장 직후 곧바로 검색 API를 호출하는 read-after-write 흐름에서 방금 쓴 문서가 0건으로 나온다. 테스트에서 간헐적으로 실패하고, 사용자는 “등록했는데 안 보여요” 문의를 넣는다.

어떻게 피하나.

  • ID를 아는 단건 조회는 검색이 아니라 GET /index/_doc/{id}를 쓴다. realtime GET이라 refresh와 무관하게 최신을 읽는다(translog 기반).
  • 정말 즉시 검색에 보여야 하면 색인 요청에 ?refresh=wait_for를 준다. 강제 refresh 비용이 들지만 그 요청만 refresh가 끝날 때까지 기다린다. refresh=true(즉시 강제 refresh)는 세그먼트를 남발하니 트래픽 경로에선 피한다.
  • 가장 안전한 설계는 읽기 직후 일관성을 ES에 기대지 않는 것이다. 화면에 바로 보여줄 데이터는 RDB에서 읽고, ES는 검색 용도로만 쓴다.

동적 매핑 폭발 — 클러스터가 갑자기 불안정해진다

왜 생기나. ES는 처음 보는 JSON 키를 자동으로 필드로 만든다(dynamic mapping). 편하지만, 키가 통제 안 되면 필드 수가 무한히 늘어난다. 사용자 입력이나 로그의 임의 키(attr_1, attr_2, … 또는 에러 메시지를 키로 쓰는 경우)를 그대로 색인하면 필드가 수천, 수만 개로 폭증한다. mapping explosion이다.

증상. 필드 메타데이터가 클러스터 상태(cluster state)를 비대하게 만들고, 매핑 갱신이 마스터 노드를 압박해 클러스터 전체가 느려지거나 불안정해진다. ES는 기본적으로 인덱스당 필드 1,000개(index.mapping.total_fields.limit) 제한을 두는데, 이걸 올려서 막으려다 더 키우는 실수가 흔하다.

어떻게 피하나.

  • 스키마가 고정이면 "dynamic": "strict"로 정의 안 된 필드가 들어오면 거부하게 한다. 받아주되 색인은 안 하려면 "dynamic": false.
  • 키가 가변적인 데이터(태그 맵, 속성 맵)는 flattened 타입으로 통째 하나의 필드처럼 다룬다. 내부 키마다 필드를 만들지 않아 폭발을 막는다.
  • 필드 수 한도를 무작정 올리지 말고 데이터 모델을 먼저 의심한다.

deep pagination — 뒤 페이지로 갈수록 메모리가 터진다

왜 생기나. from + size 페이징은 scatter-gather 구조상 각 샤드가 from + size개를 모아 코디네이터로 보낸다. from: 100000이면 모든 샤드가 10만 건 이상을 정렬해 모으고, 코디네이터는 (샤드 수 × from+size)를 메모리에 올려 정렬한 뒤 한 페이지만 남긴다.

증상. 페이지가 깊어질수록 응답이 느려지고 힙이 출렁인다. ES는 index.max_result_window(기본 10,000)로 from+size를 막아 Result window is too large 에러를 던진다.

어떻게 피하나.

  • 무한 스크롤/순차 페이징은 **search_after + PIT(Point In Time)**를 쓴다. 직전 페이지 마지막 정렬값을 커서로 넘겨 그 다음부터 읽으므로 깊이와 무관하게 비용이 일정하다. 정렬에 고유 tie-breaker(예: id)가 필수다.
  • 전체를 훑는 배치성 작업은 scroll을 쓴다(스냅샷 고정). 단 사용자 대면 페이징엔 부적합하고, 요즘은 search_after + PIT가 권장된다.

→ search_after 구현과 tie-breaker 코드: Spring Boot에서 Elasticsearch 다루기

샤드 수를 잘못 잡으면 되돌릴 수 없다

왜 생기나. 라우팅이 hash(_routing) % number_of_primary_shards라서, 프라이머리 샤드 수를 바꾸면 기존 문서의 소속 샤드가 전부 달라진다. 그래서 ES는 생성 후 프라이머리 샤드 수 변경을 막는다.

증상. 처음에 감으로 샤드 5개를 잡았는데 데이터가 예상보다 커져 샤드당 수백 GB가 됐다. 줄이거나 늘리려면 새 인덱스를 만들고 reindex하는 수밖에 없다. 운영 중이면 reindex + alias 스위칭으로 무중단 전환을 해야 해 비용이 크다.

어떻게 피하나. 처음에 데이터량을 예측해 샤드당 10~50GB가 되게 설계한다. 계속 쌓이는 시계열은 큰 인덱스 하나 대신 시간 단위로 롤오버해 인덱스당 샤드 수를 일정하게 유지한다.

→ 샤드 수 설계와 오버샤딩: Elasticsearch 성능 튜닝 — 샤드 설계·벌크 색인·검색 최적화 → 시계열 롤오버: Elasticsearch 핫/콜드 데이터 운영 — ILM과 데이터 티어

text 필드로 정렬·집계하면 힙이 터진다 (fielddata)

왜 생기나. 정렬·집계는 “문서 → 값” 조회라 컬럼 구조인 doc_values로 처리된다. 그런데 text 타입은 분석(토큰화)되기 때문에 doc_values가 없다. 그래도 text에 정렬/집계를 걸면 ES는 fielddata라는 인메모리 구조를 즉석에서 만든다.

증상. fielddata는 해당 필드의 모든 term을 힙에 올린다. 카디널리티가 큰 text 필드에 집계를 걸면 힙이 순식간에 차 OOM이나 circuit breaker(Data too large)가 터진다.

어떻게 피하나. 정렬·집계할 필드는 keyword 타입(doc_values 사용)으로 둔다. 같은 값을 검색도 하고 집계도 하면 멀티필드로 text + keyword를 함께 둔다. text에 fielddata: true를 켜는 건 거의 항상 잘못된 선택이다.

text와 keyword를 혼동하면 매칭이 어긋난다

왜 생기나. text는 분석돼서 토큰으로 쪼개지고, keyword는 통째 하나의 값으로 색인된다. 용도가 정반대인데 헷갈려 쓴다.

증상.

  • 카테고리 코드("ACCESSORY_CASE")를 text로 두면 형태소/토크나이저가 쪼개서, 정확 일치 term 쿼리나 집계가 엉뚱하게 동작한다.
  • 반대로 상품명을 keyword로만 두면 “갤럭시”로 부분 검색이 안 되고 정확히 전체 문자열이 같아야만 매칭된다.

어떻게 피하나. 전문 검색·부분 일치는 text, 정확 일치·정렬·집계·필터는 keyword. 둘 다 필요하면 멀티필드. 매핑 단계에서 “이 필드는 검색용인가, 필터/집계용인가”를 먼저 정한다.

→ 분석기와 토큰화의 동작: Elasticsearch Analyzer 내부 구조와 ngram 원리

그 밖에 자주 밟는 것들

  • aggregation 메모리. 카디널리티가 높은 필드의 terms 집계나 중첩 집계는 버킷이 폭증해 힙을 먹는다. 버킷 수를 제한하고, 정확값이 꼭 필요한 게 아니면 cardinality(HyperLogLog 근사)를 쓴다. 집계 전용 요청은 size: 0으로 히트를 끄면 request cache 이득도 본다.
  • reindex 무중단. 매핑을 바꾸려면(샤드 수, 타입, analyzer 변경 등) 기존 인덱스를 못 고치니 새 인덱스로 reindex해야 한다. 앱이 항상 alias로만 읽고 쓰게 해두면, reindex 완료 후 alias를 새 인덱스로 원자적으로 스위칭해 무중단 전환할 수 있다. 인덱스 이름을 코드에 박으면 이게 안 된다.
  • 대량 색인 중 refresh. 초기 적재나 대량 reindex 때 refresh가 1초마다 돌면 작은 세그먼트가 폭증하고 머지 비용이 터진다. 색인 동안 refresh_interval: -1 + 레플리카 0으로 두고 끝나고 복원한다.

→ 위 세 가지의 수치·코드: Elasticsearch 성능 튜닝, Spring Boot에서 Elasticsearch 다루기

가장 근본적인 함정 — ES를 1차 저장소로 쓰는 것

왜 생기나. ES가 빠르고 편하다 보니 원본 데이터를 ES에만 두고 싶어진다. 하지만 ES는 트랜잭션이 없고, refresh 지연으로 즉시 일관성이 없고, 강한 조인이 없고, 노드 장애 시 동기화 모델이 RDB와 다르다.

증상. ES만 믿었다가 색인 실패/유실, 매핑 변경 사고, 정합성 깨짐이 곧 데이터 유실로 직결된다. 복구할 원본이 없다.

어떻게 피하나. 원본(Source of Truth)은 RDB에 두고, ES는 검색용 사본으로만 취급한다. RDB → ES 동기화는 CDC(Debezium 등)나 AFTER_COMMIT 이벤트로 단방향으로 흘리고, 정합성 모니터링과 주기적 전체 재색인 잡을 함께 둔다. 매핑 변경은 alias 스위칭 reindex로 한다.

→ 이 역할 분리와 동기화 설계 전반: Elasticsearch 검색 시스템 — 4개 계층으로 보는 전체 그림, Spring Boot에서 Elasticsearch 다루기

한 줄 정리

ES 운영 사고의 대부분은 “ES를 RDB처럼 다뤄서” 생긴다. 즉시 일관성(→ refresh 지연), 자유로운 스키마(→ 매핑 폭발), 무제한 페이징(→ deep pagination), 사후 변경(→ 샤드 수 고정), 트랜잭션(→ 1차 저장소 오용)을 기대하는 순간 터진다. ES는 검색에 특화된 준실시간 분산 검색엔진이고, 그 전제 위에서 설계하면 대부분의 함정을 처음부터 피할 수 있다.