Elasticsearch 실전 가이드 | 검색·인덱싱·Aggregation·성능 최적화

이 글의 핵심

Elasticsearch 검색 품질은 쿼리 문법보다 매핑과 분석기를 처음에 어떻게 잡느냐에서 갈립니다. 상품명을 text로만 넣었다가 정렬과 필터가 막히는 매핑 실수, nori 없이 한글을 넣었을 때 토큰이 엉뚱하게 잘리는 문제처럼 나중에 reindex를 부르는 결정을 먼저 짚습니다. ES를 주 DB가 아닌 검색·분석 계층으로 두는 기준과 클러스터 상태 점검법도 FAQ로 정리했습니다.

Elasticsearch의 검색 품질은 쿼리 문법보다 매핑과 분석기(analyzer)를 어떻게 잡느냐에서 갈립니다. 쿼리는 나중에 얼마든지 바꿀 수 있지만, 필드 타입이나 토크나이저, 멀티 필드(sub-field) 구성은 한번 색인하고 나면 바꿀 수 없고 새 인덱스로 reindex해야 합니다. 특히 한국어나 일본어처럼 띄어쓰기만으로 단어가 나뉘지 않는 언어에서는 분석기 선택이 검색 결과를 거의 결정합니다.

가장 흔한 매핑 실수는 카테고리나 상태값처럼 필터·정렬·집계에 쓸 필드를 text로만 색인하는 것입니다. 나중에 “이 필드로 facet을 보여 달라”는 요구가 오면 text 필드에는 terms 집계를 걸 수 없어서(fielddata가 기본으로 꺼져 있음), keyword 서브 필드를 추가하고 전체 데이터를 다시 색인해야 합니다. 데이터가 수백 GB로 커진 뒤라면 reindex에 몇 시간씩 걸리고 그동안 디스크도 두 배로 필요합니다. 그래서 “필터·정렬·집계에 쓰는 값은 처음부터 keyword로” 잡고, 한글 필드는 nori 같은 형태소 분석기를 적용한 뒤 스테이징에서 _analyze API로 토큰이 어떻게 쪼개지는지 확인해 두는 것이 사고를 줄입니다.

Elasticsearch를 한 문장으로 설명하면 역인덱스(inverted index)와 분산 샤딩을 기반으로 한 검색·분석 엔진입니다. LIKE '%…%'는 앞에 와일드카드가 붙는 순간 B-tree 인덱스를 쓰지 못해 테이블 전체를 훑지만, 역인덱스는 “이 단어가 들어 있는 문서 목록”을 미리 만들어 두기 때문에 검색 비용이 전체 문서 수가 아니라 일치하는 문서 수에 가깝게 움직입니다. 여기에 오타 허용(fuzzy), 필드별 가중치, 집계까지 한 API로 처리할 수 있습니다. 다만 DB를 대체하지는 않습니다. 트랜잭션과 강한 일관성이 필요한 원본 데이터(source of truth)는 RDB나 문서 DB에 두고, Elasticsearch는 검색·로그·분석 계층으로 두는 편이 운영과 복구 측면에서 안전합니다.

로컬 실습은 Docker 하나로 충분합니다. 보안 설정은 프로덕션에서 따로 잡는다는 전제로, 단일 노드는 아래처럼 띄웁니다.

# 실행 예제
docker run -d \
  --name elasticsearch \
  -p 9200:9200 \
  -p 9300:9300 \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  docker.elastic.co/elasticsearch/elasticsearch:8.12.0

컨테이너가 뜬 뒤 curl http://localhost:9200에 클러스터 정보가 JSON으로 돌아오면 준비된 것입니다.

