<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jeong_hun_hui.log</title>
        <link>https://velog.io/</link>
        <description>DB를 사랑하는 백엔드 개발자입니다. 열심히 공부하고 열심히 기록합니다.</description>
        <lastBuildDate>Tue, 04 Mar 2025 09:39:48 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jeong_hun_hui.log</title>
            <url>https://velog.velcdn.com/images/jeong_hun_hui/profile/c13b1dc8-8b63-48b8-abc0-9e6ddf5d746b/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jeong_hun_hui.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jeong_hun_hui" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[티켓팅 서비스 동시성 문제 해결하기 - 2. 구매 제한 수량 초과 문제]]></title>
            <link>https://velog.io/@jeong_hun_hui/%ED%8B%B0%EC%BC%93%ED%8C%85-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2.-%EA%B5%AC%EB%A7%A4-%EC%A0%9C%ED%95%9C-%EC%88%98%EB%9F%89-%EC%B4%88%EA%B3%BC-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@jeong_hun_hui/%ED%8B%B0%EC%BC%93%ED%8C%85-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2.-%EA%B5%AC%EB%A7%A4-%EC%A0%9C%ED%95%9C-%EC%88%98%EB%9F%89-%EC%B4%88%EA%B3%BC-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Tue, 04 Mar 2025 09:39:48 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/45868702-6629-416e-b52e-07483ef8d953/image.gif" alt=""></p>
<p><a href="https://velog.io/@jeong_hun_hui/%ED%8B%B0%EC%BC%93%ED%8C%85-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-1.-%EC%A4%91%EB%B3%B5-%EC%98%88%EB%A7%A4-%EB%AC%B8%EC%A0%9C">이전 글</a>에서 티켓팅 서비스를 구현한 뒤 부하 테스트를 진행하면서 두 가지 동시성 문제를 발견했다.</p>
<p>그 중 첫 번째 문제를 다양한 방식을 비교해본 뒤 낙관적 락을 통해 해결했다.</p>
<p>이번에는 1인당 티켓 구매 제한 수량을 초과한 문제를 해결해보자.</p>
<h2 id="테스트-환경-및-결과">테스트 환경 및 결과</h2>
<p>테스트 환경 및 테스트 결과는 아래 링크를 통해 확인할 수 있다.</p>
<ul>
<li><a href="https://velog.io/@jeong_hun_hui/%ED%8B%B0%EC%BC%93%ED%8C%85-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-1.-%EC%A4%91%EB%B3%B5-%EC%98%88%EB%A7%A4-%EB%AC%B8%EC%A0%9C#%EC%9A%94%EA%B5%AC%EC%82%AC%ED%95%AD">https://velog.io/@jeong_hun_hui/티켓팅-서비스-동시성-문제-해결하기-1.-중복-예매-문제#요구사항</a></li>
</ul>
<h2 id="구매-제한-수량-초과-문제-해결하기">구매 제한 수량 초과 문제 해결하기</h2>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/da065901-4cde-4f1a-95f8-7c39b58eb582/image.png" alt=""></p>
<p>현재 시나리오에서는 1인당 최대 4매의 티켓을 구매할 수 있다.</p>
<p>하지만 이전에 진행한 부하 테스트 결과, 4매 보다 더 많은 티켓을 구매한 유저가 발생하였다.</p>
<p>왜 이러한 문제가 생겼는지 분석해보자.</p>
<h3 id="원인-분석---결제-완료-api">원인 분석 - 결제 완료 API</h3>
<p>구매 제한 초과 여부는 <code>결제 완료 API</code>에서 검사하고 있다. 아래는 <code>결제 완료 API</code>를 구현한 코드이다.</p>
<pre><code class="language-kotlin">@Transactional
fun completePayment(req: PaymentCompleteRequest) {
    // 1. 예매 조회
    val reservation = reservationRepository.getById(req.reservationId)

    /** 중간 로직 생략 */

    // 2-1. 구매한 티켓 수 조회
    val paidTicketCount = reservationRepository.getPaidTickets(reservation.roundId, req.userId).size
    // 2-2. 구매 제한 초과 확인(구매 제한 수량 &lt; 결제한 티켓 수 + 결제하려는 티켓 수)
    val isCountExceed = performance.maxCount &lt; reservation.tickets.size + paidTicketCount
    if (isCountExceed) throw BusinessException(RESERVATION_COUNT_EXCEED)

    // 3. 예매 확정 및 결제 완료
    reservationRepository.save(reservation.confirm(/** */))
    val payment = paymentRepository.getById(req.paymentId)
    paymentRepository.save(payment.complete())
}</code></pre>
<p>결제 완료 API는 아래와 같은 과정으로 이루어진다.</p>
<ol>
<li>예매 조회</li>
<li><code>결제한 티켓 수 + 결제하려는 티켓 수</code>가 구매 제한을 초과했는지 확인</li>
<li>예매 확정 및 결제 완료</li>
</ol>
<p>이 과정에서 같은 유저가 동시에 결제 요청을 보내게 되면 어떻게 될까?</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/f02104e3-3d7b-4ae2-85ca-22895ff29227/image.png" alt=""></p>
<p>트랜잭션 A와 B가 동시에 구매한 티켓들을 조회하고, 구매 제한을 초과하지 않았기 때문에 두 트랜잭션 모두 결제 및 예매 완료 처리를 하게되며 1인당 구매 제한을 초과하는 문제가 발생하게 되었다.</p>
<p>이제 원인을 알았으니 해결 방법을 찾아보자.</p>
<h3 id="해결-방법-1-낙관적-락-→-x">해결 방법 1. 낙관적 락 → X</h3>
<p>이전에 경험한 중복 예매 문제 처럼 낙관적 락을 적용하면 해결될까?</p>
<p>결론부터 말하자면 아니다. 낙관적 락을 적용하고 테스트를 진행한 결과는 아래와 같다.</p>
<table>
<thead>
<tr>
<th>테스트 회차</th>
<th>1차</th>
<th>2차</th>
<th>3차</th>
<th>4차</th>
<th>5차</th>
<th>avg</th>
</tr>
</thead>
<tbody><tr>
<td>구매 제한 초과</td>
<td>0명</td>
<td>1명</td>
<td>1명</td>
<td>1명</td>
<td>0명</td>
<td>0.6명</td>
</tr>
</tbody></table>
<p>왜 낙관적 락을 적용했는데 문제가 해결되지 않았을까?</p>
<p>낙관적 락은 업데이트 대상의 version 컬럼 정보를 통해 충돌을 감지한다. 그러므로 <strong>여러 트랜잭션이 동시에 같은 row를 업데이트 하는 경우에 효과가 있다.</strong></p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/edde5b1b-39af-4c9a-8c10-c8d84846c4a4/image.png" alt=""></p>
<p>하지만, 결제 완료 API는 구매한 티켓 수를 확인하기 위해 조회하는 티켓들은 두 트랜잭션 모두 같지만, 업데이트 하는 티켓들은 각 트랜잭션 마다 다르다.</p>
<ul>
<li>구매한 티켓 수를 확인하기 위해 조회하는 티켓들 - A, B 모두: 1, 2</li>
<li>업데이트하는 티켓들 - A: 3, 4 / B: 5, 6</li>
</ul>
<p>그렇기 때문에, 낙관적 락으로는 구매 제한 수량 초과 문제를 해결할 수 없다.</p>
<h3 id="해결-방법-2-비관적-락">해결 방법 2. 비관적 락</h3>
<pre><code class="language-kotlin">@Query(
    &quot;&quot;&quot;
    SELECT t
    FROM ReservationEntity r
    JOIN r.tickets t
    WHERE t.performanceRoundId = :roundId
    AND r.userId = :userId
    AND t.isPaid = true
    &quot;&quot;&quot;,
)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun getPaidTicketsByRoundIdAndUserIdWithPessimistic(
    roundId: UUID,
    userId: UUID,
): List&lt;TicketEntity&gt;</code></pre>
<p>그렇다면, 위와 같이 비관적 락을 적용시키면 되지 않을까? 이렇게 하면 유저가 구매한 티켓들을 조회할 때 락이 걸리기 때문에, 문제를 해결할 수 있을 것 같다.</p>
<table>
<thead>
<tr>
<th>테스트 회차</th>
<th>1차</th>
<th>2차</th>
<th>3차</th>
<th>4차</th>
<th>5차</th>
<th>avg</th>
</tr>
</thead>
<tbody><tr>
<td>구매 제한 초과 유저 수</td>
<td>0명</td>
<td>0명</td>
<td>0명</td>
<td>0명</td>
<td>0명</td>
<td>0명</td>
</tr>
<tr>
<td>- 유저 당 티켓 구매 수</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>![](https://velog.velcdn.com/images/jeong_hun_hui/post/2d91a3db-0f2f-4eab-b818-3fec5d183891/image.png)

- 구매 제한 수량인 4를 초과한 유저가 없다.</code></pre><p>예상대로 잘 해결된 모습이다.</p>
<p>하지만, 이전 동시성 문제 해결 시 비관적 락으로 인해 Lock 대기 시간이 높아져서 성능이 저하되는 문제가 있었다. 과연 문제가 없을지 <strong>쿼리의 실행계획</strong>과 <strong>Lock이 걸리는 범위</strong>를 분석해보자.</p>
<h2 id="구매-티켓-조회-쿼리-분석">구매 티켓 조회 쿼리 분석</h2>
<blockquote>
<p>📢 해당 분석은 MySQL 8.0.35, InnoDB 환경에서 진행되었습니다.</p>
</blockquote>
<ul>
<li><p><strong>SQL</strong></p>
<pre><code class="language-sql">  -- 구매 티켓 조회 쿼리 --
  SELECT t
  FROM reservation r
  JOIN ticket t ON r.id = t.reservation_id
  WHERE t.performance_round_id = {roundId}
  AND r.user_id = {userId}
  AND t.is_paid = TRUE
  FOR UPDATE;</code></pre>
</li>
<li><p><strong>Explain</strong></p>
</li>
</ul>
<pre><code>| id | select_type | table | type | key | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | reservation | ref | idx_reservation_user_id | const | 2 | 100 | Using where; **Using index** |
| 1 | SIMPLE | ticket | ref | fk_reservation_ticket | reservation.id | 5 | 5 | Using where |</code></pre><h3 id="실행-계획-분석">실행 계획 분석</h3>
<p>실행 계획을 바탕으로 구매 티켓 조회 쿼리가 실행되는 과정을 정리하면 아래와 같다.</p>
<ol>
<li>reservation 테이블에서 user_id가 <code>{userId}</code>인 row들을 조회</li>
</ol>
<pre><code>| id | select_type | table | type | key | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | reservation | ref | idx_reservation_user_id | const | 2 | 100 | Using where; **Using index** |
- Extra의 `Using index`를 통해 해당 쿼리는 reservation 테이블에 대한 직접적인 접근 없이 인덱스(`idx_reservation_user_id`)만으로 처리되었음을 알 수 있다. 즉, **커버링 인덱스**가 적용된 것이다. 테이블에 대한 접근 없이 인덱스만으로 처리되었기 때문에 성능적으로 더 좋다.
- InnoDB의 세컨더리 인덱스는 PK를 포함하고 있기 때문에, 위 쿼리에서 필요한 `reservation.id`와 `user_id` 컬럼은 전부 `idx_reservation_user_id` 인덱스에 포함되어 있다.</code></pre><ol start="2">
<li>1번에서 조회한 reservation의 row들과 ticket 테이블을 왜래키(<code>fk_reservation_ticket</code>)를 통해 Join한다.</li>
</ol>
<pre><code>| id | select_type | table | type | key | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | ticket | ref | fk_reservation_ticket | reservation.id | 5 | 5 | Using where |</code></pre><ol start="3">
<li><p><code>performance_round_id</code>와 <code>is_paid</code>에 대한 필터링을 진행한다.</p>
<pre><code class="language-sql"> WHERE t.performance_round_id = {roundId} AND t.is_paid = TRUE</code></pre>
<ul>
<li>해당 과정에서 <code>performance_round_id</code>와 <code>is_paid</code>에 인덱스가 없기 때문에 직접 필터링을 진행한다. 하지만, 한 유저가 많은 예약을 하지 않는 이상 필터링 할 row가 많지 않기 때문에, 성능에 큰 영향은 없어보인다.</li>
</ul>
</li>
</ol>
<p>이렇게 실행 계획을 살펴본 결과, 쿼리 자체는 큰 문제가 없어보인다. 추후 유저별 예매 건 수가 많아지게 되면, 추가 인덱스를 생성하는 등의 튜닝을 고려할 수 있겠다.</p>
<h3 id="락-범위-분석">락 범위 분석</h3>
<pre><code class="language-sql">-- 트랜잭션 시작 --
START TRANSACTION;

-- 구매 티켓 조회 --
SELECT t.*, r.user_id
FROM reservation r
JOIN ticket t ON r.id = t.reservation_id
WHERE t.performance_round_id = {roundId}
AND r.user_id = {userId}
AND t.is_paid = TRUE
FOR UPDATE;

-- Lock 상태 조회 --
SELECT * FROM performance_schema.data_locks;

-- 트랜잭션 커밋 --
COMMIT;</code></pre>
<p>락 범위를 자세히 알아보기 위해서 위와 같이 performance_schema.data_locks를 조회해봤다.</p>
<ul>
<li><code>SELECT * FROM performance_schema.data_locks;</code> 결과</li>
</ul>
<pre><code>| Row | Table | Index Name | Lock Mode | Lock Data |
| --- | --- | --- | --- | --- |
| 3 | reservation | idx_reservation_user_id | X | user_id: 3, id: 3 |
| 4 | reservation | idx_reservation_user_id | X | user_id: 3, id: 4 |
| 5 | reservation | PRIMARY | X, REC_NOT_GAP | id: 3 |
| 6 | ticket | fk_reservation_ticket | X | reservation_id: 3, id: 5 |
| 7 | ticket | fk_reservation_ticket | X | reservation_id: 3, id: 6 |
| 8 | ticket | PRIMARY | X, REC_NOT_GAP | id: 6 |
| 9 | ticket | PRIMARY | X, REC_NOT_GAP | id: 5 |
| 10 | ticket |  | X, GAP | reservation_id: 3 |
| 11 | reservation | PRIMARY | X, REC_NOT_GAP | id: 4 |
| 12 | ticket | fk_reservation_ticket | X | reservation_id: 4, id: 7 |
| 13 | ticket | fk_reservation_ticket | X | reservation_id: 4, id: 8 |
| 14 | ticket | PRIMARY | X, REC_NOT_GAP | id: 8 |
| 15 | ticket | PRIMARY | X, REC_NOT_GAP | id: 7 |
| 16 | ticket | fk_reservation_ticket | X, GAP | reservation_id: 4 |
| 17 | reservation | idx_reservation_user_id | X, GAP | user_id: 3 |
- Lock Type이 RECORD가 아닌 row는 생략했다.</code></pre><p>이렇게만 보면 이해하기 어렵기 때문에, 쿼리의 실행 과정을 따라가며 자세히 분석해보자.</p>
<ol>
<li><p>reservation 테이블에서 <code>user_id</code>가 <code>3</code>인 row들을 조회
 <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/0c5c2654-0c5b-4a1c-92aa-ed25d970abbb/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>Row</th>
<th>Table</th>
<th>Index Name</th>
<th>Lock Mode</th>
<th>Lock Data</th>
</tr>
</thead>
<tbody><tr>
<td>3</td>
<td>reservation</td>
<td>idx_reservation_user_id</td>
<td>X</td>
<td>user_id: 3, id: 3</td>
</tr>
<tr>
<td>4</td>
<td>reservation</td>
<td>idx_reservation_user_id</td>
<td>X</td>
<td>user_id: 3, id: 4</td>
</tr>
<tr>
<td>5</td>
<td>reservation</td>
<td>PRIMARY</td>
<td>X, REC_NOT_GAP</td>
<td>id: 3</td>
</tr>
<tr>
<td>11</td>
<td>reservation</td>
<td>PRIMARY</td>
<td>X, REC_NOT_GAP</td>
<td>id: 4</td>
</tr>
<tr>
<td>17</td>
<td>reservation</td>
<td>idx_reservation_user_id</td>
<td>X, GAP</td>
<td>user_id: 3</td>
</tr>
<tr>
<td>- row 3, 4, 5, 11을 보면 user_id가 3인 두 개의 row에 X락(베타 락, Exclusive Lock)이 걸린 것을 알 수 있다. 이렇게 레코드 단위로 Lock을 거는 것을 <strong>레코드 락</strong>(Record Lock)이라고 한다.</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- row 17을 보면 user_id가 3인 reservation 테이블에 <strong>갭 락</strong>(Gap Lock)이 걸려있는 것을 알 수 있다. 이를 통해, user_id가 3인 인덱스 범위에 락을 걸어 해당 범위에 레코드의 생성, 수정, 삭제를 막는다.</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- 이렇게 레코드 락과 갭 락이 합쳐진 형태를 <strong>넥스트 키 락</strong>(Next Key Lock)이라고 한다.</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
</li>
<li><p>ticket 테이블에서 <code>reservation_id</code>가 <code>3</code>, <code>4</code>인 row들을 조회</p>
<p> <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/6848c552-8914-41ab-8256-662df8d79197/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>Row</th>
<th>Table</th>
<th>Index Name</th>
<th>Lock Mode</th>
<th>Lock Data</th>
</tr>
</thead>
<tbody><tr>
<td>6</td>
<td>ticket</td>
<td>fk_reservation_ticket</td>
<td>X</td>
<td>reservation_id: 3, id: 5</td>
</tr>
<tr>
<td>7</td>
<td>ticket</td>
<td>fk_reservation_ticket</td>
<td>X</td>
<td>reservation_id: 3, id: 6</td>
</tr>
<tr>
<td>8</td>
<td>ticket</td>
<td>PRIMARY</td>
<td>X, REC_NOT_GAP</td>
<td>id: 6</td>
</tr>
<tr>
<td>9</td>
<td>ticket</td>
<td>PRIMARY</td>
<td>X, REC_NOT_GAP</td>
<td>id: 5</td>
</tr>
<tr>
<td>10</td>
<td>ticket</td>
<td>fk_reservation_ticket</td>
<td>X, GAP</td>
<td>reservation_id: 3</td>
</tr>
<tr>
<td>12</td>
<td>ticket</td>
<td>fk_reservation_ticket</td>
<td>X</td>
<td>reservation_id: 4, id: 7</td>
</tr>
<tr>
<td>13</td>
<td>ticket</td>
<td>fk_reservation_ticket</td>
<td>X</td>
<td>reservation_id: 4, id: 8</td>
</tr>
<tr>
<td>14</td>
<td>ticket</td>
<td>PRIMARY</td>
<td>X, REC_NOT_GAP</td>
<td>id: 8</td>
</tr>
<tr>
<td>15</td>
<td>ticket</td>
<td>PRIMARY</td>
<td>X, REC_NOT_GAP</td>
<td>id: 7</td>
</tr>
<tr>
<td>16</td>
<td>ticket</td>
<td>fk_reservation_ticket</td>
<td>X, GAP</td>
<td>reservation_id: 4</td>
</tr>
<tr>
<td>- row 6, 7, 8, 9 와 12, 13, 14, 15 에선 각각 reservation_id가 3과 4인 ticket들에 대해서 레코드 락이 걸린 것을 알 수 있다.</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>- row 10, 16에선 각각 reservation_id가 3, 4인 ticket 테이블에 <strong>갭 락</strong>(Gap Lock)이 걸려있는 것을 알 수 있다.</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
</li>
</ol>
<p>결과적으로는, user_id가 3인 reservation들과 이와 Join되는 ticket들에 전부 베타 락이 걸리게된다.</p>
<p>이렇게 락의 범위에 대해서 분석을 하다보니 두 가지 궁금한 점이 생겼다.</p>
<h3 id="1-왜-나머지-필터링-조건들에-대해선-lock이-안걸리는가">1. 왜 나머지 필터링 조건들에 대해선 Lock이 안걸리는가?</h3>
<pre><code class="language-sql">WHERE t.performance_round_id = {roundId} AND t.is_paid = TRUE</code></pre>
<p>구매 티켓 조회 쿼리는 <code>user_id</code> 말고도 <code>performance_round_id</code>, <code>is_paid</code>에 대한 필터링 조건이 더 있다.</p>
<p>그런데, 왜 user_id에 대해서만 락이 걸렸을까?</p>
<p>이유는, <strong>InnoDB는 인덱스를 통해 Lock</strong>을 걸기 때문이다. <code>performance_round_id</code>와 <code>is_paid</code>에 대해서는 별도의 인덱스가 없기 때문에 락이 걸리지 않은 것이다.</p>
<p>그렇기 때문에, <strong>두 컬럼에 대해 인덱스를 생성</strong>하게 되면 해당 조건 까지 포함하여 락이 걸리게 될 것이다. 그렇게 되면 <strong>락의 범위가 더 줄어들어 Lock 대기 시간이 감소</strong>할 수 있다.</p>
<h3 id="2-만약-조회되는-row가-없으면-어떻게-될까">2. 만약 조회되는 row가 없으면 어떻게 될까?</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/79f0941b-9ec3-4924-8e27-cfab5c77a234/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>Row</th>
<th>Table</th>
<th>Index Name</th>
<th>Lock Mode</th>
<th>Lock Data</th>
</tr>
</thead>
<tbody><tr>
<td>2</td>
<td>reservation</td>
<td>idx_reservation_user_id</td>
<td>X, GAP</td>
<td>user_id: 3</td>
</tr>
</tbody></table>
<p>reservation 테이블에 user_id가 3인 row가 없다면, 위와 같이 갭 락만 걸게된다. 그렇기 때문에, 조회되는 row가 없어도 user_id의 동시 구매 요청을 막을 수 있다.</p>
<h3 id="개선할-수-있는-포인트">개선할 수 있는 포인트</h3>
<blockquote>
<p><strong>두 컬럼에 대해 인덱스를 생성</strong>하게 되면 해당 조건 까지 포함하여 락이 걸리게 될 것이다. 그렇게 되면 <strong>락의 범위가 더 줄어들어 Lock 대기 시간이 감소</strong>할 수 있다.</p>
</blockquote>
<p>의문점 1에서 이야기한대로 인덱스를 추가로 생성하면 Lock 대기 시간이 감소하여 성능 향상을 기대할 수 있을 것이다.</p>
<p>하지만, 아래의 이유들로 추가 인덱스를 생성하지 않기로 결정하였다.</p>
<ol>
<li><strong>미미한 성능 향상 기대</strong><ul>
<li>유저별 예매 건수가 늘어나게 된다면 추가 인덱스 생성이 필요할 것이다. 하지만, 한 명의 유저가 수많은 예매를 진행하는 경우는 많지 않고, 추후 문제가 발생했을 때 추가 인덱스 생성을 고려하는게 맞다고 판단하였다.</li>
</ul>
</li>
<li><strong>인덱스 생성의 오버헤드</strong><ul>
<li>인덱스 생성이 무조건 좋은 효과만 있는 것은 아니다. 인덱스를 생성하게 되면 추가적인 디스크 용량을 차지하고, 쓰기 작업에 대한 오버헤드가 생기게된다.</li>
<li>그렇기 때문에, 성능 향상 기댓값이 낮은 상황에서 오버헤드가 있는 추가 인덱스 생성을 하지 않는게 맞다고 판단하였다.</li>
</ul>
</li>
</ol>
<p>또한, 여전히 락 획득을 위해 DB의 자원을 계속 쓰고 있다. 그러므로, <strong>요청이 늘어날 수록 DB의 부하가 증가하여 서비스의 병목지점이 될 가능성이 높다.</strong></p>
<p>이를 해결하기 위해 추후에 <strong>분산 락</strong>을 도입하여 Lock 관리에 대한 책임을 DB에서 Redis와 같은 곳으로 옮겨서 더 개선을 할 수 있겠다.</p>
<h2 id="결론-및-느낀점">결론 및 느낀점</h2>
<p>같은 동시성 문제라고 해도, <strong>문제 상황과 요구 사항에 따라 해결 방법이 천차만별</strong>이라는 것을 깨달았다.</p>
<p>이번 경험을 통해 <strong>동시성 문제 해결을 위한 다양한 방법들을 깊게 학습</strong>하였고, 앞으로 어떤 문제가 발생해도 충분히 해결할 수 있을 것이란 자신감이 생겼다.</p>
<p>또한, 동시성 문제 해결에서 시작하여 MySQL(InnoDB)의 락에 대해 다시 한 번 복습할 수 있었던 알찬 경험이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[티켓팅 서비스 동시성 문제 해결하기 - 1. 중복 예매 문제]]></title>
            <link>https://velog.io/@jeong_hun_hui/%ED%8B%B0%EC%BC%93%ED%8C%85-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-1.-%EC%A4%91%EB%B3%B5-%EC%98%88%EB%A7%A4-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@jeong_hun_hui/%ED%8B%B0%EC%BC%93%ED%8C%85-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-1.-%EC%A4%91%EB%B3%B5-%EC%98%88%EB%A7%A4-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Thu, 20 Feb 2025 11:55:05 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/50aa6f67-2c5f-4e3e-a8f9-1d7334b6c96b/image.png" alt=""></p>
<p>티켓팅을 하다보면 심심찮게 보게되는 <code>&quot;이미 선택된 좌석입니다&quot;</code></p>
<p>문득 이 문구를 보니까 <strong>티켓팅 서비스는 어떻게 동시성 문제들을 해결</strong>하는지 궁금해졌다.</p>
<p>그래서 직접 <strong>티켓팅 서비스를 구현</strong>한 뒤 <strong>부하 테스트</strong>를 진행하였고, 이 과정에서 발생한 <strong>동시성 문제를 해결</strong>해보았다.</p>
<h2 id="요구사항">요구사항</h2>
<ol>
<li><p>티켓팅 과정을 수행할 수 있다.
 <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/e42551a9-8f2d-477d-a716-aa699a6b6f1d/image.png" alt=""></p>
</li>
<li><p>티켓팅 과정에서 동시성 문제가 발생해선 안된다.</p>
<ul>
<li><strong>전체 티켓 보다 많은 티켓이 예매되면 안된다.</strong></li>
<li><strong>1인당 최대 4개</strong>의 티켓을 예매할 수 있다.</li>
</ul>
</li>
</ol>
<h2 id="테스트-환경">테스트 환경</h2>
<table>
<thead>
<tr>
<th>대상</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>SpringBoot 리소스</td>
<td>CPU 2코어, 메모리 2GB</td>
</tr>
<tr>
<td>MySQL 리소스</td>
<td>CPU 2코어, 메모리 2GB</td>
</tr>
<tr>
<td>테스트 스크립트</td>
<td>k6</td>
</tr>
<tr>
<td>APM</td>
<td>NewRelic</td>
</tr>
<tr>
<td>MySQL 모니터링</td>
<td>MySQL Expoter, Prometheus, Grafana</td>
</tr>
<tr>
<td>테스트 결과 대시보드</td>
<td>InfluxDB, Grafana</td>
</tr>
<tr>
<td>데이터(row)</td>
<td>공연 549, 회차 6067, 티켓 2690310</td>
</tr>
<tr>
<td>- Money Issue로 Local 환경에서 진행하였다.</td>
<td></td>
</tr>
</tbody></table>
<h2 id="테스트-시나리오">테스트 시나리오</h2>
<ul>
<li><strong>티켓이 400개</strong>인 공연을 <strong>500명이 동시에 티켓팅</strong>한다.<ul>
<li>1인 당 구매 제한 테스트를 위해 <strong>userId는 총 400개</strong>로 설정하였다.</li>
</ul>
</li>
<li>자세한 테스트 시나리오는 아래 링크를 참조바랍니다.<ul>
<li><a href="https://github.com/JeongHunHui/Performing-Arts-Ticketing/tree/load-test/load-test">https://github.com/JeongHunHui/Performing-Arts-Ticketing/tree/load-test/load-test</a></li>
</ul>
</li>
</ul>
<h2 id="테스트-결과---동시성-문제-발생">테스트 결과 - 동시성 문제 발생</h2>
<table>
<thead>
<tr>
<th>테스트 회차</th>
<th>1차</th>
<th>2차</th>
<th>3차</th>
<th>4차</th>
<th>5차</th>
<th>avg</th>
</tr>
</thead>
<tbody><tr>
<td>예매된 티켓 수</td>
<td>417</td>
<td>419</td>
<td>420</td>
<td>432</td>
<td>422</td>
<td>422</td>
</tr>
<tr>
<td>구매 제한 초과</td>
<td>1명</td>
<td>1명</td>
<td>1명</td>
<td>1명</td>
<td>0명</td>
<td>0.8명</td>
</tr>
</tbody></table>
<h4 id="1-중복-예매-문제-발생">1. <strong>중복 예매 문제</strong> 발생 <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/eeaa1bb7-bdb1-4885-8754-4259d24250a3/image.png" alt=""></h4>
<p>모든 테스트에서 <strong>예매된 티켓수가 총 티켓 수 400을 초과</strong></p>
<h4 id="2-구매-제한-초과-문제-발생">2. <strong>구매 제한 초과 문제</strong> 발생 <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/ffb638cf-ba86-46f8-8045-0baf17ae6253/image.png" alt=""></h4>
<p>구매 제한 수량인 4개를 초과한 유저가 4명 발생</p>
<h2 id="중복-예매-동시성-문제-해결하기">중복 예매 동시성 문제 해결하기</h2>
<p>두 가지 동시성 문제 중 예매된 티켓 수가 총 티켓 수를 초과하는 중복 예매 동시성 문제를 먼저 해결해보자.</p>
<h3 id="원인-분석">원인 분석</h3>
<pre><code class="language-kotlin">@Transactional
fun tempReserve(request: TempReserveRequest): TempReserveResponse {
    // 예매할 티켓들 조회
    val tickets = reservationRepository.getTicketsByIds(request.ticketIds)

    // 전부 예매 가능한 상태면 예매 생성
    val reservation = Reservation.createTempReservation(tickets, request.userId)

    /** 중간 로직 생략 */

    // 예매 저장
    reservationRepository.save(reservation)

    return TempReserveResponse(reservation.id)
}</code></pre>
<p>임시 예매는 아래와 같은 과정으로 이루어진다.</p>
<ol>
<li>예매할 티켓들을 조회</li>
<li>모든 티켓이 예매가 가능한 상태면 임시 예매 생성 후 저장</li>
</ol>
<p>하지만 같은 티켓에 여러 트랜잭션이 동시에 접근하면 조회 시점과 갱신 시점의 차이로 동시성 문제가 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/bca0d364-606b-40e6-adad-f863e74aa419/image.png" alt=""></p>
<p>트랜잭션 A와 B가 동시에 같은 티켓을 조회한다. 조회 시에는 둘 다 예매 가능 상태였기 때문에 서로 업데이트를 하게되고, 결국 먼저 업데이트를 한 트랜잭션 A의 예매는 손실되는 <strong>갱신 손실</strong>이 발생한다.</p>
<h3 id="해결-방법-1-synchronized-→-scale-out-환경에서는-해결-불가">해결 방법 1. synchronized → Scale Out 환경에서는 해결 불가</h3>
<p>Java의 <code>synchronized</code>(Kotlin은 <code>@Synchronized</code>) 키워드를 붙히면 여러 쓰레드가 동시에 메서드를 실행하지 못하게 하므로 동시성 문제를 방지할 수 있다.</p>
<p>하지만, <code>synchronized</code>가 &quot;여러 쓰레드가 동시에 메서드를 실행하지 못하게 하는 것&quot;은 하나의 프로세스 에서만 가능하다. 그래서, <strong>scale out 환경에서는 동시성 문제가 발생</strong>할 수 있다.</p>
<h3 id="해결-방법-2-비관적-락">해결 방법 2. 비관적 락</h3>
<p>해결 방법은 간단하다. 임시 예매 시 조회하는 티켓들이 동시 접근이 이루어질 것이라고 가정하고 바로 락을 걸어서 읽거나 쓰지 못하게 하면 된다.</p>
<p>이렇게 비관적으로 <strong>동시 접근이 이루어질 것이라고 가정</strong>하고 먼저 락을 거는 방식을 <strong>비관적 락</strong>(Pessimistic Lock)이라고 한다.</p>
<pre><code class="language-kotlin">@Query(&quot;SELECT t FROM TicketEntity t WHERE t.id IN :ids&quot;)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findTicketsByIdsWithPessimistic(ids: List&lt;UUID&gt;): List&lt;TicketEntity&gt;</code></pre>
<p>구현은 위와 같이 매우 간단하다. 기존 티켓 조회 쿼리에 <code>@Lock(LockModeType.PESSIMISTIC_WRITE)</code>을 붙히면 된다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/4b13aabf-c13e-4e57-9a30-cac873ed2845/image.png" alt=""></p>
<p>이렇게 하면, 조회 쿼리에 <code>FOR UPDATE</code>가 추가되어 다른 트랜잭션에서 Lock을 획득한 상태면 해당 트랜잭션 Commit 시점까지 대기한다.</p>
<h3 id="비관적-락-적용-후-테스트-결과">비관적 락 적용 후 테스트 결과</h3>
<table>
<thead>
<tr>
<th>테스트 회차</th>
<th>1차</th>
<th>2차</th>
<th>3차</th>
<th>4차</th>
<th>5차</th>
<th>avg</th>
</tr>
</thead>
<tbody><tr>
<td>예매된 티켓 수</td>
<td>400</td>
<td>400</td>
<td>400</td>
<td>400</td>
<td>400</td>
<td>400</td>
</tr>
</tbody></table>
<p>이로써, 중복 예매 동시성 문제는 해결되었다.</p>
<p>하지만, 비관적 락은 미리 충돌을 가정하고 Lock을 걸기 때문에, 충돌이 자주 발생하지 않는 경우에도 무조건 Lock 획득을 위해 대기해야 하므로 <strong>충돌이 자주 발생하는 경우에 유리</strong>하다. 또한, <strong>데드락</strong>이 발생할 가능성이 높아진다.</p>
<ul>
<li><p>MySQL Expoter로 수집한 락 대기시간을 모니터링한 결과, 비관적 락을 적용한 뒤 락 대기시간이 약 <strong>7배 증가</strong>하였다.</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/2bcaf6ec-a756-4052-bede-a27e70b1b8a1/image.png" alt=""></p>
</li>
</ul>
<p>그렇다면 다른 방법은 없을까?</p>
<h3 id="해결-방법-3-낙관적-락">해결 방법 3. 낙관적 락</h3>
<p>비관적 락은 충돌을 가정하고 미리 락을 걸기 때문에, 성능 저하 및 데드락과 같은 오버헤드가 있다.</p>
<p>그래서, 등장한 것이 낙관적 락이다. 낙관적 락은 데이터 <strong>조회에 Lock을 걸지 않고</strong>, 데이터 <strong>업데이트 시점에 충돌을 검사</strong>하는 식으로 동시성 문제를 해결한다.</p>
<pre><code class="language-kotlin">@Entity
@Table(name = &quot;ticket&quot;)
class TicketEntity(
    //...
    @Version
    @Column(name = &quot;version&quot;)
    val version: Long = 0L,
)</code></pre>
<p>위와 같이, JPA에서는 간단하게 <code>@Version</code>을 활용하여 낙관적 락을 구현할 수 있다.</p>
<p>낙관적 락을 적용하면 id가 1이고, version이 3인 티켓에 대한 업데이트 시 아래와 같은 쿼리가 날라간다.</p>
<pre><code class="language-sql">UPDATE ticket
SET reservation_id = ?, version = 4
WHERE id = 1 AND version = 3</code></pre>
<p>만약 충돌이 일어나면 <code>ObjectOptimisticLockingFailureException</code>예외가 발생하고, 재시도 로직을 구현하거나 롤백 처리를 해야한다.</p>
<p>이를 적용한 뒤 테스트를 진행해보자.</p>
<h3 id="낙관적-락-적용-후-테스트-결과">낙관적 락 적용 후 테스트 결과</h3>
<table>
<thead>
<tr>
<th>테스트 회차</th>
<th>1차</th>
<th>2차</th>
<th>3차</th>
<th>4차</th>
<th>5차</th>
<th>avg</th>
</tr>
</thead>
<tbody><tr>
<td>예매된 티켓 수</td>
<td>400</td>
<td>400</td>
<td>400</td>
<td>400</td>
<td>400</td>
<td>400</td>
</tr>
</tbody></table>
<p>비관적 락과 마찬가지로, 낙관적 락 또한 중복 예매 동시성 문제가 해결된 모습이다.</p>
<h2 id="중복-예매-동시성-문제-해결---비관적-락-vs-낙관적-락">중복 예매 동시성 문제 해결 - 비관적 락 vs 낙관적 락</h2>
<p>중복 예매 동시성 문제는 비관적 락과 낙관적 락 모두 해결 가능하다는 것을 알게되었다. 그렇다면 어떤 락이 더 효과적일까? 앞서 진행한 두 부하 테스트 결과를 비교해보자.</p>
<blockquote>
<p>시나리오는 앞서 설명한 내용과 동일, 각각 10회 수행 후 평균 응답시간 최상위와 최하위 결과 제외</p>
</blockquote>
<table>
<thead>
<tr>
<th>API Endpoint</th>
<th>낙관적 락 (ms)</th>
<th>비관적 락 (ms)</th>
<th>차이 (비관 – 낙관)</th>
<th>성공</th>
<th>실패</th>
</tr>
</thead>
<tbody><tr>
<td>전체 평균</td>
<td>1516.34</td>
<td>1753.79</td>
<td>+236.66</td>
<td>3871.29</td>
<td>894.45</td>
</tr>
<tr>
<td>공연 상세</td>
<td>1,492.73</td>
<td>1,593.01</td>
<td>+100.28</td>
<td>500</td>
<td>0</td>
</tr>
<tr>
<td>좌석 영역</td>
<td>1,898.49</td>
<td>2,078.93</td>
<td>+180.44</td>
<td>500</td>
<td>0</td>
</tr>
<tr>
<td>티켓 상태</td>
<td>1,422.89</td>
<td>1,643.15</td>
<td>+220.26</td>
<td>1879.13</td>
<td>0</td>
</tr>
<tr>
<td>임시 예매</td>
<td>1,547.03</td>
<td>1,836.41</td>
<td>+289.38</td>
<td>251.26</td>
<td>881.57</td>
</tr>
<tr>
<td>할인 목록</td>
<td>1,521.53</td>
<td>1,797.80</td>
<td>+276.27</td>
<td>251.26</td>
<td>0</td>
</tr>
<tr>
<td>결제 시작</td>
<td>1,520.03</td>
<td>2,037.48</td>
<td>+517.45</td>
<td>251.26</td>
<td>0</td>
</tr>
<tr>
<td>결제 승인</td>
<td>1,159.35</td>
<td>1,556.88</td>
<td>+397.53</td>
<td>238.38</td>
<td>12.88</td>
</tr>
</tbody></table>
<ul>
<li>상세 테스트 결과: <a href="https://hunhui.notion.site/Data-1a0aa2ba233d806e883df89f41ebc40d?pvs=4">https://hunhui.notion.site/Data-1a0aa2ba233d806e883df89f41ebc40d?pvs=4</a></li>
</ul>
<h3 id="결과-분석---낙관적-락-win">결과 분석 - 낙관적 락 win</h3>
<p>모든 API에서 <strong>낙관적 락이 비관적 락 보다 응답속도가 빨랐다.</strong></p>
<p>또한, 티켓의 락에 영향을 받는 API들과, 티켓팅 과정의 후반에 실행되는 API들의 응답시간이 더 크게 상승했다.</p>
<p>분명 임시 예매 API는 충돌이 많이 일어난다(총 1133번의 호출 중 80%가 실패). 근데, 왜 비관적 락 보다 낙관적 락이 왜 더 빨랐을까? 그 이유를 분석해보자.</p>
<h3 id="낙관적-락이-더-빠른-이유---1-충돌-시-재시도할-필요가-없다">낙관적 락이 더 빠른 이유 - 1. 충돌 시 재시도할 필요가 없다.</h3>
<p>티켓이 업데이트 되는 경우는 <code>1. 임시 예매</code>, <code>2. 결제 승인</code> 이렇게 두 가지 경우다. 그렇기 때문에, 임시 예매 과정에서 충돌이 났다면 다른 사용자가 임시 예매 혹은 결제를 완료한 것이므로 재시도를 할 필요가 없다.</p>
<p>물론, 충돌 시 트랜잭션 Roll Back을 수행해야 한다. 하지만, 낙관적 락의 가장 큰 오버헤드는 잦은 충돌과 이로 인해 <strong>반복되는 재시도로 인한 응답 시간 지연</strong>이다.</p>
<p>그렇기 때문에, 재시도를 고려하지 않아도 되므로 낙관적 락의 응답속도가 더 빨랐다고 생각한다.</p>
<h3 id="낙관적-락이-더-빠른-이유---2-락이-없다-당연히">낙관적 락이 더 빠른 이유 - 2. 락이 없다. (당연히..)</h3>
<p>만약 API의 응답 시간이 50ms고, 5명의 유저가 동시에 하나의 티켓에 접근한다고 가정해보자.</p>
<p>비관적 락이 걸린 경우는 락을 먼저 차지한 사용자가 트랜잭션을 <strong>Commit할 때 까지 다른 사용자들은 대기</strong>해야한다. 그러므로 5건의 요청을 모두 처리하기 위해선 <strong>250ms</strong>가 소모된다. (물론 쓰기 시간 및 다른 작업을 고려하지 않은 것이므로 더 빠를 것이다.)</p>
<p>반면에 낙관적 락이 걸린 경우는 두 사용자 모두 <strong>대기 없이 트랜잭션을 수행</strong>하고, 먼저 Commit한 유저가 예매에 성공하게된다. 그러므로 5건의 요청을 모두 처리하기 위해선 <strong>50ms</strong>가 소모된다. (물론 Roll Back 및 DB 부하는 고려하지 않은 것이므로 더 느릴 것이다.)</p>
<h2 id="결론---낙관적-락-도입">결론 - 낙관적 락 도입</h2>
<p>단순히 이론적인 내용으로만 생각했을 때는 충돌이 잦으니 비관적 락을 적용해야 된다고 생각했다.</p>
<p>하지만, 직접 테스트를 통해 결과를 비교하고 원인을 분석하며 더 적절한 락을 도입할 수 있었다.</p>
<p>추후 분산 환경으로 옮기게 된다면 분산 락도 고려해볼 수 있을 것 같다.</p>
<p><a href="https://velog.io/@jeong_hun_hui/%ED%8B%B0%EC%BC%93%ED%8C%85-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2.-%EA%B5%AC%EB%A7%A4-%EC%A0%9C%ED%95%9C-%EC%88%98%EB%9F%89-%EC%B4%88%EA%B3%BC-%EB%AC%B8%EC%A0%9C">다음화 보기(구매 제한 수량 초과 문제 해결기)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[설문이용] 기업 연계 시나리오 기반 부하 테스트 진행하기]]></title>
            <link>https://velog.io/@jeong_hun_hui/%EC%84%A4%EB%AC%B8%EC%9D%B4%EC%9A%A9-%EA%B8%B0%EC%97%85-%EC%97%B0%EA%B3%84-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%EA%B8%B0%EB%B0%98-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A7%84%ED%96%89%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_hun_hui/%EC%84%A4%EB%AC%B8%EC%9D%B4%EC%9A%A9-%EA%B8%B0%EC%97%85-%EC%97%B0%EA%B3%84-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%EA%B8%B0%EB%B0%98-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A7%84%ED%96%89%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 24 Oct 2024 12:56:34 GMT</pubDate>
            <description><![CDATA[<h2 id="0️⃣-배경">0️⃣ 배경</h2>
<p>진행 중인 프로젝트 &quot;<a href="http://sulmoon.io"><strong>설문이용</strong></a>&quot;을 기업과 연계하게될 기회가 생겼다.</p>
<p>우리 팀은 기업 연계에 앞서 서비스가 잘 동작할 수 있는지 파악하기 위해 실제 요구사항을 바탕으로 시나리오를 작성하고, 부하 테스트를 진행해보았다.</p>
<h3 id="목차">목차</h3>
<p>1️⃣ 시나리오 작성</p>
<p>2️⃣ 부하 테스트 준비</p>
<p>3️⃣ 부하 테스트 진행 및 결과 분석</p>
<p>4️⃣ 개선 결과 및 비교</p>
<h2 id="1️⃣-시나리오-작성">1️⃣ 시나리오 작성</h2>
<h3 id="배경">배경</h3>
<p>이번에 기업 연계를 진행하게 될 대형 마트의 회원 수는 약 5천명이고, 모든 회원에게 고객 만족도 설문조사 참여 메시지를 보낼예정이다. 이러한 상황에서 필요한 요구사항을 정의해보자.</p>
<h3 id="기능적-요구사항">기능적 요구사항</h3>
<ul>
<li><p>설문 참여 과정을 수행할 수 있다. (설문 조회 및 응답 제출)</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/c21a0f10-8420-4540-813e-f971886f910d/image.png" alt=""></p>
</li>
</ul>
<h3 id="비기능적-요구사항">비기능적 요구사항</h3>
<ul>
<li><strong>설문 참여 과정을 1분에 최대 50번 수행할 수 있다.</strong><ul>
<li>기업이 제시한 요구사항, 기존 사용자들의 요청, 갑작스러운 변수 등을 고려하여 위와 같이 요구사항을 정의하였다.</li>
</ul>
</li>
<li><strong>각 API 요청의 평균 응답 시간은 200ms 미만이여야 한다.</strong><ul>
<li>단, 설문 응답 API는 외부 서비스를 호출하므로 400ms로 설정한다.</li>
</ul>
</li>
</ul>
<h2 id="2️⃣-부하-테스트-준비">2️⃣ 부하 테스트 준비</h2>
<h3 id="더미-데이터-삽입">더미 데이터 삽입</h3>
<p>부하 테스트에 앞서 실제 운영 환경과 최대한 비슷하도록 더미 데이터를 삽입하는 API를 구현하였다. <a href="https://github.com/SUIN-BUNDANG-LINE/Backend/pull/95">PR 링크</a></p>
<ul>
<li>추후 운영을 고려하여, 예상 시나리오보다 더 많은 더미데이터를 삽입하고 진행하였습니다. (매일 설문조사가 50개 생성되고 각 설문의 평균 응답자 수가 100명이라고 가정 )</li>
</ul>
<h3 id="외부-서비스-모킹-서버-구성">외부 서비스 모킹 서버 구성</h3>
<p><img src="https://github.com/user-attachments/assets/776617db-1411-47ac-ba87-42199248c551" alt="image"></p>
<p>현재 설문 응답 시 중복 참가자 검사를 위해 <a href="https://fingerprint.com/">Fingerprint</a>라는 외부 서비스를 이용하고 있다.</p>
<p>하지만, 해당 서비스에는 요청 수 제한이 있고, 이를 초과하면 추가 비용이 발생하기 때문에 부하 테스트로 많은 호출을 할 수 없었다.</p>
<p><img src="https://github.com/user-attachments/assets/83859b76-1405-4336-84b9-fd6bac5e18cd" alt="image"></p>
<p>그래서, 위와 같이 부하 테스트를 진행 중이면 외부 네트워크에 별도로 구성한 Fingerprint Mocking 서버로 요청을 보내도록 하였다. <a href="https://github.com/SUIN-BUNDANG-LINE/FingerprintJSMockingServer">Repository 링크</a></p>
<p><img src="https://github.com/user-attachments/assets/d5b8b995-4ac7-4e5b-9cde-48026815b9b8" alt="image"></p>
<p>Fingerprint Mocking 서버는 API 요청을 받으면 Fingerprint API의 평균 응답 시간인 175ms 뒤에 요청을 반환하도록 구현하였다.</p>
<h3 id="스크립트-작성--결과-저장-및-시각화k6--influxdb--grafana">스크립트 작성 &amp; 결과 저장 및 시각화(k6 &amp; InfluxDB &amp; Grafana)</h3>
<p>다른 부하 테스트 도구에 비해 성능이 가장 좋고, 시나리오 기반의 테스트가 가능한 k6로 스크립트를 작성했다.</p>
<p>스크립트가 긴 관계로, 자세한 코드는 <a href="https://github.com/SUIN-BUNDANG-LINE/Monitoring-Test/blob/main/k6/survey_participant_test.js">이곳</a>에서 확인할 수 있다.</p>
<p>또한, 테스트 결과를 저장하고 이를 쉽게 확인하고 분석하기 위해 InfluxDB와 Grafana를 로컬 환경에 세팅하였다.  <a href="https://github.com/SUIN-BUNDANG-LINE/Monitoring-Test">Repository 링크</a></p>
<p>Grafana 대시보드는 <a href="https://grafana.com/grafana/dashboards/4411-k6-load-testing-results/">해당 링크</a>의 대시보드를 조금 커스텀하여 각 API의 응답과 시나리오 성공률 등을 볼 수 있도록 하였다.</p>
<h2 id="3️⃣-부하-테스트-결과-분석">3️⃣ 부하 테스트 결과 분석</h2>
<h3 id="테스트-결과---요구사항-달성-실패">테스트 결과 - 요구사항 달성 실패</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/fca53234-185c-473c-8208-b04cb74b1c64/image.png" alt=""></p>
<p>테스트 결과, 설문 응답 API의 응답 시간이 요구사항(400ms)을 달성하지 못하였다.</p>
<h3 id="문제점-찾기---왜-느릴까">문제점 찾기 - 왜 느릴까?</h3>
<p>APM이 제공하는 Breakdown Table을 통해 설문 응답 API의 세부 동작과 소요 시간을 확인해보았다.</p>
<p><img src="https://github.com/user-attachments/assets/ad943473-a93e-4c19-86c9-43818efe64e4" alt="image"></p>
<p>확인결과, MongoDB 쿼리(participants find)가 대부분의 응답 시간을 차지하는 것을 확인할 수 있었다.</p>
<p>각 쿼리들이 왜 느린지 분석하기 위해 MongoDB의 slow query log를 분석하였다.</p>
<pre><code class="language-json">{
    &quot;attr.command.filter.surveyId.$uuid&quot;: &quot;2b051e6a-4d92-4ad9-b34c-7e4b7de89377&quot;,
    &quot;attr.command.find&quot;: &quot;participants&quot;,
    &quot;attr.cursorid&quot;: 1970698316047089400,
    &quot;attr.docsExamined&quot;: 109091,
    &quot;attr.durationMillis&quot;: 2728,
    &quot;attr.nreturned&quot;: 101,
    &quot;attr.planSummary&quot;: &quot;COLLSCAN&quot;
}</code></pre>
<p>확인해보면, 우선 find 명령으로 surveyId가 <code>2b051e6a-4d92-4ad9-b34c-7e4b7de89377</code>인 참가자를 COLLSCAN 방식(컬렉션 전체를 스캔)으로 먼저 101개 찾는다.</p>
<pre><code class="language-json">{
    &quot;attr.command.collection&quot;: &quot;participants&quot;,
    &quot;attr.command.getMore&quot;: 1970698316047089400,
    &quot;attr.cursorExhausted&quot;: true,
    &quot;attr.docsExamined&quot;: 110830,
    &quot;attr.durationMillis&quot;: 2762,
    &quot;attr.nreturned&quot;: 101,
    &quot;attr.planSummary&quot;: &quot;COLLSCAN&quot;
}</code></pre>
<p>그 다음, find 명령으로 생성된 커서를 통해 나머지 데이터 101개를 찾아서 반환한다.</p>
<p>결국 가장 큰 문제는 <strong>surveyId에 해당하는 참가자들을 찾는 과정에서 컬렉션 전체를 탐색하는 것</strong>이다.</p>
<h2 id="4️⃣-개선-및-결과-비교">4️⃣ 개선 및 결과 비교</h2>
<h3 id="개선하기---participants의-surveyid-속성에-대해-index-생성">개선하기 - participants의 surveyId 속성에 대해 index 생성</h3>
<p>결국 가장 큰 문제는 참가자를 조회할 때 surveyId에 대해 index가 없어서 컬렉션 전체를 조회하는 것이었다.</p>
<p>그래서, participants 컬렉션의 surveyId에 대해 아래와 같이 index를 생성해주었다.</p>
<pre><code class="language-jsx">db.participants.createIndex({ surveyId: 1 })</code></pre>
<h3 id="2차-부하-테스트-진행-결과">2차 부하 테스트 진행 결과</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/cbf8320e-4e34-45b2-9bb5-995151052e1d/image.png" alt=""></p>
<p>participants 컬렉션에 surveyId에 대한 index를 생성한 결과 설문 응답 API의 응답시간이 93% 감소하여 요구사항을 달성하였다. (3.95s → 263.49ms)</p>
<h2 id="5️⃣-느낀점">5️⃣ 느낀점</h2>
<p>이전에 서비스를 개발했을 땐 대충 &quot;어느정도는 버티겠지?&quot;라고 생각하고, 문제가 닥치고 나서야 급하게 해결했던 기억이 난다.</p>
<p>이번 기회에 서비스 사용 시나리오를 꼼꼼하게 작성하고, 이를 바탕으로 부하 테스트를 진행해보니 놓쳤던 문제를  조기에 발견할 수 있어서 좋았다.</p>
<p>또한, 이번 시나리오 기반 부하 테스트를 통해 기업 연계 과정에서도 문제 없이 서비스가 동작할 수 있음을 검증하여 서비스의 안정성과 신뢰성을 확인할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[설문이용] 설문 클래스 설계 및 구현 과정]]></title>
            <link>https://velog.io/@jeong_hun_hui/%EC%84%A4%EB%AC%B8%EC%9D%B4%EC%9A%A9-%EC%84%A4%EB%AC%B8-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A5%BC-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%84%A4%EA%B3%84%ED%95%B4%EC%95%BC%ED%95%A0%EA%B9%8C-feat.-DDD-TDD</link>
            <guid>https://velog.io/@jeong_hun_hui/%EC%84%A4%EB%AC%B8%EC%9D%B4%EC%9A%A9-%EC%84%A4%EB%AC%B8-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A5%BC-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%84%A4%EA%B3%84%ED%95%B4%EC%95%BC%ED%95%A0%EA%B9%8C-feat.-DDD-TDD</guid>
            <pubDate>Thu, 05 Sep 2024 13:52:14 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>해당 글은 소프트웨어 마에스트로 15기 과정에서 진행 중인 <strong>대학생을 위한 설문조사 서비스 &quot;설문이용&quot;</strong>을 개발하며 겪은 경험을 정리한 것입니다.</p>
</blockquote>
<p>이전에 프로젝트를 진행했을 때는 안정성이나 유지보수를 고려하지 않고 빠르게 기능을 개발하는 것에 중점을 두었다. 그러다 보니 기능이 추가될 수록 예상치 못한 버그가 자주 발생하게 되었다.</p>
<p>이런 문제를 최소화하기 위해 다양한 방법을 통해 설문 클래스를 설계 및 구현하였다. 그 과정과 성과를 정리해보았다.</p>
<h2 id="1️⃣-설계--구현-결과">1️⃣ 설계 &amp; 구현 결과</h2>
<p>아래와 같은 과정으로 설계 및 구현을 진행하였다.</p>
<ol>
<li>유지 보수를 고려한 설문 관련 클래스 설계</li>
<li>요구사항에 맞는 테스트 코드 작성</li>
<li>테스트 코드를 만족하도록 클래스 구현</li>
<li>지속적인 리팩토링을 통해 코드 품질 개선</li>
</ol>
<p>그 결과는 아래와 같다.</p>
<ul>
<li><a href="https://github.com/SUIN-BUNDANG-LINE/Backend/pull/9"><strong>구현 과정이 담긴 PR 링크</strong></a></li>
</ul>
<h2 id="2️⃣-설문-클래스-설계-과정-및-성과">2️⃣ 설문 클래스 설계 과정 및 성과</h2>
<h3 id="ℹ️-설문-도메인-설명">ℹ️ 설문 도메인 설명</h3>
<p><img src="https://github.com/user-attachments/assets/40f71d25-bac7-4afb-b542-b1ba13cbef25" alt="설문 구조"></p>
<p>설문의 구조를 살펴보면, 하나의 설문에는 여러개의 섹션이, 하나의 섹션에는 여러개의 질문이 있는 형태이다.</p>
<p><img src="https://github.com/user-attachments/assets/c88627a8-519b-418b-a0e0-e49f7f532276" alt="image"></p>
<p>또한, 사용자는 설문에 참여해서 응답을 제출할 수 있고, 위와 같이 응답에 따라 다른 섹션으로 이동할 수 있도록 라우팅 설정 기능을 제공한다.</p>
<h3 id="ℹ️-ddd의-aggregate-개념-적용">ℹ️ DDD의 Aggregate 개념 적용</h3>
<p><img src="https://github.com/user-attachments/assets/afc75373-a665-4f8b-9a0f-48ccfd0024e4" alt="image"></p>
<p>요구사항들을 바탕으로 위와 같이 설문 클래스들을 설계하였다.</p>
<p>DDD의 주요 개념 중 하나인 Aggregate를 도입하여, 설문과 관련된 객체를 하나의 설문 Aggregate로 묶었다.</p>
<p>또한, 설문 Aggregate root를 <code>Survey</code>클래스로 설정하여 하위 객체에 직접 접근하지 못하도록 하여 하위 객체를 캡슐화하고, 예상치 못한 변경을 막았다.</p>
<pre><code class="language-kotlin">data class Survey(
    val id: UUID,
    val title: String,
    val sections: List&lt;Section&gt;,
    // 그 외의 속성들
) {
        // Survey Class 생성 시 유효성 검증 진행
    init {
        require(sections.isNotEmpty()) { throw InvalidSurveyException() }
        require(isSectionsUnique()) { throw InvalidSurveyException() }
        require(isSurveyStatusValid()) { throw InvalidSurveyException() }
        require(isFinishedAtAfterPublishedAt()) { throw InvalidSurveyException() }
        require(isSectionIdsValid()) { throw InvalidSurveyException() }
    }

    /** 설문의 응답 순서가 유효한지, 응답이 각 섹션에 유효한지 확인하는 메서드 */
    fun validateResponse(surveyResponse: SurveyResponse) { /* 메서드 코드 */ }

    fun updateContent(/* 매개 변수들 */): Survey { /* 메서드 코드 */ }

    fun start(): Survey { /* 메서드 코드 */ }

    fun finish(): Survey { /* 메서드 코드 */ }

    // 그 외의 메서드들
}</code></pre>
<p><code>Survey</code> 클래스의 코드는 위와 같다. 특징은 아래와 같다.</p>
<ol>
<li><code>Survey</code> 클래스 생성 시 유효성 검증을 진행하여, 항상 유효한 <code>Survey</code> 클래스만 존재할 수 있도록 했다.</li>
<li>설문과 관련된 기능을 전부 <code>Survey</code>에 모아두어 응집도가 상승하고, 재사용성이 향상되었다.</li>
<li>불변 객체로 설계하여, 의도치 않은 객체의 변경을 막아 코드의 안정성이 향상되었다.</li>
</ol>
<h3 id="ℹ️-성과-1---응집도-상승으로-인한-service-계층의-복잡도-감소--유지-보수성-향상">ℹ️ 성과 1 - 응집도 상승으로 인한 Service 계층의 복잡도 감소 &amp; 유지 보수성 향상</h3>
<p>설문이용에서 설문이 끝나는 경우는 <strong>1. 추첨권이 소진</strong>되거나, <strong>2. 설문 마감일이 되는 경우</strong>가 있다.</p>
<p>두 경우 다 동일하게 설문을 종료 상태로 업데이트 하는 기능이 필요하다.</p>
<p>이러한 기능을 <strong>여러 Service 계층에 각자 구현</strong>하게되면 아래와 같다.</p>
<p><img src="https://github.com/user-attachments/assets/92ba60d6-344a-4304-ae4a-2cab9769eb9b" alt="image"></p>
<p>하지만 위의 경우, 설문 종료 로직을 수정하는 경우, 각 서비스의 설문 종료 코드를 수정해야한다. 즉 <strong>수정 범위가 증가하게 되어 유지 보수가 어려워</strong>진다.</p>
<p>하지만, <strong>설문 종료 메서드를 Survey 클래스에 구현</strong>하고, <strong>각 Service 계층에서는 Survey 클래스의 메서드를 사용</strong>한다면 어떨까? 이를 그림으로 표현하면 아래와 같다.</p>
<p><img src="https://github.com/user-attachments/assets/8ff596b3-cd29-4e57-8c3a-e83a213731c8" alt="image"></p>
<p>위의 경우는 Service 계층에선 <code>Survey</code> 클래스의 메서드를 호출하기만 하면 되므로 <strong>Service 계층의 복잡도가 감소</strong>한다.</p>
<p>또한 설문 종료 로직을 수정하기 위해선 <strong>Survey 클래스의 finish 메서드만 수정</strong>하면 되고, 추후 설문 종료 기능이 필요한 곳이 생기면 <strong>Survey 클래스의 finish 메서드를 재사용</strong>하면 되므로 <strong>유지 보수성이 향상</strong>된다.</p>
<h3 id="ℹ️-성과-2---aggregate-root를-설정하여-설문-전체의-일관성을-유지">ℹ️ 성과 2 - Aggregate root를 설정하여 <strong>설문 전체의 일관성을 유지</strong></h3>
<p><img src="https://github.com/user-attachments/assets/b21eb693-56d6-465a-a091-43cc1669fbae" alt="image"></p>
<p>설문이용에서는 위와 같이 선택지에 따라 다른 섹션으로 이동하도록 설정할 수 있다.</p>
<p>이러한 상황에서, 선택지에 따라 이동할 섹션을 존재하지 않는 섹션으로 수정한다면 어떻게 될까?</p>
<ul>
<li>직접 수정 가능<ul>
<li>설문 클래스를 거치지 않고 라우팅 설정을 직접 수정할 수 있으므로 존재하지 않는 섹션으로 수정 가능 → <strong>설문 전체의 일관성이 깨짐</strong></li>
</ul>
</li>
<li>Aggregate root를 통해서만 하위 객체 접근 가능<ul>
<li>설문 클래스와 섹션 클래스를 거치면서 <strong>변경하려는 섹션이 존재하는지 확인 가능</strong>하므로 존재하지 않는 섹션으로 수정이 불가능 → <strong>설문 전체의 일관성이 유지됨</strong></li>
</ul>
</li>
</ul>
<h2 id="3️⃣-설문-클래스-구현-과정-및-성과">3️⃣ 설문 클래스 구현 과정 및 성과</h2>
<h3 id="ℹ️-구현-과정에서-단위-테스트-작성-및-테스트-커버리지-측정">ℹ️ 구현 과정에서 단위 테스트 작성 및 테스트 커버리지 측정</h3>
<p><img src="https://github.com/user-attachments/assets/e1480192-4cf4-49b2-a578-86087277bf69" alt="image"></p>
<p>우리는 정리한 설문에 대한 요구사항을 바탕으로 위와 같이 테스트 코드를 작성했다.</p>
<p>그리고, 테스트 코드가 통과하도록 최대한 빠르게 클래스의 메서드를 구현했다.</p>
<p><img src="https://github.com/user-attachments/assets/9f891b59-bead-4a46-beec-4ec63ca3e974" alt="image"></p>
<p>이 과정에서, <strong>테스트 코드 작성을 놓친 부분이 있는지 확인하기 위해 Jacoco를 통해 테스트 커버리지를 측정</strong>하였고, domain 패키지의 테스트 커버리지를 100%로 달성하였다.</p>
<p>이후에는 테스트 커버리지를 100% 유지하면서 지속적으로 리팩토링을 진행하였다.</p>
<h3 id="ℹ️-성과-1---테스트-코드-작성으로-인한-기능-안정성-향상">ℹ️ 성과 1 - 테스트 코드 작성으로 인한 기능 안정성 향상</h3>
<p>domain 패키지의 테스트 커버리지를 100% 달성하여, 도메인 클래스의 메서드를 활용하는 서비스 코드의 안정성도 같이 향상되었다.</p>
<p>실제로 테스트 코드 작성 이후, 핵심 도메인 로직에 대한 버그 발생 빈도가 크게 줄었다.</p>
<h3 id="ℹ️-성과-2---리팩토링의-안정성-및-생산성-향상">ℹ️ 성과 2 - 리팩토링의 안정성 및 생산성 향상</h3>
<p>리팩토링을 진행할 때 아무리 변경을 해도 테스트 코드를 통해 기존과 같은 동작을 한다는 것을 확인할 수 있어서 안정적이고 더 효율적으로 리팩토링을 진행할 수 있었다.</p>
<h2 id="4️⃣-리팩토링-과정-및-성과">4️⃣ 리팩토링 과정 및 성과</h2>
<p>앞서서 설문 클래스들을 설계하고, 구현 까지 완료했지만 아래와 같은 이유로 리팩토링을 진행하기로 결정했다.</p>
<ol>
<li>설문 제작 시 어려움</li>
<li>질문 추가에 대한 확장성 부족</li>
<li>코드 로직의 복잡함(null을 로직에 활용, 라우팅 방식에 대한 모호함, 가독성이 부족한 코드)</li>
</ol>
<h3 id="ℹ️-리팩토링-결과">ℹ️ 리팩토링 결과</h3>
<p><img src="https://github.com/user-attachments/assets/e0d93fea-6bfd-445c-b21f-b62b1f5f972e" alt="image"></p>
<p><a href="https://github.com/SUIN-BUNDANG-LINE/Backend/pull/20"><strong>리팩토링 과정이 담긴 PR 링크</strong></a></p>
<p><a href="https://www.notion.so/f51d423cc3584357b3a2918927215720?pvs=21"><strong>리팩토링 후 설문 클래스 구조 설명 링크</strong></a></p>
<h3 id="ℹ️-성과-1---질문-인터페이스-세분화를-통한-확장성-증가">ℹ️ 성과 1 - 질문 인터페이스 세분화를 통한 확장성 증가</h3>
<p><img src="https://github.com/user-attachments/assets/1b363d5a-2c69-4be7-8d4b-e3f1e7d5bb42" alt="image"></p>
<p>기존에는 질문 인터페이스 하나와 질문 유형별 구현체의 구조였다.</p>
<p>하지만, 객관식 질문들의 공통 요구사항과 객관식 질문 중 선택지 기반 라우팅의 대상이 될 수 있는 단일 선택 질문에 대한 요구사항이 있는 등 <strong>추후 질문 확장 시 코드 중복이 우려되어 세분화를 진행</strong>했다.</p>
<p>이를 통해, 앞으로 주관식을 추가하면 <code>TextQuestion</code> 인터페이스 구현, 객관식을 추가하면 단일 선택이면 <code>SingleChoiceQuestion</code>, 다중 선택이면 <code>MultipleChoiceQuestion</code> 인터페이스를 구현하면 되므로 <strong>질문 유형 추가에 대한 확장성이 증가</strong>했다.</p>
<h3 id="ℹ️-성과-2---sealed-클래스를-활용하여-가독성-및-유지보수성-향상">ℹ️ 성과 2 - sealed 클래스를 활용하여 가독성 및 유지보수성 향상</h3>
<p><img src="https://github.com/user-attachments/assets/dc4cadb5-c149-4cb3-80a8-acff97273b18" alt="image"></p>
<p>기존에는 기타 선택지를 null로 구분하였는데, 이 때 null의 의미가 모호하여 코드의 가독성이 떨어졌고, 매번 추가적인 null 처리를 하다보니 유지보수가 어려웠다.</p>
<p>이제 sealed 클래스를 활용하여 명시적으로 기타 선택지임을 알 수 있어 더 유지보수와 가독성이 좋아졌다.</p>
<p><img src="https://github.com/user-attachments/assets/cd415036-7884-47aa-9400-f26a6711686b" alt="image"></p>
<p>섹션 ID도 마찬가지로, 기존에는 마지막 섹션 ID를 null로 구분하여 의미가 모호해서 코드의 가독성이 떨어졌었다.</p>
<p>이제 sealed 클래스를 활용하여 명시적으로 마지막 섹션 ID임을 알 수 있어 더 유지보수와 가독성이 좋아졌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트랜잭션의 동시성 문제를 알아보자]]></title>
            <link>https://velog.io/@jeong_hun_hui/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%98-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@jeong_hun_hui/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%98-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Sat, 13 Jul 2024 19:10:40 GMT</pubDate>
            <description><![CDATA[<h2 id="0️⃣-배경">0️⃣ 배경</h2>
<p>데이터 중심 애플리케이션 설계 7장(트랜잭션)을 읽던 중 여러 트랜잭션이 동시에 진행될 때 발생할 수 있는 문제들에 대해 접했다.</p>
<p>사실 모든 트랜잭션을 동기적으로 실행하는 Serializable(직렬성) 격리 수준을 적용하면 모든 문제를 해결할 수 있지만, 큰 성능 문제가 있다. 그렇기 때문에 각 상황에 맞는 적절한 해결법을 적용시키는 것이 중요하다.</p>
<p>그래서, 이번에는 어떤 상황에서 어떤 동시성 문제가 발생할 수 있는지, 어떻게 해결할 수 있는지 정리해보았다.</p>
<h2 id="1️⃣-더티-읽기-문제dirty-read">1️⃣ 더티 읽기 문제(Dirty Read)</h2>
<p>더티 읽기 문제는 아직 커밋되지 않은 트랜잭션에서 쓴 데이터를 읽을 수 있는 문제이다.</p>
<h3 id="문제-상황">문제 상황</h3>
<p><img src="https://github.com/user-attachments/assets/b7edd93c-8b3d-4144-9f8d-f325da4bbbf6" alt="image"></p>
<p>사용자 1이 사용자 2에게 이메일을 보내면 사용자 2의 메일함에 Insert하고, 읽지 않은 이메일 수를 +1 한다.</p>
<p>이 과정에서, 커밋되지 않은 값을 확인할 수 있으면 아래와 같은 문제가 발생하게 된다.</p>
<ul>
<li>1번 작업 직후에 사용자 2가 메일함을 확인하면 새로운 메일이 왔지만, 읽지 않은 이메일 수는 0인 문제가 발생한다.</li>
</ul>
<h3 id="적절한-해결-방법">적절한 해결 방법</h3>
<p>이를 막기 위해서 읽기 잠금을 걸 수 있겠지만, 읽기 작업을 진행하는 여러 트랜잭션이 하나의 쓰기 트랜잭션으로 인해 지연되는 문제가 발생한다.</p>
<p>그래서, 대부분의 DB는 <strong>최근에 커밋된 값과 실행중인 트랜잭션이 쓴 값을 모두 기억</strong>하고, 다른 트랜잭션이 읽을 때는 <strong>최근에 커밋된 값을 읽도록 하여 더티 읽기 문제를 해결</strong>한다.</p>
<p>위 문제 상황에서는, 메일함 Insert 작업은 커밋되지 않았으므로 보이지 않으므로 이상 현상이 나타나지 않을 것이다.</p>
<p>이러한 해결 방법이 적용된 격리 수준으로는 <strong>Read Committed</strong>가 있다.</p>
<h2 id="2️⃣-더티-쓰기-문제dirty-write">2️⃣ 더티 쓰기 문제(Dirty Write)</h2>
<p>더티 쓰기 문제는 아직 커밋되지 않은 트랜잭션에서 쓴 데이터에 쓰기 작업을 진행할 수 있는 문제이다.</p>
<h3 id="문제-상황-1">문제 상황</h3>
<p><img src="https://github.com/user-attachments/assets/0a58f994-9e28-49f3-afda-3253f7a842ce" alt="image"></p>
<p>중고차를 구매하기 위해선 중고차의 구매자와 수신자를 갱신해야한다.</p>
<p>이 과정에서 커밋되지 않은 값에 쓰기 작업이 가능하면 아래와 같이 문제가 발생하게 된다.</p>
<ol>
<li>먼저 엘리스가 구매자를 갱신한다. → <code>구매자 = 엘리스</code></li>
<li>밥이 구매자를 갱신한다. → <code>구매자 = 밥</code></li>
<li>밥이 수신자를 갱신하고 커밋한다 → <code>구매자 = 밥</code>, <code>수신자 = 밥</code></li>
<li>엘리스가 수신자를 갱신하고 커밋한다 → <code>구매자 = 밥</code>, <code>수신자 = 엘리스</code></li>
</ol>
<p>즉, 구매는 밥이하고, 수신을 엘리스가 하게된다.</p>
<h3 id="적절한-해결-방법-1">적절한 해결 방법</h3>
<p>이를 해결하기 위한 가장 흔한 방법은 <strong>Row 수준 잠금</strong>을 이용하는 것이다.</p>
<p>트랜잭션에서 객체를 변경하고 싶다면 해당 객체에 대한 잠금을 획득하고, 트랜잭션이 끝날 때(커밋 or 어보트) 잠금을 해제한다.</p>
<p>위 문제 상황에서는, 밥의 구매자 갱신 작업이 엘리스의 구매 트랜잭션이 끝난 뒤에 이루어지므로 구매자와 수신자가 다른 이상 현상을 겪지 않을 것이다.</p>
<p>이러한 해결 방법이 적용된 격리 수준으로는 더티 읽기와 마찬가지로 <strong>Read Committed</strong>가 있다.</p>
<h2 id="3️⃣-읽기-스큐read-skew">3️⃣ 읽기 스큐(read skew)</h2>
<p>비반복 읽기(Non Repeatable Read)라고도 하는 읽기 스큐는 한 트랜잭션에서 여러번의 읽기 작업을 수행했을 때 일관성이 깨진 데이터를 읽게되는 문제이다.</p>
<h3 id="문제-상황-2">문제 상황</h3>
<p><img src="https://github.com/user-attachments/assets/9c334a7c-33e5-4c5a-be76-d8bc19f0d614" alt="image"></p>
<p>사용자 1과 2는 모두 500$가 있는 상황에서 사용자 2가 1에게 100$를 송금하는 트랜잭션이 진행중이다.</p>
<p>만약, 엘리스가 트랜잭션 커밋 이전과, 이후로 잔고를 조회하면 일관성이 깨진 데이터를 볼 수 있다.</p>
<ul>
<li>커밋 이전 사용자 1의 잔고는 500$, 커밋 이후 사용자 2의 잔고는 400$ → 100$가 증발??</li>
</ul>
<h3 id="적절한-해결-방법-2">적절한 해결 방법</h3>
<p>읽기 스큐는 지속적인 문제는 아니다. 만약 송금 트랜잭션 이후에 두 잔고를 다시 조회한다면 정상적으로 보일 것이다.</p>
<p>그러나, 일시적인 일관성 파괴가 치명적인 상황에서는 일관성을 감내할 수 없다.</p>
<ul>
<li>예를 들면, 백업과 같은 상황이 치명적일 수 있다. 만약 일관성이 파괴된 순간의 데이터가 백업된다면 큰 문제가 발생할 수 있다.</li>
</ul>
<p>이런 문제의 가장 흔한 해결책으로는 스냅숏 격리가 있다.</p>
<h3 id="스냅숏-격리">스냅숏 격리</h3>
<p>스냅숏 격리는 DB가 객체 마다 여러 버전의 데이터를 유지하는 MVCC 기법을 사용하여 구현된다.</p>
<p><img src="https://github.com/user-attachments/assets/73037b15-b15a-40c6-b7c1-b57da2ebcaad" alt="image"></p>
<p>그림과 같이, 트랜잭션에는 계속 증가하는 ID가 할당된다. 트랜잭션에서 읽기 작업이 진행된다면, 현재 트랜잭션이 시작한 시점에 커밋된 데이터를 읽는다. 다른 트랜잭션이 쓰기 작업 후 커밋된다 해도 같은 시점의 데이터를 읽으므로 영향이 없다.</p>
<p>즉, 트랜잭션이 시작되는 시점에 스냅숏을 생성하고, 읽기 &amp; 쓰기 작업을 해당 스냅숏에 진행한다.</p>
<p>위 문제 상황에서는 엘리스가 트랜잭션 시작 시점의 스냅숏에 읽기를 진행하므로 두 번의 조회에서 500$를 읽어올 수 있어서 일관성 있는 데이터를 조회할 수 있다.</p>
<p>이러한 해결 방법이 적용된 격리 수준으로는 오라클에서는 <strong>직렬성</strong>, PostgreSQL과 MySQL에서는 <strong>Repeatable Read</strong>가 있다.</p>
<h2 id="4️⃣-갱신-손실">4️⃣ 갱신 손실</h2>
<p>갱신 손실은 값을 읽고 변경한 후 변경한 값을 다시 쓰는 갱신 작업이 동시에 일어났을 때 다른 갱신 작업이 손실될 수 있는 문제이다.</p>
<h3 id="문제-상황-3">문제 상황</h3>
<p><img src="https://github.com/user-attachments/assets/0feb9a7e-d7b1-4a58-bb54-b16fe4df57db" alt="image"></p>
<p>사용자 1과 2가 카운터를 증가시키는 트랜잭션을 동시에 진행한다.</p>
<p>이때, 사용자 1과 2는 같은 값(42)을 읽었고, 각자 1씩 증가시키고 커밋하였다.</p>
<p>두 번의 증가 작업이 일어났으므로 44가 되어야 정상이지만, 두 번째 갱신 작업이 첫 번째 갱신 작업을 덮어 씌우면서 갱신 손실 문제가 발생했다.</p>
<p>이러한 문제는 아래와 같은 다양한 상황에서 발생할 수 있다.</p>
<ul>
<li>계좌 잔고 갱신 작업(계좌 잔고를 읽고, 변경한 뒤 다시 쓰는 갱신 작업)</li>
<li>문서의 일부분 변경(문서를 읽고, 일부를 변경한 뒤 다시 쓰는 갱신 작업)</li>
</ul>
<h3 id="적절한-해결-방법-3">적절한 해결 방법</h3>
<p>갱신 손실은 이렇게 자주 발생하는 문제라서 다양한 해결책이 개발되었다.</p>
<ol>
<li><p><strong>원자적 쓰기 연산</strong></p>
<p> 여러 DB에서 원자적 갱신 연산을 제공한다. 이러한 원자적 연산은 그 객체에 독점적인(Exclusive) 잠금을 획득해서 구현한다. 그래서 갱신 적용 전까지 다른 트랜잭션에서 그 객체를 읽지 못하게 한다. 혹은 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하여 구현할 수도 있다.</p>
</li>
<li><p><strong>명시적인 잠금</strong></p>
<p> 애플리케이션에서 갱신할 객체를 명시적으로 잠궈서 갱신 손실을 해결할 수 있다. 동시에 같은 객체를 읽으려 할 때 첫 번째 갱신이 완료될 때 까지 다음 작업들이 기다린다.</p>
</li>
<li><p><strong>갱신 손실 자동 감지</strong></p>
<p> 원자적 연산과 잠금은 결국 순차적으로 실행되게 함으로써 갱신 손실을 방지하는 방법이다. 이에 대한 대안으로는 병렬 실행을 허용하고, 갱신 손실을 발견하면 해당 트랜잭션을 어보트하고 재시도하도록 하는 방법이 있다.</p>
<p> 해당 방법의 이점은 스냅숏 격리와 결합해 효율적으로 수행될 수 있다는 것이다. 실제로 PostgreSQL의 Repeatable Read와 오라클의 직렬성 격리수준은 갱신 손실 자동 감지를 제공하지만, MySQL(InnoDB)의 Repeatable Read는 제공하지 않는다.</p>
</li>
</ol>
<h3 id="복제가-적용된-상황이라면">복제가 적용된 상황이라면?</h3>
<p>여러 노드에 데이터의 복사본이 있어서 데이터가 다른 노드들에서 동시에 변경될 수 있으므로 갱신 손실을 방지하려면 어떻게 해야할까?</p>
<p>가장 흔한 방법은 쓰기가 동시에 실행될 때 한 값에 대해 여러개의 충돌된 버전(sibling)을 생성하는 것을 허용하고 사후에 어플리케이션 코드나 특별한 데이터 구조를 사용해 충돌을 해소하고 이 버전들을 병합하는 것이다.</p>
<p>또한, 원자적 연산은 복제 상황에서도 잘 작동한다.</p>
<h2 id="5️⃣-쓰기-스큐와-팬텀write-skew-phantom">5️⃣ 쓰기 스큐와 팬텀(Write Skew, Phantom)</h2>
<p>이정도나 했는데도 아직도 문제 상황이 남아있다.. 이 부분은 구체적인 예시를 살펴보며 설명하겠다.</p>
<h3 id="문제-상황---1-서로-다른-객체-갱신">문제 상황 - 1 (서로 다른 객체 갱신)</h3>
<p><img src="https://github.com/user-attachments/assets/5799127d-41d0-4424-b063-68497f19c2d2" alt="image"></p>
<p>병원에는 최소 1명이 근무하고 있어야 한다. 만약, 1명 보다 많은 사람이 근무 중이라면 쉬러갈 수 있다.</p>
<p>위 그림에서는 엘리스와 밥이 병원에서 근무 중이다. 만약 엘리스와 밥이 동시에 트랜잭션을 시작하면 두 트랜잭션 다 근무 중인 인원을 2명으로 읽고, 자기 자신을 휴식 상태로 만든다.</p>
<p>이러한 이상 현상을 쓰기 스큐라고 한다. 두 트랜잭션이 두 개의 다른 객체를 갱신하므로 더티 쓰기도, 갱신 손실도 아니다.</p>
<h3 id="문제-상황---2-삽입">문제 상황 - 2 (삽입)</h3>
<p>두 명이 같은 시간에 회의실 예약을 진행하려고 한다. 이를 위해선 예약하려는 시간에 회의 예약 내역이 있는지 확인하고, 없다면 예약 데이터를 삽입한다.</p>
<p>스냅숏 격리는 이렇게 충돌되는 삽입 연산을 막을 수 없다.</p>
<h3 id="쓰기-스큐를-유발하는-팬텀">쓰기 스큐를 유발하는 팬텀</h3>
<p>팬텀은 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 쿼리 결과를 바꾸는 효과를 말한다.</p>
<p>쓰기 스큐를 유발하는 팬텀은 비슷한 패턴을 따른다.</p>
<ol>
<li>SELECT 쿼리를 통해 요구사항을 만족하는지 확인한다.<ul>
<li>ex) 최소 두 명의 의사가 근무 중이다, 해당 시간에 예약 기록이 없다</li>
</ul>
</li>
<li>요구사항을 만족하면 DB에 쓰고 트랜잭션을 커밋한다. 이 쓰기의 효과로 1단계의 결과가 바뀐다.<ul>
<li>ex) 근무 중인 의사가 1명 줄었다, 해당 시간에 예약 기록이 생겼다</li>
<li>만약 쓰기의 대상이 명확하다면 잠금을 통해 해결할 수 있다. 하지만, 다르거나 명확하지 않다면 해결이 어려워진다.<ul>
<li>ex1) 의사 본인의 상태를 업데이트 → 동시에 실행되는 트랜잭션이 같은 Row를 업데이트하지 않음</li>
<li>ex2) 예약 기록을 삽입 → 수정이 아닌 삽입 작업이므로 잠금의 대상이 명확하지 않음</li>
</ul>
</li>
</ul>
</li>
</ol>
<h3 id="적절한-해결-방법-4">적절한 해결 방법</h3>
<p>만약, 잠글 객체가 없는 것이라면 잠글 객체를 만드는 충돌 구체화 방식을 통해서 해결할 수 있다.</p>
<p>회의 예약 예시의 경우, 예약 가능한 모든 시간에 객체를 생성해서 해당 객체에 잠금을 거는 방식으로 해결이 가능하다. 다만, 대부분의 경우는 직렬성 격리 수준이 훨씬 더 선호된다.</p>
<h2 id="6️⃣-결론">6️⃣ 결론</h2>
<p>생각 보다도 데이터의 일관성을 지키는데는 장애물이 많다는 것을 새삼 깨달았다…</p>
<p>이래서 다들 트랜잭션과 격리 수준이 중요하다고 하고, 면접에서도 단골 질문으로 등장하는구나 싶다..!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트랜잭션은 어떻게 데이터 시스템의 문제들을 해결할까? (feat. ACID)]]></title>
            <link>https://velog.io/@jeong_hun_hui/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%98-%EB%AC%B8%EC%A0%9C%EB%93%A4%EC%9D%84-%ED%95%B4%EA%B2%B0%ED%95%A0%EA%B9%8C-feat.-ACID</link>
            <guid>https://velog.io/@jeong_hun_hui/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%98-%EB%AC%B8%EC%A0%9C%EB%93%A4%EC%9D%84-%ED%95%B4%EA%B2%B0%ED%95%A0%EA%B9%8C-feat.-ACID</guid>
            <pubDate>Sun, 23 Jun 2024 13:57:42 GMT</pubDate>
            <description><![CDATA[<h2 id="0️⃣-배경">0️⃣ 배경</h2>
<p>데이터 중심 애플리케이션 설계 7장(트랜잭션)을 읽던 중 데이터 시스템에서 발생할 수 있는 문제들과, 안전성을 보장하는 트랜잭션의 성질인 ACID에 대한 내용을 접했다.</p>
<p>나는 막연히 트랜잭션은 ACID의 성질을 통해 안전성을 보장한다고 알고 있었지만, ACID의 각 성질이 <strong>어떻게 안전성을 보장</strong>하는 것인지 깊게 생각하지 못했다.</p>
<p>그래서, 이번에는 데이터 시스템에서 생길 수 있는 문제들을 살펴보고, 트랜잭션은 어떻게 문제들을 해결하는지 ACID의 각 성질을 살펴보며 알아보겠다.</p>
<h2 id="1️⃣-데이터-시스템에서-발생할-수-있는-문제들">1️⃣ 데이터 시스템에서 발생할 수 있는 문제들</h2>
<h3 id="⚠️-문제-및-해결-방법---트랜잭션">⚠️ 문제 및 해결 방법 - 트랜잭션</h3>
<p>데이터 시스템은 아래와 같은 문제들이 발생할 수 있다.</p>
<ul>
<li>작업 중 네트워크 연결이 끊기거나 프로세스가 죽는 등의 이유로 작업이 불완전하게 끝날 수 있다.</li>
<li>정해진 비즈니스 규칙에서 벗어나는 유효하지 않은 데이터가 저장될 수 있다.</li>
<li>여러 클라이언트가 동시에 쓰기 작업을 실행해서 다른 클라이언트가 쓴 내용을 덮어쓸 수 있다.</li>
</ul>
<p>시스템이 신뢰성을 지니려면 이러한 문제들을 해결해야한다.</p>
<p>트랜잭션은 ACID라는 네 가지 성질을 통해 안전성을 보장하여 위 문제들을 해결한다.</p>
<h3 id="✅-acid-란">✅ ACID 란?</h3>
<p>ACID는 트랜잭션이 안전성을 보장할 수 있게해주는 4가지 성질이다.</p>
<p>4가지 성질은 다음과 같다.</p>
<ul>
<li>원자성(Atomicity)</li>
<li>일관성(Consistency)</li>
<li>격리성(Isolation)</li>
<li>지속성(Durability)</li>
</ul>
<p>ACID의 각 성질을 아래와 같은 과정으로 살펴보겠다.</p>
<ul>
<li>해당 성질이 필요한 문제 상황</li>
<li>해당 성질의 정의</li>
<li>해당 성질이 문제를 해결하는 방법</li>
</ul>
<h2 id="2️⃣-원자성atomicity">2️⃣ 원자성(Atomicity)</h2>
<h3 id="⚠️-문제-상황">⚠️ 문제 상황</h3>
<p>만약 트랜잭션에서 여러 변경 중 일부만 진행된 상태에서 네트워크 연결이 끊기거나 프로세스가 죽는 등의 문제로 트랜잭션이 중단되면 어떻게 될까?</p>
<p>여러 변경 중 어떤 변경은 효과가 있고, 어떤 변경은 그렇지 않은지 알기 어렵다. 또한 재시도를 하면서 중복되거나 잘못된 데이터가 만들어지기도 쉽다.</p>
<h3 id="✅-원자성이란">✅ 원자성이란?</h3>
<p>원자성은 <strong>한 트랜잭션을 더 작은 부분으로 쪼갤 수 없는 원자처럼 취급</strong>하는 성질이다. 즉, 트랜잭션은 트랜잭션 내의 모든 작업이 전부 실행된 상태나 하나도 실행되지 않은 상태만 존재할 수 있다는 것이다.</p>
<h3 id="🤝-문제-해결-방법">🤝 문제 해결 방법</h3>
<p>원자성은 트랜잭션 실행 중 문제가 발생할 경우 해당 트랜잭션에서 기록한 모든 내용을 취소하는 어보트(abort)를 진행하여 문제를 해결한다.</p>
<p>이를 통해 트랜잭션은 모든 작업이 실행되거나 아무 변경이 일어나지 않음을 보장하고, 트랜잭션이 어보트됐다면 애플리케이션에서 이 트랜잭션이 어떤 것도 변경하지 않았음을 알 수 있으므로 안전하게 재시도할 수 있다.</p>
<h2 id="3️⃣-일관성consistency">3️⃣ 일관성(Consistency)</h2>
<h3 id="⚠️-문제-상황-1">⚠️ 문제 상황</h3>
<p>계좌 송금 작업을 진행하기 위해서는 계좌에서 송금할 금액을 출금해야한다. 하지만, 계좌에 잔액이 충분하지 않은 상태에서 작업이 진행된다면 계좌의 잔액이 음수가 돼서 유효하지 않은 상태가 된다.</p>
<h3 id="✅-일관성이란">✅ 일관성이란?</h3>
<p>일관성은 데이터베이스가 트랜잭션 실행 후에도 유효한 상태에 있어야 한다는 성질이다.</p>
<h3 id="🤝-문제-해결-방법-1">🤝 문제 해결 방법</h3>
<p>이렇게 유효한 상태를 유지하기 위해서는 트랜잭션 진행 중에 유효하지 않은 데이터 변경이 일어날 때 트랜잭션을 어보트하면 된다.</p>
<p>하지만, 생각해보면 일관성을 보장하는 것이 데이터베이스만의 역할인지는 의문이다. 문자열인지 정수인지는 타입을 통해 유효성을 검증할 수 있지만, 음수인지, n글자인지 등 다양한 경우 애플리케이션에서 유효성을 검증해야하기 때문이다.</p>
<h2 id="4️⃣-격리성isolation">4️⃣ 격리성(Isolation)</h2>
<h3 id="⚠️-문제-상황-2">⚠️ 문제 상황</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/1c2957d5-59d9-417b-8650-0996085b1d62/image.png" alt=""></p>
<p>위 그림을 보면, 사용자 1과 2가 거의 동시에 조회 및 변경 작업을 진행하고 있다. 하지만, 분명 두 사용자가 각각 카운터 증가 작업을 시행했음에도 카운터가 1만 증가하는 문제가 발생했다.</p>
<p>이처럼 동시에 여러 클라이언트에서 동일한 레코드에 접근하면 동시성 문제가 발생한다.</p>
<h3 id="✅-격리성이란">✅ 격리성이란?</h3>
<p>격리성은 동시에 실행되는 트랜잭션은 격리된다는 것을 의미한다. 즉, 여러 트랜잭션이 동시에 실행됐더라도 결과가 트랜잭션이 순차적으로 실행됐을 때의 결과와 동일하도록 보장한다.</p>
<h3 id="🤝-문제-해결-방법-2">🤝 문제 해결 방법</h3>
<p>격리성을 보장하기 위해 데이터베이스는 잠금 기능과 다양한 격리 수준을 제공한다. 격리 수준 중 Serializable이 트랜잭션이 순차적으로 실행되도록 보장하여 위 그림에서 발생하는 문제를 해결할 수 있다. 하지만 Serializable 격리 수준은 심각한 성능 손해를 동반하므로 거의 사용되지 않는다.</p>
<p>대신 스냅샷 격리라는 대안이 있다. 스냅샷 격리는 각 트랜잭션이 시작될 때의 스냅샷을 기반으로 작업을 수행한다. 그리고 트랜잭션이 커밋을 시도할 때 변경된 데이터가 중간에 다른 트랜잭션에 의해 변경되었는지(이런 현상을 Write Skew 라고 함) 확인할 수 있도록 한다. 자세한 내용은 아래 링크를 참고하자.</p>
<p><a href="https://www.geeksforgeeks.org/what-is-snapshot-isolation/">https://www.geeksforgeeks.org/what-is-snapshot-isolation/</a></p>
<h2 id="5️⃣-지속성durability">5️⃣ 지속성(Durability)</h2>
<h3 id="⚠️-문제-상황-3">⚠️ 문제 상황</h3>
<p>여러 문제로 인해 데이터가 소실되는 문제가 발생할 수 있다.</p>
<h3 id="✅-지속성이란">✅ 지속성이란?</h3>
<p>지속성은 트랜잭션이 성공적으로 커밋됐다면, 기록한 모든 데이터는 소실되지 않는다는 성질이다.</p>
<h3 id="🤝-문제-해결-방법-3">🤝 문제 해결 방법</h3>
<p>지속성을 보장하기 위해서는 트랜잭션이 커밋되기 전에 쓰기나 복제가 완료되어야 한다. 복제, 백업도 지속성을 보장하는 하나의 방법이다.</p>
<p>하지만 그럼에도 불구하고 모든 HDD와 백업이 동시에 파괴된다면 어쩔 수 없기 때문에 완벽한 지속성은 존재할 수 없다.</p>
<h2 id="6️⃣-그렇다면-모든-데이터-시스템은-트랜잭션을-사용해야할까">6️⃣ 그렇다면 모든 데이터 시스템은 트랜잭션을 사용해야할까?</h2>
<p>역시나 트랜잭션도 트레이드오프가 있다.</p>
<p>위에서 살펴본 것 처럼 트랜잭션은 안전성을 보장하여 여러 문제를 쉽게 해결할 수 있도록 도와준다. 하지만 트랜잭션이 실행하면서 잠금으로 인해 성능이 저하되고 데드락이 발생할 수 있다는 문제점도 존재한다.</p>
<p>위에서 살펴본 ACID를 통해 내 애플리케이션에 트랜잭션이 필요한지 깊게 생각해본 뒤 사용 여부를 결정해야한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[static의 오해와 진실(feat. k6, VisualVM)]]></title>
            <link>https://velog.io/@jeong_hun_hui/static%EC%9D%98-%EC%98%A4%ED%95%B4%EC%99%80-%EC%A7%84%EC%8B%A4feat.-k6-VisualVM</link>
            <guid>https://velog.io/@jeong_hun_hui/static%EC%9D%98-%EC%98%A4%ED%95%B4%EC%99%80-%EC%A7%84%EC%8B%A4feat.-k6-VisualVM</guid>
            <pubDate>Sat, 18 May 2024 16:50:59 GMT</pubDate>
            <description><![CDATA[<h2 id="0️⃣-배경---static-쓰면-안될까"><strong>0️⃣ 배경 - static 쓰면 안될까?</strong></h2>
<p>코드를 작성할 때 마다 별 생각 없이 static 키워드를 사용했었다. 그럴 때 마다 <strong>static 키워드를 붙히면 메모리를 계속 차지하고 있기 때문에 성능이 저하되므로 자제</strong>하라는 리뷰를 받았다.</p>
<p>왜 메모리를 계속 차지하고 있지? 왜 성능이 저하되지? 라는 의문이 생겨서 이에 대한 궁금증을 해결하기 위해 관련 내용을 공부하고, 직접 테스트한 뒤 결과를 분석한 과정을 정리하였다.</p>
<h2 id="1️⃣-jvm-메모리-구조">1️⃣ JVM 메모리 구조</h2>
<p>static 키워드와 메모리에 관련해서 여러 글을 찾다보니, 먼저 JVM의 메모리 구조에 대해 알 필요성을 느끼게 되었다.</p>
<h3 id="▶️-heap과-metaspace">▶️ Heap과 Metaspace</h3>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/07d0d872-b89e-479b-ba83-fa60c845b5de" alt="image"></p>
<p><code>Heap</code>은 새로 생성된 인스턴스가 저장되는 영역이다. 생성된 인스턴스는 <code>Heap</code> 영역에서 돌아다니다가 더 이상 사용되지 않을 때 GC에 의해 메모리가 해제된다.</p>
<p><code>Metaspace</code>는 클래스와 메서드의 메타데이터가 저장되는 영역이고, 네이티브 메모리를 사용한다. Java 8버전 부터 등장했다.</p>
<p>static 변수나 메서드는 두 영역 중 <code>Metaspace</code> 영역에 저장된다.</p>
<h3 id="▶️-왜-static-키워드를-붙히면-메모리를-계속-차지하는가">▶️ 왜 static 키워드를 붙히면 메모리를 계속 차지하는가?</h3>
<p>그 이유는 static 키워드를 붙힌 변수나 메서드는 속한 클래스와 같은 생명주기를 가지기 때문이다. 즉, 속한 클래스가 로드될 때 <code>Metaspace</code> 영역에 저장되고, 언로드될 때 GC의 대상이 되어 메모리가 해제된다는 의미이다.</p>
<p>하지만, JVM에서 클래스를 언로드하는 경우는 매우 드물기 때문에 계속 메모리를 차지하고 있는 것이다.</p>
<p>그렇다면 static 변수에 많은 데이터를 넣으면 실제로 어떻게 될까?</p>
<h2 id="2️⃣-static-list에-인스턴스-삽입-테스트">2️⃣ static List에 인스턴스 삽입 테스트</h2>
<p>이론적으로는 예상이 되지만, 과연 static 변수에 계속 데이터를 넣으면 어떻게 되는지 직접 테스트 하고 싶어졌다.</p>
<h3 id="▶️-테스트-과정">▶️ 테스트 과정</h3>
<ol>
<li>static List를 선언하고, 해당 List에 값을 넣는 API를 만든다.</li>
<li>k6를 활용한 부하 테스트로 static List에 많은 데이터를 넣고 API 통신 지표를 측정한다.</li>
<li>VisualVM을 활용하여 해당 과정에서 JVM을 모니터링 하여 여러 지표를 측정한다.</li>
<li>다양한 지표(<strong>Heap Dump</strong>, CPU 및 메모리 사용량 그래프 등)를 통해서 결과를 분석한다.</li>
</ol>
<h3 id="▶️-테스트-환경">▶️ 테스트 환경</h3>
<ul>
<li>Kotlin + SpringBoot 환경</li>
<li>JVM 메모리 최대 사용량을 <code>256mb</code>로 제한(<code>-Xmx256m</code>)<ul>
<li>메모리 용량을 낮춰 메모리 사용에 더 민감하게 하기 위함</li>
</ul>
</li>
<li>k6의 가상 유저 수는 <code>2500</code>, 테스트 시간은 <code>10초</code>로 설정<ul>
<li>테스트 시간을 짧게 설정하고 여러 번 수행하여 평균 계산</li>
</ul>
</li>
</ul>
<h3 id="▶️-테스트-할-코드">▶️ 테스트 할 코드</h3>
<ul>
<li><p>github 주소: <a href="https://github.com/JeongHunHui/StaticKeywordTest">https://github.com/JeongHunHui/StaticKeywordTest</a></p>
</li>
<li><p><code>FruitClass</code> - 과일의 정보를 담고 있는 객체</p>
<pre><code class="language-kotlin">  data class FruitClass (
      val koreanName: String,
      val price: Int,
      val description: String
  )</code></pre>
</li>
<li><p><code>StaticTestController</code> - FruitClass 인스턴스를 저장하는 API를 구현한 컨트롤러</p>
<pre><code class="language-kotlin">  @RestController
  class StaticTestController {
      companion object {
          val fruitClasses = arrayListOf&lt;FruitClass&gt;()
      }

      @GetMapping(&quot;/nonStaticFruitClass&quot;)
      fun saveNonStaticFruitClass() {
          for (i in 0..2) {
              fruitClasses.add(FruitClass(&quot;사과&quot;, 1000, &quot;붉고 맛있는 과일&quot;))
              fruitClasses.add(FruitClass(&quot;바나나&quot;, 1500, &quot;길고 노란 열대 과일&quot;))
              fruitClasses.add(FruitClass(&quot;체리&quot;, 2000, &quot;작고 달콤한 빨간 과일&quot;))
              fruitClasses.add(FruitClass(&quot;포도&quot;, 3000, &quot;다수의 작은 과일이 한송이에 달려있는 과일&quot;))
              fruitClasses.add(FruitClass(&quot;오렌지&quot;, 2500, &quot;비타민 C가 풍부한 주황색 과일&quot;))
          }
      }
  }</code></pre>
</li>
</ul>
<h3 id="▶️-테스트-결과">▶️ 테스트 결과</h3>
<ul>
<li><p>K6 부하 테스트 결과(스크린샷)</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/cc54df64-9030-42be-a59b-69aeffdebdc7" alt="image"></p>
</li>
<li><p>K6 부하 테스트 결과(표)</p>
</li>
</ul>
<pre><code>| 지표 | 1차 테스트 | 2차 테스트 | 3차 테스트 | 평균 |
| --- | --- | --- | --- | --- |
| 데이터 수신량 (MB) | 21 | 21 | 21 | 21 |
| 그룹 지속 시간 (ms) | 86.97 | 97.85 | 90.48 | 91.77 |
| HTTP 요청 응답 시간 (ms) | 53.44 | 69.95 | 58.9 | 60.76 |
| 반복 수행 시간 (ms) | 90.85 | 102.01 | 94.33 | 95.73 |
| 반복 수행 횟수 | 287629 | 286628 | 286133 | 286797 |</code></pre><ul>
<li><p>VisualVM 모니터링 결과</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/3cb776a9-44af-4d34-be66-8631912171b3" alt="image"></p>
</li>
</ul>
<h3 id="▶️-테스트-결과-분석---oom-발생">▶️ 테스트 결과 분석 - OOM 발생</h3>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/0f1ba83b-2efc-4ec9-aee2-57005e136f39" alt="image"></p>
<p>우선, 테스트를 진행하며 위와 같이 OOM(Out Of Memory)에러가 발생하였다. 이에 대한 원인은 VisualVM을 통해 파악할 수 있었다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/639f7ef0-3e46-4e95-850a-b7a152ab45ac" alt="image"></p>
<p>Heap 메모리 사용량 그래프를 보면, 메모리 사용량이 줄어들지 않고 우상향하고 있다. GC가 작동하면서 메모리를 확보해야하는데 그렇지 못하고 있다. 이는 전형적인 메모리 누수 현상이다.</p>
<p>그렇다면 메모리는 어떤 데이터를 가지고 있을까? 이는 <strong>Heap Dump</strong>를 통해 확인할 수 있다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/3f6a4d9b-172f-42a9-bdac-b9231b63bd1b" alt="image"></p>
<p><strong>Heap Dump</strong>를 확인해보니, <code>FruitClass</code>가 전체 인스턴스의 <code>91.8%</code>인 <code>4121115개</code> 생성되었고, 용량도 전체의 <code>63.1%</code>에 해당하는 약 <code>95MB</code>를 차지하고 있는 모습이다.</p>
<p>정리하면 static List에 <code>FruitClass</code>가 계속 추가됐고, GC가 메모리를 확보하지 못해 Heap 메모리가 <code>FruitClass</code>로 가득 차며 OOM이 발생하였다.</p>
<p>그렇다면 왜 GC가 메모리를 확보하지 못했을까?</p>
<p>static List는 Metaspace 영역에 저장되어있고, 속해있는 클래스(<code>StaticTestController</code>)와 생명주기를 함께한다. GC는 참조가 없는 인스턴스들을 수거하는데, static List에 저장된 FruitClass 인스턴스들은 계속 static List에 의해 참조되고 있기 때문에 GC가 메모리를 확보하지 못했다.</p>
<h3 id="▶️-테스트-결론">▶️ 테스트 결론</h3>
<p>static 키워드가 참조하는 인스턴스들은 계속 Heap 메모리를 차지하고 있으므로 사용을 주의해야한다. 또한, 이런 메모리 누수(Memory Leak)현상을 확인하고 원인을 분석하는 과정에 대해서도 배우게 되었다.</p>
<h2 id="3️⃣-static-키워드는-쓰면-안되는걸까">3️⃣ static 키워드는 쓰면 안되는걸까?</h2>
<p>사실 static 키워드가 붙은 상수, 클래스, 메서드 자체는 Heap 영역이 아닌 Metaspace 영역에 저장되고, 그 자체로 많은 메모리를 차지하기는 어렵다. 대신, 위 테스트 처럼 <strong>static 변수가 참조하는 인스턴스가 매우 많거나, 엄청 큰 데이터를 저장하는 방식으로 사용해서는 안된다.</strong></p>
<h3 id="▶️-static-키워드는-언제-써야할까">▶️ static 키워드는 언제 써야할까?</h3>
<p>위 테스트에서는 아래와 같이 반복되는 값들에 대해서 일일히 인스턴스를 생성해서 List에 삽입하였다.</p>
<pre><code class="language-kotlin">@RestController
class StaticTestController {
    companion object {
        val fruitClasses = arrayListOf&lt;FruitClass&gt;()
    }

    @GetMapping(&quot;/nonStaticFruitClass&quot;)
    fun saveNonStaticFruitClass() {
        for (i in 0..2) {
            fruitClasses.add(FruitClass(&quot;사과&quot;, 1000, &quot;붉고 맛있는 과일&quot;))
            fruitClasses.add(FruitClass(&quot;바나나&quot;, 1500, &quot;길고 노란 열대 과일&quot;))
            fruitClasses.add(FruitClass(&quot;체리&quot;, 2000, &quot;작고 달콤한 빨간 과일&quot;))
            fruitClasses.add(FruitClass(&quot;포도&quot;, 3000, &quot;다수의 작은 과일이 한송이에 달려있는 과일&quot;))
            fruitClasses.add(FruitClass(&quot;오렌지&quot;, 2500, &quot;비타민 C가 풍부한 주황색 과일&quot;))
        }
    }
}</code></pre>
<p>하지만, 이렇게 반복되는 값에 아래 코드 처럼 static 키워드를 붙혀서 저장한다면 일일히 인스턴스를 생성하는 것 보다 효율적으로 메모리를 사용할 수 있게 된다.</p>
<pre><code class="language-kotlin">data class FruitClass (
    val koreanName: String,
    val price: Int,
    val description: String
) {
    companion object {
        val APPLE = FruitClass(&quot;사과&quot;, 1000, &quot;붉고 맛있는 과일&quot;)
        val BANANA = FruitClass(&quot;바나나&quot;, 1500, &quot;길고 노란 열대 과일&quot;)
        val CHERRY = FruitClass(&quot;체리&quot;, 2000, &quot;작고 달콤한 빨간 과일&quot;)
        val GRAPE = FruitClass(&quot;포도&quot;, 3000, &quot;다수의 작은 과일이 한송이에 달려있는 과일&quot;)
        val ORANGE = FruitClass(&quot;오렌지&quot;, 2500, &quot;비타민 C가 풍부한 주황색 과일&quot;)
    }
}</code></pre>
<p>과연 정말 그럴지 테스트를 진행해서 결과를 비교해보자.</p>
<h2 id="4️⃣-static-list에-static-값-삽입-테스트">4️⃣ static List에 static 값 삽입 테스트</h2>
<h3 id="▶️-테스트-과정-환경-코드">▶️ 테스트 과정, 환경, 코드</h3>
<p>테스트 과정 및 환경은 이전 테스트와 동일하고, 코드는 아래와 같이 변경하였다.</p>
<ul>
<li><p><code>FruitClass</code></p>
<pre><code class="language-kotlin">  data class FruitClass (
      val koreanName: String,
      val price: Int,
      val description: String
  ) {
      companion object {
          val APPLE = FruitClass(&quot;사과&quot;, 1000, &quot;붉고 맛있는 과일&quot;)
          val BANANA = FruitClass(&quot;바나나&quot;, 1500, &quot;길고 노란 열대 과일&quot;)
          val CHERRY = FruitClass(&quot;체리&quot;, 2000, &quot;작고 달콤한 빨간 과일&quot;)
          val GRAPE = FruitClass(&quot;포도&quot;, 3000, &quot;다수의 작은 과일이 한송이에 달려있는 과일&quot;)
          val ORANGE = FruitClass(&quot;오렌지&quot;, 2500, &quot;비타민 C가 풍부한 주황색 과일&quot;)
      }
  }</code></pre>
</li>
<li><p><code>StaticTestController</code></p>
<pre><code class="language-kotlin">  @RestController
  class StaticTestController {
      companion object {
          val fruitClasses = arrayListOf&lt;FruitClass&gt;()
      }

      @GetMapping(&quot;/fruitClass&quot;)
      fun saveFruitClass() {
          for (i in 0..2) {
              fruitClasses.add(FruitClass.APPLE)
              fruitClasses.add(FruitClass.BANANA)
              fruitClasses.add(FruitClass.CHERRY)
              fruitClasses.add(FruitClass.GRAPE)
              fruitClasses.add(FruitClass.ORANGE)
          }
      }
  }</code></pre>
</li>
</ul>
<h3 id="▶️-테스트-결과-1">▶️ 테스트 결과</h3>
<ul>
<li><p>K6 부하 테스트 결과(스크린샷)</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/aae4c401-3b65-4795-811e-e7106204b837" alt="image"></p>
</li>
<li><p>K6 부하 테스트 결과(표)</p>
</li>
</ul>
<pre><code>| 지표 | 1차 테스트 | 2차 테스트 | 3차 테스트 | 평균 |
| --- | --- | --- | --- | --- |
| 데이터 수신량 (MB) | 26 | 26 | 25 | 25.67 |
| 그룹 지속 시간 (ms) | 59.81 | 63.52 | 63.2 | 62.18 |
| HTTP 요청 응답 시간 (ms) | 31.17 | 37.41 | 35.59 | 34.72 |
| 반복 수행 시간 (ms) | 64.13 | 66.8 | 67.78 | 66.24 |
| 반복 수행 횟수 | 353542 | 350561 | 338023 | 347375 |</code></pre><ul>
<li><p>VisualVM 모니터링 결과</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/16c2c897-8a70-46c2-a5cc-8988e43c77fa" alt="image"></p>
</li>
</ul>
<h3 id="▶️-테스트-결과-분석">▶️ 테스트 결과 분석</h3>
<p>우선 1차 테스트에서 발생했던 OOM 에러는 발생하지 않았다.</p>
<p>그리고 Heap Dump를 확인해보면, 1차 테스트에서는 <code>FruitClass</code>가 Heap 영역의 대부분을 차지했었지만, 2차 테스트에서는 없다는 것을 확인할 수 있다.</p>
<ul>
<li><p>1차 테스트 Heap Dump (인스턴스)</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/b967d902-ee72-4905-8b6a-a71c8b9d1e6d" alt="image"></p>
</li>
<li><p>2차 테스트 Heap Dump (static)</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/91ffe188-df4d-4acd-a723-a5d26bbe5cf8" alt="image"></p>
</li>
</ul>
<p>또한, 1차 테스트와 2차 테스트의 K6 지표를 비교해보면 더 많은 데이터를 수신하고, 짧은 지속시간과 응답 시간이 소요되고, 더 많은 처리를 했다는 점을 통해 성능적으로 우수하다는 점을 확인할 수 있다.</p>
<ul>
<li>1차 테스트 K6 지표 (인스턴스)</li>
</ul>
<pre><code>| 지표 | 1차 테스트 | 2차 테스트 | 3차 테스트 | 평균 |
| --- | --- | --- | --- | --- |
| 데이터 수신량 (MB) | 21 | 21 | 21 | 21 |
| 그룹 지속 시간 (ms) | 86.97 | 97.85 | 90.48 | 91.77 |
| HTTP 요청 응답 시간 (ms) | 53.44 | 69.95 | 58.9 | 60.76 |
| 반복 수행 시간 (ms) | 90.85 | 102.01 | 94.33 | 95.73 |
| 반복 수행 횟수 | 287629 | 286628 | 286133 | 286797 |</code></pre><ul>
<li>2차 테스트 K6 지표 (static)</li>
</ul>
<pre><code>| 지표 | 1차 테스트 | 2차 테스트 | 3차 테스트 | 평균 |
| --- | --- | --- | --- | --- |
| 데이터 수신량 (MB) | 26 | 26 | 25 | 25.67 |
| 그룹 지속 시간 (ms) | 59.81 | 63.52 | 63.2 | 62.18 |
| HTTP 요청 응답 시간 (ms) | 31.17 | 37.41 | 35.59 | 34.72 |
| 반복 수행 시간 (ms) | 64.13 | 66.8 | 67.78 | 66.24 |
| 반복 수행 횟수 | 353542 | 350561 | 338023 | 347375 |</code></pre><p>그렇다면 왜 성능적으로 우수한걸까?</p>
<h3 id="▶️-두-테스트-간-성능-차이의-원인">▶️ 두 테스트 간 성능 차이의 원인</h3>
<p>성능 테스트에서 static 키워드를 사용하는 것이 인스턴스를 사용하는 것보다 더 효율적인 결과를 보인 이유는 크게 2가지가 있다.</p>
<ol>
<li><p><strong>객체 생성 및 메모리 할당 작업이 없다.</strong></p>
<p> 위에서 설명했듯이 static 키워드를 붙힌 요소는 프로그램 실행 시 메모리에 할당되고, 프로그램의 생명주기 동안 계속 유지된다. 이 때문에 매번 새로운 인스턴스를 생성하고 메모리에 할당할 필요가 없으므로 성능이 향상된다.</p>
</li>
<li><p><strong>GC가 비효율적으로 동작한다.</strong></p>
<p> 두 테스트에서 GC가 일어난 횟수와 GC 작업에 소요된 총 시간을 비교해보면, 횟수는 3배 더 많았고, 소요 시간은 22배 더 많았다.</p>
<ul>
<li><p>1차 테스트 GC 통계 (인스턴스)</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/40fd2613-de9e-45f4-aab6-2cb1611fbf3c" alt="image"></p>
</li>
<li><p>2차 테스트 GC 통계 (static)</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/f13809c9-8895-45c5-b5b1-96f41a2fc2d3" alt="image"></p>
</li>
</ul>
</li>
</ol>
<pre><code>당연히 Heap 메모리를 계속 차지하게 되면, GC는 메모리를 확보하기 위해 동작하게 된다. 하지만 대부분 참조가 일어나고 있기 때문에 GC가 메모리 해제를 시킬 수 없으며, 계속 메모리를 차지하고 있기 때문에 수거하기 위해 스캔할 데이터도 더 많다.

GC는 작동하게되면 어플리케이션 실행을 멈추는 `Stop The World`를 발생시키기 때문에 더 자주, 그리고 더 길게 실행하게 되면 당연히 성능에 악영향을 미치게된다.</code></pre><h2 id="5️⃣-결론---잘-쓰면-좋다">5️⃣ 결론 - 잘 쓰면 좋다!</h2>
<p>static 키워드를 붙히면 메모리를 계속 차지한다. 그러므로 static 키워드를 붙힌 데이터의 용량이 크거나, 참조하는 값이 많다면 성능에 악영향을 끼치는 것은 물론이고, 메모리 누수로 인한 OOM까지 발생할 수 있다.</p>
<p>하지만 자주 사용되는 상수나 메서드에 static 키워드를 붙히면 더 효과적으로 메모리를 사용하여 성능에도 좋은 영향을 미치게된다.</p>
<p>그러므로 static 키워드는 적절한 상황에 잘 사용하는 것이 중요하다.</p>
<p>또한, 위 과정에서 <strong>JVM의 메모리 구조</strong>, <strong>K6를 이용한 부하 테스트</strong>, <strong>VisualVM을 이용한 JVM 모니터링</strong>까지 많은 내용을 학습할 수 있었다.</p>
<p>다음에는 내 프로젝트에 적용시켜서 내 프로젝트에서는 메모리 누수같은 문제가 일어날 지 테스트 해봐야겠다.</p>
<h2 id="6️⃣-부록---enum은-어떨까">6️⃣ 부록 - Enum은 어떨까?</h2>
<p>문득 위 테스트를 Enum으로 하면 다른 결과가 나올지 궁금해 졌다. 그래서 진행해봤다.</p>
<ul>
<li><p>코드</p>
<ul>
<li><p><code>FruitEnum</code></p>
<pre><code class="language-kotlin">  enum class FruitEnum(
      private val koreanName: String,
      private val price: Int,
      private val description: String
  ) {
      APPLE(&quot;사과&quot;, 1000, &quot;붉고 맛있는 과일&quot;),
      BANANA(&quot;바나나&quot;, 1500, &quot;길고 노란 열대 과일&quot;),
      CHERRY(&quot;체리&quot;, 2000, &quot;작고 달콤한 빨간 과일&quot;),
      GRAPE(&quot;포도&quot;, 3000, &quot;다수의 작은 과일이 한송이에 달려있는 과일&quot;),
      ORANGE(&quot;오렌지&quot;, 2500, &quot;비타민 C가 풍부한 주황색 과일&quot;)
      ;
  }</code></pre>
</li>
<li><p><code>StaticTestController</code></p>
<pre><code class="language-kotlin">  @RestController
  class StaticTestController {
      companion object {
          val fruitEnums = arrayListOf&lt;FruitEnum&gt;()
      }

      @GetMapping(&quot;/fruitEnum&quot;)
      fun saveFruitEnum() {
          for (i in 0..2) {
              fruitEnums.add(FruitEnum.APPLE)
              fruitEnums.add(FruitEnum.BANANA)
              fruitEnums.add(FruitEnum.CHERRY)
              fruitEnums.add(FruitEnum.GRAPE)
              fruitEnums.add(FruitEnum.ORANGE)
          }
      }
  }</code></pre>
</li>
</ul>
</li>
<li><p>K6 부하 테스트 결과(스크린샷)</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/a4595679-3312-49a0-9d35-5f701b26abda" alt="image"></p>
</li>
<li><p>VisualVM 모니터링 결과</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/9888a97f-e897-4c76-8241-5aee3480092c" alt="image"></p>
</li>
</ul>
<p>테스트 해보니 큰 차이는 없었다 😅</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[헥사고날 아키텍처 왜 씀? (Feat. 계층형 아키텍처)]]></title>
            <link>https://velog.io/@jeong_hun_hui/%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%99%9C-%EC%94%80-Feat.-%EA%B3%84%EC%B8%B5%ED%98%95-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@jeong_hun_hui/%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%99%9C-%EC%94%80-Feat.-%EA%B3%84%EC%B8%B5%ED%98%95-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Sat, 30 Mar 2024 13:25:01 GMT</pubDate>
            <description><![CDATA[<h2 id="0️⃣-배경">0️⃣ 배경</h2>
<p>기존에 <code>계층형 아키텍처</code>로 진행중이던 프로젝트가 있었다. 프로젝트 POC 단계 까진 문제가 없었다. 하지만 유지보수를 위한 <code>테스트 코드</code>, <code>외부 기술들의 변화</code>, <code>복잡한 비즈니스 로직</code> 등 다양한 문제를 직면하게 되었다.</p>
<p>이러한 문제들을 해결하기 위한 방법 중 하나로 <code>헥사고날 아키텍처</code>를 알게 되었고 공부하게 되었다.</p>
<p>책 <code>만들면서 배우는 클린 아키텍처</code>을 읽으며 공부하고 프로젝트에 적용하면서 느낀점을 위주로 헥사고날 아키텍처에 대해 정리했다.</p>
<h2 id="1️⃣-헥사고날-아키텍처란">1️⃣ 헥사고날 아키텍처란?</h2>
<p>소프트웨어 설계에 사용되는 아키텍처 패턴 중 하나이며, 사전적 의미로는 육각형 건축물을 말한다. 사실, 이름만 들어서는 전혀 어떤 아키텍처인지 감이 안온다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/0161400b-c8f6-44a1-9b61-538ae218de93" alt="image"></p>
<p>위 그림을 보면 육각형이 있고, 육각형 경계를 기준으로 영역을 내부와 외부로 나눌 수 있다. 헥사고날 아키텍처는 <strong>내부의 도메인 비즈니스로직이 외부요소에 의존하지 않도록 설계된 아키텍처</strong>이다.</p>
<p>영역의 외부에는 어댑터가, 내부는 유스케이스와 엔티티, 그리고 그 경계는 포트로 이루어져있다. 각 요소에 대해 설명하며 어떻게 헥사고날 아키텍처가 내부와 외부의 결합도를 낮추고, 그렇게 하면 뭐가 좋은지 알아보겠다.</p>
<h2 id="2️⃣-포트와-어댑터-아키텍처">2️⃣ 포트와 어댑터 아키텍처</h2>
<p>헥사고날 아키텍처는 다른 말로 포트와 어댑터 아키텍처라고도 한다. 그만큼 헥사고날 아키텍처를 이해하기 위해선 포트와 어댑터가 무엇이고, 이들의 역할에 대해 이해할 필요가 있다.</p>
<h3 id="포트와-어댑터는-뭘까">포트와 어댑터는 뭘까?</h3>
<ul>
<li><strong>포트</strong><ul>
<li>포트는 외부 영역과 내부 영역 사이의 연결을 추상화한 인터페이스이다. 포트는 내부 영역 사용을 위해 노출된 인바운드 포트와, 내부 영역에서 외부 영역을 사용하기 위한 아웃 바운드 포트로 구분할 수 있다.</li>
</ul>
</li>
<li><strong>어댑터</strong><ul>
<li>어댑터는 외부 시스템과 상호작용 하는 역할을 한다. 어댑터도 마찬가지로 인바운드와 아웃바운드로 나뉜다. 예를 들면, HTTP 요청을 받는 인바운드 웹 어댑터, DB에 접근하여 데이터를 읽고 쓸 수 있는 아웃바운드 영속성 어댑터 등이 있다.</li>
</ul>
</li>
</ul>
<h3 id="흐름-예시">흐름 예시</h3>
<p>이렇게 설명만 봐서는 감이 안오니까, 송금 기능을 이용하는 과정을 예시로 들며 흐름을 설명하겠다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/3676eb33-c41a-4906-8c86-62d57b055e01" alt="image"></p>
<p>위 그림은 송금 기능을 이용하는 과정을 그림으로 그린 것이다. 예시 코드는 <a href="https://github.com/wikibook/clean-architecture">여기</a>를 참고하면 된다.</p>
<ol>
<li><p>사용자의 HTTP 요청을 인바운드 어댑터인 <code>AccountController</code>가 받고, 인바운드 포트인 <code>SendMoneyUseCase</code>를 사용하여 내부 로직에 접근한다.</p>
<pre><code class="language-java"> @WebAdapter
 @RestController
 @RequiredArgsConstructor
 class AccountController {
     private final SendMoneyUseCase sendMoneyUseCase;

     @PostMapping(path = &quot;/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}&quot;)
     void sendMoney(
             @PathVariable(&quot;sourceAccountId&quot;) Long sourceAccountId,
             @PathVariable(&quot;targetAccountId&quot;) Long targetAccountId,
             @PathVariable(&quot;amount&quot;) Long amount
     ) {
         sendMoneyUseCase.sendMoney(sourceAccountId, targetAccountId, amount);
     }
 }</code></pre>
<pre><code class="language-java"> public interface SendMoneyUseCase {
     boolean sendMoney(Long sourceAccountId, Long targetAccountId, Long amount);
 }</code></pre>
</li>
<li><p>인바운드 포트 <code>SendMoneyUseCase</code>를 구현한 <code>SendMoneyService</code>는 비즈니스 로직을 처리하고, 아웃바운드 포트인 <code>LoadAccountPort</code>를 통해 결과를 외부로 전달한다.</p>
<pre><code class="language-java"> @RequiredArgsConstructor
 @UseCase
 @Transactional
 public class SendMoneyService implements SendMoneyUseCase {
     private final LoadAccountPort loadAccountPort;

     @Override
     public boolean sendMoney(Long sourceId, Long targetId, Long amount) {
         LocalDateTime baseDate = LocalDateTime.now().minusDays(10);
         Account sourceAccount = loadAccountPort.loadAccount(sourceId, baseDate);
         Account targetAccount = loadAccountPort.loadAccount(targetId, baseDate);
         return true;
     }
 }</code></pre>
<pre><code class="language-java"> public interface LoadAccountPort {
     Account loadAccount(Long accountId, LocalDateTime baseDate);
 }</code></pre>
</li>
<li><p><code>LoadAccountPort</code>를 구현한 아웃바운드 어댑터인 <code>AccountPersistenceAdapter</code>는 받은 결과를 DB에 저장한다.</p>
<pre><code class="language-java"> @RequiredArgsConstructor
 @PersistenceAdapter
 class AccountPersistenceAdapter implements LoadAccountPort {
     private final SpringDataAccountRepository accountRepository;
     private final ActivityRepository activityRepository;
     private final AccountMapper accountMapper;

     @Override
     public Account loadAccount(Long accountId, LocalDateTime baselineDate) {
         AccountJpaEntity account = accountRepository.findById(accountId)

         List&lt;ActivityJpaEntity&gt; activities =
                 activityRepository.findByOwnerSince(accountId, baseDate);

         Long withdrawalBalance = orZero(
             activityRepository.getWithdrawalBalanceUntil(accountId, baseDate)
         );

         Long depositBalance = orZero(
             activityRepository.getDepositBalanceUntil(accountId, baseDate)
         );

         return accountMapper.mapToDomainEntity(
             account, activities, withdrawalBalance, depositBalance
         );
     }

     private Long orZero(Long value){
         return value == null ? 0L : value;
     }
 }</code></pre>
</li>
</ol>
<h2 id="3️⃣-계층형-아키텍처-vs-헥사고날-아키텍처">3️⃣ 계층형 아키텍처 VS 헥사고날 아키텍처</h2>
<p>이제 헥사고날 아키텍처가 무엇인지 알았을 것이다. 그렇다면, 기존에 많이 사용하던 계층형 아키텍처와 비교해서 어떤 점이 좋은걸까?</p>
<h3 id="계층형-아키텍처의-문제점">계층형 아키텍처의 문제점</h3>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/0c62e51b-348b-4e89-9eee-46d325c0c250" alt="image"></p>
<p>우선 계층형 아키텍처는 웹, 도메인, 영속성 계층으로 이루어진 아키텍처이다. 각 계층은 하위 계층에 의존한다. 이러한 계층형 아키텍처는 아래와 같은 문제점들이 있다.</p>
<ol>
<li><p><strong>데이터베이스 주도 설계를 유도한다.</strong></p>
<ul>
<li><p>웹 계층은 도메인 계층에, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스럽게 모든 것이 데이터베이스에 의존하기 쉬워진다. 즉, 데이터베이스의 구조를 먼저 생각하고, 이를 토대로 도메인 로직을 구현하는 것이다.</p>
</li>
<li><p>결국 아래와 같이 도메인 계층과 영속성 계층간에 강한 결합이 생기고, 이는 순수한 도메인 로직 구현을 방해한다.</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/716208c9-b108-48e3-83e9-211194d60071" alt="image"></p>
</li>
</ul>
</li>
<li><p><strong>외부 시스템의 변화에 취약하다.</strong></p>
<ul>
<li>해당 내용은 1번 내용과도 연결되는 내용이고, 직접 경험해본 내용이기도 하다. 퀴즈 서비스를 개발하는 중에 요구사항의 변경으로 기존에 <code>MySQL</code>을 사용하여 퀴즈 정보를 저장하던 것을 <code>Redis</code>를 사용하도록 리팩토링을 하게 되었다. 하지만 1번과 같이 도메인 로직이 영속성 관점과 섞여있는 상태에서 이를 리팩토링하는 것은 상당히 힘들었다.</li>
</ul>
</li>
<li><p><strong>테스트가 어렵다.</strong></p>
<ul>
<li>서비스의 기능이 확장됨에 따라 서비스는 영속성 계층에 많은 의존성을 갖게 되고, 웹 계층도 이러한 서비스들에 의존하게된다. 그렇기 때문에 웹 계층을 테스트하기 위해선 도메인 계층은 물론 영속성 계층도 Mocking해야 하므로 테스트가 어려워진다.</li>
</ul>
</li>
<li><p><strong>동시 작업이 어렵다.</strong></p>
<ul>
<li>만약 어플리케이션에 새로운 유스케이스를 추가한다고 생각해보자. 개발자가 총 3명이라고 했을 때 한 명은 웹 계층, 한 명은 도메인 계층, 한 명은 영속성 계층을 담당해서 하면 될까? 계층형 아키텍처에선 이는 불가능하다. 모든 계층이 영속성 계층에 의존하기 때문에 영속성 계층을 먼저 개발해야하고, 그 다음에는 도메인, 마지막으로 웹 계층을 만들어야한다.</li>
</ul>
</li>
</ol>
<h3 id="헥사고날-아키텍처와-비교---장점">헥사고날 아키텍처와 비교 - 장점</h3>
<p>그렇다면, 헥사고날 아키텍처는 계층형 아키텍처와 비교했을 때 어떤 장점이 있을까?</p>
<ol>
<li><p><strong>도메인 중심 설계가 쉬워진다.</strong></p>
<ul>
<li><p>계층형 아키텍처에서는 상위 계층이 하위 계층에 강한 결합이 있었다. 특히 영속성 계층과의 강한 결합으로 인해 다양한 문제가 발생했다.</p>
</li>
<li><p>하지만 헥사고날 아키텍처는 내부 영역이 외부 영역에 의해 영향을 받지 않는다.</p>
<pre><code class="language-java">  @RequiredArgsConstructor
  @UseCase
  @Transactional
  public class SendMoneyService implements SendMoneyUseCase {
      private final LoadAccountPort loadAccountPort;

      @Override
      public boolean sendMoney(Long sourceId, Long targetId, Long amount) {
          LocalDateTime baseDate = LocalDateTime.now().minusDays(10);
          Account sourceAccount = loadAccountPort.loadAccount(sourceId, baseDate);
          Account targetAccount = loadAccountPort.loadAccount(targetId, baseDate);
          return true;
      }
  }</code></pre>
<p>  위와 같은 <code>SendMoneyService</code> 입장에서는 외부에서 데이터를 어떻게 가져오는지 알 필요가 없다. 이로 인해 외부 시스템에 구애받지 않고 도메인 중심 설계를 할 수 있다.</p>
</li>
<li><p>도메인 중심 설계를 하게되면, 도메인 객체를 중심으로 비즈니스 로직이 짜여지므로 코드의 재사용성이 증가하고, 더 자연스러운 코드를 짤 수 있게된다.</p>
</li>
</ul>
</li>
<li><p><strong>외부 요소의 변경이 쉬워진다.</strong></p>
<ul>
<li>계층형 아키텍처에서는 도메인 계층과 영속성 계층간의 강한 결합으로 인해 외부 요소의 변경이 일어나면 대부분의 코드를 다시 작성해야했다.</li>
<li>하지만 헥사고날 아키텍처에서는 요구사항의 변경으로 외부 요소를 변경(ex: MySQL를 Redis로 변경)하는 경우 외부 영역의 어댑터만 바꿔주면 되기 때문에 외부 요소의 변경이 쉽다.</li>
</ul>
</li>
<li><p><strong>테스트가 쉬워진다.</strong></p>
<ul>
<li>헥사고날 아키텍처에서는 외부 요소와는 포트를 통해 연결되기 때문에, 비즈니스 로직 테스트 시 포트의 Mock 객체를 통해 쉽게 테스트를 진행할 수 있다.</li>
</ul>
</li>
<li><p><strong>동시 작업이 쉽다.</strong></p>
<ul>
<li>계층형 아키텍처에서는 하나의 유스케이스를 여러명의 개발자가 동시에 개발하는 것이 어려웠다.</li>
<li>하지만 헥사고날 아키텍처에서는 어댑터 부분, 도메인 부분으로 나눠서 여러 개발자가 동시에 작업이 가능하다.</li>
</ul>
</li>
</ol>
<h3 id="헥사고날-아키텍처와-비교---단점">헥사고날 아키텍처와 비교 - 단점</h3>
<p>그렇다면 헥사고날 아키텍처는 <code>은탄환</code>일까? 절대 아니다. 헥사고날 아키텍처는 아래와 같은 단점들이 있다.</p>
<ol>
<li><strong>코드가 많아진다.</strong><ul>
<li>내부 영역이 외부 영역과 완전히 분리되어야 하기 때문에 각 영역별로 추가적인 코드 작성이 필요해서 코드가 많아진다.</li>
<li>예시로 계층형은 도메인 객체와 ORM에 사용되는 엔티티의 구분이 없지만, 헥사고날은 ORM용 엔티티, 도메인 객체, 상호 변환을 위한 Mapper까지 필요하다.</li>
</ul>
</li>
<li><strong>진입 장벽이 높다.</strong><ul>
<li>아키텍처를 도입하기 위해서 포트, 어댑터 등 알아야할 개념이 추가로 생기고, 초반에 프로젝트를 구성하는 데에 시간이 오래 걸린다.</li>
</ul>
</li>
</ol>
<h2 id="4️⃣-결론">4️⃣ 결론</h2>
<p>항상 완벽한 아키텍처는 없다. 사실 처음 헥사고날 아키텍처를 들었을 때는 그냥 실속없이 이름만 멋진 개념인 줄 알았다. 하지만, 계층형 아키텍처로 프로젝트를 진행하면서 마주한 문제점들을 해결하기 위해 찾다가 헥사고날 아키텍처를 알게 되었다. 처음에는 반신반의 했지만, 책을 읽어보고 직접 프로젝트에 적용해보며 현재 상황에 잘 맞는 아키텍처라는 생각이 들게되었고, 결국 헥사고날 아키텍처로 리팩토링하게 되었다.</p>
<p>무조건 유행한다고 해보기 보다는 현재 진행하는 프로젝트의 성격과 잘 비교해서 적용하면 좋을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[IntelliJ 유용한 설정과 플러그인]]></title>
            <link>https://velog.io/@jeong_hun_hui/IntelliJ-%EC%9C%A0%EC%9A%A9%ED%95%9C-%EC%84%A4%EC%A0%95%EA%B3%BC-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8</link>
            <guid>https://velog.io/@jeong_hun_hui/IntelliJ-%EC%9C%A0%EC%9A%A9%ED%95%9C-%EC%84%A4%EC%A0%95%EA%B3%BC-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8</guid>
            <pubDate>Thu, 19 Oct 2023 11:10:12 GMT</pubDate>
            <description><![CDATA[<p>IntelliJ는 정말 강력한 IDE지만, 추가적인 설정과 플러그인을 이용하면 더더욱 강력해진다.</p>
<p>이번 글에서는 유용한 설정과 플러그인에 대해서 소개하겠다.</p>
<h2 id="유용한-설정">유용한 설정</h2>
<h3 id="1-auto-import">1. Auto Import</h3>
<p>코드를 작성하다 보면 다양한 패키지를 사용하게 된다. 하지만, 코드가 변경될 때 마다 import문을 삽입/삭제하는 것은 상당히 귀찮다. 이러한 귀찮음을 해결해주는 옵션이 Auto Import 옵션이다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/d47116e1-edf1-4e27-8658-873c6bd4c328/image.png" alt=""></p>
<p>Settings에서 Editor → General → Auto Import → Optimize imports on the fly 를 체크하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/c4669d5f-2fc5-4b6e-8434-47a6b0dc0806/image.png" alt=""></p>
<p>바로 위의 Insert imports on paste에서 코드를 붙혀넣기한 경우에 import문을 어떻게 삽입할지 선택할 수 있다.</p>
<ul>
<li><strong>Always</strong>: 자동 import</li>
<li><strong>Ask</strong>: 확인한 뒤 import</li>
<li><strong>Never</strong>: import 하지 않음</li>
</ul>
<h3 id="2-live-template-설정">2. Live Template 설정</h3>
<p>Java 코드를 작성하다 보면 <code>System.out.println()</code>나 <code>public static void main(String[] args) {}</code>같이 비슷한 형태의 코드를 작성해야할 일이 자주 있다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/06d08ad1-ef3c-4573-8010-6edd9a7ec350/image.png" alt=""></p>
<p>이럴 때 Live Template 설정을 해놓는다면 이렇게 자주 사용되는 형태의 코드를 위와 같이 줄임말(<code>sout</code>, <code>psvm</code> 등)을 통해 간단하게 작성할 수 있다.</p>
<p>설정 방법은 아래와 같다.</p>
<ol>
<li><p>Settings에서 Editor → Live Templates에서 Java의 하위 항목들을 전부 다중선택해준다. (가장 위에 있는 항목 선택 → shift를 누른 상태로 아래 방향키 누르기)</p>
<p> <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/159be233-f641-4d12-b4aa-db3238fa8095/image.png" alt=""></p>
</li>
<li><p>마우스 우클릭 후 Change context 클릭</p>
<p> <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/650aaa4a-dd65-464e-b7e0-77a988ab711e/image.png" alt=""></p>
</li>
<li><p>Java에 체크한 뒤 OK 클릭</p>
<p> <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/24156e49-4335-42eb-b581-de82ca228dc8/image.png" alt=""></p>
</li>
</ol>
<h3 id="3-자동-코드-포맷팅">3. 자동 코드 포맷팅</h3>
<p>개발 시 코드 스타일은 아주 중요한 요소이다. 하지만 정신없이 코딩하다보면 코드 스타일에 맞지 않는 코드를 짜는 경우가 많다. 이를 까먹지 않게 파일 저장 시 자동으로 해당 파일의 코드 전체를 코드 스타일에 맞게 코드를 포맷팅하는 방법이 있다.</p>
<p>설정 방법은 아래와 같다.</p>
<ol>
<li><p>XML형식의 코드 스타일 파일을 가져온다. 나는 가장 많이 사용되는 intellij-java-google-style.xml을 다운로드하겠다.</p>
<p> <a href="https://github.com/google/styleguide/blob/gh-pages/intellij-java-google-style.xml">https://github.com/google/styleguide/blob/gh-pages/intellij-java-google-style.xml</a></p>
</li>
<li><p>Settings에서 Editor → Code Style → Java에서 아래 그림의 1번 부분을 클릭하고, 2번 부분의 Import Scheme → IntelliJ IDEA code style XML 클릭</p>
<p> <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/f9abb49c-290b-4abc-bc0f-148537dbc9b3/image.png" alt=""></p>
</li>
<li><p>파일 선택창에서 아까 다운로드 받은 xml파일을 선택한다.</p>
</li>
<li><p>Tab size(탭의 공백 개수), Indent(들여쓰기의 공백 개수)등을 조절한다.</p>
<p> <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/9f652481-adc5-4ef8-b024-f262e89f5eaa/image.png" alt=""></p>
</li>
<li><p>Tools → Actions on Save 에서 Reformat code를 체크한다.</p>
<p> <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/8a7e51e9-fc61-408c-af7d-7a6801b1b897/image.png" alt=""></p>
</li>
</ol>
<p>이 과정까지 거치면 파일을 저장할 때 Java코드가 Google의 Java Style로 포맷팅된다.</p>
<p>저장하지 않더라도, <code>option + command + L</code> 단축키를 사용하면 해당 파일이 포맷팅된다.</p>
<h3 id="4-자동완성-시-대소문자-구분-없애기">4. 자동완성 시 대소문자 구분 없애기</h3>
<p>원래는 <code>String</code>을 입력하기 위해 <code>st..</code> 이런식으로 입력하면 String이 자동완성에 뜨지 않는다. 은근 이러한 경우가 많기 때문에 대소문자 구분을 하지 않도록 하는게 더 편하다.</p>
<p>설정 방법은 Settings에서 Editor → General → Code Completion 에서 Match case의 체크를 해제하면된다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/64528ca6-56fb-494d-989c-bb661599cb8f/image.png" alt=""></p>
<h2 id="유용한-플러그인">유용한 플러그인</h2>
<p>플러그인은 Settings(Preference)에서 Plugins에 들어가서 설치/관리할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/722597b4-bd1d-4e29-8f65-83a397263cf9/image.png" alt=""></p>
<h3 id="1-atom-material-icons">1. Atom Material Icons</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/1cbc71eb-5b09-4cd4-87bb-936d19d3aa8d/image.png" alt=""></p>
<p>메뉴 아이콘의 가독성을 높혀준다. </p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/8fd7caf5-6058-4a92-9570-dd50f9978551/image.png" alt=""></p>
<p>왼쪽이 적용전이고, 오른쪽이 적용한 모습이다.</p>
<h3 id="2-rainbow-brackets">2. Rainbow Brackets</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/d92d0f27-7ef5-4f5a-9d89-84ffdb3b1c49/image.png" alt=""></p>
<p>서로 짝이되는 괄호나 중괄호에 색을 입혀서 괄호를 구분하기 편해진다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/c161971f-0b61-4b43-9058-94278a6c3285/image.png" alt=""></p>
<p>왼쪽이 적용전이고, 오른쪽이 적용한 모습이다.</p>
<h3 id="3-gittoolbox">3. GitToolBox</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/9356a29f-ada5-4b7a-83ec-a0b5fea1f8da/image.png" alt=""></p>
<p>Git사용시 관련된 내용의 가독성을 높혀준다.</p>
<p>해당 코드를 누가 Commit했는지, 현재의 브랜치는 어디인지 등의 정보를 보여준다.</p>
<h3 id="4-key-promoter-x">4. Key Promoter X</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/4331df3e-fc94-44c3-bc13-cff5fa9dee35/image.png" alt=""></p>
<p>IntelliJ에는 수많은 단축키가 있다. Key Promoter X는 내가 만약 단축키로 할 수 있는 동작을 단축키 없이 했다면, 이 단축키로 해당 동작을 할 수 있었다고 알려준다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/98ec6252-08fa-4c19-b589-09d9adb660d7/image.png" alt=""></p>
<p>위와 같은 경우는 붙혀넣기를 단축키를 사용하지 않았을 때 표시된 팝업이다. 초반에는 단축키를 익히기 힘든데, 단축키를 익히는 과정에 해당 플러그인이 큰 도움이 된다.</p>
<h3 id="5-codemetrics">5. CodeMetrics</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/23a35458-a410-4fe3-b3f1-d83c7ac052d4/image.png" alt=""></p>
<p>우리는 항상 메소드나 클래스의 복잡도를 생각하며 코드를 짜야한다. 한 클래스나 메소드가 너무 많은 기능을 하고있다면, 점점 관리하기 힘들어지기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/dcee7846-4c14-4e76-849a-28d85205e8a3/image.png" alt=""></p>
<p>CodeMetrics 플러그인은 위와 같이 메소드와 클래스 단위별로 복잡도에 대한 점수를 매겨준다. 최대한 5 이하를 유지하려고 하며 코드를 짜면 더 좋은 코드가 나올것이다.</p>
<h3 id="6-tabnine">6. Tabnine</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/ff1ef164-ec6e-4ac4-a35e-e0ba3c039ebd/image.png" alt=""></p>
<p>아래와 같이 코드를 치는 중에 AI를 활용해서 코드를 추천해준다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/3b3fb383-689a-40c9-ba08-6a819198bd3d/image.png" alt=""></p>
<p>가끔 소름돋을정도로 내가 치려던 코드를 추천해준다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[여기서 놀자] 프로젝트 개요]]></title>
            <link>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B0%9C%EC%9A%94</link>
            <guid>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B0%9C%EC%9A%94</guid>
            <pubDate>Sun, 15 Oct 2023 06:44:39 GMT</pubDate>
            <description><![CDATA[<h3 id="프로젝트-개요">프로젝트 개요</h3>
<p><strong>이름</strong>: 여기서 놀자!
<strong>설명</strong>: 호텔을 검색하고 예약할 수 있는 웹 서비스
<strong>목적</strong>: 대규모의 데이터를 처리하고, 성능을 개선하는 경험을 하는 것</p>
<h3 id="진행-과정"><strong>진행 과정</strong></h3>
<ol>
<li>요구사항에 맞는 쿼리를 작성</li>
<li>쿼리의 실행 계획을 분석하여 문제점을 파악한 뒤 문제에 맞는 적절한 기술을 사용하여 성능을 개선</li>
<li>부하테스트를 통하여 목표 성능에 도달했는지 확인</li>
<li>목표 달성 실패시 추가 개선 진행, 목표 달성 시 목표 상향</li>
<li>설계 및 구현 과정에서 책에서 배운 내용을 적용 및 진행 과정을 글로 기록</li>
</ol>
<h3 id="프로젝트-성과">프로젝트 성과</h3>
<ul>
<li><p>레코드 <strong>500만건 이상</strong>, 용량 약 <strong>1GB</strong>의 대규모 데이터가 들어가있는 MySQL 환경에서 예약이 가능한 호텔을 검색하는 쿼리 작성</p>
</li>
<li><p><strong>Explain 쿼리</strong>를 이용해 쿼리의 <strong>실행 계획</strong>을 파악한 뒤 <strong>문제 상황에 맞는 적절한 기술</strong>을 도입하여 <strong>성능을 개선</strong></p>
<ul>
<li><code>index merge</code> 실행 계획을 확인하여 <strong>카디널리티가 높은 컬럼</strong>을 선행 컬럼으로한 <strong>복합 인덱스를 적용</strong>, 여러 방식의 페이징 기법을 비교하여 요구사항에 가장 잘 맞는 <strong>커서 방식 페이징을 적용</strong></li>
</ul>
</li>
</ul>
<pre><code>     - [**관련 글 링크(복합 인덱스와 페이징을 적용하여 쿼리 성능 향상시키기)**](https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EB%B3%B5%ED%95%A9-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EC%99%80-%ED%8E%98%EC%9D%B4%EC%A7%95%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%98%EC%97%AC-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0)

- 변경이 거의 없는 `categories`, `detail_regions`의 데이터를 `hotels`에 추가하는 **역정규화** 작업을 통해 불필요한 `JOIN`연산을 제거

    - [**관련 글 링크(역정규화로 쿼리 성능 향상시키기)**](https://velog.io/@jeong_hun_hui/여기서-놀자-역정규화로-쿼리-성능-향상시키기)

- `WITH`문을 사용하여 **쿼리의 가독성 향상** 및 **필터링 조건을 먼저 적용**하여 **검색 대상을 감소**시키는 방식으로 쿼리를 수정

    - [**관련 글 링크(예약 가능한 호텔만 노출하기)**](https://velog.io/@jeong_hun_hui/여기서-놀자-예약-가능한-호텔만-노출하기)</code></pre><ul>
<li><p>실행 시간이 <strong>튜닝 이전 39498ms</strong> → <strong>튜닝 후 22.7ms</strong>로 감소하여 <strong>성능이 99.942% 향상</strong></p>
<ul>
<li><p>튜닝 이전 Explain Analyze</p>
<pre><code class="language-sql">  -&gt; Group aggregate: min(ar.price)  (cost=12787 rows=0) (actual time=30141..39498 rows=242 loops=1)
      -&gt; Nested loop inner join  (cost=12787 rows=0) (actual time=30139..39494 rows=306 loops=1)
          -&gt; Filter: (h.detail_region_id = 1)  (cost=10474 rows=925) (actual time=8.45..9359 rows=410 loops=1)
              -&gt; Index lookup on h using hotels_ibfk_2 (category_id=1)  (cost=10474 rows=66606) (actual time=3.39..9355 rows=33382 loops=1)
          -&gt; Index lookup on ar using &lt;auto_key0&gt; (hotel_id=h.id)  (cost=0.25..2.5 rows=10) (actual time=73.5..73.5 rows=0.746 loops=410)
              -&gt; Materialize  (cost=0..0 rows=0) (actual time=30130..30130 rows=129667 loops=1)
                  -&gt; Filter: (coalesce(max(rc.count),0) &lt;= r.count)  (actual time=29907..29946 rows=129667 loops=1)
                      -&gt; Table scan on &lt;temporary&gt;  (actual time=29906..29925 rows=131412 loops=1)
                          -&gt; Aggregate using temporary table  (actual time=29906..29906 rows=131412 loops=1)
                              -&gt; Nested loop left join  (cost=181917 rows=150937) (actual time=1.42..29596 rows=155575 loops=1)
                                  -&gt; Filter: ((r.max_people_count &gt;= 3) and (r.price &gt;= 50000) and (r.price &lt;= 200000))  (cost=63424 rows=22874) (actual time=1.01..2104 rows=131412 loops=1)
                                      -&gt; Table scan on r  (cost=63424 rows=617789) (actual time=0.999..1991 rows=619984 loops=1)
                                  -&gt; Filter: (rc.stay_date between &#39;2023-06-22&#39; and &#39;2023-06-25&#39;)  (cost=4.52 rows=6.6) (actual time=0.206..0.209 rows=0.383 loops=131412)
                                      -&gt; Index lookup on rc using reservation_check_ibfk_1 (room_id=r.id)  (cost=4.52 rows=6.6) (actual time=0.179..0.205 rows=5.85 loops=131412)</code></pre>
</li>
</ul>
</li>
</ul>
<pre><code>- 튜닝 후 Explain Analyze

    ```sql
    -&gt; Limit: 10 row(s)  (actual time=22.7..22.7 rows=10 loops=1)
        -&gt; Sort: h.id, limit input to 10 row(s) per chunk  (actual time=22.7..22.7 rows=10 loops=1)
            -&gt; Table scan on &lt;temporary&gt;  (actual time=22.6..22.6 rows=123 loops=1)
                -&gt; Aggregate using temporary table  (actual time=22.6..22.6 rows=123 loops=1)
                    -&gt; Nested loop inner join  (cost=85.4 rows=0) (actual time=20.3..21.1 rows=155 loops=1)
                        -&gt; Table scan on ar  (cost=2.5..2.5 rows=0) (actual time=20.2..20.3 rows=155 loops=1)
                            -&gt; Materialize CTE available_rooms  (cost=0..0 rows=0) (actual time=20.2..20.2 rows=155 loops=1)
                                -&gt; Filter: (coalesce(max(rc.count),0) &lt;= r.count)  (actual time=20.1..20.2 rows=155 loops=1)
                                    -&gt; Table scan on &lt;temporary&gt;  (actual time=20.1..20.2 rows=157 loops=1)
                                        -&gt; Aggregate using temporary table  (actual time=20.1..20.1 rows=157 loops=1)
                                            -&gt; Nested loop left join  (cost=597 rows=149) (actual time=0.255..19.8 rows=187 loops=1)
                                                -&gt; Nested loop inner join  (cost=481 rows=22.5) (actual time=0.144..8.22 rows=157 loops=1)
                                                    -&gt; Filter: ((h.category_id = 1) and (h.detail_region_id = 1) and (h.id &gt; 100000))  (cost=40.8 rows=196) (actual time=0.0528..0.375 rows=196 loops=1)
                                                        -&gt; Covering index range scan on h using detail_regions_categories_idx over (detail_region_id = 1 AND category_id = 1 AND 100000 &lt; id)  (cost=40.8 rows=196) (actual time=0.0499..0.295 rows=196 loops=1)
                                                    -&gt; Filter: ((r.max_people_count &gt;= 3) and (r.price &gt;= 50000) and (r.price &lt;= 200000))  (cost=1.94 rows=0.115) (actual time=0.0386..0.0397 rows=0.801 loops=196)
                                                        -&gt; Index lookup on r using hotel_id (hotel_id=h.id)  (cost=1.94 rows=3.11) (actual time=0.037..0.0388 rows=3.12 loops=196)
                                                -&gt; Filter: (rc.stay_date between &#39;2023-06-22&#39; and &#39;2023-06-25&#39;)  (cost=4.5 rows=6.6) (actual time=0.072..0.0731 rows=0.382 loops=157)
                                                    -&gt; Index lookup on rc using reservation_check_ibfk_1 (room_id=r.id)  (cost=4.5 rows=6.6) (actual time=0.0613..0.0688 rows=5.86 loops=157)
                        -&gt; Single-row index lookup on h using PRIMARY (id=ar.hotel_id)  (cost=0.561 rows=1) (actual time=0.00508..0.00511 rows=1 loops=155)
    ```</code></pre><ul>
<li><p>프로시저를 활용하여 실제 데이터와 유사한 더미 데이터를 삽입</p>
<ul>
<li><p><a href="https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EB%8D%94%EB%AF%B8-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%84%A3%EA%B8%B0"><strong>관련 글 링크(더미 데이터 넣기)</strong></a></p>
</li>
<li><p>호텔 테이블의 데이터 예시</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/f980ba8b-6f84-4bb1-acb8-dc4dd98e9132/image.png" alt=""></p>
</li>
</ul>
</li>
<li><p>튜닝 이전 쿼리</p>
<pre><code class="language-sql">  SELECT h.id,
         h.name        AS hotel_name,
         MIN(ar.price) AS min_price,
         h.rating,
         h.address,
         dr.name,
         c.name
  FROM hotels h IGNORE INDEX (detail_regions_categories_idx)
      JOIN (
          SELECT r.id,
                 r.hotel_id,
                 r.price
          FROM rooms r
              LEFT OUTER JOIN reservation_check rc
                  ON r.id = rc.room_id
                      AND rc.stay_date BETWEEN {숙박_시작_날짜} AND {숙박_종료_날짜}
          WHERE r.max_people_count &gt;= {숙박_인원}
            AND r.price &gt;= {최저_가격}
            AND r.price &lt;= {최대_가격}
          GROUP BY r.id, r.count
          HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count
      ) AS ar
          ON h.id = ar.hotel_id
      JOIN categories AS c
          ON c.id = h.category_id
      JOIN detail_regions AS dr
          ON h.detail_region_id = dr.id
  WHERE h.detail_region_id = {상세_지역_id}
    AND h.category_id = {카테고리_id}
  GROUP BY h.id
  ORDER BY h.id;</code></pre>
</li>
<li><p>튜닝 이후 쿼리</p>
<pre><code class="language-sql">  WITH available_rooms AS (
      SELECT r.id,
             r.hotel_id,
             r.price
      FROM rooms r
          JOIN hotels h
              ON r.hotel_id = h.id
          LEFT OUTER JOIN reservation_check rc
              ON r.id = rc.room_id
                  AND rc.stay_date BETWEEN {숙박_시작_날짜} AND {숙박_종료_날짜}
      WHERE r.max_people_count &gt;= {숙박_인원}
          AND r.price &gt;= {최저_가격}
          AND r.price &lt;= {최대_가격}
          AND h.detail_region_id = {상세_지역_id}
          AND h.category_id = {카테고리_id}
          AND h.id &gt; {직전에_조회한_호텔의_id}
      GROUP BY r.id, r.count
      HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count
  )
  SELECT h.id,
         h.name        AS hotel_name,
         MIN(ar.price) AS min_price,
         h.rating,
         h.address,
         h.detail_region_name,
         h.category_name
  FROM hotels h
           JOIN available_rooms ar
                ON h.id = ar.hotel_id
  GROUP BY h.id
  ORDER BY h.id
  LIMIT 10;</code></pre>
</li>
</ul>
<h3 id="프로젝트-진행-배경">프로젝트 진행 배경</h3>
<p>“내가 백엔드 개발자로 취업을 하려면 무엇을 해야할까?”라는 고민을 최근에 많이 하였다. 내가 내린 결론은 기업이 요구하는 사항을 파악하고 이에 맞는 공부를 해야한다는 것이다. 나는 여러 기업들의 채용공고들을 확인하던 중 “<strong>성능 개선</strong>”, “<strong>대규모 데이터 처리</strong>”와 같은 키워드를 볼 수 있었다.</p>
<p>주니어 개발자가 성능 개선이나 대규모 데이터 처리 경험을 할 수 있는 방법이 무엇이 있을까 고민하다가, DB에 <strong>더미 데이터</strong>를 넣어서 <strong>대규모의 데이터를 처리</strong>하는 경험을 해보고, <strong>쿼리 튜닝</strong>을 통해 저하되는 개선해보며 비슷한 경험을 할 수 있지 않을까? 라는 생각이 들었다.</p>
<p>그리하여 “여기서 놀자”라는 사이드 프로젝트를 진행하기로 계획하였고, 프로젝트를 진행하며 경험한 내용을 글로 정리하려고 한다.</p>
<h3 id="기능-요구사항---호텔-검색">기능 요구사항 - 호텔 검색</h3>
<blockquote>
<p>지역, 카테고리, 숙박 날짜, 숙박 인원 등을 통해 조건에 맞는 호텔을 검색하는 기능</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/07f356d7-34af-47d7-acfd-73eb7399bc17/image.png" alt=""></p>
<p><strong>[세부사항]</strong></p>
<ul>
<li>카테고리, 상세 지역 등으로 필터링이 가능하다.</li>
<li>페이징을 지원한다.</li>
<li>호텔 이름, 평점, 가격, 주소, 지역, 카테고리 등 필요한 정보를 보여준다.</li>
<li>지역으로 필터링이 가능하다.</li>
<li>설정한 인원이 묵을 수 있는 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>각 호텔에서 예약 가능한 가장 저렴한 객실의 가격을 표시한다.</li>
<li>지정한 날짜에 예약이 가능한 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>가격 범위를 지정하여 검색이 가능하다.</li>
</ul>
<h3 id="mysql-관련-정리글">MySQL 관련 정리글</h3>
<blockquote>
<p>💡 해당 프로젝트를 진행하기 전 MySQL에 대해 공부한 내용을 정리한 글 목록입니다.</p>
</blockquote>
<ul>
<li><strong><a href="https://velog.io/@jeong_hun_hui/Index%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%99%9C-%EC%93%B0%EB%8A%94%EA%B1%B8%EA%B9%8C">인덱스란 무엇이고, 왜 쓰는걸까?</a></strong></li>
<li><strong><a href="https://velog.io/@jeong_hun_hui/DB-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A1%9C-B-tree%EA%B0%80-%EC%82%AC%EC%9A%A9%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0">인덱스로 왜 B tree가 사용될까?</a></strong></li>
<li><strong><a href="https://velog.io/@jeong_hun_hui/MySQL%EC%97%90%EC%84%9C-Explain%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-%EB%B6%84%EC%84%9D%ED%95%98%EA%B8%B0">MySQL에서 Explain을 이용하여 실행 계획 분석하기</a></strong></li>
<li><strong><a href="https://velog.io/@jeong_hun_hui/series/Real-MySQL">Real MySQL 정리글</a></strong></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] MySQL에서 Explain을 이용하여 실행 계획 분석하기]]></title>
            <link>https://velog.io/@jeong_hun_hui/MySQL%EC%97%90%EC%84%9C-Explain%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-%EB%B6%84%EC%84%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_hun_hui/MySQL%EC%97%90%EC%84%9C-Explain%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D-%EB%B6%84%EC%84%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 14 Oct 2023 13:10:28 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 이 글의 내용은 MySQL 8.0 이상 + InnoDB 스토리지 엔진 환경을 기준으로 작성되었습니다.</p>
</blockquote>
<h3 id="실행-계획이란">실행 계획이란?</h3>
<p>동일한 쿼리여도 현재 DBMS의 상황에 따라서 가장 효율적으로 쿼리를 실행할 수 있는 방법은 다양하다.</p>
<p>DBMS에서는 옵티마이저가 통계 정보를 참고하여 다양한 방법 중 가장 효율적인 방법을 찾아서 쿼리를 실행하는데, 이렇게 쿼리를 실행하는 방법을 실행 계획이라고 한다.</p>
<h3 id="통계-정보">통계 정보</h3>
<ul>
<li><p><strong>MySQL 서버의 통계 정보</strong></p>
<ul>
<li>통계 정보에는 인덱스가 가진 유니크한 값의 수, 테이블의 전체 레코드 건수 등의 정보가 저장되어 있다.</li>
<li>통계 정보는 아래와 같은 이벤트들이 발생하면 갱신된다.<ul>
<li>테이블이 새로 오픈되는 경우</li>
<li>테이블의 레코드가 대량으로 변경되는 경우</li>
<li><code>ANALYZE TABLE</code> 명령이 실행되는 경우</li>
<li><code>SHOW TABLE STATUS</code> 명령이나 <code>SHOW INDEX FROM</code> 명령이 실행되는 경우</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>히스토그램</strong></p>
<p>  기존 통계 정보만으로는 최적의 실행 계획을 수립하기에는 많이 부족했다.</p>
<p>  MySQL 8.0부터는 컬럼의 데이터 분포도를 참조할 수 있는 히스토그램을 활용할 수 있다.</p>
</li>
</ul>
<h3 id="쿼리의-실행-시간-확인---explain-analyze">쿼리의 실행 시간 확인 - EXPLAIN ANALYZE</h3>
<p><code>EXPLAIN ANALYZE</code>명령으로 쿼리의 실행 계획과 단계별 소요된 시간 정보를 확인할 수 있다.</p>
<p><code>EXPLAIN ANALYZE</code>명령은 기본적으로 TREE 포맷으로 출력되며 아래 규칙으로 호출 순서를 파악할 수 있다.</p>
<ul>
<li>들여쓰기가 같은 레벨에서는 상단에 위치한 라인이 먼저 실행된다.</li>
<li>들여쓰기가 다른 레벨에서는 가장 안쪽에 위치한 라인이 먼저 실행된다.</li>
</ul>
<pre><code class="language-sql">-&gt; Index lookup on s using PRIMARY (emp_no=e.emp_no) (cost=0.98 rows=10)
         (actual time=0.007..0.009 rows=10 loops=233)</code></pre>
<p><code>EXPLAIN ANALYZE</code>명령으로 확인한 실행 계획 중 일부를 살펴보며 의미를 알아보자.</p>
<ul>
<li><p><code>Index lookup on s using PRIMARY (emp_no=e.emp_no)</code></p>
<ul>
<li><code>s</code>테이블의 <code>emp_no</code>와 <code>e</code>테이블의 <code>emp_no</code>가 일치하는 레코드를 PK를 이용해 검색한다는 뜻이다.</li>
</ul>
</li>
<li><p><code>actual time=0.007 ..0.009</code></p>
<ul>
<li><code>e</code>테이블에서 읽은 <code>emp_no</code>을 기준으로 <code>s</code>테이블에서 검색하는 데 걸린 시간(밀리초)을 뜻한다.</li>
<li>두 숫자는 각각 첫 레코드를 찾는 데 걸린 평균 시간, 마지막 레코드를 찾는데 걸린 평균 시간을 의미한다.</li>
</ul>
</li>
<li><p><code>rows=10</code></p>
<ul>
<li><code>e</code>테이블에서 읽은 <code>emp_no</code>에 일치하는 <code>s</code>테이블의 평균 레코드 건수를 의미한다.</li>
</ul>
</li>
<li><p><code>loops=233</code></p>
<ul>
<li><p><code>e</code>테이블에서 읽은 <code>emp_no</code>를 이용해 <code>s</code>테이블의 레코드를 찾는 작업이 반복된 횟수를 의미한다.</p>
<p>  → <code>e</code>테이블에서 읽은 <code>emp_no</code>의 개수가 233개임을 의미한다.</p>
</li>
</ul>
</li>
</ul>
<p>→ <code>s</code>테이블에서 <code>emp_no</code>가 일치하는 레코드를 찾는 작업을 233번 반복했는데, <code>s</code>테이블에서 첫 레코드를 찾는데 평균 0.007ms가, 10개의 레코드를 모두 찾는 데 평균 0.009ms가 걸렸다는 뜻이다.</p>
<h2 id="실행-계획-확인---explain">실행 계획 확인 - EXPLAIN</h2>
<p>MySQL의 실행 계획은 <code>EXPLAIN</code>명령으로 확인할 수 있다.</p>
<p><code>EXPLAIN</code>명령을 실행하면 12개의 컬럼과 값들이 테이블 형식으로 나온다.</p>
<p>각 컬럼이 나타내는 정보에 대해서 알아보자.</p>
<h3 id="id-컬럼">id 컬럼</h3>
<p>실행 계획의 <code>id</code> 컬럼은 단위 <code>SELECT</code>쿼리별로 부여되는 식별자 값이다.</p>
<p>만약 한 <code>SELECT</code>에서 여러 테이블을 조인하면 조인 테이블 수 만큼 레코드가 생성되지만, 같은 <code>id</code>가 부여된다.</p>
<p><code>id</code>가 큰 <code>SELECT</code>쿼리 부터 실행되고, <code>id</code>가 같은 레코드들은 레코드 순서대로 조인이 실행된다.</p>
<h3 id="select_type-컬럼"><strong>select_type 컬럼</strong></h3>
<p>각 단위 <code>SELECT</code>쿼리가 어떤 타입의 쿼리인지 표시되는 컬럼이다.</p>
<p>여러 타입이 있지만, 그 중 몇 가지만 설명하겠다.</p>
<ul>
<li><strong>SIMPLE</strong><ul>
<li><code>UNION</code>이나 서브쿼리를 사용하지 않는 단순한 <code>SELECT</code>쿼리의 경우 <code>select_type</code>이 <code>SIMPLE</code>이다.</li>
<li><code>SIMPLE</code> 쿼리는 하나만 존재하고, 일반적으로 제일 바깥 <code>SELECT</code> 쿼리이다.</li>
</ul>
</li>
<li><strong>PRIMARY</strong><ul>
<li><code>UNION</code>이나 서브쿼리를 사용하는 <code>SELECT</code>쿼리의 가장 바깥 쿼리는 <code>select_type</code>이 <code>PRIMARY</code>다.</li>
</ul>
</li>
<li><strong>DERIVED</strong><ul>
<li><code>DERIVED</code>는 단위 <code>SELECT</code>쿼리의 결과로 메모리나 디스크에 임시 테이블을 생성하는 것을 의미한다.</li>
</ul>
</li>
</ul>
<h3 id="table-컬럼"><strong>table 컬럼</strong></h3>
<p>실행 계획은 테이블 기준으로 표시되고, <code>table</code>컬럼에는 실행 계획의 테이블 이름이 들어간다.</p>
<p><code>id</code>, <code>select_type</code>, <code>table</code> 컬럼을 통해 아래 실행 계획을 간단하게 살펴보자.</p>
<table>
<thead>
<tr>
<th>id</th>
<th>select_type</th>
<th>table</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>PRIMARY</td>
<td><derived2></td>
</tr>
<tr>
<td>1</td>
<td>PRIMARY</td>
<td>h</td>
</tr>
<tr>
<td>2</td>
<td>DERIVED</td>
<td>h</td>
</tr>
<tr>
<td>2</td>
<td>DERIVED</td>
<td>r</td>
</tr>
<tr>
<td>2</td>
<td>DERIVED</td>
<td>res</td>
</tr>
</tbody></table>
<p>위 실행 계획을 예시로 들면 총 2번의 <code>SELECT</code>쿼리가 실행되었고, <code>id</code>가 2인 <code>SELECT</code>쿼리는 세 개의 테이블(<code>h</code>, <code>r</code>, <code>res</code>)이 조인되었고, <code>id</code>가 2인 <code>SELECT</code>쿼리는 두 개의 테이블(<code>&lt;derived2&gt;</code>, <code>h</code>)이 조인되었다.</p>
<p>이때, <code>id</code>가 2인 쿼리의 <code>select_type</code>은 <code>DERIVED</code>인데, 이는 2번 쿼리의 결과로 임시 테이블이 생성된다는 것을 뜻하고, 1번 쿼리의 <code>table</code> 컬럼의 <code>&lt;derived2&gt;</code>는 2번 쿼리에서 생성된 임시 테이블을 뜻한다.</p>
<h3 id="partitions-컬럼"><strong>partitions 컬럼</strong></h3>
<p><code>partitions</code>컬럼은 해당 쿼리가 어느 파티션에 접근했는지를 알려준다.</p>
<h3 id="type-컬럼"><strong>type 컬럼</strong></h3>
<p><code>type</code> 컬럼은 각 테이블의 접근 방법이라고 생각하면 된다.</p>
<p>총 12가지 방법이 있으며, <code>ALL</code>을 제외한 나머지 타입은 모두 인덱스를 사용한다.</p>
<p>12가지 방법 중 중요하다고 생각하는 몇 가지만 설명하겠다.</p>
<ul>
<li><p><strong>const</strong></p>
<ul>
<li>PK나 유니크 키 컬럼을 이용하는 <code>WHERE</code>절을 가지고 있고, 1건만 반환하는 방식의 쿼리를 말한다.</li>
<li><code>type</code>이 <code>const</code>인 실행 계획은 옵티마이저가 쿼리를 최적하며 먼저 실행해서 통째로 상수화한다.</li>
<li>예를 들어, <code>SELECT name FROM user WHERE id=1</code> 이런 서브쿼리가 있다면 이를 통째로 <code>‘name1&#39;</code>으로 상수화 하는 것이다.</li>
</ul>
</li>
<li><p><strong>eq_ref</strong></p>
<ul>
<li><p>조인에서 처음 읽은 테이블의 칼럼값을 두번째 테이블의 PK나 유니크키 칼럼의 검색 조건에 사용할 때의 접근 방법을 <code>eq_ref</code>라고 한다.</p>
</li>
<li><p>아래 쿼리로 예로 들면 <code>rooms</code>, <code>hotels</code>와 가 조인하고, <code>h.id</code>는 PK이기 때문에 <code>r.hotel_id=h.id</code>를 만족하는 <code>hotels</code>의 레코드가 단 하나이므로 <code>eq_ref</code>접근 방법이다.</p>
<pre><code class="language-sql">  SELECT r.id, h.id FROM rooms r JOIN hotels h ON r.hotel_id = h.id;</code></pre>
</li>
<li><p>처음 읽는 테이블의 한 행마다 1건의 레코드만 검색하면 되므로 성능이 뛰어난 접근 방법이다.</p>
</li>
</ul>
</li>
<li><p><strong>ref</strong></p>
<ul>
<li><code>ref</code> 접근 방법은 인덱스의 종류와 상관없이 동등 조건으로 검색할 때 사용된다.</li>
<li>레코드가 반드시 1건이란 보장이 없으므로 <code>const</code>나 <code>eq_ref</code>보다 느리지만 매우 빠른 조회 방법이다.</li>
</ul>
</li>
<li><p><strong>range</strong></p>
<ul>
<li>인덱스 레인지 스캔 형태의 접근 방법으로, 나쁘지 않은 접근 방법이다.</li>
</ul>
</li>
<li><p><strong>index_merge</strong></p>
<ul>
<li><p>여러개의 인덱스를 이용해 검색 결과를 만들어낸 뒤 그 결과를 병합해서 처리하는 방식이다.</p>
</li>
<li><p>아래 쿼리로 예로 들면 <code>detail_region_idx</code>를 이용해서 <code>detail_region_id=1</code>인 레코드들과, <code>category_id_idx</code>를 이용해서 <code>category_id=1</code>인 레코드들을 가져와서 교집합 연산을 수행한다.</p>
<pre><code class="language-sql">  // detail_region_idx(detail_region_id), category_id_idx(category_id)
  SELECT * FROM hotels h WHERE detail_region_id=1 AND category_id=1</code></pre>
</li>
<li><p>이 실행 계획은 여러 인덱스를 읽어야하고, 두 결과를 가지고 교집합, 합집합, 중복 제거와 같은 부가적인 작업이 더 필요하기 때문에 그렇게 효율적이지 않다.</p>
</li>
<li><p><code>index_merge</code> 접근 방법이 이용되면 Extra 컬럼에 추가적인 내용이 표시된다.</p>
<ul>
<li><code>Using union</code>: 합집합 / <code>Using sort_union</code>: 정렬 후 합집합 / <code>Using intersect</code>: 교집합</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>index</strong></p>
<ul>
<li>인덱스 풀 스캔을 의미하는 접근 방법이다.</li>
<li>비효율적인 방법이다.</li>
</ul>
</li>
<li><p><strong>ALL</strong></p>
<ul>
<li>풀 테이블 스캔을 의미하는 접근 방법이다.</li>
<li>비효율적인 방법이다.</li>
</ul>
</li>
</ul>
<h3 id="possible_keys-컬럼"><strong>possible_keys 컬럼</strong></h3>
<p>옵티마이저가 사용을 고려했던 인덱스의 목록들을 담고있는 컬럼이다.</p>
<p>즉, 사용되지 않은 인덱스들이 들어있고, 그냥 무시해도 되는 컬럼이다.</p>
<h3 id="key-컬럼"><strong>key 컬럼</strong></h3>
<p>옵티마이저가 최종으로 선택한 인덱스를 담고있는 컬럼이다.</p>
<h3 id="key_len-컬럼"><strong>key_len 컬럼</strong></h3>
<p><code>key_len</code> 컬럼은 ****쿼리를 처리하기 위해 인덱스에서 몇 바이트 까지 썼는지를 의미한다.</p>
<p>예를 들어 크기가 8바이트인 두 컬럼으로 구성된 복합 인덱스를 사용했을 때 <code>key_len</code>이 8이면 복합 인덱스의 선행 컬럼만 사용했다는 것을 의미하고 <code>key_len</code>이 16이면 두 컬럼을 다 사용했다는 것을 의미한다.</p>
<h3 id="ref-컬럼"><strong>ref 컬럼</strong></h3>
<p>접근 방법이 <code>ref</code>면 참조 조건으로 어떤 값이 제공됐는지 보여준다.</p>
<p>상숫값을 지정했다면 <code>const</code>, 다른 테이블의 컬럼 값이면 그 테이블이름과 컬럼이름이 표시된다.</p>
<p><code>ref</code> 컬럼의 값이 <code>func</code>면 값에 연산을 거쳐서 참조했다는 것을 의미한다.</p>
<h3 id="rows-컬럼"><strong>rows 컬럼</strong></h3>
<p>rows 컬럼은 인덱스를 사용하는 조건에만 일치하는 레코드 건수를 예측한 값이다.</p>
<h3 id="filtered-컬럼"><strong>filtered 컬럼</strong></h3>
<p>fintered 컬럼은 인덱스를 사용한 조건으로 걸러진 레코드들 중 인덱스를 사용하지 못하는 조건으로 인해 필터링되고 남은 레코드의 비율을 의미한다.</p>
<p>즉, <code>rows</code>가 233이고, <code>filtered</code>가 16.03이면 결과 레코드 건수는 233 * 0.1603 = 37이 된다.</p>
<h3 id="extra-컬럼"><strong>Extra 컬럼</strong></h3>
<p>쿼리의 실행 계획에서 성능에 관련된 중요한 내용이 Extra 컬럼에 자주 표시된다.</p>
<p>정말 많은 내용이 표시될 수 있지만, 그 중 자주 나타나고 중요한 내용만 설명하겠다.</p>
<ul>
<li><p><strong>Using where</strong></p>
<ul>
<li>MySQL 엔진 레이어에서 필터링 작업을 처리한 경우 표시된다.</li>
</ul>
</li>
<li><p><strong>Using index(커버링 인덱스)</strong></p>
<ul>
<li><p>인덱스만 읽어서 쿼리를 모두 처리할 수 있을 때(커버링 인덱스) 표시된다.</p>
<p>인덱스로만 쿼리를 처리할 수 있으면 디스크 접근이 필요 없어지므로 성능이 향상된다.</p>
</li>
</ul>
</li>
<li><p><strong>Using temorary</strong></p>
<ul>
<li>쿼리 실행 시 임시 테이블이 생성되면 표시된다. (표시 안되도 생성되는 경우도 있음)</li>
</ul>
</li>
<li><p><strong>Using filesort</strong></p>
<ul>
<li><code>ORDER BY</code>처리가 인덱스를 사용하지 못할 때 표시된다.</li>
<li><code>Using filesort</code>가 표시되는 쿼리는 많은 부하를 일으키므로 튜닝이 필요하다.</li>
</ul>
</li>
<li><p><strong>Using union, sort_union, intersect</strong></p>
<p>  <code>index_merge</code>접근 방법으로 실행되는 경우 어떤 방식의 <code>index_merge</code>인지 알려주기 위해 표시된다.</p>
</li>
<li><p><strong>Using index for group-by</strong></p>
<p>  인덱스를 사용하여 <code>GROUP BY</code>처리를 수행하면 별도의 정렬 작업이 필요 없어지고, 인덱스의 필요한 부분만 읽으면 되므로 성능이 향상되는데, 이 때 <code>Using index for group-by</code>메시지가 표시된다.</p>
<p>  인덱스를 이용하여 <code>GROUP BY</code>를 처리할 수 있더라도 <code>AVG()</code>, <code>SUM()</code> 처럼 조회하려는 값이 모든 인덱스를 다 읽어야 할 경우 루스 인덱스 스캔이 불가능하다.</p>
<p>  이 경우에는 <code>Using index for group-by</code>메시지가 표시되지 않는다.</p>
<p>  참고로, 루스 인덱스 스캔은 대량의 레코드를 <code>GROUP BY</code> 하는 경우엔 성능 향상효과가 있지만 레코드 건수가 적으면 루스 인덱스 스캔을 사용하지 않아도 빠르게 처리가 가능하므로 무조건 좋은 것은 아니다.</p>
</li>
<li><p><strong>Select tables optimized away</strong></p>
<p>  <code>MIN()</code> 또는 <code>MAX()</code>만 <code>SELECT</code> 절에 사용되거나 <code>GROUP BY</code>로 <code>MIN()</code>, <code>MAX()</code>를 조회하는 쿼리가 인덱스를 이용해 1건만 읽는 형태의 최적화가 적용되면 표시된다.</p>
</li>
</ul>
<h2 id="실행-계획-분석-예시">실행 계획 분석 예시</h2>
<p>이제 실제 쿼리의 실행 계획을 상세히 분석해보며 위 내용들을 정리해보자.</p>
<pre><code class="language-sql">WITH available_rooms AS (
    SELECT r.id,
           r.hotel_id,
           r.price
    FROM rooms r
        JOIN hotels h
            ON r.hotel_id = h.id
        LEFT OUTER JOIN reservation_check rc
            ON r.id = rc.room_id
                AND rc.stay_date BETWEEN &#39;2023-06-22&#39; AND &#39;2023-06-25&#39;
    WHERE r.max_people_count &gt;= 3
        AND r.price &gt;= 50000
        AND r.price &lt;= 200000
        AND h.detail_region_id = 1
        AND h.category_id
        AND h.id &gt; 100000
    GROUP BY r.id, r.count
    HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count
)
SELECT h.id,
       h.name        AS hotel_name,
       MIN(ar.price) AS min_price,
       h.rating,
       h.address,
       h.detail_region_name,
       h.category_name
FROM hotels h
         JOIN available_rooms ar
              ON h.id = ar.hotel_id
GROUP BY h.id
ORDER BY h.id
LIMIT 10;</code></pre>
<p>위 쿼리는 내가 개발 중인 사이드 프로젝트 “여기서 놀자”의 호텔 검색 쿼리이다.</p>
<p>쿼리를 간단히 설명하면, 호텔과 객실의 조건에 따라 필터링한 뒤 예약 내역을 확인하여 예약이 가능한 객실을 찾아 CTE로 생성하고, 이를 이용하여 예약이 가능한 객실을 보유한 호텔을 조회한다.</p>
<p>이제 위 쿼리의 실행 계획을 Explain, Explain Analyze 명령을 이용하여 분석해보자.</p>
<ul>
<li><strong>Explain</strong> (<code>partitions</code>, <code>possible_keys</code>컬럼은 미표시)</li>
</ul>
<pre><code>| id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | PRIMARY | &lt;derived2&gt; | ALL | null | null | null | 1003 | 100 | Using temporary; Using filesort |
| 1 | PRIMARY | h | eq_ref | PRIMARY | 8 | ar.hotel_id | 1 | 100 | null |
| 2 | DERIVED | h | range | detail_regions_categories_idx | 24 | null | 196 | 100 | Using where; Using index; Using temporary |
| 2 | DERIVED | r | ref | hotel_id | 8 | h.id | 3 | 3.7 | Using where |
| 2 | DERIVED | rc | ref | reservation_check_ibfk_1 | 8 | r.id | 6 | 100 | Using where |</code></pre><ul>
<li><p><strong>Explain Analyze</strong></p>
<pre><code class="language-sql">  -&gt; Limit: 10 row(s)  (actual time=11.6..11.6 rows=10 loops=1)
      -&gt; Sort: h.id, limit input to 10 row(s) per chunk  (actual time=11.6..11.6 rows=10 loops=1)
          -&gt; Table scan on &lt;temporary&gt;  (actual time=11.5..11.5 rows=123 loops=1)
              -&gt; Aggregate using temporary table  (actual time=11.5..11.5 rows=123 loops=1)
                  -&gt; Nested loop inner join  (cost=142 rows=0) (actual time=11..11.3 rows=155 loops=1)
                      -&gt; Table scan on ar  (cost=2.5..2.5 rows=0) (actual time=11..11 rows=155 loops=1)
                          -&gt; Materialize CTE available_rooms  (cost=0..0 rows=0) (actual time=11..11 rows=155 loops=1)
                              -&gt; Filter: (coalesce(max(rc.count),0) &lt;= r.count)  (actual time=11..11 rows=155 loops=1)
                                  -&gt; Table scan on &lt;temporary&gt;  (actual time=11..11 rows=157 loops=1)
                                      -&gt; Aggregate using temporary table  (actual time=10.9..10.9 rows=157 loops=1)
                                          -&gt; Nested loop left join  (cost=697 rows=149) (actual time=0.0933..10.8 rows=187 loops=1)
                                              -&gt; Nested loop inner join  (cost=540 rows=22.5) (actual time=0.061..3.03 rows=157 loops=1)
                                                  -&gt; Filter: ((h.category_id = 1) and (h.detail_region_id = 1) and (h.id &gt; 100000))  (cost=40.8 rows=196) (actual time=0.0197..0.146 rows=196 loops=1)
                                                      -&gt; Covering index range scan on h using detail_regions_categories_idx over (detail_region_id = 1 AND category_id = 1 AND 100000 &lt; id)  (cost=40.8 rows=196) (actual time=0.018..0.104 rows=196 loops=1)
                                                  -&gt; Filter: ((r.max_people_count &gt;= 3) and (r.price &gt;= 50000) and (r.price &lt;= 200000))  (cost=2.24 rows=0.115) (actual time=0.0141..0.0146 rows=0.801 loops=196)
                                                      -&gt; Index lookup on r using hotel_id (hotel_id=h.id)  (cost=2.24 rows=3.11) (actual time=0.00922..0.0141 rows=3.12 loops=196)
                                              -&gt; Filter: (rc.stay_date between &#39;2023-06-22&#39; and &#39;2023-06-25&#39;)  (cost=6.33 rows=6.6) (actual time=0.0485..0.0491 rows=0.382 loops=157)
                                                  -&gt; Index lookup on rc using reservation_check_ibfk_1 (room_id=r.id)  (cost=6.33 rows=6.6) (actual time=0.0206..0.0471 rows=5.86 loops=157)
                      -&gt; Single-row index lookup on h using PRIMARY (id=ar.hotel_id)  (cost=0.945 rows=1) (actual time=0.00167..0.0017 rows=1 loops=155)</code></pre>
<ul>
<li><p>이해를 돕기위한 다이어그램</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/32cf11c6-9a5e-4973-bc03-83d6b6ecbf51/image.png" alt=""></p>
</li>
</ul>
</li>
</ul>
<h3 id="실행-계획-상세-설명">실행 계획 상세 설명</h3>
<ol>
<li><code>**h</code>테이블(hotels)에서 <code>h.detail_region_id = 1 AND h.category_id = 1 AND h.id &gt; 100000</code> 조건에 맞는 레코드 탐색**<ul>
<li><strong>Explain</strong></li>
</ul>
</li>
</ol>
<pre><code>    | id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
    | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
    | 2 | DERIVED | h | range | detail_regions_categories_idx | 24 | null | 196 | 100 | Using where; Using index; Using temporary |
- **Explain Analyze**

    ```sql
    -&gt; Filter: ((h.category_id = 1) and (h.detail_region_id = 1) and (h.id &gt; 100000))  (cost=40.8 rows=196) (actual time=0.0197..0.146 rows=196 loops=1)
            -&gt; Covering index range scan on h using detail_regions_categories_idx over (detail_region_id = 1 AND category_id = 1 AND 100000 &lt; id)  (cost=40.8 rows=196) (actual time=0.018..0.104 rows=196 loops=1)
    ```

- 위 과정에서 사용된 인덱스는 `key`컬럼에서 볼 수 있듯이 `detail_regions_categories_idx`이다. 해당 인덱스는 `detail_region_id`와 `category_id` 두 컬럼으로 구성된 복합 인덱스이다.

    두 컬럼 다 bigint 자료형이므로 각각 8byte의 용량을 가지지만 `key_len`컬럼을 보면 24인 것을 볼 수 있다.

    그 이유는, MySQL의 세컨더리 인덱스에는 기본적으로 PK가 포함되어 있고, 조건을 보면 `h.id &gt; 100000` 이 있고, 이를 위해 `detail_regions_categories_idx`에 포함된 `h.id`까지 사용했기 때문이다.

- `Extra`컬럼에 표시된 `Using temporary`메시지와 `select_type`컬럼의 `DERIVED`는 해당 과정에서 임시 테이블이 생성되었다는 뜻이다.
- `Extra`컬럼에 표시된 `Using index`메시지는 테이블에 접근하지 않고 인덱스에 포함된 데이터만을 사용하여 쿼리를 처리했다는 의미이다. 즉, 커버링 인덱스가 적용되었다는 뜻이다.

    해당 쿼리의 `SELECT`절과 `WHERE`절을 살펴보면 `detail_regions_categories_idx`에 포함된 컬럼만을 사용하고 있기 때문에 실제 `hotels`테이블에 접근하지 않고도 쿼리를 처리할 수 있다.

- `Extra`컬럼에 표시된 `Using where`메시지는 MySQL엔진 레벨에서 필터링이 수행되었다는 뜻이다. 즉, 인덱스를 활용한 검색만으로 조건을 만족하는 데이터를 찾지 못하고 추가적인 필터링 작업이 일어났다는 뜻이다.

    `detail_regions_categories_idx`에서 `h.id`는 선행 컬럼이 아니기 때문에 정렬이 선행 컬럼을 기준으로 이루어져 있다. 그렇기 때문에 `h.id &gt; 100000` 조건은 인덱스 탐색만으로 해결하지 못하였고, MySQL엔진에서 추가적인 필터링이 이루어진 것이다.</code></pre><ol start="2">
<li><strong>1번의 결과와 <code>r</code>테이블(rooms)을 Nested loop join</strong><ul>
<li><strong>Explain</strong></li>
</ul>
</li>
</ol>
<pre><code>    | id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
    | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
    | 2 | DERIVED | r | ref | hotel_id | 8 | h.id | 3 | 3.7 | Using where |
- **Explain Analyze**

    ```sql
    -&gt; Nested loop inner join  (cost=540 rows=22.5) (actual time=0.061..3.03 rows=157 loops=1)
            # 1번 과정
            -&gt; Filter: ((r.max_people_count &gt;= 3) and (r.price &gt;= 50000) and (r.price &lt;= 200000))  (cost=2.24 rows=0.115) (actual time=0.0141..0.0146 rows=0.801 loops=196)
                    -&gt; Index lookup on r using hotel_id (hotel_id=h.id)  (cost=2.24 rows=3.11) (actual time=0.00922..0.0141 rows=3.12 loops=196)
    ```

- 1번의 결과로 나온 196개의 레코드를 반복문을 돌며 `h.id=r.hotel_id`를 만족하고 `200000 &gt;= r.price AND r.price &gt;= 50000 AND r.max_people_count &gt;= 3`를 만족하는 레코드를 찾았다.
    - MySQL 엔진 레벨에서 필터링을 처리했다. (Using where)</code></pre><ol start="3">
<li><strong>2번의 결과와 <code>rc</code>테이블(reservation_check)을 Nested loop left(outer) join</strong><ul>
<li><strong>Explain</strong></li>
</ul>
</li>
</ol>
<pre><code>    | id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
    | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
    | 2 | DERIVED | rc | ref | reservation_check_ibfk_1 | 8 | r.id | 6 | 100 | Using where; |
- **Explain Analyze**

    ```sql
    -&gt; Nested loop left join  (cost=697 rows=149) (actual time=0.0933..10.8 rows=187 loops=1)
            # 2번 과정
            # 1번 과정
            -&gt; Filter: (rc.stay_date between &#39;2023-06-22&#39; and &#39;2023-06-25&#39;)  (cost=6.33 rows=6.6) (actual time=0.0485..0.0491 rows=0.382 loops=157)
                    -&gt; Index lookup on rc using reservation_check_ibfk_1 (room_id=r.id)  (cost=6.33 rows=6.6) (actual time=0.0206..0.0471 rows=5.86 loops=157)
    ```

- 2번의 결과로 나온 157개의 레코드를 반복문을 돌며 `room.id=rc.room_id`를 만족하고 `rc.stay_date BETWEEN &#39;2023-06-22&#39; AND &#39;2023-06-25&#39;`를 만족하는 레코드를 찾았다.
    - MySQL 엔진 레벨에서 필터링을 처리했다. (Using where)</code></pre><ol start="4">
<li><strong>3번의 결과를 <code>r.id</code>를 기준으로 그룹핑하고 <code>MAX(rc.count) &lt;= r.count</code>조건으로 필터링한 뒤 지금까지의 결과로 <code>available_rooms</code>라는 이름의 CTE 생성</strong><ul>
<li><strong>Explain</strong></li>
</ul>
</li>
</ol>
<pre><code>    | id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
    | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
    | 1 | PRIMARY | &lt;derived2&gt; | ALL | null | null | null | 1003 | 100 | Using temporary; Using filesort |
- **Explain Analyze**

    ```sql
    -&gt; Table scan on ar  (cost=2.5..2.5 rows=0) (actual time=11..11 rows=155 loops=1)
            -&gt; Materialize CTE available_rooms  (cost=0..0 rows=0) (actual time=11..11 rows=155 loops=1)
                    -&gt; Filter: (coalesce(max(rc.count),0) &lt;= r.count)  (actual time=11..11 rows=155 loops=1)
                            -&gt; Table scan on &lt;temporary&gt;  (actual time=11..11 rows=157 loops=1)
                                    -&gt; Aggregate using temporary table  (actual time=10.9..10.9 rows=157 loops=1)
                                            # 3,2,1번 과정
    ```

- Explain의 `table`컬럼을 살펴보면 `&lt;derived2&gt;`라고 되어있는데, 이는 `id`가 2인 단위 `SELECT`문의 결과로 생성된 임시 테이블이라는 의미이다.
- `Extra`컬럼의 `Using temporary`메시지를 통해 쿼리 실행을 위해 임시 테이블이 생성되었음을 알 수 있다. 위 경우는 그룹핑 작업을 위해 임시 테이블이 필요하여 생성한 것이다.
- `Extra`컬럼의 `Using filesort`메시지는 정렬 시 filesort 방식의 정렬을 이용했음 의미한다.</code></pre><ol start="5">
<li><code>**available_rooms</code>CTE와 <code>hotels</code>테이블을 Nested loop join**<ul>
<li><strong>Explain</strong></li>
</ul>
</li>
</ol>
<pre><code>    | id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
    | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
    | 1 | PRIMARY | h | eq_ref | PRIMARY | 8 | ar.hotel_id | 1 | 100 | null |
- **Explain Analyze**

    ```sql
    -&gt; Nested loop inner join  (cost=142 rows=0) (actual time=11..11.3 rows=155 loops=1)
            # 4,3,2,1번 과정
    ```

- Explain의 `type`컬럼을 살펴보면 `eq_ref`라는 타입을 볼 수 있는데, 이는 `available_rooms`의 `hotel_id`를 `hotels`의 PK과 비교하므로 `available_rooms`하나에 무조건 하나의 hotels의 레코드만 나온다는 뜻으로, 이 경우 성능이 뛰어나다.</code></pre><ol start="6">
<li><p><strong>5번의 결과를 <code>h.id</code>를 기준으로 그룹핑하고 정렬한 뒤 <code>LIMIT 10</code> 적용</strong></p>
<ul>
<li><p><strong>Explain Analyze</strong></p>
<pre><code class="language-sql">  -&gt; Limit: 10 row(s)  (actual time=11.6..11.6 rows=10 loops=1)
      -&gt; Sort: h.id, limit input to 10 row(s) per chunk  (actual time=11.6..11.6 rows=10 loops=1)
          -&gt; Table scan on &lt;temporary&gt;  (actual time=11.5..11.5 rows=123 loops=1)
              -&gt; Aggregate using temporary table  (actual time=11.5..11.5 rows=123 loops=1)
                                  # 5,4,3,2,1번 과정</code></pre>
</li>
</ul>
</li>
</ol>
<p>이렇게 실행 계획에 대해 알아보고, Explain 명령을 이용하여 실행 계획을 분석하는 것 까지 완료하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[여기서 놀자] 예약 가능한 호텔만 노출하기]]></title>
            <link>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EC%98%88%EC%95%BD-%EA%B0%80%EB%8A%A5%ED%95%9C-%ED%98%B8%ED%85%94%EB%A7%8C-%EB%85%B8%EC%B6%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EC%98%88%EC%95%BD-%EA%B0%80%EB%8A%A5%ED%95%9C-%ED%98%B8%ED%85%94%EB%A7%8C-%EB%85%B8%EC%B6%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 14 Oct 2023 13:06:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 이 글의 내용은 MySQL 8.0 이상 + InnoDB 스토리지 엔진 환경을 기준으로 작성되었습니다.</p>
</blockquote>
<blockquote>
<p>💡 이 글은 사이드 프로젝트 “여기서 놀자”의 호텔 검색 기능을 구현하고 성능을 개선한 과정을 정리한 글입니다.</p>
</blockquote>
<h3 id="구현할-기능---호텔-검색">구현할 기능 - 호텔 검색</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/0e570115-b5b8-4f7f-bb9b-882a488b96c2/image.png" alt=""></p>
<p>지역, 카테고리, 숙박 날짜, 숙박 인원 등을 통해 조건에 맞는 호텔을 검색하는 기능</p>
<p><strong>[요구사항]</strong></p>
<ul>
<li>카테고리, 상세 지역 등으로 필터링이 가능하다. <strong>[완료]</strong></li>
<li>페이징을 지원한다. <strong>[완료]</strong></li>
<li>호텔 이름, 평점, 가격, 주소, 지역, 카테고리 등 필요한 정보를 보여준다. <strong>[완료]</strong></li>
<li>지역으로 필터링이 가능하다. <strong>[완료]</strong></li>
<li>설정한 인원이 묵을 수 있는 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>각 호텔에서 예약 가능한 가장 저렴한 객실의 가격을 표시한다.</li>
<li>지정한 날짜에 예약이 가능한 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>가격 범위를 지정하여 검색이 가능하다.</li>
</ul>
<h3 id="목표">목표</h3>
<ul>
<li>설정한 인원이 묵을 수 있는 객실이 있는 숙소들을 우선적으로 노출</li>
<li>각 호텔에서 예약 가능한 가장 저렴한 객실의 가격을 표시</li>
<li>지정한 날짜에 예약이 가능한 객실이 있는 숙소들을 우선적으로 노출</li>
<li>가격 범위를 지정하여 검색이 가능하다.</li>
</ul>
<h2 id="예약-가능한-객실-찾기">예약 가능한 객실 찾기</h2>
<p>이제 숙박 인원, 가격 범위, 숙박 기간이 주어졌을 때 예약이 가능한 객실을 찾는 쿼리를 작성해보자.</p>
<p>우선 예약이 가능하려면 숙박 인원이 객실의 최대 수용 인원보다 작거나 같고, 객실의 가격이 가격 범위 내에 있어야 한다. 그리고 숙박 기간동안의 날짜마다 예약의 수가 객실의 개수보다 작아야 한다.</p>
<p>이를 위해서는 예약의 개수를 체크하는 테이블이 필요하다. 아래와 같이 만들어보자.</p>
<pre><code class="language-sql">CREATE TABLE `reservation_check` (
    `id` bigint NOT NULL AUTO_INCREMENT,
    `room_id` bigint NOT NULL,
    `stay_date` date NOT NULL,
    `count` int NOT NULL,
    PRIMARY KEY (`id`),
    CONSTRAINT `reservation_check_ibfk_1` FOREIGN KEY (`room_id`) REFERENCES `rooms` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;</code></pre>
<p>위 테이블은 날짜마다 각 객실의 예약의 개수를 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/c770fdfa-1ab7-48b9-a979-da96d4e7201d/image.png" alt=""></p>
<p>위와 같이 reservation_check 테이블의 데이터를 채워넣었다.</p>
<p>이제 reservation_check 테이블을 활용하여 예약이 가능한 객실을 찾는 쿼리를 만들어보자.</p>
<pre><code class="language-sql">SELECT r.id,
       r.hotel_id,
       r.price
FROM rooms r
         LEFT OUTER JOIN reservation_check rc
                   ON r.id = rc.room_id
                       AND rc.stay_date BETWEEN &#39;2023-06-18&#39; AND &#39;2023-06-25&#39;
WHERE r.max_people_count &gt;= 3
  AND 200000 &gt;= r.price
  AND r.price &gt;= 50000
GROUP BY r.id, r.count
HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count;</code></pre>
<p>위 쿼리는 방 가격 범위, 숙박 가능 인원 수로 객실을 필터링한 뒤 해당 기간동안 최대 예약 수가 객실 수 이하인 경우 객실들을 구한다.</p>
<p>위 쿼리의 실행 계획을 살펴보자.</p>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | key | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | r | ALL | null | null | 617789 | 3.7 | Using where; Using temporary |
| 1 | SIMPLE | rc | ref | reservation_check_ibfk_1 | r.id | 6 | 100 | Using where |</code></pre><ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/e30b31c5-6c83-4e14-a247-731512d33b72/image.png" alt=""></p>
</li>
</ul>
<p>실행 계획을 살펴보면 아래와 같다.</p>
<ol>
<li>rooms 테이블을 풀 스캔한 뒤 <code>r.max_people_count &gt;= 3  AND 200000 &gt;= r.price AND r.price &gt;= 50000</code> 조건에 맞는 객실만 필터링한다.</li>
<li>rooms의 각 행과 rooms.id와 room_id가 일치하는 rc의 행들 중 <code>rc.stay_date BETWEEN &#39;2023-06-18&#39; AND &#39;2023-06-25&#39;</code>를 만족하는 행을 left outer join한다.</li>
<li>room_id를 기준으로 그룹핑하고, HAVING절의 조건으로 필터링을 진행한다.</li>
</ol>
<p>위 쿼리의 문제는, <code>r.max_people_count &gt;= 3  AND 200000 &gt;= r.price AND r.price &gt;= 50000</code> 조건으로 필터링을 진행할 때 해당 컬럼으로 생성된 인덱스가 없어서 rooms 테이블을 풀스캔한 뒤 필터링을 진행하는 것이다.</p>
<h3 id="인덱스는-만능일까">인덱스는 만능일까?</h3>
<p>rooms 테이블을 풀스캔하는 문제를 해결하기 위해서 인덱스를 생성해보자. 우선 price와 max_people_count 중 어떤 컬럼을 선행 컬럼으로 할 것인지 결정해야한다.</p>
<p>max_people_count는 1~6의 값만 있으므로 price 컬럼보다 카디널리티가 낮다. 단순히 생각해서 카디널리티가 높은 price컬럼을 선행 컬럼으로 하여 복합 인덱스를 생성해보자.</p>
<pre><code class="language-sql">CREATE INDEX price_max_people_count_idx ON rooms(price, max_people_count);</code></pre>
<p>이제 다시 위 쿼리를 실행하여 실행 계획을 살펴보자.</p>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | possible_keys | key | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | r | ALL | price_max_people_count_idx | null | null | 617789 | 16.66 | Using where; Using temporary |
| 1 | SIMPLE | rc | ref | reservation_check_ibfk_1 | reservation_check_ibfk_1 | r.id | 6 | 100 | Using where |</code></pre><p>어라? 실행 계획에 변화가 없다. 분명히 possible_keys 컬럼에 보면 방금 추가한 price_max_people_count_idx 인덱스가 있는데, 사용하지 않고 여전히 풀스캔을 하고있다.</p>
<p>옵티마이저가 인덱스를 사용하여 rooms테이블을 스캔하는 것 보다 풀스캔을 하는 것이 더 효율적이라고 판단한 것이다. 왜일까?</p>
<h3 id="인덱스가-비효율적인-경우-1">인덱스가 비효율적인 경우 (1)</h3>
<p>일반적으로 인덱스를 통해 레코드를 읽는 것은 바로 테이블의 레코드를 읽는 것 보다 비용이 높다. (약 4~5배)</p>
<ul>
<li>인덱스 레인지 스캔을 하게되면 인덱스를 통해 얻은 레코드의 주소를 이용하여 레코드를 읽는데, 이때 레코드 한 건 마다 랜덤 I/O가 발생하므로 인덱스를 통해 레코드를 읽는 작업은 그냥 읽는 것보다 비용이 많이든다.</li>
</ul>
<p>즉, 인덱스를 통해 읽어야 할 레코드의 건수가 전체 테이블 레코드의 20~25%를 넘어서면 인덱스를 이용하지 않고 테이블 풀 스캔 후 필요한 레코드만 걸러내는 방식으로 처리하는 것이 효율적이다.</p>
<p>위 쿼리의 경우 rooms 테이블의 전체 레코드 수는 619984개인데, 그 중 조건을 만족하는 레코드 수는 102954개이다. 전체테이블의 16.66% 정도를 조회하는 것이다.</p>
<p>그렇다면, 조건을 만족하는 레코드 수가 더 줄어들면 옵티마이저는 인덱스를 사용할까? </p>
<pre><code class="language-sql">SELECT r.id,
       r.hotel_id,
       r.price
FROM rooms r
         LEFT OUTER JOIN reservation_check rc
                   ON r.id = rc.room_id
                       AND rc.stay_date BETWEEN &#39;2023-06-22&#39; AND &#39;2023-06-25&#39;
WHERE r.max_people_count &gt;= 6
  AND 400000 &gt;= r.price
  AND r.price &gt;= 300000
GROUP BY r.id, r.count
HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count;</code></pre>
<p>위와 같이 쿼리를 실행하여 실행 계획을 살펴보자.</p>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | r | range | price_max_people_count_idx | 8 | null | 18210 | 33.33 | Using index condition; Using temporary |
| 1 | SIMPLE | rc | ref | reservation_check_ibfk_1 | 8 | r.id | 6 | 100 | Using where |</code></pre><p>이번에는 의도대로 price_max_people_count_idx 인덱스를 사용해서 인덱스 레인지 스캔을 한 모습이다.</p>
<h3 id="인덱스가-비효율적인-경우-2">인덱스가 비효율적인 경우 (2)</h3>
<p>가격 범위의 경우 사용자가 설정을 하지 않는 경우도 있다. 만약 가격 범위 조건을 설정해주지 않는다면 실행 계획은 어떻게 될까?</p>
<pre><code class="language-sql">SELECT r.id,
       r.hotel_id,
       r.price
FROM rooms r
         LEFT OUTER JOIN reservation_check rc
                   ON r.id = rc.room_id
                       AND rc.stay_date BETWEEN &#39;2023-06-22&#39; AND &#39;2023-06-25&#39;
WHERE r.max_people_count &gt;= 6
GROUP BY r.id, r.count
HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count;</code></pre>
<p>기존 쿼리에서 가격 범위 조건을 없앤 쿼리이다. 실행 계획을 살펴보자.</p>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | r | ALL | null | null | null | null | 617789 | 33.33 | Using where; Using temporary |
| 1 | SIMPLE | rc | ref | reservation_check_ibfk_1 | reservation_check_ibfk_1 | 8 | r.id | 6 | 100 | Using where |</code></pre><p>맨 처음 실행 계획처럼 테이블 풀 스캔을 하는 모습이다. 하지만, possible_keys에도 price_max_people_count_idx가 없다. 왜 그런 것일까?</p>
<p>우리는 price_max_people_count_idx 인덱스를 생성할 때 price 컬럼을 복합 인덱스의 선행컬럼으로 했었다.</p>
<p>B-Tree 인덱스는 선행 컬럼을 기준으로 나머지 컬럼들이 정렬되어있다. 즉, 우선 price로 정렬이 되어있고, price가 같은 레코드들 끼리는 max_people_count로 정렬되는 것이다.</p>
<p>즉, WHERE 절에 price에 관한 조건이 없어서 복합 인덱스를 사용할 수 없는 것이다.</p>
<p>이처럼, 복합 인덱스를 생성할 때 선행 컬럼의 조건이 없다면 인덱스를 아예 사용할 수 없으므로 이를 잘 고려하여 선행 컬럼을 정해야한다.</p>
<h3 id="rooms-테이블-풀-스캔-문제-결론">rooms 테이블 풀 스캔 문제 결론</h3>
<p>잘 생각해보면, 숙박 인원수를 낮은 수로 설정하게 되면 필터링이 거의 되지않을 수도 있고, 가격 범위 조건은 필수로 적용되는 것이 아니고, 범위를 어떻게 설정하느냐에 따라 너무 유동적이다.</p>
<p>결국 단순히 인덱스를 적용시켜 해결할 문제가 아니라는 결론을 내렸다.</p>
<p>대신, 먼저 rooms 테이블의 범위를 좁히는 것이 더 좋은 해결책이라는 생각이 들었다.</p>
<p>검색 시 호텔에 대한 조건을 넣을 것이고, 호텔의 범위를 줄이고 범위를 줄인 호텔의 객실을 찾게되면 탐색할 rooms 테이블의 레코드의 수가 줄어들 것이다.</p>
<pre><code class="language-sql">SELECT r.id,
       r.hotel_id,
       r.price
FROM rooms r
         JOIN hotels h
              ON r.hotel_id = h.id
         LEFT OUTER JOIN reservation_check rc
                   ON r.id = rc.room_id
                       AND rc.stay_date BETWEEN &#39;2023-06-22&#39; AND &#39;2023-06-25&#39;
WHERE r.max_people_count &gt;= 3
  AND r.price &gt;= 50000
  AND r.price &lt;= 200000
  AND h.detail_region_id = 1
  AND h.category_id
GROUP BY r.id, r.count
HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count;</code></pre>
<p>위와 같이 hotels 테이블과 join 및 where절에 <code>h.detail_region_id = 1 AND h.category_id</code>조건을 추가하여 쿼리를 수정하고, 실행 계획을 살펴보자.</p>
<ul>
<li>Explain</li>
</ul>
<pre><code>| id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | h | ref | detail_regions_categories_idx | 16 | const,const | 410 | 100 | Using index; Using temporary |
| 1 | SIMPLE | r | ref | hotel_id | 8 | h.id | 3 | 3.7 | Using where |
| 1 | SIMPLE | rc | ref | reservation_check_ibfk_1 | 8 | r.id | 6 | 100 | Using where |</code></pre><ul>
<li><p>Explain Analyze</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/22000de3-a1bd-49fe-b3c8-cb1a94c0ae3e/image.png" alt=""></p>
</li>
</ul>
<p>우선 detail_regions_categories_idx 인덱스만을 사용하여 조건에 맞는 호텔들의 id를 찾았다. 그런 다음 해당 호텔의 객실에 대해서만 탐색을 진행하여 탐색한 row수가 현저히 줄어들었다.</p>
<h2 id="예약-가능한-객실을-보유한-호텔-찾기">예약 가능한 객실을 보유한 호텔 찾기</h2>
<p>이제 위에서 만든 쿼리를 활용해서 예약 가능한 객실을 보유한 호텔을 찾는 쿼리를 작성해보자.</p>
<pre><code class="language-sql">WITH available_rooms AS (
    SELECT r.id,
           r.hotel_id,
           r.price
    FROM rooms r
        JOIN hotels h
            ON r.hotel_id = h.id
        LEFT OUTER JOIN reservation_check rc
            ON r.id = rc.room_id
                AND rc.stay_date BETWEEN &#39;2023-06-22&#39; AND &#39;2023-06-25&#39;
    WHERE r.max_people_count &gt;= 3
        AND r.price &gt;= 50000
        AND r.price &lt;= 200000
        AND h.detail_region_id = 1
        AND h.category_id
        AND h.id &gt; 100000
    GROUP BY r.id, r.count
    HAVING COALESCE(MAX(rc.count), 0) &lt;= r.count
)
SELECT h.id,
       h.name        AS hotel_name,
       MIN(ar.price) AS min_price,
       h.rating,
       h.address,
       h.detail_region_name,
       h.category_name
FROM hotels h
         JOIN available_rooms ar
              ON h.id = ar.hotel_id
GROUP BY h.id
ORDER BY h.id
LIMIT 10;</code></pre>
<p>위에서 만든 쿼리를 WITH절에 넣고, 메인 쿼리에서 hotels테이블과 한 번더 JOIN하도록 하였다.</p>
<p>위 쿼리를 실행하여 아래와 같이 예약이 가능한 객실을 보유한 호텔들의 정보를 성공적으로 가져왔다.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/2cf5eca4-0033-4436-96ba-031a971a4380/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[여기서 놀자] 역정규화로 쿼리 성능 향상시키기]]></title>
            <link>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EC%97%AD%EC%A0%95%EA%B7%9C%ED%99%94%EB%A1%9C-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EC%97%AD%EC%A0%95%EA%B7%9C%ED%99%94%EB%A1%9C-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0</guid>
            <pubDate>Sat, 14 Oct 2023 13:02:54 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 이 글의 내용은 MySQL 8.0 이상 + InnoDB 스토리지 엔진 환경을 기준으로 작성되었습니다.</p>
</blockquote>
<blockquote>
<p>💡 이 글은 사이드 프로젝트 “여기서 놀자”의 호텔 검색 기능을 구현하고 성능을 개선한 과정을 정리한 글입니다.</p>
</blockquote>
<h3 id="구현할-기능---호텔-검색">구현할 기능 - 호텔 검색</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/97829855-aa04-4207-96e8-fae7f9531178/image.png" alt=""></p>
<p>지역, 카테고리, 숙박 날짜, 숙박 인원 등을 통해 조건에 맞는 호텔을 검색하는 기능</p>
<p><strong>[요구사항]</strong></p>
<ul>
<li>카테고리, 상세 지역으로 필터링이 가능하다. <strong>[완료]</strong></li>
<li>페이징을 지원한다. <strong>[완료]</strong></li>
<li>호텔 이름, 평점, 가격, 주소, 지역, 카테고리 등 필요한 정보를 보여준다.</li>
<li>지역으로 필터링이 가능하다.</li>
<li>설정한 인원이 묵을 수 있는 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>각 호텔에서 예약 가능한 가장 저렴한 객실의 가격을 표시한다.</li>
<li>지정한 날짜에 예약이 가능한 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>가격 범위를 지정하여 검색이 가능하다.</li>
</ul>
<h3 id="목표">목표</h3>
<ul>
<li>필요한 정보 노출</li>
<li>지역으로 필터링</li>
</ul>
<h2 id="필요한-정보-노출시키기">필요한 정보 노출시키기</h2>
<p>일단은 현재까지는 <code>SELECT *</code> 로 hotels 테이블의 모든 데이터를 불러왔다. 이제는 원하는 정보들만 보여줄 수 있도록 해보자.</p>
<pre><code class="language-sql">SELECT h.name    AS hotel_name,
       h.rating  AS hotel_rating,
       h.address AS hotel_address,
       dr.name   AS detail_region_name,
       c.name    AS category_name
FROM hotels h
         JOIN detail_regions dr ON h.detail_region_id = dr.id
         JOIN categories c ON h.category_id = c.id
WHERE h.id &gt; 100000
ORDER BY h.id
LIMIT 20;</code></pre>
<p>기존 쿼리에서 카테고리 이름과 상세 지역 이름을 가져오기 위해 JOIN이 추가되었다.</p>
<p>여기서 성능을 향상시키기 위해서 categories 테이블이나 detail_regions 테이블의 데이터를 hotels 테이블에 포함시키는 <strong>역정규화</strong>를 고려해볼 수 있다.</p>
<p><strong>역정규화를 했을 때의 장점</strong></p>
<ol>
<li><strong>쿼리 성능 향상</strong>: <code>hotels</code> 테이블에 다른 테이블의 정보가 포함되면, <code>hotels</code> 테이블에 대한 쿼리만으로 필요한 정보를 얻을 수 있다. 이는 조인 연산을 없애므로 쿼리 성능을 향상시킨다.</li>
<li><strong>복잡성 감소</strong>: <code>hotels</code> 테이블을 쿼리할 때 마다 다른 테이블을 조인하지 않아도 되므로, 쿼리의 복잡성이 감소합니다.</li>
</ol>
<p><strong>역정규화를 했을 때의 단점</strong></p>
<ol>
<li><strong>데이터 불일치 위험</strong>: 만약 다른 테이블의 데이터가 변경되면, <strong><code>hotels</code></strong> 테이블에서도 해당 데이터를 업데이트해야 한다. 이는 두 테이블 간에 데이터 불일치를 일으킬 수 있다.</li>
<li><strong>저장 공간 증가</strong>: 다른 테이블의 데이터를 <code>hotels</code> 테이블에 복제하면, 저장 공간이 증가한다.</li>
</ol>
<p>더 확실히 장단점을 파악하기 위해 역정규화를 하기 전과 하고난 뒤를 비교해보자.</p>
<h3 id="역정규화-이전">역정규화 이전</h3>
<p>역 정규화를 하기 전의 실행 계획을 살펴보자.</p>
<ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/d8847455-691d-4fcb-aa48-64dc0d01aced/image.png" alt=""></p>
</li>
</ul>
<p>실행 계획을 살펴보면 상세 지역 이름, 카테고리 이름을 가져오기 위해 hotels 테이블과 detail_regions 테이블과 categories 테이블을 조인하고있다.</p>
<h3 id="역정규화-이후">역정규화 이후</h3>
<p>이제 역정규화를 진행해보자.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/d4fc1b31-4cf7-4da2-a0fe-188c61f7f983/image.png" alt=""></p>
<p>위와 같이 hotels 테이블에 category_name, detail_region_name 컬럼을 추가했다.</p>
<pre><code class="language-sql">SELECT name,
       rating,
       address,
       detail_region_name,
       category_name
FROM hotels
WHERE id &gt; 100000
ORDER BY id
LIMIT 20;</code></pre>
<p>이제 위와 같이 쿼리를 바꾸고 성능을 측정해보자.</p>
<ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/3bf0e41f-7434-442d-b914-6fc93bb54975/image.png" alt=""></p>
</li>
</ul>
<p>쿼리도 단순해졌고, 성능도 역정규화 전보다 60%(0.1ms → 0.04ms)정도 향상된 모습이다.</p>
<p>역정규화를 진행함으로써 데이터 일관성을 신경써야하고, 전체 데이터 용량도 늘어났지만 결국 아래와 같은 이유들로 역정규화를 진행하는 것이 좋다고 판단하였다.</p>
<ul>
<li>categories, detail_regions 테이블의 데이터 추가, 변경, 삭제가 적다. (호텔의 카테고리나 새로운 상세지역이 추가, 변경, 삭제될 일이 거의 없을 것이라고 판단)</li>
<li>쿼리의 성능이 60% 정도 향상된다.</li>
</ul>
<h2 id="지역으로-필터링">지역으로 필터링</h2>
<p>이번에는 지역으로 필터링 할 수 있는 기능을 추가해보자.</p>
<pre><code class="language-sql">SELECT h.name AS hotel_name,
       rating,
       address,
       detail_region_name,
       category_name
FROM hotels h
         JOIN detail_regions dr ON detail_region_id = dr.id
WHERE dr.region_id = 1
  AND h.id &gt; 100000
ORDER BY h.id
LIMIT 20;</code></pre>
<p>지역 id는 detail_regions테이블에 있으므로 위와 같이 JOIN을 수행해야한다.</p>
<p>이제 실행계획을 살펴보자.</p>
<ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/1368650f-8fa4-4fc8-a0d1-97249c8ebd35/image.png" alt=""></p>
</li>
</ul>
<p>실행 계획을 분석해보면 아래와 같다.</p>
<ol>
<li>hotels 테이블의 id 인덱스를 사용하여 <code>id &gt; 100000</code> 조건을 만족하는 row를 필터링한다.</li>
<li>detail_regions 테이블의 region_id 인덱스를 사용하여 <code>region_id = 1</code> 조건을 만족하는 row를 필터링한다.</li>
<li>Nested Loop Join을 사용하여 hotels 테이블과 detail_regions 테이블을 조인한다. 이 조인은 detail_region_id와 id 컬럼을 사용한다.</li>
<li><code>LIMIT 20</code> 절을 적용하여 최종 결과를 20개의 row로 제한한다.</li>
</ol>
<p>실행 계획을 보면 Nested Loop Join 작업에서 조인 결과를 필터링하는 과정에서 약간의 성능 저하가 발생한다. 이는 detail_regions 테이블에서 <code>region_id = 1</code> 조건을 만족하는 로우가 많아서 발생하는 문제이다.</p>
<p>이를 해결하기 위해서는 역시나 역정규화를 고려할 수 있다. 역정규화를 진행해서 성능을 비교해보자.</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/c6e0d05d-0899-44b0-8be9-306f3fd2a1c0/image.png" alt=""></p>
<p>위와 같이 hotels 테이블에 region_id 컬럼을 추가했다.</p>
<pre><code class="language-sql">SELECT h.name AS hotel_name,
       rating,
       address,
       detail_region_name,
       category_name
FROM hotels h
WHERE h.region_id = 1
  AND h.id &gt; 100000
ORDER BY h.id
LIMIT 20;</code></pre>
<p>이제 위와 같이 쿼리를 바꾸고 성능을 측정해보자.</p>
<ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/d69e1997-3f34-4f95-bcad-b29b7de2ba7e/image.png" alt=""></p>
</li>
</ul>
<p>쿼리의 복잡도도 감소하고, 성능도 역정규화 전보다 71%(0.38ms → 0.11ms)정도 향상된 모습이다.</p>
<p>숙소의 지역 정보는 변경될 일이 거의 없고, 추가되는 데이터도 작기 때문에 역정규화를 진행하는 것으로 결정하였다.</p>
<p>역정규화를 진행함으로써 데이터 일관성을 신경써야하고, 전체 데이터 용량도 늘어났지만 결국 아래와 같은 이유들로 역정규화를 진행하는 것이 좋다고 판단하였다.</p>
<ul>
<li>regions 테이블의 데이터 추가, 변경, 삭제가 적다.</li>
<li>쿼리의 성능이 71% 정도 향상된다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[여기서 놀자] 복합 인덱스와 페이징을 적용하여 쿼리 성능 향상시키기]]></title>
            <link>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EB%B3%B5%ED%95%A9-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EC%99%80-%ED%8E%98%EC%9D%B4%EC%A7%95%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%98%EC%97%AC-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EB%B3%B5%ED%95%A9-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EC%99%80-%ED%8E%98%EC%9D%B4%EC%A7%95%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%98%EC%97%AC-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EC%8B%9C%ED%82%A4%EA%B8%B0</guid>
            <pubDate>Sat, 14 Oct 2023 12:59:15 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 이 글의 내용은 MySQL 8.0 이상 + InnoDB 스토리지 엔진 환경을 기준으로 작성되었습니다.</p>
</blockquote>
<blockquote>
<p>💡 이 글은 사이드 프로젝트 “여기서 놀자”의 호텔 검색 기능을 구현하고 성능을 개선한 과정을 정리한 글입니다.</p>
</blockquote>
<h3 id="구현할-기능---호텔-검색">구현할 기능 - 호텔 검색</h3>
<img src="https://velog.velcdn.com/images/jeong_hun_hui/post/88d6e8d6-1fc7-4ab3-bc90-bbfb78e80347/image.png">

<p>지역, 카테고리, 숙박 날짜, 숙박 인원 등을 통해 조건에 맞는 호텔을 검색하는 기능</p>
<p><strong>[요구사항]</strong></p>
<ul>
<li>카테고리, 상세 지역 등으로 필터링이 가능하다.</li>
<li>페이징을 지원한다.</li>
<li>호텔 이름, 평점, 가격, 주소, 지역, 카테고리 등 필요한 정보를 보여준다.</li>
<li>지역으로 필터링이 가능하다.</li>
<li>설정한 인원이 묵을 수 있는 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>각 호텔에서 예약 가능한 가장 저렴한 객실의 가격을 표시한다.</li>
<li>지정한 날짜에 예약이 가능한 객실이 있는 호텔들을 우선적으로 노출한다.</li>
<li>가격 범위를 지정하여 검색이 가능하다.</li>
</ul>
<h3 id="목표">목표</h3>
<ul>
<li>카테고리와 상세 지역으로 필터링</li>
<li>페이징 구현</li>
</ul>
<h2 id="카테고리와-상세-지역으로-필터링">카테고리와 상세 지역으로 필터링</h2>
<pre><code class="language-sql">SELECT *
FROM hotels
WHERE category_id = 1
  AND detail_region_id = 1;</code></pre>
<p>나는 위와 같은 쿼리로 카테고리와 상세 지역으로 필터링이 가능한 호텔 검색 쿼리를 구현하려고 한다.</p>
<p>일단 위 쿼리의 실행 계획을 분석해보자.</p>
<ul>
<li><p><strong>Explain</strong></p>
<table>
<thead>
<tr>
<th>id</th>
<th>select_type</th>
<th>table</th>
<th>type</th>
<th>key</th>
<th>key_len</th>
<th>ref</th>
<th>rows</th>
<th>filtered</th>
<th>Extra</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>SIMPLE</td>
<td>hotels</td>
<td>index_merge</td>
<td>detail_region_id,category_id</td>
<td>8,8</td>
<td>null</td>
<td>928</td>
<td>99.94</td>
<td>Using intersect(detail_region_id,category_id); Using where</td>
</tr>
</tbody></table>
</li>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/d529f56f-130f-4e68-a362-804e4b2fa47a/image.png" alt=""></p>
</li>
</ul>
<p>위 결과로 알 수 있는 것으로는 아래의 사항들이 있다.</p>
<ul>
<li>Explain 쿼리의 type 파라미터의 값이 <code>index_merge</code>인데, 이 경우는 2개 이상의 인덱스를 이용하여 여러 결과를 만들어낸 후 그 결과를 병합해서 처리하는 방식이다.</li>
<li>위 쿼리의 실행 과정을 살펴보면 <code>detail_region_id</code>를 사용하여 검색한 결과와, <code>category_id</code>를 사용하여 검색한 결과를 교집합 연산으로 합친다.<ul>
<li>InnoDB 스토리지 엔진이 외래 키에도 자동으로 인덱스를 생성하기 때문에 왜래키인 <code>detail_region_id</code>와 <code>category_id</code>에 인덱스가 자동으로 생성되어 적용되어 있다.</li>
</ul>
</li>
</ul>
<p><code>index_merge</code> 는 아래와 같은 단점이 있다.</p>
<ul>
<li>여러 인덱스를 읽어야 하므로 인덱스 레인지 스캔 보다 효율이 떨어진다.</li>
<li>여러 결과들에 교집합, 합집합, 중복 제거와 같은 추가적인 연산을 수행해야 해서 성능이 떨어진다.</li>
</ul>
<p>결국 이 쿼리는 두개의 인덱스를 각각 사용한 뒤 그 결과를 합치는 연산을 한다.</p>
<h3 id="복합-인덱스로-성능-개선하기1">복합 인덱스로 성능 개선하기(1)</h3>
<p>위와 같은 상황은, 복합 인덱스를 생성하여 성능을 개선할 수 있다.</p>
<pre><code class="language-sql">create index categories_detail_regions_idx on hotels (category_id, detail_region_id);</code></pre>
<p>이제 다시 Explain과 Explain Analyze로 실행 계획을 분석해보자.</p>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | hotels | ref | categories_detail_regions_idx | 16 | const,const | 471 | 100 | null |</code></pre><ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/85ec78aa-8bc9-46bf-b3f0-f70d64e80338/image.png" alt=""></p>
</li>
</ul>
<p>이제 두 인덱스가 아닌 하나의 복합 인덱스를 통해서 데이터를 조회한다.</p>
<h3 id="복합-인덱스로-성능-개선하기2">복합 인덱스로 성능 개선하기(2)</h3>
<p>하지만, 복합 인덱스를 생성할 때 선행 컬럼은 카디널리티가 높은 컬럼으로 하는 것이 좋다.</p>
<p>왜냐하면, 복합 인덱스 사용 시 선행 컬럼에 대한 조건으로 필터링을 한 뒤 그 뒤의 컬럼으로 필터링을 하는데, 선행 컬럼의 카디널리티가 높으면 뒤에 조건을 확인해야할 레코드 수가 줄어들기 때문이다.</p>
<ul>
<li>참고로, 카테고리는 6개, 상세 지역은 75개의 고유한 값을 갖고있으므로, 상세 지역의 카디널리티가 더 높다.</li>
</ul>
<pre><code class="language-sql">create index detail_regions_categories_idx on hotels (detail_region_id, category_id);</code></pre>
<p>이번에는 위와 같은 인덱스를 생성해서 다시 쿼리를 실행해보자.</p>
<ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/4b36725b-ee8c-4e44-92b8-51fa745dba73/image.png" alt=""></p>
</li>
</ul>
<p>확인해보면 cost가 224에서 167로 상당히 감소한 것을 알 수 있다.</p>
<p>이처럼, 복합 인덱스는 쿼리 성능 향상에 큰 영향을 미치고, 복합 인덱스의 순서 또한 중요하다.</p>
<h2 id="페이징-구현">페이징 구현</h2>
<p>만약 hotels 테이블에 데이터가 100만건이 있는 상태에서 호텔을 검색하는 쿼리를 실행하면 어떻게 될까? 그렇게 되면 hotels 테이블의 모든 데이터 100만건을 조회하여 가져올 것이다.</p>
<p>하지만, 웹 서비스에서 테이블 전체 데이터를 한번에 요구하는 일은 거의 없다. 이때, 페이징 기법을 사용해서 조회한 결과를 필요한 만큼만 제한할 수 있다.</p>
<p>페이징 기법은 크게 Offset 방식, 커버링 인덱스 방식, 커서 방식 이렇게 세 가지가 있다. 이제 각 페이징 기법을 적용해보고, 장단점을 설명하도록 하겠다.</p>
<h3 id="offset-방식-페이징">Offset 방식 페이징</h3>
<p>Offset 방식은 가장 기본적인 페이징 기법으로, <code>LIMIT</code>과 <code>OFFSET</code>을 이용하여 페이징을 구현한다.</p>
<p>아래와 같이 Offset 방식을 이용하여 페이징을 구현해보자.</p>
<pre><code class="language-sql">SELECT *
FROM hotels
WHERE detail_region_id = 1
  AND category_id = 1
LIMIT 10 OFFSET 0;</code></pre>
<p>위 쿼리는 쿼리의 결과를 0번째 부터 10개만 가져오겠다는 쿼리이다. 위 쿼리의 실행 계획을 확인해보자.</p>
<ul>
<li><p><strong>Explain</strong> <strong>Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/8165a33c-946a-4786-8070-6816bfc3b3d9/image.png" alt=""></p>
</li>
</ul>
<p>결과를 확인해보면, 실제로 조회하는 row수가 10개인 것을 확인할 수 있다.</p>
<p>하지만, 만약 가장 끝 페이지를 조회해야 한다면 어떻게 될까?</p>
<pre><code class="language-sql">SELECT *
FROM hotels
WHERE detail_region_id = 1
  AND category_id = 1
LIMIT 10 OFFSET 400;</code></pre>
<p>위 쿼리의 실행 계획을 분석해보자.</p>
<ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/b3909da5-1996-4555-bf83-519b5546fbed/image.png" alt=""></p>
</li>
</ul>
<p>확인해보면 나는 10개의 데이터만 조회하고 싶었지만, 실제로 조회한 row수는 410개임을 볼 수 있다. 즉, 10개의 데이터만 읽고 싶지만 실제로는 모든 데이터를 읽어온 셈이다. 또한, 실행시간이 0.066ms 에서 0.918ms로 매우 크게 증가했다.</p>
<p>이처럼 Offset 페이징 방식은 끝 페이지에 있는 데이터를 읽을 수록 쿼리의 성능이 낮아지는 문제가 있다.</p>
<h3 id="커버링-인덱스-방식-페이징">커버링 인덱스 방식 페이징</h3>
<p>이번에는 커버링 인덱스 방식으로 이전 방식보다 성능을 개선해보자.</p>
<p>기존 쿼리를 아래와 같이 바꿔보자.</p>
<pre><code class="language-sql">SELECT *
FROM hotels as h
         JOIN (SELECT id
               FROM hotels as h2
               WHERE detail_region_id = 1
                 AND category_id = 1
               LIMIT 10 OFFSET 400) as temp
              ON temp.id = h.id;</code></pre>
<p>위 쿼리를 설명하면, 인덱스만으로 처리되는(커버링 인덱스) 서브 쿼리를 이용해서 id값들을 얻고, 그 id값들로 데이터들을 찾는다. 이제 위 쿼리의 실행 계획을 살펴보자.</p>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | key | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | PRIMARY | &lt;derived2&gt; | ALL | null | null | 410 | 100 | null |
| 1 | PRIMARY | h | eq_ref | PRIMARY | temp_hotel.id | 1 | 100 | null |
| 2 | DERIVED | hotels | ref | detail_regions_categories_idx | const,const | 410 | 100 | Using index |</code></pre><ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/367cf288-146a-402f-b5cd-af093344ee59/image.png" alt=""></p>
</li>
</ul>
<p>다이어 그램을 기반으로 쿼리를 분석해보면</p>
<ol>
<li>우선 <code>detail_regions_categories_idx</code> 인덱스를 이용해서 410개의 데이터를 스캔 후 400번째 부터 10개만 가져오고 나머지는 버린다. (<code>LIMIT 10 OFFSET 400</code>) 그리고 이 과정에서 커버링 인덱스가 적용된다.</li>
<li>그렇게 가져온 10개의 id를 가지고 loop를 돌며 hotels 테이블에서 해당 id에 해당하는 데이터들을 가져온다.</li>
</ol>
<p>이렇게 커버링 인덱스 방식 페이징을 적용시켜서 실행 시간을 0.918ms 에서 0.199ms로 많이 감소시켰다.</p>
<p>하지만, 커버링 인덱스 방식 페이징은 아래와 같은 단점이 있다.</p>
<ul>
<li>너무 많은 인덱스가 필요하다.</li>
</ul>
<h3 id="커서-방식-페이징">커서 방식 페이징</h3>
<p>만약 UI가 아래와 같거나, 무한 스크롤 방식이라면 어떨까?</p>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/55444e5b-7a19-4f0b-83d6-e5a6a9d65666/image.png" alt=""></p>
<p>위와 같은 경우에는 다음 페이지나 이전 페이지 밖에 없기 때문에 다음에 불러올 데이터의 시작 위치를 알 수 있다.</p>
<p>이렇게 시작 위치를 알 수 있다면, 기존 쿼리를 아래와 같이 바꿔볼 수 있다.</p>
<pre><code class="language-sql">SELECT *
FROM hotels
WHERE detail_region_id = 1
  AND category_id = 1
  AND id &gt; 192821 #직전 조회 결과의 마지막 id
LIMIT 10;</code></pre>
<p>위 쿼리의 실행 계획을 살펴보자.</p>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | hotels | index_merge | detail_regions_categories_idx,category_id | 24,16 | null | 1 | 100 | Using intersect(detail_regions_categories_idx, category_id); Using where |</code></pre><ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/1d289b6a-6731-42a3-be26-980208ad4a47/image.png" alt=""></p>
</li>
</ul>
<p>엇.. Cost는 매우 감소했지만, 실행시간은 오히려 증가한 모습이다. 위 결과를 통해 분석해보면 <code>category_id</code>인덱스로 가져온 결과와 <code>detail_regions_categories_idx</code>인덱스로 가져온 결과를 교집합 연산으로 합친다.</p>
<p>정확한 이유는 모르겠지만, 옵티마이저가 잘못된 인덱스를 사용하는 것 같다. 아래와 같이 기존 쿼리에 인덱스 힌트를 붙혀서 <code>detail_regions_categories_idx</code> 인덱스를 사용하도록 수정해보자.</p>
<pre><code class="language-sql">SELECT *
FROM hotels USE INDEX (detail_regions_categories_idx)
WHERE detail_region_id = 1
  AND category_id = 1
  AND id &gt; 192821 #직전 조회 결과의 마지막 id
LIMIT 10;</code></pre>
<ul>
<li><strong>Explain</strong></li>
</ul>
<pre><code>| id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | SIMPLE | hotels | range | detail_regions_categories_idx | 24 | null | 13 | 100 | Using index condition |</code></pre><ul>
<li><p><strong>Explain Analyze</strong></p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/dc3c4773-6f97-425f-80ce-2311c7f9a1db/image.png" alt=""></p>
</li>
</ul>
<p>인덱스 힌트를 사용하지 않은 쿼리보다 코스트는 증가하였지만, 실행시간이 확실히 감소하였다. 왜 이전 실행 계획의 코스트가 더 높은지는 더 알아보아야 할 것 같다.</p>
<p>그리고 커버링 인덱스 방식 페이징 보다 실행 시간이 0.199ms에서 0.08ms로 감소하였다.</p>
<p>커서 방식 페이징은 데이터의 수가 많아질 수록 성능 개선의 효과가 훨씬 증가한다. 하지만 이전 데이터의 id를 알아야 사용이 가능하기 때문에 UI를 이에 맞게 변경해야하는 단점이 있다.</p>
<h3 id="페이징-방식-결론">페이징 방식 결론</h3>
<p>각 서비스와 기능의 요구 사항에 따라 위 페이징 방식들 중 가장 적절한 방식을 선택하면 된다.</p>
<p>내가 구현 중인 호텔 검색 기능의 경우 사용자가 여러 페이지를 건너 뛰어야 하는 경우가 거의 없고, 대부분의 호텔 예약 사이트들이 무한 스크롤 방식을 사용하고 있으므로 커서 방식을 사용하는 것으로 결정하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[여기서 놀자] 더미 데이터 넣기]]></title>
            <link>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EB%8D%94%EB%AF%B8-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%84%A3%EA%B8%B0</link>
            <guid>https://velog.io/@jeong_hun_hui/%EC%97%AC%EA%B8%B0%EC%84%9C-%EB%86%80%EC%9E%90-%EB%8D%94%EB%AF%B8-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%84%A3%EA%B8%B0</guid>
            <pubDate>Sat, 14 Oct 2023 12:52:27 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 이 글은 대용량 처리 및 성능 개선 경험을 위해 진행하는 사이드 프로젝트 “여기서 놀자”의 더미 데이터를 삽입하는 과정을 정리한 글입니다.</p>
</blockquote>
<h3 id="데이터베이스-정보">데이터베이스 정보</h3>
<p>데이터베이스는 AWS의 RDS(MySQL)를 사용했다.</p>
<ul>
<li><strong>엔진 버전</strong>: 8.0.33 버전</li>
<li><strong>인스턴스 클래스</strong>: db.t3.micro</li>
</ul>
<h3 id="erd">ERD</h3>
<p><img src="https://velog.velcdn.com/images/jeong_hun_hui/post/2aa6a9de-1e5e-430c-9d5b-4d706ca408c6/image.png" alt=""></p>
<p>요구사항에 맞게 우선 위와 같이 ERD를 설계하였다.(추후 변경될 수 있다.)</p>
<h3 id="삽입할-데이터-양">삽입할 데이터 양</h3>
<table>
<thead>
<tr>
<th>테이블명</th>
<th>레코드 수</th>
</tr>
</thead>
<tbody><tr>
<td>users</td>
<td>80만</td>
</tr>
<tr>
<td>hotels</td>
<td>20만</td>
</tr>
<tr>
<td>rooms</td>
<td>약 60만</td>
</tr>
<tr>
<td>reservations</td>
<td>150만</td>
</tr>
</tbody></table>
<h3 id="db에-들어간-더미-데이터">DB에 들어간 더미 데이터</h3>
<p>더미 데이터는 프로시저를 통해 삽입하였으며, 그 과정은 이 글의 가장 아래에 정리하였다.</p>
<ul>
<li><p><strong>users</strong></p>
<p>  <strong>설명</strong>: 숙소 예약 서비스에 가입한 사용자, 사용자 1명당 여러개의 예약을 할 수 있으므로 <strong>reservations</strong> 테이블과 1 : N 관계</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/e453d63b-74f6-4a01-9cac-e5c37beb0c53/image.png" alt=""></p>
</li>
</ul>
<pre><code>이름은 한글 20글자 중 3글자 무작위로 조합해서 생성

전화번호, 이메일은 순서대로 생성

비밀번호는 user{id} 를 인코딩</code></pre><ul>
<li><p><strong>categories</strong></p>
<p>  <strong>설명</strong>: 숙소의 카테고리, 하나의 숙소 카테고리에는 여러개의 숙소가 있으므로 <strong>hotels</strong> 테이블과 1 : N 관계</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/89f1b9ee-9486-4e5a-8ba4-94a94db04fc1/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p><strong>regions</strong></p>
<p>  <strong>설명</strong>: 도 / 특별시 단위의 지역 정보, 각 지역에 속한 상세지역이 여러개 있으므로 <strong>detail_regions</strong> 테이블과 1 : N 관계</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/00a5aff4-d8fc-447c-9d63-e05c34c1ed71/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p><strong>detail_regions</strong></p>
<p>  <strong>설명</strong>: 더 상세한 단위의 지역 정보, 하나의 상세 지역에 여러 숙소가 있으므로 <strong>hotels</strong> 테이블과 1 : N 관계</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/85787e51-7d35-47c8-8d33-e1b0936a0d49/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p><strong>hotels</strong></p>
<p>  <strong>설명</strong>: 숙소의 정보, 하나의 숙소에는 여러개의 객실이 있으므로 <strong>rooms</strong> 테이블과 1 : N 관계</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/f6782246-9f08-4e69-8e1a-5ceabf1839a5/image.png" alt=""></p>
</li>
</ul>
<pre><code>이름은 3~5글자로 무작위로 생성

주소는 지역, 상세지역, 숙소 이름 정보를 활용하여 생성

평점은 1~5사이 소숫점 1자리 소수

소개글은 10%가 NULL, 나머지는 3종류의 소개글을 무작위로 작성

상세 지역과 카테고리는 무작위 선택</code></pre><ul>
<li><p><strong>rooms</strong></p>
<p>  <strong>설명</strong>: 객실의 정보, 하나의 숙소에는 여러건의 예약이 있으므로 <strong>reservations</strong> 테이블과 1: N 관계</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/d0029ee4-8b34-407c-a4ff-527a967a250d/image.png" alt=""></p>
</li>
</ul>
<pre><code>객실의 이름은 싱글룸, 더블룸, 트윈룸, 트리플룸, 패밀리룸, 스위트룸 이렇게 6가지 중 설정

각 숙소는 평균적으로 3종류의 객실을 가짐

객실의 가격은 객실의 종류와 숙소의 카테고리에 따라 달라짐

객실의 개수는 1~3개 사이 (객실의 종류마다 확률이 다름)

최대 인원수는 객실의 종류에 따라 달라짐</code></pre><ul>
<li><p><strong>reservations</strong></p>
<p>  <strong>설명</strong>: 예약 정보, 예약을 한 유저와 객실의 id, 숙소에 머무는 기간에 대한 정보를 담고있음</p>
<p>  <img src="https://velog.velcdn.com/images/jeong_hun_hui/post/04e31d79-3427-44cf-bd23-f02a81f2ef4f/image.png" alt=""></p>
</li>
</ul>
<h3 id="더미데이터-생성-프로시저">더미데이터 생성 프로시저</h3>
<ul>
<li><p>멀티 세션을 활용하면 더 빠르게 더미데이터 삽입 가능</p>
<pre><code class="language-sql">  SET autocommit=0;
  START TRANSACTION;
  CALL 프로시저();
  CALL 프로시저();
  CALL 프로시저();
  ...
  COMMIT;
  SET autocommit=1;</code></pre>
</li>
<li><p><strong>users</strong></p>
<pre><code class="language-sql">  DELIMITER //
  CREATE PROCEDURE generate_users(IN start_id INT, IN end_id INT)
  BEGIN
      DECLARE i INT DEFAULT start_id;
      DECLARE name_list VARCHAR(200) DEFAULT &#39;김이박최정강조윤장임오서신권황안송류홍고문양손배조백&#39;;
      WHILE i &lt;= end_id DO
              INSERT INTO users (name, email, phone, password)
              VALUES (
                         CONCAT(
                                 SUBSTRING(name_list, FLOOR(RAND() * 20) + 1, 1),
                                 SUBSTRING(name_list, FLOOR(RAND() * 20) + 1, 1),
                                 SUBSTRING(name_list, FLOOR(RAND() * 20) + 1, 1)
                             ),
                         CONCAT(&#39;user&#39;, i, &#39;@example.com&#39;),
                         CONCAT(&#39;010-&#39;, LPAD(i DIV 10000, 4, &#39;0&#39;), &#39;-&#39;, LPAD(i MOD 10000, 4, &#39;0&#39;)),
                         MD5(RAND())
                     );
              SET i = i + 1;
          END WHILE;
  END //
  DELIMITER ;</code></pre>
</li>
<li><p><strong>categories</strong></p>
<pre><code class="language-sql">  INSERT INTO categories (name) 
  VALUES (&#39;모텔&#39;), (&#39;호텔&#39;), (&#39;리조트&#39;), (&#39;펜션&#39;), (&#39;캠핑&#39;), (&#39;게스트하우스&#39;);</code></pre>
</li>
<li><p><strong>regions</strong></p>
<pre><code class="language-sql">  INSERT INTO regions (name)
  VALUES (&#39;서울&#39;), (&#39;부산&#39;), (&#39;제주&#39;), (&#39;경기&#39;), (&#39;인천&#39;), (&#39;강원&#39;), (&#39;경상&#39;), (&#39;전라&#39;), (&#39;충청&#39;);</code></pre>
</li>
<li><p><strong>detail_regions</strong> - 생략</p>
</li>
<li><p><strong>hotels</strong></p>
<ul>
<li><p>랜덤 이름 생성 함수</p>
<pre><code class="language-sql">CREATE FUNCTION `generate_hotel_name`() RETURNS VARCHAR(10)
BEGIN
  DECLARE i INT DEFAULT 0;
  DECLARE str_list_len INT DEFAULT 45;
  DECLARE name_len INT DEFAULT RAND() * 2 + 3;
  DECLARE str_list VARCHAR(200) DEFAULT &#39;가나다라마바사아자차카타파하고노도로모보소오조초코토포호구누두루무부수우주추쿠투푸후기니디리미비시이지치키티피히거너더러머버서어저처커터퍼허&#39;;
  DECLARE name VARCHAR(10) DEFAULT &#39;&#39;;
  WHILE i &lt; name_len DO
          SET name = CONCAT(name, SUBSTRING(str_list, FLOOR(RAND() * str_list_len) + 1, 1));
          SET i = i + 1;
      END WHILE;
  RETURN name;
END;</code></pre>
</li>
<li><p>프로시저</p>
<pre><code class="language-sql">DELIMITER //
CREATE PROCEDURE `generate_hotels`(IN p_num_rows INT)
BEGIN
  DECLARE i INT DEFAULT 0;
  DECLARE rand_name VARCHAR(100);
  DECLARE rand_category_id BIGINT;
  DECLARE rand_detail_region_id BIGINT;
  DECLARE rand_rating DECIMAL(2,1);
  DECLARE rand_content LONGTEXT;
  DECLARE rand_address VARCHAR(255);
  DECLARE region_name VARCHAR(255) DEFAULT NULL;
  DECLARE detail_region_name VARCHAR(255) DEFAULT NULL;
  DECLARE category_name VARCHAR(255) DEFAULT NULL;
  DECLARE rand_num INT;

  WHILE i &lt; p_num_rows DO
          SET rand_num = RAND() * 3;
          SET rand_category_id = FLOOR(1 + RAND() * 6);
          SET category_name = (SELECT categories.name FROM categories WHERE categories.id = rand_category_id);
          SET rand_name = CONCAT(generate_hotel_name(), &#39; &#39;, category_name);
          SET rand_detail_region_id = FLOOR(1 + RAND() * 75);
          SET detail_region_name = (SELECT detail_regions.name FROM detail_regions WHERE id = rand_detail_region_id);
          SET region_name = (SELECT regions.name FROM detail_regions join regions ON detail_regions.region_id = regions.id WHERE detail_regions.id = rand_detail_region_id);
          SET rand_address = CONCAT(CONVERT(region_name USING utf8), &#39; &#39;, CONVERT(detail_region_name USING utf8), &#39; &#39;, CONVERT(rand_name USING utf8));
          SET rand_rating = ROUND(RAND() * 4 + 1, 1);

          IF RAND() &lt; 0.1 THEN
              SET rand_content = NULL;
          ELSEIF rand_num &lt; 1 THEN
              SET rand_content = CONCAT(&#39;당신의 휴식과 여행을 위한 최적의 공간,  &#39;, rand_name, &#39;입니다. 저희 숙소는 고객의 만족을 위해 최선을 다하고 있습니다. 고객들이 가장 필요로 하는 편의시설과 서비스를 제공하기 위해 노력하며, 아늑하고 깨끗한 객실과 편안한 침구를 제공합니다. 또한, 매일 청소를 하여 깨끗하고 편안한 환경을 유지합니다. 저희는 훌륭한 위치와 탁월한 서비스로 고객들의 여행을 즐겁게 만들어줄 것입니다.&#39;);
          ELSEIF rand_num &lt; 2 THEN
              SET rand_content = CONCAT(&#39;최신 시설과 아름다운 자연이 조화를 이루는 최적의 휴식처, &#39;, rand_name, &#39;. &#39;, rand_name, &#39;은 지역에서 가장 멋진 경관을 자랑합니다. 객실 내부는 편안하고 넓은 공간으로 구성되어 있으며, 모던한 디자인과 고급스러운 인테리어가 돋보입니다. 휴식을 취하며 편안한 휴가를 보내기에는 최적의 장소이니, 언제든지 편안한 휴식을 취하실 수 있습니다.&#39;);
          ELSE
              SET rand_content = CONCAT(&#39;쾌적하고 세련된 분위기를 자랑하는 &#39;, rand_name, &#39;은 매일 아침 제공되는 풍성한 조식과 함께 편안한 휴식을 취할 수 있는 공간을 제공합니다. 유럽풍의 인테리어와 편안한 침구류로 꾸며진 객실은 숙면에 최적화된 공간이며, 다양한 관광지와 쇼핑몰이 밀집한 지역에서의 이동성도 용이합니다. 또한 레스토랑에서는 지역적인 식재료와 요리를 즐길 수 있으며, 주변에는 다양한 레스토랑과 카페가 위치하고 있습니다. 여행자들의 편안하고 즐거운 여행을 위해 최선을 다하는 &#39;, rand_name, &#39;에서 편안하고 기억에 남을 숙박을 즐겨보세요.&#39;);
          END IF;

          INSERT INTO hotels (name, address, rating, content, detail_region_id, category_id)
          VALUES (rand_name, rand_address, rand_rating, rand_content, rand_detail_region_id, rand_category_id);
          SET i = i + 1;
      END WHILE;
END //
DELIMITER ;</code></pre>
</li>
</ul>
</li>
<li><p><strong>rooms</strong> - 생략</p>
</li>
<li><p><strong>reservations</strong> - 생략</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 10장] 실행 계획]]></title>
            <link>https://velog.io/@jeong_hun_hui/Real-MySQL-10%EC%9E%A5-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D</link>
            <guid>https://velog.io/@jeong_hun_hui/Real-MySQL-10%EC%9E%A5-%EC%8B%A4%ED%96%89-%EA%B3%84%ED%9A%8D</guid>
            <pubDate>Wed, 13 Sep 2023 11:11:36 GMT</pubDate>
            <description><![CDATA[<h2 id="통계-정보">통계 정보</h2>
<p>통계 정보는 MySQL의 실행 계획에 가장 큰 영향을 미친다.</p>
<p>통계 정보를 <strong>테이블 및 인덱스에 대한 통계 정보</strong>와 <strong>히스토그램</strong>으로 나누어 살펴보자.</p>
<h3 id="테이블-및-인덱스-통계-정보">테이블 및 인덱스 통계 정보</h3>
<ul>
<li><strong>MySQL 서버의 통계 정보</strong>
MySQL 5.5 버전 까지는 각 테이블의 통계 정보가 메모리에서만 관리되어서 서버 재시작시 모두 사라졌다.
이후의 버전에서는 각 테이블의 통계 정보를 <code>mysql</code> 데이터베이스의 <code>innodb_index_stats</code> 테이블과 <code>innodb_index_stats</code> 테이블로 관리할 수 있게되어 재시작 되어도 통계 정보가 유지된다.
특정 테이블의 통계 정보를 영구적으로 관리하고 싶지 않을 경우 테이블 생성 시 <code>STATS_PERSISTENT</code>를 0으로 설정하면 된다.
<code>STATS_PERSISTENT</code>를 설정하지 않으면 <code>innodb_stats_persistent</code> 시스템 변수의 값에 따라 결정한다. (ON이면 영구 저장, OFF면 영구 저장 X)<pre><code class="language-sql">  // employees 테이블의 인덱스 통계 정보
  SELECT *
  FROM innodb_index_stats
  WHERE database_name=&#39;employees&#39;
      AND TABLE_NAME=&#39;employees&#39;;</code></pre>
<img src="https://github.com/JeongHunHui/TIL/assets/108508730/24aad6d2-099c-41af-898b-9a4b28ca0dd7" alt="image">
<code>employees</code> 테이블에 있는 인덱스들의 통계 정보는 위와 같이 저장되어 있다.</li>
<li><code>stat_name=’n_diff_pfx%’</code>: 인덱스가 가진 유니크한 값의 개수</li>
<li><code>stat_name=’n_leaf_pages’</code>: 인덱스의 리프 노드 페이지 개수</li>
<li><code>stat_name=’size’</code>: 인덱스 트리의 전체 페이지 개수<pre><code class="language-sql">  // employees 테이블의 통계 정보
  SELECT *
  FROM innodb_table_stats
  WHERE database_name=&#39;employees&#39;
      AND TABLE_NAME=&#39;employees&#39;;</code></pre>
<img src="https://github.com/JeongHunHui/TIL/assets/108508730/3f0c23a7-ca4c-4994-98d5-32cd3f808f2a" alt="image">
<code>employees</code> 테이블의 통계 정보는 위와 같이 저장되어 있다.</li>
<li><code>n_rows</code>: 테이블의 전체 레코드 건수</li>
<li><code>clustered_index_size</code>: 프라이머리 키의 크기(InnoDB 페이지 개수)</li>
<li><code>sum_of_other_index_sizes</code>: 프라이머리 키를 제외한 인덱스의 크기(InnoDB 페이지 개수)</li>
</ul>
<p>통계 정보는 아래와 같은 이벤트들이 발생하면 갱신된다.</p>
<ul>
<li><p>테이블이 새로 오픈되는 경우</p>
</li>
<li><p>테이블의 레코드가 대량으로 변경되는 경우</p>
</li>
<li><p><code>ANALYZE TABLE</code> 명령이 실행되는 경우</p>
</li>
<li><p><code>SHOW TABLE STATUS</code> 명령이나 <code>SHOW INDEX FROM</code> 명령이 실행되는 경우</p>
<p>  하지만 갑자기 통계 정보가 변경되면 의도치 않게 실행 계획이 변경되는 문제가 발생할 수 있는데, <code>innodb_stats_auto_recalc</code> 시스템 변수의 값을 OFF로 설정하면 이를 막을 수 있다.
  또한, 통계 정보를 자동으로 수집할지 여부도 옵션을 통해 테이블 단위로 조정할 수 있다.
  통계 정보를 수집할 때 몇 개의 InnoDB 테이블 블록을 샘플링할지 설정하는 시스템 변수가 2개있다.</p>
<ul>
<li><code>innodb_stats_transient_sample_pages</code>: 기본값=8, 자동으로 통계가 수집될 때 8개의 페이지만 분석하여 통계 정보로 활용함을 의미한다.</li>
<li><code>innodb_stats_persistent_sample_pages</code>: 기본값=20, ANALYZE TABLE 명령이 실행되면 20개의 페이지만 분석하여 통계 정보로 활용함을 의미한다.</li>
</ul>
</li>
</ul>
<p>→ 정확도를 높히고 싶다면 위 시스템 변수 값을 올리면 되지만, 통계 수집 시간이 길어지므로 주의해야한다.</p>
<h3 id="히스토그램"><strong>히스토그램</strong></h3>
<p>기존 통계 정보만으로는 최적의 실행 계획을 수립하기에는 많이 부족했다.</p>
<p>MySQL 8.0부터는 컬럼의 데이터 분포도를 참조할 수 있는 히스토그램을 활용할 수 있다.</p>
<p>히스토그램 정보는 컬럼 단위로 관리되는데, 이는 자동으로 수집되지 않고 <code>ANALYZE TABLE … UPDATE HISTOGRAM</code> 명령을 실행해 수동으로 수집된다.</p>
<p>이 히스토그램 정보를 조회하려면 <code>column_statistics</code> 테이블을 <code>SELECT</code>하면 된다.</p>
<p>히스토그램은 싱글톤 히스토그램, 높이 균형 히스토그램 두 가지가 지원된다.</p>
<h3 id="코스트-모델">코스트 모델</h3>
<p>전체 쿼리의 비용을 계산하는데 필요한 단위 작업들의 비용을 코스트 모델이라고 한다.</p>
<p>MySQL의 코스트 모델은 다음 2개 테이블에 저장되어 있는 설정값을 사용한다.</p>
<ul>
<li><code>server_cost</code>: 인덱스를 찾고 레코드를 비교하고 임시 테이블 처리에 대한 비용 관리</li>
<li><code>engine_cost</code>: 레코드를 가진 데이터 페이지를 가져오는데 필요한 비용 관리</li>
</ul>
<p>두 테이블은 아래 5개의 컬럼을 공통으로 가지고있다.</p>
<ul>
<li><code>cost_name</code>: 코스트 모델의 각 단위 작업</li>
<li><code>default_value</code>: 각 단위 작업의 비용(기본값, MySQL 서버 소스 코드에 설정된 값)</li>
<li><code>cost_value</code>: DBMS 관리자가 설정한 값(NULL이면 <code>default_value</code> 값 사용)</li>
<li><code>last_updated</code>: 단위 작업의 비용이 변경된 시점</li>
<li><code>comment</code>: 비용에 대한 추가 설명</li>
</ul>
<p><code>engine_cost</code> 테이블은 아래 2개의 컬럼을 더 가지고 있다.</p>
<ul>
<li><code>engine_name</code>: 비용이 적용된 스토리지 엔진</li>
<li><code>device_type</code>: 디스크 타입</li>
</ul>
<p>단위 작업의 종류는 아래와 같이 8개가 있다.</p>
<table>
<thead>
<tr>
<th>테이블 이름</th>
<th>cost_name</th>
<th>default_value</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>engine_cost</td>
<td>io_block_read_cost</td>
<td>1</td>
<td>디스크 데이터 페이지 읽기</td>
</tr>
<tr>
<td>engine_cost</td>
<td>memory_block_read_cost</td>
<td>0.25</td>
<td>메모리 데이터 페이지 읽기</td>
</tr>
<tr>
<td>server_cost</td>
<td>disk_temptable_cost</td>
<td>20</td>
<td>디스크 임시 테이블 생성</td>
</tr>
<tr>
<td>server_cost</td>
<td>disk_temptable_row_cost</td>
<td>0.5</td>
<td>디스크 임시 테이블의 레코드 읽기</td>
</tr>
<tr>
<td>server_cost</td>
<td>key_compare_cost</td>
<td>0.05</td>
<td>인덱스 키 비교</td>
</tr>
<tr>
<td>server_cost</td>
<td>memory_temptable_create_cost</td>
<td>1</td>
<td>메모리 임시 테이블 생성</td>
</tr>
<tr>
<td>server_cost</td>
<td>memory_temptable_row_cost</td>
<td>0.1</td>
<td>메모리 임시 테이블의 레코드 읽기</td>
</tr>
<tr>
<td>server_cost</td>
<td>row_evaluate_cost</td>
<td>0.1</td>
<td>레코드 비교</td>
</tr>
</tbody></table>
<p>코스트 모델은 각 단위 작업에 설정되는 비용이 커지면 어떤 실행 계획의 비용이 변하는지 파악하는 것이 중요하다.</p>
<p>웬만하면 위 테이블들의 <code>default_value</code>를 바꾸지 말자</p>
<hr>
<h2 id="실행-계획-확인">실행 계획 확인</h2>
<p>MySQL의 실행 계획은 <code>DESC</code> 또는 <code>EXPLAIN</code> 명령으로 확인할 수 있다.</p>
<p>실행 계획의 포맷은 아래와 같이 테이블, 트리, JSON 3가지 중 하나를 선택할 수 있다.</p>
<ul>
<li><code>EXPLAIN [FORMAT=TREE or JSON]</code> (테이블이 기본값)</li>
</ul>
<h3 id="쿼리의-실행-시간-확인">쿼리의 실행 시간 확인</h3>
<p><code>EXPLAIN ANALYZE</code> 명령으로 쿼리의 실행 계획과 단계별 소요된 시간 정보를 확인할 수 있다.</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT e.emp_no, avg(s.salary)
FROM employees e
    INNER JOIN salaries s ON s.emp_no=e.emp_no
                         AND s.salary&gt;50000
                         AND s.from_date&lt;=&#39;1990-01-01&#39;
                         AND s.to_date&gt;&#39;1990-01-01&#39;
WHERE e.first_name=&#39;Matt&#39;
GROUP BY e.hire_date;</code></pre>
<p>위 쿼리의 결과는 아래와 같다.</p>
<pre><code class="language-sql">A) -&gt; Table scan on &lt;temporary&gt; (actual time=0.001..0.004 rows=48 loops=1)
B)     -&gt; Aggregate using temporary table (actual time=3.799. .3.808 rows=48 loops=1)
C)         -&gt; Nested loop inner join (cost=685.24 rows=135)
                         (actual time=0.367..3.602 rows=48 loops=1)
D)             -&gt; Index lookup on e using ix_firstname (first_name=&#39;Matt&#39;) (cost=215.08 rows=233)
                         (actual time 0.348..1.046 rows=233 loops=1)
E)             -&gt; Filter: ((s.salary &gt; 50000) and (s.from_date &lt;= DATE&#39; 1990-01-01&#39;)
                                                                            and (s.to_date &gt; DATE&#39; 1990-01-01&#39;)) (cost=0.98 rows=1)
                         (actual time 0.009..0.011 rows=0 loops=233)
F)                 -&gt; Index lookup on s using PRIMARY (emp_no=e.emp_no) (cost=0.98 rows=10)
                         (actual time=0.007..0.009 rows=10 loops=233)</code></pre>
<p>위와 같은 TREE 포맷의 실행 계획에서 들여쓰기는 호출 순서를 의미하며 규칙은 아래와 같다.</p>
<ul>
<li>들여쓰기가 같은 레벨에서는 상단에 위치한 라인이 먼저 실행</li>
<li>들여쓰기가 다른 레벨에서는 가장 안쪽에 위치한 라인이 먼저 실행</li>
</ul>
<p>위 실행 계획을 풀어서 설명하면</p>
<ol>
<li><strong>D</strong>, <code>employees</code> 테이블의 <code>ix_firstname</code> 인덱스를 통해 <code>first_name=’Matt’</code> 조건에 일치하는 레코드를 찾는다.</li>
<li><strong>F</strong>, <code>salaries</code> 테이블의 PK를 이용해 1번 결과의 <code>emp_no</code>와 같은 <code>emp_no</code>를 가진 레코드를 <code>s</code>테이블에서 찾는다.</li>
<li><strong>E</strong>, 2번 결과를 <code>s.salary &gt; 50000 and s.from_date &lt;= DATE&#39;1990-01-01&#39; and s.to_date &gt; DATE&#39;1990-01-01&#39;</code> 조건으로 필터링한다.</li>
<li><strong>C</strong>, 1번 결과와 3번 결과를 조인한다.</li>
<li><strong>B</strong>, 임시 테이블에 결과를 저장하며 <code>GROUP BY</code> 집계를 실행한다.</li>
<li><strong>A</strong>, 임시 테이블의 결과를 읽어서 결과를 반환한다.</li>
</ol>
<pre><code class="language-sql">F) -&gt; Index lookup on s using PRIMARY (emp_no=e.emp_no) (cost=0.98 rows=10)
         (actual time=0.007..0.009 rows=10 loops=233)</code></pre>
<p>실행 계획의 F라인을 자세히 분석해보자.</p>
<ul>
<li><p><code>actual time=0.007 ..0.009</code>
<code>employees</code> 테이블에서 읽은 <code>emp_no</code> 값을 기준으로 <code>salaries</code> 테이블에서 일치하는 레코드를 검색하는 데 걸린 시간(밀리초)을 의미한다.</p>
<p>  첫 번째 숫자는 첫 번째 레코드를 가져오는 데 걸린 평균 시간, 두 번째 숫자는 마지막 레코드를 가져오는데 걸린 평균 시간을 의미한다.</p>
</li>
<li><p><code>rows=10</code>
<code>employees</code> 테이블에서 읽은 <code>emp_no</code>에 일치하는 <code>salaries</code>테이블의 평균 레코드 건수를 의미한다.</p>
</li>
<li><p><code>loops=233</code>
<code>employees</code>테이블에서 읽은 <code>emp_no</code>를 이용해 <code>salaries</code>테이블의 레코드를 찾는 작업이 반복된 횟수를 의미한다.
→ <code>employees</code>테이블에서 읽은 <code>emp_no</code>의 개수가 233개임을 의미한다.</p>
</li>
</ul>
<p>→ <code>salaries</code> 테이블에서 <code>emp_no</code>일치 건을 찾는 작업을 233번 반복했는데, 매번 <code>salaries</code> 테이블에서 첫 번째 레코드를 가져오는데 0.007 밀리초가, 10개의 레코드를 모두 가져오는 데 0.009 밀리초가 걸린 것이다.</p>
<h3 id="실행-계획-분석">실행 계획 분석</h3>
<ul>
<li><p><strong>id 컬럼</strong>:</p>
<p>  실행 계획의 id 컬럼은 단위 SELECT 쿼리별로 부여되는 식별자 값이다.</p>
<p>  만약 하나의 SELECT에서 여러 테이블을 조인하면 조인 테이블 수 만큼 실행 계획 레코드가 출력되지만 같은 id가 부여된다.</p>
<p>  id가 같은 레코드들은 조인이 레코드 순서대로 실행된다.</p>
</li>
</ul>
<ul>
<li><p><strong>select_type 컬럼</strong>:</p>
<p>  각 단위 SELECT 쿼리가 어떤 타입의 쿼리인지 표시되는 컬럼이다.</p>
<ul>
<li><strong>SIMPLE</strong>
UNION이나 서브쿼리를 사용하지 않는 단순한 SELECT 쿼리의 경우 <code>select_type</code>이 <code>SIMPLE</code>이다.
<code>SIMPLE</code> 쿼리는 하나만 존재하고, 일반적으로 제일 바깥 <code>SELECT</code> 쿼리이다.</li>
</ul>
</li>
</ul>
<pre><code>- **PRIMARY**
`UNION`이나 서브쿼리를 사용하는 `SELECT` 쿼리의 가장 바깥쪽 쿼리는 `select_type`이 `PRIMARY`이다.


- **UNION**
`UNION`으로 결합하는 단위 `SELECT` 쿼리 가운데 첫 번째 이후 쿼리의 `select_type`은 `UNION`으로 표시된다. 첫 번째 쿼리는 `DERIVED`로 표시된다. 왜냐하면 여러 쿼리의 결과를 합치기 위한 임시 테이블이 필요하기 때문이다.


- **DEPENTENT UNION**
이 경우도 `UNION`으로 결합하는 쿼리에서 표시되지만, 외부 쿼리의 영향을 받는 경우를 말한다.


- **UNION RESULT**
결과를 버퍼링 하는 임시 테이블의 `select_type`이 `UNION RESULT`이다.


- **SUBQUERY**
`FROM`절 이외에서 사용되는 서브쿼리만을 의미한다.
`FROM`절에 사용된 서브쿼리는 `DERIVED`로 표시된다.


- **DEPENTENT SUBQUERY**
서브쿼리가 바깥쪽에 정의된 컬럼을 사용하는 경우 표시된다.


- **DERIVED**
`DERIVED`는 단위 `SELECT` 쿼리의 결과로 메모리나 디스크에 임시 테이블을 생성하는 것을 의미한다.


- **DEPENTENT DERIVED**
`LATERAL JOIN`을 통해 `FROM`절 서브쿼리에서 외부 컬럼을 참조할 수 있는데, 이때 나오는 `select_type`이다.


- **UNCACHEABLE SUBQUERY**
같은 서브쿼리가 여러번 실행될 때는 이전 실행 결과를 그대로 사용하도록 서브쿼리의 결과를 캐시한다.
하지만 캐시를 사용하지 못하는 경우 `select_type`이 `UNCACHEABLE SUBQUERY`이다.


- **UNCACHEABLE UNION**
`UNION` + `UNCACHEABLE`


- **MATERIALIZED**
주로 FROM절이나 IN(subquery) 형태의 쿼리에 사용된 서브쿼리의 최적화를 위해 사용된다.
서브쿼리 내용을 임시 테이블로 구체화한 뒤 임시 테이블과 employees 테이블을 조인하는 형태로 최적화되어 처리된다.</code></pre><ul>
<li><p><strong>table 컬럼</strong>:</p>
<p>  실행 계획은 테이블 기준으로 표시된다. table 컬럼에는 실행 계획의 테이블 이름이 들어간다.</p>
<p>  <derived N>, &lt;union M,N&gt; 과 같이 “&lt;&gt;”로 둘러싸인 경우는 임시 테이블을 의미하고, 안의 숫자는 id값을 지칭한다.</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/e754819a-baaa-49ae-867c-7e01097b3bac" alt="image"></p>
<p>  만약 위와 같은 실행 계획이 있다고 하면 <code>&lt;derived2&gt;</code>의 경우는 id가 2인 <code>SELECT</code>의 결과를 의미한다.</p>
<p>  즉, 2번 <code>SELECT</code>문에서 생성된 임시테이블과 <code>e</code>테이블을 조인한 것이 결과가 된다.</p>
<p>  <code>select_type</code>이 <code>MATERIALIZED</code>인 실행 계획에서는 <code>&lt;subquery N&gt;</code>과 같은 값이 표시된다.</p>
</li>
</ul>
<ul>
<li><p><strong>partitions 컬럼</strong>:</p>
<p>  불필요한 파티션을 빼고 쿼리를 수행하기 위해 접근해야 할 테이블만 골라내는 과정을 파티션 프루닝이라 한다.</p>
<p>  파티션 컬럼은 해당 쿼리가 어느 파티션에 접근했다는 것을 알려준다.</p>
</li>
</ul>
<ul>
<li><p><strong>type 컬럼</strong>:</p>
<p>  type 컬럼은 각 테이블의 접근 방법이라고 생각하면 된다.</p>
<p>  총 12가지 방법이 있으며, <code>ALL</code>을 제외한 나머지 타입은 모두 인덱스를 사용한다.</p>
<ul>
<li><p><strong>system</strong>:</p>
<p>  레코드가 1건만 존재하는 테이블 또는 한 건도 존재하지 않는 테이블을 참조하는 형태의 방법을 말한다.</p>
<p>  InnoDB 스토리지 엔진을 사용하는 테이블에서는 나타나지 않는다.</p>
</li>
</ul>
</li>
</ul>
<pre><code>- **const**:

    PK나 유니크키 컬럼을 이용하는 `WHERE`절을 가지고 있고, 1건만 반환하는 방식의 쿼리를 말한다.

    type이 `const`인 실행 계획은 옵티마이저가 쿼리를 최적하며 먼저 실행해서 통째로 상수화한다.

    예를들어 `SELECT name FROM user WHERE id=1;` 이런 서브쿼리가 있다면 이 서브쿼리를 통째로 `‘name1&#39;`으로 상수화 하는 것이다.


- **eq_ref**:

    조인에서 처음 읽은 테이블의 컬럼값을 다음 읽을 테이블의 PK나 유니크키 컬럼 검색 조건에 사용할 때의 접근 방법을 `eq_ref`라고 한다.

    즉, 조인에서 두 번째 이후에 읽는 테이블에 조건에 맞는 레코드가 1건만 존재한다는 보장이 있어야한다.


- **ref**:

    `ref` 접근 방법은 인덱스의 종류와 상관없이 동등 조건으로 검색할 때 사용된다.

    레코드가 반드시 1건이란 보장이 없으므로 `const`나 `eq_ref`보다 느리지만 매우 빠른 조회 방법이다.

- **fulltext**:

    `fulltext` 접근 방법은 전문 검색 인덱스를 사용해 레코드를 읽는 방법을 의미한다.


- **ref_or_null**:

    `ref` 방식 또는 `NULL`비교 접근 방법을 의미한다. 잘 사용되진 않지만 나쁘지 않은 접근 방법이다.


- **unique_subquery**:

    `WHERE`절에서 사용될 수 있는 `IN(subquery)` 형태의 쿼리를 위한 접근 방법이다.

    말 그대로 서브쿼리에서 중복되지 않은 값만 반환할 때 이 방법을 사용한다.


- **index_subquery**:

    `unique_subquery`와 비슷하지만 중복된 값이 있을 수 있어서 중복 제거 작업이 필요한 경우 사용되는 접근 방법이다.

    MySQL 8.0 버전에서는 세미조인을 최적화 하는 많은 기능이 생겨 `unique_subquery`와 `index_subquery`은 잘 보이지 않는다.


- **range**:

    인덱스 레인지 스캔 형태의 접근 방법이다. 나쁘지 않은 접근 방법이다.


- **index_merge**:

    여러개의 인덱스를 이용해 검색 결과를 만들어낸 뒤 그 결과를 병합해서 처리하는 방식이다.

    이 실행 계획은 여러 인덱스를 읽어야하고, 두 결과를 가지고 교집합, 합집합, 중복 제거와 같은 부가적인 작업이 더 필요하기 때문에 그렇게 효율적이지 않다.

    `index_merge` 접근 방법이 이용되면 Extra 컬럼에 추가적인 내용이 표시된다.


- **index**:

    `index` 접근 방법은 인덱스 풀 스캔을 의미한다.

    `LIMIT` 조건이 없거나 가져올 레코드 건수가 많으면 상당히 느린 처리를 수행한다.


- **ALL**:

    풀 테이블 스캔을 의미하는 접근 방법이다.

    가장 비효율적인 방법이다.</code></pre><ul>
<li><p><strong>possible_keys 컬럼</strong>:</p>
<p>  옵티마이저가 사용을 고려했던 인덱스의 목록들을 담고있는 컬럼이다.</p>
<p>  즉, 사용되지 않은 인덱스들이 들어있고, 그냥 무시해도 되는 컬럼이다.</p>
</li>
<li><p><strong>key 컬럼</strong>:</p>
<p>  옵티마이저가 최종으로 선택한 인덱스를 담고있는 컬럼이다.</p>
</li>
<li><p><strong>key_len 컬럼</strong>:</p>
<p>  <code>key_len</code> 컬럼은 ****쿼리를 처리하기 위해 다중 컬럼으로 구성된 인덱스에서 몇 바이트 까지 썼는지를 의미한다.</p>
</li>
<li><p><strong>ref 컬럼</strong>:</p>
<p>  접근 방법이 <code>ref</code>면 참조 조건으로 어떤 값이 제공됐는지 보여준다. 상숫값을 지정했다면 <code>const</code>, 다른 테이블의 컬럼 값이면 그 테이블이름과 컬럼이름이 표시된다.</p>
<p>  <code>ref</code> 컬럼의 값이 <code>func</code>면 값에 연산을 거쳐서 참조했다는 것을 의미한다.</p>
</li>
<li><p><strong>rows 컬럼</strong>:</p>
<p>  rows 컬럼은 인덱스를 사용하는 조건에만 일치하는 레코드 건수를 예측한 값이다.</p>
</li>
<li><p><strong>filtered 컬럼</strong>:</p>
<p>  fintered 컬럼은 인덱스를 사용한 조건으로 걸러진 레코드들 중 인덱스를 사용하지 못하는 조건으로 인해 필터링되고 남은 레코드의 비율을 의미한다.</p>
<p>  즉, <code>rows</code>가 233이고, <code>filtered</code>가 16.03이면 결과 레코드 건수는 233 * 0.1603 = 37이 된다.</p>
</li>
<li><p><strong>Extra 컬럼</strong>:</p>
<p>  쿼리의 실행 계획에서 성능에 관련된 중요한 내용이 Extra 컬럼에 자주 표시된다.</p>
<ul>
<li><p><strong>const row not found</strong>:</p>
<p>  const 접근 방법으로 테이블을 읽었지만 레코드가 1건도 없을 때 표시된다.</p>
</li>
</ul>
</li>
</ul>
<pre><code>- **Distinct**:

    ```sql
    SELECT DISTINCT d.dept_no
    FROM departments d, dept_emp de WHERE de.dept_no=d.dept_no;
    ```

    ![image](https://github.com/JeongHunHui/TIL/assets/108508730/2956fc04-4040-4935-b5f7-ab28b4ccfcc5)

    `Extra` 컬럼에 `Distinct`가 표시되면 위와 같이 처리된다.

    만약 `departments` 테이블을 조회하는데, 조건에 `dept_emp` 테이블에 존재하는 `dept_no`를 가져야 하는 경우 위와 같이 일부 레코드만 조인한다.

- **FirstMatch, LooseScan**:

    세미조인 전략 중 FirstMatch, LooseScan 전략이 사용되면 표시된다.


- **Full scan on NULL key**:

    `col1 IN (SELECT col2 FROM …)` 과 같은 조건을 가진 쿼리의 실행 계획에서 표시될 수 있다.

    `Full scan on NULL key`는 위와 같은 쿼리에서 `col1`이 `NULL`일때 발생할 수 있고, 결과를 가지는지 확인하기 위해 서브쿼리 테이블에 대해 풀 스캔을 할 것이라는 것을 의미한다.


- **Impossible HAVING, WHERE**:

    `HAVING`, `WHERE`절의 조건이 무조건 `FALSE`가 나오는 경우 표시된다.


- **No matching min/max row**:

    `MIN`이나 `MAX`함수의 대상이 `NULL`일 때 표시된다.


- **no matching row in const table**:

    `const` 방법으로 접근할 때 일치하는 레코드가 없을 때 표시된다.


- **No matching rows after partition pruning**:

    파티션된 테이블에 대한 `UPDATE` or `DELETE` 명령의 실행 계획에서 해당 파티션이 없을 때 표시된다.


- **Not exists**:

    A 테이블에는 존재하지만 B 테이블에는 없는 값을 조회할 경우 `NOT IN(subquery) 나 NOT EXISTS` 연산자를 주로 사용하는데, 이러한 형태의 조인을 안티 조인이라고 한다.

    ```sql
    SELECT *
    FROM dept_emp de
        LEFT JOIN departments d ON de.dept_no=dept_no
    WHERE d.dept_no IS NULL;
    ```

    레코드 건수가 많을 때는 위와 같이 아우터 조인을 이용해서 안티 조인을 구현하는 것이 빠른데, 이렇게 아우터 조인을 이용해 안티 조인을 수행하는 쿼리의 경우 `Not exists`가 표시된다.

    `Not exists`의 의미는 옵티마이저가 조인 시 `departments` 테이블의 레코드가 존재여부만 판단한다는 것을 의미한다.

    즉, 조건에 일치하는 레코드가 여러 건 있어도 1건만 조회해보고 처리를 완료하는 최적화를 의미한다.


- **Plan ins’t ready yet**:

    아직 쿼리의 실행 계획이 수립되지 않았을 때 표시된다.


- **Range checked for each record(index map: N)**:

    ```sql
    SELECT *
    FROM employees e1, employees e2
    WHERE e2.emp_no &gt;= e1.emp_no;
    ```

    위 쿼리는 WHERE절의 조인 조건에 변수만 있기 때문에 인덱스 레인지 스캔과 풀 테이블 스캔 중 어느 것이 효율적인지 판단할 수 없다.

    즉, 의미 그대로 레코드 마다 인덱스 레인지 스캔을 체크한다는 뜻이다.

    `index map: N` 의 경우는 사용할 인덱스의 후보에 대한 정보를 담고있다.


- **Recursive**:

    CTE를 이용한 재귀 쿼리의 실행 계획의 Extra 컬럼에서 표시된다.


- **Rematerialize**:

    래터럴 조인 시 조인되는 테이블은 선행 테이블의 레코드별로 서브쿼리를 실행해서 그 결과를 임시 테이블에 저장하는데, 이 과정을 `Rematerialining`이라고 한다.

    결국 각 레코드 마다 새 내부 임시 테이블이 생성되는데 이때 `Rematerialize`가 표시된다.


- **Select tables optimized away**:

    `MIN()` 또는 `MAX()`만 `SELECT` 절에 사용되거나 `GROUP BY`로 `MIN()`, `MAX()`를 조회하는 쿼리가 인덱스를 이용해 1건만 읽는 형태의 최적화가 적용되면 표시된다.


- **Start temporary, End temporary**:

    세미 조인 최적화 중 Duplicate Weed-out 전략이 사용되면 표시된다.

    이 전략은 중복제거를 위해 내부 임시 테이블을 사용하는데, 어떤 테이블들이 조인되어 임시 테이블에 저장되는지 알 수 있게 첫 테이블에 `Start temporary`, 마지막 테이블에 `End temporary`를 표시한다.


- **unique row not found**:

    두 개의 테이블이 각각 유니크 컬럼으로 아우터 조인을 수행하는 쿼리에서 아우터 테이블에 일치하는 레코드가 존재하지 않을 때 표시된다.


- **Using filesort**:

    `ORDER BY` 처리가 인덱스를 사용하지 못할 때 표시된다.

    Using filesort가 표시되는 쿼리는 많은 부하를 일으키므로 튜닝이 필요하다.


- **Using index(커버링 인덱스)**:

    인덱스만 읽어서 쿼리를 모두 처리할 수 있을 때(커버링 인덱스) 표시된다.

    인덱스로만 쿼리를 처리할 수 있으면 디스크 접근이 필요 없어지므로 성능이 향상된다.


- **Using index condition**:

    옵티마이저가 인덱스 컨디션 푸시 다운 최적화를 사용하면 표시된다.


- **Using index for group-by**:

    인덱스를 사용하여 `GROUP BY` 처리를 수행하면 별도의 정렬 작업이 필요 없어지고, 인덱스의 필요한 부분만 읽으면(루스 인덱스 스캔) 되므로 성능이 향상되는데, 이 때 `Using index for group-by`메시지가 표시된다.

    인덱스를 이용하여 `GROUP BY`를 처리할 수 있더라도 `AVG()`, `SUM()` 처럼 조회하려는 값이 모든 인덱스를 다 읽어야 할 경우 루스 인덱스 스캔이 불가능하다.

    이 경우에는 `Using index for group-by`메시지가 표시되지 않는다.

    참고로, 루스 인덱스 스캔은 대량의 레코드를 `GROUP BY` 하는 경우엔 성능 향상효과가 있지만 레코드 건수가 적으면 루스 인덱스 스캔을 사용하지 않아도 빠르게 처리가 가능하므로 무조건 좋은 것은 아니다.

- **Using index for skip scan**:

    인덱스 스킵 스캔 최적화를 사용할 때 표시된다.


- **Using join buffer (hash join, Batched Key Access)**:

    조인 버퍼가 사용되는 실행 계획에 표시된다.


- **Using MRR**:

    MRR 최적화를 사용할 경우 표시된다.


- **Using union, sort_union, intersect**:

    `index_merge` 접근 방법으로 실행되는 경우 어떤 방식의 `index_merge`인지 알려주기 위해 표시된다.


- **Using temorary**:

    쿼리 실행 시 임시 테이블이 생성되면 표시된다. (표시 안되도 생성되는 경우도 있음)


- **Using where**:

    MySQL 엔진 레이어에서 필터링 작업을 처리한 경우 표시된다.</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 9장] 옵티마이저와 힌트]]></title>
            <link>https://velog.io/@jeong_hun_hui/Real-MySQL-9%EC%9E%A5-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EC%99%80-%ED%9E%8C%ED%8A%B8-zi13bpcd</link>
            <guid>https://velog.io/@jeong_hun_hui/Real-MySQL-9%EC%9E%A5-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EC%99%80-%ED%9E%8C%ED%8A%B8-zi13bpcd</guid>
            <pubDate>Mon, 11 Sep 2023 18:36:13 GMT</pubDate>
            <description><![CDATA[<h2 id="옵티마이저">옵티마이저</h2>
<p>MySQL 서버로 요청된 쿼리는 결과는 동일하지만 결과를 만들어내는 방법은 다양한데, 다양한 방법 중 어떤 방법이 최적인지 실행 계획을 수립하는 작업을 담당하는 것을 “옵티마이저”라 한다.</p>
<p>EXPLAIN이라는 명령을 통해 쿼리의 실행 계획을 확인할 수 있다.</p>
<h3 id="쿼리-실행-절차">쿼리 실행 절차</h3>
<ol>
<li>SQL 문장을 잘게 쪼개서 MySQL 서버가 이해할 수 있는 수준으로 분리하여 SQL 파스 트리를 생성한다.<ul>
<li>이 과정은 SQL 파서가 담당한다.</li>
</ul>
</li>
<li>SQL 파스 트리를 확인하며 실행 계획 수립한다.<ul>
<li>이 과정은 옵티마이저가 담당한다.</li>
</ul>
</li>
<li>실행계획에 따라 스토리지 엔진에서 데이터를 가져온다.<ul>
<li>이 과정은 MySQL 엔진과 스토리지 엔진이 함께 담당한다.</li>
</ul>
</li>
</ol>
<h3 id="기본-데이터-처리">기본 데이터 처리</h3>
<ul>
<li><p><strong>리드 어헤드</strong></p>
<ul>
<li><p>어떤 영역의 데이터가 앞으로 필요해질 것을 예측해서 미리 디스크에서 읽어 버퍼 풀에 가져다 두는 것을 의미한다.</p>
</li>
<li><p>예를 들면 풀 테이블 스캔이 시작되면 처음 몇 개의 페이지는 포그라운드 스레드가 읽어오지만, 특정 시점부터는 읽기 작업을 백그라운드 스레드로 넘긴다.</p>
<p>  백그라운드 스레드는 한번에 4개 or 8개의 페이지를 읽어서 버퍼 풀에 저장해둔다. (읽어오는 페이지 수는 점점 증가한다.)</p>
<p>  포그라운드 스레드는 버퍼 풀에 준비된 데이터를 가져다 사용하면 되므로 쿼리가 빠르게 처리된다.</p>
</li>
<li><p>풀 테이블 스캔, 풀 인덱스 스캔에서 사용된다.</p>
</li>
</ul>
</li>
<li><p><strong>풀 테이블 스캔</strong></p>
<p>  풀 테이블 스캔은 아래와 같은 경우에 쓰인다.</p>
<ul>
<li>테이블의 레코드 건수가 너무 작아서 인덱스를 쓰지 않는 것이 더 빠른 경우</li>
<li><code>WHERE</code> 절이나 <code>ON</code> 절에 인덱스를 이용할 수 있는 조건이 없는 경우</li>
<li>옵티마이저가 판단한 예상 레코드 건수가 너무 많아 인덱스 활용이 비효율적인 경우</li>
</ul>
</li>
<li><p><strong>풀 인덱스 스캔</strong></p>
<p>  풀 인덱스 스캔은 인덱스를 처음부터 끝까지 스캔하는 것을 의미한다.</p>
</li>
<li><p><strong>병렬 처리</strong></p>
<p>  하나의 쿼리를 여러 스레드가 나누어 처리하는 것을 의미한다.</p>
<p>  아무런 조건 없이 단순히 테이블의 전체 건수를 가져오는 쿼리만 병렬로 처리할 수 있다.</p>
</li>
</ul>
<h3 id="정렬-처리">정렬 처리</h3>
<p>정렬을 처리하는 방법은 인덱스를 이용하는 방법과 쿼리가 실행될 때 “Filesort”라는 별도의 처리를 이용하는 방법으로 나눌 수 있다.</p>
<table>
<thead>
<tr>
<th></th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 이용</td>
<td>이미 인덱스가 정렬되어 있어서 순서대로 읽기만 하면 되므로 매우 빠르다.</td>
<td>쓰기 작업 시 부가적인 인덱스 추가/삭제 작업이 필요하므로 느리다. 디스크 공간이 더 많이 필요하다. 인덱스의 개수가 늘어날수록 버퍼 풀을 위한 메모리가 많이 필요하다.</td>
</tr>
<tr>
<td>Filesort 이용</td>
<td>인덱스를 이용할 때의 단점이 없어진다. 정렬할 레코드가 많지 않으면 메모리에서 Filesort가 처리되므로 충분히 빠르다.</td>
<td>정렬 작업이 쿼리 실행 시 처리되므로 레코드 건수가 많아질수록 쿼리의 응답 속도가 느리다.</td>
</tr>
</tbody></table>
<ul>
<li><p><strong>소트 버퍼</strong>
정렬을 수행하기 위해 할당받은 별도의 메모리 공간을 소트 버퍼라고 한다.</p>
<p>  소트 버퍼는 정렬이 필요한 경우에만 할당되며, 버퍼의 크기는 정렬할 레코드 크기에 따라 증가한다.</p>
<p>  만약 소트 버퍼로 할당된 공간보다 정렬할 레코드 건수가 크다면 아래와 같은 방식으로 처리한다.</p>
<ol>
<li><p>정렬할 레코드를 여러 조각으로 나눠서 처리하며 결과를 디스크에 임시 저장한다.</p>
</li>
<li><p>모든 레코드를 정렬했으면, 임시 저장한 결과들을 병합한다. (이 작업을 멀티 머지 라고 한다.)</p>
<p>이 작업들은 모두 디스크 I/O를 유발하며, 레코드 건수가 많을수록 반복 횟수가 많아진다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/159b6121-97a6-4697-bbd0-145276385357" alt="image"></p>
<p>참고로, 위 그림에서 볼 수 있듯 소트 버퍼의 사이즈가 커진다고 성능이 무조건 빨라지는 것은 아니다.</p>
<p>대신, 디스크 I/O는 줄일 수 있으므로 디스크 I/O 성능이 낮은 장비라면 소트 버퍼의 크기를 더 크게 설정하는 것이 좋을 수 있다.</p>
<p>하지만, 소트 버퍼를 너무 크게 설정하면 서버의 메모리가 부족해져서 적절한 것이 좋다.</p>
<p>대량 데이터 정렬이 필요한 경우 해당 세션의 소트 버퍼만 일시적으로 늘려서 쿼리를 실행하고 다시 줄이는 것도 좋다.</p>
</li>
</ol>
</li>
<li><p><strong>정렬 알고리즘</strong></p>
<ul>
<li><p><strong>싱글 패스</strong>
  레코드 정렬 시 레코드 전체를 소트 버퍼에 담는 정렬 방식을 말한다.</p>
<pre><code class="language-sql">  SELECT emp_no, first_name, last_name
  FROM employees
  ORDER BY first_name;</code></pre>
<p>  위 쿼리를 싱글 패스 방식으로 처리하는 절차는 아래와 같다.</p>
<ol>
<li><p>소트 버퍼 크기 만큼 SELECT절의 컬럼들(위 예시에선 <code>emp_no</code>, <code>first_name</code>, <code>last_name</code>)을 읽어와서 정렬 후 임시 저장한다.</p>
</li>
<li><p>위 과정을 반복한 뒤 멀티 머지하여 쿼리의 결과를 반환한다.</p>
<p>싱글 패스 방식은 불필요한 데이터 까지 읽어오므로 많은 소트 버퍼 공간이 필요하다.</p>
</li>
</ol>
</li>
<li><p><strong>투 패스</strong>
  레코드 정렬 시 정렬 기준 컬럼과 PK만 소트 버퍼에 담는 정렬 방식을 말한다.</p>
<p>  투 패스 방식은 테이블을 두 번 읽어야 한다.</p>
<p>  최신 버전에서는 주로 싱글 패스 정렬 방식을 사용하지만, 다음의 경우 싱글 패스 정렬 방식을 사용하지 못하고 투 패스 정렬 방식을 사용한다.</p>
<ul>
<li><p>레코드의 크기가 max_length_for_sort_data 시스템 변수에 설정된 값보다 클 때</p>
</li>
<li><p>BLOB이나 TEXT 타입의 컬럼이 SELECT 대상에 포함될 때</p>
<p>싱글 패스 방식은 정렬 대상 레코드의 크기나 건수가 작은 경우 빠르고, 투 패스 방식은 정렬 대상 레코드의 크기나 건수가 상당히 많은 경우 효율적이다.</p>
<p>→ SELECT 쿼리에서 꼭 필요한 컬럼만 조회하지 않고 모든 컬럼을 가져오도록 개발하게 되면 정렬 버퍼를 비효율적으로 사용하게된다. 또한, 임시 테이블이 필요한 쿼리에서도 영향을 미친다.</p>
<p>→ 불필요한 컬럼을 SELECT하지 않게 쿼리를 작성하자.</p>
</li>
</ul>
</li>
<li><p>정렬 처리 방법</p>
</li>
</ul>
</li>
</ul>
<pre><code>    | 정렬 처리 방법 | 실행 계획의 Extra 내용 | 속도 |
    | --- | --- | --- |
    | 인덱스를 이용한 정렬 | 별도 표기 없음 | 빠름 |
    | 조인에서 드라이빙 테이블만 정렬 | “Using filesort” | 보통 |
    | 조인에서 조인 결과를 임시 테이블로 저장 후 정렬 | “Using temporary; Using filesort” | 느림 |

    우선 인덱스를 사용할 수 있다면 인덱스를 순서대로 읽어서 결과를 반환한다.

    인덱스를 사용할 수 없다면 조건에 일치하는 레코드를 검색해 버퍼에 저장하며 정렬을 처리한다.

    이때 옵티마이저는 정렬 대상 레코드를 최소화하기 위해 아래 두 방법 중 하나를 선택한다.

    - 조인의 드라이빙 테이블만 정렬한 다음 조인을 수행
    - 조인이 끝나고 일치하는 레코드를 모두 가져온 후 정렬을 수행
    1. **인덱스를 이용한 정렬**

        ORDER BY 절에 명시된 컬럼이 제일 먼저 읽는 테이블(조인이 사용된 경우 드라이빙 테이블)에 속하고, ORDER BY의 순서대로 생성된 인덱스가 있어야 한다.

        - 드라이빙 테이블: 조인 시 먼저 읽히는 테이블을 말하고, 쿼리에 따라 옵티마이저가 결정한다.

        또한, WHERE절에 첫 번째로 읽는 테이블이 컬럼에 대한 조건이 있다면 그 조건과 ORDER BY는 같은 인덱스를 사용할 수 있어야 한다.

        ```sql
        SELECT *
        FROM users
        WHERE name LIKE &#39;a%&#39; and age &gt;= 20
        ORDER BY name, age;
        // 복합 인덱스 name_age_idx(name, age)가 있는 상태
        ```

        위 쿼리는 ORDER BY 절에 명시된 컬럼이 users 테이블에 속하고, 순서대로 생성된 인덱스가 있고, WHERE 절에서도 같은 인덱스를 사용할 수 있으므로 인덱스를 이용해 정렬할 수 있다.

        여러 테이블이 조인되는 경우에는 Nested-loop 방식의 조인에서만 이 방식을 사용할 수 있다.

        참고로, 위 경우 ORDER BY 절을 넣지 않아도 정렬되지만, ORDER BY 절을 넣는다고 불필요한 정렬을 한 번 더 하지 않고, 실행계획이 변경되면 버그로 이어질 수 있으므로 넣는 것이 좋다.

    2. **조인에서 드라이빙 테이블만 정렬**

        인덱스를 이용한 정렬이 불가능하다면 조인을 실행하기 전에 첫 번째로 읽히는 테이블(드라이빙 테이블)을 먼저 정렬한 다음 조인을 실행하는 것이 차선책이 될 것이다.

        이 방법으로 정렬이 처리되려면 조인의 드라이빙 테이블의 컬럼만으로 ORDER BY 절을 작성해야 한다.

        ```sql
        SELECT *
        FROM users, posts
        WHERE users.id = posts.user_id
            AND users.id BETWEEN 10000 AND 10010
        ORDER BY users.name;
        ```

        일단 위 쿼리에서는 아래 이유로 옵티마이저가 `users` 테이블을 드라이빙 테이블로 선택할 것이다.

        - `WHERE` 절의 검색 조건 `users.id BETWEEN 10000 AND 10010` 은 `users` 테이블의 PK를 이용하면 작업량을 줄일 수 있다.
        - 드리븐 테이블(`posts`)의 조인 컬럼인 `users.id`에 인덱스가 있다.

        ORDER BY 절에 명시된 컬럼으로 생성된 인덱스가 없으므로 인덱스를 이용할 수 없다.

        하지만, ORDER BY 절에 명시된 컬럼들이 드라이빙 테이블에 속해 있으므로 드라이빙 테이블만 먼저 정렬한 뒤 posts 테이블과 조인하는 식으로 처리할 수 있다.

        즉, 아래와 같은 과정으로 실행된다.

        1. 인덱스를 이용해 `users.id BETWEEN 10000 AND 10010` 를 만족하는 레코드를 검색
        2. 검색 결과를 `name` 컬럼으로 정렬(Filesort)
        3. 정렬된 결과를 순서대로 읽으면서 `posts` 테이블과 조인을 수행해 결과를 가져옴
    3. **임시 테이블을 이용한 정렬**

        2번과 같은 패턴을 제외한 쿼리에서는 항상 조인의 결과를 임시 테이블에 저장하고, 그 결과를 다시 정렬하는 과정을 거친다.

        이 방법은 다른 방법보다 정렬할 레코드 건수가 가장 많기 때문에 가장 느리다.

        ```sql
        SELECT *
        FROM users, posts
        WHERE users.id = posts.user_id
            AND users.id BETWEEN 10000 AND 10010
        ORDER BY posts.created_at;
        ```

        위 쿼리는 2번 방법의 예시 쿼리에서 ORDER BY 절의 컬럼만 바꾼 쿼리이다.

        위 쿼리는 정렬 기준 컬럼이 드리븐 테이블에 있으므로 조인을 한 뒤에 정렬해야한다.


    위 3가지 정렬 방법을 아래 쿼리를 기준으로 비교해보자.

    ```sql
    SELECT *
    FROM tb_test1 t1, tb_test2 t2
    WHERE t1.c1=t2.c1
    ORDER BY t1.c2
    LIMIT 10;
    // t1의 레코드는 100건, t2의 레코드는 1000건
    ```

    ![image](https://github.com/JeongHunHui/TIL/assets/108508730/74a5947f-b8ea-467f-8b45-6bde8883133f)

    ![image](https://github.com/JeongHunHui/TIL/assets/108508730/66b6fc64-fb1e-4a87-ad67-0e8356ed528f)

    → 어떤 테이블이 먼저 드라이빙 되어 조인되는지, 어떤 정렬 방식으로 처리되는지에 따라서 큰 성능차이가 발생한다.

- **정렬 처리 방법의 성능 비교**
    `ORDER BY` 나 `GROUP BY` 작업은 `LIMIT` 가 있어도 정렬이나 그루핑 작업 후에 건수를 제한할 수 있다.

    → `ORDER BY` 나 `GROUP BY` 을 잘못 활용하면 쿼리가 느려진다.

    인덱스를 사용하지 못하는 정렬이나 그루핑 작업이 왜 느리게 작동할 수밖에 없는지 알기 위해 쿼리가 처리되는 방법을 두 가지로 나눠서 알아보자.

    - **스트리밍 방식**
        조건에 일치하는 레코드가 검색될 때마다 바로바로 클라이언트로 전송해주는 방식을 의미한다.

        위 방식으로 처리되는 쿼리에서 LIMIT처럼 결과 건수를 제한하는 조건들은 쿼리의 실행 시간을 상당히 줄여줄 수 있다.

    - **버퍼링 방식**
        `ORDER BY` 나 `GROUP BY` 같은 처리는 쿼리의 결과가 스트리밍되는 것을 불가능 하게 한다.

        왜냐하면 조건에 맞는 모든 레코드를 가져온 뒤 정렬하거나 그루핑해서 보내야 하기 때문이다.

        MySQL 서버에서는 모든 레코드를 검색하고 정렬 작업을 하는 동안 클라이언트는 아무것도 하지 않고 기다려야 하기 때문에 응답 속도가 느려진다.

        그렇기 때문에 버퍼링 방식으로 처리되는 쿼리에선 `LIMIT` 처럼 결과 건수를 제한하는 조건이 있어도 성능 향상에 큰 도움이 되지 않는다.

        ![image](https://github.com/JeongHunHui/TIL/assets/108508730/39402839-de66-4e00-b898-561ee651f4bd)


    인덱스를 사용하지 못하고 Filesort 작업을 거쳐야 하는 쿼리에서 LIMIT 조건을 걸면 모든 레코드를 정렬하는 것이 아니고, 정렬하다가 LIMIT 조건 만큼의 레코드가 정렬되면 바로 결과를 반환한다.

    하지만 MySQL은 정렬을 위해 퀵 소트와 힙 소트 알고리즘을 사용하는데, 즉 상위 10건의 데이터를 얻기 위해 대부분의 데이터를 살펴야할 수도 있다.

    → 인덱스를 사용하지 못하는 쿼리에 LIMIT 조건을 붙혀도 쿼리가 생각보단 많이 빨라지지 않는다.

- **정렬 관련 상태 변수 보는법**

    ```sql
    FLUSH STATUS;
    SHOW STATUS LIKE &#39;Sort%&#39;;
    ```</code></pre><h3 id="group-by-처리">GROUP BY 처리</h3>
<p><code>GROUP BY</code> 또한 <code>ORDER BY</code>와 같이 스트리밍 처리를 할 수 없다.</p>
<p>참고로 <code>GROUP BY</code>에 사용된 조건(<code>HAVING</code> 절)은 인덱스를 사용해서 처리될 수 없다.</p>
<p><code>GROUP BY</code>도 인덱스를 사용하거나 못할 수 있다.</p>
<ul>
<li><p><strong>인덱스 스캔을 이용하는 GROUP BY</strong>
  조인의 드라이빙 테이블에 속한 컬럼만 이용해 그루핑할 때 <code>GROUP BY</code> 컬럼으로 이미 인덱스가 있다면 그 인덱스를 읽으며 그루핑 작업을 수행한다.</p>
<p>  인덱스를 사용해서 처리하더라도 그룹 함수 등 그룹값을 처리해야 해서 임시 테이블이 필요할 때도 있다.</p>
<p>  이러한 그루핑 방식을 사용하는 쿼리의 실행 계획의 Extra 컬럼에는 별도의 코멘트가 표시되지 않는다.</p>
</li>
<li><p><strong>루스 인덱스 스캔을 이용하는 GROUP BY</strong>
  이 방식을 사용할 때는 실행 계획의 Extra 컬럼에 “Using index for group-by” 코멘트가 표시된다.</p>
<pre><code class="language-sql">  // 인덱스는 (emp_no, from_date) 이렇게 생성된 상태
  SELECT emp_no
  FROM salaries
  WHERE from_date=&#39;1985-03-01&#39;
  GROUP BY emp_no;</code></pre>
<p>  위 쿼리의 <code>WHERE</code> 절의 <code>from_date</code>는 복합 인덱스의 첫 컬럼이 아니므로 인덱스를 사용할 수 없다.</p>
<p>  하지만 위 쿼리의 실행 계획을 보면 인덱스 레인지 스캔을 사용했고, <code>GROUP BY</code> 처리도 인덱스를 사용했다.</p>
<p>  위 쿼리는 아래와 같은 과정으로 실행된다.</p>
<ol>
<li><p><code>(emp_no, from_date)</code> 인덱스를 스캔하며 <code>emp_no</code>의 첫 번째 유일한 값 “10001” 을 찾는다.</p>
</li>
<li><p>인덱스에서 <code>emp_no</code>가 “10001”인 것 중 <code>from_date</code> 값이 “1985-03-01”인 레코드를 찾는다.</p>
</li>
<li><p>다시 <code>emp_no</code>의 유일한 값을 찾은 뒤 2번을 반복하고 더 이상 유일한 값이 없으면 처리를 종료한다.</p>
<p>즉 인덱스의 첫 번째 컬럼의 유니크한 값들 별로 <code>WHERE</code> 조건에 해당하는 레코드를 검색하는 것이다.</p>
<p>이 방식에선 인덱스의 유니크한 값이 적을수록 성능이 향상된다.</p>
</li>
</ol>
</li>
<li><p><strong>임시 테이블을 사용하는 GROUP BY</strong>
  <code>GROUP BY</code>의 기준 컬럼이 인덱스를 전혀 사용하지 못할 때는 이 방식으로 처리된다.</p>
<p>  MySQL 8.0에선 <code>GROUP BY</code>가 필요한 경우 내부적으로 <code>GROUP BY</code> 절의 컬럼들로 구성된 유니크 인덱스를 가진 임시 테이블을 만들어서 중복 제거와 집합 함수 연산을 수행한다.</p>
</li>
</ul>
<h3 id="distinct-처리">DISTINCT 처리</h3>
<p>특정 컬럼의 유니크한 값만 조회하기 위해 <code>SELECT</code> 쿼리에 <code>DISTINCT</code>를 사용한다.</p>
<ul>
<li><p><strong>SELECT DISTINCT</strong>
  GROUP BY와 동일한 방식으로 처리된다.</p>
<p>  참고로 SELECT절의 DISTINCT는 SELECT절의 모든 컬럼들을 기준으로 유니크한 값들을 가져온다.</p>
<pre><code class="language-sql">  SELECT DISTINCT first_name, last_name FROM employees;</code></pre>
<p>  즉, 위 쿼리는 성이 같지만 이름이 다른 경우를 유니크한 값으로 판단하여 결과에 포함한다.</p>
</li>
<li><p><strong>집합 함수와 함께 사용된 DISTINCT</strong></p>
<pre><code class="language-sql">  SELECT COUNT(DISTINCT s.salary)
  FROM employees e, salaries s
  WHERE e.emp_no=s.emp_no
  AND e.emp_no BETWEEN 100001 AND 100100;</code></pre>
<p>  이 쿼리는 <code>COUNT(DISRINCT s.salary)</code>를 처리하기 위해 임시 테이블을 사용한다.</p>
<p>  이때 임시 테이블의 <code>salary</code> 컬럼엔 유니크 인덱스가 생성되기 때문에 레코드 수가 많아지면 느릴 수 있다.</p>
<p>  하지만 인덱스된 컬럼에 대해 <code>DISTINCT</code> 처리를 할 땐 인덱스를 이용하며 임시 테이블 없이 처리할 수 있다.</p>
</li>
</ul>
<h3 id="내부-임시-테이블-활용">내부 임시 테이블 활용</h3>
<p>MySQL 엔진이 스토리지 엔진으로부터 받은 레코드를 정렬, 그루핑할 때는 내부적인 임시 테이블을 사용한다.</p>
<p>이는 <code>CREATE TEMPORARY TABLE</code> 명령으로 만든 임시 테이블과는 다르다.</p>
<p>내부 임시 테이블은 쿼리의 처리가 완료되면 자동으로 삭제된다.</p>
<ul>
<li><p><strong>메모리 임시 테이블과 디스크 임시 테이블</strong>
  시스템 변수를 이용해 임시 테이블이 최대로 사용 가능한 메모리 공간의 크기를 정할 수 있다.</p>
<p>  메모리에 저장되는 임시 테이블은 TempTable을, 디스크에 저장되는 임시 테이블은 InnoDB를 사용한다.</p>
</li>
<li><p><strong>임시 테이블이 필요한 쿼리</strong>
  어떤 쿼리에서 임시테이블을 사용하는지는 Extra 컬럼의 <code>Using temporary</code> 가 표시되는지 확인하면 된다.</p>
<p>  대표적으로 아래와 같은 경우에 내부 임시 테이블을 생성한다.</p>
<ul>
<li><p><code>ORDER BY</code>와 <code>GROUP BY</code>에 명시된 컬럼이 다른 쿼리</p>
</li>
<li><p><code>ORDER BY</code>와 <code>GROUP BY</code>에 명시된 컬럼이 드라이빙 테이블이 아닌 쿼리</p>
</li>
<li><p><code>DISTINCT</code>와 <code>ORDER BY</code>가 동시에 존재하거나 <code>DISTINCT</code>가 인덱스로 처리되지 못하는 쿼리</p>
</li>
<li><p><code>UNION</code>이나 <code>UNION DISTINCT</code>가 사용된 쿼리</p>
</li>
<li><p>쿼리의 실행 계획에서 <code>select_type</code>이 <code>DERIVED</code>인 쿼리</p>
<p>위 경우 중 마지막 쿼리 패턴을 제외하고 전부 유니크 인덱스를 가지는 내부 임시 테이블이 만들어진다.</p>
<p>일반적으로 유니크 인덱스가 있는 내부 임시 테이블은 처리 성능이 느린편이다.</p>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="고급-최적화">고급 최적화</h2>
<p>옵티마이저는 최적의 실행 계획을 수립하기 위해 통계 정보와 옵티마이저 옵션을 결합하여 이용한다.</p>
<p>옵티마이저 옵션은 크게 조인 관련 옵티마이저 옵션과 옵티마이저 스위치로 구분된다.</p>
<h3 id="옵티마이저-스위치-옵션">옵티마이저 스위치 옵션</h3>
<p>옵티마이저 스위치 옵션은 <code>optimizer_switch</code> 시스템 변수를 이용하여 제어하는데, 여러 옵션을 세트로 묶어 설정하는 방식으로 사용한다.</p>
<p>옵티마이저 스위치 옵션은 <code>optimizer_switch</code> 시스템 변수를 이용하여 제어하는데 옵션들은 아래와 같다.</p>
<table>
<thead>
<tr>
<th>옵티마이저 스위치 이름</th>
<th>기본값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>batched_key_access</td>
<td>off</td>
<td>BKA 조인 알고리즘 사용 여부</td>
</tr>
<tr>
<td>block_nested_loop</td>
<td>on</td>
<td>Block Nested Loop 조인 알고리즘 사용 여부</td>
</tr>
<tr>
<td>engine_condition_pushdown</td>
<td>on</td>
<td>Engine Condition Pushdown 기능 사용 여부</td>
</tr>
<tr>
<td>index_condition_pushdown</td>
<td>on</td>
<td>Index Condition Pushdown 기능 사용 여부</td>
</tr>
<tr>
<td>use_index_extensions</td>
<td>on</td>
<td>Index Extension 최적화 사용 여부</td>
</tr>
<tr>
<td>index_merge</td>
<td>on</td>
<td>Index Merge 최적화 사용 여부</td>
</tr>
<tr>
<td>index_merge_intersection</td>
<td>on</td>
<td>Index Merge Intersection 최적화 사용 여부</td>
</tr>
<tr>
<td>index_merge_sort_union</td>
<td>on</td>
<td>Index Merge Sort Union 최적화 사용 여부</td>
</tr>
<tr>
<td>index_merge_union</td>
<td>on</td>
<td>Index Merge Union 최적화 사용 여부</td>
</tr>
<tr>
<td>mrr</td>
<td>on</td>
<td>MRR 최적화 사용 여부</td>
</tr>
<tr>
<td>mrr_cost_based</td>
<td>on</td>
<td>비용 기반 MRR 최적화 사용 여부</td>
</tr>
<tr>
<td>semijoin</td>
<td>on</td>
<td>세미 조인 최적화 사용 여부</td>
</tr>
<tr>
<td>firstmatch</td>
<td>on</td>
<td>FirstMatch 세미 조인 최적화 사용 여부</td>
</tr>
<tr>
<td>loosescan</td>
<td>on</td>
<td>LooseScan 세미 조인 최적화 사용 여부</td>
</tr>
<tr>
<td>materialization</td>
<td>on</td>
<td>Materialization 최적화 사용 여부</td>
</tr>
<tr>
<td>subquery_materialization_cost_based</td>
<td>on</td>
<td>비용 기반 Materialization 최적화 사용 여부</td>
</tr>
</tbody></table>
<p>위 옵션은 글로벌, 현재 커넥션, 현재 쿼리에 대해서 등 다양한 범위로 설정할 수 있다.</p>
<ul>
<li><p><strong>네스티드 루프 조인 (Nested Loop Join)</strong>
  가장 자주 사용되는 조인 방식이고, 이 방식은 드라이빙 테이블의 레코드를 한 건 읽어서 드리븐 테이블의 일치하는 레코드를 찾아 조인을 수행하는 방식이다.</p>
<p>  아래와 같은 쿼리로 예시를 들어보자.</p>
<pre><code class="language-sql">  SELECT *
  FROM employees e
  INNER JOIN salaries s
      ON s.emp_no=e.emp_no;</code></pre>
<p>  위 쿼리는 <code>employees</code> 테이블이 드라이빙 테이블이 되어 순서대로 레코드를 한 건 읽고, 드리븐 테이블인 salaries 에서 조건에 만족하는 레코드를 찾아서 바로 반환한다.</p>
<p>  이를 의사 코드로 표현하면 아래와 같다.</p>
<pre><code class="language-sql">  for(row1 IN employees) {
      for(row2 IN salaries) {
          if(row1.emp_no == row2.emp_no) return (row1, row2);
      }
  }</code></pre>
<p>  위 코드에서 알 수 있듯이 레코드를 읽어서 다른 버퍼 공간에 저장하지 않고 즉시 드리븐 테이블의 레코드를 찾아 반환한다.</p>
</li>
<li><p><strong>MRR과 배치 키 엑세스 (mrr &amp; batched_key_access)</strong>
  MySQL 서버에서 지원하던 조인 방식은 Nested Loop Join 방식인데 이 방식으로 레코드를 읽고 바로 조인을 하면 스토리지 엔진에서는 아무런 최적화를 할 수 없다.</p>
<p>  이를 보완하기 위해 MySQL에서는 레코드를 읽고 바로 조인을 하지 않고 조인 버퍼에 버퍼링하고, 버퍼에 레코드가 가득 차면 버퍼링된 레코드를 스토리지 엔진으로 한 번에 요청한다.</p>
<p>  이 과정에서 레코드를 읽을 때 디스크 및 버퍼 풀 접근을 최소화하여 성능을 향상시킬 수 있다.</p>
<p>  이러한 읽기 방식을 MRR(Multi Range Read)라고 하며, MRR을 응용한 조인 방식을 BKA 조인이라 한다.</p>
<p>  BKA 조인은 쿼리에 따라 큰 도움이 되는 경우도 있지만, 부가적인 정렬로 인해 성능에 악영향을 미칠 수 있다.</p>
</li>
<li><p><strong>블록 네스티드 루프 조인 (block_nested_loop)</strong>
  네스티드 루프 조인과의 가장 큰 차이는 조인 버퍼 사용 여부와 테이블이 어떤 순서로 조인되느냐이다.</p>
<p>  조인 알고리즘에서 “Block”이라는 단어가 사용되면 조인용으로 버퍼가 사용됐다는 것을 의미한다.</p>
<p>  만약 드리븐 테이블의 레코드를 찾을 때 인덱스를 사용할 수 없다면 드라이빙 테이블의 레코드 수 만큼 드리븐 테이블을 풀 스캔하게된다.</p>
<p>  만약 어떤 방식으로도 위 상황을 피할 수 없다면 드라이빙 테이블에서 읽은 레코드를 메모리에 캐시한 뒤 드리븐 테이블과 메모리 캐시를 조인하는 형태로 처리한다.</p>
<p>  이 때 사용되는 메모리의 캐시를 <strong>조인 버퍼</strong>라고 한다.</p>
<p>  결국 블록 네스티드 루프 조인 방식은 조인 버퍼를 이용하여 조인을 하는데, 이 때 드라이빙 테이블의 결과를 조인 버퍼에 담아두고 드리븐 테이블을 먼저 읽고 조인 버퍼에서 일치하는 레코드를 찾는 방식으로 처리된다.</p>
<p>  주의할 점은 일반적으로 조인이 수행된 후 가져오는 결과는 드라이빙 테이블의 순서에 의해 결정되지만, 조인 버퍼가 사용되는 조인에서는 결과의 정렬 순서가 달라질 수 있다는 것이다.</p>
</li>
<li><p><strong>인덱스 컨디션 푸시다운 (index_condition_pushdown)</strong>
  인덱스 컨디션 푸시 다운은 <code>WHERE</code> 절의 조건 중 일부를 스토리지 엔진 레벨에서 평가하도록 &quot;푸시 다운&quot;하여, 필요하지 않은 레코드를 더 빠르게 걸러내는데 도움을 준다.</p>
<ul>
<li><p><strong>장점</strong></p>
<ul>
<li>효율적인 레코드 필터링이 가능하다.</li>
<li>불필요한 레코드를 걸러내기 위한 작업이 줄어들어 CPU 사용이 줄어든다.</li>
</ul>
</li>
<li><p><strong>단점</strong></p>
<ul>
<li>특정 쿼리나 데이터셋에서는 성능 저하의 원인이 될 수 있다.</li>
<li>InnoDB, MEMORY 스토리지 엔진에서만 사용된다.</li>
</ul>
<p>→ 대부분의 경우에선 활성화 하는 것이 좋다.</p>
</li>
</ul>
</li>
<li><p><strong>인덱스 확장 (use_index_extensions)</strong>
  <code>use_index_extensions</code> 옵션은 세컨더리 인덱스에 추가된 PK를 활용여부를 결정하는 옵션이다.</p>
</li>
<li><p><strong>인덱스 머지 (index_merge)</strong>
  인덱스 머지는 하나의 테이블에 2개 이상의 인덱스를 이용해서 나온 결과를 병합하는 방식으로 처리된다.</p>
</li>
<li><p><strong>인덱스 머지 - 교집합 (index_merge_intersection)</strong>
  교집합의 경우는 2개 이상의 인덱스를 이용해서 나온 여러 결과들을 <code>AND</code> 연산자로 연결할 경우 실행된다.</p>
<p>  실행 계획의 Extra 컬럼에 <code>Using intersect(idx_1, PRIMARY)</code> 라는 메시지가 있다면 <code>idx_1</code>와 PK로 검색한 두 결과를 교집합했다는 뜻이다.</p>
<p>  만약 1개의 인덱스를 사용하는 것이 더 효율적이면 <code>index_merge_intersection</code> 최적화를 비활성화 하자.</p>
</li>
<li><p><strong>인덱스 머지 - 합집합 (index_merge_union)</strong>
  합집합의 경우는 2개 이상의 인덱스를 이용해서 나온 여러 결과들을 <code>OR</code> 연산자로 연결할 경우 실행된다.</p>
<pre><code class="language-sql">  // idx_1(first_name), idx_2(hire_date) 두 세컨더리 인덱스가 생성되어 있다.
  SELECT * FROM employees WHERE first_name=&#39;Matt&#39; OR hire_date=&#39;1987-03-31&#39;;</code></pre>
<p>  위와 같은 쿼리의 실행 계획에 <code>Using union(idx_1, idx_2</code> 이러한 메시지가 나왔다면 두 인덱스로 검색한 값을 합집합 했다는 것이다.</p>
<p>  이때, 합집합 연산을 진행하며 중복되는 데이터가 있을 수 있는데, <code>first_name=&#39;Matt&#39;</code>인 레코드와 <code>hire_date=&#39;1987-03-31&#39;</code>인 레코드는 인덱스 스캔을 하게되면 PK로 정렬되어 있으므로 두 결과를 우선 순위 큐 알고리즘을 통해 합치면서 중복을 제거한다.</p>
<ul>
<li><p><strong>참고 1</strong>
  모든 세컨더리 인덱스는 클러스터링 인덱스인 PK를 포함하고 있으므로 같은 값 끼리는 PK로 정렬된다.</p>
</li>
<li><p><strong>참고 2</strong>
  두 조건이 <code>AND</code>로 연결된 경우에는 두 조건 중 하나라도 인덱스를 사용할 수 있으면 인덱스 레인지 스캔으로 쿼리가 실행되지만 <code>OR</code>로 연결된 경우에는 둘 중 하나라도 인덱스를 사용하지 못하면 풀 테이블 스캔으로 처리된다.</p>
</li>
</ul>
</li>
<li><p><strong>인덱스 머지 - 정렬 후 합집합 (index_merge_sort_union)</strong>
  만약 별도의 정렬이 필요한 경우에는 “Sort union”알고리즘을 사용한다.</p>
<p>  이전 예제처럼 동등 조건을 사용하면 정렬이 필요 없지만, <code>BETWEEN</code> 같은 경우는 정렬이 필요하다.</p>
<p>  이렇게 합집합 연산 전에 정렬을 해야하는 경우 Extra 컬럼에 <code>Using sort_union</code> 문구가 표시된다.</p>
</li>
<li><p><strong>세미 조인 (semijoin)</strong>
  실제 조인을 수행하진 않고 다른 테이블에 조건에 맞는 레코드가 있는지 체크하는 쿼리를 세미 조인이라 한다.</p>
<p>  일단 세미조인 최적화 옵션은 ON으로 해놓자.</p>
<p>  아래 쿼리가 세미 조인의 대표적인 예시이다.</p>
<pre><code class="language-sql">  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>
</li>
<li><p><strong>테이블 풀 아웃 (Table Pull-out)</strong>
  테이블 풀 아웃 최적화는 세미 조인의 서브쿼리에 사용된 테이블을 아우터 쿼리로 끄집어낸 후에 쿼리를 조인 쿼리로 재작성하는 형태이다.</p>
<pre><code class="language-sql">  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>  위 쿼리에 테이블 풀 아웃 최적화를 적용하면 아래와 같은 쿼리로 바뀐다.</p>
<pre><code class="language-sql">  SELECT e.*
  FROM dept_emp de
      JOIN employees e
  WHERE e.emp_no=de.emp_no AND de.dept_no=&#39;d009&#39;;</code></pre>
<p>  즉, 기존 서브쿼리를 조인으로 풀어서 사용한 형태이다.</p>
<p>  테이블 풀 아웃 최적화는 서브쿼리 부분에서 유니크 인덱스나 PK로 검색하여 결과가 1건인 경우에만 사용가능하다.</p>
<p>  또한, 테이블 풀 아웃은 다른 최적화와 함께 적용될 수 있으므로 가능하면 최대한 적용한다.</p>
</li>
<li><p><strong>퍼스트 매치 (firstmatch)</strong>
  퍼스트 매치 최적화는 <code>IN(subquery)</code> 형태의 세미 조인을 <code>EXISTS(subquery)</code> 형태로 튜닝한 것과 비슷한 방법으로 실행된다.</p>
<pre><code class="language-sql">  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>  위 쿼리는 <code>first_name=&#39;Matt&#39;</code>인 레코드들을 찾고 그 결과와 <code>titles</code> 테이블을 조인하는 식으로 실행된다.</p>
<p>  조인하는 과정에서 </p>
<p>  <code>e.emp_no</code>가 1이고, <code>e.first_name</code>이 ‘Matt’인 레코드와 <code>e.emp_no</code>가 3이고, <code>e.first_name</code>이 ‘Matt’인 레코드를 찾은 상태라고 하자.</p>
<p>  <code>t.emp_no</code>가 1인 레코드들 중 <code>t.from_date BETWEEN &#39;1995-01-01&#39; AND &#39;1995-01-30&#39;</code> 인 레코드를 하나만 발견하면 더이상 <code>emp_no</code>가 1인 경우에 대해서는 탐색하지 않는다.</p>
<p>  그런 뒤 <code>emp_no</code>가 2인 레코드에 대해서도 동일한 작업을 시행한다.</p>
</li>
<li><p><strong>루스 스캔 (loosescan)</strong>
  세미 조인 서브쿼리 최적화의 루스 스캔은 GROUP BY의 루스 인덱스 스캔과 비슷한 방식을 사용한다.</p>
<pre><code class="language-sql">  // departments 테이블의 레코드는 9건, dept_emp 테이블의 레코드는 33만건
  SELECT *
  FROM departments d
  WHERE d.dept_no IN (
      SELECT de.dept_no FROM dept_emp de);</code></pre>
<p>  위 쿼리는 dept_emp 테이블에 존재하는 모든 부서 번호에 대해 부서 정보를 읽어 오는 쿼리다.</p>
<p>  <code>dept_emp</code> 테이블은 <code>dept_no</code>를 기준으로 그루핑하면 9건 밖에 없다.</p>
<p>  → <code>dept_emp</code> 테이블을 루스 인덱스 스캔으로 유니크한 <code>dept_no</code>만 읽으면 효율적 실행이 가능하다.</p>
</li>
<li><p><strong>구체화 (materialization)</strong>
  Materialization 최적화는 세미 조인에 사용된 서브쿼리를 통째로 구체화해서 쿼리를 최적화하는 방식이다.</p>
<p>  구체화는 쉽게 표현하면 내부 임시 테이블을 생성한다는 것을 의미한다.</p>
<p>  보통 FirstMatch 최적화가 도움이 안되는 경우에 사용된다.</p>
<p>  서브 쿼리의 결과로 임시 테이블을 만들고, 임시 테이블과 대상 테이블을 조인해서 결과를 반환하는 식이다.</p>
<p>  Materialization 최적화는 서브쿼리가 코릴레이트 서브쿼리가 아니여야 사용가능하고, <code>GROUP BY</code>나 집합 함수가 사용되어도 사용 가능하다.</p>
</li>
<li><p><strong>중복 제거 (Duplicated Weed-out)</strong>
  Duplicate Weedout은 세미 조인 서브쿼리를 일반적인 <code>INNER JOIN</code> 쿼리로 바꿔서 실행하고 중복된 레코드를 제거하는 방법으로 처리되는 최적화 알고리즘이다.</p>
<p>  보통 이 방법으로 최적화되는 경우는 다른 최적화 방법이 많이 있다.</p>
</li>
<li><p><strong>컨디션 팬아웃 (condition_fanout_filter)</strong>
  <code>condition_fanout_filter</code> 최적화를 활성화하면 옵티마이저가 조건을 충족하는 레코드 수를 더 정확히 예측할 수 있다.</p>
<p>  조인 시 테이블의 순서는 쿼리의 성능에 매우 큰 영향을 미치는데, 예상 레코드 수가 정확해 지면 옵티마이저가 더 효율적인 순서로 조인이 실행되도록 계획을 세울 수 있다.</p>
<p>  대신 <code>condition_fanout_filter</code> 최적화를 활성화하면 계산을 위해 더 많은 자원을 사용한다.</p>
<p>  그러므로 쿼리의 실행 계획이 잘못된적이 별로 없다면 별로 도움이 되지 않는다.</p>
</li>
<li><p><strong>파생 테이블 머지 (derived_merge)</strong>
  이전 버전 MySQL에선 <code>FROM</code>절에 사용된 서브쿼리는 먼저 실행해서 임시 테이블로 만드는 식으로 처리했다.</p>
<p>  이 실행 계획은 임시 테이블을 생성하고 조건에 맞는 레코드를 읽어서 임시 테이블에 <code>INSERT</code>한다.</p>
<p>  그리고 다시 임시 테이블을 읽으므로 오버헤드가 추가된다.</p>
<p>  만약 임시 테이블의 크기가 메모리에 못 들어갈 정도로 커지면 성능은 많이 느려질 것이다.</p>
<p>  <code>derived_merge</code>최적화 옵션은 파생 테이블로 만들어지는 서브쿼리를 외부 쿼리와 병합하는 식으로 최적화한다.</p>
</li>
<li><p><strong>인비저블 인덱스 (use_invisible_indexes)</strong>
  인덱스를 삭제하지 않으면서 옵티마이저가 사용하지 못하도록 할 수 있다.</p>
<p>  <code>use_invisible_indexes</code> 옵티마이저 옵션을 이용하면 <code>INVISIBLE</code>로 설정된 인덱스도 사용할 수 있다.</p>
</li>
<li><p><strong>스킵 스캔 (skip_scan)</strong>
  인덱스 스킵 스캔은 인덱스의 선행 컬럼이 아닌 컬럼을 조건으로 쓸 때 사용할 수 있는 최적화 방법이다.</p>
<p>  만약 인덱스 선행 컬럼의 카디널리티가 크면 스킵 스캔최적화를 비활성화 하는 것이 좋다.</p>
</li>
<li><p><strong>해시 조인 (hash_join)</strong>
  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/55866994-e525-45ad-b244-6a91c93f295a" alt="image"></p>
<p>  해시 조인은 첫 레코드를 찾는데는 오래걸리지만, 마지막 레코드를 찾는 시점은 더 빠르다.</p>
<p>  즉, 중첩 루프 조인은 첫 레코드는 빠르게 받기 때문에 최고 응답 속도 전략(OLTP에 유리)에 적합하고, 해시 조인은 최고 스루풋 전략(OLAP에 유리)에 적합하다.</p>
<p>  해시 조인은 두 단계로 나뉘어 처리된다.</p>
<ul>
<li><p><strong>빌드 단계</strong>: 조인 대상 테이블 중 레코드 건수가 적은 테이블을 메모리에 해시 테이블을 생성한다.</p>
</li>
<li><p><strong>프로브 단계</strong>: 나머지 테이블의 레코드를 읽어서 해시 테이블의 일치 레코드를 찾는다.</p>
<p>보통 네스티드 루프 조인을 사용할 수 없는 경우 해시 조인이 사용된다.</p>
</li>
</ul>
</li>
<li><p><strong>인덱스 정렬 선호 (prefer_ordering_index)</strong></p>
<pre><code class="language-sql">  SELECT *
  FROM employees
  WHERE hire_date BETWEEN &#39;1985-01-01&#39; AND &#39;1985-02-01&#39;
  ORDER BY emp_no;</code></pre>
<p>  위 쿼리는 대표적으로 아래 2가지 실행 계획을 선택할 수 있다.</p>
<ol>
<li><p><code>hire_date</code> 컬럼으로 생성된 인덱스를 이용해 조건에 맞는 레코드를 찾고 <code>emp_no</code>로 정렬</p>
</li>
<li><p>PK를 정순으로 읽으며 조건에 맞는 레코드를 찾아서 반환</p>
<p>일반적으로는 <code>hire_date</code> 컬럼의 조건에 부합되는 레코드 건수가 많지 않다면 1번이 효율적일 것이다.</p>
<p>하지만, 옵티마이저가 <code>ORDER BY</code>절의 인덱스에 가중치를 너무 부여하여 2번이 선택될 수 있다.</p>
<p>이렇게 자주 실수를 한다면 <code>prefer_ordering_index</code> 옵션을 OFF로 변경하자.</p>
</li>
</ol>
</li>
</ul>
<h3 id="조인-최적화-알고리즘">조인 최적화 알고리즘</h3>
<p>Exhaustive 검색 알고리즘, Greedy 검색 알고리즘 두 가지가 있다.</p>
<p>조인 테이블 개수가 많아지면 실행 계획을 수립하는 데만 많은 시간이 걸린다.</p>
<hr>
<h2 id="쿼리-힌트">쿼리 힌트</h2>
<p>MySQL에서 사용 가능한 쿼리 힌트는 인덱스 힌트, 옵티마이저 힌트 두 가지로 구분할 수 있다.</p>
<p>쿼리 힌트는 옵티마이저에게 올바른 방향으로 실행 계획을 수립할 수 있도록 알려주는 역할을 한다.</p>
<h3 id="인덱스-힌트">인덱스 힌트</h3>
<p>인덱스 힌트는 <code>SELECT</code>, <code>UPDATE</code> 문에서만 사용할 수 있고, 가능하면 옵티마이저 힌트를 사용할 것을 추천한다.</p>
<ul>
<li><p><strong>STRAIGHT_JOIN</strong>
  <code>STRAIGHT_JOIN</code>은 <code>SELECT STRAIGHT_JOIN ~</code> 이러한 형태로 쓰이고, <code>FROM</code>절에 명시된 테이블의 순서대로 조인을 수행하도록 유도한다.</p>
</li>
<li><p><strong>USE / FORCE / IGNORE INDEX</strong></p>
<pre><code class="language-sql">  SELECT *
  FROM table_1 USE INDEX [FOR ORDER BY/GROUP BY/JOIN](idx_1)</code></pre>
<p>  <code>~ INDEX</code> 힌트를 사용하기 위해선 위 쿼리와 같이 인덱스를 가지는 테이블 뒤에 힌트를 명시해야한다.</p>
<p>  <code>FOR ORDER BY/GROUP BY/JOIN</code> 를 붙혀 인덱스의 용도를 제한할 수 있다.</p>
<p>  <code>USE</code>는 인덱스 사용 권장, <code>FORCE</code>는 더 강하게 사용 권장(잘 안씀), <code>IGNORE</code>는 인덱스를 못 사용하게 한다.</p>
</li>
<li><p><strong>SQL_CALC_FOUND_ROWS</strong>
  LIMIT이 걸려있어도 끝까지 검색하여 결과를 반환하도록 하는 힌트이다.</p>
</li>
</ul>
<h3 id="옵티마이저-힌트">옵티마이저 힌트</h3>
<p>옵티마이저 힌트는 영향 범위에 따라 4가지로 나눌 수 있다.</p>
<ul>
<li><strong>인덱스</strong>: 특정 인덱스의 이름을 사용할 수 있는 옵티마이저 힌트</li>
<li><strong>테이블</strong>: 특정 테이블의 이름을 사용할 수 있는 옵티마이저 힌트</li>
<li><strong>쿼리 블록</strong>: 힌트가 명시된 쿼리 블록에 대해서 영향을 미치는 옵티마이저 힌트</li>
<li><strong>글로벌(쿼리 전체)</strong>: 전체 쿼리에 대해서 영향을 미치는 힌트</li>
</ul>
<p>참고로 모든 인덱스 수준의 힌트는 아래와 같이 테이블명이 선행되어야 한다.</p>
<pre><code class="language-sql">SELECT /*+ INDEX(employees ix_firstname) */ *
FROM employees
WHERE first_name=&#39;Matt&#39;;</code></pre>
<ul>
<li><p><strong>MAX_EXECUTION_TIME</strong>
  쿼리의 최대 실행 시간을 설정하는 힌트다.</p>
<p>  밀리초 단위의 시간을 설정할 수 있으며, 지정된 시간을 초과하면 쿼리가 실패한다.</p>
</li>
<li><p><strong>SET_VAR</strong>
  MySQL 서버의 시스템 변수를 설정하는 힌트이다.</p>
</li>
<li><p><strong>SEMIJOIN &amp; NO_SEMIJOIN</strong>
  <code>SEMIJOIN(세미_조인_최적화_전략_이름)</code> 힌트는 어떤 세미 조인 최적화 전략을 사용할지를 제어할 수 있다.</p>
<p>  만약 세미 조인을 사용하고 싶지 않다면 <code>NO_SEMIJOIN</code> 힌트를 사용하면된다.</p>
</li>
<li><p><strong>SUBQUERY</strong>
  서브쿼리 최적화는 세미 조인 최적화가 사용되지 못할 때 사용하는 방법으로 <code>INTOEXISTS</code>와 <code>MATERIALIZATION</code> 두 가지 방법이 있다.</p>
</li>
<li><p><strong>BNL &amp; NO_BNL &amp; HASHJOIN &amp; NO_HASHJOIN</strong>
  해시 조인을 유도하고 싶으면 <code>BNL(테이블1, 테이블2)</code> 이렇게 힌트를 작성하고, 사용하지 않게 한다면 <code>NO_BNL</code> 힌트를 사용하면 된다.</p>
</li>
<li><p><strong>JOIN 관련</strong>
  JOIN 순서를 위한 4가지 힌트가 있다.</p>
<ul>
<li><code>JOIN_FIXED_ORDER()</code>: <code>FROM</code>절의 테이블 순서대로 조인을 실행</li>
<li><code>JOIN_ORDER(tb1,tb2, ...)</code>: 힌트에 명시된 테이블 순서대로 조인을 실행</li>
<li><code>JOIN_PREFIX(tb1)</code>: 드라이빙 테이블을 설정</li>
<li><code>JOIN_SUFFIX(tb1, tb2, …)</code>: 드리븐 테이블을 설정</li>
</ul>
</li>
<li><p><strong>MERGE &amp; NO_MERGE</strong>
  <code>MERGE(서브쿼리_이름)</code>: 임시 테이블을 사용하지 않게 서브 쿼리를 외부 쿼리와 병합</p>
<p>  <code>NO_MERGE(서브쿼리_이름)</code>: 임시 테이블을 사용하도록 강제</p>
</li>
<li><p><strong>INDEX_MERGE &amp; NO_INDEX_MERGE</strong>
  인덱스 머지를 강제하거나 사용하지 않도록 하는 힌트이다.</p>
</li>
<li><p><strong>NO_ICP</strong>
  인덱스 컨디션 푸시다운 최적화는 성능 향상에 도움이 되므로 사용하는 방향으로 실행 계획을 수립한다.</p>
<p>  그런데 ICP로 인해 실행 계획 비용 계산이 잘못되어 잘못된 실행 계획이 수립될 수도 있다.</p>
<p>  그래서 <code>NO_ICP</code> 힌트로 ICP를 비활성화할 수 있다.</p>
</li>
<li><p><strong>SKIP_SCAN &amp; NO_SKIP_SCAN</strong>
  인덱스 스킵 스캔을 강제하거나 사용하지 않도록 하는 힌트이다.</p>
</li>
<li><p><strong>INDEX &amp; NO_INDEX</strong>
  이전에 사용되던 인덱스 힌트를 대체하는 용도로 제공되는 힌트이다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 8장] 인덱스]]></title>
            <link>https://velog.io/@jeong_hun_hui/Real-MySQL-8%EC%9E%A5-%EC%9D%B8%EB%8D%B1%EC%8A%A4</link>
            <guid>https://velog.io/@jeong_hun_hui/Real-MySQL-8%EC%9E%A5-%EC%9D%B8%EB%8D%B1%EC%8A%A4</guid>
            <pubDate>Sat, 09 Sep 2023 13:03:45 GMT</pubDate>
            <description><![CDATA[<h2 id="디스크-io">디스크 I/O</h2>
<p>DB의 성능 튜닝은 대부분 디스크 I/O를 줄이는 방향으로 이루어진다.</p>
<h3 id="ssd-vs-hdd">SSD vs HDD</h3>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/85d4dca2-8a4a-4b00-940b-21941af21cf2" alt="image"></p>
<p>SSD는 DRAM보다는 느리지만, HHD보다 1000배 가량 빠르다.</p>
<p>순차 I/O에선 SSD가 HDD보다 조금 빠르거나 비슷하지만, 랜덤 I/O에선 SSD가 HDD보다 압도적으로 빠르다.</p>
<p>→ DBMS에선 랜덤 I/O가 대부분이므로 SSD가 좋다.</p>
<h3 id="랜덤-io-vs-순차-io">랜덤 I/O vs 순차 I/O</h3>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/964f26f4-93f4-4cf0-959e-ec4c7478205d" alt="image"></p>
<p>랜덤 I/O, 순차 I/O 다 읽을 데이터의 위치로 디스크 헤더를 이동시킨 다음 데이터를 읽어야 한다.</p>
<p>위 그림 기준으로 순차 I/O는 읽을 데이터가 붙어있기 때문에 헤더를 1번만 움직여도 되지만 랜덤 I/O는 읽을 데이터가 흩어져있기 때문에 헤더를 3번 움직여야한다.</p>
<p>→ 순차 I/O의 성능이 랜덤 I/O보다 좋다.</p>
<p>→ 쿼리 튜닝 시 랜덤 I/O를 순차 I/O로 바꾸는 것이 중요하다.</p>
<hr>
<h2 id="b-tree-인덱스">B-Tree 인덱스</h2>
<h3 id="인덱스란">인덱스란?</h3>
<p>DBMS에서 인덱스는 데이터의 쓰기(<code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code>)성능을 희생하고 읽기 성능을 높이는 기능이다.</p>
<h3 id="b-tree-인덱스-1">B-Tree 인덱스</h3>
<p>B-Tree는 DB에서 가장 일반적으로 사용되는 인덱스 알고리즘이다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/c632dfd7-bbe4-47b1-8e1e-eb6b76ecdc5b" alt="image"></p>
<p>B-Tree는 트리 구조의 최상위에 하나의 <strong>루트 노드</strong>, 가장 하위에 있는 <strong>리프 노드</strong>, 루트 노드도 리프 노드도 아닌 중간 노드인 <strong>브랜치 노드</strong>로 구성된다.</p>
<p>인덱스의 키 값은 모두 정렬되어 있지만, 데이터 파일의 레코드는 정렬되어 있지 않다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/a1224b90-eec4-4615-ad87-9340ab1f6816" alt="image"></p>
<p>위 그림은 InnoDB에서 인덱스를 통해 데이터를 읽는 과정을 나타낸다.</p>
<p>InnoDB에선 PK만이 물리적인 주소를 가지므로 세컨더리 인덱스를 이용하여 데이터를 찾기 위해선 두번의 B-Tree 탐색이 필요하다.</p>
<h3 id="b-tree-인덱스-키-추가-삭제-변경">B-Tree 인덱스 키 추가, 삭제, 변경</h3>
<ul>
<li><p><strong>인덱스 추가로 인해 쓰기 성능에 미치는 영향</strong></p>
<p>  테이블에 레코드를 추가하는 작업의 비용을 1이라고 하면 인덱스에 키를 추가하는 비용은 대략 1.5이다.</p>
<p>  이 비용의 대부분은 디스크로 부터 인덱스 페이지를 읽고 쓰기를 해야해서 발생하는 비용이다.</p>
</li>
</ul>
<p>다른 스토리지 엔진에서는 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 문장이 실행되면 즉시 쓰기 연산을 실행한다.</p>
<p>But, InnoDB에선 필요한 경우 키 추가 작업을 지연시켜 나중에 처리할 수 있다.(By 체인지 버퍼)</p>
<p>But, PK나 유니크 인덱스의 경우는 중복 체크가 필요하므로 지연 처리가 불가능하다.</p>
<p>변경의 경우는, 인덱스는 키 값에 따라 저장될 위치가 결정되므로 삭제한 뒤 새로운 값을 추가하는 형태로 처리한다.</p>
<h3 id="b-tree-인덱스-키-검색">B-Tree 인덱스 키 검색</h3>
<p>여러 단점을 감당하며 인덱스를 쓰는 이유는 “<strong>빠른 검색</strong>” 때문이다.</p>
<ul>
<li><p><strong>B-Tree 인덱스 검색 시 주의점</strong></p>
<ul>
<li><p>100% 일치, 앞 부분만 일치, 부등호 비교 조건에서는 인덱스를 활용할 수 있지만, 인덱스를 구성하는 키 값의 뒷부분만 검색하는 용도로는 인덱스를 사용할 수 없다.</p>
<p>  ex1) 이름이 “John”인 사람 검색 → 인덱스 O</p>
<p>  ex2) 이름이 “Jo”로 시작하는 사람 검색 → 인덱스 O</p>
<p>  ex3) 이름이 “J” 이후 인 사람 검색 → 인덱스 O</p>
<p>  ex4) 이름이 “hn”로 끝나는 사람 검색 → 인덱스 X</p>
<p>  ex5) 이름이 “John”이 아닌 사람 검색 → 인덱스 X (사용하지만 비효율적)</p>
<p>  ex6) 이름이 “Jo”로 시작하지 않는 사람 검색 → 인덱스 X (경우에 따라 사용됨, 비효율적)</p>
</li>
<li><p>인덱스를 이용한 검색 시 인덱스의 키 값에 변형이 가해진 후 비교되는 경우는 사용할 수 없다.</p>
<p>  → 연산을 수행한 결과로 검색하는 작업은 B-Tree의 장점을 이용할 수 없다.</p>
</li>
<li><p>InnoDB에선 데이터를 읽어오며 해당 레코드의 잠금을 획득하는데, 인덱스가 설정되어 있지 않아서 테이블 풀 스캔을 하게 되면 많은 레코드와 갭이 잠기게되어 성능 문제나 데드락이 발생할 수 있다.</p>
<p>→ <strong>인덱스 설계가 매우 중요하다.</strong></p>
</li>
</ul>
</li>
</ul>
<h3 id="b-tree-인덱스-사용에-영향을-미치는-요소">B-Tree 인덱스 사용에 영향을 미치는 요소</h3>
<ol>
<li><p><strong>인덱스 키 값의 크기</strong></p>
<p> 디스크에 데이터를 저장하는 가장 기본 단위를 “<strong>페이지</strong>” 라 하고, ****인덱스도 페이지 단위로 관리된다.</p>
<p> B-Tree는 자식 노드의 개수가 가변적인데, MySQL에서는 인덱스의 페이지 크기와 키 값의 크기에 따라 자식 노드 수가 결정된다.</p>
<ul>
<li><p>페이지의 크기는 <code>innodb_page_size</code> 시스템 변수를 이용해 4~64kb 선택할 수 있다. (기본 값은 16kb)</p>
<p>만약 페이지 크기 = 16kb, 키 값의 크기 = 16b, 자식 노드 주소 = 12b 라고 하면 <code>16 * 1024 / (16 + 12)</code> = 585 개의 자식 노드를 가질 수 있다.</p>
<p>그러므로 당연히 키 값이 커지면 가질 수 있는 자식 노드의 수가 줄어들고, 그렇게 되면 디스크 I/O 횟수가 늘어나고, 속도가 느려지게 된다.</p>
<p>또한, 인덱스 키의 크기가 커지면 전체적인 인덱스의 크기도 커진다.</p>
<p>→ 인덱스를 캐시해 두는 InnoDB의 버퍼 풀 영역의 크기는 제한적이므로 인덱스 키의 크기가 커지면 메모리에 캐시해 둘 수 있는 레코드 수는 줄어들어 성능이 저하될 수 있다.</p>
</li>
</ul>
</li>
<li><p><strong>B-Tree 깊이</strong></p>
<p> 인덱스 키 값의 크기가 커지면 한 페이지에 담을 수 있는 키의 수가 적어지고, 같은 레코드 건수라 하더라도 B-Tree의 깊이가 깊어져서 더 많은 디스크 읽기가 필요하다.</p>
<p> ex) B-Tree의 깊이가 3이고 키의 크기가 16b면 최대 2억개 정도의 키를 담을 수 있지만, 키의 크기가 32b면 5천만개 정도의 키를 담을 수 있다.</p>
</li>
<li><p><strong>카디널리티</strong></p>
<p> 카디널리티는 데이터의 중복도를 말하며, 높을 수록 중복된 값이 적다.</p>
<p> 인덱스는 카디널리티가 높을수록 검색 대상이 줄어들기 때문에 더 빠르게 처리된다.</p>
<pre><code class="language-sql"> SELECT *
 FROM tb_test
 WHERE country = &#39;KOREA&#39; AND city = &#39;SEOUL&#39;;</code></pre>
<p> <code>tb_test</code> 테이블의 레코드 건수는 1만 건, <code>country</code> 컬럼에만 인덱스를 생성했다 했을 때 아래 두가지 케이스를 살펴보자</p>
<ol>
<li><p><strong>country 컬럼의 유니크한 값의 개수가 10개인 경우</strong></p>
<p> <code>country = ‘KOREA’</code> 이 조건에서 일치하는 값은 1000개 일 것이고, 그 중 <code>city = ‘SEOUL’</code> 인 값이 1건이면 999건은 불필요하게 읽은 것이다.</p>
</li>
<li><p><strong>country 컬럼의 유니크한 값의 개수가 1000개인 경우</strong></p>
<p> <code>country = ‘KOREA’</code> 이 조건에서 일치하는 값은 10개 일 것이고, 그 중 <code>city = ‘SEOUL’</code> 인 값이 1건이면 9건은 불필요하게 읽은 것이다.</p>
</li>
</ol>
</li>
</ol>
<pre><code>→ 카디널리티가 높을 수록 읽어오는 데이터가 줄어들며 인덱스의 효율이 상승한다.</code></pre><ol start="4">
<li><p><strong>읽어야 하는 레코드의 건수</strong></p>
<p> 인덱스를 통해 레코드를 읽는 것은 바로 테이블의 레코드를 읽는 것 보다 비용이 높다. (약 4~5배)</p>
<p> 즉, 인덱스를 통해 읽어야 할 레코드의 건수가 전체 테이블 레코드의 20~25%를 넘어서면 인덱스를 이용하지 않고 테이블 풀 스캔 후 필요한 레코드만 걸러내는 방식으로 처리한다.</p>
<p> → 읽어야 하는 레코드의 건수가 많을 수록 인덱스 사용이 비효율적이다.</p>
</li>
</ol>
<h3 id="b-tree-인덱스를-통한-데이터-읽기">B-Tree 인덱스를 통한 데이터 읽기</h3>
<ol>
<li><p><strong>인덱스 레인지 스캔</strong></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/d99e7411-592f-4283-a914-e7644a5c922d" alt="image"></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/8c98182d-b044-4b6d-be46-eb2068d5bbd3" alt="image"></p>
<p> 인덱스의 접근 방법 중 가장 대표적이고, 뒤에 나오는 다른 방식보다 빠르다.</p>
<p> <strong>[ 과정 ]</strong></p>
<ol>
<li><p>인덱스에서 조건이 만족하는 값이 저장된 위치를 찾는다. (인덱스 탐색)</p>
</li>
<li><p>탐색된 위치부터 필요한 만큼 인덱스를 차례대로 읽는다. (인덱스 스캔)</p>
<p> → 해당 인덱스를 구성하는 컬럼의 정순 or 역순으로 정렬된 상태로 레코드를 가져온다.</p>
</li>
<li><p>읽어온 인덱스 키와 레코드 주소를 이용해 레코드를 읽는다.</p>
<p> → 이때 레코드 한 건 마다 랜덤 I/O가 일어난다.</p>
<p> → 인덱스를 통해 레코드를 읽는 작업은 그냥 읽는 것 보다 비용이 많이든다.</p>
<p> 근데, 만약 필요한 데이터가 전부 인덱스에 있다면 이 과정을 생략한다. (커버링 인덱스)</p>
</li>
</ol>
</li>
<li><p><strong>인덱스 풀 스캔</strong></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/6debcc11-c89e-4059-8d71-cf53890f867a" alt="image"></p>
<p> 인덱스를 사용하지만 인덱스의 처음부터 끝까지 모두 읽는 방식이다.</p>
<p> 인덱스 크기가 테이블의 크기보다 작으므로 인덱스만으로 조건을 처리할 수 있을 때 테이블 전체를 읽는 것 보다 인덱스를 읽는 것이 더 효율적이고, 그럴 때 인덱스 풀 스캔 방식이 사용된다.</p>
</li>
<li><p><strong>루스 인덱스 스캔</strong></p>
<p> 인덱스 레인지 스캔과 비슷하지만, 중간에 필요치 않은 인덱스 키 값은 무시하고 넘어가는 형태로 처리한다.</p>
<p> 일반적으로 <code>GROUP BY</code> or 집합 함수 중 <code>MIN()</code>, <code>MAX()</code> 함수에 대해 최적화를 하는 경우에 사용한다.</p>
</li>
<li><p><strong>인덱스 스킵 스캔</strong></p>
<p> 인덱스 스킵 스캔이 없다면 <code>new_idx(A,B)</code> 와 같은 인덱스가 있을 때 아래 쿼리는 인덱스를 효율적으로 사용할 수 없다.</p>
<pre><code class="language-sql"> SELECT * FROM employees WHERE B &gt;= &#39;2000-11-05&#39;;</code></pre>
<p> 하지만, 인덱스 스킵 스캔은 아래와 같은 방식으로 처리하여 인덱스를 활용할 수 있다.</p>
<pre><code class="language-sql"> // A 컬럼이 &#39;type1&#39;, &#39;type2&#39; 두 종류의 값만 가지고 있음
 SELECT * FROM employees WHERE A = &#39;type1&#39; AND B &gt;= &#39;2000-11-05&#39;;
 SELECT * FROM employees WHERE A = &#39;type2&#39; AND B &gt;= &#39;2000-11-05&#39;;</code></pre>
<p> 위와 같이 처리하므로 아래와 같은 단점들이 있다.</p>
<ol>
<li><code>WHERE</code> 조건절에 조건이 없는 인덱스의 선행 컬럼(위에선 A 컬럼)의 유니크한 값의 개수가 적어야한다.</li>
<li>쿼리가 인덱스에 존재하는 컬럼만으로 처리 가능해야 한다. (커버링 인덱스)</li>
</ol>
</li>
</ol>
<h3 id="다중-칼럼-인덱스복합-인덱스">다중 칼럼 인덱스(복합 인덱스)</h3>
<p>여러 컬럼을 포함하는 인덱스를 말한다.</p>
<p>중요한 점은, 복합 인덱스 생성 시 순서에 따라 정렬 값이 결정된다.</p>
<p>예를 들어 index_1(A,B) 이러한 인덱스라면, A에 따라 정렬된 뒤 A 값이 같은 경우 B 컬럼으로 정렬된다.</p>
<p>→ <strong>복합 인덱스 생성 시 컬럼의 순서가 매우 중요하다.</strong></p>
<h3 id="인덱스의-정렬-및-스캔-방향">인덱스의 정렬 및 스캔 방향</h3>
<p>인덱스 역순 스캔은 아래의 이유들 때문에 정순 스캔에 비해 28.9% 정도 시간이 더 걸린다.</p>
<ol>
<li>페이지 잠금이 인덱스 정순 스캔에 적합한 구조이다.</li>
<li>페이지 내에서 인덱스 레코드가 단방향으로만 연결된 구조이다.</li>
</ol>
<p>→ 자주 사용되는 정렬 순서로 인덱스를 생성하는 것이 효율적이다.</p>
<h3 id="인덱스의-가용성과-효율성">인덱스의 가용성과 효율성</h3>
<ol>
<li>복합 인덱스의 경우 카디널리티가 높은 컬럼을 앞으로 해서 생성하는 것이 좋다.</li>
<li>복합 인덱스의 첫 번째 컬럼이 없으면 인덱스 레인지 스캔 방식을 이용할 수 없어서 효율이 떨어진다.</li>
<li><code>≠</code>, <code>LIKE ‘%??’</code>, 인덱스 컬럼이 변형된 후 비교, 데이터 타입이 다른 경우 등 몇 몇 경우에는 인덱스를 효율적으로 활용할 수 없다.</li>
</ol>
<hr>
<h2 id="그-외-다양한-인덱스들">그 외 다양한 인덱스들</h2>
<ol>
<li><p><strong>R-Tree 인덱스</strong></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/fea01e9b-0b77-4d73-ab09-fb8317de2019" alt="image"></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/22341572-eff9-47d5-80d8-cb2f46b7135c" alt="image"></p>
<ul>
<li><p><strong>공간 인덱스</strong></p>
<p>  R-Tree 인덱스 알고리즘을 이용해 2차원의 데이터를 인덱싱하고 검색하는 목적의 인덱스이다.</p>
</li>
</ul>
</li>
</ol>
<pre><code>B-Tree와 내부 메커니즘은 비슷하지만, R-Tree는 인덱스를 구성하는 컬럼의 값이 2차원 공간 값이다.

MySQL은 공간 데이터 타입, 공간 인덱스, 공간 연산 함수 를 제공한다.

각 도형을 감싸는 최소 크기의 사각형을 기준으로 인덱스가 생성된다.

위 그림 처럼 루트 노드가 여러 도형들을 감싸는 가장 큰 범위의 사각형, 리프 노드가 하나의 도형을 감싸는 최소 크기의 사각형 인 형태로 되어있다.

`ST_Contains()`, `ST_Within()` 함수로 공간 인덱스를 활용한 효율적인 공간 검색이 가능하다.</code></pre><ol start="2">
<li><p><strong>전문 검색 인덱스</strong></p>
<p> 문서 전체에 대한 분석과 검색을 위한 인덱싱 알고리즘을 말한다.</p>
<p> 불용어 처리, 어근 분석 의 과정을 거쳐 색인 작업이 수행된다.</p>
</li>
<li><p><strong>함수 기반 인덱스</strong></p>
<p> 칼럼의 값을 변형해서 만들어진 값에 대한 인덱스이다.</p>
<p> 구현 방법은 아래와 같이 2가지가 있다.</p>
<ol>
<li><p>가상 컬럼을 이용한 인덱스</p>
</li>
<li><p>함수를 이용한 인덱스</p>
<p>예를 들어 <code>first_name</code>, <code>last_name</code> 이렇게 2개 컬럼이 있고 이를 이용하여 <code>full_name</code>이라는 가상 컬럼을 만들면 가상 컬럼을 이용한 인덱스를 만들 수 있다.</p>
</li>
</ol>
</li>
<li><p>멀티 밸류 인덱스</p>
<p> 하나의 레코드에 여러가지 값의 인덱스를 가질 수 있는 인덱스이다.</p>
<p> 주로 JSON의 배열 타입의 필드에 저장된 원소들에 대한 인덱스를 구현하기 위해 사용된다.</p>
</li>
</ol>
<h3 id="클러스터링-인덱스란">클러스터링 인덱스란?</h3>
<p>MySQL 서버에서 클러스터링은 테이블의 레코드를 비슷한 것(PK 기준)들 끼리 묶어서 저장하는 것을 말한다.</p>
<p>그러므로 PK 값이 변경된다면 그 레코드의 물리적인 저장 위치가 바뀌어야하고, PK 기반 검색이 매우 빠르지만, 레코드 저장 및 PK의 변경이 느리다.</p>
<p>만약 PK가 없다면 아래와 같은 로직으로 대체 컬럼을 선택한다.</p>
<ol>
<li><code>NOT NULL</code> 옵션의 유니크 인덱스 중 첫 번째 인덱스를 클러스터링 키로 선택한다.</li>
<li>자동으로 유니크한 값을 가지도록 증가되는 컬럼을 내부적으로 추가한 후 클러스터링 키로 선택한다.</li>
</ol>
<h3 id="클러스터링-인덱스의-장단점">클러스터링 인덱스의 장단점</h3>
<ul>
<li><strong>장점</strong><ul>
<li>PK 검색 시 처리 성능이 매우 빠르다.</li>
<li>모든 세컨더리 인덱스가 PK를 가지고 있으므로 인덱스만으로 처리될 수 있는 경우가 많다. (커버링 인덱스)</li>
</ul>
</li>
<li><strong>단점</strong><ul>
<li>테이블의 모든 세컨더리 인덱스가 클러스터링 키를 갖기 때문에 클러스터링 키 값의 크기가 클 경우 전체적인 인덱스의 크기가 커진다.</li>
<li>세컨더리 인덱스를 통해 검색할 때 PK로 한번 더 검색해야 하므로 성능이 느리다.</li>
<li><code>INSERT</code>할 때 PK에 의해 저장 위치가 결정되므로 처리 성능이 느리다.</li>
<li>PK를 변경할 때 레코드를 <code>DELETE</code>하고 <code>INSERT</code>하는 작업이 필요하기 때문에 처리 성능이 느리다.</li>
</ul>
</li>
</ul>
<h3 id="유니크-인덱스">유니크 인덱스</h3>
<p>유니크 인덱스는 같은 값이 2개 이상 저장될 수 없는 인덱스를 말한다.</p>
<p>하지만 NULL은 특정 값이 아니므로 2개 이상 저장될 수 있다.</p>
<p>PK는 기본적으로 NOT NULL + 유니크 인덱스이다.</p>
<h3 id="유니크-vs-일반-인덱스">유니크 VS 일반 인덱스</h3>
<ul>
<li><strong>인덱스 읽기</strong><ul>
<li>유니크 인덱스는 데이터가 고유하므로 일치하는 레코드를 하나 찾으면 바로 검색이 끝난다.</li>
<li>반면, 일반 인덱스는 같은 인덱스 키 값인 레코드가 여러개일 수 있어서 여러 값중 어떤 것이 만족하는 레코드인지 찾는 연산이 필요하다.</li>
<li>하지만 이는 디스크 접근이 아닌 메모리 상에서 이뤄지는 작업이므로 성능에 영향이 거의 없다.</li>
<li>그러므로 유니크 vs 일반 인덱스는 같은 수의 레코드를 읽을 때 성능 차이가 거의 없다.<ul>
<li>물론 일반 인덱스는 중복된 데이터 때문에 더 많은 데이터를 읽을 수 있다.</li>
</ul>
</li>
<li><strong>결론:</strong> 같은 수의 레코드를 읽을 때 두 인덱스의 읽기 성능은 차이가 거의 없다.</li>
</ul>
</li>
<li><strong>인덱스 쓰기</strong><ul>
<li>유니크 인덱스의 키 값을 쓸 때는 중복 체크 과정이 필요해서 일반 인덱스 보다 느리다.</li>
<li>근데, MySQL에선 유니크 인덱스에서 중복 체크 시 읽기 잠금을 사용하고, 쓰기 시 쓰기 잠금을 사용하므로 데드락이 자주 발생한다.</li>
<li>또한, 유니크 인덱스는 저장 및 변경 시 중복 체크를 해야하므로 작업을 버퍼링하지 못한다.</li>
</ul>
</li>
</ul>
<p>→ 유일성이 꼭 보장되어야 하는 컬럼에 대해선 유니크 인덱스를 생성하되, 그렇지 않다면 일반 인덱스 생성을 고려하자.</p>
<h3 id="왜래키">왜래키</h3>
<p>왜래키 제약이 설정되면 자동으로 연관되는 테이블의 컬럼에 인덱스가 생성된다.</p>
<pre><code class="language-sql">// 커넥션 1 시작
// 커넥션 1 부모 테이블 컬럼 업데이트
UPDATE users SET name = &#39;change&#39; WHERE id = 1;
// 커넥션 2 시작
// 커넥션 2 자식 테이블 컬럼 업데이트
UPDATE posts SET user_id = 1 WHERE id = 100;
// 커넥션 1 롤백
// 커넥션 2 완료</code></pre>
<p>InnoDB의 왜래키 관리에는 두 가지 특징이 있음</p>
<ul>
<li><p>테이블의 변경(쓰기 잠금)이 발생하는 경우에만 잠금 대기가 발생한다.</p>
<p>  → 위와 같이 자식 테이블의 왜래키 변경(<code>UPDATE posts SET user_id = 1 WHERE id = 100;</code>)은 부모 테이블의 확인이 필요한데, 이 상태에서 부모 테이블의 해당 레코드가 쓰기 잠금이 걸려 있으면 해당 쓰기 잠금이 해제될 때 까지 기다린다. (테이블이 변경되며 잠금 대기가 발생했다.)</p>
</li>
<li><p>왜래키가 연관되지 않은 컬럼의 변경은 최대한 잠금 대기를 발생시키지 않는다.</p>
<p>  → 자식 테이블의 왜래키가 아닌 컬럼의 변경(<code>UPDATE posts SET user_id = 1 WHERE id = 100;</code>)은 왜래키로 인한 잠금 확장이 발생하지 않는다. (왜래키가 연관되지 않아서 잠금 대기가 발생하지 않았다.)</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 5장] 트랜잭션과 잠금]]></title>
            <link>https://velog.io/@jeong_hun_hui/Real-MySQL-5%EC%9E%A5-%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/@jeong_hun_hui/Real-MySQL-5%EC%9E%A5-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EC%9E%A0%EA%B8%88</guid>
            <pubDate>Fri, 08 Sep 2023 09:18:11 GMT</pubDate>
            <description><![CDATA[<h2 id="mysql에서의-트랜잭션">MySQL에서의 트랜잭션</h2>
<h3 id="트랜잭션-이란">트랜잭션 이란?</h3>
<p>트랜잭션은 꼭 여러 개의 변경 작업을 수행하는 쿼리가 조합됐을 때를 말하는 것이 아니라, 하나의 작업 세트가 일부만 적용되지 않도록 함을 보장하는 것을 말한다.</p>
<p>즉, 전부 다 적용되어 <code>COMMIT</code> 되거나, 오류 발생으로 변경된 사항을 전부 <code>ROLLBACK</code> 함을 보장한다.</p>
<h3 id="트랜잭션-설정-범위">트랜잭션 설정 범위</h3>
<p>트랜잭션은 꼭 필요한 최소의 코드에만 적용하는 것이 좋다.</p>
<p>왜냐하면 DB의 커넥션 수가 제한적이고, 커넥션을 소유하는 시간이 길어질 수록 여유 커넥션 수는 줄어들기 때문이다.</p>
<p>아래 예시를 통해 트랜잭션의 적절한 범위에 대해 알아보자.</p>
<pre><code>1) 처리 시작
    -&gt; 커넥션 생성
    -&gt; 트랜잭션 시작
2) 로그인 여부 확인
3) 글쓰기 오류 확인
4) 첨부로 업로드된 파일 저장
5) 사용자의 입력 내용을 DBMS에 저장
6) 첨부 파일 정보를 DBMS에 저장
7) 저장된 내용 또는 기타 정보를 DBMS에서 조회
8) 게시물 등록에 대한 알림 메일 발송
9) 알림 메일 발송 이력을 DBMS에 저장
10) 처리 완료
    -&gt; 트랜잭션 COMMIT
    -&gt; 커넥션 반납</code></pre><p>위와 같은 과정의 경우 DBMS에 관련되지 않은 작업(2,3,4,8)도 트랜잭션에 들어가 있다.</p>
<p>이런 경우, 쓸데없이 커넥션만 더 오래 소유하고 있게 되므로 트랜잭션에 포함시키지 않는 것이 좋다.</p>
<p>또한, 위 작업은 크게 <strong>글 저장 관련 작업</strong>과 <strong>알림 메일 발송 작업</strong>으로 나뉠 수 있는데, 성격이 다른 작업의 경우는 다른 트랜잭션으로 분리하는 것이 좋다.</p>
<p>그리고, 단순 조회의 경우는 트랜잭션에 포함될 필요는 없으므로 아래와 같이 과정을 바꿀 수 있다.</p>
<pre><code>1) 처리 시작
2) 로그인 여부 확인
3) 글쓰기 오류 확인
4) 첨부로 업로드된 파일 저장
    -&gt; 커넥션 생성
    -&gt; 트랜잭션A 시작
5) 사용자의 입력 내용을 DBMS에 저장
6) 첨부 파일 정보를 DBMS에 저장
    -&gt; 트랜잭션A COMMIT
7) 저장된 내용 또는 기타 정보를 DBMS에서 조회
8) 게시물 등록에 대한 알림 메일 발송
    -&gt; 트랜잭션B 시작
9) 알림 메일 발송 이력을 DBMS에 저장
    -&gt; 트랜잭션B COMMIT
    -&gt; 커넥션 반납
10) 처리 완료</code></pre><hr>
<h2 id="mysql-엔진의-잠금">MySQL 엔진의 잠금</h2>
<p>MySQL에서 사용되는 잠금은 크게 스토리지 엔진 레벨과 MySQL 엔진 레벨로 나눌 수 있다.</p>
<h3 id="글로벌-락">글로벌 락</h3>
<p>한 세션에서 글로벌 락을 획득하면 다른 세션에서 <code>SELECT</code>를 제외한 대부분의 DDL, DML 문장을 실행하는 경우 글로벌 락이 해제될 때까지 해당 문장이 대기 상태로 남는다.</p>
<p>글로벌 락의 범위는 MySQL 서버 전체이므로 테이블이나 데이터베이스가 다르더라도 영향을 받는다.</p>
<p>전체 데이터의 변경 작업을 멈추어야 할 때 적용할 수 있지만, 트랜잭션을 지원하므로 그럴 필요가 없고, 글로벌 락은 서버에 큰 영향을 미치므로 사용하지 않는 것이 좋다.</p>
<p>MySQL 8.0 부터는 조금 더 가벼운 글로벌 락인 백업 락이 생겼다.</p>
<h3 id="테이블-락">테이블 락</h3>
<p>개별 테이블 단위로 설정되는 잠금을 말한다.</p>
<ul>
<li><p><strong>명시적 테이블 락</strong></p>
<pre><code class="language-sql">  // 테이블 락 획득
  LOCK TABLES table_name [ READ | WRITE ];

  // 테이블 락 반납
  UNLOCK TABLES;</code></pre>
<p>  글로벌 락과 마찬가지로 서버에 큰 영향을 미치고, 사용할 필요가 거의 없다.</p>
</li>
<li><p><strong>묵시적 테이블 락</strong></p>
<p>  InnoDB는 스토리지 엔진 차원에서 레코드 기반 잠금을 제공하므로 스키마를 변경하는 DDL 쿼리의 경우에만 테이블 락이 설정된다.</p>
</li>
</ul>
<h3 id="네임드-락">네임드 락</h3>
<pre><code class="language-sql">// mylock 이라는 문자열에 대해 잠금 획득
SELECT GET_LOCK(&#39;mylock&#39;, 2);

// mylock 이라는 문자열에 대해 잠금이 설정되어 있는지 확인
SELECT IS_FREE_LOCK(&#39;mylock&#39;);

// mylock 이라는 문자열에 대해 획득한 잠금을 반납
SELECT RELEASE_LOCK(&#39;mylock&#39;);</code></pre>
<p>사용자가 지정한 문자열에 대해 잠금을 설정하는 것을 말한다.</p>
<p>자주 사용되진 않고, 여러 클라이언트가 상호 동기화를 처리해야 하는 경우에 사용된다.</p>
<ul>
<li>ex) 1대의 DB서버에 여러 웹 서버가 접속하는 서비스에서 여러 웹 서버가 어떤 정보를 동기화 하는 경우</li>
</ul>
<p>위 예시와 같이 여러 서비스가 접속해 있는 상황에서 한번에 많은 데이터를 수정하는 경우 데드락의 원인이 된다.</p>
<p>→ 이때 동일한 데이터를 변경하거나 참조하는 프로그램끼리 분류해서 네임드 락을 걸면 데드락을 막을 수 있다.</p>
<h3 id="메타데이터-락">메타데이터 락</h3>
<p>DB객체(테이블, 뷰 등)의 이름이나 구조를 변경하는 경우에 획득하는 잠금을 말한다.</p>
<p>명시적으로는 획득이 불가능하고, <code>RENAME TABLE table_1 TO table_2</code> 이런 식으로 테이블의 이름을 변경할 때 자동으로 획득한다.</p>
<pre><code class="language-sql">// 1. 두 RENAME 작업을 각각의 문장으로 실행
RENAME TABLE rank TO rank_backup;
RENAME TABLE rank_new TO rank;

// 2. 두 RENAME 작업을 하나의 문장으로 실행
RENAME TABLE rank TO rank_backup, rank_new TO rank;</code></pre>
<p>1번의 경우는 짧은 시간동안 rank 테이블이 존재하지 않는 시간이 생겨 <code>Table not found &#39;rank&#39;</code> 오류 발생하지만 2번과 같이 하나의 문장으로 실행하면 정상적으로 RENAME 작업이 실행된다.</p>
<hr>
<h2 id="innodb-스토리지-엔진의-잠금">InnoDB 스토리지 엔진의 잠금</h2>
<p>InnoDB는 MySQL에서 제공하는 잠금과는 별개로 레코드 기반의 잠금 방식을 탑재하고 있다.</p>
<p>→ 동시성 처리가 매우 뛰어나다.</p>
<h3 id="레코드-락"><strong>레코드 락</strong></h3>
<p>레코드 자체만을 잠그는 것을 말한다.</p>
<p>InnoDB는 레코드 자체가 아닌 인덱스의 레코드를 잠근다. (인덱스 생성을 안했어도 자동 생성되는 클러스터 인덱스를 이용하여 잠근다.)</p>
<h3 id="갭-락"><strong>갭 락</strong></h3>
<p>레코드와 바로 인접한 레코드 사이의 간격만을 잠그는 것을 의미한다.</p>
<p>→ 레코드와 레코드 사이의 간격에 새로운 레코드가 생성되는 것을 제어할 수 있다.</p>
<p>갭 락 자체 보단 넥스트 키 락의 일부로 자주 사용된다.</p>
<h3 id="넥스트-키-락"><strong>넥스트 키 락</strong></h3>
<p>레코드 락과 갭락을 합친 잠금이다.</p>
<p>바이너리 로그에 기록되는 쿼리가 레플리카 서버에서 실행될 때 소스 서버에서 만들어 낸 결과와 동일한 결과를 만들어내도록 보장한다.</p>
<p>→ 넥스트 키 락으로 인해 데드락을 발생하는 일이 자주 발생하면 바이너리 로그 포맷을 ROW 형태로 설정하면 좋다.</p>
<h3 id="자동-증가-락"><strong>자동 증가 락</strong></h3>
<p><code>AUTO_INCREMENT</code> 칼럼이 사용된 테이블에 여러 레코드가 <code>INSERT</code>될 경우 각 레코드는 중복되지 않고 순차적인 값을 가져야하는데 이를 위해 자동 증가 락을 사용한다.</p>
<p>시스템 변수를 통해 서버가 <code>INSERT</code>되는 레코드 건수를 정확히 예측할 수 있을 때는 자동 증가 락 대신 래치(뮤텍스)를 이용하여 처리하게 할 수 있다.</p>
<h3 id="인덱스와-잠금">인덱스와 잠금</h3>
<p>InnoDB의 잠금은 레코드를 잠그는 것이 아닌 인덱스를 잠그는 방식으로 처리된다.</p>
<pre><code class="language-sql">// employees 테이블의 first_name 컬럼에만 index가 있는 상태
UPDATE employees
SET hire_date = NOW()
WHERE first_name = &#39;Georgi&#39; AND last_name = &#39;Klassen&#39;;
// -&gt; 1개의 row 업데이트</code></pre>
<p>위 상황에서는, <code>first_name</code>이 <code>Georgi</code>인 모든 레코드를 잠그고 <code>last_name</code>이 <code>Klassen</code>인 레코드를 찾는다.</p>
<p>→ 인덱스가 적절히 설정되어 있지 않으면 잠금의 범위가 커져서 동시성이 떨어진다.</p>
<p>만약 아예 인덱스가 없다면 테이블을 풀스캔하며 <code>UPDATE</code> 작업을 하는데, 이 과정에서 모든 레코드를 잠궈버린다.</p>
<p>→ 잠금의 범위를 최소화할 수 있도록 인덱스 설계를 잘 해야한다.</p>
<hr>
<h2 id="mysql의-격리-수준">MySQL의 격리 수준</h2>
<p>트랜잭션의 격리 수준이란, 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 다루는 데이터를 볼 수 있게 허용할지를 결정하는 것을 말한다.</p>
<h3 id="격리-수준의-종류와-세-가지-문제점">격리 수준의 종류와 세 가지 문제점</h3>
<table>
<thead>
<tr>
<th></th>
<th>DIRTY READ</th>
<th>NON-REPEATABLE READ</th>
<th>PHANTOM READ</th>
</tr>
</thead>
<tbody><tr>
<td>READ UNCOMMITTED</td>
<td>O</td>
<td>O</td>
<td>O</td>
</tr>
<tr>
<td>READ COMMITTED</td>
<td>X</td>
<td>O</td>
<td>O</td>
</tr>
<tr>
<td>REPEATABLE READ</td>
<td>X</td>
<td>X</td>
<td>O (InnoDB는 X)</td>
</tr>
<tr>
<td>SERIALIZABLE</td>
<td>X</td>
<td>X</td>
<td>X</td>
</tr>
</tbody></table>
<p>4가지의 격리 수준에서 아래로 갈 수록 격리 수준이 높아지고 동시 처리 성능이 떨어진다.</p>
<p>READ UNCOMMITTED는 거의 사용X, SERIALIZABLE는 동시성이 중요한 경우엔 거의 사용하지 않는다.</p>
<h3 id="격리-수준의-종류">격리 수준의 종류</h3>
<ol>
<li><p><strong>READ UNCOMMITTED</strong></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/521c0ebb-2563-4799-91e6-70f332eb3a1f" alt="image"></p>
<p> <code>READ UNCOMMITTED</code> 격리 수준에서는 각 트랜잭션에서의 변경 내용이 <code>COMMIT</code>, <code>ROLLBACK</code> 여부에 상관없이 다른 트랜잭션에서 조회할 수 있다.</p>
<p> → 아직 <code>COMMIT</code>되지 않은 내용이 조회되는 <code>DIRTY READ</code> 문제가 발생할 수 있다.</p>
<p> → 만약 오류로 인해 <code>ROLLBACK</code> 되더라도 이미 조회한 다른 사용자는 잘못된 정보를 가지게 된다.</p>
</li>
<li><p><strong>READ COMMITTED</strong></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/14c00054-0a3f-4f90-81ca-8837184ce0cb" alt="image"></p>
<p> <code>READ UNCOMMITTED</code> 격리 수준에서는 <code>COMMIT</code>이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있다.</p>
<p> 새로운 데이터가 들어오면 변경 전 데이터는 언두 로그에 저장하고, <code>COMMIT</code> 되기전에 다른 트랜잭션에서 조회하면 언두 로그에 있는 변경 전 데이터를 반환한다.</p>
<p> → 하나의 트랜잭션 안에서 동일한 <code>SELECT</code> 쿼리로 데이터를 요청하면 항상 같은 결과를 가져오지 못하는 문제인 <code>NON-REPEATABLE READ</code> 문제가 발생할 수 있다.</p>
<p> 일반적인 웹 서비스에선 큰 문제가 아닐 수 있지만 하나의 트랜잭션에서 동일 데이터를 여러 번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 될 수 있다.</p>
<p> 참고로, 오라클 DBMS의 기본 격리 수준이자, 온라인 서비스에서 가장 많이 사용되는 격리 수준이다.</p>
</li>
<li><p><strong>REPEATABLE READ</strong></p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/585e69ad-4729-4e05-a66f-1cc7919bba55" alt="image"></p>
<p> <code>REPEATABLE READ</code> 격리 수준에서는 트랜잭션 Id를 기준으로 자신 이후에 발생한 트랜잭션에서의 변경사항은 읽지 않는다.</p>
<p> → 동일 트랜잭션 내에서는 동일한 결과를 보여줌을 보장한다.</p>
<p> 원래 <code>REPEATABLE READ</code>는 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안 보였다 하는 <code>PHANTOM READ</code> 현상이 발생하지만 InnoDB에선 일부 특별한 쿼리(<code>FOR UPDATE</code> 등)를 제외하고는 발생하지 않는다.</p>
<p> 참고로 InnoDB에서 기본으로 사용되는 격리 수준이다.</p>
</li>
<li><p><strong>SERIALIZABLE</strong></p>
<p> <code>SERIALIZABLE</code> 격리 수준에선 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서 절대 접근할 수 없다.</p>
<p> 읽기 작업에도 읽기 잠금을 획득해야하므로 동시 처리 성능이 매우 떨어진다.</p>
<p> InnoDB는 갭 락과 넥스트 키 락 덕분에 <code>PHANTOM READ</code>가 발생하지 않기 때문에 사용할 이유가 없다.</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Real MySQL 4장] 아키텍처]]></title>
            <link>https://velog.io/@jeong_hun_hui/Real-MySQL-4%EC%9E%A5-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@jeong_hun_hui/Real-MySQL-4%EC%9E%A5-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Fri, 08 Sep 2023 08:54:55 GMT</pubDate>
            <description><![CDATA[<h2 id="mysql-아키텍처">MySQL 아키텍처</h2>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/007254ba-94b5-4ff3-aa39-c485d815795a" alt="image"></p>
<p>프로그래밍 API를 통해 MySQL에 요청을 보낸다.</p>
<p>MySQL은 요청 받은 쿼리를 분석해서 최적의 실행계획을 수립 및 스토리지 엔진에게 명령한다.</p>
<p>받은 명령대로 스토리지 엔진이 디스크에 접근하여 데이터를 입출력한다.</p>
<h3 id="쿼리-실행-구조">쿼리 실행 구조</h3>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/63def712-0673-4a88-b7bc-2b27ed4c05de" alt="image"></p>
<ol>
<li><p><strong>쿼리 파서</strong></p>
<p> 요청으로 들어온 쿼리를 MySQL이 인식할 수 있는 최소 단위인 토큰으로 분리해 트리 형태의 구조로 만드는 역할을 한다.</p>
<p> 쿼리 문장의 기본 문법 오류는 여기서 발견된다.</p>
</li>
<li><p><strong>전처리기</strong></p>
<p> 각 토큰을 테이블, 컬럼 등과 매핑해 해당 객체의 존재 여부와 접근 권한을 확인한다.</p>
</li>
<li><p><strong>옵티마이저</strong></p>
<p> 요청이 들어온 쿼리를 가장 효율적으로 실행할 수 있는 실행 계획을 수립한다.</p>
</li>
<li><p><strong>실행 엔진</strong></p>
<p> 옵티마이저가 수립한 실행 계획대로 핸들러에게 명령을 내린다.</p>
</li>
<li><p><strong>핸들러(스토리지 엔진)</strong></p>
<p> 실행 엔진의 요청에 따라 데이터를 디스크로 저장하고 읽어오는 역할을 담당한다.</p>
</li>
</ol>
<h3 id="쿼리-캐시">쿼리 캐시</h3>
<p>쿼리 캐시는 성능 자체는 빨랐으나, 테이블의 데이터가 변경되면 변경된 테이블에 관련된 캐시들을 삭제해야 했기 때문에 성능 저하를 유발하여 MySQL 8.0 이후로는 제거됨</p>
<h3 id="스레딩-구조">스레딩 구조</h3>
<p>MySQL 서버는 스레드 기반으로 작동하며, 포그라운드 스레드와 백그라운드 스레드로 구분할 수 있다.</p>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/43d2dcaf-09eb-4a55-8f42-d3fc1f16b8f3" alt="image"></p>
<ul>
<li><p><strong>포그라운드 스레드(클라이언트 스레드)</strong></p>
<p>  MySQL 서버에 접속된 클라이언트의 수만큼 존재하며, 주로 각 클라이언트의 사용자가 요청하는 쿼리 문장을 처리한다.</p>
<p>  포그라운드 스레드는 데이터를 버퍼나 캐시에서 읽어와서 작업을 처리한다.</p>
</li>
<li><p><strong>백그라운드 스레드</strong></p>
<p>  여러 작업들이 백그라운드 스레드로 처리된다. 아래는 그 중 몇 가지 작업들이다.</p>
<ul>
<li>로그를 디스크로 기록하는 스레드</li>
<li>InnoDB 버퍼 풀의 데이터를 디스크에 기록하는 스레드</li>
<li>잠금이나 데드락을 모니터링하는 스레드</li>
</ul>
</li>
</ul>
<h3 id="스레드-풀">스레드 풀</h3>
<p>스레드가 너무 많아지면 성능이 감소한다. 성능이 떨어지는 데는 아래와 같은 원인들이 있다.</p>
<ul>
<li>스레드가 많아질수록 더 많은 <strong>컨텍스트 스위칭</strong>이 발생하게 되어 성능이 감소한다.</li>
<li>여러 스레드가 동시에 같은 자원에 접근하려고 할 때 리소스 경합이 발생하며 대기 시간이 증가하고 성능이 감소한다.</li>
</ul>
<p>→ 스레드 풀은 CPU가 제한된 개수의 스레드 처리에만 집중할 수 있게 해서 서버의 자원 소모를 줄이는 것이 목적이다.</p>
<p>MySQL 커뮤니티 에디션에서 스레드 풀 기능을 쓰려면 스레드 풀 플러그인(Percona Server)을 추가로 설치해야한다.</p>
<p>스레드 풀을 도입한다고 무조건 성능이 향상되는 것이 아니라, 적절한 세팅이 중요하다.</p>
<h3 id="스레드-풀-동작-과정"><strong>스레드 풀 동작 과정</strong></h3>
<ul>
<li>서버는 요청이 들어올 때마다 새 스레드를 생성하는 대신, 스레드 풀의 스레드에 작업을 할당한다.</li>
<li>작업들은 큐에 저장되며, 스레드 풀에 놀고있는 스레드가 있으면 큐에서 작업을 빼서 실행한다.</li>
<li>예를 들어 스레드 풀의 스레드 개수를 10개로 정했고, 100개의 요청이 한 번에 들어왔다고 하면, 처음 10개의 요청에 대해서는 스레드를 할당하고, 나머지는 큐에다 넣어서 대기시킨다.</li>
</ul>
<hr>
<h2 id="innodb">InnoDB</h2>
<p>InnoDB는 MySQL에서 사용 가능한 스토리지 엔진 중 레코드 기반 잠금을 제공한다.</p>
<p>→ 높은 동시성 처리가 가능하고, 안정적이며, 성능이 우수하다.</p>
<h3 id="innodb-구조">InnoDB 구조</h3>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/a65e1974-187b-4e80-b1f1-11fead9bc0cd" alt="image"></p>
<h3 id="innodb의-특징">InnoDB의 특징</h3>
<ol>
<li><p><strong>PK에 의한 클러스터링</strong></p>
<p> InnoDB의 모든 테이블은 PK값의 순서대로 디스크에 저장된다.</p>
<p> → PK가 클러스터링 인덱스이므로 PK를 이용한 검색은 빠르게 처리된다.</p>
</li>
<li><p><strong>외래 키 지원</strong></p>
<p> 왜래 키는 부모 &amp; 자식 테이블 모두 인덱스 생성이 필요하고, 변경 시 부모 &amp; 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하다.</p>
<p> → 위 과정에서 잠금이 여러 테이블로 전파되어 데드락 발생할 수 있다.</p>
<p> <code>foreign_key_checks</code> 시스템 변수를 OFF로 설정해서 왜래 키 체크를 중지할 수 있다.</p>
</li>
<li><p><strong>MVCC(Multi Version Concurrency Control)</strong></p>
<p> InnoDB는 MVCC 기술을 이용하여 잠금을 사용하지 않는 일관된 읽기를 제공하는데, 이를 위해 언두 로그를 이용한다.</p>
<ul>
<li><p><strong>데이터 변경 처리 과정</strong></p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/72443839-b3d9-42df-b63d-148ed25abeef" alt="image"></p>
<ol>
<li><p>업데이트 쿼리 실행</p>
</li>
<li><p>버퍼 풀의 데이터를 즉시 새 데이터로 변경</p>
</li>
<li><p>기존 데이터는 언두 로그에 복사</p>
</li>
<li><p><code>COMMIT</code> 명령 실행 시 지금의 상태를 영구적으로 적용</p>
</li>
<li><p><code>ROLLBACK</code> 명령 실행 시 언두 영역의 데이터를 버퍼 풀로 복구하고 언두 영역의 데이터 삭제</p>
<p>디스크의 데이터 파일에는 시점에 따라 업데이트 여부가 다르다.</p>
<p>만약 <code>COMMIT</code>, <code>ROLLBACK</code>이 안된 상태에서 작업중인 레코드를 조회하면 격리 수준에 따라 결과가 다르다.</p>
</li>
</ol>
<ul>
<li><p>ex1) <code>READ_UNCOMMITTED</code>: InnoDB 버퍼 풀이 현재 가지고 있는 변경된 데이터를 읽어서 반환한다.</p>
</li>
<li><p>ex2) <code>READ_COMMITTED</code> or 그 이상의 격리 수준: 언두 영역의 데이터를 반환한다.</p>
<p>→ 이러한 과정을 MVCC라고 표현한다.</p>
</li>
</ul>
</li>
</ul>
</li>
</ol>
<pre><code>참고로 언두 영역의 데이터는 언두 영역의 데이터를 필요로 하는 트랜잭션이 없을 때 삭제된다.</code></pre><ol start="4">
<li><p><strong>잠금 없는 일관된 읽기(Non-Locking Consistent Read)</strong></p>
<p> InnoDB는 MVCC 기술을 통해 잠금을 걸지 않고 읽기 작업을 수행하기 때문에 다른 트랜잭션의 잠금을 기다리지 않고 읽기 작업이 가능하다.</p>
<p> 일관된 읽기를 위해 언두 로그를 삭제하지 못하고 계속 유지해야 하기 때문에 오랜 시간 동안 활성 상태인 트랜잭션으로 인해 문제가 발생할 수 있다.</p>
<p> → 트랜잭션이 시작됐다면 빠르게 롤백이나 커밋을 하는 것이 좋음</p>
</li>
<li><p><strong>자동 데드락 감지</strong></p>
<p> InnoDB는 잠금이 교착 상태에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 그래프 형태로 관리한다.</p>
<p> 데드락 감지 스레드가 주기적으로 그래프를 검사해 교착 상태에 빠진 트랜잭션들을 찾아서 그 중 언두 레코드를 적게 가진 트랜잭션을 강제 종료시킨다.</p>
<ul>
<li><p>언두 레코드를 적게 가졌다는 것은 롤백을 해도 언두 처리를 해야 할 내용이 적다는 뜻이다.</p>
<p>  → 그러므로 언두 레코드를 적게 가진 트랜잭션은 강제 종료 시 부하를 덜 유발한다.</p>
</li>
</ul>
</li>
</ol>
<pre><code>데드락 감지 스레드는 일반적으로는 크게 부담되는 작업은 아니지만, 동시 처리 스레드가 매우 많아지거나 각 트랜잭션이 가진 잠금의 개수가 많아지면 느려진다.

데드락 감지 스레드는 잠금 목록을 검사하기 위해 잠금 상태가 변경되지 않도록 새로운 잠금을 건다.

→ 데드락 감지 스레드가 느려지면 서비스 쿼리를 처리 중인 스레드도 느려진다.

`innodb_deadlock_detect` 시스템 변수를 OFF로 설정하면 데드락 감지 스레드가 작동하지 않는다.

하지만, 그러면 데드락 발생 시 교착상태 발생하게 된다.

`innodb_lock_wait_timeout` 시스템 변수를 설정하면 일정 시간이 지났을 때 요청을 실패하게 할 수 있다.

→ `innodb_deadlock_detect` 시스템 변수를 OFF로 설정하면 `innodb_lock_wait_timeout` 시스템 변수는 기본값인 50초 보다 훨씬 낮게 설정하는 것이 좋다.

![image](https://github.com/JeongHunHui/TIL/assets/108508730/0d6c5fc8-5f03-47dd-82cf-d378cbad5fa7)</code></pre><ol start="6">
<li><p><strong>자동화된 장애 복구</strong></p>
<p> InnoDB에는 손실이나 장애로부터 데이터를 보호하기 위한 여러 가지 메커니즘이 탑재되어 있다.</p>
<p> 자동으로 복구가 안되는 경우는 <code>innodb_force_recovery</code> 시스템 변수를 설정하여 복구할 수 있다.</p>
</li>
<li><p><strong>InnoDB 버퍼 풀</strong></p>
<p> 버퍼 풀은 디스크의 데이터 파일이나 인덱스 정보를 캐시해 두는 메모리 공간을 말한다.</p>
<p> 또한, 쓰기 작업을 지연시켜 일괄 작업으로 처리할 수 있게 해주는 버퍼 역할도 같이 한다.</p>
<p> 일반적으로 데이터를 변경하는 쿼리는 데이터 파일의 이곳저곳에 위치한 레코드를 변경하기 때문에 랜덤한 디스크 작업을 발생 시킨다.</p>
<p> → 버퍼 풀이 이러한 변경된 데이터를 모아서 처리하면 랜덤한 디스크 작업의 횟수를 줄일 수 있다.</p>
<ul>
<li><p><strong>버퍼 풀의 크기 설정</strong></p>
<p>  InnoDB 버퍼 풀의 크기를 적절히 작은 값으로 설정해서 조금씩 상황을 봐 가며 증가시키는 방법이 좋다.</p>
<p>  버퍼 풀의 크기 변경은 서버가 한가한 시점을 골라 진행하는 것이 좋다.</p>
<p>  버퍼 풀의 크기를 늘리는 것은 영향이 적지만, 줄이는 것은 영향이 매우 크므로 지양하자.</p>
</li>
<li><p><strong>버퍼 풀의 구조</strong></p>
<p>  InnoDB는 버퍼 풀을 페이지 단위로 쪼개어 필요한 데이터를 가져와서 각 페이지에 저장한다.</p>
<p>  페이지 조각을 관리하기 위해 Free 리스트, LRU 리스트, Flush 리스트라는 3가지 자료 구조를 관리한다.</p>
<ul>
<li><p><strong>Free 리스트</strong></p>
<p>  버퍼 풀에서 실제 사용자 데이터로 채워지지 않은 비어 있는 페이지들의 목록을 저장하는 자료구조이다.</p>
</li>
<li><p><strong>LRU 리스트</strong></p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/20e64efe-e4d5-43ba-8768-0d48b2ac7e2c" alt="image"></p>
<p>  디스크로부터 한 번 읽어온 페이지를 최대한 오랫동안 버퍼풀에 유지해서 디스크 읽기를 최소화 하기 위해 필요한 자료구조다.</p>
<p>  처음 들어온 데이터는 old 리스트(LRU)에 들어가고, 한번 더 읽으면 new 리스트(MRU)에 들어간다.</p>
<p>  → 이렇게 New와 Old를 나누는 이유는 한번에 일회성 데이터가 많이 들어와서 기존에 자주 사용하던 데이터가 버퍼 풀에서 지워지는 것을 막기 위해서이다.</p>
</li>
<li><p><strong>플러시 리스트</strong></p>
<p>  디스크로 동기화되지 않은 데이터를 가진 데이터 페이지(더티 페이지)의 변경 시점 기준의 페이지 목록을 관리하는 자료구조다.</p>
<p>  디스크에서 읽은 상태 그대로 변경이 전혀 없다면 리스트에서 관리되지 않지만, 변경이 가해진 데이터 페이지는 플러시 리스트에 관리되고 특정 시점이 되면 디스크로 기록된다.</p>
</li>
</ul>
</li>
<li><p><strong>버퍼 풀과 리두 로그</strong></p>
<ul>
<li><p><strong>리두 로그</strong>(Redo Log)</p>
<ul>
<li>DB에서 일어난 모든 변화를 저장하는 메모리 공간을 말한다.</li>
<li>사용자의 <code>INSERT</code>, <code>DELETE</code>, <code>UPDATE</code> 작업으로 인한 데이터의 변화가 아직 디스크에는 적용되지 않은 상태에서 에러가 발생하면 리두 로그를 사용하여 작업 내용을 디스크에 반영한다.</li>
</ul>
<p><img src="https://github.com/JeongHunHui/TIL/assets/108508730/f6421317-7871-4241-acb0-5083cd6e1311" alt="image"></p>
<p>버퍼 풀은 성능 향상을 위해 데이터 캐시와 쓰기 버퍼링이라는 두 가지 용도가 있다.</p>
<p>버퍼 풀의 더티 페이지는 특정 리두 로그와의 관계를 가진다.</p>
<p>리두 로그의 크기 만큼 더티 페이지를 가질 수 있고, 일정 수준이 되면 디스크 쓰기 작업을 진행한다.</p>
</li>
</ul>
</li>
<li><p><strong>버퍼 풀 플러시</strong></p>
<p>  InnoDB는 더티 페이지를 성능상의 악영향 없이 디스크에 동기화 하기위해 2개의 플러시 기능을 백그라운드로 실행한다.</p>
<ul>
<li><p><strong>플러시 리스트 플러시</strong></p>
<p>  리두 로그 공간의 재활용을 위해 주기적으로 오래된 리두 로그 공간을 비워야한다.</p>
<p>  이때 오래된 리두 로그 공간을 지우려면 해당 리두 로그와 연결된 더티 페이지가 디스크에 동기화 되어야하는데, 이를 위해 플러시 리스트를 이용하여 디스크에 더티 페이지 데이터를 동기화 한다.</p>
</li>
<li><p><strong>LRU 리스트 플러시</strong></p>
<p>  LRU 리스트에서 사용 빈도가 낮은 페이지들을 제거해서 공간을 만드는 작업을 말한다.</p>
</li>
</ul>
</li>
<li><p><strong>버퍼 풀 상태 백업 및 복구</strong></p>
<p>  DB 서버를 껐다가 키면 성능이 1/10도 안되는 경우가 많다.</p>
<p>  왜냐하면 버퍼 풀에 쿼리들이 사용할 데이터가 이미 준비되어 있는 상태(워밍업 상태)라면 디스크에서 데이터를 읽지 않아도 돼서 성능이 좋지만, 재시작 한 뒤에는 버퍼풀이 비어있기 때문이다.</p>
<p>  서버를 재시작 하기 전에 버퍼 풀을 백업하고, 다시 시작하면 버퍼 풀을 복구할 수 있다.</p>
<p>  백업은 메타 데이터만 해서 빠르지만, 복구는 많은 디스크 읽기를 요구하므로 버퍼 풀 복구가 실행중일 때 서비스를 시작하는 것은 좋지 않다.</p>
<p>  시스템 변수로 이를 자동으로 하도록 할 수 있다.</p>
</li>
<li><p><strong>버퍼 풀의 적재 내용 확인</strong></p>
<p>  <code>information_schema</code> 데이터베이스의 <code>innodb_buffer_page</code> 테이블을 이용하여 버퍼 풀의 적재 내용을 확인할 수 있다.</p>
<p>  하지만 버퍼 풀이 크면 테이블 조회가 큰 부하를 일으키며 서비스 쿼리가 많이 느려지는 문제가 있다.</p>
<p>  <img src="https://github.com/JeongHunHui/TIL/assets/108508730/eafbced2-e505-499e-b26d-eb649cdbcf92" alt="image"></p>
<p>  위 사진 처럼 <code>innodb_cached_indexes</code> 테이블을 이용하여 테이블의 인덱스별로 데이터 페이지가 얼마나 적재되어 있는지 확인할 수 있다.</p>
</li>
</ul>
<ol>
<li><p><strong>Double Write Buffer</strong></p>
<p> Double Write Buffer는 ****더티페이지를 디스크로 플러시 할 때, 중간에 일부만 기록되는 문제(파셜 페이지, 톤 페이지)를 방지하기 위해 사용하는 버퍼를 말한다.</p>
<p> <img src="https://github.com/JeongHunHui/TIL/assets/108508730/408d64a3-383c-41ac-a763-aee8ff769624" alt="image"></p>
<p> 위 사진과 같이 실제 데이터 파일에 변경 내용을 기록하기 전에 더티 페이지를 묶어서 Double Write 버퍼에 기록하고, 각 데이터 페이지를 실제 데이터 파일에 기록한다.</p>
<p> 만약 실제 데이터 파일에 기록 중에 시스템이 비정상적으로 종료될 시 버퍼의 내용을 실제 데이터와 비교하여 동기화 작업을 완료한다.</p>
</li>
<li><p><strong>언두 로그</strong></p>
<p> InnoDB이 트랜잭션 격리 수준, 롤백을 보장하기 위해 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code>로 변경되기 이전의 데이터를 백업해두는 공간을 <strong>언두 로그</strong>라고 한다.</p>
<ul>
<li><p><strong>격리 수준</strong></p>
<p>  데이터 변경 중에 데이터 조회 시 격리 수준에 따라 언두 로그에 백업해둔 데이터를 읽을 수 있다.</p>
</li>
<li><p>롤백</p>
<p>  트랜잭션 롤백 시 이전 데이터로 복구해야 하는데, 이때 언두 로그에 백업해둔 데이터를 사용한다.</p>
</li>
</ul>
</li>
</ol>
</li>
</ol>
<pre><code>    활성 상태의 트랜잭션이 장시간 유지되면 해당 트랜잭션이 시작된 시점부터 생성된 언두 로그를 계속 보존해야 하고, 변경된 레코드를 조회하게 되면 언두 로그의 이력을 조회해야 하므로 쿼리의 성능이 떨어진다.

    → 언두 로그의 용량을 모니터링 하는 것이 좋다.

3. **체인지 버퍼**

    RDBMS에서 레코드가 변경될 때는 데이터 파일 변경 작업과 테이블에 포함된 인덱스를 업데이트 해야한다.

    But 인덱스를 업데이트 하는 작업은 랜덤하게 디스크를 읽는 작업이 필요하므로 많은 자원을 소모한다.

    → InnoDB는 인덱스 업데이트 시 디스크에서 읽어와야 한다면 즉시 실행하지 않고 메모리에서 임시로 처리하고 바로 결과를 반환하여 성능을 향상시키는데,  이때 사용하는 메모리 공간을 **체인지 버퍼**라고 한다.

    참고로 유니크 인덱스는 중복 여부를 체크해야 하므로 체인지 버퍼 사용이 불가능하다.

    체인지 버퍼에 임시로 저장된 인덱스는 체인지 버퍼 머지 스레드에 의해 병합된다.

4. **리두 로그, 로그 버퍼**

    리두 로그는 서버가 비정상적으로 종료되어도 데이터 파일에 기록되지 못한 데이터를 잃지 않게 해준다.

    대부분의 DBMS는 파일 쓰기 시 디스크 랜덤 엑세스가 필요 → 큰 비용 필요 → 쓰기 비용이 낮은 리두 로그에 먼저 기록하고, 주기적으로 로그의 내용을 디스크에 동기화한다.

    리두 로그는 아래와 같은 상황에서 사용한다.

    1. 커밋됐지만 데이터 파일에 기록되지 않은 데이터 처리 시
    2. 롤백됐지만 데이터 파일에 기록된 데이터 처리 시

    여기서 2번의 경우는 롤백 시에는 언두 로그가 사용되지만, 변경 사항이 커밋되었는지, 롤백되었는지를 판단하기 위해서 리두 로그도 사용된다.

    - 참고로 변경된 데이터는 우선 리두 로그에 쓰여지고, 리두 로그의 내용은 주기적으로 디스크에 동기화 된다. (이 동기화 주기는 `innodb_flush_log_at_trx_commit` 시스템 변수로 결정)
    - 커밋은 리두 로그에 변경 사항이 전부 쓰여진 뒤에 진행되기 때문에 커밋 되었어도 디스크에 일부 변경 사항이 반영되지 않았을 수도 있고, 커밋 이전에도 디스크에 변경 사항 일부가 반영되었을 수도 있다.

    리두 로그 파일의 크기가 적절해야 변경된 내용을 버퍼 풀에 모았다가 한 번에 디스크에 기록할 수 있다.

    But 변경 작업이 많은 서버의 경우는 리두 로그 기록 작업이 문제가 되므로 버퍼링을 해야하는데, 이때 사용되는 공간을 **로그 버퍼**라고 한다.

5. **어댑티브 해시 인덱스**

    자주 조회되는 데이터 페이지의 키 값을 이용해 해시 인덱스를 만들어 B-Tree 인덱스를 타지 않고 바로 데이터에 접근할 수 있는 기능을 말한다.

    B-Tree 인덱스의 경우 시간 복잡도는 O(log n) 이지만, 해시 인덱스의 경우는 O(1)이므로 성능이 향상된다.

    → CPU 사용률은 줄고, 초당 쿼리 처리 수는 증가한다.

    어댑티브 해시 인덱스는 사용자가 직접 생성하는 것이 아닌 InnoDB가 자주 조회되는 데이터에 대해 자동으로 생성한다.

    - 어댑티브 해시 인덱스가 도움이 되는 경우
        - 디스크 읽기가 많지 않은 경우
        - 동등 비교와 IN 연산자가 많은 경우
        - 쿼리가 데이터 중에서 일부 데이터에만 집중되는 경우
    - 어댑티브 해시 인덱스가 도움이 되지 않는 경우
        - 디스크 읽기가 많은 경우
        - Join이나 Like 검색이 많은 경우
        - 매우 큰 데이터를 가진 테이블의 레코드를 폭넓게 읽는 경우

    도움이 안되는 경우에는 `innodb_adaptive_hash_index` 시스템 변수를 이용하여 비활성화할 수 있다.

    ![image](https://github.com/JeongHunHui/TIL/assets/108508730/9483d404-5e7f-4f82-8f33-2f8c6df1990f)

    어댑티브 인덱스가 도움이 되는지는 위 사진과 같이 MySQL 서버의 상태 값들을 통해 판단할 수 있다.

    위 사진 같은 경우는 1.03번이 해시 인덱스를 사용, 2.64번이 해시인덱스를 사용하지 않았음을 의미한다.</code></pre><hr>
<h2 id="mysql-로그-파일">MySQL 로그 파일</h2>
<ol>
<li><p><strong>에러 로그 파일</strong></p>
<p> MySQL이 실행 중에 발생하는 경고, 에러 메시지가 출력되는 로그 파일을 말한다.</p>
</li>
<li><p><strong>제너럴 쿼리 로그 파일</strong></p>
<p> 서버에서 실행되는 쿼리들을 전부 기록하는 로그 파일을 말한다.</p>
</li>
<li><p><strong>슬로우 쿼리 로그</strong></p>
<p> 특정 시간보다 오래 걸린 쿼리를 기록하는 로그 파일을 말한다.</p>
<p> 서비스 운영 중에 어떤 쿼리가 문제인지 파악할 때 용이하다.</p>
<p> 쿼리의 내용이 상당히 많고 복잡하므로 pt-query-digest 스크립트를 이용하여 분석할 수 있다.</p>
</li>
</ol>
]]></description>
        </item>
    </channel>
</rss>