<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Gustavo Llermaly - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Gustavo Llermaly - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/kr/search-labs/author/gustavo-llermaly</link>
    </image>
    <link>https://www.elastic.co/kr/search-labs/author/gustavo-llermaly</link>
    <atom:link href="https://www.elastic.co/kr/search-labs/rss/author/gustavo-llermaly.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[kr]]></language>
    <lastBuildDate>Wed, 23 Sep 2026 00:49:26 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Elastic Workflows를 활용한 Kibana 대시보드 조회수 모니터링]]></title>
    <description><![CDATA[Elastic Workflows를 사용하여 30분마다 Kibana 대시보드 조회 지표를 수집하고 이를 Elasticsearch에 인덱싱하는 방법을 알아보세요. 수집된 데이터를 바탕으로 맞춤형 분석 및 시각화 기능을 직접 구축할 수 있습니다.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/kibana">Kibana</a>는 각 대시보드의 조회수를 추적하지만 해당 데이터는 기본으로 제공되는 대시보드 내에서 직접적으로 확인할 수는 없습니다. 이 글에서는 <strong>Elastic Workflows</strong>를 사용하여 30분마다 해당 데이터를 자동으로 수집하고 Elasticsearch에 인덱싱하고 이를 바탕으로 직접 분석 환경을 구축하는 방법을 알아보겠습니다.</p><p><a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a>는 Kibana에 내장된 자동화 엔진입니다. 간단한 YAML 설정을 통해 여러 단계로 구성된 프로세스를 정의할 수 있습니다. 각 워크플로우는 일정 또는 이벤트에 따라 트리거될 수 있으며 <a href="https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder">Elastic Agent Builder</a>의 도구로 활용할 수 있습니다. 또한 각 단계에서 Kibana API 호출, Elasticsearch 쿼리 실행, 데이터 변환 작업을 수행할 수 있습니다.</p><p>여기서는 대시보드 조회수를 구체적인 예로 사용하지만 Kibana saved objects API를 통해 노출되는 모든 메트릭에 동일한 패턴을 적용할 수 있습니다.</p><h2>필수 구성 요소</h2><ul><li><p>9.3 버전이 실행 중인 <a href="https://www.elastic.co/cloud">Elastic Cloud</a> 또는 <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed">자체 관리형</a> 클러스터</p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/workflows/get-started#workflows-prerequisites">워크플로우 기능 활성화</a>(고급 설정)</p></li></ul><h2>1단계: <a href="https://www.elastic.co/docs/explore-analyze/query-filter/tools/console">Dev Tools</a>에서 원시 데이터를 탐색하기</h2><p>본격적인 구축에 앞서 우리가 활용할 데이터가 어떤 구성을 갖추고 있는지 먼저 이해해 봅시다. Kibana는 대부분의 설정 정보와 메타데이터를 전용 내부 인덱스에 <a href="https://www.elastic.co/docs/api/doc/kibana/group/endpoint-saved-objects">Saved Objects</a>라는 형태로 저장합니다. Kibana가 이러한 방식으로 추적하는 항목 중 하나는 대시보드 조회수입니다. 이때 'Usage Counters'라는 특수한 유형의 Saved Object를 사용하게 됩니다. 다음과 같이 Dev Tools에서 직접 쿼리할 수 있습니다.</p>GET kbn:/api/saved_objects/_find?type=usage-counter&amp;filter=usage-counter.attributes.domainId:"dashboard"%20and%20usage-counter.attributes.counterType:"viewed"&amp;per_page=10000<p>응답은 다음과 같습니다.</p>{
  "page": 1,
  "per_page": 10000,
  "total": 1,
  "saved_objects": [
    {
      "type": "usage-counter",
      "id": "dashboard:346f3c64-ebca-484d-9d57-ec600067d596:viewed:server:20260310",
      "attributes": {
        "domainId": "dashboard",
        "counterName": "346f3c64-ebca-484d-9d57-ec600067d596",
        "counterType": "viewed",
        "source": "server",
        "count": 1
      },
      ...
    }
  ]<p><code>counterName</code> 필드는 대시보드의 ID를 나타내며 <code>count</code>는 해당 특정 날짜의 대시보드 누적 조회수입니다. Kibana는 대시보드당 하루에 하나의 카운터 객체를 생성합니다. 객체 ID의 날짜 접미사(예: ...viewed:server:20260310)를 통해 이를 확인할 수 있습니다. 조회수는 사용자가 대시보드를 열 때마다 하루 동안 계속해서 증가합니다.</p><p>인덱스에 이러한 일일 문서 모델을 그대로 복제하는 대신 워크플로우 실행당 하나의 문서를 생성해 보겠습니다. 각 문서는 캡처 시점을 기준으로 해당 대시보드의 당일 누적 조회수를 기록합니다.</p><h2>2단계: 대상 인덱스 생성</h2><p>대시보드 조회 스냅샷을 저장할 인덱스가 필요합니다. 나중에 집계와 시각화가 가능하도록 명시적 매핑을 사용하여 다음과 같이 인덱스를 생성합니다. Dev Tools에서 다음 명령을 실행하세요.</p>PUT dashboard-views
{
  "mappings": {
    "properties": {
      "captured_at": {
        "type": "date"
      },
      "dashboard_id": {
        "type": "keyword"
      },
      "dashboard_name": {
        "type": "keyword"
      },
      "view_count": {
        "type": "integer"
      }
    }
  }
}<p>ID와 이름에 <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword"><code>keyword</code></a> 매핑을 사용하면 <a href="https://www.elastic.co/docs/explore-analyze/query-filter/aggregations">집계</a>를 수행할 수 있습니다. <code>view_count</code>에 <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/number"><code>integer</code></a> 타입을 사용하는 것은 안전한 기본값입니다. Kibana는 카운터를 매일 초기화하므로 32비트 제한(하루 20억 회 이상의 조회수)에 도달하는 것은 현실적인 우려 사항이 아니기 때문입니다. 이 타입은 여전히 <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-max-aggregation"><code>max</code></a>, <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-avg-aggregation"><code>avg</code></a>, <a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-min-aggregation"><code>min</code></a>과 같은 수치 연산을 지원합니다.</p><h2>3단계: 워크플로우 생성</h2><p><strong>Stack Management &gt; Workflows &gt; New Workflow</strong> 메뉴로 이동한 뒤 아래의 워크플로우 YAML 설정 내용을 복사하여 붙여넣으세요.</p>name: dashboard-views-ingestion
triggers:
  - type: scheduled
    with:
      every: 30m

steps:
  - name: fetch_dashboard_views
    type: kibana.request
    with:
      method: GET
      path: &gt;-
        /api/saved_objects/_find?type=usage-counter&amp;per_page=10000&amp;filter=usage-counter.attributes.domainId:"dashboard"%20and%20usage-counter.attributes.counterType:"viewed"

  - name: index_each_dashboard
    type: foreach
    foreach: "{{ steps.fetch_dashboard_views.output.saved_objects }}"
    steps:
      - name: fetch_dashboard_name
        type: kibana.request
        with:
          method: GET
          path: /api/saved_objects/dashboard/{{ foreach.item.attributes.counterName }}
        on-failure:
          continue: true

      - name: index_doc
        type: elasticsearch.request
        with:
          method: POST
          path: /dashboard-views/_doc
          body:
            dashboard_id: "{{ foreach.item.attributes.counterName }}"
            dashboard_name: "{{ steps.fetch_dashboard_name.output.attributes.title }}"
            view_count: "${{ foreach.item.attributes.count | plus: 0 }}"
            captured_at: "{{ execution.startedAt | date: '%Y-%m-%dT%H:%M:%SZ' }}"<p>다음 섹션에서는 워크플로를 단계별로 자세히 살펴봅니다.</p><h3>워크플로우 작동 방식</h3><h4>트리거</h4><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7672aa533b4bc9ed/6a17dc5b420229d07c29f4d4/5670991d65c64ee833924225c2d375a1be868b13-325x162.png" alt=" 예약된 트리거" /><p>워크플로우는 30분 간격의 예약된 트리거에 따라 실행됩니다. 이를 통해 API에 무리를 주지 않으면서도 시계열 데이터를 확보할 수 있습니다.</p><h4>fetch_dashboard_views</h4><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltab2a16f1f11304ea/6a17dc5d25daab26f608a117/66eaec147c3d01c524c67cf1c7f663ac56a3259d-812x215.png" alt=" 대시보드 가져오기" /><p><code>kibana.request</code>를 사용하여 Kibana Saved Objects API를 호출합니다. 별도의 인증 설정은 필요하지 않습니다. 워크플로우 엔진이 실행 컨텍스트에 따라 적절한 헤더를 자동으로 첨부하기 때문입니다.</p><h4>index_each_dashboard (foreach)</h4><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte6b2611b0216555e/6a17dc5f445de95b584cffe5/aad45e8aed8dc81ded6260cd6199ff78dcffe3b4-1892x290.png" alt="각 대시보드 인덱싱하기" /><p>이전 단계에서 반환된 <a href="https://www.elastic.co/docs/api/doc/kibana/group/endpoint-saved-objects"><code>saved_objects</code></a> 배열을 순회합니다. 각 반복에서 현재 항목은 <code>foreach.item</code>을 통해 접근할 수 있습니다. 루프 내부에서는 각 대시보드에 대해 두 개의 하위 단계를 실행합니다.</p><p><strong>1. </strong><strong><code>fetch_dashboard_name</code></strong><strong>:</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb3733b3c24629abf/6a17dc60a292993fe3d02b75/db21ec5094b743018b9cd66c5052681f14c7d7e3-1999x431.png" alt=": 대시보드 이름 가져오기" /><p><code>GET /api/saved_objects/dashboard/{id}</code>를 호출하여 사용자가 읽을 수 있는 대시보드 제목을 확인합니다. <code>on-failure: continue: true</code>설정을 추가합니다. 이를 통해 특정 대시보드가 삭제되었더라도 조회수 카운터가 남아있는 경우 전체 실행이 실패 처리되지 않고 루프가 계속 지속됩니다.</p><p><strong>2. </strong><strong><code>index_doc</code></strong><strong>:</strong></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt385c1f29d717c280/6a17dc62faa91353cb93c759/f49dd0c9f0817bb1e1e5d9f4a2b05d13ef331054-1999x626.png" alt=" Elasticsearch 요청" /><p><code>POST /dashboard-views/_doc</code>을 사용해 명시적 ID 없이 각 문서를 인덱싱하며 이 과정에서 Elasticsearch가 ID를 자동으로 생성합니다. 이는 실행할 때마다 새로운 문서를 생성합니다. 이전 스냅샷을 덮어쓰지 않고 시간에 따른 조회수 이력을 쌓을 수 있습니다.</p><p>주목할 만한 두 가지 사항이 있습니다:</p><ul><li><p><code>captured_at</code> 필드는 날짜 필터를 사용하여 타임스탬프를 <a href="https://www.iso.org/iso-8601-date-and-time-format.html">ISO 8601</a> 형식으로 변환합니다. 이 설정이 없으면 값이 <code>Tue Mar 10 2026 05:03:47 GMT+0000</code>과 같은 JavaScript 날짜 문자열로 출력됩니다. 이 경우 Elasticsearch가 이를 날짜 형식으로 매핑하지 못합니다.</p></li><li><p><code>view_count</code>는 숫자 유형을 유지하기 위해 <code>${{ }}</code> 구문과 <code>| plus: 0</code>을 사용합니다. 단순히 <code>{{ }}</code>를 사용하면 값이 문자열로 렌더링되어 대시보드에서 수치 연산을 수행할 수 없게 됩니다.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3b94bb6c22c0253e/6a17dc6425daab37a508a11b/6d48c8784d5df6192e8b5175e69dbab5098194bc-919x774.png" alt="" /><p><em>UI를 통해 워크플로의 각 단계를 편리하게 디버깅할 수 있습니다.</em></p><h2>4단계: 통계 대시보드 구축</h2><p>워크플로우가 몇 번 실행되어 데이터가 수집되면 dashboard-views 데이터 뷰를 사용하여 Kibana에서 새 대시보드를 만듭니다.</p><p>활용 가능한 패널 예시:</p><ul><li><p><strong>조회수별 주요 대시보드:</strong> X축에 <code>dashboard_name</code>을, Y축에 <code>last_value(view_count)</code>를 설정한 <a href="https://www.elastic.co/docs/explore-analyze/visualize/charts/bar-charts"><strong>막대 차트</strong></a>를 사용하세요. 이를 통해 각 대시보드의 현재 일일 조회수를 확인할 수 있습니다.</p></li><li><p><strong>시간에 따른 조회수 변화:</strong> X축에 <code>captured_at</code>을, Y축에 <code>last_value(view_count)</code>를 설정하고 <code>dashboard_name</code>으로 구분한 <a href="https://www.elastic.co/docs/explore-analyze/visualize/charts/line-charts"><strong>선형 차트</strong></a>를 사용하세요. 워크플로우를 실행할 때마다 새로운 문서가 추가되므로 중복 합산 대신 'Last value' 집계를 사용하여 시간 버킷당 최종 수치를 가져옵니다.</p></li><li><p><strong>현재 스냅샷:</strong> 가장 최근의 <code>captured_at</code> 데이터를 사용하는 <a href="https://www.elastic.co/docs/explore-analyze/visualize/charts/tables"><strong>데이터 테이블</strong></a>을 만들어 모든 대시보드의 최신 조회수를 표시하세요.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt18d0390e0526b215/6a17dc65a292991da7d02b79/e245b95f67daf76a2aaf4cb9df2c75ef4cfef582-1462x747.png" alt="" /><p>각 워크플로우 실행 시마다 새 문서가 생성되므로 시간 범위를 필터링하여 특정 기간의 활동을 분석하거나 주간 단위로 비교할 수 있습니다. 또한 대시보드 조회수가 임계값 아래로 떨어질 때 알림을 구축할 수 있습니다.</p><h2><strong>결론</strong></h2><p>Elastic Workflows는 이러한 주기적인 데이터 수집에 매우 적합합니다. 데이터 소스(Kibana API)와 대상(Elasticsearch)이 모두 네이티브로 연결되어 있어 자격 증명을 별도로 관리할 필요가 없기 때문입니다. 워크플로우 엔진이 <code>kibana.request</code> 및 <code>elasticsearch.request</code>단계의 인증을 자동으로 처리하므로 사용자는 로직만 작성하면 됩니다.</p><h2><strong>리소스</strong></h2><ul><li><p><a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a></p></li><li><p><a href="https://www.elastic.co/docs/api/doc/kibana/">Kibana API</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/monitor-kibana-dashboard-views-elastic-workflows</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/monitor-kibana-dashboard-views-elastic-workflows</guid>
    <category><![CDATA[개발자 경험]]></category>
    <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltef604bbb6dee6be0/6a17dc67a29299db23d02b7d/0ed94ce00962287b5507f45c92ecb60fdcbf2718-1280x720.png" length="0" type="image/png"/>
    <pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[코드로서의 Elastic Open Web Crawler]]></title>
    <description><![CDATA[변경 사항을 리포지토리에 푸시할 때마다 배포된 크롤러 인스턴스에 변경 사항이 자동으로 적용되도록 GitHub Actions를 사용하여 Elastic Open Crawler 구성을 관리하는 방법에 대해 알아보세요.]]></description>
    <content:encoded><![CDATA[<p><a href="https://github.com/elastic/crawler">Elastic Open Web Crawler와</a> CLI 기반 아키텍처를 사용하면 이제 버전이 관리되는 크롤러 구성과 로컬 테스트가 포함된 CI/CD 파이프라인을 매우 간단하게 구축할 수 있습니다.</p><p>기존에는 크롤러 관리가 수작업으로 이루어졌고 오류가 발생하기 쉬운 프로세스였습니다. UI에서 직접 구성을 편집하고 크롤링 구성 복제, 롤백, 버전 관리 등으로 어려움을 겪어야 했습니다. 크롤러 구성을 코드로 취급하면 소프트웨어 개발에서 기대하는 반복성, 추적성 및 자동화와 같은 이점을 제공함으로써 이 문제를 해결할 수 있습니다.</p><p>이 워크플로우를 사용하면 Elastic Web Crawler나 App Search Crawler와 같은 이전 Elastic Crawler에서는 훨씬 더 까다로웠던 롤백, 백업, 마이그레이션 작업을 위해 CI/CD 파이프라인에 Open Web Crawler를 더 쉽게 도입할 수 있습니다.</p><p>이 글에서는 그 방법을 알아보겠습니다:</p><ul><li><p>GitHub를 사용하여 크롤링 구성 관리하기</p></li><li><p>배포하기 전에 파이프라인을 테스트할 수 있는 로컬 설정이 있습니다.</p></li><li><p>메인 브랜치에 변경 사항을 푸시할 때마다 새로운 설정으로 웹 크롤러를 실행하도록 프로덕션 설정을 만듭니다.</p></li></ul><p>프로젝트 리포지토리는 <a href="https://github.com/llermaly/elastic-open-crawler-as-code"><em><strong>여기에서</strong></em></a>찾을 수 있습니다<em><strong>. </strong></em><em>이 글을 쓰는 현재, 저는 Elasticsearch 9.1.3과 Open Web Crawler 0.4.2를 사용하고 있습니다.</em></p><h2>필수 구성 요소</h2><ul><li><p>Docker 데스크톱</p></li><li><p>Elasticsearch 인스턴스</p></li><li><p>SSH 액세스 권한이 있는 가상 머신(예: AWS EC2) 및 Docker가 설치된 가상 머신</p></li></ul><h2>단계</h2><ol><li><p>폴더 구조</p></li><li><p>크롤러 구성</p></li><li><p>Docker-작성 파일(로컬 환경)</p></li><li><p>깃허브 액션</p></li><li><p>로컬 테스트</p></li><li><p>프로덕션에 배포</p></li><li><p>변경 및 다시 배포</p></li></ol><h2>폴더 구조</h2><p>이 프로젝트의 파일 구조는 다음과 같습니다:</p>├── docker-compose.yml # Local elasticsearch + crawler
├── config/crawler-config.yml # Crawler config
├── .github/workflows/deploy.yml # GH Action to deploy changes
├── local.sh # Script to run our local crawler<h2>크롤러 구성</h2><p><code>crawler-config.yml,</code> 아래에 다음을 입력합니다:</p>output_sink: elasticsearch
output_index: web-crawl-index
max_crawl_depth: 1

elasticsearch:
  host: ${ES_HOST}
  api_key: ${ES_API_KEY}
     
domains:
  - url: https://web-scraping.dev
    seed_urls:
      - https://web-scraping.dev/product/1
      - https://web-scraping.dev/product/2
      - https://web-scraping.dev/product/3<p>제품 모의 <a href="https://web-scraping.dev/products">사이트인 https://web-scraping.dev/products</a> 에서 크롤링됩니다. 처음 세 개의 제품 페이지만 크롤링합니다. <code>max_crawl_depth</code> 설정은 크롤러가 <code>seed_urls</code> 으로 정의된 페이지보다 더 많은 페이지를 발견하지 못하도록 하여 그 안의 링크를 열지 않도록 합니다.</p><p>Elasticsearch <code>host</code> 및 <code>api_key</code> 는 스크립트를 실행하는 환경에 따라 동적으로 채워집니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d37b966aafbd3c1/6a17ef9842022946e929f6b9/f9831034e1c4ccb554d37bdd188f2824338355a0-890x624.png" alt="웹 스크래핑 테스트용 모의 사이트인 web-scraping.dev 도메인의 &quot;상자 초콜릿 캔디&quot; 제품 페이지입니다. 이 페이지에는 제품 제목, 이미지, 설명, 가격 및 구매 버튼에 대한 HTML 요소가 표시됩니다." /><h2>Docker-작성 파일(로컬 환경)</h2><p>로컬 <code>docker-compose.yml,</code> 의 경우, 프로덕션에 배포하기 <em><strong>전에</strong></em> 크롤링 결과를 쉽게 시각화할 수 있도록 크롤러와 단일 Elasticsearch 클러스터 + Kibana를 배포합니다.</p>services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:9.1.3
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    ports:
      - "9200:9200"
    networks: [esnet]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9200"]
      interval: 5s
      timeout: 5s
      retries: 10

  kibana:
    image: docker.elastic.co/kibana/kibana:9.1.3
    environment:
      - ELASTICSEARCH_HOSTS=http://es01:9200
    ports:
      - "5601:5601"
    networks: [esnet]
    depends_on: [es01]

  crawler:
    image: docker.elastic.co/integrations/crawler:0.4.2
    environment:
      - ES_HOST=http://es01:9200
      - CRAWLER_JRUBY_OPTS=--server
    container_name: crawler
    volumes:
      - ./config:/home/app/config
    networks: [esnet]
    entrypoint: ["/home/app/bin/crawler", "crawl", "/home/app/config/crawl-config-final.yml"]
    stdin_open: true
    tty: true

networks:
  esnet:
    driver: bridge<p>크롤러가 Elasticsearch를 실행할 준비가 될 때까지 기다리는 방식에 주목하세요.</p><h2>깃허브 액션</h2><p>이제 새 설정을 복사하고 메인으로 푸시할 때마다 가상 머신에서 크롤러를 실행하는 GitHub 액션을 만들어야 합니다. 이렇게 하면 가상 머신에 수동으로 들어가서 파일을 업데이트하고 크롤러를 실행할 필요 없이 항상 최신 구성이 배포되어 있습니다. 가상 머신 공급자로 AWS EC2를 사용하겠습니다.</p><p>첫 번째 단계는 호스트(<code>VM_HOST</code>), 머신 사용자(<code>VM_USER</code>), SSH RSA 키(<code>VM_KEY</code>), Elasticsearch 호스트(<code>ES_HOST</code>), Elasticsearch API 키(<code>ES_API_KEY</code>)를 GitHub Action 시크릿에 추가하는 것입니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb5a0fcfff9b7f997/6a17ef9a6df731d7d40a0fdf/e1075bc54151b4b94eac2a6bd2682e9997e6c709-1106x707.png" alt="&quot;작업, 비밀 및 변수&quot; 구성 페이지로, 웹 기반 인터페이스 내에 VM_HOST, VM_KEY, VM_USER와 같은 리포지토리 비밀을 표시합니다." /><p>이렇게 하면 액션이 서버에 액세스하여 새 파일을 복사하고 크롤링을 실행할 수 있습니다.</p><p>이제 <code>.github/workflows/deploy.yml</code> 파일을 만들어 보겠습니다:</p>name: Deploy

on:
  push:
    branches: [main]

jobs:
  Deploy:
    name: Deploy to EC2
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v5

      - name: Deploy crawler
        env:
          HOSTNAME: ${{ secrets.VM_HOST }}
          USER_NAME: ${{ secrets.VM_USER }}
          PRIVATE_KEY: ${{ secrets.VM_KEY }}
          ES_HOST: ${{ secrets.ES_HOST }}
          ES_API_KEY: ${{ secrets.ES_API_KEY }}
        run: |
          # Save private key
          echo "$PRIVATE_KEY" &gt; private_key
          chmod 600 private_key

          # Generate final config locally
          envsubst &lt; config/crawler-config.yml &gt; config/crawl-config-final.yml

          # Copy the config folder to VM
          scp -o StrictHostKeyChecking=no -i private_key -r config ${USER_NAME}@${HOSTNAME}:~/config

          # SSH into VM and run crawler
          ssh -o StrictHostKeyChecking=no -i private_key ${USER_NAME}@${HOSTNAME} &lt;&lt; EOF
            docker run --rm \
              -v ~/config:/config \
              docker.elastic.co/integrations/crawler:latest jruby \
              bin/crawler crawl /config/crawl-config-final.yml
          EOF<p>이 작업은 크롤러 구성 파일에 변경 사항을 푸시할 때마다 다음 단계를 실행합니다:</p><ol><li><p>yml 구성에서 Elasticsearch 호스트 및 API 키 채우기</p></li><li><p>구성 폴더를 VM에 복사합니다.</p></li><li><p>SSH를 통해 VM에 연결</p></li><li><p>리포지토리에서 방금 복사한 구성으로 크롤링을 실행합니다.</p></li></ol><h2>로컬 테스트</h2><p>로컬에서 크롤러를 테스트하기 위해, Docker의 로컬 호스트에 Elasticsearch 호스트를 채우고 크롤링을 시작하는 bash 스크립트를 만들었습니다. <code>./local.sh</code> 을 실행하여 실행할 수 있습니다.</p>#!/bin/bash

# Exit on any error
set -e

# Load environment variables
export ES_HOST="http://es01:9200"

# Generate final crawler config
envsubst &lt; ./config/crawler-config.yml &gt; ./config/crawl-config-final.yml

# Bring everything up
docker compose up --build<p>Kibana 개발자 도구를 살펴보고<code> web-crawler-index</code> 이 올바르게 채워졌는지 확인해 보겠습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt989660368fe14db8/6a17ef9b9da390c79ce46562/18551635e8265866e389a9632c4e4540958e4468-990x723.png" alt="웹 크롤러 인덱스가 올바르게 설정되었는지 확인하기 위한 Kibana 개발자 도구 코드입니다." /><h2>프로덕션에 배포</h2><p>이제 가상 머신에 크롤러를 배포하고 서버리스 Elasticsearch 인스턴스로 로그 전송을 시작하는 메인 브랜치로 푸시할 준비가 되었습니다.</p>git add .
git commit -m "First commit"
git push<p>그러면 가상 머신 내에서 배포 스크립트를 실행하고 크롤링을 시작하는 GitHub 액션이 트리거됩니다.</p><p>GitHub 리포지토리로 이동하여 "작업" 탭을 방문하면 작업이 실행되었는지 확인할 수 있습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt986f1a4e4f3288d2/6a17ef9c7f6f1584fdc09c10/67ba3a7164d7a8049fe5661264820826cb18ed64-667x325.png" alt="작업은 GitHub 리포지토리의 EC2 탭에 배포됩니다." /><h2>변경 및 다시 배포</h2><p>눈에 띄는 점은 각 제품의 <code>price</code> 이 문서 본문 필드의 일부라는 점입니다. 가격을 별도의 필드에 저장하여 필터를 실행할 수 있도록 하는 것이 가장 이상적입니다.</p><p><code>crawler.yml</code> 파일에 이 변경 사항을 추가하여 <a href="https://github.com/elastic/crawler/blob/main/docs/features/EXTRACTION_RULES.md">추출 규칙을</a> 사용하여 <code>product-price</code> CSS 클래스에서 가격을 추출해 보겠습니다:</p>output_sink: elasticsearch
output_index: web-crawl-index
max_crawl_depth: 1

elasticsearch:
  host: ${ES_HOST}
  api_key: ${ES_API_KEY}
     
  # Index ingest pipeline to process documents before indexing          
  pipeline_enabled: true
  pipeline: pricing-pipeline

domains:
  - url: https://web-scraping.dev
    seed_urls:
      - https://web-scraping.dev/product/1
      - https://web-scraping.dev/product/2
      - https://web-scraping.dev/product/3
    extraction_rulesets:
      - url_filters:
          - type: ends
            pattern: /product/*
        rules:
          - action: extract
            field_name: price
            selector: .product-price
            join_as: string
            source: html<p>또한 가격에는 달러 기호(<code>$</code>)가 포함되어 있으며, 범위 쿼리를 실행하려면 이 기호를 제거해야 합니다. 이를 위해 수집 파이프라인을 사용할 수 있습니다. 위의 새 크롤러 구성 파일에서 이를 참조하고 있습니다:</p>PUT _ingest/pipeline/pricing-pipeline
{
  "processors": [
    {
      "script": {
        "source": """
                ctx['price'] = ctx['price'].replace("$","")
            """
      }
    }
  ]
}<p>프로덕션 Elasticsearch 클러스터에서 해당 명령을 실행할 수 있습니다. 개발의 경우, 임시적이므로 다음 서비스를 추가하여 <code>docker-compose.yml</code> 파일에 파이프라인 생성 부분을 만들 수 있습니다. 또한 크롤러 서비스에 <code>depends_on</code> 을 추가하여 파이프라인이 성공적으로 생성된 후에 시작되도록 했습니다.</p> crawler:
    image: docker.elastic.co/integrations/crawler:0.4.2
    environment:
      - ES_HOST=http://es01:9200
      - CRAWLER_JRUBY_OPTS=--server
    container_name: crawler
    volumes:
      - ./config:/home/app/config
    networks: [esnet]
    entrypoint: ["/home/app/bin/crawler", "crawl", "/home/app/config/crawl-config-final.yml"]
    depends_on:
      pipeline-init:
        condition: service_completed_successfully
    stdin_open: true
    tty: true  


  pipeline-init:
    image: curlimages/curl:latest
    depends_on:
      es01:
        condition: service_healthy
    networks: [esnet]
    entrypoint: &gt;
        sh -c "
        echo 'Creating ingest pipeline...';
        curl -s -X PUT http://es01:9200/_ingest/pipeline/pricing-pipeline \\
          -H 'Content-Type: application/json' \\
          -d '{\"processors\":[{\"script\":{\"source\":\"ctx.price = ctx.price.replace(\\\"$\\\", \\\"\\\")\"}}]}';
        echo 'Pipeline created!';
        "<p>이제 <code>`./local.sh`</code> 을 실행하여 로컬에서 변경 사항을 확인해 보겠습니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0f390aedf67cb4fe/6a17ef9eaf47b62bd2cde05b/dc1801599344a9f69f072b07ff828c4ba3815d7b-738x473.png" alt="./local.sh`를 실행하여 로컬에서 가격 변동을 확인합니다." /><p>훌륭합니다! 이제 변경 사항을 적용해 보겠습니다:</p>git add crawler-config.yml
git commit -m "added price CSS selector"
git push<p>모든 것이 제대로 작동하는지 확인하려면 프로덕션 Kibana를 확인하면 변경 사항이 반영되어 달러 기호가 없는 새 필드로 가격이 표시되어야 합니다.</p><h2>결론</h2><p>Elastic Open Web Crawler를 사용하면 크롤러를 코드로 관리할 수 있으므로 개발부터 배포까지 전체 파이프라인을 자동화하고 임시 로컬 환경을 추가하고 크롤링된 데이터에 대해 프로그래밍 방식으로 테스트하는 등 몇 가지 예를 들 수 있습니다.</p><p>공식 리포지토리를 복제하고 이 워크플로우를 사용하여 자체 데이터 색인화를 시작할 수 있습니다. <a href="https://www.elastic.co/search-labs/blog/semantic-search-open-crawler">이 문서에서</a> 크롤러가 생성한 인덱스에 대해 시맨틱 검색을 실행하는 방법을 알아볼 수도 있습니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-open-crawler-config-as-code</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-open-crawler-config-as-code</guid>
    <category><![CDATA[인덱스 데이터]]></category>
    <category><![CDATA[운영]]></category>
    <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt00e7c95012dc38cc/6a17efa0fbc5f80dad491b93/0ac41f55c85ad3f647cb0e0d750ed80bacd397f3-1036x581.png" length="0" type="image/png"/>
    <pubDate>Mon, 22 Sep 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Blazor와 Elasticsearch로 검색 앱 구축하기]]></title>
    <description><![CDATA[Blazor와 Elasticsearch를 사용해 검색 애플리케이션을 구축하는 방법과 하이브리드 검색을 위해 Elasticsearch .NET 클라이언트를 사용하는 방법을 알아보세요.]]></description>
    <content:encoded><![CDATA[<p>이 문서에서는 C# 기술을 활용하여 Blazor와 Elasticsearch를 사용하여 검색 애플리케이션을 구축하는 방법에 대해 알아봅니다. <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/full-text-queries.html">전체</a> 텍스트, <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search.html">시맨틱</a> 및 <a href="https://www.elastic.co/search-labs/tutorials/search-tutorial/vector-search/hybrid-search">하이브리드</a> 검색 쿼리를 실행하기 위해 <a href="https://www.elastic.co/guide/en/elasticsearch/client/net-api/current/introduction.html">Elasticsearch .NET 클라이언트를 사용하겠습니다.</a></p><p><strong>참고</strong> 이전 버전의 Elasticsearch C# 클라이언트 <a href="https://www.elastic.co/guide/en/elasticsearch/client/net-api/7.17/nest.html">NEST에</a> 익숙한 경우, NEST 클라이언트 지원 중단 및 새로운 기능에 대한 이 <a href="https://www.elastic.co/search-labs/blog/net-client-evolution">블로그 포스팅을</a> 읽어보세요. <em>NEST는 이전 세대의 .NET 클라이언트가 </em><code>Elastic.Clients.Elasticsearch package</code></p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltca8d1da68641fac0/6a17f65763173044d4585bfb/18f890286ca122cd97286c09ebf740208b802d0b-650x395.png" alt="블레이저 앱 빌드: 블레이저 다이어그램을 사용한 ESRE" /><ul><li><p><a href="https://www.elastic.co/search-labs/blog/search-app-with-esre-blazor#what-is-blazor?">블레이저란 무엇인가요?</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-app-with-esre-blazor#what-is-esre?">ESRE란 무엇인가요?</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-app-with-esre-blazor#configuring-elser">ELSER 구성</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-app-with-esre-blazor#indexing-data">데이터 인덱싱</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-app-with-esre-blazor#building-the-app-with-blazor-&amp;-elasticsearch">애플리케이션 구축</a></p></li></ul><h2>블레이저란 무엇인가요?</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt91278ae422883e28/6a17f6592f4a5c3213fa8a95/622741915d016b68bf94f742332d10d736d60052-707x461.png" alt="블레이저 서버" /><p><a href="https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor">Blazor는</a> 개발자가 클라이언트 또는 서버에서 실행되는 웹 애플리케이션을 빌드할 수 있도록 Microsoft에서 만든 오픈 소스 HTML, CSS 및 C# 기반 웹 프레임워크입니다. 또한 재사용 가능한 컴포넌트를 만들어 애플리케이션을 더 빠르게 빌드할 수 있으며, 개발자가 동일한 파일 내에서 C#으로 HTML 보기와 동작을 빌드할 수 있어 가독성 높고 깔끔한 코드를 유지할 수 있습니다. 또한 Blazor Hybrid를 사용하면 .NET 코드를 통해 네이티브 플랫폼 기능에 액세스하는 네이티브 모바일 앱을 빌드할 수 있습니다.</p><p>블레이저를 작업하기 좋은 프레임워크로 만드는 몇 가지 기능을 소개합니다:</p><ul><li><p>서버 측 및 클라이언트 측 렌더링 옵션</p></li><li><p>재사용 가능한 UI 구성 요소</p></li><li><p>SignalR로 실시간 업데이트</p></li><li><p>기본 제공 상태 관리</p></li><li><p>내장 라우팅 시스템</p></li><li><p>강력한 타이핑 및 컴파일 타임 검사</p></li></ul><h3>왜 블레이저인가요?</h3><p>Blazor는 다른 프레임워크 및 라이브러리에 비해 몇 가지 장점을 제공합니다. 개발자는 클라이언트 및 서버 코드 모두에 C#을 사용할 수 있으며, 강력한 타이핑 및 컴파일 타임 검사를 제공하여 안정성을 향상시킵니다. .NET 에코시스템과 원활하게 통합되어 .NET 라이브러리 및 도구를 재사용할 수 있으며, 강력한 디버깅 지원을 제공합니다.</p><h2>ESRE란 무엇인가요?</h2><p><a href="https://www.elastic.co/elasticsearch/elasticsearch-relevance-engine">Elasticsearch Relevance Engine™ (ESRE)</a> 은 강력한 Elasticsearch 검색 엔진 위에 머신 러닝과 인공 지능을 사용해 <a href="https://www.elastic.co/guide/en/esre/current/learn.html">검색 애플리케이션을 구축하는 도구</a> 세트입니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf313ba986de01b93/6a17d851505ac306fcad8975/4c0f2645ed1c27fe3ef61a1a9126adadfd8d5368-721x421.png" alt="esre" /><p>ESRE에 대해 자세히 알아보려면 <a href="https://www.elastic.co/search-labs/blog/introducing-elasticsearch-relevance-engine-esre">여기에서</a>인사이트가 담긴 블로그 게시물을 읽어보세요.</p><h2>ELSER 구성</h2><p>Elastic의 <a href="https://www.elastic.co/elasticsearch/elasticsearch-relevance-engine">ESRE</a> 기능을 활용하기 위해, 우리는 모델 공급자로 <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html">ELSER를</a> 사용할 것입니다.</p><p><em>Elasticsearch의 ELSER 모델을 사용하려면, 플래티넘 또는 엔터프라이즈 라이선스가 있어야 하며 최소 4GB 크기의 전용 머신 러닝(ML) 노드가 있어야 합니다.</em> <a href="https://www.elastic.co/guide/en/machine-learning/8.15/ml-nlp-elser.html#elser-req"><em>여기에서 자세히 알아보세요.</em></a></p><p>추론 엔드포인트를 만드는 것부터 시작하세요:</p>PUT _inference/sparse_embedding/my-elser-model
{
  "service": "elser",
  "service_settings": {
    "num_allocations": 1,
    "num_threads": 1
  }
}<p>ELSER를 처음 사용하는 경우 모델이 백그라운드에서 로드될 때 502 잘못된 게이트웨이 오류가 발생할 수 있습니다. Kibana의 <code>Machine Learning &gt; Trained Models</code> 에서 모델의 상태를 확인할 수 있습니다. 배포가 완료되면 다음 단계로 진행할 수 있습니다.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltee295a3516338302/6a17f65b414c640deb94531d/a7ff94b72d337cde892165d744b9f42fba702a87-1440x649.png" alt="훈련된 모델 확인" /><h2>데이터 인덱싱</h2><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/esre-with-blazor/books.zip">여기에서</a> 데이터 세트를 다운로드한 다음 Kibana를 사용하여 데이터를 가져올 수 있습니다. 이렇게 하려면 홈페이지로 이동하여 "데이터 업로드" 를 클릭합니다. 그런 다음 파일을 업로드하고 <code>Import</code> 을 클릭합니다. 마지막으로 <code>Advanced</code> 탭으로 이동하여 다음 매핑을 붙여넣습니다:</p>{
   "properties":{
      "authors":{
         "type":"keyword"
      },
      "categories":{
         "type":"keyword"
      },
      "longDescription":{
         "type":"semantic_text",
         "inference_id":"my-elser-model",
         "model_settings":{
            "task_type":"sparse_embedding"
         }
      },
      "pageCount":{
         "type":"integer"
      },
      "publishedDate":{
         "type":"date"
      },
      "shortDescription":{
         "type":"text"
      },
      "status":{
         "type":"keyword"
      },
      "thumbnailUrl":{
         "type":"keyword"
      },
      "title":{
         "type":"text"
      }
   }
}<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0f07300647edd2ca/6a17f65dfaa913172a93ca0c/1aea0b9c51e275f89339f5d463fcaef799fc3943-1235x1083.png" alt="데이터 가져오기" /><p>시맨틱 및 전체 텍스트 쿼리를 실행할 수 있는 인덱스를 만들려고 합니다. <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-text.html">semantic_text</a> 필드 유형은 데이터 청크 및 임베딩을 처리합니다. <em>을 으로 색인하고 있으며, 필드를 과 text` 로</em> <em>모두 색인하려면</em> <em><code>longDescription</code></em>  <em><code>semantic_text</code></em><em>copy_to를 사용할 수</em><em><code>semantic_text</code></em> 있습니다.</p><h2>Blazor로 앱 구축 &amp; Elasticsearch</h2><h3>API 키</h3><p>가장 먼저 해야 할 일은 Elasticsearch에 대한 요청을 인증하기 위해 API 키를 생성하는 것입니다. API 키는 읽기 전용이어야 하며 <code>books-blazor</code> 인덱스에 대한 쿼리만 허용되어야 합니다.</p>POST /_security/api_key
{
  "name": "books-blazor-key",
  "role_descriptors": {
    "books-blazor-reader": {
      "indices": [
        {
          "names": ["books-blazor"],
          "privileges": ["read"]
        }
      ]
    }
  }
}<p>다음과 같은 내용이 표시됩니다:
</p>{
  "id": "XXXXXXXXXXXXXXXXXXXXXXXX",
  "name": "books-blazor-key",
  "api_key": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "encoded": "XXXXXXXXXXXXXXXXXXXXXXXX=="
}<p><code>encoded</code> 응답 필드의 값은 나중에 필요하므로 저장합니다. <a href="https://www.elastic.co/cloud/">Elastic Cloud에서</a> 실행 중인 경우, 클라우드 ID도 필요합니다. ( <a href="https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud#finding-your-cloud-id">여기에서</a> 찾을 수 있습니다).</p><h4>블레이저 프로젝트 만들기</h4><p>먼저 Blazor를 설치하고 <a href="https://dotnet.microsoft.com/en-us/learn/aspnet/blazor-tutorial/install">공식 지침에</a> 따라 샘플 프로젝트를 생성하세요.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb5c20b896fe1e98e/6a17f65f6317301566585bff/f5720867d960c91fd1b3c4ae9be174e062e6b2fc-975x830.png" alt="블레이저 튜토리얼 - 블레이저 프로젝트 만들기" /><p>프로젝트가 생성되면 폴더 구조와 파일은 다음과 같이 보일 것입니다:</p>BlazorApp/
|-- BlazorApp.csproj
|-- BlazorApp.sln
|-- Program.cs
|-- appsettings.Development.json
|-- appsettings.json
|-- Properties/
|   `-- launchSettings.json
|-- Components/
|   |-- App.razor
|   |-- Routes.razor
|   |-- _Imports.razor
|   |-- Layout/
|   |   |-- MainLayout.razor
|   |   |-- MainLayout.razor.css
|   |   |-- NavMenu.razor
|   |   `-- NavMenu.razor.css
|   `-- Pages/
|       |-- Counter.razor
|       |-- Error.razor
|       |-- Home.razor
|       `-- Weather.razor
|-- wwwroot/
|-- bin/
`-- obj/ <p><a href="https://blog.getbootstrap.com/2021/08/04/bootstrap-5-1-0/">템플릿</a> 애플리케이션에는 부트스트랩 v5.1.0이 포함됩니다. 스타일링을 위해.</p><p><a href="https://www.elastic.co/guide/en/elasticsearch/client/net-api/8.0/installation.html">Elasticsearch .NET</a> 클라이언트를 설치하여 프로젝트 설정을 완료합니다:</p>dotnet add package Elastic.Clients.Elasticsearch<p>이 단계를 완료하면 페이지가 다음과 같이 표시됩니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3b90e394ded7ff56/6a17f660faa913d49f93ca10/ef9d2186c2cf49e78a307c4aa69c51632e84578d-940x529.png" alt="블레이저 헬로 월드" /><h3>폴더 구조</h3><p>이제 폴더를 다음과 같이 정리해 보겠습니다:</p>BlazorApp/
|-- Components/
|   |-- Pages/
|   |   |-- Search.razor
|   |   `-- Search.razor.css
|   `-- Elasticsearch/
|       |-- SearchBar.razor
|       |-- Results.razor
|       `-- Facet.razor
|-- Models/
|   |-- Book.cs
|   `-- Response.cs
`-- Services/
    `-- ElasticsearchService.cs<p>파일 설명:</p><ul><li><p>Components/Pages/Search.razor: 검색창, 결과, 필터가 포함된 메인 페이지입니다.</p></li><li><p>Components/Pages/Search.razor.css: 페이지 스타일.</p></li><li><p>Components/Elasticsearch/SearchBar.razor: 검색창 컴포넌트.</p></li><li><p>Components/Elasticsearch/Results.razor: 결과 컴포넌트.</p></li><li><p>Components/Elasticsearch/Facet.razor: 구성 요소를 필터링합니다.</p></li><li><p>Components/Svg/GlassIcon.razor: 검색 아이콘.</p></li><li><p>Components/_Imports.razor: 모든 컴포넌트를 가져옵니다.</p></li><li><p>Models/Book.cs: 여기에는 책 필드 스키마가 저장됩니다.</p></li><li><p>Models/Response.cs: 검색 결과, 패싯 및 총 조회수를 포함한 응답 스키마를 저장합니다.</p></li><li><p>Services/ElasticsearchService.cs: Elasticsearch 서비스. Elasticsearch에 대한 연결과 쿼리를 처리합니다.</p></li></ul><h4>초기 구성</h4><p>몇 가지 정리부터 시작하겠습니다.</p><p>파일을 삭제합니다:</p><ul><li><p>Components/Pages/Counter.razor</p></li><li><p>Components/Pages/Weather.razor</p></li><li><p>Components/Pages/Home.razor</p></li><li><p>컴포넌트/레이아웃/NavMenu.razor</p></li><li><p>Components/Layout/NavMenu.razor.css</p></li></ul><p><code>/Components/_Imports.razor</code> 파일을 확인하세요. 다음과 같은 가져오기 항목이 있어야 합니다:</p>@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BlazorApp
@using BlazorApp.Components<h4>프로젝트에 Elastic 통합하기</h4><p>이제 Elasticsearch 구성 요소를 가져와 보겠습니다:</p>@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BlazorApp
@using BlazorApp.Components
@using BlazorApp.Components.Elasticsearch @* &lt;--- Add this line *@<p><code>/Components/Layout/MainLayout.razor</code> 파일에서 기본 사이드바를 제거하여 애플리케이션을 위한 더 많은 공간을 확보하겠습니다:</p>@inherits LayoutComponentBase

&lt;div class="page"&gt;
    &lt;main&gt;
        &lt;article class="content"&gt;
            @Body
        &lt;/article&gt;
    &lt;/main&gt;
&lt;/div&gt;

&lt;div id="blazor-error-ui"&gt;
    An unhandled error has occurred.
    &lt;a href="" class="reload"&gt;Reload&lt;/a&gt;
    &lt;a class="dismiss"&gt;🗙&lt;/a&gt;
&lt;/div&gt;<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt441012c5aeac85f8/6a17f661ec0f8949f15a67ac/55bfff607f9167a532f83ad074b479364a346c6e-816x472.png" alt="레이아웃에서 내비게이션 바 제거" /><p>이제 <a href="https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-8.0&amp;tabs=linux#secret-manager">사용자 비밀에</a> 대한 Elasticsearch 자격 증명을 입력해 보겠습니다:</p>dotnet user-secrets init
dotnet user-secrets set ElasticsearchCloudId "your Cloud ID"
dotnet user-secrets set ElasticsearchApiKey "your API Key"<p>이 접근 방식을 사용하면 .Net 8은 프로젝트 폴더 외부의 별도 위치에 중요한 데이터를 저장하고 <code>IConfiguration</code> 인터페이스를 사용하여 액세스할 수 있습니다. 이러한 변수는 동일한 사용자 비밀을 사용하는 모든 .Net 프로젝트에서 사용할 수 있습니다.</p><p>그런 다음 <code>Program.cs</code> 파일을 수정하여 시크릿을 읽고 Elasticsearch 클라이언트를 마운트해 보겠습니다:</p><p>먼저 필요한 라이브러리를 가져옵니다:</p>using BlazorApp.Services;
using Elastic.Clients.Elasticsearch;
using Elastic.Transport;<ul><li><p>BlazorApp.Services: Elasticsearch 서비스를 포함합니다.</p></li><li><p>Elastic.Clients.Elasticsearch: Elasticsearch 클라이언트 .Net 8 라이브러리를 가져옵니다.</p></li><li><p>Elastic.Transport: 요청을 인증하는 데 ApiKey 클래스를 사용할 수 있는 Elasticsearch 전송 라이브러리를 가져옵니다.</p></li></ul><p>둘째, <code>var app = builder.Build()</code> 줄 앞에 다음 코드를 삽입합니다:</p>// Initialize the Elasticsearch client.
builder.Services.AddScoped(sp =&gt;
{
    // Getting access to the configuration service to read the Elasticsearch credentials.
    var configuration = sp.GetRequiredService&lt;IConfiguration&gt;();
    var cloudId = configuration["ElasticsearchCloudId"];
    var apiKey = configuration["ElasticsearchApiKey"];

    if (string.IsNullOrEmpty(cloudId) || string.IsNullOrEmpty(apiKey))
    {
        throw new InvalidOperationException(
            "Elasticsearch credentials are missing in configuration."
        );
    }

    var settings = new ElasticsearchClientSettings(cloudId, new ApiKey(apiKey)).EnableDebugMode();
    return new ElasticsearchClient(settings);
});<p>이 코드는 사용자 비밀번호에서 Elasticsearch 자격 증명을 읽고 Elasticsearch 클라이언트 인스턴스를 생성합니다.</p><p>ElasticSearch 클라이언트 초기화 후, 다음 줄을 추가하여 Elasticsearch 서비스를 등록합니다:</p>builder.Services.AddScoped&lt;ElasticsearchService&gt;();<p>다음 단계는 <code>/Services/ElasticsearchService.cs</code> 파일에 검색 로직을 구축하는 것입니다:</p><p>먼저 필요한 라이브러리와 모델을 가져옵니다:</p>using BlazorApp.Models;
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.QueryDsl;<p>둘째, <code>ElasticsearchService</code>, 생성자 및 변수를 추가합니다:</p>namespace BlazorApp.Services
{
    public class ElasticsearchService
    {
        private readonly ElasticsearchClient _client;

        // The logger is used to log information, warnings and errors about the Elasticsearch service and requests.
        private readonly ILogger&lt;ElasticsearchService&gt; _logger;

        public ElasticsearchService(
            ElasticsearchClient client,
            ILogger&lt;ElasticsearchService&gt; logger
        )
        {
            _client = client ?? throw new ArgumentNullException(nameof(client));
            _logger = logger;
        }
    }
}<h4>검색 구성</h4><p>이제 검색 로직을 구축해 보겠습니다:</p>private static Action&lt;RetrieverDescriptor&lt;BookDoc&gt;&gt; BuildHybridQuery(
    string searchTerm,
    Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets
)
{
    var filters = BuildFilters(selectedFacets);

    return retrievers =&gt;
        retrievers.Rrf(rrf =&gt;
            rrf.RankWindowSize(50)
                .RankConstant(20)
                .Retrievers(
                    retrievers =&gt;
                        retrievers.Standard(std =&gt;
                            std.Query(q =&gt;
                                q.Bool(b =&gt;
                                    b.Must(m =&gt;
                                            m.MultiMatch(mm =&gt;
                                                mm.Query(searchTerm)
                                                    .Fields(
                                                        new[]
                                                        {
                                                            "title",
                                                            "shortDescription",
                                                        }
                                                    )
                                            )
                                        )
                                        .Filter(filters.ToArray())
                                )
                            )
                        ),
                    retrievers =&gt;
                        retrievers.Standard(std =&gt;
                            std.Query(q =&gt;
                                q.Bool(b =&gt;
                                    b.Must(m =&gt;
                                            m.Semantic(sem =&gt;
                                                sem.Field("longDescription")
                                                    .Query(searchTerm)
                                            )
                                        )
                                        .Filter(filters.ToArray())
                                )
                            )
                        )
                )
        );
}

public static List&lt;Action&lt;QueryDescriptor&lt;BookDoc&gt;&gt;&gt; BuildFilters(
    Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets
)
{
    var filters = new List&lt;Action&lt;QueryDescriptor&lt;BookDoc&gt;&gt;&gt;();

    if (selectedFacets != null)
    {
        foreach (var facet in selectedFacets)
        {
            foreach (var value in facet.Value)
            {
                var field = facet.Key.ToLower();
                if (!string.IsNullOrEmpty(field))
                {
                    filters.Add(m =&gt; m.Term(t =&gt; t.Field(new Field(field)).Value(value)));
                }
            }
        }
    }

    return filters;
}<ul><li><p><code>BuildFilters</code> 는 사용자가 선택한 패싯을 사용하여 검색 쿼리에 대한 필터를 작성합니다.</p></li><li><p><code>BuildHybridQuery</code> 는 전체 텍스트와 시맨틱 검색을 결합한 <a href="https://www.elastic.co/search-labs/tutorials/search-tutorial/vector-search/hybrid-search">하이브리드 검색</a> 쿼리를 작성합니다.</p></li></ul><p>다음으로 검색 방법을 추가합니다:</p>public async Task&lt;ElasticResponse&gt; SearchBooksAsync(
    string searchTerm,
    Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets
)
{
    try
    {
        _logger.LogInformation($"Performing search for: {searchTerm}");

        // Retrieve the hybrid query with filters applied.
        var retrieverQuery = BuildHybridQuery(searchTerm, selectedFacets);

        var response = await _client.SearchAsync&lt;BookDoc&gt;(s =&gt;
            s.Index("elastic-blazor-books")
                .Retriever(retrieverQuery)
                .Aggregations(aggs =&gt;
                    aggs.Add("Authors", agg =&gt; agg.Terms(t =&gt; t.Field(p =&gt; p.Authors)))
                        .Add(
                            "Categories",
                            agg =&gt; agg.Terms(t =&gt; t.Field(p =&gt; p.Categories))
                        )
                        .Add("Status", agg =&gt; agg.Terms(t =&gt; t.Field(p =&gt; p.Status)))
                )
        );

        if (response.IsValidResponse)
        {
            _logger.LogInformation($"Found {response.Documents.Count} documents");

            var hits = response.Total;
            var facets =
                response.Aggregations != null
                    ? FormatFacets(response.Aggregations)
                    : new Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt;();

            var elasticResponse = new ElasticResponse
            {
                TotalHits = hits,
                Documents = response.Documents.ToList(),
                Facets = facets,
            };

            return elasticResponse;
        }
        else
        {
            _logger.LogWarning($"Invalid response: {response.DebugInformation}");
            return new ElasticResponse();
        }
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error performing search");
        return new ElasticResponse();
    }
}

public static Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt; FormatFacets(
    Elastic.Clients.Elasticsearch.Aggregations.AggregateDictionary aggregations
)
{
    var facets = new Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt;();

    foreach (var aggregation in aggregations)
    {
        if (
            aggregation.Value
            is Elastic.Clients.Elasticsearch.Aggregations.StringTermsAggregate termsAggregate
        )
        {
            var facetName = aggregation.Key;
            var facetDictionary = ConvertFacetDictionary(
                termsAggregate.Buckets.ToDictionary(b =&gt; b.Key, b =&gt; b.DocCount)
            );
            facets[facetName] = facetDictionary;
        }
    }

    return facets;
}

private static Dictionary&lt;string, long&gt; ConvertFacetDictionary(
    Dictionary&lt;Elastic.Clients.Elasticsearch.FieldValue, long&gt; original
)
{
    var result = new Dictionary&lt;string, long&gt;();
    foreach (var kvp in original)
    {
        result[kvp.Key.ToString()] = kvp.Value;
    }
    return result;
}<ul><li><p><code>SearchBooksAsync</code>는 하이브리드 쿼리를 사용하여 검색을 수행하고 패싯을 구축하기 위한 집계가 포함된 결과를 반환합니다.</p></li><li><p><code>FormatFacets</code>는 집계 응답의 형식을 사전으로 지정합니다.</p></li><li><p><code>ConvertFacetDictionary</code>를 입력하면 패싯 사전이 더 읽기 쉬운 형식으로 변환됩니다.</p></li></ul><p>다음 단계는 검색 페이지의 결과로 인쇄될 Elasticsearch 쿼리의 <code>hits</code> 에서 반환된 데이터를 나타낼 모델을 만드는 것입니다.</p><p>먼저 <code>/Models/Book.cs</code> 파일을 만들고 다음을 추가합니다:</p>namespace BlazorApp.Models
{
    public class BookDoc
    {
        public string? Title { get; set; }
        public int? PageCount { get; set; }
        public string? PublishedDate { get; set; }
        public string? ThumbnailUrl { get; set; }
        public string? ShortDescription { get; set; }
        public LongDescription? LongDescription { get; set; }
        public string? Status { get; set; }
        public List&lt;string&gt;? Authors { get; set; }
        public List&lt;string&gt;? Categories { get; set; }
    }

    public class LongDescription
    {
        public string? Text { get; set; }
    }
}<p>그런 다음 <code>/Models/Response.cs</code> 파일에서 Elastic 응답을 설정하고 다음을 추가합니다:</p>namespace BlazorApp.Models
{
    public class ElasticResponse
    {
        public ElasticResponse()
        {
            Documents = new List&lt;BookDoc&gt;();
            Facets = new Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt;();
        }

        public long TotalHits { get; set; }
        public List&lt;BookDoc&gt; Documents { get; set; }
        public Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt; Facets { get; set; }
    }
}<h4>기본 UI 구성</h4><p>다음으로 SearchBar 컴포넌트를 추가합니다. <code>/Components/Elasticsearch/SearchBar.razor</code> 파일을 열고 다음을 추가합니다:</p>@using System.Threading.Tasks

&lt;form @onsubmit="SubmitSearch"&gt;
  &lt;div class="input-group mb-3"&gt;
    &lt;input type="text" @bind-value="searchTerm" class="form-control" placeholder="Enter search term..." /&gt;
    &lt;button type="submit" class="btn btn-primary input-btn"&gt;
      &lt;span class="input-group-svg"&gt;
        Search
      &lt;/span&gt;
    &lt;/button&gt;
  &lt;/div&gt;
&lt;/form&gt;

@code {
  [Parameter]
  public EventCallback&lt;string&gt; OnSearch { get; set; }

  private string searchTerm = "";

  private async Task SubmitSearch()
  {
    await OnSearch.InvokeAsync(searchTerm);
  }
}<p>이 구성 요소에는 검색창과 검색을 수행하는 버튼이 포함되어 있습니다.</p><p>Blazor는 동일한 파일 내에서 C# 코드를 사용하여 동적으로 HTML을 생성할 수 있어 뛰어난 유연성을 제공합니다.</p><p>그 후 <code>/Components/Elasticsearch/Results.razor</code> 파일에서 검색 결과를 표시할 결과 컴포넌트를 빌드합니다:</p>@using BlazorApp.Models

@if (SearchResults != null &amp;&amp; SearchResults.Any())
{
  &lt;div class="row"&gt;
  @foreach (var result in SearchResults)
    {
      &lt;div class="col-12 mb-3"&gt;
        &lt;div class="card"&gt;
          &lt;div class="row g-0"&gt;
            &lt;div class="col-md-3 image-container"&gt;
              @if (!string.IsNullOrEmpty(result?.ThumbnailUrl))
              {
                &lt;img src="@result?.ThumbnailUrl" class="img-fluid rounded-start" alt="Thumbnail"&gt;
              }
              else
              {
                &lt;div class="placeholder"&gt;
                  @result?.Title
                &lt;/div&gt;
              }
            &lt;/div&gt;

            &lt;div class="col-md-9"&gt; &lt;!-- Adjusted to use the remaining 75% --&gt;
              &lt;div class="card-body"&gt;
                &lt;h4 class="card-title"&gt;
                  @result?.Title
                &lt;/h4&gt;

                &lt;div class="details-container"&gt;
                  &lt;div class=""&gt;

                    @if (result?.Authors?.Any() == true)
                    {
                      &lt;p class="card-text p-first"&gt;
                        Authors: &lt;small class="text-muted"&gt;@string.Join(", ", result.Authors)&lt;/small&gt;
                      &lt;/p&gt;
                    }

                    @if (result?.Categories?.Any() == true)
                    {
                      &lt;p class="card-text p-second"&gt;
                        Categories: &lt;small class="text-muted"&gt;@string.Join(", ", result.Categories)&lt;/small&gt;
                      &lt;/p&gt;
                    }
                  &lt;/div&gt;
                  &lt;div class="numPages-status"&gt;
                    @if (result?.PageCount != null)
                    {
                      &lt;p class="card-text p-first"&gt;
                        Pages: &lt;small class="text-muted"&gt;@result.PageCount&lt;/small&gt;
                      &lt;/p&gt;
                    }

                    @if (result?.Status != null)
                    {
                      &lt;p class="card-text p-second"&gt;
                        Status: &lt;small class="text-muted"&gt;@result.Status&lt;/small&gt;
                      &lt;/p&gt;
                    }
                  &lt;/div&gt;
                &lt;/div&gt;

                &lt;div class="long-text-container"&gt;
                  &lt;p class="card-text"&gt;&lt;small class="text-muted"&gt;@result?.LongDescription?.Text&lt;/small&gt;&lt;/p&gt;
                &lt;/div&gt;
                @if (!string.IsNullOrEmpty(result?.PublishedDate))
                {
                  &lt;div class="date-container"&gt;
                    &lt;p class="card-text"&gt;
                      Published Date: &lt;small class="text-muted small-date"&gt;@FormatDate(result.PublishedDate)&lt;/small&gt;
                    &lt;/p&gt;
                  &lt;/div&gt;
                }
              &lt;/div&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    }
  &lt;/div&gt;
}
else if (SearchResults != null)
{
  &lt;p&gt;No results found.&lt;/p&gt;
}

@code {
  [Parameter]
  public List&lt;BookDoc&gt; SearchResults { get; set; } = new List&lt;BookDoc&gt;();

  private string FormatDate(string? date)
  {
    if (DateTime.TryParse(date, out DateTime parsedDate))
    {
      return parsedDate.ToString("MMMM dd, yyyy");
    }
    return "";
  }
}<p>마지막으로 검색 결과를 필터링하기 위해 패싯을 만들어야 합니다.</p><p><em>참고: 패싯은 사용자가 제품 유형, 가격대, 브랜드 등 특정 속성이나 카테고리를 기준으로 검색 결과의 범위를 좁힐 수 있는 필터입니다. 이러한 필터는 일반적으로 사용자가 검색 범위를 좁히고 관련 결과를 더 쉽게 찾을 수 있도록 클릭 가능한 옵션으로 표시되며, 종종 확인란 형태로 제공됩니다. Elasticsearch 컨텍스트에서 패싯은</em> <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html"><em>집계를</em></a>사용하여 생성됩니다<em>.</em></p><p>다음 코드를 입력하여 패싯을 설정합니다. <code>/Components/Elasticsearch/Facet.razor</code> 파일에서 :</p>@if (Facets != null)
{
  &lt;div class="facets-container"&gt;
  @foreach (var facet in Facets)
    {
      &lt;h3&gt;@facet.Key&lt;/h3&gt;
      @foreach (var option in facet.Value)
      {
        &lt;div&gt;
          &lt;input type="checkbox" checked="@IsFacetSelected(facet.Key, option.Key)"
            @onclick="() =&gt; ToggleFacet(facet.Key, option.Key)" /&gt;
          @option.Key (@option.Value)
        &lt;/div&gt;
      }
    }
  &lt;/div&gt;
}


@code {
  [Parameter]
  public Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt;? Facets { get; set; }

  [Parameter]
  public EventCallback&lt;Dictionary&lt;string, List&lt;string&gt;&gt;&gt; OnFacetChanged { get; set; }

  private Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets = new();

  private void ToggleFacet(string facetName, string facetValue)
  {
    if (!selectedFacets.TryGetValue(facetName, out var facetValues))
    {
      facetValues = selectedFacets[facetName] = new List&lt;string&gt;();
    }

    if (!facetValues.Remove(facetValue))
    {
      facetValues.Add(facetValue);
    }

    OnFacetChanged.InvokeAsync(selectedFacets);
  }

  private bool IsFacetSelected(string facetName, string facetValue)
  {
    return selectedFacets.ContainsKey(facetName) &amp;&amp; selectedFacets[facetName].Contains(facetValue);
  }
}<p>이 구성 요소는 <code>author</code>, <code>categories</code>, <code>status</code> 필드에 있는 <code>terms</code> 집계에서 읽은 다음 필터 목록을 생성하여 Elasticsearch로 다시 보냅니다.</p><p>이제 모든 것을 종합해 보겠습니다.</p><p><code>/Components/Pages/Search.razor</code> 파일에서:</p>@page "/"
@rendermode InteractiveServer
@using BlazorApp.Models
@using BlazorApp.Services
@inject ElasticsearchService ElasticsearchService
@inject ILogger&lt;Search&gt; Logger

&lt;PageTitle&gt;Search&lt;/PageTitle&gt;

&lt;div class="top-row px-4 "&gt;

    &lt;div class="searchbar-container"&gt;
        &lt;h4&gt;Semantic Search with Elasticsearch and Blazor&lt;/h4&gt;

        &lt;SearchBar OnSearch="PerformSearch" /&gt;
    &lt;/div&gt;

    &lt;a href="https://www.elastic.co/search-labs/esre-with-blazor" target="_blank"&gt;About&lt;/a&gt;
&lt;/div&gt;

&lt;div class="px-4"&gt;

    &lt;div class="search-details-container"&gt;
        &lt;p role="status"&gt;Current search term: @currentSearchTerm&lt;/p&gt;
        &lt;p role="status"&gt;Total results: @totalResults&lt;/p&gt;
    &lt;/div&gt;

    &lt;div class="results-facet-container"&gt;
        &lt;div class="facets-container"&gt;
            &lt;Facet Facets="facets" OnFacetChanged="OnFacetChanged" /&gt;
        &lt;/div&gt;
        &lt;div class="results-container"&gt;
            &lt;Results SearchResults="searchResults" /&gt;
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;

@code {
    private string currentSearchTerm = "";
    private long totalResults = 0;
    private List&lt;BookDoc&gt; searchResults = new List&lt;BookDoc&gt;();
    private Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt; facets = new Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt;();
    private Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets = new Dictionary&lt;string, List&lt;string&gt;&gt;();

    protected override async Task OnInitializedAsync()
    {
        await PerformSearch();
    }

    private async Task PerformSearch(string searchTerm = "")
    {
        try
        {
            currentSearchTerm = searchTerm;

            var response = await ElasticsearchService.SearchBooksAsync(currentSearchTerm, selectedFacets);
            if (response != null)
            {
                searchResults = response.Documents;
                facets = response.Facets;
                totalResults = response.TotalHits;
            }
            else
            {
                Logger.LogWarning("Search response is null.");
            }

            StateHasChanged();
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Error performing search.");
        }
    }

    private async Task OnFacetChanged(Dictionary&lt;string, List&lt;string&gt;&gt; newSelectedFacets)
    {
        selectedFacets = newSelectedFacets;
        await PerformSearch(currentSearchTerm);
    }
}<p>페이지가 작동 중입니다!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc750e55fff43de4b/6a17f6636864a4ab08b68931/5f0f25cb029a34df7f577a9f307278226ed07c3f-816x473.png" alt="블레이저 페이지 예시" /><p>보시다시피 이 페이지는 기능적이지만 스타일이 부족합니다. 좀 더 체계적이고 반응이 빠른 CSS를 추가해 보겠습니다.</p><p>레이아웃 스타일 교체를 시작해 보겠습니다. <code>Components/Layout/MainLayout.razor.css</code> 파일에서:</p>.page {
  position: relative;
  display: flex;
  flex-direction: column;
}

main {
  flex: 1;
}

#blazor-error-ui {
  background: lightyellow;
  bottom: 0;
  box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
  display: none;
  left: 0;
  padding: 0.6rem 1.25rem 0.7rem 1.25rem;
  position: fixed;
  width: 100%;
  z-index: 1000;
}

#blazor-error-ui .dismiss {
  cursor: pointer;
  position: absolute;
  right: 0.75rem;
  top: 0.5rem;
}<p><code>Components/Pages/Search.razor.css</code> 파일에 검색 페이지의 스타일을 추가합니다:</p>.input-group .input-group-svg {
  background: transparent;
  border: transparent;
  pointer-events: none;
}

.results-facet-container {
  display: flex;
  margin-top: 1rem;
  overflow-x: auto;
}

.search-details-container {
  display: flex;
  justify-content: space-between;
  margin-top: 1rem;
}

.searchbar-container {
  padding-top: 2rem;
  display: flex;
  flex-direction: column; 
  flex-grow: 1;
  height: 100%;
  max-width: 100%; 
}

.searchbar-container h4 {
  margin: 0;
}

.top-row {
  margin-top: -1.1rem;
  position: relative; 
  background-color: hsl(216, 29%, 67%);
  border-bottom: 1px solid #d6d5d5;
  display: flex;
  align-items: center;
  height: 100%;
  padding: 0 1rem;
}

.top-row a {
  margin-left: auto;
  margin-top: -4rem; 
  color: #000000;
  text-decoration: none;
}

.top-row a:hover {
  text-decoration: underline;
}

@media (max-width: 640.98px) {
  .top-row {
    justify-content: space-between;
  }

  .top-row ::deep a,
  .top-row ::deep .btn-link {
    margin-left: 0;
  }
}

@media (min-width: 641px) {
  .top-row.auth ::deep a:first-child {
    flex: 1;
    text-align: right;
    width: 0;
  }

  .top-row,
  article {
    padding-left: 2rem !important;
    padding-right: 1.5rem !important;
  }
}<p>페이지가 더 보기 좋아지기 시작합니다:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3ecc49f404f18591/6a17f6654b055d959b432384/3dadd7ade2dd9070300a4e0514a4da2ae9cc9fb9-817x473.png" alt="검색 페이지에 스타일을 추가한 후 Blazor 페이지" /><p>이제 마지막 마무리를 해보겠습니다:</p><p>다음 파일을 만듭니다:</p><ul><li><p>Components/Elasticsearch/Facet.razor.css</p></li><li><p>Components/Elasticsearch/Results.razor.css</p></li></ul><p>그리고 <code>Facet.razor.css</code> 에 스타일을 추가합니다:</p>.facets-container {
  font-size: 15px;
  margin-right: 4rem;
  overflow-x: auto;
  white-space: nowrap;
  max-width: 300px;
}

.results-facet-container {
  display: flex;
  margin-top: 1rem;
  overflow-x: auto;
}

.results-facet-container &gt; * {
  flex-shrink: 0;
}<p><code>Results.razor.css</code> 의 경우 :</p>.image-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  padding: 1rem;
  box-sizing: border-box;
}

.image-container img {
  max-width: 100%;
  height: auto;
  border-radius: 0.5rem;
}

.placeholder {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  width: 100%;
  background-color: #f0f0f0;
  border: 1px solid #ccc;
  font-size: 0.9rem;
  color: #888;
  text-align: center;
  padding: 1rem;
  border-radius: 0.5rem;
}

.card-body {
  padding: 1rem;
}

.details-container {
  display: flex;
  justify-content: space-between;
  padding: 1.5rem 0;
}

.date-container {
  margin-top: 1rem;
  display: flex;
  justify-content: flex-end;
}

.date-container .small-date {
  font-weight: bold;
}<p>최종 결과:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt78bbd8e6f9e23988/6a17f6664b055d2ed8432388/3e68ec4a38775fdfbeaa1a990e6a1e11dfb081d8-816x472.png" alt="블레이저 앱 페이지 구축의 최종 결과" /><p>애플리케이션을 실행하려면 다음 명령을 사용할 수 있습니다:</p><p><code>dotnet watch</code></p><p>해냈어요! 이제 검색창을 사용해 Elasticsearch 색인에서 책을 검색하고 저자, 카테고리, 상태별로 결과를 필터링할 수 있습니다.</p><h3>전체 텍스트 및 시맨틱 검색 수행</h3><p>기본적으로 앱은 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html">전체 텍스트</a> 검색과 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-semantic-query.html">시맨틱 검색을</a> <a href="https://www.elastic.co/search-labs/tutorials/search-tutorial/vector-search/hybrid-search">모두</a> 사용하는 하이브리드 검색을 수행합니다. 전체 텍스트 검색과 시맨틱 검색을 위한 두 가지 방법을 별도로 만든 다음 사용자의 입력에 따라 쿼리를 작성하는 방법 중 하나를 선택하여 검색 로직을 변경할 수 있습니다.</p><p><code>/Services/ElasticsearchService.cs</code> 파일의 <code>ElasticsearchService</code> 클래스에 다음 메서드를 추가합니다:</p>private static Action&lt;QueryDescriptor&lt;BookDoc&gt;&gt; BuildSemanticQuery(
    string searchTerm,
    Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets
)
{
    var filters = BuildFilters(selectedFacets);

    return query =&gt;
        query.Bool(b =&gt;
            b.Must(m =&gt; m.Semantic(sem =&gt; sem.Field("longDescription").Query(searchTerm)))
                .Filter(filters.ToArray())
        );
}

private static Action&lt;QueryDescriptor&lt;BookDoc&gt;&gt; BuildMultiMatchQuery(
    string searchTerm,
    Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets
)
{
    var filters = BuildFilters(selectedFacets);

    if (string.IsNullOrEmpty(searchTerm))
    {
        return query =&gt; query.Bool(b =&gt; b.Filter(filters.ToArray()));
    }

    return query =&gt;
        query.Bool(b =&gt;
            b.Should(m =&gt;
                    m.MultiMatch(mm =&gt;
                        mm.Query(searchTerm).Fields(new[] { "title", "shortDescription" })
                    )
                )
                .Filter(filters.ToArray())
        );
}<p>두 방법 모두 <code>BuildHybridQuery</code> 방법과 유사하게 작동하지만 전체 텍스트 또는 시맨틱 검색만 수행합니다.</p><p>선택한 검색 방법을 사용하도록 <code>SearchBooksAsync</code> 방법을 수정할 수 있습니다:</p>public async Task&lt;ElasticResponse&gt; SearchBooksAsync(
    string searchTerm,
    Dictionary&lt;string, List&lt;string&gt;&gt; selectedFacets
)
{
    try
    {
        _logger.LogInformation($"Performing search for: {searchTerm}");
        
        // Modify the query builder to use the selected search method.
        var multiMatchQuery = BuildMultiMatchQuery(searchTerm, selectedFacets); // For full text search
        var semanticQuery = BuildSemanticQuery(searchTerm, selectedFacets); // For semantic search

        // In this case we will not use retrievers, but you can add them if you want to use them.
        var response = await _client.SearchAsync&lt;BookDoc&gt;(s =&gt;
            s.Index("elastic-blazor-books")
                .Query(multiMatchQuery) // Change this line to use different search methods, for example: .Query(semanticQuery) for semantic search
                .Aggregations(aggs =&gt;
                    aggs.Add("Authors", agg =&gt; agg.Terms(t =&gt; t.Field(p =&gt; p.Authors)))
                        .Add(
                            "Categories",
                            agg =&gt; agg.Terms(t =&gt; t.Field(p =&gt; p.Categories))
                        )
                        .Add("Status", agg =&gt; agg.Terms(t =&gt; t.Field(p =&gt; p.Status)))
                )
        );

        if (response.IsValidResponse)
        {
            _logger.LogInformation($"Found {response.Documents.Count} documents");

            var hits = response.Total;
            var facets =
                response.Aggregations != null
                    ? FormatFacets(response.Aggregations)
                    : new Dictionary&lt;string, Dictionary&lt;string, long&gt;&gt;();

            var elasticResponse = new ElasticResponse
            {
                TotalHits = hits,
                Documents = response.Documents.ToList(),
                Facets = facets,
            };

            return elasticResponse;
        }
        else
        {
            _logger.LogWarning($"Invalid response: {response.DebugInformation}");
            return new ElasticResponse();
        }
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error performing search");
        return new ElasticResponse();
    }
}<p>전체 신청서는 <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/esre-with-blazor">여기에서</a>확인할 수 있습니다.</p><h2>결론</h2><p>Blazor는 C#을 사용하여 웹 애플리케이션을 빌드할 수 있는 효과적인 프레임워크입니다. Elasticsearch는 검색 애플리케이션을 구축할 수 있는 강력한 검색 엔진입니다. 이 두 가지를 결합하면 강력한 검색 애플리케이션을 쉽게 구축할 수 있으며, ESRE의 강력한 기능을 활용하여 단시간에 <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-search.html">시맨틱 검색 환경을</a> 만들 수 있습니다.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-app-with-esre-blazor</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-app-with-esre-blazor</guid>
    <category><![CDATA[벡터 데이터베이스]]></category>
    <category><![CDATA[.NET]]></category>
    <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte7424ac5f0b223b4/6a17f668414c641971945323/7ba0d6bec908bfcae966b7f38626fabd682c6f3d-1200x628.png" length="0" type="image/png"/>
    <pubDate>Wed, 09 Oct 2024 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>