<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://kjh-sun.github.io/juhyun-tech-blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://kjh-sun.github.io/juhyun-tech-blog/" rel="alternate" type="text/html" hreflang="ko" /><updated>2026-05-25T13:02:50+00:00</updated><id>https://kjh-sun.github.io/juhyun-tech-blog/feed.xml</id><title type="html">KJH의 기술블로그</title><subtitle>도메인 구조 개선, 무중단 마이그레이션, 개발 생산성 향상 경험을 기록하는 공간입니다.</subtitle><author><name>김주현</name></author><entry><title type="html">코드베이스 탐색에 Knowledge Graph를 붙여보니, 중요한 것은 그래프보다 라우팅 규칙이었다</title><link href="https://kjh-sun.github.io/juhyun-tech-blog/2026/05/25/graphify-knowledge-graph-routing.html" rel="alternate" type="text/html" title="코드베이스 탐색에 Knowledge Graph를 붙여보니, 중요한 것은 그래프보다 라우팅 규칙이었다" /><published>2026-05-25T00:00:00+00:00</published><updated>2026-05-25T00:00:00+00:00</updated><id>https://kjh-sun.github.io/juhyun-tech-blog/2026/05/25/graphify-knowledge-graph-routing</id><content type="html" xml:base="https://kjh-sun.github.io/juhyun-tech-blog/2026/05/25/graphify-knowledge-graph-routing.html"><![CDATA[<p>대규모 마이크로서비스 코드베이스를 다루다 보면, 답을 찾는 일보다 “어디서부터 찾아야 하는지”를 정하는 일이 더 오래 걸릴 때가 많습니다.</p>

<p>특히 LLM에게 코드를 읽히는 워크플로우에서는 이 문제가 더 선명하게 드러납니다. 함수 하나를 이해하는 일은 어렵지 않습니다. 하지만 다음과 같은 질문은 쉽게 느려집니다.</p>

<ul>
  <li>주문 승인 같은 end-to-end 흐름은 어느 서비스들을 거치는가</li>
  <li>여러 서비스가 공유하는 인프라는 어디에 묶여 있는가</li>
  <li>설정과 DI 구성이 여러 파일에 흩어져 있을 때 전체 구조는 어떻게 생겼는가</li>
</ul>

<p>이런 질문은 보통 <code class="language-plaintext highlighter-rouge">grep</code>과 파일 열람을 반복하면서 답하게 됩니다. 답은 얻을 수 있지만, 탐색 비용이 높습니다. 그래서 코드 인덱싱 기반 Knowledge Graph를 붙이면 이 비용을 줄일 수 있을지 실험해 봤습니다.</p>

<h2 id="핵심-요약">핵심 요약</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>결론</strong></td>
      <td>Knowledge Graph는 모든 질문을 빠르게 만들지 않습니다. 하지만 특정 질문 유형에서는 tool call을 30~60% 이상 줄여줍니다.</td>
    </tr>
    <tr>
      <td><strong>핵심 인사이트</strong></td>
      <td>실전 ROI는 그래프 자체의 품질보다 <strong>“어떤 질문을 그래프로 보낼지”를 정하는 라우팅 규칙</strong>에서 나옵니다.</td>
    </tr>
    <tr>
      <td><strong>확정 사항</strong></td>
      <td>cross-service, architecture, distributed config 질문에 그래프 적용 시 유효</td>
    </tr>
    <tr>
      <td><strong>확인 필요</strong></td>
      <td>팀 내 질문 유형 분류 기준, 라우팅 규칙의 운영 일관성</td>
    </tr>
  </tbody>
</table>

<h2 id="문제-상황-왜-기존-탐색은-특정-질문에서-느려질까">문제 상황: 왜 기존 탐색은 특정 질문에서 느려질까</h2>

<h3 id="답이-한-파일에-있는-질문--직접-탐색이-빠름">답이 한 파일에 있는 질문 — 직접 탐색이 빠름</h3>

<ul>
  <li>특정 메서드 구현 상세를 묻는 질문</li>
  <li>테스트 시나리오 목록처럼 한 파일 안에 답이 있는 질문</li>
  <li>최신 변경 이유처럼 변경 이력이 더 중요한 질문</li>
</ul>

<p>이 경우에는 파일 하나를 찾고 읽으면 끝납니다. 그래프를 거치는 순간 오히려 단계가 하나 늘어납니다.</p>

<h3 id="답이-여러-파일에-흩어진-질문--구조적으로-느림">답이 여러 파일에 흩어진 질문 — 구조적으로 느림</h3>

<ul>
  <li>여러 서비스와 컴포넌트가 연결된 end-to-end 흐름</li>
  <li>cross-service shared infrastructure 식별</li>
  <li>여러 Config 클래스에 흩어진 Bean 또는 설정 구조 파악</li>
</ul>

<p>느린 이유는 단순합니다. 답이 한 파일에 없기 때문입니다. 탐색자는 여러 파일을 순차적으로 열고, 각 파일이 가리키는 다음 파일로 계속 이동해야 합니다. LLM도 똑같이 이 비용을 지불합니다.</p>

<pre><code class="language-mermaid">flowchart LR
    A[질문 시작] --&gt; B{답이 한 파일에 있는가?}
    B -- "예" --&gt; C[Grep/Read로 즉시 해결]
    B -- "아니오" --&gt; D[여러 파일 순차 탐색]
    D --&gt; E[publisher 찾기]
    E --&gt; F[client 찾기]
    F --&gt; G[config 찾기]
    G --&gt; H[adapter 찾기]
    H --&gt; I[탐색 비용 누적]
    style C fill:#2d5f2d,stroke:#4a9eff,color:#fff
    style I fill:#5f2d2d,stroke:#ff6b6b,color:#fff
</code></pre>

<h2 id="실험-설계">실험 설계</h2>

<p>두 가지 접근을 비교했습니다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>직접 탐색 (Baseline)</th>
      <th>Knowledge Graph 탐색</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>허용 도구</td>
      <td><code class="language-plaintext highlighter-rouge">grep</code>, <code class="language-plaintext highlighter-rouge">glob</code>, 파일 읽기</td>
      <td>위 도구 + <code class="language-plaintext highlighter-rouge">graphify query / explain / path</code></td>
    </tr>
    <tr>
      <td>워크플로우</td>
      <td>코드를 직접 순차 탐색</td>
      <td>subgraph를 먼저 추출 → 필요한 부분만 코드 확인</td>
    </tr>
  </tbody>
</table>

<p>평가 기준은 세 가지였습니다.</p>

<ul>
  <li><strong>정확도</strong>: 정답 품질이 유지되는가 (rubric 0~3점)</li>
  <li><strong>도구 호출 수</strong>: 세션 내 총 도구 호출 횟수</li>
  <li><strong>입력 토큰</strong>: 세션 전체 입력 토큰량</li>
</ul>

<p>질문은 5개 범주로 나눴습니다.</p>

<pre><code class="language-mermaid">pie title 질문 범주 분포
    "architecture" : 2
    "function" : 2
    "dependency (cross-service)" : 2
    "config" : 2
    "history (why)" : 2
</code></pre>

<h2 id="실험-결과">실험 결과</h2>

