<?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-09-20T07:04:56+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">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><entry><title type="html">MySQL의 TLS/SSL: 내부 네트워크에서 정말 필요할까?</title><link href="https://rastalion.dev/writing/mysql-tls-ssl-internal-network/" rel="alternate" type="text/html" title="MySQL의 TLS/SSL: 내부 네트워크에서 정말 필요할까?" /><published>2024-07-29T12:00:01+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/mysql-tls-ssl-internal-network</id><content type="html" xml:base="https://rastalion.dev/writing/mysql-tls-ssl-internal-network/"><![CDATA[<h2 id="mysql의-암호화-통신">MySQL의 암호화 통신</h2>

<p>데이터베이스로 가는 트래픽을 암호화하라는 권고는 어디서나 볼 수 있습니다. 그런데 AWS VPC나 사내 내부망처럼 외부 접근이 통제된 구간이라면 어떨까요? 애플리케이션 서버와 MySQL 사이에도 TLS를 걸어야 할까요?</p>

<p>이 글은 그 질문을 세 축으로 나눠 봅니다. 법령이 실제로 무엇을 요구하는지, 암호화가 막는 위협이 그 구간에 있는지, 켜기로 정했을 때 무엇을 설정해야 하는지입니다.</p>

<blockquote>
  <p><strong>NOTE</strong> — 2026-09 현재 지원되는 MySQL 라인은 8.4 LTS와 9.7 LTS입니다. 8.0은 2026-04-21부로 Oracle Sustaining Support로 넘어갔습니다. 이 글의 설정값은 8.4 기준입니다.</p>
</blockquote>

<h2 id="1-tlsssl의-기본-개념">1. TLS/SSL의 기본 개념</h2>

<p>TLS는 네트워크 구간에 세 가지를 제공합니다.</p>

<ul>
  <li><strong>기밀성</strong> — 데이터를 암호화해 제3자가 읽지 못하게 합니다.</li>
  <li><strong>무결성</strong> — 전송 중 변경·유실·재전송을 탐지합니다.</li>
  <li><strong>인증</strong> — X.509 인증서로 통신 상대의 신원을 확인합니다.</li>
</ul>

<p>이름부터 짚고 갑니다. MySQL 공식 문서는 TLS를 SSL이라고 부르는 관행을 인정하면서도, SSL의 암호화가 약하기 때문에 MySQL이 실제로 SSL 프로토콜을 쓰지는 않는다고 명시합니다. 설정 변수와 상태 변수에 <code class="language-plaintext highlighter-rouge">ssl_</code> 접두어가 남아 있는 것은 역사적 이유입니다.</p>

<h3 id="지금-이-연결이-암호화되고-있는가">지금 이 연결이 암호화되고 있는가</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 값이 비어 있으면 이 연결은 암호화되지 않았습니다</span>
<span class="k">SHOW</span> <span class="n">STATUS</span> <span class="k">LIKE</span> <span class="s1">'ssl_cipher'</span><span class="p">;</span>

<span class="c1">-- 어떤 프로토콜로 붙었는지까지 확인합니다</span>
<span class="k">SHOW</span> <span class="k">SESSION</span> <span class="n">STATUS</span> <span class="k">LIKE</span> <span class="s1">'Ssl_version'</span><span class="p">;</span>
</code></pre></div></div>

<p>두 값 모두 지금 세션에 대한 값입니다. <code class="language-plaintext highlighter-rouge">SHOW STATUS</code>는 범위를 생략하면 <code class="language-plaintext highlighter-rouge">SESSION</code>입니다.</p>

<h3 id="84가-받아들이는-프로토콜">8.4가 받아들이는 프로토콜</h3>

<p>MySQL 8.4는 TLSv1.2와 TLSv1.3만 지원합니다. TLSv1과 TLSv1.1은 8.0.26에서 폐기 예고됐고 8.0.28에서 제거됐습니다. 문서는 두 프로토콜이 쓰는 알고리즘이 약하고 낡았다는 이유를 적습니다.</p>

<p><code class="language-plaintext highlighter-rouge">tls_version</code>의 기본값은 서버가 링크한 SSL 라이브러리와 해당 릴리스가 지원하는 프로토콜 전체이고, 8.4에서는 보통 <code class="language-plaintext highlighter-rouge">TLSv1.2,TLSv1.3</code>입니다. TLSv1.3은 OpenSSL 1.1.1 이상으로 컴파일된 서버에서만 쓸 수 있습니다. 서버는 기동할 때 OpenSSL 버전을 확인해 1.1.1보다 낮으면 기본값에서 TLSv1.3을 빼고 <code class="language-plaintext highlighter-rouge">TLSv1.2</code>만 남깁니다.</p>

<h3 id="켜지-않았는데-켜져-있을-수-있습니다">켜지 않았는데 켜져 있을 수 있습니다</h3>

<p>서버는 인증서와 키 파일을 자동으로 찾습니다. 데이터 디렉터리에 <code class="language-plaintext highlighter-rouge">ca.pem</code>·<code class="language-plaintext highlighter-rouge">server-cert.pem</code>·<code class="language-plaintext highlighter-rouge">server-key.pem</code>이라는 이름의 유효한 파일이 있으면 암호화 연결 지원을 스스로 켜고 에러 로그에 기록을 남깁니다. OpenSSL로 컴파일한 서버는 이 파일이 없을 때 기동 시점에 자동 생성할 수도 있습니다. 클라이언트 기본값도 같은 방향입니다. MySQL 클라이언트 프로그램은 서버가 지원하면 암호화 연결을 시도하고, 맺지 못하면 평문으로 내려갑니다.</p>

<p>그래서 “우리는 TLS를 안 씁니다”가 사실이 아닐 수 있고, “TLS를 켰습니다”가 모든 연결에 대해 참이 아닐 수도 있습니다. 논의의 출발점은 위 상태 변수로 실제 연결을 확인하는 것입니다.</p>

<h2 id="2-내부-네트워크에서의-tlsssl-장단점-분석">2. 내부 네트워크에서의 TLS/SSL: 장단점 분석</h2>

<h3 id="장점">장점</h3>

<ol>
  <li><strong>기밀성 보장</strong> — 같은 세그먼트에 올라온 스니핑과 중간자 공격을 막습니다.</li>
  <li><strong>심층 방어</strong> — 네트워크 통제가 무너졌을 때 남는 계층입니다. 저장 데이터 암호화는 이 구간을 보호하지 않습니다. MySQL 문서는 데이터가 네트워크에 올라간 시점에는 평문 형태라고 명시합니다.</li>
  <li><strong>규정 준수</strong> — 적용받는 기준이 전송 구간 암호화를 요구하는 경우입니다(3절).</li>
</ol>

<h3 id="단점">단점</h3>

<ol>
  <li><strong>성능 오버헤드</strong> — 암복호에 CPU를 쓰고 지연이 늘어납니다. 비용이 몰리는 지점은 핸드셰이크입니다(5절).</li>
  <li><strong>인증서 수명 관리</strong> — 발급·배포·갱신이 운영 업무로 남고, 만료는 장애로 옵니다.</li>
  <li><strong>관찰 비용</strong> — 패킷 캡처로 쿼리를 들여다보던 방식이 막힙니다.</li>
</ol>

<h2 id="3-법령은-무엇을-요구하는가">3. 법령은 무엇을 요구하는가</h2>

<p>국내에서 전송 구간 암호화 의무의 근거는 「개인정보의 안전성 확보조치 기준」 제7조입니다. 현행 고시는 개인정보보호위원회고시 제2026-9호이고 2026-07-01 시행입니다. 상위 근거는 시행령 제30조제1항제4호 다목입니다.</p>

<p>제7조에서 전송 암호화를 요구하는 항은 둘이고, 적용 구간이 서로 다릅니다.</p>

<table>
  <thead>
    <tr>
      <th>항</th>
      <th>대상</th>
      <th>구간</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>제1항</td>
      <td>비밀번호·생체인식정보 등 인증정보</td>
      <td>정보통신망 송수신 시, 구간 제한 없음</td>
    </tr>
    <tr>
      <td>제4항</td>
      <td>개인정보</td>
      <td>정보통신망을 통해 인터넷망 구간으로 송·수신할 때</td>
    </tr>
  </tbody>
</table>

<p>이 차이가 질문의 핵심입니다. 제4항은 인터넷망 구간을 전제하므로, 문언대로 읽으면 내부망 안의 애플리케이션 서버와 DB 사이 구간은 제4항의 범위 밖입니다. 고시 제2조제12호는 내부망을 인터넷망 차단이나 접근 통제시스템으로 인터넷 구간에서의 접근이 통제·차단되는 구간으로 정의합니다. DB 연결에 TLS를 쓰라고 직접 요구하는 조문은 없습니다.</p>

<p>그런데 제1항은 구간을 제한하지 않습니다. DB 접속은 그 자체로 인증정보를 실어 보냅니다. 내부망이라도 인증정보가 같은 연결을 타고 흐른다면 제1항이 그 구간에 걸립니다. 조문만 근거로 “내부망이니 켜지 않아도 된다”고 정리하기 어려운 이유가 여기 있습니다.</p>

<p>조문 좌표와 이용자·비이용자 트랙 구분은 <a href="/docs/database/encryption/">데이터 암호화</a> 문서에 정리해 두었습니다.</p>

<p>조문과 별개로, 내부망이어도 TLS를 켜는 쪽으로 기울게 하는 조건이 있습니다.</p>

<ol>
  <li><strong>멀티 테넌트 환경</strong> — 같은 VPC나 클러스터에서 여러 고객의 데이터를 처리하는 경우</li>
  <li><strong>민감도가 높은 데이터</strong> — 결제 정보나 의료 기록처럼 유출 시 피해가 큰 데이터를 다루는 경우</li>
  <li><strong>복잡한 네트워크 토폴로지</strong> — 여러 세그먼트·리전·계정을 지나 어느 구간이 인터넷망인지 단정하기 어려운 경우</li>
  <li><strong>일관된 보안 정책</strong> — 개발·테스트·운영에서 같은 설정을 유지하려는 경우. 운영에만 켜 두면 인증서 문제를 배포 시점에 발견합니다.</li>
</ol>

<h2 id="4-tlsssl-없이-보안을-강화하는-방법">4. TLS/SSL 없이 보안을 강화하는 방법</h2>

<p>내부망에서 TLS를 켜지 않기로 정한다면, 그 판단을 받쳐 줄 통제가 필요합니다.</p>

<ol>
  <li><strong>네트워크 분리</strong> — VLAN, 서브넷, NACL로 데이터베이스 트래픽을 격리합니다.</li>
  <li><strong>강력한 인증</strong> — 다단계 인증(MFA)과 IAM 역할을 사용합니다.</li>
  <li><strong>암호화된 스토리지</strong> — 저장 데이터(data at rest) 암호화를 사용합니다.</li>
  <li><strong>최소 권한 원칙</strong> — 필요한 최소한의 권한만 부여합니다.</li>
  <li><strong>네트워크 모니터링</strong> — 이상 징후를 탐지하기 위한 실시간 모니터링을 구현합니다.</li>
</ol>

<p>AWS 환경에서는 다음과 같이 구성할 수 있습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">VPC</span><span class="pi">:</span>
  <span class="na">SecurityGroups</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">InboundRule</span><span class="pi">:</span>
        <span class="na">Protocol</span><span class="pi">:</span> <span class="s">TCP</span>
        <span class="na">Port</span><span class="pi">:</span> <span class="m">3306</span>
        <span class="na">Source</span><span class="pi">:</span> <span class="s">10.0.0.0/16</span>  <span class="c1"># 내부 네트워크만 허용</span>
  <span class="na">NetworkACLs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">Rule</span><span class="pi">:</span>
        <span class="na">Action</span><span class="pi">:</span> <span class="s">ALLOW</span>
        <span class="na">Protocol</span><span class="pi">:</span> <span class="s">TCP</span>
        <span class="na">Port</span><span class="pi">:</span> <span class="m">3306</span>
        <span class="na">Source</span><span class="pi">:</span> <span class="s">10.0.0.0/16</span>  <span class="c1"># 내부 네트워크만 허용</span>

<span class="na">RDS</span><span class="pi">:</span>
  <span class="na">EncryptionAtRest</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">IAMDatabaseAuthentication</span><span class="pi">:</span> <span class="s">enabled</span>

<span class="na">CloudWatch</span><span class="pi">:</span>
  <span class="na">Alarms</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">Metric</span><span class="pi">:</span> <span class="s">DatabaseConnections</span>
      <span class="na">Threshold</span><span class="pi">:</span> <span class="m">100</span>
      <span class="na">Period</span><span class="pi">:</span> <span class="s">5 minutes</span>
</code></pre></div></div>

<p>이 조치들은 네트워크에 올라간 평문 자체를 바꾸지 않습니다. TLS를 대체하는 것이 아니라, 접근 경로를 좁혀 위험을 낮추는 쪽입니다.</p>

<h2 id="5-켤-때-확인할-설정과-성능">5. 켤 때 확인할 설정과 성능</h2>

<h3 id="강제-지점은-셋입니다">강제 지점은 셋입니다</h3>

<p><code class="language-plaintext highlighter-rouge">require_secure_transport</code>는 Boolean이고 기본값은 <code class="language-plaintext highlighter-rouge">OFF</code>입니다. 켜면 서버는 TLS로 암호화된 TCP/IP 연결, Unix 소켓 파일 연결, Windows 공유 메모리 연결만 허용하고 나머지는 <code class="language-plaintext highlighter-rouge">ER_SECURE_TRANSPORT_REQUIRED</code>로 거절합니다. 이 변수만으로는 모든 연결이 TLS라고 말할 수 없습니다. 소켓 파일 접속은 통과합니다.</p>

<p>계정별 요구가 이 변수보다 우선합니다. 문서는 <code class="language-plaintext highlighter-rouge">REQUIRE SSL</code>로 만든 계정이라면 <code class="language-plaintext highlighter-rouge">require_secure_transport</code>를 켜도 Unix 소켓 파일로는 접속할 수 없다고 적습니다.</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[mysqld]</span>
<span class="py">require_secure_transport</span><span class="p">=</span><span class="s">ON</span>
<span class="py">tls_version</span><span class="p">=</span><span class="s">TLSv1.2,TLSv1.3</span>
</code></pre></div></div>

<p>클라이언트 쪽은 <code class="language-plaintext highlighter-rouge">--ssl-mode</code>로 갈립니다.</p>

<table>
  <thead>
    <tr>
      <th>값</th>
      <th>동작</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DISABLED</code></td>
      <td>평문으로 접속하고 다른 <code class="language-plaintext highlighter-rouge">--ssl-xxx</code> 옵션을 무시합니다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PREFERRED</code></td>
      <td>기본값. 암호화를 시도하고 실패하면 평문으로 내려갑니다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">REQUIRED</code></td>
      <td>암호화 연결을 맺지 못하면 실패합니다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">VERIFY_CA</code></td>
      <td>암호화를 요구하고 서버 CA 인증서를 검증합니다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">VERIFY_IDENTITY</code></td>
      <td>여기에 인증서의 호스트 이름 일치까지 확인합니다</td>
    </tr>
  </tbody>
</table>

