<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko-KR"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://0x22ff.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://0x22ff.github.io/" rel="alternate" type="text/html" hreflang="ko-KR" /><updated>2026-05-04T11:58:10+09:00</updated><id>https://0x22ff.github.io/feed.xml</id><title type="html">0x22ff Journal</title><subtitle>Engineering notes, production logs, and build diaries.</subtitle><entry><title type="html">Resilience4j Bulkhead, 외부 API 장애를 서비스 전체 장애로 번지지 않게 막는 방법</title><link href="https://0x22ff.github.io/backend/2026/04/21/resilience4j-bulkhead-ops-guide/" rel="alternate" type="text/html" title="Resilience4j Bulkhead, 외부 API 장애를 서비스 전체 장애로 번지지 않게 막는 방법" /><published>2026-04-21T18:20:00+09:00</published><updated>2026-04-21T18:20:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/04/21/resilience4j-bulkhead-ops-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/04/21/resilience4j-bulkhead-ops-guide/"><![CDATA[<p>이번엔 <code class="language-plaintext highlighter-rouge">Bulkhead</code>를 정리해본다.</p>

<p>앞에서 <code class="language-plaintext highlighter-rouge">Circuit Breaker</code>, <code class="language-plaintext highlighter-rouge">Retry + TimeLimiter</code>, <code class="language-plaintext highlighter-rouge">RateLimiter</code>를 봤다면<br />
Bulkhead는 그 사이에서 조금 덜 화려하지만 운영에서는 꽤 중요한 역할을 한다.</p>

<p>상황은 이런 식이었다.</p>

<ul>
  <li>외부 API 하나가 느려짐</li>
  <li>그 API를 호출하는 요청이 계속 쌓임</li>
  <li>애플리케이션의 공용 스레드나 커넥션이 같이 묶임</li>
  <li>결국 관련 없는 기능까지 느려짐</li>
</ul>

<p>Circuit Breaker가 “이미 나빠진 의존성을 끊는 장치”라면,<br />
Bulkhead는 <strong>나쁜 의존성이 잡아먹을 수 있는 자원 자체를 제한하는 장치</strong>에 가깝다.</p>

<h2 id="1-왜-bulkhead가-필요했는지">1) 왜 Bulkhead가 필요했는지</h2>

<p><img src="/assets/images/bulkhead-failure-isolation.svg" alt="Bulkhead failure isolation" /></p>

<p>문제는 외부 API 장애가 항상 에러로만 오지 않는다는 점이다.</p>

<p>오히려 운영에서 더 무서운 건 “느린 성공”이었다.</p>

<ol>
  <li>외부 API 응답이 200ms에서 3초로 늘어남</li>
  <li>우리 API는 계속 기다림</li>
  <li>대기 중인 요청이 늘면서 스레드/커넥션 점유 시간이 길어짐</li>
  <li>관련 없는 요청까지 영향을 받음</li>
</ol>

<p>Circuit Breaker가 열리기 전까지는 어느 정도 호출이 흘러간다.<br />
Retry가 붙어 있으면 호출 수가 더 늘 수도 있다.</p>

<p>그래서 외부 API별로 “이 의존성이 동시에 가져갈 수 있는 최대 자원”을 먼저 정해두는 게 필요했다.</p>

<h2 id="2-bulkhead는-무엇을-막는가">2) Bulkhead는 무엇을 막는가</h2>

<p>Bulkhead는 선박의 격벽에서 나온 패턴이다.<br />
한 구역에 물이 들어와도 배 전체가 바로 가라앉지 않도록 구역을 나누는 구조다.</p>

<p>애플리케이션에서는 이렇게 해석했다.</p>

<ul>
  <li>결제 API가 느려져도 상품 조회 스레드까지 잡아먹지 않게 한다</li>
  <li>알림 API가 막혀도 주문 생성 요청이 같이 밀리지 않게 한다</li>
  <li>특정 파트너 API가 포화돼도 전체 요청 처리량을 보호한다</li>
</ul>

<p>즉 Bulkhead의 핵심은 “성공률을 높이는 것”이 아니라,<br />
<strong>장애 영향 반경을 작게 유지하는 것</strong>이다.</p>

<h2 id="3-semaphorebulkhead와-threadpoolbulkhead">3) SemaphoreBulkhead와 ThreadPoolBulkhead</h2>

<p><img src="/assets/images/bulkhead-threadpool-vs-semaphore.svg" alt="Semaphore bulkhead vs thread pool bulkhead" /></p>

<p>Resilience4j에는 크게 두 가지 방식이 있다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">SemaphoreBulkhead</code>: 현재 실행 흐름은 유지하고 동시 실행 수만 제한</li>
  <li><code class="language-plaintext highlighter-rouge">ThreadPoolBulkhead</code>: 별도 thread pool과 bounded queue로 실행 영역을 분리</li>
</ul>

<p>둘의 차이를 간단히 잡으면 이렇다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>SemaphoreBulkhead</th>
      <th>ThreadPoolBulkhead</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>제한 기준</td>
      <td>동시 실행 permit</td>
      <td>thread pool + queue</td>
    </tr>
    <tr>
      <td>실행 thread</td>
      <td>호출한 쪽 thread</td>
      <td>별도 bulkhead thread</td>
    </tr>
    <tr>
      <td>주요 설정</td>
      <td><code class="language-plaintext highlighter-rouge">maxConcurrentCalls</code>, <code class="language-plaintext highlighter-rouge">maxWaitDuration</code></td>
      <td><code class="language-plaintext highlighter-rouge">coreThreadPoolSize</code>, <code class="language-plaintext highlighter-rouge">maxThreadPoolSize</code>, <code class="language-plaintext highlighter-rouge">queueCapacity</code></td>
    </tr>
    <tr>
      <td>장점</td>
      <td>단순하고 오버헤드가 작음</td>
      <td>느린 I/O를 별도 풀로 격리하기 쉬움</td>
    </tr>
    <tr>
      <td>주의점</td>
      <td>호출 thread가 그대로 대기할 수 있음</td>
      <td>queue를 크게 잡으면 장애를 늦게 발견함</td>
    </tr>
  </tbody>
</table>

<p>내 기준은 이랬다.</p>

<ul>
  <li>이미 비동기/논블로킹 모델이고 동시성만 제한하면 되면 <code class="language-plaintext highlighter-rouge">SemaphoreBulkhead</code></li>
  <li>블로킹 외부 API 호출을 별도 풀로 격리해야 하면 <code class="language-plaintext highlighter-rouge">ThreadPoolBulkhead</code></li>
</ul>

<p>Spring Cloud CircuitBreaker Resilience4j를 같이 쓰는 경우에는 기본 동작과 적용 범위를 꼭 확인해야 한다.<br />
<code class="language-plaintext highlighter-rouge">resilience4j-bulkhead</code>가 classpath에 있으면 메서드가 Bulkhead로 감싸질 수 있고, 기본 Bulkhead 타입도 설정에 따라 달라진다.</p>

<h2 id="4-설정은-작게-시작하는-게-낫다">4) 설정은 작게 시작하는 게 낫다</h2>

<p>Semaphore 방식은 아래처럼 시작했다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resilience4j</span><span class="pi">:</span>
  <span class="na">bulkhead</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">partnerApi</span><span class="pi">:</span>
        <span class="na">maxConcurrentCalls</span><span class="pi">:</span> <span class="m">20</span>
        <span class="na">maxWaitDuration</span><span class="pi">:</span> <span class="m">0</span>
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">maxWaitDuration: 0</code>은 permit이 없으면 기다리지 않고 바로 거절한다는 의미로 잡았다.<br />
운영에서는 대기열을 길게 두는 것보다 빠르게 거절하고 fallback/에러 응답으로 전환하는 쪽이 더 해석하기 쉬웠다.</p>

<p>ThreadPool 방식은 별도 설정을 쓴다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resilience4j</span><span class="pi">:</span>
  <span class="na">thread-pool-bulkhead</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">partnerApi</span><span class="pi">:</span>
        <span class="na">coreThreadPoolSize</span><span class="pi">:</span> <span class="m">8</span>
        <span class="na">maxThreadPoolSize</span><span class="pi">:</span> <span class="m">16</span>
        <span class="na">queueCapacity</span><span class="pi">:</span> <span class="m">20</span>
        <span class="na">keepAliveDuration</span><span class="pi">:</span> <span class="s">20ms</span>
</code></pre></div></div>

<p>중요한 건 두 설정이 서로 대체 관계가 아니라는 점이다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead</code>는 SemaphoreBulkhead 설정</li>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.thread-pool-bulkhead</code>는 ThreadPoolBulkhead 설정</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">maxConcurrentCalls</code>를 ThreadPoolBulkhead에 넣어놓고 왜 안 먹는지 찾는 식의 실수가 생기기 쉽다.</p>

<h2 id="5-코드에서는-bulkheadfullexception을-정상적인-운영-분기로-본다">5) 코드에서는 BulkheadFullException을 정상적인 운영 분기로 본다</h2>

<p>annotation 방식은 읽기 쉽다.</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">PartnerStockClient</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ExternalPartnerClient</span> <span class="n">externalPartnerClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">PartnerStockClient</span><span class="o">(</span><span class="nc">ExternalPartnerClient</span> <span class="n">externalPartnerClient</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">externalPartnerClient</span> <span class="o">=</span> <span class="n">externalPartnerClient</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Bulkhead</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"partnerApi"</span><span class="o">,</span> <span class="n">fallbackMethod</span> <span class="o">=</span> <span class="s">"getStockFallback"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">StockResponse</span> <span class="nf">getStock</span><span class="o">(</span><span class="nc">String</span> <span class="n">sku</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">externalPartnerClient</span><span class="o">.</span><span class="na">getStock</span><span class="o">(</span><span class="n">sku</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">StockResponse</span> <span class="nf">getStockFallback</span><span class="o">(</span><span class="nc">String</span> <span class="n">sku</span><span class="o">,</span> <span class="nc">Throwable</span> <span class="n">throwable</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">throwable</span> <span class="k">instanceof</span> <span class="nc">BulkheadFullException</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="nc">StockResponse</span><span class="o">.</span><span class="na">temporarilyUnavailable</span><span class="o">(</span><span class="n">sku</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="nc">StockResponse</span><span class="o">.</span><span class="na">fallback</span><span class="o">(</span><span class="n">sku</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 핵심은 <code class="language-plaintext highlighter-rouge">BulkheadFullException</code>을 “예상 못한 예외”처럼 다루지 않는 것이다.</p>

<p>Bulkhead가 꽉 찼다는 건 설정이 의도대로 동작해서 더 큰 장애를 막았다는 뜻이기도 하다.<br />
그래서 로그 레벨, 알람 기준, 사용자 응답을 별도로 잡아야 한다.</p>

<p>예를 들어:</p>

<ul>
  <li>사용자에게는 “일시적으로 조회할 수 없음”을 명확히 반환</li>
  <li>운영 로그에는 bulkhead 이름과 요청 키를 남김</li>
  <li>알람은 단건 예외가 아니라 거절률 기준으로 발송</li>
</ul>

<p>이렇게 해야 Bulkhead가 장애를 숨기는 장치가 아니라, 장애 범위를 제한하는 장치로 남는다.</p>

<h2 id="6-조합-순서가-중요하다">6) 조합 순서가 중요하다</h2>

<p><img src="/assets/images/bulkhead-composition-order.svg" alt="Bulkhead composition order" /></p>

<p>Bulkhead를 단독으로 쓰는 경우보다 다른 Resilience4j 모듈과 같이 쓰는 경우가 많다.</p>

<p>내가 운영 관점에서 잡은 기본 순서는 이랬다.</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">Bulkhead</code>로 동시에 들어갈 수 있는 호출 수를 먼저 제한</li>
  <li><code class="language-plaintext highlighter-rouge">TimeLimiter</code>로 각 호출의 최대 대기 시간을 제한</li>
  <li><code class="language-plaintext highlighter-rouge">Retry</code>는 제한된 실패에만 아주 작게 허용</li>
  <li><code class="language-plaintext highlighter-rouge">Circuit Breaker</code>로 실패율/느린 호출 비율이 높아지면 차단</li>
</ol>

<p>정답 순서라기보다 “자원 보호를 먼저 생각하는 순서”다.</p>

<p>특히 Retry를 Bulkhead 바깥에서 크게 걸면, Bulkhead가 거절한 호출을 계속 재시도하면서 다시 포화를 만들 수 있다.<br />
그래서 retry 대상 예외에서 <code class="language-plaintext highlighter-rouge">BulkheadFullException</code>을 제외하거나, 아주 제한적으로만 다뤄야 한다.</p>

<h2 id="7-관측은-permit-queue-rejection을-같이-본다">7) 관측은 permit, queue, rejection을 같이 본다</h2>

<p><img src="/assets/images/bulkhead-ops-dashboard.svg" alt="Bulkhead operations dashboard" /></p>

<p>SemaphoreBulkhead에서는 최소한 아래 지표를 본다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead.available.concurrent.calls</code></li>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead.max.allowed.concurrent.calls</code></li>
  <li>bulkhead 거절 횟수/비율</li>
  <li>호출 latency p95/p99</li>
</ul>

<p>ThreadPoolBulkhead에서는 여기에 queue와 thread pool 지표가 추가된다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead.queue.depth</code></li>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead.queue.capacity</code></li>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead.thread.pool.size</code></li>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead.core.thread.pool.size</code></li>
  <li><code class="language-plaintext highlighter-rouge">resilience4j.bulkhead.max.thread.pool.size</code></li>
</ul>

<p>여기서 내가 가장 먼저 보는 건 queue depth였다.</p>

<p>queue가 계속 차오르는데 에러율이 낮으면 겉으로는 안정적으로 보일 수 있다.<br />
하지만 실제로는 장애를 queue에 숨기고 있는 상태일 수 있다.</p>

<p>그래서 ThreadPoolBulkhead를 쓸 때는 queue를 크게 잡지 않았다.<br />
queue는 순간 피크를 흡수하는 용도이지, 다운스트림 장애를 오래 버티는 버퍼가 아니라고 봤다.</p>

<h2 id="8-값은-어떻게-잡았나">8) 값은 어떻게 잡았나</h2>

<p>처음부터 복잡하게 계산하지 않았다.<br />
대신 아래 기준으로 작게 시작했다.</p>

<h3 id="semaphorebulkhead">SemaphoreBulkhead</h3>

<ul>
  <li>외부 API별 정상 p95 latency 확인</li>
  <li>해당 API가 동시에 가져가도 되는 최대 요청 수 산정</li>
  <li><code class="language-plaintext highlighter-rouge">maxWaitDuration</code>은 0 또는 아주 짧게 시작</li>
  <li>거절률과 사용자 영향도를 보고 점진 조정</li>
</ul>

<h3 id="threadpoolbulkhead">ThreadPoolBulkhead</h3>

<ul>
  <li>블로킹 I/O 전용 pool을 작게 분리</li>
  <li><code class="language-plaintext highlighter-rouge">queueCapacity</code>는 작게 시작</li>
  <li>pool size보다 queue 증가가 먼저 보이면 다운스트림 지연으로 판단</li>
  <li>pool을 늘리기 전에 timeout과 circuit 상태를 먼저 확인</li>
</ul>

