Elasticsearch 입문 15편 — Compound Queries (bool·must·should·must_not·filter·function_score)

2026-05-19Elasticsearch 입문에서 운영까지

Elasticsearch 입문 15편 Compound Queries. bool·must·should·must_not·filter·function_score·dis_max.

📚 Elasticsearch 입문에서 운영까지 · 15편 — Compound Queries (bool·must·should·must_not·filter·function_score)

이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 15편이에요. 13편에서 풀텍스트 쿼리 (match · multi_match · match_phrase) 를, 14편에서 term-level 쿼리 (term · terms · range · exists) 를 봤어요. 이번 편은 그 둘을 조합 하는 자리 — Compound Queries.

📚 학습 노트

이 글은 Elasticsearch 8.x 공식 docs 의 *Compound queries* 섹션을 한국어 학습 노트로 풀어쓴 자료예요.

Kibana Dev Tools 콘솔로 `GET /_search` 에 직접 쿼리를 던져 보면서 읽으면 본문이 머리에 훨씬 잘 박혀요.

실무 쿼리 90%는 bool 안에 있어요

검색 화면 하나 떠올려 볼게요. "상품명에 '에어팟' 들어가고, 가격은 10만 원 이하, 카테고리는 audio, 단종 상품은 제외, 베스트셀러는 점수 가산". 이 한 줄 요구사항이 곧 한 개의 ES 쿼리예요.

이 쿼리를 풀텍스트 쿼리 하나로는 못 만들어요. 여러 조건을 AND · OR · NOT · 필터링 · 점수 조정 으로 조합해야 하는데, 그 조합을 책임지는 게 Compound Queries 가족이에요. 대표 다섯이 있어요.

  • bool — 4 절 (must · should · must_not · filter) 로 모든 조합 표현. 90% 자리.
  • constant_score — 모든 매칭에 같은 점수 부여. 필터링만 필요 한 자리.
  • dis_max — 여러 쿼리 중 가장 높은 점수 만 채택. best field 매칭.
  • function_score — 점수를 직접 계산하거나 조작. 추천·랭킹 부스팅 자리.
  • boostingpositive · negative 두 절로 한쪽 점수를 깎음.

실무에서 만나는 ES 쿼리의 절대 다수가 bool 한 덩어리예요. 그 위에 랭킹 조정 이 필요할 때 function_score 또는 dis_max 가 한 겹 더 들어가는 구조. 이 구조만 잡으면 검색 쿼리 설계는 거의 다 풀려요.

bool 의 4 절 — must·should·must_not·filter

bool 쿼리는 네 개의 절을 하나의 객체로 묶어요. 각 절은 쿼리의 배열 이고, 절마다 의미가 달라요.

GET /products/_search
{
  "query": {
    "bool": {
      "must":     [ { "match": { "name": "에어팟" } } ],
      "should":   [ { "term": { "is_bestseller": true } } ],
      "must_not": [ { "term": { "discontinued":  true } } ],
      "filter":   [
        { "range": { "price":    { "lte": 100000 } } },
        { "term":  { "category": "audio" } }
      ]
    }
  }
}

이 한 쿼리에 "풀텍스트 + 부스팅 + 제외 + 필터" 가 모두 들어가 있어요. 각 절의 역할을 한 줄로 정리하면 이렇게 돼요.

  • must — 반드시 매칭. AND 의미. 점수 계산 O.
  • should — 매칭되면 좋음 (OR). 점수 계산 O. 점수 가산용으로 자주 쓰임.
  • must_not — 반드시 매칭 X. 점수 계산 X · 필터 컨텍스트.
  • filter — 반드시 매칭 (AND). 점수 계산 X · 필터 컨텍스트 · 캐시 O.

여기서 가장 중요한 개념이 query context vs filter context. 같은 "매칭 O" 라도 mustfilter 는 동작이 완전히 달라요. 다음 절에서 깊이.

must vs filter — query context vs filter context

mustfilter 가 똑같이 "매칭되어야 함" 인데, 왜 두 갈래로 나뉘어 있을까. 핵심은 점수 계산 여부캐시 여부 두 가지예요.

Query Context"이 문서가 이 쿼리에 얼마나 잘 맞아?" 라는 질문에 답해요. 매칭 여부만이 아니라 _score 라는 숫자를 계산해서 관련도 순위에 반영해요. must · should 가 이 컨텍스트에서 동작.

