<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Quality Assurance on jyukki's Blog</title><link>https://jyukki.com/tags/quality-assurance/</link><description>Recent content in Quality Assurance on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Tue, 17 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/quality-assurance/index.xml" rel="self" type="application/rss+xml"/><item><title>Go로 PostgreSQL 프록시 만들기 (53) - QA 4차: 라우팅 우회와 운영 안전성</title><link>https://jyukki.com/posts/2026-03-17-pgmux-53-qa-round4-routing-safety/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-17-pgmux-53-qa-round4-routing-safety/</guid><description>Prepared statement 재사용으로 read-only를 우회하는 버그, side-effectful SELECT의 잘못된 라우팅, extended query timeout 사각지대 등 QA 4차에서 발견된 5건의 버그를 분석하고 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>QA 4차에서 5건의 소견이 나왔다. 이번엔 <strong>라우팅 안전성</strong>이 핵심 주제다. Prepared statement 재사용이 read-only 모드를 우회하는 보안 버그, <code>SELECT ... FOR UPDATE</code>가 reader로 가는 라우팅 오류, timeout이 빠진 실행 경로 — 모두 &ldquo;정상 경로에서는 잘 되지만 특정 조합에서 깨지는&rdquo; 패턴이다.</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>심각도</th>
          <th>요약</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>High</td>
          <td>Read-only 모드가 prepared statement 재사용으로 우회됨</td>
      </tr>
      <tr>
          <td>2</td>
          <td>High</td>
          <td>Side-effectful SELECT가 read로 분류됨</td>
      </tr>
      <tr>
          <td>3</td>
          <td>Medium</td>
          <td>Extended/multiplex 경로에서 query timeout 미적용</td>
      </tr>
      <tr>
          <td>4</td>
          <td>Medium</td>
          <td>Data API의 기본 DB가 hot-reload를 따라가지 않음</td>
      </tr>
      <tr>
          <td>5</td>
          <td>Medium</td>
          <td>Per-database config validation 누락으로 panic 가능</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="1-prepared-statement-재사용으로-read-only-우회">1. Prepared Statement 재사용으로 Read-Only 우회</h2>
