Elasticsearch 입문 12편 — Search API 기본 (_search·query·size·from·sort·_source)

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

Elasticsearch 입문 12편 Search API 기본. _search·query·size·from·sort·_source·track_total_hits 함정.

📚 Elasticsearch 입문에서 운영까지 · 12편 — Search API 기본 (_search·query·size·from·sort·_source)

이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 12편이에요. 11편(Korean Analyzer) 까지 데이터를 어떻게 색인하느냐 자리를 마쳤다면, 이번 12편부터는 그렇게 색인된 데이터를 어떻게 꺼내 쓰느냐 — 즉 검색 API 의 입구로 들어갑니다.

📚 학습 노트

이 글은 Elasticsearch 8.x 공식 docs 의 *Search APIs* 섹션을 한국어 학습 노트로 풀어쓴 자료예요. 13편(Full-text Queries) · 14편(Term-level Queries) · 15편(Compound Queries) 의 *공통 입구* 가 이 글이에요.

로컬에 8편(Mapping Deep) 에서 만든 인덱스를 띄워 두고 `curl` 또는 Kibana Dev Tools 로 한두 번 같이 쳐 보면 머리에 잘 박혀요.

검색 API 의 입구

Elasticsearch 가 검색 엔진 이라는 정체를 가장 또렷하게 드러내는 자리가 _search API 예요. 색인·맵핑·analyzer 같은 준비 자리 가 아무리 잘 되어 있어도, 이 한 줄을 못 다루면 데이터는 들어가 있는데 꺼낼 줄을 모르는 상태가 돼요.

다행히 _search기본 표면 은 그렇게 크지 않아요. endpoint · query · size · from · sort · _source 여섯 단어만 잡으면 90% 의 일상 검색이 풀려요. 이 글은 이 여섯 단어를 한 호흡에 모아 두는 글이에요. 깊은 query DSL 은 13~18편에서 다루고, 여기서는 모든 검색 요청이 공통으로 깔고 가는 뼈대 만 잡습니다.

검색 응답 한 덩어리는 항상 같은 형태로 돌아와요 — hits.total.value (몇 건 매칭됐는지) · hits.max_score (최고 점수) · hits.hits (실제 문서 배열) · took (응답에 걸린 ms). 이 형태가 머리에 박혀 있으면 어떤 query 가 들어가도 응답을 읽기가 쉬워져요.

_search endpoint

가장 기본 형태는 GET /<index>/_search 예요. 인덱스 자리에 * (모든 인덱스) · logs-* (와일드카드) · _all (전체) · index-a,index-b (쉼표 나열) 도 들어갈 수 있어서, 여러 인덱스를 한 번에 검색 하는 것도 같은 endpoint 에서 풀려요.

ES 의 _search두 가지 호출 방식 을 지원해요. URI 검색쿼리 파라미터 로 검색어를 넘기는 단순 형태고, Request Body 검색JSON body 로 query DSL 을 통째로 넘기는 일반 형태예요. 일상 운영에서는 거의 항상 후자.

URI 검색은 이렇게 생겼어요 — GET /products/_search?q=field:value&size=10 . 짧고 빠르지만 표현력이 약해서 임시 디버깅 자리에만 어울려요. 실 서비스 코드에서 보이면 왜 body 검색이 아닌가 한 번은 의심하는 게 좋아요.

Body 검색은 이렇게 — GET /products/_search 와 함께 JSON body 를 보내요.

GET /products/_search
{
  "query": { "match": { "title": "맥북" } },
  "size": 20,
  "from": 0,
  "sort": [{ "price": "asc" }],
  "_source": ["title", "price"]
}

content type 은 application/json 이 표준이에요. curl 로 칠 때 -H 'Content-Type: application/json' 빠뜨리면 406 Not Acceptable 가 떨어져요. Kibana Dev Tools 는 이걸 자동으로 넣어 주니까 학습 단계에서는 Dev Tools 가 편해요.

HTTP 메서드는 GET · POST 둘 다 받아요. RESTful 원칙 으로 보면 GET 이 맞지만, 일부 HTTP 클라이언트 (예전 브라우저·일부 프록시) 가 GET 에 body 를 안 실어 보내는 문제 때문에 실 서비스 클라이언트는 POST 로 검색 하는 경우도 흔해요. ES 는 이걸 둘 다 동등하게 처리해요.

