Elasticsearch 입문 23편 Bulk API. NDJSON·batch sizing·refresh·partial failure·idempotent retry.
이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 23편이에요. 7편(Document CRUD·Versioning) 에서 "Bulk 라는 게 있어요" 정도로 짧게 스쳤다면, 23편은 운영 깊이 자리예요. 대량 데이터 수집의 사실상 표준 도구이고, 잘못 쓰면 클러스터가 한 번에 흔들리는 자리이기도 해요.
이 글은 Elasticsearch 8.x 공식 docs 의 Bulk API 페이지와 Tune for indexing speed 가이드를 학습 노트로 재정리한 자료예요.
실제 1만 건 이상 색인을 한 번 돌려 보면 batch size·refresh 차이가 눈에 띄게 박혀요.
왜 Bulk 가 필요한가
POST /products/_doc/1 같은 단건 색인 API 는 문서 하나당 HTTP 한 번 이에요. 문서 1만 건이면 HTTP 요청 1만 번. TCP handshake · TLS · HTTP 헤더 파싱 · 인증 검사 가 매 요청마다 반복돼서 네트워크 RTT 만으로도 수십 분이 사라져요.
게다가 단건 API 는 매 요청마다 refresh 후보가 돼서, 검색 가능 시점을 1초 단위로 깎으려는 near real-time 옵션이 켜져 있으면 segment 가 폭증해요. 결과적으로 검색 성능까지 같이 망가져요. (segment 와 refresh 의 관계는 3편 Lucene Internals 에서 깊이 다뤘어요.)
Bulk API 는 이 자리를 한 HTTP 요청에 수천~수만 문서 를 묶어 보내서 풉니다. HTTP 오버헤드 제거 + 묶음 단위 refresh + 묶음 단위 응답 — 운영 색인의 사실상 유일한 옵션이에요.
NDJSON 포맷 — newline delimited JSON
Bulk API 의 본문 형식이 흔히 헷갈리는 첫 자리예요. 일반 JSON 배열이 아닌 NDJSON (Newline Delimited JSON) 을 써요. 문서 하나당 두 줄 — action line · source line 페어가 반복돼요.
POST /_bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "name": "에어맥스", "price": 159000, "stock": 12 }
{ "index": { "_index": "products", "_id": "2" } }
{ "name": "스탠스미스", "price": 129000, "stock": 30 }
{ "delete": { "_index": "products", "_id": "old-99" } }
{ "update": { "_index": "products", "_id": "3" } }
{ "doc": { "price": 119000 } }
규칙 세 가지가 결정적이에요. (1) 각 줄은 단일 라인 JSON, 줄 안에 개행이 들어가면 안 돼요. (2) 마지막 줄도 반드시 \n 으로 끝나야 함, 안 그러면 ES 가 EOF 직전 줄을 읽다 멈춤 사고. (3) delete 만 source line 이 없고, 나머지 action 은 두 줄 페어.
Content-Type 헤더는 반드시 application/x-ndjson 이에요. application/json 으로 보내면 ES 가 single JSON object 로 파싱하려다 즉시 400 을 던져요.
cURL 로 테스트할 때 파일에서 NDJSON 을 읽어 보내는 게 안전해요.
curl -X POST "localhost:9200/_bulk" \
-H "Content-Type: application/x-ndjson" \
--data-binary "@bulk.ndjson"
--data-binary 가 핵심 — --data 나 -d 를 쓰면 cURL 이 개행을 공백으로 변환 해 버려서 NDJSON 이 깨져요.
4가지 action — index·create·update·delete
Bulk 본문의 action line 에 들어가는 키워드는 네 가지예요. 의미와 idempotency(같은 요청을 두 번 보내도 같은 결과인가) 가 다 달라서, retry 전략을 짜는 자리에서 핵심.
index — upsert 비슷한 자리. _id 가 없으면 만들고, 있으면 덮어써요. 가장 자주 쓰는 액션. 같은 요청을 두 번 보내도 결과가 같아서 idempotent. retry 안전.
create — insert 전용. _id 가 이미 있으면 충돌 (version_conflict_engine_exception · HTTP 409) 을 내고 실패. 중복 방지가 필수인 자리(예: 결제·주문 색인) 에서 써요. retry 시 409 를 정상 케이스 로 처리하는 로직이 필요.
update — 부분 갱신. doc 또는 script 로 기존 문서의 일부 필드만 바꿔요. 내부 동작은 get → merge → reindex 라서 index 보다 비싸요. retry 시 version_conflict 가능, retry_on_conflict 옵션을 같이 박는 게 표준.
delete — 삭제. source line 없이 action line 한 줄만. 이미 없으면 404 가 응답에 박히지만 bulk 전체는 실패하지 않아요. idempotent 하지만 retry 했을 때 404 를 무시 하는 처리가 필요.
{ "create": { "_index": "orders", "_id": "ord-7771" } }
{ "amount": 39900, "user_id": 1234 }
{ "update": { "_index": "products", "_id": "3", "retry_on_conflict": 3 } }
{ "doc": { "stock": 9 }, "doc_as_upsert": true }
doc_as_upsert: true 는 update 인데 문서가 없으면 새로 만든다 — index 와 update 를 한 액션으로 묶는 흔한 패턴이에요.
Batch size 가이드 — byte 기준 5~15MB
가장 자주 묻는 자리예요 — "한 번에 몇 건 보내는 게 맞나요". 정답은 건수가 아니라 바이트 크기 예요. 공식 가이드는 5~15MB per request 권장.
이유는 단순해요. 문서 한 건이 100 byte 인 회사도 있고 100KB 인 회사도 있어서 건수 기준은 의미가 없음. ES 노드 입장에서 부담은 HTTP body 바이트 + 파싱 메모리 + thread pool 점유 시간 이고, 이 셋이 다 바이트에 비례해요.
거친 환산표 — 문서 평균 크기로 어림잡으면 이렇게 돼요.
| 평균 문서 크기 | 한 번에 보낼 건수 (10MB 기준) |
|---|---|
| 500 byte | 약 20,000건 |
| 2 KB | 약 5,000건 |
| 10 KB | 약 1,000건 |
| 50 KB | 약 200건 |
운영 시작점은 5MB · 1,000건 정도로 잡고, 모니터링 보면서 위로 올리는 게 안전해요. 10MB 를 넘기 시작하면 node thread pool 점유 가 길어져서 다른 쓰기·검색이 밀려요. 20MB 를 넘기면 413 Request Entity Too Large 가 떨어지거나 circuit breaker 가 도는 사고가 잦아져요.
기본 HTTP body 한도는 http.max_content_length: 100mb 예요. 이걸 늘리지 말고, 애플리케이션 쪽에서 batch 를 잘게 쪼개는 게 표준 방향.
refresh 옵션 — true·false·wait_for
Bulk API 의 refresh 파라미터는 운영 사고의 가장 흔한 입구예요. 세 가지 값이 있고, 의미가 다 달라요.
refresh=false (기본값) — refresh 를 강제하지 않음. 색인된 문서는 기본 refresh_interval (1초) 이 지나야 검색에서 보여요. 처리량 최대 — 운영 대량 색인은 이 값.
refresh=true — 즉시 refresh 강제. 색인 직후 검색에서 보임 보장. 다만 segment 가 한 번 더 강제로 만들어져서 매번 호출하면 segment 폭증 → merge thread 폭증 → CPU 100% 사고로 직행. 단건 테스트·디버깅 외에는 사용 금지.
refresh=wait_for — 다음 refresh 가 일어날 때까지 응답을 미룸. 즉시 보장은 아니지만 클라이언트가 응답 받았을 때 검색 가능 이 보장돼요. 운영에서 write-then-read (쓰고 바로 읽기) 패턴이 필요할 때 표준 옵션. throughput 비용은 거의 없음 — 강제 refresh 가 아니라서.
POST /_bulk?refresh=wait_for
{ "index": { "_index": "orders", "_id": "1" } }
{ "amount": 39900 }
거친 권장 — 백오피스 일괄 동기화는 false, 사용자가 등록 직후 검색되길 원하는 자리는 wait_for, true 는 통합 테스트 외 절대 금지.
Partial Failure — 일부만 실패한다는 의미
Bulk 응답은 HTTP 상태 코드가 200 이라도 내부 항목 중 일부는 실패 할 수 있어요. 이게 다른 ES API 와 가장 다른 자리이고, 운영에서 가장 자주 놓치는 자리.
응답 본문 구조가 핵심이에요.
{
"took": 38,
"errors": true,
"items": [
{ "index": { "_id": "1", "status": 201, "result": "created" } },
{ "index": { "_id": "2", "status": 409, "error": { "type": "version_conflict_engine_exception", ... } } },
{ "delete": { "_id": "old-99", "status": 404, "result": "not_found" } }
]
}
체크 순서는 셋이에요. (1) 최상위 errors: true 인지 — false 면 전체 성공이라 items 안 봐도 됨. (2) errors: true 면 items 배열 순회 — 각 항목의 status 또는 error 필드 확인. (3) status 별 분기 — 4xx 는 클라이언트 문제 (retry 의미 X), 5xx 와 es_rejected_execution_exception 은 서버 일시 과부하 (retry 대상).
가장 흔한 실패 코드 셋.
- 409 version_conflict — 같은
_id가 이미 더 새 버전. create 액션에서 자주 발생. 재처리할 필요 없음. - 429 es_rejected_execution_exception — write thread pool 큐 full. 클러스터가 backpressure 신호 보낸 것. exponential backoff 으로 재시도해야 함.
- 400 mapper_parsing_exception — 필드 타입 불일치 (예:
price가long인데 문자열 보냄). 클라이언트 코드 버그 라 retry 의미 X — 로그 남기고 dead-letter queue 로 보내기. - 503 cluster_block_exception — 인덱스가 read_only (디스크 watermark 초과 등). retry 전에 클러스터 상태 부터 풀어야 함.
자바 클라이언트 (Spring Data Elasticsearch · 공식 RestClient) 는 BulkResponse.hasFailures() 와 items() 순회를 반드시 구현 하는 게 표준. try-catch 만으로는 절대 못 잡아요 — 예외가 안 던져지니까.
처리량 튜닝 — concurrent·refresh_interval·replica
운영에서 분당 100만 건 색인 같은 자리에 도달하려면 단순 batch 만으로는 부족해요. 네 가지 추가 손잡이가 있어요.
(1) Concurrent Bulk — 여러 bulk 요청을 병렬 로 던지기. ES 의 write thread pool 갯수가 기본 (코어 수) + 1 이라, 클라이언트도 비슷한 갯수의 worker 로 던지는 게 적정. 너무 많으면 429 가 떨어져요. 자바 공식 클라이언트의 BulkProcessor (또는 Spring Data 의 ElasticsearchAsyncClient.bulk) 가 이걸 관리해 줘요.
(2) refresh_interval=-1 임시 적용 — 초기 적재·재색인처럼 완료까지 검색 안 봐도 되는 자리 에서, 인덱스의 refresh_interval 을 -1 로 잠시 꺼요. segment 안 만들어져서 CPU 와 disk IO 가 30~50% 절약. 적재 끝나면 1s 로 되돌려요.
PUT /products/_settings
{ "index": { "refresh_interval": "-1" } }
# bulk 적재
PUT /products/_settings
{ "index": { "refresh_interval": "1s" } }
(3) number_of_replicas=0 임시 적용 — 초기 적재 동안 replica 를 0 으로 두면 primary 만 쓰기 라 처리량이 거의 두 배. 끝난 뒤 replica 를 1 또는 2 로 늘리면 ES 가 자동으로 복제. 운영 중 인덱스 에는 절대 쓰지 말 것 — HA 가 사라져요. 재색인 전용 신규 인덱스 에만.
(4) Backpressure 신호 무시 금지 — 클라이언트는 429 응답을 받으면 반드시 멈추거나 늦춰야 해요. 같은 속도로 계속 던지면 thread pool 큐가 더 차서 클러스터가 yellow → red 로 떨어져요. exponential backoff (예: 1s · 2s · 4s · 8s) 가 표준.
자주 만나는 사고
사고 1 — Bulk 한 번에 50MB 보내고 OOM
원인 — "많이 보낼수록 빠르다" 는 잘못된 직관으로 한 요청에 50,000 건 (≈ 50MB) 을 묶어 보냈더니, ES 노드가 파싱 메모리 확보 중 circuit breaker 발동. 응답은 503, 클라이언트는 retry 폭주.
해결 — 5~15MB 가이드 로 잘게 쪼개고, 문서 평균 크기 × 건수 로 사전 계산. byte 기반 split 로직을 클라이언트에 박는 게 표준.
사고 2 — refresh=true 폭주
원인 — 단건 색인 "검색 즉시 반영" 요구를 단순하게 풀려고 모든 bulk 에 refresh=true 를 박았다. 분당 수천 segment 가 만들어져서 merge thread 가 CPU 90% 점유, 검색 응답 1초 → 30초로 폭증.
해결 — wait_for 로 갈아끼우고, 진짜 즉시성이 필요한 자리에만 단건 _doc API 로 처리. bulk 에 refresh=true 는 통합 테스트 외 금지 가 운영 규칙.
사고 3 — Partial Failure 무시
원인 — 클라이언트가 HTTP 200 만 보고 "성공" 처리. 사실은 items 의 10% 가 mapper_parsing_exception 으로 실패해서, 결과적으로 데이터 결손. 며칠 뒤 상품 검색 누락 으로 발견.
해결 — BulkResponse.hasFailures() 와 items() 순회를 반드시 구현. 실패 항목은 errors 토픽 또는 dead-letter 인덱스 로 따로 보내서 수동 점검 + alerting 으로 가시화.
사고 4 — 409 conflict 를 retry 폭주
원인 — create 액션의 409 를 재시도 대상 으로 잘못 분류해서 클라이언트가 1초마다 같은 요청을 무한 재시도. 결과적으로 쓸데없는 트래픽 폭증 + ES 로그 폭증.
해결 — 4xx 와 5xx 를 분리. 409 는 정상 비즈니스 케이스 (이미 같은 주문 ID 가 있음) 로 분류하고 재시도 X. 429·503 만 retry 대상, 400·404·409 는 로그 + 종료.
사고 5 — Thread Pool Queue 폭주 (429)
원인 — concurrent bulk 를 80 개로 띄워 두고 backpressure 무시. write thread pool queue (기본 200) 가 즉시 차서 429 폭주 → 데이터 일부 유실.
해결 — 클라이언트 worker 수를 (노드 수 × 코어 수) / 2 정도로 제한하고, 429 받으면 exponential backoff + 최대 동시성 자동 축소. BulkProcessor 의 setBackoffPolicy 가 이걸 자동화해 줘요.
사고 6 — Content-Type: application/json 으로 400
원인 — 자체 구현한 클라이언트가 NDJSON 본문에 application/json 헤더를 박아 보냄. ES 는 "전체를 single JSON 으로 파싱" 시도 → 400 Bad Request.
해결 — 헤더를 정확히 application/x-ndjson 으로. 자바 공식 RestClient · Spring Data ES 는 자동으로 처리하지만, 직접 HTTP 라이브러리 (OkHttp · Apache HttpClient) 로 짠 코드에서 가장 흔한 사고.
사고 7 — 마지막 줄 \n 누락
원인 — NDJSON 빌더가 String.join("\n", lines) 같은 식으로 마지막 개행을 안 붙임. ES 가 마지막 페어를 incomplete 로 보고 전체 bulk 거부 또는 마지막 문서 누락.
해결 — 빌더 마지막에 반드시 "\n" 추가. 라이브러리 빌더 (예: BulkRequest.Builder) 를 쓰면 자동 처리되니까 직접 NDJSON 을 짜는 자리 만 의식해서 점검.
운영 권장 패턴
(1) BulkProcessor 또는 동급 라이브러리 강제 — 자체 NDJSON 빌더·재시도 로직을 직접 짜지 말고, 공식 클라이언트의 BulkProcessor (또는 Spring Data 의 비동기 bulk API) 를 써요. flush 정책 (시간·바이트·갯수) · backoff · concurrent 제어 가 다 들어 있어요. 32편(Spring Data Elasticsearch) 에서 깊이.
(2) 초기 적재 / 재색인 모드 분리 — 운영 색인은 replica=1·refresh_interval=1s, 초기 적재 / 재색인은 replica=0·refresh_interval=-1 로 명시 분리. 끝난 뒤 setting 을 원복하는 toggle 스크립트 를 사전에 준비. 5편(Index 관리) 의 reindex 패턴과 결합.
(3) Partial Failure 알람 표준화 — bulk 응답의 실패율 (failed items / total items) 을 Prometheus 또는 ELK 메트릭으로 노출하고, 1% 초과 시 alarm. 실패 아이템은 dead-letter 인덱스 로 따로 적재해서 주간 점검 가능하게 가시화.
(4) batch size 운영 정책 — 문서 평균 크기를 추적 하는 메트릭을 두고, 목표 batch byte size (5~10MB) 와 최대 건수 한도 (예: 5,000) 둘 다 박아요. 둘 중 먼저 도달하는 쪽에서 flush.
(5) 429 → 자동 backoff 표준 — 429 / 503 응답은 exponential backoff (1s·2s·4s·8s·16s) 로 재시도. 5회 이상 실패하면 dead-letter 로 격리하고 alarm. 26편(Cluster Operations) 의 thread pool 모니터링과 묶음.
시험 직전 한 번 더 — 압축 노트
- Bulk API = 한 HTTP 요청에 수천~수만 문서 묶어 보내는 대량 색인 표준.
- 본문 = NDJSON (newline delimited JSON),
Content-Type: application/x-ndjson, 마지막 줄\n필수. - 4가지 action = index (upsert) · create (insert 전용·409 가능) · update (부분·doc/script) · delete (source line 없음).
- batch size 권장 = 5~15MB per request (건수 X, 바이트 O). 운영 시작점 5MB · 1,000건.
http.max_content_length기본 100MB — 늘리지 말고 클라이언트에서 쪼개기.- refresh 옵션 = false (운영 기본) · true (segment 폭증·금지) · wait_for (write-then-read 표준).
- errors: true 응답이라도 HTTP 200 — items 배열 순회 필수.
- 4xx = 클라이언트 버그 (retry X) / 5xx · 429 = 서버 일시 과부하 (exponential backoff retry).
- 처리량 튜닝 4가지 = concurrent bulk ·
refresh_interval=-1·number_of_replicas=0· backpressure 존중. - 자주 만나는 사고 7개 = batch 50MB OOM · refresh=true 폭주 · partial failure 무시 · 409 retry 폭주 · thread pool queue 폭주 · Content-Type 오류 · 마지막
\n누락. - 자바 표준 = BulkProcessor (공식) 또는 Spring Data ES 비동기 bulk — flush · backoff · concurrent 다 자동.
- 운영 토글 = 초기 적재 동안 replica=0 + refresh_interval=-1, 끝나면 원복.
시리즈 다른 편
- 직전 글 = 22편 RAG — Retrieval-Augmented Generation·LLM 결합
- 다음 글 = 24편 Ingest Pipeline — Processor·Grok·Conditional
- 7편 = Document CRUD·Versioning — 단건 색인 기본
- 25편 = Logstash·Beats — 파일·DB·로그 수집기
- 26편 = Cluster Operations — health·thread pool·allocation explain
- 31편 = Performance Tuning — indexing speed·search speed
- 32편 = Spring Data Elasticsearch — Repository·Template·POJO
한 줄 정리 — Bulk API = NDJSON 으로 수천 문서를 한 요청에 묶는 대량 색인 표준. batch 5~15MB · refresh=wait_for · partial failure 순회 · 429 backoff 네 줄을 머리에 박아 두면 운영 사고 90% 가 사라져요.