Back to Blog
ElasticsearchAnalyzerQuery DSLFull-text SearchAggregationBool Query

0x02. Elasticsearch 검색과 분석

Elasticsearch의 텍스트 분석기(Analyzer), Query DSL을 활용한 검색, 그리고 Aggregation을 통한 데이터 분석을 알아본다.

Elasticsearch에 데이터를 넣는 것까지는 비교적 간단하다. 진짜 문제는 그 다음이다. "어떻게 원하는 데이터를 정확하고 빠르게 찾아낼 것인가?" 그리고 "찾아낸 데이터에서 어떤 의미를 뽑아낼 것인가?"

이 글에서는 Elasticsearch의 텍스트 분석 파이프라인(Analyzer), 검색 질의 언어(Query DSL), 그리고 데이터 집계(Aggregation)를 다룬다. 이 세 가지를 이해하면 Elasticsearch를 단순한 저장소가 아닌, 강력한 검색 엔진이자 분석 도구로 활용할 수 있다.


Analyzer: 텍스트를 검색 가능하게 만드는 과정

Elasticsearch가 전문 검색(Full-text Search)을 수행할 수 있는 이유는, 텍스트를 저장하기 전에 분석(Analysis) 과정을 거치기 때문이다. "Elasticsearch is amazing"이라는 문장을 그대로 저장하는 것이 아니라, ["elasticsearch", "is", "amazing"]처럼 개별 토큰으로 쪼개어 역색인(Inverted Index) 에 저장한다.

이 분석 과정을 담당하는 것이 바로 Analyzer(분석기) 다.

분석 파이프라인의 구조

Analyzer는 세 단계의 파이프라인으로 구성된다.

1. Character Filter (문자 필터)

원본 텍스트를 문자 스트림 단위로 전처리한다. HTML 태그를 제거하거나, 특정 문자를 다른 문자로 치환하는 등의 작업을 수행한다. 0개 이상 지정할 수 있으며, 지정한 순서대로 적용된다.

  • html_strip: HTML 태그 제거 (<b>bold</b> -> bold)
  • mapping: 지정한 문자 매핑에 따라 치환 (& -> and)
  • pattern_replace: 정규표현식 기반 치환

2. Tokenizer (토크나이저)

문자 스트림을 받아 개별 토큰(token) 으로 분리한다. 파이프라인에서 반드시 하나 존재해야 하는 필수 요소다. 공백이나 구두점을 기준으로 나누는 것이 가장 기본적인 방식이다.

  • standard: 유니코드 텍스트 분할 알고리즘 기반 (기본값)
  • whitespace: 공백 기준으로만 분리
  • keyword: 텍스트 전체를 하나의 토큰으로 취급 (분리하지 않음)
  • pattern: 정규표현식 기반 분리

3. Token Filter (토큰 필터)

토크나이저가 생성한 토큰을 후처리한다. 소문자 변환, 불용어(stop word) 제거, 동의어 처리 등이 이 단계에서 이루어진다. 0개 이상 지정 가능하며, 순서대로 적용된다.

  • lowercase: 소문자 변환
  • stop: 불용어 제거 (the, a, is 등)
  • stemmer: 어간 추출 (running -> run)
  • synonym: 동의어 확장 (quick -> quick, fast)

정리하면 다음과 같다.

원본 텍스트
  -> [Character Filter] -> 전처리된 문자 스트림
  -> [Tokenizer]        -> 토큰 스트림
  -> [Token Filter]     -> 최종 토큰 목록
  -> 역색인(Inverted Index) 저장

내장 Analyzer

Elasticsearch는 자주 사용되는 조합을 내장 Analyzer로 제공한다.

Standard Analyzer (기본값)

별도로 Analyzer를 지정하지 않으면 사용되는 기본 분석기다. Unicode Text Segmentation 알고리즘 기반의 standard 토크나이저와 lowercase 토큰 필터를 조합한다.

POST _analyze
{
  "analyzer": "standard",
  "text": "The Quick Brown Fox!"
}

결과: ["the", "quick", "brown", "fox"]

대부분의 서양 언어에서 잘 동작하지만, 한국어처럼 형태소 분석이 필요한 언어에는 적합하지 않다.

Nori Analyzer (한국어)

Elasticsearch 6.4부터 제공되는 공식 한국어 분석 플러그인이다. analysis-nori 플러그인을 설치하면 사용할 수 있으며, mecab-ko-dic 사전을 기반으로 한국어 형태소 분석을 수행한다.

# 플러그인 설치
bin/elasticsearch-plugin install analysis-nori

