<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://jude-z.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jude-z.github.io/" rel="alternate" type="text/html" /><updated>2026-04-09T23:20:08+00:00</updated><id>https://jude-z.github.io/feed.xml</id><title type="html">jude-z</title><subtitle>jude-z&apos;s developer blog</subtitle><author><name>jude-z</name></author><entry xml:lang="ko"><title type="html">G1 GC — Region 기반 메모리 구조와 수집 과정 정리</title><link href="https://jude-z.github.io/dev%20notes/g1-gc-region-memory-layout/" rel="alternate" type="text/html" title="G1 GC — Region 기반 메모리 구조와 수집 과정 정리" /><published>2026-03-30T00:00:00+00:00</published><updated>2026-03-30T00:00:00+00:00</updated><id>https://jude-z.github.io/dev%20notes/g1-gc-region-memory-layout</id><content type="html" xml:base="https://jude-z.github.io/dev%20notes/g1-gc-region-memory-layout/"><![CDATA[<blockquote>
  <p><strong>GC Deep Dive 시리즈</strong>
1편. G1 GC — Region 기반 메모리와 수집 과정 <em>(현재 글)</em>
<a href="/dev%20notes/zgc-colored-pointer-concurrent/">2편. ZGC — Colored Pointer와 동시 수집</a></p>
</blockquote>

<hr />

<h2 id="왜-g1-gc인가">왜 G1 GC인가</h2>

<p>JDK 9부터 기본 GC가 G1으로 바뀌었다. CMS가 deprecated 된 이후로 사실상 프로덕션에서 가장 많이 쓰이는 GC다. 그런데 막상 “Region이 뭔데?”라고 물으면 정확하게 설명하기 어려웠다. 이번에 제대로 정리해본다.</p>

<h2 id="region-기반-메모리-구조">Region 기반 메모리 구조</h2>

<p><img src="/assets/images/jvm/g1-gc-architecture.png" alt="G1 GC Region Based Memory Layout" /></p>

<p>기존 GC(Serial, Parallel, CMS)는 힙을 Young(Eden + Survivor)과 Old로 물리적으로 나눈다. 연속된 메모리 블록이라 Old 영역이 크면 Full GC 시간이 길어질 수밖에 없다.</p>

<p>G1은 접근이 다르다. <strong>힙 전체를 동일 크기의 Region으로 잘게 쪼갠다.</strong> 기본적으로 1MB~32MB 사이인데, 힙 크기에 따라 JVM이 자동으로 결정한다. 보통 2048개 내외의 Region이 만들어진다.</p>

<p>각 Region은 다음 중 하나의 역할을 가진다:</p>

<table>
  <thead>
    <tr>
      <th>Region 타입</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Eden</strong></td>
      <td>새 객체가 할당되는 영역</td>
    </tr>
    <tr>
      <td><strong>Survivor</strong></td>
      <td>Young GC에서 살아남은 객체가 복사되는 영역</td>
    </tr>
    <tr>
      <td><strong>Old</strong></td>
      <td>오래 살아남은 객체가 승격되는 영역</td>
    </tr>
    <tr>
      <td><strong>Humongous</strong></td>
      <td>Region 크기의 50%를 초과하는 대형 객체 전용</td>
    </tr>
    <tr>
      <td><strong>Free</strong></td>
      <td>아직 어떤 역할도 할당되지 않은 빈 영역</td>
    </tr>
  </tbody>
</table>

<p>핵심은 <strong>역할이 고정이 아니라는 점</strong>이다. 한 Region이 지금은 Eden이지만, GC 후에 Free가 되고, 나중에 Old가 될 수 있다. 이 유연성 때문에 G1이 pause time을 예측 가능하게 제어할 수 있다.</p>

<h3 id="humongous-region-주의점">Humongous Region 주의점</h3>

<p>Region 크기의 50% 이상인 객체는 Humongous로 분류된다. 문제는 Humongous 객체가 연속된 여러 Region을 차지할 수 있다는 것이다. 예를 들어 Region이 4MB인데 10MB 객체가 들어오면 3개의 연속 Region을 사용한다.</p>

<p>Humongous 객체는 Young GC에서 수집되지 않고, Mixed GC나 Full GC에서만 회수된다. 그래서 큰 byte 배열이나 컬렉션을 무분별하게 만들면 Humongous 할당이 빈번해지고, GC 효율이 떨어진다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Humongous 할당이 발생하는 전형적인 케이스</span>
<span class="kt">byte</span><span class="o">[]</span> <span class="n">largeBuffer</span> <span class="o">=</span> <span class="k">new</span> <span class="kt">byte</span><span class="o">[</span><span class="mi">4</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span><span class="o">];</span> <span class="c1">// 4MB — Region 크기에 따라 Humongous</span>

<span class="c1">// 이런 패턴은 피하자</span>
<span class="nc">List</span><span class="o">&lt;</span><span class="kt">byte</span><span class="o">[]&gt;</span> <span class="n">chunks</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ArrayList</span><span class="o">&lt;&gt;();</span>
<span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">100</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
    <span class="n">chunks</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="k">new</span> <span class="kt">byte</span><span class="o">[</span><span class="mi">2</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span><span class="o">]);</span> <span class="c1">// 반복적인 대형 할당</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="remember-set-rset과-card-table">Remember Set (RSet)과 Card Table</h2>

<p>Region이 독립적으로 수집되려면 한 가지 문제를 풀어야 한다. <strong>다른 Region에서 이 Region 안의 객체를 참조하고 있는지</strong> 어떻게 알 것인가?</p>

<p>Young GC를 한다고 하자. Eden Region의 객체가 살아있는지 확인하려면 Old Region에서 참조하는지도 봐야 한다. 그렇다고 Old 전체를 스캔하면 Young GC의 의미가 없다.</p>

<p>이 문제를 해결하는 게 <strong>RSet(Remembered Set)</strong>이다.</p>

<p>각 Region은 자신만의 RSet을 가진다. RSet에는 <strong>“나를 참조하는 다른 Region의 카드 정보”</strong>가 기록되어 있다. 여기서 카드(Card)는 512바이트 단위의 메모리 블록이다.</p>

<p>동작 방식을 좀 더 구체적으로 보면:</p>

<ol>
  <li>Old Region의 객체 A가 Eden Region의 객체 B를 참조한다</li>
  <li>이 참조가 생기는 시점에 <strong>Write Barrier</strong>가 작동한다</li>
  <li>A가 위치한 카드를 dirty로 마킹한다</li>
  <li>별도의 <strong>Refinement Thread</strong>가 dirty 카드를 처리해서 B가 속한 Region의 RSet에 기록한다</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Old Region [Card 0][Card 1][Card 2 - dirty]...[Card N]
                              |
                              ↓
Eden Region의 RSet: "Old Region의 Card 2에서 나를 참조함"
</code></pre></div></div>

<p>이렇게 하면 Young GC 때 Old 전체를 스캔할 필요 없이, <strong>RSet만 보면 외부 참조를 빠르게 파악</strong>할 수 있다. 단점은 RSet 자체가 힙의 일부를 차지한다는 것이다. 보통 힙의 5~10% 정도를 RSet이 사용한다.</p>

<h2 id="g1-gc-collection-과정">G1 GC Collection 과정</h2>

<p>G1의 수집은 크게 <strong>Young GC</strong>와 <strong>Mixed GC</strong> 두 가지로 나뉜다.</p>

<h3 id="young-gc">Young GC</h3>

<p>Eden Region이 가득 차면 발생한다. 모든 Eden과 Survivor Region이 대상이다.</p>

<ol>
  <li>STW(Stop-The-World) 발생</li>
  <li>GC Root에서 시작해서 살아있는 객체를 탐색</li>
  <li>살아있는 객체를 새로운 Survivor Region으로 복사 (age가 임계치를 넘으면 Old로 승격)</li>
  <li>기존 Eden/Survivor Region은 Free로 반환</li>
</ol>

<p>Young GC는 비교적 빠르다. Eden Region 수를 조절해서 pause time 목표(<code class="language-plaintext highlighter-rouge">-XX:MaxGCPauseMillis</code>, 기본 200ms)에 맞추려 한다.</p>

<h3 id="mixed-gc-concurrent-marking--mixed-collection">Mixed GC (Concurrent Marking + Mixed Collection)</h3>

<p>Old 영역 사용량이 <code class="language-plaintext highlighter-rouge">InitiatingHeapOccupancyPercent</code>(기본 45%)를 넘으면 Concurrent Marking이 시작되고, 이후 Mixed GC가 수행된다.</p>

<p><strong>Phase 1: Initial Mark (STW)</strong></p>

<p>GC Root에서 직접 참조하는 객체만 마킹한다. Young GC에 편승(piggyback)해서 실행되기 때문에 별도의 STW가 추가되지 않는다.</p>

<p><strong>Phase 2: Root Region Scan</strong></p>

<p>Survivor Region을 스캔해서 Old Region으로의 참조를 찾는다. 애플리케이션 스레드와 동시에 실행된다. 다음 Young GC가 시작되기 전에 반드시 완료되어야 한다.</p>

<p><strong>Phase 3: Concurrent Marking</strong></p>

<p>힙 전체에서 살아있는 객체를 마킹한다. 애플리케이션과 동시에 실행된다. 이때 <strong>SATB(Snapshot-At-The-Beginning)</strong> 방식을 사용한다.</p>

<p>SATB는 마킹 시작 시점의 객체 그래프 스냅샷을 기준으로 “그 시점에 살아있던 객체는 모두 살아있다고 간주”하는 전략이다. Concurrent Marking 도중 애플리케이션이 참조를 변경하면, <strong>pre-write barrier</strong>가 변경 전 참조값을 SATB 큐에 기록한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Concurrent Marking 중 참조 변경 시
obj.field = newRef;
// → pre-write barrier가 oldRef를 SATB 큐에 push
// → oldRef가 가리키던 객체는 이번 사이클에서 살아있다고 간주
</code></pre></div></div>

<p>이 방식의 장점은 마킹 도중 참조가 끊겨도 객체가 잘못 수집되는 일이 없다는 것이다. 대신 floating garbage(이미 죽었지만 이번에 못 거둔 객체)가 약간 발생할 수 있다. 다음 사이클에서 회수되니까 큰 문제는 아니다.</p>

<p><strong>Phase 4: Remark (STW)</strong></p>

<p>SATB 큐에 남아있는 참조들을 처리하고, 마킹을 최종 확정한다. 짧은 STW가 발생한다.</p>

<p><strong>Phase 5: Cleanup / Evacuation</strong></p>

<ul>
  <li>각 Region의 살아있는 객체 비율을 계산한다</li>
  <li>가비지가 많은 Region을 우선적으로 수집 대상에 넣는다 (<strong>Garbage First</strong>의 이름 유래)</li>
  <li>선택된 Region의 살아있는 객체를 다른 Region으로 복사(Evacuation)하고, 원래 Region은 Free로 반환한다</li>
</ul>

<p>Mixed GC는 Young Region과 선택된 Old Region을 함께 수집한다. 한 번에 모든 Old Region을 처리하지 않고, pause time 목표에 맞춰 몇 개씩 나눠서 처리한다.</p>

<h2 id="young-gc-vs-mixed-gc-정리">Young GC vs Mixed GC 정리</h2>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>Young GC</th>
      <th>Mixed GC</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>트리거</td>
      <td>Eden 영역 가득 참</td>
      <td>Old 사용률 &gt; IHOP(45%)</td>
    </tr>
    <tr>
      <td>대상</td>
      <td>Eden + Survivor</td>
      <td>Eden + Survivor + 선택된 Old</td>
    </tr>
    <tr>
      <td>Concurrent Marking</td>
      <td>불필요</td>
      <td>필수 (수집 대상 선정용)</td>
    </tr>
    <tr>
      <td>pause time</td>
      <td>짧음 (수~수십 ms)</td>
      <td>상대적으로 김 (수십~수백 ms)</td>
    </tr>
  </tbody>
</table>

<h2 id="튜닝-포인트">튜닝 포인트</h2>

<p>실무에서 자주 건드리는 옵션 몇 가지:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># pause time 목표 (기본 200ms)</span>
<span class="nt">-XX</span>:MaxGCPauseMillis<span class="o">=</span>100

<span class="c"># Concurrent Marking 시작 임계치 (기본 45%)</span>
<span class="nt">-XX</span>:InitiatingHeapOccupancyPercent<span class="o">=</span>35

<span class="c"># Mixed GC에서 한 번에 수집할 Old Region 최대 수 (기본 8)</span>
<span class="nt">-XX</span>:G1MixedGCCountTarget<span class="o">=</span>8

<span class="c"># Region 크기 직접 지정 (보통 자동에 맡김)</span>
<span class="nt">-XX</span>:G1HeapRegionSize<span class="o">=</span>4m
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MaxGCPauseMillis</code>를 너무 낮게 잡으면 Young Region 수가 줄어들어 Young GC가 자주 발생하고, Old 승격이 빨라져서 오히려 Mixed GC나 Full GC가 늘어날 수 있다. 적정 값을 찾는 게 중요하다.</p>

<h2 id="마무리">마무리</h2>

<p>G1 GC의 핵심은 결국 <strong>“힙을 Region으로 쪼개서, 가비지가 많은 Region부터 선택적으로 수집한다”</strong>는 것이다. RSet으로 Region 간 참조를 추적하고, SATB로 Concurrent Marking의 정확성을 보장한다. 완벽하지는 않지만(RSet 오버헤드, Humongous 문제 등), 대부분의 서버 워크로드에서 합리적인 성능을 보여준다.</p>

<p>다음 글에서는 G1의 한계를 넘어서려는 ZGC의 Colored Pointer와 sub-millisecond pause에 대해 정리할 예정이다.</p>

<h2 id="reference">Reference</h2>

<ul>
  <li><a href="https://www.alibabacloud.com/blog/601536">Alibaba Cloud - JVM GC Deep Dive</a></li>
</ul>]]></content><author><name>jude-z</name></author><category term="Dev Notes" /><category term="GC" /><category term="JVM" /><summary type="html"><![CDATA[G1 GC의 Region 분할 방식, RSet/Card Table 동작, Young GC와 Mixed GC 수집 과정을 정리한다.]]></summary></entry><entry xml:lang="ko"><title type="html">JVM 아키텍처 개요와 런타임 데이터 영역 정리</title><link href="https://jude-z.github.io/dev%20notes/jvm-architecture-runtime-data/" rel="alternate" type="text/html" title="JVM 아키텍처 개요와 런타임 데이터 영역 정리" /><published>2026-03-23T00:00:00+00:00</published><updated>2026-03-23T00:00:00+00:00</updated><id>https://jude-z.github.io/dev%20notes/jvm-architecture-runtime-data</id><content type="html" xml:base="https://jude-z.github.io/dev%20notes/jvm-architecture-runtime-data/"><![CDATA[<blockquote>
  <p><strong>JVM Internals 시리즈</strong>
1편. JVM 아키텍처와 런타임 데이터 영역 <em>(현재 글)</em>
<a href="/dev%20notes/jvm-classloader-execution-engine/">2편. 클래스 로딩과 실행 엔진</a>
<a href="/dev%20notes/jvm-thread-stack-frame/">3편. JVM 스레드와 스택 프레임 구조</a></p>
</blockquote>

<hr />

<h2 id="왜-jvm-내부를-알아야-하는가">왜 JVM 내부를 알아야 하는가</h2>

<p>솔직히 말하면, 대부분의 Java 개발자가 JVM 내부를 몰라도 코드를 짤 수 있다. Spring Boot 띄우고 API 만들고 배포하는 데 Heap이 몇 개의 Generation으로 나뉘는지 알 필요는 없다.</p>

<p>그런데 <strong>운영 환경에서 문제가 터지면</strong> 얘기가 달라진다. OOM이 났는데 Heap dump를 보고도 뭐가 문제인지 모르겠다거나, GC 로그에 Full GC가 반복되는데 왜 그런지 감이 안 잡히거나. 이런 상황에서 JVM 구조를 아는 것과 모르는 것의 차이는 꽤 크다.</p>

<p>이 시리즈에서는 JVM 내부 구조를 3편에 걸쳐 정리한다. 첫 번째 글에서는 JVM의 전체 아키텍처와 Runtime Data Areas를 다룬다.</p>

<h2 id="jvm-전체-아키텍처">JVM 전체 아키텍처</h2>

<p><img src="/assets/images/jvm/jvm-architecture-overview.png" alt="JVM Architecture" /></p>

<p>JVM은 크게 세 파트로 나눌 수 있다:</p>

<ol>
  <li><strong>Class Loader Subsystem</strong> — <code class="language-plaintext highlighter-rouge">.class</code> 파일을 읽어서 메모리에 올린다</li>
  <li><strong>Runtime Data Areas</strong> — 프로그램 실행에 필요한 데이터를 저장하는 메모리 공간</li>
  <li><strong>Execution Engine</strong> — 바이트코드를 실제로 실행한다</li>
</ol>

<p>Class Loader와 Execution Engine은 다음 편에서 깊게 다룰 거고, 이 글에서는 Runtime Data Areas에 집중한다.</p>

<h2 id="runtime-data-areas">Runtime Data Areas</h2>

<p>JVM이 프로그램을 실행하면서 사용하는 메모리 영역이다. 중요한 건 이 영역들이 <strong>스레드 공유 여부</strong>에 따라 나뉜다는 점이다.</p>

<table>
  <thead>
    <tr>
      <th>영역</th>
      <th>공유 범위</th>
      <th>생성 시점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Heap</td>
      <td>모든 스레드 공유</td>
      <td>JVM 시작 시</td>
    </tr>
    <tr>
      <td>Method Area</td>
      <td>모든 스레드 공유</td>
      <td>JVM 시작 시</td>
    </tr>
    <tr>
      <td>JVM Stack</td>
      <td>스레드별 독립</td>
      <td>스레드 생성 시</td>
    </tr>
    <tr>
      <td>PC Register</td>
      <td>스레드별 독립</td>
      <td>스레드 생성 시</td>
    </tr>
    <tr>
      <td>Native Method Stack</td>
      <td>스레드별 독립</td>
      <td>스레드 생성 시</td>
    </tr>
  </tbody>
</table>

