🔍

엘라스틱서치 데이터 핸들링

최민석·2025-12-31

엘라스틱서치 데이터 핸들링

elasticsearch

🔍 엘라스틱서치 스터디 시리즈

⭐️본 포스팅에서는 각종 API의 간단한 사용법을 나열합니다.

단건 문서 API

색인 API

PUT  [인덱스 이름]/_doc/[_id값]
POST [인덱스 이름]/_doc

PUT  [인덱스 이름]/_create/[_id값]
POST [인덱스 이름]/_create/[_id값]

_doc은 upsert를 허용하고, _create는 insert만 허용합니다.

routing 매개변수

PUT routing_test/_doc/2?routing=myid2
{
  "login_id": "myid2",
  "comment": "hello elasticsearch",
  "created_at": "2020-12-01T00:08:12.378Z"
}

색인시 라우팅값을 지정해줄 수 있습니다. routing을 통해 특정한 문서 집합을 특정 샤드에 몰아넣어 검색 성능을 향상시킬 수 있습니다.

refresh 매개변수

refresh 값 동작 방식
true 색인 직후 문서가 색인된 샤드를 refresh하고 응답을 반환한다.
wait_for 색인 이후 문서가 refresh될 때까지 기다린 후 응답을 반환한다. true로 지정했을 때와는 다르게 refresh를 직접 유발하지는 않는다. 다만 너무 많은 요청이 refresh 대기 중인 경우 강제로 refresh가 수행될 수 있다.
false 아무 값도 지정하지 않았을 때 기본값이다. refresh와 관련된 동작을 수행하지 않는다.

조회 API

GET [인덱스 이름]/_doc/[_id값]
GET [인덱스 이름]/_source/[_id값]

_doc은 문서의 메타데이터를 포함한 모든 정보를, _source는 문서의 본문만을 반환합니다.

필드 필터링 _source_includes, _source_excludes 옵션을 통해 source 기반 필터링을 수행할 수 있습니다.

GET my_index2/_doc/1?_source_includes=p*,views

{
  "_index": "my_index2",
  "_type": "_doc",
  "_id": "1",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "public": true,
    "views": 1234,
    "point": 4.5
  }
}
GET my_index2/_doc/1?_source_includes=p*,views&_source_excludes=public

{
  "_index": "my_index2",
  "_type": "_doc",
  "_id": "1",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "views": 1234,
    "point": 4.5
  }
}

업데이트 API

POST [인덱스 이름]/_update/[_id값]

업데이트 API는 기본적으로 부분 업데이트를 수행하기 때문에, 전체를 교체하려면 색인 API를 사용해야합니다.

doc을 통한 업데이트

POST [인덱스 이름]/_update/[_id값]
{
  "doc": {
    [업데이트할 내용]
  }
}

script를 통한 업데이트

POST update_test/_update/1
{
  "script": {
    "source": "ctx._source.views += params.amount",
    "lang": "painless",
    "params": {
      "amount": 1
    }
  },
  "scripted_upsert": false
}

script 방식에서 사용할 수 있는 문맥 정보

이름 내용
params 업데이트 요청에서 params로 제공한 매개변수의 Map이다. 읽기 전용이다.
ctx._source 문서의 _source를 Map 형태로 반환한다. 이 값은 변경 가능하다.
ctx.op 작업의 종류를 String으로 나타낸다. 기본적으로 업데이트 요청의 ctx.op 값은 "index"가 된다. 이 값은 변경 가능하다. 즉, 스크립트를 수행하는 도중에 조건의 분기에 따라 업데이트 작업을 수행하지 않도록 하거나 아예 삭제 작업을 수행하도록 할 수도 있다. 가능한 값은 "index", "none", "delete"의 3개다.
ctx._now 현재 타임스탬프값을 밀리세컨드로 반환한다. 읽기 전용이다.
ctx._index 문서의 각 메타데이터를 반환한다. 모두 읽기 전용이다.
ctx._id 문서의 각 메타데이터를 반환한다. 모두 읽기 전용이다.
ctx._type 문서의 각 메타데이터를 반환한다. 모두 읽기 전용이다.
ctx._routing 문서의 각 메타데이터를 반환한다. 모두 읽기 전용이다.
ctx._version 문서의 각 메타데이터를 반환한다. 모두 읽기 전용이다.

삭제 API

DELETE [인덱스 이름]/_doc/[_id값]

복수 문서 API

엘라스틱 서치의 대부분의 기능은 HTTP 프로토콜을 사용하는 Rest API 이므로, 각 API 호출시 통신 오버헤드를 줄이기 위해 요청 응답 사이클을 최소화하는 것이 중요합니다. 이를 위해 벌크처리 기능이 존재합니다.

Bulk API

bulk API는 요청 본문을 JSON이 아니라 NDJSON 형태로 만들어서 보냅니다. NDJSON은 여러 줄의 JSON을 줄바꿈 문자로 구분하여 요청을 보내는 형태입니다.

Content-Type 헤더도 application/json 대신 application/x-ndjson을 사용해야 합니다.

⚠️ 가장 마지막 줄도 줄바꿈 문자 \n으로 끝나야 함

POST _bulk
{"index":{"_index":"bulk_test","_id":"1"}}
{"field1":"value1"}

{"delete":{"_index":"bulk_test","_id":"2"}}

{"create":{"_index":"bulk_test","_id":"3"}}
{"field1":"value3"}

{"update":{"_id":"1","_index":"bulk_test"}}
{"doc":{"field2":"value2"}}

