Elasticsearch 입문 14편 Term-level Queries. term·terms·range·exists·prefix·wildcard·fuzzy. 정확 일치 검색의 함정과 표준.
이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 14편이에요. 직전 13편에서 match·multi_match·match_phrase 같은 풀텍스트 검색을 다뤘다면, 이번 편은 그 반대 자리 인 Term-level Queries 차례예요. analyzer 를 통과하지 않고 색인된 원본 토큰 을 그대로 비교하는 정확 일치 계열 쿼리 묶음이에요.
이 글은 Elasticsearch 8.x 공식 docs 의 Term-level queries 페이지를 자체 작성으로 풀어쓴 자료예요.
Kibana Dev Tools 에 직접 쿼리를 던져 보면서 읽으면 가장 빨라요. 모든 예시는 그대로 복붙해서 8.x 환경에서 도는 형태로 작성했어요.
도입 — term-level 이 뭐가 다른가
13편 full-text queries 가 "검색어를 analyzer 에 한 번 통과시킨 뒤 색인된 토큰과 비교" 한다면, term-level queries 는 "검색어를 가공하지 않고 색인된 원본 토큰과 1:1 비교" 해요. analyzer 통과 X — 그래서 대소문자·공백·구두점 까지 그대로 비교돼요.
이게 결정적인 이유는 keyword 필드 와 한 묶음이라서예요. 8편(Mapping Deep) 과 9편(Field Types) 에서 풀었듯이 text 타입은 analyzer 를 통과해 토큰으로 쪼개지고, keyword 타입은 통째로 한 토큰으로 색인 돼요. term-level 쿼리는 후자 — keyword·숫자·날짜·boolean·ip 같은 정확 일치가 의미 있는 필드 에서만 의도대로 동작해요.
세 가지 사용 자리를 미리 봐 두면 좋아요. 필터링 이 가장 큰 자리 — status: published 처럼 카테고리·상태·태그 로 거르는 자리예요. 범위 검색 이 두 번째 — price: 10000~50000, created_at: 2026-01-01~2026-12-31 같은 숫자·날짜 구간. 마지막이 ID·존재 여부 체크 — _id IN (1,2,3), field NOT NULL 같은 자리예요.
성능 특성도 풀텍스트와 달라요. term-level 은 스코어 계산을 생략 할 수 있어서 (특히 filter 컨텍스트에서) 풀텍스트보다 몇 배 빠르고 캐시도 잘 먹어요. 운영에서 Bool 쿼리의 filter 자리 에 들어가는 쿼리는 거의 모두 term-level 이에요.
term & terms — 정확 일치의 표준
가장 자주 쓰는 한 쌍이에요. term 은 한 값 정확 일치, terms 는 여러 값 중 하나라도 일치 예요. SQL 로 치면 WHERE col = 'A' 와 WHERE col IN ('A','B','C') 자리예요.
GET /products/_search
{
"query": {
"term": {
"status": {
"value": "published",
"boost": 1.0,
"case_insensitive": false
}
}
}
}
case_insensitive 옵션이 Elasticsearch 7.10+ 부터 들어왔어요. 기본은 false — 즉 대소문자를 그대로 비교해요. "Published" 로 색인된 문서를 "published" 로 검색하면 0건 이 나오는 게 기본 동작이라, 한국 시장에서 가장 자주 만나는 "왜 검색이 0건이지" 사고의 절반이 이 자리예요.
terms 는 배열을 받아요.
GET /products/_search
{
"query": {
"terms": {
"category": ["kitchen", "bedroom", "bathroom"],
"boost": 1.0
}
}
}
기본 한도는 값 65,536개 — 그 이상이 필요하면 index.max_terms_count 를 조정하거나 terms_lookup 패턴을 써요. terms_lookup 은 다른 인덱스의 문서에서 배열을 끌어와 IN 조건으로 쓰는 패턴이에요.
GET /products/_search
{
"query": {
"terms": {
"user_id": {
"index": "vip_users",
"id": "vip_list_2026",
"path": "user_ids"
}
}
}
}
이러면 vip_users 인덱스의 vip_list_2026 문서의 user_ids 필드 를 그 자리에 펼쳐 줘요. 블랙리스트·VIP 리스트·구독자 리스트 같이 수시로 갱신되는 큰 IN 조건 을 다룰 때 표준 패턴이에요.
terms_set 은 "최소 N개 매치해야 통과" 라는 변형이에요. 예를 들어 추천 태그 5개 중 최소 3개 가 일치해야 통과하는 쿼리.
GET /products/_search
{
"query": {
"terms_set": {
"tags": {
"terms": ["organic", "vegan", "gluten-free", "fair-trade", "local"],
"minimum_should_match_field": "required_matches"
}
}
}
}
minimum_should_match_field 는 그 문서에 박혀 있는 필드 값 을 기준 매치 수로 쓰겠다는 뜻 — 각 문서별로 임계값을 다르게 가져갈 때 유용해요. 정해진 상수면 minimum_should_match_script 로 Painless 스크립트 를 넣어도 돼요.
range — 숫자·날짜 범위
세 번째로 자주 쓰는 쿼리예요. 숫자·날짜·ip·long 같은 순서가 있는 필드 에서 동작해요.
GET /products/_search
{
"query": {
"range": {
"price": {
"gte": 10000,
"lte": 50000,
"boost": 1.0
}
}
}
}
연산자는 네 가지 — gte (이상)·lte (이하)·gt (초과)·lt (미만). SQL 의 BETWEEN 자리예요.
날짜 range 는 옵션이 좀 더 있어요.
GET /orders/_search
{
"query": {
"range": {
"created_at": {
"gte": "2026-01-01",
"lte": "2026-12-31",
"format": "yyyy-MM-dd",
"time_zone": "+09:00"
}
}
}
}
format 은 그 쿼리에 한해서 날짜 포맷을 덮어쓰는 옵션이에요. mapping 에 박힌 기본 포맷과 다른 입력을 받을 때 유용. time_zone 은 입력 날짜를 어느 타임존으로 해석할지 를 정해요. 작성 시점(2026-05-19) 기준 ES 8.x 도 내부적으로는 UTC 로 저장 하기 때문에 한국 시간 기준 검색 이 필요하면 +09:00 을 명시해야 "2026-05-19 자정" 이 의도대로 잡혀요.
Date math 표현도 그대로 먹어요. now-7d (7일 전), now/d (오늘 자정), 2026-05-19||+1M/d (2026-05-19 의 한 달 뒤 자정) 같은 식이에요.
GET /logs/_search
{
"query": {
"range": {
"@timestamp": {
"gte": "now-1h",
"lte": "now"
}
}
}
}
ELK 스택의 최근 1시간 로그 자리에 가장 흔하게 등장하는 형태예요.
range 의 relation 옵션 은 range 필드 (한 문서가 시작·끝 한 쌍 을 들고 있는 필드) 와 비교할 때만 쓰여요. INTERSECTS·CONTAINS·WITHIN 세 가지를 받아 시간 구간끼리의 포함 관계 를 다뤄요. 예약·렌트·시간표 도메인에서 빛나는 옵션인데, 입문 단계에선 "이런 게 있다" 만 기억해도 충분.
exists — 필드 존재 여부
"이 필드 값이 있는 문서만" 자리예요. SQL 의 IS NOT NULL 에 대응해요.
GET /products/_search
{
"query": {
"exists": {
"field": "discount_price"
}
}
}
주의할 점이 둘 있어요. 첫째 — null 값이거나 빈 배열 인 문서는 exists 가 false 로 잡혀요. 둘째 — 필드 자체가 없는 문서 와 값이 null 인 문서 가 동일하게 잡혀요. 이 둘을 구분하려면 mapping 에서 null_value 를 박아 두면 "null 도 어떤 값으로 색인하라" 로 바꿀 수 있어요.
PUT /products
{
"mappings": {
"properties": {
"discount_price": {
"type": "long",
"null_value": -1
}
}
}
}
이러면 입력이 null 인 문서 도 -1 로 색인 돼서 term 으로 -1 검색 이 가능해져요.
IS NULL 자리 (즉 값이 없는 문서만) 는 bool + must_not + exists 로 표현해요.
GET /products/_search
{
"query": {
"bool": {
"must_not": [
{ "exists": { "field": "discount_price" } }
]
}
}
}
15편(Compound Queries) 에서 다시 다룰 bool 패턴이에요.
prefix·wildcard — 시작 일치·패턴 일치
prefix 는 "이 문자열로 시작하는 토큰" 을 찾아요. 한국에서는 "강남·강동·강서" 같은 동 이름·지역명 자동완성 자리에 자주 쓰여요.
GET /addresses/_search
{
"query": {
"prefix": {
"district": {
"value": "강",
"case_insensitive": false
}
}
}
}
성능은 prefix 가 짧을수록 느려요. 한 글자 prefix 면 인덱스 거의 전체를 훑으니 최소 2~3글자부터 검색 이 일반 패턴. 더 나은 방법은 search_as_you_type 필드 타입을 mapping 에 박는 거예요 — 9편(Field Types) 에서 다뤘듯이 내부적으로 prefix 색인을 미리 만들어 두는 타입이에요.
wildcard 는 * (0개 이상)·? (정확히 1개) 같은 glob 패턴 을 받아요.
GET /products/_search
{
"query": {
"wildcard": {
"sku": {
"value": "ABC-*-2026",
"case_insensitive": false
}
}
}
}
가장 위험한 쿼리 중 하나예요. *ABC* 처럼 leading wildcard (앞쪽 ) 를 쓰면 인덱스 전체 토큰 스캔 이라 수초~수십초 가 나와요. 운영 ES 가 한순간에 load average 폭증 으로 망가지는 가장 흔한 자리예요. 8.x 에서는 wildcard field type (9편) 을 따로 만들어서 leading wildcard 도 빠르게 만든 우회 경로가 있는데, 기본 keyword 필드에선 leading wildcard 금지* 가 안전한 운영 룰이에요.
* 가 0개 이상 매치 라서 * 단독 은 match_all 과 같은 효과가 나요 — 절대 운영에서 던지지 말 것.
fuzzy·regexp·ids·term_vectors — 보조 4종
fuzzy 는 오타 허용 검색 이에요. Levenshtein 편집 거리 (한 글자 추가·삭제·교체를 1단위로 세는 거리) 기반.
GET /products/_search
{
"query": {
"fuzzy": {
"name": {
"value": "코우팡",
"fuzziness": "AUTO",
"prefix_length": 1,
"max_expansions": 50
}
}
}
}
fuzziness 옵션이 가장 중요. AUTO 가 표준 — 글자수에 따라 1~2단위 편집 거리 를 자동으로 적용해요. 3글자 이하 = 0, 4~5글자 = 1, 6글자 이상 = 2 가 기본 규칙이에요. 1 이나 2 같은 상수 도 받을 수 있지만 AUTO 가 거의 항상 더 나은 선택 이에요.
prefix_length 는 앞쪽 N글자는 정확히 일치해야 한다 는 제약 — 성능 보호 장치 예요. 0 으로 두면 인덱스 거의 전체를 훑게 돼서 운영에선 1~2 권장. max_expansions 는 후보 토큰 최대 갯수 — 기본 50.
regexp 는 정규식 매치 예요. Lucene regex 문법 (Java 표준과 약간 다름) 을 받아요.
GET /products/_search
{
"query": {
"regexp": {
"sku": {
"value": "ABC-[0-9]{4}-2026",
"flags": "ALL",
"case_insensitive": false,
"max_determinized_states": 10000
}
}
}
}
max_determinized_states 가 정규식의 폭주를 막는 안전 장치 예요. 복잡한 alternation·중첩 반복자 가 들어오면 DFA 변환 비용이 폭증 해서 수십초~분 단위 응답 이 가능해요. 기본 10,000 — 운영에선 높이지 말고 쿼리를 단순화 하는 게 정답.
ids 는 _id 로만 검색 하는 가장 단순한 쿼리예요.
GET /products/_search
{
"query": {
"ids": {
"values": ["sku-001", "sku-002", "sku-003"]
}
}
}
여러 인덱스에 걸쳐 _id 가 같은 문서 를 한 번에 가져올 때 편해요. 단일 인덱스라면 GET /products/_mget (Multi-Get API) 이 더 효율적이에요.
term_vectors 는 엄밀히 쿼리가 아닌 별도 API 이지만 같은 자리에 자주 등장해서 같이 적어 둡니다.
GET /products/_termvectors/sku-001
{
"fields": ["name"],
"term_statistics": true,
"field_statistics": true
}
어느 문서의 어느 필드가 어떤 토큰으로 색인됐는지 디버깅할 때 쓰는 도구예요. "왜 이 검색이 안 잡히지" 사고에서 문서가 실제로 어떻게 색인됐는지 확인하는 첫 단추 자리.
자주 만나는 사고
사고 1 — term 으로 text 필드 검색해서 0건
가장 흔한 사고예요. text 타입 필드는 analyzer 통과 후 토큰으로 쪼개져 색인 돼 있는데, term 은 원본 그대로 비교 하니까 "하늘색 무선 이어폰" 이 색인된 문서를 그대로 term 검색 하면 0건 이 나와요. 색인된 토큰은 "하늘색·무선·이어폰" 셋으로 쪼개져 있어서요.
해결 — text 필드엔 term 금지, match 또는 match_phrase 사용. 정확 일치가 필요한 자리 는 text 와 함께 text.keyword multi-field 를 만들어 .keyword 서브필드 로 term 검색해요. mapping 표준 패턴이에요.
PUT /products
{
"mappings": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 }
}
}
}
}
}
이러면 name 은 풀텍스트 검색, name.keyword 는 정확 일치·집계·정렬용으로 따로 쓸 수 있어요.
사고 2 — wildcard leading * 로 클러스터 폭주
운영자가 *ABC* 같은 쿼리를 한 번 던지자 load average 가 30 으로 치솟고 다른 검색이 모두 timeout 이 났어요.
해결 — leading wildcard 를 막는 룰 을 검색 게이트웨이에서 거르거나, wildcard 검색이 필요한 필드는 wildcard field type 으로 미리 mapping 해 둬요. 운영 권장은 prefix 검색은 prefix 쿼리·search_as_you_type 으로, 전 문장 부분 매치는 풀텍스트로 분리.
사고 3 — range date format mismatch
mapping 의 created_at 이 yyyy-MM-dd HH:mm:ss 인데 쿼리에서 2026-05-19T00:00:00Z 를 던지면 parse 에러 가 나거나 0건 이 잡혀요.
해결 — range 의 format 옵션으로 그 쿼리 한정 포맷 을 명시. 더 좋은 건 mapping 시 format: "strict_date_optional_time||epoch_millis" 처럼 여러 포맷을 허용 하도록 박아 두는 거예요. ISO-8601 표준만 받는다면 strict_date_optional_time 만으로 OK.
사고 4 — fuzziness 폭주
대량 검색 자리에 fuzziness: 2 를 박았더니 응답이 기존 50ms → 800ms 로 폭증.
해결 — fuzziness: AUTO 로 돌리고 prefix_length: 2 박기. fuzziness 2 + prefix_length 0 조합이 최악의 성능 함정이에요. 오타 허용 자리 자체를 suggester (20편) 로 옮기는 것도 한 선택지.
사고 5 — ids 너무 많이
한 번에 _id 10,000개 를 ids 쿼리로 던졌더니 URL 길이 한도 초과 와 클러스터 메모리 폭증 이 동시에.
해결 — _mget API + body 로 옮기거나, _id 를 어떻게든 다른 키로 묶어 terms_lookup 패턴으로 풀어요. 한 번에 수천 개 이상의 ID 조회 자체가 애플리케이션 설계의 신호 — 캐시·페이지네이션을 다시 보세요.
사고 6 — exists 가 빈 배열을 false 로
mapping 에서 tags: ["array of keyword"] 인 필드에 빈 배열 [] 을 박으면 exists 가 false 로 잡혀요. "태그가 비어 있지만 필드는 있다" 를 "필드 자체가 없다" 와 구분할 방법이 exists 만으로는 없어요.
해결 — 별도 boolean 필드 has_tags 를 함께 색인해 그 필드로 구분. 또는 tags_count 같은 카운트 필드 를 박아 두기.
사고 7 — case_insensitive 의존
term 쿼리에 case_insensitive: true 를 박아 두면 편한데, 대량 검색 에서 내부적으로 토큰을 재가공 해야 해서 전용 인덱스보다 느려요.
해결 — 대량·고QPS 자리는 mapping 시 normalizer: "lowercase" 를 박아 색인 시점에 소문자로 정규화. 이러면 검색·색인 모두 소문자 토큰 만 다뤄서 항상 빠르고 일관 돼요.
운영 권장 패턴
운영에 들어가는 term-level 쿼리는 거의 다음 네 가지 룰을 따르면 사고가 안 나요.
첫째 — 항상 filter 컨텍스트로. term-level 쿼리는 대부분 스코어가 의미 없는 자리예요. 15편(Compound) 에서 다룰 bool.filter 자리에 넣으면 스코어 계산 생략 + 결과 캐시 의 두 가지 이득을 동시에 챙겨요.
GET /products/_search
{
"query": {
"bool": {
"must": [{ "match": { "name": "이어폰" } }],
"filter": [
{ "term": { "status": "published" } },
{ "range": { "price": { "gte": 10000, "lte": 50000 } } }
]
}
}
}
둘째 — keyword·숫자·날짜·ip 만. text 필드에 term-level 을 던지지 마세요. mapping 단계에서 .keyword multi-field 를 미리 박아 두면 99% 의 헷갈림이 사라져요.
셋째 — wildcard·regexp 는 운영 게이트웨이에서 검수. leading wildcard·복잡 regexp 를 막는 애플리케이션 레벨 검증 을 둬요. 사용자 입력을 그대로 쿼리로 만들면 공격용 쿼리 한 줄로 클러스터가 멈출 수 있어요.
넷째 — terms_lookup 으로 큰 IN 풀기. 수백~수만 개의 IN 조건 은 매번 쿼리 본문에 직렬화하지 말고 전용 인덱스의 문서 한 줄 로 묶어 terms_lookup 으로 끌어 써요. VIP·블랙리스트·구독자 자리에 표준.
시험 직전 한 번 더 — 압축 노트
- term-level queries = analyzer 통과 X 정확 일치 검색.
keyword·숫자·날짜·boolean·ip 필드와 한 묶음. - 9가지 핵심: term · terms · terms_set · range · exists · prefix · wildcard · fuzzy · regexp · ids.
- term = 한 값, terms = 배열 (최대 65,536), terms_set = "N개 이상 매치".
- terms_lookup = 다른 인덱스 문서의 배열을 IN 자리에 펼치기. 큰 리스트 표준 패턴.
- range 연산자 = gte·lte·gt·lt. 날짜는
format+time_zone명시. - Date math =
now-7d · now/d · 2026-05-19||+1M— ELK 로그 자리 표준. - exists =
IS NOT NULL. null·빈 배열은 false 로 잡힘.IS NULL은 bool + must_not + exists. - prefix·wildcard = 시작·패턴 매치. leading wildcard 금지 — 클러스터 폭주.
- fuzzy = 편집 거리 기반 오타 허용. fuzziness: AUTO · prefix_length: 1~2 표준.
- regexp = Lucene 정규식.
max_determinized_states가 폭주 방지선. - ids =
_id직접. 단일 인덱스면_mget이 더 효율적. - 7대 사고: text 에 term·leading wildcard·date format·fuzziness 폭주·ids 과다·exists 빈 배열·case_insensitive 의존.
- 운영 룰:
bool.filter컨텍스트 · keyword/숫자/날짜만 · wildcard 검수 · terms_lookup 으로 큰 IN. - 13편(full-text) 과의 차이 한 줄: analyzer 통과 X · 정확 일치 · filter 컨텍스트 · 캐시.
시리즈 다른 편
- 이전 글 = 13편 Full-text Queries — match·multi_match·match_phrase·match_bool_prefix
- 다음 글 = 15편 Compound Queries — bool·boosting·constant_score·dis_max·function_score
- 9편 = Field Types Deep — text·keyword·wildcard·search_as_you_type
- 11편 = Korean Analyzer — Nori·mecab-ko·사용자 사전
- 16편 = Aggregations Metric — sum·avg·percentiles·cardinality
- 19편 = Search Features — search_after·pit·highlighting
- 20편 = Suggesters — term·phrase·completion·context
- 32편 = Spring Data Elasticsearch — Criteria·NativeQuery·term-level 매핑
- 38편 = 시리즈 마무리 — 결정 트리·체크리스트·자격증
한 줄 정리 — Term-level queries = analyzer 통과 X 정확 일치 검색 묶음. bool.filter 컨텍스트 + keyword·숫자·날짜·ip 필드와 한 세트로 굴리면 빠르고 캐시 잘 먹는 필터링 의 표준 자리.