<p>여기서 주의할 점은 thread pool을 키우는 게 항상 해결책은 아니라는 것이다.</p>

<p>외부 API가 느린데 pool만 키우면 더 많은 요청이 동시에 외부 API로 몰린다.<br />
그 결과 다운스트림을 더 압박하고, 우리 쪽 메모리/스레드 사용량도 같이 올라간다.</p>

<h2 id="9-롤아웃-체크리스트">9) 롤아웃 체크리스트</h2>

<p>Bulkhead는 설정값 하나로 끝내기보다 아래 순서로 붙이는 게 안전했다.</p>

<ol>
  <li>보호할 외부 의존성 목록을 먼저 정한다</li>
  <li>의존성별 timeout, retry, circuit breaker 설정을 같이 확인한다</li>
  <li>Semaphore/ThreadPool 중 어떤 격리가 필요한지 결정한다</li>
  <li>rejection/fallback 응답을 사용자 관점에서 정의한다</li>
  <li>permit, queue, rejection, latency 지표를 먼저 대시보드에 올린다</li>
  <li>canary로 일부 트래픽에만 적용한다</li>
  <li>거절률이 정상적인 보호 신호인지, 과도한 사용자 영향인지 구분한다</li>
</ol>

<p>특히 Bulkhead를 붙인 직후에는 에러가 “늘어난 것처럼” 보일 수 있다.</p>

<p>하지만 그 에러가 기존에는 긴 대기와 전체 지연으로 숨어 있었을 가능성이 있다.<br />
그래서 단순 에러 수보다 p95/p99 latency, thread pool 사용량, 다른 기능 영향도를 같이 봐야 한다.</p>

<h2 id="10-자주-헷갈렸던-포인트">10) 자주 헷갈렸던 포인트</h2>

<h3 id="q-circuit-breaker가-있으면-bulkhead는-없어도-되나">Q. Circuit Breaker가 있으면 Bulkhead는 없어도 되나?</h3>

<p>아니다. Circuit Breaker는 실패율이나 느린 호출 비율을 보고 상태를 바꾼다.<br />
그 판단이 일어나기 전까지 이미 많은 호출이 자원을 점유할 수 있다.</p>

<p>Bulkhead는 그 전에 “이 의존성이 가져갈 수 있는 자원”을 제한한다.</p>

<h3 id="q-queuecapacity를-크게-잡으면-더-안전한가">Q. queueCapacity를 크게 잡으면 더 안전한가?</h3>

<p>대부분은 아니다.<br />
queue가 크면 순간 피크는 흡수하지만, 다운스트림 장애를 늦게 드러내고 tail latency를 키울 수 있다.</p>

<h3 id="q-bulkheadfullexception은-장애인가">Q. BulkheadFullException은 장애인가?</h3>

<p>사용자 관점에서는 실패지만, 시스템 관점에서는 보호 동작이다.<br />
그래서 단건 예외 알림보다 비율 기반 알람이 더 낫다.</p>

<h3 id="q-webflux에서도-threadpoolbulkhead가-필요한가">Q. WebFlux에서도 ThreadPoolBulkhead가 필요한가?</h3>

<p>항상 필요한 건 아니다.<br />
논블로킹 호출이면 SemaphoreBulkhead로 동시성만 제한하는 쪽이 단순하다.<br />
다만 어쩔 수 없이 블로킹 라이브러리를 호출한다면 별도 scheduler나 thread pool 격리를 같이 검토해야 한다.</p>

<hr />

<p>정리하면 Bulkhead는 장애를 없애는 기능이 아니라,<br />
<strong>장애가 가져갈 수 있는 자원을 제한하는 기능</strong>이다.</p>

<p>Circuit Breaker가 장애 전파를 끊고,<br />
Retry + TimeLimiter가 일시 장애와 지연을 제어한다면,<br />
Bulkhead는 그 모든 호출이 사용할 수 있는 실행 공간을 먼저 나눈다.</p>

<p>운영에서는 이 차이가 꽤 컸다.</p>

<p>외부 API 하나가 느려졌을 때 전체 서비스가 같이 느려지는지,<br />
아니면 해당 기능만 제한적으로 실패하고 나머지는 살아남는지의 차이를 만들기 때문이다.</p>

<h2 id="references">References</h2>

<ul>
  <li>Resilience4j Bulkhead Guide: https://resilience4j.readme.io/docs/bulkhead</li>
  <li>Resilience4j Micrometer Guide: https://resilience4j.readme.io/docs/micrometer</li>
  <li>Spring Cloud CircuitBreaker Bulkhead Pattern: https://docs.spring.io/spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-resilience4j/bulkhead-pattern-supporting.html</li>
  <li>Spring Cloud CircuitBreaker Bulkhead Properties: https://docs.spring.io/spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-resilience4j/bulkhead-properties-configuration.html</li>
</ul>]]></content><author><name></name></author><category term="backend" /><category term="spring" /><category term="resilience4j" /><category term="bulkhead" /><category term="threadpool" /><category term="sre" /><summary type="html"><![CDATA[이번엔 Bulkhead를 정리해본다.]]></summary></entry><entry><title type="html">Resilience4j Retry + TimeLimiter, 운영에서 안전하게 붙이는 방법</title><link href="https://0x22ff.github.io/backend/2026/04/01/resilience4j-retry-timeout-ops-guide/" rel="alternate" type="text/html" title="Resilience4j Retry + TimeLimiter, 운영에서 안전하게 붙이는 방법" /><published>2026-04-01T13:10:00+09:00</published><updated>2026-04-01T13:10:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/04/01/resilience4j-retry-timeout-ops-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/04/01/resilience4j-retry-timeout-ops-guide/"><![CDATA[<p>이번엔 retry를 “한 번 더 해보자” 수준이 아니라, 실제 운영에서 안전하게 붙인 기준으로 정리해본다.</p>

<p>상황은 비슷했다.</p>

<ul>
  <li>다운스트림 API가 간헐적으로 timeout</li>
  <li>한 번 실패했다고 바로 장애로 볼 정도는 아님</li>
  <li>그런데 retry를 크게 걸면 호출 수만 늘어나서 장애가 더 커짐</li>
</ul>

<p>그래서 <code class="language-plaintext highlighter-rouge">Retry</code>만 따로 쓰지 않고,<br />
<code class="language-plaintext highlighter-rouge">TimeLimiter</code> + 예외 정책 + 관측을 같이 묶어서 적용했다.</p>

<h2 id="1-왜-retry만-단독으로-붙이면-위험한가">1) 왜 Retry만 단독으로 붙이면 위험한가</h2>

<p><img src="/assets/images/retry-timeout-failure-pattern.svg" alt="Failure pattern before retry timeout policy" /></p>

<p>문제는 retry 자체가 아니다.<br />
<strong>느린 호출에 retry를 겹치면, 복구보다 증폭이 먼저 온다</strong>는 점이다.</p>

<p>운영에서 실제로 보인 흐름은 이랬다.</p>

<ol>
  <li>다운스트림 지연/timeout 증가</li>
  <li>우리 쪽 대기 시간이 길어짐</li>
  <li>같은 요청에 재시도까지 붙으면서 총 호출 수 증가</li>
  <li>지연과 에러가 같이 커짐</li>
</ol>

<p>즉 retry는 보호막이 될 수도 있지만, 설정을 잘못 잡으면 트래픽 증폭기가 되기도 한다.</p>

<h2 id="2-내가-잡은-기본-원칙-시도마다-timeout-상한을-먼저-둔다">2) 내가 잡은 기본 원칙: 시도마다 timeout 상한을 먼저 둔다</h2>

<p><img src="/assets/images/retry-timeout-call-flow.svg" alt="Retry and timeout call flow" /></p>

<p>내 기준은 단순했다.</p>

<ul>
  <li>각 시도마다 <code class="language-plaintext highlighter-rouge">timeoutDuration</code>으로 상한 시간을 먼저 고정</li>
  <li>retry는 정말 재시도할 가치가 있는 실패에만 제한적으로 허용</li>
  <li>전체 요청 지연이 SLO budget을 넘지 않게 계산</li>
</ul>

<p>여기서 중요한 건 <strong>attempt 단위 timeout</strong>이다.<br />
timeout 없이 retry만 있으면, 느린 호출이 길게 붙잡힌 뒤 다시 재시도되면서 전체 응답 시간이 쉽게 무너진다.</p>

<p>예를 들어:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">maxAttempts: 3</code></li>
  <li><code class="language-plaintext highlighter-rouge">waitDuration: 200ms</code></li>
  <li><code class="language-plaintext highlighter-rouge">timeoutDuration: 800ms</code></li>
</ul>

<p>이 설정이면 최악 지연은 대략 <code class="language-plaintext highlighter-rouge">800 * 3 + 200 * 2 = 2.8s</code>까지 갈 수 있다.<br />
숫자만 보면 작아 보여도, API SLO가 1초대면 이미 예산을 넘긴다.</p>

<h2 id="3-설정은-먼저-보수적으로-시작했다">3) 설정은 먼저 보수적으로 시작했다</h2>

<p><code class="language-plaintext highlighter-rouge">application.yml</code> 예시는 아래처럼 시작했다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resilience4j</span><span class="pi">:</span>
  <span class="na">retry</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">partnerApi</span><span class="pi">:</span>
        <span class="na">maxAttempts</span><span class="pi">:</span> <span class="m">3</span>
        <span class="na">waitDuration</span><span class="pi">:</span> <span class="s">200ms</span>
        <span class="na">retryExceptions</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s">java.net.SocketTimeoutException</span>
          <span class="pi">-</span> <span class="s">java.io.IOException</span>
          <span class="pi">-</span> <span class="s">java.util.concurrent.TimeoutException</span>
        <span class="na">ignoreExceptions</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s">com.example.api.ValidationException</span>
          <span class="pi">-</span> <span class="s">org.springframework.web.client.HttpClientErrorException$BadRequest</span>

  <span class="na">timelimiter</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">partnerApi</span><span class="pi">:</span>
        <span class="na">timeoutDuration</span><span class="pi">:</span> <span class="s">800ms</span>
        <span class="na">cancelRunningFuture</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>포인트는 이 정도였다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">maxAttempts</code>는 보통 <code class="language-plaintext highlighter-rouge">2</code> 또는 <code class="language-plaintext highlighter-rouge">3</code>부터 시작</li>
  <li><code class="language-plaintext highlighter-rouge">waitDuration</code>은 짧게 두고, 필요하면 backoff로 확장</li>
  <li><code class="language-plaintext highlighter-rouge">ignoreExceptions</code>로 4xx/비즈니스 오류는 fail-fast</li>
  <li><code class="language-plaintext highlighter-rouge">cancelRunningFuture: true</code>로 timeout 이후 남는 작업을 최대한 줄임</li>
</ul>

<p>코드 예시는 <code class="language-plaintext highlighter-rouge">TimeLimiter</code> 때문에 <code class="language-plaintext highlighter-rouge">CompletableFuture</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">PartnerOrderClient</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Executor</span> <span class="n">ioExecutor</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ExternalPartnerClient</span> <span class="n">externalPartnerClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">PartnerOrderClient</span><span class="o">(</span><span class="nd">@Qualifier</span><span class="o">(</span><span class="s">"partnerIoExecutor"</span><span class="o">)</span> <span class="nc">Executor</span> <span class="n">ioExecutor</span><span class="o">,</span>
                              <span class="nc">ExternalPartnerClient</span> <span class="n">externalPartnerClient</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">ioExecutor</span> <span class="o">=</span> <span class="n">ioExecutor</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">externalPartnerClient</span> <span class="o">=</span> <span class="n">externalPartnerClient</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Retry</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"partnerApi"</span><span class="o">,</span> <span class="n">fallbackMethod</span> <span class="o">=</span> <span class="s">"getOrderFallback"</span><span class="o">)</span>
    <span class="nd">@TimeLimiter</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"partnerApi"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">CompletableFuture</span><span class="o">&lt;</span><span class="nc">OrderResponse</span><span class="o">&gt;</span> <span class="nf">getOrder</span><span class="o">(</span><span class="nc">String</span> <span class="n">orderId</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">CompletableFuture</span><span class="o">.</span><span class="na">supplyAsync</span><span class="o">(</span>
                <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">externalPartnerClient</span><span class="o">.</span><span class="na">getOrder</span><span class="o">(</span><span class="n">orderId</span><span class="o">),</span>
                <span class="n">ioExecutor</span>
        <span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">CompletableFuture</span><span class="o">&lt;</span><span class="nc">OrderResponse</span><span class="o">&gt;</span> <span class="nf">getOrderFallback</span><span class="o">(</span><span class="nc">String</span> <span class="n">orderId</span><span class="o">,</span> <span class="nc">Throwable</span> <span class="n">t</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">CompletableFuture</span><span class="o">.</span><span class="na">completedFuture</span><span class="o">(</span><span class="nc">OrderResponse</span><span class="o">.</span><span class="na">fallback</span><span class="o">(</span><span class="n">orderId</span><span class="o">));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 한 가지는 꼭 기억해야 한다.<br />
Spring annotation 방식을 쓰면 aspect order 영향을 받는다.</p>

<p>기본적으로 <code class="language-plaintext highlighter-rouge">Retry</code>가 바깥에서 감싸기 때문에,<br />
<strong>각 retry attempt가 안쪽 <code class="language-plaintext highlighter-rouge">TimeLimiter</code> 제한을 받는 구조</strong>로 이해하면 된다.</p>

<p>기본 순서가 애매하거나 조합을 더 세밀하게 제어하고 싶으면, annotation보다 decorator 방식이 덜 헷갈린다.</p>

<h2 id="4-어떤-실패만-retry-대상으로-둘지-먼저-고정해야-한다">4) 어떤 실패만 retry 대상으로 둘지 먼저 고정해야 한다</h2>

<p><img src="/assets/images/retry-decision-matrix.svg" alt="Retry decision matrix" /></p>

<p>운영에서 기준은 아주 보수적으로 잡는 게 낫다.</p>

<p>retry 후보:</p>

<ul>
  <li>네트워크 timeout</li>
  <li>다운스트림 일시적 5xx</li>
  <li>connection reset 같은 일시 장애</li>
</ul>

<p>fail-fast 후보:</p>

<ul>
  <li>validation/business 오류(주로 4xx)</li>
  <li>idempotency key 없는 쓰기 요청</li>
  <li>원인이 명확한 애플리케이션 예외</li>
</ul>

<p>여기서 제일 많이 실수하는 게 쓰기 요청이다.</p>

<ul>
  <li>조회성 GET</li>
  <li>멱등성이 보장된 PUT</li>
  <li><code class="language-plaintext highlighter-rouge">idempotency key</code>가 있는 결제/주문 요청</li>
</ul>

<p>이 정도가 아니면 retry는 더 조심해야 한다.<br />
POST를 아무 생각 없이 재시도하면 중복 처리 사고로 바로 이어질 수 있다.</p>

<h2 id="5-설정보다-더-중요한-건-관측이다">5) 설정보다 더 중요한 건 관측이다</h2>

<p><img src="/assets/images/retry-observability-dashboard.svg" alt="Retry timeout observability dashboard" /></p>

<p>Retry를 붙인 뒤 최소로 본 건 4가지였다.</p>

