[Elasticsearch] 검색 최적화하기 (2) - 마이그레이션(reindex)

2025. 12. 17. 00:28·ELK

개요

지난 포스팅에서는 검색 최적화를 위해 analyzer 설정을 개선했습니다.

2025.12.15 - [ELK] - [Elasticsearch] 검색 최적화하기

 

[Elasticsearch] 검색 최적화하기

개요SNS 도메인 특성상 불완전한 문장, 신조어·은어·합성어가 빈번하게 등장하면서 검색 품질이 떨어졌습니다.저장 시점과 검색 시점에서 동일한 Nori 기반 분석기를 사용하고 있었음에도, fuzzin

featherdale.tistory.com

 


이 과정에서 analyzer가 변경되면서 기존 인덱스에 저장된 데이터 역시 새로운 분석 규칙으로 다시 색인할 필요가 있었습니다.
이를 위해 기존 인덱스를 유지한 채 신규 인덱스로 데이터를 이관하는 reindex 작업을 진행했고, 서비스가 운영 중인 상황을 고려해 검색 및 쓰기 중단 없이 인덱스를 교체하는 무중단 reindex를 전제로 작업을 진행했습니다.

 

문제 상황

검색 품질 개선을 목적으로 analyzer 설정을 수정했습니다.

기존 인덱스와는 서로 다른 분석 결과가 필요해졌습니다.

Elasticsearch에서는 신규 인덱스를 생성하고 기존 데이터를 이관하는 방식이 불가피했습니다.

또한, 베타 테스트중인 서버에서 중단 없이 수행해야 했습니다.

 

개선 목표

  • analyzer가 변경된 신규 인덱스를 생성할 것
  • 기존 데이터를 신규 인덱스로 안전하게 이관할 것
  • reindex 도중에도 검색 및 쓰기 요청이 정상적으로 처리되도록 할 것
  • reindex 과정에서 메모리 사용량과 장애 가능성을 통제할 것
  • 문제가 발생했을 경우 즉시 이전 상태로 되돌릴 수 있는 롤백 전략을 확보할 것

rolling update 형태의 reindex를 안정적으로 수행하는 것이 핵심 목표였습니다.

 

해결 과정

전략은 다음과 같았습니다.

1. 버전이 다른 인덱스 하나를 생성하기.

2. 그 인덱스에다가 데이터 이관시키기.

3. 이관 중에 새로 create 또는 update된 document가 있다면 catch-up 해주기. (혹은 double write 설정)

4. 이관시키고 alias를 그 쪽으로 옮기기.

process

 

무사하게 옮기기 위해서는, 모니터링과 elasticsearch의 원리를 이해해야 했습니다.

사전 설정 

  • reindex 성능 향상을 위해 refresh_interval을 임시로 비활성화
  • 애플리케이션은 인덱스 이름을 직접 참조하지 않고 항상 alias를 통해 검색 및 쓰기 요청을 수행
  • throttling 설정

인덱스 전환 시점에는 alias만 교체하는 방식으로 트래픽을 전환할 수 있도록 했습니다.

 

refresh_interval로 인해 latency, GC 압박 등의 예상치 못한 장애가 발생할 수 있으므로 비활성화 하였습니다.

 

throttling은 특정 작업의 처리 속도를 의도적으로 제한하는 것입니다.

작업 속도는 느려질 수 있으나 많은 량의 데이터를 insert하는 작업이기 때문에

자바로 구현된 elastic search의 GC 폭주를 억제하고 세그먼트를 merge 시에 생기는 I/O 바운드를 완화시킬 수 있습니다.

#refresh interval 비활성화
PUT /post_index_alias/_settings
{
  "refresh_interval": "-1"
}

 

reindex 원리

  • scroll 기반 데이터 조회
  • bulk indexing
  • analyzer 재적용
  • segment merge

elasticsearch가 reindex할때는, scroll 기반으로 데이터를 조회해서, 

bulk indexing을 통해 여러 데이터를 한 번에 집어 넣습니다.

copy on write 방식이 아닌, 완전히 새로운 문서로 write하는 방식입니다.

 

reindex 실행 방식

10만건 가량의 게시글을 옮겼습니다.

다음은 실행 순서입니다.

 

1. 새로운 post_index 생성 (post_index_v2)

2. post_index_v2로 데이터 이관 (throttling 적용)

POST /_reindex
{
  "source": {
    "index": "post_index_v1"
  },
  "dest": {
    "index": "post_index_v2"
  },
  "requests_per_second": 500
}

 3. v1 → v2로 alias 전환, 원자적 연산

POST /_aliases
{
  "actions": [
    {
      "remove": {
        "index": "post_index_v1",
        "alias": "post_index_alias"
      }
    },
    {
      "add": {
        "index": "post_index_v2",
        "alias": "post_index_alias"
      }
    }
  ]
}

 