Nori Analyzer는 내부적으로 다음 구성 요소를 사용한다.

  • nori_tokenizer: 한국어 형태소 분석 토크나이저
  • nori_part_of_speech: 품사 기반 토큰 필터 (조사, 어미 등 제거)
  • nori_readingform: 한자를 한글로 변환
  • lowercase: 소문자 변환
POST _analyze
{
  "analyzer": "nori",
  "text": "동해물과 백두산이 마르고 닳도록"
}

결과: ["동해", "", "백두", "", "마르", ""] (조사와 어미가 제거된 형태소 단위)

Custom Analyzer

내장 Analyzer로 충족되지 않는 요구사항이 있다면, Custom Analyzer를 직접 정의할 수 있다. 인덱스 설정(Settings)에서 Character Filter, Tokenizer, Token Filter를 직접 조합한다.

PUT my_index
{
  "settings": {
    "analysis": {
      "char_filter": {
        "my_char_filter": {
          "type": "mapping",
          "mappings": ["& => and", "| => or"]
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "pattern",
          "pattern": "[\\W_]+"
        }
      },
      "filter": {
        "my_stop": {
          "type": "stop",
          "stopwords": ["the", "a", "is"]
        }
      },
      "analyzer": {
        "my_custom_analyzer": {
          "type": "custom",
          "char_filter": ["my_char_filter"],
          "tokenizer": "my_tokenizer",
          "filter": ["lowercase", "my_stop"]
        }
      }
    }
  }
}

이렇게 정의한 Custom Analyzer는 매핑(Mapping)에서 특정 필드에 적용할 수 있다.

PUT my_index/_mapping
{
  "properties": {
    "content": {
      "type": "text",
      "analyzer": "my_custom_analyzer"
    }
  }
}

주의할 점: Analyzer는 인덱스 생성 시점에 정의해야 한다. 이미 생성된 인덱스의 Analyzer를 변경하려면 인덱스를 닫고(_close) 설정을 업데이트한 뒤 다시 열거나(_open), 새 인덱스를 만들어 재색인(reindex)해야 한다.


Query DSL: Elasticsearch의 검색 언어

Query DSL(Domain Specific Language) 은 Elasticsearch에 검색 질의를 전달하기 위한 JSON 기반 언어다. SQL이 관계형 데이터베이스의 질의 언어라면, Query DSL은 Elasticsearch의 질의 언어에 해당한다.

match: 전문 검색의 기본

match 쿼리는 전문 검색(Full-text Search)을 수행하는 가장 기본적인 쿼리다. 검색어를 Analyzer로 분석한 뒤, 분석된 토큰을 역색인에서 찾는다.

GET my_index/_search
{
  "query": {
    "match": {
      "content": "elasticsearch tutorial"
    }
  }
}

위 쿼리는 "elasticsearch tutorial"을 분석하여 ["elasticsearch", "tutorial"] 두 토큰을 생성하고, 두 토큰 중 하나라도 포함된 문서를 반환한다. 기본 동작이 OR 조건이기 때문이다.

두 단어가 모두 포함된 문서만 찾으려면 operator를 지정한다.

GET my_index/_search
{
  "query": {
    "match": {
      "content": {
        "query": "elasticsearch tutorial",
        "operator": "and"
      }
    }
  }
}

term: 정확한 값 매칭

term 쿼리는 Analyzer를 거치지 않고, 입력한 값을 그대로 역색인에서 찾는다. keyword 타입 필드(상태값, ID, 태그 등)에 대한 정확한 매칭에 사용한다.

GET my_index/_search
{
  "query": {
    "term": {
      "status": "published"
    }
  }
}

흔한 실수 중 하나가 text 타입 필드에 term 쿼리를 사용하는 것이다. text 필드는 저장 시 Analyzer를 거쳐 소문자로 변환되므로, "Published"term 검색하면 결과가 나오지 않는다. text 필드의 전문 검색에는 match를, keyword 필드의 정확한 매칭에는 term을 사용해야 한다.

range: 범위 검색

range 쿼리는 숫자, 날짜 등의 필드에서 특정 범위에 해당하는 문서를 찾는다.

GET my_index/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 1000,
        "lte": 5000
      }
    }
  }
}
  • gt: 초과 (greater than)
  • gte: 이상 (greater than or equal)
  • lt: 미만 (less than)
  • lte: 이하 (less than or equal)

날짜 필드에도 동일하게 사용할 수 있다.