query 절 — 모든 검색의 중앙

검색 요청 body 의 심장query 키예요. 이 안에 들어가는 객체가 Query DSL 이고, 13~18편 일곱 편이 통째로 이 안에 들어가는 문법 을 다루는 시리즈예요.

가장 단순한 형태가 match_all조건 없이 전부 가져오는 쿼리예요. 디버깅·학습 단계에서 인덱스에 데이터가 들어가 있긴 한지 확인할 때 거의 항상 첫 줄에 쳐 보는 쿼리.

GET /products/_search
{
  "query": { "match_all": {} }
}

match_all 의 짝이 match_none아무 것도 안 가져오는 쿼리예요. 조건부 검색을 일부 분기에서 비활성화 할 때 쓰는 자리.

query 자리에 들어갈 수 있는 최상위 query 종류 가 크게 네 갈래예요.

  • Full-text queriesmatch · match_phrase · multi_match · query_string 등. 13편에서 깊이.
  • Term-level queriesterm · terms · range · exists · wildcard 등. 14편에서 깊이.
  • Compound queriesbool · constant_score · dis_max · boosting 등. 15편에서 깊이.
  • Specialized queriesgeo_distance · nested · join · script_score · knn 등. 19~22편에서 깊이.

이 글에서는 query 안에 들어가는 객체가 곧 Query DSL 이다 한 자리만 잡고, 깊이는 뒤편에 모두 미루겠습니다.

size + from — 페이징 기본

size한 응답에 몇 건 돌려줄지 지정하는 자리예요. 기본값이 10 이고, 명시 안 하면 어떤 검색이든 상위 10건 만 돌려줘요. 이게 처음 ES 만진 사람이 가장 자주 놓치는 자리데이터가 100건 있는데 왜 10건만 보이지? 의 정체.

from몇 번째 결과부터 잘라 줄지예요. 기본값이 0 이고, 0-based offset 이에요. 2페이지 (size=10 기준) 를 가져오려면 from: 10, size: 10 이 돼요.

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

위 요청은 21번째 ~ 30번째 결과 를 돌려줘요. 일반 페이지네이션 UX 가 이 패턴.

여기서 결정적 함정 이 하나 있어요 — from + size <= 10,000 제약. 이걸 넘어가면 circuit breaker 에 걸려 Result window is too large 에러가 떨어져요. 정확하게는 인덱스 설정 index.max_result_window 의 기본값이 10,000 이라서 그래요.

왜 이 제약이 있냐 — ES 는 분산 검색 이에요. 1만 페이지째를 가져오려면 모든 샤드에서 1만 건씩 정렬 한 다음 coordinating node 가 다시 합쳐서 1만 건 뽑아 그 중 마지막 10건을 돌려줘야 해요. 메모리·CPU 가 비선형으로 폭증해서 클러스터 전체가 위험 해져요.

해결은 두 갈래예요 — search_after (커서 기반 다음 페이지) 또는 scroll (스냅샷 기반 일괄 추출). 19편(Search Features) 에서 깊이 들어가니까 여기서는 from + size 는 10,000 까지가 안전선 만 기억해 두면 충분.

sort — 정렬

sort 가 없으면 ES 는 기본적으로 _score (관련도 점수) 내림차순 으로 결과를 돌려줘요. match query 처럼 점수가 있는 쿼리에서는 이 기본값이 자연스럽지만, match_all 이나 term query 같이 점수가 의미 없는 자리에서는 임의 순서 처럼 보여요.

명시적 정렬은 배열로 넘겨요 — 첫 항목이 1차 기준, 둘째 항목이 동률일 때 2차 기준.

GET /products/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "category": "asc" },
    { "price": "desc" },
    { "created_at": { "order": "desc", "missing": "_last" } }
  ]
}

missing 옵션은 그 필드가 없는 문서 를 어디 둘지예요 — _last (뒤로) · _first (앞으로) 두 값을 받아요. 기본은 asc 면 _last · desc 면 _first — 의외로 헷갈리는 자리.