로컬에서 컨테이너가 뜨자마자 죽는다면 먼저 로그(docker logs elasticsearch)를 봅니다. Linux 호스트에서 가장 흔한 원인은 max virtual memory areas vm.max_map_count [65530] is too low 에러로, 호스트에서 sysctl -w vm.max_map_count=262144를 설정해야 합니다(single-node 모드에서는 경고로 끝나는 경우도 있습니다). 또 8.x 이미지는 기본 JVM 힙을 컨테이너 메모리의 절반 정도로 잡으므로, 노트북에서 메모리가 부족하면 -e "ES_JAVA_OPTS=-Xms1g -Xmx1g"처럼 힙을 명시합니다. xpack.security.enabled=false는 로컬 실습용이고, 8.x는 기본으로 TLS와 인증이 켜지므로 프로덕션에서는 절대 끄면 안 됩니다. 인증 없는 9200 포트가 인터넷에 노출되어 데이터가 삭제되고 몸값을 요구받는 사고는 여러 차례 보안 업체 보고서에 등장한 유형입니다.

인덱스 매핑은 문서의 스키마와 각 필드를 어떤 분석기로 쪼갤지를 함께 정의합니다. products 인덱스라면 아래처럼 잡을 수 있고, 앞에서 말한 이유로 category는 keyword로 둡니다.

# 인덱스 생성
# 실행 예제
curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d'
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "description": { "type": "text" },
      "price": { "type": "float" },
      "category": { "type": "keyword" },
      "tags": { "type": "keyword" },
      "created_at": { "type": "date" }
    }
  }
}
'

이 매핑에서 name은 text만 있어서 전문 검색은 되지만 이름순 정렬이나 정확 일치 필터는 할 수 없습니다. 그래서 실무에서는 "name": { "type": "text", "fields": { "raw": { "type": "keyword" } } }처럼 멀티 필드로 두고, 검색은 name, 정렬·집계는 name.raw로 나눠 씁니다. 매핑을 지정하지 않고 문서를 넣으면 동적 매핑이 문자열을 text + keyword(256자 제한) 조합으로 자동 생성해 주는데, 편한 대신 필드가 늘 때마다 매핑이 커지고 숫자처럼 보이는 문자열이 long으로 잡히는 등 의도와 다른 타입이 고정되기 쉽습니다. 기존 필드의 타입은 나중에 바꿀 수 없고 reindex만 가능하므로, 운영 인덱스는 명시적 매핑과 인덱스 템플릿으로 관리하는 편이 안전합니다.

문서는 단건으로 넣을 수도 있지만, 실서비스에서는 여러 문서를 한 요청에 묶는 _bulk API가 기본입니다.

# 단일 문서
curl -X POST "localhost:9200/products/_doc" -H 'Content-Type: application/json' -d'
{
  "name": "Laptop",
  "description": "High performance laptop",
  "price": 1200.00,
  "category": "Electronics",
  "tags": ["laptop", "computer"],
  "created_at": "2026-04-30"
}
'
# Bulk 추가
curl -X POST "localhost:9200/_bulk" -H 'Content-Type: application/json' -d'
{"index":{"_index":"products"}}
{"name":"Mouse","price":25.00,"category":"Electronics"}
{"index":{"_index":"products"}}
{"name":"Keyboard","price":75.00,"category":"Electronics"}
'

_bulk 본문은 JSON 배열이 아니라 줄바꿈으로 구분된 NDJSON이고, 마지막 줄 뒤에도 줄바꿈이 있어야 합니다. curl로 보낼 때는 -H 'Content-Type: application/x-ndjson'과 --data-binary를 쓰는 것이 정석인데, -d는 줄바꿈을 제거하지 않지만 파일(-d @file)에서 읽을 때는 줄바꿈을 지워 버려서 The bulk request must be terminated by a newline 에러가 나기 쉽습니다. 또 bulk 요청은 일부 문서가 실패해도 HTTP 200을 돌려주고 응답의 "errors": true와 항목별 status로만 실패를 알리므로, 응답을 검사하지 않으면 매핑 충돌로 버려진 문서를 모른 채 넘어갑니다.

