쿼리 DSL 심화와 집계(Aggregation)
Query DSL이란
Elasticsearch의 Query DSL(Domain Specific Language)은 JSON 형식의 쿼리 언어다. SQL처럼 구조화된 방식으로 검색 조건과 집계를 표현한다. GET /index/_search API의 요청 본문에 담아 사용한다.
Query DSL을 이해하는 핵심은 Query Context와 Filter 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를 곱하는 부스팅이다. type은 best_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)로 구성된다.
실무 예시: "published 상태의 상품 중 'elasticsearch'가 포함된 것을 점수 순으로, 단 품절은 제외"
{
"query": {
"bool": {
"must": [{ "match": { "title": "elasticsearch" } }],
"filter": [{ "term": { "status": "published" } }],
"must_not":[{ "term": { "in_stock": false } }]
}
}
}status와 in_stock은 점수와 무관하므로 filter/must_not에 배치해 캐싱 이점을 활용한다.
Aggregation: 데이터 집계·분석
집계(Aggregation)는 검색 결과로 통계·그루핑·분석을 수행한다. SQL의 GROUP BY, COUNT, AVG에 해당한다. 검색 쿼리와 같은 요청에 함께 실행할 수 있다.
Metric 집계
단일 숫자값을 계산하는 집계다.
| 집계 | 설명 | 예시 |
|---|---|---|
avg | 평균 | 평균 상품 가격 |
sum | 합계 | 총 판매액 |
min / max | 최솟값 / 최댓값 | 최저가 상품 |
value_count | null이 아닌 값 개수 | 리뷰 있는 상품 수 |
cardinality | 유일 값 개수 (HyperLogLog 근사) | 접속 국가 수 |
stats | count·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)
버킷 집계 안에 다시 집계를 넣어 "카테고리별 평균 가격" 같은 복합 분석을 만든다.
"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