여기서 가장 큰 사고가 text 필드를 직접 정렬 대상으로 잡는 자리 예요. ES 는 형태소 분석된 text 필드 를 직접 정렬하려면 fielddata 를 메모리에 올려야 해서 비용이 엄청 커요. 그래서 기본값으로 fielddata 가 비활성 이고, Fielddata is disabled 에러가 떨어져요.

해결은 Multi-field 패턴이에요. titletext 로 색인하면서 동시에 title.keyword 로 정렬용 keyword 서브필드를 박아 두면, 정렬은 title.keyword 로 쓰면 됩니다. 8편(Mapping Deep) 의 핵심 패턴.

"sort": [{ "title.keyword": "asc" }]

특수 정렬 키 두 개도 알아 두면 좋아요. _score관련도 점수 정렬, _doc문서 색인 순서가장 싼 정렬이에요. 페이징 안 하고 모든 문서 를 단순 훑는 자리에선 sort: ["_doc"] 이 최적.

_source — 결과에서 어떤 필드 받을지

검색 응답의 실제 문서 내용 이 들어 있는 자리가 _source 예요. 기본값은 문서 전체 를 그대로 돌려주는 것 — 이게 e-commerce 1MB 상품 1만 건 페이지 같은 자리에서 대역폭 폭증 의 주범이 돼요.

_source 옵션으로 어떤 필드만 받을지 좁힐 수 있어요.

GET /products/_search
{
  "query": { "match_all": {} },
  "_source": ["title", "price"]
}

이렇게 하면 응답에 title · price 만 들어와요. 더 정교하게는 includes · excludes 패턴 — 받을 필드 · 제외할 필드 를 따로 지정.

"_source": {
  "includes": ["title", "price", "category.*"],
  "excludes": ["description", "internal.*"]
}

극단으로 문서 본문은 필요 없고 메타만 있으면 되는 자리도 있어요 — 그때는 "_source": false 로 끄면 됩니다. 응답에 _id · _index · _score 같은 메타 만 들어와서 최저 비용 검색 이 돼요. count 비슷한 자리·다음 페이지 커서만 뽑는 자리 에 어울려요.

와일드카드도 받아요 — "user.*" 라고 쓰면 user.id · user.name · user.profile.avatar 같은 user 로 시작하는 모든 필드 가 다 들어와요. JSON 중첩 구조에서 한 블록만 받을 때 편해요.

stored_fields, fields, runtime_fields — 추가 데이터 가져오기 옵션

_source 만으로는 모자란 자리가 있어요. 세 가지 보완 옵션을 정리.

stored_fields — 매핑 단계에서 "store": true 로 따로 저장해 둔 필드만 돌려받아요. _source 를 비활성화 한 인덱스에서나 의미가 있고, 일반 운영에서는 거의 안 써요. 옛날 ES 1.x 때 유행했던 패턴 의 잔재.

fields — 7.10+ 에서 추가된 표준 옵션이에요. 매핑된 필드 값을 응답 형식에 맞춰 돌려주는 자리예요. 예를 들어 date 필드를 ISO 문자열로 또는 geo_point{lat, lon} 객체로 같은 변환 후 값 을 받아요. runtime_fields 와 짝.

GET /products/_search
{
  "query": { "match_all": {} },
  "fields": [
    "title",
    { "field": "created_at", "format": "yyyy-MM-dd" }
  ],
  "_source": false
}

runtime_fields색인 시점이 아니라 검색 시점에 정의되는 가상 필드예요. 검색할 때 Painless 스크립트온더플라이 값을 만들어서 쓰는 자리. 재색인 없이 새 필드를 임시로 추가할 수 있어서 과거 데이터 분석·임시 분류 자리에 어울려요.

GET /products/_search
{
  "runtime_mappings": {
    "price_krw_10k": {
      "type": "long",
      "script": "emit(doc['price'].value / 10000)"
    }
  },
  "query": { "range": { "price_krw_10k": { "gte": 100 } } },
  "fields": ["price_krw_10k"]
}

