<?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>Query Rewriting on jyukki's Blog</title><link>https://jyukki.com/tags/query-rewriting/</link><description>Recent content in Query Rewriting on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Wed, 18 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/query-rewriting/index.xml" rel="self" type="application/rss+xml"/><item><title>Go로 PostgreSQL 프록시 만들기 (63) - AST 기반 Query Rewriting Rules 엔진</title><link>https://jyukki.com/posts/2026-03-18-pgmux-63-query-rewriting/</link><pubDate>Wed, 18 Mar 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/2026-03-18-pgmux-63-query-rewriting/</guid><description>pg_query_go AST 파서 위에 선언적 쿼리 변환 엔진을 구현한다. table_rename, column_rename, add_where, schema_qualify 4가지 액션과 코드 리뷰에서 발견한 Critical 버그 수정까지.</description><content:encoded><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>v1.0.0 이후 첫 기능으로 <strong>Query Rewriting Rules</strong>를 구현했다. deprecated 테이블명 치환, 조건 자동 주입, 스키마 자동 한정 같은 변환을 YAML 설정으로 선언하면 프록시가 AST 레벨에서 쿼리를 변환한다.</p>
<p>프록시에서 쿼리를 변환하면 애플리케이션 코드를 수정하지 않고도 무중단 스키마 마이그레이션이나 멀티테넌시 격리를 적용할 수 있다. 이미 W21에서 도입한 <code>pg_query_go</code> AST 파서 위에 쌓았기 때문에 정규식 치환이 아닌 <strong>구문 트리 조작</strong>으로 안전하게 변환한다.</p>
<hr>
<h2 id="설계">설계</h2>
<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>Client → [Parse] → [Rewrite] → [Firewall] → [Route] → Backend
</span></span></code></pre></div><p>Rewriting은 <strong>Firewall보다 앞</strong>에 위치한다. 변환 후의 쿼리가 방화벽 검사를 통과해야 하기 때문이다. 예를 들어 <code>DELETE FROM orders</code>를 <code>DELETE FROM orders WHERE tenant_id = 42</code>로 변환하면 <code>block_delete_without_where</code> 규칙에 걸리지 않는다.</p>
<h3 id="4가지-액션">4가지 액션</h3>
<table>
  <thead>
      <tr>
          <th>액션</th>
          <th>설명</th>
          <th>사용 예</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>table_rename</code></td>
          <td>RangeVar 노드의 테이블명 치환</td>
          <td>deprecated 테이블 → 새 테이블</td>
      </tr>
      <tr>
          <td><code>column_rename</code></td>
          <td>ColumnRef 노드의 컬럼명 치환</td>
          <td>컬럼명 변경 마이그레이션</td>
      </tr>
      <tr>
          <td><code>add_where</code></td>
          <td>SELECT/UPDATE/DELETE에 AND 조건 주입</td>
          <td>멀티테넌시 row-level 격리</td>
      </tr>
      <tr>
          <td><code>schema_qualify</code></td>
          <td>미한정 테이블에 스키마 접두사 추가</td>
          <td><code>orders</code> → <code>shop.orders</code></td>
      </tr>
  </tbody>