검색은 match 쿼리부터 시작합니다. 여러 필드에 가중치를 주는 multi_match, 점수에 반영할 조건과 거르기만 할 조건을 나누는 bool 쿼리가 기본 조합입니다. 오타 허용은 fuzzy로 처리할 수 있지만, 허용 범위를 넓힐수록 엉뚱한 문서가 상위에 올라오므로 실제 검색 로그를 보면서 조정해야 합니다.

curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "query": {
    "match": {
      "name": "laptop"
    }
  }
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "query": {
    "multi_match": {
      "query": "laptop",
      "fields": ["name^2", "description"]
    }
  }
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "query": {
    "bool": {
      "must": [
        { "match": { "category": "Electronics" } }
      ],
      "filter": [
        { "range": { "price": { "gte": 100, "lte": 1000 } } }
      ],
      "should": [
        { "match": { "tags": "laptop" } }
      ]
    }
  }
}
'

bool 쿼리에서 절(clause)의 위치는 점수 계산 여부를 정합니다. must와 should는 관련도 점수(_score)에 반영되고, filter와 must_not은 점수를 계산하지 않고 결과를 거르기만 하며 결과가 캐시될 수 있습니다. 위 예제의 category는 keyword라서 match를 써도 정확 일치로 동작하지만, 점수에 영향을 줄 이유가 없으므로 "filter": [{ "term": { "category": "Electronics" } }]로 옮기는 것이 의도에 맞고 더 빠릅니다. 또 must나 filter가 있는 bool 쿼리에서 should는 기본적으로 “맞으면 점수 가산”일 뿐 필수 조건이 아니므로, tags에 laptop이 없는 문서도 결과에 나옵니다. should 중 하나는 반드시 맞아야 한다면 minimum_should_match: 1을 지정해야 합니다.

curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "query": {
    "fuzzy": {
      "name": {
        "value": "lapto",
        "fuzziness": "AUTO"
      }
    }
  }
}
'

fuzzy 쿼리는 검색어를 분석하지 않고 그대로 편집 거리로 비교한다는 점을 기억해 두세요. 색인된 토큰은 소문자로 바뀌어 있으므로 "value": "Lapto"처럼 대문자로 보내면 기대만큼 매칭되지 않습니다. 사용자 입력을 받는 검색창이라면 match 쿼리에 "fuzziness": "AUTO"를 주는 편이 분석기를 거친 뒤 오타를 허용해 주어 더 자연스럽습니다. AUTO는 토큰 길이에 따라 0~2글자 편집을 허용하는데, 짧은 한글 토큰에서는 한 글자 차이로 전혀 다른 단어가 맞기 쉬워 한국어 서비스에서는 오타 교정을 fuzzy보다 동의어 사전이나 별도 자동완성으로 처리하는 경우가 많습니다.

집계(aggregation)는 SQL의 GROUP BY와 통계 함수를 검색 API 안에서 처리하는 기능입니다. 필터 UI의 facet에는 terms, 가격 분포에는 stats나 histogram을 주로 씁니다. size: 0은 문서 본문 없이 집계 결과만 받겠다는 뜻입니다.

curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "size": 0,
  "aggs": {
    "categories": {
      "terms": {
        "field": "category"
      }
    }
  }
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "size": 0,
  "aggs": {
    "price_stats": {
      "stats": {
        "field": "price"
      }
    }
  }
}
'
curl -X GET "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{
  "size": 0,
  "aggs": {
    "price_ranges": {
      "histogram": {
        "field": "price",
        "interval": 100
      }
    }
  }
}
'

terms 집계는 기본으로 상위 10개 버킷만 돌려주고, 여러 샤드에서 각자 상위 N개를 모아 합치는 방식이라 샤드가 많으면 개수가 근사치가 될 수 있습니다. 응답의 doc_count_error_upper_bound와 sum_other_doc_count가 그 오차와 “나머지” 문서 수를 알려 주므로, 정확한 개수가 중요한 대시보드라면 size와 shard_size를 늘리거나 카테고리 수가 적을 때 샤드를 하나로 두는 방법을 씁니다. text 필드에 terms 집계를 걸면 Fielddata is disabled on [category] in [products] 에러가 나는데, 이것이 서두에서 말한 매핑 실수의 증상입니다. fielddata: true로 켜면 동작은 하지만 토큰 단위로 집계되고 힙을 많이 쓰므로, 답은 keyword 필드입니다.