Filter Context"이 문서가 이 쿼리에 맞아 안 맞아?" 만 묻는 자리예요. 점수 계산을 건너뛰고 yes/no 만 판정. 결과는 Node-level Query Cache 에 저장돼서 같은 필터가 반복 호출되면 캐시 히트. filter · must_not 이 이 컨텍스트.

운영 관점에서 결론은 단순해요. "정확 매칭 (term · range · exists) 은 filter 절에 넣는다". 점수 가산이 필요한 풀텍스트 (match · match_phrase) 만 must · should 에 두고, 나머지는 전부 filter 로 가요. 이 한 가지 규칙으로 ES 쿼리 응답이 수십 ms 단위 로 빨라지는 경우가 많아요.

// 안 좋은 패턴 — term 까지 must 에 넣음
"must": [
  { "match": { "name": "에어팟" } },
  { "term":  { "category": "audio" } },
  { "range": { "price": { "lte": 100000 } } }
]

// 좋은 패턴 — 정확 매칭은 filter 로
"must":   [ { "match": { "name": "에어팟" } } ],
"filter": [
  { "term":  { "category": "audio" } },
  { "range": { "price": { "lte": 100000 } } }
]

이 단순한 분리가 캐시 히트율 + 점수 계산 절약 두 효과를 동시에 가져와요. 14편에서 term-level 쿼리를 보면서 "왜 filter 컨텍스트로 쓰라" 고 강조했던 게 바로 여기예요.

should + minimum_should_match — OR 매칭의 두 얼굴

should 절은 두 가지 다른 의미로 동작해요. 같은 키워드인데 동작이 달라지는 자리라서 처음 본 사람이 가장 자주 헷갈리는 지점.

케이스 1 — must·filter 가 있는 bool 안의 should. should 절은 옵션 으로 동작해요. 매칭되면 점수가 올라가지만, 매칭 안 돼도 문서가 결과에서 빠지지 않아요. 부스팅 용도.

케이스 2 — must·filter 가 없는 bool 안의 should. should 절이 유일한 매칭 조건 이 되면서 "적어도 하나는 매칭해야 함" 으로 동작해요. 즉 OR 가 됩니다.

이 분기가 헷갈려서 운영 사고의 단골 자리. 이걸 명시적으로 제어하는 옵션이 minimum_should_match 예요.

GET /products/_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "name": "에어팟" } },
        { "match": { "name": "버즈" } },
        { "match": { "name": "프리버즈" } }
      ],
      "minimum_should_match": 2
    }
  }
}

이 쿼리는 "세 단어 중 적어도 2개 매칭" 으로 동작해요. minimum_should_match 값은 정수 (2), 퍼센트 ("75%"), 음수 ("-1", "최대 1개 누락 허용"), 조합식 ("2<75%", "쿼리가 2개면 1개, 3개 이상이면 75%") 까지 다양하게 받아요. 카테고리 검색·다중 키워드 검색 자리에서 자주 등장.

should 만 쓰고 minimum_should_match 를 안 박으면 "하나만 매칭돼도 통과" 로 동작해 recall 폭발 사고가 일어나요. 사고 1에서 다시.

constant_score — 모든 매칭에 같은 점수

가끔 "매칭만 되면 같은 점수로 다루고 싶다" 는 자리가 있어요. 카테고리 필터링·태그 검색 같이 정렬은 다른 필드 (가격·날짜) 로 따로 하는 자리. 이 자리에서 constant_score 가 깔끔해요.

GET /products/_search
{
  "query": {
    "constant_score": {
      "filter": {
        "term": { "category": "audio" }
      },
      "boost": 1.2
    }
  }
}

filter 안의 쿼리가 매칭되면 모든 문서에 boost 값 (기본 1.0) 이 _score 로 부여돼요. 점수 계산이 없으니 빠르고, 필터 컨텍스트라 캐시도 받아요. "이 쿼리는 점수 계산이 필요 없다" 가 명확한 자리에서 의도를 분명히 드러내는 효과까지.

boolfilter 절도 비슷한 효과를 내지만, constant_score"이게 점수 부여 의도다" 가 더 또렷해요. function_score 와 함께 묶어 기본 점수 + 부스팅 패턴을 만들 때도 자주 등장.

dis_max — best field 매칭

상품명·설명·태그 세 필드에 같은 키워드를 던져 "가장 잘 맞는 필드의 점수만 채택" 하고 싶을 때가 있어요. 이 자리가 dis_max (Disjunction Max, 분리 최댓값) 의 본진.