<h3 id="전체-평균-두-방식이-사실상-동등">전체 평균: 두 방식이 사실상 동등</h3>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>직접 탐색 (A)</th>
      <th>그래프 탐색 (B)</th>
      <th>Δ 절대값</th>
      <th>Δ 상대값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>평균 도구 호출 수</td>
      <td>6.2</td>
      <td>6.8</td>
      <td>+0.6</td>
      <td>+9.7%</td>
    </tr>
    <tr>
      <td>평균 입력 토큰</td>
      <td>36,598</td>
      <td>37,426</td>
      <td>+828</td>
      <td>+2.3%</td>
    </tr>
    <tr>
      <td>평균 정확도 (0~3)</td>
      <td>3.00</td>
      <td>3.00</td>
      <td>0.00</td>
      <td>동등</td>
    </tr>
    <tr>
      <td>정확도 만점 비율</td>
      <td>10/10</td>
      <td>10/10</td>
      <td>—</td>
      <td>동등</td>
    </tr>
  </tbody>
</table>

<p><strong>해석</strong>: 두 그룹은 전체 평균 수준에서 사실상 동등합니다. 평균이 “동등”한 이유는 질문 유형마다 효율이 크게 다른데 이를 섞어서 평균을 냈기 때문입니다.</p>

<h3 id="질문별-실측-데이터-편차의-핵심">질문별 실측 데이터: 편차의 핵심</h3>

<p>10개 질문 각각에 대해 두 방식의 도구 호출 수를 직접 비교했습니다.</p>

<table>
  <thead>
    <tr>
      <th>Q</th>
      <th>카테고리</th>
      <th>A (직접)</th>
      <th>B (그래프)</th>
      <th>Δ%</th>
      <th>우세 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>m1</td>
      <td>architecture</td>
      <td><strong>14</strong></td>
      <td>5</td>
      <td><strong>-64.3%</strong></td>
      <td><strong>B</strong></td>
    </tr>
    <tr>
      <td>m2</td>
      <td>architecture</td>
      <td>8</td>
      <td>9</td>
      <td>+12.5%</td>
      <td>A</td>
    </tr>
    <tr>
      <td>m3</td>
      <td>function</td>
      <td><strong>2</strong></td>
      <td>6</td>
      <td>+200.0%</td>
      <td><strong>A</strong></td>
    </tr>
    <tr>
      <td>m4</td>
      <td>function</td>
      <td>7</td>
      <td>6</td>
      <td>-14.3%</td>
      <td>B</td>
    </tr>
    <tr>
      <td>m5</td>
      <td>dependency</td>
      <td>6</td>
      <td>11</td>
      <td>+83.3%</td>
      <td>A</td>
    </tr>
    <tr>
      <td>m6</td>
      <td>dependency</td>
      <td><strong>10</strong></td>
      <td>7</td>
      <td><strong>-30.0%</strong></td>
      <td><strong>B</strong></td>
    </tr>
    <tr>
      <td>m7</td>
      <td>config</td>
      <td>4</td>
      <td>10</td>
      <td>+150.0%</td>
      <td>A</td>
    </tr>
    <tr>
      <td>m8</td>
      <td>config</td>
      <td><strong>5</strong></td>
      <td>3</td>
      <td><strong>-40.0%</strong></td>
      <td><strong>B</strong></td>
    </tr>
    <tr>
      <td>m9</td>
      <td>history</td>
      <td>3</td>
      <td>5</td>
      <td>+66.7%</td>
      <td>A</td>
    </tr>
    <tr>
      <td>m10</td>
      <td>history</td>
      <td>3</td>
      <td>6</td>
      <td>+100.0%</td>
      <td>A</td>
    </tr>
  </tbody>
</table>

<pre><code class="language-mermaid">xychart-beta
    title "질문별 도구 호출 수 비교"
    x-axis ["m1","m2","m3","m4","m5","m6","m7","m8","m9","m10"]
    y-axis "도구 호출 수" 0 --&gt; 15
    bar [14, 8, 2, 7, 6, 10, 4, 5, 3, 3]
    line [5, 9, 6, 6, 11, 7, 10, 3, 5, 6]
</code></pre>

<p><strong>패턴 요약</strong>:</p>

<table>
  <thead>
    <tr>
      <th>관찰</th>
      <th>근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>그래프 탐색이 유리했던 4문항은 절감 폭이 컸음 (-14% ~ -64%)</td>
      <td>m1, m4, m6, m8: 평균 <strong>-37.2%</strong> 호출 절감</td>
    </tr>
    <tr>
      <td>직접 탐색이 유리했던 6문항은 차이가 상대적으로 작았음 (절대값 기준 +1~+6 calls)</td>
      <td>단일 파일에 답이 있으면 Grep 한두 번이면 끝남</td>
    </tr>
    <tr>
      <td>전체 평균에서 B가 +9.7%인 이유는 직접 탐색이 유리한 문항 수가 더 많았기 때문</td>
      <td>절대 차이는 작음 (62 vs 68 calls over 10Q)</td>
    </tr>
  </tbody>
</table>

<h3 id="카테고리별-집계">카테고리별 집계</h3>

<table>
  <thead>
    <tr>
      <th>카테고리</th>
      <th>A calls 합</th>
      <th>B calls 합</th>
      <th>Δ%</th>
      <th>A tokens 합</th>
      <th>B tokens 합</th>
      <th>Δ%</th>
      <th>대표 문항</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>architecture</td>
      <td>22</td>
      <td>14</td>
      <td><strong>-36.4%</strong></td>
      <td>105,688</td>
      <td>79,959</td>
      <td><strong>-24.3%</strong></td>
      <td>m1: end-to-end 흐름 (-64%)</td>
    </tr>
    <tr>
      <td>function</td>
      <td>9</td>
      <td>12</td>
      <td>+33.3%</td>
      <td>56,925</td>
      <td>67,943</td>
      <td>+19.4%</td>
      <td>m3: 특정 메서드 (A 3배 효율)</td>
    </tr>
    <tr>
      <td>dependency</td>
      <td>16</td>
      <td>18</td>
      <td>+12.5%</td>
      <td>72,891</td>
      <td>77,585</td>
      <td>+6.4%</td>
      <td>m6: shared infra (B -30%)</td>
    </tr>
    <tr>
      <td>config</td>
      <td>9</td>
      <td>13</td>
      <td>+44.4%</td>
      <td>67,779</td>
      <td>67,283</td>
      <td>-0.7%</td>
      <td>m8: 분산 설정 (B -40%)</td>
    </tr>
    <tr>
      <td>history</td>
      <td>6</td>
      <td>11</td>
      <td>+83.3%</td>
      <td>62,692</td>
      <td>81,494</td>
      <td>+30.0%</td>
      <td>단일 파일 또는 주석으로 답이 나옴</td>
    </tr>
  </tbody>
</table>

<p><strong>핵심 발견</strong>: architecture 카테고리만 카테고리 수준에서도 그래프 탐색이 뚜렷하게 우세했습니다. 나머지 카테고리는 질문별 편차가 커서 평균만으로 일괄 판단하기 어려웠습니다.</p>

<h3 id="그래프가-유효했던-질문--구체-사례">그래프가 유효했던 질문 — 구체 사례</h3>

<p><strong>사례 1: end-to-end 흐름 질문 (m1, -64%)</strong></p>

<p>질문: “특정 비즈니스 흐름이 end-to-end로 어떻게 동작하는가?”</p>

<table>
  <thead>
    <tr>
      <th>경로</th>
      <th>도구 호출 흐름</th>
      <th>호출 수</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A (직접)</td>
      <td>EventPublisher → GrpcClient → Config → Adapter를 하나씩 찾아가며 파일 열람</td>
      <td><strong>14</strong></td>
      <td>정답</td>
    </tr>
    <tr>
      <td>B (그래프)</td>
      <td><code class="language-plaintext highlighter-rouge">graphify query</code> 한 번으로 BFS subgraph 추출 → 관련 노드·엣지 확인 → 검증 필요 코드만 추가 열람</td>
      <td><strong>5</strong></td>
      <td>정답</td>
    </tr>
  </tbody>
