LLM WikiAccess-protected knowledge portal
← 스터디 홈
4편 · 약 15분

쿼리 DSL 심화와 집계(Aggregation)

Query DSL이란

Elasticsearch의 Query DSL(Domain Specific Language)은 JSON 형식의 쿼리 언어다. SQL처럼 구조화된 방식으로 검색 조건과 집계를 표현한다. GET /index/_search API의 요청 본문에 담아 사용한다.

Query DSL을 이해하는 핵심은 Query ContextFilter Context의 구분이다.

구분설명캐싱점수 계산
Query Context"얼마나 잘 매칭되는가?"✓ (BM25 점수)
Filter Context"매칭되는가?"✓ (bitset)

Filter Context는 점수를 계산하지 않으므로 빠르고, 결과가 bitset으로 캐싱된다. 날짜 범위, 상태값, 카테고리처럼 예/아니오로 답할 수 있는 조건은 항상 Filter Context에 넣어야 성능이 좋다.

기본 쿼리 유형

전문 검색 쿼리

match: 전문 검색의 기본. 쿼리 텍스트를 분석기로 처리한 뒤 역 인덱스에서 검색한다.

GET /products/_search
{
  "query": {
    "match": {
      "description": "빠른 배송 서비스"
    }
  }
}

기본 연산자는 OR다. "빠른 배송 서비스"는 세 토큰 중 하나라도 있으면 매칭된다. "operator": "and"로 모든 토큰이 있어야 하도록 강화할 수 있다.

match_phrase: 토큰 순서와 위치까지 일치해야 한다. "빠른 배송"이 정확히 이 순서로 나와야 매칭된다. slop 파라미터로 토큰 간 허용 거리를 설정할 수 있다.

multi_match: 여러 필드에 동시에 match를 적용한다.

{
  "multi_match": {
    "query": "elasticsearch",
    "fields": ["title^2", "description", "tags"],
    "type": "best_fields"
  }
}

^2는 해당 필드의 점수에 2를 곱하는 부스팅이다. typebest_fields(가장 높은 점수 필드 기준), most_fields(모든 필드 점수 합산), cross_fields(여러 필드를 하나로 합쳐서 검색) 중 선택한다.

정확값 쿼리

term: 분석 없이 정확값으로 조회한다. keyword 필드 또는 숫자·날짜 필드에 사용한다.

{ "term": { "status": "published" } }

text 필드에 term 쿼리를 쓰면 예상과 다른 결과가 나온다. 색인 시 분석기가 소문자 변환 등을 적용했기 때문에 원문 그대로의 값이 역 인덱스에 없을 수 있다.

terms: 여러 정확값 중 하나와 일치하면 매칭. SQL의 IN절에 해당.

range: 숫자·날짜 범위 조건. gte, gt, lte, lt 연산자를 사용한다.

{ "range": { "price": { "gte": 1000, "lte": 50000 } } }

exists: 필드가 존재하는 문서(null이 아닌)만 조회.

bool 쿼리: 복합 조건 조합

bool 쿼리는 여러 조건을 논리적으로 결합한다. 네 가지 절(clause)로 구성된다.

bool 쿼리 must (Query Context) 반드시 매칭 + 점수에 기여 예: match query, full-text 조건 캐싱 ✗ | 스코어 기여 ✓ filter (Filter Context) 반드시 매칭 + 점수에 미기여 예: range, term, exists 조건 캐싱 ✓ (bitset) | 스코어 기여 ✗ should (Query Context) 하나 이상 매칭 시 점수 부스팅 must/filter 없으면 최소 1개 필수 minimum_should_match 파라미터로 제어 must_not (Filter Context) 매칭되면 제외 (점수 미기여) 예: 삭제됨 상태 제외 캐싱 ✓ | 스코어 기여 ✗ → 성능 팁: 점수 불필요한 조건은 must 대신 filter에 넣으면 캐싱 + 스코어 계산 생략으로 빨라진다 → filter만 있으면 점수 0.0, must가 있으면 BM25 점수 계산됨
bool 쿼리 구조: must·filter·should·must_not의 역할

실무 예시: "published 상태의 상품 중 'elasticsearch'가 포함된 것을 점수 순으로, 단 품절은 제외"

{
  "query": {
    "bool": {
      "must":    [{ "match": { "title": "elasticsearch" } }],
      "filter":  [{ "term":  { "status": "published" } }],
      "must_not":[{ "term":  { "in_stock": false } }]
    }
  }
}

statusin_stock은 점수와 무관하므로 filter/must_not에 배치해 캐싱 이점을 활용한다.

Aggregation: 데이터 집계·분석

집계(Aggregation)는 검색 결과로 통계·그루핑·분석을 수행한다. SQL의 GROUP BY, COUNT, AVG에 해당한다. 검색 쿼리와 같은 요청에 함께 실행할 수 있다.

Metric 집계

단일 숫자값을 계산하는 집계다.

집계설명예시
avg평균평균 상품 가격
sum합계총 판매액
min / max최솟값 / 최댓값최저가 상품
value_countnull이 아닌 값 개수리뷰 있는 상품 수
cardinality유일 값 개수 (HyperLogLog 근사)접속 국가 수
statscount·min·max·avg·sum 한번에가격 통계 요약
"aggs": {
  "avg_price": { "avg": { "field": "price" } },
  "unique_users": { "cardinality": { "field": "user_id" } }
}