<ul>
  <li>retry 후 성공 비율</li>
  <li>timeout 비율</li>
  <li>fallback 비율</li>
  <li>전체 응답 지연(p95/p99)</li>
</ul>

<p>여기서 중요한 건 “retry 성공이 많다 = 좋은 설정”이 아니라는 점이다.<br />
retry 후 성공이 늘어도 timeout 비율이나 전체 latency가 같이 올라가면,<br />
실제로는 장애를 길게 끌고 있을 가능성이 크다.</p>

<p>내가 본 기준은 이랬다.</p>

<ul>
  <li>retry 성공 비율은 오르는데 p95가 같이 튄다 -&gt; wait/attempt 과다 가능성</li>
  <li>timeout 비율이 빠르게 늘어난다 -&gt; 다운스트림 자체 문제 먼저 확인</li>
  <li>fallback 비율이 높다 -&gt; 사용자 영향 범위를 같이 점검</li>
  <li>배포 직후 retry 호출 수가 급증한다 -&gt; 즉시 canary 범위 재검토</li>
</ul>

<p>관측 없이 retry를 켜면 “실패가 줄어든 것처럼 보이는 착시”가 자주 생긴다.</p>

<h2 id="6-롤아웃은-체크리스트를-고정해두는-게-낫다">6) 롤아웃은 체크리스트를 고정해두는 게 낫다</h2>

<p><img src="/assets/images/retry-rollout-checklist.svg" alt="Retry timeout rollout checklist" /></p>

<p>나는 순서를 거의 고정했다.</p>

<ol>
  <li>idempotency 먼저 확인</li>
  <li>retry/ignore exception 정책 확정</li>
  <li>메트릭과 알람부터 붙임</li>
  <li>canary로 일부 트래픽만 적용</li>
  <li>이상 없을 때만 점진 확대</li>
</ol>

<p>특히 <code class="language-plaintext highlighter-rouge">maxAttempts</code>와 <code class="language-plaintext highlighter-rouge">timeoutDuration</code>은<br />
SLO budget 안에 들어오는지 먼저 계산해두는 게 좋다.</p>

<p>retry는 실패를 가리는 기능이 아니라,<br />
<strong>일시 장애를 작은 비용으로 넘기는 기능</strong>이어야 한다.</p>

<h2 id="7-자주-헷갈렸던-포인트">7) 자주 헷갈렸던 포인트</h2>

<h3 id="q-timeout을-늘리면-retry는-줄여야-하나">Q. timeout을 늘리면 retry는 줄여야 하나?</h3>

<p>대부분은 그렇다.<br />
전체 지연 예산은 한정돼 있으니, 한쪽을 늘리면 다른 쪽은 줄이는 게 맞다.</p>

<h3 id="q-fallback이-있으면-성공으로-봐도-되나">Q. fallback이 있으면 성공으로 봐도 되나?</h3>

<p>아니다. fallback은 대체 응답이지, 원래 호출이 건강했다는 뜻은 아니다.<br />
운영 지표에서도 fallback 비율은 따로 봐야 한다.</p>

<h3 id="q-retry에-exponential-backoff를-바로-넣는-게-좋나">Q. retry에 exponential backoff를 바로 넣는 게 좋나?</h3>

<p>바로 넣기보다, 먼저 fixed wait로 작게 시작하는 쪽이 해석이 쉽다.<br />
장애 패턴이 확인된 뒤에 backoff/jitter를 추가하는 게 운영에서 덜 꼬였다.</p>

<h3 id="q-쓰기-요청에도-retry를-걸-수-있나">Q. 쓰기 요청에도 retry를 걸 수 있나?</h3>

<p>가능은 하지만, 멱등성 보장 전략이 먼저다.<br />
<code class="language-plaintext highlighter-rouge">idempotency key</code>나 중복 방지 설계 없이는 매우 조심해야 한다.</p>

<hr />

<p>정리하면 Retry는 “한 번 더 시도해주는 친절한 옵션”이 아니라,</p>

<ul>
  <li>timeout 상한을 먼저 두고</li>
  <li>retry할 실패를 좁게 고르고</li>
  <li>관측과 롤아웃까지 같이 설계해야 하는</li>
</ul>

<p>운영 장치에 가깝다.</p>

<p>특히 <code class="language-plaintext highlighter-rouge">Retry + TimeLimiter</code>를 같이 보면<br />
“실패를 조금 덜 보이게 만드는 설정”이 아니라,<br />
<strong>전체 지연과 호출 증폭을 통제하는 설정</strong>으로 보게 된다.</p>

<h2 id="references">References</h2>

<ul>
  <li>Resilience4j Retry Guide: https://resilience4j.readme.io/docs/retry</li>
  <li>Resilience4j TimeLimiter Guide: https://resilience4j.readme.io/docs/timeout</li>
  <li>Resilience4j Spring Boot Getting Started: https://resilience4j.readme.io/v2.0.0/docs/getting-started-3</li>
  <li>Spring Boot Actuator Metrics: https://docs.spring.io/spring-boot/reference/actuator/metrics.html</li>
</ul>]]></content><author><name></name></author><category term="backend" /><category term="spring" /><category term="resilience4j" /><category term="retry" /><category term="timelimiter" /><category term="timeout" /><category term="sre" /><summary type="html"><![CDATA[이번엔 retry를 “한 번 더 해보자” 수준이 아니라, 실제 운영에서 안전하게 붙인 기준으로 정리해본다.]]></summary></entry><entry><title type="html">Kafka Consumer를 초당 1건으로 제어한 방법: Resilience4j RateLimiter 적용기</title><link href="https://0x22ff.github.io/backend/2026/03/18/kafka-consumer-rate-limiter-ops-guide/" rel="alternate" type="text/html" title="Kafka Consumer를 초당 1건으로 제어한 방법: Resilience4j RateLimiter 적용기" /><published>2026-03-18T12:10:00+09:00</published><updated>2026-03-18T12:10:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/03/18/kafka-consumer-rate-limiter-ops-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/03/18/kafka-consumer-rate-limiter-ops-guide/"><![CDATA[<p>이번엔 딱 내가 실제로 썼던 케이스만 정리해본다.</p>

<p>요구사항은 단순했다.</p>

<ul>
  <li>Kafka 메시지를 consumer에서 읽어서 비즈니스 처리</li>
  <li>다운스트림 제약 때문에 처리 속도는 <code class="language-plaintext highlighter-rouge">초당 1건</code>으로 고정</li>
  <li>과속 처리로 장애 전파가 나지 않게 제어</li>
</ul>

<p>핵심은 producer를 막는 게 아니라, <strong>consumer 처리 속도를 의도적으로 제한</strong>하는 쪽이었다.</p>

<h2 id="1-전체-흐름부터-보면-쉽다">1) 전체 흐름부터 보면 쉽다</h2>

<p><img src="/assets/images/kafka-rate-limiter-flow.svg" alt="Kafka consumer flow with rate limiter" /></p>

<p>흐름은 아래처럼 잡았다.</p>

<ol>
  <li>poll로 레코드 1건 수신</li>
  <li><code class="language-plaintext highlighter-rouge">RateLimiter</code> permit 획득 시도</li>
  <li>permit이 있으면 비즈니스 처리</li>
  <li>정상 처리 후 ack(오프셋 커밋)</li>
</ol>

<p>여기서 중요한 건, permit을 못 받았는데 ack를 해버리면 메시지를 잃을 수 있다는 점이다.</p>

<h2 id="2-설정은-먼저-보수적으로-시작했다">2) 설정은 먼저 보수적으로 시작했다</h2>

<p><img src="/assets/images/kafka-rate-limiter-config.svg" alt="Configuration map for 1 msg/sec consumer" /></p>

<p><code class="language-plaintext highlighter-rouge">application.yml</code> 예시는 아래처럼 시작했다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spring</span><span class="pi">:</span>
  <span class="na">kafka</span><span class="pi">:</span>
    <span class="na">listener</span><span class="pi">:</span>
      <span class="na">ack-mode</span><span class="pi">:</span> <span class="s">manual</span>
      <span class="na">concurrency</span><span class="pi">:</span> <span class="m">1</span>
    <span class="na">consumer</span><span class="pi">:</span>
      <span class="na">max-poll-records</span><span class="pi">:</span> <span class="m">1</span>

<span class="na">resilience4j</span><span class="pi">:</span>
  <span class="na">ratelimiter</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">kafkaWorker</span><span class="pi">:</span>
        <span class="na">limitForPeriod</span><span class="pi">:</span> <span class="m">1</span>
        <span class="na">limitRefreshPeriod</span><span class="pi">:</span> <span class="s">1s</span>
        <span class="na">timeoutDuration</span><span class="pi">:</span> <span class="m">0</span>
</code></pre></div></div>

<p>설정 포인트:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">limitForPeriod: 1</code> + <code class="language-plaintext highlighter-rouge">limitRefreshPeriod: 1s</code> -&gt; 초당 1 permit</li>
  <li><code class="language-plaintext highlighter-rouge">timeoutDuration: 0</code> -&gt; permit 없으면 즉시 실패 처리</li>
  <li><code class="language-plaintext highlighter-rouge">concurrency: 1</code>, <code class="language-plaintext highlighter-rouge">max-poll-records: 1</code> -&gt; burst를 먼저 차단</li>
</ul>

<p>인스턴스가 여러 개인 경우엔 전체 처리량이 합산되니, 인스턴스 수까지 포함해서 총량 계산을 먼저 해야 한다.</p>

<h2 id="3-consumer-코드-예시">3) Consumer 코드 예시</h2>

<p>아래 코드는 Spring Kafka listener에서 permit을 먼저 확인한 다음 처리/ack를 분기하는 패턴이다.</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">BillingConsumer</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">RateLimiter</span> <span class="n">rateLimiter</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">BillingService</span> <span class="n">billingService</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">BillingConsumer</span><span class="o">(</span><span class="nc">RateLimiterRegistry</span> <span class="n">registry</span><span class="o">,</span> <span class="nc">BillingService</span> <span class="n">billingService</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">rateLimiter</span> <span class="o">=</span> <span class="n">registry</span><span class="o">.</span><span class="na">rateLimiter</span><span class="o">(</span><span class="s">"kafkaWorker"</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">billingService</span> <span class="o">=</span> <span class="n">billingService</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@KafkaListener</span><span class="o">(</span><span class="n">topics</span> <span class="o">=</span> <span class="s">"billing-events"</span><span class="o">,</span> <span class="n">groupId</span> <span class="o">=</span> <span class="s">"billing-worker"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">consume</span><span class="o">(</span><span class="nc">ConsumerRecord</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">BillingEvent</span><span class="o">&gt;</span> <span class="n">record</span><span class="o">,</span>
                        <span class="nc">Acknowledgment</span> <span class="n">ack</span><span class="o">)</span> <span class="o">{</span>

        <span class="kt">boolean</span> <span class="n">permitted</span> <span class="o">=</span> <span class="n">rateLimiter</span><span class="o">.</span><span class="na">acquirePermission</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(!</span><span class="n">permitted</span><span class="o">)</span> <span class="o">{</span>
            <span class="c1">// permit 미획득: ack하지 않고 예외를 던져 재시도/백오프 경로로 보냄</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span><span class="s">"rate limit exceeded"</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="n">billingService</span><span class="o">.</span><span class="na">process</span><span class="o">(</span><span class="n">record</span><span class="o">.</span><span class="na">value</span><span class="o">());</span>
        <span class="n">ack</span><span class="o">.</span><span class="na">acknowledge</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 재시도 전략은 listener container의 error handler(<code class="language-plaintext highlighter-rouge">DefaultErrorHandler</code>, backoff, DLT 등)와 같이 설계해야 한다.</p>

<h2 id="4-운영에서-진짜-봐야-하는-지표">4) 운영에서 진짜 봐야 하는 지표</h2>

<p><img src="/assets/images/kafka-rate-limiter-metrics.svg" alt="Rate limiter metrics dashboard" /></p>

<p>내가 최소로 본 건 3개였다.</p>

<ul>
  <li>실제 처리 TPS(정말 1 근처로 유지되는지)</li>
  <li>consumer lag(지속 증가하는지)</li>
  <li>permit 미획득 비율(거부가 과도한지)</li>
</ul>

<p>TPS만 보면 놓치는 게 많다. lag가 천천히 쌓이면 나중에 한 번에 터지기 때문에, lag 추세를 같이 봐야 한다.</p>

<h2 id="5-롤아웃은-한-번에-하지-않았다">5) 롤아웃은 한 번에 하지 않았다</h2>

<p><img src="/assets/images/kafka-rate-limiter-rollout.svg" alt="Kafka rate limiter rollout checklist" /></p>

<p>안전하게 적용하려고 순서를 고정했다.</p>

<ol>
  <li>현재 TPS/lag baseline 먼저 확보</li>
  <li>일부 consumer(canary)만 적용</li>
  <li>24시간 이상 지표 관찰</li>
  <li>이상 없으면 점진 확대</li>
  <li>알람 임계치 + 장애 runbook 확정</li>
</ol>

<p>메시지 처리 계열은 작은 설정 하나가 누적 지연으로 바로 연결되기 때문에, canary 없이 한 번에 여는 건 피하는 게 낫다.</p>

<h2 id="6-자주-헷갈렸던-포인트">6) 자주 헷갈렸던 포인트</h2>

<h3 id="q-kafkalistener-concurrency가-2-이상이면">Q. <code class="language-plaintext highlighter-rouge">@KafkaListener</code> concurrency가 2 이상이면?</h3>

<p>전체 처리량이 늘어난다. <code class="language-plaintext highlighter-rouge">초당 1건</code> 요구가 전체 기준인지, 인스턴스/스레드 기준인지 먼저 정의해야 한다.</p>

<h3 id="q-permit-미획득-시-sleep으로-기다리면-안-되나">Q. permit 미획득 시 sleep으로 기다리면 안 되나?</h3>

<p>가능은 하지만, listener 스레드를 오래 묶는 방식은 운영에서 병목 원인이 되기 쉽다. 그래서 나는 <code class="language-plaintext highlighter-rouge">timeoutDuration: 0</code>으로 즉시 실패시키고, 재시도/백오프 경로에서 제어하는 쪽을 택했다.</p>

<h3 id="q-lag가-계속-오르면-limit을-올려야-하나">Q. lag가 계속 오르면 limit을 올려야 하나?</h3>

<p>먼저 처리 로직 지연(외부 API, DB, 락 경합)부터 확인했다. 무작정 limit만 올리면 다운스트림 장애를 다시 키울 수 있다.</p>

<hr />

<p>정리하면 이 케이스의 핵심은 이것 하나였다.</p>

<p><strong>“consumer 속도를 제어해서 시스템 전체를 안정적으로 운영한다.”</strong></p>

<p>RateLimiter는 단순한 성능 옵션이 아니라, 메시지 처리 안정성을 지키는 운영 장치에 가깝다.</p>

<h2 id="references">References</h2>

<ul>
  <li>Resilience4j RateLimiter Guide: https://resilience4j.readme.io/docs/ratelimiter</li>
  <li>Resilience4j Spring Boot 3 Guide: https://resilience4j.readme.io/docs/getting-started-3</li>
  <li>Spring for Apache Kafka Reference: https://docs.spring.io/spring-kafka/reference/</li>
  <li>Spring Kafka Message Listener Containers: https://docs.spring.io/spring-kafka/reference/kafka/receiving-messages/message-listener-container.html</li>