<p>이 구분이 중요한 이유는 <strong>동시성 문제</strong>와 직결되기 때문이다. 공유 영역에 있는 데이터는 여러 스레드가 동시에 접근할 수 있으니, synchronized나 volatile 같은 동기화 메커니즘이 필요해진다.</p>

<h3 id="heap">Heap</h3>

<p>Java 개발자가 가장 많이 듣는 영역. <code class="language-plaintext highlighter-rouge">new</code> 키워드로 생성한 <strong>모든 객체 인스턴스와 배열</strong>이 여기에 저장된다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 이 객체는 Heap에 할당된다</span>
<span class="nc">User</span> <span class="n">user</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">User</span><span class="o">(</span><span class="s">"jude"</span><span class="o">,</span> <span class="mi">28</span><span class="o">);</span>

<span class="c1">// 배열도 Heap</span>
<span class="kt">int</span><span class="o">[]</span> <span class="n">scores</span> <span class="o">=</span> <span class="k">new</span> <span class="kt">int</span><span class="o">[</span><span class="mi">100</span><span class="o">];</span>

<span class="c1">// String 리터럴은 String Pool(Heap 내부)에 저장</span>
<span class="nc">String</span> <span class="n">name</span> <span class="o">=</span> <span class="s">"hello"</span><span class="o">;</span>
</code></pre></div></div>

<p>Heap은 GC(Garbage Collector)가 관리하는 영역이다. 더 이상 참조되지 않는 객체는 GC가 정리한다. JVM 옵션으로 크기를 조절할 수 있다:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 초기 Heap 크기 256MB, 최대 1GB</span>
java <span class="nt">-Xms256m</span> <span class="nt">-Xmx1g</span> <span class="nt">-jar</span> app.jar
</code></pre></div></div>

<p>Heap이 꽉 차면 <code class="language-plaintext highlighter-rouge">OutOfMemoryError: Java heap space</code>가 발생한다. 운영 환경에서 이 에러를 보면 보통 메모리 누수를 의심해야 한다.</p>

<h3 id="method-area-metaspace">Method Area (Metaspace)</h3>

<p>클래스 수준의 정보가 저장되는 곳이다:</p>

<ul>
  <li>클래스의 <strong>런타임 상수 풀</strong> (Runtime Constant Pool)</li>
  <li>필드, 메서드 데이터</li>
  <li>메서드와 생성자의 <strong>바이트코드</strong></li>
  <li>static 변수</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">UserService</span> <span class="o">{</span>
    <span class="c1">// static 변수 → Method Area에 저장</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="kt">int</span> <span class="no">MAX_RETRY</span> <span class="o">=</span> <span class="mi">3</span><span class="o">;</span>

    <span class="c1">// 메서드의 바이트코드 → Method Area에 저장</span>
    <span class="kd">public</span> <span class="nc">User</span> <span class="nf">findById</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// ...</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Java 8부터는 기존의 PermGen이 <strong>Metaspace</strong>로 대체됐다. 가장 큰 차이는 Metaspace가 <strong>Native Memory</strong>를 사용한다는 것이다. PermGen은 Heap 안에 있어서 크기가 고정이었는데, Metaspace는 OS 메모리를 직접 사용하므로 동적으로 확장된다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Metaspace 최대 크기 설정 (설정 안 하면 시스템 메모리까지 증가)</span>
java <span class="nt">-XX</span>:MaxMetaspaceSize<span class="o">=</span>256m <span class="nt">-jar</span> app.jar
</code></pre></div></div>

<p>동적 프록시를 많이 생성하는 프레임워크(Spring AOP 등)를 쓸 때 Metaspace가 계속 증가하는 경우가 있다. 이때 <code class="language-plaintext highlighter-rouge">OutOfMemoryError: Metaspace</code>가 나온다.</p>

<h3 id="jvm-stack-java-virtual-machine-stack">JVM Stack (Java Virtual Machine Stack)</h3>

<p><strong>스레드마다 하나씩</strong> 생성되는 영역이다. 메서드가 호출될 때마다 <strong>Stack Frame</strong>이 하나 push되고, 메서드가 리턴되면 pop된다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">void</span> <span class="nf">methodA</span><span class="o">()</span> <span class="o">{</span>
    <span class="kt">int</span> <span class="n">x</span> <span class="o">=</span> <span class="mi">10</span><span class="o">;</span>        <span class="c1">// x는 methodA의 Stack Frame에 저장</span>
    <span class="n">methodB</span><span class="o">();</span>          <span class="c1">// methodB의 Stack Frame이 push</span>
<span class="o">}</span>

<span class="kd">public</span> <span class="kt">void</span> <span class="nf">methodB</span><span class="o">()</span> <span class="o">{</span>
    <span class="kt">int</span> <span class="n">y</span> <span class="o">=</span> <span class="mi">20</span><span class="o">;</span>        <span class="c1">// y는 methodB의 Stack Frame에 저장</span>
    <span class="n">methodC</span><span class="o">();</span>
<span class="o">}</span>

<span class="c1">// 호출 순서: methodA → methodB → methodC</span>
<span class="c1">// Stack: [methodA] → [methodA][methodB] → [methodA][methodB][methodC]</span>
<span class="c1">// 리턴하면 역순으로 pop</span>
</code></pre></div></div>

<p>각 Stack Frame은 다음을 포함한다:</p>
<ul>
  <li><strong>Local Variables Array</strong> — 지역 변수와 파라미터</li>
  <li><strong>Operand Stack</strong> — 바이트코드 연산에 사용되는 스택</li>
  <li><strong>Frame Data</strong> — Constant Pool 참조, 예외 테이블 등</li>
</ul>

<p>Stack의 깊이가 허용 범위를 초과하면 <code class="language-plaintext highlighter-rouge">StackOverflowError</code>가 발생한다. 재귀 호출이 끝나지 않을 때 흔히 볼 수 있다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 스레드당 Stack 크기 설정 (기본값은 보통 512KB ~ 1MB)</span>
java <span class="nt">-Xss1m</span> <span class="nt">-jar</span> app.jar
</code></pre></div></div>

<h3 id="pc-register-program-counter-register">PC Register (Program Counter Register)</h3>

<p>각 스레드가 <strong>현재 실행 중인 JVM 명령어의 주소</strong>를 저장한다. 바이트코드 인터프리터가 다음에 실행할 명령어를 알기 위해 이 레지스터를 참조한다.</p>

<p>스레드가 Java 메서드를 실행 중이면 현재 바이트코드 명령어의 주소가 저장되고, Native 메서드를 실행 중이면 undefined 상태가 된다.</p>

<p>크기가 매우 작아서 OOM이 발생할 일은 없다. JVM 스펙상 이 영역에 대한 <code class="language-plaintext highlighter-rouge">OutOfMemoryError</code>는 정의되어 있지 않다.</p>

<h3 id="native-method-stack">Native Method Stack</h3>

<p>JNI(Java Native Interface)를 통해 호출되는 <strong>네이티브 메서드(C/C++)를 위한 스택</strong>이다. Java 코드가 아닌 네이티브 코드 실행을 위한 영역이다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// System.currentTimeMillis()는 내부적으로 네이티브 메서드를 호출</span>
<span class="kd">public</span> <span class="kd">static</span> <span class="kd">native</span> <span class="kt">long</span> <span class="nf">currentTimeMillis</span><span class="o">();</span>

<span class="c1">// Thread.sleep()도 네이티브 메서드</span>
<span class="kd">public</span> <span class="kd">static</span> <span class="kd">native</span> <span class="kt">void</span> <span class="nf">sleep</span><span class="o">(</span><span class="kt">long</span> <span class="n">millis</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">InterruptedException</span><span class="o">;</span>
</code></pre></div></div>

<p>HotSpot JVM에서는 JVM Stack과 Native Method Stack을 구분하지 않고 하나로 합쳐서 관리한다. 하지만 JVM 스펙상으로는 별개의 영역이다.</p>

<h2 id="정리-메모리-영역과-에러-매핑">정리: 메모리 영역과 에러 매핑</h2>

<p>실무에서 만나는 에러와 메모리 영역의 관계를 정리하면:</p>

<table>
  <thead>
    <tr>
      <th>에러</th>
      <th>관련 영역</th>
      <th>흔한 원인</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">OutOfMemoryError: Java heap space</code></td>
      <td>Heap</td>
      <td>메모리 누수, Heap 크기 부족</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">OutOfMemoryError: Metaspace</code></td>
      <td>Method Area</td>
      <td>클래스 로딩 과다, 동적 프록시</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">StackOverflowError</code></td>
      <td>JVM Stack</td>
      <td>무한 재귀, 깊은 호출 체인</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">OutOfMemoryError: unable to create native thread</code></td>
      <td>—</td>
      <td>스레드 수 초과 (OS 레벨)</td>
    </tr>
  </tbody>
</table>

<p>JVM 튜닝이라는 게 결국 이 메모리 영역들의 <strong>크기와 GC 전략을 조절</strong>하는 작업이다. 어떤 영역에서 문제가 발생하는지 알아야 올바른 옵션을 적용할 수 있다.</p>

<p>다음 편에서는 <code class="language-plaintext highlighter-rouge">.class</code> 파일이 이 메모리 영역들에 어떻게 로딩되고, Execution Engine이 바이트코드를 어떻게 실행하는지 정리한다.</p>

<h2 id="참고">참고</h2>

<ul>
  <li><a href="https://blog.jamesdbloom.com/JVMInternals.html">JVM Internals — James D Bloom</a></li>
  <li><a href="https://docs.oracle.com/javase/specs/jvms/se17/html/">The Java Virtual Machine Specification (Java SE 17)</a></li>
</ul>]]></content><author><name>jude-z</name></author><category term="Dev Notes" /><category term="JVM" /><category term="Java" /><summary type="html"><![CDATA[JVM의 전체 구조를 잡고, Runtime Data Areas 각 영역이 어떤 역할을 하는지 정리한다.]]></summary></entry><entry xml:lang="ko"><title type="html">모임 조회 1단계 — 튜닝 없이 v1 쿼리, 389초의 현실</title><link href="https://jude-z.github.io/projects/gathering-query-v1-no-tuning/" rel="alternate" type="text/html" title="모임 조회 1단계 — 튜닝 없이 v1 쿼리, 389초의 현실" /><published>2026-03-19T00:00:00+00:00</published><updated>2026-03-19T00:00:00+00:00</updated><id>https://jude-z.github.io/projects/gathering-query-v1-no-tuning</id><content type="html" xml:base="https://jude-z.github.io/projects/gathering-query-v1-no-tuning/"><![CDATA[<blockquote>
  <p><strong>모임 조회 API 시리즈</strong>
1단계. 튜닝 없이 v1 쿼리 분석 — 389초 <em>(현재 글)</em>
<a href="/projects/gathering-query-v2-separation/">2단계. 쿼리 분리 + 복합 인덱스 — 389초 → 5초</a>
<a href="/projects/gathering-query-v3-remove-rownum/">3단계. ROW_NUMBER 제거 — 5초 → 317ms</a>
<a href="/projects/gathering-query-v4-redis-cache/">4단계. Redis 캐시 + Redisson 분산 락 — p50 11ms</a></p>
</blockquote>

<hr />

<h2 id="요구사항">요구사항</h2>

<p>모임 메인 페이지는 <strong>카테고리별 인기 모임 9개</strong>를 한 번에 보여줘야 한다. 카테고리가 20개라면 총 180개의 모임을 한 번의 API 호출로 반환해야 한다.</p>

<h2 id="v1-쿼리-단일-sql로-모든-것을-해결">v1 쿼리: 단일 SQL로 모든 것을 해결</h2>

<p>v1은 이 요구사항을 <strong>하나의 SQL</strong>로 해결했다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">title</span><span class="p">,</span> <span class="n">content</span><span class="p">,</span> <span class="n">registerDate</span><span class="p">,</span> <span class="n">category</span><span class="p">,</span> <span class="n">createdBy</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="k">count</span>
<span class="k">FROM</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="k">g</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">title</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">content</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">register_date</span> <span class="k">AS</span> <span class="n">registerDate</span><span class="p">,</span>
         <span class="n">ca</span><span class="p">.</span><span class="n">name</span> <span class="k">AS</span> <span class="n">category</span><span class="p">,</span> <span class="n">cr</span><span class="p">.</span><span class="n">username</span> <span class="k">AS</span> <span class="n">createdBy</span><span class="p">,</span> <span class="n">im</span><span class="p">.</span><span class="n">url</span> <span class="k">AS</span> <span class="n">url</span><span class="p">,</span>
         <span class="n">ec</span><span class="p">.</span><span class="k">count</span> <span class="k">AS</span> <span class="k">count</span><span class="p">,</span>
         <span class="n">ROW_NUMBER</span><span class="p">()</span> <span class="n">OVER</span> <span class="p">(</span><span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">ca</span><span class="p">.</span><span class="n">name</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="k">g</span><span class="p">.</span><span class="k">count</span> <span class="k">DESC</span><span class="p">)</span> <span class="k">AS</span> <span class="n">rownum</span>
  <span class="k">FROM</span> <span class="n">gathering</span> <span class="k">g</span>
  <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">category</span> <span class="n">ca</span> <span class="k">ON</span> <span class="k">g</span><span class="p">.</span><span class="n">category_id</span> <span class="o">=</span> <span class="n">ca</span><span class="p">.</span><span class="n">id</span>
  <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="k">user</span> <span class="n">cr</span> <span class="k">ON</span> <span class="k">g</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">cr</span><span class="p">.</span><span class="n">id</span>
  <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">image</span> <span class="n">im</span> <span class="k">ON</span> <span class="k">g</span><span class="p">.</span><span class="n">image_id</span> <span class="o">=</span> <span class="n">im</span><span class="p">.</span><span class="n">id</span>
  <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="k">count</span><span class="p">,</span> <span class="n">gathering_id</span>
    <span class="k">FROM</span> <span class="n">enrollment</span> <span class="k">WHERE</span> <span class="n">accepted</span> <span class="o">=</span> <span class="k">true</span>
    <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">gathering_id</span>
  <span class="p">)</span> <span class="n">ec</span> <span class="k">ON</span> <span class="n">ec</span><span class="p">.</span><span class="n">gathering_id</span> <span class="o">=</span> <span class="k">g</span><span class="p">.</span><span class="n">id</span>
<span class="p">)</span> <span class="k">AS</span> <span class="n">subquery</span>
<span class="k">WHERE</span> <span class="n">rownum</span> <span class="k">BETWEEN</span> <span class="mi">1</span> <span class="k">AND</span> <span class="mi">9</span>
</code></pre></div></div>

