<?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://yunhwane.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://yunhwane.github.io/" rel="alternate" type="text/html" /><updated>2026-03-18T08:45:55+00:00</updated><id>https://yunhwane.github.io/feed.xml</id><title type="html">Yunhwan’s Tech</title><subtitle>개발과 기술에 대한 기록</subtitle><author><name>yunhwane</name></author><entry><title type="html">코드 테이블 로컬 캐시 사용하기</title><link href="https://yunhwane.github.io/backend/code-table-local-cache/" rel="alternate" type="text/html" title="코드 테이블 로컬 캐시 사용하기" /><published>2026-03-18T09:00:00+00:00</published><updated>2026-03-18T09:00:00+00:00</updated><id>https://yunhwane.github.io/backend/code-table-local-cache</id><content type="html" xml:base="https://yunhwane.github.io/backend/code-table-local-cache/"><![CDATA[<h2 id="문제-상황-api마다-반복되는-코드-테이블-조회">문제 상황: API마다 반복되는 코드 테이블 조회</h2>

<p>회사에서 공통 코드 테이블을 사용하면서 한 가지 불편함을 느꼈습니다. 성별, 상태값, 카테고리 같은 코드성 데이터를 조회하는 로직이 <strong>API마다 반복</strong>되고 있었습니다.</p>

<p>상품 조회 API에서도 코드를 조회하고, 사용자 정보 API에서도 같은 코드를 조회하고, 목록 API에서도 또 조회합니다. 쿼리를 살펴보니 코드 테이블을 JOIN하거나 서브쿼리로 가져오는 <strong>중복 코드</strong>가 여러 Repository에 흩어져 있었습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[상품 조회 API] → SELECT ... JOIN code_table → 상태 코드 조회
[사용자 API]   → SELECT ... JOIN code_table → 성별 코드 조회
[목록 API]     → SELECT ... JOIN code_table → 카테고리 코드 조회
</code></pre></div></div>

<p>매 요청마다 거의 변하지 않는 같은 데이터를 DB에서 반복 조회하고, 그 조회 로직이 여기저기 중복되어 있는 상황이었습니다.</p>

<p>코드 테이블의 특성을 다시 생각해보면 이건 불필요한 낭비입니다.</p>

<ul>
  <li>데이터가 <strong>거의 변하지 않는다</strong> — 운영자가 수동으로 변경할 때만 바뀜</li>
  <li>데이터 양이 <strong>적다</strong> — 수백 건 이내</li>
  <li><strong>조회 빈도가 매우 높다</strong> — 거의 모든 API에서 사용</li>
  <li><strong>중복 쿼리가 발생한다</strong> — 여러 Repository에 코드 조회 로직이 산재</li>
</ul>

<p>캐시에 올려두고 한 곳에서 관리하면 DB 부하도 줄이고, 중복 코드도 제거할 수 있겠다고 판단했습니다.</p>

<h2 id="개발-제약-사항-redis-없이-여러-서버의-캐시-동기화">개발 제약 사항: Redis 없이 여러 서버의 캐시 동기화</h2>

<p>우리 서비스 구조에서 가장 큰 제약은 <strong>Front Office(사용자 서비스)와 Back Office(관리자)가 별도 서버로 운영</strong>되고, 각각 여러 인스턴스가 떠 있다는 점이었습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Front Office 서버 1] ──┐
[Front Office 서버 2] ──┤
[Back Office  서버 1] ──┼── 모두 같은 코드 테이블을 사용
[Back Office  서버 2] ──┘
</code></pre></div></div>

<p>일반적으로 이런 환경에서는 <strong>Redis 같은 중앙 캐시 서버</strong>를 두고 모든 서버가 같은 캐시를 바라보게 합니다. 하지만 우리는 Redis를 사용하지 않는 환경이었습니다.</p>

<p>그렇다면 <strong>로컬 캐시를 선택할 수밖에 없는데, 데이터 동기화를 어떻게 할 것인가?</strong> 이것이 가장 핵심적인 문제였습니다. Back Office에서 관리자가 코드를 변경하면, Front Office 서버들의 로컬 캐시에도 반영이 되어야 합니다. 서버마다 독립적인 캐시를 갖고 있으니, 아무런 장치 없이는 변경 사항이 전파되지 않습니다.</p>

<p>이 문제의 해결 방향은 뒤에서 다루겠지만, 결론부터 말하면 <strong>주기적 Warm-Up(3분 간격)</strong>으로 모든 서버가 DB를 기준으로 캐시를 갱신하는 방식을 택했습니다. 실시간 동기화 대신 “최대 3분 내 반영”이라는 트레이드오프를 받아들인 것입니다. 코드 테이블은 관리자가 수동으로 변경하는 데이터이기 때문에, 이 정도의 지연은 충분히 허용 가능했습니다.</p>

<h2 id="왜-로컬-캐시인가">왜 로컬 캐시인가?</h2>

<p>Redis를 사용하지 않는 환경이라는 제약도 있었지만, 코드 테이블의 특성을 따져보면 오히려 로컬 캐시가 더 나은 선택이었습니다.</p>

<table>
  <thead>
    <tr>
      <th>비교 항목</th>
      <th>Redis (리모트 캐시)</th>
      <th>Caffeine (로컬 캐시)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>네트워크 비용</td>
      <td>요청마다 네트워크 I/O 발생</td>
      <td>없음 (JVM 힙 메모리)</td>
    </tr>
    <tr>
      <td>응답 속도</td>
      <td>~1ms</td>
      <td>~ns (나노초)</td>
    </tr>
    <tr>
      <td>인프라 의존성</td>
      <td>Redis 서버 필요</td>
      <td>없음</td>
    </tr>
    <tr>
      <td>데이터 일관성</td>
      <td>서버 간 즉시 공유</td>
      <td>서버별 개별 관리 (주기적 동기화)</td>
    </tr>
  </tbody>
</table>

<p>코드 테이블은 <strong>변경 빈도가 낮고</strong>, <strong>데이터 크기가 작으며</strong>, <strong>읽기 비율이 압도적</strong>입니다. Redis를 도입하면 인프라 관리 비용이 추가되는 반면, 로컬 캐시는 네트워크 홉 없이 JVM 메모리에서 바로 읽으므로 성능상으로도 유리합니다. 동기화 지연이라는 단점은 주기적 갱신으로 충분히 커버할 수 있었습니다.</p>

<h3 id="왜-caffeine인가">왜 Caffeine인가?</h3>