</table>

<p><strong>사례 2: cross-service 공유 인프라 질문 (m6, -30%)</strong></p>

<p>질문: “여러 서비스가 공유하는 특정 인프라 컴포넌트를 나열하라”</p>

<table>
  <thead>
    <tr>
      <th>경로</th>
      <th>도구 호출 흐름</th>
      <th>호출 수</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A (직접)</td>
      <td>Grep으로 여러 서비스에서 히트 → Read 각 파일 → 관련 클래스 추적 → 추가 조사</td>
      <td><strong>10</strong></td>
      <td>정답 (9개 컴포넌트 식별)</td>
    </tr>
    <tr>
      <td>B (그래프)</td>
      <td><code class="language-plaintext highlighter-rouge">graphify query</code> → Cross-service community 라벨 확인 → 필요한 2개 파일만 Read</td>
      <td><strong>7</strong></td>
      <td>정답 (9개 컴포넌트 식별 + 그룹화 정보 함께 제공)</td>
    </tr>
  </tbody>
</table>

<h3 id="그래프가-비효율적이었던-질문">그래프가 비효율적이었던 질문</h3>

<table>
  <thead>
    <tr>
      <th>질문 유형</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>특정 메서드 구현 상세</td>
      <td>이미 답이 한 파일에 있어 subgraph 추출이 불필요한 오버헤드</td>
    </tr>
    <tr>
      <td>단일 파일 내 열거형 목록</td>
      <td>파일 하나를 읽으면 끝인데 graph query 호출이 단계만 추가</td>
    </tr>
    <tr>
      <td>“왜 바뀌었나” 변경 이력 질문</td>
      <td>그래프는 구조를 보여줄 뿐, 변경 맥락은 담지 못함</td>
    </tr>
  </tbody>
</table>

<h2 id="의사결정-질문-라우팅-규칙">의사결정: 질문 라우팅 규칙</h2>

<blockquote>
  <p>Knowledge Graph의 가치는 “항상 쓰면 빨라진다”가 아니라, <strong>“특정 질문에만 선택적으로 쓸 때 이득이 커진다”</strong>는 데 있습니다.</p>
</blockquote>

<p>그래프를 도입하는 것 자체보다 먼저 해야 할 일은 <strong>질문 라우팅 규칙</strong>을 만드는 것입니다.</p>

<h3 id="라우팅-의사결정표">라우팅 의사결정표</h3>

<pre><code class="language-mermaid">flowchart TD
    Q[질문 입력] --&gt; D1{여러 서비스/컴포넌트가&lt;br/&gt;연관되어 있는가?}
    D1 -- "아니오" --&gt; R1[직접 탐색&lt;br/&gt;Grep/Read]
    D1 -- "예" --&gt; D2{답이 단일 파일에&lt;br/&gt;존재하는가?}
    D2 -- "예" --&gt; R1
    D2 -- "아니오" --&gt; D3{최신 변경 이력이&lt;br/&gt;핵심인가?}
    D3 -- "예" --&gt; R2[Git history +&lt;br/&gt;설계 문서 확인]
    D3 -- "아니오" --&gt; R3[Knowledge Graph&lt;br/&gt;우선 적용]
    style R3 fill:#2d5f2d,stroke:#4a9eff,color:#fff
    style R1 fill:#1e293b,stroke:#8cc5ff,color:#fff
    style R2 fill:#1e293b,stroke:#8cc5ff,color:#fff
</code></pre>

<table>
  <thead>
    <tr>
      <th>라우팅 대상</th>
      <th>진입 방식</th>
      <th>근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>여러 서비스를 가로지르는 흐름 질문</td>
      <td><code class="language-plaintext highlighter-rouge">graphify query</code> 우선</td>
      <td>architecture 질문에서 최대 -64% 절감 확인</td>
    </tr>
    <tr>
      <td>공유 인프라 식별 (“A, B, C가 공유하는 X는?”)</td>
      <td><code class="language-plaintext highlighter-rouge">graphify query</code> 우선</td>
      <td>cross-service 커뮤니티 라벨이 즉시 관련 컴포넌트 묶어줌</td>
    </tr>
    <tr>
      <td>여러 파일에 흩어진 설정/DI 구조</td>
      <td><code class="language-plaintext highlighter-rouge">graphify query</code> 우선</td>
      <td>분산된 Bean을 community 단위로 한 번에 제시</td>
    </tr>
    <tr>
      <td>두 개념 사이 관계/경로</td>
      <td><code class="language-plaintext highlighter-rouge">graphify path</code> 우선</td>
      <td>최단 경로 조회로 바로 연결 구조 파악</td>
    </tr>
    <tr>
      <td>특정 함수/메서드 내부 로직</td>
      <td>Grep/Read 직접</td>
      <td>단일 파일에 답이 있어 graph query가 오버헤드</td>
    </tr>
    <tr>
      <td>단일 파일 내 열거형 목록</td>
      <td>Grep/Read 직접</td>
      <td>위와 동일</td>
    </tr>
    <tr>
      <td>최신 변경 이력/설계 논의</td>
      <td>Git log + 문서</td>
      <td>그래프는 최신 PR 이력 반영 못함</td>
    </tr>
  </tbody>
</table>

<h2 id="제한점-및-리스크">제한점 및 리스크</h2>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>제한점</th>
      <th>영향</th>
      <th>대응 방안</th>
      <th>확인 방법</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>그래프는 최신 맥락을 항상 반영하지 못함</td>
      <td>“왜 바뀌었나” 질문에 한계</td>
      <td>변경 이력과 설계 문서를 별도로 확인</td>
      <td>graph 결과와 git log 비교</td>
    </tr>
    <tr>
      <td>2</td>
      <td>범위를 너무 넓히면 community가 “잡탕”이 됨</td>
      <td>전체 서비스 통합 그래프는 오히려 정보 분산</td>
      <td>좁은 범위 그래프를 기본값으로 사용</td>
      <td>community별 노드 수 모니터링</td>
    </tr>
    <tr>
      <td>3</td>
      <td>LLM은 긴 wiki보다 정제된 진입점에 더 적합</td>
      <td>wiki markdown 직접 열람 시 +14~84% 오버헤드</td>
      <td><code class="language-plaintext highlighter-rouge">graphify query</code>로 subgraph만 소비</td>
      <td>wiki 직접 읽기 vs query 비교 측정</td>
    </tr>
  </tbody>
</table>

<h2 id="후속-액션">후속 액션</h2>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>액션</th>
      <th>완료 기준</th>
      <th>우선순위</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>팀 내 빈출 질문 유형 분류</td>
      <td>상위 20개 질문을 5개 범주로 분류 완료</td>
      <td>높음</td>
    </tr>
    <tr>
      <td>2</td>
      <td>라우팅 규칙을 워크스페이스 지침에 반영</td>
      <td><code class="language-plaintext highlighter-rouge">CLAUDE.md</code> 또는 <code class="language-plaintext highlighter-rouge">AGENTS.md</code>에 규칙 명시</td>
      <td>높음</td>
    </tr>
    <tr>
      <td>3</td>
      <td>좁은 범위 그래프를 기본값으로 고정</td>
      <td>타겟 서비스 단위 그래프를 주 탐색 도구로 사용</td>
      <td>중간</td>
    </tr>
    <tr>
      <td>4</td>
      <td>추가 인원으로 재측정</td>
      <td>2인 이상, 동일 질문셋 + 실무 질문 샘플로 검증</td>
      <td>중간</td>
    </tr>
    <tr>
      <td>5</td>
      <td>그래프 자동 재빌드 파이프라인 구축</td>
      <td>CI weekly 재빌드로 최신성 유지</td>
      <td>낮음</td>
    </tr>
  </tbody>