<h3 id="jdbcgatheringrepository">JdbcGatheringRepository</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="nf">gatherings</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">String</span> <span class="n">sql</span> <span class="o">=</span> <span class="s">"select id, title, content, registerDate, category, createdBy, url, count from ( "</span> <span class="o">+</span>
            <span class="s">"  select g.id as id, g.title as title, g.content as content, "</span> <span class="o">+</span>
            <span class="s">"         g.register_date as registerDate, ca.name as category, "</span> <span class="o">+</span>
            <span class="s">"         cr.username as createdBy, im.url as url, ec.count as count, "</span> <span class="o">+</span>
            <span class="s">"         row_number() over (partition by ca.name order by g.count desc) as rownum "</span> <span class="o">+</span>
            <span class="s">"  from gathering g "</span> <span class="o">+</span>
            <span class="s">"  left join category ca on g.category_id = ca.id "</span> <span class="o">+</span>
            <span class="s">"  left join user cr on g.user_id = cr.id "</span> <span class="o">+</span>
            <span class="s">"  left join image im on g.image_id = im.id "</span> <span class="o">+</span>
            <span class="s">"  left join (select count(*) as count, gathering_id "</span> <span class="o">+</span>
            <span class="s">"             from enrollment where accepted = true "</span> <span class="o">+</span>
            <span class="s">"             group by gathering_id) ec "</span> <span class="o">+</span>
            <span class="s">"  on ec.gathering_id = g.id"</span> <span class="o">+</span>
            <span class="s">") as subquery "</span> <span class="o">+</span>
            <span class="s">"where rownum between 1 and 9"</span><span class="o">;</span>
    <span class="k">return</span> <span class="n">jdbcTemplate</span><span class="o">.</span><span class="na">query</span><span class="o">(</span><span class="n">con</span> <span class="o">-&gt;</span> <span class="n">con</span><span class="o">.</span><span class="na">prepareStatement</span><span class="o">(</span><span class="n">sql</span><span class="o">),</span> <span class="n">mainGatheringsRowMapper</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="gatheringservice">GatheringService</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">ApiResponse</span> <span class="nf">gatherings</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="n">mainGatheringElements</span> <span class="o">=</span> <span class="n">jdbcGatheringRepository</span><span class="o">.</span><span class="na">gatherings</span><span class="o">();</span>
    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">CategoryTotalGatherings</span><span class="o">&gt;</span> <span class="n">map</span> <span class="o">=</span> <span class="n">categorizeByCategory</span><span class="o">(</span><span class="n">mainGatheringElements</span><span class="o">);</span>
    <span class="k">return</span> <span class="nf">toMainGatheringResponse</span><span class="o">(</span><span class="n">map</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>한 번의 쿼리, 한 번의 서비스 호출. 코드만 보면 깔끔해 보인다.</p>

<h2 id="문제-분석">문제 분석</h2>

<p>하지만 이 쿼리에는 세 가지 구조적 문제가 숨어 있다.</p>

<h3 id="1-gathering-99만-행-풀-스캔">1. gathering 99만 행 풀 스캔</h3>

<p><code class="language-plaintext highlighter-rouge">ROW_NUMBER()</code>는 <strong>전체 행에 번호를 매긴 뒤</strong> <code class="language-plaintext highlighter-rouge">WHERE rownum BETWEEN 1 AND 9</code>로 필터링한다. 카테고리별로 9개만 필요하지만, 99만 행을 전부 읽고 정렬해야 번호를 매길 수 있다.</p>

<h3 id="2-enrollment-전체-group-by">2. enrollment 전체 GROUP BY</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="k">count</span><span class="p">,</span> <span class="n">gathering_id</span>
<span class="k">FROM</span> <span class="n">enrollment</span> <span class="k">WHERE</span> <span class="n">accepted</span> <span class="o">=</span> <span class="k">true</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">gathering_id</span>
</code></pre></div></div>

<p>이 서브쿼리는 <code class="language-plaintext highlighter-rouge">accepted = true</code>인 <strong>모든 enrollment 행</strong>을 집계한다. 최종적으로 필요한 건 선별된 180개 모임의 enrollment count뿐인데, 전체를 GROUP BY하고 있다.</p>

<h3 id="3-4개-테이블이-99만-행에-join">3. 4개 테이블이 99만 행에 JOIN</h3>

<p>category, user, image, enrollment — 이 4개 테이블 JOIN이 ROW_NUMBER() 필터링 <strong>이전에</strong> 발생한다. 99만 행 전부에 조인이 걸린다.</p>

<h2 id="explain-analyze-결과">EXPLAIN ANALYZE 결과</h2>

<p><img src="/assets/images/gathering/첫번째%20실행전략.png" alt="v1 실행 전략" /></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Table scan on g</code> — rows=990,742, actual time=388,888~389,337ms</li>
  <li><code class="language-plaintext highlighter-rouge">Nested loop left join</code> — 4개 테이블 중첩</li>
  <li><code class="language-plaintext highlighter-rouge">Group aggregate: count(0)</code> — enrollment 전체 집계</li>
  <li>전체 실행 시간: <strong>약 389초 (6.5분)</strong></li>
</ul>

<h2 id="postman-결과">Postman 결과</h2>

<p><img src="/assets/images/gathering/첫번쨰%20postman결과.png" alt="v1 Postman 결과" /></p>

<p>단일 요청 응답 시간: <strong>7분 38초</strong>. API 하나가 DB 커넥션을 6분 이상 점유한다.</p>

<h2 id="reflection">Reflection</h2>

<p>“한 번의 쿼리로 끝낸다”는 것은 코드의 단순함이지, 성능의 단순함이 아니다. ROW_NUMBER()는 “카테고리별 Top N”을 우아하게 표현하지만, 그 대가로 <strong>99만 행 풀 스캔 + 정렬 + 4개 테이블 JOIN</strong>이라는 비용을 숨기고 있다.</p>

<p>EXPLAIN ANALYZE를 실행하기 전까지는 이 쿼리가 왜 느린지 체감하기 어렵다. 실행 계획을 확인하는 습관이 모든 최적화의 출발점이다.</p>

<hr />

<blockquote>
  <p>하나의 거대한 SQL을 역할별로 분리하면 어떻게 되는가?
<a href="/projects/gathering-query-v2-separation/">2단계: 쿼리 분리 + 복합 인덱스 — 389초 → 5초 →</a></p>
</blockquote>]]></content><author><name>jude-z</name></author><category term="Projects" /><category term="Java" /><category term="Spring Boot" /><category term="MySQL" /><category term="EXPLAIN ANALYZE" /><summary type="html"><![CDATA[ROW_NUMBER() + 4개 테이블 JOIN + enrollment 서브쿼리가 중첩된 v1 쿼리가 99만 행에서 어떤 결과를 만들어내는지 분석한다.]]></summary></entry><entry xml:lang="ko"><title type="html">모임 조회 2단계 — 쿼리 분리와 복합 인덱스로 389초 → 5초</title><link href="https://jude-z.github.io/projects/gathering-query-v2-separation/" rel="alternate" type="text/html" title="모임 조회 2단계 — 쿼리 분리와 복합 인덱스로 389초 → 5초" /><published>2026-03-19T00:00:00+00:00</published><updated>2026-03-19T00:00:00+00:00</updated><id>https://jude-z.github.io/projects/gathering-query-v2-separation</id><content type="html" xml:base="https://jude-z.github.io/projects/gathering-query-v2-separation/"><![CDATA[<blockquote>
  <p><strong>모임 조회 API 시리즈</strong>
<a href="/projects/gathering-query-v1-no-tuning/">1단계. 튜닝 없이 v1 쿼리 분석 — 389초</a>
2단계. 쿼리 분리 + 복합 인덱스 — 389초 → 5초 <em>(현재 글)</em>
<a href="/projects/gathering-query-v3-remove-rownum/">3단계. ROW_NUMBER 제거 — 5초 → 317ms</a>
<a href="/projects/gathering-query-v4-redis-cache/">4단계. Redis 캐시 + Redisson 분산 락 — p50 11ms</a></p>
</blockquote>

<hr />

<h2 id="problem">Problem</h2>

<p>1단계에서 확인한 v1 쿼리의 핵심 병목:</p>

<ol>
  <li>enrollment 전체 GROUP BY가 메인 쿼리에 포함</li>
  <li>category/user/image JOIN이 99만 행 전부에 발생</li>
  <li>ROW_NUMBER()가 풀 스캔을 강제</li>
</ol>

<p>이 중 1번과 2번은 <strong>쿼리 분리</strong>로 해결할 수 있다. 3번은 <code class="language-plaintext highlighter-rouge">(category_id, count)</code> 복합 인덱스를 추가하지만, ROW_NUMBER()가 남아있는 한 완전한 해결은 불가능하다.</p>

<h2 id="action">Action</h2>

<h3 id="1-enrollment-서브쿼리-분리">1. enrollment 서브쿼리 분리</h3>

<p>v1은 enrollment 전체를 GROUP BY하는 서브쿼리가 메인 쿼리에 포함되어 있었다. 이를 <strong>별도 배치 쿼리로 분리</strong>하여, ROW_NUMBER()로 선별된 ID에만 집계한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">Integer</span><span class="o">&gt;</span> <span class="n">enrollmentCounts</span> <span class="o">=</span>
    <span class="n">jdbcGatheringRepository</span><span class="o">.</span><span class="na">gatheringEnrollmentCounts</span><span class="o">(</span><span class="n">gatheringIds</span><span class="o">);</span>
</code></pre></div></div>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">gathering_id</span><span class="p">,</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="k">count</span>
<span class="k">FROM</span> <span class="n">enrollment</span>
<span class="k">WHERE</span> <span class="n">accepted</span> <span class="o">=</span> <span class="k">true</span> <span class="k">AND</span> <span class="n">gathering_id</span> <span class="k">IN</span> <span class="p">(</span><span class="o">?</span><span class="p">,</span> <span class="o">?</span><span class="p">,</span> <span class="p">...)</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">gathering_id</span>
</code></pre></div></div>

<p>99만 건 전체가 아니라, 최대 <strong>카테고리 수 × 9건</strong>에만 집계가 발생한다.</p>

<h3 id="2-category-join-제거--category_id로-변경">2. category JOIN 제거 → category_id로 변경</h3>

<p>v1은 <code class="language-plaintext highlighter-rouge">PARTITION BY ca.name</code>으로 카테고리명 기준 파티션을 나눴다. 이를 <code class="language-plaintext highlighter-rouge">category_id</code>(FK)로 변경하면 category JOIN 없이 gathering 테이블 단독으로 파티션을 나눌 수 있다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- v2: category JOIN 제거, gathering 단독 서브쿼리</span>
<span class="k">SELECT</span> <span class="k">g</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">title</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">content</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">register_date</span> <span class="k">AS</span> <span class="n">registerDate</span><span class="p">,</span>
       <span class="k">g</span><span class="p">.</span><span class="n">category_id</span> <span class="k">AS</span> <span class="n">categoryId</span><span class="p">,</span> <span class="n">cr</span><span class="p">.</span><span class="n">username</span> <span class="k">AS</span> <span class="n">createdBy</span><span class="p">,</span> <span class="n">im</span><span class="p">.</span><span class="n">url</span> <span class="k">AS</span> <span class="n">url</span>
<span class="k">FROM</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="k">g</span><span class="p">.</span><span class="o">*</span><span class="p">,</span> <span class="n">ROW_NUMBER</span><span class="p">()</span> <span class="n">OVER</span> <span class="p">(</span><span class="k">PARTITION</span> <span class="k">BY</span> <span class="k">g</span><span class="p">.</span><span class="n">category_id</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="k">g</span><span class="p">.</span><span class="k">count</span> <span class="k">DESC</span><span class="p">)</span> <span class="k">AS</span> <span class="n">rownum</span>
  <span class="k">FROM</span> <span class="n">gathering</span> <span class="k">g</span>
<span class="p">)</span> <span class="k">g</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="k">user</span> <span class="n">cr</span> <span class="k">ON</span> <span class="k">g</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">cr</span><span class="p">.</span><span class="n">id</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">image</span> <span class="n">im</span> <span class="k">ON</span> <span class="k">g</span><span class="p">.</span><span class="n">image_id</span> <span class="o">=</span> <span class="n">im</span><span class="p">.</span><span class="n">id</span>
<span class="k">WHERE</span> <span class="k">g</span><span class="p">.</span><span class="n">rownum</span> <span class="k">BETWEEN</span> <span class="mi">1</span> <span class="k">AND</span> <span class="mi">9</span>
</code></pre></div></div>

<p>내부 서브쿼리가 <code class="language-plaintext highlighter-rouge">SELECT g.*</code>만 처리하므로, user/image JOIN이 ROW_NUMBER() 필터링 <strong>이후에</strong> 발생한다. 180건에만 JOIN이 걸린다.</p>

<h3 id="3-카테고리명은-java-map으로-해결">3. 카테고리명은 Java Map으로 해결</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">categoryNameMap</span> <span class="o">=</span> <span class="n">categoryRepository</span><span class="o">.</span><span class="na">findAll</span><span class="o">().</span><span class="na">stream</span><span class="o">()</span>
    <span class="o">.</span><span class="na">collect</span><span class="o">(</span><span class="nc">Collectors</span><span class="o">.</span><span class="na">toMap</span><span class="o">(</span><span class="nl">Category:</span><span class="o">:</span><span class="n">getId</span><span class="o">,</span> <span class="nl">Category:</span><span class="o">:</span><span class="n">getName</span><span class="o">));</span>
</code></pre></div></div>

<p>카테고리는 수십 개 이하의 소규모 테이블이므로, <code class="language-plaintext highlighter-rouge">findAll()</code>로 한 번 로드하여 메모리에서 매핑하는 것이 SQL JOIN보다 효율적이다.</p>

<h3 id="4-복합-인덱스-추가">4. 복합 인덱스 추가</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_gathering_category_count</span> <span class="k">ON</span> <span class="n">gathering</span> <span class="p">(</span><span class="n">category_id</span><span class="p">,</span> <span class="k">count</span> <span class="k">DESC</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">(category_id, count)</code> 복합 인덱스를 추가했지만, ROW_NUMBER()가 전체 행에 번호를 매기는 특성상 <strong>옵티마이저가 이 인덱스를 선택하지 않는다.</strong></p>

<h3 id="jdbcgatheringrepository">JdbcGatheringRepository</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjectionV2</span><span class="o">&gt;</span> <span class="nf">gatheringsV2</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">String</span> <span class="n">sql</span> <span class="o">=</span> <span class="s">"select g.id, g.title, g.content, g.register_date as registerDate, "</span> <span class="o">+</span>
            <span class="s">"g.category_id as categoryId, cr.username as createdBy, im.url as url "</span> <span class="o">+</span>
            <span class="s">"from ( "</span> <span class="o">+</span>
            <span class="s">"  select g.*, row_number() over (partition by g.category_id order by g.count desc) as rownum "</span> <span class="o">+</span>
            <span class="s">"  from gathering g "</span> <span class="o">+</span>
            <span class="s">") g "</span> <span class="o">+</span>
            <span class="s">"left join user cr on g.user_id = cr.id "</span> <span class="o">+</span>
            <span class="s">"left join image im on g.image_id = im.id "</span> <span class="o">+</span>
            <span class="s">"where g.rownum between 1 and 9"</span><span class="o">;</span>
    <span class="k">return</span> <span class="n">jdbcTemplate</span><span class="o">.</span><span class="na">query</span><span class="o">(</span><span class="n">con</span> <span class="o">-&gt;</span> <span class="n">con</span><span class="o">.</span><span class="na">prepareStatement</span><span class="o">(</span><span class="n">sql</span><span class="o">),</span> <span class="n">mainGatheringsV2RowMapper</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="gatheringservice">GatheringService</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">ApiResponse</span> <span class="nf">gatheringsV2</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjectionV2</span><span class="o">&gt;</span> <span class="n">projections</span> <span class="o">=</span> <span class="n">jdbcGatheringRepository</span><span class="o">.</span><span class="na">gatheringsV2</span><span class="o">();</span>

    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">gatheringIds</span> <span class="o">=</span> <span class="n">projections</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="nl">MainGatheringsProjectionV2:</span><span class="o">:</span><span class="n">getId</span><span class="o">).</span><span class="na">toList</span><span class="o">();</span>
    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">Integer</span><span class="o">&gt;</span> <span class="n">enrollmentCounts</span> <span class="o">=</span>
            <span class="n">jdbcGatheringRepository</span><span class="o">.</span><span class="na">gatheringEnrollmentCounts</span><span class="o">(</span><span class="n">gatheringIds</span><span class="o">);</span>

    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">categoryNameMap</span> <span class="o">=</span> <span class="n">categoryRepository</span><span class="o">.</span><span class="na">findAll</span><span class="o">().</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">collect</span><span class="o">(</span><span class="nc">Collectors</span><span class="o">.</span><span class="na">toMap</span><span class="o">(</span><span class="nl">Category:</span><span class="o">:</span><span class="n">getId</span><span class="o">,</span> <span class="nl">Category:</span><span class="o">:</span><span class="n">getName</span><span class="o">));</span>

    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="n">mainGatheringElements</span> <span class="o">=</span> <span class="n">projections</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">p</span> <span class="o">-&gt;</span> <span class="nc">MainGatheringsProjection</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
                    <span class="o">.</span><span class="na">id</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getId</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">title</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getTitle</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">content</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getContent</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">registerDate</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getRegisterDate</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">category</span><span class="o">(</span><span class="n">categoryNameMap</span><span class="o">.</span><span class="na">getOrDefault</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getCategoryId</span><span class="o">(),</span> <span class="s">"unknown"</span><span class="o">))</span>
                    <span class="o">.</span><span class="na">createdBy</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getCreatedBy</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">url</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getUrl</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">count</span><span class="o">(</span><span class="n">enrollmentCounts</span><span class="o">.</span><span class="na">getOrDefault</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="mi">0</span><span class="o">))</span>
                    <span class="o">.</span><span class="na">build</span><span class="o">())</span>
            <span class="o">.</span><span class="na">toList</span><span class="o">();</span>

    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">CategoryTotalGatherings</span><span class="o">&gt;</span> <span class="n">map</span> <span class="o">=</span> <span class="n">categorizeByCategory</span><span class="o">(</span><span class="n">mainGatheringElements</span><span class="o">);</span>
    <span class="k">return</span> <span class="nf">toMainGatheringResponse</span><span class="o">(</span><span class="n">map</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="result">Result</h2>

<h3 id="explain-analyze">EXPLAIN ANALYZE</h3>

<p><img src="/assets/images/gathering/두번쨰%20실행전략.png" alt="v2 실행 전략" /></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Table scan on g</code> — rows=990,742 (여전히 풀 스캔)</li>
  <li><code class="language-plaintext highlighter-rouge">Materialize</code> + <code class="language-plaintext highlighter-rouge">Window aggregate: row_number()</code> — actual time=4568~4568ms</li>
  <li>Nested loop left join — rows=180에만 user/image JOIN 발생</li>
  <li>전체: <strong>약 5초</strong></li>
</ul>

<p>쿼리 분리만으로 <strong>389초 → 5초, 약 78배</strong> 개선됐다. 하지만 ROW_NUMBER()가 gathering 99만 행 풀 스캔을 강제하는 구조는 변하지 않았다.</p>

<h3 id="locust-100명-부하테스트">Locust 100명 부하테스트</h3>

<p><img src="/assets/images/gathering/두번쨰%20부하테스트.png" alt="v2 부하테스트" /></p>

<ul>
  <li><strong>0.5 RPS, 61% 요청 실패</strong></li>
  <li>p50 84,000ms / p95 84,000ms</li>
</ul>

<h3 id="grafana--hikaricp">Grafana — HikariCP</h3>

<p><img src="/assets/images/gathering/두번째%20그라파나.png" alt="v2 Grafana HikariCP" /></p>

<ul>
  <li>Active connections: 10 (풀 전체 고갈)</li>
  <li><strong>Pending threads: 91</strong> — 커넥션을 얻지 못해 대기하는 스레드</li>
  <li>쿼리 하나가 5초 동안 커넥션을 점유하면서, 동시 100명의 요청이 줄줄이 밀리는 구조</li>
</ul>

<h3 id="rds--dbloadcpu">RDS — DBLoadCPU</h3>

<p><img src="/assets/images/gathering/두번쨰%20db.png" alt="v2 DBLoadCPU" /></p>

<ul>
  <li>DBLoadCPU: <strong>2.47</strong> — 풀 스캔으로 인한 CPU 부하 급등</li>
</ul>

<h2 id="reflection">Reflection</h2>

<p>하나의 SQL에 모든 것을 담으면 “한 번에 끝난다”는 편안함이 있다. 하지만 그 안에서 enrollment 전체 집계, category JOIN, user/image JOIN이 <strong>99만 행에 중첩</strong>되면, 각각은 작은 비용이라도 곱셈으로 폭발한다.</p>

<p>쿼리 분리의 핵심은 <strong>“이 JOIN이 정말 99만 행 전부에 필요한가?”</strong>를 묻는 것이다. enrollment count는 최종 선별된 ID에만 필요하고, 카테고리명은 수십 개짜리 테이블이다. 분리하면 각각의 비용이 독립적으로 최소화된다.</p>

<p>그러나 <strong>ROW_NUMBER()가 풀 스캔을 강제하는 한, 인덱스를 추가해도 효과가 없다.</strong> 복합 인덱스 <code class="language-plaintext highlighter-rouge">(category_id, count)</code>는 존재하지만 옵티마이저가 선택하지 않는다. 이 구조적 한계를 넘으려면 ROW_NUMBER() 자체를 제거해야 한다.</p>

<hr />

<blockquote>
  <p>ROW_NUMBER()를 제거하면 비로소 복합 인덱스가 동작한다.
<a href="/projects/gathering-query-v3-remove-rownum/">3단계: ROW_NUMBER 제거 — 5초 → 317ms →</a></p>
</blockquote>]]></content><author><name>jude-z</name></author><category term="Projects" /><category term="Java" /><category term="Spring Boot" /><category term="MySQL" /><category term="EXPLAIN ANALYZE" /><category term="Locust" /><summary type="html"><![CDATA[enrollment 서브쿼리 분리, category JOIN 제거, 복합 인덱스 추가로 389초에서 5초로 개선했지만, ROW_NUMBER()가 풀 스캔을 강제하는 한계가 남는다.]]></summary></entry><entry xml:lang="ko"><title type="html">모임 조회 3단계 — ROW_NUMBER 제거로 5초 → 317ms</title><link href="https://jude-z.github.io/projects/gathering-query-v3-remove-rownum/" rel="alternate" type="text/html" title="모임 조회 3단계 — ROW_NUMBER 제거로 5초 → 317ms" /><published>2026-03-19T00:00:00+00:00</published><updated>2026-03-19T00:00:00+00:00</updated><id>https://jude-z.github.io/projects/gathering-query-v3-remove-rownum</id><content type="html" xml:base="https://jude-z.github.io/projects/gathering-query-v3-remove-rownum/"><![CDATA[<blockquote>
  <p><strong>모임 조회 API 시리즈</strong>