<h3 id="문제">문제</h3>
<p>Read-only 모드는 write 쿼리를 차단하는 운영 기능이다. Simple Query에서는 잘 동작한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>-- Simple Query: 정상 차단
</span></span><span style="display:flex;"><span>INSERT INTO orders VALUES (1, &#39;test&#39;);
</span></span><span style="display:flex;"><span>→ &#34;cannot execute write query: pgmux is in read-only mode&#34;
</span></span></code></pre></div><p>그런데 Extended Query Protocol에서는 다른 이야기다. PostgreSQL 클라이언트 라이브러리(JDBC, libpq 등)는 prepared statement를 적극적으로 재사용한다. 한 번 Parse한 INSERT 문을 이후에는 Bind/Execute/Sync만 보낸다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>-- 1단계: Parse (read-only 전환 전)
</span></span><span style="display:flex;"><span>Parse(&#34;INSERT INTO orders VALUES ($1, $2)&#34;)
</span></span><span style="display:flex;"><span>Bind(params...)
</span></span><span style="display:flex;"><span>Execute
</span></span><span style="display:flex;"><span>Sync
</span></span><span style="display:flex;"><span>→ 성공
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>-- 2단계: admin에서 read-only 모드 활성화
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>-- 3단계: 같은 prepared statement 재사용 (Parse 없이)
</span></span><span style="display:flex;"><span>Bind(params...)     ← Parse가 없으므로 extIsWrite 미설정
</span></span><span style="display:flex;"><span>Execute
</span></span><span style="display:flex;"><span>Sync
</span></span><span style="display:flex;"><span>→ 성공!? ← read-only 우회
</span></span></code></pre></div><p>원인은 <code>extIsWrite</code> 플래그의 생명주기에 있다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query.go — Parse 때만 설정</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> protocol.MsgParse:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.<span style="color:#50fa7b">classifyQuery</span>(query) <span style="color:#ff79c6">==</span> router.QueryWrite {
</span></span><span style="display:flex;"><span>        extIsWrite = <span style="color:#ff79c6">true</span>   <span style="color:#6272a4">// ← Parse가 있을 때만 실행</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Sync 때 체크</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> protocol.MsgSync:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.<span style="color:#50fa7b">InReadOnly</span>() <span style="color:#ff79c6">&amp;&amp;</span> extIsWrite {  <span style="color:#6272a4">// extIsWrite가 false → 통과</span>
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// reject...</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 배치 끝에서 리셋</span>
</span></span><span style="display:flex;"><span>    extIsWrite = <span style="color:#ff79c6">false</span>
</span></span></code></pre></div><p>Parse가 없는 Bind-only 배치에서는 <code>extIsWrite</code>가 이전 배치 리셋으로 <code>false</code> 상태다. Read-only 체크가 통과된다.</p>
<h3 id="수정">수정</h3>
<p>Session에 statement별 write 분류를 추적하는 맵을 추가했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// router.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Session <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    stmtRoutes <span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]Route
</span></span><span style="display:flex;"><span>    stmtWrite  <span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]<span style="color:#8be9fd">bool</span>  <span style="color:#6272a4">// 추가: statement별 write 분류</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Session) <span style="color:#50fa7b">RegisterStatement</span>(name, query <span style="color:#8be9fd">string</span>) Route {
</span></span><span style="display:flex;"><span>    route <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">routeLocked</span>(query)
</span></span><span style="display:flex;"><span>    s.stmtRoutes[name] = route
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// write 분류도 함께 저장</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.astParser {
</span></span><span style="display:flex;"><span>        s.stmtWrite[name] = <span style="color:#50fa7b">ClassifyAST</span>(query) <span style="color:#ff79c6">==</span> QueryWrite
</span></span><span style="display:flex;"><span>    } <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>        s.stmtWrite[name] = <span style="color:#50fa7b">Classify</span>(query) <span style="color:#ff79c6">==</span> QueryWrite
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> route
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Session) <span style="color:#50fa7b">StatementIsWrite</span>(name <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> isWrite, ok <span style="color:#ff79c6">:=</span> s.stmtWrite[name]; ok {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> isWrite
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>  <span style="color:#6272a4">// 모르면 write로 간주 (safe default)</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>MsgBind에서 statement의 write 여부를 확인한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">case</span> protocol.MsgBind:
</span></span><span style="display:flex;"><span>    route <span style="color:#ff79c6">:=</span> session.<span style="color:#50fa7b">StatementRoute</span>(stmtName)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> route <span style="color:#ff79c6">==</span> router.RouteWriter {
</span></span><span style="display:flex;"><span>        extRoute = router.RouteWriter
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 추가: write 분류 복원</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> session.<span style="color:#50fa7b">StatementIsWrite</span>(stmtName) {
</span></span><span style="display:flex;"><span>        extIsWrite = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div><p>핵심은 <strong>분류 시점과 검사 시점의 분리</strong>다. Parse 때 분류 결과를 저장하고, Bind 때 복원한다. 미등록 statement는 <code>true</code>(write)로 간주해서 안전한 방향으로 동작한다.</p>
<hr>
<h2 id="2-side-effectful-select가-read로-분류">2. Side-Effectful SELECT가 Read로 분류</h2>
<h3 id="문제-1">문제</h3>
<p>pgmux의 쿼리 분류기는 첫 번째 키워드로 read/write를 판단한다. <code>SELECT</code>로 시작하면 read, <code>INSERT</code>/<code>UPDATE</code>/<code>DELETE</code>로 시작하면 write. 대부분의 경우 맞지만, PostgreSQL에는 <strong>부작용이 있는 SELECT</strong>가 있다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#6272a4">-- 행 잠금 획득 (reader에서 실행 불가)
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span> <span style="color:#ff79c6">FROM</span> orders <span style="color:#ff79c6">WHERE</span> id <span style="color:#ff79c6">=</span> <span style="color:#bd93f9">1</span> <span style="color:#ff79c6">FOR</span> <span style="color:#ff79c6">UPDATE</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- 시퀀스 값 증가 (writer에서만 실행해야 함)
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> nextval(<span style="color:#f1fa8c">&#39;order_id_seq&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- 세션 파라미터 변경
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> set_config(<span style="color:#f1fa8c">&#39;statement_timeout&#39;</span>, <span style="color:#f1fa8c">&#39;5000&#39;</span>, <span style="color:#ff79c6">false</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- Advisory lock 획득
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> pg_advisory_lock(<span style="color:#bd93f9">12345</span>);
</span></span></code></pre></div><p>이 쿼리들이 reader(replica)로 가면:</p>
<ul>
<li><code>FOR UPDATE</code>: replica에서 lock 획득 불가 → 에러</li>
<li><code>nextval()</code>: replica에서 실행 불가 → 에러</li>
<li><code>set_config()</code>: replica에서 실행 가능하지만, 커넥션이 풀로 돌아가면 설정 누수</li>
<li><code>pg_advisory_lock()</code>: replica에서 잠금이 걸리면 writer와 별개 → 동기화 불가</li>
</ul>
<h3 id="수정-1">수정</h3>
<p>문자열 파서와 AST 파서 양쪽에 감지를 추가했다.</p>
<p><strong>문자열 파서</strong> — SELECT 문에서 locking clause와 부작용 함수를 감지:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// parser.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> lockingClauses = []<span style="color:#8be9fd">string</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;FOR UPDATE&#34;</span>, <span style="color:#f1fa8c">&#34;FOR NO KEY UPDATE&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;FOR SHARE&#34;</span>, <span style="color:#f1fa8c">&#34;FOR KEY SHARE&#34;</span>,
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> sideEffectFuncs = []<span style="color:#8be9fd">string</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;NEXTVAL(&#34;</span>, <span style="color:#f1fa8c">&#34;SETVAL(&#34;</span>, <span style="color:#f1fa8c">&#34;CURRVAL(&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;SET_CONFIG(&#34;</span>, <span style="color:#f1fa8c">&#34;PG_ADVISORY_LOCK(&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;PG_ADVISORY_XACT_LOCK(&#34;</span>, <span style="color:#f1fa8c">&#34;PG_ADVISORY_UNLOCK(&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;PG_TRY_ADVISORY_LOCK(&#34;</span>, <span style="color:#f1fa8c">&#34;PG_NOTIFY(&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;LO_CREATE(&#34;</span>, <span style="color:#f1fa8c">&#34;LO_UNLINK(&#34;</span>,
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">isSideEffectfulSelect</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    stripped <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">stripStringLiterals</span>(query)
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(stripped)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, lc <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> lockingClauses {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">Contains</span>(upper, lc) { <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span> }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">hasSideEffectFunc</span>(upper)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>classifyFast</code>에서도 SELECT일 때 빠른 탈출을 막는다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> kw <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#34;SELECT&#34;</span> {
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">Contains</span>(upper, <span style="color:#f1fa8c">&#34;FOR UPDATE&#34;</span>) <span style="color:#ff79c6">||</span> <span style="color:#ff79c6">...</span> <span style="color:#ff79c6">||</span> <span style="color:#50fa7b">hasSideEffectFunc</span>(upper) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#bd93f9">0</span>, <span style="color:#ff79c6">false</span>  <span style="color:#6272a4">// full parser로 위임</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> QueryRead, <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>AST 파서</strong> — pg_query의 구조화된 정보를 활용:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// parser_ast.go</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_SelectStmt:
</span></span><span style="display:flex;"><span>    s <span style="color:#ff79c6">:=</span> n.SelectStmt
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// CTE 체크 (기존)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Locking clause: FOR UPDATE, FOR SHARE 등</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> <span style="color:#8be9fd;font-style:italic">len</span>(s.<span style="color:#50fa7b">GetLockingClause</span>()) &gt; <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 부작용 함수 호출</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">hasSideEffectFuncCalls</span>(node)
</span></span></code></pre></div><p>AST 파서에서는 <code>FuncCall</code> 노드를 워킹하며 함수명을 체크한다. 문자열 파서보다 정확하다 — 문자열 리터럴 안의 <code>nextval(</code>에 속지 않는다.</p>
<hr>
<h2 id="3-extendedmultiplex-경로의-timeout-사각지대">3. Extended/Multiplex 경로의 Timeout 사각지대</h2>
<h3 id="문제-2">문제</h3>
<p>pgmux는 per-query timeout hint를 지원한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#6272a4">/* timeout:5s */</span> <span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span> <span style="color:#ff79c6">FROM</span> large_table;
</span></span></code></pre></div><p>Simple Query에서는 잘 동작한다. 그런데 Extended Query에서는 global timeout만 적용된다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query.go — Sync 핸들러</span>
</span></span><span style="display:flex;"><span>extQueryTimeout <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">getConfig</span>().Pool.QueryTimeout  <span style="color:#6272a4">// global만!</span>
</span></span></code></pre></div><p>더 심각한 건 multiplex 경로(synthesized query)다. 여기는 <strong>아예 timer가 없다</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// executeSynthesizedQuery — timer 없이 relay</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> protocol.<span style="color:#50fa7b">WriteMessage</span>(wConn, protocol.MsgQuery, queryPayload); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">relayUntilReady</span>(clientConn, wConn); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ← timeout 없이 무한 대기 가능</span>
</span></span></code></pre></div><h3 id="수정-2">수정</h3>
<ol>
<li>Parse에서 query 텍스트를 저장해 Sync에서 hint를 추출한다:</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> extQueryText <span style="color:#8be9fd">string</span>  <span style="color:#6272a4">// 최신 Parse의 query 텍스트</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> protocol.MsgParse:
</span></span><span style="display:flex;"><span>    extQueryText = query  <span style="color:#6272a4">// 저장</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> protocol.MsgSync:
</span></span><span style="display:flex;"><span>    extQueryTimeout <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">resolveQueryTimeout</span>(extQueryText, s.<span style="color:#50fa7b">getConfig</span>())
</span></span></code></pre></div><ol start="2">
<li><code>executeSynthesizedQuery</code>에 <code>queryTimeout</code> 파라미터를 추가하고 timer를 건다:</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">executeSynthesizedQuery</span>(ctx context.Context, <span style="color:#ff79c6">...</span>, queryTimeout time.Duration, <span style="color:#ff79c6">...</span>) <span style="color:#8be9fd">error</span> {
</span></span><span style="display:flex;"><span>    ct.<span style="color:#50fa7b">setFromConn</span>(dbg.writerAddr, wConn)
</span></span><span style="display:flex;"><span>    stopTimer <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">startQueryTimer</span>(queryTimeout, ct, <span style="color:#f1fa8c">&#34;writer&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... relay ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> stopTimer <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> { <span style="color:#50fa7b">stopTimer</span>() }
</span></span><span style="display:flex;"><span>    ct.<span style="color:#8be9fd;font-style:italic">clear</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>handleSynthesizedRead</code>에도 동일하게 적용해서 reader/writer/fallback 세 경로 모두 커버한다.</p>
<hr>
<h2 id="4-data-api의-기본-db가-hot-reload를-무시">4. Data API의 기본 DB가 Hot-Reload를 무시</h2>
<h3 id="문제-3">문제</h3>
<p>Data API의 <code>/v1/query</code>에 <code>database</code> 파라미터를 생략하면 기본 DB로 라우팅된다. 이 기본 DB는 <strong>생성 시점에 한 번</strong> 설정된다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// main.go</span>
</span></span><span style="display:flex;"><span>apiSrv <span style="color:#ff79c6">:=</span> dataapi.<span style="color:#50fa7b">New</span>(<span style="color:#ff79c6">...</span>, srv.<span style="color:#50fa7b">DefaultDBName</span>(), <span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span><span style="color:#6272a4">//                         ^^^^^^^^^^^^^^^^^ 문자열 한 번 평가</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// handler.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Server <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    defaultDB <span style="color:#8be9fd">string</span>  <span style="color:#6272a4">// 정적 필드</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Config reload로 기본 DB를 바꾸거나 기존 기본 DB를 제거해도, Data API는 예전 값을 그대로 쓴다.</p>
<h3 id="수정-3">수정</h3>
<p>정적 문자열 대신 함수 참조를 저장한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// handler.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Server <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    defaultDBFn <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#8be9fd">string</span>  <span style="color:#6272a4">// 동적 조회</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">handleQuery</span>(<span style="color:#ff79c6">...</span>) {
</span></span><span style="display:flex;"><span>    dbName <span style="color:#ff79c6">:=</span> r.URL.<span style="color:#50fa7b">Query</span>().<span style="color:#50fa7b">Get</span>(<span style="color:#f1fa8c">&#34;database&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> dbName <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#34;&#34;</span> {
</span></span><span style="display:flex;"><span>        dbName = s.<span style="color:#50fa7b">defaultDBFn</span>()  <span style="color:#6272a4">// 매 요청마다 최신값</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// main.go</span>
</span></span><span style="display:flex;"><span>dataapi.<span style="color:#50fa7b">New</span>(<span style="color:#ff79c6">...</span>, srv.DefaultDBName, <span style="color:#ff79c6">...</span>)  <span style="color:#6272a4">// 메서드 참조 (호출이 아님)</span>
</span></span></code></pre></div><p><code>srv.DefaultDBName</code>은 이미 <code>func() string</code> 시그니처를 가진 메서드다. 호출 결과(<code>string</code>) 대신 메서드 자체를 전달하면 된다.</p>
<hr>
<h2 id="5-per-database-config-validation-누락">5. Per-Database Config Validation 누락</h2>
<h3 id="문제-4">문제</h3>
<p>top-level pool 설정은 검증된다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> c.Pool.MaxConnections &lt; <span style="color:#bd93f9">1</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;pool.max_connections must be &gt;= 1&#34;</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>하지만 per-database 설정은 검증이 없다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">databases</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">mydb</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">pool</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">max_connections</span>: -<span style="color:#bd93f9">5</span>   <span style="color:#6272a4"># ← 검증 없이 통과</span>
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">idle_timeout</span>: -1s     <span style="color:#6272a4"># ← 검증 없이 통과</span>
</span></span></code></pre></div><p><code>max_connections: -5</code>는 <code>make([]*Conn, 0, -5)</code>까지 흘러 <strong>panic</strong>을 일으킨다. <code>idle_timeout: -1s</code>는 <code>time.NewTicker(-500ms)</code>에서 panic이다. 설정 오류가 런타임 crash로 이어진다.</p>
<h3 id="수정-4">수정</h3>
<p><code>validate()</code>의 per-database 루프에 pool 설정 검증을 추가했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">for</span> name, db <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> c.Databases {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 기존: host/port 검증</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 추가: pool 설정 검증</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> db.Pool.MaxConnections &lt; <span style="color:#bd93f9">1</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;databases.%s.pool.max_connections must be &gt;= 1, got %d&#34;</span>,
</span></span><span style="display:flex;"><span>            name, db.Pool.MaxConnections)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> db.Pool.MinConnections &lt; <span style="color:#bd93f9">0</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> db.Pool.MinConnections &gt; db.Pool.MaxConnections { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> db.Pool.IdleTimeout &lt; <span style="color:#bd93f9">0</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> db.Pool.MaxLifetime &lt; <span style="color:#bd93f9">0</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> db.Pool.ConnectionTimeout &lt; <span style="color:#bd93f9">0</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> db.Pool.QueryTimeout &lt; <span style="color:#bd93f9">0</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Top-level timeout도 음수 검증을 추가했다. <code>applyDefaults()</code>가 0을 기본값으로 채우지만, 명시적 음수는 건드리지 않기 때문이다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 QA 라운드의 패턴을 정리하면:</p>
<p><strong>1. 프로토콜 레벨 우회</strong> — Finding 1은 PG Extended Query Protocol의 &ldquo;Parse 재사용&rdquo; 특성을 이용한 우회다. 프록시는 클라이언트가 메시지를 어떤 조합으로 보낼지 통제할 수 없다. 보안 체크는 모든 경로에서 동일하게 동작해야 한다.</p>
<p><strong>2. 암묵적 부작용</strong> — Finding 2는 SQL의 의미론적 분석 한계다. <code>SELECT</code>라고 다 읽기가 아니다. <code>FOR UPDATE</code>, <code>nextval()</code>, <code>pg_advisory_lock()</code> — PostgreSQL은 SELECT 안에서도 상태를 변경한다. 프록시가 이를 인지하지 못하면 데이터 정합성이 깨진다.</p>
<p><strong>3. 경로별 일관성</strong> — Finding 3, 4는 &ldquo;메인 경로에서는 되는데 대체 경로에서 빠진&rdquo; 패턴이다. Simple Query vs Extended Query, proxy vs multiplex, 생성 시점 vs reload 시점 — 모든 경로에서 동일한 동작을 보장해야 한다.</p>
<p><strong>4. 방어적 검증</strong> — Finding 5는 &ldquo;잘못된 입력이 crash로 이어지면 안 된다&quot;는 기본 원칙이다. Go의 panic은 전체 프로세스를 죽인다. 사용자 설정은 반드시 검증하고, 불가능한 값은 시작 시점에 거부해야 한다.</p>
<p>다음 글에서는 성능 벤치마크나 새 기능을 다룰 예정이다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (54) - QA 5차: 릴리즈 위생과 CI 안정성</title><link>https://jyukki.com/posts/2026-03-17-pgmux-54-qa-round5-release-hygiene/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-17-pgmux-54-qa-round5-release-hygiene/</guid><description>QA 4차 수정으로 dataapi.New 시그니처가 바뀌었지만 테스트가 따라가지 못해 컴파일이 깨졌고, watcher 테스트는 time.Sleep 의존으로 race detector에서 간헐 실패했다. 릴리즈 파이프라인을 막는 2건의 테스트 블로커를 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>QA 4차에서 5건의 버그를 수정했다. 그중 4번 Finding — &ldquo;Data API defaultDB가 hot-reload 미반영&rdquo; — 의 수정이 <code>dataapi.New</code>의 시그니처를 바꿨다. <code>string</code> → <code>func() string</code>. 런타임 코드는 잘 동작하지만, 테스트 호출부가 이전 시그니처를 그대로 쓰고 있었다. <code>go test ./...</code>가 <code>internal/dataapi</code>에서 바로 깨진다.</p>
<p>별도로, Phase 17~18에서 추가한 <code>FileWatcher</code>의 <code>Ready()</code> 채널이 테스트에 반영되지 않았다. 4개 테스트 모두 <code>time.Sleep(100ms)</code>로 watcher 초기화를 기다리는데, <code>-race</code> 모드에서 타이밍에 따라 실패한다.</p>
<p>새 기능은 없다. 릴리즈를 막는 테스트 블로커 2건만 수정한다.</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>심각도</th>
          <th>요약</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Blocker</td>
          <td><code>dataapi.New</code> 시그니처 변경 후 테스트 호출부 미반영 (컴파일 실패)</td>
      </tr>
      <tr>
          <td>2</td>
          <td>Blocker</td>
          <td>Watcher 테스트 <code>time.Sleep</code> 초기화로 <code>-race</code> 플래키</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="1-dataapinew-시그니처-불일치">1. dataapi.New 시그니처 불일치</h2>
<h3 id="문제">문제</h3>
<p>QA 4차 Finding 4의 수정은 <code>dataapi.Server</code>의 <code>defaultDB</code> 필드를 정적 문자열에서 함수 참조로 바꿨다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// 변경 전</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Server <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    defaultDB <span style="color:#8be9fd">string</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// 변경 후</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Server <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    defaultDBFn <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#8be9fd">string</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>New()</code> 시그니처도 함께 바뀌었다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">New</span>(<span style="color:#ff79c6">...</span>, defaultDBFn <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#8be9fd">string</span>, <span style="color:#ff79c6">...</span>) <span style="color:#ff79c6">*</span>Server
</span></span></code></pre></div><p>런타임 호출부(<code>main.go</code>)는 <code>srv.DefaultDBName</code> (메서드 참조, <code>func() string</code> 타입)을 넘기도록 수정했다. 그런데 <strong>테스트 호출부 7곳</strong>이 이전 시그니처를 그대로 쓰고 있었다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// handler_test.go — proxySrv.DefaultDBName()는 string을 반환</span>
</span></span><span style="display:flex;"><span>srv <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">New</span>(<span style="color:#ff79c6">...</span>, proxySrv.<span style="color:#50fa7b">DefaultDBName</span>(), <span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span><span style="color:#6272a4">//                ^^^^^^^^^^^^^^^^^^^^^^ string, not func() string</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// cancel_leak_test.go — 빈 문자열 리터럴</span>
</span></span><span style="display:flex;"><span>srv <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">New</span>(<span style="color:#ff79c6">...</span>, <span style="color:#f1fa8c">&#34;&#34;</span>, <span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span><span style="color:#6272a4">//              ^^ string, not func() string</span>
</span></span></code></pre></div><p>Go는 <code>string</code>과 <code>func() string</code>을 타입 불일치로 거부한다. <code>go build</code>는 되지만 <code>go test ./internal/dataapi/</code>는 컴파일 단계에서 실패한다.</p>
<h3 id="이걸-놓친-이유">이걸 놓친 이유</h3>
<p><code>go build ./...</code>는 <code>_test.go</code>를 컴파일하지 않는다. CI에서 <code>go test ./...</code>를 돌리면 잡히지만, 로컬에서 &ldquo;빌드 통과&rdquo; 확인만 하고 넘어갔다. 코드 리뷰에서 시그니처 변경의 영향 범위를 체크했어야 한다.</p>
<h3 id="수정">수정</h3>
<p>호출 결과(<code>string</code>)가 아니라 함수로 감싸서 전달한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// handler_test.go — 4곳 수정</span>
</span></span><span style="display:flex;"><span>srv <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">New</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#ff79c6">*</span>config.Config { <span style="color:#ff79c6">return</span> cfg },
</span></span><span style="display:flex;"><span>    proxySrv.DBGroups,
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#8be9fd">string</span> { <span style="color:#ff79c6">return</span> proxySrv.<span style="color:#50fa7b">DefaultDBName</span>() }, <span style="color:#6272a4">// 함수 래퍼</span>
</span></span><span style="display:flex;"><span>    nilCache,
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">nil</span>,
</span></span><span style="display:flex;"><span>    nilRateLimiter,
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">nil</span>,
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// cancel_leak_test.go — 3곳 수정</span>
</span></span><span style="display:flex;"><span>srv <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">New</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#ff79c6">*</span>config.Config { <span style="color:#ff79c6">return</span> cfg },
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">nil</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#8be9fd">string</span> { <span style="color:#ff79c6">return</span> <span style="color:#f1fa8c">&#34;&#34;</span> }, <span style="color:#6272a4">// 빈 문자열 반환 함수</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">nil</span>, <span style="color:#ff79c6">nil</span>, <span style="color:#ff79c6">nil</span>, <span style="color:#ff79c6">nil</span>,
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>단순히 타입을 맞추는 것이지만, <code>func() string</code>인 이유가 중요하다. 매 요청마다 최신 설정을 반환해야 하니까 함수를 쓴 것이다. 테스트에서는 설정이 변하지 않으므로 고정값 반환 함수면 충분하다.</p>
<hr>
<h2 id="2-watcher-테스트의-timesleep-플래키">2. Watcher 테스트의 time.Sleep 플래키</h2>
<h3 id="문제-1">문제</h3>
<p><code>FileWatcher</code>에는 <code>Ready()</code> 채널이 있다. <code>Start()</code>가 디렉토리 감시를 등록한 직후 이 채널을 닫는다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (fw <span style="color:#ff79c6">*</span>FileWatcher) <span style="color:#50fa7b">Start</span>(ctx context.Context) <span style="color:#8be9fd">error</span> {
</span></span><span style="display:flex;"><span>    dir <span style="color:#ff79c6">:=</span> filepath.<span style="color:#50fa7b">Dir</span>(fw.path)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> fw.watcher.<span style="color:#50fa7b">Add</span>(dir); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;watch directory %s: %w&#34;</span>, dir, err)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">close</span>(fw.readyCh)  <span style="color:#6272a4">// ← 감시 등록 완료 신호</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// event loop 진입...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (fw <span style="color:#ff79c6">*</span>FileWatcher) <span style="color:#50fa7b">Ready</span>() <span style="color:#ff79c6">&lt;-</span><span style="color:#8be9fd;font-style:italic">chan</span> <span style="color:#8be9fd;font-style:italic">struct</span>{} {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fw.readyCh
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>그런데 테스트 4개가 모두 이 채널을 무시하고 <code>time.Sleep(100ms)</code>를 쓰고 있었다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    fw.<span style="color:#50fa7b">Start</span>(ctx)
</span></span><span style="display:flex;"><span>}()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>time.<span style="color:#50fa7b">Sleep</span>(<span style="color:#bd93f9">100</span> <span style="color:#ff79c6">*</span> time.Millisecond)  <span style="color:#6272a4">// watcher 초기화 &#34;기다림&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>os.<span style="color:#50fa7b">WriteFile</span>(cfgFile, []<span style="color:#8be9fd;font-style:italic">byte</span>(<span style="color:#f1fa8c">&#34;modified&#34;</span>), <span style="color:#bd93f9">0644</span>)
</span></span></code></pre></div><p>대부분의 환경에서 100ms면 충분하다. 하지만 <code>-race</code> 모드는 goroutine 스케줄링에 오버헤드를 준다. 100ms 안에 <code>Start()</code>가 <code>watcher.Add(dir)</code>까지 도달하지 못할 수 있다. 이 경우:</p>
<ol>
<li><code>time.Sleep</code> 종료</li>
<li>파일 수정 발생</li>
<li><strong>그 후에</strong> <code>watcher.Add(dir)</code> 실행 — 이벤트를 놓침</li>
<li>콜백 미호출 → 테스트 실패</li>
</ol>
<p><code>go test -race ./internal/config -run TestFileWatcher_SymlinkSwap -count=5</code>로 재현했을 때 5회 중 2회 실패했다. CI에서도 같은 환경이라면 <strong>비결정적 실패</strong>가 반복된다.</p>
<h3 id="수정-1">수정</h3>
<p><code>time.Sleep</code>을 <code>&lt;-fw.Ready()</code>로 교체한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    fw.<span style="color:#50fa7b">Start</span>(ctx)
</span></span><span style="display:flex;"><span>}()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">&lt;-</span>fw.<span style="color:#50fa7b">Ready</span>()  <span style="color:#6272a4">// 디렉토리 감시 등록이 완료될 때까지 대기</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>os.<span style="color:#50fa7b">WriteFile</span>(cfgFile, []<span style="color:#8be9fd;font-style:italic">byte</span>(<span style="color:#f1fa8c">&#34;modified&#34;</span>), <span style="color:#bd93f9">0644</span>)
</span></span></code></pre></div><p>4개 테스트 모두 동일하게 수정했다:</p>
<ul>
<li><code>TestFileWatcher_Modification</code></li>
<li><code>TestFileWatcher_Debounce</code></li>
<li><code>TestFileWatcher_SymlinkSwap</code></li>
<li><code>TestFileWatcher_Stop</code></li>
</ul>
<p><code>Ready()</code>는 <strong>이벤트 기반 동기화</strong>다. &ldquo;얼마나 기다려야 하는지&quot;를 추측하지 않고, &ldquo;준비됐을 때&rdquo; 진행한다. race detector가 스케줄링을 아무리 지연시켜도, 채널이 닫히기 전까지는 진행하지 않는다.</p>
<h3 id="검증">검증</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>$ go test -race ./internal/config/ -run TestFileWatcher -count=5
</span></span><span style="display:flex;"><span>--- PASS: TestFileWatcher_Modification (1.50s)
</span></span><span style="display:flex;"><span>--- PASS: TestFileWatcher_Debounce (2.01s)
</span></span><span style="display:flex;"><span>--- PASS: TestFileWatcher_SymlinkSwap (1.51s)
</span></span><span style="display:flex;"><span>--- PASS: TestFileWatcher_Stop (1.50s)
</span></span><span style="display:flex;"><span>(x5, 20/20 PASS)
</span></span></code></pre></div><hr>
<h2 id="timesleep-vs-채널-동기화">time.Sleep vs 채널 동기화</h2>
<p>이번 수정은 코드 한 줄이지만, 테스트에서 반복되는 실수 패턴이다. 정리하면:</p>
<table>
  <thead>
      <tr>
          <th>방식</th>
          <th>장점</th>
          <th>단점</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>time.Sleep(N)</code></td>
          <td>간단</td>
          <td>N이 충분한지 보장 불가, race 모드에서 플래키, 불필요하게 느림</td>
      </tr>
      <tr>
          <td><code>&lt;-readyCh</code></td>
          <td>결정적, 최소 대기</td>
          <td>생산 코드에 동기화 포인트 필요</td>
      </tr>
      <tr>
          <td>polling + deadline</td>
          <td>외부 상태 체크 가능</td>
          <td>복잡, busy wait</td>
      </tr>
  </tbody>
</table>
<p><code>time.Sleep</code>이 테스트에서 필요한 경우도 있다 — debounce interval 이후 결과를 확인할 때처럼, 일정 시간이 <strong>실제로 흘러야</strong> 하는 경우. 하지만 <strong>초기화 동기화</strong>에는 절대 쓰면 안 된다. 초기화는 시간이 아니라 상태에 의존하기 때문이다.</p>
<p>이 프로젝트에서는 <code>Ready()</code> 채널을 Phase 17~18에서 이미 구현해놨다. 구현한 사람이 테스트에 적용하는 걸 깜빡한 것이다. API를 만들었으면 테스트가 첫 번째 소비자여야 한다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 QA 라운드는 짧다. 새 기능도 없고, 런타임 버그도 아니다. 하지만 <strong>릴리즈 파이프라인을 막는</strong> 블로커였다:</p>
<ol>
<li>테스트 컴파일 실패 → <code>go test ./...</code> 불가 → CI 레드</li>
<li>race 플래키 → CI 비결정적 실패 → 머지 신뢰도 하락</li>
</ol>
<p>둘 다 &ldquo;코드는 맞는데 테스트가 틀린&rdquo; 케이스다. 시그니처 변경 시 호출부 전수 검사, 동기화 프리미티브가 있으면 테스트에서 먼저 사용 — 이 두 가지가 교훈이다.</p>
<p>다음 글에서는 새 기능이나 성능 주제를 다룰 예정이다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (55) - QA 6차: 파서 우회와 분류 사각지대</title><link>https://jyukki.com/posts/2026-03-17-pgmux-55-qa-round6-parser-bypass/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-17-pgmux-55-qa-round6-parser-bypass/</guid><description>앞쪽 주석(/*x*/ BEGIN)이 트랜잭션/세션 상태기를 통째로 우회하고, MERGE·COPY·CALL이 reader로 빠지며, 주석/리터럴 안의 키워드가 false positive를 내는 5건의 파서·라우터 버그를 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>QA 5차까지는 런타임 안전성(풀 오염, race, timeout)에 집중했다. 이번 라운드는 <strong>쿼리 파서와 라우터의 정확성</strong>을 집중 검토한다. &ldquo;이 쿼리가 올바른 백엔드에 도달하는가?&ldquo;가 핵심 질문이다.</p>
<p>발견된 5건 중 3건이 High — 트랜잭션 상태 우회, 세션 오염, write 오분류 — 로, 실제 운영에서 데이터 불일치나 세션 혼선을 유발할 수 있다.</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>심각도</th>
          <th>요약</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>High</td>
          <td>앞쪽 주석이 트랜잭션 상태기 우회 (<code>/*x*/ BEGIN</code> → reader 라우팅)</td>
      </tr>
      <tr>
          <td>2</td>
          <td>High</td>
          <td>앞쪽 주석이 세션 의존 감지·리셋 동시 우회 (<code>/*x*/ SET</code> → reader 오염)</td>
      </tr>
      <tr>
          <td>3</td>
          <td>High</td>
          <td>MERGE, COPY FROM, CALL이 read로 분류 → reader 오라우팅</td>
      </tr>
      <tr>
          <td>4</td>
          <td>Medium</td>
          <td>advisory lock 검출이 리터럴/주석에서 false positive</td>
      </tr>
      <tr>
          <td>5</td>
          <td>Low-Medium</td>
          <td><code>isSideEffectfulSelect</code>가 주석 안 텍스트를 write 신호로 오인</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="1-앞쪽-주석이-트랜잭션-상태기를-우회한다">1. 앞쪽 주석이 트랜잭션 상태기를 우회한다</h2>
<h3 id="문제">문제</h3>
<p><code>hasTxPrefix</code>는 쿼리의 첫 번째 키워드를 보고 트랜잭션 진입/이탈을 판단한다. 그런데 <strong>공백만 건너뛰고 주석은 건너뛰지 않는다</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">hasTxPrefix</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">int</span> {
</span></span><span style="display:flex;"><span>    i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> (query[i] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39; &#39;</span> <span style="color:#ff79c6">||</span> query[i] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;\t&#39;</span> <span style="color:#ff79c6">||</span> <span style="color:#ff79c6">...</span>) {
</span></span><span style="display:flex;"><span>        i<span style="color:#ff79c6">++</span>  <span style="color:#6272a4">// 공백만 skip</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    rest <span style="color:#ff79c6">:=</span> query[i:]
</span></span><span style="display:flex;"><span>    ch <span style="color:#ff79c6">:=</span> rest[<span style="color:#bd93f9">0</span>] | <span style="color:#bd93f9">0x20</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">switch</span> ch {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">case</span> <span style="color:#f1fa8c">&#39;b&#39;</span>: <span style="color:#6272a4">// BEGIN</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span></code></pre></div><p><code>/*x*/ BEGIN</code>을 넣으면:</p>
<ol>
<li>공백 skip → i=0 (첫 글자가 <code>/</code>)</li>
<li><code>rest[0]</code> = <code>/</code> → <code>ch</code> = <code>/</code> → 어떤 case에도 매칭 안 됨</li>
<li><code>hasTxPrefix</code> 반환값 0 → <strong>트랜잭션 상태 미변경</strong></li>
</ol>
<p>같은 문제가 <code>updateTransactionState</code>, <code>containsTransactionKeyword</code>, <code>routeLocked</code>에도 있다. <code>TrimSpace</code> + <code>HasPrefix</code> 패턴은 주석을 제거하지 않기 때문이다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(strings.<span style="color:#50fa7b">TrimSpace</span>(stmt))
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">HasPrefix</span>(upper, <span style="color:#f1fa8c">&#34;BEGIN&#34;</span>) {  <span style="color:#6272a4">// &#34;/*X*/ BEGIN&#34;은 매칭 실패</span>
</span></span></code></pre></div><p>Extended Query 경로의 tx 체크도 동일한 패턴이다.</p>
<h3 id="영향">영향</h3>
<p><code>/*x*/ BEGIN</code>이 트랜잭션으로 인식되지 않으면:</p>
<ul>
<li>이후 쿼리가 writer에 묶이지 않고 reader로 분산됨</li>
<li>reader에서 write 시도 → <code>ERROR: cannot execute INSERT in a read-only transaction</code></li>
<li>또는 각 쿼리가 다른 커넥션으로 가서 트랜잭션 격리 깨짐</li>
</ul>
<h3 id="수정">수정</h3>
<p><strong><code>SkipLeadingNoise</code></strong> 함수를 추가한다. 공백 + 블록 주석(<code>/* ... */</code>, 중첩 포함) + 라인 주석(<code>-- ...\n</code>)을 모두 건너뛴다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">SkipLeadingNoise</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">int</span> {
</span></span><span style="display:flex;"><span>    i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) {
</span></span><span style="display:flex;"><span>        ch <span style="color:#ff79c6">:=</span> query[i]
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39; &#39;</span> <span style="color:#ff79c6">||</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;\t&#39;</span> <span style="color:#ff79c6">||</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;\n&#39;</span> <span style="color:#ff79c6">||</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;\r&#39;</span> {
</span></span><span style="display:flex;"><span>            i<span style="color:#ff79c6">++</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">continue</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;/&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;*&#39;</span> {
</span></span><span style="display:flex;"><span>            depth <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">1</span>
</span></span><span style="display:flex;"><span>            i <span style="color:#ff79c6">+=</span> <span style="color:#bd93f9">2</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">for</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> depth &gt; <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">if</span> i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> query[i] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;/&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;*&#39;</span> {
</span></span><span style="display:flex;"><span>                    depth<span style="color:#ff79c6">++</span>; i <span style="color:#ff79c6">+=</span> <span style="color:#bd93f9">2</span>
</span></span><span style="display:flex;"><span>                } <span style="color:#ff79c6">else</span> <span style="color:#ff79c6">if</span> i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> query[i] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;*&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;/&#39;</span> {
</span></span><span style="display:flex;"><span>                    depth<span style="color:#ff79c6">--</span>; i <span style="color:#ff79c6">+=</span> <span style="color:#bd93f9">2</span>
</span></span><span style="display:flex;"><span>                } <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>                    i<span style="color:#ff79c6">++</span>
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">continue</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;-&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;-&#39;</span> {
</span></span><span style="display:flex;"><span>            i <span style="color:#ff79c6">+=</span> <span style="color:#bd93f9">2</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">for</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> query[i] <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#39;\n&#39;</span> { i<span style="color:#ff79c6">++</span> }
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) { i<span style="color:#ff79c6">++</span> }
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">continue</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">break</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> i
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>적용 범위:</p>
<ul>
<li><strong><code>hasTxPrefix</code></strong>: 공백 skip 루프를 <code>SkipLeadingNoise</code>로 교체</li>
<li><strong><code>updateTransactionState</code></strong>, <strong><code>containsTransactionKeyword</code></strong>: <code>stripComments(stmt)</code> 후 <code>TrimSpace</code>+<code>HasPrefix</code></li>
<li><strong><code>routeLocked</code></strong>: 동일하게 <code>stripComments</code> 적용</li>
<li><strong>Extended Query path</strong> (proxy/query.go): <code>IsTxControl(query)</code> export 함수로 교체</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// 변경 전 (proxy/query.go, 2곳)</span>
</span></span><span style="display:flex;"><span>upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(strings.<span style="color:#50fa7b">TrimSpace</span>(query))
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">HasPrefix</span>(upper, <span style="color:#f1fa8c">&#34;BEGIN&#34;</span>) { extTxStart = <span style="color:#ff79c6">true</span> }
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">HasPrefix</span>(upper, <span style="color:#f1fa8c">&#34;COMMIT&#34;</span>) { extTxEnd = <span style="color:#ff79c6">true</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// 변경 후</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> txStart, txEnd <span style="color:#ff79c6">:=</span> router.<span style="color:#50fa7b">IsTxControl</span>(query); txStart {
</span></span><span style="display:flex;"><span>    extTxStart = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>} <span style="color:#ff79c6">else</span> <span style="color:#ff79c6">if</span> txEnd {
</span></span><span style="display:flex;"><span>    extTxEnd = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>IsTxControl</code>은 내부적으로 이미 수정된 <code>hasTxPrefix</code>를 호출하므로 주석을 올바르게 처리한다.</p>
<hr>
<h2 id="2-앞쪽-주석이-세션-감지리셋을-동시에-우회한다">2. 앞쪽 주석이 세션 감지·리셋을 동시에 우회한다</h2>
<h3 id="문제-1">문제</h3>
<p>Finding 1과 같은 근본 원인이 세션 관련 함수에도 있다.</p>
<p><strong><code>detectSingleStmtDependency</code></strong> (session_compat.go):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">detectSingleStmtDependency</span>(query <span style="color:#8be9fd">string</span>) SessionDependencyResult {
</span></span><span style="display:flex;"><span>    i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> (query[i] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39; &#39;</span> <span style="color:#ff79c6">||</span> <span style="color:#ff79c6">...</span>) { i<span style="color:#ff79c6">++</span> } <span style="color:#6272a4">// 공백만</span>
</span></span><span style="display:flex;"><span>    rest <span style="color:#ff79c6">:=</span> query[i:]
</span></span><span style="display:flex;"><span>    ch <span style="color:#ff79c6">:=</span> rest[<span style="color:#bd93f9">0</span>] | <span style="color:#bd93f9">0x20</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">switch</span> ch {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">case</span> <span style="color:#f1fa8c">&#39;s&#39;</span>: <span style="color:#6272a4">// SET</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">case</span> <span style="color:#f1fa8c">&#39;l&#39;</span>: <span style="color:#6272a4">// LISTEN</span>
</span></span></code></pre></div><p><strong><code>isSessionModifying</code></strong> (backend.go) — 동일 구조:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">isSessionModifying</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> (query[i] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39; &#39;</span> <span style="color:#ff79c6">||</span> <span style="color:#ff79c6">...</span>) { i<span style="color:#ff79c6">++</span> } <span style="color:#6272a4">// 공백만</span>
</span></span></code></pre></div><p><code>/*x*/ SET search_path = 'evil'</code>을 보내면:</p>
<ol>
<li><code>DetectSessionDependency</code> → 미감지 → pin/block 안 걸림</li>
<li><code>Classify</code> → <code>firstKeyword</code> = &ldquo;SET&rdquo; (stripComments 적용됨) → <strong>하지만 SET은 <code>writeKeywords</code>에 없다</strong> → <code>QueryRead</code></li>
<li>reader로 라우팅 → reader 커넥션의 search_path가 변경됨</li>
<li><code>isSessionModifying</code> → 미감지 → <code>connDirty</code> 안 켜짐 → DISCARD ALL 없이 풀에 반환</li>
<li>다음 사용자가 오염된 커넥션을 받음</li>
</ol>
<h3 id="수정-1">수정</h3>
<p>두 함수 모두 공백 skip을 <code>SkipLeadingNoise</code>로 교체:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// session_compat.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">detectSingleStmtDependency</span>(query <span style="color:#8be9fd">string</span>) SessionDependencyResult {
</span></span><span style="display:flex;"><span>    i <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">SkipLeadingNoise</span>(query) <span style="color:#6272a4">// 주석도 skip</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// backend.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">isSessionModifying</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    i <span style="color:#ff79c6">:=</span> router.<span style="color:#50fa7b">SkipLeadingNoise</span>(query) <span style="color:#6272a4">// 주석도 skip</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="3-merge-copy-call이-read로-분류된다">3. MERGE, COPY, CALL이 read로 분류된다</h2>
<h3 id="문제-2">문제</h3>
<p>string parser의 <code>writeKeywords</code> 맵:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> writeKeywords = <span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]<span style="color:#8be9fd">bool</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;INSERT&#34;</span>: <span style="color:#ff79c6">true</span>, <span style="color:#f1fa8c">&#34;UPDATE&#34;</span>: <span style="color:#ff79c6">true</span>, <span style="color:#f1fa8c">&#34;DELETE&#34;</span>: <span style="color:#ff79c6">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;CREATE&#34;</span>: <span style="color:#ff79c6">true</span>, <span style="color:#f1fa8c">&#34;ALTER&#34;</span>: <span style="color:#ff79c6">true</span>, <span style="color:#f1fa8c">&#34;DROP&#34;</span>: <span style="color:#ff79c6">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;TRUNCATE&#34;</span>: <span style="color:#ff79c6">true</span>, <span style="color:#f1fa8c">&#34;GRANT&#34;</span>: <span style="color:#ff79c6">true</span>, <span style="color:#f1fa8c">&#34;REVOKE&#34;</span>: <span style="color:#ff79c6">true</span>,
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>MERGE</strong> (PostgreSQL 15+), <strong>COPY</strong>, <strong>CALL</strong>이 없다. AST parser의 <code>isWriteNode</code>에도 해당 노드 타입이 없다.</p>
<p>결과:</p>
<ul>
<li><code>MERGE INTO target USING source ...</code> → reader로 라우팅 → <code>ERROR: read-only transaction</code></li>
<li><code>COPY users FROM STDIN</code> → reader로 라우팅 → <code>ERROR: read-only transaction</code> (relay 코드는 있지만 라우팅이 잘못됨)</li>
<li><code>CALL my_procedure(1)</code> → reader로 라우팅 → 프로시저가 write하면 실패</li>
</ul>
<p>추가로, <code>EXPLAIN ANALYZE INSERT INTO ...</code>는 실제로 INSERT를 실행하지만 string/AST 양쪽에서 read로 분류된다.</p>
<h3 id="수정-2">수정</h3>
<p><strong>String parser</strong> — <code>writeKeywords</code>에 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> writeKeywords = <span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]<span style="color:#8be9fd">bool</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... 기존 9개 ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;MERGE&#34;</span>: <span style="color:#ff79c6">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;COPY&#34;</span>:  <span style="color:#ff79c6">true</span>,  <span style="color:#6272a4">// COPY TO도 write로 분류 (안전한 기본값)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f1fa8c">&#34;CALL&#34;</span>:  <span style="color:#ff79c6">true</span>,
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>String parser에서 COPY TO를 read로 정밀 분류하려면 FROM/TO 파싱이 필요해서 복잡도 대비 효용이 낮다. 안전하게 모두 writer로 보낸다.</p>
<p><strong>AST parser</strong> — <code>isWriteNode</code>에 노드 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_MergeStmt:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_CopyStmt:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> n.CopyStmt.<span style="color:#50fa7b">GetIsFrom</span>()  <span style="color:#6272a4">// COPY FROM만 write, COPY TO는 read</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_CallStmt:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_ExplainStmt:
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// EXPLAIN ANALYZE + write subquery만 write</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, opt <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> n.ExplainStmt.<span style="color:#50fa7b">GetOptions</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> de <span style="color:#ff79c6">:=</span> opt.<span style="color:#50fa7b">GetDefElem</span>(); de <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> <span style="color:#ff79c6">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>           strings.<span style="color:#50fa7b">ToLower</span>(de.<span style="color:#50fa7b">GetDefname</span>()) <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#34;analyze&#34;</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> n.ExplainStmt.<span style="color:#50fa7b">GetQuery</span>() <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">isWriteNode</span>(n.ExplainStmt.<span style="color:#50fa7b">GetQuery</span>())
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">false</span>
</span></span></code></pre></div><p>AST parser는 CopyStmt의 <code>IsFrom</code> 필드로 방향을 정확히 구분할 수 있다. <code>EXPLAIN ANALYZE</code>는 options에서 <code>analyze</code> DefElem을 찾고, 내부 쿼리가 write인 경우에만 write로 분류한다. <code>EXPLAIN</code>만 쓰면 실제 실행하지 않으므로 read다.</p>
<hr>
<h2 id="4-advisory-lock-검출의-false-positive">4. Advisory lock 검출의 false positive</h2>
<h3 id="문제-3">문제</h3>
<p><code>containsSessionAdvisoryLock</code>은 원본 쿼리에 바로 <code>strings.Contains</code>를 건다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">containsSessionAdvisoryLock</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    lower <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToLower</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> strings.<span style="color:#50fa7b">Contains</span>(lower, <span style="color:#f1fa8c">&#34;advisory_lock&#34;</span>) <span style="color:#ff79c6">||</span>
</span></span><span style="display:flex;"><span>           strings.<span style="color:#50fa7b">Contains</span>(lower, <span style="color:#f1fa8c">&#34;advisory_unlock&#34;</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><ul>
<li><code>SELECT 'pg_advisory_lock'</code> → 문자열 리터럴 안의 텍스트에 반응 → <strong>false positive</strong></li>
<li><code>/* advisory_unlock */ SELECT 1</code> → 주석 안의 텍스트에 반응 → <strong>false positive</strong></li>
</ul>
<p><code>DetectSessionDependencyAST</code>도 AST를 먼저 체크하지만, advisory lock만은 string 검사로 폴백한다 (&ldquo;function calls require walking the full expression tree&quot;라는 주석과 함께). 그래서 AST 경로에서도 같은 false positive가 발생한다.</p>
<h3 id="수정-3">수정</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">containsSessionAdvisoryLock</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    lower <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToLower</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Fast path: raw 쿼리에 &#34;advisory&#34;가 없으면 skip</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> !strings.<span style="color:#50fa7b">Contains</span>(lower, <span style="color:#f1fa8c">&#34;advisory&#34;</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">false</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Slow path: 리터럴과 주석 제거 후 검사</span>
</span></span><span style="display:flex;"><span>    cleaned <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToLower</span>(<span style="color:#50fa7b">stripComments</span>(<span style="color:#50fa7b">stripStringLiterals</span>(query)))
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> strings.<span style="color:#50fa7b">Contains</span>(cleaned, <span style="color:#f1fa8c">&#34;advisory_lock&#34;</span>) <span style="color:#ff79c6">||</span>
</span></span><span style="display:flex;"><span>           strings.<span style="color:#50fa7b">Contains</span>(cleaned, <span style="color:#f1fa8c">&#34;advisory_unlock&#34;</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>99.9%의 쿼리는 &ldquo;advisory&quot;를 포함하지 않으므로 fast path에서 즉시 반환한다. 실제 advisory lock 호출이 있을 때만 <code>stripComments(stripStringLiterals())</code>의 비용을 지불한다.</p>
<p>순서가 중요하다: <code>stripStringLiterals</code>를 먼저 호출해야 한다. 문자열 안의 <code>/*</code>가 주석 시작으로 오인되는 것을 방지하기 위해서다. <code>stripStringLiterals</code>는 문자열 내용을 비우고 따옴표는 남기므로, 이후 <code>stripComments</code>가 안전하게 동작한다.</p>
<hr>
<h2 id="5-string-parser가-주석-안-텍스트를-write-신호로-오인한다">5. String parser가 주석 안 텍스트를 write 신호로 오인한다</h2>
<h3 id="문제-4">문제</h3>
<p><code>isSideEffectfulSelect</code>와 <code>containsWriteKeyword</code>가 <code>stripStringLiterals</code>만 호출하고 <code>stripComments</code>는 호출하지 않는다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">isSideEffectfulSelect</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(<span style="color:#50fa7b">stripStringLiterals</span>(query))
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// &#34;FOR UPDATE&#34;, &#34;nextval(&#34; 등을 검색</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">containsWriteKeyword</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(<span style="color:#50fa7b">stripStringLiterals</span>(query))
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// INSERT, UPDATE 등을 검색</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>/* FOR UPDATE */ SELECT 1</code>:</p>
<ol>
<li><code>stripStringLiterals</code> → 변화 없음 (문자열 리터럴이 없으므로)</li>
<li><code>FOR UPDATE</code>가 주석 안에 있지만 그대로 매칭됨</li>
<li><code>isSideEffectfulSelect</code> = true → <code>QueryWrite</code> → writer로 오라우팅</li>
</ol>
<p><code>WITH x AS (SELECT 1) /* UPDATE */ SELECT * FROM x</code>도 같은 문제다.</p>
<p><code>classifyFast</code>는 <code>/*</code>를 발견하면 slow path로 넘기므로 이 쿼리들은 반드시 이 경로를 탄다. 그리고 <code>firstKeyword</code>는 내부적으로 <code>stripComments</code>를 호출해서 키워드 추출은 정확하다. 하지만 이후 <code>isSideEffectfulSelect(stmt)</code>에 원본 stmt이 전달된다.</p>
<h3 id="수정-4">수정</h3>
<p>두 함수 모두 <code>stripComments</code>를 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">isSideEffectfulSelect</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(<span style="color:#50fa7b">stripComments</span>(<span style="color:#50fa7b">stripStringLiterals</span>(query)))
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">containsWriteKeyword</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(<span style="color:#50fa7b">stripComments</span>(<span style="color:#50fa7b">stripStringLiterals</span>(query)))
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Finding 4와 동일한 순서: <code>stripStringLiterals</code> → <code>stripComments</code>.</p>
<hr>
<h2 id="공통-패턴-noise-skip의-일관성">공통 패턴: &ldquo;noise skip&quot;의 일관성</h2>
<p>5건의 발견을 관통하는 패턴이 있다. <strong>키워드 매칭 전에 제거해야 할 &ldquo;노이즈&quot;의 범위가 함수마다 달랐다</strong>:</p>
<table>
  <thead>
      <tr>
          <th>함수</th>
          <th style="text-align: center">공백</th>
          <th style="text-align: center">주석</th>
          <th style="text-align: center">문자열 리터럴</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>hasTxPrefix</code> (수정 전)</td>
          <td style="text-align: center">O</td>
          <td style="text-align: center">X</td>
          <td style="text-align: center">-</td>
      </tr>
      <tr>
          <td><code>firstKeyword</code></td>
          <td style="text-align: center">O</td>
          <td style="text-align: center">O</td>
          <td style="text-align: center">-</td>
      </tr>
      <tr>
          <td><code>isSideEffectfulSelect</code> (수정 전)</td>
          <td style="text-align: center">-</td>
          <td style="text-align: center">X</td>
          <td style="text-align: center">O</td>
      </tr>
      <tr>
          <td><code>containsSessionAdvisoryLock</code> (수정 전)</td>
          <td style="text-align: center">-</td>
          <td style="text-align: center">X</td>
          <td style="text-align: center">X</td>
      </tr>
  </tbody>
</table>
<p>수정 후에는 모두 동일한 기준으로 노이즈를 제거한다:</p>
<table>
  <thead>
      <tr>
          <th>함수</th>
          <th>방식</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>prefix 매칭 (hasTxPrefix, detect*)</td>
          <td><code>SkipLeadingNoise</code> (앞쪽만)</td>
      </tr>
      <tr>
          <td>substring 매칭 (isSideEffectful*, advisory*)</td>
          <td><code>stripComments(stripStringLiterals())</code> (전체)</td>
      </tr>
      <tr>
          <td>keyword 추출 (firstKeyword)</td>
          <td><code>stripComments</code> (기존 정상)</td>
      </tr>
  </tbody>
</table>
<p>앞쪽만 건너뛰는 <code>SkipLeadingNoise</code>가 <code>stripComments</code>보다 빠르다. 전체 문자열을 새로 만들지 않고 인덱스만 반환하기 때문이다. prefix 매칭에는 이쪽이 적합하다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 라운드는 &ldquo;파서가 정확한가?&ldquo;를 집중적으로 파고들었다. 5건 모두 공격자가 의도적으로 주석을 붙이면 라우팅을 조작할 수 있는 문제였다:</p>
<ol>
<li><strong><code>/*x*/ BEGIN</code></strong> → 트랜잭션 없이 write 분산</li>
<li><strong><code>/*x*/ SET</code></strong> → reader 세션 오염</li>
<li><strong><code>MERGE INTO ...</code></strong> → reader 오라우팅</li>
<li><strong><code>SELECT 'pg_advisory_lock'</code></strong> → 불필요한 session pin</li>
<li><strong><code>/* FOR UPDATE */ SELECT 1</code></strong> → 불필요한 writer 라우팅</li>
</ol>
<p>1<del>3은 보안/정합성 문제, 4</del>5는 성능 문제다. 근본 원인은 하나다: <strong>SQL 텍스트에서 의미 있는 부분만 보려면, 주석과 리터럴을 먼저 제거해야 한다.</strong> 이 원칙이 코드베이스 전체에 일관되게 적용되지 않았다.</p>
<p><code>SkipLeadingNoise</code>와 <code>stripComments(stripStringLiterals())</code> 두 가지 도구로 통일했다. 앞으로 새 키워드 매칭 로직을 추가할 때도 이 패턴을 따르면 같은 실수를 반복하지 않을 것이다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (56) - QA 7차: 릴리즈 전 최종 코드 리뷰</title><link>https://jyukki.com/posts/2026-03-17-pgmux-56-qa-round7-pre-release/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-17-pgmux-56-qa-round7-pre-release/</guid><description>v1.0.0 릴리즈 직전, 전체 코드베이스를 엣지케이스까지 훑어 CopyBoth 고루틴 race, 캐시 인덱스 정합성 파손, Synthesizer 메모리 고갈 등 14건을 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>QA 6차까지 파서 정확성을 잡았다. 이번에는 릴리즈 직전 <strong>전체 코드베이스를 처음부터 끝까지</strong> 훑는다. 엣지케이스, 동시성, 메모리, 보안 — 카테고리를 가리지 않고 14건을 찾아 수정했다.</p>
<p>심각도별로 3개 그룹으로 나뉜다:</p>
<table>
  <thead>
      <tr>
          <th>그룹</th>
          <th>건수</th>
          <th>핵심</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HIGH</td>
          <td>4건</td>
          <td>CopyBoth race, rate limiter clock skew, SQL 에러 노출, 캐시 인덱스 파손</td>
      </tr>
      <tr>
          <td>MEDIUM</td>
          <td>5건</td>
          <td>메시지 크기 제한, synthesizer 메모리, watcher 블로킹, parseSize 무경고, 테스트 fmt.Println</td>
      </tr>
      <tr>
          <td>Parser 일관성</td>
          <td>5건</td>
          <td>EXPLAIN ANALYZE, ABORT, 캐시 무효화 갭, Data API COPY, SET CONSTRAINTS</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="high-운영-장애를-유발할-수-있는-4건">HIGH: 운영 장애를 유발할 수 있는 4건</h2>
<h3 id="1-copyboth-고루틴-race">1. CopyBoth 고루틴 race</h3>
<p>PostgreSQL의 Logical Replication은 <code>CopyBoth</code> 서브프로토콜을 사용한다. 클라이언트→백엔드, 백엔드→클라이언트 두 방향을 동시에 릴레이해야 하므로 고루틴 2개를 띄운다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// 변경 전</span>
</span></span><span style="display:flex;"><span>errCh <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>(<span style="color:#8be9fd;font-style:italic">chan</span> <span style="color:#8be9fd">error</span>, <span style="color:#bd93f9">2</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() { errCh <span style="color:#ff79c6">&lt;-</span> <span style="color:#50fa7b">relayCopyData</span>(clientConn, backendConn) }()
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() { errCh <span style="color:#ff79c6">&lt;-</span> <span style="color:#50fa7b">relayCopyData</span>(backendConn, clientConn) }()
</span></span><span style="display:flex;"><span>err <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">&lt;-</span>errCh  <span style="color:#6272a4">// 하나만 기다림</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">return</span> err
</span></span></code></pre></div><p>한쪽이 에러로 종료하면 <strong>나머지 고루틴은 방치된다</strong>. 반대편 소켓이 닫히면 결국 종료되지만, 그 사이에 닫힌 소켓에 쓰기를 시도하거나 이미 반환된 커넥션에 접근할 수 있다.</p>
<p>수정은 양쪽 모두 수거하는 것이다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>err1 <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">&lt;-</span>errCh
</span></span><span style="display:flex;"><span>err2 <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">&lt;-</span>errCh
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err1 <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> err1
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">return</span> err2
</span></span></code></pre></div><h3 id="2-rate-limiter-시계-역행">2. Rate Limiter 시계 역행</h3>
<p>Token Bucket rate limiter는 <code>time.Now()</code>의 차분으로 토큰을 채운다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>elapsed <span style="color:#ff79c6">:=</span> now.<span style="color:#50fa7b">Sub</span>(rl.lastTime)
</span></span><span style="display:flex;"><span>rl.tokens <span style="color:#ff79c6">+=</span> elapsed.<span style="color:#50fa7b">Seconds</span>() <span style="color:#ff79c6">*</span> rl.rate
</span></span></code></pre></div><p>NTP 보정으로 시계가 뒤로 가면 <code>elapsed</code>가 음수가 된다. 토큰이 음수로 빠져서 rate limiter가 모든 요청을 무기한 차단한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> elapsed &lt; <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>    elapsed = <span style="color:#bd93f9">0</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>한 줄이면 된다. 시계가 뒤로 가면 토큰을 추가하지 않을 뿐, 차감하지도 않는다.</p>
<h3 id="3-data-api-sql-에러-노출">3. Data API SQL 에러 노출</h3>
<p>Data API에서 쿼리 실패 시 PostgreSQL 에러 메시지를 그대로 HTTP 응답에 넣고 있었다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#50fa7b">writeError</span>(w, http.StatusInternalServerError, err.<span style="color:#50fa7b">Error</span>())
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// → {&#34;error&#34;: &#34;ERROR: relation \&#34;users\&#34; does not exist (SQLSTATE 42P01)&#34;}</span>
</span></span></code></pre></div><p>테이블 이름, 스키마 구조, PostgreSQL 버전 정보가 외부에 노출된다. 제네릭 메시지로 교체:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#50fa7b">writeError</span>(w, http.StatusInternalServerError, <span style="color:#f1fa8c">&#34;query execution failed&#34;</span>)
</span></span></code></pre></div><h3 id="4-캐시-tableindex-스테일-엔트리">4. 캐시 tableIndex 스테일 엔트리</h3>
<p>캐시 <code>Set()</code>에서 기존 엔트리를 업데이트할 때, 이전 테이블 목록의 인덱스 참조를 제거하지 않았다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// 변경 전</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> e, ok <span style="color:#ff79c6">:=</span> c.items[key]; ok {
</span></span><span style="display:flex;"><span>    e.result = result
</span></span><span style="display:flex;"><span>    e.tables = tables  <span style="color:#6272a4">// 이전 tables의 tableIndex 참조가 남음</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>시나리오:</p>
<ol>
<li>캐시 엔트리가 <code>tables: [&quot;users&quot;]</code>로 저장됨 → <code>tableIndex[&quot;users&quot;]</code>에 키 등록</li>
<li>같은 쿼리가 <code>tables: [&quot;users&quot;, &quot;orders&quot;]</code>로 업데이트됨</li>
<li><code>tableIndex[&quot;users&quot;]</code>에 <strong>이전 참조가 남아있음</strong></li>
<li><code>users</code> 테이블 무효화 시, 이미 업데이트된 엔트리를 삭제 시도 — 키는 같으므로 문제없어 보이지만</li>
<li><strong>반대 케이스</strong>: <code>tables: [&quot;users&quot;, &quot;orders&quot;]</code> → <code>tables: [&quot;orders&quot;]</code>로 업데이트되면 <code>tableIndex[&quot;users&quot;]</code>에 stale 참조가 영구히 남음</li>
<li><code>users</code> 무효화 시 이 엔트리를 삭제하지만, 실제로는 <code>users</code>와 무관한 엔트리임 → <strong>과잉 무효화</strong></li>
</ol>
<p><code>removeTableIndex</code> 헬퍼를 추가해서 업데이트 전에 이전 참조를 정리한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (c <span style="color:#ff79c6">*</span>Cache) <span style="color:#50fa7b">removeTableIndex</span>(key <span style="color:#8be9fd">uint64</span>, tables []<span style="color:#8be9fd">string</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, table <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> tables {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> keys, ok <span style="color:#ff79c6">:=</span> c.tableIndex[table]; ok {
</span></span><span style="display:flex;"><span>            <span style="color:#8be9fd;font-style:italic">delete</span>(keys, key)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> <span style="color:#8be9fd;font-style:italic">len</span>(keys) <span style="color:#ff79c6">==</span> <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#8be9fd;font-style:italic">delete</span>(c.tableIndex, table)
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="medium-운영-안정성-5건">MEDIUM: 운영 안정성 5건</h2>
<h3 id="5-백엔드-메시지-크기-미검증">5. 백엔드 메시지 크기 미검증</h3>
<p><code>relayUntilReady</code>에서 백엔드 메시지의 payload 길이를 읽고 바로 <code>make([]byte, payloadLen)</code>을 호출한다. 악의적이거나 손상된 백엔드가 <code>payloadLen = 2GB</code>를 보내면 OOM이 발생한다.</p>
<p>프로토콜 레이어에 이미 <code>MaxMessageSize</code> 상수가 있다. <code>ReadMessage</code>에서는 체크하지만 <code>relayUntilReady</code>의 수동 읽기 경로에는 빠져있었다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> payloadLen &gt; protocol.MaxMessageSize {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;backend message too large: %d bytes (max %d)&#34;</span>,
</span></span><span style="display:flex;"><span>        payloadLen, protocol.MaxMessageSize)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="6-synthesizer-무한-증가">6. Synthesizer 무한 증가</h3>
<p>Prepared Statement Multiplexing의 <code>StatementStore</code>가 등록된 statement를 삭제하지 않으면 메모리가 계속 증가한다. <code>CloseStatement</code>가 있지만, 드라이버가 Close를 보내지 않거나 연결이 비정상 종료되면 누적된다.</p>
<p>10,000개 상한 + LRU 퇴거를 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">const</span> maxSynthStatements = <span style="color:#bd93f9">10000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>StatementStore) <span style="color:#50fa7b">RegisterStatement</span>(name <span style="color:#8be9fd">string</span>, <span style="color:#ff79c6">...</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> <span style="color:#8be9fd;font-style:italic">len</span>(s.statements) <span style="color:#ff79c6">&gt;=</span> maxSynthStatements {
</span></span><span style="display:flex;"><span>        oldest <span style="color:#ff79c6">:=</span> s.order[<span style="color:#bd93f9">0</span>]
</span></span><span style="display:flex;"><span>        s.order = s.order[<span style="color:#bd93f9">1</span>:]
</span></span><span style="display:flex;"><span>        <span style="color:#8be9fd;font-style:italic">delete</span>(s.statements, oldest)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    s.statements[name] = stmt
</span></span><span style="display:flex;"><span>    s.order = <span style="color:#8be9fd;font-style:italic">append</span>(s.order, name)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="7-config-watcher-블로킹-시작">7. Config watcher 블로킹 시작</h3>
<p><code>main.go</code>에서 <code>&lt;-fw.Ready()</code>를 무조건 기다린다. 파일이 존재하지 않거나 권한이 없으면 <code>Start()</code>가 <code>readyCh</code>를 닫지 않고 에러를 반환한다. 그런데 <code>Start()</code>는 별도 고루틴에서 실행되므로 메인 고루틴이 <code>&lt;-fw.Ready()</code>에서 영원히 블록된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">select</span> {
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">&lt;-</span>fw.<span style="color:#50fa7b">Ready</span>():
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">&lt;-</span>ctx.<span style="color:#50fa7b">Done</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> ctx.<span style="color:#50fa7b">Err</span>()
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">&lt;-</span>time.<span style="color:#50fa7b">After</span>(<span style="color:#bd93f9">5</span> <span style="color:#ff79c6">*</span> time.Second):
</span></span><span style="display:flex;"><span>    slog.<span style="color:#50fa7b">Warn</span>(<span style="color:#f1fa8c">&#34;config file watcher did not become ready within 5s, continuing&#34;</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="8-parsesize-무경고-0-반환">8. parseSize 무경고 0 반환</h3>
<p><code>parseSize(&quot;invalid&quot;)</code> → 0. 설정 파일에 <code>max_result_size: &quot;1mbb&quot;</code> 같은 오타가 있으면 캐시가 결과를 저장하지 않지만, 왜 캐시가 안 되는지 알 수 없다. <code>slog.Warn</code>으로 경고를 남기도록 수정.</p>
<h3 id="9-테스트에서-fmtprintln-사용">9. 테스트에서 fmt.Println 사용</h3>
<p><code>e2e_test.go</code>에서 <code>fmt.Println(&quot;proxy start/stop OK&quot;)</code> — <code>go test -v</code>가 아니면 출력 안 되고, 병렬 테스트 시 출력이 섞인다. <code>t.Log()</code>로 교체.</p>
<hr>
<h2 id="parser-일관성-stringast-파서-동기화-5건">Parser 일관성: String/AST 파서 동기화 5건</h2>
<p>QA 6차에서 AST parser에 MERGE, COPY, CALL, EXPLAIN ANALYZE를 추가했다. 이번에는 <strong>반대 방향</strong> — string parser에도 같은 수정이 필요한 부분과, 양쪽에 공통으로 빠진 부분을 수정한다.</p>
<h3 id="10-explain-analyze-write-감지-string-parser">10. EXPLAIN ANALYZE write 감지 (string parser)</h3>
<p>AST parser는 EXPLAIN ANALYZE + write subquery를 write로 분류하지만, string parser의 <code>classifyFast</code>는 EXPLAIN을 항상 read로 처리했다. <code>classifyFast</code>에서 EXPLAIN을 slow path로 넘기고, <code>isExplainAnalyzeWrite()</code> 함수를 추가.</p>
<h3 id="11-abort-트랜잭션-키워드">11. ABORT 트랜잭션 키워드</h3>
<p>PostgreSQL에서 <code>ABORT</code>는 <code>ROLLBACK</code>의 동의어다. <code>hasTxPrefix</code>, <code>updateTransactionState</code>, <code>containsTransactionKeyword</code> 세 곳에 추가.</p>
<h3 id="12-캐시-무효화-테이블-추출-갭">12. 캐시 무효화 테이블 추출 갭</h3>
<p><code>extractTablesFromStmt</code>가 MERGE, COPY, EXPLAIN의 대상 테이블을 추출하지 못했다. write는 writer로 올바르게 라우팅되지만, 캐시 무효화가 누락되어 stale read가 발생할 수 있다.</p>
<p>string parser에 <code>extractCopyTable()</code>, <code>extractExplainTables()</code> 추가. AST parser에도 <code>extractWriteTables</code>에 MergeStmt, CopyStmt, ExplainStmt, CallStmt case 추가.</p>
<h3 id="13-data-api-copy-차단">13. Data API COPY 차단</h3>
<p>Data API는 HTTP request/response 구조다. COPY 프로토콜은 스트리밍이므로 HTTP에서 지원할 수 없다. COPY를 실행하면 백엔드가 CopyIn/CopyOut 메시지를 보내는데, Data API의 결과 파싱 로직은 이를 처리하지 못해 연결이 꼬인다.</p>
<p>입구에서 차단:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>sqlUpper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(strings.<span style="color:#50fa7b">TrimSpace</span>(req.SQL))
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">HasPrefix</span>(sqlUpper, <span style="color:#f1fa8c">&#34;COPY &#34;</span>) <span style="color:#ff79c6">||</span> strings.<span style="color:#50fa7b">HasPrefix</span>(sqlUpper, <span style="color:#f1fa8c">&#34;COPY\t&#34;</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#50fa7b">writeError</span>(w, http.StatusBadRequest, <span style="color:#f1fa8c">&#34;COPY is not supported via Data API&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="14-set-constraints-false-positive">14. SET CONSTRAINTS false positive</h3>
<p><code>SET CONSTRAINTS ALL DEFERRED</code>는 트랜잭션 범위 명령이다. 그런데 session dependency detector가 <code>SET</code>으로 시작하는 모든 것을 잡아서 <code>FeatureSessionSet</code>으로 분류했다. <code>SET LOCAL</code>, <code>SET TRANSACTION</code>은 이미 제외되어 있었지만 <code>SET CONSTRAINTS</code>는 빠져있었다.</p>
<p><code>detectSingleStmtDependency</code>와 <code>isSessionModifying</code> 양쪽에 추가.</p>
<hr>
<h2 id="검증">검증</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>$ go build ./...     ✓
</span></span><span style="display:flex;"><span>$ go vet ./...       ✓
</span></span><span style="display:flex;"><span>$ go test ./internal/...
</span></span><span style="display:flex;"><span>ok  internal/admin       0.013s
</span></span><span style="display:flex;"><span>ok  internal/audit       1.015s
</span></span><span style="display:flex;"><span>ok  internal/cache       0.004s
</span></span><span style="display:flex;"><span>ok  internal/config      7.535s
</span></span><span style="display:flex;"><span>ok  internal/dataapi     0.012s
</span></span><span style="display:flex;"><span>ok  internal/digest      0.007s
</span></span><span style="display:flex;"><span>ok  internal/metrics     0.003s
</span></span><span style="display:flex;"><span>ok  internal/mirror      0.008s
</span></span><span style="display:flex;"><span>ok  internal/pool        3.013s
</span></span><span style="display:flex;"><span>ok  internal/protocol    0.003s
</span></span><span style="display:flex;"><span>ok  internal/proxy       0.004s
</span></span><span style="display:flex;"><span>ok  internal/resilience  0.103s
</span></span><span style="display:flex;"><span>ok  internal/router      0.009s
</span></span><span style="display:flex;"><span>ok  internal/telemetry   0.003s
</span></span><span style="display:flex;"><span>14/14 PASS
</span></span></code></pre></div><hr>
<h2 id="마무리">마무리</h2>
<p>14건을 심각도별로 정리하면:</p>
<ul>
<li><strong>HIGH 4건</strong>: 동시성 (CopyBoth race), 시계 (rate limiter), 보안 (SQL 에러 노출), 데이터 정합성 (캐시 인덱스)</li>
<li><strong>MEDIUM 5건</strong>: OOM 방어, 메모리 상한, 블로킹 방지, 경고 로그, 테스트 위생</li>
<li><strong>Parser 5건</strong>: string/AST 파서 간 분류 동기화</li>
</ul>
<p>HIGH 4건의 공통점은 &ldquo;정상 경로에서는 발생하지 않지만, 엣지 조건에서 조용히 깨진다&quot;는 것이다. CopyBoth는 Logical Replication을 쓸 때만, rate limiter clock skew는 NTP 보정 시에만, 캐시 인덱스는 같은 쿼리의 테이블 구성이 바뀔 때만 발생한다. 이런 버그는 테스트보다 코드 리뷰에서 잡히는 경우가 많다.</p>
<p>이것으로 릴리즈 전 코드 리뷰가 끝났다. 다음 글에서는 CHANGELOG 작성과 릴리즈 체크리스트를 정리한다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (46) - QA 소견 6건과 운영 안전성 수정</title><link>https://jyukki.com/posts/2026-03-14-pgmux-46-qa-findings-six-bugs/</link><pubDate>Sat, 14 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-14-pgmux-46-qa-findings-six-bugs/</guid><description>QA에서 올라온 6건의 소견 — Pool race, credential 미갱신, XFF spoofing, reader 격리 실패, 캐시 write-only, 설정 오류 — 을 분석하고 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p><a href="/posts/2026-03-14-pgmux-45-healthz-readyz-probes/">이전 글</a>에서 Health Check Endpoint를 구현했다. 이번에는 QA 리뷰에서 올라온 <strong>6건의 소견</strong>을 분석하고 수정한다.</p>
<p>이전에도 <a href="/posts/2026-03-11-pgmux-15-security-qa-hardening/">보안 QA (15편)</a>에서 비슷한 작업을 했지만, 이번 소견은 보안보다는 <strong>운영 안전성</strong>에 초점이 맞춰져 있다. 커넥션 풀 수명주기, 설정 리로드, 장애 격리 같은 프로덕션에서 시간이 지나야 드러나는 종류의 버그들이다.</p>
<hr>
<h2 id="소견-1-poolclose-이후-acquire-race-high">소견 1: Pool.Close() 이후 Acquire() race (High)</h2>
<h3 id="문제">문제</h3>
<p><code>Pool.Close()</code>가 <code>numOpen = 0</code>으로 리셋한 직후, 아직 for 루프를 돌고 있던 goroutine이 <code>Acquire()</code>를 통해 <strong>새 연결을 생성</strong>할 수 있었다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// pool.go — Close()</span>
</span></span><span style="display:flex;"><span>p.closed = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>p.numOpen = <span style="color:#bd93f9">0</span>  <span style="color:#6272a4">// ← 리셋</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// pool.go — Acquire() (다른 goroutine)</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">for</span> {
</span></span><span style="display:flex;"><span>    p.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ← p.closed 체크 없음!</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> p.numOpen &lt; p.cfg.MaxConnections {  <span style="color:#6272a4">// 0 &lt; 10 → true</span>
</span></span><span style="display:flex;"><span>        p.numOpen<span style="color:#ff79c6">++</span>
</span></span><span style="display:flex;"><span>        p.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>        conn, _ <span style="color:#ff79c6">:=</span> p.<span style="color:#50fa7b">newConn</span>()  <span style="color:#6272a4">// 닫힌 풀에 새 연결 생성</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> conn, <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>이후 <code>Release()</code>에서 <code>p.closed</code>를 보고 <code>numOpen--</code>를 수행하면 <strong>카운터가 음수</strong>가 된다.</p>
<h3 id="수정">수정</h3>
<p><code>Acquire()</code> for 루프의 Lock 직후에 <code>p.closed</code> 체크를 추가했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> ErrPoolClosed = fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;connection pool: closed&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (p <span style="color:#ff79c6">*</span>Pool) <span style="color:#50fa7b">Acquire</span>(ctx context.Context) (<span style="color:#ff79c6">*</span>Conn, <span style="color:#8be9fd">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> {
</span></span><span style="display:flex;"><span>        p.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> p.closed {
</span></span><span style="display:flex;"><span>            p.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">nil</span>, ErrPoolClosed
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// 기존 idle/newConn 로직...</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>단 3줄이지만, shutdown/reload 중 in-flight 요청이 stale 풀에서 연결을 생성하는 것을 완전히 차단한다.</p>
<hr>
<h2 id="소견-2-hot-reload-시-credential-미갱신-high">소견 2: Hot Reload 시 credential 미갱신 (High)</h2>
<h3 id="문제-1">문제</h3>
<p><code>DatabaseGroup.Reload()</code>에서:</p>
<ol>
<li><strong>Writer pool은 아예 재생성하지 않았다</strong> — 메서드에 writer 관련 코드가 없음</li>
<li><strong>동일 주소 reader pool은 그대로 재사용</strong> — <code>newPools[addr] = p</code></li>
</ol>
<p>두 경우 모두 <code>DialFunc</code> 클로저가 생성 시점의 credential을 캡처하고 있어, 비밀번호 rotation 후에도 <strong>구 자격증명으로 계속 연결</strong>했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// 생성 시점 — dbCfg.Backend.Password = &#34;old-password&#34;</span>
</span></span><span style="display:flex;"><span>wp, _ <span style="color:#ff79c6">:=</span> pool.<span style="color:#50fa7b">New</span>(pool.Config{
</span></span><span style="display:flex;"><span>    DialFunc: <span style="color:#8be9fd;font-style:italic">func</span>() (net.Conn, <span style="color:#8be9fd">error</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">pgConnect</span>(addr, dbCfg.Backend.User, dbCfg.Backend.Password, <span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// ↑ 클로저가 &#34;old-password&#34;를 영구적으로 잡고 있음</span>
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>})
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Reload 후 — 새 config에 &#34;new-password&#34;가 들어와도</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// 기존 pool의 DialFunc는 여전히 &#34;old-password&#34;로 연결</span>
</span></span></code></pre></div><p>재시작 전까지 영구적으로 stale 상태가 되는 심각한 버그다.</p>
<h3 id="수정-1">수정</h3>
<p><code>Reload()</code>에서 credential 변경을 감지하고, 변경 시 pool을 재생성하도록 수정했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (g <span style="color:#ff79c6">*</span>DatabaseGroup) <span style="color:#50fa7b">Reload</span>(dbCfg config.DatabaseConfig, cbCfg config.CircuitBreakerConfig) {
</span></span><span style="display:flex;"><span>    g.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">defer</span> g.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    oldCfg <span style="color:#ff79c6">:=</span> g.backendCfg
</span></span><span style="display:flex;"><span>    credChanged <span style="color:#ff79c6">:=</span> oldCfg.User <span style="color:#ff79c6">!=</span> dbCfg.Backend.User <span style="color:#ff79c6">||</span>
</span></span><span style="display:flex;"><span>        oldCfg.Password <span style="color:#ff79c6">!=</span> dbCfg.Backend.Password <span style="color:#ff79c6">||</span>
</span></span><span style="display:flex;"><span>        oldCfg.Database <span style="color:#ff79c6">!=</span> dbCfg.Backend.Database
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Writer pool 재생성 (credential 변경 또는 주소 변경 시)</span>
</span></span><span style="display:flex;"><span>    newWriterAddr <span style="color:#ff79c6">:=</span> fmt.<span style="color:#50fa7b">Sprintf</span>(<span style="color:#f1fa8c">&#34;%s:%d&#34;</span>, dbCfg.Writer.Host, dbCfg.Writer.Port)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> credChanged <span style="color:#ff79c6">||</span> newWriterAddr <span style="color:#ff79c6">!=</span> g.writerAddr {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> g.writerPool <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>            g.writerPool.<span style="color:#50fa7b">Close</span>()
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        g.writerPool = <span style="color:#50fa7b">createWriterPool</span>(newWriterAddr, dbCfg)
</span></span><span style="display:flex;"><span>        g.writerAddr = newWriterAddr
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Reader pools — credential 변경 시 전부 재생성</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> credChanged {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">for</span> _, p <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> g.readerPools {
</span></span><span style="display:flex;"><span>            p.<span style="color:#50fa7b">Close</span>()
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// 모든 reader pool을 새 credential로 생성</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>핵심은 <strong>credential 변경 감지</strong>와 <strong>구 pool의 graceful close</strong>다.</p>
<hr>
<h2 id="소견-3-admin-ip-allowlist-xff-spoofing-medium-high">소견 3: Admin IP Allowlist XFF Spoofing (Medium-High)</h2>
<h3 id="문제-2">문제</h3>
<p><code>extractClientIP()</code>가 <code>X-Forwarded-For</code> 헤더를 <strong>무조건 신뢰</strong>했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">extractClientIP</span>(r <span style="color:#ff79c6">*</span>http.Request) <span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> xff <span style="color:#ff79c6">:=</span> r.Header.<span style="color:#50fa7b">Get</span>(<span style="color:#f1fa8c">&#34;X-Forwarded-For&#34;</span>); xff <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#34;&#34;</span> {
</span></span><span style="display:flex;"><span>        parts <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">SplitN</span>(xff, <span style="color:#f1fa8c">&#34;,&#34;</span>, <span style="color:#bd93f9">2</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> strings.<span style="color:#50fa7b">TrimSpace</span>(parts[<span style="color:#bd93f9">0</span>])  <span style="color:#6272a4">// 클라이언트가 임의 설정 가능</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    host, _, _ <span style="color:#ff79c6">:=</span> net.<span style="color:#50fa7b">SplitHostPort</span>(r.RemoteAddr)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> host
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Admin API가 직접 노출된 환경에서 공격자가 <code>X-Forwarded-For: 10.0.0.1</code>만 넣으면 allowlist를 우회할 수 있었다.</p>
<h3 id="수정-2">수정</h3>
<p><code>trusted_proxies</code> 설정을 도입하고, <strong>신뢰할 수 있는 프록시에서 온 요청만</strong> XFF를 신뢰하도록 변경했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">extractClientIP</span>(r <span style="color:#ff79c6">*</span>http.Request, trustedProxies []<span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    host, _, _ <span style="color:#ff79c6">:=</span> net.<span style="color:#50fa7b">SplitHostPort</span>(r.RemoteAddr)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// trusted proxy에서 온 요청만 XFF 신뢰</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> <span style="color:#8be9fd;font-style:italic">len</span>(trustedProxies) &gt; <span style="color:#bd93f9">0</span> <span style="color:#ff79c6">&amp;&amp;</span> <span style="color:#50fa7b">isTrustedProxy</span>(host, trustedProxies) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> xff <span style="color:#ff79c6">:=</span> r.Header.<span style="color:#50fa7b">Get</span>(<span style="color:#f1fa8c">&#34;X-Forwarded-For&#34;</span>); xff <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#34;&#34;</span> {
</span></span><span style="display:flex;"><span>            parts <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">SplitN</span>(xff, <span style="color:#f1fa8c">&#34;,&#34;</span>, <span style="color:#bd93f9">2</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">return</span> strings.<span style="color:#50fa7b">TrimSpace</span>(parts[<span style="color:#bd93f9">0</span>])
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> host
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Secure default</strong>: <code>trusted_proxies</code>가 비어있으면 XFF를 절대 신뢰하지 않는다. 기존 배포 환경에서 리버스 프록시를 사용하는 경우에만 명시적으로 설정해야 한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">admin</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">auth</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">enabled</span>: <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">trusted_proxies</span>: [<span style="color:#f1fa8c">&#34;10.0.0.0/8&#34;</span>]  <span style="color:#6272a4"># 내부 LB 대역만 신뢰</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">ip_allowlist</span>: [<span style="color:#f1fa8c">&#34;192.168.1.0/24&#34;</span>]
</span></span></code></pre></div><hr>
<h2 id="소견-4-reader-장애-격리-미작동-medium">소견 4: Reader 장애 격리 미작동 (Medium)</h2>
<h3 id="문제-3">문제</h3>
<p><code>balancer.MarkUnhealthy()</code>가 정의되어 있지만, <strong>운영 코드에서 호출하는 곳이 없었다</strong>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// balancer.go — 정의만 존재</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (r <span style="color:#ff79c6">*</span>RoundRobin) <span style="color:#50fa7b">MarkUnhealthy</span>(addr <span style="color:#8be9fd">string</span>) { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// query_read.go — 에러 시 CB만 기록, MarkUnhealthy 미호출</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> cb, ok <span style="color:#ff79c6">:=</span> dbg.<span style="color:#50fa7b">ReaderCB</span>(readerAddr); ok {
</span></span><span style="display:flex;"><span>        cb.<span style="color:#50fa7b">RecordFailure</span>()  <span style="color:#6272a4">// CB가 disabled면 아무 효과 없음</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> s.<span style="color:#50fa7b">fallbackToWriter</span>(<span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>circuit_breaker.enabled</code> 기본값이 <code>false</code>이므로, 죽은 reader가 rotation에 <strong>영구적으로 남아</strong> 매 요청마다 fallback penalty를 냈다.</p>
<h3 id="수정-3">수정</h3>
<p>reader 에러 경로 3개 파일에 <code>MarkUnhealthy()</code> 호출을 추가했다:</p>
<ul>
<li><strong><code>query_read.go</code></strong>: Acquire 실패, Forward 실패, relay 실패 (4곳)</li>
<li><strong><code>query_extended.go</code></strong>: 동일 패턴 (7곳)</li>
<li><strong><code>dataapi/handler.go</code></strong>: executeOnPool 실패 (1곳)</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query_read.go — 수정 후</span>
</span></span><span style="display:flex;"><span>rConn, err <span style="color:#ff79c6">:=</span> rPool.<span style="color:#50fa7b">Acquire</span>(poolCtx)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    dbg.balancer.<span style="color:#50fa7b">MarkUnhealthy</span>(readerAddr)  <span style="color:#6272a4">// ← 추가</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> cb, ok <span style="color:#ff79c6">:=</span> dbg.<span style="color:#50fa7b">ReaderCB</span>(readerAddr); ok {
</span></span><span style="display:flex;"><span>        cb.<span style="color:#50fa7b">RecordFailure</span>()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> s.<span style="color:#50fa7b">fallbackToWriter</span>(<span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>MarkUnhealthy</code>는 CB와 독립적으로 동작한다. CB가 비활성이어도 balancer의 health check 루프가 주기적으로 TCP probe를 보내 복구하므로, reader가 살아나면 자동으로 rotation에 복귀한다.</p>
<hr>
<h2 id="소견-5-extended-protocol-캐시-write-only-medium">소견 5: Extended Protocol 캐시 write-only (Medium)</h2>
<h3 id="문제-4">문제</h3>
<p>Extended Query Protocol 경로에서 캐시를 <strong>저장은 하지만 조회는 하지 않았다</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query_extended.go — Set은 있지만</span>
</span></span><span style="display:flex;"><span>key <span style="color:#ff79c6">:=</span> cache.<span style="color:#50fa7b">WithNamespace</span>(s.<span style="color:#50fa7b">cacheKey</span>(query, dbg.name), cache.NSExtended)
</span></span><span style="display:flex;"><span>s.queryCache.<span style="color:#50fa7b">Set</span>(key, collected, tables)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Get은 없다! handleExtendedRead() 시작부에 캐시 조회 로직이 누락</span>
</span></span></code></pre></div><p>반면 Simple Query 경로(<code>query_read.go</code>)에는 제대로 된 Get → hit → early return이 있었다. Extended 캐시 엔트리는 <strong>LRU 공간만 차지하고 hit는 절대 안 나는</strong> 상태였다.</p>
<h3 id="수정-4">수정</h3>
<p><code>handleExtendedRead()</code> 시작부에 캐시 조회 로직을 추가했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">handleExtendedRead</span>(ctx context.Context, clientConn net.Conn,
</span></span><span style="display:flex;"><span>    buf []<span style="color:#ff79c6">*</span>protocol.Message, syncMsg <span style="color:#ff79c6">*</span>protocol.Message, <span style="color:#ff79c6">...</span>) <span style="color:#8be9fd">error</span> {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 캐시 조회 (추가)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.queryCache <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> <span style="color:#ff79c6">&amp;&amp;</span> <span style="color:#8be9fd;font-style:italic">len</span>(buf) &gt; <span style="color:#bd93f9">0</span> <span style="color:#ff79c6">&amp;&amp;</span> buf[<span style="color:#bd93f9">0</span>].Type <span style="color:#ff79c6">==</span> protocol.MsgParse {
</span></span><span style="display:flex;"><span>        _, query <span style="color:#ff79c6">:=</span> protocol.<span style="color:#50fa7b">ParseParseMessage</span>(buf[<span style="color:#bd93f9">0</span>].Payload)
</span></span><span style="display:flex;"><span>        key <span style="color:#ff79c6">:=</span> cache.<span style="color:#50fa7b">WithNamespace</span>(s.<span style="color:#50fa7b">cacheKey</span>(query, dbg.name), cache.NSExtended)
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> cached <span style="color:#ff79c6">:=</span> s.queryCache.<span style="color:#50fa7b">Get</span>(key); cached <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> s.metrics <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>                s.metrics.CacheHits.<span style="color:#50fa7b">Inc</span>()
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            _, err <span style="color:#ff79c6">:=</span> clientConn.<span style="color:#50fa7b">Write</span>(cached)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">return</span> err
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> s.metrics <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>            s.metrics.CacheMisses.<span style="color:#50fa7b">Inc</span>()
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 기존 backend 실행 로직...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>relayAndCollect</code>로 저장한 바이트에는 ReadyForQuery까지 포함되어 있으므로, 그대로 클라이언트에 Write하면 된다.</p>
<hr>
<h2 id="소견-6-sample_ratio-0-설정-불가-medium-low">소견 6: sample_ratio: 0 설정 불가 (Medium-Low)</h2>
<h3 id="문제-5">문제</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// config.go — applyDefaults()</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> c.Telemetry.SampleRatio <span style="color:#ff79c6">==</span> <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>    c.Telemetry.SampleRatio = <span style="color:#bd93f9">1.0</span>  <span style="color:#6272a4">// &#34;미설정&#34;으로 취급해 덮어씀</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// telemetry.go — 이 분기에 도달 불가</span>
</span></span><span style="display:flex;"><span>} <span style="color:#ff79c6">else</span> <span style="color:#ff79c6">if</span> cfg.SampleRatio <span style="color:#ff79c6">&lt;=</span> <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>    sampler = sdktrace.<span style="color:#50fa7b">NeverSample</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>YAML에서 <code>sample_ratio: 0</code>을 설정해도 <code>float64(0)</code>이 Go 제로값과 동일해 &ldquo;미설정&quot;으로 처리됐다. 프로덕션에서 트레이싱을 끄려면 <code>enabled: false</code> 대신 <strong>sample_ratio: 0으로 수집만 안 하는</strong> 전략을 쓸 수 있어야 한다.</p>
<h3 id="수정-5">수정</h3>
<p><code>SampleRatio</code>를 <code>*float64</code> 포인터로 변경해 nil(미설정)과 0(명시적)을 구분했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> TelemetryConfig <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    Enabled     <span style="color:#8be9fd">bool</span>     <span style="color:#f1fa8c">`yaml:&#34;enabled&#34;`</span>
</span></span><span style="display:flex;"><span>    SampleRatio <span style="color:#ff79c6">*</span><span style="color:#8be9fd">float64</span> <span style="color:#f1fa8c">`yaml:&#34;sample_ratio&#34;`</span>  <span style="color:#6272a4">// nil = 미설정, 0 = NeverSample</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// applyDefaults()</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> c.Telemetry.SampleRatio <span style="color:#ff79c6">==</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    defaultRatio <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">1.0</span>
</span></span><span style="display:flex;"><span>    c.Telemetry.SampleRatio = <span style="color:#ff79c6">&amp;</span>defaultRatio
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Go의 <code>== 0</code> 제로값 패턴은 편리하지만, <strong>&ldquo;미설정&quot;과 &ldquo;명시적 0&quot;을 구분해야 하는 경우</strong>에는 포인터나 별도 <code>isSet</code> 플래그가 필요하다. YAML/JSON 설정에서 흔히 만나는 함정이다.</p>
<hr>
<h2 id="교훈">교훈</h2>
<p>이번 QA 소견에서 공통적으로 드러난 패턴:</p>
<ol>
<li><strong>클로저 캡처의 함정</strong> — <code>DialFunc</code>가 생성 시점의 값을 영구적으로 잡고 있어 hot reload가 무력화</li>
<li><strong>상태 전이 누락</strong> — <code>Close()</code> 후 <code>Acquire()</code>, healthy → unhealthy 전이 경로가 빠져있음</li>
<li><strong>Secure default 부재</strong> — XFF를 무조건 신뢰하는 것은 &ldquo;편의 기본값&quot;이지 &ldquo;안전 기본값&quot;이 아님</li>
<li><strong>대칭성 검증</strong> — 캐시 Set이 있으면 반드시 Get이 있어야 한다. write path만 있는 캐시는 메모리 낭비</li>
<li><strong>Go 제로값 함정</strong> — <code>float64(0)</code>과 &ldquo;미설정&quot;은 다른 의미. 포인터 타입으로 구분 필요</li>
</ol>
<p>이런 류의 버그는 단위 테스트로 잡기 어렵다. <strong>코드 리뷰에서 &ldquo;이 경로의 반대편은?&ldquo;이라고 묻는 습관</strong>이 가장 효과적인 방어선이다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글에서 수정한 것:</p>
<ul>
<li>Pool.Close() 이후 Acquire() race 방어 (<code>ErrPoolClosed</code>)</li>
<li>Hot reload 시 credential 변경 감지 및 pool 재생성</li>
<li>Admin IP allowlist의 XFF spoofing 방어 (<code>trusted_proxies</code>)</li>
<li>Reader 장애 시 <code>MarkUnhealthy()</code> 호출로 즉시 격리</li>
<li>Extended protocol 캐시에 read path 추가</li>
<li><code>sample_ratio: 0</code> 설정이 NeverSample로 정상 동작</li>
</ul>
<p>6건 모두 <strong>&ldquo;동작은 하지만 edge case에서 깨지는&rdquo;</strong> 유형이다. 프로덕션에서 시간이 지나야 드러나는 종류라 QA 단계에서 잡은 것은 다행이다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (47) - QA 2차: Cross-Pool 오염과 캐시 정확성</title><link>https://jyukki.com/posts/2026-03-14-pgmux-47-qa-round2-five-bugs/</link><pubDate>Sat, 14 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-14-pgmux-47-qa-round2-five-bugs/</guid><description>QA 2차 소견 5건 — boundWriter cross-pool 오염, extended cache 파라미터 무시, Pool.Close outstanding borrow, writer CB reload 누락, watcher startup race — 을 분석하고 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p><a href="/posts/2026-03-14-pgmux-46-qa-findings-six-bugs/">이전 글</a>에서 QA 1차 소견 6건을 수정했다. 곧바로 2차 리뷰가 올라왔는데, 이번에는 <strong>1차 수정이 만든 새로운 문제</strong>와 <strong>1차에서 덜 파고든 영역</strong>이 섞여 있다. 특히 #1과 #2는 데이터 정확성에 직결되는 심각한 버그다.</p>
<hr>
<h2 id="소견-1-boundwriter-cross-pool-반환-high">소견 1: boundWriter cross-pool 반환 (High)</h2>
<h3 id="문제">문제</h3>
<p><code>relayQueries()</code>에서 <code>boundWriter</code>는 트랜잭션 동안 writer 연결을 잡고 있다. 반환 시 <code>dbg.writerPool</code>을 참조하는데, Reload가 <code>dbg.writerPool</code>을 교체하면 <strong>old pool에서 빌린 conn이 new pool로 반환</strong>된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query.go — COMMIT 시 반환</span>
</span></span><span style="display:flex;"><span>boundWriter = <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>dbg.writerPool.<span style="color:#50fa7b">Release</span>(wConn)  <span style="color:#6272a4">// ← reload 후 dbg.writerPool은 새 pool</span>
</span></span></code></pre></div><p>이로 인해:</p>
<ol>
<li><strong>new pool에 old backend 소켓이 섞임</strong> — writer 주소가 바뀌었으면 잘못된 서버로 쿼리 전송</li>
<li><strong>numOpen 불일치</strong> — new pool은 이 conn을 Acquire한 적이 없으므로 카운터 맞지 않음</li>
<li><strong>MaxConnections 초과</strong> — new pool의 numOpen에 반영 안 된 유령 conn이 idle에 추가</li>
</ol>
<h3 id="수정">수정</h3>
<p>conn을 Acquire한 시점의 pool 참조를 함께 캡처하는 방식으로 수정했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> boundWriter <span style="color:#ff79c6">*</span>pool.Conn
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> boundWriterPool <span style="color:#ff79c6">*</span>pool.Pool  <span style="color:#6272a4">// ← Acquire 시점의 pool 참조</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Acquire 시점</span>
</span></span><span style="display:flex;"><span>acquiredPool <span style="color:#ff79c6">:=</span> dbg.writerPool  <span style="color:#6272a4">// 현재 pool 캡처</span>
</span></span><span style="display:flex;"><span>wConn, acquired, err <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">acquireWriterConn</span>(ctx, boundWriter, dbg)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// BEGIN — bind</span>
</span></span><span style="display:flex;"><span>boundWriter = wConn
</span></span><span style="display:flex;"><span>boundWriterPool = acquiredPool
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// COMMIT — release (항상 원본 pool로)</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">resetAndReleaseToPool</span>(wConn, boundWriterPool)
</span></span></code></pre></div><p><code>backend.go</code>에 pool-explicit 헬퍼를 추가했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">resetAndReleaseToPool</span>(conn <span style="color:#ff79c6">*</span>pool.Conn, p <span style="color:#ff79c6">*</span>pool.Pool) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">resetConn</span>(conn); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        p.<span style="color:#50fa7b">Discard</span>(conn)
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    p.<span style="color:#50fa7b">Release</span>(conn)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>핵심 원칙: <strong>모든 Release/Discard는 Acquire한 pool로 돌아가야 한다</strong>.</p>
<hr>
<h2 id="소견-2-extended-cache-키가-bind-파라미터-무시-high">소견 2: Extended cache 키가 Bind 파라미터 무시 (High)</h2>
<h3 id="문제-1">문제</h3>
<p>이전 수정(P46 소견 5)에서 extended query에 캐시 조회를 추가했는데, 캐시 키가 <strong>Parse 메시지의 SQL 텍스트만</strong> 사용했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>_, query <span style="color:#ff79c6">:=</span> protocol.<span style="color:#50fa7b">ParseParseMessage</span>(buf[<span style="color:#bd93f9">0</span>].Payload)
</span></span><span style="display:flex;"><span>key <span style="color:#ff79c6">:=</span> cache.<span style="color:#50fa7b">WithNamespace</span>(s.<span style="color:#50fa7b">cacheKey</span>(query, dbg.name), cache.NSExtended)
</span></span></code></pre></div><p><code>SELECT * FROM users WHERE id = $1</code> 같은 prepared statement에서:</p>
<ul>
<li><code>$1 = 1</code>로 실행 → 캐시 저장</li>
<li><code>$1 = 2</code>로 실행 → 캐시 hit → <strong>$1 = 1의 결과 반환</strong></li>
</ul>
<p>이건 <strong>데이터 정확성 버그</strong>다. 캐시 최적화가 오히려 잘못된 데이터를 돌려주게 된 것.</p>
<h3 id="수정-1">수정</h3>
<p>파라미터가 있는 prepared statement는 <strong>캐시하지 않는</strong> 것이 가장 안전하다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">hasParameterPlaceholders</span>(buf []<span style="color:#ff79c6">*</span>protocol.Message) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, m <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> buf {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> m.Type <span style="color:#ff79c6">==</span> protocol.MsgParse {
</span></span><span style="display:flex;"><span>            _, query <span style="color:#ff79c6">:=</span> protocol.<span style="color:#50fa7b">ParseParseMessage</span>(m.Payload)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">for</span> i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>; i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query)<span style="color:#ff79c6">-</span><span style="color:#bd93f9">1</span>; i<span style="color:#ff79c6">++</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">if</span> query[i] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;$&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">&gt;=</span> <span style="color:#f1fa8c">&#39;1&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">&lt;=</span> <span style="color:#f1fa8c">&#39;9&#39;</span> {
</span></span><span style="display:flex;"><span>                    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">false</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>캐시 read/write 양쪽에 가드를 추가했다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// Cache lookup</span>
</span></span><span style="display:flex;"><span>canCache <span style="color:#ff79c6">:=</span> !<span style="color:#50fa7b">hasParameterPlaceholders</span>(buf)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> s.queryCache <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> <span style="color:#ff79c6">&amp;&amp;</span> canCache <span style="color:#ff79c6">&amp;&amp;</span> <span style="color:#8be9fd;font-style:italic">len</span>(buf) &gt; <span style="color:#bd93f9">0</span> <span style="color:#ff79c6">&amp;&amp;</span> buf[<span style="color:#bd93f9">0</span>].Type <span style="color:#ff79c6">==</span> protocol.MsgParse {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... cache Get ...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Cache store</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> canCache <span style="color:#ff79c6">&amp;&amp;</span> collected <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> <span style="color:#ff79c6">&amp;&amp;</span> <span style="color:#ff79c6">...</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... cache Set ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>파라미터 없는 리터럴 쿼리(<code>SELECT 1</code>, <code>SELECT now()</code> 등)만 캐시되고, 파라미터화된 쿼리는 항상 backend로 전달된다. 캐시 키에 Bind 파라미터 해시를 포함하는 방안도 있지만, 복잡도 대비 이득이 적어 단순한 접근을 택했다.</p>
<hr>
<h2 id="소견-3-poolclose-outstanding-borrow-무시-medium">소견 3: Pool.Close() outstanding borrow 무시 (Medium)</h2>
<h3 id="문제-2">문제</h3>
<p>P46 소견 1에서 <code>Acquire()</code>에 <code>p.closed</code> 체크를 추가했지만, <code>numOpen++</code> 후 unlock → <code>newConn()</code> 사이에 <code>Close()</code>가 끼어드는 창은 여전히 열려 있었다.</p>
<p>핵심은 <code>Close()</code>의 <code>p.numOpen = 0</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (p <span style="color:#ff79c6">*</span>Pool) <span style="color:#50fa7b">Close</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    p.numOpen = <span style="color:#bd93f9">0</span>  <span style="color:#6272a4">// ← outstanding borrow(5개 중 3개 idle, 2개 borrowed)를 무시</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>이후 borrowed conn이 Release되면 <code>numOpen--</code> → <strong>음수</strong>.</p>
<h3 id="수정-2">수정</h3>
<p>idle conn 수만 차감하도록 변경:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (p <span style="color:#ff79c6">*</span>Pool) <span style="color:#50fa7b">Close</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    idleCount <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">len</span>(p.idle)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, conn <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> p.idle {
</span></span><span style="display:flex;"><span>        conn.<span style="color:#50fa7b">Close</span>()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    p.idle = <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>    p.numOpen <span style="color:#ff79c6">-=</span> idleCount  <span style="color:#6272a4">// outstanding borrow는 보존</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>추가로 <code>Release()</code>와 <code>Discard()</code>에 음수 방어 가드:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (p <span style="color:#ff79c6">*</span>Pool) <span style="color:#50fa7b">Discard</span>(conn <span style="color:#ff79c6">*</span>Conn) {
</span></span><span style="display:flex;"><span>    conn.<span style="color:#50fa7b">Close</span>()
</span></span><span style="display:flex;"><span>    p.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> p.numOpen &gt; <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>        p.numOpen<span style="color:#ff79c6">--</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    p.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="소견-4-writer-circuit-breaker-reload-누락-medium">소견 4: Writer Circuit Breaker reload 누락 (Medium)</h2>
<h3 id="문제-3">문제</h3>
<p><code>Reload()</code>에서 readerCBs만 갱신하고 writerCB는 건드리지 않았다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> cbCfg.Enabled {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// reader CBs만 갱신</span>
</span></span><span style="display:flex;"><span>    newCBs <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>(<span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]<span style="color:#ff79c6">*</span>resilience.CircuitBreaker)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, addr <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> newReaderAddrs { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span>    g.readerCBs = newCBs
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// writerCB → 미갱신!</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// CB disabled 시 clear하는 else 분기도 없음</span>
</span></span></code></pre></div><h3 id="수정-3">수정</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> cbCfg.Enabled {
</span></span><span style="display:flex;"><span>    brCfg <span style="color:#ff79c6">:=</span> resilience.BreakerConfig{<span style="color:#ff79c6">...</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Writer CB — 없으면 생성, 있으면 상태 보존을 위해 유지</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> g.writerCB <span style="color:#ff79c6">==</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        g.writerCB = resilience.<span style="color:#50fa7b">NewCircuitBreaker</span>(brCfg)
</span></span><span style="display:flex;"><span>        slog.<span style="color:#50fa7b">Info</span>(<span style="color:#f1fa8c">&#34;reload: writer circuit breaker enabled&#34;</span>, <span style="color:#f1fa8c">&#34;db&#34;</span>, g.name)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Reader CBs (기존 코드)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>} <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// CB 비활성 — 양쪽 모두 정리</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> g.writerCB <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        g.writerCB = <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>        slog.<span style="color:#50fa7b">Info</span>(<span style="color:#f1fa8c">&#34;reload: writer circuit breaker disabled&#34;</span>, <span style="color:#f1fa8c">&#34;db&#34;</span>, g.name)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    g.readerCBs = <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="소견-5-config-watcher-startup-race-medium-low">소견 5: Config Watcher startup race (Medium-Low)</h2>
<h3 id="문제-4">문제</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// main.go</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    fw.<span style="color:#50fa7b">Start</span>(ctx)  <span style="color:#6272a4">// ← watcher.Add(dir)이 여기서 실행</span>
</span></span><span style="display:flex;"><span>}()
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ← 여기로 바로 진행. watcher가 arm됐는지 보장 없음</span>
</span></span></code></pre></div><p><code>Start()</code> 안의 <code>watcher.Add(dir)</code>이 완료되기 전에 ConfigMap swap이 발생하면 이벤트를 놓친다.</p>
<h3 id="수정-4">수정</h3>
<p><code>FileWatcher</code>에 ready 채널을 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> FileWatcher <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    readyCh <span style="color:#8be9fd;font-style:italic">chan</span> <span style="color:#8be9fd;font-style:italic">struct</span>{}
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (fw <span style="color:#ff79c6">*</span>FileWatcher) <span style="color:#50fa7b">Start</span>(ctx context.Context) <span style="color:#8be9fd">error</span> {
</span></span><span style="display:flex;"><span>    dir <span style="color:#ff79c6">:=</span> filepath.<span style="color:#50fa7b">Dir</span>(fw.path)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> fw.watcher.<span style="color:#50fa7b">Add</span>(dir); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> err
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">close</span>(fw.readyCh)  <span style="color:#6272a4">// ← watch가 arm된 후 시그널</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// event loop...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (fw <span style="color:#ff79c6">*</span>FileWatcher) <span style="color:#50fa7b">Ready</span>() <span style="color:#ff79c6">&lt;-</span><span style="color:#8be9fd;font-style:italic">chan</span> <span style="color:#8be9fd;font-style:italic">struct</span>{} {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fw.readyCh
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// main.go</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    fw.<span style="color:#50fa7b">Start</span>(ctx)
</span></span><span style="display:flex;"><span>}()
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">&lt;-</span>fw.<span style="color:#50fa7b">Ready</span>()  <span style="color:#6272a4">// ← watch가 arm될 때까지 대기</span>
</span></span></code></pre></div><p>race window를 <strong>제로</strong>로 만든다. 단 <code>Start()</code>가 에러를 반환하면 <code>readyCh</code>가 닫히지 않아 deadlock이 될 수 있으므로, 에러 시에도 close하거나 타임아웃을 두는 것이 안전하다.</p>
<hr>
<h2 id="교훈">교훈</h2>
<p>이번 라운드에서 드러난 패턴:</p>
<ol>
<li><strong>수정이 만든 새 버그</strong> — P46 소견 5에서 extended cache read path를 추가했는데, 파라미터를 고려하지 않아 정확성 버그를 만들었다. 기능 추가 시 <strong>입력 공간의 전체 범위</strong>를 검토해야 한다.</li>
<li><strong>참조 갱신의 함정</strong> — <code>dbg.writerPool</code>처럼 중간에 바뀔 수 있는 포인터를 통한 접근은 hot-reload 환경에서 위험하다. <strong>Acquire 시점에 스냅샷</strong>을 뜨는 것이 안전하다.</li>
<li><strong>카운터 불변량</strong> — <code>numOpen = 0</code>처럼 절대값으로 리셋하면 outstanding 작업과의 불변량이 깨진다. <strong>delta로 조정</strong>하는 것이 원칙.</li>
<li><strong>대칭성 누락</strong> — reader CB는 reload 대상인데 writer CB는 아닌 것은 단순한 누락. <strong>한 쪽을 구현하면 반대쪽도 확인</strong>하는 습관이 필요하다.</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글에서 수정한 것:</p>
<ul>
<li>boundWriter origin pool 추적으로 cross-pool 오염 방지</li>
<li>파라미터화된 prepared statement의 extended cache 비활성화</li>
<li>Pool.Close()에서 outstanding borrow 카운터 보존</li>
<li>Writer circuit breaker reload 대상에 추가</li>
<li>FileWatcher에 ready 시그널로 startup race 제거</li>
</ul>
<p>2차 QA에서 특히 주목할 점은, <strong>1차 수정(P46 소견 5)이 새로운 정확성 버그를 만들었다</strong>는 것이다. 캐시처럼 투명해야 하는 레이어는 &ldquo;있으면 좋고 없어도 동작&quot;해야 하는데, 잘못 구현하면 <strong>없는 것보다 나쁜</strong> 상태가 된다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (48) - QA 3차: 풀 안전성의 마지막 구멍들</title><link>https://jyukki.com/posts/2026-03-14-pgmux-48-qa-round3-pool-safety/</link><pubDate>Sat, 14 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-14-pgmux-48-qa-round3-pool-safety/</guid><description>QA 3차 소견 6건 — fallback 경로 cross-pool 오염, extended cache 포맷 충돌, 깨진 연결 Release, Pool.Acquire close race, boundWriter discard 누락, circuit breaker 일관성 — 을 분석하고 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p><a href="/posts/2026-03-14-pgmux-47-qa-round2-five-bugs/">이전 글</a>에서 QA 2차 소견 5건을 수정했다. 3차에서는 <strong>2차 수정이 덜 커버한 경로</strong>와 <strong>기존에 아예 누락된 안전장치</strong>가 발견됐다. 6건 중 2건이 High — 모두 hot-reload 시나리오에서 데이터 정확성이나 연결 안전성이 깨지는 문제다.</p>
<hr>
<h2 id="소견-1-fallback-경로-writer-pool-소유권-추적-누락-high">소견 1: Fallback 경로 writer pool 소유권 추적 누락 (High)</h2>
<h3 id="문제">문제</h3>
<p>P47에서 <code>boundWriterPool</code> 추적을 추가했지만, <strong>simple-query 트랜잭션 경로</strong>에만 적용했다. reader fallback, extended fallback, synthesized fallback, multiplex describe 경로는 여전히 <code>dbg.writerPool</code>에서 직접 Acquire/Release한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// handleExtendedRead — fallbackToWriter</span>
</span></span><span style="display:flex;"><span>wConn, err <span style="color:#ff79c6">:=</span> dbg.writerPool.<span style="color:#50fa7b">Acquire</span>(ctx)  <span style="color:#6272a4">// ← reload 후 다른 pool</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>dbg.writerPool.<span style="color:#50fa7b">Discard</span>(wConn)              <span style="color:#6272a4">// ← 또 다른 pool일 수 있음</span>
</span></span></code></pre></div><p>영향받는 경로 4곳:</p>
<ol>
<li><code>fallbackToWriter</code> (backend.go) — simple-query reader fallback</li>
<li><code>handleExtendedRead.fallbackToWriter</code> — extended query reader fallback</li>
<li><code>handleSynthesizedRead.fallbackToWriter</code> — multiplex mode reader fallback</li>
<li><code>handleMultiplexDescribe</code> — Describe 메시지 처리</li>
</ol>
<h3 id="수정">수정</h3>
<p>모든 경로에서 <strong>Acquire 전에 pool 참조를 캡처</strong>하고, 해당 참조로 Release/Discard:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>fallbackToWriter <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">func</span>() <span style="color:#8be9fd">error</span> {
</span></span><span style="display:flex;"><span>    wPool <span style="color:#ff79c6">:=</span> dbg.writerPool <span style="color:#6272a4">// capture before acquire</span>
</span></span><span style="display:flex;"><span>    wConn, err <span style="color:#ff79c6">:=</span> wPool.<span style="color:#50fa7b">Acquire</span>(ctx)
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    wPool.<span style="color:#50fa7b">Discard</span>(wConn)   <span style="color:#6272a4">// 원본 pool로 반환</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    s.<span style="color:#50fa7b">resetAndReleaseToPool</span>(wConn, wPool)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>P47에서 도입한 <code>resetAndReleaseToPool</code>/<code>releaseToPool</code>이 이번에도 그대로 사용된다. 이로써 <code>resetAndReleaseWriter</code>/<code>releaseWriterFast</code>(dbg.writerPool 직접 참조)는 호출처가 전무해져 삭제했다.</p>
<p><strong>원칙: 프록시에서 pool을 직접 참조하는 코드는 0이어야 한다.</strong> 모든 Release/Discard는 Acquire 시점의 스냅샷으로 간다.</p>
<hr>
<h2 id="소견-2-extended-cache-키가-result-format과-partial-fetch-무시-high">소견 2: Extended cache 키가 result format과 partial fetch 무시 (High)</h2>
<h3 id="문제-1">문제</h3>
<p>P47에서 <code>$N</code> 파라미터가 있는 쿼리를 캐시에서 제외했다. 하지만 파라미터가 없는 prepared statement도 <strong>Bind의 result format codes</strong>와 <strong>Execute의 maxRows</strong>에 따라 다른 응답을 만든다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>Client A: Parse(&#34;SELECT 1&#34;) → Bind(resultFormat=text)  → Execute(maxRows=0)
</span></span><span style="display:flex;"><span>Client B: Parse(&#34;SELECT 1&#34;) → Bind(resultFormat=binary) → Execute(maxRows=0)
</span></span></code></pre></div><p>같은 SQL이지만 A는 text <code>'1'</code>, B는 binary <code>\x00\x00\x00\x01</code>을 기대한다. 캐시 키가 SQL 텍스트만 사용하므로 A의 응답이 B에게 반환될 수 있다.</p>
<p>partial fetch(maxRows≠0)도 마찬가지다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>Execute(maxRows=1)  → DataRow 1건 + PortalSuspended
</span></span><span style="display:flex;"><span>Execute(maxRows=0)  → DataRow 전체 + CommandComplete
</span></span></code></pre></div><h3 id="수정-1">수정</h3>
<p>캐시 키를 복잡하게 만드는 대신, <strong>non-default 설정을 사용하는 배치는 캐싱에서 제외</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">hasBinaryFormatOrPartialFetch</span>(buf []<span style="color:#ff79c6">*</span>protocol.Message) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, m <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> buf {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">switch</span> m.Type {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> protocol.MsgBind:
</span></span><span style="display:flex;"><span>            detail, err <span style="color:#ff79c6">:=</span> protocol.<span style="color:#50fa7b">ParseBindMessageFull</span>(m.Payload)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span> <span style="color:#6272a4">// parse 실패 → 안전하게 캐시 제외</span>
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">for</span> _, fc <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> detail.ResultFormatCodes {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">if</span> fc <span style="color:#ff79c6">!=</span> <span style="color:#bd93f9">0</span> { <span style="color:#6272a4">// 0 = text, 1 = binary</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> protocol.MsgExecute:
</span></span><span style="display:flex;"><span>            <span style="color:#6272a4">// Execute: portal_name\0 + int32(maxRows)</span>
</span></span><span style="display:flex;"><span>            idx <span style="color:#ff79c6">:=</span> bytes.<span style="color:#50fa7b">IndexByte</span>(m.Payload, <span style="color:#bd93f9">0</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> idx <span style="color:#ff79c6">&gt;=</span> <span style="color:#bd93f9">0</span> <span style="color:#ff79c6">&amp;&amp;</span> idx<span style="color:#ff79c6">+</span><span style="color:#bd93f9">5</span> <span style="color:#ff79c6">&lt;=</span> <span style="color:#8be9fd;font-style:italic">len</span>(m.Payload) {
</span></span><span style="display:flex;"><span>                maxRows <span style="color:#ff79c6">:=</span> binary.BigEndian.<span style="color:#50fa7b">Uint32</span>(m.Payload[idx<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> : idx<span style="color:#ff79c6">+</span><span style="color:#bd93f9">5</span>])
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">if</span> maxRows <span style="color:#ff79c6">!=</span> <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>                    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">false</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>캐싱 조건이 이제 3중 가드다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>cacheable <span style="color:#ff79c6">:=</span> !<span style="color:#50fa7b">hasParameterPlaceholders</span>(buf) <span style="color:#ff79c6">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>             !<span style="color:#50fa7b">hasBinaryFormatOrPartialFetch</span>(buf)
</span></span></code></pre></div><ol>
<li><code>$N</code> 파라미터 없음 (P47)</li>
<li>binary result format 없음 (이번)</li>
<li>partial fetch 없음 (이번)</li>
</ol>
<p>대부분의 ORM/드라이버는 text format + full fetch를 기본으로 사용하므로 캐시 적중률 영향은 최소한이다.</p>
<hr>
<h2 id="소견-3-extended-read-cache-에러-시-깨진-연결-release-medium">소견 3: Extended read cache 에러 시 깨진 연결 Release (Medium)</h2>
<h3 id="문제-2">문제</h3>
<p><code>handleExtendedRead()</code>의 cache-enabled 분기:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>collected, err <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">relayAndCollect</span>(clientConn, rConn)
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>rPool.<span style="color:#50fa7b">Release</span>(rConn)  <span style="color:#6272a4">// ← 에러 확인 전에 Release!</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    dbg.balancer.<span style="color:#50fa7b">MarkUnhealthy</span>(readerAddr)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>relayAndCollect()</code>가 backend read 실패나 client write 실패를 반환해도, 연결 상태가 불명확한 채로 pool에 복귀한다. 다음 Acquire에서 이 연결을 받으면 프로토콜 desync가 발생할 수 있다.</p>
<p>같은 함수의 non-cache 경로와 <code>handleReadQueryTraced()</code>는 에러 시 <code>Discard()</code> 처리:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query_read.go — 올바른 패턴</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    rPool.<span style="color:#50fa7b">Discard</span>(rConn)  <span style="color:#6272a4">// ← 에러 시 Discard</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>rPool.<span style="color:#50fa7b">Release</span>(rConn)      <span style="color:#6272a4">// ← 성공 시에만 Release</span>
</span></span></code></pre></div><h3 id="수정-2">수정</h3>
<p>에러 확인을 Release 앞으로 이동:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>collected, err <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">relayAndCollect</span>(clientConn, rConn)
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    rPool.<span style="color:#50fa7b">Discard</span>(rConn)  <span style="color:#6272a4">// 깨진 연결은 버린다</span>
</span></span><span style="display:flex;"><span>    dbg.balancer.<span style="color:#50fa7b">MarkUnhealthy</span>(readerAddr)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>rPool.<span style="color:#50fa7b">Release</span>(rConn)      <span style="color:#6272a4">// 성공 시에만 pool 복귀</span>
</span></span></code></pre></div><hr>
<h2 id="소견-4-poolacquire-close-race-medium">소견 4: Pool.Acquire close race (Medium)</h2>
<h3 id="문제-3">문제</h3>
<p><code>Acquire()</code>에서 <code>numOpen++</code> → <code>Unlock()</code> → <code>newConn()</code> 사이에 <code>Close()</code>가 개입할 수 있다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>Goroutine A (Acquire)        Goroutine B (Close)
</span></span><span style="display:flex;"><span>─────────────────────        ──────────────────
</span></span><span style="display:flex;"><span>p.numOpen++ (10→11)
</span></span><span style="display:flex;"><span>p.mu.Unlock()
</span></span><span style="display:flex;"><span>                             p.mu.Lock()
</span></span><span style="display:flex;"><span>                             p.closed = true
</span></span><span style="display:flex;"><span>                             // idle conn 정리
</span></span><span style="display:flex;"><span>                             p.mu.Unlock()
</span></span><span style="display:flex;"><span>conn, _ := p.newConn()
</span></span><span style="display:flex;"><span>return conn, nil             // ← 닫힌 pool에서 live conn 탈출!
</span></span></code></pre></div><p>P47에서 <code>Release()</code>에 <code>p.closed</code> 체크를 추가했으므로, 이 conn이 Release되면 닫히긴 한다. 하지만 그 전까지 caller는 <strong>정상 연결인 줄 알고</strong> 사용한다.</p>
<h3 id="수정-3">수정</h3>
<p><code>newConn()</code> 성공 후 closed 상태를 재확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> p.numOpen &lt; p.cfg.MaxConnections {
</span></span><span style="display:flex;"><span>    p.numOpen<span style="color:#ff79c6">++</span>
</span></span><span style="display:flex;"><span>    p.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    conn, err <span style="color:#ff79c6">:=</span> p.<span style="color:#50fa7b">newConn</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        p.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>        p.numOpen<span style="color:#ff79c6">--</span>
</span></span><span style="display:flex;"><span>        p.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">nil</span>, err
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Re-check: Close() may have run while we were dialing.</span>
</span></span><span style="display:flex;"><span>    p.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> p.closed {
</span></span><span style="display:flex;"><span>        p.numOpen<span style="color:#ff79c6">--</span>
</span></span><span style="display:flex;"><span>        p.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>        conn.<span style="color:#50fa7b">Close</span>()
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">nil</span>, ErrPoolClosed
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    p.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> conn, <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>race window가 <code>newConn()</code> 동안(수 ms~수십 ms TCP dial)에서 <strong>lock 재확인까지</strong>(sub-μs)로 줄어든다. 완전 제거는 아니지만, lock을 잡은 채로 dial하면 전체 pool이 블로킹되므로 이 trade-off가 합리적이다.</p>
<hr>
<h2 id="소견-5-executesynthesizedquery--boundwriter-write-실패-시-discard-누락-medium">소견 5: executeSynthesizedQuery — boundWriter write 실패 시 discard 누락 (Medium)</h2>
<h3 id="문제-4">문제</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> protocol.<span style="color:#50fa7b">WriteMessage</span>(wConn, protocol.MsgQuery, queryPayload); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    ct.<span style="color:#8be9fd;font-style:italic">clear</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> acquired {
</span></span><span style="display:flex;"><span>        <span style="color:#50fa7b">discardToPool</span>(wConn, acquiredPool)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ← !acquired (boundWriter 사용 중) 경로: 아무것도 안 함!</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;send synthesized query: %w&#34;</span>, err)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>boundWriter에 write가 실패하면 연결이 깨진 상태인데, discard하지 않고 그대로 둔다. <code>relayQueries()</code>의 다음 루프에서 이 깨진 boundWriter로 다시 쿼리를 보내면 연쇄 실패가 발생하고, 최종적으로 defer의 cleanup이 처리할 때까지 모든 쿼리가 실패한다.</p>
<h3 id="수정-4">수정</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> acquired {
</span></span><span style="display:flex;"><span>    <span style="color:#50fa7b">discardToPool</span>(wConn, acquiredPool)
</span></span><span style="display:flex;"><span>} <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#50fa7b">discardToPool</span>(wConn, <span style="color:#ff79c6">*</span>boundWriterPool)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">*</span>boundWriter = <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">*</span>boundWriterPool = <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>boundWriter를 nil로 설정하면 다음 쿼리에서 <code>acquireWriterConn()</code>이 새 연결을 할당한다.</p>
<hr>
<h2 id="소견-6-extendedsynthesized-read에-circuit-breaker-누락-low">소견 6: Extended/Synthesized read에 circuit breaker 누락 (Low)</h2>
<h3 id="문제-5">문제</h3>
<p><code>handleReadQueryTraced()</code>(simple-query read 경로)에는 reader circuit breaker가 완전히 통합되어 있다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query_read.go — 정상</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> cb, ok <span style="color:#ff79c6">:=</span> dbg.<span style="color:#50fa7b">ReaderCB</span>(readerAddr); ok {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> cb.<span style="color:#50fa7b">Allow</span>(); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> s.<span style="color:#50fa7b">fallbackToWriter</span>(<span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>cb.<span style="color:#50fa7b">RecordSuccess</span>()  <span style="color:#6272a4">// 성공</span>
</span></span><span style="display:flex;"><span>cb.<span style="color:#50fa7b">RecordFailure</span>()  <span style="color:#6272a4">// 실패</span>
</span></span></code></pre></div><p>하지만 <code>handleExtendedRead()</code>와 <code>handleSynthesizedRead()</code>에는 CB 체크가 전혀 없다. reader가 장애 상태여도 extended/synthesized 쿼리는 CB를 우회하여 실패한 reader에 계속 시도한다.</p>
<h3 id="수정-5">수정</h3>
<p>두 함수 모두에 CB Allow/RecordSuccess/RecordFailure를 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// handleExtendedRead — CB check 추가</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> cb, ok <span style="color:#ff79c6">:=</span> dbg.<span style="color:#50fa7b">ReaderCB</span>(readerAddr); ok {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> cb.<span style="color:#50fa7b">Allow</span>(); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        slog.<span style="color:#50fa7b">Warn</span>(<span style="color:#f1fa8c">&#34;reader circuit breaker open for extended query&#34;</span>, <span style="color:#f1fa8c">&#34;addr&#34;</span>, readerAddr)
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">fallbackToWriter</span>()
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ... 성공 경로</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> cb, ok <span style="color:#ff79c6">:=</span> dbg.<span style="color:#50fa7b">ReaderCB</span>(readerAddr); ok {
</span></span><span style="display:flex;"><span>    cb.<span style="color:#50fa7b">RecordSuccess</span>()
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// ... 에러 경로마다</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> cb, ok <span style="color:#ff79c6">:=</span> dbg.<span style="color:#50fa7b">ReaderCB</span>(readerAddr); ok {
</span></span><span style="display:flex;"><span>    cb.<span style="color:#50fa7b">RecordFailure</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>handleSynthesizedRead()</code>도 동일하게 적용. 이제 3개 read 경로(simple, extended, synthesized) 모두 CB가 일관되게 작동한다.</p>
<hr>
<h2 id="교훈">교훈</h2>
<p>3차 QA에서 드러난 패턴:</p>
<ol>
<li><strong>수정 범위의 착각</strong> — P47에서 <code>boundWriterPool</code>을 도입했지만 simple-query 트랜잭션 경로에만 적용하고 fallback 경로를 놓쳤다. 같은 패턴(<code>dbg.writerPool</code> 직접 접근)을 프로젝트 전체에서 grep하는 습관이 필요하다.</li>
<li><strong>캐시 불변량의 점진적 확장</strong> — 1차에서 extended cache를 추가하고, 2차에서 <code>$N</code> 파라미터를 제외하고, 3차에서 binary format/partial fetch를 제외했다. 캐시 키의 완전성은 <strong>처음부터 모든 입력 차원을 나열</strong>해야 안전하다.</li>
<li><strong>에러 경로의 일관성</strong> — 같은 함수 안에서 cache path는 Release, non-cache path는 Discard. 이런 비대칭은 코드 리뷰에서도 놓치기 쉽다.</li>
<li><strong>안전장치의 대칭성</strong> — CB가 simple-query에만 있고 extended/synthesized에 없는 것은 기능 추가 시 <strong>모든 read 경로를 체크리스트로 관리</strong>하지 않으면 반복된다.</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글에서 수정한 것:</p>
<ul>
<li>fallback 4개 경로에서 writer pool 소유권 추적 완성</li>
<li>extended cache에 binary format/partial fetch 가드 추가</li>
<li>깨진 reader 연결이 pool로 복귀하는 버그 수정</li>
<li>Pool.Acquire에서 close race 방지</li>
<li>boundWriter write 실패 시 discard + nil 처리</li>
<li>extended/synthesized read에 circuit breaker 일관성 확보</li>
</ul>
<p>3번의 QA를 거치면서 커넥션 풀 관련 안전장치가 상당히 촘촘해졌다. 특히 hot-reload 시나리오에서의 pool 소유권 문제는 이제 <strong>모든 writer 접근 경로</strong>에서 캡처 패턴이 적용되었다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (30) - AST 라우팅 사각지대와 캐시 무효화 실종</title><link>https://jyukki.com/posts/2026-03-12-pgmux-30-qa-round3-routing-cache-parsing/</link><pubDate>Thu, 12 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-12-pgmux-30-qa-round3-routing-cache-parsing/</guid><description>QA 3차 리포트 5건 — AST 분류가 라우팅에 미반영, 캐시 테이블 무효화 no-op, 중복 파싱 5회/요청, 헬스체크 순차 지연, splitStatements 달러쿼팅 미처리 — 의 원인과 수정 과정을 정리한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>P29까지의 수정이 끝나자마자 QA 팀이 3차 리포트를 보내왔다. 이번에는 &ldquo;표면적으로는 동작하지만 실은 안 하고 있는&rdquo; 유형의 버그가 주를 이뤘다. 설정을 켜도 반영되지 않는 AST 라우팅, nil 하나 때문에 통째로 무력화된 캐시 무효화, 요청 하나에 같은 SQL을 5번 파싱하는 낭비까지. 총 5건, 높음 2건 + 중간 3건이다.</p>
<hr>
<h2 id="버그-1-ast-라우팅이-껍데기뿐-높음">버그 1: AST 라우팅이 껍데기뿐 (높음)</h2>
<h3 id="증상">증상</h3>
<p><code>routing.ast_parser: true</code>를 설정해도 CTE 내 INSERT/UPDATE가 reader로 라우팅될 수 있다.</p>
<h3 id="원인">원인</h3>
<p><code>Session.Route()</code>가 AST 설정을 전혀 모른다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/router/router.go — 수정 전</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Session) <span style="color:#50fa7b">Route</span>(query <span style="color:#8be9fd">string</span>) Route {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    qtype <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">Classify</span>(query)  <span style="color:#6272a4">// 항상 문자열 기반</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>ClassifyAST()</code>는 <code>proxy/helpers.go</code>의 <code>classifyQuery()</code>를 통해 호출되지만, 그 결과는 <strong>텔레메트리 span의 attribute</strong>와 <strong>캐시 무효화 판단</strong>에만 사용된다. 실제 라우팅 결정을 내리는 <code>Session.Route()</code>에는 도달하지 않는다.</p>
<p><code>Session</code> 구조체에는 <code>astParser</code> 필드 자체가 없었다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Session <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    inTransaction       <span style="color:#8be9fd">bool</span>
</span></span><span style="display:flex;"><span>    readAfterWriteDelay time.Duration
</span></span><span style="display:flex;"><span>    causalConsistency   <span style="color:#8be9fd">bool</span>
</span></span><span style="display:flex;"><span>    lastWriteLSN        LSN
</span></span><span style="display:flex;"><span>    stmtRoutes          <span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]Route
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// astParser 필드 없음!</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>NewSession()</code> 호출부(<code>server.go</code>)에서도 AST 설정을 전달하지 않았다. 기능은 구현되어 있지만 <strong>배선이 안 된</strong> 것이다.</p>
<h3 id="수정">수정</h3>
<p><code>Session</code>에 <code>astParser</code> 필드를 추가하고, <code>Route()</code>와 <code>routeLocked()</code>에서 분기한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/router/router.go — 수정 후</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Session <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    astParser <span style="color:#8be9fd">bool</span>
</span></span><span style="display:flex;"><span>    stmtRoutes <span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]Route
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">NewSession</span>(readAfterWriteDelay time.Duration, causalConsistency <span style="color:#8be9fd">bool</span>, astParser <span style="color:#8be9fd">bool</span>) <span style="color:#ff79c6">*</span>Session {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">&amp;</span>Session{
</span></span><span style="display:flex;"><span>        readAfterWriteDelay: readAfterWriteDelay,
</span></span><span style="display:flex;"><span>        causalConsistency:   causalConsistency,
</span></span><span style="display:flex;"><span>        astParser:           astParser,
</span></span><span style="display:flex;"><span>        stmtRoutes:          <span style="color:#8be9fd;font-style:italic">make</span>(<span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]Route),
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Session) <span style="color:#50fa7b">Route</span>(query <span style="color:#8be9fd">string</span>) Route {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">var</span> qtype QueryType
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.astParser {
</span></span><span style="display:flex;"><span>        qtype = <span style="color:#50fa7b">ClassifyAST</span>(query)
</span></span><span style="display:flex;"><span>    } <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>        qtype = <span style="color:#50fa7b">Classify</span>(query)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>server.go</code>에서 설정 전달:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>session <span style="color:#ff79c6">:=</span> router.<span style="color:#50fa7b">NewSession</span>(cfg.Routing.ReadAfterWriteDelay, cfg.Routing.CausalConsistency, cfg.Routing.ASTParser)
</span></span></code></pre></div><h3 id="교훈">교훈</h3>
<p>기능을 구현하는 것과 기능을 <strong>연결하는 것</strong>은 별개다. 이 버그의 위험한 점은 <code>ClassifyAST</code>가 분명히 호출되고 있어서 — span에 올바른 <code>query.type</code> 값이 찍히고, 캐시 무효화에도 쓰이고 있어서 — &ldquo;잘 되고 있다&quot;고 착각하기 쉽다는 것이다. 실제 라우팅 결정은 전혀 다른 경로에서 이루어지고 있었다.</p>
<hr>
<h2 id="버그-2-nil-하나로-캐시-무효화가-전멸-높음">버그 2: nil 하나로 캐시 무효화가 전멸 (높음)</h2>
<h3 id="증상-1">증상</h3>
<p>테이블에 INSERT 후 다른 세션에서 SELECT하면 TTL 만료 전까지 stale 데이터가 반환된다.</p>
<h3 id="원인-1">원인</h3>
<p>읽기 캐시 저장 3곳 모두 <code>tables=nil</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/proxy/query_read.go:137 — handleReadQueryTraced</span>
</span></span><span style="display:flex;"><span>s.queryCache.<span style="color:#50fa7b">Set</span>(key, collected, <span style="color:#ff79c6">nil</span>)  <span style="color:#6272a4">// tables가 nil!</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// internal/proxy/query_read.go:258 — handleReadQuery</span>
</span></span><span style="display:flex;"><span>s.queryCache.<span style="color:#50fa7b">Set</span>(key, collected, <span style="color:#ff79c6">nil</span>)  <span style="color:#6272a4">// 여기도 nil!</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// internal/proxy/query_extended.go:109 — handleExtendedRead</span>
</span></span><span style="display:flex;"><span>s.queryCache.<span style="color:#50fa7b">Set</span>(key, collected, <span style="color:#ff79c6">nil</span>)  <span style="color:#6272a4">// 여기도 nil!</span>
</span></span></code></pre></div><p>캐시의 <code>Set()</code> → <code>updateTableIndex()</code> 흐름:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (c <span style="color:#ff79c6">*</span>Cache) <span style="color:#50fa7b">updateTableIndex</span>(key <span style="color:#8be9fd">uint64</span>, tables []<span style="color:#8be9fd">string</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, t <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> tables {  <span style="color:#6272a4">// tables가 nil이면 루프 진입 안 함</span>
</span></span><span style="display:flex;"><span>        c.tableIndex[t] = <span style="color:#8be9fd;font-style:italic">append</span>(c.tableIndex[t], key)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>tables=nil</code>이면 <code>tableIndex</code>에 아무것도 등록되지 않는다. 쓰기 후 <code>InvalidateTable(&quot;users&quot;)</code>을 호출해도:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (c <span style="color:#ff79c6">*</span>Cache) <span style="color:#50fa7b">InvalidateTable</span>(table <span style="color:#8be9fd">string</span>) {
</span></span><span style="display:flex;"><span>    keys, ok <span style="color:#ff79c6">:=</span> c.tableIndex[table]  <span style="color:#6272a4">// 비어있으므로 ok=false</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> !ok {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span>  <span style="color:#6272a4">// 즉시 반환 — 무효화 안 됨</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>빈 <code>tableIndex</code>에서 조회 → no-op. 캐시 무효화 코드가 존재하지만 <strong>한 번도 실행된 적이 없는</strong> 상태다.</p>
<h3 id="수정-1">수정</h3>
<p>읽기 쿼리에서 테이블명을 추출하는 함수를 추가하고, 캐시 저장 시 전달한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/router/parser_ast.go — 새 함수</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">ExtractReadTablesAST</span>(query <span style="color:#8be9fd">string</span>) []<span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    tree, err <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">ParseSQL</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">ExtractReadTables</span>(query) <span style="color:#6272a4">// 문자열 fallback</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    seen <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>(<span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]<span style="color:#8be9fd">bool</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">var</span> tables []<span style="color:#8be9fd">string</span>
</span></span><span style="display:flex;"><span>    <span style="color:#50fa7b">WalkNodes</span>(tree, <span style="color:#8be9fd;font-style:italic">func</span>(node <span style="color:#ff79c6">*</span>pg_query.Node) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> rv <span style="color:#ff79c6">:=</span> node.<span style="color:#50fa7b">GetRangeVar</span>(); rv <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>            t <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToLower</span>(rv.<span style="color:#50fa7b">GetRelname</span>())
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> t <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#34;&#34;</span> <span style="color:#ff79c6">&amp;&amp;</span> !seen[t] {
</span></span><span style="display:flex;"><span>                seen[t] = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>                tables = <span style="color:#8be9fd;font-style:italic">append</span>(tables, t)
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>    })
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> tables
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>호출부 수정:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/proxy/query_read.go — 수정 후</span>
</span></span><span style="display:flex;"><span>tables <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">extractReadQueryTables</span>(query)
</span></span><span style="display:flex;"><span>s.queryCache.<span style="color:#50fa7b">Set</span>(key, collected, tables)  <span style="color:#6272a4">// nil → 실제 테이블명</span>
</span></span></code></pre></div><p>3곳 모두 동일하게 수정했다.</p>
<h3 id="테스트">테스트</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">TestCache_InvalidateTable_ReadCacheWithTables</span>(t <span style="color:#ff79c6">*</span>testing.T) {
</span></span><span style="display:flex;"><span>    c <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">New</span>(Config{MaxEntries: <span style="color:#bd93f9">100</span>, TTL: time.Minute, MaxSize: <span style="color:#bd93f9">1024</span>})
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    c.<span style="color:#50fa7b">Set</span>(<span style="color:#50fa7b">CacheKey</span>(<span style="color:#f1fa8c">&#34;SELECT * FROM users&#34;</span>), []<span style="color:#8be9fd;font-style:italic">byte</span>(<span style="color:#f1fa8c">&#34;users-result&#34;</span>), []<span style="color:#8be9fd">string</span>{<span style="color:#f1fa8c">&#34;users&#34;</span>})
</span></span><span style="display:flex;"><span>    c.<span style="color:#50fa7b">Set</span>(<span style="color:#50fa7b">CacheKey</span>(<span style="color:#f1fa8c">&#34;SELECT * FROM orders&#34;</span>), []<span style="color:#8be9fd;font-style:italic">byte</span>(<span style="color:#f1fa8c">&#34;orders-result&#34;</span>), []<span style="color:#8be9fd">string</span>{<span style="color:#f1fa8c">&#34;orders&#34;</span>})
</span></span><span style="display:flex;"><span>    c.<span style="color:#50fa7b">Set</span>(<span style="color:#50fa7b">CacheKey</span>(<span style="color:#f1fa8c">&#34;SELECT * FROM users JOIN orders ...&#34;</span>), []<span style="color:#8be9fd;font-style:italic">byte</span>(<span style="color:#f1fa8c">&#34;join-result&#34;</span>), []<span style="color:#8be9fd">string</span>{<span style="color:#f1fa8c">&#34;users&#34;</span>, <span style="color:#f1fa8c">&#34;orders&#34;</span>})
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    c.<span style="color:#50fa7b">InvalidateTable</span>(<span style="color:#f1fa8c">&#34;users&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// users, join 캐시 삭제됨, orders는 유지</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> c.<span style="color:#50fa7b">Len</span>() <span style="color:#ff79c6">!=</span> <span style="color:#bd93f9">1</span> { t.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;Len() = %d, want 1&#34;</span>, c.<span style="color:#50fa7b">Len</span>()) }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="교훈-1">교훈</h3>
<p>함수 시그니처에 <code>tables []string</code>이 있고 nil이 합법적인 값이면, 호출자는 &ldquo;나중에 채우자&quot;고 nil을 넣고 잊어버리기 쉽다. 이 경우 nil은 &ldquo;테이블 없음&quot;이 아니라 &ldquo;추출을 안 함&quot;이라는 뜻이었다. Go에서 nil slice는 조용히 empty처럼 동작하므로 에러도 패닉도 없이 로직 전체를 무력화한다.</p>
<hr>
<h2 id="개선-3-요청-1건에-같은-sql을-5번-파싱-중간">개선 3: 요청 1건에 같은 SQL을 5번 파싱 (중간)</h2>
<h3 id="증상-2">증상</h3>
<p>AST 모드에서 트래픽이 올라가면 CPU 사용량이 예상보다 급격히 증가한다.</p>
<h3 id="원인-2">원인</h3>
<p>요청 하나의 처리 경로에서 <code>pg_query.Parse()</code>가 <strong>독립적으로 5회 이상</strong> 호출된다:</p>
<table>
  <thead>
      <tr>
          <th>단계</th>
          <th>위치</th>
          <th>호출</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>방화벽</td>
          <td><code>firewall.go:42</code></td>
          <td><code>ParseSQL(query)</code></td>
      </tr>
      <tr>
          <td>분류</td>
          <td><code>parser_ast.go:46</code></td>
          <td><code>ParseSQL(query)</code></td>
      </tr>
      <tr>
          <td>캐시 키</td>
          <td><code>normalize.go:17</code></td>
          <td><code>pg_query.Parse(query)</code></td>
      </tr>
      <tr>
          <td>테이블 추출</td>
          <td><code>parser_ast.go:125</code></td>
          <td><code>ParseSQL(query)</code></td>
      </tr>
      <tr>
          <td>힌트</td>
          <td><code>parser_ast.go:38</code></td>
          <td><code>pg_query.Scan(query)</code></td>
      </tr>
  </tbody>
</table>
<p><code>pg_query.Parse()</code>는 CGO 경계를 넘어 PostgreSQL C 파서를 호출한다. 벤치마크:</p>
<ul>
<li><code>ClassifyAST</code> SELECT: ~10.3us</li>
<li><code>SemanticCacheKey</code>: ~17.5us</li>
<li><code>CheckFirewall</code>: ~5.5us</li>
</ul>
<p>요청당 ~33us 이상이 순수 파싱에 소비된다.</p>
<h3 id="수정-2">수정</h3>
<p><code>ParsedQuery</code> 구조체로 파싱 결과를 한 번 만들어 재사용한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/router/parsed_query.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> ParsedQuery <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    SQL  <span style="color:#8be9fd">string</span>
</span></span><span style="display:flex;"><span>    Tree <span style="color:#ff79c6">*</span>pg_query.ParseResult
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">NewParsedQuery</span>(sql <span style="color:#8be9fd">string</span>) (<span style="color:#ff79c6">*</span>ParsedQuery, <span style="color:#8be9fd">error</span>) {
</span></span><span style="display:flex;"><span>    tree, err <span style="color:#ff79c6">:=</span> pg_query.<span style="color:#50fa7b">Parse</span>(sql)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">nil</span>, fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;parse SQL: %w&#34;</span>, err)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">&amp;</span>ParsedQuery{SQL: sql, Tree: tree}, <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>각 함수에 <code>WithTree</code> 변형을 추가하고, 기존 함수는 backward-compatible 래퍼로 유지:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">ClassifyASTWithTree</span>(query <span style="color:#8be9fd">string</span>, pq <span style="color:#ff79c6">*</span>ParsedQuery) QueryType { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">CheckFirewallWithTree</span>(pq <span style="color:#ff79c6">*</span>ParsedQuery, cfg FirewallConfig) FirewallResult { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">ExtractTablesASTWithTree</span>(pq <span style="color:#ff79c6">*</span>ParsedQuery) []<span style="color:#8be9fd">string</span> { <span style="color:#ff79c6">...</span> }
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">SemanticCacheKeyWithTree</span>(tree <span style="color:#ff79c6">*</span>pg_query.ParseResult, query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">uint64</span> { <span style="color:#ff79c6">...</span> }
</span></span></code></pre></div><p>쿼리 처리 진입점에서 한 번 파싱:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/proxy/query.go — 쿼리 루프 내</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> parsedQuery <span style="color:#ff79c6">*</span>router.ParsedQuery
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> queryCfg.Routing.ASTParser {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> pq, err <span style="color:#ff79c6">:=</span> router.<span style="color:#50fa7b">NewParsedQuery</span>(query); err <span style="color:#ff79c6">==</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        parsedQuery = pq
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// 이후 모든 호출에 parsedQuery 전달</span>
</span></span><span style="display:flex;"><span>fwResult = router.<span style="color:#50fa7b">CheckFirewallWithTree</span>(parsedQuery, fwCfg)
</span></span><span style="display:flex;"><span>qtype = s.<span style="color:#50fa7b">classifyQueryParsed</span>(query, parsedQuery)
</span></span><span style="display:flex;"><span>key = s.<span style="color:#50fa7b">cacheKeyParsed</span>(query, parsedQuery)
</span></span><span style="display:flex;"><span>tables = s.<span style="color:#50fa7b">extractQueryTablesParsed</span>(query, parsedQuery)
</span></span></code></pre></div><h3 id="벤치마크-결과">벤치마크 결과</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>BenchmarkQueryPipeline_WithoutParsedQuery   25,000 ns/op   6,552 B/op   135 allocs/op
</span></span><span style="display:flex;"><span>BenchmarkQueryPipeline_WithParsedQuery       7,300 ns/op   2,280 B/op    46 allocs/op
</span></span></code></pre></div><p><strong>3.4x 속도 향상, 65% 메모리 감소, 66% 할당 감소.</strong></p>
<h3 id="교훈-2">교훈</h3>
<p>모듈별로 독립적으로 파싱하는 것이 깔끔한 설계처럼 보이지만, CGO 호출이 포함되면 비용이 기하급수적으로 쌓인다. &ldquo;parse once, pass the tree&rdquo; 패턴은 모듈 간 결합을 약간 높이지만, 요청 경로의 latency를 극적으로 줄인다.</p>
<hr>
<h2 id="버그-4-백엔드가-죽을수록-헬스체크가-느려진다-중간">버그 4: 백엔드가 죽을수록 헬스체크가 느려진다 (중간)</h2>
<h3 id="증상-3">증상</h3>
<p>reader 3대 중 2대가 죽으면 <code>/admin/health</code> 응답이 ~6초 걸린다.</p>
<h3 id="원인-3">원인</h3>
<p>순차적 TCP 다이얼:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/admin/admin.go — 수정 전</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">handleHealth</span>(w http.ResponseWriter, r <span style="color:#ff79c6">*</span>http.Request) {
</span></span><span style="display:flex;"><span>    writerHealthy <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">checkTCP</span>(writerAddr)          <span style="color:#6272a4">// 죽으면 2초</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, r <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> cfg.Readers {
</span></span><span style="display:flex;"><span>        readers = <span style="color:#8be9fd;font-style:italic">append</span>(readers, backendHealth{
</span></span><span style="display:flex;"><span>            Healthy: <span style="color:#50fa7b">checkTCP</span>(addr),               <span style="color:#6272a4">// 각각 2초</span>
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">checkTCP</span>(addr <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    conn, err <span style="color:#ff79c6">:=</span> net.<span style="color:#50fa7b">DialTimeout</span>(<span style="color:#f1fa8c">&#34;tcp&#34;</span>, addr, <span style="color:#bd93f9">2</span><span style="color:#ff79c6">*</span><span style="color:#bd93f9">1e9</span>)  <span style="color:#6272a4">// 2초 타임아웃</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>writer 1 + reader N개를 순차 검사하므로, 모두 죽으면 <code>(1+N) × 2초</code>.</p>
<h3 id="수정-3">수정</h3>
<p>goroutine으로 병렬화:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/admin/admin.go — 수정 후</span>
</span></span><span style="display:flex;"><span>readers <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>([]backendHealth, <span style="color:#8be9fd;font-style:italic">len</span>(cfg.Readers))
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">for</span> i, rd <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> cfg.Readers {
</span></span><span style="display:flex;"><span>    readers[i].Addr = fmt.<span style="color:#50fa7b">Sprintf</span>(<span style="color:#f1fa8c">&#34;%s:%d&#34;</span>, rd.Host, rd.Port)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> wg sync.WaitGroup
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> writerHealthy <span style="color:#8be9fd">bool</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>wg.<span style="color:#50fa7b">Add</span>(<span style="color:#bd93f9">1</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">defer</span> wg.<span style="color:#50fa7b">Done</span>()
</span></span><span style="display:flex;"><span>    writerHealthy = <span style="color:#50fa7b">checkTCP</span>(writerAddr)
</span></span><span style="display:flex;"><span>}()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">for</span> i <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> readers {
</span></span><span style="display:flex;"><span>    wg.<span style="color:#50fa7b">Add</span>(<span style="color:#bd93f9">1</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>(idx <span style="color:#8be9fd">int</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">defer</span> wg.<span style="color:#50fa7b">Done</span>()
</span></span><span style="display:flex;"><span>        readers[idx].Healthy = <span style="color:#50fa7b">checkTCP</span>(readers[idx].Addr)
</span></span><span style="display:flex;"><span>    }(i)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>wg.<span style="color:#50fa7b">Wait</span>()
</span></span></code></pre></div><p>pre-allocated slice의 서로 다른 인덱스에 쓰므로 mutex 불필요. <code>WaitGroup</code>만으로 동기화 완료.</p>
<h3 id="테스트-1">테스트</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">TestHandleHealth_ParallelTiming</span>(t <span style="color:#ff79c6">*</span>testing.T) {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// RFC 5737 TEST-NET 주소 — 무조건 타임아웃</span>
</span></span><span style="display:flex;"><span>    cfg <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">&amp;</span>config.Config{
</span></span><span style="display:flex;"><span>        Writer:  config.DBConfig{Host: <span style="color:#f1fa8c">&#34;192.0.2.1&#34;</span>, Port: <span style="color:#bd93f9">9999</span>},
</span></span><span style="display:flex;"><span>        Readers: []config.DBConfig{
</span></span><span style="display:flex;"><span>            {Host: <span style="color:#f1fa8c">&#34;192.0.2.1&#34;</span>, Port: <span style="color:#bd93f9">9999</span>},
</span></span><span style="display:flex;"><span>            {Host: <span style="color:#f1fa8c">&#34;192.0.2.1&#34;</span>, Port: <span style="color:#bd93f9">9999</span>},
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    start <span style="color:#ff79c6">:=</span> time.<span style="color:#50fa7b">Now</span>()
</span></span><span style="display:flex;"><span>    srv.<span style="color:#50fa7b">handleHealth</span>(w, req)
</span></span><span style="display:flex;"><span>    elapsed <span style="color:#ff79c6">:=</span> time.<span style="color:#50fa7b">Since</span>(start)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 순차 시 ~6초, 병렬이면 ~2초</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> elapsed &gt; <span style="color:#bd93f9">4</span><span style="color:#ff79c6">*</span>time.Second {
</span></span><span style="display:flex;"><span>        t.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;took %v; expected &lt; 4s&#34;</span>, elapsed)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="교훈-3">교훈</h3>
<p>I/O 바운드 작업을 루프에서 순차 실행하면, 장애 상황에서 지연이 선형으로 누적된다. 헬스체크는 장애 시에 가장 빨라야 하는데, 장애 시에 가장 느려지는 역설이 발생한다. Go에서는 goroutine + WaitGroup으로 O(N)을 O(1)로 만드는 비용이 매우 낮다.</p>
<hr>
<h2 id="버그-5-splitstatements가-달러-쿼팅을-모른다-중간">버그 5: splitStatements가 달러 쿼팅을 모른다 (중간)</h2>
<h3 id="증상-4">증상</h3>
<p>PL/pgSQL 함수 정의가 세미콜론 기준으로 잘못 분리되어 라우팅 및 트랜잭션 추적 오류 발생.</p>
<h3 id="원인-4">원인</h3>
<p><code>splitStatements()</code> 구현이 <code>'</code>와 <code>&quot;</code> 만 인식:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/router/router.go — 수정 전</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">splitStatements</span>(query <span style="color:#8be9fd">string</span>) []<span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    inSingleQuote <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">false</span>
</span></span><span style="display:flex;"><span>    inDoubleQuote <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">false</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>; i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query); i<span style="color:#ff79c6">++</span> {
</span></span><span style="display:flex;"><span>        ch <span style="color:#ff79c6">:=</span> query[i]
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">switch</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;\&#39;&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inDoubleQuote:
</span></span><span style="display:flex;"><span>            inSingleQuote = !inSingleQuote  <span style="color:#6272a4">// &#39;&#39; 이스케이프도 오동작</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;&#34;&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inSingleQuote:
</span></span><span style="display:flex;"><span>            inDoubleQuote = !inDoubleQuote
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;;&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inSingleQuote <span style="color:#ff79c6">&amp;&amp;</span> !inDoubleQuote:
</span></span><span style="display:flex;"><span>            <span style="color:#6272a4">// 분리!</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>아래 입력에서 잘못 분리된다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">CREATE</span> <span style="color:#ff79c6">FUNCTION</span> f() <span style="color:#ff79c6">AS</span> $$ <span style="color:#ff79c6">BEGIN</span> <span style="color:#ff79c6">SELECT</span> <span style="color:#bd93f9">1</span>; <span style="color:#ff79c6">END</span>; $$ <span style="color:#ff79c6">LANGUAGE</span> plpgsql
</span></span></code></pre></div><p>결과: <code>CREATE FUNCTION f() AS $$ BEGIN SELECT 1</code> / <code>END</code> / <code>$$ LANGUAGE plpgsql</code> — 3개로 분리.</p>
<p>같은 패키지의 <code>parser.go</code>에는 <code>stripStringLiterals()</code>가 달러 쿼팅을 정상 처리하고, <code>stripComments()</code>가 <code>--</code>와 <code>/* */</code>를 정상 처리한다. 하지만 <code>splitStatements()</code>는 이 유틸을 사용하지 않고 독자 구현.</p>
<h3 id="수정-4">수정</h3>
<p><code>splitStatements()</code>를 재작성. 기존 <code>parseDollarTag()</code>를 재사용하고 라인/블록 주석을 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">splitStatements</span>(query <span style="color:#8be9fd">string</span>) []<span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>; i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query); i<span style="color:#ff79c6">++</span> {
</span></span><span style="display:flex;"><span>        ch <span style="color:#ff79c6">:=</span> query[i]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// Dollar quoting: $$ 또는 $tag$ 감지 → 닫는 태그까지 skip</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;$&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inSingleQuote <span style="color:#ff79c6">&amp;&amp;</span> !inDoubleQuote {
</span></span><span style="display:flex;"><span>            tag, ok <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">parseDollarTag</span>(query, i)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> ok {
</span></span><span style="display:flex;"><span>                <span style="color:#6272a4">// opening tag + body + closing tag를 통째로 current에 추가</span>
</span></span><span style="display:flex;"><span>                <span style="color:#6272a4">// i를 closing tag 끝으로 이동</span>
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">continue</span>
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// Line comment: -- → 줄 끝까지 skip</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;-&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inSingleQuote <span style="color:#ff79c6">&amp;&amp;</span> !inDoubleQuote <span style="color:#ff79c6">&amp;&amp;</span> i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;-&#39;</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">for</span> i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> query[i] <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#39;\n&#39;</span> { current.<span style="color:#50fa7b">WriteByte</span>(query[i]); i<span style="color:#ff79c6">++</span> }
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">continue</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// Block comment: /* → */ (중첩 지원)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;/&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inSingleQuote <span style="color:#ff79c6">&amp;&amp;</span> !inDoubleQuote <span style="color:#ff79c6">&amp;&amp;</span> i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;*&#39;</span> {
</span></span><span style="display:flex;"><span>            depth <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">1</span>
</span></span><span style="display:flex;"><span>            <span style="color:#6272a4">// depth == 0 될 때까지 skip</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">continue</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// Escaped quote: &#39;&#39; → skip (기존 토글 방식 수정)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="테스트-4건--14건">테스트 (4건 → 14건)</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>{<span style="color:#f1fa8c">&#34;dollar-quoted function body&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f1fa8c">&#34;CREATE FUNCTION f() AS $$ BEGIN SELECT 1; END; $$ LANGUAGE plpgsql&#34;</span>, <span style="color:#bd93f9">1</span>},
</span></span><span style="display:flex;"><span>{<span style="color:#f1fa8c">&#34;tagged dollar quote&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f1fa8c">&#34;SELECT $tag$hello;world$tag$&#34;</span>, <span style="color:#bd93f9">1</span>},
</span></span><span style="display:flex;"><span>{<span style="color:#f1fa8c">&#34;line comment with semicolon&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f1fa8c">&#34;SELECT 1; -- comment; here\nSELECT 2&#34;</span>, <span style="color:#bd93f9">2</span>},
</span></span><span style="display:flex;"><span>{<span style="color:#f1fa8c">&#34;block comment with semicolon&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f1fa8c">&#34;SELECT 1; /* comment; here */ SELECT 2&#34;</span>, <span style="color:#bd93f9">2</span>},
</span></span><span style="display:flex;"><span>{<span style="color:#f1fa8c">&#34;nested block comment&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f1fa8c">&#34;SELECT 1; /* outer /* inner; */ still; */ SELECT 2&#34;</span>, <span style="color:#bd93f9">2</span>},
</span></span><span style="display:flex;"><span>{<span style="color:#f1fa8c">&#34;escaped single quotes&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f1fa8c">&#34;SELECT &#39;it&#39;&#39;s;fine&#39;; SELECT 2&#34;</span>, <span style="color:#bd93f9">2</span>},
</span></span><span style="display:flex;"><span>{<span style="color:#f1fa8c">&#34;mixed function with comments&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f1fa8c">&#34;CREATE FUNCTION f() AS $$ BEGIN\n-- a; comment\nSELECT 1; /* block; */ END; $$ LANGUAGE plpgsql; SELECT 2&#34;</span>, <span style="color:#bd93f9">2</span>},
</span></span></code></pre></div><h3 id="교훈-4">교훈</h3>
<p>SQL의 &ldquo;세미콜론으로 분리&quot;는 자명한 작업처럼 보이지만, PostgreSQL의 quoting 규칙은 매우 풍부하다. 같은 패키지에 이미 올바른 구현이 있었는데 <code>splitStatements</code>만 독자적으로 간략화한 것이 원인이다. <strong>유틸리티 함수는 만들었으면 써야 한다.</strong></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 QA 라운드에서 발견된 5건의 공통점은 <strong>&ldquo;기능이 존재하지만 연결되지 않았거나, 정상 경로에서만 동작하는&rdquo;</strong> 유형이다.</p>
<table>
  <thead>
      <tr>
          <th>패턴</th>
          <th>사례</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>배선 누락</td>
          <td>AST 라우팅 (#143) — 구현은 있으나 Session에 미연결</td>
      </tr>
      <tr>
          <td>nil 전파</td>
          <td>캐시 무효화 (#144) — nil slice가 조용히 로직 전체를 무력화</td>
      </tr>
      <tr>
          <td>중복 비용</td>
          <td>AST 파싱 (#145) — 모듈 독립성이 CGO 비용을 5배로 증폭</td>
      </tr>
      <tr>
          <td>장애 시 역전</td>
          <td>헬스체크 (#146) — 장애 시에 가장 느려지는 역설</td>
      </tr>
      <tr>
          <td>유틸 미사용</td>
          <td>splitStatements (#147) — 같은 패키지의 유틸을 안 쓴 독자 구현</td>
      </tr>
  </tbody>
</table>
<p>버그는 &ldquo;없는 기능&rdquo; 보다 &ldquo;있는 것 같은 기능&quot;이 더 위험하다. 설정을 켜도 반영되지 않는 라우팅, nil을 넘겨도 에러 없이 돌아가는 캐시 — 이런 것들은 정상 경로 테스트에서 잡히지 않는다. QA의 엣지케이스 리뷰가 또 한 번 빛을 발한 라운드였다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (31) - 캐시 포맷 충돌과 HTTP 서버 수명주기</title><link>https://jyukki.com/posts/2026-03-12-pgmux-31-cache-format-collision-and-http-lifecycle/</link><pubDate>Thu, 12 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-12-pgmux-31-cache-format-collision-and-http-lifecycle/</guid><description>QA 4차 리포트 5건 — 캐시 키 네임스페이스 부재로 JSON/wire 응답 충돌, 읽기 캐시 무효화 실종, balancer 상태 초기화, HTTP 서버 lifecycle 미관리, AST 재파싱 — 의 원인과 수정 과정을 정리한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>P30에서 AST 라우팅 사각지대와 캐시 무효화 실종을 수정한 직후, QA 4차 리포트가 도착했다. 이번 라운드의 핵심은 <strong>캐시가 프로토콜을 깨뜨릴 수 있다</strong>는 Critical 급 발견이었다. Data API, proxy simple query, extended query 세 경로가 동일한 캐시를 공유하면서 전혀 다른 응답 포맷을 저장하고 있었다.</p>
<p>총 5건: 높음 2건 + 중간 3건.</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>심각도</th>
          <th>요약</th>
          <th>PR</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><strong>높음</strong></td>
          <td>캐시 키에 응답 포맷 namespace가 없어 JSON/wire 충돌</td>
          <td><a href="https://github.com/jyukki97/pgmux/pull/158">#158</a></td>
      </tr>
      <tr>
          <td>2</td>
          <td><strong>높음</strong></td>
          <td>Data API 읽기 캐시가 write table extractor를 사용하여 무효화 불가</td>
          <td><a href="https://github.com/jyukki97/pgmux/pull/159">#159</a></td>
      </tr>
      <tr>
          <td>3</td>
          <td>중간</td>
          <td>Hot reload가 balancer의 healthy/replayLSN 상태를 초기화</td>
          <td><a href="https://github.com/jyukki97/pgmux/pull/162">#162</a></td>
      </tr>
      <tr>
          <td>4</td>
          <td>중간</td>
          <td>HTTP 서버(metrics/admin/data_api)에 lifecycle 관리 없음</td>
          <td><a href="https://github.com/jyukki97/pgmux/pull/161">#161</a></td>
      </tr>
      <tr>
          <td>5</td>
          <td>중간</td>
          <td>read cache path에서 AST 재파싱</td>
          <td><a href="https://github.com/jyukki97/pgmux/pull/160">#160</a></td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="버그-1-캐시-키-네임스페이스-부재--json이-wire로-높음">버그 1: 캐시 키 네임스페이스 부재 — JSON이 wire로 (높음)</h2>
<h3 id="증상">증상</h3>
<p>캐시 활성화 + Data API와 proxy를 동시에 사용하면, proxy simple query 클라이언트가 JSON 바이트를 PG wire 응답으로 받아 프로토콜이 깨진다.</p>
<h3 id="원인">원인</h3>
<p>세 경로가 동일한 <code>cache.Cache</code> 인스턴스를 공유하며, 같은 SQL에 대해 동일한 캐시 키(FNV-1a 해시)를 생성한다. 그런데 저장하는 응답 포맷이 각기 다르다:</p>
<table>
  <thead>
      <tr>
          <th>경로</th>
          <th>저장 포맷</th>
          <th>코드</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Data API</td>
          <td><code>json.Marshal(QueryResponse{...})</code></td>
          <td><code>handler.go:282</code></td>
      </tr>
      <tr>
          <td>Proxy simple read</td>
          <td>PG wire bytes (RowDesc+DataRow+&hellip;+ReadyForQuery)</td>
          <td><code>query_read.go:138</code></td>
      </tr>
      <tr>
          <td>Extended query</td>
          <td>PG wire bytes (ParseComplete+BindComplete+&hellip;)</td>
          <td><code>query_extended.go:108</code></td>
      </tr>
  </tbody>
</table>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>시나리오:
</span></span><span style="display:flex;"><span>1. Data API: SELECT * FROM users → 캐시에 JSON 저장 (key=0xABCD)
</span></span><span style="display:flex;"><span>2. Proxy client: SELECT * FROM users → 같은 키로 캐시 HIT
</span></span><span style="display:flex;"><span>3. clientConn.Write(cached) → JSON 바이트가 PG wire로 전송
</span></span><span style="display:flex;"><span>4. PostgreSQL 클라이언트: 프로토콜 파싱 실패 → 연결 끊김
</span></span></code></pre></div><p>proxy simple read의 캐시 반환 코드가 바이트를 그대로 소켓에 쏘기 때문에, 포맷 검증 없이 무조건 전송된다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query_read.go:33 — 포맷 검증 없이 raw write</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> cached <span style="color:#ff79c6">:=</span> s.queryCache.<span style="color:#50fa7b">Get</span>(key); cached <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    _, err <span style="color:#ff79c6">:=</span> clientConn.<span style="color:#50fa7b">Write</span>(cached)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> err
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="수정">수정</h3>
<p>XOR 기반 네임스페이스를 캐시 키에 혼합한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// internal/cache/cache.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">const</span> (
</span></span><span style="display:flex;"><span>    NSProxyWire <span style="color:#8be9fd">uint64</span> = <span style="color:#bd93f9">0</span>                    <span style="color:#6272a4">// 기본값 (proxy simple query)</span>
</span></span><span style="display:flex;"><span>    NSDataAPI   <span style="color:#8be9fd">uint64</span> = <span style="color:#bd93f9">0xa5a5a5a5a5a5a5a5</span>  <span style="color:#6272a4">// Data API JSON</span>
</span></span><span style="display:flex;"><span>    NSExtended  <span style="color:#8be9fd">uint64</span> = <span style="color:#bd93f9">0x5a5a5a5a5a5a5a5a</span>  <span style="color:#6272a4">// extended query wire</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">WithNamespace</span>(key <span style="color:#8be9fd">uint64</span>, ns <span style="color:#8be9fd">uint64</span>) <span style="color:#8be9fd">uint64</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> key ^ ns
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>각 경로에서 캐시 GET/SET 시 namespace를 적용:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// Data API (handler.go)</span>
</span></span><span style="display:flex;"><span>key <span style="color:#ff79c6">:=</span> cache.<span style="color:#50fa7b">WithNamespace</span>(s.<span style="color:#50fa7b">cacheKeyParsed</span>(sql, pq), cache.NSDataAPI)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Extended query (query_extended.go)</span>
</span></span><span style="display:flex;"><span>key <span style="color:#ff79c6">:=</span> cache.<span style="color:#50fa7b">WithNamespace</span>(s.<span style="color:#50fa7b">cacheKey</span>(query), cache.NSExtended)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Proxy simple read — NS=0이므로 기존 코드 변경 없음</span>
</span></span></code></pre></div><p>동일 SQL이라도 경로별로 독립된 캐시 공간을 갖게 되어, 포맷 충돌이 원천 차단된다.</p>
<h3 id="왜-xor인가">왜 XOR인가?</h3>
<p>namespace 상수가 0이면 기존 키와 동일 (backward compatible), 0이 아니면 완전히 다른 키 공간으로 분리된다. 해시 함수를 다시 돌리는 것보다 비용이 제로에 가깝고, 구현도 한 줄이다. 두 namespace 상수의 비트 패턴이 서로 다르면 충돌 확률은 무시할 수 있다.</p>
<hr>
<h2 id="버그-2-data-api-읽기-캐시-무효화-실종-높음">버그 2: Data API 읽기 캐시 무효화 실종 (높음)</h2>
<h3 id="증상-1">증상</h3>
<p>Data API로 캐시된 SELECT 결과가 같은 테이블에 write가 와도 TTL까지 stale하게 유지된다.</p>
<h3 id="원인-1">원인</h3>
<p><code>executeRead</code>에서 캐시 저장 시 <code>extractTablesParsed</code>를 호출하는데, 이 함수는 <strong>write table extractor</strong>를 사용한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// handler.go:283 — 수정 전</span>
</span></span><span style="display:flex;"><span>tables <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">extractTablesParsed</span>(sql, pq)
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// → ExtractTablesASTWithTree → extractTablesFromTree → extractWriteTables</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// → INSERT/UPDATE/DELETE 대상 테이블만 수집</span>
</span></span></code></pre></div><p>SELECT 쿼리에 대해 write table extractor는 당연히 빈 배열을 반환한다. 결과적으로 <code>queryCache.Set(key, data, [])</code> — 테이블 인덱스가 비어 있으므로 <code>InvalidateTable(&quot;users&quot;)</code>가 호출되어도 이 엔트리를 찾지 못한다.</p>
<p>반면 proxy 경로(<code>query_read.go:137</code>)는 올바르게 <code>extractReadQueryTables</code>(FROM/JOIN 테이블 수집)를 사용하고 있었다. Data API만 잘못된 함수를 호출하고 있었다.</p>
<h3 id="수정-1">수정</h3>
<p>Data API에 <code>extractReadTablesParsed</code> 메서드를 추가하고, <code>executeRead</code>에서 사용:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// handler.go — 수정 후</span>
</span></span><span style="display:flex;"><span>tables <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">extractReadTablesParsed</span>(sql, pq)
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// → ExtractReadTablesASTWithTree → extractReadTablesFromTree</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// → WalkNodes로 모든 RangeVar (FROM/JOIN) 수집</span>
</span></span></code></pre></div><p>이를 위해 <code>router/parser_ast.go</code>에도 <code>ExtractReadTablesASTWithTree</code>와 <code>extractReadTablesFromTree</code>를 추가했다. 기존 <code>ExtractReadTablesAST</code>도 <code>extractReadTablesFromTree</code>를 내부 호출하도록 리팩토링하여 코드 중복을 제거했다.</p>
<hr>
<h2 id="버그-3-hot-reload가-balancer-상태를-초기화-중간">버그 3: Hot reload가 balancer 상태를 초기화 (중간)</h2>
<h3 id="증상-2">증상</h3>
<p>설정 reload 직후 (1) 직전에 unhealthy였던 reader가 잠깐 선택 대상이 되고, (2) causal consistency read가 writer fallback으로 몰린다.</p>
<h3 id="원인-2">원인</h3>
<p><code>UpdateBackends</code>가 매번 새 <code>Backend</code> 구조체를 생성하며 <code>healthy=true</code>, <code>replayLSN=0</code>으로 초기화한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// balancer.go:105 — 수정 전</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (r <span style="color:#ff79c6">*</span>RoundRobin) <span style="color:#50fa7b">UpdateBackends</span>(addrs []<span style="color:#8be9fd">string</span>) {
</span></span><span style="display:flex;"><span>    backends <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>([]<span style="color:#ff79c6">*</span>Backend, <span style="color:#8be9fd;font-style:italic">len</span>(addrs))
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i, addr <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> addrs {
</span></span><span style="display:flex;"><span>        b <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">&amp;</span>Backend{Addr: addr}
</span></span><span style="display:flex;"><span>        b.healthy.<span style="color:#50fa7b">Store</span>(<span style="color:#ff79c6">true</span>)  <span style="color:#6272a4">// 죽어있던 reader도 healthy로 리셋</span>
</span></span><span style="display:flex;"><span>        backends[i] = b        <span style="color:#6272a4">// replayLSN = 0</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    r.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>    r.backends = backends
</span></span><span style="display:flex;"><span>    r.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>replayLSN=0</code>이 되면 <code>NextWithLSN(minLSN)</code>에서 모든 reader가 LSN 조건을 만족하지 못하게 되어, 다음 LSN poll 주기(1초)까지 빈 문자열 반환 → writer fallback이 발생한다.</p>
<h3 id="수정-2">수정</h3>
<p>기존 backend 맵을 빌드하여 addr가 동일한 경우 상태를 복사한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// balancer.go — 수정 후</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (r <span style="color:#ff79c6">*</span>RoundRobin) <span style="color:#50fa7b">UpdateBackends</span>(addrs []<span style="color:#8be9fd">string</span>) {
</span></span><span style="display:flex;"><span>    r.mu.<span style="color:#50fa7b">RLock</span>()
</span></span><span style="display:flex;"><span>    oldMap <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>(<span style="color:#8be9fd;font-style:italic">map</span>[<span style="color:#8be9fd">string</span>]<span style="color:#ff79c6">*</span>Backend, <span style="color:#8be9fd;font-style:italic">len</span>(r.backends))
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, b <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> r.backends {
</span></span><span style="display:flex;"><span>        oldMap[b.Addr] = b
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    r.mu.<span style="color:#50fa7b">RUnlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    backends <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>([]<span style="color:#ff79c6">*</span>Backend, <span style="color:#8be9fd;font-style:italic">len</span>(addrs))
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i, addr <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> addrs {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> old, ok <span style="color:#ff79c6">:=</span> oldMap[addr]; ok {
</span></span><span style="display:flex;"><span>            backends[i] = old  <span style="color:#6272a4">// 기존 상태 보존</span>
</span></span><span style="display:flex;"><span>        } <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>            b <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">&amp;</span>Backend{Addr: addr}
</span></span><span style="display:flex;"><span>            b.healthy.<span style="color:#50fa7b">Store</span>(<span style="color:#ff79c6">true</span>)
</span></span><span style="display:flex;"><span>            backends[i] = b
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    r.mu.<span style="color:#50fa7b">Lock</span>()
</span></span><span style="display:flex;"><span>    r.backends = backends
</span></span><span style="display:flex;"><span>    r.mu.<span style="color:#50fa7b">Unlock</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="버그-4-http-서버에-수명주기-관리-없음-중간">버그 4: HTTP 서버에 수명주기 관리 없음 (중간)</h2>
<h3 id="증상-3">증상</h3>
<ul>
<li>포트 충돌 시 프로세스가 부분 성공 상태로 실행됨 (proxy는 뜨지만 admin은 안 뜸)</li>
<li>종료 시 in-flight HTTP 요청이 drain되지 않음</li>
</ul>
<h3 id="원인-3">원인</h3>
<p>세 HTTP 서버(metrics, admin, data_api)가 모두 goroutine fire-and-forget으로 시작된다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// main.go — 수정 전</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> http.<span style="color:#50fa7b">ListenAndServe</span>(addr, mux); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> <span style="color:#ff79c6">&amp;&amp;</span> err <span style="color:#ff79c6">!=</span> http.ErrServerClosed {
</span></span><span style="display:flex;"><span>        slog.<span style="color:#50fa7b">Error</span>(<span style="color:#f1fa8c">&#34;server error&#34;</span>, <span style="color:#f1fa8c">&#34;error&#34;</span>, err)  <span style="color:#6272a4">// 로그만 남기고 끝</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}()
</span></span></code></pre></div><p><code>http.ListenAndServe</code>는 <code>net.Listen</code> + <code>http.Serve</code>를 한 번에 수행하므로, bind 실패가 goroutine 내부에서만 관찰된다. main goroutine은 이 에러를 전혀 모른 채 <code>srv.Start(ctx)</code>로 진행한다.</p>
<p>또한 <code>http.ListenAndServe</code>로 생성된 서버는 <code>*http.Server</code> 핸들이 외부에 노출되지 않아 <code>Shutdown()</code>을 호출할 방법이 없다.</p>
<h3 id="수정-3">수정</h3>
<ol>
<li><strong>Eager bind</strong>: <code>net.Listen</code>으로 먼저 바인딩하여 포트 충돌 시 <code>run()</code> 자체가 에러를 반환</li>
<li><strong><code>*http.Server</code> 노출</strong>: admin, dataapi에 <code>HTTPServer()</code> 메서드 추가</li>
<li><strong>Graceful shutdown</strong>: context 취소 시 모든 HTTP 서버에 <code>Shutdown(5s)</code> 호출</li>
<li><strong>Runtime 에러 전파</strong>: <code>httpErrCh</code>로 런타임 에러 수신 → context cancel</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// main.go — 수정 후 (핵심)</span>
</span></span><span style="display:flex;"><span>ln, err <span style="color:#ff79c6">:=</span> net.<span style="color:#50fa7b">Listen</span>(<span style="color:#f1fa8c">&#34;tcp&#34;</span>, cfg.Admin.Listen)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;admin server bind %s: %w&#34;</span>, cfg.Admin.Listen, err)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>httpServers = <span style="color:#8be9fd;font-style:italic">append</span>(httpServers, adminHTTP)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> adminHTTP.<span style="color:#50fa7b">Serve</span>(ln); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> <span style="color:#ff79c6">&amp;&amp;</span> err <span style="color:#ff79c6">!=</span> http.ErrServerClosed {
</span></span><span style="display:flex;"><span>        httpErrCh <span style="color:#ff79c6">&lt;-</span> fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;admin server: %w&#34;</span>, err)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// Graceful shutdown</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">&lt;-</span>ctx.<span style="color:#50fa7b">Done</span>()
</span></span><span style="display:flex;"><span>    shutdownCtx, cancel <span style="color:#ff79c6">:=</span> context.<span style="color:#50fa7b">WithTimeout</span>(context.<span style="color:#50fa7b">Background</span>(), <span style="color:#bd93f9">5</span><span style="color:#ff79c6">*</span>time.Second)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">defer</span> <span style="color:#50fa7b">cancel</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, s <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> httpServers {
</span></span><span style="display:flex;"><span>        s.<span style="color:#50fa7b">Shutdown</span>(shutdownCtx)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}()
</span></span></code></pre></div><hr>
<h2 id="버그-5-read-cache-path-ast-재파싱-중간">버그 5: read cache path AST 재파싱 (중간)</h2>
<h3 id="증상-4">증상</h3>
<p>성능 문제. AST mode + cache-enabled read 트래픽에서 불필요한 재파싱이 발생한다.</p>
<h3 id="원인-4">원인</h3>
<p><code>handleReadQueryTraced</code>에서 캐시 키 생성에는 <code>pq</code>(pre-parsed tree)를 사용하면서, 테이블 추출에는 raw SQL을 다시 파싱하고 있었다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// query_read.go — 수정 전</span>
</span></span><span style="display:flex;"><span>key <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">cacheKeyParsed</span>(query, pq)         <span style="color:#6272a4">// ✓ pq 재활용</span>
</span></span><span style="display:flex;"><span>tables <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">extractReadQueryTables</span>(query)  <span style="color:#6272a4">// ✗ 내부에서 ParseSQL(query) 다시 호출</span>
</span></span></code></pre></div><p>벤치마크에서 <code>SemanticCacheKey</code>(재파싱)가 32.6μs, <code>SemanticCacheKeyWithTree</code>(tree 재활용)가 16.0μs였으므로, 테이블 추출도 같은 수준의 절감을 기대할 수 있다.</p>
<h3 id="수정-4">수정</h3>
<p><code>extractReadQueryTablesParsed</code> 메서드를 추가하고 <code>handleReadQueryTraced</code>에서 사용:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// helpers.go</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">extractReadQueryTablesParsed</span>(query <span style="color:#8be9fd">string</span>, pq <span style="color:#ff79c6">*</span>router.ParsedQuery) []<span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.<span style="color:#50fa7b">getConfig</span>().Routing.ASTParser <span style="color:#ff79c6">&amp;&amp;</span> pq <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> router.<span style="color:#50fa7b">ExtractReadTablesASTWithTree</span>(pq)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> s.<span style="color:#50fa7b">extractReadQueryTables</span>(query)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// query_read.go — 수정 후</span>
</span></span><span style="display:flex;"><span>tables <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">extractReadQueryTablesParsed</span>(query, pq)  <span style="color:#6272a4">// ✓ pq 재활용</span>
</span></span></code></pre></div><hr>
<h2 id="교훈">교훈</h2>
<h3 id="1-공유-캐시의-키-공간을-신뢰하지-말-것">1. 공유 캐시의 키 공간을 신뢰하지 말 것</h3>
<p>여러 경로가 하나의 캐시를 공유할 때, &ldquo;같은 SQL = 같은 키 = 같은 응답&quot;이라는 가정은 <strong>응답 포맷이 동일할 때만</strong> 성립한다. 포맷이 다르면 키 공간을 분리해야 한다. 이건 HTTP 캐시에서 <code>Vary</code> 헤더가 하는 역할과 정확히 같다.</p>
<h3 id="2-추출-함수의-이름이-비슷하면-잘못-쓰기-쉽다">2. 추출 함수의 이름이 비슷하면 잘못 쓰기 쉽다</h3>
<p><code>extractTables</code>와 <code>extractReadTables</code>는 이름만 보면 차이가 불분명하다. 전자는 write 대상 테이블, 후자는 FROM/JOIN 테이블을 수집한다. Data API 개발 시 &ldquo;테이블 추출이 필요하니까 extractTables를 쓰자&quot;라고 생각한 것이 버그의 원인이었다.</p>
<h3 id="3-fire-and-forget-goroutine은-에러를-삼킨다">3. fire-and-forget goroutine은 에러를 삼킨다</h3>
<p><code>go func() { if err := serve(); ... }()</code>는 편리하지만, main goroutine에 에러를 전파할 수 없다. 특히 서버 바인딩처럼 &ldquo;실패하면 프로세스를 올리면 안 되는&rdquo; 작업은 goroutine 시작 전에 eager bind로 검증해야 한다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 QA 4차에서 가장 임팩트가 컸던 건 역시 캐시 포맷 충돌(버그 1)이다. 프로토콜 레벨 corruption은 디버깅이 극도로 어려운데, 증상이 &ldquo;가끔 연결이 끊긴다&rdquo; 수준이라 재현도 쉽지 않다. 캐시 활성화 + Data API 동시 사용이라는 특정 조건에서만 발생하기 때문이다.</p>
<p>5건의 수정으로 pgmux의 캐시 안정성, 운영 안정성, 성능이 한 단계 더 개선되었다. 다음 단계는 로드맵에 따라 Phase 20+ 고도화 작업으로 넘어갈 예정이다.</p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (15) - 보안 QA와 취약점 수정</title><link>https://jyukki.com/posts/2026-03-11-pgmux-15-security-qa-hardening/</link><pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-11-pgmux-15-security-qa-hardening/</guid><description>QA 과정에서 발견된 4건의 보안 취약점 — 캐시 충돌로 인한 개인정보 유출, 무한 재귀, 방화벽 우회, 힌트 주입 — 을 분석하고 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 글에서 pg_query_go를 도입하여 AST 기반 쿼리 분류, 쿼리 방화벽, 시맨틱 캐시 키를 구현했다. 코드를 작성하고 단위 테스트를 통과시켰을 때는 꽤 만족스러웠다. 그런데 QA에서 올라온 리포트를 보고 식은땀이 났다.</p>
<p><strong>Critical 2건, Major 2건. 총 4건의 보안 취약점.</strong></p>
<p>한 건은 다른 유저의 개인정보가 노출되는 사고, 한 건은 설정 하나로 서버가 죽는 버그, 나머지 두 건은 보안 기능 자체를 우회하는 취약점이었다. 코드를 짤 때는 &ldquo;잘 돌아간다&quot;에 집중하느라 놓친 것들이었다. 이번 글에서는 각 취약점의 원인을 분석하고 어떻게 수정했는지 정리한다.</p>
<hr>
<h2 id="1-캐시-충돌로-인한-개인정보-유출-critical">1. 캐시 충돌로 인한 개인정보 유출 (CRITICAL)</h2>
<h3 id="문제">문제</h3>
<p>시맨틱 캐시 키에 <code>pg_query.FingerprintToUInt64</code>를 사용했다. 이 함수는 쿼리의 <strong>구조적 동등성</strong>을 판단하기 위해 리터럴 값을 모두 제거한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// 수정 전</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">SemanticCacheKey</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">uint64</span> {
</span></span><span style="display:flex;"><span>    fp, _ <span style="color:#ff79c6">:=</span> pg_query.<span style="color:#50fa7b">FingerprintToUInt64</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> fp  <span style="color:#6272a4">// 리터럴 값이 제거된 구조 해시</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>문제는 이 특성이 캐시 키로는 치명적이라는 점이다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#6272a4">-- A 유저
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span> <span style="color:#ff79c6">FROM</span> payment_logs <span style="color:#ff79c6">WHERE</span> user_id <span style="color:#ff79c6">=</span> <span style="color:#bd93f9">1</span>;
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- B 유저
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span> <span style="color:#ff79c6">FROM</span> payment_logs <span style="color:#ff79c6">WHERE</span> user_id <span style="color:#ff79c6">=</span> <span style="color:#bd93f9">2</span>;
</span></span></code></pre></div><p>두 쿼리는 구조가 같으므로 <strong>동일한 캐시 키</strong>가 생성된다. A 유저의 결제 내역이 캐시에 저장된 상태에서 B 유저가 같은 쿼리를 보내면, B 유저에게 A 유저의 결제 내역이 그대로 반환된다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>Fingerprint의 용도를 잘못 이해한 것이 근본 원인이다. Fingerprint는 <strong>쿼리 통계 집계</strong>를 위해 설계되었다. &ldquo;이 구조의 쿼리가 몇 번 실행되었는가&quot;를 추적하는 용도이지, 캐시 키로 쓰라고 만든 것이 아니다. pg_query의 공식 문서에도 이 점이 명시되어 있다.</p>
<p>시맨틱 캐시 키가 원했던 것은 &ldquo;공백과 대소문자가 달라도 같은 쿼리면 같은 키&quot;였는데, Fingerprint는 거기에 &ldquo;리터럴 값이 달라도 같은 키&quot;까지 포함해버렸다.</p>
<h3 id="수정">수정</h3>
<p><code>FingerprintToUInt64</code> 대신 <code>Parse</code> + <code>Deparse</code> 조합을 사용했다. <code>Deparse</code>는 AST를 다시 SQL 문자열로 변환하는데, 이 과정에서 공백과 대소문자가 정규화되면서 리터럴 값은 그대로 보존된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// 수정 후</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">SemanticCacheKey</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">uint64</span> {
</span></span><span style="display:flex;"><span>    tree, err <span style="color:#ff79c6">:=</span> pg_query.<span style="color:#50fa7b">Parse</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">CacheKey</span>(query)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    deparsed, err <span style="color:#ff79c6">:=</span> pg_query.<span style="color:#50fa7b">Deparse</span>(tree)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">CacheKey</span>(query)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    h <span style="color:#ff79c6">:=</span> fnv.<span style="color:#50fa7b">New64a</span>()
</span></span><span style="display:flex;"><span>    h.<span style="color:#50fa7b">Write</span>([]<span style="color:#8be9fd;font-style:italic">byte</span>(deparsed))
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> h.<span style="color:#50fa7b">Sum64</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>이제 <code>WHERE user_id = 1</code>과 <code>WHERE user_id = 2</code>는 다른 캐시 키를 생성하고, <code>SELECT * FROM users</code>와 <code>select  *  from  users</code>는 같은 캐시 키를 생성한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">TestSemanticCacheCollision</span>(t <span style="color:#ff79c6">*</span>testing.T) {
</span></span><span style="display:flex;"><span>    key1 <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">SemanticCacheKey</span>(<span style="color:#f1fa8c">&#34;SELECT * FROM users WHERE id = 1&#34;</span>)
</span></span><span style="display:flex;"><span>    key2 <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">SemanticCacheKey</span>(<span style="color:#f1fa8c">&#34;SELECT * FROM users WHERE id = 2&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> key1 <span style="color:#ff79c6">==</span> key2 {
</span></span><span style="display:flex;"><span>        t.<span style="color:#50fa7b">Error</span>(<span style="color:#f1fa8c">&#34;different literals must produce different cache keys&#34;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">TestSemanticCacheEquivalence</span>(t <span style="color:#ff79c6">*</span>testing.T) {
</span></span><span style="display:flex;"><span>    key1 <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">SemanticCacheKey</span>(<span style="color:#f1fa8c">&#34;SELECT * FROM users WHERE id = 1&#34;</span>)
</span></span><span style="display:flex;"><span>    key2 <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">SemanticCacheKey</span>(<span style="color:#f1fa8c">&#34;select  *  from  users  where  id  =  1&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> key1 <span style="color:#ff79c6">!=</span> key2 {
</span></span><span style="display:flex;"><span>        t.<span style="color:#50fa7b">Error</span>(<span style="color:#f1fa8c">&#34;equivalent queries should produce the same cache key&#34;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="2-설정-변경으로-서버-즉사-critical">2. 설정 변경으로 서버 즉사 (CRITICAL)</h2>
<h3 id="문제-1">문제</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">cacheKey</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">uint64</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.cfg.Routing.ASTParser {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> cache.<span style="color:#50fa7b">SemanticCacheKey</span>(query)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> s.<span style="color:#50fa7b">cacheKey</span>(query) <span style="color:#6272a4">// 자기 자신을 호출!</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>ASTParser = true</code>일 때는 정상 동작하지만, <code>false</code>로 설정하면 <code>s.cacheKey</code>가 자기 자신을 무한 재귀 호출한다. 단 1건의 쿼리만 들어와도 즉시 stack overflow로 서버가 패닉한다.</p>
<h3 id="원인-분석-1">원인 분석</h3>
<p>단순 오타다. <code>cache.CacheKey(query)</code>를 호출해야 하는데 <code>s.cacheKey(query)</code>를 타이핑했다. Go에서 메서드 이름과 패키지 함수 이름이 같을 때 발생하기 쉬운 실수인데, 컴파일러가 잡아주지 못한다. 타입이 같고 시그니처가 호환되기 때문이다.</p>
<h3 id="수정-1">수정</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Server) <span style="color:#50fa7b">cacheKey</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">uint64</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> s.cfg.Routing.ASTParser {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> cache.<span style="color:#50fa7b">SemanticCacheKey</span>(query)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> cache.<span style="color:#50fa7b">CacheKey</span>(query) <span style="color:#6272a4">// 패키지 함수 호출</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>한 줄 수정이지만, <code>ASTParser = false</code>로 배포했을 때 서비스 전면 장애로 이어질 수 있는 버그였다.</p>
<h3 id="교훈">교훈</h3>
<p>이런 유형의 버그를 잡으려면:</p>
<ul>
<li><code>go vet</code>이나 정적 분석 도구(<code>staticcheck</code>)로 무한 재귀를 탐지할 수 있다</li>
<li>두 가지 코드 경로(AST on/off)에 대해 각각 테스트를 작성해야 한다</li>
<li>메서드 이름과 패키지 함수 이름이 겹치지 않게 네이밍하는 것이 안전하다</li>
</ul>
<hr>
<h2 id="3-cte로-우회되는-쿼리-방화벽-major">3. CTE로 우회되는 쿼리 방화벽 (MAJOR)</h2>
<h3 id="문제-2">문제</h3>
<p>방화벽의 <code>checkNode</code> 함수가 top-level 노드만 검사하고 있었다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">checkNode</span>(node <span style="color:#ff79c6">*</span>pg_query.Node, cfg FirewallConfig) FirewallResult {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">switch</span> n <span style="color:#ff79c6">:=</span> node.<span style="color:#50fa7b">GetNode</span>().(<span style="color:#8be9fd;font-style:italic">type</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_DeleteStmt:
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// WHERE 없는 DELETE 차단</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_UpdateStmt:
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// WHERE 없는 UPDATE 차단</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_DropStmt:
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// DROP 차단</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_TruncateStmt:
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// TRUNCATE 차단</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// SelectStmt는? → 검사 안 함!</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>공격자가 CTE를 사용하면 방화벽을 완전히 우회할 수 있다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">WITH</span> bypass <span style="color:#ff79c6">AS</span> (<span style="color:#ff79c6">DELETE</span> <span style="color:#ff79c6">FROM</span> users) <span style="color:#ff79c6">SELECT</span> <span style="color:#bd93f9">1</span>;
</span></span></code></pre></div><p>이 쿼리의 루트 노드는 <code>SelectStmt</code>이고, <code>checkNode</code>에 <code>SelectStmt</code> 케이스가 없으므로 방화벽을 그대로 통과한다.</p>
<h3 id="아이러니">아이러니</h3>
<p>재미있는 것은, 쿼리 <strong>분류</strong> 로직(<code>parser_ast.go</code>)에서는 이미 CTE 내부의 write를 감지하고 있었다는 점이다. <code>isWriteNode</code>에 <code>SelectStmt</code> → <code>WithClause</code> → <code>CommonTableExpr</code> 재귀 검사가 구현되어 있었다. 방화벽에만 같은 로직을 빠뜨린 것이다.</p>
<h3 id="수정-2">수정</h3>
<p><code>checkNode</code>에 <code>SelectStmt</code> 케이스를 추가하여 CTE 내부를 재귀적으로 검사한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">case</span> <span style="color:#ff79c6">*</span>pg_query.Node_SelectStmt:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> wc <span style="color:#ff79c6">:=</span> n.SelectStmt.<span style="color:#50fa7b">GetWithClause</span>(); wc <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">for</span> _, cte <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> wc.<span style="color:#50fa7b">GetCtes</span>() {
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> ce <span style="color:#ff79c6">:=</span> cte.<span style="color:#50fa7b">GetCommonTableExpr</span>(); ce <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">if</span> q <span style="color:#ff79c6">:=</span> ce.<span style="color:#50fa7b">GetCtequery</span>(); q <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>                    <span style="color:#ff79c6">if</span> result <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">checkNode</span>(q, cfg); result.Blocked {
</span></span><span style="display:flex;"><span>                        <span style="color:#ff79c6">return</span> result
</span></span><span style="display:flex;"><span>                    }
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div><hr>
<h2 id="4-dollar-quoting으로-우회되는-힌트-추출-major">4. Dollar Quoting으로 우회되는 힌트 추출 (MAJOR)</h2>
<h3 id="문제-3">문제</h3>
<p>AST 파서를 도입했지만, 라우팅 힌트를 추출하는 부분은 여전히 문자열 기반의 레거시 코드를 사용하고 있었다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">ClassifyAST</span>(query <span style="color:#8be9fd">string</span>) QueryType {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// AST 모드인데 문자열 파서의 extractHint를 사용!</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> hint <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">extractHint</span>(query); hint <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#34;&#34;</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>extractHint</code>는 <code>stripStringLiterals</code>라는 문자열 치환 함수로 문자열 리터럴을 제거한 뒤 주석을 파싱한다. 이 방식은 Dollar Quoting 등의 edge case에서 우회될 수 있다.</p>
<h3 id="수정-3">수정</h3>
<p>pg_query의 렉서(<code>Scan</code>)를 사용하여 실제 SQL 주석만 정확히 추출하는 <code>extractHintAST</code>를 구현했다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">extractHintAST</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    result, err <span style="color:#ff79c6">:=</span> pg_query.<span style="color:#50fa7b">Scan</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">extractHint</span>(query) <span style="color:#6272a4">// fallback</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, token <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> result.<span style="color:#50fa7b">GetTokens</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> token.<span style="color:#50fa7b">GetToken</span>() <span style="color:#ff79c6">==</span> pg_query.Token_C_COMMENT {
</span></span><span style="display:flex;"><span>            comment <span style="color:#ff79c6">:=</span> query[token.<span style="color:#50fa7b">GetStart</span>():token.<span style="color:#50fa7b">GetEnd</span>()]
</span></span><span style="display:flex;"><span>            matches <span style="color:#ff79c6">:=</span> hintRegex.<span style="color:#50fa7b">FindStringSubmatch</span>(comment)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> <span style="color:#8be9fd;font-style:italic">len</span>(matches) <span style="color:#ff79c6">&gt;=</span> <span style="color:#bd93f9">2</span> {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">return</span> matches[<span style="color:#bd93f9">1</span>]
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#f1fa8c">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>pg_query.Scan</code>은 PostgreSQL의 렉서를 직접 사용하므로 Dollar Quoting, 문자열 리터럴, 중첩 주석 등을 정확히 처리한다. 문자열 안에 <code>/* route:writer */</code>가 있어도 주석 토큰이 아닌 문자열 토큰으로 분류되므로 힌트로 인식되지 않는다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">TestClassifyAST_DollarQuotingHintInjection</span>(t <span style="color:#ff79c6">*</span>testing.T) {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Dollar-quoted 문자열 안의 힌트는 무시되어야 함</span>
</span></span><span style="display:flex;"><span>    query <span style="color:#ff79c6">:=</span> <span style="color:#f1fa8c">`SELECT $$ /* route:writer */ $$ FROM readonly_table`</span>
</span></span><span style="display:flex;"><span>    result <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">ClassifyAST</span>(query)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> result <span style="color:#ff79c6">!=</span> QueryRead {
</span></span><span style="display:flex;"><span>        t.<span style="color:#50fa7b">Error</span>(<span style="color:#f1fa8c">&#34;hint inside dollar quote should not affect routing&#34;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="배운-점">배운 점</h2>
<h3 id="기능의-용도를-정확히-이해하기">기능의 용도를 정확히 이해하기</h3>
<p>Fingerprint 캐시 충돌은 라이브러리 함수의 용도를 제대로 확인하지 않은 데서 비롯됐다. <code>FingerprintToUInt64</code>라는 이름이 &ldquo;쿼리의 고유 식별자를 만들어준다&quot;처럼 보여서 캐시 키로 쓰기 딱 좋다고 생각했지만, 실제로는 리터럴을 제거하는 통계용 함수였다. 라이브러리를 사용할 때는 API 이름만 보고 추측하지 말고 문서와 소스를 반드시 확인해야 한다.</p>
<h3 id="모든-코드-경로에-대한-테스트">모든 코드 경로에 대한 테스트</h3>
<p>무한 재귀 버그는 <code>ASTParser = true</code>인 기본 경로만 테스트하고 <code>false</code> 경로를 놓쳐서 발생했다. 설정 분기가 있으면 양쪽 모두에 대한 테스트를 작성해야 한다. 특히 feature flag처럼 런타임에 동작이 바뀌는 코드는 더욱 그렇다.</p>
<h3 id="보안-기능은-일관되게-적용하기">보안 기능은 일관되게 적용하기</h3>
<p>방화벽 CTE 우회와 힌트 주입은 &ldquo;새로운 보안 로직을 추가했지만 기존 코드의 일부를 업데이트하지 않은&rdquo; 패턴이다. 쿼리 분류에서는 CTE를 처리하면서 방화벽에서는 빠뜨렸고, AST 파서를 도입하면서 힌트 추출은 레거시 그대로 놔뒀다. 보안 관련 로직을 변경할 때는 같은 패턴이 적용되어야 하는 다른 코드 경로가 없는지 반드시 점검해야 한다.</p>
<h3 id="qa의-가치">QA의 가치</h3>
<p>4건 모두 단위 테스트를 통과한 코드였다. &ldquo;테스트가 통과한다&quot;와 &ldquo;버그가 없다&quot;는 다른 이야기다. 테스트는 작성자가 예상한 시나리오만 커버하고, 공격적인 QA는 작성자가 예상하지 못한 시나리오를 찾아낸다. 특히 보안 관련 코드에서는 &ldquo;정상 동작&quot;이 아닌 &ldquo;우회 가능성&quot;을 중심으로 테스트하는 시각이 필수적이다.</p>
<hr>
<p>프로젝트 소스코드: <a href="https://github.com/jyukki97/pgmux">github.com/jyukki97/pgmux</a></p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (7) - QA 버그 수정과 멀티 인스턴스 스케일링</title><link>https://jyukki.com/posts/2026-03-11-pgmux-7-qa-bugfix-scaling/</link><pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-11-pgmux-7-qa-bugfix-scaling/</guid><description>QA 리뷰에서 발견된 Critical/Major 버그 4건을 수정하고, 프록시 수평 확장을 위한 Redis Pub/Sub 캐시 무효화를 구현한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<blockquote>
<p>&ldquo;테스트가 통과한다&quot;와 &ldquo;프로덕션에서 안전하다&quot;는 완전히 다른 이야기다.</p></blockquote>
<p>6편까지 기능적으로 완성했다고 생각했지만, QA 리뷰에서 운영 시 장애로 직결되는 버그 4건이 발견됐다. 거기에 &ldquo;프록시를 여러 대 띄우면 동작하나?&ldquo;라는 질문도 받았다. 대답은 **&ldquo;캐시 정합성이 깨진다&rdquo;**였다.</p>
<p>이번 편에서 수정한 것:</p>
<ol>
<li>[CRITICAL] OOM — 거대 쿼리 결과의 무한 버퍼링</li>
<li>[CRITICAL] 좀비 헬스체크 — Pool.Close() 후에도 살아있는 고루틴</li>
<li>[MAJOR] 트랜잭션 릭 — 세미콜론 복합 쿼리에서 COMMIT 미감지</li>
<li>[MAJOR] 캐시 무효화 누락 — CTE/다중 테이블 미지원</li>
<li>[SCALING] Redis Pub/Sub 기반 멀티 인스턴스 캐시 무효화</li>
</ol>
<h2 id="-critical-1-oom-위험">🔥 CRITICAL #1: OOM 위험</h2>
<h3 id="문제">문제</h3>
<p><code>relayAndCollect()</code>는 백엔드 응답을 클라이언트에 릴레이하면서 동시에 캐시용 버퍼에 수집한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// Before: 응답 크기에 관계없이 무한정 수집</span>
</span></span><span style="display:flex;"><span>buf = <span style="color:#8be9fd;font-style:italic">append</span>(buf, msgBytes<span style="color:#ff79c6">...</span>)
</span></span></code></pre></div><p><code>SELECT * FROM ten_gigabyte_table</code> → 10GB가 <code>buf</code>에 쌓임 → OOM 패닉.</p>
<p>캐시의 <code>MaxSize</code> 검사는 <code>Set()</code> 내부에서만 이뤄지므로, 그 전에 이미 메모리를 다 잡아먹는다.</p>
<h3 id="수정">수정</h3>
<p>수집 중에 <code>max_result_size</code>를 실시간으로 체크하고, 초과하면 버퍼를 즉시 해제한다. 클라이언트로의 릴레이는 계속한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff79c6">if</span> !oversize {
</span></span><span style="display:flex;"><span>    buf = <span style="color:#8be9fd;font-style:italic">append</span>(buf, msgBytes<span style="color:#ff79c6">...</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> maxSize &gt; <span style="color:#bd93f9">0</span> <span style="color:#ff79c6">&amp;&amp;</span> <span style="color:#8be9fd;font-style:italic">len</span>(buf) &gt; maxSize {
</span></span><span style="display:flex;"><span>        buf = <span style="color:#ff79c6">nil</span>     <span style="color:#6272a4">// 메모리 즉시 해제</span>
</span></span><span style="display:flex;"><span>        oversize = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>relayAndCollect</code>가 <code>nil</code>을 반환하면 호출부에서 캐시 저장을 건너뛴다.</p>
<h2 id="-critical-2-좀비-헬스체크">🔥 CRITICAL #2: 좀비 헬스체크</h2>
<h3 id="문제-1">문제</h3>
<p><code>Pool.Close()</code> 후에도 <code>StartHealthCheck</code>로 시작된 고루틴이 살아있다. 이 고루틴이 <code>healthCheck()</code>를 실행하면:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// healthCheck 내부: Close()로 numOpen=0이 된 상태에서</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">for</span> p.numOpen &lt; p.cfg.MinConnections {
</span></span><span style="display:flex;"><span>    conn, _ <span style="color:#ff79c6">:=</span> p.<span style="color:#50fa7b">newConn</span>()  <span style="color:#6272a4">// 새 DB 커넥션 생성!</span>
</span></span><span style="display:flex;"><span>    p.numOpen<span style="color:#ff79c6">++</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>닫힌 풀에서 좀비 커넥션이 계속 생성된다.</p>
<h3 id="수정-1">수정</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">type</span> Pool <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    done <span style="color:#8be9fd;font-style:italic">chan</span> <span style="color:#8be9fd;font-style:italic">struct</span>{} <span style="color:#6272a4">// Close 시 닫힘</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (p <span style="color:#ff79c6">*</span>Pool) <span style="color:#50fa7b">Close</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> p.closed { <span style="color:#ff79c6">return</span> }
</span></span><span style="display:flex;"><span>    p.closed = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">close</span>(p.done) <span style="color:#6272a4">// 헬스체크 고루틴 종료 신호</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (p <span style="color:#ff79c6">*</span>Pool) <span style="color:#50fa7b">StartHealthCheck</span>(ctx context.Context, interval time.Duration) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">go</span> <span style="color:#8be9fd;font-style:italic">func</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">for</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">select</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">case</span> <span style="color:#ff79c6">&lt;-</span>ctx.<span style="color:#50fa7b">Done</span>(): <span style="color:#ff79c6">return</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">case</span> <span style="color:#ff79c6">&lt;-</span>p.done: <span style="color:#ff79c6">return</span>     <span style="color:#6272a4">// Close 시 즉시 종료</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">case</span> <span style="color:#ff79c6">&lt;-</span>ticker.C: p.<span style="color:#50fa7b">healthCheck</span>()
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }()
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (p <span style="color:#ff79c6">*</span>Pool) <span style="color:#50fa7b">healthCheck</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> p.closed { <span style="color:#ff79c6">return</span> }  <span style="color:#6272a4">// 이중 방어</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="-major-3-트랜잭션-릭">🚨 MAJOR #3: 트랜잭션 릭</h2>
<h3 id="문제-2">문제</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">SELECT</span> <span style="color:#bd93f9">1</span>; <span style="color:#ff79c6">COMMIT</span>;
</span></span></code></pre></div><p>PG의 Simple Query Protocol은 세미콜론으로 구분된 여러 문장을 한 번에 보낼 수 있다. 기존 파서는 첫 번째 키워드만 확인하므로 <code>SELECT</code>로 분류하고, <code>COMMIT</code>을 놓친다. <code>inTransaction</code>이 영원히 <code>true</code>로 남아 모든 후속 쿼리가 writer로 쏠린다.</p>
<h3 id="수정-2">수정</h3>
<p>세미콜론으로 분리해서 모든 문장을 스캔:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">splitStatements</span>(query <span style="color:#8be9fd">string</span>) []<span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// 세미콜론으로 분리, 단 따옴표 내부의 세미콜론은 무시</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// &#34;INSERT INTO t VALUES (&#39;a;b&#39;)&#34; → 1개의 문장</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (s <span style="color:#ff79c6">*</span>Session) <span style="color:#50fa7b">updateTransactionState</span>(query <span style="color:#8be9fd">string</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, stmt <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> <span style="color:#50fa7b">splitStatements</span>(query) {
</span></span><span style="display:flex;"><span>        upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(strings.<span style="color:#50fa7b">TrimSpace</span>(stmt))
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">HasPrefix</span>(upper, <span style="color:#f1fa8c">&#34;BEGIN&#34;</span>) { s.inTransaction = <span style="color:#ff79c6">true</span> }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> strings.<span style="color:#50fa7b">HasPrefix</span>(upper, <span style="color:#f1fa8c">&#34;COMMIT&#34;</span>) { s.inTransaction = <span style="color:#ff79c6">false</span> }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="-major-4-다중-테이블-캐시-무효화">🚨 MAJOR #4: 다중 테이블 캐시 무효화</h2>
<h3 id="문제-3">문제</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">WITH</span> x <span style="color:#ff79c6">AS</span> (<span style="color:#ff79c6">UPDATE</span> users <span style="color:#ff79c6">SET</span> score<span style="color:#ff79c6">=</span><span style="color:#bd93f9">0</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">UPDATE</span> ranking <span style="color:#ff79c6">SET</span> total<span style="color:#ff79c6">=</span><span style="color:#bd93f9">0</span>;
</span></span></code></pre></div><p><code>ExtractTables</code>가 첫 번째 테이블(<code>ranking</code>)만 추출하고 CTE 내부의 <code>users</code>를 놓친다. <code>users</code> 캐시가 무효화되지 않아 stale 데이터를 반환한다.</p>
<h3 id="수정-3">수정</h3>
<ol>
<li>멀티 스테이트먼트: 세미콜론 분리 후 각 문장에서 테이블 추출</li>
<li>CTE: <code>WITH</code> 절 내부를 스캔하여 <code>INSERT INTO</code>, <code>UPDATE</code>, <code>DELETE FROM</code> 뒤의 테이블명 추출</li>
<li><code>Classify</code>도 CTE 내 write 키워드를 감지하도록 확장</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// &#34;WITH x AS (UPDATE users ...) UPDATE ranking ...&#34; → [&#34;users&#34;, &#34;ranking&#34;]</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">extractCTETables</span>(query <span style="color:#8be9fd">string</span>) []<span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// INSERT INTO, UPDATE, DELETE FROM 키워드를 모두 찾아서 테이블명 추출</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="-멀티-인스턴스-스케일링">🌐 멀티 인스턴스 스케일링</h2>
<h3 id="문제-4">문제</h3>
<p>프록시를 LB 뒤에 여러 대 띄우면:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>Proxy A: SELECT * FROM users → 캐시 저장
</span></span><span style="display:flex;"><span>Proxy B: UPDATE users SET ... → Proxy B 캐시만 무효화
</span></span><span style="display:flex;"><span>Proxy A: SELECT * FROM users → stale 캐시 반환 ❌
</span></span></code></pre></div><h3 id="해결-redis-pubsub-캐시-무효화-브로드캐스트">해결: Redis Pub/Sub 캐시 무효화 브로드캐스트</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>┌─────────┐     ┌─────────┐     ┌─────────┐
</span></span><span style="display:flex;"><span>│ Proxy A │     │ Proxy B │     │ Proxy C │
</span></span><span style="display:flex;"><span>│         │     │  WRITE  │     │         │
</span></span><span style="display:flex;"><span>│ Cache ✓ │     │ Cache ✓ │     │ Cache ✓ │
</span></span><span style="display:flex;"><span>└────┬────┘     └────┬────┘     └────┬────┘
</span></span><span style="display:flex;"><span>     │ subscribe     │ publish       │ subscribe
</span></span><span style="display:flex;"><span>     └───────┐  ┌────┘  ┌───────────┘
</span></span><span style="display:flex;"><span>             ▼  ▼       ▼
</span></span><span style="display:flex;"><span>         ┌──────────────────┐
</span></span><span style="display:flex;"><span>         │  Redis Pub/Sub   │
</span></span><span style="display:flex;"><span>         │  channel:        │
</span></span><span style="display:flex;"><span>         │  pgmux:invalidate
</span></span><span style="display:flex;"><span>         └──────────────────┘
</span></span></code></pre></div><ol>
<li><strong>Proxy B</strong>에서 쓰기 발생 → 로컬 캐시 무효화 + Redis에 <code>&quot;users&quot;</code> publish</li>
<li><strong>Proxy A, C</strong>가 subscribe 중 → <code>&quot;users&quot;</code> 수신 → 로컬 캐시 무효화</li>
<li>Full flush는 <code>&quot;*&quot;</code> 메시지로 브로드캐스트</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">cache</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">enabled</span>: <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">invalidation</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">mode</span>: <span style="color:#f1fa8c">&#34;pubsub&#34;</span>           <span style="color:#6272a4"># &#34;local&#34; or &#34;pubsub&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">redis_addr</span>: <span style="color:#f1fa8c">&#34;redis:6379&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">channel</span>: <span style="color:#f1fa8c">&#34;pgmux:invalidate&#34;</span>
</span></span></code></pre></div><p><code>mode: &quot;local&quot;</code>이면 기존처럼 로컬-only로 동작한다 (하위 호환).</p>
<h2 id="운영-적용-체크포인트">운영 적용 체크포인트</h2>
<p>이번 편의 수정은 단순 버그 패치라기보다, 프록시를 &ldquo;한 대짜리 데모&quot;에서 &ldquo;여러 대로 늘려도 버틸 수 있는 서비스&quot;로 옮기는 작업에 가깝다. 특히 <a href="/posts/2026-03-11-pgmux-4-query-caching/">쿼리 캐싱 편</a>에서 도입한 캐시는 로컬 메모리 기반이라 스케일아웃 순간 정합성 문제가 바로 드러난다. 그래서 운영에서는 <strong>캐시 적중률보다 stale risk를 먼저 측정</strong>해야 한다.</p>
<ul>
<li>거대 결과셋 경로는 <code>max_result_size</code> 초과 건수를 메트릭으로 남긴다.</li>
<li>헬스체크 고루틴은 시작 개수와 종료 개수를 같이 기록해 누수 여부를 본다.</li>
<li>Redis Pub/Sub가 일시 실패하면 전체 flush 또는 짧은 TTL로 자동 폴백되게 설계한다.</li>
<li>복합 쿼리, CTE, 멀티 인스턴스 무효화는 회귀 테스트 세트로 고정한다.</li>
</ul>
<p>이 지점이 정리돼 있어야 이후 <a href="/posts/2026-03-11-pgmux-11-circuit-breaker-ratelimit/">Circuit Breaker와 Rate Limit</a>이나 <a href="/posts/2026-03-11-pgmux-13-lsn-causal-consistency/">Causal Consistency</a> 같은 상위 안정성 기능도 의미를 갖는다. 기본 정합성이 흔들리면 상위 제어 로직은 오히려 장애를 더 복잡하게 만들 수 있기 때문이다.</p>
<h2 id="배운-점">배운 점</h2>
<ol>
<li><strong>테스트 통과 ≠ 프로덕션 안전</strong> — 정상 경로만 테스트하면 엣지 케이스(거대 쿼리, 복합 문장, 좀비 고루틴)를 놓친다</li>
<li><strong>고루틴은 반드시 종료 경로가 있어야 한다</strong> — <code>go func()</code> 하나 띄우면 반드시 대응하는 종료 채널이 필요하다</li>
<li><strong>파서의 한계를 인정하라</strong> — 완벽한 SQL 파서 없이는 CTE, 서브쿼리를 100% 커버할 수 없다. 최선의 노력을 하되, TTL이라는 안전망이 있다</li>
<li><strong>수평 확장은 공유 상태를 제거해야 가능하다</strong> — 인메모리 캐시는 본질적으로 로컬 상태. 멀티 인스턴스를 지원하려면 브로드캐스트 메커니즘이 필수</li>
</ol>
<p>프로젝트 소스코드: <a href="https://github.com/jyukki97/pgmux">github.com/jyukki97/pgmux</a></p>
]]></content:encoded></item><item><title>Go로 PostgreSQL 프록시 만들기 (8) - 보안 취약점 심화 수정</title><link>https://jyukki.com/posts/2026-03-11-pgmux-8-security-hardening/</link><pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-11-pgmux-8-security-hardening/</guid><description>프로토콜 레벨 DoS 공격, SQL 문자열 리터럴을 이용한 힌트 인젝션과 키워드 오탐을 수정한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<blockquote>
<p>&ldquo;정상적인 클라이언트만 온다고 가정하면, 그건 보안이 아니라 희망사항이다.&rdquo;</p></blockquote>
<p>7편에서 기능적 버그를 수정했지만, QA 심화 리뷰에서 <strong>보안 관점</strong>의 취약점 3건이 추가로 발견됐다. 이번에는 악의적인 입력을 전제로 한 공격 시나리오다.</p>
<ol>
<li>[CRITICAL] Memory Bomb DoS — 프로토콜 length 스푸핑으로 OOM</li>
<li>[MAJOR] 힌트 인젝션 — 문자열 리터럴 내 <code>/* route:writer */</code></li>
<li>[MAJOR] 키워드 오탐 — 문자열 리터럴 내 SQL 키워드</li>
</ol>
<h2 id="-critical-memory-bomb-dos">🔥 CRITICAL: Memory Bomb DoS</h2>
<h3 id="문제">문제</h3>
<p>PG wire protocol의 모든 메시지는 <code>[type:1byte][length:4bytes][payload]</code> 구조다. <code>ReadMessage()</code>는 length를 읽고 그만큼 <code>make([]byte, length-4)</code>로 버퍼를 할당한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6272a4">// Before: length에 상한 제한 없음</span>
</span></span><span style="display:flex;"><span>payload <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>([]<span style="color:#8be9fd">byte</span>, length<span style="color:#ff79c6">-</span><span style="color:#bd93f9">4</span>)
</span></span></code></pre></div><p>공격자가 length에 <code>1073741824</code> (1GB)를 보내면?</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>→ make([]byte, 1GB)
</span></span><span style="display:flex;"><span>→ OS가 1GB 메모리 할당
</span></span><span style="display:flex;"><span>→ OOM 패닉 → 프록시 크래시
</span></span></code></pre></div><p><strong>인증 전에도 악용 가능</strong>하다. TCP 연결만 맺으면 첫 메시지에서 바로 공격할 수 있다.</p>
<h3 id="수정">수정</h3>
<p><code>MaxMessageSize</code> 상수(16MB)를 추가하고, <code>make()</code> 전에 검사:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">const</span> MaxMessageSize = <span style="color:#bd93f9">16</span> <span style="color:#ff79c6">*</span> <span style="color:#bd93f9">1024</span> <span style="color:#ff79c6">*</span> <span style="color:#bd93f9">1024</span> <span style="color:#6272a4">// 16MB</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">ReadMessage</span>(r io.Reader) (<span style="color:#ff79c6">*</span>Message, <span style="color:#8be9fd">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... type, length 읽기 ...</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    payloadLen <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">int</span>(length <span style="color:#ff79c6">-</span> <span style="color:#bd93f9">4</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> payloadLen &gt; MaxMessageSize {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">nil</span>, fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;message too large: %d bytes (max %d)&#34;</span>,
</span></span><span style="display:flex;"><span>            payloadLen, MaxMessageSize)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    payload <span style="color:#ff79c6">:=</span> <span style="color:#8be9fd;font-style:italic">make</span>([]<span style="color:#8be9fd">byte</span>, payloadLen)
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>16MB는 PostgreSQL의 기본 <code>max_allowed_packet</code>과 유사한 수준이다. 정상적인 쿼리가 16MB를 넘는 경우는 거의 없고, 넘더라도 프록시가 아닌 직접 연결을 사용하면 된다.</p>
<h3 id="왜-readstartupmessage은-이미-안전한가">왜 ReadStartupMessage은 이미 안전한가?</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">ReadStartupMessage</span>(r io.Reader) (<span style="color:#ff79c6">*</span>Message, <span style="color:#8be9fd">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> length &lt; <span style="color:#bd93f9">4</span> <span style="color:#ff79c6">||</span> length &gt; <span style="color:#bd93f9">10000</span> {  <span style="color:#6272a4">// ← 이미 10KB 제한</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">nil</span>, fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;invalid startup message length: %d&#34;</span>, length)
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div><p>startup 메시지는 처음 만들 때부터 10KB 상한이 있었다. 하지만 이후의 일반 메시지(<code>ReadMessage</code>)에는 적용하지 않았던 것이 빈틈이었다.</p>
<h2 id="-major-힌트-인젝션">🚨 MAJOR: 힌트 인젝션</h2>
<h3 id="문제-1">문제</h3>
<p>프록시는 <code>/* route:writer */</code> 힌트 주석으로 강제 라우팅을 지원한다. 문제는 <code>extractHint()</code>가 <strong>SQL 문자열 리터럴 내부의 힌트도 감지</strong>한다는 것:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span> <span style="color:#ff79c6">FROM</span> users <span style="color:#ff79c6">WHERE</span> note <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;/* route:writer */ trick&#39;</span>
</span></span></code></pre></div><p>정규식이 쿼리 전체를 스캔하므로, 따옴표 안의 <code>/* route:writer */</code>도 매칭된다. 결과적으로 reader 쿼리가 writer로 라우팅된다.</p>
<h3 id="공격-시나리오">공격 시나리오</h3>
<p>악의적인 사용자가 모든 SELECT에 <code>'/* route:writer */'</code> 문자열을 넣으면:</p>
<ul>
<li>모든 읽기 쿼리가 writer(Primary)로 몰림</li>
<li>reader(Replica) 유휴, writer 과부하</li>
<li>사실상 R/W 분산 무효화</li>
</ul>
<h3 id="수정-1">수정</h3>
<p>힌트 검사 전에 문자열 리터럴을 제거하는 <code>stripStringLiterals()</code> 유틸리티를 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">stripStringLiterals</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">var</span> result strings.Builder
</span></span><span style="display:flex;"><span>    inSingle, inDouble <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">false</span>, <span style="color:#ff79c6">false</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> i <span style="color:#ff79c6">:=</span> <span style="color:#bd93f9">0</span>; i &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query); i<span style="color:#ff79c6">++</span> {
</span></span><span style="display:flex;"><span>        ch <span style="color:#ff79c6">:=</span> query[i]
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">switch</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;\&#39;&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inDouble:
</span></span><span style="display:flex;"><span>            result.<span style="color:#50fa7b">WriteByte</span>(ch)
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> inSingle {
</span></span><span style="display:flex;"><span>                <span style="color:#ff79c6">if</span> i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span> &lt; <span style="color:#8be9fd;font-style:italic">len</span>(query) <span style="color:#ff79c6">&amp;&amp;</span> query[i<span style="color:#ff79c6">+</span><span style="color:#bd93f9">1</span>] <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;\&#39;&#39;</span> {
</span></span><span style="display:flex;"><span>                    result.<span style="color:#50fa7b">WriteByte</span>(<span style="color:#f1fa8c">&#39;\&#39;&#39;</span>)
</span></span><span style="display:flex;"><span>                    i<span style="color:#ff79c6">++</span> <span style="color:#6272a4">// escaped quote (&#39;&#39;)</span>
</span></span><span style="display:flex;"><span>                } <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>                    inSingle = <span style="color:#ff79c6">false</span>
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            } <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>                inSingle = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> ch <span style="color:#ff79c6">==</span> <span style="color:#f1fa8c">&#39;&#34;&#39;</span> <span style="color:#ff79c6">&amp;&amp;</span> !inSingle:
</span></span><span style="display:flex;"><span>            result.<span style="color:#50fa7b">WriteByte</span>(ch)
</span></span><span style="display:flex;"><span>            inDouble = !inDouble
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">case</span> inSingle <span style="color:#ff79c6">||</span> inDouble:
</span></span><span style="display:flex;"><span>            <span style="color:#6272a4">// 따옴표 내부 콘텐츠 스킵</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">default</span>:
</span></span><span style="display:flex;"><span>            result.<span style="color:#50fa7b">WriteByte</span>(ch)
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> result.<span style="color:#50fa7b">String</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>적용 전후:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>입력: SELECT * FROM users WHERE note = &#39;/* route:writer */ trick&#39;
</span></span><span style="display:flex;"><span>변환: SELECT * FROM users WHERE note = &#39;&#39;
</span></span><span style="display:flex;"><span>→ 힌트 매칭 실패 → QueryRead ✓
</span></span></code></pre></div><p>PostgreSQL의 escaped quote (<code>''</code>)도 올바르게 처리한다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>입력: SELECT &#39;it&#39;&#39;s fine&#39;
</span></span><span style="display:flex;"><span>변환: SELECT &#39;&#39;&#39;&#39;
</span></span></code></pre></div><h2 id="-major-키워드-오탐">🚨 MAJOR: 키워드 오탐</h2>
<h3 id="문제-2">문제</h3>
<p><code>containsWriteKeyword()</code>와 <code>extractCTETables()</code>는 쿼리 텍스트에서 SQL 키워드를 직접 검색한다. 문자열 리터럴 내부도 예외 없이 스캔하므로:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#6272a4">-- 1) false cache invalidation
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span> <span style="color:#ff79c6">FROM</span> logs <span style="color:#ff79c6">WHERE</span> action <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;INSERT INTO admin_table&#39;</span>
</span></span><span style="display:flex;"><span>→ <span style="color:#f1fa8c">&#34;admin_table&#34;</span> 캐시 무효화 (오탐)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- 2) false table extraction
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">WITH</span> x <span style="color:#ff79c6">AS</span> (<span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span> <span style="color:#ff79c6">FROM</span> a <span style="color:#ff79c6">WHERE</span> b <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;INSERT INTO oops&#39;</span>) <span style="color:#ff79c6">SELECT</span> <span style="color:#bd93f9">1</span>
</span></span><span style="display:flex;"><span>→ <span style="color:#f1fa8c">&#34;oops&#39;)&#34;</span> 테이블 추출 (오탐 <span style="color:#ff79c6">+</span> 파싱 깨짐)
</span></span></code></pre></div><h3 id="실제-영향">실제 영향</h3>
<ul>
<li><strong>캐시 히트율 저하</strong>: 무관한 테이블이 무효화되어 캐시 효과 감소</li>
<li><strong>잘못된 분류</strong>: SELECT 쿼리가 <code>QueryWrite</code>로 분류될 수 있음 (CTE 경로)</li>
<li><strong>메트릭 왜곡</strong>: writer/reader 카운터가 실제와 불일치</li>
</ul>
<h3 id="수정-2">수정</h3>
<p>힌트 인젝션과 동일한 <code>stripStringLiterals()</code>를 적용:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">containsWriteKeyword</span>(query <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(<span style="color:#50fa7b">stripStringLiterals</span>(query))
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... 기존 word boundary 검사 ...</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> <span style="color:#50fa7b">extractCTETables</span>(query <span style="color:#8be9fd">string</span>) []<span style="color:#8be9fd">string</span> {
</span></span><span style="display:flex;"><span>    sanitized <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">stripStringLiterals</span>(query)
</span></span><span style="display:flex;"><span>    upper <span style="color:#ff79c6">:=</span> strings.<span style="color:#50fa7b">ToUpper</span>(sanitized)
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... 기존 keyword 스캔 ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>stripStringLiterals</code>는 따옴표 자체는 유지하고 내용만 제거하므로, 문자열 위치나 길이가 바뀌어도 키워드 검색에는 영향이 없다.</p>
<h2 id="공통-패턴-전처리--분석">공통 패턴: 전처리 → 분석</h2>
<p>세 함수 모두 같은 패턴을 따른다:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>원본 쿼리
</span></span><span style="display:flex;"><span>  ↓ stripStringLiterals()     ← 문자열 리터럴 제거
</span></span><span style="display:flex;"><span>  ↓ extractHint() / containsWriteKeyword() / extractCTETables()
</span></span><span style="display:flex;"><span>  ↓ 분석 결과
</span></span></code></pre></div><p>이 전처리 단계는 <code>splitStatements()</code>의 따옴표 추적 로직과 동일한 원리다. SQL 파서 없이 안전하게 분석하려면 <strong>따옴표 경계를 먼저 처리</strong>해야 한다는 교훈이다.</p>
<h2 id="보안-운영-체크리스트">보안 운영 체크리스트</h2>
<p>이번 수정이 중요한 이유는 세 취약점이 모두 &ldquo;복잡한 해킹 기법&quot;이 아니라 입력 검증 누락에서 출발했다는 점이다. 즉, 공격 난이도보다 <strong>방어 기본기 부족</strong>이 더 큰 문제였다. 운영에서는 아래 항목을 기본 점검표로 두는 편이 안전하다.</p>
<ul>
<li>프로토콜 length, SQL 길이, 결과셋 크기처럼 메모리 할당을 유발하는 값에는 항상 상한을 둔다.</li>
<li>문자열 리터럴, 주석, 식별자 따옴표를 제거하거나 분리한 뒤에만 라우팅 힌트와 키워드 분석을 수행한다.</li>
<li>보안 전처리 유틸리티는 한 함수에 모아 <a href="/posts/2026-03-11-pgmux-10-tls-auth/">TLS와 프론트 인증 편</a>처럼 다른 계층 보안 기능과도 재사용 가능하게 둔다.</li>
<li>애플리케이션 레벨 방어만 믿지 말고, 이후 <a href="/posts/2026-03-11-pgmux-14-ast-parser-firewall/">AST Parser + Firewall</a> 같은 더 강한 구조로 점진적으로 올린다.</li>
</ul>
<p>결국 보안 강화의 핵심은 &ldquo;정상 입력을 더 잘 처리한다&quot;가 아니라, <strong>이상 입력이 와도 안전하게 실패한다</strong>는 보장이다. 프록시는 DB 앞단에 있으므로 한 번의 오판이 라우팅, 캐시, 메트릭 왜곡까지 연쇄적으로 번질 수 있다.</p>
<h2 id="배운-점">배운 점</h2>
<ol>
<li><strong>프로토콜 레벨 방어는 필수</strong> — 네트워크에서 들어오는 데이터는 length 필드까지 포함해서 전부 의심해야 한다. <code>ReadStartupMessage</code>는 방어가 있었지만 <code>ReadMessage</code>에는 없었다.</li>
<li><strong>문자열 리터럴은 SQL 분석의 지뢰</strong> — 완전한 SQL 파서 없이 정규식으로 분석하면, 따옴표 내부가 반드시 문제를 일으킨다. <code>stripStringLiterals</code> 같은 전처리가 최소한의 방어선이다.</li>
<li><strong>공격자 관점으로 리뷰하라</strong> — &ldquo;정상적인 사용자&quot;만 상정한 코드 리뷰로는 이런 취약점을 놓친다. &ldquo;이 입력값을 내가 제어할 수 있다면?&rdquo; 관점이 필요하다.</li>
<li><strong>유틸리티 하나가 여러 버그를 막는다</strong> — <code>stripStringLiterals</code>라는 단일 함수가 힌트 인젝션, 키워드 오탐, CTE 파싱 오류를 동시에 해결했다.</li>
</ol>
<p>프로젝트 소스코드: <a href="https://github.com/jyukki97/pgmux">github.com/jyukki97/pgmux</a></p>
]]></content:encoded></item><item><title>2026 개발 트렌드: Browser/Computer-Use 에이전트, 데모를 넘어 실서비스 자동화가 되려면</title><link>https://jyukki.com/posts/2026-03-05-browser-computer-use-agent-trend/</link><pubDate>Thu, 05 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-05-browser-computer-use-agent-trend/</guid><description>브라우저·컴퓨터 사용 에이전트를 실서비스에 붙일 때 필요한 성공 기준(성공률, 복구성, 보안 경계, 운영비)을 숫자 중심으로 정리합니다.</description><content:encoded><![CDATA[<p>2026년 개발 조직에서 눈에 띄게 늘어난 실험이 하나 있습니다. 바로 Browser/Computer-Use 에이전트입니다. 브라우저를 직접 조작해 반복 운영 업무를 자동화하거나, 레거시 백오피스 UI를 통해 사람이 하던 클릭 기반 작업을 대체하는 시도죠.</p>
<p>문제는 데모 성공과 운영 성공이 완전히 다르다는 점입니다. 데모에서는 10번 중 9번 성공해도 “와, 된다”가 되지만, 실서비스는 10번 중 1번 실패가 누적되면 장애 티켓과 수동 복구 비용이 폭증합니다. 그래서 지금 트렌드는 기능 자체보다 <strong>신뢰 가능한 실행 체계</strong>로 빠르게 이동하고 있습니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>Browser/Computer-Use 자동화가 왜 2026년 운영 트렌드가 되었는지, 그리고 어디서 실패하는지 이해합니다.</li>
<li>PoC 단계에서 운영 단계로 넘어가기 위한 **합격 기준(성공률·회복시간·보안 경계)**을 숫자로 정의할 수 있습니다.</li>
<li>팀 규모가 작아도 바로 적용 가능한 도입 순서(파일럿 → 제한 운영 → 표준화)를 가져갑니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-실패-원인의-70는-모델-품질보다-런타임-변동성이다">1) 실패 원인의 70%는 모델 품질보다 런타임 변동성이다</h3>
<p>실무 로그를 보면 실패는 대개 다음 유형으로 모입니다.</p>
<ul>
<li>셀렉터/DOM 구조 변경으로 클릭 대상 탐색 실패</li>
<li>로딩 타이밍 변동으로 이벤트 순서 꼬임</li>
<li>인증 세션 만료(2FA, CSRF 토큰, 쿠키 갱신)</li>
<li>예상치 못한 팝업/권한 다이얼로그</li>
</ul>
<p>즉 “모델이 똑똑한가”보다 “실행 환경을 얼마나 통제했는가”가 더 큰 변수입니다. 그래서 최근 팀들은 프롬프트 고도화보다 <strong>상태 검증, 재시도 조건, 안전 중단 조건</strong> 설계를 먼저 합니다.</p>
<h3 id="2-성공률만-보면-안-되고-복구-비용까지-같이-봐야-한다">2) 성공률만 보면 안 되고, 복구 비용까지 같이 봐야 한다</h3>
<p>자동화 성공률 92%는 얼핏 높아 보이지만, 실패 8%가 수동 복구 20분짜리 업무라면 운영비가 급격히 올라갑니다.</p>
<p>핵심 지표 예시:</p>
<ul>
<li>Task Success Rate: 98% 이상(업무 중요도 높을수록 99% 목표)</li>
<li>Human Intervention Rate: 5% 미만</li>
<li>Mean Time To Recover(MTTR): 10분 이하</li>
<li>False Positive Completion(완료로 표시됐지만 실제 실패): 0.5% 미만</li>
</ul>
<p>특히 FP Completion은 위험합니다. 실패를 성공으로 기록하면 데이터 정합성 문제를 늦게 발견해 피해가 커집니다.</p>
<h3 id="3-브라우저-자동화는-보안-경계가-먼저다">3) 브라우저 자동화는 보안 경계가 먼저다</h3>
<p>Computer-Use는 사실상 “사람 계정 권한을 대신 실행”하는 행위입니다. 그래서 기능 도입보다 권한 경계 정의가 선행되어야 합니다.</p>
<p>권장 정책:</p>
<ul>
<li>전용 서비스 계정 사용(개인 계정 금지)</li>
<li>접근 가능한 도메인 allowlist</li>
<li>민감 화면(결제, 개인정보 다운로드) 접근 시 사람 승인</li>
<li>실행 로그(스크린샷, 액션 로그, 결과 코드) 보관</li>
</ul>
<p>도입 초기에 이걸 생략하면, 사고가 났을 때 원인 추적도 책임 분리도 어렵습니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-도입-3단계-파일럿--제한-운영--표준화">1) 도입 3단계: 파일럿 → 제한 운영 → 표준화</h3>
<ol>
<li>
<p>파일럿(2~3주)</p>
<ul>
<li>단일 업무 1개만 자동화</li>
<li>성공/실패 원인 분류 체계 수립</li>
<li>사람 수동 경로를 항상 유지</li>
</ul>
</li>
<li>
<p>제한 운영(4~8주)</p>
<ul>
<li>저위험 업무 3~5개로 확대</li>
<li>실패 자동 분류 + 재실행 룰 적용</li>
<li>주간 지표 리뷰(성공률/개입률/MTTR)</li>
</ul>
</li>
<li>
<p>표준화</p>
<ul>
<li>공통 런타임 템플릿(로그, 알람, 권한 정책) 배포</li>
<li>업무별 난이도 등급(A/B/C) 관리</li>
<li>신규 자동화는 체크리스트 통과 후 배포</li>
</ul>
</li>
</ol>
<p>우선순위는 항상 동일합니다.</p>
<ol>
<li>안전성(보안/정합성)</li>
<li>복구성(MTTR/대체 경로)</li>
<li>속도(처리량)</li>
</ol>
<h3 id="2-안정화-패턴-액션보다-검증-단계를-늘려라">2) 안정화 패턴: 액션보다 검증 단계를 늘려라</h3>
<p>초기 구현은 “클릭 → 입력 → 제출” 중심인데, 운영 단계에서는 “검증 → 액션 → 검증” 패턴이 더 중요합니다.</p>
<p>예시:</p>
<ul>
<li>액션 전: 대상 요소 존재 + 텍스트 일치 확인</li>
<li>액션 후: 상태 변화 확인(버튼 disabled, 성공 토스트, URL 변화)</li>
<li>종료 전: 결과 데이터 샘플 검증</li>
</ul>
<p>이 3단계를 넣으면 평균 처리 시간은 늘어도 재작업 비용이 크게 줄어듭니다. 실무에서는 처리속도 15% 감소를 감수하고 재실행률 40% 감소를 선택하는 경우가 많습니다.</p>
<h3 id="3-테스트-전략-회귀-세트가-없으면-운영-품질이-유지되지-않는다">3) 테스트 전략: 회귀 세트가 없으면 운영 품질이 유지되지 않는다</h3>
<p>UI 자동화는 대상 서비스가 바뀌면 바로 깨집니다. 따라서 기능 테스트가 아니라 <strong>업무 시나리오 회귀 세트</strong>를 운영해야 합니다.</p>
<ul>
<li>스모크 시나리오: 핵심 5개 작업, 배포 전 매일 실행</li>
<li>계약 시나리오: 필수 화면 요소/문구 변화 감지</li>
<li>장애 시나리오: 팝업, 타임아웃, 로그인 만료 강제 주입</li>
</ul>
<p>실무 기준:</p>
<ul>
<li>스모크 실패율 2% 초과 시 신규 기능 배포 중단</li>
<li>동일 시나리오 3회 연속 실패 시 롤백 또는 수동 전환</li>
<li>월 1회는 수동 경로 복구 훈련(자동화 의존 리스크 완화)</li>
</ul>
<h3 id="4-비용-모델-호출비--실패비--운영인건비를-함께-계산">4) 비용 모델: 호출비 + 실패비 + 운영인건비를 함께 계산</h3>
<p>많은 팀이 API 호출비만 보고 자동화 ROI를 과대평가합니다. 실제 비용은 다음 합입니다.</p>
<p>총비용 = 모델/도구 호출비 + 인프라 실행비 + 실패 복구 인건비 + 운영 관제 비용</p>
<p>의사결정 기준 예시:</p>
<ul>
<li>자동화 1건당 총비용이 수동의 60% 이하일 때 확대</li>
<li>개입률이 10% 이상이면 업무 재설계 우선(에이전트 튜닝보다 효과 큼)</li>
<li>MTTR이 15분 초과면 완전자동화 대신 반자동 승인 플로우 유지</li>
</ul>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li>
<p><strong>빠른 자동화 확장 vs 운영 부채 누적</strong><br>
초기 성과 욕심으로 업무를 한꺼번에 붙이면 실패 유형이 폭증합니다. 업무 1개를 끝까지 안정화한 뒤 확장하는 편이 총 기간이 짧습니다.</p>
</li>
<li>
<p><strong>높은 자율성 vs 감사 가능성</strong><br>
에이전트에게 재량을 넓히면 커버리지는 늘지만, 의사결정 추적성이 떨어집니다. 민감 작업은 규칙 기반 분기를 명시적으로 두는 게 안전합니다.</p>
</li>
<li>
<p><strong>실시간 처리 vs 검증 강도</strong><br>
검증 단계를 늘리면 지연이 증가합니다. 고객 영향도가 높은 경로는 지연을 감수하고 검증을 강화하고, 내부 보고성 업무는 속도 우선으로 분리하세요.</p>
</li>
<li>
<p><strong>단일 벤더 편의성 vs 이식성</strong><br>
특정 도구 의존도가 과하면 전환 비용이 커집니다. 액션 정의와 검증 규칙을 내부 포맷으로 추상화해두면 리스크를 줄일 수 있습니다.</p>
</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<ul>
<li><input disabled="" type="checkbox"> 자동화 대상 업무를 위험도(A/B/C)로 분류했다.</li>
<li><input disabled="" type="checkbox"> 성공률·개입률·MTTR·FP Completion 목표치를 수치로 정의했다.</li>
<li><input disabled="" type="checkbox"> 실패 시 수동 전환(runbook) 경로를 10분 내 실행 가능하게 문서화했다.</li>
<li><input disabled="" type="checkbox"> 도메인 allowlist, 서비스 계정, 민감 작업 승인 규칙을 적용했다.</li>
<li><input disabled="" type="checkbox"> 스모크/계약/장애 주입 시나리오 회귀 세트를 운영한다.</li>
</ul>
<p>연습 과제:</p>
<ol>
<li>현재 팀의 반복 UI 업무 3개를 골라, 자동화 ROI를 “호출비+복구비”까지 포함해 계산해보세요.</li>
<li>실패 로그 30건을 RETRYABLE/ENV_CHANGE/POLICY_BLOCK으로 분류하고, 가장 큰 원인 1개를 먼저 줄이는 액션을 설계해보세요.</li>
<li>업무 1개를 대상으로 ‘완전자동’ 대신 ‘반자동 승인’ 모델을 적용해 품질 지표 변화를 비교해보세요.</li>
</ol>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/posts/2026-02-28-ai-agent-observability-trend/">2026 개발 트렌드: AI 에이전트 도입, 이제는 성능보다 관측/통제가 먼저다</a></li>
<li><a href="/posts/2026-03-02-mcp-tooling-security-governance-trend/">2026 개발 트렌드: MCP 도입은 빨라졌지만, 툴 호출 보안·거버넌스가 성패를 가른다</a></li>
<li><a href="/posts/2026-03-03-evals-driven-development-trend/">2026 개발 트렌드: AI 코드 생성 경쟁의 승부처는 ‘생성’이 아니라 Evals 기반 품질 게이트</a></li>
<li><a href="/posts/2026-03-04-ai-coding-agent-runtime-governance-trend/">2026 개발 트렌드: AI 코딩 에이전트 시대, 팀 경쟁력은 ‘모델’보다 런타임 거버넌스에서 갈린다</a></li>
<li><a href="/learning/deep-dive/deep-dive-observability-baseline/">관측성 베이스라인: 로그·메트릭·트레이스</a></li>
</ul>
]]></content:encoded></item></channel></rss>