GET /products/_search
{
  "query": {
    "dis_max": {
      "queries": [
        { "match": { "name":        "에어팟 프로" } },
        { "match": { "description": "에어팟 프로" } },
        { "match": { "tags":        "에어팟 프로" } }
      ],
      "tie_breaker": 0.3
    }
  }
}

queries 안의 여러 쿼리를 모두 실행하고, 최고 점수 하나 를 그 문서의 _score 로 채택해요. tie_breaker 가 들어가면 (최고 점수) + tie_breaker × (나머지 점수 합) 으로 계산돼서 2등·3등 점수 도 약간 반영. 기본값 0 (완전한 best-field) — 0.1~0.7 사이로 두면 부드러워져요.

언제 dis_max 를 쓰고, 언제 boolshould 로 풀까. 가이드는 단순해요. "동일 키워드를 여러 필드에 던지는 자리" = dis_max, "서로 다른 의미의 조건을 OR 로 묶는 자리" = bool.should.

multi_match (13편) 의 best_fields · most_fields 모드가 사실 내부적으로 dis_max · bool 위에서 돌아가는 syntactic sugar 예요. 직접 dis_max 를 쓰는 자리는 세밀한 tie_breaker 제어· 필드별 다른 쿼리 타입 혼용 같은 고급 자리.

function_score — 점수 조작의 정석

검색 결과 정렬을 "단순 텍스트 관련도" 가 아니라 "인기도·신선도·거리·재고" 같은 비즈니스 신호로 흔들고 싶을 때가 있어요. 이 자리가 function_score 의 본진.

기본 골격은 이래요. "먼저 쿼리로 후보를 잡고, 그 다음 함수들로 점수를 재계산".