GET my_index/_search
{
  "query": {
    "range": {
      "created_at": {
        "gte": "2026-01-01",
        "lt": "2026-02-01",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

multi_match: 여러 필드 동시 검색

multi_match 쿼리는 하나의 검색어를 여러 필드에서 동시에 검색한다. 제목과 본문을 한 번에 검색하고 싶을 때 유용하다.

GET my_index/_search
{
  "query": {
    "multi_match": {
      "query": "elasticsearch",
      "fields": ["title^3", "content"]
    }
  }
}

title^3은 title 필드의 가중치를 3배로 높인다는 의미다. 제목에서 매칭된 결과가 본문에서 매칭된 결과보다 점수가 높아져 상위에 노출된다.

multi_matchtype 파라미터로 동작 방식을 세밀하게 조절할 수 있다.

  • best_fields (기본값): 가장 높은 점수를 받은 필드의 스코어를 사용
  • most_fields: 매칭된 모든 필드의 스코어를 합산
  • cross_fields: 여러 필드를 하나의 필드처럼 취급

bool: 복합 조건 검색

Bool Query는 여러 쿼리를 논리적으로 조합할 수 있는 가장 강력한 쿼리다. 실무에서 가장 많이 사용되는 쿼리이기도 하다. 네 가지 절(clause)을 조합하여 사용한다.

설명점수 영향
must반드시 매칭되어야 한다 (AND)O
should매칭되면 점수 상승 (OR)O
must_not반드시 매칭되지 않아야 한다 (NOT)X
filter반드시 매칭되어야 한다 (AND)X

mustfilter는 둘 다 AND 조건이지만, 핵심적인 차이가 있다. must관련성 점수(relevance score) 계산에 영향을 주고, filter는 점수 계산 없이 단순 필터링만 수행한다. 따라서 점수가 필요 없는 조건(날짜 범위, 상태값 필터 등)은 filter에 넣는 것이 캐시 활용성능 면에서 유리하다.

GET my_index/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "elasticsearch" } }
      ],
      "should": [
        { "match": { "content": "performance tuning" } }
      ],
      "must_not": [
        { "term": { "status": "draft" } }
      ],
      "filter": [
        { "range": { "created_at": { "gte": "2026-01-01" } } }
      ]
    }
  }
}

위 쿼리를 해석하면 다음과 같다.

  • title에 "elasticsearch"가 반드시 포함되어야 하고 (must)
  • content에 "performance tuning"이 포함되면 점수가 올라가며 (should)
  • status가 "draft"인 문서는 제외하고 (must_not)
  • 2026년 1월 1일 이후에 생성된 문서만 필터링한다 (filter)

Search API: 검색 실행과 결과 제어

_search 엔드포인트

모든 검색 요청은 _search 엔드포인트를 통해 이루어진다.

GET my_index/_search
{
  "query": { ... },
  "from": 0,
  "size": 10,
  "sort": [ ... ],
  "_source": ["title", "created_at"],
  "highlight": { ... }
}
  • query: 검색 조건
  • from / size: 페이지네이션
  • sort: 정렬 기준
  • _source: 반환할 필드 지정 (네트워크 비용 절감)
  • highlight: 검색어 하이라이팅

정렬 (Sorting)

기본적으로 검색 결과는 관련성 점수(_score) 기준 내림차순으로 정렬된다. 다른 기준으로 정렬하려면 sort 파라미터를 사용한다.

GET my_index/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "created_at": { "order": "desc" } },
    { "_score": { "order": "desc" } }
  ]
}

정렬 기준을 여러 개 지정하면, 첫 번째 기준이 같은 문서끼리 두 번째 기준으로 다시 정렬된다.

페이지네이션

from / size 방식

가장 직관적인 페이지네이션 방법이다. from은 건너뛸 문서 수, size는 반환할 문서 수를 지정한다.

GET my_index/_search
{
  "from": 20,
  "size": 10,
  "query": { "match_all": {} }
}

이 방식은 구현이 간단하지만, 깊은 페이지(deep pagination)에서 성능 문제가 발생한다. from + size가 커질수록 Elasticsearch는 각 샤드에서 from + size개의 문서를 모두 가져온 뒤 정렬해야 하기 때문이다. 기본적으로 from + size10,000을 초과할 수 없도록 제한되어 있다.

search_after 방식

대량의 결과를 순회하거나 무한 스크롤 UI를 구현할 때는 search_after가 적합하다. 이전 페이지의 마지막 문서의 정렬 값을 기준으로 다음 페이지를 요청하는 방식이다.

// 첫 번째 요청
GET my_index/_search
{
  "size": 10,
  "sort": [
    { "created_at": "desc" },
    { "_id": "asc" }
  ],
  "query": { "match_all": {} }
}