{"index":{"_index":"bulk_test","_id":"4","routing":"a"}}
{"field1":"value4"}

Bulk API의 작업 순서

Bulk API의 작업 순서는 기본적으로 보장되지 않습니다.

각 요청에 대해 어떤 샤드로 넘어간 요청은 각자 독자적으로 수행되므로, 여기에는 조정노드가 순서를 관여하지 않습니다.

단,_index, _id, _routing 조합이 같은 경우는 같은 샤드 내에서 처리되므로 그 안에서의 순서가 보장됩니다.

Multi Get API

GET _mget
GET [인덱스 이름]/_mget

아래는 3개 문서를 한번에 bulk get 하는 예시입니다.

GET _mget
{
  "docs": [
    {
      "_index": "bulk_test",
      "_id": 1
    },
    {
      "_index": "bulk_test",
      "_id": 4,
      "routing": "a"
    },
    {
      "_index": "my_index2",
      "_id": "1",
      "_source": {
        "include": [ "p*" ],
        "exclude": [ "point" ]
      }
    }
  ]
}

아래는 그 응답입니다.

{
  "docs": [
    {
      "_index": "bulk_test",
      "_type": "_doc",
      "_id": "1",
      "_version": 2,
      "_seq_no": 3,
      "_primary_term": 1,
      "found": true,
      "_source": {
        "field1": "value1",
        "field2": "value2"
      }
    },
    {
      // ...
    },
    {
      // ...
    }
  ]
}

update by query & delete by query

update by query와 delete by query는 bulk api나 multi get api와는 성경이 사뭇 다릅니다.

먼저 update by query는 update api와 달리 _doc을 이용한 업데이트 기능을 지원하지 않습니다. _script만 지원합니다.

또한 문맥 정보 중 ctx._now도 사용할 수 없습니다.

update by query는 작업 전에 처리할 문서에 대해 스냅샷을 찍어두는데, 이때 스냅샷과 현재 문서가 일치하지 않는 경우 어떤 정책을 적용할지 conflict매개변수로 지정할 수 있습니다.

⚠️update by query는 트랜잭션이 지원되지 않습니다.
즉, 업데이트 작업이 도중에 실패하더라도 지금까지의 변경분은 유지되므로, 이를 염두에 두어야합니다.

아래는 _source안에 field1이 존재하는 모든 문서를 업데이트하는 예시입니다.

POST bulk_test/_update_by_query
{
  "script": {
    "source": "ctx._source.field1 = ctx._source.field1 + '-' + ctx._id",
    "lang": "painless"
  },
  "query": {
    "exists": {
      "field": "field1"
    }
  }
}

아래는 그 응답입니다.

{
  "took": 191,
  "timed_out": false,
  "total": 3,
  "updated": 3,
  "deleted": 0,
  "batches": 1,
  "version_conflicts": 0,
  "noops": 0,
  "retries": {
    "bulk": 0,
    "search": 0
  },
  "throttled_millis": 0,
  "requests_per_second": -1.0,
  "throttled_until_millis": 0,
  "failures": []
}

💡쓰로틀링
update by query는 보통 관리 목적으로 사용되므로, 실서버에서 사용할 경우 라이브 서비스 중인 어플리케이셔에 지장이 없도록 벌크 작업에 쓰로틀링 처리를 해주어야 합니다.

POST bulk_test/_update_by_query?scroll_size=1000&scroll=1m&requests_per_seconds=500
{
// ...
}
  • scroll_size=1000: 한 번의 스크롤 조회에서 처리할 문서 수(batch 크기)를 1000개로 지정한다
  • scroll=1m: 스크롤 컨텍스트를 1분 동안 유지하여 대량 문서를 나눠서 조회·처리할 수 있게 한다(처리 가능한 시간 이상으로 설정)
  • requests_per_seconds=500: 초당 최대 500건의 업데이트 요청만 처리하도록 제한해 클러스터 부하를 제어한다

delete by query 역시 update by query와 마찬가지로 검색 조건에 맞는 문서를 찾아 스냅샷을 찍습니다. 삭제 작업이 진행되는 동안 문서의 내용이 변경됐다면 버전 충돌이 일어나는 부분도 update by query와 동일합니다. 버전 충돌 시 작업 여부를 지정하는 conflicts 매개변수, 스로틀링 적용 등과 관련된 내용도 update by query와 동일합니다.

POST [인덱스 이름]/_delete_by_query
{
  "query": {
    // ...
  }
}

검색 API

검색 대상 지정

엘라스틱서치는 다양한 검색 쿼리를 제공하지만, 검색 대상(index)을 지정하는 방식은 쿼리 종류와 무관하게 동일합니다.
즉, 어떤 쿼리를 사용하든 검색 대상은 요청 경로(URL)에서 결정되고, 쿼리 내용만 요청 본문에서 바뀝니다.

GET  [인덱스 이름]/_search
POST [인덱스 이름]/_search
GET  _search
POST _search

GET과 POST 중 어떤 메서드를 사용하든 동작은 동일합니다.
인덱스 이름을 지정하지 않으면 클러스터 내 전체 인덱스를 대상으로 검색이 수행됩니다.

일반적인 서비스 환경에서는 검색 범위를 가능한 한 좁혀야 하므로,
전체 인덱스를 대상으로 검색하는 방식은 거의 사용하지 않습니다.
보통은 인덱스 이름을 명시적으로 지정해 검색합니다.

인덱스 이름에는 와일드카드(*)를 사용할 수 있으며,
콤마(,)로 여러 인덱스를 동시에 지정하는 것도 가능합니다.