<a href="/projects/gathering-query-v1-no-tuning/">1단계. 튜닝 없이 v1 쿼리 분석 — 389초</a>
<a href="/projects/gathering-query-v2-separation/">2단계. 쿼리 분리 + 복합 인덱스 — 389초 → 5초</a>
3단계. ROW_NUMBER 제거 — 5초 → 317ms <em>(현재 글)</em>
<a href="/projects/gathering-query-v4-redis-cache/">4단계. Redis 캐시 + Redisson 분산 락 — p50 11ms</a></p>
</blockquote>

<hr />

<h2 id="problem">Problem</h2>

<p>2단계에서 쿼리 분리로 389초 → 5초까지 개선했지만, <strong>ROW_NUMBER()가 풀 스캔을 강제하는 구조적 한계</strong>가 남아 있었다.</p>

<p>v2 쿼리의 내부 서브쿼리:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="k">g</span><span class="p">.</span><span class="o">*</span><span class="p">,</span> <span class="n">ROW_NUMBER</span><span class="p">()</span> <span class="n">OVER</span> <span class="p">(</span><span class="k">PARTITION</span> <span class="k">BY</span> <span class="k">g</span><span class="p">.</span><span class="n">category_id</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="k">g</span><span class="p">.</span><span class="k">count</span> <span class="k">DESC</span><span class="p">)</span> <span class="k">AS</span> <span class="n">rownum</span>
<span class="k">FROM</span> <span class="n">gathering</span> <span class="k">g</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">(category_id, count)</code> 복합 인덱스가 존재하지만, ROW_NUMBER()는 <strong>전체 행에 번호를 매긴 뒤</strong> <code class="language-plaintext highlighter-rouge">WHERE rownum BETWEEN 1 AND 9</code>로 필터링하므로, MySQL 옵티마이저는 인덱스 대신 풀 스캔을 선택할 수밖에 없다.</p>

<p><strong>인덱스를 추가하는 것만으로는 해결되지 않는다.</strong> ROW_NUMBER() 자체를 제거해야 인덱스가 동작한다.</p>

<h2 id="action">Action</h2>

<h3 id="핵심-전략-row_number-제거--카테고리별-단순-쿼리-전환">핵심 전략: ROW_NUMBER() 제거 → 카테고리별 단순 쿼리 전환</h3>

<p>“카테고리별 인기 9개”를 구하는 데 ROW_NUMBER()가 반드시 필요한가?</p>

<p>카테고리별로 <strong>개별 쿼리</strong>를 날리면, 각 쿼리는 <code class="language-plaintext highlighter-rouge">WHERE + ORDER BY + LIMIT</code>만으로 충분하다. 이 구조는 복합 인덱스를 <strong>100% 활용</strong>할 수 있다.</p>

<h3 id="v3-쿼리-카테고리별-limit-9">v3 쿼리: 카테고리별 LIMIT 9</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="k">g</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">title</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">content</span><span class="p">,</span> <span class="k">g</span><span class="p">.</span><span class="n">register_date</span> <span class="k">AS</span> <span class="n">registerDate</span><span class="p">,</span>
       <span class="k">g</span><span class="p">.</span><span class="n">category_id</span> <span class="k">AS</span> <span class="n">categoryId</span><span class="p">,</span> <span class="n">cr</span><span class="p">.</span><span class="n">username</span> <span class="k">AS</span> <span class="n">createdBy</span><span class="p">,</span> <span class="n">im</span><span class="p">.</span><span class="n">url</span> <span class="k">AS</span> <span class="n">url</span>
<span class="k">FROM</span> <span class="n">gathering</span> <span class="k">g</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="k">user</span> <span class="n">cr</span> <span class="k">ON</span> <span class="k">g</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">cr</span><span class="p">.</span><span class="n">id</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">image</span> <span class="n">im</span> <span class="k">ON</span> <span class="k">g</span><span class="p">.</span><span class="n">image_id</span> <span class="o">=</span> <span class="n">im</span><span class="p">.</span><span class="n">id</span>
<span class="k">WHERE</span> <span class="k">g</span><span class="p">.</span><span class="n">category_id</span> <span class="o">=</span> <span class="o">?</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="k">g</span><span class="p">.</span><span class="k">count</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="mi">9</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">WHERE category_id = ? ORDER BY count DESC LIMIT 9</code> — 이 조합은 <code class="language-plaintext highlighter-rouge">(category_id, count)</code> 복합 인덱스를 타면 <strong>인덱스 레인지 스캔으로 9건만 읽고 즉시 반환</strong>한다.</p>

<h3 id="jdbcgatheringrepository">JdbcGatheringRepository</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjectionV2</span><span class="o">&gt;</span> <span class="nf">gatheringsV3</span><span class="o">(</span><span class="nc">Long</span> <span class="n">categoryId</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">String</span> <span class="n">sql</span> <span class="o">=</span> <span class="s">"select g.id, g.title, g.content, g.register_date as registerDate, "</span> <span class="o">+</span>
            <span class="s">"g.category_id as categoryId, cr.username as createdBy, im.url as url "</span> <span class="o">+</span>
            <span class="s">"from gathering g "</span> <span class="o">+</span>
            <span class="s">"left join user cr on g.user_id = cr.id "</span> <span class="o">+</span>
            <span class="s">"left join image im on g.image_id = im.id "</span> <span class="o">+</span>
            <span class="s">"where g.category_id = ? "</span> <span class="o">+</span>
            <span class="s">"order by g.count desc "</span> <span class="o">+</span>
            <span class="s">"limit 9"</span><span class="o">;</span>
    <span class="k">return</span> <span class="n">jdbcTemplate</span><span class="o">.</span><span class="na">query</span><span class="o">(</span><span class="n">con</span> <span class="o">-&gt;</span> <span class="o">{</span>
        <span class="nc">PreparedStatement</span> <span class="n">pstmt</span> <span class="o">=</span> <span class="n">con</span><span class="o">.</span><span class="na">prepareStatement</span><span class="o">(</span><span class="n">sql</span><span class="o">);</span>
        <span class="n">pstmt</span><span class="o">.</span><span class="na">setLong</span><span class="o">(</span><span class="mi">1</span><span class="o">,</span> <span class="n">categoryId</span><span class="o">);</span>
        <span class="k">return</span> <span class="n">pstmt</span><span class="o">;</span>
    <span class="o">},</span> <span class="n">mainGatheringsV2RowMapper</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="gatheringservice-카테고리별-반복-호출">GatheringService: 카테고리별 반복 호출</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">ApiResponse</span> <span class="nf">gatheringsV3</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">categoryNameMap</span> <span class="o">=</span> <span class="n">categoryRepository</span><span class="o">.</span><span class="na">findAll</span><span class="o">().</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">collect</span><span class="o">(</span><span class="nc">Collectors</span><span class="o">.</span><span class="na">toMap</span><span class="o">(</span><span class="nl">Category:</span><span class="o">:</span><span class="n">getId</span><span class="o">,</span> <span class="nl">Category:</span><span class="o">:</span><span class="n">getName</span><span class="o">));</span>

    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjectionV2</span><span class="o">&gt;</span> <span class="n">projections</span> <span class="o">=</span> <span class="n">categoryNameMap</span><span class="o">.</span><span class="na">keySet</span><span class="o">().</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">flatMap</span><span class="o">(</span><span class="n">categoryId</span> <span class="o">-&gt;</span>
                <span class="n">jdbcGatheringRepository</span><span class="o">.</span><span class="na">gatheringsV3</span><span class="o">(</span><span class="n">categoryId</span><span class="o">).</span><span class="na">stream</span><span class="o">())</span>
            <span class="o">.</span><span class="na">toList</span><span class="o">();</span>

    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">gatheringIds</span> <span class="o">=</span> <span class="n">projections</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="nl">MainGatheringsProjectionV2:</span><span class="o">:</span><span class="n">getId</span><span class="o">).</span><span class="na">toList</span><span class="o">();</span>
    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">Integer</span><span class="o">&gt;</span> <span class="n">enrollmentCounts</span> <span class="o">=</span>
            <span class="n">jdbcGatheringRepository</span><span class="o">.</span><span class="na">gatheringEnrollmentCounts</span><span class="o">(</span><span class="n">gatheringIds</span><span class="o">);</span>

    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="n">mainGatheringElements</span> <span class="o">=</span> <span class="n">projections</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">p</span> <span class="o">-&gt;</span> <span class="nc">MainGatheringsProjection</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
                    <span class="o">.</span><span class="na">id</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getId</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">title</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getTitle</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">content</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getContent</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">registerDate</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getRegisterDate</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">category</span><span class="o">(</span><span class="n">categoryNameMap</span><span class="o">.</span><span class="na">getOrDefault</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getCategoryId</span><span class="o">(),</span> <span class="s">"unknown"</span><span class="o">))</span>
                    <span class="o">.</span><span class="na">createdBy</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getCreatedBy</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">url</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getUrl</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">count</span><span class="o">(</span><span class="n">enrollmentCounts</span><span class="o">.</span><span class="na">getOrDefault</span><span class="o">(</span><span class="n">p</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="mi">0</span><span class="o">))</span>
                    <span class="o">.</span><span class="na">build</span><span class="o">())</span>
            <span class="o">.</span><span class="na">toList</span><span class="o">();</span>

    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">CategoryTotalGatherings</span><span class="o">&gt;</span> <span class="n">map</span> <span class="o">=</span> <span class="n">categorizeByCategory</span><span class="o">(</span><span class="n">mainGatheringElements</span><span class="o">);</span>
    <span class="k">return</span> <span class="nf">toMainGatheringResponse</span><span class="o">(</span><span class="n">map</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>카테고리가 20개면 쿼리 20번 + enrollment 배치 1번 = <strong>총 21번</strong>. ROW_NUMBER()의 99만 행 풀 스캔 대비 각 쿼리가 인덱스로 9건만 읽으므로, N번의 라운드트립 비용을 감안해도 압도적으로 빠르다.</p>

<h2 id="result">Result</h2>

<h3 id="explain-analyze">EXPLAIN ANALYZE</h3>

<p><img src="/assets/images/gathering/세번쨰%20실행전략.png" alt="v3 실행 전략" /></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Index lookup on g using idx_gathering_category_count</code> — rows=9, actual time <strong>0.02~0.05ms</strong></li>
  <li><code class="language-plaintext highlighter-rouge">Limit: 9 row(s)</code> — 인덱스에서 9건 읽고 즉시 반환</li>
  <li>개별 쿼리: <strong>0.17ms</strong></li>
</ul>

<p><strong>5초 → 317ms (15배 개선)</strong>. 개별 쿼리는 0.17ms지만, 카테고리 수만큼 반복 + enrollment 배치 + 네트워크 오버헤드를 합산하면 실제 API 응답은 317ms다.</p>

<h3 id="locust-100명-부하테스트">Locust 100명 부하테스트</h3>

<p><img src="/assets/images/gathering/세번째%20부하테스트.png" alt="v3 부하테스트" /></p>

<ul>
  <li><strong>28 RPS, 0% 실패</strong></li>
  <li>p50 1,600ms / p95 1,900ms</li>
</ul>

<p>이전 61% 실패에서 <strong>0% 실패</strong>로 전환됐다.</p>

<h3 id="grafana--hikaricp">Grafana — HikariCP</h3>

<p><img src="/assets/images/gathering/세번째%20그라파나.png" alt="v3 Grafana HikariCP" /></p>

<ul>
  <li>Active connections: 10</li>
  <li><strong>Pending threads: 33</strong> — v2의 91에서 크게 감소</li>
  <li>개별 쿼리가 빨라졌지만, 카테고리 수만큼 커넥션을 반복 사용하면서 여전히 대기 발생</li>
</ul>

<h3 id="rds--dbloadcpu">RDS — DBLoadCPU</h3>

<p><img src="/assets/images/gathering/세번째%20db.png" alt="v3 DBLoadCPU" /></p>

<ul>
  <li>DBLoadCPU: <strong>0.12</strong> — v2의 2.47에서 <strong>95% 감소</strong></li>
  <li>인덱스 레인지 스캔으로 CPU 부하가 극적으로 줄었다</li>
</ul>

<h2 id="reflection">Reflection</h2>

<p>ROW_NUMBER()는 “한 번의 쿼리로 카테고리별 Top N”을 구하는 우아한 방법이다. 하지만 <strong>전체 행에 번호를 매기는 특성상 인덱스를 무력화</strong>한다. 99만 행에서 9개만 필요한데, 99만 행을 전부 읽고 정렬한 뒤 9개를 고르는 것이다.</p>

<p>ROW_NUMBER()를 제거하고 카테고리별 단순 쿼리로 전환하면, <strong>비로소 복합 인덱스가 동작</strong>한다. 쿼리 횟수는 늘어나지만, 각 쿼리가 인덱스로 9건만 읽으므로 총 비용은 비교할 수 없을 만큼 작다.</p>

<p>다만 <strong>p50 1,600ms / p95 1,900ms</strong>로 응답 지연이 남아 있다. 카테고리 수만큼 쿼리가 발생하는 구조적 한계 — 카테고리가 늘어날수록 쿼리 수가 선형 증가한다. 이 한계를 넘으려면 <strong>캐시 레이어</strong>가 필요하다.</p>

<hr />

<blockquote>
  <p>쿼리 튜닝의 한계를 캐시로 보완한다. Redis + Redisson 분산 락 + DB fallback.
<a href="/projects/gathering-query-v4-redis-cache/">4단계: Redis 캐시 + Redisson 분산 락 — p50 11ms →</a></p>
</blockquote>]]></content><author><name>jude-z</name></author><category term="Projects" /><category term="Java" /><category term="Spring Boot" /><category term="MySQL" /><category term="EXPLAIN ANALYZE" /><category term="Locust" /><summary type="html"><![CDATA[ROW_NUMBER()를 제거하고 카테고리별 단순 쿼리로 전환하여, 복합 인덱스가 비로소 동작하게 만든 과정.]]></summary></entry><entry xml:lang="ko"><title type="html">모임 조회 4단계 — Redis 캐시 + Redisson 분산 락으로 p50 11ms 달성</title><link href="https://jude-z.github.io/projects/gathering-query-v4-redis-cache/" rel="alternate" type="text/html" title="모임 조회 4단계 — Redis 캐시 + Redisson 분산 락으로 p50 11ms 달성" /><published>2026-03-19T00:00:00+00:00</published><updated>2026-03-19T00:00:00+00:00</updated><id>https://jude-z.github.io/projects/gathering-query-v4-redis-cache</id><content type="html" xml:base="https://jude-z.github.io/projects/gathering-query-v4-redis-cache/"><![CDATA[<blockquote>
  <p><strong>모임 조회 API 시리즈</strong>
<a href="/projects/gathering-query-v1-no-tuning/">1단계. 튜닝 없이 v1 쿼리 분석 — 389초</a>
<a href="/projects/gathering-query-v2-separation/">2단계. 쿼리 분리 + 복합 인덱스 — 389초 → 5초</a>
<a href="/projects/gathering-query-v3-remove-rownum/">3단계. ROW_NUMBER 제거 — 5초 → 317ms</a>
4단계. Redis 캐시 + Redisson 분산 락 — p50 11ms <em>(현재 글)</em></p>
</blockquote>

<hr />

<h2 id="problem">Problem</h2>

<p>3단계에서 복합 인덱스로 개별 쿼리는 0.17ms까지 줄였지만, <strong>카테고리 수만큼 쿼리가 발생하는 구조적 한계</strong>가 남아 있었다.</p>

<p>Locust 100명 부하테스트:</p>
<ul>
  <li>28 RPS, 0% 실패</li>
  <li><strong>p50 1,600ms / p95 1,900ms</strong></li>
</ul>

<p>카테고리가 20개면 쿼리 20번 + enrollment 배치 1번 = 21번의 DB 왕복이 매 요청마다 발생한다. 개별 쿼리가 아무리 빨라도, 동시 100명이 각각 21번씩 DB를 때리면 커넥션 풀과 네트워크 오버헤드가 병목이 된다.</p>

<p>모임 메인 페이지는 <strong>모든 사용자에게 동일한 결과</strong>를 보여준다. 사용자별 개인화가 없으므로, 캐싱에 이상적인 엔드포인트다.</p>

<h2 id="action">Action</h2>

<h3 id="캐시-설계-redis--db-fallback--redisson-분산-락">캐시 설계: Redis + DB fallback + Redisson 분산 락</h3>

<p>단순히 Redis에 캐싱하면 끝나는 게 아니다. 세 가지 문제를 동시에 해결해야 한다:</p>

<ol>
  <li><strong>캐시 스탬피드</strong> — TTL 만료 시 동시 100명이 한꺼번에 DB를 조회</li>
  <li><strong>Redis 장애</strong> — Redis가 내려가면 캐시 자체가 불가능</li>
  <li><strong>데이터 정합성</strong> — 캐시가 너무 오래되면 사용자 경험 저하</li>
</ol>

