<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.idean.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.idean.me/" rel="alternate" type="text/html" hreflang="ko" /><updated>2026-09-07T17:51:25+09:00</updated><id>https://blog.idean.me/feed.xml</id><title type="html">idean3885</title><subtitle>Spring Boot·Kubernetes 기반 멀티테넌시 서비스를 개발·운영하는 7년차 백엔드 개발자. 과금 미터링 데이터 파이프라인·인증서 자동화 같은 운영 사례와 공부 기록을 꾸준히 쌓습니다.</subtitle><author><name>idean3885</name></author><entry><title type="html">부하테스트로 커넥션 상한 찾기: 요청률 고정과 꼬리 지연</title><link href="https://blog.idean.me/posts/connection-limit-load-test/" rel="alternate" type="text/html" title="부하테스트로 커넥션 상한 찾기: 요청률 고정과 꼬리 지연" /><published>2026-08-28T00:10:00+09:00</published><updated>2026-08-28T08:45:00+09:00</updated><id>https://blog.idean.me/posts/connection-limit-load-test</id><content type="html" xml:base="https://blog.idean.me/posts/connection-limit-load-test/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
<strong>커넥션 상한은 요청률을 고정해 부하를 걸고 버린 요청을 집계하면 나옵니다. 커넥션 1개당 REST 상한은 약 9,000 req/s 였습니다.</strong><br />
포화는 꼬리 지연에서 먼저 나타납니다. 중앙값이 움직이는 시점에는 이미 상한을 넘은 뒤입니다.</p>
</blockquote>

<h2 id="1-문제-상한이-어디인지-나오지-않았습니다">1. 문제: 상한이 어디인지 나오지 않았습니다</h2>

<p>부하를 견디는 구조를 공부하면서 부하가 왔을 때 어디가 먼저 막히는지를 지점별로 보고 있습니다.
이번 지점은 커넥션입니다.</p>

<p>서비스 간 호출을 측정하면서 커넥션 수를 줄여 봤는데 처리량도 지연도 거의 그대로였습니다.
풀이 200개인데 동시 요청이 50건이라 커넥션이 병목에서 멀었기 때문입니다.
그래서 커넥션 예산을 1개로 낮추고 다시 측정했습니다.
이번에는 상한 자체가 나오지 않았습니다.</p>

<p>원인은 부하를 거는 방식에 있었습니다.</p>

<table>
  <thead>
    <tr>
      <th>실행 모델</th>
      <th>부하를 정하는 값</th>
      <th>응답이 늦어지면</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>동시 사용자 고정</td>
      <td>가상 사용자 수</td>
      <td>다음 요청도 늦게 발송돼 부하가 스스로 줄어듭니다</td>
    </tr>
    <tr>
      <td>요청률 고정</td>
      <td>초당 요청 수</td>
      <td>요청은 그대로 도착하고 처리하지 못한 만큼 밀립니다</td>
    </tr>
  </tbody>
</table>

<p>동시 사용자 50명으로 걸면 응답이 100 ms 로 늘어난 순간 초당 요청 수도 함께 떨어집니다.
서버가 감당하는 만큼만 부하가 도착하므로 그래프는 평형에 머무릅니다.
상한을 찾으려는 측정에서 부하가 스스로 조절되면 찾을 대상이 없어집니다.</p>

<h2 id="2-측정">2. 측정</h2>

<h3 id="요청률을-고정하고-버린-요청을-집계했습니다">요청률을 고정하고 버린 요청을 집계했습니다</h3>

<p>k6 의 실행 모델을 <code class="language-plaintext highlighter-rouge">constant-arrival-rate</code> 로 바꿨습니다.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">scenarios</span><span class="p">:</span> <span class="p">{</span>
  <span class="nl">openLoop</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">executor</span><span class="p">:</span> <span class="dl">'</span><span class="s1">constant-arrival-rate</span><span class="dl">'</span><span class="p">,</span>   <span class="c1">// 응답과 무관하게 초당 rate 만큼 요청</span>
    <span class="na">rate</span><span class="p">:</span> <span class="nx">RATE</span><span class="p">,</span>
    <span class="na">timeUnit</span><span class="p">:</span> <span class="dl">'</span><span class="s1">1s</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">duration</span><span class="p">:</span> <span class="dl">'</span><span class="s1">15s</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">preAllocatedVUs</span><span class="p">:</span> <span class="mi">50</span><span class="p">,</span>
    <span class="c1">// 응답이 밀려 VU 가 모두 점유되면 k6 가 요청을 보내지 않고</span>
    <span class="c1">// dropped_iterations 로 집계한다. 그 값이 과부하 지표가 된다.</span>
    <span class="na">maxVUs</span><span class="p">:</span> <span class="mi">400</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">}</span>
</code></pre></div></div>

<p>가상 사용자를 400개까지 확보해 두고 그것이 모두 점유되면 k6 는 요청을 보내지 않고 <code class="language-plaintext highlighter-rouge">dropped_iterations</code> 로 집계합니다.
<strong>서버가 감당하지 못한 양이 부하 도구 쪽에 숫자로 남습니다.</strong>
회차가 15초이므로 버린 요청의 모수는 요청률 × 15 입니다.</p>

<h3 id="커넥션만-줄였습니다">커넥션만 줄였습니다</h3>

<p>커넥션을 병목으로 만드는 방법은 둘입니다.
부하를 올리거나 커넥션을 줄이는 것입니다.
앞을 택하면 CPU 포화와 부하 도구 경합이 함께 들어와 무엇이 막았는지 판정할 수 없습니다.
그래서 <strong>요청률 사다리는 그대로 두고 커넥션 예산만 1개로 낮췄습니다.</strong></p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>런타임</td>
      <td>Java 21, Spring Boot 3.5</td>
    </tr>
    <tr>
      <td>부하</td>
      <td>k6, 요청률 고정, 회차당 15초</td>
    </tr>
    <tr>
      <td>요청률 사다리</td>
      <td>400 부터 12,800 까지 두 배씩</td>
    </tr>
    <tr>
      <td>커넥션 예산</td>
      <td>풀 1개 (비교 회차만 4개)</td>
    </tr>
    <tr>
      <td>응답 크기</td>
      <td>1건 고정</td>
    </tr>
    <tr>
      <td>데이터 계층</td>
      <td>인메모리 고정 (DB 없음)</td>
    </tr>
    <tr>
      <td>호스트</td>
      <td>단일 호스트 (M2 Pro 12코어, 16GB)</td>
    </tr>
    <tr>
      <td>측정 지점</td>
      <td>호출 측이 측정한 피호출 서비스 왕복 시간</td>
    </tr>
  </tbody>
</table>

<h3 id="회차는-구성마다-하나입니다">회차는 구성마다 하나입니다</h3>

<p>반복 측정은 하지 않았습니다.
회차마다 서버를 새로 띄우고 워밍업을 본 회차와 분리했지만, 앞선 측정에서 같은 조건이 12% 차이로 나온 적이 있습니다.
그 크기 안쪽 차이는 뒤에서 신호로 읽지 않습니다.</p>

<h2 id="3-결과">3. 결과</h2>

<h3 id="달성-요청률이-목표를-따라가지-못하는-지점이-상한입니다">달성 요청률이 목표를 따라가지 못하는 지점이 상한입니다</h3>

<p>REST, 커넥션 풀 1개로 요청률을 두 배씩 올린 결과입니다.</p>

<table>
  <thead>
    <tr>
      <th>목표 요청률</th>
      <th>달성 요청률</th>
      <th>버린 요청</th>
      <th>상류 p50</th>
      <th>상류 p95</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>400</td>
      <td>400 req/s</td>
      <td>0 (0%)</td>
      <td>309 µs</td>
      <td>680 µs</td>
    </tr>
    <tr>
      <td>800</td>
      <td>800 req/s</td>
      <td>0 (0%)</td>
      <td>198 µs</td>
      <td>660 µs</td>
    </tr>
    <tr>
      <td>1,600</td>
      <td>1,591 req/s</td>
      <td>130 (0.5%)</td>
      <td>113 µs</td>
      <td>307 µs</td>
    </tr>
    <tr>
      <td>3,200</td>
      <td>3,190 req/s</td>
      <td>152 (0.3%)</td>
      <td>79 µs</td>
      <td>954 µs</td>
    </tr>
    <tr>
      <td>6,400</td>
      <td>6,316 req/s</td>
      <td>1,259 (1.3%)</td>
      <td>72 µs</td>
      <td>1,795 µs</td>
    </tr>
    <tr>
      <td>9,600</td>
      <td><strong>8,812 req/s</strong></td>
      <td>11,631 (8.1%)</td>
      <td>33,394 µs</td>
      <td>61,987 µs</td>
    </tr>
    <tr>
      <td>12,800</td>
      <td><strong>9,043 req/s</strong></td>
      <td><strong>56,008 (29%)</strong></td>
      <td>40,396 µs</td>
      <td>54,690 µs</td>
    </tr>
  </tbody>
</table>

<p>6,400 까지는 목표를 거의 그대로 달성합니다.
9,600 에서 8,812 로 미달하고 12,800 을 줘도 9,043 에서 멈춥니다.
<strong>커넥션 1개당 REST 상한은 약 9,000 req/s 입니다.</strong></p>

<p>버린 요청이 같은 판정을 뒷받침합니다.
6,400 까지 1% 안쪽이고 9,600 에서 8.1%, 12,800 에서 29% 입니다.
목표를 두 배로 올렸는데 달성은 231 req/s 만 늘고 버린 양은 다섯 배 가까이 됐습니다.</p>

<p>저부하 구간의 p50 이 오히려 큰 것(400 에서 309 µs, 6,400 에서 72 µs)은 회차가 15초로 짧아 워밍업 영향이 남은 것으로 봅니다.
상한 판정에는 쓰지 않았습니다.</p>

<h3 id="꼬리부터-무너집니다">꼬리부터 무너집니다</h3>

<p>6,400 은 REST 가 목표를 달성한 구간입니다.
같은 회차를 gRPC 와 나란히 놓으면 백분위마다 그림이 달라집니다.</p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>REST</th>
      <th>gRPC</th>
      <th>배수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상류 p50</td>
      <td><strong>72 µs</strong></td>
      <td>79 µs</td>
      <td>0.9배</td>
    </tr>
    <tr>
      <td>상류 p90</td>
      <td>390 µs</td>
      <td><strong>156 µs</strong></td>
      <td>2.5배</td>
    </tr>
    <tr>
      <td>상류 p95</td>
      <td>1,795 µs</td>
      <td><strong>245 µs</strong></td>
      <td>7.3배</td>
    </tr>
  </tbody>
</table>

<p>중앙값으로는 두 전송이 같고 REST 가 오히려 조금 빠릅니다.
그런데 p90 에서 2.5배, p95 에서 7.3배로 뒤로 갈수록 벌어집니다.</p>

<p><strong>커넥션이 모자라지는 신호는 꼬리에만 나타납니다.</strong>
커넥션 1개에 요청이 순서를 기다리면 대부분은 기다리지 않고 통과하고 일부만 앞의 요청이 끝날 때까지 대기합니다.
그 일부가 백분위 뒤쪽에 쌓입니다.</p>

<h3 id="중앙값이-움직이면-이미-늦었습니다">중앙값이 움직이면 이미 늦었습니다</h3>

<table>
  <thead>
    <tr>
      <th>목표 요청률</th>
      <th>목표 달성</th>
      <th>상류 p50</th>
      <th>상류 p95</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>6,400</td>
      <td>달성</td>
      <td>72 µs</td>
      <td>1,795 µs</td>
    </tr>
    <tr>
      <td>9,600</td>
      <td><strong>미달</strong></td>
      <td><strong>33,394 µs</strong></td>
      <td>61,987 µs</td>
    </tr>
  </tbody>
</table>

<p>사다리를 한 칸 올리자 p50 이 72 µs 에서 33.4 ms 로 460배가 됐습니다.
중앙값까지 밀린 상태입니다.</p>

<p>그리고 이 구간에서 REST 는 목표를 달성하지 못했습니다.
<strong>중앙값이 나빠졌다면 상한을 이미 넘은 뒤입니다.</strong>
상한을 찾으려면 중앙값이 아니라 꼬리와 버린 요청을 봐야 합니다.</p>

<h3 id="풀을-늘리면-처리량은-돌아오고-꼬리는-남습니다">풀을 늘리면 처리량은 돌아오고 꼬리는 남습니다</h3>

<p>커넥션이 상한이라면 커넥션을 늘리는 것이 첫 선택입니다.
풀을 4개로 올려 12,800 목표를 다시 걸었습니다.</p>

<table>
  <thead>
    <tr>
      <th>풀</th>
      <th>달성 요청률</th>
      <th>상류 p50</th>
      <th>상류 p90</th>
      <th>버린 요청</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>9,043 req/s</td>
      <td>40,396 µs</td>
      <td>48,088 µs</td>
      <td>29%</td>
    </tr>
    <tr>
      <td><strong>4</strong></td>
      <td><strong>12,328 req/s</strong></td>
      <td>118 µs</td>
      <td>10,274 µs</td>
      <td>3.5%</td>
    </tr>
  </tbody>
</table>

<p>처리량은 돌아옵니다.
12,800 목표에서 12,328 req/s 를 달성하고 중앙값도 118 µs 로 정상입니다.
<strong>그런데 p90 이 10.3 ms 로 중앙값의 87배입니다.</strong>
중앙값만 걸린 대시보드에서는 정상으로 읽히는 상태입니다.</p>

<p>풀 4개로 9,600 목표를 건 회차도 있습니다.
그쪽 p90 은 18.3 ms 로 요청률이 더 낮은데 12,800 회차보다 나쁩니다.
회차가 하나씩이라 이 역전은 신호로 읽지 않았습니다.</p>

<p>같은 목표를 gRPC 는 커넥션 1개로 처리했습니다.
12,800 에서 12,625 req/s 를 달성하고 버린 요청이 1.4% 입니다.
HTTP/2 는 한 커넥션 안에서 스트림을 나눠 동시에 실어 보내므로 동시 요청 수만큼 커넥션이 필요하지 않습니다.</p>

<table>
  <thead>
    <tr>
      <th>선택</th>
      <th>얻는 것</th>
      <th>남는 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>풀 확대</td>
      <td>처리량 회복. 설정값 하나로 끝납니다</td>
      <td>꼬리 지연. 호출자 수만큼 곱해지는 커넥션</td>
    </tr>
    <tr>
      <td>다중화 프로토콜</td>
      <td>커넥션 1개로 같은 처리량</td>
      <td>도입 비용. 계약과 도구를 함께 교체합니다</td>
    </tr>
  </tbody>
</table>

<h2 id="4-마치며">4. 마치며</h2>

<p>커넥션을 첫 지점으로 둔 이유는 이 값이 호출자 수만큼 곱해지기 때문입니다.
여러 서비스가 한 서비스로 몰리는 구조에서 호출자가 200개면 풀 4개는 피호출 쪽에서 커넥션 800개입니다.
파일 디스크립터와 소켓 메모리와 로드밸런서 커넥션 한계에 그대로 걸립니다.
단일 인스턴스에서 「풀을 늘리면 된다」로 끝나는 판단이 규모에서 뒤집히는 자리입니다.</p>

<p><strong>측정에서 남는 것은 값이 아니라 판정 순서입니다.</strong>
9,000 req/s 는 이 노트북의 이 구성에서 나온 값이고 다른 환경에 그대로 쓰이지 않습니다.
요청률을 고정해 부하를 걸고 달성 요청률과 버린 요청으로 상한을 찾은 뒤 꼬리에서 신호를 읽는 순서는 환경이 바뀌어도 남습니다.
같은 순서를 DB 커넥션이나 스레드 풀에 그대로 적용할 수 있습니다.</p>

<p>범위를 적습니다.
단일 호스트라 왕복 지연이 거의 0이고 데이터 계층이 인메모리라 대기 시간이 짧습니다.
풀 1개는 실무 설정이 아니므로 절대 처리량보다 배수를 봐야 합니다.
회차는 구성마다 하나이고 반복 측정이 없습니다.</p>

<p>전체 측정 설계와 gRPC 를 포함한 9개 지표 비교는 <a href="/posts/grpc-vs-rest-msa-adoption/">MSA 는 왜 gRPC 를 쓰는가</a> 에 있습니다.
측정 코드는 <a href="https://github.com/idean3885/grpc-vs-rest-lab">grpc-vs-rest-lab</a> 에 정리해 두었습니다.</p>

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="개발 기록" /><category term="부하테스트" /><category term="k6" /><category term="커넥션 풀" /><category term="HTTP/1.1" /><category term="HTTP/2" /><category term="성능 측정" /><summary type="html"><![CDATA[동시 사용자를 고정해 부하를 걸면 상한이 보이지 않습니다. 요청률을 고정하고 버린 요청을 집계해 커넥션 1개당 REST 상한을 찾은 기록입니다. 포화 신호는 중앙값이 아니라 꼬리 지연에 먼저 나타났습니다.]]></summary></entry><entry><title type="html">OpenTelemetry 적용: 요청 단위 추적성을 확보하기까지</title><link href="https://blog.idean.me/posts/request-level-traceability/" rel="alternate" type="text/html" title="OpenTelemetry 적용: 요청 단위 추적성을 확보하기까지" /><published>2026-08-15T18:15:00+09:00</published><updated>2026-08-28T11:47:00+09:00</updated><id>https://blog.idean.me/posts/request-level-traceability</id><content type="html" xml:base="https://blog.idean.me/posts/request-level-traceability/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
<strong>OpenTelemetry 로 요청 단위 식별자를 전파해 오류가 난 지점의 <code class="language-plaintext highlighter-rouge">trace_id</code> 하나로 전 구간을 확인합니다.</strong><br />
사람이든 AI 든 같은 값 하나로 들어가므로 로그를 서비스별로 모아 정리할 일이 없습니다.</p>
</blockquote>

<h2 id="1-문제-요청-하나를-특정할-방법이-없었습니다">1. 문제: 요청 하나를 특정할 방법이 없었습니다</h2>

<p>고객사마다 클러스터를 구축해 납품하는 B2B 제품을 MSA 구조로 운영하고 있습니다.
요청 하나가 지나는 경로는 이렇습니다.</p>

<pre><code class="language-mermaid">flowchart LR
  C(["클라이언트"])
  subgraph K8S["Kubernetes 클러스터"]
    direction LR
    GW["gateway"]
    subgraph BE["backend services"]
      direction TB
      S1["service-a"]
      S2["service-b"]
      S3["…"]
    end
  end
  C --&gt; GW --&gt; S1
  S1 --&gt; S2
  S1 --&gt; S3
</code></pre>

<p>장애나 문의는 보고를 거쳐 조치로 넘어옵니다.
조치는 에러 로그를 찾는 데서 시작하는데, 여기서 셋에 걸렸습니다.</p>

<table>
  <thead>
    <tr>
      <th>걸리는 지점</th>
      <th>왜</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>어느 요청인지 특정할 수 없음</td>
      <td>같은 시각에 다른 사용자 요청이 섞여 있어 타임스탬프만으로는 한 건을 고를 수 없습니다</td>
    </tr>
    <tr>
      <td>필드로 조회할 수 없음</td>
      <td>로그가 평문이라 정규식으로 줄 전체를 검색하는 수밖에 없습니다</td>
    </tr>
    <tr>
      <td>역추적할 연결이 없음</td>
      <td>조사는 에러 로그에서 시작해 거슬러 올라가는데, 그 로그에 같은 요청의 앞뒤 구간으로 이어지는 값이 없습니다</td>
    </tr>
  </tbody>
</table>

<p>로그는 아래와 같은 포맷으로 수집됩니다. 같은 시각대에 두 사용자의 요청이 섞여 있습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>14:55:52.676 F {"level":"INFO","requestId":"a1f2","module":"gateway","msg":"POST /v1/volumes"}
14:55:52.681 F {"level":"INFO","requestId":"77c0","module":"gateway","msg":"GET /v1/instances"}
14:55:52.694 F {"level":"INFO","requestId":"a1f2","module":"service-a","msg":"volume create accepted"}
14:55:52.755 F {"level":"ERROR","requestId":"a1f2","module":"service-b","msg":"upstream call timeout"}
14:55:52.802 F {"level":"ERROR","module":"service-c","msg":"scheduled cleanup failed"}
14:55:52.913 F {"level":"INFO","requestId":"a1f2","module":"gateway","msg":"500 response"}
</code></pre></div></div>

<p>오류가 두 건입니다. 넷째 줄은 사용자 요청에서, 다섯째 줄은 스케줄 배치에서 났습니다.</p>

<p><code class="language-plaintext highlighter-rouge">requestId</code> 가 있는데 왜 부족한지를 봅니다.
이 값은 gateway 가 헤더로 실어 주고 각 서비스가 받아서 자기 로그에 기록합니다.
<code class="language-plaintext highlighter-rouge">a1f2</code> 로 걸러내면 사용자 요청 쪽 줄은 남습니다. 거기까지입니다.</p>

<table>
  <thead>
    <tr>
      <th>한계</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>gateway 를 거치지 않는 작업</td>
      <td>스케줄 배치는 외부 요청 없이 돌아 <code class="language-plaintext highlighter-rouge">requestId</code> 가 붙지 않습니다. 다섯째 줄을 묶을 값이 없습니다</td>
    </tr>
    <tr>
      <td>관계가 없는 평면 식별자</td>
      <td><code class="language-plaintext highlighter-rouge">service-a</code> 가 <code class="language-plaintext highlighter-rouge">service-b</code> 를 부른 것인지 둘 다 gateway 가 부른 것인지 로그로는 구분되지 않습니다</td>
    </tr>
    <tr>
      <td>구간 소요가 없음</td>
      <td>각 줄은 시각 한 점입니다. 어디서 시간을 쓴 것인지 알려면 사람이 앞뒤 줄을 짝지어야 합니다</td>
    </tr>
  </tbody>
</table>

<p>그래서 오류 줄을 찾아도 그 요청이 어디를 거쳐 왔고 어디서 시간을 쓴 것인지는 알 수 없습니다.</p>

<h2 id="2-해결방안-모색">2. 해결방안 모색</h2>

<h3 id="필요한-것을-먼저-적었습니다">필요한 것을 먼저 적었습니다</h3>

<p>도구를 고르기 전에 조건을 적었습니다.
결과적으로 이 순서가 이후 판단을 전부 결정했습니다.</p>

<table>
  <thead>
    <tr>
      <th>조건</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>요청 단위 식별자</td>
      <td>사용자와 시각만으로는 한 건을 특정할 수 없습니다</td>
    </tr>
    <tr>
      <td>서비스를 가로지르는 전파</td>
      <td>경로에 안 보이는 구간이 하나라도 있으면 거기서 추적이 끊깁니다</td>
    </tr>
    <tr>
      <td>구현 방식과 무관</td>
      <td>서비스마다 스택이 달라 특정 런타임 전용 방식으로는 경로 전체를 커버할 수 없습니다</td>
    </tr>
    <tr>
      <td>폐쇄망 동작</td>
      <td>고객사 클러스터에 구축하는 제품이라 폐쇄망에 납품됩니다</td>
    </tr>
    <tr>
      <td>조치 시작 시점까지 보존</td>
      <td>보고를 받아 조치를 시작할 때 그 요청이 남아 있어야 합니다</td>
    </tr>
  </tbody>
</table>

<h3 id="후보는-셋이었습니다">후보는 셋이었습니다</h3>

<table>
  <thead>
    <tr>
      <th>후보</th>
      <th>판정</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SaaS 형 상용 APM</td>
      <td>폐쇄망 납품이라 후보에서 제외됩니다</td>
    </tr>
    <tr>
      <td>특정 런타임 전용 APM</td>
      <td>대상 런타임 밖의 서비스를 커버하지 못해 두 번째 조건에 미달합니다</td>
    </tr>
    <tr>
      <td>로그 포맷 통일 + 조회 규칙</td>
      <td>동작은 합니다. 다만 서비스를 연결하는 일을 사람이 계속 해야 합니다</td>
    </tr>
  </tbody>
</table>

<p>세 번째는 실제로 해 본 적이 있는 방식입니다.</p>

<h3 id="4년-전에도-같은-조건이었습니다">4년 전에도 같은 조건이었습니다</h3>

<p>2022년 삼성카드 통합플랫폼 모니모에서 어뷰징 증적을 만들 때 세 번째 방식을 썼습니다.
Spring MDC 로 고객번호를 로그에 주입하고 포맷을 공통화한 뒤 Kibana KQL 템플릿으로 조회했습니다.
동작은 했습니다. 다만 추적 단위가 요청이 아니라 사용자와 기간이었고 서비스를 연결하는 일은 끝까지 사람이 했습니다.</p>

<p>바꿔야 할 것은 <strong>단위</strong>(사용자 → 요청)와 <strong>연결 주체</strong>(사람 → 데이터)였습니다.</p>

<h3 id="여기서-opentelemetry-를-알게-됐습니다">여기서 OpenTelemetry 를 알게 됐습니다</h3>

<p>찾던 것은 “요청 단위 식별자를 정의하고 구현 방식과 무관하게 전파하는 방법” 이었습니다. 그 자리에 이미 규격이 있었습니다.</p>

<p>자체 정의 대신 이 규격을 고른 이유는 유지 주체가 우리가 아니라는 점입니다.
OpenTelemetry 는 2026년 5월 CNCF 졸업 프로젝트가 됐고 프로젝트 속도는 쿠버네티스 다음입니다.
우리가 떠나도 규격은 남습니다.</p>