GET /products/_search
{
  "query": {
    "function_score": {
      "query": { "match": { "name": "에어팟" } },
      "functions": [
        { "field_value_factor": { "field": "sales_count", "modifier": "log1p", "factor": 0.1 } },
        { "gauss": { "released_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

functions 배열 안에 점수 함수가 여러 개 들어갈 수 있고, 각각의 의미가 다섯 갈래로 나뉘어요.

1. field_value_factor — 특정 필드의 값을 그대로 점수에 반영. modifierlog · log1p · sqrt · reciprocal 같은 비선형 변환을 박아요. 판매량·조회수 같은 값이 크게 흔들리는 필드 는 반드시 log 계열로. factor 는 곱셈 가중치.

2. script_score — Painless 스크립트로 점수 직접 계산. 가장 유연하지만 가장 위험해요. 매 문서마다 스크립트가 돌기 때문에 복잡한 로직은 성능 폭망. 사고 3에서 다시.

3. gauss · linear · exp decay원점에서 멀어질수록 점수 감쇠 함수. 시간 (최근일수록 가산)·거리 (가까울수록 가산) 자리에서 표준. gauss (정규분포) 는 자연스럽고, linear 는 1차 선형, exp 는 급격한 감쇠.

4. random_score — 매 요청마다 다른 점수. 같은 결과만 보여 주면 지루한 추천·피드 자리에 약간 섞으면 효과적. seed 로 사용자별 일관성 유지.

5. weight — 단순 상수 가중치. "이 함수만 매칭되면 5점 더" 같은 자리.

score_mode (functions 끼리 결과 합치는 방법: multiply · sum · avg · first · max · min) 와 boost_mode (query 점수와 합치는 방법: multiply · sum · avg · replace · max · min) 두 옵션의 차이가 자주 헷갈리는데, "functions 안에서 합치는 게 score_mode, query 와 합치는 게 boost_mode" 로 외워요.

운영 자리에서 자주 등장하는 패턴은 "query 로 텍스트 매칭 후, field_value_factor 로 판매량 가산, gauss 로 신선도 감쇠, boost_mode: multiply" — 이 한 줄이 거의 모든 e-commerce 검색 랭킹 의 기본형이에요.

boosting — positive/negative 절로 점수 조정

function_score추가 함수로 점수 키우기 였다면, boosting 쿼리는 특정 매칭은 점수 깎기 자리예요.

GET /products/_search
{
  "query": {
    "boosting": {
      "positive": { "match": { "name": "노트북" } },
      "negative": { "term":  { "is_refurbished": true } },
      "negative_boost": 0.3
    }
  }
}

positive 절이 매칭된 문서를 가져오고, 그 중 negative 절도 매칭되면 점수에 negative_boost 를 곱해 깎아요 (0.0~1.0 사이). 위 예시는 "리퍼 상품은 점수 70% 깎고 결과 뒤로 밀어" 의미.

bool.must_not 과 다른 점이 또렷해요. must_not완전히 제외, boosting.negative결과에 남기되 뒤로 밀기. "품질이 살짝 떨어지는 상품도 보이긴 해야 하는데 앞에 오면 안 되는" 자리에서 정확.

function_score · boosting 둘 다 랭킹 조작 도구지만 "점수 키우기 + 함수 조합 = function_score, 점수 깎기 + 단순 조건 = boosting" 으로 분리해서 기억하면 헷갈리지 않아요.

자주 만나는 사고 7가지

사고 1 — should 만 있고 minimum_should_match 누락

원인bool 안에 must · filter 가 없고 should 만 있으면 자동으로 "적어도 하나 매칭" 으로 동작해요. 키워드 5개 중 1개만 매칭돼도 통과하니 recall 폭발 — 결과가 수십만 건으로 튀어 응답이 느려져요.

해결should 만 쓰는 자리에는 반드시 minimum_should_match 를 박아요. 2~3개 키워드면 2, 5개 이상이면 "60%" 같이 정량.

사고 2 — 정확 매칭을 must 에 넣어 캐시 못 받음

원인term · range 같은 정확 매칭을 must 절에 넣으면 query context 에서 동작해 점수 계산이 매번 일어나고 Query Cache 도 못 받아요. QPS 가 올라가면 CPU 가 먼저 죽음.

해결풀텍스트는 must, 정확 매칭은 filter 규칙을 박아요. 14편에서 본 term-level 쿼리는 95% 이상 filter 절로 가는 게 정답.

사고 3 — function_score script_score 폭증

원인script_scoreDB 조회·복잡한 분기·문자열 처리 가 들어가면 매 문서마다 스크립트가 돌아서 클러스터 CPU 가 100% 로 튐. 검색 응답이 수십 ms → 수 초 로 폭발.

해결 — 스크립트는 간단한 수식 + 필드 1~2개 참조 만 허용해요. 복잡한 로직은 색인 시점에 미리 계산해 필드로 저장 하는 패턴 (denormalize for read). Painless 스크립트는 캐시되지만 인자가 바뀌면 무효.

사고 4 — dis_max tie_breaker 0 으로 두고 best field 부족

원인dis_maxtie_breaker 가 기본 0 이라 가장 잘 맞는 필드 하나의 점수만 채택돼요. 그러다 보니 "두 필드 모두 약하게 매칭되는 문서""한 필드 강하게 매칭되는 문서" 에 항상 밀려 recall 부족.

해결tie_breaker 를 0.1~0.7 사이로 두면 부드러워져요. 일반적으로 0.3 이 안전한 기본값. 13편 multi_matchbest_fields 모드도 내부적으로 같은 값을 받음.

사고 5 — bool 절 안에 또 bool 을 깊게 중첩

원인bool → must → bool → must → bool 식으로 3~5 레벨 중첩되면 쿼리 파싱·실행 비용이 커지고, 가독성도 망함. 운영 사고 때 어느 조건이 어디서 평가되는지 추적이 어려움.

해결bool 중첩은 최대 2 레벨 이 가이드. 3 레벨 넘어가면 named query (_name) 를 박아서 어느 절이 매칭됐는지 추적 가능하게 해요. 4 레벨 이상이면 쿼리 설계 자체를 다시.

사고 6 — function_score boost_mode 기본값 오해

원인function_scoreboost_mode 기본값이 multiply"functions 가 점수를 0 으로 만들면 전체 점수가 0" 이 돼요. 모든 결과가 점수 0 으로 정렬되어 완전히 무작위 순서 가 나옴.

해결기본은 multiply 라는 사실을 외우고, field_value_factor 가 0 을 만들지 않게 modifier: "log1p" (log(1+x), 0 입력에도 안전) 를 쓰거나 missing 으로 fallback 값을 박아요.

사고 7 — minimum_should_match 퍼센트 반올림 함정

원인minimum_should_match: "75%"총 쿼리 수 × 0.75 의 내림 으로 계산돼요. 쿼리 4개면 3, 3개면 2, 2개면 1, 1개면 1. 의도 ("3개 이상일 때만 75% 매칭") 와 달리 1~2개 쿼리에서도 강한 제약이 걸려요.

해결조합식 ("3<75%" = "쿼리 3개 이하면 모두 매칭, 4개 이상이면 75%") 으로 명시. 케이스별 동작을 docs 의 Minimum should match parameter 표로 확인.

운영 권장 패턴 5가지

설계 시작은 "이 쿼리에서 점수가 의미 있나" 를 먼저 물어요. 카테고리 페이지·필터 검색 처럼 정렬이 가격·날짜 면 점수가 의미 없으니 constant_score 또는 bool.filter 쓰는 게 정답. 검색 결과 페이지 처럼 관련도가 중요하면 bool.must · function_score 를 깊이.

Filter 우선 패턴 을 박아요. 모든 정확 매칭 조건은 무조건 filter 절에. 풀텍스트만 must · should. 이 한 가지가 캐시 히트율을 2~3배로 키워요.

function_score 는 점진적 도입 으로 가요. 처음엔 bool 만 쓰다가, 검색 품질 측정 (CTR · 전환율) 결과가 나오면 판매량 가산 → 신선도 감쇠 → A/B 테스트 순서로 하나씩 추가. 처음부터 함수 5개를 넣으면 디버깅이 불가능.

named queries (_name) 를 디버깅 도구로 활용해요. 복잡한 bool 안의 각 절에 "_name": "category_filter" 식으로 이름을 박으면, 응답의 matched_queries 필드에 어느 절이 매칭됐는지 표시돼서 "왜 이 문서가 결과에 들어왔나" 추적이 쉬워져요.

explain API 를 운영 디버깅 도구로 둡니다. GET /products/_search?explain=true 또는 GET /products/_explain/{id}점수 계산 내역 을 트리 구조로 볼 수 있어요. 점수가 이상하다는 사고 신고가 오면 첫 번째로 explain.

시험 직전 한 번 더 — 압축 노트

  • Compound Queries 가족 = bool · constant_score · dis_max · function_score · boosting 다섯.
  • bool 의 4 절: must · should · must_not · filter. 실무 쿼리 90% 가 bool.
  • must = AND + 점수 O, should = OR + 점수 O, must_not = NOT + 점수 X, filter = AND + 점수 X + 캐시 O.
  • query context = 점수 계산 (must · should), filter context = yes/no 만 (filter · must_not).
  • 운영 규칙 한 줄: 정확 매칭 (term · range · exists) 은 filter 절로.
  • minimum_should_match = should 절의 최소 매칭 갯수. 정수 · 퍼센트 · 음수 · 조합식 가능.
  • boolmust · filter 없이 should 만 있으면 "적어도 1개 매칭" 자동 적용 — 반드시 minimum_should_match 박기.
  • constant_score = 모든 매칭에 동일 점수. 필터링만 필요할 때.
  • dis_max = 여러 쿼리 중 최고 점수만. tie_breaker 0.3 기본 권장. best field 매칭.
  • function_score = 점수 조작. 함수 5종: field_value_factor · script_score · decay (gauss/linear/exp) · random_score · weight.
  • score_mode = functions 끼리 합치는 방법, boost_mode = query 점수와 합치는 방법. 기본 multiply 주의.
  • boosting = positive 매칭 + negative 절 점수 깎기. 결과에 남기되 뒤로 밀기.
  • 7대 사고: should + min_should_match 누락 · 정확매칭을 must 에 · script_score 폭증 · dis_max tie_breaker 0 · bool 중첩 폭증 · boost_mode multiply 함정 · 퍼센트 반올림 함정.
  • 디버깅 도구: named queries (_name) · explain API.
  • e-commerce 검색 랭킹 기본형: bool.must (텍스트) + filter (카테고리·가격) + function_score (판매량 + 신선도) + boost_mode multiply.

시리즈 다른 편

  • 이전 글 = 14편 Term-Level Queries — term·terms·range·exists·prefix·wildcard·regexp
  • 다음 글 = 16편 Aggregations Metric — sum·avg·min·max·stats·percentiles·cardinality
  • 12편 = Search API — search 엔드포인트·source filter·sort·pagination·highlight
  • 13편 = Full-Text Queries — match·multi_match·match_phrase·match_phrase_prefix
  • 17편 = Aggregations Bucket — terms·date_histogram·range·filter·composite
  • 18편 = Aggregations Pipeline — bucket_script·moving_avg·cumulative_sum
  • 19편 = Search Features — search_after·scroll·PIT·collapse·highlight 깊이
  • 32편 = Spring Data Elasticsearch — Repository·Template·POJO
  • 38편 = 시리즈 마무리 — 결정 트리·체크리스트·자격증

한 줄 정리 — Compound Queries = boolmust · should · must_not · filter 4 절로 90% 쿼리 조립 + 위에 function_score · dis_max · boosting 으로 랭킹 조정. 정확 매칭은 무조건 filter 절, 풀텍스트만 must · should.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!