GET my_index*,analyzer_test*,mapping_test/_search

검색 대상만 지정하고 쿼리 조건을 주지 않으면,
지정한 인덱스 내의 모든 문서가 hit 대상이 됩니다.


쿼리 DSL 검색과 쿼리 문자열 검색

검색 조건을 지정하는 방식에는 두 가지가 있습니다.

  • 요청 본문에 Query DSL을 작성하는 방식
  • 요청 URL에 q 파라미터로 쿼리 문자열을 전달하는 방식

이 두 방식은 동시에 사용할 수 없으며,
요청 본문과 q 파라미터가 함께 존재할 경우 q 파라미터가 우선 적용됩니다.

Query DSL 검색

Query DSL은 엘라스틱서치에서 가장 일반적으로 사용하는 검색 방식입니다.
요청 본문에 query 필드를 두고, 그 안에 원하는 검색 조건을 기술합니다.

GET my_index/_search
{
  "query": {
    "match": {
      "title": "hello"
    }
  }
}

위 예시는 title 필드에 hello라는 단어가 매칭되는 문서를 검색합니다.

쿼리 문자열 검색 (Query String)

쿼리 문자열 검색은 요청 URL의 q 매개변수에 루씬 쿼리 문법을 사용해 검색 조건을 기술하는 방식입니다.
아래 예시는 Query DSL 예제와 동일한 검색을 쿼리 문자열 방식으로 수행한 것입니다.

GET my_index/_search?q=title:hello

q 매개변수에 루씬 쿼리 문자열을 직접 전달합니다.
요청 본문을 사용하지 않기 때문에 간단한 검색을 빠르게 수행할 수 있다는 장점이 있습니다.

다만 요청 주소(URL)에 쿼리를 기술하는 방식의 특성상,
복잡한 조건을 표현하기 어렵고 쿼리가 길어질수록 가독성과 유지보수성이 급격히 떨어집니다.
이러한 이유로 쿼리 문자열 검색은 단순한 조회 요청에 한해 제한적으로 사용하는 것이 일반적입니다.

루씬 쿼리 문자열에서 사용할 수 있는 대표적인 문법은 다음과 같습니다.

문법 설명 예시
질의어만 기술 전체 필드를 대상으로 검색합니다 hello
필드이름:질의어 지정한 필드를 대상으로 검색합니다 title:hello
필드이름:(A OR B) OR 조건 검색을 수행합니다 title:(hello OR world)
_exists_:필드이름 특정 필드가 존재하는 문서를 검색합니다 _exists_:title
필드이름:[시작 TO 끝] 지정한 필드 값이 범위 내에 있는 문서를 검색합니다(경계 포함) date:[2021-05-03 TO 2021-09-03]
필드이름:{시작 TO 끝} 지정한 필드 값이 범위 내에 있는 문서를 검색합니다(경계 제외) count:{10 TO 20}
와일드카드 사용 *, ? 와일드카드를 사용할 수 있습니다 title:hello*

또한 AND, OR, NOT 연산자를 사용할 수 있으며,
괄호(())를 통해 연산 우선순위를 명확히 지정할 수 있습니다.

(title:(hello OR world)) AND (contents:NOT bye) OR count:[1 TO 3]

⚠️ 와일드카드 검색 주의사항
와일드카드(*, ?)를 포함한 쿼리는 매우 느리고 위험할 수 있습니다.
특히 *hello, ?ello처럼 와일드카드가 앞에 오는 쿼리
인덱스가 보유한 모든 term을 대상으로 검색을 수행하므로 클러스터 전체에 큰 부하를 줄 수 있습니다.

서비스 환경에서는 와일드카드 검색을 반드시 제한적으로 사용해야 합니다.
사용 전에는 반드시 매핑 설정과 데이터 규모를 충분히 파악한 뒤,
그 영향 범위를 명확히 판단할 수 있는 경우에만 사용해야 합니다.

운영 환경에서는 다음과 같은 설정을 통해 위험한 쿼리를 사전에 차단할 수 있습니다.

  • indices.query.query_string.allowLeadingWildcard: false
    → 와일드카드가 앞에 오는 쿼리를 차단합니다.
  • search.allow_expensive_queries: false
    → 와일드카드 검색을 포함한 고비용 쿼리 자체를 차단합니다.

서비스 특성과 운영 상황에 따라 적절히 판단해 적용하는 것이 중요합니다.

match_all 쿼리

match_all 쿼리는 모든 문서를 매칭하는 쿼리입니다.
query 부분을 비워 두면 사실상 기본값으로 동작하는 쿼리이기도 합니다.

GET [인덱스 이름]/_search
{
  "query": {
    "match_all": {}
  }
}

조건 없이 “일단 전부 조회”가 필요할 때 유용하지만,
운영 환경에서는 문서 수가 많은 인덱스에 대해 무심코 실행하면 부담이 커질 수 있으니 주의합니다.


match 쿼리

match 쿼리는 지정한 필드의 내용이 질의어와 매치되는 문서를 찾는 쿼리입니다.
대상 필드가 text 타입이라면, 필드 값과 질의어 모두 애널라이저(analyzer)로 분석된 뒤 매칭이 수행됩니다.

GET [인덱스 이름]/_search
{
  "query": {
    "match": {
      "fieldName": {
        "query": "test query sentence"
      }
    }
  }
}

예를 들어 fieldNametext 타입이고 standard 애널라이저를 사용한다면,
test query sentencetest, query, sentence 3개의 토큰으로 분석됩니다.
기본적으로 match 쿼리는 OR 조건으로 동작하므로, 3개 토큰 중 하나만 매칭되어도 검색 결과에 포함될 수 있습니다.