세 옵션 중 일상 운영에서 자주 보는 건 fields 하나예요. stored_fields 는 거의 안 쓰고, runtime_fields재색인이 부담 일 때만 한 번씩 꺼내 써요.

track_total_hits — 가장 헷갈리는 함정

응답에 들어오는 hits.total 값을 ES 7.0+ 부터는 기본 10,000 까지만 정확히 세 줘요. 그 이상이면 {"value": 10000, "relation": "gte"} (10,000 이상이라는 뜻만) 으로 돌려요. 왜 갑자기 10,000 에서 멈추지? 의 정체.

이 동작이 정확히 track_total_hits 파라미터예요. 기본값은 10000 (Boolean 처럼 보이지만 숫자 도 받음). 정확한 전체 건수 가 필요하면 true 로 켜야 해요.

GET /products/_search
{
  "track_total_hits": true,
  "query": { "match_all": {} }
}

켜면 응답에 {"value": 1234567, "relation": "eq"} 같이 정확 건수 가 들어와요. 단 — 모든 매칭 문서를 끝까지 세느라 응답 비용이 커져요. 페이지네이션 UI 에 "전체 N 건" 을 정확히 표시할 때만 켜는 게 표준이에요.

무한 스크롤 · 다음 페이지 커서 UX 라면 전체 건수가 필요 없으니 기본값 그대로 두는 게 빨라요. 신규 서비스는 상위 1만 건만 보여주는 UX 로 정하는 곳이 많아서, 이 함정을 UX 단에서 피해 가는 패턴이 일반.

자주 만나는 사고

사고 1 — from 폭증

원인 — 페이지네이션에 from + size 를 그대로 쓰다가 1만 페이지째 사용자가 들어오면 Result window is too large 에러로 502 폭증.

해결 — UX 자체를 10,000 건 안에서만 페이징 으로 제한하거나, 무한 스크롤 로 바꿔서 search_after 를 사용. 19편(Search Features) 에서 깊이.

사고 2 — sort 시 fielddata error

원인text 필드를 정렬 키 로 그대로 쓰면 Fielddata is disabled on text fields by default 에러.

해결 — 매핑에 Multi-fieldfield_name.keyword 서브필드를 박고 정렬은 keyword 로. 운영에서 fielddata 직접 활성화는 거의 안 함 — 메모리 폭증의 직행 코스.

사고 3 — _source 전체 가져오기 폭주

원인 — 검색 결과 1페이지 당 1MB 상품 10건 을 전부 _source 째 받아서 네트워크 대역폭DB 쿼리보다 더 비싼 자리가 됨.

해결list 화면목록 카드용 필드 (title · price · thumbnail) 만, 상세 화면전체 _source두 가지 검색 패턴 으로 분리. _source: ["title", "price", "thumbnail"] 한 줄이 즉효.

사고 4 — timeout 미설정

원인 — 무거운 집계 검색이 수십 초간 워커 쓰레드를 잡고 있어서 다른 요청까지 큐에 밀려 응답 폭증.

해결"timeout": "1s" 같이 명시적 timeout 을 박아요. 시간 안에 끝나지 않으면 부분 결과 를 돌려주고 timed_out: true 가 응답에 들어옵니다. 완전 거절보다 부분 응답 이 운영에는 더 어울리는 자리가 많아요.

사고 5 — track_total_hits 함정

원인"검색 결과가 1만 건 이상인 자리에서 정확 건수가 안 나옵니다" 라는 사용자 제보. 응답을 까 보면 total.relation: "gte".

해결 — 그 화면이 전체 건수가 정말 중요 한지부터 확인 → 맞으면 track_total_hits: true · 아니면 UX 카피를 "10,000+ 건" 으로 변경.

사고 6 — POST vs GET

원인 — 일부 HTTP 클라이언트·프록시가 GET 요청에 body 안 실어 보내는 문제로 body 가 비어서 match_all 처럼 동작 하는 버그.

해결 — 실 서비스 코드는 POST /index/_search 로 표준화. ES 가 둘 다 동등 처리.

사고 7 — 다중 인덱스 검색의 매핑 충돌

원인logs-* 처럼 와일드카드로 여러 인덱스를 한 번에 검색하다가 어느 인덱스의 level 은 keyword · 다른 인덱스는 long 으로 잡혀 있어서 number_format_exception.

