DB Study

10장. 안티패턴 모음 — 클러스터를 무너뜨리는 습관들

Elasticsearch는 강력하지만, RDB의 직관을 그대로 가져오면 클러스터가 무너집니다. 운영 현장에서 반복 관찰되는 안티패턴을 "왜 위험한가 → 어떤 증상이 나오는가 → 어떻게 고치는가" 순서로 정리합니다.

중급 ⏱ 약 27분 🎬 애니메이션 2개 선수 지식: 5~9장
🧭
이 장에서 배우는 것

① 매핑 안티패턴 — 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)입니다.

❌ BEFORE — dynamic: true 인덱스 매핑 (fields) fields: 3 fields: 412 fields: 1000! user_id · @timestamp · message metrics.cpu_host01, metrics.cpu_host02, … session_9f2ka1.start, session_x71b2.start, … 문서마다 새 키 → 필드가 자동 증식 📈 Limit of total fields [1000] has been exceeded cluster state 비대화 → master 압박 → 색인 실패 ✅ AFTER — dynamic: strict fields: 3 user_id: keyword @timestamp: date message: text 정의 밖 필드가 오면? strict_dynamic_mapping_exception → 즉시 발견 선택지: 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도 남발하면 안티패턴

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 항목)을 차지합니다.

data node (heap 31GB) logs-01-01 × 5 shards, logs-01-02 × 5 shards … …수천 개. shard당 파일 핸들 + heap 메타데이터 고정비 heap 사용률 🧠 master node — cluster state shards: 210 · state 갱신 빠름 shards: 10,000 · state 갱신 지연 ⛔ shards: 600 · state 가볍고 빠름 증상: "too many shards" 경고 · 색인/검색 둔화 · 노드 재시작 복구가 분 → 시간 단위로 shard 42GB shard 47GB shard 38GB shard 44GB ✅ AFTER: ILM rollover로 shard 10~50GB 유지 · heap GB당 shard 20개 이하 목표 같은 매핑의 작은 인덱스들은 1 shard 인덱스로 통합 검토

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 필드는 이제 없다

모든 필드를 합쳐 검색하던 _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 paginationcoordinating OOMPIT + search_after
leading wildcardterm 풀스캔wildcard 타입, 모델 재설계
단건 INSERT처리량 1/100, 429bulk 5~15MiB
overshardingcluster state 비대화shard 10~50GB, heap GB당 20개
replica로 색인 가속색인 더 느려짐primary shard 수 ↑
활성 인덱스 force_merge디스크 2배, merge 폭주ILM warm 자동화 (9장)
heap > 32GBcompressed oops 비활성31GB 상한
update 남발deleted doc 폭증append-only, reindex

✍️ 이해도 체크

사용자 세션 ID가 필드 이름으로 들어오는 로그를 dynamic: true 인덱스에 색인하고 있다. 가장 먼저 발생할 문제는?
✅ 동적 키가 필드 이름이 되면 문서 수만큼 매핑 필드가 증식합니다. 기본 한계(total fields 1000) 초과로 색인이 실패하고, 그 전부터 cluster state가 비대해져 master를 압박합니다. 처방: dynamic: strict + 명시 매핑, 그리고 "키를 값으로" 데이터 구조 변경입니다.
색인 처리량이 부족해서 replica를 1에서 3으로 늘렸다. 결과는?
✅ replica는 검색 처리량을 늘리는 장치입니다. 색인은 primary가 처리한 것을 모든 in-sync replica로 복제하므로 네트워크 트래픽과 디스크 쓰기가 replica 수만큼 늘어 색인 latency가 증가합니다. 색인 처리량이 필요하면 primary shard 수 증설, 노드 추가, bulk/refresh 튜닝이 올바른 방향입니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch10_anti_patterns.md — 12가지 안티패턴 전체와 트러블슈팅 케이스 링크가 담겨 있습니다.