operator 옵션

operatorand로 지정하면, 모든 토큰이 매칭되는 문서만 반환하도록 바꿀 수 있습니다.

GET [인덱스 이름]/_search
{
  "query": {
    "match": {
      "fieldName": {
        "query": "test query sentence",
        "operator": "and"
      }
    }
  }
}

term 쿼리

term 쿼리는 지정한 필드의 값이 질의어와 정확히 일치하는 문서를 찾는 쿼리입니다.
대상 필드에 normalizer가 지정되어 있다면, 질의어도 동일한 노멀라이저 처리를 거칩니다.

GET [인덱스 이름]/_search
{
  "query": {
    "term": {
      "fieldName": {
        "value": "hello"
      }
    }
  }
}

term 쿼리는 문자열 필드 중에서도 보통 keyword 타입과 잘 맞습니다.
keyword는 분석(analyze)되지 않기 때문에 “값이 정확히 같은지”를 판단하는 데 적합합니다.

반대로 text 타입 필드에 term 쿼리를 사용하는 것은 주의가 필요합니다.
질의어는 노멀라이저 처리를 거칠 수 있지만, 필드 값은 애널라이저로 분석되어 생성된 역색인(term)을 대상으로 매칭이 수행됩니다.
즉, 분석 결과 단일 term이 생성되고 그 값이 질의어와 정확히 일치하는 경우에만 검색에 걸릴 수 있습니다.

정리하면 다음과 같습니다.

  • match: 분석 기반 검색에 적합합니다(text 필드에 주로 사용합니다).
  • term: 정확한 값 일치 검색에 적합합니다(keyword 필드에 주로 사용합니다).

terms 쿼리

terms 쿼리는 term 쿼리와 매우 유사하지만, 여러 개의 질의어를 한 번에 지정할 수 있다는 차이가 있습니다.
지정한 필드의 값이 질의어 목록 중 하나라도 정확히 일치하면 검색 결과에 포함됩니다.

GET [인덱스 이름]/_search
{
  "query": {
    "terms": {
      "fieldName": ["hello", "world"]
    }
  }
}

terms 쿼리는 내부적으로 OR 조건으로 동작합니다.
따라서 특정 필드 값이 여러 후보 중 하나에 속하는지를 판단할 때 유용합니다.

term 쿼리와 마찬가지로, terms 쿼리는 주로 keyword 타입 필드에 사용하는 것이 적합합니다.
정확한 값 일치 여부를 판단하는 용도로 사용하며, 분석이 필요한 text 필드에는 일반적으로 적합하지 않습니다.


range 쿼리

range 쿼리는 지정한 필드의 값이 특정 범위 내에 포함되는 문서를 찾는 쿼리입니다.

GET [인덱스 이름]/_search
{
  "query": {
    "range": {
      "fieldName": {
        "gte": 100,
        "lt": 200
      }
    }
  }
}

범위 조건은 다음 연산자를 사용해 지정합니다.

  • gt : 초과 (greater than)
  • gte : 이상 (greater than or equal to)
  • lt : 미만 (less than)
  • lte : 이하 (less than or equal to)

gt, lt는 경계값을 포함하지 않으며,
gte, lte는 경계값을 포함합니다.

문자열 필드를 대상으로 한 range 쿼리는 고비용 쿼리로 분류되는 경우가 많습니다.
데이터 규모가 큰 환경에서는 클러스터에 큰 부하를 줄 수 있으므로, 데이터 특성과 사용 목적을 충분히 고려해 사용합니다.

운영 환경에서는 다음 설정을 통해 고비용 쿼리를 사전에 차단할 수 있습니다.

  • search.allow_expensive_queries: false

날짜 필드에서의 range 쿼리

range 쿼리는 date 타입 필드를 대상으로 사용할 때 특히 자주 활용됩니다.
엘라스틱서치는 날짜 필드에 대해 간단한 날짜/시간 계산 표현식을 지원합니다.

GET [인덱스 이름]/_search
{
  "query": {
    "range": {
      "dateField": {
        "gte": "2019-01-15T00:00:00.000Z||+36h/d",
        "lte": "now-3h/d"
      }
    }
  }
}

날짜 계산식에는 다음과 같은 표현이 사용됩니다.

  • now : 현재 시각을 의미합니다.
  • || : 날짜 문자열과 계산식을 구분합니다. || 뒤의 문자열은 시간 계산식으로 파싱됩니다.
  • +, - : 지정한 시간만큼 더하거나 빼는 연산을 수행합니다.
  • / : 버림(round down)을 수행합니다. 예를 들어 /d는 날짜 단위 이하의 시간을 버립니다.

날짜 계산에서 사용할 수 있는 주요 시간 단위는 다음과 같습니다.

기호 단위
y 연도
M
w
d 날짜
h 시간
m
s

쿼리 문맥과 필터 문맥

bool 쿼리를 보다 보면 mustfilter가 둘 다 AND 조건으로 동작하는데, 왜 굳이 둘로 나뉘는지 궁금해진다. 핵심 차이는 점수(score)를 계산하느냐 여부다.

  • filter 조건절에 들어간 쿼리는 문서가 조건을 만족하는지 여부만 판단하고, 랭킹에 사용할 점수를 매기지 않는다.
  • must_not도 점수를 매기지 않는다. 애초에 검색 결과에서 제외할 조건이므로 점수를 계산할 이유가 없다.

