jongkwan.dev
개발 · Essay №062

Elasticsearch Query DSL과 집계

점수를 매기는 질의와 거르기만 하는 질의를 구분해 검색과 집계를 설계하는 방법을 정리합니다.

이종관2026년 3월 12일11 min read
Contents

점수를 계산하는 질의와 조건만 거르는 질의를 구분하는 순간, 같은 검색의 응답 시간이 달라집니다.

두 종류의 질의

Elasticsearch는 JSON 기반 질의어인 Query DSL(Domain Specific Language)로 검색과 집계를 함께 표현합니다. 하나의 요청 본문 안에 검색 조건, 정렬, 페이지네이션, 집계를 모두 담습니다. 질의는 처리 방식에 따라 구조화 쿼리와 전문 쿼리로 나뉩니다.

구조화 쿼리는 값의 정확한 일치나 범위처럼 예/아니오로 답할 수 있는 조건을 판단합니다. 전문 쿼리는 분석기로 텍스트를 토큰으로 쪼갠 뒤 문서가 검색어와 얼마나 관련 있는지를 계산합니다. 두 갈래는 개별 질의 조각(leaf query)으로 존재하다가 bool 같은 복합 쿼리 안에서 결합됩니다.

질의용도
match전문 검색, 토큰 분석을 거침
term정확 일치, 분석 없이 값 그대로 비교
range수치·날짜 범위
multi_match여러 필드를 한 번에 검색
boolmust, should, filter, must_not 조합

표의 질의들은 단독으로도 쓰지만, 실제 애플리케이션 로직은 대부분 bool로 묶어 복합 조건을 만듭니다. 그리고 각 질의를 어떤 절에 배치하는지가 응답 시간을 좌우합니다.

쿼리 컨텍스트와 필터 컨텍스트

같은 조건이라도 실행되는 컨텍스트에 따라 비용이 갈립니다. 쿼리 컨텍스트는 "이 문서가 검색 의도와 얼마나 관련 있는가"를 수치로 답합니다. Elasticsearch는 이때 BM25(Best Matching 25) 알고리즘을 돌려 관련도 점수(relevance score)를 매기고 내림차순으로 정렬합니다.

BM25는 TF-IDF(Term Frequency-Inverse Document Frequency)의 개선형입니다. 단어 출현 빈도와 문서 길이, 인덱스 전체에서의 단어 희귀성을 함께 반영합니다. 반면 필터 컨텍스트는 점수 연산을 생략하고 일치 여부만 판단합니다. 그 결과는 노드의 인메모리 구조인 Bitset 캐시에 저장되어 같은 조건이 반복될 때 빠르게 재사용됩니다.

구분목적특징
쿼리 컨텍스트관련도 점수 계산전문 검색, 스코어링, 정렬
필터 컨텍스트조건 일치 여부 판단점수 생략, 캐시 친화적, 빠른 실행

정확한 조건은 filter에, 검색 의도와 관련도는 query에 두는 것이 기본 원칙입니다. 카테고리 분류나 날짜 범위처럼 점수가 필요 없는 조건을 filter로 보내는 것이 성능 최적화의 출발점입니다.

bool 쿼리로 조건 조립

bool 쿼리는 네 개의 절로 검색 트리를 만듭니다. must는 필수 포함이면서 점수에 반영하고, should는 포함되면 가산점을 줍니다. must_not은 결과에서 강제로 제외하고, filter는 필수 포함이되 점수는 무시합니다.

products 인덱스에서 이름은 전문 검색하되 재고와 가격은 정확히 거르는 요청 본문은 다음과 같습니다.

json
{
  "query": {
    "bool": {
      "must":   [{ "match": { "name": "무선 이어폰" } }],
      "filter": [
        { "term":  { "in_stock": true } },
        { "range": { "price": { "lte": 200000 } } }
      ]
    }
  }
}

name은 관련도가 중요하므로 must에 두어 점수를 받습니다. 재고 여부와 가격 상한은 점수와 무관하므로 filter로 보내 캐시 혜택을 받습니다. 이 분배에 따라 요청은 점수를 매기는 경로와 거르기만 하는 경로로 갈라집니다.