</table>

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

<p>Knowledge Graph는 코드 탐색의 만능 해법이 아니었습니다. 하지만 그 점이 오히려 더 흥미로웠습니다. 정말 중요한 것은 “그래프를 썼는가”가 아니라 <strong>“언제 써야 하는가”</strong>였습니다.</p>

<p>LLM을 코드 탐색에 본격적으로 활용할수록, 검색 품질 못지않게 <strong>질문을 올바른 탐색 경로로 라우팅하는 설계</strong>가 중요해집니다. 이번 실험의 가장 큰 수확은 그래프 자체보다 그 운영 규칙이었습니다.</p>

<p>비슷한 실험을 하시려면, 그래프 구축부터 시작하지 않으셔도 됩니다. 먼저 팀의 질문 유형부터 정리해 보세요. 어떤 질문이 가장 자주 느린지 분류하는 순간, 그래프가 필요한 자리와 필요 없는 자리가 생각보다 빨리 드러납니다.</p>]]></content><author><name>김주현</name></author><category term="knowledge-graph" /><category term="llm" /><category term="code-search" /><category term="developer-tooling" /><summary type="html"><![CDATA[LLM 기반 코드 탐색에서 Knowledge Graph가 실제로 유효한 질문과 그렇지 않은 질문을 정리한 실험 기록]]></summary></entry><entry><title type="html">gRPC + Service Mesh 환경에서 만난 세 가지 Long-lived Connection 문제</title><link href="https://kjh-sun.github.io/juhyun-tech-blog/2026/05/25/grpc-istio-long-lived-connections.html" rel="alternate" type="text/html" title="gRPC + Service Mesh 환경에서 만난 세 가지 Long-lived Connection 문제" /><published>2026-05-25T00:00:00+00:00</published><updated>2026-05-25T00:00:00+00:00</updated><id>https://kjh-sun.github.io/juhyun-tech-blog/2026/05/25/grpc-istio-long-lived-connections</id><content type="html" xml:base="https://kjh-sun.github.io/juhyun-tech-blog/2026/05/25/grpc-istio-long-lived-connections.html"><![CDATA[<p>gRPC와 service mesh를 함께 쓰는 시스템을 운영하다 보면, 얼핏 서로 관계없어 보이는 문제들이 비슷한 시기에 튀어나올 때가 있습니다.</p>

<ul>
  <li>rollout 중 간헐적인 <code class="language-plaintext highlighter-rouge">UNAVAILABLE</code> 에러</li>
  <li>일부 Pod에만 트래픽이 몰리는 connection skew</li>
  <li>fan-out 요청에서 비정상적으로 높아지는 지연 시간</li>
</ul>

<p>처음에는 각각 따로 해결해야 하는 문제처럼 보였습니다. 하나는 배포 문제, 하나는 load balancing 문제, 또 하나는 성능 문제였기 때문입니다.</p>

<p>그런데 실험을 진행할수록 세 문제를 하나로 묶는 공통 배경이 보이기 시작했습니다. 핵심은 <strong>long-lived connection</strong>이었습니다. gRPC의 HTTP/2 연결은 오래 살아 있고, service mesh는 그 연결 위에서 endpoint를 선택하고 유지합니다. 이 특성이 rollout, 분산, 성능에 연쇄적으로 영향을 줬습니다.</p>

<h2 id="핵심-요약">핵심 요약</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>공통 원인</strong></td>
      <td>세 문제 모두 <strong>long-lived connection + service mesh의 endpoint 선택 방식</strong>에서 비롯됨</td>
    </tr>
    <tr>
      <td><strong>확정 사항</strong></td>
      <td>shutdown timing 설계, locality-aware distribution 조정, fan-out reduction이 유효했음</td>
    </tr>
    <tr>
      <td><strong>확인 필요</strong></td>
      <td>프로덕션 환경 동일 테스트, 재연결 시나리오 확대 검증</td>
    </tr>
  </tbody>
</table>

<h3 id="문제-원인-해결-한눈에-보기">문제-원인-해결 한눈에 보기</h3>

<pre><code class="language-mermaid">flowchart TD
    ROOT["long-lived connection&lt;br/&gt;(gRPC HTTP/2)"]
    ROOT --&gt; P1["문제 1&lt;br/&gt;rollout 중 UNAVAILABLE"]
    ROOT --&gt; P2["문제 2&lt;br/&gt;connection skew"]
    ROOT --&gt; P3["문제 3&lt;br/&gt;fan-out 지연"]
    P1 --&gt; F1["해결: shutdown timing 설계"]
    P2 --&gt; F2["해결: endpoint selection 우선 조정"]
    P3 --&gt; F3["해결: 호출 형태 재설계&lt;br/&gt;(batch API)"]
    style ROOT fill:#5f2d2d,stroke:#ff6b6b,color:#fff
    style F1 fill:#2d5f2d,stroke:#4a9eff,color:#fff
    style F2 fill:#2d5f2d,stroke:#4a9eff,color:#fff
    style F3 fill:#2d5f2d,stroke:#4a9eff,color:#fff
</code></pre>

<h2 id="배경-http11-vs-grpc-http2-핵심-차이">배경: HTTP/1.1 vs gRPC (HTTP/2) 핵심 차이</h2>

<table>
  <thead>
    <tr>
      <th>특성</th>
      <th>HTTP/1.1</th>
      <th>gRPC (HTTP/2)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>연결 방식</td>
      <td>요청마다 새 연결 또는 짧은 keep-alive</td>
      <td>하나의 연결에 모든 요청을 멀티플렉싱</td>
    </tr>
    <tr>
      <td>연결 수명</td>
      <td>짧음 (수 초 ~ 수십 초)</td>
      <td>긺 (수십 분 ~ 수 시간)</td>
    </tr>
    <tr>
      <td>LB 재분배 기회</td>
      <td>연결을 맺을 때마다 다른 Pod 선택 가능</td>
      <td>연결 생성 시 한 번만 endpoint 결정</td>
    </tr>
    <tr>
      <td>연결당 동시 요청</td>
      <td>1 (또는 파이프라이닝)</td>
      <td>수백~수천 스트림 동시 가능</td>
    </tr>
  </tbody>
</table>

<pre><code class="language-mermaid">flowchart LR
    subgraph "HTTP/1.1"
        direction TB
        H1C1[연결 1] --&gt; H1P1[Pod A]
        H1C2[연결 2] --&gt; H1P2[Pod B]
        H1C3[연결 3] --&gt; H1P1b[Pod A]
    end
    subgraph "gRPC (HTTP/2)"
        direction TB
        G2C1["연결 1 (장기)"] --&gt; G2P1["Pod A&lt;br/&gt;100% 고착"]
        G2S1[stream 1] --&gt; G2C1
        G2S2[stream 2] --&gt; G2C1
        G2S3[stream 3] --&gt; G2C1
    end
    style G2P1 fill:#5f2d2d,stroke:#ff6b6b,color:#fff
</code></pre>