이렇게 점수를 매기지 않고 true/false만 판단하는 검색 과정을 필터 문맥(filter context) 이라고 부른다. 반대로 문서가 질의어를 얼마나 잘 만족하는지 유사도 점수까지 계산하는 검색 과정은 쿼리 문맥(query context) 이라고 부른다.

조건을 만족하는지 여부만 중요하고, 최종 결과에서 랭킹에 영향을 줄 필요가 없다면 필터 문맥으로 처리하는 편이 성능상 유리하다. 점수 계산 비용을 아낄 수 있고, 필터 문맥의 결과는 쿼리 캐시(query cache) 로 재활용될 수 있기 때문이다.

아래는 둘의 차이를 간단히 정리한 표다.

구분 쿼리 문맥 필터 문맥
질의 개념 문서가 질의어와 얼마나 잘 매치되는가 질의 조건을 만족하는가
점수 계산한다 계산하지 않는다
성능 상대적으로 느리다 상대적으로 빠르다
캐시 쿼리 캐시 활용이 제한적이다 쿼리 캐시 활용이 가능하다
종류(예) bool의 must, bool의 should, match, term bool의 filter, bool의 must_not, exists, range, constant_score

쿼리 수행 순서

bool 쿼리는 여러 하위 쿼리를 조합해 실행한다. 이때 must, filter, must_not, should 중 무엇이 먼저 실행되는지에 대한 고정된 규칙은 없다. 또한 요청 본문에서 위쪽에 적었다고 해서 그 쿼리가 먼저 실행되는 것도 아니다.

엘라스틱서치는 검색 요청을 받으면 내부적으로 쿼리를 여러 하위 쿼리로 쪼개고 재작성(rewrite)한 뒤, 각 하위 쿼리를 실행했을 때의 비용과 효과를 추정한다. 이 추정에는 역색인에 저장된 정보나 통계 정보 등이 활용된다. 그리고 유리하다고 판단되는 부분을 먼저 수행한다.

또한 하위 쿼리를 하나씩 순차로만 실행하는 것도 아니다. 여러 조건을 만족하는 문서 후보를 뽑아 놓고, 실제 매칭 여부를 검사하는 과정에서 하위 쿼리를 병렬적으로 수행하기도 한다.

중요한 점은, 매칭된 문서를 찾는 과정 자체는 쿼리 문맥과 필터 문맥이 동일하게 수행된다는 것이다. 차이는 점수 계산에서 발생한다. 점수(score)를 계산하는 과정은 모든 문서의 매칭을 확인한 이후에 수행되며, 필터 문맥의 쿼리는 이 과정에서 점수 계산을 하지 않을 뿐이다.


prefix 쿼리

prefix 쿼리는 필드 값이 지정한 접두어(prefix) 로 시작하는 문서를 찾는 쿼리입니다.

GET [인덱스 이름]/_search
{
  "query": {
    "prefix": {
      "fieldName": {
        "value": "hello"
      }
    }
  }
}

prefix 쿼리는 상황에 따라 고비용 쿼리로 분류될 수 있습니다. 관리 목적의 단발성 조회로 사용하는 것은 가능한 경우가 많지만, 서비스 트래픽에서 상시 호출하는 용도로는 신중해야 합니다.

index_prefixes로 prefix 성능 보완 prefix 쿼리를 서비스 호출에 가까운 용도로 사용해야 한다면, 매핑에 index_prefixes를 지정하는 방법이 있습니다. index_prefixes를 켜면 엘라스틱서치는 색인 시점에 min_chars~max_chars 길이의 prefix를 미리 별도 색인해 두고, prefix 쿼리 성능을 개선합니다. 대신 색인 크기와 색인 비용이 증가할 수 있습니다.

PUT prefix_mapping_test
{
  "mappings": {
    "properties": {
      "prefixField": {
        "type": "text",
        "index_prefixes": {
          "min_chars": 3,
          "max_chars": 5
        }
      }
    }
  }
}

min_chars의 기본값은 2이며, max_chars의 기본값은 5입니다.

운영 환경에서 search.allow_expensive_queries: false로 설정한 경우, index_prefixes가 적용되지 않은 prefix 쿼리는 차단될 수 있습니다.

exist 쿼리

exists 쿼리는 지정한 필드를 포함하고 있는 문서를 검색합니다. 값이 어떤 내용이든 “필드가 존재하는지”만 판단할 때 유용합니다.

GET [인덱스 이름]/_search
{
  "query": {
    "exists": {
      "field": "fieldName"
    }
  }
}

bool 쿼리

bool 쿼리는 여러 쿼리를 조합해 검색하는 쿼리입니다. 조건절은 must, must_not, filter, should로 구성되며, 필요한 것만 골라 사용하면 됩니다.

GET [인덱스 이름]/_search
{
  "query": {
    "bool": {
      "must": [
        { "term": { "field1": { "value": "hello" } } },
        { "term": { "field2": { "value": "world" } } }
      ],
      "must_not": [
        { "term": { "field4": { "value": "elasticsearch-test" } } }
      ],
      "filter": [
        { "term": { "field3": { "value": true } } }
      ],
      "should": [
        { "match": { "field4": { "query": "elasticsearch" } } },
        { "match": { "field5": { "query": "lucene" } } }
      ],
      "minimum_should_match": 1
    }
  }
}
  • must: 하위 쿼리를 모두 만족해야 검색 결과에 포함됩니다(AND 조건).
  • filter: must와 동일하게 AND 조건으로 동작하지만, 점수 계산과 무관한 필터링 용도에 적합합니다.
  • must_not: 조건을 만족하는 문서는 검색 결과에서 제외됩니다.
  • should: minimum_should_match에 지정한 개수 이상 매칭되어야 검색 결과에 포함됩니다.