다이어그램은 하나의 요청이 어떻게 두 컨텍스트로 갈렸다가 집계로 이어지는지를 보여줍니다. 점수가 필요한 절과 거르기만 하는 절이 나뉘고, 정렬된 문서 집합이 다시 집계 단계로 들어갑니다.

집계의 세 범주

집계(aggregation)는 검색으로 찾은 문서를 다시 계산해 요약하는 작업입니다. 그래서 필드 타입과 카디널리티(고유 값의 개수)에 큰 영향을 받습니다. 집계는 산출 방식에 따라 세 범주로 나뉩니다.

유형하는 일예시
메트릭숫자 필드를 하나의 통계 값으로 산출avg, sum, max, percentiles
버킷문서를 논리적 그룹으로 묶음terms, date_histogram
파이프라인앞선 집계 결과를 입력으로 재연산moving_fn, derivative

메트릭 집계는 평균이나 합계처럼 집단 전체를 스칼라 값으로 요약합니다. 버킷 집계는 카테고리별 빈도나 시간 구간으로 문서를 분류합니다. 파이프라인 집계는 원본 문서가 아니라 다른 집계가 만든 버킷 경로를 입력으로 받아 이동 평균이나 변화율을 계산합니다.

최근 7일 매출을 일 단위로 묶어 합산하는 집계는 다음과 같이 작성합니다.

json
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [{ "range": { "@timestamp": { "gte": "now-7d" } } }]
    }
  },
  "aggs": {
    "daily": {
      "date_histogram": { "field": "@timestamp", "calendar_interval": "day" },
      "aggs": {
        "revenue": { "sum": { "field": "amount" } }
      }
    }
  }
}

size를 0으로 두면 문서 본문을 가져오지 않고 집계 결과만 받습니다. 바깥 date_histogram이 날짜 버킷을 만들고, 안쪽 sum이 버킷마다 매출을 더합니다. 다만 terms 집계를 고카디널리티 필드에 넓게 걸면 버킷이 폭증해 비용이 커지므로, 대상 필드의 고유 값 개수를 먼저 가늠해야 합니다.

깊은 페이지네이션 다루기

검색 결과를 페이지로 끊어 보여줄 때 가장 단순한 방법은 fromsize입니다. 그러나 페이지가 깊어질수록 각 샤드가 from + size만큼을 모아 정렬해야 하므로 비용이 누적됩니다. 기본 설정상 from + size의 합이 index.max_result_window(기본값 10,000)를 넘으면 요청이 거부됩니다.

깊은 페이지네이션에는 search_after가 유리합니다. 직전 페이지 마지막 문서의 정렬 값을 다음 요청의 커서로 넘겨, 오프셋을 건너뛰지 않고 이어서 읽습니다. 정렬에는 동점을 가르는 고유 필드를 함께 넣어야 결과가 누락되지 않습니다.

json
{
  "size": 20,
  "query": { "match_all": {} },
  "sort": [
    { "created_at": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [1719700000000, "doc-8842"]
}

search_after 배열의 값은 직전 페이지 마지막 문서의 sort 값과 순서·타입이 일치해야 합니다. 전체 데이터를 한 번에 훑어 내보내야 한다면 과거에는 스냅샷을 들고 도는 scroll을 썼습니다. 현재는 Point In Time(PIT)으로 시점을 고정한 뒤 search_after로 순회하는 방식이 일반적인 대안입니다.

정리

Query DSL을 다룰 때 가장 먼저 잡아야 할 축은 점수가 필요한 질의와 거르기만 하는 질의의 구분입니다. 점수가 필요 없는 조건을 filter로 보내면 Bitset 캐시 덕분에 같은 검색이 반복될 때 응답이 빨라집니다.

집계는 검색 결과를 다시 계산하는 작업이라 필드 타입과 카디널리티에 민감하므로, 버킷이 얼마나 늘어날지를 미리 가늠해야 합니다. 페이지네이션은 얕을 때 fromsize로 충분하지만, 깊어지면 search_after가 비용 면에서 유리합니다. 이 세 가지 기준만 분리해도 검색 품질과 응답 시간을 함께 챙길 수 있습니다.