<h2 id="문제-1-rollout은-성공했지만-요청은-불안정했다">문제 1: rollout은 성공했지만 요청은 불안정했다</h2>

<h3 id="현상">현상</h3>

<p>애플리케이션은 graceful shutdown을 하도록 설정했는데도, rollout 시점에 gRPC <code class="language-plaintext highlighter-rouge">UNAVAILABLE</code>가 간헐적으로 발생했습니다. 애플리케이션 로그만 보면 종료가 천천히 진행되는 것처럼 보였지만, 실제 네트워크 경로에서는 더 빨리 무언가가 사라지고 있었습니다.</p>

<h3 id="원인">원인</h3>

<p>이 문제를 이해하려면 애플리케이션만 보면 안 됩니다. service mesh sidecar까지 포함한 종료 순서를 같이 봐야 합니다.</p>

<ul>
  <li>app보다 sidecar가 먼저 연결 정리를 끝내는 경우</li>
  <li>endpoint 제거가 전파되기 전에 종료 중인 Pod로 트래픽이 한 번 더 들어오는 경우</li>
  <li>에러가 난 endpoint를 mesh가 바로 격리하지 못하는 경우</li>
</ul>

<p>즉, 애플리케이션 하나의 graceful shutdown만으로는 충분하지 않았습니다. <strong>Kubernetes lifecycle, service registry 반영 타이밍, sidecar drain, client retry</strong>가 함께 맞물려야 했습니다.</p>

<h3 id="실측-fix-적용-전후-비교">실측: Fix 적용 전후 비교</h3>

<h4 id="baseline-fix-적용-전">Baseline (Fix 적용 전)</h4>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>Test v1 (0.5초 간격)</th>
      <th>Test v2 (0.2초 간격)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>총 요청</td>
      <td>233건</td>
      <td>600건</td>
    </tr>
    <tr>
      <td>성공 (HTTP 200)</td>
      <td>233건 (100%)</td>
      <td>600건 (100%)</td>
    </tr>
    <tr>
      <td>실패</td>
      <td>0건</td>
      <td>0건</td>
    </tr>
    <tr>
      <td>느린 요청 (&gt;1s)</td>
      <td>0건</td>
      <td>1건 (1.41s)</td>
    </tr>
    <tr>
      <td>UNAVAILABLE 로그</td>
      <td>0건</td>
      <td>0건</td>
    </tr>
  </tbody>
</table>

<p><strong>분석</strong>: gRPC client retry(maxAttempts=3)가 Envoy 레벨 에러를 마스킹하고 있었습니다. 1건의 느린 요청(1.41s)은 retry가 실제로 발생했음을 시사합니다 (정상 응답 ~0.03s 대비).</p>

<h4 id="fix-후-단일-서비스-배포">Fix 후 단일 서비스 배포</h4>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>총 요청</td>
      <td>400건</td>
    </tr>
    <tr>
      <td>성공 (HTTP 200)</td>
      <td>400건 (100%)</td>
    </tr>
    <tr>
      <td>느린 요청 (&gt;1s)</td>
      <td>1건 (1.29s)</td>
    </tr>
    <tr>
      <td>UNAVAILABLE 로그</td>
      <td>0건</td>
    </tr>
  </tbody>
</table>

<h4 id="fix-전후-비교">Fix 전후 비교</h4>

<table>
  <thead>
    <tr>
      <th>메트릭</th>
      <th>Fix 전</th>
      <th>Fix 후</th>
      <th>변화</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>성공률</td>
      <td>100%</td>
      <td>100%</td>
      <td>동일</td>
    </tr>
    <tr>
      <td>느린 요청 최대 응답시간</td>
      <td>1.41s</td>
      <td>1.29s</td>
      <td>-0.12s 개선</td>
    </tr>
  </tbody>
</table>

<h4 id="multi-hop-grpc-chain-동시-배포-검증">Multi-hop gRPC Chain 동시 배포 검증</h4>

<p><strong>시나리오 A: 2개 서비스 동시 배포</strong></p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>단일 hop</th>
      <th>Multi-hop</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>총 요청</td>
      <td>500건</td>
      <td>500건</td>
    </tr>
    <tr>
      <td>성공</td>
      <td><strong>500건 (100%)</strong></td>
      <td><strong>500건 (100%)</strong></td>
    </tr>
    <tr>
      <td>느린 요청 (&gt;1s)</td>
      <td><strong>0건</strong></td>
      <td><strong>0건</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>시나리오 B: Multi-hop chain 양쪽 동시 배포</strong></p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>Multi-hop</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>총 요청</td>
      <td>47건</td>
    </tr>
    <tr>
      <td>성공</td>
      <td><strong>47건 (100%)</strong></td>
    </tr>
    <tr>
      <td>느린 요청 (&gt;1s)</td>
      <td>1건 (2.85s)</td>
    </tr>
    <tr>
      <td>UNAVAILABLE 로그</td>
      <td>0건</td>
    </tr>
  </tbody>
</table>

<p><strong>분석</strong>: Multi-hop chain의 양쪽 끝을 동시 재배포해도 에러 0건. 시나리오 A에서는 느린 요청도 0건으로, Fix 후 Envoy drain이 정상 작동하여 retry 자체가 불필요해졌습니다.</p>

<h3 id="해결-종료-타이밍-시간관계-설계">해결: 종료 타이밍 시간관계 설계</h3>

<p>종료 관련 설정은 독립 값이 아니라 <strong>시간 관계</strong>로 봐야 합니다.</p>

<pre><code class="language-mermaid">gantt
    title Pod 종료 시퀀스 (정상 설계)
    dateFormat X
    axisFormat %s초

    section Kubernetes
    SIGTERM 수신             :done,   k1, 0, 0
    terminationGracePeriod   :active, k2, 0, 180

    section Sidecar
    drain 시작               :done,   s1, 0, 0
    활성 연결 0 시 즉시 종료  :s2, 0, 30
    drain 대기 (최대 30s)     :active, s3, 0, 30

    section App
    preStop sleep            :done,   a1, 0, 20
    Spring graceful shutdown  :active, a2, 20, 100
    gRPC server shutdown      :active, a3, 20, 95
</code></pre>

<p><strong>핵심 부등식</strong>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>preStop(20s) &lt; sidecar drain(30s) &lt; app shutdown(80s) &lt; terminationGracePeriod(180s)
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>설정</th>
      <th>값</th>
      <th>역할</th>
      <th>어긋나면</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">preStop sleep</code></td>
      <td><strong>20s</strong></td>
      <td>K8s Endpoints에서 제거될 시간 확보</td>
      <td>짧으면 트래픽이 종료 중인 Pod으로 유입</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">terminationDrainDuration</code></td>
      <td><strong>30s</strong></td>
      <td>Envoy sidecar가 기존 연결 정리</td>
      <td>preStop보다 짧으면 sidecar가 먼저 죽음</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">EXIT_ON_ZERO_ACTIVE_CONNECTIONS</code></td>
      <td><strong>true</strong></td>
      <td>활성 연결 없으면 즉시 종료</td>
      <td>false면 drain 시간을 항상 풀로 대기</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SPRING_LIFECYCLE_TIMEOUT</code></td>
      <td><strong>80s</strong></td>
      <td>Spring이 진행 중인 요청 완료 대기</td>
      <td>짧으면 처리 중인 요청 강제 중단</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">terminationGracePeriodSeconds</code></td>
      <td><strong>180s</strong></td>
      <td>K8s 강제 종료 안전망</td>
      <td>짧으면 graceful shutdown 전에 SIGKILL</td>
    </tr>
  </tbody>