</ul>]]></content><author><name></name></author><category term="backend" /><category term="kafka" /><category term="spring-kafka" /><category term="resilience4j" /><category term="rate-limiter" /><category term="ops" /><summary type="html"><![CDATA[이번엔 딱 내가 실제로 썼던 케이스만 정리해본다.]]></summary></entry><entry><title type="html">Resilience4j Circuit Breaker, 운영에서 이렇게 적용했다</title><link href="https://0x22ff.github.io/backend/2026/03/15/resilience4j-circuit-breaker-ops-guide/" rel="alternate" type="text/html" title="Resilience4j Circuit Breaker, 운영에서 이렇게 적용했다" /><published>2026-03-15T01:40:00+09:00</published><updated>2026-03-15T01:40:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/03/15/resilience4j-circuit-breaker-ops-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/03/15/resilience4j-circuit-breaker-ops-guide/"><![CDATA[<p>이번엔 Circuit Breaker를 이론 말고, 실제 적용한 방식 기준으로 정리해본다.</p>

<p>상황은 단순했다.</p>

<ul>
  <li>외부 API가 간헐적으로 느려지거나 timeout</li>
  <li>우리 API 스레드가 기다리다가 묶임</li>
  <li>지연이 누적되면서 5xx가 같이 증가</li>
</ul>

<p>그래서 <code class="language-plaintext highlighter-rouge">Resilience4j</code>를 넣었고, 운영에서 급할 때는 Circuit을 강제로 열고/닫을 수 있게<br />
<code class="language-plaintext highlighter-rouge">Spring Cloud</code> 기반 제어를 같이 붙였다.</p>

<h2 id="1-왜-circuit-breaker가-필요했는지">1) 왜 Circuit Breaker가 필요했는지</h2>

<p><img src="/assets/images/circuit-breaker-failure-cascade.svg" alt="Failure cascade without circuit breaker" /></p>

<p>Circuit 없을 때는 장애가 아래처럼 번졌다.</p>

<ol>
  <li>다운스트림 timeout 증가</li>
  <li>업스트림 대기 스레드 누적</li>
  <li>전체 응답 지연 + 에러율 상승</li>
</ol>

<p>핵심은 “느린 외부 의존성 하나”가 전체 서비스 품질을 같이 끌어내린다는 점이었다.</p>

<h2 id="2-적용-스택">2) 적용 스택</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">spring-cloud-starter-circuitbreaker-resilience4j</code></li>
  <li><code class="language-plaintext highlighter-rouge">spring-boot-starter-actuator</code></li>
  <li><code class="language-plaintext highlighter-rouge">spring-cloud-starter-config</code> (운영 제어용)</li>
</ul>

<p>이 조합으로,</p>

<ul>
  <li>코드 호출은 Spring Cloud CircuitBreaker API로 감싸고</li>
  <li>실제 상태 관리는 Resilience4j가 담당하고</li>
  <li>강제 OPEN/CLOSE는 Spring Cloud Config 값으로 제어했다.</li>
</ul>

<p>여기서 많이 헷갈리는 포인트가 하나 있다.</p>

<ul>
  <li>Spring Cloud는 Circuit/Config를 위한 <strong>추상화 + 연동 레이어</strong></li>
  <li>운영 화면(UI)은 기본 제공이 아니라 보통 Grafana/Spring Boot Admin/사내 도구로 구성</li>
</ul>

<p>즉 Spring Cloud를 붙였다고 Circuit 전용 웹 콘솔이 자동으로 생기진 않는다.</p>

<h2 id="3-기본-설정값-실무에서-많이-쓰는-시작점">3) 기본 설정값 (실무에서 많이 쓰는 시작점)</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resilience4j</span><span class="pi">:</span>
  <span class="na">circuitbreaker</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">orderApi</span><span class="pi">:</span>
        <span class="na">registerHealthIndicator</span><span class="pi">:</span> <span class="no">true</span>
        <span class="na">slidingWindowType</span><span class="pi">:</span> <span class="s">COUNT_BASED</span>
        <span class="na">slidingWindowSize</span><span class="pi">:</span> <span class="m">50</span>
        <span class="na">minimumNumberOfCalls</span><span class="pi">:</span> <span class="m">20</span>
        <span class="na">failureRateThreshold</span><span class="pi">:</span> <span class="m">50</span>
        <span class="na">slowCallRateThreshold</span><span class="pi">:</span> <span class="m">60</span>
        <span class="na">slowCallDurationThreshold</span><span class="pi">:</span> <span class="s">2s</span>
        <span class="na">waitDurationInOpenState</span><span class="pi">:</span> <span class="s">30s</span>
        <span class="na">permittedNumberOfCallsInHalfOpenState</span><span class="pi">:</span> <span class="m">10</span>
        <span class="na">automaticTransitionFromOpenToHalfOpenEnabled</span><span class="pi">:</span> <span class="no">true</span>

<span class="na">management</span><span class="pi">:</span>
  <span class="na">endpoints</span><span class="pi">:</span>
    <span class="na">web</span><span class="pi">:</span>
      <span class="na">exposure</span><span class="pi">:</span>
        <span class="na">include</span><span class="pi">:</span> <span class="s">health,info,prometheus,refresh,busrefresh</span>
</code></pre></div></div>

<p><img src="/assets/images/resilience4j-config-map.svg" alt="Resilience4j config mapping" /></p>

<p>이 값은 정답이 아니라 시작점이다.<br />
중요한 건 운영 지표 보면서 계속 튜닝하는 거다.</p>

<h2 id="4-실제-호출-코드-예시">4) 실제 호출 코드 예시</h2>

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

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">CircuitBreakerFactory</span><span class="o">&lt;?,</span> <span class="o">?&gt;</span> <span class="n">circuitBreakerFactory</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">RestClient</span> <span class="n">orderClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nc">OrderResponse</span> <span class="nf">getOrder</span><span class="o">(</span><span class="nc">String</span> <span class="n">orderId</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">CircuitBreaker</span> <span class="n">cb</span> <span class="o">=</span> <span class="n">circuitBreakerFactory</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="s">"orderApi"</span><span class="o">);</span>

        <span class="k">return</span> <span class="n">cb</span><span class="o">.</span><span class="na">run</span><span class="o">(</span>
                <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">orderClient</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
                        <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/external/orders/{id}"</span><span class="o">,</span> <span class="n">orderId</span><span class="o">)</span>
                        <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
                        <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">OrderResponse</span><span class="o">.</span><span class="na">class</span><span class="o">),</span>
                <span class="n">throwable</span> <span class="o">-&gt;</span> <span class="nc">OrderResponse</span><span class="o">.</span><span class="na">fallback</span><span class="o">(</span><span class="n">orderId</span><span class="o">)</span>
        <span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 포인트는 fallback을 “무조건 성공처럼” 만들지 않는 거다.<br />
프론트/클라이언트가 fallback 응답임을 구분할 수 있게 설계해야 운영에서 덜 꼬인다.</p>

<h3 id="fallbackmethod-방식도-같이-쓴다"><code class="language-plaintext highlighter-rouge">fallbackMethod</code> 방식도 같이 쓴다</h3>

<p>팀/코드베이스에 따라 annotation 방식이 더 읽기 쉬울 때가 있다.</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">OrderQueryService</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ExternalOrderClient</span> <span class="n">externalOrderClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">OrderQueryService</span><span class="o">(</span><span class="nc">ExternalOrderClient</span> <span class="n">externalOrderClient</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">externalOrderClient</span> <span class="o">=</span> <span class="n">externalOrderClient</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@io</span><span class="o">.</span><span class="na">github</span><span class="o">.</span><span class="na">resilience4j</span><span class="o">.</span><span class="na">circuitbreaker</span><span class="o">.</span><span class="na">annotation</span><span class="o">.</span><span class="na">CircuitBreaker</span><span class="o">(</span>
            <span class="n">name</span> <span class="o">=</span> <span class="s">"orderApi"</span><span class="o">,</span>
            <span class="n">fallbackMethod</span> <span class="o">=</span> <span class="s">"getOrderFallback"</span>
    <span class="o">)</span>
    <span class="kd">public</span> <span class="nc">OrderResponse</span> <span class="nf">getOrder</span><span class="o">(</span><span class="nc">String</span> <span class="n">orderId</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">externalOrderClient</span><span class="o">.</span><span class="na">getOrder</span><span class="o">(</span><span class="n">orderId</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">OrderResponse</span> <span class="nf">getOrderFallback</span><span class="o">(</span><span class="nc">String</span> <span class="n">orderId</span><span class="o">,</span> <span class="nc">Throwable</span> <span class="n">t</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">OrderResponse</span><span class="o">.</span><span class="na">fallback</span><span class="o">(</span><span class="n">orderId</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>정리하면 둘 다 가능하다.</p>

<ul>
  <li>Spring Cloud CircuitBreaker <code class="language-plaintext highlighter-rouge">cb.run(..., fallback)</code> 함수형 방식</li>
  <li>Resilience4j annotation + <code class="language-plaintext highlighter-rouge">fallbackMethod</code> 방식</li>
</ul>

<h2 id="5-circuit-상태-이해-운영-기준">5) Circuit 상태 이해 (운영 기준)</h2>

<p><img src="/assets/images/resilience4j-circuit-states.svg" alt="Resilience4j circuit states" /></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CLOSED</code>: 정상 호출 + 통계 수집</li>
  <li><code class="language-plaintext highlighter-rouge">OPEN</code>: fail-fast, 다운스트림 호출 차단</li>
  <li><code class="language-plaintext highlighter-rouge">HALF_OPEN</code>: 제한된 probe 호출로 복구 여부 확인</li>
</ul>

<p>그리고 운영 제어용으로 <code class="language-plaintext highlighter-rouge">FORCED_OPEN</code>, <code class="language-plaintext highlighter-rouge">FORCED_CLOSED</code>를 사용했다.</p>

<h2 id="6-강제-openclose-제어를-spring-cloud로-붙인-방식">6) 강제 OPEN/CLOSE 제어를 Spring Cloud로 붙인 방식</h2>

<p>요구사항이 이거였다.</p>

<ul>
  <li>장애 대응 중 운영자가 circuit을 강제로 열거나 닫을 수 있어야 함</li>
  <li>재배포 없이 반영돼야 함</li>
</ul>

<p>그래서 아래 흐름으로 구성했다.</p>

<p><img src="/assets/images/spring-cloud-circuit-force-control.svg" alt="Spring cloud forced control flow" /></p>

<ol>
  <li>Spring Cloud Config 저장소에 모드 값 관리</li>
  <li>운영자가 값 변경 (<code class="language-plaintext highlighter-rouge">AUTO</code>, <code class="language-plaintext highlighter-rouge">FORCED_OPEN</code>, <code class="language-plaintext highlighter-rouge">FORCED_CLOSED</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">/actuator/refresh</code> (단일 인스턴스) 또는 <code class="language-plaintext highlighter-rouge">/actuator/busrefresh</code> (다중 인스턴스) 호출</li>
  <li>앱이 갱신된 모드를 읽고 Circuit 상태 전환</li>
</ol>

<p>여기서 <code class="language-plaintext highlighter-rouge">/actuator</code>는 Prometheus 전용 경로가 아니다.<br />
Actuator 관리 엔드포인트 공통 베이스 경로이고, <code class="language-plaintext highlighter-rouge">prometheus</code>, <code class="language-plaintext highlighter-rouge">health</code>, <code class="language-plaintext highlighter-rouge">refresh</code> 등이 그 아래에 붙는다.</p>

<p>예시:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># single instance refresh</span>
curl <span class="nt">-X</span> POST http://127.0.0.1:18081/actuator/refresh
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># multi instance refresh (Spring Cloud Bus)</span>
curl <span class="nt">-X</span> POST http://127.0.0.1:18081/actuator/busrefresh
</code></pre></div></div>

<p>운영에서는 이 엔드포인트를 외부 공개하지 않고, 내부망 + 인증으로 제한하는 걸 전제로 사용했다.</p>

<h3 id="운영-모드-프로퍼티">운영 모드 프로퍼티</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">ops</span><span class="pi">:</span>
  <span class="na">circuit</span><span class="pi">:</span>
    <span class="na">order-api</span><span class="pi">:</span>
      <span class="na">mode</span><span class="pi">:</span> <span class="s">AUTO</span> <span class="c1"># AUTO | FORCED_OPEN | FORCED_CLOSED</span>
</code></pre></div></div>

<h3 id="refreshscope--상태-동기화-코드">RefreshScope + 상태 동기화 코드</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Getter</span>
<span class="nd">@Setter</span>
<span class="nd">@RefreshScope</span>
<span class="nd">@Component</span>
<span class="nd">@ConfigurationProperties</span><span class="o">(</span><span class="n">prefix</span> <span class="o">=</span> <span class="s">"ops.circuit.order-api"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">CircuitControlProperties</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="nc">Mode</span> <span class="n">mode</span> <span class="o">=</span> <span class="nc">Mode</span><span class="o">.</span><span class="na">AUTO</span><span class="o">;</span>

    <span class="kd">public</span> <span class="kd">enum</span> <span class="nc">Mode</span> <span class="o">{</span>
        <span class="no">AUTO</span><span class="o">,</span>
        <span class="no">FORCED_OPEN</span><span class="o">,</span>
        <span class="no">FORCED_CLOSED</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

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

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">CircuitBreakerRegistry</span> <span class="n">registry</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">CircuitControlProperties</span> <span class="n">properties</span><span class="o">;</span>

    <span class="nd">@Scheduled</span><span class="o">(</span><span class="n">fixedDelayString</span> <span class="o">=</span> <span class="s">"${ops.circuit.sync-delay-ms:3000}"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">syncState</span><span class="o">()</span> <span class="o">{</span>
        <span class="n">io</span><span class="o">.</span><span class="na">github</span><span class="o">.</span><span class="na">resilience4j</span><span class="o">.</span><span class="na">circuitbreaker</span><span class="o">.</span><span class="na">CircuitBreaker</span> <span class="n">cb</span> <span class="o">=</span>
                <span class="n">registry</span><span class="o">.</span><span class="na">circuitBreaker</span><span class="o">(</span><span class="s">"orderApi"</span><span class="o">);</span>

        <span class="k">switch</span> <span class="o">(</span><span class="n">properties</span><span class="o">.</span><span class="na">getMode</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">case</span> <span class="no">FORCED_OPEN</span> <span class="o">-&gt;</span> <span class="n">cb</span><span class="o">.</span><span class="na">transitionToForcedOpenState</span><span class="o">();</span>
            <span class="k">case</span> <span class="no">FORCED_CLOSED</span> <span class="o">-&gt;</span> <span class="n">cb</span><span class="o">.</span><span class="na">transitionToClosedState</span><span class="o">();</span>
            <span class="k">case</span> <span class="no">AUTO</span> <span class="o">-&gt;</span> <span class="n">cb</span><span class="o">.</span><span class="na">reset</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">FORCED_CLOSED</code>는 보호막을 우회하는 동작이라 진짜 짧게만 써야 한다.<br />
장애 대응에선 보통 <code class="language-plaintext highlighter-rouge">FORCED_OPEN</code>이 더 자주 필요했다.</p>

<h2 id="7-운영에서-실제로-본-지표">7) 운영에서 실제로 본 지표</h2>

<ul>
  <li>circuit state 전환 빈도</li>
  <li>fallback 비율</li>
  <li>실패/느린 호출 비율</li>
</ul>

<p><img src="/assets/images/resilience4j-metrics-dashboard.svg" alt="Circuit breaker metrics dashboard" /></p>

<p>이 지표를 안 보면 “설정 넣었으니 끝”이 되는데,<br />
실제로는 상태 전환 패턴을 보고 threshold를 계속 조정해야 한다.</p>

<h2 id="8-롤아웃할-때-체크한-순서">8) 롤아웃할 때 체크한 순서</h2>

<p><img src="/assets/images/circuit-breaker-rollout-plan.svg" alt="Circuit breaker rollout plan" /></p>

<ol>
  <li>관측부터 붙이고(상태/실패/느린 호출)</li>
  <li>fallback 응답 품질 검증하고</li>
  <li>온콜 runbook 정리하고</li>
  <li>강제 제어 모드(OPEN/CLOSED)까지 운영 절차에 포함</li>
</ol>

<hr />

<p>정리하면 Circuit Breaker는 라이브러리 하나 추가하는 작업이 아니라,</p>

<ul>
  <li>장애 전파를 끊는 런타임 장치</li>
  <li>운영자가 제어할 수 있는 대응 장치</li>
</ul>

<p>이 두 개를 같이 만드는 작업이었다.</p>

<p>특히 <code class="language-plaintext highlighter-rouge">Spring Cloud Config + refresh</code>로 강제 상태 제어를 붙여두면,<br />
긴급 대응 속도가 확실히 빨라진다.</p>

<h2 id="references">References</h2>

<ul>
  <li>Spring Cloud Circuit Breaker Reference: https://docs.spring.io/spring-cloud-circuitbreaker/reference/</li>
  <li>Resilience4j CircuitBreaker Guide: https://resilience4j.readme.io/docs/circuitbreaker</li>
  <li>Spring Cloud Config Reference: https://docs.spring.io/spring-cloud-config/docs/current/reference/html/</li>
  <li>Spring Cloud Commons Refresh Scope: https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/application-context-services.html#refresh-scope</li>
  <li>Spring Boot Actuator Endpoints: https://docs.spring.io/spring-boot/reference/actuator/endpoints.html</li>
</ul>]]></content><author><name></name></author><category term="backend" /><category term="spring" /><category term="spring-cloud" /><category term="resilience4j" /><category term="circuit-breaker" /><category term="sre" /><summary type="html"><![CDATA[이번엔 Circuit Breaker를 이론 말고, 실제 적용한 방식 기준으로 정리해본다.]]></summary></entry><entry><title type="html">SLO/SLI/Error Budget, 운영에서 진짜로 쓰는 방식 정리</title><link href="https://0x22ff.github.io/backend/2026/03/15/slo-sli-error-budget-practical-guide/" rel="alternate" type="text/html" title="SLO/SLI/Error Budget, 운영에서 진짜로 쓰는 방식 정리" /><published>2026-03-15T00:10:00+09:00</published><updated>2026-03-15T00:10:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/03/15/slo-sli-error-budget-practical-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/03/15/slo-sli-error-budget-practical-guide/"><![CDATA[<p>모니터링 도구는 다 붙였는데, 막상 장애가 나면 이런 상황이 자주 나온다.</p>

<ul>
  <li>알람은 많이 오는데 뭐가 진짜 급한지 모르겠고</li>
  <li>팀마다 “이 정도면 괜찮다” 기준이 다르고</li>
  <li>배포 멈출지 계속 갈지 결정이 감으로 흘러간다</li>
</ul>

<p>여기서 기준선을 잡아주는 게 <code class="language-plaintext highlighter-rouge">SLO/SLI/Error Budget</code>이다.</p>

<p>이번 글은 이론 설명보다, <strong>운영에서 바로 적용 가능한 형태</strong>로 정리했다.</p>

<h2 id="1-용어부터-딱-맞춰두기">1) 용어부터 딱 맞춰두기</h2>

<p><img src="/assets/images/slo-sli-budget-overview.svg" alt="SLO SLI Budget overview" /></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">SLI</code> (Service Level Indicator): 측정값
    <ul>
      <li>예: 요청 성공 비율, p95 지연 시간</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">SLO</code> (Service Level Objective): 목표값
    <ul>
      <li>예: 30일 기준 가용성 99.9%</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">Error Budget</code>: 목표를 기준으로 허용 가능한 실패량
    <ul>
      <li>수식: <code class="language-plaintext highlighter-rouge">1 - SLO</code></li>
    </ul>
  </li>
</ul>

<p>핵심은 단순하다.<br />
<strong>측정(SLI) -&gt; 목표(SLO) -&gt; 허용 실패량(Error Budget)</strong> 순서다.</p>

<h2 id="2-숫자로-보면-훨씬-명확해진다">2) 숫자로 보면 훨씬 명확해진다</h2>