minimum_should_match의 기본값은 1입니다. 값이 1이면 should는 사실상 OR 조건처럼 동작합니다.


constant_score 쿼리

constant_score 쿼리는 하위 filter에 지정한 쿼리를 필터 문맥(filter context) 으로 실행하고, 매칭된 문서의 점수(score)는 항상 동일한 값(기본 1.0) 으로 고정하는 쿼리입니다.
즉, “조건으로 걸러내되 랭킹은 의미 없다”는 상황에서 점수 계산 비용을 피하려고 사용할 수 있습니다.

GET [인덱스 이름]/_search
{
  "query": {
    "constant_score": {
      "filter": {
        "term": {
          "fieldName": "hello"
        }
      }
    }
  }
}

bool.filter로도 같은 필터링을 할 수 있는데, constant_score는 “이 검색은 점수 의미 없음”을 더 명시적으로 표현해 줍니다.
운영에서 랭킹이 필요 없는 조회(권한 필터링, 상태값 필터링 등)에 잘 어울립니다.


그 외 주요 매개변수

지금까지는 검색 쿼리 종류를 중심으로 살펴봤습니다.
여기서는 쿼리 종류와 무관하게 검색 API에 공통적으로 자주 붙는 매개변수 몇 가지를 정리합니다.

routing

검색 요청에도 색인/조회 API처럼 routing을 지정할 수 있습니다.

GET [인덱스 이름]/_search?routing=[라우팅]
{
  "query": {
    // ...
  }
}

routing을 지정하지 않으면 검색이 전체 샤드로 브로드캐스트됩니다.
반대로 routing을 지정하면 해당 routing이 매핑되는 샤드로만 검색이 들어가므로 성능상 이득이 큽니다.

다만 비즈니스 요구상 “전체 샤드를 대상으로 검색”해야 하는 경우도 있으므로, 언제나 routing을 강제할 수는 없습니다.
가능하다면 인덱스 설계 단계에서부터 데이터 특성과 서비스 질의 패턴을 고려해 routing 이득을 볼 수 있게 설계하는 편이 좋습니다.

explain

explain=true를 붙이면, 각 검색 hit에 대해 _explanation 필드가 포함되어 점수가 어떻게 계산됐는지 상세한 설명을 볼 수 있습니다.

GET [인덱스 이름]/_search?explain=true
{
  "query": {
    // ...
  }
}

아래처럼 간단한 데이터를 넣고 bool 쿼리에 explain=true를 붙여 보면, 어떤 절이 점수에 기여했는지 확인할 수 있습니다.

PUT my_index3/_doc/1
{
  "field1": "hello",
  "field2": "world",
  "field3": true,
  "field4": "elasticsearch",
  "field5": "lucene"
}
GET my_index3/_search?explain=true
{
  "query": {
    "bool": {
      "must": [
        { "term": { "field1": { "value": "hello" } } },
        { "term": { "field2": { "value": "world" } } }
      ],
      "must_not": [
        { "term": { "field4": { "value": "elasticsearch-test" } } }
      ],
      "filter": [
        { "term": { "field3": { "value": true } } }
      ],
      "should": [
        { "match": { "field4": { "query": "elasticsearch" } } },
        { "match": { "field5": { "query": "lucene" } } }
      ],
      "minimum_should_match": 1
    }
  }
}

응답의 _explanation에서 “어떤 term이 얼마나 기여했는지(tf, idf 등)” 같은 내용을 확인할 수 있습니다.
다만 이 설명을 만들기 위해 내부적으로 쿼리를 더 무겁게 수행하는 경우가 있어, explain을 켠 검색은 성능이 떨어질 수 있습니다.
따라서 explain디버깅/튜닝 용도로만 제한적으로 사용하는 편이 좋습니다.


페이지네이션

검색 결과를 페이지 단위로 나누는 방식은 여러 가지가 있습니다. 여기서는 기본적으로 사용할 수 있는 from/size, 전체 문서를 빠짐없이 순회할 때 쓰는 scroll, 그리고 본격적인 페이지네이션에 사용하는 search_after 순서로 정리합니다.

또 한 가지 주의할 점이 있습니다. 정렬(sort) 옵션에서 _score를 포함하지 않는 경우, 엘라스틱서치는 유사도 점수 계산을 생략할 수 있습니다(기본 정렬이 아니라 특정 필드 정렬을 하는 상황). 점수가 필요하면 _score 정렬을 사용하거나, 필요에 따라 track_scores 같은 옵션을 함께 고려해야 합니다.

from과 size

  • size는 한 번의 요청에서 몇 개 문서를 반환할지 지정합니다.
  • from은 몇 번째 문서부터 결과를 반환할지(오프셋)를 지정합니다.
GET [인덱스 이름]/_search
{
  "from": 10,
  "size": 5,
  "query": {
    // ...
  }
}

위처럼 요청하면 유사도 점수로 내림차순 정렬된 문서 중, 11번째부터 15번째까지 5개 문서가 반환됩니다.

from의 기본값은 0이고, size의 기본값은 10입니다. 다만 from/size얕은 페이지네이션에만 제한적으로 사용하는 편이 좋습니다.

  • from 값이 커질수록 내부적으로 더 많은 문서를 수집·정렬한 뒤 일부만 잘라 반환하므로 비용이 급격히 증가합니다(CPU/메모리 부담 증가).
  • 두 번의 요청 사이에 문서가 새로 색인되거나 삭제되면, 페이지를 넘기는 과정에서 중복/누락이 발생할 수 있습니다.

