<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>while-true-study.log</title>
        <link>https://velog.io/</link>
        <description>while( true ) { study(); }</description>
        <lastBuildDate>Sat, 29 Aug 2026 10:42:04 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>while-true-study.log</title>
            <url>https://velog.velcdn.com/images/while-true-study/profile/82fb7fbb-fce2-4fb2-9032-18926842d658/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. while-true-study.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/while-true-study" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[이미지를 굳이 CNN으로 봐야 하나? - Vision Transformer (ViT)]]></title>
            <link>https://velog.io/@while-true-study/%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A5%BC-%EA%B5%B3%EC%9D%B4-CNN%EC%9C%BC%EB%A1%9C-%EB%B4%90%EC%95%BC-%ED%95%98%EB%82%98-Vision-Transformer-ViT</link>
            <guid>https://velog.io/@while-true-study/%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A5%BC-%EA%B5%B3%EC%9D%B4-CNN%EC%9C%BC%EB%A1%9C-%EB%B4%90%EC%95%BC-%ED%95%98%EB%82%98-Vision-Transformer-ViT</guid>
            <pubDate>Sat, 29 Aug 2026 10:42:04 GMT</pubDate>
            <description><![CDATA[<p>지난 글에서는 Transformer를 보면서 이런 질문에서 시작했다.</p>
<blockquote>
<p><strong>문장을 굳이 순서대로 읽어야 하나?</strong></p>
</blockquote>
<p>RNN은 문장을 앞에서부터 순서대로 읽었다. Transformer는 이 구조를 버리고 <code>Self-Attention</code>을 사용해서 각 단어가 다른 단어를 직접 볼 수 있게 만들었다.</p>
<p>그런데 Transformer가 NLP에서 그렇게 잘 된다면 이런 생각도 해볼 수 있다.</p>
<blockquote>
<p><strong>이미지도 Transformer로 보면 안 되나?</strong></p>
</blockquote>
<p>이미지 쪽에서는 이미 CNN이 너무 잘하고 있었다. AlexNet, VGG, ResNet 등을 거치면서 이미지를 처리한다면 CNN을 사용하는 게 거의 당연하게 여겨졌다.</p>
<p>그런데 2021년, 이런 논문이 등장한다.</p>
<blockquote>
<p><strong>An Image is Worth 16×16 Words: Transformers for Image Recognition at Scale</strong></p>
</blockquote>
<p>이름부터 상당히 특이하다. 이미지가 <code>16×16개의 단어</code>라는 걸까?</p>
<p>그런 뜻은 아니다. 이 논문의 아이디어는 생각보다 단순하다.</p>
<blockquote>
<p><strong>이미지를 작은 조각으로 나누고, 그 조각들을 단어처럼 Transformer에 넣으면 안 될까?</strong></p>
</blockquote>
<p>이렇게 등장한 모델이 <code>Vision Transformer</code>, 줄여서 <code>ViT</code>다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/eac2969e-c8d3-4293-bd66-12073030cdea/image.png" alt=""></p>
<hr>
<h2 id="1-그런데-이미지는-원래-어떻게-보고-있었지">1. 그런데 이미지는 원래 어떻게 보고 있었지?</h2>
<p>ViT를 보기 전에 CNN부터 다시 생각해보자.</p>
<p>예를 들어 크기가 <code>224 × 224 × 3</code>인 고양이 사진 하나가 있다고 해보자. <code>224 × 224</code>는 이미지의 높이와 너비이고, <code>3</code>은 RGB 채널을 의미한다.</p>
<p>CNN은 이 이미지를 처음부터 통째로 보는 게 아니다. 예를 들어 <code>3 × 3</code> kernel을 사용한다면 이미지의 아주 작은 영역부터 본다.</p>
<pre><code class="language-text">전체 이미지

┌───────────────────────────┐
│                         │
│        ┌─────┐           │
│        │ 3×3 │          │
│        └─────┘           │
│                         │
└───────────────────────────┘</code></pre>
<p>이 kernel이 이미지 전체를 이동하면서 각 위치의 픽셀 값과 kernel의 weight를 이용해 가중합을 계산한다. 학습을 반복하면 kernel의 weight도 계속 바뀌고, 어떤 kernel은 경계나 texture 같은 특정 패턴에 잘 반응하게 될 수 있다.</p>
<p>깊은 CNN에서는 앞쪽 layer에서 얻은 feature들이 다시 조합되면서 점점 복잡한 표현을 만든다. 아주 단순하게 생각하면 초반에는 edge나 texture 같은 비교적 단순한 특징을 잡고, 뒤로 갈수록 부분적인 형태와 더 복잡한 특징을 표현하게 된다.</p>
<p>물론 실제 신경망을 보고</p>
<blockquote>
<p>&quot;이 kernel은 정확히 고양이 귀를 찾는다.&quot;</p>
</blockquote>
<p>라고 딱 잘라 말할 수 있는 건 아니다. 중요한 건 CNN이 <strong>작은 영역부터 특징을 만들어간다</strong>는 점이다.</p>
<hr>
<h2 id="2-왜-cnn은-굳이-작은-영역부터-볼까">2. 왜 CNN은 굳이 작은 영역부터 볼까?</h2>
<p>여기서 질문이 하나 생긴다.</p>
<blockquote>
<p>왜 굳이 <code>3 × 3</code> 같은 작은 영역부터 보는 걸까?</p>
</blockquote>
<p>생각해보면 이미지에는 꽤 강한 특징이 있다. 어떤 픽셀 하나가 물체의 어떤 부분인지 판단한다고 했을 때, 이미지 반대편 끝의 픽셀보다 바로 주변 픽셀이 더 중요할 가능성이 높다.</p>
<pre><code class="language-text">· · ·
· X ·
· · ·</code></pre>
<p>예를 들어 고양이 눈에 해당하는 픽셀 주변에는 다른 눈 픽셀이나 털, 얼굴과 관련된 픽셀이 있을 가능성이 높다. 따라서 <strong>가까운 픽셀끼리 관계가 강할 것이다</strong>라는 가정은 꽤 합리적이다.</p>
<p>CNN은 이 생각을 모델의 구조 자체에 넣었다. <code>3 × 3 kernel</code>을 사용한다는 것 자체가 모델에게 <strong>일단 주변부터 봐</strong>라고 알려주는 것과 비슷하다.</p>
<p>또 하나 있다. 어떤 kernel이 특정 특징에 잘 반응하게 학습되었다면 그 특징이 이미지 왼쪽 위에 있든 오른쪽 아래에 있든 비슷하게 잡아내는 것이 좋을 것이다. 그래서 CNN에서는 같은 kernel을 이미지 전체에 공유해서 사용한다.</p>
<pre><code class="language-text">        같은 Kernel
      ↙      ↓      ↘
    왼쪽     중앙    오른쪽</code></pre>
<p>즉 CNN은 학습을 시작하기 전부터 이미지에 대해 어느 정도 힌트를 받고 있다.</p>
<blockquote>
<p>가까운 픽셀끼리 중요할 것이다.
같은 특징이라면 위치가 바뀌어도 비슷하게 볼 수 있을 것이다.</p>
</blockquote>
<p>이런 식으로 모델에 미리 넣어주는 가정을 <code>Inductive Bias</code>라고 한다. 이름은 어려운데 개인적으로는 <strong>모델에게 미리 주는 힌트</strong> 정도로 이해하면 편했다.</p>
<p>CNN은 이미지를 완전히 아무것도 모르는 상태에서 보는 것이 아니다. 이미지 처리에 유리한 규칙을 어느 정도 가지고 시작하고, 이러한 inductive bias 덕분에 상대적으로 적은 데이터에서도 잘 학습할 수 있다.</p>
<p>그런데 여기서 ViT가 재미있는 질문을 던진다.</p>
<blockquote>
<p><strong>이런 규칙을 우리가 꼭 미리 넣어줘야 할까?</strong></p>
</blockquote>
<hr>
<h2 id="3-transformer는-조금-다르게-생각했다">3. Transformer는 조금 다르게 생각했다</h2>
<p>다시 문장으로 돌아가보자.</p>
<pre><code class="language-text">The cat sat on the mat</code></pre>
<p>Transformer의 Self-Attention에서는 <code>cat</code>이 반드시 바로 옆 단어만 볼 필요가 없다. 문장 안의 다른 token들과 직접 관계를 계산할 수 있다.</p>
<p>즉 <strong>누구와 관계가 중요한지는 모델이 직접 학습한다.</strong></p>
<p>그러면 이미지도 이런 식으로 보면 안 될까?</p>
<p>CNN처럼 가까운 영역부터 보고 layer를 쌓으면서 점점 넓은 영역의 정보를 합치는 대신, 이미지의 서로 멀리 떨어진 영역도 처음부터 직접 관계를 계산하게 만드는 것이다.</p>
<pre><code class="language-text">왼쪽 위 영역  &lt;----------------&gt;  오른쪽 아래 영역</code></pre>
<p>문제는 하나다. Transformer는 원래 이미지를 입력받는 모델이 아니다.</p>
<hr>
<h2 id="4-그럼-이미지를-어떻게-transformer에-넣지">4. 그럼 이미지를 어떻게 Transformer에 넣지?</h2>
<p>Transformer가 받는 입력은 기본적으로 sequence다.</p>
<pre><code class="language-text">Token 1 → Token 2 → Token 3 → ... → Token N</code></pre>
<p>반면 이미지는 $H \times W \times C$ 형태다. 예를 들어 <code>224 × 224 × 3</code> 크기의 이미지를 Transformer에 넣는다고 생각해보자.</p>
<p>가장 단순하게 생각하면 픽셀 하나를 token 하나로 만들 수도 있다. 하지만 그러면</p>
<p>$$
224 \times 224 = 50,176
$$</p>
<p>개의 token이 생긴다.</p>
<p>Self-Attention은 token 사이의 관계를 계산하기 때문에 token 수가 $N$이라면 계산량은 대략</p>
<p>$$
O(N^2)
$$</p>
<p>으로 증가한다. 5만 개가 넘는 token을 모두 서로 보게 만드는 것은 너무 비싸다.</p>
<p>그래서 ViT에서는 픽셀 하나를 token으로 사용하지 않는다.</p>
<blockquote>
<p><strong>픽셀 하나 말고, 이미지의 작은 영역 하나를 token으로 쓰자.</strong></p>
</blockquote>
<p>이 작은 영역이 바로 <code>Patch</code>다.</p>
<hr>
<h2 id="5-이미지를-patch로-잘라보자">5. 이미지를 Patch로 잘라보자</h2>
<p>224 × 224 이미지를 <code>16 × 16</code> 크기의 patch로 자른다고 해보자. 가로와 세로에 각각 14개씩 들어가므로 총 patch의 개수는</p>
<p>$$
14 \times 14 = 196
$$</p>
<p>개가 된다.</p>
<pre><code class="language-text">224 × 224 Image
        ↓
16 × 16 Patch로 분할
        ↓
196 Patches</code></pre>
<p>이제 이 196개의 patch를 문장의 단어처럼 생각한다.</p>
<pre><code class="language-text">문장  : Word 1, Word 2, Word 3, ...
이미지: Patch 1, Patch 2, Patch 3, ...</code></pre>
<p>이게 ViT의 핵심 아이디어다.</p>
<p>논문의 제목인 <strong>An Image is Worth 16×16 Words</strong>도 여기에서 나온다. 이미지가 정말 <code>16 × 16개의 단어</code>라는 뜻이 아니라, <code>16 × 16 pixel</code> 크기의 이미지 조각을 하나의 단어처럼 다룬다는 의미다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/eac2969e-c8d3-4293-bd66-12073030cdea/image.png" alt=""></p>
<blockquote>
<p><strong>Figure 1. Vision Transformer의 전체 구조</strong>
이미지를 일정한 크기의 Patch로 나누고 Linear Projection한 뒤 Position Embedding을 더해 Transformer Encoder에 입력한다.
출처: Dosovitskiy et al., <em>An Image is Worth 16×16 Words</em>, ICLR 2021.</p>
</blockquote>
<p>논문의 전체 구조를 보면 생각보다 단순하다. 이미지를 patch로 나누고 Transformer가 받을 수 있는 vector로 바꿔준 뒤에는 기존 Transformer Encoder를 거의 그대로 사용한다.</p>
<p>이제 그림의 앞부분부터 하나씩 뜯어보자.</p>
<hr>
<h2 id="6-그런데-patch를-그냥-transformer에-넣을-수-있나">6. 그런데 Patch를 그냥 Transformer에 넣을 수 있나?</h2>
<p>아직 안 된다.</p>
<p>RGB 이미지에서 <code>16 × 16</code> patch 하나에는</p>
<p>$$
16 \times 16 \times 3 = 768
$$</p>
<p>개의 값이 들어 있다.</p>
<p>우선 이 patch를 길게 펼치면 하나의 768차원 vector를 만들 수 있다.</p>
<pre><code class="language-text">16 × 16 × 3 Patch → Flatten → 768차원 Vector</code></pre>
<p>그리고 이 vector를 <code>Linear Projection</code>에 통과시켜 Transformer가 사용하는 $D$차원의 vector로 바꿔준다.</p>
<pre><code class="language-text">Patch → Flatten → Linear Projection → Patch Embedding</code></pre>
<p>식으로 표현하면 대략 다음과 같다.</p>
<p>$$
\mathbf{x}_p^i E
$$</p>
<p>여기서 $E$도 학습되는 weight다. 단순히 patch의 픽셀을 펼쳐서 그대로 사용하는 것이 아니라, <strong>이 patch를 어떤 vector로 표현해야 분류에 유리한지</strong>도 학습 과정에서 같이 배우는 것이다.</p>
<p>이를 <code>Patch Embedding</code>이라고 한다.</p>
<p>이 과정은 NLP의 Word Embedding과 상당히 비슷하다.</p>
<pre><code class="language-text">NLP : Word        → Word Embedding  → Transformer
ViT : Image Patch → Patch Embedding → Transformer</code></pre>
<p>Patch Embedding이 끝난 순간부터 Transformer 입장에서는 이 vector가 이미지에서 왔는지 문장에서 왔는지가 크게 중요하지 않다. 그냥 여러 개의 vector로 이루어진 sequence를 받은 것이고, 이제 이 vector들 사이에서 Self-Attention을 계산하면 된다.</p>
<hr>
<h2 id="7-그런데-이미지에서-위치가-바뀌면-큰일-아닌가">7. 그런데 이미지에서 위치가 바뀌면 큰일 아닌가?</h2>
<p>여기서 또 문제가 하나 생긴다.</p>
<p>얼굴에서 눈, 코, 입이라는 특징이 모두 존재하더라도 위치가 뒤섞이면 전혀 다른 이미지가 된다. 이미지에서는 <strong>무엇이 있는가</strong>뿐만 아니라 <strong>어디에 있는가</strong>도 중요하다.</p>
<p>하지만 Self-Attention 자체는 token이 원래 어디에 있었는지를 자동으로 알지 못한다. Transformer에서도 같은 문제가 있었고, 이를 해결하기 위해 위치 정보를 따로 추가했다.</p>
<p>ViT도 마찬가지로 Patch Embedding에 <code>Position Embedding</code>을 더한다.</p>
<pre><code class="language-text">Patch Embedding + Position Embedding → Transformer Input</code></pre>
<p>각 patch에게 <strong>너는 원래 이미지의 이 위치에 있었어</strong>라는 정보를 같이 주는 것이다.</p>
<p>ViT에서는 학습 가능한 Position Embedding을 사용한다. 흥미로운 부분은 처음부터 2차원 이미지의 구조를 아주 강하게 설계해서 넣어준 것이 아니라, 학습 과정에서 patch 사이의 위치 관계도 같이 배우게 했다는 점이다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/32df6cd2-b84e-4cc7-9ba9-cbcbb00e47ce/image.png" alt=""></p>
<blockquote>
<p><strong>Figure 7 (Center). 학습된 Position Embedding의 유사도</strong>
출처: Dosovitskiy et al., <em>An Image is Worth 16×16 Words</em>, ICLR 2021.</p>
</blockquote>
<p>실제로 학습이 끝난 Position Embedding을 확인해보면 가까이에 있는 patch들의 embedding이 서로 비슷해지고, 같은 행이나 열에 있는 patch 사이에서도 구조가 나타난다.</p>
<p>처음부터 복잡한 2차원 위치 규칙을 강하게 넣어주지 않았는데도 모델이 학습하면서 이미지의 <strong>2차원 공간 구조를 어느 정도 스스로 만들어낸 것</strong>이다.</p>
<hr>
<h2 id="8-갑자기-cls는-왜-나올까">8. 갑자기 <code>[CLS]</code>는 왜 나올까?</h2>
<p>ViT 구조를 보면 patch들 앞에 <code>[CLS]</code>라는 특별한 token 하나가 붙어 있다.</p>
<pre><code class="language-text">[CLS] [P1] [P2] [P3] ... [P196]</code></pre>
<p>이 token도 다른 patch들과 함께 Self-Attention에 참여한다. Transformer layer를 여러 개 지나면서 <code>[CLS]</code>에는 이미지 전체를 분류하는 데 필요한 정보가 모이게 되고, 마지막에는 이 vector를 classifier에 넣어 고양이인지 강아지인지 등을 판단한다.</p>
<p>즉 <code>[CLS]</code>는 <strong>이미지 전체를 대표하는 token</strong>처럼 사용하는 것이다.</p>
<hr>
<h2 id="9-여기까지-왔으면-이제-거의-transformer다">9. 여기까지 왔으면 이제 거의 Transformer다</h2>
<p>전체 흐름을 다시 보면 다음과 같다.</p>
<pre><code class="language-text">Image
 → Patch
 → Flatten
 → Linear Projection
 → Patch Embedding + Position Embedding
 → [CLS] 추가
 → Transformer Encoder × L
 → [CLS]
 → Classifier</code></pre>
<p>Transformer Encoder 내부도 기존 Transformer와 크게 다르지 않다. LayerNorm, Multi-Head Self-Attention, MLP와 Residual Connection이 반복된다.</p>
<p>결국 ViT에서 정말 중요한 아이디어는 엄청나게 새로운 Attention을 만든 것이 아니다.</p>
<blockquote>
<p><strong>이미지를 Transformer가 받을 수 있는 형태로 바꿔버렸다.</strong></p>
</blockquote>
<p>이미지를 patch들의 sequence로 만들고 나면 그 이후에는 기존 Transformer 구조를 거의 그대로 사용할 수 있다.</p>
<hr>
<h2 id="10-근데-cnn도-작은-영역을-보고-vit도-patch를-보잖아">10. 근데 CNN도 작은 영역을 보고 ViT도 Patch를 보잖아?</h2>
<p>여기서 나도 처음에 궁금했던 게 있다.</p>
<blockquote>
<p><strong>CNN도 작은 영역을 보는데 ViT도 이미지를 작은 patch로 자르면 둘이 비슷한 거 아닌가?</strong></p>
</blockquote>
<p>겉으로 보면 비슷해 보이지만 작은 영역을 사용하는 목적과 그 이후의 처리 방식이 다르다.</p>
<p>CNN에서는 작은 영역에 convolution을 적용해 local feature를 만들고, 그 feature에 다시 convolution을 적용하면서 점점 넓은 영역의 정보를 합친다.</p>
<pre><code class="language-text">CNN: Local → Local → Local → 점점 넓은 영역</code></pre>
<p>반면 ViT의 patch는 convolution을 수행하기 위한 영역이라기보다 하나의 <strong>token 단위</strong>다. patch가 token으로 변환된 이후에는 첫 Transformer layer부터 서로 멀리 떨어져 있는 patch 사이의 관계를 계산할 수 있다.</p>
<pre><code class="language-text">ViT: Patch → Token → Global Self-Attention</code></pre>
<p>예를 들어 이미지 왼쪽 위의 patch와 오른쪽 아래의 patch도 첫 layer부터 직접 Attention을 계산할 수 있다.</p>
<p>물론 CNN이 global 정보를 못 본다는 뜻은 아니다. CNN도 layer가 깊어지면서 receptive field가 커지고 결국 전체 이미지 정보를 사용할 수 있다.</p>
<p>차이는 <strong>전체 정보를 만들어가는 방식</strong>이다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/fe3c9b9a-67bb-495c-be16-d53b62232e96/image.png" alt=""></p>
<blockquote>
<p><strong>Figure 6. ViT의 Attention 시각화</strong>
출처: Dosovitskiy et al., <em>An Image is Worth 16×16 Words</em>, ICLR 2021.</p>
</blockquote>
<p>논문에서는 실제로 ViT가 이미지의 어디에 Attention을 두는지도 확인했다. 강아지 사진에서는 강아지가 있는 영역, 비행기 사진에서는 비행기가 있는 영역처럼 분류에 의미 있는 부분에 Attention이 모이는 것을 볼 수 있다.</p>
<p>즉 patch들을 무작정 섞는 것이 아니라, 이미지 분류에 필요한 영역 사이의 관계를 실제로 학습하고 있는 것이다.</p>
<hr>
<h2 id="11-그럼-vit가-cnn보다-무조건-좋은-건가">11. 그럼 ViT가 CNN보다 무조건 좋은 건가?</h2>
<p>여기서 가장 조심해야 한다.</p>
<p>ViT 논문을 보고</p>
<blockquote>
<p><strong>CNN은 이제 필요 없다.</strong></p>
</blockquote>
<p>라고 이해하면 안 된다.</p>
<p>오히려 작은 규모의 데이터에서는 CNN이 상당히 강했다. 이유는 앞에서 봤던 <code>Inductive Bias</code>다.</p>
<p>CNN은 <strong>가까운 픽셀부터 보는 것이 좋다</strong>, <strong>같은 특징은 위치가 달라져도 비슷하게 처리할 수 있다</strong> 같은 이미지에 유리한 힌트를 가지고 시작한다.</p>
<p>반면 ViT는 이런 이미지 전용 힌트가 상대적으로 적다.</p>
<p>조금 과장해서 표현하면 CNN은</p>
<blockquote>
<p>이미지는 이런 특징이 있으니까 이런 방식으로 보면 좋아.</p>
</blockquote>
<p>라는 힌트를 받고 시작하는 반면, ViT는</p>
<blockquote>
<p>여기 Patch들이 있어. 어떤 관계가 중요한지는 데이터를 보고 네가 알아내.</p>
</blockquote>
<p>에 더 가깝다.</p>
<p>따라서 데이터가 적을 때는 CNN이 유리할 수 있다. ViT는 이미지가 가진 구조 자체도 데이터에서 더 많이 학습해야 하기 때문이다.</p>
<p>실제로 논문에서도 작은 규모의 데이터에서는 ViT가 비슷한 크기의 ResNet보다 좋지 않았다.</p>
<p>그런데 데이터가 많아지면서 상황이 달라진다.</p>
<hr>
<h2 id="12-데이터가-엄청-많아지면">12. 데이터가 엄청 많아지면?</h2>
<p>ViT 논문에서는 서로 다른 규모의 데이터로 pre-training을 진행했다.</p>
<table>
<thead>
<tr>
<th>Dataset</th>
<th align="right">이미지 수</th>
</tr>
</thead>
<tbody><tr>
<td>ImageNet</td>
<td align="right">약 130만 장</td>
</tr>
<tr>
<td>ImageNet-21k</td>
<td align="right">약 1,400만 장</td>
</tr>
<tr>
<td>JFT</td>
<td align="right">약 3억 장</td>
</tr>
</tbody></table>
<p>작은 데이터에서는 CNN의 inductive bias가 강력했다. 하지만 pre-training 데이터가 커지면서 ViT의 성능이 계속 올라갔고, 대규모 데이터에서는 굉장히 강력한 결과를 보여줬다.</p>
<p>즉 이 논문의 이야기는 단순히</p>
<blockquote>
<p>Transformer를 이미지에 사용했더니 CNN보다 좋더라.</p>
</blockquote>
<p>가 아니다.</p>
<p>오히려 다음 질문에 더 가깝다.</p>
<blockquote>
<p><strong>충분한 데이터가 있다면 사람이 미리 넣어준 규칙을 줄이고, 필요한 구조를 데이터에서 직접 배우게 할 수도 있지 않을까?</strong></p>
</blockquote>
<p>CNN은 강한 inductive bias를 가지고 있고 ViT는 상대적으로 그 bias가 적다. 대신 ViT는 그만큼 많은 것을 데이터에서 배워야 한다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/a1de0206-f353-488c-a01e-ba937d44b776/image.png" alt=""></p>
<blockquote>
<p><strong>Figure 3, 4. Pre-training 데이터 규모에 따른 ViT와 ResNet의 성능 변화</strong>
출처: Dosovitskiy et al., <em>An Image is Worth 16×16 Words</em>, ICLR 2021.</p>
</blockquote>
<p>이 두 그림이 사실 ViT 논문의 핵심 결과 중 하나다.</p>
<p>Figure 3을 보면 작은 데이터에서는 큰 ViT가 ResNet보다 좋지 않다. 하지만 ImageNet-21k를 거쳐 JFT-300M으로 pre-training 데이터가 커질수록 상황이 달라진다.</p>
<p>Figure 4에서는 이 차이가 조금 더 직접적으로 나타난다. 데이터가 적을 때는 ResNet이 강하지만 데이터의 규모가 커지면서 ViT의 성능도 계속 증가한다.</p>
<p>결국 작은 데이터에서는 CNN이 가지고 있는 inductive bias가 큰 도움이 되지만, 데이터가 충분히 많다면 필요한 이미지 구조를 모델이 데이터에서 직접 학습하는 것도 가능하다는 것이다.</p>
<hr>
<h2 id="13-그러면-patch-안에서-cnn을-쓰면-더-좋은-거-아닌가">13. 그러면 Patch 안에서 CNN을 쓰면 더 좋은 거 아닌가?</h2>
<p>ViT를 보다 보면 이런 생각도 들 수 있다. 나도 이 부분이 처음에 궁금했다.</p>
<blockquote>
<p><strong>16×16 patch 안에서 convolution으로 특징을 먼저 뽑아주면 더 좋은 token을 만들 수 있지 않을까?</strong></p>
</blockquote>
<p>가능하다. CNN이 먼저 local feature를 뽑고 Transformer가 그 feature 사이의 관계를 학습하도록 만들 수 있다.</p>
<p>다만 patch를 먼저 완전히 잘라놓은 뒤 각각 독립적으로 convolution하는 것보다는 이미지 전체에 CNN을 적용한 후 만들어진 feature map을 Transformer의 token으로 사용하는 방식이 더 자연스럽다.</p>
<pre><code class="language-text">Image → CNN → Feature Map → Tokens → Transformer</code></pre>
<p>patch를 먼저 잘라버리면 patch 경계에 걸린 local feature를 CNN이 한 번에 보기 어렵기 때문이다.</p>
<p>실제로 원래 ViT 논문에서도 <code>ResNet + Transformer</code> 형태의 <strong>Hybrid Architecture</strong>를 함께 실험했다. CNN이 local feature extraction을 담당하고 Transformer가 global relationship을 담당하는 식이다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/748a7391-0471-4bb8-a6b0-af8cd1e45853/image.png" alt=""></p>
<blockquote>
<p><strong>Figure 5. ViT, ResNet, Hybrid 모델의 Pre-training Compute 대비 성능</strong>
파란색은 순수 ViT, 회색은 ResNet, 주황색은 ResNet과 ViT를 결합한 Hybrid 모델이다.
출처: Dosovitskiy et al., <em>An Image is Worth 16×16 Words</em>, ICLR 2021.</p>
</blockquote>
<p>그렇다고 CNN과 Transformer를 합치면 항상 더 좋은 것도 아니었다. 작은 computational budget에서는 Hybrid가 순수 ViT보다 조금 유리했지만, 모델과 계산 규모가 커질수록 그 차이는 거의 사라졌다.</p>
<p>결국 중요한 질문은 <strong>CNN이 좋냐 Transformer가 좋냐</strong>보다, 모델에 이미지에 대한 inductive bias를 얼마나 넣어줄 것이냐에 더 가깝다.</p>
<p>CNN을 앞에 붙이면 local feature를 쉽게 학습할 수 있도록 다시 강한 힌트를 주는 셈이고, 순수 ViT는 그런 가정을 줄인 대신 더 많은 것을 데이터에서 직접 학습한다.</p>
<hr>
<h2 id="14-patch는-작을수록-좋은-거-아닌가">14. Patch는 작을수록 좋은 거 아닌가?</h2>
<p>16×16 patch보다 8×8 patch를 사용하면 이미지를 더 세밀하게 볼 수 있다. 그렇다면 patch를 그냥 최대한 작게 만들면 되는 걸까?</p>
<p>그렇게 간단하지는 않다.</p>
<p>224×224 이미지에서 32×32 patch를 사용하면</p>
<p>$$
7 \times 7 = 49
$$</p>
<p>개의 token이 생기지만, 16×16 patch를 사용하면</p>
<p>$$
14 \times 14 = 196
$$</p>
<p>개의 token이 생긴다.</p>
<p>patch가 작아질수록 이미지의 정보를 더 세밀하게 유지할 수 있는 대신 token의 개수가 크게 늘어난다. 문제는 Self-Attention의 계산량이 token 수에 대해 대략 $O(N^2)$으로 증가한다는 점이다.</p>
<p>즉 <strong>작은 Patch → 더 많은 Token → 더 세밀한 표현 → 더 큰 Attention 계산 비용</strong>이라는 trade-off가 있다.</p>
<p>그래서 <code>ViT-B/16</code> 같은 모델 이름에서 <code>B</code>는 Base 크기의 Transformer를, <code>/16</code>은 <code>16 × 16</code> patch를 사용한다는 의미다. 같은 방식으로 <code>ViT-L/32</code>라면 Large 모델에 32×32 patch를 사용하는 구조라고 이해하면 된다.</p>
<hr>
<h2 id="15-결국-vit는-뭘-한-걸까">15. 결국 ViT는 뭘 한 걸까?</h2>
<p>처음 보면 ViT가 상당히 복잡해 보이지만 전체 흐름만 다시 보면 의외로 단순하다.</p>
<pre><code class="language-text">Image
 → Patch로 분할
 → Patch Embedding
 → Position Embedding 추가
 → Transformer Encoder
 → [CLS]
 → Classification</code></pre>
<p>먼저 이미지를 여러 patch로 자르고, 각 patch를 Transformer가 사용할 수 있는 vector로 바꾼다. 여기에 위치 정보를 추가한 뒤 Transformer Encoder에 넣고, 마지막 <code>[CLS]</code> representation으로 이미지를 분류한다.</p>
<p>결국 ViT가 한 가장 중요한 일은 <strong>이미지를 단어들의 sequence처럼 바꿔 Transformer가 처리할 수 있게 만든 것</strong>이라고 볼 수 있다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Transformer 논문에서는 이런 질문을 던졌다.</p>
<blockquote>
<p><strong>문장을 굳이 순서대로 읽어야 하나?</strong></p>
</blockquote>
<p>RNN이 당연하게 사용하던 순차적인 계산 방식을 버리고 Self-Attention이라는 다른 방법을 선택했다.</p>
<p>ViT도 비슷하다. Computer Vision에서는 CNN을 사용하는 것이 너무나 당연하게 여겨지고 있었는데, 여기서 다시 질문을 던졌다.</p>
<blockquote>
<p><strong>이미지를 굳이 CNN으로 봐야 하나?</strong></p>
</blockquote>
<p>그리고 이미지를 patch로 자른 뒤 각 patch를 단어처럼 Transformer에 넣었다. 기존 Transformer가 <code>Word ↔ Word</code>의 관계를 봤다면 ViT에서는 <code>Patch ↔ Patch</code>의 관계를 보는 셈이다.</p>
<p>처음에는 조금 이상해 보인다. CNN은 이미지에 정말 잘 맞는 구조이고 이미지에는 실제로 locality 같은 성질도 존재한다. 그런데 ViT는 이런 가정을 상대적으로 줄이고, 대신 많은 데이터를 통해 어떤 관계가 중요한지를 직접 학습하게 만들었다.</p>
<p>그리고 데이터의 규모가 충분히 커지자 이 단순한 방식이 굉장히 강력해졌다.</p>
<p>그래서 개인적으로 이 논문의 핵심은</p>
<blockquote>
<p><strong>Transformer를 이미지에도 사용할 수 있다.</strong></p>
</blockquote>
<p>라는 사실보다도 다음 질문에 더 가까운 것 같다.</p>
<blockquote>
<p><strong>우리가 모델에 당연하게 넣고 있던 구조는 정말 반드시 필요한가?</strong></p>
</blockquote>
<p>기존 CNN을 조금 수정하는 것이 아니라, 이미지를 바라보는 방식 자체를 다시 생각했다는 점에서 재미있는 논문이었다.</p>
<hr>
<h2 id="논문-정보">논문 정보</h2>
<p>이 글은 Dosovitskiy et al.이 2021년 ICLR에서 발표한 <strong>「An Image is Worth 16×16 Words: Transformers for Image Recognition at Scale」</strong> 논문을 읽고, 쉽게 이해할 수 있도록 제 방식대로 정리한 글입니다. 감사합니다.</p>
<ul>
<li>논문명: An Image is Worth 16×16 Words: Transformers for Image Recognition at Scale</li>
<li>저자: Alexey Dosovitskiy et al.</li>
<li>발표: ICLR 2021</li>
<li>arXiv: <a href="https://arxiv.org/abs/2010.11929">논문 페이지</a></li>
<li>PDF: <a href="https://arxiv.org/pdf/2010.11929">원문 PDF 보기</a></li>
<li>ICLR: <a href="https://iclr.cc/virtual/2021/oral/3458">공식 발표 페이지</a></li>
<li>Google Research: <a href="https://research.google/pubs/an-image-is-worth-16x16-words-transformers-for-image-recognition-at-scale/">공식 연구 페이지</a></li>
</ul>
<p>본문에 사용된 논문의 표와 그림에는 원 출처를 함께 표기했습니다.</p>
<blockquote>
<p><a href="https://arxiv.org/abs/2010.11929">Source: Dosovitskiy et al., <em>An Image is Worth 16×16 Words: Transformers for Image Recognition at Scale</em>, ICLR 2021.</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[문장을 굳이 순서대로 읽어야 하나? (Attention Is All You Need)]]></title>
            <link>https://velog.io/@while-true-study/%EB%AC%B8%EC%9E%A5%EC%9D%84-%EA%B5%B3%EC%9D%B4-%EC%88%9C%EC%84%9C%EB%8C%80%EB%A1%9C-%EC%9D%BD%EC%96%B4%EC%95%BC-%ED%95%98%EB%82%98-Attention-Is-All-You-Need</link>
            <guid>https://velog.io/@while-true-study/%EB%AC%B8%EC%9E%A5%EC%9D%84-%EA%B5%B3%EC%9D%B4-%EC%88%9C%EC%84%9C%EB%8C%80%EB%A1%9C-%EC%9D%BD%EC%96%B4%EC%95%BC-%ED%95%98%EB%82%98-Attention-Is-All-You-Need</guid>
            <pubDate>Mon, 17 Aug 2026 21:15:04 GMT</pubDate>
            <description><![CDATA[<h3 id="transformer를-만든-논문-attention-is-all-you-need">Transformer를 만든 논문, Attention Is All You Need</h3>
<p><strong>Transformer? 자동차가 로봇으로 변신하는거?</strong></p>
<p>당연히 그 로봇 트랜스포머는 아니다.</p>
<p>AI를 조금 공부하면 이 단어를 자주 만나게 된다. ChatGPT도 <code>Transformer</code>, BERT도 <code>Transformer</code>, 요즘 나오는 LLM도 대부분 <code>Transformer</code>를 기반으로 한다.</p>
<p>그런데 막상 <code>Transformer</code>가 뭔지 설명해보라고 하면 조금 애매해진다.</p>
<blockquote>
<p>1: Transformer가 뭐야?
2: Attention을 사용하는 모델이야
1: Attention은 뭔데? 왜 써?</p>
</blockquote>
<p>그래서 이번에 <code>Transformer</code>가 처음 등장한 2017년 논문, <strong>Attention Is All You Need</strong>를 읽어봤다.</p>
<p>이 논문의 아이디어는 상당히 센세이션하다</p>
<blockquote>
<p><strong>문장을 굳이 앞에서부터 하나씩 읽어야 할까?</strong></p>
</blockquote>
<p>당시 널리 사용되던 <code>RNN</code>과 <code>LSTM</code>을 아예 빼버리고, <code>Attention</code>만으로 문장을 처리해보자는 것이다.</p>
<hr>
<h2 id="1-transformer-이전에는-문장을-어떻게-읽었을까">1. Transformer 이전에는 문장을 어떻게 읽었을까?</h2>
<p>예를 들어서 이런 문장이 있다고 해보자.</p>
<blockquote>
<p>나는 어제 친구와 영화를 보고 집에 돌아왔다.</p>
</blockquote>
<p>사람은 이 문장을 읽을 때 단어 하나하나를 완전히 독립적으로 이해하지 않는다.</p>
<p><code>돌아왔다</code>를 보면 자연스럽게</p>
<ul>
<li>누가 돌아왔지 ?? -&gt; 나</li>
<li>언제 ?? -&gt; 어제</li>
<li>뭘 했는데 ?? -&gt; 영화를 보고</li>
</ul>
<p>같은 관계를 동시에 생각한다. 그런데 당시 대표적인 방식이었던 <code>RNN</code>은 조금 다르다.</p>
<p>문장을 이런 식으로 순서대로 처리한다.</p>
<pre><code class="language-text">나는 → 어제 → 친구와 → 영화를 → 보고 → 집에 → 돌아왔다</code></pre>
<p>앞 단어를 처리한 결과가 다음 단어를 처리하는 데 사용된다.</p>
<pre><code class="language-text">h1 → h2 → h3 → h4 → h5 ...</code></pre>
<p>처럼 이전 계산이 끝나야 다음 계산을 할 수 있다. 문장이 짧을 때는 큰 문제가 아닐 수 있다.</p>
<p><strong>하지만 문장이 길면?</strong> -&gt; 앞부분의 계산이 끝나기를 계속 *<em>기다려야 한다. *</em></p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/07a98743-82da-4601-941c-0550101f89f7/image.png" alt=""></p>
<p>GPU가 아무리 <strong>빨라도</strong> 문장 내부 계산 자체가 <strong>순차적</strong>이기 때문에 <strong>병렬화하기 어렵다.</strong></p>
<p><code>Attention Is All You Need</code>의 저자들은 이 부분을 <code>RNN</code> 계열 모델의 제약으로 짚었다.</p>
<p>그래서 아예 다른 방향을 생각했다.</p>
<blockquote>
<p><strong>각 단어가 문장 전체에서 필요한 정보를 직접 찾게 하면 안 될까?</strong></p>
</blockquote>
<p>여기서 <code>Transformer</code>가 시작됐다.</p>
<hr>
<h2 id="2-지금-이-단어와-관련-있는-단어는-뭘까--self-attention-">2. 지금 이 단어와 관련 있는 단어는 뭘까? ( Self-Attention )</h2>
<p>이런 문장을 봐보자</p>
<blockquote>
<p>나는 어제 시장에서 사과를 먹었다.</p>
</blockquote>
<p><code>먹었다</code>라는 단어를 이해할 때 어떤 단어를 참고하면 좋을까? 아마 <code>사과를</code>이 눈에 들어올 것이다.</p>
<p>기존 <code>RNN</code>에서는 정보가 앞에서부터 전달된다.</p>
<pre><code class="language-text">나는 → 어제 → 시장에서 → 사과를 → 먹었다</code></pre>
<p><code>Self-Attention</code>에서는 <code>먹었다</code>가 문장 안의 다른 단어들과 직접 관련도를 계산할 수 있다.</p>
<p>예를 들어서 이런식이다.</p>
<pre><code class="language-text">나는        -&gt; 관련도 낮음
어제        -&gt; 조금 관련
시장에서 -&gt; 조금 관련
사과를    -&gt; 관련도 높음</code></pre>
<p>그러면 모델은 <code>먹었다</code>를 표현할 때 <code>사과를</code>의 정보를 더 많이 참고할 수 있다.</p>
<p><code>Self-Attention</code>은 이런 질문을 <strong>각 단어마다 하는 방식이라고 생각하면 편하다.</strong></p>
<blockquote>
<p><strong>지금 이 단어를 이해하는데 어떤 단어가 얼마나 관련있을까?</strong></p>
</blockquote>
<p>논문에서는 <code>Self-Attention</code>을 하나의 <code>sequence</code> 안에 있는 서로 다른 위치 사이의 관계를 계산하는 <code>Attention</code>이라고 설명한다.</p>
<hr>
<h2 id="3-q-k-v는-뭘까">3. Q, K, V는 뭘까?</h2>
<p>Transformer를 공부하다 보면 여기서 갑자기 세 글자가 등장한다.</p>
<p><strong>Q, K, V</strong></p>
<p>Query, Key, Value다.</p>
<p>이것은 검색이라고 생각하면 꽤 단순하다.</p>
<p>예를 들어서 갑자기 오디세이 원작 소설을 읽어보고 싶어졌다고 해보자. 서점에 가서 책을 찾을 것이다.</p>
<blockquote>
<p>호메로스의 오디세이아 책을 찾고 싶다.</p>
</blockquote>
<p>이게 <strong>Query</strong>다. 그리고 서점에는 수많은 책이 있다.</p>
<pre><code class="language-text">그리스 로마 신화
오디세이
일리아스
그리스 여행 가이드
영화 각본집
...</code></pre>
<p>각 책에는 제목이나 카테고리처럼</p>
<blockquote>
<p>나는 이런 책이야.</p>
</blockquote>
<p>라고 알려주는 정보가 있다. 이것을 <strong>Key</strong>라고 생각할 수 있다.</p>
<p>내가 <strong>찾고 싶은 것(Query)</strong>과 <strong>책이 가진 정보(Key)</strong>를 비교하면,</p>
<pre><code class="language-text">Query: 오디세이 원작을 찾고 싶다

오디세이       → 관련도 매우 높음
일리아스       → 어느 정도 관련 있음
여행 가이드    → 관련도 낮음</code></pre>
<p>이런 식으로 <strong>관련도</strong>를 계산할 수 있다. 내가 실제로 꺼내 읽을 <strong>책의 내용</strong>이 <strong>Value</strong>다.</p>
<p>정리하면 이렇다.</p>
<pre><code class="language-text">Query
내가 지금 무엇을 찾고 있는가?

        ↓ 비교

Key
각 정보는 무엇과 관련되어 있는가?

        ↓

    관련도 계산

        ↓

Value
실제로 가져올 정보</code></pre>
<p><code>Transformer</code>도 문장의 각 단어에서 Q, K, V를 만들고 비슷한 일을 한다.</p>
<hr>
<h2 id="4-논문에-나오는-attention-수식도-같은-이야기다">4. 논문에 나오는 Attention 수식도 같은 이야기다</h2>
<p><code>Transformer</code>의 유명한 수식이다.</p>
<p>$$
Attention(Q,K,V) = softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)V
$$</p>
<p>처음 보면 꽤 많이 부담스러울 수 있다. 그래도 방금 서점 이야기를 생각해보면 쉽다.</p>
<p>먼저</p>
<pre><code class="language-text">QKᵀ</code></pre>
<p>를 이용해 <strong>Query</strong>와 <strong>Key</strong>가 얼마나 관련 있는지 계산한다.</p>
<p>그다음 <code>softmax</code>를 거치며 아래처럼 정보의 비중을 만든다. <strong>(중요도)</strong></p>
<pre><code class="language-text">이 정보는 70%
저 정보는 20%
다른 정보는 10%</code></pre>
<p>마지막으로 그 비중을 이용해 <code>Value</code>를 가져온다.</p>
<pre><code class="language-text">Q와 K 비교
   ↓
관련도 계산
   ↓
Softmax
   ↓
비중 결정
   ↓
Value를 비중만큼 가져오기</code></pre>
<hr>
<h2 id="5-왜-sqrtd_k로-나눌까">5. 왜 $\sqrt{d_k}$로 나눌까?</h2>
<p>수식을 자세히 보면 <code>QKᵀ</code>만 있지 않다. $\sqrt{d_k}$로 나눈다.</p>
<p>그래서 이름도 그냥 <code>Dot-Product Attention</code>이 아니라 <strong><code>Scaled Dot-Product Attention</code></strong>이다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/10292d84-5efa-4108-9ab3-6f0f9b1c62a4/image.png" alt=""></p>
<blockquote>
<p>출처: <a href="https://arxiv.org/abs/1706.03762">Vaswani et al., <em>Attention Is All You Need</em>, NeurIPS 2017, Figure 2.</a></p>
</blockquote>
<p><strong>Q</strong>와 <strong>K</strong>의 차원이 커지면 <code>dot product</code> 값도 너무 커질 수 있다.</p>
<p>그 상태에서 <code>softmax</code>통과하면 결과가 한쪽으로 심하게 몰릴 수 있고, <code>gradient</code>도 매우 작아질 수 있다. 그래서 값이 너무 커지지 않도록 $\sqrt{d_k}$로 한 번 조절해준다.</p>
<blockquote>
<p><strong>Attention score가 너무 커지지 않도록 한 번 줄여준다.</strong></p>
</blockquote>
<p>정도로 이해해도 충분할 것이다.</p>
<hr>
<h2 id="6-attention-하나로는-부족했나">6. Attention 하나로는 부족했나?</h2>
<p>이런 문장을 보자.</p>
<blockquote>
<p>민수는 영희에게 책을 빌려주었고, 그녀는 다음 날 책을 돌려주었다.</p>
</blockquote>
<p>사람은 이 문장에서 여러 관계를 동시에 본다.</p>
<p><code>빌려주었다</code>를 보면 민수와 연결되고, <code>그녀</code>를 보면 영희와 연결되고, <code>돌려주었다</code>를 보면 책과 연결된다.</p>
<p>문장 안에는 관계가 하나만 있는 게 아니다. 그래서 <code>Transformer</code>는 <code>Attention</code>을 하나만 사용하지 않는다. 여러 <code>Attention</code>을 동시에 돌린다.</p>
<p>이게 <code>Multi-Head Attention</code>이다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/6a8fae88-2dd9-4632-9243-95c1ed30b522/image.png" alt=""></p>
<blockquote>
<p>출처: <a href="https://arxiv.org/abs/1706.03762">Vaswani et al., <em>Attention Is All You Need</em>, NeurIPS 2017, Figure 2.</a></p>
</blockquote>
<p>하지만</p>
<blockquote>
<p>&quot;Head 1은 주어 담당, Head 2는 목적어 담당&quot;</p>
</blockquote>
<p>처럼 사람이 역할을 정해놓는게 아니다. 모델이 학습하면서 서로 다른 위치와 서로 다른 표현을 바라보게 된다.</p>
<p>실제로 논문 뒤쪽의 <code>Attention</code> 시각화를 보면, 일부 Head가 멀리 떨어진 단어 사이의 관계를 포착하거나 문장의 구조와 관련된 서로 다른 패턴을 학습한 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/e2617c1e-4dcc-41b3-8be1-2754beaba30b/image.png" alt=""></p>
<blockquote>
<p>출처: <a href="https://arxiv.org/abs/1706.03762">Vaswani et al., <em>Attention Is All You Need</em>, NeurIPS 2017, Figure 3.</a></p>
</blockquote>
<hr>
<h2 id="7-rnn을-없앴더니-문제가-하나-생겼다">7. RNN을 없앴더니 문제가 하나 생겼다</h2>
<p>지금까진 <code>RNN</code>을 없애면 모든 게 좋아지는 것처럼 보인다. 그런데 문제가 생겼다.</p>
<p>다음 두 문장을 보자.</p>
<blockquote>
<p>개가 사람을 물었다.</p>
</blockquote>
<blockquote>
<p>사람이 개를 물었다.</p>
</blockquote>
<p>사용된 단어는 거의 똑같다. 그런데 뜻은 전혀 다르다. <strong>순서 때문이다.</strong></p>
<p><code>RNN</code>은 구조 자체가</p>
<pre><code class="language-text">1 → 2 → 3 → 4</code></pre>
<p>형태이기 때문에 자연스럽게 순서 정보가 들어간다. 그런데 <code>Self-Attention</code>은 여러 단어들을 동시에 바라본다.</p>
<p>모델 입장에서는</p>
<blockquote>
<p>&quot;그래서 이 단어가 몇 번째야?&quot;</p>
</blockquote>
<p>라는 정보가 필요하다. 그래서 <code>Transformer</code>는 <strong>Positional Encoding</strong>을 추가한다.</p>
<pre><code class="language-text">단어의 의미
Embedding

    +

문장에서의 위치
Positional Encoding

    ↓

Transformer</code></pre>
<p>논문에서는 sine과 cosine 함수를 이용해 위치 정보를 만들어 입력 Embedding에 더해준다.</p>
<p><code>Transformer</code>의 입력에는 <strong>무슨 단어인지 + 몇 번째 단어인지</strong> 가 함께 담긴다.</p>
<hr>
<h2 id="8-transformer-그림을-보자">8. Transformer 그림을 보자</h2>
<p><code>Transformer</code>를 처음 검색하면 거의 반드시 이 그림을 보게 된다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/184c2b05-3e4c-49aa-a690-1b97772791d4/image.png" alt=""></p>
<blockquote>
<p>출처: <a href="https://arxiv.org/abs/1706.03762">Vaswani et al., <em>Attention Is All You Need</em>, NeurIPS 2017, Figure 1.</a></p>
</blockquote>
<p>논문 3페이지에 나오는 <code>Encoder-Decoder</code> 그림이다.</p>
<p><code>Multi-Head Attention</code>, <code>Add &amp; Norm</code>, <code>Feed Forward</code>, <code>Masked Multi-Head Attention</code>이 한꺼번에 나와서 상당히 복잡해 보인다.</p>
<p>그래도 지금까지 이야기한 내용을 알고 보면 조금? 다르게 보일 수 있다.</p>
<p><code>Encoder</code>쪽만 간단하게 줄이면 이렇다.</p>
<pre><code class="language-text">입력 문장
   ↓
Embedding
   +
Positional Encoding
   ↓
Multi-Head Self-Attention
   ↓
Feed Forward Network
   ↓
다음 Layer</code></pre>
<p>논문에서는 이런 <code>Encoder Layer</code>를 6개 쌓았다.</p>
<p>각 <code>Layer</code>는 이런걸 한다.</p>
<pre><code class="language-text">1. Attention으로 단어들의 관계를 본다.

2. Feed Forward Network로
   각 위치의 정보를 다시 가공한다.</code></pre>
<p>이 과정을 여러 번 반복한다.</p>
<hr>
<h2 id="9-decoder에는-왜-mask가-붙어-있을까">9. Decoder에는 왜 Mask가 붙어 있을까?</h2>
<p>논문의 <code>Transformer</code> 그림을 보면 <code>Encoder</code>에는 그냥 <code>Multi-Head Attention</code>이 있는데,</p>
<p>Decoder에는 <code>Masked Multi-Head Attention</code> 이라는 게 있다. <code>Masked</code> 라는 이름 그대로 뭔가를 가린다는 뜻이다. 왜 가릴까?</p>
<p>만약에 번역한다고 생각해보자.</p>
<blockquote>
<p>I love this movie.</p>
</blockquote>
<p><code>Decoder</code>가 지금까지</p>
<blockquote>
<p>나는 이 </p>
</blockquote>
<p>까지 만들었다면 다음 단어를 예측해야 한다.</p>
<p>근데 모델을 학습할 때 미래의 정답까지 미리 볼 수 있다면? 그건 사실상 답안지를 펼쳐놓고 문제를 푸는 것이다.</p>
<p>그래서 현재 단어보다 뒤에 있는 단어를 못 보게 가린다.</p>
<pre><code class="language-text">현재까지 생성한 단어 -&gt; 볼 수 있음
미래에 생성할 단어   -&gt; 볼 수 없음</code></pre>
<p>이게 <code>Masked Self-Attention</code>이다.</p>
<hr>
<h2 id="10-그래서-rnn보다-뭐가-좋았을까">10. 그래서 RNN보다 뭐가 좋았을까?</h2>
<p><code>Transformer</code>의 가장 큰 장점 중 하나는 <strong>병렬화</strong>였다.</p>
<p><code>RNN</code>은 구조상</p>
<pre><code class="language-text">1 → 2 → 3 → 4 → 5</code></pre>
<p>처럼 앞에 계산이 끝나야 다음 계산으로 넘어갈 수 있다.</p>
<p><code>Self-Attention</code>은 문장 안의 여러 위치에 대한 연산을 병렬화할 수 있다. 멀리 떨어진 단어를 연결하는 방식도 다르다.</p>
<p><code>RNN</code>에서 첫 번째 단어의 정보가 마지막 단어까지 전달되려면 <strong>여러 단계를 거쳐야 한다.</strong></p>
<pre><code class="language-text">A → B → C → D → E</code></pre>
<p>하지만 <code>Self-Attention</code>은 두 위치 사이의 관계를 <strong>바로 계산할 수 있다.</strong></p>
<pre><code class="language-text">A ------------- E</code></pre>
<p>논문의 Table 1에서는 <code>maximum path length</code>를 다음처럼 비교한다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/98575d8e-71b0-4295-ba7d-dcb3a44e89b7/image.png" alt=""></p>
<blockquote>
<p>출처: <a href="https://arxiv.org/abs/1706.03762">Vaswani et al., <em>Attention Is All You Need</em>, NeurIPS 2017, Table 1.</a></p>
</blockquote>
<p>순차 연산 수도 <code>Self-Attention</code>은 <strong>O(1)</strong>, <code>recurrent layer</code>는 <strong>O(n)</strong>으로 분석한다.</p>
<p>그렇다고 <code>Transformer</code>가 언제나 <code>RNN</code>보다 <strong>계산량 자체가 적다는 뜻은 아니다.</strong></p>
<p><code>Self-Attention</code>은 sequence 길이에 대해 <strong>O(n²·d)</strong>의 계산 복잡도를 가진다.</p>
<p>문장이 아주 길어지면 <code>Attention</code> 자체의 계산량이 빠르게 커지는 문제가 있다.</p>
<p><code>Transformer</code>의 강점은 단순히 <strong>연산량이 적어서</strong>가 아니라, <strong>병렬화</strong>하기 쉽고, <strong>멀리 떨어진 단어끼리 직접 관계를 계산</strong>할 수 있고, <strong>번역 성능</strong>까지 잘 나온 것이다.</p>
<hr>
<h2 id="11-실제-성능은">11. 실제 성능은?</h2>
<p><code>RNN</code>과 <code>CNN</code>까지 뺐는데, 성능은 오히려 어떻게 나왔을까?</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/9cbc321b-e988-4729-8ab4-f135d82d9a04/image.png" alt=""></p>
<blockquote>
<p>출처: <a href="https://arxiv.org/abs/1706.03762">Vaswani et al., <em>Attention Is All You Need</em>, NeurIPS 2017, Table 2.</a></p>
</blockquote>
<p>논문에서는 <code>WMT 2014 영어→독일어 번역 문제</code>에서 <code>Transformer Big</code>이 <strong>BLEU 28.4</strong>를 기록했다. 당시 비교된 기존 최고 결과보다 <strong>2 BLEU</strong> 이상 높은 성능이었다.</p>
<p><code>RNN</code>과 <code>CNN</code>을 뺀 구조였는데도, 당시 비교한 기존 모델보다 더 높은 성능을 냈다.</p>
<hr>
<h2 id="12-attention을-만든-논문이-아니다">12. Attention을 만든 논문이 아니다</h2>
<p><code>Attention Is All You Need</code>라는 제목만 보면</p>
<blockquote>
<p>그럼 이 논문에서 Attention이 처음 나온 건가?</p>
</blockquote>
<p>라고 생각하기 쉽다. 근데 <code>Attention</code>은 이 논문 이전에도 이미 사용되고 있었다.</p>
<p>당시에는 주로 <code>RNN</code> / <code>LSTM</code> + <code>Attention</code> 처럼 기존 <code>sequence model</code>에 <code>Attention</code>을 함께 사용하는 방식이었다. </p>
<p><code>Transformer</code>가 다른건 이거였다.</p>
<blockquote>
<p><strong>Attention이 이렇게 잘 되는데, RNN이 정말 필요할까?</strong></p>
</blockquote>
<p>그리고 <code>RNN</code>과 <code>CNN</code>을 제거했다.</p>
<pre><code class="language-text">기존 방식

RNN / CNN
   +
Attention


Transformer

Self-Attention
      +
Feed Forward</code></pre>
<hr>
<h1 id="transformer를-한-문장으로-설명하면">Transformer를 한 문장으로 설명하면?</h1>
<blockquote>
<p><strong>문장을 반드시 앞에서부터 하나씩 처리하지 말고, 각 단어가 필요한 다른 단어를 직접 바라보게 하자.</strong></p>
</blockquote>
<p>그 방법이 <code>Self-Attention</code>이고, 그 <code>Self-Attention</code>을 모델의 중심으로 놓은 구조가 <code>Transformer</code>이다.</p>
<p>결국에 Transformer를 하나씩 뜯어보면 비슷한 질문으로 돌아온다.</p>
<blockquote>
<p><strong>&quot;지금 이 단어를 이해하려면 어디를 봐야 할까?&quot;</strong></p>
</blockquote>
<p>2017년에 나온 이 구조는 이후 BERT와 GPT 같은 모델로 이어졌고, 지금의 LLM을 이해할 때도 빠지지 않는 구조가 됐다.</p>
<hr>
<h3 id="논문-정보">논문 정보</h3>
<p>이 글은 Vaswani et al.이 2017년 NeurIPS에서 발표한 <strong>「Attention Is All You Need」</strong> 논문을 읽고, 쉽게 이해할 수 있도록 제 방식대로 정리한 글입니다. 감사합니다.</p>
<ul>
<li>논문명: Attention Is All You Need</li>
<li>저자: Ashish Vaswani et al.</li>
<li>발표: NeurIPS 2017</li>
<li>arXiv: <a href="https://arxiv.org/abs/1706.03762">논문 페이지</a></li>
<li>PDF: <a href="https://proceedings.neurips.cc/paper_files/paper/2017/file/3f5ee243547dee91fbd053c1c4a845aa-Paper.pdf?utm_campaign=news&amp;utm_medium=nationaltribune">원문 PDF 보기</a></li>
<li>NeurIPS: <a href="https://proceedings.neurips.cc/paper_files/paper/2017/hash/3f5ee243547dee91fbd053c1c4a845aa-Abstract.html">공식 Proceedings</a></li>
</ul>
<p>논문에 사용된 표와 그림은 원 논문에 명시된 이용 허가에 따라 출처를 표기하여 사용했습니다.</p>
<blockquote>
<p>Source: <a href="https://arxiv.org/abs/1706.03762">Vaswani et al., Attention Is All You Need, NeurIPS 2017.</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[블로그 검색에 Elasticsearch를 붙이면서 MySQL-ES 정합성 맞춘 과정 (Outbox 패턴)]]></title>
            <link>https://velog.io/@while-true-study/%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B2%80%EC%83%89%EC%97%90-Elasticsearch%EB%A5%BC-%EB%B6%99%EC%9D%B4%EB%A9%B4%EC%84%9C-MySQL-ES-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%A7%9E%EC%B6%98-%EA%B3%BC%EC%A0%95-Outbox-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@while-true-study/%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B2%80%EC%83%89%EC%97%90-Elasticsearch%EB%A5%BC-%EB%B6%99%EC%9D%B4%EB%A9%B4%EC%84%9C-MySQL-ES-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%A7%9E%EC%B6%98-%EA%B3%BC%EC%A0%95-Outbox-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Tue, 24 Mar 2026 11:18:17 GMT</pubDate>
            <description><![CDATA[<h2 id="0-문제-상황">0. 문제 상황</h2>
<p>블로그 검색 기능을 붙이면서 게시글 검색을 Elasticsearch로 분리했다.<br>처음에는 단순하게 생각했다.</p>
<ul>
<li>게시글 생성</li>
<li>MySQL 저장</li>
<li>Elasticsearch에도 바로 반영</li>
</ul>
<p>그런데 구현하다 보니 진짜 문제는 검색 기능 자체가 아니었다.</p>
<blockquote>
<p><strong>문제는 MySQL이 원본이고 Elasticsearch는 검색용 인덱스일 때, 두 저장소 상태가 어긋날 수 있다는 점이었다.</strong></p>
</blockquote>
<h3 id="예시-1-생성-시">예시 1) 생성 시</h3>
<ul>
<li>MySQL 저장은 성공</li>
<li>Elasticsearch 저장은 실패</li>
<li>결과적으로 <strong>DB에는 있는데 검색에는 안 잡힘</strong></li>
</ul>
<h3 id="예시-2-삭제-시">예시 2) 삭제 시</h3>
<ul>
<li>DB에서는 삭제 처리됨</li>
<li>ES에서는 문서 삭제 실패</li>
<li>결과적으로 <strong>이미 지워진 글이 검색에 계속 노출됨</strong></li>
</ul>
<p>결국 <strong>원본 DB와 검색 인덱스의 정합성을 어떻게 유지할 것인가</strong>가 문제였다.</p>
<hr>
<h2 id="1-처음-생각했던-방식">1. 처음 생각했던 방식</h2>
<p>처음에는 아래처럼 처리하려고 했다.</p>
<ol>
<li>MySQL에 게시글 저장</li>
<li>바로 Elasticsearch에도 저장</li>
</ol>
<p>이 방식은 생각보다 불안했다. 이전에 했던 토스 프로젝트 경험이 떠올랐다.</p>
<h3 id="문제점">문제점</h3>
<ul>
<li>MySQL 저장과 ES 저장은 <strong>하나의 트랜잭션으로 묶이지 않음</strong></li>
<li>ES 반영 실패 시 <strong>재시도 기준이 없음</strong></li>
<li>어떤 게시글이 반영 실패했는지 <strong>추적하기 어려움</strong></li>
<li>운영 중 장애가 나면 DB와 ES 상태가 <strong>어긋난 채 남을 수 있음</strong></li>
</ul>
<p>이 방식은 장애 상황까지 고려한 구조는 아니었다.</p>
<hr>
<h2 id="2-그래서-outbox-패턴으로-바꿨다">2. 그래서 Outbox 패턴으로 바꿨다</h2>
<p>그래서 아래 구조로 변경했다.</p>
<ul>
<li><strong>MySQL</strong>: 원본 데이터 저장소</li>
<li><strong>Elasticsearch</strong>: 검색 전용 저장소</li>
<li>게시글 변경 시 ES에 바로 쓰지 않고, 먼저 <strong>outbox 이벤트</strong>를 남김</li>
<li><strong>스케줄러</strong>가 outbox를 읽어 ES 반영 처리</li>
</ul>
<blockquote>
<h3 id="전체-흐름">전체 흐름</h3>
<p><strong>게시글 변경 → MySQL 저장 + outbox_event 기록 → Scheduler가 읽어서 ES 반영</strong></p>
</blockquote>
<p>이 구조로 바꾼 이유는 아래와 같다.</p>
<ul>
<li>게시글 변경과 동기화 이벤트 기록을 <strong>같은 DB 트랜잭션 안에서 처리할 수 있음</strong></li>
<li>ES 반영 실패 시 <strong>재처리 대상이 남음</strong></li>
<li>어떤 이벤트가 아직 처리되지 않았는지 <strong>추적 가능</strong></li>
<li>ES 반영이 조금 늦더라도 <strong>결국 최신 상태로 수렴</strong>하게 만들 수 있음</li>
</ul>
<p><strong>최종적 일관성(Eventual Consistency)</strong> 을 선택한 구조이다.</p>
<hr>
<h2 id="3-내가-만든-구조">3. 내가 만든 구조</h2>
<h3 id="3-1-역할-분리">3-1. 역할 분리</h3>
<ul>
<li><strong>MySQL</strong>: 게시글 원본 저장</li>
<li><strong>outbox_event</strong>: ES 반영해야 할 작업 저장</li>
<li><strong>Scheduler</strong>: 미처리 이벤트 조회 및 ES 반영</li>
<li><strong>Elasticsearch</strong>: 검색 전용 인덱스</li>
</ul>
<h3 id="3-2-전체-처리-흐름">3-2. 전체 처리 흐름</h3>
<ol>
<li>사용자가 게시글 생성 / 수정 / 삭제</li>
<li><code>post</code> 테이블 변경</li>
<li>같은 트랜잭션 안에서 <code>outbox_event</code> 저장</li>
<li>스케줄러가 <code>PENDING</code> 이벤트 조회</li>
<li>이벤트 타입에 따라 ES 색인 또는 삭제</li>
<li>성공 시 <code>SUCCESS</code>, 실패 시 재처리 가능 상태 유지</li>
</ol>
<blockquote>
<p><strong>ES에 바로 쓰는 구조가 아니라, 반영해야 할 작업 자체를 DB에 남겨두는 구조로 바꿨다.</strong></p>
</blockquote>
<hr>
<h2 id="4-데이터-모델에서-중요했던-점">4. 데이터 모델에서 중요했던 점</h2>
<h3 id="4-1-post-엔티티에-syncversion-추가">4-1. <code>post</code> 엔티티에 <code>syncVersion</code> 추가</h3>
<p>게시글에는 <code>syncVersion</code> 필드를 추가했다.
이 값은 <strong>지금 ES에 반영하려는 데이터가 최신인가?</strong> 를 판단하기 위한 기준이다.</p>
<ul>
<li>최초 생성: <code>syncVersion = 1</code></li>
<li>수정 1회: <code>syncVersion = 2</code></li>
<li>수정 2회: <code>syncVersion = 3</code></li>
<li>삭제: <code>syncVersion = 4</code></li>
</ul>
<p>아직 오래된 이벤트 역전 방지 로직까지 구현한 것은 아니지만,<br>나중에 그 문제를 처리할 수 있도록 기반은 만들어두었다.</p>
<hr>
<h3 id="4-2-outbox_event에-넣은-정보">4-2. <code>outbox_event</code>에 넣은 정보</h3>
<p>outbox에는 아래 정보를 저장했다.</p>
<ul>
<li><code>aggregateType = POST</code></li>
<li><code>aggregateId = postId</code></li>
<li>이벤트 타입: <code>CREATED</code>, <code>UPDATED</code>, <code>DELETED</code></li>
<li>ES 반영용 payload</li>
<li><code>version</code></li>
<li>상태값: <code>PENDING</code>, <code>SUCCESS</code>, <code>FAILED</code></li>
<li>생성 시각 / 처리 시각 / 재시도 횟수</li>
</ul>
<p>이렇게 하면 다음이 가능해진다.</p>
<ul>
<li>어떤 게시글 반영이 실패했는지 확인</li>
<li>지금 어디까지 처리됐는지 확인</li>
<li>재처리 대상 추적</li>
</ul>
<hr>
<h2 id="4-3-elasticsearch-문서-구성">4-3. Elasticsearch 문서 구성</h2>
<p>ES 인덱스는 <code>post_search_v4</code>를 사용했고, 문서 <code>_id</code>는 <code>postId</code>로 맞췄다.</p>
<h3 id="필드-구성">필드 구성</h3>
<ul>
<li><code>postid</code></li>
<li><code>title</code></li>
<li><code>contentpreview</code></li>
<li><code>authorid</code></li>
<li><code>authornickname</code></li>
<li><code>viewcount</code></li>
<li><code>likecount</code></li>
<li><code>createdat</code></li>
<li><code>updatedat</code></li>
<li><code>poststatus</code></li>
<li><code>version</code></li>
</ul>
<hr>
<h2 id="5-구현-흐름">5. 구현 흐름</h2>
<h3 id="5-1-생성">5-1. 생성</h3>
<h4 id="생성-흐름">생성 흐름</h4>
<ol>
<li><code>post</code> 저장</li>
<li><code>CREATED</code> outbox 이벤트 저장</li>
<li>스케줄러가 <code>PENDING</code> 이벤트 조회</li>
<li>ES 문서 저장</li>
<li>outbox 상태 <code>SUCCESS</code></li>
</ol>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/f25a0591-5bf6-495e-9a77-df040a98144c/image.png" alt=""></p>
<p>생성 후 Dev Tools에서 <code>_doc/{postId}</code>로 조회했을 때<br><code>found: true</code>가 나오는 것을 확인했다.</p>
<p><strong>ES 문서가 생성된 것까지 검증했다.</strong></p>
<hr>
<h3 id="5-2-수정">5-2. 수정</h3>
<h4 id="수정-흐름">수정 흐름</h4>
<ol>
<li>제목 / 내용 수정</li>
<li><code>updatedAt</code> 직접 갱신</li>
<li><code>syncVersion</code> 증가</li>
<li><code>UPDATED</code> outbox 이벤트 저장</li>
<li>스케줄러가 ES 문서 재색인</li>
</ol>
<p>처음에는 JPA lifecycle에 맡겼는데,<br>outbox payload 생성 시점과 <code>updatedAt</code> 갱신 시점이 어긋나서<br>ES 문서에는 <strong>이전 수정 시간</strong>이 들어가고 있었다.</p>
<p>그래서 수정 로직에서 <code>updatedAt</code>을 <strong>명시적으로 넣도록 변경</strong>했다.</p>
<h3 id="수정-후-확인한-것">수정 후 확인한 것</h3>
<ul>
<li>outbox에 <code>UPDATED</code> 이벤트 생성</li>
<li>처리 후 <code>SUCCESS</code></li>
<li>ES 문서의 <code>title</code> 변경</li>
<li><code>contentpreview</code> 변경</li>
<li><code>version</code> 증가</li>
<li><code>updatedat</code> 변경</li>
</ul>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/da950767-40ee-45fb-8edb-d02553d6fbe5/image.png" alt=""></p>
<p><strong>ES까지 정상적으로 따라 수정 되는 것을 확인했다.</strong></p>
<hr>
<h2 id="5-3-삭제">5-3. 삭제</h2>
<p>삭제는 아래처럼 처리했다.</p>
<ul>
<li><strong>DB</strong>: 소프트 삭제</li>
<li><strong>ES</strong>: 문서 삭제</li>
</ul>
<h3 id="삭제-흐름">삭제 흐름</h3>
<ol>
<li><code>postStatus = DELETED</code></li>
<li><code>deletedAt = now</code></li>
<li><code>updatedAt = now</code></li>
<li><code>syncVersion</code> 증가</li>
<li><code>DELETED</code> outbox 이벤트 저장</li>
<li>스케줄러가 ES 문서 삭제</li>
<li>outbox 상태 <code>SUCCESS</code></li>
</ol>
<p>삭제 후에는 ES에서 <code>_doc/{postId}</code>를 직접 조회해서<br><code>found: false</code>가 나오는지 확인했다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/8cedd349-07b2-4c08-a011-60c7b8ef5441/image.png" alt=""></p>
<ul>
<li>DB에는 삭제 이력이 남고</li>
<li>검색에서는 노출되지 않는 구조</li>
</ul>
<p>로 정상 동작하는 것을 확인했다.</p>
<hr>
<h2 id="6-구현하면서-막혔던-부분">6. 구현하면서 막혔던 부분</h2>
<h3 id="6-1-payload-컬럼-길이-부족">6-1. <code>payload</code> 컬럼 길이 부족</h3>
<p>outbox payload를 JSON 문자열로 저장할 때<br><code>payload</code> 컬럼 길이가 부족해서 insert가 실패했다.</p>
<p>애플리케이션에서는 <code>@Lob</code>를 붙였지만,<br>DB 컬럼 타입이 충분히 크지 않아서 실제 저장 시점에 길이 초과가 발생했다.</p>
<h4 id="해결">해결</h4>
<ul>
<li>DB 컬럼을 <code>LONGTEXT</code> 쪽으로 수정</li>
<li><code>@Column(nullable = false, columnDefinition = &quot;LONGTEXT&quot;)</code></li>
</ul>
<blockquote>
<p><strong>JPA 애노테이션만 바꿨다고 DB 스키마까지 자동으로 안전해지는 건 아니다.</strong></p>
</blockquote>
<hr>
<h3 id="6-2-localdatetime-직렬화-문제">6-2. <code>LocalDateTime</code> 직렬화 문제</h3>
<p><code>PostOutboxPayload</code>에 <code>createdAt</code>, <code>updatedAt</code>을 <code>LocalDateTime</code>으로 넣었더니<br>Jackson 기본 설정만으로는 직렬화가 되지 않았다.</p>
<h4 id="해결-1">해결</h4>
<ul>
<li><code>ObjectMapper</code>에 <code>JavaTimeModule</code> 등록</li>
</ul>
<p>payload 구조를 바꾸면 직렬화 설정도 같이 따라와야 한다.</p>
<hr>
<h3 id="6-3-updatedat이-es에-반영되지-않던-문제">6-3. <code>updatedAt</code>이 ES에 반영되지 않던 문제</h3>
<p>수정 후 <code>version</code>은 올라가는데 <code>updatedAt</code>은 이전 값으로 남는 문제가 있었다.</p>
<h4 id="원인">원인</h4>
<ul>
<li>payload 생성 시점이 JPA 자동 갱신보다 빨랐음</li>
</ul>
<h4 id="해결-2">해결</h4>
<ul>
<li>수정 / 삭제 로직에서 <code>updatedAt</code> 직접 세팅</li>
</ul>
<p>이후에는 ES에서도 수정 시간이 정상적으로 반영됐다.</p>
<hr>
<h3 id="6-4-post_status에-deleted-저장-실패">6-4. <code>post_status</code>에 <code>DELETED</code> 저장 실패</h3>
<p>삭제 처리 시 <code>postStatus = DELETED</code>로 바꾸는 순간<br>DB에서 상태값 저장 오류가 발생했다.</p>
<h4 id="원인-1">원인</h4>
<ul>
<li>자바 enum에는 <code>DELETED</code> 추가</li>
<li>DB 컬럼 제약은 아직 반영 안 됨</li>
</ul>
<h4 id="해결-3">해결</h4>
<ul>
<li>DB 컬럼 제약 수정</li>
</ul>
<p><strong>DB 스키마 제약도 함께 맞췄다.</strong></p>
<hr>
<h3 id="6-5-수정-api-테스트에서-인증-객체-null">6-5. 수정 API 테스트에서 인증 객체 null</h3>
<p>Postman으로 수정 API 테스트를 하다가<br><code>@AuthenticationPrincipal</code>이 null이라 NPE가 발생했다.</p>
<p>처음에는 인증 로직이 문제인 줄 알았는데,<br>실제로는 security 설정에서 <code>.permitAll()</code> 때문에 요청은 통과하고 있었고<br>인증 객체는 보장되지 않는 상태였다.</p>
<h3 id="해결-4">해결</h3>
<ul>
<li>수정 / 삭제 API는 인증이 필요하다는 전제로 정리</li>
<li>테스트 시 실제 인증 헤더 포함해서 다시 검증</li>
</ul>
<hr>
<h2 id="7-실제로-확인한-것">7. 실제로 확인한 것</h2>
<h3 id="생성-검증">생성 검증</h3>
<ul>
<li>게시글 작성</li>
<li><code>outbox_event</code>에 <code>CREATED</code></li>
<li>처리 후 <code>SUCCESS</code></li>
<li>ES <code>_doc/{postId}</code> 조회</li>
<li><code>found: true</code></li>
</ul>
<h3 id="수정-검증">수정 검증</h3>
<ul>
<li>게시글 수정</li>
<li><code>syncVersion</code> 증가</li>
<li><code>UPDATED</code> 이벤트 생성</li>
<li>처리 후 <code>SUCCESS</code></li>
<li>ES 문서에서 <code>title</code>, <code>contentpreview</code>, <code>updatedat</code>, <code>version</code> 변경 확인</li>
</ul>
<h3 id="삭제-검증">삭제 검증</h3>
<ul>
<li>게시글 삭제</li>
<li><code>DELETED</code> 이벤트 생성</li>
<li>처리 후 <code>SUCCESS</code></li>
<li>ES <code>_doc/{postId}</code> 조회 시 <code>found: false</code></li>
</ul>
<p>결국 생성 / 수정 / 삭제 전부<br><strong>MySQL → Outbox → Scheduler → Elasticsearch</strong><br>흐름으로 실제 반영되는 것을 확인했다.</p>
<hr>
<h2 id="8-한계점">8. 한계점</h2>
<h3 id="8-1-초기-대량-색인">8-1. 초기 대량 색인</h3>
<p>Outbox 패턴은 운영 중 발생하는 변경 이벤트를 안정적으로 ES에 반영하는 데는 적합했지만,
이미 MySQL에 쌓여 있는 기존 전체 데이터 (1000만건)를 처음부터 적재하는 용도로는 적절하지 않았다.</p>
<p>그래서 초기 색인은 Logstash 기반 배치 적재로 처리하고,
그 이후의 생성/수정/삭제 동기화는 Outbox + Scheduler 구조로 분리했다.</p>
<ul>
<li><strong>초기 적재는 Logstash</strong></li>
<li><strong>운영 동기화는 Outbox</strong></li>
</ul>
<p>로 책임을 나눴다.</p>
<h3 id="8-2-오래된-이벤트-역전-방지">8-2. 오래된 이벤트 역전 방지</h3>
<p>지금은 <code>syncVersion</code>을 넣어뒀지만,<br>늦게 도착한 오래된 이벤트를 ES 반영 단계에서 건너뛰는 로직은 아직 없다.</p>
<p>이후 고도화하려고 한다.</p>
<h3 id="8-3-보정-배치">8-3. 보정 배치</h3>
<p>장기적으로는 MySQL과 ES 상태를 주기적으로 대조해서<br>누락이나 불일치를 보정하는 배치도 필요하다.</p>
<p>지금은 outbox 재시도 구조까지 맞춘 단계이고,<br>보정 배치까지 들어가야 운영 안정성이 더 올라간다.</p>
<h3 id="8-4-운영성">8-4. 운영성</h3>
<p>아직 아래 같은 부분은 없다.</p>
<ul>
<li>성공 이벤트 정리 정책</li>
<li>장기 실패 이벤트 분리</li>
<li>관리자 모니터링</li>
<li>장애 대응용 운영 도구</li>
</ul>
<hr>
<h2 id="9-정리">9. 정리</h2>
<p>중요한 것은 <strong>검색 인덱스를 별도 저장소로 분리했을 때, 원본 DB와 어떻게 정합성을 맞출 것인가</strong><br>이 문제를 직접 구현하고 검증해본 점이라고 생각한다.</p>
<p>처음에 생각했던 것 처럼 서비스 로직에서 DB 저장 직후 바로 ES 저장하는 구조로 갔다면,</p>
<ul>
<li>실패 추적도 어렵고</li>
<li>재처리 기준도 없고</li>
<li>운영 중 정합성 문제도 남았을 가능성이 컸다</li>
</ul>
<p>반면 이번에는</p>
<ul>
<li>MySQL을 원본으로 두고</li>
<li>outbox에 반영할 작업을 남기고</li>
<li>스케줄러가 비동기 반영하고</li>
<li>생성 / 수정 / 삭제 각각을 직접 검증했다</li>
</ul>
<p><strong>정합성 문제를 고려한 검색 동기화 구조를 직접 구현한 것</strong>에 의미가 있다고 생각한다.</p>
<hr>
<h2 id="10-요약">10. 요약</h2>
<p>블로그 검색 기능에 Elasticsearch를 도입하면서,<br>MySQL과 ES 간 정합성 문제를 해결하기 위해<br><strong>Outbox 패턴 + Scheduler 기반 비동기 동기화 구조</strong>를 적용했다.</p>
<p>그리고 생성 / 수정 / 삭제 전 경로에서<br>실제로 ES 반영까지 되는지 직접 검증했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[검색 기능 개선기 2]]></title>
            <link>https://velog.io/@while-true-study/%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0-2</link>
            <guid>https://velog.io/@while-true-study/%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0-2</guid>
            <pubDate>Mon, 02 Mar 2026 15:59:23 GMT</pubDate>
            <description><![CDATA[<h2 id="0-왜-elasticsearch를-도입하게-됐는가">0. 왜 Elasticsearch를 도입하게 됐는가</h2>
<p>1편에서는 MySQL 기반 검색을 단계적으로 비교했다.</p>
<ul>
<li><code>LIKE &#39;%keyword%&#39;</code></li>
<li><code>prefix LIKE (&#39;keyword%&#39;)</code></li>
<li>MySQL Full-Text (기본)</li>
<li>MySQL Full-Text + ngram</li>
</ul>
<p>1편에서는 검색 결과 후보 수 + 정렬(<code>ORDER BY</code>) + OFFSET 페이징 + 동시성 부하가 합쳐진 비용이었다는 점이다.</p>
<p>MySQL Full-Text / ngram까지 적용해보면서 개선 가능성은 확인했지만, 내 환경에서는 아래 문제가 남았다.</p>
<ul>
<li>키워드/모드/offset에 따라 응답시간 편차가 큼</li>
<li>p95/p99 관점에서 성능 안정성이 부족함</li>
<li>일부 구간은 여전히 타임아웃 발생</li>
<li>한글 검색 품질과 성능을 동시에 맞추기 어려움</li>
</ul>
<p>그래서 검색 부하를 DB에서 분리하고, 검색 전용 인덱싱/분석/튜닝이 가능한 Elasticsearch를 다음 단계로 선택했다.</p>
<hr>
<h2 id="1-이번-글-내용-요약">1. 이번 글 내용 요약</h2>
<p>이번 글에서는 Elasticsearch를 실제로 붙이면서 겪은 과정을 정리했다.</p>
<ul>
<li>MySQL =&gt; Logstash =&gt; Elasticsearch 재색인 과정</li>
<li>대량 적재 최적화와 병목 대응</li>
<li>재색인 중 발생한 트러블슈팅 (<code>document_id</code>, 필드명, refresh 등)</li>
<li>Spring 검색 API 연동 시 발생한 매핑/날짜 파싱 이슈</li>
<li>Locust로 확인한 Elasticsearch 검색 성능 결과</li>
</ul>
<p>1편이 왜 DB 검색에서 병목이 생겼는지 다뤘고, 이번 글은 DB 데이터를 어떻게 옮겼고, 결과가 어떻게 바뀌었는지를 다룬다.</p>
<hr>
<h2 id="2-도입-구조-mysql-→-logstash-→-elasticsearch">2. 도입 구조 (MySQL → Logstash → Elasticsearch)</h2>
<p>Elasticsearch 도입 구조는 아래처럼 잡았다.</p>
<ul>
<li><strong>MySQL</strong>: 원본 데이터 저장소</li>
<li><strong>Logstash (JDBC input)</strong>: MySQL 데이터를 읽어 Elasticsearch로 적재</li>
<li><strong>Elasticsearch</strong>: 검색 인덱스 저장소</li>
<li><strong>Spring Boot API</strong>: 검색 요청을 Elasticsearch로 조회</li>
</ul>
<p>즉, 검색 요청이 DB를 직접 때리지 않고, <strong>검색 전용 인덱스(Elasticsearch)</strong> 를 조회하도록 분리했다.</p>
<h3 id="2-1-왜-logstash를-선택했는가">2-1. 왜 Logstash를 선택했는가</h3>
<p>대량 재색인을 빠르게 붙이는 게 먼저 목표였기 때문에, ETL 파이프라인을 코드로 직접 짜기보다 Logstash JDBC input을 사용했다.</p>
<p>장점:</p>
<ul>
<li>JDBC 기반으로 빠르게 구성 가능</li>
<li>MySQL → ES 적재 파이프라인을 짧은 시간 안에 만들 수 있음</li>
<li>대량 적재 설정(<code>paging</code>, batch, workers) 조절 가능</li>
</ul>
<hr>
<h2 id="3-elasticsearch-인덱스-적재-전략">3. Elasticsearch 인덱스 적재 전략</h2>
<h3 id="3-1-대량-적재-중-elasticsearch-설정">3-1. 대량 적재 중 Elasticsearch 설정</h3>
<p>대량 적재 중에는 검색 가시성보다 적재 성능이 중요해서 아래 설정을 사용했다.</p>
<ul>
<li><code>refresh_interval = -1</code></li>
<li><code>number_of_replicas = 0</code></li>
</ul>
<p>이렇게 하면 인덱싱 중 refresh/replica 비용을 줄여 적재 성능을 높일 수 있다고 한다.
적재가 끝난 뒤에는 운영용 값으로 다시 돌려놓았다.
(<code>refresh_interval=&#39;1s&#39;</code>, <code>replicas=0</code>)</p>
<h3 id="3-2-logstash-jdbc-paging-사용">3-2. Logstash JDBC paging 사용</h3>
<p>Logstash JDBC input에서 paging을 켰다.</p>
<ul>
<li><code>jdbc_paging_enabled =&gt; true</code></li>
<li><code>jdbc_page_size =&gt; 5000</code></li>
<li><code>jdbc_fetch_size =&gt; 5000</code></li>
</ul>
<p>처음에는 이걸로 충분할 줄 알았는데, 뒤로 갈수록 속도가 점점 느려지는 문제가 나타났다.
원인은 <strong>OFFSET 페이징 병목</strong>이었다.</p>
<hr>
<h2 id="4-재색인-병목-logstash-jdbc-offset이-뒤로-갈수록-느려짐">4. 재색인 병목: Logstash JDBC OFFSET이 뒤로 갈수록 느려짐</h2>
<p>Logstash JDBC paging은 내부적으로 <code>LIMIT ... OFFSET ...</code> 형태로 페이지를 가져오는데, 뒤 페이지로 갈수록 OFFSET 비용이 커진다.</p>
<pre><code>[dac520dce07779dc404cec35c0fbaec9d88feb4eac871abdc56f519785748ee3] (1.893400s) 
SELECT * FROM ( logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 840000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 940000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1040000 logstash_1-1 | [2026-02-28T23:23:27,400][INFO ][logstash.inputs.jdbc ][main]
[dac520dce07779dc404cec35c0fbaec9d88feb4eac871abdc56f519785748ee3] (2.203273s) 
SELECT * FROM ( logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1140000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1240000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1340000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1400000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1440000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1540000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1640000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1740000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1840000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 1940000 logstash_1-1 | [2026-02-28T23:32:44,666][INFO ][logstash.inputs.jdbc ][main]
[dac520dce07779dc404cec35c0fbaec9d88feb4eac871abdc56f519785748ee3] (3.940005s) SELECT * FROM ( logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 2040000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 2140000 logstash_1-1 | ) AS t1 LIMIT 5000 OFFSET 2240000</code></pre><p>1.8s =&gt; 2.2s =&gt; 3.9s
실제로 로그를 보면 뒤로 갈수록 같은 5000건을 가져오는 쿼리 시간이 증가했다. 하나의 Logstash로 1000만개를 하니 5000개당 10초수준으로 되기도 했다. 1편에서 검색 쿼리의 OFFSET 병목을 본 것과 비슷했다. </p>
<h3 id="4-1-해결-post_id-범위-분할--병렬-재색인">4-1. 해결: <code>post_id</code> 범위 분할 + 병렬 재색인</h3>
<p>한 개 Logstash 파이프라인으로 끝까지 미는 대신, <code>post_id</code> 범위 기준으로 4분할해서 병렬 적재했다.</p>
<p>나는 1000만개의 row가 있기 때문에 250만개씩 분할 해서 적재했다.</p>
<ul>
<li>1번: <code>post_id &lt;= 2500000</code></li>
<li>2번: <code>2500000 &lt; post_id &lt;= 5000000</code></li>
<li>3번: <code>5000000 &lt; post_id &lt;= 7500000</code></li>
<li>4번: <code>post_id &gt; 7500000</code></li>
</ul>
<p>이렇게 하면 각 파이프라인이 처리하는범위가 줄어 OFFSET 깊이가 작아졌고, 재색인 속도가 훨씬 나아졌다.</p>
<h3 id="4-2-병렬-실행-시-주의점">4-2. 병렬 실행 시 주의점</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/82cd0ffc-0588-40e3-ba68-9e685cc3323b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/6528bba3-88b5-4c52-97ec-a980f396529d/image.png" alt=""></p>
<p>4개를 동시에 돌릴 때 기본 worker 설정으로 두면 오히려 로컬 리소스가 과포화될 수 있어서, 서비스별 pipeline workers를 줄여서 실행했다.</p>
<pre><code class="language-yml">logstash_1:
  image: docker.elastic.co/logstash/logstash:9.2.1
  depends_on:
    - elasticsearch
  command: &gt;
    logstash -f /usr/share/logstash/pipeline
    --pipeline.workers 2
    --pipeline.batch.size 200
  volumes:
    - ./logstash/pipeline-1:/usr/share/logstash/pipeline
    - ./logstash/mysql-connector-j.jar:/usr/share/logstash/mysql-connector-j.jar</code></pre>
<p>병렬성은 늘리고 서비스당 과도한 worker를 줄이는 방식으로 조절해서 사용했다.</p>
<hr>
<h2 id="5-재색인-트러블슈팅">5. 재색인 트러블슈팅</h2>
<p>성능 튜닝보다 더 시간을 잡아먹은 건, 재색인 중 <strong>데이터 정합성/필드명/가시성 문제</strong>였다.</p>
<h3 id="5-1-kibana-문서-수가-안-올라가-보였던-문제">5-1. Kibana 문서 수가 안 올라가 보였던 문제</h3>
<p>Logstash 로그에서는 데이터가 많이 들어가는 것처럼 보이는데 Kibana 문서 수가 적게 보이는 상황이 있었다.</p>
<ol>
<li><p><strong><code>refresh_interval=-1</code> 영향</strong></p>
<ul>
<li>대량 적재 중에는 바로 검색 가능 상태로 보이지 않을 수 있음</li>
</ul>
</li>
<li><p><strong>실제로 적재 건수가 적은 경우도 있음</strong></p>
<ul>
<li><code>_stats</code>, <code>_count</code>, <code>_cat/indices</code>로 확인 필요</li>
</ul>
</li>
</ol>
<h3 id="5-2-document_id-문제로-deleted만-폭증">5-2. <code>document_id</code> 문제로 <code>deleted</code>만 폭증</h3>
<p>재색인 중 <code>1 document + deleted만 폭증</code>하는 이상한 상태를 겪었다.</p>
<p>원인은 <code>document_id</code>에 넣은 필드명이 실제 이벤트 필드명과 달랐기 때문이었다.</p>
<p>예를 들어서 SQL에서는 <code>AS postId</code>로 alias를 줬지만, Logstash JDBC가 컬럼명을 소문자로 내리면서 실제 필드가 <code>postid</code>로 들어왔다.
그 상태에서 아래처럼 쓰면:</p>
<pre><code class="language-conf">document_id =&gt; &quot;%{postId}&quot;</code></pre>
<p>치환에 실패해서 모든 문서가 같은 <code>_id</code>로 들어가고, 결과적으로 <strong>한 문서만 계속 덮어쓰기</strong> 되는 현상이 발생했다.</p>
<p>증상:</p>
<ul>
<li>Kibana 문서 수는 1개만 늘어났다.</li>
<li><code>deleted</code>수치만 계속 증가, 감소하는 패턴이 보였다.</li>
</ul>
<p>해결:</p>
<ul>
<li>나는 <code>document_id =&gt; &quot;%{postid}&quot;</code> 로 변경해서 해결 했다.</li>
<li><code>lowercase_column_names =&gt; false</code> 사용해도 가능하다고 한다.</li>
</ul>
<p>문제 원인 찾는 데 시간이 꽤 걸렸었다.</p>
<h3 id="5-3-hosts--httpelasticsearch9200-vs-localhost9200">5-3. <code>hosts =&gt; [&quot;http://elasticsearch:9200&quot;]</code> vs <code>localhost:9200</code></h3>
<pre><code class="language-bash">output {
  elasticsearch {
    hosts =&gt; [&quot;http://elasticsearch:9200&quot;]
    index =&gt; &quot;post_search_v4&quot;
    document_id =&gt; &quot;%{postid}&quot;
    action =&gt; &quot;index&quot;
  }
}</code></pre>
<p>처음엔 <code>localhost:9200</code>로 써야 하는지 헷갈렸는데, Logstash가 Docker 컨테이너 안에서 돌고 있을 때는 <code>localhost</code>가 컨테이너 자기 자신을 가리킨다.</p>
<p>Docker Compose 네트워크에서 Elasticsearch 컨테이너 서비스명(<code>elasticsearch</code>)으로 접근하는 설정이 맞았다.</p>
<ul>
<li>컨테이너 내부 Logstash → <code>http://elasticsearch:9200</code> (정상)</li>
<li>호스트에서 직접 실행하는 클라이언트 → <code>http://localhost:9200</code></li>
</ul>
<hr>
<h2 id="6-최종-적재-결과">6. 최종 적재 결과</h2>
<p>필드명/<code>document_id</code> 문제를 정리하고 재색인을 다시 수행한 뒤, 최종적으로 <code>post_search_v4</code> 인덱스에 약 <strong>9,824,079</strong>건이 적재되었다.</p>
<p>Kibana 기준으로 문서 수와 저장 크기가 정상 범위로 올라오는 것을 확인했다.</p>
<ul>
<li>인덱스: <code>post_search_v4</code></li>
<li>Documents: 약 <strong>9,824,079</strong></li>
<li>Storage: 약 <strong>2.07GB</strong> (실험 기준)</li>
</ul>
<p>재색인 문제는 해결했으니 이제 <strong>Spring 검색 API와 Elasticsearch 조회</strong>를 제대로 연결을 해야했다.</p>
<hr>
<h2 id="7-spring-검색-api-연동과-쿼리-이슈">7. Spring 검색 API 연동과 쿼리 이슈</h2>
<p>Elasticsearch 인덱스가 만들어졌다고 끝이 아니었다.
애플리케이션 코드와 실제 ES 문서 필드명이 맞지 않으면 검색이 바로 깨졌다.</p>
<h3 id="7-1-all-shards-failed-발생">7-1. <code>all shards failed</code> 발생</h3>
<p>재색인한 문서의 필드명은 Logstash 문제를 해결하며 소문자로 바꿨었다.</p>
<ul>
<li><code>postid</code></li>
<li><code>createdat</code></li>
<li><code>poststatus</code></li>
<li><code>viewcount</code></li>
<li><code>likecount</code></li>
</ul>
<p>그런데 Spring 코드에서 예전 camelCase 기준으로 정렬/조회 필드를 쓰면, 예를 들어 <code>createdAt</code> 같은 필드로 정렬할 때 Elasticsearch에서 <code>all shards failed</code>가 발생할 수 있었다.</p>
<p>해결 방향:</p>
<ul>
<li>ES 쿼리에서 실제 필드명(<code>createdat</code>, <code>postid</code>, <code>poststatus</code>) 사용</li>
<li><code>PostSearchDocument</code> 매핑에서 <code>@JsonProperty</code>로 소문자 필드명 매핑</li>
</ul>
<h3 id="7-2-날짜-파싱-이슈">7-2. 날짜 파싱 이슈</h3>
<p>필드명 문제를 해결한 뒤에는 날짜 파싱 이슈가 발생했다.</p>
<p>Elasticsearch에서 내려온 날짜 값은 아래처럼 <code>Z</code>(UTC offset)가 포함된 문자열이었다.</p>
<ul>
<li><code>2026-02-25T09:52:02.000Z</code></li>
</ul>
<p>처음에는 <code>LocalDateTime.parse()</code>로 바로 파싱했는데, <code>Z</code>를 처리하지 못해서 <code>DateTimeParseException</code>이 발생했다.</p>
<p>해결은 <code>Instant.parse()</code>(또는 <code>OffsetDateTime.parse()</code>)로 먼저 파싱한 뒤, 필요한 시간대(예: <code>Asia/Seoul</code>)로 변환하는 방식으로 했다.</p>
<p>즉, Elasticsearch 연동에서는 성능 이전에 <strong>필드명/매핑/날짜 포맷 정합성</strong>을 맞추는 작업이 중요했다.</p>
<hr>
<h2 id="8-elasticsearch-부하-테스트-설계">8. Elasticsearch 부하 테스트 설계</h2>
<p>Elasticsearch 검색 성능을 깔끔하게 보기 위해, Locust 스크립트도 수정했다.</p>
<p>초기에는 <code>/posts</code> 목록 API를 같이 호출해서 키워드 풀을 만들었는데, 이 요청 자체가 aggregate 수치를 오염시킬 수 있었다.
그래서 최종 테스트에서는 <strong>ES 검색 API만 호출</strong>하도록 분리했다.</p>
<p>테스트 대상:</p>
<ul>
<li><code>GET /api/v1/search/posts/es</code></li>
</ul>
<p>분포:</p>
<ul>
<li>키워드: 한글/영문 혼합 + 핫키워드</li>
<li>offset: <code>0</code>, <code>1~200</code>, <code>201~1000</code>, <code>1001+</code></li>
</ul>
<hr>
<h2 id="9-elasticsearch-부하-테스트-결과-locust">9. Elasticsearch 부하 테스트 결과 (Locust)</h2>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/9bd9040d-dea5-42ef-9c26-b12d649dc796/image.png" alt=""></p>
<h3 id="9-1-전체-집계">9-1. 전체 집계</h3>
<p>실측 결과는 아래와 같았다.</p>
<ul>
<li>총 요청 수: <strong>653,979</strong></li>
<li>실패 수: <strong>0</strong></li>
<li>평균 응답시간: <strong>8.69ms</strong></li>
<li>중앙값(p50): <strong>7ms</strong></li>
<li>p95: <strong>18ms</strong></li>
<li>p99: <strong>26ms</strong></li>
<li>최대 응답시간: <strong>178ms</strong></li>
<li>RPS: <strong>약 62.1</strong></li>
</ul>
<p>1편에서 했던 MySQL 기반 검색 결과와 완전히 다른 차원이었다. 너무 빨랐다.</p>
<h3 id="9-2-offset-구간별-결과">9-2. offset 구간별 결과</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/8292b2f8-8538-4a08-840d-d29ae4ef65e2/image.png" alt=""></p>
<p>offset 구간이 커질수록 응답시간이 올라가는 패턴은 여전히 보였다.</p>
<ul>
<li><p><code>offset=0</code></p>
<ul>
<li>평균 <strong>7.04ms</strong></li>
<li>p95 <strong>10ms</strong></li>
<li>p99 <strong>14ms</strong></li>
</ul>
</li>
<li><p><code>offset=1~200</code></p>
<ul>
<li>평균 <strong>7.41ms</strong></li>
<li>p95 <strong>11ms</strong></li>
<li>p99 <strong>15ms</strong></li>
</ul>
</li>
<li><p><code>offset=201~1000</code></p>
<ul>
<li>평균 <strong>9.05ms</strong></li>
<li>p95 <strong>15ms</strong></li>
<li>p99 <strong>19ms</strong></li>
</ul>
</li>
<li><p><code>offset=1001+</code></p>
<ul>
<li>평균 <strong>12.19ms</strong></li>
<li>p95 <strong>24ms</strong></li>
<li>p99 <strong>29ms</strong></li>
</ul>
</li>
</ul>
<h3 id="9-3-해석">9-3. 해석</h3>
<p>Elasticsearch로 바꿔도 <code>from/size</code> 기반 offset 페이징 비용은 완전히 사라지지 않았다.
offset이 커질수록 느려지는 패턴 자체는 남아 있었다.</p>
<ul>
<li>실패율 0</li>
<li>p95 18ms / p99 26ms</li>
<li>RPS 안정적 유지</li>
</ul>
<p>내가 원했던 건 평균값 몇 ms보다도 <strong>p95/p99와 timeout 안정성</strong>이었는데, 그 기준에서 Elasticsearch 도입 효과가 확실했다.</p>
<hr>
<h2 id="10-최종-비교-요약">10. 최종 비교 요약</h2>
<p>이번 실험에서 얻은 결론을 정리하면 아래와 같다.</p>
<ul>
<li><code>LIKE &#39;%keyword%&#39;</code>: 구현은 단순하지만 대용량 + 정렬/offset 조합에서 병목이 크게 발생했다.</li>
<li>prefix LIKE: 이론상 인덱스 후보이지만, 내 실험 조건에서는 실행계획상 풀스캔 + 파일정렬</li>
<li>MySQL Full-Text / ngram: 개선 가능성은 있지만 키워드/모드/offset 편차가 크고 안정성 부족 + 한글 검색 빈약</li>
<li>Elasticsearch: 재색인/동기화/매핑 관리 비용은 늘지만, 제품 API 응답시간 안정성은 크게 개선</li>
</ul>
<h3 id="실험-한계와-해석-범위">실험 한계와 해석 범위</h3>
<p>이번 글의 비교는 <strong>내 데이터 분포 / 내 쿼리 구조 / 내 정렬·페이징 조건 / 내 튜닝 수준</strong>에서의 결과다.<br>MySQL Full-Text / ngram은 설정과 데이터 특성에 따라 결과가 달라질 수 있다.</p>
<p>따라서 이 글의 결론은 MySQL Full-Text/ngram이 항상 느리다가 아니라,<br><strong>현재 조건에서는 키워드/모드/offset에 따른 편차가 크고, 내가 목표로 한 p95/p99·timeout 안정성을 만족시키기 어려웠다</strong>는 의미로 해석해야 한다.</p>
<hr>
<h2 id="11-결론">11. 결론</h2>
<p>Elasticsearch를 붙이고 나서 가장 크게 느낀 점은 두 가지였다.</p>
<ol>
<li><p><strong>검색 엔진 도입보다 어려운 건 데이터 이행과 정합성 관리다</strong></p>
<ul>
<li>Logstash JDBC paging 병목</li>
<li><code>document_id</code> 덮어쓰기</li>
<li>필드명 소문자화</li>
<li>Spring-ES 매핑/날짜 파싱 이슈</li>
</ul>
</li>
<li><p><strong>최종 사용자 관점 성능은 확실히 개선됐다</strong></p>
<ul>
<li>1편의 MySQL 기반 검색은 최악 구간에서 수십 초 ~ 타임아웃</li>
<li>2편의 ES 기반 검색은 p95/p99가 수십 ms 수준으로 안정적</li>
</ul>
</li>
</ol>
<p><strong>현재 데이터 규모와 검색 요구사항, 그리고 내가 중요하게 본 지표(p95/p99/timeout 안정성)</strong> 기준을 보고 내상황에서는 ES가 더 맞았던 선택인 것 같다.</p>
<hr>
<h2 id="12-다음-개선-포인트">12. 다음 개선 포인트</h2>
<p>아직 해야할 것들이 많이 남았다.</p>
<ul>
<li><code>from/size</code> → <code>search_after</code> 전환 (커서 기반 페이징)</li>
<li>검색 품질 튜닝 (analyzer, ranking)</li>
<li>재색인 자동화/운영 개선</li>
<li>MySQL ↔ ES 동기화 전략 정리 (최종적 일관성 포함)</li>
</ul>
<p>다음 글에서는 <code>search_after</code> 적용 전/후 비교를 정리해보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[검색 기능 개선기 1]]></title>
            <link>https://velog.io/@while-true-study/%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0</link>
            <guid>https://velog.io/@while-true-study/%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0</guid>
            <pubDate>Sun, 15 Feb 2026 14:14:57 GMT</pubDate>
            <description><![CDATA[<h2 id="0-당시-상황">0. 당시 상황</h2>
<p>블로그 서버를 만들고 있었고, 검색 기능도 함께 붙이고 있었다.
데이터가 적을 때는 검색이 잘 됬는데, 게시글을 늘려보니까 문제가 생겼다.</p>
<p>초기에는 DB에서 바로 검색했었다.</p>
<pre><code>SELECT *
FROM post
WHERE title   LIKE CONCAT(&#39;%&#39;, :keyword, &#39;%&#39;)
   OR content LIKE CONCAT(&#39;%&#39;, :keyword, &#39;%&#39;)
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset;</code></pre><p>데이터가 커질수록 검색 응답시간이 급격히 늘어났고, 40초 이상 걸리는 구간도 나왔다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/95d7e290-ade5-4d5c-8966-54c9edfdfd72/image.png" alt=""></p>
<h3 id="0-1-구현-흐름">0-1. 구현 흐름</h3>
<ul>
<li>검색 API: <code>GET /api/v1/search/posts?...</code></li>
<li>검색 방식(초기 방법): MySQL <code>LIKE &#39;%keyword%&#39;</code></li>
<li>정렬/페이징: <code>ORDER BY created_at DESC LIMIT/OFFSET</code></li>
</ul>
<h3 id="0-2-실험-환경">0-2. 실험 환경</h3>
<ul>
<li>앱: Spring Boot + JPA + MySQL8 + Tomcat</li>
<li>커넥션 풀: HikariCP</li>
<li>부하 도구: Locust</li>
<li>관측: access log, actuator metrics, MySQL, Elasticsearch/Kibana</li>
</ul>
<h3 id="0-3-스펙">0-3. 스펙</h3>
<ul>
<li>CPU: 13th Gen Intel(R) Core(TM) i5-13500</li>
<li>DB 분리 여부: 로컬 동일 머신</li>
<li>Hikari 설정 maximumPoolSize: 10</li>
</ul>
<h3 id="0-4-테스트-데이터-규모">0-4. 테스트 데이터 규모</h3>
<p>데이터가 적었을때는 병목이 잘 보이지 않았다. 그래서 데이터 규모를 많이 크게 해서 확인해보았다.</p>
<ul>
<li>post 테이블: 약 990만 ~ 1,100만 건 규모 테스트</li>
<li>검색 요구사항: 부분 포함 검색 (%keyword%)</li>
<li>페이징: offset/limit</li>
</ul>
<p>이 글은 위 조건에서 검색 병목을 관측하고, 여러가지 시도를 해보며 개선을 한 과정을 정리했다.</p>
<h2 id="1-부하-테스트-설계">1. 부하 테스트 설계</h2>
<p>같은 조건에서 검색 방식을 바꿨을 때 숫자로 비교했다.</p>
<h3 id="1-1-테스트-대상">1-1. 테스트 대상</h3>
<p>검색 API (초기: MySQL 검색, 이후: ES 검색 API)
키워드 분포: 한글/영문 혼합
페이징 분포: offset=0, 1<del>200, 201</del>1000, 1001+</p>
<h3 id="1-2-성공실패-기준">1-2. 성공/실패 기준</h3>
<p><strong>Locust</strong>
failure rate(실패율)
p95 / p99 응답시간
RPS 유지 여부
timeout 발생 여부</p>
<p><strong>DB(MySQL)</strong>
EXPLAIN / EXPLAIN ANALYZE 결과
rows examined
filesort 여부
performance_schema / 상태 변수</p>
<p><strong>서버/앱</strong>
access log 응답시간
HikariCP active / idle / pending
Tomcat thread / connection 메트릭</p>
<h2 id="2-원인-분석">2. 원인 분석</h2>
<p><code>LIKE &#39;%keyword%&#39;</code>쿼리를 확인했다.</p>
<h2 id="2-1-왜-like-keyword가-문제일까">2-1. 왜 LIKE &#39;%keyword%&#39;가 문제일까</h2>
<blockquote>
<p>MySQL 공식문서의 B-Tree 인덱스 특성 설명에 따르면, LIKE 비교에서 인덱스는 상수 문자열이며 선행 와일드카드가 없는 경우에 사용할 수 있다. 반대로 LIKE &#39;%Patrick%&#39;처럼 선행 와일드카드가 있는 경우는 인덱스를 사용하지 않는 예시로 제시된다.
관련 문서: <a href="https://dev.mysql.com/doc/refman/8.4/en/index-btree-hash.html">https://dev.mysql.com/doc/refman/8.4/en/index-btree-hash.html</a></p>
</blockquote>
<p>그런데 %keyword%는 앞에 와일드카드가 있어서 시작점을 모른다. 그래서 B-Tree인덱스로는 범위 탐색에 불리하다.</p>
<ul>
<li>인덱스로 빠르게 범위 탐색 못 함</li>
<li>테이블/인덱스를 넓게 훑음</li>
<li>조건 필터링</li>
<li>ORDER BY 정렬</li>
<li>OFFSET 있으면 앞부분을 읽고 버림</li>
</ul>
<p>데이터가 커질수록 선형적으로 비싸지는 구조라고 생각했다.</p>
<h3 id="2-2-explain--explain-analyze">2-2. EXPLAIN / EXPLAIN ANALYZE</h3>
<pre><code class="language-sql">EXPLAIN
SELECT
    p.post_id,
    p.title,
    p.created_at
FROM post p
WHERE p.post_status = &#39;PUBLISHED&#39;
  AND p.deleted_at IS NULL
  AND p.title LIKE CONCAT(&#39;%&#39;, &#39;검색&#39;, &#39;%&#39;)
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 1000;</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/2067e565-71ff-4596-8d77-0c0c24bf344a/image.png" alt=""></p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT p.post_id, p.title, p.created_at
FROM post p
WHERE p.post_status = &#39;PUBLISHED&#39;
  AND p.deleted_at IS NULL
  AND p.title LIKE CONCAT(&#39;%&#39;, &#39;검색&#39;, &#39;%&#39;)
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 1000;</code></pre>
<pre><code>-&gt; Limit: 20 row(s)  (cost=1.21e+6 rows=20) (actual time=6763..6763 rows=20 loops=1)     
-&gt; Sort: p.created_at DESC, p.post_id DESC, limit input to 20 row(s) per chunk  (cost=1.21e+6 rows=10.9e+6) (actual time=6763..6763 rows=20 loops=1)         
-&gt; Filte...</code></pre><p>여기에서 확인한 것은 </p>
<ul>
<li>type=ALL인가?(풀스캔 성향)</li>
<li>key=NULL인가?</li>
<li>Using where, Using filesort가 붙어 있는가?</li>
<li>rows examined가 얼마나 큰가?</li>
<li>actual time이 얼마나 나오는가?</li>
</ul>
<p>확인한 결과 쿼리의 비용이 너무 크다고 생각했다.</p>
<h2 id="3-개선-가설과-실험-계획">3. 개선 가설과 실험 계획</h2>
<h3 id="3-1-비교-후보">3-1. 비교 후보</h3>
<p>infix LIKE (%keyword%)
Prefix LIKE (keyword%)
MySQL Full-Text (기본)
MySQL Full-Text + ngram
Elasticsearch (전용 검색엔진)</p>
<h3 id="3-2-비교-포인트">3-2. 비교 포인트</h3>
<ul>
<li>실제로 검색 속도가 얼마나 빨라지나</li>
<li>한글 검색 퀄리티가 괜찮은가</li>
<li>운영하기 너무 복잡하지는 않나</li>
</ul>
<h2 id="4-infix-like-keyword">4. infix LIKE (%keyword%)</h2>
<p>일단 부분 포함 검색을 하고 싶어서 먼저 <code>infix LIKE</code>를 테스트 해보았다</p>
<h3 id="41-keyword는-왜-위험한가">4.1 %keyword%는 왜 위험한가?</h3>
<blockquote>
<p>MySQL 공식문서의 B-Tree 인덱스 특성 설명에 따르면, LIKE 비교에서 인덱스는 상수 문자열이며 선행 와일드카드가 없는 경우에 사용할 수 있다. 반대로 LIKE &#39;%Patrick%&#39;처럼 선행 와일드카드가 있는 경우는 인덱스를 사용하지 않는 예시로 제시된다.
<a href="https://dev.mysql.com/doc/refman/8.4/en/index-btree-hash.html">https://dev.mysql.com/doc/refman/8.4/en/index-btree-hash.html</a></p>
</blockquote>
<p>나는 LIKE &#39;%keyword%&#39;에 ORDER BY + OFFSET까지 붙으면서 비용이 엄청 커진 상황인것 같다.</p>
<h3 id="42-locust-테스트">4.2 Locust 테스트</h3>
<p>타임 아웃을 90초로 늘려서 테스트 했다. 그 결과 infix 검색에서 60~70초대 응답시간도 확인됐다.</p>
<p><code>GET /search/posts/infix (offset=0)</code></p>
<ul>
<li>평균 61,943ms</li>
<li>p95 / p99 = 68,000ms / 68,000ms</li>
</ul>
<p><code>GET /search/posts/infix (offset=1001+)</code></p>
<ul>
<li>평균 65,939ms</li>
<li>p95 / p99 = 71,000ms / 71,000ms</li>
</ul>
<h2 id="5-prefix-like-keyword">5. Prefix LIKE (keyword%)</h2>
<p>대비 실험으로 prefix 검색도 같은 조건에서 확인했다.</p>
<pre><code class="language-sql">EXPLAIN
SELECT
    p.post_id,
    p.title,
    p.created_at
FROM post p
WHERE p.post_status = &#39;PUBLISHED&#39;
  AND p.deleted_at IS NULL
  AND p.title LIKE CONCAT(&#39;검색&#39;, &#39;%&#39;)
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 1000;</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/d56fc15b-31f8-4fd8-af30-33ad4b0193cb/image.png" alt=""></p>
<h3 id="5-1-prefix가-인덱스-후보가-맞는데-못탔다">5-1. prefix가 인덱스 후보가 맞는데 못탔다.</h3>
<p>MySQL 공식문서 기준으로 B-Tree 인덱스는 LIKE &#39;abc%&#39;처럼 선행 와일드카드가 없는 상수 문자열일 때 인덱스를 사용할 수 있다. </p>
<p>그런데 내 실험에는 아래처럼 나왔다.</p>
<blockquote>
<p>type = ALL
key = NULL
rows = 10,852,380
Extra = Using where; Using filesort</p>
</blockquote>
<p>나는 풀스캔 + filesort가 발생했다.
좀 더 찾아본 결과 아래 이유도 고려해야 한다고 한다.</p>
<ul>
<li>옵티마이저는 조건 조합, 정렬, 선택도, 사용 가능한 인덱스 상태를 보고 계획을 정한다.</li>
<li>MySQL 공식문서도 인덱스가 있어도 옵티마이저가 테이블 스캔을 선택할 수 있다고 설명한다.</li>
</ul>
<h3 id="5-2-explain-analyze로-본-실제-비용-prefix">5-2. EXPLAIN ANALYZE로 본 실제 비용 (prefix)</h3>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT
    p.post_id,
    p.title,
    p.created_at
FROM post p
WHERE p.post_status = &#39;PUBLISHED&#39;
  AND p.deleted_at IS NULL
  AND p.title LIKE CONCAT(&#39;검색&#39;, &#39;%&#39;)
ORDER BY p.created_at DESC, p.post_id DESC
LIMIT 20 OFFSET 0;</code></pre>
<pre><code>-&gt; Limit: 20 row(s)  (cost=1.21e+6 rows=20) (actual time=5339..5339 rows=20 loops=1)     
-&gt; Sort: p.created_at DESC, p.post_id DESC, limit input to 20 row(s) per chunk  (cost=1.21e+6 rows=10.9e+6) (actual time=5339..5339 rows=20 loops=1)         
-&gt; Filte...</code></pre><p>EXPLAIN ANALYZE에서도 prefix 쿼리는 가볍지 않았다.</p>
<p>prefix로 바꿨다고 해도 현재 스키마/실행계획에서는 대용량 스캔 + 정렬 비용이 아직 너무 크다는 것을 확인했다.</p>
<h3 id="5-3-locust-테스트">5-3. Locust 테스트</h3>
<p>Locust 결과에서도 prefix가 기대만큼 빠르지 않았다.
<img src="https://velog.velcdn.com/images/while-true-study/post/9f970477-95e1-415e-8219-49e459cf5786/image.png" alt=""></p>
<p><code>GET /search/posts/prefix (offset=200~1000)</code></p>
<ul>
<li>평균 65,723ms</li>
<li>p95 / p99 = 68,000ms / 68,000ms</li>
</ul>
<h3 id="5-4-prefix-정리">5-4. prefix 정리</h3>
<p>prefix LIKE는 B-Tree 인덱스를 탈 수 있는 형태가 맞다.</p>
<p>근데 내 실험 조건에서는 prefix도 EXPLAIN상 풀스캔/파일정렬로 나왔고, 부하 테스트에서도 병목이 해소되지 않았다.</p>
<p>그래서 prefix는 비교 기준점으로만 해보고, 이후 실험은 Full-Text / ngram / Elasticsearch로 넘어갔다.</p>
<h2 id="6-mysql-full-text-기본">6. MySQL Full-Text (기본)</h2>
<p>LIKE &#39;%keyword%&#39; 병목을 확인한 뒤, 다음으로 시도한 것은 MySQL Full-Text 검색이었다.
목표는 단순했다.</p>
<ul>
<li>풀스캔 성향을 줄일 수 있는지</li>
<li>실제 API 응답시간이 내려가는지</li>
<li>키워드(한글/영문)에 따라 어떤 차이가 나는지</li>
</ul>
<h3 id="6-1-full-text-인덱스-추가">6-1. Full-Text 인덱스 추가</h3>
<pre><code class="language-sql">ALTER TABLE post
ADD FULLTEXT INDEX ft_title_content (title, content);</code></pre>
<h3 id="6-2-match--against-사용해보기">6-2. MATCH ... AGAINST 사용해보기</h3>
<pre><code class="language-sql">SELECT post_id, title
FROM post
WHERE MATCH(title, content)
      AGAINST (:keyword IN NATURAL LANGUAGE MODE)
ORDER BY created_at DESC
LIMIT 20;</code></pre>
<p>초기 가설은 이랬다.</p>
<ul>
<li>LIKE &#39;%keyword%&#39;보다 Full-Text가 유리할 가능성이 크다.</li>
<li>영문/공백 기반 검색에서는 개선될 가능성이 있다.</li>
<li>한글은 토큰화 방식 때문에 품질/성능 편차가 클 수 있다.</li>
</ul>
<h3 id="6-3-수동-호출브라우저-네트워크-탭키워드별-속도">6-3. 수동 호출(브라우저 네트워크 탭)키워드별 속도</h3>
<p>먼저 브라우저에서 키워드를 직접 넣어 호출해보니,키워드에 따라 시간이 크게 달랐다.</p>
<p>spring 검색: 약 13.75초
<img src="https://velog.velcdn.com/images/while-true-study/post/05f07cad-7bb2-41fe-aee0-b610b56c4120/image.png" alt=""></p>
<p>redis 검색: 약 8.98초
<img src="https://velog.velcdn.com/images/while-true-study/post/fb4e2501-e6ee-47f5-a9f7-7fcfa1f61e62/image.png" alt=""></p>
<p>explain 검색: 약 1.20초
<img src="https://velog.velcdn.com/images/while-true-study/post/e8361369-dbf6-43b3-aae1-6749c5bfb472/image.png" alt=""></p>
<p>query 검색: 약 1.84초
<img src="https://velog.velcdn.com/images/while-true-study/post/1cce9517-6dd7-49da-9135-814d1889a49d/image.png" alt=""></p>
<p>Full-Text를 붙였다고 해서 일괄적으로 빨라지는 게 아니었다.
키워드에 따라 결과 건수(선택도), 토큰화 방식, 정렬 비용이 달라지면서 응답시간 편차가 나오는 것 같았다.</p>
<h3 id="6-4-locust-부하-테스트-결과-full-text--natural">6-4. Locust 부하 테스트 결과 (Full-Text / Natural)</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/688e61dd-c143-44b8-81f4-a62e0dfd78b5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/77861903-830e-44f7-b6fd-73c3999ba009/image.png" alt=""></p>
<p>Locust로 같은 엔드포인트를 부하 테스트했을 때도 비슷한 패턴이 나왔다.
응답시간 분포가 넓었다. 키워드에 따라 편차가 있었다.</p>
<h4 id="6-4-1-초기-locust-스크립트의-posts-키워드-풀-요청도-매우-느렸다">6-4-1. 초기 Locust 스크립트의 /posts 키워드 풀 요청도 매우 느렸다.</h4>
<p>초기 테스트 스크립트에는 키워드 풀 갱신용 /posts 요청이 포함되어 있었는데, 이 요청 자체도 느려서 aggregate 수치를 제대로 나오지 않게 할 수 있던 것 같다.
그래서 이후로는 합계만 보지 않고, 검색 엔드포인트 row를 따로 보는 방식으로 정리해야겠다고 생각했다.</p>
<h4 id="6-4-2-full-text-검색-엔드포인트-자체도-여전히-느리다">6-4-2. Full-Text 검색 엔드포인트 자체도 여전히 느리다.</h4>
<p>동일 표에서 Full-Text 검색 API는 아래 수준이었다</p>
<ul>
<li><code>GET /search/posts/fulltext (offset=0)</code><ul>
<li>평균 11,165.51ms</li>
<li>p95 25,000ms</li>
<li>p99 25,000ms</li>
</ul>
</li>
<li><code>GET /search/posts/fulltext (offset=1~200)</code><ul>
<li>평균 12,805.45ms</li>
<li>p95 25,000ms</li>
<li>p99 25,000ms</li>
</ul>
</li>
<li><code>GET /search/posts/fulltext (offset=201~1000)</code><ul>
<li>평균 14,099.95ms</li>
<li>p95 26,000ms</li>
<li>p99 26,000ms</li>
</ul>
</li>
<li><code>GET /search/posts/fulltext (offset=1001+)</code><ul>
<li>평균 13,082.16ms</li>
<li>p95 26,000ms</li>
<li>p99 27,000ms</li>
</ul>
</li>
</ul>
<p>LIKE &#39;%keyword%&#39;보다 개선된 구간이 있더라도, 서비스 응답시간 관점에서 봤을 때 여전히 수 초~수십 초가 나오고 있었다. 내 목표에서는 아직 충분하지 않았다고 생각했다.</p>
<h3 id="6-5-full-text-기본-파서에서-확인한-한계">6-5. Full-Text 기본 파서에서 확인한 한계</h3>
<p>이 실험에서 확인한 건 단순히 느리다/빠르다가 아니었다.</p>
<ul>
<li>키워드별 편차가 매우 큼 (query는 몇초, spring/redis는 수십초까지)</li>
<li><code>ORDER BY created_at DESC + LIMIT/OFFSET</code>가 붙으면서 정렬 비용이 계속 커짐</li>
<li>한글/짧은 키워드는 검색 품질과 성능이 모두 불안정할 수 있음. 어떤 단어는 아예 안나오기도 하고 너무 느리기도 했다.</li>
</ul>
<h2 id="7-mysql-full-text--ngram">7. MySQL Full-Text + ngram</h2>
<p>기본 Full-Text의 다음 후보는 ngram 파서였다.
한글 검색에서 찾아보니 많이 보여서 직접 해보고 싶었다.</p>
<ul>
<li>기본 파서: 단어/공백 기반에 가까움</li>
<li>ngram 파서: 문자열을 n-그램(예: 2글자 단위)으로 쪼개서 색인</li>
</ul>
<p>한글 부분 검색 기대치에는 더 맞을 수 있다. 대신 인덱스/성능 비용이 늘어날 수 있다고 한다.</p>
<h3 id="7-1-ngram-인덱스-생성">7-1. ngram 인덱스 생성</h3>
<pre><code class="language-sql">ALTER TABLE post
ADD FULLTEXT INDEX ft_title_content_ngram (title, content) WITH PARSER ngram;</code></pre>
<h3 id="7-2-동일-쿼리로-비교">7-2. 동일 쿼리로 비교</h3>
<pre><code class="language-sql">SELECT post_id, title
FROM post
WHERE MATCH(title, content)
      AGAINST (:keyword IN NATURAL LANGUAGE MODE)
LIMIT 20;</code></pre>
<h3 id="7-3-브라우저에서-본-ngramboolean-편차">7-3. 브라우저에서 본 ngram/boolean 편차</h3>
<p>브라우저에서 FULL-TEXT-BOOLEAN 테스트를 해보니, 한글 키워드에서도 결과가 나오긴 하지만 편차가 매우 컸다. 어떤 키워드는 잘나오기도 했지만 아예 검색이 안되기도 하고, ngram없을때보다 더 느리게 나오기도 했다.</p>
<p>테스, 테 계열: 약 2.47초 ~ 3.45초
<img src="https://velog.velcdn.com/images/while-true-study/post/1d417fb4-be20-413f-ba74-a026b8dddabc/image.png" alt=""></p>
<p>query: 약 12.33초
<img src="https://velog.velcdn.com/images/while-true-study/post/dc692072-c6c4-49f3-a409-8e54dd25c0b0/image.png" alt=""></p>
<p>추적: 약 2.40초
<img src="https://velog.velcdn.com/images/while-true-study/post/6e49cecb-4573-4aa0-9698-7e4804068a7e/image.png" alt=""></p>
<p>쿼리: 약 55.68초
<img src="https://velog.velcdn.com/images/while-true-study/post/eed0abb7-3d30-4e75-b4e1-7cd19c5b60e1/image.png" alt=""></p>
<p>같은 모드에서도 키워드 선택도와 토큰 분해 결과에 따라 시간 편차가 매우 큰 걸 확인할 수 있었다.</p>
<h3 id="7-4-locust-결과-full-text--ngram-키워드">7-4. Locust 결과 (Full-Text + ngram 키워드)</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/370e2214-bd34-4b47-bffb-65ed84ba8bd5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/a363e3e9-147a-49ca-aab5-5c845966d791/image.png" alt=""></p>
<p>Locust에서도 NGRAM_KEYWORDS (한글 + 영어 키워드)로 분리해서 테스트했는데, 응답시간 분포가 더 거칠게 나왔다.</p>
<ul>
<li>offset=0<ul>
<li>평균 46,122.36ms</li>
<li>p95/p99 90,000ms / 90,000ms</li>
<li>최대 90,001ms</li>
</ul>
</li>
<li>offset=1~200<ul>
<li>평균 40,644.15ms</li>
<li>p95/p99 73,000ms / 73,000ms</li>
</ul>
</li>
<li>offset=201~1000<ul>
<li>평균 29,365.20ms</li>
<li>p95/p99 48,000ms / 48,000ms</li>
</ul>
</li>
<li>offset=1001+<ul>
<li>평균 31,644.53ms</li>
<li>p95/p99 45,000ms / 45,000ms</li>
</ul>
</li>
</ul>
<p>Locust 설정의 read timeout(90초)로 설정을 해놨기 때문에 90초동안 못받고 실패한 것이다.</p>
<h3 id="7-5-boolean-모드">7-5. Boolean 모드</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/c7b78a9a-76a6-4b8b-b5a4-3e78ea47b1cf/image.png" alt=""></p>
<p>Boolean 모드도 같이 테스트했지만, 키워드/offset에 따라 편차가 컸다.</p>
<ul>
<li><p><code>GET /search/posts/fulltext-boolean (offset=0)</code></p>
<ul>
<li>평균 52,375.67ms</li>
<li>p95/p99 90,000ms / 90,000ms</li>
</ul>
</li>
<li><p><code>GET /search/posts/fulltext-boolean (offset=1~200)</code></p>
<ul>
<li>평균 52,556.51ms</li>
<li>p95/p99 90,000ms / 90,000ms</li>
</ul>
</li>
</ul>
<p>오히려 키워드와 데이터 분포에 따라 극단적인 지연/타임아웃이 계속 발생했다.</p>
<h3 id="7-6-ngram-정리">7-6. ngram 정리</h3>
<p>이번 실험에서 내 환경 기준으로 확인한 점은 다음과 같다.</p>
<ol>
<li>기본 Full-Text에서 잘 안 맞는 부분 검색 기대치를 맞출 가능성이 있다.</li>
<li>짧은 한글 키워드는 후보가 많아질 수 있고 ORDER BY + OFFSET 비용과 겹치면 응답시간이 급격히 느려졌다.</li>
<li>키워드 편차가 상당히 크다.</li>
</ol>
<p>ngram은 검색 품질 개선에는 유효할 가능성이 있었지만, 내 조건에서는 성능 안정성이 생각보다 나오지 않았다.
그래서 다음 단계로 Elasticsearch를 붙여 비교하게 되었다.</p>
<h2 id="8-정리">8. 정리</h2>
<h3 id="8-1-증상">8-1. 증상</h3>
<ul>
<li>LIKE &#39;%keyword%&#39; 검색에서 응답시간이 급격히 증가했다</li>
<li>ORDER BY created_at DESC + LIMIT/OFFSET 조합으로 정렬/페이징 비용이 커졌다</li>
<li>Locust에서 p95/p99가 빠르게 상승했고, 일부 구간은 타임아웃 상한에 걸렸다</li>
<li>prefix LIKE(keyword%)도 이론상 유리할 수 있지만, 내 실험 조건에서는 EXPLAIN상 풀스캔(type=ALL, key=NULL)이 나왔다</li>
<li>MySQL Full-Text / ngram도 적용해봤지만, 키워드/모드/offset에 따라 응답시간 편차가 매우 컸다 (+ 그래도 느렸다)</li>
</ul>
<h3 id="8-2-이번-글-결론">8-2. 이번 글 결론</h3>
<p>이번 실험에서 확인한 핵심은 아래였다.</p>
<ul>
<li>검색 병목의 출발점은 단순히 DB가 느리다가 아니라
LIKE &#39;%keyword%&#39; + 정렬 + OFFSET 의 비용이 상당히 컸다.</li>
<li>MySQL Full-Text와 ngram은 개선 가능성은 확인했지만, 나는 성능 안정성과 검색 품질이 좀 더 필요하다고 생각했었다.</li>
<li>데이터 규모와 요구사항에 맞는 검색 전략을 잘 선택해야 하겠다고 생각이 들었다. </li>
</ul>
<h3 id="8-3-다음-글">8-3. 다음 글</h3>
<p>그래서 다음 글에서는 Elasticsearch를 실제로 도입한 과정, DB데이터를 ES에 적재한 경험, 테스트 결과를 정리하려고 한다.</p>
<ul>
<li>대량 적재 중 겪었던 문제들 (OFFSET 병목, document_id 덮어쓰기, 필드명 mismatch)</li>
<li>MySQL → Logstash → Elasticsearch 재색인 과정</li>
<li>Spring 검색 API 연동 시 발생한 매핑/날짜 파싱 이슈</li>
<li>Locust로 확인한 ES 검색 성능 결과</li>
</ul>
<p>개선 과정은 다음 글에 정리하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자동 배포(ECR/ECS) 구축하면서 터진 오류 유형과 해결 과정]]></title>
            <link>https://velog.io/@while-true-study/%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%ACECRECS-%EA%B5%AC%EC%B6%95%ED%95%98%EB%A9%B4%EC%84%9C-%ED%84%B0%EC%A7%84-%EC%98%A4%EB%A5%98-%EC%9C%A0%ED%98%95%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@while-true-study/%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%ACECRECS-%EA%B5%AC%EC%B6%95%ED%95%98%EB%A9%B4%EC%84%9C-%ED%84%B0%EC%A7%84-%EC%98%A4%EB%A5%98-%EC%9C%A0%ED%98%95%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Thu, 12 Feb 2026 14:02:04 GMT</pubDate>
            <description><![CDATA[<p>지난 글에서는 git tag 푸시로 GitHub Actions =&gt; ECR push =&gt; ECS 롤링 배포가 되는 CI/CD 파이프라인을 구성했다.
이번 글에서는 구축 과정에서 실제로 마주쳤던 오류들을 증상 =&gt; 원인 =&gt; 확인 =&gt; 해결 순서로 정리했다.</p>
<p>같은 에러를 다시 만났을 때 해결 방법을 바로 떠올릴 수 있도록, 내가 확인했던 포인트까지 함께 정리했다.</p>
<h2 id="0-내-배포-구성">0. 내 배포 구성</h2>
<ul>
<li><strong>Trigger</strong>: <code>git tag v0.0.1 &amp;&amp; git push --tags</code></li>
<li><strong>CI</strong>: GitHub Actions (OIDC)</li>
<li><strong>Image</strong>: Amazon ECR</li>
<li><strong>Runtime</strong>: Amazon ECS + Service 롤링 배포</li>
<li><strong>Secret</strong>: AWS Secrets Manager → Task Definition secrets.valueFrom 주입</li>
<li><strong>Log</strong>: CloudWatch Logs (awslogs)</li>
</ul>
<h2 id="1-내가-확인한-정보들">1. 내가 확인한 정보들</h2>
<p>나는 에러가 났을때 아래 정보들을 확인했다.</p>
<ol>
<li>GitHub Actions 로그: OIDC/권한, ECR push, ECS deploy 단계</li>
<li>ECS Service 이벤트: 배포 실패 이유</li>
<li>ECS Task 목록/상세: STOPPED 이유, exit code</li>
<li>CloudWatch Logs: 스프링 부트 로그</li>
</ol>
<h2 id="2-클러스터-생성-실패-unable-to-assume-the-service-linked-role">2. 클러스터 생성 실패: Unable to assume the service linked role</h2>
<p>클러스터 생성 중 아래 오류가 발생했다.</p>
<blockquote>
<p>CreateCluster Invalid Request: Unable to assume the service linked role. Please verify that the ECS service linked role exists.</p>
</blockquote>
<p><code>에러 로그</code></p>
<pre><code class="language-bash">클러스터 service-cluster 생성 중 오류가 발생했습니다. 
Resource handler returned message: &quot;Invalid request provided: CreateCluster Invalid Request: Unable to assume the service linked role.
Please verify that the ECS service linked role exists. (Service: AmazonECS; Status Code: 400; Error Code: InvalidParameterException;
Request ID: c2aa1ff6-cda9-4955-84ae-ad63fb144861; Proxy: null)&quot; (RequestToken: 3747083a-bed9-1901-3746-7539ec34229c, HandlerErrorCode: InvalidRequest)</code></pre>
<p>ECS가 내부적으로 사용하는 Service linked role이 계정에 없거나(삭제), ECS가 그 Role을 Assume 할 수 없는 상태다.</p>
<p><code>원인</code></p>
<ul>
<li>현재 계정/권한 정책이 iam:CreateServiceLinkedRole 같은 작업을 막음</li>
<li>드물게 IAM 설정이 꼬여 Role이 있으나 ECS가 정상 assume 실패</li>
</ul>
<p><code>확인 방법</code>
IAM 콘솔 =&gt; Roles(역할)에서 아래 이름을 검색한다. AWSServiceRoleForECS
가 있는지 찾아본다.</p>
<p><code>해결 방법</code>
Service linked role을 생성한다.
생성후에 클러스터 생성을 다시 시도하면 해결된다.</p>
<h2 id="3-github-actions-배포-실패-error-service-is-inactive">3. GitHub Actions 배포 실패: Error: Service is INACTIVE</h2>
<p>GitHub Actions의 amazon-ecs-deploy-task-definition 단계에서 아래 에러가 났다.</p>
<blockquote>
<p>Error: Service is INACTIVE</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/a1c9e5bd-80fc-4076-8678-31488d47ea91/image.png" alt=""></p>
<p><code>의미</code></p>
<p>워크플로우가 가리키는 ECS 서비스가 존재는 하되 INACTIVE 상태라는 뜻이다.</p>
<p><code>원인</code></p>
<ul>
<li>서비스가 삭제되었거나</li>
<li>생성이 실패해서 활성화되지 못했거나</li>
<li>다른 이름/다른 클러스터를 가르키는데 변수만 남아있는 경우</li>
</ul>
<p><code>확인 방법</code></p>
<ul>
<li>ECS 콘솔 → Cluster → Services에서 weblog-service가 ACTIVE인지 확인</li>
<li>GitHub Variables의 ECS_CLUSTER, ECS_SERVICE가 콘솔 이름과 일치하는지 확인</li>
</ul>
<p>나는 github variables에 이름 설정이 동일하지 않아 오류가 났었다.</p>
<p><code>해결</code></p>
<ul>
<li>서비스가 없거나 비활성(INACTIVE)이면 서비스를 정상 생성한다.</li>
<li>서비스 이름이 바뀌었다면 GitHub Variables의 ECS_SERVICE를 최신으로 수정한다.</li>
</ul>
<h2 id="4-ecs-배포-실패-ecs-deployment-circuit-breaker-was-triggered">4. ECS 배포 실패: ECS Deployment Circuit Breaker was triggered</h2>
<p>ECS 쪽 이벤트에서 아래 메시지가 보였다.</p>
<blockquote>
<p>ECS Deployment Circuit Breaker was triggered</p>
</blockquote>
<p>이 메시지는 ECS가 배포를 포기했다는 뜻이다. 원인은 task에 있다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/d46ac3e9-7689-43a9-b87f-7c438071cecc/image.png" alt=""></p>
<p><code>의미</code></p>
<p>배포된 새 태스크들이 정상 상태에 도달하지 못해서 ECS가 자동으로 배포를 중단한 것이다.</p>
<p><code>원인</code></p>
<ul>
<li>태스크가 뜨자마자 죽는다 (컨테이너 exit)</li>
<li>헬스체크가 계속 실패한다 (ALB/컨테이너 헬스체크)</li>
<li>초기화 단계에서 막힌다 (Secrets/Logs/Pull 등 ResourceInitializationError)</li>
</ul>
<p><code>해결</code></p>
<ol>
<li>ECS → Service → Tasks 탭에서 STOPPED가 있는지 확인</li>
<li>STOPPED 태스크 클릭 → Stopped reason / Container exit code 확인</li>
<li>Container → CloudWatch logs 열기</li>
<li>로그에서 원인을 확인하고 수정하기</li>
</ol>
<h2 id="5-태스크가-계속-중지됨-essential-container-in-task-exited--exit-code-1">5. 태스크가 계속 중지됨: Essential container in task exited + Exit code 1</h2>
<p>Tasks 목록에서 반복적으로 아래 상태가 쌓였다.</p>
<blockquote>
<p>Essential container in task exited</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/b191bc09-d963-4815-a768-75adb3a3500c/image.png" alt=""></p>
<p>그리고 Task 상세에서 컨테이너가:</p>
<blockquote>
<p>Stopped | 종료 코드: 1</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/9dd652e9-77c3-4369-ade7-d64ce3b729ff/image.png" alt=""></p>
<p><code>의미</code>
스프링 부트 컨테이너가 실행 직후 에러로 종료된 것이다.
서비스는 태스크를 유지해야 하므로 재기동을 반복하다가 배포가 실패한다.</p>
<p><code>원인</code></p>
<ul>
<li>DB 연결 실패 (보안그룹/URL/계정/비번)</li>
<li>환경변수 누락 (SPRING_DATASOURCE_URL, USERNAME 등)</li>
<li>Secrets Manager 주입 실패로 필수 값이 비어있음</li>
<li>포트 불일치(앱이 8080인데 설정은 다른 포트 설정)</li>
</ul>
<p><code>확인 방법</code>
CloudWatch Logs에서 스프링이 왜 죽었는지를 보거나 태스크가 중단되었을때 태스크 로그에서 스프링 로그를 확인해보면 된다.</p>
<p>나의 경우에는 secret manager에서 ARN 키 지정을 잘못 사용해서 DB 연결이 실패해 저 오류가 났었다.</p>
<p><code>해결</code></p>
<ul>
<li>RDS 보안그룹 인바운드에 ECS 태스크 보안그룹을 Source로 허용했는지</li>
<li>Task Definition의 DB URL/USERNAME이 정확한지</li>
<li>비밀번호는 environment가 아니라 secrets로 들어가며 ARN 포맷이 맞는지</li>
<li>포트 설정이 8080 기준으로 일치하는지</li>
</ul>
<h2 id="6-초기화-단계에서-막힘-resourceinitializationerror-failed-to-validate-">6. 초기화 단계에서 막힘: ResourceInitializationError: failed to validate ...</h2>
<p>Tasks 목록에 아래처럼 뜨고 바로 STOPPED 됐다.</p>
<blockquote>
<p>ResourceInitializationError: failed to validate ...</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/fc5cc406-4779-412b-9823-a5bdc250e565/image.png" alt=""></p>
<p><code>원인</code></p>
<ul>
<li>Secrets 주입 단계 실패</li>
<li>CloudWatch Logs 설정 문제</li>
<li>Execution Role의 ECR pull 권한 부족</li>
</ul>
<p><code>확인 방법</code>
STOPPED task를 클릭해 상세 메시지에서 secrets, logs, pull 키워드를 확인한다.</p>
<p>나의 경우는 task-definition에 awslog-group이름과 실제 log group 이름이 달라서 오류가 났었다.</p>
<p><code>해결 방향</code></p>
<ul>
<li>secrets 관련: Execution Role에 secretsmanager:GetSecretValue 추가, ARN/키 지정(:password::) 재확인</li>
<li>logs 관련: Log Group 존재 여부, Execution Role의 logs 권한 및 region/이름 일치 확인</li>
<li>pull 관련: ECR 권한 및 네트워크 점검</li>
</ul>
<h2 id="7-결론">7. 결론</h2>
<p>특히 권한이나 리소스 이름(서비스/클러스터/로그 그룹) 같은 기초 설정이 아주 조금만 달라도 배포가 전혀 안됬다.
나의 경우 문제가 생겼을 때는 바로 STOPPED task와 CloudWatch logs를 먼저 확인하는 것이 가장 빠르게 해결이 됬다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ECS + ECR + GitHub Actions로 Spring Boot 배포 자동화(CI/CD) 구축하기]]></title>
            <link>https://velog.io/@while-true-study/ECS-ECR-GitHub-Actions%EB%A1%9C-Spring-Boot-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94CICD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@while-true-study/ECS-ECR-GitHub-Actions%EB%A1%9C-Spring-Boot-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94CICD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 09 Feb 2026 21:01:36 GMT</pubDate>
            <description><![CDATA[<h2 id="0-목표">0. 목표</h2>
<h3 id="01-요약">0.1 요약</h3>
<ol>
<li>먼저 ECR에 리포지토리 만들고 docker 빌드 파일 업로드하기</li>
<li>ECS에서 클러스터를 하나 만들고 태스크 정의를 ECR에서 만든 docker로 설정하기</li>
<li><code>GitHub Actions Workflow</code>로 배포 설정하기</li>
</ol>
<h3 id="02-목표">0.2 목표</h3>
<p>로컬에서 <code>docker-compose up</code>으로 돌리던 Spring Boot 서버를, CI/CD 자동화 구축을 하게 만들어 보고자 했다.</p>
<pre><code>git tag v0.0.1
git push --tags</code></pre><p>나는 이런식으로 태그를 붙여서 push하면 자동으로 배포까지 되는 것을 원했다.</p>
<ol>
<li><code>GitHub Actions</code>이 Docker 이미지를 빌드해서 <code>Amazon ECR</code>에 push한다.</li>
<li><code>Amazon ECS</code>가 새 이미지를 가져와서 배포한다.</li>
<li>RDS(MySQL) 비밀번호와 JWT 시크릿은 AWS Secrets Manager로 관리하고, 태스크 실행 시 주입한다.</li>
</ol>
<h2 id="1-아키텍처">1. 아키텍처</h2>
<p>아래 사진은 CI/CD 흐름도이다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/c70ed98d-86ae-4100-b4be-cee4774035b2/image.png" alt=""></p>
<p><code>Secrets Manager</code>는 태스크가 시작될 때 ECS가 값을 조회해 컨테이너 환경변수로 주입한다.
<code>CloudWatch Logs</code>는 컨테이너 표준 출력 로그를 전송해 확인한다.</p>
<h2 id="2-준비">2. 준비</h2>
<h3 id="21-애플리케이션-포트환경변수">2.1 애플리케이션 포트/환경변수</h3>
<p>Spring Boot 컨테이너는 8080을 포트로 썼다.
<img src="https://velog.velcdn.com/images/while-true-study/post/9bfeb92b-c2a4-46a3-ac38-7275136077fd/image.png" alt=""></p>
<p>DB 정보는 ECS Task 환경변수로 주입한다.</p>
<p><code>SPRING_DATASOURCE_URL</code>
<code>SPRING_DATASOURCE_USERNAME</code>
<code>SPRING_DATASOURCE_PASSWORD (Secrets manager로 넣기)</code>
<img src="https://velog.velcdn.com/images/while-true-study/post/a4c027f6-3df4-4422-be80-302d84f569b2/image.png" alt=""></p>
<h2 id="3-ecr-리포지토리-만들기">3. ECR 리포지토리 만들기</h2>
<p><code>ECR</code>에서 리포지토리를 생성한다.
<img src="https://velog.velcdn.com/images/while-true-study/post/00b6af67-0125-4f52-9c37-854712f1e28b/image.png" alt=""></p>
<p>이 리포지토리에 GitHub Actions가 빌드한 Docker이미지를 푸시한다.</p>
<h2 id="4-rdsmysql">4. RDS(MySQL)</h2>
<p>서버에서 사용할 RDS의 URL이나 비밀번호 같은것을 정리해서 </p>
<h3 id="41-rds-정보">4.1 RDS 정보</h3>
<p>ECS 태스크는 스프링 부트 컨테이너를 실행하고, 그 컨테이너는 DB에 접속하기 때문에 DB정보를 태스크에 넣어줘야 한다. 사용하는 RDS의 URL, password, username등을 잘 알아두어야한다.</p>
<ul>
<li><code>SPRING_DATASOURCE_PASSWORD</code></li>
<li><code>SPRING_DATASOURCE_URL</code></li>
<li><code>SPRING_DATASOURCE_USERNAME</code></li>
</ul>
<h3 id="42-보안그룹security-group">4.2 보안그룹(Security Group)</h3>
<p>RDS 인바운드 규칙을 설정해줘야한다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/6d3a3e8c-dc8d-45b0-af32-3e24ab5a468c/image.png" alt=""></p>
<p>Type: <code>MySQL/Aurora (3306)</code>
Source: <code>CS Task가 사용하는 보안그룹</code>
이런식으로 ECS 보안 그룹을 허용해주면 된다. 그러면 ECS에서만 DB접근이 가능하게된다.</p>
<p>ECS서비스를 만들 때 어떤 보안그룹을 붙였는지 확인하고 그 보안그룹을 RDS 인바운드에 넣어야한다.</p>
<h2 id="5-secrets-manager-만들기">5. Secrets Manager 만들기</h2>
<h3 id="51-secret-생성-타입">5.1 Secret 생성 타입</h3>
<p><code>Amazon RDS</code> 데이터베이스 자격 증명 타입으로 만들면 편하다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/0d811b9c-2928-44ea-b93e-4b8bb3604e04/image.png" alt=""></p>
<p>새 보안 암호 저장을 누르고 DB에서 사용하는 비번같은 비밀정보를 넣어서 사용하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/8e90ad86-f6a4-4e07-996e-2ab40c5c8f15/image.png" alt=""></p>
<p>레포지토리가 public이기때문에, 비밀번호/JWT 시크릿을 코드나 GitHub 저장소에 직접 적지 않는다.</p>
<h3 id="52-ecs-task-definition에서-valuefrom으로-주입하기">5.2 ECS Task Definition에서 valueFrom으로 주입하기</h3>
<p>secrets사용법에서 조금 헷갈렸는데 보안 암호 세부 정보에서 보안암호 ARN 정의에서 secret 사용은 이렇게 했다.</p>
<p>ECS 태스크 정의에서 secrets에 valueFrom으로 ARN을 넣는다.
Secrets Manager ARN은 <code>:키이름::</code> 형태로 특정 키를 지정할 수 있다.</p>
<p><code>ex) arn:~~~~:secret:service_name:root_password::</code></p>
<pre><code class="language-json">&quot;secrets&quot;: [
  {
    &quot;name&quot;: &quot;SPRING_DATASOURCE_PASSWORD&quot;,
    &quot;valueFrom&quot;: &quot;arn:aws:secretsmanager:ap-northeast-2:&lt;ACCOUNT_ID&gt;:secret:XXXXXX&quot;
  }
]</code></pre>
<h2 id="6-cloudwatch-logs">6. CloudWatch Logs</h2>
<p>log를 남겨서 보고 싶어서 cloudWatch로 로그를 남겨놓도록 설정해놨다. 이부분은 사실 하지 않아도 된다.</p>
<p>ECS 컨테이너 로그를 CloudWatch로 보내려면 CloudWatch Log Group을 미리 만들어야한다.</p>
<p>Task Definition 로그 설정에 awslogs를 넣고 CloudWatch에서 같은 이름의 로그 그룹을 만들어 주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/0e92e569-69c9-4722-ac45-6d4d0eb982ba/image.png" alt=""></p>
<p>예: /ecs/service/log 로 로그 그룹을 만들었다면</p>
<pre><code class="language-yml">&quot;logConfiguration&quot;: {
  &quot;logDriver&quot;: &quot;awslogs&quot;,
  &quot;options&quot;: {
    &quot;awslogs-group&quot;: &quot;/ecs/service/log&quot;,
    &quot;awslogs-region&quot;: &quot;ap-northeast-2&quot;,
    &quot;awslogs-stream-prefix&quot;: &quot;ecs&quot;,
    &quot;awslogs-create-group&quot;: &quot;true&quot;
  }
}</code></pre>
<h2 id="7-iam-권한-구성">7. IAM 권한 구성</h2>
<p>권한은 두 종류로 나눠서 만든다.</p>
<h3 id="71-ecs-task-execution-role">7.1 ECS Task Execution Role</h3>
<p>아래 사진을 보면 <code>weblog-ecs-secrets-read</code>이라고 커스텀 권한이 있는데 Secrets Manager read write 아님 GetSecretValue 같은 것을 넣어줘도 될 것이다. ecs는 배포하고 실행까지 하기 때문에 RDS에 접근해야한다. 그래서 RDS비밀번호가 있는 Secrets Manager 권한이 필요하다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/1e6c0253-d6cb-47e1-984e-718fd090fdc3/image.png" alt=""></p>
<h3 id="72-github-actions-배포-role-oidc">7.2 GitHub Actions 배포 Role (OIDC)</h3>
<p>GitHub Actions가 AWS에 배포를 하려면 OIDC Role이 필요하다. 나는 Access Key를 저장하지 않고, OIDC로 임시 자격 증명을 발급받는 방법을 사용했다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/73f5ac4a-6069-4c88-978c-077e80c65e80/image.png" alt=""></p>
<h2 id="8-ecs-클러스터-생성">8. ECS 클러스터 생성</h2>
<p>쉽게 말해보면 ECS는 우리가 사용하는 컨테이너(Docker 같은 것)를 관리하기 위한 도구이다. 컨테이너를 쉽게 실행, 중지하는 등의 관리가 가능하다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/a725a35e-6682-4ac5-8b32-61c458fd31e2/image.png" alt=""></p>
<h2 id="9-task-definition-작성">9. Task Definition 작성</h2>
<p>나는 레포에 .aws/ecs/task-definition.json로 고정을 해두었다.</p>
<h3 id="91-레포-구조">9.1 레포 구조</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/0cb41ab3-0d35-407d-8921-772bbf7e1e84/image.png" alt=""></p>
<h3 id="92-task-definition">9.2 Task Definition</h3>
<p>내가 사용하는 Def구조이다. <a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html">여기</a>를 참고하면 더 자세히 볼 수 있다.</p>
<pre><code>{
  &quot;family&quot;: &quot;여기에 task이름&quot;,
  &quot;networkMode&quot;: &quot;awsvpc&quot;,
  &quot;requiresCompatibilities&quot;: [&quot;FARGATE&quot;],
  &quot;cpu&quot;: &quot;512&quot;,
  &quot;memory&quot;: &quot;1024&quot;,
  &quot;executionRoleArn&quot;: &quot;arn:aws:iam::&lt;AWS_ACCOUNT_ID&gt;:role/ecsTaskExecutionRole 여기에 역할 ARN넣기&quot;,
  &quot;taskRoleArn&quot;: &quot;arn:aws:iam::&lt;AWS_ACCOUNT_ID&gt;:role/ecsTaskExecutionRole 여기도&quot;,
  &quot;containerDefinitions&quot;: [
    {
      &quot;name&quot;: &quot;태스크 안 컨테이너 이름&quot;,
      &quot;image&quot;: &quot;&lt;AWS_ACCOUNT_ID&gt;.dkr.ecr.ap-northeast-2.amazonaws.com/service:latest&quot;,
      &quot;essential&quot;: true,
      &quot;portMappings&quot;: [{ &quot;containerPort&quot;: 8080, &quot;protocol&quot;: &quot;tcp&quot; }],
      &quot;environment&quot;: [
        { &quot;name&quot;: &quot;SPRING_DATASOURCE_URL&quot;, &quot;value&quot;: &quot;jdbc:mysql://&lt;RDS_ENDPOINT&gt;:3306/serviceDB?serverTimezone=Asia/Seoul&amp;characterEncoding=UTF-8&amp;sslMode=REQUIRED&quot; RDS URL넣기},
        { &quot;name&quot;: &quot;SPRING_DATASOURCE_USERNAME&quot;, &quot;value&quot;: &quot;service_root&quot; RDS에서 사용하는 이름}
      ],
      &quot;secrets&quot;: [
        { &quot;name&quot;: &quot;SPRING_DATASOURCE_PASSWORD&quot;, &quot;valueFrom&quot;: &quot;arn:aws:secretsmanager:ap-northeast-2:&lt;AWS_ACCOUNT_ID&gt;:secret:service-db-password-XXXXXX:password::&quot; 여기는 secretmanager에 비번 ARN}
      ],
      &quot;logConfiguration&quot;: { CloudWatch를 안쓰면 없애야함
        &quot;logDriver&quot;: &quot;awslogs&quot;,
        &quot;options&quot;: {
          &quot;awslogs-group&quot;: &quot;/ecs/weblog&quot;,
          &quot;awslogs-region&quot;: &quot;ap-northeast-2&quot;,
          &quot;awslogs-stream-prefix&quot;: &quot;ecs&quot;,
          &quot;awslogs-create-group&quot;: &quot;true&quot;
        }
      }
    }
  ]
}</code></pre><p>DB 비번은 environment가 아니라 secrets을 썼다.
로그를 설정하면 CloudWatch log group이랑 IAM권한도 맞춰줘야 한다.</p>
<h2 id="10-ecs-서비스-생성">10. ECS 서비스 생성</h2>
<p>클러스터 위에 서비스를 만들면, 서비스는 태스크를 항상 N개 유지하고, 새 task definition이 올라오면 롤링 배포를 한다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/047a87d6-b8df-4f05-a568-5567cb31cbcd/image.png" alt=""></p>
<h2 id="11-github-actions-workflow">11. GitHub Actions Workflow</h2>
<h3 id="111-레포-변수시크릿">11.1 레포 변수/시크릿</h3>
<p>나는 Github에있는 레포 변수값을 사용했다 위치는 아래 사진과 같다.
<img src="https://velog.velcdn.com/images/while-true-study/post/ecd18a60-4e38-4a91-bbbd-fce02fb09d3a/image.png" alt=""></p>
<p><code>Variables</code></p>
<ul>
<li>AWS_REGION</li>
<li>ECR_REPOSITORY</li>
<li>ECS_CLUSTER</li>
<li>ECS_SERVICE</li>
<li>ECS_TASK_DEFINITION</li>
<li>CONTAINER_NAME</li>
</ul>
<p><code>Secrets</code></p>
<ul>
<li>AWS_ROLE_TO_ASSUME (OIDC role arn)</li>
</ul>
<h3 id="112-워크플로우">11.2 워크플로우</h3>
<p>내가 사용한 워크플로우는 아래와 같다.</p>
<pre><code class="language-yml">name: Deploy to ECS

on:
  push:
    tags: [&quot;v*&quot;]
  workflow_dispatch:

env:
  AWS_REGION: ${{ vars.AWS_REGION }}
  ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
  ECS_CLUSTER: ${{ vars.ECS_CLUSTER }}
  ECS_SERVICE: ${{ vars.ECS_SERVICE }}
  ECS_TASK_DEFINITION: .aws/ecs/task-definition.json
  CONTAINER_NAME: ${{ vars.CONTAINER_NAME }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image to ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.ref_name }}
        run: |
          IMAGE_URI=&quot;$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:$IMAGE_TAG&quot;
          echo &quot;IMAGE_URI=$IMAGE_URI&quot; &gt;&gt; $GITHUB_OUTPUT

          docker build -t &quot;$IMAGE_URI&quot; .
          docker push &quot;$IMAGE_URI&quot;

      - name: Render Amazon ECS task definition
        id: render-task
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ env.ECS_TASK_DEFINITION }}
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.IMAGE_URI }}

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.render-task.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true</code></pre>
<h3 id="11-3-워크플로우-설명">11-3. 워크플로우 설명</h3>
<p><code>on:</code>은 워크플로우 트리거이다. tag에 v가 붙은게 푸시된다면 실행이 된다. (v0.0.1, v1,2,4같은 태그)
<code>env:</code>는 워크플로우 전역 환경변수이다.. 여기에서 github variables에 있는것을 가져와서 썼다.
<code>jobs:</code>는 수행할 작업을 넣는 곳이다. 서로 병렬 실행도 가능하다고 한다.
<code>jobs: 안 steps:</code> 위에서 아래로 순서대로 실행된다. 각 step에 무슨 이름(name), 어떤 액션(uses), 어떤 쉘 명령(run)하는지 들어간다.</p>
<p>나는 워크플로우 안에 순서대로</p>
<ol>
<li><strong>Checkout</strong> (여기에서 레포 소스코드 내려받음)</li>
<li><strong>Configure AWS credentials</strong> (여기에서 AWS 자격증명을 한다. 저기 넣어놓은 AWS_ROLE_TO_ASSUME으로 임시 자격 증명을 받아 다음 작업들을 할 수 있다)</li>
<li><strong>Login to Amazon ECR</strong> (ECR에 docker push를 함)</li>
<li><strong>Build, tag, and push image to ECR</strong> (도커 이미지를 만들고, 이미지를 빌드하고, 태그를 붙인 후 ECR에 올림)</li>
<li><strong>Render Amazon ECS task definition</strong> (task-definition.json을 읽고 container-name에 해당하는 이미지로 태스크를 정의함)</li>
<li><strong>Deploy Amazon ECS task definition</strong> (정의된 태스크를 ECS에 등록하고 여기에 들어가있는 클러스터의 서비스를 그 새 태스크로 업데이트를 함)</li>
</ol>
<h2 id="12-태그-푸시-후-배포">12. 태그 푸시 후 배포</h2>
<h3 id="121-태그-푸시">12.1 태그 푸시</h3>
<pre><code>git tag v0.0.1
git push --tags</code></pre><h3 id="122-성공">12.2 성공</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/38221a63-e143-422d-b511-e44fb01a7caf/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/96118d8c-1128-417f-8910-6dd528a5dbcb/image.png" alt=""></p>
<h2 id="13-결론">13. 결론</h2>
<ul>
<li>배포 방식을 태그 푸시 기반 자동 배포로 바꿨다.</li>
<li>ECS_CLUSTER, ECS_SERVICE는 ECS 콘솔에 표시된 실제 이름을 그대로 사용해야 한다.</li>
<li>Secrets Manager는 ARN으로 주입하며, Task Execution Role에 GetSecretValue 권한이 필요했다.</li>
<li>IAM과 보안 그룹 설정은 너무 어려웠고 중요하다라는 걸 알게됐다. 권한이랑 인바운드 규칙이 달라지면 바로 에러를 쏟아냈다.</li>
</ul>
<p>다음 글에서는 이 인프라를 구축하면서 겪었던 오류 유형과 해결 과정을 정리해보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[블로그 인기글 부하 테스트 병목현상 개선 과정 2]]></title>
            <link>https://velog.io/@while-true-study/%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%9D%B8%EA%B8%B0%EA%B8%80-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B3%91%EB%AA%A9%ED%98%84%EC%83%81-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95-2</link>
            <guid>https://velog.io/@while-true-study/%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%9D%B8%EA%B8%B0%EA%B8%80-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B3%91%EB%AA%A9%ED%98%84%EC%83%81-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95-2</guid>
            <pubDate>Wed, 04 Feb 2026 01:25:31 GMT</pubDate>
            <description><![CDATA[<p>이전 글에서 약 10만 건 데이터 기준으로 부하 테스트를 했고, connection refused / 지연 폭증 / Hikari pending 증가 / InnoDB row lock wait 증가까지 연쇄 병목을 확인했다.
이번 글에서는 무엇을 어떻게 개선했고, 왜 그 실험을 했는지를 쓸 예정이다.</p>
<h2 id="0-실험-목표-기준">0. 실험 목표, 기준</h2>
<h3 id="0-1-목표">0-1. 목표</h3>
<p>Locust failures 감소
p95/p99 지연 감소
InnoDB row lock wait 감소
Hikari pending 감소
Tomcat threads/connections 상한 도달 방지
를 달성하는 것을 목표로 했다.</p>
<h3 id="0-2-테스트-설계">0-2. 테스트 설계</h3>
<p>데이터 규모: 게시글 10만건
트래픽 믹스: 핫스팟 80 / 랜덤 20
Locust 시나리오 대로 실험</p>
<hr>
<h2 id="1-개선-후보">1. 개선 후보</h2>
<p>개선은 할게 끝이 없다고 생각했다. 그래서 3가지를 잡았다.</p>
<ol>
<li>리소스 상한 튜닝하기 (Tomcat/Hikari)</li>
<li>DB 최적화하기 (쿼리/인덱스/트랜잭션)</li>
<li>아키텍처 변경하기 (Redis/비동기/배치)</li>
</ol>
<hr>
<h2 id="2-실험-설계">2. 실험 설계</h2>
<h3 id="2-1-테스트를-분리">2-1. 테스트를 분리</h3>
<p>Break test(파괴 테스트): 유저 10,000(스텝 1,000)</p>
<p>Observation test(관측 테스트): 유저 1,000(스텝 100)</p>
<p>파괴 테스트는 failures가 너무 많으면 p95 같은 지표가 왜곡되기 쉽다.
그래서 원인 추적은 관측 테스트 구간에서 진행했다.</p>
<h3 id="2-2-비교-지표">2-2. 비교 지표</h3>
<p>Locust: failures, RPS, p95/p99, (가능하면 실패 사유 캡처)</p>
<p>DB: Innodb_row_lock_waits, Innodb_row_lock_time</p>
<p>Hikari: active/idle/pending</p>
<p>Tomcat: threads.busy/current/max, connections.current/max, keepalive</p>
<hr>
<h2 id="3-상한-튜닝-먼저-해보기">3. 상한 튜닝 먼저 해보기</h2>
<p>이 실험의 목적은 간단하다.</p>
<p>연결 거절/타임아웃은 줄어들 수 있지만, DB row lock 병목은 남는다. 그냥 조금 더 버틸 것이다.</p>
<h3 id="3-1-변경한-것">3-1. 변경한 것</h3>
<p><code>properties</code></p>
<pre><code class="language-java"># Tomcat tuning
server.tomcat.threads.max=400
server.tomcat.max-connections=20000
server.tomcat.accept-count=200

# HikariCP tuning 새 요청이 커넥션을 못받고 3초 지나면 타임아웃 에러
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=50
spring.datasource.hikari.connection-timeout=3000 </code></pre>
<h3 id="3-2-관측-결과">3-2. 관측 결과</h3>
<pre><code class="language-console">ERROR [http-nio-8080-exec-386] o.a.c.c.C.[.[.[.[dispatcherServlet] - 
Servlet.service() for servlet [dispatcherServlet] in context with path 
[] threw exception [Request processing failed: org.springframework.transaction.CannotCreateTransactionException: 
Could not open JPA EntityManager for transaction] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available,
request timed out after 3003ms (total=50, active=50, idle=0, waiting=2)...</code></pre>
<ul>
<li>Hikari 풀을 50으로 늘렸지만, 요청이 증가하면서 active가 50까지 고정되고 idle은 0이 된다.</li>
<li>그 상태에서 신규 요청은 커넥션을 얻지 못하고 <code>connectionTimeout(3s)</code> 이후 실패한다.</li>
<li>스프링에서는 트랜잭션을 시작하기 위해 <code>EntityManager</code>를 열어야 하는데 커넥션을 못 얻어서 <code>CannotCreateTransactionException</code>으로 떨어진다.</li>
</ul>
<p>상한을 늘려 더 많이 받는 것처럼 보였지만 결국 DB병목이 남아있으면 실패가 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/23823fa3-f7bb-49d7-9fdc-4c7229671d8c/image.png" alt=""></p>
<h3 id="3-3-한-줄-결론">3-3. 한 줄 결론</h3>
<p>상한을 늘리면 좀 더 늦게 밀리지만, 조회수 UPDATE라는 단일 row 핫스팟은 그대로라서 결국 다시 같은 형태로 포화된다.</p>
<hr>
<h2 id="4-db-최적화하기">4. DB 최적화하기</h2>
<h3 id="4-1-변경한-것">4-1. 변경한 것</h3>
<p><strong>트랜잭션 범위 최소화</strong>
이전 코드는 서비스 클래스에 <code>@Transactional</code>이 잘못 걸려서, 조회 API까지도 불필요하게 트랜잭션이 넓게 잡힐 수 있었다.</p>
<p>트래픽이 몰릴 때 DB가 느려지면 트랜잭션이 길어질 것이다. 그리고 트랜잭션을 메서드 단위로 다시 분리했다.</p>
<p><code>Post get 메소드</code></p>
<pre><code class="language-java">    @Override
    @Transactional(readOnly = true)
    public PostDetailDto getPost(Long id) {
        viewCountService.increment(id);
        return PostDetailDto.from(postRepository.findById(id).orElse(null));
    }</code></pre>
<p><code>ViewCountService</code></p>
<pre><code>    @Transactional // 또는 REQUIRES_NEW (의미를 더 강하게 주고 싶으면)
    public void increment(Long postId) {
        postRepository.incrementViewCount(postId);
    }</code></pre><p>이런식으로 커넥션을 조금이라도 짧게 잡아보고자 생각해봤다.</p>
<p><strong>인기글 쿼리 인덱스 추가</strong>
10만 건 기준에서 인기글 API는 아래 쿼리를 사용한다.
<code>WHERE post_status = &#39;PUBLISHED&#39; ORDER BY view_count DESC LIMIT 20</code></p>
<p>데이터가 적을 떄는 티가 많이 나지 않지만, 데이터가 커지면 비용이 크게 늘어날 수 있다. 그래서 post_status, view_count가 인덱스를 타도록 추가했다.</p>
<pre><code class="language-sql">CREATE INDEX idx_post_status_viewcount ON post (post_status, view_count, post_id);</code></pre>
<p>EXPLAIN ANALYZE 결과 실제로 굉장히 많이 줄어든 것을 확인할 수 있었다. 인덱스 추가 전에는 Sort가 들어가 있고 수십 ms가 걸렸다.</p>
<p>인덱스 추가 전</p>
<pre><code class="language-sql">-&gt; Limit: 20 row(s)  (cost=10164 rows=20) (actual time=65.7..65.7 rows=20 loops=1)
     -&gt; Sort row IDs: p.view_count DESC, limit input to 20 row(s) per chunk  
     (cost=10164 rows=99148) (actual time=65.7..65.7 rows=20 loops=1)
         -&gt; Filter: (p.post_sta...</code></pre>
<p>인덱스 추가 후</p>
<pre><code class="language-sql">-&gt; Limit: 20 row(s)  (cost=5704 rows=20) (actual time=0.0324..0.067 rows=20 loops=1)
     -&gt; Filter: (p.post_status = &#39;PUBLISHED&#39;)  (cost=5704 rows=49574) (actual time=0.0317..0.0657 rows=20 loops=1)
         -&gt; Index lookup on p using idx_post_status_view...</code></pre>
<p>인덱스 추가 후에는 Sort가 사라지고 Index lookup으로 바뀌었다. 실행 시간도 크게 줄어들었다.</p>
<h3 id="4-2-테스트-결과">4-2. 테스트 결과</h3>
<p><strong>튜닝 후 (10,000명)</strong>
튜닝 이후에도 전과 비슷하게 RPS가 급격하게 오르며 실패율이 굉장히 높아졌다.
<img src="https://velog.velcdn.com/images/while-true-study/post/086e36c6-9b94-4c7f-b042-63826e7d0599/image.png" alt=""></p>
<p><strong>튜닝 전(1,000명)</strong>
하지만 튜닝 전 1,000명 테스트에서 RPS가 60대였고, 응답속도가 계속 증가하는 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/while-true-study/post/23823fa3-f7bb-49d7-9fdc-4c7229671d8c/image.png" alt=""></p>
<p><strong>튜닝 후 (1,000명)</strong>
튜닝 후 1,00명 테스트에서 RPS가 약 300까지 상승해 개선효과가 있었다.
<img src="https://velog.velcdn.com/images/while-true-study/post/25134569-bb55-491c-9732-ec7504fe8a8d/image.png" alt=""></p>
<p>그리고 실패율이 상대적으로 안정되고, p95/p99도 10,000 유저 대비 훨씬 완만한 형태로 보였다.
이 결과로 볼 때, 인기글 쿼리 인덱스 적용으로 정렬/스캔 비용이 크게 완화되었고, 중간 부하 구간에서는 처리량이 눈에 띄게 개선됐다.</p>
<h3 id="4-3-결론">4-3. 결론</h3>
<p>인기글 인덱스 튜닝으로 정렬/스캔 비용은 줄어 중간 부하(1,000명)에서는 처리량이 크게 개선됐지만, 조회수 증가 UPDATE는 여전히 단일 row 핫스팟 쓰기 병목이라 고부하(10,000명)에서는 커넥션 풀/스레드 포화로 다시 실패했다.</p>
<hr>
<h2 id="5-샤딩-해보기">5. 샤딩 해보기</h2>
<h3 id="5-1-변경한-점">5-1 변경한 점</h3>
<p><strong>조회수 증가 로직 샤딩 카운터로 분리</strong>
기존 구현은 게시글 상세 조회 시 <code>post.view_count = post.view_count + 1</code> 업데이트가 발생했다. 트래픽이 몰리면 같은 post row를 계속 UPDATE하는 구조가 되고, 결국 row lock 경합과 InnoDB write 부담이 병목이 나는 상황이라고 생각했다.</p>
<p>그래서 post 테이블의 view_count 업데이트를 제거하고, 별도의 샤딩 카운터 테이블로 조회수 증가를 분리했다. </p>
<blockquote>
<p>하나의 row에 쓰기가 몰리는 구조를 여러 row로 분산시키면 좋아지지 않을까라는 생각으로 시도해보았다.</p>
</blockquote>
<p><strong>테이블 구조 변경</strong>
<code>post_view_counter_shard(post_id, shard_id, view_count)</code> 형태로 테이블을 만들고, <code>(post_id, shard_id)</code>를 PK로 뒀다. 하나의 post에 shard 8개(0~7)가 존재하도록 구성했다.</p>
<p><strong>조회수 증가 로직 변경</strong>
상세 조회 API에서 더 이상 post를 UPDATE하지 않고, shard_id를 랜덤으로 골라 해당 row만 증가시키도록 바꿨다.</p>
<p><code>PostServiceImpl</code></p>
<pre><code class="language-java">@Override
@Transactional
public PostDetailDto getPost(Long id) {
    viewCountService.incrementView(id);   // post 테이블 UPDATE 제거
    Post post = postRepository.findById(id).orElse(null);
    return PostDetailDto.from(post);
}</code></pre>
<p><code>ViewCountService</code></p>
<pre><code class="language-java">private static final int SHARD_COUNT = 8;

@Transactional
public void incrementView(Long postId) {
    int shardId = ThreadLocalRandom.current().nextInt(SHARD_COUNT);
    repo.increment(postId, shardId);
}
</code></pre>
<p><code>PostViewCounterShardRepository</code></p>
<pre><code class="language-java">@Modifying
@Query(value = &quot;&quot;&quot;
    UPDATE post_view_counter_shard
    SET view_count = view_count + 1
    WHERE post_id = :postId AND shard_id = :shardId
&quot;&quot;&quot;, nativeQuery = true)
int increment(@Param(&quot;postId&quot;) Long postId, @Param(&quot;shardId&quot;) int shardId);</code></pre>
<p><strong>의도</strong>
같은 게시글의 조회수가 몰려도 업데이트 대상 row가 8개로 분산되기 때문에, 단일 row에 락이 몰리는 핫스팟을 완화하려고 했다.</p>
<p><strong>한계</strong>
쓰기 경합은 줄일 수 있어도, 읽기와 랭킹에서 비용이 커졌다.
조회수 조회 시 SUM(view_count)로 8개 shard를 합산해야 했다.</p>
<p>인기글처럼 정렬/랭킹이 필요한 경우 ORDER BY SUM(view_count) DESC 형태가 되어서 쿼리가 너무 복잡해질 것이라고 생각했다.</p>
<p>데이터 규모가 너무 커진다고 생각했다. 지금 게시글이 10만개인데 게시글 개당 8개라고 생각하면 80만개가 들어가야한다. 게시글이 늘어날 수록 카운터 테이블과 인덱스도 함께 커지기 때문에 계속 불어난다고 생각했다.</p>
<h3 id="5-2-부하-테스트-결과">5-2. 부하 테스트 결과</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/e977b858-bb58-4812-8211-bab47c4e6e04/image.png" alt=""></p>
<ul>
<li>테스트 초반 응답 시간이 수 초~10초대로 급상승하는 구간이 존재했다.</li>
<li>이후 특정 시점부터 응답 시간이 급격히 내려가며, 동시에 RPS가 크게 상승하는 구간이 나타났다.</li>
<li>안정 구간 진입 이후에도 응답 시간과 RPS가 완만하게 오르내리는 파동이 반복됐다.</li>
</ul>
<p>초반 급상승 =&gt; 급회복 =&gt; 안정 구간 파동패턴이 반복적으로 나타났다.</p>
<h3 id="5-3-왜-이렇게-나왔지">5-3. 왜 이렇게 나왔지?</h3>
<p>포화 근처에서 시스템이 밀렸다가 회복되는 사이클로 해석하는 게 자연스럽다.</p>
<p><strong>초반 급상승은 워밍업/콜드 상태 영향 가능성이 크다</strong>
테스트 시작 직후에는 JVM/DB/OS 캐시가 차지 않은 상태라 지연이 쉽게 튄다. 일정 시간이 지나면서 캐시가 채워지고 실행 경로가 안정화되면 응답 시간이 내려가서 이렇게 나왔다고 생각했다.</p>
<p><strong>Locust(닫힌 모델) 특성</strong>
Locust는 요청 =&gt; 응답이 끝나야 다음 요청을 보내는 구조라, 서버가 느려지면 유저당 요청 빈도가 줄어 RPS가 내려가고, 잠깐 빨라지면 RPS가 다시 튄다.</p>
<p>그래서 응답 시간이 떨어지는 순간 RPS가 확 올라가는 모양이 만들어질 수 있다.
<a href="https://www.locust.cloud/blog/closed-vs-open-workload-models/">참고: 닫힌 모델 vs 열린 모델</a></p>
<h3 id="5-4-redis를-쓰기로-한-이유">5-4. Redis를 쓰기로 한 이유</h3>
<p>샤딩 카운터는 DB에서 조회수 쓰기 핫스팟을 분산시키는 데는 도움이 됐지만, 구조적으로 한계가 있다고 생각했다.</p>
<ol>
<li><p>조회수는 고빈도 쓰기인데 샤딩을 해도 조회 1회당 DB write 1회라는 건 바뀌지 않았다. 락 경합을 완화할 수는 있어도 쓰기 트래픽의 근본 비용은 계속 DB가 떠안게 됐다.</p>
</li>
<li><p>읽기에서 합산이 필요해졌고, 인기글처럼 정렬이 필요한 순간 너무 복잡해졌다.
<code>SUM(view_count)</code> 기반 정렬은 쿼리 비용이 커지고, 결국 결과를 별도로 캐시하거나 집계 테이블로 머지하거나 랭킹을 미리 계산하는 작업이 필요해졌다.</p>
</li>
</ol>
<p>Redis는 메모리 기반이라 INCR 같은 카운터 증가가 매우 가볍고, 랭킹은 Sorted Set으로 상위 N개 조회가 빠르다. 조회수처럼 쓰기 빈도가 극단적으로 높은 데이터와 인기글 조회를 DB보다 훨씬 단순한 형태로 처리할 수 있었다.
DB는 데이터의 정합성과 영속 저장에 집중시키고, 조회수/랭킹처럼 변동이 극심한 값은 Redis로 분리하는게 더 좋아보인다고 생각했다.</p>
<h3 id="5-5-결론">5-5. 결론</h3>
<p>DB에서 조회수는 결국 쓰기 핫스팟이었고, 샤딩으로 락을 완화할 수는 있었지만 읽기 합산/랭킹과 충돌하면서 복잡도가 급격히 증가했다. 그래서 조회수/랭킹은 Redis 같은 인메모리 구조로 분리하는 방향이 더 현실적이라고 생각했다.</p>
<hr>
<h2 id="6-redis로-요청당-db-update-제거">6. Redis로 요청당 DB UPDATE 제거</h2>
<p>요청 경로에서 조회수 쓰기(UPDATE) 를 빼버리는 방식으로 바꿨다.</p>
<p>기존에는 게시글 상세 조회 요청이 들어올 때마다 DB에서 <code>view_count = view_count + 1</code> 같은 쓰기 트랜잭션이 매번 발생했다. 이 구조는 트래픽이 몰릴수록 DB가 락/redo/flush 비용을 계속 떠안게 되고, 결국 조회 API인데도 DB write 병목이 터지는 형태라고 판단했다.</p>
<p>그래서 조회수와 랭킹은 Redis로 처리하고, DB 반영은 나중에 하는것으로 생각을 했다.</p>
<h3 id="6-1-변경점">6-1. 변경점</h3>
<p><strong>요청 경로에서 DB UPDATE를 제거했다</strong></p>
<p>상세 조회 요청이 들어와도 더 이상 DB에 조회수 UPDATE를 하지 않게 바꿨다.
Redis에만 카운트를 올리도록 바꿨다.</p>
<p>조회수: HINCRBY (Hash)</p>
<p>랭킹: ZINCRBY (Sorted Set)</p>
<p><strong>랭킹 쿼리를 DB 정렬에서 Redis ZSET으로 바꿨다</strong>
DB에서 인기글을 구하려면 <code>ORDER BY view_count DESC LIMIT N</code>을 계속 했어야 했다.
Redis ZSET은 점수 기반 정렬이 기본이어서, 상위 N개 조회가 구조적으로 유리하다고 생각했다.</p>
<p><strong>정합성은 최종 DB 반영이 아니라 요청 경로 분리가 목표였다</strong></p>
<p>이 단계에서 목표는 완벽하게 실시간으로 DB에 맞추기가 아니었다.
목표는 요청 경로에서 write를 제거해 병목을 없애는 것이었다.</p>
<p>UI에서 보여줄 조회수는 Redis에서 읽으면 된다.</p>
<p>DB 반영은 배치/스케줄로 나중에 합쳐도 된다.</p>
<h3 id="6-2-테스트-결과">6-2. 테스트 결과</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/e6c6829c-fa9e-45fa-b39c-8b6d3c90df54/image.png" alt=""></p>
<p><strong>RPS 상승</strong></p>
<p>Redis 적용 후 차트에서 피크 구간 RPS가 약 1,900~2,000 수준까지 올라간 구간이 확인됐다.
동일하게 1,000 유저 조건에서 진행했는데도, DB UPDATE를 제거하자 처리량 상한이 크게 올라갔다고 생각했다.</p>
<p><strong>레이턴시가 급락하는 구간 존재</strong></p>
<p>3시 6분쯤에는 지연시간이</p>
<p>p50 ≈ 360ms
p95 ≈ 430ms</p>
<p>수준까지 내려간 구간이 보였다.</p>
<p>쓰기 제거가 응답시간과 처리량에 반영됐다고 생각했다.</p>
<h3 id="6-3-근데-그래프가-왜-이렇게-출렁거릴까">6-3. 근데 그래프가 왜 이렇게 출렁거릴까?</h3>
<p>차트가 반복적으로 출렁이는 이유는, 이번 캡처들이 DB가 완전히 병목에서 해방된 상태는 아니었기 때문이라고 생각했다.</p>
<p><strong>Hikari 풀 자체가 꽉 찼다</strong>
hikaricp.connections.max = 10
<img src="https://velog.velcdn.com/images/while-true-study/post/476fb115-3ae5-4c61-8597-30cabc9b5ed4/image.png" alt=""></p>
<p>hikaricp.connections.active = 10
<img src="https://velog.velcdn.com/images/while-true-study/post/6e7a4863-9a54-41db-8b93-c1862a7a549b/image.png" alt=""></p>
<p>hikaricp.connections.idle = 0
<img src="https://velog.velcdn.com/images/while-true-study/post/9f316e51-ed30-48d4-b8d4-0a7034958fe4/image.png" alt=""></p>
<p>hikaricp.connections.pending ≈ 182 ~ 190
<img src="https://velog.velcdn.com/images/while-true-study/post/a31dc458-f397-4d72-b856-3c5b98e65a05/image.png" alt=""></p>
<p>풀 최대치(10)까지 항상 active가 꽉 찼다. idle(대기 커넥션)은 0이었다 커넥션을 못 빌려서 기다리는 스레드(pending)가 180명대까지 쌓였다.</p>
<p>조회수 UPDATE는 Redis로 빠졌지만, 상세 조회 자체가 DB SELECT를 계속 필요로 했고 (게시글/태그/작성자 등), 그 read 트래픽으로 커넥션 풀이 꽉 찼다고 생각했다.</p>
<p><strong>Locust의 닫힌 모델</strong>
이 패턴은 Locust가 기본적으로 요청 =&gt; 응답 후 다음 요청을 보내는 닫힌 모델이라, 서버가 잠깐 느려지면 RPS가 같이 내려가고, 잠깐 빨라지면 RPS가 튀는 특성이 반영된 결과로 볼 수 있다.</p>
<p>그래서 서버가 느려져 응답이 밀리면, Locust가 초당 요청을 덜 보내게 되고 RPS가 내려가고
잠깐이라도 응답이 빨라지면 유저들이 한꺼번에 더 자주 때리면서 RPS가 확 올라간것같다.</p>
<p><a href="https://www.locust.cloud/blog/closed-vs-open-workload-models/">참고: 닫힌 모델 vs 열린 모델</a></p>
<h3 id="6-4-결론">6-4. 결론?</h3>
<p>조회수 증가에서 요청당 DB UPDATE를 제거하자, 처리량이 눈에 띄게 올라갔다.</p>
<p>Failures/s가 0에 수렴하는 구간이 길게 유지되어, 쓰기 병목 패턴이 크게 줄었다고 봤다.</p>
<p>Actuator 지표에서 Hikari 풀(max=10)이 상시 포화(active=10, idle=0, pending 180~200) 상태였기 때문에, 병목이 완전히 사라진 것이 아니라 DB read/커넥션 풀이 병목이 됬다고 생각한다.</p>
<p>그래서 다음 단계는 Redis 도입 성공에서 끝이 아니라, DB 조회의 커넥션 점유 시간을 줄여서 병목을 완화시키는 게 목표이다.</p>
<hr>
<h2 id="7-또-다른-병목">7. 또 다른 병목</h2>
<h3 id="7-1-read-병목">7-1. Read 병목</h3>
<p>Redis로 조회수 UPDATE를 요청 경로에서 제거했지만, 병목이 완전히 사라진 것은 아니었다.
Actuator에서 Hikari가 active=max, idle=0, pending 180+ 상태로 포화된 것을 확인했다. 즉, 병목이 write에서 read로 이동했다고 판단했다.</p>
<p>Locust 통계에서도 GET /posts (list)가 다른 API보다 지연이 크게 튀는 순간이 있었고, 그 타이밍에 전체 RPS가 함께 꺾였다. 목록(list)이 DB 커넥션을 오래 점유하면서 풀 포화를 유발했다고 해석했다.</p>
<h3 id="7-2-쿼리-인덱스-추가">7-2. 쿼리 인덱스 추가</h3>
<p>목록 API는 PUBLISHED 필터와 정렬을 사용한다. </p>
<pre><code class="language-sql">CREATE INDEX idx_post_status_created_at_id ON post (post_status, created_at, post_id);</code></pre>
<p><code>튜닝 전</code></p>
<pre><code>-&gt; Limit: 50 row(s)  (cost=5704 rows=50) (actual time=103..103 rows=50 loops=1)     
-&gt; Sort: p.created_at DESC, p.post_id DESC, limit input to 50 row(s) per chunk  
(cost=5704 rows=49574) (actual time=103..103 rows=50 loops=1)         -&gt; Index lookup on p...</code></pre><p>튜닝 전에도 조건 자체는 인덱스를 탔지만, 정렬(created_at DESC)을 만족시키지 못해 Sort 단계를 진행한것 같다.</p>
<p><code>튜닝후</code></p>
<pre><code>-&gt; Limit: 50 row(s)  (cost=5701 rows=50) (actual time=0.3..0.318 rows=50 loops=1)     
-&gt; Filter: (p.post_status = &#39;PUBLISHED&#39;)  (cost=5701 rows=49549) (actual time=0.299..0.315 rows=50 loops=1)         
-&gt; Index lookup on p using idx_post_status_created_a...</code></pre><p>인덱스가 (post_status → created_at → post_id) 순서로 잡히면서, 옵티마이저가 정렬을 별도 Sort로 처리하지 않고 인덱스 순서로 해결할 수 있는 형태가 됐다.</p>
<h3 id="7-3-적용후-부하-테스트-결과">7-3. 적용후 부하 테스트 결과</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/04819e1f-3dbc-421a-8e73-367f439162d0/image.png" alt=""></p>
<p>인덱스 적용 후에는 failures가 0으로 유지된 상태에서 RPS가 1,700<del>2,100 범위로 훨씬 안정적으로 유지되었고, p50/p95 지연도 300</del>500ms대에서 안정적으로 관측되었다.</p>
<p>Locust 통계에서 GET /posts (list)는 median이 1초대, p95가 4<del>11초까지 튀었고, detail은 p95가 1</del>2초대로 상대적으로 낮았다.</p>
<p>그래프가 주기적으로 출렁이는 형태는 남아있었는데, 이는 Locust 시나리오에서 list를 일정 주기로 refresh하는 요청이 순간적으로 몰리면서 read 경로가 잠깐 포화되고, 닫힌 모델 특성상 RPS가 같이 출렁이는 패턴이라고 생각을 하고 있다.</p>
<p>실제로 Locust시나리오에 list를 60초 주기로 refresh하도록 설정되어있어 60초마다 list 요청이 몰리는 상태였다.</p>
<pre><code class="language-py">def _refresh_post_pool(self, force=False):
        now = time.time()
        if not force and (now - self._last_refresh) &lt; REFRESH_LIST_EVERY_SEC:
            return</code></pre>
<h2 id="8-정리">8. 정리</h2>
<p>처음 병목은 단순했다. 상세 조회가 들어올 때마다 <code>view_count = view_count + 1 UPDATE</code>가 발생했고, 트래픽이 몰리면 특정 post row에 쓰기가 집중되면서
InnoDB row lock wait =&gt; 커넥션 점유 시간 증가 =&gt; Hikari pending 증가 =&gt; 지연 폭증/실패로 이어졌다.</p>
<ol>
<li><p>상한(Tomcat/Hikari) 튜닝은 실패를 늦게 만들 뿐이었다. DB 병목이 남아 있으면 active가 max에 붙고, 결국 connection timeout으로 무너졌다.</p>
</li>
<li><p>DB 인덱스/트랜잭션 정리는 중간 부하에서는 분명 효과가 있었지만, 조회수 UPDATE라는 본질적인 쓰기 핫스팟은 고부하에서 한계가 왔다.</p>
</li>
<li><p>샤딩 카운터는 쓰기 락 경합을 분산시키는 데는 도움이 됐지만, 읽을 때 합산(SUM)과 랭킹(ORDER BY SUM)을 만들기가 너무 복잡해졌다.</p>
</li>
</ol>
<p>그래서 최종적으로 선택한 방식이 Redis로 요청 경로에서 DB UPDATE를 제거하는 것이었다. 그 결과 failures가 0으로 수렴했고, 처리량 상한도 눈에 띄게 올라갔다.</p>
<p>마지막으로, 인덱스를 정렬 패턴에 맞춰 추가하면서 list의 Sort 비용을 줄였고, 그 결과 RPS와 지연이 더 안정적인 형태로 바뀌었다. 그래프에 남아 있는 출렁임은 Locust 시나리오에서 list refresh가 일정 주기로 몰리는 구조가 반영된 결과라고 생각했다.</p>
<p><strong>개선 전</strong>
<img src="https://velog.velcdn.com/images/while-true-study/post/0d6ad766-a391-444a-bc0f-4ba37fbc7e6a/image.png" alt=""></p>
<p><strong>개선 후</strong></p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/35f0a267-e268-4ac5-a968-39551be3827d/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[블로그 인기글 부하 테스트 병목현상 개선 과정]]></title>
            <link>https://velog.io/@while-true-study/%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%9D%B8%EA%B8%B0%EA%B8%80-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B3%91%EB%AA%A9-%EB%B0%9C%EA%B2%AC%EA%B3%BC-%EC%9B%90%EC%9D%B8-%EC%B6%94%EC%A0%81</link>
            <guid>https://velog.io/@while-true-study/%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%9D%B8%EA%B8%B0%EA%B8%80-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B3%91%EB%AA%A9-%EB%B0%9C%EA%B2%AC%EA%B3%BC-%EC%9B%90%EC%9D%B8-%EC%B6%94%EC%A0%81</guid>
            <pubDate>Fri, 30 Jan 2026 22:58:43 GMT</pubDate>
            <description><![CDATA[<h2 id="0-당시-상황">0. 당시 상황</h2>
<p>블로그 서버를 만들고 있었고, 인기글 기능을 추가한 뒤 부하 테스트를 돌리고 있었다.
테스트 시나리오는 단순했다.</p>
<p>상세 조회 API는 조회할 때 조회수<code>(view_count)</code>를 1 증가시킨다.
인기글 API는 <code>view_count DESC</code> 로 상위 N개를 가져온다.</p>
<h3 id="0-1-구현-흐름">0-1. 구현 흐름</h3>
<ul>
<li>게시글 상세 조회: <code>GET /posts/{id}</code></li>
<li>조회수 증가: 상세 조회 시 <code>view_count = view_count + 1</code></li>
<li>인기글(랭킹): <code>ORDER BY view_count DESC LIMIT 20</code></li>
</ul>
<h3 id="0-2-실험-환경">0-2. 실험 환경</h3>
<ul>
<li>앱: Spring Boot + JPA + MySQL + Tomcat</li>
<li>커넥션 풀: HikariCP</li>
<li>부하 도구: Locust</li>
<li>관측: access log, actuator metrics, MySQL</li>
</ul>
<h3 id="0-3-스펙">0-3. 스펙</h3>
<ul>
<li>CPU: 13th Gen Intel(R) Core(TM) i5-13500</li>
<li>DB 분리 여부: 로컬 동일 머신</li>
<li>Hikari 설정 maximumPoolSize: 10</li>
</ul>
<h3 id="0-4-수정-테스트-데이터-규모-변경-3--약-10만개">0-4. (수정) 테스트 데이터 규모 변경 3 =&gt; 약 10만개</h3>
<p>처음에는 게시글 테이블에 데이터가 거의 없는 상태(3건 수준)로 테스트했다.
그 상태에서는 인기글 정렬이든, 리스트 조회든 별로 티가 나지 않았다.</p>
<p>그래서 실서비스에 가까운 조건을 만들기 위해 게시글 데이터를 약 100,000건으로 늘려서 다시 테스트했다.</p>
<ul>
<li><code>ORDER BY view_count DESC LIMIT 20</code> 는 데이터가 많아질수록 정렬/스캔 비용이 커진다.</li>
<li>조회수 UPDATE 핫스팟 문제뿐 아니라 인기글 SELECT 자체도 병목일 수도 있다.</li>
</ul>
<p>이 글은 약 10만 건 기준으로 병목을 관측/추적한 내용으로 수정했다.</p>
<hr>
<h2 id="1-부하-테스트-설계">1. 부하 테스트 설계</h2>
<p>실제 서비스는 조회가 특정 글/특정 컨텐츠에 몰릴 것이라고 생각했다. 그래서 핫스팟을 의도적으로 만들었다.</p>
<h3 id="1-1-핫스팟-만들기">1-1. 핫스팟 만들기</h3>
<p>특정 게시글 1~2개에 조회 트래픽의 80% 집중
나머지는 랜덤 게시글로 트래픽을 조절했다.</p>
<h3 id="1-2-성공실패-기준">1-2. 성공/실패 기준</h3>
<p><strong>Locust</strong>
failure rate(실패율)
timeout 발생 여부/개수
p95/p99 응답시간
RPS 변화(피크 찍고 떨어지는지)</p>
<p><strong>서버</strong>
access log에서 엔드포인트별 응답시간(%D)이 튀는지</p>
<p><strong>DB(MySQL/InnoDB)</strong>
row lock wait / deadlock 흔적
UPDATE가 병목이 되는지</p>
<p><strong>풀(HikariCP)</strong>
pending(대기 커넥션) 증가 여부
pool timeout 발생 여부</p>
<hr>
<h2 id="2-locust-failures가-뜨기-시작했다">2. Locust failures가 뜨기 시작했다</h2>
<h3 id="2-1-break-test-10000명">2-1. Break test (10,000명)</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/da2849a3-446e-446d-bdf0-89d17ae8d81a/image.png" alt=""></p>
<ul>
<li>유저 수를 10,000까지 올리자 Failures/s가 RPS와 거의 비슷한 속도로 증가했다.</li>
<li>원인 분석은 관측 가능한 구간(1,000명 테스트)에서 진행했다.</li>
</ul>
<h3 id="2-2-observation-test-1000명">2-2. Observation test (1,000명)</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/2e632e3c-63b5-49bf-9f51-bfd0f8a8e6dc/image.png" alt=""></p>
<ul>
<li>10,000명 구간은 실패율이 너무 높아 내부 병목을 관측하기 어려웠다.</li>
<li>그래서 유저 수를 1,000명으로 낮추고 동일 시나리오로 재현하여, 실패 없이도 지연이 급증하는 구간을 확보했다.</li>
<li>이후의 access log / HikariCP / InnoDB / Tomcat 메트릭 분석은 1,000명 테스트를 기준으로 정리했다.</li>
</ul>
<h3 id="2-3-이-failures는-어디에서-나온걸까">2-3. 이 failures는 어디에서 나온걸까</h3>
<p>Locust의 실패는 서버가 500을 주는 것 같은 HTTP 실패만 의미하지 않는다. 연결 자체 실패도 failures로 묶어서 보여준다.</p>
<p>그래서 나는 제일 먼저 살펴본것은 3가지 였다</p>
<ol>
<li>서버는 요청을 받았고, HTTP 에러를 돌려줬다</li>
<li>서버가 너무 느려서 timeout으로 응답을 못 받았다</li>
<li>아예 TCP 연결이 성립하지 않았다 (요청이 서버까지 못 갔다)</li>
</ol>
<hr>
<h2 id="3-원인-찾기">3. 원인 찾기</h2>
<p>관측 가능한 1,000명 재현 테스트를 기준으로 정리했다.</p>
<h3 id="3-1-access-log-확인">3-1. access log 확인</h3>
<p>내가 먼저 확인한 것은 access log였다.</p>
<p>access log에 찍히면 요청이 톰캣까지는 도착했다. 라고 생각하기 떄문이다.
access log에 안 찍히면 애초에 연결이 안 됐거나(서버까지 못 왔음), 톰캣 앞단에서 끊겼다쪽이라고 생각했다.</p>
<p>access log는 아래와 같이 설정했다.</p>
<pre><code class="language-properties">server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.directory=logs
server.tomcat.accesslog.prefix=access_log
server.tomcat.accesslog.suffix=.log
server.tomcat.accesslog.pattern=%h %l %u %t &quot;%r&quot; %s %b %D
server.tomcat.accesslog.buffered=false</code></pre>
<p>그리고 확인해보니 아래와 같이 모두 200 응답만 찍혀 있었다.</p>
<p>access log는 톰캣이 받아서 처리한 요청만 찍는다. 연결 단계에서 거절된 요청은 애초에 로그에 남지 않는다.</p>
<pre><code>GET /api/v1/posts?page=0&amp;size=50 HTTP/1.1&quot; 200 562 12155
GET /api/v1/posts?page=0&amp;size=50 HTTP/1.1&quot; 200 562 13103
GET /api/v1/posts?page=0&amp;size=50 HTTP/1.1&quot; 200 562 13111
.
.
.
GET /api/v1/posts?page=0&amp;size=50 HTTP/1.1&quot; 200 564 643916
GET /api/v1/posts?page=0&amp;size=50 HTTP/1.1&quot; 200 564 641821
GET /api/v1/posts?page=0&amp;size=50 HTTP/1.1&quot; 200 564 633855</code></pre><p>여기서 생각한건 서버가 받은건 대부분 정상 처리됬는데 Locust는 failures가 40%정도였다. 서버가 500을 한게 아니고 서버에 도달하지 못했거나 도달 전에 끊겼다고 생각을 하였다.</p>
<h3 id="3-1-1-d가-계속-증가">3-1-1. %D가 계속 증가</h3>
<p>access log에는 요청 처리 시간(%D = ms단위) 도 같이 찍히게 해두었다. (요청을 받아서 응답을 완료할 때까지 처리 시간)</p>
<p>부하가 올라갈수록 access log에 200은 유지되지만 <code>%D</code>가 계속 커지는 현상을 발견하였다.
이것을 보고 서버는 정상 응답을 주긴 주지만, 점점 더 오래걸리며 처리가 제대로 되지 않는 것을 확인하였다.</p>
<ol>
<li>처리 시간이 커지니까 (%D 증가) =&gt;</li>
<li>워커 스레드가 더 오래 잡고 있으면 (스레드가 고갈되니까) =&gt;</li>
<li>threads, connections가 상한에 도달함 =&gt;</li>
<li>그때부터 연결이 거절됨.</li>
</ol>
<p><strong>처리 시간이 증가하니까 워커 스레드가 더 오래잡고있게 되고, 오래 잡으니까 스레드가 상한에 도달하게 된다. 그때부터 연결이 거절될 수 있다고 생각하게 되었다.</strong></p>
<h3 id="3-2-locust-failure-확인">3-2. Locust failure 확인</h3>
<p>access log 다음으로 본 건 Locust failures 상세였다.
실패 메시지는 아래와 같았다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/a6d6c90b-2ec5-40c6-b198-02db4ce8fa9c/image.png" alt=""></p>
<p>HTTP 요청을 보내고 500을 받은 게 아니다. TCP 연결 자체가 성립하지 않았다.
그래서 access log에 안 찍히는 게 정상이라고 생각했다.</p>
<p>애플리케이션이 아닌 서버에서 새 연결을 받아줄 여유가 없었던 상태였다고 생각했다.</p>
<hr>
<h2 id="4-그럼-왜-connection-refused가-났을까">4. 그럼 왜 connection refused가 났을까?</h2>
<p><code>localhost:8080</code>에서 connection refused가 나오는 대표 원인은 보통 이거라고 한다.</p>
<ol>
<li>해당 시점에 8080 리스너가 없었다 (프로세스 죽음/재시작/포트 변경)</li>
<li>리스너는 있지만 <strong>Tomcat 리소스(threads/maxConnections)</strong>가 포화되어 새 연결이 거절되는 상태</li>
<li>로컬 환경 특수 이슈(방화벽/보안SW 등)</li>
</ol>
<hr>
<h2 id="5-8080-리스너-확인">5. 8080 리스너 확인</h2>
<p>아래 순서로 확인했다.</p>
<h3 id="5-1-8080-리스닝-pid확인">5-1. 8080 리스닝 PID확인</h3>
<pre><code class="language-bash">netstat -ano | findstr :8080</code></pre>
<ul>
<li>결과: <code>LISTENING 49260</code></li>
</ul>
<h3 id="5-2-pid가-뭔지-확인">5-2. PID가 뭔지 확인:</h3>
<pre><code>tasklist /FI &quot;PID eq 49260&quot;</code></pre><ul>
<li>결과: <code>java.exe 49260</code></li>
</ul>
<h3 id="5-3-이-javaexe-가-내-서버인지-확인">5-3. 이 java.exe 가 내 서버인지 확인</h3>
<pre><code>wmic process where &quot;ProcessId=49260&quot; get ProcessId,Name,ExecutablePath,CommandLine /format:list</code></pre><ul>
<li>결과: CommandLine에 프로젝트 <code>classpath</code>랑 <code>com.example.blog.BlogApplication</code>이 찍혔다.</li>
</ul>
<p>8080은 내 스프링 서버가 맞았다.</p>
<hr>
<h2 id="6-원인-생각-앱-풀-db">6. 원인 생각 (앱, 풀, DB)</h2>
<p>나는 아래 3개로 원인을 생각해보았다.</p>
<h3 id="6-1-애플리케이션-cpugc-병목">6-1. 애플리케이션 CPU/GC 병목</h3>
<p>CPU가 100%에 붙거나
GC가 길게 발생하거나
스레드가 특정 락에 묶이거나</p>
<h3 id="6-2-커넥션-풀-병목hikari">6-2. 커넥션 풀 병목(Hikari)</h3>
<p>DB가 느려지면 커넥션이 반환이 안 되고
결국 요청 스레드가 커넥션 기다리다가 잠듦
결과적으로 API가 타임아웃/실패</p>
<h3 id="6-3-dbinnodb-락쿼리-병목">6-3. DB(InnoDB) 락/쿼리 병목</h3>
<ul>
<li>상세 조회마다 실행되는 쿼리:
<code>UPDATE post SET view_count = view_count + 1 WHERE id=?</code></li>
</ul>
<p>특정 게시글에 트래픽이 몰리면 같은 row를 동시에 update
InnoDB row lock wait 증가 =&gt; 트랜잭션 지연 =&gt; 풀 대기 증가</p>
<ul>
<li>인기글 쿼리:
<code>ORDER BY view_count DESC LIMIT 20</code>
데이터가 10만 건이라서 인덱스 구성에 따라 정렬/스캔 자체가 병목이 될 수 있다.</li>
</ul>
<p>인덱스가 없으면 큰 지연이 일어날 수 있다.</p>
<hr>
<h2 id="7-병목이-일어나는-시나리오">7. 병목이 일어나는 시나리오</h2>
<p>한번 병목이 일어나는 시나리오를 생각해보았다.</p>
<ol>
<li>요청이 들어온다</li>
<li>톰캣 worker thread가 처리한다</li>
<li>처리 중 DB 작업에서 막힌다(락/슬로우쿼리/커넥션풀 대기)</li>
<li>worker thread가 대기 상태로 점유된다</li>
<li>어느 순간 톰캣의 처리 스레드가 꽉 찬다</li>
<li>새 연결은 대기열로 쌓이다가, 클라이언트에서 timeout/거부 같은 형태로 failures가 생겨난다.</li>
</ol>
<p>DB 대기 → 애플리케이션 스레드 점유 → 새 연결을 못 받음</p>
<hr>
<h2 id="8-원인-추적-결과-정리">8. 원인 추적 결과 정리</h2>
<p>나는 크게 3가지를 확인하였다.</p>
<h2 id="8-1-hikaricp커넥션풀에-대기열이-생기나">8-1. HikariCP(커넥션풀)에 대기열이 생기나?</h2>
<p><a href="https://velog.io/@while-true-study/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80Connection-Pool-%ED%99%95%EC%9D%B8%ED%95%98%EA%B3%A0-%EC%A1%B0%EC%A0%88%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-HikariCPActuator">HikariCP 메트릭 찍는방법</a>
active(사용 중 커넥션) = 10 (모두 사용중)
<img src="https://velog.velcdn.com/images/while-true-study/post/0b3c6c0c-e67e-489a-bc93-cdb3fb14a2eb/image.png" alt=""></p>
<p>idle(남은 커넥션) = 0 (없음)
<img src="https://velog.velcdn.com/images/while-true-study/post/be38b425-9c13-40cb-a648-0d6e725018e7/image.png" alt=""></p>
<p>pending(커넥션 기다리는 스레드 수) = 188~190
<img src="https://velog.velcdn.com/images/while-true-study/post/e395c985-3d8b-481e-ac9b-f713a14e7554/image.png" alt=""></p>
<p>pool timeout 발생 여부 = 0 (Hikari의 connectionTimeout으로 실패한 건수는 관측되지 않았다)
<img src="https://velog.velcdn.com/images/while-true-study/post/57ae87e4-4591-4b11-92ee-e6c4f57d7008/image.png" alt=""></p>
<p>active가 max(10)에 고정되고 idle은 0이고 pending은 계속 증가했다. 요청 스레드가 DB 커넥션을 못 얻고 있다는 뜻이라고 생각했다.</p>
<p>pending이 190까지 올라간 걸 보면, 커넥션 대기가 엄청났고, 일부 요청은 Hikari timeout까지 가지기 전에 톰캣 연결 단계에서 실패했을 것 같다.</p>
<h2 id="8-2-innodb-row-lock-wait로-병목-확인">8-2. InnoDB row lock wait로 병목 확인</h2>
<p>조회수 증가가 <code>UPDATE post SET view_count=view_count+1</code> 형태면,
핫스팟 트래픽에서는 같은 row에 업데이트가 몰린다.</p>
<h3 id="8-2-1-innodb-row-lock-wait확인">8-2-1. InnoDB Row Lock Wait확인</h3>
<p>부하 테스트 직후 MySQL 상태 변수에서 아래 값을 확인했다.</p>
<pre><code class="language-sql">SHOW GLOBAL STATUS LIKE &#39;Innodb_row_lock%&#39;;</code></pre>
<table>
<thead>
<tr>
<th align="center">variable_name</th>
<th align="center">value</th>
</tr>
</thead>
<tbody><tr>
<td align="center">Innodb_row_lock_current_waits</td>
<td align="center">1</td>
</tr>
<tr>
<td align="center">Innodb_row_lock_time</td>
<td align="center">3056796</td>
</tr>
<tr>
<td align="center">Innodb_row_lock_time_avg</td>
<td align="center">25</td>
</tr>
<tr>
<td align="center">Innodb_row_lock_time_max</td>
<td align="center">140</td>
</tr>
<tr>
<td align="center">Innodb_row_lock_waits</td>
<td align="center">118284</td>
</tr>
</tbody></table>
<p><code>Innodb_row_lock_waits</code> 는 행 락 대기 발생 횟수이고, <code>Innodb_row_lock_time</code> 은 누적 대기 시간이다. 핫스팟 UPDATE가 반복되면 이 값들이 빠르게 증가한다.</p>
<p>조회수 UPDATE가 같은 row에 몰리면서 락 경합이 실제로 발생했고, 그 대기가 커넥션 반환 지연과 pending 증가로 이어진 것 같다.</p>
<h3 id="8-2-2-실제로-시간이-많이-든-쿼리">8-2-2. 실제로 시간이 많이 든 쿼리</h3>
<p>슬로우/요약에서 쿼리 분포를 보니, 가장 비중이 큰 쿼리가 조회수 UPDATE였다.</p>
<pre><code class="language-sql">SELECT
  DIGEST_TEXT,
  COUNT_STAR,
  ROUND(SUM_TIMER_WAIT/1e12, 3) AS total_sec,
  ROUND(AVG_TIMER_WAIT/1e12, 6) AS avg_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;
</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/a455ed97-66ad-46ec-9f0a-14114b80a584/image.png" alt=""></p>
<p>이 상황은 인기글/상세조회가 읽기처럼 보여도, 조회수 때문에 결국 쓰기 병목이 된 것 같다.</p>
<h3 id="8-2-3-이게-왜-locust-failures--d-증가--hikari-pending으로-이어졌을까">8-2-3. 이게 왜 Locust failures / %D 증가 / Hikari pending으로 이어졌을까?</h3>
<p>DB에서 row lock 대기가 늘어나면, 트랜잭션이 오래 잡히고 커넥션이 늦게 반환된다.</p>
<ul>
<li>DB 락 대기 커지면 =&gt; 커넥션 반환 지연 커짐</li>
<li>커넥션 반환 지연 커짐 =&gt; Hikari active=10(고정), idle=0, pending 약 190</li>
<li>pending(대기 스레드) 커짐 =&gt; 요청 처리시간 증가(access log의 %D 폭증)</li>
<li>처리 스레드가 대기로 묶이면 =&gt; 결국 일부 요청은 connection refused, timeout로 Locust 실패로 측정됨.</li>
</ul>
<p>이 문제는 DB 락 대기가 애플리케이션 스레드/커넥션풀을 잠그면서 생긴 리소스 병목이었다.</p>
<h2 id="8-3-톰캣서버-메트릭-확인">8-3. 톰캣/서버 메트릭 확인</h2>
<p>Actuator에서 Tomcat 메트릭을 찍어보니:</p>
<p>tomcat.threads.busy = 200
<img src="https://velog.velcdn.com/images/while-true-study/post/ccf52f3c-f34b-435b-b68b-852e6f5cd7cc/image.png" alt=""></p>
<p>tomcat.threads.current = 200
<img src="https://velog.velcdn.com/images/while-true-study/post/290c0ec1-a7d5-4886-9f1a-b78b008d4b0a/image.png" alt=""></p>
<p>워커 스레드가 200개 전부 바쁘다. (스레드 풀 고갈)</p>
<p>그리고
tomcat.connections.current = 8192
<img src="https://velog.velcdn.com/images/while-true-study/post/822f2f7e-175e-4470-9ec2-54caa905304d/image.png" alt=""></p>
<p>tomcat.connections.keepalive.current = 8192 (열려있는 연결이 keep-alive로 살아있는 상태)
<img src="https://velog.velcdn.com/images/while-true-study/post/2d9bdd2c-cf70-47b9-9acb-f3e558ea990b/image.png" alt=""></p>
<p>tomcat.threads.config.max = 200
<img src="https://velog.velcdn.com/images/while-true-study/post/5a9c5124-a856-470c-b826-d33c2b29d3d7/image.png" alt=""></p>
<p>tomcat.connections.config.max = 8192
<img src="https://velog.velcdn.com/images/while-true-study/post/6655dc1d-1496-425b-9f5b-8888de34a1fa/image.png" alt=""></p>
<p><code>threads.busy(200) == threads.config.max(200)</code>
<code>connections.current(8192) == connections.config.max(8192)</code></p>
<p>tomcat.threads.busy=200 이고 tomcat.threads.config.max=200 이었다.
톰캣 워커 스레드 풀이 상한에 도달해 더 이상 요청을 처리할 여유가 없었다.</p>
<p>또한 tomcat.connections.current=8192 와 tomcat.connections.config.max=8192 가 똑같았다.
동시 연결이 설정 상한까지 차서 새 연결은 OS/Tomcat 단계에서 거절될 수 있는 상태였다.</p>
<ol>
<li>DB 락 대기 때문에 요청 처리가 느려짐</li>
<li>느려지니 커넥션이 오래 열려있음(keep-alive 포함)</li>
<li>스레드는 DB 대기 상태로 점유됨</li>
<li>결국 톰캣은 threads도 상한, connections도 상한</li>
<li>그 순간부터 신규 연결은 거절/실패로 되고, 그게 Locust의 WinError 10061로 관측된 것 같다.</li>
</ol>
<p><code>Tomcat 메트릭 사용 커맨드</code></p>
<pre><code class="language-bash">curl.exe -s &quot;http://localhost:8081/actuator/metrics/tomcat.threads.busy&quot;
curl.exe -s &quot;http://localhost:8081/actuator/metrics/tomcat.threads.current&quot;
curl.exe -s &quot;http://localhost:8081/actuator/metrics/tomcat.connections.current&quot;
curl.exe -s &quot;http://localhost:8081/actuator/metrics/tomcat.connections.keepalive.current&quot;
curl.exe -s &quot;http://localhost:8081/actuator/metrics/tomcat.threads.config.max&quot;
curl.exe -s &quot;http://localhost:8081/actuator/metrics/tomcat.connections.config.max&quot;</code></pre>
<hr>
<h2 id="9-정리">9. 정리</h2>
<h3 id="9-1-증상">9-1. 증상</h3>
<ul>
<li>InnoDB row lock wait 증가</li>
<li>커넥션 반환 지연</li>
<li>pending 누적</li>
<li>처리 지연이 누적되면서 톰캣 스레드/커넥션이 상한 도달</li>
</ul>
<p>DB 락대기 =&gt; 풀 대기 =&gt; 톰캣 리소스 X =&gt; 클라 연결 실패</p>
<h3 id="9-2-결론">9-2. 결론</h3>
<p>조회수 UPDATE는 핫스팟 트래픽에서 DB row lock 경합을 만든다</p>
<p>그 결과 DB에서 끝나지 않고, Hikari pending, %D (요청시간) 폭증, Tomcat threads/connections 상한까지 연쇄로 번진다</p>
<p>그래서 Locust failures는 <strong>연결 레벨</strong>에서 거절로 관측될 수 있다.</p>
<h3 id="9-3-개선-과정">9-3. 개선 과정</h3>
<p>개선 과정은 <a href="https://velog.io/@while-true-study/%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%9D%B8%EA%B8%B0%EA%B8%80-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B3%91%EB%AA%A9%ED%98%84%EC%83%81-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95-2">다음 글</a>에 정리하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RabbitMQ consumer 처리량 개선 과정]]></title>
            <link>https://velog.io/@while-true-study/RabbitMQ-consumer-%EC%B2%98%EB%A6%AC%EB%9F%89-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@while-true-study/RabbitMQ-consumer-%EC%B2%98%EB%A6%AC%EB%9F%89-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Fri, 02 Jan 2026 14:54:48 GMT</pubDate>
            <description><![CDATA[<h2 id="0-문제-상황">0. 문제 상황</h2>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/c6b9f244-f7dd-4b7b-bb6a-2cc620a38059/image.PNG" alt=""></p>
<p>컨슈머 처리량이 20~40 msg/s 정도로 매우 느렸습니다.</p>
<p>publish는 300~400 msg/s 까지 발행되었는데, 컨슈머가 따라가지 못해서 snapshot의 반영이 지연되었습니다.</p>
<h2 id="1-조건">1. 조건</h2>
<p>DB: MySQL (동일 인스턴스)</p>
<p>테이블: 변경 없음(최적화 실험 전/후 동일)</p>
<p>메시지: 동일 DTO(SnapshotMsg)</p>
<p>테스트: Locust로 동일 패턴/부하로 발행</p>
<p>측정: RabbitMQ 관리 페이지 deliver/ack rate 기준 msg/s 측정</p>
<p>실험 모두 동일합니다.</p>
<h2 id="2-consumer늘리기-2040--100초반">2. consumer늘리기 (20~40 =&gt; 100초반)</h2>
<p>처음에는 코드를 보기전에 단순 consumer를 늘리면 해결될 것이라고 생각했습니다.
<img src="https://velog.velcdn.com/images/while-true-study/post/7a7fe169-8e48-471d-b2df-bd968611b881/image.png" alt=""></p>
<h3 id="21-변경내용">2.1 변경내용</h3>
<p>RabbitListener concurrency를 늘렸습니다.</p>
<pre><code class="language-java">@RabbitListener(queues = RabbitConfig.Q, concurrency = &quot;#&quot;)</code></pre>
<h3 id="22-결과">2.2 결과</h3>
<p>concurrency 증가 전 : 약 20 msg/s
concurrency 증가 후 : 약 60~100 msg/s</p>
<p>처리량은 올라갔지만 여전히 너무 느렸습니다. 그리고 1개에서 10개로 늘렸지만 200이 아닌 100이 된 이유는 로컬머신의 한계라고 생각했습니다. 테스트를 할때 CPU는 100% 이용을 하고 있었고 한계치라고 생각했습니다.</p>
<p>그래서 결국 메시지를 처리하는 consumer 시간 자체를 줄여야한다고 생각했습니다.</p>
<p>실제로 메시지 한건의 처리 속도는 약 100~200 ms로 매우 느렸습니다.</p>
<h2 id="3-느린-이유에-대한-생각">3. 느린 이유에 대한 생각</h2>
<p>당시 로직은 아래와 같습니다.</p>
<pre><code class="language-java">    @Transactional
    public void processOneMessage(SnapshotMsg msg) {
        validate(msg);
        ledgerRepository.existsByTxIdAndSideAndStatus // ledger상태 조회
        snapshotAppliedRepository.save(SnapshotApplied.of(msg.accountId(), msg.txId())); // 멱등 처리
        availableBalanceRepo.credit(msg.accountId(), msg.amount()); // 잔액 업데이트
        snapshotRepository.applyDelta(msg.accountId(), msg.amount()); // 스냅샷 업데이트
        ledgerRepository.markCommitted(msg.txId(), msg.accountId(), msg.entrySide()); // ledger 상태 업데이트
        outboxRepository.markSent(msg.outboxId()); // outbox 상태 업데이트
    }</code></pre>
<p>메시지 1건당 DB왕복이 너무 많았고 생각해본건 아래 3가지였습니다.</p>
<ol>
<li>DB왕복이 너무 많다</li>
<li>balance 업데이트가 쿼리가 2개라 오래걸릴 것이다.</li>
<li>existsBy 조회가 비쌀것 같다 </li>
</ol>
<h2 id="4-outbox-업데이트를-조건부로-변경">4. outbox 업데이트를 조건부로 변경</h2>
<p>먼저 쿼리문들을 한번 봤습니다. 이 쿼리는 이미 SENT인 row에도 매번 UPDATE를 시도합니다. 메시지가 중복되거나 재시도되면 이 UPDATE가 계속 찍힙니다.</p>
<pre><code class="language-sql">UPDATE outbox
SET status = &#39;SENT&#39;
WHERE outbox_id = ?;</code></pre>
<h3 id="41-변경내용">4.1 변경내용</h3>
<p>아래와 같이 이미 SENT라면 굳이 다시 쓰지 않도록 수정했습니다. 작지만 얼마나 개선되는가를 확인해 보고싶었습니다.</p>
<pre><code class="language-sql">UPDATE outbox
SET status = &#39;SENT&#39;
WHERE outbox_id = ?
  AND status &lt;&gt; &#39;SENT&#39;;</code></pre>
<h3 id="42-결과">4.2 결과</h3>
<p>큰 차이가 없었습니다. </p>
<h2 id="5-쿼리-합치기">5. 쿼리 합치기</h2>
<p>이번에는 쿼리의 개수를 줄여보고자 해보았습니다. 원래는 가용잔액 테이블에 + 연산을 해주고 스냅샷 테이블에서 + 연산을 해줍니다. 여기서 account_id가 같으니까 두개를 합쳐보자는 생각을 해보았습니다. </p>
<h3 id="51-변경내용">5.1 변경내용</h3>
<p>아래와 같이 JOIN을 하여 묶어서 처리를 하도록 했습니다. </p>
<pre><code class="language-sql">UPDATE available_balance ab
        JOIN snapshot_balance sb
          ON sb.account_id = ab.account_id
           SET ab.balance = ab.balance + :amt,
               ab.ver     = ab.ver + 1,
               sb.balance = sb.balance + :amt,
               sb.update_time = NOW()
         WHERE ab.account_id = :accountId</code></pre>
<h3 id="52-결과">5.2 결과</h3>
<p>이것 또한 차이가 없었습니다. 쿼리 수는 줄었지만, 전체 병목이 잔액+스냅샷 업데이트보다 ledger existsBy/트랜잭션 비용 쪽에 있었던 것으로 보였습니다.</p>
<h2 id="6-existsby-제거">6. existsBy 제거</h2>
<p>처음 로직에는 이런 쿼리가 있었습니다.</p>
<pre><code class="language-java">ledgerRepository.existsByTxIdAndSideAndStatus(...)</code></pre>
<p>이 쿼리는 이미 COMMITTED이면 처리하지 않기위한 의도로 넣었습니다.</p>
<h3 id="61-변경내용">6.1 변경내용</h3>
<p>markCommittedIfPending으로 한번에 처리하고 변수(int updated)로 빼서 분기를 하였습니다. 이렇게 하면 existsBy(SELECT)가 없어지고, UPDATE한번으로 할 수 있습니다.</p>
<pre><code class="language-java">@Modifying
@Query(&quot;&quot;&quot;
update Ledger l
   set l.status = com.maeng.toss.ledger.domain.Status.COMMITTED
 where l.txId = :txId
   and l.accountId.accountId = :accountId
   and l.side = :side
   and l.status = com.maeng.toss.ledger.domain.Status.PENDING
&quot;&quot;&quot;)
int markCommittedIfPending(String txId, Long accountId, EntrySide side);</code></pre>
<pre><code class="language-java">int updated = ledgerRepository.markCommittedIfPending(msg.txId(), msg.accountId(), msg.entrySide());
if (updated == 0) { // 변경된 것이 없다 =&gt; 이미 다른 컨슈머가 처리했거나 중복 메시지이므로 스킵
    return;
}</code></pre>
<h3 id="62-결과">6.2 결과</h3>
<p>existsBy(SELECT)를 제거하고 조건부 UPDATE로 통합한 뒤, 메시지 처리 시간이 눈에 띄게 줄었습니다.
메시지 한건의 처리 속도가 6~20 ms 수준으로 빨라지며, ACK rate기준 약 2000 msg/s를 찍기도 하였습니다. 
<img src="https://velog.velcdn.com/images/while-true-study/post/5cc6659c-125a-4c0d-a830-e9b9b3154255/image.PNG" alt=""></p>
<h3 id="63-왜-차이가-컸을까">6.3 왜 차이가 컸을까</h3>
<p>제가 생각한 existsBy가 느린 이유는 이렇습니다.</p>
<ol>
<li><p>일단 메시지 1건에서 SELECT가 한번 늘어나는 구조입니다. 처리량이 많아질 수록 그게 손해라고 생각했습니다. 즉 메시지 한건당 DB왕복이 SELECT + UPDATE에서 UPDATE 하나로 줄었습니다.</p>
</li>
<li><p>Time-of-check to time-of-use 가 발생할 수도 있다고 생각했습니다. 이건 검사 시점과 사용 시점 사이에 발생하는 레이스 컨디션입니다. existsBy로 확인 후 UPDATE는 분리되어 레이스가 일어날 수 있습니다.</p>
</li>
<li><p>existsBy는 결국 내부적으로 limit 1 형태의 조회 SQL이 실행됩니다.
실제로 아래 SQL 로그에서 LIMIT 1 조회로 나가는 것을 확인할 수 있습니다.
DB작업이 이미 많이 있는 상황에서 읽기 하나 추가가 느리게 만든다고 생각했습니다.</p>
<pre><code class="language-sql">DEBUG org.hibernate.SQL -
 select
     l1_0.ledger_id
 from
     ledger_entry l1_0
 where
     l1_0.tx_id=?
     and l1_0.side=?
     and l1_0.status=?
 limit
     ?
...
binding parameter (3:ENUM) &lt;- [COMMITTED]
binding parameter (4:INTEGER) &lt;- [1]</code></pre>
</li>
</ol>
<h2 id="7-정리">7. 정리</h2>
<p>최종 변경 코드</p>
<pre><code class="language-java">@Transactional
public void processOneMessage(SnapshotMsg msg) {
    snapshotAppliedRepository.insertIgnore(msg.accountId(), msg.txId());
    outboxRepository.markSentIfNotSent(msg.outboxId());
    balanceRepository.applyDeltaBoth(msg.accountId(), msg.amount());
    ledgerRepository.markCommitted(msg.txId(), msg.accountId(), msg.entrySide());
}</code></pre>
<p>existsBy는 읽기-판단이라서 DB왕복도 늘리고 동시성도 완벽하지 않다고 생각했습니다.
같은 조건의 작업이면 조건부 UPDATE로 합치는게 더 빠르고 안전할 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 서버에서 커넥션 풀(Connection Pool) 확인하고 조절하는 방법 (HikariCP/Actuator)]]></title>
            <link>https://velog.io/@while-true-study/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80Connection-Pool-%ED%99%95%EC%9D%B8%ED%95%98%EA%B3%A0-%EC%A1%B0%EC%A0%88%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-HikariCPActuator</link>
            <guid>https://velog.io/@while-true-study/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80Connection-Pool-%ED%99%95%EC%9D%B8%ED%95%98%EA%B3%A0-%EC%A1%B0%EC%A0%88%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-HikariCPActuator</guid>
            <pubDate>Thu, 18 Dec 2025 16:12:23 GMT</pubDate>
            <description><![CDATA[<h2 id="0-3줄-요약">0. 3줄 요약</h2>
<ol>
<li>Actuator 의존성 추가<pre><code>dependencies {implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;}</code></pre></li>
<li>서버 재시작 후 터미널에 명령어 치고 커넥션 상태 확인<pre><code>curl.exe -s http://localhost:8080/actuator/metrics/hikaricp.connections</code></pre></li>
<li>application.properties 기준<pre><code># 최대 커넥션 수 조절
spring.datasource.hikari.maximum-pool-size=20</code></pre></li>
</ol>
<h2 id="1-왜-커넥션-풀을-볼까">1. 왜 커넥션 풀을 볼까</h2>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/58cca733-e5c5-48e5-9ee6-7eeef6f5587c/image.png" alt=""></p>
<p>저는 서버에 부하 테스트를 하였고 RPS가 올라감에 따라 응답속도가 증가하는 경험을 하였습니다.</p>
<p>응답속도가 높아져 timeout이 발생했고, 원인 후보 중 하나로 DB 커넥션 풀 포화를 의심했습니다. 커넥션 풀이 부족하면 요청이 커넥션을 얻기까지 대기하게 되고(pending 증가), 그 대기가 길어지면 결국 timeout으로 이어질 수 있습니다. 그래서 Actuator 메트릭으로 현재 커넥션 풀 상태를 확인한 뒤, 필요하다면 최대 풀 크기를 조절해 보기로 했습니다.</p>
<p>DB 커넥션은 무겁고 비싼 자원입니다.
커넥션 풀이 너무 작으면 요청이 몰릴 때 커넥션을 못 구해서 대기(pending) 증가 → 응답 지연/타임아웃이 나고,
너무 크면 DB가 감당 못해서 DB CPU/메모리/스레드 폭증, 락 경합 증가, 전체가 느려질 수 있습니다.</p>
<hr>
<h2 id="2-현재-커넥션-풀-상태-확인하기">2. 현재 커넥션 풀 상태 확인하기</h2>
<h3 id="2-1-actuator-의존성-추가">2-1. Actuator 의존성 추가</h3>
<p>Gradle</p>
<pre><code class="language-java">dependencies {
  implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;
}</code></pre>
<h3 id="2-2-metrics-엔드포인트-노출">2-2. metrics 엔드포인트 노출</h3>
<p>application.properties</p>
<pre><code>management.endpoints.web.exposure.include=health,info,metrics,threaddump
management.endpoint.health.show-details=always</code></pre><p>서버 재시작후</p>
<pre><code>curl.exe http://localhost:8080/actuator</code></pre><p><img src="https://velog.velcdn.com/images/while-true-study/post/06b1ebcf-34c5-43b4-bbff-c3baf8a3cb31/image.png" alt="">
이런식으로 mertrics가 잘 보이면 Actuator 엔드포인트 노출이 제대로 된 상태입니다.</p>
<hr>
<h2 id="3-hikaricp-커넥션-풀-메트릭-조회하기">3. HikariCP 커넥션 풀 메트릭 조회하기</h2>
<h3 id="3-1-total-커넥션-수-확인하기">3-1. Total 커넥션 수 확인하기</h3>
<pre><code class="language-bash">curl.exe -s http://localhost:8080/actuator/metrics/hikaricp.connections</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/ad0edb95-32c1-49d4-a8c7-b5cbd25b5cc2/image.png" alt=""></p>
<p>value: 10 이라고 나와있습니다. 이는 현재 풀에 존재하는 전체 커넥션 수가 10이라는 것입니다. 보통 active + idle의 합입니다. (최대치는 아님)</p>
<h3 id="3-2-activeidlependingmax">3-2. active/idle/pending/max</h3>
<p>무슨 메트릭이 있는지 목록을 확인할 수 있습니다. 아래  사진을 잘 보시면 connections.active, .pending , .timeout, idle등 여러가지를 확인할 수 있습니다.</p>
<pre><code>curl.exe -s http://localhost:8080/actuator/metrics | findstr /i hikaricp</code></pre><p><img src="https://velog.velcdn.com/images/while-true-study/post/b32c1279-2c7c-4bc1-9a02-c07ec8130348/image.png" alt=""></p>
<ul>
<li>hikaricp.connections.active : 사용 중 커넥션 수</li>
<li>hikaricp.connections.idle : 유휴 커넥션 수</li>
<li>hikaricp.connections.pending : 커넥션을 못 구해 대기 중인 스레드 수(병목 신호)</li>
<li>hikaricp.connections.max : 풀 최대치</li>
<li>hikaricp.connections.min : 풀 최소치<pre><code>curl.exe -s &quot;http://localhost:8080/actuator/metrics/hikaricp.connections.active?tag=pool:HikariPool-1&quot;
curl.exe -s &quot;http://localhost:8080/actuator/metrics/hikaricp.connections.idle?tag=pool:HikariPool-1&quot;
curl.exe -s &quot;http://localhost:8080/actuator/metrics/hikaricp.connections.pending?tag=pool:HikariPool-1&quot;
curl.exe -s &quot;http://localhost:8080/actuator/metrics/hikaricp.connections.max?tag=pool:HikariPool-1&quot;</code></pre></li>
</ul>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/2fb89449-ec08-4b81-97c6-5e24a52a01f5/image.png" alt=""> 현재 풀 최대 커넥션 수가 10입니다.(maximum-pool-size:10)</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/f0252fc1-84d8-43cc-a139-668c85be80bb/image.png" alt=""> 현재 유휴상태 커넥션이 10개입니다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/c14826a2-c61c-44f2-bfec-f98c88f6eb60/image.png" alt=""> 현재 DB에 붙어서 실제로 사용중인 커넥션이 0개입니다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/6237cdc3-7001-40b0-a81e-4661feeabd66/image.png" alt=""> 커넥션을 못구해서 기다리는 스레드가 0개입니다.</p>
<hr>
<h2 id="4-커넥션-풀-설정-변경hikaricp">4. 커넥션 풀 설정 변경(HikariCP)</h2>
<p>properties기준</p>
<pre><code># 최대 커넥션 수
spring.datasource.hikari.maximum-pool-size=20

# 최소 유휴 커넥션
spring.datasource.hikari.minimum-idle=10

# 커넥션을 못 구했을 때 기다리는 최대 시간(ms)
spring.datasource.hikari.connection-timeout=30000

# 유휴 커넥션 제거 기준(ms)
spring.datasource.hikari.idle-timeout=600000

# 커넥션 최대 생존시간(ms)
spring.datasource.hikari.max-lifetime=1800000</code></pre><hr>
<h2 id="5-코드로-커넥션-풀-상태-출력하기">5. 코드로 커넥션 풀 상태 출력하기</h2>
<pre><code class="language-java">import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.pool.HikariPoolMXBean;
import javax.sql.DataSource;

public class PoolLogger {
    public static void print(DataSource dataSource) {
        HikariDataSource hikari = (HikariDataSource) dataSource;
        HikariPoolMXBean mx = hikari.getHikariPoolMXBean();

        System.out.println(&quot;최대 풀 개수 =&quot; + hikari.getMaximumPoolSize());
        System.out.println(&quot;최소 Idle=&quot; + hikari.getMinimumIdle());
        System.out.println(&quot;전체 풀=&quot; + mx.getTotalConnections()
                + &quot;, active=&quot; + mx.getActiveConnections()
                + &quot;, idle=&quot; + mx.getIdleConnections()
                + &quot;, awaiting=&quot; + mx.getThreadsAwaitingConnection());
    }
}
</code></pre>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring mysql jpa 데드락 상황 해결 과정 (REQUIRES_NEW)]]></title>
            <link>https://velog.io/@while-true-study/Spring-mysql-jpa-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%83%81%ED%99%A9-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95-k47quuuy</link>
            <guid>https://velog.io/@while-true-study/Spring-mysql-jpa-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%83%81%ED%99%A9-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95-k47quuuy</guid>
            <pubDate>Tue, 16 Dec 2025 17:07:41 GMT</pubDate>
            <description><![CDATA[<h2 id="0-문제-상황">0. 문제 상황</h2>
<p><a href="https://velog.io/@while-true-study/Spring-mysql-jpa-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%83%81%ED%99%A9-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95">이전 글</a>과 이어진다.</p>
<p>이전 글에서 <code>snapshot_balance</code> / <code>available_balance</code> 업데이트 과정에서 데드락이 났던 원인과 해결 과정을 정리했다.<br>이번에는 스냅샷 프로젝터 (consumer) 쪽에서 비슷한 데드락이 다시 발생했다.</p>
<p>이전에 락 순서를 맞춰놔서 이번에는 데드락 덤프를 확인했다.</p>
<pre><code class="language-sql">SHOW ENGINE INNODB STATUS</code></pre>
<p>덤프 일부는 아래와 같았다.</p>
<pre><code class="language-bash">LOCK WAIT ... 1952 row lock(s), undo log entries 141
update available_balance ... where account_id=3

HOLDS THE LOCK(S):
RECORD LOCKS ... index FKn763t766d31cgf7m4w4kj3hs0 of table `tossclone`.`ledger_entry` ...

 그 뒤로 무수히 많은 record lock들 . . . </code></pre>
<p>현재 실행중인 쿼리는 available_balance update이다. 그런데 같은 트랜잭션이 ledger_entry쪽 인덱스 락을 엄청 많이 잡고 있었다. (HOLDS THE LOCKS)</p>
<p>추가로 FKn... 처럼 보이는 인덱스명은</p>
<pre><code class="language-sql">SELECT
    kcu.CONSTRAINT_NAME         AS fk_name,
    kcu.TABLE_NAME              AS table_name,
    kcu.COLUMN_NAME             AS column_name,
    kcu.REFERENCED_TABLE_NAME   AS referenced_table,
    kcu.REFERENCED_COLUMN_NAME  AS referenced_column
FROM information_schema.KEY_COLUMN_USAGE kcu
WHERE
    kcu.TABLE_SCHEMA = &#39;테이블 명&#39;
    AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
ORDER BY
    kcu.TABLE_NAME,
    kcu.CONSTRAINT_NAME,
    kcu.ORDINAL_POSITION;
</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/fa0407b4-02c0-4807-a2f1-67be94771a37/image.png" alt="">
ledger_entry.account_id =&gt; account.account_id
FK와 연관된 이름이었다.</p>
<hr>
<h2 id="1-문제-원인">1. 문제 원인</h2>
<p>당시 스냅샷 프로젝터가 메시지 큐에서 메시지를 꺼내서 다음을 처리한다.</p>
<p>1) <code>SnapshotApplied</code>에 UNIQUE(accountId, txId)로 멱등 락<br>2) <code>snapshot_balance</code> 반영<br>3) <code>available_balance</code> 반영<br>4) outbox <code>SENT</code> 마킹<br>5) ledger 상태 <code>COMMITTED</code> 마킹</p>
<p>그런데 이 작업을 스케줄러에서 다음처럼 돌리고 있었다.</p>
<pre><code class="language-java">    @Scheduled(fixedRate = 500)
    @Transactional
    public void scheduledQueue() { // 소비자임 MQ를 받아서 스냅샷/가용잔액 반영함.
        // 엄청 많은 row lock이 걸림
        int max = 2000;
        for (int i = 0; i &lt; max; i++) {
            SnapshotMsg msg = sharedMessageQueue.poll();
            if (msg == null) break;
            // 1,2,3,4,5 처리
        }
    }</code></pre>
<ol>
<li><code>@Scheduled(fixedRate = 500)</code></li>
<li>한 번에 최대 2000개까지 <code>for</code>문으로 처리</li>
<li>스케줄러 메서드에 <code>@Transactional</code>이 붙어 있음</li>
</ol>
<p>결과: 테스트를 돌려보면 가끔씩 데드락이 터졌다.</p>
<p>이부분이 원인이라고 생각하였다.</p>
<hr>
<h2 id="2-문제를-해결해보자">2. 문제를 해결해보자</h2>
<p>현재는 Transactional안에 for문이 있었기 때문에 2000개를 한 트랜잭션으로 처리하기 때문에 이부분을 고쳐야겠다고 생각했다.</p>
<p><code>REQUIRES_NEW</code>를 사용하여 분리해보았다.</p>
<pre><code class="language-java">    @Scheduled(fixedRate = 500)
    @Transactional
    public void scheduledQueue() {
        // 엄청 많은 row lock이 걸림
        int max = 2000;
        for (int i = 0; i &lt; max; i++) {
            SnapshotMsg msg = sharedMessageQueue.poll();
            if (msg == null) break;
            log.info(&quot;메시지 큐에서 꺼냄&quot;, msg);
            processOneMessage(msg);
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processOneMessage(SnapshotMsg msg) {
        // 1,2,3,4,5 처리
    }
</code></pre>
<p>하지만 또 다시 데드락이 났다.</p>
<h3 id="2-1-requires_new를-붙였는데도-왜-데드락이-났을까">2-1. REQUIRES_NEW를 붙였는데도 왜 데드락이 났을까?</h3>
<p>처음엔 processOneMessage()에 <code>REQUIRES_NEW</code>를 붙였으니 메시지 1개당 트랜잭션이 분리되겠지? 라고 생각했다.</p>
<p>하지만 다시 데드락 로그를 확인해봤더니 한 트랜잭션이 이미 수백 개의 row lock을 잡고 있었다.</p>
<ul>
<li><code>LOCK WAIT ... 1952 row lock(s), undo log entries 141</code></li>
<li>현재 실행 중인 쿼리는 <code>available_balance</code> 업데이트인데,</li>
<li>동시에 <code>ledger_entry</code> 쪽 인덱스 락을 엄청 많이 쥐고 있는 상태였다.</li>
</ul>
<p>메시지 하나 처리 =&gt; 커밋 =&gt; 락 해제 순서가 아니라<br>한 트랜잭션이 계속 살아 있으면서 ledger_entry 락이 누적되고, 마지막에 available_balance에서 서로 물려 데드락이 터진 형태였다.</p>
<p>REQUIRES_NEW가 실제로는 적용되지 않았다라고 생각을 했다 그래서 한번 확인해보기로 하였다.</p>
<h3 id="2-1-1-requires_new-확인">2-1-1. REQUIRES_NEW 확인</h3>
<p>트랜잭션 로그를 TRACE로 하고 확인했다.</p>
<pre><code>logging.level.org.springframework.transaction: TRACE
logging.level.org.springframework.transaction.interceptor: TRACE
logging.level.org.springframework.orm.jpa.JpaTransactionManager: TRACE</code></pre><p>정상적으로 REQUIRES_NEW가 적용되면 processOneMessage 진입 시점에 이런 로그가 떠야한다고 한다.</p>
<ul>
<li>Suspending current transaction</li>
<li>Creating new transaction with name [..processOneMessage]: PROPAGATION_REQUIRES_NEW</li>
<li>(메서드 종료 후) Resuming suspended transaction</li>
</ul>
<p>하지만 실제 로그는 processOneMessage안에서 찍힌 트랜잭션 이름이 계속 scheduledQueue였다.
(기존 트랜잭션 컨텍스트를 그대로 사용하고 있었던 것 같다.)</p>
<p>아래 로그를 보면 No need to create transaction for... 이 있는데 이건 해당 메서드 호출에서 새로운 트랜잭션을 만들 필요가 없다라는 의미이다.</p>
<pre><code>INFO 46256 --- [toss] [   scheduling-1] c.m.t.s.S.BalanceSnapshotProjector       : [TX] where=processOneMessage:beforeCommitPoint, active=true, name=com.maeng.toss.snapshot.SnapshotProjector.BalanceSnapshotProjector.scheduledQueue, isNew=true, thread=scheduling-1
INFO 46256 --- [toss] [   scheduling-1] c.m.t.s.S.BalanceSnapshotProjector       : 메시지 큐에서 꺼냄
INFO 46256 --- [toss] [   scheduling-1] c.m.t.s.S.BalanceSnapshotProjector       : [TX] where=processOneMessage:enter, active=true, name=com.maeng.toss.snapshot.SnapshotProjector.BalanceSnapshotProjector.scheduledQueue, isNew=true, thread=scheduling-1
TRACE 46256 --- [toss] [   scheduling-1] o.s.t.i.TransactionInterceptor           : No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsByTxIdAndAccountId_AccountIdAndStatus]: This method is not transactional.</code></pre><p>아래 로그를 보면 실제로 새로운 트랜잭션을 열지않고 기존 트랜잭션에 합류했다.</p>
<pre><code>DEBUG 46256 --- [toss] [   scheduling-1] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1793071322&lt;open&gt;)] for JPA transaction
DEBUG 46256 --- [toss] [   scheduling-1] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
TRACE 46256 --- [toss] [   scheduling-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]</code></pre><p>이를 통해 REQUIRES_NEW가 제대로 분리되지 않았다고 생각했다.</p>
<h3 id="2-2-원인-같은-빈-내부-호출self-invocation로-프록시를-안-탄다">2-2. 원인: 같은 빈 내부 호출(Self-invocation)로 프록시를 안 탄다</h3>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/4c37db72-d0ba-4c5b-b675-317691b464df/image.png" alt=""></p>
<p>Spring의 <code>@Transactional</code>은 AOP 프록시 기반이다.<br><strong>프록시를 통해 호출될 때만</strong> 트랜잭션이 새로 열리고(REQUIRES_NEW), 커밋/롤백이 관리된다.</p>
<p>그런데 내 코드는 이런 구조였다.</p>
<ul>
<li><code>scheduledQueue()</code> (같은 클래스)<ul>
<li><code>processOneMessage()</code>를 <strong>직접 호출</strong></li>
</ul>
</li>
</ul>
<p>이 같은 빈 내부에서 자기 메서드 호출은 프록시를 거치지 않는다.<br>그래서 <code>processOneMessage()</code>에 <code>REQUIRES_NEW</code>를 붙여도, 실제로는 새로운 트랜잭션이 열리지 않을 수 있다.</p>
<p>결과적으로 아래 코드는 의도와 달리 2000개를 한 트랜잭션으로 처리하는 효과가 남는다.</p>
<pre><code class="language-java">@Scheduled(fixedRate = 500)
@Transactional
public void scheduledQueue() {
    for (...) {
        processOneMessage(msg); // 같은 빈 내부 호출 -&gt; 프록시 미적용 가능
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOneMessage(SnapshotMsg msg) { ... }</code></pre>
<h2 id="3-해결-메시지-처리-로직을-별도-빈으로-분리하기">3. 해결: 메시지 처리 로직을 별도 빈으로 분리하기</h2>
<p>그래서 DB 반영 (processOneMessage)을 별도 컴포넌트로 분리했다.</p>
<p>스케줄러: 큐에서 poll만 하고 위임
프로세서 빈: 메시지 1개를 트랜잭션으로 처리</p>
<p>그리고 스케줄러 메서드의 @Transactional은 제거했다.</p>
<p><strong>스케줄러 빈</strong></p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class BalanceSnapshotProjector {
    private final Queue&lt;SnapshotMsg&gt; sharedMessageQueue;
    private final SnapshotMessageProcessor processor;

    @Scheduled(fixedRate = 500)
    public void scheduledQueue() {
        int max = 2000;
        for (int i = 0; i &lt; max; i++) {
            SnapshotMsg msg = sharedMessageQueue.poll();
            if (msg == null) break;
            processor.processOneMessage(msg); // 다른 빈 호출 -&gt; 프록시 적용
        }
    }
}</code></pre>
<p><strong>처리 빈</strong></p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class SnapshotMessageProcessor {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processOneMessage(SnapshotMsg msg) {
        // 1,2,3,4,5 처리
    }
}</code></pre>
<p>이렇게 바꾸니 트랜잭션이 메시지 1개 단위로 끊기면서
락이 누적되지 않았고, 같은 조건에서 데드락이 일어나지 않았다.</p>
<hr>
<h2 id="4-정리">4. 정리</h2>
<p>@Transactional + for(2000) 조합은 트랜잭션 생존시간이 길어지고 락이 누적될 가능성이 커진다.</p>
<p>REQUIRES_NEW를 쓸때는 프록시를 타는 호출인지 확인하는 것이 핵심이라고 생각했다.</p>
<p>같은 빈 내부 호출(self-invocation)이면 REQUIRES_NEW가 적용되지 않을 수 있다.</p>
<p>트랜잭션/락 문제는 SHOW ENGINE INNODB STATUS + 트랜잭션 TRACE 로그를 확인하여 방향을 잡으면 해결이 빨라진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring mysql jpa 데드락 상황 해결 과정]]></title>
            <link>https://velog.io/@while-true-study/Spring-mysql-jpa-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%83%81%ED%99%A9-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@while-true-study/Spring-mysql-jpa-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%83%81%ED%99%A9-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Mon, 01 Dec 2025 11:06:08 GMT</pubDate>
            <description><![CDATA[<h2 id="0-당시-상황">0. 당시 상황</h2>
<p>나는 현재 뱅킹 시스템을 혼자 만들어 보고 있었고 locust로 테스트를 하는데 deadlock을 만나게 되었다</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/cbc428b2-15dd-4951-8fe3-5607b6dfdeef/image.png" alt=""></p>
<p>transfer 서비스는 HTTP 요청을 받아 실제 이체를 처리하는 온라인 트랜잭션이다.</p>
<ol>
<li>available_balance에서 출금 가능 금액을 차감하고</li>
<li>snapshot_balance를 업데이트하고</li>
<li>ledger_entry에 이중기장(DEBIT/CREDIT)을 기록하고</li>
<li>outbox에 이벤트를 적재한다.</li>
</ol>
<p>비동기 프로젝터는 Outbox → MQ → 메모리 큐(sharedMessageQueue)를 통해 들어온 메시지를 처리하는 오프라인 트랜잭션이다.</p>
<ol>
<li>snapshot_balance를 업데이트하고</li>
<li>available_balance를 증가시키고</li>
<li>ledger 상태를 COMMITTED로 확정한다.</li>
</ol>
<p><strong>transfer 서비스</strong></p>
<pre><code class="language-java">@Transactional
public void transfer(Long fromId, Long toId, Long amount) {
    // 1. 출금 계좌: 가용잔액 차감
    availableBalanceRepo.tryDebit(fromId, amount);
    //  → available_balance(account_id = fromId) UPDATE (SELECT ... FOR UPDATE 포함 가능)

    // 2. 출금 계좌: 스냅샷 -amount 반영
    snapshotRepository.applyDelta(fromId, -amount);
    //  → snapshot_balance(account_id = fromId) 에
    //     INSERT ... ON DUPLICATE KEY UPDATE (PK = account_id)

    ledgerRepo.saveAll(List.of(debit, credit)); // 원장에 2줄 insert
    outboxRepository.save(outbox);                // outbox에 insert
}
</code></pre>
<p><strong>스케줄러</strong> (인메모리 큐에서 메시지를 빼어 스냅샷, 가용잔액에 반영하는 스케줄러)</p>
<pre><code class="language-java">@Scheduled(fixedRate = 500)
@Transactional
public void scheduledQueue() {
    for (...) {
        SnapshotMsg msg = sharedMessageQueue.poll();
        if (msg == null) break;

        if (ledgerRepository.existsByTxIdAndSideAndStatus(
                msg.txId(), EntrySide.CREDIT, Status.COMMITTED)) {
            continue;
        }

        try {
            snapshotAppliedRepository.save(SnapshotApplied.of(msg.accountId(), msg.txId()));

            snapshotRepository.applyDelta(msg.accountId(), msg.amount());  // 1. snapshot + insert
            int updated = availableBalanceRepo.credit(msg.accountId(), msg.amount());  // 2. available + update
            if (updated == 0) throw ...;

            outboxRepository.markSent(msg.outboxId());
            ledgerRepository.markCommitted(msg.txId(), msg.accountId(), msg.entrySide());
        } catch (DataIntegrityViolationException dup) {
            outboxRepository.markSent(msg.outboxId());
        }
    }
}
</code></pre>
<h2 id="1-왜-발생했을까">1. 왜 발생했을까</h2>
<p>왜 발생할까에 대해 계속 고민하다가 이상한 점을 하나 찾았다
<img src="https://velog.velcdn.com/images/while-true-study/post/7ea9fbb7-33f3-40e2-b457-4b686aaddfce/image.png" alt=""></p>
<p>출처 : <a href="https://pubul.tistory.com/36">땔감툰</a></p>
<p>프로젝터 입장에서 특정 계좌 accountId에 대해 테이블을 만지는 순서는 다음과 같다.</p>
<ol>
<li>snapshot_balance(accountId) – applyDelta(+amount)</li>
<li>available_balance(accountId) – credit(+amount)</li>
</ol>
<p>즉, 락 순서: snapshot =&gt; available 이다.</p>
<p>transfer입장에서 accountId에 대해 테이블을 만지는 순서는 다음과 같다.</p>
<ol>
<li>available_balance(accountId) – tryDebit(-amount)</li>
<li>snapshot_balance(accountId) – applyDelta(-amount)</li>
</ol>
<p>즉, 락 순서:  available =&gt; snapshot 이다.</p>
<p>예를 들어서,</p>
<p>계좌 A, B, C가 있다고 하자.
이전에 B → A 송금이 이미 발생했고,
그때 생성된 “계좌 A, CREDIT, +10,000원” 메시지가 Outbox → MQ → sharedMessageQueue 에 들어와 있다.</p>
<p>그 와중에 사용자가 A → C 송금을 요청했다.
즉, 아래 두 트랜잭션이 거의 동시에 실행될 수 있다.</p>
<p>두 트랜잭션은 모두 계좌 A에 대해 snapshot_balance와 available_balance를 건드린다.
<img src="https://velog.velcdn.com/images/while-true-study/post/23c330e8-c58d-4b5c-ac3b-67c9ee2b795d/image.PNG" alt=""></p>
<p>T1 = 스케줄러 트랜잭션 (B → A 송금의 CREDIT 메시지 처리 중)
     snapshot_balance(A)  += 10,000   (락 순서 1: snapshot_balance)
     available_balance(A) += 10,000   (락 순서 2: available_balance)</p>
<p>T2 = 온라인 트랜잭션 (A → C 송금 처리 중)
     available_balance(A) -= 50,000   (락 순서 1: available_balance)
     snapshot_balance(A)  -= 50,000   (락 순서 2: snapshot_balance)</p>
<p>이 상황이라고 생각했다.</p>
<h2 id="2-문제를-해결">2. 문제를 해결</h2>
<p>락 순서를 통일 시키면 해결이 될 것이라 생각해 
프로젝터 입장
락 순서를 snapshot =&gt; available 에서 available =&gt; snapshot으로 수정했다.</p>
<pre><code>// 변경 전 (프로젝터)
snapshotRepository.applyDelta(msg.accountId(), msg.amount());                // 1. snapshot
int updated = availableBalanceRepo.credit(msg.accountId(), msg.amount()); // 2. available

// 변경 후 (락 순서 통일: available -&gt; snapshot)
int updated = availableBalanceRepo.credit(msg.accountId(), msg.amount()); // 1. available
snapshotRepository.applyDelta(msg.accountId(), msg.amount());             // 2. snapshot
</code></pre><p>그랬더니 snapshot deadlock은 더이상 볼 수 없게 되었다.</p>
<h2 id="3-그-이후">3. 그 이후</h2>
<pre><code>Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed:
org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction]
[insert into ledger_entry (account_id,amount,idempotency_key,memo,occurred_at,side,status,tx_id) values (?,?,?,?,?,?,?,?)]; 
SQL [insert into ledger_entry (account_id,amount,idempotency_key,memo,occurred_at,side,status,tx_id) values (?,?,?,?,?,?,?,?)]] with root cause</code></pre><p>snapshot과 available의 락 순서는 맞춰서 해결했지만 ledger를 동시에 insert하는 부분이 남아있었다.
이 부분에서 새로운 deadlock이 나왔다.</p>
<p>이 데드락은 <a href="https://velog.io/@while-true-study/Spring-mysql-jpa-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%83%81%ED%99%A9-%ED%95%B4%EA%B2%B0-%EA%B3%BC%EC%A0%95-k47quuuy">다음 글</a>에서 정리해보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Can't lock pointer back immediately after escape 오류 해결하는 방법]]></title>
            <link>https://velog.io/@while-true-study/Cant-lock-pointer-back-immediately-after-escape-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@while-true-study/Cant-lock-pointer-back-immediately-after-escape-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 15 May 2025 02:11:41 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가기">0. 들어가기</h2>
<p>R3F로 웹 게임을 제작하다가 Can&#39;t lock pointer back immediately after escape라는 오류를 만났습니다.. 이 오류는 LockPointerAPI라는 API를 쓸때 나는 오류입니다.. 마우스 커서가 Lock되있는 상태에서 사용자가 ESC를 누르고 다시 바로 상호작용시 오류가 나는 문제입니다. 해당 오류는 크게 2가지 정도 해결 방법이 있는 것을 알게 되었는데 그것을 정리해보고자 합니다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/b4e3d77e-642f-44aa-b1e2-ec47a250ede4/image.png" alt=""></p>
<h2 id="1-버튼-만들기">1. 버튼 만들기</h2>
<p><a href="https://sbcode.net/threejs/follow-cam/">https://sbcode.net/threejs/follow-cam/</a>
이 방법은 Three.js 공식 사이트 튜토리얼 예제에서 쓴 방법입니다. PointerLock이 걸리고 ESC를 누르면 화면이 뿌애지면서 Click to Start라는 버튼을 표시합니다.
<img src="https://velog.velcdn.com/images/while-true-study/post/7cf8e9e9-07a2-43e1-a3ec-e07c0e78b4fa/image.png" alt=""></p>
<p>그리고 이 버튼을 클릭해야만 다시 Pointer Lock이 작동을 합니다. 저는 이방법으로 해결을 했습니다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/cd2cd633-150c-4561-99d8-f5847e8fe11a/image.gif" alt=""></p>
<p>구현 코드</p>
<pre><code class="language-jsx">{pointerLocked ? null : (
    &lt;ClicktoStart reqInside={reqInside} pointerLocked={pointerLocked}/&gt;
)}</code></pre>
<pre><code class="language-jsx">컴포넌트
import React, { useEffect, useState } from &#39;react&#39;;
import CTSbutton from &#39;./CTSbutton&#39;;

const ClicktoStart = ({ reqInside, pointerLocked }) =&gt; {
  const [visible, setVisible] = useState(false);
  useEffect(() =&gt; {
    const timeout = setTimeout(() =&gt; {
      setVisible(true);
    }, 1000);
    return () =&gt; clearTimeout(timeout);
  }, []);

  const handleClick = (e) =&gt; {
    if (pointerLocked) {
      e.preventDefault();
      return;
    }
  };

  return (
    &lt;div
      onClick={handleClick}
      style={{
        width: &#39;100%&#39;,
        height: &#39;100%&#39;,
        opacity: 0.65,
        backgroundColor: &#39;black&#39;,
        zIndex: 50,
        position: &#39;absolute&#39;,
        top: &#39;50%&#39;,
        left: &#39;50%&#39;,
        transform: &#39;translate(-50%, -50%)&#39;,
        display: &#39;flex&#39;,
        justifyContent: &#39;center&#39;,
        alignItems: &#39;center&#39;,
        pointerEvents: &#39;none&#39;,
      }}
    &gt;
      &lt;CTSbutton onClick={reqInside} visible={visible}&gt;&lt;/CTSbutton&gt;
    &lt;/div&gt;
  );
};

export default ClicktoStart;
</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/63aaa1d8-ba77-4c42-834c-39b22d57f07a/image.gif" alt=""></p>
<p>pointerLocked는 포인터가 잠긴 상태면 true, 아니면 false입니다. CTSbutton은 단순 버튼 컴포넌트입니다.</p>
<h2 id="2-시간차-두기">2. 시간차 두기</h2>
<p>두 번째 방법은 inactivatePointerLock()이 호출될때 최근 pointer Lock이 활성화된 시점과 현재 시간의 차이를 계산해서 만약에 100ms보다 작으면 그냥 return을 해주는 코드입니다. 사용자가 너무 빨리 잠금해제/클릭을 하면 그것을 막을 수 있다고 합니다. 저는 어떻게 구현해야할지 이해가 안가서 첫 번째 방법을 사용해 해결했습니다.</p>
<pre><code class="language-js">let pointerLockActivatedAt = null;

function inactivatePointerLock() {
    const now = performance.now()
    if (pointerLockActivatedAt != null &amp;&amp; now - pointerLockActivatedAt &lt; 100) {
        return // 무시
    }
    // do your thing
}
function activatePointerLock() {
    const now = performance.now()
    pointerLockActivatedAt = now
}</code></pre>
<p>이렇게 마치겠습니다. 오늘도 고생하셨습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[async await 은 진짜 비동기일까? 내부 작동 원리 알아보기]]></title>
            <link>https://velog.io/@while-true-study/async-await-%EC%9D%80-%EC%A7%84%EC%A7%9C-%EB%B9%84%EB%8F%99%EA%B8%B0%EC%9D%BC%EA%B9%8C-%EB%82%B4%EB%B6%80-%EC%9E%91%EB%8F%99-%EC%9B%90%EB%A6%AC-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@while-true-study/async-await-%EC%9D%80-%EC%A7%84%EC%A7%9C-%EB%B9%84%EB%8F%99%EA%B8%B0%EC%9D%BC%EA%B9%8C-%EB%82%B4%EB%B6%80-%EC%9E%91%EB%8F%99-%EC%9B%90%EB%A6%AC-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 02 May 2025 01:21:14 GMT</pubDate>
            <description><![CDATA[<p>출처 : <a href="https://pozafly.github.io/javascript/event-loop-and-async/">https://pozafly.github.io/javascript/event-loop-and-async/</a></p>
<h2 id="들어가며">들어가며</h2>
<p>JavaScript를 하다 보면 async/await, Promise, setTimeout 등에서 예상치 못한 로그 순서를 마주하게 됩니다. 이번 글에서는 이벤트 루프, 콜 스택, 마이크로태스크, 콜백 큐를 중심으로 JavaScript의 비동기 실행 원리를 파헤쳐 보겠습니다.</p>
<h2 id="async-함수는-언제-비동기일까">async 함수는 언제 비동기일까?</h2>
<pre><code class="language-js">async function asyncFunc() {
  console.log(&quot;1. Start&quot;);
  const data = await longRunningAsyncOperation(); // 논블로킹
  console.log(&quot;3. End&quot;);
}

console.log(&quot;0. Before asyncFunc call&quot;);
asyncFunc();
console.log(&quot;2. After asyncFunc call&quot;);</code></pre>
<p>이 코드에서 로그 순서는 어떻게 될까요?</p>
<pre><code class="language-js">0. Before asyncFunc call
1. Start
2. After asyncFunc call
3. End</code></pre>
<h3 id="포인트">포인트</h3>
<p>asyncFunc()을 호출하면 바로 실행되며, 내부 코드도 await 전까지는 <strong>동기적</strong>으로 실행됨.</p>
<p>await을 만나면 해당 줄 이후는 마이크로태스크 큐에 등록되고, 나중에 실행된다.</p>
<h2 id="javascript-이벤트-루프-구조">JavaScript 이벤트 루프 구조</h2>
<p>JavaScript는 싱글 스레드 언어지만 비동기처럼 작동하는 이유는 이벤트 루프(Event Loop) 덕분입니다.
<img src="https://velog.velcdn.com/images/while-true-study/post/8ff8df29-0a6a-43f2-9c38-85ea450f4f8c/image.gif" alt=""></p>
<h3 id="구성-요소">구성 요소</h3>
<p><strong>Call Stack</strong>    : 실행 중인 함수들이 쌓이는 곳
<strong>Microtask Queue</strong> :    Promise.then(), async/await, queueMicrotask() 등
<strong>Task Queue</strong> : (Callback Queue)    setTimeout, setInterval, DOM 이벤트 등
<strong>Event Loop</strong> :    콜 스택이 비면 큐에서 작업을 꺼내 실행</p>
<h3 id="실행-흐름-예제">실행 흐름 예제</h3>
<pre><code class="language-js">console.log(&quot;1&quot;);

setTimeout(() =&gt; {
  console.log(&quot;2 - setTimeout&quot;);
}, 0);

Promise.resolve().then(() =&gt; {
  console.log(&quot;3 - Promise&quot;);
});

console.log(&quot;4&quot;);</code></pre>
<h3 id="실행-순서">실행 순서</h3>
<pre><code class="language-bash">1
4
3 - Promise
2 - setTimeout</code></pre>
<p>1, 4는 동기 코드이니까 즉시 실행됩니다.</p>
<p>Promise.then()은 마이크로태스크 큐 → 콜 스택이 비자마자 실행됩니다.
setTimeout()은 콜백 큐 → 마이크로태스크가 끝난 뒤 실행됩니다.</p>
<h3 id="예제">예제</h3>
<pre><code class="language-js">async function run() {
  console.log(&quot;1&quot;);
  await Promise.resolve(&quot;result&quot;);
  console.log(&quot;2&quot;);
}

run();
console.log(&quot;3&quot;);console.log(&quot;3&quot;);
console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);
console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);
console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);
console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);
console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);
console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);console.log(&quot;3&quot;);
console.log(&quot;3&quot;);console.log(&quot;3&quot;); // 3이지만 오래걸리는 동기작업 이라고 생각</code></pre>
<h3 id="실행-순서-1">실행 순서</h3>
<pre><code class="language-bash">1
3 (반복 출력)
2
console.log(&quot;2&quot;)는 await 이후라 마이크로태스크 큐에 들어감</code></pre>
<p>모든 동기 작업이 끝난 후 실행됨 (절대 끼어들지 않음!)</p>
<h3 id="비유로-이해하는-구조">비유로 이해하는 구조</h3>
<pre><code class="language-bash">브라우저
├── Web API (setTimeout, fetch 등)
├── JavaScript Engine
│   ├── Call Stack
│   ├── Callback Queue
│   ├── Microtask Queue
│   └── Event Loop</code></pre>
<p>예를 들면
Web API = 무대 뒤 작업자들 (실제로 오래걸리는 일 작업중인 곳)
JS 엔진 = 무대 감독 (어떤 장면이 올라갈지 선택, 결정함)
Call Stack = 현재 무대 위 배우 (현재 작업중인 곳)
Microtask/Callback Queue = 대기 중인 장면들 (대기중인 작업)
Event Loop = 무대가 비었는지 확인하는 스태프 (다음 장면을 바로 올리는 스태프)</p>
<h3 id="실전-예제-순서-맞춰보세요">실전 예제: 순서 맞춰보세요!</h3>
<pre><code class="language-js">console.log(&quot;A&quot;);

setTimeout(() =&gt; {
  console.log(&quot;B - setTimeout&quot;);
}, 0);

Promise.resolve().then(() =&gt; {
  console.log(&quot;C - Promise&quot;);
});

(async () =&gt; {
  console.log(&quot;D - async start&quot;);
  await Promise.resolve();
  console.log(&quot;E - after await&quot;);
})();

console.log(&quot;F&quot;);</code></pre>
<h3 id="순서-해설">순서 해설</h3>
<pre><code class="language-js">A
D - async start
F
C - Promise
E - after await
B - setTimeout</code></pre>
<p>A, D, F는 동기
Promise.then() → 마이크로태스크 큐 C
await 이후 → 마이크로태스크 큐 E
setTimeout() → 콜백 큐 B</p>
<h3 id="정리">정리</h3>
<p>async 함수 await 전까지는 동기적으로 실행된다. await 이후 코드는 마이크로태스크 큐에 등록된다. 마이크로태스크 큐 Promise.then, await, 우선순위 높음 콜백 큐 (Task Queue) setTimeout, DOM 이벤트 등 이벤트 루프 콜 스택이 비면 큐에서 작업을 꺼내 실행한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Bun vs Node.js - Bun이 뭐야? JS 런타임 Bun이 Node.js를 대체할 수 있을까?]]></title>
            <link>https://velog.io/@while-true-study/Bun-vs-Node.js-JS-%EB%9F%B0%ED%83%80%EC%9E%84-Bun%EC%9D%B4-Node.js%EB%A5%BC-%EB%8C%80%EC%B2%B4%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@while-true-study/Bun-vs-Node.js-JS-%EB%9F%B0%ED%83%80%EC%9E%84-Bun%EC%9D%B4-Node.js%EB%A5%BC-%EB%8C%80%EC%B2%B4%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Mon, 10 Mar 2025 08:35:10 GMT</pubDate>
            <description><![CDATA[<p>출처: <a href="https://expertbeacon.com/a-quick-look-at-bun-1-0-the-node-js-alternative/">https://expertbeacon.com/a-quick-look-at-bun-1-0-the-node-js-alternative/</a></p>
<h2 id="0-bun이란">0. Bun이란?</h2>
<p>Bun은 JavaScript 개발 환경을 획기적으로 개선할 것을 목표로 만들어진 초고속 JavaScript 런타임입니다. Node.js, Deno와 같은 기존 런타임 대비 더 빠른 실행 속도와 가벼운 빌드 환경을 제공합니다.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/7bd231fc-efdf-44de-873f-db2a4bb52e78/image.png" alt=""></p>
<p>출처: <a href="https://snyk.io/blog/javascript-runtime-compare-node-deno-bun/">https://snyk.io/blog/javascript-runtime-compare-node-deno-bun/</a></p>
<h3 id="bun의-주요-특징">Bun의 주요 특징</h3>
<ul>
<li>고속 실행: Safari 엔진인 <strong>JSC(JavaScriptCore)</strong>를 기반으로 동작하여, 기존 Node.js(V8 엔진)보다 더 빠른 실행 속도를 가짐.</li>
<li>내장 패키지 관리자: <strong>npm</strong>, <strong>pnpm</strong> 없이도 <strong>bun install</strong>을 통해 빠르게 의존성을 설치 가능.</li>
<li>TS &amp; JSX 지원: 별도 설정 없이 TypeScript 및 JSX 파일 실행 가능.</li>
<li>네이티브 API 제공: <strong>fetch</strong>, <strong>WebSocket</strong>, <strong>FS 모듈</strong> 등 웹 API를 자체 지원.</li>
<li>번들러 &amp; 테스트 러너 내장: 추가적인 빌드 툴 없이 번들링 및 테스트 실행 가능.</li>
<li>Node.js와 높은 호환성: 대부분의 Node.js 모듈과 함께 사용 가능.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/9bbb07c3-46bc-4e8d-8623-28f84678e4ff/image.png" alt=""></p>
<p>출처: <a href="https://www.linkedin.com/pulse/bun-next-gen-javascript-runtime-jay-shah-cqvcf">https://www.linkedin.com/pulse/bun-next-gen-javascript-runtime-jay-shah-cqvcf</a></p>
<h2 id="1-bun-vs-nodejs-성능-차이">1. Bun vs Node.js 성능 차이</h2>
<p>Bun은 성능 면에서 기존 Node.js보다 강력한 경쟁력을 자랑합니다. 공식 벤치마크에 따르면 Bun은 Node.js보다 4배 빠른 실행 속도를 보여줍니다.</p>
<h3 id="실행-속도-비교-consoleloghi-실행-기준">실행 속도 비교 (console.log(&quot;hi&quot;) 실행 기준)</h3>
<ul>
<li><p>Bun: Node.js 16 대비 약 4.5배 빠름</p>
</li>
<li><p>Deno: Node.js보다 2배 빠름</p>
</li>
<li><p>Node.js: 비교적 느린 실행 속도</p>
</li>
</ul>
<h3 id="패키지-설치-속도-비교-bun-install-vs-npm-install">패키지 설치 속도 비교 (bun install vs npm install)</h3>
<p>Bun은 pnpm보다 빠른 패키지 설치 속도를 제공하며, 기존 npm 대비 최대 4배 빠름</p>
<h2 id="2-bun이-빠른-이유는">2. Bun이 빠른 이유는?</h2>
<p>Bun이 압도적인 속도를 자랑하는 이유는 다음과 같습니다.</p>
<h3 id="1-javascriptcore-jsc-사용">1. JavaScriptCore (JSC) 사용</h3>
<p>Bun은 V8 대신 JSC(JavaScriptCore) 엔진을 사용하여 빠른 Engine Start Time을 제공합니다.</p>
<p><strong>브라우저 엔진 비교</strong></p>
<ul>
<li><p>Chrome, Brave, Edge → V8 사용</p>
</li>
<li><p>Safari → JavaScriptCore(JSC) 사용 (Bun도 동일)</p>
</li>
<li><p>Firefox → SpiderMonkey 사용</p>
</li>
</ul>
<h3 id="2-zig-언어로-개발">2. Zig 언어로 개발</h3>
<p>Bun은 메모리 관리를 효율적으로 수행할 수 있는 Zig 언어로 개발되었습니다. Zig는 Rust처럼 안전성과 속도를 동시에 보장하는 언어이며, 이를 활용해 Bun은 최적화된 메모리 사용이 가능합니다.</p>
<h3 id="3-번들러--빌드-시스템-최적화">3. 번들러 &amp; 빌드 시스템 최적화</h3>
<p>Node.js는 별도 번들러(Webpack, Rollup, esbuild 등)가 필요하지만, Bun은 자체 번들러를 내장하여 빌드 속도가 더 빠릅니다.</p>
<h2 id="3-nodejs와의-호환성">3. Node.js와의 호환성</h2>
<p>Bun은 기본적으로 <strong>ES Modules (ESM)</strong>을 권장하지만, 기존 <strong>CommonJS (CJS)</strong>도 지원합니다. 따라서 기존 Node.js 프로젝트를 Bun으로 쉽게 마이그레이션할 수 있습니다.</p>
<h3 id="지원하는-기능">지원하는 기능:</h3>
<ul>
<li><p>require() 및 import 모두 지원</p>
</li>
<li><p>process.env 및 Buffer와 같은 Node.js 글로벌 객체 사용 가능</p>
</li>
<li><p>Node.js의 네이티브 모듈 대다수와 호환 가능</p>
</li>
</ul>
<h3 id="그러나-일부-패키지는-bun에서-완벽하게-동작하지-않을-수도-있습니다">그러나 일부 패키지는 Bun에서 완벽하게 동작하지 않을 수도 있습니다.</h3>
<h2 id="4-bun을-사용해야-할까">4. Bun을 사용해야 할까?</h2>
<p>Bun은 빠른 속도와 간편한 설정을 원하는 스타트업, 실험적인 프로젝트, 개인 개발자에게 매우 유용합니다.</p>
<h3 id="bun을-추천하는-경우">Bun을 추천하는 경우</h3>
<ul>
<li><p>빠른 실행 속도가 중요한 프로젝트 (예: CLI 툴, 서버리스 환경)</p>
</li>
<li><p>스타트업, 신규 프로젝트 (빠른 개발 &amp; 배포가 중요한 경우)</p>
</li>
<li><p>패키지 설치 속도를 개선하고 싶은 경우</p>
</li>
</ul>
<h3 id="nodejs가-여전히-유리한-경우">Node.js가 여전히 유리한 경우</h3>
<ul>
<li><p>안정성이 중요한 대규모 프로덕션 프로젝트</p>
</li>
<li><p>완벽한 호환성이 필요한 경우 (일부 패키지가 Bun에서 완전히 동작하지 않을 수 있음)</p>
</li>
<li><p>이미 Node.js 환경이 잘 구축된 팀</p>
</li>
</ul>
<h2 id="5-bun-설치-방법">5. Bun 설치 방법</h2>
<p>Bun을 설치하는 방법은 간단합니다. 터미널에서 다음 명령어를 실행하면 됩니다.</p>
<pre><code class="language-bash">curl -fsSL https://bun.sh/install | bash</code></pre>
<p>설치가 완료되면 bun 명령어를 사용할 수 있습니다.</p>
<pre><code class="language-bash">bun --version</code></pre>
<h2 id="6-bun-기반-초고속-프레임워크-elysiajs">6. Bun 기반 초고속 프레임워크: ElysiaJS</h2>
<p>Node.js의 Express, Fastify보다 훨씬 빠른 성능을 자랑하는 ElysiaJS가 Bun을 위한 서버 프레임워크로 주목받고 있습니다.</p>
<h3 id="elysiajs-생김새">ElysiaJS 생김새</h3>
<pre><code class="language-js">import { Elysia, t } from &#39;elysia&#39;;

new Elysia()
    .post(&#39;/sign-in&#39;, ({ body }) =&gt; signIn(body), {
        body: t.Object({
            username: t.String(),
            password: t.String()
        })
    })
    .listen(8080);</code></pre>
<h2 id="7-결론">7. 결론</h2>
<p>Bun은 빠른 실행 속도, 가벼운 패키지 관리, Zig 기반의 최적화된 메모리 사용을 통해 Node.js의 강력한 대안이 될 가능성이 높습니다. 다만 아직 호환성과 안정성이 100% 검증되지 않았기 때문에, 대규모 프로덕션 환경에서 사용하기 전에는 충분한 테스트 기간이 필요할 것 입니다.</p>
<h3 id="향후-전망">향후 전망</h3>
<ul>
<li><p>Bun 생태계가 성장한다면, 장기적으로 Node.js를 대체할 수도 있을 것 같습니다.</p>
</li>
<li><p>현재로서는 스타트업, 개인 프로젝트, 신규 애플리케이션에 적합할 것 같습니다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/4eb04a78-ba07-47db-97bf-b723c6905fbb/image.png" alt="">
출처: <a href="https://codingmart.com/bun-1-0-fast-javascript-runtime-with-built-in-tools/">https://codingmart.com/bun-1-0-fast-javascript-runtime-with-built-in-tools/</a></p>
<h2 id="마치며">마치며</h2>
<p>저는 이번에 Bun을 처음 알게 되었는데 여러분들은 알고 계셨나요? 이번에 처음 구글링을하며 js 런타임을 직접 만들 수 있다는 것에 놀랐습니다. 특히 Bun은 빠른 실행 속도, 메모리 최적화 등으로 사용할 만한 가치가 있다고 느꼈습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드 수정중인데 코드가 PR되었을 때 (git stash)]]></title>
            <link>https://velog.io/@while-true-study/git-hub-stash-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@while-true-study/git-hub-stash-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 20 Feb 2025 04:00:44 GMT</pubDate>
            <description><![CDATA[<p>그림 출처: <a href="https://sysxplore.substack.com/p/what-is-git-stash">https://sysxplore.substack.com/p/what-is-git-stash</a></p>
<p>PR(풀 리퀘스트)이 머지되거나 업데이트되었을 때, pull을 하면 <strong>작업 중인 변경 사항이 날아갈 가능성이 있는 상황</strong>이 있습니다.</p>
<h3 id="0-결론">0. 결론</h3>
<ul>
<li><code>git stash</code>    임시저장</li>
<li><code>git pull --rebase origin main</code> 업데이트</li>
<li><code>git stash pop</code> 임시저장 적용</li>
</ul>
<hr>
<h2 id="작업을-안전하게-유지하는-방법"><strong>작업을 안전하게 유지하는 방법</strong></h2>
<h3 id="1--변경-사항을-커밋하거나-스태시-하기">1. ** 변경 사항을 커밋하거나 스태시 하기**</h3>
<p>PR 업데이트 전에 작업이 날아가는 걸 방지하려면 <strong>커밋을 하거나 <code>git stash</code>로 임시 저장</strong>하는 게 좋습니다.</p>
<p><strong>아직 커밋할 준비가 안 되었다면 스태시하기</strong>
git stash를 하면 현재 변경 사항이 임시로 저장되고 워킹 디렉터리는 깨끗한 상태가 되니 놀라지 마세요.</p>
<pre><code class="language-bash">임시 저장하기
git stash
</code></pre>
<p><strong>커밋 하기</strong> (커밋 후에는 충돌을 해결할 가능성 있음)</p>
<pre><code class="language-bash">커밋하기
git add .
git commit -m &quot;add feature&quot;
</code></pre>
<hr>
<h3 id="2-업데이트된-pr-내용-가져오기-rebase-or-merge">2. <strong>업데이트된 PR 내용 가져오기 (rebase or merge)</strong></h3>
<p>1번째나 2번째 방법으로 해주시면 됩니다.</p>
<p>**  1. <code>git pull --rebase origin main</code> (pull 가져오기)**</p>
<pre><code class="language-bash">git pull --rebase origin main  # (혹은 master, develop 등)
</code></pre>
<ul>
<li>내 변경 사항을 PR 업데이트 위에 재적용함.</li>
<li>충돌이 나면 해결한 후 <code>git rebase --continue</code> 실행.</li>
</ul>
<p>리베이스는 내 커밋을 최신 업데이트 위에 올려서 히스토리를 일직선으로 유지하게 도와주며, 머지 커밋이 생성되지 않습니다.</p>
<p>**  2. <code>git pull</code> (머지)**</p>
<pre><code class="language-bash">git pull origin main
</code></pre>
<ul>
<li>기존 변경 사항과 PR 업데이트 내용을 병합.</li>
<li>필요하면 충돌을 해결하고 커밋.</li>
</ul>
<hr>
<h3 id="3-스태시-사용-시-내-작업-다시-적용">3. <strong>스태시 사용 시 내 작업 다시 적용</strong></h3>
<p>스태시를 사용했으면 둘중 하나로 스태시를 적용시켜 주시면 됩니다.</p>
<pre><code class="language-bash">임시저장 꺼내기
git stash pop  # 스태시에서 꺼내서 다시 적용
</code></pre>
<p>혹은</p>
<pre><code class="language-bash">임시저장 적용하기
git stash apply  # 스태시를 적용하지만, 삭제되지는 않음.
</code></pre>
<hr>
<h3 id="4-결론">4. <strong>결론</strong></h3>
<ul>
<li><p><code>git stash</code></p>
</li>
<li><p><code>git pull --rebase origin main</code></p>
</li>
<li><p><code>git stash pop</code></p>
</li>
<li><p>충돌나면 빠르게 reset 해줍시다...</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Expo go java.io.IOException: Failed to download remote update 오류 해결하는 방법]]></title>
            <link>https://velog.io/@while-true-study/Expo-go-java.io.IOException-Failed-to-download-remote-update-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@while-true-study/Expo-go-java.io.IOException-Failed-to-download-remote-update-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 13 Feb 2025 08:03:51 GMT</pubDate>
            <description><![CDATA[<h2 id="0-배경">0. 배경</h2>
<p>최근 Expo Go와 React Native를 연동하는 과정에서 java.io.IOException: Failed to download remote update라는 오류가 발생하여 해결해보았습니다.이번 글에서는 이 오류가 발생한 원인과 그에 대한 해결 방법을 공유하고자 합니다.</p>
<p>먼저 Expo go로 QR scan을 하고 오류가 난 뒤에 Viw error log를 눌러주세요
<img src="https://velog.velcdn.com/images/while-true-study/post/e44891b2-2cf2-49e5-8ca7-e996f156aad2/image.PNG" alt=""></p>
<p>저의 경우는 오류에 java.io.IOException: Failed to download remote update가 떴습니다.</p>
<h2 id="1-해결-방법-찾기">1. 해결 방법 찾기</h2>
<p>그래서 오랜 구글링을 하다가 <a href="https://www.reddit.com/r/expo/comments/1gr2tij/javaioioexception_failed_to_download_remote_update/?rdt=64267">https://www.reddit.com/r/expo/comments/1gr2tij/javaioioexception_failed_to_download_remote_update/?rdt=64267</a>
이런 래딧글을 발견했습니다. 저와 비슷한 오류를 겪고 있는 유저였고 여러 해결 의견들중에</p>
<blockquote>
<p><strong>Remarkable_Car_9916</strong>
Just a guess, but are you using windows? I think its very likely the windows defender which blocks the request. You do not see any logs in the terminal on the host machine where you start the expo app isnt it? if I am correct with my assumings you need to enable access to the node.js (javascript) runtime on your machine for the local network. activate both private and public networks.<br/>
Edit: Sometimes the settings can break without changing them! ie after a restart. I am not very sure about if it&#39;s a problem on expo side or on windows side, but anyway if it breaks, check the settings again. you might find a new node.js (javascript) runtime. if so activate both private and public network access as well. And it should work again. But this is just a quick workaround, not a fix of the problem itself.</p>
</blockquote>
<p>윈도우 Defender를 확인해보라는 글이었습니다. Window 방화벽 설정을 확인하고 변경해주니 바로 해결되었습니다. 아래는 해결 방법 단계입니다.</p>
<h2 id="1-windows-defender-방화벽-열기">1. Windows Defender 방화벽 열기</h2>
<h3 id="1-cmd로-열기">1. cmd로 열기</h3>
<p>Windows Key + R을 눌러서 실행을 열어주시고 wf.msc를 입력하시면 바로 방화벽이 열립니다.</p>
<h3 id="2-찾기로-열기">2. 찾기로 열기</h3>
<p>찾기에서 Windows Defender 방화벽을 쳐서 열어줍니다. <img src="https://velog.velcdn.com/images/while-true-study/post/210faa79-814f-4902-9606-814bb706b08e/image.png" alt="">
왼쪽 사이드바에서 고급 설정에 들어가주세요.</p>
<h2 id="2-인바운드-규칙-들어가기">2. 인바운드 규칙 들어가기</h2>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/f4e243e0-4eb0-442b-9c04-e804b44e358d/image.png" alt="">
왼쪽 로컬 컴퓨터의 고급 보안이 포함된 Windows Defender 방화벽에서 인바운드 규칙에 들어가주세요.</p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/eef0d3de-664a-4cdf-9f81-216ec0aae0b5/image.png" alt="">
여기에서 Node.js JavaScript Runtime을 찾아주세요. 여기서 빨간 경고 아이콘이 있으면 연결이 차단되어 있는 것입니다. 모두 V 표시가 되어있다면 문제가 없으신 것입니다.</p>
<h2 id="3-연결-허용">3. 연결 허용</h2>
<p>경고 아이콘인 Node.js JavaScript Runtime 더블클릭
<img src="https://velog.velcdn.com/images/while-true-study/post/0d23205b-01e9-4109-88e7-8c1733b68fa5/image.png" alt=""></p>
<p>작업에 연결 허용(L) 눌러주시면 됩니다.</p>
<h2 id="4-재시도">4. 재시도</h2>
<pre><code>expo start</code></pre><p>다시 Expo Start 해서 QR찍어보시면 됩니다.</p>
<p>캐시 초기화 해서 하기</p>
<pre><code>expo start --clear</code></pre><p>오류가 해결 되셨으면 좋겠습니다. 고생하셨습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[왜 페이스북은 Git을 사용하지 않을까 - 대부분의 개발자는 Git을 사용할까 (번역글)]]></title>
            <link>https://velog.io/@while-true-study/%EC%99%9C-%ED%8E%98%EC%9D%B4%EC%8A%A4%EB%B6%81%EC%9D%80-Git%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EC%95%8A%EC%9D%84%EA%B9%8C-%EB%8C%80%EB%B6%80%EB%B6%84%EC%9D%98-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%8A%94-Git%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C-%EB%B2%88%EC%97%AD%EA%B8%80</link>
            <guid>https://velog.io/@while-true-study/%EC%99%9C-%ED%8E%98%EC%9D%B4%EC%8A%A4%EB%B6%81%EC%9D%80-Git%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%A7%80-%EC%95%8A%EC%9D%84%EA%B9%8C-%EB%8C%80%EB%B6%80%EB%B6%84%EC%9D%98-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%8A%94-Git%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C-%EB%B2%88%EC%97%AD%EA%B8%80</guid>
            <pubDate>Thu, 06 Feb 2025 07:35:45 GMT</pubDate>
            <description><![CDATA[<p>Facebook, by solomon7 via Shutterstock</p>
<p>페이스북의 내부 개발은 처음에 Subversion을 사용하고 Git 미러를 운영하는 방식으로 <a href="https://engineering.fb.com/2014/01/07/core-infra/scaling-mercurial-at-facebook/">시작했으나</a>, 이후 소스 코드 관리를 위해 Mercurial로 전환했다. 이에 대해 페이스북에서 일했던 일부 개발자들은 &quot;압도적으로 최고의 개발 도구&quot;라고 평가했지만, 대부분의 개발자는 여전히 Git을 사용하고 있으며, 이러한 흐름이 바뀔 가능성은 낮다.</p>
<p>Graphite라는 코드 리뷰 도구 회사의 공동 창립자이자 CTO인 Greg Foster는 지난 3월 <a href="https://graphite.dev/blog/why-facebook-doesnt-use-git">페이스북이 Git을 사용하지 않는 이유</a>에 대해 글을 작성했다. 그는 페이스북에서 근무한 적은 없지만, Graphite 도구들이 페이스북 내부 개발 환경에서 영감을 얻었다고 밝혔다. 그는 페이스북이 왜 Git을 사용하지 않는지를 조사했다.</p>
<p>그 이유는 규모의 문제였다. 페이스북은 방대한 코드베이스를 가지고 있으며, 모든 코드가 하나의 저장소에 포함되는 모노레포(monorepo) 방식을 사용하고 있다. 엔지니어들은 코드베이스가 계속 증가할 경우 어떤 문제가 발생할지 예측했고, 기본적인 Git 명령어 실행에 45분 이상이 소요된다는 사실을 발견했다.</p>
<p>페이스북은 Git 유지보수팀에 해결책을 문의했지만, 답변은 코드베이스를 여러 개의 저장소로 나누라는 것이었다. 그러나 페이스북은 Mercurial 팀과 논의했고, Mercurial은 협력할 의사를 보였다. 그 결과 페이스북은 Mercurial의 성능을 개선하는 데 기여했고, 대규모 모노레포를 관리하는 데 가장 적합한 버전 관리 시스템이 되었다고 Foster는 전했다.</p>
<p>어떤 개발자는 Reddit에서 &quot;여러 회사를 다녀봤지만, 메타(Meta)의 개발 도구가 단연 최고였다&quot;고 <a href="https://www.reddit.com/r/programming/comments/1e3fwyl/comment/ldbcmbs/">말했다.</a> 또 다른 개발자는 &quot;나는 지금 메타에서 일하고 있는데, 솔직히 시스템이 너무 특이하지만 실제로는 잘 작동한다&quot;고 <a href="https://www.reddit.com/r/programming/comments/1e3fwyl/comment/ld8tgzt/">평가했다.</a></p>
<p>그렇다면 다른 개발자들도 Mercurial을 사용해야 할까? 가능성은 있지만, Foster는 &quot;10년이 지난 지금, Git도 모노레포를 더 잘 지원하도록 상당한 개선이 이루어졌다&quot;고 지적했다.</p>
<p>다만, 페이스북은 일반적인 사례와 다르다. 페이스북은 방대한 자원을 투자해 자체 개발 도구를 최적화할 수 있기 때문이다. 현재 페이스북은 Git을 완전히 배제한 것은 아니며, <a href="https://github.com/facebook/sapling">Sapling SCM</a>이라는 Git 호환 도구를 일부 사용하고 있다. 이 도구의 CLI는 원래 Mercurial 기반으로 개발되었으며, EdenFS라는 가상 파일 시스템을 포함하고 있다. EdenFS는 메타의 프로덕션 환경에서는 사용되지만, 오픈소스 환경에서는 빌드되지 않으며 외부에서 사용할 수 없다. 즉, 페이스북 내부에서는 효과적이지만, 일반 개발자들이 사용하기에는 적합하지 않은 맞춤형 도구인 것이다.</p>
<p>Git은 복잡하고 직관적이지 않은 경우가 많다. 예를 들어, 특정 파일을 .gitignore 목록에 추가하더라도 이미 추적 중인 파일은 자동으로 Git에서 제외되지 않는다. Stack Overflow에서 9,000개 이상의 추천을 받은 <a href="https://stackoverflow.com/questions/1274057/how-do-i-make-git-forget-about-a-file-that-was-tracked-but-is-now-in-gitignore">답변</a>에 따르면, 추적을 중지하려면 다음과 같이 명령어를 실행해야 한다.</p>
<pre><code class="language-bash">git rm --cached &lt;file&gt;</code></pre>
<p>하지만 이 명령어를 실행하면, 다른 개발자가 git pull을 할 때 해당 파일이 삭제된다는 문제가 있다. 개발자가 로컬에서 해당 파일을 유지하면서 Git에서만 제외하려면 추가적인 조치가 필요하다.</p>
<p>그럼에도 불구하고 Git의 성장세는 멈출 기미가 보이지 않는다. 2020년 Atlassian은 Bitbucket Cloud에서 Mercurial 지원을 중단하고, <a href="https://bitbucket.org/blog/sunsetting-mercurial-support-in-bitbucket">Git을 기본 버전 관리 시스템으로 채택했다.</a> 2022년 Apple의 Safari에 사용되는 WebKit 오픈소스 프로젝트가 Subversion에서 GitHub로 이동했다. 2023년 Mozilla는 Firefox 개발을 Mercurial에서 GitHub로 <a href="https://devclass.com/2023/11/07/mozilla-will-move-firefox-development-from-mercurial-to-microsofts-github/">이전했다.</a></p>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/6dc444e6-f104-483a-a8d3-340d64416472/image.png" alt=""></p>
<p>Git usage share according to a 2022 StackOverflow survey</p>
<p>현재 Stack Overflow의 설문조사에 따르면, Git은 <strong>93.87%</strong>의 점유율을 차지하고 있으며, 그다음으로 많이 사용되는 Subversion은 <strong>5.18%</strong>에 불과하다.</p>
<p>개발자들이 Git을 선택하는 이유 중 하나는 Git이 이미 표준이 되었기 때문이다. Git의 단점에도 불구하고, 대부분의 개발자가 Git을 알고 있기 때문에 새로운 개발자를 빠르게 온보딩할 수 있다. 또한, GitHub와 GitLab 같은 플랫폼이 제공하는 이슈 관리, 토론 기능, 보안 기능, CI/CD(지속적 통합 및 배포) 지원 등이 Git의 가치를 더욱 높이고 있다.</p>
<p>출처 : <a href="https://devclass.com/2024/07/17/why-facebook-does-not-use-git-and-why-most-other-devs-do/">https://devclass.com/2024/07/17/why-facebook-does-not-use-git-and-why-most-other-devs-do/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Three.js를 이용해 웹사이트를 예쁘게 꾸며보자! – 5 (Camera)]]></title>
            <link>https://velog.io/@while-true-study/Three.js%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EC%98%88%EC%81%98%EA%B2%8C-%EA%BE%B8%EB%A9%B0%EB%B3%B4%EC%9E%90-5-Camera</link>
            <guid>https://velog.io/@while-true-study/Three.js%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EC%98%88%EC%81%98%EA%B2%8C-%EA%BE%B8%EB%A9%B0%EB%B3%B4%EC%9E%90-5-Camera</guid>
            <pubDate>Wed, 05 Feb 2025 07:05:59 GMT</pubDate>
            <description><![CDATA[<h2 id="지난글">지난글</h2>
<ul>
<li>Material</li>
<li>MeshBasicMaterial</li>
<li>MeshLambertMaterial</li>
<li>MeshPhongMaterial</li>
<li>MeshToonMaterial</li>
<li>MeshStandardMaterial</li>
<li>MeshPhysicalMaterial</li>
</ul>
<h2 id="이어서">이어서</h2>
<p>지난 포스트에서는 주로 많이 쓰이는 텍스쳐들을 알아봤는데요. 이번 포스트에서는 카메라에 대해 알아보겠습니다. 카메라는 저희가 보는 눈을 담당하기 때문에 많이 중요합니다. 이번에는 카메라 개념을 빠르게 알아보고 예제 하나를 해보고 마무리할께요.</p>
<h2 id="0camera">0.Camera</h2>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/4e68da84-9538-436e-8a92-5e958a09a372/image.png" alt=""></p>
<p>출처 : <a href="https://www.tutorialspoint.com/threejs/threejs_perspectivecamera.htm">https://www.tutorialspoint.com/threejs/threejs_perspectivecamera.htm</a></p>
<p><strong>fov</strong>는 field of view(시야각)의 줄임말입니다. 예제의 경우 수직면 75도로 설정했습니다. 알아둬야 할 건 Three.js의 대부분이 각도 단위로 호도(radians)를 사용하는데, 원근 카메라만 특이하게 도(degrees)를 인자로 받는다는 점입니다.</p>
<p>aspect는 canvas의 가로 세로 비율입니다. 이는 다른 글 에서 자세히 다루겠지만, 기본 설정으로 canvas의 크기는 300x150이니 비율도 300/150, 2로 설정했습니다.</p>
<p><strong>Near</strong>와 <strong>Far</strong>는 카메라 앞에 렌더링되는 공간 범위를 지정하는 요소입니다. 이 공간 바깥에 있는 요소는 화면에서 잘려나가며, 렌더링되지 않을 것입니다.</p>
<p>위에서 살펴본 4가지 속성은 하나의 &quot;절두체&quot;를 만듭니다. &quot;절두체&quot;는 끝부분이 잘려나간 피라미드처럼 생긴 3차원 모양인데, 구, 정육면체, 각기둥(prism)처럼 3차원 모양의 하나로 생각하면 됩니다.</p>
<p>결국 위의 사진을 보면 Near와 Far사이의 공간만 저희 카메라로 볼 수 있다는 뜻입니다. Mesh를 조절하다가 갑자기 안보이면 Carmera 설정도 확인해보면 좋습니다.</p>
<h2 id="1사용법">1.사용법</h2>
<p>사실 저희 예제에도 계속 있었습니다.</p>
<pre><code class="language-js">const fov = 70;
const aspect = window.innerWidth / window.innerHeight;
const near = 0.1;
const far = 100;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);</code></pre>
<p>바로 이부분인데요 near는 0.1, far은 100 즉 position 0.1~100정도 볼 수 있었던거예요. 저런식으로 4가지 요소를 주고 new THREE.PerspectiveCamera 를 해주면 됩니다. PerspectiveCamera는 원근법이 적용된 카메라인데요 원근법을 없애고 싶으면 OrthographicCamera 정사영 카메라를 사용하시면 됩니다.</p>
<h2 id="2예제-코드">2.예제 코드</h2>
<p>예제코드는 전에 쓰던 예제코드를 변형해서 만들어볼께요</p>
<pre><code class="language-html">index.html
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;Document&lt;/title&gt;
  &lt;/head&gt;
  &lt;body style=&quot;margin: 0px&quot;&gt;
    &lt;canvas id=&quot;c&quot;&gt;&lt;/canvas&gt;
    &lt;script type=&quot;module&quot; src=&quot;./index.js&quot;&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<pre><code class="language-js">index.js
import * as THREE from &#39;./node_modules/three/build/three.module.js&#39;;

const canvas = document.querySelector(&#39;#c&#39;);
const renderer = new THREE.WebGLRenderer({ antialias: true, canvas });
renderer.setSize(window.innerWidth, window.innerHeight);
const fov = 70;
const aspect = window.innerWidth / window.innerHeight;
const near = 0.1;
const far = 100;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.z = 10;

const scene = new THREE.Scene();

const color = 0xffffff;
const intensity = 10;
const light = new THREE.DirectionalLight(color, intensity);
light.position.set(0, 10, 0);
scene.add(light);

const geometry = new THREE.SphereGeometry(1);
const material = new THREE.MeshBasicMaterial({
  color: &#39;#049ef4&#39;,
  clearcoat: 1, // 빤딱빤딱
  clearcoatRoughness: 0.5,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

const geometry2 = new THREE.SphereGeometry(2);
const material2 = new THREE.MeshBasicMaterial({
  color: &#39;red&#39;,
  clearcoat: 1, // 빤딱빤딱
  clearcoatRoughness: 0.5,
});
const mesh2 = new THREE.Mesh(geometry2, material2);
mesh2.position.set(0, 0, 0);
scene.add(mesh2);

let angle = 0;
const radius = 5; // 공전 반경
const speed = 0.01; // 회전 속도

function animate() {
  angle += speed;
  mesh.position.x = Math.cos(angle) * radius;
  mesh.position.z = Math.sin(angle) * radius;

  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

animate();</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/63759c75-6007-4ea6-ba6a-aadbf5858487/image.gif" alt=""></p>
<p>이렇게 만들어주고 보면 이렇게 태양을 도는 지구처럼 빙글빙글 돌게 됩니다. 그냥 도는거 보는건 식상해서 이번엔 저 원을 한번 따라다녀보도록 하겠습니다.</p>
<h2 id="3lookat">3.lookAt</h2>
<p>lookAt은 카메라가 보는 위치를 정할 수 있는 함수입니다. lookAt(position[])을 넣어주면 그 좌표를 쳐다보게됩니다. 이것을 활용해보겠습니다. 아래쪽 animate 부분을 살짝 수정해주세요</p>
<pre><code class="language-js">let angle = 0;
const radius = 5;
const speed = 0.01;

function animate() {
  angle += speed;
  mesh.position.x = Math.cos(angle) * radius;
  mesh.position.z = Math.sin(angle) * radius;

  camera.lookAt(mesh.position);

  renderer.render(scene, camera);

  requestAnimationFrame(animate);
}
animate();
</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/84162b92-edb3-47eb-a721-8a9b3a753a64/image.gif" alt=""></p>
<p>카메라가 지구를 따라다니는 것 보이시나요? 이런식으로 mesh의 좌표를 줘서 쳐다보게끔 할 수 있습니다.</p>
<h2 id="4cameraposition">4.camera.position</h2>
<p>카메라가 있는 위치또한 바꿔줄 수 있습니다. camera.position = position(,,) 줄수 있고 그 위치로 카메라가 가게됩니다. position과 lookAt을 활용해서 만들어 볼게요. 지구 위를 따라다니면서 태양을 찍어보겠습니다.</p>
<pre><code class="language-js">let angle = 0;
const radius = 5;
const speed = 0.01;

function animate() {
  angle += speed;
  mesh.position.x = Math.cos(angle) * radius;
  mesh.position.z = Math.sin(angle) * radius;

  const cameraOffset = 3;
  camera.position.x = mesh.position.x + Math.cos(angle) * cameraOffset;
  camera.position.z = mesh.position.z + Math.sin(angle) * cameraOffset;
  camera.position.y = mesh.position.y + 3;
  camera.lookAt(mesh2.position);

  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

animate();</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/0368d19f-6211-4f52-8771-b8a52233af0e/image.gif" alt=""></p>
<p>하지만 뭔가 아직 아쉬워서 조금 더 다이나믹하게 만들어볼게요.</p>
<pre><code class="language-js">import * as THREE from &#39;./node_modules/three/build/three.module.js&#39;;

const canvas = document.querySelector(&#39;#c&#39;);
const renderer = new THREE.WebGLRenderer({ antialias: true, canvas });
renderer.setSize(window.innerWidth, window.innerHeight);
const fov = 70;
const aspect = window.innerWidth / window.innerHeight;
const near = 0.1;
const far = 100;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 5, 10);

const scene = new THREE.Scene();
const light = new THREE.DirectionalLight(0xffffff, 2, 50);
light.position.set(0, 3, 0);
scene.add(light);
// 지구 (Mesh)
const geometry = new THREE.SphereGeometry(1);
const material = new THREE.MeshBasicMaterial({ color: &#39;#049ef4&#39; });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 태양 (Mesh2)
const geometry2 = new THREE.SphereGeometry(2);
const material2 = new THREE.MeshBasicMaterial({ color: &#39;red&#39; });
const mesh2 = new THREE.Mesh(geometry2, material2);
mesh2.position.set(0, 0, 0);
scene.add(mesh2);

let angle = 0;
const radius = 5;
const speed = 0.01;
const cameraPositions = [
  { x: 7, y: 5, z: 6 },
  { x: -6, y: 3, z: 7 },
  { x: 6, y: 1, z: -7 },
  { x: -7, y: 0, z: 6 },
];

let currentPositionIndex = 0;
function moveCameraToNextPosition() {
  currentPositionIndex = (currentPositionIndex + 1) % cameraPositions.length;
}
function animate() {
  angle += speed;
  mesh.position.x = Math.cos(angle) * radius;
  mesh.position.z = Math.sin(angle) * radius;

  const targetPosition = cameraPositions[currentPositionIndex];
  camera.position.x = targetPosition.x;
  camera.position.y = targetPosition.y;
  camera.position.z = targetPosition.z;
  camera.lookAt(mesh.position);

  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}
//2초마다 바꾸기
setInterval(moveCameraToNextPosition, 2000);
animate();</code></pre>
<p><img src="https://velog.velcdn.com/images/while-true-study/post/269d1498-c504-4425-a3e2-01a60798d00d/image.gif" alt=""></p>
<p>2초마다 시점을 바꾸면서 지구를 따라가게 됩니다. 조금 다이나믹해진 것 같습니다.</p>
<h2 id="정리">정리</h2>
<p>이번 포스트에서는 카메라에 대해 다뤄봤습니다. 카메라는 3D 씬에서 우리가 보는 눈이기 때문에 굉장히 중요한 요소입니다.</p>
<ul>
<li>원근법(Perspective) 카메라와 정사영(Orthographic) 카메라로 나누어 집니다.</li>
<li>카메라 기본 속성 : fov, aspect, near, far</li>
<li>camera.lookAt()을 사용해 카메라가 항상 특정 객체를 쳐다보도록 만들 수 있음.</li>
<li>camera.position을 직접 설정하여 카메라의 위치를 변경할 수 있습니다.</li>
</ul>
<p>감사합니다. 오늘도 굉장히 길었는데 고생하셨어요.</p>
]]></description>
        </item>
    </channel>
</rss>