검색은 잘 되는데 클러스터가 느리다. 색인이 한참 걸리고, 검색 응답이 들쭉날쭉하고, 노드 하나가 유독 뜨겁다. 대부분은 ES가 느린 게 아니라 샤드를 잘못 잡았거나, 색인/검색을 ES 구조에 안 맞게 쓰고 있는 것이다.
성능 튜닝은 마법이 아니라 구조를 따라가는 작업이다. 저장 단위(샤드), 색인 경로(bulk + refresh), 검색 경로(filter + 페이징), 그리고 매핑. 이 네 곳을 순서대로 점검하면 대부분의 병목이 잡힌다.
샤드는 몇 개, 얼마 크기로 잡아야 하나?
가장 먼저 그리고 가장 되돌리기 어려운 결정이다. 프라이머리 샤드 수는 인덱스 생성 후 못 바꾼다(바꾸려면 reindex). 그래서 처음에 데이터량을 예측해서 잡아야 한다.
→ 샤드가 곧 Lucene 인덱스이고 수가 고정인 이유: Elasticsearch 동작 원리 — 역색인부터 세그먼트까지
실무 기준은 샤드당 10~50GB다. 이 범위에서 정하고, 필요한 샤드 수를 역산한다.
프라이머리 샤드 수 ≈ 예상 데이터량 / 타깃 샤드 크기
예) 600GB 예상, 타깃 40GB → 약 15 샤드
여기에 노드당 샤드 수 한도도 같이 본다. 경험칙으로 힙 1GB당 샤드 약 20개 이하를 권장한다. 31GB 힙 노드라면 한 노드에 600개 안팎이 상한선이다(레플리카 포함, 전체 인덱스 합산).
샤드를 너무 잘게 쪼개면 왜 안 되나?
“많을수록 병렬이 잘 되겠지”라고 1GB짜리 샤드 수백 개를 만들면 오히려 느려진다. **오버샤딩(over-sharding)**이다.
샤드 하나하나가 비용이다. 각 샤드는 자체 Lucene 인덱스라 메모리(FST, 세그먼트 메타데이터)를 먹고, 클러스터 상태(cluster state)에 메타데이터가 쌓인다. 검색은 scatter-gather라 모든 샤드에 쿼리를 뿌리는데, 샤드가 많을수록 흩뿌리고 다시 모으는 코디네이션 오버헤드가 커진다. 작은 샤드 100개를 검색하는 게 적당한 샤드 10개보다 느릴 수 있다.
반대로 너무 적으면 핫스팟이 생긴다. 샤드가 노드 수보다 적으면 일부 노드는 놀고 일부만 일한다. 색인/검색 부하가 한 노드에 몰려 그 노드가 병목이 된다.
정리: 샤드는 적당히 크게(10~50GB), 노드 수에 맞춰 고르게 분산되도록. 시계열이면 인덱스를 시간 단위로 롤오버해서 인덱스당 샤드 수를 일정하게 유지한다.
→ 시계열 인덱스 롤오버와 티어 운영: Elasticsearch 핫/콜드 데이터 운영 — ILM과 데이터 티어
대량 색인은 어떻게 빠르게 하나?
한 건씩 색인(save() 반복)은 매번 HTTP 왕복이 나가 끔찍하게 느리다. Bulk API로 묶는 게 기본이다.
bulk 요청 하나의 크기는 보통 5~15MB를 측정해서 정한다. 너무 작으면 왕복이 늘고, 너무 크면 한 요청이 무거워져 노드 메모리를 압박하고 타임아웃이 난다. 문서 크기에 따라 건수로는 대략 1,000~5,000건 사이다.
대량 색인 중에는 두 설정을 임시로 바꾸면 크게 빨라진다.
# 색인 직전: refresh 끄고, 레플리카 0으로
PUT /products/_settings
{ "index": { "refresh_interval": "-1", "number_of_replicas": 0 } }
# ... bulk 색인 ...
# 끝나고 원복 + 강제 refresh
PUT /products/_settings
{ "index": { "refresh_interval": "1s", "number_of_replicas": 1 } }
POST /products/_refresh
refresh_interval: -1: 1초마다 작은 세그먼트가 우후죽순 생기고 그걸 머지하는 비용이 폭발하는 걸 막는다. 색인이 끝나면 한 번에 refresh한다.number_of_replicas: 0: 색인 중에는 레플리카에도 똑같이 써야 해서 부하가 배가 된다. 0으로 뒀다가 끝나고 복원하면 ES가 세그먼트를 통째로 복사해 더 빠르다. 단 복원 전까지는 이중화가 없으므로 초기 적재 같은 상황에만 쓴다.
클라이언트 병렬도도 중요하다. 단일 스레드로 bulk를 순차 전송하면 노드가 놀 수 있다. 여러 스레드/커넥션으로 동시에 bulk를 보내되, 노드가 거부(429 TOO_MANY_REQUESTS, bulk 큐 포화)를 내기 시작하면 그 직전이 적정 병렬도다. 측정해서 올린다.
→ Spring/Java에서의 bulk 구현과 refresh 제어 코드: Spring Boot에서 Elasticsearch 다루기
검색을 빠르게 하는 첫 번째 — filter context
검색 절을 어디에 두느냐가 성능을 가른다. ES 쿼리에는 두 가지 맥락이 있다.
- query context: “얼마나 잘 맞나”를 점수(
_score)로 계산한다. 비싸고 캐시 안 됨. - filter context: “맞나 안 맞나” yes/no만 본다. 점수 계산이 없고, 결과가 캐시(query cache)된다.
정확 일치 조건(카테고리, 가격 범위, 재고 여부, 날짜 범위, term 매칭)은 점수가 필요 없으므로 전부 filter로 빼야 한다. 관련도가 필요한 텍스트 매칭만 query(must)에 둔다.
{
"query": {
"bool": {
"must": [ { "match": { "name": "갤럭시 케이스" } } ],
"filter": [
{ "term": { "category": "accessory" } },
{ "range": { "price": { "gte": 10000, "lte": 50000 } } },
{ "term": { "inStock": true } }
]
}
}
}
같은 필터 조건이 반복되는 트래픽(예: “재고 있음”)은 캐시 히트로 빨라진다. 반대로 모든 조건을 must에 넣으면 매번 불필요한 점수 계산을 하고 캐시도 못 탄다.
검색을 빠르게 하는 두 번째 — _source와 응답 줄이기
검색 결과로 ES는 기본적으로 문서 원본 전체(_source)를 돌려준다. 큰 본문/설명 필드가 있으면 네트워크와 역직렬화 비용이 커진다.
- 필요한 필드만:
"_source": ["id", "name", "price"]로 화면에 쓸 필드만 가져온다. 큰 필드는 제외. - 정렬·집계는 doc_values 위에서: 정렬/집계는 역색인이 아니라 컬럼 지향 구조인
doc_values로 처리된다.text타입은 doc_values가 없어 정렬·집계가 안 되므로, 그 용도는keyword필드를 쓴다. - 전체 건수 끄기: 총 몇 건인지 화면에 안 쓰면
track_total_hits: false로 둔다. 매칭 문서를 끝까지 세는 비용을 아낀다.
깊은 페이징은 왜 from+size가 아닌가?
무한 스크롤에서 from: 10000, size: 20처럼 깊이 들어가면 급격히 느려진다. scatter-gather 구조 때문이다.
from + size = 10020 이면,
각 샤드가 자기 top-10020을 모아서 코디네이터로 보내고,
코디네이터가 (샤드 수 × 10020)개를 모아 정렬한 뒤 20개만 남긴다.
뒤로 갈수록 모든 샤드가 모으는 양과 코디네이터 메모리가 선형으로 커진다. 그래서 ES는 index.max_result_window(기본 10,000)로 from+size를 막아둔다.
대신 **search_after(+ PIT)**를 쓴다. 직전 페이지 마지막 문서의 정렬값을 커서로 넘겨 그 다음부터 가져오는 방식이라, 몇 페이지를 가도 비용이 일정하다. 정렬에 고유값 tie-breaker(예: id)를 반드시 포함해야 안정적으로 동작한다.
→ search_after 구현 코드와 tie-breaker: Spring Boot에서 Elasticsearch 다루기
와일드카드와 정규식은 왜 피하나?
*검색어*(leading wildcard), regexp, 무거운 fuzziness는 term dictionary를 넓게 훑어야 해서 비싸다. 특히 앞 와일드카드는 RDB의 LIKE '%..%'와 비슷하게 사전을 광범위하게 스캔한다.
부분 일치/자동완성이 필요하면 런타임 와일드카드 대신 색인 시점에 처리한다. edge_ngram으로 미리 토큰을 만들어두면 검색은 단순 term 매칭이 된다. 오타 보정 fuzzy도 정확 매칭이 0건일 때만 fallback으로 쓴다.
→ ngram·자동완성·fuzzy의 동작과 비용: Elasticsearch Analyzer 내부 구조와 ngram 원리, Elasticsearch 오타 처리 — 퍼지 검색과 한글 자모 분해
매핑으로도 성능이 바뀐다
스키마 단계에서 안 쓰는 기능을 끄면 색인 속도와 용량이 좋아진다.
- text vs keyword 구분: 전문 검색은
text, 정확 일치·정렬·집계는keyword. 한 필드를 멀티필드로 둘 다 두는 게 흔한 패턴(brand+brand.raw). index: false: 검색 안 하고 결과로만 보여주는 필드(예: 썸네일 URL)는 색인을 끈다. 역색인을 안 만들어 용량·속도 이득.doc_values: false: 정렬·집계 안 하는 필드는 컬럼 구조를 끈다.norms: false: 길이 정규화 점수가 필요 없는 필드(태그, 코드성 키워드)는 norms를 꺼 용량을 줄인다._source비활성은 신중히:_source를 끄면 용량은 주지만 reindex·update·부분 필드 조회가 막힌다. 대부분은 켜두고_source필터링으로 응답만 줄이는 게 낫다.
인덱스 레벨 — force_merge
색인이 끝나 더 이상 안 변하는 인덱스(어제자 로그 등)는 force_merge로 세그먼트를 하나로 합치면 검색이 빨라지고 삭제 문서가 실제로 제거된다.
POST /orders-2026.06.28/_forcemerge?max_num_segments=1
단 색인 중인 핫 인덱스에는 절대 하면 안 된다. 새 문서가 또 세그먼트를 만들어 의미가 없고 CPU/디스크 I/O만 잡아먹는다. 그래서 ILM에서는 warm 단계(색인 종료 후)에 돌린다.
노드와 JVM 레벨
마지막은 클러스터 자체 설정이다.
- 힙은 32GB 미만으로: JVM은 약 32GB 경계 아래에서 compressed oops(압축 객체 포인터)를 쓴다. 이 경계를 넘기면 포인터가 커져 오히려 실질 메모리 효율이 떨어진다. 그래서 힙을 26~30GB 선에서 멈추고, 남는 물리 메모리는 OS 파일시스템 캐시(Lucene이 세그먼트를 여기서 읽음)로 양보한다. 물리 메모리의 절반 이하 + 32GB 미만이 원칙.
- 노드 역할 분리: 규모가 커지면 master / data / coordinating(ingest) 역할을 노드별로 나눈다. 전용 마스터는 클러스터 상태만 관리해 안정적이고, coordinating 노드가 scatter-gather의 gather 부하를 받아 data 노드를 보호한다.
- query cache vs request cache: query cache는 filter 결과(세그먼트 단위)를 캐시한다(위 filter context). request cache는 집계 위주의 검색 응답 전체를 샤드 단위로 캐시하는데,
size: 0(히트 없이 집계만)인 요청에 특히 효과적이다. 대시보드성 집계 쿼리가 반복되면 큰 이득이다.
튜닝 우선순위 정리
병목을 못 찾았을 때 보통 이 순서로 점검하면 빠르다.
- 샤드 설계: 샤드당 10~50GB, 노드에 고르게. 오버샤딩/핫스팟 점검. (가장 되돌리기 어려움 → 처음에)
- 색인 경로: bulk 5~15MB, 색인 중
refresh_interval: -1+ 레플리카 0, 클라이언트 병렬도. - 검색 경로: 정확 조건은 filter context로,
_source필요한 필드만, 깊은 페이징은 search_after, 와일드카드 회피. - 매핑: text/keyword 구분, 안 쓰는
index/doc_values/norms끄기. - 인덱스/노드: 읽기 전용 인덱스 force_merge, 힙 32GB 미만, 역할 분리, 캐시 활용.
각 단계는 측정하고 바꾼다. “느릴 것 같아서”가 아니라 slow log와 노드 메트릭(힙, CPU, bulk 큐, 머지 시간)을 보고 병목을 특정한 다음, 해당 레이어만 손대는 게 원칙이다.