느린 Elasticsearch 쿼리 최적화: 성능 튜닝을 위한 모범 사례

더 나은 쿼리 구조, 페이지네이션, 캐싱, 매핑 및 프로필 API를 사용하여 느린 Elasticsearch 쿼리를 진단하고 개선합니다.

느린 Elasticsearch 쿼리 최적화: 성능 튜닝을 위한 모범 사례

느린 Elasticsearch 쿼리는 일반적으로 네 가지 원인 중 하나에서 발생합니다: 쿼리가 너무 많은 데이터를 요청하거나, 매핑이 쿼리를 비용이 많이 들게 하거나, 클러스터의 리소스가 부족하거나, 애플리케이션이 캐싱되거나 재설계되어야 할 비용이 많이 드는 검색을 반복하는 경우입니다. 해결 방법은 어떤 원인이 해당하는지에 따라 다릅니다.

모든 것을 다시 작성하기 전에 인덱스, 필터, 정렬, 집계, 페이지 깊이, 응답 크기 및 타이밍을 포함한 실제 느린 요청을 캡처하세요. 대시보드 집계, 자동 완성 쿼리 및 내보내기 작업은 모두 Elasticsearch에 다른 방식으로 부하를 줍니다.

쿼리 성능 병목 현상 이해

솔루션에 들어가기 전에 느린 Elasticsearch 쿼리의 일반적인 이유를 이해하는 것이 도움이 됩니다. 여기에는 다음이 포함됩니다:

  • 복잡한 쿼리: 여러 bool 절, 중첩 쿼리 또는 대규모 데이터셋에 대한 wildcard 또는 regexp와 같은 비용이 많이 드는 작업이 포함된 쿼리.
  • 비효율적인 데이터 검색: 불필요하게 _source를 가져오거나 페이지네이션을 위해 많은 수의 문서를 검색하는 경우.
  • 리소스 제약: 데이터 노드의 CPU, 메모리 또는 디스크 I/O 부족.
  • 최적이 아닌 매핑: 잘못된 데이터 유형을 사용하거나 집계를 위해 doc_values를 활용하지 않는 경우.
  • 샤드 불균형 또는 과부하: 너무 많거나 적은 샤드, 또는 샤드/데이터의 불균형한 분포.
  • 캐시 미스 또는 잘못된 캐시 적합성: 요청 캐싱, 필터 컨텍스트 또는 애플리케이션 수준 캐싱을 사용하지 않고 비용이 많이 드는 검색을 반복하는 경우.

쿼리 구조 최적화

쿼리를 구성하는 방식은 성능에 큰 영향을 미칩니다. 작은 변경으로도 상당한 개선을 이끌어낼 수 있습니다.

1. 필요한 필드만 검색 (_source 필터링 및 stored_fields)

기본적으로 Elasticsearch는 일치하는 각 문서에 대해 전체 _source 필드를 반환합니다. 문서가 크고 UI에 제목, ID 및 타임스탬프만 필요한 경우 전체 문서를 가져오면 네트워크 대역폭과 구문 분석 시간이 낭비됩니다.

  • _source 필터링: _source 매개변수를 사용하여 포함하거나 제외할 필드 배열을 지정합니다.

    GET /my-index/_search
    {
      "_source": ["title", "author", "publish_date"],
      "query": {
        "match": {
          "content": "Elasticsearch 성능"
        }
      }
    }
    
  • stored_fields: 매핑에서 특정 필드를 명시적으로 저장한 경우("store": true), stored_fields로 검색할 수 있습니다. 대부분의 배포에서는 이 방식으로 많은 필드를 저장하지 않으므로 _source 필터링이 더 일반적인 수정 방법입니다.

    GET /my-index/_search
    {
      "stored_fields": ["title", "author"],
      "query": {
        "match": {
          "content": "Elasticsearch 성능"
        }
      }
    }
    

2. 효율적인 쿼리 유형 선호