<h3 id="1-soft-ttl--hard-ttl-stale-while-revalidate">1. Soft TTL + Hard TTL: stale-while-revalidate</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">Duration</span> <span class="no">CACHE_TTL</span> <span class="o">=</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">60</span><span class="o">);</span>   <span class="c1">// Hard TTL</span>
<span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">Duration</span> <span class="no">SOFT_TTL</span> <span class="o">=</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">55</span><span class="o">);</span>    <span class="c1">// Soft TTL</span>
</code></pre></div></div>

<ul>
  <li><strong>Hard TTL 60분</strong>: Redis 키 자체의 만료 시간</li>
  <li><strong>Soft TTL 55분</strong>: 남은 TTL이 5분 이하일 때 <strong>한 스레드만 선점하여 갱신</strong></li>
</ul>

<p>현재 요청은 기존 캐시(약간 stale)를 그대로 반환하고, 백그라운드에서 갱신이 완료되면 다음 요청부터 새 데이터를 받는다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kt">void</span> <span class="nf">triggerRefreshIfNeeded</span><span class="o">(</span><span class="nc">Supplier</span><span class="o">&lt;</span><span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;&gt;</span> <span class="n">dbLoader</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="nc">Long</span> <span class="n">remainTtl</span> <span class="o">=</span> <span class="n">redisTemplate</span><span class="o">.</span><span class="na">getExpire</span><span class="o">(</span><span class="no">CACHE_KEY</span><span class="o">,</span> <span class="nc">TimeUnit</span><span class="o">.</span><span class="na">MINUTES</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">remainTtl</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">remainTtl</span> <span class="o">&gt;</span> <span class="o">(</span><span class="no">CACHE_TTL</span><span class="o">.</span><span class="na">toMinutes</span><span class="o">()</span> <span class="o">-</span> <span class="no">SOFT_TTL</span><span class="o">.</span><span class="na">toMinutes</span><span class="o">()))</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>  <span class="c1">// 아직 여유 있음</span>
        <span class="o">}</span>

        <span class="c1">// 남은 TTL 5분 이하 → 하나의 스레드만 갱신</span>
        <span class="nc">RLock</span> <span class="n">lock</span> <span class="o">=</span> <span class="n">redissonClient</span><span class="o">.</span><span class="na">getLock</span><span class="o">(</span><span class="no">LOCK_KEY</span><span class="o">);</span>
        <span class="kt">boolean</span> <span class="n">acquired</span> <span class="o">=</span> <span class="n">lock</span><span class="o">.</span><span class="na">tryLock</span><span class="o">(</span><span class="mi">0</span><span class="o">,</span> <span class="no">LOCK_LEASE_TIME</span><span class="o">,</span> <span class="nc">TimeUnit</span><span class="o">.</span><span class="na">SECONDS</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">acquired</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">try</span> <span class="o">{</span>
                <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="n">result</span> <span class="o">=</span> <span class="n">dbLoader</span><span class="o">.</span><span class="na">get</span><span class="o">();</span>
                <span class="nc">String</span> <span class="n">json</span> <span class="o">=</span> <span class="n">serialize</span><span class="o">(</span><span class="n">result</span><span class="o">);</span>
                <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">set</span><span class="o">(</span><span class="no">CACHE_KEY</span><span class="o">,</span> <span class="n">json</span><span class="o">,</span> <span class="no">CACHE_TTL</span><span class="o">);</span>
                <span class="n">saveToDbCache</span><span class="o">(</span><span class="n">json</span><span class="o">);</span>
            <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
                <span class="n">lock</span><span class="o">.</span><span class="na">unlock</span><span class="o">();</span>
            <span class="o">}</span>
        <span class="o">}</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">InterruptedException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Thread</span><span class="o">.</span><span class="na">currentThread</span><span class="o">().</span><span class="na">interrupt</span><span class="o">();</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"Cache refresh failed"</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">tryLock(0, ...)</code> — wait 시간 0초. 락을 잡지 못하면 즉시 포기한다. 이미 다른 스레드가 갱신 중이므로, 나머지 스레드는 기존 캐시를 반환하면 된다.</p>

<h3 id="2-redisson-분산-락-캐시-미스-시-보호">2. Redisson 분산 락: 캐시 미스 시 보호</h3>