<p>OpenTelemetry 가 다루는 신호는 trace·metric·log 셋입니다.
메트릭은 인프라 팀이 맡고 있어 이번에 적용한 것은 추적성 확보에 필요한 <strong>trace</strong> 까지입니다.</p>

<p>용어는 셋만 알면 충분합니다.</p>

<table>
  <thead>
    <tr>
      <th>용어</th>
      <th>뜻</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>trace</td>
      <td>요청 하나의 전체 여정. <code class="language-plaintext highlighter-rouge">trace_id</code> 로 식별합니다</td>
    </tr>
    <tr>
      <td>span</td>
      <td>그 여정 안의 한 구간. 고유 <code class="language-plaintext highlighter-rouge">span_id</code> 를 갖고 같은 <code class="language-plaintext highlighter-rouge">trace_id</code> 로 묶입니다</td>
    </tr>
    <tr>
      <td>전파</td>
      <td>서비스 호출 시 <code class="language-plaintext highlighter-rouge">traceparent</code> 헤더로 두 id 를 전달해 다음 서비스가 자식 span 을 연결합니다</td>
    </tr>
  </tbody>
</table>

<p>4년 전에는 이 규격을 몰랐고 수집 계층을 담당하지도 않았습니다.
그래서 식별자도 조회 규칙도 직접 정의했습니다.
구조로는 같은 일인데 <strong>누가 그 정의를 유지하는가</strong>가 달랐습니다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>자체 정의 (4년 전)</th>
      <th>표준 규격 (지금)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>식별자 형식</td>
      <td>우리가 정하고 우리가 문서화</td>
      <td>W3C Trace Context 가 정의</td>
    </tr>
    <tr>
      <td>전파 방법</td>
      <td>서비스마다 합의</td>
      <td><code class="language-plaintext highlighter-rouge">traceparent</code> 헤더 하나</td>
    </tr>
    <tr>
      <td>새 서비스가 들어올 때</td>
      <td>우리 규칙을 알려 줘야 함</td>
      <td>그 스택의 계측 라이브러리를 넣으면 끝</td>
    </tr>
    <tr>
      <td>저장·조회 도구 교체</td>
      <td>조회 규칙을 다시 작성</td>
      <td>계측은 그대로. 뒤만 교체</td>
    </tr>
  </tbody>
</table>

<h2 id="3-적용">3. 적용</h2>

<h3 id="trace_id-를-서비스마다-전파했습니다"><code class="language-plaintext highlighter-rouge">trace_id</code> 를 서비스마다 전파했습니다</h3>

<p>앱 쪽은 의존성과 설정입니다.</p>

<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 트레이스 계측 + OTLP export</span>
<span class="n">implementation</span> <span class="s1">'io.micrometer:micrometer-tracing-bridge-otel'</span>
<span class="n">implementation</span> <span class="s1">'io.opentelemetry:opentelemetry-exporter-otlp'</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 전송할 환경에만 둡니다</span>
<span class="na">management</span><span class="pi">:</span>
  <span class="na">otlp</span><span class="pi">:</span>
    <span class="na">tracing</span><span class="pi">:</span>
      <span class="na">endpoint</span><span class="pi">:</span> <span class="s">http://collector:4318/v1/traces</span>
</code></pre></div></div>

<p><strong>endpoint 를 지정한 환경만 전송하고 미지정 환경은 exporter 가 생성되지 않습니다.</strong>
게이트를 따로 두지 않아도 다른 환경에 영향이 없는 이유입니다.</p>

<p>구조화 로깅은 Spring Boot 3.4 부터 <code class="language-plaintext highlighter-rouge">logging.structured.format</code> 설정만으로 됩니다.
그 이상 버전을 쓰고 있어 로그 포맷과 전송 모두 설정으로 끝났습니다.</p>

<p>인프라 쪽은 조건부 서브차트로 구성했습니다.
로그는 필수이고 트레이스와 Collector 는 게이트로 두어 끄면 렌더링되지 않고 워크로드도 생기지 않습니다.</p>

<h3 id="로그는-json-으로-통일했습니다">로그는 JSON 으로 통일했습니다</h3>

<p><code class="language-plaintext highlighter-rouge">trace_id</code> 를 실어도 로그가 평문이면 그 값으로 조회할 수 없습니다.
그래서 전 서비스의 로그 포맷을 JSON 으로 통일했습니다.</p>

<p>부수 효과가 하나 있었습니다.
수집기(fluent-bit)가 앱 로그 포맷을 알고 파싱하던 결합이 끊겼습니다.
앱이 구조를 갖춰 내보내면 수집기는 그대로 전달하고 해석은 조회 시점의 몫이 됩니다.
앱이 로그를 바꿀 때마다 수집기 설정을 함께 고치던 일이 없어집니다.</p>

<h3 id="샘플링은-넣지-않았습니다">샘플링은 넣지 않았습니다</h3>

<p>head 기준 100% 이고 Collector tail 샘플링도 없습니다.
tail 샘플링은 트래픽이 많은 환경에서 “다 저장하면 비싸니 에러와 느린 것만 남기는” 장치입니다.
고객사당 트래픽이 크지 않은 우리 서비스에서는 다 저장해도 양이 적어 그 장치가 필요하지 않습니다.</p>

<p>전수 수집이 두 가지를 함께 성립시킵니다.</p>

<table>
  <thead>
    <tr>
      <th>100% 라서 되는 것</th>
      <th>왜</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>로그와 트레이스의 링크가 끊기지 않음</td>
      <td>로그만 100% 로 두고 트레이스를 낮추면 로그의 <code class="language-plaintext highlighter-rouge">trace_id</code> 상당수가 백엔드에 없는 트레이스를 참조합니다</td>
    </tr>
    <tr>
      <td>사후에 아무 요청이나 꺼내 볼 수 있음</td>
      <td>조사는 보고를 받은 뒤에 시작하므로 어느 요청이 대상이 될지 미리 알 수 없습니다. 샘플링은 그때 선택되지 않은 요청을 다시 볼 수 없게 합니다</td>
    </tr>
  </tbody>
</table>

<p>보존도 같은 이유로 늘렸습니다.
초기 보존이 24시간이었는데 보고와 조치 시작 사이의 시간을 감안하면 짧습니다.
100% 로 2주를 담아도 볼륨 용량 안에 들어오므로 답은 샘플링이 아니라 보존 상향이었습니다.</p>

<h2 id="4-결과">4. 결과</h2>

<p>문제로 적었던 세 가지가 어떻게 됐는지 그대로 대응시킵니다.</p>

<table>
  <thead>
    <tr>
      <th>문제</th>
      <th>지금</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>어느 요청인지 특정할 수 없음</td>
      <td><code class="language-plaintext highlighter-rouge">trace_id</code> 하나로 그 요청의 경로 전체를 시간순으로 봅니다</td>
    </tr>
    <tr>
      <td>필드로 조회할 수 없음</td>
      <td>로그 레벨·모듈·<code class="language-plaintext highlighter-rouge">trace_id</code> 를 필드로 조회합니다</td>
    </tr>
    <tr>
      <td>역추적할 연결이 없음</td>
      <td>에러 로그의 <code class="language-plaintext highlighter-rouge">trace_id</code> 로 같은 요청의 전 구간을 확인합니다. 전수 수집에 보존 2주라 조치를 시작할 때 그 요청이 남아 있습니다</td>
    </tr>
  </tbody>
</table>

<p>JWT 클레임의 회원 번호도 로그 컨텍스트에 싣고 있습니다.
발생 시각과 회원 번호로 요청을 특정하고 거기서 <code class="language-plaintext highlighter-rouge">trace_id</code> 로 나머지 구간을 따라갑니다.
4년 전 MDC 로 만든 사용자 추적과 이번 요청 추적이 여기서 합쳐집니다.</p>

<h3 id="분석을-ai-에게-맡길-때-값이-가장-크게-나왔습니다">분석을 AI 에게 맡길 때 값이 가장 크게 나왔습니다</h3>

<p>로그는 Loki 스택으로 수집하고 있어 에이전트에게 조회 권한을 주면 그대로 읽습니다.
<strong>추적성을 확보하고 나서 분석 속도가 올라갔고 잘못 판단하는 일이 줄었습니다.</strong></p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>평문 + 사람이 정리하는 구조</th>
      <th>JSON + <code class="language-plaintext highlighter-rouge">trace_id</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>에이전트에게 주는 것</td>
      <td>서비스별로 사람이 모아 정리한 로그</td>
      <td><code class="language-plaintext highlighter-rouge">trace_id</code> 하나</td>
    </tr>
    <tr>
      <td>에이전트가 하는 일</td>
      <td>포맷이 제각각인 텍스트에서 관계를 추측</td>
      <td>필드로 질의하고 시간순으로 정렬된 전 구간을 읽음</td>
    </tr>
    <tr>
      <td>누락되는 구간</td>
      <td>사람이 모으지 않은 서비스</td>
      <td>없음 (전수 수집)</td>
    </tr>
  </tbody>
</table>

<p>오류가 줄어드는 지점이 여기입니다.
에이전트가 관계를 추측하면 그럴듯한 오답이 나옵니다. 추측할 자리를 없애면 그 오답도 없어집니다.</p>

<p>샘플링을 넣지 않은 판단이 여기서도 작용합니다.
「그 요청은 수집되지 않았습니다」 라는 답이 나오면 맡길 수 있는 범위도 반쪽이 됩니다.</p>

<h2 id="5-마치며">5. 마치며</h2>

<p>4년 전에도 같은 문제를 풀었지만 그때는 식별자와 조회 규칙을 직접 정의했습니다.
동작은 했고 성과도 났습니다. 다만 그 정의는 우리 팀 안에서만 통했고 서비스가 늘면 규칙도 같이 늘어나는 구조였습니다.</p>

<p>이번에는 클라우드 네이티브 환경에서 통용되는 표준을 찾아 같은 자리를 채웠습니다.
관측성 수집을 OpenTelemetry 규격에 맞추자 추적성이 따라왔습니다.
<strong>같은 문제를 두 번 푸는 동안 달라진 것은 문제 해결 여부가 아니라 그 해법이 조직 밖에서도 통하는가였습니다.</strong></p>

<p>여기까지가 이번에 적용한 범위입니다.
메트릭에서 트레이스로 가는 역추적은 아직 적용하지 않았습니다.
exemplar 를 추가하면 지표의 이상 구간에서 바로 그 요청으로 내려갈 수 있습니다.
지금은 로그에서 트레이스로 가는 한 방향입니다.</p>

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="개발 기록" /><category term="OpenTelemetry" /><category term="Loki" /><category term="분산 트레이싱" /><category term="구조화 로그" /><category term="관측성" /><category term="kubernetes" /><summary type="html"><![CDATA[장애 문의가 오면 서비스마다 로그를 따로 열어 타임스탬프를 대조했습니다. 요청 하나를 특정할 식별자가 없었기 때문입니다. 조건을 먼저 적고 후보를 좁히다 OpenTelemetry 규격을 찾은 과정을 정리합니다.]]></summary></entry><entry><title type="html">지침을 영어로 바꾸면 토큰이 줄어들까: 예측 25%, 실측 1%</title><link href="https://blog.idean.me/posts/instruction-language-token-measurement/" rel="alternate" type="text/html" title="지침을 영어로 바꾸면 토큰이 줄어들까: 예측 25%, 실측 1%" /><published>2026-08-14T20:10:00+09:00</published><updated>2026-08-14T20:10:00+09:00</updated><id>https://blog.idean.me/posts/instruction-language-token-measurement</id><content type="html" xml:base="https://blog.idean.me/posts/instruction-language-token-measurement/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
지침 문서를 영어로 옮겨 토큰을 아끼려다 접었습니다. 문서 파일은 4분의 1이 줄었는데 실제 비용은 1%도 줄지 않았습니다.</p>

  <ul>
    <li>파일 크기와 실제 비용은 같은 값이 아닙니다. 지침은 세션마다 캐시에 한 번 올라간 뒤 10분의 1 단가로 읽히고 그마저도 전체 컨텍스트의 일부입니다.</li>
    <li>비용이든 지시 준수든 손댈 곳은 문서의 언어가 아니라 문서에 든 지시였습니다.</li>
  </ul>
</blockquote>

<h2 id="지침이-쌓이면서-생긴-질문">지침이 쌓이면서 생긴 질문</h2>

<p>Claude Code 에서 쓰는 에이전트 플러그인을 1년 가까이 키워 왔습니다. 스킬이 늘고 규칙이 쌓이면서 마크다운과 JSON 이 63개, 125,311 토큰이 됐습니다. 한국어 글자 비중은 62.5%입니다.</p>

<p>여기서 “지침을 영어로 쓰면 토큰이 준다”는 이야기를 접했습니다. 근거는 명확해 보였습니다. 한국어는 같은 정보를 담을 때 영어의 약 2.36배 토큰을 씁니다. 단어당 토큰 수는 영어가 1.2~1.4, 한국어가 1.5~2.3입니다.</p>

<p>한국어 문서 절반 이상을 영어로 옮기면 절반 가까이 줄어든다는 계산이 나옵니다. 계획도 세웠습니다. 절차와 판정 로직은 영어로 옮기고 사람이 읽는 README 와 설계 문서는 한국어로 남기는 방식입니다.</p>

<h2 id="문서-기준-측정-25">문서 기준 측정: 25%</h2>

<p>옮기기 전에 측정하기로 했습니다. 레포에서 성격이 다른 세 군데를 골라 뜻이 같은 영어로 다시 썼습니다. 조건은 정보량을 줄이지 않는 것입니다. 항목을 빼거나 조건을 뭉뚱그리면 절감이 아니라 삭제이므로, 표·코드·파일 경로를 그대로 두고 문장만 옮겼습니다.</p>

<p>실제로 옮긴 한 항목입니다.</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">-</span> <span class="gs">**GATE 0 (대외비 가드, 필수)**</span>: 퍼블릭 리모트 대상 텍스트(이슈·PR 본문/제목/코멘트,
  커밋 메시지, 릴리즈 노트) 생성 직전 references/confidential-guard.md 적용.
  히트 시 하드 차단. 가이드 단계에서 키워드를 피해 작성하고,
  <span class="sb">`scripts/pre-tool-use.mjs`</span> 훅이 최종 재검증한다.
</code></pre></div></div>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">-</span> <span class="gs">**GATE 0 (confidentiality guard, required)**</span>: Apply references/confidential-guard.md
  immediately before generating any text bound for a public remote (issue/PR body,
  title, comment; commit message; release note). A hit is a hard block. Write around
  the keywords during the guide step; the <span class="sb">`scripts/pre-tool-use.mjs`</span> hook re-checks
  at the end.
</code></pre></div></div>

<p>103 토큰에서 81 토큰, 21.4% 줄었습니다. 두 파일 경로만 해도 12 토큰이 양쪽에 똑같이 남아 있습니다. 세 군데의 결과입니다.</p>

<table>
  <thead>
    <tr>
      <th>대상</th>
      <th>한국어</th>
      <th>영어</th>
      <th>절감</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>게이트 절차 (스킬 본문)</td>
      <td>490</td>
      <td>376</td>
      <td>23.3%</td>
    </tr>
    <tr>
      <td>정책 서술 + 표 2개</td>
      <td>304</td>
      <td>217</td>
      <td>28.6%</td>
    </tr>
    <tr>
      <td>톤 규칙 3항</td>
      <td>285</td>
      <td>207</td>
      <td>27.4%</td>
    </tr>
  </tbody>
</table>

<p>평균 25%였습니다. 기대했던 50%의 절반입니다.</p>

<p>차이의 원인은 문서 구성에 있었습니다. 2.36배라는 배율은 순수 산문을 옮겼을 때 나오는 값입니다. 지침 문서는 순수 산문이 아닙니다.</p>

<p>표 구분자, 파일 경로, 코드 블록, 명령어, 스킬 이름, 마크다운 문법은 번역 전후 모두 ASCII 로 남습니다. 번역해도 그대로 남는 이 부분이 배율을 끌어내립니다. 여기까지는 기대보다 작을 뿐 방향은 맞았습니다.</p>

<h2 id="파일-크기와-실제-비용의-간격">파일 크기와 실제 비용의 간격</h2>

<p>문제는 25%가 무엇에 대한 25%인가입니다. 파일의 토큰 수는 디스크 위의 값입니다. 실제로 지불하는 것은 요청마다 모델에 들어가는 입력 토큰이고 둘 사이에는 두 단계가 끼어 있습니다.</p>

<p>첫 번째는 로딩 시점입니다. 스킬은 필요할 때만 읽히므로 레포 총량이 그대로 컨텍스트에 올라가지 않습니다. 세션 성격별로 측정했습니다.</p>

<table>
  <thead>
    <tr>
      <th>세션 유형</th>
      <th>로딩되는 것</th>
      <th>토큰</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>이슈 플로우</td>
      <td>글로벌·레포 지침, 플로우 스킬, 단계 가이드, 대외비 가드</td>
      <td>17,393</td>
    </tr>
    <tr>
      <td>콘텐츠 작성</td>
      <td>글로벌 지침, 작성·검증 스킬, 문체 규칙 6종</td>
      <td>34,403</td>
    </tr>
  </tbody>
</table>

<p>콘텐츠 세션 34,403 중 25,572, 그러니까 74%가 문체 규칙 하나였습니다. 그런데 이 파일은 한국어 문장을 판정 대상으로 삼는 규칙 모음입니다. 규칙 본문에 한국어 예시와 정규식이 들어 있어 옮기면 판정 대상은 한국어인데 규칙만 영어가 됩니다. 절감 여지가 가장 큰 파일이 가장 옮기기 어려운 파일이었습니다.</p>

<p>두 번째가 더 컸습니다. 프롬프트 캐싱입니다.</p>

<h2 id="실사용-로그-측정">실사용 로그 측정</h2>

<p>Claude Code 는 세션 기록을 <code class="language-plaintext highlighter-rouge">~/.claude/projects/</code> 아래 JSONL 로 남깁니다. 각 어시스턴트 턴에 <code class="language-plaintext highlighter-rouge">usage</code> 가 붙어 있고 여기에 캐시 관련 필드가 들어 있습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">json</span><span class="p">,</span> <span class="n">glob</span>
<span class="n">totals</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">input_tokens</span><span class="sh">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="sh">"</span><span class="s">cache_creation_input_tokens</span><span class="sh">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
          <span class="sh">"</span><span class="s">cache_read_input_tokens</span><span class="sh">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="sh">"</span><span class="s">output_tokens</span><span class="sh">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">}</span>
<span class="n">turns</span> <span class="o">=</span> <span class="mi">0</span>
<span class="k">for</span> <span class="n">path</span> <span class="ow">in</span> <span class="n">glob</span><span class="p">.</span><span class="nf">glob</span><span class="p">(</span><span class="sh">"</span><span class="s">~/.claude/projects/&lt;프로젝트&gt;/*.jsonl</span><span class="sh">"</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="nf">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">"</span><span class="s">utf-8</span><span class="sh">"</span><span class="p">):</span>
        <span class="n">usage</span> <span class="o">=</span> <span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="nf">loads</span><span class="p">(</span><span class="n">line</span><span class="p">).</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">message</span><span class="sh">"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}).</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">usage</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">usage</span><span class="p">:</span>
            <span class="k">continue</span>
        <span class="n">turns</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">totals</span><span class="p">:</span>
            <span class="n">totals</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">+=</span> <span class="n">usage</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</code></pre></div></div>

<p>이 프로젝트 디렉토리의 세션 17개, 6,053턴을 집계한 결과입니다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>토큰</th>
      <th>단가</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>신규 입력</td>
      <td>109,383</td>
      <td>1.0배</td>
    </tr>
    <tr>
      <td>캐시 생성</td>
      <td>49,759,569</td>
      <td>2.0배 (1시간 TTL)</td>
    </tr>
    <tr>
      <td>캐시 읽기</td>
      <td>1,597,855,174</td>
      <td>0.1배</td>
    </tr>
  </tbody>
</table>

<p>입력 토큰의 97%가 캐시 읽기였습니다. 단가를 반영해도 입력 비용의 61.6%를 캐시 읽기가 차지합니다. 지침 문서는 세션 시작 시 한 번 캐시에 올라가고 그다음부터는 매 턴 1/10 단가로 읽힙니다.</p>

<p>여기에 컨텍스트 크기까지 더해집니다. 턴당 평균 캐시 읽기가 263,977 토큰이었습니다. 이슈 플로우 지침 17,393은 그 6.6%이고 절감분 4,300은 1.63%입니다. 나머지는 대화 이력, 읽은 파일, 도구 결과입니다.</p>

<p>지침 4,300 토큰은 세션 시작 시 한 번 캐시에 기록되고 이후 모든 턴에서 읽힙니다. 두 경로에 단가를 적용해 전체 입력 비용과 비교했습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>캐시 읽기 절감  4,300 × 6,053턴  × 0.1배 =   2,602,790
캐시 생성 절감  4,300 × 17세션   × 2.0배 =     146,200
                                            ----------
                                             2,748,990

전체 입력 비용  109,383 × 1.0
              + 49,759,569 × 2.0
              + 1,597,855,174 × 0.1        = 259,414,038

절감 비율      2,748,990 / 259,414,038     = 1.06%
</code></pre></div></div>

<p>콘텐츠 세션에도 같은 계산을 적용한 결과입니다.</p>

<table>
  <thead>
    <tr>
      <th>세션</th>
      <th>파일 기준 예측</th>
      <th>실사용 비용 기준</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>이슈 플로우</td>
      <td>25%</td>
      <td>1.06%</td>
    </tr>
    <tr>
      <td>콘텐츠 작성</td>
      <td>6%</td>
      <td>0.54%</td>
    </tr>
  </tbody>
</table>

<p>파일에서 4분의 1을 덜어내도 실제 비용은 100분의 1만 줄어듭니다.</p>

<h2 id="전환을-접은-이유">전환을 접은 이유</h2>

<p>토큰이 근거였다면 1%로는 부족합니다. 남는 근거는 영어 지시가 더 잘 지켜진다는 쪽이었습니다. 11개 과제를 비교한 연구에서 비모국어(영어) 프롬프트가 평균 1위, 혼용 2위, 모국어 3위로 나옵니다.</p>

<p>이 방향을 레포에 옮기기 전에 확인할 곳이 있었습니다. Anthropic 은 모델 세대가 바뀔 때마다 프롬프트를 어떻게 손볼지 마이그레이션 문서로 공개합니다. 지금 쓰는 세대의 항목을 열어 보니 언어는 목록에 없었습니다.</p>

<table>
  <thead>
    <tr>
      <th>문서가 지시하는 조정</th>
      <th>방향</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>자가 검증·재확인 지시</td>
      <td>삭제 (시키지 않아도 검증하므로 남겨 두면 과검증)</td>
    </tr>
    <tr>
      <td>위임 권장 지시</td>
      <td>삭제 후 상한 명시 (이전 세대보다 서브 에이전트 호출이 잦음)</td>
    </tr>
    <tr>
      <td>응답·산출물 분량</td>
      <td>명시 추가</td>
    </tr>
    <tr>
      <td>작업 범위</td>
      <td>명시 추가</td>
    </tr>
  </tbody>
</table>

<p>지시가 지켜지는 정도를 올리려면 언어를 바꾸는 게 아니라 이 목록을 맞추면 됩니다. 삭제 두 건에 추가 두 건이니 지시를 걷어내라는 방향도 아닙니다. 대조해 보니 삭제 대상은 이미 없었고 추가 대상도 들어 있었습니다. 영어 전환으로 기대하던 몫을 다른 경로가 이미 가져간 상태였습니다.</p>

<p>반대로 치르는 비용은 구체적이었습니다. 영어 시스템 프롬프트는 비영어 생성에 대한 통제력이 약해집니다. 응답과 산출물이 전부 한국어인 환경에서는 출력 언어를 따로 고정해야 합니다.</p>

<p>스킬 설명문의 한국어 트리거 키워드는 자연어 라우팅이 스킬을 찾는 단서라 지우면 라우팅이 끊깁니다. 문체 규칙은 앞서 본 이유로 옮길 수 없습니다. 1%를 얻으려고 라우팅·출력 언어·문체 규칙 세 곳에 고장 날 자리를 만드는 셈이었습니다. 전환하지 않기로 했습니다.</p>

<h2 id="남은-판단-기준">남은 판단 기준</h2>

<p>이 측정에서 남은 것은 결론보다 판단 기준이었습니다. 언어를 바꾸는 일은 같은 자료를 짧게 만들 뿐 그 자료가 매 턴 실린다는 사실을 바꾸지 않습니다. 비용을 줄이려면 물어야 할 질문은 “이 문서를 짧게 쓸 수 있는가”가 아니라 “이 문서가 매 턴 실릴 값을 하는가”입니다.</p>

<p>정확도 쪽도 같은 자리로 돌아왔습니다. 매 턴 실리는 자료를 참조 링크 뒤로 보내거나, 모델이 이미 아는 것을 지우거나, 아무것도 바꾸지 않는 지시를 걷어내는 쪽이 양도 크고 고장 위험도 없습니다.</p>

<h2 id="측정의-한계와-재현">측정의 한계와 재현</h2>

<p>두 가지 한계가 있습니다. 세션에서 Anthropic 토크나이저에 접근할 경로가 없어 GPT-4o 계열 토크나이저를 대리 지표로 썼으므로 25%는 근삿값입니다. 표본도 1인 사용 기록 17세션이라 “일반적으로 1%”가 아니라 “이 사용 패턴에서 1%”입니다. 컨텍스트가 264K까지 커지는 패턴이 전제고 짧은 세션 위주라면 지침 비중이 커져 결과가 달라집니다.</p>