<p>Java 진영의 로컬 캐시 라이브러리 중 <strong>Caffeine</strong>을 선택한 이유는 다음과 같습니다.</p>

<ul>
  <li><strong>성능</strong>: Window TinyLFU 알고리즘 기반으로 Guava Cache 대비 높은 히트율</li>
  <li><strong>자동 로딩</strong>: <code class="language-plaintext highlighter-rouge">LoadingCache</code>를 제공하여 캐시 미스 시 자동으로 데이터를 로딩</li>
  <li><strong>Spring 공식 지원</strong>: <code class="language-plaintext highlighter-rouge">spring-boot-starter-cache</code>에서 Caffeine을 공식 CacheManager로 지원</li>
  <li><strong>풍부한 설정</strong>: 만료 정책, 최대 크기, 통계 수집 등을 세밀하게 제어 가능</li>
</ul>

<h2 id="구현하기">구현하기</h2>

<h3 id="의존성-추가">의존성 추가</h3>

<div class="language-gradle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// build.gradle</span>
<span class="n">implementation</span> <span class="s1">'com.github.ben-manes.caffeine:caffeine:3.1.8'</span>
</code></pre></div></div>

<h3 id="캐시-설정-정의">캐시 설정 정의</h3>

<p>캐시 타입별로 설정값을 관리하면 여러 종류의 캐시를 일관되게 다룰 수 있습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Getter</span>
<span class="kd">public</span> <span class="kd">enum</span> <span class="nc">CacheType</span> <span class="o">{</span>
    <span class="no">CODE</span><span class="o">(</span><span class="mi">500</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="kd">private</span> <span class="kd">final</span> <span class="kt">int</span> <span class="n">maxSize</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Duration</span> <span class="n">refreshDuration</span><span class="o">;</span>

    <span class="nc">CacheType</span><span class="o">(</span><span class="kt">int</span> <span class="n">maxSize</span><span class="o">,</span> <span class="nc">Duration</span> <span class="n">refreshDuration</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">maxSize</span> <span class="o">=</span> <span class="n">maxSize</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">refreshDuration</span> <span class="o">=</span> <span class="n">refreshDuration</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="o">&lt;</span><span class="no">K</span><span class="o">,</span> <span class="no">V</span><span class="o">&gt;</span> <span class="nc">LoadingCache</span><span class="o">&lt;</span><span class="no">K</span><span class="o">,</span> <span class="no">V</span><span class="o">&gt;</span> <span class="nf">createLoadingCache</span><span class="o">(</span>
            <span class="nc">CacheLoader</span><span class="o">&lt;</span><span class="no">K</span><span class="o">,</span> <span class="no">V</span><span class="o">&gt;</span> <span class="n">loader</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">Caffeine</span><span class="o">.</span><span class="na">newBuilder</span><span class="o">()</span>
                <span class="o">.</span><span class="na">maximumSize</span><span class="o">(</span><span class="n">maxSize</span><span class="o">)</span>
                <span class="o">.</span><span class="na">refreshAfterWrite</span><span class="o">(</span><span class="n">refreshDuration</span><span class="o">)</span>
                <span class="o">.</span><span class="na">recordStats</span><span class="o">()</span>
                <span class="o">.</span><span class="na">build</span><span class="o">(</span><span class="n">loader</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">CacheType</code>을 enum으로 정의하면 캐시가 추가될 때마다 상수만 추가하면 됩니다. <code class="language-plaintext highlighter-rouge">recordStats()</code>를 넣어두면 히트율, 미스율 등을 모니터링할 수 있습니다.</p>

<h3 id="캐시-repository-구현">캐시 Repository 구현</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">CachedCodeRepository</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">CodeRepository</span> <span class="n">codeRepository</span><span class="o">;</span>
    <span class="kd">private</span> <span class="nc">LoadingCache</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">Code</span><span class="o">&gt;</span> <span class="n">cache</span><span class="o">;</span>

    <span class="nd">@PostConstruct</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">init</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">cache</span> <span class="o">=</span> <span class="nc">CacheType</span><span class="o">.</span><span class="na">CODE</span><span class="o">.</span><span class="na">createLoadingCache</span><span class="o">(</span><span class="n">key</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="nc">String</span><span class="o">[]</span> <span class="n">parts</span> <span class="o">=</span> <span class="n">key</span><span class="o">.</span><span class="na">split</span><span class="o">(</span><span class="s">"::"</span><span class="o">,</span> <span class="mi">2</span><span class="o">);</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">parts</span><span class="o">.</span><span class="na">length</span> <span class="o">!=</span> <span class="mi">2</span><span class="o">)</span> <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
            <span class="k">return</span> <span class="n">codeRepository</span>
                    <span class="o">.</span><span class="na">findByGroupNameAndCodeName</span><span class="o">(</span><span class="n">parts</span><span class="o">[</span><span class="mi">0</span><span class="o">],</span> <span class="n">parts</span><span class="o">[</span><span class="mi">1</span><span class="o">])</span>
                    <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="nl">CodeEntity:</span><span class="o">:</span><span class="n">toDomain</span><span class="o">)</span>
                    <span class="o">.</span><span class="na">orElse</span><span class="o">(</span><span class="kc">null</span><span class="o">);</span>
        <span class="o">});</span>

        <span class="n">warmUp</span><span class="o">();</span>
        <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"Code cache initialized - maxSize: {}, refresh: {}m"</span><span class="o">,</span>
                <span class="nc">CacheType</span><span class="o">.</span><span class="na">CODE</span><span class="o">.</span><span class="na">getMaxSize</span><span class="o">(),</span>
                <span class="nc">CacheType</span><span class="o">.</span><span class="na">CODE</span><span class="o">.</span><span class="na">getRefreshDuration</span><span class="o">().</span><span class="na">toMinutes</span><span class="o">());</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">Code</span> <span class="nf">findByGroupAndName</span><span class="o">(</span><span class="nc">String</span> <span class="n">groupName</span><span class="o">,</span> <span class="nc">String</span> <span class="n">codeName</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">codeName</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">codeName</span><span class="o">.</span><span class="na">isBlank</span><span class="o">())</span> <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
        <span class="nc">String</span> <span class="n">key</span> <span class="o">=</span> <span class="n">groupName</span> <span class="o">+</span> <span class="s">"::"</span> <span class="o">+</span> <span class="n">codeName</span><span class="o">;</span>
        <span class="k">return</span> <span class="n">cache</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>핵심 포인트를 살펴보겠습니다.</p>

<p><strong>키 설계</strong>: <code class="language-plaintext highlighter-rouge">그룹명::코드명</code> 형태의 복합 키를 사용합니다. 예를 들어 <code class="language-plaintext highlighter-rouge">GENDER::MALE</code> 처럼 하나의 문자열로 코드를 식별합니다.</p>

<p><strong>LoadingCache</strong>: 캐시 미스가 발생하면 <code class="language-plaintext highlighter-rouge">CacheLoader</code>를 호출하여 자동으로 DB에서 로딩합니다. 같은 키에 대해 동시에 여러 요청이 들어와도 <strong>한 번만 로딩</strong>됩니다(thundering herd 방지).</p>

<p><strong>왜 <code class="language-plaintext highlighter-rouge">AsyncLoadingCache</code>가 아닌가?</strong>: Caffeine은 <code class="language-plaintext highlighter-rouge">AsyncLoadingCache</code>도 제공하지만, 이 케이스에서는 동기 <code class="language-plaintext highlighter-rouge">LoadingCache</code>로 충분합니다. warm-up으로 미리 전체 데이터를 적재하기 때문에 실제 운영 중 캐시 미스가 거의 발생하지 않고, Spring MVC 기반이라 <code class="language-plaintext highlighter-rouge">CompletableFuture</code>를 리액티브하게 활용할 일도 없습니다. <code class="language-plaintext highlighter-rouge">AsyncLoadingCache</code>는 WebFlux 같은 리액티브 스택에서 non-blocking 파이프라인에 태울 때 더 적합합니다.</p>

<h3 id="warm-up-애플리케이션-시작-시-캐시-채우기">Warm-Up: 애플리케이션 시작 시 캐시 채우기</h3>

<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">warmUp</span><span class="o">()</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">Code</span><span class="o">&gt;</span> <span class="n">newEntries</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">HashMap</span><span class="o">&lt;&gt;();</span>

    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">groupNames</span> <span class="o">=</span> <span class="n">codeRepository</span><span class="o">.</span><span class="na">findAllGroupNames</span><span class="o">();</span>
    <span class="k">for</span> <span class="o">(</span><span class="nc">String</span> <span class="n">groupName</span> <span class="o">:</span> <span class="n">groupNames</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">CodeEntity</span><span class="o">&gt;</span> <span class="n">codes</span> <span class="o">=</span> <span class="n">codeRepository</span><span class="o">.</span><span class="na">findByGroupName</span><span class="o">(</span><span class="n">groupName</span><span class="o">);</span>
        <span class="k">for</span> <span class="o">(</span><span class="nc">CodeEntity</span> <span class="n">entity</span> <span class="o">:</span> <span class="n">codes</span><span class="o">)</span> <span class="o">{</span>
            <span class="nc">String</span> <span class="n">key</span> <span class="o">=</span> <span class="n">groupName</span> <span class="o">+</span> <span class="s">"::"</span> <span class="o">+</span> <span class="n">entity</span><span class="o">.</span><span class="na">getCodeName</span><span class="o">();</span>
            <span class="n">newEntries</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">entity</span><span class="o">.</span><span class="na">toDomain</span><span class="o">());</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="c1">// 삭제된 코드는 캐시에서 제거</span>
    <span class="nc">Set</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">currentKeys</span> <span class="o">=</span> <span class="n">cache</span><span class="o">.</span><span class="na">asMap</span><span class="o">().</span><span class="na">keySet</span><span class="o">();</span>
    <span class="nc">Set</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">newKeys</span> <span class="o">=</span> <span class="n">newEntries</span><span class="o">.</span><span class="na">keySet</span><span class="o">();</span>
    <span class="n">currentKeys</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">filter</span><span class="o">(</span><span class="n">key</span> <span class="o">-&gt;</span> <span class="o">!</span><span class="n">newKeys</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="n">key</span><span class="o">))</span>
            <span class="o">.</span><span class="na">forEach</span><span class="o">(</span><span class="nl">cache:</span><span class="o">:</span><span class="n">invalidate</span><span class="o">);</span>

    <span class="c1">// 새로운 데이터로 캐시 갱신</span>
    <span class="n">cache</span><span class="o">.</span><span class="na">putAll</span><span class="o">(</span><span class="n">newEntries</span><span class="o">);</span>
    <span class="n">log</span><span class="o">.</span><span class="na">debug</span><span class="o">(</span><span class="s">"Code cache warmed up: {} items"</span><span class="o">,</span> <span class="n">cache</span><span class="o">.</span><span class="na">estimatedSize</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Warm-Up은 두 가지 역할을 합니다.</p>

<ol>
  <li><strong>애플리케이션 시작 시</strong> 모든 코드를 미리 로딩하여 첫 요청부터 캐시 히트가 되도록 합니다</li>
  <li><strong>주기적 갱신 시</strong> DB에서 삭제된 코드는 캐시에서도 제거하여 데이터 정합성을 유지합니다</li>
</ol>

<p>단순히 <code class="language-plaintext highlighter-rouge">putAll</code>만 하면 DB에서 삭제된 코드가 캐시에 남아있게 됩니다. <code class="language-plaintext highlighter-rouge">currentKeys</code>와 <code class="language-plaintext highlighter-rouge">newKeys</code>를 비교하여 차집합을 <code class="language-plaintext highlighter-rouge">invalidate</code>하는 부분이 중요합니다.</p>

<h3 id="주기적-캐시-갱신-ttl-3분">주기적 캐시 갱신 (TTL 3분)</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">CacheRefreshScheduler</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">CachedCodeRepository</span> <span class="n">cachedCodeRepository</span><span class="o">;</span>

    <span class="nd">@Scheduled</span><span class="o">(</span><span class="n">fixedRate</span> <span class="o">=</span> <span class="mi">180_000</span><span class="o">)</span> <span class="c1">// 3분</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">refreshCaches</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="n">cachedCodeRepository</span><span class="o">.</span><span class="na">warmUp</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">error</span><span class="o">(</span><span class="s">"Failed to refresh Code cache"</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">refreshAfterWrite</code>만으로는 <strong>요청이 들어와야 갱신이 트리거</strong>됩니다. 스케줄러로 3분마다 <code class="language-plaintext highlighter-rouge">warmUp()</code>을 호출하면 요청 유무와 관계없이 캐시가 최신 상태를 유지합니다.</p>

<p>갱신 주기를 3분으로 잡은 이유는 코드 테이블의 변경이 <strong>운영자의 수동 작업</strong>으로만 발생하기 때문입니다. 실시간 반영이 아닌 “수 분 내 반영”이면 충분하고, 너무 짧으면 불필요한 DB 부하가 발생합니다.</p>

<blockquote>
  <p>각 캐시의 <code class="language-plaintext highlighter-rouge">warmUp()</code>을 try-catch로 감싸는 것이 중요합니다. 하나의 캐시 갱신이 실패해도 다른 캐시에 영향을 주지 않도록 격리합니다.</p>
</blockquote>

<h2 id="전체-흐름">전체 흐름</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[애플리케이션 시작]
    └─ @PostConstruct → warmUp() → DB 전체 조회 → 캐시 적재

[API 요청]
    └─ findByGroupAndName("GENDER", "MALE")
        └─ cache hit → 즉시 반환 (ns 단위)
        └─ cache miss → CacheLoader → DB 조회 → 캐시 적재 → 반환

[3분마다]
    └─ @Scheduled → warmUp()
        └─ DB 전체 조회 → 신규 코드 추가, 삭제된 코드 제거
</code></pre></div></div>

<h2 id="주의할-점">주의할 점</h2>

<h3 id="1-메모리-사용량-관리">1. 메모리 사용량 관리</h3>

<p>로컬 캐시는 JVM 힙 메모리를 사용합니다. <code class="language-plaintext highlighter-rouge">maximumSize</code>를 반드시 설정하고, 코드 테이블의 예상 크기를 고려해야 합니다. 설정하지 않으면 메모리 누수로 이어질 수 있습니다.</p>

<h3 id="2-다중-인스턴스-환경">2. 다중 인스턴스 환경</h3>

<p>서버가 여러 대라면 각 인스턴스가 <strong>독립적인 캐시</strong>를 갖게 됩니다. 코드 변경 후 최대 3분간 서버마다 다른 데이터를 반환할 수 있습니다. 코드 테이블의 특성상 이 정도는 허용 가능하지만, 실시간 일관성이 필요한 데이터라면 로컬 캐시는 적합하지 않습니다.</p>

<h3 id="3-cache-stampede-방지">3. Cache Stampede 방지</h3>

<p><code class="language-plaintext highlighter-rouge">LoadingCache</code>는 같은 키에 대한 동시 요청을 하나로 합쳐줍니다. 하지만 <code class="language-plaintext highlighter-rouge">warmUp()</code> 시 대량 DB 조회가 발생하므로, 코드 데이터가 매우 많다면 warm-up 자체의 부하도 고려해야 합니다.</p>

<h3 id="4-null-처리">4. null 처리</h3>

<p>존재하지 않는 코드를 조회하면 <code class="language-plaintext highlighter-rouge">null</code>이 캐시에 저장될 수 있습니다. Caffeine은 기본적으로 null value를 허용하지 않으므로 <code class="language-plaintext highlighter-rouge">CacheLoader</code>에서 null을 반환하면 해당 키는 캐시되지 않습니다. 매번 DB를 조회하는 negative lookup이 발생할 수 있으니, 의도적으로 빈 객체를 반환하는 것도 고려해볼 수 있습니다.</p>

<h3 id="5-갱신-실패-시-기존-캐시-유지">5. 갱신 실패 시 기존 캐시 유지</h3>

<p><code class="language-plaintext highlighter-rouge">warmUp()</code> 내부에서 DB 조회가 실패하면 Exception이 발생하고, try-catch에 의해 기존 캐시가 그대로 유지됩니다. 이는 의도된 동작으로, 일시적인 DB 장애가 서비스 장애로 이어지지 않도록 합니다.</p>

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

<p>코드 테이블처럼 <strong>변경이 적고, 크기가 작고, 조회가 잦은</strong> 데이터에는 로컬 캐시가 효과적입니다. Caffeine의 <code class="language-plaintext highlighter-rouge">LoadingCache</code>와 주기적 warm-up을 조합하면, 거의 제로에 가까운 지연시간으로 코드를 조회하면서도 데이터 정합성을 유지할 수 있습니다.</p>

<p>핵심을 정리하면 다음과 같습니다.</p>

<ul>
  <li>코드 테이블은 <strong>로컬 캐시</strong>가 적합하다 (네트워크 비용 제거)</li>
  <li><strong>Caffeine</strong>은 높은 히트율과 비동기 로딩을 지원한다</li>
  <li><strong>Warm-Up</strong>으로 콜드 스타트를 방지하고, <strong>스케줄러</strong>로 주기적 갱신한다</li>
  <li>삭제된 데이터의 <strong>캐시 무효화</strong>를 잊지 말자</li>
  <li>다중 인스턴스 환경에서의 <strong>일시적 불일치</strong>를 허용할 수 있는 데이터에만 적용하자</li>
</ul>]]></content><author><name>yunhwane</name></author><category term="backend" /><category term="spring-boot" /><category term="caffeine" /><category term="cache" /><category term="java" /><summary type="html"><![CDATA[문제 상황: API마다 반복되는 코드 테이블 조회]]></summary></entry><entry><title type="html">메시지 큐에서 트랜잭션 문제 극복하기 — Transactional Outbox Pattern</title><link href="https://yunhwane.github.io/backend/message-relay-outbox-pattern/" rel="alternate" type="text/html" title="메시지 큐에서 트랜잭션 문제 극복하기 — Transactional Outbox Pattern" /><published>2026-03-15T09:00:00+00:00</published><updated>2026-03-15T09:00:00+00:00</updated><id>https://yunhwane.github.io/backend/message-relay-outbox-pattern</id><content type="html" xml:base="https://yunhwane.github.io/backend/message-relay-outbox-pattern/"><![CDATA[<h2 id="문제-상황-커밋은-됐는데-메시지가-사라진다">문제 상황: 커밋은 됐는데 메시지가 사라진다</h2>

<p>포스팅 예약 시스템에서 <code class="language-plaintext highlighter-rouge">@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code>를 사용해 DB 커밋 이후 Kafka로 이벤트를 발행하고 있었습니다. 평소에는 문제없이 동작했지만, <strong>Kafka 브로커 장애</strong> 상황에서 치명적인 버그가 드러났습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 애플리케이션 → DB 커밋 완료 ✅
2. TransactionalEventListener → 이벤트 위임
3. Kafka 전송 시도 → 브로커 장애 ❌
4. 메시지 유실 → 포스팅이 WORKING 상태에서 영원히 멈춤
</code></pre></div></div>

<p>DB에는 정상적으로 저장됐지만 Kafka 메시지가 유실되면서, 예약된 포스팅이 발행되지 않는 상태가 되었습니다. <strong>DB 트랜잭션과 메시지 발행 사이의 원자성이 보장되지 않는 전형적인 분산 시스템 문제</strong>였습니다.</p>

<hr />

<h2 id="해결-방법-후보-two-phase-commit-vs-outbox-pattern">해결 방법 후보: Two-Phase Commit vs Outbox Pattern</h2>

<h3 id="two-phase-commit-2pc">Two-Phase Commit (2PC)</h3>

<p>분산 시스템에서 원자성을 보장하는 전통적인 방법입니다.</p>

<table>
  <thead>
    <tr>
      <th>Phase</th>
      <th>동작</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Prepare</strong></td>
      <td>코디네이터가 각 참여자에게 “커밋 준비 완료?” 질의 → 참여자는 준비만 하고 실제 커밋은 하지 않음</td>
    </tr>
    <tr>
      <td><strong>Commit</strong></td>
      <td>모든 참여자가 OK이면 커밋, 하나라도 실패하면 전체 롤백</td>
    </tr>
  </tbody>
</table>

<p>“모두 성공하거나 모두 실패”라는 강력한 일관성을 제공하지만, 실제 운영 환경에서는 몇 가지 단점이 있습니다.</p>

<ul>
  <li><strong>성능 저하</strong> — 모든 참여자가 응답할 때까지 락을 잡고 대기해야 합니다</li>
  <li><strong>단일 장애점</strong> — 코디네이터가 다운되면 참여자들이 불확실한 상태로 남습니다</li>
  <li><strong>Kafka는 XA 트랜잭션을 지원하지 않음</strong> — 사실상 DB + Kafka 조합에서 2PC 적용이 불가능합니다</li>
</ul>

<h3 id="transactional-outbox-pattern">Transactional Outbox Pattern</h3>

<p>이벤트를 외부 시스템에 직접 전송하지 않고, <strong>Outbox 테이블에 먼저 저장</strong>한 후 별도 프로세스가 읽어서 외부 시스템으로 전송하는 패턴입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[비즈니스 로직]
    │
    ├─ 도메인 데이터 저장  ──┐
    │                        ├── 같은 DB 트랜잭션
    └─ Outbox 테이블 저장  ──┘

[Message Relay (별도 스케줄러)]
    │
    ├─ Outbox에서 PENDING 이벤트 조회
    ├─ Kafka로 전송
    └─ 전송 결과에 따라 상태 업데이트 (SUCCESS / FAIL)
</code></pre></div></div>

<p>핵심은 <strong>도메인 데이터와 이벤트를 같은 DB 트랜잭션으로 묶는 것</strong>입니다. DB 트랜잭션의 원자성을 활용하기 때문에 “데이터는 저장됐는데 이벤트는 없다”는 상황이 원천적으로 발생하지 않습니다.</p>

<hr />

<h2 id="왜-outbox-pattern을-선택했는가">왜 Outbox Pattern을 선택했는가</h2>

<table>
  <thead>
    <tr>
      <th>기준</th>
      <th>2PC</th>
      <th>Outbox Pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Kafka 호환성</td>
      <td>XA 미지원으로 사실상 불가</td>
      <td>DB 트랜잭션만 사용하므로 문제없음</td>
    </tr>
    <tr>
      <td>성능</td>
      <td>분산 락으로 인한 지연</td>
      <td>로컬 트랜잭션이라 빠름</td>
    </tr>
    <tr>
      <td>장애 대응</td>
      <td>코디네이터 장애 시 복구 어려움</td>
      <td>재시도 + 상태 추적 가능</td>
    </tr>
    <tr>
      <td>운영 가시성</td>
      <td>별도 모니터링 필요</td>
      <td>Outbox 테이블 자체가 로그 역할</td>
    </tr>
    <tr>
      <td>구현 난이도</td>
      <td>높음</td>
      <td>상대적으로 낮음</td>
    </tr>
  </tbody>
</table>

<p>특히 기존 시스템에서 메시지 발행 상황에 대한 <strong>로깅이 부족</strong>했기 때문에, Outbox 테이블이 자연스럽게 이벤트 이력 역할까지 하는 점이 큰 장점이었습니다.</p>

<hr />

<h2 id="outbox-테이블-설계">Outbox 테이블 설계</h2>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">outbox</span> <span class="p">(</span>
    <span class="n">id</span>          <span class="nb">BIGINT</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="n">AUTO_INCREMENT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">event_type</span>  <span class="nb">ENUM</span><span class="p">(</span><span class="s1">'POSTING_EVENT'</span><span class="p">,</span> <span class="s1">'POSTING_RESERVATION_EVENT'</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">payload</span>     <span class="n">JSON</span><span class="p">,</span>
    <span class="n">created</span>     <span class="nb">DATETIME</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="k">CURRENT_TIMESTAMP</span><span class="p">,</span>
    <span class="n">status</span>      <span class="nb">ENUM</span><span class="p">(</span><span class="s1">'PENDING'</span><span class="p">,</span> <span class="s1">'SUCCESS'</span><span class="p">,</span> <span class="s1">'FAIL'</span><span class="p">,</span> <span class="s1">'RETRY'</span><span class="p">),</span>
    <span class="n">retry_count</span> <span class="nb">BIGINT</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="mi">0</span>
<span class="p">);</span>

<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_outbox_status_created_id</span> <span class="k">ON</span> <span class="n">outbox</span> <span class="p">(</span><span class="n">status</span><span class="p">,</span> <span class="n">created</span><span class="p">,</span> <span class="n">id</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="인덱스-설계-의도">인덱스 설계 의도</h3>

<p><code class="language-plaintext highlighter-rouge">(status, created, id)</code> 복합 인덱스를 사용한 이유는 Message Relay의 조회 쿼리 패턴 때문입니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Message Relay가 실행하는 쿼리</span>
<span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">outbox</span>
<span class="k">WHERE</span> <span class="n">status</span> <span class="o">=</span> <span class="s1">'PENDING'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created</span><span class="p">,</span> <span class="n">id</span>
<span class="k">LIMIT</span> <span class="mi">100</span><span class="p">;</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">status = 'PENDING'</code> — 인덱스 선두 컬럼으로 빠르게 필터링</li>
  <li><code class="language-plaintext highlighter-rouge">ORDER BY created, id</code> — 인덱스 순서와 일치하여 <strong>filesort 없이</strong> 정렬 가능</li>
  <li><code class="language-plaintext highlighter-rouge">LIMIT 100</code> — 인덱스 스캔을 100건에서 조기 종료</li>
</ul>

<p>이 인덱스가 없으면 Outbox 테이블이 커질수록 전체 테이블 스캔이 발생해 성능이 급격히 저하됩니다.</p>

<hr />

<h2 id="message-relay-구현">Message Relay 구현</h2>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Scheduled</span><span class="o">(</span><span class="n">fixedDelay</span> <span class="o">=</span> <span class="mi">5_000</span><span class="o">)</span>
<span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">publishPendingPostingEvents</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Outbox</span><span class="o">&gt;</span> <span class="n">retryableEvents</span> <span class="o">=</span>
        <span class="n">outBoxRepository</span><span class="o">.</span><span class="na">findTop100PendingOrderByCreatedAndId</span><span class="o">();</span>

    <span class="n">retryableEvents</span><span class="o">.</span><span class="na">forEach</span><span class="o">(</span><span class="n">outbox</span> <span class="o">-&gt;</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="nc">PostingEventPayload</span> <span class="n">payload</span> <span class="o">=</span>
                <span class="nc">DataSerializer</span><span class="o">.</span><span class="na">deserialize</span><span class="o">(</span><span class="n">outbox</span><span class="o">.</span><span class="na">getPayload</span><span class="o">(),</span>
                    <span class="nc">PostingEventPayload</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
            <span class="nc">String</span> <span class="n">postId</span> <span class="o">=</span> <span class="nc">String</span><span class="o">.</span><span class="na">valueOf</span><span class="o">(</span><span class="n">payload</span><span class="o">.</span><span class="na">getPostId</span><span class="o">());</span>

            <span class="n">kafkaTemplate</span><span class="o">.</span><span class="na">send</span><span class="o">(</span><span class="n">kafkaTopicConfig</span><span class="o">.</span><span class="na">getTopicName</span><span class="o">(),</span>
                <span class="n">postId</span><span class="o">,</span> <span class="n">postId</span><span class="o">).</span><span class="na">get</span><span class="o">();</span>

            <span class="n">outbox</span><span class="o">.</span><span class="na">markSent</span><span class="o">();</span>
            <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"Kafka 전송 성공: postId={}"</span><span class="o">,</span> <span class="n">postId</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">outbox</span><span class="o">.</span><span class="na">markFailed</span><span class="o">();</span>
            <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"Kafka 전송 실패: outboxId={}, retryCount={}"</span><span class="o">,</span>
                <span class="n">outbox</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="n">outbox</span><span class="o">.</span><span class="na">getRetryCount</span><span class="o">(),</span> <span class="n">e</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">});</span>

    <span class="n">outBoxRepository</span><span class="o">.</span><span class="na">saveAll</span><span class="o">(</span><span class="n">retryableEvents</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="전체-흐름-정리">전체 흐름 정리</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[사용자 포스팅 예약 요청]
    │
    ▼
[예약 스케줄러 - 1분 주기]
    ├─ 5분 전 예약 데이터 조회
    ├─ WORKING 상태로 마킹
    └─ Outbox 테이블에 이벤트 저장  ← 같은 트랜잭션
    │
    ▼
[Message Relay - 5초 주기]
    ├─ PENDING 상태 이벤트 100건 조회
    ├─ Kafka로 전송 시도
    ├─ 성공 → SUCCESS로 마킹
    └─ 실패 → FAIL로 마킹, retry_count 증가
         └─ 최대 3회 재시도 후 수동 처리 대상으로 분류
</code></pre></div></div>

<h3 id="구현에서-주의한-점">구현에서 주의한 점</h3>

<p><strong>1. 동기 전송 사용 (<code class="language-plaintext highlighter-rouge">kafkaTemplate.send().get()</code>)</strong></p>

<p><code class="language-plaintext highlighter-rouge">KafkaTemplate.send()</code>는 기본적으로 비동기입니다. <code class="language-plaintext highlighter-rouge">.get()</code>을 호출해 동기로 전환한 이유는, 전송 성공/실패를 확실히 확인한 후 Outbox 상태를 업데이트해야 하기 때문입니다. 비동기로 처리하면 전송 결과를 모르는 채로 상태를 변경할 위험이 있습니다.</p>

<p><strong>2. 배치 조회 + 개별 전송</strong></p>

<p>100건을 한 번에 조회하되 개별 건마다 try-catch로 감싸서, 한 건의 실패가 나머지 건의 전송을 막지 않도록 했습니다.</p>

<p><strong>3. 재시도 횟수 제한</strong></p>

<p>무한 재시도는 장애 상황에서 시스템 부하를 가중시킵니다. 최대 3회로 제한하고, 그 이후에는 운영자가 직접 확인할 수 있도록 별도 상태로 분류합니다.</p>

<hr />

<h2 id="적용-결과">적용 결과</h2>

<table>
  <thead>
    <tr>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Kafka 장애 시 메시지 유실</td>
      <td>Outbox에 보존되어 장애 복구 후 자동 재전송</td>
    </tr>
    <tr>
      <td>이벤트 발행 이력 없음</td>
      <td>Outbox 테이블이 이벤트 이력 역할 수행</td>
    </tr>
    <tr>
      <td>DB와 Kafka 간 일관성 미보장</td>
      <td>같은 트랜잭션으로 원자성 보장</td>
    </tr>
    <tr>
      <td>장애 대응이 어려움</td>
      <td>상태/재시도 횟수 기반으로 빠른 파악 가능</td>
    </tr>
  </tbody>
</table>

<p>Outbox 패턴은 추가 테이블과 스케줄러라는 복잡성이 생기지만, <strong>메시지 유실 방지, 운영 가시성 확보, 시스템 간 결합도 감소</strong>라는 이점이 그 비용을 충분히 상쇄했습니다. 복잡성과 운영 효율성, 확장성을 고려했을 때 현재 서비스 환경에 가장 적합한 선택이었습니다.</p>]]></content><author><name>yunhwane</name></author><category term="backend" /><category term="kafka" /><category term="transaction" /><category term="outbox-pattern" /><category term="spring-boot" /><category term="distributed-system" /><summary type="html"><![CDATA[문제 상황: 커밋은 됐는데 메시지가 사라진다]]></summary></entry><entry><title type="html">[카프카 핵심 가이드] Chapter 01 — 카프카는 왜 분산 메시징의 표준이 되었는가</title><link href="https://yunhwane.github.io/study/kafka-core-guide-chapter01/" rel="alternate" type="text/html" title="[카프카 핵심 가이드] Chapter 01 — 카프카는 왜 분산 메시징의 표준이 되었는가" /><published>2026-03-11T09:00:00+00:00</published><updated>2026-03-11T09:00:00+00:00</updated><id>https://yunhwane.github.io/study/kafka-core-guide-chapter01</id><content type="html" xml:base="https://yunhwane.github.io/study/kafka-core-guide-chapter01/"><![CDATA[<h2 id="이-글에-대해">이 글에 대해</h2>

<p>이 글은 <strong>“카프카 핵심 가이드(Kafka: The Definitive Guide)”</strong>를 읽고 Chapter 1의 내용을 정리한 것입니다. 단순 요약이 아니라, 각 개념이 <strong>왜 그렇게 설계되었는지</strong>에 초점을 맞춰 정리했습니다.</p>

<blockquote>
  <p>Kafka는 LinkedIn에서 시작된 프로젝트로, 대규모 실시간 데이터 파이프라인과 스트리밍 처리를 위해 설계되었습니다. 현재는 Apache 재단의 최상위 프로젝트로, 분산 메시징 시스템의 사실상 표준(de facto standard)이 되었습니다.</p>
</blockquote>

<hr />

<h2 id="메시지message--kafka의-데이터-단위">메시지(Message) — Kafka의 데이터 단위</h2>

<p>Kafka에서 데이터의 최소 단위는 <strong>메시지</strong>입니다. 데이터베이스의 row, 테이블의 record에 대응하는 개념입니다.</p>

<p>메시지는 선택적으로 <strong>key</strong>라는 메타데이터를 포함할 수 있습니다. key는 단순한 식별자가 아니라 <strong>파티션 배치 전략</strong>에 직접적으로 관여합니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>파티션 결정 방식:
hash(key) % partition_count = 저장될 파티션 번호
</code></pre></div></div>

<p>이 설계 덕분에 <strong>동일한 key를 가진 메시지는 항상 같은 파티션에 저장</strong>됩니다. 예를 들어, 주문 ID를 key로 사용하면 같은 주문의 모든 이벤트가 하나의 파티션에 순서대로 쌓이게 됩니다.</p>

<hr />

<h2 id="배치batch--처리량과-지연의-트레이드오프">배치(Batch) — 처리량과 지연의 트레이드오프</h2>

<p>Kafka는 메시지를 하나씩 전송하지 않고 <strong>배치 단위로 모아서 전송</strong>합니다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>배치 크기 작음</th>
      <th>배치 크기 큼</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>처리량</strong></td>
      <td>낮음</td>
      <td>높음</td>
    </tr>
    <tr>
      <td><strong>지연 시간</strong></td>
      <td>짧음</td>
      <td>길어짐</td>
    </tr>
    <tr>
      <td><strong>네트워크 효율</strong></td>
      <td>오버헤드 큼</td>
      <td>오버헤드 줄어듦</td>
    </tr>
  </tbody>
</table>

<p>배치 크기를 키우면 네트워크 왕복 횟수가 줄어 전체 처리량이 증가하지만, 개별 메시지 입장에서는 배치가 채워질 때까지 기다려야 하므로 지연이 늘어납니다. 실시간성이 중요한 서비스에서는 이 트레이드오프를 신중하게 조정해야 합니다.</p>

<hr />

<h2 id="토픽topic과-파티션partition">토픽(Topic)과 파티션(Partition)</h2>

<p>토픽은 메시지를 <strong>논리적으로 분류</strong>하는 단위이고, 파티션은 토픽을 <strong>물리적으로 분할</strong>하는 단위입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Topic: order-events
├── Partition 0: [msg1] [msg3] [msg5] → append-only
├── Partition 1: [msg2] [msg6]        → append-only
└── Partition 2: [msg4] [msg7]        → append-only
</code></pre></div></div>

<h3 id="파티션의-핵심-특성">파티션의 핵심 특성</h3>

<p><strong>1. Append-Only 구조</strong></p>

<p>메시지는 파티션 끝에만 추가됩니다. 수정이나 삽입이 없기 때문에 디스크 순차 쓰기(sequential write)가 가능하고, 이것이 Kafka의 높은 처리량의 기반입니다.</p>

<p><strong>2. 순서 보장 범위</strong></p>

<ul>
  <li>단일 파티션 내에서는 메시지 순서가 보장됩니다</li>
  <li>파티션 간에는 순서가 보장되지 않습니다</li>
</ul>

<p>이 때문에 순서가 중요한 메시지는 같은 key를 사용해 같은 파티션으로 보내야 합니다.</p>

<p><strong>3. 복제(Replication)</strong></p>

<p>각 파티션은 여러 브로커에 복제되어 저장됩니다. 하나의 브로커가 다운되더라도 다른 브로커에 복제본이 있으므로 데이터 유실 없이 서비스를 지속할 수 있습니다.</p>

<hr />

<h2 id="프로듀서producer와-컨슈머consumer">프로듀서(Producer)와 컨슈머(Consumer)</h2>

<h3 id="프로듀서--메시지를-만드는-쪽">프로듀서 — 메시지를 만드는 쪽</h3>

<p>프로듀서는 메시지를 생성하여 토픽에 전송합니다.</p>

<ul>
  <li><strong>key가 없으면</strong> — 라운드 로빈으로 파티션에 균등 분배</li>
  <li><strong>key가 있으면</strong> — 파티셔너(Partitioner)가 hash 기반으로 파티션 결정</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// key 없이 전송 → 라운드 로빈</span>
<span class="n">producer</span><span class="o">.</span><span class="na">send</span><span class="o">(</span><span class="k">new</span> <span class="nc">ProducerRecord</span><span class="o">&lt;&gt;(</span><span class="s">"order-events"</span><span class="o">,</span> <span class="n">orderJson</span><span class="o">));</span>

<span class="c1">// key 지정 전송 → 같은 orderId는 항상 같은 파티션</span>
<span class="n">producer</span><span class="o">.</span><span class="na">send</span><span class="o">(</span><span class="k">new</span> <span class="nc">ProducerRecord</span><span class="o">&lt;&gt;(</span><span class="s">"order-events"</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span> <span class="n">orderJson</span><span class="o">));</span>
</code></pre></div></div>

<h3 id="컨슈머--메시지를-읽는-쪽">컨슈머 — 메시지를 읽는 쪽</h3>

<p>컨슈머는 하나 이상의 토픽을 구독하고 메시지를 읽습니다. 핵심 개념은 <strong>오프셋(offset)</strong>입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Partition 0: [0] [1] [2] [3] [4] [5] [6]
                          ↑
                   현재 오프셋 = 3
                   (여기까지 읽었음)
</code></pre></div></div>

<p>오프셋은 파티션 내에서 메시지의 위치를 나타내는 순차적인 번호입니다. 컨슈머는 자신이 어디까지 읽었는지를 오프셋으로 관리하기 때문에, 장애 후 재시작해도 마지막으로 읽은 위치부터 이어서 처리할 수 있습니다.</p>

<hr />

<h2 id="컨슈머-그룹consumer-group--수평-확장의-핵심">컨슈머 그룹(Consumer Group) — 수평 확장의 핵심</h2>

<p>컨슈머 그룹은 Kafka가 <strong>수평 확장</strong>을 지원하는 핵심 메커니즘입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Consumer Group: order-service
├── Consumer A → Partition 0, Partition 1
├── Consumer B → Partition 2, Partition 3
└── Consumer C → Partition 4

규칙: 하나의 파티션은 그룹 내 오직 하나의 컨슈머에만 할당
</code></pre></div></div>

<h3 id="왜-이렇게-설계했을까">왜 이렇게 설계했을까?</h3>

<p>하나의 파티션을 여러 컨슈머가 동시에 읽으면 메시지 처리 순서를 보장할 수 없고, 중복 처리 가능성도 생깁니다. “하나의 파티션 = 하나의 컨슈머” 규칙 덕분에 순서 보장과 정확히 한 번 처리가 가능해집니다.</p>

<h3 id="장애-시-자동-리밸런싱">장애 시 자동 리밸런싱</h3>

<p>컨슈머 하나가 다운되면 해당 컨슈머가 담당하던 파티션이 나머지 컨슈머에게 자동으로 재할당됩니다. 별도의 수동 작업 없이 장애 복구가 이루어집니다.</p>

<blockquote>
  <p><strong>주의</strong>: 컨슈머 수가 파티션 수보다 많으면 초과된 컨슈머는 유휴 상태가 됩니다. 따라서 파티션 수를 설계할 때 예상되는 최대 컨슈머 수를 고려해야 합니다.</p>
</blockquote>

<hr />

<h2 id="브로커broker와-클러스터">브로커(Broker)와 클러스터</h2>

<p>브로커는 Kafka 서버의 단일 인스턴스입니다. 프로듀서로부터 메시지를 수신하고, 오프셋을 할당한 뒤, 디스크에 저장하는 역할을 합니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Kafka 클러스터
├── Broker 0 (Leader: P0, P3)   (Follower: P1, P4)
├── Broker 1 (Leader: P1, P4)   (Follower: P2, P0)
└── Broker 2 (Leader: P2)       (Follower: P3)
</code></pre></div></div>

<h3 id="리더와-팔로워">리더와 팔로워</h3>

<ul>
  <li><strong>리더(Leader)</strong> — 해당 파티션의 모든 읽기/쓰기를 처리합니다</li>
  <li><strong>팔로워(Follower)</strong> — 리더의 데이터를 복제합니다. 리더가 다운되면 팔로워 중 하나가 새로운 리더로 승격됩니다</li>
</ul>

<p>이 구조 덕분에 브로커 하나가 장애를 겪어도 클러스터 전체는 정상 운영됩니다.</p>

<hr />

<h2 id="kafka의-네-가지-설계-철학">Kafka의 네 가지 설계 철학</h2>

<h3 id="1-다중-프로듀서-지원">1. 다중 프로듀서 지원</h3>

<p>여러 프로듀서가 동일한 토픽에 동시에 메시지를 보낼 수 있습니다. 서로 다른 마이크로서비스에서 발생하는 이벤트를 하나의 토픽으로 모을 수 있어, 이벤트 통합이 자연스럽습니다.</p>

<h3 id="2-다중-컨슈머-그룹-지원">2. 다중 컨슈머 그룹 지원</h3>

<p>같은 토픽을 여러 컨슈머 그룹이 독립적으로 읽을 수 있습니다. 주문 이벤트를 결제 서비스, 알림 서비스, 분석 서비스가 각각 독립적으로 소비할 수 있습니다. 한 그룹이 느려지더라도 다른 그룹에 영향을 주지 않습니다.</p>

<h3 id="3-디스크-기반-저장">3. 디스크 기반 저장</h3>

<p>메시지를 메모리가 아닌 디스크에 저장합니다. 브로커가 재시작되어도 데이터가 유지되고, 보존 기간(retention period) 동안 메시지를 재소비할 수 있습니다. 장애 복구나 데이터 재처리 시나리오에서 큰 장점입니다.</p>

<h3 id="4-고성능">4. 고성능</h3>

<p>순차 쓰기, 배치 처리, zero-copy 전송 등의 기법으로 높은 처리량을 달성합니다. 디스크 기반임에도 메모리 기반 시스템에 준하는 성능을 보여줍니다.</p>

<hr />

<h2 id="마치며">마치며</h2>

<p>Chapter 1을 읽으며 느낀 점은, Kafka의 각 구성 요소가 <strong>독립적으로 존재하는 것이 아니라 서로 맞물려 동작하도록 설계</strong>되었다는 것입니다.</p>

<ul>
  <li>key → 파티션 결정 → 순서 보장</li>
  <li>파티션 → 컨슈머 그룹 → 수평 확장</li>
  <li>복제 → 리더/팔로워 → 고가용성</li>
</ul>

<p>단순히 “메시지를 보내고 받는 시스템”으로 이해하면 Kafka의 절반만 아는 것입니다. 다음 장에서는 프로듀서의 내부 동작과 설정 옵션에 대해 정리해 보겠습니다.</p>]]></content><author><name>yunhwane</name></author><category term="study" /><category term="kafka" /><category term="distributed-system" /><category term="message-queue" /><category term="book-review" /><summary type="html"><![CDATA[이 글에 대해]]></summary></entry></feed>