<p>문서는 기본값 <code class="language-plaintext highlighter-rouge">PREFERRED</code>가 서버 신원을 확인하지 않는다는 점을 짚고, 정교한 중간자 공격을 막으려면 <code class="language-plaintext highlighter-rouge">VERIFY_CA</code>나 <code class="language-plaintext highlighter-rouge">VERIFY_IDENTITY</code>를 쓰라고 권합니다. 두 값이 기본이 아닌 이유는 모든 클라이언트에 CA 인증서를 안정적으로 배포하지 않으면 가용성 문제가 생기기 때문입니다. 서버가 자동 생성한 자기서명 인증서에는 Common Name에 서버 이름이 없어 <code class="language-plaintext highlighter-rouge">VERIFY_IDENTITY</code>가 동작하지 않으므로, 1절의 자동 발견으로 켜진 TLS를 그대로 두면 신원 검증까지는 가지 못합니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 900 528" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="tls-enforcement-archify-diagram-title">MySQL 접속이 통과하는 TLS 강제 지점</title>
        <desc id="tls-enforcement-archify-diagram-description">A workflow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="tls-enforcement-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="tls-enforcement-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="tls-enforcement-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="tls-enforcement-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="tls-enforcement-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(#tls-enforcement-grid)" />

        <!-- Swimlanes -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-0" x="40" y="52" width="640" 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="640" 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="640" 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="attempt" data-edge-to="transport" data-edge-key="0" data-composition-points="88,145;88,243;168,243" d="M 88 145 L 88 243 L 168 243" class="a-default" stroke-width="1.4" marker-end="url(#tls-enforcement-arrowhead)" />
        <path data-edge-from="transport" data-edge-to="denied_tcp" data-edge-label="암호화 없음" data-edge-key="1" data-composition-points="220,269;220,290;220,290;220,341" d="M 220 269 L 220 290 L 220 290 L 220 341" class="a-default" stroke-width="1.4" marker-end="url(#tls-enforcement-arrowhead)" />
        <path data-edge-from="transport" data-edge-to="account" data-edge-key="2" data-composition-points="272,243;384,243" d="M 272 243 L 384 243" class="a-emphasis" stroke-width="1.8" marker-end="url(#tls-enforcement-arrowhead-emphasis)" />
        <path data-edge-from="account" data-edge-to="denied_socket" data-edge-label="소켓 접속" data-edge-key="3" data-composition-points="430,269;430,290;430,290;430,341" d="M 430 269 L 430 290 L 430 290 L 430 341" class="a-default" stroke-width="1.4" marker-end="url(#tls-enforcement-arrowhead)" />
        <path data-edge-from="account" data-edge-to="handshake" data-edge-key="4" data-composition-points="476,243;579,243" d="M 476 243 L 579 243" class="a-emphasis" stroke-width="1.8" marker-end="url(#tls-enforcement-arrowhead-emphasis)" />
        <path data-edge-from="handshake" data-edge-to="plaintext" data-edge-label="핸드셰이크 실패" data-edge-key="5" data-composition-points="625,269;625,290;625,290;625,341" d="M 625 269 L 625 290 L 625 290 L 625 341" class="a-default" stroke-width="1.4" marker-end="url(#tls-enforcement-arrowhead)" />
        <path data-edge-from="handshake" data-edge-to="encrypted" data-edge-label="REQUIRED 이상" data-edge-key="6" data-composition-points="625,217;625,166;625,166;625,145" d="M 625 217 L 625 166 L 625 166 L 625 145" class="a-emphasis" stroke-width="1.8" marker-end="url(#tls-enforcement-arrowhead-emphasis)" />

        <!-- Nodes -->
        <g id="tls-enforcement-node-attempt" data-node-id="attempt" data-node-label="접속 시도" tabindex="0" role="button" aria-label="Focus 접속 시도, --ssl-mode, 클라이언트" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="--ssl-mode" data-node-context="클라이언트">
          <title>접속 시도 · --ssl-mode · 클라이언트</title>
          <rect x="42" y="93" width="92" height="52" rx="6" class="c-mask" />
          <rect x="42" y="93" width="92" 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(48 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="88" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">접속 시도</text>
          <text data-detail="context" x="88" y="131" class="t-muted" font-size="8" text-anchor="middle">--ssl-mode</text>
        </g>

        <g id="tls-enforcement-node-encrypted" data-node-id="encrypted" data-node-label="암호화 연결" tabindex="0" role="button" aria-label="Focus 암호화 연결, VERIFY_CA 이상, 클라이언트" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="VERIFY_CA 이상" data-node-context="클라이언트">
          <title>암호화 연결 · VERIFY_CA 이상 · 클라이언트</title>
          <rect x="579" y="93" width="92" height="52" rx="6" class="c-mask" />
          <rect x="579" y="93" width="92" 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(585 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="625" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">암호화 연결</text>
          <text data-detail="context" x="625" y="131" class="t-muted" font-size="8" text-anchor="middle">VERIFY_CA 이상</text>
        </g>

        <g id="tls-enforcement-node-transport" data-node-id="transport" data-node-label="전송 방식" tabindex="0" role="button" aria-label="Focus 전송 방식, require_secure_transport, 서버·계정 강제" aria-pressed="false" data-node-kind="security" data-node-sublabel="require_secure_transport" data-node-context="서버·계정 강제">
          <title>전송 방식 · require_secure_transport · 서버·계정 강제</title>
          <rect x="168" y="217" width="104" height="52" rx="6" class="c-mask" />
          <rect x="168" y="217" width="104" 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(174 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="220" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">전송 방식</text>
          <text data-detail="context" x="220" y="255" class="t-muted" font-size="6.6" text-anchor="middle">require_secure_transport</text>
        </g>

        <g id="tls-enforcement-node-account" data-node-id="account" data-node-label="계정 요구" tabindex="0" role="button" aria-label="Focus 계정 요구, REQUIRE SSL, 서버·계정 강제" aria-pressed="false" data-node-kind="security" data-node-sublabel="REQUIRE SSL" data-node-context="서버·계정 강제">
          <title>계정 요구 · REQUIRE SSL · 서버·계정 강제</title>
          <rect x="384" y="217" width="92" height="52" rx="6" class="c-mask" />
          <rect x="384" y="217" width="92" 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(390 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="430" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">계정 요구</text>
          <text data-detail="context" x="430" y="255" class="t-muted" font-size="8" text-anchor="middle">REQUIRE SSL</text>
        </g>

        <g id="tls-enforcement-node-handshake" data-node-id="handshake" data-node-label="핸드셰이크" tabindex="0" role="button" aria-label="Focus 핸드셰이크, tls_version, 서버·계정 강제" aria-pressed="false" data-node-kind="backend" data-node-sublabel="tls_version" data-node-context="서버·계정 강제">
          <title>핸드셰이크 · tls_version · 서버·계정 강제</title>
          <rect x="579" y="217" width="92" height="52" rx="6" class="c-mask" />
          <rect x="579" y="217" width="92" 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(585 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="625" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">핸드셰이크</text>
          <text data-detail="context" x="625" y="255" class="t-muted" font-size="8" text-anchor="middle">tls_version</text>
        </g>

        <g id="tls-enforcement-node-denied_tcp" data-node-id="denied_tcp" data-node-label="전송 거절" tabindex="0" role="button" aria-label="Focus 전송 거절, 소켓은 통과한다, 거절과 평문" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="소켓은 통과한다" data-node-context="거절과 평문">
          <title>전송 거절 · 소켓은 통과한다 · 거절과 평문</title>
          <rect x="174" y="341" width="92" height="52" rx="6" class="c-mask" />
          <rect x="174" y="341" width="92" 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(180 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="" data-detail-anchor="" x="220" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">전송 거절</text>
          <text data-detail="context" x="220" y="379" class="t-muted" font-size="8" text-anchor="middle">소켓은 통과한다</text>
        </g>

        <g id="tls-enforcement-node-denied_socket" data-node-id="denied_socket" data-node-label="계정 거절" tabindex="0" role="button" aria-label="Focus 계정 거절, 소켓도 막힌다, 거절과 평문" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="소켓도 막힌다" data-node-context="거절과 평문">
          <title>계정 거절 · 소켓도 막힌다 · 거절과 평문</title>
          <rect x="384" y="341" width="92" height="52" rx="6" class="c-mask" />
          <rect x="384" y="341" width="92" 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(390 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="" data-detail-anchor="" x="430" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">계정 거절</text>
          <text data-detail="context" x="430" y="379" class="t-muted" font-size="8" text-anchor="middle">소켓도 막힌다</text>
        </g>

        <g id="tls-enforcement-node-plaintext" data-node-id="plaintext" data-node-label="평문 연결" tabindex="0" role="button" aria-label="Focus 평문 연결, PREFERRED 로 내려감, 거절과 평문" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="PREFERRED 로 내려감" data-node-context="거절과 평문">
          <title>평문 연결 · PREFERRED 로 내려감 · 거절과 평문</title>
          <rect x="579" y="341" width="92" height="52" rx="6" class="c-mask" />
          <rect x="579" y="341" width="92" 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(585 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="" data-detail-anchor="" x="625" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">평문 연결</text>
          <text data-detail="context" x="625" y="379" class="t-muted" font-size="7.3" text-anchor="middle">PREFERRED 로 내려감</text>
        </g>

        <!-- Edge labels -->

        <g data-detail="context" data-edge-from="transport" data-edge-to="denied_tcp" data-edge-label="암호화 없음" data-edge-key="1">
          <rect x="188.6" y="270" width="62.8" height="14" rx="3" class="c-mask" />
          <text x="220" y="280" class="t-muted" font-size="8" text-anchor="middle">암호화 없음</text>
        </g>

        <g data-detail="context" data-edge-from="account" data-edge-to="denied_socket" data-edge-label="소켓 접속" data-edge-key="3">
          <rect x="403.4" y="270" width="53.199999999999996" height="14" rx="3" class="c-mask" />
          <text x="430" y="280" class="t-muted" font-size="8" text-anchor="middle">소켓 접속</text>
        </g>

        <g data-detail="context" data-edge-from="handshake" data-edge-to="plaintext" data-edge-label="핸드셰이크 실패" data-edge-key="5">
          <rect x="584" y="270" width="82" height="14" rx="3" class="c-mask" />
          <text x="625" y="280" class="t-muted" font-size="8" text-anchor="middle">핸드셰이크 실패</text>
        </g>
        <g data-detail="context" data-edge-from="handshake" data-edge-to="encrypted" data-edge-label="REQUIRED 이상" data-edge-key="6">
          <rect x="588.8" y="146" width="72.4" height="14" rx="3" class="c-mask" />
          <text x="625" y="156" class="t-backend" font-size="8" text-anchor="middle">REQUIRED 이상</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="87">
            <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="114" data-legend-baseline="448" data-legend-width="87">
            <rect x="114" y="440" width="14" height="9" rx="2" class="c-backend" stroke-width="1" />
            <text x="136" 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="208" data-legend-baseline="448" data-legend-width="104">
            <rect x="208" y="440" width="14" height="9" rx="2" class="c-security" stroke-width="1" />
            <text x="230" 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="319" data-legend-baseline="448" data-legend-width="83">
            <rect x="319" y="440" width="14" height="9" rx="2" class="c-cloud" stroke-width="1" />
            <text x="341" y="448" class="t-muted" font-size="7.5" font-weight="500">약한 결과</text>
          </g>
        </g>
      </svg>
</div><figcaption>세 강제 지점과 각 지점에서 갈라지는 약한 결과</figcaption>
</figure>

<p>세 지점을 다 지나야 암호화 연결입니다. 하나만 켜 두면 아래 칸의 결과 가운데 하나로 떨어지고, 그중 평문 연결은 오류 없이 조용히 맺힙니다.</p>

<p>암호군은 프로토콜별로 변수가 다릅니다. TLSv1.2는 서버 <code class="language-plaintext highlighter-rouge">ssl_cipher</code>와 클라이언트 <code class="language-plaintext highlighter-rouge">--ssl-cipher</code>, TLSv1.3은 서버 <code class="language-plaintext highlighter-rouge">tls_ciphersuites</code>와 클라이언트 <code class="language-plaintext highlighter-rouge">--tls-ciphersuites</code>입니다. <code class="language-plaintext highlighter-rouge">tls_ciphersuites</code>를 설정하지 않으면 기본값 <code class="language-plaintext highlighter-rouge">NULL</code>로 기본 집합을 허용하고, 빈 문자열로 두면 활성 암호군이 없어 암호화 연결을 맺을 수 없습니다.</p>

<p>대체로 기본값을 그대로 쓰는 편이 안전합니다. 8.4가 TLSv1.2에 기본으로 넘기는 암호군은 ECDHE·DHE 키 교환과 GCM·CCM·CHACHA20-POLY1305 조합뿐입니다. 순방향 비밀성과 AEAD 모드를 갖춘 것만 켜 둔 셈입니다. 반대로 <code class="language-plaintext highlighter-rouge">aNULL</code>·<code class="language-plaintext highlighter-rouge">eNULL</code>·<code class="language-plaintext highlighter-rouge">EXPORT</code>·<code class="language-plaintext highlighter-rouge">LOW</code>·<code class="language-plaintext highlighter-rouge">MD5</code>·<code class="language-plaintext highlighter-rouge">DES</code>·<code class="language-plaintext highlighter-rouge">RC2</code>·<code class="language-plaintext highlighter-rouge">RC4</code>·<code class="language-plaintext highlighter-rouge">PSK</code>·<code class="language-plaintext highlighter-rouge">SSLv3</code>은 영구 제한입니다. <code class="language-plaintext highlighter-rouge">ssl_cert</code>에 지정한 인증서가 제한된 암호군을 쓰면 서버는 암호화 연결 지원을 끈 상태로 기동합니다.</p>

<h3 id="인증-플러그인이-이미-전송-보안을-전제합니다">인증 플러그인이 이미 전송 보안을 전제합니다</h3>

<p>8.4의 기본 인증 플러그인은 <code class="language-plaintext highlighter-rouge">caching_sha2_password</code>입니다. 이 플러그인으로 접속하려면 보안 연결이거나, RSA 키페어로 비밀번호를 교환하는 평문 연결이어야 합니다. 보안 연결이면 RSA 키페어가 필요 없고, 평문 연결이면 비밀번호만 RSA로 암호화해 보냅니다. 서버는 기본적으로 공개키를 클라이언트에 보내지 않으므로, 아무 준비 없이 평문으로 붙으면 이렇게 끊깁니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR 2061 (HY000): Authentication plugin 'caching_sha2_password'
reported error: Authentication requires secure connection.
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--get-server-public-key</code>로 서버에서 공개키를 받거나 <code class="language-plaintext highlighter-rouge">--server-public-key-path</code>로 로컬 사본을 지정하면 통과합니다. 한 번 성공하면 서버 캐시에 항목이 남아 이후 연결은 보안 연결도 RSA 키페어도 요구하지 않습니다. 계정 생성, 비밀번호 변경, <code class="language-plaintext highlighter-rouge">RENAME USER</code>, <code class="language-plaintext highlighter-rouge">FLUSH PRIVILEGES</code> 뒤의 첫 연결은 다시 둘 중 하나를 요구하고, 캐시는 서버 재시작 사이에 유지되지 않습니다.</p>

<p>TLS를 끄더라도 비밀번호가 평문으로 흐르지는 않는다는 뜻입니다. 다만 그 뒤에 흐르는 쿼리와 결과는 평문입니다.</p>

<h3 id="성능">성능</h3>

<p>TLS 오버헤드로 인용할 만한 공개 측정 자료는 마땅치 않습니다. 쿼리 특성과 연결 수립 빈도에 따라 결과가 갈리므로, 도입 판단은 자기 워크로드에서 측정해 정합니다.</p>

<p>비용이 몰리는 지점은 대칭키 암복호보다 핸드셰이크입니다. 그래서 먼저 볼 값이 정해져 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>이름</th>
      <th>역할</th>
      <th>기본값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ssl_session_cache_mode</code></td>
      <td>서버측 세션 캐시와 세션 티켓 발급</td>
      <td><code class="language-plaintext highlighter-rouge">ON</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ssl_session_cache_timeout</code></td>
      <td>세션 재사용 허용 기간(초)</td>
      <td>300, 최대 84600</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ssl_sessions_reused</code></td>
      <td>세션을 재사용했으면 1, 아니면 0</td>
      <td>세션 상태 변수</td>
    </tr>
  </tbody>
</table>

<p>두 시스템 변수를 바꾼 값은 <code class="language-plaintext highlighter-rouge">ALTER INSTANCE RELOAD TLS</code>를 실행하거나 서버를 재시작한 뒤에 적용됩니다.</p>

<p>측정은 구간을 나눠서 합니다.</p>

<ul>
  <li>연결을 재사용하는 정상 상태의 응답시간 분포와 서버 CPU 사용량</li>
  <li>연결을 새로 맺을 때의 지연. 커넥션 풀이 없거나 자주 비는 워크로드가 여기서 드러납니다</li>
  <li>재시작이나 페일오버 직후처럼 연결이 한꺼번에 몰리는 구간</li>
</ul>

<p>커넥션 풀로 연결을 재사용하면 핸드셰이크 비용은 대부분 지나갑니다. 요청마다 접속을 새로 맺는 구조라면 TLS보다 그 구조를 먼저 봅니다.</p>

<h2 id="6-의사결정-가이드">6. 의사결정 가이드</h2>

<ol>
  <li><strong>위험 평가</strong> — 데이터 민감도는 어느 정도인가, 이 구간이 고시 제2조제12호가 말하는 내부망인가, 인증정보가 이 연결로 흐르는가</li>
  <li><strong>조문 판정</strong> — 제7조제1항과 제4항 중 무엇에 걸리는지 확인하고 판단 근거를 기록으로 남깁니다</li>
  <li><strong>성능 측정</strong> — 5절 항목을 테스트 환경에서 재 봅니다. 감당하기 어려운 수치가 나오면 연결 재사용 구조를 먼저 고칩니다</li>
  <li><strong>대안 검토</strong> — IPsec이나 서비스 메시처럼 네트워크 계층에서 암호화하는 방식이 더 맞는지 봅니다. 이쪽은 애플리케이션 코드와 DB 계정 설정을 건드리지 않습니다</li>
  <li><strong>단계적 적용</strong> — 서버에서 암호화 지원을 켜고, 클라이언트를 <code class="language-plaintext highlighter-rouge">--ssl-mode=REQUIRED</code> 이상으로 옮긴 뒤, 평문 연결이 남지 않았는지 세션 단위로 확인하고 마지막에 <code class="language-plaintext highlighter-rouge">require_secure_transport</code>를 켭니다</li>
</ol>

<h2 id="7-결론">7. 결론</h2>

<p>내부망 MySQL 연결에 TLS가 필요한지는 한 문장으로 답하기 어렵습니다. 다만 판단을 흐리게 만드는 두 가지는 정리할 수 있습니다.</p>

<p>첫째, 조문이 직접 요구하지 않는다는 것과 켜지 않아도 된다는 것은 다릅니다. 고시 제7조제4항은 인터넷망 구간을 전제하므로 내부망은 그 범위 밖이지만, 제7조제1항은 인증정보에 대해 구간을 제한하지 않습니다.</p>

<p>둘째, 성능 부담은 대개 막연하게 추정됩니다. 남의 수치로 결정할 문제가 아니라, 연결 재사용 패턴을 확인하고 자기 워크로드에서 재보면 대부분 답이 나옵니다.</p>

<p>내부망이 통제돼 있고 다른 계층의 통제가 갖춰져 있다면 TLS 없이 운영하는 선택도 성립합니다. 그때 남길 것은 설정이 아니라 판단의 근거입니다. 어느 조문에 걸리는지, 어느 통제로 대체했는지, 무엇을 측정했는지를 남기면 다음 담당자가 같은 질문을 처음부터 다시 하지 않습니다.</p>]]></content><author><name>teinam</name></author><category term="mysql" /><summary type="html"><![CDATA[AWS VPC나 사내 내부망처럼 접근이 통제된 구간에서도 MySQL 연결에 TLS를 걸어야 하는지를 법령 조문, 위협모델, 실제 설정값으로 나눠 판단합니다.]]></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">쿠버네티스와 EKS에서의 데이터베이스 영속성: PV, StorageClass, EBS 비교 분석</title><link href="https://rastalion.dev/writing/k8s-eks-database-persistence/" rel="alternate" type="text/html" title="쿠버네티스와 EKS에서의 데이터베이스 영속성: PV, StorageClass, EBS 비교 분석" /><published>2024-07-28T05:56:08+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/k8s-eks-database-persistence</id><content type="html" xml:base="https://rastalion.dev/writing/k8s-eks-database-persistence/"><![CDATA[<h2 id="rds-사용하기는-아깝고-eks-올리기엔-불안한-컨테이너-데이터베이스">RDS 사용하기는 아깝고, EKS 올리기엔 불안한 컨테이너 데이터베이스</h2>

<p>EKS 에 올라가는 작은 서비스나 Superset·Airflow 같은 솔루션의 메타 정보를 담을 DB 하나 때문에 RDS 인스턴스를 따로 띄우는 것은 비용이 아깝습니다. 그런데 컨테이너로 그냥 올리면 파드가 종료될 때 데이터가 같이 사라집니다. 그래서 DB 컨테이너에는 파드와 수명이 분리된 스토리지를 마운트해야 합니다.</p>

<p>쿠버네티스는 이 목적으로 PersistentVolume(PV), PersistentVolumeClaim(PVC), StorageClass(SC) 를 제공하고, Amazon EKS 에서는 그 뒤를 Amazon EBS 같은 AWS 스토리지가 받습니다. 이 글에서는 세 가지를 비용·백업·고가용성 측면에서 비교합니다.</p>

<blockquote>
  <p><strong>NOTE</strong> — 동작 설명은 Amazon EKS 표준 지원 버전인 쿠버네티스 1.34·1.35·1.36 을 기준으로 합니다. EKS 는 마이너 버전마다 표준 지원 14개월, 이어서 연장 지원 12개월을 제공합니다.</p>
</blockquote>

<h2 id="스토리지-옵션-비교">스토리지 옵션 비교</h2>

<p>먼저 세 가지의 특징을 표로 정리합니다.</p>

<table>
  <thead>
    <tr>
      <th>측면</th>
      <th>PersistentVolume</th>
      <th>StorageClass</th>
      <th>Amazon EBS</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>비용</td>
      <td>백엔드 스토리지 가격을 그대로 따름</td>
      <td>필요한 용량만 동적으로 할당</td>
      <td>프로비저닝한 용량·IOPS 단위 과금</td>
    </tr>
    <tr>
      <td>백업</td>
      <td>백엔드가 지원하는 방식</td>
      <td>CSI 스냅샷 컨트롤러를 따로 설치</td>
      <td>스냅샷 내장, AWS Backup 연동</td>
    </tr>
    <tr>
      <td>고가용성</td>
      <td>백엔드 기능에 따라 다름</td>
      <td><code class="language-plaintext highlighter-rouge">allowedTopologies</code> 로 AZ 를 제한</td>
      <td>볼륨 하나는 단일 AZ 안에서만</td>
    </tr>
    <tr>
      <td>성능</td>
      <td>백엔드에 따라 다름</td>
      <td>클래스별 파라미터로 조절</td>
      <td>gp3·io2 Block Express 등 타입 선택</td>
    </tr>
    <tr>
      <td>확장성</td>
      <td>수동 확장</td>
      <td><code class="language-plaintext highlighter-rouge">allowVolumeExpansion</code> 으로 PVC 확장</td>
      <td>크기·IOPS·처리량 온라인 변경</td>
    </tr>
    <tr>
      <td>관리 복잡성</td>
      <td>PV 를 직접 만들고 지움</td>
      <td>정책만 정의하면 자동 생성</td>
      <td>드라이버 애드온과 IAM 권한 필요</td>
    </tr>
  </tbody>
</table>

<p>세 가지는 서로 대체재가 아닙니다. PVC 가 필요한 용량을 요청하고, StorageClass 가 그 요청을 어느 드라이버로 어떻게 만들지 정하고, 그 결과로 EBS 볼륨과 PV 가 생깁니다. 이제 각각을 자세히 보겠습니다.</p>

<h2 id="1-persistentvolume-과-persistentvolumeclaim">1. PersistentVolume 과 PersistentVolumeClaim</h2>

<p>PersistentVolume 은 관리자가 미리 프로비저닝하거나 StorageClass 를 통해 동적으로 프로비저닝하는 클러스터의 스토리지 조각입니다. 파드는 PV 를 직접 참조하지 않고 PVC 로 요청하며, 바인딩된 PVC 를 볼륨으로 마운트합니다.</p>

<h3 id="장점">장점</h3>

<ul>
  <li>스토리지와 사용을 분리해 관리하기 쉽습니다.</li>
  <li>NFS, iSCSI, 클라우드 블록 스토리지 등 다양한 백엔드를 CSI 드라이버로 붙일 수 있습니다.</li>
  <li>데이터 수명을 파드 수명과 분리할 수 있습니다.</li>
</ul>

<h3 id="단점">단점</h3>

<ul>
  <li>PV 를 직접 만드는 방식은 용량·AZ·재사용을 사람이 관리해야 합니다.</li>
  <li>볼륨 수가 늘어나면 남은 볼륨을 추적하기 어려워집니다.</li>
</ul>

<h3 id="비용과-백업-고가용성">비용과 백업, 고가용성</h3>

<p>PV 자체는 과금 대상이 아니고, 비용과 내구성은 뒤에 있는 스토리지가 결정합니다. 온프레미스라면 하드웨어 투자, 클라우드라면 해당 서비스의 가격 정책을 그대로 따릅니다. 백업도 마찬가지로 백엔드가 스냅샷을 지원하는지에 달려 있고, 지원하지 않으면 덤프 같은 별도 수단을 마련해야 합니다.</p>

<h3 id="statefulset-이-만드는-pvc-의-수명">StatefulSet 이 만드는 PVC 의 수명</h3>

<p>데이터베이스는 보통 Deployment 가 아니라 StatefulSet 으로 띄웁니다. StatefulSet 의 <code class="language-plaintext highlighter-rouge">volumeClaimTemplates</code> 에 PVC 템플릿을 적으면 파드마다 전용 PVC 가 만들어지고, 파드가 재시작해도 같은 PVC 에 다시 붙습니다. 안정 버전 API 인 <code class="language-plaintext highlighter-rouge">apps/v1</code> 을 씁니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">StatefulSet</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">postgres</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">serviceName</span><span class="pi">:</span> <span class="s">postgres</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">1</span>
  <span class="c1"># selector, template 은 생략</span>
  <span class="na">volumeClaimTemplates</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">metadata</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">data</span>
      <span class="na">spec</span><span class="pi">:</span>
        <span class="na">accessModes</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">ReadWriteOnce"</span><span class="pi">]</span>
        <span class="na">storageClassName</span><span class="pi">:</span> <span class="s">ebs-gp3</span>
        <span class="na">resources</span><span class="pi">:</span>
          <span class="na">requests</span><span class="pi">:</span>
            <span class="na">storage</span><span class="pi">:</span> <span class="s">20Gi</span>
</code></pre></div></div>

<p>주의할 점은 정리 동작입니다. StatefulSet 을 지우거나 레플리카를 줄여도 쿠버네티스는 그 PVC 와 볼륨을 함께 지우지 않습니다. 공식 문서는 자동 삭제보다 데이터 안전이 더 중요하기 때문이라고 설명합니다. 그래서 StatefulSet 만 지우고 잊으면 EBS 볼륨이 그대로 남아 매달 요금이 나갑니다. <code class="language-plaintext highlighter-rouge">kubectl get pvc</code> 로 확인하고 직접 지워야 합니다.</p>

<p>정리를 자동화하려면 <code class="language-plaintext highlighter-rouge">.spec.persistentVolumeClaimRetentionPolicy</code> 를 씁니다. <code class="language-plaintext highlighter-rouge">apps/v1</code> StatefulSet 스펙의 필드이며 <code class="language-plaintext highlighter-rouge">whenDeleted</code> 와 <code class="language-plaintext highlighter-rouge">whenScaled</code> 에 각각 <code class="language-plaintext highlighter-rouge">Retain</code> 또는 <code class="language-plaintext highlighter-rouge">Delete</code> 를 줄 수 있고, 기본값은 둘 다 <code class="language-plaintext highlighter-rouge">Retain</code> 입니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">persistentVolumeClaimRetentionPolicy</span><span class="pi">:</span>
    <span class="na">whenDeleted</span><span class="pi">:</span> <span class="s">Retain</span>
    <span class="na">whenScaled</span><span class="pi">:</span> <span class="s">Delete</span>
</code></pre></div></div>

<p>이 정책은 StatefulSet 이 삭제되거나 스케일 다운되어 파드가 사라지는 경우에만 적용됩니다. 노드 장애로 파드가 없어진 경우에는 PVC 가 남고, 대체 파드가 같은 볼륨에 다시 붙습니다. StatefulSet 이 만든 PVC 의 자동 정리는 쿠버네티스 1.32 에 들어갔으므로, 현재 EKS 표준 지원 버전에서는 모두 쓸 수 있습니다.</p>

<h2 id="2-storageclass-sc">2. StorageClass (SC)</h2>

<p>StorageClass 는 관리자가 제공하는 스토리지의 “클래스”를 기술하는 방법입니다. 어떤 프로비저너를 쓸지, 어떤 파라미터로 볼륨을 만들지, PVC 를 지웠을 때 볼륨을 어떻게 할지를 한곳에 모읍니다.</p>

<h3 id="장점-1">장점</h3>

<ul>
  <li>동적 볼륨 프로비저닝을 지원합니다.</li>
  <li>스토리지 타입과 성능 특성을 추상화해 PVC 쪽 매니페스트를 단순하게 유지합니다.</li>
  <li>클라우드 제공업체의 스토리지 서비스와 CSI 드라이버로 연결됩니다.</li>
</ul>

<h3 id="단점-1">단점</h3>

<ul>
  <li>드라이버 설치와 권한 설정 등 초기 구성이 필요합니다.</li>
  <li>쓸 수 있는 파라미터는 드라이버가 정하므로 백엔드마다 다릅니다.</li>
</ul>

<h3 id="eks-에서-쓰는-gp3-storageclass">EKS 에서 쓰는 gp3 StorageClass</h3>

<p>EBS 볼륨을 동적으로 받으려면 프로비저너가 <code class="language-plaintext highlighter-rouge">ebs.csi.aws.com</code> 인 StorageClass 가 필요합니다. EBS CSI 드라이버는 <code class="language-plaintext highlighter-rouge">type</code> 을 지정하지 않으면 gp3 볼륨을 만들지만, 클래스에 명시해 두면 의도가 드러납니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">storage.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">StorageClass</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">ebs-gp3</span>
<span class="na">provisioner</span><span class="pi">:</span> <span class="s">ebs.csi.aws.com</span>
<span class="na">parameters</span><span class="pi">:</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">gp3</span>
  <span class="na">encrypted</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
<span class="na">reclaimPolicy</span><span class="pi">:</span> <span class="s">Delete</span>
<span class="na">allowVolumeExpansion</span><span class="pi">:</span> <span class="no">true</span>
<span class="na">volumeBindingMode</span><span class="pi">:</span> <span class="s">WaitForFirstConsumer</span>
</code></pre></div></div>

<p>이 네 줄이 운영 결과를 좌우합니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">volumeBindingMode</code> — 비워 두면 <code class="language-plaintext highlighter-rouge">Immediate</code> 가 적용되어 PVC 를 만드는 즉시 볼륨이 생깁니다. <code class="language-plaintext highlighter-rouge">WaitForFirstConsumer</code> 는 그 PVC 를 쓰는 파드가 스케줄될 때까지 볼륨 생성을 미룹니다.</li>
  <li><code class="language-plaintext highlighter-rouge">reclaimPolicy</code> — 지정하지 않으면 <code class="language-plaintext highlighter-rouge">Delete</code> 입니다. PVC 를 지우면 동적으로 만들어진 볼륨까지 지워집니다. 데이터를 남기려면 <code class="language-plaintext highlighter-rouge">Retain</code> 으로 둡니다.</li>
  <li><code class="language-plaintext highlighter-rouge">allowVolumeExpansion</code> — <code class="language-plaintext highlighter-rouge">true</code> 여야 PVC 의 요청 용량을 키워 볼륨을 확장할 수 있습니다. 줄이는 것은 불가능합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">encrypted</code> — 드라이버 기본값은 <code class="language-plaintext highlighter-rouge">false</code> 이므로 암호화가 필요하면 직접 켭니다.</li>
</ul>

<p>클러스터에 기본 StorageClass 가 있는지는 <code class="language-plaintext highlighter-rouge">kubectl get storageclass</code> 로 확인합니다. <code class="language-plaintext highlighter-rouge">storageclass.kubernetes.io/is-default-class: "true"</code> 애노테이션이 붙은 클래스가 PVC 에 <code class="language-plaintext highlighter-rouge">storageClassName</code> 을 적지 않았을 때 쓰입니다. EKS Auto Mode 는 StorageClass 를 만들어 주지 않으므로 직접 만들어야 하고, 이때 프로비저너는 <code class="language-plaintext highlighter-rouge">ebs.csi.eks.amazonaws.com</code> 입니다.</p>

<blockquote>
  <p><strong>WARNING</strong> — EBS 볼륨은 만들어진 가용 영역 하나에만 속합니다. AWS 문서는 볼륨과 인스턴스가 같은 가용 영역에 있어야 한다고 적습니다. <code class="language-plaintext highlighter-rouge">Immediate</code> 로 먼저 만든 볼륨이 a 존에 있는데 파드가 c 존 노드로 스케줄되면 마운트가 실패하고 파드는 <code class="language-plaintext highlighter-rouge">Pending</code> 에 머뭅니다. 노드 그룹이 여러 AZ 에 걸쳐 있다면 <code class="language-plaintext highlighter-rouge">WaitForFirstConsumer</code> 를 쓰거나 <code class="language-plaintext highlighter-rouge">allowedTopologies</code> 로 AZ 를 고정합니다.</p>
</blockquote>

<h2 id="3-amazon-ebs-elastic-block-store">3. Amazon EBS (Elastic Block Store)</h2>

<p>EBS 는 AWS 의 블록 스토리지 서비스입니다. 쿠버네티스는 1.23 부터 AWS EBS 의 CSI 마이그레이션을 기본으로 켰고, 현행 쿠버네티스 볼륨 문서의 in-tree 볼륨 타입 목록에 AWS EBS 는 들어 있지 않습니다. EKS 에서 EBS 볼륨을 쓰려면 Amazon EBS CSI 드라이버를 설치해야 합니다.</p>

<h3 id="드라이버-설치와-iam-권한">드라이버 설치와 IAM 권한</h3>

<p>AWS 는 EBS CSI 드라이버를 EKS 애드온으로 설치하라고 권고합니다. 애드온 이름은 <code class="language-plaintext highlighter-rouge">aws-ebs-csi-driver</code> 이고, 클러스터가 요구하는 플랫폼 버전은 다음 명령으로 확인합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws eks describe-addon-versions <span class="nt">--addon-name</span> aws-ebs-csi-driver
</code></pre></div></div>

<p>드라이버는 EC2 API 를 호출하므로 IAM 권한이 필요합니다. AWS 는 EKS Pod Identity 를 권하며, 서비스 어카운트용 IAM 역할(IRSA)도 쓸 수 있습니다. 관리형 정책 <code class="language-plaintext highlighter-rouge">AmazonEBSCSIDriverPolicyV2</code> 를 붙인 역할을 만들어 애드온에 연결하면, 애드온이 <code class="language-plaintext highlighter-rouge">kube-system</code> 네임스페이스에 <code class="language-plaintext highlighter-rouge">ebs-csi-controller-sa</code> 서비스 어카운트를 만들어 사용합니다.</p>

<p>권한이 없으면 볼륨이 생기지 않고 <code class="language-plaintext highlighter-rouge">kubectl describe pvc</code> 에 다음 문구가 찍힙니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>failed to provision volume with StorageClass
could not create volume in EC2: UnauthorizedOperation
</code></pre></div></div>

<p>스냅샷 기능을 쓰려면 CSI 스냅샷 컨트롤러를 먼저 설치해야 합니다. EKS Auto Mode 클러스터에서는 EBS CSI 컨트롤러를 설치하지 않아도 되지만, 블록 스토리지가 별도 프로비저너 <code class="language-plaintext highlighter-rouge">ebs.csi.eks.amazonaws.com</code> 로 동작하며 <code class="language-plaintext highlighter-rouge">ebs.csi.aws.com</code> 이 만든 볼륨과 따로 관리됩니다. 기존 볼륨을 Auto Mode 로 옮기려면 스냅샷을 거쳐야 합니다.</p>

<h3 id="제약">제약</h3>

<ul>
  <li>Fargate 파드에는 EBS 볼륨을 마운트할 수 없습니다. 컨트롤러는 Fargate 노드에서 실행할 수 있지만, 노드 DaemonSet 은 EC2 인스턴스에서만 실행됩니다.</li>
  <li>EBS 볼륨과 EBS CSI 드라이버는 EKS Hybrid Nodes 와 호환되지 않습니다.</li>
  <li>애드온 지원 범위는 최신 버전과 그 직전 버전입니다.</li>
</ul>

<h3 id="비용">비용</h3>

<p>EBS 는 프로비저닝한 용량 기준으로 과금하므로, 파드를 내려도 볼륨이 남아 있으면 요금이 계속 나갑니다. gp3 는 용량과 IOPS·처리량을 따로 지정할 수 있어 성능과 비용을 나눠 조절하기 좋습니다. 스냅샷은 증분으로 저장되므로 세대마다 전체 용량이 중복 과금되지는 않습니다.</p>

<h3 id="백업">백업</h3>

<p>스냅샷 기능이 서비스에 내장돼 있고 AWS Backup 과 연동해 정책으로 관리할 수 있습니다. 쿠버네티스 쪽에서 <code class="language-plaintext highlighter-rouge">VolumeSnapshot</code> 리소스로 다루려면 앞서 말한 CSI 스냅샷 컨트롤러가 필요합니다.</p>

<h3 id="고가용성">고가용성</h3>

<p>내구성은 볼륨 타입마다 다릅니다. AWS 문서는 gp2·gp3·io1·st1·sc1 을 99.8~99.9%(연간 장애율 0.1~0.2%), io2 Block Express 를 99.999%(연간 장애율 0.001%) 로 적습니다.</p>

<p>내구성과 AZ 장애 대비는 다른 문제입니다. 볼륨은 단일 AZ 자원이므로 AZ 하나가 죽으면 그 볼륨에 붙은 DB 파드를 다른 AZ 에서 되살릴 수 없습니다. AZ 장애까지 견뎌야 한다면 데이터베이스 자체의 복제를 구성하거나, 복제와 장애 조치를 서비스가 맡아 주는 RDS 를 선택하는 편이 낫습니다.</p>

<h2 id="결론">결론</h2>

<p>스토리지 솔루션을 고를 때는 워크로드 특성, 필요한 성능, 예산, 운영 팀의 숙련도를 함께 봐야 합니다.</p>

<ul>
  <li><strong>PersistentVolume 과 PVC</strong> 는 데이터 수명을 파드와 분리하는 계층입니다. PV 를 손으로 만드는 방식은 온프레미스나 특수한 요구가 있을 때로 남겨 두는 편이 좋습니다.</li>
  <li><strong>StorageClass</strong> 는 그 생성을 자동화하는 규칙입니다. EKS 에서는 <code class="language-plaintext highlighter-rouge">volumeBindingMode</code> 와 <code class="language-plaintext highlighter-rouge">reclaimPolicy</code> 를 잘못 잡으면 마운트 실패나 데이터 삭제로 이어지므로 먼저 확인합니다.</li>
  <li><strong>Amazon EBS</strong> 는 EKS 에서 가장 손이 덜 가는 백엔드지만, CSI 드라이버 애드온과 IAM 권한이 전제이고 볼륨이 단일 AZ 에 묶입니다.</li>
</ul>

<p>작은 메타 DB 하나를 컨테이너로 두는 것은 합리적인 선택입니다. 다만 영속성은 볼륨을 붙이는 데서 끝나지 않고 복구를 해 보는 데서 끝납니다. 운영에 넣기 전에 PVC 를 지웠을 때 볼륨이 어떻게 되는지, 스냅샷에서 되살린 볼륨으로 DB 가 기동하는지 소규모로 확인해 보시기 바랍니다.</p>]]></content><author><name>teinam</name></author><category term="database" /><summary type="html"><![CDATA[EKS 위 작은 데이터베이스에 RDS 를 붙이기 아까울 때, PersistentVolume·StorageClass·Amazon EBS 로 컨테이너 데이터를 남기는 방법을 비용·백업·가용성 기준으로 비교합니다.]]></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">회원 가입 및 로그인을 위한 테이블 설계</title><link href="https://rastalion.dev/writing/table-design-for-signup-login/" rel="alternate" type="text/html" title="회원 가입 및 로그인을 위한 테이블 설계" /><published>2022-08-31T03:43:24+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/table-design-for-signup-login</id><content type="html" xml:base="https://rastalion.dev/writing/table-design-for-signup-login/"><![CDATA[<p>《개발자의 글쓰기》라는 책을 읽다 보면 이런 에피소드가 나옵니다. 문장을 주고 특정 핵심 단어로 문장을 요약해 보라고 했을 때 DBA는 추려낸 단어에서 중복된 단어는 제거해야 한다고 말한다고 하죠. 이처럼 DBA는 습관적으로 데이터 중복에 민감함을 드러냅니다.</p>

<p>데이터베이스 이론서는 대부분 정규화의 필요성을 강조합니다. 정규화로 데이터 중복을 피하고, 무손실 분해와 종속성 유지 분해의 조건이 갖춰지면 데이터 무결성과 종속성을 유지할 수 있습니다. 보이스 코드 정규화(BCNF)까지 구현할 수 있으면 가장 좋지만, 최소 3정규화까지는 해야 구조에서 오는 데이터 중복을 막을 수 있습니다. 데이터 중복은 불필요한 공간을 차지하는 문제만이 아니라 데이터베이스 I/O 처리에도 문제를 불러오기 때문에, 중복을 제거하는 일은 데이터베이스를 다루는 데 중요한 부분입니다.</p>

<p>데이터베이스 성능은 결국 I/O에서 갈리고, 데이터 중복은 읽기와 쓰기 양쪽의 I/O를 늘립니다. 그래서 DBA와 DA는 I/O를 줄이려고 정규화를 하고 SQL 튜닝을 합니다.</p>

<p>데이터의 속성에 따라 정규화할 필요가 있다고 이론서는 늘 말합니다. 하지만 그 속성을 어떻게 나누고 분류할지 결정하는 것은 어려운 문제입니다. DBA나 DA가 없는 상황에서 개발자가 서비스에 필요한 수준으로만 설계한 DB는 이런 기본 원칙이 잘 지켜지지 않습니다. 성능 문제가 드러난 시점에는 구조가 이미 얽히고 꼬여 있어서 손을 댈 수 없는 경우가 많습니다.</p>

<p>1:1 항목은 굳이 테이블을 분리할 필요가 없다고 말하는 분도 있습니다. 하지만 데이터가 많아지고 서비스가 커지면 한 테이블에 컬럼이 너무 많이 담겨 문제가 되는 상황이 반드시 옵니다.</p>

<p>데이터베이스는 인덱스를 타든 풀스캔을 하든, 특정 값을 읽으려면 그 값이 들어 있는 행이 담긴 블록 전체를 먼저 읽습니다.</p>

<p><img src="/assets/img/wp/2022/08/table-design-inline-1.png" alt="" /></p>

<ol>
  <li>인덱스를 통해 해당 값이 있는 row에 도착하지만,</li>
  <li>Row 값의 일부분인 해당 값만 읽어오는 것이 아니라 해당 값이 있는 Row 전체 블록을 읽어옵니다.</li>
</ol>

<p><img src="/assets/img/wp/2022/08/table-design-inline-2.png" alt="" /></p>

<p>입출력의 최소 단위는 행이 아니라 이 블록이고, InnoDB에서는 페이지라고 부릅니다. 기본 크기는 16KB이며 <code class="language-plaintext highlighter-rouge">innodb_page_size</code> 로 정해집니다. 그래서 행이 넓어지면 한 페이지에 담기는 행 수가 줄고, 같은 건수를 읽는 데 읽어야 할 페이지가 늘어납니다. 컬럼이 많을수록 읽기가 많아진다는 말의 실제 내용이 이것입니다.</p>

<p>1:1 매칭이라고 해서 컬럼을 마구 늘렸다가 속성이 맞지 않는 값이 섞이고 나중에 안 쓰는 컬럼이 생기면, 구조 문제뿐 아니라 성능 이슈도 함께 따라옵니다.</p>

<p>컬럼이 많은 테이블의 행을 불러올 때, 비슷하게 컬럼이 많은 다른 테이블과 조인한다고 가정하면 실제 필요한 데이터보다 훨씬 많은 페이지를 읽어야 결과가 나옵니다. 데이터가 쌓일수록 점점 더 느려지는 결과를 가져옵니다.</p>

<p>복합 인덱스를 잘 구성하면 어느 정도는 해소됩니다. 조회에 필요한 컬럼이 인덱스 안에 모두 들어 있으면 테이블 접근 없이 인덱스만 읽는 커버링 인덱스가 되기 때문입니다. 그래도 한 테이블에 컬럼이 100개씩 있는 구조에서 성능이 잘 나오기를 바라는 것은 무리죠.</p>

<h2 id="회원-테이블의-비즈니스-로직과-구조화하기">회원 테이블의 비즈니스 로직과 구조화하기</h2>

<p>회원 테이블은 한 번 구성하면 거의 모든 서비스에서 참조하는 테이블입니다. 따라서 회원 테이블에 불필요한 데이터가 많거나 컬럼이 많으면 좋지 않습니다. 속성이 다른 데이터가 많으면 1:1로 매칭되더라도 정규화하여 분리하는 편이 낫습니다.</p>

<p>회원 테이블은 회원 가입과 로그인에 가장 먼저 쓰이고, 이후 서비스가 돌아가면 정산이나 통계, 서비스 이용 목록을 조회할 때 계속 쓰입니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 900 530" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-animation="trace" data-preset="signal-flow" data-quality-profile="showcase">
        <title id="signup-flow-archify-diagram-title">회원 가입 프로세스</title>
        <desc id="signup-flow-archify-diagram-description">A workflow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="signup-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="signup-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="signup-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="signup-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="signup-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(#signup-flow-grid)" />

        <!-- Swimlanes -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-0" x="40" y="52" width="750" 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="750" 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="750" 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="create_account" data-edge-to="signup_done" data-edge-key="0" data-composition-points="491.6,341;491.6,119;565.6,119" d="M 491.6 341 L 491.6 119 L 565.6 119" class="a-emphasis" data-animate="edge" style="--step:3" stroke-width="1.8" marker-end="url(#signup-flow-arrowhead-emphasis)" />
        <path data-edge-from="hash_password" data-edge-to="create_account" data-edge-key="1" data-composition-points="417.6,243;491.6,243;491.6,341" d="M 417.6 243 L 491.6 243 L 491.6 341" class="a-default" data-animate="edge" style="--step:2" stroke-width="1.4" marker-end="url(#signup-flow-arrowhead)" />
        <path data-edge-from="input_id" data-edge-to="hash_password" data-edge-key="2" data-composition-points="311.6,119;371.6,119;371.6,217" d="M 311.6 119 L 371.6 119 L 371.6 217" class="a-default" data-animate="edge" style="--step:1" stroke-width="1.4" marker-end="url(#signup-flow-arrowhead)" />
        <path data-edge-from="method_choice" data-edge-to="input_id" data-edge-label="자체 ID" data-edge-key="3" data-composition-points="140,119;191.6,119" d="M 140 119 L 191.6 119" class="a-emphasis" data-animate="edge" style="--step:0" stroke-width="1.8" marker-end="url(#signup-flow-arrowhead-emphasis)" />
        <path data-edge-from="method_choice" data-edge-to="social_auth" data-edge-label="소셜" data-edge-key="4" data-composition-points="94,145;94,166;251.6,166;251.6,217" d="M 94 145 L 94 166 L 251.6 166 L 251.6 217" class="a-emphasis" data-animate="edge" style="--step:9" stroke-width="1.8" marker-end="url(#signup-flow-arrowhead-emphasis)" />
        <path data-edge-from="social_auth" data-edge-to="create_account" data-edge-label="랜덤 ID 생성" data-edge-key="5" data-composition-points="251.6,269;251.6,374;445.6,374" d="M 251.6 269 L 251.6 374 L 445.6 374" class="a-default" data-animate="edge" style="--step:10" stroke-width="1.4" marker-end="url(#signup-flow-arrowhead)" />

        <!-- Nodes -->
        <g id="signup-flow-node-method_choice" data-node-id="method_choice" 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="92" height="52" rx="6" class="c-mask" />
          <rect x="48" y="93" width="92" height="52" rx="6" class="c-frontend" data-animate="node" style="--step:0" 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="94" y="114" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">가입 방식 선택</text>
          <text data-detail="context" x="94" y="131" class="t-muted" font-size="8" text-anchor="middle">자체 / 소셜</text>
        </g>

        <g id="signup-flow-node-input_id" data-node-id="input_id" data-node-label="ID·비밀번호 입력" tabindex="0" role="button" aria-label="Focus ID·비밀번호 입력, 사용자" aria-pressed="false" data-node-kind="frontend" data-node-context="사용자">
          <title>ID·비밀번호 입력 · 사용자</title>
          <rect x="191.6" y="93" width="120" height="52" rx="6" class="c-mask" />
          <rect x="191.6" y="93" width="120" height="52" rx="6" class="c-frontend" data-animate="node" style="--step:1" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(197.6 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="" x="251.6" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">ID·비밀번호 입력</text>
        </g>

        <g id="signup-flow-node-signup_done" data-node-id="signup_done" data-node-label="가입 완료" tabindex="0" role="button" aria-label="Focus 가입 완료, 사용자" aria-pressed="false" data-node-kind="frontend" data-node-context="사용자">
          <title>가입 완료 · 사용자</title>
          <rect x="565.6" y="93" width="92" height="52" rx="6" class="c-mask" />
          <rect x="565.6" y="93" width="92" height="52" rx="6" class="c-frontend" data-animate="node" style="--step:4" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(571.6 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="" x="611.6" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">가입 완료</text>
        </g>

        <g id="signup-flow-node-social_auth" data-node-id="social_auth" data-node-label="소셜 인증" tabindex="0" role="button" aria-label="Focus 소셜 인증, OAuth 2.0, 애플리케이션" aria-pressed="false" data-node-kind="external" data-node-sublabel="OAuth 2.0" data-node-context="애플리케이션">
          <title>소셜 인증 · OAuth 2.0 · 애플리케이션</title>
          <rect x="205.6" y="217" width="92" height="52" rx="6" class="c-mask" />
          <rect x="205.6" y="217" width="92" height="52" rx="6" class="c-external" data-animate="node" style="--step:8" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(211.6 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="251.6" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">소셜 인증</text>
          <text data-detail="context" x="251.6" y="255" class="t-muted" font-size="8" text-anchor="middle">OAuth 2.0</text>
        </g>

        <g id="signup-flow-node-hash_password" data-node-id="hash_password" 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="325.6" y="217" width="92" height="52" rx="6" class="c-mask" />
          <rect x="325.6" y="217" width="92" height="52" rx="6" class="c-backend" data-animate="node" style="--step:2" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(331.6 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="371.6" y="238" class="t-primary" font-size="10.7" font-weight="600" text-anchor="middle">비밀번호 해시</text>
          <text data-detail="context" x="371.6" y="255" class="t-muted" font-size="8" text-anchor="middle">키 유도 함수</text>
        </g>

        <g id="signup-flow-node-create_account" data-node-id="create_account" data-node-label="계정 생성" tabindex="0" role="button" aria-label="Focus 계정 생성, member + auth, 데이터베이스" aria-pressed="false" data-node-kind="database" data-node-sublabel="member + auth" data-node-context="데이터베이스">
          <title>계정 생성 · member + auth · 데이터베이스</title>
          <rect x="445.6" y="341" width="92" height="52" rx="6" class="c-mask" />
          <rect x="445.6" y="341" width="92" height="52" rx="6" class="c-database" data-animate="node" style="--step:3" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(451.6 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="491.6" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">계정 생성</text>
          <text data-detail="context" x="491.6" y="379" class="t-muted" font-size="8" text-anchor="middle">member + auth</text>
        </g>

        <!-- Edge labels -->



        <g data-detail="context" data-edge-from="method_choice" data-edge-to="input_id" data-edge-label="자체 ID" data-edge-key="3">
          <rect x="144" y="99" width="43.6" height="14" rx="3" class="c-mask" />
          <text x="165.8" y="109" class="t-backend" font-size="8" text-anchor="middle">자체 ID</text>
        </g>
        <g data-detail="context" data-edge-from="method_choice" data-edge-to="social_auth" data-edge-label="소셜" data-edge-key="4">
          <rect x="157.8" y="146" width="30" height="14" rx="3" class="c-mask" />
          <text x="172.8" y="156" class="t-backend" font-size="8" text-anchor="middle">소셜</text>
        </g>
        <g data-detail="context" data-edge-from="social_auth" data-edge-to="create_account" data-edge-label="랜덤 ID 생성" data-edge-key="5">
          <rect x="314.8" y="354" width="67.6" height="14" rx="3" class="c-mask" />
          <text x="348.6" y="364" class="t-muted" font-size="8" text-anchor="middle">랜덤 ID 생성</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="91">
            <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="118" data-legend-baseline="448" data-legend-width="96">
            <rect x="118" y="440" width="14" height="9" rx="2" class="c-backend" stroke-width="1" />
            <text x="140" 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="221" data-legend-baseline="448" data-legend-width="96">
            <rect x="221" y="440" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="243" y="448" 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="324" data-legend-baseline="448" data-legend-width="91">
            <rect x="324" y="440" width="14" height="9" rx="2" class="c-external" stroke-width="1" />
            <text x="346" y="448" class="t-muted" font-size="7.5" font-weight="500">외부 서비스</text>
          </g>
        </g>
      </svg>
</div><figcaption>일반적인 앱 서비스의 회원 가입 프로세스</figcaption>
</figure>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 900 530" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-animation="trace" data-preset="signal-flow" data-quality-profile="showcase">
        <title id="login-flow-archify-diagram-title">로그인 프로세스</title>
        <desc id="login-flow-archify-diagram-description">A workflow diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="login-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="login-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="login-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="login-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="login-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(#login-flow-grid)" />

        <!-- Swimlanes -->
        <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-0" x="40" y="52" width="778" 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="778" 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="778" 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="create_session" data-edge-to="login_done" data-edge-key="0" data-composition-points="519.6,217;519.6,119;593.6,119" d="M 519.6 217 L 519.6 119 L 593.6 119" class="a-emphasis" data-animate="edge" style="--step:3" stroke-width="1.8" marker-end="url(#login-flow-arrowhead-emphasis)" />
        <path data-edge-from="input_credential" data-edge-to="query_user" data-edge-key="1" data-composition-points="279.6,145;279.6,161;336.1,161;336.1,325;392.6,325;392.6,341" d="M 279.6 145 L 279.6 161 L 336.1 161 L 336.1 325 L 392.6 325 L 392.6 341" class="a-default" data-animate="edge" style="--step:1" stroke-width="1.4" marker-end="url(#login-flow-arrowhead)" />
        <path data-edge-from="method_choice" data-edge-to="input_credential" data-edge-label="자체 ID" data-edge-key="2" data-composition-points="168,119;219.60000000000002,119" d="M 168 119 L 219.60000000000002 119" class="a-emphasis" data-animate="edge" style="--step:0" stroke-width="1.8" marker-end="url(#login-flow-arrowhead-emphasis)" />
        <path data-edge-from="method_choice" data-edge-to="social_auth" data-edge-label="소셜" data-edge-key="3" data-composition-points="108,145;108,166;279.6,166;279.6,217" d="M 108 145 L 108 166 L 279.6 166 L 279.6 217" class="a-emphasis" data-animate="edge" style="--step:8" stroke-width="1.8" marker-end="url(#login-flow-arrowhead-emphasis)" />
        <path data-edge-from="query_user" data-edge-to="create_session" data-edge-label="소셜" data-edge-key="4" data-composition-points="445.6,367;519.6,367;519.6,269" d="M 445.6 367 L 519.6 367 L 519.6 269" class="a-default" data-animate="edge" style="--step:2" stroke-width="1.4" marker-end="url(#login-flow-arrowhead)" />
        <path data-edge-from="query_user" data-edge-to="verify_password" data-edge-label="자체만" data-edge-key="5" data-composition-points="353.6,367;337.6,367;337.6,290;399.6,290;399.6,269" d="M 353.6 367 L 337.6 367 L 337.6 290 L 399.6 290 L 399.6 269" class="a-default" data-animate="edge" style="--step:10" stroke-width="1.4" marker-end="url(#login-flow-arrowhead)" />
        <path data-edge-from="social_auth" data-edge-to="query_user" data-edge-key="6" data-composition-points="279.6,269;279.6,367;353.6,367" d="M 279.6 269 L 279.6 367 L 353.6 367" class="a-default" data-animate="edge" style="--step:11" stroke-width="1.4" marker-end="url(#login-flow-arrowhead)" />
        <path data-edge-from="verify_password" data-edge-to="create_session" data-edge-key="7" data-composition-points="445.6,243;473.6,243" d="M 445.6 243 L 473.6 243" class="a-default" data-animate="edge" style="--step:12" stroke-width="1.4" marker-end="url(#login-flow-arrowhead)" />

        <!-- Nodes -->
        <g id="login-flow-node-method_choice" data-node-id="method_choice" 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" data-animate="node" style="--step:0" 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="login-flow-node-input_credential" data-node-id="input_credential" data-node-label="ID·비밀번호 입력" tabindex="0" role="button" aria-label="Focus ID·비밀번호 입력, 사용자" aria-pressed="false" data-node-kind="frontend" data-node-context="사용자">
          <title>ID·비밀번호 입력 · 사용자</title>
          <rect x="219.60000000000002" y="93" width="120" height="52" rx="6" class="c-mask" />
          <rect x="219.60000000000002" y="93" width="120" height="52" rx="6" class="c-frontend" data-animate="node" style="--step:1" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(225.60000000000002 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="" x="279.6" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">ID·비밀번호 입력</text>
        </g>

        <g id="login-flow-node-login_done" data-node-id="login_done" data-node-label="로그인 완료" tabindex="0" role="button" aria-label="Focus 로그인 완료, 사용자" aria-pressed="false" data-node-kind="frontend" data-node-context="사용자">
          <title>로그인 완료 · 사용자</title>
          <rect x="593.6" y="93" width="92" height="52" rx="6" class="c-mask" />
          <rect x="593.6" y="93" width="92" height="52" rx="6" class="c-frontend" data-animate="node" style="--step:4" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="frontend" class="semantic-sigil s-frontend" transform="translate(599.6 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="" x="639.6" y="114" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">로그인 완료</text>
        </g>

        <g id="login-flow-node-social_auth" data-node-id="social_auth" data-node-label="소셜 인증" tabindex="0" role="button" aria-label="Focus 소셜 인증, OAuth 2.0, 애플리케이션" aria-pressed="false" data-node-kind="external" data-node-sublabel="OAuth 2.0" data-node-context="애플리케이션">
          <title>소셜 인증 · OAuth 2.0 · 애플리케이션</title>
          <rect x="233.60000000000002" y="217" width="92" height="52" rx="6" class="c-mask" />
          <rect x="233.60000000000002" y="217" width="92" height="52" rx="6" class="c-external" data-animate="node" style="--step:8" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="external" class="semantic-sigil s-external" transform="translate(239.60000000000002 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="279.6" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">소셜 인증</text>
          <text data-detail="context" x="279.6" y="255" class="t-muted" font-size="8" text-anchor="middle">OAuth 2.0</text>
        </g>

        <g id="login-flow-node-verify_password" data-node-id="verify_password" data-node-label="비밀번호 검증" tabindex="0" role="button" aria-label="Focus 비밀번호 검증, 애플리케이션" aria-pressed="false" data-node-kind="backend" data-node-context="애플리케이션">
          <title>비밀번호 검증 · 애플리케이션</title>
          <rect x="353.6" y="217" width="92" height="52" rx="6" class="c-mask" />
          <rect x="353.6" y="217" width="92" height="52" rx="6" class="c-backend" data-animate="node" style="--step:9" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(359.6 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" x="399.6" y="238" class="t-primary" font-size="10.7" font-weight="600" text-anchor="middle">비밀번호 검증</text>
        </g>

        <g id="login-flow-node-create_session" data-node-id="create_session" 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="473.6" y="217" width="92" height="52" rx="6" class="c-mask" />
          <rect x="473.6" y="217" width="92" height="52" rx="6" class="c-backend" data-animate="node" style="--step:3" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="backend" class="semantic-sigil s-backend" transform="translate(479.6 223) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="519.6" y="238" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">세션 생성</text>
          <text data-detail="context" x="519.6" y="255" class="t-muted" font-size="8" text-anchor="middle">구독 정보 캐싱</text>
        </g>

        <g id="login-flow-node-query_user" data-node-id="query_user" data-node-label="계정 조회" tabindex="0" role="button" aria-label="Focus 계정 조회, member / dormant, 데이터베이스" aria-pressed="false" data-node-kind="database" data-node-sublabel="member / dormant" data-node-context="데이터베이스">
          <title>계정 조회 · member / dormant · 데이터베이스</title>
          <rect x="353.6" y="341" width="92" height="52" rx="6" class="c-mask" />
          <rect x="353.6" y="341" width="92" height="52" rx="6" class="c-database" data-animate="node" style="--step:2" stroke-width="1.5" />
          <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(359.6 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="399.6" y="362" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">계정 조회</text>
          <text data-detail="context" x="399.6" y="379" class="t-muted" font-size="8" text-anchor="middle">member / dormant</text>
        </g>

        <!-- Edge labels -->


        <g data-detail="context" data-edge-from="method_choice" data-edge-to="input_credential" data-edge-label="자체 ID" data-edge-key="2">
          <rect x="172" y="99" width="43.6" height="14" rx="3" class="c-mask" />
          <text x="193.8" y="109" class="t-backend" font-size="8" text-anchor="middle">자체 ID</text>
        </g>
        <g data-detail="context" data-edge-from="method_choice" data-edge-to="social_auth" data-edge-label="소셜" data-edge-key="3">
          <rect x="178.8" y="146" width="30" height="14" rx="3" class="c-mask" />
          <text x="193.8" y="156" class="t-backend" font-size="8" text-anchor="middle">소셜</text>
        </g>
        <g data-detail="context" data-edge-from="query_user" data-edge-to="create_session" data-edge-label="소셜" data-edge-key="4">
          <rect x="467.6" y="347" width="30" height="14" rx="3" class="c-mask" />
          <text x="482.6" y="357" class="t-muted" font-size="8" text-anchor="middle">소셜</text>
        </g>
        <g data-detail="context" data-edge-from="query_user" data-edge-to="verify_password" data-edge-label="자체만" data-edge-key="5">
          <rect x="349.20000000000005" y="270" width="38.8" height="14" rx="3" class="c-mask" />
          <text x="368.6" y="280" 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="frontend" data-legend-kind="frontend" data-legend-label="사용자 입력" data-legend-x="20" data-legend-baseline="448" data-legend-width="91">
            <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="118" data-legend-baseline="448" data-legend-width="96">
            <rect x="118" y="440" width="14" height="9" rx="2" class="c-backend" stroke-width="1" />
            <text x="140" 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="221" data-legend-baseline="448" data-legend-width="96">
            <rect x="221" y="440" width="14" height="9" rx="2" class="c-database" stroke-width="1" />
            <text x="243" y="448" 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="324" data-legend-baseline="448" data-legend-width="91">
            <rect x="324" y="440" width="14" height="9" rx="2" class="c-external" stroke-width="1" />
            <text x="346" y="448" class="t-muted" font-size="7.5" font-weight="500">외부 서비스</text>
          </g>
        </g>
      </svg>
</div><figcaption>일반적인 앱 서비스의 로그인 프로세스</figcaption>
</figure>

<p>모든 서비스가 이런 비즈니스 로직을 가진다는 뜻은 아니고, 일반적이고 단순한 앱 서비스를 기준으로 그려 본 것입니다. 회원 가입과 로그인은 대부분 이 로직으로 동작합니다. 그에 따라 테이블 설계도 이 로직을 커버할 수 있어야 합니다.</p>

<h2 id="rdbms-테이블-설계">RDBMS 테이블 설계</h2>

<p><img src="/assets/img/wp/2022/08/member.png" alt="" /></p>

<p>MySQL 8.4 LTS를 기준으로 작성했고, 한 인스턴스 안에서 스키마 단위로 첫 번째 분리를 했습니다. ERD의 레이어 한 칸이 스키마입니다. MySQL 8.0은 2026-04-21부터 Oracle Sustaining Support 단계로 넘어갔으니, 새로 만드는 서비스라면 8.4 LTS 이상에서 시작합니다.</p>

<p>스키마로 나누는 첫 번째 이유는 권한입니다. SQL 인젝션 자체는 파라미터 바인딩으로 막는 것이고, 스키마 분리가 하는 일은 계정별 접근 범위를 갈라 사고가 났을 때 닿는 데이터를 줄이는 것입니다. 「개인정보의 안전성 확보조치 기준」 제5조도 업무 수행에 필요한 최소한의 범위로 권한을 차등 부여하라고 요구합니다. 두 번째 이유는 속성별로 나눠 두면 서비스 규모가 커졌을 때 스키마를 인스턴스로 떼어내 MSA까지 이어갈 수 있다는 점입니다.</p>

<h3 id="member-스키마부터-살펴보겠습니다"><strong>member</strong> 스키마부터 살펴보겠습니다</h3>

<h4 id="memberuser"><strong>member.user</strong></h4>

<ul>
  <li>가장 기본적인 회원 ID를 저장하는 방식인데, <code class="language-plaintext highlighter-rouge">user_name</code>은 일반적으로 사이트나 서비스에서 회원 가입할 때 사용하는 ID라고 보시면 됩니다.</li>
  <li>로그인 ID에 유니크 인덱스를 걸 때는 콜레이션을 확인해야 합니다. MySQL 8.0 이후 기본값인 <code class="language-plaintext highlighter-rouge">utf8mb4_0900_ai_ci</code> 는 호환 자모 분해형(<code class="language-plaintext highlighter-rouge">ㄱㅏㄴㅏㄷㅏ</code>)과 완성형(<code class="language-plaintext highlighter-rouge">가나다</code>)을 같은 값으로 판정합니다. Oracle은 UCA 표준 동작으로 보아 버그가 아니라고 처리했습니다. 한국어 전용 콜레이션은 없으니, 분해형 입력을 막을지 저장 전에 정규화할지는 애플리케이션에서 정해야 합니다.</li>
  <li>
    <p>MySQL에서는 <code class="language-plaintext highlighter-rouge">user_no</code> 를 <code class="language-plaintext highlighter-rouge">AUTO_INCREMENT</code> 로 순차 할당했습니다. Oracle과 PostgreSQL에서는 시퀀스를 쓰고, PostgreSQL은 <code class="language-plaintext highlighter-rouge">GENERATED ALWAYS AS IDENTITY</code> 컬럼이 표준 문법입니다.</p>

    <p>순차 번호 대신 UUID로 고유 식별 코드를 만드는 구성도 있습니다. 36자 문자열을 그대로 두지 말고 <code class="language-plaintext highlighter-rouge">UUID_TO_BIN()</code> 으로 <code class="language-plaintext highlighter-rouge">BINARY(16)</code> 에 저장합니다. 시간 기반 UUID라면 두 번째 인자에 1을 주어 시간 하위·상위 부분의 순서를 바꿔 두면 연속 생성한 값이 인덱스에서 이웃하게 되고, 읽을 때는 <code class="language-plaintext highlighter-rouge">BIN_TO_UUID()</code> 로 되돌립니다.</p>
  </li>
  <li>최소한의 데이터로 구성해 두면 휴면(dormant)으로 넘어가거나 탈퇴(withdrawal)할 때까지 어떤 update도 하지 않고, 계정 상태가 바뀔 때 delete만 하는 것으로 정리됩니다.</li>
</ul>

<h4 id="memberauthentication"><strong>member.authentication</strong></h4>

<ul>
  <li>회원의 인증 정보를 담습니다. 암호화가 필요한 개인정보만 이 테이블에 모으는 구조이고, 나중에 배송지나 주소록이 필요해지면 그 테이블만 따로 암호화하면 됩니다.</li>
  <li>암호화 대상은 항목 이름만으로 정해지지 않습니다. 「개인정보의 안전성 확보조치 기준」(개인정보보호위원회고시 제2026-9호) 제7조제2항은 이용자의 주민등록번호·여권번호·운전면허번호·외국인등록번호·신용카드번호·계좌번호·생체인식정보를 조건 없는 저장 암호화 대상으로 둡니다. 신용카드번호와 계좌번호가 이 목록에 있다는 점을 놓치기 쉬운데, 고유식별정보가 아니라는 이유로 결제·정산 테이블을 평문으로 두면 근거가 없습니다. 조문 좌표는 <a href="/docs/database/encryption/">데이터 암호화</a>에 정리해 두었습니다.</li>
  <li>컬럼을 암호화하면 그 컬럼으로는 동등 비교 외에 검색·정렬·범위 조건을 쓸 수 없습니다. 암호화 대상을 한 테이블로 모으는 설계가 이 제약과도 맞습니다. 목록이나 통계 화면에 필요한 값은 연령대·지역 구분처럼 범주화한 파생 컬럼을 평문으로 따로 둡니다.</li>
  <li>개인정보를 담는 이유는 서비스를 개선할 근거가 필요하기 때문입니다. 어떤 사용자가 있고 연령층과 성별 분포가 어떤지 같은 통계가 그렇습니다. 여기 담기는 정보는 본인 인증 서비스를 제공하는 타업체의 인증으로 가입하는 경우만 기록됩니다.</li>
</ul>

<h4 id="memberprofile"><strong>member.profile</strong></h4>

<ul>
  <li>사용자가 직접 등록하는 정보인데, 닉네임과 프로필 사진은 앱 서비스에서 자주 여기저기서 불려 다니는 데이터입니다.</li>
  <li>나머지 컬럼은 자주 읽히지는 않지만, 회원 조회나 닉네임을 클릭했을 때 대체로 같이 보이는 데이터입니다.</li>
</ul>

<h4 id="membersubscription"><strong>member.subscription</strong></h4>

<ul>
  <li>요즘 앱 서비스는 구독 모델을 많이 쓰는데, 해당 사용자가 무료 이용자인지, 유료면 어떤 플랜인지 기록해 둡니다.</li>
  <li>로그인할 때 세션 정보와 함께 캐싱해 두면 처리가 빨라집니다. 세션 정보는 Redis나 Valkey 같은 캐시에 두는 편이 좋습니다.</li>
</ul>

<h4 id="memberdevice"><strong>member.device</strong></h4>

<ul>
  <li>사용자가 로그인하는 기기 정보를 담습니다. 어떤 기기가 많이 들어오는지 통계를 모으고, 사용자별 기기 대수를 제한하고, 인증되지 않은 기기의 사용을 막아 보안성을 높이는 것이죠.</li>
</ul>

<h3 id="그다음은-auth-스키마입니다">그다음은 <strong>auth</strong> 스키마입니다</h3>

<h4 id="authpassword"><strong>auth.password</strong></h4>

<ul>
  <li>ID 방식으로 가입하면 password를 받습니다. 소셜 로그인과 ID 방식이 섞인 서비스에서 password를 user 테이블에 같이 기록하면 password가 null인 행이 생깁니다. 정규화로 이런 부분을 줄이고, 비밀번호 해시에 닿을 수 있는 계정을 따로 떼어 두기 위해 이렇게 분리합니다.</li>
  <li>비밀번호는 복호화할 수 있는 형태로 저장하지 않습니다. 고시 제7조제1항 단서가 복호화되지 아니하도록 일방향 암호화하여 저장하라고 요구하고, 시행령 제30조제1항제4호 가목도 일방향을 명문으로 씁니다.</li>
  <li>일방향이라고 아무 해시나 되는 것은 아닙니다. SHA 계열 같은 범용 해시는 빠른 것이 목적이라 대입 공격에도 그만큼 빠릅니다. 연산 비용을 조절할 수 있는 키 유도 함수를 쓰고, 솔트는 계정마다 다르게 생성해 해시와 함께 보관합니다. 반복 횟수는 기본값을 그대로 쓰지 말고 실제 서버에서 로그인 지연을 측정해 정하고, 하드웨어를 바꿀 때 다시 올립니다.</li>
  <li>알고리즘 선택은 환경에 따라 좁아집니다. 공공 조달처럼 검증필 암호모듈이 요구되는 환경에서 검증대상 키 유도 함수는 KBKDF와 PBKDF이고 Argon2·bcrypt·scrypt는 그 목록에 없습니다. 민간 서비스에는 검증필 모듈 사용 의무가 없으므로 선택은 자유롭지만, 그 선택이 안전하다는 근거를 기록으로 남깁니다. ISMS-P 심사나 내부 감사에서 요구받는 것은 설정 화면이 아니라 이 기록입니다.</li>
  <li>컬럼 폭은 넉넉하게 잡습니다. 해시 값과 솔트, 알고리즘과 반복 횟수 식별자가 함께 들어가고 알고리즘을 교체하면 길이가 또 바뀝니다.</li>
</ul>

<h4 id="authsocial_login"><strong>auth.social_login</strong></h4>

<ul>
  <li>소셜 로그인 정보는 서비스 단위로 테이블을 나눌 필요 없이 <code class="language-plaintext highlighter-rouge">social_code</code> 값으로 (1:apple, 2:google, 3:kakao, 4:naver …) 구분하고 <code class="language-plaintext highlighter-rouge">external_id</code> 를 기록하면 됩니다. 계정을 잇는 데 필요한 값은 <code class="language-plaintext highlighter-rouge">external_id</code> 입니다.</li>
  <li>액세스 토큰은 성격이 다릅니다. 유효기간이 짧고 갱신되는 자격증명이라, 회원 DB에 오래 들고 있을 이유가 있는지부터 따집니다. 소셜 서비스의 API를 대신 호출해야 해서 보관한다면 평문 컬럼으로 두지 않고, 만료나 연동 해지 시점에 지우는 경로까지 함께 설계합니다.</li>
  <li>소셜 로그인으로 가입하면 <code class="language-plaintext highlighter-rouge">user_no</code> 를 자동으로 생성하고 <code class="language-plaintext highlighter-rouge">user_name</code> 에 랜덤 ID를 부여하는 프로세스를 추가해야 합니다.</li>
</ul>

<h4 id="authcidi"><strong>auth.cidi</strong></h4>

<ul>
  <li>휴대폰 인증이나 아이핀처럼 본인 인증 절차를 거쳤을 때 인증 업체로부터 받는 값입니다. CI는 법령 용어로 연계정보이고, 생성·처리는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제23조의5, 안전조치 의무는 제23조의6에 있습니다.</li>
  <li>CI는 어느 업체에서 인증을 받더라도 한 사람에게 하나의 값만 부여됩니다. 그래서 서비스 간 동일인 판정에 쓸 수 있고, 같은 이유로 한번 유출되면 되돌릴 수 없습니다.</li>
  <li>DI는 사이트 중복 가입을 방지하는 데 쓴다고 하는데, 인증 업체마다 다른 값이 올 수도 있기 때문에 사용하는 데 주의가 필요합니다.</li>
  <li>연계정보는 시행령 제19조가 정하는 고유식별정보 네 가지(주민등록번호, 여권번호, 운전면허의 면허번호, 외국인등록번호)에 들어가지 않고 고시 제7조제2항의 조건 없는 암호화 대상 목록에도 없습니다. 그래도 제23조의6이 안전조치 의무를 따로 두고 있으니, 이 컬럼을 어떻게 보호할지는 판단과 근거를 남겨 둡니다. 주민등록번호로 대신하는 선택은 정보통신망법 제23조의2가 사용을 제한합니다.</li>
</ul>

<h3 id="휴면dormant-스키마입니다">휴면(<strong>dormant</strong>) 스키마입니다</h3>

<ul>
  <li>member 스키마의 오브젝트에 특별한 FK를 가지고 있지는 않지만 스키마 이상으로 떼어 두는 편이 낫습니다. 휴면에 빠진 회원 정보는 member에서 delete하고 기존 값을 그대로 dormant로 옮깁니다. member뿐만 아니라 auth의 데이터도 함께 옮겨 휴면 회원의 데이터는 전부 한곳에 자리하게 됩니다.</li>
  <li>다만 휴면 분리 자체를 법정 의무로 적어 두면 근거가 어긋납니다. 일정 기간 접속하지 않은 이용자의 개인정보를 자동으로 삭제하게 했던 유효기간제는 구 정보통신망법 제29조에 있었고 2020-02-04 개정으로 삭제됐습니다. 현행에서 보유기간과 파기를 정하는 조문은 개인정보 보호법 제21조입니다. 보유기간이 지나거나 처리 목적을 달성해 불필요해진 개인정보는 지체 없이 파기하고, 다른 법령에 보존 의무가 있는 항목만 예외로 남기되 그때는 다른 개인정보와 분리하여 저장·관리합니다. 분리 보관을 스키마 분리로 구현하는 근거가 이 단서입니다.</li>
  <li>휴면 스키마로 옮겨온 시점부터 회사 정책에 따라 1년, 3년, 5년 등의 단위로 복구하지 않는 회원을 공지와 함께 자동 탈퇴 처리하는 프로세스를 가지게 됩니다. 이 기간은 법령이 정한 값이 아니라 정책 값이므로, 처리방침에 공개한 보유기간과 파기 배치 주기가 서로 맞는지 대조합니다.</li>
</ul>

<h3 id="탈퇴withdrawal-스키마입니다">탈퇴(<strong>withdrawal</strong>) 스키마입니다</h3>

<ul>
  <li>탈퇴한 회원의 개인정보를 계속 보유할 근거는 사전 통보가 아니라 다른 법령의 보존 의무입니다(법 제21조). 환불 고지에 필요한 연락처처럼 남겨야 하는 항목이 있다면 근거 법령과 보존 기간을 항목별로 적어 두고, 나머지 개인정보와 분리해 저장·관리합니다.</li>
  <li>탈퇴한 회원이 재가입해 가입 시에만 받는 혜택을 중복으로 받지 않도록 CI를 일정 기간 보관하는 구성도 씁니다. 이때도 보관 기간과 근거를 먼저 정하고, 기간이 지나면 지우는 배치를 함께 만듭니다.</li>
  <li><code class="language-plaintext highlighter-rouge">is_deleted = true</code> 같은 플래그는 파기가 아닙니다. 시행령 제16조제1항은 전자적 파일 형태의 개인정보를 복원이 불가능한 방법으로 영구 삭제하라고 정합니다. 논리 삭제로 운영한다면 물리 삭제나 복원 불가 처리로 넘어가는 후속 단계를 따로 둡니다. 파기 범위에서 자주 빠지는 곳은 백업본, 리드 레플리카, 바이너리 로그, 개발·스테이징으로 복사한 덤프입니다.</li>
  <li>탈퇴는 서비스에 따라 구성 내용이 많이 바뀌기 때문에 오브젝트를 구성하지 않았고, Billing 테이블이나 CI를 별도로 남기는 테이블을 구성하거나 withdrawal 테이블에 컬럼으로 기록할 수도 있습니다.</li>
</ul>

<h3 id="log-스키마의-오브젝트를-보면-기존-테이블과-구성이-다릅니다"><strong>Log</strong> 스키마의 오브젝트를 보면 기존 테이블과 구성이 다릅니다</h3>

<ul>
  <li>로그를 설계할 때 많은 개발자가 실수하는 부분이 테이블의 row 값을 그대로 복사해서 날짜만 붙이는 것인데, 로그의 설계 원칙은 모두 코드값으로 표현하는 것이 기본입니다. 코드만 보고 어떤 행동이 어떻게 발생했는지 알 수 있어야 합니다.</li>
  <li>데이터 중복을 만드는 것보다 행위의 결과와 원인을 나타내는 것을 기본으로 해야 합니다.</li>
  <li>서비스 행위 로그와 개인정보처리시스템의 접속기록은 목적이 다르므로 같은 테이블에 섞지 않습니다. 접속기록의 보관 기간은 고시 제8조가 정하는데, 기본은 1년 이상이고 5만명 이상 정보주체의 개인정보를 처리하거나 고유식별정보·민감정보를 처리하는 시스템은 2년 이상입니다. 기준이 바뀌면 보존 기간이 두 배가 되니 월 단위 파티션에 드롭 정책을 걸어 두면 정책 값만 고치면 됩니다. 관련 조문은 <a href="/docs/database/data-3-law/">데이터 3법</a>에 모아 두었습니다.</li>
</ul>

<p>앱 서비스에 필요한 기본적인 테이블 설계로 1:1 매칭 데이터가 어떤 속성을 가지고 분리되는지, 위 ERD 모델을 보고 천천히 분석해 보면 테이블 모델링이 조금은 이해되지 않을까 해서 그려 봤습니다.</p>

<p>만 14세 미만 아동의 법정대리인 동의나 성인 인증이 필요한 경우를 저 모델의 어디에 어떻게 추가할지 스스로 고민해 보시면, 앞으로 새로운 설계를 하는 데 많은 도움이 될 것이라 생각합니다.</p>

<p>1:1 정규화로 조인해야 하는 테이블 간 블록 읽기를 최소화하면 데이터 출력 속도도 빠르게 유지할 수 있고, 속성별로 테이블을 분리했기 때문에 적재적소에 필요한 컬럼이나 별도의 테이블을 추가해서 서비스를 늘려가는 것이 어렵지 않습니다.</p>

<p><a href="/assets/img/wp/2022/08/테이블정의서.zip">테이블정의서</a>를 첨부합니다.</p>]]></content><author><name>teinam</name></author><category term="mysql" /><summary type="html"><![CDATA[회원 가입과 로그인에 쓰이는 테이블을 1:1 관계까지 정규화해 분리하는 이유, 그리고 회원·인증·휴면·탈퇴 스키마를 나눌 때 함께 걸리는 법정 요건을 정리합니다.]]></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">MERN Stack 이란?</title><link href="https://rastalion.dev/writing/what-is-mern-stack/" rel="alternate" type="text/html" title="MERN Stack 이란?" /><published>2022-05-20T16:20:29+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/what-is-mern-stack</id><content type="html" xml:base="https://rastalion.dev/writing/what-is-mern-stack/"><![CDATA[<p>MongoDB 자료를 찾아보면 해외 사이트에서 자주 등장하는 단어입니다. Udemy 같은 온라인 교육 사이트에는 MERN Stack 통합 교육과정도 업로드되고 있습니다.</p>

<p>MERN은 스택을 구성하는 4가지 핵심 기술을 따서 MongoDB, Express, React, Node를 나타냅니다.</p>

<ul>
  <li>MongoDB – document database</li>
  <li>Express.js – Node.js web framework</li>
  <li>React.js – a client-side JavaScript library</li>
  <li>Node.js – JavaScript server platform</li>
</ul>

<p>Express.js 및 Node.js는 중간(애플리케이션) 계층을 구성합니다. Express.js는 서버 측 웹 프레임워크이며 Node.js는 JavaScript 서버 플랫폼입니다. React.js 대신 다른 프론트엔드 기술을 선택하면 ME(RVA)N 스택이 됩니다.</p>

<ul>
  <li>R – React.js</li>
  <li>V – Vue.js</li>
  <li>A – Angular</li>
</ul>

<h2 id="mern-stack은-어떻게-동작할까요">MERN Stack은 어떻게 동작할까요?</h2>

<p>MERN 아키텍처를 사용하면 JavaScript 및 JSON으로 3-tier 아키텍처(프론트엔드, 백엔드, 데이터베이스)를 구성할 수 있습니다.</p>

<figure class="diagram">
  <div class="diagram-canvas"><svg viewBox="0 0 740 208" role="img" lang="en" aria-labelledby="archify-diagram-title archify-diagram-description" data-preset="classic" data-quality-profile="showcase">
        <title id="mern-stack-archify-diagram-title">MERN Stack 아키텍처</title>
        <desc id="mern-stack-archify-diagram-description">An architecture diagram generated by Archify.</desc>
        <!-- Definitions -->
        <defs>
          <marker id="mern-stack-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="mern-stack-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="mern-stack-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="mern-stack-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="mern-stack-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(#mern-stack-grid)" />

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


        <!-- Connection paths (before components for correct z-order) -->
        <path data-edge-from="react" data-edge-to="express-nodejs" data-edge-label="HTTP/XHR" data-edge-key="0" data-composition-points="160,110;310,110" d="M 160 110 L 310 110" class="a-default" stroke-width="1.5" marker-end="url(#mern-stack-arrowhead)" />
        <path data-edge-from="express-nodejs" data-edge-to="mongodb" data-edge-label="MongoDB 드라이버" data-edge-key="1" data-composition-points="430,110;580,110" d="M 430 110 L 580 110" class="a-default" stroke-width="1.5" marker-end="url(#mern-stack-arrowhead)" />

        <!-- Components -->
        <g id="mern-stack-node-react" data-node-id="react" data-node-label="React" tabindex="0" role="button" aria-label="Focus React, JavaScript 라이브러리, Architecture component" aria-pressed="false" data-node-kind="frontend" data-node-sublabel="JavaScript 라이브러리" data-node-context="Architecture component">
          <title>React · JavaScript 라이브러리 · Architecture component</title>
          <rect x="40" y="80" width="120" height="60" rx="6" class="c-mask" />
          <rect x="40" y="80" width="120" height="60" 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="100" y="108" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">React</text>
        <text data-detail="context" x="100" y="124" class="t-muted" font-size="8.8" text-anchor="middle">JavaScript 라이브러리</text>
        </g>

        <g id="mern-stack-node-express-nodejs" data-node-id="express-nodejs" data-node-label="Express + Node.js" tabindex="0" role="button" aria-label="Focus Express + Node.js, 애플리케이션 계층, Architecture component" aria-pressed="false" data-node-kind="backend" data-node-sublabel="애플리케이션 계층" data-node-context="Architecture component">
          <title>Express + Node.js · 애플리케이션 계층 · Architecture component</title>
          <rect x="310" y="80" width="120" height="60" rx="6" class="c-mask" />
          <rect x="310" y="80" width="120" 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(316 86) scale(0.6875)">
            <path d="M6 3 3 8l3 5M10 3l3 5-3 5" />
          </g>
          <text data-node-label="" data-detail-anchor="" x="370" y="108" class="t-primary" font-size="10.9" font-weight="600" text-anchor="middle">Express + Node.js</text>
        <text data-detail="context" x="370" y="124" class="t-muted" font-size="9" text-anchor="middle">애플리케이션 계층</text>
        </g>

        <g id="mern-stack-node-mongodb" data-node-id="mongodb" data-node-label="MongoDB" tabindex="0" role="button" aria-label="Focus MongoDB, Document Database, Architecture component" aria-pressed="false" data-node-kind="database" data-node-sublabel="Document Database" data-node-context="Architecture component">
          <title>MongoDB · Document Database · Architecture component</title>
          <rect x="580" y="80" width="120" height="60" rx="6" class="c-mask" />
          <rect x="580" y="80" width="120" 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(586 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="640" y="108" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">MongoDB</text>
        <text data-detail="context" x="640" y="124" class="t-muted" font-size="9" text-anchor="middle">Document Database</text>
        </g>

        <!-- Connection labels -->
        <g data-detail="context" data-edge-from="react" data-edge-to="express-nodejs" data-edge-label="HTTP/XHR" data-edge-key="0">
          <rect x="210.8" y="90" width="48.4" height="14" rx="3" class="c-mask" />
          <text x="235" y="100" class="t-muted" font-size="8" text-anchor="middle">HTTP/XHR</text>
        </g>
        <g data-detail="context" data-edge-from="express-nodejs" data-edge-to="mongodb" data-edge-label="MongoDB 드라이버" data-edge-key="1">
          <rect x="461.6" y="90" width="86.8" height="14" rx="3" class="c-mask" />
          <text x="505" y="100" class="t-muted" font-size="8" text-anchor="middle">MongoDB 드라이버</text>
        </g>

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


        <!-- Legend -->
        <g data-legend="" data-legend-bridge="">
          <text x="40" y="172" 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="192" data-legend-width="93">
            <rect x="40" y="183" width="16" height="10" rx="2.5" class="c-frontend" stroke-width="1" />
            <text x="62" y="192" class="t-muted" font-size="10" font-weight="500">프론트엔드</text>
          </g>
          <g data-legend-semantic-kind="backend" data-legend-kind="backend" data-legend-label="백엔드" data-legend-x="155" data-legend-baseline="192" data-legend-width="73">
            <rect x="155" y="183" width="16" height="10" rx="2.5" class="c-backend" stroke-width="1" />
            <text x="177" y="192" 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="250" data-legend-baseline="192" data-legend-width="103">
            <rect x="250" y="183" width="16" height="10" rx="2.5" class="c-database" stroke-width="1" />
            <text x="272" y="192" class="t-muted" font-size="10" font-weight="500">데이터베이스</text>
          </g>
        </g>
      </svg>
</div><figcaption>MERN Stack 3-tier 아키텍처 — React · Express + Node.js · MongoDB</figcaption>
</figure>

<h3 id="reactjs-frontend">React.js Frontend</h3>

<p>MERN 스택의 가장 앞단은 React.js입니다. 동적 HTML을 클라이언트 단에서 생성하는 선언적 JavaScript 라이브러리입니다. React.js를 사용하면 간단한 구성 요소로 복잡한 인터페이스를 구축하고, 백엔드 서버의 데이터에 연결해 HTML로 렌더링할 수 있습니다.</p>

<p>React.js의 강점은 최소한의 코드로 데이터 기반 인터페이스를 처리하는 것입니다. 최신 UI 라이브러리에서 기대할 수 있는 모든 기능을 갖추고 있습니다.</p>

<h3 id="expressjs-and-nodejs-server-tier">Express.js and Node.js Server Tier</h3>

<p>다음 레벨은 Node.js 서버 내부에서 실행되는 Express.js 서버 측 프레임워크입니다. Express.js는 스스로를 “Node.js를 위한 빠르고, 의견이 없는, 미니멀리스트 웹 프레임워크”라고 칭하고 있습니다. Express.js는 URL 라우팅(서버 기능과 수신 URL 일치)과 HTTP 요청 및 응답 처리 모델을 제공합니다.</p>

<p>React.js 프론트엔드에서 HTTP 요청(XHR, fetch API)을 통해 Express.js 서버의 기능에 연결할 수 있습니다. 서버 측 기능은 MongoDB Node.js 드라이버를 이용해 데이터베이스의 데이터에 액세스하고 업데이트합니다.</p>

<h3 id="mongodb-database-tier">MongoDB Database Tier</h3>

<p>애플리케이션이 데이터(사용자 프로필, 콘텐츠, 댓글, 업로드, 이벤트 등)를 저장하는 경우 React.js, Express.js 및 Node.js만큼 쉽게 작업할 수 있는 데이터베이스가 필요합니다. 바로 여기에서 MongoDB가 등장합니다.</p>

<p>React.js 프론트엔드에서 생성된 JSON 문서가 유효하면 Express.js 서버로 전달되어 문서를 처리하고, MongoDB에 저장할 수 있습니다.</p>

<h2 id="mern은-풀스택-솔루션">MERN은 풀스택 솔루션</h2>

<p>MERN은 프론트엔드 디스플레이 계층(React.js), 애플리케이션 계층(Express.js 및 Node.js), 데이터베이스 계층(MongoDB)을 포함한 기존의 3계층 아키텍처 패턴을 따르는 풀스택입니다.</p>

<h3 id="mern-스택을-선택하는-이유는-무엇일까">MERN 스택을 선택하는 이유는 무엇일까?</h3>

<p>MERN 스택의 기반인 MongoDB는 JSON 데이터를 저장하도록 설계되었습니다(기술적으로는 BSON이라는 JSON의 이진 버전을 사용). 명령줄 인터페이스부터 쿼리 언어(MQL, MongoDB 쿼리 언어)까지 모든 것이 JSON과 JavaScript를 기반으로 합니다.</p>

<p>MongoDB는 Node.js와 호환성이 좋으며, 애플리케이션의 모든 계층에서 JSON 데이터를 저장, 조작, 표현할 수 있습니다. 클라우드 네이티브 애플리케이션의 경우 MongoDB Atlas는 선택한 클라우드 플랫폼에서 자동 확장되는 MongoDB 클러스터를 제공합니다.</p>

<p>Express.js(Node.js에서 실행)와 React.js를 결합하면 전체 스택을 JavaScript/JSON으로 구축할 수 있습니다. Express.js는 HTTP 요청과 응답을 처리하고 URL을 서버 측 기능에 매핑합니다. React.js는 대화형 사용자 인터페이스를 구축하고 원격 서버와 통신하는 프론트엔드 JavaScript 라이브러리입니다. JSON 데이터가 프론트엔드부터 백엔드까지 자연스럽게 흐르므로 구축이 빠르고 디버그가 간단합니다. 전체 시스템을 이해하려면 하나의 프로그래밍 언어와 JSON 문서 구조만 알면 됩니다.</p>

<h2 id="mern-사용-사례">MERN 사용 사례</h2>

<p>모든 웹 스택과 마찬가지로 MERN에서 원하는 모든 것을 구축할 수 있지만 JSON이 많고 클라우드 네이티브이며 동적 웹 인터페이스가 있는 경우에 가장 이상적입니다.</p>

<p>몇 가지 예는 다음과 같습니다.</p>

<ul>
  <li>워크플로 관리</li>
  <li>뉴스 집계</li>
  <li>Todo 앱 및 캘린더</li>
  <li>대화형 포럼/소셜 제품 등</li>
</ul>]]></content><author><name>teinam</name></author><category term="mongodb" /><summary type="html"><![CDATA[MERN은 MongoDB, Express, React, Node.js로 구성된 JavaScript 기반 풀스택 개발 스택입니다. 프론트엔드부터 백엔드, 데이터베이스까지 JavaScript와 JSON으로 일관되게 구축할 수 있습니다.]]></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">MongoDB Developer Workshop 후기 (with Google Cloud)</title><link href="https://rastalion.dev/writing/mongodb-developer-workshop-review/" rel="alternate" type="text/html" title="MongoDB Developer Workshop 후기 (with Google Cloud)" /><published>2022-04-22T14:29:18+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/mongodb-developer-workshop-review</id><content type="html" xml:base="https://rastalion.dev/writing/mongodb-developer-workshop-review/"><![CDATA[<p>오랜만에 포스팅입니다. 정말 오랜만이네요. 이직하고 나서 딱히 쓸 글이 없었습니다. 다들 저보다 잘하시고, 연차는 쌓여가는데 초급 포스팅만 계속 하기도 좀 그랬습니다.</p>

<p>이번에 MongoDB Korea에서 오프라인 핸즈온 세미나를 열었습니다.</p>

<p>전 세계 게임업계의 MongoDB 레퍼런스를 소개하고, Google Cloud의 BigQuery와 함께 MongoDB로 데이터 분석 파이프라인을 구축하는 법을 알 수 있는 좋은 기회였습니다.</p>

<p>또 MongoDB가 주력하는 관리형 클라우드 MongoDB 서비스인 Atlas의 핸즈온이 아젠다에 포함되어 있었습니다.</p>

<h3 id="세미나-시작">세미나 시작</h3>

<p>우선 전 세계 게임업계가 MongoDB를 어떻게 사용하고 있는지를 다룬 아젠다로 시작했습니다. 이번 아젠다에서 MongoDB 측이 소개한 게임 회사들은 아래와 같습니다.</p>

<ul>
  <li>스퀘어 에닉스</li>
  <li>텐센트</li>
  <li>Epic Games</li>
  <li>세가</li>
  <li>EA Sports</li>
  <li>이름이 기억 안나는 중국 2위의 게임 회사</li>
</ul>

<p>이런 큰 게임회사들이 MongoDB를 사용하는 이유와 이점을 설명했습니다.</p>

<blockquote>
  <p>중국의 거대 기업 텐센트는 왜 MongoDB를 사용하는가?</p>
</blockquote>

<ul>
  <li>유연한 개발 요구사항 충족</li>
  <li>주변 플레이어 지원 (위치 기반)</li>
  <li>대량의 데이터 지원 및 무중단 업그레이드</li>
  <li>운영 데이터 분석 지원</li>
  <li>개발언어와 접근성 호환성 높음 – Node.js</li>
</ul>

<blockquote>
  <p>스퀘어 에닉스의 효율성을 위한 빅데이터 게임 플랫폼 구축</p>
</blockquote>

<ul>
  <li>SQL 서버 비용 감소</li>
  <li>데이터 처리 능력 0.5TB/day</li>
  <li>데이터 분석 3weeks → 2min</li>
</ul>

<p>즉, MongoDB가 강조하는 장점은 <strong>“실시간 운영, 실시간 분석에 최적화된 데이터베이스”</strong>입니다. 운영과 분석을 동시에 할 수 있다는 점에 초점을 맞춰 소개했습니다.</p>

<p>그리고 MongoDB를 제대로 이해하지 못하고 사용하는 많은 사용자가 겪는 “MongoDB는 성능이 안 좋아요!”에 대한 해명도 해주셨죠.</p>

<ul>
  <li>20%의 사용자: MongoDB에 최적화된 모델링 설계를 하지 못함 (RDBMS처럼 설계)</li>
  <li>80%의 사용자: 인덱스를 제대로 활용하지 못함 (인덱스 설계가 매우 중요)</li>
</ul>

<h3 id="mongodb-to-bigquery-데이터-분석-파이프-라인">MongoDB to BigQuery 데이터 분석 파이프 라인</h3>

<p>앞서 MongoDB에서 자체적으로 분석할 수 있는데도 BigQuery를 사용해야 하는 이유를 다음과 같이 설명했습니다.</p>

<p>데이터 분석을 하게 되면 더 많은 요구사항과 더 많은 데이터를 다루게 되는데, 점점 커져가는 데이터 분석 환경에 따라 BigQuery 같은 뛰어난 제품이 필요하다는 것이 핵심 메시지였습니다.</p>

<p>BigQuery가 좋은 이유는 구글이 데이터 분석 플랫폼으로 시작한 회사이기 때문에 인프라보다 분석 기술에 집중했으며, 접근이 쉽다는 장점이 있습니다.</p>

<p>최근 데이터 분석 트렌드가 AI, 머신러닝 분야이며, 예전처럼 분석의 최종 지점이 시각화가 아닌 AI와 머신러닝 쪽으로 변해가고 있기 때문에 사용하기 쉬운 BigQuery가 적합하다고 설명했습니다. 도구가 쉬워질수록 분석가의 학습 곡선이 완만해져 접근이 편합니다.</p>

<p>그리고 기존에는 스토리지 비용이 비쌌기 때문에 DW 형태로 저장했지만, 머신러닝에는 원본 데이터를 그대로 저장하는 것이 필요합니다. BigQuery는 DW+Lake를 함께 사용할 수 있는 데이터 저장소이기 때문에 원본 데이터를 바로 저장해 머신러닝에 이용할 수 있다는 점이 특징입니다.</p>

<p>또한 데이터 분석을 위한 포괄적인 BI 솔루션인 ‘구글 데이터 스튜디오’(현재는 Looker Studio)가 무료라는 점도 강조했습니다.</p>

<p>GCP는 BigQuery를 혁신적이고 개방적인 서버리스 제품으로 소개했습니다. 완전 관리형 서비스로 증설이 필요 없고, 연산이 필요한 만큼 리소스를 모두 제공하기 때문에 사양에 얽매이지 않는 것이 서버리스의 장점입니다.</p>

<p>예를 들어 BigQuery에서 1,000억 건, 4.06TB 데이터를 처리하는 데 걸리는 시간은 20~30초 사이이며, 데이터 분석 시스템이 왜 빨라야 하는가에 대한 답변으로 “분석을 하는 사람들은 시간과 싸움을 하고 있으며, 분석이 빨라야 빠른 의사 결정이 이루어지기 때문”이라고 설명했습니다.</p>

<p>다른 DW처럼 Data Mart로 데이터 복제가 필요 없기에 별도의 자원 낭비가 없으며, Standard SQL을 지원합니다.</p>

<p>세미나 당시 소개된 비용 정보는 다음과 같습니다.</p>

<ul>
  <li>저장: 1TB / 월 $20</li>
  <li>장기 저장: 1TB / 월 $10</li>
</ul>

<p>또한 BigQuery는 데이터를 이전하지 않고 머신러닝에 바로 사용할 수 있습니다.</p>

<h4 id="데이터-분석-파이프라인">데이터 분석 파이프라인</h4>

<p>BigQuery에 데이터를 적재하는 방법을 소개했습니다. 초기 데이터를 적재하는 방법으로는 두 가지를 설명해 주셨고, 실시간 데이터 전송 파이프라인도 설명해 주셨습니다.</p>

<ul>
  <li>초기 로딩: Mongo → Cloud Dataflow → BigQuery</li>
  <li>초기 로딩: Mongo → ETL 도구 (Data Fusion) → BigQuery</li>
</ul>

<p><img src="/assets/img/wp/2022/04/6cc4c16a-868d-463d-bb1a-90d95aaf8c68.png" alt="" /></p>

<ul>
  <li>실시간 동기화: Mongo (Change Stream) → Cloud Pub/Sub (like Kafka) → Cloud Dataflow (분산 처리 엔진 like Spark) → BigQuery</li>
</ul>

<p><img src="/assets/img/wp/2022/04/37997a99-0a49-42bf-b86a-697d0481c45d.png" alt="" /></p>

<p>특히 MongoDB Change Stream 기능을 이용하여 변경 이벤트를 실시간으로 분석 시스템에 전달할 수 있다는 점으로 MongoDB와 BigQuery의 연계성을 강조했습니다.</p>

<h3 id="mongodb-핸즈온-세션">MongoDB 핸즈온 세션</h3>

<p>MongoDB Atlas는 AWS나 GCP, Azure 등의 퍼블릭 클라우드와 함께 사용할 수 있는 멀티 클라우드를 지원하는 관리형 MongoDB 클라우드 서비스입니다.</p>

<p>쉽게 DB 클러스터를 구축하고 배포하는 것뿐만 아니라 AWS, GCP와의 연계 등 하나의 클러스터에서 각각의 노드를 서로 다른 클라우드로 배포할 수 있는 것이 강점입니다.</p>

<p>동일한 데이터베이스 노드를 각기 다른 클라우드에 배포할 수 있다는 것이 큰 장점입니다.</p>

<p><img src="/assets/img/wp/2022/04/5ce8927f-697f-4717-921f-69bf1eca9850.png" alt="" /></p>

<p>직접 Atlas에서 클러스터를 배포하고, 간단한 MongoDB 실습까지 진행했습니다.</p>

<p>그동안 온라인을 통해서 기승전 Atlas였던 MongoDB의 웨비나들은 실습까지는 어려운 부분이 많았지만, 드디어 오프라인 세션이 열리면서 실습을 해볼 수 있는 환경이 마련된 것이 참 좋았습니다.</p>

<p>오랜만에 오프라인 행사여서 그런지 MongoDB Korea에서 굿즈도 많이 준비해 주셨고, 함께한 지인분이 2등 선물에 당첨되는 등 여러 가지 재미있는 순간들이 있었습니다.</p>

<p>그리고 DB 오픈 카톡방에서 활동하시는 다른 DBA분들과 마주하여 다양한 이야기를 나눌 수 있었던 기회가 된 것 같아 좋은 경험이었습니다.</p>

<p>이상 MongoDB Developer Workshop 후기였습니다.</p>

<p>읽어 주셔서 감사합니다.</p>]]></content><author><name>teinam</name></author><category term="mongodb" /><summary type="html"><![CDATA[MongoDB Korea의 오프라인 핸즈온 세미나 후기입니다. 게임업계 MongoDB 레퍼런스와 BigQuery 데이터 파이프라인, Atlas 핸즈온을 경험했습니다.]]></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">Redis 요약 정리</title><link href="https://rastalion.dev/writing/redis-summary/" rel="alternate" type="text/html" title="Redis 요약 정리" /><published>2021-11-23T05:54:56+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/redis-summary</id><content type="html" xml:base="https://rastalion.dev/writing/redis-summary/"><![CDATA[<p>Redis 시리즈를 마무리하며 핵심 내용을 정리합니다. 라이선스와 자료형의 변화, Sentinel과 Cluster의 차이, 데이터 영속성 메커니즘, 그리고 운영 시 주의할 포인트들을 빠르게 훑을 수 있도록 구성했습니다.</p>

<h2 id="라이선스와-자료형">라이선스와 자료형</h2>

<p>Redis는 8.0부터 제품 이름이 Redis Open Source로 바뀌고 라이선스가 3중 구조가 되었습니다. 사용자가 세 가지 중 하나를 골라 적용합니다 — Redis Source Available License v2(RSALv2), Server Side Public License v1(SSPLv1), GNU Affero General Public License v3(AGPLv3). 공식 문서는 RSALv2와 SSPLv1을 오픈소스 라이선스가 아니라고 명시하고, 세 가지 중 AGPLv3만 OSI 승인 오픈소스라고 적습니다.</p>

<table>
  <thead>
    <tr>
      <th>버전</th>
      <th>라이선스</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>7.2.x 이하</td>
      <td>BSD 3-Clause</td>
    </tr>
    <tr>
      <td>7.4.x ~ 7.8.x (Redis Community Edition)</td>
      <td>RSALv2 또는 SSPLv1</td>
    </tr>
    <tr>
      <td>8.0 이상 (Redis Open Source)</td>
      <td>RSALv2 · SSPLv1 · AGPLv3 중 택 1</td>
    </tr>
  </tbody>
</table>

<p>BSD 라이선스를 유지하려는 쪽에서는 Redis OSS 7.2.4를 포크한 Valkey가 나왔습니다. Valkey는 <code class="language-plaintext highlighter-rouge">INFO</code> 응답에 여전히 <code class="language-plaintext highlighter-rouge">redis_version:7.2.4</code>를 함께 보고하므로, 버전 문자열만 보고 제품을 판별하면 안 됩니다.</p>

<p>자료형도 2021년보다 넓어졌습니다. 현재 Redis Open Source가 구현하는 자료형은 String(Bitmap·Bitfield 포함), Array, Geospatial index, Hash, JSON, List, 확률형 자료형(Bloom filter·Cuckoo filter·Count-min sketch·HyperLogLog·t-digest·Top-K), Set, Sorted Set, Stream, Time series, Vector set입니다. 별도 모듈로 배포되던 RediSearch·RedisJSON·RedisTimeSeries·RedisBloom은 Redis 8부터 Redis Open Source의 구성 요소로 편입되어 같은 라이선스를 따릅니다.</p>

<h2 id="redis-cluster와-sentinel의-차이점">Redis Cluster와 Sentinel의 차이점</h2>

<h3 id="sentinel">Sentinel</h3>

<p>Redis Sentinel은 <strong>고가용성(HA)에 집중한 솔루션</strong>입니다. 단일 primary를 여러 개의 replica로 복제하고, primary 장애 시 자동으로 replica를 승격시킵니다.</p>

<p><strong>특징</strong></p>

<ul>
  <li>Redis 본체와 <strong>별도 프로세스</strong>로 동작합니다 (기본 포트 <strong>26379</strong>)</li>
  <li><strong>최소 3개 이상의 Sentinel 인스턴스</strong>가 필요합니다 (quorum 확보를 위해)</li>
  <li>Sentinel을 통해 접근하며, 클라이언트 라이브러리의 Sentinel 지원이 필요합니다</li>
  <li>단일 인스턴스와 마찬가지로 Redis의 모든 기능(0~15번 DB, 전체 명령)을 사용할 수 있습니다</li>
</ul>

<p><strong>Failover 동작 방식</strong></p>

<ol>
  <li><strong>SDOWN (Subjectively Down)</strong> — 개별 Sentinel이 primary로부터 30초(<code class="language-plaintext highlighter-rouge">down-after-milliseconds</code> 기본값 30000ms) 동안 <code class="language-plaintext highlighter-rouge">PING</code>/<code class="language-plaintext highlighter-rouge">INFO</code> 응답을 받지 못하면 “주관적 다운”으로 판단합니다</li>
  <li><strong>ODOWN (Objectively Down)</strong> — 설정한 quorum 이상의 Sentinel이 동시에 다운을 인지하면 “객관적 다운”으로 인정합니다. 실제 failover 개시에는 Sentinel 과반의 투표가 추가로 필요합니다</li>
  <li>Sentinel <strong>과반(majority)</strong>의 투표로 failover 리더를 선출합니다</li>
  <li>리더가 replica 중 하나를 primary로 승격시키고, 기존 primary는 replica로 강등합니다</li>
  <li>다른 replica들은 새 primary로부터 데이터를 받도록 재구성됩니다</li>
</ol>

<blockquote>
  <p>과반수가 필요한 이유: 네트워크 파티션 상황에서 소수 파티션이 독단적으로 failover를 하지 못하도록 방지합니다. 예를 들어 Sentinel 5개 중 2개만 격리되면, 격리된 쪽은 과반을 확보하지 못해 failover를 시도할 수 없습니다.</p>
</blockquote>

<p><strong>주의사항</strong></p>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>문제</th>
      <th>해결책</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Failover 중 쓰기 실패</td>
      <td>Failover Timeout 만큼 쓰기 요청이 실패합니다</td>
      <td>데이터량에 따라 최적의 timeout 값을 찾고 <code class="language-plaintext highlighter-rouge">sentinel.conf</code>에 적용</td>
    </tr>
    <tr>
      <td>Replica → Primary 승격 실패</td>
      <td>Replica 다운 → Primary 다운 → Replica 재시작 순서일 때, 재시작된 서버는 여전히 <code class="language-plaintext highlighter-rouge">redis.conf</code>에 <code class="language-plaintext highlighter-rouge">replicaof</code> 설정을 갖고 있어 Primary로 전환되지 않음</td>
      <td>재시작 전 <code class="language-plaintext highlighter-rouge">replicaof</code> 설정을 삭제하거나, 수동으로 <code class="language-plaintext highlighter-rouge">REPLICAOF NO ONE</code> 실행</td>
    </tr>
    <tr>
      <td>Primary 정보 조회 오류</td>
      <td>Primary/Replica 모두 다운 후 <code class="language-plaintext highlighter-rouge">get-master-addr-by-name</code>으로 조회하면 다운된 서버 정보를 반환</td>
      <td><code class="language-plaintext highlighter-rouge">INFO sentinel</code> 명령으로 primary status를 직접 확인</td>
    </tr>
  </tbody>
</table>

<p><strong>단점</strong></p>

<ul>
  <li>Single primary 구조이므로 데이터가 커지면 <strong>수직 확장(scale-up)</strong>이 필요합니다</li>
  <li>샤딩을 제공하지 않습니다</li>
</ul>

<h3 id="cluster">Cluster</h3>

<p>Redis Cluster는 <strong>고가용성(HA)과 샤딩을 동시에 제공</strong>하는 Redis 자체 클러스터링 시스템입니다.</p>

<p><strong>특징</strong></p>

<ul>
  <li>모든 데이터를 primary 단위로 샤딩하고, replica 단위로 복제합니다</li>
  <li><strong>최소 3개의 primary 노드</strong>가 필요하며, 배포 권장은 <strong>6노드(primary 3 + replica 3)</strong>입니다</li>
  <li>Primary마다 최소 하나 이상의 replica를 두는 것이 좋습니다</li>
</ul>

<p><strong>해시 슬롯(Hash Slot)을 이용한 샤딩</strong></p>

<p>Redis Cluster는 CRC-16 해시 함수를 이용해 키를 정수로 변환하고, 그 값을 <strong>16384</strong>로 모듈 연산합니다. 16384개의 슬롯을 primary 노드 수만큼 균등하게 분할합니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>slot <span class="o">=</span> CRC16<span class="o">(</span>key<span class="o">)</span> mod 16384
</code></pre></div></div>

<p>예를 들어 primary 3개라면:</p>
<ul>
  <li>노드 A: 슬롯 0~5460</li>
  <li>노드 B: 슬롯 5461~10922</li>
  <li>노드 C: 슬롯 10923~16383</li>
</ul>

<p><strong>클러스터 HA 동작</strong></p>

<ul>
  <li>Primary 노드가 shutdown되면 <strong>gossip protocol</strong>로 상태를 확인하고, replica 중 하나를 primary로 승격시킵니다</li>
  <li>Gossip은 Redis 노드 간 직접 연결로 동작하며, 기본값은 <strong>클라이언트 포트 + 10000</strong> 입니다 (예: 클라이언트 포트 6379 → 버스 포트 16379). <code class="language-plaintext highlighter-rouge">cluster-port</code> 설정으로 따로 지정할 수 있습니다</li>
  <li>기존 primary가 재시작되면 자동으로 승격된 replica의 새 replica로 구성됩니다</li>
  <li><strong>primary 과반에 도달하지 못한 노드는 쿼리 수용을 중단합니다</strong> (<code class="language-plaintext highlighter-rouge">cluster-node-timeout</code> 경과 후)</li>
</ul>

<p><strong>클러스터 관리</strong></p>

<p>Redis 8.x에서는 <code class="language-plaintext highlighter-rouge">redis-cli --cluster</code> 명령으로 클러스터를 관리합니다. 기존의 <code class="language-plaintext highlighter-rouge">redis-trib.rb</code>는 deprecation stub으로 대체되었습니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 클러스터 생성 (6노드, replica 1개씩)</span>
redis-cli <span class="nt">--cluster</span> create 127.0.0.1:7000 ... 127.0.0.1:7005 <span class="se">\</span>
  <span class="nt">--cluster-replicas</span> 1

<span class="c"># 노드 추가</span>
redis-cli <span class="nt">--cluster</span> add-node 127.0.0.1:7006 127.0.0.1:7000

<span class="c"># 리샤딩</span>
redis-cli <span class="nt">--cluster</span> reshard 127.0.0.1:7000

<span class="c"># 상태 점검</span>
redis-cli <span class="nt">--cluster</span> check 127.0.0.1:7000
</code></pre></div></div>

<p><strong>주의사항</strong></p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>제약 / 이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>멀티 DB 불가</td>
      <td>클러스터 모드에서는 <strong>0번 DB만</strong> 사용 가능합니다. <code class="language-plaintext highlighter-rouge">SELECT</code> 명령을 실행할 수 없습니다</td>
    </tr>
    <tr>
      <td>멀티키 연산 제약</td>
      <td><code class="language-plaintext highlighter-rouge">MGET</code>, <code class="language-plaintext highlighter-rouge">MSET</code>, <code class="language-plaintext highlighter-rouge">SUNION</code> 등 여러 키를 다루는 명령은 <strong>모든 키가 같은 슬롯</strong>에 있어야 합니다. 해시 태그 <code class="language-plaintext highlighter-rouge">{user}:profile</code>, <code class="language-plaintext highlighter-rouge">{user}:account</code> 형태로 같은 슬롯을 보장할 수 있습니다</td>
    </tr>
    <tr>
      <td>클라이언트 리다이렉션</td>
      <td>쿼리를 받은 노드가 해당 키를 갖고 있지 않으면 <code class="language-plaintext highlighter-rouge">MOVED</code> 에러와 함께 올바른 노드 정보를 반환합니다. 클라이언트는 다시 요청해야 합니다</td>
    </tr>
    <tr>
      <td>메모리 오버헤드</td>
      <td>슬롯 테이블을 메모리에 유지하므로 키가 많아질수록 오버헤드가 증가합니다</td>
    </tr>
    <tr>
      <td>Failover 중 슬롯 접근 불가</td>
      <td>Primary 장애 시 replica가 승격될 때까지 해당 슬롯의 키는 사용할 수 없습니다</td>
    </tr>
  </tbody>
</table>

<p><strong>Docker / NAT 환경</strong></p>

<p>공식 문서는 Redis Cluster가 NAT 환경과 IP·포트가 재매핑되는 환경을 지원하지 않는다고 못 박고, Docker에서는 호스트 네트워킹 모드(<strong><code class="language-plaintext highlighter-rouge">--net=host</code></strong>)를 쓰라고 안내합니다. 포트를 그대로 노출할 수 없다면 <code class="language-plaintext highlighter-rouge">redis.conf</code>에서 노드가 알릴 주소를 정적으로 지정합니다:</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cluster</span>-<span class="n">announce</span>-<span class="n">ip</span> <span class="m">10</span>.<span class="m">1</span>.<span class="m">1</span>.<span class="m">5</span>
<span class="n">cluster</span>-<span class="n">announce</span>-<span class="n">port</span> <span class="m">6379</span>
<span class="n">cluster</span>-<span class="n">announce</span>-<span class="n">tls</span>-<span class="n">port</span> <span class="m">6380</span>
<span class="n">cluster</span>-<span class="n">announce</span>-<span class="n">bus</span>-<span class="n">port</span> <span class="m">16379</span>
</code></pre></div></div>

<h3 id="redis-샤딩-직접-구현">Redis 샤딩 (직접 구현)</h3>

<p>클러스터 기능을 사용하지 않고 직접 샤딩을 구현할 수도 있습니다. 일반적으로 <strong>Consistent Hashing</strong> 아키텍처를 사용합니다.</p>

<p><strong>일반 모듈러 해싱 vs Consistent Hashing</strong></p>

<table>
  <thead>
    <tr>
      <th>방식</th>
      <th>동작</th>
      <th>서버 추가/장애 시</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>모듈러 해싱</td>
      <td><code class="language-plaintext highlighter-rouge">hash(key) mod N</code></td>
      <td>서버 수가 바뀌면 대부분의 키가 다른 서버로 이동 (리밸런싱 비용 큼)</td>
    </tr>
    <tr>
      <td>Consistent Hashing</td>
      <td>해시 링에서 자기보다 큰 가장 가까운 서버로 매핑</td>
      <td>전체의 1/N만 이동 (영향 범위 제한)</td>
    </tr>
  </tbody>
</table>

<p><strong>샤딩 전략</strong></p>

<ul>
  <li><strong>Range</strong> — 특정 범위(예: A-M, N-Z)를 정의해 저장. 데이터가 특정 범위에 몰리거나 비어있을 수 있습니다</li>
  <li><strong>Modular</strong> — 하나씩 추가하면 리밸런싱이 빈번하므로, <strong>2배씩 늘려</strong> 규칙적으로 데이터를 이동시킵니다</li>
  <li><strong>Indexed</strong> — 키가 저장될 위치를 관리 서버가 따로 보관. 인덱스 서버가 SPOF가 됩니다</li>
</ul>

<p>Consistent Hashing도 완벽하게 균등하지는 않으므로, virtual node를 둔 Adaptive Consistent Hashing을 고려할 수 있습니다.</p>

<h2 id="data-persistence-데이터-영속성">Data Persistence (데이터 영속성)</h2>

<p>Redis는 인메모리 DB이지만 디스크에 데이터를 기록하는 두 가지 방식을 제공합니다.</p>

<h3 id="rdb-redis-database">RDB (Redis Database)</h3>

<p>주기적으로 <strong>point-in-time 스냅샷</strong>을 생성합니다.</p>

<p><strong>장점</strong></p>

<ul>
  <li><strong>단일 파일</strong>로 저장되어 백업과 복구가 간편합니다</li>
  <li>부모 프로세스가 자식 프로세스를 fork해 persist I/O를 처리하므로, 메인 프로세스 성능에 미치는 영향이 적습니다</li>
  <li><strong>재시작 시간이 짧습니다</strong> (snapshot을 그대로 메모리에 로드)</li>
</ul>

<p><strong>단점</strong></p>

<ul>
  <li>스냅샷 간격 사이에 Redis가 멈추면 <strong>데이터 유실 가능성</strong>이 있습니다</li>
  <li>Fork 시 순간적으로 메모리가 2배까지 증가할 수 있습니다 (Copy-on-Write)</li>
</ul>

<p><strong>설정</strong></p>

<p>배포되는 <code class="language-plaintext highlighter-rouge">redis.conf</code>에는 세 개의 저장 지점이 기본값으로 적혀 있습니다.</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">save</span> <span class="m">3600</span> <span class="m">1</span> <span class="m">300</span> <span class="m">100</span> <span class="m">60</span> <span class="m">10000</span>
<span class="c"># 3600초 동안 1번 이상 변경 or 300초 동안 100번 이상 변경 or 60초 동안 10000번 이상 변경
</span></code></pre></div></div>

<p>비활성화: <code class="language-plaintext highlighter-rouge">save ""</code></p>

<h3 id="aof-append-only-file">AOF (Append Only File)</h3>

<p>모든 쓰기 작업을 <strong>로그로 기록</strong>하고, 재시작 시점에 replay합니다.</p>

<p><strong>장점</strong></p>

<ul>
  <li>RDB보다 <strong>데이터 유실 가능성이 낮습니다</strong></li>
  <li>증분 파일은 Redis 프로토콜과 같은 포맷이라 사람이 읽고 편집할 수 있습니다. 예를 들어 실수로 <code class="language-plaintext highlighter-rouge">FLUSHALL</code>을 실행했다면, rewrite가 일어나기 전이라면 서버를 멈추고 그 명령 한 줄을 지워 되살릴 수 있습니다</li>
</ul>

<p><strong>단점</strong></p>

<ul>
  <li>같은 데이터셋에 대해 <strong>RDB보다 파일 크기가 큽니다</strong></li>
  <li>쓰기 요청이 많을 때 RDB보다 <strong>반응성이 느릴 수 있습니다</strong></li>
</ul>

<p><strong>AOF Rewrite</strong></p>

<p>데이터가 많아지면 현재 시점의 데이터셋을 만들어낼 수 있는 <strong>최소한의 로그만 남기고 압축</strong>합니다. 예를 들어 같은 키에 100번의 <code class="language-plaintext highlighter-rouge">SET</code>이 있었다면 마지막 값 하나만 기록합니다.</p>

<p>Redis 7.0부터는 AOF가 단일 파일이 아니라 <strong>multi-part AOF</strong> 구조입니다. base 파일(최대 1개, RDB 또는 AOF 포맷 스냅샷)과 증분 파일 여러 개를 <code class="language-plaintext highlighter-rouge">appenddirname</code> 디렉터리에 두고 manifest 파일로 추적합니다. rewrite 중에는 부모 프로세스가 새 증분 파일에 계속 기록하므로, 예전처럼 rewrite 중 쓰기가 메모리에 버퍼링되지 않습니다.</p>

<p><strong>설정</strong></p>

<p>AOF는 기본적으로 꺼져 있고(<code class="language-plaintext highlighter-rouge">appendonly no</code>), 켜면 <code class="language-plaintext highlighter-rouge">appendfsync</code>의 기본값은 <code class="language-plaintext highlighter-rouge">everysec</code>입니다.</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">appendonly</span> <span class="n">yes</span>
<span class="n">appendfsync</span> <span class="n">everysec</span>   <span class="c"># always | everysec | no
</span></code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>옵션</th>
      <th>의미</th>
      <th>내구성</th>
      <th>성능</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">always</code></td>
      <td>모든 쓰기마다 fsync</td>
      <td>가장 안전 (1개 명령까지만 유실)</td>
      <td>가장 느림</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">everysec</code></td>
      <td>1초마다 fsync</td>
      <td>1초 분량 유실 가능</td>
      <td>균형</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">no</code></td>
      <td>OS에 맡김</td>
      <td>최대 수십 초 유실 가능</td>
      <td>가장 빠름</td>
    </tr>
  </tbody>
</table>

<p><strong>권장 설정</strong></p>

<ul>
  <li>관계형 DB 수준의 데이터 안전성이 필요하면 공식 문서는 <strong>RDB와 AOF를 함께 쓰라</strong>고 권합니다. AOF 단독 구성은 백업·재시작 속도와 AOF 엔진 버그 대비 때문에 권하지 않습니다</li>
  <li>몇 분 정도의 유실을 감수할 수 있다면 RDB만 써도 됩니다. 캐시로만 쓴다면 둘 다 off해도 됩니다</li>
  <li>둘 다 켜져 있으면 재시작 시 <strong>AOF로 복구</strong>합니다. AOF가 더 완전한 데이터셋을 보장하기 때문입니다</li>
  <li>복제를 쓴다면 primary와 replica <strong>양쪽에 영속성을 켜는 것</strong>이 공식 권고입니다</li>
</ul>

<blockquote>
  <p><strong>WARNING</strong> — 디스크가 느려서 primary의 영속성을 껐다면 <strong>자동 재시작을 반드시 끄십시오.</strong> 빈 데이터셋으로 살아난 primary가 replica 전체를 비워 버립니다. Sentinel 환경에서는 primary가 너무 빨리 재시작되어 Sentinel이 장애를 감지하지 못하는 경로로 같은 일이 벌어집니다.</p>
</blockquote>

<h2 id="redis-사용-시-주의사항">Redis 사용 시 주의사항</h2>

<h3 id="1-collection-크기-제한">1. Collection 크기 제한</h3>

<p><strong>Collection 안에 너무 많은 아이템을 저장하지 마세요.</strong> Hash/Set/Sorted Set은 규격상 2³²-1 요소까지 가능하지만, 큰 컬렉션은 O(N) 명령(예: <code class="language-plaintext highlighter-rouge">HGETALL</code>)과 메모리 사용으로 문제가 될 수 있습니다. <code class="language-plaintext highlighter-rouge">redis-cli --bigkeys</code>로 진단하세요.</p>

<ul>
  <li>Hash, Sorted Set, Set은 메모리를 많이 사용합니다</li>
  <li>작은 컬렉션은 listpack으로 압축 저장되고, <code class="language-plaintext highlighter-rouge">hash-max-listpack-entries</code> 같은 임계값을 넘으면 해시테이블·스킵리스트 인코딩으로 전환됩니다. 임계값을 크게 올리면 메모리는 줄지만 조회가 선형 탐색에 가까워집니다</li>
  <li>Hash는 Redis 7.4부터 <code class="language-plaintext highlighter-rouge">HEXPIRE</code> 계열 명령으로 필드별 TTL이 가능하고, Set/Sorted Set/List는 여전히 키 단위 TTL만 지원합니다</li>
</ul>

<h3 id="2-메모리-관리">2. 메모리 관리</h3>

<p><strong>메모리 모니터링과 관리가 필수입니다.</strong></p>

<ul>
  <li>Redis는 자기가 사용하는 정확한 메모리 양을 모릅니다 (단편화 포함)</li>
  <li>쓰기가 많은 Redis는 fork 시 메모리를 <strong>최대 2배까지 사용</strong>할 수 있습니다 (Copy-on-Write)</li>
  <li><strong>작은 인스턴스 여러 개</strong>로 나누는 것이 큰 인스턴스 하나보다 안전합니다</li>
</ul>

<p><strong><code class="language-plaintext highlighter-rouge">maxmemory-policy</code> 선택</strong></p>

<p>Redis 8.6 기준 축출 정책은 다음과 같습니다:</p>

<table>
  <thead>
    <tr>
      <th>정책</th>
      <th>대상</th>
      <th>동작</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">noeviction</code> (기본값)</td>
      <td>—</td>
      <td>메모리 초과 시 쓰기 요청에 에러 반환. 읽기는 정상</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">allkeys-lru</code></td>
      <td>모든 키</td>
      <td>최근 사용이 가장 오래된 키 축출</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">allkeys-lrm</code></td>
      <td>모든 키</td>
      <td><strong>Least Recently Modified</strong> (쓰기 시점만 추적, 8.6 신규)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">allkeys-lfu</code></td>
      <td>모든 키</td>
      <td>최소 빈도 사용 키 축출 (4.0+)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">allkeys-random</code></td>
      <td>모든 키</td>
      <td>무작위</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">volatile-lru</code> / <code class="language-plaintext highlighter-rouge">lrm</code> / <code class="language-plaintext highlighter-rouge">lfu</code> / <code class="language-plaintext highlighter-rouge">random</code> / <code class="language-plaintext highlighter-rouge">ttl</code></td>
      <td>TTL 설정된 키만</td>
      <td>TTL 키가 없으면 <code class="language-plaintext highlighter-rouge">noeviction</code>처럼 동작</td>
    </tr>
  </tbody>
</table>

<p><strong>선택 기준</strong></p>

<ul>
  <li>일부 키가 나머지보다 훨씬 자주 접근된다면 → <strong><code class="language-plaintext highlighter-rouge">allkeys-lru</code></strong> (문서의 “좋은 기본값”)</li>
  <li>읽기는 많지만 갱신 여부로 신선도를 판단하고 싶다면 → <strong><code class="language-plaintext highlighter-rouge">allkeys-lrm</code></strong></li>
  <li>모든 키가 대략 균등 접근(반복 순회)한다면 → <strong><code class="language-plaintext highlighter-rouge">allkeys-random</code></strong></li>
  <li>가능하면 캐시와 영속 키를 <strong>인스턴스 분리</strong>하세요. <code class="language-plaintext highlighter-rouge">volatile-*</code>은 한 인스턴스에 섞어 쓰는 경우용이지만 권장하지 않습니다</li>
</ul>

<h3 id="3-단일-스레드와-io-threads">3. 단일 스레드와 <code class="language-plaintext highlighter-rouge">io-threads</code></h3>

<p><strong>명령 실행은 여전히 한 번에 하나씩입니다.</strong> Redis 6.0에 도입된 <code class="language-plaintext highlighter-rouge">io-threads</code>는 소켓 읽기·쓰기와 프로토콜 파싱을 별도 스레드로 넘기는 설정이고, 명령 실행 자체를 병렬화하지는 않습니다.</p>

<p><code class="language-plaintext highlighter-rouge">redis.conf</code>에서 이 스레딩은 <strong>기본적으로 꺼져 있습니다.</strong> 공식 설정 파일은 코어가 4개 이상인 머신에서만 켜기를 권하고, <code class="language-plaintext highlighter-rouge">io-threads 1</code>은 예전처럼 메인 스레드만 쓴다는 뜻이라고 적습니다.</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># redis.conf (기본 비활성)
# io-threads 4
</span></code></pre></div></div>

<p><strong>주의할 명령</strong></p>

<p>시간이 오래 걸리는 명령은 다른 클라이언트를 블로킹합니다:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">KEYS</code> 패턴</strong> — O(N). 프로덕션에서 절대 사용하지 마세요. 대신 <code class="language-plaintext highlighter-rouge">SCAN</code> 사용</li>
  <li><strong><code class="language-plaintext highlighter-rouge">FLUSHALL</code> / <code class="language-plaintext highlighter-rouge">FLUSHDB</code></strong> — 모든 키 삭제</li>
  <li><strong><code class="language-plaintext highlighter-rouge">DEL</code> Collection</strong> — Collection이 크면 <code class="language-plaintext highlighter-rouge">UNLINK</code>(비동기 삭제) 사용</li>
  <li><strong>Get All Collections</strong> — <code class="language-plaintext highlighter-rouge">HGETALL</code>, <code class="language-plaintext highlighter-rouge">SMEMBERS</code> 등. 요소가 많으면 <code class="language-plaintext highlighter-rouge">HSCAN</code>, <code class="language-plaintext highlighter-rouge">SSCAN</code> 사용</li>
  <li>큰 컬렉션을 읽을 때는 <code class="language-plaintext highlighter-rouge">HSCAN</code>, <code class="language-plaintext highlighter-rouge">SSCAN</code>, <code class="language-plaintext highlighter-rouge">ZSCAN</code> 커서 순회를 사용하세요</li>
</ul>

<p><strong>진단 도구</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 빅키 / 메모리 많이 쓰는 키 찾기 (SCAN 기반)</span>
redis-cli <span class="nt">--bigkeys</span>
redis-cli <span class="nt">--memkeys</span>
redis-cli <span class="nt">--keystats</span>

<span class="c"># 핫키 찾기 (maxmemory-policy가 *lfu일 때만 동작)</span>
redis-cli <span class="nt">--hotkeys</span>

<span class="c"># 레이턴시 측정</span>
redis-cli <span class="nt">--latency</span>
redis-cli <span class="nt">--latency-history</span>
</code></pre></div></div>

<h3 id="4-replication-사용-시-주의사항">4. Replication 사용 시 주의사항</h3>

<p><strong>비동기 복제 특성</strong></p>

<ul>
  <li>Replication은 <strong>비동기 방식</strong>입니다. Primary에서 ack된 쓰기도 failover 시 유실될 수 있습니다</li>
  <li><code class="language-plaintext highlighter-rouge">WAIT</code> 명령으로 N개 replica의 ack를 확인할 수 있지만, 이것으로 CP 강일관성이 되지는 않습니다. 유실 확률을 크게 낮추는 장치일 뿐입니다</li>
</ul>

<p><strong>설정 지시어</strong></p>

<p>Redis 5.0부터 <strong>slave 표기가 replica로 바뀌었고</strong>, master/primary 표기는 문서·옵션명에 혼재합니다:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">REPLICAOF &lt;host&gt; &lt;port&gt;</code> — replica 설정 (구 명령 <code class="language-plaintext highlighter-rouge">SLAVEOF</code>는 deprecated이지만 호환성을 위해 동작)</li>
  <li><code class="language-plaintext highlighter-rouge">replicaof &lt;host&gt; &lt;port&gt;</code> — 설정 파일 지시어</li>
  <li><code class="language-plaintext highlighter-rouge">replica-read-only yes</code> — 기본값. Replica를 읽기 전용으로 유지 (writable replica는 비권장)</li>
</ul>

<p><strong>기타 주의사항</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">redis-cli --rdb</code>는 복제 첫 동기화와 같은 경로로 RDB를 받아 갑니다. fork와 전체 데이터셋 전송이 일어나므로 운영 인스턴스에 아무 때나 걸지 않습니다</li>
  <li>연결이 끊긴 동안 밀린 양이 primary의 replication backlog를 넘어서면 partial resync가 불가능해져 <strong>full sync</strong>로 떨어집니다. 이때 fork와 전송 부하가 다시 발생합니다</li>
</ul>

<h3 id="5-권장-운영-설정">5. 권장 운영 설정</h3>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 클라이언트 연결 수
</span><span class="n">maxclients</span> <span class="m">10000</span>   <span class="c"># 기본값. 파일 디스크립터 한도에 걸리면 실제 상한이 더 낮아짐
</span>
<span class="c"># 영속성
</span><span class="n">save</span> <span class="s2">""</span>           <span class="c"># RDB off (캐시용)
</span><span class="n">appendonly</span> <span class="n">no</span>     <span class="c"># AOF off (캐시용, 기본값)
</span>
<span class="c"># 위험 명령은 rename-command 대신 ACL 로 차단
# ACL SETUSER default -keys -flushall -flushdb -config
</span>
<span class="c"># 보안
</span><span class="n">requirepass</span> &lt;강력한<span class="err">_</span>비밀번호&gt;
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">maxclients</code>는 프로세스가 열 수 있는 파일 디스크립터 soft limit을 확인해 그보다 크면 자동으로 낮춰 잡습니다. 연결 수를 올릴 때는 <code class="language-plaintext highlighter-rouge">ulimit -n</code>도 같이 올려야 합니다. 명령 차단에는 <code class="language-plaintext highlighter-rouge">rename-command</code>를 쓰지 않는 편이 낫습니다 — 공식 설정 파일이 이 항목을 deprecated로 표시하고, 기본 사용자에서 명령을 제거하는 ACL 방식을 권합니다.</p>

<p><code class="language-plaintext highlighter-rouge">KEYS</code>와 예기치 않은 <code class="language-plaintext highlighter-rouge">save</code> 트리거는 대표적인 장애 원인입니다. <code class="language-plaintext highlighter-rouge">KEYS</code>는 O(N)이라 정규 애플리케이션 코드에서 사용하지 말아야 하고, <code class="language-plaintext highlighter-rouge">save</code> 설정은 “N초 안에 키가 M개 바뀌면 자동으로 RDB를 dump”하는데, 예상치 못한 시점에 fork가 발생해 메모리를 2배로 소비할 수 있습니다.</p>

<hr />

<p>Sentinel과 Cluster의 역할 차이, 영속성 메커니즘, 그리고 운영에서 피해야 할 함정 — 이 세 가지가 Redis 운영의 뼈대입니다. 버전이 올라가면서 라이선스와 자료형은 크게 바뀌었지만, 단일 스레드로 명령을 처리하고 fork로 디스크에 쓴다는 기본 구조는 그대로입니다.</p>]]></content><author><name>teinam</name></author><category term="redis" /><summary type="html"><![CDATA[Redis 시리즈를 마무리하며 라이선스와 자료형 변화, Sentinel과 Cluster의 차이, 영속성 메커니즘, 운영 시 주의할 점을 정리합니다.]]></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 utf8mb4_0900_ai_ci의 한글 비교 문제와 콜레이션 선택</title><link href="https://rastalion.dev/writing/mysql-utf8mb4-0900-ai-ci-korean-issue/" rel="alternate" type="text/html" title="MySQL utf8mb4_0900_ai_ci의 한글 비교 문제와 콜레이션 선택" /><published>2021-11-18T00:50:32+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://rastalion.dev/writing/mysql-utf8mb4-0900-ai-ci-korean-issue</id><content type="html" xml:base="https://rastalion.dev/writing/mysql-utf8mb4-0900-ai-ci-korean-issue/"><![CDATA[<p>MySQL 8.0.1에서 기본 문자셋과 콜레이션이 <code class="language-plaintext highlighter-rouge">utf8mb4</code>·<code class="language-plaintext highlighter-rouge">utf8mb4_0900_ai_ci</code>로 바뀌었습니다.<sup id="fnref:default" role="doc-noteref"><a href="#fn:default" class="footnote" rel="footnote">1</a></sup> 이 콜레이션에서는 완성형 <code class="language-plaintext highlighter-rouge">가나다</code>와 호환 자모 <code class="language-plaintext highlighter-rouge">ㄱㅏㄴㅏㄷㅏ</code>가 <code class="language-plaintext highlighter-rouge">=</code> 비교에서 같은 값입니다. 한글이 깨지거나 저장된 문자열이 바뀌는 문제가 아니라, <strong>서로 다른 표기를 어디까지 같은 값으로 볼 것인가</strong>의 문제입니다.</p>

<p>이 글은 2026-09-20에 MySQL 8.4·9.7 공식 매뉴얼과 유니코드 표준을 확인해 갱신했습니다. SQL 결과는 <strong>MySQL Community Server 8.4.11</strong>에서 재현했습니다. 다른 버전이나 관리형 서비스에서는 아래 쿼리로 실제 동작을 확인해야 합니다.</p>

<h2 id="이름과-적용-범위부터-확인합니다">이름과 적용 범위부터 확인합니다</h2>

<p><code class="language-plaintext highlighter-rouge">utf8mb4_0900_ai_ci</code>의 이름은 다음 뜻입니다.<sup id="fnref:unicode" role="doc-noteref"><a href="#fn:unicode" class="footnote" rel="footnote">2</a></sup></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">utf8mb4</code>: 문자 하나를 최대 4바이트로 표현하는 UTF-8 문자셋입니다.</li>
  <li><code class="language-plaintext highlighter-rouge">0900</code>: Unicode Collation Algorithm(UCA) 9.0.0에 기반한 비교·정렬 규칙입니다.</li>
  <li><code class="language-plaintext highlighter-rouge">ai</code>: 악센트를 구분하지 않습니다. 예를 들어 <code class="language-plaintext highlighter-rouge">e</code>와 <code class="language-plaintext highlighter-rouge">é</code>가 같습니다.</li>
  <li><code class="language-plaintext highlighter-rouge">ci</code>: 대소문자를 구분하지 않습니다. 예를 들어 <code class="language-plaintext highlighter-rouge">a</code>와 <code class="language-plaintext highlighter-rouge">A</code>가 같습니다.</li>
</ul>

<p>이 글의 <code class="language-plaintext highlighter-rouge">0900_ai_ci</code>는 1차 가중치로 비교하고, <code class="language-plaintext highlighter-rouge">0900_as_ci</code>는 2차까지, <code class="language-plaintext highlighter-rouge">0900_as_cs</code>는 3차까지 비교합니다. 3차 차이에는 대소문자 외의 문자 형태 차이도 포함되므로, 한글에 대소문자가 없다고 해서 <code class="language-plaintext highlighter-rouge">ci</code>와 <code class="language-plaintext highlighter-rouge">cs</code>의 결과가 항상 같은 것은 아닙니다.<sup id="fnref:uca" role="doc-noteref"><a href="#fn:uca" class="footnote" rel="footnote">3</a></sup></p>

<p>UCA 9.0 기반 콜레이션은 이전 UCA 기반 콜레이션보다 빠르도록 구현되어 있지만, 그것만으로 기본값 변경의 이유나 모든 데이터에서의 성능 우위를 설명할 수는 없습니다. 보조 문자 지원과 비교 정확성도 함께 봐야 합니다.<sup id="fnref:unicode:1" role="doc-noteref"><a href="#fn:unicode" class="footnote" rel="footnote">2</a></sup></p>

<h2 id="완성형과-호환-자모가-같은-값으로-검색됩니다">완성형과 호환 자모가 같은 값으로 검색됩니다</h2>

<p><code class="language-plaintext highlighter-rouge">mysql</code> CLI에서 실습용 데이터베이스를 선택한 뒤, 같은 연결에서 아래 쿼리를 실행합니다. 임시 테이블은 연결을 종료하면 사라집니다.</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="k">COLLATE</span> <span class="n">utf8mb4_0900_ai_ci</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">character_set_connection</span><span class="p">,</span> <span class="o">@@</span><span class="n">collation_connection</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">TEMPORARY</span> <span class="k">TABLE</span> <span class="n">korean_collation_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">name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">30</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</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">korean_collation_demo</span> <span class="p">(</span><span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">)</span>
<span class="k">VALUES</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'가나다'</span><span class="p">),</span> <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'ㄱㅏ나다'</span><span class="p">),</span> <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'ㄱㅏㄴㅏㄷㅏ'</span><span class="p">);</span>

<span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="k">CHAR_LENGTH</span><span class="p">(</span><span class="n">name</span><span class="p">)</span> <span class="k">AS</span> <span class="n">chars</span><span class="p">,</span>
       <span class="k">LENGTH</span><span class="p">(</span><span class="n">name</span><span class="p">)</span> <span class="k">AS</span> <span class="n">bytes</span><span class="p">,</span> <span class="n">HEX</span><span class="p">(</span><span class="n">name</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hex_value</span>
<span class="k">FROM</span> <span class="n">korean_collation_demo</span>
<span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'가나다'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">id</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>id</th>
      <th>name</th>
      <th>chars</th>
      <th>bytes</th>
      <th>hex_value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>가나다</td>
      <td>3</td>
      <td>9</td>
      <td>EAB080EB8298EB8BA4</td>
    </tr>
    <tr>
      <td>2</td>
      <td>ㄱㅏ나다</td>
      <td>4</td>
      <td>12</td>
      <td>E384B1E3858FEB8298EB8BA4</td>
    </tr>
    <tr>
      <td>3</td>
      <td>ㄱㅏㄴㅏㄷㅏ</td>
      <td>6</td>
      <td>18</td>
      <td>E384B1E3858FE384B4E3858FE384B7E3858F</td>
    </tr>
  </tbody>
</table>

<p>바이트도 문자 수도 다른 세 행이 모두 나옵니다. 조건을 <code class="language-plaintext highlighter-rouge">name = 'ㄱㅏ나다'</code>나 <code class="language-plaintext highlighter-rouge">name = 'ㄱㅏㄴㅏㄷㅏ'</code>로 바꿔도 같은 세 행이 나옵니다. 반면 비교식에 <code class="language-plaintext highlighter-rouge">utf8mb4_general_ci</code>를 명시하면 첫 번째 행만 나옵니다.</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">name</span>
<span class="k">FROM</span> <span class="n">korean_collation_demo</span>
<span class="k">WHERE</span> <span class="n">name</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_general_ci</span> <span class="o">=</span> <span class="s1">'가나다'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">id</span><span class="p">;</span>
</code></pre></div></div>

<p>이 예제는 <code class="language-plaintext highlighter-rouge">=</code> 비교에 관한 것입니다. <code class="language-plaintext highlighter-rouge">LIKE</code>는 문자 단위로 비교하므로 같은 결과라고 가정하면 안 됩니다.<sup id="fnref:like" role="doc-noteref"><a href="#fn:like" 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">_utf8mb4</span><span class="s1">'가나다'</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_0900_ai_ci</span> <span class="o">=</span> <span class="n">_utf8mb4</span><span class="s1">'ㄱㅏㄴㅏㄷㅏ'</span>
    <span class="k">AS</span> <span class="n">equal_result</span><span class="p">,</span>
  <span class="n">_utf8mb4</span><span class="s1">'가나다'</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_0900_ai_ci</span> <span class="k">LIKE</span> <span class="n">_utf8mb4</span><span class="s1">'ㄱㅏㄴㅏㄷㅏ'</span>
    <span class="k">AS</span> <span class="n">like_result</span><span class="p">;</span>
<span class="c1">-- equal_result = 1, like_result = 0</span>
</code></pre></div></div>

<p>콜레이션은 <code class="language-plaintext highlighter-rouge">GROUP BY</code>, <code class="language-plaintext highlighter-rouge">DISTINCT</code>, 유니크 인덱스의 중복 판정에도 영향을 줍니다. 위 테이블에서 <code class="language-plaintext highlighter-rouge">COUNT(DISTINCT name)</code>은 <code class="language-plaintext highlighter-rouge">1</code>입니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="k">DISTINCT</span> <span class="n">name</span><span class="p">)</span> <span class="k">AS</span> <span class="n">distinct_names</span> <span class="k">FROM</span> <span class="n">korean_collation_demo</span><span class="p">;</span>
<span class="c1">-- distinct_names = 1</span>

<span class="k">CREATE</span> <span class="k">TEMPORARY</span> <span class="k">TABLE</span> <span class="n">korean_unique_demo</span> <span class="p">(</span>
  <span class="n">name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">30</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="k">UNIQUE</span> <span class="k">KEY</span> <span class="n">uq_korean_unique_demo_name</span> <span class="p">(</span><span class="n">name</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="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">korean_unique_demo</span> <span class="p">(</span><span class="n">name</span><span class="p">)</span> <span class="k">VALUES</span> <span class="p">(</span><span class="s1">'가나다'</span><span class="p">);</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">korean_unique_demo</span> <span class="p">(</span><span class="n">name</span><span class="p">)</span> <span class="k">VALUES</span> <span class="p">(</span><span class="s1">'ㄱㅏㄴㅏㄷㅏ'</span><span class="p">);</span>
<span class="c1">-- 두 번째 INSERT는 ERROR 1062 (23000): Duplicate entry ... 로 실패합니다.</span>
</code></pre></div></div>

<p>애플리케이션에서 코드포인트나 바이트로만 중복 검사했다면, 검사 결과와 DB의 유니크 판정이 달라질 수 있습니다. 중복 조회와 저장의 비교 기준을 맞추고, 동시에 들어오는 요청에 대해서도 최종 유니크 제약 위반을 처리해야 합니다.</p>

<h2 id="uca-비교와-유니코드-정규화를-구분합니다">UCA 비교와 유니코드 정규화를 구분합니다</h2>

<p>UCA 9.0의 가중치표에서 결합 자모 <code class="language-plaintext highlighter-rouge">ᄀ</code>(U+1100)와 호환 자모 <code class="language-plaintext highlighter-rouge">ㄱ</code>(U+3131)는 1차·2차 가중치가 같고 3차 가중치가 다릅니다.<sup id="fnref:weights" role="doc-noteref"><a href="#fn:weights" class="footnote" rel="footnote">5</a></sup> 따라서 두 형태의 차이가 <code class="language-plaintext highlighter-rouge">ai_ci</code> 비교에서 사라지는 것은 UCA의 가중치 규칙으로 설명됩니다. 버전 업그레이드만 기대하기보다, 서비스에서 필요한 동일성 기준을 정해야 합니다.</p>

<p>또한 확인한 8.4·9.7 매뉴얼에는 <code class="language-plaintext highlighter-rouge">utf8mb4_ko_0900_ai_ci</code> 같은 한국어 전용 콜레이션이 없습니다.<sup id="fnref:unicode:2" role="doc-noteref"><a href="#fn:unicode" class="footnote" rel="footnote">2</a></sup><sup id="fnref:current" role="doc-noteref"><a href="#fn:current" class="footnote" rel="footnote">6</a></sup> <code class="language-plaintext highlighter-rouge">euckr_korean_ci</code>는 다른 문자셋용이므로 <code class="language-plaintext highlighter-rouge">utf8mb4</code> 컬럼에 사용할 수 없습니다.</p>

<p>여기서 <strong>호환 자모와 NFD 분해형은 다릅니다.</strong> 둘을 모두 “분해형”이라고 부르면 해결 방법을 잘못 고르게 됩니다.</p>

<table>
  <thead>
    <tr>
      <th>형태</th>
      <th>예</th>
      <th>첫 음절에 해당하는 코드포인트</th>
      <th>NFC 적용 결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>완성형 음절</td>
      <td><code class="language-plaintext highlighter-rouge">가나다</code></td>
      <td>U+AC00</td>
      <td><code class="language-plaintext highlighter-rouge">가나다</code></td>
    </tr>
    <tr>
      <td>NFD 분해형의 결합 자모</td>
      <td><code class="language-plaintext highlighter-rouge">가나다</code></td>
      <td>U+1100 + U+1161</td>
      <td><code class="language-plaintext highlighter-rouge">가나다</code></td>
    </tr>
    <tr>
      <td>호환 자모 나열</td>
      <td><code class="language-plaintext highlighter-rouge">ㄱㅏㄴㅏㄷㅏ</code></td>
      <td>U+3131 + U+314F</td>
      <td><code class="language-plaintext highlighter-rouge">ㄱㅏㄴㅏㄷㅏ</code></td>
    </tr>
  </tbody>
</table>

<p>NFC는 정준적으로 동등한 문자를 정규화하며 호환성 차이는 유지합니다. 따라서 <strong>NFC만 적용해서는 이 글의 호환 자모 입력을 완성형으로 바꿀 수 없습니다.</strong> NFKC는 호환성 정규화까지 하지만, 전각 문자·동그라미 숫자 등의 차이도 함께 없애므로 입력 정책에 맞춰 선택해야 합니다.<sup id="fnref:normalization" role="doc-noteref"><a href="#fn:normalization" class="footnote" rel="footnote">7</a></sup></p>

<p>Python 표준 라이브러리로 차이를 확인할 수 있습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">unicodedata</span>

<span class="c1"># 결합 자모는 NFC로 합쳐지지만 호환 자모는 그대로 남습니다.
</span><span class="k">assert</span> <span class="n">unicodedata</span><span class="p">.</span><span class="n">normalize</span><span class="p">(</span><span class="s">"NFC"</span><span class="p">,</span> <span class="s">"</span><span class="se">\u1100\u1161\u1102\u1161\u1103\u1161</span><span class="s">"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"가나다"</span>
<span class="k">assert</span> <span class="n">unicodedata</span><span class="p">.</span><span class="n">normalize</span><span class="p">(</span><span class="s">"NFC"</span><span class="p">,</span> <span class="s">"ㄱㅏㄴㅏㄷㅏ"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"ㄱㅏㄴㅏㄷㅏ"</span>
<span class="k">assert</span> <span class="n">unicodedata</span><span class="p">.</span><span class="n">normalize</span><span class="p">(</span><span class="s">"NFKC"</span><span class="p">,</span> <span class="s">"ㄱㅏㄴㅏㄷㅏ"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"가나다"</span>
</code></pre></div></div>

<p>정규화는 키보드 입력기의 한글 조합 과정도 아닙니다. 예를 들어 NFKC가 <code class="language-plaintext highlighter-rouge">ㄱㅏㄱ</code>을 받침 있는 <code class="language-plaintext highlighter-rouge">각</code>으로 조합해 주지는 않습니다. 따라서 임의의 호환 자모 나열을 복원하는 방법으로 취급해서는 안 됩니다.</p>

<h2 id="요구사항에-맞는-콜레이션을-컬럼에-지정합니다">요구사항에 맞는 콜레이션을 컬럼에 지정합니다</h2>

<p>아래는 MySQL 8.4.11에서 각 쌍을 <code class="language-plaintext highlighter-rouge">=</code>로 비교한 결과입니다. “같음”은 <code class="language-plaintext highlighter-rouge">1</code>, “다름”은 <code class="language-plaintext highlighter-rouge">0</code>입니다. NFD 열은 위 표의 결합 자모 문자열을 뜻합니다.</p>

<table>
  <thead>
    <tr>
      <th>콜레이션</th>
      <th><code class="language-plaintext highlighter-rouge">가나다</code> / 호환 자모</th>
      <th><code class="language-plaintext highlighter-rouge">가나다</code> / NFD</th>
      <th><code class="language-plaintext highlighter-rouge">a</code> / <code class="language-plaintext highlighter-rouge">A</code></th>
      <th><code class="language-plaintext highlighter-rouge">e</code> / <code class="language-plaintext highlighter-rouge">é</code></th>
      <th><code class="language-plaintext highlighter-rouge">a</code> / <code class="language-plaintext highlighter-rouge">a </code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">utf8mb4_0900_ai_ci</code></td>
      <td>같음</td>
      <td>같음</td>
      <td>같음</td>
      <td>같음</td>
      <td>다름</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">utf8mb4_0900_as_ci</code></td>
      <td>같음</td>
      <td>같음</td>
      <td>같음</td>
      <td>다름</td>
      <td>다름</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">utf8mb4_0900_as_cs</code></td>
      <td>다름</td>
      <td>같음</td>
      <td>다름</td>
      <td>다름</td>
      <td>다름</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">utf8mb4_0900_bin</code></td>
      <td>다름</td>
      <td>다름</td>
      <td>다름</td>
      <td>다름</td>
      <td>다름</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">utf8mb4_bin</code></td>
      <td>다름</td>
      <td>다름</td>
      <td>다름</td>
      <td>다름</td>
      <td>같음</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">utf8mb4_general_ci</code></td>
      <td>다름</td>
      <td>다름</td>
      <td>같음</td>
      <td>같음</td>
      <td>같음</td>
    </tr>
  </tbody>
</table>

<p>각 환경에서 재검증할 때는 다음 비교식의 <code class="language-plaintext highlighter-rouge">COLLATE</code> 이름을 바꿔 실행하면 됩니다. NFD 문자열은 편집기에서 모양을 구분하기 어려워 UTF-8 바이트로 지정했습니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
  <span class="n">_utf8mb4</span><span class="s1">'가나다'</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_0900_as_cs</span> <span class="o">=</span> <span class="n">_utf8mb4</span><span class="s1">'ㄱㅏㄴㅏㄷㅏ'</span>
    <span class="k">AS</span> <span class="n">compatibility_equal</span><span class="p">,</span>
  <span class="n">_utf8mb4</span><span class="s1">'가나다'</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_0900_as_cs</span> <span class="o">=</span>
    <span class="k">CONVERT</span><span class="p">(</span><span class="mi">0</span><span class="n">xE18480E185A1E18482E185A1E18483E185A1</span> <span class="k">USING</span> <span class="n">utf8mb4</span><span class="p">)</span>
    <span class="k">AS</span> <span class="n">nfd_equal</span><span class="p">;</span>
<span class="c1">-- compatibility_equal = 0, nfd_equal = 1</span>
</code></pre></div></div>

<h3 id="uca-비교를-유지하면서-호환-자모를-구분할-때">UCA 비교를 유지하면서 호환 자모를 구분할 때</h3>

<p><code class="language-plaintext highlighter-rouge">utf8mb4_0900_as_cs</code>를 검토합니다. 호환 자모와 완성형을 구분하지만, 악센트와 영문 대소문자도 함께 구분합니다. 완성형과 NFD 분해형은 여전히 같은 값입니다.</p>

<p><code class="language-plaintext highlighter-rouge">utf8mb4_0900_as_ci</code>만으로는 호환 자모 문제가 해결되지 않습니다. 이 예제의 차이는 2차까지 비교해서는 드러나지 않고 3차 비교에서 드러납니다. 대소문자 무시도 필요한 계정명이라면, <code class="language-plaintext highlighter-rouge">as_cs</code>로 바꾸기 전에 입력 정규화·대소문자 처리·유니크 판정을 함께 설계해야 합니다.</p>

<h3 id="문자-코드의-차이와-후행-공백까지-구분할-때">문자 코드의 차이와 후행 공백까지 구분할 때</h3>

<p><code class="language-plaintext highlighter-rouge">utf8mb4_0900_bin</code>을 검토합니다. 이 콜레이션은 MySQL 8.0.17에 추가되었고 <code class="language-plaintext highlighter-rouge">NO PAD</code>입니다. 기존 <code class="language-plaintext highlighter-rouge">utf8mb4_bin</code>도 문자 코드에 따른 비교를 하지만 <code class="language-plaintext highlighter-rouge">PAD SPACE</code>라서 후행 공백을 무시합니다.<sup id="fnref:binary-release" role="doc-noteref"><a href="#fn:binary-release" class="footnote" rel="footnote">8</a></sup></p>

<p><code class="language-plaintext highlighter-rouge">_bin</code> 콜레이션을 쓰는 <code class="language-plaintext highlighter-rouge">VARCHAR</code>는 여전히 <strong>문자열 타입</strong>입니다. <code class="language-plaintext highlighter-rouge">utf8mb4_bin</code>을 “바이트를 그대로 비교하는 타입”이라고 설명하면 부정확합니다. 인코딩 변환 없이 바이트 자체를 저장·비교해야 한다면 <code class="language-plaintext highlighter-rouge">VARBINARY</code>나 <code class="language-plaintext highlighter-rouge">BLOB</code>을 검토해야 합니다.<sup id="fnref:binary" role="doc-noteref"><a href="#fn:binary" class="footnote" rel="footnote">9</a></sup> 또한 바이너리 계열의 정렬은 언어별 정렬 규칙과 다르므로 표시 순서도 확인합니다.</p>

<h3 id="기존-utf8mb4_general_ci를-유지할-때">기존 <code class="language-plaintext highlighter-rouge">utf8mb4_general_ci</code>를 유지할 때</h3>

<p>기존 서비스가 이 비교 규칙에 의존한다면 유지할 수 있습니다. 다만 이를 한국어 전용 해결책이나 단순 코드포인트 정렬로 설명해서는 안 됩니다. <code class="language-plaintext highlighter-rouge">general_ci</code>는 대소문자·악센트를 무시하는 레거시 비교 규칙이며, 확장·축약·무시문자 처리가 제한됩니다.<sup id="fnref:unicode:3" role="doc-noteref"><a href="#fn:unicode" class="footnote" rel="footnote">2</a></sup></p>

<p>보조 문자도 주의해야 합니다. 같은 문자셋이라 저장은 가능하지만, 비교는 기대와 다를 수 있습니다. 아래 이모지 비교도 MySQL 8.4.11에서 재현됩니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
  <span class="n">_utf8mb4</span><span class="s1">'😀'</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_general_ci</span> <span class="o">=</span> <span class="n">_utf8mb4</span><span class="s1">'😁'</span> <span class="k">AS</span> <span class="n">general_equal</span><span class="p">,</span>
  <span class="n">_utf8mb4</span><span class="s1">'😀'</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_0900_ai_ci</span> <span class="o">=</span> <span class="n">_utf8mb4</span><span class="s1">'😁'</span> <span class="k">AS</span> <span class="n">uca_equal</span><span class="p">;</span>
<span class="c1">-- general_equal = 1, uca_equal = 0</span>
</code></pre></div></div>

<p>한글 호환 자모만 구분하려고 전체 DB를 <code class="language-plaintext highlighter-rouge">general_ci</code>로 바꾸면, 이모지의 유니크 판정과 후행 공백 등 다른 동작까지 바뀝니다. 한글이라는 이유만으로 하나의 콜레이션을 일괄 적용하기보다, 컬럼이 무엇을 식별하는지에 맞춰 선택하는 편이 낫습니다.</p>

<h2 id="연결-설정만으로-기존-컬럼은-바뀌지-않습니다">연결 설정만으로 기존 컬럼은 바뀌지 않습니다</h2>

<p><code class="language-plaintext highlighter-rouge">collation_connection</code>이나 Connector/J의 <code class="language-plaintext highlighter-rouge">connectionCollation</code>을 지정해도 기존 컬럼의 콜레이션은 변경되지 않습니다. 일반적인 컬럼과 문자열 리터럴의 비교에서는 컬럼의 우선순위가 더 높습니다. MySQL의 coercibility 값은 명시적 <code class="language-plaintext highlighter-rouge">COLLATE</code>가 0, 컬럼이 2, 리터럴이 4이며 작은 쪽을 적용합니다.<sup id="fnref:coercibility" role="doc-noteref"><a href="#fn:coercibility" class="footnote" rel="footnote">10</a></sup></p>

<p>앞서 만든 임시 테이블로 확인할 수 있습니다.</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="k">COLLATE</span> <span class="n">utf8mb4_general_ci</span><span class="p">;</span>

<span class="k">SELECT</span> <span class="o">@@</span><span class="n">collation_connection</span><span class="p">;</span>
<span class="c1">-- utf8mb4_general_ci</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">korean_collation_demo</span> <span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'가나다'</span><span class="p">;</span>
<span class="c1">-- matched = 3: 컬럼의 utf8mb4_0900_ai_ci를 따릅니다.</span>

<span class="k">SET</span> <span class="k">NAMES</span> <span class="n">utf8mb4</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_0900_ai_ci</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">SHOW</span> <span class="k">FULL</span> <span class="n">COLUMNS</span> <span class="k">FROM</span> <span class="n">korean_collation_demo</span><span class="p">;</span>
<span class="k">SHOW</span> <span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">korean_collation_demo</span><span class="p">;</span>

<span class="k">SELECT</span> <span class="k">COLLATION_NAME</span><span class="p">,</span> <span class="n">PAD_ATTRIBUTE</span>
<span class="k">FROM</span> <span class="n">information_schema</span><span class="p">.</span><span class="n">COLLATIONS</span>
<span class="k">WHERE</span> <span class="k">COLLATION_NAME</span> <span class="k">IN</span> <span class="p">(</span>
  <span class="s1">'utf8mb4_0900_ai_ci'</span><span class="p">,</span> <span class="s1">'utf8mb4_0900_as_cs'</span><span class="p">,</span>
  <span class="s1">'utf8mb4_0900_bin'</span><span class="p">,</span> <span class="s1">'utf8mb4_bin'</span><span class="p">,</span> <span class="s1">'utf8mb4_general_ci'</span>
<span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="k">COLLATION_NAME</span><span class="p">;</span>
</code></pre></div></div>

<p>위의 <code class="language-plaintext highlighter-rouge">SET NAMES</code>는 <code class="language-plaintext highlighter-rouge">mysql</code> CLI 재현용입니다. Connector/J 애플리케이션에서는 <code class="language-plaintext highlighter-rouge">characterEncoding</code>·<code class="language-plaintext highlighter-rouge">connectionCollation</code> 등 드라이버 설정을 사용합니다. Connector/J는 애플리케이션이 직접 실행한 <code class="language-plaintext highlighter-rouge">SET NAMES</code>의 변경을 감지하지 못하므로, 공식 문서도 이를 사용하지 말라고 안내합니다.<sup id="fnref:connector" role="doc-noteref"><a href="#fn:connector" class="footnote" rel="footnote">11</a></sup></p>

<h2 id="기존-컬럼을-변경하기-전에-확인할-것">기존 컬럼을 변경하기 전에 확인할 것</h2>

<p>기존 컬럼은 <code class="language-plaintext highlighter-rouge">ALTER TABLE ... MODIFY</code>로 문자셋과 콜레이션을 지정할 수 있습니다. 아래는 앞의 실습 테이블에 대한 예제이며, 운영 테이블에서는 <code class="language-plaintext highlighter-rouge">SHOW CREATE TABLE</code>로 확인한 타입·길이·NULL 허용·기본값·COMMENT 등의 속성을 보존해야 합니다. 생략한 컬럼 속성은 자동으로 유지되지 않습니다.<sup id="fnref:alter" role="doc-noteref"><a href="#fn:alter" class="footnote" rel="footnote">12</a></sup></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">korean_collation_demo</span>
  <span class="k">MODIFY</span> <span class="n">name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">30</span><span class="p">)</span>
  <span class="nb">CHARACTER</span> <span class="k">SET</span> <span class="n">utf8mb4</span> <span class="k">COLLATE</span> <span class="n">utf8mb4_0900_as_cs</span> <span class="k">NOT</span> <span class="k">NULL</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">korean_collation_demo</span> <span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'가나다'</span><span class="p">;</span>
<span class="c1">-- matched = 1</span>
</code></pre></div></div>

<p>서버·데이터베이스·테이블의 <strong>기본값만 변경해도 이미 존재하는 컬럼이 함께 변환되는 것은 아닙니다.</strong> 실제 컬럼 정의를 확인해야 합니다.<sup id="fnref:alter:1" role="doc-noteref"><a href="#fn:alter" class="footnote" rel="footnote">12</a></sup></p>

<p>운영 데이터에는 다음을 점검합니다.</p>

<ul>
  <li><strong>변경 후 중복:</strong> 대상 콜레이션으로 <code class="language-plaintext highlighter-rouge">GROUP BY ... HAVING COUNT(*) &gt; 1</code>을 실행해 유니크 충돌 후보를 찾습니다. 복합 유니크 키라면 전체 키 조합을 기준으로 검사합니다.</li>
  <li><strong>공백과 정규화:</strong> <code class="language-plaintext highlighter-rouge">NO PAD</code>에서 <code class="language-plaintext highlighter-rouge">PAD SPACE</code>로 바꾸면 <code class="language-plaintext highlighter-rouge">a</code>와 <code class="language-plaintext highlighter-rouge">a </code>가 충돌할 수 있습니다. 입력 정규화를 새로 도입할 때도 기존 데이터의 충돌을 확인합니다.</li>
  <li><strong>쿼리와 조인:</strong> 대소문자·악센트의 일치 여부, 정렬 순서, 조인 상대 컬럼과의 콜레이션 호환성을 확인합니다. 조회식의 <code class="language-plaintext highlighter-rouge">COLLATE</code>로 우회한다면 인덱스 사용도 <code class="language-plaintext highlighter-rouge">EXPLAIN</code>으로 확인합니다.</li>
  <li><strong>변경 비용:</strong> 컬럼 콜레이션 변경에 따른 테이블·인덱스 재구성, 잠금, 필요한 디스크 공간을 사본에서 확인합니다. 운영 테이블에 같은 DDL을 즉시 실행할 수 있다고 가정하지 않습니다.</li>
</ul>

<p>후행 공백 비교는 값을 실제로 잘라 저장한다는 뜻도 아닙니다. <code class="language-plaintext highlighter-rouge">VARCHAR</code>의 저장과 콜레이션의 비교를 구분해야 하며, <code class="language-plaintext highlighter-rouge">CHAR</code>에는 별도의 패딩·조회 규칙이 있습니다. 또한 <code class="language-plaintext highlighter-rouge">LIKE</code>의 후행 공백 처리를 <code class="language-plaintext highlighter-rouge">=</code>와 같다고 가정해서는 안 됩니다.<sup id="fnref:char" role="doc-noteref"><a href="#fn:char" class="footnote" rel="footnote">13</a></sup></p>

<p><strong>완성형·결합 자모·호환 자모·영문 대소문자·후행 공백을 각각 같은 값으로 볼지 정하고, 그 기준을 입력 처리와 컬럼의 유니크 제약에 일관되게 적용해야 합니다.</strong></p>

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

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:default" role="doc-endnote">
      <p>MySQL 8.0.1 Release Notes — Character Set Support. 기본 문자셋·콜레이션 변경. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.0-relnotes-en/news-8-0-1.html">https://docs.oracle.com/cd/E17952_01/mysql-8.0-relnotes-en/news-8-0-1.html</a> <a href="#fnref:default" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:unicode" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Unicode Character Sets. UCA 버전·언어별 콜레이션·레거시 콜레이션의 제한. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/charset-unicode-sets.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/charset-unicode-sets.html</a> <a href="#fnref:unicode" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:unicode:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:unicode:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a> <a href="#fnref:unicode:3" class="reversefootnote" role="doc-backlink">&#8617;<sup>4</sup></a></p>
    </li>
    <li id="fn:uca" role="doc-endnote">
      <p>Unicode Technical Standard #10 — Unicode Collation Algorithm. 비교 수준과 가중치의 의미. <a href="https://www.unicode.org/reports/tr10/">https://www.unicode.org/reports/tr10/</a> <a href="#fnref:uca" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:like" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — String Comparison Functions and Operators. <code class="language-plaintext highlighter-rouge">LIKE</code>와 <code class="language-plaintext highlighter-rouge">=</code>의 비교 차이. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/string-comparison-functions.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/string-comparison-functions.html</a> <a href="#fnref:like" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:weights" role="doc-endnote">
      <p>UCA 9.0.0 Default Unicode Collation Element Table. U+1100과 U+3131의 가중치. <a href="https://www.unicode.org/Public/UCA/9.0.0/allkeys.txt">https://www.unicode.org/Public/UCA/9.0.0/allkeys.txt</a> <a href="#fnref:weights" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:current" role="doc-endnote">
      <p>MySQL 9.7 Reference Manual — Unicode Character Sets. 9.7 매뉴얼의 지원 목록 대조. <a href="https://docs.oracle.com/cd/E17952_01/mysql-9.7-en/charset-unicode-sets.html">https://docs.oracle.com/cd/E17952_01/mysql-9.7-en/charset-unicode-sets.html</a> <a href="#fnref:current" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:normalization" role="doc-endnote">
      <p>Unicode Standard Annex #15 — Unicode Normalization Forms. NFC·NFKC의 정의와 호환성 차이. <a href="https://www.unicode.org/reports/tr15/">https://www.unicode.org/reports/tr15/</a> <a href="#fnref:normalization" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:binary-release" role="doc-endnote">
      <p>MySQL 8.0.17 Release Notes — <code class="language-plaintext highlighter-rouge">utf8mb4_0900_bin</code> 추가와 <code class="language-plaintext highlighter-rouge">utf8mb4_bin</code>과의 차이. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.0-relnotes-en/news-8-0-17.html">https://docs.oracle.com/cd/E17952_01/mysql-8.0-relnotes-en/news-8-0-17.html</a> <a href="#fnref:binary-release" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:binary" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — The binary Collation Compared to _bin Collations. 문자 코드 비교와 바이트 비교의 차이. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/charset-binary-collations.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/charset-binary-collations.html</a> <a href="#fnref:binary" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:coercibility" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — Collation Coercibility in Expressions. 컬럼·리터럴·명시적 <code class="language-plaintext highlighter-rouge">COLLATE</code>의 우선순위. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/charset-collation-coercibility.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/charset-collation-coercibility.html</a> <a href="#fnref:coercibility" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:connector" role="doc-endnote">
      <p>MySQL Connector/J Developer Guide — Using Character Sets and Unicode. 연결 속성과 <code class="language-plaintext highlighter-rouge">SET NAMES</code> 주의사항. <a href="https://docs.oracle.com/cd/E17952_01/connector-j-en/connector-j-reference-charsets.html">https://docs.oracle.com/cd/E17952_01/connector-j-en/connector-j-reference-charsets.html</a> <a href="#fnref:connector" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:alter" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — ALTER TABLE Statement. 컬럼 정의 변경 시 속성 보존과 기본값 변경의 범위. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/alter-table.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/alter-table.html</a> <a href="#fnref:alter" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:alter:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:char" role="doc-endnote">
      <p>MySQL 8.4 Reference Manual — The CHAR and VARCHAR Types. 저장·후행 공백·유니크 인덱스의 동작. <a href="https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/char.html">https://docs.oracle.com/cd/E17952_01/mysql-8.4-en/char.html</a> <a href="#fnref:char" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>teinam</name></author><category term="mysql" /><summary type="html"><![CDATA[utf8mb4_0900_ai_ci의 한글 동등성 판정을 MySQL 8.4에서 재현하고, 호환 자모와 NFD 분해형의 차이, 유니크 제약, 후행 공백, 컬럼별 콜레이션 선택 기준을 정리합니다.]]></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>