<p>집계 코드는 위에 그대로 옮겼습니다. Claude Code 를 쓰고 있다면 자기 프로젝트 디렉토리에 돌려 캐시 읽기 비중과 턴당 컨텍스트를 확인할 수 있습니다. 그 두 값이 저와 다르면 이 글의 1%도 달라집니다.</p>

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

<ul>
  <li><a href="https://tonybaloney.github.io/posts/cjk-chinese-japanese-korean-llm-ai-best-practices.html">Working with Chinese, Japanese, and Korean text in Generative AI pipelines</a> (한국어 약 2.36배)</li>
  <li><a href="https://arxiv.org/html/2409.07054v1">Native vs Non-Native Language Prompting: A Comparative Analysis</a> (11개 과제 비교)</li>
  <li><a href="https://platform.claude.com/docs/en/about-claude/models/migration-guide">Claude Model Migration Guide</a> (세대별 프롬프트 조정 항목)</li>
  <li><a href="https://arxiv.org/html/2402.10962v3">Measuring and Controlling Instruction (In)Stability in Language Model Dialogs</a> (영어 시스템 프롬프트의 비영어 생성 통제력)</li>
  <li><a href="https://arxiv.org/pdf/2510.09426">KORMo: Korean Open Reasoning Model for Everyone</a> (토크나이저별 단어당 토큰 수)</li>
</ul>

<hr />

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="개발 기록" /><category term="AI 적응기" /><category term="AI" /><category term="LLM" /><category term="Claude Code" /><category term="프롬프트캐싱" /><category term="토크나이저" /><summary type="html"><![CDATA[에이전트 지침 문서를 영어로 옮기면 토큰이 준다는 이야기를 실측했습니다. 문서 기준으로는 25% 줄었지만 실사용 로그 17세션·6,053턴으로 계산한 비용 절감은 1.06%였습니다.]]></summary></entry><entry><title type="html">MSA 는 왜 gRPC 를 쓰는가: gRPC vs REST 실측</title><link href="https://blog.idean.me/posts/grpc-vs-rest-msa-adoption/" rel="alternate" type="text/html" title="MSA 는 왜 gRPC 를 쓰는가: gRPC vs REST 실측" /><published>2026-08-02T03:34:08+09:00</published><updated>2026-08-30T10:00:00+09:00</updated><id>https://blog.idean.me/posts/grpc-vs-rest-msa-adoption</id><content type="html" xml:base="https://blog.idean.me/posts/grpc-vs-rest-msa-adoption/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>문제</strong><br />
MSA 에서 서비스 간 통신을 gRPC 로 두는 이유는 보통 속도로 설명됩니다.<br />
그런데 그 근거로 “7~10배 빠르다”와 “작은 페이로드에서는 차이가 없다”가 함께 돌아다닙니다.</p>

  <p><strong>결과</strong><br />
단건 조회에서 gRPC 가 더 느렸습니다. REST 의 0.88배, 가상 스레드 REST 대비 0.76배입니다.<br />
빨라지는 구간은 다건뿐이고 1,000건에서 1.56배입니다.<br />
조건과 무관하게 남은 것은 커넥션 50배 절감과 스키마 계약이었습니다.</p>

  <p><strong>범위</strong><br />
측정 환경은 Java 21 · Spring Boot 3.5 입니다. 기준선으로 쓴 가상 스레드가 Java 21 기능이라 스레드 모델 비교는 이 스택에 묶입니다.<br />
위 배수는 DB 를 뺀 인메모리 구성에서 나온 값입니다. 프로토콜 오버헤드의 상한이고 실제 서비스에서는 DB 시간에 묻힙니다.<br />
그래서 gRPC 를 쓸지가 아니라 어떤 조건에서 이득이 남는지를 다룹니다.</p>
</blockquote>

<h2 id="1-속도-때문일까">1. 속도 때문일까?</h2>

<p>MSA 에서 서비스 간 통신을 gRPC 로 두는 이유를 찾으면 대개 속도가 먼저 나옵니다.
그런데 그 근거로 붙는 수치가 “REST 보다 7~10배 빠르다” 와 “작은 페이로드에서는 차이가 없다” 로 나뉩니다.
둘 다 맞는 말이라면 각각 맞는 조건이 서로 다른 것입니다.
그 조건을 모르면 도입 판단이 먼저 읽은 글에 끌려갑니다.</p>

<p>찾아본 결과가 예상과 달랐습니다.
gRPC 는 2015년 기술이고 Kubernetes 는 2022년부터 1급으로 다뤘습니다.
그렇게 다룰 이유가 있다는 뜻인데, <strong>10년이 지나도 채택 근거의 수치가 정리되어 있지 않다는 것은 그 이유가 속도만은 아닐 가능성을 뜻합니다.</strong>
속도라고 짚을 근거도 헤더와 필드명을 덜 실으니 전송 바이트가 줄어 빠를 것이라는 추측 수준이었습니다.</p>

<p>그래서 두 구성이 어디서 다른지부터 봤습니다.</p>

<h2 id="2-두-구성의-차이">2. 두 구성의 차이</h2>

<p>전송과 직렬화가 짝을 이룬 두 구성입니다.
항목별로 비교해 두면 뒤의 결과가 어디서 오는지 읽힙니다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>REST 구성</th>
      <th>gRPC 구성</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>전송</td>
      <td>HTTP/1.1</td>
      <td>HTTP/2</td>
    </tr>
    <tr>
      <td>직렬화</td>
      <td>JSON (자기 기술적)</td>
      <td>Protobuf (스키마 의존)</td>
    </tr>
    <tr>
      <td>커넥션당 동시 요청</td>
      <td><strong>1건</strong></td>
      <td>스트림으로 다중화</td>
    </tr>
    <tr>
      <td>페이로드에 실리는 것</td>
      <td>필드명 + 구조 + 값</td>
      <td>태그 번호 + 값</td>
    </tr>
    <tr>
      <td>계약 위반 검출 시점</td>
      <td>런타임</td>
      <td><strong>컴파일</strong> (스텁 재생성)</td>
    </tr>
    <tr>
      <td>브라우저 직접 호출</td>
      <td>가능</td>
      <td>불가 (trailer 제약)</td>
    </tr>
  </tbody>
</table>

<p><strong>커넥션당 1건</strong>이 뒤에서 결론을 뒤집는 조건입니다.
HTTP/1.1 은 한 커넥션에 요청 하나를 실어 보내고 응답을 기다립니다.
그래서 동시 요청 수만큼 커넥션이 필요합니다.
HTTP/2 는 한 커넥션 안에서 스트림을 나눠 동시에 실어 보냅니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1   동시 요청 3건 = 커넥션 3개
  conn 1   [req1] -&gt; [res1]
  conn 2   [req2] -&gt; [res2]
  conn 3   [req3] -&gt; [res3]

HTTP/2     동시 요청 3건 = 커넥션 1개
  conn 1   [req1] -&gt; [res1]   stream 1
           [req2] -&gt; [res2]   stream 2
           [req3] -&gt; [res3]   stream 3
</code></pre></div></div>

<p><strong>브라우저 직접 호출 불가</strong>는 프런트엔드 대면 구간의 제약입니다.
gRPC 는 호출 상태(<code class="language-plaintext highlighter-rouge">grpc-status</code> 성공·오류 코드)를 HTTP/2 trailer 에 싣는데, trailer 는 본문 뒤에 오는 헤더라 브라우저 <code class="language-plaintext highlighter-rouge">fetch</code> 로 읽을 수 없습니다.
그래서 항상 중간 계층(grpc-gateway 같은 변환 계층)이 필요합니다.</p>

<p>확인 대상은 표에서 나왔습니다.</p>

<ol>
  <li><strong>얼마나 빠른가</strong><br />
페이로드 크기별로 나눠 차이가 커지는 구간과 사라지는 구간을 찾습니다.</li>
  <li><strong>무엇이 빠르게 만드는가</strong><br />
Protobuf 직렬화인지 HTTP/2 다중화인지 구분합니다. 둘을 묶어 두면 “넣었는데 안 빨라졌다”의 원인을 짚을 수 없습니다.</li>
</ol>

<h2 id="3-어떻게-측정할까">3. 어떻게 측정할까?</h2>

<p>측정 조건을 후보와 함께 적습니다.
선택하지 않은 쪽을 밝히지 않으면 결과가 조건에 얼마나 매여 있는지 알 수 없습니다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>후보</th>
      <th style="text-align: center">선택</th>
      <th>사유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>기준선</td>
      <td>플랫폼 스레드 REST 만</td>
      <td style="text-align: center"> </td>
      <td>가상 스레드를 켠 REST 가 오늘의 현실적 기준선인데 그것과 비교하지 않으면 gRPC 이득이 부풀려집니다</td>
    </tr>
    <tr>
      <td> </td>
      <td>플랫폼 + 가상 둘 다</td>
      <td style="text-align: center">✅</td>
      <td>같은 빌드에서 세 구성을 측정합니다</td>
    </tr>
    <tr>
      <td>측정 지점</td>
      <td>프로토콜 직접 호출만</td>
      <td style="text-align: center"> </td>
      <td>부하 도구의 클라이언트 구현이 결과를 지배합니다</td>
    </tr>
    <tr>
      <td> </td>
      <td>직접 + 애플리케이션 경유</td>
      <td style="text-align: center">✅</td>
      <td>인용 값은 전부 경유 측정입니다</td>
    </tr>
    <tr>
      <td>데이터 계층</td>
      <td>실제 DB</td>
      <td style="text-align: center"> </td>
      <td>DB 시간이 프로토콜 차이를 덮습니다</td>
    </tr>
    <tr>
      <td> </td>
      <td>인메모리 고정</td>
      <td style="text-align: center">✅</td>
      <td>프로토콜 오버헤드의 <strong>상한</strong>을 봅니다</td>
    </tr>
    <tr>
      <td>부하 모델</td>
      <td>동시 사용자 고정</td>
      <td style="text-align: center"> </td>
      <td>서버가 느려지면 부하도 줄어 과부하 구간을 못 봅니다</td>
    </tr>
    <tr>
      <td> </td>
      <td>요청률 고정</td>
      <td style="text-align: center">✅</td>
      <td>커넥션 회차에 적용</td>
    </tr>
  </tbody>
</table>

<p>두 번째 항목은 실제로 사고가 났던 자리입니다.
<strong>부하 도구가 Protobuf 를 동적으로 파싱하는 비용 때문에 “gRPC 가 3.3배 느리다”는 잘못된 결론이 먼저 나왔습니다.</strong></p>

<h3 id="전송만-바꿨다는-근거">전송만 바꿨다는 근거</h3>

<p>헥사고날 아키텍처라 도메인과 유스케이스는 그대로 두고 아웃포트 어댑터만 바꿔 측정했습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">interface</span> <span class="nc">ProfileLookupPort</span> <span class="o">{</span>
  <span class="nc">Transport</span> <span class="nf">transport</span><span class="o">();</span>          <span class="c1">// 이 구현이 담당하는 전송 방식</span>
  <span class="nc">LookupResult</span> <span class="nf">list</span><span class="o">(</span><span class="kt">int</span> <span class="n">size</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="c1">// REST 구현: 선언형 HTTP 클라이언트에 위임</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ProfileLookupRestPortImpl</span> <span class="kd">implements</span> <span class="nc">ProfileLookupPort</span> <span class="o">{</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ProfileHttpClient</span> <span class="n">profileHttpClient</span><span class="o">;</span>
  <span class="kd">public</span> <span class="nc">Transport</span> <span class="nf">transport</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="nc">Transport</span><span class="o">.</span><span class="na">REST</span><span class="o">;</span> <span class="o">}</span>
<span class="o">}</span>

<span class="c1">// gRPC 구현: 생성된 스텁에 위임</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ProfileLookupGrpcPortImpl</span> <span class="kd">implements</span> <span class="nc">ProfileLookupPort</span> <span class="o">{</span>
  <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ProfileServiceGrpc</span><span class="o">.</span><span class="na">ProfileServiceBlockingStub</span> <span class="n">profileStub</span><span class="o">;</span>
  <span class="kd">public</span> <span class="nc">Transport</span> <span class="nf">transport</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="nc">Transport</span><span class="o">.</span><span class="na">GRPC</span><span class="o">;</span> <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="커넥션-조건을-맞춘-근거">커넥션 조건을 맞춘 근거</h3>

<p>gRPC 채널은 커넥션과 스트림 다중화를 스스로 관리합니다.
REST 쪽을 기본 설정으로 두면 gRPC 에만 재사용 이득이 생깁니다.
그래서 양쪽 다 명시했습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// REST: 커넥션 풀 상한을 설정값으로 고정</span>
<span class="nc">PoolingHttpClientConnectionManagerBuilder</span><span class="o">.</span><span class="na">create</span><span class="o">()</span>
    <span class="o">.</span><span class="na">setMaxConnTotal</span><span class="o">(</span><span class="n">properties</span><span class="o">.</span><span class="na">maxConnections</span><span class="o">())</span>
    <span class="o">.</span><span class="na">setMaxConnPerRoute</span><span class="o">(</span><span class="n">properties</span><span class="o">.</span><span class="na">maxConnections</span><span class="o">())</span>
    <span class="o">.</span><span class="na">build</span><span class="o">();</span>

<span class="c1">// gRPC: 채널 하나를 애플리케이션 수명 동안 재사용</span>
<span class="nc">ManagedChannelBuilder</span><span class="o">.</span><span class="na">forAddress</span><span class="o">(</span><span class="n">properties</span><span class="o">.</span><span class="na">grpcHost</span><span class="o">(),</span> <span class="n">properties</span><span class="o">.</span><span class="na">grpcPort</span><span class="o">())</span>
    <span class="o">.</span><span class="na">usePlaintext</span><span class="o">()</span>   <span class="c1">// 양쪽 모두 평문. TLS 핸드셰이크가 차이에 섞이지 않게</span>
    <span class="o">.</span><span class="na">build</span><span class="o">();</span>
</code></pre></div></div>

<h2 id="4-페이로드-크기별-결과">4. 페이로드 크기별 결과</h2>

<p>Java 21 · 50 VU · 30초 · 단일 호스트(M2 Pro 12코어 · 16GB)입니다.
상류는 호출 측이 측정한 피호출 서비스 왕복 시간입니다.
워밍업은 별도로 실행하고 회차마다 서버를 새로 띄웠습니다.
앞선 부하가 늘려놓은 스레드가 남은 JVM 에서 측정하면 같은 조건이 12% 낮게 나옵니다.</p>

<p>가상 스레드는 OS 스레드를 점유하지 않고 JVM 이 스케줄하는 경량 실행 단위입니다.
같은 동시 요청 50에서 플랫폼 스레드는 38~50개까지 늘어나는데 가상 스레드는 캐리어 12개로 고정됐습니다.
그래서 스레드 수가 병목인 구간에서만 이득이 나옵니다.</p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>REST 플랫폼</th>
      <th>REST 가상</th>
      <th>gRPC</th>
      <th>우위</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>처리량 (응답 1건)</td>
      <td>27,030 req/s</td>
      <td><strong>31,136 req/s</strong></td>
      <td>23,698 req/s</td>
      <td>REST 가상</td>
    </tr>
    <tr>
      <td>처리량 (응답 1,000건)</td>
      <td>8,153 req/s</td>
      <td>8,085 req/s</td>
      <td><strong>12,685 req/s</strong></td>
      <td>gRPC</td>
    </tr>
    <tr>
      <td>상류 p95 (응답 1건)</td>
      <td>1.33 ms</td>
      <td><strong>1.18 ms</strong></td>
      <td>2.48 ms</td>
      <td>REST 가상</td>
    </tr>
    <tr>
      <td>상류 p95 (응답 1,000건)</td>
      <td>8.36 ms</td>
      <td>9.84 ms</td>
      <td><strong>4.96 ms</strong></td>
      <td>gRPC</td>
    </tr>
    <tr>
      <td>상류 max (응답 1건)</td>
      <td>119.4 ms</td>
      <td><strong>35.5 ms</strong></td>
      <td>125.0 ms</td>
      <td>REST 가상</td>
    </tr>
    <tr>
      <td>상류 max (응답 1,000건)</td>
      <td>335.7 ms</td>
      <td>239.6 ms</td>
      <td><strong>50.8 ms</strong></td>
      <td>gRPC</td>
    </tr>
    <tr>
      <td>TCP 커넥션</td>
      <td>50개</td>
      <td>50개</td>
      <td><strong>1개</strong></td>
      <td>gRPC</td>
    </tr>
    <tr>
      <td>요청당 CPU 시간 (응답 1,000건)</td>
      <td>360.0 µs</td>
      <td>337.9 µs</td>
      <td><strong>203.5 µs</strong></td>
      <td>gRPC</td>
    </tr>
    <tr>
      <td>요청당 전송량</td>
      <td>기준</td>
      <td>기준</td>
      <td><strong>약 2.5배 절감</strong></td>
      <td>gRPC</td>
    </tr>
  </tbody>
</table>

<p>9개 지표 중 gRPC 가 6개, 가상 스레드 REST 가 3개에서 우위입니다.
가상 스레드가 우위인 3개는 모두 단건이고, gRPC 가 우위인 6개는 다건 3개와 페이로드 크기에 무관한 3개(커넥션·CPU·전송량)입니다.
<strong>플랫폼 스레드 REST 는 한 항목도 없습니다.</strong></p>

<p>배수로 보면 단건은 플랫폼 대비 0.88배, 가상 대비 0.76배입니다.
<strong>기준선을 플랫폼으로 잡으면 gRPC 이득이 커 보입니다.</strong>
1,000건은 1.56배와 1.57배로 기준선과 무관한데, 직렬화 비용은 스레드 모델로 줄지 않기 때문입니다.</p>

<p><strong>가상 스레드는 단건에서만 이득입니다.</strong>
1건에서 처리량 1.15배, 최대 지연 3.4배 개선인데 1,000건에서는 0.99배로 사라집니다.
병목이 스레드 모델에서 직렬화 CPU 로 옮겨가면 손댈 곳이 없습니다.</p>

<h2 id="5-커넥션이-모자라면-뒤집힌다">5. 커넥션이 모자라면 뒤집힌다</h2>

<p>앞 회차는 풀 200 에 동시 요청 50 이라 커넥션이 병목에서 멀어서, 50개 대 1개라는 차이가 지연에도 처리량에도 나타나지 않았습니다.
그래서 이번에는 커넥션을 제한한 상태로 비교했습니다.
부하를 올리면 CPU 포화와 도구 경합이 섞이므로 <strong>부하는 그대로 두고 커넥션 예산만 양쪽 1개로 낮췄습니다.</strong></p>

<p>부하는 k6 로 걸었고 실행 모델은 요청률 고정입니다.
회차가 15초로 짧아 p99 이상은 표본이 부족하므로 p95 까지만 봅니다.</p>

<table>
  <thead>
    <tr>
      <th>요청률 목표</th>
      <th>전송</th>
      <th>달성 요청률</th>
      <th>상류 p50</th>
      <th>상류 p95</th>
      <th>버린 요청</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>6,400</td>
      <td>REST</td>
      <td>6,316 req/s</td>
      <td>72 µs</td>
      <td>1,795 µs</td>
      <td>1.3%</td>
    </tr>
    <tr>
      <td>6,400</td>
      <td>gRPC</td>
      <td>6,373 req/s</td>
      <td>79 µs</td>
      <td><strong>245 µs</strong></td>
      <td>0.4%</td>
    </tr>
    <tr>
      <td>9,600</td>
      <td>REST</td>
      <td>8,812 req/s</td>
      <td><strong>33,394 µs</strong></td>
      <td>61,987 µs</td>
      <td>8.1%</td>
    </tr>
    <tr>
      <td>9,600</td>
      <td>gRPC</td>
      <td>9,494 req/s</td>
      <td><strong>129 µs</strong></td>
      <td>1,728 µs</td>
      <td>1.1%</td>
    </tr>
    <tr>
      <td>12,800</td>
      <td>REST</td>
      <td>9,043 req/s</td>
      <td>40,396 µs</td>
      <td>54,690 µs</td>
      <td><strong>29%</strong></td>
    </tr>
    <tr>
      <td>12,800</td>
      <td>gRPC</td>
      <td><strong>12,625 req/s</strong></td>
      <td>156 µs</td>
      <td>3,806 µs</td>
      <td>1.4%</td>
    </tr>
  </tbody>
</table>

<p>12,800 목표에서 REST 는 9,043 req/s 에서 멈추고 29% 를 버립니다.
같은 목표를 gRPC 는 커넥션 1개로 12,625 req/s 까지 처리하고 1.4% 만 버립니다.
HTTP/2 가 한 커넥션 안에서 스트림을 나눠 동시에 실어 보내므로 동시 요청 수만큼 커넥션이 필요하지 않기 때문입니다.</p>

<p>gRPC 쪽 상한은 이 회차에서 찾지 못했습니다.
12,800 구간에서 피호출 서비스 CPU 가 15초 부하에 15.7초, 코어 하나를 채우는 수준이었습니다.
<strong>그 위는 커넥션이 아니라 CPU 에 먼저 막히므로 gRPC 의 커넥션 한계는 이 조건에서 관측되지 않습니다.</strong></p>

<p><strong>차이는 꼬리 지연에서 먼저 나타납니다.</strong>
6,400 구간에서 p50 은 거의 같은데(72 대 79 µs) p90 에서 2.5배, p95 에서 7.3배로 뒤로 갈수록 벌어집니다.
중앙값으로는 커넥션 포화를 판정할 수 없습니다.</p>

<blockquote class="prompt-info">
  <p>REST 쪽 요청률 사다리 전체와 상한을 찾는 판정 순서, 풀을 4개로 늘렸을 때 처리량이 돌아와도 남는 꼬리는 <a href="/posts/connection-limit-load-test/">부하테스트로 커넥션 상한 찾기</a> 에 따로 정리했습니다.</p>
</blockquote>

<p>결론은 우열이 아니라 교환 조건입니다.
같은 처리량을 REST 는 커넥션 4개로, gRPC 는 1개로 냅니다.
커넥션이 싼 환경에서는 풀을 키우면 됩니다.
다만 <strong>여러 호출자가 한 서비스로 몰리는 팬인 구조에서는 호출자 인스턴스 수만큼 이 배수가 곱해집니다.</strong>
인스턴스가 수백 개면 피호출 쪽 파일 디스크립터와 소켓 메모리와 로드밸런서 커넥션 한계에 그대로 걸립니다.</p>

<p>풀 1개는 실무 설정이 아닙니다.
이 절의 값은 절대 처리량이 아니라 <strong>두 전송 사이의 배수로 읽어야 합니다.</strong>
그리고 크기 조건과 커넥션 조건은 독립입니다.
응답이 작아도 커넥션이 부족하면 gRPC 가 이깁니다.</p>

<h2 id="6-크기-이득은-직렬화에서-나온다">6. 크기 이득은 직렬화에서 나온다</h2>

<p>4장의 전송량 2.5배 절감이 어디서 나오는지 봅니다.
JSON 은 필드명과 구조를 데이터마다 함께 싣고, Protobuf 는 스키마를 양쪽이 공유하니 와이어에 태그 번호와 값만 남습니다.</p>

<p>직렬화가 원인인지 확인하려면 <strong>전송 계층이 같고 직렬화만 다른 두 경로</strong>가 필요합니다.
2장에서 말한 중간 계층 두 방식이 그 조합입니다.
grpc-gateway 는 JSON 으로 변환해 넘기고, grpc-web 은 브라우저가 Protobuf 를 그대로 주고받습니다.</p>

<table>
  <thead>
    <tr>
      <th>경로</th>
      <th>전송</th>
      <th>직렬화</th>
      <th>100건 크기</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>REST 직접</td>
      <td>HTTP/1.1</td>
      <td>JSON</td>
      <td>13,086 B</td>
    </tr>
    <tr>
      <td>grpc-gateway</td>
      <td>HTTP/1.1</td>
      <td>JSON</td>
      <td>13,286 B (1.02배)</td>
    </tr>
    <tr>
      <td>grpc-web</td>
      <td>HTTP/1.1</td>
      <td>Protobuf</td>
      <td><strong>5,086 B (0.39배)</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>HTTP/2 와 Protobuf 로 통신하고도 마지막에 JSON 으로 직렬화하면 크기 이득이 0 입니다.</strong>
grpc-gateway 가 그 경우이고, 브라우저 데브툴에서는 REST 와 구별되지 않습니다.
protojson 이 int64 를 문자열로 인코딩하기 때문입니다.
<strong>grpc-gateway 를 쓰는 이유는 성능이 아니라 호환성입니다.</strong></p>

<p>확인한 것은 크기까지입니다.
지연이 HTTP/2 때문인지 Protobuf 때문인지는 구분하지 못했고, 구분하려면 REST 를 h2c(TLS 없는 평문 HTTP/2)로 올린 구성이 하나 더 필요합니다.</p>

