9장. 인덱싱 전략 — 처리량과 내구성의 설계
색인은 "문서를 저장한다"가 아닙니다. segment 생성, refresh, translog, merge가 맞물려 도는 기계이며 — 잘못 돌리면 처리량 붕괴와 검색 지연 폭주로 직결됩니다. 8.x/9.x 기준의 내부 동작과 운영 정석을 그림으로 잡습니다.
① 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는 액션만). 마지막 줄 끝에도 개행 필수
- 권장 페이로드: 5~15 MiB (공식 권장 시작점). 문서 수로는 보통 1,000~10,000건
- 너무 작으면 오버헤드 비율↑, 너무 크면(>20MiB) coordinating node 메모리 압박·GC 유발
- 클라이언트 배칭은 크기(10MiB) / 건수(1000) / 시간(1s) 셋 중 하나라도 차면 flush — 자바
BulkProcessor/BulkIngester의 표준 패턴
Bulk 응답은 항목별로 성공/실패가 섞여 옵니다. "errors": true면 반드시 항목을 순회해서 429(es_rejected_execution_exception)·503은 exponential backoff로 재시도하세요. 전체 status 200만 보고 안심하면 데이터 유실로 직결됩니다.
9.2 색인의 내부 파이프라인 — refresh와 translog
"색인했는데 왜 바로 검색이 안 되죠?"와 "장애가 나면 방금 쓴 데이터는요?"의 답이 모두 이 파이프라인에 있습니다. Lucene은 segment 단위로만 검색할 수 있고, segment를 디스크에 완전히 커밋(fsync)하는 것은 비싸기 때문에, ES는 중간 단계들을 둡니다.
색인 요청이 들어오면 문서는 우선 in-memory buffer에 쌓입니다. 이 상태에서는 아직 검색되지 않습니다.
동시에 translog(WAL)에 기록·fsync된 뒤에야 클라이언트에 응답합니다(기본 durability: request). 응답을 받았다면 노드가 죽어도 그 데이터는 안전합니다.
기본 1초마다 refresh가 buffer를 새 segment로 만들어 검색 가능 상태로 엽니다. "색인 후 최대 1초 내 검색 노출" — 이것이 Near Real Time의 실체입니다.
refresh가 반복되며 작은 segment가 누적되고, background merge가 이를 큰 segment로 합칩니다. 소량 색인이 잦으면 merge가 CPU·I/O를 잡아먹는 이유가 여기 있습니다.
Flush가 Lucene commit(진짜 디스크 fsync)을 수행하고 translog를 비웁니다. 장애가 나면 마지막 commit 이후의 translog를 replay해 복원 — buffer는 휘발성이어도 데이터는 지켜집니다.
refresh_interval 트레이드오프
refresh_interval | 검색 신선도 | 색인 처리량 | segment 수 |
|---|---|---|---|
1s (기본) | 1초 내 | 기준 | 많음 |
30s | 30초 내 | +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
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들이 자동 관리됩니다.
애플리케이션은 늘 logs라는 data stream 이름으로만 색인합니다. 실제 쓰기는 현재 write index인 .ds-logs-000001로 들어가고, shard가 점점 차오릅니다.
primary shard가 50GB에 도달 — rollover 조건 충족. 조건은 max_age, max_primary_shard_size, max_primary_shard_docs 중 하나라도 차면 발동합니다.
새 backing index 000002가 만들어지고 write 포인터가 이동합니다. 000001은 더 이상 쓰이지 않는 read-only가 되어 force merge·티어 이동의 대상이 됩니다.
검색은 여전히 logs 하나로 — 모든 backing index가 자동으로 검색 대상에 포함됩니다. 애플리케이션 코드는 rollover를 전혀 몰라도 됩니다.
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 phase: 갓 태어난 인덱스는 NVMe·고RAM 노드에서 색인과 검색을 모두 감당합니다. rollover도 이 단계의 액션입니다.
rollover 후 7일이 지나면 warm 티어로 이동. 더 이상 색인이 없으므로 forcemerge(segment 1개)와 shrink(shard 수 축소)로 검색 효율을 최적화합니다.
30일 후 cold/frozen으로. frozen은 데이터를 S3 같은 object storage에 두고 노드는 캐시만 가지는 searchable snapshot — 검색은 되지만 수 초 단위로 느려질 수 있습니다.
365일이 지나면 delete phase가 인덱스를 영구 삭제합니다. 디스크가 가득 차서 새벽에 삭제 작업을 하는 대신, 정책이 알아서 합니다.
주의: 각 phase의 min_age는 인덱스 생성이 아니라 rollover 시점부터 측정됩니다. 전체 흐름이 자동화되면 보존 비용은 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개로 합쳐 검색을 최적화하고 삭제 문서의 공간을 회수하는 마무리 작업입니다.
① 새 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 durability | request 유지 원칙, async는 의식적 선택 |
_id | 가능하면 ES 자동 생성 (auto-id 최적화 경로 존재) |
| Primary shard 크기 | 10~50GB, rollover는 max_primary_shard_size: 50gb 우선 |
| Force merge | read-only 인덱스 한정, ILM warm에서 자동화 |
| Update | append-only가 가능하면 update 회피 (10장 참고) |
| 모니터링 | indexing_pressure, write thread_pool의 rejected 카운터 (11장) |
✍️ 이해도 체크
_forcemerge?max_num_segments=1을 실행하면 안 되는 이유가 아닌 것은?이 장의 원문 문서: chapters/ch09_indexing_strategy.md — index buffer 크기 계산, flush 트리거 상세, data stream 템플릿 전체 예시가 담겨 있습니다.