Node.js에서는 공식 클라이언트 @elastic/elasticsearch를 쓰고, 쿼리 본문은 REST API와 같은 구조의 객체로 넘깁니다. 검색·색인·자동완성을 함수로 나누고, 응답의 took(서버 처리 시간)과 _shards.failed를 로그로 남겨 두면 나중에 느려진 원인을 추적하기 쉽습니다.

npm install @elastic/elasticsearch
import { Client } from '@elastic/elasticsearch';
const client = new Client({
  node: 'http://localhost:9200',
});
// 검색
async function searchProducts(query: string) {
  const result = await client.search({
    index: 'products',
    body: {
      query: {
        multi_match: {
          query,
          fields: ['name^2', 'description'],
        },
      },
    },
  });
  return result.hits.hits.map((hit) => hit._source);
}
// 인덱싱
async function indexProduct(product: any) {
  await client.index({
    index: 'products',
    body: product,
  });
}
// 자동완성
async function autocomplete(prefix: string) {
  const result = await client.search({
    index: 'products',
    body: {
      query: {
        match_phrase_prefix: {
          name: prefix,
        },
      },
      size: 5,
    },
  });
  return result.hits.hits.map((hit) => hit._source.name);
}

위 코드는 v7 스타일의 body 파라미터를 쓰고 있습니다. 8.x 클라이언트는 client.search({ index: 'products', query: {...}, size: 5 })처럼 요청 본문의 키를 최상위에 바로 쓰는 방식이 기본이고, body는 하위 호환용으로 남아 있어 향후 제거될 수 있습니다. 또 8.x에서는 응답이 result.body 없이 바로 본문이라 result.hits.hits로 접근하지만, 7.x 클라이언트는 result.body.hits.hits라서 버전을 섞으면 Cannot read properties of undefined (reading 'hits')가 납니다. 클라이언트 메이저 버전은 서버 메이저 버전과 맞추는 것이 원칙입니다.

match_phrase_prefix를 자동완성에 쓰는 것은 가장 간단한 방법이지만, 마지막 단어의 접두사를 색인 전체 용어에서 확장하는 방식이라(기본 max_expansions 50) 데이터가 커지면 기대한 후보가 빠지거나 느려질 수 있습니다. 트래픽이 있는 자동완성은 search_as_you_type 필드 타입이나 edge_ngram 분석기로 색인 시점에 접두사를 만들어 두는 편이 안정적이고, 이 경우 색인 크기가 늘어나는 비용을 감수합니다.

한글 text 필드에 기본 standard 분석기를 쓰면 공백과 문장부호 기준으로만 나뉘어, “삼성전자는” 같은 어절이 조사까지 붙은 하나의 토큰이 되고 “삼성전자”로 검색해도 매칭되지 않습니다. 한국어 형태소 분석기인 nori 플러그인을 설치하고 커스텀 분석기를 정의한 뒤, 스테이징에서 _analyze로 토큰을 확인하는 순서로 진행합니다.

# nori 플러그인 설치
bin/elasticsearch-plugin install analysis-nori

플러그인은 클러스터의 모든 노드에 설치하고 재시작해야 합니다. 한 노드라도 빠지면 그 노드에 샤드가 배정될 때 분석기를 찾지 못해 인덱스 생성이 실패합니다. Docker라면 실행 중인 컨테이너에 설치하는 대신 FROM docker.elastic.co/elasticsearch/elasticsearch:8.12.0 위에 RUN bin/elasticsearch-plugin install --batch analysis-nori를 넣은 이미지를 따로 만드는 것이 재현 가능한 방법이고, 플러그인 버전은 ES 버전과 정확히 일치해야 합니다.