<h2 id="7-그래서-무엇을-고를까">7. 그래서 무엇을 고를까?</h2>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>선택</th>
      <th>근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>단건 조회 + 커넥션 여유</td>
      <td><strong>REST + 가상 스레드</strong></td>
      <td>gRPC 가 0.76배로 더 느림</td>
    </tr>
    <tr>
      <td>단건 조회 + <strong>커넥션 부족</strong></td>
      <td><strong>gRPC</strong></td>
      <td>커넥션 1개당 상한 1.4배, 포화 시 지연 260배</td>
    </tr>
    <tr>
      <td>다건·대용량</td>
      <td><strong>gRPC</strong></td>
      <td>1.56배, 요청당 CPU 40% 절감</td>
    </tr>
    <tr>
      <td>대역폭 비용이 병목</td>
      <td><strong>gRPC</strong></td>
      <td>크기 무관 2.5배 절감</td>
    </tr>
    <tr>
      <td>API 명세가 자주 바뀐다</td>
      <td><strong>gRPC</strong></td>
      <td>스텁을 다시 생성하면 깨진 호출부가 컴파일에서 드러남</td>
    </tr>
  </tbody>
</table>

<p><strong>REST 가 이기는 구간은 하나입니다.</strong>
단건 조회에 커넥션이 넉넉할 때이고, 그때는 가상 스레드 설정 한 줄이 gRPC 도입보다 큰 이득을 냅니다.
계약도 배포 단위도 그대로 두고 얻습니다.</p>

<p><strong>나머지 조건은 전부 gRPC 입니다.</strong>
응답이 커지거나 커넥션이 부족하거나 대역폭이 비용이 되거나 명세가 자주 바뀌면 그렇습니다.
규모가 커지면 이 조건들이 대체로 함께 오므로, 판단은 도입 여부가 아니라 <strong>순서</strong>가 됩니다.
단건 위주면 가상 스레드부터 켜고, 호출자가 수백 개로 늘어 커넥션이 병목이 되는 시점에 gRPC 로 옮깁니다.</p>

<h2 id="8-마치며">8. 마치며</h2>

<p>MSA 가 서비스 간 통신에 gRPC 를 쓰는 이유는 속도가 아니었습니다.
속도는 조건부고 단건 조회에서는 REST 가 더 빠릅니다.
조건과 무관하게 남은 것은 커넥션 1개가 나르는 양과 컴파일에서 깨지는 계약이었고, <strong>둘 다 서비스가 많고 서로를 부르는 구조에서만 비용으로 드러납니다.</strong>
gRPC 를 부르는 것은 속도가 아니라 MSA 라는 조건 자체입니다.</p>

<p><strong>남은 한계.</strong> 단일 호스트라 왕복 지연이 거의 0 이고 이 조건은 gRPC 에 불리합니다.
DB 가 없어 프로토콜 오버헤드의 상한을 본 셈입니다.
커넥션 회차의 풀 1개는 실무 설정이 아닙니다.
그래서 절대 처리량을 인용하지 않고 배수만 씁니다.</p>

<p>더 자세한 내용은 <a href="https://github.com/idean3885/grpc-vs-rest-lab">grpc-vs-rest-lab</a> 에 정리해 두었습니다.</p>

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="개발 기록" /><category term="gRPC" /><category term="REST" /><category term="HTTP/1.1" /><category term="HTTP/2" /><category term="가상 스레드" /><category term="벤치마크" /><summary type="html"><![CDATA[Java 21 · Spring Boot 3.5 에서 같은 도메인 코어에 전송 방식만 바꿔 끼우고 측정했습니다. 단건 조회에서 gRPC 가 더 느렸고, 조건과 무관하게 남는 것은 커넥션과 계약이었습니다.]]></summary></entry><entry><title type="html">여러 개발 플러그인을 하나의 에이전트로 통합하기: 1M 컨텍스트와 유지보수 비용</title><link href="https://blog.idean.me/posts/dev-plugins-into-one-assistant/" rel="alternate" type="text/html" title="여러 개발 플러그인을 하나의 에이전트로 통합하기: 1M 컨텍스트와 유지보수 비용" /><published>2026-07-14T18:33:00+09:00</published><updated>2026-07-14T18:33:00+09:00</updated><id>https://blog.idean.me/posts/dev-plugins-into-one-assistant</id><content type="html" xml:base="https://blog.idean.me/posts/dev-plugins-into-one-assistant/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
여러 Claude Code 플러그인으로 개인 개발 도구를 나눠 만들었는데 늘수록 관리·위임 오류로 번거로워졌습니다.<br />
모델이 발전하고 메인 세션 컨텍스트가 1M 으로 커지자 나눠 둘 이유가 약해져 하나의 작업 비서(ops-agent)로 합쳤습니다. 이제 <strong>이슈를 시작한다</strong> 하나로 단순해졌습니다.</p>
</blockquote>

<h2 id="배경-ai에게-맡기려면-매번-같은-출발선이-필요하다">배경: AI에게 맡기려면 매번 같은 출발선이 필요하다</h2>

<p>AI 에게 코드와 문서를 맡기면 반복 작업이 줄어듭니다. 문제는 같은 요청에도 매번 다르게 답하고, 어제 합의한 규칙을 오늘 잊는다는 점입니다. 매번 사람이 교정하면 그 교정이 다시 일이 됩니다. 그래서 일회성 프롬프트로 쓰는 대신, 판단과 규칙을 하네스에 심어 AI 가 매 세션 같은 원칙 위에서 시작하게 만들기로 했습니다.</p>

<p>필요가 생길 때마다 도구를 하나씩 만들었습니다. 각 판단은 그 시점엔 맞았습니다. 모델 컨텍스트가 넉넉하지 않던 때라 도구를 작게 나눠 필요할 때만 불러 쓰는 편이 합리적이었습니다. 범용 도구는 대외 공개를 염두에 두고 회사 내부 로직을 넣지 않았고, 멀티레포와 사내 연동은 각각 별도 플러그인으로 분리했습니다. 4개의 층이 생기게 되었습니다.</p>

<table>
  <thead>
    <tr>
      <th>도구</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>cross-verify</td>
      <td>의사결정·설계·문서·구현 4축 교차 검증</td>
    </tr>
    <tr>
      <td>flow</td>
      <td>단일 레포 이슈 라이프사이클 (이슈 → spec → 구현 → 커밋 → PR)</td>
    </tr>
    <tr>
      <td>org-flow</td>
      <td>멀티레포 오케스트레이션 (spec·FE·BE 가 별도 레포)</td>
    </tr>
    <tr>
      <td>toolkit</td>
      <td>사내 도구·시스템 접근 (이슈 트래커·CI·내부 API 등)</td>
    </tr>
  </tbody>
</table>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>org-flow (멀티레포 오케스트레이션)
  └─ flow (단일 레포 이슈 라이프사이클)
       └─ toolkit (사내 도구·시스템 접근)
            └─ provider (환경별 설정)
</code></pre></div></div>

<h2 id="문제-겹겹이-쌓을수록-에이전트가-놓친다">문제: 겹겹이 쌓을수록 에이전트가 놓친다</h2>

<p>org-flow 가 flow 에 위임할 때마다 컨텍스트 전환이 생깁니다. 멀티레포 이슈를 시작하면 내부에서 flow 의 이슈 생성·시작을 호출하고 결과를 검증하고 워크트리 생성 같은 후속 작업을 이어갑니다. 위임 전 가드 설정, 위임 결과 검증, 실패 시 롤백을 LLM 에이전트가 순서대로 기억하고 실행해야 합니다. 층이 하나 늘 때마다 에이전트가 놓칠 수 있는 지점이 늘어납니다.</p>

<p>같은 기간 다른 배치 파이프라인 작업에서 같은 문제를 겪었습니다. 에이전트가 5단계를 수동으로 수행하다 캐시 미기록, 폴백 재삽입 같은 버그가 서너 번 반복됐습니다. 해법은 결정적 로직을 스크립트로 분리하고 불변식 검증을 스크립트에 내장해 에이전트의 개입 구간을 줄이는 데 있었습니다.</p>

<h2 id="전환점-모델이-발전하자-분리의-근거가-약해졌다">전환점: 모델이 발전하자 분리의 근거가 약해졌다</h2>

<p>도구를 작게 나눈 근거는 <strong>필요할 때만 불러 쓴다</strong>였습니다. 모델 컨텍스트가 작은 경우 이 방식이 맞습니다. 그런데 모델이 세대를 거치며 발전하면서 전제가 흔들렸습니다. 체감상 가장 컸던 건 메인 세션이 한 번에 담는 컨텍스트가 1M 으로 늘어난 점입니다. 컨텍스트가 넉넉해지면서 하나의 에이전트가 이슈 플로우, 멀티레포, 검증, 규칙을 담아도 모자라지 않게 됐고 필요한 부분만 인덱스로 지연 로드하면 되는 구조가 된 것입니다.</p>

<p>다만 컨텍스트 하나로 단정하진 않습니다. 같은 기간 모델의 지시 이행·판단 품질도 함께 올라갔고 어느 요인이 결정적이었는지는 딱 잘라 말하기 어렵습니다. 정말 컨텍스트 때문인지는 다음 통합·분리 결정 때 다시 확인할 문제입니다. 분명한 건 방향입니다. 분리로 얻던 <strong>작게 유지</strong>의 이점은 줄고, 관리 포인트와 위임 오류라는 비용은 남았습니다. 그래서 판단을 다시 했습니다.</p>

<blockquote>
  <p>나눠 두는 대신, 하나로 합친다.</p>
</blockquote>

<h2 id="설계의-실체-레포-수는-매니페스트가-알고-도구가-처리한다">설계의 실체: 레포 수는 매니페스트가 알고, 도구가 처리한다</h2>

<p>합치되, 레포 수처럼 바뀌는 정보는 코드가 아니라 데이터로 밀어냈습니다. org-flow 의 결정적 로직을 스크립트로 분리하고, flow 가 레포 수를 자동 감지해 단일·멀티를 투명하게 처리하도록 흡수했습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>이전 · 4층:  org-flow → flow → toolkit → provider
   │
   │ 통합
   ▼
이후 · 2층:  ops-agent(통합) → 사내 연동 어댑터
</code></pre></div></div>

<p>멀티레포 정보는 프로젝트 매니페스트 <code class="language-plaintext highlighter-rouge">project.json</code> 에 레포 수·역할·베이스 브랜치로 둡니다. 매니페스트가 없으면 단일 레포로 동작해 하위 호환을 유지하고, 새 프로젝트는 스킬 문서를 고치는 대신 매니페스트 십여 줄만 작성하면 됩니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>이슈를 시작한다
  → flow 가 레포 수 자동 감지
  → 매니페스트로 관련 레포 결정
  → 워크트리 스크립트 (브랜치 생성·불변식 검증)
  → 작업 · AI 오케스트레이션
  → 커밋 스크립트 (원격 브랜치·불변식 검증)
</code></pre></div></div>

<p>결정적 작업은 스크립트로 옮기고, 각 스크립트에 불변식 검증을 내장했습니다. 브랜치나 원격 브랜치가 없으면 스크립트가 중단하므로, 에이전트는 호출하고 결과를 보고할 뿐입니다.</p>

<p>규칙도 세 시점(세션 시작, 편집·응답, 스킬 실행)에 자동으로 걸리게 두었습니다. 가령 표현 가드는 출력을 막는 대신 사전 주입과 사후 통지로 두어, 도구가 판단을 대체하지 않고 판단할 자리만 좁힙니다.</p>

<h2 id="판단-보조-도구에서-개인-개발-운영-에이전트로">판단: 보조 도구에서 개인 개발 운영 에이전트로</h2>

<p>하나로 합친 뒤 도구의 성격이 처음과 달라졌습니다. 처음엔 개발 편의를 돕는 보조 도구(DevEx)였는데, 이슈 플로우, 멀티레포 조정, 검증, 규칙 적용을 하나가 맡으면서 개인 개발을 운영하는 에이전트에 가까워졌습니다. 그래서 이름을 devex 에서 ops-agent 로 바꿨습니다. 단독 플러그인이던 <a href="/posts/cross-verify-tool-3month/">교차 검증 도구</a>도 이 안으로 들어와, 이제 별도 도구가 아니라 하나의 비서가 제공하는 검증 기능입니다. 이 도구는 회사 업무에도 쓰기 때문에 사내 연동은 별도(비공개) 플러그인으로 분리해, 퍼블릭 표면에 사내 정보가 섞이지 않도록 경계를 두었습니다.</p>

<p>지금 규모(1인 개발, 소수 프로젝트)에서는 매니페스트 + 스크립트 + 규칙 하네스로 충분하다고 판단했습니다. 과한 것을 미리 들이지 않았습니다. 정량 지표는 대부분 이슈를 병행하며 만든 것이라 측정이 끝나지 않았고, 검증되지 않은 수치는 적지 않았습니다. 다만 교차 검증 기능은 업무 레포 두 곳에서 타임존·페이지네이션 관련 이슈를 배포 전에 발견했습니다.</p>

<p>가장 큰 변화는 인지 모델입니다. 이슈가 멀티레포냐 단일 레포냐에 따라 org-flow 와 flow 를 구분해 쓰던 것이, <strong>이슈를 시작한다</strong> 하나로 단일화됐습니다. 레포가 몇 개인지는 매니페스트가 알고, 도구가 처리합니다. 이 도구를 만들면서 분명해진 것은, AI 를 잘 쓰는 일이 모델을 다루는 일이 아니라 도구와 규칙의 아키텍처를 판단하는 일이라는 점입니다. 그리고 그 판단은 한 번으로 끝나지 않습니다. 처음엔 나누는 게 맞았고 지금은 합치는 게 맞았는데, 그 사이를 가른 건 모델이 발전한 것이고 그중 컨텍스트가 커진 영향이 컸습니다. 모델이 또 달라지면 분리와 통합을 다시 물어야 합니다. AI 는 그 판단을 실어 나르는 도구입니다.</p>

<p>매니페스트 스키마와 스크립트 등 구현 세부는 <a href="https://github.com/idean3885/claude-ops-agent">ops-agent 레포</a>에 공개돼 있습니다.</p>

<hr />

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="개발 기록" /><category term="AI 적응기" /><category term="Claude Code" /><category term="AI 에이전트" /><category term="아키텍처" /><category term="설계" /><category term="의사결정" /><summary type="html"><![CDATA[모델 컨텍스트가 커지자 여러 Claude Code 플러그인을 하나의 작업 비서(ops-agent)로 합친 판단과 설계를 정리합니다.]]></summary></entry><entry><title type="html">여러 쿠버네티스 클러스터를 백엔드 하나로 다루는 법: 설정 증설 대신 위임 서비스</title><link href="https://blog.idean.me/posts/multi-cluster-delegation-layer/" rel="alternate" type="text/html" title="여러 쿠버네티스 클러스터를 백엔드 하나로 다루는 법: 설정 증설 대신 위임 서비스" /><published>2026-07-02T09:40:00+09:00</published><updated>2026-07-08T01:30:00+09:00</updated><id>https://blog.idean.me/posts/multi-cluster-delegation-layer</id><content type="html" xml:base="https://blog.idean.me/posts/multi-cluster-delegation-layer/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
k8s 멀티테넌시 서비스 오픈 이후 여러 클러스터에 워크로드를 실행해야 하는 요구사항이 발생했습니다.<br />
클러스터별 설정을 늘리는 방법 대신 위임 서비스(delegator)를 두고 k8s 의존을 그 뒤로 분리해, 클러스터를 추가해도 재배포 없이 DB 등록으로 끝나게 했습니다.<br />
대안과 전제, 왜 그렇게 판단했는지는 아래에서 풀어 씁니다.</p>
</blockquote>

<h2 id="배경-애플리케이션이-k8s에-워크로드를-만든다">배경: 애플리케이션이 k8s에 워크로드를 만든다</h2>

<p>GPU 워크로드를 쿠버네티스(k8s) 클러스터에서 실행하는 서비스를 개발하고 운영하고 있습니다. 애플리케이션(백엔드)이 컨트롤플레인 역할을 맡아 사용자의 워크로드 생성·관리 요청을 받아 k8s API로 실행합니다. 애플리케이션이 곧 k8s API의 클라이언트입니다.</p>

<p>초기 오픈은 단일 클러스터였습니다. 애플리케이션은 사용자 워크로드와 같은 클러스터 안에 있었고, 그 클러스터의 k8s API 서버와 통신하는 방식이었습니다. 애플리케이션과 API 서버가 같은 네트워크 안에 있어 도달성(요청이 상대에 닿는지)을 신경 쓸 일이 없었습니다.</p>

<h2 id="문제-단일-클러스터-전제가-깨졌다">문제: 단일 클러스터 전제가 깨졌다</h2>

<p>오픈 이후 다음 버전을 준비하며 멀티클러스터 요구가 생겼습니다. 애플리케이션이 서로 다른 클러스터의 k8s API와 각각 통신해야 했습니다. “클러스터가 하나”라는 전제, 그 위에 얹혀 있던 “API 서버 주소도 하나”라는 전제가 함께 무너졌습니다.</p>

<h2 id="대안-설정을-늘릴까-위임-계층을-둘까">대안: 설정을 늘릴까, 위임 계층을 둘까</h2>

<p>가장 손쉬운 길은 클러스터별 k8s 접속 설정을 애플리케이션에 추가하는 것이었습니다. 클러스터가 적다면 이 방법도 괜찮습니다.</p>

<p>걸린 건 클러스터가 계속 늘어난다는 점이었습니다. 여러 리전에 걸친 다양한 환경의 클러스터를 다뤄야 하는 요구사항이 있었습니다. 클러스터가 늘 때마다 kubeconfig·인증·엔드포인트 설정이 코드나 설정 파일에 쌓이면, 애플리케이션이 클러스터 개수와 접속 방식에 직접 결합됩니다. 결합도가 높아집니다.</p>

<p>헥사고날로 도메인을 포트 뒤에 두면 도메인은 k8s·전송 세부에서 분리된 채 유지됩니다. 그러나 애플리케이션이 모든 클러스터의 kubeconfig·자격증명을 직접 쥔다는 사실은 남습니다. 전 클러스터의 자격증명을 한 곳이 보유해 사고 범위가 커지고, 클러스터를 추가하려면 설정 변경과 재배포가 따라옵니다.</p>

<p>그래서 위임 서비스를 뒀습니다. k8s와 직접 통신하는 책임을 위임 서비스로 옮기고 애플리케이션은 “어느 클러스터의 위임 서비스로 보낼지”만 알면 됩니다. 그 주소(클러스터별 위임 서비스 URL)는 DB 설정으로 관리합니다. <strong>클러스터를 추가해도 애플리케이션 재배포 없이 DB 등록으로 끝납니다.</strong></p>

<p>각 클러스터의 자격증명도 애플리케이션이 아니라 해당 위임 서비스가 갖습니다. 어댑터는 클러스터별 k8s 접속 세부가 아니라 위임 서비스 계약 하나만 알면 됩니다. 실행 플랫폼 교체도 같은 구조가 흡수합니다. 당시 GPU 스케줄링을 k8s 대신 docker + Slurm으로 옮기는 안이 검토되고 있었는데, k8s 관련 기능이 위임 서비스 뒤에 모여 있어 실행 플랫폼이 바뀌어도 애플리케이션은 그대로 두고 어댑터만 교체하면 됩니다.</p>

<h2 id="설계의-실체-포트는-도메인에-전송은-어댑터에">설계의 실체: 포트는 도메인에, 전송은 어댑터에</h2>

<p>격리는 코드의 이음새로 드러납니다. 도메인은 포트 인터페이스에만 의존합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 도메인이 의존하는 포트 (전송·k8s 세부 없음)</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">RemoteExecutionPort</span> <span class="o">{</span>
  <span class="kt">void</span> <span class="nf">createWorkload</span><span class="o">(</span><span class="nc">Workload</span> <span class="n">workload</span><span class="o">);</span>
  <span class="c1">// ...</span>
<span class="o">}</span>
</code></pre></div></div>

<p>전송(HTTP)은 어댑터에만 있습니다. 어댑터가 <code class="language-plaintext highlighter-rouge">clusterId -&gt; delegatorUrl -&gt; client</code> 순서로 대상 위임 서비스를 해석합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 포트 구현 = 전송 계층</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RemoteExecutionAdapter</span> <span class="kd">implements</span> <span class="nc">RemoteExecutionPort</span> <span class="o">{</span>

  <span class="kd">private</span> <span class="nc">DelegatorClient</span> <span class="nf">getClient</span><span class="o">(</span><span class="nc">ClusterId</span> <span class="n">clusterId</span><span class="o">)</span> <span class="o">{</span>
    <span class="kt">var</span> <span class="n">cluster</span> <span class="o">=</span> <span class="n">clusterQuery</span><span class="o">.</span><span class="na">getBy</span><span class="o">(</span><span class="n">clusterId</span><span class="o">);</span>
    <span class="k">return</span> <span class="n">delegatorClientFactory</span><span class="o">.</span><span class="na">getClient</span><span class="o">(</span><span class="no">URI</span><span class="o">.</span><span class="na">create</span><span class="o">(</span><span class="n">cluster</span><span class="o">.</span><span class="na">delegatorUrl</span><span class="o">()));</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>동적 baseUrl은 팩토리가 클러스터별로 캐싱합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 위임 서비스 클라이언트 팩토리</span>
<span class="kd">public</span> <span class="nc">DelegatorClient</span> <span class="nf">getClient</span><span class="o">(</span><span class="no">URI</span> <span class="n">baseUrl</span><span class="o">)</span> <span class="o">{</span>
  <span class="nc">Objects</span><span class="o">.</span><span class="na">requireNonNull</span><span class="o">(</span><span class="n">baseUrl</span><span class="o">,</span> <span class="s">"baseUrl must not be null"</span><span class="o">);</span>
  <span class="kt">var</span> <span class="n">normalizedUrl</span> <span class="o">=</span> <span class="n">normalizeUrl</span><span class="o">(</span><span class="n">baseUrl</span><span class="o">);</span>
  <span class="k">return</span> <span class="n">clientCache</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">normalizedUrl</span><span class="o">,</span> <span class="n">url</span> <span class="o">-&gt;</span> <span class="o">{</span>
    <span class="kt">var</span> <span class="n">restClient</span> <span class="o">=</span> <span class="n">restClientBuilder</span><span class="o">.</span><span class="na">baseUrl</span><span class="o">(</span><span class="n">url</span><span class="o">).</span><span class="na">build</span><span class="o">();</span>
    <span class="c1">// ... HttpServiceProxyFactory 로 DelegatorClient 생성</span>
  <span class="o">});</span>
<span class="o">}</span>
</code></pre></div></div>

<p>핵심은 여기입니다. 도메인은 <code class="language-plaintext highlighter-rouge">RemoteExecutionPort</code>에만 의존하고 전송·주소·k8s 세부는 전부 어댑터 밖에 있습니다. 그래서 전송을 gRPC 스트림이나 브로커로 바꿔도 <strong>어댑터를 하나 더 구현할 뿐 도메인 코드는 그대로</strong>입니다.</p>

<h2 id="판단-지금-규모에-맞춘-선택-미뤄-둔-결정">판단: 지금 규모에 맞춘 선택, 미뤄 둔 결정</h2>

<p>현 규모(신뢰망 안 소수 클러스터, 동기 명령 중심)에서는 위임 서비스 + 동기 REST + DB 등록으로 충분하다고 판단했습니다. 과한 것(멀티클러스터 서비스 메시, 브로커)을 미리 들이지 않았습니다.</p>

<p>비가역적(irreversible) 결정은 도달성, 곧 어느 쪽이 연결을 여느냐입니다. 지금은 애플리케이션이 위임 서비스로 직접 요청을 보냅니다. 신뢰망 안에선 닿지만, 리전이 갈려 위임 서비스가 방화벽·NAT 뒤로 가면 이 방향이 막혀 위임 서비스가 먼저 연결을 여는 구조로 뒤집어야 합니다.</p>

<p>그래서 이 결정만 포트 뒤에 격리해 두고 멀티리전이 실제 요구가 되는 순간(last responsible moment)까지 미뤘습니다. 그 시점엔 어댑터를 하나 더 구현하면 됩니다. 도메인은 그대로입니다.</p>

<p>“설정만 늘렸다면”과 비교하면 차이가 분명합니다. 설정 증설은 당장은 빠르지만 클러스터 증가와 실행 플랫폼 변경을 코드나 설정에 계속 반영해야 합니다. 위임 서비스는 그 변화를 데이터(DB 등록)와 어댑터 교체 지점으로 모아 도메인이 흔들리지 않게 했습니다.</p>

<hr />

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="기술 노하우" /><category term="실무 노하우" /><category term="아키텍처" /><category term="설계" /><category term="의사결정" /><category term="헥사고날" /><category term="쿠버네티스" /><summary type="html"><![CDATA[여러 쿠버네티스 클러스터를 설정 증설 대신 위임 서비스 하나로 다루기로 한 과정과 판단을 정리합니다.]]></summary></entry><entry><title type="html">미터링 배치 시스템 설계: 쓰기 경합·청사진·패턴 명명·저장 전략 통일까지</title><link href="https://blog.idean.me/posts/metering-batch-system-design/" rel="alternate" type="text/html" title="미터링 배치 시스템 설계: 쓰기 경합·청사진·패턴 명명·저장 전략 통일까지" /><published>2026-05-17T23:20:00+09:00</published><updated>2026-07-16T20:40:00+09:00</updated><id>https://blog.idean.me/posts/metering-batch-system-design</id><content type="html" xml:base="https://blog.idean.me/posts/metering-batch-system-design/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
5분 수집 / 10분 집계 / 일간 집계 미터링 파이프라인을 만들며, 하나의 시스템을 여러 각도에서 바라봤습니다. 순서가 아니라, 각 각도에서 내린 의사결정을 정리합니다.</p>

  <ul>
    <li><strong>쓰기 경합</strong>: 스키마 분리 + <code class="language-plaintext highlighter-rouge">@Transactional(readOnly)</code> 라우팅으로 해결.</li>
    <li><strong>배치 진화</strong>: 내장 스케줄러 → CronJob → 이벤트 → 분산, Stage 0→3 청사진 + 전환 시그널.</li>
    <li><strong>패턴 명명</strong>: 적용 패턴에 이름 붙이기(HWM, Tumbling Window, Catch-up, Idempotency).</li>
    <li><strong>저장 전략</strong>: 합리적 UPSERT를 DELETE+INSERT로 통일(YAGNI).</li>
  </ul>

  <p>더 깊게 파고든 두 주제(MySQL 파티셔닝, 용량 세 제약 의사결정)는 별도 글로 분리.</p>