첫 번째 응답의 마지막 문서에서 sort 값(예: [1707609600000, "doc_123"])을 꺼내어 다음 요청에 전달한다.

// 두 번째 요청
GET my_index/_search
{
  "size": 10,
  "sort": [
    { "created_at": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [1707609600000, "doc_123"],
  "query": { "match_all": {} }
}

search_after를 사용할 때는 반드시 고유한 정렬 기준(tiebreaker) 을 포함해야 한다. 위 예시에서 _id가 그 역할을 한다. 동일한 created_at 값을 가진 문서가 여러 개 있을 때, 어디서부터 다음 페이지를 시작할지 명확히 결정할 수 있어야 하기 때문이다.

하이라이팅 (Highlighting)

검색 결과에서 검색어가 매칭된 부분을 강조 표시하는 기능이다. 검색 포털의 결과에서 검색어가 굵게 표시되는 것과 같은 원리다.

GET my_index/_search
{
  "query": {
    "match": { "content": "elasticsearch" }
  },
  "highlight": {
    "fields": {
      "content": {
        "pre_tags": ["<em>"],
        "post_tags": ["</em>"],
        "fragment_size": 150,
        "number_of_fragments": 3
      }
    }
  }
}

응답에는 highlight 필드가 추가되어, 매칭된 부분이 태그로 감싸져 반환된다.

{
  "highlight": {
    "content": [
      "Learn how to use <em>Elasticsearch</em> for full-text search..."
    ]
  }
}
  • pre_tags / post_tags: 강조 표시에 사용할 태그
  • fragment_size: 하이라이트 조각의 최대 문자 수
  • number_of_fragments: 반환할 하이라이트 조각 수

Aggregation: 데이터에서 의미를 뽑아내다

Aggregation(집계) 은 검색 결과를 기반으로 통계, 요약, 패턴 분석을 수행하는 기능이다. SQL의 GROUP BY, SUM, AVG에 대응하는 개념이지만, 훨씬 유연하고 강력하다.

Aggregation은 크게 Metric AggregationBucket Aggregation, 그리고 Pipeline Aggregation으로 나뉜다. 이 글에서는 가장 자주 사용되는 Metric과 Bucket을 중점적으로 다룬다.

Metric Aggregation: 수치 계산

필드 값을 기반으로 수치적 통계를 계산한다. SQL의 집계 함수와 유사하다.

avg (평균)

GET my_index/_search
{
  "size": 0,
  "aggs": {
    "avg_price": {
      "avg": {
        "field": "price"
      }
    }
  }
}

"size": 0은 검색 결과 문서는 반환하지 않고 집계 결과만 받겠다는 의미다. 집계만 필요한 경우 불필요한 데이터 전송을 줄일 수 있다.

sum (합계)

{
  "aggs": {
    "total_revenue": {
      "sum": {
        "field": "revenue"
      }
    }
  }
}

cardinality (고유값 수)

cardinality 집계는 필드의 고유한 값의 개수를 근사적으로 계산한다. SQL의 COUNT(DISTINCT field)에 해당한다.

{
  "aggs": {
    "unique_users": {
      "cardinality": {
        "field": "user_id"
      }
    }
  }
}

"근사적"이라고 한 이유가 있다. Cardinality 집계는 HyperLogLog++ 알고리즘을 사용하여 고유값 수를 추정한다. 정확한 값이 아닌 근사값을 반환하지만, 메모리 사용량이 매우 적고 대규모 데이터셋에서도 빠르게 동작한다. precision_threshold 파라미터로 정확도와 메모리 사용량 간의 균형을 조절할 수 있다.

stats (기본 통계 한 번에)

count, min, max, avg, sum을 한 번에 계산한다.

{
  "aggs": {
    "price_stats": {
      "stats": {
        "field": "price"
      }
    }
  }
}

Bucket Aggregation: 문서를 그룹으로 분류

문서를 특정 기준에 따라 버킷(bucket) 으로 분류한다. SQL의 GROUP BY에 해당한다.

terms: 필드값 기준 그룹화

가장 많이 사용되는 Bucket Aggregation이다. 필드의 고유한 값별로 문서를 그룹화하고, 각 그룹의 문서 수를 반환한다.

GET my_index/_search
{
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category.keyword",
        "size": 10,
        "order": { "_count": "desc" }
      }
    }
  }
}

응답 예시:

{
  "aggregations": {
    "by_category": {
      "buckets": [
        { "key": "electronics", "doc_count": 1523 },
        { "key": "clothing", "doc_count": 987 },
        { "key": "books", "doc_count": 654 }
      ]
    }
  }
}