</table>

<h3 id="영향-범위">영향 범위</h3>

<table>
  <thead>
    <tr>
      <th>영역</th>
      <th>영향</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>배포 프로세스</td>
      <td>rollout 중 오류 제거</td>
      <td>sidecar drain 연장 + outlier detection</td>
    </tr>
    <tr>
      <td>애플리케이션</td>
      <td>preStop, shutdown timeout 조정 필요</td>
      <td>Spring lifecycle, gRPC shutdown period</td>
    </tr>
    <tr>
      <td>운영</td>
      <td>probe 설정 재점검 필요</td>
      <td>startup/readiness/liveness 분리 설계</td>
    </tr>
    <tr>
      <td>QA</td>
      <td>재배포 시나리오 테스트 필요</td>
      <td>단일/다중 서비스 동시 배포 검증</td>
    </tr>
  </tbody>
</table>

<h2 id="문제-2-연결을-끊어도-트래픽은-다시-같은-pod로-향했다">문제 2: 연결을 끊어도 트래픽은 다시 같은 Pod로 향했다</h2>

<h3 id="현상-1">현상</h3>

<p>요청 분포를 확인해 보니 일부 상황에서 트래픽이 특정 Pod에 거의 고정되었습니다.</p>

<h3 id="초기-시도와-한계">초기 시도와 한계</h3>

<p>처음에는 gRPC의 장기 연결 특성 때문이라고 생각했습니다. 그래서 가장 먼저 시도한 것은 <strong>연결 rotation</strong>이었습니다.</p>

<h4 id="실험-1-baseline-설정-없음-2-pod">실험 1: Baseline (설정 없음, 2-Pod)</h4>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>Total CV</th>
      <th>판정</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Steady-state (부하만)</td>
      <td>0.02</td>
      <td>BALANCED</td>
      <td>2-Pod에서는 LEAST_REQUEST가 양쪽에 균등 분배</td>
    </tr>
    <tr>
      <td>Scale-out (2→4)</td>
      <td>1.09</td>
      <td>SKEWED</td>
      <td>기존 연결이 유지되는 동안 신규 Pod로 트래픽이 가지 않음</td>
    </tr>
    <tr>
      <td>Rolling Restart</td>
      <td><strong>1.00</strong></td>
      <td><strong>SKEWED</strong></td>
      <td>새 연결이 단일 Pod에만 맺어짐 → 100% 고착</td>
    </tr>
    <tr>
      <td>반복 Restart (x2)</td>
      <td>0.73</td>
      <td>SKEWED</td>
      <td>여전히 편중</td>
    </tr>
  </tbody>
</table>

<pre><code class="language-mermaid">gantt
    title Rolling Restart 시계열 (Baseline, 2-Pod)
    dateFormat X
    axisFormat %s초

    section Pod-1 (기존)
    트래픽 100% 유지     :done, p1, 0, 60
    종료                  :milestone, p1e, 60, 60

    section Pod-2 (신규)
    대기                  :active, p2, 0, 60
    트래픽 100% 이동      :done, p2a, 60, 120
</code></pre>

<h4 id="실험-2-maxrequestsperconnection1000">실험 2: maxRequestsPerConnection=1000</h4>

<p>1000회 요청 후 연결을 재생성하여 장기 분산 개선을 시도했습니다.</p>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>지표</th>
      <th>Exp 1 (무제한)</th>
      <th>Exp 2 (1000)</th>
      <th>변화</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Rolling restart</td>
      <td>Total CV</td>
      <td>1.00</td>
      <td><strong>0.25</strong></td>
      <td>대폭 개선</td>
    </tr>
    <tr>
      <td>반복 restart</td>
      <td>Total CV</td>
      <td>0.73</td>
      <td><strong>0.001</strong></td>
      <td>극적 개선</td>
    </tr>
    <tr>
      <td>Rolling restart</td>
      <td>Time-series CV</td>
      <td>1.53</td>
      <td>1.77</td>
      <td>악화</td>
    </tr>
  </tbody>
</table>

<p><strong>시계열 (1000, Rolling restart)</strong>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>시간    Pod1    Pod2    Pod3    Pod4    Pod5
+56s    27      25       0       0       0   ← 기존 Pod
+91s     0       0      50       0       0   ← restart 후 → Pod A
+170s    0       0       0       0      52   ← 1000req rotation → Pod B
+260s    0      52       0       0       0   ← rotation → 다른 Pod
</code></pre></div></div>

<p><strong>해석</strong>: 장기적 분산은 개선되었으나, 각 시점에서는 여전히 단일 Pod에 집중합니다.</p>

<h4 id="실험-3-maxrequestsperconnection50-2-pod">실험 3: maxRequestsPerConnection=50 (2-Pod)</h4>

<p>50회 요청(~10초)마다 rotation하여 순간 분산도 개선을 시도했습니다.</p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>Exp 1 (무제한)</th>
      <th>Exp 2 (1000)</th>
      <th>Exp 3 (50)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Total CV</td>
      <td>1.00</td>
      <td>0.25</td>
      <td><strong>0.10</strong></td>
    </tr>
    <tr>
      <td>Time-series CV</td>
      <td>1.53</td>
      <td>1.77</td>
      <td><strong>1.02</strong></td>
    </tr>
    <tr>
      <td>cx_total/endpoint</td>
      <td>1</td>
      <td>1~2</td>
      <td><strong>8~9</strong></td>
    </tr>
    <tr>
      <td>에러</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p><strong>시계열 (50, Rolling restart)</strong>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>시간    Pod1(기존) Pod2(기존) Pod3(신규) Pod4(신규)
+11s      24        24         0         0   ← 기존 2개에 균등
+45s      31        18         0         0   ← rotation마다 전환
+113s      0        29         0        21   ← 과도기: 기존1 + 신규1
+158s      0         0        30        19   ← 신규 2개에 즉시 균등
+294s      0         0        23        28   ← 끝까지 균등 유지
</code></pre></div></div>

<p><strong>해석</strong>: 2-Pod에서는 rotation 빈도 증가로 분산이 개선됩니다.</p>

<h4 id="실험-4-전환점--4-pod에서-가설-기각">실험 4: 전환점 — 4-Pod에서 가설 기각</h4>

<table>
  <thead>
    <tr>
      <th>시도</th>
      <th>방법</th>
      <th>Pod 수</th>
      <th>CV</th>
      <th>판정</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>maxRequestsPerConn=50</td>
      <td>50회마다 rotation</td>
      <td>4</td>
      <td><strong>0.92</strong></td>
      <td><strong>SKEWED</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>4-Pod 실측 데이터</strong>:</p>

<table>
  <thead>
    <tr>
      <th>Endpoint</th>
      <th>Zone</th>
      <th>rq_total</th>
      <th>cx_total</th>
      <th>비율</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pod-1</td>
      <td>AZ-b</td>
      <td><strong>1,348</strong></td>
      <td>27</td>
      <td><strong>100%</strong></td>
    </tr>
    <tr>
      <td>Pod-2</td>
      <td>AZ-d</td>
      <td>0</td>
      <td>0</td>
      <td>0%</td>
    </tr>
    <tr>
      <td>Pod-3</td>
      <td>AZ-a</td>
      <td>0</td>
      <td>0</td>
      <td>0%</td>
    </tr>
    <tr>
      <td>Pod-4</td>
      <td>AZ-c</td>
      <td>0</td>
      <td>0</td>
      <td>0%</td>
    </tr>
  </tbody>
