DB Study

9장. 인덱싱 전략 — 처리량과 내구성의 설계

색인은 "문서를 저장한다"가 아닙니다. segment 생성, refresh, translog, merge가 맞물려 도는 기계이며 — 잘못 돌리면 처리량 붕괴와 검색 지연 폭주로 직결됩니다. 8.x/9.x 기준의 내부 동작과 운영 정석을 그림으로 잡습니다.

고급 ⏱ 약 32분 🎬 애니메이션 3개 선수 지식: 3장 Lucene · 4장 샤드
🧭
이 장에서 배우는 것

① Bulk API — 왜 단건 색인은 처리량이 1/100인가 ② refresh와 translog — NRT 검색과 내구성의 내부 파이프라인 ③ ILM과 rollover — 시계열 인덱스를 자동으로 자르고 옮기는 법 ④ hot-warm-cold 티어와 force merge의 원칙

9.1 단건 vs Bulk — 요청당 오버헤드는 같다

단건 색인 요청 하나에도 HTTP 라운드트립, primary 샤드 라우팅, replica 복제 대기, translog fsync가 전부 발생합니다. 핵심은 이것입니다: 1건을 쓰든 1,000건을 쓰든 요청당 라우팅·acking 오버헤드는 거의 동일합니다. 그래서 단건 색인의 처리량은 bulk 대비 1/100 이하로 떨어집니다.

POST /_bulk
{ "index" : { "_index" : "logs", "_id" : "1" } }
{ "field1" : "value1" }
{ "create" : { "_index" : "logs", "_id" : "2" } }
{ "field1" : "value2" }
{ "delete" : { "_index" : "logs", "_id" : "3" } }
// NDJSON 포맷: 액션 줄 + 문서 줄(delete는 액션만). 마지막 줄 끝에도 개행 필수
🚨
Bulk 응답의 status 200을 믿지 마라

Bulk 응답은 항목별로 성공/실패가 섞여 옵니다. "errors": true면 반드시 항목을 순회해서 429(es_rejected_execution_exception)·503은 exponential backoff로 재시도하세요. 전체 status 200만 보고 안심하면 데이터 유실로 직결됩니다.

9.2 색인의 내부 파이프라인 — refresh와 translog

"색인했는데 왜 바로 검색이 안 되죠?"와 "장애가 나면 방금 쓴 데이터는요?"의 답이 모두 이 파이프라인에 있습니다. Lucene은 segment 단위로만 검색할 수 있고, segment를 디스크에 완전히 커밋(fsync)하는 것은 비싸기 때문에, ES는 중간 단계들을 둡니다.

색인 요청 bulk 10MiB 🧠 in-memory buffer 아직 검색 불가 📜 translog (WAL) 매 요청 fsync (durability: request) ✓ 클라이언트 응답 (translog 기록 후) refresh (1s) new segment 🔍 검색 가능! seg… background merge 작은 segment → 큰 것으로 💾 Flush = Lucene commit(디스크 fsync) + translog 비우기 기본 30분마다 또는 translog 512MiB 도달 시 · 장애 시엔 마지막 commit 이후의 translog를 replay해 복원 ⚡ 장애 시나리오: buffer는 날아가도 translog가 있으면 데이터는 안전

refresh_interval 트레이드오프

refresh_interval검색 신선도색인 처리량segment 수
1s (기본)1초 내기준많음
30s30초 내+30~50%줄어듦
-1 (비활성)수동 refresh 시최대 2~3배최소화
// 대량 backfill/reindex의 정석: refresh와 replica를 끄고 넣는다
PUT /target/_settings
{ "refresh_interval": "-1", "number_of_replicas": 0 }

... (bulk indexing) ...

PUT /target/_settings
{ "refresh_interval": "1s", "number_of_replicas": 1 }
POST /target/_forcemerge?max_num_segments=1
⚠️
translog durability: async는 "의식적 선택"

index.translog.durability: async로 바꾸면 fsync를 sync_interval(기본 5s)마다만 수행해 처리량이 20~50% 오르지만, 응답을 받은 데이터도 노드 크래시 시 최대 interval 분량 유실될 수 있습니다. 로그·메트릭처럼 소량 유실이 무해한 데이터에만, 명시적으로 선택하세요. 원칙은 request 유지입니다.

9.3 Rollover와 Data Stream — 시계열 인덱스를 자르는 법

로그·메트릭 같은 시계열 데이터를 하나의 인덱스에 계속 쌓으면 shard가 무한히 커집니다. 해법은 일정 기준마다 새 인덱스로 갈아타는 rollover입니다. Data stream은 이 패턴을 빌트인으로 자동화한 것으로, 사용자에게는 단일 이름(logs)으로 보이지만 내부적으로 .ds-logs-…-000001 같은 backing index들이 자동 관리됩니다.

data stream: logs 색인은 항상 이 이름으로 .ds-logs-000001 primary shard: 26GB → 50GB write ✍️ rollover 조건 충족! max_primary_shard_size: 50gb .ds-logs-000002 새 write index write 포인터 이동 ✍️ 000001은 read-only로 (색인 중단) 🔍 검색은 data stream 이름 하나로 — 모든 backing index(000001 + 000002)를 대상으로 실행
💡
왜 50GB인가