일부 쿼리 유형은 본질적으로 다른 것보다 리소스를 더 많이 사용합니다.

  • 선행 와일드카드 및 광범위한 정규식 피하기: wildcardregexp 쿼리는 특히 *test와 같은 선행 와일드카드가 있는 경우 비용이 많이 들 수 있습니다. prefix 쿼리는 일반적으로 선행 와일드카드 검색보다 관리하기 쉽지만 여전히 적절한 매핑과 제한된 입력이 필요합니다.

    # 비효율적 - 선행 와일드카드 피하기
    {
      "query": {
        "wildcard": {
          "name.keyword": {
            "value": "*search"
          }
        }
      }
    }
    
    # 더 나은 방법 - 접두사를 알고 있는 경우
    {
      "query": {
        "prefix": {
          "name.keyword": {
            "value": "Elastic"
          }
        }
      }
    }
    
  • 구문 의도에 match_phrase 사용: 사용자가 정확한 구문을 검색하는 경우 match_phrase는 여러 개의 관련 없는 match 절보다 그 의도를 더 잘 표현합니다. 항상 더 저렴한 것은 아니지만 단어가 멀리 떨어져 있는 문서를 반환하지 않습니다.

  • 예/아니오 조건에 대한 필터 컨텍스트: 문서가 조건과 일치하는지 여부만 신경 쓰는 경우 해당 조건을 filter 컨텍스트에 넣거나 constant_score를 사용하세요. 이렇게 하면 불필요한 점수 계산 작업을 피하고 캐시에 더 친화적입니다.

    GET /my-index/_search
    {
      "query": {
        "constant_score": {
          "filter": {
            "term": {
              "status": "active"
            }
          }
        }
      }
    }
    

3. 부울 쿼리 최적화

  • 구조화된 제약 조건에 필터 사용: 테넌트 ID, 상태 값, 날짜 범위 및 정확한 태그를 점수가 필요하지 않은 경우 must가 아닌 filter에 넣으세요. Elasticsearch는 내부적으로 절을 재정렬하고 최적화할 수 있으므로 JSON 순서를 주요 성능 도구로 의존하지 마세요.
  • 의도적으로 minimum_should_match 사용: 관련성을 개선하고 광범위한 일치를 줄일 수 있지만 너무 높게 설정하면 유효한 결과가 숨겨질 수 있습니다.

4. 효율적인 페이지네이션 (search_afterscroll)

전통적인 from/size 페이지네이션은 깊은 페이지(예: from: 10000, size: 10)에서 매우 비효율적이 됩니다. Elasticsearch는 각 샤드에서 from + size까지 모든 문서를 검색하고 정렬한 다음 from 문서를 폐기해야 합니다.

  • search_after: 실시간 깊은 페이지네이션의 경우 search_after가 권장됩니다. 이전 페이지의 마지막 문서 정렬 순서를 사용하여 전통적인 데이터베이스의 커서와 유사하게 다음 결과 집합을 찾습니다. 상태 비저장이며 더 잘 확장됩니다.

    # 첫 번째 요청
    GET /my-index/_search
    {
      "size": 10,
      "query": {"match_all": {}},
      "sort": [{"timestamp": "asc"}, {"_id": "asc"}]
    }
    
    # 첫 번째 요청의 마지막 문서 정렬 값을 사용한 후속 요청
    GET /my-index/_search
    {
      "size": 10,
      "query": {"match_all": {}},
      "search_after": [1678886400000, "doc_id_XYZ"],
      "sort": [{"timestamp": "asc"}, {"_id": "asc"}]
    }
    
  • scroll API: 재색인 또는 내보내기와 같은 대규모 데이터셋의 대량 검색에는 scroll이 여전히 유용할 수 있습니다. 최신 Elasticsearch 버전 및 장기 실행 전체 인덱스 스캔의 경우 point-in-time과 search_after도 고려하세요. Scroll은 사용자 대상 실시간 페이지네이션에는 적합하지 않습니다.

5. 집계 최적화

집계는 특히 고유 값이 많은 필드에서 리소스를 많이 사용할 수 있습니다.

  • 집계 사전 계산: 복잡하고 실시간이 아닌 집계는 인덱싱 중이나 일정에 따라 실행하여 결과를 사전 계산하고 별도의 인덱스에 저장하는 것을 고려하세요.
  • doc_values: 집계에 사용되는 필드에 doc_values가 활성화되어 있는지 확인하세요(대부분의 비텍스트 필드에서 기본값). 이를 통해 Elasticsearch는 _source를 로드하지 않고도 집계를 위해 데이터를 효율적으로 로드할 수 있습니다.
  • eager_global_ordinals: terms 집계에 자주 사용되는 keyword 필드의 경우 매핑에서 eager_global_ordinals: true를 설정하면 전역 서수를 미리 구축하여 성능을 향상시킬 수 있습니다. 이는 인덱스 새로 고침 시간에 비용이 발생하지만 쿼리 시간 집계 속도를 높입니다.

캐싱 기술 활용

Elasticsearch는 반복되는 쿼리의 속도를 크게 높일 수 있는 여러 계층의 캐싱을 제공합니다.

1. 노드 쿼리 캐시

  • 메커니즘: 자주 사용되는 bool 쿼리 내 필터 절의 결과를 캐시합니다. 노드 수준의 인메모리 캐시입니다.
  • 효과: 반복되는 필터 절에 가장 효과적입니다. 모든 쿼리에 대해 의존하지 마세요. Elasticsearch가 캐시할 가치가 있는 것을 결정합니다.
  • 구성: 기본적으로 활성화되어 있습니다. indices.queries.cache.size로 크기를 제어할 수 있습니다(기본 힙의 10%).