<p>예시(계산 예시):</p>

<ul>
  <li>SLO: 30일 가용성 99.9%</li>
  <li>허용 에러율: <code class="language-plaintext highlighter-rouge">1 - 0.999 = 0.001 (0.1%)</code></li>
  <li>30일 총 분: <code class="language-plaintext highlighter-rouge">30 * 24 * 60 = 43,200분</code></li>
  <li>허용 장애 시간(완전 장애 기준): <code class="language-plaintext highlighter-rouge">43,200 * 0.001 = 43.2분</code></li>
</ul>

<p><img src="/assets/images/slo-budget-math-example.svg" alt="Error budget math example" /></p>

<p>즉 30일 동안 완전 다운 시간이 약 43.2분을 넘기면 이 SLO는 깨진다.</p>

<h2 id="3-sli는-식을-먼저-확정해야-한다">3) SLI는 식을 먼저 확정해야 한다</h2>

<p>SLI가 애매하면 SLO도 애매해진다.<br />
그래서 분자/분모를 코드 리뷰 가능한 수준으로 명확히 정의하는 게 중요하다.</p>

<p>예시(요청 기반 가용성 SLI):</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SLI = successful_requests / total_requests
</code></pre></div></div>

<p>Spring + Prometheus 환경에서 자주 쓰는 형태는 아래다.</p>

<pre><code class="language-promql">sum(rate(http_server_requests_seconds_count{status!~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count[5m]))
</code></pre>

<p>주의할 점:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">4xx</code>를 실패로 볼지 말지는 서비스 정책에 따라 다름</li>
  <li>중요한 건 팀 내에서 정의를 고정하고 계속 유지하는 것</li>
</ul>

<h2 id="4-burn-rate를-붙이면-알람-품질이-달라진다">4) Burn Rate를 붙이면 알람 품질이 달라진다</h2>

<p>Error Budget을 “얼마나 남았는지”만 보면 늦을 때가 있다.<br />
그래서 운영에서는 “얼마나 빨리 타고 있는지”를 같이 본다.</p>

<p><code class="language-plaintext highlighter-rouge">burn_rate</code> 기본식:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>burn_rate = current_error_rate / allowed_error_rate
</code></pre></div></div>

<p>예시:</p>

<ul>
  <li>허용 에러율 0.1% (<code class="language-plaintext highlighter-rouge">0.001</code>)</li>
  <li>현재 에러율 1% (<code class="language-plaintext highlighter-rouge">0.01</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">burn_rate = 0.01 / 0.001 = 10</code></li>
</ul>

<p><img src="/assets/images/slo-burn-rate-concept.svg" alt="Burn rate concept" /></p>

<p><code class="language-plaintext highlighter-rouge">burn_rate &gt; 1</code>이면 예산을 계획보다 빠르게 소모 중이라는 뜻이다.</p>

<h2 id="5-알람은-멀티-윈도우로-가는-게-안정적이다">5) 알람은 멀티 윈도우로 가는 게 안정적이다</h2>

<p>짧은 윈도우만 보면 스파이크 노이즈에 취약하고,<br />
긴 윈도우만 보면 감지가 늦다.</p>

<p>그래서 실무에선 짧은/긴 윈도우를 같이 본다.</p>

<p><img src="/assets/images/slo-alerting-windows.svg" alt="Multi-window alerting" /></p>

<p>예시(개념 예시):</p>

<ul>
  <li>short window: 5분 burn rate</li>
  <li>long window: 1시간 burn rate</li>
  <li>두 조건이 같이 나쁠 때 페이지 알람 발송</li>
</ul>

<p>PromQL 예시(99.9% SLO, 허용 에러율 0.001 가정):</p>

<pre><code class="language-promql">(
  sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
  /
  sum(rate(http_server_requests_seconds_count[5m]))
) / 0.001
</code></pre>

<p>운영에서는 보통 이 값을 recording rule로 저장하고 alert rule에서 재사용한다.</p>

<h2 id="6-grafana-대시보드는-이-3개가-핵심이다">6) Grafana 대시보드는 이 3개가 핵심이다</h2>

<ul>
  <li>현재 SLI 추세</li>
  <li>Burn rate(short/long)</li>
  <li>Error budget remaining</li>
</ul>

<p><img src="/assets/images/slo-dashboard-layout.svg" alt="SLO dashboard layout" /></p>

<p>처음부터 패널을 많이 만들기보다,<br />
온콜 상황에서 바로 의사결정 가능한 화면부터 만드는 게 낫다.</p>

<h2 id="7-배포운영-의사결정에-연결해야-의미가-있다">7) 배포/운영 의사결정에 연결해야 의미가 있다</h2>

<p>SLO를 만든 뒤 운영에 안 붙이면 그냥 숫자 대시보드로 끝난다.</p>

<p>실무에서는 보통 이렇게 쓴다.</p>

<ul>
  <li>budget이 빠르게 줄면 신규 변경 배포를 보수적으로 진행</li>
  <li>경보가 반복되면 원인 제거 작업(성능/안정성)을 우선순위로 올림</li>
  <li>월 단위 리뷰에서 SLO 타당성(너무 느슨/빡빡)을 다시 조정</li>
</ul>

<p><img src="/assets/images/slo-adoption-roadmap.svg" alt="SLO adoption roadmap" /></p>

<h2 id="8-시작-체크리스트">8) 시작 체크리스트</h2>

<ol>
  <li>사용자 영향이 큰 API 하나만 먼저 고른다</li>
  <li>SLI 식(분자/분모)을 문서로 고정한다</li>
  <li>SLO 목표값/기간을 합의한다</li>
  <li>Burn-rate 알람(짧은+긴 윈도우)을 붙인다</li>
  <li>월 1회 SLO 리뷰를 정례화한다</li>
</ol>

<hr />

<p>결국 SLO/SLI/Error Budget은 “모니터링 고급 기능”이 아니라,<br />
<strong>장애 대응과 배포 결정을 같은 기준으로 맞추는 운영 언어</strong>에 가깝다.</p>

<p>이 기준만 맞아도,</p>

<ul>
  <li>알람 노이즈가 줄고</li>
  <li>우선순위가 분명해지고</li>
  <li>팀 의사결정이 빨라진다.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li>Google SRE Book - Service Level Objectives: https://sre.google/sre-book/service-level-objectives/</li>
  <li>Google SRE Workbook - Alerting on SLOs: https://sre.google/workbook/alerting-on-slos/</li>
  <li>Prometheus docs - Histograms and summaries: https://prometheus.io/docs/practices/histograms/</li>
  <li>Prometheus docs - Alerting rules: https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/</li>
  <li>Grafana docs - Alerting: https://grafana.com/docs/grafana/latest/alerting/</li>
</ul>]]></content><author><name></name></author><category term="backend" /><category term="sre" /><category term="slo" /><category term="sli" /><category term="error-budget" /><category term="prometheus" /><category term="grafana" /><summary type="html"><![CDATA[모니터링 도구는 다 붙였는데, 막상 장애가 나면 이런 상황이 자주 나온다.]]></summary></entry><entry><title type="html">Spring + Java21에서 Grafana + Prometheus 운영 모니터링 가이드</title><link href="https://0x22ff.github.io/backend/2026/03/14/spring-grafana-prometheus-monitoring-guide/" rel="alternate" type="text/html" title="Spring + Java21에서 Grafana + Prometheus 운영 모니터링 가이드" /><published>2026-03-14T09:40:00+09:00</published><updated>2026-03-14T09:40:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/03/14/spring-grafana-prometheus-monitoring-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/03/14/spring-grafana-prometheus-monitoring-guide/"><![CDATA[<p>운영하다 보면 결국 이 질문으로 돌아오더라.</p>

<ul>
  <li>지금 서비스 정상 맞나?</li>
  <li>느려지면 어디부터 봐야 하나?</li>
  <li>알람은 빨리 오고, 원인 파악은 가능한가?</li>
</ul>

<p>이번 글은 Spring Boot(Java 21) 기준으로,<br />
<strong>Prometheus + Grafana를 실제 운영에서 쓰는 기본 뼈대</strong>를 정리한 글이다.</p>

<p>핵심은 4가지다.</p>

<ul>
  <li>메트릭 노출(Actuator + Micrometer)</li>
  <li><code class="language-plaintext highlighter-rouge">/actuator</code> 운영 보안</li>
  <li>Prometheus 수집 설정</li>
  <li>Grafana 대시보드/알람 설계</li>
</ul>

<h2 id="1-전체-흐름-먼저-한-장으로-보기">1) 전체 흐름 먼저 한 장으로 보기</h2>

<p><img src="/assets/images/grafana-prometheus-overview.svg" alt="Monitoring architecture overview" /></p>

<p>구조 자체는 어렵지 않다.</p>

<ol>
  <li>Spring Boot 앱이 <code class="language-plaintext highlighter-rouge">/actuator/prometheus</code>로 메트릭 노출</li>
  <li>Prometheus가 주기적으로 scrape</li>
  <li>Grafana가 Prometheus를 조회해서 시각화</li>
  <li>Grafana Alerting으로 경보 발행</li>
</ol>

<p>여기서 포인트는 도구를 많이 붙이는 게 아니라,<br />
<strong>초기 지표를 작게 시작하고 정확하게 보는 것</strong>이다.</p>

<h2 id="2-spring-bootjava-21에서-메트릭-노출">2) Spring Boot(Java 21)에서 메트릭 노출</h2>

<h3 id="2-1-의존성">2-1. 의존성</h3>

