<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dbgur_.log</title>
        <link>https://velog.io/</link>
        <description>백엔드 개발자</description>
        <lastBuildDate>Mon, 08 Jun 2026 07:44:36 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dbgur_.log</title>
            <url>https://velog.velcdn.com/images/youhyuk_/profile/82497229-3dcc-415a-a747-059c12d70158/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dbgur_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/youhyuk_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 10. 실행 계획 (3)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-10.-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-3-3oj7qtt3</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-10.-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-3-3oj7qtt3</guid>
            <pubDate>Mon, 08 Jun 2026 07:44:36 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="10312-extra-칼럼">10.3.12 <code>Extra</code> 칼럼</h2>
<h3 id="const-row-not-found">const row not found</h3>
<p>쿼리의 실행 계획에서 <strong>const</strong> 접근 방법으로 테이블을 읽었지만 실제로 해당 테이블에 레코드가 1건도 존재하지 않으면 <code>Extra</code> 칼럼에 <strong>&quot;const row not found&quot;</strong> 문구가 표시된다.</p>
<h3 id="deleting-all-rows">Deleting all rows</h3>
<p>MyISAM 스토리지 엔진과 같이 스토리지 엔진의 핸들러 차원에서 테이블의 모든 레코드를 삭제하는 기능을 제공하는 스토리지 엔진 테이블인 경우 <code>Extra</code> 칼럼에 <strong>&quot;Deleting all rows&quot;</strong> 문구가 표시된다.</p>
<p>이 문구는 <code>WHERE</code> 조건절이 없는 <code>DELETE</code> 문장의 실행 계획에서 자주 표시되며, 테이블의 모든 레코드를 삭제하는 핸들러 기능(API)를 한번 호출함으로써 처리됐다는 것을 의미한다.</p>
<h3 id="distinct">Distinct</h3>
<p>다음은 <code>Extra</code> 칼럼에 <code>DISTINCT</code> 키워드가 표시되는 예제 쿼리이다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT DISTINCT d.dept_no
        FROM departments d, dept_emp de WHERE de.dept_no=d.dept_no;</code></pre>
<p>위 쿼리에서 조회하려는 값은 <code>dept_no</code>로, 두 테이블에 모두 존재하는 <code>dept_no</code>만 중복 없이 유니크하게 가져오기 위한 쿼리이다. 따라서 두 테이블을 조인한 후 결과에 다시 <code>DISTINCT</code> 처리를 넣은 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/44bc9d00-52b6-483a-b73c-3046a317fa9e/image.png" alt=""></p>
<p>위 이미지는 실행 계획의 <code>Extra</code> 칼럼에 <strong>&quot;Distinct&quot;</strong>가 표시되는 경우 어떻게 처리되는지 보여준다. 쿼리의 <code>DISTINCT</code>를 처리하기 위해 조인하지 않아도 되는 항목은 모두 무시하고 필요한 것만 조인한다.</p>
<h3 id="firstmatch">FirstMatch</h3>
<p>세미 조인의 여러 최적화 중에서 <strong>FirstMatch</strong> 전력이 사용되면 실행 계획의 <code>Extra</code> 칼럼에 <strong>&quot;FirtstMatch(table_name)&quot;</strong> 메시지를 출력하며, 메시지의 테이블명은 기준 테이블을 의미한다.</p>
<h3 id="full-scan-on-null-key">Full scan on NULL key</h3>
<p>이 처리는 <code>col1 IN (SELECT col2 FROM ...)</code>과 같은 조건을 가진 쿼리에서  자주 발생할 수 있는데, <code>col1</code>의 값이 <code>NULL</code>이 된다면 조건은 <code>NULL IN (SELECT col2 FROM ...)</code>과 같이 바뀐다.</p>
<p>SQL 표준에서는 <code>NULL</code>을 &quot;알 수 없는 값&quot;으로 정의하고 있으며, <code>NULL</code>에 대한 연산 규칙을 정의하고 있다. 그 정의대로 연산을 수행하기 위해 이 조건은 다음과 같이 비교돼야 한다.</p>
<ul>
<li>서브쿼리가 1건이라도 결과 레코드를 가진다면 최종 비교 결과는 <code>NULL</code></li>
<li>서브쿼리가 1건도 결과 레코드를 가지지 않는다면 최종 비교 결과는 <code>FALSE</code></li>
</ul>
<p>이 비교 과정에서 <code>co1</code>이 <code>NULL</code>이면 서브쿼리에 사용된 테이블에 대해서 <strong>풀 테이블 스캔(Full scan)</strong>을 해야만 결과를 알아낼 수 있다.</p>
<p><code>Extra</code> 칼럼의 <strong>&quot;Full scan on NULL key&quot;</strong>는 MySQL 서버가 쿼리를 실행하는 중 <code>col1</code>이 <code>NULL</code>을 만나면 차선책으로 서브쿼리 테이블에 대해서 <strong>풀 테이블 스캔</strong>을 사용할 것이라는 사실을 알려주는 문구다.</p>
<h3 id="impossible-having">Impossible HAVING</h3>
<p>쿼리에 사용된 <code>HAVING</code> 절의 조건을 만족하는 레코드가 없을 때 실행 계획의 <code>Extra</code> 칼럼에는 <strong>&quot;Impossible HAVING&quot;</strong> 키워드가 표시된다.</p>
<h3 id="impossible-where">Impossible WHERE</h3>
<p>쿼리의 <code>WHERE</code> 조건이 항상 <code>FALSE</code>가 될 수밖에 없는 경우 <strong>&quot;Impossible WHERE&quot;</strong>가 표시된다.</p>
<h3 id="loosescan">LooseScan</h3>
<p>세미 조인 최적화 중에서 <strong>LooseScan</strong> 최적화 전략이 사용되면 실행 계획의 <code>Extra</code> 칼럼에 <strong>&quot;LooseScan&quot;</strong> 문구가 표시된다.</p>
<h3 id="no-matching-minmax-row">No matching min/max row</h3>
<p><code>MIN()</code>이나 <code>MAX()</code>와 같은 집합 함수가 있는 쿼리의 조건절에 일치하는 레코드가 한 건도 없을 때 <code>Extra</code> 칼럼에 <strong>&quot;No matching min/max row&quot;</strong> 메시지가 출력되며,<code>MIN()</code> 혹은 <code>MAX()</code>의 결과로 <code>NULL</code>이 반환된다.</p>
<h3 id="no-matching-row-in-const-table">no matching row in const table</h3>
<p>쿼리의 조인에 사용된 테이블에서 <strong>const</strong> 방법으로 접근할 때 일치하는 레코드가 없다면 <strong>&quot;no matching row in const table&quot;</strong> 메시지를 표시한다.</p>
<h3 id="no-matching-rows-after-partition-pruning">No matching rows after partition pruning</h3>
<p><strong>&quot;No matching rows after partition pruning&quot;</strong> 메시지는 파티션된 테이블에 대한 <code>UPDATE</code> 또는 <code>DELETE</code> 명령의 실행 계획에서 표시될 수 있는데, 해당 파티션에서 <code>UPDATE</code>하거나 <code>DELETE</code>할 레코드가 없을 때 표시된다.</p>
<h3 id="no-tables-used">No tables used</h3>
<p><code>FROM</code> 절이 없는 쿼리 문장이나 <code>FROM DUAL</code> 형태의 쿼리 실행 계획에서는 <code>Extra</code> 칼럼에 <strong>&quot;No tables used&quot;</strong>라는 메시지가 출력된다.</p>
<h3 id="not-exists">Not exists</h3>
<p>A 테이블에는 존재하지만 B 테이블에는 없는 값을 조회해야 하는 쿼리에서 <code>NOT IN (subquery)</code> 형태 혹은 <code>NOT EXISTS</code> 연산자를 주로 사용하며, 이런 형태의 조인을 <strong>안티-조인(ANTI-JOIN)</strong>이라고 한다.</p>
<p>레코드의 건수가 많을 때는 <strong>아우터 조인(LEFT OUTER JOIN)</strong>을 이용해 빠른 성능으로 똑같은 처리를 구현할 수 있다.</p>
<p>다음 예제 쿼리는 <code>departments</code> 테이블을 <strong>아우터 조인</strong>해서 <code>ON</code> 절이 아닌 <code>WHERE</code> 절에 아우터 테이블(<code>departments</code>)의 <code>dept_no</code> 칼럼이 <code>NULL</code>인 레코드만 체크해서 가져온다. 즉, <strong>안티-조인</strong>은 일반 조인을 했을 때 나오지 않는 결과만 가져오는 방법이다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT *
        FROM dept_emp de
            LEFT JOIN departments d ON de.dept_no=d.dept_no
        WHERE d.dept_no IS NULL;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/9fec31fd-c4ac-4579-bafc-813fa7ed6004/image.png" alt=""></p>
<p><strong>아우터 조인</strong>을 이용해 안티-조인을 수행하는 쿼리에서는 실행 계획의 <code>Extra</code> 칼럼에 <strong>&quot;Not exists&quot;</strong> 메시지가 표시되며, 이는 테이블에 조인 조건에 일치하는 레코드가 여러 건이 있다고 하더라도 딱 1건만 조회해보고 처리를 완료하는 최적화를 의미한다.</p>
<h3 id="plan-isnt-ready-yet">Plan isn&#39;t ready yet</h3>
<p>MySQL 8.0 버전에서는 <code>SHOW PROCESSLIST</code> 명령으로 실행 중인 커넥션<code>id</code>를 확인한 뒤, <code>EXPLAIN FOR CONNECTION &quot;id&quot;</code> 명령을 통해 해당 커넥션에서 실행하고 있는 쿼리의 실행 계획을 확인할 수 있다.</p>
<p><code>EXPLAIN FOR CONNCETION</code> 명령을 실행했을 때 <code>Extra</code> 칼럼에 <strong>&quot;Plan is not ready yet&quot;</strong> 메시지가 표시되는 경우는 해당 커넥션에서 아직 쿼리의 실행 계획을 수립하지 못한 상태에서 <code>EXPLAIN FOR CONNCETION</code> 명령이 실행된 것을 의미한다.</p>
<h3 id="range-checked-for-each-record-index-map-n">Range checked for each record (index map: N)</h3>
<p>다음 두 개의 테이블을 조인하는 쿼리를 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT *
        FROM employees e1, employees e2
        WHERE e2.emp_no &gt;= e1.emp_no;</code></pre>
<p>조인 조건에 상수가 없이 둘 다 변수(<code>e1.emp_no</code>, <code>e2.emp_no</code>)인 경우  <code>e1</code> 테이블의 레코드를 하나씩 읽을 때마다 <code>e1.emp_no</code>의 값이 달라져 쿼리 비용 계산을 위한 기준 값이 계속 변화하게 되어 MySQL 옵티마이저는 <code>e2</code> 테이블을 읽을 때 어떤 접근 방법이 효율적일지 판단할 수 없다.</p>
<p><code>emp_no</code>가 1번부터 1억 번까지 있다고 가정하면, <code>e1</code> 테이블을 처음부터 끝까지 스캔하며 <code>e2</code> 테이블에서 조건을 만족하는 레코드를 찾아야한다.</p>
<p><code>e1.emp_no=1</code>인 최악의 경우에는 <code>e2</code> 테이블의 1억 건 전부를 읽어야 하지만, <code>e1.emp_no=10000000</code>인 경우에는 <code>e2</code> 테이블을 한 건만 읽으면 된다.</p>
<p>따라서 <code>e1</code> 테이블의 <code>emp_no</code>가 작을 때는 <code>e2</code> 테이블을 풀 테이블 스캔으로, 큰 값일 때는 인덱스 레인지 스캔으로 접근하는 것이 최적의 조인 방식이다.</p>
<p>이는 레코드마다 인덱스 레인지 스캔을 체크하는 것으로, <code>Extra</code> 표시되는 <strong>&quot;Range checked for each record&quot;</strong>의 의미다.</p>
<p>아래 이미지는 예제의 <strong>&quot;Range checked for each record&quot;</strong> 실행 계획 및 처리 시나리오를 나타낸 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/7b24a86c-7b70-4382-b680-c7551134e60c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/1c7e87e4-2f7f-4eeb-95e0-b08819f85c72/image.png" alt=""></p>
<p>실행 계획의 <code>Extra</code> 칼럼 출력 내용 중 &quot;(index map: 0x1)&quot;은 사용할지 말지를 판단하는 후보 인덱스의 순서를 나타낸다. &quot;index map&quot;은 16진수로 표현되며, &quot;0x1&quot;은 이진수로 바꿔도 1이다.</p>
<p>따라서 이 쿼리는 <code>e2(employees)</code> 테이블의 첫 번째 인덱스를 사용할지, 테이블을 풀 스캔할지를 매 레코드 단위로 결정하면서 처리된다.</p>
<p>여기서 첫 번째 인덱스는 <code>SHOW CREATE TABLE employees</code> 명령으로 테이블의 구조를 조회했을 때 제일 먼저 출력되는 인덱스를 의미한다.</p>
<p><code>Extra</code> 칼럼에 <strong>&quot;Range checked for each record&quot;</strong>가 표시되면 <code>type</code> 칼럼에는 <strong>ALL</strong>로 표시되어 풀 테이블 스캔으로 처리된 것으로 해석할 수 있지만, &quot;index map&quot;에 표시된 후보 인덱스를 사용할지 여부를 검토해 후보 인덱스가 도움이 되지 않는다면 최종적으로 풀 테이블 스캔을 사용하기 때문에 <strong>ALL</strong>로 표시된 것이다.</p>
<h3 id="recursive">Recursive</h3>
<p>MySQL 8.0 버전부터는 CTE(Common Table Expression)을 이용해 재귀 쿼리를 작성할 수 있게 되었으며, 이를 이용한 재귀 쿼리의 실행 계획은 <code>Extra</code> 칼럼에 <strong>&quot;Recursive&quot;</strong> 구문이 표시된다.</p>
<h3 id="rematerialize">Rematerialize</h3>
<p>MySQL 8.0 버전부터는 레터럴 조인(LATERAL JOIN) 기능이 추가됐는데, 이 경우 레터럴로 조인되는 테이블은 선행 테이블의 레코드별로 서브 쿼리를 실행해서 그 결과를 임시 테이블에 저장한다. </p>
<p>이 과정을 <strong>Rematerializing</strong>이라고 하며, <code>Extra</code> 칼럼에는 <strong>&quot;Rematerialize&quot;</strong> 문구가 표시된다.</p>
<h3 id="select-tables-optimized-away">Select tables optimized away</h3>
<p><code>MIN()</code> 또는 <code>MAX()</code>만 <code>SELECT</code> 절에 사용되거나 <code>GROUP BY</code>로 <code>MIN()</code>, <code>MAX()</code>를 조회하는 쿼리가 인덱스를 오름차순 또는 내림차순으로 1건만 읽는 형태의 최적화가 적용된다면 <code>Extra</code> 칼럼에 <strong>&quot;Select tables optimized away&quot;</strong>가 표시된다.</p>
<h3 id="start-temporary-end-temporary">Start temporary, End temporary</h3>
<p>세미 조인 최적화 중 <strong>Duplicate Weed-Out</strong> 최적화 전략이 사용되면 MySQL 옵티마이저는 <code>Extra</code> 칼럼에 <strong>&quot;Start temporary&quot;</strong>, <strong>&quot;End temporary&quot;</strong> 문구를 표시하게 된다.</p>
<p><strong>Duplicate Weed-Out</strong> 최적화 전략은 불필요한 중복 건을 제거하기 위해서 내부 임시 테이블을 사용하는데, 이때 조인되어 내부 임시 테이블에 저장되는 테이블을 식별할 수 있게 조인의 첫 번째 테이블이 <strong>&quot;Start temporary&quot;</strong> 문구를, 조인이 끝나는 부분에 <strong>&quot;End temporary&quot;</strong> 문구를 표시한다.</p>
<h3 id="unique-row-not-found">unique row not found</h3>
<p>두 개의 테이블이 각각 유니크(프라이머리 키 포함) 칼럼으로 아우터 조인을 수행하는 쿼리에서 아우터 테이블에 일치하는 레코드가 존재하지 않을 때 <code>Extra</code> 칼럼에 이 코멘트가 표시된다.</p>
<h3 id="using-filesort">Using filesort</h3>
<p><code>ORDER BY</code> 처리가 인덱스를 사용하지 못할 때 실행 계획의 <code>Extra</code> 칼럼에 <strong>&quot;Using filesort&quot;</strong> 코멘트가 표시되며, 이는 조회된 레코드를 정렬용 메모리 버퍼에 복사해 퀵 소트 호는 힙 소트 알고리즘을 이용해 정렬을 수행하게 된다는 의미다.</p>
<blockquote>
<p>실행 계획에 <code>Extra</code> 칼럼에 <strong>&quot;Using filesort&quot;</strong>가 출력되는 쿼리는 많은 부하를 일으키므로 가능하다면 쿼리를 튜닝하거나 인덱스를 생성하는 것이 좋다.</p>
</blockquote>
<h3 id="using-index커버링-인덱스">Using index(커버링 인덱스)</h3>
<p>데이터 파일을 전혀 읽지 않고 인덱스만 읽어서 쿼리를 모두 처리할 수 있을 때 <code>Extra</code> 칼럼에 <strong>&quot;Using Index&quot;</strong>가 표시되며, 이렇게 인덱스만으로 처리되는 것을 <strong>커버링 인덱스(Covering index)</strong>라고 한다.</p>
<p>레코드 건수에 따른 차이가 있겠지만 쿼리를 커버링 인덱스로 처리할 수 있을 때와 못할 때의 성능 차이는 수십 배에서 수백 배까지 날 수 있으나, 무조건 커버링 인덱스로 처리하려고 인덱스에 많은 칼럼을 추가하면 인덱스의 크기가 커져 메모리 낭비가 심해지고 레코드를 저장하거나 변경하는 작업이 매우 느려지므로 주의하자.</p>
<p>접근 방법 중 인덱스를 사용하는 실행 계획에서는 모두 <code>Extra</code> 칼럼에 <strong>&quot;Using index&quot;</strong>가 표시될 수 있다. 즉, 인덱스 레인지 스캔(eq_ref, ref, range, index_merge 등)을 사용할 때만 커버링 인덱스로 처리되는 것이 아니라, 인덱스 풀 스캔(index)을 실행할 때도 처리가 가능하다.</p>
<h3 id="using-index-condition">Using index condition</h3>
<p>MySQL 옵티마이저가 <strong>인덱스 컨디션 푸시다운(Index condition Pushdown)</strong> 최적화를 사용하면 <code>Extra</code> 칼럼에 <strong>&quot;Using index condition&quot;</strong> 메시지가 표시된다.</p>
<h3 id="using-index-for-group-by">Using index for group-by</h3>
<p><code>GROUP BY</code> 처리를 인덱스를 이용해 레코드의 정렬 없이 인덱스의 필요한 부분만을 읽는 <strong>루스 인덱스 스캔</strong>을 사용하면 쿼리의 실행 계획에서 <code>Extra</code> 칼럼에 <strong>&quot;Using index for group-by&quot;</strong> 메시지가 표시된다.</p>
<h4 id="타이트-인덱스-스캔인덱스-스캔을-통한-group-by-처리">타이트 인덱스 스캔(인덱스 스캔)을 통한 <code>GROUP BY</code> 처리</h4>
<p>인덱스를 이용해 <code>GROUP BY</code> 절을 처리할 수 있더라도 <code>AVG()</code>, <code>SUM()</code>, <code>COUNT()</code> 처럼 조회하려는 값이 모든 인덱스를 다 읽어야 할 때는 필요한 레코드만 읽을 수 없어 루스 인덱스 스캔이라고 하지 않는다. 또한 이런 쿼리의 실행 계획은 <strong>&quot;Using index for group-by&quot;</strong> 메시지가 출력되지 않는다.</p>
<h4 id="루스-인덱스-스캔을-통한-group-by-처리">루스 인덱스 스캔을 통한 <code>GROUP BY</code> 처리</h4>
<p>단일 칼럼으로 구성된 인덱스에서는 그루핑 칼럼 말고는 아무것도 조회하지 않는 쿼리에서 루스 인덱스 스캔을 사용할 수 있다.</p>
<p>또한 다중 칼럼으로 이루어진 인덱스에서는 <code>GROUP BY</code> 절이 인덱스를 사용할 수 있어야 함은 물론이고 <code>MIN()</code>이나 <code>MAX()</code> 같이 조회하는 값이 인덱스의 첫 번째 또는 마지막 레코드만 읽어도 되는 쿼리는 루스 인덱스 스캔이 사용될 수 있다.</p>
<p><code>GROUP BY</code>에서 인덱스를 사용하려면 위와 같은 요건이 갖춰져야 하지만, 아래와 같은 <code>WHERE</code> 절에서 사용하는 인덱스에 의해서도 인덱스 사용 여부가 영향을 받는다.</p>
<ul>
<li><p><code>WHERE</code> 조건절이 없는 경우
해당 쿼리는 <code>GROUP BY</code> 절의 칼럼과 <code>SELECT</code>로 가져오는 칼럼이 루스 인덱스 스캔을 사용할 수 있는 조건을 갖추면 된다. 그렇지 못한 경우 인덱스 스캔 혹은 별도의 정렬 과정을 통해 처리된다.</p>
</li>
<li><p><code>WHERE</code> 조건절이 있지만 검색을 위해 인덱스를 사용하지 못하는 경우
<code>GROUP BY</code>를 위해 인덱스를 읽은 후, <code>WHERE</code> 조건 비교를 위해 데이터 레코드를 읽어야만 하므로 루스 인덱스 스캔을 이용할 수 없으며, 타이트 인덱스 스캔 과정을 통해 처리된다.</p>
</li>
<li><p><code>WHERE</code> 조건절이 있으며, 검색을 위해 인덱스를 사용하는 경우
<code>WHERE</code> 절의 조건과 <code>GROUP BY</code> 처리가 똑같은 인덱스를 공통으로 사용할 수 있을 때만 루스 인덱스 스캔을 사용할 수 있다.</p>
</li>
</ul>
<h3 id="using-index-for-skip-scan">Using index for skip scan</h3>
<p>MySQL 옵티마이저가 인덱스 스킵 스캔 최적화를 사용하면 <code>Extra</code> 칼럼에 <strong>&quot;Using index for skip scan&quot;</strong> 메시지를 표시한다.</p>
<h3 id="using-join-bufferblock-nested-loop-using-join-bufferbatched-key-access-using-join-bufferhash-join">Using join buffer(Block Nested Loop), Using join buffer(Batched Key Access), Using join buffer(hash join)</h3>
<p>MySQL 옵티마이저는 조인되는 두 테이블에 있는 각 칼럼에서 인덱스를 조사해 인덱스가 없는 테이블을 먼저 읽어 조인을 실행한다. 뒤에 읽는 테이블(드리븐 테이블)은 검색 위주로 사용되기 때문에 인덱스가 없으면 성능에 미치는 영향이 매우 크기 때문이다.</p>
<p>드리븐 테이블에 검색을 위한 적절한 인덱스가 없을 때 MySQL 서버는 블록 네스티드 루프나 해시 조인을 사용하며, 이는 조인 버퍼를 사용한다. 이처럼 실행 계획에서 조인 버퍼가 사용되는 실행 계획의 <code>Extra</code> 칼럼에는 <strong>&quot;Using join buffer&quot;</strong>라는 메시지와 함께 사용된 조인 알고리즘이 추가로 표시된다.</p>
<h3 id="using-mrr">Using MRR</h3>
<p>MySQL 옵티마이저가 <strong>MRR</strong> 최적화를 사용한 경우 <code>Extra</code> 칼럼에 <strong>&quot;Using MRR&quot;</strong> 문구가 표시된다.</p>
<h3 id="using-sort_union-using-union-using-intersect">Using sort_union(...), Using union(...), Using intersect(...)</h3>
<p>쿼리가 <strong>index_merge</strong> 접근 방법으로 실행되는 경우 2개 이상의 인덱스가 동시에 사용될 수 있다. 이때 실행 계획의 <code>Extra</code> 칼럼에는 두 인덱스로부터 읽은 결과를 어떻게 병합했는지 설명하기 위해 다음 3개 중 하나의 메시지를 출력한다.</p>
<ul>
<li><p>Using intersect(...)
각각의 인덱스를 사용할 수 있는 조건이 <code>AND</code>로 연결된 경우 각 처리 결과에서 교집합을 추출해내는 작업을 수행했다는 의미다.</p>
</li>
<li><p>Using union(...)
각 인덱스를 사용할 수 있는 조건이 <code>OR</code>로 연결된 경우 각 처리 결과에서 합집합을 추출해내는 작업을 수행했다는 의미다.</p>
</li>
<li><p>Using sort_union(...)
Using union과 같은 작업을 수행하지만 sort_union은 프라이머리 키만 먼저 읽어서 정렬하고 병합한 이후 레코드를 읽어서 반환한다. 상대적으로 대량의 <strong>range</strong> 조건들이 <code>OR</code>로 연결되어 Using union으로 처리될 수 없는 경우 사용한다.</p>
</li>
</ul>
<blockquote>
<p>인덱스를 사용할 수 있는 조건이 <code>OR</code> 연산자로 연결되었을 때, 실제로는 레코드 건수와 관계없이 <code>WHERE</code> 조건에 사용된 비교 조건이 모두 동등 조건이라면 Using union(), 그렇지 않다면 Using sort_union()이 사용된다.</p>
</blockquote>
<h3 id="using-temporary">Using temporary</h3>
<p>MySQL 서버에서 쿼리를 처리하는 동안 중간 결과를 담아 두기 위해 임시 테이블(Temporary table)을 사용할 수 있다. 이는 메모리 혹은 디스크에서 생성될 수 있다.</p>
<p>쿼리의 실행 계획에서 <code>Extra</code> 칼럼에 <strong>&quot;Using temporary&quot;</strong> 키워드가 표시되면 임시 테이블을 사용한 것으로, 임시 테이블의 생성 위치는 실행 계획만으로 판단할 수 없다.</p>
<h3 id="using-where">Using where</h3>
<p>MySQL 서버는 내부적으로 크게 <strong>MySQL 엔진</strong>과 <strong>스토리지 엔진</strong>이라는 두 개의 레이어로 나눠 볼 수 있다. 각 <strong>스토리지 엔진</strong>은 디스크나 메모리 상에서 필요한 레코드를 읽거나 저장하는 역할을, <strong>MySQL 엔진</strong>은 스토리지 엔진으로부터 받은 레코드를 가공 또는 연산하는 작업을 수행한다.</p>
<p><strong>MySQL 엔진</strong> 레이어에서 별도의 가공을 해서 필터링 작업을 처리한 경우에 <code>Extra</code> 칼럼에 <strong>&quot;Using where&quot;</strong> 코멘트가 표시된다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/9cfec1d0-5d46-4300-8cb4-4952f4036afb/image.png" alt=""></p>
<blockquote>
<p><strong>&quot;Using where&quot;</strong>가 성능상의 문제를 야기할지 적절히 선별하는 능력이 필요한데, 실행 계획의 <code>filtered</code> 칼럼을 통해 이슈 여부를 확인할 수 있다.</p>
</blockquote>
<h3 id="zero-limit">Zero limit</h3>
<p>MySQL 서버에서 데이터 값이 아닌 쿼리 결괏값의 메타 데이터만 필요한 경우가 존재한다.</p>
<p>위 경우 쿼리 마지막에 <code>LIMIT 0</code>을 사용하면 되는데, MySQL 옵티마이저는 사용자의 의도를 알아채고 실제 테이블의 레코드는 전혀 읽지 않고 결괏값의 메타 정보만 반환한다. 이 경우 실행 계획의 <code>Extra</code> 칼럼에는 <strong>&quot;Zero limit&quot;</strong> 메시지가 출력된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 10. 실행 계획 (2)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-10.-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-2</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-10.-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-2</guid>
            <pubDate>Sat, 06 Jun 2026 06:48:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h1 id="103-실행-계획-분석">10.3 실행 계획 분석</h1>
<h2 id="1031-id-칼럼">10.3.1 <code>id</code> 칼럼</h2>
<p>하나의 <code>SELECT</code> 문장은 1개 이상의 하위 <code>SELECT</code> 문장을 포함할 수 있으며, 이는 다시 <code>SELECT</code> 키워드 단위로 구분하여 나타낼 수 있다. 이를 <strong>단위(<code>SELECT</code>) 쿼리</strong>라고 표현하겠다.</p>
<pre><code class="language-sql">mysql&gt; SELECT ...
        FROM (SELECT ... FROM tb_test1) tb1, tb_test2 tb2
        WHERE tb1.id=tb2.id);

mysql&gt; SELECT ... FROM tb_test1;
mysql&gt; SELECT ... FROM tb1, tb_test2 tb2 WHERE tb1.id=tb2.id;</code></pre>
<p>실행 계획에서 가장 왼쪽에 표시되는 <code>id</code> 칼럼은 단위 <code>SELECT</code> 쿼리별로 부여되는 식별자 값이다. 하나의 <code>SELECT</code> 문장 안에서 여러 개의 테이블을 조인하면 테이블 개수만큼 실행 계획 레코드가 출력되지만 같은 <code>id</code>값이 부여된다.</p>
<ul>
<li>조인이 사용된 경우</li>
</ul>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT e.emp_no, e.first_name, s.from_date, s.salary
        FROM employees e, salaries s
          WHERE e.emp_no=s.emp_no LIMIT 10;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/44755c92-e48c-47fd-b532-a11f159e4457/image.png" alt=""></p>
<ul>
<li>단위 <code>SELECT</code> 쿼리로 구성되어 있는 경우</li>
</ul>
<pre><code class="language-sql">mysql&gt; EXPLAIN FORMAT=TRADITIONAL
        SELECT 
           ( (SELECT COUNT(*) FROM employees) + (SELECT COUNT(*) 
            FROM departments) ) AS total_count;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/609ca02b-3156-4d5c-a7e9-ac5900e5833e/image.png" alt=""></p>
<blockquote>
<p>실행 계획의 <code>id</code> 칼럼이 테이블의 접근 순서를 의미하지는 않는다.</p>
</blockquote>
<h2 id="1032-select_type-칼럼">10.3.2 <code>select_type</code> 칼럼</h2>
<p><code>select_type</code> 칼럼은 각 단위 <code>SELECT</code> 쿼리가 어떤 타입의 쿼리인지 표시되는 칼럼으로, 표시될 수 있는 값은 다음과 같다.</p>
<h3 id="simple">&quot;SIMPLE&quot;</h3>
<p><code>UNION</code>이나 서브쿼리를 사용하지 않은 단순한 <code>SELECT</code> 쿼리인 경우 <strong>&quot;SIMPLE&quot;</strong>로 표시된다.</p>
<h3 id="primary">&quot;PRIMARY&quot;</h3>
<p><code>UNION</code>이나 서브쿼리를 가지는 <code>SELECT</code> 쿼리의 실행 계획에서 가장 바깥쪽에 있는 단위 쿼리는 <strong>&quot;PRIMARY&quot;</strong>로 표시된다.</p>
<h3 id="union">&quot;UNION&quot;</h3>
<p><code>UNION</code>으로 결합하는 단위 <code>SELECT</code> 쿼리 가운데 첫 번째를 제외한 두 번째 이후 단위 <code>SELECT</code> 쿼리는 <strong>&quot;UNION&quot;</strong>으로 표시된다.</p>
<p><code>UNION</code>의 첫 번째 단위 <code>SELECT</code>는<code>UNION</code>되는 쿼리 결과들을 모아서 저장하는 임시 테이블로, <strong>&quot;DEREIVED&quot;</strong>로 표시된다.</p>
<h3 id="dependent-union">&quot;DEPENDENT UNION&quot;</h3>
<p><strong>&quot;DEPENDENT UNION&quot;</strong>은 <code>UNION</code>이나 <code>UNION ALL</code>로 집합을 결합하는 쿼리에서 표시되며, &quot;DEPENDENT&quot;는 결합된 단위 쿼리가 외부 쿼리에 의해 영향을 받는 것을 의미한다.</p>
<h3 id="union-result">&quot;UNION RESULT&quot;</h3>
<p><strong>&quot;UNION RESULT&quot;</strong>는 <code>UNION</code> 결과를 담아두는 테이블을 의미한다. <strong>&quot;UNION RESULT&quot;</strong>는 실제 쿼리에서 단위 쿼리가 아니기 때문에 별도의 <code>id</code> 값은 부여되지 않는다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT emp_no FROM salaries WHERE salary&gt;100000
        UNION DISTINCT
        SELECT emp_no FROM dept_emp WHERE from_date&gt;&#39;2001-01-01&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/28ff9a2f-28e9-4ca1-a24f-69f640670317/image.png" alt=""></p>
<p>위 쿼리에 대한 실행 계획의 마지막 <strong>&quot;UNION RESULT&quot;</strong> 라인의 <code>table</code> 칼럼은 &quot;&lt;union 1,2&gt;&quot;로 표시돼있는데, 이것은 <code>id</code>값이 1인 단위 쿼리의 조회 결과와 <code>id</code>값이 2인 단위 쿼리의 조회 결과를 <code>UNION</code> 했다는 것을 의미한다. </p>
<blockquote>
<p>MySQL 8.0 버전부터는 <code>UNION ALL</code>의 경우 임시 테이블을 사용하지 않지만, <code>UNION</code>, <code>UNION DISTINCT</code>는 여전히 임시 테이블에 결과를 버퍼링한다.</p>
</blockquote>
<h3 id="subquery">&quot;SUBQUERY&quot;</h3>
<p><code>select_type</code> 칼럼의 <strong>&quot;SUBQUERY&quot;</strong>는 <code>FROM</code> 절 이외에서 사용되는 서브쿼리만을 의미한다.</p>
<h3 id="dependent-subquery">&quot;DEPENDENT SUBQUERY&quot;</h3>
<p>서브쿼리가 바깥쪽 <code>SELECT</code> 쿼리에서 정의된 칼럼을 사용하는 경우, <code>select_type</code>에 <strong>&quot;DEPENDENT SUBQUERY&quot;</strong>라고 표시된다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN FORMAT=TRADITIONAL
        SELECT e.first_name,
            (SELECT COUNT(*)
                FROM dept_emp de, dept_manager dm
                WHERE dm.dept_no=de.dept_no 
                    AND de.emp_no=e.emp_no) AS cnt
            FROM employees e
        WHERE e.first_name=&#39;Matt&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/15652fb8-eb8b-4b2a-a9ea-bfcff7440e07/image.png" alt=""></p>
<p>위 쿼리의 경우 안쪽의 서브쿼리 결과가 바깥쪽 <code>SELECT</code> 쿼리의 칼럼에 의존적이기 때문에 &quot;DEPENDENT&quot; 키워드가 붙는다.</p>
<p><strong>&quot;DEPENDENT UNION&quot;</strong>과 <strong>&quot;DEPENDENT SUBQUERY&quot;</strong> 모두 외부 쿼리가 먼저 수행된 후 내부 쿼리가 실행돼야 하므로 일반 서브쿼리보다는 처리 속도가 느릴 때가 많다.</p>
<h3 id="derived">&quot;DERIVED&quot;</h3>
<p>MySQL 서버의 실행 계획에서 <code>FROM</code> 절에 사용된 서브쿼리는 <code>select_type</code>이 <strong>&quot;DERIVED&quot;</strong>로 표시된다.</p>
<p><strong>&quot;DERIVED&quot;</strong>는 단위 <code>SELECT</code> 쿼리의 실행 결과로 메모리나 디스크에 임시 테이블을 생성하는 것을 의미하며, <code>select_type</code>이 <strong>&quot;DERIVED&quot;</strong>인 경우에 생성되는 임시 테이블을 파생 테이블이라고도 한다.</p>
<p>MySQL 5.6 버전부터는 옵티마이저 옵션(<code>optimizer_switch</code> 시스템 변수)에 따라 <code>FROM</code> 절의 서브쿼리를 외부 쿼리와 통합하는 형태의 최적화가 수행되기도 하며, 쿼리 특성에 맞게 임시 테이블에도 인덱스를 추가해서 만들 수 있게 최적화됐다.</p>
<blockquote>
<p>쿼리를 튜닝에서 가장 먼저 <code>select_type</code> 칼럼의 값이 <strong>&quot;DERIVED&quot;</strong>인 것이 있는지 확인하고, 서브쿼리는 최대한 조인을 사용하도록 변경하는 것을 권장한다.</p>
</blockquote>
<h3 id="dependent-derived">&quot;DEPENDENT DERIVED&quot;</h3>
<p>MySQL 8.0 버전부터는 <strong>래터럴 조인(LATERAL JOIN)</strong> 기능이 추가되면서 <code>FROM</code> 절의 서브쿼리에서도 외부 칼럼을 참조할 수 있게 됐다. 다음은 래터럴 조인 활용 예제 및 실행 계획이다.</p>
<pre><code class="language-sql">mysql&gt; select *
        FROM employees e
        LEFT JOIN LATERAL
            (SELECT * 
                FROM salaries s
                WHERE s.emp_no=e.emp_no
                ORDER BY s.from_date DESC LIMIT 2) 
        AS s2 ON s2.emp_no=e.emp_no;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/87e3883e-ea38-4ae0-955d-ecd1f860f849/image.png" alt=""></p>
<p><code>select_type</code> 칼럼의 <strong>&quot;DEPENDENT DERIVED&quot;</strong> 키워드는 해당 테이블이 래터럴 조인으로 사용된 것을 의미한다.</p>
<h3 id="uncacheable-subquery">&quot;UNCACHEABLE SUBQUERY&quot;</h3>
<p>하나의 쿼리 문장에서 조건이 똑같은 서브쿼리가 실행될 때 다시 실행하지 않고 이전 실행 결과를 그대로 사용할 수 있게 서브쿼리의 결과를 내부적인 캐시 공간에 담아둔다.</p>
<p><strong>&quot;SUBQUERY&quot;</strong> 와 <strong>&quot;DEPENDENT SUBQUERY&quot;</strong>가 캐시를 사용하는 방법은 아래과 같다.</p>
<ul>
<li><strong>&quot;SUBQUERY&quot;</strong>는 바깥쪽의 영향을 받지 않으므로 처음 한 번만 실행해서 그 결과를 캐시하고 필요할 때 캐시된 결과를 이용한다.</li>
<li><strong>&quot;DEPENDENT SUBQUERY&quot;</strong>는 의존하는 바깥쪽 쿼리의 칼럼의 값 단위로 캐시해두고 사용한다.</li>
</ul>
<p>아래 이미지는 <code>select_type</code>이 <strong>&quot;SUBQUERY&quot;</strong>인 경우 캐시를 사용하는 방법을 표현한 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/175b6cc4-866f-4683-8f4e-6c954750a6bb/image.png" alt=""></p>
<p>서브쿼리에 포함된 요소에 의해 캐시 자체가 불가능할 수 있는데, 그럴 경우 <code>select_type</code>이 <strong>&quot;UNCACHEABLE SUBQUERY&quot;</strong>로 표시되며, 캐시를 사용하지 못하게 하는 대표적인 요소는 다음과 같다.</p>
<ul>
<li>사용자 변수가 서브쿼리에 사용된 경우</li>
<li><code>NOT-DETERMINISTIC</code> 속성의 스토어드 루틴이 서브쿼리 내에 사용된 경우</li>
<li><code>UUID()</code>나 <code>RAND()</code>와 같이 결괏값이 호출할 때마다 달라지는 함수가 서브쿼리에 사용된 경우</li>
</ul>
<h3 id="uncacheable-union">&quot;UNCACHEABLE UNION&quot;</h3>
<p><strong>&quot;UNCACHEABLE UNION&quot;</strong>은 <strong>&quot;UNION&quot;</strong>과 <strong>&quot;UNCACHEABLE&quot;</strong> 두 개 키워드의 속성이 혼합된 <code>select_type</code>이다.</p>
<h3 id="materialized">&quot;MATERIALIZED&quot;</h3>
<p>MySQL 5.6 버전부터 도입된 <code>select_type</code>으로, 주로 <code>FROM</code> 절이나 <code>IN (subquery)</code> 형태의 쿼리에 사용된 서브쿼리 부분이 임시 테이블로 구체화된 경우 <code>select_type</code>이 <strong>&quot;MATERIALIZED&quot;</strong> 키워드로 표시된다.</p>
<h2 id="1033-table-칼럼">10.3.3 <code>table</code> 칼럼</h2>
<p>MySQL 서버의 실행 계획은 단위 <code>SELECT</code> 쿼리 기준이 아닌 테이블 기준으로 표시되며, 테이블 이름에 별칭이 부여된 경우 별칭이 표시된다.</p>
<p>별도의 테이블을 사용하지 않는 <code>SELECT</code> 쿼리인 경우에는 <code>table</code> 칼럼에 <code>NULL</code>이 표시된다.</p>
<p><code>table</code> 칼럼에 &lt;derived N&gt;, &lt;union M, N&gt;, 또는 &lt;subquery N&gt;과 같이 &quot;&lt;&gt;&quot;로 둘러싸인 이름이 명시되는 경우는 임시 테이블을 의미하며, &quot;&lt;&gt;&quot;안에 항상 표시되는 숫자는 단위 <code>SELECT</code> 쿼리의 <code>id</code> 값을 지칭한다. </p>
<h2 id="1034-patitions-칼럼">10.3.4 <code>patitions</code> 칼럼</h2>
<p>MySQL 8.0 버전부터는 <code>EXPLAIN</code> 명령으로 파티션 관련 실행 계획을 확인할 수 있게 되었다.</p>
<p>파티션을 참조하는 쿼리(파티션 키 칼럼을 <code>WHERE</code> 조건으로 가진)의 경우 옵티마이저가 쿼리 처리를 위해 필요한 파티션들의 목록만 모아서 <code>partitions</code> 칼럼에 표시해준다.</p>
<h2 id="1035-type-칼럼">10.3.5 <code>type</code> 칼럼</h2>
<p>쿼리의 실행 계획에서 <code>type</code> 칼럼은 MySQL 서버가 각 테이블의 레코드를 어떤 방식으로 읽었는지를 나타낸다.</p>
<h3 id="system">system</h3>
<p>레코드가 1건만 존재하는 테이블 또는 한 건도 존재하지 않는 테이블을 참조하는 형태의 접근 방법을 <strong>system</strong>이라고 한다.</p>
<p>이 접근 방법은 MyISAM이나 MEMORY 테이블에서만 사용되느 접근 방법이다.</p>
<h3 id="const">const</h3>
<p>테이블의 레코드 건수와 관계없이 쿼리가 프라이머리 키나 유니크 키 칼럼을 이용하는 <code>WHERE</code> 조건절을 가지고 있으며, 반드시 1건을 반환하는 쿼리의 처리 방식을 <strong>const</strong>라고 한다. </p>
<p>다른 DBMS에서는 이를 <strong>유니크 인덱스 스캔(UNIQUE INDEX SCAN)</strong>이라고도 표현한다.</p>
<p>다중 칼럼으로 구성된 프라이머리 키나 유니크 키 중에서 인덱스의 일부 칼럼만 조건으로 사용하는 경우 MySQL 엔진이 데이터를 읽어보지 않고서는 레코드가 1건이라는 것을 확신할 수 없으므로 <code>const</code> 타입의 접근 방법을 사용할 수 없으며, <code>const</code>가 아닌 <code>ref</code>로 표시된다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM dept_emp WHERE dept_no=&#39;d005&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/c903a02c-03a0-485f-a135-53435a7cf68c/image.png" alt=""></p>
<p>프라이머리 키나 유니크 인덱스의 모든 칼럼을 동등 조건으로 <code>WHERE</code> 절에 명시하면 다음 예제와 같이 <strong>const</strong> 접근 방법을 사용한다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM dept_emp WHERE dept_no=&#39;d005&#39; AND emp_no=10001;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/2c07ec1e-77ed-4415-aefb-a5e51725b2cb/image.png" alt=""></p>
<h3 id="eq_ref">eq_ref</h3>
<p><strong>eq_ref</strong> 접근 방법은 여러 테이블이 조인되는 쿼리의 실행 계획에서만 표시된다. </p>
<p>조인에서 처음 읽은 테이블의 칼럼값을 그다음 읽어야 할 테이블의 프라이머리 키나 유니크 키 칼럼의 검색 조건에 사용할 때를 가리켜 <strong>eq_ref</strong>라고 하며, 두 번째 이후에 읽는 테이블의 <code>type</code> 칼럼에 <strong>eq_ref</strong>가 표시된다.</p>
<p>두 번째 이후에 읽히는 테이블을 유니크 키로 검색할 때 그 유니크 인덱스는 <code>NOT NULL</code>이어야 하며, 다중 칼럼으로 만들어진 프라이머리 키나 유니크 인덱스라면 인덱스의 모든 칼럼이 비교 조건에 사용돼야만 <strong>eq_ref</strong> 접근 방법이 사용될 수 있다.</p>
<p>즉, 조인에서 두 번째 이후에 읽는 테이블에서 반드시 1건만 존재한다는 보장이 있어야 사용할 수 있다.</p>
<h3 id="ref">ref</h3>
<p><strong>ref</strong> 접근 방법은 <strong>eq_ref</strong>와는 달리 조인의 순서와 관계없이 사용되며, 프라이머리 키나 유니크 키 등의 제약 조건이 없다.</p>
<p>인덱스의 종류와 관게없이 동등 조건으로 검색할 때는 <strong>ref</strong> 접근 방법이 사용된다.</p>
<p><strong>ref</strong> 타입은 반환되는 레코드가 반드시 1건이라는 보장이 없으므로 <strong>const</strong>나 <strong>eq_ref</strong>보다는 빠르지 않지만, 동등 조건으로 비교되므로 매우 빠른 레코드 조회 방법의 하나다.</p>
<blockquote>
<p><strong>const</strong>, <strong>eq_ref</strong>, <strong>ref</strong> 세 가지 모두 매우 좋은 방법으로 인덱스의 분포도가 나쁘지 않다면 성능상의 문제를 일으키지 않으므로 쿼리를 튜닝할 때 크게 신경 쓰지 않고 넘어가도 무방하다.</p>
</blockquote>
<h3 id="fulltext">fulltext</h3>
<p><strong>fulltext</strong> 접근 방법은 MySQL 서버의 <strong>전문 검색(Full-text Search) 인덱스</strong>를 사용해 레코드를 읽는 접근 방법을 의미한다.</p>
<p>MySQL 서버는 쿼리에서 전문 인덱스를 사용하는 조건과 그 이외의 일반 인덱스를 사용하는 조건을 함께 사용하면 일반 인덱스의 접근 방법이 <strong>const</strong>, <strong>eq_ref</strong>, <strong>ref</strong>가 아니라면 일반적으로 전문 인덱스를 사용하는 조건을 선택해서 처리한다.</p>
<p>전문 검색은 <code>MATCH (...) AGAINST (...)</code> 구문을 사용해 실행하는데, 반드시 해당 테이블에 전문 검색용 인덱스가 준비돼 있어야만 한다.</p>
<blockquote>
<p>일반적으로 쿼리에 전문 검색 조건 (<code>MATCH (...) AGAINST(...)</code>)을 사용하면 MySQL 서버는 주저 없이 <strong>fulltext</strong> 접근 방법을 사용하지만,<strong>range</strong> 접근 방법이 더 빨리 처리되는 경우가 빈번해 조건별로 성능을 확인해 보는 편이 좋다.</p>
</blockquote>
<h3 id="ref_or_null">ref_or_null</h3>
<p>이 접근 방법은 <strong>ref</strong> 접근 방법 또는 <code>NULL</code> 비교 (<code>IS NULL</code>) 접근 방법을 의미한다.</p>
<h3 id="unique_subquery">unique_subquery</h3>
<p><code>WHERE</code> 조건절에 사용될 수 있는 <code>IN (subquery)</code> 형태의 쿼리를 위한 접근 방법으로, 서브쿼리에서 중복되지 않는 유니크한 값만을 반환할 때 사용한다.</p>
<h3 id="index_subquery">index_subquery</h3>
<p><code>IN</code> 연산자의 특성상 <code>IN (subquery)</code> 또는 <code>IN (상수 나열)</code> 형태의 조건은 괄호 안에 있는 값의 목록에서 중복된 값이 먼저 제거돼야 한다. </p>
<p><code>IN (subquery)</code>에서 서브쿼리가 중복된 값을 반환하는 경우가 존재하는데, 이를 인덱스를 이용해 제거할 수 있을 때 <strong>index_subquery</strong> 접근 방법이 사용된다. </p>
<h3 id="range">range</h3>
<p><strong>range</strong>는 인덱스를 하나의 값이 아닌 범위로 검색하는 경우를 의미하며, 주로 <code>&lt;</code>, <code>&gt;</code>, <code>IS NULL</code>, <code>BETWEEN</code>, <code>IN</code>, <code>LIKE</code> 등의 연산자를 이용해 인덱스를 검색할 때 사용된다.</p>
<h3 id="index_merge">index_merge</h3>
<p><strong>index_merge</strong> 접근 방법은 2개 이상의 인덱스를 이용해 각각의 검색 결과를 만들어낸 후, 그 결과를 병합해서 처리하는 방식이다.</p>
<p><strong>index_merge</strong> 접근 방법에는 다음과 같은 특징이 있다.</p>
<ul>
<li><p>여러 인덱스를 읽어야 하므로 일반적으로 <strong>range</strong> 접근 방법보다 효율성이 떨어진다.</p>
</li>
<li><p>전문 검색 인덱스를 사용하는 쿼리에서는 <strong>index_merge</strong>가 적용되지 않는다.</p>
</li>
<li><p><strong>index_merge</strong> 접근 방법으로 처리된 결과는 항상 2개 이상의 집합이 되기 때문에 교집합이나 합집합, 또는 중복 제거와 같은 부가적인 작업이 더 필요하다.</p>
</li>
</ul>
<h3 id="index">index</h3>
<p><strong>index</strong> 접근 방법은 인덱스를 처음부터 끝까지 읽는 <strong>인덱스 풀 스캔</strong>을 의미하며, <strong>range</strong>, <strong>const</strong>, <strong>ref</strong> 같은 접근 방법으로 인덱스를 사용하지 못하는 경우와 동시에 아래 두 조건 중 하나를 충족하는 쿼리에서 사용된다.</p>
<ul>
<li>인덱스에 포함된 칼럼만으로 처리할 수 있는 쿼리인 경우</li>
<li>인덱스를 이용해 정렬이나 그루핑 작업이 가능한 경우</li>
</ul>
<h3 id="all">ALL</h3>
<p><strong>ALL</strong> 접근 방법은 풀 테이블 스캔을 의미하며, 테이블을 처음부터 끝까지 전부 읽어서 불필요한 레코드를 제거하고 반환하므로 위 접근 방법들로 처리할 수 없을 때 가장 마지막에 선택하는 비효율적인 방법이다.</p>
<h2 id="1036-possible_keys-칼럼">10.3.6 <code>possible_keys</code> 칼럼</h2>
<p><code>possible_keys</code> 칼럼에 있는 내용은 옵티마이저가 최적의 실행 계획을 만들기 위해 후보로 선정했던 접근 방법에서 사용되는 인덱스의 목록이다.</p>
<blockquote>
<p>말 그대로 &quot;사용될 법했던 인덱스의 목록&quot;으로, <code>possible_keys</code> 칼럼에 인덱스 이름이 나열됐다고 해서 그 인덱스를 사용한다고 판단하지 않도록 주의하자.</p>
</blockquote>
<h2 id="1037-key-칼럼">10.3.7 <code>key</code> 칼럼</h2>
<p><code>key</code> 칼럼에 표시되는 인덱스는 최종 선택된 실행 계획에서 사용하는 인덱스로, 쿼리를 튜닝할 때는 <code>key</code> 칼럼에 의도했던 인덱스가 표시되는지 확인하는 것이 중요하다.</p>
<p><code>key</code> 칼럼에 표시되는 값이 <code>PRIMARY</code>인 경우에는 프라이머리 키를 사용한다는 의미이며, 이외의 값은 모두 테이블이나 인덱스를 생성할 때 부여했던 고유 이름이다.</p>
<p>실행 계획의 <code>type</code> 칼럼이 <code>index_merge</code>가 아닌 경우에는 반드시 테이블 하나당 하나의 인덱스만 이용하며, <code>index_merge</code> 실행 계획이 사용될 때는 2개 이상의 인덱스가 사용되어 인덱스가 &quot;,&quot;로 구분되어 표시된다.</p>
<p><code>type</code>이 <code>ALL</code>일 때와 같이 인덱스를 전혀 사용하지 못하면 <code>key</code> 칼럼은 <code>NULL</code>로 표시된다.</p>
<h2 id="1038-key_len-칼럼">10.3.8 <code>key_len</code> 칼럼</h2>
<p><code>key_len</code> 칼럼은 매우 중요한 정보 중 하나로, 쿼리를 처리하기 위해 인덱스의 각 레코드에서 몇 바이트까지 사용했는지 알려주는 값이다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM dept_emp WHERE dept_no=&#39;d005&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/65b31170-be58-4be3-95b4-8f10c6ab2367/image.png" alt=""></p>
<p>위 예제는 두 개의 칼럼(<code>dept_no</code>, <code>emp_no</code>)으로 구성된 프라이머리 키를 가지는 <code>dept_emp</code> 테이블을 조회하는 쿼리 및 실행 계획으로, <code>dept_no</code>만 비교에 사용된다.</p>
<p><code>dept_no</code>의 칼럼의 타입은 <code>CHAR(4)</code>로, 프라이머리 키에서 앞쪽 16바이트만 유효하게 사용되어 <code>key_len</code> 칼럼의 값이 16으로 나타난다.</p>
<blockquote>
<p>utf8mb4 문자 집합에서 문자 하나가 차지하는 공간은 1바이트에서 4바이트까지 가변적이나, MySQL 서버가 메모리 공간을 할당해야 할 때는 고정적으로 4바이트로 계산한다.</p>
</blockquote>
<p>위와 똑같은 인덱스를 사용하지만 <code>dept_no</code> 칼럼과 <code>emp_no</code> 칼럼에 대해 각각 조건을 하나씩 가지고 있는 경우를 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM dept_emp WHERE dept_no=&#39;d005&#39; AND emp_no=10001;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/19ea74ab-b6dc-4bd3-a9a4-0722f6fcb74b/image.png" alt=""></p>
<p><code>emp_no</code> 칼럼 타입은 <code>INTEGER</code>로 4바이트를 차지한다. 따라서 위 쿼리의 <code>key_len</code> 칼럼은 <code>dept_no</code> 칼럼의 길이와 <code>emp_no</code> 칼럼의 길이의 합인 20으로 표시된다.</p>
<blockquote>
<p>MySQL에서 <code>NULLABLE</code> 칼럼으로 정의된 경우, <code>NULL</code> 여부를 저장하기 위해 1바이트를 추가로 더 사용한다.</p>
</blockquote>
<h2 id="1039-ref-칼럼">10.3.9 <code>ref</code> 칼럼</h2>
<p>실행 계획의 <code>ref</code> 칼럼의 내용은 접근 방법이 <code>ref</code>일 때 참조 조건으로 어떤 값이 제공됐는지 보여준다. 상숫값을 지정했다면 <code>const</code>, 다른 테이블의 칼럼값이면 그 테이블명과 칼럼명이 표시된다.</p>
<p>가끔 <code>ref</code> 칼럼의 값이 &quot;func&quot;라고 표시될 때가 있다. 이는 &quot;Function&quot;의 줄임말로 참조용으로 사용되는 값을 그대로 사용한 것이 아닌 콜레이션 변환이나 값 자체의 연산을 거쳐 참조했다는 것을 의미한다.</p>
<p>사용자가 명시적으로 값을 변환할 때뿐만 아니라, MySQL 서버가 내부적으로 값을 변환해야 할 때도 <code>ref</code> 칼럼에 &quot;func&quot;가 출력된다.</p>
<h2 id="10310-rows-칼럼">10.3.10 <code>rows</code> 칼럼</h2>
<p>MySQL 실행 계획의 <code>rows</code> 칼럼값은 옵티마이저가 실행 계획의 효율성 판단을 위해 예측했던 레코드 건수를 보여준다.</p>
<p>이 값은 각 스토리지 엔진별로 가지고 있는 통계 정보를 참조해 MySQL 옵티마이저가 산출해 낸 예상값이라서 정확하지 않다.</p>
<p>또한 <code>rows</code> 칼럼에 표시되는 값은 반환하는 레코드의 예측치가 아니라 쿼리를 처리하기 위해 얼마나 많은 레코드를 읽고 체크해야하는 지를 의미하여, 실제 쿼리 결과 반환된 레코드 건수와 일치하지 않는 경우가 많다.</p>
<h2 id="10311-filtered-칼럼">10.3.11 <code>filtered</code> 칼럼</h2>
<p>대부분의 쿼리에서 <code>WHERE</code> 절에 사용되는 조건이 모두 인덱스를 사용할 수 있는 것은 아니다.</p>
<p>조인이 사용된 경우에는 <code>WHERE</code> 절에서 인덱스를 사용할 수 있는 조건도 중요하지만, 인덱스를 사용하지 못하는 조건에 일치하는 레코드 건수를 파악하는 것도 중요하다.</p>
<p><code>filtered</code> 칼럼의 값은 조건에 의해 필터링되고 남은 레코드의 비율을 의미한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 10. 실행 계획 (1)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-10.-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-1</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-10.-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-1</guid>
            <pubDate>Mon, 01 Jun 2026 05:47:06 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h1 id="101-통계-정보">10.1 통계 정보</h1>
<h2 id="1011-테이블-및-인덱스-통계-정보">10.1.1 테이블 및 인덱스 통계 정보</h2>
<h3 id="mysql-서버의-통계-정보">MySQL 서버의 통계 정보</h3>
<p>MySQL 5.5 버전까지는 각 테이블의 통계 정보가 메모리에 관리되어, MySQL 서버가 재시작되면 수집되었던 통계 정보가 모두 사라졌다.</p>
<p>MySQL 5.6 버전부터는 InnoDB 스토리지 엔진을 사용하는 테이블에 대한 통계 정보를 영구적으로 <code>mysql</code> 데이터베이스의 <code>innodb_index_stats</code> 테이블과 <code>innodb_table_stats</code> 테이블로 관리할 수 있게 개선되었다.</p>
<pre><code class="language-sql">mysql&gt; ALTER TABLE employees.employees STATS_PERSISTENT=1;

mysql&gt; SELECT *
        FROM innodb_index_stasts
        WHERE database_name=&#39;employees&#39;
            AND TABLE_NAME=&#39;employees&#39;;

mysql&gt; SELECT *
        FROM innodb_table_stasts
        WHERE database_name=&#39;employees&#39;
            AND TABLE_NAME=&#39;employees&#39;;</code></pre>
<p>또한 MySQL 서버에 특정 이벤트가 발생하면 자동으로 통계 정보가 갱신되는데, <code>innodb_stats_auto_recalc</code> 시스템 설정 변수의 값을 &quot;OFF&quot;로 설정하면 통계 정보가 자동으로 갱신되는 것을 막을 수 있다. 따라서 영구적인 통계 정보를 이용하고자 하면 이 설정을 &quot;OFF&quot;로 변경하면 된다.</p>
<h2 id="1012-히스토그램">10.1.2 히스토그램</h2>
<p>MySQL 8.0 버전으로 업그레이드되면서 MySQL 서버도 칼럼의 데이터 분포도를 참조할 수 있는 <strong>히스토그램(Histogram)</strong> 정보를 활용할 수 있게 됐다.</p>
<h3 id="히스토그램-정보-수집-및-삭제">히스토그램 정보 수집 및 삭제</h3>
<p>MySQL 8.0 버전에서 히스토그램 정보는 칼럼 단위로 관리하는데, 이는 자동으로 수집되지 않고 <code>ANALYZE TABLE ... UPDATE HISTOGRAM</code> 명령을 실행해 수동으로 수집 및 관리된다.</p>
<p>수집된 히스토그램 정보는 시스템 딕셔너리에 함께 저장되고, MySQL 서버가 시작될 때 딕셔너리의 히스토그램 정보를 <code>information_schema</code> 데이터베이스의 <code>column_statistics</code> 테이블로 로드하므로 이를 <code>SELECT</code>해서 정보를 조회할 수 있다.</p>
<p>MySQL 8.0 버전에서는 다음과 같은 2종류의 히스토그램 타입이 지원된다.</p>
<ul>
<li><p>Singleton (싱글톤 히스토그램)
칼럼값 개별로 레코드 건수를 관리하는 히스토그램, Value-Based 히스토그램 또는 도수 분포라고도 부른다.</p>
</li>
<li><p>Equi-Height (높이 균형 히스토그램)
칼럼값의 범위를 균등한 개수로 구분해서 관리하는 히스토그램, Height-Balanced 히스토그램이라고도 부른다.</p>
</li>
</ul>
<p>생성된 히스토그램은 다음과 같이 삭제할 수 있으며, 참조 데이터가 아닌 딕셔너리의 내용만 삭제하기 때문에 다른 쿼리 처리 성능에는 영향을 주지 않는다. 하지만 히스토그램이 삭제되면 실행 계획이 달라질 수 있으므로 주의하자.</p>
<pre><code class="language-sql">mysql&gt; ANALYZE TABLE employees.employees
        DROP HISTOGRAM gender, hire_date;</code></pre>
<p>히스토그램을 삭제하지 않고 MySQL 옵티마이저가 히스토그램을 사용하지 않게 하려면 다음과 같이 <code>optimizer_switch</code> 시스템 변수 값을 변경하면 되지만 <code>conidition_fanout_filter</code> 옵션에 의해 영향받는 다른 최적화 기능들이 사용되지 않을 수 있다.</p>
<pre><code class="language-sql">mysql&gt; SET GLOBAL optimizer_switch=&#39;condition_fanout_filter=off&#39;</code></pre>
<p>특정 커넥션 또는 쿼리에서만 히스토그램을 사용하지 않고자 한다면 다음과 같은 방법을 사용하면 된다.</p>
<pre><code class="language-sql">-- // 현재 커넥션에서 실행되는 쿼리만 히스토그램을 사용하지 않게 설정
mysql &gt; SET SESSION optimizer_switch=&#39;condition_fanout_filter=off&#39;;

-- // 현재 쿼리만 히스토그램을 사용하지 않게 설정
mysql&gt; SELECT /*+ SET_VAR(optimizer_switch=&#39;condition_fanout_filter=off&#39;) */ *
        FROM ...</code></pre>
<h3 id="히스토그램의-용도">히스토그램의 용도</h3>
<p>히스토그램은 특정 칼럼이 가지는 모든 값에 대한 분포도 정보를 가지지는 않지만 각 범위(버킷)별로 레코드의 건수와 유니크한 값의 개수 정보를 가지기 때문에 기존 MySQL의 통계 정보에 비해 훨씬 더 정확한 예측을 할 수 있다.</p>
<p>히스토그램 정보가 없으면 옵티마이저는 데이터가 균등하게 분포돼 있을 것으로 예측한다. 하지만 히스토그램이 있으면 특정 범위의 데이터가 많고 적음을 식별하므로 쿼리 성능에 상당한 영향을 미친다.</p>
<p>간단한 예제로 <code>employees</code> 테이블의 <code>bitrh_date</code> 칼럼에 대해 히스토그램이 있을 때와 없을 때의 예측치의 차이를 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT *
        FROM employees
        WHERE first_name=&#39;Zita&#39;
            AND birth_date BETWEEN &#39;1950-01-01&#39; AND &#39;1960-01-01&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/9b40e063-c5a8-4c52-8b6c-36bb1cfdf3cb/image.png" alt=""></p>
<p>히스토그램 정보를 수집하기 전, 옵티마이저는 <code>first_name=&#39;Zita&#39;</code> 조건에 일치하는 레코드가 224건 있고, 대략 11.11%가 <code>birth_date</code> 조건을 만족한다고 예측했다.</p>
<pre><code class="language-sql">mysql&gt; ANALYZE TABLE employees
        UPDATE histogram ON first_name, birth_date;

mysql&gt; EXPLAIN
        SELECT *
        FROM employees
        WHERE first_name=&#39;Zita&#39;
            AND birth_date BETWEEN &#39;1950-01-01&#39; AND &#39;1960-01-01&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/48967b11-83cb-4916-978e-100f66d3d7b5/image.png" alt=""></p>
<p>반면 히스토그램 정보를 수집한 뒤의 실행 계획에서는 61.14%가 조건을 만족할 것으로 예측했으며, 실제 데이터를 조회해보면 63.84%의 레코드가 <code>birth_date</code> 조건을 만족하고 있었다. 히스토그램 정보의 유무가 데이터 분포 정보 예측에 굉장히 큰 차이를 보임을 확인할 수 있다.</p>
<p>다음으로 이러한 데이터 분포 정보가 쿼리 성능에 어떤 영향을 주는지 확인해보자. 다음 예제는 2개의 테이블을 조인하는데, 옵티마이저 힌트를 사용해 강제로 조인의 순서를 바꿔 성능을 살펴본 것이다.</p>
<pre><code class="language-sql"> mysql&gt; SELECT /*+ JOIN_ORDER(e, s) */ *
         FROM salaries s
            INNER JOIN employees e ON e.emp_no=s.emp_no
                AND e.birth_date BETWEEN &#39;1950-01-01&#39; AND &#39;1950-02-01&#39;
            WHERE s.salary BETWEEN 40000 AND 70000;

mysql&gt; SELECT /*+ JOIN_ORDER(s, e) */ *
         FROM salaries s
            INNER JOIN employees e ON e.emp_no=s.emp_no
                AND e.birth_date BETWEEN &#39;1950-01-01&#39; AND &#39;1950-02-01&#39;
            WHERE s.salary BETWEEN 40000 AND 70000;</code></pre>
<p> <img src="https://velog.velcdn.com/images/youhyuk_/post/97ab1e1a-6bb5-4206-9958-94fff35f3871/image.png" alt=""></p>
<p> 두 쿼리 모두 동일한 결과를 만들어 내지만 쿼리의 성능은 굉장히 큰 차이를 보이고 있다. <code>bitrh_date</code> 칼럼과 <code>salary</code> 칼럼은 인덱스되지 않은 칼럼으로 해당 칼럼들에 히스토그램이 없다면 옵티마이저는 이 칼럼들의 데이터 분포를 전혀 알지 못하고 실행 계획을 수립하여, 옵티마이저 힌트를 제거했을 때 테이블의 전체 레코드 건수나 크기 등의 단순한 정보만으로 조인의 드라이빙 테이블을 결정하게 된다. </p>
<p>각 칼럼에 대해 히스토그램 정보가 있다면 어느 테이블을 먼저 읽어야 조인 횟수를 줄일 수 있을지 옵티마이저가 더 정확히 판단하여 실행 계획 수립에 크게 영향을 미칠 수 있다.</p>
<h3 id="히스토그램과-인덱스">히스토그램과 인덱스</h3>
<p>MySQL 서버에서는 쿼리의 실행 계획을 수립할 때 사용 가능한 인덱스들로부터 조건절에 일치하는 레코드 건수를 대략 파악하고 최종적으로 가장 나은 실행 계획을 선택한다. 이때 조건절에 일치하는 레코드 건수를 예측하기 위해 옵티마이저는 실제 인덱스의 B-Tree를 샘플링해서 살펴보는데, 이 작업을 <strong>인덱스 다이브(Index Dive)</strong>라고 표현한다.</p>
<p>MySQL 8.0 서버에서는 인덱스된 칼럼을 검색 조건으로 사용하는 경우 그 칼럼의 히스토그램은 사용하지 않고 <strong>인덱스 다이브</strong>를 통해 직접 수집한 정보를 활용하고, 히스토그램은 주로 인덱스되지 않은 칼럼에 대한 데이터 분포도를 참조하는 용도로 사용한다.</p>
<h2 id="1013-코스트-모델-cost-model">10.1.3 코스트 모델 (Cost Model)</h2>
<p>MySQL 서버가 쿼리를 처리하려면 다음과 같은 다양한 작업을 필요로 한다.</p>
<ul>
<li>디스크로부터 데이터 페이지 읽기</li>
<li>메모리(InnoDB 버퍼 풀)로부터 데이터 페이지 읽기</li>
<li>인덱스 키 비교</li>
<li>레코드 평가</li>
<li>메모리 임시 테이블 작업</li>
<li>디스크 임시 테이블 작업</li>
</ul>
<p>MySQL 서버는 사용자의 쿼리 처리에 다양한 작업이 얼마나 필요한지 예측하고 전체 작업 비용을 계산한 결과를 바탕으로 최적의 실행 계획을 찾는다. 이렇게 전체 쿼리 비용을 계산하는 데 필요한 단위 작업들의 비용을 <strong>코스트 모델(Cost Model)</strong>이라고 한다.</p>
<p>MySQL 8.0 서버의 코스트 모델은 다음 2개 테이블에 저장돼 있는 설정값을 사용하며, 모두 <code>mysql</code> DB에 존재한다.</p>
<ul>
<li><p><code>server_cost</code> 
인덱스를 찾고 레코드를 비교하고 임시 테이블 처리에 대한 비용 관리</p>
</li>
<li><p><code>engine_cost</code> 
레코드를 가진 데이터 페이지를 가져오는 데 필요한 비용 관리</p>
</li>
</ul>
<p>MySQL 8.0 버전의 코스트 모델에서 지원하는 단위 작업은 다음과 같이 8개다.</p>
<ul>
<li><code>engine_cost</code></li>
</ul>
<table>
<thead>
<tr>
<th>cost_name</th>
<th>default_value</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>io_block_read_cost</td>
<td>1.00</td>
<td>디스크 데이터 페이지 읽기</td>
</tr>
<tr>
<td>memory_block_read_cost</td>
<td>0.25</td>
<td>메모리 데이터 페이지 읽기</td>
</tr>
</tbody></table>
<ul>
<li><code>server_cost</code></li>
</ul>
<table>
<thead>
<tr>
<th>cost_name</th>
<th>default_value</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>disk_temptable_create_cost</td>
<td>20.00</td>
<td>디스크 임시 테이블 생성</td>
</tr>
<tr>
<td>disk_temptable_row_cost</td>
<td>0.50</td>
<td>디스크 임시 테이블의 레코드 읽기</td>
</tr>
<tr>
<td>key_compare_cost</td>
<td>0.05</td>
<td>인덱스 키 비교</td>
</tr>
<tr>
<td>memory_temptable_create_cost</td>
<td>1.00</td>
<td>메모리 임시 테이블 생성</td>
</tr>
<tr>
<td>memory_temptable_row_cost</td>
<td>0.10</td>
<td>메모리 임시 테이블의 레코드 읽기</td>
</tr>
<tr>
<td>row_evaluate_cost</td>
<td>0.10</td>
<td>레코드 비교</td>
</tr>
</tbody></table>
<p>코스트 모델에서 중요한 것은 각 단위 작업에 설정되는 비용 값 설정에 따른 실행 계획의 비용 변화를 파악하는 것이다. 대표적인 예시는 다음과 같다.</p>
<ul>
<li><p><code>key_compare_cost</code> 비용을 높이면 MySQL 서버 옵티마이저가 가능하면 정렬을 수행하지 않는 방향의 실행 계획을 선택할 가능성이 높아진다.</p>
</li>
<li><p><code>row_evaluate_cost</code> 비용을 높이면 풀 스캔을 실행하는 쿼리들의 비용이 높아지고, MySQL 서버 옵티마이저는 가능하면 인덱스 레인지 스캔을 사용하는 실행 계획을 선택할 가능성이 높아진다.</p>
</li>
<li><p><code>disk_temptable_create_cost</code>와 <code>dist_temptable_row_cost</code> 비용을 높이면 MySQL 옵티마이저는 디스크에 임시 테이블을 만들지 않는 방향의 실행 계획을 선택할 가능성이 높아진다.</p>
</li>
<li><p><code>memory_temptable_create_cost</code>와 <code>memory_temptable_row_cost</code> 비용을 높이면 MySQL 서버 옵티마이저는 디스크에 임시 테이블을 만들지 않는 방향의 실행 계획을 선택할 가능성이 높아진다.</p>
</li>
<li><p><code>io_block_read_cost</code> 비용이 높아지면 MySQL 서버 옵티마이저는 가능하면 InnoDB 버퍼 풀에 데이터 페이지가 많이 적재돼 있는 인덱스를 사용하는 실행 계획을 선택할 가능성이 높아진다.</p>
</li>
<li><p><code>memory_block_read_cost</code> 비용이 높아지면 MySQL 서버는 InnoDB 버퍼 풀에 적재된 데이터 페이지가 상대적으로 적다고 하더라도 그 인덱스를 사용할 가능성이 높아진다.</p>
</li>
</ul>
<h1 id="102-실행-계획-확인">10.2 실행 계획 확인</h1>
<h2 id="1021-실행-계획-출력-포맷">10.2.1 실행 계획 출력 포맷</h2>
<p>MySQL 8.0 버전부터는 <code>FORMAT</code> 옵션을 사용해 실행 계획의 표시 방법을 JSON, TREE, 단순 테이블 형태로 선택할 수 있다.</p>
<pre><code class="language-sql">-- // 테이블 포맷 표시
mysql&gt; EXPLAIN
        SELECT * FROM ...

-- // 트리 포맷 표시
mysql&gt; EXPLAIN FORMAT=TREE
        SELECT * FROM ...

-- // JSON 포맷 표시
mysql&gt; EXPLAIN FORMAT=JSON
        SELECT * FROM ...</code></pre>
<h2 id="1022-쿼리의-실행-시간-확인">10.2.2 쿼리의 실행 시간 확인</h2>
<p>MySQL 8.0.18 버전부터는 쿼리의 실행 계획과 단계별 소요된 시간 정보를 확인할 수 있는 <code>EXPLAIN ANALYZE</code> 기능이 추가됐다.</p>
<p><code>EXPLAIN ANALYZE</code> 명령은 항상 결과를 TREE 포맷으로 보여주기 때문에 EXPLAIN 명령에 FORMAT 옵션을 사용할 수 없다. TREE 포맷의 실행 계획에서 들여쓰기는 호출 순서를 의미하며, 실제 실행 순서는 다음 기준으로 읽으면 된다.</p>
<ul>
<li>들여쓰기가 같은 레벨에서는 상단에 위치하는 라인이 먼저 실행</li>
<li>들여쓰기가 다른 레벨에서는 가장 안쪽에 위치한 라인이 먼저 실행</li>
</ul>
<p><code>EXPLAIN ANALYZE</code> 명령은 <code>EXPLAIN</code> 명령과 달리 실행 계획만 추출하는 것이 아닌 실제 쿼리를 사용하고 사용된 실행 계획과 소요된 시간을 보여주므로 쿼리가 완료돼야 실행 계획의 결과를 확인할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 09. 옵티마이저 (4)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80-4</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80-4</guid>
            <pubDate>Wed, 27 May 2026 10:24:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h1 id="94-쿼리-힌트">9.4 쿼리 힌트</h1>
<h2 id="941-인덱스-힌트">9.4.1 인덱스 힌트</h2>
<blockquote>
<p>인덱스 힌트는 옵티마이저 힌드가 도입되기 전에 사용되던 기능들로, ANSI-SQL 표준 문법을 준수하지 못하는 단점이 있기 때문에 가능하다면 옵티마이저 힌트를 사용하자.</p>
</blockquote>
<h3 id="straight_join"><code>STRAIGHT_JOIN</code></h3>
<p><code>STRAIGHT_JOIN</code>은 옵티마이저 힌트인 동시에 조인 키워드로 <code>SELECT</code>, <code>UPDATE</code>, <code>DELETE</code> 쿼리에서 여러 개의 테이블이 조인되는 경우 조인 순서를 고정하는 역할을 한다.</p>
<p><code>STRAIGHT_JOIN</code> 키워드의 사용 예시는 아래와 같으며, 두 쿼리는 힌트의 표기법만 조금 다르게 했을 뿐 동일한 쿼리다. <code>SELECT</code> 키워드 바로 뒤에 사용되었다는 것을 주의하자.</p>
<pre><code class="language-sql">mysql&gt; SELECT STRAIGHT JOIN
            e.first_name, e.last_name, d.dept_name 
        FROM employees e, dept_emp de, departments d
        WHERE e.emp_no=de.emp_no
            AND d.dept_no=de.dept_no;

mysql&gt; SELECT /*! STRAIGHT JOIN */
            e.first_name, e.last_name, d.dept_name 
        FROM employees e, dept_emp de, departments d
        WHERE e.emp_no=de.emp_no
            AND d.dept_no=de.dept_no;</code></pre>
<h3 id="use-index--force-index---ignore-index"><code>USE INDEX</code> / <code>FORCE INDEX</code>  / <code>IGNORE INDEX</code></h3>
<p>대체로 MySQL 옵티마이저는 인덱스를 잘 선택하지만, 3~4개 이상의 칼럼을 포함하는 비슷한 인덱스가 여러 개가 존재하는 경우 실수를 할 수 있어, 이런 경우 강제로 특정 인덱스를 사용하도록 힌트를 추가한다.</p>
<p>인덱스 힌트는 크게 다음과 같이 3종류가 있으며, 키워드 뒤에 사용할 인덱스의 이름을 괄호로 묶어 사용한다.</p>
<ul>
<li><p><code>USE INDEX</code>
MySQL 옵티마이저에게 특정 테이블의 인덱스를 사용하도록 <strong>권장</strong>하는 힌트</p>
</li>
<li><p><code>FORCE INDEX</code>
<code>USE INDEX</code>와 동일하지만 옵티마이저에게 미치는 영향이 더 강한 힌트</p>
</li>
<li><p><code>IGNORE INDEX</code>
특정 인덱스를 사용하지 못하게 하는 용도로 사용하는 힌트</p>
</li>
</ul>
<p>최적의 실행 계획은 데이터의 성격에 따라 시시각각 변하므로, 현재의 좋은 계획이 나중에는 달라질 가능성이 있다. 따라서 가능하다면 그때그때 옵티마이저가 당시 통계 정보를 가지고 선택하게 하는 것이 가장 좋다.</p>
<h3 id="sql_calc_found_rows"><code>SQL_CALC_FOUND_ROWS</code></h3>
<p>MySQL의 <code>LIMIT</code>를 사용하는 경우, 조건을 만족하는 레코드가 <code>LIMIT</code>에 명시된 수보다 더 많다고 하더라도 <code>LIMIT</code>에 명시된 수만큼 만족하는 레코드를 찾으면 즉시 검색 작업을 멈춘다.</p>
<p>하지만 <code>SQL_CALC_FOUND_ROW</code> 힌트가 포함된 쿼리의 경우 <code>LIMIT</code>을 만족하는 수만큼 레코드를 찾아도 끝까지 검색을 수행하며, 사용자는 <code>LIMIT</code>에 제한된 수만큼만 결과 레코드를 반환받는다.</p>
<p>웹 프로그램의 페이징 기능에 적용하기 위해 이를 검토하거나 사용하는 경우가 존재하지만, <code>SQL_CALC_FOUND_ROWS</code>는 조건을 만족하는 레코드의 수만큼 랜덤 I/O가 발생하게 되어 굉장히 느려지게 된다. 따라서 해당 힌트를 사용하지 않고 레코드 카운트용 쿼리와 데이터를 조회하는 쿼리를 분리하는 것이 보통 더 효율적이다.</p>
<h2 id="942-옵티마이저-힌트">9.4.2 옵티마이저 힌트</h2>
<h3 id="옵티마이저-힌트-종류">옵티마이저 힌트 종류</h3>
<p>옵티마이저 힌트는 영향 범위에 따라 4개의 그룹으로 나누어 볼 수 있다.</p>
<ul>
<li><p>인덱스
특정 인덱스의 이름을 사용할 수 있는 옵티마이저 힌트</p>
</li>
<li><p>테이블
특정 테이블의 이름을 사용할 수 있는 옵티마이저 힌트</p>
</li>
<li><p>쿼리 블록
특정 쿼리 블록에 사용할 수 있는 옵티마이저 힌트로서, 특정 쿼리 블록의 이름을 명시하는 것이 아니라 힌트가 명시된 쿼리 블록에 대해서만 영향을 미치는 옵티마이저 힌트</p>
</li>
<li><p>글로벌(쿼리 전체)
전체 쿼리에 대해서 영향을 미치는 힌트</p>
</li>
</ul>
<p>하나의 SQL 문장에서 <code>SELECT</code> 키워드는 여러 번 사용될 수 있는데, 각 <code>SELECT</code> 키워드로 시작하는 서브쿼리 영역을 <strong>쿼리 블록</strong>이라고 한다. 특정 쿼리 블록에 영향을 미치는 옵티마이저 힌트는 그 쿼리 블록 내부와 외부 모두 사용될 수 있다.</p>
<p>특정 쿼리 블록을 외부 쿼리 블록에서 사용하려면 <code>QB_NAME()</code> 힌트를 이용해 해당 쿼리 블록에 이름을 부여해야 한다. 다음은 특정 쿼리 블록(서브쿼리)에 대해 &quot;subq1&quot;이라는 이름을 부여하고, 그 쿼리 블록을 힌트에 사용하는 예제다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT /*+ JOIN_ORDER(e, s@subq1) */
            COUNT(*)
        FROM employees e
        WHERE e.first_name=&#39;Matt&#39;
            AND e.emp_no IN (SELECT /* QB_NAME(subq1) */ s.emp_no
                                FROM salaries s
                                WHERE s.salary BETWEEN 50000 AND 50500);</code></pre>
<h3 id="max_execution_time"><code>MAX_EXECUTION_TIME</code></h3>
<p>옵티마이저 힌트 중 유일하게 쿼리의 실행 계획에 영향을 미치지 않는 힌트로, 단순히 쿼리의 최대 실행 시간을 설정하는 힌트다.</p>
<h3 id="set_var"><code>SET_VAR</code></h3>
<p><code>SET_VAT</code> 힌트는 시스템 변수를 제어할 수 있어, 실행 계획을 바꾸는 용도 및 조인 버퍼나 정렬용 버퍼의 크기를 일시적으로 증가시켜 쿼리의 성능을 향상시키는 용도로도 사용할 수 있다.</p>
<h3 id="semijoin--no_semijoin"><code>SEMIJOIN</code> &amp; <code>NO_SEMIJOIN</code></h3>
<p><code>SEMIJOIN</code> 힌트는 어떤 세부 전략을 사용할지 제어하는 데 사용할 수 있다.</p>
<table>
<thead>
<tr>
<th align="left">최적화 전략</th>
<th align="left">힌트</th>
</tr>
</thead>
<tbody><tr>
<td align="left">Duplicate Weed-out</td>
<td align="left">SEMIJOIN(DUPSWEEDOUT)</td>
</tr>
<tr>
<td align="left">First Match</td>
<td align="left">SEMIJOIN(FIRSTMATCH)</td>
</tr>
<tr>
<td align="left">Loose Scan</td>
<td align="left">SEMIJOIN(LOOSESCAN)</td>
</tr>
<tr>
<td align="left">Materialization</td>
<td align="left">SEMIJOIN(MATERIALIZATION)</td>
</tr>
<tr>
<td align="left">Table Pull-out</td>
<td align="left">없음</td>
</tr>
</tbody></table>
<blockquote>
<p>Table Pull-out 최적화 전략은 항상 더 나은 성능을 보장하기 때문에 별도로 힌트를 사용할 수 없다.</p>
</blockquote>
<h3 id="subquery"><code>SUBQUERY</code></h3>
<p>서크붜리 최적화는 세미 조인 최적화가 사용되지 못할 때 사용하는 최적화 방법으로, 다음 2가지 형태로 최적화할 수 있다.</p>
<table>
<thead>
<tr>
<th align="left">최적화 방법</th>
<th align="left">힌트</th>
</tr>
</thead>
<tbody><tr>
<td align="left">IN-TO-EXISTS</td>
<td align="left">SUBQUERY(INTOEXISTS)</td>
</tr>
<tr>
<td align="left">Materialization</td>
<td align="left">SUBQUERY(MATERIALIZATION)</td>
</tr>
</tbody></table>
<p>서브쿼리 최적화 힌트는 주로 안티 세미 조인 최적화에 사용되며, 서브쿼리에 힌트를 사용하거나 서브쿼리에 쿼리 블록 이름을 지정해서 외부 쿼리 블록에서 최적화 방법을 명시하면 된다.</p>
<h3 id="bnl--no_bnl--hashjoin--no_hashjoin"><code>BNL</code> &amp; <code>NO_BNL</code> &amp; HASHJOIN &amp; NO_HASHJOIN</h3>
<p>MySQL 8.0.20 버전부터는 블록 네스티드 루프 조인이 사용되지 않지만 <code>BNL</code> 힌트와 <code>NO_BNL</code> 힌트를 사용해 해시 조인을 사용하도록 유도하는 용도로 사용한다.</p>
<p><code>HASHJOIN</code>과 <code>NO_HASHJOIN</code> 힌트는 MySQL 8.0.18 버전에서만 유효하며, 그 이후 버전에서는 효력이 없다.</p>
<h3 id="join_fixed_order--join_order--join_prefix--join_suffix"><code>JOIN_FIXED_ORDER</code> &amp; <code>JOIN_ORDER</code> &amp; <code>JOIN_PREFIX</code> &amp; <code>JOIN_SUFFIX</code></h3>
<p>MySQL 서버에서 조인 순서를 결정하기 위해 사용하던 <code>STRAIGHT_JOIN</code> 힌트의 단점을 보완하기 위해 다음과 같은 4개의 힌트를 제공한다.</p>
<ul>
<li><p><code>JOIN_FIXED_ORDER</code>
<code>STRAIGHT_JOIN</code> 힌트와 동일하게 <code>FROM</code> 절의 테이블 순서대로 조인을 실행하게 하는 힌트</p>
</li>
<li><p><code>JOIN_ORDER</code>
<code>FROM</code> 절에 사용된 테이블의 순서가 아니라 힌트에 명시된 테이블의 순서대로 조인을 실행하는 힌트</p>
</li>
<li><p><code>JOIN_PREFIX</code>
조인에서 드라이빙 테이블만 강제하는 힌트</p>
</li>
<li><p><code>JOIN_SUFFIX</code>
조인에서 드리븐 테이블(가장 마지막에 조인돼야 할 테이블들)만 강제하는 힌트</p>
</li>
</ul>
<h3 id="merge--no_merge"><code>MERGE</code> &amp; <code>NO_MERGE</code></h3>
<p>MySQL 옵티마이저가 내부 쿼리를 외부 쿼리와 병합하는 것과 임시 테이블을 생성하는 것 중 최적의 방법을 선택하지 못할 때 <code>MERGE</code> 또는 <code>NO_MERGE</code> 옵티마이저 힌트를 사용하면 된다.</p>
<h3 id="index_merge--no_index_merge"><code>INDEX_MERGE</code> &amp; <code>NO_INDEX_MERGE</code></h3>
<p>인덱스 머지 실행 계획은 성능 향상에 도움이 되지만 항상 그렇지는 않으므로, 이를 제어하고자 할 때 <code>INDEX_MERGE</code>와 <code>NO_INDEX_MERGE</code> 옵티마이저 힌트를 이용할 수 있다.</p>
<h3 id="no_icp">NO_ICP</h3>
<p>인덱스 컨디션 푸시다운 최적화는 사용 가능하다면 항상 성능 향상에 도움이 되므로 MySQL 옵티마이저는 최대한 인덱스 컨디션 푸시다운 기능을 사용하는 방향으로 실행 계획을 수립하므로 ICP 힌트는 제공되지 않는다.</p>
<p>그러나 인덱스 컨디션 푸시다운으로 인해 여러 실행 계획의 비용 계산이 잘못된다면 결과적으로 잘못된 실행 계획을 수립하게 될 수 있으므로, 조금 더 유연하고 정확하게 실행 계획을 선택하게 하고 싶은 경우 <code>NO_ICP</code> 옵티마이저 힌트를 사용해 인덱스 컨디션 푸시다운 최적화를 비활성화할 수 있다.</p>
<h3 id="skip_scan--no_skip_scan"><code>SKIP_SCAN</code> &amp; <code>NO_SKIP_SCAN</code></h3>
<p>MySQL 옵티마이저가 유니크한 값의 개수를 제대로 분석하지 못하거나 잘못된 경로로 인해 비효율적인 스킵 스캔을 선택하는 경우가 발생한다. 따라서 인덱스 스킵 스캔 여부를 <code>SKIP_SCAN</code> 및 <code>NO_SKIP_SCAN</code> 옵티마이저 힌트를 이용해 제어할 수 있다.</p>
<h3 id="index--no_index"><code>INDEX</code> &amp; <code>NO_INDEX</code></h3>
<p><code>INDEX</code>와 <code>NO_INDEX</code> 옵티마이저 힌트는 이전 인덱스 힌트를 대체하는 용도로, 다음과 같이 제공된다.</p>
<table>
<thead>
<tr>
<th align="left">인덱스 힌트</th>
<th align="left">옵티마이저 힌트</th>
</tr>
</thead>
<tbody><tr>
<td align="left">USE INDEX</td>
<td align="left">INDEX</td>
</tr>
<tr>
<td align="left">USE INDEX FOR GROUP BY</td>
<td align="left">GROUP_INDEX</td>
</tr>
<tr>
<td align="left">USE INDEX FOR ORDER BY</td>
<td align="left">ORDER_INDEX</td>
</tr>
<tr>
<td align="left">IGNORE INDEX</td>
<td align="left">NO_INDEX</td>
</tr>
<tr>
<td align="left">IGNORE INDEX FOR GROUP BY</td>
<td align="left">NO_GROUP_INDEX</td>
</tr>
<tr>
<td align="left">IGNORE INDEX FOR ORDER BY</td>
<td align="left">NO_ORDER_INDEX</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 09. 옵티마이저 (3)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80-3</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80-3</guid>
            <pubDate>Tue, 26 May 2026 08:03:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h1 id="93-고급-최적화">9.3 고급 최적화</h1>
<h2 id="931-옵티마이저-스위치-옵션">9.3.1 옵티마이저 스위치 옵션</h2>
<h3 id="루스-스캔-loosescan">루스 스캔 (loosescan)</h3>
<p><strong>LooseScan</strong>은 인덱스를 사용하는 <code>GROUP BY</code> 최적화 방법의 <strong>루스 인덱스 스캔</strong>과 비슷한 읽기 방식을 사용한다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM departments d WHERE d.dept_no IN (
            SELECT de.dept_no FROM dept_emp de );</code></pre>
<p>위 쿼리에서 <code>dept_emp</code> 테이블에는 (<code>dept_no</code> + <code>emp_no</code>) 칼럼의 조합으로 프라이머리 키 인덱스가 만들어져 있으며, 전체 레코드 수는 33만 건 정도 존재하지만 <code>dept_no</code> 만으로 그루핑하면 9건만 남게 된다.</p>
<p>따라서 <code>dept_mep</code> 테이블의 프라이머리 키를 루스 인덱스 스캔으로 유니크한 <code>dept_no</code>만 읽으면 중복된 레코드를 제거하며 아주 효율적으로 서브쿼리 부분을 실행할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/f18c5420-2af3-4c40-a2f0-e8eb2c2c2338/image.png" alt=""></p>
<p>해당 쿼리의 실행 계획은 아래와 같으며, <code>Extra</code> 칼럼에 &quot;LooseScan&quot; 문구를 확인할 수 있다. 또한 각 테이블에 할당된 <code>id</code> 칼럼 값이 동일하게 &quot;1&quot;이라는 것을 통해 내부적으로는 조인처럼 처리됐다는 것을 말해준다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/3acfcb2a-7c7f-4846-9aa3-b296e3790dcd/image.png" alt=""></p>
<blockquote>
<p>해당 쿼리의 실행 계획을 실제로 확인했을 때는 루스 스캔이 아닌 Firstmatch 최적화 방식을 사용되었다. 이는 서브쿼리 최적화의 성숙도 문제로, 옵티마이저 스위치를 변경 후 다시 테스트했다.</p>
</blockquote>
<p>LooseScan 최적화는 다음과 같은 특성을 가진다.</p>
<ul>
<li>루스 인덱스 스캔으로 서브쿼리 테이블을 읽은 이후 아우터 에이블을 드리븐으로 사용해서 조인을 수행한다. 그래서 서브쿼리 부분이 루스 인덱스 스캔을 사용할 수 있는 조건이 갖추져야 사용할 수 있는 최적화이며, 다음과 같은 형태의 서브쿼리에서 사용할 수 있다.</li>
</ul>
<pre><code class="language-sql">SELECT .. FROM .. WHERE expr IN (SELECT keypart1 FROM tab WHERE ...)

SELECT .. FROM .. WHERE expr IN (
    SELECT keypart2 FROM tab WHERE keypart1=&#39;상수&#39; ...
)</code></pre>
<h3 id="구체화-materialization">구체화 (Materialization)</h3>
<p><strong>Materialization 최적화</strong>는 세미 조인에 사용된 서브쿼리를 통째로 구체화해서 쿼리를 최적화한다는 의미다. 여기서 <strong>구체화(Materialization)</strong>은 쉽게 표현하면 내부 임시 테이블을 생성한다는 것을 의미한다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * 
        FROM employees e
        WHERE e.emp_no IN (
            SELECT de.emp_no FROM dept_emp de
            WHERE de.from_date=&#39;1995-01-01&#39;);</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/7fe329a9-1db4-4ca4-9c3f-bac0680d1946/image.png" alt=""></p>
<p>위 쿼리는 <strong>서브쿼리 구체화(Subquery Materialization)</strong>을 사용하는 예제이며, 아래는 해당 쿼리의 실행 계획이다.</p>
<p><code>select_type</code> 칼럼에서 &quot;MATERIALIZED&quot; 문구를 확인할 수 있으며, 쿼리의 테이블은 2개인데 실행 계획은 3개 라인이 생성된 것을 보아 임시 테이블이 생성된 것을 짐작할 수 있다.</p>
<p><code>dept_emp</code> 테이블을 읽는 서브쿼리가 먼저 실행되어 그 결과로 임시 테이블(&lt;subquery2&gt;)이 만들어 졌고, 최종적으로 <code>employees</code> 테이블을 조인해 결과를 반환한다.</p>
<p><strong>Materialization 최적화</strong>가 사용될 수 있는 형태의 쿼리에 몇 가지 제한 사항 및 특성이 존재한다.</p>
<ul>
<li><code>IN(subquery)</code>에서 서브쿼리는 상관 서브쿼리가 아니어야 하낟.</li>
<li>서브쿼리는 <code>GROUP BY</code>나 집함 함수들이 사용돼도 구체화를 사용할 수 있다.</li>
<li>구체화가 사용된 경우에는 내부 임시 테이블이 사용된다.</li>
</ul>
<h3 id="중복-제거-duplicated-weed-out">중복 제거 (Duplicated Weed-Out)</h3>
<p><strong>Duplicate Weedout</strong>은 세미 조인 서브쿼리를 일반적인 <code>INNER JOIN</code> 쿼리로 바꿔서 실행하고 마지막에 중복된 레코드를 제거하는 방법으로 처리되는 최적화 알고리즘이다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM employees e
        WHERE e.emp_no IN (
            SELECT s.emp_no FROM salaries s WHERE s.salary &gt; 1500000);</code></pre>
<p><code>salaries</code> 테이블의 프라이머리 키는 (<code>emp_no</code> + <code>from_date</code>)로,<code>salary</code>가 150000 이상인 레코드를 조회하면 결과에는 중복된 <code>emp_no</code>가 발생할 수 있다.</p>
<p><strong>Duplicate Weedout</strong> 최적화는 아래와 같이 원본 쿼리를 <code>INNER JOIN</code> + <code>GROUP BY</code> 절로 바꿔서 실행하는 것과 동일한 작업으로 쿼리를 처리한다.</p>
<pre><code class="language-sql">mysql&gt; SELECT e.*
        FROM employees e, salaries s
        WHERE e.emp_no=s.emp_no AND s.salary&gt;150000
        GROUP BY e.emp_no;</code></pre>
<p>다음은 <strong>Dulicate Weedout</strong> 최적화 알고리즘을 처리하는 과정이며, 이를 이미지로 나타냈다.</p>
<ol>
<li><code>salaries</code> 테이블의 <code>ix_salary</code> 인덱스를 스캔해서 <code>salary</code>가 150000보다 큰 사원을 검색해 <code>employees</code> 테이블 조인을 실행</li>
<li>조인된 결과를 임시 테이블에 저장</li>
<li>임시 테이블에 저장된 결과에서 <code>emp_no</code> 기준으로 중복 제거</li>
<li>중복을 제거하고 남은 레코드를 최종적으로 반환</li>
</ol>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/e828d3a1-7842-4357-9a8c-61658ab39638/image.png" alt=""></p>
<p>실행 계획은 다음과 같으며, <code>Extra</code> 칼럼에서 &quot;Start templorary&quot;, &quot;End temporary&quot; 문구를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/c26d0ea7-5151-4df0-8559-4264e10458f6/image.png" alt=""></p>
<p><strong>Duplicate Weedout</strong> 최적화는 다음과 같은 장점과 제약 사항이 있다.</p>
<ul>
<li>서브쿼리가 상관 서브쿼리여도 사용할 수 있다.</li>
<li>서브쿼리가 <code>GROUP BY</code>나 집합 함수가 사용된 경우에는 사용될 수 없다.</li>
<li>서브쿼리의 테이블을 조인으로 처리하기 때문에 최적화할 수 있는 방법이 많다.</li>
</ul>
<h3 id="컨디션-팬아웃-condition_fanout_filter">컨디션 팬아웃 (condition_fanout_filter)</h3>
<p>조인을 실행할 때 테이블의 순서는 쿼리의 성능에 매우 큰 영향을 미치므로, MySQL 옵티마이저는 여러 테이블이 조인되는 경우 가능하면 일치하는 레코드 건수가 적은 순서대로 조인을 실행한다.</p>
<p>다음은 예제 쿼리 및 <code>condition_fanout_filter</code> 옵티마이저 옵션을 비활성화한 상태의 실행 계획이다.</p>
<pre><code class="language-sql">mysql&gt; SET optimizer_switch=&#39;condition_fanout_filter=off&#39;;

mysql&gt; SELECT *
        FROM employees e
            INNER JOIN salaries s ON s.emp_no=e.emp_no
          WHERE e.first_name=&#39;Matt&#39;
            AND e.hire_date BETWEEN &#39;1985-11-21&#39; AND &#39;1986-11-21&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/6940a3a9-0151-48cd-9bfa-196f96d8a6b8/image.png" alt=""></p>
<p>실행 계획에 의한 쿼리의 처리 절차는 대략 다음과 같다.</p>
<ol>
<li><code>employees</code> 테이블에서 <code>ix_firstname</code> 인덱스를 이용해 <code>first_name=&#39;Matt&#39;</code> 조건에 일치하는 233건의 레코드를 검색한다.</li>
<li>검색된 레코드 중에서 <code>hire_date</code> 조건에 해당하는 레코드만 걸러내는데, <code>filtered</code> 칼럼의 값이 100인 것은 옵티마이저가 233건 모두 조건을 만족할 것으로 예측했다는 것을 의미한다.</li>
<li><code>employees</code> 테이블을 읽은 결과 233건에 대해 <code>salaries</code> 테이블의 프라이머리 키를 이용해 <code>salaries</code> 테이블의 레코드를 읽는다. 이때 MySQL 옵티마이저는 <code>employees</code> 테이블 레코드 한 건당 <code>salaries</code> 테이블 레코드 9건이 일치할 것으로 예상했다.</li>
</ol>
<p>다음은 옵션을 활성화 한 뒤 실행 계획이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/1f72d679-160c-4efd-bb08-e085b844ec14/image.png" alt=""></p>
<p>여기서 중요한 차이점은 <code>rows</code> 칼럼의 값은 233으로 동일하지만, <code>filtered</code> 칼럼의 값이 100%에서 26.07%로 변경됐다는 점으로, <code>condition_fandout_filter</code> 최적화가 활성화되며 MySQL 옵티마이저는 인덱스를 사용할 수 있는 <code>first_name</code> 칼럼 조건 이외의 나머지 조건(<code>hire_date</code>)에 대해서도 얼마나 조건을 충족할지를 고려했다는 의미다.</p>
<p><code>condition_fanout_filter</code> 최적화는 다음과 같은 조건을 만족하는 칼럼의 조건들에 대해 조건을 만족하는 레코드의 비율을 계산할 수 있다.</p>
<ol>
<li><code>WHERE</code> 조건절에 사용된 칼럼에 대해 인덱스가 있는 경우</li>
<li><code>WHERE</code> 조건절에 사용된 칼럼에 대해 히스토그램이 존재하는 경우</li>
</ol>
<h3 id="파생-테이블-머지-derived_merge">파생 테이블 머지 (derived_merge)</h3>
<p>MySQL 서버에서 <code>FROM</code> 절에 사용된 서브쿼리를 임시 테이블로 만들었는데, 이를 <strong>파생 테이블(Derived table)</strong>이라고 한다.</p>
<p>이전 버전에서는 MySQL 서버 내부적으로 임시 테이블을 생성한 다음 외부 쿼리 부분을 처리했는데, MySQL 5.7 버전부터는 파생 테이블로 만들어지는 서브쿼리를 외부 쿼리와 병합해서 서브쿼리 부분을 제거하는 <strong>파생 테이블 머지(<code>derived_merge</code>)</strong> 최적화가 도입되었다.</p>
<p>다음과 같은 조건에서는 옵티마이저가 자동으로 서브쿼리를 외부 쿼리로 병합할 수 없게 되어, 수동으로 서브쿼리를 외부 쿼리로 병합해서 작성해야 한다.</p>
<ul>
<li><code>SUM()</code>, <code>MIN()</code>, <code>MAX()</code> 같은 집계 함수와 윈도우 함수가 사용된 서브쿼리</li>
<li><code>DISTINCT</code>가 사용된 서브쿼리</li>
<li><code>GROUP BY</code>나 <code>HAVING</code>이 사용된 서브쿼리</li>
<li><code>LIMIT</code>이 사용된 서브쿼리</li>
<li><code>UNION</code> 또는 <code>UNION ALL</code>을 포함하는 서브쿼리</li>
<li><code>SELECT</code> 절에 사용된 서브쿼리</li>
<li>값이 변경되는 사용자 변수가 사용된 서브쿼리</li>
</ul>
<h3 id="인비저블-인덱스-use_invisible_indexes">인비저블 인덱스 (use_invisible_indexes)</h3>
<p>MySQL 8.0 버전부터는 인덱스가 존재하더라도 옵티마이저가 해당 인덱스를 사용하지 못하게 제어하는 기능을 제공한다.</p>
<pre><code class="language-sql">-- // 옵티마이저가 ix_hiredate 인덱스를 사용하지 못하게 변경
mysql&gt; ALTER TABLE employees ALTER INDEX ix_hiredate INVISIBLE;

-- // 옵티마이저가 ix_hiredate 인덱스를 사용할 수 있게 변경
mysql&gt; ALTER TABLE employees ALTER INDEX ix_hiredate VISIBLE;</code></pre>
<h3 id="스킵-스캔-skip_scan">스킵 스캔 (skip_scan)</h3>
<p><strong>인덱스 스킵 스캔</strong> 최적화는 인덱스의 선행 칼럼이 조건절에 사용되지 않더라도 후행 칼럼의 조건만으로도 인덱스를 이용한 쿼리 성능 개선이 가능하다. </p>
<pre><code class="language-sql">mysql&gt; ALTER TABLE employees ADD INDEX ix_gender_birthdate (gender, birth_date);

-- // ix_gender_birthdate 인덱스를 사용하지 못하는 쿼리
mysql&gt; SELECT * FROM employees WHERE bitrh_date&gt;=&#39;1965-02-01&#39;;

-- // ix_gender_birthdate 인덱스를 사용할 수 있는 쿼리
mysql&gt; SELECT * FROM employees WHERE gender=&#39;M&#39; AND birth_date&gt;=&#39;1965-02-01&#39;;</code></pre>
<p>위처럼 <code>gender</code> 칼럼에 대한 조건이 존재하지 않아 인덱스를 사용할 수 없는 경우, MySQL 8.0 버전의 옵티마이저는 테이블에 존재하는 모든 <code>gender</code> 칼럼의 값을 가져와 두 번째 쿼리와 같이 <code>gender</code> 칼럼의 조건이 있는 것처럼 쿼리를 최적화한다.</p>
<p>인덱스의 선행 칼럼이 다양한 값을 가지는 경우 <strong>인덱스 스킵 스캔</strong> 최적화가 비효율적일 수 있어, 옵티마이저는 인덱스의 선행 칼럼이 소수의 유니크한 값을 가질 때만 <strong>인덱스 스킵 스캔</strong> 최적화를 사용한다.</p>
<h3 id="해시-조인-hash_join">해시 조인 (hash_join)</h3>
<p>대부분 <strong>해시 조인</strong>이 기존 네스티드 루프 조인보다 빠르다고 생각하지만, 이는 항상 옳지는 않다. 다음은 네스티드 루프 조인과 해시 조인의 처리 성능을 비교해 보여주는 것으로, 화살표의 길이는 전체 쿼리의 실행 시간을 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/b1ac0460-3d8e-49d0-9113-dacf9a728871/image.png" alt=""></p>
<p>네스티드 루프 조인과 해시 조인은 똑같은 시점에 시작했지만 해시 조인이 먼저 끝난 것을 확인할 수 있다. A 지점은 쿼리가 실행되며 MySQL 서버가 첫 번째 레코드를 찾아낸 시점, B 지점은 마지막 레코드를 찾아낸 시점을 의미한다.</p>
<p>해시 조인은 첫 번째 레코드를 찾는 데는 시간이 많이 걸리지만 최종 레코드를 찾는 데까지 시간이 많이 걸리지만, 네스티드 루프 조인은 반대로 첫 번째 레코드를 찾는 것은 빠르나 마지막 레코드를 찾는 데까지 시간이 많이 걸리는 것을 알 수 있다.</p>
<p>즉, 해시 조인은 <strong>최고 스루풋</strong> 전략에, 네스티드 루프 조인은 <strong>최고 응답 속도</strong> 전략에 적합하다.</p>
<p>MySQL서버는 온라인 트랜잭션 처리를 위한 범용 RDBMS로, 기본적으로 응답 속도에 집중해 최적화한다. 따라서 MySQL 서버의 <strong>해시 조인</strong> 최적화는 네스티드 루프 조인이 사용되기 적합하지 않은 경우를 위한 차선책 정도로 생각하면 되며, MySQL 8.0.20 버전부터는 네스티드 루프 조인을 사용할 수 없는 경우에는 항상 해시 조인이 사용되도록 바뀌었다.</p>
<p>일반적으로 해시 조인은 <strong>빌드 단계(Build-phase)</strong>와 <strong>프로브 단계(Probe-phase)</strong>로 나뉘어 처리된다.</p>
<p><strong>빌드 단계</strong>에서는 조인 대상 테이블 중에서 레코드 건수가 적어서 해시 테이블로 만들기에 용이한 테이블을 골라서 메모리에 해시 테이블을 생성(빌드)하는 작업을 수행한다.</p>
<p><strong>프로브 단계</strong>는 나머지 테이블의 레코드를 읽어서 해시 테이블의 일치 레코드를 찾는 과정을 진행한다.</p>
<blockquote>
<p>빌드 단계에서 해시 테이블을 만들 때 사용되는 원본 테이블을 <strong>빌드 테이블</strong>, 프로브 단계에서 읽는 나머지 테이블을 <strong>프로브 테이블</strong>이라고도 한다.</p>
</blockquote>
<p>다음은 해시 조인 최적화 방식을 살펴보기 위한 예제로, <code>IGNORE INDEX</code> 힌트는 네스티드 루프 조인이 사용되지 못하게 하기 위해 사용했다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT *
        FROM employees e IGNORE INDEX(PRIMARY, ix_hiredate)
            INNER JOIN dept_emp de IGNORE INDEX(
                ix_empno_fromdate, ix_fromdate
            ) ON de.emp_no=e.emp_no AND de.from_date=e.hire_date;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/6a9613f7-2da8-40ed-85ab-1688fe63b7d5/image.png" alt=""></p>
<p>MySQL 옵티마이저는 해시 조인을 위해 빌드 테이블인 <code>dept_emp</code> 테이블의 레코드를 읽어서 메모리에 해시 테이블을 생성하고, 프로브 테이블 <code>employees</code> 테이블을 스캔하면서 메모리에 생성된 해시 테이블에서 레코드를 찾아서 결과를 사용자에게 반환하였다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/86d4ca56-478a-424b-a836-de963600e10b/image.png" alt=""></p>
<p>해시 테이블의 레코드 건수가 많아서 조인 버퍼의 공간이 부족한 경우, MySQL 서버는 빌드 테이블과 프로브 테이블을 적당한 크기(하나의 청크가 조인 버퍼보다 작도록)의 청크로 분리한 다음, 청크별로 위 이미지와 동일한 방식을 사용해 해시 조인을 처리한다.</p>
<p>다음 이미지는 조인 버퍼보다 해시 테이블이 큰 경우 해시 조인이 실행되는 방법으로, 해시 테이블이 설정된 메모리 크기(<code>join_buffer_size</code>)보다 큰 지를 알 수 없어 조금 복잡하다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/1c070aa7-b834-4ccf-8758-75b93d4e7753/image.png" alt=""></p>
<p>위와 같은 1차 조인 결과를 생성한 뒤에는 MySQL 서버는 디스크에 저장된 <strong>빌드 테이블 청크</strong>에서 첫 번째 청크를 읽어 다시 메모리 해시 테이블을 구축하고, <strong>프로브 테이블 청크</strong>에서 첫 번째 청크를 읽어 조인을 수행해 2차 결과를 가져오며, 해당 과정을 디스크에 저장된 청크 개수만큼 반복하여 완성된 조인 결과를 만들어낸다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/fc3a6e6e-c791-48b6-b7a2-da29352e7c23/image.png" alt=""></p>
<p>MySQL 옵티마이저는 빌드 테이블의 크기에 따라 메모리에서 모두 처리 가능한 경우에는 <strong>클래식 해시 조인 알고리즘</strong>을, 해시 테이블이 조인 버퍼 메모리보다 큰 경우에는 <strong>그레이시 해시 조인 알고리즘</strong>을 활용하도록 구현돼있다.</p>
<h3 id="인덱스-정렬-선호-prefer_ordering_index">인덱스 정렬 선호 (prefer_ordering_index)</h3>
<p>MySQL 옵티마이저는 <code>ORDER BY</code> 또는 <code>GROUP BY</code>를 인덱스를 사용해 처리 가능한 경우 쿼리의 실행계획에서 이 인덱스의 가중치를 높이 설정해서 실행된다.</p>
<p>MySQL 8.0.20 버전까지는 옵티마이저가 체크해야 하는 레코드 건수가 많음에도 불구하고 정렬된 인덱스를 활용하여 실행 계획을 선택하는 실수가 가끔 발생하여, 직접 <code>IGNORE INDEX</code> 힌트를 사용하여 방지하였다.</p>
<p>MySQL 8.0.21 버전부터는 MySQL 서버 옵티마이저가 <code>ORDER BY</code>를 위한 인덱스에 너무 가중치를 부여하지 않도록 <code>prefer_ordering_index</code> 옵티마이저 옵션이 추가되었다.</p>
<h2 id="932-조인-최적화-알고리즘">9.3.2 조인 최적화 알고리즘</h2>
<h3 id="exhaustive-검색-알고리즘">Exhaustive 검색 알고리즘</h3>
<p><strong>Exhaustive 검색 알고리즘</strong>은 <code>FROM</code>절에 명시된 모든 테이블의 조합에 대해 실행 계획의 비용을 계산해서 최적의 조합 1개를 찾는 방법이다.</p>
<p>다음과 같이 간단한 4개의 테이블을 조인하는 쿼리 문장이 존재할 때, 이를 <strong>Exhaustive 검색 알고리즘</strong>으로 처리하는 과정을 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; SELECT *
        FROM t1, t2, t3, t4
        WHERE ...</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/b8a8e608-5601-4ab0-b8e7-04ab11d980ba/image.png" alt=""></p>
<p>이미지는 <strong>Exhaustive 검색 알고리즘</strong>으로 4개의 테이블의 최적 조인 순서를 찾는 방법을 표현한 것이다. 만약 테이블이 더 많아지는 경우 모든 테이블의 조합에 대한 실행 계획 비용을 계산하기 때문에 시간 소모적인 문제가 발생한다.</p>
<h3 id="greedy-검색-알고리즘">Greedy 검색 알고리즘</h3>
<p><strong>Greedy 검색 알고리즘</strong>은 Exhaustive검색 알고리즘의 시간 소모적인 문제점을 해결하기 위해 MySQL 5.0부터 도입된 최적화 기법이다.</p>
<p>이전과 동일하게 4개의 테이블 조인 쿼리를 <strong>Greedy 검색 알고리즘</strong>을 통해 처리할 때의 과정은 다음과 같다. (<code>optimizer_search_depth</code> 값 2로 가정)</p>
<ol>
<li>전체 N개의 테이블 중에서 <code>optimizer_search_depth</code> 시스템 설정 변수에 정의된 개수의 테이블로 가능한 조인 조합 생성</li>
<li>생성된 조인 조합 중에서 최소 비용의 실행 계획 하나 선정</li>
<li>선정된 실행 계획의 첫 번째 테이블을 &quot;부분 실행 계획&quot;의 첫 번째 테이블로 선정</li>
<li>전체 N-1개의 테이블 중(3번에서 선택된 테이블 제외)에서 <code>optimizer_search_depth</code> 시스템 설정 변수에 정의된 개수의 테이블로 가능한 조인 조합 생성</li>
<li>생성된 조인 조합들을 하나씩 &quot;부분 실행 계획&quot;에 대입해 실행 비용 계산</li>
<li>비용 계산 결과, 최적의 실행 계획에서 두 번째 테이블을 &quot;부분 실행 계획&quot;의 두 번째 테이블로 선정</li>
<li>남은 테이블이 모두 없어질 때까지 4~6번 과정을 반복하며 &quot;부분 실행 계획&quot;의 테이블 조인 순서 기록</li>
<li>최종적으로 &quot;부분 실행 계획&quot;이 테이블의 조인 순서로 결정됨</li>
</ol>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/7c5ff3b0-5b49-4ad3-add2-e87b246daa9f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 09. 옵티마이저와 힌트 (2)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EC%99%80-%ED%9E%8C%ED%8A%B8-2</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EC%99%80-%ED%9E%8C%ED%8A%B8-2</guid>
            <pubDate>Thu, 21 May 2026 06:23:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="93-고급-최적화">9.3 고급 최적화</h2>
<h3 id="931-옵티마이저-스위치-옵션">9.3.1 옵티마이저 스위치 옵션</h3>
<p>옵티마이저 스위치 옵션은 <code>optimizer_switch</code> 시스템 변수를 이용해 여러 개의 옵션을 세트로 묶어서 설정하는 방식으로 사용하며, 설정할 수 있는 최적화 옵션은 다음과 같다.</p>
<table>
<thead>
<tr>
<th align="left">옵티마이저 스위치 이름</th>
<th align="left">기본값</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">batced_key_access</td>
<td align="left">off</td>
<td align="left">BKA 조인 알고리즘 사용 여부</td>
</tr>
<tr>
<td align="left">block_nested loop</td>
<td align="left">on</td>
<td align="left">Block Nested Loop 조인 알고리즘 사용 여부</td>
</tr>
<tr>
<td align="left">engine_condition_pushdown</td>
<td align="left">on</td>
<td align="left">Engine Condition Pushdown 기능 사용 여부</td>
</tr>
<tr>
<td align="left">index_condition_pushdown</td>
<td align="left">on</td>
<td align="left">Index Condition Pushdown 기능 사용 여부</td>
</tr>
<tr>
<td align="left">use_index_extensions</td>
<td align="left">on</td>
<td align="left">Index Extension 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">index_merge</td>
<td align="left">on</td>
<td align="left">Index Merge 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">index_merge_intersection</td>
<td align="left">on</td>
<td align="left">Index Merge Intersection 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">index_merge_sort_union</td>
<td align="left">on</td>
<td align="left">Index Merge Sort Union 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">index_merge_union</td>
<td align="left">on</td>
<td align="left">Index Merge Union 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">mrr</td>
<td align="left">on</td>
<td align="left">MRR 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">mrr_cost_based</td>
<td align="left">on</td>
<td align="left">비용 기반 MRR 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">semijoin</td>
<td align="left">on</td>
<td align="left">세미 조인 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">firstmatch</td>
<td align="left">on</td>
<td align="left">FirstMatch 세미 조인 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">loosescan</td>
<td align="left">on</td>
<td align="left">LooseScan 세미 조인 최적화 사용 여부</td>
</tr>
<tr>
<td align="left">materialization</td>
<td align="left">on</td>
<td align="left">Materialization 최적화 사용 여부 (Materialization 세미 조인 최적화 포함)</td>
</tr>
<tr>
<td align="left">subquery_materialization_cost_based</td>
<td align="left">on</td>
<td align="left">비용 기반 Materialization 최적화 사용 여부</td>
</tr>
</tbody></table>
<h4 id="mrr과-배치-키-액세스-mrr--batched_key_access">MRR과 배치 키 액세스 (mrr &amp; batched_key_access)</h4>
<p><strong>MRR</strong>은 Multi-Range Read를 줄여서 부르는 것으로, DS-MRR(Disk Sweep Multi-Range Read)이라고도 한다. </p>
<p>MySQL 서버에서 지금까지 지원하던 조인 방식은 드라이빙 테이블의 레코드를 한 건 읽어서 드리븐 테이블의 일치하는 레코드를 찾아 조인을 수행하는 <strong>네스티드 루프</strong>방식을 사용했다. 조인 처리는 MySQL 엔진이 처리하지만, 실제 레코드를 검색하고 읽는 부분은 스토리지 엔진이 담당하므로, 네스티드 루프 방식을 사용하면 레코드를 찾고 읽는 스토리지 엔진에서는 아무런 최적화를 수행할 수 없다는 단점이 있다.</p>
<p>이를 보완하기 위해 드라이빙 테이블의 레코드를 읽어서 드리븐 테이블과의 조인을 즉시 실행하지 않고 조인 대상을 버퍼링하고, 조인 버퍼에 레코드가 가득 차면 MySQL 엔진은 버퍼링된 레코드를 스토리지 엔진으로 한 번에 요청하도록 하였다. </p>
<p>이로 인해 스토리지 엔진은 읽어야 할 레코드들을 데이터 페이지에 정렬된 순서로 접근해 디스크의 데이터 페이지 읽기를 최소화 할 수 있다.</p>
<p>이러한 읽기 방식을 <strong>MRR</strong>이라고 하며, MRR을 응용해서 실행되는 조인 방식을 <strong>BKA(Batched Key Access) 조인</strong>이라고 한다. BKA 조인은 부가적인 정렬 작업이 필요해지며 성능에 안 좋은 영향을 미치는 경우가 있어 기본적으로 비활성화돼있다.</p>
<h4 id="블록-네스티드-루프-조인">블록 네스티드 루프 조인</h4>
<p><strong>네스티드 루프 조인(Nested Loop Join)</strong>은 조인의 연결 조건이 되는 칼럼에 모두 인덱스가 있는 경우 사용되는 조인 방식으로, 프로그래밍 언어에서 마치 중첩된 반복 명령을 사용하는 것처럼 작동한다고 해서 이와 같이 부르게 되었다.</p>
<p><strong>네스티드 루프 조인</strong>과 <strong>블록 네스티드 로프 조인</strong>의 가장 큰 차이는 조인 버퍼의 사용 여부와 조인에서 드라이빙 테이블과 드리븐 테이블이 어떤 순서로 조인되느냐이다.</p>
<blockquote>
<p>조인 알고리즘에서 &quot;Block&quot;이라는 단어가 사용되면 조인용으로 별도의 버퍼를 사용됐다는 것을 의미한다.</p>
</blockquote>
<p>조인은 드라이빙 테이블에서 일치하는 레코드의 건수만큼 드리븐 테이블을 검색하며 처리된다. 즉 드라이빙 테이블은 한 번에 쭉 읽지만, 드리븐 테이블은 여러 번 읽어야 한다. </p>
<p>따라서 드리븐 테이블을 검색할 때 인덱스를 사용할 수 없는 쿼리는 상당히 느려지며, 옵티마이저는 최대한 인덱스를 사용할 수 있게 실행 계획을 수립한다.</p>
<p>하지만 드리븐 테이블이 인덱스를 사용할 수 없는 경우, 옵티마이저는 드라이빙 테이블에서 읽은 레코드를 메모리에 캐시한 후 드리븐 테이블과 메모리 캐시를 조인하는 형태로 처리하며, 이때 사용되는 메모리의 캐시를 <strong>조인 버퍼(Join buffer)</strong>라고 한다. 조인이 완료되면 조인 버퍼는 바로 해제된다.</p>
<p>아래 쿼리의 경우 각 테이블에 대한 조건은 있지만. 조인 조건은 없어 각 테이블의 조건을 만족하는 레코드의 카테시안 조인을 수행한다. </p>
<pre><code class="language-sql">mysql&gt; SELECT *
        FROM dept_emp de, employees e
        WHERE de.from_date&gt;&#39;1995-01-01&#39; AND e.emp_no&lt;109004;</code></pre>
<p>다음 이미지는 해당 쿼리에서 조인 버퍼가 어떻게 사용되는지 보여준다. 여기서 중요한 점은 조인 버퍼가 사용되는 쿼리에서는 조인의 순서가 거꾸로인 것처럼 실행된다는 것이다. </p>
<p>드라이빙 테이블의 결과는 조인 버퍼에 담아두고, 드리븐 테이블을 먼저 읽고 조인 버퍼에서 일치하는 레코드를 찾는 방식으로 처리되기 때문에 결과의 정렬 순서가 흐트러질 수 있다.
<img src="https://velog.velcdn.com/images/youhyuk_/post/48668fdd-113d-48d1-9ea8-10613b3c9749/image.png" alt=""></p>
<blockquote>
<p>MySQL 8.0.20 버전부터는 해시 조인 알고리즘으로 대체되어 사용되고 있다.</p>
</blockquote>
<h4 id="인덱스-컨디션-푸시다운-index_condition_pushdown">인덱스 컨디션 푸시다운 (index_condition_pushdown)</h4>
<p>인덱스 컨디션 푸시다운의 개념을 설명하기 전, 예제를 먼저 살펴보자. 다음과 같이 인덱스를 생성하고, 옵티마이저 스위치를 조정해 인덱스 컨디션 푸시다운 기능을 비활성화하였다.</p>
<pre><code class="language-sql">mysql&gt; ALTER TABLE employees ADD INDEX 
        ix_lastname_firstname (last_name, first_name);

mysql&gt; SET optimizer_switch=&#39;index_condition_pushdown=off&#39;;</code></pre>
<p>이제 다음 쿼리를 실행할 때의 처리 과정을 살펴보자. </p>
<pre><code class="language-sql">mysql&gt; SELECT * FROM employees 
        WHERE last_name=&#39;Action&#39; AND first_name LIKE &#39;%sal&#39;;</code></pre>
<p>위 쿼리에서 <code>last_name=&#39;Action&#39;</code> 조건은 <code>ix_lastname_firstname</code> 인덱스를 레인지 스캔으로 사용할 수 있으나, <code>first_name LIKE &#39;%sal&#39;</code> 조건은 인덱스 레인지 스캔으로는 검색해야 할 인덱스의 범위를 좁힐 수 없어, 해당 조건은 데이터를 모두 읽은 후 필터링 조건으로만 사용된다.</p>
<p>실행 계획을 확인해보면, <code>Extra</code> 칼럼에 &quot;Using Where&quot;가 표시된 것을 확인할 수 있으며, 이는 InnoDB 스토리지 엔진이 읽어서 반환해준 레코드가 인덱스를 사용할 수 없는 <code>WHERE</code> 조건에 일치하는지 검사하는 과정을 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/986b6677-076a-413d-b4e4-374355ec21f9/image.png" alt=""></p>
<p>다음 이미지는 인덱스 컨디션 푸시다운이 작동하지 않을 때의 처리 과정을 나타내고 있다. 여기서 중요한 점은, <code>last_name=&#39;Action&#39;</code> 조건으로 인덱스 레인지 스캔을 하고 테이블의 레코드를 잃은 뒤, <code>first_name LIKE &#39;%sal&#39;</code>  조건으로 필터링 하는데, 테이블을 읽어 3건의 레코드를 읽었지만 일치하는 것은 단 1건이라는 것으로, 불필요한 레코드 읽기가 발생하게 된다는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/203bbf91-e74e-4954-b4c6-1e091f4cb1a3/image.png" alt=""></p>
<p><code>ix_lastname_firstname</code> 인덱스의 <code>first_name</code> 칼럼을 이용하지 않고 다시 테이블의 레코드를 읽고 있는데, 인덱스를 비교하는 작업은 InnoDB 스토리지 엔진이 수행하지만 테이블의 레코드에서 <code>first_name</code>을 비교하는 작업은 MySQL 엔진이 수행하는 작업이다. </p>
<p>MySQL 5.0 버전까지는 인덱스를 범위 제한 조건으로 사용하지 못하는 <code>first_name</code> 조건은 MySQL 엔진이 스토리지 엔진으로 아예 전달해주지 않아 불필요한 테이블 읽기 작업이 발생한 것이다.</p>
<p>MySQL 5.6 버전부터는 인덱스를 범위 제한 조건으로 사용하지 못하더라도 인덱스에 포함된 칼럼의 조건이 있다면 모두 같이 모아서 스토리지 엔진으로 전달할 수 있게 핸들러 API가 개선되어, 인덱스를 이용해 최대한 필터링까지 완료해 꼭 필요한 레코드에 대해서만 테이블 읽기를 수행할 수 있게 되었다.</p>
<p>다음은 옵티마이저 옵션을 원래대로 되돌린 후의 실행 계획 및 인덱스 컨디션 푸시다운이 적용된 쿼리 처리 과정이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/25debeb0-3b68-4fb5-9287-7e1f05e30ede/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/6200abc0-f180-47f0-b927-6b9667ea3b5b/image.png" alt=""></p>
<h4 id="인덱스-확장-use_index_extensions">인덱스 확장 (use_index_extensions)</h4>
<p><code>use_index_extension</code> 옵티마이저 옵션은 InnoDB 스토리지 엔진을 사용하는 테이블에서 세컨더리 인덱스에 자동으로 추가된 프라이머리 키를 활용할 수 있게 할지를 결정하는 옵션이다.</p>
<p>다음과 같이 프라이머리 키와 세컨더리 인덱스를 가진 테이블을 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; CREATE TABLE dept_emp (
        emp_no INT NOT NULL,
        dept_no CHAR(4) NOT NULL,
        from_date DATE NOT NULL,
        to_date DATE NOT NULL,
        PRIMARY KEY (dept_no, emp_no),
        KEY ix_fromdate (from_date)
    ) ENGINE=InnoDB;</code></pre>
<p><code>dept_emp</code> 테이블의 세컨더리 인덱스인 <code>ix_fromdate</code>는 프라이머리 키인 <code>dept_no</code>와 <code>emp_no</code> 칼럼을 프라이머리 키에 명시된 순서대로 포함하여, 최종적으로는 <code>(from_date, dept_no, emp_no)</code> 조합으로 인덱스를 생성한 것과 흡사하게 작동한다.</p>
<p>다음은 두 개의 쿼리 및 각각의 실행 계획을 보여준다.</p>
<pre><code class="language-sql">mysql&gt; SELECT COUNT(*) FROM dept_emp 
        WHERE from_date=&#39;1987-07-25&#39; AND dept_no=&#39;d001&#39;;

mysql&gt; SELECT COUNT(*) FROM dept_emp 
        WHERE from_date=&#39;1987-07-25&#39;;</code></pre>
<ul>
<li><p><code>dept_no=&#39;d001&#39;</code> 조건을 포함한 경우 실행 계획
<img src="https://velog.velcdn.com/images/youhyuk_/post/85405876-db2e-4329-b8f7-89dec2ab65d3/image.png" alt=""></p>
</li>
<li><p><code>dept_no=&#39;d001&#39;</code> 조건을 포함하지 않는 경우 실행 계획
<img src="https://velog.velcdn.com/images/youhyuk_/post/ee3330a7-37a7-4f4f-b01e-e67319026c62/image.png" alt=""></p>
</li>
</ul>
<p>실행 계획의 <code>key_len</code> 칼럼은 쿼리가 인덱스를 구성하는 칼럼 중에서 어느 부분까지 사용했는지를 바이트 수로 보여준다. <code>dept_no</code> 칼럼의 조건을 포함하는 경우, <code>from_date</code>(3바이트), <code>dept_no</code>(16바이트) 까지 사용했으며, 조건이 제거된 경우 <code>from_date</code>(3바이트)만을 사용한 것을 확인할 수 있다.</p>
<p>또한 정렬 작업도 인덱스를 활용해서 처리되는 장점도 존재한다. 아래 쿼리의 실행 계획에서 <code>Extra</code> 칼럼에 &quot;Using Filesort&quot;가 표시되지 않았다는 것은 별도의 정렬 작업 없이 인덱스 순서대로 레코드를 읽기만 하여 <code>ORDER BY dept_no</code>를 만족했다는 것을 의미한다.</p>
<pre><code class="language-sql">mysql&gt; SELECT * FROM dept_emp 
        WHERE from_date=&#39;1987-07-25&#39; ORDER BY dept_no;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/ff483c7e-5eb4-4972-b9ed-bb6d350fdf29/image.png" alt=""></p>
<h4 id="인덱스-머지-index_merge">인덱스 머지 (index_merge)</h4>
<p><strong>인덱스 머지</strong> 실행 계획을 사용하면 하나의 테이블에 대해 2개 이상의 인덱스를 이용해 쿼리를 처리한다. 쿼리의 사용된 각각의 <code>WHERE</code> 조건이 서로 다른 인덱스를 사용할 수 있고, 그 조건을 만족하는 레코드 건수가 많을 것으로 예상될 때 MySQL 서버는 인덱스 머지 실행 계획을 실행한다.</p>
<h4 id="인덱스-머지---교집합-index_merge_intersection">인덱스 머지 - 교집합 (index_merge_intersection)</h4>
<p>다음 쿼리의 2개의 <code>WHERE</code> 조건은 모두 각각의 인덱스(<code>ix_firstname</code>, <code>PRIMARY</code>)를 가지고 있어, 어떤 조건을 사용하더라도 인덱스를 사용할 수 있다. 그에 따라 옵티마이저는 두 키를 모두 사용해 쿼리를 처리하기로 결정한다. </p>
<p>실행 계획의 <code>Extra</code> 칼럼에 &quot;Using intersect&quot;는 여러 개의 인덱스를 각각 검색해 그 결과의 교집합만을 반환했음을 의미한다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN SELECT *
        FROM employees
        WHERE first_name=&#39;Georgi&#39; AND emp_no BETWEEN 10000 AND 20000;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/e417e31e-c670-4ec2-96b9-7ab8eeb0a4d5/image.png" alt=""></p>
<p>한 칼럼의 조건만으로도 충분히 효율적으로 쿼리를 처리할 수 있다면 옵티마이저는 2개의 인덱스를 모두 사용하지 않았을 것이며, 이는 각각의 조건에 일치하는 레코드 건수를 예측해 본 결과, 두 조건 모두 상대적으로 많은 레코드를 가져와야 한다는 것을 알게 된 것이다.</p>
<h4 id="인덱스-머지---합집합-index_merge_union">인덱스 머지 - 합집합 (index_merge_union)</h4>
<p>인덱스 머지의 &quot;Using union&quot;은 <code>WHERE</code> 절에 사용된 2개 이상의 조건이 각각의 인덱스를 사용하되 <code>OR</code> 연산자로 연결된 경우에 사용되는 최적화로, 두 인덱스의 검색 결과의 합집합을 반환한다.</p>
<pre><code class="language-sql">mysql&gt; SELECT *
        FROM employees
        WHERE first_name=&#39;Matt&#39; OR hire_date=&#39;1987-03-31&#39;;</code></pre>
<p>위 쿼리는 2개의 조건이 <code>OR</code>로 연결돼 있으며, 각 조건의 칼럼에 해당하는 인덱스(<code>ix_firstname</code>, <code>ix_hiredate</code>)가 존재하여 각각의 인덱스를 사용할 수 있다. 이 쿼리의 실행 계획은 다음과 같이 &quot;Using union&quot; 최적화를 사용한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/bf8ec0e7-a731-4eea-9f44-a5a56150e091/image.png" alt=""></p>
<p>예제의 쿼리에서 두 개의 조건에 모두 해당하는 경우가 존재할 수 있는데, 해당 쿼리의 결과는 두 번 출력되지 않는다. </p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/fe9c36c2-08d8-489d-8c22-31a5d993388f/image.png" alt=""></p>
<p>이는 MySQL 서버는 두 조건의 검색 결과가 프라이머리 키(<code>emp_no</code>)로 정렬되어 있음을 알고 있어, 두 집합에서 하나씩 가져와 서로 비교하며 프라이머리 키인 <code>emp_no</code> 칼럼의 값이 중복된 레코드들을 정렬 없이 걸러낼 수 있다. </p>
<p>이렇게 정렬된 두 집합의 결과를 하나씩 가져와 중복 제거를 수행할 때 사용된 알고리즘은 <strong>우선순위 큐</strong>라고 한다.</p>
<h4 id="인덱스-머지---정렬-후-합집합-index_merge_sort_union">인덱스 머지 - 정렬 후 합집합 (index_merge_sort_union)</h4>
<p>인덱스 머지 작업을 하는 도중에 결과의 정렬이 필요한 경우 인덱스 머지 최적화의 &#39;Sort Unoion&#39; 알고리즘을 사용한다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM employees
        WHERE first_name = &#39;Matt&#39;
            OR hire_date BETWEEN &#39;1987-03-01&#39; AND &#39;1987-03-31&#39;;</code></pre>
<p>위 예제 쿼리를 다음과 같이 2개의 쿼리로 분리해서 생각해보자.</p>
<pre><code class="language-sql">mysql&gt; SELECT * FROM employees WHERE first_name=&#39;Matt&#39;;

mysql&gt; SELECT * FROM employees
        WHERE hire_date BETWEEN &#39;1987-03-01&#39; AND &#39;1987-03-31&#39;;</code></pre>
<p>첫 번째 쿼리의 경우 <code>emp_no</code>로 정렬되어 출력되지만, 두 번째 쿼리의 결과는 정렬돼있지 않아, 중복을 제거하기 위해 우선순위 큐를 사용하는 것이 불가능하다.</p>
<p>따라서 MySQL 서버는 중복을 제거하기 위해 각 집합을 <code>emp_no</code> 칼럼으로 정렬한 다음 중복 제거를 수행한다. 실행 계획은 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/58dfba03-b864-4792-91b3-c8d21dab490b/image.png" alt=""></p>
<h4 id="세미-조인-semijoin">세미 조인 (semijoin)</h4>
<p><strong>세미 조인(Semi-Join)</strong>이란 다른 테이블과 실제 조인을 수행하지는 않고, 다른 테이블에 조건에 일치하는 레코드의 유무만을 체크하는 형태의 쿼리이다.</p>
<p>MySQL 서버 8.0 버전부터는 세미 조인 쿼리의 성능을 개선하기 위한 다음과 같은 세미 조인 최적화 전략이 있다.</p>
<ul>
<li>Table Pull-out</li>
<li>Duplicate Weed-out</li>
<li>First Match</li>
<li>Loose scan</li>
<li>Materialization</li>
</ul>
<p><code>optimizer_switch</code> 시스템 변수의 <code>semijoin</code> 옵션은 <code>firstmatch</code>, <code>loosescan</code>, <code>materialization</code> 옵션을 한 번에 설정할 때 사용한다.</p>
<h4 id="테이블-풀-아웃-table-pull-out">테이블 풀-아웃 (Table Pull-out)</h4>
<p><strong>Table pullout</strong> 최적화는 세미 조인의 서브쿼리에 사용된 테이블을 아우터 쿼리로 끄집어낸 후에 쿼리를 조인 쿼리로 재작성하는 형태의 최적화다.</p>
<p>다음 예제 쿼리는 <code>IN (subquery)</code> 형태의 세미 조인이 사용된 쿼리이다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN
        SELECT * FROM employees e
        WHERE e.emp_no IN 
            (SELECT de.emp_no FROM dept_emp de WHERE de.dept_no=&#39;d009&#39;);</code></pre>
<p>MySQL 8.0 버전에서 실행 계획은 다음과 같다. 여기서 <code>dept_emp</code> 테이블과 <code>employees</code> 테이블이 순서대로 표시돼 있으며, id 칼럼의 값이 모두 1이다. 이는 두 테이블이 서브쿼리 형태가 아닌 조인으로 처리됐음을 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/a18ecaa2-8e1f-4f8c-8fae-b70b9908092d/image.png" alt=""></p>
<p>Table pullout 최적화의 몇 가지 제한 사항과 특성은 아래와 같으니 유의하도록 하자.</p>
<ul>
<li>세미 조인 서브쿼리에서만 사용 가능하다.</li>
<li>서브쿼리 부분이 <code>UNIQUE</code> 인덱스나 프라이머리 키 룩업으로 결과가 1건인 경우에만 사용 가능하다.</li>
<li>Table pullout이 적용된다고 하더라도 기존 쿼리에서 가능했던 최적화 방법이 사용 불가능한 것은 아니므로 MySQL에서는 가능하다면 Table pullout 최적화를 최대한 이용한다.</li>
<li>서브쿼리의 모든 테이블이 아우터 쿼리로 끄집어 낼 수 있다면 서브쿼리 자체는 없어진다.</li>
<li>MySQL에서 &quot;최대한 서브쿼리를 조인으로 풀어서 사용해라&quot;라는 튜닝 가이드를 그대로 실행하는 것이다.</li>
</ul>
<h4 id="퍼스트-매치-firstmatch">퍼스트 매치 (firstmatch)</h4>
<p><strong>First Match</strong> 최적화 전략은 <code>IN (subquery)</code> 형태의 세미 조인을 <code>EXISTS (subquery)</code> 형태로 튜닝한 것과 비슷한 방법으로 실행된다.</p>
<p>다음은 예제 쿼리 및 실행 계획이다.</p>
<pre><code class="language-sql">mysql&gt; EXPLAIN SELECT *
        FROM employees e WHERE e.first_name=&#39;Matt&#39;
            AND e.emp_no IN (
                SELECT t.emp_no FROM titles t
                WHERE t.from_date BETWEEN &#39;1995-01-01&#39; 
                    AND &#39;1995-01-30&#39;
            );</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/a0a0ff86-f751-4699-87c8-df5665046810/image.png" alt=""></p>
<p>실행 계획의 <code>id</code> 칼럼 값이 &quot;1&quot;로 표시된 것을 통해 <code>titles</code> 테이블이 서브쿼리 패턴이 아닌 조인으로 처리됐음을 알 수 있다. <code>Extar</code> 칼럼의 &quot;FirstMatch(e)&quot; 문구는 <code>employees</code> 테이블의 레코드에 대해 <code>titles</code> 테이블에 일치하는 레코드 1건만 찾으면 더이상 <code>titles</code> 테이블 검색을 하지 않는다는 것을 의미한다.</p>
<p>실제 의미론적으로는 <code>EXISTS (subquery)</code>와 동일하게 처리된 것이지만, 서브쿼리가 아닌 조인으로 출어서 일치하는 첫번째 레코드만 검색하는 최적화를 실행한 것이다. 다음은 FirstMatch 최적화를 사용해 쿼리가 처리된 방식을 이미지로 표현한 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/5d113012-e6a1-40c7-8e6a-7252abccfa0e/image.png" alt=""></p>
<p>First Match 최적화는 MySQL 5.5의 최적화 방법인 IN-to_EXISTS 변환과 거의 비슷한 처리 로직을 수행하나, 다음과 같은 추가적인 장점이 있다.</p>
<ul>
<li><p>가끔은 여러 테이블이 조인되는 경우 원래 쿼리에는 없던 동등 조건을 옵티마이저가 자동으로 추가하는 형태의 최적화가 실행되기도 한다. 기존의 IN-to-EXISTS 최적화에서는 이러한 동등 조건 전파가 서브쿼리 내에서만 가능했지만 FirstMatch에서는 조인 형태로 처리됨으로 아우터 쿼리의 테이블까지 전파될 수 있다. 최종적으로 더 많은 조건이 주어지는 것이므로 더 나은 실행 계획을 수립할 수 있다.</p>
</li>
<li><p>IN-to-EXISTS 변환 최적화 전략에서는 아무런 조건 없이 변환이 가능한 경우 무조건 그 최적화를 수행했으나, FirstMatch 최적화는 서브쿼리의 모든 테이블에 대해 최적화를 수행할지 일부 테이블만 수행할지 선택할 수 있다.</p>
</li>
</ul>
<p>다음은 FirtsMatch 최적화의 몇 가지 제한 사항과 특성이다.</p>
<ul>
<li>서브쿼리에서 하나의 레코드만 검색되면 더이상 검색을 멈추는 단축 실행 경로이기 때문에 서브쿼리는 그 서브쿼리가 참조하는 모든 아우터 테이블이 먼저 조회딘 이후에 실행된다.</li>
<li>실행 계획의 <code>Extra</code> 칼럼에는 &quot;FirstMatch(table-N)&quot; 문구가 표시된다.</li>
<li>상관 서브쿼리에서도 사용될 수 있다.</li>
<li><code>GROUP BY</code>나 집합 함수가 사용된 서브쿼리 최적화에는 사용될 수 없다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 09. 옵티마이저와 힌트 (1)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EC%99%80-%ED%9E%8C%ED%8A%B8-1</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-09.-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EC%99%80-%ED%9E%8C%ED%8A%B8-1</guid>
            <pubDate>Mon, 18 May 2026 07:57:37 GMT</pubDate>
            <description><![CDATA[<h1 id="91-개요">9.1 개요</h1>
<hr>
<h2 id="911-쿼리-실행-절차">9.1.1 쿼리 실행 절차</h2>
<p><strong>MySQL 서버 쿼리 실행 과정</strong></p>
<ol>
<li><p>사용자로부터 요청된 SQL 문장을 잘게 쪼개서 MySQL 서버가 이해할 수 있는 수준으로 분리(파스 트리)한다.</p>
</li>
<li><p>SQL의 파싱 정보(파스 트리)를 확인하면서 어떤 테이블부터 읽고 어떤 인덱스를 이용해 테이블을 읽을지 선택한다.</p>
</li>
<li><p>두 번째 단계에서 결정된 테이블의 읽기 순서나 선택된 인덱스를 이용해 스토리지 엔진으로부터 데이터를 가져온다.</p>
</li>
</ol>
<h2 id="912-옵티마이저의-종류">9.1.2 옵티마이저의 종류</h2>
<p><strong>옵티마이저</strong>는 데이터베이스 서버에서 두뇌와 같은 역할을 담당하며, <strong>비용 기반 최적화 방법(Cost-based optimizer, CBO)</strong>과 <strong>규칙 기반 최적화 방법(Rule-based optimizer, RBO)</strong> 두 가지로 크게 나눌 수 있다.</p>
<p><strong>규칙 기반 최적화</strong>는 대상 테이블의 레코드 건수나 선택도 등을 고려하지 않고 옵티마이저에 내장된 우선순위에 따라 실행 계획을 수립하는 방식으로, 같은 쿼리에 대해서 항상 거의 같은 실행 방법을 만들어 낸다. 하지만 사용자의 데이터 분포도가 매우 다양하기 때문에 오래전부터 거의 사용되지 않는다.</p>
<p><strong>비용 기반 최적화</strong>는 쿼리를 처리하기 위한 여러 가지 가능한 방법을 만들고, 각 단위 작업의 비용(부하) 정보와 대상 테이블의 예측된 통계 정보를 이용해 실행 계획별 비용을 산출한 뒤, 비용이 최소로 소요되는 처리 방식을 선택해 쿼리를 실행한다.</p>
<h1 id="92-데이터-처리">9.2 데이터 처리</h1>
<hr>
<h2 id="921-풀-테이블-스캔과-풀-인덱스-스캔">9.2.1 풀 테이블 스캔과 풀 인덱스 스캔</h2>
<p><strong>풀 테이블 스캔</strong>은 인덱스를 사용하지 않고 테이블의 데이터를 처음부터 끝까지 읽어서 요청된 작업을 처리하는 작업으로, MySQL 옵티마이저는 다음과 같은 조건이 일치할 때 주로 <strong>풀 테이블 스캔</strong>을 선택한다.</p>
<ul>
<li><p>테이블의 레코드 건수가 너무 작아서 인덱스를 통해 읽는 것보다 풀 테이블 스캔을 하는 편이 더 빠른 경우(일반적으로 테이블이 페이지 1개로 구성된 경우)</p>
</li>
<li><p><code>WHERE</code> 절이나 <code>ON</code> 절에 인덱스를 이용할 수 있는 적절한 조건이 없는 경우</p>
</li>
<li><p>인덱스 레인지 스캔을 사용할 수 있는 쿼리라고 하더라도 옵티마이저가 판단한 조건 일치 레코드 건수가 너무 많은 경우(인덱스의 B-Tree를 샘플링해서 조사한 통계 정보 기준)</p>
</li>
</ul>
<p>MySQL은 테이블 스캔을 실행할 때 한꺼번에 몇 개씩 페이지를 읽어올지 설정하는 시스템 변수는 없으나, InnoDB의 경우 특정 테이블의 연속된 데이터 페이지가 읽히면 백그라운드 스레드에 의해 <strong>리드 어헤드(Read ahead)</strong> 작업이 자동으로 시작한다. </p>
<p><strong>리드 어헤드</strong>란 어떤 영역의 데이터가 앞으로 필요해지리라는 것을 예측해 요청이 오기 전 미리 디스크에서 읽어 InnoB 버퍼 풀에 가져다 두는 것을 의미한다. 따라서 포그라운드 스레드는 미리 버퍼 풀에 준비된 데이터를 가져다 사용하기만 하면 되므로 쿼리가 상당히 빨리 처리된다.</p>
<p><strong>풀 인덱스 스캔</strong>은 인덱스를 처음부터 끝까지 스캔하는 것을 의미하며, <strong>리드 어헤드</strong>는 풀 테이블 스캔에서만 사용되는 것이 아닌 풀 인덱스 스캔에서도 동일하게 사용된다.</p>
<p>MySQL 서버는 단순히 레코드의 건수만 필요로 하는 쿼리라면 용량이 작은 인덱스를 선택하는 것이 디스크 읽기 횟수를 줄일 수 있어 <strong>풀 인덱스 스캔</strong>을 선택할 가능성이 높지만, 레코드에만 있는 칼럼이 필요한 쿼리의 경우에는 <strong>풀 테이블 스캔</strong>을 한다.</p>
<h2 id="922-병렬-처리">9.2.2 병렬 처리</h2>
<p>MySQL 8.0에서는 <code>innodb_parallel_read_threads</code>라는 시스템 변수를 이용해 하나의 쿼리를 최대 몇 개의 스레드를 이용해서 처리할지를 변경할 수 있다. 아직 쿼리를 여러 개의 스레드를 이용해 병렬로 처리하게 하는 힌트나 옵션은 없으며, 아무런 <code>WHERE</code> 조건 없이 단순히 테이블의 전체 건수를 가져오는 쿼리만 병렬로 처리할 수 있다.</p>
<pre><code class="language-sql">mysql&gt; SET SESSION innodb_parallel_read_threads=1;
mysql&gt; SELECT COUNT(*) FROM salaries;
1 row in set (0.32 sec)

mysql&gt; SET SESSION innodb_parallel_read_threads=4;
mysql&gt; SELECT COUNT(*) FROM salaries;
1 row in set (0.18 sec)</code></pre>
<h2 id="923-order-by-처리using-filesort">9.2.3 <code>ORDER BY</code> 처리(Using filesort)</h2>
<p>대부분의 <code>SELECT</code> 쿼리에서 정렬은 필수적으로 사용되며, 정렬을 처리하는 방법은 인덱스를 이용하는 방법과 쿼리가 실행될 때 <strong>Filesort</strong>라는 별도의 처리를 이용하는 방법으로 나눌 수 있다.</p>
<p>Filesort의 장점은 인덱스를 생성하지 않아도 되므로 인덱스를 이용할 때의 단점이 장점이 되며, 정렬해야 할 레코드가 많지 않으면 메모리에서 Filesort가 처리되므로 충분히 빠르다. 그러나 정렬 작업이 쿼리 실행 시 처리되므로 레코드 대상 건수가 많아질수록 쿼리의 응답 속도가 다소 느리다는 단점이 있다.</p>
<p>MySQL 서버에서 인덱스를 이용하지 않고 별도의 정렬 처리를 수행했는지는 실행 계획의 <code>Extra</code> 칼럼에 &quot;Using filesort&quot; 메시지가 표시되는지 여부로 판단할 수 있다.</p>
<h3 id="소트-버퍼">소트 버퍼</h3>
<p>MySQL은 정렬을 수행하기 위해 별도의 메모리 공간을 할당받아서 사용하는데, 이 메모리 공간을 <strong>소트 버퍼(Sort buffer)</strong>라고 한다. 소트 버퍼는 정렬이 필요한 경우에만 할당되며, 버퍼의 크기는 정렬해야 할 레코드의 크기에 따라 가변적으로 증가하지만 최대 사용 가능한 소트 버퍼의 공간은 <code>sort_buffer_size</code> 시스템 변수로 설정할 수 있다. 소트 버퍼를 위한 메모리 공간은 쿼리의 실행이 완료되면 즉시 시스템으로 반납된다.</p>
<p>정렬해야 할 레코드의 건수가 소트 버퍼로 할당된 공간보다 큰 경우, MySQL은 레코드를 여러 조각으로 나눠서 메모리의 소트 버퍼에서 정렬을 수행하고, 그 결과를 임시로 디스크에 기록해 둔다. 그리고 다음 레코드를 가져와 다시 정렬해 반복적으로 디스크에 임시 저장한다. 이처럼 각 버퍼 크기만큼 정렬된 레코드를 다시 병합하면서 정렬을 수행하는 <strong>멀티 머지(Multi-merge)</strong> 단계를 거친다.</p>
<p>이 작업들은 모두 디스크의 쓰기와 읽기를 유발하며, 레코드 건수가 많을수록 횟수가 많아진다. 소트 버퍼를 크게 설정하면 디스크를 사용하지 않아 더 빨라질 것으로 생각할 수 있지만, 실제로는 큰 차이를 보이지 않는다. 오히려 리눅스 계열 운영체제에서는 큰 메모리 공간 할당 때문에 성능이 훨씬 떨어질 수도 있다.</p>
<h3 id="정렬-알고리즘">정렬 알고리즘</h3>
<p>레코드를 정렬할 때 레코드 전체를 소트 버퍼에 담을지 또는 정렬 기준 칼럼만 소트 버퍼에 담을지에 따라 저자는 <strong>싱글 패스(Single-pass)</strong>와 <strong>투 패스(Two-pass)</strong> 2가지 정렬 모드로 분류한다.</p>
<h4 id="싱글-패스-정렬-방식">싱글 패스 정렬 방식</h4>
<p><strong>싱글 패스 정렬 방식</strong>은 소트 버퍼에 정렬 기준 칼럼을 포함해 <code>SELECT</code> 대상이 되는 칼럼 전부를 담아서 정렬을 수행하는 정렬 방식이다.</p>
<pre><code class="language-sql">mysql&gt; SELECT emp_no, first_name, last_name
        FROM employees 
        ORDER BY first_name;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/ce46d887-1175-4df5-a989-843ab2a3109b/image.png" alt=""></p>
<p>위 쿼리를 <code>first_name</code>으로 정렬해서 <code>emp_no</code>, <code>first_name</code>, <code>last_name</code>을 <code>SELECT</code>하는 쿼리를 <strong>싱글 패스 정렬 방식</strong>으로 처리하는 절차를 이미지로 나타낸 것으로, 처음 <code>employees</code> 테이블을 읽을 때 정렬에 필요하지 않은 <code>last_name</code> 칼럼까지 전부 읽어 소트 버퍼에 담고 정렬을 수행하고, 완료되면 정렬 버퍼의 내용을 그대로 클라이언트에게 넘겨준다.</p>
<h4 id="투-패스-정렬-방식">투 패스 정렬 방식</h4>
<p><strong>투 패스 정렬 방식</strong>은 정렬 대상 칼럼과 프라이머리 키 값만 소트 버퍼에 담아서 정렬을 수행하고, 정렬된 순서대로 다시 프라이머리 키로 테이블을 읽어서 <code>SELECT</code>할 칼럼을 가져오는 방식으로, <strong>싱글 패스 정렬 방식</strong> 이전부터 사용하던 방식이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/1a9a574f-15af-42f4-9bef-76ff3dfac604/image.png" alt=""></p>
<p>위 이미지는 동일한 쿼리를 <strong>투 패스 정렬 방식</strong>으로 정렬하는 과정으로 표현한 것으로, <code>last_name</code> 칼럼을 조회하기 위해 테이블을 다시 읽어야 한다는 점에서 큰 차이가 존재한다.</p>
<p><strong>투 패스 정렬 방식</strong>의 경우 테이블을 두 번 읽어야 하는 불합리함이 있지만, <strong>싱글 패스 정렬 방식</strong>에 비해 적은 소트 버퍼 공간을 사용하여 레코드를 정렬한다. 따라서 <strong>싱글 패스 정렬 방식</strong>은 정렬 대상 레코드의 크기나 건수가 작은 경우, <strong>투 패스 정렬 방식</strong>은 크기나 건수가 상당히 많은 경우 효율적이다.</p>
<p>최신 버전에서는 일반적으로 <strong>싱글 패스 정렬 방식</strong>을 주로 사용하나, 아래의 경우 <strong>투 패스 정렬 방식</strong>을 사용한다.</p>
<ul>
<li>레코드의 크기가 <code>max_lengtg_for_sort_data</code> 시스템 변수에 설정된 값보다 클 때</li>
<li>BLOB이나 TEXT 타입의 칼럼이 <code>SELECT</code> 대상에 포함될 때</li>
</ul>
<h3 id="정렬-처리-방법">정렬 처리 방법</h3>
<h4 id="인덱스를-이용한-정렬">인덱스를 이용한 정렬</h4>
<p><strong>인덱스를 이용한 정렬</strong>을 위해서는 반드시 <code>ORDER BY</code>에 명시된 칼럼이 제일 먼저 읽는 테이블(조인이 사용된 경우 드라이빙 테이블)에 속하고, <code>ORDER BY</code> 순서대로 생성된 인덱스가 있어야 한다. 또한 <code>WHERE</code>절에 첫 번째로 읽는 테이블의 칼럼에 대한 조건과 <code>ORDER BY</code>는 같은 인덱스를 사용할 수 있어야 한다.</p>
<p>B-Tree 계열의 인덱스가 아닌 경우 사용할 수 없으며, 여러 테이블이 조인되는 경우에는 네스티드-루프(Nested-loop) 방식의 조인에서만 사용할 수 있다.</p>
<p>인덱스를 이용해 정렬이 처리되는 경우에는 B-Tree 인덱스가 키 값으로 정렬돼 있기 때문에 순서대로 읽기만 하면 된다. 또한 조인이 네스티드-루프 방식으로 실행되기 때문에 드라이빙 테이블의 인덱스 읽기 순서가 흐트러지지 않는다. 하지만 조인이 사용된 커리의 실행 계획에 조인 버퍼가 사용되면 순서가 흐트러질 수 있기 때문에 조심해야 한다.</p>
<p>다음 예제의 경우 <code>ORDER BY</code>의 여부와 상관없이 인덱스를 레인지 스캔해서 나온 결과는 같은 순서로 출력되는데, 이는 <code>employees</code> 테이블의 프라이머리 키를 읽은 뒤 <code>salaries</code> 테이블을 조인했기 때문이다.</p>
<pre><code class="language-sql">mysql&gt; SELECT *
        FROM employees, salaries s
        WHERE s.emp_no=e.emp_no
            AND e.emp_no BETWEEN 100002 AND 10020
        ORDER BY e.emp_no;


mysql&gt; SELECT *
        FROM employees, salaries s
        WHERE s.emp_no=e.emp_no
            AND e.emp_no BETWEEN 100002 AND 10020;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/3629b195-ce5c-4f21-93f2-25aabdc51d37/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><code>ORDER BY</code> 절을 넣지 않아도 자동 정렬되므로 이를 제거해서 쿼리를 작성하기도 하지만, <code>ORDER BY</code> 절이 쿼리에 있어도 작업량은 늘어나지 않으며 오히려 쿼리 계획이 변경되는 경우 쿼리 결과를 기대했던 순서로 가져오지 못하는 상황이 발생할 수 있다.</p>
<h4 id="조인의-드라이빙-테이블만-정렬">조인의 드라이빙 테이블만 정렬</h4>
<p>일반적인 조인은 결과 레코드 건수가 몇 배로 불어나며 레코드의 크기도 늘어나므로, 조인 실행 전 드라이빙 테이블의 레코드를 먼저 정렬한 후 조인을 실행하는 것이 정렬의 차선책이 된다. 이 방법으로 정렬이 처리되려면 조인에서 드라이빙 테이블의 칼럼만으로 <code>ORDER BY</code> 절을 작성해야 한다.</p>
<pre><code class="language-sql">mysql&gt; SELECT *
        FROM employees, salaries s
        WHERE s.emp_no=e.emp_no
            AND e.emp_no BETWEEN 100002 AND 10010;
           ORDER BY e.last_name;</code></pre>
<p><code>WHERE</code> 절의 조건을 통해 옵티마이저는 <code>employees</code> 테이블을 드라이빙 테이블로 선택한다. <code>ORDER BY</code>의 칼럼은 프라이머리 키와 연관이 없어 인덱스를 이용한 정렬이 불가능하지만, 드라이빙 테이블에 포함된 칼럼이다. 따라서 옵티마이저는 드라이빙 테이블만 검색해서 정렬을 먼저 수행하고, 그 결과와 <code>salaries</code> 테이블을 조인한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/07eef74a-9059-45e5-b22b-75a60a6dd45b/image.png" alt=""></p>
<h4 id="임시-테이블을-이용한-정렬">임시 테이블을 이용한 정렬</h4>
<p>이 방법은 조인의 결과를 임시 테이블에 저장하고, 그 결과를 다시 정렬하는 과정을 거치므로 가운데 정렬해야 할 레코드 건수가 가장 많기 때문에 가장 느린 정렬 방법이다.</p>
<pre><code class="language-sql">mysql&gt; SELECT *
        FROM employees, salaries s
        WHERE s.emp_no=e.emp_no
            AND e.emp_no BETWEEN 100002 AND 10010;
           ORDER BY s.salary;</code></pre>
<p>이번 쿼리의 <code>ORDER BY</code>절의 정렬 기준 칼럼이 드리븐 테이블(<code>salaries</code>)에 있는 칼럼이므로, 정렬이 수행되기 전 <code>salaries</code> 테이블을 읽어야만 해 조인된 데이터를 가지고 정렬해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/3d91e090-7cef-49eb-a23d-753b9c7a9dfb/image.png" alt=""></p>
<h4 id="정렬-처리-방법의-성능-비교">정렬 처리 방법의 성능 비교</h4>
<p><strong>두 가지 쿼리 처리 방법</strong></p>
<ul>
<li><p>스트리밍 처리 방식
서버 쪽에서 처리할 데이터가 얼마인지에 관계 없이 조건에 일치하는 레코드가 검색될 때마다 클라이언트로 전송해주는 방식이다. 이는 클라이언트가 첫 번째 레코드를 전달받는 응답 시간이 굉장히 짧아지며, <code>LIMIT</code> 조건을 추가하면 마지막 레코드를 가져오는 시간도 상당히 줄일 수 있다. <code>ORDER BY</code>나 <code>GROUP BY</code> 같은 처리는 <code>WHERE</code> 조건에 일치하는 모든 레코드를 가져온 후, 정렬하거나 그루핑해서 차례대로 보내야하기 때문에 스트리밍 방식을 사용할 수 없다.</p>
</li>
<li><p>버퍼링 방식
처리되는 쿼리의 결과를 모아서 가공한 뒤 모든 결과를 가져오기까지 기다리는 방식이다. 기다리는 동안 클라이언트는 아무것도 하지 않고 기다려야 하므로 응답 속도가 느려지며, <code>LIMIT</code> 조건이 성능 향상에 별로 도움을 주지 않는다.</p>
</li>
</ul>
<p>인덱스를 사용한 정렬 방식만 스트리밍 형태의 처리이며, 나머지는 버퍼링된 후에 정렬된다. 즉, <code>LIMIT</code> 조건의 성능 향상은 인덱스를 사용한 정렬 방식에서만 성능 향상을 보이므로 가능하다면 인덱스를 사용한 정렬로 유도하고, 최소한 드라이빙 테이블만 정렬해도 되는 수준은 유지하는 것이 좋다.</p>
<h3 id="924-group-by-처리">9.2.4 <code>GROUP BY</code> 처리</h3>
<h4 id="인덱스-스캔을-이용하는-group-by타이트-인덱스-스캔">인덱스 스캔을 이용하는 <code>GROUP BY</code>(타이트 인덱스 스캔)</h4>
<p>조인의 드라이빙 테이블에 속한 칼럼만 이용해 그루핑할 때 <code>GROUP BY</code> 칼럼으로 이미 인덱스가 있다면 그 인덱스를 차례대로 읽으면 서 그루핑 작업을 수행하고 그 결과로 조인을 처리한다.</p>
<h4 id="루스-인덱스-스캔을-이용하는-group-by">루스 인덱스 스캔을 이용하는 <code>GROUP BY</code></h4>
<p>MySQL의 루스 인덱스 스캔 방식은 단일 테이블에 대해 수행되는 <code>GROUP BY</code> 처리에만 사용할 수 있으며, 프리픽스 인덱스는 이를 사용할 수 없다. </p>
<h4 id="임시-테이블을-사용하는-group-by">임시 테이블을 사용하는 <code>GROUP BY</code></h4>
<p><code>GROUP BY</code>의 기준 칼럼이 인덱스를 전혀 사용하지 못할 때 임시 테이블을 사용한다.</p>
<h3 id="925-distinct-처리">9.2.5 <code>DISTINCT</code> 처리</h3>
<h4 id="select-distinct-"><code>SELECT DISTINCT ...</code></h4>
<p><code>SELECT DISTINCT</code> 형태의 쿼리 문장은 <code>GROUP BY</code>와 동일한 방식으로 처리되며, 특히 <code>ORDER BY</code>절이 없는 경우 정렬을 사용하지 않아 내부적으로 같은 작업을 수행한다.</p>
<p><code>DISTINCT</code>는 <code>SELECT</code>하는 레코드를 유니크하게 <code>SELECT</code>하는 것일 뿐, 특정 칼럼만 유니크하게 조회하는 것이 아니다. 즉, <code>SELECT</code> 절에 사용된 <code>DISTINCT</code> 키워드는 조회되는 모든 칼럼에 영향을 미친다.</p>
<h4 id="집합-함수와-함께-사용된-distinct">집합 함수와 함께 사용된 <code>DISTINCT</code></h4>
<p><code>COUNT()</code>, <code>MIN()</code>, <code>MAX()</code> 같은 집합 함수 내에서의 <code>DISTINCT</code>는 집합 함수의 인자로 전달된 칼럼값이 유니크한 것들을 가져온다. </p>
<p><code>DISTINCT</code> 처리를 위해 인덱스를 이용할 수 없는 경우에는 임시 테이블을 사용하게 되며, 인덱스된 칼럼에 대해 <code>DISTINCT</code> 처리를 수행하는 경우 인덱스 풀 스캔 혹은 레인지 스캔을 통해 처리한다.</p>
<blockquote>
<p>임시 테이블을 사용하는 경우에도 실행 계획에서는 &quot;Using temporary&quot;를 표시하지 않고 있으니 주의하자.</p>
</blockquote>
<h3 id="926-내부-임시-테이블-활용">9.2.6 내부 임시 테이블 활용</h3>
<h4 id="메모리-임시-테이블과-디스크-임시-테이블">메모리 임시 테이블과 디스크 임시 테이블</h4>
<p>MySQL 8.0 버전부터 메모리는 가변 길이 타입을 지원하는 TempTable, 디스크에 저장되는 임시 테이블은 트랜잭션 지원이 가능한 InnoDB 스토리지 엔진 또는 TempTable 스토리지 엔진의 MMAP 파일 버전을 사용한다. </p>
<h4 id="임시-테이블이-필요한-쿼리">임시 테이블이 필요한 쿼리</h4>
<p>다음과 같은 패턴의 쿼리는 MySQL 엔진에서 별도의 데이터 가공 작업을 필요로 하는 대표적인 내부 임시 테이블을 생성하는 케이스들이다.</p>
<ul>
<li><code>ORDER BY</code>와 <code>GROUP BY</code>에 명시된 칼럼이 다른 쿼리</li>
<li><code>ORDER BY</code>나 <code>GROUP BY</code>에 명시된 칼럼이 조인의 순서상 첫 번째 테이블이 아닌 쿼리</li>
<li><code>DISTINCT</code>나 <code>ORDER BY</code>가 동시에 쿼리에 존재하는 경우 또는 <code>DISTINCT</code>가 인덱스로 처리되지 못하는 쿼리</li>
<li><code>UNION</code>이나 <code>UNION DISTINCT</code>가 사용된 쿼리 (<code>select_type</code> 칼럼이 <code>UNION RESULT</code>인 경우)</li>
<li>쿼리의 실행 계획에서 <code>select_type</code>이 <code>DERIVED</code>인 쿼리</li>
</ul>
<p>위 5개의 패턴 중 아래 3개의 패턴은 실행 계획의 <code>Extra</code> 칼럼에서 임시 테이블 사용 여부를 확인하는 &quot;Using temporary&quot; 메시지가 표시되지 않는다.</p>
<p>첫 번째부터 네 번째까지의 쿼리 패턴은 유니크 인덱스를 가지는 내부 임시 테이블이 만들어지며, 마지막 쿼리 패턴은 유니크 인덱스가 없는 내부 임시 테이블이 생성된다.</p>
<h4 id="임시-테이블이-디스크에-생성되는-경우">임시 테이블이 디스크에 생성되는 경우</h4>
<p>내부 임시 테이블은 기본적으로 메모리 상에서 만들어지지만 다음과 같은 조건을 만족하면 메모리 임시 테이블을 사용할 수 없게 되어 디스크 기반의 임시 테이블을 사용한다.</p>
<ul>
<li><code>UNION</code>이나 <code>UNION ALL</code>에서 <code>SELECT</code>되는 칼럼 중에서 길이가 512바이트 이상의 크기의 칼럼이 있는 경우</li>
<li><code>GROUP BY</code>나 <code>DISTINCT</code> 칼럼에서 512 바이트 이상인 크기의 칼럼이 있는 경우</li>
<li>메모리 임시 테이블의 크기가 (MEMORY 스토리지 엔진) <code>tmp_table_size</code> 또는 <code>max_heap_table_size</code> 시스템 변수보다 크거나 (TempTable 스토리지 엔진) <code>temptable_max_ram</code> 시스템 변수 값보다 큰 경우</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 08. 인덱스 (4)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-4</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-4</guid>
            <pubDate>Tue, 12 May 2026 09:04:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="88-클러스터링-인덱스">8.8 클러스터링 인덱스</h2>
<h3 id="881-클러스터링-인덱스">8.8.1 클러스터링 인덱스</h3>
<p><strong>클러스터링 인덱스</strong>는 프라이머리 키 값이 비슷한 레코드끼리 묶어서 저장하는 것을 의미한다. 프라이머리 키 값에 의해 레코드의 저장 위치가 결정 및 변경되기 때문에 프라이머리 키의 의존도가 상당히 크며, 인덱스 알고리즘이라기보다 테이블 레코드 저장 방식이라고 볼 수 있어 <strong>클러스터링 테이블</strong>이라고도 한다. </p>
<p>클러스터링의 기준이 되는 프라이머리 키는 <strong>클러스터링 키</strong>라고도 표현한다. InnoDB와 같이 항상 클러스터링 인덱스로 저장되는 테이블은 프라이머리 키 기반의 검색이 매우 빠르며, 대신 레코드의 저장이나 프라이머리 키 변경이 상대적으로 느리다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/dc09aad7-29ab-4b3f-a5df-4fb39bb97b4f/image.png" alt=""></p>
<p>위 이미지는 클러스터링 테이블의 구조로 일반 B-Tree와 비슷하지만, 세컨더리 인덱스를 위한 B-Tree의 리프 노드와는 달리 클러스터링 인덱스의 리프 노드에는 레코드의 모든 칼럼이 같이 저장되어 있다. 즉, 클러스터링 테이블은 그 자체가 하나의 거대한 인덱스 구조로 관리되는 것이다.</p>
<p>기존 클러스터링 테이블에서 다음 쿼리와 같이 프라이머리 키를 변경하는 문장이 실행될 때 클러스터링 테이블의 데이터 레코드의 변화를 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; UPDATE tb_test SET emp_no=100002 WHERE emp_no=100007;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/0fc46e2d-5d29-4dce-bdd3-f2aea8882386/image.png" alt=""></p>
<p>3번 페이지에 저장되어 있던 <code>emp_no</code>가 100007인 레코드의 <code>emp_no</code>가 100002로 변경되며 2번 페이지로 이동한 것을 알 수 있다. 실제로 프라이머리 키의 값이 변경되는 경우는 거의 없지만, 클러스터링 테이블에서의 프라이머리 키 값의 중요성을 강조하고 작동 방식을 설명하기 위해 프라이머리 키 값의 변경 과정을 살펴보았다.</p>
<p>프라이머리 키가 없는 InnoDB 테이블의 경우 다음 우선순위대로 프라이머리 키를 대체할 칼럼을 선택한다.</p>
<ol>
<li>프라이머리 키가 있으면 기본적으로 프라이머리 키를 클러스터링 키로 선택</li>
<li><code>NOT NULL</code> 옵션의 유니크 인덱스 중에서 첫 번째 인덱스를 클러스터링 키로 선택</li>
<li>자동으로 유니크한 값을 가지도록 증가되는 칼럼을 추가한 후, 클러스터링 키로 선택</li>
</ol>
<h3 id="882-세컨더리-인덱스에-미치는-영향">8.8.2 세컨더리 인덱스에 미치는 영향</h3>
<p>세컨더리 인덱스가 실제 레코드가 저장된 주소를 가지고 있다면 클러스터링 키 값이 변경될 때마다 데이터 레코드의 주소가 변경되고 그때마다 해당 테이블의 모든 인덱스에 저장된 주솟값을 변경해야 할 것이다. 이런 오버헤드를 제거하기 위해 InnoDB 클러스터링 테이블의 모든 세컨더리 인덱스는 해당 레코드가 저장된 주소가 아니라 <strong>프라이머리 키 값</strong>을 저장하도록 구현돼 있다.</p>
<h3 id="883-클러스터링-인덱스의-장점과-단점">8.8.3 클러스터링 인덱스의 장점과 단점</h3>
<p><strong>장점</strong></p>
<ul>
<li><p>프라이머리 키(클러스터링 키)로 검색할 때 처리 성능이 매우 빠름(특히, 프라이머리 키를 범위 검색하는 경우 매우 빠름)</p>
</li>
<li><p>테이블의 모든 세컨더리 인덱스가 프라이머리 키를 가지고 있기 때문에 인덱스만으로 처리될 수 있는 경우가 많음</p>
</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li><p>테이블의 모든 세컨더리 인덱스가 클러스터링 키를 갖기 때문에 클러스터링 키 값의 크기가 클 경우 전체적으로 인덱스의 크기가 커짐</p>
</li>
<li><p>세컨더리 인덱스를 통해 검색할 때 프라이머리 키로 다시 한번 검색해야 하므로 처리 성능이 느림</p>
</li>
<li><p><code>INSERT</code>할 때 프라이머리 키에 의해 레코드의 저장 위치가 결정되기 때문에 처리 성능이 느림</p>
</li>
<li><p>프라이머리 키를 변경할 때 레코드를 <code>DELETE</code>하고 <code>INSERT</code>하는 작업이 필요하기 때문에 성능이 느림</p>
</li>
</ul>
<h3 id="884-클러스터링-테이블-사용-시-주의사항">8.8.4 클러스터링 테이블 사용 시 주의사항</h3>
<h4 id="클러스터링-인덱스-키의-크기">클러스터링 인덱스 키의 크기</h4>
<p>클러스터링 테이블의 경우 모든 세컨더리 인덱스가 프라이머리 키(클러스터링 키) 값을 포함하므로, 프라이머리 키의 크기가 커지면 세컨더리 인덱스도 자동으로 커진다. 또한 일반적으로 테이블에 세컨더리 인덱스가 4~5개 정도 생성된다는 것을 고려하면 세컨더리 인덱스 크기는 급격히 증가한다. 추가로 인덱스가 커질수록 같은 성능을 내기 위해 그만큼의 메모리가 더 필요해지므로 InnoDB 테이블의 프라이머리 키는 신중하게 선택해야 한다.</p>
<h4 id="프라이머리-키는-auto-increment보다는-업무적인-칼럼으로-생성가능한-경우">프라이머리 키는 <code>AUTO-INCREMENT</code>보다는 업무적인 칼럼으로 생성(가능한 경우)</h4>
<p>프라이머리 키로 검색하는 경우 클러스터링되지 않은 테이블에 비해 매우 빠르게 처리될 수 있다. 프라이머리 키는 중요한 역할을 하기 때문에 대부분 검색에서 빈번하게 사용된다. 설령 그 칼럼의 크기가 크더라도 업무적으로 해당 레코드를 대표할 수 있다면 그 칼럼을 프라이머리 키로 설정하는 것이 좋다.</p>
<h4 id="프라이머리-키는-반드시-명시할-것">프라이머리 키는 반드시 명시할 것</h4>
<p>InnoDB 테이블에서 프라이머리 키를 정의하지 않으면 InnoDB 스토리지 엔진이 내부적으로 일련번호 칼럼을 추가한다. 이렇게 추가된 칼럼은 사용자에게 보이지 않기 때문에 사용자가 접근할 수 없다. 이는 <code>AUTO_INCREMET</code> 칼럼을 생성하고 프라이머리 키로 설정하는 것과 같으므로 사용자가 사용할 수 있는 값(<code>AUTO_INCREMENT</code> 값)을 프라이머리 키로 설정하는 것이 좋다.</p>
<h4 id="auto-increment-칼럼을-인조-식별자로-사용할-경우"><code>AUTO-INCREMENT</code> 칼럼을 인조 식별자로 사용할 경우</h4>
<p>프라이머리 키의 크기가 길어도 세컨더리 인덱스가 필요치 않다면 그대로 프라이머리 키를 사용하는 것이 좋다. 세컨더리 인덱스도 필요하고 키의 크기도 길다면 <code>AUTO_INCREMENT</code> 칼럼을 추가하고, 이를 프라이머리 키로 설정하면 된다. 또한 로그 테이블과 같이 조회보다는 <code>INSERT</code> 위주의 테이블을 <code>AUTO_INCREMENT</code>를 이용한 인조 식별자를 프라이머리 키로 설정하는 것이 성능 향상에 도움이 된다.</p>
<blockquote>
<p>프라이머리 키를 대체하기 위해 인위적으로 추가된 프라이머리 키를 <strong>인조 식별자(Surrogate Key)</strong>라고 한다.</p>
</blockquote>
<h2 id="89-유니크-인덱스">8.9 유니크 인덱스</h2>
<h3 id="891-유니크-인덱스와-일반-세컨더리-인덱스의-비교">8.9.1 유니크 인덱스와 일반 세컨더리 인덱스의 비교</h3>
<h4 id="인덱스-읽기">인덱스 읽기</h4>
<p>많은 사람들이 유니크하지 않은 세컨더리 인덱스가 더 느리다고 생각한다. 하지만 유니크하지 않은 세컨더리 인덱스는 중복된 값이 허용되므로 읽어야 할 레코드가 많아서 느린 것이지, 인덱스 자체의 특성 때문에 느린 것이 아니다. 즉, 읽어야 할 레코드의 건수가 같다면 성능상의 차이는 미미하다.</p>
<h4 id="인덱스-쓰기">인덱스 쓰기</h4>
<p>새로운 레코드드가 <code>INSERT</code>되거나 인덱스 칼럼의 값이 변경되는 경우 인덱스 쓰기 작업이 필요하다. 유니크 인덱스의 키 값을 쓸 때는 중복을 체크하는 과정이 더 필요해 유니크하지 않은 세컨더리 인덱스보다 느리다. </p>
<p>MySQL에서는 유니크 인덱스에서 중복된 값을 체크할 때는 읽기 잠금을, 쓰기를 할 때는 쓰기 잠금을 사용하는데 이 과정에서 데드락이 아주 빈번히 발생한다. 또한 InnoDB 스토리지 엔진에는 인덱스 키의 저장을 버퍼링 하기 위해 체인지 버퍼를 사용하여 인덱스의 저장이나 변경 작업이 상당히 빨리 처리되지만, 유니크 인덱스는 반드시 중복 체크를 해야 하므로 작업 자체를 버퍼링하지 못한다.</p>
<h3 id="892-유니크-인덱스-사용-시-주의사항">8.9.2 유니크 인덱스 사용 시 주의사항</h3>
<p>꼭 필요한 경우 유니크 인덱스를 생성하는 것은 당연하지만, 성능이 더 좋아질 것으로 생각하고 불필요하게 유니크 인덱스를 생성하지 않는 것이 좋다. 또한 하나의 테이블에서 같은 칼럼에 유니크 인덱스와 일반 인덱스를 중복해서 생성하는데, 유니크 인덱스는 일반 다른 인덱스와 같은 역할을 하므로 중복해서 생성할 필요는 없다. 프라이머리 키의 경우도 불필요한 중복이므로 주의하자.</p>
<h2 id="810-외래키">8.10 외래키</h2>
<p>MySQL에서 외래키는 InnoDB 스토리지 엔진에서만 생성할 수 있으며, 외래키 제약이 설정되면 자동으로 연관되는 테이블의 칼럼에 인덱스까지 생성된다. </p>
<p>InnoDB의 외래키 관리 특징</p>
<ul>
<li><p>테이블의 변경(쓰기 잠금)이 발생하는 경우에만 잠금 경합(잠금 대기)이 발생한다.</p>
</li>
<li><p>외래키와 연관되지 않은 칼럼의 변경은 최대한 잠금 경합(잠금 대기)을 발생시키지 않는다.</p>
</li>
</ul>
<p>다음과 같은 테이블에서 자식 테이블과 부모 테이블의 변경이 언제 잠금 대기를 하는지 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; CREATE TABLE tb_parent (
        id INT NOT NULL,
        fd VARCHAR(100) NOT NULL, 
        PRIMARY KEY (id)
    )ENGINE=InnoDB;

mysql&gt; CREATE TABLE tb_child (
        id INT NOT NULL,
        pid INT DEFAULT NULL, -- // parent.id 칼럼 참조
        fd VARCHAR(100) DEFAULT NULL,
        PRIMARY KEY (id),
        KEY ix_parentid (pid)
        CONSTRAINT chuld_ibfk_1 FOREIGHN KEY (pid) REFERENCES tb_parent (id) ON DELETE CASCADE
    )ENGINE=InnoDB;

mysql&gt; INSERT INTO tb_parent VALUES (1, &#39;parent-1&#39;), (2, &#39;parent-2&#39;);
mysql&gt; INSERT INTO tb_child VALUDE (100, 1, &#39;child-100&#39;);</code></pre>
<h3 id="8101-자식-테이블의-변경이-대기하는-경우">8.10.1 자식 테이블의 변경이 대기하는 경우</h3>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/7fea92d6-6226-4f51-9021-1f79a989ffc7/image.png" alt=""></p>
<p>이 작업에서는 1번 커넥션에서 먼저 트랜잭션을 시작하고 부모(<code>tb_parent</code>) 테이블에서 <code>UPDATE</code>를 실행하여 <code>id</code>가 2인 레코드에 대해 쓰기 잠금을 획득한다. 그리고 2번 커넥션에서 자식 테이블(<code>tb_child</code>)의 외래키 칼럼인 <code>pid</code>를 2로 변경하는 쿼리를 실행하는 경우, 부모 테이블의 변경 작업이 완료될 때까지 대기하고 1번 커넥션의 트랜잭션이 종료되면 2번 커넥션의 대기 중이던 작업이 즉시 처리되는 것을 확인할 수 있다.</p>
<p>즉, 자식 테이블의 외래 키 칼럼의 변경(<code>INSERT</code>, <code>UPDATE</code>)은 부모 테이블의 확인이 필요한데, 이 상태에서 부모 테이블의 해당 레코드가 쓰기 잠금이 걸려 있으면 해당 쓰기 잠금이 해제될 때까지 기다리게 되는 것이다.</p>
<h3 id="8102-부모-테이블의-변경-작업이-대기하는-경우">8.10.2 부모 테이블의 변경 작업이 대기하는 경우</h3>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/2c4c6afd-a55f-48ba-9b94-68b9f77c1164/image.png" alt=""></p>
<p>첫 번째 커넥션에서 부모 키 &quot;1&quot;을 참조하는 자식 테이블의 레코드를 변경하면 <code>tb_child</code> 테이블의 레코드에 대해 쓰기 잠금을 획득한다. 이 상태에서 2번 커넥션이 <code>tb_parent</code> 테이블에서 <code>id</code>가 1인 레코드를 삭제하는 경우 <code>tb_child</code> 테이블의 레코드에 대한 쓰기 잠금이 해제될 때까지 기다려야 한다. 이는 자식 테이블이 생성될 때 정의된 외래키의 특성때문에 부모 레코드가 삭제되면 자식 레코드도 동시에 삭제되는 식으로 작동하기 때문이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 08. 인덱스 (3)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-3</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-3</guid>
            <pubDate>Fri, 08 May 2026 08:23:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="84-r-tree-인덱스">8.4 R-Tree 인덱스</h2>
<p>MySQL의 <strong>공간 인덱스(Spartial Index)</strong>는 <strong>R-Tree 인덱스 알고리즘</strong>을 이용해 2차원의 데이터를 인덱싱하고 검색하는 목적의 인덱스로, 기본적인 내부 매커니즘은 B-Tree와 흡사하다.</p>
<h3 id="841-구조-및-특성">8.4.1 구조 및 특성</h3>
<p>MySQL은 공간 정보의 저장 및 검색을 위해 여러 가지 기하학적 도형 정보를 관리할 수 있는 데이터 타입을 제공한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/47a2663a-3029-4d45-b6aa-73ef6652c0ab/image.png" alt=""></p>
<p>위 이미지는 MySQL에서 대표적으로 지원하는 데이터 타입이다. GEOMETRY 타입은 아머지 3개 타입의 슈퍼타입으로, POINT와 LINE, POLYGON 객체를 모두 저장할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/9d2d8903-2e6e-456e-a542-dffac91de6d4/image.png" alt=""></p>
<p><strong>MBR(Minumum Bounding Rectangle)</strong>은 각 도형을 감싸는 최소 크기의 사각형을 의미하며, 위 이미지는 각 도형의 <strong>MBR</strong>을 보여주고 있다. 이 사각형들의 포함 관계를 B-Tree 형태로 구현한 인덱스가 R-Tree 인덱스다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/4c2d5876-1848-4c89-b62c-7a6311c81493/image.png" alt=""></p>
<p>위 이미지는 각 공간 데이터, 즉 도형 객체가 존재할 때 MBR을 3개의 레벨로 나눠서 그린 것이다. </p>
<ul>
<li>최상위 레벨: R1, R2</li>
<li>차상위 레벨: R3, R4, R5, R6</li>
<li>최하위 레벨: R7 ~ R14</li>
</ul>
<p>최하위 레벨의 MBR은 각 도형 데이터의 MBR, 차상위 레벨의 MBR은 중간 크기의 MBR이다. 최상위 MVR은 R-Tree의 루트 노드에 저장되는 정보이며, 차상위 그룹 MBR은 R-Tree의 브랜치 노드가 된다. 마지막으로 각 도형의 객체는 리프 노드에 저장되므로 아래와 같이 R-Tree 인덱스의 내부를 표현할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/bd4559db-3756-4d5a-bbc2-ddfe248a0ed8/image.png" alt=""></p>
<h3 id="842-r-tree-인덱스의-용도">8.4.2 R-Tree 인덱스의 용도</h3>
<p><strong>R-Tree</strong>는 MRB 정보를 이용해 B-Tree 형태로 인덱스를 구축하므로 Rectangle의 &#39;R&#39;과 B-Tree의 &#39;Tree&#39;를 섞어 <strong>R-Tree</strong>라는 이름이 붙여졌으며, <strong>공간(Spatial) 인덱스</strong>라고도 한다. 일반적으로는 WGS84(GPS) 기준의 위도, 경도 좌표 저장에 주로 사용되며, CAD/CAM 소프트웨어 또는 회로 디자인 등과 같이 좌표 시스템에 기반을 둔 정보에 대해서는 모두 적용할 수 있다. MySQL에서 <code>ST_Contains()</code>, <code>ST_Within()</code> 함수를 사용해 공간 인덱스를 통한 거리 기반 검색이 가능하다.</p>
<h2 id="85-전문-검색-인덱스">8.5 전문 검색 인덱스</h2>
<p>문서의 내용 전체를 인덱스화해서 특정 키워드가 포함된 문서를 검색하는 전문(Full Text) 검색에는 InnoDB나 MyISAM 스토리지 엔진에서 제공하는 일반적인 용도의 B-Tree 인덱스를 사용할 수 없다. 문서 전체에 대한 분석과 검색을 위한 인덱싱 알고리즘을 <strong>전문 검색(Full Text search) 인덱스</strong>라고 한다.</p>
<h3 id="851-인덱스-알고리즘">8.5.1 인덱스 알고리즘</h3>
<h4 id="어근-분석-알고리즘">어근 분석 알고리즘</h4>
<p>MySQL 서버의 전문 검색 인덱스는 <strong>불용어 처리</strong>, <strong>어근 분석</strong> 과정을 거쳐서 색인 작업이 수행된다.</p>
<p><strong>불용어 처리</strong>는 검색에서 별 가치가 없는 단어를 필터링해서 제거하는 작업을 의미한다. MySQL 서버는 불용어가 소스코드에 정의돼 있지만, 사용자가 별도로 불용어를 정의할 수 있는 기능을 제공한다. </p>
<p><strong>어근 분석</strong>은 검색어로 선정된 단어의 뿌리인 원형을 찾는 작업이다.MySQL 서버에서는 오픈소스 형태소 분석 라이브러리인 <strong>MeCab</strong>을 플러그인 형태로 사용할 수 있게 지원한다. 하지만 <strong>MeCab</strong>은 일본어를 위한 형태소 분석 프로그램으로, 한글에 맞게 완성도를 갖추는 작업은 많은 시간과 노력이 필요하다.</p>
<h4 id="n-gram-알고리즘">n-gram 알고리즘</h4>
<p>MeCab을 이용한 형태소 분석은 많은 노력과 시간이 필요해 전문적인 검색 엔진을 고려한는 것이 아니라면 범용적으로 적용하기는 쉽지 않다. 이런 단점을 보완하기 위해 단순히 키워드를 검색해내기 위한 인덱싱 알고리즘인 <strong>n-gram 알고리즘</strong>이 도입되었다.</p>
<p><strong>n-gram</strong>이란 본문을 무조건 몇 글자씩 잘라서 인덱싱하는 방법이다. 형태소 분석보다는 알고리즘이 단순하고 국가별 언어에 대한 이해와 준비 작업이 필요 없는 반면, 만들어진 인덱스의 크기는 상당히 큰 편이다. <strong>n-gram</strong>에서 &#39;n&#39;은 인덱싱할 키워드의 최소 글자수로, 일반적으로는 2글자 단위로 키워드를 쪼개서 인덱싱하는 2-gram(Bi-gram) 방식이 많이 사용된다.</p>
<p>2-gram 알고리즘으로 다음 문장의 토큰을 분리하는 방법을 살펴보자.</p>
<blockquote>
<p>That is the question</p>
</blockquote>
<p>각 단어는 다음과 같이 띄어쓰기와 마침표를 기준으로 4개의 단어로 구분되고, 2글짜식 중첩해서 토큰으로 분리된다. 이렇게 구분된 각 토큰을 인덱스에 저장하며, 중복된 토큰은 하나의 인덱스 엔트리로 병합되어 저장된다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/9458622a-54b6-43ff-af2e-7d610ad8af4d/image.png" alt=""></p>
<p>이후 MySQL 서버는 생성된 토큰들에 대해 불용어를 걸러내는 작업을 수행하는데, 불용어와 동일하거나 불용어를 포함하는 경우 걸러서 버린 이후 남은 토큰들을 전문 검색 인덱스에 등록한다. 기본적으로 MySQL 서버에 내장된 불용어는 <code>information_schema.innodb_ft_default_stopword</code> 테이블을 통해 확인할 수 있다.</p>
<h3 id="852-전문-검색-인덱스의-가용성">8.5.2 전문 검색 인덱스의 가용성</h3>
<p><strong>전문 검색 인덱스</strong>를 사용하려면 반드시 다음 두 가지 조건을 갖춰야 한다.</p>
<ul>
<li>쿼리 문장이 전문 검색을 위한 문법(<code>MATCH ... AGAINST ...</code>)을 사용</li>
<li>테이블이 전문 검색 대상 칼럼에 대한 전문 인덱스 보유</li>
</ul>
<p>물론 <code>LIKE</code>를 사용한 검색 쿼리로도 원하는 검색 결과를 얻을 수 있으나, 이는 전문 검색 인덱스를 이용한 것이 아닌 풀 테이블 스캔으로 쿼리가 처리된다.</p>
<h2 id="86-함수-기반-인덱스">8.6 함수 기반 인덱스</h2>
<p>일반적인 인덱스는 칼럼의 값 일부 또는 전체에 대해서만 인덱스 생성이 허용된다. 하지만 칼럼의 값을 변형해서 만들어진 값에 대해 인덱스를 구축해야 할 때도 있는데, 이러한 경우 <strong>함수 기반 인덱스</strong>를 활용할 수 있다. MySQL 서버의 함수 기반 인덱스는 인덱싱할 값을 계산하는 과정의 차이만 있을 뿐, 실제 인덱스의 내부 구조 및 유지관리 방법은 B-Tree 인덱스와 동일하다.</p>
<h3 id="861-가상-칼럼을-이용한-인덱스">8.6.1 가상 칼럼을 이용한 인덱스</h3>
<p>이전 버전의 MySQL 서버는 원하는 조건의 칼럼을 추가하고 모든 레코드에 대해 해당 칼럼을 업데이트하는 작업을 거쳐야했으나, MySQL 8.0 버전부터는 가상 칼럼을 추가하고 그 가상 칼럼에 인덱스를 생성할 수 있게 됐다. 예시 쿼리는 다음과 같다.</p>
<pre><code class="language-sql">mysql&gt; ALTER TABLE user
        ADD full_name VARCHAR(30) AS (CONCAT(first_name,&#39; &#39;,last_name)) VIRTUAL,
        ADD INDEX ix_fullname (full_name)</code></pre>
<p>그러나 가상 칼럼은 새로운 칼럼을 추가하는 것과 같은 효과를 내기 때문에 실제 테이블 구조가 변경된다는 단점이 있다.</p>
<h3 id="862-함수를-이용한-인덱스">8.6.2 함수를 이용한 인덱스</h3>
<p>MySQL 8.0 버전부터 다음과 같이 테이블의 구조를 변경하지 않고, 함수를 직접 사용하는 인덱스를 생성할 수 있게 됐다.</p>
<pre><code class="language-sql">mysql&gt; CREATE TABLE (
        user_id BIGINT,
        first_name VARCHAR(10),
        last_name VARCHAR(10),
        PRIMARY KEY (user_id),
        INDEX ix_fullname ((CONCAT(first_name,&#39; &#39;,last_name)))
    );</code></pre>
<p>함수를 직접 사용하는 인덱스는 테이블의 구조는 변경하지 않고, 계산된 결괏값의 검색을 빠르게 만들어준다. 함수 기반 인덱스를 제대로 활용하려면 반드시 조건절에 함수 기반 인덱스에 명시된 표현식이 그대로 사용돼야 한다.</p>
<h2 id="87-멀티-밸류-인덱스">8.7 멀티 밸류 인덱스</h2>
<p><strong>멀티 밸류(Multi-Value) 인덱스</strong>는 하나의 데이터 레코드가 여러 개의 키 값을 가질 수 있는 형태의 인덱스다. 일반적인 DBMS 기준으로는 정규화에 위배되는 형태이나, 최근 RDBMS들이 JSON 데이터 타입을 지원하기 시작하며 JSON의 배열 타입의 필드에 저장된 원소들에 대한 인덱스 요건이 발생한 것이다.</p>
<p><strong>멀티 밸류 인덱스</strong>를 활용하기 위해서는 일반적인 조건 방식이 아닌 반드시 다음 함수들을 이용해서 검색해야 옵티마이저가 인덱스를 활용한 실행 계획을 수립한다.</p>
<ul>
<li><code>MEMBER OF()</code></li>
<li><code>JSON_CONTAINS()</code></li>
<li><code>JSON_OVERLAPS()</code></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 08. 인덱스 (2)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-2</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-2</guid>
            <pubDate>Mon, 04 May 2026 06:16:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="83-b-tree-인덱스">8.3 B-Tree 인덱스</h2>
<p><strong>B-Tree</strong>는 데이터베이스의 인덱싱 알고리즘 가운데 가장 일반적으로 사용되고 먼저 도입된 알고리즘이며, 아직도 가장 범용적인 목적으로 사용되는 인덱스 알고리즘이다. B-Tree는 일반적으로 DBMS에서 사용하는 B+-Tree, B*-Tree와 같은 여러 가지 변형된 알고리즘이 존재한다.</p>
<blockquote>
</blockquote>
<p>B-Tree의 &quot;B&quot;는 &quot;Binary&quot;의 약자가 아닌 &quot;Balanced&quot;를 의미한다.</p>
<h3 id="831-구조-및-특성">8.3.1 구조 및 특성</h3>
<p>B-Tree는 트리 구조의 최상위에 하나의 <strong>루트 노드(Root node)</strong>가 존재하고 그 하위에 자식 노드가 붙어 있는 형태다. 트리 구조 가장 하위에 있는 노드를 <strong>리프 노드(Leaf node)</strong>, 루트 노드와 리프 노드가 아닌 중간의 노드를 <strong>브랜치 노드(Branch node)</strong>라고 한다. 데이터베이스에서 인덱스와 실제 데이터가 저장된 데이터는 따로 관리되는데, 인덱스의 리프 노드는 항상 실제 데이터 레코드를 찾아가기 위한 주솟값을 가지고 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/8fc375e2-dec5-4617-9cf0-b36b50cf9da3/image.png" alt=""></p>
<p>위 이미지는 B-Tree 인덱스의 각 노드와 데이터 파일의 관계를 보여준다. 인덱스의 키 값은 모두 정렬돼 있지만, 데이터 파일의 레코드는 정렬돼 있지 않고 임의의 순서로 저장돼 있다. 데이터 파일의 레코드는 항상 <code>INSERT</code>된 순서대로 저장되는 것이 아니라 레코드가 삭제되어 빈 공간이 생긴 자리에 <code>INSERT</code>는 삭제된 공간을 재활용하도록 DBMS가 설계되어 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/06225f49-a71f-4223-818c-059521d2a3ff/image.png" alt=""></p>
<p>위 그림은 MyISAM테이블의 인덱스와 데이터 파일의 관계로, &quot;레코드 주소&quot;는 MyISAM 테이블의 생성 옵션에 따라 레코드가 테이블에 <code>INSERT</code>된 순번이거나 데이터 파일 내의 위치다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/f509c74c-e686-4fd5-a9de-7ed9ceb43f82/image.png" alt=""></p>
<p>다음으로 위 그림은 InnoDB 테이블의 인덱스의 데이터 파일의 관계를 보여주는데, 여기서는 프라이머리 키가 ROWID의 역할을 한다. 두 스토리지 엔진의 가장 큰 차이점은 세컨더리 인덱스를 통해 데이터 파일의 레코드를 찾아가는 방법으로, MyISAM 테이블은 세컨더리 인덱스가 <strong>물리적인 주소</strong>를 가지는 반면 InnoDB 테이블은 프라이머리 키를 주소처럼 사용하기 때문에 <strong>논리적인 주소</strong>를 가진다고 볼 수 있다.</p>
<p>따라서 InnoDB 테이블에서 인덱스를 통해 레코드를 읽을 때는 인덱스에 저장돼 있는 프라이머리 키 값을 이용해 <strong>프라이머리 키 인덱스를 한 번 더 검색한 후</strong>, 프라이머리 키 인덱스의 리프 페이지에 저장돼 있는 레코드를 읽는다. 이와 같은 작업으로 InnoDB 스토리지 엔진을 사용하는 테이블은 성능이 떨어질 것처럼 보이지만 MyISAM 인덱스 구조와 InnoDB 인덱스 구조는 각각 장단점을 가지고 있다.</p>
<h3 id="832-b-tree-인덱스-추가-및-삭제">8.3.2 B-Tree 인덱스 추가 및 삭제</h3>
<h4 id="인덱스-키-추가">인덱스 키 추가</h4>
<p>B-Tree에 저장될 때는 저장될 키 값을 이용해 B-Tree상의 적절한 위치를 검색해야 한다. 저장될 위치가 결정되면 레코드의 키 값과 대상 레코드의 주소 정보를 B-Tree의 리프 노드에 저장한다. 리프 노드가 꽉 차서 더는 저장할 수 없을 때는 <strong>리프 노드가 분리(Split)</strong>돼야 하는데, 이는 상위 브랜치 노드까지 처리 범위가 넓어져 상대적으로 쓰기 작업에 비용이 많이 소모된다.</p>
<p>MyISAM이나 MEMORY 스토리지 엔진을 사용하는 테이블에서는 <code>INSERT</code> 문장이 실행되면 즉시 새로운 키 값을 B-Tree 인덱스에 변경하지만, InnoDB는 필요에 따라 인덱스 키 작업을 지연시켜 나중에 처리할 수 있다. 하지만 프라이머리 키나 유니크 인덱스의 경우 중복 체크가 필요하기 때문에 즉시 B-Tree에 추가하거나 삭제한다.</p>
<h4 id="인덱스-키-삭제">인덱스 키 삭제</h4>
<p>B-Tree의 키값의 삭제는 해당 키 값이 저장된 리프 노드를 찾아서 삭제 마크만 하면 작업이 완료된다. 이렇게 삭제 마킹된 인덱스 키 공간은 계속 방치하거나 재활용할 수 있다. 인덱스 키 삭제로 인한 마킹 작업 또한 디스크 쓰기가 필요하므로 디스크 I/O가 필요한 작업이다. MySQL 5.5 이상 버전의 InnoDB 스토리지 엔진에서는 이 작업 또한 버퍼링되어 지연 처리될 수도 있다.</p>
<h4 id="인덱스-키-변경">인덱스 키 변경</h4>
<p>B-Tree의 키 값 변경 작업은 키 값을 삭제한 후, 다시 새로운 키 값을 추가하는 형태로 처리된다. 키 값의 변경 때문에 발생하는 키 값의 삭제와 추가 작업은 위에서 설명한 절차대로 처리된다.</p>
<h4 id="인덱스-키-검색">인덱스 키 검색</h4>
<p>인덱스를 검색하는 작업은 B-Tree 루트 노드부터 시작해 브랜치 노드를 거쳐 최종 리프 노드까지 이동하며 비교 작업을 수행하는 <strong>트리 탐색</strong> 과정을 거친다. 인덱스 트리 탐색은 <code>SELECT</code>에서만 사용하는 것이 아닌 <code>UPDATE</code>, <code>DELETE</code>를 처리하기 위해 항상 해당 레코드를 먼저 검색해야 할 경우에도 사용된다. B-Tree 인덱스를 이용하는 검색은 100% 일치 또는 값의 일부분만 일치하는 경우에 사용할 수 있다. 부등호 비교 조건에서도 활용할 수 있지만, 인덱스를 구성하는 키 값의 뒷부분만 검색하는 용도로는 인덱스를 사용할 수 없다. 또한 인덱스의 키값에 변형이 가해진 후 비교되는 경우에는 이미 변형된 값은 B-Tree 인덱스에 존재하는 값이 아니므로 절대 빠른 검색 기능을 사용할 수 없다. 따라서 함수나 연산을 수행한 결과로 정렬한다거나 검색하는 작업은 B-Tree의 장점을 이용할 수 없으므로 주의해야 한다.</p>
<p>InnoDB 테이블에서 지원하는 레코드 잠금이나 넥스트 키락(갭락)이 검색을 수행한 후 인덱스를 잠근 후 테이블의 레코드를 잠그는 방식으로 구현되어 <code>UPDATE</code>나 <code>DELETE</code> 문장이 실행될 때 테이블에 적절히 사용할 수 있는 인덱스가 없으면 불필요하게 많은 레코드를 잠글 수 있어 인덱스를 설계할 때 주의해야 한다.</p>
<h3 id="833-b-tree-인덱스-사용에-영향을-미치는-요소">8.3.3 B-Tree 인덱스 사용에 영향을 미치는 요소</h3>
<h4 id="인덱스-키-값의-크기">인덱스 키 값의 크기</h4>
<p>InnoDB 스토리지 엔진은 디스크에 데이터를 저장하는 가장 기본 단위를 <strong>페이지(Page)</strong> 또는 <strong>블록(Block)</strong>이라고 하며, 디스크의 모든 읽기 및 쓰기 작없의 최소 단위이자 버퍼 풀에서 데이터를 버퍼링하는 기본 단위로, 인덱스도 페이지 단위로 구분된다.</p>
<p>일반적으로 DBMS의 B-Tree는 자식 노드의 개수가 가변적인 구조로, MySQL의 B-Tree의 자식 노드 개수는 인덱스의 페이지 크기와 키 값의 크기에 따라 결정된다. 인덱스의 키가 16바이트라고 가정하면 아래 이미지같이 인덱스 페이지가 구성될 것이다. 자식 노드 주소라는 것은 여러 가지 복합적인 정보가 담긴 영역이며, 페이지의 종류별로 대략 6바이트에서 12바이트까지 다양한 크기 값을 가질 수 있다. 여기서는 편의상 자식 노드 주소 영역이 평균적으로 12바이트로 구성된다고 가정한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/191fc31e-16cd-4cda-aa2f-1282e1b2ba8e/image.png" alt=""></p>
<p>위 이미지의 경우 하나의 인덱스 페이지(페이지 기본값 16KB)에 $16*1024/(16+12)=585$개의 키(자식노드)를 가질 수 있다. </p>
<p>인덱스의 키 값의 크기가 두 배인 32바이트로 늘어났다고 가정한다면 한 페이지에 인덱스 키를 $16*1024(32+12) = 372$개 저장할 수 있다. </p>
<p>만약 <code>SELECT</code>쿼리가 레코드 500개를 읽어야 하는 경우 키 값의 크기가 32바이트라면 최소한 2번 이상 디스크로부터 읽어야 한다. 따라서 인덱스를 구성하는 키 값의 크기가 커지면 디스크로부터 읽어야 하는 횟수가 늘어나고, 그만큼 느려진다는 것을 의미한다.</p>
<h4 id="b-tree-깊이">B-Tree 깊이</h4>
<p><strong>B-Tree의 깊이(Depth)</strong>는 MySQL에서 값을 검색할 때 몇 번이나 랜덤하게 디스크를 읽어야 하는지와 직결되는 문제다. 인덱스 키 값의 크기가 커지면 커질수록 하나의 인덱스 페이지가 담을 수 있는 인덱스 키 값의 개수가 적어지고, 그 때문에 같은 레코드 건수라 하더라고 <strong>B-Tree의 깊이(Depth)</strong>가 깊어져서 디스크 읽기가 더 많이 필요하게 된다는 것을 의미한다.</p>
<blockquote>
<p>즉 인덱스 키 값의 크기는 가능하면 작게 만드는 것이 좋으며, 실제로 아무리 대용량 데이터베이스라도 <strong>B-Tree의 깊이(Depth)</strong>가 5단계 이상까지 깊어지는 경우는 흔치 않다.</p>
</blockquote>
<h4 id="선택도기수성">선택도(기수성)</h4>
<p>인덱스에서 <strong>선택도(Selectivity)</strong>또는 <strong>기수성(Cardinality)</strong>은 거의 같은 의미로 사용되며, 모든 인덱스 키 값 가운데 유니크한 값의 수를 의미한다. 전체 인덱스 키 값은 100개인데, 그중에서 유니크한 값의 수는 10개라면 기수성은 10이다. 인덱스 키 값 가운데 중복된 값이 많을수록 기수성과 선택도는 떨어진다. 인덱스는 선택도가 높을수록 검색 대상이 줄어들기 때문에 그만큼 빠르게 처리된다.</p>
<blockquote>
<p>선택도가 좋지 않다고 하더라도 정렬이나 그루핑과 같은 작업을 위해 인덱스를 만드는 것이 훨씬 나은 경우도 많다. 인덱스가 항상 검색에만 사용되는 것은 아니므로 여러가지 용도를 고려해 적절히 인덱스를 설계할 필요가 있다.</p>
</blockquote>
<h4 id="읽어야-하는-레코드의-건수">읽어야 하는 레코드의 건수</h4>
<p>인덱스를 통해 테이블의 레코드를 읽는 것은 인덱스를 거치지 않고 바로 테이블의 레코드를 읽는 것보다 높은 비용이 드는 작업이다. 따라서 인덱스를 이용한 읽기의 손익 분기점이 얼마인지 판단할 필요가 있다.</p>
<p>일반적인 DBMS의 옵티마이저에서는 인덱스를 통해 레코드 1건을 읽는 것이 테이블에서 직접 레코드 1건을 읽는 것보다 4<del>5배 정도 비용이 더 많이 드는 작업인 것으로 예측한다. 즉, 인덱스를 통해 읽어야 할 레코드의 건수가 전체 테이블 레코드의 20</del>25%를 넘어서면 인덱스를 이용하지 않고 테이블을 모두 직접 읽어서 필요한 레코드만 가려내는 방식으로 처리하는 것이 효율적이다. MySQL의 옵티마이저는 손익 분기점을 확인하고 인덱스 이용 여부를 판단한다.</p>
<h3 id="834-b-tree-인덱스를-통한-데이터-읽기">8.3.4 B-Tree 인덱스를 통한 데이터 읽기</h3>
<h4 id="인덱스-레인지-스캔">인덱스 레인지 스캔</h4>
<p><strong>인덱스 레인지 스캔</strong>은 인덱스의 접근 방법 중 가장 대표적인 접근 방식으로, 뒤에서 설명할 두 가지 접근 방식보다는 빠른 방법이다. 인덱스를 통해 한 건만ㅇ 읽는 경우와 한 건 이상을 읽는 경우를 각각 다른 이름으로 구분하지만, 이번 절에서는 모두 묶어서 <strong>인덱스 레인지 스캔</strong>이라고 표현했다. 다음 쿼리를 예제로 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; SELECT * FROM employees WHERE first_name BETWEEN &#39;Ebbe&#39; AND &#39;Gad&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/1d3ffe97-1aac-428e-a074-fdd673d71a0e/image.png" alt=""></p>
<p><strong>인덱스 레인지 스캔</strong>은 검색해야 할 인덱스의 범위가 결정됐을 때 사용하는 방식으로, 검색하려는 값의 수나 검색 결과 레코드 건수와 관계없이 레인지 스캔이라고 표현한다. 위 이미지의 화살표처럼 루트 노드에서부터 비교를 시작해 브랜치 노드를 거치고 최종적으로 리프 노드까지 찾아 들어가야만 비로소 필요한 레코드의 시작 지점을 찾을 수 있으며, 이후에는 리프 노드의 레코드만 순서대로 읽으면 된다. 이처럼 차례대로 쭉 읽는 것을 <strong>스캔</strong>이라고 표현한다. 스캔 중 리프노드의 끝까지 읽으면 리프 노드 간의 링크를 이용해 다음 리프 노드를 찾아 다시 스캔하고, 스캔을 멈춰야 할 위치에 다다르면 지금까지 읽은 레코드를 사용자에게 반환하고 쿼리를 끝낸다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/eb295538-baa7-43c7-b060-b7dad003d479/image.png" alt=""></p>
<p>위 이미지는 <strong>인덱스 레인지 스캔</strong>을 통한 데이터 레코드 읽기 과정으로, B-Tree 인덱스에서 루트와 브랜치 노드를 이용해 스캔 시작 위치를 검색하고, 그 지점부터 필요한 방향(오름차순 또는 내림차순)으로 인덱스를 읽어나가는 것을 확인할 수 있다. 중요한 것은 어떤 방식으로 스캔하든 관계없이, 해당 인덱스를 구성하는 칼럼의 정순 또는 역순으로 정렬된 상태로 레코드를 가져온다는 것이다. 이는 별도의 정렬 과정이 수반되는 것이 아니라 인덱스 자체의 정렬 특성 때문에 자동으로 그렇게 된다.</p>
<p>또 한 가지 중요한 것은 인덱스의 리프 노드에서 검색 조건에 일치하는 건들은 데이터 파일에서 레코드를 읽어오는 과정이 필요하다는 것이다. 이때 리프 노드에 저장된 레코드 주소로 데이터 파일의 레코드를 읽어오는데, 레코드 한 건 단위로 랜덤 I/O가 한 번씩 발생한다. 위 이미지처럼 3건의 레코드가 검색 조건에 일치한 경우 랜덤 I/O가 최대 3번 필요한 것이다. 그래서 인덱스를 통해 데이터 레코드를 읽는 작업은 비용이 많이 드는 작업으로 분류된다.</p>
<p><strong>인덱스 레인지 스캔</strong> 과정을 3단계로 분류하면 아래와 같다.</p>
<ol>
<li>인덱스에서 조건을 만족하는 값이 저장된 위치를 찾는다.</li>
<li>1번에서 탐색된 위치부터 필요한 만큼 인덱스를 차례대로 쭉 읽는다.</li>
<li>2번에서 읽어 들인 인덱스 키와 레코드 주소를 이용해 레코드가 저장된 페이지를 가져오고, 최종 레코드를 읽어온다.</li>
</ol>
<blockquote>
<p>쿼리가 필요로 하는 데이터에 따라 3번 과정은 필요하지 않을 수도 있는데, 이를 <strong>커버링 인덱스</strong>라고 한다. <strong>커버링 인덱스</strong>로 처리되는 쿼리는 디스크의 레코드를 읽지 않아도 되기 때문에 랜덤 읽기가 상당히 줄어들고 성능은 그만큼 빨라진다.</p>
</blockquote>
<h4 id="인덱스-풀-스캔">인덱스 풀 스캔</h4>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/bde67521-e78b-4a25-afa3-0d13ef520425/image.png" alt=""></p>
<p>인덱스 레인지 스캔과는 달리 인덱스의 처음부터 끝까지 모두 읽는 방식을 <strong>인덱스 풀 스캔</strong>이라고 한다. 위 이미지는 인덱스 풀 스캔의 예로, 인덱스 리프 노드의 제일 앞 또는 뒤로 이동한 후, 리프 노드를 연결하는 링크드 리스트를 따라서 처음부터 끝까지 스캔한다. 쿼리가 인덱스에 명시된 칼럼만으로 조건을 처리할 수 있는 경우 주로 이 방식이 사용된다.</p>
<h4 id="루스-인덱스-스캔">루스 인덱스 스캔</h4>
<p><strong>루스 인덱스 스캔</strong>이란 말 그대로 느슨하게 또는 듬성듬성하게 인덱스를 읽는 것을 의미하며, 루스 인덱스 스캔은 인덱스 레인지 스캔과 비슷하게 작동하지만 중간에 필요치 않은 인덱스 키 값은 <strong>무시(SKIP)</strong>하고 다음으로 넘어가는 형태로 처리한다. 일반적으로 <code>GROUP BY</code> 또는 집합 함수 가운데 <code>MAX()</code> 또는 <code>MIN()</code> 함수에 대해 최적화를 하는 경우에 사용된다. </p>
<pre><code class="language-sql">mysql&gt; SELECT dept_no, MIN(emp_no)
        FROM dept_emp
        WHERE dept_no BETWEEN &#39;d002&#39; AND &#39;d004&#39;
        GROUP BY dept_no;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/cf25d476-77bf-4585-87ca-549c63e4758d/image.png" alt=""></p>
<p>위 쿼리에서 사용된 <code>dept_emp</code> 테이블은 <code>dept_no</code>와 <code>emp_no</code>라는 두 개의 칼럼으로 인덱스가 생성 및 정렬되어 있어 위 이미지와 같이 <code>dept_no</code> 그룹별로 첫 번째 레코드의 <code>emp_no</code> 값만 읽으면 된다. 즉 인덱스에서 <code>WHERE</code> 조건에 만족하지 않는 레코드는 무시하고 다음 레코드로 이동한다.</p>
<h4 id="인덱스-스킵-스캔">인덱스 스킵 스캔</h4>
<pre><code class="language-sql">mysql&gt; ALTER employees 
        ADD INDEX ix_gender_birthdate (gender, birth_date);

-- // 인덱스를 사용하지 못하는 쿼리
mysql&gt; SELECT * FROM employees WHERE birth_date&gt;=&#39;1965-02-01&#39;;

-- // 인덱스를 사용할 수 있는 쿼리
mysql&gt; SELECT * FROM employees
        WHERE gender=&#39;M&#39; AND birth_date&gt;=&#39;1965-02-01&#39;;</code></pre>
<p>기존에는 <code>gender</code> 칼럼과 <code>birth_date</code> 칼럼으로 생성된 인덱스를 생성했을 때, 인덱스의 조건을 모두 가진 두 번째 <code>SELECT</code> 쿼리는 인덱스를 효율적으로 사용할 수 있지만, 첫 번째 <code>SELECT</code> 쿼리는 <code>gender</code> 칼럼에 대한 비교 조건이 없어 인덱스를 사용할 수가 없었다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/7cd6f0f1-10e1-411a-b06e-ff9ad541e670/image.png" alt=""></p>
<p>MySQL 8.0 버전부터는 옵티마이저가 인덱스 내의 필요한 부분만 읽어 검색이 가능하게 해주는 <strong>인덱스 스킵 스캔(Index skip scan)</strong> 최적화 기능이 도입되었다. 위 이미지는 <code>ix_gender_birthdate</code> 인덱스에 대한 인덱스 스킵 스캔 처리 과정을 보여준다. 우선 <code>gender</code> 칼럼에서 유니크한 값(&#39;M&#39;, &#39;F&#39;)을 모두 조회해서 주어진 쿼리에 <code>gender</code> 칼럼의 조건을 추가해서 쿼리를 다시 실행하는 형태로 처리한다.</p>
<p>인덱스 스킵 스캔은 두 가지 제약 조건을 만족하는 경우 사용하는 것이 좋으며, 충족하지 않는 경우에는 오히려 쿼리의 처리 성능이 더 느려질 수 있다.</p>
<ol>
<li><code>WHERE</code> 조건절에 조건이 없는 인덱스의 선행 칼럼의 유니크한 값의 개수가 적어야 함</li>
<li>쿼리가 인덱스에 존재하는 칼럼만으로 처리 가능해야 함(커버링 인덱스)</li>
</ol>
<h3 id="835-다중-칼럼multi-column-인덱스">8.3.5 다중 칼럼(Multi-column) 인덱스</h3>
<p>두 개 이상의 칼럼으로 구성된 인덱스를 <strong>다중 칼럼 인덱스(복합 칼럼 인덱스)</strong>라고 하며, 2개 이상의 칼럼이 연결됐다고 해서 <strong>Concatenated Index</strong>라고도 한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/40b22478-704e-4e15-957e-d387b288d2cc/image.png" alt=""></p>
<p>위 그림은 <strong>다중 칼럼 인덱스의 구조</strong>로, 편의상 루트 노드는 생략했으나 실제로 데이터 레코드 건수가 작은 경우에는 브랜치 노드가 없는 경우도 존재한다. 이 이미지에서 중요한 것은 두 번째 칼럼이 첫 번째 칼럼에 의존해서 정렬되어 있다는 것으로, 두 번째 칼럼의 정렬은 첫 번째 칼럼이 똑같은 레코드에서만 의미가 있다는 것이다. <strong>다중 칼럼 인덱스</strong>에서는 인덱스 내에서 각 칼럼의 위치(순서)가 상당히 중요하다.</p>
<h3 id="836-b-tree-인덱스의-정렬-및-스캔-방향">8.3.6 B-Tree 인덱스의 정렬 및 스캔 방향</h3>
<h4 id="인덱스의-정렬">인덱스의 정렬</h4>
<p>MySQL 5.7 버전까지는 칼럼 단위로 정렬 순서를 혼합해서 인덱스를 생성할 수 없었으나, 8.0 버전부터는 다음과 같은 형태의 정렬 순서를 혼합한 인덱스도 생성할 수 있게 됐다.</p>
<pre><code class="language-sql">mysql&gt; CREATE INDEX ix_teamname_userscore 
        ON employees (team_name ASC, user_score DESC);</code></pre>
<h5 id="인덱스의-스캔-방향">인덱스의 스캔 방향</h5>
<p>인덱스 생성 시점에 오름차순 또는 내림차순으로 정렬이 결정되지만 쿼리가 인덱스를 사용하는 시점에 인덱스를 읽는 방향에 따라 오름차순 또는 내림차순 정렬 효과를 얻을 수 있다. 오름차순으로 생성된 인덱스를 읽으면 정순으로 읽으면 오름차순으로 정렬된 결과가, 역순으로 읽으면 내림차순으로 정렬된  결과를 얻게 되는 것이다. MySQL 옵티마이저는 인덱스의 읽기 방향을 전환해서 사용하도록 실행 계획을 만들어 낸다.</p>
<h5 id="내림차순-인덱스">내림차순 인덱스</h5>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/d36dbd4d-8482-476e-a3ad-696638b34d96/image.png" alt=""></p>
<ul>
<li><p>오름차순 인덱스(Ascending index): 작은 값의 인덱스 키가 B-Tree 왼쪽으로 정렬된 인덱스</p>
</li>
<li><p>내림차순 인덱스(Descending index): 큰 값의 인덱스 키가 B-Tree 왼쪽으로 정렬된 인덱스</p>
</li>
<li><p>인덱스 정순 스캔(Forward index scan): 인덱스 키의 크고 작음에 관계없이 인덱스 리프 노드의 왼쪽 페이지부터 오른쪽으로 스캔</p>
</li>
<li><p>인덱스 역순 스캔(Backward index scan): 인덱스 키의 크고 작음에 관계없이 인덱스 리프 노드의 오른쪽 페이지부터 왼쪽으로 스캔</p>
</li>
</ul>
<p>MySQL 서버의 InnoDB 스토리지 엔진에서 정순 스캔과 역순 스캔은 페이지(블록)간의 양방향 연결 고리를 통해 전진하느냐 후진하느냐의 차이만 있지만, 실제 내부적으로는 InnoDB에서 인덱스 역순 스캔이 인덱스 정순 스캔에 비해 느릴 수밖에 없는 두 가지 이유가 있다.</p>
<ol>
<li>페이지 잠금이 인덱스 정순 스캔에 적합한 구조</li>
<li>페이지 내에서 인덱스 레코드가 단방향으로 연결된 구조 (아래 이미지)</li>
</ol>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/434afcdd-9128-4266-8241-ec4dd694d2ed/image.png" alt=""></p>
<p>따라서 일반적으로 인덱스를 <code>ORDER BY ... DESC</code> 하는 쿼리가 소량의 레코드에 드물게 실행되는 경우라면 내림차순 인덱스를 굳이 고려할 필요는 없다. </p>
<p>많은 쿼리가 인덱스의 앞쪽 혹은 뒤쪽만 집중적으로 읽어서 인덱스의 특정 페이지 잠금이 병목이 될 것으로 예상된다면 쿼리에서 자주 사용되는 정렬 순서대로 인덱스를 생성하는 것이 잠금 병목 현상을 완화하는데 도움이 될 것이다.</p>
<h3 id="837-b-tree-인덱스의-가용성과-효율성">8.3.7 B-Tree 인덱스의 가용성과 효율성</h3>
<h4 id="비교-조건의-종류와-효율성">비교 조건의 종류와 효율성</h4>
<p>다중 칼럼 인덱스에서 각 컬럼의 순서와 그 칼럼에 사용된 조건이 동등 비교인지, 범위 조건인지에 따라 각 인덱스 칼럼의 활용 형태 및 효율이 달라진다. 아래 예제를 살펴보자.</p>
<pre><code class="language-sql">mysql&gt; SELECT * FROM dept_emp
        WHERE dept_no=&#39;d002&#39; AND emp_no &gt;= 10114;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/d6b39250-26bc-42ba-97bf-f5ef2a365e39/image.png" alt=""></p>
<p>위 <code>SELECT</code> 쿼리를 실행했을 때, 칼럼의 순서가 다른 두 개의 인덱스 처리 과정을 이미지로 나타냈다. 케이스 A의 인덱스는 <code>dept_no=&#39;d002&#39; AND emp_no&gt;=10144</code>인 레코드를 찾은 뒤 <code>dept_no</code>가 <code>d002</code>가 아닐 때까지 인덱스를 쭉 읽으면 된다. 이 경우에는 읽은 5건의 레코드가 모두 사용자가 원하는 결과로 상당히 효율적으로 인덱스를 이용한 것임을 알 수 있다.</p>
<p>그러나 케이스 B의 인덱스는 <code>emp_no&gt;=10144 AND dept_no=&#39;d002&#39;</code>인 레코드 7건을 찾은 이후 <code>dept_no</code>가 &#39;d002&#39;인 조건을 만족하는 레코드 5건을 가져온다. 즉, 5건의 레코드를 찾기 위해 7번의 비교 과정을 거친 것이다.</p>
<p>위처럼 다중 인덱스의 정렬 방식에 따라 효율의 차이가 발생한다. 케이스 A 인덱스에서 2번째 칼럼인 <code>emp_no</code>는 비교 작업의 범위를 줄이는데 도움을 주지만, 케이스 B 인덱스에서 2번째 칼럼인 <code>dept_no</code>는 작업의 범위를 좁히는 데 아무런 도움을 주지 못하고, 단지 쿼리의 조건에 맞는지 검사하는 용도로만 사용됐다.</p>
<p>작업의 범위를 결정하는 조건을 <strong>작업 범위 결정 조건</strong>, 비교 작업의 범위를 줄이지 못하고 단순리 거름종이 역할만 하는 조건을 <strong>필터링 조건</strong> 또는 <strong>체크 조건</strong>이라고 표현한다. <strong>작업 범위 결정 조건</strong>은 많을수록 쿼리의 처리 성능을 높이지만 <strong>체크 조건</strong>은 많다고 해서 쿼리의 처리 성능을 높이지 못하며, 오히려 쿼리 실행을 더 느리게 만들 때가 많다.</p>
<h4 id="인덱스의-가용성">인덱스의 가용성</h4>
<p>B-Tree 인덱스의 특징은 왼쪽 값에 기준해서 오른쪽 값이 정렬돼 있다는 점으로, 이는 다중 칼럼 인덱스의 칼럼에 대해서도 동일하게 적용된다. 이러한 특징으로 인해, 하나의 칼럼으로 검색하는 경우에 값의 왼쪽 부분이 없으면 인덱스 레인지 스캔 방식의 검색이 불가능하다. 또한 다중 칼럼 인덱스에서도 왼쪽 칼럼의 값을 모르면 인덱스 레인지 스캔을 사용할 수 없다.</p>
<h4 id="가용성과-효율성-판단">가용성과 효율성 판단</h4>
<p>작업 범위 결정 조건으로 사용할 수 없는 조건</p>
<ul>
<li>NOT-EQUAL로 비교된 경우(<code>&lt;&gt;</code>, <code>NOT IN</code>, <code>NOT BETWEEN</code>, <code>IS NOT NULL</code>)</li>
<li><code>LIKE &#39;%??&#39;</code></li>
<li>스토어드 함수나 다른 연산자로 인덱스 칼럼이 변형된 후 비교된 경우</li>
<li>NOT-DETERMINISTIC 속성의 스토어드 함수가 비교 조건에 사용된 경우</li>
<li>데이터 타입이 서로 다른 비교</li>
<li>문자열 데이터 타입의 콜레이션이 다른 경우</li>
</ul>
<blockquote>
<p>MySQL에서는 <code>NULL</code> 값도 인덱스에 저장되어, 다음과 같은 <code>WHERE</code> 조건도 작업 범위 결정 조건으로 인덱스를 사용한다.</p>
</blockquote>
<pre><code class="language-sql">mysql&gt; .. WHERE column IS NULL ..</code></pre>
<p>다중 칼럼으로 만들어진 아래와 같은 인덱스가 있을 때 어떤 조건에서 사용될 수 있고, 어떤 경우에 절대 사용할 수 없는지 살펴보자.</p>
<pre><code class="language-sql">INDEX ix_text (column_1, column_2, ..., column_n )</code></pre>
<p>작업 범위 결정 조건으로 인덱스를 사용하지 못하는 경우</p>
<ul>
<li><code>column_1</code> 칼럼에 대한 조건이 없는 경우</li>
<li><code>column_1</code> 칼럼의 비교 조건이 인덱스 사용 불가 조건 중 하나인 경우</li>
</ul>
<p>작업 범위 결정 조건으로 인덱스를 사용하는 경우 ($2 &lt; i &lt; n$)</p>
<ul>
<li><code>column_1</code> ~ <code>column_(i-1)</code> 칼럼까지 동등 비교 형태(<code>=</code>, <code>IN</code>)로 비교</li>
<li><code>column_i</code> 칼럼에 대해 다음 연사자 중 하나로 비교<ul>
<li>동등 비교(<code>=</code>, `IN)</li>
<li>크다 작다 형태 (<code>&gt;</code>, <code>&lt;</code>)</li>
<li><code>LIKE</code>로 좌측 일치 패턴(<code>LIKE &#39;~~%&#39;</code>)</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 08. 인덱스 (1)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-1</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-08.-%EC%9D%B8%EB%8D%B1%EC%8A%A4-1</guid>
            <pubDate>Mon, 27 Apr 2026 08:57:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h1 id="8-인덱스">8. 인덱스</h1>
<h2 id="81-디스크-읽기-방식">8.1 디스크 읽기 방식</h2>
<h3 id="811-하드-디스크-드라이브hdd와-솔리드-스테이트-드라이브ssd">8.1.1 하드 디스크 드라이브(HDD)와 솔리드 스테이트 드라이브(SSD)</h3>
<p>컴퓨터에서 CPU나 메모리 같은 주요 장치는 대부분 전자식 장치지만 <strong>하드 디스크 드라이브</strong>는 기계식 장치다. 그래서 데이터베이스 서버에서는 항상 디스크 장치가 병목이 된다. 이를 대체하기 위해 전자식 저장 매체인 <strong>SSD(Solid State Drive)</strong>가 많이 출시되고 있다.</p>
<p>SSD는 기존 하드 디스크 드라이브에서 데이터 저장용 플래터(원판)를 제거하고 플래시 메모리를 장착하므로 디스크 원판을 기계적으로 회전시킬 필요가 없어 아주 빨리 데이터를 읽고 쓸 수 있다. 플래시 메모리는 전원이 공급되지 않아도 데이터가 삭제되지 않는다. 또한 컴퓨터의 메모리(D-Ram)보다는 느리지만 기계식 하드 디스크 드라이브보다는 훨씬 빠르다.</p>
<p>디스크의 헤더를 움직이지 않고 한 번에 많은 데이터를 읽는 순차 I/O에서는 SSD가 하드 디스크 드라이브보다 조금 빠르거나 거의 비슷한 성능을 보이나, 랜덤 I/O에서는 훨씬 좋은 성능을 보여준다. 데이터베이스 서버는 랜덤 I/O를 통해 작은 데이터를 읽고 쓰는 작업이 대부분이므로 SSD의 장점은 DBMS용 스토리지에 최적이라고 볼 수 있다.</p>
<h3 id="812-랜덤-io와-순차-io">8.1.2 랜덤 I/O와 순차 I/O</h3>
<p><strong>랜덤 I/O</strong>는 하드 디스크 드라이브의 플래터를 돌려서 읽어야 할 데이터가 저장된 위치로 디스크 헤더를 이동시킨 다음 데이터를 읽는 것을 의미하는데, 사실 <strong>순차 I/O</strong>또한 이 작업 과정은 같다. 둘의 차이는 아래 이미지를 통해 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/4f2995ce-277f-410d-9d37-7e51c410f598/image.png" alt=""></p>
<p>순차 I/O는 3개의 페이지를 디스크에 기록하기 위해 1번 시스템 콜을 요청했지만, 랜덤 I/O는 3번 시스템 콜을 요청했다. 즉, 디스크에 기록해야 할 위치를 찾기 위해 순차 I/O는 디스크 헤드를 1번 움직였고 랜덤 I/O는 3번 움직였다.디스크에 데이터를 쓰고 읽는 데 걸리는 시간은 디스크 헤더를 움직여서 읽고 쓸 위치를 옮기는 단계에서 결정된다. 따라서 디스크의 성능은 디스크 헤더의 위치 이동 없이 얼마나 많은 데이터를 한 번에 기록하느냐에 의해 결정된다고 볼 수 있다. 위 이미지에서 순차 I/O는 랜덤 I/O보다 거의 3배 정도 빠르다고 볼 수 있으며, 여러 번 쓰기 또는 읽기를 요청하는 랜덤 I/O 작업이 작업 부하가 훨씬 더 크다. </p>
<p>데이터베이스의 대부분의 작업은 이러한 작은 데이터를 빈번히 읽고 쓰기 때문에 MySQL 서버에시는 그룹 커밋이나 바이너리 로그 버퍼 또는 InnoDB 로그 버퍼 등의 기능이 내장돼 있다. 디스크 원판을 가지지 않는 SSD 드라이브에서도 랜덤 I/O는 여전히 순차 I/O보다 전체 스루풋이 떨어진다.</p>
<p>사실 쿼리를 튜닝해서 랜덤 I/O를 순차 I/O로 바꿔서 실행할 방법은 그다지 많지 않으며, 일반적으로 쿼리 튜닝의 목적은 랜덤 I/O 자체를 줄이는 것이다. 여기서 랜덤 I/O를 줄인다는 것은 쿼리를 처리하는 데 꼭 필요한 데이터만 읽도록 쿼리를 개선하는 것을 의미한다.</p>
<h2 id="82-인덱스란">8.2 인덱스란?</h2>
<p><strong>인덱스</strong>가 무엇인지를 책에 비유하자면 책의 마지막에 있는 &quot;찾아보기&quot;이며, 책의 내용은 데이터 파일, &quot;찾아보기&quot;를 통해 알아낼 수 있는 페이지 번호는 데이터 파일에 저장된 레코드의 주소에 비유될 수 있다. DBMS가 데이터베이스 테이블의 모든 데이터를 검색해 원하는 결과를 가져오려면 시간이 오래 걸린다. 그래서 칼럼의 값과 해당 레코드가 저장된 주소를 키와 값의 쌍(Key-Value pair)으로 삼아 인덱스를 만들어 두는 것이다. 인덱스는 칼럼의 값을 주어진 순서로 미리 정렬해서 보관하여 원하는 데이터를 빠르게 가져올 수 있도록 한다.</p>
<p>DBMS에서 인덱스는 데이터가 저장될 때마다 항상 값을 정렬해야 하므로 데이터의 저장(<code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code>) 성능이 저하되지만, 이미 정렬되어 있는 데이터를 아주 빠르게 읽을(<code>SELECT</code>) 수 있다. 따라서 테이블에서의 인덱스 추가는 데이터의 저장 속도를 어디까지 희생할 수 있는지, 읽기 속도를 얼마나 더 빠르게 만들어야 하는지를 고려해 결정해야 한다. </p>
<p>이 책에서는 키(Key)라는 말과 인덱스(Index)는 같은 의미로 사용하겠다. 인덱스를 역할별로 구분해 본다면 <strong>프라이머리 키(Primary Key)</strong>와 <strong>보조 키(Secondary key)</strong>로 구분할 수 있다.</p>
<ul>
<li><p><strong>프라이머리 키</strong>는 그 레코드를 대표하는 칼럼의 값으로 만들어진 인덱스를 의미하며, 이는 테이블에서 해동 레코드를 식별할 수 있는 기준값이 되기 때문에 이를 <strong>식별자</strong>라고 부른다. 프라이머리 키는 <code>NULL</code>값과 중복을 허용하지 않는 것이 특징이다.</p>
</li>
<li><p><strong>세컨더리 인덱스(Secondary Index)</strong>는 프라이머리 키를 제외한 나머지 모든 인덱스다. 유니크 인덱스는 프라이머리 키와 성격이 비슷하고 프라이머리 키를 대체해서 사용할 수도 있다고 해서 대체 키라고도 하는데, 별도로 분류하기도 하고 세컨더리 인덱스로 분류하기도 한다.</p>
</li>
</ul>
<p>데이터 저장 방식(알고리즘)별로 구분할 경우 대표적으로 B-Tree 인덱스와 Hash 인덱스로 구분할 수 있다.</p>
<ul>
<li><p><strong>B-Tree 알고리즘</strong>은 가장 일반적으로 사용되는 인덱스 알고리즘이다. B-Tree 인덱스는 칼럼의 값을 변형하지 않고 원래의 값을 이용해 인덱싱하는 알고리즘이다.</p>
</li>
<li><p><strong>Hash 인덱스 알고리즘</strong>은 칼럼의 값으로 해시값을 계산해서 인덱싱하는 알고리즘으로, 매우 빠른 검색을 지원한다. 하지만 값을 변형해서 인덱싱하므로 전방(Prefix) 일치와 같이 값의 일부만 검색하거나 범위를 검색할 때는 해시 인덱스를 사용할 수 없다. Hash 인덱스는 주로 메모리 기반의 데이터베이스에서 많이 사용한다.</p>
</li>
</ul>
<p>데이터의 중복 허용 여부로 분류하면 <strong>유니크 인덱스(Unique)</strong>와 <strong>유니크하지 않은 인덱스(Non-Unique)</strong>로 구분할 수 있다. 인덱스가 유니크한지 아닌지는 단순히 같은 값이 1개만 존재하는지 1개 이상 존재할 수 있는지를 의미하지만, 실제 DBMS의 쿼리를 실행해야 하는 옵티마이저에게는 상당히 중요한 문제가 된다. 유니크 인덱스에 대해 동등 조건으로 검색한다는 것은 항상 1건의 레코드만 찾으면 더 찾지 않아도 된다는 것을 옵티마이저에게 알려주는 효과를 낸다.</p>
<p>인덱스의 기능별로 분류해보면 전문 검색용 인덱스나 공간 검색용 등을 예로 들 수 있다. 이 밖에도 수없이 많은 인덱스가 있겠지만, MySQL을 사용할 때는 이 두가지만으로도 충분하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 07. 데이터 암호화]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-07.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%95%94%ED%98%B8%ED%99%94</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-07.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%95%94%ED%98%B8%ED%99%94</guid>
            <pubDate>Sun, 26 Apr 2026 07:40:06 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="71-mysql-서버의-데이터-암호화">7.1 MySQL 서버의 데이터 암호화</h2>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/ea37990b-9e10-4dad-8512-b60bfeda3015/image.png" alt=""></p>
<p>MySQL 서버의 암호화 기능은 위 이미지와 같이 데이터베이스 서버와 디스크 사이의 데이터를 읽고 쓰기 지점에서 암호화 또는 복호화를 수행하는데, MySQL 서버에서 디스크 입출력 이외의 부분에서는 암호화 처리가 필요하지 않아 InnoDB I/O 레이어에서만 데이터의 암호화 및 복호화 과정이 실행된다.</p>
<p>MySQL 서버에서 사용자의 쿼리를 처리하는 과정에서 테이블의 데이터의 암호화 여부와 상관없이 동일한 처리 과정을 거치므로 MySQL 내부와 사용자 입장에서 아무런 차이가 없기 때문에 이러한 암호 방식을 <strong>TDE(Transparent Data Encryption)</strong> 혹은 <strong>Data at Rest Encryption</strong>이라고 한다.</p>
<h3 id="711-2단계-키-관리">7.1.1 2단계 키 관리</h3>
<p>MySQL 서버의 TDE에서 암호화 키는 키링(KeyRing) 플러그인에 의해 관리되며 다양한 플러그인이 제공되지만 마스터 키를 관리하는 방법만 다르며 내부적으로 작동하는 방식은 모두 동일하다. MySQL 서버의 키링 플러그인은 <strong>2단계(2-Tier) 키 관리 방식</strong>을 사용하는데, 아래 이미지는 2단계 키 관리 아키텍처를 보여준다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/10b4596c-c6f2-4d3e-89fe-a2d5aa7e87dd/image.png" alt=""></p>
<p>MySQL 서버의 데이터 암호화는 마스터 키(master key)와 테이블스페이스 키(tablespace key)라는 두 가지 종류의 키를 가지고 있으며, 테이블스페이스 키는 프라이빗 키(private key)라고도 한다. MySQL 서버는 HashiCorp Vault 같은 외부 키 관리 솔루션 또는 디스크의 파일에서 마스터 키를 가져오고, 암호화된 테이블이 생성될 때마다 해당 테이블을 위한 임의의 테이블스페이스 키를 발급한다. 그리고 MySQL 서버는 테이블스페이스키를 암호화해서 각 테이블의 데이터 파일 헤더에 저장한다. 이렇게 생성된 테이블스페이스 키는 테이블이 삭제되지 않는 이상 절대 변경되지 않지만, 이는 외부로 노출되지 않기 때문에 주기적으로 변경하지 않아도 보안상 취약점이 되지는 않는다.</p>
<p>하지만 마스터 키는 외부의 파일을 이용하기 때문에 노출될 가능성이 있어 주기적으로 변경해야 한다. 이는 다음과 같이 변경할 수 있다.</p>
<pre><code class="language-sql">mysql&gt; ALTER INSTANCE ROTATE INNODB MASTER KEY;</code></pre>
<p>마스터 키를 변경하면 MySQL 서버는 기존의 마스터 키를 이용해 각 테이블의 테이블스페이스 키를 복호화한 다음 새로운 마스터 키로 다시 암호화하며, 변경 과정에서 테이블스페이스 키 자체와 데이터 파일의 데이터는 전혀 변경되지 않는다.</p>
<p>MySQL 서버에서 2단계 암호화 방식을 사용하는 이유는 암호화 키 변경으로 인한 과도한 시스템 부하를 피하기 위해서다. 테이블스페이스 키가 변경된다면 MySQL 서버는 데이터 파일의 모든 데이터를 다시 복호화했다가 다시 암호화해야 한다. 이로 인해 키를 변경할 때마다 엄청난 작업을 해야 하며, 사용자 쿼리를 처리하는 데도 상당한 영향을 미치게 된다.</p>
<h3 id="712-암호화와-성능">7.1.2 암호화와 성능</h3>
<p>MySQL 서버의 암호화는 TDE 방식이기 때문에 디스크로부터 한 번 읽은 데이터 페이지는 복호화되어 InnoDB의 버퍼 풀에 적재되므로 암호화되지 않은 테이블과 동일한 성능을 보인다. 하지만 쿼리가 InnoDB 버퍼 풀에 존재하지 않는 데이터 페이지를 읽어야 하는 경우에는 복호화 과정을 거치기 때문에 쿼리 처리가 지연된다. 또한 암호화된 테이블이 변경되면 다시 디스크로 동기화될 때 암호화돼야 하기 때문에 디스크에 저장할 때도 추가로 시간이 걸린다. 하지만 데이터 페이지 저장은 MySQL 서버의 백그라운드 스레드가 수행하기 때문에 실제 사용자 쿼리가 지연되는 것은 아니다.</p>
<p>같은 테이블에 대해 암호화와 압축이 동시에 적용되면 MySQL 서버는 압축을 먼저 실행하고 암호화를 적용하는데, 이유는 다음과 같다.</p>
<ul>
<li>일반적으로 암호화된 결과문은 아주 랜덤한 바이트의 배열을 가지게 되는데, 이는 압축률을 상당히 떨어뜨린다. 그래서 최대한 압축 효율을 높이기 위해 사용자의 데이터를 그대로 압축해서 용량을 최소솨한 후 암호화를 적용한다.</li>
<li>암호화된 테이블의 데이터 페이지는 복호화된 상태로 InnoDB 버퍼 풀에 저장되지만, 압축된 데이터 페이지는 압축 또는 압축 해제의 모든 상태로 InnoDB 버퍼 풀에 존재할 수 있다. 따라서 암호화가 먼저 실행되고 압축이 적용된다면 InnoDB 버퍼 풀에 존재하는 데이터 페이지에 대해서도 매번 암복호화 작업을 수행하게 된다.</li>
</ul>
<h3 id="713-암호화와-복제">7.1.3 암호화와 복제</h3>
<p>MySQL 서버에서 기본적으로 모든 노드는 각자의 마스터 키를 할당해야 한다. 데이터베이스의 서버의 마스터 키를 관리하는 경우 로컬 디렉토리든 원격 키 관리 솔루션을 사용하든 소스 서버와 레플리카 서버는 서로 다른 마스터 키를 갖도록 설정해야 한다. 마스터 키 자체가 레플리카로 복제되지 않기 때문에 테이블스페이스 키 또한 복제되지 않아, 소스 서버와 레플리카 서버는 서로 각자의 마스터 키와 테이블스페이스 키를 관리하기 때문에 복제 멤버들의 데이터 파일은 암호화 되기 전 값이 동일하더라도 실제 암호화된 데이터가 저장된 데이터 파일의 내용은 완전히 달라진다.</p>
<p>MySQL 서버의 백업에서 TDE의 키링(Key Ring) 파일을 찾지 못하면 데이터를 복구할 수 없게 된다. 키링 파일을 데이터 백업과 별도로 백업한다면 마스터 키 로테이션 명령으로 TDE의 마스터 키가 언제 변경됐는지까지 기억하고 있어야 한다. 보안을 위해 데이터 파일과 키링 파일을 별도로 보관하는 것을 권장하지만, 마스터 키도 계속 변경될 수 있으므로 복구를 감안하고 백업 방식을 고려해야 한다.</p>
<h2 id="73-테이블-암호화">7.3 테이블 암호화</h2>
<h3 id="731-테이블-생성">7.3.1 테이블 생성</h3>
<p>TDE를 이용하는 테이블은 아래와 같이 일반적인 테이블 생성 구문 마지막에 <code>ENCRYPTION=&#39;Y&#39;</code> 옵션만 추가로 넣으면 되며, MySQL 서버의 모든 테이블에 대해 암호화를 적용하고자 한다면 <code>default_table_encryption</code> 시스템 변수를 ON으로 설정하면 된다.</p>
<pre><code class="language-sql">mysql&gt; CREATE TABLE tab_encrypted (
        id INT,
        data VARCHAR(100),
        PRIMARY KEY(id)
    ) ENCRYPTION=&#39;Y&#39;;</code></pre>
<h3 id="732-응용-프로그램-암호화와의-비교">7.3.2 응용 프로그램 암호화와의 비교</h3>
<p>응용 프로그램의 암호화와 MySQL 서버의 암호화 기능 중 선택해야 하는 상황이라면 고민할 필요 없이 MySQL 서버의 암호화 기능을 선택할 것을 권장한다. 물론 둘의 암호화 목적과 용도가 조금 다르나, MySQL 서버의 TDE 기능으로 암호화한다면 실행 중인 MySQL 서버에 로그인만 할 수 있다면 모든 데이터를 평문으로 확인할 수 있으나 응용 프로그램 암호화는 불가하다. 따라서 응용 프로그램에서의 암호화 기능은 서비스 요건 및 성능을 고려해 선택하고, MySQL 서버의 암호화 기능과 혼합해 사용한다면 더 안전한 서비스를 구축할 수 있을 것이다.</p>
<h3 id="733-테이블스페이스-이동">7.3.3 테이블스페이스 이동</h3>
<p>테이블을 다른 서버로 복사하거나 특정 테이블 데이터 파일만 백업했다가 복구하는 경우 테이블스페이스 이동(Export &amp; Import) 기능이 레코드를 덤프했다가 복구하는 방식보다 훨씬 효율적이고 빠르다. MySQL 서버에서 다음과 같이 <code>FLUSH TABLES</code> 명령으로 테이블스페이스를 익스포트(Export)할 수 있다.</p>
<pre><code class="language-sql">mysql&gt; FLUSH TABLES source_table FOR EXPORT;</code></pre>
<p>암호화되지 않은 테이블의 테이블스페이스 복사는 위 명령이 실행되면 MySQL <code>source_table</code>의 저장되지 않은 변경 사항을 모두 디스크로 기록하고, 더이상 <code>source_table</code>에 접근할 수 없게 잠금을 걸며 동시에 <code>source_table</code> 구조를 <code>source_table.cfg</code> 파일로 기록해둔다. 그러면 <code>source_table.ibd</code> 파일과 <code>source_table.cfg</code> 파일이 목적지 서버로 복사되며, <code>UNLCOK TABLES</code> 명령을 실행해 <code>source_table</code>을 사용할 수 있게 된다. </p>
<p>반면 TDE로 암호화된 테이블에 대해 위 명령을 실행하면 MySQL 서버는 임시로 사용할 마스터 키를 발급해서 <code>source_table.cfp</code>라는 파일로 기록한다. 그리고 암호화된 테이블의 테이블스페이스 키를 기존 마스터 키로 복호화한 후, 임시로 발급한 마스터 키를 이용해 다시 암호화해서 데이터 파일의 헤더 부분에 저장한다. 그래서 암호화된 테이블의 경우 테이블스페이스 이동 기능을 사용할 때는 반드시 데이터 파일과 임시 마스터 키가 저장된 <code>*.cfp</code> 파일을 함께 복사해야 한다. <code>*.cfg</code> 파일은 단순히 테이블의 구조만 가지고 있기 때문에 파일이 없어져도 경고만 발생하고 테이블스페이스를 복구할 수 있지만, <code>*.cfp</code> 파일이 없어지면 복구가 불가능해진다.</p>
<h2 id="74-언두-로그-및-리두-로그-암호화">7.4 언두 로그 및 리두 로그 암호화</h2>
<p>테이블의 암호화를 적용하더라도 디스크로 저장되는 데이터만 함호화되고 MySQL 서버 메모리에 존재하는 데이터는 복호화된 평문으로 관리되며, 이 평문 데이터가 테이블의 데이터 파일 이외의 디스크 파일로 기록되는 경우에는 여전히 평문으로 저장되므로 테이블에 암호화를 적용해도 리두 로그, 언두 로그, 그리고 복제를 위한 바이너리 로그에는 평문으로 저장된다. MySQL 8.016 버전부터는 <code>innodb_undo_log_encrypt</code> 시스템 변수와 <code>innodb_redo_log_encrypt</code> 시스템 변수를 이용해 리두 로그와 언두 로그를 암호화된 상태로 저장할 수 있게 개선됐다.</p>
<p>리두 로그와 언두 로그는 암호화가 적용되어도 해당 테이블의 모든 데이터가 암호화를 적용할 수 없다. 그래서 MySQL 서버는 리두 로그나 언두 로그를 평문으로 저장하다가 암호화가 활성화되면 그때부터 생성되는 리두 로그나 언두 로그만 암호화해서 저장하며, 암호화를 비활성화하는 경우에는 그때부터 저장되는 로그만 평문으로 저장한다. 그래서 리두 로그와 언두 로그는 암호화를 활성화했다가 비활성화한다고 해서 즉시 암호화에 사용된 키가 불필요해지는 것이 아니다.</p>
<p>리두 로그와 언두 로그 데이터 모두 각각의 테이블스페이스 키로 암호화되고, 테이블스페이스 키는 다시 마스터 키로 암호화된다. 리두 로그와 언두 로그 데이터의 암호화에 사용되는 테이블스페이스 키는 실제 테이블의 암호화에 사용된 테이블스페이스 키가 아닌 리두 로그와 언두 로그 파일을 위한 프라이빗 키를 의미한다. 즉 리두 로그와 언두 로그를 위한 각각의 프라이빗 키가 발급되고, 해당 프라이빗 키는 마스터 키로 암호화되어 리두 로그 파일과 언두 로그 파일의 헤더에 저장되는 것이다.</p>
<h2 id="75-바이너리-로그-암호화">7.5 바이너리 로그 암호화</h2>
<p>테이블 암호화가 적용돼도 바이너리 로그와 릴레이 파일은 평문을 저장한다. 바이너리 로그는 의도적으로 상상히 긴 시간 동안 보관하는 서비스도 있고 때로는 증분 백업(Incremental Backup)을 위해 바이너리 로그를 보관하므로 바이너리 로그 파일의 암호화는 상황에 따라 중요도가 높아질 수도 있다.</p>
<p>바이너리 로그와 릴레이 로그 파일 암호화 기능은 디스크에 저장된 로그 파일에 대한 암호화만 담당하고, MySQL 서버의 메모리 내부 또는 소스 서버와 레플리카 서버 간의 네트워크 구간에서 로그 데이터를 암호화하지는 않는다. 복제 멤버 간의 네트워크 구간에서도 바이너리 로그를 암호화하고자 한다면 MySQL 복제를 위한 계정이 SSL을 사용하도록 설정하면 된다.</p>
<h3 id="751-바이너리-로그-암호화-키-관리">7.5.1 바이너리 로그 암호화 키 관리</h3>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/3a318768-ca9f-482c-88ab-d35596d045ff/image.png" alt=""></p>
<p>위 이미지는 바이너리 로그 파일의 암호화 방식으로, 바이너리 로그와 릴레이 로그 파일의 데이터는 파일 키로 암호화해서 디스크로 저장하고, 파일 키는 <strong>바이너리 로그 암호화 키</strong>로 암호화해서 각 바이너리 로그와 릴레이 로그 파일의 헤더에 저장된다. 즉 <strong>바이너리 로그 암호화 키</strong>는 테이블 암호화의 마스터 키와 동일한 역할을 하며, 파일 키는 바이너리 로그와 릴레이 로그 파일 단위로 자동으로 생성되어 해당 로그 파일의 데이터 암호화에서만 사용된다.</p>
<h3 id="752-바이너리-로그-암호화-키-변경">7.5.2 바이너리 로그 암호화 키 변경</h3>
<p>바이너리 로그 암호화 키는 다음과 같이 변경할 수 있다.</p>
<pre><code class="language-sql">mysql&gt; ALTER INSTANCE ROTATE BINLOG MASTER KEY;</code></pre>
<p>바이너리 로그 암호화 키가 변경되면 다음과 같은 과정을 거친다.</p>
<ol>
<li>증가된 시퀀스 번호와 함께 새로운 바이너리 로그 암호화 키 발급 후 키링 파일에 저장</li>
<li>바이너리 로그 파일과 릴레이 로그 파일 스위치(새로운 로그 파일로 로테이션)</li>
<li>새로 생성되는 바이너리 로그와 릴레이 로그 파일의 암호화를 위해 파일 키를 생성하고, 파일 키는 바이너리 로그 파일 키(마스터 키)로 암호화해서 각 로그 파일에 저장</li>
<li>기존 바이너리 로그와 릴레이 로그 파일의 파일 키를 읽어서 새로운 바이너리 로그 파일 키로 암호화해서 다시 저장(암호화되지 않은 로그 파일은 무시)</li>
<li>모든 바이너리 로그와 릴레이 로그 파일이 새로운 바이너리 로그 암호화 키로 다시 암호화됐다면 기존 바이너리 로그 암호화 키를 키링 파일에서 제거</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 06. 데이터 압축]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-06.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%95%95%EC%B6%95</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-06.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%95%95%EC%B6%95</guid>
            <pubDate>Tue, 21 Apr 2026 07:00:02 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h1 id="06-데이터-압축">06. 데이터 압축</h1>
<h2 id="61-페이지-압축">6.1 페이지 압축</h2>
<p><strong>페이지 압축</strong>은 Transparent Page Compression 이라고도 불리는데, MySQL 서버가 디스크에 저장하는 시점에서 데이터 페이지가 압축되어 저장되고, 반대로 MySQL 서버가 디스크에서 데이터 페이지를 읽어올 때 압축이 해제되기 때문이다. 즉 버퍼 풀에 데이터 페이지가 한 번 적재되면 InnoDB 스토리지 엔진은 압축이 해제된 상태로만 데이터 페이지를 관리한다. 그래서 MySQL 서버의 내부 코드에서는 압축 여부와 관계없이 <strong>투명</strong>하게 작동한다. 이 과정에서 어떤 데이터 페이지를 압축한 결과가 용량이 얼마나 될지 예측이 불가능한데 적어도 하나의 테이블은 동일한 크기의 페이지(블록)로 통일돼야 한다는 것이다.</p>
<p>그래서 페이지 압축 기능은 운영체제별로 특정 버전의 파일 시스템에서만 지원되는 <strong>펀치 홀(Punch hole)</strong>이라는 기능을 사용한다. 운영체제의 블록 사이즈가 512바이트인 경우, 페이지 압축이 작동하는 방식을 살펴보면 아래와 같다. MySQL 서버는 특정 테이블에 대해 16KB 크기의 페이지를 유지하면서도 압축된 다양한 크기의 데이터 페이지를 디스크에 저장하고 압축된 만큼의 공간을 절약할 수 있다.</p>
<blockquote>
</blockquote>
<ol>
<li>16KB 페이지 압축 (압축 결과 7KB 가정)</li>
<li>MySQL 서버는 압축 결과 7KB 및 9KB 빈 데이터 기록</li>
<li>디스크에 데이터 기록 후, 7KB 이후 공간 9KB에 대해 펀치 홀을 생성</li>
<li>파일 시스템은 7KB만 남기고 나머지 디스크의 9KB 공간은 운영체제로 반납</li>
</ol>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/66acf25e-be8d-4cc8-9d5d-457a85300ff0/image.png" alt=""></p>
<p>그러나 MySQL 서버의 페이지 압축이 가진 문제는 펀치 홀 기능은 운영체제뿐만 아니라 하드웨어 자체에서도 해당 기능을 지원해야 사용 가능하며, 파일 시스템 관련 명령어가 펀치 홀을 지원하지 못한다는 것이다. 이러한 이유로 실제 페이지 압축은 많이 사용되지 않는 상태이다.</p>
<h2 id="62-테이블-압축">6.2 테이블 압축</h2>
<p>테이블 압축은 운영체제나 하드웨어에 대한 제약 없이 사용할 수 있어 활용도가 더 높으며 디스크의 데이터 파일 크기를 줄일 수 있는 이점은 존재하나 아래와 같은 몇 가지 단점이 존재한다.</p>
<ul>
<li>버퍼 풀 공간 활용률이 낮음</li>
<li>쿼리 처리 성능이 낮음</li>
<li>빈번한 데이터 변경 시 압축률이 떨어짐</li>
</ul>
<h3 id="621-압축-테이블-생성">6.2.1 압축 테이블 생성</h3>
<p>테이블 압축을 사용하기 위해서는 압축을 사용하려는 테이블이 별도의 테이블 스페이스를 사용해야 하며, 이는 <code>innodb_file_per_table</code> 시스템 변수가 ON으로 설정된 상태에서 테이블이 생성돼야 한다. 또한 테이블 압축을 사용하는 테이블은 테이블을 생성할 때 <code>ROW_FORMAT=COMPRESSED</code> 옵션을 명시해야 하며, 추가로 <code>KEY_BLOCK_SIZE</code> 옵션을 이용해 2n(n은 2 이상)이상의 압축된 페이지 타깃 크기를 명시할 수 있다. InnoDB 스토리지 엔진의 페이지 크기가 16KB라면 <code>KEY_BLOCK_SIZE</code>는 4KB 또는 8KB만 설정할 수 있으며, 페이지 크기가 32KB 또는 64KB인 경우 테이블 압축을 적용할 수 없다. 아래는 테이블 압축의 작동 방식 및 이를 나타낸 이미지이다. 테이블 압축에서는 InnoDB 스토리지 엔진의 InnoDB I/O 레이어에서는 아무런 역할을 하지 않는다는 것을 볼 수  있다.</p>
<blockquote>
</blockquote>
<ol>
<li>16KB 데이터 페이지 압축
1.1 압축된 결과가 8KB 이하이면 그대로 디스크 저장 (압축 완료)
1.2 압축된 결과가 8KB 초과시 원본 페이지 스플릿, 2개의 페이지에 8KB씩 저장</li>
</ol>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/33dd248b-8a39-4762-a08b-35e802046b28/image.png" alt=""></p>
<p>테이블 압축 방식에서 가장 중요한 것은 원본 데이터 페이지의 압축 결과가 목표 크기(<code>KEY_BLOCK_SIZE</code>)보다 작거나 같을 때까지 페이지를 스플릿하는 것이다. 그래서 목표 크기가 잘못 설정되면 MySQL 서버의 처리 성능이 급격히 떨어질 수 있다.</p>
<h3 id="622-key_block_size-결정">6.2.2 KEY_BLOCK_SIZE 결정</h3>
<p>테이블 압축에서 가장 중요한 부분은 압축된 결과가 어느 정도가 될지를 예측해서 <code>KEY_BLOCK_SIZE</code>를 결정하는 것으로, 테이블 압축 적용 전에 <code>KEY_BLOCK_SIZE</code>를 4KB 또는 8KB로 테이블을 생성해 샘플 데이터를 저장한 뒤 적절한지 판단하는 것이 좋다.</p>
<p><code>KEY_BLOCK_SIZE</code>를 결정하며 주의해야 할 점은 압축 실패율이 높다고 해서 압축을 사용하지 말아야 한다는 것을 의미하지는 않는다. 예를 들어, <code>INSERT</code>만 되는 로그 테이블의 경우 한 번 <code>INSERT</code>되면 이후 다시는 변경되지 않으므로 한 번 정도는 압축 시도가 실패해 페이지 스플릿 후 재압축한다고 하더라도 전체적으로 데이터 파일의 크기가 큰 폭으로 줄어든다면 큰 손해는 아닐 것이다. 물론 반대로 테이블의 데이터가 매우 빈번하게 조회되고 변경된다면 압축을 고려하지 않는 것이 좋다. 압축 알고리즘은 많은 CPU 자원을 소모한다.</p>
<h3 id="623-압축된-페이지의-버퍼-풀-적재-및-사용">6.2.3 압축된 페이지의 버퍼 풀 적재 및 사용</h3>
<p>InnoDB 스토리지 엔진은 압축된 테이블의 데이터 페이지를 버퍼풀에 적재하면 압축된 상태와 압축이 해제된 상태 2개 버전을 관리한다. 따라서 디스크에 읽은 상태 그대로의 데이터 페이지 목록을 관리하는 LRU 리스트와 압축된 페이지들의 압축 해제 버전인 Unzip_LRU 리스트를 별도로 관리하게 된다.</p>
<p>LRU 리스트는 압축이 적용되지 않은 테이블의 데이터 페이지와 압축이 적용된 테이블의 압축된 데이터 페이지를 모두 가질 수 있으며, Unzip_LRU 리스트는 압축이 적용된 테이블에서 읽은 압축을 해제한 상태의 데이터 페이지 목록을 관리한다.</p>
<p>결국 InnoDB 스토리지 엔진은 압축된 테이블에 대해서는 버퍼 풀의 공간을 이중으로 사용함으로써 메모리를 낭비하며, 압축된 페이지에서 데이터를 읽거나 변경하기 위해서 압축을 해제하는 과정에서 CPU를 상대적으로 많이 소모하게 된다. 이러한 두 가지 단점을 보완하기 위해 Unzip_LRU 리스트를 별도로 관리하고 있다가 MySQL 서버로 유입되는 요청 패턴에 따라서 적절히 다음과 같은 처리를 수행한다.</p>
<ul>
<li>InnoDB 버퍼 풀의 공간이 필요한 경우 LRU 리스트에서 원본 데이터 페이지(압축된 형태)는 유지하고, Unzip_LRU 리스트에서 압축 해제된 버전은 제거해서 버퍼 풀의 공간을 확보한다.</li>
<li>압축된 데이터 페이지가 자주 사용되는 경우 Unzip_LRU 리스트에 압축 해제된 페이지를 계속 유지하면서 압축 및 해제 작업을 최소화한다.</li>
<li>압축된 페이지가 사용되지 않아서 LRU 리스트에서 제거되는 경우에는 Unzip_LRU 리스트에서도 함께 제거된다.</li>
</ul>
<p>InnoDB 스토리지 엔진은 버퍼 풀에서 압축 해제된 버전의 데이터 페이지를 적절한 수준으로 유지하기 위해 아래와 같은 어댑티브 알고리즘을 사용한다.</p>
<ul>
<li>CPU 사용량이 높은 서버에서는 가능하면 압축과 압축 해제를 피하기 위해 Unzip_LRU의 비율을 높여서 유지한다.</li>
<li>Disk IO 사용량이 높은 서버에서는 가능하면 Unzip_LRU 리스트의 비율을 낮춰서 InnoDB 버퍼 풀의 공간을 더 확보하도록 작동한다.</li>
</ul>
<h3 id="624-테이블-압축-관련-설정">6.2.4 테이블 압축 관련 설정</h3>
<p>테이블 압축 관련 시스템 변수들로, 모두 페이지 압축 실패율을 낮추기 위해 필요한 튜닝 포인트를 제공한다.</p>
<ul>
<li><p><code>innodb_cmp_per_index_enabled</code>
테이블 압축이 사용된 테이블의 모든 인덱스별로 압축 성공 및 압축 실행 횟수를 수집하도록 설정한다.</p>
</li>
<li><p><code>innodb_compression_level</code>
InnoDB의 테이블이 지원하는 zlib 압축 알고리즘의 압축률을 0~9까지의 값으로 설정할 수 있으며 값이 작을수록 압축 속도는 빨라지며 저장 공간은 커질 수 있다. 기본값은 6이다.</p>
</li>
<li><p><code>innodb_compression_failure_threshold_pct</code>, <code>innodb_compression_pad_pct_max</code>
테이블 단위 압축 실패율이 <code>innodb_compression_failure_threshold_pct</code>시스템 설정 값보다 커지면 압축을 실행하기 전 원본 데이터 페이지의 끝에 의도적으로 일정 크기의 빈 공간을 추가해 압축률을 높여 압축 결과가 <code>KEY_BLOCK_SIZE</code>보다 작아지게 한다. 이때 추가는 빈 공간을 패딩(Padding)이라고 하며, 최대 크기는 <code>innodb_compression_pad_pct_max</code> 이상을 넘을 수 없다.</p>
</li>
<li><p><code>innodb_log_compressed_pages</code>
MySQL 서버가 비정상적으로 종료됐다가 다시 시작되는 경우 압축 알고리즘의 버전 차이가 있더라도 복구 과정이 실패하지 않도록 InnoDB 스토리지 엔진은 압축된 데이터 페이지를 그대로 리두 로그에 기록한다. 기본값은 ON이다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 05. 트랜잭션과 잠금]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-05.-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EC%9E%A0%EA%B8%88</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-05.-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EC%9E%A0%EA%B8%88</guid>
            <pubDate>Mon, 20 Apr 2026 07:40:12 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/youhyuk_/post/09ebe23c-1685-4c73-9fd8-8d71449b878a/image.jpg" alt=""></p>
<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="51-트랜잭션">5.1 트랜잭션</h2>
<h3 id="511-mysql에서의-트랜잭션">5.1.1 MySQL에서의 트랜잭션</h3>
<p><strong>트랜잭션</strong>은 하나의 논리적인 작업 셋에 쿼리의 개수와 관계없이 논리적인 작업 셋 자체가 100% 적용되거나 아무것도 적용되지 않아야 함을 보장해 주는 것이다.</p>
<p>InnoDB와 달리 MyISAM, MEMORY 스토리지 엔진의 경우 트랜잭션을 지원하지 않는다. 따라서 쿼리 중 일부 오류가 발생하는 경우 이전 정상적으로 작동한 부분은 업데이트 되며, 오류가 발생한 부분만 업데이트 되지 않는 <strong>부분 업데이트(Partial Update)</strong> 현상이 발생하며, 이는 테이블 데이터 정합성을 맞추는데 상당히 어려운 문제를 만들어 낸다.</p>
<p>트랜잭션을 상당히 복잡하고 어려운 기능으로 생각할 수 있지만, 그만큼 애플리케이션 개발에서 고민해야 할 문제를 줄여주는 아주 필수적인 DBMS의 기능이다.</p>
<h3 id="512-주의사항">5.1.2 주의사항</h3>
<p>트랜잭션 또한 DBMS의 커넥션과 동일하게 꼭 필요한 최소의 코드에만 적용하여 범위를 최소화하는 것이 좋다. 또한 네트워크 작업이 있는 경우에는 반드시 트랜잭션에서 배제해야 한다. 이는 DBMS 서버 과부하 혹은 위험한 상태로 빠지게 할 수 있다.</p>
<h2 id="52-mysql-엔진의-잠금">5.2 MySQL 엔진의 잠금</h2>
<p>MySQL에서 사용되는 잠금은 크게 스토리지 엔진 레벨과 MySQL 엔진 레벨로 나눌 수 있다. MySQL 엔진은 MySQL 서버에서 스토리지 엔진을 제외한 나머지 부분으로 이해하면 된다. MySQL 엔진 레벨의 잠금은 모든 스토리지 엔진에 영향을 미치지만, 스토리지 엔진 레벨의 잠금은 스토리지 엔진 간 상호 영향을 미치지는 않는다.</p>
<h3 id="글로벌-락">글로벌 락</h3>
<p><strong>글로벌 락(GLOBAL LOCK)</strong>은 <code>FLUSH TABLES WITH READ LOCK</code> 명령으로 획득할 수 있으며, MySQL에서 제공하는 잠금 가운데 가장 범위가 크다. 한 세션에서 글로벌 락을 획득하면 다른 세션에서 <code>SELECT</code>를 제외한 대부분의 DDL 문장이나 DML 문장을 실행하는 경우 글로벌 락이 해제될 때까지 해당 문장이 대기 상태로 남는다. 글로벌 락이 영향을 미치는 범위는 MySQL 서버 전체이며, 작업 대상 테이블이나 데이터베이스가 다르더라도 동일하게 영향을 미친다.</p>
<p>InnoDB 스토리지 엔진은 트랜잭션을 지원하기 때문에 일관된 데이터 상태를 위해 데이터 변경 작업을 멈출 필요는 없으며, MySQL 8.0부터는 InnoDB가 기본 스토리지 엔진으로 채택되면서 조금 더 가벼운 글로벌 락의 필요성이 생겨 Xtrabackup이나 Enterprise Backup과 같은 백업 툴들의 안정적인 실행을 위해 <strong>백업 락</strong>이 도입됐다.</p>
<p>특정 세션에서 백업 락을 획득하면 모든 세션에서 다음과 같이 테이블의 스키마나 사용자의 인증 관련 정보를 변경할 수 없게 되지만 일반적인 테이블의 데이터 변경은 허용된다.</p>
<ul>
<li>데이터베이스 및 테이블 등 모든 객체 생성 및 변경, 삭제</li>
<li><code>REPAIR TABLE</code>과 <code>OPTIMIZE TABLE</code> 명령</li>
<li>사용자 관리 및 비밀번호 변경</li>
</ul>
<h3 id="522-테이블-락">5.2.2 테이블 락</h3>
<p><strong>테이블 락(Table Lock)</strong>은 개별 테이블 단위로 설정되는 잠금이며, 명시적 또는 묵시적으로 특정 테이블의 락을 획득할 수 있다.  명시적으로는 <code>LOCK TABLES table_name [ READ | WRITE ]</code> 명령으로 특정 테이블의 락을 획득할 수 있으며, MyISAM, InnoDB 스토리지 엔진을 사용하는 테이블 모두 동일하게 설정할 수 있다. 또한 <code>UNLOCK TABLES</code> 명령으로 잠금을 반납할 수 있다. 명시적으로 테이블을 잠그는 작업을 글로벌 락과 동일하게 온라인 작업에 상당한 영향을 미치므로 특별한 상황이 아닌 경우 거의 사용하지 않는다.</p>
<p>묵시적인 테이블 락은 MyISAM이나 MEMORY 테이블에 데이터를 변경하는 쿼리를 실행하면 자동으로 획득됐다가 쿼리가 완료된 후 자동 해제된다. 하지만 InnoDB 테이블의 경우 스토리지 엔진 차원에서 레코드 기반의 잠금을 제공하기 때문에 단순 데이터 변경 쿼리로 인해 묵시적인 테이블 락이 설정되지는 않는다. 정확히는 테이블 락이 설정되지만 대부분의 데이터 변경(DML) 쿼리에서는 무시되고 스키마를 변경하는 쿼리(DDL)의 경우에만 영향을 미친다.</p>
<h3 id="523-네임드-락">5.2.3 네임드 락</h3>
<p><strong>네임드 락(Named Lock)</strong>은 <code>GET_LOCK()</code> 함수를 이용해 임의의 문자열에 대해 잠금을 설정할 수 있다. 네임드 락은 자주 사용되지는 않으며, 여러 클라이언트가 상호 동기화를 처리해야 하는 상황 혹은 많은 레코드에 대해서 복잡한 요건으로 레코드를 변경하는 트랜잭션에 유용하게 사용할 수 있다. MySQL 8.0 버전부터는 네임드 락을 중첩해서 사용할 수 있게 됐으며, 현재 세션에서 획득한 네임드 락을 한 번에 모두 해제하는 기능도 추가됐다.</p>
<h3 id="524-메타데이터-락">5.2.4 메타데이터 락</h3>
<p><strong>메타데이터 락(Metadata Lock)</strong>은 데이터베이스 객체의 이름이나 구조를 변경하는 경우에 획득하는 잠금이다. 메타데이터 락은 명시적으로 획득하거나 해제하는 것이 아닌 <code>RENAME TABLE tab_a TO tab_b</code> 같이 테이블 이름을 변경하는 경우 자동으로 획득하는 잠금이다. <code>RENAME TABLE</code> 명령의 경우 원본 이름과 변경될 이름 두 개 모두 한꺼번에 잠금을 설정한다. 이는 기존 데이터 구조 변경 등 실시간으로 테이블을 바꿔야 하는 경우에 사용된다.</p>
<h2 id="53-innodb-스토리지-엔진-잠금">5.3 InnoDB 스토리지 엔진 잠금</h2>
<p><strong>InnoDB 스토리지 엔진</strong>은 MySQL에서 제공하는 잠금과는 별개로 스토리지 엔진 내부에서 레코드 기반의 잠금 방식을 탑재하고 있어 MyISAM보다는 훨씬 뛰어난 동시성 처리를 제공할 수 있다. 이전에는 이원화된 잠금 처리 탓에 InnoDB 스토리지 엔진에서 사용되는 잠금에 대한 정보를 확인하기 어려웠으나 최근 버전에서는 MySQL 서버의 <code>information_schema</code> 데이터베이스에 존재하는 <code>INNODB_TRX</code>, <code>INNODB_LOCKS</code>, <code>INNODB_LOCK_WAITS</code> 테이블을 조인해서 조회해 InnoDB의 트랜잭션과 잠금, 잠금 대기 중인 트랜잭션의 목록을 조회할 수 있는 방법이 도입되었으며 <code>Performance Schema</code>를 이용해 InnoDB 스토리지 엔진의 내부 잠금에 대한 모니터링 방법도 추가됐다.</p>
<h3 id="531-innodb-스토리지-엔진의-잠금">5.3.1 InnoDB 스토리지 엔진의 잠금</h3>
<p>InnoDB 스토리지 엔진은 레코드 기반의 잠금 기능을 제공하며, 잠금 정보가 상당히 작은 공간으로 관리되기 때문에 레코드 락이 레벨업되는 경우(락 에스컬레이션)는 없다. 일반 상용 DBMS와 다르게 InnoDB 스토리지 엔진에서는 레코드 락뿐 아니라 레코드와 레코드 사이 간격을 잠그는 갭(GAP) 락이라는 것이 존재한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/f2356110-1e9e-4284-90d0-410f3e2b9380/image.png" alt=""></p>
<h4 id="레코드-락">레코드 락</h4>
<p>레코드 자체만을 잠그는 것을 <strong>레코드 락(Record lock)</strong>이라고 하며, 다른 상용 DBMS의 레코드 락과 동일한 역할을 하지만, InnoDB 스토리지 엔진은 레코드 자체가 아니라 인덱스의 레코드를 잠근다는 것이 중요한 차이점이다. 인덱스가 하나도 없는 테이블이더라도 내부적으로 자동 생선된 클러스터 인덱스를 이용해 잠금을 생성한다.</p>
<h4 id="갭-락">갭 락</h4>
<p><strong>갭 락(Gap lock)</strong>은 레코드 자체가 아니라 레코드와 바로 인접한 레코드 사이의 간격만을 잠그는 것을 의미한다. 갭 락의 역할은 레코드와 레코드 사이의 간격에 새로운 레코드가 생성(<code>INSERT</code>)되는 것을 제어하는 것이다.</p>
<h4 id="넥스트-키-락">넥스트 키 락</h4>
<p>레코드 락과 갭 락을 합쳐놓은 형태의 잠금을 <strong>넥스트 키 락(Next key lock)</strong>이라고 한다. InnoDB의 갭 락이나 넥스트 키 락은 바이너리 로그에 기록되는 쿼리가 레플리카 서버에서 실행될 때 소스 서버에서 만들어 낸 결과와 동일한 결과를 만들어내도록 보장하는 것이 주목적이나 이로 인한 데드락이 발생하거나 다른 트랜잭션을 기다리게 만드는 일이 자주 발생한다. 가능하다면 바이너리 로그 포맷을 ROW 형태로 바꿔서 넥스트 키 락이나 갭 락을 줄이는 것이 좋다.</p>
<h4 id="자동-증가-락">자동 증가 락</h4>
<p>MySQL에서는 자동 증가하는 숫자 값을 추출하기 위해 <code>AUTO_INCREMENT</code>라는 칼럼 속성을 제공한다. 해당 칼럼이 사용된 테이블에 동시에 여러 레코드가 <code>INSERT</code> 되는 경우, 저장되는 각 레코드는 중복되지 않고 저장된 순서대로 증가하는 일련번호 값을 가져와야 하는데 이를 위해 InnoDB 스토리지 엔진은 내부적으로 <strong><code>AUTO_INCREMENT</code> 락(Auto increment lock)</strong>이라고 하는 테이블 수준의 잠금을 사용한다.</p>
<p><code>AUTO_INCREMENT</code>락은 <code>INSERT</code>,<code>REPLACE</code>와 같이 새로운 레코드를 저장하는 쿼리에서만 필요하다. InnoDB의 다른  잠금과는 달리 <code>AUTO_INCREMENT</code>락은 트랜잭션과 관계없이 <code>INSERT</code>와 <code>REPLACE</code> 문장에서 <code>AUTO_INCREMENT</code> 값을 가져오는 순간만 락이 걸렸다가 즉시 해제되며, 테이블에 하나만 존재하는 락으로 두 개의 <code>INSERT</code> 쿼리가 동시에 실행되는 경우 하나의 쿼리가 <code>AUTO_INCREMENT</code>락을 걸면 나머지 쿼리는 이를 기다려야 한다.</p>
<h3 id="532-인덱스와-잠금">5.3.2 인덱스와 잠금</h3>
<p>InnoDB의 잠금은 레코드를 잠그는 것이 아니라 인덱스를 잠그는 방식으로 처리되므로 변경해야 할 레코드를 찾기 위해 검색한 인덱스의 레코드를 모두 락을 걸어야 한다.</p>
<p>만약 <code>employees</code> 테이블에 <code>first_name</code> 칼럼만 멤버로 담긴 <code>ix_firstname</code> 인덱스만 존재할 때 <code>first_name=&#39;GEORGI&#39;</code>이고 <code>last_name=&#39;Klassen&#39;</code>인 사원의 입사 일자를 오늘로 변경하는 쿼리를 실행한다고 가정하자.</p>
<pre><code class="language-sql">mysql&gt; UPDATE employees SET hire_date=NOW() WHERE firts_name=&#39;Georgi&#39; AND last_name=&#39;Klassen&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/92ec5a33-cb6f-4e5b-927f-0f38e7ff97f7/image.png" alt=""></p>
<p>위 이미지는 <code>UPDATE</code> 문장이 실행되었을 때 잠기는 레코드를 나타낸다. 해당 <code>UPDATE</code> 문장의 조건에서 인덱스를 이용할 수 있는 조건은 <code>first_name=&#39;Georgi&#39;</code>이며 <code>last_name</code> 칼럼은 인덱스에 없기 때문에 <code>first_name=&#39;Georgi&#39;</code>인 레코드는 모두 잠기게 된다.</p>
<p>만약 위 예제의 인덱스가 하나도 없는 경우, 테이블을 풀 스캔하면서 <code>UPDATE</code> 작업을 하며 테이블에 있는 모든 레코드를 잠그게 된다. 이것이 MySQL의 방식이며, MySQL의 InnoDB에서 인덱스 설계가 중요한 이유이다.</p>
<h3 id="533-레코드-수준의-잠금-확인-및-해제">5.3.3 레코드 수준의 잠금 확인 및 해제</h3>
<p>InnoDB 스토리지 엔진을 사용하는 레코드 수준의 잠금은 테이블의 레코드 각각에 잠금이 걸리므로 그 레코드가 자주 사용되지 않는다면 오랜 시간 동안 잠겨진 상태로 남아 있어도 잘 발견되지 않는다. 예전 버전의 MySQL 서버에서는 레코드 잠금에 대한 메타 정보를 제공하지 않아 더더욱 어려운 부분이다. 하지만 MySQL 5.1부터는 레코드 잠금과 잠금 대기에 대한 조회가 가능하므로 쿼리 하나만 실행해 보면 잠금과 잠금 대기를 바로 확인할 수 있다. MySQL 8.0 버전에서는 <code>performance_schema</code>의 <code>data_locks</code>와 <code>data_lock_waits</code> 테이블을 통해 잠금과 잠금 대기 순서와 같은 메타 정보를 확인할 수 있다.</p>
<h2 id="54-mysql의-격리-수준">5.4 MySQL의 격리 수준</h2>
<p>트랜잭션의 <strong>격리 수준(isolation level)</strong>이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것이다. 격리 수준은 4가지가 존재하며, 격리 정도가 높아질 수록 동시 처리 성능도 떨어지는 것이 일반적이다.</p>
<p>데이터베이스의 격리 수준을 이야기하면 항상 함께 언급되는 세 가지 부정합의 문제점이 있다. 이 세 가지 부정합의 문제는 격리 수준의 레벨에 따라 발생할 수도 있고 발생하지 않을 수도 있다.</p>
<table>
<thead>
<tr>
<th></th>
<th align="center">DIRTY READ</th>
<th align="center">NON-REPEATABLE READ</th>
<th align="center">PHANTOM READ</th>
</tr>
</thead>
<tbody><tr>
<td>READ UNCOMMITTED</td>
<td align="center">발생</td>
<td align="center">발생</td>
<td align="center">발생</td>
</tr>
<tr>
<td>READ COMMITTED</td>
<td align="center">없음</td>
<td align="center">발생</td>
<td align="center">발생</td>
</tr>
<tr>
<td>REPEATABLE READ</td>
<td align="center">없음</td>
<td align="center">없음</td>
<td align="center">발생(InnoDB는 없음)</td>
</tr>
<tr>
<td>SERIALIZABLE</td>
<td align="center">없음</td>
<td align="center">없음</td>
<td align="center">없음</td>
</tr>
</tbody></table>
<h3 id="541-read-uncommitted">5.4.1 READ UNCOMMITTED</h3>
<p><strong>READ UNCOMMITTED</strong> 격리 수준에서는 아래 이미지와 같이 각 트랜잭션의 변경 내용이 <code>COMMIT</code>이나 <code>ROLLBACK</code> 여부에 상관없이 다른 트랜잭션에서 보인다. 아래 이미지는 다른 트랜잭션이 사용자 B가 실행하는 <code>SELECT</code> 쿼리의 결과에 어떤 영향을 미치는지를 보여주는 예제다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/a58eb605-23d7-4ee6-9a4c-d3d7e452fb31/image.png" alt=""></p>
<p>위 이미지미에서 사용자 A는 <code>emp_no</code>가 500000이고 <code>first_name</code>이 &quot;Lara&quot;인 새로운 사원을 <code>INSERT</code>한다. 사용자 B가 변경된 내용을 커밋하기 전에 <code>emp_no=500000</code>인 사원을 검색하고 있으며 이를 조회할 수 있다. 만약 사용자 A가 처리 도중 롤백한다고 하더라도 사용자 B는 &quot;Lara&quot;가 정상적인 사원이라고 생각하고 계속 처리할 것이다.</p>
<p>이처럼 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상을 <strong>더티 리드(Dirty Read)</strong>라 하고, 더티 리드가 허용되는 격리 수준이 <strong>READ UNCOMMITTED</strong>다. 더티 리드 현상은 데이터가 나타났다가 사라졌다 하는 현상을 초래해 혼란스럽게 하고, RDBMS 표준에서는 트랜잭션의 격리 수준으로 인정하지 않을 정도로 정합성에 문제가 많은 격리 수준으로 MySQL을 사용한다면 READ COMMITTED 이상의 격리 수준을 사용할 것을 권장한다.</p>
<h3 id="542-read-committed">5.4.2 READ COMMITTED</h3>
<p><strong>READ COMMITTED</strong>는 오라클 DBMS에서 기본으로 사용되는 격리 수준이며, 온라인 서비스에서 가장 많이 선택되는 격리 수준으로, <code>COMMIT</code>이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있으므로 더티 리드 같은 현상은 발생하지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/ec61ef9a-b7b3-4949-8eaa-e683e9f73706/image.png" alt=""></p>
<p>사용자 A는 <code>emp_no=500000</code>인 사원의 <code>first_name</code>을 &quot;Lara&quot;에서 &quot;Toto&quot;로 변경했는데, 이때 새로운 값인 &quot;Toto&quot;는 <code>employees</code> 테이블에 즉시 기록되고 이전 값인 &quot;Lara&quot;는 언두 영역으로 백업된다. 사용자 A가 커밋을 수행하기 전 사용자 B가 <code>emp_no=500000</code>인 사원을 <code>SELECT</code>하면 조회된 결과는 언두 영역에 백업된 레코드의 <code>first_name</code> 칼럼의 값인 &quot;Lara&quot;이다. </p>
<p><strong>READ COMMITTED</strong> 격리 수준에서는 어떤 트랜잭션에 변경한 내용이 커밋되기 전까지는 다른 트랜잭션에서 그러한 변경 내역을 조회할 수 없기 때문이며, 커밋이 된 이후에는 다른 트랜잭션에서도 백업된 언두 레코드가 아닌 새롭게 변경된 &quot;Toto&quot;라는 값을 참조할 수 있게 된다.</p>
<p><strong>READ COMMITTED</strong> 격리 수준에서도 <strong>NON-REPEATABLE READ</strong> 부정합 문제가 존재하며, 아래 이미지는 해당 문제가 왜 발생하며 어떤 문제를 만들어낼 수 있는지 보여준다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/9fecc2f0-cbca-4894-9beb-d27974f69322/image.png" alt=""></p>
<p>처음 사용자 B가 <code>BEGIN</code> 명령으로 트랜잭션을 시작하고 <code>first_name</code>이 &quot;Toto&quot;인 사용자를 검색했는데, 일치하는 결과가 없었다. 하지만 사용자 A가 사원 번호가 500000인 사원이 이름을 &quot;Toto&quot;로 변경하고 커밋을 실행한 뒤 사용자 B가 똑같은 쿼리로 조회하면 이번에는 결과가 1건이 조회된다. 이는 하나의 트랜잭션 내에서 똑같은 <code>SELECT</code> 쿼리를 실행했을 때는 항상 같은 결과를 가져와야 한다는 <strong>REPEATABLE READ</strong> 정합성에 어긋난다.</p>
<p><strong>READ COMMITTED</strong> 격리 수준에서는 트랜잭션 내에서 실행되는 <code>SELECT</code> 문장과 트랜잭션 외부에서 실행되는 <code>SELECT</code> 문장의 차이가 별로 없으나 <strong>REPEATABLE READ</strong> 격리 수준에서는 기본적으로 <code>SELECT</code> 쿼리 문장도 트랜잭션 범위 내에서만 작동한다. 즉 트랜잭션을 시작한 상태에서 온종일 동일하 쿼리를 반복해서 실행해봐도 동일한 결과만 보게 된다.</p>
<h3 id="543-repeatable-read">5.4.3 REPEATABLE READ</h3>
<p><strong>REPEATABLE READ</strong>는 MySQL의 InnoDB 스토리지 엔진에서 기본적으로 사용되는 격리 수준이다. <strong>REPEATABLE READ</strong>는 InnoDB가 제공하는 방식인 MVCC를 위해 언두 영역에 백업된 이전 데이터를 이용해 동일 트랜잭션 내에서는 동일한 결과를 보여줄 수 있게 보장한다. <strong>READ COMMITTED</strong>도 MVCC를 이용해 <code>COMMIT</code>되기 전의 데이터를 보여주지만 둘의 차이는 언두 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 이전 버전까지 찾아 들어가야 하느냐에 있다.</p>
<p>모든 InnoDB의 트랜잭션은 고유한 트랜잭션 번호를 가지며, 언두 영역에 백업된 모든 레코드에는 변경을 발생시킨 트랜잭션의 번호가 포함돼 있다. 그리고 언두 영역의 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다고 판단하는 시점에 주기적으로 삭제한다. <strong>REPEATABLE READ</strong> 격리 수준에서는 MVCC를 보장하기 위해 실행 중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 트랜잭션 번호가 앞선 언두 영역의 데이터는 삭제할 수가 없다. 모든 언두 데이터가 필요한 것은 아니며, 특정 트랜잭션 번호의 구간 내에서 백업된 언두 데이터가 보존돼야 한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/ac3fde78-c47e-4526-ba96-ae796ef99469/image.png" alt=""></p>
<p>위 이미지는 <strong>REPEATABLE READ</strong> 격리 수준이 작동하는 방식을 보여준다. 사용자 A의 트랜잭션 번호는 12, 사용자 B의 트랜잭션 번호는 10이었다. 이때 사용자 A는 사원의 이름을 &quot;Toto&quot;로 변경하고 커밋을 수행했으나 사용자 B가 <code>emp_no=500000</code>인 사원을 A 트랜잭션 변경 전후 각각 한 번씩 <code>SELECT</code>했는데 결과는 항상 &quot;Lara&quot;라는 값을 가져온다. 사용자 B가 <code>BEGIN</code> 명령으로 트랜잭션을 시작하며 10번이라는 부여받은 트랜잭션 내부에서 실행되는 모든 <code>SELECT</code>쿼리는 트랜잭션 번호가 10보다 작은 트랜잭션 번호에서 변경하느 것만 보게 된다.</p>
<p>이미지에서는 언두 영역에 백업된 데이터가 하나지만 이는 얼마든지 늘어날 수 있으며 한 사용자가 <code>BEGIN</code>으로 트랜잭션을 시작하고 장시간 트랜잭션을 종료하지 않으면 언두 영역이 백업된 데이터로 무한정 커질 수도 있어 MySQL 서버의 처리 성능이 저하될 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/2a53dfe4-1202-4538-a2f8-3ddeca5873f6/image.png" alt=""></p>
<p>위 이미지는 <strong>REPEATABLE READ</strong> 격리 수준에서 발생할 수 있는 부정합 문제를 보여준다. 사용자 B는 <code>BEGIN</code> 명령으로 트랜잭션을 시작한 후 <code>SELECT</code>를 수행하므로 두 번의 <code>SELECT</code> 쿼리 결과는 동일해야 하지만 서로 다르게 조회된다. 이처럼 트랜잭션에서 수행된 변경 작업에 의해 레코드가 보였다 안 보였다 하는 현상을 <strong>PHANTOM READ(PHANTOM ROW)</strong>라고 한다. <code>SELECT ... FOR UPDATE</code> 쿼리는 <code>SELECT</code>하는 쿼리에 쓰기 잠금을 걸어야 하는데, 언두 레코드에는 잠금을 걸 수 없어 <code>SELECT ... FOR UPDATE</code>나 <code>SELECT ... LOCK IN SHAPE MODE</code>로 조회되는 쿼리는 언두 영역의 변경 전 데이터를 가져오는 것이 아닌 현재 레코드의 값을 가져오게 된다.</p>
<h3 id="544-serializable">5.4.4 SERIALIZABLE</h3>
<p><strong>SERIALIZABLE</strong> 격리 수준은 가장 단순하면서 가장 엄격한 격리 수준이다. 그만큼 동시 처리 성능도 다른 격리 수준보다 떨어진다. InnoDB 테이블에서 기본적으로 순수한 <code>SELECT</code> 작업(<code>INSERT ... SELECT ...</code> 또는 <code>CREATE TABLE ... AS SELECT ...</code>가 아닌)은 아무런 레코드 잠금도 설정하지 않고 실행되나 <strong>SERIALIZABLE</strong> 격리 수준에서는 읽기 작업도 공유 잠금(읽기 잠금)을 획득해야만 하며, 동시에 다른 트랜잭션은 그러한 레코드를 변경하지 못하게 한다. 즉, 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서는 절대 접근할 수 없는 것이다. <strong>SERIALIZABLE</strong> 격리 수준에서는 <strong>PHANTOM READ</strong>문제가 발생하지는 않으나, InnoDB 스토리지 엔진에서는 갭 락과 넥스트 키 락 덕분에 <strong>REPEATABLE READ</strong> 격리 수준에서도 <strong>PHANTOM READ</strong> 문제가 발생하지 않으므로 굳이 사용할 필요성은 없다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 04. 아키텍처 (4)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-4</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-4</guid>
            <pubDate>Wed, 15 Apr 2026 10:43:23 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="43-myisam-스토리지-엔진-아키텍처">4.3 MyISAM 스토리지 엔진 아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/40a1612b-8a13-4b33-b317-fe345e648d8e/image.png" alt=""></p>
<h3 id="431-키-캐시">4.3.1 키 캐시</h3>
<p>InnoDB의 버퍼 풀과 비슷한 역할을 하는 것이 MyISAM의 <strong>키 캐시</strong>로, 인덱스만을 대상으로 작동하며 인덱스의 디스크 쓰기 작업에 대해서만 버퍼링 역할을 한다는 점에서 차이가 있다. 키 캐시의가 얼마나 효율적으로 작동하는지는 아래 수식을 통해 간단히 확인할 수 있다.</p>
<blockquote>
<p>키 캐시 히트율(Hit rate) = 100 - (Key_reads / Key_read_requests * 100)</p>
</blockquote>
<p><code>Key_reads</code>는  인덱스를 디스크에서 읽어 들인 횟수를 저장하는 상태 변수이며, <code>Key_read_requests</code>는 키 캐시로부터 인덱스를 읽은 횟수를 저장하는 상태 변수이다. 이 상태 값들은 <code>SHOW GLOBAL STATUS</code> 명령으로 확인할 수 있다.</p>
<p>메뉴얼에서는 일반적으로 키 캐시를 이용한 쿼리의 비율(Hit rate)을 99% 이상을 유지하도록 권장하며, 99% 미만이라면 키 캐시를 조금 더 크게 설정하는 것이 좋다. 하지만 32비트 운영체제에서는 하나의 키 캐시에 4GB 이상의 메모리 공간을 설정할 수 없다. 64비트 운영체제에서는 <code>OS_PER_PROCESS_LIMIT</code> 값에 설정된 크기만큼의 메모리를 할당할 수 있다. 제한 값 이상의 키 캐시를 할당하고 싶다면 기본(Default) 키 캐시 이외에 별도의 명명된 키 캐시 공간을 설정해야 한다. 기본(Default) 키 캐시 공간을 설정하는 파라미터는 <code>key_buffer_size</code>다.</p>
<p>그러나 기본 키 캐시 이외의 명명된 키 캐시 영역은 아무런 설정을 하지 않으면 메모리 할당만 해두고 사용하지 않게 되므로 어떤 인덱스를 캐시할지 MySQL(MyISAM 스토리지 엔진)에 알려줘야 한다.</p>
<h3 id="432-운영체제의-캐시-및-버퍼">4.3.2 운영체제의 캐시 및 버퍼</h3>
<p>MyISAM 테이블의 인덱스는 키 캐시를 이용해 디스크를 검색하지 않고도 충분히 빠르게 검색할 수 있으나 테이블의 데이터에 대해서는 디스크로부터 I/O를 해결해 줄만한 캐시, 버퍼링 기능이 존재하지 않아 항상 운영체제의 디스크 읽기 또는 쓰기 작업으로 요청될 수밖에 없다. 물론 운영체제에 디스크로부터 읽고 쓰는 파일에 대한 캐시, 버퍼링 메커니즘이 존재하므로 매번 디스크의 파일을 읽지는 않는다. 운영체제의 캐시 공간은 남는 메모리를 사용하는 것이 기본 원칙이다.</p>
<h3 id="433-데이터-파일과-프라이머리-키인덱스-구조">4.3.3 데이터 파일과 프라이머리 키(인덱스) 구조</h3>
<p>InnoDB 스토리지 엔진을 사용하는 테이블은 프라이머리 키에 의해서 클러스터링되어 저장되는 반면, MyISAM 테이블은 클러스터링 없이 데이터 파일이 힙(Heap) 공간처럼 활용되어 레코드가 프라이머리 키 값과 무관하게 <code>INSERT</code>되는 순서대로 데이터 파일에 저장된다. 또한 MyISAM 테이블에 저장되는 레코드는 모두 <code>ROWID</code>라는 물리적인 주솟값을 가지는데, 프라이머리 키와 세컨더리 인덱스 모두 데이터 파일에 저장된 레코드의 <code>ROWID</code> 값을 포인터로 가진다. <code>ROWID</code>는 가변 길이와 고정 길이 두 가지 방법으로 저장될 수 있다.</p>
<hr>
<h2 id="44-mysql-로그-파일">4.4 MySQL 로그 파일</h2>
<h3 id="441-에러-로그-파일">4.4.1 에러 로그 파일</h3>
<p>MySQL이 실행되는 도중에 발생하는 에러나 경고 메시지가 출력되는 로그 파일이다. 에러 로그 파일의 위치는 MySQL 설정 파일(my.cnf)에서 <code>lof_error</code>라는 이름의 파라미터로 정의된 경로에 생성된다. MySQL 설정 파일에 별도로 정의되지 않은 경우 데이터 디렉토리에 .err라는 확장자가 붙은 파일로 생성된다. 가장 자주 보게될 메시지들은 아래와 같다.</p>
<ul>
<li>MySQL이 시작하는 과정과 관련된 정보성 및 에러 메시지</li>
<li>마지막으로 종료할 때 비정상으로 종료된 경우 나타나는 InnoDB 트랜잭션 복구 메시지</li>
<li>쿼리 처리 도중에 발생하는 문제에 대한 에러 메시지</li>
<li>비정상적으로 종료된 커넥션 메시지</li>
<li>InnoDB의 모니터링 또는 상태 조회 명령의 결과 메시지</li>
<li>MySQL의 종료 메시지</li>
</ul>
<h3 id="442-제네럴-쿼리-로그-파일-제네럴-로그-파일-general-log">4.4.2 제네럴 쿼리 로그 파일 (제네럴 로그 파일, General log)</h3>
<p>MySQL 서버에서 실행되는 쿼리로 어떤 것들이 있는지 전체 목록을 뽑아서 검토해볼 때가 있는데, 이때는 쿼리 로그를 활성화해서 쿼리 로그 파일을 기록하게 한 다음 그 파일을 검토하면 된다. 쿼리 로그 파일은 시간 단위로 실행됐던 쿼리의 내용이 모두 기록되며, 제네럴 쿼리 로그는 MySQL이 쿼리 요청을 받으면 바로 기록하기 때문에 쿼리 실행 중에 에러가 발생해도 로그 파일에 기록된다.</p>
<h3 id="443-슬로우-쿼리-로그">4.4.3 슬로우 쿼리 로그</h3>
<p>MySQL 서버의 쿼리 튜닝은 서비스가 적용되기 전에 전체적으로 튜닝하는 경우와 서비스 운영 중에 MySQL 서버의 전체적인 성능 저하를 검사하거나 정기적인 점검을 위한 튜닝으로 나눌 수 있다. 전자의 경우 검토해야 할 대상 쿼리가 전부라서 모두 튜닝하면 되지만, 후자의 경우 어떤 쿼리가 문제인 쿼리인지 판단하기가 상당히 어려운데, 이때 슬로우 쿼리 로그가 상당히 많은 도움이 된다.</p>
<p>슬로우 쿼리 로그 파일에는 시스템 변수에 설정한 시간 이상의 시간이 소요된 커리가 모두 기록된다. 슬로우 쿼리 로그는 MySQL이 쿼리를 실행한 후, 실제 소요된 시간을 기준으로 슬로우 쿼리 로그에 기록할지 여부를 판단하기 때문에 정상적으로 실행이 완료돼야 슬로우 쿼리 로그에 기록될 수 있다.</p>
<p>일반적으로 슬로우 쿼리 또는 제네럴 로그 파일의 내용이 상당히 많아 직접 쿼리를 하나씩 검토하기에는 어려움이 있는데, Percona에서 개발한 Percona Toolkit의 <code>pt-query-digest</code> 스크립트를 이용하면 쉽게 빈도 처리나 처리 성능별로 쿼리를 정렬해서 살펴볼 수 있다. 로그 파일 분석이 완료되면 아래ㄱ와 같이 3개의 그룹으로 나뉘어 결과가 저장된다.</p>
<ul>
<li>슬로우 쿼리 통계</li>
<li>실행 빈도 및 누적 실행 시간순 랭킹</li>
<li>쿼리별 실행 횟수 및 누적 실행 시간</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 04. 아키텍처 (3)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-3</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-3</guid>
            <pubDate>Tue, 14 Apr 2026 10:00:27 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h3 id="423-double-write-buffer">4.2.3 Double Write Buffer</h3>
<p>InnoDB 스토리지 엔진의 리두 로그는 공간 낭비를 막기 위해 페이지의 변경된 내용만 기록한다. 이로 인해 더티 페이지를 디스크 파일로 플러시할 때 일부만 기록하는 문제가 발생하면 그 페이지 내용은 복구할 수 없을 수도 있는데, 이러한 현상을 <strong>파셜 페이지(Partial-page)</strong> 또는 <strong>톤 페이지(Torn-page)</strong>라고 하며, 하드웨어 오작동이나 시스템의 비정상적인 종료 등으로 발생할 수 있다.
<img src="https://velog.velcdn.com/images/youhyuk_/post/24e492cf-abcb-48ce-a56c-2695ab1c7ba8/image.png" alt=""></p>
<p>위 같은 문제를 막기 위해 <strong>Double-Write</strong> 기법을 이용한다. 위 그림처럼 <code>A</code> ~ <code>E</code>까지의 더티 페이지를 디스크로 플러시할 때, InnoDB 스토리지 엔진은 실제 데이터 파일에 변경 내용을 기록하기 전 <code>A</code>~<code>E</code> 까지의 더티 페이지를 우선 묶어서 한 번의 디스크 쓰기로 시스템 테이블스페이스의 DoubleWrite 버퍼에 기록한 후, 각 더티 페이지를 파일의 적당한 위치에 하나씩 랜덤으로 쓰기를 실행한다.</p>
<p>시스템 테이블스페이스의 DoubleWrite 버퍼 공간에 기록된 변경 내용은 실제 데이터 파일에 <code>A</code>~<code>E</code> 더티 페이지가 정상적으로 기록되는 경우 필요가 없어지며, 실제 데이터 파일의 쓰기가 중간에 실패할 때만 원래의 목적으로 사용된다. 쓰기 작업 중 서버가 비정상적으로 종료되었을 때 재시작하게 된다면 InnoDB 스토리지 엔진은 항상 DoubleWrite 버퍼의 내용과 데이터 파일의 페이지들을 모두 비교해 다른 내용을 담고 있는 페이지가 있다면 DoubleWrite 버퍼의 내용을 데이터 파일의 페이지로 복사한다. 이는 <code>innodb_doublewrite</code> 시스템 변수로 제어할 수 있다.</p>
<h3 id="429-언두-로그">4.2.9 언두 로그</h3>
<p>InnoDB 스토리지 엔진은 트랜잭션과 격리 수준을 보장하기 위해 DML(<code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code>)로 변경되기 이전 버전의 데이터를 별도로 백업하고, 백업된 데이터를 <strong>언두 로그(Undo Log)</strong>라고 한다. </p>
<h4 id="언두-로그-모니터링">언두 로그 모니터링</h4>
<p><strong>언두 영역</strong>은 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 같은 문장으로 데이터를 변경했을 때 변경되기 전의 데이터를 보관하는 곳이다. 만약 사용자의 <code>UPDATE</code>문장으로 데이터를 변경할 때, 트랜잭션이 커밋되기 전에도 실제 데이터 파일의 내용은 변경된다. 이때 트랜잭션이 커밋된다면 현재 상태가 유지되고, 롤백하면 언두 영역의 백업된 데이터를 다시 데이터 파일로 복구한다.</p>
<p>언두 로그는 아래와 같이 사용된다.</p>
<ul>
<li>트랜잭션 보장
트랜잭션이 롤백되는 경우 언두 로그에 백업해둔 이전 버전의 데이터를 이용해 복구한다.</li>
<li>격리 수준 보장
특정 커넥션에서 데이터 변경 도중 다른 커넥션에서 데이터를 조회하면 트랜잭션 격리 수준에 맞게 변경정운 레코드를 읽지 않고 백업해둔 데이터를 읽어서 반환하기도 한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/dc7d3f0d-efd5-4e76-be07-14a0107b5945/image.png" alt=""></p>
<p>대용량의 데이터를 처리하는 트랜잭션 뿐만 아니라 트랜잭션이 오랜 시간 동안 실행될 때도 언두 로그의 양은 급격히 증가할 수 있다. 트랜잭션이 완료됐다고 해서 해당 트랜잭션이 생성한 언두 로그를 즉시 삭제할 수 있는 것은 아니다. 위 그림은 B 트랜잭션과 C 트랜잭션은 완료됐지만 가장 먼저 시작된 트랜잭션 A는 완료되지 않은 상태를 나타낸다. 이때 트랜잭션 B와 C는 각각 <code>UPDATE</code>와 <code>DELETE</code>를 실행했으므로 변경 이전의 데이터를 언두 로그에 백업했을 것이다. 하지만 먼저 시작된 A 트랜잭션이 아직 활성 상태이기 때문에 B와 C 트랜잭션 완료 여부와 관계없이 B와 C 트랜잭션이 만들어낸 언두 로그는 삭제되지 않는다.</p>
<p>만약 MySQL 서버에서 사용자가 트랜잭션을 시작한 상태에서 완료하지 않고 하루정도 방치한 경우, InnoDB 스토리지 엔진은 이 트랜잭션이 시작된 시점부터 생성된 언두 로그를 계속 보존하게 되어 하루치 데이터 변경을 모두 저장하고 언두 로그 저장 공간은 계속 증가하게 된다. 이로 인해 빈번하게 변경된 레코드 조회 쿼리가 실행되면 언두 로그의 이력을 필요한 만큼 스캔해야만 필요한 레코드를 찾을 수 있기 때문에 쿼리 성능이 전반적으로 떨어지게 된다.</p>
<p>MySQL 5.7, MySQL 8.0 으로 업그레이드 되면서 언두 로그 공간의 문제는 완전히 해결됐다. MySQL 8.0에서는 언두 로그를 돌아가면서 순차적으로 사용해 디스크 공간을 줄이는 것도 가능하며, MySQL 서버가 필요한 시점에 사용 공간을 자동으로 줄여주기도 한다. 그럼에도 MySQL 서버에서 활성 상태의 트랜잭션 장시간 유지는 성능상 좋지 않으므로 MySQL 서버의 언두 로그 건수를 모니터링하는 것이 좋다.</p>
<h4 id="언두-테이블스페이스-관리">언두 테이블스페이스 관리</h4>
<p>언두 로그가 저장되는 공간을 <strong>언두 테이블스페이스(Undo Tablespace)</strong>라고 한다. 위 그림은 언두 테이블스페이스가 어떤 형태로 구성되는지 보여준다. 하나의 언두 테이블스페이스는 1개 이상 128개 이하의 롤백 세그먼트를 가지며, 롤백 세그먼트는 1개 이상의 언두 슬롯(Undo Slot)을 가진다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/67d63221-b7d8-4298-a0d8-0ae7ddf6aa6e/image.png" alt=""></p>
<p>하나의 롤백 세그먼트는 InnoDB의 페이지 크기를 16바이트로 나눈 값의 개수만큼의 언두 슬록스 가진다. 하나의 트랜잭션이 필요로 하는 언두 슬롯의 개수는 트랜잭션이 실행하는 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 문장의 특성에 따라 최대 4개까지 언두 슬롯을 사용하게 된다. 일반적으로는 트랜잭션이 임시 테이블을 사용하지 않으므로 하나의 트랜잭션은 대략 2개 정도의 언두 슬롯을 필요로 한다고 가정하면 된다. 그래서 최대 동시 처리 가능한 트랜잭션 개수는 다음 수식으로 예측해볼 수 있다.</p>
<blockquote>
<p>최대 동시 트랜잭션 수 = 
(InnoDB 페이지 크기 / 16* (롤백 세그먼트 개수) * (언두 테이블스페이스 개수)</p>
</blockquote>
<p>MySQL 8.0 버전부터 <code>CREATE UNDO TABLESPACE</code>나 <code>DROP TABLESPACE</code> 같은 명령으로 새로운 언두 테이블스페이스를 동적으로 추가하고 삭제할 수 있게 개선되었다. </p>
<p>언두 테이블스페이스 공간을 필요한 만큼만 남기고 불필요하거나 과도하게 할당된 공간을 운영체제로 반납하는 것을 **Undo tablespace truncate`라고 한다. 언두 테이블스페이스의 불필요한 공간을 잘라내는 방법은 자동과 수동으로, 모두 MySQL 8.0부터 지원된다.</p>
<ul>
<li>자동 모드
트랜잭션이 데이터를 변경하면 이전 버전의 데이터를 언두 로그로 기록하는데, 트랜잭션이 커밋되면 더이상 언두 로그의 복사된 이전 값은 불필요해진다. InnoDB 스토리지 엔진의 퍼지 스레드는 주기적으로 깨어나서 언두 로그 공간에서 불필요해진 언두 로그를 삭제하는 <strong>언두 퍼지(Undo Purge)</strong>를 실행한다. <code>innodb_undo_log_truncate</code> 시스템 변수가 ON으로 설정되면 퍼지 스레드는 주기적으로 언두 로그 파일에서 사용되지 않는 공간을 잘라내고 운영체제로 반납하게 된다.</li>
<li>수동 모드
<code>innodb_undo_log_truncate</code> 시스템 변수가 OFF로 설정하여 언두 로그 파일의 잘라내기가 자동으로 실행되지 않거나 예상보다 자동 모드로 언두 테이블스페이스의 공간 반납이 부진한 경우 언두 테이블스페이스를 비활성화해서 언두 테이블스페이스가 더이상 사용되지 않도록 설정하면 퍼지 스레드는 비활성 상태의 언두 테이블스페이스를 찾아서 공간을 잘라내고 해동 공간을 반납한 뒤 다시 활성화한다. 수동 모드는 언두 테이블스페이스가 최소 3개 이상은 돼야 작동한다.</li>
</ul>
<h3 id="4210-체인지-버퍼">4.2.10 체인지 버퍼</h3>
<p>RDBMS에서 레코드가 <code>INSERT</code>되거나 <code>UPDATE</code>될 때는 데이터 파일을 변경하는 작업뿐 아니라 인덱스를 업데이트 하는 작업이 필요한데, 업데이트 작업은 랜덤하게 디스크를 읽는 작업이 필요해 테이블에 인덱스가 많다면 상당히 많은 자원을 소모하게 된다. 따라서 InnoDB는 변경해야 할 인덱스 페이지가 버퍼 풀에 있으면 바로 업데이트를 수행하지만 디스크로부터 읽어와서 업데이트 해야 하는 경우에는 즉시 실행하지 않고 임시 공간에 저장해 두고 바로 사용자에게 결과를 반환하는 형태로 성능을 향상시키는데, 이때 사용하는 임시 메모리 공간을 <strong>체인지 버퍼(Change Buffer)</strong>라고 한다.</p>
<p>유니크 인덱스는 체인지 버퍼를 사용할 수 없다, 체인지 버퍼에 임시로 저장된 인덱스 레코드 조각은 이후 백그라운드의 <strong>체인지 버퍼 머지 스레드(Merge thread)</strong>에 의해 병합된다. MySQL 8.0 에서는 <code>INSERT</code>, <code>DELETE</code>, <code>UPDATE</code>로 인해 키를 추가하거나 삭제하는 작업에 대해서도 버퍼링이 될 수 있게 개선되었으며, MySQL 5.5부터 <code>innodb_change_buffering</code> 시스템 변수를 통해 작엽의 종류별로 체인지 버퍼를 활성화하거나 사용하지 않게 설정할 수 있다.</p>
<h3 id="4211-리두-로그-및-로그-버퍼">4.2.11 리두 로그 및 로그 버퍼</h3>
<p><strong>리두 로그(Redo Log)</strong>는 트랜잭션의 4가지 요소인 ACID중에서 D(Durable)에 해당하는 영속성과 가장 밀접하게 연관되어 있다. 리두 로그는 하드웨어나 소프트웨어 등 여러 가지 문제점으로 인해 MySQL 서버가 비정상적으로 종료됐을 때 데이터 파일에 기록되지 못한 데이터를 잃지 않게 해주는 안전장치다.</p>
<p>대부분의 데이터베이스 서버는 데이터 변경 내용을 로그로 먼저 기록한다. 거의 모든 DBMS에서 데이터 파일은 쓰기보다 읽기 성능을 고려한 자료 구조를 가지고 있기 때문에 쓰기는 디스크의 랜덤 액세스가 필요하고, 따라서 변경된 데이터를 데이터 파일에 기록하려면 상대적으로 큰 비용이 필요하다. 이로 인한 성능 저하를 막기 위해 데이터베이스 서버는 쓰기 비용이 낮은 리두 로그를 가지고 있으며, 비정상 종료가 발생하면 리두 로그의 내용을 이용해 데이터 파일을 다시 서버 종료 직전 상태로 복구한다. 데이터베이스 서버는 ACID도 중요하지만 성능도 중요하기 때문에 데이터 파일뿐만 아니라 리두 로그를 버퍼링할 수 있는 InnoDB 버퍼 풀이나 리두 로그를 버퍼링 할 수 있는 로그 버퍼와 같은 자료 구조도 가지고 있다.</p>
<p>MySQL 서버가 비정상 종료되는 경우 InnoDB 스토리지 엔진의 데이터 파일은 두 종료의 일관되지 않은 데이터를 가질 수 있다. 첫번째로 <strong>커밋됐지만 데이터 파일에 기록되지 않은 데이터</strong>로, 이는 리두 로그에 저장된 데이터를 데이터 파일에 다시 복사하기만 하면 된다. 두번째는 <strong>롤백됐지만 데이터 파일에 이미 기록된 데이터</strong>로 이는 리두 로그로는 해결할 수 없고, 변경 전 데이터를 가진 언두 로그의 내용을 가져와 데이터 파일에 복사하면 된다. 이때 리두로그는 그 변경이 커밋, 롤백, 혹은 트랜잭션 실행 중간 상태를 확인하기 위해 필요하다.</p>
<h4 id="리두-로그-아카이빙">리두 로그 아카이빙</h4>
<p>MySQL 8.0 버전부터 InnoDB 스토리지 엔진의 리두 로그를 아카이빙할 수 있는 기능이 추가됐다. MySQL 엔터프라이즈 백업이나 Xtrabackup 툴은 데이터 파일을 복사하는 동안 일관된 상태 유지를 위해 InnoDB 스토리지 엔진의 리두 로그에 쌓인 내용을 계속 추적하면서 새로 추가된 리두 로그 엔드리를 복사한다. 그런데 데이터 변경이 너무 많으면 리두 로그가 매우 빠르게 증가하며 백업 툴이 새로 추가되는 리두 로그 내용을 복사하기도 전에 덮어쓰여 리두 로그 엔트리를 복사할 수 없어 백업은 실패하게 된다. MySQL 8.0의 <strong>리두 로그 아카이빙</strong> 기능은 데이터 변경이 많아서 리두 로그가 덮어쓰인다고 하더라도 백업이 실패하지 않게 해준다.</p>
<h4 id="리두-로그-활성화-및-비활성화">리두 로그 활성화 및 비활성화</h4>
<p>MySQL 8.0 이전 버전까지는 수동으로 리두 로그를 비활성화할 수 있는 방법이 없었지만 MySQL 8.0 버전부터는 수동으로 리두 로그를 활성화 혹은 비활성화할 수 있게 되었다. 이로 인해 데이터를 복구하거나 대용량 데이터를 한번에 적재하는 경우 다음과 같이 리두 로그를 비활성화해서 데이터의 적재 시간을 단축시킬 수 있다. 비활성화한 뒤 데이터 적재 작업이 완료되었다면 리두 로그를 다시 활성화하여 데이터의 일관성을 유지해야 한다.</p>
<h3 id="4212-어댑티브-해시-인덱스">4.2.12 어댑티브 해시 인덱스</h3>
<p>일반적인 <strong>인덱스</strong>는 테이블에 사용자가 생성해둔 B-Tree 등의 알고리즘을 사용하는 인덱스를 의미하지만 <strong>어댑티브 해시 인덱스(Adaptive Hash Index)</strong>는 사용자가 수동으로 생성하는 인덱스로, <code>innodb_adaptive_hash_index</code> 시스템 변수를 이용해 기능을 활성화 혹은 비활성화할 수 있다.</p>
<p>B-Tree 인덱스에서 값을 찾는 과정은 데이터베이스 서버가 얼마나 많은 일을 하느냐에 따라 속도가 달라질 수 있는데, 어댑티브 해시 인덱스는 이러한 B-Tree 검색시간을 줄여주기 위해 도입된 기능이다. InnoDB 스토리지 엔진은 자주 읽히는 데이터 페이지의 키 값을 이용해 해시 인덱스를 만들고 필요할 때마다 어댑티브 해시 인덱스를 검색해 레코드가 저장된 데이터 페이지를 즉시 찾아갈 수 있어 B-Tree를 루트 노드부터 리프 노드까지 찾아가는 비용이 없어져 쿼리의 성능은 빠랄지고 컴퓨터는 더 많은 쿼리를 동시에 처리할 수 있게 된다.</p>
<p>해시 인덱스는 B-Tree 인덱스 고유번호(Id)와 B-Tree 인덱스의 실제 키 값의 조합으로 <strong>인덱스 키 값</strong>과 해당 인덱스 키 값이 저장된 <strong>데이터 페이지 주소</strong>의 쌍으로 관리된다. B-Tree 인덱스 고유번호가 포함되는 이유는 InnoDB 스토리지 엔진에서 어댑티브 해시 인덱스는 하나만 존재하기 때문이다. <strong>데이터 페이지 주소</strong>는 실제 키 값이 저장된 데이터 페이지의 메모리 주소를 가지는데, 이는 InnoDB 버퍼 풀에 로딩된 페이지 주소를 의미한다. 그래서 어댑티브 해시 인덱스는 버퍼 풀에 올려진 데이터 페이지에 대해서만 관리되며 버퍼 풀에서 해당 데이터 페이지가 없어지면 어댑티브 해시 인덱스에서도 해당 페이지 정보는 사라진다.</p>
<p>어댑티브 해시 인덱스가 성능적인 이점만 가지고 있는 것 같이 보이나,실제 어댑티브 해시 인덱스를 의도적으로 비활성화하는 경우도 많다. 어댑티브 해시 인덱스 사용으로 성능이 향상되는 경우와 도움이 되지 않는 경우는 아래와 같다.</p>
<h4 id="성능-향상에-도움이-되는-경우">성능 향상에 도움이 되는 경우</h4>
<ul>
<li>디스크의 데이터가 InnoDB 버퍼 풀 크기와 비슷한 경우(디스크 읽기가 많지 않은 경우)</li>
<li>동등 조건 검색(동등 비교와 연산자)이 많은 경우</li>
<li>쿼리가 데이터 중에서 일부 데이터에만 집중되는 경우</li>
</ul>
<h4 id="성능-향상에-도움이-되지-않는-경우">성능 향상에 도움이 되지 않는 경우</h4>
<ul>
<li>디스크 읽기가 많은 경우</li>
<li>특정 패턴의 쿼리가 많은 경우(조인이나 LIKE 패턴 검색)</li>
<li>매우 큰 데이터를 가진 테이블의 레코드를 폭넓게 읽는 경우</li>
</ul>
<p>어댑티브 해시 인덱스는 테이블의 삭제 작업에도 많은 영향을 미친다. 어떤 테이블의 인덱스가 어댑티브 해시 인덱스에 적재되어있을 때 테이블을 삭제 혹은 변경하려고 하면 InnoDB 스토리지 엔진은 테이블이 가진 모든 데이터 페이지의 내용을 어댑티브 해시 인덱스에서 제거해야 한다. 이로 인해 테이블 삭제 및 스키마 변경 시 상당히 많은 CPU 자원을 사용하고, 데이터베이스 서버의 처리 성능이 느려진다.</p>
<h3 id="4213-innodb와-myisam-memory-스토리지-엔진-비교">4.2.13 InnoDB와 MyISAM, MEMORY 스토리지 엔진 비교</h3>
<p>MySQL 5.5 버전부터 InnoDB 스토리지 엔진이 기본 스토리지 엔진으로 채택됐으며 MySQL 8.0부터는 MySQL 서버의 모든 시스템 테이블이 InnoDB 스토리지 엔진으로 교체됐고, 공간 좌표 검색 혹은 전문 검색 기능이 모두 InnoDB 스토리지 엔진을 지원하도록 개선됐다. 이로 인해 MyISAM 스토리지 엔진의 기능은 도태되어 이후 버전에서는 없어질 것으로 예상된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 04.  아키텍처 (2)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-2</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-2</guid>
            <pubDate>Tue, 07 Apr 2026 11:33:35 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="42-innodb-스토리지-엔진-아키텍처">4.2 InnoDB 스토리지 엔진 아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/b3041e0d-02b2-4a23-8b36-b9579bde89b0/image.png" alt="InnoDB 구조"></p>
<h3 id="421-프라이머리-키에-의한-클러스터링">4.2.1 프라이머리 키에 의한 클러스터링</h3>
<p>InnoDB의 모든 테이블은 프라이머리 키를 기준으로 클러스터링되어 저장된다. 즉 프라이머리 키 값의 순서대로 디스크에 저장되며, 모든 세컨더리 인덱스는 레코드의 주소 대신 프라이머리 키의 값을 논리적 주소로 사용한다. 이로 인해 프라이머리 키를 이용한 레인지 스캔 성능이 우수하며, 다른 보조 인덱스에 비해 프라이머리 키의 비중이 높게 설정되어 있다.</p>
<h3 id="422-외래키-지원">4.2.2 외래키 지원</h3>
<p>InnoDB에서 외래 키는 부모 테이블과 자식 테이블 모두 해당 칼럼에 인덱스 생성이 필요하고, 변경 시에는 반드시 부모 테이블이나 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하므로 잠금이 여러 테이블로 전파되고, 그로 인해 데드락이 발생할 때가 많으므로 개발할 때도 외래 키의 존재에 주의하는 것이 좋다.</p>
<blockquote>
</blockquote>
<p>서비스의 문제가 발생해 긴급하게 조치를 해야 하는 경우 <code>foreigh_key_checks</code> 시스템 변수를 OFF로 설정하면 외래 키 관계 체크 작업을 일시적으로 멈출 수 있다. 비활성화 이후 작업 시 테이블 간의 관계를 일관성있도록 조정하는 작업을 거친 뒤 활성화해야한다.</p>
<h3 id="423-mvcc-multi-version-concurrency-control">4.2.3 MVCC (Multi Version Concurrency Control)</h3>
<p>일반적으로 레코드 레벨 트랜잭션을 지원하는 DBMS가 제공하는 기능으로, 잠금을 사용하지 않는 일관된 읽기를 제공하는 것이 가장 큰 목적이다. InnoDB는 이를 언두 로그(Undo log)를 이용해 구현한다.</p>
<blockquote>
<p>멀티 버전은 하나의 레코드에 대해 여러개의 버전이 동시에 관리된다는 의미이다.</p>
</blockquote>
<p>아래는 격리 수준이 <code>READ_COMMITTED</code>인 MySQL 서버의 InnoDB 스토리지 엔진을 사용하는 테이블 데이터 변경 예시이다.</p>
<pre><code class="language-sql">mysql&gt; CREATE TABLE member (
    m_id INT NOT NULL,
    m_name VARCHAR(20) NOT NULL,
    m_area VARCHAR(20) NOT NULL,
    PRIMARY_KEY (m_id),
    INDEX ix_area(m_area)
   );

mysql&gt; INSERT INTO member (m_id, m_name, m_area) VALUES (12, &#39;홍길동&#39;, 서울&#39;);
mysql&gt; COMMIT;</code></pre>
<p>위 <code>INSERT</code> 문이 실행된 경우 데이터베이스 상태는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/64849802-4aa8-407b-b8cc-6abfc2c0b791/image.png" alt=""></p>
<p>위 상황에서 <code>UPDATE</code> 문을 실행하게 된다면 아래 그림처럼 데이터베이스 상태가 변경된다.</p>
<pre><code class="language-sql">myql&gt; UPDATE member SET m_area=&#39;경기&#39; WHERE m_id=12;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/4677ba74-5d6f-45e4-bab6-f3585a029464/image.png" alt=""></p>
<p><code>UPDATE</code> 문이 실행되면 커밋 실행 여부와 관계 없이 InnoDB 버퍼 풀은 새로운 값인 &#39;경기&#39;로 업데이트되며, <code>m_area</code>의 변경 전 값만을 언두 로그로 복사한다. 디스크의 데이터 파일에는 체크포인트 혹은 InnoDB의 Write 스레드에 의해 새로운 값이 업데이트되어 있을 수도 있고 아닐 수도 있다.</p>
<blockquote>
<p>InnoDB는 ACID를 보장하기 때문에 일반적으로는 InnoDB의 버퍼 풀과 데이터 파일은 동일한 상태라고 가정해도 무방하다.</p>
</blockquote>
<p>위 상태에서 <code>COMMIT</code>을 실행하면 InnoDB는 더 이상의 변경 작업 없이 지금의 상태를 영구적인 데이터로 만들며, <code>ROLLBACK</code>을 실행하는 경우 InnoDB는 언두 영역에 있는 백업된 데이터를 InnoDB 버퍼 풀로 다시 복구한 뒤 언두 영역의 내용을 삭제한다. 언두 영역의 백업 데이터는 언두 영역을 필요로 하는 트랜잭션이 존재하지 않을 때 삭제된다.</p>
<p>만약 <code>COMMIT</code> 혹은 <code>ROLLBACK</code>이 되지 않은 상태에서 사용자가 작업 중인 레코드를 조회하게 되는 경우 MySQL 서버의 시스템 변수에 설정된 격리 수준에 따라 다른 데이터를 조회하게 된다. 격리 수준이 <code>READ_UNCOMMITTED</code>인 경우 데이터 커밋 여부와 관계없이 InnoDB 버퍼 풀이 현재 가지고 있는 변경된 데이터를 읽어 반환한다. 그 외의 <code>READ_COMMITTED</code>나 그 이상의 격리 수준(<code>REPEATABLE_READ</code>, <code>SERIALIZABLE</code>)의 경우에는 아직 커밋이 되지 않았기 때문에 변경되기 이전 내용을 보관하는 언두 영역의 데이터를 반환한다.</p>
<p>이처럼 하나의 레코드에 대한 여러 버전을 유지하며 필요에 따라 어느 데이터가 보여지는지 여러 가지 상황에 따라 달라지는 구조를 <strong>MVCC</strong>라고 한다.</p>
<h3 id="424-잠금-없는-일관된-읽기-non-locking-consistent-read">4.2.4 잠금 없는 일관된 읽기 (Non-Locking Consistent Read)</h3>
<p>InnoDB 스토리지 엔진은 MVCC 기술을 이용해 잠금을 걸지 않고 읽기 작업을 수행하므로 다른 트랜잭션이 가지고 있는 잠금을 기다리지 않고 읽기 작업이 가능하다. 격리 수준이 <code>SERIALIZABLE</code>이 아닌 <code>READ_UNCOMMITTED</code>, <code>READ_COMMITTED</code>, <code>REPEATABLE_READ</code>인 경우 순수한 읽기 작업은 다른 트랜잭션의 변경 작업과 관계없이 항상 잠금을 대기하지 않고 바로 실행된다. 이를 <strong>잠금 없는 일관된 읽기</strong>라고 표현하며, InnoDB에서는 변경 전 데이터를 읽기 위해 언두 로그를 사용한다.</p>
<blockquote>
<p>MySQL 서버가 느려지거나 문제가 발생하는 경우, 일관된 읽기를 위해 언두 로그를 삭제하지 못하고 계속 유지해야 하기 때문에 발생하는 문제다. 따라서 트랜잭션이 시작됐다면 가능한 한 빨리 롤백이나 커밋을 통해 트랜잭션을 종료하는 것이 좋다.</p>
</blockquote>
<h3 id="425-자동-데드락-감지">4.2.5 자동 데드락 감지</h3>
<p>InnoDB 스토리지 엔진은 내부적으로 잠금이 교착 상태에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 그래프(Wait-for List) 형태로 관리하며, <strong>데드락 감지 스레드</strong>가 주기적으로 잠금 대기 그래프를 검사해 교착 상태에 빠진 트랜잭션들을 찾아서 그중 하나를 강제 종료한다. 트랜잭션 종료 우선순위 기준은 트랜잭션의 언두 로그 양으로 언두 로그 레코드를 더 적게 가진 트랜잭션이 일반적인 롤백 대상이 된다. 해당 트랜잭션은 롤백을 해도 언두 처리를 해야 할 내용이 적으며 강제 롤백으로 읺란 MySQL 서버 부하도 덜 유발하기 때문이다.</p>
<p>일반적인 서비스 에서는 데드락을 찾아내는 작업이 크게 부담되지 않지만 동시 처리 스레드가 매우 많아지거나 각 트랜잭션이 가진 잠금 개수가 많아지는 경우 데드락 감지 스레드가 느려진다. 데드락 감지 스레드는 잠금 목록을 검사해야 하기 때문에 잠금 상태가 변경되지 않도록 잠금 목록이 저장된 리스트에 새로운 잠금을 걸고 데드락 스레드를 찾게 된다. 이로 인해 서비스 쿼리를 처리 중인 스레드가 작업을 진행하지 못하고 대기하며 서비스에 악영향을 미칠 수 있다.</p>
<p>위 문제점을 해결하기 위해 MySQL 서버는 <code>innodb_deadlock_detect</code> 시스템 변구를 제공하며 이를 OFF로 설정하면 데드락 감지 스레드는 작동하지 않는다. 만약 2개 이상의 트랜잭션이 상대방이 가진 잠금을 요구하는 데드락이 발생해도 중재를 할 수 없어 무한정 대기하게 된다. </p>
<p><code>innodb_lock_wait_timeout</code> 시스템 변수를 활성화하면 데드락 상황에서 일정 시간이 지나면 자동으로 요청이 실패하고 에러 메세지를 반환하므로 데드락 감지 스레드를 비활성화 하는 경우 함께 설정하는 것이 좋다.</p>
<h3 id="426-자동화된-장애-복구">4.2.6 자동화된 장애 복구</h3>
<p>InnoDB에는 손실이나 장애로부터 데이터를 보호하기 위한 여러 매커니즘이 탑재되어 있어 MySQL 서버가 시작될 때 완료되지 못한 트랜잭션이나 디스크에 일부만 기록된 데이터 페이지 등에 대한 일련의 복구 작업을 자동으로 진행한다.</p>
<p>InnoDB 스토리지 엔진은 매우 견고해 데이터 파일이 손생되거나 MySQL 서버가 시작되지 못하는 경우는 거의 발생하지 않지만 디스크나 서버 하드웨어 이슈로 자동으로 복구를 못하는 경우가 발생할 수 있다. InnoDB는 기본적으로 MySQL 서버가 시작될 때 자동 복구를 수행하며, 복구할 수 없는 손상이 있다면 자동 복구를 멈추고 서버는 종료된다.</p>
<p>위 경우 <code>innodb_force_recovery</code> 시스템 변수를 설정해 데이터 파일이나 로그 파일의 손상 여부 검사 과정을 선별적으로 진행할 수 있게 해야한다. 해당 시스템 변수에 설정 가능한 값은 1부터 6까지로, 각 숫자 값의 의미는 아래와 같다.</p>
<ul>
<li>1 (SRV_FORCE_IGNORE_CORRUPT)
InnoDB의 테이블스페이스의 데이터나 인덱스 페이지에서 손상된 부분이 발견돼도 무시하고 MySQL 서버를 시작한다.</li>
<li>2 (SRV_FORCE_IGNORE_CORRUPT)
백그라운드 스레드 가운데 메인 스레드를 시작하지 않고 MySQL 서버를 시작한다. </li>
<li>3 (SRV_FORCE_NO_TRX_UNDO)
커밋되지 않고 종료된 트랜잭션은 계속 그 상태로 남아 있게 MySQL 서버를 시작한다. </li>
<li>4 (SRV_FORCE_NO_IBUF_MERGE)
InnoDB 스토리지 엔진이 인서트 버퍼의 내용을 무시하고 강제로 MySQL 서버를 시작한다.</li>
<li>5 (SRV_FORCE_NO_UNDO_LOG_SCAN)
InnoDB 엔진이 언두 로그를 모두 무시하고 MySQL을 시작한다.</li>
<li>6 (SRV_FORCE_NO_LOG_REDO)
InnoDB 엔진이 리두 로그를 모두 무시한 채로 MySQL 서버를 시작한다.</li>
</ul>
<h3 id="427-innodb-버퍼-풀">4.2.7 InnoDB 버퍼 풀</h3>
<p><strong>InnoDB 버퍼 풀</strong>은 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐시해 두는 공간이며 쓰기 작업을 지연시켜 일괄 작업으로 처리할 수 있게 해주는 버퍼 역할도 함께 한다. 버퍼 풀은 변경된 데이터를 모아서 처리하여 디스크 작업 횟수를 줄인다.</p>
<h4 id="버퍼-풀의-크기-설정">버퍼 풀의 크기 설정</h4>
<p>InnoDB 버퍼 풀의 크기는 운영체제와 각 클라이언트 스레드가 사용할 메모리를 충분히 고려해서 설정해야 한다. 만약 MySQL 서버를 처음 준비하는 경우, 운영체제 메모리 공간이 8GB 미만이라면 50% 정도만 InnoDB 버퍼 풀로 설정하고 나머지는 MySQL 서버와 운영체제, 그리고 다른 프로그램이 사용할 수 있는 공간으로 확보해주는 것이 좋다. 메모리 공간이 그 이상이라면 버퍼 풀의 크기를 50%부터 조금씩 올려가며 최적점을 찾는다.</p>
<p>InnoDB 버퍼 풀 전체를 관리하는 잠금(세마포어)으로 인해 재부 잠금 경합을 유발해왔으나 이를 줄이기 위해 버퍼 풀을 여러 개로 쪼개어 관리할 수 있는 <strong>버퍼 풀 인스턴스</strong>를 사용하도록 개선되었다.</p>
<h4 id="버퍼-풀의-구조">버퍼 풀의 구조</h4>
<p>InnoDB 스토리지 엔진은 버퍼 풀을 페이지 크기의 조각으로 쪼개어 InnoDB 스토리지 엔진이 데이터를 필요로 할 때 해당 데이터 페이지를 읽어서 각 조각에 저장한다. 버퍼 풀의 페이지 크기 조각을 관리하기 위해 <strong>LRU(Least Recently Used) 리스트, 플러시(Flush) 리스트, 프리(Free) 리스트</strong> 자료 구조를 관리한다.</p>
<p><strong>프리 리스트</strong>는 InnoDB 버퍼 풀에서 실제 사용자 데이터로 채워지지 않는 비어 있는 페이지들의 목록이며, 사용자의 쿼리가 새롭게 디스크의 데이터 페이지를 읽어와야 하는 경우 사용된다.
<img src="https://velog.velcdn.com/images/youhyuk_/post/c6354bbd-447c-46fc-8fb8-be5fd38ba6e1/image.png" alt=""></p>
<p>위 그림은 <strong>LRU리스트</strong> 구조로, LRU와 MRU(Most Recently Used)리스트가 결합된 형태이다. LRU 리스트를 관리하는 목적은 디스크로부터 한 번 읽어온 페이지를 최대한 오랫동안 InnoDB 버퍼 풀의 메모리에 유지해 디스크 읽기를 최소하하는 것이다. InnoDB 스토리지 엔진에서 데이터를 과정은 아래와 같다.</p>
<ol>
<li>필요한 레코드가 저장된 데이터 페이지가 버퍼 풀에 있는지 검사
A. InnoDB 어댑티브 해시 인덱스를 이용해 페이지를 검색
B. 해당 테이블의 인덱스(B-Tree)를 이용해 버퍼 풀에서 페이지 검색
C. 버퍼 풀에 이미 데이터 페이지가 있었다면 해당 페이지 포인터를 MRU 방향으로 승급</li>
</ol>
<ol start="2">
<li><p>디스크에서 필요한 데이터 페이지를 버퍼 풀에 적재한 뒤 포인터를 LRU 헤더 부분에 추가</p>
</li>
<li><p>버퍼 풀의 LRU 헤더 부분에 적재된 데이터 페이지가 실제로 읽히면 MRU 헤더 부분으로 이동</p>
</li>
<li><p>버퍼 풀에 상주하는 데이터 페이지는 사용자 쿼리가 얼마나 최근에 접근했었는지에 따라 나이(Age)가 부여되며 오래된 데이터 페이지는 버퍼 풀에서 제거된다. 버퍼 풀의 데이터 페이지가 쿼리에 의해 사용되면 나이가 초기화된 뒤 MRU 헤더 부분으로 옮겨진다.</p>
</li>
<li><p>필요한 데이터가 자주 접근됐다면 해당 페이지의 인덱스 키를 어댑티브 해시 인덱스에 추가</p>
</li>
</ol>
<p><strong>플러시 리스트</strong>는 디스크로 동기화되지 않은 데이터를 가진 데이터 페이지(더티 페이지)의 변경 시점 기준의 페이지 목록을 관리한다. 한 번 데이터 변경이 가해진 데이터 페이지는 플러시 리스트에 관리되고 특정 시점이 되면 디스크로 기록돼야 한다. 데이터가 변경되면 InnoDB는 변경 내용을 리두 로그에 기록하고 버퍼 풀의 데이터 페이지에도 변경 내용을 반영한다.</p>
<p><strong>체크포인트</strong>는 MySQL 서버가 시작될 때 InnoDB 스토리지 엔진이 리두 로그의 어느 부분부터 복구를 실행할 지 판단하는 기준점을 만드는 역할을 하는데, InnoDB 스토리지 엔진은 이를 발생시켜 디스크의 리두 로그와 데이터 페이지의 상태를 동기화한다.</p>
<h4 id="버퍼-풀과-리두-로그">버퍼 풀과 리두 로그</h4>
<p>InnoDB 버퍼 풀은 디스크에서 읽은 상태로 전혀 변경되지 않은 클린 페이지(Clean Page)와 함께 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 명령으로 변경된 데이터를 가진 더티 페이지(Dirty Page)도 가지고 있다. 더티 페이지는 디스크와 메모리의 데이터 상태가 다르기 때문에 언젠가 디스크로 기록되어야 하지만 더티 페이지가 무한정 버퍼 풀에 머무를 수는 없다.</p>
<p>InnoDB 스토리지 엔진에서 리두 로그는 1개 이상의 고정된 크기 파일을 연결하고, 데이터 변경이 발생하면 리두 로그 파일에 기록됐던 로그 엔트리는 새로운 로그 엔트리로 덮어 쓰인다.리두 로그 파일에서 재사용 불가능한 공간을 <strong>활성 리두 로그(Active Redo Log)</strong>라고 하며, 아래 그림의 화살표를 가진 엔드리들이 활성 리두 공간이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/b7727874-7f42-426c-a090-8fff58962f1f/image.png" alt=""></p>
<p>리두 로그 파일의 공간은 매번 기록될 때마다 로그 포지션은 계속 증가된 값을 갖게 되며, 이를 <strong>LSB(Log Sequence Number)</strong>라고 한다. InnoDB 스토리지 엔진은 주기적으로 체크포인트 이벤트를 발생시켜 리두 로그와 버퍼 풀의 더티 페이지를 디스크로 동기화하는데, 가장 최근 체크포인트 지점의 LSN이 활성 리두 로그 공간의 시작점이 된다. 또한 가장 최큰 체크포인트의 LSN과 마지막 리두 로그 엔트리의 LSN의 차이를 <strong>체크포인트 에이지(Checkpoint Age)</strong>라고 하며, 활성 리두 로그 공간의 크기를 나타낸다.</p>
<p>InnoDB 버퍼 풀의 더티 페이지는 특정 리두 로그 엔트리와 관계를 가지고, 체크포인트가 발생하면 체크포인트 LSN보다 작은 리두 고르 엔트리와 관련된 더티 페이지와 리두 로그 엔트리는 모두 디스크로 동기화돼야 한다.</p>
<h4 id="버퍼-풀-플러시">버퍼 풀 플러시</h4>
<p>InnoDB 스토리지 엔진은 버퍼 풀에서 아직 디스크로 기록되지 않은 더티 페이지들을 성능상의 악영향 없이 디스크에 동기화하기 위해 2개의 플러시 기능을 백그라운드로 실행한다.</p>
<p><strong>플러시 리스트(Flushlist) 플러시</strong>
InnoDB 스토리지 엔진은 리두 로그 공간의 재활용을 위해 주기적으로 오래된 리두 로그 엔트리가 사용하는 공간을 비워야 하는데, 이때 반드시 InnoDB 버퍼 풀의 더티 페이지가 먼저 기스크로 동기화돼야 한다. 이를 위해 플러시 리스트(Flush_list) 플러시 함수를 호출해 플러시 리스트에서 오래전에 변경된 데이터 페이지 순서대로 디스크에 동기화하는 작업을 수행하며, 얼마나 많은 더티 페이지를 한 번에 디스크로 기록하느냐에 따라 사용자 쿼리 처리에 영향을 미친다. </p>
<p><strong>LRU 리스트(LRU_list) 플러시</strong>
InnoDB 스토리지 엔진은 LRU 리스트에서 사용 빈도가 낮은 데이터 페이지들을 제거하여 새로운 페이지들을 읽어올 공간을 만드는데, 이때 LRU 리스트(LRU_list) 플러시 함수를 사용해 LRU 리스트의 끝부분부터 최대 <code>innodb_lru_scan_depth</code> 시스템 변수만큼의 페이지들을 스캔하며, 더티 페이지는 디스크에 동기화하고 클린 페이지는 프리(Free) 라스트로 옮긴다.</p>
<h4 id="버퍼-풀-상태-백업-및-복구">버퍼 풀 상태 백업 및 복구</h4>
<p>MySQL 5.6 버전부터 서버 점검이나 기타 작업을 위해 서버를 재시작해야 하는 경우 셧다운 전 InnoDB 버퍼 풀의 상태를 백업할 수 있고, 재시작 시 백업된 버퍼 풀의 상태를 복구할  수 있다.</p>
<p>InnoDB 버퍼 풀의 백업 파일의 크기는 InnoDB 크기가 크다 하더라도 몇십 MB 이하이다. 이는 버퍼 풀의 LRU 리스트에서 적재된 데이터 페이지의 메타 정보만 가져와서 저장하기 때문이며 버퍼 풀의 백업은 매우 빨리 완료된다. 하지만 복구하는 과정은 백업된 내용에서 각 테이블의 데이터 페이지를 다시 디스크에서 읽어와야 하기 때문에 상당한 시간이 걸릴 수 있다.</p>
<h4 id="버퍼-풀의-적재-내용-확인">버퍼 풀의 적재 내용 확인</h4>
<p>MySQL 5.6 버전부터 <code>information_schema</code> 데이터베이스의 <code>innodb_buffer_page</code> 테이블을 이용해 InnoDB 버퍼 풀의 메모리에 어떤 테이블 페이지들이 적재돼 있는지 확인할 수 있었지만, 버퍼 풀이 큰 경우 테이블 조회가 큰 부하를 일으켜 서비스 쿼리 성능 저하를 유발하였다.</p>
<p>MySQL 8.0 버전에서는 <code>innodb_cached_indexes</code> 테이블을 새로 추가해 테이블의 인덱스별로 데이터 페이지가 얼마나 InnoDB 버퍼 풀에 적재돼있는지 확인할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 04. 아키텍처 (1)]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-04.-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Sat, 04 Apr 2026 06:55:28 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="41-mysql-엔진-아키텍처">4.1 MySQL 엔진 아키텍처</h2>
<h3 id="411-mysql의-전체-구조">4.1.1 MySQL의 전체 구조</h3>
<p><img src="https://dev.mysql.com/doc/refman/8.0/en/images/mysql-architecture.png" alt="MySQL 공식 아키텍처"></p>
<h4 id="mysql-엔진">MySQL 엔진</h4>
<p><strong>MySQL 엔진</strong>은 요청된 SQL 문장을 분석하거나 최적화하는 등 DBMS의 두뇌에 해당하는 처리를 수행한다.</p>
<p>클라이언트로부터의 접속 및 쿼리 요청을 처리하는 커넥션 핸들러, SQL 파서 및 전처리기, 쿼리의 최적화된 실행을 위한 옵티마이저가 중심을 이룬다.</p>
<blockquote>
<p>MySQL은 표준 SQL(ANIS SQL) 문법을 지원하므로 표준 문법에 따라 작성된 쿼리는 타 DBMS와 호환되어 실행될 수 있다.</p>
</blockquote>
<h4 id="스토리지-엔진">스토리지 엔진</h4>
<p><strong>스토리지 엔진</strong>은 실제 데이터를 디스크 스토리지에 저장하거나 디스크 스토리지로부터 데이터를 읽어오는 부분을 전담한다.</p>
<p>테이블이 사용할 스토리지 엔진을 지정하면 해당 테이블의 모든 읽기 작업 및 변경 작업은 정의된 스토리지 엔진이 처리한다.</p>
<pre><code class="language-sql">mysql&gt; CREATE TABLE test_table (fd1 INT, fd2 INT) ENGINE=INNODB;</code></pre>
<p>각 스토리지 엔진은 성능 향상을 위해 키 캐시(MyISAM) 혹은 InnoDB 버퍼 풀(InnoDB)과 같은 기능을 내장하고 있다.</p>
<h4 id="핸들러-api">핸들러 API</h4>
<p>핸들러(Handler) 요청이란 MySQL 엔진의 쿼리 실행기에서 데이터를 쓰거나 읽는 경우 스토리지 엔진에 보내는 요청을 의미하며, 사용되는 API를 <strong>핸들러 API</strong>라고 한다.</p>
<h3 id="412-mysql-스레딩-구조">4.1.2 MySQL 스레딩 구조</h3>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/8719f0f8-4f1c-43a5-825b-079540267232/image.png" alt="MySQL 스레딩 구조"></p>
<h4 id="포그라운드-스레드-클라이언트-스레드">포그라운드 스레드 (클라이언트 스레드)</h4>
<p><strong>포그라운드 스레드</strong>는 주로 각 클라이언트 사용자가 요청하는 쿼리 문장을 처리하며, 이를 위해 최소한 MySQL 서버에 접속된 클라이언트 수만큼 존재한다.</p>
<p>클라이언트 사용자가 작업을 마치고 커넥션을 종료하는 경우 해당 커넥션을 담당하던 스레드는 다시 <strong>스레드 캐시(Thread cache)</strong>로 되돌아간다. 이때 스레드 캐시에 일정 개수 이상 대기 중인 스레드가 있는 경우 스레드 캐시에 넣지 않고 종료시켜 일정 개수의 스레드만 존재할 수 있도록 한다.</p>
<p>포그라운드 스레드는 데이터를 MySQL 데이터 버퍼나 캐시로부터 가져오거나 직접 디스크의 데이터나 인덱스 파일로부터 데이터를 읽어 작업을 처리한다.</p>
<p>MyISAM 테이블은 디스크 쓰기 작업까지 포그라운드 스레드가 처리하지만 InnoDB 테이블은 데이터 버퍼나 캐시까지만 포그라운스 스레드가 처리하고, 버퍼로부터 디스크까지 기록하는 작업은 백그라운드 스레드가 처리한다.</p>
<h4 id="백그라운드-스레드">백그라운드 스레드</h4>
<p>InnoDB 백그라운드에서 처리되는 작업 </p>
<ul>
<li>인서트 버퍼를 병합하는 스레드</li>
<li>로그를 디스크로 기록하는 스레드</li>
<li>InnoDB 버퍼 풀의 데이터를 디스크에 기록하는 스레드</li>
<li>데이터를 버퍼로 읽어 오는 스레드</li>
<li>잠금이나 데드락을 모니터링하는 스레드</li>
</ul>
<p>InnoDB의 경우 쓰기 작업을 버퍼링해서 일괄 처리할 수 있어 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 쿼리로 데이터가 변경되는 경우 데이터가 디스크의 데이터 파일로 완전히 저장될 때까지 기다리지 않아도 된다. 하지만 MyISAM에서 일반적인 쿼리는 쓰기 버퍼링 기능을 사용할 수 없다.</p>
<h3 id="413-메모리-할당-및-사용-구조">4.1.3 메모리 할당 및 사용 구조</h3>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/30610777-903f-49df-b14f-83898e9fecd6/image.png" alt="MySQL 메모리 사용 및 할당 구조"></p>
<h4 id="글로벌-메모리-영역">글로벌 메모리 영역</h4>
<p>일반적으로 클라이언트 스레드 수와 무관하게 하나의 메모리 공간만 할당되며, 필요에 따라 2개 이상의 메모리 공간을 할당받을 수 있지만 클라이언트 스레드 수와 무관하며 모든 스레드에 의해 공유된다.</p>
<p>대표적인 글로벌 메모리 영역</p>
<ul>
<li>테이블 캐시</li>
<li>InnoDB 버퍼 풀</li>
<li>InnoDB 어댑티브 해시 인덱스</li>
<li>InnoDB 리두 로그 버퍼</li>
</ul>
<h4 id="로컬-메모리-영역">로컬 메모리 영역</h4>
<p>MySQL 서버상에 존재하는 클라이언트 스레드가 쿼리를 처리하는 데 사용하는 메모리 영역이며 클라이언트 스레드가 사용하는 메모리 공간으로 <strong>클라이언트 메모리 영역</strong> 혹은 클라이언트와 MySQL 서버와의 커넥션을 세션이라고 하기 때문에 <strong>세션 메모리 영역</strong>이라고도 표현한다.</p>
<p>로컬 메모리는 각 클라리언트 스레드별로 독립적으로 할당되며 절대 공유되어 사용되지 않는 다는 특징이 있다. 또한 각 쿼리의 용도별로 필요할 때만 공간이 할당되고 필요하지 않은 경우에는 MySQL이 메모리 공간을 할당조차 하지 않을 수 있다.</p>
<p>대표적인 로컬 메모리 영역</p>
<ul>
<li>정렬 버퍼(Sort Buffer)</li>
<li>조인 버퍼</li>
<li>바이너리 로그 캐시</li>
<li>네트워크 버퍼</li>
</ul>
<h3 id="414-플러그인-스토리지-엔진-모델">4.1.4 플러그인 스토리지 엔진 모델</h3>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/cbf4c26e-20fd-455e-9c11-5e194b526f46/image.png" alt="MySQL 플러그인 모델"></p>
<p>MySQL의 독특한 구조 중 대표적인 플러그인 모델로, 스토리지 엔진 뿐만 아니라 전문 검색 엔진을 위한 검색어 파서를 플러그인 형태로 개발해 사용할 수 있으며, 사용자의 인증을 위한 Native Authentication과 Caching SHA-2 Authentication 등도 모두 플러그인으로 구현되어 제공된다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/31908a0c-af98-4c15-ac2b-900779d96fd6/image.png" alt="MySQL 엔진과 스토리지 엔진의 처리 영역"></p>
<p>MySQL 쿼리가 실행되는 과정에서 대부분의 작업은 MySQL 작업에서 처리되고, 마지막 <strong>데이터 읽기/쓰기</strong> 작업만 스토리지 엔진에서 처리된다.</p>
<p>MySQL 엔진이 스토리지 엔진을 조정하기 위해 <strong>핸들러(Handler)</strong>를 사용하는데, 이는 프로그래밍 언어에서 어떤 기능을 호출하기 위해 사용하는 객체이며, MySQL 엔진이 각 스토리지 엔진에게 데이터를 읽어오거나 저장하도록 명령하려면 반드시 핸들러를 통제해야 한다.</p>
<p>MySQL에서 MyISAM이나 InnoDB와 같이 다른 스토리지 엔진을 사용하는 테이블에 대해 쿼리를 실행하더라도 MySQL 처리 내용은 대부분 동일하며, 단순히 <strong>데이터 읽기/쓰기</strong> 영역의 처리만 차이가 존재하며, 실질적인 <code>GROUP BY</code>, <code>ORDER BY</code> 등 복잡한 처리는 MySQL 엔진의 <strong>쿼리 실행기</strong>에서 처리된다.</p>
<p>MySQL 서버에서는 스토리지 엔진뿐만 아니라 인증이나 전문 검색 파서, 쿼리 재작성과 같은 플러그인이 있으며 비밀번호 검증과 커넥션 제어 등에 관련된 다양한 플러그인이 제공된다. 뿐만 아니라 MySQL 서버의 기능을 확장할 수 있게 플러그인 API가 메뉴얼에 공개돼 있으므로 제공된 기능을 확장하거나 새로운 기능들을 플러그인을 이용해 구현할 수 있다.</p>
<h3 id="415-컴포넌트">4.1.5 컴포넌트</h3>
<p>MySQL 8.0부터는 기존 플러그인 아키텍처의 단점을 보완한 컴포넌트 아키텍처가 지원된다.</p>
<p>플러그인 아키텍처의 단점</p>
<ul>
<li>플러그인은 오직 MySQL 서버와 인터페이스할 수 있고, 플러그인끼리는 통신할 수 없음</li>
<li>플러그인은 MySQL 서버의 변수나 함수를 직접 호출하기 때문에 안전하지 않음(캡슐화 안 됨)</li>
<li>플러그인은 상호 의존 관계를 설정할 수 없어 초기화가 어려움.</li>
</ul>
<h3 id="416-쿼리-실행-구조">4.1.6 쿼리 실행 구조</h3>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/de9ba6a7-92d8-4a03-afa4-7a6e5da782a1/image.png" alt="쿼리 실행 구조"></p>
<h4 id="쿼리-파서">쿼리 파서</h4>
<p>쿼리 파서는 사용자 요청으로 들어온 쿼리 문장을 토큰(MySQL이 인식할 수 있는 최소 단위의 어휘나 기호)으로 분리해 트리 형태의 구조로 만들어 내는 작업을 의미한다. 쿼리 문장의 기본 문법 오류는 이 과정에서 발견되며 사용자에게 오류 메세지를 전달한다.</p>
<h4 id="전처리기">전처리기</h4>
<p>파서 트리를 기반으로 각 토큰의 테이블 혹은 칼럼의 이름, 또는 내장 함수와 같은 개체를 매핑해 해당 객체의 존재 여부와 접근 권한 등을 확인하며 토큰을 걸러낸다.</p>
<h4 id="옵티마이저">옵티마이저</h4>
<p>옵티마이저란 사용자의 요청으로 들어온 쿼리 문장을 저렴한 비용으로 가장 빠르게 처리할지를 결정하는 역할로, DBMS의 두뇌에 해당한다.</p>
<h4 id="실행-엔진">실행 엔진</h4>
<p>실행 엔진은 만들어진 계획대로 각 핸들러에게 요청해서 받은 결과를 또 다른 핸들러 요청의 입력으로 연결하는 역할을 수행한다.</p>
<h4 id="핸들러-스토리지-엔진">핸들러 (스토리지 엔진)</h4>
<p>핸들러는 서버의 가장 밑단에서 MySQL 실행 엔진의 요청에 따라 데이터를 디스크로 저장하고 디스크로부터 읽어 오는 역할을 담당한다.</p>
<h3 id="418-쿼리-캐시">4.1.8 쿼리 캐시</h3>
<p>쿼리 캐시는 SQL 실행 결과를 메모리에 캐시하고, 동일 SQL 쿼리가 실행되면 테이블을 읽지 않고 즉시 결과를 반환해 매우 빠른 성능을 보였지만 테이블의 데이터가 변경되면 캐시의 저장된 결과를 모두 삭제해 동시 처리 성능 저하를 유발하기도 하였다. 이로 인해 MySQL 8.0부터 제거하게 되었다.</p>
<h3 id="419-스레드풀">4.1.9 스레드풀</h3>
<p>스레드 풀은 내부적으로 사용자의 요청을 처리하는 스레드 개수를 줄여 동시 처리되는 요청이 많더라도 MySQL 서버의 CPU가 재한된 개수의 스레드 처리에만 집중할 수 있게 해서 CPU의 프로세서 친화도(Processor affinity)를 높이고 운영체제 입장에서 불필요한 컨텍스트 스위치(Context switch)를 줄여 오버헤드를 낮출 수 있다.</p>
<blockquote>
<p>스레드 풀의 사용으로 성능 향상을 보여준 경우는 드물며, 오히려 스케줄링 과정에서 CPU 시간을 제대로 확보하지 못하는 경우 쿼리 처리가 더 느려질 수도 있다.</p>
</blockquote>
<h3 id="4110-트랜잭션-지원-메타데이터">4.1.10 트랜잭션 지원 메타데이터</h3>
<p>데이터베이스 서버에서 테이블 구조 정보와 스토어드 프로그램 등의 정보를 데이터 딕셔너리 또는 메타데이터라고 하는데, MySQL 5.7까지 이를 파일 기반으로 관리했으나, 이는 생성 및 변경 작업이 트랜잭션을 지원하지 않아 문제가 발생하기도 하여 MySQL 8.0 버전부터는 관련 정보를 모두 InnoDB 테이블에 저장하도록 개선했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8.0 1권] 03. 사용자 및 권한]]></title>
            <link>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-03.-%EC%82%AC%EC%9A%A9%EC%9E%90-%EB%B0%8F-%EA%B6%8C%ED%95%9C</link>
            <guid>https://velog.io/@youhyuk_/Real-MySQL-8.0-1%EA%B6%8C-03.-%EC%82%AC%EC%9A%A9%EC%9E%90-%EB%B0%8F-%EA%B6%8C%ED%95%9C</guid>
            <pubDate>Sat, 28 Mar 2026 05:46:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.</p>
<hr>
<h2 id="31-사용자-식별">3.1 사용자 식별</h2>
<p>MySQL의 경우 타 RDBMS와 다르게 사용자의 계정 뿐만 아니라 접속 지점 또한 계정의 일부가 되므로 아이디와 호스트를 함께 명시해야 한다. </p>
<p>MySQL 서버의 계정 선택 기준은 <strong>항상 범위가 가장 작은 것</strong>을 먼저 선택한다. 
예를 들어 아래와 같이 ID가 동일하지만 IP가 지정된 계정과, 모든 IP를 허용하는 계정이 각각 존재하는 경우에 IP가 192.168.0.10인 PC에서 svc_id에 로그인 한다면 범위가 더 작은 첫 번째 계정을 선택하게 되므로 비밀번호는 123을 입력해야 한다.</p>
<pre><code>&#39;svc_id&#39;@&#39;192.168.0.10&#39; (비밀번호 123)
&#39;svc_id&#39;@&#39;%&#39; (비밀번호 abc)</code></pre><blockquote>
<p>% 문자는 모든 IP 또는 모든 호스트명을 의미하며, 모든 외부 컴퓨터에서 접속이 가능한 사용자 계정을 생성하고 싶은 경우 호스트 부분에 % 문자를 사용할 수 있다.</p>
</blockquote>
<p>이와 같이 중복되는 계정을 생성하게 되는 경우 원하는 계정으로 로그인 할 수 없는 문제가 발생할 수 있기 때문에 주의해야 한다.</p>
<h2 id="32-사용자-계정-관리">3.2 사용자 계정 관리</h2>
<h3 id="321-시스템-계정과-일반-계정">3.2.1 시스템 계정과 일반 계정</h3>
<p>MySQL 8.0부터 <strong>SYSTEM_USER</strong> 권한 유무에 따라 <strong>시스템 계정(System Account)</strong>과 <strong>일반 계정(Regular Account)</strong>로 구분된다.</p>
<h4 id="시스템-계정">시스템 계정</h4>
<ul>
<li>데이터베이스 서버 관리자를 위한 계정</li>
<li>계정 관리(계정 생성 및 삭제, 계정의 권한 부여 및 제거)</li>
<li>다른 세션 또는 그 세션에서 실행중인 쿼리 강제 종료</li>
<li>스토어드 프로그램 생성 시 DEFINER를 타 사용자로 설정</li>
</ul>
<p>MySQL 서버에 내장된 계정들 중 &#39;root&#39;@&#39;localhost&#39;를 제외한 3개의 계정은 각기 다른 목적으로 사용되므로 삭제하지 않도록 주의가 필요하며, 기본적으로 잠겨있는 상태이다.</p>
<ol>
<li>&#39;mysql.sys&#39;@&#39;localhost&#39;- MySQL 8.0부터 기본으로 내장된 sys 스키마의 객체(뷰, 함수, 프로시저)들의 DEFINER로 사용되는 계정</li>
<li>&#39;mysql.session&#39;@&#39;localhost&#39; - MySQL 플러그인이 서버로 접근할 때 사용되는 계정 </li>
<li>&#39;mysql&#39;infoschema&#39;@&#39;localhost&#39; - information_schema에 정의된 뷰의 DEFINER로 사용되는 계정</li>
</ol>
<h3 id="322-계정-생성">3.2.2 계정 생성</h3>
<p>MySQL 8.0 버전부터는 계정의 생성은 <code>CREATE USER</code>, 권한 부여는 <code>GRANT</code> 명령으로 구분해서 실행된다.</p>
<p><strong>계정 생성 시 설정할 수 있는 옵션</strong></p>
<ul>
<li>계정의 인증 방식 및 비밀번호</li>
<li>비밀번호 관련 옵션 (유효기간, 이력 개수, 재사용 불가 기간)</li>
<li>기본 역할 (Role)</li>
<li>SSL 옵션</li>
<li>계정 잠금 여부</li>
</ul>
<h2 id="33-비밀번호-관리">3.3 비밀번호 관리</h2>
<h3 id="331-고수준-비밀번호">3.3.1 고수준 비밀번호</h3>
<p>MySQL 서버의 비밀번호는 이를 쉽게 유추할 수 있는 단어들이 사용되지 않게 글자의 조합을 강제하거나 금칙어를 설정하는 기능이 있다.</p>
<pre><code class="language-sql">## validate_password 컴포넌트 설치
mysql&gt; INSTALL COMPNENT &#39;file://component_validate_password&#39;

## 설치된 컴포넌트 확인
mysql&gt; SELECT * FROM mysql.component;

## 컴포넌트에서 제공하는 시스템 변수 확인
mysql&gt; SHOW GLOBAL VARIABLES LIKE &#39;validate_password%&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/837141ae-4e49-4a29-ba2b-5116437fd413/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/c2d0f2d7-c97b-43fa-b846-77f8de228e94/image.png" alt=""></p>
<p>비밀번호 정책은 크게 3가지 중 선택 가능하며, 기본값은 MEDIUM이다.</p>
<ul>
<li>LOW: 비밀번호 길이만 검증</li>
<li>MEDIUM: 비밀번호 길이 검증, 숫자와 대소문자, 특수문자의 배합 검증</li>
<li>STRONG: MEDIUM 레벨 검증 + 금칙어 포함 여부 검증</li>
</ul>
<p>금칙어를 처리하고 싶은 경우, <code>validate_password.dictionary_file</code> 시스템 변수에 금칙어들이 저장되니 사전 파일을 등록하면 된다. 
또한 금칙어를 적용하는 경우 <code>validate_password.policy</code> 시스템 변수를 STRONG으로 함께 변경해야 한다.</p>
<h3 id="32-이중-비밀번호">3.2 이중 비밀번호</h3>
<p>MySQL 서버의 이중 비밀번호 기능은 하나의 계정에 대해 2개의 비밀번호를 동시에 설정할 수 있는데, 2개의 비밀번호는 <strong>프라이머리(Primary)</strong>와 <strong>세컨더리(Secondary)</strong>로 구분된다.</p>
<p>최근에 설정된 비밀번호가 프라이머리 비밀번호, 이전 비밀번호가 세컨더리 비밀번호가 되며, 이중 비밀 번호를 사용하려면 기존 비밀번호 변경 문구에 <code>RETAIN CURRENT PASSWORD</code> 옵션을 추가한다.</p>
<pre><code class="language-sql">## 비밀번호를 &quot;abcdef&quot;로 설정
mysql&gt; ALTER USER &#39;root&#39;@&#39;localhost&#39; IDENTIFIED BY &#39;old_password&#39;;

## 비밀번호를 &quot;123456&quot;로 변경하며, 기존 비밀번호 세컨더리 비밀번호 설정
mysql&gt; ALTER USER &#39;root&#39;@&#39;localhost IDENTIFIED BY &#39;new_password&#39; RETAIN CURRENT PASSWORD;</code></pre>
<p>이후 MySQL 서버에 접속하는 모든 응용 프로그램의 재시작이 완료된 경우 계정의 보안을 위해 다음 명령으로 세컨더리 비밀번호를 삭제하여, 기존 비밀번호로는 로그인이 불가능해진다.</p>
<pre><code class="language-sql">mysql&gt; ALTER USER &#39;root&#39;@&#39;localhost&#39; DISCARD OLD PASSWORD;</code></pre>
<h2 id="34-권한privilege">3.4 권한(Privilege)</h2>
<p>MySQL 8.0 버전부터는 MySQL 5.7 버전의 <strong>정적 권한</strong>에 <strong>동적 권한</strong>이 더 추가되었다.</p>
<blockquote>
<p>정적 권한 - MySQL 서버의 소스코드에 고정적으로 명시돼 있는 권한
동적 권한 - MySQL 서버가 시작되면서 동적으로 생성하는 권한</p>
</blockquote>
<p>사용자에게 권한을 부여할 때는 <code>GRANT</code> 명령을 사용하며, 각 권한의 특성(범위)에 따라 <code>GRANT</code> 명령의 <code>ON</code> 절에 명시되는 오브젝트(DB, 테이블)의 내용이 바뀌어야 한다.</p>
<pre><code class="language-sql">mysql&gt; GRANT privilege_list ON db.table TO &#39;user&#39;@&#39;host&#39;;</code></pre>
<p><strong>글로벌 권한</strong></p>
<pre><code class="language-sql">mysql&gt; GRANT SUPER ON *.* TO &#39;user&#39;@&#39;localhost&#39;;</code></pre>
<p>글로벌 권한은 특정 DB나 테이블에 부여될 수 없기 때문에 항상 <code>ON</code> 절에 모든 DB의 오브젝트를 포함한 MySQL 서버 전체를 의미하는 *.*를 사용하게 된다.</p>
<p><strong>DB 권한</strong></p>
<pre><code class="language-sql">mysql&gt; GRANT EVENT ON *.* TO &#39;user&#39;@&#39;localhost&#39;;
mysql&gt; GRANT EVENT ON employees.* TO &#39;user&#39;@&#39;localhost&#39;;</code></pre>
<p>DB 권한은 특정 DB 혹은 서버에 존재하는 모든 DB에 권한을 부여할 수 있기 때문에 위처럼 *.* 혹은 employees.* 모두 사용할 수 있지만, 테이블까지 명시할 수는 없다.</p>
<p><strong>테이블 권한</strong></p>
<pre><code class="language-sql">mysql&gt; GRANT SELECT,INSERT,UPDATE,DELETE ON *.* TO &#39;user&#39;@&#39;localhost&#39;;
mysql&gt; GRANT SELECT,INSERT,UPDATE,DELETE ON employees.* TO &#39;user&#39;@&#39;localhost&#39;;
mysql&gt; GRANT SELECT,INSERT,UPDATE,DELETE ON employees.department TO &#39;user&#39;@&#39;localhost&#39;;</code></pre>
<p>테이블 권한은 모든 DB, 특정 DB, 그리고 특정 DB의 특정 테이블에 대해서만 권한을 부여하는 것이 가능하다.</p>
<p>테이블의 특정 컬럼에 부여할 수 있는 권한은 <code>DELETE</code>를 제외한 <code>INSERT</code>, <code>UPDATE</code>, <code>SELECT</code> 3가지로, 각 권한 뒤에 컬럼을 명시하는 형태로 부여한다.</p>
<pre><code class="language-sql">mysql&gt; GRANT SELECT,INSERT,UPDATE(dept_name) ON employees.department TO &#39;user&#39;@&#39;localhost&#39;;</code></pre>
<p>여러 레벨이나 범위로 권한을 설정하는 것이 가능하지만, 테이블이나 컬럼 단위 권한을 설정하게 된다면 나머지 모든 테이블, 모든 컬럼에 대한 권한 체크를 하기 때문에 전체 성능에 영향이 갈 수 있어 지양하는 편이다.
컬럼 단위의 접근 권한이 정말 필요한 경우 테이블에서 권한을 허용한 뒤 별도의 뷰를 만드는 방법도 있다.</p>
<h2 id="35-역할role">3.5 역할(Role)</h2>
<p>MySQL 8.0 버전부터 권한을 묶어서 <strong>역할(Role)</strong>을 사용할 수 있게 됐다.
역할을 정의한 뒤 실질적인 권한을 부여하고, 이후 해당 역할을 계정에 부여하는 방식으로 사용한다.</p>
<pre><code class="language-sql">## 역할 정의
mysql&gt; CREATE ROLE
        role_emp_read,
        role_emp_write;

## 역할에 권한 부여
mysql&gt; GRANT SELECT ON employees.* TO role_emp_read;
mysql&gt; GRANT INSERT, UPDATE, DELETE ON employees.* TO role_emp_write;

## 계정에 역할 부여
mysql&gt; GRANT role_emp_read TO reader@&#39;127.0.0.1&#39;;
mysql&gt; GRANT role_emp_read, role_emp_write TO writer@&#39;127.0.0.1&#39;;</code></pre>
<p> 위 과정으로 역할을 생성하여 계정에 역할을 부여하는 작업을 완료하였지만, 실제로 DB 데이터 조회 혹은 변경 시 권한이 없다는 에러를 만나게 된다. 이는 <code>SET ROLE</code> 명령을 실행해 해당 역할을 활성화해야만 한다.</p>
<pre><code class="language-sql"> ## 역할 활성화
 mysql&gt; SET ROLE &#39;role_emp_read&#39;;</code></pre>
<p> 기본적으로 MySQL 서버는 역할이 자동으로 활성화되지 않게 설정되어있다.
 자동으로 활성화하기 위해서는 <code>activate_all_roles_on_login</code> 시스템 변수를 아래와 같이 변경하면 된다.</p>
<pre><code class="language-sql"> mysql&gt; SET GLOBAL activate_all_roles_on_login=ON;</code></pre>
<p>MySQL 서버의 역할은 사용자 계정과 거의 같은 모습이며, 서버 내부적으로 역할과 계정을 동일한 객체로 취급된다. 단지 하나의 사용자 계정에 다른 사용자 계정이 가진 권한을 병합해 권한 제어가 가능하도록 한 것이다.</p>
<p>역할을 생성하는 경우 호스트 부분을 별도로 명시하지 않는 경우, 자동으로 모든 호스트(%)가 추가된다. 아래 두 <code>CREATE ROLE</code> 명령은 동일한 역할을 만들게 된다.</p>
<pre><code class="language-sql">mysql&gt; CREATE ROLE
        role_emp_read,
        role_emp_write;

mysql&gt; CREATE ROLE
        role_emp_read@&#39;%&#39;,
        role_emp_write@&#39;%&#39;;</code></pre>
<p>역할과 계정은 내외부적으로 동일한 객체이지만 MySQL 서버에서 <code>CREATE ROLE</code> 명령과 <code>CREATE USER</code> 명령을 구분해서 지원하는 이유는 데이터베이스 관리의 직무를 분리할 수 있게 해서 보안을 강화하는 용도로 사용될 수 있게 하기 위해서다. </p>
<p><code>CREATE USER</code> 명령에 대해서는 권한이 없지만 <code>CREATE ROLE</code>명령만 실행 가능한 사용자는 역할을 생성할 수 있다. 이렇게 생성된 역할은 계정과 동일한 객체를 생성하지만, 실제 이 역할은 <code>account_locked</code> 컬럼의 값이 &#39;Y&#39;로 설정되어 로그인 용도로 사용할 수 없게 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[백준] 12865 평범한 배낭 - JAVA]]></title>
            <link>https://velog.io/@youhyuk_/%EB%B0%B1%EC%A4%80-12865-%ED%8F%89%EB%B2%94%ED%95%9C-%EB%B0%B0%EB%82%AD-JAVA</link>
            <guid>https://velog.io/@youhyuk_/%EB%B0%B1%EC%A4%80-12865-%ED%8F%89%EB%B2%94%ED%95%9C-%EB%B0%B0%EB%82%AD-JAVA</guid>
            <pubDate>Wed, 18 Mar 2026 08:06:58 GMT</pubDate>
            <description><![CDATA[<h2 id="🔗-문제">🔗 문제</h2>
<p><a href="https://www.acmicpc.net/problem/12865">백준 12865 - 평범한 배낭</a></p>
<h2 id="🔎-풀이-과정">🔎 풀이 과정</h2>
<p>Knapsack problem으로 불리는 꽤 유명한 문제인데, 해당 문제는 짐의 무게가 쪼개지지 않는 0-1 Knapsack problem이다. </p>
<p>배낭에 넣을 물건들은 모두 무게와 가치를 가지고 있으며, 주어진 배낭의 무게를 넘지 않는 최대한의 가치값을 구해야 한다. 이는 다이나믹 프로그래밍을 사용하여 해결하였다.</p>
<p>각 물건에 대해 <strong>넣는다 / 넣지 않는다</strong> 두 가지 선택을 비교하여 더 큰 가치를 선택하는 것이 이 문제의 핵심이다.</p>
<hr>
<p>우선 주어진 테스트케이스에 대해서 고려해보겠다. 주어진 물건의 개수는 4개이며 최대 무게는 7이다. 각 물건의 무게 및 가치$(W, V)$는 $(6, 13), (4, 8), (3, 6), (5, 12)$이다.</p>
<p>dp 배열의 경우 아래와 같이 정의한다.</p>
<blockquote>
</blockquote>
<p>dp[i][k]는 배낭의 무게가 $k$일 때 $i$번째 물건까지 고려했을 때의 최대 가치를 뜻한다.</p>
<p>이를 표로 나타내면 아래와 같으며, 각 칸의 값은 dp[i][k]가 된다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/fa3e329e-efe2-41ad-868d-9a3734f130ca/image.png" alt=""></p>
<p>$k$가 0일 경우, 배낭의 무게가 0이므로 어떠한 물건도 담을 수 없다. 물건들 중 가장 가벼운 물건의 무게는 3이므로, $k$가 1, 2인 경우에도 물건을 담을 수 없으므로 최대 가치는 0이 된다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/2789aac1-0270-46ea-b8b3-eb92b2f1af8f/image.png" alt=""></p>
<p>$k$가 3인 경우, 첫 번째 물건과 두 번째 물건의 무게는 각각 6, 4로 3을 초과하여 배낭에 넣을 수 없다. </p>
<p>세 번째 물건의 무게는 3으로, 배낭의 무게를 넘지 않으므로 넣을 수 있으므로 dp[3][3]은 세 번째 물건의 가치인 6이 된다. </p>
<p>이후 네 번째 물건을 고려하더라도, 무게가 5로 $k = 3$을 초과하므로 값을 갱신할 수 없다. 따라서 해당 값은 이전 상태(dp[3][3] = 6)를 그대로 유지한다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/970fdaad-685b-49ef-b1e0-808f0afe85a8/image.png" alt=""></p>
<p>$k$가 4인 경우, 첫 번째 물건의 무게는 6으로 배낭에 넣을 수 없다.</p>
<p>두 번째 물건의 무게는 4로 해당 물건의 가치인 8이 된다. </p>
<p>세 번째 물건의 무게는 3으로, 배낭에 넣을 수 있는 무게이다. 하지만 해당 물건의 가치는 6으로 두 번째 물건을 넣었을 때의 가치인 8보다 작다. 따라서 해당 값은 8이 된다. </p>
<p>네 번째 물건은 무게가 초과하므로 이전 값인 8이 된다.</p>
<p>해당 과정은 $k = 6$까지 위 과정을 진행한 테이블의 상태이다.</p>
<p><img src="https://velog.velcdn.com/images/youhyuk_/post/84ef0550-44de-4f38-ad1a-56fa338132e4/image.png" alt=""></p>
<p>마지막으로 $k$가 7인 경우를 확인하자. </p>
<p>첫 번째 물건의 경우 배낭에 담을 수 있으므로 가치값 13이 된다. </p>
<p>두 번째 물건을 넣는 경우 배낭의 무게가 7일 때 두 번째 물건을 넣게 된다면 배낭의 남은 무게가 3이 되어 물건을 추가로 넣을 수 있게 된다. (이전 과정에서는 배낭의 남은 무게에 물건을 넣을 수 없으므로 생략했다.) </p>
<p>이를 이전 상태의 값과 비교하여 더 큰 값이 최대 가치가 된다.</p>
<p>$$
dp[2][7] = \max(dp[1][7],\ dp[1][3] + 8)
$$</p>
<p>이는 첫 번째 물건을 탐색했을 때의 최대 가치(dp[1][7]) 13보다 작은 값이므로, 해당 칸의 값은 13이 된다.</p>
<p>다음으로 세 번째 물건이다. 무게가 3인 물건을 배낭에 넣게 된다면 남은 무게는 4 이므로, 해당 물건의 가치인 6과 세 번째 물건을 넣기 전 무게가 4일 때 최대 가치값을 더한 뒤 이전 값과 비교한다.
$$
dp[3][7] = \max(dp[2][7],\ dp[2][4] + 6)
$$</p>
<p>두 번째 물건까지 탐색했을 때의 최대 가치(dp[2][7]) 13보다 큰 값이므로 해당 칸의 값은 14가 된다.</p>
<p>마지막 네 번째 물건도 동일한 방식으로 계산하며, 최종적으로 dp[4][7] = 14가 되어 최대 가치는 14가 된다.
$$
dp[4][7] = \max(dp[3][7],\ dp[3][2] + 12)
$$</p>
<p>완성된 테이블은 아래와 같다.
<img src="https://velog.velcdn.com/images/youhyuk_/post/f22e44e9-0b94-42f8-860d-66df18fa3b0f/image.png" alt=""></p>
<hr>
<p>위와 같은 각 칸의 최대 가치를 구하는 로직을 수식으로 표현하면 아래와 같다. $W_i$ 와 $V_i$는 각각 $i$번째 물건의 무게와 가치를 의미한다.
$$
dp[i][k] =
\begin{cases}
dp[i-1][k] &amp; (W_i &gt; k) \
\max(dp[i-1][k],\ dp[i-1][k - W_i] + V_i) &amp; (W_i \le k)
\end{cases}
$$</p>
<h2 id="🖥️-코드">🖥️ 코드</h2>
<pre><code class="language-java">import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(System.out));
        StringTokenizer stringTokenizer = new StringTokenizer(bufferedReader.readLine());

        int n = Integer.parseInt(stringTokenizer.nextToken());
        int k = Integer.parseInt(stringTokenizer.nextToken());
        Item[] items = new Item[n + 1];

        int[][] dp = new int[n + 1][k + 1];

        for (int i = 1; i &lt;= n; i++) {
            stringTokenizer = new StringTokenizer(bufferedReader.readLine());
            int w = Integer.parseInt(stringTokenizer.nextToken());
            int v = Integer.parseInt(stringTokenizer.nextToken());
            items[i] = new Item(w, v);
        }

        // i -&gt; 아이템 인덱스
        for (int i = 1; i &lt;= n; i++) {
            Item item = items[i];
            for (int j = 1; j &lt;= k; j++) {
                if (item.w &gt; j) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - item.w] + item.v);
                }
            }
        }

        bufferedWriter.write(String.valueOf(dp[n][k]));
        bufferedWriter.flush();
        bufferedReader.close();
        bufferedWriter.close();
    }

    static class Item {
        int w;
        int v;

        public Item(int w, int v) {
            this.w = w;
            this.v = v;
        }
    }
}

</code></pre>
]]></description>
        </item>
    </channel>
</rss>