</blockquote>

<h2 id="0-배경-미터링-파이프라인과-기술-스택">0. 배경: 미터링 파이프라인과 기술 스택</h2>

<p>운영 중인 클라우드 GPU 서비스의 과금 근거가 되는 사용량 데이터를 빠짐없이 수집·집계해야 했습니다. 설계부터 구현까지 직접 담당했습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Prometheus (GPU/CPU/Memory)
    ↓ 5분 수집
[원천] Pod 단위 저장
    ↓ 10분 집계
[구간] 서비스/Pod 단위
    ↓ 일간 누적
[일간] 서비스/Pod 단위
    ↓
사용자 조회 API
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>분류</th>
      <th>기술</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Language / Framework</td>
      <td>Java 17, Spring Boot</td>
    </tr>
    <tr>
      <td>ORM</td>
      <td>Spring Data JPA</td>
    </tr>
    <tr>
      <td>Database</td>
      <td>MySQL(마스터/슬레이브 복제, 월별 파티셔닝)</td>
    </tr>
    <tr>
      <td>Architecture</td>
      <td>헥사고날 아키텍처</td>
    </tr>
    <tr>
      <td>Batch</td>
      <td>JobRunr</td>
    </tr>
    <tr>
      <td>Source</td>
      <td>Prometheus</td>
    </tr>
    <tr>
      <td>Infra</td>
      <td>Kubernetes</td>
    </tr>
  </tbody>
</table>

<h2 id="1-쓰기-경합-스키마-분리--트랜잭션-라우팅">1. 쓰기 경합: 스키마 분리 + 트랜잭션 라우팅</h2>

<h3 id="단일-datasource의-세-가지-문제">단일 DataSource의 세 가지 문제</h3>

<table>
  <thead>
    <tr>
      <th>문제</th>
      <th>내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>쓰기 경합</td>
      <td>5분 수집과 10분 집계가 같은 DB에서 동시 실행, 락 경합</td>
    </tr>
    <tr>
      <td>조회 성능 불안정</td>
      <td>배치가 대량 INSERT/UPDATE 중일 때 사용자 조회 API 응답 시간 불안정</td>
    </tr>
    <tr>
      <td>장애 전파</td>
      <td>외부 소스 하나의 응답 지연이 수집→집계→조회까지 연쇄 영향</td>
    </tr>
  </tbody>
</table>

<p>서비스 수가 늘면 배치 실행 시간이 길어지고 겹침 확률이 높아집니다. 2년 예측(분기당 50 서비스 증가)으로 약 2억 건이 누적됩니다. 단일 데이터소스로는 한계에 이릅니다.</p>

<h3 id="세-방안-검토">세 방안 검토</h3>

<table>
  <thead>
    <tr>
      <th>방안</th>
      <th>평가</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>시간대 분리</td>
      <td>5분/10분으로 촘촘해서 시간대 확보 어려움. 한 배치 지연 시 연쇄</td>
    </tr>
    <tr>
      <td><strong>스키마 분리 + 트랜잭션 라우팅(선택)</strong></td>
      <td>영역별 장애 격리, 읽기 부하 분산, DataSource 복잡도 증가</td>
    </tr>
    <tr>
      <td>메시지 큐 기반 비동기</td>
      <td>완전 디커플링이지만 현재 규모 대비 인프라 과함, 순서 제어 등 고려 부담</td>
    </tr>
  </tbody>
</table>

<h3 id="영역-분리-설계">영역 분리 설계</h3>

<table>
  <thead>
    <tr>
      <th>영역</th>
      <th>역할</th>
      <th>스키마</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>원천(Source)</td>
      <td>Pod 데이터 수집만, 비즈니스 로직 없음</td>
      <td>별도 스키마(마스터/슬레이브)</td>
    </tr>
    <tr>
      <td>가공(Processing)</td>
      <td>집계 + 조회, 비즈니스 로직 포함</td>
      <td>기본 스키마(마스터)</td>
    </tr>
  </tbody>
</table>

<p>가공은 집계 주기(10분)와 조회 패턴이 겹칠 가능성이 낮아 마스터 하나로 충분하다고 판단했습니다. 원천만 슬레이브로 라우팅합니다.</p>

<h3 id="transactionalreadonly-기반-라우팅"><code class="language-plaintext highlighter-rouge">@Transactional(readOnly)</code> 기반 라우팅</h3>

<p><code class="language-plaintext highlighter-rouge">AbstractRoutingDataSource</code>를 확장하여 트랜잭션의 readOnly 속성으로 라우팅합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">ReadWriteRoutingDataSource</span> <span class="kd">extends</span> <span class="nc">AbstractRoutingDataSource</span> <span class="o">{</span>
  <span class="nd">@Override</span>
  <span class="kd">protected</span> <span class="nc">Object</span> <span class="nf">determineCurrentLookupKey</span><span class="o">()</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nc">TransactionSynchronizationManager</span>
        <span class="o">.</span><span class="na">isCurrentTransactionReadOnly</span><span class="o">()</span> <span class="o">?</span> <span class="s">"slave"</span> <span class="o">:</span> <span class="s">"master"</span><span class="o">;</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>서비스 레이어는 어노테이션만 붙이면 라우팅이 결정됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="s">"sourceTransactionManager"</span><span class="o">,</span> <span class="n">readOnly</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">SourceMetric</span><span class="o">&gt;</span> <span class="nf">findByRange</span><span class="o">(</span><span class="nc">Instant</span> <span class="n">from</span><span class="o">,</span> <span class="nc">Instant</span> <span class="n">to</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>  <span class="c1">// → 슬레이브</span>

<span class="nd">@Transactional</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="s">"sourceTransactionManager"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">saveAll</span><span class="o">(</span><span class="nc">List</span><span class="o">&lt;</span><span class="nc">SourceMetric</span><span class="o">&gt;</span> <span class="n">instances</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>  <span class="c1">// → 마스터</span>
</code></pre></div></div>

<p>원천 영역은 독립된 <code class="language-plaintext highlighter-rouge">EntityManagerFactory</code>와 <code class="language-plaintext highlighter-rouge">TransactionManager</code>를 둡니다. 가공은 Spring Boot 기본 설정을 그대로 씁니다.</p>

<h3 id="미리-대응한-엣지-케이스">미리 대응한 엣지 케이스</h3>

<ul>
  <li><strong>슬레이브 복제 지연</strong>: 집계 주기(10분)가 수집 주기(5분)보다 길어 자연스러운 버퍼 확보</li>
  <li><strong>배치 실패 시 빈 구간</strong>: 마지막 성공 시점 추적 + 자동 재처리</li>
  <li><strong>멀티 소스 장애 격리</strong>: 소스별 Job 분리</li>
  <li><strong>데이터 증가 대응</strong>: 월별 파티셔닝 + 일정 기간 후 아카이빙</li>
</ul>

<p>CQRS라고 하면 이벤트 소싱이나 별도 읽기 저장소를 떠올리기 쉽지만, <strong>스키마 분리 + 트랜잭션 라우팅만으로도 쓰기·읽기 독립 이점을 얻을 수 있었습니다</strong>. 시스템 규모에 맞는 수준 선택이 더 중요합니다.</p>

<h2 id="2-배치-진화-청사진-stage-0--3--전환-시그널">2. 배치 진화 청사진: Stage 0 → 3 + 전환 시그널</h2>

<p>핵심 원칙은 하나입니다. <strong>전환 시그널이 나타날 때까지 현재 단계를 유지합니다.</strong> 미리 과도하게 설계하면 복잡성만 늘어납니다.</p>

<h3 id="stage-0-내장-스케줄러--상시-서버현재">Stage 0: 내장 스케줄러 + 상시 서버(현재)</h3>

<p>JobRunr <code class="language-plaintext highlighter-rouge">BackgroundJobServer</code>가 주기적으로 Job을 폴링합니다. 메서드명이 곧 Job 이름이라 이름을 바꾸면 새 Job으로 인식되어 이력이 단절됩니다. 이 제약은 의도적입니다. <strong>Job 이력 일관성이 실패 복구의 기반</strong>이기 때문입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Job</span>  <span class="c1">// name 생략 → 메서드명이 Job 이름</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">collectSource</span><span class="o">()</span> <span class="o">{</span>
    <span class="kt">var</span> <span class="n">from</span> <span class="o">=</span> <span class="n">helper</span><span class="o">.</span><span class="na">getLastSuccessAt</span><span class="o">(</span><span class="s">"collectSource"</span><span class="o">);</span>
    <span class="kt">var</span> <span class="n">rawData</span> <span class="o">=</span> <span class="n">prometheus</span><span class="o">.</span><span class="na">queryRange</span><span class="o">(</span><span class="n">query</span><span class="o">,</span> <span class="n">from</span><span class="o">,</span> <span class="n">now</span><span class="o">,</span> <span class="s">"1m"</span><span class="o">);</span>
    <span class="n">sourceRepository</span><span class="o">.</span><span class="na">upsertAll</span><span class="o">(</span><span class="n">rawData</span><span class="o">);</span>  <span class="c1">// 멱등성 (UPSERT)</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>왜 CronJob이 아니라 상시 서버?</strong></p>

<p>CronJob은 매 실행마다 JVM 기동(~30초)이 실제 작업 시간(~10초)의 3배입니다. 5분마다 이 비용을 내느니 서버를 상시 띄우는 편이 합리적입니다.</p>

<table>
  <thead>
    <tr>
      <th>시그널</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>적합 규모</td>
      <td>소스 1~2종, 인스턴스 수천 건, 단일 DB</td>
    </tr>
    <tr>
      <td>한계 시그널</td>
      <td>유휴 리소스 80%+, Job 미실행 시간 대부분</td>
    </tr>
  </tbody>
</table>

<p>상시 서버의 대가는 명확합니다. 하루 약 48분만 실행되고 나머지 23시간 12분은 JobRunr 폴링과 Health Check만 하면서 리소스를 점유합니다.</p>

<h3 id="stage-1-cronjob--native-image">Stage 1: CronJob + Native Image</h3>

<p>상시 점유 → 실행 시에만 리소스. GraalVM Native Image로 기동 ~0.5초, 메모리 128MB로 줄이면 CronJob의 기동 오버헤드 문제가 크게 풀립니다.</p>

<p>대가: JobRunr의 Job 이력 자동 관리가 없으니 별도 <code class="language-plaintext highlighter-rouge">BatchStatusRepository</code>로 마지막 성공 시점을 직접 관리합니다.</p>

<p><strong>전환 시그널</strong>: 유휴 리소스 비용 &gt; 구현 복잡성 비용</p>

<h3 id="stage-2-이벤트-기반수집과-집계-디커플링">Stage 2: 이벤트 기반(수집과 집계 디커플링)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[CronJob: 수집] → [Message Queue] → [집계 Worker]
                  (수집 완료 이벤트)
</code></pre></div></div>

<p>소스 종류가 늘면 시간 기반 집계(10:10) 시점에 일부 소스 데이터가 아직 없을 수 있습니다. 이벤트 기반이면 <strong>모든 소스 수집 완료 후</strong> 집계를 시작합니다.</p>

<p>대가: Exactly-once 처리. Stage 0부터 유지한 멱등성(UPSERT)이 이 지점에서 필요해집니다.</p>

<p><strong>전환 시그널</strong>: 소스 종류 3개 이상, 수집-집계 타이밍 이슈 발생</p>

<h3 id="stage-3-분산-처리파티셔닝">Stage 3: 분산 처리(파티셔닝)</h3>

<p>Prometheus는 쿼리당 총 샘플 수를 제한합니다(기본 5천만). 인스턴스 5,000개 + 7일 복구가 필요하면 단일 쿼리로 한계에 도달합니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>총 샘플 수 = 인스턴스 수 × (시간 범위(분) / step(분))
5,000 × (7일 × 1,440 / 1) = 50,400,000  ← 5천만 초과
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>파티셔닝 전략</th>
      <th>장단</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Range(네임스페이스 A~M / N~Z)</td>
      <td>단순, 데이터 편중 가능</td>
    </tr>
    <tr>
      <td>Hash(<code class="language-plaintext highlighter-rouge">hash(groupId) % N</code>)</td>
      <td>균등, 리밸런싱 복잡</td>
    </tr>
    <tr>
      <td><strong>논리(프로젝트 단위)</strong></td>
      <td>비즈니스 의미 일치(과금 단위), 프로젝트 크기 불균등</td>
    </tr>
  </tbody>
</table>

<p>미터링은 <strong>논리 분할(프로젝트 단위)</strong>이 적합합니다. 분할 경계가 과금 단위와 일치하기 때문입니다.</p>

<p><strong>전환 시그널</strong>: 단일 Prometheus 쿼리 한계 도달, 처리 시간이 배치 주기 초과</p>

<h3 id="단계를-관통하는-원칙">단계를 관통하는 원칙</h3>

<table>
  <thead>
    <tr>
      <th>원칙</th>
      <th>단계별 구현</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>멱등성</td>
      <td>모든 단계에서 UPSERT 패턴 유지</td>
    </tr>
    <tr>
      <td>마지막 성공 시점 기반 복구</td>
      <td>JobRunr 이력 → 별도 테이블 → 이벤트 오프셋(구현은 달라도 추적 원칙은 동일)</td>
    </tr>
    <tr>
      <td>데이터 완전성 우선</td>
      <td>성능보다 누락 방지가 먼저, 구간이 완전히 끝난 후에만 집계</td>
    </tr>
  </tbody>
</table>

<h2 id="3-적용-패턴에-이름-붙이기">3. 적용 패턴에 이름 붙이기</h2>

<p>배치를 만들면서 “이게 안전하겠다”는 직관으로 결정한 것들이 사실 공식 이름이 있는 패턴이었습니다.</p>

<table>
  <thead>
    <tr>
      <th>우리가 한 것</th>
      <th>패턴 이름</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>마지막 성공 시점 이후만 처리</td>
      <td>Incremental Load(<strong>High Water Mark</strong>)</td>
    </tr>
    <tr>
      <td>10분 고정 구간으로 집계</td>
      <td><strong>Tumbling Window</strong></td>
    </tr>
    <tr>
      <td>구간 종료 + 5분 후 처리</td>
      <td><strong>Safety Margin</strong>(팀 내 명명)</td>
    </tr>
    <tr>
      <td>스케줄러 복구 시 누락 구간 자동 처리</td>
      <td><strong>Catch-up</strong></td>
    </tr>
    <tr>
      <td>운영자가 기간 지정하여 재처리</td>
      <td><strong>Backfill</strong></td>
    </tr>
    <tr>
      <td>같은 구간 재처리해도 결과 동일</td>
      <td><strong>Idempotency</strong>(UPSERT)</td>
    </tr>
    <tr>
      <td>JobRunr 이력으로 진행 상태 추적</td>
      <td><strong>Checkpoint</strong></td>
    </tr>
    <tr>
      <td>소스별 Job 분리</td>
      <td><strong>Fault Isolation</strong></td>
    </tr>
    <tr>
      <td>프로젝트 단위 병렬 처리(계획)</td>
      <td><strong>List Partitioning</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="catch-up-vs-backfill">Catch-up vs Backfill</h3>

<p>같은 UseCase 메서드의 오버로드로 표현합니다. 비즈니스 능력은 “집계”로 동일하고 트리거 방식만 다르기 때문입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">aggregateUsage</span><span class="o">()</span>                          <span class="c1">// Catch-up (자동, 크론 기반 + 누락 구간 복구)</span>
<span class="n">aggregateUsage</span><span class="o">(</span><span class="nc">AggregateUsageRequest</span><span class="o">)</span>     <span class="c1">// Backfill (수동, 운영자가 기간 지정)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">max-recovery-days</code>(7일)와 <code class="language-plaintext highlighter-rouge">max-aggregate-range-days</code>(7일) 상한이 무한 백필을 차단합니다.</p>

<h3 id="계층별-멱등성-전략">계층별 멱등성 전략</h3>

<p>같은 멱등성 원칙이라도 계층마다 구현이 다릅니다.</p>

<table>
  <thead>
    <tr>
      <th>계층</th>
      <th>전략</th>
      <th>근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>원천 수집</td>
      <td><code class="language-plaintext highlighter-rouge">ON DUPLICATE KEY UPDATE</code></td>
      <td>재수집 시 최신 값 갱신</td>
    </tr>
    <tr>
      <td>구간 집계(서비스)</td>
      <td>UPSERT(PK 유지)</td>
      <td>일간 배치 조회 중 행 소실 방지</td>
    </tr>
    <tr>
      <td>구간 집계(Pod)</td>
      <td>DELETE + INSERT(전체 교체)</td>
      <td>외부 참조 없음. 짧은 공백 허용 가능</td>
    </tr>
  </tbody>
</table>

<p>이건 4장 전환의 출발점이 됐습니다.</p>

<h3 id="이름을-아는-것의-가치">이름을 아는 것의 가치</h3>

<ol>
  <li><strong>의사소통이 정확해집니다</strong>: “HWM 기반 Incremental Load” 한 문장으로 줄어듭니다</li>
  <li><strong>선택지가 보입니다</strong>: “Incremental Load” 안에 HWM 외에도 Snapshot Diff, CDC가 있습니다</li>
  <li><strong>검색이 됩니다</strong>: “Catch-up pattern batch”로 정확한 사례·베스트 프랙티스를 찾을 수 있습니다</li>
</ol>

<h2 id="4-저장-전략-통일-upsert--delete--insertyagni">4. 저장 전략 통일: UPSERT → DELETE + INSERT(YAGNI)</h2>

<h3 id="upsert-선택의-합리성">UPSERT 선택의 합리성</h3>

<p>미터링 배치 설계 초기 <code class="language-plaintext highlighter-rouge">INSERT ... ON DUPLICATE KEY UPDATE</code> 선택 근거는 명확했습니다.</p>

<ul>
  <li>멱등성: 동일 키 재수집해도 중복 없음</li>
  <li>동시성 안전: 유니크 키 제약으로 충돌 감지</li>
  <li>패턴 검증: 시계열 배치 모범 사례</li>
</ul>

<p>AI 딥리서치도, 다른 시스템 사례도, UPSERT가 맞다는 결론이었습니다.</p>

<h3 id="전환점-팀원-자문">전환점: 팀원 자문</h3>

<p>리뷰에서 팀원 자문 결과가 리서치와 달랐습니다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>수치</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>개발팀 규모</td>
      <td>5인</td>
      <td>유지보수 인력 한정</td>
    </tr>
    <tr>
      <td>서비스 유형</td>
      <td>B2B2C GPU</td>
      <td>정적 엔터프라이즈, 버스트 트래픽 구조적으로 낮음</td>
    </tr>
    <tr>
      <td>현재 Pod 규모</td>
      <td>~500개</td>
      <td>전체 합산</td>
    </tr>
    <tr>
      <td>설계 최대치</td>
      <td>~2,000개</td>
      <td>물리 GPU 자원 상한</td>
    </tr>
    <tr>
      <td>배치 아키텍처</td>
      <td>단일 노드 JobRunR</td>
      <td>동시성 경합 구조적으로 불가능</td>
    </tr>
  </tbody>
</table>

<p><strong>합리적인 설계였지만 우리 규모에서는 오버 엔지니어링이었습니다.</strong></p>

<blockquote class="prompt-info">
  <p>AI에게 아무리 딥리서치를 시키고 다른 사례를 봐도 이런 결론은 나오지 않았습니다. “~2,000 Pod 상한의 B2B2C GPU 서비스에서 5인이 유지보수합니다”라는 맥락은 어떤 리서치에도 다뤄지지 않습니다. 이 판단은 서비스 맥락을 이해하는 팀원에게서 나왔습니다.</p>
</blockquote>

<h3 id="쿼리-비용-비교">쿼리 비용 비교</h3>

<p><code class="language-plaintext highlighter-rouge">ON DUPLICATE KEY UPDATE</code>는 행마다 유니크 키 존재 여부를 확인합니다. <strong>내부 SELECT가 반드시 1건 발생합니다</strong>. JDBC batch로 1회 왕복에 전송해도 마찬가지입니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UPSERT (N건):      내부 SELECT × N + INSERT/UPDATE × N = 2N 연산
DELETE+INSERT:     range DELETE × 1 + batch INSERT × 1 = 2 연산
</code></pre></div></div>

<p>Pod별 원천 수집(5분 주기, 하루 288회)이 누적됩니다.</p>

<table>
  <thead>
    <tr>
      <th>규모</th>
      <th>ODKU</th>
      <th>DELETE+INSERT</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>현재(~500 Pod)</td>
      <td>1,440,000</td>
      <td>576</td>
    </tr>
    <tr>
      <td>최대(~2,000 Pod)</td>
      <td>5,760,000</td>
      <td>576</td>
    </tr>
  </tbody>
</table>

<p>실측 성능 차이는 현재 규모에서 밀리초 단위입니다. 소규모에서는 체감되지 않습니다.</p>

<p><strong>성능이 비슷하다면 더 단순한 쪽이 이깁니다.</strong> 5인 팀에서 JDBC 하드코딩 SQL 관리 비용은 성능 이점을 상회합니다.</p>

<h3 id="파이프라인-전체-통일">파이프라인 전체 통일</h3>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>삭제 단위</th>
      <th>삽입 방식</th>
      <th>쿼리 수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pod별 원천 수집</td>
      <td>수집 범위(<code class="language-plaintext highlighter-rouge">from</code>~<code class="language-plaintext highlighter-rouge">to</code>)</td>
      <td>JPA <code class="language-plaintext highlighter-rouge">saveAll()</code></td>
      <td>2</td>
    </tr>
    <tr>
      <td>구간 집계(서비스)</td>
      <td>구간(<code class="language-plaintext highlighter-rouge">started_at</code>, <code class="language-plaintext highlighter-rouge">ended_at</code>)</td>
      <td>JPA <code class="language-plaintext highlighter-rouge">saveAll()</code></td>
      <td>2</td>
    </tr>
    <tr>
      <td>구간 집계(Pod)</td>
      <td>서비스에 종속</td>
      <td>JPA <code class="language-plaintext highlighter-rouge">saveAll()</code></td>
      <td>2</td>
    </tr>
    <tr>
      <td>일간 집계(서비스)</td>
      <td>날짜</td>
      <td>JPA <code class="language-plaintext highlighter-rouge">saveAll()</code></td>
      <td>2</td>
    </tr>
    <tr>
      <td>일간 집계(Pod)</td>
      <td>서비스에 종속</td>
      <td>JPA <code class="language-plaintext highlighter-rouge">saveAll()</code></td>
      <td>2</td>
    </tr>
  </tbody>
</table>

<p>모든 단계가 같은 패턴입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">repository</span><span class="o">.</span><span class="na">deleteAllBy</span><span class="o">...(</span><span class="n">from</span><span class="o">,</span> <span class="n">to</span><span class="o">);</span>    <span class="c1">// 1. 범위 삭제</span>
<span class="n">repository</span><span class="o">.</span><span class="na">createAll</span><span class="o">(</span><span class="n">entities</span><span class="o">);</span>          <span class="c1">// 2. saveAll (DELETE + INSERT)</span>
</code></pre></div></div>

<p>핵심 변경은 다음과 같습니다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>저장 방식</td>
      <td>JDBC batch UPSERT / Native SQL</td>
      <td>JPA <code class="language-plaintext highlighter-rouge">saveAll</code>(DELETE + INSERT)</td>
    </tr>
    <tr>
      <td>유니크 키</td>
      <td>복합 유니크 키(충돌 감지)</td>
      <td>없음(일반 인덱스만)</td>
    </tr>
    <tr>
      <td>PK 전략</td>
      <td>IDENTITY</td>
      <td>SEQUENCE(Hibernate 배치 INSERT 활성화)</td>
    </tr>
    <tr>
      <td>삭제 방식</td>
      <td>없음</td>
      <td>범위 기준 일괄 삭제</td>
    </tr>
  </tbody>
</table>

<h3 id="교훈-합리적-설계--올바른-설계">교훈: 합리적 설계 ≠ 올바른 설계</h3>

<p>일반론으로 옳은 결정이 특정 맥락에서는 오버 엔지니어링이 됩니다. 돌이켜보면 YAGNI 위배였습니다. 현재 필요하지 않은 동시성 보호를 미리 설계한 것이 비용이 되었습니다. 그 경계를 판단하는 것은 코드가 아니라 사람입니다.</p>

<h2 id="회고-하나의-시스템을-여러-각도에서">회고: 하나의 시스템을 여러 각도에서</h2>