</table>

<p><strong>27번 연결을 끊고 다시 맺었지만, 매번 동일한 Pod를 선택했습니다.</strong></p>

<h3 id="근본-원인-locality-aware-load-balancing">근본 원인: Locality-Aware Load Balancing</h3>

<pre><code class="language-mermaid">flowchart TD
    subgraph "문제: Locality-Aware LB"
        direction TB
        CL1[Client] --&gt;|"priority 0"| P1["Pod 1&lt;br/&gt;(동일 AZ)"]
        CL1 -.-&gt;|"priority 1&lt;br/&gt;(사용 안 함)"| P2[Pod 2]
        CL1 -.-&gt;|"priority 1"| P3[Pod 3]
        CL1 -.-&gt;|"priority 1"| P4[Pod 4]
    end
    subgraph "해결: distribute 설정"
        direction TB
        CL2[Client] --&gt;|"25%"| PP1["Pod 1&lt;br/&gt;(AZ-a)"]
        CL2 --&gt;|"25%"| PP2["Pod 2&lt;br/&gt;(AZ-b)"]
        CL2 --&gt;|"25%"| PP3["Pod 3&lt;br/&gt;(AZ-c)"]
        CL2 --&gt;|"25%"| PP4["Pod 4&lt;br/&gt;(AZ-d)"]
    end
    style P1 fill:#5f2d2d,stroke:#ff6b6b,color:#fff
    style PP1 fill:#2d5f2d,stroke:#4a9eff,color:#fff
    style PP2 fill:#2d5f2d,stroke:#4a9eff,color:#fff
    style PP3 fill:#2d5f2d,stroke:#4a9eff,color:#fff
    style PP4 fill:#2d5f2d,stroke:#4a9eff,color:#fff
</code></pre>

<p><strong>문제는 연결이 오래 살아서만이 아니라, 재연결할 때도 같은 locality 집합으로 돌아간다는 점</strong>이었습니다. 연결을 끊는 것 자체는 재분배를 보장하지 않습니다.</p>

<h3 id="해결-endpoint-selection을-먼저-조정">해결: endpoint selection을 먼저 조정</h3>

<p>적용 순서가 중요합니다. 순서를 바꾸면 안 됩니다.</p>

<ol>
  <li><strong>endpoint selection 정책 확인</strong> — locality/priority 편향 확인</li>
  <li><strong>locality-aware distribution 명시적 조정</strong> — cross-zone 균등 분배</li>
  <li><strong>connection rotation은 보조 수단</strong> — 위 두 단계가 맞을 때만 의미 있음</li>
</ol>

<table>
  <thead>
    <tr>
      <th>검증</th>
      <th>설정</th>
      <th>Pod 수</th>
      <th>CV</th>
      <th>판정</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>distribute 적용</td>
      <td>4개 AZ 균등 25%</td>
      <td>4</td>
      <td><strong>0.002</strong></td>
      <td><strong>BALANCED</strong></td>
    </tr>
    <tr>
      <td>최종 검증</td>
      <td>동일 설정</td>
      <td>8</td>
      <td>0.071</td>
      <td>BALANCED</td>
    </tr>
  </tbody>
</table>

<h3 id="영향-범위-1">영향 범위</h3>

<table>
  <thead>
    <tr>
      <th>영역</th>
      <th>영향</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>네트워크 트래픽 분산</td>
      <td>트래픽 편중 대폭 개선</td>
      <td>CV 0.92 → 0.002</td>
    </tr>
    <tr>
      <td>DestinationRule 설정</td>
      <td>localityLbSetting.distribute 추가 필요</td>
      <td>기존 설정에 영향 없음</td>
    </tr>
    <tr>
      <td>운영</td>
      <td>Envoy /clusters 메트릭 모니터링 필요</td>
      <td>rq_total 분포 정기 확인</td>
    </tr>
  </tbody>
</table>

<h2 id="문제-3-오류는-없었지만-fan-out-요청의-지연은-컸다">문제 3: 오류는 없었지만 fan-out 요청의 지연은 컸다</h2>

<h3 id="현상-2">현상</h3>

<p>특정 API는 내부적으로 downstream gRPC를 매우 많이 fan-out하고 있었습니다. 기능적으로는 정상이고 오류도 없었지만, 지연 시간은 예상보다 컸습니다.</p>

<h3 id="원인-1">원인</h3>

<p>장기 연결 하나 위에 많은 stream이 몰리면 <strong>packet density</strong>가 높아지고, service mesh proxy의 event loop가 그 부하를 직렬화된 형태로 받는 순간이 생깁니다.</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph "Before: 1000회 fan-out"
        GW1[Gateway] --&gt;|"1000회 호출"| PR1[Proxy]
        PR1 --&gt;|"단일 연결에&lt;br/&gt;stream 1000개"| SVC1[Downstream Service]
    end
    subgraph "After: 배치 API"
        GW2[Gateway] --&gt;|"1회 호출&lt;br/&gt;(batch)"| PR2[Proxy]
        PR2 --&gt;|"stream 1개"| SVC2[Downstream Service]
    end
    style PR1 fill:#5f2d2d,stroke:#ff6b6b,color:#fff
    style PR2 fill:#2d5f2d,stroke:#4a9eff,color:#fff
</code></pre>

<h3 id="해결-호출-형태-재설계">해결: 호출 형태 재설계</h3>

<h4 id="실측-요약">실측 요약</h4>

<table>
  <thead>
    <tr>
      <th>비교 항목</th>
      <th>변경 전</th>
      <th>변경 후</th>
      <th>변화</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>요청 형태</td>
      <td>downstream fan-out 1000회</td>
      <td>batch API 1회</td>
      <td>구조 단순화</td>
    </tr>
    <tr>
      <td>end-to-end latency</td>
      <td>기준값 100</td>
      <td>16</td>
      <td><strong>-84%</strong></td>
    </tr>
    <tr>
      <td>병목 위치</td>
      <td>proxy event loop, packet density</td>
      <td>단일 batch 호출 중심</td>
      <td>병목 축소</td>
    </tr>
  </tbody>
</table>

<table>
  <thead>
    <tr>
      <th>접근</th>
      <th>방법</th>
      <th>효과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>pool tuning</td>
      <td>연결 수, concurrent stream 조정</td>
      <td>한계 있음</td>
    </tr>
    <tr>
      <td><strong>batch API 전환</strong></td>
      <td>1000회 호출 → 1회 batch 호출</td>
      <td><strong>end-to-end latency -84%</strong></td>
    </tr>
  </tbody>
</table>

<p>fan-out 자체가 과하면, 가장 큰 이득은 <strong>더 많은 튜닝이 아니라 호출 수를 줄이는 방향</strong>에서 나옵니다.</p>

<h3 id="영향-범위-2">영향 범위</h3>

<table>
  <thead>
    <tr>
      <th>영역</th>
      <th>영향</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>API 설계</td>
      <td>fan-out 호출을 batch API로 재설계 필요</td>
      <td>기존 API 유지 가능 (점진 전환)</td>
    </tr>
    <tr>
      <td>성능</td>
      <td>latency 대폭 개선</td>
      <td>-84% 확인</td>
    </tr>
    <tr>
      <td>downstream 서비스</td>
      <td>batch endpoint 추가 필요</td>
      <td>기존 단건 API는 영향 없음</td>
    </tr>
    <tr>
      <td>QA</td>
      <td>batch API 정합성 테스트 필요</td>
      <td>기존 단건 호출 결과와 동일 확인</td>
    </tr>
  </tbody>