"field": "category.keyword"에서 .keyword를 사용한 점에 주목하자. text 타입 필드는 분석 과정을 거치므로 집계에 적합하지 않다. Elasticsearch는 text 필드를 생성할 때 자동으로 .keyword 서브 필드를 함께 만들며, 정확한 값 기준의 집계에는 이 keyword 서브 필드를 사용해야 한다.

date_histogram: 시간 기반 그룹화

날짜 필드를 기준으로 일정 시간 간격(interval) 으로 문서를 그룹화한다. 시계열 데이터 분석에 필수적인 집계다.

GET my_index/_search
{
  "size": 0,
  "aggs": {
    "orders_over_time": {
      "date_histogram": {
        "field": "order_date",
        "calendar_interval": "month",
        "format": "yyyy-MM",
        "min_doc_count": 0
      }
    }
  }
}
  • calendar_interval: 달력 기반 간격 (day, week, month, quarter, year)
  • fixed_interval: 고정 간격 (1h, 30m, 7d 등)
  • min_doc_count: 0으로 설정하면 문서가 없는 구간도 빈 버킷으로 반환

calendar_intervalfixed_interval의 차이를 알아두면 좋다. calendar_interval: "month"는 28일, 30일, 31일 등 실제 달력에 맞게 동작하는 반면, fixed_interval: "30d"는 항상 정확히 30일 단위로 나눈다.

range: 사용자 정의 범위 그룹화

숫자나 날짜 필드를 사용자가 정의한 범위로 그룹화한다.

GET my_index/_search
{
  "size": 0,
  "aggs": {
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "key": "cheap", "to": 10000 },
          { "key": "mid", "from": 10000, "to": 50000 },
          { "key": "expensive", "from": 50000 }
        ]
      }
    }
  }
}

from은 포함, to는 미포함(exclusive)이다. 즉, "from": 10000, "to": 50000은 10000 이상 50000 미만을 의미한다.

중첩 집계: Aggregation 안의 Aggregation

Bucket Aggregation 안에 다른 Aggregation을 중첩(nesting) 할 수 있다. 이것이 Elasticsearch Aggregation의 진정한 힘이다.

예를 들어, 카테고리별 평균 가격을 구하고 싶다면 terms 집계 안에 avg 집계를 중첩한다.

GET my_index/_search
{
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category.keyword",
        "size": 10
      },
      "aggs": {
        "avg_price": {
          "avg": {
            "field": "price"
          }
        }
      }
    }
  }
}

응답 예시:

{
  "aggregations": {
    "by_category": {
      "buckets": [
        {
          "key": "electronics",
          "doc_count": 1523,
          "avg_price": { "value": 450000.0 }
        },
        {
          "key": "clothing",
          "doc_count": 987,
          "avg_price": { "value": 35000.0 }
        }
      ]
    }
  }
}

여러 단계로 중첩하는 것도 가능하다. 월별 > 카테고리별 > 평균 가격 같은 복잡한 분석도 하나의 쿼리로 수행할 수 있다.

GET my_index/_search
{
  "size": 0,
  "aggs": {
    "monthly": {
      "date_histogram": {
        "field": "order_date",
        "calendar_interval": "month"
      },
      "aggs": {
        "by_category": {
          "terms": {
            "field": "category.keyword"
          },
          "aggs": {
            "avg_price": {
              "avg": { "field": "price" }
            }
          }
        }
      }
    }
  }
}

이처럼 Aggregation을 중첩하면, SQL에서는 여러 번의 쿼리가 필요한 복잡한 분석을 단 한 번의 요청으로 처리할 수 있다.


정리

Elasticsearch의 검색과 분석은 세 가지 축으로 구성된다.

  • Analyzer: 텍스트를 토큰으로 분해하여 검색 가능한 형태로 변환한다. Character Filter, Tokenizer, Token Filter의 파이프라인으로 구성되며, 한국어에는 Nori Analyzer를 사용한다.
  • Query DSL: JSON 기반의 검색 질의 언어다. matchterm의 차이를 이해하고, bool 쿼리로 복합 조건을 구성하는 것이 핵심이다. 점수가 필요 없는 조건은 filter에 넣어 성능을 확보한다.
  • Aggregation: 검색 결과를 집계하여 통계와 패턴을 분석한다. Metric과 Bucket 집계를 중첩하면 복잡한 분석도 단일 쿼리로 처리할 수 있다.

이 세 가지를 조합하면, 단순한 키워드 검색부터 실시간 대시보드용 복합 분석까지 Elasticsearch 하나로 구현할 수 있다.