4. 연산 중 create 또는 update가 남아있을 경우, 업데이트 시점 이후로 catch-up을 통해 동기화. (2번 및 3번 반복)

POST /_reindex?requests_per_second=500
{
  "source": {
    "index": "post_index_alias",
    "query": {
      "range": {
        "updated_at": {
          "gte": "2025-01-16T10:30:00Z"
        }
      }
    }
  },
  "dest": {
    "index": "post_index_v2",
    "op_type": "index"
  }
}

 

모니터링

reindex가 실제로 장애없이 잘 동작하고 있는지 확인이 필요하였습니다.

또, 색인이 끝나면 얼마나 걸렸는지와 정상적으로 잘 색인이 되었는지 확인이 필요하였습니다.

 

 

1. reindex가 실제로 실행 중인지 확인

 
GET /_tasks?actions=*reindex
다음 태스크의 응답을 통해 reindex 작업이 백그라운드 task로 정상 실행 중임을 확인했습니다.

 

 

2. JVM / Heap / CPU 상태 확인

GET /_nodes/stats?filter_path=nodes.*.process.cpu.percent,nodes.*.jvm.mem,nodes.*.fs.total

 

중간 진행 시점 응답

  • heap_used_percent: 약 65%
  • Young Gen 사용량 : 2.5GB
  • Old Gen 사용량: 300MB대
  • CPU 사용률: 0%

해석

  • heap은 최대 4.118GB 중에 2.5GB~2.68GB(60~65%) 정도를 사용하였습니다.
  • old gen이 318~365MB 수준에서 크게 변동하지 않았고, young에서 변동이 큰 형태였습니다.
  • Full GC 위험 신호는 없었습니다.
  • throttling(requests_per_second=500)이 잘 작동 중임을 확인하였습니다.

 

3. merge / segment 확인

GET /_nodes/stats/indices/merges,segments
 

중간 실행 시점 응답

  • merges.current: 0
  • segments.count: 30대

해석:

  • reindex에 따른 merge는 이미 대부분 정리되었습니다.
  • 검색 latency를 유발할 만한 merge backlog는 따로 없었습니다

4. 클러스터 안정성 확인

GET /_cluster/health

 

  • 단일 노드 환경에서 replica shard 미할당으로 인한 정상적인 yellow 반응이 나왔습니다.
  • primary shard는 모두 정상이었습니다.

 

5. 색인 작업 종료 확인

 

reindex 전체 소요 시간은 약 209초(3분 29초)가 걸렸습니다.

총 처리 문서 수는 100,000건이며, 이를 기준으로 계산한 평균 처리량은 초당 약 478건입니다.

이는 설정한 requests_per_second=500에 수렴하는 수치로,

reindex가 클러스터 상태에 밀려 느려진 것이 아니라 throttle 값에 따라 제한된 속도로 실행되었음을 보여줍니다.

throttled_millis 값은 약 200초로, 전체 실행 시간 대부분이 throttling 상태였습니다.

 

reindex 이후 변경 데이터(catch-up) 처리 방법

 

Elasticsearch의 _reindex 는 작업 시작 시점의 스냅샷을 기준으로 동작합니다.

따라서 reindex 도중이나 이후에 문서가 생성·수정되었다면, 신규 인덱스에는 해당 변경분이 반영되지 않을 수 있습니다.

이 여부를 판단하기 위해 다음 지표를 확인합니다.

GET /_nodes/stats/indices/indexing

 

  • index_total 값이 reindex 종료 이후에도 증가하는지
  • bulk 또는 update 관련 task가 지속적으로 관측되는지

이 두 가지가 확인된다면, reindex 이후에도 실제 쓰기(write/update)가 발생한 것이며,

추가 보정(catch-up reindex)이 필요합니다.

반대로, reindex 이후에 insert나 update가 전혀 없었다면, catch-up 없이도 정합성이 유지될 수 있습니다.

catch-up reindex의 전제 조건

  • 문서에 updated_at 과 같은 변경 시각 필드가 존재해야 합니다.
  • 해당 필드는
    • update 시 항상 갱신되고
    • 시간 역행이 없으며
    • source 인덱스 기준으로 신뢰 가능해야 합니다.

이 전제가 깨진다면, catch-up 방식은 성립하지 않으며 데이터 동기화 전략 자체를 다시 설계해야 합니다.

catch-up 기준 시각 설정

catch-up의 기준은 전체 backfill reindex가 시작된 시각입니다.
이 시각 이후에 변경된 문서만 다시 이관하는 것이 목적이기 때문입니다.

다음 중 하나로 기준 시각을 잡습니다.

  • reindex task의 start_time_in_millis
  • reindex 실행 시점의 로그 타임스탬프

catch-up reindex 실행 방식