<div class="language-gradle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">dependencies</span> <span class="o">{</span>
    <span class="n">implementation</span><span class="o">(</span><span class="s2">"org.springframework.boot:spring-boot-starter-actuator"</span><span class="o">)</span>
    <span class="n">runtimeOnly</span><span class="o">(</span><span class="s2">"io.micrometer:micrometer-registry-prometheus"</span><span class="o">)</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="2-2-endpoint-설정">2-2. endpoint 설정</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">management</span><span class="pi">:</span>
  <span class="na">server</span><span class="pi">:</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">18081</span>
  <span class="na">endpoints</span><span class="pi">:</span>
    <span class="na">web</span><span class="pi">:</span>
      <span class="na">exposure</span><span class="pi">:</span>
        <span class="na">include</span><span class="pi">:</span> <span class="s">health,info,prometheus</span>
  <span class="na">endpoint</span><span class="pi">:</span>
    <span class="na">health</span><span class="pi">:</span>
      <span class="na">probes</span><span class="pi">:</span>
        <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">management.server.port</code> 분리는 나중에 보안 정책 잡을 때 진짜 편하다.</p>

<h3 id="2-3-커스텀-메트릭-예시">2-3. 커스텀 메트릭 예시</h3>

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

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Counter</span> <span class="n">sermonCreateCounter</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Timer</span> <span class="n">sermonPublishTimer</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">SermonMetrics</span><span class="o">(</span><span class="nc">MeterRegistry</span> <span class="n">registry</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">sermonCreateCounter</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">"cms_sermon_create_total"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">description</span><span class="o">(</span><span class="s">"Total created sermons"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">registry</span><span class="o">);</span>

        <span class="k">this</span><span class="o">.</span><span class="na">sermonPublishTimer</span> <span class="o">=</span> <span class="nc">Timer</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="s">"cms_sermon_publish_seconds"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">description</span><span class="o">(</span><span class="s">"Sermon publish latency"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">publishPercentiles</span><span class="o">(</span><span class="mf">0.5</span><span class="o">,</span> <span class="mf">0.95</span><span class="o">,</span> <span class="mf">0.99</span><span class="o">)</span>
                <span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">registry</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">incrementCreate</span><span class="o">()</span> <span class="o">{</span>
        <span class="n">sermonCreateCounter</span><span class="o">.</span><span class="na">increment</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="o">&lt;</span><span class="no">T</span><span class="o">&gt;</span> <span class="no">T</span> <span class="nf">recordPublish</span><span class="o">(</span><span class="nc">Supplier</span><span class="o">&lt;</span><span class="no">T</span><span class="o">&gt;</span> <span class="n">supplier</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">sermonPublishTimer</span><span class="o">.</span><span class="na">record</span><span class="o">(</span><span class="n">supplier</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><img src="/assets/images/spring-boot-micrometer-flow.svg" alt="Micrometer flow" /></p>

<p>실무에서 제일 많이 터지는 건 태그(cardinality)다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">userId</code>, <code class="language-plaintext highlighter-rouge">requestId</code>, raw URL 같이 값이 계속 늘어나는 태그는 피하기</li>
  <li><code class="language-plaintext highlighter-rouge">method</code>, <code class="language-plaintext highlighter-rouge">status</code>, <code class="language-plaintext highlighter-rouge">uri</code>처럼 범위가 제한된 태그 위주로 쓰기</li>
</ul>

<p>카디널리티가 커지면 Prometheus 메모리/쿼리 비용이 생각보다 빨리 올라간다.</p>

<h2 id="3-운영-보안-actuator는-내부망-전용">3) 운영 보안: <code class="language-plaintext highlighter-rouge">/actuator</code>는 내부망 전용</h2>

<p>이건 거의 원칙에 가깝다.<br />
운영에서 <code class="language-plaintext highlighter-rouge">/actuator</code>를 퍼블릭으로 열어두면 안 된다.</p>

<p>권장 패턴은 이렇다.</p>

<ul>
  <li>앱 포트와 관리 포트 분리</li>
  <li>Security Group에서 Prometheus 서버(또는 내부 SG)만 허용</li>
  <li>외부 인터넷에서 management 포트 차단</li>
  <li>endpoint 최소 공개(<code class="language-plaintext highlighter-rouge">prometheus</code>, <code class="language-plaintext highlighter-rouge">health</code>, <code class="language-plaintext highlighter-rouge">info</code>)</li>
</ul>

<p><img src="/assets/images/actuator-private-access.svg" alt="Actuator private access" /></p>

<p>앱 코드에서 IP 필터만 거는 방식보다,<br />
<strong>네트워크 레벨에서 먼저 막는 구조</strong>가 훨씬 안전하다.</p>

<h2 id="4-prometheus-수집-설정">4) Prometheus 수집 설정</h2>

<p>처음 시작할 때는 아래 정도면 충분하다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">global</span><span class="pi">:</span>
  <span class="na">scrape_interval</span><span class="pi">:</span> <span class="s">15s</span>

<span class="na">scrape_configs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">bssj-web-backend"</span>
    <span class="na">metrics_path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/actuator/prometheus"</span>
    <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">host.docker.internal:18081"</span><span class="pi">]</span>
</code></pre></div></div>

<p><img src="/assets/images/prometheus-scrape-pipeline.svg" alt="Prometheus scrape pipeline" /></p>

<p>주의할 점:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">host.docker.internal</code>은 로컬/도커 개발용 예시</li>
  <li>EC2/ECS/K8s 운영에서는 내부 DNS 또는 서비스 디스커버리 경로 사용</li>
</ul>

<p>운영에서는 보통 이렇게 시작한다.</p>

<ul>
  <li>scrape interval: <code class="language-plaintext highlighter-rouge">15s</code> 또는 <code class="language-plaintext highlighter-rouge">30s</code></li>
  <li>retention/디스크 사용량 같이 모니터링</li>
  <li>반복적으로 무거운 쿼리는 recording rule 고려</li>
</ul>

<h2 id="5-grafana-대시보드-처음부터-크게-만들-필요-없다">5) Grafana 대시보드: 처음부터 크게 만들 필요 없다</h2>

<p>초기에는 아래 4축만 제대로 봐도 충분하다.</p>

<ul>
  <li>Request Rate (RPS)</li>
  <li>Error Rate (5xx 비율)</li>
  <li>Latency p95/p99</li>
  <li>JVM/CPU/메모리 + DB pool</li>
</ul>

<p><img src="/assets/images/grafana-dashboard-sample.svg" alt="Grafana dashboard mock" /></p>

<p>자주 쓰는 PromQL 예시:</p>

<pre><code class="language-promql">sum(rate(http_server_requests_seconds_count{uri!="/actuator/prometheus"}[5m]))
</code></pre>

<pre><code class="language-promql">histogram_quantile(
  0.95,
  sum(rate(http_server_requests_seconds_bucket[5m])) by (le)
)
</code></pre>

<pre><code class="language-promql">sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count[5m]))
</code></pre>

<p>패널 추가 기준은 단순하다.<br />
“장애 났을 때 원인 좁히는 데 실제로 도움 되냐” 이것만 보면 된다.</p>

<h2 id="6-알람-설계-임계치보다-노이즈-관리가-더-중요">6) 알람 설계: 임계치보다 노이즈 관리가 더 중요</h2>

<p>예시 룰(지연 p95 700ms 초과가 10분 지속):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">groups</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">latency-alerts</span>
    <span class="na">rules</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">alert</span><span class="pi">:</span> <span class="s">ApiLatencyP95High</span>
        <span class="na">expr</span><span class="pi">:</span> <span class="s">histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) &gt; </span><span class="m">0.7</span>
        <span class="na">for</span><span class="pi">:</span> <span class="s">10m</span>
        <span class="na">labels</span><span class="pi">:</span>
          <span class="na">severity</span><span class="pi">:</span> <span class="s">warning</span>
          <span class="na">service</span><span class="pi">:</span> <span class="s">bssj-web-backend</span>
        <span class="na">annotations</span><span class="pi">:</span>
          <span class="na">summary</span><span class="pi">:</span> <span class="s2">"</span><span class="s">API</span><span class="nv"> </span><span class="s">latency</span><span class="nv"> </span><span class="s">p95</span><span class="nv"> </span><span class="s">is</span><span class="nv"> </span><span class="s">above</span><span class="nv"> </span><span class="s">700ms"</span>
          <span class="na">runbook</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://internal.wiki/runbooks/api-latency"</span>
</code></pre></div></div>

<p><img src="/assets/images/grafana-alert-webhook-flow.svg" alt="Alert webhook flow" /></p>

<p>알람 품질 올릴 때는 이것들부터 챙기면 된다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">for</code> 시간으로 순간 스파이크 노이즈 줄이기</li>
  <li><code class="language-plaintext highlighter-rouge">severity</code> 라벨로 채널 분리</li>
  <li>annotations에 <code class="language-plaintext highlighter-rouge">runbook</code> 링크 포함</li>
  <li>경보 후 조치 완료까지 걸리는 시간 추적</li>
</ul>

<h2 id="7-운영-체크리스트">7) 운영 체크리스트</h2>

<p>구축 직후에 바로 확인할 체크리스트:</p>

<ol>
  <li>Prometheus가 scrape를 실제로 성공하는가</li>
  <li>Grafana 수치와 앱 로그의 방향성이 맞는가</li>
  <li>알람 테스트를 수동으로 한 번 발생시켜 봤는가</li>
  <li><code class="language-plaintext highlighter-rouge">/actuator</code>가 외부에서 차단되어 있는가</li>
  <li>runbook 링크가 알람 payload에 들어가는가</li>
</ol>

<p><img src="/assets/images/monitoring-ops-loop.svg" alt="Ops loop" /></p>

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

<p>Prometheus + Grafana의 장점은 거창한 기능보다,<br />
<strong>운영 기준선을 빠르게 세울 수 있다는 점</strong>이다.</p>

<ul>
  <li>어떤 지표를 계속 볼지</li>
  <li>어디서 알람을 울릴지</li>
  <li>알람이 오면 뭘 먼저 볼지</li>
</ul>

<p>이 세 가지만 팀 기준으로 맞춰도, 장애 대응 속도랑 품질이 꽤 안정적으로 올라간다.</p>]]></content><author><name></name></author><category term="backend" /><category term="spring" /><category term="java21" /><category term="prometheus" /><category term="grafana" /><category term="observability" /><summary type="html"><![CDATA[운영하다 보면 결국 이 질문으로 돌아오더라.]]></summary></entry><entry><title type="html">WebFlux에서 BlockHound로 블로킹 잡기: 실무 적용 가이드</title><link href="https://0x22ff.github.io/backend/2026/03/12/webflux-blockhound-guide/" rel="alternate" type="text/html" title="WebFlux에서 BlockHound로 블로킹 잡기: 실무 적용 가이드" /><published>2026-03-12T11:30:00+09:00</published><updated>2026-03-12T11:30:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/03/12/webflux-blockhound-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/03/12/webflux-blockhound-guide/"><![CDATA[<p>WebFlux를 쓰는데도 응답이 갑자기 느려질 때가 있다.<br />
대부분 원인은 비슷하다. <strong>논블로킹 경로 안에 블로킹 코드가 섞여 들어간 경우</strong>다.</p>

<p>BlockHound는 이 지점을 꽤 직설적으로 잡아준다.</p>

<ul>
  <li>지금 이 스레드에서 이 호출은 블로킹이다</li>
  <li>그래서 여기서 실패시킨다</li>
</ul>

<p>이 글은 BlockHound를 왜 쓰는지, 어떻게 붙이는지, 실패가 나면 어떻게 고치는지를<br />
실전 흐름으로 정리한 가이드다.</p>

<h2 id="1-왜-blockhound가-필요한가">1) 왜 BlockHound가 필요한가</h2>

<p><img src="/assets/images/blockhound-why.svg" alt="Why BlockHound" /></p>

<p>WebFlux 장점은 이벤트 루프 스레드를 오래 붙잡지 않는 데 있다.<br />
그런데 <code class="language-plaintext highlighter-rouge">Thread.sleep</code>, 블로킹 JDBC, 파일 I/O가 끼어들면 장점이 바로 사라진다.</p>

<p>겉으로 CPU가 여유 있어 보여도 p95/p99가 튀는 케이스가 여기서 자주 나온다.</p>

<h2 id="2-blockhound가-실제로-하는-일">2) BlockHound가 실제로 하는 일</h2>

<p><img src="/assets/images/blockhound-detection-flow.svg" alt="BlockHound detection flow" /></p>

<p>BlockHound는 런타임에서 블로킹 메서드 호출을 감지한다.<br />
그리고 그 호출이 논블로킹 스레드에서 일어나면 <code class="language-plaintext highlighter-rouge">BlockingOperationError</code>를 터뜨린다.</p>

<p>포인트는 성능 최적화 도구라기보다 <strong>위반 탐지 도구</strong>라는 점이다.</p>

<h2 id="3-적용-테스트에서-먼저-켜기">3) 적용: 테스트에서 먼저 켜기</h2>

<h3 id="gradle-의존성">Gradle 의존성</h3>

<div class="language-gradle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">dependencies</span> <span class="o">{</span>
    <span class="n">testImplementation</span><span class="o">(</span><span class="s2">"io.projectreactor.tools:blockhound"</span><span class="o">)</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="junit에서-설치">JUnit에서 설치</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">reactor.blockhound.BlockHound</span><span class="o">;</span>

<span class="kd">class</span> <span class="nc">ReactiveTestSupport</span> <span class="o">{</span>
    <span class="kd">static</span> <span class="o">{</span>
        <span class="nc">BlockHound</span><span class="o">.</span><span class="na">install</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>실무에서는 보통 테스트 공통 베이스에서 한 번만 설치한다.</p>

<h2 id="4-실패를-재현해보면-바로-이해된다">4) 실패를 재현해보면 바로 이해된다</h2>

<p><img src="/assets/images/blockhound-test-story.svg" alt="BlockHound test story" /></p>

<p>아래 코드는 의도적으로 블로킹을 넣은 예시다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.junit.jupiter.api.Test</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">reactor.core.publisher.Mono</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">reactor.core.scheduler.Schedulers</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">reactor.test.StepVerifier</span><span class="o">;</span>

<span class="kd">class</span> <span class="nc">BlockHoundDemoTest</span> <span class="kd">extends</span> <span class="nc">ReactiveTestSupport</span> <span class="o">{</span>

    <span class="nd">@Test</span>
    <span class="kt">void</span> <span class="nf">detectsBlockingCallOnNonBlockingThread</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">pipeline</span> <span class="o">=</span> <span class="nc">Mono</span><span class="o">.</span><span class="na">fromRunnable</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="nc">Thread</span><span class="o">.</span><span class="na">sleep</span><span class="o">(</span><span class="mi">30</span><span class="o">);</span> <span class="c1">// blocking</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="k">throw</span> <span class="k">new</span> <span class="nf">RuntimeException</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><span class="na">publishOn</span><span class="o">(</span><span class="nc">Schedulers</span><span class="o">.</span><span class="na">parallel</span><span class="o">())</span>
                <span class="o">.</span><span class="na">thenReturn</span><span class="o">(</span><span class="s">"ok"</span><span class="o">);</span>

        <span class="nc">StepVerifier</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="n">pipeline</span><span class="o">)</span>
                <span class="o">.</span><span class="na">expectErrorMatches</span><span class="o">(</span><span class="n">t</span> <span class="o">-&gt;</span> <span class="n">t</span><span class="o">.</span><span class="na">getClass</span><span class="o">().</span><span class="na">getName</span><span class="o">().</span><span class="na">contains</span><span class="o">(</span><span class="s">"BlockingOperationError"</span><span class="o">))</span>
                <span class="o">.</span><span class="na">verify</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이 실패는 오히려 반갑다.<br />
운영에서 늦게 터질 문제를 CI에서 먼저 막아주기 때문이다.</p>

<h2 id="5-실패가-나면-어떻게-고칠까">5) 실패가 나면 어떻게 고칠까</h2>

<p><img src="/assets/images/blockhound-fix-patterns.svg" alt="Fix patterns" /></p>

<p>실무에서는 보통 아래 셋 중 하나다.</p>

<h3 id="a-논블로킹-대체재로-교체-가장-좋음">A. 논블로킹 대체재로 교체 (가장 좋음)</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">RestTemplate</code> -&gt; <code class="language-plaintext highlighter-rouge">WebClient</code></li>
  <li>JDBC -&gt; R2DBC (가능한 경우)</li>
</ul>

<h3 id="b-블로킹-구간-격리">B. 블로킹 구간 격리</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">User</span><span class="o">&gt;</span> <span class="nf">loadUser</span><span class="o">(</span><span class="nc">String</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nc">Mono</span><span class="o">.</span><span class="na">fromCallable</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="n">blockingRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">id</span><span class="o">))</span>
            <span class="o">.</span><span class="na">subscribeOn</span><span class="o">(</span><span class="nc">Schedulers</span><span class="o">.</span><span class="na">boundedElastic</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p>마이그레이션 단계에서는 이 방식이 현실적으로 가장 많이 쓰인다.</p>

<h3 id="c-불가피한-레거시만-allow-list">C. 불가피한 레거시만 allow-list</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">BlockHound</span><span class="o">.</span><span class="na">install</span><span class="o">(</span><span class="n">builder</span> <span class="o">-&gt;</span>
    <span class="n">builder</span><span class="o">.</span><span class="na">allowBlockingCallsInside</span><span class="o">(</span><span class="s">"com.example.LegacyBridge"</span><span class="o">,</span> <span class="s">"readOnce"</span><span class="o">)</span>
<span class="o">);</span>
</code></pre></div></div>

<p>중요한 점: 이건 문제 해결이 아니라 예외 관리다.<br />
이유, 담당자, 제거 시점을 같이 기록해두는 게 좋다.</p>

<h2 id="6-롤아웃은-이렇게-가면-안전하다">6) 롤아웃은 이렇게 가면 안전하다</h2>