Bucket 집계

문서를 그룹으로 나누는 집계. 각 그룹(버킷)이 하위 집계의 입력이 된다.

terms: 필드의 상위 N개 고유값으로 버킷을 만든다. SQL의 GROUP BY와 유사.

"aggs": {
  "by_category": {
    "terms": { "field": "category", "size": 10 }
  }
}

date_histogram: 날짜 필드를 주기(1일, 1주, 1달 등)로 버킷화한다. 시계열 차트의 기반이다.

"aggs": {
  "daily_sales": {
    "date_histogram": {
      "field": "created_at",
      "calendar_interval": "day"
    }
  }
}

range: 수동으로 범위를 지정해 버킷화. "0~10만원", "10~50만원", "50만원 이상"처럼 가격대별 분류에 사용.

nested: nested 타입 필드를 집계할 때 반드시 먼저 감싸야 한다.

중첩 집계(Sub-Aggregation)

버킷 집계 안에 다시 집계를 넣어 "카테고리별 평균 가격" 같은 복합 분석을 만든다.

① date_histogram: 월별 버킷 버킷: 2026-01 ② terms: 카테고리별 전자기기 버킷 ③ avg_price: 485,000원 도서 버킷 ③ avg_price: 18,500원 … (size:10 기준 최대 10개) 버킷: 2026-02 ② terms: 카테고리별 전자기기 avg_price: 512,000원 ⚠ 버킷 수 = 월수 × 카테고리 수 깊은 중첩은 메모리 주의
중첩 집계 구조: date_histogram 안의 terms, 그 안의 avg
"aggs": {
  "monthly": {
    "date_histogram": {
      "field": "created_at",
      "calendar_interval": "month"
    },
    "aggs": {
      "by_category": {
        "terms": { "field": "category", "size": 10 },
        "aggs": {
          "avg_price": { "avg": { "field": "price" } }
        }
      }
    }
  }
}

Pipeline 집계

다른 집계의 결과를 입력으로 받아 계산한다.

  • moving_avg: 이동 평균 (시계열 스무딩)
  • derivative: 변화율 계산
  • bucket_sort: 버킷 결과를 정렬
"sales_change": {
  "derivative": {
    "buckets_path": "total_sales"
  }
}

쿼리 + 집계 동시 실행

query로 범위를 좁힌 뒤 aggs로 분석하는 것이 기본 패턴이다. 쿼리는 집계 대상 문서를 제한하고, 집계는 그 결과를 분석한다.

GET /orders/_search
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "range": { "created_at": { "gte": "2026-01-01" } } },
        { "term":  { "status": "completed" } }
      ]
    }
  },
  "aggs": {
    "monthly_revenue": {
      "date_histogram": {
        "field": "created_at",
        "calendar_interval": "month"
      },
      "aggs": {
        "total": { "sum": { "field": "amount" } }
      }
    }
  }
}

"size": 0은 검색 결과 문서를 반환하지 않고 집계 결과만 받겠다는 선언이다. 집계 전용 요청에 항상 넣어야 불필요한 _source 전송을 막는다.

집계 성능 팁

1. 집계 전 filter로 범위 좁히기: 전체 1억 건 중 1만 건 집계가 1억 건 집계보다 100배 빠르다. query.bool.filter를 적극 활용한다.

2. size: 0: 히트 목록이 필요 없으면 항상 설정한다.

3. terms 집계의 size: 기본값은 10. 전체 고유값 수가 아니라 상위 N개만 반환한다. 너무 크게 설정하면 각 샤드에서 size만큼 수집하므로 메모리·네트워크 부담이 커진다.

4. 깊은 중첩 주의: 버킷 수가 곱해진다. 12개월 × 100개 카테고리 × 50개 지역 = 60,000개 버킷. 깊은 다차원 분석은 composite 집계로 페이지네이션해야 한다.

5. cardinality 집계는 근사값: HyperLogLog++ 알고리즘으로 메모리를 절약하지만 약간의 오차(기본 0.5%)가 있다. 정확한 유일값 수가 필요하면 다른 방법을 고려한다.

한 줄 정리

Query DSL의 핵심은 점수가 필요한 조건은 must에, 점수와 무관한 조건은 filter에 넣는 것이다. 집계는 Metric(계산) → Bucket(그루핑) → Pipeline(집계의 집계) 세 층으로 구성되며, query로 범위를 좁힌 뒤 집계하는 것이 기본 성능 패턴이다.

References

  • https://www.elastic.co/docs/explore-analyze/query-filter/languages/querydsl
  • https://moldstud.com/articles/p-mastering-elasticsearch-query-dsl-a-comprehensive-guide-for-effective-data-search-and-analysis
  • https://bigdataboutique.com/blog/a-comprehensive-guide-to-elasticsearch-aggregations-615b29
  • https://pulse.support/kb/elasticsearch-bool-query
  • https://medium.com/@souravchoudhary0306/elasticsearch-aggregation-queries-557131ef5ea4
  • https://kindatechnical.com/elasticsearch/nested-and-sub-aggregations-in-elasticsearch.html
  • https://pulse.support/kb/elasticsearch-query-language