만약 reindex 이후에 변경 데이터가 존재한다면, 다음과 같은 형태로 catch-up reindex를 수행합니다.

POST /_reindex?requests_per_second=500
{
  "source": {
    "index": "post_index_alias",
    "query": {
      "range": {
        "updated_at": {
          "gte": "2025-01-16T10:30:00Z"
        }
      }
    }
  },
  "dest": {
    "index": "post_index_v2",
    "op_type": "index"
  }
}
 

dual-write 전략

write 시에, post_index_v2 까지 write가 되도록 만든다면, catch-up을 따로 하지 않아도 정합성이 보장됩니다.

이번 작업에서는 서비스 코드 변경 없이 analyzer 변경을 적용하는 것을 전제로 했기 때문에 dual-write는 적용하지 않았습니다.

 

alias 전환 및 롤백

 

_aliases API는 클러스터 메타데이터(cluster state)를 단일 업데이트로 교체하며,

remove와 add 작업은 논리적으로 하나의 원자적 연산으로 처리됩니다.

이로 인해 중간 상태가 외부에 노출되지 않고, 검색 및 쓰기 트래픽을 즉시 이전 인덱스로 되돌릴 수 있습니다.

POST /_aliases
{
  "actions": [
    {
      "remove": {
        "index": "post_index_v2",
        "alias": "post_index_alias"
      }
    },
    {
      "add": {
        "index": "post_index_v1",
        "alias": "post_index_alias"
      }
    }
  ]
}

 

GC 운영 고려사항

  • Stop-The-World GC
  • 검색 및 쓰기 지연
  • shard relocation 폭증
  • OOM 및 노드 재시작

apache lucene은 자바 기반의 프로그램이기 때문에, GC를 고려하여 운영해야합니다.

 

이를 방지하기 위해 ES heap을 시스템 RAM의 50% 이하, 최대 32GB 이하로 유지했고, reindex 전 heap 사용률을 확인했으며, GC 로그와 GC pause 시간을 모니터링했습니다. 또한 reindex 속도보다 GC 안정성을 우선하는 운영 기준을 적용했습니다.

 

결과

  • 서비스 중단 없이 analyzer 변경이 적용된 신규 인덱스로 전환할 수 있었습니다
  • reindex 전 과정에서 검색 및 쓰기 요청 정상 처리하였습니다.
  • throttling 적용으로 메모리 사용량 안정적으로 유지하였습니다.
  • alias 기반 전환 및 롤백 구조로 장애 발생 시 즉시 복구 가능한 방법으로 안정적으로 reindex를 수행하였습니다.

'ELK' 카테고리의 다른 글

[Elasticsearch] 검색 최적화하기 (1) - analyzer  (0) 2025.12.15
[Elasticsearch] Nori Tokenizer 파헤치기 (2) - Viterbi 알고리즘  (0) 2025.11.12
[Elasticsearch] Nori Tokenizer 파헤치기 (1) - Lattice 구조  (0) 2025.11.11
[Elasticsearch] 인기 검색어 구현하기 (2)  (0) 2025.11.10
[Elasticsearch] 인기 검색어 구현하기 (1)  (2) 2025.11.07
'ELK' 카테고리의 다른 글
  • [Elasticsearch] 검색 최적화하기 (1) - analyzer
  • [Elasticsearch] Nori Tokenizer 파헤치기 (2) - Viterbi 알고리즘
  • [Elasticsearch] Nori Tokenizer 파헤치기 (1) - Lattice 구조
  • [Elasticsearch] 인기 검색어 구현하기 (2)
ksngh
ksngh
웹 백엔드 개발 블로그입니다. https://github.com/ksngh
  • ksngh
    featherdale
    ksngh
  • 전체
    오늘
    어제
    • 분류 전체보기 (66)
      • 데이터베이스 (10)
      • spring (11)
      • redis (7)
      • ELK (11)
      • 회고 (6)
      • 기타 (12)
        • java (2)
        • 디자인패턴 (2)
        • 영어 (1)
        • 자바스크립트 (1)
        • graphQL (2)
        • 블록체인 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Elasticsearch
    엘라스틱 서치 인 액션
    spring
    조인의 종류
    graphql
    core
    Redis
    회고
    단위테스트
    인기검색어 구현
    NORI
    연말 회고
    레디스
    nori tokenizer
    대용량 데이터 베이스
    NoriTokenizer
    PostgreSQL
    엘라스틱서치
    대용량데이터베이스
    자료구조
    elastic search in action
    Spy
    gof
    단위 테스트
    엘라스틱 서치
    데이터베이스
    Elastic Search
    디자인패턴
    Spring Core
    Mock
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
ksngh
[Elasticsearch] 검색 최적화하기 (2) - 마이그레이션(reindex)
상단으로

티스토리툴바