<table>
  <thead>
    <tr>
      <th>각도</th>
      <th>의사결정</th>
      <th>핵심 원칙</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>쓰기 경합</td>
      <td>스키마 분리 + 트랜잭션 라우팅(CQRS 라이트)</td>
      <td>시스템 규모에 맞는 수준</td>
    </tr>
    <tr>
      <td>진화 청사진</td>
      <td>Stage 0~3 + 전환 시그널 정의</td>
      <td>시그널이 올 때까지 현재 단계 유지</td>
    </tr>
    <tr>
      <td>패턴 명명</td>
      <td>HWM, Tumbling Window, Catch-up, Idempotency</td>
      <td>이름이 의사소통과 검색을 정확하게 만든다</td>
    </tr>
    <tr>
      <td>저장 통일</td>
      <td>UPSERT → DELETE + INSERT</td>
      <td>합리적 설계와 올바른 설계는 다르다(YAGNI)</td>
    </tr>
  </tbody>
</table>

<p>배치 시스템 설계의 핵심 역량은 <strong>적정 설계 선택</strong>이라고 생각합니다. 다음 단계로의 전환 시점을 판단하는 능력이 정확한 청사진보다 중요합니다.</p>

<p>돌아보면 이 프로젝트에서 남은 건 개별 해법보다, 하나의 시스템을 쓰기 경합·진화·패턴·저장이라는 여러 각도에서 바라본 경험입니다. 한 각도에 매몰되지 않으려 한 시도였습니다.</p>

<p>이 시스템에서 더 깊게 다룬 두 주제는 별도 글로 분리했습니다.</p>
<ul>
  <li>데이터 증가 대응의 구체적 구현: <a href="/posts/mysql-partitioning-jpa-composite-key/">MySQL 파티셔닝 도입기(JPA 복합 키 전환부터 시간 독립 DDL까지)</a></li>
  <li>용량 설계의 근거: <a href="/posts/metering-capacity-triple-constraint/">어디까지 견뎌야 하는가: 미터링 용량을 세 가지 제약으로 역산한 의사결정</a></li>
</ul>

<hr />

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="개발 기록" /><category term="미터링 시스템 구축" /><category term="설계" /><category term="아키텍처" /><category term="배치" /><category term="JPA" /><category term="JobRunr" /><category term="멱등성" /><category term="Tumbling Window" /><category term="YAGNI" /><summary type="html"><![CDATA[미터링 배치 파이프라인을 만들며 내린 쓰기 경합·청사진·패턴 명명·저장 전략 네 의사결정을 정리합니다.]]></summary></entry><entry><title type="html">인증서 자동화: 사용자 도메인 ACME4j 구현부터 와일드카드 Jenkins 갱신까지</title><link href="https://blog.idean.me/posts/cert-automation-acme-and-wildcard/" rel="alternate" type="text/html" title="인증서 자동화: 사용자 도메인 ACME4j 구현부터 와일드카드 Jenkins 갱신까지" /><published>2026-05-17T23:00:00+09:00</published><updated>2026-05-18T15:05:00+09:00</updated><id>https://blog.idean.me/posts/cert-automation-acme-and-wildcard</id><content type="html" xml:base="https://blog.idean.me/posts/cert-automation-acme-and-wildcard/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
1편: 사용자 도메인 인증서를 HTTP-01 + ACME4j 로 자동 발급. 헥사고날 + 상태 머신 + 4개 CronJob 으로 발급·갱신·재시도·타임아웃을 다 잡았습니다.<br />
2편: 1년 뒤 와일드카드 인증서 갱신을 Docker certbot 과 호스트 DNS Handler 의 파일 기반 IPC + Jenkins 격월 크론으로 끝까지 자동화했습니다.<br />
1편의 ACME 학습이 2편 자동화의 판단 기반이 됐습니다. 2편은 AI 에 코드 위임을 했지만, 엣지 케이스는 1편의 운영 경험에서만 나왔습니다.</p>
</blockquote>

<h2 id="0-배경-두-가지-인증서-과제">0. 배경: 두 가지 인증서 과제</h2>

<p>운영 중인 앱 배포 플랫폼은 두 종류의 인증서를 다룹니다.</p>

<ul>
  <li><strong>플랫폼 와일드카드</strong>: <code class="language-plaintext highlighter-rouge">*.platform.com</code>. 모든 사용자 서비스가 공유. 90일 주기 갱신 필요</li>
  <li><strong>사용자 개별 도메인</strong>: <code class="language-plaintext highlighter-rouge">my-service.example.com</code> 같은 사용자 자신의 도메인. 도메인 연결 시점에 자동 발급되어야 함</li>
</ul>

<p>두 과제가 다른 시점에 나왔습니다. 사용자 도메인 자동 발급은 BE 구현 8주 (2024 말). 와일드카드 갱신 자동화는 1년 뒤 운영 자동화 (2026 초). 1편의 학습이 2편의 설계 기반이 됐고, 2편에서는 AI 협업으로 구현 속도를 크게 끌어올렸습니다.</p>

<h2 id="1-lets-encrypt-와-acme-프로토콜">1. Let’s Encrypt 와 ACME 프로토콜</h2>

<p><a href="https://letsencrypt.org/">Let’s Encrypt</a> 는 무료 SSL 인증서를 자동 발급하는 CA. 핵심은 <strong>도메인 소유권 검증</strong>이고, ACME 프로토콜이 그 검증을 표준화한 메커니즘입니다.</p>

<h3 id="challenge-유형">Challenge 유형</h3>

<table>
  <thead>
    <tr>
      <th>Challenge</th>
      <th>검증 방식</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>HTTP-01</td>
      <td><code class="language-plaintext highlighter-rouge">http://{도메인}/.well-known/acme-challenge/{token}</code> 에 응답</td>
      <td>단일 도메인</td>
    </tr>
    <tr>
      <td>DNS-01</td>
      <td><code class="language-plaintext highlighter-rouge">_acme-challenge.{도메인}</code> TXT 레코드 설정</td>
      <td>와일드카드 도메인</td>
    </tr>
  </tbody>
</table>

<p>와일드카드는 DNS-01 만 가능 (HTTP-01 은 호스트 단위라 와일드카드와 호환되지 않습니다).</p>

<h3 id="rate-limits-이후-구현에-직접-영향">Rate Limits (이후 구현에 직접 영향)</h3>

<table>
  <thead>
    <tr>
      <th>제한</th>
      <th>값</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>동일 도메인 세트 인증서</td>
      <td>주당 5회</td>
      <td>초과 시 최대 7일 대기</td>
    </tr>
    <tr>
      <td>계정당 인증서</td>
      <td>시간당 10개</td>
      <td>갱신은 10배 허용</td>
    </tr>
    <tr>
      <td>계정당 신규 주문</td>
      <td>3시간당 300개</td>
      <td>주문 생성 제한</td>
    </tr>
  </tbody>
</table>

<p>이 제한이 배치 스케줄과 재시도 전략을 결정한 핵심 입력이었습니다.</p>

<h3 id="certbot-으로-먼저-학습">certbot 으로 먼저 학습</h3>

<p>본격적인 BE 구현 전에 certbot CLI 로 발급 과정을 직접 돌렸습니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>certbot certonly <span class="nt">--manual</span> <span class="se">\</span>
  <span class="nt">--preferred-challenges</span> dns <span class="se">\</span>
  <span class="nt">-d</span> <span class="s2">"*.platform.com"</span>
</code></pre></div></div>

<p>certbot 이 TXT 레코드 값을 알려주면 DNS 에 수동으로 추가하고 확인. 이 학습에서 두 가지를 알게 됐습니다.</p>

<ol>
  <li>와일드카드는 DNS-01 필수. 사용 중인 클라우드 DNS 서비스에는 certbot 공식 플러그인이 없어 수동 발급이 유일 방법이었음 (이게 2편의 자동화 동기가 됐습니다)</li>
  <li>사용자 도메인은 HTTP-01 이 적합. 사용자 DNS 를 플랫폼이 직접 제어할 수 없으니 HTTP 응답 기반이 자동화에 유리</li>
</ol>

<h2 id="2-1편-사용자-도메인-be-구현-http-01--acme4j">2. 1편: 사용자 도메인 BE 구현 (HTTP-01 + ACME4j)</h2>

<h3 id="헥사고날-모듈-구조">헥사고날 모듈 구조</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cert/
├── api/        # 공유 인터페이스 (UseCase, Command, Event)
├── core/       # 비즈니스 로직 (도메인, 서비스, 포트, 어댑터)
├── server/     # REST API + 이벤트 구독
├── verifier/   # HTTP-01 토큰 응답 서비스 (사용자 도메인 트래픽 수신)
├── job/        # 배치 CronJob
└── client/     # 외부 서비스용 클라이언트
</code></pre></div></div>

<p>도메인 포트와 어댑터 매핑.</p>

<table>
  <thead>
    <tr>
      <th>포트</th>
      <th>어댑터</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CertificateIssuerPort</code></td>
      <td>ACME4j 래핑</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DomainVerifyPort</code></td>
      <td>Verifier 모듈 연동</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CertificateRepository</code></td>
      <td>JPA</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CertificateValidator</code></td>
      <td>상태 전이 검증</td>
    </tr>
  </tbody>
</table>

<h3 id="상태-머신-설계">상태 머신 설계</h3>

<p>발급은 즉시 완료되지 않습니다. 도메인 검증·ACME 통신·인증서 생성 여러 단계에서 실패할 수 있으니 상태 머신이 필요했습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[서비스 등록] → APPLICATION → PROCESSING → SUCCESS → (갱신 시) APPLICATION
                  ▲              │
                  │              ▼
                  └── FAILURE_REAPPLICATION (재시도)
                       └→ PROCESSING → FAILURE_EXIT (최대 시도 초과)
</code></pre></div></div>

<h3 id="rich-domain-패턴">Rich Domain 패턴</h3>

<p><code class="language-plaintext highlighter-rouge">Certificate</code> 엔티티가 상태 전이 로직과 외부 호출을 내부에 캡슐화합니다. 서비스 레이어는 도메인에 메시지만 전달하면 됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">Certificate</span> <span class="o">{</span>
    <span class="c1">// 발급 시작: APPLICATION → PROCESSING</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">issueStart</span><span class="o">(</span><span class="nc">Duration</span> <span class="n">jobExpectDuration</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">issuanceAttemptCount</span> <span class="o">+=</span> <span class="mi">1</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">issuanceStatus</span> <span class="o">=</span> <span class="no">PROCESSING</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">jobRunExpectAt</span> <span class="o">=</span> <span class="nc">ZonedDateTime</span><span class="o">.</span><span class="na">now</span><span class="o">().</span><span class="na">plus</span><span class="o">(</span><span class="n">jobExpectDuration</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="c1">// 발급 처리: 도메인이 직접 Output Port 호출</span>
    <span class="kd">public</span> <span class="nc">IssueProcessResponse</span> <span class="nf">issueProcess</span><span class="o">(</span><span class="nc">IssueProcessRequest</span> <span class="n">req</span><span class="o">)</span> <span class="o">{</span>
        <span class="kt">var</span> <span class="n">issuerPort</span> <span class="o">=</span> <span class="n">req</span><span class="o">.</span><span class="na">issuerPort</span><span class="o">();</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="kt">var</span> <span class="n">data</span> <span class="o">=</span> <span class="n">issuerPort</span><span class="o">.</span><span class="na">issue</span><span class="o">(</span><span class="k">this</span><span class="o">);</span>   <span class="c1">// ACME4j 통신</span>
            <span class="k">this</span><span class="o">.</span><span class="na">issuanceStatus</span> <span class="o">=</span> <span class="no">SUCCESS</span><span class="o">;</span>
            <span class="k">this</span><span class="o">.</span><span class="na">issuedAt</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="na">issuedAt</span><span class="o">();</span>
            <span class="k">this</span><span class="o">.</span><span class="na">expiredAt</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="na">expiredAt</span><span class="o">();</span>
            <span class="k">this</span><span class="o">.</span><span class="na">publicKey</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="na">publicKey</span><span class="o">();</span>
            <span class="k">this</span><span class="o">.</span><span class="na">privateKey</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="na">privateKey</span><span class="o">();</span>
            <span class="k">this</span><span class="o">.</span><span class="na">jobRunExpectAt</span> <span class="o">=</span> <span class="k">this</span><span class="o">.</span><span class="na">expiredAt</span><span class="o">.</span><span class="na">minus</span><span class="o">(</span><span class="n">req</span><span class="o">.</span><span class="na">renewalExpectDuration</span><span class="o">());</span>
            <span class="k">return</span> <span class="k">new</span> <span class="nf">IssueProcessResponse</span><span class="o">(</span><span class="kc">true</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="c1">// 재시도 또는 최종 실패 처리</span>
            <span class="k">return</span> <span class="k">new</span> <span class="nf">IssueProcessResponse</span><span class="o">(</span><span class="kc">false</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">CertificateValidator</code> 가 담당. APPLICATION 이 아닌 인증서에 <code class="language-plaintext highlighter-rouge">issueStart()</code> 를 호출하면 예외가 납니다.</p>

<h3 id="acme4j-발급-흐름">ACME4j 발급 흐름</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CertificateIssuerPortImpl.issue()
  ├─ Acme4jService.createOrder(도메인)
  ├─ Acme4jService.createChallenge(주문)
  ├─ VerifierFeign.saveChallengeToken()    ─── Verifier 에 토큰 저장
  ├─ Acme4jService.triggerChallenge()       ─── Let's Encrypt 가 Verifier 로 검증 요청
  │     └─ GET /.well-known/acme-challenge/{token}
  ├─ Acme4jService.createCertificate()
  └─ VerifierFeign.deleteChallengeToken()  ─── 토큰 정리 (finally)
</code></pre></div></div>

<p>Verifier 의 토큰 저장은 인메모리 HashMap. 처음엔 임베디드 Redis 를 적용했지만 토큰의 일회성 특성을 보고 HashMap 으로 단순화. 어댑터로 격리되어 있어 교체가 짧았습니다.</p>

<h3 id="4개-cronjob-으로-생명주기-관리">4개 CronJob 으로 생명주기 관리</h3>

<table>
  <thead>
    <tr>
      <th>Job</th>
      <th>주기</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">certificateIssueAllJob</code></td>
      <td>20분</td>
      <td>APPLICATION 인증서 발급</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">certificateRenewalApplyAllJob</code></td>
      <td>10분</td>
      <td>만료 임박 갱신 신청</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">certificateIssueFailureReapplyAllJob</code></td>
      <td>30분</td>
      <td>실패 인증서 재신청</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">certificatePendingIssueProcessFailureAllJob</code></td>
      <td>10분</td>
      <td>PROCESSING 타임아웃 처리</td>
    </tr>
  </tbody>
</table>

<p>발급 Job 은 Rate Limit 을 따릅니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">void</span> <span class="nf">issueAll</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Certificate</span><span class="o">&gt;</span> <span class="n">targets</span> <span class="o">=</span> <span class="n">repository</span><span class="o">.</span><span class="na">findAllIssueTargetLimit</span><span class="o">(</span>
        <span class="no">ACCOUNT_SPEC</span><span class="o">.</span><span class="na">getMaxCharge</span><span class="o">()</span>  <span class="c1">// 시간당 10건</span>
    <span class="o">);</span>
    <span class="k">for</span> <span class="o">(</span><span class="nc">Certificate</span> <span class="n">cert</span> <span class="o">:</span> <span class="n">targets</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">cert</span><span class="o">.</span><span class="na">issueStart</span><span class="o">(...);</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="n">domainVerifyPort</span><span class="o">.</span><span class="na">verifyServiceLink</span><span class="o">(</span><span class="n">cert</span><span class="o">.</span><span class="na">getDomain</span><span class="o">());</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">DomainVerifyServiceNotLinkedException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">handleDomainLinkFailure</span><span class="o">(</span><span class="n">cert</span><span class="o">);</span>
            <span class="k">continue</span><span class="o">;</span>
        <span class="o">}</span>
        <span class="n">certificateJobAsync</span><span class="o">.</span><span class="na">issueProcessAsync</span><span class="o">(</span><span class="n">cert</span><span class="o">);</span>  <span class="c1">// @Async</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="실전-이슈-5가지">실전 이슈 5가지</h3>

<table>
  <thead>
    <tr>
      <th>이슈</th>
      <th>원인</th>
      <th>해결</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Rate Limit 초과</td>
      <td>배치가 한 번에 너무 많이 발급 시도</td>
      <td><code class="language-plaintext highlighter-rouge">ACCOUNT_SPEC.maxCharge</code> (10건) 로 조회 제한</td>
    </tr>
    <tr>
      <td>ACME 계정 중복 생성</td>
      <td>매 발급마다 계정 새로 등록</td>
      <td>Lazy 초기화로 한 번만 생성·재사용</td>
    </tr>
    <tr>
      <td>PROCESSING 상태 교착</td>
      <td>비동기 발급 중 서버 재시작</td>
      <td><code class="language-plaintext highlighter-rouge">pendingIssueProcessFailureAllJob</code> 으로 <code class="language-plaintext highlighter-rouge">jobRunExpectAt</code> 초과 PROCESSING 을 실패 처리 + 재시도</td>
    </tr>
    <tr>
      <td>도메인 재연결 시 이벤트 미발행</td>
      <td>기존 SUCCESS 가 있어 발급 절차 스킵</td>
      <td>도메인 연결 시 발급 절차를 항상 진행, 이미 발급된 경우 기존 인증서의 성공 이벤트 즉시 발행</td>
    </tr>
    <tr>
      <td>실패 이벤트 중복 발행</td>
      <td>도메인 검증 실패 / 발급 실패가 각각 별도 이벤트</td>
      <td>통합 + 최대 시도 초과한 최종 실패에만 발행</td>
    </tr>
  </tbody>
</table>

<p>설계부터 QA 까지 약 8주. 일정 산정은 18.5MD (3.7주) 였지만 설계 시간과 엣지 케이스 대응이 두 배 이상 갔습니다. 설계에 시간을 더 쓰면 구현이 빨라진다는 걸 체감했습니다.</p>

<h2 id="3-2편-와일드카드-갱신-운영-자동화-1년-뒤">3. 2편: 와일드카드 갱신 운영 자동화 (1년 뒤)</h2>

<h3 id="수동-갱신의-한계">수동 갱신의 한계</h3>

<p>플랫폼 와일드카드 (<code class="language-plaintext highlighter-rouge">*.platform.com</code>) 의 갱신이 90일마다 수동이었습니다.</p>

<ol>
  <li>certbot 으로 DNS-01 발급 (수동 TXT 설정)</li>
  <li>4개 클러스터에 인증서 적용 (kubectl patch / YAML 수정 / git push)</li>
  <li>환경별 검증 (브라우저에서 하나씩)</li>
</ol>

<p>회당 1~2시간. 게다가 동일 도메인 세트 재발급 제한 (주당 5회) 으로 실수하면 7일 대기. 자동화 목표는 “사람 개입 없는 격월 크론”.</p>

<h3 id="dns-01-자동화-docker-certbot--호스트-dns-handler-ipc">DNS-01 자동화: Docker certbot + 호스트 DNS Handler IPC</h3>

<p>DNS Plus 에 certbot 공식 플러그인이 없으니 <code class="language-plaintext highlighter-rouge">--manual-auth-hook</code> 으로 API 를 직접 호출해야 합니다. certbot 을 Docker 로 실행하면 환경 격리·재현성을 얻지만, DNS API 호출에 필요한 <code class="language-plaintext highlighter-rouge">curl</code>·<code class="language-plaintext highlighter-rouge">jq</code> 는 호스트에 있습니다. <strong>파일 기반 IPC</strong> 로 분리.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Docker certbot]                    [호스트 DNS Handler]
     │                                     │
     ├── auth-hook 실행                     │
     │   ├── request 파일 생성 ────────→    │
     │   │   (DOMAIN, VALIDATION)          │
     │   │                                 ├── DNS API 호출 (TXT 레코드 설정)
     │   │                                 ├── DNS 전파 폴링 (8.8.8.8, 3초 간격)
     │   ← done 파일 감지 ←────────────    ├── done 파일 생성
     │   └── certbot 검증 진행              │
</code></pre></div></div>

<p>설계 포인트 3가지.</p>

<ol>
  <li><strong>파일 통신</strong>: Docker 볼륨 마운트로 <code class="language-plaintext highlighter-rouge">/certbot-comm</code> 공유</li>
  <li><strong>challenge 값 누적</strong>: <code class="language-plaintext highlighter-rouge">*.platform.com</code> 과 <code class="language-plaintext highlighter-rouge">platform.com</code> 은 같은 TXT 레코드 사용. 도메인별 challenge 파일에 값을 누적하여 한 번의 API 호출로 전체 업데이트</li>
  <li><strong>DNS 전파 폴링</strong>: 고정 대기 대신 Google DNS 폴링으로 실제 전파 확인. 대기 시간 최소화</li>
</ol>

<p>Docker 내부 auth-hook 은 <code class="language-plaintext highlighter-rouge">/bin/sh</code> 호환 (certbot 공식 이미지에 bash 없음).</p>

<h3 id="4개-클러스터-배포">4개 클러스터 배포</h3>

<p>플랫폼은 Dev / Prod 환경에 각각 IDC (베어메탈) 와 매니지드 K8s 두 클러스터씩, 총 4개. 인증서 하나를 각각 다른 방식으로 적용합니다.</p>

<table>
  <thead>
    <tr>
      <th>클러스터</th>
      <th>적용 방식</th>
      <th>ArgoCD 관리</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>IDC Dev/Prod</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl patch secret</code></td>
      <td>밖 (sync 시 롤백 방지)</td>
    </tr>
    <tr>
      <td>매니지드 Dev/Prod</td>
      <td>YAML annotation 교체 + <code class="language-plaintext highlighter-rouge">kubectl apply</code></td>
      <td>밖</td>
    </tr>
    <tr>
      <td>Helm</td>
      <td>git push → ArgoCD auto-sync</td>
      <td>안</td>
    </tr>
  </tbody>
</table>

<p>IDC 가 ArgoCD 관리 밖에 있는 이유는 sync 시 인증서가 이전 버전으로 롤백되기 때문. 매니지드 클러스터는 로드밸런서 annotation 에 인증서를 직접 포함하므로 <code class="language-plaintext highlighter-rouge">yq</code> 로 annotation 만 교체합니다.</p>

<h3 id="kubectl-context-이식성">kubectl context 이식성</h3>

<p>로컬 (macOS) 과 Jenkins 서버에서 context 이름이 달라 스크립트가 깨졌습니다. 추상화로 해결.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>resolve_context<span class="o">()</span> <span class="o">{</span>
    <span class="k">case</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="k">in
        </span>alpha-idc<span class="p">)</span>
            <span class="nv">local_name</span><span class="o">=</span><span class="s2">"platform-alpha-idc"</span>
            <span class="nv">jenkins_name</span><span class="o">=</span><span class="s2">"dev-idc"</span>
            <span class="p">;;</span>
    <span class="k">esac</span>
    <span class="k">if </span>kubectl config get-contexts <span class="s2">"</span><span class="nv">$local_name</span><span class="s2">"</span> &amp;&gt;/dev/null<span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$local_name</span><span class="s2">"</span>
    <span class="k">else
        </span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$jenkins_name</span><span class="s2">"</span>
    <span class="k">fi</span>
<span class="o">}</span>
</code></pre></div></div>

<p>스크립트가 실행 환경을 자동 감지. 로컬 테스트와 Jenkins 실행이 같은 스크립트를 씁니다.</p>

<h3 id="jenkins-파이프라인-파라미터-매트릭스">Jenkins 파이프라인: 파라미터 매트릭스</h3>

<p>발급과 배포를 독립 제어.</p>

<table>
  <thead>
    <tr>
      <th>파라미터</th>
      <th>값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ISSUE</code></td>
      <td>NONE / STAGING / PRODUCTION</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DEPLOY</code></td>
      <td>NONE / ALPHA / REAL / ALL</td>
    </tr>
  </tbody>
</table>

<p>이 매트릭스로 다양한 시나리오 대응.</p>
<ul>
  <li>발급 테스트만: <code class="language-plaintext highlighter-rouge">ISSUE=STAGING, DEPLOY=NONE</code></li>
  <li>Alpha 먼저 검증: <code class="language-plaintext highlighter-rouge">ISSUE=PRODUCTION, DEPLOY=ALPHA</code></li>
  <li>Alpha 확인 후 Real: <code class="language-plaintext highlighter-rouge">ISSUE=NONE, DEPLOY=REAL</code></li>
  <li>정기 갱신: <code class="language-plaintext highlighter-rouge">ISSUE=PRODUCTION, DEPLOY=ALL</code></li>
</ul>

<p>크론 트리거 (격월 1일 정오 KST).</p>

<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">triggers</span> <span class="o">{</span> <span class="n">cron</span><span class="o">(</span><span class="s1">'0 12 1 */2 *'</span><span class="o">)</span> <span class="o">}</span>
</code></pre></div></div>

<p>크론 실행 감지 시 <code class="language-plaintext highlighter-rouge">ISSUE=PRODUCTION, DEPLOY=ALL</code> 자동 설정. 배포 결과는 메신저 webhook 으로 알림.</p>

<h3 id="엣지-케이스-3가지">엣지 케이스 3가지</h3>