<p><img src="/assets/images/blockhound-rollout.svg" alt="Rollout strategy" /></p>

<p>추천 순서:</p>

<ol>
  <li>로컬 테스트에 먼저 적용</li>
  <li>CI 테스트에서 강제</li>
  <li>allow-list 최소화 + 정리 일정 관리</li>
  <li>지표(p95/p99)로 실제 효과 확인</li>
</ol>

<p>이 순서로 가면 팀 충격을 줄이면서 품질 가드를 만들 수 있다.</p>

<h2 id="7-도입-시-자주-나오는-질문">7) 도입 시 자주 나오는 질문</h2>

<h3 id="q1-blockhound를-프로덕션에-항상-켜야-하나요">Q1. BlockHound를 프로덕션에 항상 켜야 하나요?</h3>

<p>필수는 아니다. 많은 팀이 테스트/스테이징에서 강하게 사용한다.<br />
목표가 “위반 조기 발견”이기 때문이다.</p>

<h3 id="q2-모든-블로킹을-100-잡아주나요">Q2. 모든 블로킹을 100% 잡아주나요?</h3>

<p>아니다. 다만 실무에서 문제를 만드는 주요 패턴을 빠르게 드러내는 데 꽤 유용하다.</p>

<h3 id="q3-webflux면-무조건-blockhound를-써야-하나요">Q3. WebFlux면 무조건 BlockHound를 써야 하나요?</h3>

<p>필수는 아니지만, 팀 규모가 커지거나 코드가 많아질수록 효과가 커진다.<br />
특히 리뷰에서 놓치기 쉬운 블로킹 호출을 자동으로 잡아준다.</p>

<hr />

<p>정리하면 BlockHound는 성능 튜닝 도구보다,<br />
<strong>반응형 규율을 지키게 해주는 안전장치</strong>에 가깝다.</p>

<ul>
  <li>이벤트 루프를 막는 코드 조기 발견</li>
  <li>실패를 테스트 단계로 당김</li>
  <li>운영 지연 사고 가능성 낮춤</li>
</ul>

<p>WebFlux를 이미 쓰고 있다면, BlockHound는 투자 대비 효과가 큰 품질 가드다.</p>]]></content><author><name></name></author><category term="backend" /><category term="spring" /><category term="webflux" /><category term="blockhound" /><category term="reactor" /><category term="async" /><summary type="html"><![CDATA[WebFlux를 쓰는데도 응답이 갑자기 느려질 때가 있다. 대부분 원인은 비슷하다. 논블로킹 경로 안에 블로킹 코드가 섞여 들어간 경우다.]]></summary></entry><entry><title type="html">Spring WebFlux 비동기, 한 번에 이해하는 실전 가이드</title><link href="https://0x22ff.github.io/backend/2026/03/12/spring-webflux-async-guide/" rel="alternate" type="text/html" title="Spring WebFlux 비동기, 한 번에 이해하는 실전 가이드" /><published>2026-03-12T10:00:00+09:00</published><updated>2026-03-12T10:00:00+09:00</updated><id>https://0x22ff.github.io/backend/2026/03/12/spring-webflux-async-guide</id><content type="html" xml:base="https://0x22ff.github.io/backend/2026/03/12/spring-webflux-async-guide/"><![CDATA[<p>트래픽이 늘어나면 서버가 버거워지는 이유는 생각보다 단순하다.<br />
CPU가 부족해서가 아니라, <strong>스레드가 I/O 대기 중에 묶여버리는 경우</strong>가 많다.</p>

<p>Spring WebFlux는 이 문제를 “스레드를 더 늘리는 방식”이 아니라,<br />
<strong>대기 자체를 논블로킹으로 처리하는 방식</strong>으로 푼다.</p>

<p>이번 글은 Reactive Streams/Reactor/WebFlux 기준으로,<br />
실무에서 제일 헷갈리는 포인트를 흐름 중심으로 정리했다.</p>

<h2 id="먼저-큰-그림">먼저 큰 그림</h2>

<p><img src="/assets/images/spring-webflux-sync-vs-async.svg" alt="MVC vs WebFlux" /></p>

<p>WebFlux를 한 줄로 말하면 이렇다.</p>

<ul>
  <li>스레드는 기다리지 않고</li>
  <li>I/O가 끝나는 시점에 다음 체인을 이어서 처리한다</li>
</ul>

<p>중요한 건 “무조건 빠르다”가 아니다.<br />
<strong>동시성이 큰 I/O 바운드 작업에서 유리하다</strong>가 정확한 표현이다.</p>

<h2 id="mono-flux부터-잡고-가기">Mono, Flux부터 잡고 가기</h2>

<p><img src="/assets/images/spring-webflux-mono-flux.svg" alt="Mono and Flux timeline" /></p>

<p>WebFlux에서 반환 타입은 거의 둘이다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Mono&lt;T&gt;</code>: 0개 또는 1개</li>
  <li><code class="language-plaintext highlighter-rouge">Flux&lt;T&gt;</code>: 0개 이상 N개</li>
</ul>