<p>캐시 미스(Redis에 키가 없는 상태)일 때, 동시 100명이 한꺼번에 DB를 조회하는 <strong>캐시 스탬피드</strong>를 방지한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="nf">loadWithLock</span><span class="o">(</span><span class="nc">Supplier</span><span class="o">&lt;</span><span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;&gt;</span> <span class="n">dbLoader</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">RLock</span> <span class="n">lock</span> <span class="o">=</span> <span class="n">redissonClient</span><span class="o">.</span><span class="na">getLock</span><span class="o">(</span><span class="no">LOCK_KEY</span><span class="o">);</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="kt">boolean</span> <span class="n">acquired</span> <span class="o">=</span> <span class="n">lock</span><span class="o">.</span><span class="na">tryLock</span><span class="o">(</span><span class="mi">3</span><span class="o">,</span> <span class="no">LOCK_LEASE_TIME</span><span class="o">,</span> <span class="nc">TimeUnit</span><span class="o">.</span><span class="na">SECONDS</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">acquired</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">try</span> <span class="o">{</span>
                <span class="c1">// Double-check: 다른 스레드가 이미 채웠을 수 있음</span>
                <span class="nc">String</span> <span class="n">cached</span> <span class="o">=</span> <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">get</span><span class="o">(</span><span class="no">CACHE_KEY</span><span class="o">);</span>
                <span class="k">if</span> <span class="o">(</span><span class="n">cached</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="k">return</span> <span class="n">deserialize</span><span class="o">(</span><span class="n">cached</span><span class="o">);</span>

                <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="n">result</span> <span class="o">=</span> <span class="n">dbLoader</span><span class="o">.</span><span class="na">get</span><span class="o">();</span>
                <span class="nc">String</span> <span class="n">json</span> <span class="o">=</span> <span class="n">serialize</span><span class="o">(</span><span class="n">result</span><span class="o">);</span>
                <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">set</span><span class="o">(</span><span class="no">CACHE_KEY</span><span class="o">,</span> <span class="n">json</span><span class="o">,</span> <span class="no">CACHE_TTL</span><span class="o">);</span>
                <span class="n">saveToDbCache</span><span class="o">(</span><span class="n">json</span><span class="o">);</span>
                <span class="k">return</span> <span class="n">result</span><span class="o">;</span>
            <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
                <span class="n">lock</span><span class="o">.</span><span class="na">unlock</span><span class="o">();</span>
            <span class="o">}</span>
        <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
            <span class="c1">// 락 획득 실패 → 다른 스레드가 채운 캐시 확인</span>
            <span class="nc">String</span> <span class="n">cached</span> <span class="o">=</span> <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">get</span><span class="o">(</span><span class="no">CACHE_KEY</span><span class="o">);</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">cached</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="k">return</span> <span class="n">deserialize</span><span class="o">(</span><span class="n">cached</span><span class="o">);</span>
            <span class="k">return</span> <span class="nf">getFromDbCacheOrLoad</span><span class="o">(</span><span class="n">dbLoader</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">InterruptedException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Thread</span><span class="o">.</span><span class="na">currentThread</span><span class="o">().</span><span class="na">interrupt</span><span class="o">();</span>
        <span class="k">return</span> <span class="nf">getFromDbCacheOrLoad</span><span class="o">(</span><span class="n">dbLoader</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"Redis failed during loadWithLock, fallback to DB cache"</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
        <span class="k">return</span> <span class="nf">getFromDbCacheOrLoad</span><span class="o">(</span><span class="n">dbLoader</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">tryLock(3, 30, SECONDS)</code> — 최대 3초 대기, 30초 lease</li>
  <li><strong>Double-check locking</strong>: 락 획득 후 Redis를 다시 확인. 대기 중에 다른 스레드가 캐시를 채웠을 수 있다.</li>
</ul>

<h3 id="3-db-캐시-fallback-redis-장애-대비">3. DB 캐시 fallback: Redis 장애 대비</h3>

<p>Redis가 완전히 내려간 상황에서도 서비스가 동작해야 한다. <code class="language-plaintext highlighter-rouge">gathering_cache</code> 테이블에 동일한 데이터를 저장한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kt">void</span> <span class="nf">saveToDbCache</span><span class="o">(</span><span class="nc">String</span> <span class="n">json</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">sql</span> <span class="o">=</span> <span class="s">"INSERT INTO gathering_cache (cache_key, data, expired_at, created_at) "</span> <span class="o">+</span>
                <span class="s">"VALUES (?, ?, ?, ?) "</span> <span class="o">+</span>
                <span class="s">"ON DUPLICATE KEY UPDATE data = VALUES(data), "</span> <span class="o">+</span>
                <span class="s">"expired_at = VALUES(expired_at), created_at = VALUES(created_at)"</span><span class="o">;</span>
        <span class="n">jdbcTemplate</span><span class="o">.</span><span class="na">update</span><span class="o">(</span><span class="n">sql</span><span class="o">,</span> <span class="no">CACHE_KEY</span><span class="o">,</span> <span class="n">json</span><span class="o">,</span>
                <span class="nc">LocalDateTime</span><span class="o">.</span><span class="na">now</span><span class="o">().</span><span class="na">plus</span><span class="o">(</span><span class="no">CACHE_TTL</span><span class="o">),</span> <span class="nc">LocalDateTime</span><span class="o">.</span><span class="na">now</span><span class="o">());</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"DB cache save failed"</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>

<span class="kd">private</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="nf">getFromDbCacheOrLoad</span><span class="o">(</span>
        <span class="nc">Supplier</span><span class="o">&lt;</span><span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;&gt;</span> <span class="n">dbLoader</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">sql</span> <span class="o">=</span> <span class="s">"SELECT data FROM gathering_cache "</span> <span class="o">+</span>
                <span class="s">"WHERE cache_key = ? AND expired_at &gt; NOW() LIMIT 1"</span><span class="o">;</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">results</span> <span class="o">=</span> <span class="n">jdbcTemplate</span><span class="o">.</span><span class="na">queryForList</span><span class="o">(</span><span class="n">sql</span><span class="o">,</span> <span class="nc">String</span><span class="o">.</span><span class="na">class</span><span class="o">,</span> <span class="no">CACHE_KEY</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(!</span><span class="n">results</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">return</span> <span class="nf">deserialize</span><span class="o">(</span><span class="n">results</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="mi">0</span><span class="o">));</span>
        <span class="o">}</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"DB cache read failed"</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="n">dbLoader</span><span class="o">.</span><span class="na">get</span><span class="o">();</span>  <span class="c1">// 최종 fallback: 직접 DB 조회</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ON DUPLICATE KEY UPDATE</code>로 캐시 키 기준 upsert. Redis 장애 시 이 테이블에서 만료되지 않은 데이터를 반환한다. DB 캐시마저 없으면 직접 DB 조회로 최종 fallback한다.</p>

<h3 id="4-메인-진입점-getorload">4. 메인 진입점: getOrLoad</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="nf">getOrLoad</span><span class="o">(</span><span class="nc">Supplier</span><span class="o">&lt;</span><span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;&gt;</span> <span class="n">dbLoader</span><span class="o">)</span> <span class="o">{</span>
    <span class="c1">// 1. Redis 시도</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">cached</span> <span class="o">=</span> <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">get</span><span class="o">(</span><span class="no">CACHE_KEY</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">cached</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">cacheHitCounter</span><span class="o">.</span><span class="na">increment</span><span class="o">();</span>
            <span class="n">triggerRefreshIfNeeded</span><span class="o">(</span><span class="n">dbLoader</span><span class="o">);</span>
            <span class="k">return</span> <span class="nf">deserialize</span><span class="o">(</span><span class="n">cached</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">cacheMissCounter</span><span class="o">.</span><span class="na">increment</span><span class="o">();</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"Redis read failed, fallback to DB cache"</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
        <span class="k">return</span> <span class="nf">getFromDbCacheOrLoad</span><span class="o">(</span><span class="n">dbLoader</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="c1">// 2. 캐시 미스 - 락 잡고 로드</span>
    <span class="n">cacheMissCounter</span><span class="o">.</span><span class="na">increment</span><span class="o">();</span>
    <span class="k">return</span> <span class="nf">loadWithLock</span><span class="o">(</span><span class="n">dbLoader</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="5-micrometer-캐시-히트율-모니터링">5. Micrometer 캐시 히트율 모니터링</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">this</span><span class="o">.</span><span class="na">cacheHitCounter</span> <span class="o">=</span> <span class="nc">Counter</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="s">"gathering_cache_hit"</span><span class="o">)</span>
        <span class="o">.</span><span class="na">description</span><span class="o">(</span><span class="s">"Gathering cache hit count"</span><span class="o">)</span>
        <span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">meterRegistry</span><span class="o">);</span>
<span class="k">this</span><span class="o">.</span><span class="na">cacheMissCounter</span> <span class="o">=</span> <span class="nc">Counter</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="s">"gathering_cache_miss"</span><span class="o">)</span>
        <span class="o">.</span><span class="na">description</span><span class="o">(</span><span class="s">"Gathering cache miss count"</span><span class="o">)</span>
        <span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">meterRegistry</span><span class="o">);</span>
</code></pre></div></div>

<p>Prometheus/Grafana에서 캐시 히트율을 실시간으로 모니터링할 수 있다.</p>

<h3 id="gatheringservice-v4">GatheringService: v4</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">ApiResponse</span> <span class="nf">gatheringsV4</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">MainGatheringsProjection</span><span class="o">&gt;</span> <span class="n">mainGatheringElements</span> <span class="o">=</span>
        <span class="n">gatheringCacheService</span><span class="o">.</span><span class="na">getOrLoad</span><span class="o">(</span><span class="k">this</span><span class="o">::</span><span class="n">loadGatheringsFromDb</span><span class="o">);</span>
    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">CategoryTotalGatherings</span><span class="o">&gt;</span> <span class="n">map</span> <span class="o">=</span> <span class="n">categorizeByCategory</span><span class="o">(</span><span class="n">mainGatheringElements</span><span class="o">);</span>
    <span class="k">return</span> <span class="nf">toMainGatheringResponse</span><span class="o">(</span><span class="n">map</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">loadGatheringsFromDb()</code>는 v3의 로직과 동일하다. 캐시 hit 시에는 DB 쿼리가 <strong>0번</strong> 발생한다.</p>

<h2 id="result">Result</h2>

<h3 id="locust-100명-부하테스트">Locust 100명 부하테스트</h3>

<p><img src="/assets/images/gathering/네번쨰%20부하테스트.png" alt="v4 부하테스트" /></p>

<ul>
  <li><strong>49.4 RPS, 0% 실패</strong></li>
  <li><strong>p50 11ms / p95 15ms</strong></li>
</ul>

<h3 id="전체-시리즈-비교">전체 시리즈 비교</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>v1 (튜닝 없음)</th>
      <th>v2 (쿼리 분리)</th>
      <th>v3 (ROW_NUMBER 제거)</th>
      <th>v4 (캐시)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>EXPLAIN ANALYZE</td>
      <td>389초</td>
      <td>5초</td>
      <td>0.17ms</td>
      <td>캐시 hit</td>
    </tr>
    <tr>
      <td>Locust RPS</td>
      <td>-</td>
      <td>0.5</td>
      <td>28</td>
      <td><strong>49.4</strong></td>
    </tr>
    <tr>
      <td>실패율</td>
      <td>-</td>
      <td>61%</td>
      <td>0%</td>
      <td><strong>0%</strong></td>
    </tr>
    <tr>
      <td>p50</td>
      <td>7분 38초</td>
      <td>84초</td>
      <td>1,600ms</td>
      <td><strong>11ms</strong></td>
    </tr>
    <tr>
      <td>p95</td>
      <td>-</td>
      <td>84초</td>
      <td>1,900ms</td>
      <td><strong>15ms</strong></td>
    </tr>
    <tr>
      <td>HikariCP Pending</td>
      <td>-</td>
      <td>91</td>
      <td>33</td>
      <td><strong>0</strong></td>
    </tr>
    <tr>
      <td>DBLoadCPU</td>
      <td>-</td>
      <td>2.47</td>
      <td>0.12</td>
      <td><strong>~0</strong></td>
    </tr>
  </tbody>
</table>

<h2 id="reflection">Reflection</h2>

<p>쿼리 튜닝만으로는 한계가 있다. 복합 인덱스로 개별 쿼리를 0.17ms까지 줄여도, <strong>카테고리 수만큼 쿼리가 발생하는 구조적 문제</strong>는 남는다. 이 한계를 인식하고 캐시 레이어를 도입한 것이 핵심 판단이었다.</p>

<p>캐시를 도입할 때 가장 중요한 질문은 “Redis가 죽으면?”이다. Redisson 분산 락으로 스탬피드를 막고, DB 캐시 테이블로 Redis 장애에 대비하고, Micrometer로 히트율을 모니터링하는 <strong>3중 안전장치</strong>가 운영 환경에서의 신뢰성을 보장한다.</p>

<hr />

<blockquote>
  <p><strong>모임 조회 API 시리즈 완결</strong>
<a href="/projects/gathering-query-v1-no-tuning/">1단계. 튜닝 없이 v1 쿼리 분석 — 389초</a>
<a href="/projects/gathering-query-v2-separation/">2단계. 쿼리 분리 + 복합 인덱스 — 389초 → 5초</a>
<a href="/projects/gathering-query-v3-remove-rownum/">3단계. ROW_NUMBER 제거 — 5초 → 317ms</a>
4단계. Redis 캐시 + Redisson 분산 락 — p50 11ms <em>(현재 글)</em></p>
</blockquote>]]></content><author><name>jude-z</name></author><category term="Projects" /><category term="Java" /><category term="Spring Boot" /><category term="Redis" /><category term="Redisson" /><category term="Locust" /><summary type="html"><![CDATA[카테고리 수만큼 쿼리가 발생하는 구조적 한계를 Redis 캐시 + Redisson 분산 락 + DB fallback으로 해결한 과정.]]></summary></entry><entry xml:lang="ko"><title type="html">JVM 클래스 로딩 과정과 실행 엔진 동작 원리</title><link href="https://jude-z.github.io/dev%20notes/jvm-classloader-execution-engine/" rel="alternate" type="text/html" title="JVM 클래스 로딩 과정과 실행 엔진 동작 원리" /><published>2026-03-17T00:00:00+00:00</published><updated>2026-03-17T00:00:00+00:00</updated><id>https://jude-z.github.io/dev%20notes/jvm-classloader-execution-engine</id><content type="html" xml:base="https://jude-z.github.io/dev%20notes/jvm-classloader-execution-engine/"><![CDATA[<blockquote>
  <p><strong>JVM Internals 시리즈</strong>
<a href="/dev%20notes/jvm-architecture-runtime-data/">1편. JVM 아키텍처와 런타임 데이터 영역</a>
2편. 클래스 로딩과 실행 엔진 <em>(현재 글)</em>
<a href="/dev%20notes/jvm-thread-stack-frame/">3편. JVM 스레드와 스택 프레임 구조</a></p>
</blockquote>

<hr />

<h2 id="클래스는-언제-메모리에-올라가는가">클래스는 언제 메모리에 올라가는가</h2>

<p>지난 편에서 Runtime Data Areas를 정리했는데, 그럼 <code class="language-plaintext highlighter-rouge">.class</code> 파일이 이 메모리 영역에 <strong>어떤 과정을 거쳐서 올라가는지</strong>가 이번 주제다.</p>

<p>Java에서 <code class="language-plaintext highlighter-rouge">new UserService()</code>를 호출하면, JVM은 먼저 <code class="language-plaintext highlighter-rouge">UserService</code> 클래스가 이미 로딩되어 있는지 확인한다. 로딩되어 있지 않으면 Class Loader가 작동한다.</p>

<p><img src="/assets/images/jvm/jvm-classloader-execution.png" alt="Class Loading &amp; Execution Engine" /></p>

<h2 id="class-loading-process">Class Loading Process</h2>

<p>클래스 로딩은 세 단계를 거친다: <strong>Loading → Linking → Initialization</strong></p>

<h3 id="1단계-loading">1단계: Loading</h3>

<p><code class="language-plaintext highlighter-rouge">.class</code> 파일의 바이트코드를 읽어서 Method Area에 저장하는 단계다. 이때 해당 클래스를 나타내는 <code class="language-plaintext highlighter-rouge">java.lang.Class</code> 객체가 Heap에 생성된다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 이 코드를 실행하면 Loading 단계에서 만들어진 Class 객체를 얻는다</span>
<span class="nc">Class</span><span class="o">&lt;?&gt;</span> <span class="n">clazz</span> <span class="o">=</span> <span class="nc">UserService</span><span class="o">.</span><span class="na">class</span><span class="o">;</span>
<span class="c1">// 또는</span>
<span class="nc">Class</span><span class="o">&lt;?&gt;</span> <span class="n">clazz</span> <span class="o">=</span> <span class="nc">Class</span><span class="o">.</span><span class="na">forName</span><span class="o">(</span><span class="s">"com.example.UserService"</span><span class="o">);</span>
</code></pre></div></div>

<p>Loading은 세 가지 종류의 Class Loader가 계층적으로 처리한다:</p>

<ol>
  <li><strong>Bootstrap Class Loader</strong> — <code class="language-plaintext highlighter-rouge">java.lang.*</code>, <code class="language-plaintext highlighter-rouge">java.util.*</code> 같은 핵심 Java API 로딩. C/C++로 구현되어 있어서 Java 코드에서 참조하면 null이 나온다.</li>
  <li><strong>Extension (Platform) Class Loader</strong> — <code class="language-plaintext highlighter-rouge">$JAVA_HOME/lib/ext</code> 디렉토리의 클래스 로딩. Java 9부터는 Platform Class Loader로 이름이 바뀜.</li>
  <li><strong>Application (System) Class Loader</strong> — classpath에 있는 애플리케이션 클래스 로딩. 우리가 작성한 코드는 대부분 여기서 로딩된다.</li>
</ol>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">ClassLoaderTest</span> <span class="o">{</span>
    <span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">main</span><span class="o">(</span><span class="nc">String</span><span class="o">[]</span> <span class="n">args</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// Application ClassLoader</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="nc">ClassLoaderTest</span><span class="o">.</span><span class="na">class</span><span class="o">.</span><span class="na">getClassLoader</span><span class="o">());</span>
        <span class="c1">// sun.misc.Launcher$AppClassLoader@...</span>

        <span class="c1">// Bootstrap ClassLoader (null로 표시)</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="nc">String</span><span class="o">.</span><span class="na">class</span><span class="o">.</span><span class="na">getClassLoader</span><span class="o">());</span>
        <span class="c1">// null</span>

        <span class="c1">// 부모 관계 확인</span>
        <span class="nc">ClassLoader</span> <span class="n">appLoader</span> <span class="o">=</span> <span class="nc">ClassLoaderTest</span><span class="o">.</span><span class="na">class</span><span class="o">.</span><span class="na">getClassLoader</span><span class="o">();</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">appLoader</span><span class="o">.</span><span class="na">getParent</span><span class="o">());</span>
        <span class="c1">// sun.misc.Launcher$ExtClassLoader@...</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">appLoader</span><span class="o">.</span><span class="na">getParent</span><span class="o">().</span><span class="na">getParent</span><span class="o">());</span>
        <span class="c1">// null (Bootstrap)</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="parent-delegation-model-부모-위임-모델">Parent Delegation Model (부모 위임 모델)</h3>

<p>클래스 로딩 요청이 들어오면, <strong>자기가 직접 로딩하지 않고 먼저 부모 Class Loader에게 위임</strong>한다. 부모가 못 찾으면 그때 자기가 로딩을 시도한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>요청: "com.example.UserService 로딩해줘"
Application ClassLoader → "부모한테 먼저 물어볼게"
  → Extension ClassLoader → "나도 부모한테 물어볼게"
    → Bootstrap ClassLoader → "나한테 없음"
  → Extension ClassLoader → "나한테도 없음"
→ Application ClassLoader → "내가 로딩함" ✓
</code></pre></div></div>

<p>이 모델의 목적은 <strong>핵심 Java 클래스를 보호</strong>하는 것이다. 만약 누군가 <code class="language-plaintext highlighter-rouge">java.lang.String</code> 클래스를 직접 만들어서 classpath에 넣어도, Bootstrap Class Loader가 먼저 진짜 String을 로딩하기 때문에 위조된 클래스는 무시된다.</p>

<h3 id="2단계-linking">2단계: Linking</h3>

<p>Linking은 다시 세 하위 단계로 나뉜다:</p>

<p><strong>Verification (검증)</strong></p>
<ul>
  <li>로딩된 바이트코드가 JVM 스펙에 맞는지 검사한다</li>
  <li>매직 넘버(<code class="language-plaintext highlighter-rouge">0xCAFEBABE</code>)가 맞는지, 바이트코드 구조가 유효한지 등</li>
  <li>잘못된 <code class="language-plaintext highlighter-rouge">.class</code> 파일이 JVM을 크래시시키는 걸 방지한다</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># .class 파일의 매직 넘버 확인</span>
xxd MyClass.class | <span class="nb">head</span> <span class="nt">-1</span>
<span class="c"># 00000000: cafe babe 0000 0034 ...</span>
</code></pre></div></div>

<p><strong>Preparation (준비)</strong></p>
<ul>
  <li>static 변수에 <strong>기본값</strong>을 할당한다 (실제 초기값이 아님!)</li>
  <li><code class="language-plaintext highlighter-rouge">int</code>는 0, <code class="language-plaintext highlighter-rouge">boolean</code>은 false, 참조 타입은 null</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">Config</span> <span class="o">{</span>
    <span class="kd">static</span> <span class="kt">int</span> <span class="n">maxRetry</span> <span class="o">=</span> <span class="mi">5</span><span class="o">;</span>  <span class="c1">// Preparation 단계에서는 0으로 초기화</span>
                               <span class="c1">// 5가 할당되는 건 Initialization 단계</span>
    <span class="kd">static</span> <span class="kt">boolean</span> <span class="n">enabled</span> <span class="o">=</span> <span class="kc">true</span><span class="o">;</span>  <span class="c1">// 여기서는 false</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>Resolution (해석)</strong></p>
<ul>
  <li>심볼릭 레퍼런스를 실제 메모리 주소(다이렉트 레퍼런스)로 변환한다</li>
  <li>Constant Pool의 심볼릭 참조가 실제 메모리 참조로 바뀌는 단계</li>
  <li>이 단계는 필요할 때(lazy) 수행될 수도 있다</li>
</ul>

<h3 id="3단계-initialization">3단계: Initialization</h3>

<p><strong>실제 초기값 할당</strong>이 이루어지는 단계다. static 변수에 코드에서 지정한 값이 할당되고, static 블록이 실행된다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">DatabaseConfig</span> <span class="o">{</span>
    <span class="c1">// Initialization 단계에서 "jdbc:mysql://localhost:3306/mydb"가 할당</span>
    <span class="kd">static</span> <span class="nc">String</span> <span class="n">url</span> <span class="o">=</span> <span class="s">"jdbc:mysql://localhost:3306/mydb"</span><span class="o">;</span>

    <span class="kd">static</span> <span class="o">{</span>
        <span class="c1">// static 초기화 블록도 이 단계에서 실행</span>
        <span class="nc">System</span><span class="o">.</span><span class="na">out</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"DatabaseConfig initialized"</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Initialization은 JVM이 보장하는 <strong>thread-safe</strong> 작업이다. 여러 스레드가 동시에 같은 클래스의 초기화를 시도해도, 실제로 초기화는 한 번만 수행된다. 이게 Singleton 패턴에서 <code class="language-plaintext highlighter-rouge">static inner class</code>를 활용하는 이유이기도 하다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// thread-safe한 Singleton (클래스 로딩의 Initialization 보장을 활용)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">Singleton</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="nf">Singleton</span><span class="o">()</span> <span class="o">{}</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">class</span> <span class="nc">Holder</span> <span class="o">{</span>
        <span class="c1">// Holder 클래스가 최초 참조될 때 한 번만 초기화</span>
        <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">Singleton</span> <span class="no">INSTANCE</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Singleton</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kd">static</span> <span class="nc">Singleton</span> <span class="nf">getInstance</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">Holder</span><span class="o">.</span><span class="na">INSTANCE</span><span class="o">;</span>  <span class="c1">// Holder 클래스 로딩 트리거</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="execution-engine">Execution Engine</h2>

<p>클래스가 메모리에 올라갔으면 이제 <strong>실행</strong>할 차례다. Execution Engine은 Method Area에 있는 바이트코드를 읽어서 실행한다.</p>

<h3 id="interpreter">Interpreter</h3>

<p>바이트코드 명령어를 <strong>한 줄씩</strong> 해석하고 실행한다. 시작 속도가 빠르지만, 같은 메서드를 반복 호출해도 매번 해석하기 때문에 전체적인 성능은 떨어진다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 이 메서드가 10000번 호출되면, Interpreter는 매번 바이트코드를 해석한다</span>
<span class="kd">public</span> <span class="kt">int</span> <span class="nf">add</span><span class="o">(</span><span class="kt">int</span> <span class="n">a</span><span class="o">,</span> <span class="kt">int</span> <span class="n">b</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">a</span> <span class="o">+</span> <span class="n">b</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="jit-compiler-just-in-time-compiler">JIT Compiler (Just-In-Time Compiler)</h3>

<p>Interpreter의 성능 문제를 보완한다. <strong>자주 실행되는 코드(Hot Code)</strong>를 감지해서 네이티브 코드로 컴파일한다. 한 번 컴파일되면 이후에는 네이티브 코드를 직접 실행하므로 훨씬 빠르다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># JIT 컴파일 로그 확인</span>
java <span class="nt">-XX</span>:+PrintCompilation <span class="nt">-jar</span> app.jar

<span class="c"># 출력 예시:</span>
<span class="c"># 100   1       java.lang.String::hashCode (55 bytes)</span>
<span class="c"># 110   2       java.lang.String::equals (81 bytes)</span>
</code></pre></div></div>

<p>HotSpot JVM에는 두 가지 JIT 컴파일러가 있다:</p>

<ul>
  <li><strong>C1 (Client Compiler)</strong> — 빠르게 컴파일하지만 최적화 수준이 낮음. 시작 속도가 중요한 클라이언트 애플리케이션에 적합.</li>
  <li><strong>C2 (Server Compiler)</strong> — 컴파일은 느리지만 고도로 최적화된 코드를 생성. 장시간 실행되는 서버 애플리케이션에 적합.</li>
</ul>

<p>현대 JVM은 <strong>Tiered Compilation</strong>을 사용한다. 처음에는 Interpreter로 시작하고, 자주 호출되는 메서드는 C1으로 먼저 컴파일, 더 자주 호출되면 C2로 재컴파일한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Tiered Compilation (Java 8부터 기본 활성화)</span>
java <span class="nt">-XX</span>:+TieredCompilation <span class="nt">-jar</span> app.jar

<span class="c"># 컴파일 임계값 확인 (기본 10000)</span>
java <span class="nt">-XX</span>:+PrintFlagsFinal <span class="nt">-version</span> | <span class="nb">grep </span>CompileThreshold
</code></pre></div></div>

<p>JIT가 수행하는 대표적인 최적화:</p>
<ul>
  <li><strong>Inlining</strong> — 메서드 호출을 메서드 본문으로 대체</li>
  <li><strong>Dead Code Elimination</strong> — 실행되지 않는 코드 제거</li>
  <li><strong>Loop Unrolling</strong> — 루프 반복을 펼쳐서 브랜치 오버헤드 제거</li>
  <li><strong>Escape Analysis</strong> — 객체가 메서드 밖으로 나가지 않으면 스택에 할당</li>
</ul>

<h3 id="garbage-collector-gc">Garbage Collector (GC)</h3>

<p>Heap에서 <strong>더 이상 참조되지 않는 객체</strong>를 찾아서 메모리를 회수한다. 이건 주제가 너무 넓어서 별도로 정리할 내용이지만, Execution Engine의 구성 요소라는 점은 알고 있어야 한다.</p>

<p>간단하게만 짚으면:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># GC 종류 선택</span>
java <span class="nt">-XX</span>:+UseG1GC <span class="nt">-jar</span> app.jar        <span class="c"># G1 GC (Java 9+ 기본)</span>
java <span class="nt">-XX</span>:+UseZGC <span class="nt">-jar</span> app.jar          <span class="c"># ZGC (저지연)</span>
java <span class="nt">-XX</span>:+UseShenandoahGC <span class="nt">-jar</span> app.jar <span class="c"># Shenandoah GC</span>
</code></pre></div></div>

<p>GC가 동작할 때 <strong>Stop-the-World(STW)</strong> 이벤트가 발생한다. 모든 애플리케이션 스레드가 멈추고 GC 스레드만 작동한다. 최신 GC(ZGC, Shenandoah)는 이 STW 시간을 최소화하는 데 초점을 맞추고 있다.</p>

<h2 id="바이트코드-실행-흐름-요약">바이트코드 실행 흐름 요약</h2>

<p>전체 흐름을 하나로 이으면 이렇다:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.java 파일
  → javac (컴파일) → .class 파일 (바이트코드)
  → Class Loader (Loading → Linking → Initialization)
  → Method Area에 클래스 정보 저장
  → Execution Engine (Interpreter / JIT)이 바이트코드 실행
  → GC가 Heap의 미사용 객체 정리
</code></pre></div></div>

<p>JVM의 강점은 이 구조 덕분에 <strong>Write Once, Run Anywhere</strong>가 가능하다는 것이다. <code class="language-plaintext highlighter-rouge">.class</code> 파일은 플랫폼에 독립적이고, 각 OS별 JVM이 해당 플랫폼에 맞게 실행을 처리한다.</p>

<p>다음 편에서는 JVM 내부의 스레드 종류와 Stack Frame의 세부 구조를 정리한다.</p>

<h2 id="참고">참고</h2>

<ul>
  <li><a href="https://blog.jamesdbloom.com/JVMInternals.html">JVM Internals — James D Bloom</a></li>
  <li><a href="https://docs.oracle.com/javase/specs/jvms/se17/html/">The Java Virtual Machine Specification (Java SE 17)</a></li>
</ul>]]></content><author><name>jude-z</name></author><category term="Dev Notes" /><category term="JVM" /><category term="Java" /><summary type="html"><![CDATA[Class Loader의 Loading → Linking → Initialization 과정과 Execution Engine의 Interpreter, JIT Compiler, GC를 정리한다.]]></summary></entry><entry xml:lang="ko"><title type="html">결제 테스트 1단계 — 멱등성 없이 100개의 스레드 동시 결제, 무슨 일이 벌어지는가</title><link href="https://jude-z.github.io/projects/trip-payment-v1-no-idempotency/" rel="alternate" type="text/html" title="결제 테스트 1단계 — 멱등성 없이 100개의 스레드 동시 결제, 무슨 일이 벌어지는가" /><published>2026-03-12T00:00:00+00:00</published><updated>2026-03-12T00:00:00+00:00</updated><id>https://jude-z.github.io/projects/trip-payment-v1-no-idempotency</id><content type="html" xml:base="https://jude-z.github.io/projects/trip-payment-v1-no-idempotency/"><![CDATA[<h2 id="problem">Problem</h2>

<p>토스페이먼츠 연동 결제 시스템에서 <strong>동시에 동일한 결제 요청이 여러 번 들어오면</strong> 어떤 일이 벌어지는가?</p>

<p>클라이언트의 네트워크 타임아웃, 버튼 더블 클릭, 혹은 로드밸런서 재시도 등으로 동일한 결제 요청이 여러 번 서버에 도달할 수 있다. 멱등성 처리가 없다면 <strong>모든 요청이 PG사에 승인 요청</strong>으로 들어간다.</p>

<p>이 테스트의 목적은 단순하다:</p>
<blockquote>
  <p>“보호 장치 없이 100개의 동시 결제 요청을 보내면, 정확히 어떤 데이터 불일치가 발생하는가?”</p>
</blockquote>

<h2 id="action">Action</h2>

<h3 id="시스템-아키텍처--보호-없는-결제-흐름">시스템 아키텍처 — 보호 없는 결제 흐름</h3>

<p><img src="/assets/images/trip-payment/v1-no-idempotency.png" alt="V1 시스템 아키텍처 — 멱등성 없음, 중복 결제 발생" /></p>

<p>V1 confirm 메서드는 어떤 멱등성 체크도 없이, 요청이 들어오면 <strong>즉시 PG에 승인을 요청</strong>한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">ApiResponse</span> <span class="nf">confirmNoIdempotency</span><span class="o">(</span><span class="nc">PaymentRequest</span> <span class="n">paymentRequest</span><span class="o">,</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
    <span class="c1">// 멱등성 체크 없음 — 모든 요청이 그대로 PG로 전달</span>
    <span class="kt">boolean</span> <span class="n">confirm</span> <span class="o">=</span> <span class="n">paymentClient</span><span class="o">.</span><span class="na">confirm</span><span class="o">(</span><span class="n">paymentRequest</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">confirm</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">paymentFacade</span><span class="o">.</span><span class="na">processConfirm</span><span class="o">(</span><span class="n">paymentRequest</span><span class="o">,</span> <span class="n">id</span><span class="o">);</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="nc">ApiStatusResponse</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">SUCCESS</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Redis 체크도 없고, DB 중복 확인도 없다. 100개의 스레드가 동시에 이 메서드를 호출하면, <strong>100개가 모두 paymentClient.confirm()에 도달</strong>한다.</p>

<h3 id="테스트-코드--100개의-스레드-동시-요청">테스트 코드 — 100개의 스레드 동시 요청</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"V1: 멱등성 없음 — 동시 100건 요청 시 중복 결제 발생"</span><span class="o">)</span>
<span class="kt">void</span> <span class="nf">v1_noIdempotency_concurrentRequests</span><span class="o">()</span> <span class="kd">throws</span> <span class="nc">InterruptedException</span> <span class="o">{</span>
    <span class="kt">int</span> <span class="n">threadCount</span> <span class="o">=</span> <span class="mi">100</span><span class="o">;</span>
    <span class="nc">ExecutorService</span> <span class="n">executorService</span> <span class="o">=</span> <span class="nc">Executors</span><span class="o">.</span><span class="na">newFixedThreadPool</span><span class="o">(</span><span class="n">threadCount</span><span class="o">);</span>
    <span class="nc">CountDownLatch</span> <span class="n">latch</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">CountDownLatch</span><span class="o">(</span><span class="n">threadCount</span><span class="o">);</span>
    <span class="nc">AtomicInteger</span> <span class="n">successCount</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AtomicInteger</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>
    <span class="nc">AtomicInteger</span> <span class="n">failCount</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AtomicInteger</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>

    <span class="nc">String</span> <span class="n">body</span> <span class="o">=</span> <span class="s">"""
            {
                "</span><span class="n">paymentKey</span><span class="s">": "</span><span class="n">paymentKey1</span><span class="s">",
                "</span><span class="n">orderId</span><span class="s">": "</span><span class="n">order1</span><span class="s">",
                "</span><span class="n">amount</span><span class="s">": 10000
            }
            """</span><span class="o">;</span>

    <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">threadCount</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
        <span class="n">executorService</span><span class="o">.</span><span class="na">submit</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="k">try</span> <span class="o">{</span>
                <span class="c1">// 동일한 paymentKey로 100개 동시 요청</span>
                <span class="kt">int</span> <span class="n">status</span> <span class="o">=</span> <span class="n">sendConfirmRequest</span><span class="o">(</span><span class="s">"/api/v1/confirm"</span><span class="o">,</span> <span class="n">body</span><span class="o">);</span>
                <span class="k">if</span> <span class="o">(</span><span class="n">status</span> <span class="o">==</span> <span class="mi">200</span><span class="o">)</span> <span class="n">successCount</span><span class="o">.</span><span class="na">incrementAndGet</span><span class="o">();</span>
                <span class="k">else</span> <span class="n">failCount</span><span class="o">.</span><span class="na">incrementAndGet</span><span class="o">();</span>
            <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
                <span class="n">latch</span><span class="o">.</span><span class="na">countDown</span><span class="o">();</span>
            <span class="o">}</span>
        <span class="o">});</span>
    <span class="o">}</span>
    <span class="n">latch</span><span class="o">.</span><span class="na">await</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>100개의 스레드가 <strong>동일한 paymentKey1</strong>으로 동시에 <code class="language-plaintext highlighter-rouge">/api/v1/confirm</code>을 호출한다. CountDownLatch로 모든 스레드가 완료될 때까지 대기한다.</p>

<h2 id="result">Result</h2>

<h3 id="테스트-실행-결과">테스트 실행 결과</h3>

<p><img src="/assets/images/trip-payment/v1%20테스트화면.png" alt="V1 테스트 화면" /></p>

<p>IntelliJ 테스트 콘솔에서 확인된 결과:</p>
<ul>
  <li><strong>성공: 1건, 실패: 99건</strong></li>
  <li>실패 원인: <code class="language-plaintext highlighter-rouge">404 Not Found Temp Payment</code> — 첫 번째 요청이 TempPayment를 삭제한 후, 나머지 99개의 요청이 TempPayment를 찾지 못해 실패</li>
</ul>

<p>언뜻 보면 “1건만 성공했으니 문제 없는 것 아닌가?”라고 생각할 수 있다. 하지만 <strong>PG서버 쪽 기록을 보면 상황이 다르다.</strong></p>

<h3 id="pg-서버-db--100건-저장">PG 서버 DB — 100건 저장</h3>

<p><img src="/assets/images/trip-payment/v1%20pg서버%20db저장내역.png" alt="V1 PG서버 DB" /></p>

<p>mock PG 서버의 <code class="language-plaintext highlighter-rouge">payment</code> 테이블에는 <strong>동일한 paymentKey1으로 100건이 저장</strong>되어 있다. 모든 요청이 PG 승인까지 도달했다는 뜻이다. PG 서버 입장에서는 100번의 결제 승인이 발생한 것이다.</p>

<h3 id="실제-db--1건-저장">실제 DB — 1건 저장</h3>

<p><img src="/assets/images/trip-payment/v1%20실제%20db%20저장내역.png" alt="V1 실제 DB" /></p>

<p>실제 서비스 DB의 <code class="language-plaintext highlighter-rouge">payment</code> 테이블에는 <strong>1건만 COMPLETE 상태</strong>로 저장되어 있다.</p>

<h3 id="데이터-불일치의-본질">데이터 불일치의 본질</h3>

<table>
  <thead>
    <tr>
      <th>저장소</th>
      <th>건수</th>
      <th>상태</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PG 서버 (mock.payment)</td>
      <td><strong>100건</strong></td>
      <td>전부 승인됨</td>
    </tr>
    <tr>
      <td>실제 서비스 DB (payment)</td>
      <td><strong>1건</strong></td>
      <td>COMPLETE</td>
    </tr>
  </tbody>
</table>

<p>PG에서는 100번 돈을 빼갔는데, 서비스에는 1건만 기록되어 있다. <strong>99건의 결제가 유령 결제</strong>가 된 것이다. 실제 운영 환경이었다면 유저의 돈은 100번 빠져나가고, 서비스에는 1건만 반영되는 치명적인 상황이다.</p>

<h2 id="reflection">Reflection</h2>

<p>이 테스트가 증명하는 것은 단순하다: <strong>멱등성 없는 결제 시스템은 동시 요청에 취약하다.</strong></p>

<p>흥미로운 점은 서버 응답만 보면 “1건 성공, 99건 실패”로 문제가 없어 보인다는 것이다. 하지만 실패 응답이 돌아오기 <strong>전에</strong> PG 승인은 이미 완료된 상태다. HTTP 응답 코드가 실패라고 해서 PG 승인이 취소되는 것이 아니다.</p>

<p>결제 시스템의 정합성은 <strong>우리 서버의 응답이 아니라, PG 서버의 상태</strong>를 기준으로 판단해야 한다. 이 간극을 메우는 것이 멱등성의 역할이다.</p>

<hr />

<blockquote>
  <p>PG에 100번 요청이 들어가는 것 자체를 막아야 한다. 첫 번째 요청만 통과시키고 나머지는 차단하려면?
<a href="/projects/trip-payment-v2-redis-idempotency/">2단계: Redis setIfAbsent로 동시 요청 차단 →</a></p>
</blockquote>]]></content><author><name>jude-z</name></author><category term="Projects" /><category term="Java" /><category term="Spring Boot" /><category term="Payment" /><category term="Toss Payments" /><category term="Concurrency" /><summary type="html"><![CDATA[멱등성 보호 없이 100개의 동시 결제 요청을 보내면 PG에는 100건, 실제 DB에는 1건만 저장되는 데이터 불일치를 테스트로 증명한다.]]></summary></entry><entry xml:lang="ko"><title type="html">결제 테스트 2단계 — Redis 멱등성은 동작하지만, Redis가 죽으면?</title><link href="https://jude-z.github.io/projects/trip-payment-v2-redis-idempotency/" rel="alternate" type="text/html" title="결제 테스트 2단계 — Redis 멱등성은 동작하지만, Redis가 죽으면?" /><published>2026-03-12T00:00:00+00:00</published><updated>2026-03-12T00:00:00+00:00</updated><id>https://jude-z.github.io/projects/trip-payment-v2-redis-idempotency</id><content type="html" xml:base="https://jude-z.github.io/projects/trip-payment-v2-redis-idempotency/"><![CDATA[<h2 id="problem">Problem</h2>

<p><a href="/projects/trip-payment-v1-no-idempotency/">1단계</a>에서 멱등성 없이 100개의 스레드를 보내면 PG에 100건이 승인되는 것을 확인했다. 해결책은 명확하다 — <strong>첫 번째 요청만 PG에 전달하고, 나머지는 차단</strong>해야 한다.</p>

<p>Redis의 <code class="language-plaintext highlighter-rouge">setIfAbsent</code>(SETNX)는 원자적 연산이다. 동시에 100개의 요청이 들어와도 <strong>딱 하나만 true를 반환</strong>한다. Redis 단일 스레드 특성상 경쟁 조건이 발생하지 않는다.</p>

<p>하지만 질문이 남는다:</p>
<blockquote>
  <p>“Redis가 정상일 때는 완벽하다. 그런데 Redis가 내려가면?”</p>
</blockquote>

<p>이 테스트는 두 가지를 검증한다:</p>
<ol>
  <li>Redis가 정상일 때 멱등성이 동작하는가</li>
  <li>Redis가 장애일 때 무슨 일이 벌어지는가</li>
</ol>

<h2 id="action">Action</h2>

<h3 id="시스템-아키텍처--redis-멱등성과-장애-시나리오">시스템 아키텍처 — Redis 멱등성과 장애 시나리오</h3>

<p><img src="/assets/images/trip-payment/v2-redis-idempotency.png" alt="V2 시스템 아키텍처 — Redis 멱등성 정상 동작과 SPOF 문제" /></p>

<h3 id="v2-코드-redis-멱등성--장애-시-스킵">V2 코드: Redis 멱등성 + 장애 시 스킵</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">ApiResponse</span> <span class="nf">confirmRedisFail</span><span class="o">(</span><span class="nc">String</span> <span class="n">idempotencyKey</span><span class="o">,</span>
                                     <span class="nc">PaymentRequest</span> <span class="n">paymentRequest</span><span class="o">,</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(!</span><span class="n">idempotencyRedisManager</span><span class="o">.</span><span class="na">tryAcquire</span><span class="o">(</span><span class="n">idempotencyKey</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">CommonException</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">ALREADY_PAYMENT_REQUEST</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">CommonException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="n">e</span><span class="o">;</span>  <span class="c1">// 이미 처리된 요청 — 그대로 거부</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// Redis 장애 시 — 멱등성 체크를 건너뛴다</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"[V2] Redis 장애 발생 - 멱등성 체크 스킵. key={}"</span><span class="o">,</span> <span class="n">idempotencyKey</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kt">boolean</span> <span class="n">confirm</span> <span class="o">=</span> <span class="n">paymentClient</span><span class="o">.</span><span class="na">confirm</span><span class="o">(</span><span class="n">paymentRequest</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">confirm</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">paymentFacade</span><span class="o">.</span><span class="na">processConfirm</span><span class="o">(</span><span class="n">paymentRequest</span><span class="o">,</span> <span class="n">id</span><span class="o">);</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="nc">ApiStatusResponse</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">SUCCESS</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>핵심은 <code class="language-plaintext highlighter-rouge">catch (Exception e)</code> 블록이다. Redis 연결 실패, 타임아웃 등 Redis 관련 예외가 발생하면 <strong>경고 로그만 남기고 결제를 진행</strong>한다. “Redis가 죽었으니 결제도 안 되면 안 되지 않나?”라는 판단이다.</p>

<h3 id="idempotencyredismanager--원자적-락-획득">IdempotencyRedisManager — 원자적 락 획득</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">tryAcquire</span><span class="o">(</span><span class="nc">String</span> <span class="n">key</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Boolean</span> <span class="n">result</span> <span class="o">=</span> <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">()</span>
        <span class="o">.</span><span class="na">setIfAbsent</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="nc">IdempotencyStatus</span><span class="o">.</span><span class="na">PROCESSING</span><span class="o">.</span><span class="na">name</span><span class="o">(),</span>
                     <span class="nc">Duration</span><span class="o">.</span><span class="na">ofHours</span><span class="o">(</span><span class="mi">24</span><span class="o">));</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">result</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">CommonException</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">REDIS_SERVER_ERROR</span><span class="o">);</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="n">result</span><span class="o">;</span>  <span class="c1">// true: 첫 요청, false: 중복 요청</span>
<span class="o">}</span>

<span class="kd">public</span> <span class="kt">void</span> <span class="nf">complete</span><span class="o">(</span><span class="nc">String</span> <span class="n">key</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">set</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="nc">IdempotencyStatus</span><span class="o">.</span><span class="na">COMPLETED</span><span class="o">.</span><span class="na">name</span><span class="o">());</span>
<span class="o">}</span>

<span class="kd">public</span> <span class="kt">void</span> <span class="nf">fail</span><span class="o">(</span><span class="nc">String</span> <span class="n">key</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">redisTemplate</span><span class="o">.</span><span class="na">delete</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>  <span class="c1">// 키 삭제 → 재시도 허용</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">tryAcquire()</code>: <code class="language-plaintext highlighter-rouge">SETNX</code> + 24시간 TTL. 첫 요청만 true.</li>
  <li><code class="language-plaintext highlighter-rouge">complete()</code>: 성공 시 상태를 COMPLETED로 변경</li>
  <li><code class="language-plaintext highlighter-rouge">fail()</code>: 실패 시 키를 삭제하여 재시도를 허용</li>
</ul>

<h3 id="테스트-1-redis-정상--100개의-스레드-동시-요청">테스트 1: Redis 정상 — 100개의 스레드 동시 요청</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"V2: Redis 정상 — 동시 100건 중 1건만 PG 호출"</span><span class="o">)</span>
<span class="kt">void</span> <span class="nf">v2_redisNormal_concurrentRequests</span><span class="o">()</span> <span class="kd">throws</span> <span class="nc">InterruptedException</span> <span class="o">{</span>
    <span class="kt">int</span> <span class="n">threadCount</span> <span class="o">=</span> <span class="mi">100</span><span class="o">;</span>
    <span class="nc">ExecutorService</span> <span class="n">executorService</span> <span class="o">=</span> <span class="nc">Executors</span><span class="o">.</span><span class="na">newFixedThreadPool</span><span class="o">(</span><span class="n">threadCount</span><span class="o">);</span>
    <span class="nc">CountDownLatch</span> <span class="n">latch</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">CountDownLatch</span><span class="o">(</span><span class="n">threadCount</span><span class="o">);</span>

    <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">threadCount</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
        <span class="n">executorService</span><span class="o">.</span><span class="na">submit</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="k">try</span> <span class="o">{</span>
                <span class="n">sendConfirmRequest</span><span class="o">(</span><span class="s">"/api/v2/confirm"</span><span class="o">,</span> <span class="n">body</span><span class="o">,</span> <span class="s">"paymentKey1"</span><span class="o">);</span>
            <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
                <span class="n">latch</span><span class="o">.</span><span class="na">countDown</span><span class="o">();</span>
            <span class="o">}</span>
        <span class="o">});</span>
    <span class="o">}</span>
    <span class="n">latch</span><span class="o">.</span><span class="na">await</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>100개의 스레드가 동일한 <code class="language-plaintext highlighter-rouge">Idempotency-Key: paymentKey1</code>로 동시 요청한다.</p>

