Elasticsearch 마스터 노트 시리즈 7편. Bulk API가 대량 작업에 표준인 이유, NDJSON 형식의 4 액션(index·create·update·delete), Reindex API로 인덱스 마이그레이션, Update by Query·Delete by Query, refresh 옵션·throttling·실패 처리, 성능 튜닝까지.
이 글은 Elasticsearch 마스터 노트 시리즈의 일곱 번째 편입니다. 1~6편이 단일 작업이었다면, 이번엔 대량 작업 — Bulk API.
수만·수백만 문서 인덱싱 시 단일 API = 불가능. Bulk가 표준. NDJSON 형식·4 액션·refresh 제어. 운영 환경 핵심.
처음 Bulk API가 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, NDJSON 형식이 낯섭니다. 둘째, 4 액션이 한 번에 등장합니다.
해결법은 한 가지예요. "Bulk = NDJSON·각 작업 2줄 (액션 + 본문)" 한 줄. 한 요청에 N 작업·N개 응답. 이 형식만 잡으면 끝.
NDJSON 형식
{ "action": { "_index": "...", "_id": "..." } }
{ "field": "value", ... }
{ "action": ... }
{ ... }
각 줄 = JSON. 줄 사이 공백·줄바꿈만. 마지막 줄도 줄바꿈 필수.
여기서 정말 중요한 시험 함정 — 마지막 줄 줄바꿈 안 하면 에러. 흔한 실수.
4 액션
1. index — INSERT 또는 UPDATE (덮어쓰기)
2. create — INSERT만 (이미 있으면 에러)
3. update — 부분 업데이트
4. delete — 삭제
index — 가장 일반적
POST /_bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "name": "Spring Boot", "price": 30000 }
{ "index": { "_index": "products", "_id": "2" } }
{ "name": "Java in Action", "price": 25000 }
{ "index": { "_index": "products" } }
{ "name": "Kotlin Cookbook", "price": 28000 }
_id 명시 또는 자동 생성. 같은 _id = 덮어쓰기.
인덱스 생략 (URL 경로로)
POST /products/_bulk
{ "index": { "_id": "1" } }
{ "name": "Spring Boot", "price": 30000 }
{ "index": { "_id": "2" } }
{ "name": "Java in Action", "price": 25000 }
URL에 인덱스 명시 → 본문 짧음.
create — INSERT만
POST /_bulk
{ "create": { "_index": "products", "_id": "1" } }
{ "name": "Spring Boot" }
_id가 이미 있으면 버전 충돌 에러. 데이터 중복 방지.
update — 부분 업데이트
POST /_bulk
{ "update": { "_index": "products", "_id": "1" } }
{ "doc": { "price": 32000 } }
{ "update": { "_index": "products", "_id": "2" } }
{ "script": { "source": "ctx._source.views += 1" } }
{ "update": { "_index": "products", "_id": "3" } }
{ "doc": { "stock": 100 }, "doc_as_upsert": true }
세 가지 패턴:
doc— 부분 필드 업데이트script— 스크립트 실행doc_as_upsert— 없으면 새로 생성
여기서 시험 함정이 하나 있어요. update는 분리 단계 — 1) 기존 문서 fetch, 2) merge, 3) reindex. 비용 큼·index보다 느림. 가능하면 index로 전체 덮어쓰기.
delete — 삭제
POST /_bulk
{ "delete": { "_index": "products", "_id": "1" } }
{ "delete": { "_index": "products", "_id": "2" } }
본문 X (액션만). 다른 액션과 차이.
응답 형식
{
"took": 30,
"errors": false,
"items": [
{
"index": {
"_index": "products",
"_id": "1",
"_version": 1,
"result": "created",
"status": 201
}
},
{
"index": {
"_index": "products",
"_id": "2",
"result": "updated",
"status": 200
}
}
]
}
여기서 정말 중요한 시험 함정 — errors: true면 일부 실패. 다른 작업은 계속. 각 item 확인 필요. 부분 실패 가능.
refresh 옵션
POST /_bulk?refresh=true
{ ... }
| refresh | 의미 |
|---|---|
| false (기본) | 즉시 검색 X (~1초 후) |
| true | 즉시 refresh (성능 ↓) |
| wait_for | refresh 대기 후 응답 |
여기서 시험 함정이 하나 있어요. 운영 = refresh false. true는 매번 segment 생성 → 매우 느림. 테스트만 OK.
Java Client — Bulk
@Autowired
private ElasticsearchClient client;
public void bulkIndex(List<Product> products) {
BulkRequest.Builder br = new BulkRequest.Builder();
for (Product p : products) {
br.operations(op -> op
.index(idx -> idx
.index("products")
.id(p.getId())
.document(p)
)
);
}
BulkResponse result = client.bulk(br.build());
if (result.errors()) {
for (BulkResponseItem item : result.items()) {
if (item.error() != null) {
log.error("Failed: {}", item.error().reason());
}
}
}
}
청크 크기 결정
권장 — 5~15 MB per bulk request
또는 — 1000~5000 문서
너무 작음: 오버헤드 ↑
너무 큼: 메모리·OOM 위험
여기서 정말 중요한 시험 함정 — 너무 큰 bulk = OOM. 메모리 한도 초과. 515MB 또는 10005000 문서 단위로 분할.
Reindex API — 인덱스 복사
POST /_reindex
{
"source": { "index": "products-old" },
"dest": { "index": "products-new" }
}
내부적으로 Bulk 사용·자동. 인덱스 마이그레이션 표준.
조건부 Reindex
POST /_reindex
{
"source": {
"index": "products-old",
"query": {
"term": { "status": "active" }
}
},
"dest": { "index": "products-new" }
}
Script로 변환
POST /_reindex
{
"source": { "index": "products-old" },
"dest": { "index": "products-new" },
"script": {
"source": "ctx._source.price = ctx._source.price * 1.1"
}
}
데이터 변환하며 reindex.
Throttling — 속도 제한
POST /_reindex?requests_per_second=1000
{
"source": {...},
"dest": {...}
}
운영 부하 줄임. 무한 = -1.
Async Reindex
POST /_reindex?wait_for_completion=false
{
"source": {...},
"dest": {...}
}
# 결과 — task ID
{ "task": "abc-123" }
# 상태 조회
GET /_tasks/abc-123
큰 데이터 = 비동기·task 모니터링.
Update by Query — 조건 업데이트
POST /products/_update_by_query
{
"script": {
"source": "ctx._source.price = ctx._source.price * 1.1"
},
"query": {
"term": { "category": "IT" }
}
}
조건 매칭 문서들 일괄 업데이트.
Delete by Query — 조건 삭제
POST /products/_delete_by_query
{
"query": {
"range": { "created_at": { "lt": "now-365d" } }
}
}
1년 이상 된 문서 삭제.
여기서 시험 함정이 하나 있어요. Update/Delete by Query = 비싸다. 매칭 문서 fetch + 작업 + reindex. 큰 결과 = 시간 ↑·throttling 권장.
성능 튜닝
1. 인덱싱 시 refresh 비활성
PUT /products/_settings
{
"index": {
"refresh_interval": "-1"
}
}
# 대량 인덱싱
POST /_bulk
...
# 다시 활성
PUT /products/_settings
{
"index": {
"refresh_interval": "30s"
}
}
2. Replica 0으로 (초기 인덱싱)
PUT /products/_settings
{
"index": {
"number_of_replicas": 0
}
}
# 대량 인덱싱
...
# 다시 추가
PUT /products/_settings
{
"index": {
"number_of_replicas": 1
}
}
여기서 정말 중요한 시험 함정 — 초기 인덱싱 = refresh_interval 비활성 + replicas 0. 인덱싱 속도 수 배. 완료 후 다시 활성.
3. 클라이언트 측 멀티 스레드
ExecutorService executor = Executors.newFixedThreadPool(8);
for (List<Product> chunk : partitions) {
executor.submit(() -> bulkIndex(chunk));
}
8 스레드 동시 bulk. ES 클러스터 부하 분산.
에러 처리 패턴
BulkResponse response = client.bulk(...);
if (response.errors()) {
List<Product> failed = new ArrayList<>();
for (int i = 0; i < response.items().size(); i++) {
BulkResponseItem item = response.items().get(i);
if (item.error() != null) {
failed.add(products.get(i));
}
}
// 실패 항목 재시도 또는 DLQ
retryOrDLQ(failed);
}
부분 실패 처리 표준.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 7편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Bulk API = NDJSON 형식, 각 작업 2줄
- 마지막 줄 줄바꿈 필수
- 4 액션 — index (덮어쓰기) / create (INSERT만) / update (부분) / delete (본문 X)
- delete만 본문 없음 (다른 셋은 본문 있음)
- update 패턴 —
doc·script·doc_as_upsert - update는 비싸다 — fetch + merge + reindex
- 가능하면 index로 덮어쓰기
- 응답 —
errors: true면 일부 실패 (각 item 확인) - refresh — false (기본·운영) / true (성능 ↓) / wait_for
- 운영 = refresh false 항상
- 청크 크기 — 5
15 MB 또는 10005000 문서 - 너무 큰 bulk = OOM
- Reindex API = 인덱스 복사·내부 Bulk
- 조건부 + 스크립트로 변환
- Throttling —
requests_per_second - Async Reindex —
wait_for_completion=false+ task 모니터링 - Update/Delete by Query = 조건 일괄 (비쌈·throttling)
- 성능 튜닝 —
refresh_interval: -1+replicas: 0(초기 인덱싱) - 멀티 스레드 클라이언트
- 에러 처리 — 부분 실패·실패 항목 재시도 또는 DLQ
시리즈 다른 편
- 1편 — 기본 개념·Cluster·Shard
- 2편 — Mapping·데이터 타입
- 3편 — Analyzer·Tokenizer·한국어
- 4편 — Query DSL
- 5편 — Full-text Search·Relevance
- 6편 — Aggregations
- 7편 — Bulk API·Reindex (현재 글)
- 8편 — Spring Data Elasticsearch
- 9편 — 검색 엔진 프로젝트 설계
- 10편 — Security·인증·인가·TLS
공식 문서: Bulk API / Reindex API 에서 더 깊이.
다음 글(8편)에서는 Spring Data Elasticsearch 통합 — Repository·@Document·ElasticsearchTemplate·테스트까지 풀어 갑니다.