2. 샤드 요청 캐시

  • 메커니즘: 샤드 수준 검색 결과를 캐시하며, 가장 일반적으로 size=0인 집계 중심 요청에 사용됩니다. 매초 변경되지 않는 데이터에 대한 반복되는 대시보드 쿼리에 매우 적합합니다.

  • 효과: 동일한 요청(집계 포함)이 동일한 매개변수로 반복적으로 실행되는 대시보드 쿼리 또는 분석 애플리케이션에 탁월합니다.

  • 사용 방법: 쿼리에서 "request_cache": true를 사용하여 명시적으로 활성화하세요.

    GET /my-index/_search?request_cache=true
    {
      "size": 0,
      "query": {
        "bool": {
          "filter": [
            {"term": {"status.keyword": "active"}},
            {"range": {"timestamp": {"gte": "now-1h"}}}
          ]
        }
      },
      "aggs": {
        "messages_per_minute": {
          "date_histogram": {
            "field": "timestamp",
            "fixed_interval": "1m"
          }
        }
      }
    }
    
  • 주의사항: 샤드가 새로 고쳐질 때마다(새 문서가 인덱싱되거나 기존 문서가 업데이트될 때) 캐시가 무효화됩니다. 자주 동일한 결과를 반환하는 쿼리에만 유용합니다.

3. 파일 시스템 캐시(OS 수준)

  • 메커니즘: 운영 체제의 파일 시스템 캐시는 중요한 역할을 합니다. Elasticsearch는 자주 액세스하는 인덱스 세그먼트를 캐시하기 위해 이에 크게 의존합니다.
  • 효과: 쿼리 성능에 매우 중요합니다. 인덱스 세그먼트가 RAM에 있으면 디스크 I/O가 완전히 우회되어 쿼리 실행 속도가 훨씬 빨라집니다.
  • 모범 사례: 파일 시스템 캐시에 충분한 RAM을 할당하세요. 일반적인 시작점은 JVM 힙을 시스템 메모리의 약 절반으로 유지하고 일반적인 Elasticsearch 힙 제한을 염두에 둔 다음 워크로드로 검증하는 것입니다.

4. 애플리케이션 수준 캐싱

  • 메커니즘: 자주 요청되는 검색 결과에 대해 애플리케이션 계층(예: Redis, Memcached 또는 인메모리 캐시 사용)에 캐시를 구현합니다.
  • 효과: 반복 요청에 대해 Elasticsearch를 완전히 우회하여 가장 빠른 응답 시간을 제공할 수 있습니다. 정적이거나 느리게 변경되는 검색 결과에 가장 적합합니다.
  • 고려사항: 캐시 무효화 전략이 핵심입니다. 데이터 일관성을 보장하기 위해 신중한 설계가 필요합니다.

병목 현상 식별을 위한 프로필 API 사용

Profile API는 Elasticsearch가 쿼리를 어떻게 실행하고 시간이 어디에 소비되는지 정확히 이해하는 데 매우 유용한 도구입니다. 쿼리 및 집계의 각 구성 요소에 대한 실행 시간을 세분화합니다.

프로필 API 사용 방법

검색 요청 본문에 "profile": true를 추가하기만 하면 됩니다.

GET /my-index/_search
{
  "profile": true,
  "query": {
    "bool": {
      "must": [
        {"match": {"title": "Elasticsearch"}},
        {"term": {"status.keyword": "published"}}
      ],
      "filter": [
        {"range": {"publish_date": {"gte": "2023-01-01"}}}
      ]
    }
  },
  "aggs": {
    "top_authors": {
      "terms": {
        "field": "author.keyword",
        "size": 10
      }
    }
  }
}

프로필 API 결과 해석

응답에는 각 샤드에서의 쿼리 및 집계 실행을 자세히 설명하는 profile 섹션이 포함됩니다. 찾아야 할 주요 지표는 다음과 같습니다:

  • description: 특정 쿼리 또는 집계 구성 요소.
  • time_in_nanos: 이 구성 요소를 실행하는 데 소요된 시간.
  • breakdown: 쿼리의 경우 build_scorer_time, collect_time, set_weight_time, 집계의 경우 reduce_time과 같은 세부 하위 지표.
  • children: 중첩된 구성 요소로, 복잡한 쿼리 내에서 시간이 어떻게 분포되는지 보여줍니다.

예제 해석:

WildcardQuery에 대해 높은 time_in_nanos가 표시되면 이는 쿼리의 비용이 많이 드는 부분임을 확인합니다. collect_time이 높으면 일치 후 문서를 검색하고 처리하는 것이 병목 현상임을 시사하며, 이는 _source 구문 분석 또는 깊은 페이지네이션 때문일 수 있습니다. 집계에서 높은 reduce_time은 최종 병합 단계에서 과부하를 나타낼 수 있습니다.

이러한 지표를 검토하여 가장 많은 리소스를 소비하는 특정 쿼리 절 또는 집계 필드를 정확히 찾아낸 다음 앞서 논의한 최적화 기술을 적용할 수 있습니다.

성능을 위한 일반적인 모범 사례

쿼리별 최적화 외에도 여러 클러스터 전체 및 인덱스 수준 모범 사례가 전체 검색 성능에 기여합니다.

1. 최적의 인덱스 매핑

  • text vs. keyword: 전체 텍스트 검색에는 text를 사용하고 정확한 값 일치, 정렬 및 집계에는 keyword를 사용하세요. 유형이 일치하지 않으면 비효율적인 쿼리가 발생할 수 있습니다.
  • doc_values: 정렬 또는 집계하려는 필드에 doc_values가 활성화되어 있는지 확인하세요. 정렬 및 집계를 지원하는 대부분의 필드 유형(예: keyword, 숫자, 날짜, 부울 및 IP 필드)에서 기본적으로 활성화되어 있습니다. 일반 text 필드는 전체 텍스트 검색용입니다. 정확한 일치 또는 집계가 필요한 경우 keyword 하위 필드를 사용하세요.
  • norms: 문서 길이 정규화가 필요하지 않은 필드(예: ID 필드)에서는 norms를 비활성화하세요("norms": false). 이렇게 하면 디스크 공간이 절약되고 인덱싱 속도가 향상되며, 점수가 없는 쿼리의 쿼리 성능에 미치는 영향은 최소화됩니다.
  • index_options: text 필드의 경우 문서에 용어가 존재하는지만 알면 되는 경우 index_options: docs를 사용하고, 구문 쿼리 및 근접 검색이 필요한 경우 index_options: positions(기본값)를 사용하세요.

2. 클러스터 상태 및 리소스 모니터링

  • 클러스터 상태: 녹색이 목표입니다. 노란색은 하나 이상의 복제본 샤드가 할당되지 않았음을 의미합니다. 검색은 여전히 작동할 수 있지만 복원력이 감소하고 성능이 저하될 수 있습니다. 빨간색은 기본 샤드가 누락되고 일부 데이터를 사용할 수 없음을 의미합니다.
  • 리소스 모니터링: 데이터 노드의 CPU, RAM, 디스크 I/O 및 네트워크 사용량을 정기적으로 모니터링하세요. 이러한 지표의 급증은 종종 느린 쿼리와 관련이 있습니다.
  • JVM 힙: JVM 힙 사용량을 주시하세요. 높은 사용률은 빈번한 가비지 수집 일시 중지로 이어져 쿼리를 느리게 만들 수 있습니다. 힙 압력을 줄이기 위해 쿼리를 최적화하세요.

3. 적절한 샤드 할당

  • 너무 많은 샤드: 각 샤드는 리소스를 소비합니다. 많은 작은 샤드는 오버헤드를 만듭니다. 수십 기가바이트의 샤드가 일반적이지만 적절한 크기는 힙, 쿼리 패턴, 복구 목표 및 하드웨어에 따라 다릅니다.
  • 너무 적은 샤드: 병렬 처리를 제한합니다. 샤드가 너무 적은 인덱스에 대한 쿼리는 사용 가능한 모든 데이터 노드를 효율적으로 활용할 수 없습니다.

4. 인덱싱 전략

  • 새로 고침 간격: 낮은 refresh_interval(기본값 1초)은 데이터를 더 빨리 볼 수 있게 하지만 인덱싱 오버헤드를 증가시킵니다. 검색 중심 워크로드의 경우 새로 고침 압력을 줄이기 위해 간격을 약간 늘리는 것(예: 5-10초)을 고려하세요.

실용적인 워크플로는 간단합니다: 실제 느린 쿼리를 찾고, 프로파일링하고, 쿼리가 접촉하는 데이터 양을 줄이고, 사용자가 검색하는 방식과 일치하도록 매핑을 만듭니다. 쿼리가 이미 깨끗하다면 샤드 레이아웃, 힙 압력, 파일 시스템 캐시 및 디스크 I/O를 살펴보세요. Elasticsearch는 인덱스 설계, 쿼리 형태 및 클러스터 리소스가 서로 일치할 때 빠릅니다.