10장. 안티패턴 모음 — 클러스터를 무너뜨리는 습관들
Elasticsearch는 강력하지만, RDB의 직관을 그대로 가져오면 클러스터가 무너집니다. 운영 현장에서 반복 관찰되는 안티패턴을 "왜 위험한가 → 어떤 증상이 나오는가 → 어떻게 고치는가" 순서로 정리합니다.
① 매핑 안티패턴 — dynamic 방치와 매핑 폭발, text/keyword 혼동, nested 남발 ② 쿼리 안티패턴 — deep pagination, leading wildcard ③ 샤딩·색인 안티패턴 — oversharding, replica 오해, 단건 INSERT ④ JVM heap 32GB의 벽과 update 남발
10.1 매핑 폭발 — dynamic: true를 방치하기
기본값(dynamic: true)에서 처음 보는 필드는 자동으로 매핑에 추가됩니다. 개발 초반에는 편하지만, 필드 이름이 동적으로 만들어지는 데이터(사용자 ID를 키로 쓰는 JSON 등)가 들어오는 순간 매핑이 통제 불능으로 불어납니다. 이것이 매핑 폭발(mapping explosion)입니다.
처음엔 평화롭습니다. 필드 3개짜리 로그 인덱스에 dynamic: true(기본값) — 새 필드가 오면 ES가 알아서 매핑에 추가합니다.
그런데 호스트명·세션ID가 필드 이름에 들어간 문서들이 유입되기 시작합니다. 문서마다 새로운 키 → 매핑 필드가 자동 증식합니다.
Limit of total fields [1000] has been exceeded — 색인이 실패하기 시작합니다. 그 전부터 이미 매핑이 담긴 cluster state가 비대해져 master를 압박하고 있었습니다.
처방은 명시적 매핑 + dynamic: strict. 정의 밖 필드는 즉시 예외로 거부되어, 잘못된 데이터 구조를 조기에 발견합니다. 필드 수는 설계한 대로 고정됩니다.
상황에 따라 strict(거부), false(저장만 하고 색인 안 함), runtime(런타임 필드) 중 선택하세요. "데이터가 키를 만들게 하지 말고, 키는 값으로 내려라"가 근본 해법입니다.
PUT /my-index
{
"mappings": {
"dynamic": "strict",
"properties": {
"user_id": { "type": "keyword" },
"@timestamp": { "type": "date" },
"message": { "type": "text" }
}
}
}
10.2 text와 keyword를 혼동하기
같은 문자열이라도 두 타입은 완전히 다른 일을 합니다. status 필드를 text로만 잡으면 정렬·집계·정확 일치가 전부 깨집니다.
| 타입 | 분석 | 정렬 | 집계 | term 정확 일치 |
|---|---|---|---|---|
text | 토큰화·소문자화 | 불가* | 불가* | 불가 |
keyword | 그대로 저장 | 가능 | 가능 | 가능 |
(*) fielddata: true로 강제할 수 있으나 heap 폭주 위험 때문에 금지 수준으로 비권장.
// ✅ 처방: multi-field — 하나의 값을 두 방식으로 색인
{
"status": {
"type": "text", // 전문 검색용
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 } // 정렬·집계·정확 일치용
}
}
}
nested 타입은 배열 요소마다 숨은 Lucene 문서를 만듭니다. 문서 1건 + 태그 2개 = Lucene 문서 3개. 인덱스가 예상보다 2~10배 커지고 join 비용으로 검색이 느려집니다. key가 고정 집합이면 평탄화(flatten), 임의 key 검색이 필요하면 flattened 타입을 먼저 검토하고, 요소 간 관계 검증이 진짜 필요할 때만 nested를 쓰세요.
10.3 쿼리 안티패턴 — deep pagination과 leading wildcard
📄 from: 100000, size: 10
모든 샤드가 from+size개의 상위 결과를 coordinating node로 전송 → 메모리 폭발. 기본 max_result_window(10,000)가 막아주는데, 이 한계를 늘리는 것이 바로 안티패턴입니다. 처방: 7장의 PIT + search_after. UI는 "X페이지 이동"보다 "더 보기" 패턴으로.
🃏 wildcard: "*example.com*"
선행 와일드카드는 역색인의 정렬을 못 쓰므로 모든 term 풀스캔. 처방: ① 8.x wildcard 필드 타입 ② 문자열을 뒤집어 저장하는 reverse 트릭 ③ edge_ngram ④ URL이면 host/path 분리 저장 — 데이터 모델 재설계가 근본 해법.
10.4 Oversharding — 작은 shard 수만 개
"인덱스마다 기본 5 shard니까 괜찮겠지"로 일별 인덱스를 몇 달 쌓으면 shard 수천~수만 개가 됩니다. shard 하나하나는 데이터가 없어도 고정 비용(Lucene IndexReader, 파일 핸들, heap 내 segment 메타데이터, cluster state 항목)을 차지합니다.
일별 인덱스 × 기본 5 shard × replica. 처음엔 heap도 cluster state도 여유롭습니다.
몇 달 뒤 — 몇 MB짜리 shard가 수천 개. 각 shard는 데이터와 무관한 고정비(IndexReader, 파일 핸들, heap 메타데이터, cluster state 항목)를 차지하고, heap이 메타데이터로 붉게 물듭니다.
증상 발현: "too many shards" 경고, master의 cluster state 갱신 지연, 색인·검색 둔화, 노드 재시작 복구가 시간 단위로 늘어납니다.
처방: ILM rollover로 primary shard 10~50GB 유지, heap GB당 shard 20개 이하, 같은 매핑의 작은 인덱스들은 1-shard 인덱스로 통합. shard 수는 인덱스 생성 시에만 정할 수 있으니 이후엔 reindex가 필요합니다.
10.5 그 밖의 함정들 — replica, heap, update, 단건 INSERT
🪞 "replica 늘리면 색인도 빨라진다?"
반대입니다. replica는 검색 처리량용. 색인은 primary가 쓴 것을 모든 replica가 반복하므로 네트워크·디스크 쓰기가 N배가 되어 색인은 오히려 느려집니다. 색인 처리량이 필요하면 primary shard 수·노드 추가·bulk 튜닝·refresh_interval 조정 순으로.
🧠 heap 32GB의 벽
JVM은 ~32GB까지만 compressed oops(4바이트 포인터)를 씁니다. 33GB로 키우면 포인터가 8바이트가 되어 오히려 쓸 수 있는 객체가 줄어듭니다. 권장: heap = RAM의 50%와 31GB 중 작은 값. 남는 RAM은 Lucene이 쓰는 OS 페이지 캐시에 양보하세요.
✏️ update 남발
Lucene segment는 불변입니다. _update = 기존 문서 tombstone 마킹 + 새 문서 색인. 잦은 update는 deleted doc 누적과 merge 폭증을 부릅니다. docs.deleted가 active의 몇 배라면 경고등. 처방: append-only 모델 + top_hits로 최신값 조회, 대량 변경은 reindex + alias 스왑.
📮 단건 INSERT
9장의 반복이지만 그만큼 흔합니다. 요청당 라우팅·acking 오버헤드는 1건이든 1000건이든 동일 — 단건 색인은 처리량 1/100. write queue가 차서 429가 폭주합니다. 처방: _bulk 5~15MiB + 클라이언트 배칭.
# update 남발의 증상 확인: deleted가 active의 몇 배인가?
GET /_cat/indices?v&h=index,docs.count,docs.deleted
index docs.count docs.deleted
my-index 100000 850000 ← 경고: deleted가 8.5배
# 대량 변경의 정석: reindex + alias 스왑 (무중단)
POST /_reindex
{ "source": { "index": "old" }, "dest": { "index": "new" } }
POST /_aliases
{ "actions": [
{ "remove": { "index": "old", "alias": "current" } },
{ "add": { "index": "new", "alias": "current" } }
]}
모든 필드를 합쳐 검색하던 _all은 6.0에서 deprecated, 7.x에서 제거되어 8.x에는 존재하지 않습니다. 대안은 copy_to(명시적으로 합칠 필드 지정) 또는 검색 시 multi_match — boost(^N)까지 지원해 _all보다 유연합니다.
10.6 한눈에 보는 안티패턴 처방전
| 안티패턴 | 핵심 증상 | 대안 |
|---|---|---|
dynamic: true 방치 | 매핑 폭발, 파싱 실패 | dynamic: strict + 명시 매핑 |
| text/keyword 혼동 | sort·agg 실패, term 미스 | multi-field (.keyword) |
| nested 남발 | doc 수 폭증, merge 비용 | flatten 또는 flattened |
| deep pagination | coordinating OOM | PIT + search_after |
| leading wildcard | term 풀스캔 | wildcard 타입, 모델 재설계 |
| 단건 INSERT | 처리량 1/100, 429 | bulk 5~15MiB |
| oversharding | cluster state 비대화 | shard 10~50GB, heap GB당 20개 |
| replica로 색인 가속 | 색인 더 느려짐 | primary shard 수 ↑ |
| 활성 인덱스 force_merge | 디스크 2배, merge 폭주 | ILM warm 자동화 (9장) |
| heap > 32GB | compressed oops 비활성 | 31GB 상한 |
| update 남발 | deleted doc 폭증 | append-only, reindex |
✍️ 이해도 체크
dynamic: true 인덱스에 색인하고 있다. 가장 먼저 발생할 문제는?이 장의 원문 문서: chapters/ch10_anti_patterns.md — 12가지 안티패턴 전체와 트러블슈팅 케이스 링크가 담겨 있습니다.