<h3 id="테스트-2-redis-장애-시뮬레이션">테스트 2: Redis 장애 시뮬레이션</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="nd">@DisplayName</span><span class="o">(</span><span class="s">"V2: Redis 장애 — 멱등성 뚫림 확인"</span><span class="o">)</span>
<span class="kt">void</span> <span class="nf">v2_redisFailure_concurrentRequests</span><span class="o">()</span> <span class="kd">throws</span> <span class="nc">InterruptedException</span> <span class="o">{</span>
    <span class="c1">// Phase 1: Redis 정상 — 50개의 스레드</span>
    <span class="c1">// → 1건만 PG 도달</span>

    <span class="nc">Thread</span><span class="o">.</span><span class="na">sleep</span><span class="o">(</span><span class="mi">10000</span><span class="o">);</span>  <span class="c1">// Redis 수동 중지</span>

    <span class="c1">// Phase 2: Redis DOWN — 50개의 스레드</span>
    <span class="c1">// → catch 블록에서 스킵 → 전부 PG 도달</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Phase 1에서 50개의 스레드를 보내고, Redis를 수동으로 중지한 후 Phase 2에서 50개의 스레드를 추가로 보낸다.</p>

<h2 id="result">Result</h2>

<h3 id="테스트-1-결과-redis-정상">테스트 1 결과: Redis 정상</h3>

<p><img src="/assets/images/trip-payment/v2%20pg서버%20db%20저장내역.png" alt="V2 PG서버 DB" /></p>

<p>mock PG 서버에 <strong>1건만 저장</strong>. Redis <code class="language-plaintext highlighter-rouge">setIfAbsent</code>가 99개의 중복 요청을 완벽하게 차단했다.</p>

<p><img src="/assets/images/trip-payment/v2%20실제%20db%20저장내역.png" alt="V2 실제 DB" /></p>

<p>실제 DB의 <code class="language-plaintext highlighter-rouge">point</code> 테이블에도 정상적으로 10,000원이 적립됐다. V1에서 발생했던 데이터 불일치가 <strong>완전히 해소</strong>됐다.</p>

<table>
  <thead>
    <tr>
      <th>저장소</th>
      <th>V1 (멱등성 없음)</th>
      <th>V2 (Redis 정상)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PG 서버</td>
      <td>100건</td>
      <td><strong>1건</strong></td>
    </tr>
    <tr>
      <td>실제 DB</td>
      <td>1건</td>
      <td><strong>1건</strong></td>
    </tr>
    <tr>
      <td>포인트</td>
      <td>불확실</td>
      <td><strong>10,000원 정상</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="테스트-2-결과-redis-장애">테스트 2 결과: Redis 장애</h3>

<p><img src="/assets/images/trip-payment/v2%20실제%20테스트%20내역.png" alt="V2 Redis 장애 테스트" /></p>

<p>Redis가 내려간 Phase 2에서는:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">[V2] Redis 장애 발생 - 멱등성 체크 스킵</code> 경고 로그 출력</li>
  <li>모든 요청이 <code class="language-plaintext highlighter-rouge">catch (Exception e)</code> 블록을 타고 <strong>PG까지 도달</strong></li>
  <li>성공 1건, 실패 99건 (V1과 동일한 패턴)</li>
</ul>

<p><strong>결론: Redis가 죽으면 V1과 동일한 상황이 재현된다.</strong> Redis가 단일 장애점(SPOF)이 된 것이다.</p>

<h3 id="왜-catch에서-결제를-진행하는가">왜 catch에서 결제를 진행하는가?</h3>

<p>“Redis 장애 시 결제를 차단하면 되지 않나?”라는 의문이 들 수 있다. 하지만 <strong>Redis 장애 때문에 결제 자체가 불가능해지면</strong>, 그것은 또 다른 장애다. 가용성과 정합성 사이의 트레이드오프에서, V2는 <strong>가용성을 선택하고 정합성을 희생</strong>한 것이다.</p>

<h2 id="reflection">Reflection</h2>

<p>V2가 증명한 것은 두 가지다:</p>

<p><strong>1. Redis 멱등성은 동작한다.</strong> setIfAbsent의 원자성 덕분에 100개의 동시 요청 중 단 1건만 통과시키는 것이 가능하다. 정상 상황에서는 완벽한 해결책이다.</p>

<p><strong>2. Redis는 단일 장애점이 된다.</strong> Redis에 의존하는 모든 보호 장치는 Redis와 함께 사라진다. catch 블록에서 “일단 진행”하는 판단은 가용성을 지키지만, 결제 정합성이라는 핵심 가치를 포기한 것이다.</p>

<p>결제 시스템에서 정합성을 포기하는 것은 허용되지 않는다. Redis가 죽어도 멱등성이 보장되려면, <strong>Redis가 아닌 다른 곳에도 멱등성 상태를 저장</strong>해야 한다. 그것이 DB다.</p>

<p>하지만 그 전에 먼저 검증할 것이 있다 — PG 승인까지 성공했는데 <strong>DB 저장이 실패하면?</strong> 이 시나리오를 의도적으로 만들어 Webhook이 복구할 수 있는지 테스트해야 한다.</p>

<hr />

<blockquote>
  <p>Redis 멱등성은 정상 시 완벽하지만 SPOF 문제가 있다. 그런데 더 근본적인 질문 — PG 승인 후 DB 저장 자체가 실패하면?
<a href="/projects/trip-payment-v3-webhook-recovery/">3단계: DB 저장 실패 시 Webhook이 복구할 수 있는가 →</a></p>
</blockquote>]]></content><author><name>jude-z</name></author><category term="Projects" /><category term="Java" /><category term="Spring Boot" /><category term="Redis" /><category term="Payment" /><category term="Concurrency" /><summary type="html"><![CDATA[Redis setIfAbsent로 동시 결제를 차단하는 데 성공했지만, Redis 장애 시 멱등성이 뚫리는 SPOF 문제를 테스트로 증명한다.]]></summary></entry><entry xml:lang="ko"><title type="html">결제 테스트 3단계 — PG 승인 후 DB 저장 실패, Webhook이 살릴 수 있는가</title><link href="https://jude-z.github.io/projects/trip-payment-v3-webhook-recovery/" rel="alternate" type="text/html" title="결제 테스트 3단계 — PG 승인 후 DB 저장 실패, Webhook이 살릴 수 있는가" /><published>2026-03-12T00:00:00+00:00</published><updated>2026-03-12T00:00:00+00:00</updated><id>https://jude-z.github.io/projects/trip-payment-v3-webhook-recovery</id><content type="html" xml:base="https://jude-z.github.io/projects/trip-payment-v3-webhook-recovery/"><![CDATA[<h2 id="problem">Problem</h2>