해결"ignore_unmapped": true 옵션 또는 runtime_fields 로 타입 통일. 운영 직전에 매핑 통일 이 정도.

운영 권장 패턴

검색 코드는 항상 명시적 size · from · sort · _source · timeout 다섯 개를 박아 두는 게 안전선이에요. 기본값에 의존하면 어느 날 ES 버전 올라가면서 기본값이 바뀌어 깨지는 자리가 생겨요.

페이지네이션 UX 는 처음부터 10,000 건 이내 또는 search_after 무한 스크롤 둘 중 하나로 결정해서 잡아 두는 게 좋아요. 중간에 바꾸려고 하면 프론트·백·UX 카피 가 한꺼번에 흔들려요.

_source목록 검색 · 상세 검색 두 가지로 반드시 나눠요. 한 함수에서 둘 다 처리하려고 하면 목록도 1MB · 상세도 1MB 가 되거나 상세에 정작 필요한 필드가 빠짐 둘 중 하나로 흘러요.

track_total_hits기본값 그대로 두는 게 안전선이에요. 전체 건수가 진짜로 화면에 표시되는 곳 만 명시적으로 켜고, 나머지는 그대로.

검색 요청은 반드시 Kibana Dev Tools 또는 curl 로 같은 요청을 직접 쳐 볼 수 있는 형태로 코드에 남겨 두세요. 재현 가능한 검색디버깅 시간을 절반 으로 줄여요.

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

  • _search = 검색 API endpoint. GET/POST /<index>/_search 둘 다 동작.
  • URI 검색 = ?q=field:value 형식, 디버깅 자리. Body 검색 = JSON body 표준.
  • query = 검색 조건 중앙. 안에 match_all · match · term · bool · range · knn 등 Query DSL.
  • size = 응답 건수, 기본 10. from = offset, 기본 0.
  • from + size <= 10,000 제약 — index.max_result_window 기본값. 넘기면 search_after · scroll.
  • sort — 기본 _score desc. 배열로 다중 정렬. missing 옵션.
  • _doc = 가장 싼 정렬 키, 모든 문서 단순 훑기 자리.
  • text 필드 직접 정렬은 fielddata errorMulti-field.keyword 서브필드 박아 정렬.
  • _source — 응답 본문 필드. includes/excludes · false (메타만) · 와일드카드.
  • stored_fields = 거의 안 씀. fields = 7.10+ 표준, 변환된 값. runtime_fields = 검색 시점 가상 필드.
  • track_total_hits — 기본 10,000 까지만 정확히. 이상은 relation: gte. 정확 건수는 true 로 켬 (비용 증가).
  • timeout — 명시 권장. 시간 초과 시 부분 응답 + timed_out: true.
  • 다중 인덱스 검색은 * · 쉼표 나열 가능, ignore_unmapped: true 로 매핑 충돌 회피.
  • 7대 사고 — from 폭증 · sort fielddata · _source 폭주 · timeout 미설정 · track_total_hits 함정 · POST/GET body · 매핑 충돌.

시리즈 다른 편

  • 이전 글 = 11편 Korean Analyzer — Nori·mecab-ko·사용자 사전
  • 다음 글 = 13편 Full-text Queries — match·match_phrase·multi_match·query_string
  • 14편 = Term-level Queries — term·terms·range·exists·wildcard
  • 15편 = Compound Queries — bool·constant_score·dis_max·boosting
  • 16편 = Aggregation Metric — sum·avg·stats·percentiles·cardinality
  • 17편 = Aggregation Bucket — terms·date_histogram·range·filter
  • 19편 = Search Features — search_after·scroll·PIT·highlight
  • 21편 = Vector Search — dense_vector·kNN·sparse_vector
  • 32편 = Spring Data Elasticsearch — Repository·Template·POJO

한 줄 정리_search = ES 검색의 입구. query · size · from · sort · _source · timeout · track_total_hits 일곱 단어가 일상 검색의 90%. 10,000 페이징 한계와 text 정렬 fielddata 함정만 피하면 안전선 안.

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

답글 남기기

error: Content is protected !!