Lucene merge 비용은 segment 크기에 비례하고, 너무 큰 shard는 force merge·snapshot·장애 복구 시간이 길어집니다. primary shard 10~50GB가 경험칙으로 검증된 sweet spot이며, 8.x부터 max_primary_shard_size: 50gb가 권장 1차 rollover 조건입니다.

Data stream의 제약: 모든 문서에 @timestamp 필수, append-only(_id로 update/delete 불가), 인덱스 템플릿 + ILM 정책과 한 세트로 동작합니다.

9.4 ILM — hot·warm·cold 티어를 흐르는 데이터의 일생

ILM(Index Lifecycle Management)은 rollover된 인덱스를 나이에 따라 싼 하드웨어로 옮기고 결국 삭제하는 자동화입니다. "최근 데이터는 비싸고 빠르게, 오래된 데이터는 싸고 느리게"가 설계 철학입니다.

🔥 hot NVMe SSD · 고RAM 🌤 warm SSD · 검색만 🧊 cold / frozen HDD · object storage 🗑 delete 365일 후 .ds-logs-000001 색인 + 검색 활발 .ds-logs-000001 forcemerge + shrink .ds-logs-000001 searchable snapshot 000001 영구 삭제 0~7d 7d~30d 30d~365d 365d~ ✅ min_age는 rollover 시점(write에서 빠진 순간)부터 측정 — 보존 비용은 hot 대비 frozen이 1/10 이하
PUT _ilm/policy/logs-policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": { "max_age": "1d", "max_primary_shard_size": "50gb" },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "forcemerge": { "max_num_segments": 1 },
          "shrink":     { "number_of_shards": 1 },
          "allocate":   { "include": { "data_tier": "data_warm" } }
        }
      },
      "cold":   { "min_age": "30d",  "actions": { "searchable_snapshot": { "snapshot_repository": "my_repo" } } },
      "delete": { "min_age": "365d", "actions": { "delete": {} } }
    }
  }
}

9.5 Force merge — 하지 말아야 할 때를 아는 것이 핵심

Lucene은 background merge로 알아서 segment를 관리합니다. _forcemerge는 read-only가 된 인덱스의 segment를 1개로 합쳐 검색을 최적화하고 삭제 문서의 공간을 회수하는 마무리 작업입니다.

🚨
활발히 색인 중인 인덱스에 force merge 금지

① 새 segment가 계속 생겨 merge가 끝나지 않고 ② 병합 중 디스크가 일시적으로 2배 사용되며 ③ 한번 만들어진 거대 단일 segment는 이후 일반 merge 대상에서 사실상 제외되어 삭제 문서가 영원히 회수되지 않을 수 있습니다. ILM warm phase에서 자동화하는 것이 정석입니다.

# 1. rollover로 write가 끝난 인덱스인지 확인 후 read-only 전환
PUT /logs-2026.07.01/_settings
{ "index.blocks.write": true }

# 2. force merge (비동기 실행)
POST /logs-2026.07.01/_forcemerge?max_num_segments=1&wait_for_completion=false

9.6 베스트 프랙티스 체크리스트

항목권장
Bulk 페이로드5~15 MiB, errors: true 항목별 처리 + 429 backoff
Refresh interval운영 워크로드 30s 검토, backfill은 -1
Replica대량 backfill 시 0, 완료 후 복구
Translog durabilityrequest 유지 원칙, async는 의식적 선택
_id가능하면 ES 자동 생성 (auto-id 최적화 경로 존재)
Primary shard 크기10~50GB, rollover는 max_primary_shard_size: 50gb 우선
Force mergeread-only 인덱스 한정, ILM warm에서 자동화
Updateappend-only가 가능하면 update 회피 (10장 참고)
모니터링indexing_pressure, write thread_pool의 rejected 카운터 (11장)

✍️ 이해도 체크

색인 직후의 문서가 아직 검색되지 않는 이유와, 그럼에도 노드 장애 시 데이터가 안전한 이유를 올바르게 짝지은 것은?
✅ 문서는 refresh(기본 1초)가 새 segment를 만들어야 검색에 노출됩니다(NRT). 한편 내구성은 segment가 아니라 translog가 책임집니다 — 기본 durability: request에서는 translog fsync 후에 응답하므로, 장애가 나도 마지막 commit 이후의 translog를 replay해 복원합니다.
활발히 색인 중인 인덱스에 _forcemerge?max_num_segments=1을 실행하면 안 되는 이유가 아닌 것은?
✅ force merge 중에도 검색 자체는 가능합니다(I/O 경합으로 느려질 뿐 차단되지는 않음). 나머지 셋은 모두 실제 위험이며, 그래서 force merge는 rollover가 끝나 read-only가 된 인덱스에만 — 가급적 ILM warm phase 자동화로 — 수행합니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch09_indexing_strategy.md — index buffer 크기 계산, flush 트리거 상세, data stream 템플릿 전체 예시가 담겨 있습니다.