<p><a href="/projects/trip-payment-v2-redis-idempotency/">2단계</a>에서 Redis 멱등성이 정상 시 동작하는 것을 확인했다. 하지만 결제 흐름에는 또 다른 위험 구간이 존재한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>클라이언트 → 서버 → Redis 체크 ✓ → PG 승인 ✓ → DB 저장 ???
</code></pre></div></div>

<p>PG 승인까지 성공한 시점에서 <strong>서버가 죽거나 DB 커넥션이 끊기면</strong>, Payment가 저장되지 않는다. 유저의 돈은 빠져나갔는데 서비스에는 기록이 없는 <strong>결제 유실</strong>이 발생한다.</p>

<p>토스페이먼츠는 결제 승인 후 <strong>Webhook으로 결과를 통보</strong>한다. 이 Webhook이 결제 유실을 복구할 수 있는지 테스트해야 한다.</p>

<blockquote>
  <p>“PG 승인은 됐는데 DB 저장이 실패하면, Webhook만으로 복구가 되는가?”</p>
</blockquote>

<p>이 질문에 답하기 위해, <strong>의도적으로 DB 저장을 스킵</strong>하고 Webhook 복구를 검증했다.</p>

<h2 id="action">Action</h2>

<h3 id="시스템-아키텍처--db-스킵--webhook-복구-흐름">시스템 아키텍처 — DB 스킵 + Webhook 복구 흐름</h3>

<p><img src="/assets/images/trip-payment/v3-webhook-recovery.png" alt="V3 아키텍처" /></p>

<h3 id="v3-코드-의도적-db-저장-스킵">V3 코드: 의도적 DB 저장 스킵</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">ApiResponse</span> <span class="nf">confirmNoDbSave</span><span class="o">(</span><span class="nc">String</span> <span class="n">idempotencyKey</span><span class="o">,</span>
                                    <span class="nc">PaymentRequest</span> <span class="n">paymentRequest</span><span class="o">,</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(!</span><span class="n">idempotencyRedisManager</span><span class="o">.</span><span class="na">tryAcquire</span><span class="o">(</span><span class="n">idempotencyKey</span><span class="o">))</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">CommonException</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">ALREADY_PAYMENT_REQUEST</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"[NO_DB_SAVE] DB 멱등성 저장 스킵 - 웹훅으로 보완 필요. key={}"</span><span class="o">,</span> <span class="n">idempotencyKey</span><span class="o">);</span>

    <span class="kt">boolean</span> <span class="n">confirm</span> <span class="o">=</span> <span class="n">paymentClient</span><span class="o">.</span><span class="na">confirm</span><span class="o">(</span><span class="n">paymentRequest</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">confirm</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// 핵심: processConfirm()을 호출하지 않는다</span>
        <span class="c1">// → Payment 미생성, 포인트 미적립, TempPayment 미삭제</span>
        <span class="n">idempotencyRedisManager</span><span class="o">.</span><span class="na">complete</span><span class="o">(</span><span class="n">idempotencyKey</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
        <span class="n">idempotencyRedisManager</span><span class="o">.</span><span class="na">fail</span><span class="o">(</span><span class="n">idempotencyKey</span><span class="o">);</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="nc">ApiStatusResponse</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">SUCCESS</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">paymentFacade.processConfirm()</code>을 <strong>의도적으로 호출하지 않는다</strong>. PG 승인은 성공하지만:</p>
<ul>
  <li>Payment 레코드 <strong>미생성</strong></li>
  <li>포인트 <strong>미적립</strong></li>
  <li>TempPayment는 <strong>PENDING 상태 그대로</strong></li>
</ul>

<p>실제 운영에서 DB 장애가 발생한 상황을 시뮬레이션한 것이다.</p>

<h3 id="webhook-복구-로직-webhookservice">Webhook 복구 로직: WebHookService</h3>

<p>PG가 결제 완료를 Webhook으로 통보하면, <code class="language-plaintext highlighter-rouge">WebHookService</code>가 복구를 시도한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">WebHookService</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">processWebHook</span><span class="o">(</span><span class="nc">JsonNode</span> <span class="n">jsonNode</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">JsonNode</span> <span class="n">data</span> <span class="o">=</span> <span class="n">jsonNode</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"data"</span><span class="o">);</span>
        <span class="nc">String</span> <span class="n">status</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"status"</span><span class="o">).</span><span class="na">asText</span><span class="o">();</span>
        <span class="nc">String</span> <span class="n">paymentKey</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"paymentKey"</span><span class="o">).</span><span class="na">asText</span><span class="o">();</span>
        <span class="nc">String</span> <span class="n">orderId</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"orderId"</span><span class="o">).</span><span class="na">asText</span><span class="o">();</span>
        <span class="kt">long</span> <span class="n">amount</span> <span class="o">=</span> <span class="nc">Long</span><span class="o">.</span><span class="na">parseLong</span><span class="o">(</span><span class="n">data</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"totalAmount"</span><span class="o">).</span><span class="na">asText</span><span class="o">());</span>

        <span class="c1">// 1. DONE이 아닌 상태는 무시</span>
        <span class="k">if</span> <span class="o">(!</span><span class="n">status</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="s">"DONE"</span><span class="o">))</span> <span class="o">{</span>
            <span class="n">webHookHistoryRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span>
                <span class="nc">WebHookHistory</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">paymentKey</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span> <span class="n">amount</span><span class="o">,</span> <span class="n">status</span><span class="o">,</span> <span class="kc">false</span><span class="o">));</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="c1">// 2. 이미 처리된 Webhook인지 확인 (멱등성)</span>
        <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">WebHookHistory</span><span class="o">&gt;</span> <span class="n">processed</span> <span class="o">=</span>
            <span class="n">webHookHistoryRepository</span><span class="o">.</span><span class="na">findByPaymentKeyAndProcessedTrue</span><span class="o">(</span><span class="n">paymentKey</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">processed</span><span class="o">.</span><span class="na">isPresent</span><span class="o">())</span> <span class="k">return</span><span class="o">;</span>

        <span class="c1">// 3. TempPayment 조회 (결제 의도 확인)</span>
        <span class="nc">TempPayment</span> <span class="n">tempPayment</span> <span class="o">=</span> <span class="n">tempPaymentRepository</span>
            <span class="o">.</span><span class="na">findByPaymentKeyAndOrderIdAndAmountAndStatus</span><span class="o">(</span>
                <span class="n">paymentKey</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span> <span class="n">amount</span><span class="o">,</span> <span class="nc">PaymentStatus</span><span class="o">.</span><span class="na">PENDING</span><span class="o">)</span>
            <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">CommonException</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">NOT_FOUND_TEMP_PAYMENT</span><span class="o">));</span>

        <span class="c1">// 4. Payment가 이미 존재하는지 확인 (confirm 경로에서 생성됐을 수 있음)</span>
        <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">Payment</span><span class="o">&gt;</span> <span class="n">existingPayment</span> <span class="o">=</span>
            <span class="n">paymentRepository</span><span class="o">.</span><span class="na">findByPaymentKeyAndOrderIdAndAmount</span><span class="o">(</span>
                <span class="n">paymentKey</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span> <span class="n">amount</span><span class="o">);</span>

        <span class="c1">// 5. Payment 미존재 시 복구</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">existingPayment</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="nc">Member</span> <span class="n">member</span> <span class="o">=</span> <span class="n">memberRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">tempPayment</span><span class="o">.</span><span class="na">getMember</span><span class="o">().</span><span class="na">getId</span><span class="o">())</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">CommonException</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">NOT_FOUND_MEMBER</span><span class="o">));</span>

            <span class="n">paymentRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="nc">PaymentFactory</span><span class="o">.</span><span class="na">from</span><span class="o">(</span><span class="n">paymentKey</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span> <span class="n">amount</span><span class="o">,</span> <span class="n">member</span><span class="o">));</span>
            <span class="n">tempPaymentRepository</span><span class="o">.</span><span class="na">delete</span><span class="o">(</span><span class="n">tempPayment</span><span class="o">);</span>

            <span class="nc">Point</span> <span class="n">point</span> <span class="o">=</span> <span class="n">pointRepository</span><span class="o">.</span><span class="na">findByMember</span><span class="o">(</span><span class="n">member</span><span class="o">)</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">CommonException</span><span class="o">(</span><span class="nc">Status</span><span class="o">.</span><span class="na">NOT_FOUND_POINT</span><span class="o">));</span>
            <span class="n">point</span><span class="o">.</span><span class="na">addAmount</span><span class="o">(</span><span class="n">amount</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="c1">// 6. 처리 완료 기록</span>
        <span class="n">webHookHistoryRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span>
            <span class="nc">WebHookHistory</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">paymentKey</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span> <span class="n">amount</span><span class="o">,</span> <span class="n">status</span><span class="o">,</span> <span class="kc">true</span><span class="o">));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Webhook의 멱등성은 <code class="language-plaintext highlighter-rouge">WebHookHistory.processed</code> 필드로 보장한다. 동일한 Webhook이 여러 번 들어와도 <strong>processed=true인 레코드가 존재하면 무시</strong>한다.</p>

<p>복구 흐름:</p>
<ol>
  <li>DONE 상태 확인</li>
  <li>WebHookHistory로 중복 처리 방지</li>
  <li>TempPayment(PENDING)로 결제 의도 확인</li>
  <li>Payment가 없으면 생성 + 포인트 적립 + TempPayment 삭제</li>
  <li>WebHookHistory에 processed=true 기록</li>
</ol>

<h2 id="result">Result</h2>

<h3 id="confirm-직후-payment-0건">confirm 직후: Payment 0건</h3>

<p><img src="/assets/images/trip-payment/v3%20pg%20.png" alt="V3 PG서버 DB" /></p>

<p>mock PG 서버에는 1건 저장. Redis 멱등성이 정상 동작하여 PG에는 1건만 도달했다.</p>

<p><img src="/assets/images/trip-payment/v3%20temppayment.png" alt="V3 TempPayment" /></p>

<p><code class="language-plaintext highlighter-rouge">temp_payment</code> 테이블에 1건이 <strong>PENDING 상태</strong>로 남아 있다. processConfirm()을 스킵했기 때문에 TempPayment가 삭제되지 않았다.</p>

<p><img src="/assets/images/trip-payment/v3%20이전%20payment.png" alt="V3 Payment 이전" /></p>

<p><code class="language-plaintext highlighter-rouge">payment</code> 테이블은 <strong>0건</strong>. DB 저장이 스킵됐으므로 결제 완료 레코드가 없다.</p>

<p>이 시점에서의 상태:</p>
<ul>
  <li><strong>PG</strong>: 승인 완료 (돈은 빠져나감)</li>
  <li><strong>서비스 DB</strong>: 결제 기록 없음</li>
  <li><strong>TempPayment</strong>: PENDING (결제 의도만 남아 있음)</li>
</ul>

<p><strong>결제 유실 상태다.</strong></p>

<h3 id="webhook-수신-후-payment-1건-복구">Webhook 수신 후: Payment 1건 복구</h3>

<p><img src="/assets/images/trip-payment/v3%20이후.png" alt="V3 Payment 복구" /></p>

<p>PG Webhook을 수신한 후, <code class="language-plaintext highlighter-rouge">payment</code> 테이블에 <strong>1건이 COMPLETE 상태</strong>로 생성됐다. 포인트도 10,000원이 정상 적립됐다.</p>

<table>
  <thead>
    <tr>
      <th>시점</th>
      <th>Payment</th>
      <th>포인트</th>
      <th>TempPayment</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>confirm 직후</td>
      <td>0건</td>
      <td>0원</td>
      <td>PENDING</td>
    </tr>
    <tr>
      <td><strong>Webhook 후</strong></td>
      <td><strong>1건 COMPLETE</strong></td>
      <td><strong>10,000원</strong></td>
      <td>삭제됨</td>
    </tr>
  </tbody>
</table>

<p>Webhook이 결제 유실을 <strong>완전히 복구</strong>했다.</p>

<h2 id="reflection">Reflection</h2>

<p>이 테스트가 증명한 것은 <strong>Webhook 단독으로도 결제 유실을 복구할 수 있다</strong>는 것이다. TempPayment가 PENDING 상태로 남아 있는 한, Webhook은 결제 의도를 확인하고 Payment를 생성할 수 있다.</p>

<p>하지만 Webhook에는 구조적 한계가 있다:</p>

<ol>
  <li><strong>PG 서버가 Webhook을 보내지 못할 수 있다</strong> — PG 장애, 네트워크 단절</li>
  <li><strong>우리 서버가 Webhook을 수신하지 못할 수 있다</strong> — 서버 다운, 네트워크 이슈</li>
  <li><strong>Webhook은 PG가 주도한다</strong> — 우리가 재시도 시점을 제어할 수 없다</li>
</ol>

<p>Webhook은 “2층 안전망”이지만, 최종 보정은 <strong>우리 서버가 직접 PG에 조회하는 구조</strong>가 필요하다. Webhook에 의존하지 않고, 스케줄러가 PENDING 상태의 TempPayment를 주기적으로 확인하여 PG에 직접 물어보는 것이다.</p>

<hr />

<blockquote>
  <p>Webhook으로 복구는 가능하다. 하지만 Webhook마저 안 오면? 최종 보정을 우리가 직접 해야 한다.
<a href="/projects/trip-payment-v4-full-safety/">4단계: Redis + DB 이중 멱등성 + Reconciliation 스케줄러 →</a></p>
</blockquote>]]></content><author><name>jude-z</name></author><category term="Projects" /><category term="Java" /><category term="Spring Boot" /><category term="Redis" /><category term="Payment" /><category term="Webhook" /><summary type="html"><![CDATA[PG 승인은 성공했지만 DB 저장을 의도적으로 스킵한 뒤, PG Webhook이 결제를 복구하는 과정을 테스트로 검증한다.]]></summary></entry></feed>