curl -X PUT "localhost:9200/products_kr" -H 'Content-Type: application/json' -d'
{
  "settings": {
    "analysis": {
      "analyzer": {
        "korean": {
          "type": "custom",
          "tokenizer": "nori_tokenizer",
          "filter": ["lowercase"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "korean"
      }
    }
  }
}
'

설정 후에는 curl -X GET "localhost:9200/products_kr/_analyze" -H 'Content-Type: application/json' -d '{"analyzer":"korean","text":"삼성전자 노트북"}'로 토큰을 직접 확인합니다. nori는 기본 decompound_mode가 discard라서 복합어를 쪼갠 뒤 원형을 버리는데, 그러면 “삼성전자”로 정확히 검색하고 싶은 사용자에게 “삼성”만 들어간 문서까지 섞여 나올 수 있습니다. 원형과 분해 결과를 모두 색인하려면 nori_tokenizer를 커스텀 토크나이저로 정의해 decompound_mode: mixed를 주고, 조사·어미를 걸러 내려면 nori_part_of_speech 필터를 추가합니다. 신조어나 상품명처럼 사전에 없는 단어는 user_dictionary로 등록해야 원하는 단위로 잘립니다. 분석기를 바꾸면 기존 문서는 이미 옛 분석기로 색인되어 있으므로, 새 인덱스를 만들고 _reindex한 뒤 alias를 전환하는 흐름이 필요합니다.

쓰기 성능은 대부분 세 가지로 정리됩니다. 문서를 _bulk로 묶어 보내고, 실시간 검색이 꼭 필요하지 않다면 refresh_interval을 늘리고, 캐시 설정은 실제 쿼리 패턴을 보고 판단합니다. 색인 파이프라인이 병목이라면 bulk 배치 크기와 refresh 주기부터 확인합니다.

// 대량 인덱싱
async function bulkIndex(products: any[]) {
  const operations = products.flatMap((doc) => [
    { index: { _index: 'products' } },
    doc,
  ]);
  const res = await client.bulk({ operations });  // 8.x: body 대신 operations
  if (res.errors) {
    // 일부 문서만 실패해도 예외가 나지 않으므로 항목별로 확인
    const failed = res.items.filter((i) => i.index?.error);
    console.error(`bulk 실패 ${failed.length}건`, failed[0]?.index?.error);
  }
}

bulk 한 번에 몇 건을 보낼지는 문서 크기에 따라 다르지만, 공식 문서는 작은 배치에서 시작해 크기를 늘려 가며 처리량이 더 늘지 않는 지점을 찾되, 한 요청이 수십 MB를 넘지 않게 하라고 권합니다. 너무 크게 보내면 코디네이팅 노드의 메모리 압박과 429 Too Many Requests(es_rejected_execution_exception)가 늘어나므로, 429를 받으면 지수 백오프로 재시도하는 로직이 필요합니다. JS 클라이언트의 client.helpers.bulk()는 배치 분할, 동시성, 재시도를 대신 처리해 줍니다.

curl -X PUT "localhost:9200/products/_settings" -H 'Content-Type: application/json' -d'
{
  "index": {
    "requests.cache.enable": true
  }
}
'

요청 캐시(index.requests.cache.enable)는 기본값이 true라 위 설정은 꺼 두었던 캐시를 다시 켤 때 쓰는 명령입니다. 이 캐시는 기본적으로 size: 0인 요청(집계·카운트)의 결과만 샤드 단위로 캐시하고, 샤드가 refresh되어 데이터가 바뀌면 무효화됩니다. 그래서 대시보드처럼 같은 집계를 반복하는 경우에는 효과가 크지만, 문서를 돌려주는 일반 검색에는 영향이 없고 now가 들어간 범위 쿼리는 요청마다 값이 달라 캐시되지 않습니다. 날짜 범위를 now/d처럼 반올림하면 캐시 적중률을 높일 수 있습니다.

# 실시간 검색이 필요 없으면 interval 증가
curl -X PUT "localhost:9200/products/_settings" -H 'Content-Type: application/json' -d'
{
  "index": {
    "refresh_interval": "30s"
  }
}
'

refresh_interval은 새로 색인한 문서가 검색에 보이기까지의 주기입니다. 기본값 1초마다 메모리 버퍼를 새 세그먼트로 만들어 검색 가능하게 하는데(near real-time), 세그먼트가 잦게 생기면 병합(merge) 비용이 커집니다. 30초로 늘리면 색인 처리량은 좋아지지만 “방금 등록한 상품이 검색에 안 나온다”는 문의가 생길 수 있으므로 서비스 요구와 맞춰야 합니다. 초기 대량 적재라면 적재 동안 refresh_interval: -1과 number_of_replicas: 0으로 두었다가 끝난 뒤 원래 값으로 되돌리는 것이 일반적인 방법입니다. 이 설정을 되돌리는 것을 잊으면 새 문서가 영원히 검색되지 않거나 복제본 없이 운영되는 상태가 되므로, 적재 스크립트의 마지막 단계로 넣어 두는 편이 안전합니다.

운영에서 가장 먼저 보는 것은 GET _cluster/health의 상태 색입니다. yellow는 모든 주 샤드는 있지만 복제본 일부가 배정되지 않은 상태로, 노드가 하나뿐인 로컬에서 복제본 1을 요구하면 항상 yellow가 됩니다. red는 주 샤드가 없어 일부 데이터를 읽을 수 없는 상태입니다. 원인은 GET _cluster/allocation/explain이 가장 정확하게 알려 주며, 실무에서 자주 만나는 것은 디스크 사용률이 워터마크(기본 low 85%, high 90%, flood stage 95%)를 넘은 경우입니다. flood stage에 닿으면 ES가 해당 인덱스를 읽기 전용(index.blocks.read_only_allow_delete)으로 바꿔 색인이 403 FORBIDDEN/12/index read-only / allow delete 에러로 막히는데, 디스크를 확보한 뒤에도 최근 버전은 자동 해제하지만 오래된 버전은 이 블록을 수동으로 풀어야 합니다. 이 에러는 애플리케이션 쪽에서는 색인 요청이 갑자기 403으로 거부되는 모습으로만 보여 코드 버그로 오해하기 쉬우므로, 403이 보이면 클러스터 디스크 상태부터 확인하는 것이 좋습니다. 로그성 데이터는 ILM(Index Lifecycle Management)으로 오래된 인덱스를 자동 삭제하는 정책을 처음부터 걸어 두는 것이 예방책입니다.

마지막으로 자주 나오는 질문 몇 가지를 정리합니다. PostgreSQL 전문 검색과 비교하면, 형태소 분석·가중 멀티 필드·집계·오타 허용까지 필요할 때는 Elasticsearch 쪽 기능이 풍부합니다. 반대로 PostgreSQL의 tsvector/GIN 인덱스나 pg_trgm으로 해결되는 간단한 텍스트 검색 수준이라면, 동기화 파이프라인과 클러스터 운영 부담을 떠안지 않는 편이 낫습니다. Elasticsearch를 붙이는 순간 DB와 검색 인덱스가 어긋나는 문제(동기화 지연, 삭제 누락)를 다뤄야 한다는 점이 가장 큰 숨은 비용입니다.

메모리는 JVM 힙과 운영체제 파일 캐시를 함께 쓰므로 넉넉해야 하며, 공식 문서는 힙을 노드 메모리의 절반 이하, 그리고 압축 포인터가 유지되는 약 31GB 이하로 두라고 권합니다. 로컬이나 소규모 서비스는 단일 노드로도 충분하지만, 프로덕션에서 마스터 선출이 한 노드 장애에 견디려면 마스터 자격 노드를 3개 이상 두는 것이 기본입니다. Kibana는 데이터 탐색과 대시보드를 위한 UI로, 로그·메트릭을 다룬다면 사실상 함께 쓰고 검색 애플리케이션만 만든다면 선택 사항입니다.

같이 보면 좋은 글