<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://rastalion.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://rastalion.dev/" rel="alternate" type="text/html" hreflang="ko" /><updated>2026-10-07T13:57:51+00:00</updated><id>https://rastalion.dev/feed.xml</id><title type="html">rastalion.dev</title><subtitle>Database와 A.I 그리고 그 사이의 Data Pipeline 을 다루는 teinam 의 기술 노트.</subtitle><author><name>teinam</name></author><entry><title type="html">벡터 DB 컬렉션은 어떻게 나눌까: 레코드 구성부터 컬렉션 설계까지</title><link href="https://rastalion.dev/writing/vector-database-schema-design/" rel="alternate" type="text/html" title="벡터 DB 컬렉션은 어떻게 나눌까: 레코드 구성부터 컬렉션 설계까지" /><published>2026-10-06T15:00:00+00:00</published><updated>2026-10-07T00:00:00+00:00</updated><id>https://rastalion.dev/writing/vector-database-schema-design</id><content type="html" xml:base="https://rastalion.dev/writing/vector-database-schema-design/"><![CDATA[<p><strong>시리즈</strong> · <a href="/writing/vector-database-overview/">1. 개념과 제품·사용 지표</a> · <a href="/writing/vector-database-selection/">2. 선택 기준과 기능 비교</a> · <a href="/writing/aws-vector-search/">3. AWS 전용 벡터 검색</a> · <strong>4. 스키마와 컬렉션 설계</strong></p>

<p>사내 문서 검색 서비스를 만든다고 해 봅시다. 인사팀 규정과 영업팀 제안서가 있고 제품 사진도 검색되면 좋겠고 업계 뉴스는 매일 수백 건씩 들어옵니다. 벡터 DB를 골랐다면 다음 질문은 “이 데이터를 컬렉션 하나에 넣을까, 여러 개로 나눌까”입니다.</p>

<p>벡터 DB에서는 컬렉션을 나누는 기준이 서비스의 성능과 운영을 좌우합니다. 관계형 데이터베이스(RDB)에서 테이블을 나누는 기준과 같은 역할입니다. 벡터 차원이나 샤드 수는 컬렉션을 만들 때 한 번 정하면 나중에 바꾸기 어려운 제품이 많습니다(7·8절). 이 때문에 처음 설계할 때 따져 볼 것을 미리 알아 두는 편이 좋습니다.</p>

<p><a href="/writing/vector-database-selection/">2편</a>에서는 제품을 고르는 기준을 비교했습니다. 이 글은 제품을 고른 뒤 그 안에 <strong>데이터를 어떤 모양과 단위로 담을지</strong>를 다룹니다.</p>

<p><strong>이 글에서 알 수 있는 것</strong></p>

<ul>
  <li>벡터 DB 레코드를 이루는 네 가지(ID·벡터·원문·메타데이터)와 RDB 테이블과의 대응</li>
  <li>컬렉션을 나누는 다섯 가지 기준과, 컬렉션 대신 파티션·테넌트·필터로 나누는 방법</li>
  <li>업무 문서, 텍스트와 이미지, 매일 쌓이는 뉴스 기사에서 컬렉션을 정하는 방법</li>
  <li>여러 컬렉션을 함께 검색하는 방법과, 그때 벡터 공간을 맞춰야 하는 이유</li>
  <li>컬렉션을 나누는 것과 컬렉션 안에서 샤드를 나누는 것의 차이</li>
</ul>

<p>이 글의 문서 조사 기준일은 2026년 10월 7일입니다. Milvus는 v3.0.x 문서, Pinecone은 API 버전 2026-07 문서, pgvector는 README에 표시된 0.8.7을 기준으로 합니다. 예제의 컬렉션 이름과 데이터는 설명을 위해 만든 것입니다.</p>

<h2 id="1-먼저-알아-둘-용어는-무엇일까요">1. 먼저 알아 둘 용어는 무엇일까요</h2>

<p>이 글에 자주 나오는 용어를 먼저 정리합니다. 제품마다 이름이 다른 개념은 2절의 표에서 다시 대응시킵니다.</p>

<table>
  <thead>
    <tr>
      <th>용어</th>
      <th>쉬운 설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>임베딩(embedding)</td>
      <td>문장이나 이미지를 숫자 배열(벡터)로 바꾼 값입니다. 의미가 비슷한 데이터는 서로 가까운 벡터가 됩니다.</td>
    </tr>
    <tr>
      <td>차원(dimension)</td>
      <td>벡터에 든 숫자의 개수입니다. 768차원 벡터는 숫자 768개로 이루어집니다.</td>
    </tr>
    <tr>
      <td>컬렉션(collection)</td>
      <td>벡터와 관련 데이터를 함께 저장하고 검색하는 단위입니다. RDB의 테이블에 해당합니다.</td>
    </tr>
    <tr>
      <td>레코드(record)</td>
      <td>컬렉션에 저장하는 데이터 한 건입니다. RDB의 행(row)에 해당합니다.</td>
    </tr>
    <tr>
      <td>메타데이터(metadata)</td>
      <td>레코드에 붙이는 부가 정보입니다. 작성일·부서·카테고리 같은 값이며, 검색 조건(필터)으로 씁니다. Qdrant는 payload라고 부릅니다.</td>
    </tr>
    <tr>
      <td>거리 측정(distance metric)</td>
      <td>두 벡터가 얼마나 가까운지 계산하는 방식입니다. 코사인 유사도, 내적(dot product), 유클리드 거리(L2)가 대표적입니다.</td>
    </tr>
    <tr>
      <td>파티션(partition)</td>
      <td>한 컬렉션을 논리적으로 나눈 부분 집합입니다. 같은 스키마를 공유하면서 검색 범위를 좁히는 데 씁니다.</td>
    </tr>
    <tr>
      <td>테넌트(tenant)</td>
      <td>데이터를 서로 분리해야 하는 사용자 단위입니다. 서비스형 소프트웨어(SaaS)의 고객사 하나가 테넌트 하나인 경우가 많습니다.</td>
    </tr>
    <tr>
      <td>멀티테넌시(multi-tenancy)</td>
      <td>테넌트 여러 개의 데이터를 한 시스템에 두면서 서로 섞이지 않게 나누는 방식입니다.</td>
    </tr>
    <tr>
      <td>샤드(shard)</td>
      <td>한 컬렉션의 데이터를 여러 조각으로 나눠 여러 노드에 분산한 것입니다. 데이터가 커질 때 수평 확장에 씁니다.</td>
    </tr>
  </tbody>
</table>

<h2 id="2-벡터-db의-레코드는-무엇으로-이루어질까요">2. 벡터 DB의 레코드는 무엇으로 이루어질까요</h2>

<p>벡터 DB 제품은 저장 단위를 부르는 이름이 서로 다릅니다. 그래도 레코드 한 건을 열어 보면 대부분 네 가지로 이루어져 있습니다.</p>

<ul>
  <li><strong>ID:</strong> 레코드를 구별하는 고유 값입니다. RDB의 기본 키(primary key)에 해당합니다.</li>
  <li><strong>벡터:</strong> 원문을 임베딩한 숫자 배열입니다. 유사도 검색은 이 값으로 합니다.</li>
  <li><strong>원문:</strong> 벡터를 만든 원래 텍스트입니다. 검색 결과로 사용자에게 보여 주거나 대규모 언어 모델(LLM)에 넘길 때 씁니다.</li>
  <li><strong>메타데이터:</strong> 작성일·부서·카테고리처럼 검색 범위를 좁히는 데 쓰는 값입니다.</li>
</ul>

<p>Chroma의 레코드는 이 네 가지 구성을 그대로 따릅니다. Chroma에서 컬렉션은 저장과 질의의 기본 단위입니다. 레코드마다 고유한 문자열 <code class="language-plaintext highlighter-rouge">id</code>가 필요하고 원문(<code class="language-plaintext highlighter-rouge">documents</code>)과 벡터(<code class="language-plaintext highlighter-rouge">embeddings</code>) 중 하나 또는 둘 다를 넣습니다. 메타데이터(<code class="language-plaintext highlighter-rouge">metadatas</code>)는 언제나 선택 사항입니다. 컬렉션마다 임베딩 함수를 지정하며 기본값은 sentence transformer 모델입니다.<sup id="fnref:chroma" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">1</a></sup></p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 776 398" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vdb-record-structure-archify-diagram-title">벡터 DB 레코드 구조와 RDB 테이블의 대응</title>
        <desc id="vdb-record-structure-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vdb-record-structure-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vdb-record-structure-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vdb-record-structure-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vdb-record-structure-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vdb-record-structure-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vdb-record-structure-grid)" />

        <!-- Boundaries (behind everything) -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="region" data-composition-frame-id="0" data-composition-frame-label="컬렉션 · 레코드" x="10" y="50" width="726" height="110" rx="12" class="c-region" stroke-width="1" />

        <rect data-graph-role="structural-frame" data-composition-frame-kind="region" data-composition-frame-id="1" data-composition-frame-label="RDB 테이블 · 행" x="10" y="220" width="726" height="110" rx="12" class="c-region" stroke-width="1" />

        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="rec_id" data-edge-to="pk_col" data-edge-key="0" data-composition-points="115,140;115,250" d="M 115 140 L 115 250" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-record-structure-arrowhead-dashed)" />
        <path data-edge-from="rec_vector" data-edge-to="vector_col" data-edge-key="1" data-composition-points="287,140;287,250" d="M 287 140 L 287 250" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-record-structure-arrowhead-dashed)" />
        <path data-edge-from="rec_text" data-edge-to="text_col" data-edge-key="2" data-composition-points="459,140;459,250" d="M 459 140 L 459 250" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-record-structure-arrowhead-dashed)" />
        <path data-edge-from="rec_meta" data-edge-to="meta_col" data-edge-key="3" data-composition-points="631,140;631,250" d="M 631 140 L 631 250" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-record-structure-arrowhead-dashed)" />

        <!-- Components -->
        <g id="vdb-record-structure-node-rec_id" data-node-id="rec_id" data-node-label="ID" tabindex="0" role="button" aria-label="Focus ID, 레코드 식별자, 컬렉션 · 레코드" aria-pressed="false" data-node-kind="database" data-node-sublabel="레코드 식별자" data-node-context="컬렉션 · 레코드">
          <title>ID · 레코드 식별자 · 컬렉션 · 레코드</title>
          <rect x="40" y="80" width="150" height="60" rx="6" class="c-mask" />
          <rect x="40" y="80" width="150" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(46 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="115" y="108" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">ID</text>
        <text data-detail="context" x="115" y="124" class="t-muted" font-size="9" text-anchor="middle">레코드 식별자</text>
        </g>

        <g id="vdb-record-structure-node-rec_vector" data-node-id="rec_vector" data-node-label="벡터" tabindex="0" role="button" aria-label="Focus 벡터, [0.12, -0.03, …], 컬렉션 · 레코드" aria-pressed="false" data-node-kind="database" data-node-sublabel="[0.12, -0.03, …]" data-node-context="컬렉션 · 레코드">
          <title>벡터 · [0.12, -0.03, …] · 컬렉션 · 레코드</title>
          <rect x="212" y="80" width="150" height="60" rx="6" class="c-mask" />
          <rect x="212" y="80" width="150" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(218 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="287" y="108" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">벡터</text>
        <text data-detail="context" x="287" y="124" class="t-muted" font-size="9" text-anchor="middle">[0.12, -0.03, …]</text>
        </g>

        <g id="vdb-record-structure-node-rec_text" data-node-id="rec_text" data-node-label="원문" tabindex="0" role="button" aria-label="Focus 원문, 벡터로 바꾼 텍스트, 컬렉션 · 레코드" aria-pressed="false" data-node-kind="database" data-node-sublabel="벡터로 바꾼 텍스트" data-node-context="컬렉션 · 레코드">
          <title>원문 · 벡터로 바꾼 텍스트 · 컬렉션 · 레코드</title>
          <rect x="384" y="80" width="150" height="60" rx="6" class="c-mask" />
          <rect x="384" y="80" width="150" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(390 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="459" y="108" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">원문</text>
        <text data-detail="context" x="459" y="124" class="t-muted" font-size="9" text-anchor="middle">벡터로 바꾼 텍스트</text>
        </g>

        <g id="vdb-record-structure-node-rec_meta" data-node-id="rec_meta" data-node-label="메타데이터" tabindex="0" role="button" aria-label="Focus 메타데이터, {부서, 작성일}, 컬렉션 · 레코드" aria-pressed="false" data-node-kind="database" data-node-sublabel="{부서, 작성일}" data-node-context="컬렉션 · 레코드">
          <title>메타데이터 · {부서, 작성일} · 컬렉션 · 레코드</title>
          <rect x="556" y="80" width="150" height="60" rx="6" class="c-mask" />
          <rect x="556" y="80" width="150" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(562 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="631" y="108" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">메타데이터</text>
        <text data-detail="context" x="631" y="124" class="t-muted" font-size="9" text-anchor="middle">{부서, 작성일}</text>
        </g>

        <g id="vdb-record-structure-node-pk_col" data-node-id="pk_col" data-node-label="기본 키 열" tabindex="0" role="button" aria-label="Focus 기본 키 열, PRIMARY KEY, RDB 테이블 · 행" aria-pressed="false" data-node-kind="backend" data-node-sublabel="PRIMARY KEY" data-node-context="RDB 테이블 · 행">
          <title>기본 키 열 · PRIMARY KEY · RDB 테이블 · 행</title>
          <rect x="40" y="250" width="150" height="60" rx="6" class="c-mask" />
          <rect x="40" y="250" width="150" height="60" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(46 256) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="115" y="278" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">기본 키 열</text>
        <text data-detail="context" x="115" y="294" class="t-muted" font-size="9" text-anchor="middle">PRIMARY KEY</text>
        </g>

        <g id="vdb-record-structure-node-vector_col" data-node-id="vector_col" data-node-label="vector 타입 열" tabindex="0" role="button" aria-label="Focus vector 타입 열, 벡터 값 저장, RDB 테이블 · 행" aria-pressed="false" data-node-kind="backend" data-node-sublabel="벡터 값 저장" data-node-context="RDB 테이블 · 행">
          <title>vector 타입 열 · 벡터 값 저장 · RDB 테이블 · 행</title>
          <rect x="212" y="250" width="150" height="60" rx="6" class="c-mask" />
          <rect x="212" y="250" width="150" height="60" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(218 256) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="287" y="278" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">vector 타입 열</text>
        <text data-detail="context" x="287" y="294" class="t-muted" font-size="9" text-anchor="middle">벡터 값 저장</text>
        </g>

        <g id="vdb-record-structure-node-text_col" data-node-id="text_col" data-node-label="일반 열" tabindex="0" role="button" aria-label="Focus 일반 열, 텍스트, RDB 테이블 · 행" aria-pressed="false" data-node-kind="backend" data-node-sublabel="텍스트" data-node-context="RDB 테이블 · 행">
          <title>일반 열 · 텍스트 · RDB 테이블 · 행</title>
          <rect x="384" y="250" width="150" height="60" rx="6" class="c-mask" />
          <rect x="384" y="250" width="150" height="60" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(390 256) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="459" y="278" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">일반 열</text>
        <text data-detail="context" x="459" y="294" class="t-muted" font-size="9" text-anchor="middle">텍스트</text>
        </g>

        <g id="vdb-record-structure-node-meta_col" data-node-id="meta_col" data-node-label="일반 열" tabindex="0" role="button" aria-label="Focus 일반 열, 부서 · 작성일, RDB 테이블 · 행" aria-pressed="false" data-node-kind="backend" data-node-sublabel="부서 · 작성일" data-node-context="RDB 테이블 · 행">
          <title>일반 열 · 부서 · 작성일 · RDB 테이블 · 행</title>
          <rect x="556" y="250" width="150" height="60" rx="6" class="c-mask" />
          <rect x="556" y="250" width="150" height="60" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(562 256) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="631" y="278" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">일반 열</text>
        <text data-detail="context" x="631" y="294" class="t-muted" font-size="9" text-anchor="middle">부서 · 작성일</text>
        </g>

        <!-- Connection labels -->





        <!-- Boundary labels (foreground masks keep routes out of titles) -->
        <g data-graph-role="structural-frame-label" data-composition-frame-id="0" data-composition-frame-kind="region" data-composition-frame-label="컬렉션 · 레코드">
          <rect data-graph-role="structural-frame-label-mask" x="14" y="60" width="91" height="16" rx="3" class="c-mask" />
          <text data-boundary-label="" x="18" y="73" class="t-cloud" font-size="9" font-weight="600">컬렉션 · 레코드</text>
        </g>

        <g data-graph-role="structural-frame-label" data-composition-frame-id="1" data-composition-frame-kind="region" data-composition-frame-label="RDB 테이블 · 행">
          <rect data-graph-role="structural-frame-label-mask" x="14" y="230" width="91" height="16" rx="3" class="c-mask" />
          <text data-boundary-label="" x="18" y="243" class="t-cloud" font-size="9" font-weight="600">RDB 테이블 · 행</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="362" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="테이블의 열" data-legend-x="40" data-legend-baseline="382" data-legend-width="98">
            <rect x="40" y="373" width="16" height="10" rx="2.5" class="c-backend" stroke-width="1" />
            <text x="62" y="382" class="t-muted" font-size="10" font-weight="500">테이블의 열</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="레코드의 칸" data-legend-x="160" data-legend-baseline="382" data-legend-width="98">
            <rect x="160" y="373" width="16" height="10" rx="2.5" class="c-database" stroke-width="1" />
            <text x="182" y="382" class="t-muted" font-size="10" font-weight="500">레코드의 칸</text>
          </g>
        </g>
      </svg>
</div><figcaption>레코드는 ID·벡터·원문·메타데이터로 이루어지며 RDB의 행과 열에 대응합니다</figcaption>
</figure>

<h3 id="rdb-테이블과는-어떻게-대응할까요">RDB 테이블과는 어떻게 대응할까요</h3>

<p>컬렉션을 RDB 테이블, 레코드를 행으로 보면 이해하기 쉽습니다. pgvector는 이 대응이 실제 구조 그대로입니다. pgvector는 PostgreSQL 확장이므로 별도의 컬렉션 개념 없이 일반 테이블에 <code class="language-plaintext highlighter-rouge">vector</code> 타입 열을 추가합니다.<sup id="fnref:pgvector" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">2</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 테이블 = 컬렉션, 행 = 레코드</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">doc_chunks</span> <span class="p">(</span>
  <span class="n">id</span>        <span class="n">bigserial</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>  <span class="c1">-- ID</span>
  <span class="n">content</span>   <span class="nb">text</span><span class="p">,</span>                   <span class="c1">-- 원문</span>
  <span class="n">metadata</span>  <span class="n">jsonb</span><span class="p">,</span>                  <span class="c1">-- 메타데이터</span>
  <span class="n">embedding</span> <span class="n">vector</span><span class="p">(</span><span class="mi">768</span><span class="p">)</span>             <span class="c1">-- 벡터(768차원)</span>
<span class="p">);</span>

<span class="c1">-- 주어진 벡터와 L2 거리가 가까운 순서로 5건 조회</span>
<span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">content</span>
<span class="k">FROM</span> <span class="n">doc_chunks</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">embedding</span> <span class="o">&lt;-&gt;</span> <span class="s1">'[0.12, -0.03, ...]'</span>
<span class="k">LIMIT</span> <span class="mi">5</span><span class="p">;</span>
</code></pre></div></div>

<p>앞 SQL의 <code class="language-plaintext highlighter-rouge">'[0.12, -0.03, ...]'</code>는 자리 표시입니다. 실제 질의에는 768개 값을 모두 넣습니다. <code class="language-plaintext highlighter-rouge">&lt;-&gt;</code>는 pgvector의 L2 거리 연산자입니다.</p>

<p>Milvus는 RDB와 어떻게 대응하는지를 문서에서 직접 설명합니다. 문서는 컬렉션을 고정된 열과 가변적인 행으로 이루어진 2차원 표로 소개합니다. 행 하나를 엔티티(entity), 열 하나를 필드(field)라고 부릅니다. 기본 키 필드(primary field)의 값은 엔티티 하나를 고유하게 가리키며 <code class="language-plaintext highlighter-rouge">Int64</code> 또는 <code class="language-plaintext highlighter-rouge">VARCHAR</code>만 받습니다.<sup id="fnref:milvus-collection" role="doc-noteref"><a href="#fn:milvus-collection" class="footnote" rel="footnote">3</a></sup></p>

<p>Milvus 스키마(schema)는 기본 키 필드, 하나 이상의 벡터 필드, 선택적인 스칼라 필드(숫자·문자열 같은 일반 값)로 구성됩니다. 벡터 필드에는 <code class="language-plaintext highlighter-rouge">dim</code> 파라미터로 차원을 지정합니다.<sup id="fnref:milvus-schema" role="doc-noteref"><a href="#fn:milvus-schema" class="footnote" rel="footnote">4</a></sup> Milvus에는 컬렉션 위에 데이터베이스(database)라는 논리 단위도 있습니다. 한 데이터베이스에 컬렉션을 여러 개 두며 기본 데이터베이스는 삭제할 수 없습니다.<sup id="fnref:milvus-database" role="doc-noteref"><a href="#fn:milvus-database" class="footnote" rel="footnote">5</a></sup></p>

<h3 id="제품마다-부르는-이름은-어떻게-다를까요">제품마다 부르는 이름은 어떻게 다를까요</h3>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>컬렉션에 해당하는 단위</th>
      <th>레코드와 구성 요소</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Chroma</td>
      <td>collection</td>
      <td>record: <code class="language-plaintext highlighter-rouge">id</code>, 벡터(<code class="language-plaintext highlighter-rouge">embeddings</code>), 원문(<code class="language-plaintext highlighter-rouge">documents</code>), 메타데이터(<code class="language-plaintext highlighter-rouge">metadatas</code>)</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>collection</td>
      <td>point: ID, 벡터(이름 붙인 벡터 여러 개 가능), payload</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>collection(database 아래)</td>
      <td>entity: 기본 키 필드, 벡터 필드, 스칼라 필드, 동적 필드</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>collection</td>
      <td>object: UUID, properties, 벡터(이름 붙인 벡터 여러 개 가능)</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td>index(namespace로 나눔)</td>
      <td>record: ID, dense 벡터·sparse 벡터, 메타데이터</td>
    </tr>
    <tr>
      <td>pgvector</td>
      <td>테이블</td>
      <td>행: 기본 키 열, <code class="language-plaintext highlighter-rouge">vector(n)</code> 열, 나머지 열</td>
    </tr>
    <tr>
      <td>Elasticsearch</td>
      <td>index</td>
      <td>문서: <code class="language-plaintext highlighter-rouge">dense_vector</code> 필드, 나머지 필드</td>
    </tr>
  </tbody>
</table>

<p>Qdrant에서 컬렉션은 point(payload가 붙은 벡터)를 모은 집합이며 컬렉션마다 이름이 있습니다. 검색은 이 집합을 대상으로 합니다. point의 ID로는 64비트 부호 없는 정수와 UUID를 쓸 수 있습니다.<sup id="fnref:qdrant-points" role="doc-noteref"><a href="#fn:qdrant-points" class="footnote" rel="footnote">6</a></sup> Weaviate는 모든 객체에 UUID를 부여하며 이 UUID는 모든 컬렉션에 걸쳐 고유합니다.<sup id="fnref:weaviate-data" role="doc-noteref"><a href="#fn:weaviate-data" class="footnote" rel="footnote">7</a></sup></p>

<p>Pinecone에서는 프로젝트 안에 인덱스가 있고 인덱스 안을 namespace로 나눕니다. 레코드는 ID와 벡터 하나(하이브리드 검색용 인덱스는 dense·sparse 두 종류), 선택적인 메타데이터로 이루어집니다.<sup id="fnref:pinecone-concepts" role="doc-noteref"><a href="#fn:pinecone-concepts" class="footnote" rel="footnote">8</a></sup> sparse 벡터는 차원 수는 매우 많지만 그 가운데 0이 아닌 값은 적은 벡터이고 dense 벡터는 대부분의 값이 0이 아닌 일반적인 벡터입니다. Elasticsearch는 인덱스의 <code class="language-plaintext highlighter-rouge">dense_vector</code> 필드에 벡터를 저장하고 나머지 필드에 원문과 메타데이터를 둡니다.<sup id="fnref:es-dense-vector" role="doc-noteref"><a href="#fn:es-dense-vector" class="footnote" rel="footnote">9</a></sup></p>

<p>Qdrant의 point 하나는 JSON으로 다음과 같이 생겼습니다. payload의 키와 값은 예시입니다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">129</span><span class="p">,</span><span class="w">
  </span><span class="nl">"vector"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mf">0.1</span><span class="p">,</span><span class="w"> </span><span class="mf">0.2</span><span class="p">,</span><span class="w"> </span><span class="mf">0.3</span><span class="p">,</span><span class="w"> </span><span class="mf">0.4</span><span class="p">],</span><span class="w">
  </span><span class="nl">"payload"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"department"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hr"</span><span class="p">,</span><span class="w"> </span><span class="nl">"created_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-10-01"</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="한-컬렉션-안의-레코드는-모양이-모두-같아야-할까요">한 컬렉션 안의 레코드는 모양이 모두 같아야 할까요</h3>

<p>꼭 그렇지는 않습니다. Chroma는 원문만 있는 레코드, 벡터만 있는 레코드, 둘 다 있는 레코드를 한 컬렉션에 받습니다. 메타데이터도 레코드마다 있을 수도 없을 수도 있습니다.<sup id="fnref:chroma:1" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">1</a></sup> Milvus는 동적 필드를 켜면 스키마에 정의하지 않은 필드를 <code class="language-plaintext highlighter-rouge">$meta</code>라는 예약된 JSON 필드에 키-값 쌍으로 저장합니다. 동적 필드는 <code class="language-plaintext highlighter-rouge">enable_dynamic_field=True</code>로 켭니다.<sup id="fnref:milvus-dynamic" role="doc-noteref"><a href="#fn:milvus-dynamic" class="footnote" rel="footnote">10</a></sup></p>

<p>다만 <strong>벡터의 차원과 거리 측정 방식은 컬렉션 안에서 같아야 합니다.</strong> Qdrant 문서는 한 컬렉션에 있는 모든 point의 벡터가 차원이 같고 하나의 거리 측정으로 비교된다고 명시합니다.<sup id="fnref:qdrant-collections" role="doc-noteref"><a href="#fn:qdrant-collections" class="footnote" rel="footnote">11</a></sup> 유연한 메타데이터와 달리 벡터에는 엄격한 규칙이 적용되는 구조입니다. 이 규칙은 7절에서 여러 컬렉션을 함께 검색할 때 다시 중요해집니다.</p>

<h2 id="3-컬렉션은-어떤-단위로-만들까요">3. 컬렉션은 어떤 단위로 만들까요</h2>

<p>컬렉션을 도출하는 방법은 RDB 테이블을 도출하는 방법과 크게 다르지 않습니다. <strong>비슷한 데이터의 묶음이자 비슷한 서비스를 제공하는 단위</strong>가 컬렉션 후보입니다. 그 후보를 다시 나눌지 합칠지는 다음 다섯 가지 기준으로 판단합니다.</p>

<table>
  <thead>
    <tr>
      <th>기준</th>
      <th>확인할 질문</th>
      <th>나누는 쪽으로 기우는 경우</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>크기와 확장</td>
      <td>데이터가 얼마나 빨리 늘고, 한 컬렉션으로 감당할 수 있는가</td>
      <td>한 덩어리로는 색인·검색·백업이 부담스러울 때(먼저 8절의 샤드를 검토)</td>
    </tr>
    <tr>
      <td>접근 패턴</td>
      <td>어떤 데이터를 함께, 얼마나 자주 검색하는가</td>
      <td>늘 따로 검색되고 함께 검색할 일이 없을 때</td>
    </tr>
    <tr>
      <td>생명주기</td>
      <td>데이터가 언제 생기고 언제 지워지는가</td>
      <td>보관 기간이 달라 묶음째 지우거나 옮겨야 할 때</td>
    </tr>
    <tr>
      <td>속성</td>
      <td>같은 필드 구성·임베딩 모델·거리 측정을 쓰는가</td>
      <td>벡터 차원이나 모델, 필드 구성이 다를 때</td>
    </tr>
    <tr>
      <td>운영 용이성</td>
      <td>컬렉션이 늘면 관리할 대상이 얼마나 늘어나는가</td>
      <td>반대로 컬렉션이 너무 많아지면 합치는 쪽을 검토</td>
    </tr>
  </tbody>
</table>

<p>다섯 기준은 서로 당기는 방향이 다릅니다. 생명주기와 속성은 나누는 쪽으로 기웁니다. 운영 용이성은 반대로 합치는 쪽을 가리킵니다. 다섯 기준을 함께 놓고 가장 단순한 구조를 찾는 것이 출발점입니다.</p>

<h3 id="컬렉션-말고도-나누는-방법이-있습니다">컬렉션 말고도 나누는 방법이 있습니다</h3>

<p>데이터를 나눠야 한다고 해서 꼭 컬렉션을 새로 만들 필요는 없습니다. 대부분의 제품은 컬렉션보다 가벼운 분리 수단을 함께 제공합니다. 나누는 수준을 위에서부터 놓으면 다음과 같습니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 776 480" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vdb-split-levels-archify-diagram-title">데이터를 나누는 수준: 컬렉션, 파티션·테넌트, 메타데이터 필터, 샤드</title>
        <desc id="vdb-split-levels-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vdb-split-levels-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vdb-split-levels-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vdb-split-levels-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vdb-split-levels-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vdb-split-levels-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vdb-split-levels-grid)" />

        <!-- Boundaries (behind everything) -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="region" data-composition-frame-id="0" data-composition-frame-label="목적: 검색 범위 나누기" x="24" y="56" width="496" height="356" rx="12" class="c-region" stroke-width="1" />

        <rect data-graph-role="structural-frame" data-composition-frame-kind="security-group" data-composition-frame-id="1" data-composition-frame-label="목적: 확장" x="540" y="176" width="196" height="116" rx="8" class="c-security-group" stroke-width="1" />

        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="collection" data-edge-to="partition" data-edge-label="더 가볍게" data-edge-key="0" data-composition-points="140,152;140,200" d="M 140 152 L 140 200" class="a-default" stroke-width="1.5" marker-end="url(#vdb-split-levels-arrowhead)" />
        <path data-edge-from="partition" data-edge-to="filter" data-edge-label="더 가볍게" data-edge-key="1" data-composition-points="140,272;140,320" d="M 140 272 L 140 320" class="a-default" stroke-width="1.5" marker-end="url(#vdb-split-levels-arrowhead)" />
        <path data-edge-from="collection" data-edge-to="collection_ex" data-edge-key="2" data-composition-points="240,116;288,116" d="M 240 116 L 288 116" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-split-levels-arrowhead-dashed)" />
        <path data-edge-from="partition" data-edge-to="partition_ex" data-edge-key="3" data-composition-points="240,236;288,236" d="M 240 236 L 288 236" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-split-levels-arrowhead-dashed)" />
        <path data-edge-from="filter" data-edge-to="filter_ex" data-edge-key="4" data-composition-points="240,356;288,356" d="M 240 356 L 288 356" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-split-levels-arrowhead-dashed)" />

        <!-- Components -->
        <g id="vdb-split-levels-node-collection" data-node-id="collection" data-node-label="데이터베이스 · 컬렉션" tabindex="0" role="button" aria-label="Focus 데이터베이스 · 컬렉션, 스키마·모델까지 분리, 목적: 검색 범위 나누기" aria-pressed="false" data-node-kind="database" data-node-sublabel="스키마·모델까지 분리" data-node-tag="가장 무거움" data-node-context="목적: 검색 범위 나누기">
          <title>데이터베이스 · 컬렉션 · 스키마·모델까지 분리 · 목적: 검색 범위 나누기 · 가장 무거움</title>
          <rect x="40" y="80" width="200" height="72" rx="6" class="c-mask" />
          <rect x="40" y="80" width="200" height="72" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(46 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="140" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">데이터베이스 · 컬렉션</text>
        <text data-detail="context" x="140" y="130" class="t-muted" font-size="9" text-anchor="middle">스키마·모델까지 분리</text>
        <text data-detail="fine" x="140" y="144" class="t-database" font-size="7" text-anchor="middle">가장 무거움</text>
        </g>

        <g id="vdb-split-levels-node-collection_ex" data-node-id="collection_ex" data-node-label="Milvus database·collection" tabindex="0" role="button" aria-label="Focus Milvus database·collection, Qdrant · Weaviate collection, 목적: 검색 범위 나누기" aria-pressed="false" data-node-kind="external" data-node-sublabel="Qdrant · Weaviate collection" data-node-tag="Pinecone index" data-node-context="목적: 검색 범위 나누기">
          <title>Milvus database·collection · Qdrant · Weaviate collection · 목적: 검색 범위 나누기 · Pinecone index</title>
          <rect x="288" y="80" width="216" height="72" rx="6" class="c-mask" />
          <rect x="288" y="80" width="216" height="72" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(294 86) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="396" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Milvus database·collection</text>
        <text data-detail="context" x="396" y="130" class="t-muted" font-size="9" text-anchor="middle">Qdrant · Weaviate collection</text>
        <text data-detail="fine" x="396" y="144" class="t-external" font-size="7" text-anchor="middle">Pinecone index</text>
        </g>

        <g id="vdb-split-levels-node-partition" data-node-id="partition" data-node-label="파티션 · 테넌트 · namespace" tabindex="0" role="button" aria-label="Focus 파티션 · 테넌트 · namespace, 같은 스키마 · 검색 범위 분리, 목적: 검색 범위 나누기" aria-pressed="false" data-node-kind="database" data-node-sublabel="같은 스키마 · 검색 범위 분리" data-node-context="목적: 검색 범위 나누기">
          <title>파티션 · 테넌트 · namespace · 같은 스키마 · 검색 범위 분리 · 목적: 검색 범위 나누기</title>
          <rect x="40" y="200" width="200" height="72" rx="6" class="c-mask" />
          <rect x="40" y="200" width="200" height="72" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(46 206) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="140" y="234" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">파티션 · 테넌트 · namespace</text>
        <text data-detail="context" x="140" y="250" class="t-muted" font-size="9" text-anchor="middle">같은 스키마 · 검색 범위 분리</text>
        </g>

        <g id="vdb-split-levels-node-partition_ex" data-node-id="partition_ex" data-node-label="Milvus partition·partition key" tabindex="0" role="button" aria-label="Focus Milvus partition·partition key, Qdrant is_tenant · Weaviate tenant, 목적: 검색 범위 나누기" aria-pressed="false" data-node-kind="external" data-node-sublabel="Qdrant is_tenant · Weaviate tenant" data-node-tag="Pinecone namespace" data-node-context="목적: 검색 범위 나누기">
          <title>Milvus partition·partition key · Qdrant is_tenant · Weaviate tenant · 목적: 검색 범위 나누기 · Pinecone namespace</title>
          <rect x="288" y="200" width="216" height="72" rx="6" class="c-mask" />
          <rect x="288" y="200" width="216" height="72" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(294 206) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="396" y="234" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Milvus partition·partition key</text>
        <text data-detail="context" x="396" y="250" class="t-muted" font-size="9" text-anchor="middle">Qdrant is_tenant · Weaviate tenant</text>
        <text data-detail="fine" x="396" y="264" class="t-external" font-size="7" text-anchor="middle">Pinecone namespace</text>
        </g>

        <g id="vdb-split-levels-node-filter" data-node-id="filter" data-node-label="메타데이터 필터" tabindex="0" role="button" aria-label="Focus 메타데이터 필터, 한 덩어리 안에서 조건으로 좁힘, 목적: 검색 범위 나누기" aria-pressed="false" data-node-kind="database" data-node-sublabel="한 덩어리 안에서 조건으로 좁힘" data-node-tag="가장 가벼움" data-node-context="목적: 검색 범위 나누기">
          <title>메타데이터 필터 · 한 덩어리 안에서 조건으로 좁힘 · 목적: 검색 범위 나누기 · 가장 가벼움</title>
          <rect x="40" y="320" width="200" height="72" rx="6" class="c-mask" />
          <rect x="40" y="320" width="200" height="72" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(46 326) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="140" y="354" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">메타데이터 필터</text>
        <text data-detail="context" x="140" y="370" class="t-muted" font-size="9" text-anchor="middle">한 덩어리 안에서 조건으로 좁힘</text>
        <text data-detail="fine" x="140" y="384" class="t-database" font-size="7" text-anchor="middle">가장 가벼움</text>
        </g>

        <g id="vdb-split-levels-node-filter_ex" data-node-id="filter_ex" data-node-label="필터 조건" tabindex="0" role="button" aria-label="Focus 필터 조건, Milvus · Qdrant · Weaviate · Pinecone, 목적: 검색 범위 나누기" aria-pressed="false" data-node-kind="external" data-node-sublabel="Milvus · Qdrant · Weaviate · Pinecone" data-node-tag="네 제품 공통" data-node-context="목적: 검색 범위 나누기">
          <title>필터 조건 · Milvus · Qdrant · Weaviate · Pinecone · 목적: 검색 범위 나누기 · 네 제품 공통</title>
          <rect x="288" y="320" width="216" height="72" rx="6" class="c-mask" />
          <rect x="288" y="320" width="216" height="72" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(294 326) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="396" y="354" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">필터 조건</text>
        <text data-detail="context" x="396" y="370" class="t-muted" font-size="9" text-anchor="middle">Milvus · Qdrant · Weaviate · Pinecone</text>
        <text data-detail="fine" x="396" y="384" class="t-external" font-size="7" text-anchor="middle">네 제품 공통</text>
        </g>

        <g id="vdb-split-levels-node-shard" data-node-id="shard" data-node-label="샤드" tabindex="0" role="button" aria-label="Focus 샤드, 한 컬렉션의 수평 조각, 목적: 확장" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="한 컬렉션의 수평 조각" data-node-tag="노드에 분산" data-node-context="목적: 확장">
          <title>샤드 · 한 컬렉션의 수평 조각 · 목적: 확장 · 노드에 분산</title>
          <rect x="556" y="200" width="164" height="72" rx="6" class="c-mask" />
          <rect x="556" y="200" width="164" height="72" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(562 206) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="638" y="234" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">샤드</text>
        <text data-detail="context" x="638" y="250" class="t-muted" font-size="9" text-anchor="middle">한 컬렉션의 수평 조각</text>
        <text data-detail="fine" x="638" y="264" class="t-cloud" font-size="7" text-anchor="middle">노드에 분산</text>
        </g>

        <!-- Connection labels -->
        <g data-detail="context" data-edge-from="collection" data-edge-to="partition" data-edge-label="더 가볍게" data-edge-key="0">
          <rect x="113.4" y="156" width="53.199999999999996" height="14" rx="3" class="c-mask" />
          <text x="140" y="166" class="t-muted" font-size="8" text-anchor="middle">더 가볍게</text>
        </g>
        <g data-detail="context" data-edge-from="partition" data-edge-to="filter" data-edge-label="더 가볍게" data-edge-key="1">
          <rect x="113.4" y="276" width="53.199999999999996" height="14" rx="3" class="c-mask" />
          <text x="140" y="286" class="t-muted" font-size="8" text-anchor="middle">더 가볍게</text>
        </g>




        <!-- Boundary labels (foreground masks keep routes out of titles) -->
        <g data-graph-role="structural-frame-label" data-composition-frame-id="0" data-composition-frame-kind="region" data-composition-frame-label="목적: 검색 범위 나누기">
          <rect data-graph-role="structural-frame-label-mask" x="28" y="60" width="128.8" height="16" rx="3" class="c-mask" />
          <text data-boundary-label="" x="32" y="73" class="t-cloud" font-size="9" font-weight="600">목적: 검색 범위 나누기</text>
        </g>

        <g data-graph-role="structural-frame-label" data-composition-frame-id="1" data-composition-frame-kind="security-group" data-composition-frame-label="목적: 확장">
          <rect data-graph-role="structural-frame-label-mask" x="544" y="180" width="64" height="16" rx="3" class="c-mask" />
          <text data-boundary-label="" x="548" y="193" class="t-security" font-size="9" font-weight="600">목적: 확장</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="444" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="검색 범위를 나누는 수준" data-legend-x="40" data-legend-baseline="464" data-legend-width="158">
            <rect x="40" y="455" width="16" height="10" rx="2.5" class="c-database" stroke-width="1" />
            <text x="62" y="464" class="t-muted" font-size="10" font-weight="500">검색 범위를 나누는 수준</text>
          </g>
          <g data-legend-semantic-kind="cloud" data-legend-kind="cloud" data-legend-label="확장 단위" data-legend-x="220" data-legend-baseline="464" data-legend-width="88">
            <rect x="220" y="455" width="16" height="10" rx="2.5" class="c-cloud" stroke-width="1" />
            <text x="242" y="464" class="t-muted" font-size="10" font-weight="500">확장 단위</text>
          </g>
          <g data-legend-semantic-kind="external" data-legend-kind="external" data-legend-label="제품 예" data-legend-x="330" data-legend-baseline="464" data-legend-width="78">
            <rect x="330" y="455" width="16" height="10" rx="2.5" class="c-external" stroke-width="1" />
            <text x="352" y="464" class="t-muted" font-size="10" font-weight="500">제품 예</text>
          </g>
        </g>
      </svg>
</div><figcaption>데이터를 나누는 수준: 컬렉션, 파티션·테넌트, 메타데이터 필터, 샤드</figcaption>
</figure>

<p>공식 문서는 테넌트나 사용자마다 컬렉션을 만드는 방식을 기본값으로 권하지 않습니다.</p>

<p><strong>Qdrant</strong>는 대부분의 경우 컬렉션 하나를 쓰고 payload(메타데이터)로 데이터를 나누라고 안내하며 이 방식을 멀티테넌시라고 부릅니다. 문서에 따르면 컬렉션을 여러 개 만드는 방식은 유연하지만 컬렉션이 많아지면 리소스 오버헤드로 비용이 커질 수 있습니다. 여러 컬렉션은 사용자 수가 적고 격리가 필요할 때 쓰는 방법으로 둡니다. 테넌트마다 컬렉션을 따로 만드는 방식은 가장 효율적인 경우가 드물다고도 적습니다.<sup id="fnref:qdrant-collections:1" role="doc-noteref"><a href="#fn:qdrant-collections" class="footnote" rel="footnote">11</a></sup><sup id="fnref:qdrant-multitenancy" role="doc-noteref"><a href="#fn:qdrant-multitenancy" class="footnote" rel="footnote">12</a></sup></p>

<p>Qdrant에서 테넌트를 구분하는 keyword payload 인덱스에 <code class="language-plaintext highlighter-rouge">is_tenant=true</code>(v1.11.0+)를 주면, 같은 테넌트의 벡터를 저장소에서 가까이 모아 둡니다. 문서는 이렇게 하면 순차 읽기를 활용해 성능이 크게 좋아질 수 있다고 적습니다.<sup id="fnref:qdrant-multitenancy:1" role="doc-noteref"><a href="#fn:qdrant-multitenancy" class="footnote" rel="footnote">12</a></sup></p>

<p><strong>Milvus</strong>는 멀티테넌시를 네 수준으로 나눠 비교합니다.<sup id="fnref:milvus-tenancy" role="doc-noteref"><a href="#fn:milvus-tenancy" class="footnote" rel="footnote">13</a></sup></p>

<table>
  <thead>
    <tr>
      <th>수준</th>
      <th>테넌트 수 상한(기본)</th>
      <th>특징</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Database</td>
      <td>64</td>
      <td>테넌트마다 데이터베이스 하나, 물리적 격리, 스키마 유연성이 가장 높음</td>
    </tr>
    <tr>
      <td>Collection</td>
      <td>65,536</td>
      <td>테넌트마다 컬렉션 하나, 물리적 격리</td>
    </tr>
    <tr>
      <td>Partition</td>
      <td>컬렉션당 1,024</td>
      <td>공유 컬렉션 안에 테넌트마다 파티션을 직접 생성</td>
    </tr>
    <tr>
      <td>Partition key</td>
      <td>수백만</td>
      <td>파티션 키 값에 따라 물리적으로 분리된 16개 파티션에 자동 분배</td>
    </tr>
  </tbody>
</table>

<p>같은 문서의 비교표에서 검색 성능은 Database·Collection 수준이 더 높고 파티션 계열은 중간으로 정리돼 있습니다. 역할 기반 접근 제어(RBAC, Role-Based Access Control)는 Database·Collection 수준에서만 쓸 수 있습니다. 대신 Partition·Partition key 수준에서는 테넌트를 하나씩 질의할 수도, 여러 파티션에 걸쳐 함께 질의할 수도 있어 테넌트를 가로지르는 집계에 맞습니다. 자주 쓰는 데이터와 드물게 쓰는 데이터를 나눠 다루는 hot·cold 처리는 Partition key 수준에서 아직 지원하지 않습니다.<sup id="fnref:milvus-tenancy:1" role="doc-noteref"><a href="#fn:milvus-tenancy" class="footnote" rel="footnote">13</a></sup> Milvus의 한도 문서에는 클러스터당 컬렉션 65,536개, 컬렉션당 파티션 1,024개가 적혀 있습니다.<sup id="fnref:milvus-limits" role="doc-noteref"><a href="#fn:milvus-limits" class="footnote" rel="footnote">14</a></sup></p>

<p><strong>Weaviate</strong>는 네이티브 멀티테넌시를 제공합니다. 테넌트마다 별도의 샤드에 저장하며 한 테넌트의 데이터는 다른 테넌트에게 보이지 않습니다. 문서는 여러 사용자에게 서비스하는 애플리케이션에서는 멀티테넌시로 데이터가 분리되고 DB 작업도 효율적이라고 정리합니다.<sup id="fnref:weaviate-mt" role="doc-noteref"><a href="#fn:weaviate-mt" class="footnote" rel="footnote">15</a></sup> 테넌트마다 전용 벡터 인덱스를 가지며 쓰지 않는 테넌트는 비활성 상태로 두거나 S3로 내보낼 수 있습니다.<sup id="fnref:weaviate-data:1" role="doc-noteref"><a href="#fn:weaviate-data" class="footnote" rel="footnote">7</a></sup></p>

<p><strong>Pinecone</strong>은 테넌트마다 namespace를 하나씩 두라고 안내합니다. namespace는 따로 저장되므로 테넌트 간 데이터가 물리적으로 분리됩니다. 읽기와 쓰기는 언제나 namespace 하나를 대상으로 하므로 한 테넌트의 사용량이 다른 테넌트에 영향을 주지 않습니다. 테넌트가 서비스를 떠나 데이터를 없앨 때는 해당 namespace를 지우면 됩니다. 문서에 따르면 이 작업은 가볍고 거의 즉시 끝납니다. 서버리스 인덱스당 namespace 수는 요금제에 따라 Starter 100개에서 Enterprise 1,000,000개까지입니다.<sup id="fnref:pinecone-mt" role="doc-noteref"><a href="#fn:pinecone-mt" class="footnote" rel="footnote">16</a></sup></p>

<p>같은 문서는 테넌트를 모두 namespace 하나에 넣고 메타데이터 필터로 나누는 방식의 비용도 짚습니다. 이 경우 필터와 관계없이 namespace 전체를 훑으므로 비용과 지연이 커진다는 취지입니다.<sup id="fnref:pinecone-mt:1" role="doc-noteref"><a href="#fn:pinecone-mt" class="footnote" rel="footnote">16</a></sup></p>

<p><strong>컬렉션은 스키마·임베딩 모델·접근 권한까지 달라야 할 때 나누는 무거운 경계</strong>입니다. 같은 스키마에서 검색 범위만 나누면 된다면 파티션·테넌트·namespace를, 그보다 가벼운 조건이라면 메타데이터 필터를 먼저 검토합니다. 2편의 <a href="/writing/vector-database-selection/">필터와 멀티테넌시 비교</a>에 제품별 권장 방식을 표로 정리했습니다.</p>

<h2 id="4-업무가-다른-비슷한-문서는-한-컬렉션에-둘까요">4. 업무가 다른 비슷한 문서는 한 컬렉션에 둘까요</h2>

<p>인사 규정, 영업 제안서, 기술 매뉴얼은 모두 문서입니다. 같은 임베딩 모델로 벡터를 만들 수 있고 형식도 비슷해서 흔히 컬렉션 하나에 넣을 생각부터 합니다. 데이터가 많지 않다면 실제로 그 방법이 가장 단순합니다(데이터가 아주 많을 때는 이 절 마지막 소절에서 다룹니다). 다만 쓰임새가 다르면 나누는 편이 검색 정확도나 운영에 유리한 경우가 있습니다.</p>

<h3 id="한-컬렉션과-메타데이터로-충분한-경우">한 컬렉션과 메타데이터로 충분한 경우</h3>

<p>다음 조건을 모두 만족하면 컬렉션 하나에 넣고 업무 구분은 메타데이터로 둡니다.</p>

<ul>
  <li>모든 문서에 같은 임베딩 모델·차원·거리 측정을 씁니다.</li>
  <li>업무를 가로질러 함께 검색할 일이 있습니다. 예를 들어 전사 통합 검색입니다.</li>
  <li>보관 기간과 삭제 규칙이 비슷합니다.</li>
  <li>접근 권한을 메타데이터 필터나 테넌트 수준에서 나눠도 됩니다.</li>
</ul>

<p>다음은 업무 구분을 메타데이터로 담은 레코드 예시입니다(특정 제품의 API 형식은 아닙니다).</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hr-policy-0012#3"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"document"</span><span class="p">:</span><span class="w"> </span><span class="s2">"연차휴가는 입사일을 기준으로 산정합니다. ..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"business"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hr"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"doc_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"policy"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"updated_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-09-01"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>인사팀 화면에서는 <code class="language-plaintext highlighter-rouge">business = "hr"</code> 조건을 걸어 검색하고 통합 검색에서는 조건 없이 검색합니다. 업무가 하나 늘어나도 컬렉션을 새로 만들 필요 없이 <code class="language-plaintext highlighter-rouge">business</code> 값만 추가하면 됩니다. 업무별로 검색 범위를 물리적으로 나누고 싶다면 3절의 파티션이나 테넌트를 같은 방식으로 씁니다.</p>

<h3 id="컬렉션을-나누는-편이-나은-경우">컬렉션을 나누는 편이 나은 경우</h3>

<p>반대로 다음과 같은 차이가 있으면 컬렉션을 나누는 쪽을 검토합니다.</p>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>나누는 이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>업무마다 다른 임베딩 모델을 씀</td>
      <td>차원이나 벡터 공간이 달라 같은 벡터 필드에 넣을 수 없음(7절)</td>
    </tr>
    <tr>
      <td>접근 권한을 DB 수준에서 분리해야 함</td>
      <td>Milvus의 RBAC처럼 컬렉션 이상의 단위에서만 권한을 나누는 제품이 있음(3절)</td>
    </tr>
    <tr>
      <td>필드 구성이 크게 다름</td>
      <td>한 스키마에 모든 업무의 필드를 넣으면 대부분 비어 있는 필드가 생김</td>
    </tr>
    <tr>
      <td>보관·삭제 주기가 다름</td>
      <td>컬렉션째 지우거나 옮기는 편이 단순함</td>
    </tr>
    <tr>
      <td>검색 부하나 증가 속도가 크게 다름</td>
      <td>샤드·복제본 설정을 컬렉션마다 따로 잡을 수 있음</td>
    </tr>
  </tbody>
</table>

<h3 id="데이터가-아주-많으면-어떻게-할까요">데이터가 아주 많으면 어떻게 할까요</h3>

<p>대용량 데이터를 컬렉션 하나에 두면 구조는 단순합니다. 대신 한 컬렉션이 커질수록 색인·검색·갱신의 부담이 한곳에 몰리고 컬렉션 단위로 하는 백업과 복구도 오래 걸립니다. 업무 유형이나 기간을 기준으로 컬렉션을 나누면 이 부담이 컬렉션마다 나뉘어 성능과 관리에 유리할 수 있습니다.</p>

<p>다만 규모 문제만 있다면 컬렉션을 나누기 전에 <strong>샤드</strong>부터 검토합니다. 샤드는 컬렉션 하나를 여러 노드에 나눠 담는 기능이므로 애플리케이션이 다루는 컬렉션은 그대로 두고 용량을 늘릴 수 있습니다(8절). 컬렉션을 여러 개로 나누면 생성·삭제와 검색 대상 관리를 자동화해야 하므로 그에 맞는 도구와 절차가 함께 필요해집니다. 컬렉션을 가로지르는 검색이 필요해지면 7절의 제약도 따라옵니다.</p>

<h2 id="5-텍스트와-이미지를-한-컬렉션에-담을-수-있을까요">5. 텍스트와 이미지를 한 컬렉션에 담을 수 있을까요</h2>

<p>멀티모달(multimodal)은 텍스트·이미지·오디오·비디오처럼 종류가 다른 데이터를 함께 이해하고 분석하는 방식입니다. “해변에서 서핑하는 사람”이라는 텍스트로 사진이나 영상을 찾는 검색이 대표적입니다. 영상에 설명을 붙이거나 이미지와 음성을 함께 넣어 질문하는 서비스도 같은 범주에 듭니다.</p>

<p>종류가 다른 데이터도 한 컬렉션에서 관리할 수 있습니다. 방법은 크게 두 가지입니다.</p>

<h3 id="공유-임베딩-공간을-쓰는-방법">공유 임베딩 공간을 쓰는 방법</h3>

<p>첫째는 텍스트와 이미지를 <strong>같은 벡터 공간</strong>에 놓는 모델을 쓰는 방법입니다. 대표적인 예가 CLIP(Contrastive Language-Image Pre-training)입니다. CLIP은 이미지 인코더와 텍스트 인코더를 함께 학습합니다. 학습 배치 안에서 실제로 짝인 이미지·캡션 N쌍은 임베딩의 코사인 유사도를 높이고 짝이 아닌 N²−N개 조합은 유사도를 낮추도록 학습합니다. 그 결과 이미지와 텍스트가 하나의 멀티모달 임베딩 공간을 공유합니다.<sup id="fnref:clip" role="doc-noteref"><a href="#fn:clip" class="footnote" rel="footnote">17</a></sup></p>

<p>공간을 공유하므로 사진의 벡터와 “해변에서 서핑하는 사람”이라는 문장의 벡터를 바로 비교할 수 있습니다. 컬렉션 하나, 벡터 필드 하나에 이미지와 텍스트를 함께 넣고 텍스트 질의로 이미지를 찾을 수 있습니다. CLIP 논문에 따르면 사전 학습 뒤 자연어로 시각 개념을 가리킬 수 있어 별도 학습 없이 다른 작업에 옮겨 쓸 수 있습니다.<sup id="fnref:clip:1" role="doc-noteref"><a href="#fn:clip" class="footnote" rel="footnote">17</a></sup></p>

<h3 id="레코드-하나에-벡터를-여러-개-두는-방법">레코드 하나에 벡터를 여러 개 두는 방법</h3>

<p>둘째는 모달리티마다 다른 모델로 벡터를 만들고 <strong>레코드 하나에 이름 붙인 벡터를 여러 개</strong> 두는 방법입니다.</p>

<p><strong>Qdrant</strong>는 named vector로 point 하나에 크기와 종류가 다른 벡터 여러 개를 둡니다. 이름마다 다른 임베딩 모델·차원·거리 측정을 쓸 수 있고 dense·sparse·multivector(같은 크기의 벡터 여러 개를 한 묶음으로 저장하는 형식)를 섞을 수도 있습니다.<sup id="fnref:qdrant-vectors" role="doc-noteref"><a href="#fn:qdrant-vectors" class="footnote" rel="footnote">18</a></sup> 벡터를 넣을 때는 이름별로 값을 줍니다.<sup id="fnref:qdrant-points:1" role="doc-noteref"><a href="#fn:qdrant-points" class="footnote" rel="footnote">6</a></sup></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">129</span><span class="p">,</span><span class="w">
  </span><span class="nl">"vector"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"image"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mf">0.9</span><span class="p">,</span><span class="w"> </span><span class="mf">0.1</span><span class="p">,</span><span class="w"> </span><span class="mf">0.1</span><span class="p">,</span><span class="w"> </span><span class="mf">0.2</span><span class="p">],</span><span class="w">
    </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mf">0.4</span><span class="p">,</span><span class="w"> </span><span class="mf">0.8</span><span class="p">,</span><span class="w"> </span><span class="mf">0.0</span><span class="p">]</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"payload"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"해변 서핑 사진"</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>이 예시에서 <code class="language-plaintext highlighter-rouge">image</code> 벡터는 4차원, <code class="language-plaintext highlighter-rouge">text</code> 벡터는 3차원으로 차원이 서로 다릅니다. 이름이 다른 벡터끼리는 차원이 달라도 되지만 같은 이름의 벡터는 컬렉션 안에서 차원이 같아야 합니다.</p>

<p><strong>Milvus</strong>는 한 컬렉션에 벡터 필드를 여러 개 두고 여러 필드를 함께 검색하는 하이브리드 검색(hybrid search)을 제공합니다. 텍스트 설명과 이미지 임베딩을 함께 쓰는 멀티모달 검색이 문서의 용례입니다. 필드별 결과는 RRF Ranker(순위를 기준으로 결합)나 Weighted Ranker(필드별 점수에 가중치를 주어 결합)로 합칩니다.<sup id="fnref:milvus-hybrid" role="doc-noteref"><a href="#fn:milvus-hybrid" class="footnote" rel="footnote">19</a></sup></p>

<p><strong>Weaviate</strong>도 컬렉션에 named vector를 여러 개 둘 수 있습니다. 벡터 공간마다 인덱스·압축·vectorizer(데이터를 벡터로 바꾸는 모듈)를 따로 설정하며 v1.31부터는 기존 컬렉션에 새 named vector를 추가할 수 있습니다. 이미 만든 named vector의 설정은 바꿀 수 없습니다.<sup id="fnref:weaviate-data:2" role="doc-noteref"><a href="#fn:weaviate-data" class="footnote" rel="footnote">7</a></sup><sup id="fnref:weaviate-config" role="doc-noteref"><a href="#fn:weaviate-config" class="footnote" rel="footnote">20</a></sup></p>

<h3 id="어느-쪽을-고를까요">어느 쪽을 고를까요</h3>

<table>
  <thead>
    <tr>
      <th>방식</th>
      <th>잘 맞는 경우</th>
      <th>주의할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>공유 임베딩 공간(벡터 하나)</td>
      <td>텍스트로 이미지를 찾는 것처럼 모달리티를 가로지르는 검색</td>
      <td>한 모델이 다룰 모달리티를 모두 지원해야 함</td>
    </tr>
    <tr>
      <td>레코드당 벡터 여러 개</td>
      <td>모달리티마다 더 잘 맞는 모델을 따로 쓰고 싶을 때</td>
      <td>레코드마다 저장할 벡터가 늘고, 결과를 합치는 방식을 정해야 함</td>
    </tr>
  </tbody>
</table>

<p>복합 정보를 벡터 하나로 표현하면 검색 한 번으로 끝나고 결과를 합칠 필요가 없습니다. 벡터를 여러 개 두면 저장할 값과 계산이 늘어납니다. 어느 쪽 검색 품질이 나은지는 데이터와 질의에 따라 다르므로 대표 질의를 정해 두 구성을 직접 비교해 보고 정합니다.</p>

<h2 id="6-매일-쌓이는-뉴스-기사는-어떻게-나눌까요">6. 매일 쌓이는 뉴스 기사는 어떻게 나눌까요</h2>

<p>이번에는 뉴스 기사를 저장하는 경우를 생각해 봅시다. 데이터와 사용 방식에는 다음과 같은 특징이 있습니다.</p>

<ul>
  <li><strong>데이터 특성:</strong> 기사가 매일 생기고 날짜별 양이 많습니다. 주제·카테고리·작성자 같은 속성이 다양합니다.</li>
  <li><strong>접근 패턴:</strong> 최신 기사를 자주 검색하고 오래된 기사는 덜 검색합니다. 특정 주제나 카테고리로 좁혀 검색하는 일이 많습니다.</li>
</ul>

<p>이 특징을 바탕으로 세 가지 설계안을 세울 수 있습니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 776 420" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vdb-news-collection-options-archify-diagram-title">뉴스 기사 컬렉션을 나누는 세 가지 안</title>
        <desc id="vdb-news-collection-options-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vdb-news-collection-options-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vdb-news-collection-options-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vdb-news-collection-options-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vdb-news-collection-options-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vdb-news-collection-options-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vdb-news-collection-options-grid)" />

        <!-- Boundaries (behind everything) -->


        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="news" data-edge-to="by_date" data-edge-key="0" data-composition-points="224,202;252,202;252,112;280,112" d="M 224 202 L 244 202 Q 252 202 252 194 L 252 120 Q 252 112 260 112 L 280 112" class="a-default" stroke-width="1.5" marker-end="url(#vdb-news-collection-options-arrowhead)" />
        <path data-edge-from="news" data-edge-to="by_topic" data-edge-key="1" data-composition-points="224,216;280,216" d="M 224 216 L 280 216" class="a-default" stroke-width="1.5" marker-end="url(#vdb-news-collection-options-arrowhead)" />
        <path data-edge-from="news" data-edge-to="by_month_topic" data-edge-key="2" data-composition-points="224,230;252,230;252,320;280,320" d="M 224 230 L 244 230 Q 252 230 252 238 L 252 312 Q 252 320 260 320 L 280 320" class="a-default" stroke-width="1.5" marker-end="url(#vdb-news-collection-options-arrowhead)" />
        <path data-edge-from="by_date" data-edge-to="date_note" data-edge-key="3" data-composition-points="496,112;520,112" d="M 496 112 L 520 112" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-news-collection-options-arrowhead-dashed)" />
        <path data-edge-from="by_topic" data-edge-to="topic_note" data-edge-key="4" data-composition-points="496,216;520,216" d="M 496 216 L 520 216" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-news-collection-options-arrowhead-dashed)" />
        <path data-edge-from="by_month_topic" data-edge-to="mixed_note" data-edge-key="5" data-composition-points="496,320;520,320" d="M 496 320 L 520 320" class="a-dashed" stroke-width="1.5" marker-end="url(#vdb-news-collection-options-arrowhead-dashed)" />

        <!-- Components -->
        <g id="vdb-news-collection-options-node-news" data-node-id="news" data-node-label="news" tabindex="0" role="button" aria-label="Focus news, 매일 쌓이는 뉴스 기사, Architecture component" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="매일 쌓이는 뉴스 기사" data-node-context="Architecture component">
          <title>news · 매일 쌓이는 뉴스 기사 · Architecture component</title>
          <rect x="40" y="184" width="184" height="64" rx="6" class="c-mask" />
          <rect x="40" y="184" width="184" height="64" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(46 190) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="132" y="214" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">news</text>
        <text data-detail="context" x="132" y="230" class="t-muted" font-size="9" text-anchor="middle">매일 쌓이는 뉴스 기사</text>
        </g>

        <g id="vdb-news-collection-options-node-by_date" data-node-id="by_date" data-node-label="(A) 날짜 기준" tabindex="0" role="button" aria-label="Focus (A) 날짜 기준, news_20240530, news_20240531 …, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="news_20240530, news_20240531 …" data-node-context="Architecture component">
          <title>(A) 날짜 기준 · news_20240530, news_20240531 … · Architecture component</title>
          <rect x="280" y="80" width="216" height="64" rx="6" class="c-mask" />
          <rect x="280" y="80" width="216" height="64" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(286 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="388" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">(A) 날짜 기준</text>
        <text data-detail="context" x="388" y="126" class="t-muted" font-size="9" text-anchor="middle">news_20240530, news_20240531 …</text>
        </g>

        <g id="vdb-news-collection-options-node-by_topic" data-node-id="by_topic" data-node-label="(B) 주제 기준" tabindex="0" role="button" aria-label="Focus (B) 주제 기준, news_politics, news_economy, news_sports, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="news_politics, news_economy, news_sports" data-node-context="Architecture component">
          <title>(B) 주제 기준 · news_politics, news_economy, news_sports · Architecture component</title>
          <rect x="280" y="184" width="216" height="64" rx="6" class="c-mask" />
          <rect x="280" y="184" width="216" height="64" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(286 190) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="388" y="214" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">(B) 주제 기준</text>
        <text data-detail="context" x="388" y="230" class="t-muted" font-size="8.6" text-anchor="middle">news_politics, news_economy, news_sports</text>
        </g>

        <g id="vdb-news-collection-options-node-by_month_topic" data-node-id="by_month_topic" data-node-label="(C) 혼합(월+주제)" tabindex="0" role="button" aria-label="Focus (C) 혼합(월+주제), news_202405_politics, news_202405_economy …, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="news_202405_politics, news_202405_economy …" data-node-context="Architecture component">
          <title>(C) 혼합(월+주제) · news_202405_politics, news_202405_economy … · Architecture component</title>
          <rect x="280" y="288" width="216" height="64" rx="6" class="c-mask" />
          <rect x="280" y="288" width="216" height="64" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(286 294) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="388" y="318" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">(C) 혼합(월+주제)</text>
        <text data-detail="context" x="388" y="334" class="t-muted" font-size="8" text-anchor="middle">news_202405_politics, news_202405_economy …</text>
        </g>

        <g id="vdb-news-collection-options-node-date_note" data-node-id="date_note" data-node-label="장점: 최근 날짜 컬렉션만 검색" tabindex="0" role="button" aria-label="Focus 장점: 최근 날짜 컬렉션만 검색, 주의: 컬렉션이 하루에 하나씩 늘어남, Architecture component" aria-pressed="false" data-node-kind="external" data-node-sublabel="주의: 컬렉션이 하루에 하나씩 늘어남" data-node-context="Architecture component">
          <title>장점: 최근 날짜 컬렉션만 검색 · 주의: 컬렉션이 하루에 하나씩 늘어남 · Architecture component</title>
          <rect x="520" y="80" width="216" height="64" rx="6" class="c-mask" />
          <rect x="520" y="80" width="216" height="64" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(526 86) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="628" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">장점: 최근 날짜 컬렉션만 검색</text>
        <text data-detail="context" x="628" y="126" class="t-muted" font-size="9" text-anchor="middle">주의: 컬렉션이 하루에 하나씩 늘어남</text>
        </g>

        <g id="vdb-news-collection-options-node-topic_note" data-node-id="topic_note" data-node-label="장점: 한 컬렉션에서 주제 검색" tabindex="0" role="button" aria-label="Focus 장점: 한 컬렉션에서 주제 검색, 주의: 오래된 기사는 컬렉션마다 날짜 조건 삭제, Architecture component" aria-pressed="false" data-node-kind="external" data-node-sublabel="주의: 오래된 기사는 컬렉션마다 날짜 조건 삭제" data-node-context="Architecture component">
          <title>장점: 한 컬렉션에서 주제 검색 · 주의: 오래된 기사는 컬렉션마다 날짜 조건 삭제 · Architecture component</title>
          <rect x="520" y="184" width="216" height="64" rx="6" class="c-mask" />
          <rect x="520" y="184" width="216" height="64" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(526 190) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="628" y="214" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">장점: 한 컬렉션에서 주제 검색</text>
        <text data-detail="context" x="628" y="230" class="t-muted" font-size="7.7" text-anchor="middle">주의: 오래된 기사는 컬렉션마다 날짜 조건 삭제</text>
        </g>

        <g id="vdb-news-collection-options-node-mixed_note" data-node-id="mixed_note" data-node-label="장점: 기간과 주제를 함께 좁힘" tabindex="0" role="button" aria-label="Focus 장점: 기간과 주제를 함께 좁힘, 주의: 컬렉션 수가 월 수×주제 수로 늘어남, Architecture component" aria-pressed="false" data-node-kind="external" data-node-sublabel="주의: 컬렉션 수가 월 수×주제 수로 늘어남" data-node-context="Architecture component">
          <title>장점: 기간과 주제를 함께 좁힘 · 주의: 컬렉션 수가 월 수×주제 수로 늘어남 · Architecture component</title>
          <rect x="520" y="288" width="216" height="64" rx="6" class="c-mask" />
          <rect x="520" y="288" width="216" height="64" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(526 294) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="628" y="318" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">장점: 기간과 주제를 함께 좁힘</text>
        <text data-detail="context" x="628" y="334" class="t-muted" font-size="8.6" text-anchor="middle">주의: 컬렉션 수가 월 수×주제 수로 늘어남</text>
        </g>

        <!-- Connection labels -->







        <!-- Boundary labels (foreground masks keep routes out of titles) -->


        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="384" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="컬렉션 구성" data-legend-x="40" data-legend-baseline="404" data-legend-width="98">
            <rect x="40" y="395" width="16" height="10" rx="2.5" class="c-database" stroke-width="1" />
            <text x="62" y="404" class="t-muted" font-size="10" font-weight="500">컬렉션 구성</text>
          </g>
          <g data-legend-semantic-kind="cloud" data-legend-kind="cloud" data-legend-label="원본 데이터" data-legend-x="160" data-legend-baseline="404" data-legend-width="98">
            <rect x="160" y="395" width="16" height="10" rx="2.5" class="c-cloud" stroke-width="1" />
            <text x="182" y="404" class="t-muted" font-size="10" font-weight="500">원본 데이터</text>
          </g>
          <g data-legend-semantic-kind="external" data-legend-kind="external" data-legend-label="장점 · 주의점" data-legend-x="280" data-legend-baseline="404" data-legend-width="108">
            <rect x="280" y="395" width="16" height="10" rx="2.5" class="c-external" stroke-width="1" />
            <text x="302" y="404" class="t-muted" font-size="10" font-weight="500">장점 · 주의점</text>
          </g>
        </g>
      </svg>
</div><figcaption>뉴스 기사를 날짜·주제·월과 주제 혼합 기준으로 나누는 세 가지 안</figcaption>
</figure>

<table>
  <thead>
    <tr>
      <th>안</th>
      <th>컬렉션 이름 예</th>
      <th>장점</th>
      <th>주의할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>날짜 기준</td>
      <td><code class="language-plaintext highlighter-rouge">news_20240530</code>, <code class="language-plaintext highlighter-rouge">news_20240531</code></td>
      <td>최신 기사는 최근 날짜 컬렉션만 검색하면 되고, 오래된 기사는 컬렉션째 보관·삭제</td>
      <td>주제 검색이나 여러 날에 걸친 검색은 여러 컬렉션을 봐야 하고, 컬렉션이 하루에 하나씩 늘어남</td>
    </tr>
    <tr>
      <td>주제 기준</td>
      <td><code class="language-plaintext highlighter-rouge">news_politics</code>, <code class="language-plaintext highlighter-rouge">news_economy</code>, <code class="language-plaintext highlighter-rouge">news_sports</code></td>
      <td>특정 주제 검색이 한 컬렉션 안에서 끝나고 컬렉션 수가 거의 고정</td>
      <td>오래된 기사를 정리하려면 컬렉션마다 날짜 조건으로 지워야 함</td>
    </tr>
    <tr>
      <td>혼합(월+주제)</td>
      <td><code class="language-plaintext highlighter-rouge">news_202405_politics</code></td>
      <td>지나치게 잘게 나누지 않으면서 기간과 주제를 함께 좁힘</td>
      <td>컬렉션 수가 월 수×주제 수로 늘어남(주제 10개면 1년에 120개)</td>
    </tr>
  </tbody>
</table>

<p>세 안 모두 “컬렉션 이름에 조건을 넣는” 방식입니다. 3절의 공식 권고를 따르면 넷째 안도 있습니다. 컬렉션은 <code class="language-plaintext highlighter-rouge">news</code> 하나로 두고 날짜와 주제를 메타데이터나 파티션 키로 담는 방식입니다. 주제별로 데이터를 물리적으로 모으고 싶다면 Milvus의 partition key나 Qdrant의 <code class="language-plaintext highlighter-rouge">is_tenant</code>를 씁니다. 이때 오래된 기사는 바로 다음 소절에서 다루는 만료·삭제 기능으로 정리합니다.</p>

<h3 id="시간-순으로-쌓이는-데이터에는-어떤-기능이-있을까요">시간 순으로 쌓이는 데이터에는 어떤 기능이 있을까요</h3>

<p>날짜별로 컬렉션을 직접 만들고 지우는 작업을 대신해 주는 기능이 제품마다 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>제공하는 기능</th>
      <th>동작</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Elasticsearch</td>
      <td>data stream, rollover, ILM, alias</td>
      <td>새 인덱스를 나이·크기 기준으로 자동 생성하고, 오래된 인덱스를 옮기거나 삭제</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>컬렉션 TTL</td>
      <td>만료된 엔티티를 결과에서 즉시 빼고 다음 compaction에서 물리적으로 삭제</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>컬렉션 수준 TTL</td>
      <td>지정한 기간이 지난 객체를 백그라운드에서 주기적으로 삭제</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>필터 삭제</td>
      <td>payload 조건에 맞는 point를 삭제</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td><code class="language-plaintext highlighter-rouge">where</code> 필터 삭제</td>
      <td>조건에 맞는 항목을 삭제(되돌릴 수 없음)</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td>ID·메타데이터 필터·namespace 삭제</td>
      <td>조건에 맞는 레코드나 namespace 전체를 삭제</td>
    </tr>
  </tbody>
</table>

<p><strong>Elasticsearch의 data stream</strong>은 여러 인덱스 위에 놓인 추상 계층으로, append-only(추가만 하는) 시계열 데이터를 저장하도록 최적화돼 있습니다. 실제 데이터는 자동으로 만들어지는 숨은 백킹 인덱스(backing index)에 들어갑니다. 모든 문서에는 <code class="language-plaintext highlighter-rouge">@timestamp</code> 필드가 있어야 합니다.<sup id="fnref:es-data-stream" role="doc-noteref"><a href="#fn:es-data-stream" class="footnote" rel="footnote">21</a></sup></p>

<p>rollover는 새 백킹 인덱스를 만들어 data stream의 새 쓰기 인덱스로 지정합니다. 백킹 인덱스 이름은 <code class="language-plaintext highlighter-rouge">.ds-&lt;data-stream&gt;-&lt;yyyy.MM.dd&gt;-&lt;generation&gt;</code> 형식입니다.<sup id="fnref:es-data-stream:1" role="doc-noteref"><a href="#fn:es-data-stream" class="footnote" rel="footnote">21</a></sup> 인덱스 하나에 계속 쓰면 성능과 리소스 사용에 나쁠 수 있으므로, 샤드 크기(예: 50GB), 인덱스의 나이(예: 7일), 샤드당 문서 수 같은 조건으로 rollover합니다.<sup id="fnref:es-rollover" role="doc-noteref"><a href="#fn:es-rollover" class="footnote" rel="footnote">22</a></sup></p>

<p>ILM(Index Lifecycle Management, 인덱스 생명주기 관리)은 오래된 백킹 인덱스를 더 저렴한 하드웨어로 옮기고 필요 없는 인덱스를 삭제합니다. 인덱스는 Hot(활발히 갱신·조회) 단계에서 Delete(더 이상 필요 없어 삭제 가능) 단계까지 차례로 이동합니다. 다음 단계로 넘어가려면 현재 단계의 작업이 모두 끝나고 인덱스가 다음 단계의 최소 나이를 넘어야 합니다.<sup id="fnref:es-ilm" role="doc-noteref"><a href="#fn:es-ilm" class="footnote" rel="footnote">23</a></sup> 날짜별 컬렉션 이름을 직접 짓는 대신 data stream이 날짜가 붙은 인덱스를 만들고 정리하는 셈입니다.</p>

<p>data stream은 로그·이벤트·메트릭처럼 계속 생성되고 갱신·삭제보다 새 문서 색인이 대부분인 데이터에 맞습니다.<sup id="fnref:es-data-stream:2" role="doc-noteref"><a href="#fn:es-data-stream" class="footnote" rel="footnote">21</a></sup> 기사 수정이 잦다면 data stream이 맞는 데이터인지 함께 따져 봅니다. 별칭(alias)은 인덱스나 data stream 하나 이상을 가리키는 이름이며 대부분의 Elasticsearch API가 인덱스 이름 자리에 alias를 받습니다. 여러 인덱스를 한 이름으로 검색하고 <code class="language-plaintext highlighter-rouge">is_write_index</code>로 쓰기 대상을 정할 수 있습니다.<sup id="fnref:es-alias" role="doc-noteref"><a href="#fn:es-alias" class="footnote" rel="footnote">24</a></sup></p>

<p><strong>Milvus의 컬렉션 TTL(Time-to-Live)</strong>은 엔티티를 자동으로 만료시킵니다. 만료된 엔티티는 질의·검색 결과에서 즉시 빠지고 다음 compaction(작은 데이터 조각을 합치고 삭제된 데이터를 정리하는 백그라운드 작업) 주기에 저장소에서 물리적으로 삭제됩니다. Milvus 문서는 이 물리 삭제가 보통 24시간 안에 이뤄진다고 적습니다. TTL은 컬렉션 속성 <code class="language-plaintext highlighter-rouge">collection.ttl.seconds</code>에 초 단위 정수로 지정하며 기존 컬렉션에는 <code class="language-plaintext highlighter-rouge">alter_collection_properties()</code>로 설정합니다.<sup id="fnref:milvus-ttl" role="doc-noteref"><a href="#fn:milvus-ttl" class="footnote" rel="footnote">25</a></sup></p>

<p><strong>Weaviate</strong>도 컬렉션 수준의 TTL 설정으로 일정 기간이 지난 객체를 자동으로 삭제할 수 있습니다. 만료된 객체는 백그라운드 프로세스가 주기적으로 지웁니다. 조건으로 지울 때는 컬렉션과 <code class="language-plaintext highlighter-rouge">where</code> 필터를 지정하며 한 번에 지울 수 있는 객체 수에는 설정 가능한 상한(<code class="language-plaintext highlighter-rouge">QUERY_MAXIMUM_RESULTS</code>, 기본 10,000)이 있습니다.<sup id="fnref:weaviate-ttl" role="doc-noteref"><a href="#fn:weaviate-ttl" class="footnote" rel="footnote">26</a></sup></p>

<p><strong>Qdrant</strong>는 ID 대신 필터로 삭제할 point를 지정할 수 있습니다.<sup id="fnref:qdrant-points:2" role="doc-noteref"><a href="#fn:qdrant-points" class="footnote" rel="footnote">6</a></sup> 아래는 <code class="language-plaintext highlighter-rouge">published_month</code> payload가 <code class="language-plaintext highlighter-rouge">2024-05</code>인 기사를 지우는 요청입니다. 컬렉션 이름과 payload 키는 예시입니다.</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">POST /collections/news/points/delete
{
  "filter": {
    "must": [
      { "key": "published_month", "match": { "value": "2024-05" } }
    ]
  }
}
</span></code></pre></div></div>

<p>이 요청은 조건에 맞는 point를 모두 지웁니다. 운영 컬렉션에서는 같은 filter로 대상 point를 먼저 조회해 확인한 뒤 실행합니다.</p>

<p><strong>Chroma</strong>는 <code class="language-plaintext highlighter-rouge">id</code>나 <code class="language-plaintext highlighter-rouge">where</code> 필터로 컬렉션의 항목을 삭제하며 이 작업은 되돌릴 수 없습니다.<sup id="fnref:chroma-delete" role="doc-noteref"><a href="#fn:chroma-delete" class="footnote" rel="footnote">27</a></sup> <strong>Pinecone</strong>은 ID(요청당 최대 1,000개), 메타데이터 필터, namespace 단위로 레코드를 삭제합니다.<sup id="fnref:pinecone-delete" role="doc-noteref"><a href="#fn:pinecone-delete" class="footnote" rel="footnote">28</a></sup></p>

<h3 id="설계-단계에서-실제-질의로-시험해-봅니다">설계 단계에서 실제 질의로 시험해 봅니다</h3>

<p>어느 안이 나은지는 데이터 특성, 접근 패턴, 성능 요구, 운영 편의를 함께 봐야 정할 수 있습니다. 같은 뉴스 데이터라도 서비스가 최신 속보 검색 위주인지, 주제별 아카이브 검색 위주인지에 따라 답이 달라집니다. 분석·설계 단계에서 후보 구성을 작게 만들어 먼저 시험해 보기를 권합니다.</p>

<p>시험할 때는 다음을 확인합니다.</p>

<ol>
  <li><strong>대표 질의를 정합니다.</strong> 최신 기사 검색, 특정 주제 검색, 기간과 주제를 함께 건 검색처럼 실제 화면에서 나올 질의를 고릅니다.</li>
  <li><strong>후보 구성마다 같은 질의를 실행합니다.</strong> 검색 결과의 품질과 응답 시간을 함께 기록합니다.</li>
  <li><strong>정리 작업을 실제로 해 봅니다.</strong> 한 달 치 기사를 지우거나 옮기는 데 드는 시간과, 그동안 검색에 주는 영향을 봅니다.</li>
  <li><strong>1년 뒤를 계산합니다.</strong> 컬렉션 수, 컬렉션당 데이터 양, 자동화해야 할 작업을 적어 봅니다.</li>
</ol>

<h2 id="7-여러-컬렉션을-한-번에-검색할-수-있을까요">7. 여러 컬렉션을 한 번에 검색할 수 있을까요</h2>

<p>날짜별이나 주제별로 컬렉션을 나눴다면 여러 컬렉션에 걸쳐 유사도 검색을 해야 할 때가 생깁니다. 여러 컬렉션을 함께 검색하는 기능은 제품마다 지원 방식이 다릅니다.</p>

<h3 id="elasticsearch는-url-경로에-인덱스를-나열합니다">Elasticsearch는 URL 경로에 인덱스를 나열합니다</h3>

<p>Elasticsearch는 검색 API의 요청 경로에 인덱스 이름을 쉼표로 나열해 여러 인덱스를 한 번에 검색합니다. <code class="language-plaintext highlighter-rouge">my-index-*</code> 같은 와일드카드와 alias도 쓸 수 있습니다. 경로에서 인덱스를 생략하거나 <code class="language-plaintext highlighter-rouge">*</code>·<code class="language-plaintext highlighter-rouge">_all</code>을 쓰면 모든 data stream과 인덱스를 검색합니다.<sup id="fnref:es-multi-search" role="doc-noteref"><a href="#fn:es-multi-search" class="footnote" rel="footnote">29</a></sup><sup id="fnref:es-search-api" role="doc-noteref"><a href="#fn:es-search-api" class="footnote" rel="footnote">30</a></sup> 특정 인덱스의 결과에 가중치를 주려면 <code class="language-plaintext highlighter-rouge">indices_boost</code> 파라미터를 씁니다.<sup id="fnref:es-multi-search:1" role="doc-noteref"><a href="#fn:es-multi-search" class="footnote" rel="footnote">29</a></sup></p>

<p>아래는 두 인덱스에서 게시된 문서만 골라 질의 벡터와의 코사인 유사도로 점수를 매기는 예입니다. 인덱스 이름과 필드 이름, 벡터 값은 예시입니다.</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET /collection1,collection2/_search
{
  "query": {
    "script_score": {
      "query": {
        "bool": {
          "filter": { "term": { "status": "published" } }
        }
      },
      "script": {
        "source": "cosineSimilarity(params.query_vector, 'my_vector_field') + 1.0",
        "params": { "query_vector": [0.1, 0.2, 0.3] }
      }
    }
  }
}
</span></code></pre></div></div>

<p>검색 대상 인덱스는 요청 본문이 아니라 첫 줄의 경로에 적습니다. 본문의 바깥 <code class="language-plaintext highlighter-rouge">query</code>는 점수를 매길 문서를 고르고 <code class="language-plaintext highlighter-rouge">script</code>는 고른 문서마다 점수를 계산합니다.</p>

<p><code class="language-plaintext highlighter-rouge">cosineSimilarity(...)</code> 뒤에 <code class="language-plaintext highlighter-rouge">+ 1.0</code>을 붙이는 이유는 점수가 음수가 될 수 없기 때문입니다. Elasticsearch 문서는 <code class="language-plaintext highlighter-rouge">script_score</code> 질의의 최종 점수가 음수일 수 없으며 검색 최적화를 위해 Lucene이 점수를 양수나 0으로 요구한다고 설명합니다.<sup id="fnref:es-script-score" role="doc-noteref"><a href="#fn:es-script-score" class="footnote" rel="footnote">31</a></sup> 코사인 유사도는 -1에서 1 사이의 값이므로 1.0을 더하면 0에서 2 사이로 옮겨집니다. 순위는 그대로 유지됩니다.</p>

<p>이 방식은 <strong>조건에 맞는 문서를 모두 하나씩 비교</strong>합니다. Elasticsearch 문서에 따르면 벡터 함수를 계산할 때 대상 문서를 모두 선형으로 훑으므로 질의 시간이 대상 문서 수에 비례해 늘어납니다.<sup id="fnref:es-script-score:1" role="doc-noteref"><a href="#fn:es-script-score" class="footnote" rel="footnote">31</a></sup> 이런 정확한(brute-force) kNN(k-최근접 이웃, 질의 벡터와 가장 가까운 벡터 k개를 찾는 검색) 검색은 작은 데이터셋이나 필터로 미리 좁힌 집합에 맞습니다. Elasticsearch는 지연 시간과 규모가 중요한 대부분의 운영 환경에 <code class="language-plaintext highlighter-rouge">knn</code> 섹션을 쓰는 근사(approximate) kNN 검색을 권장합니다.<sup id="fnref:es-knn" role="doc-noteref"><a href="#fn:es-knn" class="footnote" rel="footnote">32</a></sup> 근사 kNN은 인덱스로 후보를 좁혀 정확도를 조금 양보하는 대신 빠르게 찾는 방식입니다.</p>

<h3 id="다른-제품은-검색-요청이-컬렉션-하나를-받습니다">다른 제품은 검색 요청이 컬렉션 하나를 받습니다</h3>

<p><strong>Milvus</strong>의 검색 요청은 컬렉션 이름 하나와 파티션 이름 목록을 받습니다. 검색 요청에 파티션 이름을 넣어 범위를 좁힐 수 있으며 검색에 참여하는 파티션 수를 줄이면 성능이 좋아집니다.<sup id="fnref:milvus-search" role="doc-noteref"><a href="#fn:milvus-search" class="footnote" rel="footnote">33</a></sup> 3절에서 본 것처럼 Partition·Partition key 수준으로 나눈 데이터는 여러 파티션에 걸쳐 함께 질의할 수 있습니다.<sup id="fnref:milvus-tenancy:2" role="doc-noteref"><a href="#fn:milvus-tenancy" class="footnote" rel="footnote">13</a></sup></p>

<p><strong>Qdrant</strong>는 <code class="language-plaintext highlighter-rouge">lookup_from</code> 파라미터로 다른 컬렉션에 있는 point의 벡터를 질의 벡터로 가져올 수 있습니다.<sup id="fnref:qdrant-search" role="doc-noteref"><a href="#fn:qdrant-search" class="footnote" rel="footnote">34</a></sup> 이 기능은 질의에 쓸 벡터를 빌려 오는 것이며 여러 컬렉션을 한 번에 검색하는 기능은 아닙니다. <strong>Pinecone</strong>의 읽기와 쓰기는 언제나 namespace 하나를 대상으로 합니다.<sup id="fnref:pinecone-mt:2" role="doc-noteref"><a href="#fn:pinecone-mt" class="footnote" rel="footnote">16</a></sup> <strong>Weaviate</strong>는 컬렉션마다 고유한 벡터 공간을 가지므로 같은 객체라도 컬렉션마다 다른 임베딩을 가질 수 있습니다.<sup id="fnref:weaviate-data:3" role="doc-noteref"><a href="#fn:weaviate-data" class="footnote" rel="footnote">7</a></sup></p>

<p><strong>함께 검색할 일이 잦은 데이터라면 처음부터 한 컬렉션 안에서 파티션·테넌트·메타데이터로 나누는 편</strong>이 낫습니다. 이렇게 하면 앞에서 본 제품별 검색 범위 제약을 피할 수 있습니다. 컬렉션을 나누는 결정은 “이 데이터를 함께 검색할 일이 있는가”를 먼저 물은 뒤에 내립니다.</p>

<h3 id="벡터-공간이-일관돼야-합니다">벡터 공간이 일관돼야 합니다</h3>

<p>여러 컬렉션을 함께 검색할 수 있더라도 점수를 서로 비교하려면 <strong>벡터 공간의 일관성(consistent vector space)</strong>이 필요합니다. 서로 다른 항목을 벡터로 바꿀 때 차원 수를 같게 하고 의미 해석도 같게 맞춰야 한다는 뜻입니다. 같은 모델로, 같은 방식으로 벡터화해야 두 벡터의 거리가 의미를 가집니다.</p>

<table>
  <thead>
    <tr>
      <th>맞출 것</th>
      <th>예</th>
      <th>어긋나면</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>차원 수</td>
      <td>BERT-base로 만든 벡터는 모두 768차원</td>
      <td>같은 벡터 필드에 넣을 수 없음</td>
    </tr>
    <tr>
      <td>임베딩 모델과 버전</td>
      <td>모든 컬렉션에 같은 모델 체크포인트 사용</td>
      <td>값은 들어가도 거리를 비교하는 의미가 없음</td>
    </tr>
    <tr>
      <td>전처리</td>
      <td>토크나이징·텍스트 정규화·청크 분할 방식</td>
      <td>같은 내용이 다른 벡터가 됨</td>
    </tr>
    <tr>
      <td>거리 측정과 정규화</td>
      <td>모두 코사인, 또는 모두 단위 벡터 + 내적</td>
      <td>점수의 척도가 달라 순위를 합칠 수 없음</td>
    </tr>
  </tbody>
</table>

<p>차원은 모델 구조가 정합니다. BERT 논문에서 BERT-base는 층 12개, 은닉 크기(hidden size) 768, 어텐션 헤드 12개, 파라미터 1억 1,000만 개이고 BERT-large는 은닉 크기가 1024입니다.<sup id="fnref:bert" role="doc-noteref"><a href="#fn:bert" class="footnote" rel="footnote">35</a></sup> Hugging Face의 <code class="language-plaintext highlighter-rouge">bert-base-uncased</code> 설정 파일에도 <code class="language-plaintext highlighter-rouge">"hidden_size": 768</code>이 적혀 있습니다.<sup id="fnref:bert:1" role="doc-noteref"><a href="#fn:bert" class="footnote" rel="footnote">35</a></sup> 따라서 BERT-base로 만든 임베딩은 768차원이고 컬렉션의 벡터 필드도 768차원으로 맞춰야 합니다.</p>

<h3 id="차원은-컬렉션을-만들-때-정해집니다">차원은 컬렉션을 만들 때 정해집니다</h3>

<p>대부분의 제품은 컬렉션이나 벡터 필드를 정의할 때 차원을 고정합니다.</p>

<ul>
  <li><strong>Qdrant:</strong> 같은 컬렉션 안의 모든 벡터는 같은 차원을 가지며 컬렉션 설정에서 벡터 크기가 고정됩니다.<sup id="fnref:qdrant-collections:2" role="doc-noteref"><a href="#fn:qdrant-collections" class="footnote" rel="footnote">11</a></sup><sup id="fnref:qdrant-vectors:1" role="doc-noteref"><a href="#fn:qdrant-vectors" class="footnote" rel="footnote">18</a></sup></li>
  <li><strong>Milvus:</strong> 벡터 필드의 <code class="language-plaintext highlighter-rouge">dim</code>으로 차원을 정합니다.<sup id="fnref:milvus-schema:1" role="doc-noteref"><a href="#fn:milvus-schema" class="footnote" rel="footnote">4</a></sup> 컬렉션을 만든 뒤 바꿀 수 있는 필드 속성은 VarChar의 <code class="language-plaintext highlighter-rouge">max_length</code>, Array의 <code class="language-plaintext highlighter-rouge">max_capacity</code>, <code class="language-plaintext highlighter-rouge">mmap.enabled</code>이며 기본 키 필드는 생성 후 바꿀 수 없습니다.<sup id="fnref:milvus-alter" role="doc-noteref"><a href="#fn:milvus-alter" class="footnote" rel="footnote">36</a></sup></li>
  <li><strong>pgvector:</strong> <code class="language-plaintext highlighter-rouge">vector(768)</code>처럼 열 타입에 차원을 씁니다. 차원 없는 <code class="language-plaintext highlighter-rouge">vector</code> 열도 만들 수 있지만 인덱스는 차원이 같은 행에만 만들 수 있습니다(표현식·부분 인덱스 사용). <code class="language-plaintext highlighter-rouge">vector</code> 타입은 16,000차원까지 저장하고 HNSW·IVFFlat 인덱스는 2,000차원까지 지원합니다.<sup id="fnref:pgvector:1" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">2</a></sup></li>
  <li><strong>Elasticsearch:</strong> <code class="language-plaintext highlighter-rouge">dense_vector</code> 필드에 <code class="language-plaintext highlighter-rouge">dims</code>를 지정하지 않으면 처음 추가한 벡터의 길이로 정해집니다.<sup id="fnref:es-dense-vector:1" role="doc-noteref"><a href="#fn:es-dense-vector" class="footnote" rel="footnote">9</a></sup> 기존 필드의 타입은 바꿀 수 없으며 원하는 매핑으로 새 인덱스를 만들어 데이터를 재색인(reindex)해야 합니다.<sup id="fnref:es-mapping" role="doc-noteref"><a href="#fn:es-mapping" class="footnote" rel="footnote">37</a></sup></li>
  <li><strong>Pinecone:</strong> 인덱스를 만들 때 차원과 거리 측정을 지정하며 차원은 임베딩 모델의 출력에 맞춥니다. 예를 들어 <code class="language-plaintext highlighter-rouge">multilingual-e5-large</code>는 1024차원입니다.<sup id="fnref:pinecone-index" role="doc-noteref"><a href="#fn:pinecone-index" class="footnote" rel="footnote">38</a></sup></li>
</ul>

<h3 id="임베딩-모델을-바꾸면-다시-색인합니다">임베딩 모델을 바꾸면 다시 색인합니다</h3>

<p>벡터 값은 그 벡터를 만든 모델의 공간 안에서 의미를 가집니다. CLIP이 텍스트와 이미지를 한 공간에 놓기 위해 두 인코더를 함께 학습한 것도 이 때문입니다(5절). 따로 학습한 모델이 만든 벡터는 차원이 우연히 같더라도 같은 공간에 있다고 볼 수 없습니다. 임베딩 모델을 바꾸면 기존 데이터를 새 모델로 다시 임베딩해야 합니다.</p>

<p>제품 문서도 이 작업을 새 컬렉션을 만드는 방식으로 안내합니다. Weaviate 문서에는 vectorizer를 바꿀 수 없으며 새 컬렉션을 만들어 데이터를 옮기라고 나와 있습니다. <code class="language-plaintext highlighter-rouge">distance</code>나 <code class="language-plaintext highlighter-rouge">efConstruction</code> 같은 변경 불가 설정도 같은 방법으로 바꿉니다.<sup id="fnref:weaviate-config:1" role="doc-noteref"><a href="#fn:weaviate-config" class="footnote" rel="footnote">20</a></sup> Elasticsearch에서는 앞 소절(차원은 컬렉션을 만들 때 정해집니다)에서 설명한 재색인 절차를 따릅니다.<sup id="fnref:es-mapping:1" role="doc-noteref"><a href="#fn:es-mapping" class="footnote" rel="footnote">37</a></sup> alias를 쓰면 새 인덱스로 검색 대상을 바꾸는 작업을 중단 없이 할 수 있습니다.<sup id="fnref:es-alias:1" role="doc-noteref"><a href="#fn:es-alias" class="footnote" rel="footnote">24</a></sup></p>

<h3 id="거리-측정과-정규화도-맞춥니다">거리 측정과 정규화도 맞춥니다</h3>

<p>같은 모델로 만든 벡터라도 거리 측정이 다르면 점수의 척도가 달라집니다. 벡터를 단위 길이로 맞추는 정규화(normalization)는 코사인 유사도와 내적의 관계를 바꿉니다.</p>

<ul>
  <li><strong>Qdrant:</strong> 코사인 거리를 쓰면 벡터를 컬렉션에 넣을 때 한 번 정규화하고 비교할 때는 내적으로 계산합니다. 문서에 따르면 내적은 SIMD(한 명령으로 여러 값을 동시에 계산하는 CPU 기능) 덕분에 매우 빠른 연산입니다.<sup id="fnref:qdrant-search:1" role="doc-noteref"><a href="#fn:qdrant-search" class="footnote" rel="footnote">34</a></sup></li>
  <li><strong>Milvus:</strong> 실수 벡터에 L2·IP(내적)·COSINE을 지원합니다. 정규화한 임베딩에 IP를 쓰면 코사인 유사도를 계산한 것과 같습니다.<sup id="fnref:milvus-metric" role="doc-noteref"><a href="#fn:milvus-metric" class="footnote" rel="footnote">39</a></sup></li>
  <li><strong>Elasticsearch:</strong> <code class="language-plaintext highlighter-rouge">dot_product</code>는 float 벡터일 때 문서와 질의 벡터가 모두 단위 길이여야 합니다. <code class="language-plaintext highlighter-rouge">cosine</code>은 색인할 때 벡터를 자동으로 단위 길이로 정규화합니다. <code class="language-plaintext highlighter-rouge">max_inner_product</code>는 정규화를 요구하지 않습니다.<sup id="fnref:es-dense-vector:2" role="doc-noteref"><a href="#fn:es-dense-vector" class="footnote" rel="footnote">9</a></sup></li>
  <li><strong>pgvector:</strong> <code class="language-plaintext highlighter-rouge">&lt;-&gt;</code>는 L2 거리, <code class="language-plaintext highlighter-rouge">&lt;#&gt;</code>는 내적, <code class="language-plaintext highlighter-rouge">&lt;=&gt;</code>는 코사인 거리입니다. PostgreSQL 인덱스 스캔이 오름차순만 지원하므로 <code class="language-plaintext highlighter-rouge">&lt;#&gt;</code>는 내적에 음수를 붙여 돌려줍니다. 벡터가 길이 1로 정규화돼 있다면 내적을 쓰는 것이 성능상 가장 좋습니다.<sup id="fnref:pgvector:2" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">2</a></sup></li>
  <li><strong>Pinecone:</strong> <code class="language-plaintext highlighter-rouge">euclidean</code>·<code class="language-plaintext highlighter-rouge">cosine</code>·<code class="language-plaintext highlighter-rouge">dotproduct</code> 중에서 임베딩 모델을 학습할 때 쓴 거리 측정을 고르라고 안내합니다.<sup id="fnref:pinecone-index:1" role="doc-noteref"><a href="#fn:pinecone-index" class="footnote" rel="footnote">38</a></sup></li>
</ul>

<h2 id="8-컬렉션을-나누는-것과-샤드를-나누는-것은-무엇이-다를까요">8. 컬렉션을 나누는 것과 샤드를 나누는 것은 무엇이 다를까요</h2>

<p>컬렉션을 날짜별로 나누고 그 안을 다시 주제별로 나누는 그림을 그리다 보면 컬렉션 분리와 샤드 분할을 같은 도구로 생각하기 쉽습니다. 두 가지는 목적이 다릅니다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>컬렉션 분리</th>
      <th>컬렉션 안의 샤드</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>나누는 것</td>
      <td>스키마·모델·권한·생명주기가 다른 데이터</td>
      <td>한 컬렉션 데이터의 수평 조각</td>
    </tr>
    <tr>
      <td>목적</td>
      <td>성격이 다른 데이터를 따로 관리</td>
      <td>한 컬렉션을 여러 노드에 나눠 확장</td>
    </tr>
    <tr>
      <td>개수를 정하는 시점</td>
      <td>필요할 때 생성</td>
      <td>대개 컬렉션(인덱스)을 만들 때</td>
    </tr>
    <tr>
      <td>늘릴 때의 부담</td>
      <td>컬렉션마다 리소스 오버헤드, 관리 대상 증가</td>
      <td>제품별 상한(Milvus 16, Elasticsearch 1,024)</td>
    </tr>
  </tbody>
</table>

<p><strong>Milvus</strong>에서 샤드는 컬렉션의 수평 조각이며 샤드마다 데이터 입력 채널이 하나씩 대응합니다. 기본값은 1개이고 컬렉션을 만들 때 지정합니다. 파라미터 이름은 SDK마다 달라 Python은 <code class="language-plaintext highlighter-rouge">num_shards</code>, Node.js는 <code class="language-plaintext highlighter-rouge">shards_num</code>, Java는 <code class="language-plaintext highlighter-rouge">numShards</code>입니다. 문서는 엔티티 2억 개마다 샤드 하나를 두는 것이 일반적이고 stream node가 여러 대라면 샤드를 여러 개 쓰라고 권합니다.<sup id="fnref:milvus-create" role="doc-noteref"><a href="#fn:milvus-create" class="footnote" rel="footnote">40</a></sup> 컬렉션당 샤드는 최대 16개입니다.<sup id="fnref:milvus-limits:1" role="doc-noteref"><a href="#fn:milvus-limits" class="footnote" rel="footnote">14</a></sup></p>

<p><strong>Qdrant</strong>는 수평 확장을 위해 컬렉션의 point를 여러 샤드에 분산합니다. <code class="language-plaintext highlighter-rouge">shard_number</code>를 지정하지 않으면 컬렉션을 만들 때의 클러스터 노드 수로 정해집니다. Qdrant Cloud에서는 리샤딩으로 기존 컬렉션의 샤드 수를 바꿀 수 있고 셀프 호스팅에서는 컬렉션을 다시 만들어야 합니다.<sup id="fnref:qdrant-distributed" role="doc-noteref"><a href="#fn:qdrant-distributed" class="footnote" rel="footnote">41</a></sup></p>

<p><strong>Elasticsearch</strong>의 <code class="language-plaintext highlighter-rouge">index.number_of_shards</code>는 인덱스의 primary 샤드 수입니다. 기본값은 1이며 인덱스를 만들 때만 설정할 수 있습니다. 클러스터를 불안정하게 만드는 실수를 막기 위해 인덱스당 1,024개로 제한합니다. 복제본 수 <code class="language-plaintext highlighter-rouge">index.number_of_replicas</code>는 운영 중에 바꿀 수 있는 동적 설정이며 기본값은 1입니다.<sup id="fnref:es-shards" role="doc-noteref"><a href="#fn:es-shards" class="footnote" rel="footnote">42</a></sup></p>

<h3 id="주제별로-데이터를-모으고-싶다면">주제별로 데이터를 모으고 싶다면</h3>

<p>“날짜별 컬렉션 안에 스포츠 기사는 샤드 1, 경제 기사는 샤드 2”처럼 샤드를 주제에 맞춰 나누고 싶을 수 있습니다. 하지만 샤드 수 설정은 데이터를 어떤 값으로 모을지를 정하는 기능이 아닙니다. 특정 값으로 데이터를 물리적으로 모으는 기능은 따로 있습니다.</p>

<ul>
  <li><strong>Milvus partition key:</strong> 파티션 키 값에 따라 데이터를 물리적으로 분리된 파티션에 자동으로 나눠 넣습니다.<sup id="fnref:milvus-tenancy:3" role="doc-noteref"><a href="#fn:milvus-tenancy" class="footnote" rel="footnote">13</a></sup></li>
  <li><strong>Qdrant <code class="language-plaintext highlighter-rouge">is_tenant</code>:</strong> 같은 테넌트 값을 가진 벡터를 저장소에서 가까이 모읍니다.<sup id="fnref:qdrant-multitenancy:2" role="doc-noteref"><a href="#fn:qdrant-multitenancy" class="footnote" rel="footnote">12</a></sup></li>
  <li><strong>Qdrant user-defined sharding:</strong> 테넌트별로 샤드를 지정하는 방식입니다. 작은 테넌트는 공유 샤드에 두고 커지면 전용 샤드로 옮기는 tiered multitenancy도 안내합니다.<sup id="fnref:qdrant-multitenancy:3" role="doc-noteref"><a href="#fn:qdrant-multitenancy" class="footnote" rel="footnote">12</a></sup></li>
</ul>

<p>뉴스 예제라면 주제는 partition key나 테넌트 값으로 나누고 샤드 수는 전체 데이터 양과 노드 수에 맞춰 따로 정하는 구성이 각 기능의 목적에 맞습니다.</p>

<h2 id="정리">정리</h2>

<p>벡터 DB의 레코드는 ID·벡터·원문·메타데이터로 이루어지고 컬렉션은 RDB의 테이블에 해당합니다. 컬렉션을 나누는 기준도 RDB와 비슷하게 데이터의 크기, 접근 패턴, 생명주기, 속성, 운영 용이성입니다. 다만 공식 문서는 컬렉션을 늘리기보다 파티션·테넌트·namespace·메타데이터 필터로 나누는 방법을 먼저 안내합니다. 컬렉션은 스키마나 임베딩 모델, 권한이 다를 때 나누는 무거운 경계로 두는 편이 좋습니다.</p>

<p>자기 서비스에 적용할 때는 아래 질문을 차례로 확인해 봅니다.</p>

<table>
  <thead>
    <tr>
      <th>확인할 질문</th>
      <th>“예”라면</th>
      <th>참고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>임베딩 모델·차원·거리 측정이 데이터마다 다른가</td>
      <td>컬렉션(또는 named vector)을 나눔</td>
      <td>5·7절</td>
    </tr>
    <tr>
      <td>DB 수준의 접근 권한 분리가 필요한가</td>
      <td>컬렉션이나 데이터베이스를 나눔</td>
      <td>3절</td>
    </tr>
    <tr>
      <td>데이터를 함께 검색할 일이 잦은가</td>
      <td>한 컬렉션에 두고 파티션·테넌트·필터로 나눔</td>
      <td>3·7절</td>
    </tr>
    <tr>
      <td>테넌트·업무 단위로 검색 범위를 나눠야 하는가</td>
      <td>파티션·partition key·테넌트·namespace를 검토</td>
      <td>3·4절</td>
    </tr>
    <tr>
      <td>기간이 지나면 지워야 하는 데이터인가</td>
      <td>data stream·TTL·필터 삭제를 먼저 검토</td>
      <td>6절</td>
    </tr>
    <tr>
      <td>한 컬렉션이 노드 하나에 담기지 않을 만큼 큰가</td>
      <td>샤드 수를 늘림(생성 시점에 정하는 제품이 많음)</td>
      <td>8절</td>
    </tr>
    <tr>
      <td>컬렉션 수가 계속 늘어나는 설계인가</td>
      <td>자동화 계획을 세우거나, 더 가벼운 분리 수단으로 바꿈</td>
      <td>3·6절</td>
    </tr>
    <tr>
      <td>임베딩 모델을 바꿀 계획이 있는가</td>
      <td>새 컬렉션에 다시 임베딩하고 alias 등으로 전환</td>
      <td>7절</td>
    </tr>
  </tbody>
</table>

<p>어느 구성이 나은지는 데이터를 저장하고 쓰는 방식에 따라 달라집니다. 후보 구성을 작게 만들어 실제 질의로 결과 품질, 응답 시간, 정리 작업을 비교해 봅니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:chroma" role="doc-endnote">
      <p><a href="https://docs.trychroma.com/docs/collections/manage-collections">Chroma — Manage Collections</a>, <a href="https://docs.trychroma.com/docs/collections/add-data">Chroma — Add Data</a>. 저장·질의의 기본 단위인 컬렉션, 컬렉션별 임베딩 함수, 레코드의 <code class="language-plaintext highlighter-rouge">id</code>·<code class="language-plaintext highlighter-rouge">documents</code>·<code class="language-plaintext highlighter-rouge">embeddings</code>·<code class="language-plaintext highlighter-rouge">metadatas</code>. <a href="#fnref:chroma" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:chroma:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:pgvector" role="doc-endnote">
      <p><a href="https://github.com/pgvector/pgvector">pgvector — README</a>. 0.8.7. <code class="language-plaintext highlighter-rouge">vector(n)</code> 열, 거리 연산자, 저장·인덱스 차원 상한, 차원 없는 열의 인덱스 제한, 정규화 벡터의 내적. <a href="#fnref:pgvector" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pgvector:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:pgvector:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:milvus-collection" role="doc-endnote">
      <p><a href="https://milvus.io/docs/manage-collections.md">Milvus — Collection Explained</a>. v3.0.x. 2차원 표로서의 컬렉션, 엔티티·필드·기본 키. <a href="#fnref:milvus-collection" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:milvus-schema" role="doc-endnote">
      <p><a href="https://milvus.io/docs/schema.md">Milvus — Schema Explained</a>. v3.0.x. 스키마 구성, 기본 키 필드 타입, 벡터 필드의 <code class="language-plaintext highlighter-rouge">dim</code>. <a href="#fnref:milvus-schema" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:milvus-schema:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:milvus-database" role="doc-endnote">
      <p><a href="https://milvus.io/docs/manage_databases.md">Milvus — Database</a>. v3.0.x. 논리 단위로서의 데이터베이스와 기본 데이터베이스. <a href="#fnref:milvus-database" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:qdrant-points" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/manage-data/points/">Qdrant — Points</a>. point의 구성과 ID 형식, named vector 입력, 필터 삭제 요청. <a href="#fnref:qdrant-points" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant-points:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:qdrant-points:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:weaviate-data" role="doc-endnote">
      <p><a href="https://docs.weaviate.io/weaviate/concepts/data">Weaviate — Data Structure</a>. 컬렉션과 객체, UUID, 컬렉션별 벡터 공간, named vectors, 테넌트별 벡터 인덱스와 테넌트 상태. <a href="#fnref:weaviate-data" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:weaviate-data:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:weaviate-data:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:weaviate-data:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:pinecone-concepts" role="doc-endnote">
      <p><a href="https://docs.pinecone.io/guides/get-started/concepts">Pinecone — Concepts</a>. API 버전 2026-07. 프로젝트·인덱스·namespace·레코드·메타데이터·sparse 벡터. <a href="#fnref:pinecone-concepts" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:es-dense-vector" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector">Elasticsearch — Dense Vector Field Type</a>. <code class="language-plaintext highlighter-rouge">dense_vector</code>, <code class="language-plaintext highlighter-rouge">dims</code> 기본 동작, <code class="language-plaintext highlighter-rouge">dot_product</code>·<code class="language-plaintext highlighter-rouge">cosine</code>·<code class="language-plaintext highlighter-rouge">max_inner_product</code>의 정규화 조건. <a href="#fnref:es-dense-vector" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:es-dense-vector:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:es-dense-vector:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:milvus-dynamic" role="doc-endnote">
      <p><a href="https://milvus.io/docs/enable-dynamic-field.md">Milvus — Dynamic Field</a>. v3.0.x. <code class="language-plaintext highlighter-rouge">$meta</code> 예약 필드와 <code class="language-plaintext highlighter-rouge">enable_dynamic_field</code>. <a href="#fnref:milvus-dynamic" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:qdrant-collections" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/concepts/collections/">Qdrant — Collections</a>. 컬렉션 정의, 컬렉션 안 벡터의 같은 차원·거리 측정, payload 기반 분할 권고와 다중 컬렉션의 오버헤드, <code class="language-plaintext highlighter-rouge">shard_number</code>. <a href="#fnref:qdrant-collections" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant-collections:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:qdrant-collections:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:qdrant-multitenancy" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/guides/multitenancy/">Qdrant — Multitenancy</a>. 테넌트별 컬렉션의 비효율, <code class="language-plaintext highlighter-rouge">is_tenant</code>, user-defined sharding과 tiered multitenancy. <a href="#fnref:qdrant-multitenancy" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant-multitenancy:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:qdrant-multitenancy:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:qdrant-multitenancy:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:milvus-tenancy" role="doc-endnote">
      <p><a href="https://milvus.io/docs/multi_tenancy.md">Milvus — Implement Multi-tenancy</a>. v3.0.x. Database·Collection·Partition·Partition key 수준의 상한·격리·성능 비교와 파티션 간 질의. <a href="#fnref:milvus-tenancy" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:milvus-tenancy:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:milvus-tenancy:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:milvus-tenancy:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:milvus-limits" role="doc-endnote">
      <p><a href="https://milvus.io/docs/limitations.md">Milvus — Milvus Limits</a>. v3.0.x. 클러스터당 컬렉션, 컬렉션당 파티션·샤드 상한. <a href="#fnref:milvus-limits" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:milvus-limits:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:weaviate-mt" role="doc-endnote">
      <p><a href="https://docs.weaviate.io/weaviate/manage-collections/multi-tenancy">Weaviate — Multi-tenancy Operations</a>. 테넌트별 샤드와 데이터 분리. <a href="#fnref:weaviate-mt" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pinecone-mt" role="doc-endnote">
      <p><a href="https://docs.pinecone.io/guides/index-data/implement-multitenancy">Pinecone — Implement Multitenancy</a>. API 버전 2026-07. 테넌트별 namespace, 단일 namespace 읽기·쓰기, namespace 삭제, 요금제별 namespace 수, 메타데이터 필터 방식의 비용. <a href="#fnref:pinecone-mt" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pinecone-mt:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:pinecone-mt:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:clip" role="doc-endnote">
      <p><a href="https://arxiv.org/abs/2103.00020">Radford 외 — Learning Transferable Visual Models From Natural Language Supervision</a>. 2021. 이미지·텍스트 인코더의 공동 학습과 멀티모달 임베딩 공간, 자연어를 이용한 zero-shot 전이. <a href="#fnref:clip" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:clip:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:qdrant-vectors" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/manage-data/vectors/">Qdrant — Vectors</a>. named vectors, dense·sparse·multivector, 컬렉션 설정의 고정 벡터 크기. <a href="#fnref:qdrant-vectors" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant-vectors:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:milvus-hybrid" role="doc-endnote">
      <p><a href="https://milvus.io/docs/multi-vector-search.md">Milvus — Multi-Vector Hybrid Search</a>. v3.0.x. 여러 벡터 필드를 함께 검색하는 하이브리드 검색과 RRF·Weighted Ranker. <a href="#fnref:milvus-hybrid" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:weaviate-config" role="doc-endnote">
      <p><a href="https://docs.weaviate.io/weaviate/config-refs/collections">Weaviate — Collection Definition</a>. 변경 불가 설정(vectorizer·<code class="language-plaintext highlighter-rouge">distance</code>·<code class="language-plaintext highlighter-rouge">efConstruction</code>)과 새 컬렉션으로의 이전, named vector 추가. <a href="#fnref:weaviate-config" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:weaviate-config:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:es-data-stream" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/manage-data/data-store/data-streams">Elasticsearch — Data Streams</a>. 백킹 인덱스, <code class="language-plaintext highlighter-rouge">@timestamp</code>, rollover와 백킹 인덱스 이름, ILM, 적합한 데이터. <a href="#fnref:es-data-stream" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:es-data-stream:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:es-data-stream:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:es-rollover" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management/rollover">Elasticsearch — Rollover</a>. 한 인덱스에 계속 쓸 때의 문제와 크기·나이·문서 수 조건. <a href="#fnref:es-rollover" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:es-ilm" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management/index-lifecycle">Elasticsearch — Index Lifecycle</a>. Hot부터 Delete까지의 단계와 단계 이동 조건. <a href="#fnref:es-ilm" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:es-alias" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/manage-data/data-store/aliases">Elasticsearch — Aliases</a>. 여러 인덱스를 가리키는 alias, <code class="language-plaintext highlighter-rouge">is_write_index</code>, 중단 없는 인덱스 교체. <a href="#fnref:es-alias" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:es-alias:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:milvus-ttl" role="doc-endnote">
      <p><a href="https://milvus.io/docs/set-collection-ttl.md">Milvus — Set Collection TTL</a>. v3.0.x. 엔티티 자동 만료, compaction 시점의 물리 삭제, <code class="language-plaintext highlighter-rouge">collection.ttl.seconds</code>. <a href="#fnref:milvus-ttl" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:weaviate-ttl" role="doc-endnote">
      <p><a href="https://docs.weaviate.io/weaviate/manage-collections/time-to-live">Weaviate — Time-to-Live</a>, <a href="https://docs.weaviate.io/weaviate/manage-objects/delete">Weaviate — Delete Objects</a>. 컬렉션 수준 TTL, 백그라운드 삭제, <code class="language-plaintext highlighter-rouge">where</code> 필터 삭제와 <code class="language-plaintext highlighter-rouge">QUERY_MAXIMUM_RESULTS</code>. <a href="#fnref:weaviate-ttl" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:chroma-delete" role="doc-endnote">
      <p><a href="https://docs.trychroma.com/docs/collections/delete-data">Chroma — Delete Data</a>. <code class="language-plaintext highlighter-rouge">id</code>·<code class="language-plaintext highlighter-rouge">where</code> 필터 삭제와 되돌릴 수 없는 삭제. <a href="#fnref:chroma-delete" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pinecone-delete" role="doc-endnote">
      <p><a href="https://docs.pinecone.io/guides/manage-data/delete-data">Pinecone — Delete Records</a>. API 버전 2026-07. ID·메타데이터 필터·namespace 단위 삭제. <a href="#fnref:pinecone-delete" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:es-multi-search" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-multiple-data-streams-indices">Elasticsearch — Search Multiple Data Streams and Indices</a>. 경로의 쉼표 나열, 인덱스 패턴, <code class="language-plaintext highlighter-rouge">indices_boost</code>. <a href="#fnref:es-multi-search" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:es-multi-search:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:es-search-api" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search">Elasticsearch — Search API</a>. 경로 파라미터 <code class="language-plaintext highlighter-rouge">index</code>의 와일드카드·<code class="language-plaintext highlighter-rouge">_all</code>. <a href="#fnref:es-search-api" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:es-script-score" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-script-score-query">Elasticsearch — Script Score Query</a>. <code class="language-plaintext highlighter-rouge">cosineSimilarity</code> 예제, 음수가 될 수 없는 점수, 벡터 함수의 선형 스캔. <a href="#fnref:es-script-score" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:es-script-score:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:es-knn" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/solutions/search/vector/knn">Elasticsearch — kNN Search</a>. 정확한 kNN과 근사 kNN의 쓰임. <a href="#fnref:es-knn" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:milvus-search" role="doc-endnote">
      <p><a href="https://milvus.io/docs/single-vector-search.md">Milvus — Basic Vector Search</a>. v3.0.x. 검색 요청의 컬렉션·파티션 지정과 파티션 수에 따른 성능. <a href="#fnref:milvus-search" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:qdrant-search" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/search/search/">Qdrant — Search</a>. <code class="language-plaintext highlighter-rouge">lookup_from</code>, 코사인 거리의 정규화와 내적 계산. <a href="#fnref:qdrant-search" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant-search:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:bert" role="doc-endnote">
      <p><a href="https://arxiv.org/abs/1810.04805">Devlin 외 — BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding</a>, <a href="https://github.com/google-research/bert">google-research/bert</a>, <a href="https://huggingface.co/google-bert/bert-base-uncased/blob/main/config.json">Hugging Face — bert-base-uncased config.json</a>. BERT-base·BERT-large의 층 수·은닉 크기·파라미터 수. <a href="#fnref:bert" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:bert:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:milvus-alter" role="doc-endnote">
      <p><a href="https://milvus.io/docs/alter-collection-field.md">Milvus — Alter Collection Field</a>. v3.0.x. 생성 후 변경할 수 있는 필드 속성과 기본 키 필드의 변경 제한. <a href="#fnref:milvus-alter" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:es-mapping" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/manage-data/data-store/mapping/update-mappings-examples">Elasticsearch — Update Mappings Examples</a>. 기존 필드 타입 변경 불가와 새 인덱스로의 재색인. <a href="#fnref:es-mapping" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:es-mapping:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:pinecone-index" role="doc-endnote">
      <p><a href="https://docs.pinecone.io/guides/index-data/create-an-index">Pinecone — Create an Index</a>. API 버전 2026-07. 인덱스의 차원·거리 측정 지정과 임베딩 모델과의 일치. <a href="#fnref:pinecone-index" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pinecone-index:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:milvus-metric" role="doc-endnote">
      <p><a href="https://milvus.io/docs/metric.md">Milvus — Metric Types</a>. v3.0.x. L2·IP·COSINE과 정규화 벡터에서 IP와 코사인의 관계. <a href="#fnref:milvus-metric" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:milvus-create" role="doc-endnote">
      <p><a href="https://milvus.io/docs/create-collection.md">Milvus — Create Collection</a>. v3.0.x. 샤드의 정의와 기본값, SDK별 파라미터 이름, 샤드 수 권고. <a href="#fnref:milvus-create" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:qdrant-distributed" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/scaling/distributed_deployment/">Qdrant — Distributed Deployment</a>. 샤드 분산, <code class="language-plaintext highlighter-rouge">shard_number</code> 기본값, Cloud 리샤딩. <a href="#fnref:qdrant-distributed" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:es-shards" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules">Elasticsearch — Index Modules</a>. <code class="language-plaintext highlighter-rouge">index.number_of_shards</code>·<code class="language-plaintext highlighter-rouge">index.number_of_replicas</code>의 기본값과 인덱스당 샤드 상한. <a href="#fnref:es-shards" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="ai/ml" /><summary type="html"><![CDATA[벡터 DB 레코드를 이루는 ID·벡터·원문·메타데이터와 RDB 테이블과의 대응, 컬렉션을 나누는 기준과 파티션·테넌트·샤드 같은 대안, 여러 컬렉션을 함께 검색할 때 맞춰야 할 벡터 공간을 공식 문서에 근거해 정리합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">“DB 페일오버는 1초라는데” 애플리케이션은 왜 DB를 못 찾을까?</title><link href="https://rastalion.dev/writing/aurora-mysql-failover-application-recovery/" rel="alternate" type="text/html" title="“DB 페일오버는 1초라는데” 애플리케이션은 왜 DB를 못 찾을까?" /><published>2026-09-29T15:00:00+00:00</published><updated>2026-09-30T00:00:00+00:00</updated><id>https://rastalion.dev/writing/aurora-mysql-failover-application-recovery</id><content type="html" xml:base="https://rastalion.dev/writing/aurora-mysql-failover-application-recovery/"><![CDATA[<p>DB 클러스터가 새 쓰기 인스턴스(writer)를 승격했는데도 애플리케이션의 쓰기가 계속 실패할 수 있습니다. 이런 실패 중에는 연결이 살아 있고 읽기도 성공하는데 쓰기만 거부되는 경우도 있습니다.</p>

<p>직접 측정해 보니 실제로 이런 일이 일어났습니다. 같은 Aurora MySQL 클러스터에 연결한 애플리케이션들이 같은 페일오버(failover)를 겪었습니다. 어떤 구성은 약 2초 만에 쓰기가 다시 성공했고 어떤 구성은 약 30분이 걸렸습니다. 오래 멈춘 구성은 <strong>읽기 인스턴스(reader)로 강등된 옛 writer에 다시 연결해 그 연결을 계속 사용</strong>하고 있었습니다.<sup id="fnref:experiment" role="doc-noteref"><a href="#fn:experiment" class="footnote" rel="footnote">1</a></sup></p>

<p>제목의 <strong>“1초”가 무엇을 잰 값인지부터 구분해야 합니다. 이 기록에서 1초 미만이었던 것은 페일오버 명령이 반환되기까지의 시간이었습니다.</strong> DB 내부 전환이나 서비스 복구가 1초 안에 끝났다는 뜻은 아닙니다. 이 글에서 <strong>애플리케이션의 복구</strong>는 새 writer가 생긴 뒤 애플리케이션이 그 writer에서 실제 쓰기에 다시 성공하는 것입니다.</p>

<p>이 글은 두 가지 질문에 답합니다. 페일오버가 끝났는데 애플리케이션은 왜 쓰지 못할까요? 운영 중인 서비스에서는 무엇을 확인해야 할까요?</p>

<p><strong>이 글에서 알 수 있는 것</strong>(수동 페일오버 S1 기준)</p>

<ul>
  <li>AWS Advanced JDBC Wrapper(기존 드라이버를 감싸 새 writer를 직접 찾는 AWS 드라이버, 이하 Wrapper)를 쓴 구성은 약 2초 만에 새 writer에서 쓰기에 다시 성공했습니다.</li>
  <li>DNS·타임아웃을 조정한 일반 드라이버는 약 10초 만에 새 writer로 돌아왔습니다.</li>
  <li>기본값 일반 드라이버는 옛 writer에 남아 길게 관찰한 반복에서 약 30분 뒤에야 쓰기가 복구됐습니다. 커넥션 풀은 그 연결을 고장으로 보지 않았습니다.</li>
</ul>

<p>JDBC URL에 따라 페일오버 복구 시간이 달라진다는 사례를 보고 드라이버·DNS·커넥션 풀(connection pool) 설정을 바꿔 가며 직접 측정했습니다.</p>

<p>가벼운 JDBC 부하를 걸고 수동 페일오버 10회(S1), reader 삭제 2회(S2), Blue/Green 전환(S3)을 관찰했습니다. Blue/Green 전환은 DB 사용자(<code class="language-plaintext highlighter-rouge">probe</code>·<code class="language-plaintext highlighter-rouge">admin</code>)별로 1회씩입니다. 반복별 요약 자료는 11절에 CSV로 공개합니다.</p>

<p>이 실측 결과로 참고 사례의 복구 시간이나 운영 환경의 문제를 설명하지는 않습니다. 장기 트랜잭션, 네트워크 단절, 높은 동시성, ORM·Spring Boot 설정 바인딩, RDS Proxy는 측정 범위에 없습니다. reader 삭제는 2회, Blue/Green은 사용자별 1회뿐이어서 그 결과는 복구 시간을 일반화할 근거가 되지 않습니다.</p>

<p>이 글의 측정일과 문서 조사 기준일은 2026년 9월 28일입니다.</p>

<h2 id="1-먼저-알아-둘-개념은-무엇일까요">1. 먼저 알아 둘 개념은 무엇일까요</h2>

<p>이 글은 DB 서버 내부보다 애플리케이션과 DB 사이의 연결 경로를 따라갑니다. 이 경로에 나오는 용어부터 정리합니다.</p>

<p>Aurora 클러스터에는 쓰기를 받는 writer와 읽기만 받는 reader가 있습니다. 애플리케이션은 보통 인스턴스 주소를 직접 쓰지 않고 클러스터가 제공하는 DNS 이름(endpoint)으로 연결합니다. 페일오버가 일어나면 이 이름이 가리키는 인스턴스가 바뀝니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 760 468" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="aurora-endpoints-basics-archify-diagram-title">Aurora 클러스터 엔드포인트 기본 구조</title>
        <desc id="aurora-endpoints-basics-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="aurora-endpoints-basics-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="aurora-endpoints-basics-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="aurora-endpoints-basics-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="aurora-endpoints-basics-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="aurora-endpoints-basics-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#aurora-endpoints-basics-grid)" />

        <!-- Boundaries (behind everything) -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="region" data-composition-frame-id="0" data-composition-frame-label="Aurora 클러스터" x="540" y="56" width="180" height="344" rx="12" class="c-region" stroke-width="1" />

        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="app" data-edge-to="cluster_endpoint" data-edge-label="쓰기·읽기 연결" data-edge-key="0" data-composition-points="216,105;300,105" d="M 216 105 L 300 105" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-endpoints-basics-arrowhead-emphasis)" />
        <path data-edge-from="cluster_endpoint" data-edge-to="writer" data-edge-label="현재 writer" data-edge-key="1" data-composition-points="492,112;560,112" d="M 492 112 L 560 112" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-endpoints-basics-arrowhead-emphasis)" />
        <path data-edge-from="app" data-edge-to="reader_endpoint" data-edge-label="읽기 전용 연결" data-edge-key="2" data-composition-points="216,119;258,119;258,352;300,352" d="M 216 119 L 250 119 Q 258 119 258 127 L 258 344 Q 258 352 266 352 L 300 352" class="a-default" stroke-width="1.5" marker-end="url(#aurora-endpoints-basics-arrowhead)" />
        <path data-edge-from="reader_endpoint" data-edge-to="reader" data-edge-label="reader들에 분산" data-edge-key="3" data-composition-points="496,352;560,352" d="M 496 352 L 560 352" class="a-default" stroke-width="1.5" marker-end="url(#aurora-endpoints-basics-arrowhead)" />
        <path data-edge-from="reader" data-edge-to="writer" data-edge-label="승격 → DNS 갱신" data-edge-key="4" data-composition-points="630,320;630,140" d="M 630 320 L 630 140" class="a-dashed" stroke-width="1.5" marker-end="url(#aurora-endpoints-basics-arrowhead-dashed)" />

        <!-- Components -->
        <g id="aurora-endpoints-basics-node-app" data-node-id="app" data-node-label="애플리케이션" tabindex="0" role="button" aria-label="Focus 애플리케이션, JDBC 드라이버 + 커넥션 풀, Architecture component" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="JDBC 드라이버 + 커넥션 풀" data-node-context="Architecture component">
          <title>애플리케이션 · JDBC 드라이버 + 커넥션 풀 · Architecture component</title>
          <rect x="40" y="80" width="176" height="64" rx="6" class="c-mask" />
          <rect x="40" y="80" width="176" height="64" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(46 86) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="128" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">애플리케이션</text>
        <text data-detail="context" x="128" y="126" class="t-muted" font-size="9" text-anchor="middle">JDBC 드라이버 + 커넥션 풀</text>
        </g>

        <g id="aurora-endpoints-basics-node-cluster_endpoint" data-node-id="cluster_endpoint" data-node-label="cluster endpoint" tabindex="0" role="button" aria-label="Focus cluster endpoint, DNS · 현재 writer, Architecture component" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="DNS · 현재 writer" data-node-context="Architecture component">
          <title>cluster endpoint · DNS · 현재 writer · Architecture component</title>
          <rect x="300" y="80" width="192" height="64" rx="6" class="c-mask" />
          <rect x="300" y="80" width="192" height="64" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(306 86) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="396" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">cluster endpoint</text>
        <text data-detail="context" x="396" y="126" class="t-muted" font-size="9" text-anchor="middle">DNS · 현재 writer</text>
        </g>

        <g id="aurora-endpoints-basics-node-writer" data-node-id="writer" data-node-label="writer 인스턴스" tabindex="0" role="button" aria-label="Focus writer 인스턴스, 읽기 · 쓰기, Aurora 클러스터" aria-pressed="false" data-node-kind="database" data-node-sublabel="읽기 · 쓰기" data-node-context="Aurora 클러스터">
          <title>writer 인스턴스 · 읽기 · 쓰기 · Aurora 클러스터</title>
          <rect x="560" y="80" width="140" height="60" rx="6" class="c-mask" />
          <rect x="560" y="80" width="140" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(566 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="630" y="108" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">writer 인스턴스</text>
        <text data-detail="context" x="630" y="124" class="t-muted" font-size="9" text-anchor="middle">읽기 · 쓰기</text>
        </g>

        <g id="aurora-endpoints-basics-node-reader_endpoint" data-node-id="reader_endpoint" data-node-label="reader endpoint(cluster-ro)" tabindex="0" role="button" aria-label="Focus reader endpoint(cluster-ro), DNS · reader들에 연결 분산, Architecture component" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="DNS · reader들에 연결 분산" data-node-context="Architecture component">
          <title>reader endpoint(cluster-ro) · DNS · reader들에 연결 분산 · Architecture component</title>
          <rect x="300" y="320" width="196" height="64" rx="6" class="c-mask" />
          <rect x="300" y="320" width="196" height="64" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(306 326) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="398" y="350" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">reader endpoint(cluster-ro)</text>
        <text data-detail="context" x="398" y="366" class="t-muted" font-size="9" text-anchor="middle">DNS · reader들에 연결 분산</text>
        </g>

        <g id="aurora-endpoints-basics-node-reader" data-node-id="reader" data-node-label="reader 인스턴스" tabindex="0" role="button" aria-label="Focus reader 인스턴스, 읽기 전용, Aurora 클러스터" aria-pressed="false" data-node-kind="database" data-node-sublabel="읽기 전용" data-node-context="Aurora 클러스터">
          <title>reader 인스턴스 · 읽기 전용 · Aurora 클러스터</title>
          <rect x="560" y="320" width="140" height="60" rx="6" class="c-mask" />
          <rect x="560" y="320" width="140" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(566 326) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="630" y="348" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">reader 인스턴스</text>
        <text data-detail="context" x="630" y="364" class="t-muted" font-size="9" text-anchor="middle">읽기 전용</text>
        </g>

        <!-- Connection labels -->
        <g data-detail="context" data-edge-from="app" data-edge-to="cluster_endpoint" data-edge-label="쓰기·읽기 연결" data-edge-key="0">
          <rect x="219.4" y="65" width="77.2" height="14" rx="3" class="c-mask" />
          <text x="258" y="75" class="t-backend" font-size="8" text-anchor="middle">쓰기·읽기 연결</text>
        </g>
        <g data-detail="context" data-edge-from="cluster_endpoint" data-edge-to="writer" data-edge-label="현재 writer" data-edge-key="1">
          <rect x="494.6" y="116" width="62.8" height="14" rx="3" class="c-mask" />
          <text x="526" y="126" class="t-backend" font-size="8" text-anchor="middle">현재 writer</text>
        </g>
        <g data-detail="context" data-edge-from="app" data-edge-to="reader_endpoint" data-edge-label="읽기 전용 연결" data-edge-key="2">
          <rect x="219.4" y="215.5" width="77.2" height="14" rx="3" class="c-mask" />
          <text x="258" y="225.5" class="t-muted" font-size="8" text-anchor="middle">읽기 전용 연결</text>
        </g>
        <g data-detail="context" data-edge-from="reader_endpoint" data-edge-to="reader" data-edge-label="reader들에 분산" data-edge-key="3">
          <rect x="487" y="282" width="82" height="14" rx="3" class="c-mask" />
          <text x="528" y="292" class="t-muted" font-size="8" text-anchor="middle">reader들에 분산</text>
        </g>
        <g data-detail="context" data-edge-from="reader" data-edge-to="writer" data-edge-label="승격 → DNS 갱신" data-edge-key="4">
          <rect x="589" y="300" width="82" height="14" rx="3" class="c-mask" />
          <text x="630" y="310" class="t-messagebus" font-size="8" text-anchor="middle">승격 → DNS 갱신</text>
        </g>

        <!-- Boundary labels (foreground masks keep routes out of titles) -->
        <g data-graph-role="structural-frame-label" data-composition-frame-id="0" data-composition-frame-kind="region" data-composition-frame-label="Aurora 클러스터">
          <rect data-graph-role="structural-frame-label-mask" x="544" y="60" width="91" height="16" rx="3" class="c-mask" />
          <text data-boundary-label="" x="548" y="73" class="t-cloud" font-size="9" font-weight="600">Aurora 클러스터</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="432" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="frontend" data-legend-kind="frontend" data-legend-label="애플리케이션" data-legend-x="40" data-legend-baseline="452" data-legend-width="103">
            <rect x="40" y="443" width="16" height="10" rx="2.5" class="c-frontend" stroke-width="1" />
            <text x="62" y="452" class="t-muted" font-size="10" font-weight="500">애플리케이션</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="DB 인스턴스" data-legend-x="165" data-legend-baseline="452" data-legend-width="98">
            <rect x="165" y="443" width="16" height="10" rx="2.5" class="c-database" stroke-width="1" />
            <text x="187" y="452" class="t-muted" font-size="10" font-weight="500">DB 인스턴스</text>
          </g>
          <g data-legend-semantic-kind="cloud" data-legend-kind="cloud" data-legend-label="DNS 엔드포인트" data-legend-x="285" data-legend-baseline="452" data-legend-width="113">
            <rect x="285" y="443" width="16" height="10" rx="2.5" class="c-cloud" stroke-width="1" />
            <text x="307" y="452" class="t-muted" font-size="10" font-weight="500">DNS 엔드포인트</text>
          </g>
        </g>
      </svg>
</div><figcaption>애플리케이션은 DNS 이름으로 연결하고, 페일오버 때 그 이름이 가리키는 대상이 바뀝니다</figcaption>
</figure>

<table>
  <thead>
    <tr>
      <th>용어</th>
      <th>쉬운 설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>writer</td>
      <td>클러스터에서 쓰기를 받는 인스턴스입니다. 이 실험의 클러스터에는 writer가 1대 있었습니다.</td>
    </tr>
    <tr>
      <td>reader</td>
      <td>읽기만 받는 인스턴스입니다. reader에 쓰기를 보내면 read-only 오류가 돌아옵니다.</td>
    </tr>
    <tr>
      <td>cluster endpoint</td>
      <td>현재 writer를 가리키는 DNS 이름입니다. writer가 바뀌면 이 이름이 가리키는 IP 주소도 바뀝니다.<sup id="fnref:endpoint" role="doc-noteref"><a href="#fn:endpoint" class="footnote" rel="footnote">2</a></sup></td>
    </tr>
    <tr>
      <td>reader endpoint(<code class="language-plaintext highlighter-rouge">cluster-ro</code>)</td>
      <td>reader들에 읽기 연결을 나눠 주는 DNS 이름입니다.<sup id="fnref:endpoints-overview" role="doc-noteref"><a href="#fn:endpoints-overview" class="footnote" rel="footnote">3</a></sup> 페일오버 중에도 다른 reader는 이 이름으로 들어온 읽기를 계속 처리할 수 있습니다.<sup id="fnref:ha" role="doc-noteref"><a href="#fn:ha" class="footnote" rel="footnote">4</a></sup></td>
    </tr>
    <tr>
      <td>페일오버(failover)</td>
      <td>writer 역할을 다른 인스턴스로 넘기는 동작입니다. Aurora MySQL은 이때 옛 writer와 새 writer가 될 reader를 재시작합니다.<sup id="fnref:ha:1" role="doc-noteref"><a href="#fn:ha" class="footnote" rel="footnote">4</a></sup> 이 실험에서 옛 writer는 reader로 강등됐습니다.</td>
    </tr>
    <tr>
      <td>JDBC 드라이버</td>
      <td>Java 애플리케이션이 DB와 SQL을 주고받게 해 주는 라이브러리입니다. 이 글에서는 MySQL Connector/J와 MariaDB Connector/J를 썼습니다.</td>
    </tr>
    <tr>
      <td>커넥션 풀(HikariCP)</td>
      <td>DB 연결을 미리 만들어 두고, 애플리케이션에 빌려준 뒤 돌려받는 구성 요소입니다. 이 글의 모든 구성은 HikariCP를 썼습니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">maxLifetime</code></td>
      <td>풀에 있는 연결 하나가 살아 있을 수 있는 최대 시간입니다. 이 시간이 지난 연결은 새 연결로 교체됩니다. HikariCP 7.1.0의 기본값은 30분입니다.<sup id="fnref:hikari-config" role="doc-noteref"><a href="#fn:hikari-config" class="footnote" rel="footnote">5</a></sup></td>
    </tr>
    <tr>
      <td>JVM DNS 캐시(TTL)</td>
      <td>JVM이 DNS 조회 결과를 기억해 두는 시간입니다. 이 시간이 지나야 이름을 다시 조회합니다. 일부 Java 설정에서는 JVM을 재시작할 때까지 DNS 항목을 갱신하지 않습니다.<sup id="fnref:dns-ttl" role="doc-noteref"><a href="#fn:dns-ttl" class="footnote" rel="footnote">6</a></sup></td>
    </tr>
    <tr>
      <td>AWS Advanced JDBC Wrapper</td>
      <td>기존 JDBC 드라이버를 감싸서 쓰는 AWS의 드라이버입니다. 클러스터 구성(토폴로지)을 따로 확인해 새 writer를 찾습니다.<sup id="fnref:failover2" role="doc-noteref"><a href="#fn:failover2" class="footnote" rel="footnote">7</a></sup> 이 글에서는 줄여서 Wrapper라고 부릅니다.</td>
    </tr>
    <tr>
      <td>Blue/Green 배포</td>
      <td>운영 중인 환경(blue) 옆에 복제로 맞춰 둔 새 환경(green)을 두고, 전환(스위치오버, switchover) 때 서비스 대상을 green으로 옮기는 방식입니다. 전환 뒤에도 옛 blue는 남습니다.<sup id="fnref:blue-green" role="doc-noteref"><a href="#fn:blue-green" class="footnote" rel="footnote">8</a></sup></td>
    </tr>
    <tr>
      <td>read-only 오류(1836)</td>
      <td>읽기 전용 상태인 노드가 쓰기를 거부할 때 돌려주는 오류입니다. 이 글의 기록에서는 SQLState(JDBC 예외에 담기는 표준 오류 분류 값) <code class="language-plaintext highlighter-rouge">HY000</code>, 오류 코드 <code class="language-plaintext highlighter-rouge">1836</code>, 메시지 <code class="language-plaintext highlighter-rouge">Running in read-only mode</code>로 남았습니다.</td>
    </tr>
    <tr>
      <td>일반 드라이버</td>
      <td>Wrapper나 <code class="language-plaintext highlighter-rouge">aurora</code> 스킴 없이 MySQL·MariaDB Connector/J에 cluster endpoint URL을 그대로 쓴 구성입니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aurora</code> 스킴</td>
      <td>MariaDB Connector/J 2.7에서 JDBC URL을 <code class="language-plaintext highlighter-rouge">jdbc:mysql:aurora://</code>로 시작해 Aurora 전용 동작을 켜는 방식입니다. 3.0.3에서 제거됐습니다.<sup id="fnref:mariadb" role="doc-noteref"><a href="#fn:mariadb" class="footnote" rel="footnote">9</a></sup></td>
    </tr>
  </tbody>
</table>

<p>비교한 구성은 모두 19개입니다. 구성 이름은 접두어와 번호로 이뤄집니다.</p>

<table>
  <thead>
    <tr>
      <th>접두어</th>
      <th>뜻</th>
      <th>예</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d</code></td>
      <td>기본값 구성: 드라이버와 커넥션 풀을 기본값으로 둔 구성</td>
      <td><code class="language-plaintext highlighter-rouge">d01</code> MySQL 9.7 기본값</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t</code></td>
      <td>튜닝 구성: DNS·타임아웃·풀을 조정한 구성</td>
      <td><code class="language-plaintext highlighter-rouge">t03</code> MariaDB 2.7 튜닝</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v</code></td>
      <td>변형 구성: 튜닝 구성을 바탕으로 일부 설정을 바꾼 비교용 구성</td>
      <td><code class="language-plaintext highlighter-rouge">v01</code> t03 + DNS TTL -1</td>
    </tr>
  </tbody>
</table>

<p>시나리오 이름도 세 가지입니다.</p>

<ul>
  <li><strong>S1</strong>: 수동 페일오버를 10회 반복했습니다. 이 글의 중심 결과입니다.</li>
  <li><strong>S2</strong>: reader를 삭제하는 시험을 2회 했습니다. 결과는 9절 끝에 있습니다.</li>
  <li><strong>S3</strong>: Blue/Green 전환을 사용자별로 1회씩 관찰했습니다.</li>
</ul>

<h2 id="2-페일오버가-끝났다는-시간은-무엇을-뜻할까요">2. 페일오버가 끝났다는 시간은 무엇을 뜻할까요</h2>

<p>“페일오버에 걸린 시간”은 무엇을 쟀는지에 따라 값이 다릅니다. 시작점과 끝점이 서로 다르기 때문입니다.</p>

<p>실험에서는 <code class="language-plaintext highlighter-rouge">aws rds failover-db-cluster</code> 명령으로 페일오버를 요청했습니다. 명령은 0.4~0.5초 만에 반환됐고 RDS의 페일오버 시작·완료 이벤트는 이와 별도로 기록됐습니다.</p>

<table>
  <thead>
    <tr>
      <th>측정 대상</th>
      <th>이번 기록</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CLI 호출부터 명령 반환까지</td>
      <td>0.402~0.508초, 10회</td>
      <td>페일오버 요청을 보낸 명령의 실행 시간</td>
    </tr>
    <tr>
      <td>RDS의 페일오버 시작·완료 이벤트 간격</td>
      <td>6.386~11.660초, 10회</td>
      <td>RDS가 기록한 두 이벤트의 시각 차이</td>
    </tr>
    <tr>
      <td>DB 자체가 쓰기를 받을 수 없었던 시간</td>
      <td>별도 측정하지 않음</td>
      <td>서버의 SQL 처리 가능 상태를 독립적으로 확인해야 하는 값</td>
    </tr>
    <tr>
      <td>애플리케이션의 쓰기 공백</td>
      <td>구성에 따라 약 2초~30분, 일부 미복구</td>
      <td>클라이언트에서 성공한 쓰기가 얼마나 오래 끊겼는지</td>
    </tr>
  </tbody>
</table>

<p>S1 페일오버에서는 명령이 반환된 직후를 사건 종료 시각으로 기록했습니다. 공개 자료의 CLI 반환 시간은 CLI를 호출한 시각과 이 종료 시각의 차이입니다. 이 값을 “DB 페일오버가 0.5초에 끝났다”고 읽으면 안 됩니다. RDS 이벤트 간격도 DB의 모든 읽기·쓰기가 멈춘 시간과 같다고 볼 수 없습니다.<sup id="fnref:event-time" role="doc-noteref"><a href="#fn:event-time" class="footnote" rel="footnote">10</a></sup></p>

<p>표의 네 값은 시작점과 종료 조건이 서로 다릅니다. 그래서 RDS 이벤트가 약 9초 간격으로 남았는데 Wrapper의 쓰기 공백이 약 2초였다고 해도 모순이 아닙니다. 반대로 RDS의 완료 이벤트가 남았다는 사실만으로는 애플리케이션이 복구됐는지 가리지 못합니다.</p>

<h2 id="3-같은-페일오버를-어떻게-관찰했을까요">3. 같은 페일오버를 어떻게 관찰했을까요</h2>

<p>구성마다 결과를 공정하게 비교하려면 모든 구성이 같은 사건을 겪어야 합니다. 그래서 비교 구성 19개를 각각 별도 JVM으로 동시에 실행하고 그 상태에서 페일오버를 10회 반복했습니다.<sup id="fnref:experiment:1" role="doc-noteref"><a href="#fn:experiment" class="footnote" rel="footnote">1</a></sup></p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>환경</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DB</td>
      <td>Aurora MySQL <code class="language-plaintext highlighter-rouge">8.0.mysql_aurora.3.13.0</code>, <code class="language-plaintext highlighter-rouge">db.r7g.large</code> writer 1대 + reader 1대(reader 삭제 시험은 reader 2대로 시작)</td>
    </tr>
    <tr>
      <td>실행 환경</td>
      <td>서울 리전, 같은 VPC의 EC2 <code class="language-plaintext highlighter-rouge">t4g.large</code>, Amazon Linux 2023, Corretto 21</td>
    </tr>
    <tr>
      <td>드라이버</td>
      <td>MySQL Connector/J 9.7.0, MariaDB Connector/J 2.7.15·3.5.10</td>
    </tr>
    <tr>
      <td>Wrapper·커넥션 풀</td>
      <td>AWS Advanced JDBC Wrapper 4.4.0, HikariCP 7.1.0</td>
    </tr>
    <tr>
      <td>부하</td>
      <td>구성마다 200ms 주기의 쓰기·읽기 루프. reader 전용 구성 1개는 읽기만 수행</td>
    </tr>
    <tr>
      <td>반복·관찰</td>
      <td>매번 JVM 재기동 후 60초 워밍업. 8회는 360초, 2회는 2,100초 관찰</td>
    </tr>
  </tbody>
</table>

<p>이 글에서는 측정 JVM을 실행한 이 EC2를 <strong>runner</strong>라고 부릅니다.</p>

<p>쓰기 루프는 풀에서 연결을 빌린 뒤, 그 연결로 응답한 노드의 정보를 조회하고 <code class="language-plaintext highlighter-rouge">INSERT</code>를 실행했습니다. 읽기 루프는 다음 쿼리로 응답한 노드와 연결 상태를 남겼습니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 응답한 인스턴스, 물리 연결, Aurora의 읽기 전용 상태를 함께 기록합니다.</span>
<span class="k">SELECT</span> <span class="o">@@</span><span class="n">aurora_server_id</span><span class="p">,</span>
       <span class="o">@@</span><span class="n">hostname</span><span class="p">,</span>
       <span class="n">CONNECTION_ID</span><span class="p">(),</span>
       <span class="o">@@</span><span class="n">innodb_read_only</span><span class="p">;</span>
</code></pre></div></div>

<p>결과를 읽으려면 용어 두 개가 필요합니다.</p>

<ul>
  <li><strong>쓰기 공백</strong>: 성공한 쓰기와 다음 성공한 쓰기 사이의 간격 가운데 가장 긴 값입니다. 사건 직전의 마지막 성공부터 관찰 종료까지를 봅니다. 중간에 성공 한 건이 끼면 장애 구간이 나뉠 수 있습니다.<sup id="fnref:s1" role="doc-noteref"><a href="#fn:s1" class="footnote" rel="footnote">11</a></sup></li>
  <li><strong>미복구</strong>: 관찰이 끝날 때까지 쓰기가 한 번도 다시 성공하지 않은 반복입니다.</li>
</ul>

<p>이 두 값을 해석할 때의 주의점(정상 상태의 간격, 미복구 값의 의미, 부하의 한계)은 11절에 모았습니다.</p>

<h2 id="4-같은-db에서-쓰기-복구는-얼마나-갈렸을까요">4. 같은 DB에서 쓰기 복구는 얼마나 갈렸을까요</h2>

<p>결론부터 말하면 같은 DB 사건인데도 쓰기가 다시 성공하는 시점이 구성마다 크게 달랐습니다. 아래 표는 기본값 구성과 튜닝 구성(DNS·타임아웃·커넥션 풀을 조정한 구성)을 비교한 결과입니다.</p>

<p>표의 수치는 복구된 반복의 <strong>중앙값(최솟값~최댓값)</strong>이고 단위는 초입니다. 미복구로 표시한 칸을 뺀 나머지 칸은 각각 10회 결과입니다. 미복구 칸에 함께 적은 2,100초 관찰 값은 2회의 최솟값~최댓값입니다.<sup id="fnref:s1:1" role="doc-noteref"><a href="#fn:s1" class="footnote" rel="footnote">11</a></sup></p>

<table>
  <thead>
    <tr>
      <th>드라이버·접속 방식</th>
      <th>기본값 구성</th>
      <th>튜닝 구성</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MySQL 9.7, 일반 cluster URL (<code class="language-plaintext highlighter-rouge">d01</code>·<code class="language-plaintext highlighter-rouge">t01</code>)</td>
      <td>360초 관찰 8회 미복구 · 2,100초 관찰 2회 1,763~1,803(약 30분)</td>
      <td>10.7 (5.6~10.8)</td>
    </tr>
    <tr>
      <td>MariaDB 2.7, 일반 cluster URL (<code class="language-plaintext highlighter-rouge">d02</code>·<code class="language-plaintext highlighter-rouge">t03</code>)</td>
      <td>360초 관찰 8회 미복구 · 2,100초 관찰 2회 1,762~1,780(약 30분)</td>
      <td>10.5 (5.5~10.6)</td>
    </tr>
    <tr>
      <td>MariaDB 3.5, 일반 cluster URL (<code class="language-plaintext highlighter-rouge">d04</code>·<code class="language-plaintext highlighter-rouge">t04</code>)</td>
      <td>360초 관찰 8회 미복구 · 2,100초 관찰 2회 1,791~1,802(약 30분)</td>
      <td>10.6 (5.6~10.7)</td>
    </tr>
    <tr>
      <td>MariaDB 2.7, <code class="language-plaintext highlighter-rouge">aurora</code> 스킴 (<code class="language-plaintext highlighter-rouge">d03</code>·<code class="language-plaintext highlighter-rouge">t02</code>, <code class="language-plaintext highlighter-rouge">t02</code>는 <code class="language-plaintext highlighter-rouge">cluster-ro</code> 병용)</td>
      <td>16.1 (15.5~16.5)</td>
      <td>8.4 (3.3~14.1)</td>
    </tr>
    <tr>
      <td>Wrapper + MySQL 9.7 (<code class="language-plaintext highlighter-rouge">d05</code>·<code class="language-plaintext highlighter-rouge">t05</code>)</td>
      <td>2.0 (1.7~2.9)</td>
      <td>1.8 (1.6~2.6)</td>
    </tr>
    <tr>
      <td>Wrapper + MariaDB 3.5 (<code class="language-plaintext highlighter-rouge">d06</code>·<code class="language-plaintext highlighter-rouge">t06</code>)</td>
      <td>1.8 (1.7~2.8)</td>
      <td>1.6 (1.5~2.5)</td>
    </tr>
  </tbody>
</table>

<p>표 위쪽 세 줄의 기본값 구성은 360초 관찰에서 8회 모두 복구되지 않았습니다. 관찰을 2,100초로 늘린 나머지 두 반복에서는 모두 복구됐습니다. 세 구성의 장기 관찰 결과 6건은 <strong>1,761.7~1,803.2초</strong>, 약 29~30분이었습니다. 짧게 관찰한 24건의 미복구 결과는 이 범위나 중앙값에 넣지 않았습니다. 관찰 길이별 값과 나머지 7개 구성의 결과는 11절에 있습니다.</p>

<p>같은 세 드라이버의 튜닝 구성은 중앙값이 약 10초였습니다. Wrapper를 쓴 구성은 기본값에서도 약 2초였습니다.</p>

<p>여기서 말하는 튜닝에서는 <code class="language-plaintext highlighter-rouge">maxLifetime</code> 외에 다른 값도 함께 바꿨습니다. 튜닝 구성에 공통으로 적용한 값은 다음과 같습니다.</p>

<ul>
  <li>JVM DNS TTL 5초</li>
  <li>드라이버 <code class="language-plaintext highlighter-rouge">connectTimeout</code> 3초·<code class="language-plaintext highlighter-rouge">socketTimeout</code> 10초</li>
  <li>HikariCP <code class="language-plaintext highlighter-rouge">connectionTimeout</code> 5초·<code class="language-plaintext highlighter-rouge">validationTimeout</code> 2초·<code class="language-plaintext highlighter-rouge">maxLifetime</code> 50초·<code class="language-plaintext highlighter-rouge">keepaliveTime</code> 30초·최대 풀 크기 4</li>
</ul>

<p>기본값 구성의 최대 풀 크기는 10이었습니다.</p>

<p>드라이버별 추가 설정은 공개 자료에 있습니다.<sup id="fnref:settings" role="doc-noteref"><a href="#fn:settings" class="footnote" rel="footnote">12</a></sup></p>

<p>여러 값을 한꺼번에 바꿨으므로 이 표만으로 어느 설정 하나가 차이를 만들었다고 단정할 수는 없습니다. 다만 <strong>같은 DB 사건을 겪어도 클라이언트 구성에 따라 쓰기가 다시 성공하는 시점이 크게 달랐다</strong>는 사실은 확인할 수 있습니다.</p>

<h2 id="5-새-writer가-생겼는데-왜-옛-writer에-연결될까요">5. 새 writer가 생겼는데 왜 옛 writer에 연결될까요</h2>

<p>페일오버 뒤의 여러 단계가 동시에 끝나지 않기 때문입니다. 그 틈에 새 연결이 옛 writer의 주소로 향할 수 있습니다.</p>

<p>cluster endpoint는 현재 writer를 가리키는 DNS 이름입니다. 페일오버로 writer가 바뀌면 이 이름이 가리키는 대상도 바뀝니다. AWS 문서도 커넥션 풀을 사용할 때는 캐시된 DNS 정보를 갱신하거나 그 TTL을 줄이도록 안내합니다.<sup id="fnref:endpoint:1" role="doc-noteref"><a href="#fn:endpoint" class="footnote" rel="footnote">2</a></sup></p>

<p>그런데 페일오버 뒤에는 네 가지 일이 따로 진행됩니다.</p>

<ul>
  <li>서버의 역할이 바뀝니다(옛 writer는 reader가 되고 reader 하나가 writer가 됩니다).</li>
  <li>cluster endpoint의 DNS가 갱신됩니다.</li>
  <li>JVM이 주소를 다시 조회합니다.</li>
  <li>JDBC 드라이버가 새 연결을 맺습니다.</li>
</ul>

<p>새 연결을 시작할 때 받은 주소가 옛 writer의 주소라면 DNS가 나중에 갱신돼도 그 연결 시도는 옛 노드로 향할 수 있습니다. Wrapper의 초기 연결 플러그인(<code class="language-plaintext highlighter-rouge">initialConnection</code>)도 이 상황을 다룹니다.<sup id="fnref:initial-connection" role="doc-noteref"><a href="#fn:initial-connection" class="footnote" rel="footnote">13</a></sup></p>

<p>첫 번째 페일오버의 기록에 이 흐름이 드러납니다. 아래 시간은 일반 MariaDB 2.7 튜닝 구성(<code class="language-plaintext highlighter-rouge">t03</code>)에서 첫 실패 연산이 시작된 시각을 기준으로 맞춘 값입니다.</p>

<table>
  <thead>
    <tr>
      <th>경과</th>
      <th>관찰</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>+1.6초</td>
      <td>runner의 DNS 조회에서 새 writer가 처음 관찰됨</td>
    </tr>
    <tr>
      <td>+5.3초</td>
      <td>일반 MariaDB 2.7 튜닝 구성의 첫 쓰기 응답: 새 writer에서 성공</td>
    </tr>
    <tr>
      <td>+10.4초</td>
      <td>일반 MariaDB 2.7 기본값 구성(<code class="language-plaintext highlighter-rouge">d02</code>)의 첫 쓰기 응답: 옛 writer에서 read-only 오류</td>
    </tr>
  </tbody>
</table>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 760 480" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="aurora-first-failover-timeline-archify-diagram-title">첫 페일오버 타임라인 (S1)</title>
        <desc id="aurora-first-failover-timeline-archify-diagram-description">A sequence diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="aurora-first-failover-timeline-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="aurora-first-failover-timeline-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="aurora-first-failover-timeline-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="aurora-first-failover-timeline-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="aurora-first-failover-timeline-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#aurora-first-failover-timeline-grid)" />

        <!-- Time Segments -->


        <!-- Lifelines -->
        <path d="M 113.5 142 L 113.5 415" class="a-default" stroke-width="0.8" stroke-dasharray="3,7" />
        <path d="M 252.25 142 L 252.25 415" class="a-default" stroke-width="0.8" stroke-dasharray="3,7" />
        <path d="M 391 142 L 391 415" class="a-default" stroke-width="0.8" stroke-dasharray="3,7" />
        <path d="M 529.75 142 L 529.75 415" class="a-default" stroke-width="0.8" stroke-dasharray="3,7" />
        <path d="M 668.5 142 L 668.5 415" class="a-default" stroke-width="0.8" stroke-dasharray="3,7" />

        <!-- Activations -->


        <!-- Messages -->
        <g data-edge-from="old_writer" data-edge-to="tuned_app" data-edge-label="0초 · 연결 끊김" data-edge-key="0">
          <path data-composition-edge-from="old_writer" data-composition-edge-to="tuned_app" data-composition-points="522.75,180;259.25,180" d="M 522.75 180 L 259.25 180" class="a-default" stroke-width="1.4" stroke-dasharray="3,5" marker-end="url(#aurora-first-failover-timeline-arrowhead)" />
        <g data-detail="context">
          <rect x="346" y="160" width="90" height="16" rx="3" class="c-mask" />
          <text x="391" y="170" class="t-muted" font-size="9" text-anchor="middle">0초 · 연결 끊김</text>
        </g>
        </g>

        <g data-edge-from="old_writer" data-edge-to="default_app" data-edge-label="0초 · 연결 끊김" data-edge-key="1">
          <path data-composition-edge-from="old_writer" data-composition-edge-to="default_app" data-composition-points="522.75,208;120.5,208" d="M 522.75 208 L 120.5 208" class="a-default" stroke-width="1.4" stroke-dasharray="3,5" marker-end="url(#aurora-first-failover-timeline-arrowhead)" />
        <g data-detail="context">
          <rect x="276.625" y="188" width="90" height="16" rx="3" class="c-mask" />
          <text x="321.625" y="198" class="t-muted" font-size="9" text-anchor="middle">0초 · 연결 끊김</text>
        </g>
        </g>

        <g data-edge-from="dns" data-edge-to="new_writer" data-edge-label="+1.6초 · DNS가 새 writer를 가리킴(runner 관찰)" data-edge-key="2">
          <path data-composition-edge-from="dns" data-composition-edge-to="new_writer" data-composition-points="398,236;661.5,236" d="M 398 236 L 661.5 236" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-first-failover-timeline-arrowhead-emphasis)" />
        <g data-detail="context">
          <rect x="404.15" y="216" width="251.20000000000002" height="16" rx="3" class="c-mask" />
          <text x="529.75" y="226" class="t-backend" font-size="9" text-anchor="middle">+1.6초 · DNS가 새 writer를 가리킴(runner 관찰)</text>
        </g>
        </g>

        <g data-edge-from="tuned_app" data-edge-to="new_writer" data-edge-label="+5.3초 · 재연결 · INSERT 성공" data-edge-key="3">
          <path data-composition-edge-from="tuned_app" data-composition-edge-to="new_writer" data-composition-points="259.25,264;661.5,264" d="M 259.25 264 L 661.5 264" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-first-failover-timeline-arrowhead-emphasis)" />
        <g data-detail="context">
          <rect x="378.975" y="244" width="162.8" height="16" rx="3" class="c-mask" />
          <text x="460.375" y="254" class="t-backend" font-size="9" text-anchor="middle">+5.3초 · 재연결 · INSERT 성공</text>
        </g>
        </g>

        <g data-edge-from="default_app" data-edge-to="old_writer" data-edge-label="재연결(DNS가 바뀌기 전 주소)" data-edge-key="4">
          <path data-composition-edge-from="default_app" data-composition-edge-to="old_writer" data-composition-points="120.5,292;522.75,292" d="M 120.5 292 L 522.75 292" class="a-default" stroke-width="1.4" marker-end="url(#aurora-first-failover-timeline-arrowhead)" />
        <g data-detail="context">
          <rect x="242.825" y="272" width="157.6" height="16" rx="3" class="c-mask" />
          <text x="321.625" y="282" class="t-backend" font-size="9" text-anchor="middle">재연결(DNS가 바뀌기 전 주소)</text>
        </g>
        </g>

        <g data-edge-from="old_writer" data-edge-to="default_app" data-edge-label="+10.4초 · INSERT 거부 read-only(1836)" data-edge-key="5">
          <path data-composition-edge-from="old_writer" data-composition-edge-to="default_app" data-composition-points="522.75,320;120.5,320" d="M 522.75 320 L 120.5 320" class="a-default" stroke-width="1.4" stroke-dasharray="3,5" marker-end="url(#aurora-first-failover-timeline-arrowhead)" />
        <g data-detail="context">
          <rect x="219.425" y="300" width="204.4" height="16" rx="3" class="c-mask" />
          <text x="321.625" y="310" class="t-muted" font-size="9" text-anchor="middle">+10.4초 · INSERT 거부 read-only(1836)</text>
        </g>
        </g>

        <g data-edge-from="default_app" data-edge-to="old_writer" data-edge-label="같은 연결(conn 98)로 관찰 종료까지 쓰기 거부" data-edge-key="6">
          <path data-composition-edge-from="default_app" data-composition-edge-to="old_writer" data-composition-points="120.5,348;522.75,348" d="M 120.5 348 L 522.75 348" class="a-dashed" stroke-width="1.4" marker-end="url(#aurora-first-failover-timeline-arrowhead-dashed)" />
        <g data-detail="context">
          <rect x="201.225" y="328" width="240.8" height="16" rx="3" class="c-mask" />
          <text x="321.625" y="338" class="t-messagebus" font-size="9" text-anchor="middle">같은 연결(conn 98)로 관찰 종료까지 쓰기 거부</text>
        </g>
        </g>

        <!-- Segment Labels -->


        <!-- Participants -->
        <g id="aurora-first-failover-timeline-node-default_app" data-node-id="default_app" data-node-label="기본값 구성" tabindex="0" role="button" aria-label="Focus 기본값 구성, d02, Sequence participant" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="d02" data-node-context="Sequence participant">
          <title>기본값 구성 · d02 · Sequence participant</title>
          <rect x="62" y="72" width="103" height="54" rx="6" class="c-mask" />
          <rect x="62" y="72" width="103" height="54" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(68 78) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="113.5" y="94" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">기본값 구성</text>
          <text data-detail="context" x="113.5" y="111" class="t-muted" font-size="7" text-anchor="middle">d02</text>
        </g>

        <g id="aurora-first-failover-timeline-node-tuned_app" data-node-id="tuned_app" data-node-label="튜닝 구성" tabindex="0" role="button" aria-label="Focus 튜닝 구성, t03, Sequence participant" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="t03" data-node-context="Sequence participant">
          <title>튜닝 구성 · t03 · Sequence participant</title>
          <rect x="200.75" y="72" width="103" height="54" rx="6" class="c-mask" />
          <rect x="200.75" y="72" width="103" height="54" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(206.75 78) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="252.25" y="94" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">튜닝 구성</text>
          <text data-detail="context" x="252.25" y="111" class="t-muted" font-size="7" text-anchor="middle">t03</text>
        </g>

        <g id="aurora-first-failover-timeline-node-dns" data-node-id="dns" data-node-label="DNS" tabindex="0" role="button" aria-label="Focus DNS, cluster endpoint, Sequence participant" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="cluster endpoint" data-node-context="Sequence participant">
          <title>DNS · cluster endpoint · Sequence participant</title>
          <rect x="339.5" y="72" width="103" height="54" rx="6" class="c-mask" />
          <rect x="339.5" y="72" width="103" height="54" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(345.5 78) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="391" y="94" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">DNS</text>
          <text data-detail="context" x="391" y="111" class="t-muted" font-size="7" text-anchor="middle">cluster endpoint</text>
        </g>

        <g id="aurora-first-failover-timeline-node-old_writer" data-node-id="old_writer" data-node-label="옛 writer" tabindex="0" role="button" aria-label="Focus 옛 writer, Sequence participant" aria-pressed="false" data-node-kind="database" data-node-context="Sequence participant">
          <title>옛 writer · Sequence participant</title>
          <rect x="478.25" y="72" width="103" height="54" rx="6" class="c-mask" />
          <rect x="478.25" y="72" width="103" height="54" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(484.25 78) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" x="529.75" y="94" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">옛 writer</text>
        </g>

        <g id="aurora-first-failover-timeline-node-new_writer" data-node-id="new_writer" data-node-label="새 writer" tabindex="0" role="button" aria-label="Focus 새 writer, Sequence participant" aria-pressed="false" data-node-kind="database" data-node-context="Sequence participant">
          <title>새 writer · Sequence participant</title>
          <rect x="617" y="72" width="103" height="54" rx="6" class="c-mask" />
          <rect x="617" y="72" width="103" height="54" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(623 78) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" x="668.5" y="94" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">새 writer</text>
        </g>

        <!-- Legend -->
        <g data-legend="">
          <text x="40" y="406" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="emphasis" data-legend-x="40" data-legend-baseline="426" data-legend-width="123">
            <path d="M 40 423 L 74 423" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-first-failover-timeline-arrowhead-emphasis)" />
            <text x="83" y="426" class="t-muted" font-size="10" font-weight="500">새 writer로 전환</text>
          </g>
          <g data-legend-semantic-kind="return" data-legend-x="185" data-legend-baseline="426" data-legend-width="98">
            <path d="M 185 423 L 219 423" class="a-default" stroke-width="1.4" stroke-dasharray="3,5" marker-end="url(#aurora-first-failover-timeline-arrowhead)" />
            <text x="228" y="426" class="t-muted" font-size="10" font-weight="500">응답 · 오류</text>
          </g>
          <g data-legend-semantic-kind="dashed" data-legend-x="305" data-legend-baseline="426" data-legend-width="88">
            <path d="M 305 423 L 339 423" class="a-dashed" stroke-width="1.4" marker-end="url(#aurora-first-failover-timeline-arrowhead-dashed)" />
            <text x="348" y="426" class="t-muted" font-size="10" font-weight="500">반복 거부</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="415" data-legend-baseline="426" data-legend-width="98">
            <path d="M 415 423 L 449 423" class="a-default" stroke-width="1.4" marker-end="url(#aurora-first-failover-timeline-arrowhead)" />
            <text x="458" y="426" class="t-muted" font-size="10" font-weight="500">재연결 시도</text>
          </g>
        </g>
      </svg>
</div><figcaption>첫 페일오버: 튜닝 구성은 +5.3초에 새 writer, 기본값 구성은 +10.4초에 옛 writer</figcaption>
</figure>

<p>기본값 구성은 연결이 끊긴 직후 다시 연결을 시도했고 약 10초 뒤 reader가 된 옛 writer에 연결됐습니다. 첫 반복에서 이후의 쓰기는 계속 같은 물리 연결 <code class="language-plaintext highlighter-rouge">98</code> 하나에서 거부됐습니다. 물리 연결은 DB 서버와 맺은 실제 연결 하나이며 <code class="language-plaintext highlighter-rouge">CONNECTION_ID()</code> 값으로 구분합니다. 로그의 핵심 필드를 추리면 다음과 같습니다.<sup id="fnref:dns-path" role="doc-noteref"><a href="#fn:dns-path" class="footnote" rel="footnote">14</a></sup></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server_id = jdbc-test-1
conn_id   = 98
read_only = 1
sql_state = HY000
err_code  = 1836
err_msg   = (conn=98) Running in read-only mode
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">server_id</code>는 응답한 인스턴스, <code class="language-plaintext highlighter-rouge">conn_id</code>는 물리 연결 번호입니다. <code class="language-plaintext highlighter-rouge">read_only = 1</code>과 오류 코드 <code class="language-plaintext highlighter-rouge">1836</code>은 이 노드가 읽기 전용이어서 쓰기를 거부했다는 기록입니다.</p>

<p>일반 드라이버 기본값 구성 세 개(<code class="language-plaintext highlighter-rouge">d01</code>·<code class="language-plaintext highlighter-rouge">d02</code>·<code class="language-plaintext highlighter-rouge">d04</code>)는 30회 모두, JVM DNS를 무기한 캐시한 구성(<code class="language-plaintext highlighter-rouge">v01</code>, 6절)은 10회 모두 옛 writer에 연결된 채 머물렀습니다. 그동안 읽기는 성공하고 쓰기는 거부됐습니다.</p>

<p>반면 DNS·타임아웃을 조정한 일반 드라이버 세 구성에서는 30회 모두 새 writer로 복구됐고 옛 writer에 닿은 쓰기는 없었습니다. 성공 시점은 runner에서 DNS 변경을 관찰한 뒤였습니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 764 484" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="aurora-failover-recovery-paths-archify-diagram-title">Aurora 라이터 페일오버 후 재연결 경로</title>
        <desc id="aurora-failover-recovery-paths-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="aurora-failover-recovery-paths-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="aurora-failover-recovery-paths-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="aurora-failover-recovery-paths-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="aurora-failover-recovery-paths-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="aurora-failover-recovery-paths-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#aurora-failover-recovery-paths-grid)" />

        <!-- Boundaries (behind everything) -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="security-group" data-composition-frame-id="0" data-composition-frame-label="옛 writer에 남는 경로" x="504" y="184" width="220" height="232" rx="8" class="c-security-group" stroke-width="1" />

        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="failover" data-edge-to="reconnect" data-edge-key="0" data-composition-points="228,112;280,112" d="M 228 112 L 280 112" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-failover-recovery-paths-arrowhead-emphasis)" />
        <path data-edge-from="reconnect" data-edge-to="resolve" data-edge-key="1" data-composition-points="468,105;520,105" d="M 468 105 L 520 105" class="a-default" stroke-width="1.5" marker-end="url(#aurora-failover-recovery-paths-arrowhead)" />
        <path data-edge-from="resolve" data-edge-to="wrapper_topology" data-edge-label="토폴로지로 확인" data-edge-key="2" data-composition-points="614,144;614,160;134,160;134,208" d="M 614 144 L 614 152 Q 614 160 606 160 L 142 160 Q 134 160 134 168 L 134 208" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-failover-recovery-paths-arrowhead-emphasis)" />
        <path data-edge-from="resolve" data-edge-to="short_ttl_retry" data-edge-label="TTL 만료 후 재조회" data-edge-key="3" data-composition-points="520,119;494,119;494,240;468,240" d="M 520 119 L 502 119 Q 494 119 494 127 L 494 232 Q 494 240 486 240 L 468 240" class="a-default" stroke-width="1.5" marker-end="url(#aurora-failover-recovery-paths-arrowhead)" />
        <path data-edge-from="resolve" data-edge-to="old_writer_conn" data-edge-label="옛 주소로 재연결 · TTL -1은 계속" data-edge-key="4" data-composition-points="614,144;614,208" d="M 614 144 L 614 208" class="a-dashed" stroke-width="1.5" marker-end="url(#aurora-failover-recovery-paths-arrowhead-dashed)" />
        <path data-edge-from="wrapper_topology" data-edge-to="new_writer_c" data-edge-key="5" data-composition-points="134,272;134,336" d="M 134 272 L 134 336" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-failover-recovery-paths-arrowhead-emphasis)" />
        <path data-edge-from="short_ttl_retry" data-edge-to="new_writer_b" data-edge-key="6" data-composition-points="374,272;374,336" d="M 374 272 L 374 336" class="a-default" stroke-width="1.5" marker-end="url(#aurora-failover-recovery-paths-arrowhead)" />
        <path data-edge-from="old_writer_conn" data-edge-to="stale_recovery" data-edge-label="HikariCP 재사용" data-edge-key="7" data-composition-points="614,272;614,336" d="M 614 272 L 614 336" class="a-dashed" stroke-width="1.5" marker-end="url(#aurora-failover-recovery-paths-arrowhead-dashed)" />

        <!-- Components -->
        <g id="aurora-failover-recovery-paths-node-failover" data-node-id="failover" data-node-label="Aurora writer 페일오버" tabindex="0" role="button" aria-label="Focus Aurora writer 페일오버, 기존 연결 끊김, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="기존 연결 끊김" data-node-context="Architecture component">
          <title>Aurora writer 페일오버 · 기존 연결 끊김 · Architecture component</title>
          <rect x="40" y="80" width="188" height="64" rx="6" class="c-mask" />
          <rect x="40" y="80" width="188" height="64" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(46 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="134" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Aurora writer 페일오버</text>
        <text data-detail="context" x="134" y="126" class="t-muted" font-size="9" text-anchor="middle">기존 연결 끊김</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-reconnect" data-node-id="reconnect" data-node-label="애플리케이션 재연결 시도" tabindex="0" role="button" aria-label="Focus 애플리케이션 재연결 시도, Architecture component" aria-pressed="false" data-node-kind="backend" data-node-context="Architecture component">
          <title>애플리케이션 재연결 시도 · Architecture component</title>
          <rect x="280" y="80" width="188" height="64" rx="6" class="c-mask" />
          <rect x="280" y="80" width="188" height="64" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(286 86) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" x="374" y="116" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">애플리케이션 재연결 시도</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-resolve" data-node-id="resolve" data-node-label="JVM DNS 확인" tabindex="0" role="button" aria-label="Focus JVM DNS 확인, cluster endpoint, Architecture component" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="cluster endpoint" data-node-context="Architecture component">
          <title>JVM DNS 확인 · cluster endpoint · Architecture component</title>
          <rect x="520" y="80" width="188" height="64" rx="6" class="c-mask" />
          <rect x="520" y="80" width="188" height="64" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(526 86) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="614" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">JVM DNS 확인</text>
        <text data-detail="context" x="614" y="126" class="t-muted" font-size="9" text-anchor="middle">cluster endpoint</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-wrapper_topology" data-node-id="wrapper_topology" data-node-label="failover2 조회" tabindex="0" role="button" aria-label="Focus failover2 조회, AWS JDBC Wrapper, Architecture component" aria-pressed="false" data-node-kind="backend" data-node-sublabel="AWS JDBC Wrapper" data-node-context="Architecture component">
          <title>failover2 조회 · AWS JDBC Wrapper · Architecture component</title>
          <rect x="40" y="208" width="188" height="64" rx="6" class="c-mask" />
          <rect x="40" y="208" width="188" height="64" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(46 214) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="134" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">failover2 조회</text>
        <text data-detail="context" x="134" y="254" class="t-muted" font-size="9" text-anchor="middle">AWS JDBC Wrapper</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-short_ttl_retry" data-node-id="short_ttl_retry" data-node-label="짧은 TTL로 재시도(해석)" tabindex="0" role="button" aria-label="Focus 짧은 TTL로 재시도(해석), TTL 5초 + connect/pool timeout, Architecture component" aria-pressed="false" data-node-kind="backend" data-node-sublabel="TTL 5초 + connect/pool timeout" data-node-context="Architecture component">
          <title>짧은 TTL로 재시도(해석) · TTL 5초 + connect/pool timeout · Architecture component</title>
          <rect x="280" y="208" width="188" height="64" rx="6" class="c-mask" />
          <rect x="280" y="208" width="188" height="64" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(286 214) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="374" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">짧은 TTL로 재시도(해석)</text>
        <text data-detail="context" x="374" y="254" class="t-muted" font-size="9" text-anchor="middle">TTL 5초 + connect/pool timeout</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-old_writer_conn" data-node-id="old_writer_conn" data-node-label="옛 writer에 재연결" tabindex="0" role="button" aria-label="Focus 옛 writer에 재연결, 읽기 성공 · INSERT 거부(1836), 옛 writer에 남는 경로" aria-pressed="false" data-node-kind="security" data-node-sublabel="읽기 성공 · INSERT 거부(1836)" data-node-context="옛 writer에 남는 경로">
          <title>옛 writer에 재연결 · 읽기 성공 · INSERT 거부(1836) · 옛 writer에 남는 경로</title>
          <rect x="520" y="208" width="188" height="64" rx="6" class="c-mask" />
          <rect x="520" y="208" width="188" height="64" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(526 214) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="614" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">옛 writer에 재연결</text>
        <text data-detail="context" x="614" y="254" class="t-muted" font-size="9" text-anchor="middle">읽기 성공 · INSERT 거부(1836)</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-new_writer_c" data-node-id="new_writer_c" data-node-label="새 writer 쓰기 성공" tabindex="0" role="button" aria-label="Focus 새 writer 쓰기 성공, 중앙값 1.6~2.0초, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="중앙값 1.6~2.0초" data-node-context="Architecture component">
          <title>새 writer 쓰기 성공 · 중앙값 1.6~2.0초 · Architecture component</title>
          <rect x="40" y="336" width="188" height="60" rx="6" class="c-mask" />
          <rect x="40" y="336" width="188" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(46 342) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="134" y="364" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">새 writer 쓰기 성공</text>
        <text data-detail="context" x="134" y="380" class="t-muted" font-size="9" text-anchor="middle">중앙값 1.6~2.0초</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-new_writer_b" data-node-id="new_writer_b" data-node-label="새 writer 쓰기 성공" tabindex="0" role="button" aria-label="Focus 새 writer 쓰기 성공, 중앙값 약 10.5초, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="중앙값 약 10.5초" data-node-context="Architecture component">
          <title>새 writer 쓰기 성공 · 중앙값 약 10.5초 · Architecture component</title>
          <rect x="280" y="336" width="188" height="60" rx="6" class="c-mask" />
          <rect x="280" y="336" width="188" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(286 342) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="374" y="364" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">새 writer 쓰기 성공</text>
        <text data-detail="context" x="374" y="380" class="t-muted" font-size="9" text-anchor="middle">중앙값 약 10.5초</text>
        </g>

        <g id="aurora-failover-recovery-paths-node-stale_recovery" data-node-id="stale_recovery" data-node-label="연결 교체 뒤 새 writer(추정)" tabindex="0" role="button" aria-label="Focus 연결 교체 뒤 새 writer(추정), 기본값 1,762~1,803초, 옛 writer에 남는 경로" aria-pressed="false" data-node-kind="database" data-node-sublabel="기본값 1,762~1,803초" data-node-context="옛 writer에 남는 경로">
          <title>연결 교체 뒤 새 writer(추정) · 기본값 1,762~1,803초 · 옛 writer에 남는 경로</title>
          <rect x="520" y="336" width="188" height="60" rx="6" class="c-mask" />
          <rect x="520" y="336" width="188" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(526 342) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="614" y="364" class="t-primary" font-size="10.7" font-weight="600" text-anchor="middle">연결 교체 뒤 새 writer(추정)</text>
        <text data-detail="context" x="614" y="380" class="t-muted" font-size="9" text-anchor="middle">기본값 1,762~1,803초</text>
        </g>

        <!-- Connection labels -->


        <g data-detail="context" data-edge-from="resolve" data-edge-to="wrapper_topology" data-edge-label="토폴로지로 확인" data-edge-key="2">
          <rect x="333" y="148" width="82" height="14" rx="3" class="c-mask" />
          <text x="374" y="158" class="t-backend" font-size="8" text-anchor="middle">토폴로지로 확인</text>
        </g>
        <g data-detail="context" data-edge-from="resolve" data-edge-to="short_ttl_retry" data-edge-label="TTL 만료 후 재조회" data-edge-key="3">
          <rect x="375.8" y="167.5" width="96.39999999999999" height="14" rx="3" class="c-mask" />
          <text x="424" y="177.5" class="t-muted" font-size="8" text-anchor="middle">TTL 만료 후 재조회</text>
        </g>
        <g data-detail="context" data-edge-from="resolve" data-edge-to="old_writer_conn" data-edge-label="옛 주소로 재연결 · TTL -1은 계속" data-edge-key="4">
          <rect x="532.2" y="168" width="163.6" height="14" rx="3" class="c-mask" />
          <text x="614" y="178" class="t-messagebus" font-size="8" text-anchor="middle">옛 주소로 재연결 · TTL -1은 계속</text>
        </g>


        <g data-detail="context" data-edge-from="old_writer_conn" data-edge-to="stale_recovery" data-edge-label="HikariCP 재사용" data-edge-key="7">
          <rect x="573" y="276" width="82" height="14" rx="3" class="c-mask" />
          <text x="614" y="286" class="t-messagebus" font-size="8" text-anchor="middle">HikariCP 재사용</text>
        </g>

        <!-- Boundary labels (foreground masks keep routes out of titles) -->
        <g data-graph-role="structural-frame-label" data-composition-frame-id="0" data-composition-frame-kind="security-group" data-composition-frame-label="옛 writer에 남는 경로">
          <rect data-graph-role="structural-frame-label-mask" x="508" y="188" width="123.39999999999999" height="16" rx="3" class="c-mask" />
          <text data-boundary-label="" x="512" y="201" class="t-security" font-size="9" font-weight="600">옛 writer에 남는 경로</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="448" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="애플리케이션 · 드라이버 로직" data-legend-x="40" data-legend-baseline="468" data-legend-width="182">
            <rect x="40" y="459" width="16" height="10" rx="2.5" class="c-backend" stroke-width="1" />
            <text x="62" y="468" class="t-muted" font-size="10" font-weight="500">애플리케이션 · 드라이버 로직</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="Aurora 클러스터 노드" data-legend-x="244" data-legend-baseline="468" data-legend-width="143">
            <rect x="244" y="459" width="16" height="10" rx="2.5" class="c-database" stroke-width="1" />
            <text x="266" y="468" class="t-muted" font-size="10" font-weight="500">Aurora 클러스터 노드</text>
          </g>
          <g data-legend-semantic-kind="cloud" data-legend-kind="cloud" data-legend-label="DNS 확인" data-legend-x="409" data-legend-baseline="468" data-legend-width="83">
            <rect x="409" y="459" width="16" height="10" rx="2.5" class="c-cloud" stroke-width="1" />
            <text x="431" y="468" class="t-muted" font-size="10" font-weight="500">DNS 확인</text>
          </g>
          <g data-legend-semantic-kind="security" data-legend-kind="security" data-legend-label="오류 · 거부 상태" data-legend-x="514" data-legend-baseline="468" data-legend-width="123">
            <rect x="514" y="459" width="16" height="10" rx="2.5" class="c-security" stroke-width="1" />
            <text x="536" y="468" class="t-muted" font-size="10" font-weight="500">오류 · 거부 상태</text>
          </g>
        </g>
      </svg>
</div><figcaption>끊긴 연결이 새 writer에 닿는 세 경로 — 옛 writer에 남으면 쓰기만 계속 실패합니다</figcaption>
</figure>

<p>재시도하는 동안 짧은 DNS TTL이 지나 새 주소를 얻었다고 해석할 수는 있습니다. 하지만 DNS TTL과 두 연결 타임아웃을 하나씩 떼어 본 대조 실험은 없습니다. <strong>어느 값이 결정적이었는지는 이 결과만으로는 가릴 수 없습니다.</strong> runner의 DNS 조회도 약 1초 간격의 표본이며 각 JVM이 실제로 사용한 DNS 캐시를 직접 읽은 기록은 아닙니다.</p>

<h2 id="6-커넥션-풀은-왜-읽기-전용으로-계속-연결할까요">6. 커넥션 풀은 왜 읽기 전용으로 계속 연결할까요</h2>

<p>커넥션 풀이 보기에는 read-only가 된 옛 writer와의 연결에 아무 문제가 없기 때문입니다. 실패한 것은 쓰기 쿼리이고 연결 자체에는 오류가 없습니다.</p>

<p>응답하는 옛 writer와의 연결은 커넥션 풀의 기본 연결 검증(연결이 살아 있는지 확인하는 검사)을 통과할 수 있습니다. HikariCP 7.1.0은 이 검증에 JDBC의 <code class="language-plaintext highlighter-rouge">Connection.isValid()</code>를 사용합니다. 이 검사를 통과했다고 해서 현재 노드가 writer라는 보장은 없습니다.<sup id="fnref:hikari-validation" role="doc-noteref"><a href="#fn:hikari-validation" class="footnote" rel="footnote">15</a></sup></p>

<p>HikariCP는 SQL 예외가 났다고 해서 매번 그 연결을 폐기하지는 않습니다. 기본 설정에서는 SQLState와 일부 오류 코드로 연결이 고장 났는지 판정합니다. SQLState가 <code class="language-plaintext highlighter-rouge">08</code>로 시작하는 연결 오류이거나 미리 정해 둔 SQLState·오류 코드 목록에 해당하면 그 연결을 폐기합니다. 이번에 반복된 <code class="language-plaintext highlighter-rouge">HY000</code>·오류 코드 <code class="language-plaintext highlighter-rouge">1836</code> 조합은 이 기본 폐기 조건에 해당하지 않습니다.<sup id="fnref:hikari-eviction" role="doc-noteref"><a href="#fn:hikari-eviction" class="footnote" rel="footnote">16</a></sup></p>

<p>이번 기록에서는 다음 과정이 되풀이됐습니다.</p>

<ol>
  <li>애플리케이션이 풀에서 연결을 빌립니다.</li>
  <li>연결된 옛 writer가 노드 조회에 응답합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">INSERT</code>는 read-only 오류로 실패합니다.</li>
  <li>연결은 풀로 반환되고 다시 사용됩니다.</li>
</ol>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 760 386" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="hikari-connection-reuse-archify-diagram-title">기본값 애플리케이션의 풀 연결 재사용 루프</title>
        <desc id="hikari-connection-reuse-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="hikari-connection-reuse-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="hikari-connection-reuse-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="hikari-connection-reuse-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="hikari-connection-reuse-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="hikari-connection-reuse-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#hikari-connection-reuse-grid)" />

        <!-- Boundaries (behind everything) -->


        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="pool_wait" data-edge-to="in_use" data-edge-label="애플리케이션이 빌림" data-edge-key="0" data-composition-points="240,105;520,105" d="M 240 105 L 520 105" class="a-default" stroke-width="1.5" marker-end="url(#hikari-connection-reuse-arrowhead)" />
        <path data-edge-from="in_use" data-edge-to="error_return" data-edge-label="INSERT read-only 오류(1836)" data-edge-key="1" data-composition-points="620,144;620,254" d="M 620 144 L 620 254" class="a-security" stroke-width="1.5" marker-end="url(#hikari-connection-reuse-arrowhead-security)" />
        <path data-edge-from="error_return" data-edge-to="pool_wait" data-edge-label="폐기 대상 아님" data-edge-key="2" data-composition-points="520,286;380,286;380,119;240,119" d="M 520 286 L 388 286 Q 380 286 380 278 L 380 127 Q 380 119 372 119 L 240 119" class="a-dashed" stroke-width="1.5" marker-end="url(#hikari-connection-reuse-arrowhead-dashed)" />
        <path data-edge-from="pool_wait" data-edge-to="conn_closed" data-edge-label="maxLifetime 도달(기본 30분)" data-edge-key="3" data-composition-points="140,144;140,254" d="M 140 144 L 140 254" class="a-default" stroke-width="1.5" marker-end="url(#hikari-connection-reuse-arrowhead)" />

        <!-- Components -->
        <g id="hikari-connection-reuse-node-pool_wait" data-node-id="pool_wait" data-node-label="풀에서 대기" tabindex="0" role="button" aria-label="Focus 풀에서 대기, 커넥션 풀, Architecture component" aria-pressed="false" data-node-kind="backend" data-node-sublabel="커넥션 풀" data-node-context="Architecture component">
          <title>풀에서 대기 · 커넥션 풀 · Architecture component</title>
          <rect x="40" y="80" width="200" height="64" rx="6" class="c-mask" />
          <rect x="40" y="80" width="200" height="64" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(46 86) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="140" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">풀에서 대기</text>
        <text data-detail="context" x="140" y="126" class="t-muted" font-size="9" text-anchor="middle">커넥션 풀</text>
        </g>

        <g id="hikari-connection-reuse-node-in_use" data-node-id="in_use" data-node-label="사용 중" tabindex="0" role="button" aria-label="Focus 사용 중, 애플리케이션이 빌림, Architecture component" aria-pressed="false" data-node-kind="backend" data-node-sublabel="애플리케이션이 빌림" data-node-context="Architecture component">
          <title>사용 중 · 애플리케이션이 빌림 · Architecture component</title>
          <rect x="520" y="80" width="200" height="64" rx="6" class="c-mask" />
          <rect x="520" y="80" width="200" height="64" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(526 86) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="620" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">사용 중</text>
        <text data-detail="context" x="620" y="126" class="t-muted" font-size="9" text-anchor="middle">애플리케이션이 빌림</text>
        </g>

        <g id="hikari-connection-reuse-node-error_return" data-node-id="error_return" data-node-label="오류 반환" tabindex="0" role="button" aria-label="Focus 오류 반환, 애플리케이션에 오류 전달, Architecture component" aria-pressed="false" data-node-kind="security" data-node-sublabel="애플리케이션에 오류 전달" data-node-context="Architecture component">
          <title>오류 반환 · 애플리케이션에 오류 전달 · Architecture component</title>
          <rect x="520" y="254" width="200" height="64" rx="6" class="c-mask" />
          <rect x="520" y="254" width="200" height="64" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(526 260) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="620" y="284" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">오류 반환</text>
        <text data-detail="context" x="620" y="300" class="t-muted" font-size="9" text-anchor="middle">애플리케이션에 오류 전달</text>
        </g>

        <g id="hikari-connection-reuse-node-conn_closed" data-node-id="conn_closed" data-node-label="연결 닫힘" tabindex="0" role="button" aria-label="Focus 연결 닫힘, 새 연결 주소는 JVM DNS 캐시로 결정, Architecture component" aria-pressed="false" data-node-kind="backend" data-node-sublabel="새 연결 주소는 JVM DNS 캐시로 결정" data-node-context="Architecture component">
          <title>연결 닫힘 · 새 연결 주소는 JVM DNS 캐시로 결정 · Architecture component</title>
          <rect x="40" y="254" width="200" height="64" rx="6" class="c-mask" />
          <rect x="40" y="254" width="200" height="64" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(46 260) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="140" y="284" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">연결 닫힘</text>
        <text data-detail="context" x="140" y="300" class="t-muted" font-size="9" text-anchor="middle">새 연결 주소는 JVM DNS 캐시로 결정</text>
        </g>

        <!-- Connection labels -->
        <g data-detail="context" data-edge-from="pool_wait" data-edge-to="in_use" data-edge-label="애플리케이션이 빌림" data-edge-key="0">
          <rect x="329.4" y="85" width="101.2" height="14" rx="3" class="c-mask" />
          <text x="380" y="95" class="t-muted" font-size="8" text-anchor="middle">애플리케이션이 빌림</text>
        </g>
        <g data-detail="context" data-edge-from="in_use" data-edge-to="error_return" data-edge-label="INSERT read-only 오류(1836)" data-edge-key="1">
          <rect x="550.2" y="148" width="139.6" height="14" rx="3" class="c-mask" />
          <text x="620" y="158" class="t-security" font-size="8" text-anchor="middle">INSERT read-only 오류(1836)</text>
        </g>
        <g data-detail="context" data-edge-from="error_return" data-edge-to="pool_wait" data-edge-label="폐기 대상 아님" data-edge-key="2">
          <rect x="341.4" y="182.5" width="77.2" height="14" rx="3" class="c-mask" />
          <text x="380" y="192.5" class="t-messagebus" font-size="8" text-anchor="middle">폐기 대상 아님</text>
        </g>
        <g data-detail="context" data-edge-from="pool_wait" data-edge-to="conn_closed" data-edge-label="maxLifetime 도달(기본 30분)" data-edge-key="3">
          <rect x="70.2" y="148" width="139.6" height="14" rx="3" class="c-mask" />
          <text x="140" y="158" class="t-muted" font-size="8" text-anchor="middle">maxLifetime 도달(기본 30분)</text>
        </g>

        <!-- Boundary labels (foreground masks keep routes out of titles) -->


        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="350" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="커넥션 풀 상태" data-legend-x="40" data-legend-baseline="370" data-legend-width="113">
            <rect x="40" y="361" width="16" height="10" rx="2.5" class="c-backend" stroke-width="1" />
            <text x="62" y="370" class="t-muted" font-size="10" font-weight="500">커넥션 풀 상태</text>
          </g>
          <g data-legend-semantic-kind="security" data-legend-kind="security" data-legend-label="오류 상태" data-legend-x="175" data-legend-baseline="370" data-legend-width="88">
            <rect x="175" y="361" width="16" height="10" rx="2.5" class="c-security" stroke-width="1" />
            <text x="197" y="370" class="t-muted" font-size="10" font-weight="500">오류 상태</text>
          </g>
        </g>
      </svg>
</div><figcaption>read-only 오류가 나도 연결은 풀로 돌아가, 수명이 끝날 때까지 다시 쓰일 수 있습니다</figcaption>
</figure>

<p>기본값 구성이 쓰기를 복구하기까지 걸린 약 30분은 HikariCP의 기본 <code class="language-plaintext highlighter-rouge">maxLifetime=1800000</code>(30분)과 비슷합니다. 연결 수명이 끝나 새 연결로 교체되면서 복구됐을 가능성이 있습니다. 다만 수명 만료로 연결을 닫았다는 로그는 이번 기록에 없으므로 이 설명은 <strong>추정</strong>입니다.</p>

<p><code class="language-plaintext highlighter-rouge">maxLifetime</code>을 복구 시간의 상한으로 볼 근거는 없습니다. 이 값은 연결의 최대 수명을 정할 뿐이고 애플리케이션이 빌려 쓰고 있는 연결은 수명이 다 돼도 풀이 바로 끊지 않습니다.<sup id="fnref:hikari-lifetime" role="doc-noteref"><a href="#fn:hikari-lifetime" class="footnote" rel="footnote">17</a></sup></p>

<h3 id="dns-캐시를-고정하면-연결이-새로-갱신되어도-쓰기에-연결되지-않습니다">DNS 캐시를 고정하면 연결이 새로 갱신되어도 쓰기에 연결되지 않습니다.</h3>

<p>연결을 새로 만들어도 새 연결이 옛 주소로 향하면 소용이 없습니다. 이를 확인하려고 일반 MariaDB 2.7 튜닝 구성에서 <strong>JVM DNS TTL만 <code class="language-plaintext highlighter-rouge">-1</code>, 즉 무기한 캐시로 바꾼 구성</strong>(<code class="language-plaintext highlighter-rouge">v01</code>)도 실행했습니다. <code class="language-plaintext highlighter-rouge">maxLifetime</code>은 50초로 유지했습니다.</p>

<p>이 구성은 10회 모두 관찰 종료까지 쓰기가 복구되지 않았습니다. 장기 관찰 두 번에서 쓰기 공백은 각각 2,088.0초·2,088.2초 이상이었습니다. 그동안 응답한 물리 연결은 각각 164개·153개였지만 새로 만든 연결에서도 응답한 노드는 모두 같은 옛 writer였습니다.<sup id="fnref:dns-infinite" role="doc-noteref"><a href="#fn:dns-infinite" class="footnote" rel="footnote">18</a></sup></p>

<p>JVM이 DNS 조회 결과를 캐시하는 시간은 Java 보안 속성 <code class="language-plaintext highlighter-rouge">networkaddress.cache.ttl</code>로 정합니다. AWS의 JVM DNS 가이드는 이 값을 5초로 설정하는 방법을 안내합니다. 이 실험에서는 JVM이 처음 DNS를 조회하기 전에 <code class="language-plaintext highlighter-rouge">Security.setProperty()</code>로 이 속성을 설정했습니다. 설정하지 않았을 때의 기본 TTL 값은 이번 기록에 없습니다.<sup id="fnref:dns-ttl:1" role="doc-noteref"><a href="#fn:dns-ttl" class="footnote" rel="footnote">6</a></sup></p>

<h3 id="maxlifetime만으로-복구-시간을-계산할-수는-없었습니다"><code class="language-plaintext highlighter-rouge">maxLifetime</code>만으로 복구 시간을 계산할 수는 없었습니다</h3>

<p>일반 MariaDB 2.7 튜닝 구성에서 다른 값은 그대로 두고 <code class="language-plaintext highlighter-rouge">maxLifetime</code>만 바꾼 결과입니다. 각 구성은 10회씩 측정했습니다.</p>

<table>
  <thead>
    <tr>
      <th>설정값</th>
      <th>풀의 런타임 값</th>
      <th>쓰기 공백 중앙값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>35초</td>
      <td>35초</td>
      <td>10.5초</td>
    </tr>
    <tr>
      <td>50초</td>
      <td>50초</td>
      <td>10.5초</td>
    </tr>
    <tr>
      <td>70초</td>
      <td>70초</td>
      <td>10.5초</td>
    </tr>
    <tr>
      <td>20초</td>
      <td><strong>1,800초로 변경</strong></td>
      <td>10.5초</td>
    </tr>
  </tbody>
</table>

<p>수명 값을 바꿔도 결과는 거의 같았습니다. 이 구성들은 옛 writer에 연결되지 않고 연결 수명보다 짧은 시간 안에 새 writer로 복구됐습니다. “쓰기 공백은 항상 연결 수명에 몇 초를 더한 값”이라는 공식을 만들 근거는 없습니다. 옛 writer에 남은 구성과 새 writer를 찾은 구성은 복구 경로부터 달랐습니다.</p>

<p>20초 설정이 30분으로 바뀐 것은 HikariCP 7.1.0의 설정값 검사 규칙 때문입니다. 이 버전은 0이 아닌 <code class="language-plaintext highlighter-rouge">maxLifetime</code>이 30초보다 작으면 기본값으로 되돌립니다. 10회 모두 기동 로그에 아래 메시지가 남았고 풀의 런타임 값도 1,800초였습니다.<sup id="fnref:hikari-config:1" role="doc-noteref"><a href="#fn:hikari-config" class="footnote" rel="footnote">5</a></sup></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>maxLifetime is less than 30000ms, setting to default 1800000ms.
</code></pre></div></div>

<p>설정 파일에 적힌 값뿐 아니라 <strong>실제로 생성된 풀의 값</strong>을 확인해야 하는 이유입니다.</p>

<h2 id="7-wrapper는-새-writer를-어떻게-찾을까요">7. Wrapper는 새 writer를 어떻게 찾을까요</h2>

<p>Wrapper의 <code class="language-plaintext highlighter-rouge">failover2</code> 플러그인은 cluster endpoint의 DNS 변경만 기다리지 않습니다. 별도 토폴로지 모니터링 구성 요소로 클러스터 구성을 확인해 새 writer를 찾고 그 writer로 연결을 다시 맺습니다.<sup id="fnref:failover2:1" role="doc-noteref"><a href="#fn:failover2" class="footnote" rel="footnote">7</a></sup></p>

<p>이번 S1 실험에서 기반 드라이버만 다른 두 Wrapper 구성은 중앙값이 기본값에서 2.0초·1.8초, 튜닝에서 1.8초·1.6초로, 두 경우 모두 0.2초 차이였습니다.</p>

<p>MariaDB Connector/J 2.7의 <code class="language-plaintext highlighter-rouge">aurora</code> 스킴도 비교했습니다. 하지만 그 결과를 모든 MariaDB 드라이버에 적용할 수는 없습니다. 3.0.3 릴리스 노트에 Aurora 전용 지원을 제거했다고 적혀 있기 때문입니다. URL을 바꾸기 전에 실제 드라이버와 버전을 확인해야 합니다.<sup id="fnref:mariadb:1" role="doc-noteref"><a href="#fn:mariadb" class="footnote" rel="footnote">9</a></sup></p>

<p>Wrapper도 URL만 바꾼다고 기존 애플리케이션의 장애 처리가 모두 끝나지는 않습니다. 두 가지가 더 필요합니다.</p>

<ul>
  <li>Wrapper 아래에서 실제로 DB와 통신할 기반 JDBC 드라이버가 필요합니다.</li>
  <li>재연결 뒤 애플리케이션에 전달되는 예외를 처리해야 합니다.</li>
</ul>

<p>이번 Wrapper 구성에서 주로 나온 예외는 <code class="language-plaintext highlighter-rouge">FailoverSuccessSQLException</code>이었습니다. 연결이 다른 노드로 전환됐음을 알리는 예외이며 방금 실행하던 업무의 성공 여부와는 별개입니다.<sup id="fnref:failover-exceptions" role="doc-noteref"><a href="#fn:failover-exceptions" class="footnote" rel="footnote">19</a></sup></p>

<p>Wrapper를 HikariCP와 함께 쓰려고 튜닝 구성에는 AWS 예제의 다음 값을 적용했습니다.<sup id="fnref:wrapper-hikari" role="doc-noteref"><a href="#fn:wrapper-hikari" class="footnote" rel="footnote">20</a></sup></p>

<div class="language-properties highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># HikariCP 속성: Wrapper의 페일오버 예외를 처리하는 구현체입니다.
</span><span class="py">exceptionOverrideClassName</span><span class="p">=</span><span class="s">software.amazon.jdbc.util.HikariCPSQLException</span>
</code></pre></div></div>

<p>업무 재시도는 따로 설계해야 합니다. 트랜잭션 도중 연결이 끊기면 그 처리가 성공했는지 알 수 없는 경우가 생길 수 있습니다. 이때 연결이 복구됐다는 이유만으로 쓰기를 무조건 다시 실행할 수는 없습니다. 중복 실행을 막는 식별자와 처리 결과 확인이 필요합니다. 이 측정의 쓰기는 짧은 자동 커밋이며 업무 트랜잭션의 재시도는 측정 범위에 없습니다.</p>

<h2 id="8-에러가-0건인데도-쓰기가-멈출-수-있을까요">8. 에러가 0건인데도 쓰기가 멈출 수 있을까요</h2>

<p>멈출 수 있습니다. 예외가 발생하지 않은 채 한 연산이 오래 걸리면 에러 건수에는 아무것도 나타나지 않습니다.</p>

<p><code class="language-plaintext highlighter-rouge">t02</code>는 MariaDB 2.7의 <code class="language-plaintext highlighter-rouge">aurora</code> 스킴에 cluster endpoint와 <code class="language-plaintext highlighter-rouge">cluster-ro</code>를 함께 넣은 튜닝 구성입니다. 이 구성은 10회 중 8회에서 쓰기·읽기 에러가 모두 0건이었습니다.</p>

<p>그런데 이 8회에서 DB에 기록된 연속 행 사이의 최대 시각 간격은 <strong>중앙값 8.4초, 범위 3.3~14.2초</strong>였습니다. 200ms마다 쓰도록 만든 루프인데 모든 반복에서 1초가 넘는 공백이 생겼습니다.<sup id="fnref:silent-stall" role="doc-noteref"><a href="#fn:silent-stall" class="footnote" rel="footnote">21</a></sup></p>

<table>
  <thead>
    <tr>
      <th>에러가 없었던 8회에서 측정한 항목</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>쓰기·읽기 예외</td>
      <td>각 반복 0건</td>
    </tr>
    <tr>
      <td>DB의 연속 기록 시각 간격</td>
      <td>최대 14.2초</td>
    </tr>
    <tr>
      <td>클라이언트에서 성공한 연산 한 건의 소요 시간</td>
      <td>최대 약 14.0초</td>
    </tr>
  </tbody>
</table>

<p>예외 없이 연산 하나가 최대 약 14초 걸리는 동안, 다음 쓰기 시도도 진행되지 않았습니다. 예외가 나지 않았으므로 예외 카운터에는 이 대기 시간이 나타나지 않았습니다.</p>

<p>이 실험에서는 예외 건수와 함께 연산 지연, 성공 쓰기의 간격, DB에 저장된 행의 시각을 확인했습니다. DB 행의 시각은 클라이언트 완료 시각과 함께 봐야 기록 사이의 공백과 응답 지연을 구분할 수 있습니다.</p>

<p>반대로 Wrapper 구성에서는 페일오버 예외가 났어도 더 짧은 시간 안에 다음 쓰기가 성공했습니다. <strong>에러 건수만 비교하면, 에러가 0건인 반복에서 쓰기가 최대 약 14초 멈춘 구성이 에러를 내고 더 빨리 복구한 구성보다 나아 보입니다.</strong></p>

<h2 id="9-bluegreen-전환-뒤에는-어느-클러스터에-썼을까요">9. Blue/Green 전환 뒤에는 어느 클러스터에 썼을까요</h2>

<p>Blue/Green 전환은 같은 클러스터 안에서 writer를 바꾸는 페일오버와 다릅니다. 서비스 대상이 아예 다른 클러스터로 옮겨 가고 전환 뒤에도 옛 blue 환경이 남습니다.<sup id="fnref:blue-green:1" role="doc-noteref"><a href="#fn:blue-green" class="footnote" rel="footnote">8</a></sup> 옛 blue에 남은 애플리케이션이 어떻게 동작하는지가 중요합니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 760 444" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="aurora-bluegreen-old-blue-archify-diagram-title">Blue/Green 전환 후 옛 blue에 남은 연결</title>
        <desc id="aurora-bluegreen-old-blue-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="aurora-bluegreen-old-blue-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="aurora-bluegreen-old-blue-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="aurora-bluegreen-old-blue-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="aurora-bluegreen-old-blue-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="aurora-bluegreen-old-blue-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#aurora-bluegreen-old-blue-grid)" />

        <!-- Boundaries (behind everything) -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="region" data-composition-frame-id="0" data-composition-frame-label="green 클러스터" x="530" y="56" width="186" height="216" rx="12" class="c-region" stroke-width="1" />

        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="app_switched" data-edge-to="dns" data-edge-label="엔드포인트 조회" data-edge-key="0" data-composition-points="240,112;294,112" d="M 240 112 L 294 112" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-bluegreen-old-blue-arrowhead-emphasis)" />
        <path data-edge-from="dns" data-edge-to="green_writer" data-edge-label="이름 승계 · 쓰기 성공" data-edge-key="1" data-composition-points="500,112;548,112" d="M 500 112 L 548 112" class="a-emphasis" stroke-width="1.8" marker-end="url(#aurora-bluegreen-old-blue-arrowhead-emphasis)" />
        <path data-edge-from="app_stale" data-edge-to="blue_writer" data-edge-label="probe: 읽기 성공 · 쓰기 실패" data-edge-key="2" data-composition-points="150,376;150,384;634,384;634,368" d="M 150 376 L 150 380 Q 150 384 154 384 L 626 384 Q 634 384 634 376 L 634 368" class="a-dashed" stroke-width="1.5" marker-end="url(#aurora-bluegreen-old-blue-arrowhead-dashed)" />
        <path data-edge-from="app_stale" data-edge-to="blue_writer" data-edge-label="admin: 쓰기 성공(구성당 2,672~2,673행) · green에 없음" data-edge-key="3" data-composition-points="150,376;150,400;634,400;634,368" d="M 150 376 L 150 392 Q 150 400 158 400 L 626 400 Q 634 400 634 392 L 634 368" class="a-security" stroke-width="1.5" marker-end="url(#aurora-bluegreen-old-blue-arrowhead-security)" />

        <!-- Components -->
        <g id="aurora-bluegreen-old-blue-node-app_switched" data-node-id="app_switched" data-node-label="전환을 따라간 애플리케이션" tabindex="0" role="button" aria-label="Focus 전환을 따라간 애플리케이션, 새 엔드포인트로 재연결, Architecture component" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="새 엔드포인트로 재연결" data-node-context="Architecture component">
          <title>전환을 따라간 애플리케이션 · 새 엔드포인트로 재연결 · Architecture component</title>
          <rect x="40" y="80" width="200" height="64" rx="6" class="c-mask" />
          <rect x="40" y="80" width="200" height="64" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(46 86) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="140" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">전환을 따라간 애플리케이션</text>
        <text data-detail="context" x="140" y="126" class="t-muted" font-size="9" text-anchor="middle">새 엔드포인트로 재연결</text>
        </g>

        <g id="aurora-bluegreen-old-blue-node-dns" data-node-id="dns" data-node-label="DNS / 클러스터 엔드포인트" tabindex="0" role="button" aria-label="Focus DNS / 클러스터 엔드포인트, green이 원래 이름 승계, Architecture component" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="green이 원래 이름 승계" data-node-context="Architecture component">
          <title>DNS / 클러스터 엔드포인트 · green이 원래 이름 승계 · Architecture component</title>
          <rect x="294" y="80" width="206" height="64" rx="6" class="c-mask" />
          <rect x="294" y="80" width="206" height="64" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(300 86) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="397" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">DNS / 클러스터 엔드포인트</text>
        <text data-detail="context" x="397" y="126" class="t-muted" font-size="9" text-anchor="middle">green이 원래 이름 승계</text>
        </g>

        <g id="aurora-bluegreen-old-blue-node-green_writer" data-node-id="green_writer" data-node-label="Green writer" tabindex="0" role="button" aria-label="Focus Green writer, 전환 후 서비스 대상, green 클러스터" aria-pressed="false" data-node-kind="database" data-node-sublabel="전환 후 서비스 대상" data-node-context="green 클러스터">
          <title>Green writer · 전환 후 서비스 대상 · green 클러스터</title>
          <rect x="548" y="80" width="150" height="64" rx="6" class="c-mask" />
          <rect x="548" y="80" width="150" height="64" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(554 86) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="623" y="110" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Green writer</text>
        <text data-detail="context" x="623" y="126" class="t-muted" font-size="9" text-anchor="middle">전환 후 서비스 대상</text>
        </g>

        <g id="aurora-bluegreen-old-blue-node-green_reader" data-node-id="green_reader" data-node-label="Green reader" tabindex="0" role="button" aria-label="Focus Green reader, green 클러스터" aria-pressed="false" data-node-kind="database" data-node-context="green 클러스터">
          <title>Green reader · green 클러스터</title>
          <rect x="548" y="192" width="140" height="60" rx="6" class="c-mask" />
          <rect x="548" y="192" width="140" height="60" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(554 198) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" x="618" y="226" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Green reader</text>
        </g>

        <g id="aurora-bluegreen-old-blue-node-app_stale" data-node-id="app_stale" data-node-label="옛 blue에 남은 애플리케이션" tabindex="0" role="button" aria-label="Focus 옛 blue에 남은 애플리케이션, 캐시된 주소·기존 연결이 옛 blue를 향함, Architecture component" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="캐시된 주소·기존 연결이 옛 blue를 향함" data-node-context="Architecture component">
          <title>옛 blue에 남은 애플리케이션 · 캐시된 주소·기존 연결이 옛 blue를 향함 · Architecture component</title>
          <rect x="40" y="304" width="220" height="72" rx="6" class="c-mask" />
          <rect x="40" y="304" width="220" height="72" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(46 310) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="150" y="338" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">옛 blue에 남은 애플리케이션</text>
        <text data-detail="context" x="150" y="354" class="t-muted" font-size="9" text-anchor="middle">캐시된 주소·기존 연결이 옛 blue를 향함</text>
        </g>

        <g id="aurora-bluegreen-old-blue-node-blue_writer" data-node-id="blue_writer" data-node-label="옛 blue writer" tabindex="0" role="button" aria-label="Focus 옛 blue writer, -old1, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="-old1" data-node-tag="read_only=1" data-node-context="Architecture component">
          <title>옛 blue writer · -old1 · Architecture component · read_only=1</title>
          <rect x="548" y="304" width="172" height="64" rx="6" class="c-mask" />
          <rect x="548" y="304" width="172" height="64" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(554 310) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="634" y="334" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">옛 blue writer</text>
        <text data-detail="context" x="634" y="350" class="t-muted" font-size="9" text-anchor="middle">-old1</text>
        <text data-detail="fine" x="634" y="360" class="t-database" font-size="7" text-anchor="middle">read_only=1</text>
        </g>

        <!-- Connection labels -->
        <g data-detail="context" data-edge-from="app_switched" data-edge-to="dns" data-edge-label="엔드포인트 조회" data-edge-key="0">
          <rect x="226" y="148" width="82" height="14" rx="3" class="c-mask" />
          <text x="267" y="158" class="t-backend" font-size="8" text-anchor="middle">엔드포인트 조회</text>
        </g>
        <g data-detail="context" data-edge-from="dns" data-edge-to="green_writer" data-edge-label="이름 승계 · 쓰기 성공" data-edge-key="1">
          <rect x="468.6" y="148" width="110.8" height="14" rx="3" class="c-mask" />
          <text x="524" y="158" class="t-backend" font-size="8" text-anchor="middle">이름 승계 · 쓰기 성공</text>
        </g>
        <g data-detail="context" data-edge-from="app_stale" data-edge-to="blue_writer" data-edge-label="probe: 읽기 성공 · 쓰기 실패" data-edge-key="2">
          <rect x="319.8" y="368" width="144.4" height="14" rx="3" class="c-mask" />
          <text x="392" y="378" class="t-messagebus" font-size="8" text-anchor="middle">probe: 읽기 성공 · 쓰기 실패</text>
        </g>
        <g data-detail="context" data-edge-from="app_stale" data-edge-to="blue_writer" data-edge-label="admin: 쓰기 성공(구성당 2,672~2,673행) · green에 없음" data-edge-key="3">
          <rect x="259.8" y="396" width="264.4" height="14" rx="3" class="c-mask" />
          <text x="392" y="406" class="t-security" font-size="8" text-anchor="middle">admin: 쓰기 성공(구성당 2,672~2,673행) · green에 없음</text>
        </g>

        <!-- Boundary labels (foreground masks keep routes out of titles) -->
        <g data-graph-role="structural-frame-label" data-composition-frame-id="0" data-composition-frame-kind="region" data-composition-frame-label="green 클러스터">
          <rect data-graph-role="structural-frame-label-mask" x="534" y="60" width="85.6" height="16" rx="3" class="c-mask" />
          <text data-boundary-label="" x="538" y="73" class="t-cloud" font-size="9" font-weight="600">green 클러스터</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="408" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="frontend" data-legend-kind="frontend" data-legend-label="애플리케이션" data-legend-x="40" data-legend-baseline="428" data-legend-width="103">
            <rect x="40" y="419" width="16" height="10" rx="2.5" class="c-frontend" stroke-width="1" />
            <text x="62" y="428" class="t-muted" font-size="10" font-weight="500">애플리케이션</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="DB 클러스터" data-legend-x="165" data-legend-baseline="428" data-legend-width="98">
            <rect x="165" y="419" width="16" height="10" rx="2.5" class="c-database" stroke-width="1" />
            <text x="187" y="428" class="t-muted" font-size="10" font-weight="500">DB 클러스터</text>
          </g>
          <g data-legend-semantic-kind="cloud" data-legend-kind="cloud" data-legend-label="DNS · 엔드포인트" data-legend-x="285" data-legend-baseline="428" data-legend-width="123">
            <rect x="285" y="419" width="16" height="10" rx="2.5" class="c-cloud" stroke-width="1" />
            <text x="307" y="428" class="t-muted" font-size="10" font-weight="500">DNS · 엔드포인트</text>
          </g>
        </g>
      </svg>
</div><figcaption>전환 뒤 옛 blue에 남은 애플리케이션은 읽기를 계속하고, 쓰기는 권한에 따라 갈립니다</figcaption>
</figure>

<p>이 실험에서는 비특권 사용자 <code class="language-plaintext highlighter-rouge">probe</code>와 마스터 사용자 <code class="language-plaintext highlighter-rouge">admin</code>으로 Blue/Green 전환을 한 번씩 관찰했습니다. 두 전환은 서로 다른 배포에서 진행했으므로 사용자 권한만 바꾼 대조 실험으로 보지는 않습니다.</p>

<h3 id="probe-실행-옛-blue에서-읽기만-계속-성공했습니다"><code class="language-plaintext highlighter-rouge">probe</code> 실행: 옛 blue에서 읽기만 계속 성공했습니다</h3>

<p><code class="language-plaintext highlighter-rouge">probe</code> 실행에서는 기본값 구성 6개와 JVM DNS 무기한 캐시 구성(<code class="language-plaintext highlighter-rouge">v01</code>) 1개가 옛 blue에 남았습니다. 이 7개 구성은 600초 관찰 종료까지 쓰기가 복구되지 않았습니다. 읽기는 전환 완료 이후에도 구성마다 약 2,668건이 옛 blue에서 성공했습니다. 이 6개 기본값 구성에는 <strong>Wrapper를 사용하되 <code class="language-plaintext highlighter-rouge">bg</code>(Blue/Green 전환을 처리하는 플러그인)를 추가하지 않은 구성</strong>도 포함됩니다.<sup id="fnref:s3" role="doc-noteref"><a href="#fn:s3" class="footnote" rel="footnote">22</a></sup></p>

<p>같은 실행의 MariaDB <code class="language-plaintext highlighter-rouge">aurora</code> 튜닝 구성에서는 쓰기가 green으로 옮겨 간 뒤에도 옛 blue reader에서 10건의 읽기가 1.8초에 걸쳐 성공했습니다. 여기서 측정한 것은 옛 blue가 응답했다는 사실입니다. 읽기 쿼리는 노드 정보를 조회했으므로 업무 데이터가 실제로 얼마나 오래된 값이었는지는 측정하지 않았습니다.</p>

<h3 id="admin-실행-옛-blue에-쓰기가-성공했습니다"><code class="language-plaintext highlighter-rouge">admin</code> 실행: 옛 blue에 쓰기가 성공했습니다</h3>

<p><code class="language-plaintext highlighter-rouge">admin</code> 실행에서는 더 눈여겨봐야 할 결과가 나왔습니다. 옛 blue에 남은 7개 구성은 전환 완료 이후 <strong>각각 2,672~2,673행을 옛 blue에 기록</strong>했습니다. 같은 구간에 이 구성들이 green에 기록한 행은 없었습니다. 클라이언트에서는 쓰기가 다시 성공했지만 서비스가 옮겨 간 클러스터에 기록된 것은 아니었습니다.<sup id="fnref:s3-admin" role="doc-noteref"><a href="#fn:s3-admin" class="footnote" rel="footnote">23</a></sup></p>

<p>당시 옛 blue의 관찰값은 <code class="language-plaintext highlighter-rouge">read_only=1</code>, <code class="language-plaintext highlighter-rouge">super_read_only=0</code>, <code class="language-plaintext highlighter-rouge">innodb_read_only=0</code>이었고 <code class="language-plaintext highlighter-rouge">admin</code>에는 <code class="language-plaintext highlighter-rouge">CONNECTION_ADMIN</code> 권한이 있었습니다. MySQL 문서상 이 권한을 가진 사용자는 <code class="language-plaintext highlighter-rouge">read_only</code>의 쓰기 제한을 받지 않습니다. 이 조건은 옛 blue에서 실제로 쓰기가 성공한 결과와 맞아떨어집니다.<sup id="fnref:read-only" role="doc-noteref"><a href="#fn:read-only" class="footnote" rel="footnote">24</a></sup></p>

<p>따라서 복구를 확인할 때는 <strong>응답한 노드·클러스터가 어디인지, 실제로 어디에 기록됐는지</strong>를 봐야 합니다. 읽기 성공, 쓰기 성공, <code class="language-plaintext highlighter-rouge">@@innodb_read_only=0</code>만으로는 애플리케이션이 새 운영 환경으로 옮겨 갔는지까지 알 수 없습니다.</p>

<p>Blue/Green의 메타데이터 권한도 양쪽에서 확인해야 합니다. Wrapper 문서는 비특권 사용자에게 필요한 권한을 blue와 green 모두에 준비하도록 안내합니다. Wrapper 튜닝 구성(<code class="language-plaintext highlighter-rouge">t05</code>·<code class="language-plaintext highlighter-rouge">t06</code>)의 쓰기 공백은 <code class="language-plaintext highlighter-rouge">probe</code> 실행 35.7초, <code class="language-plaintext highlighter-rouge">admin</code> 실행 4.1초였습니다(11절 표). 이번 기록에는 배포 생성 후 blue에 <code class="language-plaintext highlighter-rouge">mysql.rds_topology</code> 조회 권한을 부여한 사실이 남아 있습니다. 하지만 green의 권한을 직접 검증한 기록은 없습니다. 사용자별 Wrapper 전환 시간 차이를 권한 하나 때문이라고 볼 근거는 없습니다.<sup id="fnref:bg-permissions" role="doc-noteref"><a href="#fn:bg-permissions" class="footnote" rel="footnote">25</a></sup></p>

<h3 id="reader-삭제는-페일오버와-다르게-진행됐습니다">reader 삭제는 페일오버와 다르게 진행됐습니다</h3>

<p>reader 삭제(S2)도 따로 두 번 시험했습니다. 이때는 <code class="language-plaintext highlighter-rouge">cluster-ro</code>를 함께 넣지 않은 MariaDB <code class="language-plaintext highlighter-rouge">aurora</code> 구성(<code class="language-plaintext highlighter-rouge">v05</code>)도 읽기 에러가 0건이었습니다.</p>

<p>삭제 요청을 받은 reader는 요청 뒤 약 400초까지 응답했습니다(마지막 성공 읽기 기준). 구성마다 남은 reader로 읽기를 옮긴 시점은 달랐습니다. <code class="language-plaintext highlighter-rouge">t02</code>·<code class="language-plaintext highlighter-rouge">v07</code>은 요청 뒤 25~72초에, <code class="language-plaintext highlighter-rouge">v05</code>·<code class="language-plaintext highlighter-rouge">v06</code>은 약 390~400초에 남은 reader로 읽기를 옮겼습니다. 한 장애 시나리오의 결과만으로는 모든 토폴로지 변경을 예측하지 못합니다.<sup id="fnref:s2" role="doc-noteref"><a href="#fn:s2" class="footnote" rel="footnote">26</a></sup></p>

<h2 id="10-운영에서는-복구가-멈춘-지점을-어떻게-찾을까요">10. 운영에서는 복구가 멈춘 지점을 어떻게 찾을까요</h2>

<p>복구가 멈춘 지점을 찾으려면 DB 이벤트, 주소 조회, 연결 재사용, 실제 쓰기를 단계별로 따로 봐야 합니다. 이번 측정 결과를 기준으로 먼저 살펴볼 항목은 다음과 같습니다.</p>

<table>
  <thead>
    <tr>
      <th>확인할 것</th>
      <th>확인하는 이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>실제 드라이버 버전·클래스·URL·플러그인</td>
      <td>Aurora 토폴로지를 인식하는 연결인지, 해당 버전이 그 스킴을 지원하는지 확인</td>
    </tr>
    <tr>
      <td>JVM DNS 설정과 재접속 대상</td>
      <td>연결을 새로 만들어도 옛 노드로 돌아가는지 확인</td>
    </tr>
    <tr>
      <td>풀의 런타임 설정</td>
      <td>설정 파일 값이 그대로 적용됐는지 확인</td>
    </tr>
    <tr>
      <td>오류 코드·응답 노드·연결 ID</td>
      <td>연결 고장인지, 응답 가능한 읽기 전용 노드에 남았는지 구분</td>
    </tr>
    <tr>
      <td>쓰기 공백·연산 지연·실제 기록 위치</td>
      <td>예외 없는 대기와 옛 클러스터로의 쓰기까지 확인</td>
    </tr>
    <tr>
      <td>실제 서비스와 같은 권한·부하·트랜잭션</td>
      <td>가벼운 측정 루프에서 보이지 않은 운영 동작을 검증</td>
    </tr>
  </tbody>
</table>

<p>타임아웃 설정은 이름이 비슷해도 기다리는 대상이 다릅니다.<sup id="fnref:timeouts" role="doc-noteref"><a href="#fn:timeouts" class="footnote" rel="footnote">27</a></sup></p>

<table>
  <thead>
    <tr>
      <th>설정</th>
      <th>무엇을 기다리나</th>
      <th>튜닝 구성의 값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>JDBC <code class="language-plaintext highlighter-rouge">connectTimeout</code></td>
      <td>DB와 새 연결을 맺는 시간</td>
      <td>3초</td>
    </tr>
    <tr>
      <td>JDBC <code class="language-plaintext highlighter-rouge">socketTimeout</code></td>
      <td>네트워크 소켓 작업의 응답 대기</td>
      <td>10초</td>
    </tr>
    <tr>
      <td>HikariCP <code class="language-plaintext highlighter-rouge">connectionTimeout</code></td>
      <td>풀에서 연결을 빌리려고 기다리는 시간</td>
      <td>5초</td>
    </tr>
  </tbody>
</table>

<p>이번 일반 드라이버 구성에서 최초 실패 연산은 0.3초 이내에 연결이 끊겼음을 알리는 오류(connection reset·EOF·socket 오류)로 끝났습니다. 이 0.3초는 실패한 연산의 소요 시간일 뿐 DB 장애 발생부터 감지까지를 따로 잰 값과는 다릅니다. 관찰한 10초대 복구를 <code class="language-plaintext highlighter-rouge">socketTimeout=10000</code>이 발동한 결과라고 해석할 근거는 없습니다.</p>

<p>실험에 쓴 3초·5초·10초를 그대로 운영의 정답으로 삼지 않습니다. 각 서비스의 정상 쿼리 시간과 요청 제한 시간에 맞춰 값을 정하고 그 값으로 다시 측정해야 합니다.</p>

<h2 id="11-측정-자료는-어떻게-읽을까요">11. 측정 자료는 어떻게 읽을까요</h2>

<p>본문의 수치는 아래 공개 자료의 반복별 요약에서 나왔습니다. 구성 이름의 접두어는 1절에서 설명한 대로 <code class="language-plaintext highlighter-rouge">d</code>가 기본값, <code class="language-plaintext highlighter-rouge">t</code>가 튜닝, <code class="language-plaintext highlighter-rouge">v</code>가 비교용 변형 구성입니다. 측정 행을 넣은 테이블과 데이터베이스의 이름도 <code class="language-plaintext highlighter-rouge">probe</code>지만 사용자 <code class="language-plaintext highlighter-rouge">probe</code>와는 별개입니다.</p>

<table>
  <thead>
    <tr>
      <th>파일</th>
      <th>내용</th>
      <th>행 수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/assets/data/aurora-failover/s1-failover-iterations.csv">s1-failover-iterations.csv</a></td>
      <td>S1 페일오버 10회 × 19구성, 반복별 지표</td>
      <td>190</td>
    </tr>
    <tr>
      <td><a href="/assets/data/aurora-failover/s2-reader-delete-iterations.csv">s2-reader-delete-iterations.csv</a></td>
      <td>S2 reader 삭제 2회 × 19구성</td>
      <td>38</td>
    </tr>
    <tr>
      <td><a href="/assets/data/aurora-failover/s3-bluegreen-probe-iterations.csv">s3-bluegreen-probe-iterations.csv</a></td>
      <td>S3 Blue/Green, 비특권 사용자 <code class="language-plaintext highlighter-rouge">probe</code></td>
      <td>19</td>
    </tr>
    <tr>
      <td><a href="/assets/data/aurora-failover/s3-bluegreen-admin-iterations.csv">s3-bluegreen-admin-iterations.csv</a></td>
      <td>S3 Blue/Green, 마스터 사용자 <code class="language-plaintext highlighter-rouge">admin</code></td>
      <td>19</td>
    </tr>
    <tr>
      <td><a href="/assets/data/aurora-failover/s3-bluegreen-rows-after-switchover.csv">s3-bluegreen-rows-after-switchover.csv</a></td>
      <td>S3 Blue/Green, 전환 완료 뒤 클러스터별 측정 테이블(<code class="language-plaintext highlighter-rouge">probe</code>) 기록 행 수(<code class="language-plaintext highlighter-rouge">run</code>·<code class="language-plaintext highlighter-rouge">arm</code>·<code class="language-plaintext highlighter-rouge">old_blue_rows</code>·<code class="language-plaintext highlighter-rouge">green_rows</code>)</td>
      <td>38</td>
    </tr>
    <tr>
      <td><a href="/assets/data/aurora-failover/events.csv">events.csv</a></td>
      <td>반복별 사건: 관찰 창, CLI 반환 시간, writer 변경, 지운 reader, RDS 페일오버 이벤트 간격, cluster endpoint DNS 변경 시각</td>
      <td>14</td>
    </tr>
    <tr>
      <td><a href="/assets/data/aurora-failover/arms.csv">arms.csv</a></td>
      <td>19개 구성의 드라이버·JDBC URL·설정 전체</td>
      <td>19</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">s3-bluegreen-rows-after-switchover.csv</code>의 행 수는 DB에서 집계했습니다. probe 실행은 옛 blue 행이 모두 0이고 green 행 수는 집계하지 않아 비어 있습니다.</p>

<p>S1·S2·S3의 반복별 지표 파일(<code class="language-plaintext highlighter-rouge">*-iterations.csv</code>)은 같은 열을 씁니다. 주요 열의 뜻은 다음과 같습니다.</p>

<table>
  <thead>
    <tr>
      <th>열</th>
      <th>뜻</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">write_s</code></td>
      <td>연속한 두 성공 쓰기 사이의 최대 간격(초)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">write_recovered</code></td>
      <td>관찰 창 안에서 쓰기가 다시 성공했는지. <code class="language-plaintext highlighter-rouge">False</code>이면 <code class="language-plaintext highlighter-rouge">write_s</code>는 하한</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">read_s</code>·<code class="language-plaintext highlighter-rouge">read_recovered</code></td>
      <td>읽기에 대한 같은 두 값</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">server_gap_s</code></td>
      <td>DB에 기록된 연속 행의 최대 시각 간격(초)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">err_write</code>·<code class="language-plaintext highlighter-rouge">err_read</code></td>
      <td>관찰 창의 쓰기 실패 수, 읽기 실패 수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">err_ro</code></td>
      <td>관찰 창의 실패 연산(읽기·쓰기) 가운데 오류 코드 1836·1290 또는 read-only 메시지로 판정한 수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ro_write_fail</code></td>
      <td>실패한 쓰기 가운데 응답 노드가 <code class="language-plaintext highlighter-rouge">innodb_read_only=1</code>로 기록된 수(노드 상태 기준)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">new_write_conns</code></td>
      <td>사건 뒤 쓰기에 새로 쓰인 물리 연결 수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">old_host_reads</code>·<code class="language-plaintext highlighter-rouge">old_host_span_s</code></td>
      <td>S3에서 스위치오버 완료 뒤 옛 blue가 응답한 읽기 수와 그 기간(초)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">stall_max_ms</code></td>
      <td>에러 없이 가장 오래 걸린 연산의 소요 시간(ms)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">window_s</code></td>
      <td>관찰 창의 길이(초)</td>
    </tr>
  </tbody>
</table>

<p>연산마다 한 줄씩 남긴 원시 연산 로그(약 570MB)는 공개하지 않고 반복별 요약만 공개합니다.</p>

<h3 id="수치를-읽을-때-주의할-점">수치를 읽을 때 주의할 점</h3>

<p><strong>쓰기 공백은 순수한 DB 정지 시간이 아닙니다.</strong> 쓰기 공백은 연속한 두 성공 쓰기의 완료 시각 사이에서 가장 긴 간격입니다. 계산 범위는 사건 직전 마지막 성공부터 관찰 종료까지입니다. 정상 상태에도 약 0.2초의 간격이 있습니다. DB에 저장된 <code class="language-plaintext highlighter-rouge">ts</code>의 연속 행 간격도 함께 기록했습니다. <code class="language-plaintext highlighter-rouge">ts</code>는 측정 테이블에 행을 넣을 때 <code class="language-plaintext highlighter-rouge">NOW(6)</code>으로 남긴 시각이라 커밋 완료 시각과는 다릅니다.</p>

<p><strong>미복구 값은 하한입니다.</strong> 관찰 종료까지 쓰기가 돌아오지 않은 반복은 미복구로 표시했습니다. 예를 들어 “360초 관찰에서 미복구”는 쓰기 공백이 정확히 360초였다는 뜻이 아닙니다. 실제 실패는 페일오버 요청보다 늦게 시작됐고 관찰을 끝낸 뒤 언제 복구됐는지도 알 수 없기 때문입니다.</p>

<p><strong>부하는 가볍습니다.</strong> 측정 루프는 한 연산이 오래 걸리면 다음 실행을 늦추고 밀린 실행은 건너뜁니다. 운영 서비스처럼 요청이 계속 쌓일 때의 대기열이나 사용자 응답 시간까지 측정한 실험은 아닙니다.</p>

<h3 id="구성별-결과">구성별 결과</h3>

<p>S1 페일오버의 쓰기 공백(초)을 관찰 길이별로 나눈 표입니다. 값은 중앙값(최솟값~최댓값)입니다. “미복구 8회 ≥337.8”은 8회 모두 관찰 종료까지 복구되지 않았고 그중 가장 짧은 공백 하한이 337.8초라는 뜻입니다.</p>

<table>
  <thead>
    <tr>
      <th>구성</th>
      <th>360초 관찰(1~8회)</th>
      <th>2,100초 관찰(9~10회)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d01</code> MySQL 9.7 기본값</td>
      <td>미복구 8회 ≥337.8</td>
      <td>1,782.9 (1,762.6~1,803.2)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d02</code> MariaDB 2.7 기본값</td>
      <td>미복구 8회 ≥337.9</td>
      <td>1,770.9 (1,761.7~1,780.0)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d03</code> MariaDB 2.7 <code class="language-plaintext highlighter-rouge">aurora</code> 기본값</td>
      <td>16.1 (15.5~16.5)</td>
      <td>16.1 (15.8~16.5)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d04</code> MariaDB 3.5 기본값</td>
      <td>미복구 8회 ≥338.0</td>
      <td>1,796.4 (1,790.7~1,802.1)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d05</code> Wrapper+MySQL 기본값</td>
      <td>2.0 (1.7~2.3)</td>
      <td>2.4 (1.9~2.9)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d06</code> Wrapper+MariaDB 기본값</td>
      <td>1.8 (1.7~1.9)</td>
      <td>2.3 (1.9~2.8)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t01</code> MySQL 9.7 튜닝</td>
      <td>10.7 (5.6~10.8)</td>
      <td>10.8 (10.8~10.8)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t02</code> MariaDB 2.7 <code class="language-plaintext highlighter-rouge">aurora</code>+<code class="language-plaintext highlighter-rouge">cluster-ro</code> 튜닝</td>
      <td>8.4 (3.3~10.7)</td>
      <td>11.2 (8.2~14.1)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t03</code> MariaDB 2.7 튜닝</td>
      <td>10.5 (5.5~10.6)</td>
      <td>10.6 (10.5~10.6)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t04</code> MariaDB 3.5 튜닝</td>
      <td>10.6 (5.6~10.7)</td>
      <td>10.7 (10.6~10.7)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t05</code> Wrapper+MySQL 튜닝</td>
      <td>1.7 (1.6~2.0)</td>
      <td>2.3 (2.0~2.6)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t06</code> Wrapper+MariaDB 튜닝</td>
      <td>1.6 (1.5~1.8)</td>
      <td>2.1 (1.7~2.5)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v01</code> t03 + DNS TTL -1</td>
      <td>미복구 8회 ≥338.0</td>
      <td>미복구 2회 ≥2,088.0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v02</code> t03 + maxLifetime 35초</td>
      <td>10.5 (5.5~10.6)</td>
      <td>10.6 (10.5~10.7)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v03</code> t03 + maxLifetime 70초</td>
      <td>10.5 (5.5~10.6)</td>
      <td>10.5 (10.5~10.5)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v04</code> t03 + maxLifetime 20초</td>
      <td>10.4 (5.5~10.6)</td>
      <td>10.5 (10.5~10.5)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v05</code> t02에서 <code class="language-plaintext highlighter-rouge">cluster-ro</code> 제외</td>
      <td>8.4 (3.1~13.4)</td>
      <td>11.1 (8.2~13.9)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v06</code> t05 + 읽기 분리 플러그인</td>
      <td>1.6 (1.5~3.3)</td>
      <td>2.2 (1.8~2.6)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v07</code> MySQL <code class="language-plaintext highlighter-rouge">cluster-ro</code> 읽기 전용 (읽기)</td>
      <td>5.5 (5.4~5.5)</td>
      <td>5.5 (5.5~5.5)</td>
    </tr>
  </tbody>
</table>

<p>S2 reader 삭제 2회의 읽기 공백(초)과 회차별 읽기 에러 수입니다. 표에 실은 구성은 두 종류입니다. reader로 읽기를 보낸 구성(<code class="language-plaintext highlighter-rouge">t02</code>·<code class="language-plaintext highlighter-rouge">v05</code>·<code class="language-plaintext highlighter-rouge">v06</code>의 읽기 전용 풀, <code class="language-plaintext highlighter-rouge">v07</code>의 <code class="language-plaintext highlighter-rouge">cluster-ro</code>)과, 비교를 위해 cluster endpoint로 읽은 <code class="language-plaintext highlighter-rouge">d03</code>·<code class="language-plaintext highlighter-rouge">t05</code>입니다. 나머지 구성도 읽기 공백 0.2초, 읽기 에러 0건이었습니다.</p>

<table>
  <thead>
    <tr>
      <th>구성</th>
      <th>읽기 공백</th>
      <th>읽기 에러(회차별)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t02</code> MariaDB 2.7 <code class="language-plaintext highlighter-rouge">aurora</code>+<code class="language-plaintext highlighter-rouge">cluster-ro</code> 튜닝</td>
      <td>0.2 (0.2~0.2)</td>
      <td>0·0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v05</code> t02에서 <code class="language-plaintext highlighter-rouge">cluster-ro</code> 제외</td>
      <td>0.2 (0.2~0.2)</td>
      <td>0·0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v06</code> t05 + 읽기 분리 플러그인</td>
      <td>0.4 (0.4~0.4)</td>
      <td>1·1</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v07</code> MySQL <code class="language-plaintext highlighter-rouge">cluster-ro</code> 읽기 전용</td>
      <td>0.2 (0.2~0.2)</td>
      <td>0·0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d03</code> MariaDB 2.7 <code class="language-plaintext highlighter-rouge">aurora</code> 기본값</td>
      <td>0.2 (0.2~0.2)</td>
      <td>0·0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t05</code> Wrapper+MySQL 튜닝</td>
      <td>0.2 (0.2~0.2)</td>
      <td>0·0</td>
    </tr>
  </tbody>
</table>

<p>S3 Blue/Green 사용자별 1회의 쓰기 공백(초)과, <code class="language-plaintext highlighter-rouge">probe</code> 실행에서 스위치오버 완료부터 관찰 종료까지 옛 blue가 응답한 읽기 수입니다. <code class="language-plaintext highlighter-rouge">admin</code>의 <code class="language-plaintext highlighter-rouge">d01~d06</code>·<code class="language-plaintext highlighter-rouge">v01</code> 값은 옛 blue에 쓰기가 다시 성공할 때까지의 공백이며 green으로 복구된 시간과는 별개입니다.</p>

<table>
  <thead>
    <tr>
      <th>구성</th>
      <th>probe 쓰기 공백</th>
      <th>admin 쓰기 공백</th>
      <th>옛 blue 읽기(probe)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d01</code> MySQL 9.7 기본값</td>
      <td>미복구 ≥582.5</td>
      <td>22.0 (옛 blue에 기록)</td>
      <td>2,668건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d02</code> MariaDB 2.7 기본값</td>
      <td>미복구 ≥582.5</td>
      <td>21.8 (옛 blue에 기록)</td>
      <td>2,668건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d03</code> MariaDB 2.7 <code class="language-plaintext highlighter-rouge">aurora</code> 기본값</td>
      <td>미복구 ≥582.6</td>
      <td>21.9 (옛 blue에 기록)</td>
      <td>2,668건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d04</code> MariaDB 3.5 기본값</td>
      <td>미복구 ≥582.7</td>
      <td>22.0 (옛 blue에 기록)</td>
      <td>2,668건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d05</code> Wrapper+MySQL 기본값</td>
      <td>미복구 ≥582.7</td>
      <td>22.2 (옛 blue에 기록)</td>
      <td>2,669건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d06</code> Wrapper+MariaDB 기본값</td>
      <td>미복구 ≥582.5</td>
      <td>20.7 (옛 blue에 기록)</td>
      <td>2,669건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t01</code> MySQL 9.7 튜닝</td>
      <td>14.5</td>
      <td>14.4</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t02</code> MariaDB 2.7 <code class="language-plaintext highlighter-rouge">aurora</code>+<code class="language-plaintext highlighter-rouge">cluster-ro</code> 튜닝</td>
      <td>14.5</td>
      <td>21.0 (옛 blue에 61행 기록 뒤 green)</td>
      <td>10건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t03</code> MariaDB 2.7 튜닝</td>
      <td>17.6</td>
      <td>14.5</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t04</code> MariaDB 3.5 튜닝</td>
      <td>14.5</td>
      <td>14.4</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t05</code> Wrapper+MySQL 튜닝</td>
      <td>35.7</td>
      <td>4.1</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t06</code> Wrapper+MariaDB 튜닝</td>
      <td>35.7</td>
      <td>4.1</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v01</code> t03 + DNS TTL -1</td>
      <td>미복구 ≥582.6</td>
      <td>20.9 (옛 blue에 기록)</td>
      <td>2,669건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v02</code> t03 + maxLifetime 35초</td>
      <td>14.4</td>
      <td>14.5</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v03</code> t03 + maxLifetime 70초</td>
      <td>14.4</td>
      <td>14.2</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v04</code> t03 + maxLifetime 20초</td>
      <td>14.4</td>
      <td>14.4</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v05</code> t02에서 <code class="language-plaintext highlighter-rouge">cluster-ro</code> 제외</td>
      <td>35.1</td>
      <td>35.7</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v06</code> t05 + 읽기 분리 플러그인</td>
      <td>35.7</td>
      <td>4.1</td>
      <td>0건</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v07</code> MySQL <code class="language-plaintext highlighter-rouge">cluster-ro</code> 읽기 전용 (읽기)</td>
      <td>12.5</td>
      <td>12.4</td>
      <td>0건</td>
    </tr>
  </tbody>
</table>

<h3 id="측정-코드는-어떻게-동작했을까요">측정 코드는 어떻게 동작했을까요</h3>

<p>측정 코드는 구성마다 JVM 하나를 띄우고 그 안에 HikariCP 풀을 만들어 200ms 주기의 쓰기·읽기 루프를 돌립니다. 연결을 쓸 수 있는지는 드라이버로 보낸 실제 쿼리가 성공했는지로 확인합니다. 쓰기는 응답 노드를 조회한 뒤 <code class="language-plaintext highlighter-rouge">INSERT</code>를 실행하고 읽기는 3절의 노드 조회 쿼리를 실행합니다. 페일오버·reader 삭제·Blue/Green 전환 같은 사건은 AWS CLI로 일으킵니다.</p>

<h2 id="정리">정리</h2>

<p>같은 Aurora MySQL 페일오버에서 쓰기 복구는 약 2초에서 약 30분까지 갈렸습니다. Wrapper 구성은 약 2초, DNS·타임아웃을 조정한 일반 드라이버는 약 10초, 기본값 일반 드라이버는 옛 writer에 남아 약 30분이 걸렸습니다.</p>

<p>오래 걸린 구성에서 애플리케이션이 DB를 못 찾은 것은 아니었습니다. 애플리케이션은 reader로 강등된 옛 writer에 정상적으로 연결돼 읽기는 성공하고 쓰기만 거부당하는 상태에 머물렀습니다. 커넥션 풀의 기본 연결 검증과 예외 판정은 그 연결을 고장으로 보지 않았습니다. JVM DNS를 무기한 캐시로 바꾼 구성(<code class="language-plaintext highlighter-rouge">v01</code>, 10회 시험)에서는 연결을 새로 만들어도 옛 주소로 돌아갔습니다.</p>

<p>복구를 판정할 때는 명령 반환 시간이나 RDS 이벤트, 에러 건수만 보지 않습니다. 애플리케이션의 복구 완료 조건에는 <strong>새 writer에서 의도한 쓰기가 성공했는지, 기대한 데이터가 새 운영 환경에 기록됐는지</strong>를 포함해야 합니다. DB 이벤트부터 주소 조회, 연결 재사용, 실제 쓰기까지 이어서 보면 같은 페일오버가 어떤 애플리케이션에는 2초이고 다른 애플리케이션에는 30분이 된 이유를 찾을 수 있습니다. 운영 중인 서비스에서는 다음을 먼저 확인합니다(전체 항목은 10절 표).</p>

<ul>
  <li>실제 드라이버·URL·플러그인(Wrapper 여부)</li>
  <li>JVM DNS TTL</li>
  <li>풀의 런타임 값(HikariCP 7.1.0에서 0이 아닌 <code class="language-plaintext highlighter-rouge">maxLifetime</code>이 30초 미만이면 30분으로 바뀜, 6절)</li>
  <li>쓰기 공백과 실제 기록 위치</li>
</ul>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:experiment" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/events.csv">반복별 사건 기록(CSV)</a>. S1 페일오버 10회, S2 reader 삭제 2회, S3 Blue/Green 사용자별 1회의 관찰 창·CLI 반환 시간·writer 변경. 구성별 지표 파일은 11절에 정리했습니다. <a href="#fnref:experiment" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:experiment:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:endpoint" role="doc-endnote">
      <p>AWS, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Endpoints.Cluster.html">Cluster endpoints for Amazon Aurora</a>. writer 변경 시 endpoint가 가리키는 IP 변경과 DNS 캐시 관리 안내. <a href="#fnref:endpoint" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:endpoint:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:endpoints-overview" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.Endpoints.html">Amazon Aurora endpoint connections</a>. reader endpoint로 연결하면 Aurora가 Aurora Replica들 사이에 연결을 분산(connection-balancing)합니다. <a href="#fnref:endpoints-overview" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ha" role="doc-endnote">
      <p>AWS, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Concepts.AuroraHighAvailability.html">High availability for Amazon Aurora</a>. Aurora MySQL 페일오버 때 writer와 새 writer가 될 reader만 재시작되고, 다른 reader는 reader endpoint로 들어온 쿼리를 계속 처리한다는 설명. <a href="#fnref:ha" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:ha:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:hikari-config" role="doc-endnote">
      <p>HikariCP 7.1.0, <a href="https://github.com/brettwooldridge/HikariCP/blob/HikariCP-7.1.0/src/main/java/com/zaxxer/hikari/HikariConfig.java"><code class="language-plaintext highlighter-rouge">HikariConfig.java</code></a>, <code class="language-plaintext highlighter-rouge">validateNumerics()</code>와 기본 <code class="language-plaintext highlighter-rouge">MAX_LIFETIME</code>. 실측은 S1 <code class="language-plaintext highlighter-rouge">v04</code> 구성의 기동 로그와 풀 런타임 값입니다. <a href="#fnref:hikari-config" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:hikari-config:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:dns-ttl" role="doc-endnote">
      <p>AWS, <a href="https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/jvm-ttl-dns.html">Set the JVM TTL for DNS name lookups</a>; Oracle Java 21, <a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/InetAddress.html"><code class="language-plaintext highlighter-rouge">InetAddress</code>의 주소 캐시 설명</a>. 5초는 JVM 캐시 설정이며 DNS 반영부터 쓰기 성공까지의 복구 시간 보장이 아닙니다. <a href="#fnref:dns-ttl" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:dns-ttl:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:failover2" role="doc-endnote">
      <p>AWS Advanced JDBC Wrapper 4.4.0, <a href="https://github.com/aws/aws-advanced-jdbc-wrapper/blob/4.4.0/docs/using-the-jdbc-driver/using-plugins/UsingTheFailover2Plugin.md">Failover Plugin v2</a>, 토폴로지 모니터링과 writer 확인. <a href="#fnref:failover2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:failover2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:blue-green" role="doc-endnote">
      <p>AWS, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/blue-green-deployments-switching.html">Switching a blue/green deployment</a>. 스위치오버 절차와 전환 후 이전 운영 환경의 상태. <a href="#fnref:blue-green" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:blue-green:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:mariadb" role="doc-endnote">
      <p>MariaDB, <a href="https://mariadb.com/docs/release-notes/connectors/java/3.0/3.0.3">Connector/J 3.0.3 Release Notes</a>, Aurora 지원 제거. 비교한 2.7.15의 <code class="language-plaintext highlighter-rouge">aurora</code> 스킴은 <code class="language-plaintext highlighter-rouge">jdbc:mysql:aurora://</code>이며 MySQL Connector/J의 기능을 뜻하지 않습니다. <a href="#fnref:mariadb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:mariadb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:event-time" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/events.csv">반복별 사건 기록(CSV)</a>. CLI 반환 시간은 <code class="language-plaintext highlighter-rouge">event_api_s</code>, RDS 이벤트 간격은 <code class="language-plaintext highlighter-rouge">Started cross AZ failover</code>와 <code class="language-plaintext highlighter-rouge">Completed customer initiated failover</code>의 <code class="language-plaintext highlighter-rouge">Date</code> 차이인 <code class="language-plaintext highlighter-rouge">rds_failover_events_s</code>입니다. RDS 이벤트 간격의 중앙값은 9.203초입니다. 명령 반환 시각을 사건 종료로 보는 것은 S1의 기록 방식이며, S3는 별도 완료 상태를 기다립니다. <a href="#fnref:event-time" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:s1" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/s1-failover-iterations.csv">S1 페일오버 반복별 지표(CSV)</a>. 표의 기본값 행은 <code class="language-plaintext highlighter-rouge">d01~d06</code>, 튜닝 행은 <code class="language-plaintext highlighter-rouge">t01~t06</code>이고, 쓰기 공백은 <code class="language-plaintext highlighter-rouge">write_s</code> 열입니다. 도중에 성공 한 건이 끼면 장애 구간이 나뉠 수 있어, 실패 구간과 이후 지속적인 성공도 함께 확인해야 합니다. <a href="#fnref:s1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:s1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:settings" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/arms.csv">19개 구성의 드라이버·JDBC URL·설정(CSV)</a>. MariaDB 2.7 일반 스킴은 <code class="language-plaintext highlighter-rouge">usePipelineAuth=false</code>, <code class="language-plaintext highlighter-rouge">useBatchMultiSend=false</code>, 3.5 튜닝 구성은 <code class="language-plaintext highlighter-rouge">disablePipeline=true</code>입니다. Wrapper 튜닝은 <code class="language-plaintext highlighter-rouge">initialConnection,auroraConnectionTracker,failover2,efm2,bg</code>, <code class="language-plaintext highlighter-rouge">failureDetectionTime=6000</code>, <code class="language-plaintext highlighter-rouge">failureDetectionInterval=1000</code>, <code class="language-plaintext highlighter-rouge">failureDetectionCount=2</code>, <code class="language-plaintext highlighter-rouge">clusterTopologyRefreshRateMs=5000</code>, <code class="language-plaintext highlighter-rouge">clusterId=jdbc-test</code>와 HikariCP 예외 처리 설정을 사용했습니다. 이 값 전체가 AWS의 일괄 권장 설정이라는 뜻은 아닙니다. <a href="#fnref:settings" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:initial-connection" role="doc-endnote">
      <p>AWS Advanced JDBC Wrapper 4.4.0, <a href="https://github.com/aws/aws-advanced-jdbc-wrapper/blob/4.4.0/docs/using-the-jdbc-driver/using-plugins/UsingTheAuroraInitialConnectionStrategyPlugin.md">Aurora Initial Connection Strategy Plugin</a>. DNS 갱신 중 옛 노드에 연결되는 상황과 초기 연결 처리. <a href="#fnref:initial-connection" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:dns-path" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/events.csv">반복별 사건 기록(CSV)</a>. 반복별 cluster endpoint DNS 변경 시각은 <code class="language-plaintext highlighter-rouge">cluster_dns_change_s</code> 열에 있고, 본문의 경과 시간과 연결 <code class="language-plaintext highlighter-rouge">98</code>의 필드는 공개하지 않은 연산 로그에서 추렸습니다. DNS 변경과 연결 끊김의 상대 비교는 runner 시계 기준입니다. CLI 호출 시각은 로컬 Mac, 연산·DNS 조회 시각은 runner에서 기록했으므로 호출 기준 비교에는 두 시계의 오차가 들어갈 수 있습니다. 두 시계의 차이는 이번 기록에 없습니다. <a href="#fnref:dns-path" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:hikari-validation" role="doc-endnote">
      <p>HikariCP 7.1.0, <a href="https://github.com/brettwooldridge/HikariCP/blob/HikariCP-7.1.0/src/main/java/com/zaxxer/hikari/pool/PoolBase.java"><code class="language-plaintext highlighter-rouge">PoolBase.java</code></a>, <code class="language-plaintext highlighter-rouge">isConnectionDead()</code>의 JDBC4 검증. <a href="#fnref:hikari-validation" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:hikari-eviction" role="doc-endnote">
      <p>HikariCP 7.1.0, <a href="https://github.com/brettwooldridge/HikariCP/blob/HikariCP-7.1.0/src/main/java/com/zaxxer/hikari/pool/ProxyConnection.java"><code class="language-plaintext highlighter-rouge">ProxyConnection.java</code></a>, <code class="language-plaintext highlighter-rouge">checkException()</code> 및 <code class="language-plaintext highlighter-rouge">ERROR_STATES</code>·<code class="language-plaintext highlighter-rouge">ERROR_CODES</code>. <a href="#fnref:hikari-eviction" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:hikari-lifetime" role="doc-endnote">
      <p>HikariCP 7.1.0, <a href="https://github.com/brettwooldridge/HikariCP/blob/HikariCP-7.1.0/README.md">README의 <code class="language-plaintext highlighter-rouge">maxLifetime</code>·<code class="language-plaintext highlighter-rouge">connectionTimeout</code></a>. 사용 중인 연결의 수명 만료 처리와 풀 대기 시간의 의미. <a href="#fnref:hikari-lifetime" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:dns-infinite" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/s1-failover-iterations.csv">S1 페일오버 반복별 지표(CSV)</a>. <code class="language-plaintext highlighter-rouge">v01</code>의 10회 결과이며, 장기 관찰은 9·10회차입니다. 164개·153개는 사건 이후 응답 노드가 기록된 물리 연결 ID 수입니다. <a href="#fnref:dns-infinite" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:failover-exceptions" role="doc-endnote">
      <p>AWS Advanced JDBC Wrapper 4.4.0, <a href="https://github.com/aws/aws-advanced-jdbc-wrapper/blob/4.4.0/docs/using-the-jdbc-driver/using-plugins/UsingTheFailoverPlugin.md">Failover Plugin의 예외와 복구 처리</a>. <code class="language-plaintext highlighter-rouge">08S02</code>·<code class="language-plaintext highlighter-rouge">FailoverSuccessSQLException</code>과 트랜잭션 결과가 불명확한 <code class="language-plaintext highlighter-rouge">08007</code>의 처리 구분. <a href="#fnref:failover-exceptions" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:wrapper-hikari" role="doc-endnote">
      <p>AWS Advanced JDBC Wrapper 4.4.0, <a href="https://github.com/aws/aws-advanced-jdbc-wrapper/blob/4.4.0/examples/HikariExample/src/main/java/software/amazon/HikariFailoverExample.java"><code class="language-plaintext highlighter-rouge">HikariFailoverExample.java</code></a>. HikariCP와 Wrapper를 함께 쓸 때 <code class="language-plaintext highlighter-rouge">exceptionOverrideClassName</code>을 설정하는 공식 예제. <a href="#fnref:wrapper-hikari" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:silent-stall" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/s1-failover-iterations.csv">S1 페일오버 반복별 지표(CSV)</a>. <code class="language-plaintext highlighter-rouge">t02</code>에서 에러가 0건인 반복은 1·2·4·5·6·7·8·10회차이며, DB 연속 행 간격은 <code class="language-plaintext highlighter-rouge">server_gap_s</code>, 에러 없이 가장 오래 걸린 연산은 <code class="language-plaintext highlighter-rouge">stall_max_ms</code> 열입니다. <code class="language-plaintext highlighter-rouge">ts</code>는 <code class="language-plaintext highlighter-rouge">NOW(6)</code>으로 기록한 시각이고, 클라이언트 완료 시각은 연산 시작 시각과 단조 시계로 잰 소요 시간으로 구성됩니다. <a href="#fnref:silent-stall" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:s3" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/s3-bluegreen-probe-iterations.csv">S3 Blue/Green 비특권 사용자 반복별 지표(CSV)</a>. 옛 blue에 남은 7개 구성은 <code class="language-plaintext highlighter-rouge">d01~d06</code>, <code class="language-plaintext highlighter-rouge">v01</code>이고, green 쓰기와 옛 blue 읽기가 함께 관찰된 구성은 <code class="language-plaintext highlighter-rouge">t02</code>입니다. 옛 blue의 읽기 수(<code class="language-plaintext highlighter-rouge">old_host_reads</code>)는 스위치오버 완료부터 관찰 종료까지 집계했습니다. <a href="#fnref:s3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:s3-admin" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/s3-bluegreen-admin-iterations.csv">S3 Blue/Green 마스터 사용자 반복별 지표(CSV)</a>, <a href="/assets/data/aurora-failover/s3-bluegreen-rows-after-switchover.csv">스위치오버 완료 뒤 클러스터별 기록 행 수(CSV)</a>. 옛 blue에 남은 7개 구성은 <code class="language-plaintext highlighter-rouge">probe</code> 실행과 같은 <code class="language-plaintext highlighter-rouge">d01~d06</code>, <code class="language-plaintext highlighter-rouge">v01</code>이며, 옛 blue의 행 수는 두 번째 파일에서 스위치오버 완료부터 관찰 종료까지 집계했습니다. <a href="#fnref:s3-admin" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:read-only" role="doc-endnote">
      <p>MySQL 8.0 Reference Manual, <a href="https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_read_only"><code class="language-plaintext highlighter-rouge">read_only</code>·<code class="language-plaintext highlighter-rouge">super_read_only</code></a>. <code class="language-plaintext highlighter-rouge">CONNECTION_ADMIN</code>의 예외와 <code class="language-plaintext highlighter-rouge">super_read_only</code>의 추가 제한. S1에서 관찰한 Aurora의 <code class="language-plaintext highlighter-rouge">innodb_read_only=1</code>·오류 1836은 S3 옛 blue의 변수 조합(<code class="language-plaintext highlighter-rouge">innodb_read_only=0</code>)과 다릅니다. <a href="#fnref:read-only" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:bg-permissions" role="doc-endnote">
      <p>AWS Advanced JDBC Wrapper 4.4.0, <a href="https://github.com/aws/aws-advanced-jdbc-wrapper/blob/4.4.0/docs/using-the-jdbc-driver/using-plugins/UsingTheBlueGreenPlugin.md#connecting-with-non-admin-users">Blue/Green Plugin — Connecting with non-admin users</a>. 배포 생성 전에 권한을 준비하거나, 생성 후라면 green에도 별도로 부여하도록 안내합니다. <a href="#fnref:bg-permissions" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:s2" role="doc-endnote">
      <p><a href="/assets/data/aurora-failover/s2-reader-delete-iterations.csv">S2 reader 삭제 반복별 지표(CSV)</a>. <code class="language-plaintext highlighter-rouge">cluster-ro</code>가 없는 비교 구성은 <code class="language-plaintext highlighter-rouge">v05</code>, 있는 구성은 <code class="language-plaintext highlighter-rouge">t02</code>이며 둘 다 읽기 에러가 0건이었습니다. 삭제 후에도 인스턴스가 응답한 기간과 각 구성의 이탈 시점은 다릅니다. <a href="#fnref:s2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:timeouts" role="doc-endnote">
      <p>MySQL Connector/J, <a href="https://dev.mysql.com/doc/connector-j/en/connector-j-connp-props-networking.html">Networking properties</a>; HikariCP 7.1.0 <a href="https://github.com/brettwooldridge/HikariCP/blob/HikariCP-7.1.0/README.md">README</a>. S1의 일반 드라이버 6개 구성(<code class="language-plaintext highlighter-rouge">d01</code>·<code class="language-plaintext highlighter-rouge">d02</code>·<code class="language-plaintext highlighter-rouge">d04</code>·<code class="language-plaintext highlighter-rouge">t01</code>·<code class="language-plaintext highlighter-rouge">t03</code>·<code class="language-plaintext highlighter-rouge">t04</code>) × 10회에서 최초 실패 연산의 소요 시간 최댓값은 292ms였습니다. <a href="#fnref:timeouts" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="mysql" /><summary type="html"><![CDATA[Aurora MySQL 페일오버를 19개 JDBC 구성으로 동시에 관찰했습니다. 약 2초와 약 30분으로 갈린 쓰기 복구, 옛 writer 재접속, 에러 없는 쓰기 정지를 실제 기록으로 살펴보고, 반복별 측정 자료를 CSV로 함께 공개합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RAG 평가 데이터셋: Gold·QA 구축부터 검색·답변 품질 평가까지</title><link href="https://rastalion.dev/writing/rag-evaluation/" rel="alternate" type="text/html" title="RAG 평가 데이터셋: Gold·QA 구축부터 검색·답변 품질 평가까지" /><published>2026-09-27T15:10:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://rastalion.dev/writing/rag-evaluation</id><content type="html" xml:base="https://rastalion.dev/writing/rag-evaluation/"><![CDATA[<p><strong>시리즈</strong> · <a href="/writing/rag-data-preparation/">1. 데이터 범위·요건·전처리·피처</a> · <strong>2. 평가 데이터셋과 품질 평가</strong></p>

<p><strong>관련 글</strong> · <a href="/writing/vector-database-overview/">벡터 DB의 개념과 제품</a> · <a href="/writing/vector-database-selection/">선택 기준과 기능 비교</a> · <a href="/writing/aws-vector-search/">AWS 전용 벡터 검색</a></p>

<p>사내 운영 문서를 검색하는 챗봇에 사용자가 “야간에 DB 장애가 나면 어디에 먼저 알려야 하나요?”라고 물었다고 해 봅시다. 답변이 틀렸다면 검색이 필요한 근거를 놓쳤는지, 가져온 근거를 모델이 잘못 읽었는지 구분할 수 있어야 고칠 곳을 찾을 수 있습니다.</p>

<p><a href="/writing/rag-data-preparation/">앞선 글</a>에서는 이 챗봇에 넣을 데이터의 범위와 요건, 전처리, 피처 설계를 정리했습니다. 이번에는 그 데이터로 만든 서비스가 제대로 동작하는지 확인할 평가 데이터셋을 만들고, 검색 품질과 답변 품질을 나눠 평가하는 방법을 살펴봅니다. 앞선 글에서 구분했듯이 검색 문서 집합(corpus), 모델을 학습할 데이터, 결과를 평가할 데이터는 목적이 다릅니다. 이미 학습된 임베딩 모델과 LLM을 사용하는 일반적인 RAG는 별도의 모델 학습 없이 구성할 수 있고, 필요에 따라 모델 학습을 추가할 수 있습니다.<sup id="fnref:rag" role="doc-noteref"><a href="#fn:rag" class="footnote" rel="footnote">1</a></sup> 이 글은 세 가지 데이터 가운데 평가 데이터를 중심으로 다룹니다.</p>

<p>이 글의 조사 기준일은 2026년 9월 27일입니다. 평가 레코드 JSON의 운영 지침은 설명을 위해 만든 가상 예시이며, 평가 지표의 수치는 계산 예시입니다. 실제 서비스의 성능을 측정한 결과는 아닙니다.</p>

<h2 id="1-gold-데이터와-qa는-어떻게-만들까요">1. Gold 데이터와 QA는 어떻게 만들까요</h2>

<p>업무 전문가가 중요한 원천 자료를 골라 검토하고, 이를 바탕으로 LLM이 질문과 답변을 확장한 뒤 전문가가 다시 확인하는 흐름으로 질문·답변(QA)을 만들 수 있습니다. 실제 사용자 질문을 활용하고, 데이터 생성 전체를 검토 없이 자동화하지 않는다는 점이 핵심입니다.</p>

<p>이 글에서는 업무 전문가가 골라 검토한 중요한 원천 자료를 <strong>Gold 데이터</strong>, 정답 근거와 참고 답변까지 확정한 질문·답변을 <strong>평가 문항</strong>이라고 부르고 둘을 구분합니다. 원천 문서가 정확하더라도 그 문서에서 생성한 질문이 모호하거나 답변이 틀릴 수 있으므로, 문서를 승인한 것만으로 QA까지 승인된 것은 아닙니다.</p>

<h3 id="대표-사례와-놓치면-안-되는-사례를-함께-고릅니다">대표 사례와 놓치면 안 되는 사례를 함께 고릅니다</h3>

<p>자료를 업무별로 분류하고, 질문의 빈도와 실패했을 때의 영향을 함께 봅니다. 벡터 유사도로 비슷한 자료를 묶어 대표 사례를 찾는 방법은 선별을 도울 수 있습니다. 다만 중심에 가까운 문서만 고르면 드문 예외나 특이한 형식이 빠질 수 있으므로, 아래와 같은 사례를 별도로 넣는 편이 좋습니다.</p>

<ul>
  <li>자주 묻는 용어·절차와 실제 Help Desk 질문</li>
  <li>표·그림·각주를 읽어야 답할 수 있는 질문</li>
  <li>둘 이상의 근거를 조합해야 하는 질문</li>
  <li>개정 전후의 내용이 달라지는 질문</li>
  <li>자료에 답이 없거나 추가 조건을 물어야 하는 질문</li>
  <li>사용자 권한에 따라 제공 가능한 정보가 달라지는 질문</li>
</ul>

<p>RAG 평가 도구인 Ragas도 단일 근거로 답하는 질문과 여러 출처를 연결해야 하는 질문을 구분합니다. 질문의 말투만 바꾸는 것에 더해, 어떤 근거와 추론이 필요한지 달라지는 문항을 만드는 데 참고할 수 있습니다.<sup id="fnref:test-generation" role="doc-noteref"><a href="#fn:test-generation" class="footnote" rel="footnote">2</a></sup></p>

<h3 id="질문-생성과-답변-작성-모두에-근거를-제공합니다">질문 생성과 답변 작성 모두에 근거를 제공합니다</h3>

<p>QA 작성 순서는 다음과 같이 잡을 수 있습니다.</p>

<ol>
  <li><strong>근거를 고릅니다.</strong> 원천 문서의 버전과 답에 필요한 부분을 지정합니다.</li>
  <li><strong>질문을 만듭니다.</strong> 실제 질문을 우선 활용하고, 빈 유형은 LLM으로 보충합니다.</li>
  <li><strong>답변을 작성합니다.</strong> 질문과 함께 근거를 제공해 참고 답변과 필수 사실을 만듭니다.</li>
  <li><strong>형식을 맞춥니다.</strong> 요약, 목록, 절차, 출처 표기 등 서비스의 출력 요구를 반영합니다.</li>
  <li><strong>전문가가 확인합니다.</strong> 업무상 타당성, 원문과의 일치, 누락된 조건, 답변 가능 여부를 검사합니다.</li>
  <li><strong>이력을 남깁니다.</strong> 원본·생성 모델·프롬프트·검토 상태를 기록하고, 중복 문항을 정리합니다.</li>
</ol>

<p>질문만 LLM에 주고 답을 만들게 하면 근거 밖의 지식으로 그럴듯한 정답을 만들어 낼 수 있습니다. 질문을 만드는 단계와 답변을 만드는 단계 모두에 승인된 자료를 연결하는 것이 좋습니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 1076 440" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="rag-qa-authoring-archify-diagram-title">평가 문항을 만드는 흐름</title>
        <desc id="rag-qa-authoring-archify-diagram-description">A data-flow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="rag-qa-authoring-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="rag-qa-authoring-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="rag-qa-authoring-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="rag-qa-authoring-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="rag-qa-authoring-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#rag-qa-authoring-grid)" />

        <!-- Data Stages -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="0" x="16" y="46" width="168" height="320" rx="10" class="c-lane" stroke-width="1" />
        <text x="100" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">01 / 입력</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="1" x="231" y="46" width="168" height="320" rx="10" class="c-lane" stroke-width="1" />
        <text x="315" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">02 / 질문</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="2" x="446" y="46" width="168" height="320" rx="10" class="c-lane" stroke-width="1" />
        <text x="530" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">03 / 답변</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="3" x="661" y="46" width="168" height="320" rx="10" class="c-lane" stroke-width="1" />
        <text x="745" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">04 / 검토</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="4" x="876" y="46" width="168" height="320" rx="10" class="c-lane" stroke-width="1" />
        <text x="960" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">05 / 확정</text>

        <!-- Flow paths -->
        <path data-edge-from="real_q" data-edge-to="q_gen" data-edge-label="우선 활용" data-edge-key="0" data-composition-points="170,150;245,150" d="M 170 150 L 245 150" class="a-default" stroke-width="1.4" marker-end="url(#rag-qa-authoring-arrowhead)" />
        <path data-edge-from="q_gen" data-edge-to="a_gen" data-edge-label="질문" data-edge-key="1" data-composition-points="385,150;455,150" d="M 385 150 L 455 150" class="a-default" stroke-width="1.4" marker-end="url(#rag-qa-authoring-arrowhead)" />
        <path data-edge-from="evidence" data-edge-to="q_gen" data-edge-label="근거" data-edge-key="2" data-composition-points="315,242;315,179" d="M 315 242 L 315 179" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-qa-authoring-arrowhead-emphasis)" />
        <path data-edge-from="evidence" data-edge-to="a_gen" data-edge-label="같은 근거" data-edge-key="3" data-composition-points="390,271;422.5,271;422.5,164;455,164" d="M 390 271 L 422.5 271 L 422.5 164 L 455 164" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-qa-authoring-arrowhead-emphasis)" />
        <path data-edge-from="a_gen" data-edge-to="review" data-edge-label="QA 초안" data-edge-key="4" data-composition-points="605,157;675,157" d="M 605 157 L 675 157" class="a-default" stroke-width="1.4" marker-end="url(#rag-qa-authoring-arrowhead)" />
        <path data-edge-from="review" data-edge-to="final" data-edge-label="확인 완료" data-edge-key="5" data-composition-points="815,157;890,157" d="M 815 157 L 890 157" class="a-default" stroke-width="1.4" marker-end="url(#rag-qa-authoring-arrowhead)" />

        <!-- Nodes -->
        <g id="rag-qa-authoring-node-real_q" data-node-id="real_q" data-node-label="실제 사용자 질문" tabindex="0" role="button" aria-label="Focus 실제 사용자 질문, Help Desk 등, 01 / 입력" aria-pressed="false" data-node-kind="external" data-node-sublabel="Help Desk 등" data-node-context="01 / 입력">
          <title>실제 사용자 질문 · Help Desk 등 · 01 / 입력</title>
          <rect x="30" y="121" width="140" height="58" rx="6" class="c-mask" />
          <rect x="30" y="121" width="140" height="58" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(36 127) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="142" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">실제 사용자 질문</text>
          <text data-detail="context" x="100" y="158" class="t-muted" font-size="7" text-anchor="middle">Help Desk 등</text>
        </g>

        <g id="rag-qa-authoring-node-q_gen" data-node-id="q_gen" data-node-label="질문 생성" tabindex="0" role="button" aria-label="Focus 질문 생성, 빈 유형은 LLM 보충, 02 / 질문" aria-pressed="false" data-node-kind="backend" data-node-sublabel="빈 유형은 LLM 보충" data-node-context="02 / 질문">
          <title>질문 생성 · 빈 유형은 LLM 보충 · 02 / 질문</title>
          <rect x="245" y="121" width="140" height="58" rx="6" class="c-mask" />
          <rect x="245" y="121" width="140" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(251 127) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="142" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">질문 생성</text>
          <text data-detail="context" x="315" y="158" class="t-muted" font-size="7" text-anchor="middle">빈 유형은 LLM 보충</text>
        </g>

        <g id="rag-qa-authoring-node-evidence" data-node-id="evidence" data-node-label="승인된 근거" tabindex="0" role="button" aria-label="Focus 승인된 근거, 문서 버전 · 필요한 부분, 02 / 질문" aria-pressed="false" data-node-kind="database" data-node-sublabel="문서 버전 · 필요한 부분" data-node-context="02 / 질문">
          <title>승인된 근거 · 문서 버전 · 필요한 부분 · 02 / 질문</title>
          <rect x="240" y="242" width="150" height="58" rx="6" class="c-mask" />
          <rect x="240" y="242" width="150" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(246 248) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">승인된 근거</text>
          <text data-detail="context" x="315" y="279" class="t-muted" font-size="7" text-anchor="middle">문서 버전 · 필요한 부분</text>
        </g>

        <g id="rag-qa-authoring-node-a_gen" data-node-id="a_gen" data-node-label="답변 작성" tabindex="0" role="button" aria-label="Focus 답변 작성, 참고 답변 · 필수 사실 · 형식, 03 / 답변" aria-pressed="false" data-node-kind="backend" data-node-sublabel="참고 답변 · 필수 사실 · 형식" data-node-context="03 / 답변">
          <title>답변 작성 · 참고 답변 · 필수 사실 · 형식 · 03 / 답변</title>
          <rect x="455" y="128" width="150" height="58" rx="6" class="c-mask" />
          <rect x="455" y="128" width="150" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(461 134) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">답변 작성</text>
          <text data-detail="context" x="530" y="165" class="t-muted" font-size="7" text-anchor="middle">참고 답변 · 필수 사실 · 형식</text>
        </g>

        <g id="rag-qa-authoring-node-review" data-node-id="review" data-node-label="전문가 확인" tabindex="0" role="button" aria-label="Focus 전문가 확인, 원문 일치 · 누락 조건, 04 / 검토" aria-pressed="false" data-node-kind="security" data-node-sublabel="원문 일치 · 누락 조건" data-node-context="04 / 검토">
          <title>전문가 확인 · 원문 일치 · 누락 조건 · 04 / 검토</title>
          <rect x="675" y="128" width="140" height="58" rx="6" class="c-mask" />
          <rect x="675" y="128" width="140" height="58" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(681 134) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">전문가 확인</text>
          <text data-detail="context" x="745" y="165" class="t-muted" font-size="7" text-anchor="middle">원문 일치 · 누락 조건</text>
        </g>

        <g id="rag-qa-authoring-node-final" data-node-id="final" data-node-label="평가 문항 확정" tabindex="0" role="button" aria-label="Focus 평가 문항 확정, 원본 · 모델 · 검토 상태, 05 / 확정" aria-pressed="false" data-node-kind="database" data-node-sublabel="원본 · 모델 · 검토 상태" data-node-context="05 / 확정">
          <title>평가 문항 확정 · 원본 · 모델 · 검토 상태 · 05 / 확정</title>
          <rect x="890" y="128" width="140" height="58" rx="6" class="c-mask" />
          <rect x="890" y="128" width="140" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(896 134) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="960" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">평가 문항 확정</text>
          <text data-detail="context" x="960" y="165" class="t-muted" font-size="7" text-anchor="middle">원본 · 모델 · 검토 상태</text>
        </g>

        <!-- Flow labels -->
        <g data-detail="context" data-edge-from="real_q" data-edge-to="q_gen" data-edge-label="우선 활용" data-edge-key="0">
          <rect x="179.45" y="129" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="140" class="t-muted" font-size="8" text-anchor="middle">우선 활용</text>
        </g>
        <g data-detail="context" data-edge-from="q_gen" data-edge-to="a_gen" data-edge-label="질문" data-edge-key="1">
          <rect x="403" y="129" width="34" height="16" rx="4" class="c-mask" />
          <text x="420" y="140" class="t-muted" font-size="8" text-anchor="middle">질문</text>
        </g>
        <g data-detail="context" data-edge-from="evidence" data-edge-to="q_gen" data-edge-label="근거" data-edge-key="2">
          <rect x="298" y="221" width="34" height="16" rx="4" class="c-mask" />
          <text x="315" y="232" class="t-backend" font-size="8" text-anchor="middle">근거</text>
        </g>
        <g data-detail="context" data-edge-from="evidence" data-edge-to="a_gen" data-edge-label="같은 근거" data-edge-key="3">
          <rect x="394.45" y="196.5" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="207.5" class="t-backend" font-size="8" text-anchor="middle">같은 근거</text>
        </g>
        <g data-detail="context" data-edge-from="a_gen" data-edge-to="review" data-edge-label="QA 초안" data-edge-key="4">
          <rect x="616.85" y="136" width="46.3" height="16" rx="4" class="c-mask" />
          <text x="640" y="147" class="t-muted" font-size="8" text-anchor="middle">QA 초안</text>
        </g>
        <g data-detail="context" data-edge-from="review" data-edge-to="final" data-edge-label="확인 완료" data-edge-key="5">
          <rect x="824.45" y="136" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="852.5" y="147" class="t-muted" font-size="8" text-anchor="middle">확인 완료</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="384" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="emphasis" data-legend-x="40" data-legend-baseline="404" data-legend-width="88">
            <path d="M 40 401 L 74 401" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-qa-authoring-arrowhead-emphasis)" />
            <text x="83" y="404" class="t-muted" font-size="10" font-weight="500">근거 제공</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="근거 · 확정 문항" data-legend-x="150" data-legend-baseline="404" data-legend-width="123">
            <rect x="150" y="396" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="172" y="404" class="t-muted" font-size="10" font-weight="500">근거 · 확정 문항</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="295" data-legend-baseline="404" data-legend-width="88">
            <path d="M 295 401 L 329 401" class="a-default" stroke-width="1.4" marker-end="url(#rag-qa-authoring-arrowhead)" />
            <text x="338" y="404" class="t-muted" font-size="10" font-weight="500">작성 흐름</text>
          </g>
        </g>
      </svg>
</div><figcaption>질문 생성과 답변 작성 모두에 승인된 근거를 넣고, 전문가가 확인한 QA를 평가 문항으로 확정합니다.</figcaption>
</figure>

<p>Few-shot 방식은 검토한 예제를 프롬프트에 넣어 새 QA의 형태와 수준을 안내하는 데 사용할 수 있습니다. 이때는 같은 질문의 표현만 바뀐 문항과 새로운 업무 상황을 다루는 문항을 구분합니다. QA 1개를 비슷한 질문 100개로 늘려도 서로 다른 업무 100개를 검증한 것은 아닙니다.</p>

<p>학습용이라면 <code class="language-plaintext highlighter-rouge">Instruction</code>·<code class="language-plaintext highlighter-rouge">Input</code>·<code class="language-plaintext highlighter-rouge">Output</code> 형태로 정리할 수 있습니다. 다만 이것은 자료를 정리하는 한 가지 형식이고, 실제 학습에 넣을 때는 학습 대상이 요구하는 스키마에 맞춰야 합니다. 평가용 레코드에는 이 세 값 외에 <strong>정답 근거와 기대 동작</strong>이 필요합니다.</p>

<h2 id="2-평가-데이터셋에는-무엇을-담을까요">2. 평가 데이터셋에는 무엇을 담을까요</h2>

<p>평가 문항은 “질문 하나, 정답 문장 하나”보다 조금 더 풍부해야 합니다. 질문에 어떤 자료가 필요한지 알아야 검색 실패와 생성 실패를 구분할 수 있습니다. 다음은 특정 제품의 API 형식이 아닌, 직접 관리할 수 있는 평가 레코드의 예시입니다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"case_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ops-night-001"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"question"</span><span class="p">:</span><span class="w"> </span><span class="s2">"야간에 DB 장애를 발견하면 어디에 먼저 알려야 하나요?"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"question_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"single_hop"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"as_of"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-09-27"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"actor_groups"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"db-oncall"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"expected_behavior"</span><span class="p">:</span><span class="w"> </span><span class="s2">"answer"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"reference_answer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"발견 후 10분 이내에 당직 채널에 등록하고 담당 DBA를 호출합니다."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"required_facts"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"발견 후 10분 이내 당직 채널 등록"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"담당 DBA 호출"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"evidence"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"document_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"db-incident-runbook"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"document_version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"v3"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"section"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.1 야간 장애 접수"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"야간 DB 장애 발견자는 발견 후 10분 이내에 당직 채널에 등록하고 담당 DBA를 호출한다."</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"split"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"origin"</span><span class="p">:</span><span class="w"> </span><span class="s2">"illustrative_example"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>이 예시의 10분과 연락 절차는 가상의 규정입니다. 실제 문항에는 원문 근거를 넣고 검토자·검토 상태·평가 데이터셋 버전도 관리합니다. <code class="language-plaintext highlighter-rouge">actor_groups</code>는 테스트에서 설정하는 사용자 조건이며, 서비스에서는 클라이언트가 보낸 문자열을 그대로 신뢰하지 않고 인증된 신원으로 결정해야 합니다.<sup id="fnref:access" role="doc-noteref"><a href="#fn:access" class="footnote" rel="footnote">3</a></sup></p>

<p><code class="language-plaintext highlighter-rouge">reference_answer</code>와 <code class="language-plaintext highlighter-rouge">required_facts</code>, <code class="language-plaintext highlighter-rouge">evidence</code>는 평가기가 사용할 기준입니다. 평가 실행 때 서비스에 정답을 함께 전달하지 않습니다. 서비스에는 질문과 필요한 사용자 조건을 넣고, 실제 검색 결과·LLM에 전달한 컨텍스트·생성 답변을 별도의 실행 결과로 저장합니다. <code class="language-plaintext highlighter-rouge">question_type</code>에는 질문 유형을 적습니다. 예시의 <code class="language-plaintext highlighter-rouge">single_hop</code>은 1절에서 본 단일 근거 질문입니다. <code class="language-plaintext highlighter-rouge">as_of</code>는 질문의 기준일을, <code class="language-plaintext highlighter-rouge">split</code>은 이 문항이 3절의 어느 데이터 구분에 속하는지를 적는 필드입니다.</p>

<h3 id="정답-근거는-청크-id에만-묶지-않습니다">정답 근거는 청크 ID에만 묶지 않습니다</h3>

<p>청크 분할 방식을 바꾸면 청크 ID와 경계가 달라질 수 있습니다. 그러므로 문서 ID·버전·절·근거 구절을 기준으로 정답을 남기고, 실행 시점의 청크가 그 근거를 포함하는지 대응시키는 방식을 제안합니다. 문서 단위 평가라면 같은 문서의 여러 청크를 중복 정답으로 세지 않는 규칙도 필요합니다.</p>

<p>이 방식은 평가 질문을 특정 검색기의 결과에 종속시키지 않기 위한 것입니다. Microsoft도 합성 질문 생성에 쓰는 일회성 분할과 실제 솔루션의 청크 분할을 구분하도록 안내합니다.<sup id="fnref:preparation" role="doc-noteref"><a href="#fn:preparation" class="footnote" rel="footnote">4</a></sup></p>

<p>현재 검색기의 Top-k에서만 근거를 고르고 질문을 역으로 만들면, 현재 검색기가 놓치는 자료는 평가 문항에도 나타나지 않을 수 있습니다. <strong>검토한 원문에서 질문·근거를 만들고, 각 검색기가 그것을 찾아내는지 비교</strong>하는 편이 이 편향을 줄이는 데 도움이 됩니다.</p>

<p>질문에 필요한 근거 수와 검색할 k도 구분합니다. 청크를 여러 개 가져왔다고 모두 정답 근거인 것은 아니며, 비슷한 문단을 모았다는 것만으로 여러 근거를 연결해야 하는 질문이 만들어지는 것도 아닙니다.</p>

<h3 id="답하지-않아야-하는-질문도-평가합니다">답하지 않아야 하는 질문도 평가합니다</h3>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>평가할 기대 동작</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>자료에 답이 없음</td>
      <td>근거 부족을 알리고 답을 지어내지 않음</td>
    </tr>
    <tr>
      <td>제품·버전 등 조건이 부족함</td>
      <td>필요한 조건을 되물음</td>
    </tr>
    <tr>
      <td>권한이 없는 자료가 필요함</td>
      <td>제한된 내용을 노출하지 않고 서비스 정책에 맞게 안내</td>
    </tr>
    <tr>
      <td>문서끼리 충돌함</td>
      <td>적용 시점·승인 상태로 판단하거나 충돌을 알림</td>
    </tr>
    <tr>
      <td>문서에 모델을 조종하려는 지시가 있음</td>
      <td>문서 속 지시를 실행 규칙으로 따르지 않음</td>
    </tr>
  </tbody>
</table>

<p>답이 없는 문항에는 <code class="language-plaintext highlighter-rouge">reference_answer: null</code>, <code class="language-plaintext highlighter-rouge">expected_behavior: "abstain"</code>(답변 유보)처럼 의미를 명시할 수 있습니다. 서비스 범위에 따라 확인 질문은 <code class="language-plaintext highlighter-rouge">clarify</code>, 접근 제한은 <code class="language-plaintext highlighter-rouge">deny</code> 등으로 나눌 수 있습니다. 이름 자체보다 기대 동작과 통과 기준을 합의하는 것이 중요합니다.</p>

<p>외부 문서에 들어 있는 지시가 모델의 행동을 바꾸는 것은 간접 프롬프트 인젝션의 한 형태입니다. OWASP는 문서·웹페이지 등의 외부 입력과 시스템 지시를 구분하고 권한을 제한하도록 안내합니다.<sup id="fnref:injection" role="doc-noteref"><a href="#fn:injection" class="footnote" rel="footnote">5</a></sup> 따라서 보안 문항은 최종 답변뿐 아니라, 제한된 자료가 LLM에 전달됐는지와 허용하지 않은 도구 호출이 발생했는지도 확인해야 합니다.</p>

<h2 id="3-학습개발최종-평가-데이터는-어떻게-나눌까요">3. 학습·개발·최종 평가 데이터는 어떻게 나눌까요</h2>

<p>데이터를 나누는 목적은 새 질문에 대한 성능을 평가하는 데 있습니다. 모델을 학습하지 않는 RAG라도 프롬프트, 청크 크기, 검색 파라미터를 같은 문항에 반복해서 맞추면 그 문항에 유리한 설정을 고를 수 있습니다. 최종 평가 데이터를 설정 선택에 계속 사용하지 않는다는 원칙이 필요합니다.<sup id="fnref:cross-validation" role="doc-noteref"><a href="#fn:cross-validation" class="footnote" rel="footnote">6</a></sup></p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>사용 목적</th>
      <th>관리 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>학습용</td>
      <td>모델 파라미터 학습</td>
      <td>실제 학습을 할 때 준비</td>
    </tr>
    <tr>
      <td>개발용</td>
      <td>프롬프트·청크·검색 설정 비교, 오류 재현</td>
      <td>실패 사례를 추가하며 반복 사용</td>
    </tr>
    <tr>
      <td>최종 평가용</td>
      <td>선택한 구성의 성능 확인</td>
      <td>조정에 쓰지 않고 별도로 보관</td>
    </tr>
  </tbody>
</table>

<p>나누는 단위도 중요합니다. 원문 하나에서 만든 질문 20개를 무작위로 나누면, 학습·개발 쪽과 최종 평가 쪽에 사실상 같은 내용이 들어갈 수 있습니다. 같은 문서 계열이나 티켓, 거의 같은 질문에서 파생된 문항은 그룹으로 묶는 방식을 고려합니다. 그룹 구조가 있는 데이터는 그룹별로 나눠야 새로운 그룹에 대한 성능을 평가할 수 있습니다.<sup id="fnref:cross-validation:1" role="doc-noteref"><a href="#fn:cross-validation" class="footnote" rel="footnote">6</a></sup> 앞의 그룹 묶기는 이 원칙을 RAG의 QA에 적용한 것입니다.</p>

<p>다만 <strong>최종 평가 질문의 근거 문서를 검색 문서 집합에서 무조건 빼라는 뜻은 아닙니다.</strong> 검색형 QA에서는 근거가 검색 대상에 있어야 정상적으로 답할 수 있습니다. 분리해야 하는 것은 평가 질문·참고 답변을 학습이나 설정 조정에 사용하는 경로입니다. 처음 보는 문서에 대한 일반화까지 평가하려면 문서 그룹도 나눕니다. 최종 평가용 문항은 학습·개발에 쓰지 않은 문서 그룹에서 만들고, 평가할 때는 그 문서를 검색할 수 있게 구성합니다.</p>

<p>시간에 따라 바뀌는 업무라면 질문의 기준일과 당시 사용 가능한 문서 버전도 맞춥니다. 과거 질문을 재현하면서 당시에는 없었던 후속 보고서를 근거로 제공하면 실제보다 쉬운 시험이 됩니다. 시간 순서가 있는 데이터의 평가에서 미래 정보를 섞지 않는 원칙을 문서 스냅샷에도 적용하는 것입니다.<sup id="fnref:cross-validation:2" role="doc-noteref"><a href="#fn:cross-validation" class="footnote" rel="footnote">6</a></sup></p>

<h2 id="4-검색과-답변을-어떻게-따로-평가할까요">4. 검색과 답변을 어떻게 따로 평가할까요</h2>

<p>최종 답변만 보면 왜 틀렸는지 알기 어렵습니다. 도입부의 야간 장애 질문처럼 답이 틀렸다면, 검색이 근거를 놓쳤는지 모델이 근거를 잘못 읽었는지부터 나눠 봐야 합니다. Amazon Bedrock도 검색만 평가하는 작업과 검색·생성을 함께 평가하는 작업을 나눕니다.<sup id="fnref:evaluation" role="doc-noteref"><a href="#fn:evaluation" class="footnote" rel="footnote">7</a></sup></p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 1000 520" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="rag-eval-split-archify-diagram-title">검색 평가와 답변 평가를 나누는 구조</title>
        <desc id="rag-eval-split-archify-diagram-description">A data-flow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="rag-eval-split-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="rag-eval-split-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="rag-eval-split-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="rag-eval-split-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="rag-eval-split-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#rag-eval-split-grid)" />

        <!-- Data Stages -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="0" x="16" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="100" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">01 / 평가 레코드</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="1" x="231" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="315" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">02 / 검색</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="2" x="446" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="530" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">03 / 답변 생성</text>

        <!-- Flow paths -->
        <path data-edge-from="question" data-edge-to="retrieve" data-edge-label="질문" data-edge-key="0" data-composition-points="173,271;259,271" d="M 173 271 L 259 271" class="a-default" stroke-width="1.4" marker-end="url(#rag-eval-split-arrowhead)" />
        <path data-edge-from="retrieve" data-edge-to="generate" data-edge-label="컨텍스트" data-edge-key="1" data-composition-points="371,271;474,271" d="M 371 271 L 474 271" class="a-default" stroke-width="1.4" marker-end="url(#rag-eval-split-arrowhead)" />
        <path data-edge-from="retrieve" data-edge-to="retrieval_eval" data-edge-label="검색 결과" data-edge-key="2" data-composition-points="315,242;315,186" d="M 315 242 L 315 186" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-eval-split-arrowhead-emphasis)" />
        <path data-edge-from="generate" data-edge-to="answer_eval" data-edge-label="답변 · 컨텍스트" data-edge-key="3" data-composition-points="530,300;530,356" d="M 530 300 L 530 356" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-eval-split-arrowhead-emphasis)" />
        <path data-edge-from="evidence" data-edge-to="retrieval_eval" data-edge-label="평가 전용" data-edge-key="4" data-composition-points="170,157;235,157" d="M 170 157 L 235 157" class="a-dashed" stroke-width="1.4" marker-end="url(#rag-eval-split-arrowhead-dashed)" />
        <path data-edge-from="reference" data-edge-to="answer_eval" data-edge-label="평가 전용" data-edge-key="5" data-composition-points="173,385;455,385" d="M 173 385 L 455 385" class="a-dashed" stroke-width="1.4" marker-end="url(#rag-eval-split-arrowhead-dashed)" />

        <!-- Nodes -->
        <g id="rag-eval-split-node-evidence" data-node-id="evidence" data-node-label="정답 근거" tabindex="0" role="button" aria-label="Focus 정답 근거, 문서 · 버전 · 절 · 구절, 01 / 평가 레코드" aria-pressed="false" data-node-kind="database" data-node-sublabel="문서 · 버전 · 절 · 구절" data-node-context="01 / 평가 레코드">
          <title>정답 근거 · 문서 · 버전 · 절 · 구절 · 01 / 평가 레코드</title>
          <rect x="30" y="128" width="140" height="58" rx="6" class="c-mask" />
          <rect x="30" y="128" width="140" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(36 134) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">정답 근거</text>
          <text data-detail="context" x="100" y="165" class="t-muted" font-size="7" text-anchor="middle">문서 · 버전 · 절 · 구절</text>
        </g>

        <g id="rag-eval-split-node-question" data-node-id="question" data-node-label="질문 · 사용자 조건" tabindex="0" role="button" aria-label="Focus 질문 · 사용자 조건, 서비스에 넣는 값, 01 / 평가 레코드" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="서비스에 넣는 값" data-node-context="01 / 평가 레코드">
          <title>질문 · 사용자 조건 · 서비스에 넣는 값 · 01 / 평가 레코드</title>
          <rect x="27" y="242" width="146" height="58" rx="6" class="c-mask" />
          <rect x="27" y="242" width="146" height="58" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(33 248) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">질문 · 사용자 조건</text>
          <text data-detail="context" x="100" y="279" class="t-muted" font-size="7" text-anchor="middle">서비스에 넣는 값</text>
        </g>

        <g id="rag-eval-split-node-reference" data-node-id="reference" data-node-label="참고 답변 · 필수 사실" tabindex="0" role="button" aria-label="Focus 참고 답변 · 필수 사실, 기대 동작 포함, 01 / 평가 레코드" aria-pressed="false" data-node-kind="database" data-node-sublabel="기대 동작 포함" data-node-context="01 / 평가 레코드">
          <title>참고 답변 · 필수 사실 · 기대 동작 포함 · 01 / 평가 레코드</title>
          <rect x="27" y="356" width="146" height="58" rx="6" class="c-mask" />
          <rect x="27" y="356" width="146" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(33 362) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">참고 답변 · 필수 사실</text>
          <text data-detail="context" x="100" y="393" class="t-muted" font-size="7" text-anchor="middle">기대 동작 포함</text>
        </g>

        <g id="rag-eval-split-node-retrieve" data-node-id="retrieve" data-node-label="검색" tabindex="0" role="button" aria-label="Focus 검색, Top-k 결과, 02 / 검색" aria-pressed="false" data-node-kind="backend" data-node-sublabel="Top-k 결과" data-node-context="02 / 검색">
          <title>검색 · Top-k 결과 · 02 / 검색</title>
          <rect x="259" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="242" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 248) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">검색</text>
          <text data-detail="context" x="315" y="279" class="t-muted" font-size="7" text-anchor="middle">Top-k 결과</text>
        </g>

        <g id="rag-eval-split-node-retrieval_eval" data-node-id="retrieval_eval" data-node-label="검색 평가" tabindex="0" role="button" aria-label="Focus 검색 평가, Precision@k · Recall@k · MRR · nDCG, 02 / 검색" aria-pressed="false" data-node-kind="security" data-node-sublabel="Precision@k · Recall@k · MRR · nDCG" data-node-context="02 / 검색">
          <title>검색 평가 · Precision@k · Recall@k · MRR · nDCG · 02 / 검색</title>
          <rect x="235" y="128" width="160" height="58" rx="6" class="c-mask" />
          <rect x="235" y="128" width="160" height="58" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(241 134) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">검색 평가</text>
          <text data-detail="context" x="315" y="165" class="t-muted" font-size="7" text-anchor="middle">Precision@k · Recall@k · MRR · nDCG</text>
        </g>

        <g id="rag-eval-split-node-generate" data-node-id="generate" data-node-label="답변 생성" tabindex="0" role="button" aria-label="Focus 답변 생성, LLM, 03 / 답변 생성" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="LLM" data-node-context="03 / 답변 생성">
          <title>답변 생성 · LLM · 03 / 답변 생성</title>
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(480 248) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">답변 생성</text>
          <text data-detail="context" x="530" y="279" class="t-muted" font-size="7" text-anchor="middle">LLM</text>
        </g>

        <g id="rag-eval-split-node-answer_eval" data-node-id="answer_eval" data-node-label="답변 평가" tabindex="0" role="button" aria-label="Focus 답변 평가, 정확성 · 충실성 · 인용, 03 / 답변 생성" aria-pressed="false" data-node-kind="security" data-node-sublabel="정확성 · 충실성 · 인용" data-node-context="03 / 답변 생성">
          <title>답변 평가 · 정확성 · 충실성 · 인용 · 03 / 답변 생성</title>
          <rect x="455" y="356" width="150" height="58" rx="6" class="c-mask" />
          <rect x="455" y="356" width="150" height="58" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(461 362) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">답변 평가</text>
          <text data-detail="context" x="530" y="393" class="t-muted" font-size="7" text-anchor="middle">정확성 · 충실성 · 인용</text>
        </g>

        <!-- Flow labels -->
        <g data-detail="context" data-edge-from="question" data-edge-to="retrieve" data-edge-label="질문" data-edge-key="0">
          <rect x="199" y="250" width="34" height="16" rx="4" class="c-mask" />
          <text x="216" y="261" class="t-muted" font-size="8" text-anchor="middle">질문</text>
        </g>
        <g data-detail="context" data-edge-from="retrieve" data-edge-to="generate" data-edge-label="컨텍스트" data-edge-key="1">
          <rect x="396.9" y="250" width="51.2" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="261" class="t-muted" font-size="8" text-anchor="middle">컨텍스트</text>
        </g>
        <g data-detail="context" data-edge-from="retrieve" data-edge-to="retrieval_eval" data-edge-label="검색 결과" data-edge-key="2">
          <rect x="286.95" y="221" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="315" y="232" class="t-backend" font-size="8" text-anchor="middle">검색 결과</text>
        </g>
        <g data-detail="context" data-edge-from="generate" data-edge-to="answer_eval" data-edge-label="답변 · 컨텍스트" data-edge-key="3">
          <rect x="487.25" y="303" width="85.5" height="16" rx="4" class="c-mask" />
          <text x="530" y="314" class="t-backend" font-size="8" text-anchor="middle">답변 · 컨텍스트</text>
        </g>
        <g data-detail="context" data-edge-from="evidence" data-edge-to="retrieval_eval" data-edge-label="평가 전용" data-edge-key="4">
          <rect x="174.45" y="136" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="202.5" y="147" class="t-messagebus" font-size="8" text-anchor="middle">평가 전용</text>
        </g>
        <g data-detail="context" data-edge-from="reference" data-edge-to="answer_eval" data-edge-label="평가 전용" data-edge-key="5">
          <rect x="285.95" y="364" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="314" y="375" class="t-messagebus" font-size="8" text-anchor="middle">평가 전용</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="464" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="emphasis" data-legend-x="40" data-legend-baseline="484" data-legend-width="88">
            <path d="M 40 481 L 74 481" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-eval-split-arrowhead-emphasis)" />
            <text x="83" y="484" class="t-muted" font-size="10" font-weight="500">실행 결과</text>
          </g>
          <g data-legend-semantic-kind="dashed" data-legend-x="150" data-legend-baseline="484" data-legend-width="98">
            <path d="M 150 481 L 184 481" class="a-dashed" stroke-width="1.4" marker-end="url(#rag-eval-split-arrowhead-dashed)" />
            <text x="193" y="484" class="t-muted" font-size="10" font-weight="500">평가기 전용</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="평가 기준" data-legend-x="270" data-legend-baseline="484" data-legend-width="88">
            <rect x="270" y="476" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="292" y="484" class="t-muted" font-size="10" font-weight="500">평가 기준</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="380" data-legend-baseline="484" data-legend-width="98">
            <path d="M 380 481 L 414 481" class="a-default" stroke-width="1.4" marker-end="url(#rag-eval-split-arrowhead)" />
            <text x="423" y="484" class="t-muted" font-size="10" font-weight="500">서비스 실행</text>
          </g>
        </g>
      </svg>
</div><figcaption>서비스엔 질문·사용자 조건만, 정답 근거는 검색 평가에만, 참고 답변·필수 사실은 답변 평가에만 씁니다.</figcaption>
</figure>

<h3 id="검색-평가-필요한-근거를-얼마나-어떤-순서로-찾았나요">검색 평가: 필요한 근거를 얼마나, 어떤 순서로 찾았나요</h3>

<p>아래는 사람이 관련성을 판정한 문서나 근거 단위를 기준으로 한 지표입니다. MRR은 Mean Reciprocal Rank, nDCG는 normalized Discounted Cumulative Gain의 약자입니다.<sup id="fnref:ranking" role="doc-noteref"><a href="#fn:ranking" class="footnote" rel="footnote">8</a></sup></p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>측정하는 것</th>
      <th>해석할 때 확인할 사항</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Precision@k</td>
      <td>상위 k개 중 관련 결과의 비율</td>
      <td>관련 결과의 순서는 반영하지 않음</td>
    </tr>
    <tr>
      <td>Recall@k</td>
      <td>전체 관련 결과 중 상위 k개에서 찾은 비율</td>
      <td>정답 목록이 불완전하면 해석도 제한됨</td>
    </tr>
    <tr>
      <td>MRR@k</td>
      <td>첫 관련 결과 순위의 역수를 질문별로 평균</td>
      <td>여러 근거를 모두 찾았는지는 알 수 없음</td>
    </tr>
    <tr>
      <td>nDCG@k</td>
      <td>관련성 등급과 순서를 함께 반영한 순위 품질</td>
      <td>관련성 등급과 이상적인 순위의 정의가 필요</td>
    </tr>
  </tbody>
</table>

<p>예를 들어 정답 근거가 <code class="language-plaintext highlighter-rouge">A, B, C</code>이고, 검색 순서가 <code class="language-plaintext highlighter-rouge">X, A, Y, B, Z</code>라면 다음과 같습니다. <code class="language-plaintext highlighter-rouge">X, Y, Z</code>는 관련 없는 결과라고 가정합니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Precision@5 = 2 / 5 = 0.40
Recall@5    = 2 / 3 ≈ 0.667
RR@5        = 1 / 2 = 0.50
</code></pre></div></div>

<p>마지막 값은 이 질문 하나의 reciprocal rank(RR)입니다. 여러 질문의 RR을 평균한 것이 MRR입니다. 상위 k개 안에 관련 결과가 없으면 해당 질문의 RR은 0으로 계산합니다.</p>

<p>이 글의 Precision@k는 분모를 k로 두고, k개를 채우지 못한 자리는 관련 결과가 없는 것으로 계산하는 규칙을 사용합니다. 도구를 사용할 때는 실제 반환 수를 분모로 쓰는지, 관련성을 판정하지 않은 결과(미판정 결과)를 제외하는지 확인해야 합니다. 정답 근거가 없는 문항의 Recall은 분모가 0이므로 일반 검색 Recall 평균에서 제외하고, 별도의 답변 유보 평가에 포함합니다.</p>

<p>정답 목록에 없는 검색 결과를 모두 오답으로 확정하는 것도 조심해야 합니다. 새로 찾은 문서가 실제로 유효한 근거일 수 있습니다. 미판정 결과를 구분하고 검토한 뒤 동일한 평가 데이터셋 버전으로 다시 비교하는 것이 좋습니다.<sup id="fnref:ranking:1" role="doc-noteref"><a href="#fn:ranking" class="footnote" rel="footnote">8</a></sup></p>

<p><strong>여기서의 Recall@k와 근사 최근접 이웃(ANN) 검색의 recall@k는 기준이 다릅니다.</strong> ANN recall은 정확한 최근접 이웃 검색 결과를 얼마나 재현했는지를 보고, 업무 검색 Recall은 사람이 관련 있다고 판정한 근거를 얼마나 찾았는지를 봅니다. 가까운 벡터를 잘 찾는 것과 업무상 필요한 문서를 잘 찾는 것은 구분해야 합니다.<sup id="fnref:embedding" role="doc-noteref"><a href="#fn:embedding" class="footnote" rel="footnote">9</a></sup></p>

<h3 id="context-지표는-이름보다-정의를-확인합니다">Context 지표는 이름보다 정의를 확인합니다</h3>

<p>Ragas의 LLM 기반 Context Recall은 참고 답변의 주장 가운데 검색한 컨텍스트로 뒷받침되는 비율을 봅니다. 같은 문서의 ID를 찾았다는 것과 필요한 내용을 실제로 포함했다는 것은 다르므로, ID 기반 Recall과 구분해서 읽어야 합니다.<sup id="fnref:context-recall" role="doc-noteref"><a href="#fn:context-recall" class="footnote" rel="footnote">10</a></sup></p>

<p>Ragas의 순위 기반 Context Precision도 앞의 단순한 Precision@k와 다릅니다. 관련 청크가 나타난 각 순위까지의 precision을 이용하므로 순서가 점수에 영향을 줍니다. Ragas의 ID 기반 구현은 반환한 ID 가운데 관련 ID가 차지하는 비율을 사용합니다. 결과표에는 지표 이름과 함께 <strong>구현·입력·버전</strong>을 남기는 것이 좋습니다.<sup id="fnref:context-precision" role="doc-noteref"><a href="#fn:context-precision" class="footnote" rel="footnote">11</a></sup></p>

<h3 id="답변-평가-근거를-바르게-사용했나요">답변 평가: 근거를 바르게 사용했나요</h3>

<p>답변은 다음 항목을 나눠 확인할 수 있습니다. Bedrock의 평가 지표 역시 정확성·완전성·근거 충실성·인용을 구분합니다.<sup id="fnref:evaluation:1" role="doc-noteref"><a href="#fn:evaluation" class="footnote" rel="footnote">7</a></sup></p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>확인할 내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>정확성(correctness)</td>
      <td>질문의 조건과 업무상 정답에 맞는가</td>
    </tr>
    <tr>
      <td>완전성(completeness)</td>
      <td>질문이 요구한 필수 사실과 예외를 빠뜨리지 않았는가</td>
    </tr>
    <tr>
      <td>근거 충실성(faithfulness)</td>
      <td>답변의 주장이 제공된 근거로 뒷받침되는가</td>
    </tr>
    <tr>
      <td>인용 정확성</td>
      <td>인용한 구절이 연결된 주장을 실제로 뒷받침하는가</td>
    </tr>
    <tr>
      <td>인용 범위</td>
      <td>근거가 필요한 주장에 인용이 빠지지 않았는가</td>
    </tr>
    <tr>
      <td>기대 동작 준수</td>
      <td>답변·확인 질문·유보·접근 제한을 상황에 맞게 수행했는가</td>
    </tr>
  </tbody>
</table>

<p>예를 들어 구버전 지침에 “30분 이내 보고”라고 쓰여 있고 모델이 그대로 답했다면, 제공된 컨텍스트에는 충실할 수 있습니다. 하지만 현재 승인된 지침이 “10분 이내 보고”라면 업무상으로는 틀린 답입니다. <strong>근거 충실성 점수가 높아도 원천 자료의 최신성이나 사실성을 보증하지는 않습니다.</strong> Ragas의 정의도 답변과 제공된 컨텍스트 사이의 일관성을 측정합니다.<sup id="fnref:faithfulness" role="doc-noteref"><a href="#fn:faithfulness" class="footnote" rel="footnote">12</a></sup></p>

<h3 id="llm-채점도-검증-대상입니다">LLM 채점도 검증 대상입니다</h3>

<p>LLM-as-a-judge는 많은 답변을 평가하는 데 사용할 수 있지만, 채점 모델의 판정이 곧 정답은 아닙니다. MT-Bench 연구는 답변 순서, 장황함, 자기 답변 선호 등의 편향을 다룹니다. 이 연구의 결과를 특정 한국어 업무 RAG의 채점 정확도로 그대로 적용할 수는 없습니다.<sup id="fnref:judge" role="doc-noteref"><a href="#fn:judge" class="footnote" rel="footnote">13</a></sup></p>

<p>사람이 판정한 일부 문항으로 채점 기준을 점검하고, 사람과 모델의 판단이 다른 사례를 검토하는 방법을 권합니다. 문서 ID·권한·출력 형식처럼 명확한 조건은 규칙으로 확인하고, 의미 판단이 필요한 부분에 LLM 평가를 사용합니다. 같은 모델로 QA를 만들고 답변하고 채점한 점수만으로 품질을 확정하지 않습니다.</p>

<h2 id="5-작은-평가-데이터셋으로-시작해-어떻게-운영할까요">5. 작은 평가 데이터셋으로 시작해 어떻게 운영할까요</h2>

<p>평가는 개발이 끝난 뒤로 미루지 않는 편이 좋습니다. 초기의 작은 평가 데이터셋으로 문제를 찾으면서 테스트·이행 단계로 갈수록 범위를 넓혀 가는 방식입니다. 초기 문항 수를 일률적으로 정하기보다는 지원할 업무와 문서 유형을 얼마나 포함했는지 확인합니다.</p>

<p>이 원칙을 적용한 작업 순서는 다음과 같습니다.</p>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>할 일</th>
      <th>남길 결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>분석·설계</td>
      <td>업무와 데이터 유형별 대표 자료·질문을 함께 선정</td>
      <td>범위 조사표, 요건, 초기 평가 문항</td>
    </tr>
    <tr>
      <td>개발</td>
      <td>단순한 검색 구성을 기준으로 분할·임베딩·검색 설정 비교</td>
      <td>개발용 평가 결과, 오류 분류</td>
    </tr>
    <tr>
      <td>테스트·이행</td>
      <td>고정된 최종 평가 데이터셋으로 품질·부하·권한·개정 반영 확인</td>
      <td>통과 여부와 실패 문항, 실행 설정</td>
    </tr>
    <tr>
      <td>운영</td>
      <td>실제 질문과 실패 사례를 검토해 다음 평가 데이터셋에 반영</td>
      <td>회귀 사례, 새 평가 데이터셋 버전, 변경 이력</td>
    </tr>
  </tbody>
</table>

<p>평균 점수와 함께 질문 유형별 점수와 표본 수를 봅니다. 예를 들어 100문항 중 90문항이 통과했어도, 실패한 10문항이 모두 권한 제한이나 문서 개정에 관한 질문이면 통과율 90%만으로 합격으로 보기 어렵습니다. 일반 질문의 품질 목표와 별개로 권한 위반처럼 허용하지 않을 실패 조건을 정해야 합니다.</p>

<h3 id="실패한-문항은-데이터-경로를-따라-확인합니다">실패한 문항은 데이터 경로를 따라 확인합니다</h3>

<p>아래는 문서 추출, 검색, 컨텍스트 구성, 답변 생성 순서로 데이터 경로를 따라가는 진단표입니다. 관찰한 현상에서 먼저 확인할 지점을 정하는 용도이며, 원인을 하나로 단정하는 표는 아닙니다.</p>

<table>
  <thead>
    <tr>
      <th>관찰한 현상</th>
      <th>먼저 확인할 지점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>원문에는 답이 있지만 추출한 텍스트에는 없음</td>
      <td>파서·OCR·표·각주 처리</td>
    </tr>
    <tr>
      <td>추출한 텍스트에는 있지만 검색 결과에는 없음</td>
      <td>청크 분할·질문 표현·임베딩·필터·검색 파라미터</td>
    </tr>
    <tr>
      <td>검색했지만 LLM에 전달한 컨텍스트에는 없음</td>
      <td>재순위화·중복 제거·토큰 예산</td>
    </tr>
    <tr>
      <td>필요한 근거를 전달했는데 답변이 틀림</td>
      <td>프롬프트·모델의 해석·상충하는 근거</td>
    </tr>
    <tr>
      <td>답변은 맞는데 출처가 엉뚱함</td>
      <td>문서·청크·인용 구절의 연결</td>
    </tr>
    <tr>
      <td>개정·삭제된 문서가 계속 나옴</td>
      <td>동기화·버전 필터·캐시 무효화</td>
    </tr>
  </tbody>
</table>

<p>비교 실행에는 문서 스냅샷, 파서·청크 설정, 임베딩·재순위화·생성 모델, 검색 설정, 프롬프트와 평가 데이터셋·평가기의 버전을 남깁니다. 검색 결과와 실제 전달한 컨텍스트도 기록해야, 같은 질문에서 결과가 달라졌을 때 어느 단계가 바뀌었는지 확인할 수 있습니다.</p>

<p>문서 갱신은 적재 완료 표시만 보고 끝내지 않습니다. 실제 질의에서 새 내용이 검색되고, 삭제된 내용이 사라지는지 확인합니다. 예를 들어 Bedrock Knowledge Bases는 원천 자료의 추가·수정·삭제 뒤 동기화가 필요하며, 저장소에 따라 동기화 완료와 검색 가능 시점 사이에 지연이 생길 수 있다고 안내합니다.<sup id="fnref:sync" role="doc-noteref"><a href="#fn:sync" class="footnote" rel="footnote">14</a></sup></p>

<p>권한 변경도 별도로 검증해야 합니다. 인덱스에 저장한 권한 정보를 이용한다면 원천 권한이 바뀌어도 동기화 전에는 이전 값으로 판단할 수 있습니다. Azure AI Search의 권한 문서도 이 동기화 경계를 명시합니다.<sup id="fnref:access:1" role="doc-noteref"><a href="#fn:access" class="footnote" rel="footnote">3</a></sup> 긴급한 접근 철회가 필요한 서비스라면 검색 시 현재 권한을 다시 확인하는 등 서비스 요구에 맞는 보완 방법을 정합니다.</p>

<h2 id="정리">정리</h2>

<p>평가 데이터셋은 질문과 정답 문장만으로 끝나지 않고, 정답 근거와 기대 동작을 함께 담습니다. 질문에 필요한 근거를 알아야 검색 실패와 생성 실패를 나눠서 확인할 수 있고, 최종 평가 문항을 설정 조정에 쓰지 않아야 새 질문에 대한 성능을 잴 수 있습니다.</p>

<p>처음에는 대표 질문과 정답 근거가 연결된 작은 평가 데이터셋으로 시작할 수 있습니다. 그 평가 데이터셋으로 문서 추출, 검색, 컨텍스트 구성, 답변 생성을 차례로 확인하면 다음에 고칠 지점이 드러납니다. 문항 수를 늘릴 때도 같은 질문을 반복하기보다, 새로운 업무 유형과 실제 실패 사례를 추가하는 것이 좋습니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:rag" role="doc-endnote">
      <p><a href="https://aws.amazon.com/what-is/retrieval-augmented-generation/">AWS — What is Retrieval-Augmented Generation?</a>. 외부 자료 검색과 프롬프트 보강, 모델 재학습 없이 구성하는 RAG의 역할. <a href="#fnref:rag" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:test-generation" role="doc-endnote">
      <p><a href="https://docs.ragas.io/en/latest/concepts/test_data_generation/rag/">Ragas — Testset Generation for RAG</a>. 단일·복수 근거 질문, 구체·추상 질문과 시나리오 기반 생성. <a href="#fnref:test-generation" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:access" role="doc-endnote">
      <p><a href="https://learn.microsoft.com/en-us/azure/search/search-document-level-access-overview">Microsoft — Document-Level Access Control</a>. 호출자 신원에 따른 검색 제한, 청크 권한 메타데이터, 원천 권한 변경과 인덱스 동기화의 경계. <a href="#fnref:access" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:access:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:preparation" role="doc-endnote">
      <p><a href="https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-preparation-phase">Microsoft — Develop a RAG Solution: Preparation Phase</a>. 문서 유형·구조·권한 분석, 질문·근거·답변의 연결, 독립적인 합성 질문 생성과 전문가 검토. <a href="#fnref:preparation" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:injection" role="doc-endnote">
      <p><a href="https://cheatsheetseries.owasp.org/cheatsheets/LLM_Prompt_Injection_Prevention_Cheat_Sheet.html">OWASP — LLM Prompt Injection Prevention Cheat Sheet</a>. 외부 문서를 통한 간접 인젝션, 지시와 데이터의 분리, 최소 권한과 도구 호출 검증. <a href="#fnref:injection" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:cross-validation" role="doc-endnote">
      <p><a href="https://scikit-learn.org/stable/modules/cross_validation.html">scikit-learn — Cross-Validation: Evaluating Estimator Performance</a>. 개발·최종 평가 분리, 그룹·시간 기반 평가. 본문의 문서 계열·티켓·문서 스냅샷 사례는 이 원칙을 RAG에 적용한 제안. <a href="#fnref:cross-validation" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:cross-validation:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:cross-validation:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:evaluation" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base-evaluation-metrics.html">Amazon Bedrock — Use Metrics to Understand RAG System Performance</a>. 검색·검색+생성 평가의 구분, 정확성·완전성·충실성·인용 지표. <a href="#fnref:evaluation" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:evaluation:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:ranking" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval">Elasticsearch — Ranking Evaluation</a>, <a href="https://nlp.stanford.edu/IR-book/html/htmledition/evaluation-of-ranked-retrieval-results-1.html">Manning·Raghavan·Schütze — Evaluation of Ranked Retrieval Results</a>. Precision@k, Recall@k, MRR, DCG·nDCG와 관련성 판정, 미판정 결과 처리. <a href="#fnref:ranking" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:ranking:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:embedding" role="doc-endnote">
      <p><a href="https://sbert.net/examples/sentence_transformer/applications/semantic-search/README.html">Sentence Transformers — Semantic Search</a>. 질문·문서의 벡터 공간, 대칭·비대칭 검색, 인코딩 경로, ANN의 recall·속도 절충. <a href="#fnref:embedding" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:context-recall" role="doc-endnote">
      <p><a href="https://docs.ragas.io/en/latest/concepts/metrics/available_metrics/context_recall/">Ragas — Context Recall</a>. 참고 답변의 주장 기반 평가와 ID 기반 평가의 차이. <a href="#fnref:context-recall" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:context-precision" role="doc-endnote">
      <p><a href="https://docs.ragas.io/en/latest/concepts/metrics/available_metrics/context_precision/">Ragas — Context Precision</a>. 순위를 반영하는 구현과 ID 기반 비율의 차이. <a href="#fnref:context-precision" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:faithfulness" role="doc-endnote">
      <p><a href="https://docs.ragas.io/en/latest/concepts/metrics/available_metrics/faithfulness/">Ragas — Faithfulness</a>. 생성 답변의 주장이 제공된 컨텍스트로 뒷받침되는지를 평가하는 정의. <a href="#fnref:faithfulness" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:judge" role="doc-endnote">
      <p><a href="https://arxiv.org/abs/2306.05685">Zheng 외 — Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena</a>. NeurIPS 2023. LLM 평가자의 순서·장황함·자기 선호 편향과 평가 한계. <a href="#fnref:judge" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:sync" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/bedrock/latest/userguide/kb-data-source-sync-ingest.html">Amazon Bedrock — Sync Your Data with Your Knowledge Base</a>. 문서 추가·수정·삭제의 증분 동기화와 검색 반영 시점. <a href="#fnref:sync" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="ai/ml" /><summary type="html"><![CDATA[Gold 데이터와 QA로 평가 데이터셋을 만들고, 데이터를 나누는 기준과 검색 품질·답변 품질을 따로 평가하는 방법, 운영하며 평가 데이터셋을 넓히는 순서를 다룹니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RAG를 위한 데이터 준비: 범위·요건 분석부터 전처리·피처 설계까지</title><link href="https://rastalion.dev/writing/rag-data-preparation/" rel="alternate" type="text/html" title="RAG를 위한 데이터 준비: 범위·요건 분석부터 전처리·피처 설계까지" /><published>2026-09-27T15:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://rastalion.dev/writing/rag-data-preparation</id><content type="html" xml:base="https://rastalion.dev/writing/rag-data-preparation/"><![CDATA[<p><strong>시리즈</strong> · <strong>1. 데이터 범위·요건·전처리·피처</strong> · <a href="/writing/rag-evaluation/">2. 평가 데이터셋과 품질 평가</a></p>

<p><strong>관련 글</strong> · <a href="/writing/vector-database-overview/">벡터 DB의 개념과 제품</a> · <a href="/writing/vector-database-selection/">선택 기준과 기능 비교</a> · <a href="/writing/aws-vector-search/">AWS 전용 벡터 검색</a></p>

<p>사내 운영 문서를 검색하는 챗봇을 만든다고 생각해 봅시다. 사용자가 “야간에 DB 장애가 나면 어디에 먼저 알려야 하나요?”라고 물었습니다. 검색 결과에는 개정 전 대응 지침과 현재 지침이 함께 나오고, 정작 당직 연락 절차가 있는 PDF의 표는 텍스트로 제대로 추출되지 않았습니다. 모델이 매끄러운 문장을 만들어도 이 상태에서는 올바른 답을 기대하기 어렵습니다.</p>

<p>앞선 글에서는 벡터 DB 제품과 AWS의 벡터 검색 서비스를 비교했습니다. 이번에는 저장소에 넣을 데이터를 어떻게 준비하고, 그 데이터로 만든 서비스가 제대로 동작하는지 어떻게 확인할지 두 편으로 나눠 살펴봅니다. <strong>데이터 범위·유형 분석 → 요건 정의 → 피처 설계 → 학습·평가 데이터셋 확보</strong>라는 흐름을 바탕으로, 문서 검색과 RAG에 필요한 작업을 구체화합니다. 이 글은 데이터 범위·요건·전처리·피처 설계까지 다루고, 평가 데이터셋과 평가 방법은 <a href="/writing/rag-evaluation/">다음 글</a>에서 다룹니다.</p>

<p>이 글의 조사 기준일은 2026년 9월 27일입니다. 도입부의 챗봇 사례와 표의 업무 상황은 설명을 위해 만든 가상 예시이며, 용량 수치는 계산 예시입니다. 실제 서비스의 성능을 측정한 결과는 아닙니다.</p>

<h2 id="1-검색-문서와-학습평가-데이터는-어떻게-다를까요">1. 검색 문서와 학습·평가 데이터는 어떻게 다를까요</h2>

<p>프로젝트에서 “데이터셋을 만든다”고 할 때 가리키는 일부터 구분해야 합니다. 검색할 원본 문서를 준비하는 일, 모델을 학습시키는 일, 결과를 평가하는 일은 목적이 다릅니다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>담는 내용</th>
      <th>사용하는 시점</th>
      <th>확인할 사항</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>검색 문서 집합(corpus)</td>
      <td>업무 문서, 청크, 출처, 문서 버전, 접근 권한</td>
      <td>사용자의 질문에 필요한 근거를 검색할 때</td>
      <td>근거의 정확성·최신성·검색 가능 여부</td>
    </tr>
    <tr>
      <td>학습 데이터셋</td>
      <td>질문·답변, 관련 문서 쌍, 선호도 등 학습 목적에 맞는 예제</td>
      <td>생성 모델·임베딩 모델·재순위화 모델을 실제로 학습할 때</td>
      <td>학습 대상과 입력·출력 형식</td>
    </tr>
    <tr>
      <td>평가 데이터셋</td>
      <td>질문, 기대 동작, 정답 근거, 참고 답변, 채점 기준</td>
      <td>검색·생성 결과를 비교할 때</td>
      <td>평가 기준의 정확성·대표성·독립성</td>
    </tr>
  </tbody>
</table>

<p><strong>이미 학습된 임베딩 모델과 LLM을 사용하는 일반적인 RAG는 별도의 모델 학습 없이 구성할 수 있습니다.</strong> 문서를 벡터로 변환해 색인하는 작업이 곧 LLM의 파라미터를 학습시키는 것은 아닙니다. RAG는 검색한 외부 자료를 질문과 함께 모델에 전달하는 방식이며, 필요에 따라 모델 학습을 추가할 수 있습니다.<sup id="fnref:rag" role="doc-noteref"><a href="#fn:rag" class="footnote" rel="footnote">1</a></sup></p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 940 520" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vector-rag-flow-archify-diagram-title">RAG의 색인과 질의 흐름</title>
        <desc id="vector-rag-flow-archify-diagram-description">A data-flow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vector-rag-flow-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vector-rag-flow-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vector-rag-flow-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vector-rag-flow-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vector-rag-flow-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vector-rag-flow-grid)" />

        <!-- Data Stages -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="0" x="16" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="100" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">01 / 입력</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="1" x="231" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="315" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">02 / 임베딩</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="2" x="446" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="530" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">03 / 검색</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="3" x="661" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="745" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">04 / 결합·생성</text>

        <!-- Flow paths -->
        <path data-edge-from="docs" data-edge-to="chunk_embed" data-edge-label="원문" data-edge-key="0" data-composition-points="156,157;259,157" d="M 156 157 L 259 157" class="a-default" stroke-width="1.4" marker-end="url(#vector-rag-flow-arrowhead)" />
        <path data-edge-from="chunk_embed" data-edge-to="vector_db" data-edge-label="벡터 저장" data-edge-key="1" data-composition-points="371,157;422.5,157;422.5,264;474,264" d="M 371 157 L 422.5 157 L 422.5 264 L 474 264" class="a-default" stroke-width="1.4" marker-end="url(#vector-rag-flow-arrowhead)" />
        <path data-edge-from="question" data-edge-to="query_embed" data-edge-label="질문" data-edge-key="2" data-composition-points="156,385;259,385" d="M 156 385 L 259 385" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
        <path data-edge-from="query_embed" data-edge-to="vector_db" data-edge-label="Top-k · 권한 필터" data-edge-key="3" data-composition-points="371,385;422.5,385;422.5,278;474,278" d="M 371 385 L 422.5 385 L 422.5 278 L 474 278" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
        <path data-edge-from="vector_db" data-edge-to="rerank" data-edge-label="후보 조각" data-edge-key="4" data-composition-points="586,271;689,271" d="M 586 271 L 689 271" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
        <path data-edge-from="rerank" data-edge-to="llm" data-edge-label="질문 + 관련 문서" data-edge-key="5" data-composition-points="745,300;745,356" d="M 745 300 L 745 356" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />

        <!-- Nodes -->
        <g id="vector-rag-flow-node-docs" data-node-id="docs" data-node-label="원본 문서" tabindex="0" role="button" aria-label="Focus 원본 문서, 규정·설명서·장애 기록, 01 / 입력" aria-pressed="false" data-node-kind="external" data-node-sublabel="규정·설명서·장애 기록" data-node-context="01 / 입력">
          <title>원본 문서 · 규정·설명서·장애 기록 · 01 / 입력</title>
          <rect x="44" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="44" y="128" width="112" height="58" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(50 134) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">원본 문서</text>
          <text data-detail="context" x="100" y="165" class="t-muted" font-size="7" text-anchor="middle">규정·설명서·장애 기록</text>
        </g>

        <g id="vector-rag-flow-node-chunk_embed" data-node-id="chunk_embed" data-node-label="분할·임베딩" tabindex="0" role="button" aria-label="Focus 분할·임베딩, 조각마다 벡터 생성, 02 / 임베딩" aria-pressed="false" data-node-kind="backend" data-node-sublabel="조각마다 벡터 생성" data-node-context="02 / 임베딩">
          <title>분할·임베딩 · 조각마다 벡터 생성 · 02 / 임베딩</title>
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 134) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">분할·임베딩</text>
          <text data-detail="context" x="315" y="165" class="t-muted" font-size="7" text-anchor="middle">조각마다 벡터 생성</text>
        </g>

        <g id="vector-rag-flow-node-question" data-node-id="question" data-node-label="사용자 질문" tabindex="0" role="button" aria-label="Focus 사용자 질문, 01 / 입력" aria-pressed="false" data-node-kind="frontend" data-node-context="01 / 입력">
          <title>사용자 질문 · 01 / 입력</title>
          <rect x="44" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="44" y="356" width="112" height="58" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(50 362) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" x="100" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">사용자 질문</text>
        </g>

        <g id="vector-rag-flow-node-query_embed" data-node-id="query_embed" data-node-label="질문 임베딩" tabindex="0" role="button" aria-label="Focus 질문 임베딩, 호환되는 인코더, 02 / 임베딩" aria-pressed="false" data-node-kind="backend" data-node-sublabel="호환되는 인코더" data-node-context="02 / 임베딩">
          <title>질문 임베딩 · 호환되는 인코더 · 02 / 임베딩</title>
          <rect x="259" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="356" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 362) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">질문 임베딩</text>
          <text data-detail="context" x="315" y="393" class="t-muted" font-size="7" text-anchor="middle">호환되는 인코더</text>
        </g>

        <g id="vector-rag-flow-node-vector_db" data-node-id="vector_db" data-node-label="벡터 DB" tabindex="0" role="button" aria-label="Focus 벡터 DB, 벡터·원본 ID·메타데이터, 03 / 검색" aria-pressed="false" data-node-kind="database" data-node-sublabel="벡터·원본 ID·메타데이터" data-node-context="03 / 검색">
          <title>벡터 DB · 벡터·원본 ID·메타데이터 · 03 / 검색</title>
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(480 248) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">벡터 DB</text>
          <text data-detail="context" x="530" y="279" class="t-muted" font-size="7" text-anchor="middle">벡터·원본 ID·메타데이터</text>
        </g>

        <g id="vector-rag-flow-node-rerank" data-node-id="rerank" data-node-label="결합·재순위화" tabindex="0" role="button" aria-label="Focus 결합·재순위화, 키워드 결과 (필요시), 04 / 결합·생성" aria-pressed="false" data-node-kind="backend" data-node-sublabel="키워드 결과 (필요시)" data-node-context="04 / 결합·생성">
          <title>결합·재순위화 · 키워드 결과 (필요시) · 04 / 결합·생성</title>
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(695 248) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">결합·재순위화</text>
          <text data-detail="context" x="745" y="279" class="t-muted" font-size="7" text-anchor="middle">키워드 결과 (필요시)</text>
        </g>

        <g id="vector-rag-flow-node-llm" data-node-id="llm" data-node-label="LLM" tabindex="0" role="button" aria-label="Focus LLM, 답변 생성, 04 / 결합·생성" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="답변 생성" data-node-context="04 / 결합·생성">
          <title>LLM · 답변 생성 · 04 / 결합·생성</title>
          <rect x="689" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="356" width="112" height="58" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(695 362) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">LLM</text>
          <text data-detail="context" x="745" y="393" class="t-muted" font-size="7" text-anchor="middle">답변 생성</text>
        </g>

        <!-- Flow labels -->
        <g data-detail="context" data-edge-from="docs" data-edge-to="chunk_embed" data-edge-label="원문" data-edge-key="0">
          <rect x="190.5" y="136" width="34" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="147" class="t-muted" font-size="8" text-anchor="middle">원문</text>
        </g>
        <g data-detail="context" data-edge-from="chunk_embed" data-edge-to="vector_db" data-edge-label="벡터 저장" data-edge-key="1">
          <rect x="394.45" y="189.5" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="200.5" class="t-muted" font-size="8" text-anchor="middle">벡터 저장</text>
        </g>
        <g data-detail="context" data-edge-from="question" data-edge-to="query_embed" data-edge-label="질문" data-edge-key="2">
          <rect x="190.5" y="364" width="34" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="375" class="t-backend" font-size="8" text-anchor="middle">질문</text>
        </g>
        <g data-detail="context" data-edge-from="query_embed" data-edge-to="vector_db" data-edge-label="Top-k · 권한 필터" data-edge-key="3">
          <rect x="374.85" y="310.5" width="95.3" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="321.5" class="t-backend" font-size="8" text-anchor="middle">Top-k · 권한 필터</text>
        </g>
        <g data-detail="context" data-edge-from="vector_db" data-edge-to="rerank" data-edge-label="후보 조각" data-edge-key="4">
          <rect x="609.45" y="250" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="637.5" y="261" class="t-backend" font-size="8" text-anchor="middle">후보 조각</text>
        </g>
        <g data-detail="context" data-edge-from="rerank" data-edge-to="llm" data-edge-label="질문 + 관련 문서" data-edge-key="5">
          <rect x="699.8" y="303" width="90.4" height="16" rx="4" class="c-mask" />
          <text x="745" y="314" class="t-backend" font-size="8" text-anchor="middle">질문 + 관련 문서</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="464" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="emphasis" data-legend-x="40" data-legend-baseline="484" data-legend-width="63">
            <path d="M 40 481 L 74 481" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
            <text x="83" y="484" class="t-muted" font-size="10" font-weight="500">질의</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="벡터 저장소" data-legend-x="125" data-legend-baseline="484" data-legend-width="98">
            <rect x="125" y="476" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="147" y="484" class="t-muted" font-size="10" font-weight="500">벡터 저장소</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="245" data-legend-baseline="484" data-legend-width="63">
            <path d="M 245 481 L 279 481" class="a-default" stroke-width="1.4" marker-end="url(#vector-rag-flow-arrowhead)" />
            <text x="288" y="484" class="t-muted" font-size="10" font-weight="500">색인</text>
          </g>
        </g>
      </svg>
</div><figcaption>검색 문서는 색인 경로로 들어가고, 질문은 검색과 답변 생성 경로를 거칩니다.</figcaption>
</figure>

<p>질문·답변(QA) 데이터도 사용 목적에 따라 역할이 달라집니다. 같은 형식의 QA를 파인튜닝에 넣으면 학습 데이터이고, 생성 결과를 채점하는 기준으로 쓰면 평가 데이터입니다. 평가용 정답을 서비스의 프롬프트 예제나 학습 자료에 넣은 뒤 같은 문항으로 성능을 재면, 새로운 질문에 얼마나 잘 답하는지 제대로 평가하기 어렵습니다.<sup id="fnref:leakage" role="doc-noteref"><a href="#fn:leakage" class="footnote" rel="footnote">2</a></sup></p>

<p>따라서 시작할 때는 “학습 데이터 몇 만 건이 필요하다”보다 <strong>어떤 질문에 답할 것이며, 어떤 문서를 근거로 답하고, 무엇을 맞히면 성공으로 볼 것인지</strong>부터 정하는 편이 좋습니다.</p>

<h2 id="2-데이터-범위와-규모는-어떻게-조사할까요">2. 데이터 범위와 규모는 어떻게 조사할까요</h2>

<p>먼저 시스템과 업무 영역을 나누고, 각 영역의 데이터 유형과 건수를 조사합니다. 이 목록이 수집 범위와 일정·인력 산정의 출발점이 됩니다. RAG 프로젝트에서는 여기에 수집 경로, 갱신 방식, 문서 소유자와 권한을 함께 적으면 실제 구현으로 연결하기 쉽습니다.</p>

<p>다음은 운영 문서 챗봇을 위한 조사표 예시입니다.</p>

<table>
  <thead>
    <tr>
      <th>데이터 소스</th>
      <th>형식·수량 단위</th>
      <th>수집 경로·갱신·권한</th>
      <th>먼저 확인할 내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>사내 Wiki</td>
      <td>HTML, 문서 수</td>
      <td>API·내보내기, 수시 개정, 팀별 권한</td>
      <td>승인본, 첨부 자료, 문서 이력</td>
    </tr>
    <tr>
      <td>업무 지침</td>
      <td>HWP·DOCX, 파일·페이지 수</td>
      <td>승인 문서 저장소, 개정 승인 시 갱신</td>
      <td>제목 계층, 각주, 적용 시점</td>
    </tr>
    <tr>
      <td>장애 티켓</td>
      <td>JSON·RDB, 티켓·행 수</td>
      <td>API·읽기 전용 조회, 계속 추가·수정</td>
      <td>해결 여부, 중복 티켓, 개인정보</td>
    </tr>
    <tr>
      <td>제품 매뉴얼</td>
      <td>PDF, 파일·페이지 수</td>
      <td>파일 저장소, 제품 버전별 개정</td>
      <td>스캔 여부, 표, 다단 편집</td>
    </tr>
    <tr>
      <td>교육 자료</td>
      <td>PPT, 파일·슬라이드 수</td>
      <td>문서 저장소, 교육 과정별 갱신</td>
      <td>도형·그림 속 글자, 발표자 노트</td>
    </tr>
    <tr>
      <td>운영 현황</td>
      <td>RDB, 테이블·행 수</td>
      <td>조회 API·SQL, 현재 상태가 중요</td>
      <td>문서 검색으로 답할지 직접 조회할지</td>
    </tr>
  </tbody>
</table>

<p>파일 수와 페이지 수, 테이블 수와 행 수는 서로 다른 단위입니다. PDF 300개라는 숫자만으로 OCR과 표 추출의 작업량을 알 수 없고, RDB 테이블 20개라는 숫자만으로 검색할 레코드 수를 알 수 없습니다. 업무 범위에 포함되는 데이터인지 담당자와 합의한 뒤, <strong>유형별 대표 자료를 실제로 열어 수집·변환 가능성을 확인</strong>해야 합니다.</p>

<p>Microsoft의 RAG 준비 가이드도 업무 분류, 파일 형식, 보안 제약, 문서 구조를 함께 조사하도록 안내합니다. 파일 안의 이미지와 표, 문서에 연결된 외부 자료도 조사 대상입니다.<sup id="fnref:preparation" role="doc-noteref"><a href="#fn:preparation" class="footnote" rel="footnote">3</a></sup></p>

<h3 id="파일-수를-벡터-수로-바꿔-봐야-합니다">파일 수를 벡터 수로 바꿔 봐야 합니다</h3>

<p>청크마다 벡터를 하나씩 만든다면 예상 벡터 수는 다음과 같이 계산할 수 있습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>예상 벡터 수 = 문서 수 × 문서당 평균 청크 수
벡터 값의 크기 = 벡터 수 × 차원 수 × 차원당 바이트 수
</code></pre></div></div>

<p>예를 들어 문서 10,000개가 평균 12개 청크로 나뉘고, 청크마다 1,024차원 float32 벡터 하나를 만든다고 가정해 보겠습니다. 벡터는 120,000개이고, 벡터 값만 <strong>491,520,000바이트, 즉 491.52MB(468.75MiB)</strong>입니다.</p>

<p>이 값에는 원문, 메타데이터, 인덱스 구조, 복제본, 백업이 들어 있지 않습니다. 이미지와 본문에 벡터를 각각 만들면 벡터 수도 달라집니다. 먼저 대표 자료에서 청크 수를 측정하고, 그 결과로 전체 용량을 추정해야 합니다. 초기 적재량과 별개로 하루에 추가·수정·삭제되는 양도 조사표에 남깁니다.</p>

<h2 id="3-데이터-요건은-검증할-수-있게-적습니다">3. 데이터 요건은 검증할 수 있게 적습니다</h2>

<p>요건은 데이터 변환, 서비스, 보안, 품질로 나눌 수 있습니다. “잘 변환한다”, “정확하게 답한다”처럼 해석이 갈리는 문장을 실제로 확인할 수 있는 조건으로 바꾸는 것이 좋습니다. 아래 표는 네 범주에 들어 있는 입력·출력, 성능, 최신성을 따로 떼어 일곱 범주로 나눈 예시입니다.</p>

<table>
  <thead>
    <tr>
      <th>범주</th>
      <th>요건 예시</th>
      <th>확인 방법</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>데이터 변환</td>
      <td>제목·절·표·각주의 관계를 보존하고 원본 위치를 추적할 수 있다</td>
      <td>대표 문서의 추출 결과를 원문과 대조</td>
    </tr>
    <tr>
      <td>서비스</td>
      <td>답변에 근거 문서와 위치를 표시하고, 근거가 없으면 부족한 정보를 안내한다</td>
      <td>출처 링크·인용 구절·응답 형식 검사</td>
    </tr>
    <tr>
      <td>입력·출력</td>
      <td>지원할 입력이 텍스트인지 이미지까지인지, 답변 길이와 형식을 정의한다</td>
      <td>입력 유형별 사례와 기대 출력 비교</td>
    </tr>
    <tr>
      <td>보안</td>
      <td>로그인한 사용자가 읽을 수 있는 자료만 검색·답변에 사용한다</td>
      <td>서로 다른 권한의 계정으로 동일 질문 실행</td>
    </tr>
    <tr>
      <td>품질</td>
      <td>필수 사실과 조건을 맞히고, 근거에 없는 절차를 만들지 않는다</td>
      <td>고정된 평가 문항과 채점표로 확인</td>
    </tr>
    <tr>
      <td>성능</td>
      <td>검색 시간, 첫 토큰까지의 시간, 답변 완료 시간을 구분한다</td>
      <td>부하 조건과 함께 지연 분포 측정</td>
    </tr>
    <tr>
      <td>최신성</td>
      <td>문서 개정·삭제·권한 변경을 합의된 시간 안에 반영한다</td>
      <td>원천 변경부터 실제 검색 반영까지 측정</td>
    </tr>
  </tbody>
</table>

<p>“응답 시간 2초 이내”라고만 적으면 첫 토큰이 나오는 시점인지, 답변이 끝나는 시점인지 알 수 없습니다. 평균인지 p95인지에 따라서도 뜻이 달라집니다. p95는 측정한 요청의 약 95%가 해당 시간 안에 끝났다는 뜻입니다.<sup id="fnref:latency" role="doc-noteref"><a href="#fn:latency" class="footnote" rel="footnote">4</a></sup> 동시 요청 수, 입력·출력 길이, 캐시 사용 조건과 함께 목표를 적어야 같은 조건에서 확인할 수 있습니다.</p>

<p>“정확도 90% 이상”도 마찬가지입니다. 어떤 문항을 몇 개 평가했는지, 완전 정답과 부분 정답을 어떻게 나눴는지, 답하면 안 되는 질문을 어떻게 채점했는지도 함께 적어야 합니다. <a href="/writing/rag-evaluation/">다음 글</a>에서 다룰 검색 Recall과 답변 정확성을 하나의 정확도라는 이름으로 합치지 않는 것이 좋습니다.</p>

<h3 id="암호화와-접근-권한은-각각-확인해야-합니다">암호화와 접근 권한은 각각 확인해야 합니다</h3>

<p>저장 데이터를 암호화해도, 검색 시스템이 다른 팀의 문서를 가져와 LLM에 전달하면 권한 문제가 남습니다. 문서 권한을 청크에 연결하고, 서버가 확인한 사용자·그룹 정보로 검색 결과를 제한해야 합니다. Azure AI Search의 보안 필터도 호출자 신원과 문서에 저장한 권한 정보를 비교하는 방식입니다.<sup id="fnref:access" role="doc-noteref"><a href="#fn:access" class="footnote" rel="footnote">5</a></sup></p>

<p>질문·답변 기록의 보관 범위와 열람 권한도 정해야 합니다. 원문에서 개인정보를 가렸더라도 검색 로그, 평가 데이터, 답변 기록에 같은 정보가 남을 수 있기 때문입니다. 외부 OCR·임베딩·LLM 서비스에 전달할 수 있는 자료인지도 수집 단계에서 확인할 항목입니다.<sup id="fnref:preparation:1" role="doc-noteref"><a href="#fn:preparation" class="footnote" rel="footnote">3</a></sup></p>

<h2 id="4-전처리는-무엇을-지우고-무엇을-남길까요">4. 전처리는 무엇을 지우고 무엇을 남길까요</h2>

<p>문서를 Markdown으로 바꾸거나 특수문자를 제거했다고 해서 검색에 적합한 데이터가 되는 것은 아닙니다. 전처리의 목표는 <strong>답에 필요한 의미와 근거 위치를 보존하는 것</strong>입니다.</p>

<p>주석·목차·특수문자 제거 같은 전처리 규칙은 문서의 성격에 따라 적용해야 합니다. 예를 들어 <code class="language-plaintext highlighter-rouge">max_connections</code>의 밑줄이나 <code class="language-plaintext highlighter-rouge">&gt;=</code>의 비교 기호를 없애면 기술 문서의 의미가 바뀝니다. 본문 아래의 각주에 적용 제외 조건이 적혀 있다면 각주도 답의 근거입니다. Microsoft의 준비 가이드 역시 각주·주석·머리말 등을 일괄 삭제하기보다 관련성이 있는지 판단하도록 설명합니다.<sup id="fnref:preparation:2" role="doc-noteref"><a href="#fn:preparation" class="footnote" rel="footnote">3</a></sup></p>

<table>
  <thead>
    <tr>
      <th>대상</th>
      <th>보존할 내용</th>
      <th>잘못 처리했을 때 생기는 문제</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>제목·절</td>
      <td>제목 계층, 현재 절의 상위 문맥</td>
      <td>“예외 사항”이 어떤 절차의 예외인지 알 수 없음</td>
    </tr>
    <tr>
      <td>표</td>
      <td>행·열 머리글, 단위, 병합 셀의 관계</td>
      <td>숫자는 남지만 어떤 조건의 값인지 사라짐</td>
    </tr>
    <tr>
      <td>그림·도표</td>
      <td>원본 위치, 캡션, 필요한 주변 설명</td>
      <td>이미지 설명만으로 원문을 확인할 수 없음</td>
    </tr>
    <tr>
      <td>코드·명령어</td>
      <td>공백, 기호, 식별자, 버전 조건</td>
      <td>명령의 의미나 실행 조건이 달라짐</td>
    </tr>
    <tr>
      <td>각주·주석</td>
      <td>적용 범위와 예외를 설명하는 내용</td>
      <td>본문만 읽고 조건을 빠뜨린 답변 생성</td>
    </tr>
    <tr>
      <td>링크</td>
      <td>원본 식별자와 해석 가능한 경로</td>
      <td>문서를 옮기면 인용 링크가 깨짐</td>
    </tr>
  </tbody>
</table>

<p>표를 HTML이나 Markdown으로 바꿀 때도 변환 후 관계가 남는지 확인해야 합니다. 긴 표를 나눈다면 각 조각에서 열 머리글과 단위를 알 수 있게 합니다. 이미지 설명을 LLM으로 생성했다면 원본에서 추출한 텍스트와 생성한 설명을 구분해 두어야 합니다. 그래야 설명에 오류가 생겼을 때 되짚을 수 있습니다.<sup id="fnref:chunking" role="doc-noteref"><a href="#fn:chunking" class="footnote" rel="footnote">6</a></sup></p>

<p>전처리된 텍스트만 남기기보다 원본과 그 위치 정보를 함께 보관하는 편이 좋습니다. 원본 문서 ID, 문서 버전, 페이지·절·문자 범위 등을 연결해 두면 청크를 다시 나누거나 파서를 바꿀 때도 이전 결과와 대조할 수 있습니다.</p>

<h3 id="청크-크기는-평가-질문으로-결정합니다">청크 크기는 평가 질문으로 결정합니다</h3>

<p>작은 청크는 문맥을 놓칠 수 있고, 큰 청크는 무관한 내용과 토큰 비용을 늘릴 수 있습니다. 어떤 크기가 적절한지는 문서 구조와 질문에 따라 다릅니다.<sup id="fnref:chunking:1" role="doc-noteref"><a href="#fn:chunking" class="footnote" rel="footnote">6</a></sup></p>

<p>예를 들어 “야간 장애의 최초 연락 대상”을 답하려면 짧은 절 하나로 충분할 수 있습니다. “장애 등급별 보고 대상과 보고 기한”을 비교하려면 표 전체나 여러 절이 필요할 수 있습니다. 분할 방식을 비교할 대표 질문을 먼저 만들고, 필요한 근거가 검색·재순위화·컨텍스트 구성 뒤에도 남는지 보면서 분할 방식을 비교합니다.</p>

<p>청크 길이는 사용할 모델의 토큰 제한에 맞춰 확인합니다. “500글자”와 “500토큰”을 같은 것으로 다루지 않습니다. 검색할 후보 수와 LLM에 최종 전달할 청크 수도 따로 정합니다. 검색 후보를 많이 가져왔다고 그 내용을 모두 모델에 넣어야 하는 것은 아닙니다.</p>

<h2 id="5-피처-설계는-rag에서-어떻게-달라질까요">5. 피처 설계는 RAG에서 어떻게 달라질까요</h2>

<p>피처 설계(feature engineering)는 중요한 피처를 선택하고, 새 피처를 만들고, 모델이 사용할 형태로 변환하는 작업입니다. 데이터 엔지니어링이 수집·처리·저장의 흐름을 만든다면, 피처 설계는 그 데이터에서 모델에 필요한 표현을 만드는 작업으로 볼 수 있습니다.</p>

<p>일반적인 머신러닝에서는 상관관계나 모델의 중요도로 피처를 고르거나, 날짜에서 요일을 만들고, 수치에 스케일링·로그 변환을 적용할 수 있습니다. 피처 선택은 모델의 정확도나 계산 성능을 개선하기 위해 사용하는 방법이며, 효과는 데이터와 모델에 따라 확인해야 합니다.<sup id="fnref:features" role="doc-noteref"><a href="#fn:features" class="footnote" rel="footnote">7</a></sup></p>

<p>문서 RAG에서는 이를 다음과 같이 적용할 수 있습니다. 아래 표는 정형 데이터의 피처 기법을 그대로 옮긴 규칙이 아니라, 검색 설계에 맞춰 바꾼 예시입니다.</p>

<table>
  <thead>
    <tr>
      <th>작업</th>
      <th>문서 RAG에서의 예시</th>
      <th>확인할 사항</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>선택</td>
      <td>제목·본문·제품 버전 중 검색에 쓸 내용을 정함</td>
      <td>답에 필요한 조건을 제외하지 않았는가</td>
    </tr>
    <tr>
      <td>생성</td>
      <td>상위 절 제목, 업무 분류, 제품 식별자를 청크에 연결</td>
      <td>원문에서 온 값인지 추론한 값인지 구분하는가</td>
    </tr>
    <tr>
      <td>변환</td>
      <td>본문을 임베딩하고 날짜·버전을 필터 가능한 값으로 정리</td>
      <td>질문·문서의 표현과 필터 타입이 일관적인가</td>
    </tr>
  </tbody>
</table>

<p>의미 검색에 넣을 본문과 <strong>정확하게 비교해야 하는 메타데이터</strong>도 나눕니다. 제품 버전, 적용일, 문서 상태, 테넌트와 접근 권한은 문장에만 섞어 넣기보다 명시적인 필드로 관리할 수 있습니다. 특히 접근 권한은 LLM이 추측해서 만든 업무 분류로 결정하지 않고 원천 권한 체계와 연결해야 합니다.<sup id="fnref:access:1" role="doc-noteref"><a href="#fn:access" class="footnote" rel="footnote">5</a></sup></p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 940 520" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="rag-chunk-record-archify-diagram-title">원본 문서가 청크 레코드가 되는 구조</title>
        <desc id="rag-chunk-record-archify-diagram-description">A data-flow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="rag-chunk-record-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="rag-chunk-record-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="rag-chunk-record-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="rag-chunk-record-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="rag-chunk-record-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#rag-chunk-record-grid)" />

        <!-- Data Stages -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="0" x="16" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="100" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">01 / 원본</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="1" x="231" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="315" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">02 / 추출 · 분할</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="2" x="446" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="530" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">03 / 청크 레코드</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="3" x="661" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="745" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">04 / 벡터 DB 필드</text>

        <!-- Flow paths -->
        <path data-edge-from="source" data-edge-to="extract" data-edge-label="원문" data-edge-key="0" data-composition-points="156,271;259,271" d="M 156 271 L 259 271" class="a-default" stroke-width="1.4" marker-end="url(#rag-chunk-record-arrowhead)" />
        <path data-edge-from="extract" data-edge-to="location" data-edge-label="위치 정보" data-edge-key="1" data-composition-points="371,271;385,271;385,157;474,157" d="M 371 271 L 385 271 L 385 157 L 474 157" class="a-default" stroke-width="1.4" marker-end="url(#rag-chunk-record-arrowhead)" />
        <path data-edge-from="extract" data-edge-to="text" data-edge-label="본문 텍스트" data-edge-key="2" data-composition-points="371,271;474,271" d="M 371 271 L 474 271" class="a-default" stroke-width="1.4" marker-end="url(#rag-chunk-record-arrowhead)" />
        <path data-edge-from="extract" data-edge-to="metadata" data-edge-label="버전 · 적용일 · 권한" data-edge-key="3" data-composition-points="371,271;385,271;385,385;474,385" d="M 371 271 L 385 271 L 385 385 L 474 385" class="a-default" stroke-width="1.4" marker-end="url(#rag-chunk-record-arrowhead)" />
        <path data-edge-from="text" data-edge-to="f_vector" data-edge-label="임베딩" data-edge-key="4" data-composition-points="586,271;689,271" d="M 586 271 L 689 271" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-chunk-record-arrowhead-emphasis)" />
        <path data-edge-from="location" data-edge-to="f_store" data-edge-label="위치 저장" data-edge-key="5" data-composition-points="586,157;689,157" d="M 586 157 L 689 157" class="a-default" stroke-width="1.4" marker-end="url(#rag-chunk-record-arrowhead)" />
        <path data-edge-from="metadata" data-edge-to="f_filter" data-edge-label="필터 값" data-edge-key="6" data-composition-points="586,385;689,385" d="M 586 385 L 689 385" class="a-default" stroke-width="1.4" marker-end="url(#rag-chunk-record-arrowhead)" />

        <!-- Nodes -->
        <g id="rag-chunk-record-node-source" data-node-id="source" data-node-label="원본 문서" tabindex="0" role="button" aria-label="Focus 원본 문서, 문서 ID · 버전 · 권한, 01 / 원본" aria-pressed="false" data-node-kind="external" data-node-sublabel="문서 ID · 버전 · 권한" data-node-context="01 / 원본">
          <title>원본 문서 · 문서 ID · 버전 · 권한 · 01 / 원본</title>
          <rect x="44" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="44" y="242" width="112" height="58" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(50 248) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">원본 문서</text>
          <text data-detail="context" x="100" y="279" class="t-muted" font-size="7" text-anchor="middle">문서 ID · 버전 · 권한</text>
        </g>

        <g id="rag-chunk-record-node-extract" data-node-id="extract" data-node-label="추출 · 분할" tabindex="0" role="button" aria-label="Focus 추출 · 분할, 제목 · 표 · 각주 보존, 02 / 추출 · 분할" aria-pressed="false" data-node-kind="messagebus" data-node-sublabel="제목 · 표 · 각주 보존" data-node-context="02 / 추출 · 분할">
          <title>추출 · 분할 · 제목 · 표 · 각주 보존 · 02 / 추출 · 분할</title>
          <rect x="259" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="242" width="112" height="58" rx="6" class="c-messagebus" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="messagebus" class="semantic-sigil s-messagebus" transform="translate(265 248) scale(0.6875)">
            <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
            <circle cx="5" cy="4.5" r="1" class="sigil-fill" />
            <circle cx="10.5" cy="8" r="1" class="sigil-fill" />
            <circle cx="7" cy="11.5" r="1" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">추출 · 분할</text>
          <text data-detail="context" x="315" y="279" class="t-muted" font-size="7" text-anchor="middle">제목 · 표 · 각주 보존</text>
        </g>

        <g id="rag-chunk-record-node-location" data-node-id="location" data-node-label="원본 위치" tabindex="0" role="button" aria-label="Focus 원본 위치, 페이지 · 절 · 문자 범위, 03 / 청크 레코드" aria-pressed="false" data-node-kind="backend" data-node-sublabel="페이지 · 절 · 문자 범위" data-node-context="03 / 청크 레코드">
          <title>원본 위치 · 페이지 · 절 · 문자 범위 · 03 / 청크 레코드</title>
          <rect x="474" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="128" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(480 134) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">원본 위치</text>
          <text data-detail="context" x="530" y="165" class="t-muted" font-size="7" text-anchor="middle">페이지 · 절 · 문자 범위</text>
        </g>

        <g id="rag-chunk-record-node-text" data-node-id="text" data-node-label="본문" tabindex="0" role="button" aria-label="Focus 본문, 상위 절 제목 연결, 03 / 청크 레코드" aria-pressed="false" data-node-kind="backend" data-node-sublabel="상위 절 제목 연결" data-node-context="03 / 청크 레코드">
          <title>본문 · 상위 절 제목 연결 · 03 / 청크 레코드</title>
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(480 248) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">본문</text>
          <text data-detail="context" x="530" y="279" class="t-muted" font-size="7" text-anchor="middle">상위 절 제목 연결</text>
        </g>

        <g id="rag-chunk-record-node-metadata" data-node-id="metadata" data-node-label="메타데이터" tabindex="0" role="button" aria-label="Focus 메타데이터, 버전 · 적용일 · 권한, 03 / 청크 레코드" aria-pressed="false" data-node-kind="backend" data-node-sublabel="버전 · 적용일 · 권한" data-node-context="03 / 청크 레코드">
          <title>메타데이터 · 버전 · 적용일 · 권한 · 03 / 청크 레코드</title>
          <rect x="474" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="356" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(480 362) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">메타데이터</text>
          <text data-detail="context" x="530" y="393" class="t-muted" font-size="7" text-anchor="middle">버전 · 적용일 · 권한</text>
        </g>

        <g id="rag-chunk-record-node-f_store" data-node-id="f_store" data-node-label="저장 필드" tabindex="0" role="button" aria-label="Focus 저장 필드, 인용 · 재분할 대조, 04 / 벡터 DB 필드" aria-pressed="false" data-node-kind="database" data-node-sublabel="인용 · 재분할 대조" data-node-context="04 / 벡터 DB 필드">
          <title>저장 필드 · 인용 · 재분할 대조 · 04 / 벡터 DB 필드</title>
          <rect x="689" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="128" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(695 134) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">저장 필드</text>
          <text data-detail="context" x="745" y="165" class="t-muted" font-size="7" text-anchor="middle">인용 · 재분할 대조</text>
        </g>

        <g id="rag-chunk-record-node-f_vector" data-node-id="f_vector" data-node-label="벡터 필드" tabindex="0" role="button" aria-label="Focus 벡터 필드, 의미 검색, 04 / 벡터 DB 필드" aria-pressed="false" data-node-kind="database" data-node-sublabel="의미 검색" data-node-context="04 / 벡터 DB 필드">
          <title>벡터 필드 · 의미 검색 · 04 / 벡터 DB 필드</title>
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(695 248) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">벡터 필드</text>
          <text data-detail="context" x="745" y="279" class="t-muted" font-size="7" text-anchor="middle">의미 검색</text>
        </g>

        <g id="rag-chunk-record-node-f_filter" data-node-id="f_filter" data-node-label="필터 필드" tabindex="0" role="button" aria-label="Focus 필터 필드, 정확 비교 · 권한, 04 / 벡터 DB 필드" aria-pressed="false" data-node-kind="database" data-node-sublabel="정확 비교 · 권한" data-node-context="04 / 벡터 DB 필드">
          <title>필터 필드 · 정확 비교 · 권한 · 04 / 벡터 DB 필드</title>
          <rect x="689" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="356" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(695 362) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">필터 필드</text>
          <text data-detail="context" x="745" y="393" class="t-muted" font-size="7" text-anchor="middle">정확 비교 · 권한</text>
        </g>

        <!-- Flow labels -->
        <g data-detail="context" data-edge-from="source" data-edge-to="extract" data-edge-label="원문" data-edge-key="0">
          <rect x="190.5" y="250" width="34" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="261" class="t-muted" font-size="8" text-anchor="middle">원문</text>
        </g>
        <g data-detail="context" data-edge-from="extract" data-edge-to="location" data-edge-label="위치 정보" data-edge-key="1">
          <rect x="356.95" y="193" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="385" y="204" class="t-muted" font-size="8" text-anchor="middle">위치 정보</text>
        </g>
        <g data-detail="context" data-edge-from="extract" data-edge-to="text" data-edge-label="본문 텍스트" data-edge-key="2">
          <rect x="389.55" y="250" width="65.9" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="261" class="t-muted" font-size="8" text-anchor="middle">본문 텍스트</text>
        </g>
        <g data-detail="context" data-edge-from="extract" data-edge-to="metadata" data-edge-label="버전 · 적용일 · 권한" data-edge-key="3">
          <rect x="330" y="307" width="110" height="16" rx="4" class="c-mask" />
          <text x="385" y="318" class="t-muted" font-size="8" text-anchor="middle">버전 · 적용일 · 권한</text>
        </g>
        <g data-detail="context" data-edge-from="text" data-edge-to="f_vector" data-edge-label="임베딩" data-edge-key="4">
          <rect x="616.8" y="250" width="41.4" height="16" rx="4" class="c-mask" />
          <text x="637.5" y="261" class="t-backend" font-size="8" text-anchor="middle">임베딩</text>
        </g>
        <g data-detail="context" data-edge-from="location" data-edge-to="f_store" data-edge-label="위치 저장" data-edge-key="5">
          <rect x="609.45" y="136" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="637.5" y="147" class="t-muted" font-size="8" text-anchor="middle">위치 저장</text>
        </g>
        <g data-detail="context" data-edge-from="metadata" data-edge-to="f_filter" data-edge-label="필터 값" data-edge-key="6">
          <rect x="614.35" y="364" width="46.3" height="16" rx="4" class="c-mask" />
          <text x="637.5" y="375" class="t-muted" font-size="8" text-anchor="middle">필터 값</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="464" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="emphasis" data-legend-x="40" data-legend-baseline="484" data-legend-width="113">
            <path d="M 40 481 L 74 481" class="a-emphasis" stroke-width="1.8" marker-end="url(#rag-chunk-record-arrowhead-emphasis)" />
            <text x="83" y="484" class="t-muted" font-size="10" font-weight="500">의미 검색 경로</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="색인 필드" data-legend-x="175" data-legend-baseline="484" data-legend-width="88">
            <rect x="175" y="476" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="197" y="484" class="t-muted" font-size="10" font-weight="500">색인 필드</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="285" data-legend-baseline="484" data-legend-width="98">
            <path d="M 285 481 L 319 481" class="a-default" stroke-width="1.4" marker-end="url(#rag-chunk-record-arrowhead)" />
            <text x="328" y="484" class="t-muted" font-size="10" font-weight="500">데이터 전달</text>
          </g>
        </g>
      </svg>
</div><figcaption>청크마다 원본 위치·본문·메타데이터를 담아 벡터 DB의 저장·벡터·필터 필드에 나눠 넣습니다.</figcaption>
</figure>

<h3 id="임베딩-차원을-일반-열처럼-지우면-안-됩니다">임베딩 차원을 일반 열처럼 지우면 안 됩니다</h3>

<p>기존 임베딩 벡터의 일부 차원을 임의로 버리는 것을 일반적인 피처 선택과 동일하게 보면 안 됩니다. 보통의 밀집 임베딩은 정해진 차원으로 출력됩니다. Matryoshka처럼 차원을 줄여도 유용하도록 학습한 모델이 있지만, 그 경우에도 지원하는 방법과 검색 품질을 확인해야 합니다.<sup id="fnref:matryoshka" role="doc-noteref"><a href="#fn:matryoshka" class="footnote" rel="footnote">8</a></sup></p>

<p>질문과 문서는 호환되는 벡터 공간에 있어야 합니다. 짧은 질문으로 긴 문서를 찾는 비대칭 검색에서는 모델에 따라 질문용·문서용 인코딩 경로와 프롬프트가 다를 수 있습니다. Sentence Transformers도 이러한 구분을 안내합니다.<sup id="fnref:embedding" role="doc-noteref"><a href="#fn:embedding" class="footnote" rel="footnote">9</a></sup></p>

<p>스케일러나 차원 축소처럼 데이터에서 변환 규칙을 학습한다면 최종 평가 데이터를 그 학습에 포함하지 않습니다. scikit-learn은 피처 선택·스케일링·PCA 등의 전처리도 데이터 누수를 일으킬 수 있다고 설명합니다.<sup id="fnref:leakage:1" role="doc-noteref"><a href="#fn:leakage" class="footnote" rel="footnote">2</a></sup></p>

<h2 id="정리">정리</h2>

<p>벡터 DB를 위한 데이터 준비는 파일을 변환하고 임베딩을 만드는 것보다 범위가 넓습니다. 어떤 업무를 지원할지 정하고, 답의 근거가 되는 자료를 찾아 구조·출처·버전·권한을 보존해야 합니다. 모델을 학습할 데이터와 서비스의 품질을 평가할 데이터도 구분해야 합니다.</p>

<p>평가 데이터셋을 만들고 검색 품질과 답변 품질을 나눠 평가하는 방법은 <a href="/writing/rag-evaluation/">다음 글</a>에서 이어집니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:rag" role="doc-endnote">
      <p><a href="https://aws.amazon.com/what-is/retrieval-augmented-generation/">AWS — What is Retrieval-Augmented Generation?</a>. 외부 자료 검색과 프롬프트 보강, 모델 재학습 없이 구성하는 RAG의 역할. <a href="#fnref:rag" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:leakage" role="doc-endnote">
      <p><a href="https://scikit-learn.org/stable/common_pitfalls.html">scikit-learn — Common Pitfalls and Recommended Practices</a>. 전처리의 일관성, 학습·평가 분리, 피처 선택·스케일링·PCA에서의 데이터 누수. <a href="#fnref:leakage" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:leakage:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:preparation" role="doc-endnote">
      <p><a href="https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-preparation-phase">Microsoft — Develop a RAG Solution: Preparation Phase</a>. 문서 유형·구조·권한 분석, 질문·근거·답변의 연결, 독립적인 합성 질문 생성과 전문가 검토. <a href="#fnref:preparation" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:preparation:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:preparation:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:latency" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Statistics-definitions.html">Amazon CloudWatch — Statistics Definitions</a>. 평균과 백분위수, p95의 정의. <a href="#fnref:latency" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:access" role="doc-endnote">
      <p><a href="https://learn.microsoft.com/en-us/azure/search/search-document-level-access-overview">Microsoft — Document-Level Access Control</a>. 호출자 신원에 따른 검색 제한, 청크 권한 메타데이터, 원천 권한 변경과 인덱스 동기화의 경계. <a href="#fnref:access" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:access:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:chunking" role="doc-endnote">
      <p><a href="https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-chunking-phase">Microsoft — Develop a RAG Solution: Chunking Phase</a>. 문서 구조와 이미지·표 처리, 청크 크기의 절충, 전처리 결과와 분할 전략 비교. <a href="#fnref:chunking" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:chunking:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:features" role="doc-endnote">
      <p><a href="https://scikit-learn.org/stable/modules/feature_selection.html">scikit-learn — Feature Selection</a>. 피처 선택의 목적과 방법. <a href="#fnref:features" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:matryoshka" role="doc-endnote">
      <p><a href="https://sbert.net/examples/sentence_transformer/training/matryoshka/README.html">Sentence Transformers — Matryoshka Embeddings</a>. 차원 축소를 고려한 임베딩 학습과 일반 임베딩의 구분. <a href="#fnref:matryoshka" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:embedding" role="doc-endnote">
      <p><a href="https://sbert.net/examples/sentence_transformer/applications/semantic-search/README.html">Sentence Transformers — Semantic Search</a>. 질문·문서의 벡터 공간, 대칭·비대칭 검색, 인코딩 경로, ANN의 recall·속도 절충. <a href="#fnref:embedding" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="ai/ml" /><summary type="html"><![CDATA[데이터 범위와 규모 조사, 검증할 수 있는 요건 정의, 의미와 근거 위치를 보존하는 전처리, 문서 RAG에 맞춘 피처 설계를 정리합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AWS에서만 쓰는 벡터 검색: S3 Vectors부터 Bedrock Knowledge Bases까지</title><link href="https://rastalion.dev/writing/aws-vector-search/" rel="alternate" type="text/html" title="AWS에서만 쓰는 벡터 검색: S3 Vectors부터 Bedrock Knowledge Bases까지" /><published>2026-09-27T01:00:00+00:00</published><updated>2026-09-27T00:00:00+00:00</updated><id>https://rastalion.dev/writing/aws-vector-search</id><content type="html" xml:base="https://rastalion.dev/writing/aws-vector-search/"><![CDATA[<p><strong>시리즈</strong> · <a href="/writing/vector-database-overview/">1. 개념과 제품·사용 지표</a> · <a href="/writing/vector-database-selection/">2. 선택 기준과 기능 비교</a> · <strong>3. AWS 전용 벡터 검색</strong> · <a href="/writing/vector-database-schema-design/">4. 스키마와 컬렉션 설계</a></p>

<p>AWS에서 사내 문서 RAG를 만든다고 생각해 봅시다. Bedrock Knowledge Bases로 지식 베이스를 만들 때 대신 만들어 주는 벡터 저장소는 S3 Vectors, OpenSearch Serverless, Aurora PostgreSQL Serverless, Neptune Analytics 네 가지입니다. 이미 운영하는 DynamoDB, MemoryDB, ElastiCache, DocumentDB에도 벡터 검색 기능이 있습니다. 모두 벡터 검색을 지원하지만 질의당 결과 수와 필터 방식, 확장 단위, 과금 단위는 서비스마다 다릅니다.</p>

<p>1편에서는 벡터 DB의 개념과 제품의 종류, 공개 사용 지표를, 2편에서는 어디서나 쓸 수 있는 오픈소스·상용 제품 12종의 기능과 한도를 다뤘습니다. 이 글은 AWS 관리형으로만 쓸 수 있는 벡터 검색 서비스를 2편과 같은 기준으로 비교합니다. 비교 대상은 S3 Vectors, OpenSearch Serverless, DynamoDB, MemoryDB, ElastiCache, DocumentDB, Neptune Analytics입니다. 엔진이 오픈소스인 OpenSearch Service 도메인과 Aurora·RDS for PostgreSQL(pgvector)은 2편과 달라지는 AWS 고유 기능만 다룹니다. Bedrock Knowledge Bases는 이 가운데 S3 Vectors, OpenSearch, Aurora PostgreSQL, Neptune Analytics를 저장소로 쓰면서 수집과 검색을 맡는 계층이라 따로 봅니다.</p>

<p>Amazon Kendra와 Amazon Q Business는 각각 2026년 7월 30일과 7월 31일부터 신규 고객을 받지 않아 비교 대상이 아닙니다. RDS for SQL Server·MariaDB·Oracle의 벡터 기능은 엔진 공급사의 기능이고, Bedrock AgentCore Memory의 의미 검색은 에이전트 메모리 전용이라 역시 비교 대상이 아닙니다.<sup id="fnref:retired" role="doc-noteref"><a href="#fn:retired" class="footnote" rel="footnote">1</a></sup></p>

<p>기능과 한도는 2026년 9월 27일 기준 AWS 공식 문서와 가격 페이지, 출시 발표에 적힌 내용을 따릅니다. AWS의 한도는 자주 바뀝니다. S3 Vectors의 질의당 최대 결과 수도 2026년 6월에 100에서 10,000으로 늘었습니다. 이 글은 성능을 측정해 비교하지 않으므로 속도 수치는 싣지 않고, 문서가 서비스 특성으로 밝힌 지연 범위만 인용합니다.</p>

<h2 id="1-aws의-벡터-검색은-어디에-들어-있을까요">1. AWS의 벡터 검색은 어디에 들어 있을까요</h2>

<p>AWS의 벡터 검색은 세 곳에 들어 있습니다. 벡터만 담는 저장소(S3 Vectors), 검색 엔진의 벡터 기능(OpenSearch Serverless·OpenSearch Service), 기존 데이터베이스에 붙은 벡터 기능(DynamoDB·MemoryDB·ElastiCache·DocumentDB·Neptune Analytics·Aurora)입니다.</p>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>기반</th>
      <th>배포 형태</th>
      <th>벡터 검색 GA</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S3 Vectors<sup id="fnref:s3v" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup></td>
      <td>객체 스토리지(S3의 벡터 버킷)</td>
      <td>서버리스, 프로비저닝 없음</td>
      <td>2025-12</td>
    </tr>
    <tr>
      <td>OpenSearch Serverless<sup id="fnref:oss" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup></td>
      <td>검색 엔진(OpenSearch의 k-NN)</td>
      <td>서버리스(OCU)</td>
      <td>2023-11, NextGen 세대 2026-05</td>
    </tr>
    <tr>
      <td>DynamoDB<sup id="fnref:ddb" role="doc-noteref"><a href="#fn:ddb" class="footnote" rel="footnote">4</a></sup></td>
      <td>NoSQL DB(테이블의 벡터 인덱스)</td>
      <td>서버리스(on-demand 용량 모드 테이블 전용)</td>
      <td>2026-08</td>
    </tr>
    <tr>
      <td>MemoryDB<sup id="fnref:mdb" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup></td>
      <td>인메모리 DB(7.1 이상, Valkey·Redis OSS 호환)</td>
      <td>노드 기반 클러스터</td>
      <td>2024-07</td>
    </tr>
    <tr>
      <td>ElastiCache<sup id="fnref:ec" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup></td>
      <td>인메모리 캐시(Valkey 8.2 이상)</td>
      <td>노드 기반 클러스터</td>
      <td>2025-10</td>
    </tr>
    <tr>
      <td>DocumentDB<sup id="fnref:docdb" role="doc-noteref"><a href="#fn:docdb" class="footnote" rel="footnote">7</a></sup></td>
      <td>문서 DB(MongoDB 호환, 5.0 이상)</td>
      <td>인스턴스 기반 클러스터</td>
      <td>2023-11</td>
    </tr>
    <tr>
      <td>Neptune Analytics<sup id="fnref:neptune" role="doc-noteref"><a href="#fn:neptune" class="footnote" rel="footnote">8</a></sup></td>
      <td>그래프 분석 엔진(인메모리)</td>
      <td>m-NCU 단위로 용량 지정</td>
      <td>2023-11</td>
    </tr>
  </tbody>
</table>

<p>문서가 서비스 특성으로 밝힌 지연도 자릿수가 다릅니다. S3 Vectors는 드물게 들어오는 질의에 1초 미만, 자주 들어오는 질의에 100밀리초 수준을 제시합니다. DynamoDB와 MemoryDB 문서는 한 자릿수 밀리초, ElastiCache 문서는 마이크로초 수준을 적습니다.<sup id="fnref:s3v:1" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:ddb:1" role="doc-noteref"><a href="#fn:ddb" class="footnote" rel="footnote">4</a></sup><sup id="fnref:mdb:1" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup><sup id="fnref:ec:1" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup></p>

<h2 id="2-인덱스와-차원은-어떻게-다를까요">2. 인덱스와 차원은 어떻게 다를까요</h2>

<p>인덱스 알고리즘은 사용자가 직접 고르는 곳과 그렇지 않은 곳으로 나뉩니다. MemoryDB와 ElastiCache는 FLAT과 HNSW 가운데서, DocumentDB는 HNSW와 IVFFlat 가운데서 고릅니다. S3 Vectors와 DynamoDB는 근사 최근접 이웃(ANN) 검색이라고만 밝히며 알고리즘 이름은 문서에 없습니다. OpenSearch Serverless의 NextGen 컬렉션은 엔진과 구성을 서비스가 자동으로 정합니다.</p>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>인덱스</th>
      <th>거리 척도</th>
      <th>최대 차원(벡터 타입)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S3 Vectors</td>
      <td>ANN(알고리즘 이름은 문서에 없음)</td>
      <td>코사인, 유클리드</td>
      <td>4,096(float32)</td>
    </tr>
    <tr>
      <td>OpenSearch Serverless</td>
      <td>NextGen은 자동 구성, Classic은 nmslib(기본)·Faiss HNSW</td>
      <td>유클리드, 코사인, 내적</td>
      <td>16,000</td>
    </tr>
    <tr>
      <td>DynamoDB</td>
      <td>ANN(알고리즘 이름은 문서에 없음)</td>
      <td>코사인, 내적, 유클리드</td>
      <td>4,096(float32 정밀도로 저장)</td>
    </tr>
    <tr>
      <td>MemoryDB</td>
      <td>FLAT(정확 검색), HNSW</td>
      <td>유클리드, 내적, 코사인</td>
      <td>32,768(float32)</td>
    </tr>
    <tr>
      <td>ElastiCache</td>
      <td>FLAT, HNSW</td>
      <td>코사인, 유클리드, 내적</td>
      <td>32,768</td>
    </tr>
    <tr>
      <td>DocumentDB</td>
      <td>HNSW(엔진 패치 3.0.4574부터 기본), IVFFlat, 인덱스가 없으면 정확 검색</td>
      <td>유클리드, 코사인, 내적</td>
      <td>인덱스 2,000, 인덱스 없이 저장 16,000</td>
    </tr>
    <tr>
      <td>Neptune Analytics</td>
      <td>HNSW, 그래프당 1개</td>
      <td>제곱 유클리드 거리(점수로 반환)</td>
      <td>1~65,535</td>
    </tr>
  </tbody>
</table>

<p>7종 가운데 사용자가 고르는 압축 선택지를 문서에 적은 곳은 OpenSearch Serverless뿐입니다. NextGen 컬렉션은 모든 인덱스를 기본 32배로 압축하고, 압축 수준을 <code class="language-plaintext highlighter-rouge">1x</code>·<code class="language-plaintext highlighter-rouge">2x</code>·<code class="language-plaintext highlighter-rouge">8x</code>·<code class="language-plaintext highlighter-rouge">16x</code>·<code class="language-plaintext highlighter-rouge">32x</code> 가운데서 다시 고르게 합니다. Classic 컬렉션은 FP16 스칼라 양자화, 이진 벡터, 디스크 기반 검색을 지원합니다. S3 Vectors와 MemoryDB는 float32 벡터만 받고, DynamoDB는 인덱스에 float32 정밀도로 저장합니다. S3 Vectors는 벡터 데이터를 자동으로 최적화한다고 설명하지만, 그 방식은 문서에 없습니다. 대부분의 데이터셋에서 평균 recall이 90% 이상이라고 하면서도, 대표 데이터로 직접 시험해 보라고 권합니다.<sup id="fnref:oss:1" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup><sup id="fnref:s3v:2" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:ddb:2" role="doc-noteref"><a href="#fn:ddb" class="footnote" rel="footnote">4</a></sup><sup id="fnref:mdb:2" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup></p>

<p>메모리 계획에서는 MemoryDB와 ElastiCache의 차이도 봐야 합니다. ElastiCache는 인덱스가 벡터 사본을 하나 더 두므로 그만큼 공간을 계획하라고 안내합니다. MemoryDB는 인덱스가 벡터 데이터의 중복 사본을 없앤다고 설명합니다.<sup id="fnref:ec:2" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup><sup id="fnref:mdb:3" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup></p>

<p>생성한 뒤에 바꿀 수 없는 설정도 많습니다. S3 Vectors 인덱스의 차원·거리 척도·필터 불가 메타데이터 키와 MemoryDB HNSW의 <code class="language-plaintext highlighter-rouge">M</code>·<code class="language-plaintext highlighter-rouge">EF_CONSTRUCTION</code>은 바꿀 수 없습니다. Neptune Analytics는 그래프를 생성할 때만 벡터 인덱스를 둘 수 있고, 차원도 그때 고정됩니다. MemoryDB는 기존 클러스터에서 벡터 검색을 켤 수 없어, 검색을 켠 설정으로 새 클러스터를 만들어야 합니다. 검색을 끈 클러스터의 스냅샷으로 검색을 켠 새 클러스터를 만들 수는 있습니다.<sup id="fnref:s3v:3" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:mdb:4" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup><sup id="fnref:neptune:1" role="doc-noteref"><a href="#fn:neptune" class="footnote" rel="footnote">8</a></sup></p>

<h2 id="3-한-번에-몇-개까지-받을-수-있을까요">3. 한 번에 몇 개까지 받을 수 있을까요</h2>

<p>비교 대상 7종과 Bedrock Knowledge Bases 가운데 질의당 결과 수(k)의 상한을 문서에 밝힌 곳은 S3 Vectors(10,000개)와 DynamoDB·Bedrock Knowledge Bases(100개)입니다. 나머지 서비스의 문서에는 k 상한이 없고, DocumentDB처럼 함께 묶인 파라미터가 사실상 한도가 되는 곳도 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>질의당 k 상한</th>
      <th>응답·페이지</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S3 Vectors</td>
      <td><code class="language-plaintext highlighter-rouge">topK</code> 10,000</td>
      <td>페이지당 100개, <code class="language-plaintext highlighter-rouge">nextToken</code>으로 이어 받음</td>
      <td>10,000을 쓰려면 최신 SDK 필요(2026년 6월 전에는 100), 응답은 기본으로 키만</td>
    </tr>
    <tr>
      <td>OpenSearch Serverless</td>
      <td>Serverless 문서에는 상한이 없음</td>
      <td>NextGen은 원본 벡터를 응답에서 기본으로 뺌</td>
      <td>OpenSearch Service 도메인 문서는 <code class="language-plaintext highlighter-rouge">k</code> 최대 10,000</td>
    </tr>
    <tr>
      <td>DynamoDB</td>
      <td><code class="language-plaintext highlighter-rouge">TopK</code> 1~100</td>
      <td>응답 16MB, 페이지네이션 없음</td>
      <td>벡터 속성은 기본으로 응답에서 뺌</td>
    </tr>
    <tr>
      <td>MemoryDB</td>
      <td><code class="language-plaintext highlighter-rouge">KNN</code> k의 상한은 문서에 없음</td>
      <td><code class="language-plaintext highlighter-rouge">LIMIT</code>을 생략하면 최대 10개</td>
      <td><code class="language-plaintext highlighter-rouge">TIMEOUT</code> 절 최대 10,000ms</td>
    </tr>
    <tr>
      <td>ElastiCache</td>
      <td><code class="language-plaintext highlighter-rouge">KNN</code> k의 상한은 문서에 없음</td>
      <td>샤드가 시간 초과되면 부분 결과(기본 켜짐)</td>
      <td><code class="language-plaintext highlighter-rouge">TIMEOUT</code> 절 최대 60,000ms</td>
    </tr>
    <tr>
      <td>DocumentDB</td>
      <td><code class="language-plaintext highlighter-rouge">$search</code>는 문서에 없음, <code class="language-plaintext highlighter-rouge">$vectorSearch</code>(8.0 이상)는 <code class="language-plaintext highlighter-rouge">limit</code> ≤ <code class="language-plaintext highlighter-rouge">numCandidates</code> ≤ 1,000</td>
      <td>문서에 없음</td>
      <td>HNSW <code class="language-plaintext highlighter-rouge">efSearch</code> 1~1,000(기본 40)</td>
    </tr>
    <tr>
      <td>Neptune Analytics</td>
      <td><code class="language-plaintext highlighter-rouge">topK</code> 기본 10, 상한은 문서에 없음</td>
      <td>결과는 노드와 점수</td>
      <td>질의 임베딩은 질의 문자열에 직접 넣음</td>
    </tr>
    <tr>
      <td>Bedrock Knowledge Bases</td>
      <td><code class="language-plaintext highlighter-rouge">numberOfResults</code> 1~100(기본 5)</td>
      <td><code class="language-plaintext highlighter-rouge">nextToken</code></td>
      <td>재순위화 뒤 개수도 1~100</td>
    </tr>
  </tbody>
</table>

<p>S3 Vectors를 직접 질의할 때와 OpenSearch Service를 거칠 때는 한도가 다릅니다. S3 Vectors의 <code class="language-plaintext highlighter-rouge">QueryVectors</code>는 <code class="language-plaintext highlighter-rouge">topK</code>를 10,000까지 받지만, OpenSearch Service 도메인의 <code class="language-plaintext highlighter-rouge">s3vector</code> 엔진으로 같은 벡터를 질의하면 <code class="language-plaintext highlighter-rouge">k</code>는 최대 100입니다. S3 Vectors의 페이지네이션 토큰은 질의 뒤 몇 분 동안 유효하고, 페이지를 받는 사이에 들어온 쓰기는 그 질의 세션에 반영되지 않습니다.<sup id="fnref:s3v:4" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:osm" role="doc-noteref"><a href="#fn:osm" class="footnote" rel="footnote">9</a></sup></p>

<p>DocumentDB의 <code class="language-plaintext highlighter-rouge">$vectorSearch</code>는 두 조건을 합치면 ANN 결과가 최대 1,000개입니다(<code class="language-plaintext highlighter-rouge">limit</code> ≤ <code class="language-plaintext highlighter-rouge">numCandidates</code> ≤ 1,000으로 계산한 값). Bedrock Knowledge Bases의 <code class="language-plaintext highlighter-rouge">numberOfResults</code>는 최대값일 뿐이라 실제 결과는 더 적을 수 있고, 계층형 청크를 쓰면 검색한 자식 청크를 부모 청크로 바꿔 돌려줍니다.<sup id="fnref:docdb:1" role="doc-noteref"><a href="#fn:docdb" class="footnote" rel="footnote">7</a></sup><sup id="fnref:kb" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<h2 id="4-필터와-하이브리드-검색-테넌트-격리는-어떻게-다를까요">4. 필터와 하이브리드 검색, 테넌트 격리는 어떻게 다를까요</h2>

<p>2편에서 본 필터 시점(사전 필터, 사후 필터, 필터 인지 탐색)으로 보면 AWS 문서의 설명 방식이 제각각입니다. S3 Vectors는 벡터 탐색과 필터 평가를 함께 한다고 설명하고, MemoryDB와 ElastiCache 문서는 조건을 만족하는 벡터에만 KNN을 적용한다고 적습니다. DocumentDB 문서에는 벡터 질의와 메타데이터 필터를 결합하는 방법이 없습니다. 아래 표는 2편의 분류에 끼워 맞추지 않고 각 문서의 설명 방식대로 적습니다.</p>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>필터</th>
      <th>k개가 안 찰 때</th>
      <th>하이브리드</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S3 Vectors</td>
      <td>탐색과 필터 평가를 함께 수행, string·number·boolean·list 메타데이터</td>
      <td>조건에 맞는 벡터가 아주 적으면 k개보다 적을 수 있음</td>
      <td>지원 안 함, 하이브리드가 필요하면 OpenSearch 연동을 안내</td>
    </tr>
    <tr>
      <td>OpenSearch Serverless</td>
      <td><code class="language-plaintext highlighter-rouge">knn</code> 질의 안의 <code class="language-plaintext highlighter-rouge">filter</code> 절, 적용 시점은 문서에 없음</td>
      <td>문서에 없음</td>
      <td>키워드·neural·k-NN 질의 결합(정규화 프로세서), neural sparse</td>
    </tr>
    <tr>
      <td>DynamoDB</td>
      <td>파티션 키(HASH)·인라인 필터 속성의 등호(=) 조건, 인라인 필터 최대 18개</td>
      <td>문서에 없음</td>
      <td>문서에 없음</td>
    </tr>
    <tr>
      <td>MemoryDB</td>
      <td>사전 필터(NUMERIC 범위·TAG)</td>
      <td>문서에 없음</td>
      <td>질의 연산자에 전문 검색 없음(TEXT 필드는 결과 표시용)</td>
    </tr>
    <tr>
      <td>ElastiCache</td>
      <td>사전 필터(TAG·NUMERIC, 9.0 이상은 전문 검색 포함)</td>
      <td>문서에 없음</td>
      <td>9.0 이상에서 전문 검색과 벡터를 한 질의로 결합</td>
    </tr>
    <tr>
      <td>DocumentDB</td>
      <td>벡터 질의와 필터를 결합하는 방법은 문서에 없음</td>
      <td>문서에 없음</td>
      <td>벡터와 전문 검색을 결합하는 방법은 문서에 없음</td>
    </tr>
    <tr>
      <td>Neptune Analytics</td>
      <td><code class="language-plaintext highlighter-rouge">vertexFilter</code>로 계산 중에 레이블·속성 필터(<code class="language-plaintext highlighter-rouge">topK.byEmbedding</code>만)</td>
      <td>문서에 없음</td>
      <td>벡터 검색 결과에서 그래프 순회로 확장</td>
    </tr>
  </tbody>
</table>

<p>7종 가운데 하이브리드 검색을 문서에 적은 곳은 OpenSearch Serverless와 ElastiCache입니다. OpenSearch Serverless는 2025년 8월부터 키워드·neural·k-NN 질의를 검색 파이프라인의 정규화 프로세서로 결합합니다. 이 기능은 11개 리전에서 제공되고 서울 리전은 목록에 없으며, 임베딩 모델은 커넥터로 연결한 원격 모델만 쓸 수 있습니다. ElastiCache는 Valkey 9.0부터 전문 검색과 벡터 검색을 한 질의로 결합합니다. AWS 예시는 텍스트로 먼저 거른 뒤 KNN으로 순위를 매깁니다. S3 Vectors는 하이브리드 검색·집계·다면 검색이 필요한 워크로드에 OpenSearch 연동을 안내합니다.<sup id="fnref:oss:2" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup><sup id="fnref:ec:3" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup><sup id="fnref:s3v:5" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup></p>

<p>테넌트 격리 방식은 서비스 구조를 따라갑니다. S3 Vectors는 테넌트마다 벡터 인덱스를 두고 IAM·버킷 정책으로 접근을 제한하는 방식을 안내하며, 인덱스마다 고객 관리형 KMS 키를 따로 지정할 수 있습니다. OpenSearch Serverless의 collection group은 KMS 키가 다른 컬렉션끼리 컴퓨팅 자원을 함께 쓰게 하는 기능이고, AWS 발표는 테넌트마다 컬렉션을 두는 멀티테넌트 앱을 주요 용례로 듭니다. NextGen은 그룹 하나에 컬렉션을 10,000개까지 둘 수 있습니다. ElastiCache는 사용자별 에이전트 메모리를 <code class="language-plaintext highlighter-rouge">@user_id</code> 같은 TAG로 먼저 거른 뒤 KNN 검색을 실행하는 방식을 권합니다.<sup id="fnref:s3v:6" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:oss:3" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup><sup id="fnref:ec:4" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup></p>

<h2 id="5-규모-한도는-어디서-막힐까요">5. 규모 한도는 어디서 막힐까요</h2>

<p>문서화된 규모 한도는 서비스 구조를 드러냅니다. S3 Vectors는 인덱스당 벡터 수와 요청률로, OpenSearch Serverless와 DynamoDB는 인덱스 수와 용량·전송률 상한으로, 노드 기반 서비스는 인덱스 수와 샤드·노드 구성으로 한도를 적습니다.</p>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>문서화된 한도</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S3 Vectors</td>
      <td>인덱스당 벡터 20억, 버킷당 인덱스 1만, 계정·리전당 버킷 1만, 인덱스당 쓰기 초당 1,000요청·2,500벡터</td>
      <td>요청률을 넘으면 429 오류, 질의는 인덱스당 초당 수백 건까지 가능하다고 안내(수치 한도 아님)</td>
    </tr>
    <tr>
      <td>OpenSearch Serverless</td>
      <td>컬렉션당 인덱스 1,000, 계정 OCU 인덱싱·검색 각 1,700(조정 불가)</td>
      <td>OCU 하나는 메모리 6GiB, Classic은 컬렉션당 hot 저장소 10TiB</td>
    </tr>
    <tr>
      <td>DynamoDB</td>
      <td>테이블당 벡터 인덱스 5, 파티션 키당 검색 1GBps·쓰기 10MBps(모두 조정 가능)</td>
      <td>별도 허용 목록(allowlist) 등록 없이 벡터 인덱스를 만들 수 있는 테이블은 600GB까지(조정 가능)</td>
    </tr>
    <tr>
      <td>MemoryDB</td>
      <td>인덱스 10, 인덱스당 필드 50, 단일 샤드</td>
      <td>문서 예시: 벡터 1,000만 개 × 1,536차원(M=16, 비벡터 데이터 0GB)에 데이터와 인덱스 1개로 104.9GB</td>
    </tr>
    <tr>
      <td>ElastiCache</td>
      <td>인덱스 1,000(9.0 이상)·10(8.2), 인덱스당 필드 1,000·50</td>
      <td>인덱스가 벡터 사본을 하나 더 둠</td>
    </tr>
    <tr>
      <td>DocumentDB</td>
      <td>컬렉션당 인덱스 64, 클러스터 256TiB(8.0 이상)</td>
      <td>IVFFlat의 <code class="language-plaintext highlighter-rouge">lists</code> 최대값이 인스턴스 유형과 차원에 묶임</td>
    </tr>
    <tr>
      <td>Neptune Analytics</td>
      <td>16~24,576 m-NCU(1 m-NCU ≈ 메모리 1GiB), 그래프당 대기 질의 8,192</td>
      <td>그래프의 모든 데이터를 담을 수 있는 m-NCU가 필요</td>
    </tr>
  </tbody>
</table>

<h3 id="1억-건--768차원이면-무엇이-달라질까요">1억 건 × 768차원이면 무엇이 달라질까요</h3>

<p>2편과 같은 조건(벡터 1억 개, 768차원, float32)으로 크기를 계산하려 해도, 공식 산정식을 둔 곳은 S3 Vectors뿐입니다. S3 가격 페이지는 벡터 한 개의 크기를 벡터 값(차원당 4바이트), 키(글자당 1바이트), 메타데이터의 합으로 셉니다. 1억 개 × 768차원이면 벡터 값만 307.2GB입니다(계산값).</p>

<p>질의 요금도 이 크기를 기준으로 합니다. S3 Vectors의 질의 요금은 요청 수, 처리한 데이터(TB), 반환한 데이터(GB)로 매기고, 처리한 데이터는 인덱스의 벡터 수에 평균 벡터 크기(벡터 값·키·필터 가능 메타데이터)를 곱해 계산합니다. 이 인덱스라면 질의 한 번마다 벡터 값만으로 약 0.31TB가 처리한 데이터로 잡힙니다(계산값). 처리한 데이터의 TB당 단가는 인덱스의 벡터 10만 개·1,000만 개를 경계로 구간이 나뉘고, 벡터가 많은 구간일수록 낮습니다. 한편 문서는 질의 성능을 위해 벡터를 여러 인덱스로 나누라고 권합니다.<sup id="fnref:s3v:7" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:pricing" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">11</a></sup></p>

<p>DynamoDB는 검색 요금을 매기는 기준 가운데 처리한 데이터가 항목 수에 비례하지 않는다고 설명합니다. 검색 한 번에 처리하는 데이터는 항목 수의 로그 함수 꼴로 커져, 항목 수가 10,000배가 되어도 1.5배 미만입니다. 처리하는 데이터에는 항목 수보다 차원 수가 더 크게 작용합니다.<sup id="fnref:pricing:1" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">11</a></sup></p>

<p>다른 서비스는 벡터 수·메모리 산정식을 문서에 두지 않습니다. MemoryDB는 벡터 1,000만 개 × 1,536차원(M=16, 비벡터 데이터 0GB)을 데이터와 인덱스 1개로 담는 데 104.9GB가 든다는 예시와 콘솔 계산기를 제시하고, ElastiCache는 에이전트 메모리 항목 하나에 약 4~6KB(차원 × 4바이트 + 메타데이터)라는 어림값을 적습니다. OpenSearch Serverless는 OCU 하나가 메모리 6GiB라는 구성만, Neptune Analytics는 그래프의 모든 데이터를 담을 수 있는 m-NCU를 골라야 한다는 조건만 밝힙니다.<sup id="fnref:mdb:5" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup><sup id="fnref:ec:5" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup><sup id="fnref:oss:4" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup><sup id="fnref:neptune:2" role="doc-noteref"><a href="#fn:neptune" class="footnote" rel="footnote">8</a></sup></p>

<h2 id="6-확장과-과금은-어떻게-다를까요">6. 확장과 과금은 어떻게 다를까요</h2>

<p>확장 단위와 과금 단위는 함께 봐야 합니다. 서버리스 서비스는 쓴 만큼 과금하는 대신 요청률과 용량 단위에 상한이 있습니다. MemoryDB, ElastiCache, DocumentDB는 벡터 검색에 추가 요금을 받지 않고 기존 요금 체계(노드·인스턴스 시간, 서비스별 저장·I/O 요금)를 그대로 적용합니다.<sup id="fnref:s3v:8" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:oss:5" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup><sup id="fnref:ddb:3" role="doc-noteref"><a href="#fn:ddb" class="footnote" rel="footnote">4</a></sup><sup id="fnref:mdb:6" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup><sup id="fnref:ec:6" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup><sup id="fnref:docdb:2" role="doc-noteref"><a href="#fn:docdb" class="footnote" rel="footnote">7</a></sup><sup id="fnref:neptune:3" role="doc-noteref"><a href="#fn:neptune" class="footnote" rel="footnote">8</a></sup><sup id="fnref:pricing:2" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">11</a></sup></p>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>확장 방식</th>
      <th>쓰기 반영</th>
      <th>과금 단위</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S3 Vectors</td>
      <td>프로비저닝 없음, 인덱스를 나눠 늘림</td>
      <td>강한 일관성, 쓰기 직후 조회 가능</td>
      <td>저장 GB-월(논리 크기), 쓰기 논리 GB(PUT당 최소 128KB), 질의 요청 수·처리 TB·반환 GB</td>
    </tr>
    <tr>
      <td>OpenSearch Serverless</td>
      <td>OCU 자동 확장(인덱싱·검색 분리), NextGen은 최소 OCU를 0으로 두면 그룹 전체가 10분 동안 요청이 없을 때 0 OCU</td>
      <td>NextGen 10초, Classic 60초</td>
      <td>OCU 시간, 저장 GB-월, GPU 가속 인덱스 빌드 OCU 시간</td>
    </tr>
    <tr>
      <td>DynamoDB</td>
      <td>on-demand 용량 모드</td>
      <td>최종 일관성(쓰기 뒤 검색에 나타나기까지 짧은 지연)</td>
      <td>벡터 쓰기 GB, 검색 처리·반환 GB, 인덱스 저장 GB-월(테이블 요금은 별도)</td>
    </tr>
    <tr>
      <td>MemoryDB</td>
      <td>수직·복제본 확장만(단일 샤드), Multi-Region 불가</td>
      <td>최종 일관성(백그라운드 스레드가 인덱스 갱신)</td>
      <td>노드 시간, 쓴 데이터 GB, 스냅샷 GB-월</td>
    </tr>
    <tr>
      <td>ElastiCache</td>
      <td>수평·수직·복제본 확장</td>
      <td>프라이머리는 쓰기를 색인한 뒤 응답(9.0 이상), 복제본은 최종 일관성</td>
      <td>노드 시간, 동기식 내구성 쓰기는 할증</td>
    </tr>
    <tr>
      <td>DocumentDB</td>
      <td>인스턴스 크기, 복제본 최대 15개, 서버리스 0.5~256 DCU</td>
      <td>클러스터 읽기 규칙: 프라이머리는 read-after-write, 복제본은 최종 일관성(벡터 인덱스 반영 시점은 문서에 없음)</td>
      <td>인스턴스(초 단위)·I/O·스토리지·백업, 서버리스는 DCU</td>
    </tr>
    <tr>
      <td>Neptune Analytics</td>
      <td>m-NCU 변경(수직), 복제본 0~2개</td>
      <td>임베딩 변경은 원자적이지 않고 쓰는 즉시 보임</td>
      <td>m-NCU 시간(복제본도 같은 요금), 일시 중지 중 컴퓨팅 요금의 10%</td>
    </tr>
  </tbody>
</table>

<p>수평 확장은 MemoryDB와 ElastiCache에서 갈립니다. 같은 Valkey 계열이지만 MemoryDB의 벡터 검색은 단일 샤드로 제한되어 샤드를 늘릴 수 없고, ElastiCache는 수평·수직·복제본 확장을 모두 지원합니다. ElastiCache 문서는 샤드를 늘리면 적재 처리량은 선형으로 늘지만 질의 처리량은 줄 수 있으니, 질의 처리량은 복제본이나 CPU로 늘리라고 안내합니다. DocumentDB도 샤딩하는 Elastic 클러스터에서는 벡터 검색을 쓸 수 없습니다.<sup id="fnref:mdb:7" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup><sup id="fnref:ec:7" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup><sup id="fnref:docdb:3" role="doc-noteref"><a href="#fn:docdb" class="footnote" rel="footnote">7</a></sup></p>

<p>OpenSearch Serverless의 NextGen은 최소 OCU를 0으로 둘 수 있습니다. 그룹의 모든 컬렉션에 10분 동안 요청이 없으면 0 OCU로 내려가 과금이 멈추고, 다시 요청이 오면 첫 요청에 10~30초의 지연이 생길 수 있다고 문서가 밝힙니다. Classic은 최소 OCU를 0으로 둘 수 없습니다.<sup id="fnref:oss:6" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup></p>

<p>Neptune Analytics는 그래프를 일시 중지하면 컴퓨팅 요금의 10%만 내고 데이터와 설정을 유지합니다.<sup id="fnref:pricing:3" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">11</a></sup> 벡터 임베딩 변경은 원자적이지 않아서, 질의가 나중에 실패해도 이미 쓴 임베딩은 남습니다.<sup id="fnref:neptune:4" role="doc-noteref"><a href="#fn:neptune" class="footnote" rel="footnote">8</a></sup></p>

<h2 id="7-opensearch-service와-aurora에서는-무엇이-달라질까요">7. OpenSearch Service와 Aurora에서는 무엇이 달라질까요</h2>

<p>두 서비스의 엔진 기능은 2편과 같습니다. AWS 관리형에서 쓸 때는 여기에 AWS 고유 기능과 제약이 더해집니다.</p>

<h3 id="opensearch-service-도메인">OpenSearch Service 도메인</h3>

<p><code class="language-plaintext highlighter-rouge">s3vector</code> 엔진은 벡터를 S3 Vectors 인덱스에 두고 나머지 필드는 도메인에 둡니다. OpenSearch 2.19 이상, OpenSearch Optimized 인스턴스, 최신 패치 버전이 조건이고, 가격 페이지는 벡터 1억 개를 이 엔진의 단일 샤드 k-NN 인덱스에 담는 예를 듭니다. 다만 다음 제약이 있습니다.<sup id="fnref:osm:1" role="doc-noteref"><a href="#fn:osm" class="footnote" rel="footnote">9</a></sup><sup id="fnref:pricing:4" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">11</a></sup></p>

<ul>
  <li><strong>질의:</strong> <code class="language-plaintext highlighter-rouge">k</code>는 최대 100이고, 필터는 사후 필터에 오버샘플링을 더해 적용합니다.</li>
  <li><strong>인덱스:</strong> 차원은 4,096까지, 거리 척도는 <code class="language-plaintext highlighter-rouge">l2</code>·<code class="language-plaintext highlighter-rouge">cosinesimil</code>만 지원합니다. 엔진은 인덱스를 만들 때만 지정할 수 있습니다.</li>
  <li><strong>기능:</strong> 스냅샷, UltraWarm 이동, 교차 클러스터 복제, split·shrink·clone을 쓸 수 없습니다.</li>
  <li><strong>운영:</strong> 엔진을 켜거나 끄면 도메인에 blue/green 배포가 일어납니다.</li>
</ul>

<p>S3 Vectors의 벡터를 OpenSearch에서 검색하는 방법은 하나 더 있습니다. OpenSearch Serverless 컬렉션으로 한 번 내보내는 방식입니다. OpenSearch Ingestion 파이프라인이 데이터를 복사하며, 복사한 뒤에는 동기화되지 않습니다. 데이터가 두 곳에 있는 동안은 두 서비스 요금이 함께 나옵니다.<sup id="fnref:s3v:9" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:oss:7" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup></p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 1010 404" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="aws-s3vectors-opensearch-archify-diagram-title">S3 Vectors의 벡터를 OpenSearch에서 검색하는 두 경로</title>
        <desc id="aws-s3vectors-opensearch-archify-diagram-description">A workflow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="aws-s3vectors-opensearch-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="aws-s3vectors-opensearch-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="aws-s3vectors-opensearch-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="aws-s3vectors-opensearch-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="aws-s3vectors-opensearch-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#aws-s3vectors-opensearch-grid)" />

        <!-- Swimlanes -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-0" x="40" y="52" width="954" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="74" class="t-dim" font-size="10" font-weight="600">01 / 내보내기 — OpenSearch Serverless로 복사</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-1" x="40" y="176" width="954" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="198" class="t-dim" font-size="10" font-weight="600">02 / 저장 계층 — OpenSearch Service의 s3vector 엔진</text>

        <!-- Phase headers -->


        <!-- Workflow groups -->


        <!-- Edge paths -->
        <path data-edge-from="engine_app" data-edge-to="engine_domain" data-edge-key="0" data-composition-points="198,243;226,243" d="M 198 243 L 226 243" class="a-default" stroke-width="1.4" marker-end="url(#aws-s3vectors-opensearch-arrowhead)" />
        <path data-edge-from="engine_domain" data-edge-to="engine_s3" data-edge-label="벡터를 실시간 이관" data-edge-key="1" data-composition-points="396,243;500.4,243" d="M 396 243 L 500.4 243" class="a-emphasis" stroke-width="1.8" marker-end="url(#aws-s3vectors-opensearch-arrowhead-emphasis)" />
        <path data-edge-from="export_osi" data-edge-to="export_col" data-edge-label="이후 동기화 없음" data-edge-key="2" data-composition-points="396,119;500.4,119" d="M 396 119 L 500.4 119" class="a-emphasis" stroke-width="1.8" marker-end="url(#aws-s3vectors-opensearch-arrowhead-emphasis)" />
        <path data-edge-from="export_src" data-edge-to="export_osi" data-edge-key="3" data-composition-points="198,119;226,119" d="M 198 119 L 226 119" class="a-default" stroke-width="1.4" marker-end="url(#aws-s3vectors-opensearch-arrowhead)" />

        <!-- Nodes -->
        <g id="aws-s3vectors-opensearch-node-export_src" data-node-id="export_src" data-node-label="S3 Vectors 인덱스" tabindex="0" role="button" aria-label="Focus S3 Vectors 인덱스, 원본 벡터, 내보내기 — OpenSearch Serverless로 복사" aria-pressed="false" data-node-kind="database" data-node-sublabel="원본 벡터" data-node-context="내보내기 — OpenSearch Serverless로 복사">
          <title>S3 Vectors 인덱스 · 원본 벡터 · 내보내기 — OpenSearch Serverless로 복사</title>
          <rect x="48" y="93" width="150" height="52" rx="6" class="c-mask" />
          <rect x="48" y="93" width="150" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(54 99) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="123" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">S3 Vectors 인덱스</text>
          <text data-detail="context" x="123" y="131" class="t-muted" font-size="8" text-anchor="middle">원본 벡터</text>
        </g>

        <g id="aws-s3vectors-opensearch-node-export_osi" data-node-id="export_osi" data-node-label="OpenSearch Ingestion" tabindex="0" role="button" aria-label="Focus OpenSearch Ingestion, 1회 복사, 내보내기 — OpenSearch Serverless로 복사" aria-pressed="false" data-node-kind="messagebus" data-node-sublabel="1회 복사" data-node-context="내보내기 — OpenSearch Serverless로 복사">
          <title>OpenSearch Ingestion · 1회 복사 · 내보내기 — OpenSearch Serverless로 복사</title>
          <rect x="226" y="93" width="170" height="52" rx="6" class="c-mask" />
          <rect x="226" y="93" width="170" height="52" rx="6" class="c-messagebus" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="messagebus" class="semantic-sigil s-messagebus" transform="translate(232 99) scale(0.6875)">
            <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
            <circle cx="5" cy="4.5" r="1" class="sigil-fill" />
            <circle cx="10.5" cy="8" r="1" class="sigil-fill" />
            <circle cx="7" cy="11.5" r="1" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="311" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">OpenSearch Ingestion</text>
          <text data-detail="context" x="311" y="131" class="t-muted" font-size="8" text-anchor="middle">1회 복사</text>
        </g>

        <g id="aws-s3vectors-opensearch-node-export_col" data-node-id="export_col" data-node-label="Serverless 컬렉션" tabindex="0" role="button" aria-label="Focus Serverless 컬렉션, 하이브리드·집계, 내보내기 — OpenSearch Serverless로 복사" aria-pressed="false" data-node-kind="backend" data-node-sublabel="하이브리드·집계" data-node-context="내보내기 — OpenSearch Serverless로 복사">
          <title>Serverless 컬렉션 · 하이브리드·집계 · 내보내기 — OpenSearch Serverless로 복사</title>
          <rect x="500.4" y="93" width="150" height="52" rx="6" class="c-mask" />
          <rect x="500.4" y="93" width="150" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(506.4 99) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="575.4" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Serverless 컬렉션</text>
          <text data-detail="context" x="575.4" y="131" class="t-muted" font-size="8" text-anchor="middle">하이브리드·집계</text>
        </g>

        <g id="aws-s3vectors-opensearch-node-engine_app" data-node-id="engine_app" data-node-label="색인·검색 요청" tabindex="0" role="button" aria-label="Focus 색인·검색 요청, _bulk · _search, 저장 계층 — OpenSearch Service의 s3vector 엔진" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="_bulk · _search" data-node-context="저장 계층 — OpenSearch Service의 s3vector 엔진">
          <title>색인·검색 요청 · _bulk · _search · 저장 계층 — OpenSearch Service의 s3vector 엔진</title>
          <rect x="48" y="217" width="150" height="52" rx="6" class="c-mask" />
          <rect x="48" y="217" width="150" height="52" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(54 223) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="123" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">색인·검색 요청</text>
          <text data-detail="context" x="123" y="255" class="t-muted" font-size="8" text-anchor="middle">_bulk · _search</text>
        </g>

        <g id="aws-s3vectors-opensearch-node-engine_domain" data-node-id="engine_domain" data-node-label="OpenSearch 도메인" tabindex="0" role="button" aria-label="Focus OpenSearch 도메인, k ≤ 100 · 사후 필터, 저장 계층 — OpenSearch Service의 s3vector 엔진" aria-pressed="false" data-node-kind="backend" data-node-sublabel="k ≤ 100 · 사후 필터" data-node-context="저장 계층 — OpenSearch Service의 s3vector 엔진">
          <title>OpenSearch 도메인 · k ≤ 100 · 사후 필터 · 저장 계층 — OpenSearch Service의 s3vector 엔진</title>
          <rect x="226" y="217" width="170" height="52" rx="6" class="c-mask" />
          <rect x="226" y="217" width="170" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(232 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="311" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">OpenSearch 도메인</text>
          <text data-detail="context" x="311" y="255" class="t-muted" font-size="8" text-anchor="middle">k ≤ 100 · 사후 필터</text>
        </g>

        <g id="aws-s3vectors-opensearch-node-engine_s3" data-node-id="engine_s3" data-node-label="S3 Vectors 인덱스" tabindex="0" role="button" aria-label="Focus S3 Vectors 인덱스, 벡터 필드만 저장, 저장 계층 — OpenSearch Service의 s3vector 엔진" aria-pressed="false" data-node-kind="database" data-node-sublabel="벡터 필드만 저장" data-node-context="저장 계층 — OpenSearch Service의 s3vector 엔진">
          <title>S3 Vectors 인덱스 · 벡터 필드만 저장 · 저장 계층 — OpenSearch Service의 s3vector 엔진</title>
          <rect x="500.4" y="217" width="150" height="52" rx="6" class="c-mask" />
          <rect x="500.4" y="217" width="150" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(506.4 223) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="575.4" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">S3 Vectors 인덱스</text>
          <text data-detail="context" x="575.4" y="255" class="t-muted" font-size="8" text-anchor="middle">벡터 필드만 저장</text>
        </g>

        <!-- Edge labels -->

        <g data-detail="context" data-edge-from="engine_domain" data-edge-to="engine_s3" data-edge-label="벡터를 실시간 이관" data-edge-key="1">
          <rect x="400" y="223" width="96.39999999999999" height="14" rx="3" class="c-mask" />
          <text x="448.2" y="233" class="t-backend" font-size="8" text-anchor="middle">벡터를 실시간 이관</text>
        </g>
        <g data-detail="context" data-edge-from="export_osi" data-edge-to="export_col" data-edge-label="이후 동기화 없음" data-edge-key="2">
          <rect x="404.8" y="99" width="86.8" height="14" rx="3" class="c-mask" />
          <text x="448.2" y="109" class="t-backend" font-size="8" text-anchor="middle">이후 동기화 없음</text>
        </g>


        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="20" y="304" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="frontend" data-legend-kind="frontend" data-legend-label="요청" data-legend-x="20" data-legend-baseline="324" data-legend-width="61">
            <rect x="20" y="316" width="14" height="9" rx="2" class="c-frontend" stroke-width="1" />
            <text x="42" y="324" class="t-muted" font-size="7.5" font-weight="500">요청</text>
          </g>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="검색 엔진" data-legend-x="88" data-legend-baseline="324" data-legend-width="83">
            <rect x="88" y="316" width="14" height="9" rx="2" class="c-backend" stroke-width="1" />
            <text x="110" y="324" class="t-muted" font-size="7.5" font-weight="500">검색 엔진</text>
          </g>
          <g data-legend-semantic-kind="messagebus" data-legend-kind="messagebus" data-legend-label="파이프라인" data-legend-x="178" data-legend-baseline="324" data-legend-width="87">
            <rect x="178" y="316" width="14" height="9" rx="2" class="c-messagebus" stroke-width="1" />
            <text x="200" y="324" class="t-muted" font-size="7.5" font-weight="500">파이프라인</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="벡터 저장" data-legend-x="272" data-legend-baseline="324" data-legend-width="83">
            <rect x="272" y="316" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="294" y="324" class="t-muted" font-size="7.5" font-weight="500">벡터 저장</text>
          </g>
        </g>
      </svg>
</div><figcaption>S3 Vectors의 벡터를 OpenSearch에서 검색하는 두 가지 경로</figcaption>
</figure>

<p>도메인의 메모리 배분은 OpenSearch Service가 정합니다. 인스턴스 RAM의 절반을 JVM 힙에 쓰고, k-NN은 기본으로 나머지 절반의 50%까지 씁니다(2편 6절의 메모리 계산에 쓴 규칙). 힙은 기본으로 32GiB까지 잡고, r7g와 OpenSearch Optimized 인스턴스는 Auto-Tune이 필요하다고 판단하거나 지원 요청을 하면 더 큰 힙을 쓸 수 있습니다.<sup id="fnref:osm:2" role="doc-noteref"><a href="#fn:osm" class="footnote" rel="footnote">9</a></sup></p>

<p>GPU 가속 인덱스 빌드는 OpenSearch 3.1 이상 도메인의 Faiss HNSW 인덱스에 쓰고, OCU 시간으로 따로 과금합니다. Bedrock Knowledge Bases의 벡터 저장소로 쓰려면 VPC 안에 있지 않은 퍼블릭 액세스 도메인이어야 합니다.<sup id="fnref:osm:3" role="doc-noteref"><a href="#fn:osm" class="footnote" rel="footnote">9</a></sup><sup id="fnref:kb:1" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<h3 id="aurorards-for-postgresql">Aurora·RDS for PostgreSQL</h3>

<p>pgvector 버전은 엔진 마이너 버전에 묶여 있습니다. 2026년 9월 기준으로 Aurora PostgreSQL 18.4·17.10·16.14·15.18·14.23과 RDS for PostgreSQL의 최신 마이너 버전이 pgvector 0.8.2를 제공합니다. 엔진을 업그레이드해도 확장 버전은 자동으로 올라가지 않습니다. 설치된 pgvector가 0.8.0 미만이라면 2편에서 다룬 iterative scan을 쓰기 전에 pgvector 확장 버전을 직접 올려야 합니다.<sup id="fnref:aurora" role="doc-noteref"><a href="#fn:aurora" class="footnote" rel="footnote">12</a></sup></p>

<p>Aurora에만 있는 기능도 벡터 검색에 쓰입니다. Optimized Reads는 NVMe를 단 R6gd·R6id(Aurora PostgreSQL 14.9·15.4·16.1 이상)와 R8gd(14.12·15.7·16.3·17.4 이상) 인스턴스에서 계층 캐시(tiered cache)로 캐시 용량을 인스턴스 메모리의 최대 5배까지 넓히며, 문서는 pgvector로 수백만 개의 임베딩을 검색하는 생성형 AI 앱을 용례로 듭니다. 계층 캐시는 I/O-Optimized 클러스터에서만 쓰입니다. Aurora ML의 <code class="language-plaintext highlighter-rouge">aws_ml</code> 2.0은 SQL에서 Bedrock 임베딩 모델을 호출하는 함수를 제공하지만, 결과는 <code class="language-plaintext highlighter-rouge">vector</code>가 아니라 <code class="language-plaintext highlighter-rouge">float8[]</code> 형식입니다. Aurora PostgreSQL Limitless Database도 pgvector 확장을 지원합니다.<sup id="fnref:aurora:1" role="doc-noteref"><a href="#fn:aurora" class="footnote" rel="footnote">12</a></sup></p>

<p>Bedrock Knowledge Bases는 Aurora PostgreSQL 클러스터를 저장소로 쓸 수 있습니다. 같은 계정의 Aurora PostgreSQL 16.1·15.4·14.9·13.12·12.16 이상 클러스터여야 하고, pgvector 0.5.0 이상과 RDS Data API, Secrets Manager 자격 증명이 필요합니다. <code class="language-plaintext highlighter-rouge">id</code>·<code class="language-plaintext highlighter-rouge">embedding</code>·<code class="language-plaintext highlighter-rouge">chunks</code>·<code class="language-plaintext highlighter-rouge">metadata</code> 열과 <code class="language-plaintext highlighter-rouge">embedding</code>의 HNSW 인덱스, <code class="language-plaintext highlighter-rouge">chunks</code>의 GIN 전문 인덱스도 미리 만들어야 합니다. 이 필드 매핑은 지식 베이스를 만든 뒤 바꿀 수 없고, Data API의 응답은 1MiB로 제한됩니다. RDS for PostgreSQL 인스턴스를 저장소로 쓰는 방법은 Bedrock 문서에 없습니다.<sup id="fnref:aurora:2" role="doc-noteref"><a href="#fn:aurora" class="footnote" rel="footnote">12</a></sup><sup id="fnref:kb:2" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<p>Aurora의 zero-ETL 통합(Redshift·SageMaker로 복제)이 지원하는 데이터 타입 목록에는 pgvector의 <code class="language-plaintext highlighter-rouge">vector</code> 타입이 없습니다. 지원하지 않는 타입이 있는 테이블은 동기화가 끊겨 대상에서 쓸 수 없습니다.<sup id="fnref:aurora:3" role="doc-noteref"><a href="#fn:aurora" class="footnote" rel="footnote">12</a></sup></p>

<h2 id="8-bedrock-knowledge-bases는-어디까지-대신해-줄까요">8. Bedrock Knowledge Bases는 어디까지 대신해 줄까요</h2>

<p>Bedrock Knowledge Bases는 벡터 저장소가 아니라, 문서를 파싱·청크 분할·임베딩해 저장소에 넣고 질의 때 검색해 주는 RAG 계층입니다. 지식 베이스는 Customer-managed와 Managed 두 종류입니다. Customer-managed는 고객이 벡터 저장소를 고르고 운영하며, Managed는 저장·색인·검색 인프라를 Bedrock이 운영합니다. Managed 지식 베이스는 2026년 6월에 정식 출시(GA)됐습니다.<sup id="fnref:kb:3" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 950 404" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="aws-bedrock-kb-types-archify-diagram-title">두 가지 Bedrock 지식 베이스가 맡는 범위</title>
        <desc id="aws-bedrock-kb-types-archify-diagram-description">A workflow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="aws-bedrock-kb-types-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="aws-bedrock-kb-types-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="aws-bedrock-kb-types-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="aws-bedrock-kb-types-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="aws-bedrock-kb-types-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#aws-bedrock-kb-types-grid)" />

        <!-- Swimlanes -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-0" x="40" y="52" width="894" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="74" class="t-dim" font-size="10" font-weight="600">01 / Customer-managed — 저장소를 고객이 고르고 운영</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-1" x="40" y="176" width="894" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="198" class="t-dim" font-size="10" font-weight="600">02 / Managed — 저장·색인·검색을 Bedrock이 운영</text>

        <!-- Phase headers -->


        <!-- Workflow groups -->


        <!-- Edge paths -->
        <path data-edge-from="cm_pipe" data-edge-to="cm_store" data-edge-key="0" data-composition-points="422.8,119;512.8,119" d="M 422.8 119 L 512.8 119" class="a-default" stroke-width="1.4" marker-end="url(#aws-bedrock-kb-types-arrowhead)" />
        <path data-edge-from="cm_src" data-edge-to="cm_pipe" data-edge-label="수동 동기화" data-edge-key="1" data-composition-points="178,119;272.8,119" d="M 178 119 L 272.8 119" class="a-default" stroke-width="1.4" marker-end="url(#aws-bedrock-kb-types-arrowhead)" />
        <path data-edge-from="cm_store" data-edge-to="cm_ret" data-edge-key="2" data-composition-points="662.8,119;690.8,119" d="M 662.8 119 L 690.8 119" class="a-emphasis" stroke-width="1.8" marker-end="url(#aws-bedrock-kb-types-arrowhead-emphasis)" />
        <path data-edge-from="mg_pipe" data-edge-to="mg_store" data-edge-key="3" data-composition-points="422.8,243;512.8,243" d="M 422.8 243 L 512.8 243" class="a-default" stroke-width="1.4" marker-end="url(#aws-bedrock-kb-types-arrowhead)" />
        <path data-edge-from="mg_src" data-edge-to="mg_pipe" data-edge-label="일정 동기화 가능" data-edge-key="4" data-composition-points="178,243;272.8,243" d="M 178 243 L 272.8 243" class="a-default" stroke-width="1.4" marker-end="url(#aws-bedrock-kb-types-arrowhead)" />
        <path data-edge-from="mg_store" data-edge-to="mg_ret" data-edge-key="5" data-composition-points="662.8,243;690.8,243" d="M 662.8 243 L 690.8 243" class="a-emphasis" stroke-width="1.8" marker-end="url(#aws-bedrock-kb-types-arrowhead-emphasis)" />

        <!-- Nodes -->
        <g id="aws-bedrock-kb-types-node-cm_src" data-node-id="cm_src" data-node-label="데이터 소스" tabindex="0" role="button" aria-label="Focus 데이터 소스, S3 · 커넥터, Customer-managed — 저장소를 고객이 고르고 운영" aria-pressed="false" data-node-kind="external" data-node-sublabel="S3 · 커넥터" data-node-context="Customer-managed — 저장소를 고객이 고르고 운영">
          <title>데이터 소스 · S3 · 커넥터 · Customer-managed — 저장소를 고객이 고르고 운영</title>
          <rect x="48" y="93" width="130" height="52" rx="6" class="c-mask" />
          <rect x="48" y="93" width="130" height="52" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(54 99) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="113" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">데이터 소스</text>
          <text data-detail="context" x="113" y="131" class="t-muted" font-size="8" text-anchor="middle">S3 · 커넥터</text>
        </g>

        <g id="aws-bedrock-kb-types-node-cm_pipe" data-node-id="cm_pipe" data-node-label="수집" tabindex="0" role="button" aria-label="Focus 수집, 파서·청크·임베딩 선택, Customer-managed — 저장소를 고객이 고르고 운영" aria-pressed="false" data-node-kind="backend" data-node-sublabel="파서·청크·임베딩 선택" data-node-context="Customer-managed — 저장소를 고객이 고르고 운영">
          <title>수집 · 파서·청크·임베딩 선택 · Customer-managed — 저장소를 고객이 고르고 운영</title>
          <rect x="272.8" y="93" width="150" height="52" rx="6" class="c-mask" />
          <rect x="272.8" y="93" width="150" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(278.8 99) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="347.8" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">수집</text>
          <text data-detail="context" x="347.8" y="131" class="t-muted" font-size="8" text-anchor="middle">파서·청크·임베딩 선택</text>
        </g>

        <g id="aws-bedrock-kb-types-node-cm_store" data-node-id="cm_store" data-node-label="벡터 저장소" tabindex="0" role="button" aria-label="Focus 벡터 저장소, 8종 중 선택·직접 운영, Customer-managed — 저장소를 고객이 고르고 운영" aria-pressed="false" data-node-kind="database" data-node-sublabel="8종 중 선택·직접 운영" data-node-context="Customer-managed — 저장소를 고객이 고르고 운영">
          <title>벡터 저장소 · 8종 중 선택·직접 운영 · Customer-managed — 저장소를 고객이 고르고 운영</title>
          <rect x="512.8" y="93" width="150" height="52" rx="6" class="c-mask" />
          <rect x="512.8" y="93" width="150" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(518.8 99) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="587.8" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">벡터 저장소</text>
          <text data-detail="context" x="587.8" y="131" class="t-muted" font-size="8" text-anchor="middle">8종 중 선택·직접 운영</text>
        </g>

        <g id="aws-bedrock-kb-types-node-cm_ret" data-node-id="cm_ret" data-node-label="Retrieve" tabindex="0" role="button" aria-label="Focus Retrieve, 결과 1~100개, Customer-managed — 저장소를 고객이 고르고 운영" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="결과 1~100개" data-node-context="Customer-managed — 저장소를 고객이 고르고 운영">
          <title>Retrieve · 결과 1~100개 · Customer-managed — 저장소를 고객이 고르고 운영</title>
          <rect x="690.8" y="93" width="130" height="52" rx="6" class="c-mask" />
          <rect x="690.8" y="93" width="130" height="52" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(696.8 99) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="755.8" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Retrieve</text>
          <text data-detail="context" x="755.8" y="131" class="t-muted" font-size="8" text-anchor="middle">결과 1~100개</text>
        </g>

        <g id="aws-bedrock-kb-types-node-mg_src" data-node-id="mg_src" data-node-label="데이터 소스" tabindex="0" role="button" aria-label="Focus 데이터 소스, 네이티브 커넥터, Managed — 저장·색인·검색을 Bedrock이 운영" aria-pressed="false" data-node-kind="external" data-node-sublabel="네이티브 커넥터" data-node-context="Managed — 저장·색인·검색을 Bedrock이 운영">
          <title>데이터 소스 · 네이티브 커넥터 · Managed — 저장·색인·검색을 Bedrock이 운영</title>
          <rect x="48" y="217" width="130" height="52" rx="6" class="c-mask" />
          <rect x="48" y="217" width="130" height="52" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(54 223) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="113" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">데이터 소스</text>
          <text data-detail="context" x="113" y="255" class="t-muted" font-size="8" text-anchor="middle">네이티브 커넥터</text>
        </g>

        <g id="aws-bedrock-kb-types-node-mg_pipe" data-node-id="mg_pipe" data-node-label="관리형 수집" tabindex="0" role="button" aria-label="Focus 관리형 수집, 파싱·청크·임베딩, Managed — 저장·색인·검색을 Bedrock이 운영" aria-pressed="false" data-node-kind="backend" data-node-sublabel="파싱·청크·임베딩" data-node-context="Managed — 저장·색인·검색을 Bedrock이 운영">
          <title>관리형 수집 · 파싱·청크·임베딩 · Managed — 저장·색인·검색을 Bedrock이 운영</title>
          <rect x="272.8" y="217" width="150" height="52" rx="6" class="c-mask" />
          <rect x="272.8" y="217" width="150" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(278.8 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="347.8" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">관리형 수집</text>
          <text data-detail="context" x="347.8" y="255" class="t-muted" font-size="8" text-anchor="middle">파싱·청크·임베딩</text>
        </g>

        <g id="aws-bedrock-kb-types-node-mg_store" data-node-id="mg_store" data-node-label="관리형 저장소" tabindex="0" role="button" aria-label="Focus 관리형 저장소, 자동 확장, Managed — 저장·색인·검색을 Bedrock이 운영" aria-pressed="false" data-node-kind="database" data-node-sublabel="자동 확장" data-node-context="Managed — 저장·색인·검색을 Bedrock이 운영">
          <title>관리형 저장소 · 자동 확장 · Managed — 저장·색인·검색을 Bedrock이 운영</title>
          <rect x="512.8" y="217" width="150" height="52" rx="6" class="c-mask" />
          <rect x="512.8" y="217" width="150" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(518.8 223) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="587.8" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">관리형 저장소</text>
          <text data-detail="context" x="587.8" y="255" class="t-muted" font-size="8" text-anchor="middle">자동 확장</text>
        </g>

        <g id="aws-bedrock-kb-types-node-mg_ret" data-node-id="mg_ret" data-node-label="Retrieve" tabindex="0" role="button" aria-label="Focus Retrieve, 항상 하이브리드, Managed — 저장·색인·검색을 Bedrock이 운영" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="항상 하이브리드" data-node-context="Managed — 저장·색인·검색을 Bedrock이 운영">
          <title>Retrieve · 항상 하이브리드 · Managed — 저장·색인·검색을 Bedrock이 운영</title>
          <rect x="690.8" y="217" width="130" height="52" rx="6" class="c-mask" />
          <rect x="690.8" y="217" width="130" height="52" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(696.8 223) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="755.8" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Retrieve</text>
          <text data-detail="context" x="755.8" y="255" class="t-muted" font-size="8" text-anchor="middle">항상 하이브리드</text>
        </g>

        <!-- Edge labels -->

        <g data-detail="context" data-edge-from="cm_src" data-edge-to="cm_pipe" data-edge-label="수동 동기화" data-edge-key="1">
          <rect x="194" y="99" width="62.8" height="14" rx="3" class="c-mask" />
          <text x="225.4" y="109" class="t-muted" font-size="8" text-anchor="middle">수동 동기화</text>
        </g>


        <g data-detail="context" data-edge-from="mg_src" data-edge-to="mg_pipe" data-edge-label="일정 동기화 가능" data-edge-key="4">
          <rect x="182" y="223" width="86.8" height="14" rx="3" class="c-mask" />
          <text x="225.4" y="233" class="t-muted" font-size="8" text-anchor="middle">일정 동기화 가능</text>
        </g>


        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="20" y="304" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="frontend" data-legend-kind="frontend" data-legend-label="검색 API" data-legend-x="20" data-legend-baseline="324" data-legend-width="78">
            <rect x="20" y="316" width="14" height="9" rx="2" class="c-frontend" stroke-width="1" />
            <text x="42" y="324" class="t-muted" font-size="7.5" font-weight="500">검색 API</text>
          </g>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="수집" data-legend-x="105" data-legend-baseline="324" data-legend-width="61">
            <rect x="105" y="316" width="14" height="9" rx="2" class="c-backend" stroke-width="1" />
            <text x="127" y="324" class="t-muted" font-size="7.5" font-weight="500">수집</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="저장소" data-legend-x="173" data-legend-baseline="324" data-legend-width="70">
            <rect x="173" y="316" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="195" y="324" class="t-muted" font-size="7.5" font-weight="500">저장소</text>
          </g>
          <g data-legend-semantic-kind="external" data-legend-kind="external" data-legend-label="데이터 소스" data-legend-x="250" data-legend-baseline="324" data-legend-width="91">
            <rect x="250" y="316" width="14" height="9" rx="2" class="c-external" stroke-width="1" />
            <text x="272" y="324" class="t-muted" font-size="7.5" font-weight="500">데이터 소스</text>
          </g>
        </g>
      </svg>
</div><figcaption>Customer-managed와 Managed 지식 베이스가 맡는 범위</figcaption>
</figure>

<p>Customer-managed 지식 베이스에서는 파서, 청크 전략, 임베딩 모델, 벡터 저장소를 고른 뒤 동기화로 문서를 수집합니다. 동기화는 수동으로 시작하며, 지난 동기화 뒤에 추가·수정·삭제된 문서만 처리합니다. 청크는 기본(약 300토큰), 고정 크기, 계층형, 의미 기반, 청크 없음 가운데 고릅니다. 임베딩 모델과 저장소 설정은 지식 베이스를 만든 뒤에, 청크 설정은 데이터 소스를 만든 뒤에 바꿀 수 없습니다.<sup id="fnref:kb:4" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<p>저장소는 여덟 가지입니다. 아래 표의 빠른 생성(quick create)은 지식 베이스를 만들 때 Bedrock이 벡터 저장소를 대신 만들어 주는 콘솔 옵션입니다.<sup id="fnref:kb:5" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<table>
  <thead>
    <tr>
      <th>저장소</th>
      <th>빠른 생성</th>
      <th>하이브리드 검색</th>
      <th>조건</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S3 Vectors</td>
      <td>지원</td>
      <td>지원 안 함</td>
      <td>float 임베딩만, 벡터당 사용자 메타데이터 1KB·35키</td>
    </tr>
    <tr>
      <td>OpenSearch Serverless</td>
      <td>지원</td>
      <td>지원(필터 가능한 텍스트 필드 필요)</td>
      <td>직접 만들 때는 <code class="language-plaintext highlighter-rouge">faiss</code> 엔진(NextGen 컬렉션 지원 여부는 Bedrock 문서에 없음), Confluence·SharePoint·Salesforce 데이터 소스는 이 저장소만</td>
    </tr>
    <tr>
      <td>Aurora PostgreSQL</td>
      <td>지원(Serverless)</td>
      <td>지원(필터 가능한 텍스트 필드 필요, 취리히·GovCloud 리전 제외)</td>
      <td>Data API·Secrets Manager, 필수 열과 HNSW 인덱스</td>
    </tr>
    <tr>
      <td>Neptune Analytics</td>
      <td>지원</td>
      <td>지원 안 함(대신 GraphRAG)</td>
      <td>벡터 인덱스를 둔 그래프, 데이터 소스는 S3만</td>
    </tr>
    <tr>
      <td>OpenSearch Service 도메인</td>
      <td>지원 안 함</td>
      <td>지원 안 함</td>
      <td>퍼블릭 액세스 도메인만, OpenSearch 2.13 이상, <code class="language-plaintext highlighter-rouge">faiss</code> 엔진</td>
    </tr>
    <tr>
      <td>Pinecone·Redis Enterprise Cloud·MongoDB Atlas</td>
      <td>지원 안 함</td>
      <td>MongoDB Atlas만 지원(필터 가능한 텍스트 필드 필요)</td>
      <td>외부 서비스, 자격 증명은 Secrets Manager</td>
    </tr>
  </tbody>
</table>

<p>Bedrock 사용자 가이드와 2025년 4월 발표는 위 표처럼 하이브리드 검색 지원 저장소를 적지만, API 레퍼런스는 OpenSearch Serverless만 적습니다. 빠른 생성은 콘솔에서만 쓸 수 있고, 이진 임베딩은 OpenSearch Serverless와 OpenSearch Service 도메인(OpenSearch 2.16 이상)에만 저장할 수 있습니다.<sup id="fnref:kb:6" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<p>지식 베이스의 검색 결과 수 상한은 S3 Vectors의 <code class="language-plaintext highlighter-rouge">topK</code>나 OpenSearch Service 도메인의 <code class="language-plaintext highlighter-rouge">k</code> 상한(10,000)보다 작습니다. 한도는 다음과 같습니다.<sup id="fnref:kb:7" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<ul>
  <li><strong>검색:</strong> <code class="language-plaintext highlighter-rouge">Retrieve</code>는 <code class="language-plaintext highlighter-rouge">numberOfResults</code>를 1~100(기본 5)으로 받고, 리전별 초당 20회로 제한됩니다(조정 불가).</li>
  <li><strong>구성:</strong> 지식 베이스는 계정당 100개, 지식 베이스당 데이터 소스는 5개까지입니다(조정 불가).</li>
  <li><strong>필터:</strong> <code class="language-plaintext highlighter-rouge">andAll</code>·<code class="language-plaintext highlighter-rouge">orAll</code>로 최대 5개씩 묶을 수 있고, <code class="language-plaintext highlighter-rouge">startsWith</code>는 OpenSearch Serverless 저장소에서만 쓸 수 있습니다.</li>
</ul>

<p>Managed 지식 베이스는 저장소를 고르지 않습니다. 사용자 지정 임베딩 모델은 float32·1024차원 모델만 쓸 수 있고, 관리형 재순위화가 기본으로 들어 있습니다.<sup id="fnref:kb:8" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup> 검색은 하이브리드 방식이며, Kendra 이전 안내에 따르면 의미 검색만 하는 모드는 없습니다.<sup id="fnref:retired:1" role="doc-noteref"><a href="#fn:retired" class="footnote" rel="footnote">1</a></sup></p>

<p>과금 단위는 원본 데이터 GB-월과 검색 호출 1,000회입니다. Agentic 검색은 Agentic 호출과 그 아래에서 실행된 <code class="language-plaintext highlighter-rouge">Retrieve</code> 호출을 각각 세고, 임베딩·재순위화 모델을 직접 고르면 그 모델 요금이 더해집니다. 지식 베이스 하나에 10TB까지 저장할 수 있습니다.<sup id="fnref:kb:9" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup><sup id="fnref:pricing:5" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">11</a></sup></p>

<p>Confluence·SharePoint·Salesforce 데이터 소스는 OpenSearch Serverless만 저장소로 쓸 수 있습니다. 그런데 Customer-managed 지식 베이스에서는 2026년 9월 30일부터 Confluence, SharePoint, Salesforce, Web Crawler 커넥터를 새로 만들 수 없습니다. 이미 만든 커넥터는 수집과 검색을 계속하며, AWS는 이 커넥터가 필요한 앱에 Managed 지식 베이스를 권합니다.<sup id="fnref:kb:10" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup></p>

<h2 id="9-상황별로-어떤-서비스부터-볼까요">9. 상황별로 어떤 서비스부터 볼까요</h2>

<p>아래는 앞의 비교에서 나온 <strong>먼저 검토할 후보</strong>입니다. 순위가 아니며, 후보를 좁힌 뒤에는 1편의 순서대로 같은 데이터와 recall 목표에서 측정해야 합니다.</p>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>먼저 볼 후보</th>
      <th>근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>벡터가 많고 질의가 드물며 저장 비용이 중요함</td>
      <td>S3 Vectors</td>
      <td>인덱스당 20억, 프로비저닝 없음, 드문 질의에 1초 미만(1·5절)</td>
    </tr>
    <tr>
      <td>키워드 검색을 함께 쓰거나 집계가 필요함</td>
      <td>OpenSearch Serverless, OpenSearch Service 도메인</td>
      <td>하이브리드·neural sparse, S3 Vectors 벡터 내보내기(4·7절)</td>
    </tr>
    <tr>
      <td>데이터가 이미 DynamoDB 테이블에 있고 질의당 결과 100개면 충분함</td>
      <td>DynamoDB</td>
      <td>on-demand 테이블에 벡터 인덱스 추가, <code class="language-plaintext highlighter-rouge">TopK</code> 1~100(1·3절)</td>
    </tr>
    <tr>
      <td>짧은 지연이 필요한 캐시·에이전트 메모리를 다룸</td>
      <td>ElastiCache, MemoryDB</td>
      <td>인메모리 Valkey 계열(ElastiCache는 Valkey 8.2 이상 노드 기반 클러스터), 문서상 마이크로초~한 자릿수 밀리초, MemoryDB는 단일 샤드(1·5·6절)</td>
    </tr>
    <tr>
      <td>MongoDB 호환 문서 DB에서 벡터와 문서를 함께 다룸</td>
      <td>DocumentDB</td>
      <td>5.0 이상 인스턴스 기반 클러스터, 인덱스 2,000차원까지(1·2절)</td>
    </tr>
    <tr>
      <td>관계가 중요한 데이터를 벡터와 함께 탐색함</td>
      <td>Neptune Analytics</td>
      <td>그래프 순회와 결합, Bedrock GraphRAG(4·8절)</td>
    </tr>
    <tr>
      <td>PostgreSQL을 이미 운영함</td>
      <td>Aurora PostgreSQL</td>
      <td>최신 마이너 버전의 pgvector 0.8.2, Optimized Reads, Bedrock 연동(7절)</td>
    </tr>
    <tr>
      <td>저장소를 운영하지 않고 RAG를 시작함</td>
      <td>Bedrock Managed 지식 베이스</td>
      <td>Bedrock이 저장소 운영, 항상 하이브리드(8절)</td>
    </tr>
  </tbody>
</table>

<p>서울 리전(ap-northeast-2)에서 쓸 계획이라면 기능별 제공 리전을 먼저 확인해야 합니다.<sup id="fnref:s3v:10" role="doc-noteref"><a href="#fn:s3v" class="footnote" rel="footnote">2</a></sup><sup id="fnref:oss:8" role="doc-noteref"><a href="#fn:oss" class="footnote" rel="footnote">3</a></sup><sup id="fnref:osm:4" role="doc-noteref"><a href="#fn:osm" class="footnote" rel="footnote">9</a></sup><sup id="fnref:neptune:5" role="doc-noteref"><a href="#fn:neptune" class="footnote" rel="footnote">8</a></sup><sup id="fnref:kb:11" role="doc-noteref"><a href="#fn:kb" class="footnote" rel="footnote">10</a></sup><sup id="fnref:mdb:8" role="doc-noteref"><a href="#fn:mdb" class="footnote" rel="footnote">5</a></sup><sup id="fnref:ec:8" role="doc-noteref"><a href="#fn:ec" class="footnote" rel="footnote">6</a></sup><sup id="fnref:docdb:4" role="doc-noteref"><a href="#fn:docdb" class="footnote" rel="footnote">7</a></sup><sup id="fnref:ddb:4" role="doc-noteref"><a href="#fn:ddb" class="footnote" rel="footnote">4</a></sup></p>

<table>
  <thead>
    <tr>
      <th>서울 리전</th>
      <th>서비스·기능</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>제공</td>
      <td>S3 Vectors, OpenSearch Serverless, Neptune Analytics(ap-northeast-2d 가용 영역 제외), Customer-managed 지식 베이스, MemoryDB·ElastiCache·DocumentDB·DynamoDB의 벡터 검색</td>
    </tr>
    <tr>
      <td>제공 리전 목록에 없음</td>
      <td>OpenSearch Serverless의 neural·하이브리드 검색과 자동 의미 보강(서비스가 관리하는 희소 모델로 텍스트를 보강), OpenSearch Service·Serverless의 GPU 가속 인덱스 빌드, Bedrock의 GraphRAG·Managed 지식 베이스·재순위화 모델</td>
    </tr>
  </tbody>
</table>

<h2 id="정리">정리</h2>

<p>AWS의 벡터 검색은 기존 데이터 서비스 안에도 들어 있습니다. 새 저장소를 고르기 전에, 이미 쓰는 서비스의 벡터 기능이 질의당 k, 필터, 확장 방식에서 요구사항을 채우는지부터 확인하면 선택지가 줄어듭니다.</p>

<p>서비스마다 막히는 지점이 다릅니다. S3 Vectors는 인덱스당 요청률에서 막히고, 인덱스가 클수록 늘어나는 질의 요금도 따져야 합니다. DynamoDB와 Bedrock Knowledge Bases는 질의당 결과 100개에서, MemoryDB는 단일 샤드에서 막힙니다. OpenSearch Serverless는 NextGen과 Classic 가운데 어느 세대인지에 따라 기능과 쓰기 반영 시간이 달라집니다. 서울 리전에서는 기능별 제공 여부도 함께 봐야 합니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:retired" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/kendra/latest/dg/kendra-availability-change.html">Amazon Kendra Availability Change</a>, <a href="https://docs.aws.amazon.com/amazonq/latest/qbusiness-ug/qbusiness-availability-change.html">Amazon Q Business Availability Change</a>. 신규 고객 가입 종료 일정과 이전 권장 서비스. <a href="#fnref:retired" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:retired:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:s3v" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors.html">Amazon S3 — Working with S3 Vectors</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-limitations.html">Limitations</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-query.html">Querying Vectors</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-metadata-filtering.html">Metadata Filtering</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-indexes.html">Vector Indexes</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-best-practices.html">Best Practices</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-regions-quotas.html">Regions and Quotas</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-opensearch.html">OpenSearch Integration</a>, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors-bedrock-kb.html">Bedrock Knowledge Bases Integration</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2025/12/amazon-s3-vectors-generally-available/">GA 발표</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2026/06/s3-vectors-supports-10000-search-results-per-query/">검색 결과 10,000개 발표</a>. 버킷·인덱스 구조, 한도, 질의와 페이지네이션, 필터, 테넌트 격리, 일관성, 리전. <a href="#fnref:s3v" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:s3v:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:s3v:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:s3v:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:s3v:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:s3v:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:s3v:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:s3v:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:s3v:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a> <a href="#fnref:s3v:9" class="reversefootnote" role="doc-backlink">&#8617;<sup>10</sup></a> <a href="#fnref:s3v:10" class="reversefootnote" role="doc-backlink">&#8617;<sup>11</sup></a></p>
    </li>
    <li id="fn:oss" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-vector-search.html">Amazon OpenSearch Serverless — Vector Search Collections</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-overview.html">Overview</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-scaling.html">Scaling</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/collection-groups-capacity-limits.html">Collection Group Capacity Limits</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-scale-to-zero.html">Scale to Zero</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-configure-neural-search.html">Neural and Hybrid Search</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/s3-opensearch-vector-bucket-integration.html">S3 Vectors Import</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-semantic-enrichment.html">Automatic Semantic Enrichment</a>, <a href="https://docs.aws.amazon.com/general/latest/gr/opensearch-service.html">Service Quotas</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2026/05/amazon-opensearch-serverless-next-generation-generally-available/">NextGen GA 발표</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2025/08/amazon-opensearch-serverless-ai-connectors-hybrid-search/">하이브리드 검색 발표</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2026/08/amazon-opensearch-serverless-supports-10000-collections-per-collection-group/">collection group 컬렉션 10,000개 발표</a>. NextGen·Classic 세대 차이, 압축, OCU 확장, 반영 지연, 하이브리드 검색, 자동 의미 보강, 리전. <a href="#fnref:oss" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:oss:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:oss:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:oss:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:oss:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:oss:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:oss:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:oss:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:oss:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a></p>
    </li>
    <li id="fn:ddb" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html">Amazon DynamoDB — What is Amazon DynamoDB?</a>, <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearch.html">Vector Search</a>, <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearch.Requirements.html">Requirements and Limitations</a>, <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearchWorkingWith.html">Working with Vector Indexes</a>, <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ServiceQuotas.html">Service Quotas</a>, <a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_SearchVectors.html">SearchVectors API</a>, <a href="https://docs.aws.amazon.com/general/latest/gr/ddb.html">Endpoints and Quotas</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2026/08/amazon-dynamodb-vector-search/">GA 발표</a>. 용량 모드 조건, 거리 함수, TopK와 응답 한도, 필터, 일관성, 쿼터, 리전. <a href="#fnref:ddb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:ddb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:ddb:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:ddb:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:ddb:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:mdb" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/memorydb/latest/devguide/vector-search.html">Amazon MemoryDB — Vector Search</a>, <a href="https://docs.aws.amazon.com/memorydb/latest/devguide/vector-search-overview.html">Overview</a>, <a href="https://docs.aws.amazon.com/memorydb/latest/devguide/vector-search-limits.html">Limits</a>, <a href="https://docs.aws.amazon.com/memorydb/latest/devguide/vector-search-commands-ft.create.html">FT.CREATE</a>, <a href="https://docs.aws.amazon.com/memorydb/latest/devguide/vector-search-commands-ft.search.html">FT.SEARCH</a>, <a href="https://docs.aws.amazon.com/memorydb/latest/devguide/multi-region.prereq.html">Multi-Region Prerequisites</a>, <a href="https://docs.aws.amazon.com/general/latest/gr/memorydb-service.html">Endpoints and Quotas</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2024/07/aws-vector-search-for-amazon-memorydb/">GA 발표</a>. 인덱스와 파라미터, 한도, 단일 샤드 제약과 Multi-Region 미지원, 일관성, 메모리 예시, 과금, 리전. <a href="#fnref:mdb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:mdb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:mdb:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:mdb:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:mdb:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:mdb:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:mdb:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:mdb:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:mdb:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a></p>
    </li>
    <li id="fn:ec" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/search.html">Amazon ElastiCache — Search</a>, <a href="https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/search-features-limits.html">Search Features and Limits</a>, <a href="https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/choosing-configuration.html">Choosing a Configuration</a>, <a href="https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/agentic-memory-best-practices.html">Agentic Memory Best Practices</a>, <a href="https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/ParameterGroups.Engine.html">Engine Parameters</a>, <a href="https://docs.aws.amazon.com/general/latest/gr/elasticache-service.html">Endpoints and Quotas</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2025/10/amazon-elasticache-vector-search/">벡터 검색 GA 발표</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2026/05/amazon-elasticache-hybrid-search/">하이브리드 검색 발표</a>, <a href="https://aws.amazon.com/blogs/database/enhanced-search-for-amazon-elasticache/">Enhanced Search 블로그</a>. Valkey 버전별 기능과 한도, 확장 방식, 일관성, 과금, 리전. <a href="#fnref:ec" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:ec:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:ec:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:ec:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:ec:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:ec:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:ec:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:ec:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:ec:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a></p>
    </li>
    <li id="fn:docdb" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/documentdb/latest/devguide/vector-search.html">Amazon DocumentDB — Vector Search</a>, <a href="https://docs.aws.amazon.com/documentdb/latest/devguide/vectorSearch.html">$vectorSearch</a>, <a href="https://docs.aws.amazon.com/documentdb/latest/devguide/limits.html">Quotas and Limits</a>, <a href="https://docs.aws.amazon.com/documentdb/latest/devguide/docdb-serverless-how-it-works.html">Serverless</a>, <a href="https://docs.aws.amazon.com/documentdb/latest/devguide/db-cluster-manage-performance.html">Managing Performance and Scaling</a>, <a href="https://docs.aws.amazon.com/documentdb/latest/devguide/how-it-works.html">How It Works</a>, <a href="https://docs.aws.amazon.com/documentdb/latest/devguide/regions-and-azs.html">Regions and Availability Zones</a>, <a href="https://aws.amazon.com/documentdb/faqs/">FAQ</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2023/11/vector-search-amazon-documentdb/">벡터 검색 발표</a>. 인덱스, 차원, k 관련 파라미터, 클러스터 조건, 확장, 일관성, 리전, 과금. <a href="#fnref:docdb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:docdb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:docdb:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:docdb:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:docdb:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:neptune" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/neptune-analytics/latest/userguide/vector-index.html">Amazon Neptune Analytics — Vector Index</a>, <a href="https://docs.aws.amazon.com/neptune-analytics/latest/userguide/vectors.topK.byEmbedding.html">topK.byEmbedding</a>, <a href="https://docs.aws.amazon.com/neptune-analytics/latest/userguide/analytics-limits.html">Limits</a>, <a href="https://docs.aws.amazon.com/neptune-analytics/latest/userguide/query-concurrency-queuing.html">Query Concurrency</a>, <a href="https://docs.aws.amazon.com/neptune-analytics/latest/userguide/managing-modifying.html">Modifying a Graph</a>, <a href="https://docs.aws.amazon.com/neptune-analytics/latest/apiref/API_CreateGraph.html">CreateGraph API</a>, <a href="https://aws.amazon.com/neptune/faqs/">FAQ</a>. 그래프당 벡터 인덱스, 점수와 필터, 용량 단위, 일관성, 리전. <a href="#fnref:neptune" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:neptune:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:neptune:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:neptune:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:neptune:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:neptune:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:osm" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/knn.html">Amazon OpenSearch Service — k-NN Search</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/s3-vector-opensearch-integration-engine.html">S3 Vectors Engine</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/gpu-acceleration-vector-index.html">GPU Acceleration</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/limits.html">Service Limits</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2025/12/amazon-opensearch-service-gpu-accelerated-auto-optimized-vector-indexes/">GPU 가속·Auto-optimize 발표</a>. <code class="language-plaintext highlighter-rouge">s3vector</code> 엔진의 조건과 제약, 힙과 k-NN 메모리 규칙, GPU 가속 빌드. <a href="#fnref:osm" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:osm:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:osm:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:osm:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:osm:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:kb" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base.html">Amazon Bedrock — Knowledge Bases</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base-setup.html">Set Up a Vector Store</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base-create.html">Create a Knowledge Base</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/kb-test-config.html">Query Configuration</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/kb-chunking.html">Chunking</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/kb-data-source-sync-ingest.html">Sync and Ingest</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/data-source-connectors.html">Data Source Connectors</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/kb-build-managed.html">Managed Knowledge Base</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/kb-managed-regions.html">Managed Knowledge Base Regions</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base-build-graphs.html">GraphRAG</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/rerank-supported.html">Reranking Models</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/kb-ds-update.html">Update a Data Source</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdateKnowledgeBase.html">UpdateKnowledgeBase API</a>, <a href="https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent-runtime_KnowledgeBaseVectorSearchConfiguration.html">KnowledgeBaseVectorSearchConfiguration API</a>, <a href="https://docs.aws.amazon.com/general/latest/gr/bedrock.html">Service Quotas</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2026/06/amazon-bedrock-managed-knowledge-base/">Managed Knowledge Base GA 발표</a>, <a href="https://aws.amazon.com/about-aws/whats-new/2025/04/amazon-bedrock-knowledge-bases-hybrid-search-aurora-postgresql-mongo-db-atlas-vector-stores/">Aurora·MongoDB Atlas 하이브리드 검색 발표</a>. 두 가지 지식 베이스, 저장소별 조건, 수집·동기화, 검색 한도, 쿼터, 리전. <a href="#fnref:kb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:kb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:kb:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:kb:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:kb:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:kb:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:kb:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:kb:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a> <a href="#fnref:kb:8" class="reversefootnote" role="doc-backlink">&#8617;<sup>9</sup></a> <a href="#fnref:kb:9" class="reversefootnote" role="doc-backlink">&#8617;<sup>10</sup></a> <a href="#fnref:kb:10" class="reversefootnote" role="doc-backlink">&#8617;<sup>11</sup></a> <a href="#fnref:kb:11" class="reversefootnote" role="doc-backlink">&#8617;<sup>12</sup></a></p>
    </li>
    <li id="fn:pricing" role="doc-endnote">
      <p><a href="https://aws.amazon.com/s3/pricing/">Amazon S3 Pricing</a>, <a href="https://aws.amazon.com/opensearch-service/pricing/">Amazon OpenSearch Service Pricing</a>, <a href="https://aws.amazon.com/dynamodb/pricing/">Amazon DynamoDB Pricing</a>, <a href="https://aws.amazon.com/memorydb/pricing/">Amazon MemoryDB Pricing</a>, <a href="https://aws.amazon.com/elasticache/pricing/">Amazon ElastiCache Pricing</a>, <a href="https://aws.amazon.com/documentdb/pricing/">Amazon DocumentDB Pricing</a>, <a href="https://aws.amazon.com/neptune/pricing/">Amazon Neptune Pricing</a>, <a href="https://aws.amazon.com/rds/aurora/pricing/">Amazon Aurora Pricing</a>, <a href="https://aws.amazon.com/bedrock/pricing/">Amazon Bedrock Pricing</a>. 서비스별 과금 단위와 산정 기준(2026-09-27 조회). <a href="#fnref:pricing" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pricing:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:pricing:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:pricing:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:pricing:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:pricing:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:aurora" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraPostgreSQLReleaseNotes/AuroraPostgreSQL.Extensions.html">Amazon Aurora — PostgreSQL Extension Versions</a>, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/PostgreSQLReleaseNotes/postgresql-extensions.html">RDS for PostgreSQL Extension Versions</a>, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraPostgreSQL.VectorDB.html">Aurora PostgreSQL as a Knowledge Base</a>, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraPostgreSQL.optimized.reads.html">Optimized Reads</a>, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/postgresql-ml.html">Aurora Machine Learning</a>, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.limitations.html">Data API Limitations</a>, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/limitless-reference.DDL-limitations.html">Limitless Database Extensions</a>, <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/zero-etl.querying.html">Zero-ETL Data Types</a>. pgvector 버전, Aurora 고유 기능, Bedrock 저장소 조건, zero-ETL 지원 타입. <a href="#fnref:aurora" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:aurora:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:aurora:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:aurora:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="ai/ml" /><summary type="html"><![CDATA[S3 Vectors, OpenSearch Serverless, DynamoDB 등 AWS 전용 벡터 검색 7종을 인덱스, 질의당 k, 필터, 규모, 확장·과금 단위로 비교하고 Bedrock Knowledge Bases가 맡는 일을 정리합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">벡터 DB는 어떻게 고를까: 선택 기준과 제품별 기능 비교</title><link href="https://rastalion.dev/writing/vector-database-selection/" rel="alternate" type="text/html" title="벡터 DB는 어떻게 고를까: 선택 기준과 제품별 기능 비교" /><published>2026-09-26T15:00:00+00:00</published><updated>2026-09-27T00:00:00+00:00</updated><id>https://rastalion.dev/writing/vector-database-selection</id><content type="html" xml:base="https://rastalion.dev/writing/vector-database-selection/"><![CDATA[<p><strong>시리즈</strong> · <a href="/writing/vector-database-overview/">1. 개념과 제품·사용 지표</a> · <strong>2. 선택 기준과 기능 비교</strong> · <a href="/writing/aws-vector-search/">3. AWS 전용 벡터 검색</a> · <a href="/writing/vector-database-schema-design/">4. 스키마와 컬렉션 설계</a></p>

<p>사내 문서 조각 1억 개 가운데 사용자가 볼 수 있는 문서만 골라 관련도가 높은 50개를 가져와야 한다고 생각해 봅시다. 제품 소개의 “빠르다”는 문장보다 먼저 확인할 것이 있습니다. 권한 필터를 걸어도 50개가 다 나오는지, 인덱스가 메모리에 들어가는지, 데이터가 두 배로 늘었을 때 샤드를 늘릴 수 있는지입니다.</p>

<p>1편에서는 벡터 DB가 무엇을 검색하는지와 오픈소스·상용 제품의 종류, 공개 사용 지표를 읽는 법을 다뤘습니다. 이 글은 같은 제품군 12종을 <strong>기능과 한도</strong> 기준으로 비교합니다. 대상은 pgvector, Milvus, Qdrant, Weaviate, Chroma, LanceDB, Pinecone, OpenSearch, Elasticsearch, Vespa, MongoDB Vector Search, Redis입니다.</p>

<p>기능과 한도는 2026년 9월 27일 기준 각 제품의 공식 문서와 저장소에 적힌 내용을 따릅니다. 이 글은 성능을 측정해 비교하지 않으므로 속도 수치는 싣지 않습니다. 6절의 메모리 크기는 공식 문서의 산정식이나 사이징 안내에 값을 넣어 계산한 결과이며, 해당 절에서 계산값이라고 밝힙니다.</p>

<h2 id="1-무엇부터-정해야-할까요">1. 무엇부터 정해야 할까요</h2>

<p>제품을 비교하기 전에 요구사항을 숫자로 적어 두면 비교할 항목이 분명해집니다. 아래 항목의 순서가 이 글의 절 순서와 같습니다.</p>

<table>
  <thead>
    <tr>
      <th>정할 것</th>
      <th>예시</th>
      <th>비교하는 절</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>인덱스와 메모리 예산</td>
      <td>전부 메모리에 적재, 또는 디스크 인덱스·양자화 허용</td>
      <td>2절</td>
    </tr>
    <tr>
      <td>질의당 결과 수(k)</td>
      <td>재순위화 전 후보 500개</td>
      <td>3절</td>
    </tr>
    <tr>
      <td>필터와 테넌트 격리</td>
      <td>권한·기간 조건, 고객사 10만 곳</td>
      <td>4절</td>
    </tr>
    <tr>
      <td>키워드 검색 결합</td>
      <td>제품 코드·약어의 BM25 검색</td>
      <td>5절</td>
    </tr>
    <tr>
      <td>규모와 증가 속도</td>
      <td>지금 1천만 건, 1년 뒤 1억 건</td>
      <td>6절</td>
    </tr>
    <tr>
      <td>확장 방식</td>
      <td>데이터 2배 증가 시 샤드 추가, 읽기 처리량 증설, 수직 확장</td>
      <td>7절</td>
    </tr>
    <tr>
      <td>운영 주체와 비용 구조</td>
      <td>셀프 호스팅 또는 관리형, 과금 단위</td>
      <td>8절</td>
    </tr>
  </tbody>
</table>

<p>비교한 제품과 기준 버전은 다음과 같습니다. 문서가 버전을 따로 표기하지 않는 제품은 조회일의 최신 안정 릴리스를 적었습니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>분류</th>
      <th>기준 버전</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pgvector<sup id="fnref:pgvector" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">1</a></sup></td>
      <td>PostgreSQL 확장</td>
      <td>0.8.6</td>
    </tr>
    <tr>
      <td>Milvus<sup id="fnref:milvus" role="doc-noteref"><a href="#fn:milvus" class="footnote" rel="footnote">2</a></sup></td>
      <td>전용 벡터 DB</td>
      <td>3.0.2</td>
    </tr>
    <tr>
      <td>Qdrant<sup id="fnref:qdrant" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">3</a></sup></td>
      <td>전용 벡터 DB</td>
      <td>1.19.1</td>
    </tr>
    <tr>
      <td>Weaviate<sup id="fnref:weaviate" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup></td>
      <td>전용 벡터 DB</td>
      <td>1.39.7</td>
    </tr>
    <tr>
      <td>Chroma<sup id="fnref:chroma" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">5</a></sup></td>
      <td>벡터 검색·검색 데이터 인프라</td>
      <td>1.5.9</td>
    </tr>
    <tr>
      <td>LanceDB<sup id="fnref:lancedb" role="doc-noteref"><a href="#fn:lancedb" class="footnote" rel="footnote">6</a></sup></td>
      <td>임베디드 검색·멀티모달 데이터 플랫폼</td>
      <td>0.39.0</td>
    </tr>
    <tr>
      <td>Pinecone<sup id="fnref:pinecone" role="doc-noteref"><a href="#fn:pinecone" class="footnote" rel="footnote">7</a></sup></td>
      <td>관리형 벡터 DB</td>
      <td>API 2026-07</td>
    </tr>
    <tr>
      <td>OpenSearch<sup id="fnref:opensearch" role="doc-noteref"><a href="#fn:opensearch" class="footnote" rel="footnote">8</a></sup></td>
      <td>검색 엔진</td>
      <td>3.8.0</td>
    </tr>
    <tr>
      <td>Elasticsearch<sup id="fnref:elasticsearch" role="doc-noteref"><a href="#fn:elasticsearch" class="footnote" rel="footnote">9</a></sup></td>
      <td>검색 엔진</td>
      <td>9.5.4</td>
    </tr>
    <tr>
      <td>Vespa<sup id="fnref:vespa" role="doc-noteref"><a href="#fn:vespa" class="footnote" rel="footnote">10</a></sup></td>
      <td>검색·추천 플랫폼</td>
      <td>8.754.14</td>
    </tr>
    <tr>
      <td>MongoDB Vector Search<sup id="fnref:mongodb" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup></td>
      <td>문서 DB의 검색 기능</td>
      <td>MongoDB 8.3</td>
    </tr>
    <tr>
      <td>Redis<sup id="fnref:redis" role="doc-noteref"><a href="#fn:redis" class="footnote" rel="footnote">12</a></sup></td>
      <td>인메모리 DB의 검색 기능</td>
      <td>Redis Open Source 8.10.2</td>
    </tr>
  </tbody>
</table>

<h2 id="2-인덱스와-압축은-어떻게-다를까요">2. 인덱스와 압축은 어떻게 다를까요</h2>

<p>벡터 인덱스는 크게 세 갈래입니다. HNSW는 메모리에 근접 그래프를 두고 탐색하므로 속도와 recall의 균형이 좋지만 메모리를 많이 씁니다. IVF 계열은 벡터를 군집으로 나누고 일부 군집만 탐색합니다. 디스크 기반 인덱스는 그래프나 군집의 대부분을 SSD나 오브젝트 스토리지에 두고, 메모리에는 압축된 부분만 올립니다.</p>

<p>압축(양자화)은 인덱스와 따로 봐야 합니다. float32 벡터를 8비트 정수로 바꾸는 스칼라 양자화는 크기가 4분의 1로, 차원당 1비트로 바꾸는 이진 양자화는 32분의 1로 줄어듭니다. 줄어든 정확도는 후보를 더 뽑은 뒤 원본 벡터로 다시 점수를 매기는 재채점(rescoring)으로 보완합니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>벡터 인덱스</th>
      <th>압축</th>
      <th>디스크·GPU</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pgvector</td>
      <td>정확 검색(기본), HNSW, IVFFlat</td>
      <td>halfvec·bit 표현식 인덱스, 원본으로 재채점</td>
      <td>전용 디스크 인덱스와 GPU는 문서에 없음</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>FLAT, IVF·HNSW 계열, DISKANN, SCANN, GPU 인덱스</td>
      <td>SQ8, PQ, RaBitQ(1비트), refine 재채점</td>
      <td>DISKANN·AISAQ(SSD), GPU_CAGRA 등</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>HNSW 하나, 정확 검색 옵션</td>
      <td>scalar(4배), binary(1~2비트), product(최대 64배), rescore</td>
      <td>벡터는 디스크에 저장, 메모리 캐시 계층(tier) 선택, GPU는 인덱스 빌드(검색 가속은 문서에 없음)</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>HNSW(기본), flat, dynamic(실험), HFresh</td>
      <td>PQ, BQ(32배), SQ(4배), RQ, rescoreLimit</td>
      <td>전용 디스크 인덱스와 GPU는 문서에 없음</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td>단일 노드는 HNSW, 분산·Cloud는 SPANN</td>
      <td>문서에 없음</td>
      <td>분산 구성은 인덱스를 오브젝트 스토리지에 저장</td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td>IVF 계열(IVF_PQ, IVF_HNSW_* 등), 정확 검색</td>
      <td>PQ, SQ, RaBitQ, refine_factor</td>
      <td>오브젝트 스토리지 위 인덱스, GPU는 빌드만</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td>인덱스 알고리즘을 설명한 공식 문서 없음</td>
      <td>사용자가 설정하는 압축 옵션은 문서에 없음</td>
      <td>오브젝트 스토리지와 메모리·SSD 캐시</td>
    </tr>
    <tr>
      <td>OpenSearch</td>
      <td>Faiss(기본)·Lucene·JVector 엔진, HNSW·IVF</td>
      <td>SQ, PQ, BQ(1·2·4비트), oversample 재채점</td>
      <td>on_disk 모드(기본 32배 압축), GPU 원격 빌드</td>
    </tr>
    <tr>
      <td>Elasticsearch</td>
      <td>hnsw·flat 계열, bbq_disk</td>
      <td>int8·int4, BBQ, oversample 재채점</td>
      <td>bbq_disk는 Enterprise 구독, GPU는 문서에 없음</td>
    </tr>
    <tr>
      <td>Vespa</td>
      <td>HNSW, 정확 검색</td>
      <td>bfloat16·int8 셀, 이진화 후 2단계 랭킹</td>
      <td>paged attribute로 디스크 이동(HNSW와 함께 쓰기는 비권장)</td>
    </tr>
    <tr>
      <td>MongoDB</td>
      <td>hnsw(기본), flat, 정확 검색(ENN)</td>
      <td>scalar(1바이트), binary(1비트, 원본으로 재채점)</td>
      <td>인덱스 전체를 메모리에 적재(자동 양자화 시 원본 벡터는 디스크)</td>
    </tr>
    <tr>
      <td>Redis</td>
      <td>FLAT, HNSW, SVS-VAMANA(8.2+)</td>
      <td>FLOAT16·INT8 등 저정밀 타입, SVS 압축</td>
      <td>데이터와 인덱스 모두 메모리</td>
    </tr>
  </tbody>
</table>

<p>기본 설정이 제품마다 달라서 기본값끼리 비교하면 서로 다른 압축 수준을 비교하게 됩니다. Elasticsearch는 <code class="language-plaintext highlighter-rouge">float</code> 벡터의 기본 인덱스로, 라이선스가 허용하면 <code class="language-plaintext highlighter-rouge">bbq_disk</code>를, 그렇지 않으면 384차원 이상에서 <code class="language-plaintext highlighter-rouge">bbq_hnsw</code>, 미만에서 <code class="language-plaintext highlighter-rouge">int8_hnsw</code>를 씁니다. OpenSearch의 디스크 기반 모드는 기본 압축률이 32배입니다. Weaviate의 기본 양자화는 <code class="language-plaintext highlighter-rouge">none</code>입니다. Milvus 문서는 <code class="language-plaintext highlighter-rouge">AUTOINDEX</code>를 쓰라고 안내하지만, 오픈소스 문서에는 그때 고르는 알고리즘이 나와 있지 않습니다. 1편에서 다룬 것처럼 같은 recall 목표를 정해 두고 비교해야 합니다.<sup id="fnref:elasticsearch:1" role="doc-noteref"><a href="#fn:elasticsearch" class="footnote" rel="footnote">9</a></sup><sup id="fnref:opensearch:1" role="doc-noteref"><a href="#fn:opensearch" class="footnote" rel="footnote">8</a></sup><sup id="fnref:weaviate:1" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup><sup id="fnref:milvus:1" role="doc-noteref"><a href="#fn:milvus" class="footnote" rel="footnote">2</a></sup></p>

<p>Redis의 SVS 압축 가운데 LVQ·LeanVec은 Intel의 비공개 최적화로, Intel 플랫폼에서만 씁니다. Redis Open Source의 기본 빌드와 Intel이 아닌 플랫폼에서는 그 대신 8비트 스칼라 양자화를 쓰며, 빌드 조건은 8절에서 다룹니다.<sup id="fnref:redis:1" role="doc-noteref"><a href="#fn:redis" class="footnote" rel="footnote">12</a></sup></p>

<h2 id="3-한-번에-몇-개까지-받을-수-있을까요">3. 한 번에 몇 개까지 받을 수 있을까요</h2>

<p>k는 질의 한 번에 돌려받는 최근접 이웃의 수입니다. 재순위화 모델에 넘길 후보를 넉넉히 뽑거나 추천 후보를 한꺼번에 만들 때는 k가 커집니다. k에 직접 상한을 두는 제품도 있고, k와 함께 움직이는 탐색 파라미터나 응답 크기가 실제 한도가 되는 제품도 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>질의당 k 상한</th>
      <th>함께 묶인 값</th>
      <th>관리형·비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pgvector</td>
      <td><code class="language-plaintext highlighter-rouge">LIMIT</code> 자체 상한 없음</td>
      <td>HNSW 결과는 <code class="language-plaintext highlighter-rouge">hnsw.ef_search</code>(기본 40, 최대 1,000) 이내, iterative scan을 켜면 더 탐색</td>
      <td>자체 관리형 없음</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td><code class="language-plaintext highlighter-rouge">topk</code> 16,384</td>
      <td><code class="language-plaintext highlighter-rouge">offset + limit</code>도 result window(기본 16,384) 이내, GPU_CAGRA는 1,024 이하</td>
      <td>Zilliz Cloud Free·Serverless는 1,024</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>문서에 상한 없음</td>
      <td><code class="language-plaintext highlighter-rouge">hnsw_ef</code>, 큰 <code class="language-plaintext highlighter-rouge">offset</code>은 느려짐</td>
      <td>Cloud 한도도 문서에 없음</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td><code class="language-plaintext highlighter-rouge">offset + limit</code> ≤ <code class="language-plaintext highlighter-rouge">QUERY_MAXIMUM_RESULTS</code>(기본 10,000)</td>
      <td>dynamic ef 기본 범위 100~500, limit이 500을 넘으면 ef = limit</td>
      <td>한도를 넘으면 잘린 결과가 아니라 오류</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td>단일 노드 상한은 문서에 없음(기본 10)</td>
      <td>HNSW <code class="language-plaintext highlighter-rouge">ef_search</code> 기본 100</td>
      <td>Chroma Cloud는 쿼리당 300(요청 시 상향)</td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td>문서에 상한 없음(기본 10)</td>
      <td>HNSW 계열 ef 기본 1.5 × limit</td>
      <td>Enterprise REST API도 최대값 명시 없음</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td><code class="language-plaintext highlighter-rouge">top_k</code> 10,000</td>
      <td>응답 크기 4MB</td>
      <td>벡터 값·메타데이터를 포함하면 4MB에 먼저 걸릴 수 있음</td>
    </tr>
    <tr>
      <td>OpenSearch</td>
      <td><code class="language-plaintext highlighter-rouge">k</code> 1~10,000</td>
      <td><code class="language-plaintext highlighter-rouge">k</code>는 샤드마다 적용, 최종 개수는 <code class="language-plaintext highlighter-rouge">size</code></td>
      <td><code class="language-plaintext highlighter-rouge">from + size</code> ≤ 10,000(기본)</td>
    </tr>
    <tr>
      <td>Elasticsearch</td>
      <td><code class="language-plaintext highlighter-rouge">num_candidates</code> ≤ 10,000, <code class="language-plaintext highlighter-rouge">k</code> ≤ <code class="language-plaintext highlighter-rouge">num_candidates</code></td>
      <td>둘 다 샤드마다 적용</td>
      <td><code class="language-plaintext highlighter-rouge">from + size</code> ≤ 10,000(기본)</td>
    </tr>
    <tr>
      <td>Vespa</td>
      <td><code class="language-plaintext highlighter-rouge">targetHits</code> 상한은 문서에 없음</td>
      <td><code class="language-plaintext highlighter-rouge">targetHits</code>는 content node마다, 반환 <code class="language-plaintext highlighter-rouge">hits</code>는 <code class="language-plaintext highlighter-rouge">maxHits</code>(기본 400) 이내</td>
      <td><code class="language-plaintext highlighter-rouge">offset</code>은 <code class="language-plaintext highlighter-rouge">maxOffset</code>(기본 1,000) 이내</td>
    </tr>
    <tr>
      <td>MongoDB</td>
      <td>ANN에서 <code class="language-plaintext highlighter-rouge">limit</code> ≤ <code class="language-plaintext highlighter-rouge">numCandidates</code> ≤ 10,000</td>
      <td><code class="language-plaintext highlighter-rouge">numCandidates</code>는 limit의 20배 이상 권장</td>
      <td>정확 검색(ENN)의 상한은 문서에 없음</td>
    </tr>
    <tr>
      <td>Redis</td>
      <td><code class="language-plaintext highlighter-rouge">KNN</code> 자체 상한은 문서에 없음</td>
      <td>기본 반환이 10개라 <code class="language-plaintext highlighter-rouge">LIMIT 0 &lt;k&gt;</code> 지정, <code class="language-plaintext highlighter-rouge">search-max-search-results</code> 기본 1,000,000</td>
      <td>Redis Cloud Free·Fixed는 10,000</td>
    </tr>
  </tbody>
</table>

<p>검색 엔진 계열은 k를 샤드마다(Vespa는 content node마다) 적용한다는 점을 기억해야 합니다. OpenSearch는 최종 개수를 <code class="language-plaintext highlighter-rouge">size</code>로 따로 정하고, Vespa의 <code class="language-plaintext highlighter-rouge">targetHits</code>는 첫 단계 랭킹에 넘길 개수입니다.<sup id="fnref:opensearch:2" role="doc-noteref"><a href="#fn:opensearch" class="footnote" rel="footnote">8</a></sup><sup id="fnref:elasticsearch:2" role="doc-noteref"><a href="#fn:elasticsearch" class="footnote" rel="footnote">9</a></sup><sup id="fnref:vespa:1" role="doc-noteref"><a href="#fn:vespa" class="footnote" rel="footnote">10</a></sup></p>

<p>pgvector는 <code class="language-plaintext highlighter-rouge">LIMIT 500</code>을 줘도 iterative scan을 켜지 않으면 HNSW 결과가 <code class="language-plaintext highlighter-rouge">hnsw.ef_search</code> 개수를 넘지 않습니다. 기본값 40이면 최대 40행입니다. <code class="language-plaintext highlighter-rouge">ef_search</code>를 올리거나(최대 1,000) 0.8.0부터 생긴 iterative scan을 켜야 합니다. MongoDB는 <code class="language-plaintext highlighter-rouge">numCandidates</code> 상한이 10,000이고 문서가 limit의 20배 이상을 권하므로, 권장 비율을 지키면 limit은 500까지입니다(10,000 ÷ 20으로 계산한 값).<sup id="fnref:pgvector:1" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">1</a></sup><sup id="fnref:mongodb:1" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup></p>

<p>k를 키우면 탐색 후보와 응답 크기도 함께 커집니다. 결과를 대량으로 꺼내야 한다면 페이지 단위 조회 API를 쓰는 편이 맞습니다. Weaviate는 전체를 순회할 때 cursor를 쓰라고 안내하고, Milvus 문서는 SDK의 search iterator에는 16,384 제한이 없다고 밝힙니다.<sup id="fnref:weaviate:2" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup><sup id="fnref:milvus:2" role="doc-noteref"><a href="#fn:milvus" class="footnote" rel="footnote">2</a></sup></p>

<h2 id="4-필터와-멀티테넌시는-어떻게-다를까요">4. 필터와 멀티테넌시는 어떻게 다를까요</h2>

<p>메타데이터 필터는 벡터 탐색과 어느 시점에 결합하느냐에 따라 결과가 달라집니다. 제품마다 부르는 이름이 다르므로, 조건을 언제 확인하는지로 나눠 보면 비교가 쉽습니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 985 528" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vector-filter-timing-archify-diagram-title">메타데이터 필터를 적용하는 세 시점</title>
        <desc id="vector-filter-timing-archify-diagram-description">A workflow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vector-filter-timing-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vector-filter-timing-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vector-filter-timing-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vector-filter-timing-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vector-filter-timing-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vector-filter-timing-grid)" />

        <!-- Swimlanes -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-0" x="40" y="52" width="929" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="74" class="t-dim" font-size="10" font-weight="600">01 / 탐색 전 — 사전 필터</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-1" x="40" y="176" width="929" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="198" class="t-dim" font-size="10" font-weight="600">02 / 탐색 후 — 사후 필터</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-2" x="40" y="300" width="929" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="322" class="t-dim" font-size="10" font-weight="600">03 / 탐색 중 — 필터 인지 탐색</text>

        <!-- Phase headers -->


        <!-- Workflow groups -->


        <!-- Edge paths -->
        <path data-edge-from="inline_query" data-edge-to="inline_search" data-edge-key="0" data-composition-points="168,367;196,367" d="M 168 367 L 196 367" class="a-default" stroke-width="1.4" marker-end="url(#vector-filter-timing-arrowhead)" />
        <path data-edge-from="inline_search" data-edge-to="inline_result" data-edge-label="조건 맞는 이웃만" data-edge-key="1" data-composition-points="346,367;611,367" d="M 346 367 L 611 367" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-filter-timing-arrowhead-emphasis)" />
        <path data-edge-from="post_filter" data-edge-to="post_result" data-edge-key="2" data-composition-points="583,243;611,243" d="M 583 243 L 611 243" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-filter-timing-arrowhead-emphasis)" />
        <path data-edge-from="post_filter" data-edge-to="post_search" data-edge-label="k개 미만이면 더 탐색" data-edge-key="3" data-composition-points="451,236;337,236" d="M 451 236 L 337 236" class="a-dashed" stroke-width="1.4" marker-end="url(#vector-filter-timing-arrowhead-dashed)" />
        <path data-edge-from="post_query" data-edge-to="post_search" data-edge-key="4" data-composition-points="168,243;205,243" d="M 168 243 L 205 243" class="a-default" stroke-width="1.4" marker-end="url(#vector-filter-timing-arrowhead)" />
        <path data-edge-from="post_search" data-edge-to="post_filter" data-edge-key="5" data-composition-points="337,250;451,250" d="M 337 250 L 451 250" class="a-default" stroke-width="1.4" marker-end="url(#vector-filter-timing-arrowhead)" />
        <path data-edge-from="pre_filter" data-edge-to="pre_search" data-edge-key="6" data-composition-points="337,119;451,119" d="M 337 119 L 451 119" class="a-default" stroke-width="1.4" marker-end="url(#vector-filter-timing-arrowhead)" />
        <path data-edge-from="pre_query" data-edge-to="pre_filter" data-edge-key="7" data-composition-points="168,119;205,119" d="M 168 119 L 205 119" class="a-default" stroke-width="1.4" marker-end="url(#vector-filter-timing-arrowhead)" />
        <path data-edge-from="pre_search" data-edge-to="pre_result" data-edge-key="8" data-composition-points="583,119;611,119" d="M 583 119 L 611 119" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-filter-timing-arrowhead-emphasis)" />

        <!-- Nodes -->
        <g id="vector-filter-timing-node-pre_query" data-node-id="pre_query" data-node-label="질의" tabindex="0" role="button" aria-label="Focus 질의, 벡터 + 조건, 탐색 전 — 사전 필터" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="벡터 + 조건" data-node-context="탐색 전 — 사전 필터">
          <title>질의 · 벡터 + 조건 · 탐색 전 — 사전 필터</title>
          <rect x="48" y="93" width="120" height="52" rx="6" class="c-mask" />
          <rect x="48" y="93" width="120" height="52" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(54 99) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="108" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">질의</text>
          <text data-detail="context" x="108" y="131" class="t-muted" font-size="8" text-anchor="middle">벡터 + 조건</text>
        </g>

        <g id="vector-filter-timing-node-pre_filter" data-node-id="pre_filter" data-node-label="조건으로 추림" tabindex="0" role="button" aria-label="Focus 조건으로 추림, 후보 집합 확정, 탐색 전 — 사전 필터" aria-pressed="false" data-node-kind="security" data-node-sublabel="후보 집합 확정" data-node-context="탐색 전 — 사전 필터">
          <title>조건으로 추림 · 후보 집합 확정 · 탐색 전 — 사전 필터</title>
          <rect x="205" y="93" width="132" height="52" rx="6" class="c-mask" />
          <rect x="205" y="93" width="132" height="52" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(211 99) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="271" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">조건으로 추림</text>
          <text data-detail="context" x="271" y="131" class="t-muted" font-size="8" text-anchor="middle">후보 집합 확정</text>
        </g>

        <g id="vector-filter-timing-node-pre_search" data-node-id="pre_search" data-node-label="남은 후보 비교" tabindex="0" role="button" aria-label="Focus 남은 후보 비교, 정확 또는 ANN, 탐색 전 — 사전 필터" aria-pressed="false" data-node-kind="backend" data-node-sublabel="정확 또는 ANN" data-node-context="탐색 전 — 사전 필터">
          <title>남은 후보 비교 · 정확 또는 ANN · 탐색 전 — 사전 필터</title>
          <rect x="451" y="93" width="132" height="52" rx="6" class="c-mask" />
          <rect x="451" y="93" width="132" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(457 99) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="517" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">남은 후보 비교</text>
          <text data-detail="context" x="517" y="131" class="t-muted" font-size="8" text-anchor="middle">정확 또는 ANN</text>
        </g>

        <g id="vector-filter-timing-node-pre_result" data-node-id="pre_result" data-node-label="Top-k" tabindex="0" role="button" aria-label="Focus Top-k, 조건 만족, 탐색 전 — 사전 필터" aria-pressed="false" data-node-kind="database" data-node-sublabel="조건 만족" data-node-context="탐색 전 — 사전 필터">
          <title>Top-k · 조건 만족 · 탐색 전 — 사전 필터</title>
          <rect x="611" y="93" width="120" height="52" rx="6" class="c-mask" />
          <rect x="611" y="93" width="120" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(617 99) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="671" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Top-k</text>
          <text data-detail="context" x="671" y="131" class="t-muted" font-size="8" text-anchor="middle">조건 만족</text>
        </g>

        <g id="vector-filter-timing-node-post_query" data-node-id="post_query" data-node-label="질의" tabindex="0" role="button" aria-label="Focus 질의, 벡터 + 조건, 탐색 후 — 사후 필터" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="벡터 + 조건" data-node-context="탐색 후 — 사후 필터">
          <title>질의 · 벡터 + 조건 · 탐색 후 — 사후 필터</title>
          <rect x="48" y="217" width="120" height="52" rx="6" class="c-mask" />
          <rect x="48" y="217" width="120" height="52" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(54 223) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="108" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">질의</text>
          <text data-detail="context" x="108" y="255" class="t-muted" font-size="8" text-anchor="middle">벡터 + 조건</text>
        </g>

        <g id="vector-filter-timing-node-post_search" data-node-id="post_search" data-node-label="ANN 탐색" tabindex="0" role="button" aria-label="Focus ANN 탐색, 후보 먼저, 탐색 후 — 사후 필터" aria-pressed="false" data-node-kind="backend" data-node-sublabel="후보 먼저" data-node-context="탐색 후 — 사후 필터">
          <title>ANN 탐색 · 후보 먼저 · 탐색 후 — 사후 필터</title>
          <rect x="205" y="217" width="132" height="52" rx="6" class="c-mask" />
          <rect x="205" y="217" width="132" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(211 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="271" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">ANN 탐색</text>
          <text data-detail="context" x="271" y="255" class="t-muted" font-size="8" text-anchor="middle">후보 먼저</text>
        </g>

        <g id="vector-filter-timing-node-post_filter" data-node-id="post_filter" data-node-label="조건으로 거름" tabindex="0" role="button" aria-label="Focus 조건으로 거름, 후보 일부 탈락, 탐색 후 — 사후 필터" aria-pressed="false" data-node-kind="security" data-node-sublabel="후보 일부 탈락" data-node-context="탐색 후 — 사후 필터">
          <title>조건으로 거름 · 후보 일부 탈락 · 탐색 후 — 사후 필터</title>
          <rect x="451" y="217" width="132" height="52" rx="6" class="c-mask" />
          <rect x="451" y="217" width="132" height="52" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(457 223) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="517" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">조건으로 거름</text>
          <text data-detail="context" x="517" y="255" class="t-muted" font-size="8" text-anchor="middle">후보 일부 탈락</text>
        </g>

        <g id="vector-filter-timing-node-post_result" data-node-id="post_result" data-node-label="Top-k" tabindex="0" role="button" aria-label="Focus Top-k, k개 미만 가능, 탐색 후 — 사후 필터" aria-pressed="false" data-node-kind="database" data-node-sublabel="k개 미만 가능" data-node-context="탐색 후 — 사후 필터">
          <title>Top-k · k개 미만 가능 · 탐색 후 — 사후 필터</title>
          <rect x="611" y="217" width="120" height="52" rx="6" class="c-mask" />
          <rect x="611" y="217" width="120" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(617 223) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="671" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Top-k</text>
          <text data-detail="context" x="671" y="255" class="t-muted" font-size="8" text-anchor="middle">k개 미만 가능</text>
        </g>

        <g id="vector-filter-timing-node-inline_query" data-node-id="inline_query" data-node-label="질의" tabindex="0" role="button" aria-label="Focus 질의, 벡터 + 조건, 탐색 중 — 필터 인지 탐색" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="벡터 + 조건" data-node-context="탐색 중 — 필터 인지 탐색">
          <title>질의 · 벡터 + 조건 · 탐색 중 — 필터 인지 탐색</title>
          <rect x="48" y="341" width="120" height="52" rx="6" class="c-mask" />
          <rect x="48" y="341" width="120" height="52" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(54 347) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="108" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">질의</text>
          <text data-detail="context" x="108" y="379" class="t-muted" font-size="8" text-anchor="middle">벡터 + 조건</text>
        </g>

        <g id="vector-filter-timing-node-inline_search" data-node-id="inline_search" data-node-label="그래프 탐색" tabindex="0" role="button" aria-label="Focus 그래프 탐색, 이웃마다 조건 검사, 탐색 중 — 필터 인지 탐색" aria-pressed="false" data-node-kind="backend" data-node-sublabel="이웃마다 조건 검사" data-node-context="탐색 중 — 필터 인지 탐색">
          <title>그래프 탐색 · 이웃마다 조건 검사 · 탐색 중 — 필터 인지 탐색</title>
          <rect x="196" y="341" width="150" height="52" rx="6" class="c-mask" />
          <rect x="196" y="341" width="150" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(202 347) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="271" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">그래프 탐색</text>
          <text data-detail="context" x="271" y="379" class="t-muted" font-size="8" text-anchor="middle">이웃마다 조건 검사</text>
        </g>

        <g id="vector-filter-timing-node-inline_result" data-node-id="inline_result" data-node-label="Top-k" tabindex="0" role="button" aria-label="Focus Top-k, 조건 만족, 탐색 중 — 필터 인지 탐색" aria-pressed="false" data-node-kind="database" data-node-sublabel="조건 만족" data-node-context="탐색 중 — 필터 인지 탐색">
          <title>Top-k · 조건 만족 · 탐색 중 — 필터 인지 탐색</title>
          <rect x="611" y="341" width="120" height="52" rx="6" class="c-mask" />
          <rect x="611" y="341" width="120" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(617 347) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="671" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Top-k</text>
          <text data-detail="context" x="671" y="379" class="t-muted" font-size="8" text-anchor="middle">조건 만족</text>
        </g>

        <!-- Edge labels -->

        <g data-detail="context" data-edge-from="inline_search" data-edge-to="inline_result" data-edge-label="조건 맞는 이웃만" data-edge-key="1">
          <rect x="435.1" y="347" width="86.8" height="14" rx="3" class="c-mask" />
          <text x="478.5" y="357" class="t-backend" font-size="8" text-anchor="middle">조건 맞는 이웃만</text>
        </g>

        <g data-detail="context" data-edge-from="post_filter" data-edge-to="post_search" data-edge-label="k개 미만이면 더 탐색" data-edge-key="3">
          <rect x="341" y="216" width="106" height="14" rx="3" class="c-mask" />
          <text x="394" y="226" class="t-database" font-size="8" text-anchor="middle">k개 미만이면 더 탐색</text>
        </g>






        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="20" y="428" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="frontend" data-legend-kind="frontend" data-legend-label="질의" data-legend-x="20" data-legend-baseline="448" data-legend-width="61">
            <rect x="20" y="440" width="14" height="9" rx="2" class="c-frontend" stroke-width="1" />
            <text x="42" y="448" class="t-muted" font-size="7.5" font-weight="500">질의</text>
          </g>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="벡터 탐색" data-legend-x="88" data-legend-baseline="448" data-legend-width="83">
            <rect x="88" y="440" width="14" height="9" rx="2" class="c-backend" stroke-width="1" />
            <text x="110" y="448" class="t-muted" font-size="7.5" font-weight="500">벡터 탐색</text>
          </g>
          <g data-legend-semantic-kind="security" data-legend-kind="security" data-legend-label="필터 조건" data-legend-x="178" data-legend-baseline="448" data-legend-width="83">
            <rect x="178" y="440" width="14" height="9" rx="2" class="c-security" stroke-width="1" />
            <text x="200" y="448" class="t-muted" font-size="7.5" font-weight="500">필터 조건</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="결과" data-legend-x="268" data-legend-baseline="448" data-legend-width="61">
            <rect x="268" y="440" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="290" y="448" class="t-muted" font-size="7.5" font-weight="500">결과</text>
          </g>
        </g>
      </svg>
</div><figcaption>메타데이터 필터를 탐색 전·후·도중에 적용할 때의 차이</figcaption>
</figure>

<ul>
  <li><strong>탐색 전(사전 필터):</strong> 조건에 맞는 후보 집합을 먼저 정하고 그 안에서만 찾습니다. 조건에 맞는 후보가 아주 적으면 인덱스 대신 남은 후보를 모두 비교하는 정확 검색으로 바꾸는 제품도 있습니다.</li>
  <li><strong>탐색 후(사후 필터):</strong> ANN으로 후보를 먼저 찾고 조건으로 거릅니다. 조건에 맞는 후보가 적으면 결과가 k개보다 적습니다. 그래서 k개가 찰 때까지 인덱스를 더 탐색하는 기능을 두는 제품이 있습니다.</li>
  <li><strong>탐색 중(필터 인지 탐색):</strong> 그래프를 따라가며 이웃마다 조건을 확인하고, 맞는 노드만 결과에 넣습니다. 조건에 걸려 빠지는 이웃이 많으면 따라갈 경로가 줄어듭니다. Qdrant와 Weaviate는 이때 걸러진 이웃의 이웃까지 살피는 ACORN 방식을 제공합니다.</li>
</ul>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>필터를 적용하는 시점</th>
      <th>k개가 안 찰 때</th>
      <th>멀티테넌시 권장 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pgvector</td>
      <td>사후 필터(인덱스 스캔 뒤)</td>
      <td>iterative scan(0.8.0+, 기본 꺼짐)</td>
      <td>리스트 파티셔닝 또는 테넌트별 테이블</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>사전 필터(기본)</td>
      <td>iterative filtering 옵션</td>
      <td>partition key(수백만 테넌트), database·collection·partition 수준도 가능</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>payload index로 전략 선택, 좁으면 정확 검색, 아니면 필터 인지 탐색(HNSW), ACORN 옵션(1.16+)</td>
      <td>문서에 없음</td>
      <td>한 컬렉션 + <code class="language-plaintext highlighter-rouge">is_tenant</code> 인덱스, 큰 테넌트는 전용 샤드</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>사전 필터(허용 목록을 만든 뒤 탐색), ACORN 기본(1.34+)</td>
      <td>문서에 없음</td>
      <td>네이티브 멀티테넌시(테넌트마다 샤드), 비활성 테넌트 offload(S3 offload 모듈 필요)</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td><code class="language-plaintext highlighter-rouge">where</code>·<code class="language-plaintext highlighter-rouge">where_document</code>, 적용 시점은 문서에 없음</td>
      <td>문서에 없음</td>
      <td>tenant·database·collection 계층, 분산 구성은 고객마다 컬렉션</td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td>사전 필터(기본) 또는 사후 필터</td>
      <td>사후 필터는 limit 미만·0건 가능</td>
      <td>공식 권장 방식은 문서에 없음</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td>메타데이터 필터, 적용 시점은 문서에 없음</td>
      <td>문서에 없음</td>
      <td>테넌트마다 namespace(따로 저장해 물리적으로 분리)</td>
    </tr>
    <tr>
      <td>OpenSearch</td>
      <td>필터 인지 탐색(efficient filtering), 좁으면 정확 검색</td>
      <td>k개 보장(대상이 k개 이상일 때), <code class="language-plaintext highlighter-rouge">post_filter</code>는 미만 가능</td>
      <td>벡터 검색 문서에 권장 방식 없음</td>
    </tr>
    <tr>
      <td>Elasticsearch</td>
      <td>필터 인지 탐색(<code class="language-plaintext highlighter-rouge">knn</code>의 <code class="language-plaintext highlighter-rouge">filter</code>)</td>
      <td>k개 보장, 다른 절·<code class="language-plaintext highlighter-rouge">post_filter</code>는 미만 가능</td>
      <td>벡터 검색 문서에 권장 방식 없음</td>
    </tr>
    <tr>
      <td>Vespa</td>
      <td>사전 필터(기본), 적중 비율이 2%(기본값) 미만이면 정확 검색</td>
      <td>사후 필터는 기본 꺼짐, 켜면 targetHits 자동 상향(기본 최대 20배)</td>
      <td>개인 데이터는 streaming search(정확 검색)</td>
    </tr>
    <tr>
      <td>MongoDB</td>
      <td>인덱스에 등록한 filter 필드로 사전 필터</td>
      <td>문서에 없음</td>
      <td>한 컬렉션 + <code class="language-plaintext highlighter-rouge">tenant_id</code> 사전 필터, 작은 테넌트가 많으면 flat 인덱스</td>
    </tr>
    <tr>
      <td>Redis</td>
      <td>사후 필터를 배치로 반복(배치 모드)과 사전 필터 후 정확 검색 가운데 자동 선택</td>
      <td>배치 모드는 k개가 찰 때까지 반복</td>
      <td>전용 가이드 없음, 테넌트별 인덱스 별칭 예시</td>
    </tr>
  </tbody>
</table>

<p>멀티테넌시 권장 방식은 제품 구조를 따라갑니다. Qdrant는 테넌트마다 컬렉션을 만들지 말고 한 컬렉션에 모은 뒤 <code class="language-plaintext highlighter-rouge">is_tenant</code> 인덱스로 나누라고 안내합니다. MongoDB도 테넌트별 컬렉션·DB를 권하지 않습니다. 반대로 Weaviate는 테넌트마다 샤드를 두는 네이티브 멀티테넌시를, Pinecone은 테넌트마다 namespace를 권합니다. Pinecone 문서는 메타데이터 필터로 테넌트를 나누면 필터와 관계없이 namespace 전체를 스캔하므로 모든 테넌트의 데이터만큼 비용이 든다고 설명합니다.<sup id="fnref:qdrant:1" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">3</a></sup><sup id="fnref:mongodb:2" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup><sup id="fnref:weaviate:3" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup><sup id="fnref:pinecone:1" role="doc-noteref"><a href="#fn:pinecone" class="footnote" rel="footnote">7</a></sup></p>

<h2 id="5-하이브리드-검색은-어디까지-될까요">5. 하이브리드 검색은 어디까지 될까요</h2>

<p>의미 검색은 표현이 달라도 관련 문서를 찾지만, 제품 코드나 약어처럼 글자가 정확히 맞아야 하는 질의에는 약합니다. 하이브리드 검색은 벡터 검색과 키워드 검색의 결과를 합쳐 이 약점을 보완합니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 940 520" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vector-hybrid-fusion-archify-diagram-title">하이브리드 검색의 결합 흐름</title>
        <desc id="vector-hybrid-fusion-archify-diagram-description">A data-flow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vector-hybrid-fusion-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vector-hybrid-fusion-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vector-hybrid-fusion-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vector-hybrid-fusion-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vector-hybrid-fusion-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vector-hybrid-fusion-grid)" />

        <!-- Data Stages -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="0" x="16" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="100" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">01 / 질의</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="1" x="231" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="315" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">02 / 후보 검색</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="2" x="446" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="530" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">03 / 결합</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="3" x="661" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="745" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">04 / 재순위화</text>

        <!-- Flow paths -->
        <path data-edge-from="query" data-edge-to="dense" data-edge-label="질의 벡터" data-edge-key="0" data-composition-points="156,264;207.5,264;207.5,157;259,157" d="M 156 264 L 207.5 264 L 207.5 157 L 259 157" class="a-default" stroke-width="1.4" marker-end="url(#vector-hybrid-fusion-arrowhead)" />
        <path data-edge-from="query" data-edge-to="keyword" data-edge-label="검색어" data-edge-key="1" data-composition-points="156,278;207.5,278;207.5,385;259,385" d="M 156 278 L 207.5 278 L 207.5 385 L 259 385" class="a-default" stroke-width="1.4" marker-end="url(#vector-hybrid-fusion-arrowhead)" />
        <path data-edge-from="dense" data-edge-to="fusion" data-edge-label="순위 목록 A" data-edge-key="2" data-composition-points="371,157;422.5,157;422.5,264;474,264" d="M 371 157 L 422.5 157 L 422.5 264 L 474 264" class="a-default" stroke-width="1.4" marker-end="url(#vector-hybrid-fusion-arrowhead)" />
        <path data-edge-from="keyword" data-edge-to="fusion" data-edge-label="순위 목록 B" data-edge-key="3" data-composition-points="371,385;422.5,385;422.5,278;474,278" d="M 371 385 L 422.5 385 L 422.5 278 L 474 278" class="a-default" stroke-width="1.4" marker-end="url(#vector-hybrid-fusion-arrowhead)" />
        <path data-edge-from="fusion" data-edge-to="rerank" data-edge-label="상위 후보" data-edge-key="4" data-composition-points="586,271;689,271" d="M 586 271 L 689 271" class="a-dashed" stroke-width="1.4" marker-end="url(#vector-hybrid-fusion-arrowhead-dashed)" />

        <!-- Nodes -->
        <g id="vector-hybrid-fusion-node-query" data-node-id="query" data-node-label="사용자 질의" tabindex="0" role="button" aria-label="Focus 사용자 질의, 문장 + 키워드, 01 / 질의" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="문장 + 키워드" data-node-context="01 / 질의">
          <title>사용자 질의 · 문장 + 키워드 · 01 / 질의</title>
          <rect x="44" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="44" y="242" width="112" height="58" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(50 248) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">사용자 질의</text>
          <text data-detail="context" x="100" y="279" class="t-muted" font-size="7" text-anchor="middle">문장 + 키워드</text>
        </g>

        <g id="vector-hybrid-fusion-node-dense" data-node-id="dense" data-node-label="벡터 검색" tabindex="0" role="button" aria-label="Focus 벡터 검색, dense Top-N, 02 / 후보 검색" aria-pressed="false" data-node-kind="backend" data-node-sublabel="dense Top-N" data-node-context="02 / 후보 검색">
          <title>벡터 검색 · dense Top-N · 02 / 후보 검색</title>
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 134) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">벡터 검색</text>
          <text data-detail="context" x="315" y="165" class="t-muted" font-size="7" text-anchor="middle">dense Top-N</text>
        </g>

        <g id="vector-hybrid-fusion-node-keyword" data-node-id="keyword" data-node-label="키워드 검색" tabindex="0" role="button" aria-label="Focus 키워드 검색, BM25 · 희소 벡터, 02 / 후보 검색" aria-pressed="false" data-node-kind="backend" data-node-sublabel="BM25 · 희소 벡터" data-node-context="02 / 후보 검색">
          <title>키워드 검색 · BM25 · 희소 벡터 · 02 / 후보 검색</title>
          <rect x="259" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="356" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 362) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">키워드 검색</text>
          <text data-detail="context" x="315" y="393" class="t-muted" font-size="7" text-anchor="middle">BM25 · 희소 벡터</text>
        </g>

        <g id="vector-hybrid-fusion-node-fusion" data-node-id="fusion" data-node-label="결과 결합" tabindex="0" role="button" aria-label="Focus 결과 결합, RRF · 점수 정규화, 03 / 결합" aria-pressed="false" data-node-kind="database" data-node-sublabel="RRF · 점수 정규화" data-node-context="03 / 결합">
          <title>결과 결합 · RRF · 점수 정규화 · 03 / 결합</title>
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(480 248) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">결과 결합</text>
          <text data-detail="context" x="530" y="279" class="t-muted" font-size="7" text-anchor="middle">RRF · 점수 정규화</text>
        </g>

        <g id="vector-hybrid-fusion-node-rerank" data-node-id="rerank" data-node-label="재순위화 모델" tabindex="0" role="button" aria-label="Focus 재순위화 모델, cross-encoder 등, 04 / 재순위화" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="cross-encoder 등" data-node-context="04 / 재순위화">
          <title>재순위화 모델 · cross-encoder 등 · 04 / 재순위화</title>
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(695 248) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">재순위화 모델</text>
          <text data-detail="context" x="745" y="279" class="t-muted" font-size="7" text-anchor="middle">cross-encoder 등</text>
        </g>

        <!-- Flow labels -->
        <g data-detail="context" data-edge-from="query" data-edge-to="dense" data-edge-label="질의 벡터" data-edge-key="0">
          <rect x="179.45" y="189.5" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="200.5" class="t-muted" font-size="8" text-anchor="middle">질의 벡터</text>
        </g>
        <g data-detail="context" data-edge-from="query" data-edge-to="keyword" data-edge-label="검색어" data-edge-key="1">
          <rect x="186.8" y="310.5" width="41.4" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="321.5" class="t-muted" font-size="8" text-anchor="middle">검색어</text>
        </g>
        <g data-detail="context" data-edge-from="dense" data-edge-to="fusion" data-edge-label="순위 목록 A" data-edge-key="2">
          <rect x="389.55" y="189.5" width="65.9" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="200.5" class="t-muted" font-size="8" text-anchor="middle">순위 목록 A</text>
        </g>
        <g data-detail="context" data-edge-from="keyword" data-edge-to="fusion" data-edge-label="순위 목록 B" data-edge-key="3">
          <rect x="389.55" y="310.5" width="65.9" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="321.5" class="t-muted" font-size="8" text-anchor="middle">순위 목록 B</text>
        </g>
        <g data-detail="context" data-edge-from="fusion" data-edge-to="rerank" data-edge-label="상위 후보" data-edge-key="4">
          <rect x="609.45" y="250" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="637.5" y="261" class="t-messagebus" font-size="8" text-anchor="middle">상위 후보</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="464" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="dashed" data-legend-x="40" data-legend-baseline="484" data-legend-width="88">
            <path d="M 40 481 L 74 481" class="a-dashed" stroke-width="1.4" marker-end="url(#vector-hybrid-fusion-arrowhead-dashed)" />
            <text x="83" y="484" class="t-muted" font-size="10" font-weight="500">선택 단계</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="결합 단계" data-legend-x="150" data-legend-baseline="484" data-legend-width="88">
            <rect x="150" y="476" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="172" y="484" class="t-muted" font-size="10" font-weight="500">결합 단계</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="260" data-legend-baseline="484" data-legend-width="88">
            <path d="M 260 481 L 294 481" class="a-default" stroke-width="1.4" marker-end="url(#vector-hybrid-fusion-arrowhead)" />
            <text x="303" y="484" class="t-muted" font-size="10" font-weight="500">후보 전달</text>
          </g>
        </g>
      </svg>
</div><figcaption>벡터 검색과 키워드 검색의 순위를 합친 뒤 필요하면 재순위화하는 흐름</figcaption>
</figure>

<p>결과를 합치는 방식은 크게 두 가지입니다. RRF(Reciprocal Rank Fusion)는 점수 대신 각 목록의 순위로 1 ÷ (상수 + 순위)를 계산해 더합니다. 점수 분포가 달라도 바로 합칠 수 있고, 여러 제품이 상수 60을 기본으로 씁니다. 점수 기반 결합(선형 결합)은 두 점수를 같은 범위로 정규화한 뒤 가중합합니다. 가중치로 한쪽을 강조할 수 있지만, 정규화 방법에 따라 결과가 달라집니다. 재순위화는 결합한 뒤에 상위 후보의 순서를 모델로 다시 매기는 단계입니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>키워드 검색</th>
      <th>희소 벡터</th>
      <th>결과 결합</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pgvector</td>
      <td>PostgreSQL 전문 검색과 함께 사용</td>
      <td><code class="language-plaintext highlighter-rouge">sparsevec</code></td>
      <td>내장 결합 없음, RRF·cross-encoder 패턴 안내</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>BM25 function(분석기를 켠 텍스트 필드)</td>
      <td><code class="language-plaintext highlighter-rouge">SPARSE_FLOAT_VECTOR</code></td>
      <td>RRF(기본 60), Weighted, 요청별 가중 RRF(3.0.1+)</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>text payload index, BM25 희소 임베딩을 서버에서 생성</td>
      <td>희소 벡터(정확 검색)</td>
      <td>Query API의 prefetch와 RRF·DBSF, RRF 가중치(1.17+)</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>BM25F 내장</td>
      <td>사용자가 넣는 희소 벡터는 문서에 없음</td>
      <td>relativeScoreFusion(기본)·rankedFusion, <code class="language-plaintext highlighter-rouge">alpha</code> 기본 0.75</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td><code class="language-plaintext highlighter-rouge">$contains</code>·<code class="language-plaintext highlighter-rouge">$regex</code> 필터(점수 없음), BM25 희소 임베딩 함수</td>
      <td>희소 벡터 인덱스(컬렉션 생성 시 선언, 단일 노드 지원은 문서에 없음)</td>
      <td>RRF·선형 결합(Search API, Cloud 전용)</td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td>네이티브 전문 검색(FTS, BM25)</td>
      <td>문서에 없음</td>
      <td>RRF(기본), 선형 결합, 모델 기반 reranker</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td>전문 검색(BM25, API 2026-07 GA, 서버리스·OnDemand 한정)</td>
      <td>희소 벡터를 밀집 벡터와 한 레코드에</td>
      <td>전문 검색으로 거른 뒤 밀집 벡터 순위, 또는 클라이언트 RRF·alpha 가중</td>
    </tr>
    <tr>
      <td>OpenSearch</td>
      <td>BM25 내장</td>
      <td>neural sparse, <code class="language-plaintext highlighter-rouge">sparse_vector</code>(3.3+)</td>
      <td>normalization processor, RRF(2.19+)</td>
    </tr>
    <tr>
      <td>Elasticsearch</td>
      <td>BM25 내장</td>
      <td><code class="language-plaintext highlighter-rouge">sparse_vector</code>(ELSER)</td>
      <td><code class="language-plaintext highlighter-rouge">rrf</code>·<code class="language-plaintext highlighter-rouge">linear</code> retriever, <code class="language-plaintext highlighter-rouge">text_similarity_reranker</code></td>
    </tr>
    <tr>
      <td>Vespa</td>
      <td><code class="language-plaintext highlighter-rouge">bm25</code> rank feature(<code class="language-plaintext highlighter-rouge">enable-bm25</code>)</td>
      <td>mapped 텐서</td>
      <td>랭킹 식, global-phase의 <code class="language-plaintext highlighter-rouge">reciprocal_rank_fusion</code>·<code class="language-plaintext highlighter-rouge">normalize_linear</code></td>
    </tr>
    <tr>
      <td>MongoDB</td>
      <td>MongoDB Search(<code class="language-plaintext highlighter-rouge">$search</code>)</td>
      <td>문서에 없음</td>
      <td><code class="language-plaintext highlighter-rouge">$rankFusion</code>(RRF, 8.0+), <code class="language-plaintext highlighter-rouge">$scoreFusion</code>(8.3+), <code class="language-plaintext highlighter-rouge">$rerank</code>(Atlas Preview)</td>
    </tr>
    <tr>
      <td>Redis</td>
      <td>TEXT 필드(기본 스코어러 BM25STD)</td>
      <td>문서의 타입 목록에 없음</td>
      <td><code class="language-plaintext highlighter-rouge">FT.HYBRID</code>(8.4+): RRF 기본, LINEAR</td>
    </tr>
  </tbody>
</table>

<p>세부 조건에서도 차이가 납니다. MongoDB의 <code class="language-plaintext highlighter-rouge">$rankFusion</code>·<code class="language-plaintext highlighter-rouge">$scoreFusion</code>은 페이지네이션을 지원하지 않고 하위 파이프라인을 차례로 실행합니다. Chroma의 Search API는 Chroma Cloud에서만 쓸 수 있습니다. Weaviate는 결합하기 전에 양쪽에서 각각 최소 <code class="language-plaintext highlighter-rouge">offset</code> + <code class="language-plaintext highlighter-rouge">QUERY_HYBRID_MAXIMUM_RESULTS</code>(기본 100)개의 후보를 가져옵니다.<sup id="fnref:mongodb:3" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup><sup id="fnref:chroma:1" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">5</a></sup><sup id="fnref:weaviate:4" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup></p>

<h2 id="6-1억-건이-넘으면-무엇이-달라질까요">6. 1억 건이 넘으면 무엇이 달라질까요</h2>

<h3 id="문서화된-한도와-대규모용-기능">문서화된 한도와 대규모용 기능</h3>

<p>셀프 호스팅 제품의 문서는 벡터 수 상한을 거의 두지 않습니다. 실제 한계는 메모리와 샤드 수, 관리형 플랜의 할당량에서 나옵니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>문서화된 한도</th>
      <th>대규모용 기능</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pgvector</td>
      <td>비파티션 테이블 32TB(PostgreSQL 기본)</td>
      <td>파티셔닝, halfvec·이진 양자화 인덱스, 병렬 빌드</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>엔티티 수 무제한, 컬렉션당 샤드 16, 로드할 데이터는 메모리의 90% 미만</td>
      <td>DISKANN·AISAQ, Tiered Storage(2.6.4+), 기본 mmap, GPU 인덱스</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>벡터 수 한도는 문서에 없음, Cloud 클러스터당 컬렉션 1,000</td>
      <td>양자화, cold tier, 사용자 정의 샤딩</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>샤드당 한도는 문서에 없음</td>
      <td>양자화, HFresh 인덱스, 비활성 테넌트 offload(S3 offload 모듈 필요)</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td>단일 노드는 보통 1천만 건 미만 규모, Cloud 컬렉션당 500만 건(요청 시 상향)</td>
      <td>분산 구성(SPANN, 오브젝트 스토리지)</td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td>한도는 문서에 없음</td>
      <td>오브젝트 스토리지 위 IVF 인덱스, Enterprise 분산 구성</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td>인덱스·namespace 레코드 한도는 문서에 없음</td>
      <td>서버리스 slab 구조, Dedicated Read Nodes</td>
    </tr>
    <tr>
      <td>OpenSearch</td>
      <td>오픈소스 문서에는 없음, AWS 도메인은 샤드 크기 쿼터</td>
      <td>on_disk 모드, 메모리 최적화 검색(3.1+), GPU 원격 빌드</td>
    </tr>
    <tr>
      <td>Elasticsearch</td>
      <td>인덱스당 샤드 1,024, Serverless Vector Database 프로젝트 1TB</td>
      <td>bbq_disk(Enterprise), BBQ·int4·int8</td>
    </tr>
    <tr>
      <td>Vespa</td>
      <td>노드당 문서 수 한도는 공식 문서에 없음, 벤치마크로 산정</td>
      <td>bfloat16·이진화, streaming search(정확 검색 전용)</td>
    </tr>
    <tr>
      <td>MongoDB</td>
      <td>벡터 수 한도는 문서에 없음, 인덱스 전체를 메모리에 적재</td>
      <td>양자화, 전용 Search Nodes, 샤딩</td>
    </tr>
    <tr>
      <td>Redis</td>
      <td>한도는 문서에 없음, 데이터와 인덱스 모두 메모리</td>
      <td>SVS-VAMANA 압축, 저정밀 타입, 샤딩</td>
    </tr>
  </tbody>
</table>

<h3 id="1억-건--768차원이면-메모리는-얼마나-필요할까요">1억 건 × 768차원이면 메모리는 얼마나 필요할까요</h3>

<p>아래는 벡터 1억 개, 768차원, float32, 사본 1개(복제본 없음)를 공식 문서의 산정식에 넣어 계산한 값입니다. HNSW의 m은 여러 제품의 기본값인 16을 넣었습니다. 측정값이 아니며 메타데이터, 필터용 인덱스, 운영체제, JVM 힙은 빠져 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>제품·구성</th>
      <th>공식 문서의 식</th>
      <th>1억 × 768차원</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>원본 벡터(float32)</td>
      <td>벡터 수 × 차원 × 4바이트</td>
      <td>307.2GB</td>
    </tr>
    <tr>
      <td>OpenSearch Faiss HNSW</td>
      <td>1.1 × (4 × 차원 + 8 × m) × 벡터 수</td>
      <td>352GB</td>
    </tr>
    <tr>
      <td>OpenSearch HNSW + 1비트 이진 양자화</td>
      <td>1.1 × (차원 ÷ 8 + 8 × m) × 벡터 수</td>
      <td>24.64GB</td>
    </tr>
    <tr>
      <td>Elasticsearch <code class="language-plaintext highlighter-rouge">hnsw</code>(float)</td>
      <td>벡터 수 × 차원 × 4 + 벡터 수 × 4 × m</td>
      <td>313.6GB</td>
    </tr>
    <tr>
      <td>Elasticsearch <code class="language-plaintext highlighter-rouge">int8_hnsw</code></td>
      <td>벡터 수 × (차원 + 16) + 벡터 수 × 4 × m</td>
      <td>84.8GB</td>
    </tr>
    <tr>
      <td>Elasticsearch <code class="language-plaintext highlighter-rouge">bbq_hnsw</code></td>
      <td>벡터 수 × (⌈차원 ÷ 64⌉ × 8 + 14) + 벡터 수 × 4 × m</td>
      <td>17.4GB</td>
    </tr>
    <tr>
      <td>Qdrant scalar 양자화 + HNSW 그래프</td>
      <td>벡터 수 × 차원 × 1 + 벡터 수 × m × 2 × 4 × 1.2</td>
      <td>92.16GB</td>
    </tr>
    <tr>
      <td>Qdrant binary 양자화 + HNSW 그래프</td>
      <td>벡터 수 × 차원 ÷ 8 + 벡터 수 × m × 2 × 4 × 1.2</td>
      <td>24.96GB</td>
    </tr>
    <tr>
      <td>Weaviate(문서 예시의 maxConnections 64)</td>
      <td>벡터 수 × (차원 × 4 + 64 × 10)</td>
      <td>371.2GB</td>
    </tr>
    <tr>
      <td>MongoDB scalar / binary 양자화(벡터 RAM)</td>
      <td>원본 벡터 크기 ÷ 3.75 / 원본 벡터 크기 ÷ 24</td>
      <td>81.92GB / 12.8GB</td>
    </tr>
    <tr>
      <td>pgvector 저장(vector / halfvec / bit)</td>
      <td>벡터 수 × (4 × 차원 + 8) / (2 × 차원 + 8) / (차원 ÷ 8 + 8)</td>
      <td>308GB / 154.4GB / 10.4GB</td>
    </tr>
  </tbody>
</table>

<p>원본 벡터만 300GB를 넘고, HNSW 그래프는 m=16에서 6.4~15GB 정도라서 전체 크기는 양자화가 좌우합니다. 같은 1억 건이라도 float32 HNSW는 300GB대, 8비트 양자화는 80~90GB대, 이진 양자화는 10~25GB대입니다. pgvector 값은 테이블 저장 크기이며, pgvector 문서는 HNSW 인덱스 크기의 산정식을 따로 두지 않습니다.<sup id="fnref:opensearch:3" role="doc-noteref"><a href="#fn:opensearch" class="footnote" rel="footnote">8</a></sup><sup id="fnref:elasticsearch:3" role="doc-noteref"><a href="#fn:elasticsearch" class="footnote" rel="footnote">9</a></sup><sup id="fnref:qdrant:2" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">3</a></sup><sup id="fnref:weaviate:5" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup><sup id="fnref:mongodb:4" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup><sup id="fnref:pgvector:2" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">1</a></sup></p>

<p>여기에 곱하고 더할 것이 있습니다. primary와 복제본을 합친 사본마다 같은 메모리가 필요하므로 사본 수만큼 곱합니다. Qdrant는 합계에 약 20% 여유를, MongoDB는 인덱스 전체보다 10% 이상 큰 RAM을 권합니다. JVM 기반 검색 엔진은 힙도 따로 떼어야 합니다. AWS 문서에 따르면 OpenSearch Service는 인스턴스 RAM의 절반을 JVM 힙에 쓰고, k-NN은 기본으로 나머지 절반의 50%까지 씁니다. 그래서 RAM 32GiB 인스턴스에 담을 수 있는 그래프는 8GiB입니다(32 × 0.5 × 0.5). JVM 힙은 최대 32GiB까지만 잡습니다.<sup id="fnref:qdrant:3" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">3</a></sup><sup id="fnref:mongodb:5" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup><sup id="fnref:opensearch:4" role="doc-noteref"><a href="#fn:opensearch" class="footnote" rel="footnote">8</a></sup></p>

<p>관리형 서비스와 단일 노드의 사이징 안내에 1억 건을 넣으면 다음과 같습니다.<sup id="fnref:chroma:2" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">5</a></sup><sup id="fnref:zilliz" role="doc-noteref"><a href="#fn:zilliz" class="footnote" rel="footnote">13</a></sup><sup id="fnref:milvus:3" role="doc-noteref"><a href="#fn:milvus" class="footnote" rel="footnote">2</a></sup></p>

<table>
  <thead>
    <tr>
      <th>사이징 안내</th>
      <th>기준</th>
      <th>1억 건이면</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Chroma 단일 노드</td>
      <td>N = R × 0.245(1024차원·메타데이터 3개·짧은 문서 조건, N은 백만 건 단위 최대 컬렉션 크기, R은 RAM GB, 공식 문서의 검증 범위는 약 700만 건)</td>
      <td>RAM 약 408GB(검증 범위 밖까지 식을 적용한 계산값)</td>
    </tr>
    <tr>
      <td>Zilliz Cloud</td>
      <td>query CU 하나에 768차원 벡터 Performance-optimized 200만 / Capacity-optimized 800만 / Tiered-storage 4,000만 개</td>
      <td>50 / 12.5 / 2.5 CU</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>엔티티 2억 건마다 샤드 하나(흔한 방식) / 넣을 데이터 100GB마다 샤드 하나</td>
      <td>엔티티 기준 샤드 1개 / 데이터 크기 기준 샤드 4개 이상(원본 307GB)</td>
    </tr>
  </tbody>
</table>

<p>Chroma 문서는 단일 노드를 보통 1천만 건 미만의 중소 규모용 배포 형태로 설명하므로, 1억 건은 이 범위를 넘습니다.</p>

<h3 id="공개-벤치마크로-알-수-있는-것">공개 벤치마크로 알 수 있는 것</h3>

<p>1억 건 이상에서 이 12개 제품을 같은 조건으로 비교하면서, 비교 대상 벤더가 운영하지도 않는 공개 벤치마크는 찾기 어렵습니다. 공개된 대규모 벤치마크는 대부분 알고리즘 비교이거나 벤더가 만든 도구입니다.<sup id="fnref:benchmarks" role="doc-noteref"><a href="#fn:benchmarks" class="footnote" rel="footnote">14</a></sup></p>

<table>
  <thead>
    <tr>
      <th>벤치마크</th>
      <th>운영 주체</th>
      <th>규모와 대상</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ANN-Benchmarks</td>
      <td>Bernhardsson·Aumüller·Faithfull(README 기준 더 이상 활발히 유지되지 않음)</td>
      <td>최대 약 1천만 건, 단일 CPU, 알고리즘과 일부 DB</td>
    </tr>
    <tr>
      <td>big-ann-benchmarks NeurIPS’21</td>
      <td>Microsoft Research India·ITU Copenhagen 등</td>
      <td>10억 건 데이터셋 6종, 알고리즘 비교</td>
    </tr>
    <tr>
      <td>big-ann-benchmarks NeurIPS’23</td>
      <td>NeurIPS’21 조직위 일부와 Pinecone·Zilliz 소속 인원</td>
      <td>약 1천만~3천만 건, 필터·희소·스트리밍 트랙</td>
    </tr>
    <tr>
      <td>SISAP Indexing Challenge 2023·2024</td>
      <td>학계(SISAP)</td>
      <td>LAION2B의 1억 건 부분집합, 인덱스 알고리즘 비교</td>
    </tr>
    <tr>
      <td>VectorDBBench</td>
      <td>Zilliz 후원</td>
      <td>제품·관리형 비교 도구, LAION 1억 건·768차원 케이스 포함</td>
    </tr>
  </tbody>
</table>

<p>1억 건 이상에서는 결국 우리 데이터로 직접 재 보고 골라야 합니다. VectorDBBench의 1억 건 케이스는 시작점으로 쓸 수 있지만, 비교 대상 가운데 하나를 만든 회사가 후원하는 도구라는 점을 감안해야 합니다. 1편에서 정리한 대로 같은 데이터·필터·recall 목표에서 p95·p99 지연과 비용을 함께 기록합니다.</p>

<h2 id="7-수평-샤딩과-수직-확장은-얼마나-쉬울까요">7. 수평 샤딩과 수직 확장은 얼마나 쉬울까요</h2>

<p>수평 확장은 두 갈래입니다. 데이터를 샤드로 나눠 여러 노드에 분산하면 담을 수 있는 규모가 커지고 적재 속도가 빨라지며, 복제본을 늘리면 읽기 처리량이 늘어납니다. 비교할 때 가장 중요한 질문은 <strong>샤드 수를 나중에 바꿀 수 있는가</strong>입니다. 샤드 수가 생성 시점에 고정되는 제품은 처음 잡은 값이 이후의 확장 여지를 결정합니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>샤딩</th>
      <th>샤드 수 변경</th>
      <th>읽기 확장·수직 확장</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pgvector</td>
      <td>자체 기능 없음, Citus·PgDog 같은 외부 수단</td>
      <td>외부 도구의 영역</td>
      <td>읽기 복제본, HNSW 빌드 속도는 <code class="language-plaintext highlighter-rouge">maintenance_work_mem</code>에 좌우</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>컬렉션을 만들 때 샤드 수 지정(기본 1, 최대 16)</td>
      <td>변경 방법은 문서에 없음</td>
      <td>무상태 노드 추가, in-memory replica, 로드할 데이터는 메모리의 90% 미만</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td><code class="language-plaintext highlighter-rouge">shard_number</code>(기본은 생성 시점의 노드 수), 사용자 정의 샤딩</td>
      <td>리샤딩은 Cloud 전용, 셀프 호스팅은 컬렉션 재생성</td>
      <td>복제로 읽기 확장, 노드당 샤드 2개 이상 권장</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>해시 샤딩, 샤드 설정은 생성 후 변경 불가</td>
      <td>리샤딩 방법은 문서에 없음, 노드를 추가해도 기존 샤드는 그대로(1.32부터 복제본의 노드 간 이동 가능)</td>
      <td>복제본은 질의 처리량, 샤드는 적재 속도 확장</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td>분산·Cloud는 컬렉션 단위로 샤딩</td>
      <td>컬렉션 하나를 나누는 방법은 문서에 없음</td>
      <td>단일 노드는 RAM이 컬렉션 크기 상한, 질의 병렬도는 vCPU 수까지</td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td>OSS는 단일 프로세스</td>
      <td>문서에 없음</td>
      <td>Enterprise는 질의 노드와 인덱서를 따로 확장, 분산 검색의 동적 확장은 예정 기능</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td>서버리스는 자동 확장</td>
      <td>Dedicated Read Nodes는 샤드·복제본 수를 수동 조정(프로젝트당 노드 20, 설정 변경은 10분에 한 번)</td>
      <td>복제본을 늘려 읽기 처리량, 샤드를 늘려 저장 용량 확장</td>
    </tr>
    <tr>
      <td>OpenSearch</td>
      <td>primary 샤드 수는 생성 시 지정</td>
      <td>split·shrink로 새 인덱스 생성</td>
      <td>복제본 수는 동적, 노드를 추가하면 자동 리밸런싱</td>
    </tr>
    <tr>
      <td>Elasticsearch</td>
      <td>primary 샤드 수는 생성 시 고정(인덱스당 최대 1,024)</td>
      <td>split(원본은 읽기 전용)·shrink로 새 인덱스 생성</td>
      <td>복제본 수는 동적, 자동 리밸런싱, Serverless는 Elastic이 샤딩을 관리</td>
    </tr>
    <tr>
      <td>Vespa</td>
      <td>bucket 단위 자동 분산, 샤드를 사람이 정하지 않음</td>
      <td>노드를 늘리거나 줄이면 자동 재분배</td>
      <td>그룹(데이터 전체 사본)으로 처리량 확장, 노드 리소스를 바꾸면 보통 노드 전체 교체(Cloud)</td>
    </tr>
    <tr>
      <td>MongoDB</td>
      <td>MongoDB 샤딩, <code class="language-plaintext highlighter-rouge">mongos</code>가 모든 샤드에 질의</td>
      <td><code class="language-plaintext highlighter-rouge">reshardCollection</code>(8.0+는 같은 샤드 키로도 재분배), 끝나면 검색 인덱스를 수동 재빌드</td>
      <td>Search Nodes는 리전당 2~32개, 확장하면 인덱스 재빌드(AWS·Azure는 조건에 따라 저장된 인덱스 사본 사용)</td>
    </tr>
    <tr>
      <td>Redis</td>
      <td>클러스터에서 문서 ID로 인덱스를 나누고 모든 샤드 결과를 병합</td>
      <td>Redis Software는 리샤딩 지원(옮긴 데이터의 인덱스를 동기 생성)</td>
      <td><code class="language-plaintext highlighter-rouge">search-workers</code>, query performance factor(최대 16배, Software·Cloud Pro)</td>
    </tr>
  </tbody>
</table>

<p>Qdrant 문서는 샤드 수를 처음에 넉넉히 잡으라고 안내합니다. 노드당 샤드를 2개 이상 두고, 크게 늘어날 예정이면 12개로 시작하라고 권합니다. 12개면 노드를 1, 2, 3, 6, 12대로 늘릴 때 리샤딩 없이 고르게 나눌 수 있기 때문입니다. OpenSearch와 Elasticsearch의 split은 새 인덱스를 만들며, 목표 샤드 수는 원래 샤드 수의 배수여야 합니다.<sup id="fnref:qdrant:4" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">3</a></sup><sup id="fnref:opensearch:5" role="doc-noteref"><a href="#fn:opensearch" class="footnote" rel="footnote">8</a></sup><sup id="fnref:elasticsearch:4" role="doc-noteref"><a href="#fn:elasticsearch" class="footnote" rel="footnote">9</a></sup></p>

<p>수직 확장에서는 메모리가 먼저 막힙니다. MongoDB는 인덱스 전체를, Redis는 데이터와 인덱스를 메모리에 둡니다. Weaviate 문서는 메모리가 데이터셋 크기의 상한을, CPU가 질의·적재 속도를 정한다고 설명합니다. Vespa Cloud에서 노드의 vCPU·메모리·디스크를 바꾸면 보통 새 노드를 할당해 클러스터의 노드를 모두 교체하고 문서를 재분배합니다.<sup id="fnref:mongodb:6" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup><sup id="fnref:redis:2" role="doc-noteref"><a href="#fn:redis" class="footnote" rel="footnote">12</a></sup><sup id="fnref:weaviate:6" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup><sup id="fnref:vespa:2" role="doc-noteref"><a href="#fn:vespa" class="footnote" rel="footnote">10</a></sup></p>

<h2 id="8-라이선스와-비용은-어떻게-볼까요">8. 라이선스와 비용은 어떻게 볼까요</h2>

<p>코어 라이선스는 1편에서 정리했습니다. 선택에 더 직접 걸리는 것은 <strong>기능 단위의 조건</strong>입니다. 같은 제품이라도 에디션이나 배포 형태, 추가 모듈에 따라 쓸 수 있는 기능이 다릅니다.</p>

<ul>
  <li><strong>Elasticsearch:</strong> 디스크 친화 인덱스 <code class="language-plaintext highlighter-rouge">bbq_disk</code>에는 Enterprise 구독이 필요하고, 기본 인덱스 종류도 라이선스에 따라 달라집니다.<sup id="fnref:elasticsearch:5" role="doc-noteref"><a href="#fn:elasticsearch" class="footnote" rel="footnote">9</a></sup></li>
  <li><strong>Qdrant:</strong> 기존 컬렉션의 샤드 수를 바꾸는 리샤딩은 Qdrant Cloud에서만 지원합니다.<sup id="fnref:qdrant:5" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">3</a></sup></li>
  <li><strong>Chroma:</strong> RRF 하이브리드를 포함한 Search API는 Chroma Cloud 전용입니다.<sup id="fnref:chroma:3" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">5</a></sup></li>
  <li><strong>MongoDB:</strong> 재순위화 단계 <code class="language-plaintext highlighter-rouge">$rerank</code>는 Atlas에서만 쓰는 Preview 기능이고, 전용 Search Nodes도 Atlas 기능입니다.<sup id="fnref:mongodb:7" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">11</a></sup></li>
  <li><strong>Redis:</strong> LVQ·LeanVec 압축은 Intel 플랫폼 전용이고, Redis Open Source에서는 RSALv2 배포판을 Intel SVS 옵션으로 빌드해야 쓸 수 있습니다. query performance factor는 Redis Software(7.4.2-54 이상)와 Redis Cloud Pro(Redis 7.2 이상)에서 제공합니다.<sup id="fnref:redis:3" role="doc-noteref"><a href="#fn:redis" class="footnote" rel="footnote">12</a></sup></li>
  <li><strong>LanceDB:</strong> 인덱스 자동 관리, 기본 strong consistency, 여러 노드로 나눈 구성은 Enterprise의 기능입니다.<sup id="fnref:lancedb:1" role="doc-noteref"><a href="#fn:lancedb" class="footnote" rel="footnote">6</a></sup></li>
  <li><strong>Pinecone:</strong> 관리형 전용입니다. Dedicated Read Nodes는 Standard·Enterprise 플랜, BYOC는 Enterprise 플랜에서 씁니다.<sup id="fnref:pinecone:2" role="doc-noteref"><a href="#fn:pinecone" class="footnote" rel="footnote">7</a></sup></li>
  <li><strong>Weaviate:</strong> 비활성 테넌트를 클라우드 스토리지로 내리는 offload에는 S3 offload 모듈이 필요합니다.<sup id="fnref:weaviate:7" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">4</a></sup></li>
</ul>

<p>관리형 서비스는 과금 단위가 서로 다릅니다. 금액은 자주 바뀌므로 단위만 정리합니다.<sup id="fnref:pricing" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">15</a></sup></p>

<table>
  <thead>
    <tr>
      <th>관리형 서비스</th>
      <th>과금 단위</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pinecone</td>
      <td>read unit(RU)·write unit·저장 GB·egress(질의는 namespace 1GB당 1 RU, 최소 0.25 RU)</td>
    </tr>
    <tr>
      <td>Zilliz Cloud</td>
      <td>CU·쓰기·읽기·저장·백업·Pipelines 비용 항목</td>
    </tr>
    <tr>
      <td>Qdrant Cloud</td>
      <td>vCPU·메모리·디스크 등의 사용 시간</td>
    </tr>
    <tr>
      <td>Weaviate Cloud</td>
      <td>저장한 벡터 차원 수·저장 GiB·백업</td>
    </tr>
    <tr>
      <td>Chroma Cloud</td>
      <td>쓴 GiB·월 저장 GiB·조회 TiB·반환 GiB</td>
    </tr>
    <tr>
      <td>MongoDB Atlas</td>
      <td>클러스터·Search Nodes의 사용 시간</td>
    </tr>
    <tr>
      <td>Redis Cloud</td>
      <td>플랜별 사용 시간(Pro는 월 최소액)</td>
    </tr>
    <tr>
      <td>Elastic Cloud</td>
      <td>Hosted는 RAM 크기(GB)와 사용 시간, Serverless는 VCU·저장 GB</td>
    </tr>
    <tr>
      <td>Amazon OpenSearch Service</td>
      <td>인스턴스 사용 시간·스토리지·데이터 전송, Serverless는 OCU 사용 시간·저장</td>
    </tr>
    <tr>
      <td>Vespa Cloud</td>
      <td>vCPU·메모리 GB·디스크 GB·GPU 메모리 GB의 사용 시간</td>
    </tr>
    <tr>
      <td>LanceDB Enterprise</td>
      <td>가격 페이지에 과금 단위 없음(문의 양식)</td>
    </tr>
  </tbody>
</table>

<p>과금 단위는 설계에도 영향을 줍니다. Pinecone은 질의 비용이 namespace 크기에 비례하므로(1GB당 1 RU), 테넌트를 namespace로 나누면 질의 한 번이 훑는 데이터가 줄어듭니다. Weaviate Cloud는 저장한 벡터 차원 수를, Chroma Cloud는 조회한 데이터 양을 과금 단위로 씁니다. 같은 데이터라도 차원 수와 테넌트 분할, 질의 패턴에 따라 비용 구조가 달라집니다.<sup id="fnref:pinecone:3" role="doc-noteref"><a href="#fn:pinecone" class="footnote" rel="footnote">7</a></sup><sup id="fnref:pricing:1" role="doc-noteref"><a href="#fn:pricing" class="footnote" rel="footnote">15</a></sup></p>

<h2 id="9-상황별로-어떤-후보부터-볼까요">9. 상황별로 어떤 후보부터 볼까요</h2>

<p>아래는 앞의 비교에서 나온 <strong>먼저 검토할 후보</strong>입니다. 순위가 아니며, 후보를 좁힌 뒤에는 1편의 순서대로 같은 조건에서 측정해야 합니다.</p>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>먼저 볼 후보</th>
      <th>근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>이미 PostgreSQL을 운영하고 트랜잭션·단일 DB 구성이 중요함</td>
      <td>pgvector</td>
      <td>SQL·JOIN·트랜잭션 활용(1편), 사후 필터는 iterative scan으로 보완(3·4절)</td>
    </tr>
    <tr>
      <td>검색 엔진을 운영하고 키워드 검색이 중요함</td>
      <td>OpenSearch, Elasticsearch, Vespa</td>
      <td>BM25와 결합 기능 내장, 사전 필터·필터 인지 탐색(4·5절)</td>
    </tr>
    <tr>
      <td>테넌트가 수만 곳 이상인 SaaS</td>
      <td>Weaviate, Qdrant, Milvus, Pinecone(Standard 플랜 이상)</td>
      <td>테넌트별 샤드, <code class="language-plaintext highlighter-rouge">is_tenant</code>, partition key, namespace(4절)</td>
    </tr>
    <tr>
      <td>1억 건 이상에서 메모리 비용을 줄여야 함</td>
      <td>Milvus, OpenSearch, Qdrant, Elasticsearch, LanceDB</td>
      <td>디스크 인덱스·이진 양자화·오브젝트 스토리지(2·6절), Elasticsearch의 <code class="language-plaintext highlighter-rouge">bbq_disk</code>는 Enterprise</td>
    </tr>
    <tr>
      <td>질의당 결과를 1만 개 넘게 받아야 함</td>
      <td>Milvus(16,384), Redis(Open Source 기본 1,000,000)</td>
      <td>문서화된 한도가 1만보다 큼(3절), Qdrant·LanceDB는 상한이 문서에 없음</td>
    </tr>
    <tr>
      <td>애플리케이션에 내장하거나 로컬 개발 환경에서 시작</td>
      <td>Chroma, LanceDB, Milvus Lite</td>
      <td>임베디드·로컬 실행(Milvus Lite는 FLAT 인덱스만 지원<sup id="fnref:milvus:4" role="doc-noteref"><a href="#fn:milvus" class="footnote" rel="footnote">2</a></sup>)</td>
    </tr>
    <tr>
      <td>운영 인력을 최소로 두어야 함</td>
      <td>Pinecone, Zilliz Cloud, Qdrant Cloud, Weaviate Cloud, MongoDB Atlas 같은 관리형</td>
      <td>8절의 과금 단위로 비용 구조를 먼저 비교</td>
    </tr>
    <tr>
      <td>검색 시점의 랭킹 식을 직접 설계해야 함</td>
      <td>Vespa</td>
      <td>단계별 랭킹(first·second·global-phase, 5절)</td>
    </tr>
  </tbody>
</table>

<h2 id="정리">정리</h2>

<p>벡터 DB 12종은 이름보다 한도와 조건에서 더 크게 갈립니다. 필터를 언제 적용하는지, 질의당 k가 어디서 막히는지, 샤드 수를 나중에 바꿀 수 있는지, 대규모 기능이 어느 에디션에 있는지를 먼저 확인하면 후보가 줄어듭니다.</p>

<p>1억 건 규모에서는 제품보다 <strong>양자화와 디스크 인덱스 선택</strong>이 필요한 메모리를 좌우합니다. 같은 1억 건이 float32 HNSW로는 300GB대, 이진 양자화로는 10~25GB대가 됩니다. 중립적인 대규모 제품 벤치마크를 찾기 어려우므로, 마지막 판단은 우리 데이터와 목표 recall에서 잰 결과로 내리는 것이 좋습니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:pgvector" role="doc-endnote">
      <p><a href="https://github.com/pgvector/pgvector">pgvector README</a>, <a href="https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md">CHANGELOG</a>, <a href="https://github.com/pgvector/pgvector/blob/master/src/hnsw.h">src/hnsw.h</a>, <a href="https://github.com/pgvector/pgvector/blob/master/src/hnsw.c">src/hnsw.c</a>. 정확·근사 검색, 사후 필터와 iterative scan, 저장 크기, 파티셔닝과 확장 방식. <code class="language-plaintext highlighter-rouge">hnsw.ef_search</code> 상한 1,000과 iterative scan 기본값은 소스 코드 기준. <a href="#fnref:pgvector" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pgvector:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:pgvector:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:milvus" role="doc-endnote">
      <p><a href="https://milvus.io/docs/limitations.md">Milvus Limitations</a>, <a href="https://milvus.io/docs/index-explained.md">Index Explained</a>, <a href="https://milvus.io/docs/diskann.md">DISKANN</a>, <a href="https://milvus.io/docs/tiered-storage-overview.md">Tiered Storage</a>, <a href="https://milvus.io/docs/gpu_index.md">GPU Index</a>, <a href="https://milvus.io/docs/filtered-search.md">Filtered Search</a>, <a href="https://milvus.io/docs/with-iterators.md">Search Iterator</a>, <a href="https://milvus.io/docs/rrf-ranker.md">RRF Ranker</a>, <a href="https://milvus.io/docs/multi_tenancy.md">Multi-tenancy</a>, <a href="https://milvus.io/docs/create-collection.md">Create Collection</a>, <a href="https://milvus.io/docs/milvus_lite.md">Milvus Lite</a>. topK·샤드·엔티티 한도, 인덱스와 압축, 필터, 샤드 수 기준, 멀티테넌시, Milvus Lite의 인덱스 제약. <a href="#fnref:milvus" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:milvus:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:milvus:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:milvus:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:milvus:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:qdrant" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/manage-data/indexing/">Qdrant — Indexing</a>, <a href="https://qdrant.tech/documentation/manage-data/quantization/">Quantization</a>, <a href="https://qdrant.tech/documentation/search/search/">Search</a>, <a href="https://qdrant.tech/documentation/search/hybrid-queries/">Hybrid Queries</a>, <a href="https://qdrant.tech/documentation/manage-data/multitenancy/">Multitenancy</a>, <a href="https://qdrant.tech/documentation/scaling/distributed_deployment/">Distributed Deployment</a>, <a href="https://qdrant.tech/documentation/capacity-planning/">Capacity Planning</a>, <a href="https://qdrant.tech/documentation/manage-data/collections/">Collections</a>, <a href="https://qdrant.tech/documentation/ops-configuration/running-with-gpu/">Running with GPU</a>. 필터 인지 HNSW와 ACORN, 양자화, 리샤딩 조건, 메모리 산정식, 벡터 저장 위치와 메모리 계층, GPU 인덱스 빌드. <a href="#fnref:qdrant" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:qdrant:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:qdrant:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:qdrant:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:qdrant:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:weaviate" role="doc-endnote">
      <p><a href="https://docs.weaviate.io/weaviate/concepts/vector-index">Weaviate — Vector Index</a>, <a href="https://docs.weaviate.io/weaviate/concepts/vector-quantization">Vector Quantization</a>, <a href="https://docs.weaviate.io/weaviate/concepts/filtering">Filtering</a>, <a href="https://docs.weaviate.io/weaviate/search/basics">Search Basics</a>, <a href="https://docs.weaviate.io/weaviate/concepts/search/hybrid-search">Hybrid Search</a>, <a href="https://docs.weaviate.io/weaviate/manage-collections/multi-tenancy">Multi-tenancy</a>, <a href="https://docs.weaviate.io/weaviate/concepts/cluster">Cluster Architecture</a>, <a href="https://docs.weaviate.io/weaviate/concepts/resources">Resource Planning</a>, <a href="https://docs.weaviate.io/deploy/configuration/env-vars">Environment Variables</a>. 결과 한도, 필터 전략, 멀티테넌시, 샤드·복제, 메모리 산정식. <a href="#fnref:weaviate" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:weaviate:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:weaviate:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:weaviate:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:weaviate:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:weaviate:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:weaviate:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:weaviate:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a></p>
    </li>
    <li id="fn:chroma" role="doc-endnote">
      <p><a href="https://docs.trychroma.com/reference/architecture/overview">Chroma — Architecture</a>, <a href="https://docs.trychroma.com/docs/collections/configure">Configure Collections</a>, <a href="https://docs.trychroma.com/cloud/quotas-limits">Quotas &amp; Limits</a>, <a href="https://docs.trychroma.com/guides/performance/single-node">Single-Node Performance</a>, <a href="https://docs.trychroma.com/reference/architecture/distributed">Distributed Architecture</a>, <a href="https://docs.trychroma.com/cloud/search-api/overview">Search API</a>, <a href="https://docs.trychroma.com/cloud/schema/index-reference">Index Reference</a>. 배포 형태별 규모, 인덱스와 희소 벡터 인덱스 제약, Cloud 한도, 단일 노드 사이징 공식, 분산 구성. <a href="#fnref:chroma" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:chroma:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:chroma:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:chroma:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:lancedb" role="doc-endnote">
      <p><a href="https://docs.lancedb.com/indexing/vector-index">LanceDB — Vector Index</a>, <a href="https://docs.lancedb.com/search/filtering">Filtering</a>, <a href="https://docs.lancedb.com/search/hybrid-search">Hybrid Search</a>, <a href="https://docs.lancedb.com/enterprise/index">Enterprise</a>, <a href="https://docs.lancedb.com/faq/faq-oss">OSS FAQ</a>, <a href="https://docs.lancedb.com/faq/faq-enterprise">Enterprise FAQ</a>. 인덱스와 양자화, 사전·사후 필터, 하이브리드, OSS와 Enterprise의 차이. <a href="#fnref:lancedb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:lancedb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:pinecone" role="doc-endnote">
      <p><a href="https://docs.pinecone.io/reference/api/database-limits/operation-limits">Pinecone — Operation Limits</a>, <a href="https://docs.pinecone.io/reference/api/database-limits/object-limits">Object Limits</a>, <a href="https://docs.pinecone.io/guides/core-concepts/architecture">Database Architecture</a>, <a href="https://docs.pinecone.io/guides/search/hybrid-search">Hybrid Search</a>, <a href="https://docs.pinecone.io/guides/index-data/implement-multitenancy">Implement Multitenancy</a>, <a href="https://docs.pinecone.io/guides/index-data/dedicated-read-nodes/overview">Dedicated Read Nodes</a>, <a href="https://docs.pinecone.io/guides/manage-cost/understanding-cost">Understanding Cost</a>, <a href="https://docs.pinecone.io/release-notes/2026">2026 Release Notes</a>. top_k·응답 크기 한도, namespace 멀티테넌시, 플랜별 기능, 과금 단위. <a href="#fnref:pinecone" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pinecone:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:pinecone:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:pinecone:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:opensearch" role="doc-endnote">
      <p><a href="https://docs.opensearch.org/3.8/mappings/supported-field-types/knn-methods-engines/">OpenSearch — Methods and Engines</a>, <a href="https://docs.opensearch.org/3.8/query-dsl/specialized/k-nn/index/">k-NN Query</a>, <a href="https://docs.opensearch.org/3.8/vector-search/filter-search-knn/index/">Filtering</a>, <a href="https://docs.opensearch.org/3.8/vector-search/optimizing-storage/disk-based-vector-search/">Disk-based Vector Search</a>, <a href="https://docs.opensearch.org/3.8/vector-search/optimizing-storage/binary-quantization/">Binary Quantization</a>, <a href="https://docs.opensearch.org/3.8/vector-search/ai-search/hybrid-search/rrf/">Hybrid Search RRF</a>, <a href="https://docs.opensearch.org/3.8/api-reference/index-apis/split/">Split Index</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/knn.html">Amazon OpenSearch Service k-NN</a>, <a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/limits.html">Amazon OpenSearch Service Quotas</a>. 엔진과 인덱스, k 한도, 필터, 메모리 산정식, 샤드 변경. <a href="#fnref:opensearch" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:opensearch:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:opensearch:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:opensearch:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:opensearch:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:opensearch:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:elasticsearch" role="doc-endnote">
      <p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector">Elasticsearch — dense_vector</a>, <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-knn-query">knn Query</a>, <a href="https://www.elastic.co/docs/solutions/search/vector/knn/filtered-knn-search">Filtered kNN Search</a>, <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/approximate-knn-search">Tune Approximate kNN Search</a>, <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/index-modules">Index Settings</a>, <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-split">Split Index API</a>, <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers">Retrievers</a>, <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/differences-from-other-elasticsearch-offerings">Serverless Differences</a>. 인덱스 종류와 기본값, k·num_candidates 한도, 필터, 메모리 산정식, 샤드 설정, Serverless 한도. <a href="#fnref:elasticsearch" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:elasticsearch:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:elasticsearch:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:elasticsearch:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:elasticsearch:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:elasticsearch:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:vespa" role="doc-endnote">
      <p><a href="https://docs.vespa.ai/en/querying/approximate-nn-hnsw.html">Vespa — Approximate Nearest Neighbor Search using HNSW</a>, <a href="https://docs.vespa.ai/en/querying/nearest-neighbor-search.html">Nearest Neighbor Search</a>, <a href="https://docs.vespa.ai/en/reference/schemas/schemas.html">Schema Reference</a>, <a href="https://docs.vespa.ai/en/reference/api/query.html">Query API Reference</a>, <a href="https://docs.vespa.ai/en/ranking/phased-ranking.html">Phased Ranking</a>, <a href="https://docs.vespa.ai/en/content/elasticity.html">Content Cluster Elasticity</a>, <a href="https://docs.vespa.ai/en/performance/streaming-search.html">Streaming Search</a>, <a href="https://docs.vespa.ai/en/performance/sizing-search.html">Sizing Search</a>, <a href="https://docs.vespa.ai/en/operations/autoscaling.html">Autoscaling</a>. targetHits·maxHits, 필터 임계값, 단계별 랭킹, 자동 분산과 노드 교체. <a href="#fnref:vespa" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:vespa:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:vespa:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:mongodb" role="doc-endnote">
      <p><a href="https://www.mongodb.com/docs/vector-search/indexes/vector-search-type/">MongoDB — Index Fields for Vector Search</a>, <a href="https://www.mongodb.com/docs/vector-search/query/aggregation-stages/vector-search-stage/">$vectorSearch</a>, <a href="https://www.mongodb.com/docs/vector-search/deployment/deployment-options/">Deployment Options</a>, <a href="https://www.mongodb.com/docs/atlas/cluster-config/multi-cloud-distribution/">Multi-Cloud Distribution</a>, <a href="https://www.mongodb.com/docs/vector-search/deployment/multi-tenant-architecture/">Multi-Tenant Architecture</a>, <a href="https://www.mongodb.com/docs/vector-search/hybrid-search/hybrid-search-overview/">Hybrid Search</a>, <a href="https://www.mongodb.com/docs/vector-search/query/aggregation-stages/rerank/">$rerank</a>, <a href="https://www.mongodb.com/docs/manual/core/sharding-reshard-a-collection/">Reshard a Collection</a>. limit·numCandidates 한도, 사전 필터, 메모리와 양자화, Search Nodes, 리샤딩 후 재빌드. <a href="#fnref:mongodb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:mongodb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:mongodb:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:mongodb:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:mongodb:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:mongodb:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:mongodb:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:mongodb:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a></p>
    </li>
    <li id="fn:redis" role="doc-endnote">
      <p><a href="https://redis.io/docs/latest/develop/ai/search-and-query/vectors/">Redis — Vectors</a>, <a href="https://redis.io/docs/latest/develop/ai/search-and-query/vectors/svs-compression/">SVS Compression</a>, <a href="https://redis.io/docs/latest/develop/ai/search-and-query/administration/configuration/">Configuration Parameters</a>, <a href="https://redis.io/docs/latest/commands/ft.hybrid/">FT.HYBRID</a>, <a href="https://redis.io/docs/latest/develop/ai/search-and-query/administration/overview/">Technical Overview</a>, <a href="https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/search/">Redis Search in Redis Software</a>, <a href="https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/search/query-performance-factor/">Query Performance Factor</a>, <a href="https://redis.io/docs/latest/operate/rc/databases/configuration/advanced-capabilities/">Redis Cloud Advanced Capabilities</a>. 인덱스와 압축, KNN과 LIMIT 한도, 필터 모드, 하이브리드, 클러스터와 리샤딩. <a href="#fnref:redis" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:redis:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:redis:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:redis:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:zilliz" role="doc-endnote">
      <p><a href="https://docs.zilliz.com/docs/limits">Zilliz Cloud Limits</a>, <a href="https://docs.zilliz.com/docs/cu-types-explained">CU Types Explained</a>. 플랜별 topK·샤드 한도, query CU 하나에 담기는 768차원 벡터 수. <a href="#fnref:zilliz" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:benchmarks" role="doc-endnote">
      <p><a href="https://github.com/erikbern/ann-benchmarks">ANN-Benchmarks</a>, <a href="https://big-ann-benchmarks.com/neurips21.html">big-ann-benchmarks NeurIPS’21</a>, <a href="https://big-ann-benchmarks.com/neurips23.html">NeurIPS’23</a>, <a href="https://sisap-challenges.github.io/2023/">SISAP 2023</a>, <a href="https://sisap-challenges.github.io/2024/tasks/">SISAP 2024 Tasks</a>, <a href="https://github.com/zilliztech/VectorDBBench">VectorDBBench</a>. 운영 주체, 데이터 규모, 비교 대상. <a href="#fnref:benchmarks" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pricing" role="doc-endnote">
      <p><a href="https://www.pinecone.io/pricing/">Pinecone Pricing</a>, <a href="https://qdrant.tech/pricing/">Qdrant Pricing</a>, <a href="https://weaviate.io/pricing">Weaviate Pricing</a>, <a href="https://www.trychroma.com/pricing">Chroma Pricing</a>, <a href="https://docs.zilliz.com/docs/analyze-cost">Zilliz Cloud Cost</a>, <a href="https://www.mongodb.com/pricing">MongoDB Pricing</a>, <a href="https://redis.io/pricing/">Redis Pricing</a>, <a href="https://www.elastic.co/docs/deploy-manage/cloud-organization/billing/cloud-hosted-deployment-billing-dimensions">Elastic Cloud Hosted Billing</a>, <a href="https://www.elastic.co/docs/deploy-manage/cloud-organization/billing/elasticsearch-billing-dimensions">Elasticsearch Serverless Billing</a>, <a href="https://aws.amazon.com/opensearch-service/pricing/">Amazon OpenSearch Service Pricing</a>, <a href="https://cloud.vespa.ai/price-calculator.html">Vespa Cloud Price Calculator</a>, <a href="https://lancedb.com/pricing">LanceDB Pricing</a>. 관리형 서비스별 과금 단위(2026-09-27 조회). <a href="#fnref:pricing" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pricing:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="ai/ml" /><summary type="html"><![CDATA[인덱스와 압축, 질의당 최대 k, 필터와 멀티테넌시, 하이브리드 검색, 1억 건 규모의 메모리, 샤딩과 확장을 기준으로 벡터 DB 12종을 공식 문서에 근거해 비교합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">벡터 DB란 무엇인가: 중요해진 이유와 주요 제품, 사용 지표로 보는 시장</title><link href="https://rastalion.dev/writing/vector-database-overview/" rel="alternate" type="text/html" title="벡터 DB란 무엇인가: 중요해진 이유와 주요 제품, 사용 지표로 보는 시장" /><published>2026-09-24T15:00:00+00:00</published><updated>2026-09-27T00:00:00+00:00</updated><id>https://rastalion.dev/writing/vector-database-overview</id><content type="html" xml:base="https://rastalion.dev/writing/vector-database-overview/"><![CDATA[<p><strong>시리즈</strong> · <strong>1. 개념과 제품·사용 지표</strong> · <a href="/writing/vector-database-selection/">2. 선택 기준과 기능 비교</a> · <a href="/writing/aws-vector-search/">3. AWS 전용 벡터 검색</a> · <a href="/writing/vector-database-schema-design/">4. 스키마와 컬렉션 설계</a></p>

<p>기술 문서에서 “DB 연결이 몰릴 때 응답이 느려지는 이유”를 검색한다고 생각해 봅시다. 찾고 싶은 문서의 제목은 “커넥션 풀 포화와 대기 시간 분석”일 수 있습니다. 검색어와 문서의 표현이 달라도 관련 있는 내용을 찾는 것이 의미 검색의 목표입니다.</p>

<p><strong>벡터 DB는 숫자 벡터로 표현한 데이터를 저장하고 벡터 사이의 유사도로 관련 항목을 찾도록 설계된 데이터베이스입니다.</strong> 벡터와 원본 식별자, 메타데이터를 함께 관리하고 검색·필터링·갱신 기능을 제공합니다. 벡터를 만드는 임베딩은 외부 모델에 맡기기도 하고 제품에 통합된 기능을 쓰기도 합니다.<sup id="fnref:qdrant-overview" role="doc-noteref"><a href="#fn:qdrant-overview" class="footnote" rel="footnote">1</a></sup><sup id="fnref:azure" role="doc-noteref"><a href="#fn:azure" class="footnote" rel="footnote">2</a></sup></p>

<p>RAG와 AI 에이전트가 늘면서 이 검색 기능을 서비스에 붙일 일도 많아졌습니다. 이때 후보는 전용 벡터 DB만이 아닙니다. 기존 DB의 벡터 기능, 검색 엔진, 관리형 검색 서비스까지 함께 살펴봐야 합니다.</p>

<p>제품·라이선스·인기도 정보는 2026년 9월 25일 기준이고 지표마다 자료 시점은 다릅니다. 사용 경험은 Stack Overflow 2025년 조사를, 인기도는 DB-Engines 2026년 9월 표를, GitHub 별 수는 2026년 9월 25일 조회값을 씁니다.</p>

<h2 id="1-벡터-db는-무엇을-검색하나요">1. 벡터 DB는 무엇을 검색하나요</h2>

<h3 id="임베딩은-데이터를-비교할-수-있게-만든-숫자-표현입니다">임베딩은 데이터를 비교할 수 있게 만든 숫자 표현입니다</h3>

<p>임베딩(embedding)은 모델이 텍스트·이미지 같은 데이터를 숫자 배열로 바꾼 표현입니다. 검색용으로 학습된 모델은 서로 관련 있는 항목이 벡터 공간에서 가깝게 놓이도록 표현을 만듭니다. 그래서 질문을 벡터로 바꿔 문서 벡터와 비교하면, 단어가 정확히 겹치지 않아도 관련 문서를 찾을 수 있습니다.<sup id="fnref:qdrant-overview:1" role="doc-noteref"><a href="#fn:qdrant-overview" class="footnote" rel="footnote">1</a></sup></p>

<p>예를 들어 검색 항목 하나는 다음 정보로 이루어집니다. 벡터 값은 구조를 보여 주려고 넣은 임의의 숫자입니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>문서 조각 ID: article-42-chunk-3
벡터:        [0.12, -0.35, 0.81, ...]
원본 위치:   기술 문서 42번의 세 번째 조각
메타데이터:  tenant_id, 문서 분류, 수정 시각, 접근 권한 정보
</code></pre></div></div>

<p>검색할 때는 질문 벡터와 가장 가까운 항목 k개, 즉 Top-k를 가져옵니다. 가까움을 재는 척도로는 코사인 유사도, 내적, 유클리드 거리를 주로 씁니다. 척도는 임베딩 모델의 특성에 맞춰 고릅니다.<sup id="fnref:faiss" role="doc-noteref"><a href="#fn:faiss" class="footnote" rel="footnote">3</a></sup></p>

<p><strong>차원 수가 같다고 해서 서로 다른 모델의 벡터를 섞어 검색할 수 있는 것은 아닙니다.</strong> 질문 벡터와 문서 벡터는 서로 호환되는 공간에 있어야 합니다. two-tower 구조처럼 질문과 문서에 서로 다른 인코더를 쓰도록 학습한 모델도 있으니, 필요한 조건은 “같은 인코더”가 아니라 “호환되는 공간”입니다. 모델·전처리·버전을 바꾸면 기존 벡터와 호환되는지 확인하고 벡터를 다시 생성할지 정해야 합니다.<sup id="fnref:google" role="doc-noteref"><a href="#fn:google" class="footnote" rel="footnote">4</a></sup></p>

<h3 id="가까운-벡터와-좋은-검색-결과를-구분해야-합니다">가까운 벡터와 좋은 검색 결과를 구분해야 합니다</h3>

<p>거리 계산을 정확히 했다고 해서 사용자가 원하는 문서를 찾았다는 뜻은 아닙니다. 임베딩이 한국어, 사내 약어, 코드, 상품명 등을 제대로 표현하지 못하면 수학적으로 가까운 결과도 업무에는 쓸모가 없을 수 있습니다.</p>

<p>검색 품질은 두 가지로 나눠 평가해야 합니다.</p>

<ul>
  <li><strong>검색 알고리즘의 품질:</strong> 정확한 최근접 이웃 결과를 얼마나 잘 재현하는가.</li>
  <li><strong>서비스 검색의 품질:</strong> 사람이 관련 있다고 판단한 문서를 얼마나 잘 찾아내는가.</li>
</ul>

<p>검색 알고리즘의 품질을 높여도 서비스 검색의 품질은 임베딩 모델과 문서 분할 방식에 따라 달라집니다. 이 구분이 제품 벤치마크를 해석하는 출발점입니다.<sup id="fnref:faiss:1" role="doc-noteref"><a href="#fn:faiss" class="footnote" rel="footnote">3</a></sup><sup id="fnref:google:1" role="doc-noteref"><a href="#fn:google" class="footnote" rel="footnote">4</a></sup></p>

<h2 id="2-데이터가-많아지면-왜-ann-인덱스가-필요할까요">2. 데이터가 많아지면 왜 ANN 인덱스가 필요할까요</h2>

<p>모든 벡터와의 거리를 계산해 정렬하면 정확한 최근접 이웃을 구할 수 있습니다. 데이터가 작거나 필터로 후보가 충분히 줄어들었다면 이 방법이 맞을 수 있습니다. pgvector도 기본 동작은 정확 검색이고 인덱스를 추가하면 근사 검색을 씁니다.<sup id="fnref:pgvector" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">5</a></sup></p>

<p>벡터 수와 차원, 동시 요청이 늘면 전체를 비교하는 비용이 커집니다. 이때 <strong>근사 최근접 이웃 검색(Approximate Nearest Neighbor, ANN)</strong>으로 탐색할 후보를 줄입니다. 정확 검색과 대표적인 ANN 방식을 비교하면 다음과 같습니다.<sup id="fnref:hnsw" role="doc-noteref"><a href="#fn:hnsw" class="footnote" rel="footnote">6</a></sup><sup id="fnref:pgvector:1" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">5</a></sup><sup id="fnref:faiss:2" role="doc-noteref"><a href="#fn:faiss" class="footnote" rel="footnote">3</a></sup></p>

<table>
  <thead>
    <tr>
      <th>방식</th>
      <th>접근 방법</th>
      <th>함께 확인할 사항</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>정확 검색</td>
      <td>대상 벡터 전체와 비교</td>
      <td>데이터 크기·필터 이후 후보 수·동시성</td>
    </tr>
    <tr>
      <td>HNSW</td>
      <td>여러 층의 근접 그래프를 따라 후보 탐색</td>
      <td>탐색량, 인덱스 메모리, 구축·갱신 비용</td>
    </tr>
    <tr>
      <td>IVF 계열</td>
      <td>벡터를 여러 그룹으로 나누고 일부 그룹 탐색</td>
      <td>그룹 학습, 검색할 그룹 수, 데이터 분포 변화</td>
    </tr>
    <tr>
      <td>양자화</td>
      <td>벡터 표현을 압축해 저장·비교 비용 절감</td>
      <td>정확도 손실, 원본 벡터 재순위화, 압축·인덱스 조합</td>
    </tr>
  </tbody>
</table>

<p>ANN에서는 속도와 재현율(recall)이 맞물려 있습니다. 탐색 범위를 줄일수록 빨라지지만 정확 검색이 찾는 이웃을 놓칠 가능성도 커집니다. 정확 검색의 상위 10개 중 ANN이 9개를 찾았다면 이 질문의 <strong>ANN recall@10은 90%</strong>입니다. “정답률 90%인 챗봇”이라는 뜻이 아닙니다.<sup id="fnref:google:2" role="doc-noteref"><a href="#fn:google" class="footnote" rel="footnote">4</a></sup></p>

<p>제품을 비교할 때는 이 절충 때문에 <strong>같은 데이터·거리 척도·필터·recall 목표에서 잰 지연 시간</strong>을 봐야 합니다. 탐색 후보를 줄여 빨라진 설정과 후보를 늘려 정확해진 설정의 QPS를 나란히 놓으면 잘못된 결론을 내릴 수 있습니다.</p>

<h2 id="3-벡터-db는-왜-중요해졌을까요">3. 벡터 DB는 왜 중요해졌을까요</h2>

<h3 id="rag는-검색한-자료를-모델-입력에-넣습니다">RAG는 검색한 자료를 모델 입력에 넣습니다</h3>

<p>RAG(Retrieval-Augmented Generation)는 질문과 관련된 자료를 먼저 검색하고 그 자료를 질문과 함께 생성 모델에 입력하는 방식입니다. 2020년에 발표된 RAG 논문(Lewis 외)도 언어 모델에 Wikipedia의 밀집 벡터 인덱스를 결합했습니다.<sup id="fnref:rag" role="doc-noteref"><a href="#fn:rag" class="footnote" rel="footnote">7</a></sup></p>

<p>기술 문서 챗봇을 만든다면 다음과 같은 흐름을 생각할 수 있습니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 940 520" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vector-rag-flow-archify-diagram-title">RAG의 색인과 질의 흐름</title>
        <desc id="vector-rag-flow-archify-diagram-description">A data-flow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vector-rag-flow-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vector-rag-flow-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vector-rag-flow-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vector-rag-flow-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vector-rag-flow-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vector-rag-flow-grid)" />

        <!-- Data Stages -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="0" x="16" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="100" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">01 / 입력</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="1" x="231" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="315" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">02 / 임베딩</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="2" x="446" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="530" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">03 / 검색</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="3" x="661" y="46" width="168" height="400" rx="10" class="c-lane" stroke-width="1" />
        <text x="745" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">04 / 결합·생성</text>

        <!-- Flow paths -->
        <path data-edge-from="docs" data-edge-to="chunk_embed" data-edge-label="원문" data-edge-key="0" data-composition-points="156,157;259,157" d="M 156 157 L 259 157" class="a-default" stroke-width="1.4" marker-end="url(#vector-rag-flow-arrowhead)" />
        <path data-edge-from="chunk_embed" data-edge-to="vector_db" data-edge-label="벡터 저장" data-edge-key="1" data-composition-points="371,157;422.5,157;422.5,264;474,264" d="M 371 157 L 422.5 157 L 422.5 264 L 474 264" class="a-default" stroke-width="1.4" marker-end="url(#vector-rag-flow-arrowhead)" />
        <path data-edge-from="question" data-edge-to="query_embed" data-edge-label="질문" data-edge-key="2" data-composition-points="156,385;259,385" d="M 156 385 L 259 385" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
        <path data-edge-from="query_embed" data-edge-to="vector_db" data-edge-label="Top-k · 권한 필터" data-edge-key="3" data-composition-points="371,385;422.5,385;422.5,278;474,278" d="M 371 385 L 422.5 385 L 422.5 278 L 474 278" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
        <path data-edge-from="vector_db" data-edge-to="rerank" data-edge-label="후보 조각" data-edge-key="4" data-composition-points="586,271;689,271" d="M 586 271 L 689 271" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
        <path data-edge-from="rerank" data-edge-to="llm" data-edge-label="질문 + 관련 문서" data-edge-key="5" data-composition-points="745,300;745,356" d="M 745 300 L 745 356" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />

        <!-- Nodes -->
        <g id="vector-rag-flow-node-docs" data-node-id="docs" data-node-label="원본 문서" tabindex="0" role="button" aria-label="Focus 원본 문서, 규정·설명서·장애 기록, 01 / 입력" aria-pressed="false" data-node-kind="external" data-node-sublabel="규정·설명서·장애 기록" data-node-context="01 / 입력">
          <title>원본 문서 · 규정·설명서·장애 기록 · 01 / 입력</title>
          <rect x="44" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="44" y="128" width="112" height="58" rx="6" class="c-external" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(50 134) scale(0.6875)">
            <rect x="2.5" y="5" width="8.5" height="8" rx="1.5" />
            <path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">원본 문서</text>
          <text data-detail="context" x="100" y="165" class="t-muted" font-size="7" text-anchor="middle">규정·설명서·장애 기록</text>
        </g>

        <g id="vector-rag-flow-node-chunk_embed" data-node-id="chunk_embed" data-node-label="분할·임베딩" tabindex="0" role="button" aria-label="Focus 분할·임베딩, 조각마다 벡터 생성, 02 / 임베딩" aria-pressed="false" data-node-kind="backend" data-node-sublabel="조각마다 벡터 생성" data-node-context="02 / 임베딩">
          <title>분할·임베딩 · 조각마다 벡터 생성 · 02 / 임베딩</title>
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 134) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">분할·임베딩</text>
          <text data-detail="context" x="315" y="165" class="t-muted" font-size="7" text-anchor="middle">조각마다 벡터 생성</text>
        </g>

        <g id="vector-rag-flow-node-question" data-node-id="question" data-node-label="사용자 질문" tabindex="0" role="button" aria-label="Focus 사용자 질문, 01 / 입력" aria-pressed="false" data-node-kind="frontend" data-node-context="01 / 입력">
          <title>사용자 질문 · 01 / 입력</title>
          <rect x="44" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="44" y="356" width="112" height="58" rx="6" class="c-frontend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(50 362) scale(0.6875)">
            <rect x="2" y="3" width="12" height="10" rx="2" />
            <path d="M2 6.5h12" />
            <circle cx="4.1" cy="4.8" r=".7" class="sigil-fill" />
            <circle cx="6.3" cy="4.8" r=".7" class="sigil-fill" />
          </g>
          <text data-node-label="" x="100" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">사용자 질문</text>
        </g>

        <g id="vector-rag-flow-node-query_embed" data-node-id="query_embed" data-node-label="질문 임베딩" tabindex="0" role="button" aria-label="Focus 질문 임베딩, 호환되는 인코더, 02 / 임베딩" aria-pressed="false" data-node-kind="backend" data-node-sublabel="호환되는 인코더" data-node-context="02 / 임베딩">
          <title>질문 임베딩 · 호환되는 인코더 · 02 / 임베딩</title>
          <rect x="259" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="356" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 362) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">질문 임베딩</text>
          <text data-detail="context" x="315" y="393" class="t-muted" font-size="7" text-anchor="middle">호환되는 인코더</text>
        </g>

        <g id="vector-rag-flow-node-vector_db" data-node-id="vector_db" data-node-label="벡터 DB" tabindex="0" role="button" aria-label="Focus 벡터 DB, 벡터·원본 ID·메타데이터, 03 / 검색" aria-pressed="false" data-node-kind="database" data-node-sublabel="벡터·원본 ID·메타데이터" data-node-context="03 / 검색">
          <title>벡터 DB · 벡터·원본 ID·메타데이터 · 03 / 검색</title>
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(480 248) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">벡터 DB</text>
          <text data-detail="context" x="530" y="279" class="t-muted" font-size="7" text-anchor="middle">벡터·원본 ID·메타데이터</text>
        </g>

        <g id="vector-rag-flow-node-rerank" data-node-id="rerank" data-node-label="결합·재순위화" tabindex="0" role="button" aria-label="Focus 결합·재순위화, 키워드 결과 (필요시), 04 / 결합·생성" aria-pressed="false" data-node-kind="backend" data-node-sublabel="키워드 결과 (필요시)" data-node-context="04 / 결합·생성">
          <title>결합·재순위화 · 키워드 결과 (필요시) · 04 / 결합·생성</title>
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(695 248) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">결합·재순위화</text>
          <text data-detail="context" x="745" y="279" class="t-muted" font-size="7" text-anchor="middle">키워드 결과 (필요시)</text>
        </g>

        <g id="vector-rag-flow-node-llm" data-node-id="llm" data-node-label="LLM" tabindex="0" role="button" aria-label="Focus LLM, 답변 생성, 04 / 결합·생성" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="답변 생성" data-node-context="04 / 결합·생성">
          <title>LLM · 답변 생성 · 04 / 결합·생성</title>
          <rect x="689" y="356" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="356" width="112" height="58" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(695 362) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="377" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">LLM</text>
          <text data-detail="context" x="745" y="393" class="t-muted" font-size="7" text-anchor="middle">답변 생성</text>
        </g>

        <!-- Flow labels -->
        <g data-detail="context" data-edge-from="docs" data-edge-to="chunk_embed" data-edge-label="원문" data-edge-key="0">
          <rect x="190.5" y="136" width="34" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="147" class="t-muted" font-size="8" text-anchor="middle">원문</text>
        </g>
        <g data-detail="context" data-edge-from="chunk_embed" data-edge-to="vector_db" data-edge-label="벡터 저장" data-edge-key="1">
          <rect x="394.45" y="189.5" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="200.5" class="t-muted" font-size="8" text-anchor="middle">벡터 저장</text>
        </g>
        <g data-detail="context" data-edge-from="question" data-edge-to="query_embed" data-edge-label="질문" data-edge-key="2">
          <rect x="190.5" y="364" width="34" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="375" class="t-backend" font-size="8" text-anchor="middle">질문</text>
        </g>
        <g data-detail="context" data-edge-from="query_embed" data-edge-to="vector_db" data-edge-label="Top-k · 권한 필터" data-edge-key="3">
          <rect x="374.85" y="310.5" width="95.3" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="321.5" class="t-backend" font-size="8" text-anchor="middle">Top-k · 권한 필터</text>
        </g>
        <g data-detail="context" data-edge-from="vector_db" data-edge-to="rerank" data-edge-label="후보 조각" data-edge-key="4">
          <rect x="609.45" y="250" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="637.5" y="261" class="t-backend" font-size="8" text-anchor="middle">후보 조각</text>
        </g>
        <g data-detail="context" data-edge-from="rerank" data-edge-to="llm" data-edge-label="질문 + 관련 문서" data-edge-key="5">
          <rect x="699.8" y="303" width="90.4" height="16" rx="4" class="c-mask" />
          <text x="745" y="314" class="t-backend" font-size="8" text-anchor="middle">질문 + 관련 문서</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="464" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="emphasis" data-legend-x="40" data-legend-baseline="484" data-legend-width="63">
            <path d="M 40 481 L 74 481" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-rag-flow-arrowhead-emphasis)" />
            <text x="83" y="484" class="t-muted" font-size="10" font-weight="500">질의</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="벡터 저장소" data-legend-x="125" data-legend-baseline="484" data-legend-width="98">
            <rect x="125" y="476" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="147" y="484" class="t-muted" font-size="10" font-weight="500">벡터 저장소</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="245" data-legend-baseline="484" data-legend-width="63">
            <path d="M 245 481 L 279 481" class="a-default" stroke-width="1.4" marker-end="url(#vector-rag-flow-arrowhead)" />
            <text x="288" y="484" class="t-muted" font-size="10" font-weight="500">색인</text>
          </g>
        </g>
      </svg>
</div><figcaption>색인 경로와 질의 경로가 벡터 DB에서 만나는 RAG 흐름</figcaption>
</figure>

<p>색인 경로는 원본 문서를 조각으로 나눠 임베딩한 뒤 벡터·원본 ID·메타데이터를 저장합니다. 질의 경로는 질문을 임베딩해 사용자가 접근할 수 있는 문서 범위 안에서 Top-k를 찾습니다. 필요하면 키워드 검색 결과와 합쳐 순위를 다시 매깁니다. 마지막으로 관련 문서를 질문과 함께 LLM에 넘겨 답변을 생성합니다.</p>

<p>이 구조에서는 검색 대상 자료를 갱신하면 새 문서를 답변의 근거로 쓸 수 있습니다. 사내 규정, 제품 설명서, 장애 대응 기록처럼 조직마다 다르고 계속 바뀌는 자료에 적용할 수 있습니다. 다만 검색기가 부정확한 자료를 가져오거나 모델이 자료를 잘못 해석할 수 있으므로, RAG를 쓴다고 답변의 정확성이 보장되지는 않습니다.<sup id="fnref:rag:1" role="doc-noteref"><a href="#fn:rag" class="footnote" rel="footnote">7</a></sup></p>

<p>벡터 검색은 RAG의 검색기를 구현하는 한 가지 방법입니다. 질문에 따라 키워드 검색, SQL, API 조회를 함께 씁니다. 현재 주문 상태처럼 정확한 최신 값이 필요한 질의라면 원천 시스템을 직접 조회하는 편이 맞습니다.</p>

<h3 id="텍스트-외의-데이터와-추천에도-적용됩니다">텍스트 외의 데이터와 추천에도 적용됩니다</h3>

<p>같은 검색 구조는 이미지 검색, 유사 상품 추천, 중복에 가까운 이미지 탐지 등에도 쓰입니다. 텍스트와 이미지를 함께 검색하려면 두 종류의 데이터를 호환되는 공간에 표현하는 모델이 필요합니다. 벡터로 저장했다는 이유만으로 모든 데이터 종류(모달리티)끼리 검색되는 것은 아닙니다.<sup id="fnref:azure:1" role="doc-noteref"><a href="#fn:azure" class="footnote" rel="footnote">2</a></sup><sup id="fnref:s3vectors" role="doc-noteref"><a href="#fn:s3vectors" class="footnote" rel="footnote">8</a></sup></p>

<h3 id="검색을-지속적으로-운영해야-합니다">검색을 지속적으로 운영해야 합니다</h3>

<p>서비스에서는 문서 추가·삭제, 사용자별 접근 범위, 장애 복구, 검색 지연을 함께 다룹니다. 벡터 검색이 제품 기능으로 자리 잡으면서 이런 운영 작업을 DB나 관리형 서비스에 맡길 필요가 커졌다고 볼 수 있습니다. 이 요구는 임베딩 알고리즘의 발전과는 별개로 제품 선택에 영향을 줍니다.<sup id="fnref:qdrant-overview:2" role="doc-noteref"><a href="#fn:qdrant-overview" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="4-어떤-오픈소스와-상용-제품이-있나요">4. 어떤 오픈소스와 상용 제품이 있나요</h2>

<p>먼저 두 기준을 나눕니다. <strong>라이선스</strong>는 소프트웨어를 쓸 수 있는 조건이고 <strong>배포 형태</strong>는 서버와 운영을 누가 맡는지에 따른 구분입니다. 오픈소스 프로젝트에도 유료 관리형 서비스가 있을 수 있습니다.</p>

<p>아래 표는 대표 제품을 추린 것으로, 전체 목록이나 성능 순위가 아닙니다.</p>

<h3 id="오픈소스-또는-오픈소스-코어가-있는-주요-선택지">오픈소스 또는 오픈소스 코어가 있는 주요 선택지</h3>

<p>도입할 때는 사용할 릴리스와 배포판의 조건을 따로 확인해야 합니다. 아래 표의 라이선스는 2026년 9월 25일 기준 공식 저장소 기본 브랜치의 표기입니다.</p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th>분류</th>
      <th>코어 라이선스·범위</th>
      <th>검토할 만한 지점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PostgreSQL + pgvector</td>
      <td>관계형 DB 확장</td>
      <td>PostgreSQL License</td>
      <td>벡터와 업무 데이터를 함께 관리하고 SQL·JOIN·트랜잭션을 활용합니다.<sup id="fnref:pgvector:2" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">5</a></sup></td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td>전용 벡터 DB</td>
      <td>Apache-2.0</td>
      <td>분산 구성, 여러 인덱스 방식, 단일 서버·Lite 형태도 제공합니다.<sup id="fnref:milvus" role="doc-noteref"><a href="#fn:milvus" class="footnote" rel="footnote">9</a></sup></td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td>전용 벡터 DB</td>
      <td>Apache-2.0</td>
      <td>벡터와 payload 관리, 메타데이터 필터, dense·sparse·하이브리드 검색을 제공합니다.<sup id="fnref:qdrant" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">10</a></sup></td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td>전용 벡터 DB</td>
      <td>BSD-3-Clause 코어와 별도 Weaviate License 영역</td>
      <td>코어와 별도 라이선스 기능의 범위를 구분해 검토해야 합니다.<sup id="fnref:weaviate" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">11</a></sup></td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td>벡터 검색·검색 데이터 인프라</td>
      <td>Apache-2.0</td>
      <td>로컬 개발, 영속 저장, 클라이언트·서버 사용 경로와 Chroma Cloud를 제공합니다.<sup id="fnref:chroma" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">12</a></sup></td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td>임베디드 검색·멀티모달 데이터 플랫폼</td>
      <td>Apache-2.0</td>
      <td>Lance 컬럼 형식 기반으로 벡터·메타데이터·멀티모달 데이터를 함께 다룹니다.<sup id="fnref:lancedb" role="doc-noteref"><a href="#fn:lancedb" class="footnote" rel="footnote">13</a></sup></td>
    </tr>
    <tr>
      <td>OpenSearch</td>
      <td>검색 엔진</td>
      <td>Apache-2.0</td>
      <td>기존 검색 데이터와 벡터 검색을 함께 운영하는 후보입니다.<sup id="fnref:opensearch" role="doc-noteref"><a href="#fn:opensearch" class="footnote" rel="footnote">14</a></sup></td>
    </tr>
    <tr>
      <td>Vespa</td>
      <td>검색·추천 플랫폼</td>
      <td>Apache-2.0</td>
      <td>텍스트·벡터·구조화 데이터와 검색 시점의 랭킹을 함께 다룹니다.<sup id="fnref:vespa" role="doc-noteref"><a href="#fn:vespa" class="footnote" rel="footnote">15</a></sup></td>
    </tr>
  </tbody>
</table>

<p>2026년 9월 25일 기준 Weaviate 저장소의 <code class="language-plaintext highlighter-rouge">LICENSE</code>는 <code class="language-plaintext highlighter-rouge">wl</code> 디렉터리 밖의 코드에 BSD-3-Clause를, 그 안의 코드에 별도 Weaviate License를 적용한다고 밝힙니다. 예전 자료처럼 저장소 전체를 단일 BSD 라이선스로 표시하면 정확하지 않습니다.<sup id="fnref:weaviate:1" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">11</a></sup></p>

<p><strong>FAISS는 따로 구분하는 편이 좋습니다.</strong> MIT 라이선스의 유사도 검색·클러스터링 라이브러리로, 인덱스 알고리즘을 직접 조합하거나 검색 엔진 내부를 구현할 때 활용할 수 있습니다. 인증·운영 API·데이터 관리 계층까지 갖춘 DB 제품과는 비교 범위가 다릅니다.<sup id="fnref:faiss:3" role="doc-noteref"><a href="#fn:faiss" class="footnote" rel="footnote">3</a></sup></p>

<h3 id="주요-상용관리형-서비스">주요 상용·관리형 서비스</h3>

<table>
  <thead>
    <tr>
      <th>서비스</th>
      <th>제공 형태</th>
      <th>구분해서 볼 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pinecone</td>
      <td>관리형 벡터 DB</td>
      <td>벡터 검색·지식 검색을 서비스 API로 이용합니다.<sup id="fnref:pinecone" role="doc-noteref"><a href="#fn:pinecone" class="footnote" rel="footnote">16</a></sup></td>
    </tr>
    <tr>
      <td>Zilliz Cloud</td>
      <td>Milvus 기반 관리형 서비스</td>
      <td>Milvus 코어의 라이선스와 관리형 서비스의 기능·운영 조건을 구분합니다.<sup id="fnref:zilliz" role="doc-noteref"><a href="#fn:zilliz" class="footnote" rel="footnote">17</a></sup></td>
    </tr>
    <tr>
      <td>Qdrant Cloud / Weaviate Cloud / Chroma Cloud / LanceDB Cloud</td>
      <td>해당 프로젝트의 관리형 서비스</td>
      <td>로컬·오픈소스 형태와 클라우드 형태의 제공 기능이 같은지 확인합니다.<sup id="fnref:qdrant:1" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">10</a></sup><sup id="fnref:weaviate:2" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">11</a></sup><sup id="fnref:chroma:1" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">12</a></sup><sup id="fnref:lancedb:1" role="doc-noteref"><a href="#fn:lancedb" class="footnote" rel="footnote">13</a></sup></td>
    </tr>
    <tr>
      <td>Google Cloud Vector Search</td>
      <td>관리형 벡터 검색 엔진</td>
      <td>인덱스·엔드포인트 운영과 Google Cloud의 검색·AI 기능 연계를 검토합니다.<sup id="fnref:google:3" role="doc-noteref"><a href="#fn:google" class="footnote" rel="footnote">4</a></sup></td>
    </tr>
    <tr>
      <td>Azure AI Search</td>
      <td>관리형 검색 서비스</td>
      <td>벡터·키워드 검색, 필터, 문서 처리 파이프라인을 함께 다룹니다.<sup id="fnref:azure:2" role="doc-noteref"><a href="#fn:azure" class="footnote" rel="footnote">2</a></sup></td>
    </tr>
    <tr>
      <td>Amazon OpenSearch Service</td>
      <td>관리형 검색 서비스</td>
      <td>OpenSearch 기반 검색 기능과 AWS 서비스 연계를 활용합니다.<sup id="fnref:aws-opensearch" role="doc-noteref"><a href="#fn:aws-opensearch" class="footnote" rel="footnote">18</a></sup></td>
    </tr>
    <tr>
      <td>MongoDB Atlas의 Vector Search</td>
      <td>관리형 문서 DB의 검색 기능</td>
      <td>MongoDB 데이터와 벡터·전문 검색을 함께 다룹니다. 공식 문서는 self-managed 사용 경로도 별도로 안내합니다.<sup id="fnref:mongodb" role="doc-noteref"><a href="#fn:mongodb" class="footnote" rel="footnote">19</a></sup></td>
    </tr>
    <tr>
      <td>Amazon S3 Vectors</td>
      <td>벡터 전용 저장·검색 서비스</td>
      <td>일반 S3 객체 버킷과 구분되는 vector bucket·index·API를 사용합니다.<sup id="fnref:s3vectors:1" role="doc-noteref"><a href="#fn:s3vectors" class="footnote" rel="footnote">8</a></sup></td>
    </tr>
  </tbody>
</table>

<p>Elasticsearch와 Redis도 벡터 검색을 제공합니다. 이 두 제품과 MongoDB는 라이선스가 버전·배포판·서비스에 따라 달라서 어느 기준의 조건인지부터 확인해야 합니다.</p>

<ul>
  <li><strong>Elasticsearch:</strong> 무료 소스 코드 영역에는 AGPLv3 선택지가 추가됐지만 기본 배포판의 ELv2와는 다른 조건입니다. Elastic Cloud와 상용 구독도 별개입니다.<sup id="fnref:elastic-license" role="doc-noteref"><a href="#fn:elastic-license" class="footnote" rel="footnote">20</a></sup></li>
  <li><strong>Redis 8 이상:</strong> AGPLv3·RSALv2·SSPLv1 중 하나를 고르는 구조입니다. 이전 버전의 BSD나 2024년의 두 가지 라이선스 체계를 그대로 적용하면 안 됩니다.<sup id="fnref:redis-license" role="doc-noteref"><a href="#fn:redis-license" class="footnote" rel="footnote">21</a></sup></li>
  <li><strong>MongoDB Community Server:</strong> SSPL을 쓰는 소스 공개 모델이며 MongoDB도 SSPL이 OSI 승인을 받은 오픈소스 라이선스가 아니라고 설명합니다. Atlas 서비스 이용 조건은 이와 별개입니다.<sup id="fnref:mongodb-license" role="doc-noteref"><a href="#fn:mongodb-license" class="footnote" rel="footnote">22</a></sup></li>
</ul>

<h2 id="5-전-세계에서-가장-많이-쓰는-벡터-db는-무엇일까요">5. 전 세계에서 가장 많이 쓰는 벡터 DB는 무엇일까요</h2>

<p><strong>공개 자료만으로는 전 세계 운영 환경의 벡터 DB 사용량 1위를 확정할 근거가 부족합니다.</strong> 설치 수, 운영 중인 클러스터 수, 이용 조직 수를 같은 기준으로 집계한 시장 전체 자료를 찾기 어렵기 때문입니다.</p>

<p>대신 성격이 다른 공개 지표 세 가지를 나눠 볼 수 있습니다.</p>

<h3 id="사용-경험-stack-overflow-2025-조사">사용 경험: Stack Overflow 2025 조사</h3>

<p>Stack Overflow 2025년 조사의 AI 에이전트 데이터 저장 도구 문항은 <strong>AI 에이전트를 사용하거나 개발하는 응답자가 지난 1년간 에이전트 메모리·데이터 관리에 쓴 도구</strong>를 물었습니다. 이 문항의 응답자는 <strong>3,398명</strong>으로 전체 응답자의 6.9%입니다. 아래 표는 벡터 검색과 관련된 선택지만 발췌한 것입니다. 선택지 이름 ChromaDB는 Chroma를 가리킵니다.<sup id="fnref:survey" role="doc-noteref"><a href="#fn:survey" class="footnote" rel="footnote">23</a></sup></p>

<table>
  <thead>
    <tr>
      <th>제품·프로젝트</th>
      <th style="text-align: right">해당 문항의 사용 경험 응답률</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ChromaDB</td>
      <td style="text-align: right">19.7%</td>
    </tr>
    <tr>
      <td>pgvector</td>
      <td style="text-align: right">17.9%</td>
    </tr>
    <tr>
      <td>Pinecone</td>
      <td style="text-align: right">11.2%</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td style="text-align: right">8.2%</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td style="text-align: right">5.2%</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td style="text-align: right">4.5%</td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td style="text-align: right">4.4%</td>
    </tr>
  </tbody>
</table>

<p>같은 문항 전체로 보면 Redis가 42.9%, GitHub MCP Server가 42.8%로 1·2위입니다. 벡터 검색 도구가 아닌 GitHub MCP Server가 상위에 있을 만큼 이 문항의 범위는 벡터 DB보다 넓습니다. 그래서 Redis 응답률을 벡터 검색 사용률로 읽을 수는 없습니다. 한 사람이 여러 도구를 고를 수 있으므로 표의 비율을 시장 점유율처럼 더해서도 안 됩니다. Supabase(20.9%)와 pgvector처럼 사용 경로가 겹칠 수 있는 선택지를 더하면 같은 사용자를 두 번 셀 수도 있습니다.<sup id="fnref:survey:1" role="doc-noteref"><a href="#fn:survey" class="footnote" rel="footnote">23</a></sup></p>

<p>전체 조사는 177개국 49,009명의 응답을 분석했고 참여자는 주로 Stack Overflow 자체 채널에서 모았습니다. 그러니 전체 조사 규모를 이 문항의 표본 수로 인용하거나, 자발적으로 응답한 표본을 세계 전체 사용자의 분포로 일반화하면 안 됩니다.<sup id="fnref:survey-method" role="doc-noteref"><a href="#fn:survey-method" class="footnote" rel="footnote">24</a></sup></p>

<p>이 자료로 말할 수 있는 것은 <strong>“2025년 조사의 에이전트 데이터 관리 문항에서, 위에 발췌한 벡터 검색 도구 중 ChromaDB의 사용 경험 응답률이 가장 높았다”</strong>까지입니다.</p>

<h3 id="인기도-db-engines-2026년-9월-자료">인기도: DB-Engines 2026년 9월 자료</h3>

<p>DB-Engines는 웹 언급, 검색 관심, 기술 토론, 채용 공고 같은 신호로 인기도를 계산합니다. 방법론 문서는 이 점수가 설치 수나 실제 사용량을 측정하지 않는다고 밝힙니다.<sup id="fnref:db-method" role="doc-noteref"><a href="#fn:db-method" class="footnote" rel="footnote">25</a></sup></p>

<p>벡터 DBMS 목록(기본 표 26개 제품)에서 데이터 모델이 <code class="language-plaintext highlighter-rouge">Vector</code> 하나로만 표시된 주요 제품을 추리면 다음과 같습니다. 표의 순위는 벡터 DBMS 원표의 순위를 그대로 옮긴 것입니다.<sup id="fnref:db-ranking" role="doc-noteref"><a href="#fn:db-ranking" class="footnote" rel="footnote">26</a></sup></p>

<table>
  <thead>
    <tr>
      <th>제품</th>
      <th style="text-align: right">원표 내 순위</th>
      <th style="text-align: right">2026년 9월 인기도 점수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pinecone</td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">9.07</td>
    </tr>
    <tr>
      <td>Milvus</td>
      <td style="text-align: right">6</td>
      <td style="text-align: right">6.85</td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td style="text-align: right">7</td>
      <td style="text-align: right">6.82</td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td style="text-align: right">9</td>
      <td style="text-align: right">4.74</td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">2.98</td>
    </tr>
  </tbody>
</table>

<p>이 조건에서는 Pinecone이 가장 높은 점수를 받았습니다. 같은 목록에서 Elasticsearch는 95.50점으로 1위, OpenSearch는 22.11점으로 2위입니다. 두 제품의 점수는 벡터 기능을 포함한 제품 전체의 인기도이므로 그 기능의 사용량으로 읽을 수 없습니다.<sup id="fnref:db-ranking:1" role="doc-noteref"><a href="#fn:db-ranking" class="footnote" rel="footnote">26</a></sup></p>

<h3 id="개발자-관심-github-저장소-별-수">개발자 관심: GitHub 저장소 별 수</h3>

<p>Stack Overflow 설문 표에 나온 오픈소스 프로젝트 6개의 별 수는 다음과 같습니다. 2026년 9월 25일 GitHub REST API로 조회한 <code class="language-plaintext highlighter-rouge">stargazers_count</code> 값이며 GitHub 웹 화면은 이 값을 반올림해 보여 줄 수 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>프로젝트</th>
      <th style="text-align: right">별 수</th>
      <th>분류</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Milvus</td>
      <td style="text-align: right">46,253</td>
      <td>전용 벡터 DB<sup id="fnref:milvus:1" role="doc-noteref"><a href="#fn:milvus" class="footnote" rel="footnote">9</a></sup></td>
    </tr>
    <tr>
      <td>Qdrant</td>
      <td style="text-align: right">34,810</td>
      <td>전용 벡터 DB<sup id="fnref:qdrant:2" role="doc-noteref"><a href="#fn:qdrant" class="footnote" rel="footnote">10</a></sup></td>
    </tr>
    <tr>
      <td>Chroma</td>
      <td style="text-align: right">29,372</td>
      <td>검색 데이터 인프라<sup id="fnref:chroma:2" role="doc-noteref"><a href="#fn:chroma" class="footnote" rel="footnote">12</a></sup></td>
    </tr>
    <tr>
      <td>pgvector</td>
      <td style="text-align: right">23,157</td>
      <td>PostgreSQL 확장<sup id="fnref:pgvector:3" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">5</a></sup></td>
    </tr>
    <tr>
      <td>Weaviate</td>
      <td style="text-align: right">16,846</td>
      <td>전용 벡터 DB·코어와 별도 라이선스 영역<sup id="fnref:weaviate:3" role="doc-noteref"><a href="#fn:weaviate" class="footnote" rel="footnote">11</a></sup></td>
    </tr>
    <tr>
      <td>LanceDB</td>
      <td style="text-align: right">11,527</td>
      <td>임베디드 검색·데이터 플랫폼<sup id="fnref:lancedb:2" role="doc-noteref"><a href="#fn:lancedb" class="footnote" rel="footnote">13</a></sup></td>
    </tr>
  </tbody>
</table>

<p>이 중에서는 Milvus가 가장 많습니다. 같은 시각에 조회한 값으로 보면 라이브러리인 FAISS는 40,978개, 검색 플랫폼인 OpenSearch와 Vespa는 각각 13,775개와 7,110개입니다. GitHub의 별은 관심 있는 저장소를 저장하거나 프로젝트에 지지를 표시하는 기능이라서 설치 수나 운영 사용, 유료 고객 수를 뜻하지 않습니다.<sup id="fnref:stars" role="doc-noteref"><a href="#fn:stars" class="footnote" rel="footnote">27</a></sup></p>

<p>세 지표에서 앞선 제품은 모두 다릅니다.</p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>재는 것</th>
      <th>앞선 제품</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Stack Overflow 2025 문항(벡터 검색 도구 발췌)</td>
      <td>AI 에이전트를 쓰거나 만드는 응답자의 도구 사용 경험</td>
      <td>ChromaDB (19.7%)</td>
    </tr>
    <tr>
      <td>DB-Engines 2026년 9월(모델이 <code class="language-plaintext highlighter-rouge">Vector</code> 하나인 제품)</td>
      <td>웹 언급·검색 관심·기술 토론·채용 공고 등으로 계산한 인기도</td>
      <td>Pinecone (9.07점)</td>
    </tr>
    <tr>
      <td>GitHub 별 수(설문 표의 오픈소스 6개)</td>
      <td>저장소에 대한 관심과 지지</td>
      <td>Milvus (46,253개)</td>
    </tr>
  </tbody>
</table>

<p>세 지표는 서로 다른 질문에 답하므로 하나의 “전 세계 점유율 순위”로 합칠 수 없습니다.</p>

<h2 id="6-제품을-고를-때-무엇을-확인해야-할까요">6. 제품을 고를 때 무엇을 확인해야 할까요</h2>

<p>제품 순위와 별개로, 도입 전에 다음 항목을 확인합니다.</p>

<table>
  <thead>
    <tr>
      <th>검토 항목</th>
      <th>확인할 질문</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>검색 품질</td>
      <td>한국어·약어·코드·고유명사 질의에서 필요한 문서를 찾는가?</td>
    </tr>
    <tr>
      <td>하이브리드 검색</td>
      <td>의미 검색과 키워드 검색을 결합했을 때 결과가 개선되는가?</td>
    </tr>
    <tr>
      <td>필터</td>
      <td>tenant·사용자 권한·기간 조건을 걸어도 필요한 Top-k를 확보하는가?</td>
    </tr>
    <tr>
      <td>갱신</td>
      <td>원문 변경과 삭제가 검색 결과에 반영되기까지 얼마나 걸리는가?</td>
    </tr>
    <tr>
      <td>성능</td>
      <td>목표 recall과 동시성에서 p95·p99 지연이 요구사항을 충족하는가?</td>
    </tr>
    <tr>
      <td>운영</td>
      <td>백업·복구·증설·모니터링을 누가 담당하며 어떤 범위까지 제공하는가?</td>
    </tr>
    <tr>
      <td>이식성</td>
      <td>원본·벡터·메타데이터를 내보내 다른 엔진에 다시 색인할 수 있는가?</td>
    </tr>
    <tr>
      <td>비용</td>
      <td>임베딩 생성, 재순위화, 인덱스, 복제, 네트워크 비용을 함께 계산했는가?</td>
    </tr>
  </tbody>
</table>

<p>특히 메타데이터 필터는 직접 재 봐야 합니다. pgvector 문서는 ANN 인덱스를 스캔한 뒤에 필터를 적용하는 경로를 설명하고 이때 결과가 모자라면 iterative scan으로 보완하는 방법도 안내합니다. Qdrant 문서는 payload index를 검색 그래프와 결합하는 방식을 설명합니다. 필터 문법이 비슷해도 실행 방식과 결과 특성은 다를 수 있습니다.<sup id="fnref:pgvector:4" role="doc-noteref"><a href="#fn:pgvector" class="footnote" rel="footnote">5</a></sup><sup id="fnref:qdrant-overview:3" role="doc-noteref"><a href="#fn:qdrant-overview" class="footnote" rel="footnote">1</a></sup></p>

<p>저장량은 대략 계산해 볼 수 있습니다. <strong>1,536차원 벡터 100만 개를 float32(4바이트)로 저장하면 숫자 배열만 1,000,000 × 1,536 × 4 = 6,144,000,000바이트, 약 6.14GB입니다.</strong> 단순 산술값이라 실제 DB 용량이나 RAM 요구량과는 다릅니다. 원문·메타데이터·인덱스·복제본·운영 여유 공간은 따로 더해야 합니다.</p>

<h3 id="후보를-고르는-순서">후보를 고르는 순서</h3>

<p>후보를 비교하기 전에 평가 세트부터 준비합니다. 우리 문서를 대상으로 질의를 만들고 질의마다 관련 있는 문서를 사람이 판정해 둡니다. 같은 임베딩·차원·거리 척도로 정확 검색 결과도 구해 두면 ANN의 recall을 잴 기준이 됩니다.</p>

<p>이미 PostgreSQL을 운영한다면 pgvector로 요구사항을 충족할 수 있는지부터 측정해 봅니다. 검색 엔진을 운영한다면 그 엔진의 벡터·하이브리드 기능을 후보에 넣습니다. 검색 부하를 따로 확장해야 하거나 전용 기능이 필요하면 전용 벡터 DB를, 운영 부담을 줄여야 하면 관리형 서비스를 후보에 더합니다. 새 후보는 기존 스택과 같은 데이터·recall 목표로 비교합니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 976 528" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="vector-selection-path-archify-diagram-title">벡터 검색 후보를 고르는 순서</title>
        <desc id="vector-selection-path-archify-diagram-description">A workflow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="vector-selection-path-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="vector-selection-path-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="vector-selection-path-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="vector-selection-path-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="vector-selection-path-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#vector-selection-path-grid)" />

        <!-- Swimlanes -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-0" x="40" y="52" width="920" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="74" class="t-dim" font-size="10" font-weight="600">01 / 기존 스택부터</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-1" x="40" y="176" width="920" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="198" class="t-dim" font-size="10" font-weight="600">02 / 검색 부하 분리·전용 기능</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-2" x="40" y="300" width="920" height="104" rx="10" class="c-lane" stroke-width="1" />
        <text x="54" y="322" class="t-dim" font-size="10" font-weight="600">03 / 운영 부담 축소</text>

        <!-- Phase headers -->


        <!-- Workflow groups -->


        <!-- Edge paths -->
        <path data-edge-from="dedicated" data-edge-to="compare" data-edge-key="0" data-composition-points="686,243;714,243" d="M 686 243 L 714 243" class="a-default" stroke-width="1.4" marker-end="url(#vector-selection-path-arrowhead)" />
        <path data-edge-from="gate" data-edge-to="adopt" data-edge-label="충족" data-edge-key="1" data-composition-points="512,119;550,119" d="M 512 119 L 550 119" class="a-emphasis" stroke-width="1.8" marker-end="url(#vector-selection-path-arrowhead-emphasis)" />
        <path data-edge-from="gate" data-edge-to="dedicated" data-edge-label="미충족" data-edge-key="2" data-composition-points="437,145;437,243;550,243" d="M 437 145 L 437 243 L 550 243" class="a-default" stroke-width="1.4" marker-end="url(#vector-selection-path-arrowhead)" />
        <path data-edge-from="gate" data-edge-to="managed" data-edge-key="3" data-composition-points="451,145;451,367;550,367" d="M 451 145 L 451 367 L 550 367" class="a-default" stroke-width="1.4" marker-end="url(#vector-selection-path-arrowhead)" />
        <path data-edge-from="managed" data-edge-to="compare" data-edge-key="4" data-composition-points="686,367;782,367;782,269" d="M 686 367 L 782 367 L 782 269" class="a-default" stroke-width="1.4" marker-end="url(#vector-selection-path-arrowhead)" />
        <path data-edge-from="measure" data-edge-to="gate" data-edge-key="5" data-composition-points="348,119;376,119" d="M 348 119 L 376 119" class="a-default" stroke-width="1.4" marker-end="url(#vector-selection-path-arrowhead)" />
        <path data-edge-from="prepare" data-edge-to="measure" data-edge-key="6" data-composition-points="184,119;212,119" d="M 184 119 L 212 119" class="a-default" stroke-width="1.4" marker-end="url(#vector-selection-path-arrowhead)" />

        <!-- Nodes -->
        <g id="vector-selection-path-node-prepare" data-node-id="prepare" data-node-label="평가 세트 준비" tabindex="0" role="button" aria-label="Focus 평가 세트 준비, 질의·관련 문서·recall 목표, 기존 스택부터" aria-pressed="false" data-node-kind="backend" data-node-sublabel="질의·관련 문서·recall 목표" data-node-context="기존 스택부터">
          <title>평가 세트 준비 · 질의·관련 문서·recall 목표 · 기존 스택부터</title>
          <rect x="48" y="93" width="136" height="52" rx="6" class="c-mask" />
          <rect x="48" y="93" width="136" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(54 99) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="116" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">평가 세트 준비</text>
          <text data-detail="context" x="116" y="131" class="t-muted" font-size="8" text-anchor="middle">질의·관련 문서·recall 목표</text>
        </g>

        <g id="vector-selection-path-node-measure" data-node-id="measure" data-node-label="기존 스택 측정" tabindex="0" role="button" aria-label="Focus 기존 스택 측정, pgvector · 검색 엔진, 기존 스택부터" aria-pressed="false" data-node-kind="database" data-node-sublabel="pgvector · 검색 엔진" data-node-context="기존 스택부터">
          <title>기존 스택 측정 · pgvector · 검색 엔진 · 기존 스택부터</title>
          <rect x="212" y="93" width="136" height="52" rx="6" class="c-mask" />
          <rect x="212" y="93" width="136" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(218 99) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="280" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">기존 스택 측정</text>
          <text data-detail="context" x="280" y="131" class="t-muted" font-size="8" text-anchor="middle">pgvector · 검색 엔진</text>
        </g>

        <g id="vector-selection-path-node-gate" data-node-id="gate" data-node-label="요구사항 충족?" tabindex="0" role="button" aria-label="Focus 요구사항 충족?, 품질·필터·갱신·지연·운영·비용, 기존 스택부터" aria-pressed="false" data-node-kind="security" data-node-sublabel="품질·필터·갱신·지연·운영·비용" data-node-context="기존 스택부터">
          <title>요구사항 충족? · 품질·필터·갱신·지연·운영·비용 · 기존 스택부터</title>
          <rect x="376" y="93" width="136" height="52" rx="6" class="c-mask" />
          <rect x="376" y="93" width="136" height="52" rx="6" class="c-security" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(382 99) scale(0.6875)">
            <path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z" />
            <path d="m5.8 8 1.5 1.5 3-3" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="444" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">요구사항 충족?</text>
          <text data-detail="context" x="444" y="131" class="t-muted" font-size="7.3" text-anchor="middle">품질·필터·갱신·지연·운영·비용</text>
        </g>

        <g id="vector-selection-path-node-adopt" data-node-id="adopt" data-node-label="기존 스택으로 시작" tabindex="0" role="button" aria-label="Focus 기존 스택으로 시작, 기존 스택부터" aria-pressed="false" data-node-kind="database" data-node-context="기존 스택부터">
          <title>기존 스택으로 시작 · 기존 스택부터</title>
          <rect x="550" y="93" width="136" height="52" rx="6" class="c-mask" />
          <rect x="550" y="93" width="136" height="52" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(556 99) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" x="618" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">기존 스택으로 시작</text>
        </g>

        <g id="vector-selection-path-node-dedicated" data-node-id="dedicated" data-node-label="전용 벡터 DB" tabindex="0" role="button" aria-label="Focus 전용 벡터 DB, 검색 부하 분리·전용 기능" aria-pressed="false" data-node-kind="cloud" data-node-context="검색 부하 분리·전용 기능">
          <title>전용 벡터 DB · 검색 부하 분리·전용 기능</title>
          <rect x="550" y="217" width="136" height="52" rx="6" class="c-mask" />
          <rect x="550" y="217" width="136" height="52" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(556 223) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" x="618" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">전용 벡터 DB</text>
        </g>

        <g id="vector-selection-path-node-compare" data-node-id="compare" data-node-label="같은 조건으로 비교" tabindex="0" role="button" aria-label="Focus 같은 조건으로 비교, 기존 스택 포함 · 같은 recall 목표, 검색 부하 분리·전용 기능" aria-pressed="false" data-node-kind="backend" data-node-sublabel="기존 스택 포함 · 같은 recall 목표" data-node-context="검색 부하 분리·전용 기능">
          <title>같은 조건으로 비교 · 기존 스택 포함 · 같은 recall 목표 · 검색 부하 분리·전용 기능</title>
          <rect x="714" y="217" width="136" height="52" rx="6" class="c-mask" />
          <rect x="714" y="217" width="136" height="52" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(720 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="782" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">같은 조건으로 비교</text>
          <text data-detail="context" x="782" y="255" class="t-muted" font-size="6.4" text-anchor="middle">기존 스택 포함 · 같은 recall 목표</text>
        </g>

        <g id="vector-selection-path-node-managed" data-node-id="managed" data-node-label="관리형 서비스" tabindex="0" role="button" aria-label="Focus 관리형 서비스, 운영 부담 축소" aria-pressed="false" data-node-kind="cloud" data-node-context="운영 부담 축소">
          <title>관리형 서비스 · 운영 부담 축소</title>
          <rect x="550" y="341" width="136" height="52" rx="6" class="c-mask" />
          <rect x="550" y="341" width="136" height="52" rx="6" class="c-cloud" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(556 347) scale(0.6875)">
            <path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z" />
          </g>
          <text data-node-label="" x="618" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">관리형 서비스</text>
        </g>

        <!-- Edge labels -->

        <g data-detail="context" data-edge-from="gate" data-edge-to="adopt" data-edge-label="충족" data-edge-key="1">
          <rect x="516" y="99" width="30" height="14" rx="3" class="c-mask" />
          <text x="531" y="109" class="t-backend" font-size="8" text-anchor="middle">충족</text>
        </g>
        <g data-detail="context" data-edge-from="gate" data-edge-to="dedicated" data-edge-label="미충족" data-edge-key="2">
          <rect x="474.1" y="223" width="38.8" height="14" rx="3" class="c-mask" />
          <text x="493.5" y="233" class="t-muted" font-size="8" text-anchor="middle">미충족</text>
        </g>





        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="20" y="428" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="평가·비교" data-legend-x="20" data-legend-baseline="448" data-legend-width="83">
            <rect x="20" y="440" width="14" height="9" rx="2" class="c-backend" stroke-width="1" />
            <text x="42" y="448" class="t-muted" font-size="7.5" font-weight="500">평가·비교</text>
          </g>
          <g data-legend-semantic-kind="security" data-legend-kind="security" data-legend-label="판정" data-legend-x="110" data-legend-baseline="448" data-legend-width="61">
            <rect x="110" y="440" width="14" height="9" rx="2" class="c-security" stroke-width="1" />
            <text x="132" y="448" class="t-muted" font-size="7.5" font-weight="500">판정</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="기존 스택" data-legend-x="178" data-legend-baseline="448" data-legend-width="83">
            <rect x="178" y="440" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="200" y="448" class="t-muted" font-size="7.5" font-weight="500">기존 스택</text>
          </g>
          <g data-legend-semantic-kind="cloud" data-legend-kind="cloud" data-legend-label="추가 후보" data-legend-x="268" data-legend-baseline="448" data-legend-width="83">
            <rect x="268" y="440" width="14" height="9" rx="2" class="c-cloud" stroke-width="1" />
            <text x="290" y="448" class="t-muted" font-size="7.5" font-weight="500">추가 후보</text>
          </g>
        </g>
      </svg>
</div><figcaption>기존 스택부터 측정하고, 부족할 때 후보를 늘려 같은 조건으로 비교하는 순서</figcaption>
</figure>

<h2 id="정리">정리</h2>

<p>벡터 DB가 중요해진 이유는 AI 서비스가 필요한 자료를 찾아 쓰는 과정에서 검색의 비중이 커졌기 때문입니다. 공개 지표는 서로 다른 것을 재므로 제품 순위도 지표마다 달라지고 어느 지표도 운영 환경의 점유율을 보여 주지 않습니다. 최종 선택은 <strong>우리 데이터에서의 검색 품질, 갱신과 권한의 정확성, 운영 가능한 성능과 비용</strong>으로 정하는 것이 좋습니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:qdrant-overview" role="doc-endnote">
      <p><a href="https://qdrant.tech/documentation/overview/">Qdrant Overview</a>. 임베딩·검색 흐름, 배포 형태, payload index와 필터, 저장·운영 고려사항. <a href="#fnref:qdrant-overview" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant-overview:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:qdrant-overview:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:qdrant-overview:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:azure" role="doc-endnote">
      <p><a href="https://learn.microsoft.com/en-us/azure/search/vector-search-overview">Microsoft — Vector Search Overview, Azure AI Search</a>. 의미·하이브리드·멀티모달 검색과 임베딩 처리 경로. <a href="#fnref:azure" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:azure:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:azure:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:faiss" role="doc-endnote">
      <p><a href="https://github.com/facebookresearch/faiss">Meta — FAISS</a>. 라이브러리 범위, 거리 척도, 검색 품질·메모리·속도의 절충, MIT 라이선스. <a href="#fnref:faiss" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:faiss:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:faiss:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:faiss:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:google" role="doc-endnote">
      <p><a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/build/vector-search/overview">Google Cloud — Vector Search</a>. 검색 개념, two-tower 예시, recall, 관리형 인덱스와 서비스. <a href="#fnref:google" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:google:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:google:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:google:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:pgvector" role="doc-endnote">
      <p><a href="https://github.com/pgvector/pgvector">pgvector 공식 저장소</a>, <a href="https://github.com/pgvector/pgvector/blob/master/LICENSE">라이선스</a>. 정확·근사 검색, 필터, PostgreSQL 통합. GitHub의 라이선스 자동 감지는 <code class="language-plaintext highlighter-rouge">NOASSERTION</code>으로 표시하지만 LICENSE 본문은 PostgreSQL License입니다. 별 수는 같은 저장소의 GitHub REST API 조회값. <a href="#fnref:pgvector" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pgvector:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:pgvector:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:pgvector:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:pgvector:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a></p>
    </li>
    <li id="fn:hnsw" role="doc-endnote">
      <p>Malkov·Yashunin, <a href="https://arxiv.org/abs/1603.09320">Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs</a>. 2016년 최초 제출. <a href="#fnref:hnsw" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:rag" role="doc-endnote">
      <p>Lewis 외, <a href="https://arxiv.org/abs/2005.11401">Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks</a>. NeurIPS 2020. <a href="#fnref:rag" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:rag:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:s3vectors" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vectors.html">AWS — Working with S3 Vectors and vector buckets</a>. <a href="#fnref:s3vectors" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:s3vectors:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:milvus" role="doc-endnote">
      <p><a href="https://github.com/milvus-io/milvus">Milvus 공식 저장소</a>. 배포 형태, 인덱스, Apache-2.0. 별 수는 같은 저장소의 GitHub REST API 조회값. <a href="#fnref:milvus" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:milvus:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:qdrant" role="doc-endnote">
      <p><a href="https://github.com/qdrant/qdrant">Qdrant 공식 저장소</a>. payload·필터·하이브리드 검색, 관리형 서비스, Apache-2.0. 별 수는 같은 저장소의 GitHub REST API 조회값. <a href="#fnref:qdrant" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:qdrant:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:qdrant:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:weaviate" role="doc-endnote">
      <p><a href="https://github.com/weaviate/weaviate">Weaviate 공식 저장소</a>, <a href="https://github.com/weaviate/weaviate/blob/e3f4df7ba89fddc6a6c03fd54102fcc7fed63b7f/LICENSE">2026-09-25 조회 시점 커밋의 LICENSE</a>. 별 수는 같은 저장소의 GitHub REST API 조회값. <a href="#fnref:weaviate" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:weaviate:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:weaviate:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:weaviate:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:chroma" role="doc-endnote">
      <p><a href="https://github.com/chroma-core/chroma">Chroma 공식 저장소</a>, <a href="https://github.com/chroma-core/chroma/blob/main/LICENSE">LICENSE</a>. 로컬·서버 사용과 Chroma Cloud. 별 수는 같은 저장소의 GitHub REST API 조회값. <a href="#fnref:chroma" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:chroma:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:chroma:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:lancedb" role="doc-endnote">
      <p><a href="https://github.com/lancedb/lancedb">LanceDB 공식 저장소</a>, <a href="https://github.com/lancedb/lancedb/blob/main/LICENSE">LICENSE</a>. 컬럼 형식·멀티모달 데이터·Cloud/Enterprise 구분. 별 수는 같은 저장소의 GitHub REST API 조회값. <a href="#fnref:lancedb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:lancedb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:lancedb:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:opensearch" role="doc-endnote">
      <p><a href="https://docs.opensearch.org/latest/vector-search/">OpenSearch Vector Search</a>, <a href="https://github.com/opensearch-project/OpenSearch">공식 저장소</a>. <a href="#fnref:opensearch" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:vespa" role="doc-endnote">
      <p><a href="https://github.com/vespa-engine/vespa">Vespa 공식 저장소</a>. 검색·추천·랭킹 범위와 Apache-2.0. <a href="#fnref:vespa" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pinecone" role="doc-endnote">
      <p><a href="https://docs.pinecone.io/guides/get-started/overview">Pinecone 공식 문서</a>. <a href="#fnref:pinecone" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:zilliz" role="doc-endnote">
      <p><a href="https://zilliz.com/cloud">Zilliz Cloud</a>. Milvus 기반 관리형 서비스. <a href="#fnref:zilliz" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:aws-opensearch" role="doc-endnote">
      <p><a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vector-search.html">Amazon OpenSearch Service — Vector search</a>. <a href="#fnref:aws-opensearch" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:mongodb" role="doc-endnote">
      <p><a href="https://www.mongodb.com/docs/vector-search/">MongoDB Vector Search Overview</a>. Atlas 및 self-managed 경로와 검색 기능. <a href="#fnref:mongodb" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:elastic-license" role="doc-endnote">
      <p><a href="https://www.elastic.co/pricing/faq/licensing">Elastic — FAQ on Software Licensing</a>. 소스 코드의 라이선스 선택지와 기본 배포판 ELv2 구분. <a href="#fnref:elastic-license" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:redis-license" role="doc-endnote">
      <p><a href="https://redis.io/legal/licenses/">Redis Licensing Overview</a>. Redis 8 이상과 이전 버전의 라이선스 구분. <a href="#fnref:redis-license" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:mongodb-license" role="doc-endnote">
      <p><a href="https://www.mongodb.com/legal/licensing/server-side-public-license/faq">MongoDB — Server Side Public License FAQ</a>. <a href="#fnref:mongodb-license" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:survey" role="doc-endnote">
      <p><a href="https://survey.stackoverflow.co/2025/ai#3-ai-agent-data-storage-tools">Stack Overflow Developer Survey 2025 — AI Agent data storage tools</a>. 해당 문항의 질문·응답 수·선택지별 비율(2026-09-25 조회). <a href="#fnref:survey" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:survey:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:survey-method" role="doc-endnote">
      <p><a href="https://survey.stackoverflow.co/2025/methodology">Stack Overflow Developer Survey 2025 — Methodology</a>. 조사 기간 2025-05-29~2025-06-23, 전체 표본과 모집 방식. <a href="#fnref:survey-method" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:db-method" role="doc-endnote">
      <p><a href="https://db-engines.com/en/ranking_definition">DB-Engines — Method of calculating the scores</a>. 설치 수·실제 사용량을 측정하지 않는다는 방법론 설명. <a href="#fnref:db-method" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:db-ranking" role="doc-endnote">
      <p><a href="https://db-engines.com/en/ranking/vector+dbms">DB-Engines — Vector DBMS Ranking</a>. 2026년 9월 표(2026-09-25 조회). 링크는 이후 달의 표로 바뀔 수 있습니다. <a href="#fnref:db-ranking" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:db-ranking:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:stars" role="doc-endnote">
      <p><a href="https://docs.github.com/en/get-started/exploring-projects-on-github/saving-repositories-with-stars">GitHub — Saving repositories with stars</a>. 수치 조회: 2026-09-25 12:45 UTC, GitHub REST API <code class="language-plaintext highlighter-rouge">GET /repos/{owner}/{repo}</code>. <a href="#fnref:stars" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="ai/ml" /><summary type="html"><![CDATA[임베딩·유사도 검색·RAG의 관계부터 오픈소스와 상용 제품까지 정리하고 개발자 설문·DB-Engines·GitHub 별 수가 각각 무엇을 재는지 구분해 읽습니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">MySQL 복제지연</title><link href="https://rastalion.dev/writing/mysql-replication-lag/" rel="alternate" type="text/html" title="MySQL 복제지연" /><published>2025-05-23T05:53:45+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/mysql-replication-lag</id><content type="html" xml:base="https://rastalion.dev/writing/mysql-replication-lag/"><![CDATA[<p>MySQL 복제 지연(replication lag)은 주 서버(source)에 반영된 변경이 보조 서버(replica)에 늦게 도착하거나 늦게 적용되는 현상입니다. 읽기 부하를 보조 서버로 분산한 구성에서는 지연이 그대로 오래된 데이터를 읽는 문제로 이어집니다.</p>

<p>이 글의 파라미터 이름과 기본값은 <strong>MySQL 8.4 LTS의 비동기 binlog 복제</strong> 기준입니다. SQL 예제와 워커 수 변경, GTID 대기는 MySQL Community Server 8.4.11의 소스·보조 서버 구성에서 확인했습니다. RDS와 Aurora는 지원 버전·파라미터 적용 방법·복제 구조를 따로 확인해야 합니다.</p>

<h2 id="먼저-복제-구조를-구분합니다">먼저 복제 구조를 구분합니다</h2>

<table>
  <thead>
    <tr>
      <th>구성</th>
      <th>이 글을 적용할 범위</th>
      <th>대표 지표</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>직접 운영하는 MySQL binlog 복제</td>
      <td>수신·적용 스레드, 워커, 릴레이 로그 진단</td>
      <td><code class="language-plaintext highlighter-rouge">SHOW REPLICA STATUS</code>, Performance Schema</td>
    </tr>
    <tr>
      <td>RDS for MySQL 읽기 복제본</td>
      <td>binlog 복제 진단과 RDS 스토리지·인스턴스 지표</td>
      <td><code class="language-plaintext highlighter-rouge">ReplicaLag</code>, I/O·CPU 지표</td>
    </tr>
    <tr>
      <td>같은 Aurora 클러스터의 Reader</td>
      <td>공유 클러스터 볼륨을 사용하는 내부 복제 진단</td>
      <td><code class="language-plaintext highlighter-rouge">AuroraReplicaLag</code></td>
    </tr>
    <tr>
      <td>외부 MySQL이나 다른 클러스터의 binlog를 받는 Aurora</td>
      <td>지원 버전의 binlog 수신·적용 진단</td>
      <td><code class="language-plaintext highlighter-rouge">AuroraBinlogReplicaLag</code></td>
    </tr>
  </tbody>
</table>

<p><strong>같은 Aurora 클러스터의 Writer→Reader 복제에 <code class="language-plaintext highlighter-rouge">replica_parallel_workers</code> 튜닝을 그대로 적용하면 안 됩니다.</strong> Aurora 내부 Reader는 공유 스토리지 구조를 사용하고, 외부·클러스터 간 binlog 복제는 별도 경로입니다. Aurora Global Database도 별도 구성으로 구분합니다.<sup id="fnref:aurora-replication" role="doc-noteref"><a href="#fn:aurora-replication" class="footnote" rel="footnote">1</a></sup><sup id="fnref:aurora-metrics" role="doc-noteref"><a href="#fn:aurora-metrics" class="footnote" rel="footnote">2</a></sup></p>

<h2 id="복제-지연이-발생하는-이유">복제 지연이 발생하는 이유</h2>

<ul>
  <li>수신 경로: 네트워크 지연·재연결, 소스의 binlog 공급 지연</li>
  <li>적용 작업: 큰 트랜잭션, 행 탐색 비용, 잠금·커밋 순서 대기</li>
  <li>자원과 설정: CPU·스토리지·메모리 경쟁, 워커 수와 버퍼 설정</li>
</ul>

<p>적용이 중단된 서버를 단순히 “느린 서버”로 보면 잘못된 파라미터를 조정하게 됩니다. 먼저 스레드 상태와 오류를 확인하고, 수신이 밀리는지 이미 받은 트랜잭션의 적용이 밀리는지 나눕니다.</p>

<p>스토리지 증설도 측정 후 결정합니다. 같은 지연이라도 대형 트랜잭션을 나눠 해결할 수 있고, DDL 잠금을 해소해야 할 수도 있습니다. 파라미터 조정은 병목이 확인된 뒤의 단계입니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 940 420" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="replication-path-archify-diagram-title">MySQL 복제 경로와 측정 지점</title>
        <desc id="replication-path-archify-diagram-description">A data-flow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="replication-path-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-default" />
          </marker>
          <marker id="replication-path-arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
          </marker>
          <marker id="replication-path-arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-security" />
          </marker>
          <marker id="replication-path-arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
          </marker>
          <pattern id="replication-path-grid" width="40" height="40" patternUnits="userSpaceOnUse">
            <path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5" />
          </pattern>
        </defs>

        <!-- Background Grid -->
        <rect width="100%" height="100%" fill="url(#replication-path-grid)" />

        <!-- Data Stages -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="0" x="16" y="46" width="168" height="300" rx="10" class="c-lane" stroke-width="1" />
        <text x="100" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">01 / 소스</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="1" x="231" y="46" width="168" height="300" rx="10" class="c-lane" stroke-width="1" />
        <text x="315" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">02 / 수신</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="2" x="446" y="46" width="168" height="300" rx="10" class="c-lane" stroke-width="1" />
        <text x="530" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">03 / 적용</text>

        <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="3" x="661" y="46" width="168" height="300" rx="10" class="c-lane" stroke-width="1" />
        <text x="745" y="68" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">04 / 저장</text>

        <!-- Flow paths -->
        <path data-edge-from="binlog" data-edge-to="receiver" data-edge-label="binlog 이벤트" data-edge-key="0" data-composition-points="156,157;259,157" d="M 156 157 L 259 157" class="a-emphasis" stroke-width="1.8" marker-end="url(#replication-path-arrowhead-emphasis)" />
        <path data-edge-from="receiver" data-edge-to="relay" data-edge-label="릴레이 로그 기록" data-edge-key="1" data-composition-points="315,186;315,242" d="M 315 186 L 315 242" class="a-emphasis" stroke-width="1.8" marker-end="url(#replication-path-arrowhead-emphasis)" />
        <path data-edge-from="relay" data-edge-to="coordinator" data-edge-label="트랜잭션 순서" data-edge-key="2" data-composition-points="371,271;422.5,271;422.5,157;474,157" d="M 371 271 L 422.5 271 L 422.5 157 L 474 157" class="a-default" stroke-width="1.4" marker-end="url(#replication-path-arrowhead)" />
        <path data-edge-from="coordinator" data-edge-to="workers" data-edge-label="워커 배분" data-edge-key="3" data-composition-points="530,186;530,242" d="M 530 186 L 530 242" class="a-default" stroke-width="1.4" marker-end="url(#replication-path-arrowhead)" />
        <path data-edge-from="workers" data-edge-to="bufferpool" data-edge-label="행 변경 적용" data-edge-key="4" data-composition-points="586,271;637.5,271;637.5,157;689,157" d="M 586 271 L 637.5 271 L 637.5 157 L 689 157" class="a-emphasis" stroke-width="1.8" marker-end="url(#replication-path-arrowhead-emphasis)" />
        <path data-edge-from="bufferpool" data-edge-to="disk" data-edge-label="더티 페이지 플러시" data-edge-key="5" data-composition-points="745,186;745,242" d="M 745 186 L 745 242" class="a-dashed" stroke-width="1.4" marker-end="url(#replication-path-arrowhead-dashed)" />

        <!-- Nodes -->
        <g id="replication-path-node-binlog" data-node-id="binlog" data-node-label="소스 binlog" tabindex="0" role="button" aria-label="Focus 소스 binlog, 소스 서버, 01 / 소스" aria-pressed="false" data-node-kind="database" data-node-sublabel="소스 서버" data-node-tag="File + Position" data-node-context="01 / 소스">
          <title>소스 binlog · 소스 서버 · 01 / 소스 · File + Position</title>
          <rect x="44" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="44" y="128" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(50 134) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="100" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">소스 binlog</text>
          <text data-detail="context" x="100" y="165" class="t-muted" font-size="7" text-anchor="middle">소스 서버</text>
        <text data-detail="fine" x="100" y="175" class="t-database" font-size="7" text-anchor="middle">File + Position</text>
        </g>

        <g id="replication-path-node-receiver" data-node-id="receiver" data-node-label="수신 스레드" tabindex="0" role="button" aria-label="Focus 수신 스레드, Replica_IO_Running, 02 / 수신" aria-pressed="false" data-node-kind="backend" data-node-sublabel="Replica_IO_Running" data-node-tag="Read_Source_Log_Pos" data-node-context="02 / 수신">
          <title>수신 스레드 · Replica_IO_Running · 02 / 수신 · Read_Source_Log_Pos</title>
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="128" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(265 134) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">수신 스레드</text>
          <text data-detail="context" x="315" y="165" class="t-muted" font-size="7" text-anchor="middle">Replica_IO_Running</text>
        <text data-detail="fine" x="315" y="175" class="t-backend" font-size="7" text-anchor="middle">Read_Source_Log_Pos</text>
        </g>

        <g id="replication-path-node-relay" data-node-id="relay" data-node-label="릴레이 로그" tabindex="0" role="button" aria-label="Focus 릴레이 로그, relay_log_space_limit, 02 / 수신" aria-pressed="false" data-node-kind="messagebus" data-node-sublabel="relay_log_space_limit" data-node-context="02 / 수신">
          <title>릴레이 로그 · relay_log_space_limit · 02 / 수신</title>
          <rect x="259" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="259" y="242" width="112" height="58" rx="6" class="c-messagebus" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="messagebus" class="semantic-sigil s-messagebus" transform="translate(265 248) scale(0.6875)">
            <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
            <circle cx="5" cy="4.5" r="1" class="sigil-fill" />
            <circle cx="10.5" cy="8" r="1" class="sigil-fill" />
            <circle cx="7" cy="11.5" r="1" class="sigil-fill" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="315" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">릴레이 로그</text>
          <text data-detail="context" x="315" y="279" class="t-muted" font-size="7" text-anchor="middle">relay_log_space_limit</text>
        </g>

        <g id="replication-path-node-coordinator" data-node-id="coordinator" data-node-label="코디네이터" tabindex="0" role="button" aria-label="Focus 코디네이터, 트랜잭션 순서 배분, 03 / 적용" aria-pressed="false" data-node-kind="backend" data-node-sublabel="트랜잭션 순서 배분" data-node-context="03 / 적용">
          <title>코디네이터 · 트랜잭션 순서 배분 · 03 / 적용</title>
          <rect x="474" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="128" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(480 134) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">코디네이터</text>
          <text data-detail="context" x="530" y="165" class="t-muted" font-size="7" text-anchor="middle">트랜잭션 순서 배분</text>
        </g>

        <g id="replication-path-node-workers" data-node-id="workers" data-node-label="워커 스레드" tabindex="0" role="button" aria-label="Focus 워커 스레드, replica_parallel_workers, 03 / 적용" aria-pressed="false" data-node-kind="backend" data-node-sublabel="replica_parallel_workers" data-node-tag="Exec_Source_Log_Pos" data-node-context="03 / 적용">
          <title>워커 스레드 · replica_parallel_workers · 03 / 적용 · Exec_Source_Log_Pos</title>
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="474" y="242" width="112" height="58" rx="6" class="c-backend" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(480 248) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="530" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">워커 스레드</text>
          <text data-detail="context" x="530" y="279" class="t-muted" font-size="7" text-anchor="middle">replica_parallel_workers</text>
        <text data-detail="fine" x="530" y="289" class="t-backend" font-size="7" text-anchor="middle">Exec_Source_Log_Pos</text>
        </g>

        <g id="replication-path-node-bufferpool" data-node-id="bufferpool" data-node-label="InnoDB 버퍼 풀" tabindex="0" role="button" aria-label="Focus InnoDB 버퍼 풀, innodb_buffer_pool_size, 04 / 저장" aria-pressed="false" data-node-kind="database" data-node-sublabel="innodb_buffer_pool_size" data-node-context="04 / 저장">
          <title>InnoDB 버퍼 풀 · innodb_buffer_pool_size · 04 / 저장</title>
          <rect x="689" y="128" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="128" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(695 134) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="149" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">InnoDB 버퍼 풀</text>
          <text data-detail="context" x="745" y="165" class="t-muted" font-size="7" text-anchor="middle">innodb_buffer_pool_size</text>
        </g>

        <g id="replication-path-node-disk" data-node-id="disk" data-node-label="디스크" tabindex="0" role="button" aria-label="Focus 디스크, innodb_io_capacity, 04 / 저장" aria-pressed="false" data-node-kind="database" data-node-sublabel="innodb_io_capacity" data-node-context="04 / 저장">
          <title>디스크 · innodb_io_capacity · 04 / 저장</title>
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-mask" />
          <rect x="689" y="242" width="112" height="58" rx="6" class="c-database" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(695 248) scale(0.6875)">
            <ellipse cx="8" cy="4" rx="5" ry="2" />
            <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="745" y="263" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">디스크</text>
          <text data-detail="context" x="745" y="279" class="t-muted" font-size="7" text-anchor="middle">innodb_io_capacity</text>
        </g>

        <!-- Flow labels -->
        <g data-detail="context" data-edge-from="binlog" data-edge-to="receiver" data-edge-label="binlog 이벤트" data-edge-key="0">
          <rect x="169.65" y="136" width="75.7" height="16" rx="4" class="c-mask" />
          <text x="207.5" y="147" class="t-backend" font-size="8" text-anchor="middle">binlog 이벤트</text>
        </g>
        <g data-detail="context" data-edge-from="receiver" data-edge-to="relay" data-edge-label="릴레이 로그 기록" data-edge-key="1">
          <rect x="269.8" y="189" width="90.4" height="16" rx="4" class="c-mask" />
          <text x="315" y="200" class="t-backend" font-size="8" text-anchor="middle">릴레이 로그 기록</text>
        </g>
        <g data-detail="context" data-edge-from="relay" data-edge-to="coordinator" data-edge-label="트랜잭션 순서" data-edge-key="2">
          <rect x="384.65" y="193" width="75.7" height="16" rx="4" class="c-mask" />
          <text x="422.5" y="204" class="t-muted" font-size="8" text-anchor="middle">트랜잭션 순서</text>
        </g>
        <g data-detail="context" data-edge-from="coordinator" data-edge-to="workers" data-edge-label="워커 배분" data-edge-key="3">
          <rect x="501.95" y="189" width="56.1" height="16" rx="4" class="c-mask" />
          <text x="530" y="200" class="t-muted" font-size="8" text-anchor="middle">워커 배분</text>
        </g>
        <g data-detail="context" data-edge-from="workers" data-edge-to="bufferpool" data-edge-label="행 변경 적용" data-edge-key="4">
          <rect x="602.1" y="193" width="70.8" height="16" rx="4" class="c-mask" />
          <text x="637.5" y="204" class="t-backend" font-size="8" text-anchor="middle">행 변경 적용</text>
        </g>
        <g data-detail="context" data-edge-from="bufferpool" data-edge-to="disk" data-edge-label="더티 페이지 플러시" data-edge-key="5">
          <rect x="694.9" y="189" width="100.2" height="16" rx="4" class="c-mask" />
          <text x="745" y="200" class="t-messagebus" font-size="8" text-anchor="middle">더티 페이지 플러시</text>
        </g>

        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="364" class="t-primary" font-size="12" font-weight="650">범례</text>
          <g data-legend-semantic-kind="emphasis" data-legend-x="40" data-legend-baseline="384" data-legend-width="103">
            <path d="M 40 381 L 74 381" class="a-emphasis" stroke-width="1.8" marker-end="url(#replication-path-arrowhead-emphasis)" />
            <text x="83" y="384" class="t-muted" font-size="10" font-weight="500">복제 주 경로</text>
          </g>
          <g data-legend-semantic-kind="dashed" data-legend-x="165" data-legend-baseline="384" data-legend-width="128">
            <path d="M 165 381 L 199 381" class="a-dashed" stroke-width="1.4" marker-end="url(#replication-path-arrowhead-dashed)" />
            <text x="208" y="384" class="t-muted" font-size="10" font-weight="500">백그라운드 플러시</text>
          </g>
          <g data-legend-semantic-kind="database" data-legend-kind="database" data-legend-label="저장 계층" data-legend-x="315" data-legend-baseline="384" data-legend-width="88">
            <rect x="315" y="376" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="337" y="384" class="t-muted" font-size="10" font-weight="500">저장 계층</text>
          </g>
          <g data-legend-semantic-kind="default" data-legend-x="425" data-legend-baseline="384" data-legend-width="88">
            <path d="M 425 381 L 459 381" class="a-default" stroke-width="1.4" marker-end="url(#replication-path-arrowhead)" />
            <text x="468" y="384" class="t-muted" font-size="10" font-weight="500">내부 전달</text>
          </g>
        </g>
      </svg>
</div><figcaption>복제 경로와 세 측정 지점. 각 파라미터가 어느 구간에 작용하는지도 함께 표시했습니다.</figcaption>
</figure>

<p>수신 구간은 소스의 binlog 위치와 보조 서버의 수신 위치 사이이고, 적용 구간은 수신 위치와 적용 위치 사이입니다. 지연을 좁힐 때는 어느 구간이 벌어져 있는지부터 가릅니다.</p>

<h2 id="지연을-먼저-측정합니다">지연을 먼저 측정합니다</h2>

<p>파라미터를 만지기 전에 지연을 재는 지표부터 정해야 합니다. 8.4 에서는 <code class="language-plaintext highlighter-rouge">SHOW SLAVE STATUS</code> 같은 옛 문장이 문법 오류가 되므로 <code class="language-plaintext highlighter-rouge">SHOW REPLICA STATUS</code> 를 씁니다. 필드 이름도 <code class="language-plaintext highlighter-rouge">Seconds_Behind_Source</code> 이고, 옛 이름 <code class="language-plaintext highlighter-rouge">Seconds_Behind_Master</code> 를 찾는 모니터링 스크립트는 함께 고쳐야 합니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SHOW</span> <span class="n">REPLICA</span> <span class="n">STATUS</span><span class="err">\</span><span class="k">G</span>
</code></pre></div></div>

<h3 id="복제-중단--수신-지연--적용-지연-순서로-봅니다">복제 중단 → 수신 지연 → 적용 지연 순서로 봅니다</h3>

<p>여러 채널을 사용한다면 같은 <code class="language-plaintext highlighter-rouge">Channel_Name</code>의 상태를 이어서 비교합니다.<sup id="fnref:status" role="doc-noteref"><a href="#fn:status" class="footnote" rel="footnote">3</a></sup></p>

<table>
  <thead>
    <tr>
      <th>확인 항목</th>
      <th>해석과 다음 행동</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Replica_IO_Running</code>이 <code class="language-plaintext highlighter-rouge">No</code> 또는 <code class="language-plaintext highlighter-rouge">Connecting</code></td>
      <td><code class="language-plaintext highlighter-rouge">Last_IO_Errno</code>, <code class="language-plaintext highlighter-rouge">Last_IO_Error</code>, 연결 상태부터 확인합니다. 의도적으로 중지했는지도 확인합니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Replica_SQL_Running</code>이 <code class="language-plaintext highlighter-rouge">No</code></td>
      <td><code class="language-plaintext highlighter-rouge">Last_SQL_Errno</code>, <code class="language-plaintext highlighter-rouge">Last_SQL_Error</code>와 워커별 오류를 확인합니다. 오류로 중단된 경우에는 재시작보다 오류 원인 해결이 먼저입니다.</td>
    </tr>
    <tr>
      <td>두 스레드가 <code class="language-plaintext highlighter-rouge">Yes</code>, 수신 위치가 소스보다 계속 뒤처짐</td>
      <td>네트워크·재연결·소스 부하를 확인합니다. <code class="language-plaintext highlighter-rouge">relay_log_space_limit</code>에 도달해 수신이 대기하는 경우도 구분합니다.</td>
    </tr>
    <tr>
      <td>수신 위치는 따라가지만 적용 위치가 계속 뒤처짐</td>
      <td>적용 중인 트랜잭션·워커 상태·잠금·CPU·스토리지 순서로 좁힙니다.</td>
    </tr>
    <tr>
      <td>조회한 순간에는 모두 따라잡음</td>
      <td>간헐적 지연일 수 있습니다. 몇 초 간격으로 반복 측정하고 애플리케이션 오류 시각과 대조합니다.</td>
    </tr>
  </tbody>
</table>

<p>소스의 현재 binlog 위치는 <strong>소스 서버</strong>에서 확인합니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SHOW</span> <span class="nb">BINARY</span> <span class="n">LOG</span> <span class="n">STATUS</span><span class="p">;</span>
</code></pre></div></div>

<p>비교할 위치는 다음 세 쌍입니다.</p>

<ul>
  <li>소스: <code class="language-plaintext highlighter-rouge">File</code> + <code class="language-plaintext highlighter-rouge">Position</code></li>
  <li>보조 서버 수신: <code class="language-plaintext highlighter-rouge">Source_Log_File</code> + <code class="language-plaintext highlighter-rouge">Read_Source_Log_Pos</code></li>
  <li>보조 서버 적용: <code class="language-plaintext highlighter-rouge">Relay_Source_Log_File</code> + <code class="language-plaintext highlighter-rouge">Exec_Source_Log_Pos</code></li>
</ul>

<p><strong>파일명이 다른 위치의 숫자만 빼면 안 됩니다.</strong> 파일·위치는 시간 지연 자체도 아니며, 병렬 적용에서 실행 위치는 완료된 작업의 하한을 나타낼 수 있습니다. 한 번의 스냅샷보다 각 위치가 전진하는지, 간격이 줄어드는지를 반복해서 봅니다.<sup id="fnref:status:1" role="doc-noteref"><a href="#fn:status" class="footnote" rel="footnote">3</a></sup></p>

<p>GTID 구성에서는 소스의 <code class="language-plaintext highlighter-rouge">Executed_Gtid_Set</code>, 보조 서버의 <code class="language-plaintext highlighter-rouge">Retrieved_Gtid_Set</code>·<code class="language-plaintext highlighter-rouge">Executed_Gtid_Set</code>도 참고합니다. <code class="language-plaintext highlighter-rouge">GTID_SUBTRACT()</code>로 미적용 집합을 구할 수 있지만 그 결과는 지연 시간이나 남은 바이트 수가 아닙니다. 보조 서버의 실행 집합에는 다른 채널이나 로컬 트랜잭션도 포함될 수 있으므로 비교 범위를 확인합니다.<sup id="fnref:gtid" role="doc-noteref"><a href="#fn:gtid" class="footnote" rel="footnote">4</a></sup></p>

<h3 id="seconds_behind_source의-한계">Seconds_Behind_Source의 한계</h3>

<p><code class="language-plaintext highlighter-rouge">Seconds_Behind_Source</code> 는 꺼내 보기 편하지만 그대로 믿을 수 있는 값이 아닙니다. 공식 문서는 이 필드가 본질적으로 적용(applier) 스레드와 수신(receiver) 스레드 사이의 시간 차를 재는 값이며 빠른 네트워크에서만 유용하다고 적습니다. 네트워크가 느리면 적용 스레드가 느린 수신 스레드를 자주 따라잡아 버려서, 수신 스레드가 소스보다 한참 뒤처져 있어도 값이 0 으로 보입니다.</p>

<p>문서가 밝히는 나머지 제약입니다.</p>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th><code class="language-plaintext highlighter-rouge">Seconds_Behind_Source</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>수신·적용이 정상이고 처리할 이벤트가 없음</td>
      <td><code class="language-plaintext highlighter-rouge">0</code></td>
    </tr>
    <tr>
      <td>적용 스레드가 돌지 않음</td>
      <td><code class="language-plaintext highlighter-rouge">NULL</code></td>
    </tr>
    <tr>
      <td>릴레이 로그 소진 + 수신 스레드 정지</td>
      <td><code class="language-plaintext highlighter-rouge">NULL</code></td>
    </tr>
    <tr>
      <td>릴레이 로그 소진 + 수신 스레드 가동</td>
      <td><code class="language-plaintext highlighter-rouge">0</code></td>
    </tr>
  </tbody>
</table>

<ul>
  <li>소스와 보조 서버의 시계가 달라도, 수신 스레드가 시작할 때 계산한 차이가 그대로 유지되는 한 계산은 성립합니다. NTP 갱신을 포함한 시각 변경은 이 값을 덜 믿을 만한 것으로 만듭니다.</li>
  <li>이벤트에 실린 타임스탬프는 최초 소스의 것이 보존됩니다. 3단 복제에서 중간 서버가 클라이언트 쓰기까지 함께 받으면, 마지막 이벤트가 최초 소스에서 온 것일 때와 중간 서버에서 생긴 것일 때가 섞여 값이 들쭉날쭉해집니다.</li>
  <li>멀티스레드 복제에서는 이 값이 <code class="language-plaintext highlighter-rouge">Exec_Source_Log_Pos</code> 를 기준으로 하므로 가장 최근에 커밋된 트랜잭션을 반영하지 않을 수 있습니다.</li>
</ul>

<h3 id="워커별-상태와-적용-시간을-확인합니다">워커별 상태와 적용 시간을 확인합니다</h3>

<p>멀티스레드 복제라면 <code class="language-plaintext highlighter-rouge">performance_schema.replication_applier_status_by_worker</code>를 함께 봅니다. <code class="language-plaintext highlighter-rouge">threads</code>와 연결하면 워커가 실행 중인지 잠금·커밋 순서를 기다리는지도 확인할 수 있습니다.<sup id="fnref:worker" role="doc-noteref"><a href="#fn:worker" class="footnote" rel="footnote">5</a></sup><sup id="fnref:thread-states" role="doc-noteref"><a href="#fn:thread-states" class="footnote" rel="footnote">6</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">w</span><span class="p">.</span><span class="n">CHANNEL_NAME</span><span class="p">,</span> <span class="n">w</span><span class="p">.</span><span class="n">WORKER_ID</span><span class="p">,</span> <span class="n">w</span><span class="p">.</span><span class="n">SERVICE_STATE</span><span class="p">,</span>
       <span class="n">t</span><span class="p">.</span><span class="n">PROCESSLIST_STATE</span><span class="p">,</span>
       <span class="n">w</span><span class="p">.</span><span class="n">APPLYING_TRANSACTION</span><span class="p">,</span>
       <span class="n">w</span><span class="p">.</span><span class="n">APPLYING_TRANSACTION_START_APPLY_TIMESTAMP</span><span class="p">,</span>
       <span class="n">w</span><span class="p">.</span><span class="n">APPLYING_TRANSACTION_RETRIES_COUNT</span><span class="p">,</span>
       <span class="n">w</span><span class="p">.</span><span class="n">LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP</span> <span class="k">AS</span> <span class="n">committed_on_source</span><span class="p">,</span>
       <span class="n">w</span><span class="p">.</span><span class="n">LAST_APPLIED_TRANSACTION_END_APPLY_TIMESTAMP</span> <span class="k">AS</span> <span class="n">applied_on_replica</span><span class="p">,</span>
       <span class="n">w</span><span class="p">.</span><span class="n">LAST_ERROR_NUMBER</span><span class="p">,</span> <span class="n">w</span><span class="p">.</span><span class="n">LAST_ERROR_MESSAGE</span>
<span class="k">FROM</span> <span class="n">performance_schema</span><span class="p">.</span><span class="n">replication_applier_status_by_worker</span> <span class="k">AS</span> <span class="n">w</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">performance_schema</span><span class="p">.</span><span class="n">threads</span> <span class="k">AS</span> <span class="n">t</span> <span class="k">ON</span> <span class="n">t</span><span class="p">.</span><span class="n">THREAD_ID</span> <span class="o">=</span> <span class="n">w</span><span class="p">.</span><span class="n">THREAD_ID</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">w</span><span class="p">.</span><span class="n">CHANNEL_NAME</span><span class="p">,</span> <span class="n">w</span><span class="p">.</span><span class="n">WORKER_ID</span><span class="p">;</span>
</code></pre></div></div>

<p>커밋 시각은 복제로 전달되지만 적용 시작·종료 시각은 보조 서버에서 수집합니다. 그래서 Performance Schema 를 끄면 적용 시각 칼럼이 0 으로 나옵니다. <code class="language-plaintext highlighter-rouge">APPLYING_TRANSACTION_START_APPLY_TIMESTAMP</code> 는 첫 시도 시각이므로, 오래 걸리는 트랜잭션을 볼 때는 <code class="language-plaintext highlighter-rouge">APPLYING_TRANSACTION_RETRIES_COUNT</code> 와 함께 읽어 재시도 때문인지 구분합니다.</p>

<blockquote>
  <p><strong>NOTE</strong> — <code class="language-plaintext highlighter-rouge">START REPLICA</code> 로 복제를 다시 시작하면 <code class="language-plaintext highlighter-rouge">APPLYING_TRANSACTION</code> 으로 시작하는 칼럼들이 초기화됩니다.</p>
</blockquote>

<p>두 시각의 차이는 <strong>해당 워커에서 마지막으로 완료한 트랜잭션의 소스 커밋→복제 적용 완료 시간</strong>입니다. 현재 대기 중인 전체 작업의 지연은 아닙니다. 유휴 서버에서 현재 시각과 마지막 커밋 시각의 차이를 계산하면, 새 쓰기가 없는데도 지연이 늘어나는 것처럼 보입니다. 서로 다른 서버의 시각을 비교하므로 시계 동기화도 확인합니다.</p>

<h2 id="파라미터로-해결되지-않는-원인을-먼저-봅니다">파라미터로 해결되지 않는 원인을 먼저 봅니다</h2>

<h3 id="하나의-큰-트랜잭션">하나의 큰 트랜잭션</h3>

<p>단일 트랜잭션은 여러 워커로 나눠 적용되지 않습니다. 대량 UPDATE·DELETE 하나가 오래 걸리면 워커 수를 늘려도 그 트랜잭션 자체는 빨라지지 않습니다. 소스에서 오래 실행 중인 InnoDB 트랜잭션은 다음처럼 찾습니다.<sup id="fnref:aurora-binlog" role="doc-noteref"><a href="#fn:aurora-binlog" class="footnote" rel="footnote">7</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">trx_mysql_thread_id</span><span class="p">,</span> <span class="n">trx_started</span><span class="p">,</span> <span class="n">trx_state</span><span class="p">,</span>
       <span class="n">trx_rows_modified</span><span class="p">,</span> <span class="n">trx_query</span>
<span class="k">FROM</span> <span class="n">information_schema</span><span class="p">.</span><span class="n">INNODB_TRX</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">trx_started</span><span class="p">;</span>
</code></pre></div></div>

<p>이 목록은 현재 활성 트랜잭션입니다. <strong>이미 소스에서 커밋됐지만 보조 서버에서 적용 중인 작업은 여기서 보이지 않습니다.</strong> 그 경우 워커의 <code class="language-plaintext highlighter-rouge">APPLYING_TRANSACTION</code>, 적용 시작 시각, 배치 실행 이력과 binlog를 대조합니다.</p>

<p>예를 들어 백만 행을 한 트랜잭션으로 갱신하던 배치는, 업무상 분할 커밋이 가능할 때 PK 구간별 작은 트랜잭션으로 나눌 수 있습니다. 배치 크기와 실행 간격은 지연·잠금·처리량을 보며 조절하고, 분할 후 재시도와 부분 완료도 처리합니다. 전체 원자성이 필요한 작업을 임의로 나누지는 않습니다.</p>

<h3 id="pk나-적절한-인덱스가-없는-테이블">PK나 적절한 인덱스가 없는 테이블</h3>

<p>ROW 복제의 UPDATE·DELETE도 보조 서버에서 변경할 행을 찾아야 합니다. MySQL은 PK, 적합한 유니크 키 등 사용 가능한 키를 검토하고, 적합한 인덱스가 없으면 테이블 스캔 비용이 발생할 수 있습니다. <strong>PK가 없다는 사실만으로 매 행마다 전체 스캔한다고 단정하면 안 됩니다.</strong> 실제 키와 행 탐색 방식을 확인합니다.<sup id="fnref:row-search" role="doc-noteref"><a href="#fn:row-search" class="footnote" rel="footnote">8</a></sup></p>

<p>다음 쿼리는 사용자 InnoDB 테이블 중 PK가 없는 후보를 찾습니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">t</span><span class="p">.</span><span class="n">TABLE_SCHEMA</span><span class="p">,</span> <span class="n">t</span><span class="p">.</span><span class="k">TABLE_NAME</span>
<span class="k">FROM</span> <span class="n">information_schema</span><span class="p">.</span><span class="n">TABLES</span> <span class="k">AS</span> <span class="n">t</span>
<span class="k">WHERE</span> <span class="n">t</span><span class="p">.</span><span class="n">TABLE_TYPE</span> <span class="o">=</span> <span class="s1">'BASE TABLE'</span> <span class="k">AND</span> <span class="n">t</span><span class="p">.</span><span class="n">ENGINE</span> <span class="o">=</span> <span class="s1">'InnoDB'</span>
  <span class="k">AND</span> <span class="n">t</span><span class="p">.</span><span class="n">TABLE_SCHEMA</span> <span class="k">NOT</span> <span class="k">IN</span> <span class="p">(</span><span class="s1">'mysql'</span><span class="p">,</span> <span class="s1">'sys'</span><span class="p">,</span> <span class="s1">'performance_schema'</span><span class="p">,</span> <span class="s1">'information_schema'</span><span class="p">)</span>
  <span class="k">AND</span> <span class="k">NOT</span> <span class="k">EXISTS</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="mi">1</span> <span class="k">FROM</span> <span class="n">information_schema</span><span class="p">.</span><span class="n">TABLE_CONSTRAINTS</span> <span class="k">AS</span> <span class="k">c</span>
    <span class="k">WHERE</span> <span class="k">c</span><span class="p">.</span><span class="n">TABLE_SCHEMA</span> <span class="o">=</span> <span class="n">t</span><span class="p">.</span><span class="n">TABLE_SCHEMA</span> <span class="k">AND</span> <span class="k">c</span><span class="p">.</span><span class="k">TABLE_NAME</span> <span class="o">=</span> <span class="n">t</span><span class="p">.</span><span class="k">TABLE_NAME</span>
      <span class="k">AND</span> <span class="k">c</span><span class="p">.</span><span class="n">CONSTRAINT_TYPE</span> <span class="o">=</span> <span class="s1">'PRIMARY KEY'</span>
  <span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">t</span><span class="p">.</span><span class="n">TABLE_SCHEMA</span><span class="p">,</span> <span class="n">t</span><span class="p">.</span><span class="k">TABLE_NAME</span><span class="p">;</span>
</code></pre></div></div>

<p>후보 테이블은 <code class="language-plaintext highlighter-rouge">SHOW CREATE TABLE</code>과 <code class="language-plaintext highlighter-rouge">SHOW INDEX</code>로 확인합니다. 키를 추가할 때는 소스와 보조 서버의 스키마를 일관되게 유지하고, 기존 데이터의 중복과 DDL 비용도 확인합니다.</p>

<h3 id="읽기-트랜잭션이-막는-ddl과-행-잠금">읽기 트랜잭션이 막는 DDL과 행 잠금</h3>

<p>보조 서버의 긴 읽기 트랜잭션이 메타데이터 잠금(MDL)을 유지하면, 복제로 전달된 <code class="language-plaintext highlighter-rouge">ALTER TABLE</code>이 기다릴 수 있습니다. <code class="language-plaintext highlighter-rouge">read_only</code> 설정만으로 읽기 작업의 이런 영향까지 없어지지는 않습니다. MDL 대기와 InnoDB 행 잠금 대기는 별도로 조회합니다.<sup id="fnref:mdl" role="doc-noteref"><a href="#fn:mdl" class="footnote" rel="footnote">9</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 복제 DDL을 막고 있는 메타데이터 잠금 후보</span>
<span class="k">SELECT</span> <span class="n">object_schema</span><span class="p">,</span> <span class="n">object_name</span><span class="p">,</span> <span class="n">waiting_pid</span><span class="p">,</span> <span class="n">blocking_pid</span><span class="p">,</span> <span class="n">waiting_query</span>
<span class="k">FROM</span> <span class="n">sys</span><span class="p">.</span><span class="n">schema_table_lock_waits</span><span class="p">;</span>

<span class="c1">-- InnoDB 행 잠금 대기 후보</span>
<span class="k">SELECT</span> <span class="n">locked_table</span><span class="p">,</span> <span class="n">waiting_pid</span><span class="p">,</span> <span class="n">blocking_pid</span><span class="p">,</span> <span class="n">waiting_query</span>
<span class="k">FROM</span> <span class="n">sys</span><span class="p">.</span><span class="n">innodb_lock_waits</span><span class="p">;</span>
</code></pre></div></div>

<p>워커 상태의 <code class="language-plaintext highlighter-rouge">Waiting for table metadata lock</code>과 차단 세션을 대조합니다. 장기 조회를 짧게 나누거나 트랜잭션을 적시에 종료하고, DDL 실행 시점을 조정합니다. 세션 종료는 해당 업무와 트랜잭션 영향을 확인한 뒤 결정합니다.</p>

<h2 id="복제-지연에-영향을-주는-파라미터">복제 지연에 영향을 주는 파라미터</h2>

<h3 id="innodb_io_capacity-와-innodb_io_capacity_max">innodb_io_capacity 와 innodb_io_capacity_max</h3>

<p><code class="language-plaintext highlighter-rouge">innodb_io_capacity</code> 는 InnoDB 가 백그라운드 작업, 특히 버퍼 풀의 더티 페이지를 디스크로 플러시하는 작업에 쓸 수 있는 I/O 작업량의 기준입니다. 보조 서버가 변경을 적용하는 속도도 이 값에 달려 있어 복제 지연과 직접 이어집니다.</p>

<p>기본값이 8.0 과 8.4 사이에서 크게 바뀐 파라미터입니다.</p>

<table>
  <thead>
    <tr>
      <th>파라미터</th>
      <th>8.0</th>
      <th>8.4</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">innodb_io_capacity</code></td>
      <td>200</td>
      <td>10000</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">innodb_io_capacity_max</code></td>
      <td>최소 2000</td>
      <td><code class="language-plaintext highlighter-rouge">innodb_io_capacity</code> 의 2배</td>
    </tr>
  </tbody>
</table>

<p>8.4 문서는 기본값 10000 이면 일반적으로 충분하고, <code class="language-plaintext highlighter-rouge">innodb_io_capacity_max</code> 의 기본값인 2배가 대부분의 작업 부하를 겨냥한 값이라고 적습니다. 8.0 에서 쓰던 “기본값 200 에서 시작해 올린다”는 절차를 8.4 에 그대로 옮기면 값을 오히려 내리는 셈이 됩니다.</p>

<p>올리고 내리는 판단 기준은 디스크의 IOPS 정격이 아니라 증상입니다.</p>

<ul>
  <li>체크포인트 때문에 처리량이 주기적으로 떨어지면 <code class="language-plaintext highlighter-rouge">innodb_io_capacity</code> 를 올립니다. 더 자주 플러시해서 밀린 작업이 쌓이지 않게 합니다.</li>
  <li>플러시가 밀리지 않는다면 실용적인 선에서 낮게 유지합니다. <code class="language-plaintext highlighter-rouge">SHOW ENGINE INNODB STATUS</code> 에서 히스토리 리스트 길이가 수천 아래이고, 버퍼 풀의 변경된 페이지 비율이 <code class="language-plaintext highlighter-rouge">innodb_max_dirty_pages_pct</code> 보다 꾸준히 낮고, <code class="language-plaintext highlighter-rouge">Log sequence number - Last checkpoint</code> 가 로그 전체 크기의 7/8 보다 작으면 내릴 여지가 있습니다.</li>
</ul>

<p>값을 정한 뒤에는 Performance Schema 와 모니터링 도구로 복제 지연과 디스크 I/O 사용률을 함께 관측해 확인합니다.</p>

<h3 id="rds-의-스토리지-타입에-따른-iops-상한">RDS 의 스토리지 타입에 따른 IOPS 상한</h3>

<p>이 절은 <strong>RDS for MySQL의 EBS 기반 스토리지</strong>에 관한 설명입니다. Aurora 클러스터 볼륨에 그대로 적용하지 않습니다. IOPS는 초당 I/O 작업 수이고, 처리량은 초당 전송 바이트 수입니다. 실제 I/O 요청 크기와 병합·분할이 영향을 주므로 InnoDB의 기본 페이지 크기 16KiB로 처리량을 나눠 스토리지 IOPS를 단정하면 안 됩니다.<sup id="fnref:storage" role="doc-noteref"><a href="#fn:storage" class="footnote" rel="footnote">10</a></sup></p>

<h4 id="gp2">gp2</h4>

<p>gp2 는 IOPS 를 직접 지정할 수 없습니다. 1 GiB 당 3 IOPS 로 스토리지 크기가 성능을 결정하고, 최소값은 100 IOPS 입니다. MariaDB·MySQL·PostgreSQL 기준입니다.</p>

<table>
  <thead>
    <tr>
      <th>스토리지 크기</th>
      <th>기준 IOPS</th>
      <th>기준 처리량</th>
      <th>버스트 IOPS</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>5–399 GiB</td>
      <td>100–1,197</td>
      <td>128–250 MiB/s</td>
      <td>3,000</td>
    </tr>
    <tr>
      <td>400–1,335 GiB</td>
      <td>1,200–4,005</td>
      <td>512–1,000 MiB/s</td>
      <td>12,000</td>
    </tr>
    <tr>
      <td>1,336–3,999 GiB</td>
      <td>4,008–11,997</td>
      <td>1,000 MiB/s</td>
      <td>12,000</td>
    </tr>
    <tr>
      <td>4,000–65,536 GiB</td>
      <td>12,000–64,000</td>
      <td>1,000 MiB/s</td>
      <td>해당 없음</td>
    </tr>
  </tbody>
</table>

<p>1,000 GiB 미만 볼륨은 I/O 크레딧이 남아 있는 동안 버스트할 수 있습니다. 4,000 GiB 이상에서는 기준 성능이 버스트 성능을 넘어서므로 버스트가 의미를 잃습니다. 400 GiB 이상이면 볼륨 네 개로 스트라이핑되어 기준 처리량과 버스트 IOPS 가 네 배가 됩니다.</p>

<h4 id="gp3">gp3</h4>

<p>gp3 는 크기와 성능을 따로 정합니다. 기준 성능은 3,000 IOPS·125 MiB/s 이고, 400 GiB 를 넘으면 스트라이핑이 적용되어 기준선 자체가 올라갑니다. Db2·MariaDB·MySQL·PostgreSQL 기준입니다.</p>

<table>
  <thead>
    <tr>
      <th>스토리지 크기</th>
      <th>기준 성능</th>
      <th>프로비저닝 IOPS</th>
      <th>프로비저닝 처리량</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>20–399 GiB</td>
      <td>3,000 IOPS · 125 MiB/s</td>
      <td>지정 불가</td>
      <td>지정 불가</td>
    </tr>
    <tr>
      <td>400–65,536 GiB</td>
      <td>12,000 IOPS · 500 MiB/s</td>
      <td>12,000–64,000</td>
      <td>500–4,000 MiB/s</td>
    </tr>
  </tbody>
</table>

<p>추가 성능은 400 GiB 이상에서만 지정할 수 있습니다. MariaDB 와 MySQL 에서 IOPS 를 32,000 위로 올리면 처리량 값이 500 MiB/s 에서 자동으로 함께 올라갑니다. 예를 들어 IOPS 를 40,000 으로 두면 처리량이 최소 625 MiB/s 가 됩니다. 처리량과 IOPS 의 비율은 최대 0.25 입니다.</p>

<p>스토리지와 인스턴스 클래스 양쪽의 IOPS·처리량 한도를 확인합니다. <code class="language-plaintext highlighter-rouge">innodb_io_capacity</code>를 올려도 물리적 용량이 늘어나지는 않습니다. 반대로 현재 관측한 <code class="language-plaintext highlighter-rouge">WriteIOPS</code>가 장치의 최대 성능이라는 뜻도 아니므로, 관측값 자체를 설정의 상한으로 삼지 않습니다.</p>

<blockquote>
  <p><strong>NOTE</strong> — 위 수치는 <a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html">Amazon RDS DB 인스턴스 스토리지</a> 문서 기준입니다. gp2·gp3 는 범용 SSD 이고, 프로비저닝 IOPS 계열인 io1·io2 Block Express 는 상한이 더 높습니다(io2 는 최대 256,000 IOPS). 스토리지 타입을 혼동하면 낼 수 없는 IOPS 를 기대하게 됩니다.</p>
</blockquote>

<h3 id="스토리지-사양을-실제-지표와-연결합니다">스토리지 사양을 실제 지표와 연결합니다</h3>

<p>CloudWatch에서 지연이 발생한 <strong>같은 시간대의 보조 서버 지표</strong>를 함께 봅니다. 아래 해석은 원인을 좁히는 단서이며, 하나의 수치만으로 원인을 확정하지 않습니다.<sup id="fnref:rds-metrics" role="doc-noteref"><a href="#fn:rds-metrics" class="footnote" rel="footnote">11</a></sup></p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>확인할 내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ReplicaLag</code></td>
      <td>RDS for MySQL에서는 복제 상태의 지연 값에 기반합니다. <code class="language-plaintext highlighter-rouge">-1</code>은 지연 0초가 아니라 복제가 활성 상태가 아니며 지연 값이 <code class="language-plaintext highlighter-rouge">NULL</code>인 경우입니다.<sup id="fnref:rds-replication" role="doc-noteref"><a href="#fn:rds-replication" class="footnote" rel="footnote">12</a></sup></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ReadLatency</code>, <code class="language-plaintext highlighter-rouge">WriteLatency</code></td>
      <td>I/O 작업당 지연입니다. CloudWatch 단위는 초이므로 대시보드의 밀리초 표시와 혼동하지 않습니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ReadIOPS</code>, <code class="language-plaintext highlighter-rouge">WriteIOPS</code> + <code class="language-plaintext highlighter-rouge">ReadThroughput</code>, <code class="language-plaintext highlighter-rouge">WriteThroughput</code></td>
      <td>IOPS 한도와 처리량 한도 중 어느 쪽에 가까운지 함께 봅니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DiskQueueDepth</code></td>
      <td>완료되지 않은 I/O가 누적되는지 확인합니다. 큐 증가와 지연·처리량을 같이 봅니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BurstBalance</code></td>
      <td>gp2의 스토리지 버스트 크레딧입니다. 소진 시 버스트 성능을 계속 낼 수 없습니다. gp3에는 같은 방식으로 적용하지 않습니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">EBSIOBalance%</code>, <code class="language-plaintext highlighter-rouge">EBSByteBalance%</code></td>
      <td>해당 지표를 제공하는 인스턴스에서 EBS I/O·처리량 크레딧을 확인합니다. 볼륨 크레딧과 구분합니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CPUUtilization</code>, <code class="language-plaintext highlighter-rouge">FreeableMemory</code>, <code class="language-plaintext highlighter-rouge">SwapUsage</code></td>
      <td>CPU 포화나 메모리 압박을 스토리지 병목으로 오인하지 않도록 함께 봅니다.</td>
    </tr>
  </tbody>
</table>

<p>예를 들어 gp2의 <code class="language-plaintext highlighter-rouge">BurstBalance</code>가 소진되고 I/O 지연·큐 길이가 함께 늘어난다면 버스트 의존도를 줄이는 방향을 검토합니다. 반대로 스토리지 지표가 여유로운데 한 워커만 오래 실행 중이라면 트랜잭션 크기와 적용 작업부터 확인합니다.</p>

<h3 id="replica_parallel_workers">replica_parallel_workers</h3>

<p>8.4 문서가 쓰는 이름은 <code class="language-plaintext highlighter-rouge">replica_parallel_workers</code>이고 기본값은 <strong>4</strong>입니다. <code class="language-plaintext highlighter-rouge">slave_parallel_workers</code>는 옛 이름입니다. 값 0은 단일 적용 스레드 방식이고 폐기 예정(deprecated)이므로, 워커를 하나만 사용할 새 설정에는 1을 검토합니다.<sup id="fnref:replica-options" role="doc-noteref"><a href="#fn:replica-options" class="footnote" rel="footnote">13</a></sup></p>

<p>값이 0 이면 보조 서버는 적용 스레드 하나로 릴레이 로그를 읽어 트랜잭션을 실행합니다. 1 이상이면 워커 스레드가 그 수만큼 생기고, 릴레이 로그에서 트랜잭션을 순서대로 읽어 워커에 배분하는 코디네이터 스레드가 하나 더 붙습니다. 이 상태를 멀티스레드 복제라고 부릅니다. 복제 채널을 여러 개 쓰면 채널마다 이 수만큼 스레드가 생깁니다.</p>

<p>워커를 늘리는 효과는 <strong>독립적으로 적용할 트랜잭션과 자원 여유가 있을 때</strong> 나옵니다. 워커 수와 CPU 사용량이 일정 비율로 늘어나는 것은 아닙니다. 대형 트랜잭션, 같은 데이터에 대한 의존성, 잠금·I/O 대기는 워커 증가만으로 해소되지 않습니다.</p>

<p>특히 <code class="language-plaintext highlighter-rouge">Waiting for preceding transaction to commit</code>은 앞선 트랜잭션의 커밋을 기다린다는 뜻입니다. 기다리는 워커를 더 늘리기 전에 선행 작업이 무엇을 하는지 확인합니다. <code class="language-plaintext highlighter-rouge">replica_preserve_commit_order</code>는 8.4에서 기본 <code class="language-plaintext highlighter-rouge">ON</code>이며, 대기를 없애려고 끄면 보조 서버에서 관측하는 커밋 순서가 달라질 수 있습니다.<sup id="fnref:thread-states:1" role="doc-noteref"><a href="#fn:thread-states" class="footnote" rel="footnote">6</a></sup><sup id="fnref:replica-options:1" role="doc-noteref"><a href="#fn:replica-options" class="footnote" rel="footnote">13</a></sup></p>

<p>현재 설정과 실제 활성 워커 수를 구분해서 확인합니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">@@</span><span class="k">GLOBAL</span><span class="p">.</span><span class="n">replica_parallel_workers</span><span class="p">,</span>
       <span class="o">@@</span><span class="k">GLOBAL</span><span class="p">.</span><span class="n">replica_preserve_commit_order</span><span class="p">;</span>

<span class="k">SELECT</span> <span class="n">CHANNEL_NAME</span><span class="p">,</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">active_workers</span>
<span class="k">FROM</span> <span class="n">performance_schema</span><span class="p">.</span><span class="n">replication_applier_status_by_worker</span>
<span class="k">WHERE</span> <span class="n">SERVICE_STATE</span> <span class="o">=</span> <span class="s1">'ON'</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">CHANNEL_NAME</span><span class="p">;</span>
</code></pre></div></div>

<p>변수 변경은 실행 중인 워커 수에 즉시 반영되지 않고 <strong>다음 <code class="language-plaintext highlighter-rouge">START REPLICA</code>에 적용</strong>됩니다. 아래는 직접 운영하는 MySQL의 기본 채널에서 적용 스레드를 멈추고 워커를 8개로 바꾸는 예제입니다. 8은 실험값이며 권장 고정값이 아닙니다. 중지 중에는 적용 지연이 늘어나므로 읽기 트래픽과 변경 시점을 먼저 조정합니다.<sup id="fnref:replica-options:2" role="doc-noteref"><a href="#fn:replica-options" class="footnote" rel="footnote">13</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">STOP</span> <span class="n">REPLICA</span> <span class="n">SQL_THREAD</span> <span class="k">FOR</span> <span class="n">CHANNEL</span> <span class="s1">''</span><span class="p">;</span>
<span class="k">SET</span> <span class="k">GLOBAL</span> <span class="n">replica_parallel_workers</span> <span class="o">=</span> <span class="mi">8</span><span class="p">;</span>
<span class="k">START</span> <span class="n">REPLICA</span> <span class="n">SQL_THREAD</span> <span class="k">FOR</span> <span class="n">CHANNEL</span> <span class="s1">''</span><span class="p">;</span>
</code></pre></div></div>

<p>명명된 채널에서는 빈 문자열 대신 실제 채널명을 사용합니다. 재시작 후 활성 워커 수·오류·지연 변화·CPU·I/O를 다시 확인합니다. <code class="language-plaintext highlighter-rouge">SET GLOBAL</code>은 서버 재시작 후 유지되지 않으므로 검증한 값만 설정 파일이나 적절한 영속 설정에 반영합니다. RDS·Aurora에서는 파라미터 그룹과 해당 서비스가 제공하는 복제 제어 절차를 따릅니다.</p>

<p>병렬 적용 관련 변수도 <strong>8.4에서의 상태</strong>를 기준으로 확인합니다.</p>

<table>
  <thead>
    <tr>
      <th>변수</th>
      <th>8.4에서의 상태</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">binlog_transaction_dependency_tracking</code></td>
      <td>8.2.0 deprecated, 8.4.0 제거</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">transaction_write_set_extraction</code></td>
      <td>8.3.0 제거</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">replica_parallel_type</code></td>
      <td>존재하지만 deprecated, 기본 <code class="language-plaintext highlighter-rouge">LOGICAL_CLOCK</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">binlog_transaction_dependency_tracking</code>이 사라진 뒤에도 writeset 기반 의존성 추적 동작은 남아 있습니다. 8.4에서 제거된 앞의 두 변수는 옛 옵션 파일에서 정리해야 합니다. 9.x의 변수 제거·기본값 변경을 8.4에 그대로 적용하지 않습니다.<sup id="fnref:upgrade" role="doc-noteref"><a href="#fn:upgrade" class="footnote" rel="footnote">14</a></sup></p>

<h3 id="그-밖에-복제-지연에-영향을-줄-수-있는-파라미터">그 밖에 복제 지연에 영향을 줄 수 있는 파라미터</h3>

<h4 id="sync_binlog-와-innodb_flush_log_at_trx_commit">sync_binlog 와 innodb_flush_log_at_trx_commit</h4>

<p>두 값 모두 기본값이 1 이고, 이 조합이 가장 안전하면서 가장 느립니다. 공식 문서는 <code class="language-plaintext highlighter-rouge">sync_binlog</code> 를 두고 가장 안전한 값은 기본값인 1 이지만 동시에 가장 느리다고 적습니다. <code class="language-plaintext highlighter-rouge">sync_binlog</code> 를 1 보다 큰 N 으로 두면 커밋 그룹 N 개마다 디스크와 동기화합니다.</p>

<p>보조 서버에서 이 조합을 내리면 적용 커밋이 빨라질 수 있지만, 이것은 장애 시점의 유실 범위를 성능과 바꾸는 거래입니다. 감당할 유실 범위를 먼저 정한 다음 값을 고릅니다. Aurora MySQL 버전 3 에서는 <code class="language-plaintext highlighter-rouge">innodb_flush_log_at_trx_commit</code> 을 1 이 아닌 값으로 바꾸려면 <code class="language-plaintext highlighter-rouge">innodb_trx_commit_allow_data_loss</code> 를 먼저 1 로 설정해야 하고, 그것은 데이터 유실 위험을 인정한다는 뜻입니다.</p>

<h4 id="log_replica_updates">log_replica_updates</h4>

<p>보조 서버가 적용한 복제 이벤트를 자신의 바이너리 로그에도 남길지 결정합니다. 옛 이름은 <code class="language-plaintext highlighter-rouge">log_slave_updates</code> 입니다. 8.4 에서는 바이너리 로깅이 기본으로 켜져 있고, 그 경우 복제 갱신 로깅도 기본 동작입니다. 보조 서버를 다시 소스로 쓰는 다단 구성에서는 켜 두어야 합니다. 반대로 이 서버가 소스 역할을 하지 않고 장애 때 소스를 세울 다른 방법이 있다면, 문서는 이 값을 끄는 선택을 제시합니다.</p>

<h4 id="innodb_buffer_pool_size">innodb_buffer_pool_size</h4>

<p>적용할 페이지가 버퍼 풀에 없으면 적용 스레드가 매번 디스크에서 읽어야 하므로 지연이 커집니다. 기본값은 128MB 이고 <code class="language-plaintext highlighter-rouge">SET GLOBAL</code> 로 재시작 없이 늘릴 수 있습니다. 단 값은 <code class="language-plaintext highlighter-rouge">innodb_buffer_pool_chunk_size</code> 와 <code class="language-plaintext highlighter-rouge">innodb_buffer_pool_instances</code> 의 곱의 배수여야 하며, 맞지 않으면 서버가 자동으로 올려 맞춥니다. 전용 서버라면 <code class="language-plaintext highlighter-rouge">innodb_dedicated_server</code> 로 자동 산정하게 하는 방법도 있습니다.</p>

<h4 id="innodb_flush_method">innodb_flush_method</h4>

<p>InnoDB 가 데이터와 로그를 디스크에 쓸 때 쓰는 방식입니다. 8.4 의 리눅스 기본값은 지원되면 <code class="language-plaintext highlighter-rouge">O_DIRECT</code>, 아니면 <code class="language-plaintext highlighter-rouge">fsync</code> 입니다(8.0 은 <code class="language-plaintext highlighter-rouge">fsync</code>). 동적 변경이 불가하므로 초기 구축 때 정해야 합니다. 8.4 부터는 <code class="language-plaintext highlighter-rouge">--innodb-dedicated-server</code> 가 이 값을 자동으로 설정하지 않습니다.</p>

<h4 id="relay_log_space_limit">relay_log_space_limit</h4>

<p>릴레이 로그 전체가 쓸 수 있는 최대 용량입니다. 기본값은 0 이고 제한이 없다는 뜻입니다. 값을 준다면 <code class="language-plaintext highlighter-rouge">max_relay_log_size</code>(이 값이 0 이면 <code class="language-plaintext highlighter-rouge">max_binlog_size</code>)의 2배보다 작게 잡지 말라고 문서가 적습니다.</p>

<h4 id="innodb_thread_concurrency">innodb_thread_concurrency</h4>

<p>InnoDB 안에서 동시에 실행되는 스레드 수를 제한합니다. 기본값은 0 이고 제한이 없다는 뜻입니다. 문서는 대부분의 작업 부하가 제한 없이도 잘 돌아간다고 적고, 제한을 걸기 전에 멀티코어 성능에 영향을 주는 다른 설정을 먼저 보라고 권합니다. 복제 지연을 이 값으로 푸는 경우는 드뭅니다.</p>

<h4 id="리두-로그-용량">리두 로그 용량</h4>

<p><code class="language-plaintext highlighter-rouge">innodb_log_file_size</code> 와 <code class="language-plaintext highlighter-rouge">innodb_log_files_in_group</code> 은 8.0.30 에서 deprecated 됐습니다. 지금은 <code class="language-plaintext highlighter-rouge">innodb_redo_log_capacity</code> 로 정하고 기본값은 100MB 입니다. <code class="language-plaintext highlighter-rouge">innodb_redo_log_capacity</code> 가 정의되지 않은 상태에서 옛 두 변수만 남아 있으면 리두 용량이 두 값의 곱으로 계산되므로, 예전 옵션 파일을 그대로 옮겨 오면 의도하지 않은 용량이 잡힙니다.</p>

<h4 id="innodb_flush_neighbors">innodb_flush_neighbors</h4>

<p>플러시할 때 인접한 페이지를 함께 플러시할지 결정합니다. 회전형 디스크를 위한 최적화이고 기본값은 비활성입니다. 비회전형 스토리지이거나 회전형과 섞여 있는 구성에서는 끄라고 문서가 적습니다.</p>

<h2 id="쓰기-직후-읽기는-애플리케이션에서도-처리합니다">쓰기 직후 읽기는 애플리케이션에서도 처리합니다</h2>

<p>저장 직후 읽기 복제본을 조회하면 “저장했는데 결과가 없다”는 문제가 생길 수 있습니다. 비동기 복제의 지연을 줄이는 작업과, 해당 요청이 방금 쓴 데이터를 읽게 보장하는 작업은 구분해야 합니다.</p>

<p>가장 단순한 방법은 최신성이 필요한 쓰기 직후 조회를 <strong>소스의 새 조회로 라우팅</strong>하는 것입니다. 일정 시간 <code class="language-plaintext highlighter-rouge">sleep</code>하거나 특정 시간 동안만 소스로 보내는 규칙은 최악의 복제 지연까지 보장하지 않습니다.</p>

<p>GTID 기반 binlog 복제에서는 해당 쓰기의 GTID가 보조 서버에 적용될 때까지 기다리는 방법도 있습니다. 다음 순서로 처리합니다.<sup id="fnref:gtid:1" role="doc-noteref"><a href="#fn:gtid" class="footnote" rel="footnote">4</a></sup><sup id="fnref:gtid-tracking" role="doc-noteref"><a href="#fn:gtid-tracking" class="footnote" rel="footnote">15</a></sup></p>

<ol>
  <li>소스에서 쓰기를 커밋하고, <strong>그 커밋의 GTID</strong>를 얻습니다. 지원하는 드라이버라면 쓰기 세션의 <code class="language-plaintext highlighter-rouge">session_track_gtids=OWN_GTID</code>와 프로토콜의 세션 추적 정보를 사용합니다.</li>
  <li>조회할 보조 서버의 연결을 확보하고 <code class="language-plaintext highlighter-rouge">WAIT_FOR_EXECUTED_GTID_SET()</code>을 제한 시간과 함께 실행합니다.</li>
  <li>반환값이 <code class="language-plaintext highlighter-rouge">0</code>일 때 새 스냅샷으로 조회합니다. <code class="language-plaintext highlighter-rouge">1</code>은 타임아웃이며, 오류나 <code class="language-plaintext highlighter-rouge">NULL</code>도 성공으로 처리하지 않습니다.</li>
  <li>타임아웃이면 정책에 따라 소스로 조회를 보내거나 재시도 가능한 응답을 반환합니다.</li>
</ol>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- UUID:번호는 예시입니다. 실제로 커밋한 트랜잭션의 GTID로 바꿉니다.</span>
<span class="k">SELECT</span> <span class="n">WAIT_FOR_EXECUTED_GTID_SET</span><span class="p">(</span>
  <span class="s1">'3E11FA47-71CA-11E1-9E33-C80AA9429562:23'</span><span class="p">,</span> <span class="mi">1</span>
<span class="p">)</span> <span class="k">AS</span> <span class="n">wait_result</span><span class="p">;</span>
<span class="c1">-- 0: 적용 완료, 1: 1초 안에 적용되지 않음</span>
</code></pre></div></div>

<p>대기는 <strong>조회할 바로 그 보조 서버 연결</strong>에서 실행합니다. 커넥션 풀이나 로드밸런서가 대기 후 다른 복제본으로 조회를 보내면 보장이 깨집니다. <code class="language-plaintext highlighter-rouge">REPEATABLE READ</code>에서 이미 만든 오래된 스냅샷을 재사용하지 않도록, 대기를 마친 뒤 새 읽기 트랜잭션을 시작합니다.</p>

<p>소스의 전체 <code class="language-plaintext highlighter-rouge">@@GLOBAL.gtid_executed</code>를 매 요청의 토큰으로 사용하면 관련 없는 커밋까지 기다릴 수 있습니다. 또한 GTID 적용 완료는 복제 필터로 제외한 데이터까지 존재한다는 보장이 아니므로, 읽을 데이터가 실제로 복제되는 구성에서 사용합니다. 같은 Aurora 클러스터의 내부 Reader에 이 binlog용 절차를 그대로 적용하지 않습니다.</p>

<h2 id="정리">정리</h2>

<p>복제 지연을 만나면 <strong>복제 구조 확인 → 중단·오류 확인 → 수신·적용 구간 분리 → 트랜잭션·잠금·자원 확인 → 설정 변경과 재측정</strong> 순서로 접근합니다. 지연이 낮아진 것뿐 아니라 오류가 없는지, 밀린 작업이 줄어드는지, 읽기 서비스가 필요한 최신성을 만족하는지 함께 확인합니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:aurora-replication" role="doc-endnote">
      <p>Amazon Aurora User Guide — Replication with Amazon Aurora. 내부 Reader와 binlog 복제의 구조. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Replication.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Replication.html</a> <a href="#fnref:aurora-replication" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:aurora-metrics" role="doc-endnote">
      <p>Amazon Aurora User Guide — Amazon CloudWatch metrics for Amazon Aurora. AuroraReplicaLag와 AuroraBinlogReplicaLag. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.AuroraMonitoring.Metrics.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.AuroraMonitoring.Metrics.html</a> <a href="#fnref:aurora-metrics" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:status" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — SHOW REPLICA STATUS Statement. 스레드 상태·오류·복제 위치·지연 지표의 의미. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/show-replica-status.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/show-replica-status.html</a> <a href="#fnref:status" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:status:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:gtid" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Functions Used with Global Transaction Identifiers (GTIDs). 집합 연산과 적용 대기 함수. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/gtid-functions.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/gtid-functions.html</a> <a href="#fnref:gtid" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:gtid:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:worker" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — The replication_applier_status_by_worker Table. 워커별 트랜잭션·시각·재시도·오류. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/performance-schema-replication-applier-status-by-worker-table.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/performance-schema-replication-applier-status-by-worker-table.html</a> <a href="#fnref:worker" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:thread-states" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Replication SQL Thread States. 커밋 순서 대기 등 적용 스레드 상태. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/replica-sql-thread-states.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/replica-sql-thread-states.html</a> <a href="#fnref:thread-states" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:thread-states:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:aurora-binlog" role="doc-endnote">
      <p>Amazon Aurora User Guide — Troubleshooting replication lag for Aurora MySQL. 대형 트랜잭션·병렬 적용·키와 적용 지연. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-mysql-troubleshooting-replication-lag.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-mysql-troubleshooting-replication-lag.html</a> <a href="#fnref:aurora-binlog" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:row-search" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Replication and Row Searches. ROW 복제에서 변경할 행을 찾는 방법. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/replication-features-row-searches.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/replication-features-row-searches.html</a> <a href="#fnref:row-search" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:mdl" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — The schema_table_lock_waits and x$schema_table_lock_waits Views. MDL 대기와 차단 세션. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/sys-schema-table-lock-waits.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/sys-schema-table-lock-waits.html</a> <a href="#fnref:mdl" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:storage" role="doc-endnote">
      <p>Amazon RDS User Guide — Amazon RDS DB instance storage. EBS 스토리지의 IOPS·처리량·버스트와 I/O 크기. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html">https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html</a> <a href="#fnref:storage" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:rds-metrics" role="doc-endnote">
      <p>Amazon RDS User Guide — Amazon CloudWatch metrics for Amazon RDS. 지연·I/O·크레딧·메모리 지표. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-metrics.html">https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-metrics.html</a> <a href="#fnref:rds-metrics" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:rds-replication" role="doc-endnote">
      <p>Amazon RDS User Guide — Monitoring read replication. ReplicaLag와 복제 비활성 상태. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReadRepl.Monitoring.html">https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReadRepl.Monitoring.html</a> <a href="#fnref:rds-replication" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:replica-options" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Replica Server Options and Variables. 워커 수·변경 적용 시점·커밋 순서. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/replication-options-replica.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/replication-options-replica.html</a> <a href="#fnref:replica-options" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:replica-options:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:replica-options:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:upgrade" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — What Is New in MySQL 8.4 since MySQL 8.0. 복제 의존성 추적 변수의 변경. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/mysql-nutshell.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/mysql-nutshell.html</a> <a href="#fnref:upgrade" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:gtid-tracking" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Server System Variables, session_track_gtids. 커밋한 트랜잭션의 GTID 추적. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/server-system-variables.html#sysvar_session_track_gtids">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/server-system-variables.html#sysvar_session_track_gtids</a> <a href="#fnref:gtid-tracking" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="mysql" /><summary type="html"><![CDATA[MySQL 8.4의 복제 중단·수신 지연·적용 지연을 구분하고, 대형 트랜잭션·잠금·병렬 복제·스토리지 병목과 쓰기 직후 읽기 문제를 진단합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">MySQL 전문 검색</title><link href="https://rastalion.dev/writing/mysql-fulltext-search/" rel="alternate" type="text/html" title="MySQL 전문 검색" /><published>2025-05-23T02:08:25+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/mysql-fulltext-search</id><content type="html" xml:base="https://rastalion.dev/writing/mysql-fulltext-search/"><![CDATA[<p>MySQL의 FULLTEXT는 텍스트를 토큰으로 나누고, 토큰이 등장하는 문서와 위치를 역색인(inverted index)에 저장하는 전문 검색 기능입니다. 검색어와 문서에 등장하는 토큰을 바탕으로 관련성 점수를 계산합니다. <strong>문장의 의미를 이해하는 검색이나 임베딩 기반 의미 검색은 아닙니다.</strong><sup id="fnref:overview" role="doc-noteref"><a href="#fn:overview" class="footnote" rel="footnote">1</a></sup><sup id="fnref:innodb" role="doc-noteref"><a href="#fn:innodb" class="footnote" rel="footnote">2</a></sup></p>

<p>이 글은 <strong>MySQL 8.4 LTS·InnoDB</strong>를 기준으로 설명합니다. SQL 예제는 MySQL Community Server 8.4.11, <code class="language-plaintext highlighter-rouge">ngram_token_size=2</code>, 기본 불용어 활성 상태에서 확인했습니다. MyISAM과 다른 동작은 별도로 표시합니다.</p>

<h2 id="like와-fulltext-중-무엇을-써야-할까요">LIKE와 FULLTEXT 중 무엇을 써야 할까요</h2>

<p>먼저 필요한 검색의 의미를 정합니다.</p>

<table>
  <thead>
    <tr>
      <th>요구사항</th>
      <th>검토할 방법</th>
      <th>주의할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>문자열 전체가 같은 값</td>
      <td><code class="language-plaintext highlighter-rouge">=</code>와 일반 인덱스</td>
      <td>컬럼 콜레이션이 동일성 기준을 결정합니다.</td>
    </tr>
    <tr>
      <td>특정 문자열로 시작</td>
      <td><code class="language-plaintext highlighter-rouge">LIKE '검색어%'</code></td>
      <td>선행 와일드카드가 없는 상수 패턴은 B-Tree 범위 검색을 사용할 수 있습니다.</td>
    </tr>
    <tr>
      <td>문자열 중간의 임의 부분이 포함됨</td>
      <td><code class="language-plaintext highlighter-rouge">LIKE '%검색어%'</code></td>
      <td>일반적으로 B-Tree의 접두 범위 검색을 사용할 수 없습니다.</td>
    </tr>
    <tr>
      <td>키워드 관련도·필수 포함·제외 조건</td>
      <td><code class="language-plaintext highlighter-rouge">MATCH() AGAINST()</code>와 FULLTEXT</td>
      <td>토큰화·불용어·검색 모드에 따라 결과가 달라집니다.</td>
    </tr>
    <tr>
      <td>한국어의 연속된 문자 조각 검색</td>
      <td>ngram FULLTEXT</td>
      <td>형태소 분석이나 임의 부분 문자열 검색과 결과가 같지는 않습니다.</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">LIKE</code>가 항상 느리고 FULLTEXT가 항상 빠른 것은 아닙니다. 조회 범위가 작은 경우에는 단순한 패턴 검색으로 충분할 수 있습니다. 필요한 결과를 먼저 정하고 실제 조건의 실행계획과 처리 시간을 비교합니다.<sup id="fnref:range" role="doc-noteref"><a href="#fn:range" class="footnote" rel="footnote">3</a></sup></p>

<p>한국어에도 띄어쓰기가 있습니다. 기본 파서가 공백으로 나눈 어절을 검색하는 데는 사용할 수 있지만, 조사·어미가 붙은 어절 내부를 찾거나 띄어쓰기 차이를 처리하는 데 한계가 있습니다. ngram은 이를 일정 길이의 문자 조각으로 나누며, <strong>조사·어간을 이해하는 한국어 형태소 분석기는 아닙니다.</strong><sup id="fnref:ngram" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup></p>

<h2 id="재현용-테이블과-데이터">재현용 테이블과 데이터</h2>

<p><code class="language-plaintext highlighter-rouge">mysql</code> CLI에서 실습용 데이터베이스를 선택하고 아래 쿼리를 같은 연결에서 실행합니다. 첫 예제는 인덱스를 포함해 테이블을 생성하므로, 이어서 같은 이름의 인덱스를 다시 <code class="language-plaintext highlighter-rouge">ADD</code>하면 안 됩니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="k">NAMES</span> <span class="n">utf8mb4</span><span class="p">;</span>

<span class="k">SELECT</span> <span class="k">VERSION</span><span class="p">(),</span> <span class="o">@@</span><span class="n">ngram_token_size</span><span class="p">,</span>
       <span class="o">@@</span><span class="n">innodb_ft_min_token_size</span><span class="p">,</span> <span class="o">@@</span><span class="n">innodb_ft_enable_stopword</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">article</span> <span class="p">(</span>
  <span class="n">article_id</span> <span class="nb">INT</span> <span class="nb">UNSIGNED</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="n">AUTO_INCREMENT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
  <span class="n">title</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">body</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">FULLTEXT</span> <span class="k">KEY</span> <span class="n">fts_article_title_body</span> <span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="k">WITH</span> <span class="n">PARSER</span> <span class="n">ngram</span>
<span class="p">)</span> <span class="n">ENGINE</span><span class="o">=</span><span class="n">InnoDB</span> <span class="k">DEFAULT</span> <span class="n">CHARSET</span><span class="o">=</span><span class="n">utf8mb4</span> <span class="k">COLLATE</span><span class="o">=</span><span class="n">utf8mb4_0900_ai_ci</span><span class="p">;</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">article</span> <span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="k">VALUES</span>
<span class="p">(</span><span class="s1">'데이터베이스 성능'</span><span class="p">,</span> <span class="s1">'인덱스와 쿼리 실행 계획을 점검합니다.'</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'데이터 분석'</span><span class="p">,</span> <span class="s1">'집계와 시각화를 다룹니다.'</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'로그 보관'</span><span class="p">,</span> <span class="s1">'베이스 설정과 보관 주기를 점검합니다.'</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'검색 기능'</span><span class="p">,</span> <span class="s1">'검색 파서와 토큰을 설명합니다.'</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'서울 여행'</span><span class="p">,</span> <span class="s1">'경복궁을 방문합니다.'</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'트랜잭션 잠금'</span><span class="p">,</span> <span class="s1">'잠금과 커밋을 설명합니다.'</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'금'</span><span class="p">,</span> <span class="s1">''</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">WITH PARSER ngram</code>을 생략하면 기본 파서를 사용합니다. FULLTEXT 인덱스는 <code class="language-plaintext highlighter-rouge">CHAR</code>, <code class="language-plaintext highlighter-rouge">VARCHAR</code>, <code class="language-plaintext highlighter-rouge">TEXT</code> 계열 컬럼에 만들 수 있으며, 같은 FULLTEXT 인덱스에 포함된 컬럼들은 문자셋과 콜레이션이 같아야 합니다.<sup id="fnref:overview:1" role="doc-noteref"><a href="#fn:overview" class="footnote" rel="footnote">1</a></sup><sup id="fnref:restrictions" role="doc-noteref"><a href="#fn:restrictions" class="footnote" rel="footnote">5</a></sup></p>

<h2 id="ngram의-자연어-모드와-불리언-모드는-결과가-다릅니다">ngram의 자연어 모드와 불리언 모드는 결과가 다릅니다</h2>

<p><code class="language-plaintext highlighter-rouge">ngram_token_size=2</code>에서 <code class="language-plaintext highlighter-rouge">데이터베이스</code>는 다음 다섯 토큰으로 나뉩니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>데이 / 이터 / 터베 / 베이 / 이스
</code></pre></div></div>

<h3 id="natural-language-mode">NATURAL LANGUAGE MODE</h3>

<p>모드를 생략하면 자연어 모드입니다. ngram 파서는 검색어를 토큰들의 합집합으로 검색하므로, 긴 검색어의 <strong>일부 토큰만 가진 문서도 결과에 포함</strong>됩니다.<sup id="fnref:ngram:1" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">article_id</span><span class="p">,</span> <span class="n">title</span><span class="p">,</span>
       <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'데이터베이스'</span> <span class="k">IN</span> <span class="k">NATURAL</span> <span class="k">LANGUAGE</span> <span class="k">MODE</span><span class="p">)</span> <span class="k">AS</span> <span class="n">score</span>
<span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'데이터베이스'</span> <span class="k">IN</span> <span class="k">NATURAL</span> <span class="k">LANGUAGE</span> <span class="k">MODE</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">score</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">article_id</span> <span class="k">ASC</span><span class="p">;</span>
</code></pre></div></div>

<p>결과에는 1·2·3번이 포함됩니다.</p>

<table>
  <thead>
    <tr>
      <th>article_id</th>
      <th>포함되는 이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">데이터베이스</code>의 토큰을 모두 포함합니다.</td>
    </tr>
    <tr>
      <td>2</td>
      <td><code class="language-plaintext highlighter-rouge">데이터</code>의 <code class="language-plaintext highlighter-rouge">데이</code>, <code class="language-plaintext highlighter-rouge">이터</code> 토큰을 포함합니다.</td>
    </tr>
    <tr>
      <td>3</td>
      <td>본문의 <code class="language-plaintext highlighter-rouge">베이스</code>에 <code class="language-plaintext highlighter-rouge">베이</code>, <code class="language-plaintext highlighter-rouge">이스</code> 토큰이 있습니다.</td>
    </tr>
  </tbody>
</table>

<p>따라서 <code class="language-plaintext highlighter-rouge">%데이터베이스%</code>를 이 자연어 검색으로 교체하면 검색 범위가 넓어집니다. 두 방식의 결과가 같다고 가정하면 안 됩니다.</p>

<h3 id="boolean-mode">BOOLEAN MODE</h3>

<p>ngram의 불리언 모드는 검색어 하나를 연속된 ngram 구문 검색으로 변환합니다. 아래 예제에서는 1번만 검색됩니다.<sup id="fnref:ngram:2" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">article_id</span><span class="p">,</span> <span class="n">title</span>
<span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'데이터베이스'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">article_id</span><span class="p">;</span>
</code></pre></div></div>

<p>여러 검색어에 필수 포함·제외 조건을 지정할 수도 있습니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">article_id</span><span class="p">,</span> <span class="n">title</span><span class="p">,</span>
       <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'+데이터 -분석'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">)</span> <span class="k">AS</span> <span class="n">score</span>
<span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'+데이터 -분석'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">score</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">article_id</span> <span class="k">ASC</span><span class="p">;</span>
<span class="c1">-- 1번: 데이터는 포함하고 분석은 제외합니다.</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>연산자</th>
      <th>의미</th>
      <th>주의할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+검색어</code></td>
      <td>반드시 포함</td>
      <td>InnoDB에서는 단어 앞에 붙입니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-검색어</code></td>
      <td>검색 결과에서 제외</td>
      <td>제외 조건만으로는 전체 문서의 여집합을 반환하지 않습니다.</td>
    </tr>
    <tr>
      <td>연산자 없는 검색어</td>
      <td>선택적 검색어</td>
      <td>여러 항을 모두 필수 포함으로 만들려면 각 항에 <code class="language-plaintext highlighter-rouge">+</code>를 붙입니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"구문 검색"</code></td>
      <td>토큰의 순서·구문 일치</td>
      <td>원문 바이트까지 동일하다는 뜻은 아닙니다.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">검색어*</code></td>
      <td>접두 토큰 검색</td>
      <td>ngram에서는 일반 단어 파서와 의미가 다릅니다.</td>
    </tr>
  </tbody>
</table>

<p>구문 검색도 토큰화·불용어·콜레이션의 영향을 받습니다. 원문의 공백·구두점까지 보존한 정확한 부분 문자열 일치가 필요하면 해당 조건을 별도로 검증해야 합니다.<sup id="fnref:boolean" role="doc-noteref"><a href="#fn:boolean" class="footnote" rel="footnote">6</a></sup><sup id="fnref:natural" role="doc-noteref"><a href="#fn:natural" class="footnote" rel="footnote">7</a></sup></p>

<h3 id="관련성-점수와-정렬">관련성 점수와 정렬</h3>

<p>InnoDB의 관련성 계산은 BM25·TF-IDF 기반 알고리즘을 사용합니다. 엔진과 검색 대상 문서 집합에 따라 점수가 달라지므로, 점수를 고정된 “정답 확률”처럼 해석하지 않습니다.<sup id="fnref:boolean:1" role="doc-noteref"><a href="#fn:boolean" class="footnote" rel="footnote">6</a></sup></p>

<p>자연어 모드는 특정 실행계획 조건에서 관련도순으로 반환하지만, 불리언 모드는 자동으로 관련도순 정렬하지 않습니다. 화면의 정렬 계약은 예제처럼 <code class="language-plaintext highlighter-rouge">ORDER BY score DESC, article_id ASC</code>로 명시하는 편이 분명합니다. 같은 점수에 대한 보조 정렬도 있어야 순서가 안정적입니다.<sup id="fnref:natural:1" role="doc-noteref"><a href="#fn:natural" class="footnote" rel="footnote">7</a></sup><sup id="fnref:boolean:2" role="doc-noteref"><a href="#fn:boolean" class="footnote" rel="footnote">6</a></sup></p>

<h2 id="50-임계값은-myisam의-자연어-검색에-해당합니다">50% 임계값은 MyISAM의 자연어 검색에 해당합니다</h2>

<p><strong>InnoDB 자연어 검색에는 MyISAM의 50% 임계값이 적용되지 않습니다.</strong> 테스트 데이터가 적다는 이유로 InnoDB 검색을 무조건 불리언 모드로 바꿀 필요는 없습니다.<sup id="fnref:natural:2" role="doc-noteref"><a href="#fn:natural" class="footnote" rel="footnote">7</a></sup></p>

<p>기본 파서로 만든 다음 InnoDB 테이블에서는 모든 행에 <code class="language-plaintext highlighter-rouge">database</code>가 있어도 두 행이 검색됩니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">word_demo</span> <span class="p">(</span>
  <span class="n">id</span> <span class="nb">INT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
  <span class="n">body</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">FULLTEXT</span> <span class="k">KEY</span> <span class="n">fts_word_demo_body</span> <span class="p">(</span><span class="n">body</span><span class="p">)</span>
<span class="p">)</span> <span class="n">ENGINE</span><span class="o">=</span><span class="n">InnoDB</span> <span class="k">DEFAULT</span> <span class="n">CHARSET</span><span class="o">=</span><span class="n">utf8mb4</span><span class="p">;</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">word_demo</span> <span class="k">VALUES</span>
<span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'database indexing'</span><span class="p">),</span>
<span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'database tuning'</span><span class="p">);</span>

<span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">matched</span>
<span class="k">FROM</span> <span class="n">word_demo</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'database'</span> <span class="k">IN</span> <span class="k">NATURAL</span> <span class="k">LANGUAGE</span> <span class="k">MODE</span><span class="p">);</span>
<span class="c1">-- matched = 2</span>
</code></pre></div></div>

<p>MyISAM의 자연어 모드는 전체 행의 50% 이상에 나타나는 단어를 검색에서 제외합니다. MyISAM에서도 불리언 모드에는 그 임계값이 적용되지 않습니다. <strong>불용어 목록과 토큰 길이 제한은 별개의 조건</strong>입니다.<sup id="fnref:natural:3" role="doc-noteref"><a href="#fn:natural" class="footnote" rel="footnote">7</a></sup><sup id="fnref:boolean:3" role="doc-noteref"><a href="#fn:boolean" class="footnote" rel="footnote">6</a></sup></p>

<h2 id="query-expansion은-검색어를-문서에서-확장합니다">Query Expansion은 검색어를 문서에서 확장합니다</h2>

<p>Query Expansion은 첫 검색에서 상위에 나온 문서의 단어를 검색어에 추가해 다시 검색합니다. 의미를 이해해 동의어를 생성하는 기능은 아닙니다. 관련 없는 결과가 늘어날 수 있으므로, 정확한 필터보다 탐색용 검색에 적합한지 평가합니다.<sup id="fnref:expansion" role="doc-noteref"><a href="#fn:expansion" class="footnote" rel="footnote">8</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">body</span>
<span class="k">FROM</span> <span class="n">word_demo</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'indexing'</span> <span class="k">WITH</span> <span class="n">QUERY</span> <span class="n">EXPANSION</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">id</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="ngram의-한-글자-검색과-와일드카드">ngram의 한 글자 검색과 와일드카드</h2>

<p>bigram 인덱스는 길이 2의 토큰을 저장합니다. 길이 1의 검색어를 그대로 검색하면 필요한 토큰이 없어 결과가 나오지 않습니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">article_id</span> <span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'금'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">article_id</span><span class="p">;</span>
<span class="c1">-- 결과 없음</span>

<span class="k">SELECT</span> <span class="n">article_id</span> <span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'금*'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">article_id</span><span class="p">;</span>
<span class="c1">-- 6번: 본문의 '잠금과'에서 '금과' 토큰이 만들어집니다.</span>

<span class="k">SELECT</span> <span class="n">article_id</span> <span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="n">title</span> <span class="k">LIKE</span> <span class="s1">'%금%'</span> <span class="k">OR</span> <span class="n">body</span> <span class="k">LIKE</span> <span class="s1">'%금%'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">article_id</span><span class="p">;</span>
<span class="c1">-- 6번, 7번</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">금*</code>은 인덱스에 있는 <code class="language-plaintext highlighter-rouge">금</code>으로 시작하는 토큰을 찾습니다. 7번의 단독 한 글자 <code class="language-plaintext highlighter-rouge">금</code>에는 bigram 토큰이 없으므로, <code class="language-plaintext highlighter-rouge">*</code>를 붙여도 검색되지 않습니다. 반대로 ngram 크기보다 긴 접두 검색어는 ngram 구문 검색으로 변환되며 <code class="language-plaintext highlighter-rouge">*</code>가 무시됩니다.<sup id="fnref:ngram:3" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup></p>

<p>한 글자 검색이 필수라면 <code class="language-plaintext highlighter-rouge">ngram_token_size=1</code> 또는 범위를 제한한 다른 검색 경로를 검토합니다. <code class="language-plaintext highlighter-rouge">*</code>만 붙여 모든 한 글자·부분 문자열 검색을 해결할 수는 없습니다.</p>

<h2 id="토큰-길이와-불용어-설정">토큰 길이와 불용어 설정</h2>

<h3 id="기본-파서와-ngram의-설정은-다릅니다">기본 파서와 ngram의 설정은 다릅니다</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>InnoDB 기본 파서</th>
      <th>ngram 파서</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>토큰 생성</td>
      <td>공백·구두점 등을 기준으로 단어 분리</td>
      <td>길이 N의 연속된 문자 조각</td>
    </tr>
    <tr>
      <td>최소 길이</td>
      <td><code class="language-plaintext highlighter-rouge">innodb_ft_min_token_size</code>, 기본 3</td>
      <td>적용하지 않음</td>
    </tr>
    <tr>
      <td>최대 길이</td>
      <td><code class="language-plaintext highlighter-rouge">innodb_ft_max_token_size</code></td>
      <td>적용하지 않음</td>
    </tr>
    <tr>
      <td>ngram 크기</td>
      <td>해당 없음</td>
      <td><code class="language-plaintext highlighter-rouge">ngram_token_size</code>, 기본 2, 범위 1~10</td>
    </tr>
    <tr>
      <td>불용어</td>
      <td>토큰 전체가 불용어와 같은지 확인</td>
      <td>토큰 안에 불용어가 포함되는지도 확인</td>
    </tr>
  </tbody>
</table>

<p>MyISAM 기본 파서는 <code class="language-plaintext highlighter-rouge">ft_min_word_len</code>과 <code class="language-plaintext highlighter-rouge">ft_max_word_len</code>을 사용하고 최소 길이 기본값은 4입니다. <strong>이 최소·최대 단어 길이 변수들은 ngram 인덱스에 적용되지 않습니다.</strong><sup id="fnref:ngram:4" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup><sup id="fnref:tuning" role="doc-noteref"><a href="#fn:tuning" class="footnote" rel="footnote">9</a></sup></p>

<p>ngram은 공백을 가로질러 무조건 토큰을 만들지도 않습니다. 예를 들어 크기 2에서 <code class="language-plaintext highlighter-rouge">ab cd</code>는 <code class="language-plaintext highlighter-rouge">ab</code>, <code class="language-plaintext highlighter-rouge">cd</code>로 나뉘며 <code class="language-plaintext highlighter-rouge">bc</code> 토큰은 만들지 않습니다.<sup id="fnref:ngram:5" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup></p>

<h3 id="영문-불용어가-ngram에도-영향을-줍니다">영문 불용어가 ngram에도 영향을 줍니다</h3>

<p>ngram도 기본적으로 영문 불용어 목록을 사용합니다. 예를 들어 <code class="language-plaintext highlighter-rouge">a</code>가 불용어이면 <code class="language-plaintext highlighter-rouge">ab</code> 토큰도 제외될 수 있습니다. 한국어와 영문 상품명·코드를 함께 저장하는 서비스에서 확인할 부분입니다.<sup id="fnref:ngram:6" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup><sup id="fnref:stopwords" role="doc-noteref"><a href="#fn:stopwords" class="footnote" rel="footnote">10</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">stopword_demo</span> <span class="p">(</span>
  <span class="n">id</span> <span class="nb">INT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
  <span class="n">body</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">FULLTEXT</span> <span class="k">KEY</span> <span class="n">fts_stopword_demo_body</span> <span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="k">WITH</span> <span class="n">PARSER</span> <span class="n">ngram</span>
<span class="p">)</span> <span class="n">ENGINE</span><span class="o">=</span><span class="n">InnoDB</span> <span class="k">DEFAULT</span> <span class="n">CHARSET</span><span class="o">=</span><span class="n">utf8mb4</span><span class="p">;</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">stopword_demo</span> <span class="k">VALUES</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'abcd'</span><span class="p">);</span>

<span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">ab_hits</span> <span class="k">FROM</span> <span class="n">stopword_demo</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'ab'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">);</span>
<span class="c1">-- ab_hits = 0: 기본 불용어 a를 포함하는 ab 토큰은 제외됩니다.</span>

<span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">bc_hits</span> <span class="k">FROM</span> <span class="n">stopword_demo</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'bc'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">);</span>
<span class="c1">-- bc_hits = 1</span>
</code></pre></div></div>

<p>불용어는 <code class="language-plaintext highlighter-rouge">innodb_ft_enable_stopword</code>, <code class="language-plaintext highlighter-rouge">innodb_ft_server_stopword_table</code>, <code class="language-plaintext highlighter-rouge">innodb_ft_user_stopword_table</code>로 제어합니다. 사용자 불용어 테이블은 공식 문서가 요구하는 스키마로 만들고 인덱스 생성·재구축 전에 지정합니다. MyISAM은 <code class="language-plaintext highlighter-rouge">ft_stopword_file</code>을 사용합니다.<sup id="fnref:stopwords:1" role="doc-noteref"><a href="#fn:stopwords" class="footnote" rel="footnote">10</a></sup></p>

<h2 id="설정-변경은-기존-인덱스-재구축까지-포함합니다">설정 변경은 기존 인덱스 재구축까지 포함합니다</h2>

<p><code class="language-plaintext highlighter-rouge">ngram_token_size</code>는 읽기 전용 시작 옵션입니다. 예를 들어 크기를 1로 바꾸려면 설정을 변경하고 서버를 재시작한 뒤, 영향을 받는 ngram FULLTEXT 인덱스를 재구축해야 합니다. <strong>서버 재시작만으로 기존 인덱스의 토큰이 다시 만들어지지는 않습니다.</strong><sup id="fnref:ngram:7" role="doc-noteref"><a href="#fn:ngram" class="footnote" rel="footnote">4</a></sup><sup id="fnref:tuning:1" role="doc-noteref"><a href="#fn:tuning" class="footnote" rel="footnote">9</a></sup></p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[mysqld]</span>
<span class="py">ngram_token_size</span><span class="p">=</span><span class="s">1</span>
</code></pre></div></div>

<p>InnoDB 기본 파서의 최소·최대 단어 길이를 변경할 때도 서버 재시작과 해당 인덱스 재구축이 필요합니다. 불용어 정책 변경 역시 기존 인덱스가 자동 갱신된다고 가정하지 않습니다.</p>

<p>다음은 앞의 실습 테이블에서 불용어를 끄고 인덱스를 재구축하는 예제입니다. 이 예제는 ngram 크기를 바꾸지 않으며, 같은 연결에서 실행합니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="k">SESSION</span> <span class="n">innodb_ft_enable_stopword</span> <span class="o">=</span> <span class="k">OFF</span><span class="p">;</span>

<span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">stopword_demo</span> <span class="k">DROP</span> <span class="k">INDEX</span> <span class="n">fts_stopword_demo_body</span><span class="p">;</span>
<span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">stopword_demo</span>
  <span class="k">ADD</span> <span class="n">FULLTEXT</span> <span class="k">KEY</span> <span class="n">fts_stopword_demo_body</span> <span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="k">WITH</span> <span class="n">PARSER</span> <span class="n">ngram</span><span class="p">;</span>

<span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">ab_hits</span> <span class="k">FROM</span> <span class="n">stopword_demo</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'ab'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">);</span>
<span class="c1">-- ab_hits = 1</span>

<span class="k">SET</span> <span class="k">SESSION</span> <span class="n">innodb_ft_enable_stopword</span> <span class="o">=</span> <span class="k">ON</span><span class="p">;</span>
</code></pre></div></div>

<p>이 예제는 삭제와 생성을 별도 문장으로 실행합니다. 그 사이에는 해당 FULLTEXT 인덱스가 없으므로 운영에서는 검색 트래픽과 작업 시간을 조정해야 합니다. 마지막 <code class="language-plaintext highlighter-rouge">SET</code>은 연결의 설정을 되돌리는 것이며, 방금 만든 인덱스를 다시 불용어 적용 상태로 재구축하는 명령은 아닙니다. 선택한 정책의 적용 범위·영속 설정·재구축 대상을 함께 관리합니다.</p>

<p>기존 기본 파서 인덱스를 ngram으로 바꿀 때도 현재 인덱스를 확인한 뒤 교체해야 합니다. <code class="language-plaintext highlighter-rouge">SHOW CREATE TABLE</code>·<code class="language-plaintext highlighter-rouge">SHOW INDEX</code>로 이름과 컬럼을 확인하고, 동일한 이름의 인덱스를 중복으로 추가하지 않습니다.</p>

<h2 id="match-컬럼과-트랜잭션에서-자주-만나는-문제">MATCH 컬럼과 트랜잭션에서 자주 만나는 문제</h2>

<h3 id="복합-fulltext는-b-tree의-왼쪽-접두-규칙과-다릅니다">복합 FULLTEXT는 B-Tree의 왼쪽 접두 규칙과 다릅니다</h3>

<p>이 글의 인덱스는 <code class="language-plaintext highlighter-rouge">(title, body)</code>입니다. InnoDB에서 <code class="language-plaintext highlighter-rouge">MATCH(title)</code>만 사용하면 이 복합 인덱스로 대신 검색할 수 없어 오류가 납니다.<sup id="fnref:restrictions:1" role="doc-noteref"><a href="#fn:restrictions" class="footnote" rel="footnote">5</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">article_id</span> <span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'데이터'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">);</span>
<span class="c1">-- ERROR 1191 (HY000): Can't find FULLTEXT index matching the column list</span>
</code></pre></div></div>

<p>제목만 검색해야 한다면 제목만의 FULLTEXT 인덱스를 별도로 만들거나, 요구에 맞는 다른 조회를 사용합니다. <code class="language-plaintext highlighter-rouge">MATCH(title, body)</code>로 바꾸면 본문까지 검색한다는 점도 함께 고려해야 합니다.</p>

<h3 id="insert-직후-같은-트랜잭션에서도-검색되지-않을-수-있습니다">INSERT 직후 같은 트랜잭션에서도 검색되지 않을 수 있습니다</h3>

<p>InnoDB FULLTEXT의 삽입·갱신 반영은 커밋 시점에 처리됩니다. 같은 트랜잭션에서 일반 SELECT로 보이는 새 행이 FULLTEXT로는 아직 보이지 않을 수 있습니다.<sup id="fnref:innodb:1" role="doc-noteref"><a href="#fn:innodb" class="footnote" rel="footnote">2</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">START</span> <span class="n">TRANSACTION</span><span class="p">;</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">article</span> <span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="k">VALUES</span> <span class="p">(</span><span class="s1">'초신성 관측'</span><span class="p">,</span> <span class="s1">'새 관측 기록입니다.'</span><span class="p">);</span>

<span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">ordinary_select</span> <span class="k">FROM</span> <span class="n">article</span> <span class="k">WHERE</span> <span class="n">title</span> <span class="o">=</span> <span class="s1">'초신성 관측'</span><span class="p">;</span>
<span class="c1">-- ordinary_select = 1</span>
<span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">before_commit</span> <span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'초신성'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">);</span>
<span class="c1">-- before_commit = 0</span>

<span class="k">COMMIT</span><span class="p">;</span>

<span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">after_commit</span> <span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'초신성'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">);</span>
<span class="c1">-- after_commit = 1</span>
</code></pre></div></div>

<p>저장 직후 같은 트랜잭션에서 저장 결과를 확인하는 용도로 FULLTEXT를 사용하지 않습니다. 그 경우 PK로 조회하는 편이 목적에 맞습니다.</p>

<h2 id="운영에-적용하기-전에-확인할-것">운영에 적용하기 전에 확인할 것</h2>

<ul>
  <li><strong>인덱스와 실행계획:</strong> 실제 서비스의 필터·정렬·LIMIT을 포함한 쿼리로 측정합니다. FULLTEXT 인덱스가 있다고 모든 조회가 빨라지는 것은 아닙니다.</li>
  <li><strong>DDL 비용:</strong> 첫 FULLTEXT 인덱스를 추가할 때 사용자 정의 <code class="language-plaintext highlighter-rouge">FTS_DOC_ID</code>가 없으면 테이블 재구축이 발생합니다. InnoDB의 FULLTEXT 인덱스 추가는 동시 DML을 허용하지 않으므로, <code class="language-plaintext highlighter-rouge">ALGORITHM=INPLACE</code>를 무중단 쓰기 허용으로 해석하면 안 됩니다.<sup id="fnref:ddl" role="doc-noteref"><a href="#fn:ddl" class="footnote" rel="footnote">11</a></sup></li>
  <li><strong>지원 범위:</strong> MySQL 8.4에서는 InnoDB·MyISAM이 FULLTEXT를 지원하며 파티션 테이블에는 사용할 수 없습니다.<sup id="fnref:restrictions:2" role="doc-noteref"><a href="#fn:restrictions" class="footnote" rel="footnote">5</a></sup></li>
  <li><strong>검색 입력:</strong> SQL 바인딩과 검색 문법 처리를 구분합니다. 값을 바인딩해도 BOOLEAN MODE의 <code class="language-plaintext highlighter-rouge">+</code>, <code class="language-plaintext highlighter-rouge">-</code>, 따옴표 등이 일반 문자로 바뀌지는 않습니다. 일반 검색창과 고급 검색 문법의 허용 범위를 정하고 빈 검색어·문법 오류를 처리합니다.</li>
  <li><strong>검색 품질:</strong> 한 글자, 조사·어미, 띄어쓰기, 영문 코드, 불용어, 구두점에 대해 기대 결과를 정해 테스트합니다. 형태소·동의어·오타 보정이 필요한 경우에는 그 요구를 지원하는 별도 검색 시스템을 검토합니다.</li>
</ul>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">EXPLAIN</span>
<span class="k">SELECT</span> <span class="n">article_id</span><span class="p">,</span> <span class="n">title</span>
<span class="k">FROM</span> <span class="n">article</span>
<span class="k">WHERE</span> <span class="k">MATCH</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span> <span class="n">AGAINST</span><span class="p">(</span><span class="s1">'데이터베이스'</span> <span class="k">IN</span> <span class="nb">BOOLEAN</span> <span class="k">MODE</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">article_id</span>
<span class="k">LIMIT</span> <span class="mi">20</span><span class="p">;</span>
</code></pre></div></div>

<p>이 실습에서는 접근 방식 <code class="language-plaintext highlighter-rouge">type=fulltext</code>, 사용 인덱스 <code class="language-plaintext highlighter-rouge">key=fts_article_title_body</code>를 확인할 수 있습니다. 실제 데이터량과 검색어 빈도에서도 실행 시간과 반환 결과를 함께 확인합니다.</p>

<p>FULLTEXT를 도입할 때는 LIKE를 기계적으로 교체하기보다, <strong>찾아야 할 결과 → 파서와 검색 모드 → 토큰·불용어 정책 → 재구축과 검증</strong> 순서로 결정합니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:overview" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Full-Text Search Functions. 검색 기능과 지원 타입. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-search.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-search.html</a> <a href="#fnref:overview" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:overview:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:innodb" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — InnoDB Full-Text Indexes. 역색인과 트랜잭션 반영 시점. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-fulltext-index.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-fulltext-index.html</a> <a href="#fnref:innodb" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:innodb:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:range" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Range Optimization. LIKE 패턴과 B-Tree 범위 검색 조건. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/range-optimization.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/range-optimization.html</a> <a href="#fnref:range" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ngram" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — ngram Full-Text Parser. 토큰 크기·공백·불용어·검색 모드·와일드카드. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-search-ngram.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-search-ngram.html</a> <a href="#fnref:ngram" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:ngram:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:ngram:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:ngram:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:ngram:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:ngram:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a> <a href="#fnref:ngram:6" class="reversefootnote" role="doc-backlink">&#8617;<sup>7</sup></a> <a href="#fnref:ngram:7" class="reversefootnote" role="doc-backlink">&#8617;<sup>8</sup></a></p>
    </li>
    <li id="fn:restrictions" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Full-Text Restrictions. MATCH 컬럼·문자셋·파티션 제약. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-restrictions.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-restrictions.html</a> <a href="#fnref:restrictions" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:restrictions:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:restrictions:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:boolean" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Boolean Full-Text Searches. 연산자·정렬·InnoDB 관련성 계산. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-boolean.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-boolean.html</a> <a href="#fnref:boolean" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:boolean:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:boolean:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:boolean:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:natural" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Natural Language Full-Text Searches. 관련도 정렬과 MyISAM의 50% 임계값. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-natural-language.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-natural-language.html</a> <a href="#fnref:natural" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:natural:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:natural:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:natural:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:expansion" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Full-Text Searches with Query Expansion. 두 단계 검색과 검색어 확장. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-query-expansion.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-query-expansion.html</a> <a href="#fnref:expansion" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:tuning" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Fine-Tuning MySQL Full-Text Search. 설정 변경과 인덱스 재구축. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-fine-tuning.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-fine-tuning.html</a> <a href="#fnref:tuning" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:tuning:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:stopwords" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Full-Text Stopwords. 기본·사용자 정의 불용어 설정. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-stopwords.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/fulltext-stopwords.html</a> <a href="#fnref:stopwords" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:stopwords:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:ddl" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Online DDL Operations. FULLTEXT 인덱스 추가 시 재구축과 동시 DML 제한. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-online-ddl-operations.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/innodb-online-ddl-operations.html</a> <a href="#fnref:ddl" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="mysql" /><summary type="html"><![CDATA[MySQL 8.4의 FULLTEXT와 ngram 검색을 재현하고, 검색 모드·한 글자 검색·불용어·인덱스 재구축·트랜잭션 가시성의 차이를 정리합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AWS Aurora의 커스텀 엔드포인트를 활용한 효율적인 데이터베이스 운영 가이드</title><link href="https://rastalion.dev/writing/aurora-custom-endpoints/" rel="alternate" type="text/html" title="AWS Aurora의 커스텀 엔드포인트를 활용한 효율적인 데이터베이스 운영 가이드" /><published>2024-10-26T02:54:21+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/aurora-custom-endpoints</id><content type="html" xml:base="https://rastalion.dev/writing/aurora-custom-endpoints/"><![CDATA[<p>AWS Aurora는 클러스터 안에 여러 DB 인스턴스를 두고, 애플리케이션이 사용할 DNS 주소를 엔드포인트로 제공합니다. 이 글에서는 커스텀 엔드포인트로 내부 조회용 인스턴스와 서비스 읽기 인스턴스를 나눠, 별도 SQL Proxy 없이 연결 대상을 분리하는 방법을 정리합니다.</p>

<p>본문의 생성·변경 예제는 같은 리전의 Aurora 클러스터를 대상으로 합니다. 리전 <code class="language-plaintext highlighter-rouge">ap-northeast-2</code>와 클러스터·인스턴스·엔드포인트 이름은 실제 환경에 맞춰 바꿉니다. 기존 엔드포인트가 있다면 새로 생성하는 절차 대신 타입과 멤버 설정을 확인하는 절차를 사용합니다.</p>

<h2 id="1-커스텀-엔드포인트란">1. 커스텀 엔드포인트란?</h2>

<p>커스텀 엔드포인트는 Aurora 클러스터 안에서 직접 고른 DB 인스턴스 그룹을 하나의 엔드포인트로 묶는 기능입니다. 연결이 들어오면 Aurora가 그룹 안의 인스턴스 중 하나를 골라 처리합니다. AWS 콘솔과 한국어 문서에서는 사용자 지정 엔드포인트라고 표기합니다.</p>

<p>예전에는 같은 효과를 내려고 CNAME으로 DNS 별칭을 만들어 두는 방식을 썼습니다. 커스텀 엔드포인트를 쓰면 클러스터가 커지거나 줄어들 때마다 CNAME 레코드를 손보지 않아도 되고, TLS/SSL 연결도 그대로 쓸 수 있습니다.</p>

<h3 id="11-aurora-엔드포인트-네-종류">1.1. Aurora 엔드포인트 네 종류</h3>

<table>
  <thead>
    <tr>
      <th>엔드포인트</th>
      <th>연결 대상</th>
      <th>쓰임</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>클러스터(writer)</td>
      <td>프라이머리 인스턴스</td>
      <td>DDL·DML, 쓰기 작업</td>
    </tr>
    <tr>
      <td>리더</td>
      <td>Aurora 복제본 전체</td>
      <td>읽기 전용 쿼리, 연결 분산</td>
    </tr>
    <tr>
      <td>커스텀</td>
      <td>직접 고른 인스턴스 그룹</td>
      <td>용량·설정이 다른 인스턴스 분리</td>
    </tr>
    <tr>
      <td>인스턴스</td>
      <td>특정 인스턴스 하나</td>
      <td>진단과 튜닝</td>
    </tr>
  </tbody>
</table>

<p>클러스터에는 DDL과 DML을 처리하는 프라이머리 인스턴스 하나와, 읽기 전용 쿼리를 받는 Aurora 복제본을 최대 15개까지 둘 수 있습니다. 커스텀 엔드포인트로 그룹을 나눠 쓰기 시작하면 그 클러스터의 리더 엔드포인트는 보통 쓰지 않습니다.</p>

<p>기본 리더 엔드포인트는 reader가 하나도 없으면 writer에 연결됩니다. <strong><code class="language-plaintext highlighter-rouge">READER</code> 타입 커스텀 엔드포인트에는 같은 writer 대체 연결을 기대하면 안 됩니다.</strong> 연결 가능한 reader가 그룹에 남아 있는지 따로 확인해야 합니다.<sup id="fnref:reader" role="doc-noteref"><a href="#fn:reader" class="footnote" rel="footnote">1</a></sup><sup id="fnref:membership" role="doc-noteref"><a href="#fn:membership" class="footnote" rel="footnote">2</a></sup></p>

<h3 id="12-주요-특징">1.2. 주요 특징</h3>

<ul>
  <li>프로비저닝 클러스터와 Aurora 서버리스 클러스터 모두, 클러스터당 커스텀 엔드포인트를 5개까지 만들 수 있습니다.</li>
  <li>멤버를 지정하는 방식은 정적 목록(static list)과 제외 목록(exclusion list) 두 가지이고, 한 엔드포인트는 둘 중 하나만 가집니다.</li>
  <li>엔드포인트 이름은 최대 63자입니다. 같은 리전 안에서 다른 클러스터와 이름을 겹쳐 쓸 수 없습니다.</li>
  <li>엔드포인트에는 타입이 있습니다. Aurora 사용자 가이드는 현재 쓸 수 있는 타입을 READER와 ANY로 적습니다. 콘솔에서 만든 엔드포인트는 모두 ANY이고, 타입을 지정하거나 바꾸려면 AWS CLI나 RDS API를 써야 합니다.<sup id="fnref:membership:1" role="doc-noteref"><a href="#fn:membership" class="footnote" rel="footnote">2</a></sup></li>
</ul>

<h3 id="13-연결이-분산되는-방식">1.3. 연결이 분산되는 방식</h3>

<p>커스텀 엔드포인트는 세션을 중계하는 프록시가 아닙니다. DNS가 그룹 안 인스턴스 중 하나의 IP 주소를 무작위로 돌려주는 방식으로 연결을 분산합니다. ANY 타입이면 연결이 멤버 인스턴스에 같은 확률로 배정되고, 라이터도 ANY 타입의 멤버가 될 수 있으므로 쓰기 인스턴스로 연결이 갈 수 있습니다. 읽기 인스턴스만 받게 하려면 타입을 READER로 지정합니다.</p>

<ul>
  <li>그룹 안의 인스턴스 하나가 응답하지 못하면, 사용 가능한 다른 대상이 있을 때 새 연결을 그 대상으로 보낼 수 있습니다. 이미 열린 연결의 실행 중 쿼리까지 다른 인스턴스로 옮겨 주는 기능은 아닙니다.</li>
  <li>응답하지 못하는 인스턴스도 멤버에서 빠지지는 않습니다. 중지·재부팅·비정상 상태에서도 멤버로 남고, 다시 사용 가능해질 때까지 그 인스턴스로 연결되지 않습니다.</li>
  <li>멤버를 추가하거나 제거해도 해당 인스턴스에 이미 열려 있는 연결은 끊기지 않습니다.<sup id="fnref:membership:2" role="doc-noteref"><a href="#fn:membership" class="footnote" rel="footnote">2</a></sup></li>
  <li>AWS 문서는 엔드포인트 단위의 DNS 전파 시간이나 초당 연결 수 상한을 제시하지 않습니다. 동시 연결 수는 엔드포인트가 아니라 각 인스턴스의 <code class="language-plaintext highlighter-rouge">max_connections</code>에 걸립니다.</li>
</ul>

<h2 id="2-내부-조회용-인스턴스와-서비스-읽기-인스턴스-그룹-설정">2. 내부 조회용 인스턴스와 서비스 읽기 인스턴스 그룹 설정</h2>

<h3 id="21-인스턴스-생성">2.1. 인스턴스 생성</h3>

<p>먼저 Aurora 클러스터 안에 다음 용도의 인스턴스들을 만듭니다.</p>

<p><strong>내부 조회용 인스턴스 (devops-aurora-test-instance-3)</strong></p>

<ul>
  <li>데이터 분석 및 내부 보고서 생성용</li>
  <li>대용량 조회 배치</li>
  <li>BI 도구 연동</li>
  <li>ETL의 읽기·추출 단계</li>
</ul>

<p><strong>서비스 읽기 인스턴스 (devops-aurora-test-instance-1, devops-aurora-test-instance-2)</strong></p>

<ul>
  <li>실시간 사용자 요청 처리</li>
  <li>API 서비스 지원</li>
  <li>빠른 응답이 필요한 조회 작업</li>
</ul>

<p>이 예제에서 초기 writer는 1번, reader는 2번과 3번입니다. 서비스 그룹의 후보에 1번이 있더라도 <code class="language-plaintext highlighter-rouge">READER</code> 타입에서는 writer인 동안 연결 대상이 아닙니다. INSERT·UPDATE·DDL을 수행하는 ETL 적재 단계나 운영 배치는 writer 엔드포인트로 분리합니다.<sup id="fnref:tutorial" role="doc-noteref"><a href="#fn:tutorial" class="footnote" rel="footnote">3</a></sup></p>

<h4 id="인스턴스-타입-권장사항">인스턴스 타입 권장사항</h4>

<ul>
  <li>분석용 인스턴스: 메모리 최적화 인스턴스</li>
  <li>서비스용 인스턴스: 범용 인스턴스</li>
</ul>

<p>한 엔드포인트에 묶인 인스턴스는 어느 쪽으로 연결이 가도 성능이 같아야 하므로, 같은 그룹 안에서는 인스턴스 클래스와 파라미터 그룹을 맞추는 편이 좋습니다.</p>

<blockquote>
  <p><strong>NOTE</strong> — 분석용 인스턴스에서 Aurora MySQL 병렬 쿼리를 쓸 계획이라면 인스턴스 클래스가 <code class="language-plaintext highlighter-rouge">db.r*</code> 여야 하고, db.t2·db.t3에서는 쓸 수 없습니다. 병렬 쿼리는 버퍼 풀을 채우지 않으므로 같은 쿼리를 반복해도 I/O 비용이 그대로 발생합니다. Aurora I/O-Optimized 스토리지 구성에서는 병렬 쿼리를 지원하지 않습니다.</p>
</blockquote>

<p>여기에서는 동일한 사양으로 3번을 내부 조회용(ETL 및 분석 등), 1, 2번을 서비스로 구성했습니다.</p>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.07.07-e1729908309730.png" alt="" /></p>

<h3 id="22-aws-aurora-커스텀-엔드포인트-생성-가이드">2.2. AWS Aurora 커스텀 엔드포인트 생성 가이드</h3>

<h4 id="콘솔을-이용한-생성-방법">콘솔을 이용한 생성 방법</h4>

<p>콘솔에서는 <strong>Attach future instances added to this cluster</strong> 체크박스가 멤버 지정 방식을 결정합니다. 체크를 비우면 화면에서 고른 인스턴스만 담는 정적 목록이 되고, 체크하면 고르지 않은 인스턴스만 빼는 제외 목록이 됩니다. 정적 목록으로 만든 엔드포인트에는 나중에 추가된 복제본이 들어오지 않고, 제외 목록으로 만든 엔드포인트에는 자동으로 들어옵니다.</p>

<ol>
  <li>
    <p>AWS Management Console에서 Aurora 클러스터 선택</p>
  </li>
  <li>
    <p>Endpoints 탭으로 이동</p>
  </li>
  <li>
    <p>Create Custom Endpoint 버튼 클릭</p>
  </li>
</ol>

<p><img src="/assets/img/wp/2024/10/2.2-3.png" alt="" /></p>

<ol>
  <li>서비스 읽기용 커스텀 엔드포인트 설정</li>
</ol>

<ul>
  <li>엔드포인트 이름 지정</li>
  <li>서비스 읽기 인스턴스 선택</li>
</ul>

<p><img src="/assets/img/wp/2024/10/2.2-4.png" alt="" /></p>

<ol>
  <li>내부 조회용 커스텀 엔드포인트 설정</li>
</ol>

<ul>
  <li>엔드포인트 이름 지정</li>
  <li>내부 조회용 인스턴스 선택</li>
</ul>

<p><img src="/assets/img/wp/2024/10/2.2-5.png" alt="" /></p>

<ol>
  <li>생성된 커스텀 엔드포인트 확인</li>
</ol>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.17.42.png" alt="" /></p>

<p><strong>다음 단계 전에 두 엔드포인트를 <code class="language-plaintext highlighter-rouge">READER</code> 타입으로 변경합니다.</strong> 콘솔 생성 직후에는 <code class="language-plaintext highlighter-rouge">ANY</code>이므로 이름에 <code class="language-plaintext highlighter-rouge">read-only</code>를 넣거나 reader를 선택한 것만으로 역할 변경 시의 동작까지 정해지지 않습니다. 아래의 멤버·페일오버 설명은 이 전환을 마친 상태를 전제로 합니다.<sup id="fnref:membership:3" role="doc-noteref"><a href="#fn:membership" class="footnote" rel="footnote">2</a></sup><sup id="fnref:modify" role="doc-noteref"><a href="#fn:modify" class="footnote" rel="footnote">4</a></sup></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws rds modify-db-cluster-endpoint <span class="se">\</span>
  <span class="nt">--region</span> ap-northeast-2 <span class="se">\</span>
  <span class="nt">--db-cluster-endpoint-identifier</span> read-only <span class="se">\</span>
  <span class="nt">--endpoint-type</span> READER

aws rds modify-db-cluster-endpoint <span class="se">\</span>
  <span class="nt">--region</span> ap-northeast-2 <span class="se">\</span>
  <span class="nt">--db-cluster-endpoint-identifier</span> service-read <span class="se">\</span>
  <span class="nt">--endpoint-type</span> READER
</code></pre></div></div>

<p>변경 요청의 응답이 <code class="language-plaintext highlighter-rouge">modifying</code>이면 완료된 것이 아닙니다. 다음 조회에서 해당 엔드포인트의 <code class="language-plaintext highlighter-rouge">Status=available</code>, <code class="language-plaintext highlighter-rouge">CustomEndpointType=READER</code>를 확인합니다.<sup id="fnref:describe" role="doc-noteref"><a href="#fn:describe" class="footnote" rel="footnote">5</a></sup></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws rds describe-db-cluster-endpoints <span class="se">\</span>
  <span class="nt">--region</span> ap-northeast-2 <span class="se">\</span>
  <span class="nt">--db-cluster-identifier</span> devops-aurora-test <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"DBClusterEndpoints[?EndpointType=='CUSTOM'].{Name:DBClusterEndpointIdentifier,Type:CustomEndpointType,Status:Status,Address:Endpoint,Static:StaticMembers,Excluded:ExcludedMembers}"</span> <span class="se">\</span>
  <span class="nt">--output</span> json
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">EndpointType=CUSTOM</code>은 엔드포인트의 종류이고, <strong>역할 필터는 <code class="language-plaintext highlighter-rouge">CustomEndpointType</code></strong>입니다. 정적·제외 목록은 설정된 목록이므로, 이것만으로 현재 접속 가능한 인스턴스가 모두 정상인지까지 판단하지 않습니다. 기존 연결도 자동 교체되지 않으므로 마지막에는 새 연결로 접속 대상을 확인합니다.</p>

<ol>
  <li>Read-Only 커스텀 엔드포인트를 확인해보면 3번 인스턴스만 있는 것을 확인할 수 있습니다. 정적 목록으로 만들었기 때문에 앞으로 인스턴스를 추가해도 이 엔드포인트의 멤버는 그대로입니다.</li>
</ol>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.50.06.png" alt="" /></p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">READER</code> 전환 후 초기 Service-read의 연결 대상은 2번입니다. 1번은 writer이고 3번은 제외 목록에 있기 때문입니다. 앞으로 추가되는 인스턴스는 제외 목록에 없고 reader이면 연결 대상이 됩니다. Aurora Auto Scaling이 붙인 복제본도 같은 멤버 지정 규칙을 따릅니다.</li>
</ol>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.19.45.png" alt="" /></p>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.20.00.png" alt="" /></p>

<ol>
  <li>다음 화면은 서비스 읽기용 4번 reader를 추가한 뒤, writer를 1번에서 4번으로 바꾸는 페일오버 예제입니다. 페일오버 전에는 서비스 읽기 대상이 2·4번이고, 이후에는 1·2번이 되는지 확인합니다. 페일오버는 실행 중 연결과 업무에 영향을 주므로 검증 환경에서 동작과 복구 절차를 확인한 뒤 운영 계획에 반영합니다.</li>
</ol>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.51.10.png" alt="" /></p>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.53.07.png" alt="" /></p>

<ol>
  <li>4번이 writer로 승격되면 <code class="language-plaintext highlighter-rouge">READER</code> 대상에서 빠지고, reader로 복귀한 1번이 연결 대상이 되는지 확인합니다. 콘솔 표시뿐 아니라 새 DB 연결에서 접속 인스턴스도 확인합니다.</li>
</ol>

<p><img src="/assets/img/wp/2024/10/스크린샷-2024-10-18-오후-12.53.18.png" alt="" /></p>

<ol>
  <li>분석용 3번의 장애조치 우선순위를 낮춥니다. <code class="language-plaintext highlighter-rouge">promotion-tier</code>는 0이 가장 높고 15가 가장 낮습니다. <strong>15는 승격 금지가 아닙니다.</strong> 다른 후보의 상태에 따라 3번이 writer가 될 수 있으며, 그때는 분석용 <code class="language-plaintext highlighter-rouge">READER</code> 엔드포인트의 연결 대상에서 빠집니다.<sup id="fnref:ha" role="doc-noteref"><a href="#fn:ha" class="footnote" rel="footnote">6</a></sup></li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws rds modify-db-instance <span class="se">\</span>
  <span class="nt">--region</span> ap-northeast-2 <span class="se">\</span>
  <span class="nt">--db-instance-identifier</span> devops-aurora-test-instance-3 <span class="se">\</span>
  <span class="nt">--promotion-tier</span> 15
</code></pre></div></div>

<p>역할과 승격 우선순위는 다음과 같이 대조합니다. 우선순위 변경 자체가 페일오버를 실행하는 것은 아닙니다.<sup id="fnref:ha:1" role="doc-noteref"><a href="#fn:ha" class="footnote" rel="footnote">6</a></sup><sup id="fnref:tutorial:1" role="doc-noteref"><a href="#fn:tutorial" class="footnote" rel="footnote">3</a></sup></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws rds describe-db-clusters <span class="se">\</span>
  <span class="nt">--region</span> ap-northeast-2 <span class="se">\</span>
  <span class="nt">--db-cluster-identifier</span> devops-aurora-test <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"DBClusters[0].DBClusterMembers[].{Instance:DBInstanceIdentifier,Writer:IsClusterWriter,Tier:PromotionTier}"</span> <span class="se">\</span>
  <span class="nt">--output</span> table
</code></pre></div></div>

<blockquote>
  <p><strong>IMPORTANT</strong> — <code class="language-plaintext highlighter-rouge">ANY</code>를 그대로 유지하면 writer·reader 역할 변경에 맞춘 자동 멤버 조정을 기대할 수 없습니다. 이 글의 읽기 분리 구성은 <code class="language-plaintext highlighter-rouge">READER</code> 전환, 멤버 목록 확인, 새 연결 확인까지 완료해야 합니다.</p>
</blockquote>

<h4 id="aws-cli를-이용한-생성-방법">AWS CLI를 이용한 생성 방법</h4>

<p><strong>내부용(배치용) 엔드포인트 생성</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws rds create-db-cluster-endpoint <span class="se">\</span>
  <span class="nt">--region</span> ap-northeast-2 <span class="se">\</span>
  <span class="nt">--db-cluster-identifier</span> devops-aurora-test <span class="se">\</span>
  <span class="nt">--db-cluster-endpoint-identifier</span> read-only <span class="se">\</span>
  <span class="nt">--endpoint-type</span> READER <span class="se">\</span>
  <span class="nt">--static-members</span> devops-aurora-test-instance-3
</code></pre></div></div>

<p><strong>서비스 읽기용 엔드포인트 생성</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws rds create-db-cluster-endpoint <span class="se">\</span>
  <span class="nt">--region</span> ap-northeast-2 <span class="se">\</span>
  <span class="nt">--db-cluster-identifier</span> devops-aurora-test <span class="se">\</span>
  <span class="nt">--db-cluster-endpoint-identifier</span> service-read <span class="se">\</span>
  <span class="nt">--endpoint-type</span> READER <span class="se">\</span>
  <span class="nt">--excluded-members</span> devops-aurora-test-instance-3
</code></pre></div></div>

<p>콘솔 생성과 CLI 생성은 같은 엔드포인트를 만드는 두 가지 경로입니다. 콘솔에서 이미 만들었다면 위 <code class="language-plaintext highlighter-rouge">create</code> 명령을 다시 실행하지 않습니다. CLI로 새로 만들 때는 처음부터 <code class="language-plaintext highlighter-rouge">--endpoint-type READER</code>를 지정하고, 앞의 조회 명령으로 생성 완료와 실제 타입을 확인합니다. writer 연결에는 기존 클러스터 엔드포인트를 사용합니다.<sup id="fnref:tutorial:2" role="doc-noteref"><a href="#fn:tutorial" class="footnote" rel="footnote">3</a></sup></p>

<p><code class="language-plaintext highlighter-rouge">--static-members</code>와 <code class="language-plaintext highlighter-rouge">--excluded-members</code>는 함께 쓰지 않습니다. 제외 목록을 쓰면 목록에 없는 인스턴스 중 엔드포인트 타입에 맞고 사용 가능한 대상에 연결합니다. 이 글의 <code class="language-plaintext highlighter-rouge">READER</code> 타입에서는 writer가 제외됩니다. 만든 뒤 멤버를 손볼 때는 <code class="language-plaintext highlighter-rouge">aws rds modify-db-cluster-endpoint</code>, 목록을 확인할 때는 <code class="language-plaintext highlighter-rouge">aws rds describe-db-cluster-endpoints</code>를 씁니다.</p>

<h3 id="23-애플리케이션-설정">2.3. 애플리케이션 설정</h3>

<h4 id="연결-풀-설정-예시">연결 풀 설정 예시</h4>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>최소 연결 수: 10
최대 연결 수: 100
연결 타임아웃: 30초
유휴 연결 제거: 300초
</code></pre></div></div>

<p>이 숫자는 출발점입니다. 최대 연결 수는 엔드포인트에 묶인 인스턴스의 <code class="language-plaintext highlighter-rouge">max_connections</code>와 애플리케이션 인스턴스 대수를 함께 계산해서 정합니다.</p>

<p>예를 들어 애플리케이션 8개가 각각 최대 100개 연결을 가지면 전체 상한은 800개입니다. 정상 상태에서 reader가 두 개라고 해서 항상 400개씩 배정된다고 가정하면 안 됩니다. 장애로 한 개만 남았을 때의 여유와 운영·모니터링 연결도 고려합니다.</p>

<h4 id="새-연결과-연결-풀-재사용을-나눠-검증합니다">새 연결과 연결 풀 재사용을 나눠 검증합니다</h4>

<p>DNS 기반 분산은 새 연결의 대상을 고르는 과정입니다. 연결 풀에서 이미 열린 연결을 재사용하면 쿼리마다 다른 인스턴스로 이동하지 않습니다. 멤버를 추가해도 기존 풀의 연결이 자동으로 새 인스턴스로 옮겨 가지 않고, 멤버를 제거해도 기존 연결은 유지될 수 있습니다.<sup id="fnref:membership:4" role="doc-noteref"><a href="#fn:membership" class="footnote" rel="footnote">2</a></sup></p>

<p>Aurora MySQL에서는 접속한 연결에서 다음을 확인합니다.<sup id="fnref:tutorial:3" role="doc-noteref"><a href="#fn:tutorial" class="footnote" rel="footnote">3</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">@@</span><span class="n">aurora_server_id</span> <span class="k">AS</span> <span class="n">instance_id</span><span class="p">,</span>
       <span class="n">CONNECTION_ID</span><span class="p">()</span> <span class="k">AS</span> <span class="n">connection_id</span><span class="p">;</span>
</code></pre></div></div>

<ol>
  <li>커스텀 엔드포인트로 새 연결을 여러 번 만들고 <code class="language-plaintext highlighter-rouge">instance_id</code>를 기록합니다.</li>
  <li>위의 역할 조회 결과와 비교해 서비스 그룹에는 허용한 reader만 나타나는지 확인합니다.</li>
  <li>같은 풀 연결을 재사용할 때와 연결을 닫고 새로 만들 때를 구분합니다. 짧은 표본이 균등하지 않다는 이유만으로 오류라고 판단하지 않습니다.</li>
  <li>멤버 변경·페일오버 이후에는 기존 연결의 상태와 새 연결의 대상을 따로 확인합니다. 애플리케이션과 드라이버의 DNS 캐시·연결 수명·재연결 정책도 함께 점검합니다.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">Status=available</code>만으로 애플리케이션의 DNS·TLS·인증·접속 대상까지 검증된 것은 아닙니다. 반대로 연결 하나가 실패했다고 모든 연결을 한꺼번에 폐기하면 재접속 부하가 커질 수 있으므로, 풀의 오류 처리와 재시도 정책을 실제 장애 시나리오에서 확인합니다.</p>

<h4 id="분석-세션의-쿼리-타임아웃">분석 세션의 쿼리 타임아웃</h4>

<p>분석용 세션은 배치나 리포트 쿼리가 중간에 끊기지 않도록 타임아웃을 길게 잡습니다. 변수 이름과 단위는 엔진마다 다릅니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Aurora MySQL: 밀리초 단위이고, 읽기 전용 SELECT 에만 적용됩니다</span>
<span class="k">SET</span> <span class="k">SESSION</span> <span class="n">max_execution_time</span> <span class="o">=</span> <span class="mi">3600000</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Aurora PostgreSQL</span>
<span class="k">SET</span> <span class="n">statement_timeout</span> <span class="o">=</span> <span class="s1">'3600s'</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="24-단일-멤버-그룹의-가용성">2.4. 단일 멤버 그룹의 가용성</h3>

<p>이 예제의 분석용 <code class="language-plaintext highlighter-rouge">read-only</code>는 3번 하나만 정적으로 지정합니다. 성능 분리를 보여 주는 구성이지 분석 조회의 고가용성까지 보장하는 구성은 아닙니다.</p>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>새 연결에서 확인할 점</th>
      <th>운영 대응</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>3번이 정상 reader</td>
      <td>분석 그룹의 유일한 연결 대상</td>
      <td>평상시 연결·쿼리 상태를 기록합니다.</td>
    </tr>
    <tr>
      <td>3번이 재부팅·비정상 상태</td>
      <td>그룹에 사용 가능한 다른 reader가 없음</td>
      <td>작업을 중단·재시도할지, 사전에 정한 대체 경로를 쓸지 결정합니다.</td>
    </tr>
    <tr>
      <td>3번이 writer로 승격</td>
      <td><code class="language-plaintext highlighter-rouge">READER</code> 대상에서 제외됨</td>
      <td>낮은 승격 우선순위만으로 막을 수 없으므로 별도 대응이 필요합니다.</td>
    </tr>
    <tr>
      <td>새 분석용 reader를 추가</td>
      <td>정적 목록은 자동 확장되지 않음</td>
      <td>정적 멤버 목록도 함께 수정합니다.</td>
    </tr>
  </tbody>
</table>

<p>분석 작업의 가용성이 필요하면 서로 다른 가용 영역에 복수의 분석용 reader를 두고 같은 그룹에 명시적으로 포함하는 방법을 검토합니다. 단, 여러 인스턴스의 동시 장애까지 없애 주는 것은 아니므로 재시도·복구 정책도 필요합니다.<sup id="fnref:ha:2" role="doc-noteref"><a href="#fn:ha" class="footnote" rel="footnote">6</a></sup><sup id="fnref:membership:5" role="doc-noteref"><a href="#fn:membership" class="footnote" rel="footnote">2</a></sup></p>

<p>기본 reader 엔드포인트로 무조건 대체하면 분석 쿼리가 서비스용 reader로 갈 수 있고, reader가 전혀 없으면 writer로 연결될 수 있습니다. 원래의 워크로드 분리 목적을 유지할지, 장애 시에는 조회를 중단할지 먼저 정합니다.<sup id="fnref:reader:1" role="doc-noteref"><a href="#fn:reader" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="3-커스텀-엔드포인트-활용의-장점">3. 커스텀 엔드포인트 활용의 장점</h2>

<h3 id="31-성능-최적화">3.1. 성능 최적화</h3>

<p><strong>워크로드 분리</strong></p>

<ul>
  <li>장시간 실행 쿼리와 빠른 응답 쿼리의 분리</li>
  <li>리소스 경합 최소화</li>
  <li>쿼리 타임아웃 설정의 유연성</li>
</ul>

<p><strong>버퍼 풀 분리</strong></p>

<p>인스턴스마다 버퍼 풀이 따로 있으므로, 분석용 인스턴스의 대용량 스캔이 서비스 인스턴스의 캐시를 밀어내지 않습니다.</p>

<p>이 분리는 주로 인스턴스의 CPU·메모리·캐시 경합을 줄이는 방법입니다. 클러스터 데이터와 스토리지는 공유하므로, 완전히 독립된 클러스터 수준의 성능·장애 격리를 의미하지는 않습니다.<sup id="fnref:ha:3" role="doc-noteref"><a href="#fn:ha" class="footnote" rel="footnote">6</a></sup></p>

<h3 id="32-운영-효율성">3.2. 운영 효율성</h3>

<p><strong>모니터링 및 관리</strong></p>

<ul>
  <li>워크로드별 독립적인 모니터링</li>
  <li>리소스 사용량 추적 용이</li>
  <li>장애 원인 신속 파악</li>
</ul>

<p><strong>확장성</strong></p>

<ul>
  <li>워크로드 그룹별 인스턴스 사양·멤버 수 조정</li>
  <li>인스턴스가 늘거나 줄어도 애플리케이션의 접속 정보는 그대로 유지</li>
  <li>트래픽 피크 대응</li>
</ul>

<p><strong>Aurora Auto Scaling의 범위</strong></p>

<p>기본 Aurora Auto Scaling은 커스텀 엔드포인트별 인스턴스 수를 따로 관리하는 기능이 아닙니다. reader CPU·연결 수를 사용하는 사전 정의 지표는 <strong>클러스터의 모든 reader 평균</strong>을 기준으로 하며, 수동으로 만든 reader와 커스텀 엔드포인트 소속 reader도 포함합니다.<sup id="fnref:autoscaling" role="doc-noteref"><a href="#fn:autoscaling" class="footnote" rel="footnote">7</a></sup></p>

<p>따라서 분석용 3번의 CPU 상승이 클러스터 평균을 올리고, 그 결과 새 reader가 추가될 수 있습니다. 이 글의 서비스 그룹은 3번만 제외하므로 새 reader가 서비스 그룹에 들어가지만, 정적 목록인 분석 그룹에는 자동으로 들어가지 않습니다. 분석 부하로 증설했는데 분석 그룹의 용량은 그대로인 상황을 검토해야 합니다.</p>

<p>그룹별로 용량을 따로 늘려야 한다면 인스턴스 생성·멤버 목록 변경을 함께 관리하는 운영 절차나 별도 자동화가 필요합니다. Auto Scaling 정책을 만들었다는 사실만으로 그룹별 독립 확장이 완성되지는 않습니다.</p>

<h3 id="33-비용-최적화">3.3. 비용 최적화</h3>

<ul>
  <li>별도의 프록시 계층을 두지 않으므로 그 계층의 요금과 운영 부담이 없습니다.</li>
  <li>워크로드에 맞는 인스턴스 클래스를 그룹별로 따로 고를 수 있습니다.</li>
</ul>

<h3 id="34-보안">3.4. 보안</h3>

<p><strong>커스텀 엔드포인트는 보안 경계가 아닙니다.</strong> Aurora의 보안 그룹 변경은 같은 클러스터의 모든 DB 인스턴스에 적용됩니다. 같은 클러스터의 분석용 인스턴스와 서비스용 인스턴스에 서로 다른 보안 그룹 경계를 둔 것처럼 설명하면 안 됩니다.<sup id="fnref:security" role="doc-noteref"><a href="#fn:security" class="footnote" rel="footnote">8</a></sup></p>

<p>워크로드별 계정·DB 권한을 별도로 정하고, 읽기 애플리케이션에는 필요한 조회 권한만 부여합니다. <code class="language-plaintext highlighter-rouge">read-only</code>라는 DNS 이름이나 <code class="language-plaintext highlighter-rouge">READER</code> 라우팅 타입이 DB 계정의 쓰기 권한을 제거하는 것은 아닙니다. 페일오버 전부터 열려 있던 연결도 고려해야 합니다.</p>

<p>IAM 데이터베이스 인증을 사용하더라도 네트워크 접근, 접속 인증, DB 안에서 실행할 수 있는 SQL 권한은 각각 확인합니다. 네트워크 수준의 분리가 요구된다면 커스텀 엔드포인트만으로 충족했다고 보지 말고 클러스터·네트워크 설계까지 검토합니다.<sup id="fnref:security:1" role="doc-noteref"><a href="#fn:security" class="footnote" rel="footnote">8</a></sup></p>

<h2 id="4-운영-및-모니터링">4. 운영 및 모니터링</h2>

<h3 id="41-핵심-모니터링-지표">4.1. 핵심 모니터링 지표</h3>

<ul>
  <li>CPU 사용률</li>
  <li>메모리 사용량</li>
  <li>IOPS</li>
  <li>지연 시간</li>
  <li>쓰레드 수</li>
  <li>연결 수</li>
</ul>

<h3 id="42-알림-임계값-예시">4.2. 알림 임계값 예시</h3>

<p>아래 값은 출발점입니다. 인스턴스 클래스와 쿼리 특성에 맞춰 조정합니다. 특히 분석용 인스턴스는 CPU가 오래 높게 유지되는 것이 정상이므로, 서비스 인스턴스와 같은 기준을 쓰면 알림이 계속 울립니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>성능 관련
- CPU 사용률 &gt; 80%
- 가용 메모리 &lt; 20%
- 평균 지연 시간 &gt; 100ms

연결 관련
- 활성 연결 수 &gt; 설정된 임계값
- 연결 거부 발생
- 연결 타임아웃 발생
</code></pre></div></div>

<blockquote>
  <p><strong>WARNING</strong> — 커스텀 엔드포인트는 스냅샷에 포함되지 않습니다. 스냅샷으로 클러스터를 복원하면 엔드포인트를 다시 만들어야 하고, 원본과 같은 리전에 복원했다면 이름도 새로 지어야 합니다. 복원 절차를 문서로 남길 때 이 단계를 함께 적어 두는 편이 좋습니다.</p>
</blockquote>

<h3 id="43-변경장애-시-검증할-항목">4.3. 변경·장애 시 검증할 항목</h3>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>확인할 내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>콘솔 생성 후 READER 전환</td>
      <td><code class="language-plaintext highlighter-rouge">CustomEndpointType=READER</code>, 변경 완료 상태, 새 연결의 인스턴스</td>
    </tr>
    <tr>
      <td>서비스 reader가 writer로 승격</td>
      <td>새 연결 대상에서 제외되는지, 기존 연결·실행 중 쿼리가 어떻게 종료·복구되는지</td>
    </tr>
    <tr>
      <td>유일한 분석 reader의 재부팅</td>
      <td>분석 작업의 타임아웃·재시도·중단 정책이 동작하는지</td>
    </tr>
    <tr>
      <td>reader 추가 또는 Auto Scaling</td>
      <td>서비스 제외 목록과 분석 정적 목록에 예상대로 반영되는지</td>
    </tr>
    <tr>
      <td>그룹에서 인스턴스 제거</td>
      <td>새 연결의 대상과 기존 풀 연결의 잔류를 구분했는지</td>
    </tr>
    <tr>
      <td>스냅샷 복원</td>
      <td>엔드포인트 재생성, 새 DNS 주소 적용, 계정·TLS·그룹별 접속 확인</td>
    </tr>
  </tbody>
</table>

<p>CPU·연결 수·쿼리 지연은 실제 연결된 DB 인스턴스와 애플리케이션 풀 단위로 대조합니다. 커스텀 엔드포인트 하나의 상태만 보고 그룹 전체가 정상이라고 판단하지 않습니다.</p>

<h2 id="5-결론-및-베스트-프랙티스">5. 결론 및 베스트 프랙티스</h2>

<p>AWS Aurora의 커스텀 엔드포인트를 활용하면 별도 SQL Proxy 없이 내부 조회용 인스턴스와 서비스 읽기 인스턴스 그룹을 나눌 수 있습니다. 분석과 서비스 조회의 인스턴스 자원 경합을 줄이되, 역할 변경·기존 연결·단일 멤버 장애·공유 클러스터의 제약까지 함께 설계해야 합니다.</p>

<h3 id="51-설계-원칙">5.1. 설계 원칙</h3>

<ul>
  <li>워크로드 특성에 따른 명확한 분리</li>
  <li>한 엔드포인트 안의 인스턴스는 사양과 파라미터를 맞춰 구성</li>
  <li>앞으로 추가될 인스턴스를 어느 엔드포인트가 받을지 정적 목록과 제외 목록으로 미리 결정</li>
  <li>페일오버로 역할이 바뀔 때의 멤버십 동작을 엔드포인트 타입으로 결정</li>
  <li>승격 우선순위를 승격 금지로, 연결 대상 분리를 보안 격리로 해석하지 않기</li>
  <li>Auto Scaling 지표와 새 reader의 그룹 편입 규칙을 함께 확인</li>
</ul>

<h3 id="52-운영-체크리스트">5.2. 운영 체크리스트</h3>

<ul>
  <li>정기적인 성능 모니터링</li>
  <li>보안 설정 검토</li>
  <li>비용 최적화 리뷰</li>
  <li>장애 대응 시나리오 검증</li>
  <li>스냅샷 복원 절차에 엔드포인트 재생성 포함</li>
  <li>변경 완료 상태뿐 아니라 새 연결의 실제 접속 인스턴스 확인</li>
  <li>단일 멤버 장애와 기존 연결 풀의 잔류에 대한 대응 확인</li>
</ul>

<h2 id="참고-자료">참고 자료</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:reader" role="doc-endnote">
      <p>Amazon Aurora User Guide — Reader endpoints for Amazon Aurora. reader가 없을 때의 writer 연결. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Endpoints.Reader.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Endpoints.Reader.html</a> <a href="#fnref:reader" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:reader:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:membership" role="doc-endnote">
      <p>Amazon Aurora User Guide — Membership rules for custom endpoints. ANY·READER, 정적·제외 목록, 기존 연결과 역할 변경. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Endpoints.Custom.Considerations.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Endpoints.Custom.Considerations.html</a> <a href="#fnref:membership" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:membership:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:membership:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:membership:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a> <a href="#fnref:membership:4" class="reversefootnote" role="doc-backlink">&#8617;<sup>5</sup></a> <a href="#fnref:membership:5" class="reversefootnote" role="doc-backlink">&#8617;<sup>6</sup></a></p>
    </li>
    <li id="fn:tutorial" role="doc-endnote">
      <p>Amazon Aurora User Guide — Using custom endpoints. 구성 예제, 승격 우선순위, 접속 인스턴스 확인. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Endpoint.Tutorial.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Endpoint.Tutorial.html</a> <a href="#fnref:tutorial" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:tutorial:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:tutorial:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:tutorial:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:modify" role="doc-endnote">
      <p>AWS CLI Reference — modify-db-cluster-endpoint. 엔드포인트 타입과 멤버 설정 변경. <a href="https://docs.aws.amazon.com/cli/latest/reference/rds/modify-db-cluster-endpoint.html">https://docs.aws.amazon.com/cli/latest/reference/rds/modify-db-cluster-endpoint.html</a> <a href="#fnref:modify" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:describe" role="doc-endnote">
      <p>AWS CLI Reference — describe-db-cluster-endpoints. 상태·CustomEndpointType·설정 목록 조회. <a href="https://docs.aws.amazon.com/cli/latest/reference/rds/describe-db-cluster-endpoints.html">https://docs.aws.amazon.com/cli/latest/reference/rds/describe-db-cluster-endpoints.html</a> <a href="#fnref:describe" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ha" role="doc-endnote">
      <p>Amazon Aurora User Guide — High availability for Amazon Aurora. 승격 우선순위와 공유 스토리지 구조. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Concepts.AuroraHighAvailability.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Concepts.AuroraHighAvailability.html</a> <a href="#fnref:ha" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:ha:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:ha:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:ha:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:autoscaling" role="doc-endnote">
      <p>Amazon Aurora User Guide — Amazon Aurora Auto Scaling with Aurora Replicas. reader 평균 지표와 조정 범위. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Integrating.AutoScaling.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Integrating.AutoScaling.html</a> <a href="#fnref:autoscaling" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:security" role="doc-endnote">
      <p>Amazon Aurora User Guide — Controlling access with security groups. 클러스터 내 모든 인스턴스에 적용되는 보안 그룹. <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Overview.RDSSecurityGroups.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Overview.RDSSecurityGroups.html</a> <a href="#fnref:security" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:security:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="mysql" /><summary type="html"><![CDATA[Aurora 커스텀 엔드포인트로 조회 워크로드를 분리하고, READER 전환·페일오버·연결 풀·Auto Scaling·보안 경계에서 확인할 사항을 정리합니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://rastalion.dev/assets/img/og-default.png" /><media:content medium="image" url="https://rastalion.dev/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>