또한 엘라스틱서치는 성능 이슈 때문에 from + size로 접근할 수 있는 최대 윈도우 크기를 제한합니다. 기본적으로 from + size의 합이 1만을 초과하면 검색이 거부될 수 있으며, 이 제한은 인덱스 설정의 index.max_result_window로 조절할 수 있습니다.

다만 이 값을 무작정 키우는 방식은 권장하지 않습니다. 본격적인 페이지네이션이 필요하면 아래의 scroll 또는 search_after를 사용해야 합니다.

scroll

scroll은 검색 조건에 매칭되는 전체 문서를 빠짐없이 순회해야 할 때 적합한 방식입니다. scroll을 시작하면 최초 검색 시점의 검색 문맥(search context)이 유지되므로, 순회 도중에 문서가 바뀌더라도 중복이나 누락이 발생하지 않습니다.

GET [인덱스 이름]/_search?scroll=1m
{
  "size": 1000,
  "query": {
    // ...
  }
}

응답에는 _scroll_id가 포함되며, 이 값을 이용해 다음 배치를 계속 가져옵니다. 중요한 점은 scroll서비스 사용자용 페이지네이션이라기보다는, 대량 조회/백필(backfill)/내보내기(export) 같은 관리성 작업에 더 잘 어울린다는 점입니다. 검색 문맥을 유지하는 동안 클러스터 리소스를 점유하므로, 사용 범위를 명확히 정해두는 편이 안전합니다.

search_after

search_after는 깊은 페이지네이션을 더 안정적으로 처리하기 위한 방식입니다. from/size처럼 큰 오프셋을 건너뛰지 않고, 이전 페이지의 마지막 문서 기준으로 다음 페이지를 이어서 가져옵니다. 실제 서비스에서 “다음 페이지”를 안정적으로 제공해야 한다면 search_after를 고려하는 편이 좋습니다.

search_after (상세)

search_after는 이전 페이지의 마지막 문서의 sort 값을 기준으로 다음 페이지를 이어서 가져오는 방식입니다. from처럼 큰 오프셋을 건너뛰지 않으므로, 깊은 페이지네이션에서도 상대적으로 안정적인 성능을 기대할 수 있습니다.

핵심 규칙은 다음과 같습니다.

  • 항상 동일한 query와 동일한 sort를 유지해야 합니다.
  • 다음 요청의 search_after에는 이전 응답의 마지막 hit에 포함된 sort 배열을 그대로 넣어야 합니다.
  • 정렬 값이 같은 문서가 존재할 수 있으므로, 동점을 깨기 위한 tie-breaker 정렬 필드를 반드시 두는 편이 안전합니다.

예시) tie-breaker 필드를 두고 search_after로 넘기기

아래 예시는 created_at 오름차순 정렬로 페이지를 넘기되, 동일 시각이 존재하는 경우를 대비해 tie_breaker_id(문서마다 유니크한 값)를 함께 정렬합니다.

첫 페이지 요청은 다음과 같습니다.

GET my_index/_search
{
  "size": 5,
  "query": {
    "match": {
      "title": "elasticsearch"
    }
  },
  "sort": [
    {"created_at": "asc"},
    {"tie_breaker_id": "asc"}
  ]
}

응답의 각 hit에는 sort 배열이 포함됩니다. 마지막 hit이 아래와 같이 반환되었다고 가정합니다.

"sort": ["2026-01-03T00:00:00.000Z", "A000000000000001"]

다음 페이지 요청은 아래와 같습니다.

GET my_index/_search
{
  "size": 5,
  "query": {
    "match": {
      "title": "elasticsearch"
    }
  },
  "sort": [
    {"created_at": "asc"},
    {"tie_breaker_id": "asc"}
  ],
  "search_after": ["2026-01-03T00:00:00.000Z", "A000000000000001"]
}

search_after정렬 기준이 바뀌면 정상적으로 이어서 조회할 수 없으므로, 서비스 코드에서는 sort 구성과 search_after 전달 로직을 한 덩어리로 관리하는 편이 좋습니다.

PIT(Point In Time)과 함께 사용하는 경우

페이지를 넘기는 동안 인덱스에 문서가 추가/삭제되면 페이지 경계가 흔들릴 수 있습니다. 이런 변화를 최소화하고 싶다면 search_afterPIT(Point In Time) 와 함께 사용해, 조회 기준 시점을 고정하는 방식이 더 안전할 수 있습니다.

참고로, deep pagination 용도에서는 scroll보다 search_after + PIT 조합이 더 적합한 경우가 많습니다.


집계

엘라스틱서치는 검색 결과를 다양한 방식으로 집계(aggregation)하는 기능을 제공합니다. 검색이 문서를 찾는 기능이라면, 집계는 검색된 문서 집합을 통계적으로 요약해 인사이트를 얻는 기능입니다.

집계 요청은 검색 요청 본문에 aggs(또는 aggregations)를 추가하여 수행합니다. 그리고 대부분의 집계는 문서 목록 자체가 필요하지 않으므로 size: 0을 함께 지정하는 것이 일반적입니다.