<p>컨트롤러 예시로 보면 바로 감이 온다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@RestController</span>
<span class="nd">@RequestMapping</span><span class="o">(</span><span class="s">"/api/users"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">UserController</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">UserService</span> <span class="n">userService</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">UserController</span><span class="o">(</span><span class="nc">UserService</span> <span class="n">userService</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">userService</span> <span class="o">=</span> <span class="n">userService</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@GetMapping</span><span class="o">(</span><span class="s">"/{id}"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">UserResponse</span><span class="o">&gt;</span> <span class="nf">getUser</span><span class="o">(</span><span class="nd">@PathVariable</span> <span class="nc">String</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">userService</span><span class="o">.</span><span class="na">findUser</span><span class="o">(</span><span class="n">id</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@GetMapping</span>
    <span class="kd">public</span> <span class="nc">Flux</span><span class="o">&lt;</span><span class="nc">UserResponse</span><span class="o">&gt;</span> <span class="nf">getUsers</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">userService</span><span class="o">.</span><span class="na">findAllUsers</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>핵심은 컨트롤러가 값을 즉시 만들어 반환하는 게 아니라,<br />
<strong>비동기 파이프라인(Publisher)을 반환한다</strong>는 점이다.</p>

<h2 id="webclient-병렬-호출-체감-차이가-나는-구간">WebClient 병렬 호출: 체감 차이가 나는 구간</h2>

<p>외부 API를 여러 개 조합하는 화면에서 WebFlux 장점이 잘 드러난다.</p>

<p><img src="/assets/images/spring-webflux-webclient-fanout.svg" alt="WebClient fan-out with Mono.zip" /></p>

<p>예시로 대시보드 API가 아래 3개를 동시에 호출한다고 보자.</p>

<ul>
  <li>프로필 API</li>
  <li>주문 API</li>
  <li>포인트 API</li>
</ul>

<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">DashboardService</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">WebClient</span> <span class="n">webClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">DashboardService</span><span class="o">(</span><span class="nc">WebClient</span><span class="o">.</span><span class="na">Builder</span> <span class="n">builder</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">webClient</span> <span class="o">=</span> <span class="n">builder</span><span class="o">.</span><span class="na">baseUrl</span><span class="o">(</span><span class="s">"http://internal-api"</span><span class="o">).</span><span class="na">build</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">DashboardResponse</span><span class="o">&gt;</span> <span class="nf">getDashboard</span><span class="o">(</span><span class="nc">String</span> <span class="n">userId</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">ProfileDto</span><span class="o">&gt;</span> <span class="n">profileMono</span> <span class="o">=</span> <span class="n">webClient</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
                <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/profiles/{id}"</span><span class="o">,</span> <span class="n">userId</span><span class="o">)</span>
                <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
                <span class="o">.</span><span class="na">bodyToMono</span><span class="o">(</span><span class="nc">ProfileDto</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>

        <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">List</span><span class="o">&lt;</span><span class="nc">OrderDto</span><span class="o">&gt;&gt;</span> <span class="n">ordersMono</span> <span class="o">=</span> <span class="n">webClient</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
                <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/orders/{id}"</span><span class="o">,</span> <span class="n">userId</span><span class="o">)</span>
                <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
                <span class="o">.</span><span class="na">bodyToFlux</span><span class="o">(</span><span class="nc">OrderDto</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
                <span class="o">.</span><span class="na">collectList</span><span class="o">();</span>

        <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">PointDto</span><span class="o">&gt;</span> <span class="n">pointMono</span> <span class="o">=</span> <span class="n">webClient</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
                <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/points/{id}"</span><span class="o">,</span> <span class="n">userId</span><span class="o">)</span>
                <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
                <span class="o">.</span><span class="na">bodyToMono</span><span class="o">(</span><span class="nc">PointDto</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>

        <span class="k">return</span> <span class="nc">Mono</span><span class="o">.</span><span class="na">zip</span><span class="o">(</span><span class="n">profileMono</span><span class="o">,</span> <span class="n">ordersMono</span><span class="o">,</span> <span class="n">pointMono</span><span class="o">)</span>
                <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">tuple</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">DashboardResponse</span><span class="o">(</span><span class="n">tuple</span><span class="o">.</span><span class="na">getT1</span><span class="o">(),</span> <span class="n">tuple</span><span class="o">.</span><span class="na">getT2</span><span class="o">(),</span> <span class="n">tuple</span><span class="o">.</span><span class="na">getT3</span><span class="o">()));</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 핵심은 <code class="language-plaintext highlighter-rouge">Mono.zip()</code>이다.<br />
순차가 아니라 병렬로 진행되기 때문에 전체 대기 시간을 줄일 수 있다.</p>

<h2 id="가장-흔한-실수-이벤트-루프에서-블로킹-호출">가장 흔한 실수: 이벤트 루프에서 블로킹 호출</h2>

<p>WebFlux에서 성능을 무너뜨리는 대표 원인:</p>

<ul>
  <li>JDBC 같은 블로킹 드라이버 호출</li>
  <li><code class="language-plaintext highlighter-rouge">Thread.sleep(...)</code></li>
  <li><code class="language-plaintext highlighter-rouge">block()</code> 남용</li>
</ul>

<p><img src="/assets/images/spring-webflux-event-loop.svg" alt="Event loop and blocking pitfall" /></p>

<h3 id="피해야-하는-코드">피해야 하는 코드</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">UserResponse</span><span class="o">&gt;</span> <span class="nf">badExample</span><span class="o">(</span><span class="nc">String</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">UserEntity</span> <span class="n">entity</span> <span class="o">=</span> <span class="n">blockingRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">id</span><span class="o">);</span> <span class="c1">// blocking call</span>
    <span class="k">return</span> <span class="nc">Mono</span><span class="o">.</span><span class="na">just</span><span class="o">(</span><span class="nc">UserResponse</span><span class="o">.</span><span class="na">from</span><span class="o">(</span><span class="n">entity</span><span class="o">));</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="최소한의-방어-코드">최소한의 방어 코드</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="nc">Mono</span><span class="o">&lt;</span><span class="nc">UserResponse</span><span class="o">&gt;</span> <span class="nf">saferExample</span><span class="o">(</span><span class="nc">String</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nc">Mono</span><span class="o">.</span><span class="na">fromCallable</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="n">blockingRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">id</span><span class="o">))</span>
            <span class="o">.</span><span class="na">subscribeOn</span><span class="o">(</span><span class="nc">Schedulers</span><span class="o">.</span><span class="na">boundedElastic</span><span class="o">())</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="nl">UserResponse:</span><span class="o">:</span><span class="n">from</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>가능하면 더 좋은 방향은 분명하다.<br />
<strong>끝까지 논블로킹 스택(WebClient, R2DBC 등)으로 가는 것</strong>이다.</p>

<p>중간에 블로킹 구간이 늘어나면 WebFlux 장점은 빠르게 줄어든다.</p>

<h2 id="backpressure-처리-가능한-만큼만-받기">Backpressure: 처리 가능한 만큼만 받기</h2>

<p>Reactive Streams 핵심은 생산자가 밀어 넣는 구조가 아니라,<br />
<strong>소비자가 처리 가능한 만큼 요청(request n)하는 구조</strong>라는 점이다.</p>

<p><img src="/assets/images/spring-webflux-backpressure.svg" alt="Reactive Streams backpressure" /></p>

<p>운영에서 이게 중요한 이유는 단순하다.</p>

<ul>
  <li>빠른 producer가 느린 consumer를 압도하지 않게 막고</li>
  <li>메모리 급증, 지연 폭증 가능성을 낮춘다</li>
</ul>

<p>Reactor에서는 상황에 맞춰 <code class="language-plaintext highlighter-rouge">limitRate</code>, <code class="language-plaintext highlighter-rouge">onBackpressureBuffer</code>, <code class="language-plaintext highlighter-rouge">onBackpressureDrop</code> 같은 전략을 쓴다.</p>

<h2 id="실무에서-자주-쓰는-스트리밍-예시-sse">실무에서 자주 쓰는 스트리밍 예시: SSE</h2>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@GetMapping</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="s">"/events"</span><span class="o">,</span> <span class="n">produces</span> <span class="o">=</span> <span class="nc">MediaType</span><span class="o">.</span><span class="na">TEXT_EVENT_STREAM_VALUE</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">Flux</span><span class="o">&lt;</span><span class="nc">ServerSentEvent</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;&gt;</span> <span class="nf">events</span><span class="o">()</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nc">Flux</span><span class="o">.</span><span class="na">interval</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</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="n">seq</span> <span class="o">-&gt;</span> <span class="nc">ServerSentEvent</span><span class="o">.&lt;</span><span class="nc">String</span><span class="o">&gt;</span><span class="n">builder</span><span class="o">()</span>
                    <span class="o">.</span><span class="na">event</span><span class="o">(</span><span class="s">"tick"</span><span class="o">)</span>
                    <span class="o">.</span><span class="na">id</span><span class="o">(</span><span class="nc">Long</span><span class="o">.</span><span class="na">toString</span><span class="o">(</span><span class="n">seq</span><span class="o">))</span>
                    <span class="o">.</span><span class="na">data</span><span class="o">(</span><span class="s">"server-time: "</span> <span class="o">+</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">())</span>
                    <span class="o">.</span><span class="na">build</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p>실시간 알림, 진행 상태, 모니터링 이벤트 같은 시나리오에 잘 맞는다.</p>

<h2 id="그러면-언제-webflux가-맞을까">그러면 언제 WebFlux가 맞을까?</h2>

<p>아래 조건이 많으면 WebFlux가 잘 맞는다.</p>

<ul>
  <li>외부 API/DB 호출이 많고 동시 요청이 큼</li>
  <li>스트리밍 응답(SSE 등)이 필요함</li>
  <li>논블로킹 기반으로 end-to-end 설계 가능함</li>
</ul>

<p>반대로 아래라면 MVC가 더 단순하고 유지보수에 유리할 수 있다.</p>

<ul>
  <li>트래픽이 크지 않고 CRUD 중심</li>
  <li>기존 코드가 블로킹 라이브러리에 크게 의존</li>
  <li>팀의 Reactor 디버깅/운영 경험이 적음</li>
</ul>

<h2 id="도입-체크리스트">도입 체크리스트</h2>

<ol>
  <li>“왜 WebFlux인지”를 먼저 정의</li>
  <li>블로킹 라이브러리 사용 지점 전수 점검</li>
  <li><code class="language-plaintext highlighter-rouge">block()</code> 사용 위치를 정책으로 제한</li>
  <li>타임아웃/재시도/서킷브레이커 같이 설계</li>
  <li>테스트에서 지연/오류/취소 시나리오 검증</li>
</ol>

<hr />

<p>WebFlux는 마법 도구는 아니다.<br />
대신 <strong>I/O 대기 시간을 어떻게 다룰지</strong>를 정확히 설계하면 체감 차이를 크게 만든다.</p>

<p>정말 중요한 건 이 세 가지다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Mono</code>, <code class="language-plaintext highlighter-rouge">Flux</code>를 자연스럽게 다루고</li>
  <li>이벤트 루프를 막지 않고</li>
  <li>블로킹 구간을 명확히 격리하는 것</li>
</ul>

<p>이 세 가지만 지켜도 WebFlux는 충분히 강력하게 동작한다.</p>]]></content><author><name></name></author><category term="backend" /><category term="spring" /><category term="webflux" /><category term="reactor" /><category term="async" /><category term="java" /><summary type="html"><![CDATA[트래픽이 늘어나면 서버가 버거워지는 이유는 생각보다 단순하다. CPU가 부족해서가 아니라, 스레드가 I/O 대기 중에 묶여버리는 경우가 많다.]]></summary></entry><entry><title type="html">BSSJ 아키텍처, 왜 지금 이 구조가 맞았는지</title><link href="https://0x22ff.github.io/architecture/2026/03/10/bssj-architecture-overview/" rel="alternate" type="text/html" title="BSSJ 아키텍처, 왜 지금 이 구조가 맞았는지" /><published>2026-03-10T09:10:00+09:00</published><updated>2026-03-10T09:10:00+09:00</updated><id>https://0x22ff.github.io/architecture/2026/03/10/bssj-architecture-overview</id><content type="html" xml:base="https://0x22ff.github.io/architecture/2026/03/10/bssj-architecture-overview/"><![CDATA[<p>서버는 많지 않고, 운영은 단순해야 하고, 화면 경험은 분리하고 싶을 때가 있다.<br />
BSSJ 구조는 딱 그 조건에서 출발했다.</p>

<p>이 글은 <code class="language-plaintext highlighter-rouge">docs/architecture.md</code>(2026-03-09 기준) 내용을 바탕으로,<br />
왜 지금 구조를 이렇게 가져갔는지 한 번에 보이게 정리한 버전이다.</p>

<h2 id="30초-요약">30초 요약</h2>

<ul>
  <li>사용자 화면은 <code class="language-plaintext highlighter-rouge">Web</code> / <code class="language-plaintext highlighter-rouge">CMS</code>로 분리</li>
  <li>백엔드는 <code class="language-plaintext highlighter-rouge">Spring Boot 단일 프로세스</code>로 통합</li>
  <li>공개 API는 <code class="language-plaintext highlighter-rouge">/api/*</code>, 관리자 API는 <code class="language-plaintext highlighter-rouge">/cms/*</code>로 경계 분리</li>
  <li>데이터 저장은 <code class="language-plaintext highlighter-rouge">MySQL</code>, 파일 저장은 <code class="language-plaintext highlighter-rouge">S3</code>로 역할 분리</li>
</ul>

<p>한 줄로 정리하면 이거다.<br />
<strong>“UX는 분리하고, 런타임은 통합한다.”</strong></p>

<h2 id="전체-구조-먼저-보기">전체 구조 먼저 보기</h2>

<p><img src="/assets/images/bssj-architecture-overview.svg" alt="BSSJ 전체 아키텍처" /></p>

<p>요청 흐름은 단순하다.</p>

<ol>
  <li>Web 사용자는 공개 화면 + 공개 API 사용</li>
  <li>CMS 사용자는 관리자 화면 + 인증 API 사용</li>
  <li>두 경로 모두 같은 백엔드 프로세스로 들어옴</li>
  <li>백엔드가 MySQL/S3로 책임 나눠 처리</li>
</ol>

<p>구조를 단순하게 잡아둔 덕분에 배포 포인트, 장애 대응 포인트도 같이 줄었다.</p>

<h2 id="왜-fe는-분리하고-be는-합쳤을까">왜 FE는 분리하고, BE는 합쳤을까?</h2>

<p>이 질문을 제일 많이 받는다.</p>

<p>“하나의 서비스면 그냥 전부 합치면 되는 거 아닌가?”</p>

<p>실제로는 반대로 보는 게 더 맞다.</p>

<ul>
  <li><strong>화면 목적이 다르다.</strong><br />
방문자용 Web과 관리자용 CMS는 사용자 흐름이 다르다. 그래서 FE는 분리하는 게 맞다.</li>
  <li><strong>운영은 단순해야 버틴다.</strong><br />
작은 팀에서 JVM을 둘로 나누면 배포/모니터링/장애 대응 비용이 빠르게 커진다.</li>
</ul>

<p>그래서 지금은 이렇게 정리했다.</p>

<ul>
  <li>FE: <code class="language-plaintext highlighter-rouge">apps/web/frontend</code>, <code class="language-plaintext highlighter-rouge">apps/cms/frontend</code> 분리</li>
  <li>BE: <code class="language-plaintext highlighter-rouge">apps:web:backend</code> 하나로 통합 실행</li>
</ul>

<p>현재 규모에서는 이 구성이 속도와 안정성 사이 균형이 제일 좋았다.</p>

<h2 id="요청-흐름을-실제로-보면">요청 흐름을 실제로 보면</h2>

<h3 id="방문자web">방문자(Web)</h3>

<ol>
  <li>사용자가 Public Web 접속</li>
  <li>Nginx가 정적 리소스 응답</li>
  <li>화면에서 <code class="language-plaintext highlighter-rouge">/api/*</code> 호출</li>
  <li>백엔드가 MySQL 조회 후 JSON 반환</li>
</ol>

<h3 id="관리자cms">관리자(CMS)</h3>

<ol>
  <li>관리자가 CMS 접속</li>
  <li>Nginx가 CMS 정적 리소스 응답</li>
  <li><code class="language-plaintext highlighter-rouge">POST /cms/auth/login</code>으로 JWT 발급</li>
  <li>이후 <code class="language-plaintext highlighter-rouge">/cms/*</code> 호출 시 토큰 검증</li>
  <li>필요 시 S3 파일 처리</li>
</ol>

<p>핵심은 명확하다.<br />
<strong>인증은 CMS 경로에 집중하고, 공개 조회는 가볍게 유지한다.</strong></p>

<h2 id="백엔드-모듈-구조-통합이지만-경계는-유지">백엔드 모듈 구조: 통합이지만 경계는 유지</h2>

<p><img src="/assets/images/bssj-module-dependency.svg" alt="BSSJ 모듈 의존 구조" /></p>

<p>실행 프로세스는 하나지만, 내부 모듈 경계는 분명하게 나눴다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">apps:web:backend</code> (실행 엔트리)</li>
  <li><code class="language-plaintext highlighter-rouge">apps:cms:backend</code> (java-library)</li>
  <li><code class="language-plaintext highlighter-rouge">apps:common:mysql</code></li>
  <li><code class="language-plaintext highlighter-rouge">apps:common:core</code></li>
</ul>

<p>의존 관계는 아래처럼 고정돼 있다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">web/backend -&gt; cms/backend</code></li>
  <li><code class="language-plaintext highlighter-rouge">web/backend -&gt; common/mysql -&gt; common/core</code></li>
  <li><code class="language-plaintext highlighter-rouge">cms/backend -&gt; common/mysql</code></li>
</ul>

<p>즉 구조의 핵심은 이거다.<br />
<strong>프로세스는 하나, 경계는 명확하게.</strong></p>

<h2 id="패키지-구조도-도메인-중심으로-정리">패키지 구조도 도메인 중심으로 정리</h2>

<p>초기 레이어 구조(controller/service/repository)에서는 수정 범위가 넓어질수록 이동 비용이 컸다.<br />
그래서 지금은 도메인 기준으로 재정렬했다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">com.bssj.webapi</code>: <code class="language-plaintext highlighter-rouge">video</code>, <code class="language-plaintext highlighter-rouge">content</code>, <code class="language-plaintext highlighter-rouge">churchinfo</code>, <code class="language-plaintext highlighter-rouge">settings</code>, <code class="language-plaintext highlighter-rouge">menu</code>, <code class="language-plaintext highlighter-rouge">modal</code>, <code class="language-plaintext highlighter-rouge">manager</code>, <code class="language-plaintext highlighter-rouge">common</code></li>
  <li><code class="language-plaintext highlighter-rouge">com.bssj.cms</code>: <code class="language-plaintext highlighter-rouge">auth</code>, <code class="language-plaintext highlighter-rouge">video</code>, <code class="language-plaintext highlighter-rouge">bulletin</code>, <code class="language-plaintext highlighter-rouge">menu</code>, <code class="language-plaintext highlighter-rouge">staff</code>, <code class="language-plaintext highlighter-rouge">worshiptime</code>, <code class="language-plaintext highlighter-rouge">mainpage</code>, <code class="language-plaintext highlighter-rouge">upload</code>, <code class="language-plaintext highlighter-rouge">bible</code>, <code class="language-plaintext highlighter-rouge">manager</code>, <code class="language-plaintext highlighter-rouge">common</code></li>
</ul>

<p>그리고 <code class="language-plaintext highlighter-rouge">cms/manager</code>는 <code class="language-plaintext highlighter-rouge">domain/port/infra</code>로 나눠 hexagonal 패턴을 적용했다.</p>

<h2 id="보안멀티-교회-관점에서-중요한-점">보안/멀티 교회 관점에서 중요한 점</h2>

<p>인증 정책은 URL 경계와 같이 간다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/api/*</code>: 인증 없음 (공개 조회)</li>
  <li><code class="language-plaintext highlighter-rouge">/cms/*</code>: JWT 필수 (<code class="language-plaintext highlighter-rouge">/cms/auth/**</code> 제외)</li>
</ul>

<p>JWT 안에 <code class="language-plaintext highlighter-rouge">cid</code>(churchId)를 담아 교회 단위 데이터 격리를 유지한다.<br />
지금은 단일 운영에 가까워도, 이 경계가 있어야 멀티 교회 확장 비용이 줄어든다.</p>

<h2 id="인프라배포-작게-시작하고-단단하게-유지">인프라/배포: 작게 시작하고 단단하게 유지</h2>

<p>현재 운영 기준은 단일 EC2(t3.small)다.</p>

<ul>
  <li>Nginx: 정적 파일 + 프록시</li>
  <li>Unified Backend: <code class="language-plaintext highlighter-rouge">:8080</code></li>
  <li>MySQL: 로컬</li>
  <li>S3: 외부 스토리지</li>
</ul>

<p>CI/CD도 목적 중심으로 가져갔다.</p>

<ul>
  <li>CI (<code class="language-plaintext highlighter-rouge">develop</code> PR): 빌드/테스트 품질 가드</li>
  <li>CD (<code class="language-plaintext highlighter-rouge">main</code> push): 빌드 -&gt; 전송 -&gt; 서비스 재시작</li>
</ul>

<p>대규모 분산보다, 운영 안정성을 먼저 챙긴 구성이라고 보면 된다.</p>

<h2 id="이-구조의-장점과-리스크">이 구조의 장점과 리스크</h2>

<h3 id="장점">장점</h3>

<ol>
  <li>운영 포인트 감소: 프로세스/배포 대상 단순화</li>
  <li>비용 효율: JVM/Tomcat/Hikari 중복 제거</li>
  <li>개발 속도: FE 분리로 작업 충돌 최소화</li>
</ol>

<h3 id="리스크">리스크</h3>

<ol>
  <li>경계 관리 실패 시 단일 앱 비대화</li>
  <li><code class="language-plaintext highlighter-rouge">/api</code>와 <code class="language-plaintext highlighter-rouge">/cms</code> 정책이 흐려지면 보안/권한 리스크 증가</li>
</ol>

<p>그래서 운영 원칙은 계속 이대로 가져간다.</p>

<ul>
  <li>런타임 통합 유지</li>
  <li>모듈/도메인 경계 강화</li>
  <li>인증 정책 URL 규칙 강제</li>
</ul>

<h2 id="다음-단계-우선순위">다음 단계 우선순위</h2>

<ol>
  <li>CMS 업로드를 presigned URL 중심으로 전환</li>
  <li>Swagger를 <code class="language-plaintext highlighter-rouge">/api</code>와 <code class="language-plaintext highlighter-rouge">/cms</code> 기준으로 명확히 분리</li>
  <li>멀티 교회 권한 모델(전역/교회 관리자) 구체화</li>
</ol>

<hr />

<p>정리하면 BSSJ 구조는 “완벽한 이상형”보다,<br />
<strong>지금 팀이 운영 가능한 최적점</strong>을 목표로 잡은 아키텍처다.</p>

<p>그리고 확장을 위한 경계(모듈/인증)는 이미 깔아둔 상태다.</p>]]></content><author><name></name></author><category term="architecture" /><category term="spring-boot" /><category term="vue" /><category term="mysql" /><category term="aws" /><category term="nginx" /><category term="monorepo" /><summary type="html"><![CDATA[서버는 많지 않고, 운영은 단순해야 하고, 화면 경험은 분리하고 싶을 때가 있다. BSSJ 구조는 딱 그 조건에서 출발했다.]]></summary></entry></feed>