</table>

<h2 id="공통-원인-세-문제를-하나로-묶는-관점">공통 원인: 세 문제를 하나로 묶는 관점</h2>

<pre><code class="language-mermaid">flowchart TD
    CORE["long-lived connection&lt;br/&gt;(gRPC HTTP/2 특성)"]
    CORE --&gt; C1["연결은 오래 산다&lt;br/&gt;→ 재분배 기회 적음"]
    CORE --&gt; C2["endpoint 선택이&lt;br/&gt;연결 생성 시점에 결정됨"]
    CORE --&gt; C3["proxy는 단순 전달자가 아님&lt;br/&gt;→ drain, balancing, pool 동작이&lt;br/&gt;실제 행태를 바꿈"]
    C1 --&gt; P1["문제 1: rollout 중&lt;br/&gt;sidecar 종료 타이밍 충돌"]
    C2 --&gt; P2["문제 2: locality 편향이&lt;br/&gt;재연결에도 고착"]
    C1 --&gt; P3["문제 3: 단일 연결에&lt;br/&gt;stream 과밀집"]
    C3 --&gt; P1
    C3 --&gt; P2
    C3 --&gt; P3
    style CORE fill:#5f2d2d,stroke:#ff6b6b,color:#fff
</code></pre>

<table>
  <thead>
    <tr>
      <th>공통 특성</th>
      <th>문제 1에 미친 영향</th>
      <th>문제 2에 미친 영향</th>
      <th>문제 3에 미친 영향</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>연결이 오래 삶</td>
      <td>sidecar가 먼저 죽어도 연결이 남아 에러</td>
      <td>장기 연결이 locality 편중을 고착화</td>
      <td>단일 연결에 stream이 과밀집</td>
    </tr>
    <tr>
      <td>endpoint 선택 시점</td>
      <td>drain 후 재연결 시점이 중요</td>
      <td>최초 선택된 locality에서 벗어나지 못함</td>
      <td>해당 없음</td>
    </tr>
    <tr>
      <td>proxy의 능동적 역할</td>
      <td>drain, outlier detection이 종료 행태 결정</td>
      <td>locality-aware balancing이 분포 결정</td>
      <td>event loop 직렬화가 병목</td>
    </tr>
  </tbody>
</table>

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

<h3 id="rollout">Rollout</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />app graceful shutdown만 보고 있지 않은가 — sidecar drain 시간도 함께 설계했는가</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />sidecar drain 시간이 preStop보다 충분히 긴가</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />readiness 제거와 endpoint 전파 시간을 고려했는가</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />retry가 문제를 숨기고만 있지는 않은가 — retry 없이도 에러가 0인가</li>
</ul>

<h3 id="load-distribution">Load Distribution</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />skew가 connection lifetime 문제인지, selection policy 문제인지 분리했는가</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />locality-aware policy가 특정 집합에 과도한 우선순위를 주고 있지 않은가</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />reconnect가 실제 분산으로 이어지는지 메트릭으로 확인했는가</li>
</ul>

<h3 id="performance">Performance</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />high fan-out 요청이 proxy에서 density 문제를 만들고 있지 않은가</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />connection pool 튜닝 전에 호출 수 자체를 줄일 수 있는가</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />batch API로 바꾸면 병목을 더 크게 줄일 수 있지 않은가</li>
</ul>

<h2 id="리스크-및-예외-케이스">리스크 및 예외 케이스</h2>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>리스크</th>
      <th>영향</th>
      <th>대응 방안</th>
      <th>확인 방법</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>프로덕션에서 다른 네트워크 topology로 재현 가능</td>
      <td>프로덕션 적용 전 검증 필요</td>
      <td>프로덕션과 동일한 zone/Pod 구성에서 재테스트</td>
      <td>프로덕션 Canary 배포 후 메트릭 비교</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Spring/gRPC 버전에 따른 graceful shutdown 동작 차이</td>
      <td>shutdown timeout 설정이 다르게 동작할 가능성</td>
      <td>주요 버전별 shutdown 동작 단위 테스트</td>
      <td>버전 업그레이드 시 회귀 테스트</td>
    </tr>
    <tr>
      <td>3</td>
      <td>batch API 전환 시 downstream 서비스 부하 패턴 변화</td>
      <td>순간 부하가 집중될 가능성</td>
      <td>batch 크기 제한, rate limiting 적용</td>
      <td>부하 테스트로 p99 latency 확인</td>
    </tr>
  </tbody>
</table>

<h2 id="후속-액션">후속 액션</h2>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>액션</th>
      <th>담당자</th>
      <th>완료 기준</th>
      <th>우선순위</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>프로덕션 환경 동일 테스트 수행</td>
      <td>미정</td>
      <td>rollout/skew/fan-out 3개 시나리오 프로덕션 검증 완료</td>
      <td>높음</td>
    </tr>
    <tr>
      <td>2</td>
      <td>QA 환경 반복 테스트</td>
      <td>미정</td>
      <td>multi-hop chain 동시 배포 시나리오 통과</td>
      <td>높음</td>
    </tr>
    <tr>
      <td>3</td>
      <td>종료 타이밍 설정값 문서화</td>
      <td>미정</td>
      <td>모든 서비스의 timeout/drain/probe 설정 단일 문서 정리</td>
      <td>중간</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Envoy 메트릭 대시보드 구축</td>
      <td>미정</td>
      <td>rq_total, cx_active, CV 값을 Grafana에서 실시간 확인</td>
      <td>중간</td>
    </tr>
    <tr>
      <td>5</td>
      <td>batch API 확대 적용</td>
      <td>미정</td>
      <td>fan-out 패턴 조사 후 우선순위별 batch 전환</td>
      <td>낮음</td>
    </tr>
  </tbody>
</table>

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

<p>운영 문제는 대개 증상 단위로 관측됩니다. 배포 오류, skew, 성능 저하처럼 각각 다른 이름으로 드러나지만, 원인을 따라가 보면 같은 구조적 특성이 여러 형태로 나타나는 경우가 있습니다.</p>

<p>gRPC + service mesh 환경에서 이 문제들을 관통하는 공통 배경은 <strong>long-lived connection</strong>이었습니다.</p>

<p>이 관점을 얻고 나니 해결 순서도 달라졌습니다. rollout은 종료 타이밍의 문제로, skew는 endpoint selection의 문제로, fan-out 지연은 호출 구조의 문제로 다시 분리해서 볼 수 있었습니다. 그리고 각각의 설정값보다 먼저, 시스템이 연결을 <strong>어떻게 만들고 얼마나 오래 유지하며 어디로 보내는지</strong>를 이해하는 쪽이 훨씬 중요하다는 점을 배웠습니다.</p>

<p>비슷한 현상을 겪고 계시다면, 문제를 세 개로 나누기 전에 먼저 하나의 질문을 던져 보세요.</p>

<blockquote>
  <p><strong>이 시스템의 long-lived connection은 지금 어디에서, 어떤 방식으로, 얼마나 오래 영향을 미치고 있는가?</strong></p>
</blockquote>

<p>대개 해답의 절반은 그 질문 안에 들어 있습니다.</p>]]></content><author><name>김주현</name></author><category term="grpc" /><category term="istio" /><category term="envoy" /><category term="kubernetes" /><category term="performance" /><summary type="html"><![CDATA[무중단 배포, connection skew, fan-out 병목을 하나의 long-lived connection 관점으로 다시 정리한 운영 기록]]></summary></entry></feed>