<?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>Extended Query Protocol on jyukki's Blog</title><link>https://jyukki.com/tags/extended-query-protocol/</link><description>Recent content in Extended Query Protocol 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/extended-query-protocol/index.xml" rel="self" type="application/rss+xml"/><item><title>Go로 PostgreSQL 프록시 만들기 (60) - Extended Query 커넥션 바인딩과 E2E 전수 검증</title><link>https://jyukki.com/posts/2026-03-17-pgmux-60-ext-query-binding-e2e/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-17-pgmux-60-ext-query-binding-e2e/</guid><description>lib/pq의 2-round Extended Query Protocol에서 transaction-level pooling이 커넥션을 릴리즈하는 버그를 수정하고, E2E 테스트를 14개에서 22개로 확장하여 전수 검증한다.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>pgmux의 Extended Query Protocol 지원은 이전 포스트에서 구현했다. 단일 클라이언트에서는 잘 동작했지만, <strong>ConcurrentWriters</strong> 테스트를 돌리자 19/20이 실패했다. 50개 goroutine이 동시에 parameterized INSERT를 날리면 <code>prepared statement does not exist</code> 에러가 쏟아진다.</p>
<p>원인은 lib/pq의 <strong>2-round Extended Query Protocol</strong>과 pgmux의 transaction-level pooling 사이의 충돌이었다.</p>
<hr>
<h2 id="문제-2-round-extended-query-protocol">문제: 2-round Extended Query Protocol</h2>
<p>PostgreSQL의 Extended Query Protocol은 원래 한 번에 <code>Parse -&gt; Bind -&gt; Execute -&gt; Sync</code>를 보내는 것이 일반적이다. 그런데 Go의 <code>lib/pq</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-fallback" data-lang="fallback"><span style="display:flex;"><span>Round 1: Parse + Describe + Sync   --&gt; ReadyForQuery
</span></span><span style="display:flex;"><span>Round 2: Bind + Execute  + Sync   --&gt; ReadyForQuery
</span></span></code></pre></div><p>Round 1에서 prepared statement를 생성하고, Round 2에서 파라미터를 바인딩해서 실행한다. 문제는 pgmux가 <strong>transaction-level pooling</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>Round 1: Parse+Describe+Sync → Reader A에서 실행 → ReadyForQuery → 커넥션 릴리즈
</span></span><span style="display:flex;"><span>Round 2: Bind+Execute+Sync   → Reader B에 할당됨  → &#34;prepared statement does not exist&#34;
</span></span></code></pre></div><p>Round 1에서 ReadyForQuery를 받으면 pgmux는 커넥션을 풀에 반환한다. Round 2가 오면 풀에서 <strong>다른 커넥션</strong>을 할당받는다. 이 커넥션에는 Round 1에서 만든 prepared statement가 없다.</p>
<p>단일 클라이언트에서는 풀에 커넥션이 하나뿐이라 같은 커넥션을 다시 받아서 동작했지만, 동시성이 올라가면 확률적으로 다른 커넥션을 받게 된다.</p>
<hr>
<h2 id="해결-exthasexecute-플래그와-커넥션-바인딩">해결: extHasExecute 플래그와 커넥션 바인딩</h2>
<p>핵심 아이디어: <strong>Execute 메시지가 없는 배치는 커넥션을 릴리즈하지 않는다.</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">var</span> extHasExecute <span style="color:#8be9fd">bool</span> <span style="color:#6272a4">// true if the current batch contains an Execute message</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>    extHasExecute = <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>    extBuf = <span style="color:#8be9fd;font-style:italic">append</span>(extBuf, protocol.<span style="color:#50fa7b">CopyMessage</span>(msg))
</span></span></code></pre></div><p>Sync 메시지가 도착하면 <code>extHasExecute</code>를 확인한다. Execute가 없으면 Parse-only 배치이므로, 커넥션을 풀에 반환하지 않고 <code>boundReader</code>/<code>boundWriter</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">// relayQueries 시작 부분의 상태 변수</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> boundReader <span style="color:#ff79c6">*</span>pool.Conn
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> boundReaderPool <span style="color:#ff79c6">*</span>pool.Pool
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">var</span> boundReaderAddr <span style="color:#8be9fd">string</span>
</span></span></code></pre></div><h3 id="3가지-라우팅-경로">3가지 라우팅 경로</h3>
<p>Sync 처리 시점에서 reader 경로는 세 가지로 분기한다.</p>
<p><strong>1. Bound reader 재사용</strong> &ndash; Round 2에서 Round 1의 커넥션을 재사용:</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> boundReader <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Reuse reader bound from a previous Parse-only batch</span>
</span></span><span style="display:flex;"><span>    ct.<span style="color:#50fa7b">setFromConn</span>(boundReaderAddr, boundReader)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> err <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">forwardExtBatch</span>(boundReader, extBuf, msg); err <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>        boundReaderPool.<span style="color:#50fa7b">Discard</span>(boundReader)
</span></span><span style="display:flex;"><span>        boundReader, boundReaderPool, boundReaderAddr = <span style="color:#ff79c6">nil</span>, <span style="color:#ff79c6">nil</span>, <span style="color:#f1fa8c">&#34;&#34;</span>
</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>    <span style="color:#6272a4">// ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Release bound reader after Execute completes</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> extHasExecute {
</span></span><span style="display:flex;"><span>        boundReaderPool.<span style="color:#50fa7b">Release</span>(boundReader)
</span></span><span style="display:flex;"><span>        boundReader, boundReaderPool, boundReaderAddr = <span style="color:#ff79c6">nil</span>, <span style="color:#ff79c6">nil</span>, <span style="color:#f1fa8c">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Execute가 포함된 배치가 완료되면 그때서야 커넥션을 릴리즈한다.</p>
<p><strong>2. Parse-only 바인딩</strong> &ndash; Round 1에서 커넥션을 바인딩:</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">else</span> <span style="color:#ff79c6">if</span> !extHasExecute <span style="color:#ff79c6">&amp;&amp;</span> <span style="color:#8be9fd;font-style:italic">len</span>(extBuf) &gt; <span style="color:#bd93f9">0</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Parse-only batch: acquire reader and keep connection bound</span>
</span></span><span style="display:flex;"><span>    readerAddr <span style="color:#ff79c6">:=</span> dbg.balancer.<span style="color:#50fa7b">Next</span>()
</span></span><span style="display:flex;"><span>    rConn, rPool, usedAddr, err <span style="color:#ff79c6">:=</span> s.<span style="color:#50fa7b">acquireReaderForBind</span>(ctx, readerAddr, dbg)
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... forward and relay ...</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Keep reader bound for next batch</span>
</span></span><span style="display:flex;"><span>    boundReader = rConn
</span></span><span style="display:flex;"><span>    boundReaderPool = rPool
</span></span><span style="display:flex;"><span>    boundReaderAddr = usedAddr
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>3. 일반 경로</strong> &ndash; Execute가 포함된 단일 배치(Parse+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-go" data-lang="go"><span style="display:flex;"><span>} <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// Normal reader path with Execute</span>
</span></span><span style="display:flex;"><span>    readerAddr <span style="color:#ff79c6">:=</span> dbg.balancer.<span style="color:#50fa7b">Next</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... acquire, forward, relay, release ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Writer 경로도 동일한 패턴으로 <code>boundWriter</code>를 사용한다. Parse-only 배치에서 <code>acquired &amp;&amp; !extHasExecute</code>이면 커넥션을 바인딩하고, <code>!acquired &amp;&amp; extHasExecute</code>이면 바인딩된 커넥션으로 Execute를 실행한 뒤 릴리즈한다.</p>
<h3 id="acquirereaderforbind-헬퍼">acquireReaderForBind 헬퍼</h3>
<p>Parse-only 배치에서 reader를 획득하는 전용 헬퍼다. Circuit breaker 체크와 writer fallback을 포함한다.</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">acquireReaderForBind</span>(ctx context.Context, readerAddr <span style="color:#8be9fd">string</span>, dbg <span style="color:#ff79c6">*</span>DatabaseGroup) (<span style="color:#ff79c6">*</span>pool.Conn, <span style="color:#ff79c6">*</span>pool.Pool, <span style="color:#8be9fd">string</span>, <span style="color:#8be9fd">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> readerAddr <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#34;&#34;</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> cb.<span style="color:#50fa7b">Allow</span>() <span style="color:#ff79c6">!=</span> <span style="color:#ff79c6">nil</span> {
</span></span><span style="display:flex;"><span>                readerAddr = <span style="color:#f1fa8c">&#34;&#34;</span> <span style="color:#6272a4">// circuit breaker open</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">if</span> readerAddr <span style="color:#ff79c6">!=</span> <span style="color:#f1fa8c">&#34;&#34;</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">if</span> rPool, ok <span style="color:#ff79c6">:=</span> dbg.<span style="color:#50fa7b">ReaderPool</span>(readerAddr); ok {
</span></span><span style="display:flex;"><span>            <span style="color:#ff79c6">if</span> rConn, err <span style="color:#ff79c6">:=</span> rPool.<span style="color:#50fa7b">Acquire</span>(ctx); 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> rConn, rPool, readerAddr, <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#6272a4">// ... mark unhealthy ...</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">// Fallback to writer</span>
</span></span><span style="display:flex;"><span>    wPool <span style="color:#ff79c6">:=</span> dbg.writerPool
</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:#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>, <span style="color:#ff79c6">nil</span>, <span style="color:#f1fa8c">&#34;&#34;</span>, fmt.<span style="color:#50fa7b">Errorf</span>(<span style="color:#f1fa8c">&#34;acquire reader (with writer fallback): %w&#34;</span>, err)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> wConn, wPool, dbg.writerAddr, <span style="color:#ff79c6">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>이 수정 후 ConcurrentWriters 50/50 성공, ConcurrentParameterizedSELECT 50/50 성공.</p>
<hr>
<h2 id="e2e-테스트-전수-검증">E2E 테스트 전수 검증</h2>
<p>버그 수정 후 기존 E2E 테스트를 전수 점검했다. pgmux에 구현된 기능 대비 테스트 커버리지가 부족했다. Auth, Firewall, Connection Limits, Data API, Rate Limit, Session Compatibility, SQL Redaction &ndash; 모두 구현은 되어 있지만 E2E 테스트가 없었다.</p>
<h3 id="configtestyaml-확장">config.test.yaml 확장</h3>
<p>기존 config.test.yaml에 테스트에 필요한 설정을 추가했다:</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:#6272a4"># --- 신규 기능 활성화 ---</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">users</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ff79c6">username</span>: <span style="color:#f1fa8c">&#34;postgres&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">password</span>: <span style="color:#f1fa8c">&#34;postgres&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">max_connections</span>: <span style="color:#bd93f9">0</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ff79c6">username</span>: <span style="color:#f1fa8c">&#34;limited&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">password</span>: <span style="color:#f1fa8c">&#34;limited&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">max_connections</span>: <span style="color:#bd93f9">3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">firewall</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">block_delete_without_where</span>: <span style="color:#ff79c6">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">block_update_without_where</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">connection_limits</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">default_max_connections_per_user</span>: <span style="color:#bd93f9">50</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">default_max_connections_per_database</span>: <span style="color:#bd93f9">50</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">session_compatibility</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">mode</span>: <span style="color:#f1fa8c">&#34;warn&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">data_api</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">listen</span>: <span style="color:#f1fa8c">&#34;0.0.0.0:18080&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">api_keys</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;test-api-key-12345&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">rate_limit</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">rate</span>: <span style="color:#bd93f9">10000</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">burst</span>: <span style="color:#bd93f9">5000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">audit</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">slow_query_threshold</span>: 100ms
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">observability</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">sql_redaction</span>: <span style="color:#f1fa8c">&#34;literals&#34;</span>
</span></span></code></pre></div><h3 id="14개---22개-테스트">14개 -&gt; 22개 테스트</h3>
<p>기존 14개 테스트에 8개를 추가했다.</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>테스트</th>
          <th>검증 대상</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>15</td>
          <td><code>TestE2E_Auth</code></td>
          <td>유효 사용자, 제한 사용자, 미등록 사용자, 잘못된 비밀번호</td>
      </tr>
      <tr>
          <td>16</td>
          <td><code>TestE2E_FirewallRules</code></td>
          <td>DELETE/UPDATE without WHERE 차단, WITH WHERE 허용, 메트릭</td>
      </tr>
      <tr>
          <td>17</td>
          <td><code>TestE2E_ConnectionLimits</code></td>
          <td>limited 사용자(max_connections=3)의 4번째 연결 거부</td>
      </tr>
      <tr>
          <td>18</td>
          <td><code>TestE2E_DataAPI</code></td>
          <td>SELECT, INSERT, 인증 없음(401), 잘못된 키(401), Firewall 연동(403), COPY 거부(400)</td>
      </tr>
      <tr>
          <td>19</td>
          <td><code>TestE2E_RateLimitMetric</code></td>
          <td>rate_limited 메트릭 존재, 높은 limit 하에서 요청 정상 처리</td>
      </tr>
      <tr>
          <td>20</td>
          <td><code>TestE2E_SessionCompatibility</code></td>
          <td>SET 실행(warn 모드), session_dependency_detected 메트릭</td>
      </tr>
      <tr>
          <td>21</td>
          <td><code>TestE2E_SQLRedaction</code></td>
          <td>리터럴 포함 쿼리 정상 동작, AST 파서 활성화, 메트릭 수집</td>
      </tr>
      <tr>
          <td>22</td>
          <td><code>TestE2E_AdminConnectionLimitStats</code></td>
          <td>/admin/connections 엔드포인트의 사용자별/DB별 통계</td>
      </tr>
  </tbody>
</table>
<p>각 테스트는 실제 PostgreSQL 백엔드(docker-compose)와 pgmux 프록시에 대해 실행된다. 프록시가 실행되지 않으면 <code>t.Skip</code>으로 건너뛴다.</p>
<h3 id="data-api-테스트가-가장-두꺼운-이유">Data API 테스트가 가장 두꺼운 이유</h3>
<p><code>TestE2E_DataAPI</code>는 서브테스트 6개로 구성된다. Data API는 HTTP 엔드포인트이므로 pgmux의 여러 레이어를 관통한다:</p>
<ul>
<li><strong>인증</strong>: API key 검증 (Bearer token)</li>
<li><strong>라우팅</strong>: SELECT는 reader, INSERT/CREATE는 writer</li>
<li><strong>방화벽</strong>: DELETE without WHERE가 Data API 경유로도 차단되는지</li>
<li><strong>프로토콜</strong>: COPY 문이 Data API에서 거부되는지 (Data API는 Simple Query만 지원)</li>
</ul>
<h3 id="테스트할-수-없는-것들">테스트할 수 없는 것들</h3>
<p>모든 기능을 E2E로 검증할 수 있는 것은 아니다.</p>
<table>
  <thead>
      <tr>
          <th>기능</th>
          <th>이유</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Mirror (트래픽 미러링)</td>
          <td>별도 PostgreSQL 인스턴스 필요</td>
      </tr>
      <tr>
          <td>TLS</td>
          <td>인증서 설정 필요, CI에서 비실용적</td>
      </tr>
      <tr>
          <td>Redis PubSub (캐시 무효화)</td>
          <td>Redis 인스턴스 필요</td>
      </tr>
      <tr>
          <td>OpenTelemetry span</td>
          <td>OTLP 수집기 필요</td>
      </tr>
      <tr>
          <td>Slack webhook</td>
          <td>외부 서비스 의존</td>
      </tr>
      <tr>
          <td>Circuit breaker 동작</td>
          <td>의도적 장애 주입 필요</td>
      </tr>
      <tr>
          <td>Hot-reload</td>
          <td>파일 변경 + 시그널 필요</td>
      </tr>
  </tbody>
</table>
<p>이들은 unit test와 integration test로 커버한다. E2E는 &ldquo;사용자 관점에서 기능이 동작하는가&quot;에 집중한다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>Extended Query Protocol의 2-round 패턴은 PostgreSQL wire protocol 스펙에는 없는, 드라이버 구현의 선택이다. <code>lib/pq</code>는 Parse+Describe로 statement의 메타데이터를 먼저 받아온 뒤 Bind+Execute를 보내는데, transaction-level pooling 프록시에서 이 두 라운드 사이에 커넥션이 바뀌면 prepared statement가 사라진다.</p>
<p><code>extHasExecute</code> 플래그 하나로 &ldquo;아직 Execute가 오지 않은 배치&quot;를 식별하고, 해당 커넥션을 바인딩해두는 것으로 해결했다. E2E 테스트 22개는 pgmux의 현재 기능을 실용적인 범위에서 전수 검증한다.</p>
]]></content:encoded></item></channel></rss>