GET kibana_sample_data_ecommerce/_search
{
  "size": 0,
  "query": {
    "term": {
      "currency": {
        "value": "EUR"
      }
    }
  },
  "aggs": {
    "my-sum-aggregation-name": {
      "sum": {
        "field": "taxless_total_price"
      }
    }
  }
}
  • aggs 아래에는 집계 이름을 키로 두고, 그 아래에 집계 타입(sum, avg, terms, range 등)과 옵션을 둡니다.
  • 한 번의 요청에 여러 집계를 함께 넣을 수 있으므로, 결과를 구분할 수 있게 집계 이름을 명확히 짓는 것이 중요합니다.

집계는 매우 강력하지만, 무심코 과도한 집계를 수행하면 클러스터에 큰 부담을 줄 수 있습니다. 특히 Kibana 대시보드처럼 동일 집계를 반복 호출하는 패턴에서는 집계 비용이 곧 성능 저하나 장애로 이어질 수 있으므로 주의가 필요합니다.

집계 기본

집계는 query에 매칭된 문서 집합을 대상으로 수행됩니다. 즉, query로 문서 후보를 정하고, 그 문서들에 대해 aggs가 계산됩니다.

대부분의 집계는 문서 내용 자체가 아니라 통계 결과가 핵심이므로, size: 0으로 hit 문서 반환을 생략하는 편이 성능상 유리합니다.

메트릭 집계

메트릭(metric) 집계는 숫자 값을 계산해 반환하는 집계입니다.

avg, max, min, sum

GET kibana_sample_data_ecommerce/_search
{
  "size": 0,
  "query": {
    "term": {
      "currency": {
        "value": "EUR"
      }
    }
  },
  "aggs": {
    "my-avg-aggregation-name": {
      "avg": {
        "field": "taxless_total_price"
      }
    }
  }
}

avg 자리에 sum, min, max를 넣으면 동일한 형태로 동작합니다.

stats

stats는 평균/최대/최소/합/개수를 한 번에 반환하는 다중 값 메트릭 집계입니다.

GET kibana_sample_data_ecommerce/_search
{
  "size": 0,
  "query": {
    "term": {
      "currency": {
        "value": "EUR"
      }
    }
  },
  "aggs": {
    "my-stats-aggregation-name": {
      "stats": {
        "field": "taxless_total_price"
      }
    }
  }
}

cardinality

cardinality는 지정한 필드의 고유 값 개수를 추정해서 반환하는 집계입니다. 근사 집계이므로 정확도와 메모리 사용량 사이의 트레이드오프가 있으며, precision_threshold로 조절합니다.

GET kibana_sample_data_ecommerce/_search
{
  "size": 0,
  "query": {
    "term": {
      "currency": {
        "value": "EUR"
      }
    }
  },
  "aggs": {
    "my-cardinality-aggregation-name": {
      "cardinality": {
        "field": "customer_id",
        "precision_threshold": 3000
      }
    }
  }
}
  • precision_threshold를 높이면 정확도가 올라갈 수 있지만, 메모리 사용량도 증가합니다.
  • 오차가 허용되지 않는 정확 유니크 카운트가 필요하다면, 별도 집계 테이블이나 배치 집계 같은 설계를 함께 고려해야 합니다.

버킷 집계

버킷(bucket) 집계는 문서를 특정 기준으로 여러 그룹(버킷)으로 나누고, 각 버킷의 문서 수(doc_count)나 하위 집계 결과를 보는 집계입니다.

range 집계

GET kibana_sample_data_flights/_search
{
  "size": 0,
  "query": {
    "match_all": {}
  },
  "aggs": {
    "distance-kilometers-range": {
      "range": {
        "field": "DistanceKilometers",
        "ranges": [
          { "to": 5000 },
          { "from": 5000, "to": 10000 },
          { "from": 10000 }
        ]
      },
      "aggs": {
        "average-ticket-price": {
          "avg": {
            "field": "AvgTicketPrice"
          }
        }
      }
    }
  }
}

range 버킷 아래에 aggs를 또 두면, 각 버킷에 속한 문서들을 대상으로 하위 집계(sub-aggregation)를 수행할 수 있습니다.

  • 바깥 range는 문서를 버킷으로 분류합니다.
  • 안쪽 average-ticket-price는 각 버킷 내부 문서에 대해 평균을 계산합니다.

버킷 집계의 실전 활용 가치는 보통 이 하위 집계에서 나옵니다. 다만 하위 집계 깊이가 지나치게 깊어지면 성능 문제가 생길 수 있으므로 적절히 제한하는 편이 좋습니다.

date_range 집계

date_rangerange와 유사하지만, date 타입 필드를 대상으로 하며 now-10d/d 같은 날짜 계산식을 사용할 수 있습니다.

GET kibana_sample_data_ecommerce/_search
{
  "size": 0,
  "query": {
    "match_all": {}
  },
  "aggs": {
    "date-range-aggs": {
      "date_range": {
        "field": "order_date",
        "ranges": [
          { "to": "now-10d/d" },
          { "from": "now-10d/d", "to": "now" },
          { "from": "now" }
        ]
      }
    }
  }
}

집계와 캐시 메모리

집계 요청은 샤드 단위로 분산되어 실행되며, 반복 호출되는 형태라면 캐시(예: 샤드 요청 캐시)의 도움을 받을 수 있습니다.

다만 now가 포함된 집계(예: now-10d/d)는 호출 시점에 따라 의미가 달라지므로, 요청 본문이 완전히 동일하게 반복되기 어렵습니다. 이런 경우 캐시 효율이 떨어질 수 있으며, 대시보드에서 짧은 주기로 새로고침하면서 now 기반 집계를 계속 호출하는 패턴은 비용이 커질 수 있습니다.