</table>
<h3 id="매칭-조건">매칭 조건</h3>
<p>규칙에는 3가지 선택적 필터가 있다:</p>
<ul>
<li><strong>tables</strong>: 특정 테이블을 참조하는 쿼리만 매칭</li>
<li><strong>statement_type</strong>: SELECT, INSERT, UPDATE, DELETE 중 선택</li>
<li><strong>query_pattern</strong>: 정규식으로 raw SQL 매칭</li>
</ul>
<p>모든 필터를 만족해야(AND) 규칙이 적용된다.</p>
<hr>
<h2 id="핵심-구현">핵심 구현</h2>
<h3 id="rewriter-구조">Rewriter 구조</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> Rewriter <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    rules []compiledRule
</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">type</span> compiledRule <span style="color:#8be9fd;font-style:italic">struct</span> {
</span></span><span style="display:flex;"><span>    rule    RewriteRule
</span></span><span style="display:flex;"><span>    pattern <span style="color:#ff79c6">*</span>regexp.Regexp <span style="color:#6272a4">// nil if no QueryPattern</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>Rewriter</code>는 생성 후 불변(immutable)이다. <code>atomic.Pointer</code>로 관리하면 설정 리로드 시 새 Rewriter를 swap하는 것만으로 lock-free 교체가 가능하다.</p>
<h3 id="ast-안전성-항상-fresh-parse">AST 안전성: 항상 Fresh Parse</h3>
<p>초기 구현에서는 <code>ParsedQuery</code>의 AST를 직접 변형했다. 코드 리뷰에서 이것이 공유 AST를 오염시키는 Critical 버그임을 발견했다.</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 (buggy): shared AST를 직접 변형</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (rw <span style="color:#ff79c6">*</span>Rewriter) <span style="color:#50fa7b">Apply</span>(query <span style="color:#8be9fd">string</span>, pq <span style="color:#ff79c6">*</span>ParsedQuery) RewriteResult {
</span></span><span style="display:flex;"><span>    tree <span style="color:#ff79c6">:=</span> pq.Tree  <span style="color:#6272a4">// shared reference!</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... mutate tree in-place</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">// After (safe): 항상 fresh tree를 파싱</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">func</span> (rw <span style="color:#ff79c6">*</span>Rewriter) <span style="color:#50fa7b">Apply</span>(query <span style="color:#8be9fd">string</span>, pq <span style="color:#ff79c6">*</span>ParsedQuery) RewriteResult {
</span></span><span style="display:flex;"><span>    tree, err <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">ParseSQL</span>(query)  <span style="color:#6272a4">// independent copy</span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// ... mutate tree safely</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>Deparse</code> 비용(~50us)보다 AST 공유 버그의 위험이 훨씬 크다. Rewriting은 규칙이 설정된 경우에만 실행되므로 이 비용은 허용 가능하다.</p>
<h3 id="add_where-statement별-독립-파싱">add_where: Statement별 독립 파싱</h3>
<p><code>add_where</code>는 WHERE 절에 AND 조건을 주입한다. 멀티 statement 쿼리에서 같은 condition 노드를 공유하면 AST 트리가 DAG가 되어 Deparse가 깨진다.</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">rewriteAddWhere</span>(tree <span style="color:#ff79c6">*</span>pg_query.ParseResult, condition <span style="color:#8be9fd">string</span>) <span style="color:#8be9fd">bool</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">for</span> _, rawStmt <span style="color:#ff79c6">:=</span> <span style="color:#ff79c6">range</span> tree.<span style="color:#50fa7b">GetStmts</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// Parse a fresh condition node per statement</span>
</span></span><span style="display:flex;"><span>        condNode, err <span style="color:#ff79c6">:=</span> <span style="color:#50fa7b">parseCondition</span>(condition)
</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 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:#ff79c6">switch</span> n <span style="color:#ff79c6">:=</span> stmt.<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_SelectStmt:
</span></span><span style="display:flex;"><span>            n.SelectStmt.WhereClause = <span style="color:#50fa7b">andWhere</span>(
</span></span><span style="display:flex;"><span>                n.SelectStmt.<span style="color:#50fa7b">GetWhereClause</span>(), condNode)
</span></span><span style="display:flex;"><span>        <span style="color:#6272a4">// ... UPDATE, DELETE</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><h3 id="extended-query-proxy-모드-parse-메시지-리빌드">Extended Query Proxy 모드: Parse 메시지 리빌드</h3>
<p>Simple Query는 SQL 문자열만 교체하면 된다. 하지만 Extended Query Protocol의 Parse 메시지는 <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>Parse: stmtName\0 + query\0 + paramCount(uint16) + paramOID[](uint32)
</span></span></code></pre></div><p>proxy 모드에서는 Parse 메시지를 그대로 backend에 전달한다. 쿼리를 rewrite했는데 원본 메시지를 전달하면 backend는 rewrite 전 쿼리를 받는다.</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 (buggy): rewrite 후에도 원본 msg를 버퍼링</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><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// After (fixed): rewrite된 경우 새 Parse 메시지 생성</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">if</span> extRewritten {
</span></span><span style="display:flex;"><span>    newPayload <span style="color:#ff79c6">:=</span> protocol.<span style="color:#50fa7b">BuildParsePayload</span>(stmtName, query, paramOIDs)
</span></span><span style="display:flex;"><span>    extBuf = <span style="color:#8be9fd;font-style:italic">append</span>(extBuf, <span style="color:#ff79c6">&amp;</span>protocol.Message{
</span></span><span style="display:flex;"><span>        Type: protocol.MsgParse, Payload: newPayload,
</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>    extBuf = <span style="color:#8be9fd;font-style:italic">append</span>(extBuf, protocol.<span style="color:#50fa7b">CopyMessage</span>(msg))
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>BuildParsePayload</code>는 statement name, rewritten SQL, parameter OID를 재조합하여 유효한 Parse 메시지를 생성한다.</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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">query_rewriting</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">rules</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ff79c6">name</span>: legacy_users_redirect
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">match</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">tables</span>: [<span style="color:#f1fa8c">&#34;users_old&#34;</span>]
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">actions</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ff79c6">type</span>: table_rename
</span></span><span style="display:flex;"><span>          <span style="color:#ff79c6">from</span>: users_old
</span></span><span style="display:flex;"><span>          <span style="color:#ff79c6">to</span>: users
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ff79c6">name</span>: tenant_isolation
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">match</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">statement_type</span>: [<span style="color:#f1fa8c">&#34;SELECT&#34;</span>, <span style="color:#f1fa8c">&#34;UPDATE&#34;</span>, <span style="color:#f1fa8c">&#34;DELETE&#34;</span>]
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">tables</span>: [<span style="color:#f1fa8c">&#34;orders&#34;</span>]
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">actions</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ff79c6">type</span>: add_where
</span></span><span style="display:flex;"><span>          <span style="color:#ff79c6">condition</span>: <span style="color:#f1fa8c">&#34;tenant_id = current_setting(&#39;app.tenant_id&#39;)::int&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ff79c6">name</span>: qualify_public
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">match</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">tables</span>: [<span style="color:#f1fa8c">&#34;products&#34;</span>]
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">actions</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ff79c6">type</span>: schema_qualify
</span></span><span style="display:flex;"><span>          <span style="color:#ff79c6">from</span>: products
</span></span><span style="display:flex;"><span>          <span style="color:#ff79c6">to</span>: shop
</span></span></code></pre></div><hr>
<h2 id="hot-reload--관측성">Hot Reload &amp; 관측성</h2>
<ul>
<li><strong>무중단 리로드</strong>: <code>SIGHUP</code> 또는 fsnotify로 설정 변경 시 새 Rewriter를 <code>atomic.Pointer</code>로 swap</li>
<li><strong>Admin API</strong>: <code>GET /admin/rewrite/rules</code> — 활성 규칙 목록 확인</li>
<li><strong>메트릭</strong>: <code>pgmux_query_rewritten_total{rule=&quot;...&quot;}</code> — 규칙별 적용 횟수</li>
</ul>
<hr>
<h2 id="코드-리뷰에서-발견한-버그-4건">코드 리뷰에서 발견한 버그 4건</h2>
<table>
  <thead>
      <tr>
          <th>심각도</th>
          <th>버그</th>
          <th>영향</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Critical</strong></td>
          <td>proxy 모드 Parse 메시지 미리빌드</td>
          <td>backend가 rewrite 전 SQL 실행</td>
      </tr>
      <tr>
          <td><strong>Critical</strong></td>
          <td>공유 AST in-place 변형</td>
          <td>동시 쿼리에서 AST 오염</td>
      </tr>
      <tr>
          <td><strong>Major</strong></td>
          <td>appliedRules 누적 추적</td>
          <td>미적용 규칙이 applied로 기록</td>
      </tr>
      <tr>
          <td><strong>Major</strong></td>
          <td>add_where condNode 공유</td>
          <td>멀티 statement에서 Deparse 실패</td>
      </tr>
  </tbody>
</table>
<p>4건 모두 코드 리뷰 단계에서 발견하고 수정했다. 특히 Critical #1은 Extended Query Protocol을 사용하는 드라이버(lib/pq 포함) 전체에 영향을 미쳤을 버그다.</p>
<hr>
<h2 id="테스트">테스트</h2>
<p>16개 유닛 테스트로 커버:</p>
<ul>
<li>4가지 액션별 기본 동작 + 엣지 케이스</li>
<li>statement type / regex / table 필터 조합</li>
<li>disabled 규칙 스킵</li>
<li>복수 규칙 체이닝</li>
<li>fail-open (파싱 실패 시 원본 반환)</li>
<li>subquery, CTE 내부 테이블 매칭</li>
</ul>
<hr>
<h2 id="마치며">마치며</h2>
<p>AST 기반 쿼리 변환은 정규식 치환보다 안전하지만, AST를 직접 변형하는 만큼 <strong>공유 참조</strong>와 <strong>프로토콜 인코딩</strong> 같은 함정이 있다. 코드 리뷰에서 Critical 2건을 잡은 것이 이번 작업의 가장 큰 수확이다.</p>
<p>다음은 Query Rewriting을 기반으로 사용자/DB/테이블 기준의 선언적 <strong>Policy Engine</strong>을 설계할 예정이다.</p>
]]></content:encoded></item></channel></rss>