Elasticsearch 마스터 — Bulk API·Reindex

2026-05-03확률과 통계 마스터 노트

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 항상
  • 청크 크기 — 515 MB 또는 10005000 문서
  • 너무 큰 bulk = OOM
  • Reindex API = 인덱스 복사·내부 Bulk
  • 조건부 + 스크립트로 변환
  • Throttlingrequests_per_second
  • Async Reindexwait_for_completion=false + task 모니터링
  • Update/Delete by Query = 조건 일괄 (비쌈·throttling)
  • 성능 튜닝 — refresh_interval: -1 + replicas: 0 (초기 인덱싱)
  • 멀티 스레드 클라이언트
  • 에러 처리 — 부분 실패·실패 항목 재시도 또는 DLQ

시리즈 다른 편

공식 문서: Bulk API / Reindex API 에서 더 깊이.

다음 글(8편)에서는 Spring Data Elasticsearch 통합 — Repository·@Document·ElasticsearchTemplate·테스트까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!