<table>
  <thead>
    <tr>
      <th>이슈</th>
      <th>원인</th>
      <th>해결</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Staging → Production 전환 시 충돌</td>
      <td>certbot 이 renewal config 에 ACME 서버 URL 기록. 서버 불일치로 실패</td>
      <td>발급 전 기존 ACME 서버 확인, 다르면 인증서 디렉토리 정리</td>
    </tr>
    <tr>
      <td>Docker root 파일 권한</td>
      <td>certbot Docker 가 root 로 생성한 파일을 Jenkins (non-root) 가 삭제 못함</td>
      <td><code class="language-plaintext highlighter-rouge">rm -rf</code> 가 <code class="language-plaintext highlighter-rouge">set -e</code> 로 실패하기 전, Docker alpine 컨테이너로 먼저 정리</td>
    </tr>
    <tr>
      <td>인증서 검증 대기</td>
      <td>적용 후 로드밸런서 리로드·ArgoCD sync 에 시간 필요</td>
      <td><code class="language-plaintext highlighter-rouge">cert-verify.sh</code> wait 모드로 최대 10분, 30초 간격 재시도</td>
    </tr>
  </tbody>
</table>

<h2 id="4-1년의-학습-차이와-ai-협업">4. 1년의 학습 차이와 AI 협업</h2>

<p>1편에서는 certbot 과 DNS-01 의 동작 원리를 이해하기 위해 8주를 투자했습니다. 그 경험이 있었기에 2편에서 “무엇을 자동화해야 하는지”, “어디서 문제가 생길 수 있는지” 를 판단할 수 있었고, AI 에게 올바른 방향을 제시할 수 있었습니다.</p>

<p>2편의 자동화에서는 스크립트와 Jenkins 파이프라인 코드 작성을 AI 에 위임했고, 본인은 전체 플로우 설계와 의사결정에 집중했습니다. 결과적으로 1편보다 훨씬 짧은 시간에 운영 수준의 자동화에 도달했습니다.</p>

<p>다만 엣지 케이스 (Staging → Production 충돌, Docker root 권한, DNS 전파 폴링 같은 디테일) 은 AI 가 처음부터 알려주지 않습니다. 실제로 한 번 겪어봐야 발견되는 종류입니다. 1편의 운영 경험이 그 발견의 토대였습니다.</p>

<h2 id="회고-깊이가-자동화-품질을-만든다">회고: 깊이가 자동화 품질을 만든다</h2>

<table>
  <thead>
    <tr>
      <th>시기</th>
      <th>작업</th>
      <th>깊이</th>
      <th>AI 협업</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2024 말 (8주)</td>
      <td>사용자 도메인 BE 자동 발급</td>
      <td>ACME 학습부터, 깊이 우선</td>
      <td>부분</td>
    </tr>
    <tr>
      <td>2026 초 (단기간)</td>
      <td>와일드카드 갱신 운영 자동화</td>
      <td>1편의 깊이를 자동화 판단 기반으로</td>
      <td>적극</td>
    </tr>
  </tbody>
</table>

<p>도구 (certbot, ACME4j, Jenkins) 는 시간이 흐르며 바뀝니다. 바뀌지 않는 것은 <strong>그 도구가 어떤 제약 위에서 동작하는지</strong> 를 이해하는 깊입니다. Rate Limit, DNS 전파, 권한 모델, 환경별 적용 방식. 이 제약을 모르고 만든 자동화는 갱신 한 번에 무너집니다. 1편의 8주가 비싸 보였지만 2편에서 회수됐습니다.</p>

<p>AI 가 코드를 빠르게 생성해주는 시대에는, 어떤 사람의 자동화가 더 단단한가가 더 잘 드러납니다. 답은 단순합니다. “어떤 상황에서 실패할 수 있는가” 를 아는 사람의 자동화가 단단합니다.</p>

<hr />

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="개발 기록" /><category term="Let&apos;s Encrypt" /><category term="ACME" /><category term="ACME4j" /><category term="certbot" /><category term="헥사고날 아키텍처" /><category term="Docker" /><category term="Jenkins" /><category term="자동화" /><category term="Spring Boot" /><summary type="html"><![CDATA[사용자 도메인 ACME4j 자동 발급과 와일드카드 인증서 Jenkins 갱신 두 사이클을 한 흐름으로 정리합니다.]]></summary></entry><entry><title type="html">자바 개발자가 본 uvx: 개념·PyPI 배포·GitHub Actions·requires-python 함정까지</title><link href="https://blog.idean.me/posts/uvx-from-java-developer/" rel="alternate" type="text/html" title="자바 개발자가 본 uvx: 개념·PyPI 배포·GitHub Actions·requires-python 함정까지" /><published>2026-05-17T22:50:00+09:00</published><updated>2026-05-18T15:05:00+09:00</updated><id>https://blog.idean.me/posts/uvx-from-java-developer</id><content type="html" xml:base="https://blog.idean.me/posts/uvx-from-java-developer/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
uvx 는 PyPI 패키지를 격리 환경에서 실행하는 명령(=<code class="language-plaintext highlighter-rouge">uv tool run</code>). 자바의 jbang 자리에 가깝습니다.<br />
배포는 <code class="language-plaintext highlighter-rouge">pyproject.toml</code> 의 <code class="language-plaintext highlighter-rouge">[project.scripts]</code> 와 <code class="language-plaintext highlighter-rouge">uv build</code>/<code class="language-plaintext highlighter-rouge">uv publish</code>, GitHub Actions 의 validate→publish 2단계로 자동화합니다.<br />
함정 하나: uvx 는 <code class="language-plaintext highlighter-rouge">requires-python</code> 을 의도적으로 무시합니다. 사용자 환경 Python 이 낮으면 <code class="language-plaintext highlighter-rouge">setup.sh</code> 에서 <code class="language-plaintext highlighter-rouge">--python</code> 플래그를 자동 부여하는 것으로 막습니다.</p>
</blockquote>

<h2 id="1-왜-uvx-인가">1. 왜 uvx 인가</h2>

<p><a href="https://github.com/idean3885/claude-slack-to-notion">claude-slack-to-notion</a> MCP 플러그인을 배포하면서 uvx 를 처음 접했습니다. MCP 공식 문서가 Python 서버 실행 방법으로 uvx 를 “recommended” 로 명시하고, Anthropic 공식 MCP 서버인 <code class="language-plaintext highlighter-rouge">mcp-server-git</code> 도 uvx 기반으로 배포됩니다. 사실상 Python MCP 플러그인의 표준 경로였습니다.</p>

<p>자바 개발자라서 처음엔 낯설었습니다. 대응표를 만들어보니 이해가 빨라졌습니다.</p>

<table>
  <thead>
    <tr>
      <th>Java</th>
      <th>Python (uv)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SDKMAN (<code class="language-plaintext highlighter-rouge">sdk install java 21</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">uv python install 3.12</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pom.xml</code> / <code class="language-plaintext highlighter-rouge">build.gradle</code></td>
      <td><code class="language-plaintext highlighter-rouge">pyproject.toml</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">mvn archetype:generate</code></td>
      <td><code class="language-plaintext highlighter-rouge">uv init</code></td>
    </tr>
    <tr>
      <td>Maven/Gradle 의존성 관리</td>
      <td><code class="language-plaintext highlighter-rouge">uv add</code>, <code class="language-plaintext highlighter-rouge">uv lock</code>, <code class="language-plaintext highlighter-rouge">uv sync</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">./gradlew run</code></td>
      <td><code class="language-plaintext highlighter-rouge">uv run</code></td>
    </tr>
    <tr>
      <td>jbang (한 줄 실행)</td>
      <td><code class="language-plaintext highlighter-rouge">uvx &lt;tool&gt;</code></td>
    </tr>
    <tr>
      <td>Maven Central</td>
      <td>PyPI</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MANIFEST.MF</code> Main-Class</td>
      <td><code class="language-plaintext highlighter-rouge">[project.scripts]</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">mvn package</code> / <code class="language-plaintext highlighter-rouge">./gradlew build</code></td>
      <td><code class="language-plaintext highlighter-rouge">uv build</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">mvn deploy</code> / Nexus 업로드</td>
      <td><code class="language-plaintext highlighter-rouge">uv publish</code></td>
    </tr>
  </tbody>
</table>

<p>명령어만 다르고 하는 일은 거의 같습니다.</p>

<h3 id="uvx-의-정의">uvx 의 정의</h3>

<p>uvx 는 <code class="language-plaintext highlighter-rouge">uv tool run</code> 의 별칭. PyPI 패키지를 격리된 가상환경에서 실행하는 명령입니다. Node 의 <code class="language-plaintext highlighter-rouge">npx</code> 와 같은 자리.</p>

<table>
  <thead>
    <tr>
      <th>모드</th>
      <th>명령어</th>
      <th>생명주기</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>임시 실행</td>
      <td><code class="language-plaintext highlighter-rouge">uvx &lt;tool&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">uv cache clean</code> 시 삭제</td>
    </tr>
    <tr>
      <td>영구 설치</td>
      <td><code class="language-plaintext highlighter-rouge">uv tool install &lt;tool&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">uv tool uninstall</code> 시 삭제</td>
    </tr>
  </tbody>
</table>

<p>임시 실행 파일은 <code class="language-plaintext highlighter-rouge">~/.cache/uv/</code>, 영구 설치는 <code class="language-plaintext highlighter-rouge">~/.local/share/uv/tools/</code> 에 저장. MCP 서버처럼 매 세션마다 실행되는 경우에도 캐시 재사용으로 체감 성능 차이는 없습니다.</p>

<h3 id="설치와-기본-사용">설치와 기본 사용</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS</span>
brew <span class="nb">install </span>uv

<span class="c"># pip (범용)</span>
pip <span class="nb">install </span>uv

<span class="c"># curl (Linux/macOS)</span>
curl <span class="nt">-LsSf</span> https://astral.sh/uv/install.sh | sh
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uvx ruff check <span class="nb">.</span>                        <span class="c"># 임시 실행</span>
uvx ruff@0.3.0 check <span class="nb">.</span>                  <span class="c"># 특정 버전</span>
uvx <span class="nt">--from</span> <span class="s1">'ruff&gt;0.2.0,&lt;0.3.0'</span> ruff <span class="nb">.</span>   <span class="c"># 버전 범위 (--from 필수)</span>
uvx <span class="nt">--from</span> httpie http                  <span class="c"># 패키지명 ≠ 커맨드명</span>
uvx <span class="nt">--with</span> mkdocs-material mkdocs build <span class="c"># 추가 의존성</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--from</code> 은 패키지 이름과 실행 커맨드 이름이 다를 때 또는 버전 범위 연산자를 쓸 때 필요.</p>

<h2 id="2-pypi-배포-pyprojecttoml-의-진입점">2. PyPI 배포: pyproject.toml 의 진입점</h2>

<p>uvx 로 실행 가능한 패키지를 만들려면 두 가지가 필요합니다. PyPI 에 배포된 패키지, 그리고 진입점이 정의된 <code class="language-plaintext highlighter-rouge">pyproject.toml</code>.</p>

<p>실제 프로젝트 핵심 발췌:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">project</span><span class="k">]</span>
<span class="n">name</span> <span class="o">=</span><span class="w"> </span><span class="s">"slack-to-notion-mcp"</span>
<span class="n">version</span> <span class="o">=</span><span class="w"> </span><span class="s">"0.1.0"</span>
<span class="n">requires-python</span> <span class="o">=</span><span class="w"> </span><span class="s">"&gt;=3.10"</span>
<span class="n">dependencies</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span>
    <span class="s">"slack_sdk&gt;=3.27.0"</span><span class="p">,</span>
    <span class="s">"notion-client&gt;=2.2.0"</span><span class="p">,</span>
    <span class="s">"mcp[cli]&gt;=1.0.0"</span><span class="p">,</span>
<span class="p">]</span>

<span class="c"># uvx 가 실행할 커맨드 정의 (핵심)</span>
<span class="k">[</span><span class="n">project</span><span class="k">.</span><span class="n">scripts</span><span class="k">]</span>
<span class="n">slack-to-notion-mcp</span> <span class="o">=</span><span class="w"> </span><span class="s">"slack_to_notion.mcp_server:main"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">[project.scripts]</code> 가 핵심. <code class="language-plaintext highlighter-rouge">uvx slack-to-notion-mcp</code> 를 실행했을 때 어떤 Python 함수가 호출될지 여기서 정의합니다. 자바의 <code class="language-plaintext highlighter-rouge">MANIFEST.MF</code> <code class="language-plaintext highlighter-rouge">Main-Class</code> 자리. 형식은 <code class="language-plaintext highlighter-rouge">커맨드명 = "모듈.경로:함수명"</code>.</p>

<p>빌드와 배포:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv version 1.0.0           <span class="c"># 버전 지정</span>
uv version <span class="nt">--bump</span> minor    <span class="c"># 자동 bump</span>
uv build                   <span class="c"># dist/ 에 wheel + sdist</span>
uv publish                 <span class="c"># PyPI 배포</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">uv build</code> 산출물:</p>

<table>
  <thead>
    <tr>
      <th>산출물</th>
      <th>형식</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>wheel (<code class="language-plaintext highlighter-rouge">.whl</code>)</td>
      <td>미리 빌드된 패키지</td>
      <td>설치 시 빌드 불필요 (Java <code class="language-plaintext highlighter-rouge">.jar</code> 과 동일 사상)</td>
    </tr>
    <tr>
      <td>sdist (<code class="language-plaintext highlighter-rouge">.tar.gz</code>)</td>
      <td>소스 아카이브</td>
      <td>wheel 미지원 환경 폴백</td>
    </tr>
  </tbody>
</table>

<p>wheel 의 핵심은 <strong>사용자 환경에서 빌드를 제거</strong>하는 것. PyPI 자체는 빌드하지 않고 GitHub Actions 같은 곳에서 빌드한 결과물을 저장하는 저장소입니다 (Maven Central, npm registry 와 같은 자리).</p>

<h3 id="git-직접-실행-대안-검토">Git 직접 실행 대안 검토</h3>

<p>uvx 는 PyPI 없이도 실행할 수 있습니다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uvx <span class="nt">--from</span> git+https://github.com/user/repo slack-to-notion-mcp
</code></pre></div></div>

<p>PyPI 배포가 빠지니 매력적으로 보였습니다. 검토 후 기각:</p>

<ol>
  <li><strong>비표준</strong>: Anthropic 공식 MCP 서버 포함 Python 도구는 거의 모두 PyPI 로 배포. Git 직접 실행은 Python 진영의 일반적 배포 방식이 아님</li>
  <li><strong>매번 소스 빌드</strong>: wheel 이 아닌 소스를 받으므로 사용자 환경에서 매번 빌드. wheel 의 존재 이유가 이 빌드를 제거하기 위함</li>
  <li><strong>보안 서명 불가</strong>: PyPI Trusted Publishing(OIDC) 은 빌드 환경을 검증. Git 직접 실행에는 이런 서명 체계가 없음</li>
</ol>

<p>대안을 검토해봐야 “왜 PyPI 를 쓰는가” 에 답이 명확해집니다.</p>

<h2 id="3-mcp-플러그인-연결-mcpjson">3. MCP 플러그인 연결: <code class="language-plaintext highlighter-rouge">.mcp.json</code></h2>

<p>PyPI 에 배포된 패키지는 <code class="language-plaintext highlighter-rouge">.mcp.json</code> 한 블록으로 연결됩니다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"slack-to-notion"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"uvx"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"slack-to-notion-mcp"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"SLACK_BOT_TOKEN"</span><span class="p">:</span><span class="w"> </span><span class="s2">"${SLACK_BOT_TOKEN}"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"SLACK_USER_TOKEN"</span><span class="p">:</span><span class="w"> </span><span class="s2">"${SLACK_USER_TOKEN}"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"NOTION_API_KEY"</span><span class="p">:</span><span class="w"> </span><span class="s2">"${NOTION_API_KEY}"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"NOTION_PARENT_PAGE_ID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"${NOTION_PARENT_PAGE_ID}"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">mcp-server-git</code> 도 <code class="language-plaintext highlighter-rouge">"command": "uvx"</code>, <code class="language-plaintext highlighter-rouge">"args": ["mcp-server-git"]</code> 동일 구조.</p>

<p>uvx 기반 MCP 서버의 장점:</p>
<ol>
  <li><strong>사용자 환경 설정 불필요</strong>: uvx 가 가상환경·의존성을 모두 처리</li>
  <li><strong>버전 고정 가능</strong>: <code class="language-plaintext highlighter-rouge">args</code> 에 <code class="language-plaintext highlighter-rouge">"slack-to-notion-mcp@0.2.0"</code> 명시</li>
  <li><strong>의존성 격리</strong>: 사용자 프로젝트 Python 환경과 충돌 없음</li>
  <li><strong>업데이트 간편</strong>: 설정의 버전 번호만 변경</li>
</ol>

<h2 id="4-github-actions-자동화-validate--publish--auto-tag">4. GitHub Actions 자동화: validate → publish → auto-tag</h2>

<p>처음에는 수동 <code class="language-plaintext highlighter-rouge">uv publish</code>. 태그 <code class="language-plaintext highlighter-rouge">v0.2.0</code> 인데 <code class="language-plaintext highlighter-rouge">pyproject.toml</code> 이 <code class="language-plaintext highlighter-rouge">0.1.9</code> 로 올라간 버전 불일치 사고 한 번을 겪고 자동화로 갔습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">PyPI 배포</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">v*"</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">validate</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">astral-sh/setup-uv@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">태그-버전 일치 확인</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">TAG_VERSION="${GITHUB_REF_NAME#v}"</span>
          <span class="s">PKG_VERSION=$(uv run python -c "</span>
          <span class="s">import tomllib</span>
          <span class="s">with open('pyproject.toml', 'rb') as f:</span>
              <span class="s">print(tomllib.load(f)['project']['version'])</span>
          <span class="s">")</span>
          <span class="s">if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then</span>
            <span class="s">echo "::error::태그($TAG_VERSION)와 pyproject.toml 버전($PKG_VERSION)이 불일치"</span>
            <span class="s">exit 1</span>
          <span class="s">fi</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">uv sync --extra dev &amp;&amp; uv run pytest tests/ -v</span>

  <span class="na">publish</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">validate</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">astral-sh/setup-uv@v4</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">uv build</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">uv publish</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">UV_PUBLISH_TOKEN</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<p>핵심 설계는 <code class="language-plaintext highlighter-rouge">validate</code> → <code class="language-plaintext highlighter-rouge">publish</code> 2단계 분리. 태그 버전과 <code class="language-plaintext highlighter-rouge">pyproject.toml</code> 버전이 다르면 배포 전에 실패합니다.</p>

<p>인증 방식:</p>

<table>
  <thead>
    <tr>
      <th>방식</th>
      <th>설정</th>
      <th>보안</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>API 토큰</td>
      <td>GitHub Secret 에 PyPI 토큰 저장</td>
      <td>양호</td>
    </tr>
    <tr>
      <td>Trusted Publisher (OIDC)</td>
      <td>PyPI + GitHub 환경 1회 설정</td>
      <td>최상 (시크릿 불필요)</td>
    </tr>
  </tbody>
</table>

<p>신규 프로젝트라면 처음부터 Trusted Publisher 권장.</p>

<h3 id="auto-tagyml-태그까지-자동화">auto-tag.yml: 태그까지 자동화</h3>

<p><code class="language-plaintext highlighter-rouge">pyproject.toml</code> 버전이 변경되어 main 에 머지되면 태그를 자동 생성하는 워크플로우.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Auto Tag</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">pyproject.toml"</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">tag</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">token</span><span class="pi">:</span> <span class="s">$</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">버전 읽기 + 태그 생성</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*= *"\(.*\)"/\1/')</span>
          <span class="s">TAG="v$VERSION"</span>
          <span class="s">if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then</span>
            <span class="s">echo "태그 $TAG 이미 존재: 스킵"</span>
          <span class="s">else</span>
            <span class="s">git config user.name "github-actions[bot]"</span>
            <span class="s">git config user.email "github-actions[bot]@users.noreply.github.com"</span>
            <span class="s">git tag "$TAG" &amp;&amp; git push origin "$TAG"</span>
          <span class="s">fi</span>
</code></pre></div></div>

<p>함정 하나: <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code> 대신 <code class="language-plaintext highlighter-rouge">AUTO_TAG_PAT</code> 을 써야 합니다. <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code> 으로 생성한 태그는 다른 워크플로우를 트리거하지 않습니다 (GitHub 의 무한 루프 방지 정책).</p>

<p>이제 손이 닿는 곳은 <code class="language-plaintext highlighter-rouge">pyproject.toml</code> 의 버전 수정뿐.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PR 머지 (pyproject.toml version 변경 포함)
  → auto-tag.yml: 태그 자동 생성
    → pypi-publish.yml: validate + PyPI 배포
</code></pre></div></div>

<h2 id="5-함정-uvx-는-requires-python-을-무시한다">5. 함정: uvx 는 <code class="language-plaintext highlighter-rouge">requires-python</code> 을 무시한다</h2>

<p>v0.2.0 배포 후 “Python 3.9 쓰는 사람한테는 왜 안 되지?” 라는 질문을 받았습니다. <code class="language-plaintext highlighter-rouge">pyproject.toml</code> 에 <code class="language-plaintext highlighter-rouge">requires-python = "&gt;=3.10"</code> 을 기재했으니 3.9 사용자는 설치 단계에서 막힐 거라고 생각했습니다. <strong>틀렸습니다.</strong></p>

<p>설치는 됐고, 실행도 됐고, 동작이 이상했습니다.</p>

<h3 id="uv-공식-문서">uv 공식 문서</h3>

<blockquote>
  <p>“will ignore non-global Python version requests like .python-version files and the requires-python value”</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">requires-python</code> 무시. <code class="language-plaintext highlighter-rouge">.python-version</code> 무시. 버그가 아니라 의도된 설계. GitHub 이슈 #8206, #14958 에서 이미 논의된 내용입니다.</p>

<p>자바 관점에서 보면 이게 얼마나 낯선지 설명이 됩니다. Maven 에서 <code class="language-plaintext highlighter-rouge">&lt;java.version&gt;11&lt;/java.version&gt;</code> 을 <code class="language-plaintext highlighter-rouge">pom.xml</code> 에 두면 JDK 8 로 빌드하려는 순간 컴파일 에러가 납니다. uvx 는 그 체크 자체를 안 합니다.</p>

<h3 id="uvx-의-python-선택-우선순위">uvx 의 Python 선택 우선순위</h3>

<ol>
  <li><code class="language-plaintext highlighter-rouge">--python</code> 플래그 (명시적 지정)</li>
  <li><code class="language-plaintext highlighter-rouge">UV_PYTHON</code> 환경변수</li>
  <li>uv 가 관리하는 Python (<code class="language-plaintext highlighter-rouge">~/.local/share/uv/python/</code>)</li>
  <li>시스템 PATH 의 Python</li>
</ol>

<blockquote class="prompt-danger">
  <p><code class="language-plaintext highlighter-rouge">requires-python</code> 은 이 목록에 없습니다.</p>
</blockquote>

<h3 id="uvx-vs-uv-run-의-결정적-차이"><code class="language-plaintext highlighter-rouge">uvx</code> vs <code class="language-plaintext highlighter-rouge">uv run</code> 의 결정적 차이</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>uvx (도구 실행)</th>
      <th>uv run (프로젝트 실행)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>requires-python</td>
      <td>무시</td>
      <td>반영</td>
    </tr>
    <tr>
      <td>.python-version</td>
      <td>무시</td>
      <td>반영</td>
    </tr>
    <tr>
      <td>환경</td>
      <td>격리 venv (캐시)</td>
      <td>프로젝트 venv</td>
    </tr>
    <tr>
      <td>Python 자동 다운로드</td>
      <td>조건부</td>
      <td>필요 시 자동</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">uv run</code> 은 프로젝트 맥락이 있고, <code class="language-plaintext highlighter-rouge">uvx</code> 는 도구 격리 실행에 집중합니다. 자바로 비유하면 <code class="language-plaintext highlighter-rouge">./gradlew run</code> 과 <code class="language-plaintext highlighter-rouge">jbang script.java</code> 의 차이.</p>

<h3 id="해결-setupsh-에서-자동-처리">해결: setup.sh 에서 자동 처리</h3>

<p>방법은 두 가지. 사용자에게 <code class="language-plaintext highlighter-rouge">uvx --python 3.10 slack-to-notion-mcp</code> 를 안내하거나, setup.sh 에서 자동 처리. 후자 선택.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Python 버전 확인 (3.10 미만이면 --python 3.10 자동 부여)</span>
<span class="nv">PYTHON_TOO_OLD</span><span class="o">=</span><span class="nb">false
</span><span class="k">if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$PYTHON_CMD</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">PYTHON_VERSION</span><span class="o">=</span><span class="si">$(</span><span class="nv">$PYTHON_CMD</span> <span class="nt">-c</span> <span class="se">\</span>
    <span class="s2">"import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"</span><span class="si">)</span>
  <span class="nv">PYTHON_MAJOR</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$PYTHON_VERSION</span><span class="s2">"</span> | <span class="nb">cut</span> <span class="nt">-d</span><span class="nb">.</span> <span class="nt">-f1</span><span class="si">)</span>
  <span class="nv">PYTHON_MINOR</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$PYTHON_VERSION</span><span class="s2">"</span> | <span class="nb">cut</span> <span class="nt">-d</span><span class="nb">.</span> <span class="nt">-f2</span><span class="si">)</span>
  <span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$PYTHON_MAJOR</span><span class="s2">"</span> <span class="nt">-lt</span> 3 <span class="o">]]</span> <span class="o">||</span> <span class="se">\</span>
     <span class="o">{</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$PYTHON_MAJOR</span><span class="s2">"</span> <span class="nt">-eq</span> 3 <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$PYTHON_MINOR</span><span class="s2">"</span> <span class="nt">-lt</span> 10 <span class="o">]]</span><span class="p">;</span> <span class="o">}</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">PYTHON_TOO_OLD</span><span class="o">=</span><span class="nb">true
  </span><span class="k">fi
else
  </span><span class="nv">PYTHON_TOO_OLD</span><span class="o">=</span><span class="nb">true
</span><span class="k">fi

if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$PYTHON_TOO_OLD</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"true"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span>claude mcp add slack-to-notion ... <span class="nt">--</span> uvx <span class="nt">--python</span> 3.10 slack-to-notion-mcp
<span class="k">else
  </span>claude mcp add slack-to-notion ... <span class="nt">--</span> uvx slack-to-notion-mcp
<span class="k">fi</span>
</code></pre></div></div>

<p>시스템 Python 이 3.10 이상이면 그대로, 미만이면 <code class="language-plaintext highlighter-rouge">--python 3.10</code> 을 자동으로 붙입니다. <code class="language-plaintext highlighter-rouge">--python 3.10</code> 지정 시 uv 가 자동으로 Python 3.10 을 찾거나 다운로드합니다.</p>

<p><code class="language-plaintext highlighter-rouge">requires-python</code> 은 지우지 않습니다. <code class="language-plaintext highlighter-rouge">pip install</code> 사용자에게는 작동하고 PyPI 메타데이터 표시에도 쓰입니다. uvx 에서만 무시될 뿐.</p>

<h2 id="회고-생태계가-다르면-안전장치도-다르다">회고: 생태계가 다르면 안전장치도 다르다</h2>

<p>Java 에서는 Maven/Gradle 이 JDK 버전을 강제합니다. 버전 불일치는 빌드 도구가 막아줍니다. 그래서 <code class="language-plaintext highlighter-rouge">pom.xml</code> 에 버전을 쓰면 끝이라고 생각했습니다. Python uvx 에서는 그렇지 않습니다. 도구 실행 맥락에서는 설계 의도상 프로젝트 메타데이터를 보지 않습니다.</p>

<p>알고 나면 간단한 이유가 있고 모르면 “왜 안 되지?” 에서 한참 헤맵니다. 새 생태계로 옮길 때 가장 비싼 비용은 그 차이를 발견하는 시간입니다.</p>

<p>uvx 로 한 사이클을 돌면서 얻은 것:</p>

<table>
  <thead>
    <tr>
      <th>영역</th>
      <th>결론</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>입문</td>
      <td>jbang/npx 와 같은 자리, 자바 도구 대응만 잡으면 충분</td>
    </tr>
    <tr>
      <td>배포</td>
      <td><code class="language-plaintext highlighter-rouge">[project.scripts]</code> 진입점 + <code class="language-plaintext highlighter-rouge">uv build</code>/<code class="language-plaintext highlighter-rouge">uv publish</code>, PyPI 가 표준 경로</td>
    </tr>
    <tr>
      <td>자동화</td>
      <td>validate → publish 2단계 + auto-tag 로 <code class="language-plaintext highlighter-rouge">pyproject.toml</code> 한 곳만 손대게</td>
    </tr>
    <tr>
      <td>함정</td>
      <td><code class="language-plaintext highlighter-rouge">requires-python</code> 은 uvx 에서 무시됨, setup.sh 에서 <code class="language-plaintext highlighter-rouge">--python</code> 자동 부여로 막기</td>
    </tr>
  </tbody>
</table>

<p>동작하는 코드에 만족하지 않고 왜 그 방식인지 파고들면 새 생태계의 골격이 빠르게 보입니다.</p>

<hr />

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="기술 노하우" /><category term="uvx" /><category term="uv" /><category term="Python" /><category term="MCP" /><category term="pyproject.toml" /><category term="PyPI" /><category term="GitHub Actions" /><category term="requires-python" /><summary type="html"><![CDATA[자바 개발자가 uvx로 MCP 플러그인을 PyPI에 배포하며 겪은 입문·GitHub Actions·requires-python 함정을 정리합니다.]]></summary></entry><entry><title type="html">Chirpy 블로그 운영: SEO·GEO·댓글·커스텀 도메인까지</title><link href="https://blog.idean.me/posts/chirpy-blog-operation/" rel="alternate" type="text/html" title="Chirpy 블로그 운영: SEO·GEO·댓글·커스텀 도메인까지" /><published>2026-05-17T22:40:00+09:00</published><updated>2026-05-18T15:05:00+09:00</updated><id>https://blog.idean.me/posts/chirpy-blog-operation</id><content type="html" xml:base="https://blog.idean.me/posts/chirpy-blog-operation/"><![CDATA[<blockquote class="prompt-tip">
  <p><strong>TL;DR</strong><br />
Chirpy 테마는 SEO 기본기·OG 태그·sitemap·robots.txt·JSON-LD를 자동 생성하므로 추가 설정이 거의 없습니다.<br />
GEO 대응도 새 기법보다 누락된 기본기(JSON-LD author, llms.txt, last_modified_at) 보완이 핵심이었습니다.<br />
Giscus 댓글은 <code class="language-plaintext highlighter-rouge">_config.yml</code> 13줄, 커스텀 도메인은 <code class="language-plaintext highlighter-rouge">*.github.io</code> 의 GSC 사이트맵 인식 한계를 즉시 해소했습니다.</p>
</blockquote>

<h2 id="1-seo-chirpy가-이미-다-하고-있었다">1. SEO: Chirpy가 이미 다 하고 있었다</h2>

<p>블로그를 시작하고 SEO를 점검했습니다. 메타태그·OG·sitemap·robots.txt·JSON-LD 를 직접 설정해야 한다고 생각했는데 결론은 <strong>대부분 이미 되어 있었습니다</strong>.</p>

<p>Chirpy 테마는 두 플러그인을 기본 의존성으로 포함합니다.</p>

<table>
  <thead>
    <tr>
      <th>플러그인</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">jekyll-seo-tag</code></td>
      <td>메타태그·OG·JSON-LD 자동 생성</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">jekyll-sitemap</code></td>
      <td>sitemap.xml·robots.txt 자동 생성</td>
    </tr>
  </tbody>
</table>

<p>별도 설치한 적 없지만 <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> 에 이미 포함되어 있습니다. front matter 에 <code class="language-plaintext highlighter-rouge">title</code>·<code class="language-plaintext highlighter-rouge">date</code>·<code class="language-plaintext highlighter-rouge">description</code> 만 쓰면 나머지는 전부 자동입니다.</p>

<h3 id="점검-결과">점검 결과</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>상태</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>og:* / canonical / meta description</td>
      <td>자동</td>
      <td>jekyll-seo-tag</td>
    </tr>
    <tr>
      <td>sitemap.xml / robots.txt</td>
      <td>자동</td>
      <td>jekyll-sitemap</td>
    </tr>
    <tr>
      <td>JSON-LD (BlogPosting)</td>
      <td>자동</td>
      <td>jekyll-seo-tag</td>
    </tr>
    <tr>
      <td>og:image</td>
      <td>미설정</td>
      <td>포스트에 <code class="language-plaintext highlighter-rouge">image</code> 필드 없음</td>
    </tr>
    <tr>
      <td>Google Search Console</td>
      <td>미등록</td>
      <td>인증 필요</td>
    </tr>
  </tbody>
</table>

<h3 id="gsc-등록은-_configyml-한-줄">GSC 등록은 <code class="language-plaintext highlighter-rouge">_config.yml</code> 한 줄</h3>

<p>GSC 에서 HTML 메타 태그 인증을 선택하면 받은 <code class="language-plaintext highlighter-rouge">content</code> 값을 <code class="language-plaintext highlighter-rouge">_config.yml</code> 한 줄에 명시하면 끝.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">webmaster_verifications</span><span class="pi">:</span>
  <span class="na">google</span><span class="pi">:</span> <span class="s">abc123xyz</span>
</code></pre></div></div>

<p>배포하면 모든 페이지의 <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> 에 인증 태그가 자동 주입됩니다. 인증 후 좌측 메뉴 → Sitemaps → <code class="language-plaintext highlighter-rouge">sitemap.xml</code> 입력 → 제출. jekyll-sitemap 이 이미 파일을 생성하므로 URL 만 등록하는 절차입니다.</p>

<blockquote class="prompt-danger">
  <p>인증 태그는 영구 유지해야 합니다. 삭제하면 수개월 후 소유권이 취소될 수 있습니다.</p>
</blockquote>

<h2 id="2-geo-새-기법보다-누락된-기본기">2. GEO: 새 기법보다 누락된 기본기</h2>

<p><a href="https://yozm.wishket.com/magazine/detail/3647/">요즘IT 콘텐츠 AX 실험기</a> 에서 GEO(Generative Engine Optimization) 를 접했습니다. AI 검색엔진(Claude·Gemini·ChatGPT 등) 이 콘텐츠를 인용하도록 최적화하는 전략.</p>

<h3 id="근거-점검">근거 점검</h3>

<p>바로 적용하기 전에 학술적 근거부터 확인했습니다. IIT Delhi·Princeton 의 <a href="https://arxiv.org/abs/2311.09735">“GEO: Generative Engine Optimization”</a> (ACM KDD 2024) 가 10,000개 쿼리로 9가지 전략을 테스트했습니다.</p>

<table>
  <thead>
    <tr>
      <th>전략</th>
      <th>가시성 향상</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>통계 추가</td>
      <td>~41%</td>
    </tr>
    <tr>
      <td>인용구 삽입</td>
      <td>~37%</td>
    </tr>
    <tr>
      <td>출처 명시</td>
      <td>~31%</td>
    </tr>
    <tr>
      <td>키워드 스터핑</td>
      <td>오히려 감소</td>
    </tr>
  </tbody>
</table>

<p>Yext 의 6.8M 인용 분석(2025.10) 에 따르면 AI Overviews URL 과 Google 1위 결과의 중복률은 <strong>4.5%에 불과</strong>. SEO 와 GEO 는 별개입니다.</p>

<p>다만 SandboxSEO 가 GEO 논문 방법론을 비판한 지점도 유효합니다. 상위 3개 전략이 모두 새 정보를 추가하는 방식이라 GEO 효과인지 단순 콘텐츠 양 증가 효과인지 구분할 수 없다는 지적, 그리고 AI 플랫폼의 인용 변동성이 월 40~60% 라는 점.</p>

<p><strong>개념은 유효하나 수치 효과는 회의적으로 봐야 합니다.</strong></p>

<h3 id="발견한-누락-json-ld-author">발견한 누락: JSON-LD author</h3>

<p>실제 출력을 확인하니 이랬습니다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"@type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"BlogPosting"</span><span class="p">,</span><span class="w"> </span><span class="nl">"headline"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="p">,</span><span class="w"> </span><span class="nl">"datePublished"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="p">,</span><span class="w"> </span><span class="nl">"dateModified"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">author</code> 와 <code class="language-plaintext highlighter-rouge">publisher</code> 가 없었습니다. <code class="language-plaintext highlighter-rouge">_config.yml</code> 에 <code class="language-plaintext highlighter-rouge">author</code> 필드가 없었기 때문. SEO·GEO 양쪽 모두에서 기본 누락이었습니다.</p>

<h3 id="저비용-적용-3가지">저비용 적용 3가지</h3>

<p>대규모 구조 변경은 근거 대비 투자가 과합니다. 저비용으로 SEO/GEO 공통 기본기만 보완했습니다.</p>

<p><strong>(1) <code class="language-plaintext highlighter-rouge">_config.yml</code> 에 author 와 logo 추가</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">author</span><span class="pi">:</span> <span class="s">idean3885</span>
<span class="na">logo</span><span class="pi">:</span> <span class="s">/assets/img/favicons/favicon-96x96.png</span>
</code></pre></div></div>

<p>jekyll-seo-tag 가 <code class="language-plaintext highlighter-rouge">author</code> → <code class="language-plaintext highlighter-rouge">Person</code>, <code class="language-plaintext highlighter-rouge">logo</code> → <code class="language-plaintext highlighter-rouge">publisher.Organization</code> 으로 JSON-LD 를 채웁니다.</p>

<p><strong>(2) <code class="language-plaintext highlighter-rouge">llms.txt</code> 추가</strong> (<a href="https://llmstxt.org/">llmstxt.org</a> 규격, 루트 배치)</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh"># idean3885.github.io</span>
<span class="gt">&gt; 개발자의 기술적 탐구, 방법론의 진화, 운영 노하우와 의사결정의 기록.</span>

<span class="gu">## 카테고리</span>
<span class="p">-</span> 개발 기록: 프로젝트 경험기, 도구 적응기
<span class="p">-</span> 기술 노하우: 운영 경험 기반 실전 지식
</code></pre></div></div>

<p><strong>(3) <code class="language-plaintext highlighter-rouge">last_modified_at</code> 수정 규칙</strong></p>

<p>포스트 수정 시 front matter 에 <code class="language-plaintext highlighter-rouge">last_modified_at</code> 을 기재. jekyll-seo-tag 가 이 값을 JSON-LD <code class="language-plaintext highlighter-rouge">dateModified</code> 에 반영합니다. 웹에는 안 보이지만 검색엔진·AI 에는 freshness 신호로 작용.</p>

<h3 id="chirpy-자체가-이미-geo-친화적">Chirpy 자체가 이미 GEO 친화적</h3>

<p>조사하면서 놀란 점. Chirpy 테마와 기존 작성 규칙이 이미 GEO 권장 패턴과 상당히 겹칩니다.</p>

<table>
  <thead>
    <tr>
      <th>기존 규칙</th>
      <th>GEO 관점 효과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>TL;DR 필수 (<code class="language-plaintext highlighter-rouge">.prompt-tip</code>)</td>
      <td>AI 가 추출할 “직접 답변” 패턴</td>
    </tr>
    <tr>
      <td>H2 부터 시작, TOC 자동 생성</td>
      <td>패시지 추출 단위가 명확</td>
    </tr>
    <tr>
      <td>코드 블록 언어 명시</td>
      <td>기술 콘텐츠 신뢰도 신호</td>
    </tr>
    <tr>
      <td>카테고리 2단계 계층</td>
      <td>콘텐츠 맥락 파악 용이</td>
    </tr>
  </tbody>
</table>

<p>“새로운 최적화 기법”보다 <strong>기본기를 먼저 챙기는 것</strong>이 답이었습니다.</p>

<h2 id="3-giscus-댓글-13줄-설정--app-설치-함정">3. Giscus 댓글: 13줄 설정 + App 설치 함정</h2>

<p>“정적 페이지인데 댓글이 어떻게 되지?” 가 첫 반응이었습니다. 알아보니 <a href="https://giscus.app">Giscus</a> (GitHub Discussions 기반 위젯) 가 답.</p>

<h3 id="동작-원리">동작 원리</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>브라우저 → Giscus iframe → GitHub OAuth2 → GraphQL API → Discussions
</code></pre></div></div>

<p>GitHub 저장소의 Discussions 탭을 댓글 저장소로 사용. 정적 HTML 자체는 변하지 않고 클라이언트가 GitHub API 로 직접 통신합니다. Git 커밋 로그에 댓글이 남지 않고 댓글 100개가 달려도 코드에 영향이 없습니다.</p>

<p>비용은 전부 0원 (GitHub Discussions·Giscus·OAuth2·GraphQL API).</p>

<h3 id="chirpy-설정-13줄">Chirpy 설정 (13줄)</h3>

<p>Chirpy 는 Giscus 를 기본 지원합니다. <code class="language-plaintext highlighter-rouge">_config.yml</code> 에 두 가지만 추가.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">defaults</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">scope</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span><span class="pi">,</span> <span class="nv">type</span><span class="pi">:</span> <span class="nv">posts</span> <span class="pi">}</span>
    <span class="na">values</span><span class="pi">:</span>
      <span class="na">layout</span><span class="pi">:</span> <span class="s">post</span>
      <span class="na">comments</span><span class="pi">:</span> <span class="kc">true</span>    <span class="c1"># false → true</span>

<span class="na">comments</span><span class="pi">:</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">giscus</span>
  <span class="na">giscus</span><span class="pi">:</span>
    <span class="na">repo</span><span class="pi">:</span> <span class="s">idean3885/idean3885.github.io</span>
    <span class="na">repo_id</span><span class="pi">:</span> <span class="s">R_kgDORQ49gw</span>
    <span class="na">category</span><span class="pi">:</span> <span class="s">General</span>
    <span class="na">category_id</span><span class="pi">:</span> <span class="s">DIC_kwDORQ49g84C59HT</span>
    <span class="na">mapping</span><span class="pi">:</span> <span class="s">pathname</span>
    <span class="na">lang</span><span class="pi">:</span> <span class="s">ko</span>
    <span class="na">input_position</span><span class="pi">:</span> <span class="s">bottom</span>
    <span class="na">reactions_enabled</span><span class="pi">:</span> <span class="m">1</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">repo_id</code>·<code class="language-plaintext highlighter-rouge">category_id</code> 는 <a href="https://giscus.app/ko">giscus.app</a> 에서 자동 발급. <code class="language-plaintext highlighter-rouge">mapping: pathname</code> 은 포스트 URL 경로별로 Discussion 스레드를 자동 생성.</p>

<h3 id="빠뜨리기-쉬운-함정">빠뜨리기 쉬운 함정</h3>

<p>배포 후 첫 시도에서 이 에러가 떴습니다.</p>

<blockquote>
  <p>오류 발생: giscus is not installed on this repository</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">_config.yml</code> 외에 <a href="https://github.com/apps/giscus">Giscus GitHub App</a> 설치가 추가로 필요. 설치 후 새로고침하면 정상 동작합니다.</p>

<h2 id="4-커스텀-도메인-githubio-의-gsc-사이트맵-함정">4. 커스텀 도메인: <code class="language-plaintext highlighter-rouge">*.github.io</code> 의 GSC 사이트맵 함정</h2>

<p>GEO 점검 시점에서 모든 기술 인프라가 정상이었는데 정작 GSC 사이트맵이 <strong>2개월간 “읽을 수 없음”</strong> 이었습니다.</p>

<h3 id="모든-점검-통과">모든 점검 통과</h3>

<table>
  <thead>
    <tr>
      <th>점검 항목</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>XML 유효성 (xmllint)</td>
      <td>통과</td>
    </tr>
    <tr>
      <td>HTTP 응답</td>
      <td>200, <code class="language-plaintext highlighter-rouge">application/xml</code></td>
    </tr>
    <tr>
      <td>BOM / 빈 줄</td>
      <td>없음</td>
    </tr>
    <tr>
      <td>Googlebot UA / IPv6 접근</td>
      <td>200</td>
    </tr>
    <tr>
      <td>robots.txt</td>
      <td>Sitemap 경로 명시</td>
    </tr>
    <tr>
      <td>사이트맵 내 URL</td>
      <td>전부 200</td>
    </tr>
  </tbody>
</table>

<p>모든 항목이 정상이었습니다. XML namespace 단순화, jekyll-sitemap 자동 생성 교체, 수동 색인 요청, 결과는 같았습니다.</p>

<h3 id="원인-githubio-도메인-자체">원인: <code class="language-plaintext highlighter-rouge">*.github.io</code> 도메인 자체</h3>

<p>Chirpy 이슈 트래커 <a href="https://github.com/cotes2020/jekyll-theme-chirpy/issues/2658">#2658</a> 에서 같은 현상이 보고되어 있었습니다. <code class="language-plaintext highlighter-rouge">*.github.io</code> 에서 GSC 사이트맵 인식이 실패하는 알려진 문제. <strong>커스텀 도메인을 연결하면 즉시 해결</strong>된다는 것이 확인된 해결책.</p>

<h3 id="도메인-선택-dev-vs-me">도메인 선택: <code class="language-plaintext highlighter-rouge">.dev</code> vs <code class="language-plaintext highlighter-rouge">.me</code></h3>

<p>용도 확장성을 고려했습니다. 블로그 외에 개인 서비스용 서브도메인 계획이 있었습니다.</p>

<table>
  <thead>
    <tr>
      <th>후보</th>
      <th>서브도메인 예시</th>
      <th>판단</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">idean3885.dev</code></td>
      <td><code class="language-plaintext highlighter-rouge">travel.idean3885.dev</code></td>
      <td>비개발 서비스에 어색</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">idean.me</code></td>
      <td><code class="language-plaintext highlighter-rouge">travel.idean.me</code></td>
      <td>용도 무관하게 자연스러움</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">idean.me</code> 선택. 연 $4 차이(<code class="language-plaintext highlighter-rouge">.dev</code> 대비) 로 10년이면 $40, 유연성 대비 저렴한 투자.</p>

<h3 id="연결-절차">연결 절차</h3>

<p>Cloudflare 에서 도메인 구매 → DNS 에 CNAME 추가 (Proxy status 는 반드시 <strong>DNS only</strong> 회색 구름, GitHub Pages SSL 발급에 필요) → GitHub Pages Settings 에서 Custom domain 입력 + Enforce HTTPS → <code class="language-plaintext highlighter-rouge">_config.yml</code> 의 <code class="language-plaintext highlighter-rouge">url</code> 변경 → 루트에 <code class="language-plaintext highlighter-rouge">CNAME</code> 파일.</p>

<p>기존 <code class="language-plaintext highlighter-rouge">idean3885.github.io</code> 접근은 자동으로 <code class="language-plaintext highlighter-rouge">blog.idean.me</code> 로 301 리다이렉트. 이미 공유된 링크는 깨지지 않습니다.</p>

<h3 id="결과">결과</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>도메인</td>
      <td><code class="language-plaintext highlighter-rouge">idean3885.github.io</code></td>
      <td><code class="language-plaintext highlighter-rouge">blog.idean.me</code></td>
    </tr>
    <tr>
      <td>GSC 사이트맵</td>
      <td>2개월 “읽을 수 없음”</td>
      <td>배포 즉시 성공</td>
    </tr>
  </tbody>
</table>

<p>2개월간 기술적 원인을 파고들었지만 답은 도메인에 있었습니다.</p>

<h2 id="5-ga4-추가-검색-노출-너머의-행동-데이터">5. GA4 추가: 검색 노출 너머의 행동 데이터</h2>

<p>사이트맵이 해소되면서 색인이 시작될 시점에 방문자 행동 분석도 같이 추가했습니다.</p>

<table>
  <thead>
    <tr>
      <th>도구</th>
      <th>보는 것</th>
      <th>범위</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>GSC</td>
      <td>어떤 키워드로 노출/클릭됐는지</td>
      <td>블로그 <strong>밖</strong> (검색 → 클릭)</td>
    </tr>
    <tr>
      <td>GA4</td>
      <td>누가, 얼마나 머물렀는지</td>
      <td>블로그 <strong>안</strong> (클릭 → 이탈)</td>
    </tr>
  </tbody>
</table>

<p>Chirpy 는 GA4 를 내장 지원. <code class="language-plaintext highlighter-rouge">_config.yml</code> 에 측정 ID 한 줄이면 gtag.js 가 자동 삽입됩니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">analytics</span><span class="pi">:</span>
  <span class="na">google</span><span class="pi">:</span>
    <span class="na">id</span><span class="pi">:</span> <span class="s">G-HRWXK11B6L</span>
</code></pre></div></div>

<p>페이지뷰·스크롤 90% 도달·이탈 클릭 같은 기본 이벤트가 자동 수집됩니다.</p>

<h2 id="회고-chirpy-운영의-핵심은-이미-된-것을-확인하기">회고: Chirpy 운영의 핵심은 “이미 된 것을 확인하기”</h2>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>한 일</th>
      <th>결정의 무게</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SEO 점검</td>
      <td><code class="language-plaintext highlighter-rouge">_config.yml</code> GSC 인증 한 줄 + 사이트맵 제출</td>
      <td>작음 (기본기 확인)</td>
    </tr>
    <tr>
      <td>GEO 보강</td>
      <td>author·logo·llms.txt·last_modified_at</td>
      <td>중간 (누락 발견)</td>
    </tr>
    <tr>
      <td>Giscus</td>
      <td>13줄 + GitHub App 설치</td>
      <td>작음 (App 함정 1개)</td>
    </tr>
    <tr>
      <td>커스텀 도메인</td>
      <td>도메인 선택 + DNS + GitHub Pages 설정</td>
      <td>큼 (2개월 함정 해소 + 장기 의사결정)</td>
    </tr>
    <tr>
      <td>GA4</td>
      <td>측정 ID 한 줄</td>
      <td>작음 (행동 데이터 확보)</td>
    </tr>
  </tbody>
</table>

<p>4편의 운영기를 한 글로 묶으면서 가장 일관된 발견은 “Chirpy 가 이미 하고 있는 것이 많다” 였습니다. SEO 도, GEO 의 절반도, 댓글 위젯도, GA4 도 테마가 들고 있습니다. 운영자가 하는 일은 누락된 한 두 가지를 채우는 것과 도메인 같은 장기 의사결정입니다.</p>

<p>다음 과제는 OG 이미지 자동화와 카테고리별 큐레이션 페이지입니다. 운영을 단순화할수록 글쓰기에 시간이 더 갑니다.</p>

<hr />

<blockquote class="prompt-info">
  <p>이 글은 Claude와 함께 작업했습니다.</p>
</blockquote>]]></content><author><name>idean3885</name></author><category term="기술 노하우" /><category term="Jekyll" /><category term="Chirpy" /><category term="SEO" /><category term="GEO" /><category term="Giscus" /><category term="GitHub Pages" /><category term="GA4" /><category term="커스텀 도메인" /><summary type="html"><![CDATA[Jekyll Chirpy 블로그의 SEO·GEO·댓글·커스텀 도메인·GA4 운영 과정을 정리합니다.]]></summary></entry></feed>