<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title><![CDATA[Jerrylog Blog]]></title>
    <link>https://jerrylog.dev/ko</link>
    <description><![CDATA[The latest posts from Jerrylog.]]></description>
    <language>ko</language>
    <lastBuildDate>Thu, 22 Oct 2026 15:50:02 GMT</lastBuildDate>
    <atom:link href="https://jerrylog.dev/ko/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title><![CDATA[Delta Uniform Multi Platform Serving]]></title>
      <link>https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving</link>
      <guid isPermaLink="true">https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving</guid>
      <description><![CDATA[S3 Parquet와 BigQuery로 이중 운영하던 데이터를 Delta Lake 한 벌로 합치고, Databricks 고객에게는 Delta Sharing, 그 밖에는 UniForm을 통한 Iceberg로 제공한 과정.]]></description>
      <content:encoded><![CDATA[<p>과거 직장에서 미국 기관 고객에게 온체인·시장 데이터를 제공하는 일을 했습니다. 실시간 지표는 API로 제공했지만, 백테스팅 등을 위해 방대한 히스토리 데이터를 필요로 하는 고객도 있었습니다. 이 데이터는 테이블 포맷 없이 Hive 파티셔닝 방식의 S3 경로에 Parquet 파일로 쌓아 두고, 고객에게 경로의 읽기 권한을 주는 방식으로 제공했습니다. SQL로 바로 조회하려는 고객을 위해서는 Query-as-a-Service 용도로 같은 데이터를 BigQuery 테이블에도 복제해 두고, 테이블 조회 권한을 주었습니다.</p>
<p><img src="https://jerrylog.dev/assets/images/delta-uniform/query-as-a-service.png" alt="SQL 편집기에서 BigQuery 테이블을 조회하는 Query-as-a-Service 화면"/></p>
<p>이 방식에는 처음부터 안고 있던 문제가 있었습니다. 파일만 쌓아 두는 구조라서, 읽는 도중에 쓰기가 일어나거나 스키마가 바뀌면 고객이 어긋난 데이터를 볼 수 있었습니다. Delta Lake나 Iceberg 같은 Open Table Format이 풀려는 바로 그 문제입니다.</p>
<p>고객이 늘면서는 다른 문제가 커졌습니다. AWS를 직접 쓰는 고객도 있었지만 BigQuery, Databricks, Snowflake를 쓰는 고객도 꽤 있었고, 데이터 자산의 규모가 크다 보니 플랫폼이 늘 때마다 복제본을 하나씩 더 둘 수는 없었습니다. 그래서, Open Table Format으로 옮기면서 같은 사본을 여러 플랫폼에서 읽을 수 있게 하는 방법이 필요했습니다. 데이터를 소비하는 쪽이 회사 내부가 아닌 외부 고객이다 보니, 테이블 포맷을 고르는 일이 곧 고객 플랫폼과의 호환성을 고르는 일이기도 했습니다.</p>
<p>이 글에서는 S3 Parquet과 BigQuery로 이중 운영하던 구조를 Delta Lake 한 벌로 합치고, Delta Sharing과 UniForm으로 네 가지 플랫폼의 고객에게 제공한 과정을 살펴보겠습니다. 각 플랫폼 기능의 동작은 2026년 10월 기준 공식 문서를 함께 참고했습니다. 운영하던 시기와 세부 동작이 다를 수 있다는 점을 미리 밝혀 둡니다.</p>

<h2 id="기존-구조와-문제"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#기존-구조와-문제">기존 구조와 문제</a></h2>
<h3 id="파일-직접-제공의-한계"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#파일-직접-제공의-한계">파일 직접 제공의 한계</a></h3>
<p>파일을 그대로 넘기는 방식에는 세 가지 문제가 있었습니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 540" role="img" aria-labelledby="dba-title dba-desc" xmlns="http://www.w3.org/2000/svg" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYToxN2YyMTE4Yi1kMDQyLTQzMjQtYWM4OS1jMjRlOTgzMDcwNDUAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaHwTJKSbYhgJeuu8ZV425yUAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpjMzY4MTgxYS00ZGUwLTQxYjgtYjgyZi1mNzM0MTUwNmVmODZscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoLHT5q6V6b50laa/zgtfykQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgguopPRpF1VrwJTBwz0/U/CjoE9muRrnWlpsed4+ajSLykZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaBF7ivJ0gxmi59ryyVpglN4AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBHblBk4PBb/wOb1UdsNwR7amCgnJCZNbK6EaLeKSV/yWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBi5Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjE3ZjIxMThiLWQwNDItNDMyNC1hYzg5LWMyNGU5ODMwNzA0NS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjFjODRlMDQwLTliNjgtNGY5Ni04ZmU3LTY3OGQ2ODI4MGY2ZnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCC6ik9GkXVWvAlMHDPT9T8KOgT2a5GudaWmx53j5qNIvKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggxDI+hiWDRU2E451OGqiG1NZ4TWRa0jcmheKhMMNjEMeiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggQMrfLDBYk9D3KthHGgHaN3+DsJGD7Rponh7Z3sWS25R0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQMkV8fL/uYAu8EWMcCCQ9NYu1JUoHwTwytOS6zl9wBc+FGcxf3mibQs+HLe69bxGGXHLk91NrDOFn+67GwknNn0=</c2pa:manifest></metadata>
  <title id="dba-title">이중 보관에서 단일 사본으로</title>
  <desc id="dba-desc">이전에는 같은 데이터를 S3 Parquet과 BigQuery 복제본에 두 번 저장해 고객에게 제공했다. 이후에는 Delta Lake 테이블 하나를 Delta Sharing과 UniForm(Iceberg)으로 나누어 제공한다.</desc>
  <defs>
    <marker id="dba-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="dba-a-delta" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="dg-arrow dg-arrow--delta" d="M0 0L10 5L0 10z"/></marker>
    <marker id="dba-a-ice" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="dg-arrow dg-arrow--iceberg" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 이전 -->
  <rect class="dg-group" x="20" y="22" width="460" height="508" rx="16"/>
  <rect class="dg-group-tab" x="40" y="11" width="172" height="22" rx="11"/>
  <text class="dg-group-label" x="126" y="26.5" text-anchor="middle">이전: 플랫폼마다 복제</text>

  <rect class="dg-node dg-node--internal" x="170" y="72" width="160" height="80" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(240 82) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="250" y="130" text-anchor="middle">내부 파이프라인</text>

  <path class="dg-edge" d="M250 152V184H135V206" marker-end="url(#dba-a)"/>
  <path class="dg-edge" d="M250 152V184H365V206" marker-end="url(#dba-a)"/>
  <text class="dg-edge-label" x="192" y="174" text-anchor="middle">적재</text>
  <text class="dg-edge-label" x="308" y="174" text-anchor="middle">복제</text>

  <path class="dg-node dg-node--store" d="M40 220L54 294A81 10 0 0 0 216 294L230 220"/>
  <ellipse class="dg-node dg-node--store" cx="135" cy="220" rx="95" ry="12"/>
  <text class="dg-label" x="135" y="260" text-anchor="middle">S3 Parquet</text>
  <text class="dg-sub" x="135" y="282" text-anchor="middle">접근 권한을 열어 제공</text>

  <path class="dg-node dg-node--bad" d="M270 218V294A95 10 0 0 0 460 294V218"/>
  <ellipse class="dg-node dg-node--bad" cx="365" cy="218" rx="95" ry="10"/>
  <text class="dg-label" x="365" y="258" text-anchor="middle">BigQuery 복제본</text>
  <text class="dg-sub" x="365" y="280" text-anchor="middle">같은 데이터를 한 번 더 저장</text>

  <path class="dg-edge" d="M135 304V360" marker-end="url(#dba-a)"/>
  <path class="dg-edge" d="M365 304V360" marker-end="url(#dba-a)"/>

  <rect class="dg-node dg-node--consumer" x="40" y="362" width="190" height="60" rx="10"/>
  <text class="dg-label" x="135" y="398" text-anchor="middle">S3 직접 접근 고객</text>
  <rect class="dg-node dg-node--consumer" x="270" y="362" width="190" height="60" rx="10"/>
  <text class="dg-label" x="365" y="398" text-anchor="middle">BigQuery 고객</text>

  <text class="dg-note" x="40" y="468">· 스키마 변경을 테이블 단위로 관리하기 어렵습니다.</text>
  <text class="dg-note" x="40" y="490">· 읽는 도중 쓰기가 일어나도 트랜잭션 보장이 없습니다.</text>
  <text class="dg-note" x="40" y="512">· 같은 데이터를 두 곳에 보관합니다.</text>

  <!-- 전환 -->
  <path class="dg-edge" d="M486 288H514" marker-end="url(#dba-a)"/>

  <!-- 이후 -->
  <rect class="dg-group" x="520" y="22" width="460" height="508" rx="16"/>
  <rect class="dg-group-tab" x="540" y="11" width="132" height="22" rx="11"/>
  <text class="dg-group-label" x="606" y="26.5" text-anchor="middle">이후: 단일 사본</text>

  <rect class="dg-node dg-node--internal" x="670" y="72" width="160" height="80" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(740 82) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="750" y="130" text-anchor="middle">내부 파이프라인</text>

  <path class="dg-edge" d="M750 152V206" marker-end="url(#dba-a)"/>
  <text class="dg-edge-label" x="762" y="192">쓰기</text>

  <path class="dg-node dg-node--store" d="M605 220L619 294A131 10 0 0 0 881 294L895 220"/>
  <ellipse class="dg-node dg-node--store" cx="750" cy="220" rx="145" ry="12"/>
  <text class="dg-label" x="750" y="260" text-anchor="middle">Delta Lake 테이블</text>
  <text class="dg-sub" x="750" y="282" text-anchor="middle">S3 위 단일 사본 (UniForm 활성화)</text>

  <path class="dg-edge dg-edge--delta" d="M635 302V360" marker-end="url(#dba-a-delta)"/>
  <text class="dg-edge-label" x="645" y="336">Delta Sharing</text>
  <path class="dg-edge dg-edge--iceberg" d="M865 302V360" marker-end="url(#dba-a-ice)"/>
  <text class="dg-edge-label" x="875" y="336">Iceberg</text>

  <rect class="dg-node dg-node--consumer" x="540" y="362" width="190" height="60" rx="10"/>
  <text class="dg-label" x="635" y="398" text-anchor="middle">Databricks 고객</text>
  <rect class="dg-node dg-node--consumer" x="770" y="362" width="190" height="60" rx="10"/>
  <text class="dg-label" x="865" y="392" text-anchor="middle">Iceberg 호환 플랫폼</text>
  <text class="dg-sub" x="865" y="411" text-anchor="middle">AWS · Snowflake · BigQuery</text>

  <text class="dg-note" x="540" y="468">· 쓰기는 Delta 한 곳에서만 합니다.</text>
  <text class="dg-note" x="540" y="490">· 테이블 단위로 스키마 변경과 트랜잭션을 관리합니다.</text>
  <text class="dg-note" x="540" y="512">· 중복 저장이 사라집니다.</text>
</svg>
</div></div><figcaption>S3 Parquet과 BigQuery에 이중으로 보관하던 구조와, Delta Lake 한 벌로 합친 구조</figcaption></figure>
<ul>
<li><strong>schema evolution</strong>: 스키마를 기록해 두는 곳이 없어서, 읽는 쪽이 파일마다 스키마를 추론해야 했습니다. 컬럼을 추가하면 예전 파일과 새 파일의 스키마가 달라지고, 엔진마다 이를 합치는 방식이 달라 고객 쪽에서 컬럼이 보이지 않을 수 있었습니다.</li>
<li><strong>트랜잭션 부재</strong>: 고객이 파일을 읽는 도중 같은 경로에 쓰기가 일어나면, 일부만 바뀐 상태를 읽을 수 있었습니다.</li>
<li><strong>이중 보관</strong>: S3와 BigQuery에 같은 데이터가 두 벌 있었습니다.</li>
</ul>
<p>앞의 두 문제는 테이블 포맷을 도입하면 풀립니다. Iceberg나 Delta Lake 같은 테이블 포맷은 스키마와 파일 목록을 메타데이터로 관리하고, 커밋 단위로 원자적인 쓰기를 보장하기 때문입니다. 하지만, 당시 테이블 포맷이나 데이터 처리 엔진을 선택하는 데는 다음과 같은 다른 문제가 있었습니다.</p>
<h3 id="기관-고객-플랫폼의-다양성"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#기관-고객-플랫폼의-다양성">기관 고객 플랫폼의 다양성</a></h3>
<p>기관, 특히 금융권 기관 고객들은 Databricks, BigQuery, Snowflake와 같은 SaaS 형태의 클라우드 데이터 플랫폼을 쓰는 경우가 많았습니다. 그래서, 테이블 포맷을 고른다는 것은 기술적인 점만 고려하는 것이 아닌, 고객의 읽기 편의성도 생각해야 했습니다.</p>
<table><thead><tr><th>고객 플랫폼</th><th>원하는 읽기 방식</th></tr></thead><tbody><tr><td><strong>AWS 직접</strong></td><td>S3 위의 테이블을 Spark, Athena 등으로 읽기</td></tr><tr><td><strong>Databricks</strong></td><td>자기 워크스페이스에서 테이블처럼 읽기</td></tr><tr><td><strong>Snowflake</strong></td><td>외부 테이블로 연결해 SQL로 읽기</td></tr><tr><td><strong>BigQuery</strong></td><td>외부 테이블로 연결해 SQL로 읽기</td></tr></tbody></table>
<p>모든 고객이 각자 플랫폼에서 편하게 가져가면서, 데이터의 중복 저장을 최대한 줄이는 것이 필요한 상황이었습니다.</p>
<h2 id="대안-검토"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#대안-검토">대안 검토</a></h2>
<h3 id="iceberg"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#iceberg">Iceberg</a></h3>
<p>업계에서 오픈소스이면서 엔진 중립적인 테이블 포맷의 주류는 Iceberg였습니다. 다행인 것은 위 SaaS 플랫폼들이 자체적으로 최적화된 managed storage(native table)를 넘어 오픈 테이블 포맷을 지원하는 추세였다는 점입니다. Snowflake와 BigQuery, Databricks의 Spark Engine 모두 Iceberg 외부 테이블에 대한 읽기를 지원하므로, 처음에는 Glue 카탈로그에 Iceberg 테이블을 두고 EMR Spark로 쓰는 구성을 유력하게 봤습니다.</p>
<p>그런데 검토할수록 걸리는 점이 있었습니다.
첫째, 원본을 Iceberg로 두면 Databricks를 쓰는 고객에게 Delta 테이블로 제공할 수 없었습니다. Iceberg 테이블은 Spark로 쓰고 읽을 수 있지만, Databricks 고객이 자기 워크스페이스에서 Delta 테이블로 받아 가는 편의는 원본이 Delta일 때만 줄 수 있었습니다. 반면, Delta Lake로 쓴 테이블은 UniForm으로 Iceberg 메타데이터를 함께 만들 수 있어서, Iceberg 클라이언트에서도 읽을 수 있었습니다. 당시에는 그 반대 방향, 곧 Iceberg 테이블을 Delta로도 읽히게 하는 방법이 없었습니다.<sup><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#user-content-fn-iceberg" id="user-content-fnref-iceberg" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup>
둘째, 적은 데이터 엔지니어로 EMR Spark를 운영하는 비용이나 학습하는 비용이 Databricks에 비해 훨씬 높다는 점이었습니다. 분석가가 많은 회사 특성상, Databricks Notebook의 압도적인 사용성은 매력적이었고 job compute mode, warm-up 등을 잘 사용하면 EMR보다 비싸지도 않았기 때문입니다.</p>
<h3 id="delta-lake와-uniform"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#delta-lake와-uniform">Delta Lake와 UniForm</a></h3>
<p>그래서 결론적으로는, Databricks 플랫폼을 사용하는 것으로 결정을 했습니다. Databricks의 Delta UniForm은 Delta 테이블을 쓸 때 Iceberg 메타데이터를 함께 만들어 줍니다. 용량의 대부분을 차지하는 데이터 파일(Parquet)은 한 벌이지만, Delta로도 읽히고 Iceberg로도 읽히는 것이 가능합니다.</p>
<p>고객은 읽기만 하고 쓰기는 내부 파이프라인에서만 일어나므로, Iceberg 쓰기를 포기하는 비용은 작았습니다. 또한, Databricks 고객에게는 플랫폼 내부에서 Delta Sharing을 통해 데이터를 쉽게 제공하는 것이 가능했습니다.</p>
<table><thead><tr><th>대안</th><th>장점</th><th>단점</th><th>결론</th></tr></thead><tbody><tr><td><strong>현행 유지</strong></td><td>추가 개발 비용 없음</td><td>데이터 중복 문제, 스키마·트랜잭션 문제</td><td>기각</td></tr><tr><td><strong>Iceberg 네이티브</strong> (EMR Spark)</td><td>엔진 중립적</td><td>적은 인원으로의 EMR 운영 부담이 큼</td><td>기각</td></tr><tr><td><strong>Delta Lake + Delta Sharing + UniForm</strong></td><td>쓰기는 한 벌, 네 플랫폼 모두 읽기 가능, Databricks 운영 부담 적음</td><td>Iceberg 쓰기 불가, Databricks 의존</td><td>채택</td></tr></tbody></table>
<h2 id="구조"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#구조">구조</a></h2>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 430" role="img" aria-labelledby="dus-title dus-desc" xmlns="http://www.w3.org/2000/svg" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpkYzM1ZjZlNC1mMjMxLTRmODYtOTljMi1iYzhhMWI5OTY3YjQAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaHWXO8cFUoo3zbe1cGtWaMUAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo5MWU1Y2U4My0zY2U1LTQ2YTMtODhhMy0yMzFkYWZhY2VkYjJscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo2B3L42+bKw+cqlRLGTQw4AAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggrPVo/KgiaVDSyYh1Xi3QLu985RXedSLewvuVsGVZk06kZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaHcjcpXNagwNkv5/D+Pok1sAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCC7vFWxX4rx5LYMK2Dp2XedrjJveKsAH0Fa8DM9jDBRVGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBi5Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmRjMzVmNmU0LWYyMzEtNGY4Ni05OWMyLWJjOGExYjk5NjdiNC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjY5MzVlMGM5LTRkMDktNDUzNi04N2NhLTRiOTA1N2QxOTUxYnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCs9Wj8qCJpUNLJiHVeLdAu73zlFd51It7C+5WwZVmTTqJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggwDzWjIv7oradm3TVTq39YFS0lUg6deg3Mt8rQN8bVQSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgghbKADn3/ha9ebiSC6qx4/XfbS1xuMdRtG9TJfxq3s2t0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQPyYAM8GAc0ZOJtcngJQxappxyQeh88K13xokq6/o0BCsjHfSGbbFdn3i5ROhrcHAmgoYom4BcAk86vLL59orlk=</c2pa:manifest></metadata>
  <title id="dus-title">Delta UniForm 서빙 구조</title>
  <desc id="dus-desc">내부 파이프라인이 Delta 형식으로 쓴 하나의 데이터 파일 사본을, Delta 로그와 Iceberg 메타데이터 두 가지로 읽는다. Unity Catalog를 통해 Databricks 고객에게는 Delta Sharing으로, Iceberg 호환 플랫폼 고객에게는 Iceberg REST Catalog로 제공한다.</desc>
  <defs>
    <marker id="dus-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="dus-a-delta" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="dg-arrow dg-arrow--delta" d="M0 0L10 5L0 10z"/></marker>
    <marker id="dus-a-ice" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="dg-arrow dg-arrow--iceberg" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 단일 사본 영역 -->
  <rect class="dg-group" x="260" y="60" width="416" height="320" rx="16"/>
  <rect class="dg-group-tab" x="272" y="49" width="380" height="22" rx="11"/>
  <text class="dg-group-label" x="462" y="64.5" text-anchor="middle">Unity Catalog에 등록된 Delta 테이블 · S3 위 단일 사본</text>

  <!-- 내부 파이프라인 -->
  <rect class="dg-node dg-node--internal" x="20" y="164" width="180" height="120" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(100 176) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="110" y="224" text-anchor="middle">내부 파이프라인</text>
  <text class="dg-sub" x="110" y="246" text-anchor="middle">적재·변환 (쓰기 주체)</text>

  <!-- 데이터 파일 -->
  <path class="dg-node dg-node--store" d="M284 108L298 342A56 10 0 0 0 410 342L424 108"/>
  <ellipse class="dg-node dg-node--store" cx="354" cy="108" rx="70" ry="12"/>
  <text class="dg-label" x="354" y="214" text-anchor="middle">Parquet</text>
  <text class="dg-label" x="354" y="236" text-anchor="middle">데이터 파일</text>
  <text class="dg-sub" x="354" y="262" text-anchor="middle">한 벌만 저장</text>

  <!-- 메타데이터 -->
  <rect class="dg-node dg-node--delta" x="500" y="96" width="156" height="80" rx="10"/>
  <text class="dg-label" x="578" y="132" text-anchor="middle">Delta 로그</text>
  <text class="dg-sub" x="578" y="154" text-anchor="middle">_delta_log</text>

  <rect class="dg-node dg-node--iceberg" x="500" y="272" width="156" height="80" rx="10"/>
  <text class="dg-label" x="578" y="308" text-anchor="middle">Iceberg 메타데이터</text>
  <text class="dg-sub" x="578" y="330" text-anchor="middle">UniForm이 함께 생성</text>

  <!-- 쓰기 -->
  <path class="dg-edge" d="M200 224H289" marker-end="url(#dus-a)"/>
  <text class="dg-edge-label" x="245" y="212" text-anchor="middle">쓰기</text>

  <!-- 참조 -->
  <path class="dg-edge dg-edge--delta" d="M500 136H422" marker-end="url(#dus-a-delta)"/>
  <text class="dg-edge-label" x="461" y="126" text-anchor="middle">참조</text>
  <path class="dg-edge dg-edge--iceberg" d="M500 312H411" marker-end="url(#dus-a-ice)"/>
  <text class="dg-edge-label" x="456" y="302" text-anchor="middle">참조</text>

  <!-- UniForm 변환 -->
  <path class="dg-edge dg-edge--dashed" d="M578 176V270" marker-end="url(#dus-a)"/>
  <text class="dg-edge-label" x="592" y="218">UniForm</text>
  <text class="dg-edge-label" x="592" y="236">변환</text>

  <!-- 소비자 -->
  <path class="dg-node dg-node--consumer" d="M32 96H116A28 28 0 0 0 124 41A38 38 0 0 0 54 30A30 30 0 0 0 28 96Z" transform="translate(772 58) scale(1.44 1.18)" vector-effect="non-scaling-stroke"/>
  <text class="dg-label" x="880" y="127" text-anchor="middle">Databricks 고객</text>
  <text class="dg-sub" x="880" y="149" text-anchor="middle">Delta 테이블 그대로 읽기</text>

  <path class="dg-node dg-node--consumer" d="M32 96H116A28 28 0 0 0 124 41A38 38 0 0 0 54 30A30 30 0 0 0 28 96Z" transform="translate(772 234) scale(1.44 1.18)" vector-effect="non-scaling-stroke"/>
  <text class="dg-label" x="880" y="303" text-anchor="middle">Iceberg 호환 플랫폼 고객</text>
  <text class="dg-sub" x="880" y="325" text-anchor="middle">AWS · Snowflake · BigQuery</text>

  <path class="dg-edge dg-edge--delta" d="M656 136H777" marker-end="url(#dus-a-delta)"/>
  <text class="dg-edge-label" x="724" y="126" text-anchor="middle">Delta Sharing</text>
  <path class="dg-edge dg-edge--iceberg" d="M656 312H777" marker-end="url(#dus-a-ice)"/>
  <text class="dg-edge-label" x="724" y="285" text-anchor="middle">Iceberg</text>
  <text class="dg-edge-label" x="724" y="301" text-anchor="middle">REST Catalog</text>

  <text class="dg-note" x="500" y="410" text-anchor="middle">데이터 파일은 한 벌만 두고, 메타데이터를 두 종류로 만들어 Unity Catalog를 통해 서로 다른 플랫폼에 제공합니다.</text>
</svg>
</div></div><figcaption>Delta 테이블 한 벌을 Delta Sharing과 Iceberg REST Catalog 두 경로로 제공하는 구조</figcaption></figure>
<p>실시간 데이터는 앞단의 Database에 실시간으로 적재되고, 이를 분석 목적으로 활용하기 위해 CDC로 데이터를 적재합니다. 그리고, 각각의 도메인에 맞게 정의된 Spark Job들이 Unity Catalog에 등록된 Delta 테이블에 데이터를 적재합니다(INSERT + UPSERT). 블록체인 데이터 특성상 UPSERT는 sliding window처럼 최근 n개 구간의 데이터만 고쳤고, 테이블이 클러스터링되어 있어서 테이블 전체를 다시 쓰는 일은 없었습니다.</p>
<ul>
<li><strong>Delta Sharing</strong>: Databricks를 쓰는 고객에게 Delta 테이블을 그대로 공유합니다. 권한은 Databricks의 거버넌스 기능으로 관리했습니다.</li>
<li><strong>UniForm + Iceberg REST Catalog</strong>: AWS를 직접 쓰거나 Snowflake를 쓰는 고객은 Unity Catalog가 제공하는 Iceberg REST Catalog를 이용해 플랫폼에서 Iceberg 테이블을 읽습니다. BigQuery를 쓰는 고객은 BigQuery Omni로 Iceberg 테이블을 읽습니다.</li>
</ul>
<h3 id="uniform-설정"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#uniform-설정">UniForm 설정</a></h3>
<p>UniForm은 Delta 테이블의 TBLPROPERTIES로 켤 수 있습니다. 테이블은 Unity Catalog에 등록되어 있어야 하고, Databricks Runtime 14.3 LTS 이상이어야 합니다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog"><code data-language="sql" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog" style="display:grid"><span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">CREATE</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> TABLE</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> market</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">history</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">exchange_flows</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> (</span></span>
<span data-line=""><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">  asset STRING,</span></span>
<span data-line=""><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">  exchange STRING,</span></span>
<span data-line=""><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">  ts </span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">TIMESTAMP</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">,</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">  value</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> DOUBLE</span></span>
<span data-line=""><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">)</span></span>
<span data-line=""><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">TBLPROPERTIES (</span></span>
<span data-line=""><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">  &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">delta.columnMapping.mode</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">id</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">,</span></span>
<span data-line=""><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">  &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">delta.enableIcebergCompatV2</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">true</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">,</span></span>
<span data-line=""><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">  &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">delta.universalFormat.enabledFormats</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">iceberg</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span></span>
<span data-line=""><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">);</span></span></code></pre></figure>
<p>UniForm은 column mapping이 켜진 테이블에서만 동작합니다. 새 테이블은 만들 때 UniForm을 켜면 column mapping도 함께 켜지고, Databricks는 호환성을 위해 위 예시처럼 <code>id</code> 모드를 권장합니다. 이미 있는 테이블에 켤 때는 Databricks Runtime 15.4 LTS 이상에서 column mapping을 <code>name</code> 모드로 함께 설정합니다 <span class="citation" id="citation--databricks2026uniform--1">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-databricks2026uniform">Databricks, 2026</a>)</span>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog"><code data-language="sql" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog" style="display:grid"><span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">ALTER</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> TABLE</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> market</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">history</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.exchange_flows </span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">SET</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> TBLPROPERTIES (</span></span>
<span data-line=""><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">  &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">delta.columnMapping.mode</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">name</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">,</span></span>
<span data-line=""><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">  &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">delta.enableIcebergCompatV2</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">true</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">,</span></span>
<span data-line=""><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">  &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">delta.universalFormat.enabledFormats</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">iceberg</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span></span>
<span data-line=""><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">);</span></span></code></pre></figure>
<h2 id="받아들인-제약"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#받아들인-제약">받아들인 제약</a></h2>
<h3 id="읽기-전용-iceberg"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#읽기-전용-iceberg">읽기 전용 Iceberg</a></h3>
<p>UniForm을 켠 Delta 테이블은 Iceberg 클라이언트에서 읽기만 할 수 있습니다. 고객이 데이터를 쓸 일은 없었기 때문에 문제가 되지 않았습니다.</p>
<h3 id="신선도-지연"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#신선도-지연">신선도 지연</a></h3>
<p>Delta Sharing 고객은 커밋이 끝나면 바로 새 데이터를 관찰할 수 있습니다. 하지만, Iceberg로 읽는 고객이 새 데이터를 보기까지는 지연이 두 번 생깁니다.</p>
<p>첫 번째로 지연이 발생하는 지점은 UniForm 변환입니다. Iceberg 메타데이터는 Delta 커밋을 마친 compute가 커밋 뒤에 비동기로 생성합니다. 한 compute에서 변환 작업이 한 번에 하나만 돌기 때문에, 메타데이터 변환 중에 들어온 커밋은 다음 변환에서야 묶여 반영됩니다. 따라서, Delta 버전과 Iceberg 스냅샷이 하나씩 대응한다는 보장도 없습니다 <span class="citation" id="citation--databricks2026uniform--2">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-databricks2026uniform">Databricks, 2026</a>)</span>.</p>
<p>두 번째는 소비처에서의 카탈로그 갱신 방식입니다. Spark처럼 Iceberg REST Catalog에 직접 붙는 클라이언트는 테이블을 열 때 카탈로그에서 최신 메타데이터 위치를 받아 갑니다. 다만, Iceberg의 Spark catalog는 불러온 테이블을 기본 30초 동안 캐시하므로, 한 세션 안에서는 새 스냅샷이 그만큼 늦게 보일 수 있습니다 <span class="citation" id="citation--apache2026spark--3">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-apache2026spark">Apache Iceberg, 2026</a>)</span>. 반면, Snowflake와 BigQuery는 테이블 정의를 자기 쪽에 등록해 두기 때문에 메타데이터를 다시 읽어야 새 스냅샷이 보입니다. Snowflake는 테이블을 만들 때 <code>AUTO_REFRESH</code>를 켜 두면 카탈로그를 기본 30초 간격으로 확인합니다. 이 옵션은 기본값이 꺼져 있어서, 켜지 않은 테이블은 <code>ALTER ICEBERG TABLE ... REFRESH</code>로 직접 갱신해야 합니다 <span class="citation" id="citation--snowflake2026create--snowflake2026autorefresh--snowflake2026refresh--4">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-snowflake2026refresh">Snowflake, 2026</a>c, <a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-snowflake2026autorefresh">2026</a>b, <a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-snowflake2026create">2026</a>a)</span>. BigQuery는 BigQuery Omni에서 S3의 Iceberg 테이블을 BigLake 테이블로 등록해 읽었습니다 <span class="citation" id="citation--google2026omni--5">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-google2026omni">Google Cloud, 2026</a>)</span>. 이렇게 등록한 테이블은 정의가 특정 <code>metadata.json</code> 파일을 가리키기 때문에, 새 스냅샷이 생겨도 이 경로를 바꿔 주기 전까지는 이전 스냅샷을 읽습니다 <span class="citation" id="citation--google2026iceberg--6">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-google2026iceberg">Google Cloud, 2026b</a>)</span>. 그래서, 최신 <code>metadata.json</code> 경로를 주기적으로 찾아 BigQuery의 테이블 정의에 반영하는 작업을 따로 돌렸고, BigQuery를 통해 데이터를 조회하는 경우 이 주기만큼 데이터가 늦게 보이게 됩니다.<sup><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#user-content-fn-bq" id="user-content-fnref-bq" data-footnote-ref="true" aria-describedby="footnote-label">2</a></sup></p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 384" role="img" aria-labelledby="uwv-title uwv-desc" xmlns="http://www.w3.org/2000/svg" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo0OGM3YjkyZC1hOTNhLTRmZWQtOGIzZS0wNmRhODcyM2U0ZDgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaDUl2djmqH/99cxxd68t+RIAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDoxZjg2OGE1MS1lNTNhLTRkYWMtODg4Mi0xMWNmNmMwYTk1NzlscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoEuSU/ycq6WJbI9CAbPHqXgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggXtkO8PLXg91amU2P4uaBWd2Do1Q0irvPhbjRniOWQ/qkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaFbdkOtqkNGwLXQVyZykmr0AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCAl3lr0G4TEvKcdfem7cVVZuCKmoylq3bbzlmkYvMmsz2RuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBi5Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjQ4YzdiOTJkLWE5M2EtNGZlZC04YjNlLTA2ZGE4NzIzZTRkOC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjc2OWY5YzUwLTcxMWYtNDcwOC05MDQwLTFkN2M0NmUzNTI1N3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCBe2Q7w8teD3VqZTY/i5oFZ3YOjVDSKu8+FuNGeI5ZD+qJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggMHWSaTfc59443x5fq7CEv1zAFxbElElmPC0c8v8+pJSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgge1iOu7GQTrPxxXttPkfSUl86ZRJIscota+WBQtzgtQd0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQECjOTu7UmdAHot+x2a8r26XFwfYufipl7+/HDHpXLR68QXr88unUgdch6/awX8CNz759qh/Dm6vtqRY2bKyFlY=</c2pa:manifest></metadata>
  <title id="uwv-title">한 번의 쓰기가 고객에게 보이기까지</title>
  <desc id="uwv-desc">내부 파이프라인이 Delta 테이블에 커밋하면 Delta Sharing 고객은 바로 새 데이터를 읽을 수 있다. Iceberg로 읽는 고객은 UniForm이 Iceberg 메타데이터를 만든 뒤에, Snowflake와 BigQuery 고객은 소비 엔진이 메타데이터를 다시 읽은 뒤에야 새 데이터를 본다. 신선도 지연은 UniForm 변환 지연과 소비 엔진 갱신 지연 두 구간으로 나뉜다.</desc>
  <defs>
    <marker id="uwv-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <text class="dg-group-label" x="16" y="30">시간 흐름</text>
  <path class="dg-edge" d="M16 44H984" marker-end="url(#uwv-a)"/>

  <!-- 1 쓰기 -->
  <rect class="dg-node dg-node--internal" x="16" y="72" width="156" height="100" rx="10"/>
  <text class="dg-label" x="94" y="108" text-anchor="middle">쓰기</text>
  <text class="dg-sub" x="94" y="130" text-anchor="middle">내부 파이프라인이</text>
  <text class="dg-sub" x="94" y="148" text-anchor="middle">Delta 테이블에 커밋</text>
  <circle class="dg-badge" cx="16" cy="72" r="12"/>
  <text class="dg-badge-text" x="16" y="76.5" text-anchor="middle">1</text>

  <!-- 2 Delta 반영 -->
  <rect class="dg-node dg-node--delta" x="220" y="72" width="156" height="100" rx="10"/>
  <text class="dg-label" x="298" y="108" text-anchor="middle">Delta 반영</text>
  <text class="dg-sub" x="298" y="130" text-anchor="middle">Parquet 파일과</text>
  <text class="dg-sub" x="298" y="148" text-anchor="middle">Delta 로그 갱신</text>
  <circle class="dg-badge" cx="220" cy="72" r="12"/>
  <text class="dg-badge-text" x="220" y="76.5" text-anchor="middle">2</text>

  <!-- 3 UniForm 변환 -->
  <rect class="dg-node dg-node--iceberg" x="424" y="72" width="156" height="100" rx="10"/>
  <text class="dg-label" x="502" y="108" text-anchor="middle">UniForm 변환</text>
  <text class="dg-sub" x="502" y="130" text-anchor="middle">커밋 뒤 비동기로</text>
  <text class="dg-sub" x="502" y="148" text-anchor="middle">Iceberg 메타데이터 생성</text>
  <circle class="dg-badge" cx="424" cy="72" r="12"/>
  <text class="dg-badge-text" x="424" y="76.5" text-anchor="middle">3</text>

  <!-- 4 Iceberg 반영 -->
  <rect class="dg-node dg-node--iceberg" x="628" y="72" width="156" height="100" rx="10"/>
  <text class="dg-label" x="706" y="108" text-anchor="middle">Iceberg 반영</text>
  <text class="dg-sub" x="706" y="130" text-anchor="middle">카탈로그가</text>
  <text class="dg-sub" x="706" y="148" text-anchor="middle">새 스냅샷을 가리킴</text>
  <circle class="dg-badge" cx="628" cy="72" r="12"/>
  <text class="dg-badge-text" x="628" y="76.5" text-anchor="middle">4</text>

  <!-- 5 소비 엔진 갱신 -->
  <rect class="dg-node dg-node--consumer" x="832" y="72" width="156" height="100" rx="10"/>
  <text class="dg-label" x="910" y="108" text-anchor="middle">소비 엔진 갱신</text>
  <text class="dg-sub" x="910" y="130" text-anchor="middle">Snowflake, BigQuery가</text>
  <text class="dg-sub" x="910" y="148" text-anchor="middle">새 스냅샷을 읽음</text>
  <circle class="dg-badge" cx="832" cy="72" r="12"/>
  <text class="dg-badge-text" x="832" y="76.5" text-anchor="middle">5</text>

  <path class="dg-edge" d="M172 122H218" marker-end="url(#uwv-a)"/>
  <path class="dg-edge" d="M376 122H422" marker-end="url(#uwv-a)"/>
  <path class="dg-edge" d="M580 122H626" marker-end="url(#uwv-a)"/>
  <path class="dg-edge" d="M784 122H830" marker-end="url(#uwv-a)"/>

  <!-- 읽을 수 있는 구간 -->
  <text class="dg-sub" x="220" y="208">Databricks 고객(Delta Sharing): 2단계부터</text>
  <rect class="dg-node dg-node--delta" x="220" y="216" width="768" height="8" rx="4"/>

  <text class="dg-sub" x="616" y="260" text-anchor="end">Iceberg REST Catalog에 직접 붙는 클라이언트(Spark 등): 4단계부터</text>
  <rect class="dg-node dg-node--iceberg" x="628" y="252" width="360" height="8" rx="4"/>

  <text class="dg-sub" x="820" y="296" text-anchor="end">Snowflake, BigQuery 고객: 5단계부터</text>
  <rect class="dg-node dg-node--consumer" x="832" y="288" width="156" height="8" rx="4"/>

  <!-- 신선도 지연 -->
  <path class="dg-edge dg-edge--dashed" d="M222 336H626" marker-start="url(#uwv-a)" marker-end="url(#uwv-a)"/>
  <text class="dg-note" x="424" y="362" text-anchor="middle">UniForm 변환 지연</text>
  <path class="dg-edge dg-edge--dashed" d="M630 336H830" marker-start="url(#uwv-a)" marker-end="url(#uwv-a)"/>
  <text class="dg-note" x="730" y="362" text-anchor="middle">소비 엔진 갱신 지연</text>
</svg>
</div></div><figcaption>Delta 커밋 이후 고객 유형별로 새 데이터가 보이는 시점</figcaption></figure>
<p>데이터 레이크를 통해 제공하는 데이터를 소비하는 고객이 원한 것은 실시간성보다는 히스토리 데이터를 백필하는 것이었기 때문에 이 지연은 감내할 수 있었습니다. 변환이 밀렸을 때는 동기적으로 작동하는 <code>MSCK REPAIR TABLE &lt;table&gt; SYNC METADATA</code>로 수동 변환을 걸 수 있습니다.</p>
<h3 id="databricks-의존성"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#databricks-의존성">Databricks 의존성</a></h3>
<p>쓰기와 데이터 거버넌스가 모두 Databricks(Unity Catalog)에 묶입니다. 플랫폼의 사용성이 뛰어나다고 판단하기도 했고, EMR을 직접 운영하는 부담을 줄이는 대가로 받아들인 의존성입니다.</p>
<h2 id="결과"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#결과">결과</a></h2>
<ul>
<li>수백 개의 테이블을 Delta Lake로 합쳤고, S3와 BigQuery에 이중으로 보관하던 수백 TB의 데이터를 줄일 수 있게 되었습니다.</li>
<li>쓰기 트랜잭션, 스키마 변경 등을 추상화된 테이블 포맷 인터페이스(SQL)를 통해 간편하게 Spark SQL로 수행할 수 있게 되었습니다.</li>
<li>Snowflake와 BigQuery를 쓰는 고객이 자기 플랫폼에서 직접 데이터를 연결할 수 있게 되어, API 외의 데이터 판매 채널이 생겼습니다.</li>
</ul>
<h2 id="복기"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#복기">복기</a></h2>
<p>Unity Catalog 기반의 거버넌스 관리, 데이터 중복 제거 등 만족스러운 점은 많았지만 기관 고객들이 중요하게 생각할 데이터 품질 지표인 SLA(Service Level Agreement) 세팅이나, 이를 관리하기 위한 데이터 품질 관리 플랫폼을 동시에 붙이지 못했던 점은 아쉬운 점입니다. 당시 DataHub를 구축하는 것을 계획하고 있었는데, Unity Catalog에도 품질이나 리니지 관련 기능이 꾸준히 추가가 되고 있어 앞으로는 이 기능으로도 충분하지 않을까 하는 생각이 들긴 했습니다.</p>
<section data-footnotes="true" class="footnotes"><h2 class="sr-only" id="footnote-label">각주</h2>
<ol>
<li id="user-content-fn-iceberg">
<p>2026년 10월 기준 Databricks는 Unity Catalog에 Iceberg 테이블을 직접 만들어 읽고 쓰는 managed Iceberg 테이블을 제공합니다(Databricks Runtime 16.4 LTS 이상) <span class="citation" id="citation--databricks2026iceberg--7">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-databricks2026iceberg">Databricks, n.d.</a>)</span>. <a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#user-content-fnref-iceberg" data-footnote-backref="" aria-label="본문 1번 각주 위치로 돌아가기" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-bq">
<p>2026년 10월 기준 Google은 <code>metadata.json</code>을 지정하는 Iceberg external table을 대부분의 경우에 권장하지 않습니다. Unity Catalog 같은 원격 카탈로그가 관리하는 테이블은 Lakehouse runtime catalog의 cross-cloud data access로 연결하도록 안내합니다. Lakehouse는 BigLake가 바뀐 이름이고, 이 기능은 원격 카탈로그의 Iceberg REST 엔드포인트에 붙어 메타데이터를 주기적으로 동기화합니다. 아직 Preview 단계입니다 <span class="citation" id="citation--google2026iceberg--google2026crosscloud--8">(<a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-google2026crosscloud">Google Cloud, 2026</a>c, <a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#bib-google2026iceberg">2026</a>a)</span>. <a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#user-content-fnref-bq" data-footnote-backref="" aria-label="본문 2번 각주 위치로 돌아가기" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section><h2 id="참고문헌"><a href="https://jerrylog.dev/ko/blog/delta-uniform-multi-platform-serving#참고문헌">참고문헌</a></h2><ul id="refs" class="references"><li class="csl-entry" id="bib-apache2026spark">Apache Iceberg. (2026). <i>Spark configuration</i>. <a href="https://iceberg.apache.org/docs/latest/spark-configuration/">https://iceberg.apache.org/docs/latest/spark-configuration/</a></li><li class="csl-entry" id="bib-databricks2026iceberg">Databricks. (n.d.). <i>What is Apache Iceberg in Databricks?</i> Retrieved October 22, 2026, from <a href="https://docs.databricks.com/aws/iceberg">https://docs.databricks.com/aws/iceberg</a></li><li class="csl-entry" id="bib-databricks2026external">Databricks. (2026a). <i>Access Databricks data using external systems</i>. <a href="https://docs.databricks.com/aws/en/external-access/">https://docs.databricks.com/aws/en/external-access/</a></li><li class="csl-entry" id="bib-databricks2026uniform">Databricks. (2026b). <i>Read Delta Lake tables with Iceberg clients</i>. <a href="https://docs.databricks.com/aws/en/delta/uniform">https://docs.databricks.com/aws/en/delta/uniform</a></li><li class="csl-entry" id="bib-databricks2026credential">Databricks. (2026c). <i>Unity Catalog credential vending for external system access</i>. <a href="https://docs.databricks.com/aws/en/external-access/credential-vending">https://docs.databricks.com/aws/en/external-access/credential-vending</a></li><li class="csl-entry" id="bib-google2026crosscloud">Google Cloud. (2026a). <i>About cross-cloud data access</i>. <a href="https://docs.cloud.google.com/lakehouse/docs/about-cross-cloud-data-access">https://docs.cloud.google.com/lakehouse/docs/about-cross-cloud-data-access</a></li><li class="csl-entry" id="bib-google2026omni">Google Cloud. (2026b). <i>Create Amazon S3 BigLake tables</i>. <a href="https://docs.cloud.google.com/bigquery/docs/omni-aws-create-external-table">https://docs.cloud.google.com/bigquery/docs/omni-aws-create-external-table</a></li><li class="csl-entry" id="bib-google2026iceberg">Google Cloud. (2026c). <i>Create Apache Iceberg external tables</i>. <a href="https://docs.cloud.google.com/bigquery/docs/iceberg-external-tables">https://docs.cloud.google.com/bigquery/docs/iceberg-external-tables</a></li><li class="csl-entry" id="bib-microsoft2026iceberg">Microsoft. (2026). <i>Access Azure Databricks tables from Apache Iceberg clients</i>. <a href="https://learn.microsoft.com/en-us/azure/databricks/external-access/iceberg">https://learn.microsoft.com/en-us/azure/databricks/external-access/iceberg</a></li><li class="csl-entry" id="bib-snowflake2026refresh">Snowflake. (2026a). <i>ALTER ICEBERG TABLE … REFRESH</i>. <a href="https://docs.snowflake.com/en/sql-reference/sql/alter-iceberg-table-refresh">https://docs.snowflake.com/en/sql-reference/sql/alter-iceberg-table-refresh</a></li><li class="csl-entry" id="bib-snowflake2026autorefresh">Snowflake. (2026b). <i>Automatically refresh Apache Iceberg tables</i>. <a href="https://docs.snowflake.com/en/user-guide/tables-iceberg-auto-refresh">https://docs.snowflake.com/en/user-guide/tables-iceberg-auto-refresh</a></li><li class="csl-entry" id="bib-snowflake2026create">Snowflake. (2026c). <i>CREATE ICEBERG TABLE (Iceberg REST catalog)</i>. <a href="https://docs.snowflake.com/en/sql-reference/sql/create-iceberg-table-rest">https://docs.snowflake.com/en/sql-reference/sql/create-iceberg-table-rest</a></li></ul>]]></content:encoded>
      <pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate>
      <category><![CDATA[delta-lake]]></category><category><![CDATA[iceberg]]></category><category><![CDATA[uniform]]></category><category><![CDATA[delta-sharing]]></category><category><![CDATA[databricks]]></category><category><![CDATA[data-platform]]></category>
    </item>
<item>
      <title><![CDATA[Why I built QTG]]></title>
      <link>https://jerrylog.dev/ko/blog/why-qtg</link>
      <guid isPermaLink="true">https://jerrylog.dev/ko/blog/why-qtg</guid>
      <description><![CDATA[거래소 주문 API 표준화는 많은데 입출금은 왜 없을까. 입출금 표준화, KMS 기반 서명, DAG 송금 모델, Airflow를 참고한 스케줄링까지 QTG의 설계 배경을 정리합니다.]]></description>
      <content:encoded><![CDATA[<p>이 글에서는 QTG(Quant Transfer Guard)를 만들게 된 세 가지 이유와, 이 이유들이 구조와 설계 결정에 어떻게 반영되었는지 살펴보겠습니다. QTG는 전략이나 에이전트가 보낸 송금 요청을 승인 절차를 거쳐 실행하고, 출금부터 입금 확인까지 전 과정을 기록합니다. 사용자가 자기 서버에 직접 설치해 운영하는(self-hosted) 방식이며, 자세한 동작은 <a href="https://jephalabs.mintlify.app/">QTG 문서</a>에 정리해 두었습니다.</p>
<h2 id="qtg-개발-배경"><a href="https://jerrylog.dev/ko/blog/why-qtg#qtg-개발-배경">QTG 개발 배경</a></h2>
<h3 id="송금-추적-표준화-부재"><a href="https://jerrylog.dev/ko/blog/why-qtg#송금-추적-표준화-부재">송금 추적 표준화 부재</a></h3>
<p>CCXT 같은 거래소 연동 라이브러리에서는 출금 요청과 조회가 표준화되어 있습니다. 예를 들어, CCXT에는 <code>withdraw</code>, <code>fetchDepositAddress</code>, <code>fetchDeposits</code>, <code>fetchWithdrawals</code>와 같은 통합 메서드가 있습니다. <code>fetchWithdrawals</code>는 출금 내역을 조회하는 메서드로, 거래소마다 다른 상태 값을 <code>pending</code>, <code>ok</code>, <code>failed</code>, <code>canceled</code> 네 가지로 바꿔 돌려줍니다.</p>
<p>하지만, CCXT는 조회 한 번의 응답만 표준화합니다. 출금이 끝날 때까지 언제 다시 조회할지, 출발 거래소의 txid를 도착 거래소의 입금 내역에서 어떻게 찾을지, 응답이 끊겼을 때 출금이 나갔는지 어떻게 확인할지는 사용자가 직접 정해야 합니다.</p>
<p>거래소 간 송금 한 건은 대략 다음 순서로 진행됩니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 20 1000 228" role="img" aria-labelledby="qts-title qts-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qts-title">거래소 간 송금 한 건의 다섯 단계</title>
  <desc id="qts-desc">출발 거래소에 출금을 요청하면 거래소가 내부 심사를 거쳐 체인에 트랜잭션을 보내고, confirmation이 쌓인 뒤 도착 거래소가 txid로 입금을 반영한다. 출금 API는 첫 단계에서만 응답하고, 나머지 네 단계는 사용자가 직접 추적해야 한다.</desc>
  <defs>
    <marker id="qts-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 위치 묶음 -->
  <rect class="dg-group" x="8" y="36" width="384" height="144" rx="14"/>
  <text class="dg-group-label" x="24" y="58">출발 거래소</text>
  <rect class="dg-group" x="408" y="36" width="384" height="144" rx="14"/>
  <text class="dg-group-label" x="424" y="58">블록체인</text>
  <rect class="dg-group" x="808" y="36" width="184" height="144" rx="14"/>
  <text class="dg-group-label" x="824" y="58">도착 거래소</text>

  <!-- 단계 -->
  <rect class="dg-node dg-node--accent" x="20" y="76" width="160" height="88" rx="10"/>
  <text class="dg-label" x="100" y="112" text-anchor="middle">출금 요청</text>
  <text class="dg-sub" x="100" y="134" text-anchor="middle">API 응답은</text>
  <text class="dg-sub" x="100" y="150" text-anchor="middle">"요청 접수"</text>
  <circle class="dg-badge" cx="20" cy="76" r="12"/>
  <text class="dg-badge-text" x="20" y="80.5" text-anchor="middle">1</text>

  <rect class="dg-node dg-node--warn" x="220" y="76" width="160" height="88" rx="10"/>
  <text class="dg-label" x="300" y="112" text-anchor="middle">심사와 처리</text>
  <text class="dg-sub" x="300" y="134" text-anchor="middle">거래소 내부</text>
  <text class="dg-sub" x="300" y="150" text-anchor="middle">수 분에서 수 시간</text>
  <circle class="dg-badge" cx="220" cy="76" r="12"/>
  <text class="dg-badge-text" x="220" y="80.5" text-anchor="middle">2</text>

  <rect class="dg-node dg-node--warn" x="420" y="76" width="160" height="88" rx="10"/>
  <text class="dg-label" x="500" y="112" text-anchor="middle">트랜잭션 전송</text>
  <text class="dg-sub" x="500" y="134" text-anchor="middle">txid 생성</text>
  <circle class="dg-badge" cx="420" cy="76" r="12"/>
  <text class="dg-badge-text" x="420" y="80.5" text-anchor="middle">3</text>

  <rect class="dg-node dg-node--warn" x="620" y="76" width="160" height="88" rx="10"/>
  <text class="dg-label" x="700" y="112" text-anchor="middle">confirmation 누적</text>
  <text class="dg-sub" x="700" y="134" text-anchor="middle">필요 횟수는</text>
  <text class="dg-sub" x="700" y="150" text-anchor="middle">체인과 거래소마다 다름</text>
  <circle class="dg-badge" cx="620" cy="76" r="12"/>
  <text class="dg-badge-text" x="620" y="80.5" text-anchor="middle">4</text>

  <rect class="dg-node dg-node--ok" x="820" y="76" width="160" height="88" rx="10"/>
  <text class="dg-label" x="900" y="112" text-anchor="middle">입금 반영</text>
  <text class="dg-sub" x="900" y="134" text-anchor="middle">입금 내역에서</text>
  <text class="dg-sub" x="900" y="150" text-anchor="middle">txid로 대조</text>
  <circle class="dg-badge" cx="820" cy="76" r="12"/>
  <text class="dg-badge-text" x="820" y="80.5" text-anchor="middle">5</text>

  <path class="dg-edge" d="M180 120H218" marker-end="url(#qts-a)"/>
  <path class="dg-edge" d="M380 120H418" marker-end="url(#qts-a)"/>
  <path class="dg-edge" d="M580 120H618" marker-end="url(#qts-a)"/>
  <path class="dg-edge" d="M780 120H818" marker-end="url(#qts-a)"/>

  <!-- 추적 구간 -->
  <path class="dg-edge dg-edge--accent" d="M20 196V204H180V196"/>
  <text class="dg-edge-label" x="100" y="228" text-anchor="middle">출금 API가 답하는 구간</text>
  <path class="dg-edge dg-edge--warn" d="M220 196V204H980V196"/>
  <text class="dg-edge-label" x="600" y="228" text-anchor="middle">사용자가 직접 추적해야 하는 구간</text>

</svg>
</div></div><figcaption>거래소 간 송금 한 건에 해당하는 다섯 단계</figcaption></figure>
<p>출금 API는 1번 단계에서 응답을 돌려줍니다. 2번부터 5번까지의 상태는 사용자가 직접 추적해야 합니다. 거래소마다 차이가 가장 큰 것도 이 구간입니다. 출금 상태를 나타내는 문자열이 거래소마다 다르고, 문서에 없는 값이 내려오기도 합니다. CCXT의 변환도 완전하지는 않습니다. CCXT 4.5.88의 Upbit 구현은 출금 상태 <code>accepted</code>를 <code>ok</code>로 바꾸는데, QTG는 같은 값을 아직 처리 중으로 봅니다. 변환 표에 없는 <code>waiting</code> 같은 값은 원본 문자열 그대로 내려옵니다. 트래블룰 때문에 출금 주소를 미리 등록해야 하는 거래소도 있고, 출금 주소 화이트리스트를 따로 관리해야 하는 거래소도 있습니다.</p>
<p>브릿지를 거치면 단계가 더 늘어납니다. Circle의 CCTP로 USDC를 다른 체인에 보내려면, 출발 체인에서 USDC를 burn하고 Circle의 attestation을 받은 뒤 도착 체인에서 mint해야 합니다. burn할 때 지정한 <code>destination_caller</code>와 실제로 mint를 호출하는 주소가 다르면 mint가 실패하고, 이미 burn된 USDC는 어느 체인에서도 되찾을 수 없습니다.</p>
<p>송금 수단마다 실패하는 방식도 다릅니다.</p>
<table><thead><tr><th>항목</th><th>CEX 출금</th><th>Bridge (Ex. CCTP)</th></tr></thead><tbody><tr><td><strong>주로 실패하는 지점</strong></td><td>거래소 정책 거절, 점검</td><td>attestation 지연, <code>destination_caller</code> 불일치</td></tr><tr><td><strong>결과를 알 수 없는 구간</strong></td><td>출금 요청이 타임아웃된 뒤</td><td>burn 후 attestation을 기다리는 동안</td></tr><tr><td><strong>재시도 방식</strong></td><td>출금 내역을 조회한 뒤에만</td><td>burn은 재시도하지 않고 mint만 재시도</td></tr></tbody></table>
<p>물론, 이 과정을 대신 처리해 주는 서비스도 있습니다. 기관용 커스터디 솔루션은 거래소 간 이체와 승인 정책을 함께 제공합니다. 하지만, 기관을 대상으로 한 서비스여서 개인이 쓰기에는 비용 부담이 컸습니다. 개인 트레이더나 에이전트 트레이딩을 해 보려는 사람, 작은 퀀트 팀은 직접 짠 스크립트와 기관용 솔루션 사이에서 마땅한 선택지를 찾기 어려웠습니다. 이런 사람들을 타겟해 QTG를 개발했습니다.</p>
<p>QTG는 셀프 호스트 방식이기 때문에 키를 대신 보관하지 않습니다. 키는 사용자의 KMS나 암호화된 keystore에 두고, 어떤 이체를 어디로 누구의 승인으로 허용할지 정하는 통제만 기관용 솔루션 수준으로 가져가려 했습니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 530" role="img" aria-labelledby="qpo-title qpo-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qpo-title">키 보관 방식과 운영 통제 깊이로 본 QTG의 위치</title>
  <desc id="qpo-desc">가로축은 키를 누가 보관하는지, 세로축은 송금을 얼마나 깊게 통제하는지를 나타낸다. 기관용 커스터디 솔루션은 업체가 키를 관리하면서 통제가 깊고, 서명 인프라와 직접 짠 스크립트는 사용자가 키를 가지지만 통제가 얕다. QTG는 사용자가 키를 가지면서 통제를 깊게 가져가는 자리에 있다.</desc>
  <defs>
    <marker id="qpo-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 축 -->
  <path class="dg-edge" d="M140 470V36" marker-end="url(#qpo-a)"/>
  <path class="dg-edge" d="M140 470H964" marker-end="url(#qpo-a)"/>
  <path class="dg-edge dg-edge--dashed" d="M552 48V462"/>
  <path class="dg-edge dg-edge--dashed" d="M148 256H956"/>

  <text class="dg-group-label" x="128" y="52" text-anchor="end">깊음</text>
  <text class="dg-group-label" x="128" y="466" text-anchor="end">얕음</text>
  <text class="dg-group-label" x="60" y="262" text-anchor="middle">운영 통제</text>
  <text class="dg-sub" x="60" y="282" text-anchor="middle">승인, 정책, 예산</text>

  <text class="dg-group-label" x="346" y="500" text-anchor="middle">사용자가 키를 보관</text>
  <text class="dg-group-label" x="758" y="500" text-anchor="middle">업체가 키를 관리</text>
  <text class="dg-sub" x="552" y="522" text-anchor="middle">키 보관 방식</text>

  <!-- 위치 -->
  <rect class="dg-node dg-node--accent" x="196" y="88" width="300" height="76" rx="10"/>
  <text class="dg-label" x="346" y="120" text-anchor="middle">QTG</text>
  <text class="dg-sub" x="346" y="144" text-anchor="middle">키는 사용자의 KMS에 두고 통제는 깊게</text>

  <rect class="dg-node dg-node--consumer" x="608" y="88" width="300" height="76" rx="10"/>
  <text class="dg-label" x="758" y="120" text-anchor="middle">기관용 커스터디 솔루션</text>
  <text class="dg-sub" x="758" y="144" text-anchor="middle">통제는 깊지만 개인에게는 비용 부담</text>

  <rect class="dg-node dg-node--internal" x="196" y="300" width="300" height="64" rx="10"/>
  <text class="dg-label" x="346" y="327" text-anchor="middle">서명 인프라 (KMS 등)</text>
  <text class="dg-sub" x="346" y="349" text-anchor="middle">키는 지켜 주지만 송금 정책은 없음</text>

  <rect class="dg-node dg-node--internal" x="196" y="388" width="300" height="64" rx="10"/>
  <text class="dg-label" x="346" y="415" text-anchor="middle">직접 짠 스크립트</text>
  <text class="dg-sub" x="346" y="437" text-anchor="middle">키는 .env에, 통제는 코드 곳곳에</text>
</svg>
</div></div><figcaption>키 보관 방식과 운영 통제 깊이로 본 QTG의 위치</figcaption></figure>
<p>QTG는 출금 요청부터 입금 확인까지의 과정 전체를 표준화합니다. 거래소와 브릿지마다 다른 부분은 각 단계의 구현 안에 숨기고, 밖에서는 같은 상태와 같은 이벤트로 보이게 했습니다.</p>
<p>예를 들어, CEX 출금 상태는 일곱 가지로 통일했습니다. 거래소 어댑터는 모두 같은 메서드를 구현하고, 거래소에서 받은 원본 값을 이 상태로 바꿔 돌려줍니다. 아래 코드는 이해를 돕기 위해 일부 메서드와 인자를 생략한 것입니다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog"><code data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog" style="display:grid"><span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">class</span><span style="--shiki-dark:#5DA994;--shiki-light:#2E8F82"> WithdrawalState</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A">StrEnum</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span></span>
<span data-line=""><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">    SUBMITTED</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">SUBMITTED</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">    PROCESSING</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">PROCESSING</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">    WAITING_CONFIRMATION</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">WAITING_CONFIRMATION</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">    COMPLETED</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">COMPLETED</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">    CANCELLED</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">CANCELLED</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">    REJECTED</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">REJECTED</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">    FAILED</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">FAILED</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""> </span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">class</span><span style="--shiki-dark:#5DA994;--shiki-light:#2E8F82"> TransferAdapterBase</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">ABC</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span></span>
<span data-line=""><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">    # 지금 이 자산과 네트워크로 출금할 수 있는지 확인</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> check_withdrawal_available</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> asset</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> network</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> WithdrawalChance</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> submit_withdrawal</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> request</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> WithdrawalResult</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span></span>
<span data-line=""><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">    # 원본 상태를 WithdrawalState로 바꾸고 txid, confirmation 수와 함께 돌려줌</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> get_withdrawal_status</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> withdrawal_id</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> WithdrawalStatus</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> get_deposit_status</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> deposit_id</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> DepositStatus</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span></span></code></pre></figure>
<p>거래소별 원본 값은 다음과 같이 바뀝니다.</p>
<table><thead><tr><th>QTG 상태</th><th>Upbit</th><th>OKX</th><th>Bybit</th></tr></thead><tbody><tr><td><strong>SUBMITTED</strong></td><td><code>submitting</code>, <code>submitted</code>, <code>waiting</code></td><td><code>0</code></td><td><code>Pending</code></td></tr><tr><td><strong>PROCESSING</strong></td><td><code>almost_accepted</code>, <code>accepted</code>, <code>processing</code></td><td><code>1</code>, <code>7</code>, <code>8</code>, <code>9</code>, <code>10</code>, <code>12</code> 등</td><td><code>SecurityCheck</code>, <code>MoreInformationRequired</code></td></tr><tr><td><strong>COMPLETED</strong></td><td><code>done</code></td><td><code>2</code></td><td><code>success</code>, <code>BlockchainConfirmed</code></td></tr><tr><td><strong>CANCELLED</strong></td><td><code>canceled</code></td><td><code>-2</code></td><td><code>CancelByUser</code></td></tr><tr><td><strong>REJECTED</strong></td><td><code>rejected</code></td><td>해당 없음</td><td><code>Reject</code></td></tr><tr><td><strong>FAILED</strong></td><td>해당 없음</td><td><code>-1</code></td><td><code>Fail</code></td></tr></tbody></table>
<p>Upbit 어댑터는 표에 없는 값이 내려오면 경고 로그를 남기고 <code>PROCESSING</code>으로 둡니다. 완료로 잘못 판단하면 도착 거래소의 입금 확인이 너무 일찍 시작되지만, 처리 중으로 두면 조회를 한 번 더 할 뿐이기 때문입니다. 송금 요청과 노드 단위의 상태는 뒤의 승인과 결과를 알 수 없는 상태 절에서 다룹니다.</p>
<h3 id="개인키-관리-부담"><a href="https://jerrylog.dev/ko/blog/why-qtg#개인키-관리-부담">개인키 관리 부담</a></h3>
<p>CEX 출금은 거래소 API 키만 있으면 가능하지만, Circle의 CCTP처럼 블록체인에 직접 트랜잭션을 보내려면 개인키 서명이 필요합니다. 대부분, 초기 테스트 단계에서는 서버의 <code>.env</code> 파일에 개인키를 두고 프로세스가 읽어 쓰게 합니다.</p>
<p>하지만, AI의 발달로 공격 시나리오도 상당히 정교해지면서, 로컬 머신에 개인키를 평문으로 두는 방식의 문제가 점점 커지기 시작했습니다. 2025년 8월 npm에 올라온 Nx 빌드 도구의 악성 버전(s1ngularity 공격)이 대표적인 사례입니다. 이 악성 코드는 개발자 PC에 설치된 Claude, Gemini, Amazon Q의 CLI를 실행해 파일 시스템에서 암호화폐 지갑, <code>.env</code> 파일, SSH 키, GitHub 토큰을 찾게 했습니다. Wiz의 집계에 따르면 GitHub 계정 2,180개와 저장소 7,200개가 노출되었습니다.</p>
<p>그 뒤로도 비슷한 공격이 이어졌습니다.</p>
<table><thead><tr><th>시점</th><th>사건</th><th>노린 것</th><th>실행 시점</th></tr></thead><tbody><tr><td><strong>2025년 8월</strong></td><td>Nx 악성 버전(s1ngularity)</td><td>지갑 파일, <code>.env</code>, SSH 키, GitHub 토큰</td><td>설치 직후</td></tr><tr><td><strong>2025년 11월</strong></td><td>Shai-Hulud 2.0</td><td>npm, GitHub 토큰, 클라우드 자격 증명</td><td>설치 전(preinstall)</td></tr><tr><td><strong>2026년 7월</strong></td><td><code>@injectivelabs/sdk-ts</code> 1.20.21</td><td>지갑 개인키, 시드 문구</td><td>라이브러리가 키를 만들 때</td></tr><tr><td><strong>2026년 7월</strong></td><td>Jscrambler 8.14.0</td><td>클라우드 자격 증명, Bitwarden 보관함, AI 코딩 도구 설정 파일</td><td>설치 전(preinstall)</td></tr></tbody></table>
<p>Shai-Hulud 2.0은 탈취한 maintainer 계정으로 감염된 패키지를 다시 배포하며 스스로 퍼졌고, 훔친 secret을 공개 GitHub 저장소에 올렸습니다. Injective 사례에서는 공격자가 SDK의 GitHub 저장소를 장악해, 정식 배포 파이프라인으로 18개 패키지에 악성 버전을 올렸습니다. 이 코드는 키 유도 함수 안에 숨어 있어서 설치할 때는 아무 일도 하지 않았고, 애플리케이션이 지갑 키를 만들 때 개인키와 시드 문구를 외부로 보냈습니다. 키를 다루는 코드 자체가 오염되면 키를 어디에 보관하든 소용이 없다는 뜻입니다.</p>
<p>코딩 에이전트가 <code>.env</code>를 읽는 것은 hook 같은 설정으로 어느 정도 막을 수 있습니다. 하지만, 의존성 공급망 공격은 개발자가 설치한 패키지 안에서 실행되고, 로컬 파일 시스템을 훑어 secret을 찾아내는 수법도 갈수록 정교해지고 있습니다. 개인키를 평문 파일로 디스크에 두는 한 이런 공격을 피하기 어렵다고 판단했습니다.</p>
<p>디스크에 평문을 남기지 않는 방법도 있습니다. 1Password CLI(<code>op run</code>, <code>op read</code>)를 쓰면 키를 vault에 두고, 프로세스가 시작될 때만 환경 변수로 주입받을 수 있습니다. 이렇게 하면 로컬 파일을 훑는 공격은 막을 수 있습니다. 하지만, 키가 프로세스 메모리에 올라온 뒤에는 같은 머신에서 실행되는 코드가 키를 읽어 갈 수 있습니다. 메모리 덤프나 <code>/proc/&lt;pid&gt;/environ</code> 조회가 그 예이고, Injective 사례처럼 의존성 안에서 실행되는 코드는 키를 쓰는 순간에 가로챌 수 있습니다. 그래서, 키는 별도 vault에 두고 애플리케이션은 서명 결과만 받아 쓰는 방식이 가장 안전하다고 판단했습니다.</p>
<p>QTG는 기본적으로 EVM 서명을 AWS KMS에 맡깁니다. 개인키는 KMS의 HSM 안에서 생성되고, 밖으로 나오지 않습니다. QTG의 서명 서비스는 키 ID만 가지고 있다가, 서명이 필요할 때마다 KMS API를 호출합니다. 모든 서명 요청은 CloudTrail에 기록되고, IAM 권한을 회수하면 곧바로 서명이 막힙니다. 또, secp256k1 키 하나로 여러 EVM 체인에서 같은 주소를 쓸 수 있어서 체인별로 키를 따로 관리하지 않아도 됩니다.</p>
<p>서명 방식에 따라 공격 시나리오별 위험은 다음과 같이 달라집니다.</p>
<table><thead><tr><th>공격 시나리오</th><th><code>.env</code> 평문 키</th><th>1Password로 주입한 키</th><th>암호화 keystore (별도 서명 서비스)</th><th>AWS KMS (별도 서명 서비스)</th></tr></thead><tbody><tr><td><strong>로컬 파일 sweep</strong></td><td>키 유출</td><td>디스크에 키 없음</td><td>암호화된 파일만 유출</td><td>디스크에 키 없음</td></tr><tr><td><strong>메모리 덤프</strong></td><td>키 유출</td><td>키 유출</td><td>잠금을 푼 동안 서명 서비스 메모리에서 유출</td><td>메모리에 키 없음</td></tr><tr><td><strong>QTG 프로세스 안에서 코드 실행</strong></td><td>키 유출</td><td>키 유출</td><td>접근이 유지되는 동안 서명 요청 가능</td><td>접근이 유지되는 동안 서명 요청 가능</td></tr><tr><td><strong>AWS 자격 증명 탈취</strong></td><td>해당 없음</td><td>해당 없음</td><td>해당 없음</td><td>접근이 유지되는 동안 서명 요청 가능</td></tr></tbody></table>
<p>KMS는 키 자체를 지켜 주지만, 서명 권한까지 지켜 주지는 않습니다. 표의 마지막 두 줄처럼 공격자가 접근을 유지하는 동안에는 서명을 요청할 수 있습니다. 다만, 그 요청은 모두 CloudTrail에 남고, IAM 권한을 회수하면 그 자리에서 끝납니다. 키가 통째로 유출되는 것보다는 훨씬 나은 위치이지만, 완전한 방어는 아닙니다.</p>
<p>암호화 keystore는 AWS를 쓰지 않는 로컬 배포를 위해 나중에 추가한 방식입니다. 디스크에는 암호화된 파일만 남아서 passphrase를 모르면 쓸 수 없지만, 서명 서비스가 잠금을 푼 동안에는 그 프로세스의 메모리에 키가 있습니다. 같은 호스트의 root나 Docker 관리자 권한을 가진 공격자는 이 메모리를 읽을 수 있고, CloudTrail 같은 외부 서명 기록이나 IAM 권한 회수 같은 수단도 없습니다. 그래서, 원격 서버나 여러 사람이 함께 쓰는 호스트에는 KMS를 권장합니다. 동작 방식은 뒤의 서명 서비스 분리에서 다룹니다.</p>
<p>서명 키 말고도 QTG가 다루는 secret이 있습니다. 각각이 유출됐을 때 공격자가 할 수 있는 일은 다음과 같습니다.</p>
<table><thead><tr><th>유출된 secret</th><th>공격자가 할 수 있는 일</th><th>할 수 없는 일</th></tr></thead><tbody><tr><td><strong>DB 자격 증명 또는 DB 파일</strong></td><td>승인 상태, allowlist, 감사 기록을 모두 고칠 수 있음</td><td>KEK 없이 거래소 API 키 읽기</td></tr><tr><td><strong>거래소 API 키</strong></td><td>내 키로 거래소 API 호출</td><td>거래소 쪽 IP 제한과 출금 주소 화이트리스트를 넘는 출금*</td></tr><tr><td><strong>admin API 키</strong></td><td>송금 승인, 템플릿과 서명 서비스, allowlist 관리</td><td>KMS 안에 있는 키를 직접 읽기</td></tr><tr><td><strong>agent API 키</strong></td><td>허용된 범위 안에서 송금 제안</td><td>승인, 권한 확장</td></tr></tbody></table>
<p class="text-muted-foreground -mt-3 mb-6 text-sm leading-relaxed">* 거래소마다 지원 범위가 다릅니다. API 키에 IP 제한을 걸 수 있는지, 출금 주소 화이트리스트를 강제할 수 있는지는 거래소별로 확인해야 합니다.</p>
<p>이 secret들을 QTG에 전달하는 방식도 바꾸었습니다. 처음에는 다른 설정과 함께 <code>.env</code>나 환경 변수로 받았습니다. 하지만, 환경 변수는 자식 프로세스에 그대로 복사되고, <code>docker inspect</code>나 <code>/proc/&lt;pid&gt;/environ</code>으로도 읽힙니다. 그래서, DB 접속 정보, HMAC secret, 알림 봇 토큰 같은 런타임 secret은 권한이 0600인 파일로만 받게 했습니다. 환경 변수나 <code>.env</code>에 secret 값이 남아 있으면 QTG는 아예 시작하지 않습니다. 다만, 파일도 디스크에 평문으로 있다는 점은 같아서, secret manager를 대신하지는 않습니다.</p>
<p>DB 쓰기 권한이 넘어가면 QTG 안의 통제는 모두 무력해집니다. 거래소 API 키는 DB에 레코드별로 암호화해 저장하고, 암호화 키(KEK)는 배포 모드에 따라 로컬 파일이나 AWS KMS가 맡습니다. DB 덤프만으로는 거래소 키를 읽을 수 없습니다. 하지만, 요청마다 서명해야 하기 때문에 실행 중인 프로세스 메모리에는 평문으로 있을 수밖에 없습니다. 다만, 대부분의 거래소는 보안상 IP 화이트리스트나 출금 주소 화이트리스트를 API로 추가할 수 없게 해 두었습니다. 거래소 웹이나 앱에서 직접 등록해야 합니다. 그래서, 이 두 가지를 미리 설정해 두면, 거래소 API 키가 유출되더라도 등록하지 않은 주소로 자산이 빠져나가는 치명적인 사고로 이어지지는 않습니다. 서버가 완전히 장악된 뒤에도 작동하는 통제는 QTG 밖에 있습니다. 거래소의 IP 제한과 출금 주소 화이트리스트, 그리고 KMS 키 정책입니다. 그래서, QTG를 어디에 어떻게 배포하느냐가 설정 하나보다 더 중요한데, 이 내용은 뒤의 배포 구성과 보안 수준에서 다룹니다.</p>
<h3 id="송금-과정-추상화-필요성"><a href="https://jerrylog.dev/ko/blog/why-qtg#송금-과정-추상화-필요성">송금 과정 추상화 필요성</a></h3>
<p>세 번째 이유는 송금 경로가 길어졌기 때문입니다. 거래소에서 출금해 온체인 지갑으로 받고, 브릿지로 다른 체인에 옮긴 다음, 다시 거래소나 DEX에 입금하는 식입니다. 단계마다 확인해야 할 것도 있습니다. 출금이 나갔는지, 체인에 기록됐는지, confirmation이 충분히 쌓였는지, 도착지에 반영됐는지 같은 것들입니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 408" role="img" aria-labelledby="qmh-title qmh-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qmh-title">CEX Lane과 On-chain Lane의 송금 경로</title>
  <desc id="qmh-desc">CEX Lane은 Upbit와 Bithumb 사이에서 XRP를 출금하고 입금하는 한 번의 이동이다. On-chain Lane은 Upbit에서 USDC를 출금해 EVM 지갑으로 받고, CCTP burn 후 Circle attestation을 거쳐 Base 체인에서 mint하고, Base DEX에 입금하는 다섯 단계 경로다.</desc>
  <defs>
    <marker id="qmh-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qmh-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--accent" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- CEX 레인 -->
  <rect class="dg-group" x="20" y="40" width="960" height="136" rx="14"/>
  <rect class="dg-group-tab" x="36" y="29" width="92" height="22" rx="11"/>
  <text class="dg-group-label" x="82" y="44.5" text-anchor="middle">CEX Lane</text>
  <path class="dg-node dg-node--consumer" d="M91 149H175A28 28 0 0 0 183 94A38 38 0 0 0 113 83A30 30 0 0 0 87 149Z"/>
  <text class="dg-label" x="134" y="119" text-anchor="middle">Upbit</text>
  <path class="dg-node dg-node--consumer" d="M475 149H559A28 28 0 0 0 567 94A38 38 0 0 0 497 83A30 30 0 0 0 471 149Z"/>
  <text class="dg-label" x="518" y="119" text-anchor="middle">Bithumb</text>
  <path class="dg-edge" d="M201 109H447" marker-end="url(#qmh-a)"/>
  <path class="dg-edge" d="M452 131H204" marker-end="url(#qmh-a)"/>
  <text class="dg-edge-label" x="326" y="101" text-anchor="middle">XRP 출금 → 입금</text>
  <text class="dg-sub" x="656" y="113">노드 3개: 출금, 출금 확인, 입금 확인</text>
  <text class="dg-sub" x="656" y="133">거래소 API만으로 끝나는 경로</text>

  <!-- 온체인 레인 -->
  <rect class="dg-group" x="20" y="224" width="960" height="136" rx="14"/>
  <rect class="dg-group-tab" x="36" y="213" width="128" height="22" rx="11"/>
  <text class="dg-group-label" x="100" y="228.5" text-anchor="middle">On-chain Lane</text>

  <path class="dg-node dg-node--consumer" d="M61 337H145A28 28 0 0 0 153 282A38 38 0 0 0 83 271A30 30 0 0 0 57 337Z"/>
  <text class="dg-label" x="104" y="297" text-anchor="middle">Upbit</text>
  <text class="dg-sub" x="104" y="319" text-anchor="middle">USDC 출금</text>
  <circle class="dg-badge" cx="39" cy="286" r="12"/>
  <text class="dg-badge-text" x="39" y="290.5" text-anchor="middle">1</text>

  <rect class="dg-node dg-node--accent" x="230" y="253" width="144" height="84" rx="10"/>
  <text class="dg-label" x="302" y="291" text-anchor="middle">EVM 지갑</text>
  <text class="dg-sub" x="302" y="313" text-anchor="middle">수령 후 CCTP burn</text>
  <circle class="dg-badge" cx="230" cy="253" r="12"/>
  <text class="dg-badge-text" x="230" y="257.5" text-anchor="middle">2</text>

  <path class="dg-node dg-node--consumer" d="M457 337H541A28 28 0 0 0 549 282A38 38 0 0 0 479 271A30 30 0 0 0 453 337Z"/>
  <text class="dg-label" x="500" y="297" text-anchor="middle">Circle</text>
  <text class="dg-sub" x="500" y="319" text-anchor="middle">attestation 발급</text>
  <circle class="dg-badge" cx="435" cy="286" r="12"/>
  <text class="dg-badge-text" x="435" y="290.5" text-anchor="middle">3</text>

  <path class="dg-node dg-node--accent" d="M655 337H739A28 28 0 0 0 747 282A38 38 0 0 0 677 271A30 30 0 0 0 651 337Z"/>
  <text class="dg-label" x="698" y="297" text-anchor="middle">Base 체인</text>
  <text class="dg-sub" x="698" y="319" text-anchor="middle">CCTP mint</text>
  <circle class="dg-badge" cx="633" cy="286" r="12"/>
  <text class="dg-badge-text" x="633" y="290.5" text-anchor="middle">4</text>

  <path class="dg-node dg-node--consumer" d="M853 337H937A28 28 0 0 0 945 282A38 38 0 0 0 875 271A30 30 0 0 0 849 337Z"/>
  <text class="dg-label" x="896" y="297" text-anchor="middle">Base DEX</text>
  <text class="dg-sub" x="896" y="319" text-anchor="middle">입금</text>
  <circle class="dg-badge" cx="831" cy="286" r="12"/>
  <text class="dg-badge-text" x="831" y="290.5" text-anchor="middle">5</text>

  <path class="dg-edge" d="M174 307H228" marker-end="url(#qmh-a)"/>
  <path class="dg-edge dg-edge--accent" d="M374 307H429" marker-end="url(#qmh-c)"/>
  <path class="dg-edge dg-edge--accent" d="M570 307H627" marker-end="url(#qmh-c)"/>
  <path class="dg-edge" d="M768 307H825" marker-end="url(#qmh-a)"/>

  <text class="dg-note" x="20" y="392">경로가 길어져도 각 단계는 제출하고 결과를 기다리는 일의 반복입니다. 파란 노드는 체인 위의 단계입니다.</text>
</svg>
</div></div><figcaption>거래소 사이의 송금(CEX Lane)과 체인을 건너는 송금(On-chain Lane)의 예</figcaption></figure>
<p>이런 확인 작업은 송금 수단이 달라도 성격이 비슷합니다. 외부에 무언가를 제출하고, 그 결과가 외부 시스템에 나타나기를 기다리는 것입니다. 그래서, 송금 한 건을 단계별 노드로 이루어진 DAG로 표현하고, 확인 작업을 몇 가지 유형으로 묶었습니다. 이렇게 하면 경로가 바뀌어도 그래프 모양만 바꾸면 되고, 노드의 동작은 다시 짤 필요가 없습니다.</p>
<h2 id="qtg-구조"><a href="https://jerrylog.dev/ko/blog/why-qtg#qtg-구조">QTG 구조</a></h2>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 644" role="img" aria-labelledby="qar-title qar-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qar-title">QTG 전체 구조</title>
  <desc id="qar-desc">전략 엔진, LLM 에이전트, owner가 QTG Core에 송금 요청을 보낸다. Core는 계획을 컴파일하고 승인한 뒤 워커가 Executor로 거래소와 체인에 제출하고 결과를 확인한다. EVM 서명은 별도 프로세스인 서명 서비스가 AWS KMS로 처리하고, 모든 상태는 Postgres에 있다.</desc>
  <defs>
    <marker id="qar-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qar-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--accent" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 요청자 -->
  <rect class="dg-node dg-node--consumer" x="20" y="96" width="160" height="150" rx="10"/>
  <g class="dg-icon dg-icon--consumer" transform="translate(90 108) scale(0.8333)"><circle cx="12" cy="8" r="4"/><path d="M4 21C4 16.6 7.6 14 12 14S20 16.6 20 21"/></g>
  <text class="dg-label" x="100" y="156" text-anchor="middle">요청자</text>
  <text class="dg-sub" x="100" y="182" text-anchor="middle">전략 엔진</text>
  <text class="dg-sub" x="100" y="204" text-anchor="middle">LLM 에이전트</text>
  <text class="dg-sub" x="100" y="226" text-anchor="middle">Owner 대시보드</text>

  <!-- QTG Core -->
  <rect class="dg-group" x="240" y="44" width="380" height="414" rx="14"/>
  <rect class="dg-group-tab" x="256" y="33" width="88" height="22" rx="11"/>
  <text class="dg-group-label" x="300" y="48.5" text-anchor="middle">QTG Core</text>

  <rect class="dg-node dg-node--internal" x="268" y="84" width="324" height="88" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(420 94) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="430" y="136" text-anchor="middle">요청 API · 계획 컴파일 · 승인</text>
  <text class="dg-sub" x="430" y="158" text-anchor="middle">HMAC 인증, 계획 해시 고정</text>

  <rect class="dg-node dg-node--internal" x="268" y="220" width="324" height="110" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(420 230) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="430" y="272" text-anchor="middle">워커 · Executor</text>
  <text class="dg-sub" x="430" y="294" text-anchor="middle">dispatcher, observer, recovery</text>
  <text class="dg-sub" x="430" y="314" text-anchor="middle">CEX Lane, Bridge Lane</text>

  <rect class="dg-node dg-node--internal" x="268" y="378" width="324" height="52" rx="10"/>
  <text class="dg-label" x="430" y="409" text-anchor="middle">Callback Outbox</text>

  <!-- Postgres: 세로 원통 -->
  <path class="dg-node dg-node--ok" d="M330 516V580A100 10 0 0 0 530 580V516"/>
  <ellipse class="dg-node dg-node--ok" cx="430" cy="516" rx="100" ry="10"/>
  <text class="dg-label" x="430" y="550" text-anchor="middle">Postgres</text>
  <text class="dg-sub" x="430" y="572" text-anchor="middle">상태, 실행 순서, 감사 기록</text>

  <!-- 서명 경계 -->
  <rect class="dg-group" x="708" y="44" width="272" height="284" rx="14"/>
  <rect class="dg-group-tab" x="724" y="33" width="184" height="22" rx="11"/>
  <text class="dg-group-label" x="816" y="48.5" text-anchor="middle">별도 프로세스와 AWS 계정</text>

  <rect class="dg-node dg-node--accent" x="732" y="84" width="216" height="88" rx="10"/>
  <g class="dg-icon dg-icon--accent" transform="translate(830 94) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="840" y="136" text-anchor="middle">서명 서비스</text>
  <text class="dg-sub" x="840" y="158" text-anchor="middle">KMS 설정을 이 프로세스만 가짐</text>

  <rect class="dg-node dg-node--accent" x="732" y="220" width="216" height="80" rx="10"/>
  <text class="dg-label" x="840" y="254" text-anchor="middle">AWS KMS</text>
  <text class="dg-sub" x="840" y="276" text-anchor="middle">개인키는 HSM 밖으로 나오지 않음</text>

  <!-- 거래소와 체인: 구름 -->
  <path class="dg-node dg-node--consumer" d="M744 484H944A36 36 0 0 0 944 412A70 70 0 0 0 844 404A62 62 0 0 0 744 412A36 36 0 0 0 744 484Z"/>
  <text class="dg-label" x="844" y="428" text-anchor="middle">거래소 · EVM 체인</text>
  <text class="dg-sub" x="844" y="450" text-anchor="middle">CEX API, CCTP, Circle Gateway</text>

  <!-- 선 -->
  <path class="dg-edge" d="M180 128H266" marker-end="url(#qar-a)"/>
  <text class="dg-edge-label" x="210" y="120" text-anchor="middle">송금 요청</text>

  <path class="dg-edge" d="M430 172V218" marker-end="url(#qar-a)"/>
  <text class="dg-edge-label" x="440" y="200">승인된 계획</text>

  <path class="dg-edge" d="M430 330V376" marker-end="url(#qar-a)"/>

  <path class="dg-edge" d="M268 404H100V248" marker-end="url(#qar-a)"/>
  <text class="dg-edge-label" x="186" y="396" text-anchor="middle">서명된 콜백</text>

  <path class="dg-edge dg-edge--accent" d="M592 250H664V128H730" marker-end="url(#qar-c)"/>
  <text class="dg-edge-label" x="664" y="116" text-anchor="middle">nonce-HMAC</text>

  <path class="dg-edge dg-edge--accent" d="M840 172V218" marker-end="url(#qar-c)"/>
  <text class="dg-edge-label" x="850" y="200">키 ID로 서명</text>

  <path class="dg-edge" d="M592 310H664V448H706" marker-end="url(#qar-a)"/>
  <text class="dg-edge-label" x="664" y="298" text-anchor="middle">제출 · 확인</text>

  <path class="dg-edge" d="M430 458V504" marker-start="url(#qar-a)" marker-end="url(#qar-a)"/>
  <text class="dg-edge-label" x="440" y="486">상태 읽기 · 쓰기</text>

  <text class="dg-note" x="20" y="624">메시지 브로커나 별도 스케줄러는 없습니다. 워커가 Postgres를 조회해 실행할 노드를 가져갑니다.</text>
</svg>
</div></div><figcaption>QTG 전체 구조. EVM 서명은 Core 밖의 서명 서비스가 맡고, 모든 상태는 Postgres에 있습니다.</figcaption></figure>
<p>QTG Core는 다음과 같이 구성됩니다.</p>
<ul>
<li><strong>요청 API</strong>: 전략, 에이전트, 대시보드는 HMAC 서명으로 인증한 뒤 송금 요청을 보냅니다.</li>
<li><strong>Plan compiler</strong>: 경로 템플릿과 입력값으로 노드 그래프를 만들고, 그 내용을 해시로 고정합니다.</li>
<li><strong>승인</strong>: owner가 직접 승인하거나, 조건에 맞는 요청은 auto-approve 정책에 따라 승인됩니다.</li>
<li><strong>Worker</strong>: Postgres를 주기적으로 조회해 실행할 노드를 가져가고, executor를 호출합니다.</li>
<li><strong>Executor</strong>: 거래소 API나 체인과 실제로 통신하며, CEX Lane과 Bridge Lane으로 나뉩니다.</li>
<li><strong>Callback outbox</strong>: 상태가 바뀔 때마다 요청한 쪽에 서명된 콜백을 보냅니다.</li>
</ul>
<p>EVM 서명은 Core 밖의 서명 서비스가 처리합니다. 모든 상태는 Postgres 한 곳에 저장하며, 메시지 브로커나 별도 스케줄러 프로세스는 두지 않았습니다. 이 결정은 뒤의 Airflow 구조를 참고한 스케줄링에서 다시 설명합니다.</p>
<h3 id="송금-그래프와-executor"><a href="https://jerrylog.dev/ko/blog/why-qtg#송금-그래프와-executor">송금 그래프와 Executor</a></h3>
<p>CEX 간 송금은 노드 3개로 이루어집니다. 출금을 요청하고, 출금 상태를 확인해 txid를 얻고, 도착 거래소의 입금 내역에서 그 txid를 찾습니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 -12 1000 372" role="img" aria-labelledby="qcx-title qcx-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qcx-title">CEX Lane의 노드 세 개</title>
  <desc id="qcx-desc">withdrawal_action 노드가 출발 거래소에 출금을 요청해 출금 ID를 받는다. withdrawal_observe 노드는 그 ID로 출금 상태를 반복 조회해 txid를 얻는다. deposit_observe 노드는 도착 거래소의 입금 내역에서 txid로 입금을 찾는다. 앞 노드가 얻은 값은 다음 노드로 전달된다.</desc>
  <defs>
    <marker id="qcx-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qcx-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--accent" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 거래소: 구름 -->
  <path class="dg-node dg-node--consumer" d="M45 100H595A25 25 0 0 0 595 50A155 155 0 0 0 412 44A156 156 0 0 0 228 44A155 155 0 0 0 45 50A25 25 0 0 0 45 100Z"/>
  <text class="dg-label" x="320" y="62" text-anchor="middle">출발 거래소</text>
  <text class="dg-sub" x="320" y="82" text-anchor="middle">Upbit, Binance 등</text>

  <path class="dg-node dg-node--consumer" d="M765 100H955A25 25 0 0 0 955 50A67 67 0 0 0 860 42A59 59 0 0 0 765 50A25 25 0 0 0 765 100Z"/>
  <text class="dg-label" x="860" y="62" text-anchor="middle">도착 거래소</text>
  <text class="dg-sub" x="860" y="82" text-anchor="middle">입금 내역</text>

  <path class="dg-edge dg-edge--dashed" d="M622 75H738" marker-end="url(#qcx-a)"/>
  <text class="dg-edge-label" x="679" y="65" text-anchor="middle">체인 전송</text>

  <!-- QTG 노드 -->
  <text class="dg-group-label" x="20" y="178">QTG 노드</text>

  <rect class="dg-node dg-node--accent" x="20" y="200" width="240" height="76" rx="10"/>
  <text class="dg-label" x="140" y="243" text-anchor="middle">withdrawal_action</text>
  <circle class="dg-badge" cx="20" cy="200" r="12"/>
  <text class="dg-badge-text" x="20" y="204.5" text-anchor="middle">1</text>

  <rect class="dg-node dg-node--internal" x="380" y="200" width="240" height="76" rx="10"/>
  <text class="dg-label" x="500" y="243" text-anchor="middle">withdrawal_observe</text>
  <circle class="dg-badge" cx="380" cy="200" r="12"/>
  <text class="dg-badge-text" x="380" y="204.5" text-anchor="middle">2</text>

  <rect class="dg-node dg-node--ok" x="740" y="200" width="240" height="76" rx="10"/>
  <text class="dg-label" x="860" y="243" text-anchor="middle">deposit_observe</text>
  <circle class="dg-badge" cx="740" cy="200" r="12"/>
  <text class="dg-badge-text" x="740" y="204.5" text-anchor="middle">3</text>

  <path class="dg-edge" d="M260 238H378" marker-end="url(#qcx-a)"/>
  <text class="dg-edge-label" x="319" y="228" text-anchor="middle">withdrawal_id</text>
  <path class="dg-edge" d="M620 238H738" marker-end="url(#qcx-a)"/>
  <text class="dg-edge-label" x="679" y="228" text-anchor="middle">txid</text>

  <!-- 노드와 거래소 -->
  <path class="dg-edge dg-edge--accent" d="M140 198V102" marker-end="url(#qcx-c)"/>
  <text class="dg-edge-label" x="150" y="146">submit_withdrawal()</text>
  <path class="dg-edge" d="M500 198V102" marker-start="url(#qcx-a)" marker-end="url(#qcx-a)"/>
  <text class="dg-edge-label" x="510" y="146">출금 상태 반복 조회</text>
  <path class="dg-edge" d="M860 198V102" marker-start="url(#qcx-a)" marker-end="url(#qcx-a)"/>
  <text class="dg-edge-label" x="870" y="146">txid로 입금 대조</text>

  <text class="dg-note" x="20" y="316">앞 노드가 얻은 값(withdrawal_id, txid)은 provider_context로 다음 노드에 전달됩니다.</text>
  <text class="dg-note" x="20" y="340">withdrawal_action의 preflight는 거래소 출금 주소 화이트리스트, 출금 가능 여부, 지갑 점검 상태를 확인합니다.</text>
</svg>
</div></div><figcaption>CEX Lane의 노드 세 개. 앞 노드가 얻은 값을 다음 노드가 이어받습니다.</figcaption></figure>
<p>CCTP 송금은 노드 5개로 이루어집니다. burn, attestation, mint 뒤에 받은 금액을 확인하는 노드와 finality를 확인하는 노드가 붙습니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 -12 1000 382" role="img" aria-labelledby="qcc-title qcc-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qcc-title">CCTP Lane의 노드 다섯 개</title>
  <desc id="qcc-desc">burn 노드가 출발 체인에 depositForBurn을 보내면 Circle IRIS가 burn을 확인해 attestation을 발급한다. attestation 노드는 IRIS를 반복 조회하고, mint 노드가 도착 체인에 receiveMessage를 보낸다. 이후 receive observe 노드와 finality 노드가 도착 체인을 조회한다.</desc>
  <defs>
    <marker id="qcc-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qcc-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--accent" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 외부 시스템: 구름 -->
  <path class="dg-node dg-node--consumer" d="M53 100H137A28 28 0 0 0 145 45A38 38 0 0 0 75 34A30 30 0 0 0 49 100Z"/>
  <text class="dg-label" x="96" y="62" text-anchor="middle">출발 체인</text>
  <text class="dg-sub" x="96" y="82" text-anchor="middle">Ethereum 등</text>

  <path class="dg-node dg-node--consumer" d="M255 100H339A28 28 0 0 0 347 45A38 38 0 0 0 277 34A30 30 0 0 0 251 100Z"/>
  <text class="dg-label" x="298" y="62" text-anchor="middle">Circle IRIS</text>
  <text class="dg-sub" x="298" y="82" text-anchor="middle">attestation API</text>

  <path class="dg-node dg-node--consumer" d="M449 100H955A25 25 0 0 0 955 50A134 134 0 0 0 786 44A133 133 0 0 0 618 44A134 134 0 0 0 449 50A25 25 0 0 0 449 100Z"/>
  <text class="dg-label" x="702" y="62" text-anchor="middle">도착 체인</text>
  <text class="dg-sub" x="702" y="82" text-anchor="middle">Arbitrum, Base 등</text>

  <path class="dg-edge dg-edge--dashed" d="M168 70H226" marker-end="url(#qcc-a)"/>
  <text class="dg-edge-label" x="197" y="60" text-anchor="middle">감지</text>

  <!-- QTG 노드 -->
  <text class="dg-group-label" x="20" y="178">QTG 노드</text>

  <rect class="dg-node dg-node--accent" x="20" y="200" width="152" height="76" rx="10"/>
  <text class="dg-label" x="96" y="243" text-anchor="middle">burn</text>
  <circle class="dg-badge" cx="20" cy="200" r="12"/>
  <text class="dg-badge-text" x="20" y="204.5" text-anchor="middle">1</text>

  <rect class="dg-node dg-node--internal" x="222" y="200" width="152" height="76" rx="10"/>
  <text class="dg-label" x="298" y="243" text-anchor="middle">attestation</text>
  <circle class="dg-badge" cx="222" cy="200" r="12"/>
  <text class="dg-badge-text" x="222" y="204.5" text-anchor="middle">2</text>

  <rect class="dg-node dg-node--accent" x="424" y="200" width="152" height="76" rx="10"/>
  <text class="dg-label" x="500" y="243" text-anchor="middle">mint</text>
  <circle class="dg-badge" cx="424" cy="200" r="12"/>
  <text class="dg-badge-text" x="424" y="204.5" text-anchor="middle">3</text>

  <rect class="dg-node dg-node--internal" x="626" y="200" width="152" height="76" rx="10"/>
  <text class="dg-label" x="702" y="232" text-anchor="middle">receive observe</text>
  <text class="dg-sub" x="702" y="254" text-anchor="middle">mint_receive_observe</text>
  <circle class="dg-badge" cx="626" cy="200" r="12"/>
  <text class="dg-badge-text" x="626" y="204.5" text-anchor="middle">4</text>

  <rect class="dg-node dg-node--ok" x="828" y="200" width="152" height="76" rx="10"/>
  <text class="dg-label" x="904" y="232" text-anchor="middle">finality</text>
  <text class="dg-sub" x="904" y="254" text-anchor="middle">mint_finality</text>
  <circle class="dg-badge" cx="828" cy="200" r="12"/>
  <text class="dg-badge-text" x="828" y="204.5" text-anchor="middle">5</text>

  <path class="dg-edge" d="M172 238H220" marker-end="url(#qcc-a)"/>
  <path class="dg-edge" d="M374 238H422" marker-end="url(#qcc-a)"/>
  <path class="dg-edge" d="M576 238H624" marker-end="url(#qcc-a)"/>
  <path class="dg-edge" d="M778 238H826" marker-end="url(#qcc-a)"/>

  <!-- 노드와 외부 시스템 -->
  <path class="dg-edge dg-edge--accent" d="M96 198V102" marker-end="url(#qcc-c)"/>
  <text class="dg-edge-label" x="104" y="146">depositForBurn()</text>
  <path class="dg-edge" d="M298 198V102" marker-start="url(#qcc-a)" marker-end="url(#qcc-a)"/>
  <text class="dg-edge-label" x="306" y="146">반복 조회</text>
  <path class="dg-edge dg-edge--accent" d="M500 198V102" marker-end="url(#qcc-c)"/>
  <text class="dg-edge-label" x="508" y="146">receiveMessage()</text>
  <path class="dg-edge" d="M702 198V102" marker-start="url(#qcc-a)" marker-end="url(#qcc-a)"/>
  <text class="dg-edge-label" x="710" y="146">금액 조회</text>
  <path class="dg-edge" d="M904 198V102" marker-start="url(#qcc-a)" marker-end="url(#qcc-a)"/>
  <text class="dg-edge-label" x="894" y="146" text-anchor="end">confirmation 조회</text>

  <text class="dg-note" x="20" y="318">파란 노드(burn, mint)는 서명 서비스를 거쳐 트랜잭션을 보냅니다.</text>
  <text class="dg-note" x="20" y="342">burn 때 지정한 destination_caller와 mint를 보내는 주소가 다르면 mint가 실패합니다.</text>
</svg>
</div></div><figcaption>CCTP Lane의 노드 다섯 개. burn과 mint 노드만 서명이 필요한 트랜잭션을 보냅니다.</figcaption></figure>
<p>각 노드는 executor가 처리합니다. Executor는 송금 수단에 관계없이 같은 메서드를 구현합니다. 아래 코드는 이해를 돕기 위해 인자를 줄인 것입니다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog"><code data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog" style="display:grid"><span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">class</span><span style="--shiki-dark:#5DA994;--shiki-light:#2E8F82"> ExecutorProtocol</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A">Protocol</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> preflight</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ctx</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ExecutionResult </span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">|</span><span style="--shiki-dark:#4D9375;--shiki-light:#1E754F"> None</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">  # 실행 전 점검</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> prepare</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ctx</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ExecutionResult</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">           # 제출할 내용 준비</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> submit</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ctx</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> prepared_action</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ExecutionResult</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">  # 외부에 제출</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> observe</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ctx</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ExecutionResult</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">           # 결과 확인</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> recover</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">self</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ctx</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> -&gt;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> ExecutionResult</span><span style="--shiki-dark:#666666;--shiki-light:#999999">:</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">           # 결과를 모를 때 대조</span></span></code></pre></figure>
<p><code>preflight</code>는 allowlist, 잔고, 한도를 확인합니다. 잔고 조회가 타임아웃되는 것처럼 확인 자체가 실패하면, 통과로 보지 않고 실패로 처리합니다(fail-closed). <code>prepare</code>와 <code>submit</code>을 나눈 이유는 서명 전에 제출할 내용을 해시로 고정하기 위해서입니다. <code>prepare</code>가 만든 payload와 실제로 서명할 payload의 해시가 다르면 서명 단계에서 거부됩니다.</p>
<p>출금이 나갔는지는 거래소 API로 확인하고, 그 이후의 확인 작업은 세 가지 probe로 묶었습니다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog"><code data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog" style="display:grid"><span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">class</span><span style="--shiki-dark:#5DA994;--shiki-light:#2E8F82"> ChainReceiveProbe</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A">Protocol</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">   # 입금 감지: 이 txid가 도착지에 기록됐는가</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> check_receive</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">...</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">class</span><span style="--shiki-dark:#5DA994;--shiki-light:#2E8F82"> ChainFinalityProbe</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A">Protocol</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">  # finality: confirmation이 충분히 쌓였는가</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> check_finality</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">...</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">class</span><span style="--shiki-dark:#5DA994;--shiki-light:#2E8F82"> ProtocolProbe</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A">Protocol</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span><span style="--shiki-dark:#758575DD;--shiki-light:#A0ADA0">       # 프로토콜 증명: CCTP attestation이 발급됐는가</span></span>
<span data-line=""><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959">    async</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> def</span><span style="--shiki-dark:#80A665;--shiki-light:#59873A"> check_proof</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">...</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> ...</span></span></code></pre></figure>
<p>새 송금 수단을 추가할 때는 executor와 probe를 구현해 등록하기만 하면 됩니다. 기존 노드 코드는 건드리지 않습니다. 부팅할 때 설정에서 켠 Lane만 등록되므로, CEX만 쓰는 배포에는 브릿지 코드가 아예 올라가지 않습니다.</p>
<h3 id="승인과-실행-전-재검증"><a href="https://jerrylog.dev/ko/blog/why-qtg#승인과-실행-전-재검증">승인과 실행 전 재검증</a></h3>
<p>송금 요청 한 건은 택배 한 건과 비슷한 과정을 거칩니다. 접수하고, 내용을 검수하고, 승인을 받은 뒤, 포장해서 발송하고, 배송을 추적하다가, 수령을 확인하면 끝납니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 350" role="img" aria-labelledby="qlc-title qlc-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qlc-title">송금 요청 한 건이 거치는 여덟 단계</title>
  <desc id="qlc-desc">송금 요청은 택배처럼 접수, 검수, 승인, 포장, 발송, 배송 추적, 수령 확인, 완료 순서로 진행된다. 승인 단계에서는 검토한 계획 해시를 대조하고, 발송 직전에는 해시와 주소 허용 목록을 다시 확인한다.</desc>
  <defs>
    <marker id="qlc-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 1행 -->
  <rect class="dg-node dg-node--internal" x="12" y="36" width="208" height="84" rx="10"/>
  <text class="dg-label" x="116" y="68" text-anchor="middle">접수</text>
  <text class="dg-sub" x="116" y="90" text-anchor="middle">송금 요청 생성</text>
  <circle class="dg-badge" cx="12" cy="36" r="12"/>
  <text class="dg-badge-text" x="12" y="40.5" text-anchor="middle">1</text>

  <rect class="dg-node dg-node--internal" x="268" y="36" width="208" height="84" rx="10"/>
  <text class="dg-label" x="372" y="68" text-anchor="middle">검수</text>
  <text class="dg-sub" x="372" y="90" text-anchor="middle">계획 컴파일, 해시 고정</text>
  <circle class="dg-badge" cx="268" cy="36" r="12"/>
  <text class="dg-badge-text" x="268" y="40.5" text-anchor="middle">2</text>

  <rect class="dg-node dg-node--accent" x="524" y="36" width="208" height="84" rx="10"/>
  <text class="dg-label" x="628" y="68" text-anchor="middle">승인</text>
  <text class="dg-sub" x="628" y="90" text-anchor="middle">owner 또는 auto-approve</text>
  <text class="dg-sub" x="628" y="108" text-anchor="middle">검토한 해시와 대조</text>
  <circle class="dg-badge" cx="524" cy="36" r="12"/>
  <text class="dg-badge-text" x="524" y="40.5" text-anchor="middle">3</text>

  <rect class="dg-node dg-node--internal" x="780" y="36" width="208" height="84" rx="10"/>
  <text class="dg-label" x="884" y="68" text-anchor="middle">포장</text>
  <text class="dg-sub" x="884" y="90" text-anchor="middle">제출할 내용 준비, 서명</text>
  <circle class="dg-badge" cx="780" cy="36" r="12"/>
  <text class="dg-badge-text" x="780" y="40.5" text-anchor="middle">4</text>

  <!-- 2행 -->
  <rect class="dg-node dg-node--accent" x="12" y="200" width="208" height="84" rx="10"/>
  <text class="dg-label" x="116" y="232" text-anchor="middle">발송</text>
  <text class="dg-sub" x="116" y="254" text-anchor="middle">출금, 트랜잭션 제출</text>
  <text class="dg-sub" x="116" y="272" text-anchor="middle">해시와 허용 목록 재확인</text>
  <circle class="dg-badge" cx="12" cy="200" r="12"/>
  <text class="dg-badge-text" x="12" y="204.5" text-anchor="middle">5</text>

  <rect class="dg-node dg-node--internal" x="268" y="200" width="208" height="84" rx="10"/>
  <text class="dg-label" x="372" y="232" text-anchor="middle">배송 추적</text>
  <text class="dg-sub" x="372" y="254" text-anchor="middle">결과를 주기적으로 조회</text>
  <circle class="dg-badge" cx="268" cy="200" r="12"/>
  <text class="dg-badge-text" x="268" y="204.5" text-anchor="middle">6</text>

  <rect class="dg-node dg-node--internal" x="524" y="200" width="208" height="84" rx="10"/>
  <text class="dg-label" x="628" y="232" text-anchor="middle">수령 확인</text>
  <text class="dg-sub" x="628" y="254" text-anchor="middle">입금 반영, 블록 확정</text>
  <circle class="dg-badge" cx="524" cy="200" r="12"/>
  <text class="dg-badge-text" x="524" y="204.5" text-anchor="middle">7</text>

  <rect class="dg-node dg-node--ok" x="780" y="200" width="208" height="84" rx="10"/>
  <text class="dg-label" x="884" y="232" text-anchor="middle">완료</text>
  <text class="dg-sub" x="884" y="254" text-anchor="middle">COMPLETED, 콜백 전송</text>
  <circle class="dg-badge" cx="780" cy="200" r="12"/>
  <text class="dg-badge-text" x="780" y="204.5" text-anchor="middle">8</text>

  <path class="dg-edge" d="M220 78H266" marker-end="url(#qlc-a)"/>
  <path class="dg-edge" d="M476 78H522" marker-end="url(#qlc-a)"/>
  <path class="dg-edge" d="M732 78H778" marker-end="url(#qlc-a)"/>
  <path class="dg-edge" d="M884 120V160H116V198" marker-end="url(#qlc-a)"/>
  <path class="dg-edge" d="M220 242H266" marker-end="url(#qlc-a)"/>
  <path class="dg-edge" d="M476 242H522" marker-end="url(#qlc-a)"/>
  <path class="dg-edge" d="M732 242H778" marker-end="url(#qlc-a)"/>

  <text class="dg-note" x="20" y="326">파란 단계에서 계획 해시를 확인합니다. 발송 직전에는 주소 허용 목록도 다시 확인합니다.</text>
</svg>
</div></div><figcaption>송금 요청이 접수부터 완료까지 거치는 여덟 단계</figcaption></figure>
<p>송금 요청이 들어오면 plan compiler가 템플릿과 입력값으로 노드 그래프를 만들고 <code>compiled_plan_hash</code>를 기록합니다. 요청은 <code>PENDING_APPROVAL</code> 상태로 승인을 기다립니다. owner는 승인할 때 자신이 검토한 계획의 해시를 함께 보내야 하고, 이 값이 서버의 해시와 다르면 승인은 거절됩니다. 승인한 내용과 실행될 내용이 같은지 해시로 확인하는 셈입니다.</p>
<p>승인을 받았다고 바로 실행되지도 않습니다. Worker는 노드를 실행하기 직전에 계획의 해시를 다시 계산하고, allowlist도 다시 확인합니다. 그래서, 승인 후에 owner가 어떤 주소를 allowlist에서 빼면, 그 주소로 가는 다음 출금은 실행 직전에 멈춥니다.</p>
<p>Auto-approve는 템플릿 단위로 설정합니다. 다만, 에이전트가 보낸 요청은 auto-approve 대상이 아니며, 항상 사람의 승인을 거칩니다.</p>
<h2 id="설계-결정"><a href="https://jerrylog.dev/ko/blog/why-qtg#설계-결정">설계 결정</a></h2>
<h3 id="airflow-구조를-참고한-스케줄링"><a href="https://jerrylog.dev/ko/blog/why-qtg#airflow-구조를-참고한-스케줄링">Airflow 구조를 참고한 스케줄링</a></h3>
<p>노드 그래프를 만들고 나니, 각 노드를 누가 언제 실행할지 정해야 했습니다. 이 부분은 데이터 엔지니어로 일하며 오래 써 온 Airflow의 구조를 참고했습니다. Airflow는 정해진 일정에 따라 데이터 파이프라인을 실행하는 도구이고, QTG는 송금 한 건의 상태를 끝까지 추적하는 도구여서 목적이 다릅니다. 그래서, 상태를 DB에 두고 실행할 작업을 DB에서 골라 가는 구조만 가져왔습니다.</p>
<p>Airflow는 DAG의 실행 상태를 메타데이터 DB에 저장하고, 스케줄러가 주기적으로 DB를 조회해 실행할 태스크를 고릅니다. 스케줄러를 여러 대 띄울 때도 스케줄러끼리 통신하거나 Raft 같은 합의 알고리즘을 쓰지 않고, DB의 행 잠금(<code>SELECT ... FOR UPDATE</code>)으로 조율합니다. Airflow 문서는 그 이유로 운영의 단순함을 듭니다. 어차피 모든 구성 요소가 이 DB와 통신하기 때문입니다. 다시 말해 DB를 공유 큐로 쓰는 셈입니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 524" role="img" aria-labelledby="qdq-title qdq-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qdq-title">DB를 공유 큐로 쓰는 구조</title>
  <desc id="qdq-desc">Airflow는 여러 스케줄러가 메타데이터 DB를 함께 조회하며 실행할 태스크를 고르고, 웹서버도 같은 DB를 읽는다. QTG는 같은 구조에서 스케줄러 대신 dispatcher, observer, recovery worker가 Postgres를 조회하고, 요청 API와 대시보드가 같은 Postgres를 읽고 쓴다.</desc>
  <defs>
    <marker id="qdq-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- Airflow -->
  <rect class="dg-group" x="20" y="40" width="470" height="430" rx="14"/>
  <rect class="dg-group-tab" x="36" y="29" width="190" height="22" rx="11"/>
  <text class="dg-group-label" x="131" y="44.5" text-anchor="middle">Airflow (HA 스케줄러)</text>

  <rect class="dg-node dg-node--internal" x="44" y="94" width="140" height="68" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(104 104) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="114" y="148" text-anchor="middle">scheduler 1</text>
  <rect class="dg-node dg-node--internal" x="44" y="218" width="140" height="68" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(104 228) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="114" y="272" text-anchor="middle">scheduler 2</text>
  <rect class="dg-node dg-node--internal" x="44" y="342" width="140" height="68" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(104 352) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="114" y="396" text-anchor="middle">scheduler n</text>
  <text class="dg-label" x="114" y="322" text-anchor="middle">⋮</text>

  <path class="dg-node dg-node--ok" d="M244 207V297A50 10 0 0 0 344 297V207"/>
  <ellipse class="dg-node dg-node--ok" cx="294" cy="207" rx="50" ry="10"/>
  <text class="dg-label" x="294" y="246" text-anchor="middle">Metadata</text>
  <text class="dg-label" x="294" y="268" text-anchor="middle">DB</text>

  <rect class="dg-node dg-node--consumer" x="392" y="218" width="84" height="68" rx="10"/>
  <g class="dg-icon dg-icon--consumer" transform="translate(424 228) scale(0.8333)"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20H16M12 16V20"/></g>
  <text class="dg-label" x="434" y="272" text-anchor="middle">web UI</text>

  <path class="dg-edge" d="M184 128H214V226H242" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>
  <path class="dg-edge" d="M184 252H242" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>
  <path class="dg-edge" d="M184 376H214V278H242" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>
  <path class="dg-edge" d="M344 252H390" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>

  <text class="dg-sub" x="255" y="448" text-anchor="middle">스케줄러끼리 통신하지 않고 DB 행 잠금으로 조율</text>

  <!-- QTG -->
  <rect class="dg-group" x="510" y="40" width="470" height="430" rx="14"/>
  <rect class="dg-group-tab" x="526" y="29" width="60" height="22" rx="11"/>
  <text class="dg-group-label" x="556" y="44.5" text-anchor="middle">QTG</text>

  <rect class="dg-node dg-node--accent" x="534" y="84" width="150" height="88" rx="10"/>
  <g class="dg-icon dg-icon--accent" transform="translate(599 94) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="609" y="136" text-anchor="middle">dispatcher</text>
  <text class="dg-sub" x="609" y="156" text-anchor="middle">제출, 2초</text>
  <rect class="dg-node dg-node--accent" x="534" y="208" width="150" height="88" rx="10"/>
  <g class="dg-icon dg-icon--accent" transform="translate(599 218) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="609" y="260" text-anchor="middle">observer</text>
  <text class="dg-sub" x="609" y="280" text-anchor="middle">결과 확인, 5초</text>
  <rect class="dg-node dg-node--warn" x="534" y="332" width="150" height="88" rx="10"/>
  <g class="dg-icon dg-icon--warn" transform="translate(599 342) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="609" y="384" text-anchor="middle">recovery</text>
  <text class="dg-sub" x="609" y="404" text-anchor="middle">UNKNOWN 대조, 60초</text>

  <path class="dg-node dg-node--ok" d="M744 207V297A50 10 0 0 0 844 297V207"/>
  <ellipse class="dg-node dg-node--ok" cx="794" cy="207" rx="50" ry="10"/>
  <text class="dg-label" x="794" y="249" text-anchor="middle">Postgres</text>
  <text class="dg-sub" x="794" y="271" text-anchor="middle">노드 상태</text>

  <rect class="dg-node dg-node--consumer" x="892" y="208" width="76" height="88" rx="10"/>
  <g class="dg-icon dg-icon--consumer" transform="translate(920 218) scale(0.8333)"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20H16M12 16V20"/></g>
  <text class="dg-label" x="930" y="260" text-anchor="middle">API</text>
  <text class="dg-sub" x="930" y="280" text-anchor="middle">대시보드</text>

  <path class="dg-edge" d="M684 128H714V226H742" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>
  <path class="dg-edge" d="M684 252H742" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>
  <path class="dg-edge" d="M684 376H714V278H742" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>
  <path class="dg-edge" d="M844 252H890" marker-start="url(#qdq-a)" marker-end="url(#qdq-a)"/>

  <text class="dg-sub" x="745" y="448" text-anchor="middle">스케줄러 프로세스 없이 worker가 행을 직접 가져감</text>

  <text class="dg-note" x="20" y="508">두 구조 모두 DB를 공유 큐로 씁니다. 상태를 아는 시스템이 DB 하나뿐이라 서로 기록이 어긋날 일이 없습니다.</text>
</svg>
</div></div><figcaption>DB를 공유 큐로 쓰는 구조. QTG는 스케줄러 자리에 역할별 worker를 둡니다.</figcaption></figure>
<p>이 구조는 실행 요청을 바로 밀어 넣는 방식보다 느립니다. 스케줄러가 다음번에 DB를 조회할 때까지 기다려야 하기 때문입니다. 하지만, QTG에서는 이 지연이 문제가 되지 않았습니다. 블록체인의 시간은 연속적으로 흐르지 않고 블록 단위로 끊어집니다. 이더리움은 12초마다 블록이 만들어지고, finality까지는 약 13분이 걸립니다. CCTP 표준 전송은 attestation을 받는 데만 10분 이상 걸리고, CEX 출금은 거래소 심사에 따라 수 분에서 수 시간이 걸립니다. 이런 환경에서는 밀리초 단위로 이벤트를 감지할 일이 거의 없습니다. 몇 초 간격으로 조회해서 생기는 지연은 전체 송금 시간에 비하면 무시할 만합니다.</p>
<p>그래서, QTG는 Airflow의 구조는 가져오되, 스케줄러 프로세스는 따로 두지 않았습니다. Worker가 Postgres를 직접 조회해 작업을 가져갑니다.</p>
<table><thead><tr><th>항목</th><th>Airflow</th><th>QTG</th></tr></thead><tbody><tr><td><strong>DAG 정의</strong></td><td>스케줄러가 읽는 Python 파일</td><td>컴파일된 노드와 엣지 행</td></tr><tr><td><strong>상태 저장</strong></td><td>메타데이터 DB와 스케줄러의 메모리 큐</td><td>Postgres</td></tr><tr><td><strong>작업 배분</strong></td><td>스케줄러가 큐에 넣고 worker가 가져감</td><td>Worker가 행을 직접 잠가 가져감</td></tr></tbody></table>
<p>Worker는 역할별로 나뉩니다. <code>node_dispatcher</code>는 2초마다 실행할 노드를 찾아 제출하고, <code>node_observer</code>는 5초마다 제출된 노드의 결과를 확인합니다. <code>node_recovery</code>는 60초마다 결과를 알 수 없는 노드를 대조합니다. Observer가 노드를 가져갈 때 쓰는 쿼리를 단순화하면 다음과 같습니다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sql" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog"><code data-language="sql" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog" style="display:grid"><span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">SELECT</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> n</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">id</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">FROM</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> movement_request_nodes n</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">JOIN</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> movement_requests r </span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">ON</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> r</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">id</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> n</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">request_id</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">WHERE</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> n</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">node_state</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> IN</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> (</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">SUBMITTED</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">, </span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">OBSERVING</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">)</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">  AND</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> r</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">request_state</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> =</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &#x27;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">EXECUTING</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&#x27;</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">  AND</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> (</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">n</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">next_observe_at</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> IS</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> NULL</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> OR</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B"> n</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">.</span><span style="--shiki-dark:#C99076;--shiki-light:#A65E2B">next_observe_at</span><span style="--shiki-dark:#CB7676;--shiki-light:#AB5959"> &lt;=</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> NOW</span><span style="--shiki-dark:#666666;--shiki-light:#999999">()</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">)</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">FOR</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> UPDATE</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> OF n, r </span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">SKIP</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> LOCKED;</span></span></code></pre></figure>
<p><code>SKIP LOCKED</code>를 쓰면 다른 트랜잭션이 이미 잠근 행은 기다리지 않고 건너뜁니다. 그래서, worker 두 개가 동시에 조회해도 서로 다른 노드를 가져가고, worker가 죽으면 트랜잭션이 끝나면서 잠금도 함께 풀립니다. 처리량을 늘리고 싶으면 worker 프로세스를 더 띄우면 됩니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 616" role="img" aria-labelledby="qsl-title qsl-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qsl-title">FOR UPDATE SKIP LOCKED로 노드를 나눠 가져가기</title>
  <desc id="qsl-desc">잠금 없이 조회하면 worker 두 개가 같은 노드 행 1, 2를 함께 가져가 같은 송금 단계를 두 번 실행할 수 있다. FOR UPDATE SKIP LOCKED로 조회하면 worker 1이 잠근 행 1, 2를 worker 2는 기다리지 않고 건너뛰어 행 3, 4를 가져간다.</desc>
  <defs>
    <marker id="qsl-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qsl-b" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--bad" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qsl-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--accent" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 1. 잠금 없이 -->
  <rect class="dg-group" x="20" y="16" width="960" height="276" rx="14"/>
  <rect class="dg-group-tab" x="36" y="5" width="112" height="22" rx="11"/>
  <text class="dg-group-label" x="92" y="20.5" text-anchor="middle">잠금 없이 조회</text>

  <rect class="dg-node dg-node--internal" x="60" y="94" width="170" height="76" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(135 104) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="145" y="151" text-anchor="middle">worker 1</text>
  <rect class="dg-node dg-node--internal" x="770" y="94" width="170" height="76" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(845 104) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="855" y="151" text-anchor="middle">worker 2</text>

  <text class="dg-sub" x="500" y="66" text-anchor="middle">movement_request_nodes</text>
  <rect class="dg-node dg-node--bad" x="380" y="76" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="98" text-anchor="middle">노드 1</text>
  <rect class="dg-node dg-node--bad" x="380" y="114" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="136" text-anchor="middle">노드 2</text>
  <rect class="dg-node dg-node--consumer" x="380" y="152" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="174" text-anchor="middle">노드 3</text>
  <rect class="dg-node dg-node--consumer" x="380" y="190" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="212" text-anchor="middle">노드 4</text>

  <path class="dg-edge dg-edge--bad" d="M230 124H300V93H378" marker-end="url(#qsl-b)"/>
  <path class="dg-edge dg-edge--bad" d="M230 140H320V131H378" marker-end="url(#qsl-b)"/>
  <path class="dg-edge dg-edge--bad" d="M770 124H700V93H622" marker-end="url(#qsl-b)"/>
  <path class="dg-edge dg-edge--bad" d="M770 140H680V131H622" marker-end="url(#qsl-b)"/>
  <text class="dg-sub" x="145" y="190" text-anchor="middle">노드 1, 2를 가져감</text>
  <text class="dg-sub" x="855" y="190" text-anchor="middle">노드 1, 2를 또 가져감</text>

  <text class="dg-code" x="500" y="256" text-anchor="middle">SELECT id FROM movement_request_nodes WHERE ... LIMIT 2;</text>
  <text class="dg-sub" x="500" y="278" text-anchor="middle">두 worker가 같은 단계를 두 번 실행할 수 있습니다. 출금 노드라면 이중 출금입니다.</text>

  <!-- 2. SKIP LOCKED -->
  <g transform="translate(0 16)">
  <rect class="dg-group" x="20" y="308" width="960" height="276" rx="14"/>
  <rect class="dg-group-tab" x="36" y="297" width="200" height="22" rx="11"/>
  <text class="dg-group-label" x="136" y="312.5" text-anchor="middle">FOR UPDATE SKIP LOCKED</text>

  <rect class="dg-node dg-node--internal" x="60" y="386" width="170" height="76" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(135 396) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="145" y="443" text-anchor="middle">worker 1</text>
  <rect class="dg-node dg-node--internal" x="770" y="386" width="170" height="76" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(845 396) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="855" y="443" text-anchor="middle">worker 2</text>

  <text class="dg-sub" x="500" y="358" text-anchor="middle">movement_request_nodes</text>
  <rect class="dg-node dg-node--accent" x="380" y="368" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="390" text-anchor="middle">노드 1 (잠김)</text>
  <rect class="dg-node dg-node--accent" x="380" y="406" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="428" text-anchor="middle">노드 2 (잠김)</text>
  <rect class="dg-node dg-node--ok" x="380" y="444" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="466" text-anchor="middle">노드 3</text>
  <rect class="dg-node dg-node--ok" x="380" y="482" width="240" height="34" rx="6"/>
  <text class="dg-label" x="500" y="504" text-anchor="middle">노드 4</text>

  <path class="dg-edge dg-edge--accent" d="M230 416H300V385H378" marker-end="url(#qsl-c)"/>
  <path class="dg-edge dg-edge--accent" d="M230 432H320V423H378" marker-end="url(#qsl-c)"/>
  <path class="dg-edge" d="M770 416H680V461H622" marker-end="url(#qsl-a)"/>
  <path class="dg-edge" d="M770 432H700V499H622" marker-end="url(#qsl-a)"/>
  <text class="dg-sub" x="145" y="482" text-anchor="middle">노드 1, 2를 잠그고 가져감</text>
  <text class="dg-sub" x="855" y="482" text-anchor="middle">잠긴 행은 건너뛰고 3, 4</text>

  <text class="dg-code" x="500" y="548" text-anchor="middle">SELECT id FROM movement_request_nodes WHERE ... LIMIT 2 FOR UPDATE SKIP LOCKED;</text>
  <text class="dg-sub" x="500" y="570" text-anchor="middle">worker가 죽으면 트랜잭션이 끝나면서 잠금도 풀리고, 다음 worker가 그 노드를 가져갑니다.</text>
  </g>
</svg>
</div></div><figcaption>잠금 없이 조회하면 두 worker가 같은 노드를 가져갈 수 있습니다. SKIP LOCKED는 이미 잠긴 행을 건너뜁니다.</figcaption></figure>
<p>실제 구현에서는 잠금 없이 후보 ID를 먼저 뽑고, ID마다 조건을 다시 확인하면서 잠그는 두 단계로 나누어 처리합니다.</p>
<p><code>next_observe_at</code>은 노드별로 다음 확인 시각을 저장하는 컬럼입니다. 몇 초면 끝나는 경로는 짧은 간격으로, 수십 분이 걸리는 브릿지는 긴 간격으로 확인합니다. 확인 간격을 데이터로 관리하기 때문에, Lane별로 간격을 바꿔도 worker 코드는 고칠 필요가 없습니다.</p>
<p>스케줄러를 따로 두지 않았기 때문에 송금 상태를 가진 시스템은 Postgres 하나뿐입니다. 스케줄러와 DB가 각자 상태를 가지고 있으면, 두 기록이 어긋났을 때 송금이 실제로 나갔는지 판단하기 어려워집니다. 대신, Airflow가 제공하는 UI, 일정 기반 실행, 다양한 오퍼레이터는 포기했습니다.</p>
<p>지금의 QTG는 정해진 간격으로 DB를 조회하는 폴링 방식입니다. Airflow도 3.0에서 외부 이벤트를 받아 바로 실행하는 이벤트 기반 스케줄링을 도입했는데, QTG도 같은 방향을 계획하고 있습니다. 노드 상태가 바뀔 때 Postgres의 <code>LISTEN/NOTIFY</code>로 worker를 깨우는 방식입니다. 아직 구현하지는 않았습니다. Circle의 Arc 테스트넷에서 노드 3개짜리 Circle Gateway 송금을 측정해 보니, 전체 13.0초 중 5.6초(43%)가 폴링을 기다리는 시간이었습니다. 블록 시간이 짧은 체인에서는 이 대기가 무시할 수 없는 비중이 됩니다. 다만, 지금은 폴링 간격이 거래소별 rate limit의 상한 역할도 하고 있어서, 우선순위와 리소스 풀 같은 장치를 먼저 갖춘 뒤 바꿀 계획입니다.</p>
<h3 id="결과를-알-수-없는-상태"><a href="https://jerrylog.dev/ko/blog/why-qtg#결과를-알-수-없는-상태">결과를 알 수 없는 상태</a></h3>
<p>분산 시스템에서 외부 호출의 결과는 성공, 실패, 알 수 없음 세 가지입니다. 출금을 요청했는데 응답을 받기 전에 연결이 끊기면, 거래소가 요청을 처리했는지 알 수 없습니다. 이때 다시 요청하면 같은 금액이 두 번 나갈 수 있습니다.</p>
<p>QTG는 이 구간을 별도의 상태로 남깁니다. Worker는 외부에 무언가를 제출하기 전에 intent 행을 먼저 기록하고, 제출이 끝나면 결과를 기록합니다. 두 기록 사이에 worker가 죽으면 해당 노드는 <code>UNKNOWN</code> 상태가 됩니다. <code>UNKNOWN</code> 노드는 자동으로 다시 제출하지 않습니다. 대신, recovery worker가 거래소 출금 내역이나 체인을 조회해, 실제로 제출됐는지부터 확인합니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 420" role="img" aria-labelledby="qns-title qns-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qns-title">QTG 노드 상태 전이</title>
  <desc id="qns-desc">노드는 READY, SUBMITTING, SUBMITTED, OBSERVING을 거쳐 COMPLETED가 된다. 제출 응답이 없거나 확인 한도를 넘으면 UNKNOWN이 되고, 자동으로 다시 제출하지 않고 recover를 거쳐 SUBMITTED, COMPLETED, FAILED 중 하나로 옮겨 간다.</desc>
  <defs>
    <marker id="qns-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qns-w" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--warn" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qns-b" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--bad" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- 정상 경로 -->
  <rect class="dg-node dg-node--internal" x="20" y="60" width="136" height="64" rx="10"/>
  <text class="dg-label" x="88" y="88" text-anchor="middle">READY</text>
  <text class="dg-sub" x="88" y="108" text-anchor="middle">실행 대기</text>

  <rect class="dg-node dg-node--internal" x="204" y="60" width="136" height="64" rx="10"/>
  <text class="dg-label" x="272" y="88" text-anchor="middle">SUBMITTING</text>
  <text class="dg-sub" x="272" y="108" text-anchor="middle">외부에 제출 중</text>

  <rect class="dg-node dg-node--internal" x="388" y="60" width="136" height="64" rx="10"/>
  <text class="dg-label" x="456" y="88" text-anchor="middle">SUBMITTED</text>
  <text class="dg-sub" x="456" y="108" text-anchor="middle">참조 ID 확보</text>

  <rect class="dg-node dg-node--internal" x="572" y="60" width="136" height="64" rx="10"/>
  <text class="dg-label" x="640" y="88" text-anchor="middle">OBSERVING</text>
  <text class="dg-sub" x="640" y="108" text-anchor="middle">결과 확인 중</text>

  <rect class="dg-node dg-node--ok" x="800" y="60" width="140" height="64" rx="10"/>
  <text class="dg-label" x="870" y="88" text-anchor="middle">COMPLETED</text>
  <text class="dg-sub" x="870" y="108" text-anchor="middle">완료 확정</text>

  <path class="dg-edge" d="M156 92H202" marker-end="url(#qns-a)"/>
  <path class="dg-edge" d="M340 92H386" marker-end="url(#qns-a)"/>
  <path class="dg-edge" d="M524 92H570" marker-end="url(#qns-a)"/>
  <path class="dg-edge" d="M708 92H798" marker-end="url(#qns-a)"/>

  <!-- 결과를 알 수 없는 상태 -->
  <rect class="dg-node dg-node--warn" x="356" y="240" width="200" height="72" rx="10"/>
  <text class="dg-label" x="456" y="268" text-anchor="middle">UNKNOWN</text>
  <text class="dg-sub" x="456" y="290" text-anchor="middle">결과를 알 수 없음, 재제출 없음</text>

  <rect class="dg-node dg-node--bad" x="800" y="240" width="140" height="72" rx="10"/>
  <text class="dg-label" x="870" y="268" text-anchor="middle">FAILED</text>
  <text class="dg-sub" x="870" y="290" text-anchor="middle">실패 확정</text>

  <!-- UNKNOWN으로 들어가는 선 -->
  <path class="dg-edge dg-edge--warn" d="M272 124V276H354" marker-end="url(#qns-w)"/>
  <text class="dg-edge-label" x="284" y="196">응답 없음</text>
  <path class="dg-edge dg-edge--warn" d="M620 124V276H558" marker-end="url(#qns-w)"/>
  <text class="dg-edge-label" x="630" y="240">확인 한도 초과</text>

  <!-- OBSERVING에서 실패 -->
  <path class="dg-edge dg-edge--bad" d="M680 124V196H870V238" marker-end="url(#qns-b)"/>
  <text class="dg-edge-label" x="775" y="188" text-anchor="middle">실패 확인</text>

  <!-- 대조(recover) 결과 -->
  <path class="dg-edge dg-edge--warn dg-edge--dashed" d="M456 238V126" marker-end="url(#qns-w)"/>
  <text class="dg-edge-label" x="466" y="186">recover: 제출 확인</text>
  <path class="dg-edge dg-edge--warn dg-edge--dashed" d="M558 296H798" marker-end="url(#qns-w)"/>
  <text class="dg-edge-label" x="678" y="288" text-anchor="middle">recover: 실패 확인</text>
  <path class="dg-edge dg-edge--warn dg-edge--dashed" d="M456 314V352H972V92H942" marker-end="url(#qns-w)"/>
  <text class="dg-edge-label" x="714" y="344" text-anchor="middle">recover: 완료 확인</text>

  <text class="dg-note" x="20" y="400">점선은 복구 워커가 거래소 출금 내역이나 체인을 조회해 상태를 확정하는 경로입니다.</text>
</svg>
</div></div><figcaption>노드 상태 전이를 단순화한 그림입니다. 준비와 서명 단계(PREPARING, AWAITING_SIGNATURE)는 생략했습니다.</figcaption></figure>
<p>돈이 이미 나간 뒤에 생긴 실패는 따로 다룹니다. 예를 들어, CEX 송금에서 <code>withdrawal_action</code>은 완료됐는데, <code>withdrawal_observe</code>가 거래소 API 오류로 실패했다고 해 보겠습니다. 이 요청을 단순 실패로 처리하고 처음부터 다시 실행하면 출금이 한 번 더 나갑니다. 그래서, 부작용이 있는 노드(<code>has_side_effect=true</code>)가 완료된 뒤에 다른 노드가 실패하거나 결과를 알 수 없게 되면, 요청 전체를 <code>MANUAL_INTERVENTION</code> 상태로 보냅니다. 이때 자동 재시도는 멈추고 owner에게 알림이 갑니다. owner는 거래소 화면에서 상황을 확인한 뒤, 실행을 이어 가거나 완료 또는 실패로 확정합니다.</p>
<p>전체 상태와 전이 규칙은 <a href="https://jephalabs.mintlify.app/concepts/state-machine">State Machine 문서</a>에서 볼 수 있습니다.</p>
<h3 id="서명-서비스-분리"><a href="https://jerrylog.dev/ko/blog/why-qtg#서명-서비스-분리">서명 서비스 분리</a></h3>
<p>Core가 KMS를 직접 호출하게 만들 수도 있었습니다. 하지만, 그러면 공급망 공격으로 Core 프로세스 안에서 코드가 실행됐을 때 KMS 자격 증명까지 노출됩니다. 그래서, 서명은 별도의 서명 서비스에 맡기고, Core는 이 서비스에 HTTP로 서명을 요청하게 했습니다. Core는 프로세스 안에 서명 설정이 들어 있으면 아예 시작하지 않습니다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog"><code data-language="python" data-theme="vitesse-dark-jerrylog vitesse-light-jerrylog" style="display:grid"><span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">if</span><span style="--shiki-dark:#B8A965;--shiki-light:#998418"> any</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34">value </span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">not</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> in</span><span style="--shiki-dark:#666666;--shiki-light:#999999"> (</span><span style="--shiki-dark:#4D9375;--shiki-light:#1E754F">None</span><span style="--shiki-dark:#666666;--shiki-light:#999999">,</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977"> &quot;&quot;</span><span style="--shiki-dark:#666666;--shiki-light:#999999">)</span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA"> for</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> value </span><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">in</span><span style="--shiki-dark:#DBD7CAEE;--shiki-light:#393A34"> local_values</span><span style="--shiki-dark:#666666;--shiki-light:#999999">):</span></span>
<span data-line=""><span style="--shiki-dark:#87A0F0;--shiki-light:#2E5BDA">    raise</span><span style="--shiki-dark:#B8A965;--shiki-light:#998418"> ValueError</span><span style="--shiki-dark:#666666;--shiki-light:#999999">(</span></span>
<span data-line=""><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">        &quot;</span><span style="--shiki-dark:#C98A7D;--shiki-light:#B56959">qtg.main forbids in-process signer configuration; configure MG_REMOTE_SIGNER_*</span><span style="--shiki-dark:#C98A7D77;--shiki-light:#B5695977">&quot;</span></span>
<span data-line=""><span style="--shiki-dark:#666666;--shiki-light:#999999">    )</span></span></code></pre></figure>
<p>KMS 키 ID와 AWS 자격 증명은 서명 서비스에만 있습니다. Core와 서명 서비스 사이의 요청은 HMAC과 일회용 nonce로 인증하며, 서명 서비스는 사용한 nonce를 Postgres에 기록해 같은 요청이 다시 들어오면 거부합니다. 서명 요청에는 payload와 함께 signing intent(자산, 수량, 수신 주소)와 payload 해시가 들어갑니다. Core는 서명 서비스가 알려 준 서명 주소가 미리 설정해 둔 주소와 같은지도 확인합니다.</p>
<p>Core는 서명을 요청하기 전에 서명 서비스가 준비된 상태인지 먼저 봅니다. 서명 서비스가 내려갔거나, 마지막 확인이 오래됐거나, 잠겨 있으면, 그 서명 서비스에 묶인 노드는 서명을 시도하지 않고 <code>READY</code> 상태로 기다립니다. 아직 아무것도 제출하지 않은 상태여서 <code>UNKNOWN</code> 노드가 생기지 않고, 다른 송금도 멈추지 않습니다. 반면, 인증 실패나 서명 주소 불일치처럼 기다려도 풀리지 않는 문제는 바로 노드를 실패로 처리합니다. 지갑 서명 승인 API도 같은 기준을 따르며, 서명 서비스가 준비되지 않았으면 job 상태를 바꾸지 않고 409 <code>SIGNER_NOT_READY</code>를 돌려줍니다.</p>
<p>서명 방식은 하나의 인터페이스(<code>SignerProtocol</code>)로 추상화했습니다. 처음에는 운영 환경에서 AWS KMS 구현을 쓰고, 로컬 개발에서는 <code>.env</code>나 1Password에서 키를 읽는 개발 전용 서명기를 쓸 수 있게 했습니다. Core는 이 서명기를 거부했지만, 실거래 점검 스크립트나 일회성 CLI 같은 호스트 도구는 프로세스 안에서 키를 읽어 서명할 수 있었습니다. 앞의 표에서 보았듯 이 방식은 키가 프로세스 메모리에 올라온다는 문제를 그대로 안고 있습니다. 그래서, 개발 전용 서명기를 없애고 호스트 도구도 AWS KMS로만 서명하게 바꾸었습니다.</p>
<p>대신, AWS를 쓰지 않는 로컬 배포를 위해 서명 서비스에 암호화 keystore 방식을 추가했습니다. keystore는 scrypt로 유도한 키로 개인키를 암호화한 Ethereum V3 형식의 파일입니다. 서명 서비스는 항상 잠긴(LOCKED) 상태로 시작하고, owner가 컨테이너 안에서 <code>signer_control unlock</code>을 실행해 passphrase를 직접 입력해야 서명을 시작합니다. 잠금 해제 요청은 컨테이너 안의 unix socket으로만 받고, HTTP로는 열어 두지 않았습니다. passphrase는 환경 변수, Compose 파일, 명령 인자, 로그 어디에도 남기지 않고, 키를 밖으로 꺼내는 API도 없습니다. 재시작하면 다시 잠기는데, 자동으로 잠금을 푸는 기능은 아직 없어서 owner가 매번 직접 풀어야 합니다. KMS와 같은 인터페이스에 구현을 추가하는 방식이어서, 나중에 다른 KMS를 써야 할 때도 같은 방법으로 넓힐 수 있습니다.</p>
<h3 id="배포-구성과-보안-수준"><a href="https://jerrylog.dev/ko/blog/why-qtg#배포-구성과-보안-수준">배포 구성과 보안 수준</a></h3>
<p>앞의 표에서 보았듯 QTG의 통제는 대부분 QTG 프로세스와 DB 안에서 동작합니다. 그래서, QTG를 어디서 운영하느냐가 설정 하나를 바꾸는 것보다 보안 수준에 더 큰 영향을 줍니다.</p>
<table><thead><tr><th>항목</th><th>개발용 노트북</th><th>전용 머신이나 VM</th><th>클라우드 VM + IAM 역할</th></tr></thead><tbody><tr><td><strong>디스크의 평문 secret</strong></td><td>secret 파일, AWS 키 파일</td><td>secret 파일, AWS 키 파일</td><td>secret 파일만 (AWS 자격 증명은 인스턴스 역할이 제공)</td></tr><tr><td><strong>공급망 공격 노출</strong></td><td>높음. 같은 파일 시스템에서 <code>npm install</code>, <code>pip install</code> 실행</td><td>낮음</td><td>낮음</td></tr><tr><td><strong>AWS 자격 증명 형태</strong></td><td>오래 유지되는 키 파일</td><td>오래 유지되는 키 파일</td><td>자동으로 교체되는 임시 자격 증명</td></tr><tr><td><strong>패키지 하나가 오염됐을 때 피해</strong></td><td>QTG secret, AWS 자격 증명, 브라우저 세션, SSH 키</td><td>QTG secret</td><td>QTG secret</td></tr></tbody></table>
<p>로컬 호스팅에는 분명한 한계가 있습니다. 패키지를 설치하고 브라우저를 여는 개발 머신에서 돈을 옮기면, 공급망 공격이 떨어지는 곳과 secret이 있는 곳이 같아집니다. 그래서, 실제 자금을 다룰 때는 다음과 같은 구성을 권장합니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg" viewBox="0 0 1000 412" role="img" aria-labelledby="qdp-title qdp-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="qdp-title">권장 배포 구성</title>
  <desc id="qdp-desc">QTG Core, 서명 서비스, Postgres를 모두 AWS에 올리고, 서명 서비스는 IAM 인스턴스 역할로 KMS를 호출한다. Owner PC는 Tailscale VPN을 거쳐 CLI로만 QTG에 접근한다. 거래소 쪽에는 IP 제한과 출금 주소 화이트리스트를 건다.</desc>
  <defs>
    <marker id="qdp-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow" d="M0 0L10 5L0 10z"/></marker>
    <marker id="qdp-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path class="dg-arrow dg-arrow--accent" d="M0 0L10 5L0 10z"/></marker>
  </defs>

  <!-- Owner PC -->
  <rect class="dg-node dg-node--consumer" x="20" y="120" width="180" height="110" rx="10"/>
  <g class="dg-icon dg-icon--consumer" transform="translate(100 130) scale(0.8333)"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20H16M12 16V20"/></g>
  <text class="dg-label" x="110" y="172" text-anchor="middle">Owner PC</text>
  <text class="dg-sub" x="110" y="194" text-anchor="middle">개발, 패키지 설치</text>
  <text class="dg-sub" x="110" y="214" text-anchor="middle">qtg CLI로만 접근</text>

  <path class="dg-edge dg-edge--accent" d="M200 175H298" marker-end="url(#qdp-c)"/>
  <text class="dg-edge-label" x="249" y="165" text-anchor="middle">Tailscale VPN</text>

  <!-- AWS -->
  <rect class="dg-group" x="300" y="40" width="440" height="316" rx="14"/>
  <rect class="dg-group-tab" x="316" y="29" width="84" height="22" rx="11"/>
  <text class="dg-group-label" x="358" y="44.5" text-anchor="middle">AWS 계정</text>

  <rect class="dg-node dg-node--internal" x="328" y="84" width="180" height="88" rx="10"/>
  <g class="dg-icon dg-icon--internal" transform="translate(408 94) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="418" y="136" text-anchor="middle">QTG Core</text>
  <text class="dg-sub" x="418" y="158" text-anchor="middle">tailnet 주소에만 노출</text>

  <path class="dg-node dg-node--ok" d="M556 94V164A80 10 0 0 0 716 164V94"/>
  <ellipse class="dg-node dg-node--ok" cx="636" cy="94" rx="80" ry="10"/>
  <text class="dg-label" x="636" y="136" text-anchor="middle">Postgres</text>
  <text class="dg-sub" x="636" y="158" text-anchor="middle">외부 접근 차단</text>

  <rect class="dg-node dg-node--accent" x="328" y="220" width="180" height="88" rx="10"/>
  <g class="dg-icon dg-icon--accent" transform="translate(408 230) scale(0.8333)"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="8" stroke-width="3.2" stroke-dasharray="3 3.3"/></g>
  <text class="dg-label" x="418" y="272" text-anchor="middle">서명 서비스</text>
  <text class="dg-sub" x="418" y="294" text-anchor="middle">KMS 설정만 보유</text>

  <rect class="dg-node dg-node--accent" x="556" y="220" width="160" height="88" rx="10"/>
  <text class="dg-label" x="636" y="262" text-anchor="middle">AWS KMS</text>
  <text class="dg-sub" x="636" y="284" text-anchor="middle">키는 HSM 안에</text>

  <path class="dg-edge" d="M508 128H554" marker-start="url(#qdp-a)" marker-end="url(#qdp-a)"/>
  <path class="dg-edge dg-edge--accent" d="M418 172V218" marker-end="url(#qdp-c)"/>
  <text class="dg-edge-label" x="428" y="200">nonce-HMAC</text>
  <path class="dg-edge dg-edge--accent" d="M508 264H554" marker-end="url(#qdp-c)"/>

  <text class="dg-sub" x="316" y="336">EC2의 IAM 인스턴스 역할이 임시 자격 증명을 발급해 AWS 키 파일이 없습니다.</text>

  <!-- 거래소와 체인: 구름 -->
  <path class="dg-node dg-node--consumer" d="M828 228H942A38 38 0 0 0 942 151A40 40 0 0 0 885 143A35 35 0 0 0 828 151A38 38 0 0 0 828 228Z"/>
  <text class="dg-label" x="885" y="162" text-anchor="middle">거래소 · 체인</text>
  <text class="dg-sub" x="885" y="185" text-anchor="middle">API 키 IP 제한</text>
  <text class="dg-sub" x="885" y="206" text-anchor="middle">출금 주소 화이트리스트</text>

  <path class="dg-edge" d="M418 84V24H855V130" marker-end="url(#qdp-a)"/>
  <text class="dg-edge-label" x="640" y="18" text-anchor="middle">제출 · 확인</text>

  <text class="dg-note" x="20" y="396">패키지를 설치하는 PC와 돈을 옮기는 서버를 나눕니다. 서버에는 AWS 키 파일을 두지 않습니다.</text>
</svg>
</div></div><figcaption>권장 배포 구성. owner PC는 VPN을 거쳐 CLI로만 QTG에 접근합니다.</figcaption></figure>
<ul>
<li><strong>모든 구성 요소를 AWS에</strong>: QTG Core, 서명 서비스, Postgres를 모두 AWS에 올리고, EC2의 IAM 인스턴스 역할로 KMS를 호출합니다. 서버에는 AWS 자격 증명 파일이 없습니다.</li>
<li><strong>네트워크는 Tailscale VPN으로</strong>: QTG의 포트는 tailnet 주소에만 열고, Postgres는 외부에서 접근할 수 없게 둡니다.</li>
<li><strong>owner PC는 CLI로만</strong>: 개발과 패키지 설치는 owner PC에서 하고, QTG에는 VPN을 거쳐 <code>qtg</code> CLI로만 접근합니다.</li>
<li><strong>거래소 쪽 통제</strong>: 거래소 API 키에 서버의 IP만 허용하고, 출금 주소 화이트리스트를 겁니다.</li>
</ul>
<p>이렇게 구성하면 개발 머신에 공급망 공격이 떨어져도 서명 키와 거래소 키에는 닿기 어렵습니다. 서버가 장악되더라도 KMS 서명 기록은 CloudTrail에 남고, 거래소 쪽 통제는 그대로 유지됩니다. 개인이나 작은 팀이 운영하면서도 기관에 가까운 수준의 보안을 갖출 수 있는 구성이라고 생각합니다. 다만, tailnet에 연결된 모든 기기가 QTG에 닿을 수 있기 때문에, Tailscale ACL로 접근할 수 있는 기기를 좁혀 두어야 합니다.</p>
<h3 id="llm-에이전트-권한-설계"><a href="https://jerrylog.dev/ko/blog/why-qtg#llm-에이전트-권한-설계">LLM 에이전트 권한 설계</a></h3>
<p>QTG의 API 키에는 admin과 agent 두 가지 역할이 있습니다. admin은 사람인 owner가 쓰는 키로, 송금 승인과 거절, 템플릿, 서명 서비스, allowlist 관리를 모두 맡습니다. agent는 허용된 템플릿, 거래소, 자산, 금액 범위 안에서 송금을 제안만 할 수 있습니다. 처음에는 설정 권한 없이 승인만 하는 operator 역할도 있었습니다. 그런데 owner가 Passkey로 로그인해 admin 키 하나로 승인과 설정을 모두 처리하는 구조로 바뀌면서 operator 키를 발급할 일이 없어졌고, 지금은 admin과 agent 두 역할만 남겼습니다. 여러 사람이 나눠 승인하는 구조는 이후 과제로 남겨 두었습니다. 앞에서 설명했듯이 에이전트가 보낸 요청은 auto-approve되지 않습니다.</p>
<p>에이전트는 전용 CLI인 <code>qtg-agent</code>나 MCP 서버인 <code>qtg-mcp</code>로 QTG를 사용합니다. <code>qtg-mcp</code>는 시작할 때 <code>whoami</code>로 API 키의 역할과 네임스페이스를 확인하고, 설정과 다르면 실행되지 않습니다. 잘못된 키가 데스크톱 앱에 조용히 연결되는 일을 막기 위해서입니다.</p>
<h2 id="오픈-코어와-수익화"><a href="https://jerrylog.dev/ko/blog/why-qtg#오픈-코어와-수익화">오픈 코어와 수익화</a></h2>
<p>QTG는 오픈 코어(open core) 방식으로 공개할 계획입니다. 기본 기능은 무료 버전(Free)에, 일부 기능은 유료 버전(Pro)에 넣습니다.</p>
<table><thead><tr><th>구분</th><th>Free</th><th>Pro</th></tr></thead><tbody><tr><td><strong>송금 통제</strong></td><td>송금 그래프, 승인, 감사 기록, allowlist</td><td>Free와 같은 통제를 그대로 사용</td></tr><tr><td><strong>거래소</strong></td><td>Upbit, Binance, Coinbase, Bybit, OKX</td><td>Bithumb, Backpack, Lighter</td></tr><tr><td><strong>브릿지와 체인</strong></td><td>CCTP, Circle Gateway, Hyperliquid</td><td>CCIP, USDT0, Stargate</td></tr><tr><td><strong>운영 기능</strong></td><td>템플릿 단위 auto-approve 한도</td><td>예산 원장 기반 auto-approve, 서명 키 교체와 폐기 관리, 고객 클라우드 배포 패키지</td></tr></tbody></table>
<p>승인, allowlist, 감사 기록 같은 안전 장치는 모두 Free에 들어 있습니다. 무료 버전을 쓴다고 송금이 덜 안전해지면 안 된다고 생각했기 때문입니다. Pro에는 트레이딩 편의성을 높이는 기능을 넣습니다. 쓸 수 있는 거래소와 브릿지를 늘리고, 예산 안에서 자동으로 승인하는 기능처럼 운영 부담을 줄여 주는 기능들입니다.</p>
<p>가격은 아직 정하지 않았지만 연간 구독 방식을 생각하고 있습니다. 서명을 AWS KMS에 맡기는 구조인 만큼, 장기적으로는 AWS Marketplace에서 바로 설치해 쓸 수 있게 만드는 것이 목표입니다.</p>
<h2 id="마무리"><a href="https://jerrylog.dev/ko/blog/why-qtg#마무리">마무리</a></h2>
<p>QTG는 주문 API처럼 입출금도 표준화할 수 없을까 하는 질문에서 출발했습니다. 송금을 노드 그래프와 probe로 나눠 거래소와 브릿지 간 차이를 감췄고, 개인키는 KMS와 별도 서명 서비스 뒤로 옮겼습니다. 또, 블록 생성과 거래소 심사라는 느린 시간 단위에 맞춰, Airflow처럼 DB를 조회하는 worker 구조를 택했습니다. QTG는 아직 개발 중이며, 공개 후 운영하면서 배운 점은 따로 정리하겠습니다.</p>
<p>외부 시스템의 느린 이벤트를 기다리는 서비스를 만든다면, 메시지 브로커를 도입하기 전에 DB를 조회하는 worker만으로 충분한지 먼저 따져 보시길 권합니다.</p>
<h2 id="참고자료"><a href="https://jerrylog.dev/ko/blog/why-qtg#참고자료">참고자료</a></h2>
<ul>
<li><a href="https://jephalabs.mintlify.app/">QTG 문서</a></li>
<li><a href="https://jephalabs.mintlify.app/concepts/overview">QTG 문서: QTG Overview</a></li>
<li><a href="https://jephalabs.mintlify.app/concepts/bridges/cctp-lane">QTG 문서: CCTP Lane</a></li>
<li><a href="https://jephalabs.mintlify.app/concepts/orchestration">QTG 문서: Worker Orchestration</a></li>
<li><a href="https://jephalabs.mintlify.app/concepts/movement-authorization-model">QTG 문서: Movement Authorization Model</a></li>
<li><a href="https://jephalabs.mintlify.app/security-tradeoffs">QTG 문서: Security Tradeoffs</a></li>
<li><a href="https://airflow.apache.org/docs/apache-airflow/2.2.5/concepts/scheduler.html">Apache Airflow 문서: Scheduler</a></li>
<li><a href="https://www.bleepingcomputer.com/news/security/ai-powered-malware-hit-2-180-github-accounts-in-s1ngularity-attack/">BleepingComputer: AI-powered malware hit 2,180 GitHub accounts in s1ngularity attack</a></li>
<li><a href="https://github.com/ccxt/ccxt/wiki/Manual">CCXT Manual</a></li>
<li><a href="https://bleepingcomputer.com/news/security/shai-hulud-malware-infects-500-npm-packages-leaks-secrets-on-github">BleepingComputer: Shai-Hulud malware infects 500 npm packages, leaks secrets on GitHub</a></li>
<li><a href="https://thehackernews.com/2026/07/injective-labs-github-compromise-pushes.html">The Hacker News: Injective Labs GitHub Compromise Pushes Wallet-Key-Stealing npm Packages</a></li>
<li><a href="https://startupfortune.com/two-npm-attacks-in-four-days-show-cryptos-weak-point-is-the-supply-chain/">Startup Fortune: Two npm attacks in four days show crypto&#x27;s weak point is the supply chain</a></li>
</ul>]]></content:encoded>
      <pubDate>Thu, 22 Oct 2026 00:00:00 GMT</pubDate>
      <category><![CDATA[QTG]]></category><category><![CDATA[movement-orchestration]]></category><category><![CDATA[quant-infra]]></category><category><![CDATA[aws-kms]]></category><category><![CDATA[airflow]]></category>
    </item>
<item>
      <title><![CDATA[Multi Armed Bandit 변천사 훑어보기]]></title>
      <link>https://jerrylog.dev/ko/blog/multi-armed-bandit-history</link>
      <guid isPermaLink="true">https://jerrylog.dev/ko/blog/multi-armed-bandit-history</guid>
      <description><![CDATA[MAB 프레임워크로 Online Learning to Rank를 어떻게 정의하는지와 그 변천사를 간략히 정리한 글.]]></description>
      <content:encoded><![CDATA[<p>특정 컨텐츠에 순위를 매긴다는 개념은 IR 분야에서 Learning to Rank 프레임워크로 많이 연구가 되었습니다. 최근 랭킹 트렌드는 그 경계가 모호해진 것 같고, 실시간 학습/인퍼런스 보다는 딥러닝 모델과 같이 배치 학습/인퍼런스가 이루어지는 경우가 많은 것 같지만, 추천 분야에서는 Online Learning의 필요성이 많았고, Online Learning to Rank를 MAB 프레임워크로 풀어나가는 경우가 여전히 많습니다.</p>
<p>MAB 프레임워크로 OLTR을 어떻게 정의하는지와, 그 변천사를 간략히 정리해보았습니다.</p>
<hr/>
<h2 id="언급된-mab-알고리즘"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#언급된-mab-알고리즘">언급된 MAB 알고리즘</a></h2>
<p><strong>기존 Classical MAB</strong> : <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span>-greedy, UCB, Thompson Sampling</p>
<p><strong>Contextual MAB</strong> : <a href="http://rob.schapire.net/papers/www10.pdf">LinUCB (WWW 10)</a>, <a href="http://proceedings.mlr.press/v28/agrawal13">LinTS (ICML 13)</a>, <a href="http://proceedings.mlr.press/v97/kveton19a.html">GIRO (ICML 19)</a></p>
<p><strong>Non-stationary MAB</strong> : <a href="https://arxiv.org/abs/0805.3415">DiscountedUCB + SlidingWindowUCB (ALT 11)</a>, <a href="https://arxiv.org/abs/1711.03539">CUSUM-UCB + PHT-UCB (AAAI 18)</a>, <a href="https://arxiv.org/pdf/1707.09727.pdf">dTS (NIPS 17)</a></p>
<p><strong>Contextual + Non-stationary MAB</strong> : <a href="https://arxiv.org/abs/1409.8191">NeuralBandit (ICONIP 14)</a>, <a href="https://doi.org/10.1145/3209978.3210051">dLinUCB (SIGIR 18)</a></p>
<p><strong>Multiple Play MAB</strong></p>
<ul>
<li>단순 해결 : <a href="https://arxiv.org/abs/1506.00779">MP-TS + IMP-TS (ICML 15)</a></li>
<li>Click Model과 결합 : <a href="https://arxiv.org/pdf/1502.02763.pdf">CascadeUCB1 + CascadeKL-UCB (ICML 15)</a>, <a href="https://arxiv.org/abs/1810.02567">RecurRank(ICML 19)-Non MAB</a></li>
<li>Combinatorial 관점: <a href="https://pdfs.semanticscholar.org/e79d/695182260feb634201a5dac71ba7bde049e8.pdf">CUCB (ICML 13)</a></li>
<li>Combinatorial + Volatile arm : <a href="https://papers.nips.cc/paper/7586-contextual-combinatorial-multi-armed-bandits-with-volatile-arms-and-submodular-reward.pdf">CC-MAB (NIPS 18)</a></li>
</ul>
<p><strong>Batched Update MAB</strong> : <a href="https://research.yahoo.com/publications/8988/batched-multi-armed-bandit-approach-news-headline-testing">bTS (IEEE BigData 18)</a></p>
<p><strong>Using Feedback as Relative Perspective</strong> : <a href="https://proceedings.mlr.press/v32/zoghi14.pdf">RUCB (ICML 14)</a>, <a href="https://homepages.dcc.ufmg.br/~rodrygo/wp-content/papercite-data/pdf/pereira2019recsys.pdf">CDB (RecSys 19)</a></p>
<hr/>
<h2 id="exploration-exploitation-dilemma"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#exploration-exploitation-dilemma">Exploration-Exploitation Dilemma</a></h2>
<p><img src="https://jerrylog.dev/assets/images/slot.png" alt="Slot machine illustration"/></p>
<p>외팔이 강도가 2개의 슬롯머신을 당길 기회가 있는데, 두 개의 슬롯머신은 reward를 반환할 확률이 다르다고 합시다. 그걸 모르는 상태에서 어떻게 슬롯 머신을 당겨야 가장 높은 reward 기댓값을 얻을 수 있을까요?</p>
<p>Classical MAB의 가장 단순하고 흔한 예시는 슬롯머신이 reward를 반환할 확률이 베르누이 분포를 따른다고 가정하는 것입니다. 그렇다면 둘 중에 확률이 높은 슬롯머신만 계속 당긴다면 최고의 결과를 얻을 수 있을 것입니다. 하지만, 우리는 oracle (확률 또는 분포를 알고 있는 절대자)이 아니기 때문에 일단 당겨보면서 어떤 슬롯머신이 더 많은 reward를 반환할지 탐험을 해야할 필요가 있습니다.</p>
<p>여기서, 최선의 선택 (그림에서는 오른쪽 슬롯머신)을 했을 때의 reward 기댓값, 그리고 최선의 선택을 하지 않았을 때의 reward 기댓값의 차이를 regret이라고 합니다. 위 그림을 예시로 들면 왼쪽 머신을 당길 때마다 0.2의 regret이 누적되는 것입니다. regret을 정의하는 방법은 논문마다 다르기도 하지만, 실서비스 적용에서 그리 중요한 포인트는 아니기 때문에 컨셉만 알고 넘어가면 될 것 같습니다.</p>
<p>그렇다면, 추천 시스템에서는 reward를 어떻게 정의할까요? 노출 (impression) 후 click이 발생한 경우 reward 1이 발생, unclick이 발생한 경우는 reward가 발생하지 않는다고 정의하면, reward의 기댓값은 노출대비 클릭의 기댓값이 되고, 그리고 그건 CTR이 됩니다!! Tada~~ 그렇기 때문에 <code>regret 최소화 == CTR 최대화</code>와 동일해집니다.</p>
<p>요약하면: <strong>모르지만, 당겨보면서 알아가보자</strong></p>
<h2 id="classical-mab"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#classical-mab">Classical MAB</a></h2>
<h3 id="1-e-greedy"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#1-e-greedy">1. <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">e</span></span></span></span>-greedy</a></h3>
<p>트래픽 중, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span> 만큼의 비율은 무작위로 탐색(exploration)하고, 나머지 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7278em;vertical-align:-0.0833em"></span><span class="mord">1</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em"></span></span><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span> 에 대해 지금까지 관찰된 결과를 바탕으로 탐욕적으로 가장 최선의 arm을 draw하는 (empirical) 전략을 <strong><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span>-greedy</strong> 전략이라 부릅니다. 시도마다 슬롯 손잡이 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">a</span></span></span></span> 의 reward의 기댓값 (CTR)을 추정해 나가고, 시도가 계속될 수록 reward의 추정치는 참값에 수렴함을 가정합니다. 하지만, 관측 횟수가 적은 경우에는 관측결과의 신뢰도가 매우 낮기 때문에 문제가 생깁니다. (ex. 12345를 4번 추천했는데 3이 2번 4가 1번 실제로 클릭되었다. 그러면 3이 best case일까? 이 결과를 믿을 수 있을까?) 반대로, 관측 횟수가 충분히 많아 신뢰도가 높은데도 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span> 만큼의 explore를 반드시 해야한다는 점이 문제가 됩니다. 이러한 문제를 시간의 흐름 (시도 횟수)에 따라 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span> 값을 적절하게 감소시켜가며 해결할 수도 있습니다. 적절하게 탐색 확률을 감소시킨다면 T시점에 최선의 선택을 하지 않는데서 발생하는 Regret <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0077em">R</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em"><span style="top:-2.55em;margin-left:-0.0077em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">A</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.1389em">T</span><span class="mclose">)</span></span></span></span>를 T로 나눈 값, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0077em">R</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em"><span style="top:-2.55em;margin-left:-0.0077em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">A</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.1389em">T</span><span class="mclose">)</span><span class="mord">/</span><span class="mord mathnormal" style="margin-right:0.1389em">T</span></span></span></span> 는 0에 수렴하게 됩니다.</p>
<h3 id="2-optimism-in-face-of-uncertainty-ofu"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#2-optimism-in-face-of-uncertainty-ofu">2. Optimism in Face of Uncertainty (OFU)</a></h3>
<p>OFU는 불확실한 것을 낙관적으로 보자는 컨셉의 알고리즘입니다.<br/></p>
<del>투자를 하고난 직후에는 장밋빛 미래가 그려지는 것 처럼</del>
<p>앞서 설명한 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span>-greedy는 항상 empirical mean이 좋은 arm만을 고르고, 나머지를 입실론의 확률로 고르지만, 실제 arm에서 얻는 보상은 constant가 아닌 특정 분포에서 draw되는 r.v라고 보는 것이 합리적이기 때문에 탐색 전략에 신뢰구간의 개념을 도입합니다. OFU는 현재까지 관찰된 결과를 바탕으로 통계적으로 계산된 신뢰구간의 상한선 (Upper Confidence Bound) 를 그 arm의 가치로 보고, 그 arm을 draw 합니다.</p>
<p>예를 들어, 관찰된 결과를 바탕으로 아래와 같은 사후분포를 추정해 냈을 때, 누가봐도 평균값은 파란색&lt;초록색&lt;주황색 순서이지만, 95% 신뢰구간의 상한선은 파란색이 제일 높으므로, 파란색 arm을 draw하는 것입니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/nSnlmxL.png" alt="Posterior distribution example"/></center>
<p>UCB를 정의하는 방법에 따라 여러 변형 알고리즘들이 있지만, 대표적인 UCB 알고리즘인 UCB1이 arm을 고르는 방식은 다음과 같습니다.</p>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6595em"></span><span class="mord mathnormal">i</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:1.5054em;vertical-align:-0.9221em"></span><span class="mord"><span class="mop op-limits"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.4306em"><span style="top:-2.1779em;margin-left:0em"><span class="pstrut" style="height:3em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span style="top:-3em"><span class="pstrut" style="height:3em"></span><span><span class="mop"><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.0278em">r</span><span class="mord mathnormal" style="margin-right:0.0359em">g</span><span class="mord mathnormal">ma</span><span class="mord mathnormal">x</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.9221em"><span></span></span></span></span></span></span><span class="mord accent"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.5678em"><span style="top:-3em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span><span style="top:-3em"><span class="pstrut" style="height:3em"></span><span class="accent-body" style="left:-0.25em"><span class="mord">ˉ</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span></span><span class="base"><span class="strut" style="height:2.44em;vertical-align:-0.8384em"></span><span class="mord sqrt"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.6016em"><span class="svg-align" style="top:-4.4em"><span class="pstrut" style="height:4.4em"></span><span class="mord" style="padding-left:1em"><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.3714em"><span style="top:-2.314em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal">n</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.677em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord">2</span><span class="mord mathnormal" style="margin-right:0.0197em">l</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.836em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span><span style="top:-3.5616em"><span class="pstrut" style="height:4.4em"></span><span class="hide-tail" style="min-width:1.02em;height:2.48em"><svg xmlns="http://www.w3.org/2000/svg" width="400em" height="2.48em" viewBox="0 0 400000 2592" preserveAspectRatio="xMinYMin slice"><path d="M424,2478
c-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514
c0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20
s-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121
s209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081
l0 -0c4,-6.7,10,-10,18,-10 H400000
v40H1014.6
s-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185
c-2,6,-10,9,-24,9
c-8,0,-12,-0.7,-12,-2z M1001 80
h400000v40h-400000z"></path></svg></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.8384em"><span></span></span></span></span></span></span></span></span></span>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> 는 i번째 arm의 지금까지 관측한 reward (클릭)의 평균값이고, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">n</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> 는 arm i가 play (impression)된 횟수를 의미합니다. 위의 값은 arm i의 실제 보상에 대한 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7278em;vertical-align:-0.0833em"></span><span class="mord">1</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em"></span></span><span class="base"><span class="strut" style="height:1.1901em;vertical-align:-0.345em"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.8451em"><span style="top:-2.655em"><span class="pstrut" style="height:3em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.394em"><span class="pstrut" style="height:3em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.345em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span> 의 신뢰구간의 upper bound를 의미하는 것으로, <a href="https://en.wikipedia.org/wiki/Chernoff_bound">Chernoff-Hoeffding bound</a> 에 의해 얻어지는 값입니다. 처음에는 관측 결과 수가 적은 arm들이 뽑힐 확률이 높지만 (exploration), time은 log scale로 증가, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">n</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> 는 linear하게 증가하므로 우항의 값은 시간이 흐를 수록 작아지고, exploration의 비율이 시간이 흐름에 따라 감소합니다.</p>
<h3 id="3-thompson-sampling"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#3-thompson-sampling">3. Thompson Sampling</a></h3>
<p>Probability Matching이라고도 불립니다. Google Analytics의 AB 테스팅 최적화에도 사용된 알고리즘이며, 일반적으로 UCB나 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span>-greedy 보다 성능이 높다고 알려져 있습니다. 시간 t마다 정책에 따라 arm a를 선택하고, 그에 상응하는 reward <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal" style="margin-right:0.0278em">r</span></span></span></span> 을 받을 때, Thompson sampling은 관측치 (<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em"></span><span class="mord"><span class="mord mathnormal">a</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em">r</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span>) 와 파라미터 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em"></span><span class="mord mathnormal" style="margin-right:0.0278em">θ</span></span></span></span> 를 사용해 likelihood function <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0278em">r</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">∣</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord mathnormal">a</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="mclose">)</span></span></span></span> 를 설계한 다음, prior를 가정해 posterior를 업데이트하고, 그 posterior에서 직접 샘플을 뽑아 의사결정을 하는 방식입니다. 이 때, 각각의 arm의 reward가 관찰될 확률 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8444em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span>이 독립적인 베르누이 분포를 따른다고 가정했을 때, conjugacy 특성으로 인해 prior를 베타분포로 잡는다면, posterior 또한 베타분포가 됩니다. 이 때, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8444em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> 는 다음과 같은 파라미터를 가집니다.</p>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8444em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">∼</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord mathnormal" style="margin-right:0.0502em">B</span><span class="mord mathnormal">e</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0037em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0528em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mclose">)</span></span></span></span></span>
<p>분포가 가정되었기 때문에 분포를 통해 평균과 분산을 알 수 있습니다. 이 때, 분산이 의미하는 것은 <strong>Explore</strong> 입니다. 베타 분포의 평균과 분산은 다음과 같은 모양입니다.</p>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:4.5954em;vertical-align:-2.0477em"></span><span class="mtable"><span class="col-align-r"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.5477em"><span style="top:-4.8116em"><span class="pstrut" style="height:3.3714em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="mopen">[</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mclose">]</span></span></span><span style="top:-2.2597em"><span class="pstrut" style="height:3.3714em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.2222em">V</span><span class="mopen">[</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mclose">]</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.0477em"><span></span></span></span></span></span><span class="col-align-l"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.5477em"><span style="top:-4.8116em"><span class="pstrut" style="height:3.3714em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.1076em"><span style="top:-2.314em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord mathnormal" style="margin-right:0.0528em">β</span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.677em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.8804em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span><span style="top:-2.2597em"><span class="pstrut" style="height:3.3714em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.3714em"><span style="top:-2.314em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="mclose"><span class="mclose">)</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.7401em"><span style="top:-2.989em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord">1</span><span class="mclose">)</span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.677em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="mord mathnormal" style="margin-right:0.0528em">β</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.936em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.0477em"><span></span></span></span></span></span></span></span><span class="tag"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.5477em"><span style="top:-4.8116em"><span class="pstrut" style="height:3.3714em"></span><span class="eqn-num"></span></span><span style="top:-2.2597em"><span class="pstrut" style="height:3.3714em"></span><span class="eqn-num"></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.0477em"><span></span></span></span></span></span></span></span></span>
<p>Thompson Sampling의 파라미터 업데이트는 다음과 같이 이루어지기 때문에, 시행이 거듭될 수록 분산은 작아지게 되므로, Exploration의 정도는 줄어들게 됩니다. 이 때, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6891em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">∈</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mopen">{</span><span class="mord">0</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord">1</span><span class="mclose">}</span></span></span></span> 는 클릭의 유무를 의미합니다.</p>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:6.1651em;vertical-align:-2.8326em"></span><span class="mtable"><span class="col-align-r"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:3.3326em"><span style="top:-6.2618em"><span class="pstrut" style="height:3.8283em"></span><span class="mord"><span class="mord mathnormal">p</span><span class="mopen">(</span><span class="mord mathnormal">u</span><span class="mclose">)</span></span></span><span style="top:-3.7734em"><span class="pstrut" style="height:3.8283em"></span><span class="mord"><span class="mord mathnormal">L</span><span class="mord mathnormal" style="margin-right:0.0315em">ik</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0197em">l</span><span class="mord mathnormal">ih</span><span class="mord mathnormal">oo</span><span class="mord mathnormal">d</span></span></span><span style="top:-1.3558em"><span class="pstrut" style="height:3.8283em"></span><span class="mord"><span class="mord mathnormal">p</span><span class="mord mathnormal">os</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.0278em">er</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.0278em">or</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.8326em"><span></span></span></span></span></span><span class="col-align-l"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:3.3326em"><span style="top:-6.2618em"><span class="pstrut" style="height:3.8283em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">∝</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal">μ</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8641em"><span style="top:-3.113em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0037em">α</span><span class="mbin mtight">−</span><span class="mord mtight">1</span></span></span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord">1</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord mathnormal">μ</span><span class="mclose"><span class="mclose">)</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8991em"><span style="top:-3.113em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0528em">β</span><span class="mbin mtight">−</span><span class="mord mtight">1</span></span></span></span></span></span></span></span></span></span></span><span style="top:-3.7734em"><span class="pstrut" style="height:3.8283em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mop op-limits"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.8283em"><span style="top:-1.8723em;margin-left:0em"><span class="pstrut" style="height:3.05em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">i</span><span class="mrel mtight">=</span><span class="mord mtight">1</span></span></span></span><span style="top:-3.05em"><span class="pstrut" style="height:3.05em"></span><span><span class="mop op-symbol large-op">∏</span></span></span><span style="top:-4.3em;margin-left:0em"><span class="pstrut" style="height:3.05em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.109em">N</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.2777em"><span></span></span></span></span></span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord"><span class="mord mathnormal">μ</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.7144em"><span style="top:-3.113em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight"><span class="mord mathnormal mtight">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3281em"><span style="top:-2.357em;margin-left:0em;margin-right:0.0714em"><span class="pstrut" style="height:2.5em"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.143em"><span></span></span></span></span></span></span></span></span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord">1</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord mathnormal">μ</span><span class="mclose"><span class="mclose">)</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8641em"><span style="top:-3.113em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">1</span><span class="mbin mtight">−</span><span class="mord mtight"><span class="mord mathnormal mtight">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3281em"><span style="top:-2.357em;margin-left:0em;margin-right:0.0714em"><span class="pstrut" style="height:2.5em"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.143em"><span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span><span style="top:-1.3558em"><span class="pstrut" style="height:3.8283em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord mathnormal" style="margin-right:0.0197em">l</span><span class="mord mathnormal" style="margin-right:0.0315em">ik</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0197em">l</span><span class="mord mathnormal">ih</span><span class="mord mathnormal">oo</span><span class="mord mathnormal">d</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord mathnormal">p</span><span class="mord mathnormal" style="margin-right:0.0278em">r</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.0278em">or</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.8326em"><span></span></span></span></span></span></span></span><span class="tag"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:3.3326em"><span style="top:-6.2618em"><span class="pstrut" style="height:3.8283em"></span><span class="eqn-num"></span></span><span style="top:-3.7734em"><span class="pstrut" style="height:3.8283em"></span><span class="eqn-num"></span></span><span style="top:-1.3558em"><span class="pstrut" style="height:3.8283em"></span><span class="eqn-num"></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.8326em"><span></span></span></span></span></span></span></span></span>
<p>OFU 알고리즘은 추정된 분포의 신뢰구간 상한선을 바탕으로 의사결정을 했다면, TS 알고리즘은 추정된 분포로부터 직접 샘플을 뽑아 의사결정을 합니다.</p>
<p>아래 영상은 베르누이 밴딧에서 Thompson Sampling을 구현하여 시뮬레이션 한 것입니다. 각 밴딧에서 reward가 관찰될 확률이 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em"></span><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mopen">[</span><span class="mord">0.1</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord">0.2</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord">0.3</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord">0.4</span><span class="mclose">]</span></span></span></span> 인 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em"></span><span class="mord mathnormal" style="margin-right:0.0715em">K</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:0.6444em"></span><span class="mord">4</span></span></span></span> 인 밴딧 상황에서 베타분포가 적합되는 과정을 관찰할 수 있습니다. 동시에, Exploration-Exploitation의 정도도 관찰할 수 있습니다.</p>
<center><video width="750" height="416.67" controls="" autoPlay="" loop=""><source type="video/mp4" src="https://jerrylog.dev/assets/videos/Good_TS.mp4"/></video></center>
<hr/>
<h2 id="after-classical"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#after-classical">After Classical</a></h2>
<p>하나의 논문을 정독하기보다는 MAB 연구의 추세를 폭넓게 이해해보고자 하였습니다.</p>
<p>MAB 리서치를 진행하며 느낀점은, sota 논문들은 대부분 기존 MAB 방법론들을 베이스라인으로 실험을 진행하고, 그 것들보다 성능이 나음을 주장하고 있다는 점입니다. 그러나, 대부분의 MAB 논문들이 stationary, Top-1 draw, reward ~ R-sub gaussian 등 이상적인 상황을 가정하고 있기 때문에 sota 알고리즘을 소개하는 논문에서의 성능 향상, 특히 offline evaluation 상황은 실 서비스에 적용했을 때 성능향상이 실제로 일어날지 최대한 의심하며, <strong>sota 알고리즘이 기존의 알고리즘에서 극복하려고 한 한계는 무엇인가?</strong> 를 중심으로 리서치를 진행했습니다. 여러 알고리즘들의 컨셉을 잘 정리해두면 후일에 더 빠른 문제해결에 도움이 될 것이라고 생각하기 때문입니다.</p>
<br/>
<h3 id="1-non-stationary-mab"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#1-non-stationary-mab">1. Non-stationary MAB</a></h3>
<p>Classical MAB에서 reward distribution이 time-independent하다는 가정을 파괴합니다. 시간이 흐르면 특정 arm의 CTR 기댓값은 감소하거나 증가할 수 있다는 것입니다. Non-stationary 환경은 서서히 reward의 기댓값이 변한다는 Slowly-varying, breakpoint 전후에는 일정함을 유지한다는 Abrubtly-changing 두 가지로 구분되어 정의되고, 이러한 각각의 환경에 맞게 MAB policy도 크게 두가지 범주로 나뉩니다.</p>
<h4 id="11-passively-adaptive-policies"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#11-passively-adaptive-policies">1.1. Passively Adaptive Policies</a></h4>
<p>시간 할인의 개념을 이용해 최근 데이터에 높은 가중치를 줌으로써 <strong>현재</strong> 의 best arm을 찾습니다. 이 범주에 속하는 ofu 방법으로는 <a href="https://arxiv.org/abs/0805.3415"><em>DiscountedUCB</em>, <em>SlidingWindowUCB</em></a> 가 있고, 톰슨 샘플링 기반으로는 <a href="https://arxiv.org/pdf/1707.09727.pdf"><em>Taming Non-stationary Bandits: A Bayesian Approach (NIPS 2017)</em></a> 에서 소개된 <em>discounted Thompson Sampling (dTS)</em>가 있습니다.</p>
<p><em>DiscountedUCB</em>는 지난 관측 결과일수록 gamma를 이용해 시간할인을 하여 그
영향력을 감소시키며, <em>SlidingWindowUCB</em>는 지정된 윈도우 기간만큼의 관측
결과만을 사용합니다. 이러한 수동적인 적응 방법론은 과거의 데이터의 영향력을
줄이는 것이지 사용하지 않는 것이 아니기 때문에 급격한 변화에 발빠르게 대응을
하지 못하는 Time-lag 문제가 발생합니다.</p>
<h4 id="12-actively-adaptive-policies"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#12-actively-adaptive-policies">1.2. Actively Adaptive Policies</a></h4>
<p>급격한 변화를 빠르게 잡아낼 수 있는 <strong>Change Detection Algorithm</strong>을 사용합니다. Two-sided CUSUM UCB, PHT-UCB가 <a href="https://arxiv.org/abs/1711.03539"><em>A Change-Detection based Framework for Piecewise-stationary MAB Problem (AAAI 2018)</em></a> 에서 소개되었습니다. 이 중 Two-sided CUSUM UCB는 Fault Detection을 위해 주로 사용하는 통계적 관리도 (SPC) 변화 탐지 모형 중 누적합을 이용한 CUSUM 관리도를 이용한 것이고, <em>PHT-UCB</em> 는 Page-Hinkley Test를 이용해 변화 탐지를 하는 모형입니다.</p>
<figure class="dg-figure"><div class="zoomable zoomable--clickable"><div class="dg-viewport"><svg class="dg dg-chart" viewBox="0 0 1000 440" role="img" aria-labelledby="cus-title cus-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="cus-title">Two-sided CUSUM 변화 탐지</title>
  <desc id="cus-desc">평균이 t=50에서 올라가면 상방 누적합 S+가 커지다가 t=55에서 임계값 h=5를 넘어 변화 신호를 보낸다. 하방 누적합 S-는 0 근처에 머문다. k=0.5.</desc>
  <path class="dg-grid dg-grid--zero" d="M88 360.0H820"/>
  <text class="dg-tick" x="78" y="364.0" text-anchor="end">0</text>
  <path class="dg-grid" d="M88 306.7H820"/>
  <text class="dg-tick" x="78" y="310.7" text-anchor="end">2</text>
  <path class="dg-grid" d="M88 253.3H820"/>
  <text class="dg-tick" x="78" y="257.3" text-anchor="end">4</text>
  <path class="dg-grid" d="M88 200.0H820"/>
  <text class="dg-tick" x="78" y="204.0" text-anchor="end">6</text>
  <path class="dg-grid" d="M88 146.7H820"/>
  <text class="dg-tick" x="78" y="150.7" text-anchor="end">8</text>
  <path class="dg-grid" d="M88 93.3H820"/>
  <text class="dg-tick" x="78" y="97.3" text-anchor="end">10</text>
  <path class="dg-grid" d="M88 40.0H820"/>
  <text class="dg-tick" x="78" y="44.0" text-anchor="end">12</text>
  <text class="dg-tick" x="88.0" y="382" text-anchor="middle">0</text>
  <text class="dg-tick" x="200.6" y="382" text-anchor="middle">10</text>
  <text class="dg-tick" x="313.2" y="382" text-anchor="middle">20</text>
  <text class="dg-tick" x="425.8" y="382" text-anchor="middle">30</text>
  <text class="dg-tick" x="538.5" y="382" text-anchor="middle">40</text>
  <text class="dg-tick" x="651.1" y="382" text-anchor="middle">50</text>
  <text class="dg-tick" x="763.7" y="382" text-anchor="middle">60</text>
  <path class="dg-axis" d="M88 360H820"/>
  <text class="dg-axis-title" x="454" y="412" text-anchor="middle">시점 t</text>
  <text class="dg-axis-title" x="24" y="200" text-anchor="middle" transform="rotate(-90 24 200)">누적합 S</text>
  <path class="dg-line dg-line--muted dg-line--dashed" d="M651.1 40V360"/>
  <text class="dg-edge-label" x="643.1" y="54" text-anchor="end">평균 변화 (t=50)</text>
  <path class="dg-line dg-line--bad dg-line--dashed" d="M88 226.7H820"/>
  <text class="dg-series-label" x="832" y="231.7">임계값 h</text>
  <path class="dg-line dg-line--ok" d="M88.0 360.0L99.3 360.0L110.5 360.0L121.8 360.0L133.0 360.0L144.3 348.5L155.6 356.2L166.8 360.0L178.1 360.0L189.4 360.0L200.6 360.0L211.9 360.0L223.1 360.0L234.4 328.9L245.7 360.0L256.9 360.0L268.2 360.0L279.4 328.2L290.7 295.1L302.0 284.7L313.2 285.5L324.5 307.0L335.8 319.1L347.0 346.3L358.3 342.5L369.5 360.0L380.8 360.0L392.1 355.7L403.3 360.0L414.6 360.0L425.8 360.0L437.1 356.8L448.4 350.4L459.6 354.6L470.9 360.0L482.2 360.0L493.4 360.0L504.7 360.0L515.9 347.8L527.2 347.3L538.5 360.0L549.7 351.8L561.0 360.0L572.2 360.0L583.5 333.6L594.8 348.2L606.0 360.0L617.3 319.6L628.6 324.4L639.8 334.9L651.1 326.4L662.3 360.0L673.6 360.0L684.9 360.0L696.1 360.0L707.4 360.0L718.6 360.0L729.9 360.0L741.2 360.0L752.4 360.0L763.7 360.0L775.0 360.0L786.2 360.0L797.5 360.0L808.7 360.0L820.0 360.0"/>
  <path class="dg-line dg-line--accent" d="M88.0 360.0L99.3 360.0L110.5 359.7L121.8 360.0L133.0 360.0L144.3 360.0L155.6 360.0L166.8 343.7L178.1 345.7L189.4 331.4L200.6 338.1L211.9 340.9L223.1 349.3L234.4 360.0L245.7 350.5L256.9 350.4L268.2 350.4L279.4 360.0L290.7 360.0L302.0 360.0L313.2 360.0L324.5 360.0L335.8 360.0L347.0 359.4L358.3 360.0L369.5 360.0L380.8 360.0L392.1 360.0L403.3 327.5L414.6 326.0L425.8 307.4L437.1 337.3L448.4 360.0L459.6 360.0L470.9 360.0L482.2 356.5L493.4 360.0L504.7 360.0L515.9 360.0L527.2 360.0L538.5 340.8L549.7 360.0L561.0 360.0L572.2 360.0L583.5 360.0L594.8 360.0L606.0 338.5L617.3 360.0L628.6 360.0L639.8 360.0L651.1 360.0L662.3 320.1L673.6 295.1L684.9 307.5L696.1 258.7L707.4 214.2L718.6 162.3L729.9 97.2L741.2 60.9"/>
  <text class="dg-series-label" x="753.2" y="72.9">S+ (상승 탐지)</text>
  <text class="dg-series-label" x="832.0" y="365.0">S- (하락 탐지)</text>
  <circle class="dg-dot dg-dot--warn" cx="707.4" cy="214.2" r="5"/>
  <text class="dg-edge-label" x="753.4" y="174.2">t=55에서 변화 신호, 리셋</text>
</svg>
</div></div><figcaption>평균이 t=50에서 올라가는 수열에 two-sided CUSUM을 적용한 예시(k=0.5, h=5). S+가 임계값을 넘는 t=55에서 변화 신호가 나옵니다.</figcaption></figure>
<p>모형이 CUSUM 또는 PHT Chart의 UCB나 LCB에 변화 탐지 신호를 보내는 경우 bandit algorithm을 리셋하여 재학습을 진행합니다. 그 구조는 다음과 같습니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/qK5hD9n.png" width="400" alt="Change detection framework"/></center>
<br/>
<p>contextual bandit 상황에서의 non-stationary 환경을 고려한 알고리즘은 <a href="https://arxiv.org/abs/1409.8191">A Neural Networks Committee for the Contextual Bandit Problem (ICONIP 2014)</a>에서 소개된 <em>NeuralBandit</em>, <a href="https://doi.org/10.1145/3209978.3210051">Learning Contextual Bandits in a Non-stationary Environment (SIGIR 2018)</a>에서 소개된 <strong>dLinUCB</strong>가 있습니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/yzJa7WJ.png" width="600" alt="dLinUCB diagram"/></center>
<p><em>dLinUCB</em> 알고리즘은 여러 개의 slave bandit model을 동시에 업데이트하며,
reward의 신뢰구간을 바탕으로 낡은 모델을 폐기하고 재학습시키며 현재 최적의
model을 사용하는 독특한 방식을 사용하는데 slave model들이 LinUCB 한 종류여야 할
필요가 없기 때문에 여러 모델을 앙상블하여 사용할 수 있는 독특한 구조를
제안합니다.</p>
<h3 id="2-context-reward-relationship"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#2-context-reward-relationship">2. Context-Reward Relationship</a></h3>
<p>슬롯머신이야 reward가 관찰될 확률이 고정되어 있지만, 실제 서비스에서는 reward가 관찰될 확률이 주변 환경이나 사용자에 따라 변할 수 있습니다. 그렇다면, reward의 분포가 컨텍스트와 무관하게 Bernoulli 분포를 따른다고 가정하는 순간 치명적인 문제가 생깁니다.</p>
<p>그래서 기존 MAB 스키마에서 사용하고 있지 않던 정보인 context와 feedback을 결합해 reward의 분포를 학습하는데 반영하는 시도가 contextual bandits으로 이어졌습니다. LinTS와 LinUCB는 user(query), content의 context와 reward의 기댓값이 선형 관계를 가짐을 가정함으로써 feedback 뿐만 아니라 context 정보를 이용해 reward의 기댓값을 학습하는데 사용합니다. 두 알고리즘 모두 SGD 등의 방식을 사용하지 않고 Closed Form으로 ridge regression 파라미터를 업데이트합니다.</p>
<p>하지만, context와 reward의 기댓값은 선형관계라는 LinTS와 LinUCB의 기본 가정에 의문을 제기할 수도 있습니다. <a href="https://arxiv.org/abs/1409.8191">NNBandit (ICONIP 2014)</a> 은 Neural Net이 Universal approximator임을 이용하여 context와 reward의 기댓값 사이의 non-linear한 관계를 표현하는 모델입니다.</p>
<p>이외에도, GLM, Balanced linear model, Adaptive learning, Bootstrap 등의 해결책도 제시됐습니다. 모델이 복잡할 수록 context-reward 관계를 잘 잡아내는 것은 당연하나, 이 연산이 bandit들의 모든 arm에 대해 이루어져야 하기 때문에 조금이라도 연산량이 많아지면 실 실행시간이 가장 크게 증가할 여력이 있는 부분이고, linear form 이 아닌 경우 online learning 상황에서 closed form이 아닌 SGD, Mini-Batch 방식의 업데이트를 수행해야 하기 때문에 연산량과 가용자원을 고려해서 반드시 상황에 맞는 모델을 선택해야 합니다.</p>
<br/>
<h3 id="3-multi-play"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#3-multi-play">3. Multi-play</a></h3>
<p>Multi-armed bandit의 multi는 arm이 K개 있다는 것을 가정하지만, 한 번에 1개의 arm만 당길 수 있는 single play 상황을 가정합니다. 하지만, 실서비스에서는 문어가 K arms 들 중 한 개만 draw하는 것이 아닌 여러 개를 한꺼번에 draw를 해야하는 경우도 분명 존재합니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/90ybRnX.png" width="300" alt="Octopus illustration"/></center>
<center><del>L개를 동시에 당기려니 헷갈리나보다</del></center>
<br/>
<p>1개만 draw (impression)을 했을 경우 이 추천결과를 click한다면 1, 아니면 0의 reward 값을 주는데, 여러 개 (best L)을 한꺼번에 draw하는 경우에도 같은 방법으로 reward를 업데이트하는 것이 reasonable한지 의문을 가질 필요가 있습니다. 왜냐하면, <strong>각 arm에서 독립적으로 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8444em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em">θ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0315em">k</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> 의 확률로 reward가 관찰되는 것이 아닌, 최대 1번의 reward가 관찰된다는 제약이 있기 때문입니다.</strong></p>
<h4 id="31-simple-idea"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#31-simple-idea">3.1 Simple Idea</a></h4>
<p><a href="https://arxiv.org/abs/1506.00779"><em>Optimal Regret Analysis of Thompson Sampling in Stochastic MAB Problem with Multiple Plays (ICML 2015)</em></a> 에서 multi-play 상황에 대처하는 방식을 소개하고 있습니다. 해당 논문에서 제안된 MP-TS는 사실상 TS와 큰 차이가 없는데, multiple-play 상황에서 Beta draw를 실시하여 top-1이 아닌 스코어 순으로 top-L개를 draw하고, 1 또는 0의 reward를 업데이트 합니다.</p>
<p>동시에, MP-TS의 개선 버전으로 I(mproved)MP-TS를 제안하는데, suboptimal draw가 너무 많이 일어나니 L개의 slot 중 L-1개에는 기존 관찰 결과로부터 최선의 결과를 (exploitation), 1개에서만 기존 Thompson sampling의 Beta draw를 실시(Exploration)한다는 것입니다.</p>
<p>위 방법은, L개의 draw된 모든 arm들이 독립적인 Expectation reward을 가지고 있다는 가정하에는 합리적이지만, 현실은 그렇지 않습니다. L개의 노출 컨텐츠 중, n번째의 컨텐츠를 클릭한 유저 중에는 n+1번째부터의 노출 컨텐츠는 쳐다보지도 않은 사람이 있을 수도 있고, 3번째 노출 컨텐츠가 4번째 노출 컨텐츠 클릭에 영향을 줄 수도 있습니다. L개가 모두 마음에 들었어도 클릭은 최대 한 개뿐입니다.</p>
<h4 id="32-click-model"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#32-click-model">3.2 Click Model</a></h4>
<p>이러한 연구는 IR 분야에서 유저의 행동 패턴을 분석하는 Click Model과 MAB를 결합하여 해결하려는 시도로 이어졌는데, 대표적인 Click Model은 다음과 같습니다. (참고 : <a href="https://pdfs.semanticscholar.org/0b19/b37da5e438e6355418c726469f6a00473dc3.pdf">Click Models for Web Search (Authors&#x27; version)</a>)</p>
<blockquote>
<p>Notation</p>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">u</span></span></span></span> : document id</p>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em"></span><span class="mord mathnormal" style="margin-right:0.0359em">q</span></span></span></span> : user&#x27;s query</p>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">u</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : A document at rank r</p>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : r.v for Click document u</p>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : r.v for Examinate document u</p>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : r.v for Attractiveness of document u</p>
<p><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">S</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : r.v for Satisfaction level after click</p>
</blockquote>
<ul>
<li>Random Click Model (RCM)</li>
</ul>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord">1</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em"></span><span class="mord mathnormal">ρ</span></span></span></span></span>
<p>가장 기본이 되는 CTR 예측 모델로 모든 컨텐츠 u는 동일한 클릭 확률 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em"></span><span class="mord mathnormal">ρ</span></span></span></span> 를 가지고 있다고 가정하는 모델입니다. Classical MAB는 해당 모델을 기본 가정으로 하고 있습니다.</p>
<ul>
<li>Rank-Based CTR Model (RCTR)</li>
</ul>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em"></span><span class="mord">1</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em"></span><span class="mord"><span class="mord mathnormal">ρ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span></span>
<p>컨텐츠가 노출된 순서(rank)에 따라 CTR의 기댓값이 달라진다고 가정하는 모델입니다. RCM보다는 상당히 현실적인 베이스라인 모델인데, <a href="https://www.cs.cornell.edu/people/tj/publications/joachims_etal_05a.pdf"><em>Accurately interpreting clickthrough data as implicit feedback (SIGIR 2005)</em></a> 의 유저 행동 패턴 연구에서 관측된 데이터에서도 Rank 1 컨텐츠의 CTR이 0.45인 것에 비해 Rank 10 컨텐츠의 CTR은 0.05보다 낮은 CTR 값을 보여줬고, 실제 사내 서비스의 클릭 모델도 위치에 따라 위 실험결과와 유사한 CTR을 보여줍니다.</p>
<p>다만, 이 Click ratio를 그대로 RCTR 모형에 적용을 할 수는 없는 것이, 실제 슬롯별 CTR은 rank의 효과에만 영향을 받는 것이 아닌, MAB가 최선의 rank를 찾기 위한 노력의 효과에도 같이 영향을 받기 때문입니다.</p>
<ul>
<li>Position-based Model (PBM)</li>
</ul>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/ROk4zm6.png" alt="Position-based model"/></center>
<p>PBM의 CTR은 위와같은 Bayesian Network(참고 : <a href="http://norman3.github.io/prml/docs/chapter08/1">PRML-Bayesian Network</a>)로 나타낼 수 있습니다. 이 모형은 유저 q가 추천 컨텐츠를 확인(Examine)함과 동시에 해당 컨텐츠에 이끌려야 (Attracted) 클릭이 일어난다는 모형입니다. 해당 모형에서 검사 여부와 이끌림 여부는 독립이라고 가정하며, 검사 여부는 컨텐츠 u의 rank에 영향을 받고, 이끌림 여부는 유저 q와 컨텐츠 u에 동시에 영향을 받는다고 가정합니다. 따라서 위의 베이즈 네트워크는 아래와 같은 수식으로 표현됩니다.</p>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:4.2em;vertical-align:-1.85em"></span><span class="mtable"><span class="col-align-r"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.35em"><span style="top:-4.51em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span></span></span><span style="top:-3.01em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span></span></span><span style="top:-1.51em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.85em"><span></span></span></span></span></span><span class="col-align-l"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.35em"><span style="top:-4.51em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span></span></span><span style="top:-3.01em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0037em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">u</span><span class="mord mathnormal mtight" style="margin-right:0.0359em">q</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em"><span></span></span></span></span></span></span></span></span><span style="top:-1.51em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0556em">γ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0556em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1645em"><span style="top:-2.357em;margin-left:-0.0278em;margin-right:0.0714em"><span class="pstrut" style="height:2.5em"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mathnormal mtight">u</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.143em"><span></span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2501em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.85em"><span></span></span></span></span></span></span></span><span class="tag"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.35em"><span style="top:-4.35em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:-2.85em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:-1.35em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.85em"><span></span></span></span></span></span></span></span></span>
<ul>
<li>Cascade Model (CM)</li>
</ul>
<p><a href="https://www.microsoft.com/en-us/research/publication/an-experimental-comparison-of-click-position-bias-models/">An experimental comparison of click position-bias models (WSDM 2008)</a> 에서 소개된 가장 널리쓰이는 유저 행동패턴 가정으로, 유저가 컨텐츠를 top-to-bottom으로 원하는(relevant) 컨텐츠를 찾을 때까지 계속 스크롤한다는 것입니다. 이를 나타내는 베이즈 네트워크는 아래와 같습니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/TuVl14l.png" width="800" alt="Cascade model network"/></center>
<p>CM 모델에서는 1번째 추천 컨텐츠는 항상 검토(examined)되며, r번째 컨텐츠는 r-1번째 컨텐츠가 검토되고 동시에 클릭되지 않아야만 검토됩니다. 그 수식은 다음과 같습니다.</p>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:8.7em;vertical-align:-4.1em"></span><span class="mtable"><span class="col-align-r"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:4.6em"><span style="top:-6.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span></span></span><span style="top:-5.26em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span></span></span><span style="top:-3.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">1</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span></span></span><span style="top:-2.26em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1∣</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span><span class="mbin mtight">−</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">0</span><span class="mclose">)</span></span></span><span style="top:-0.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1∣</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span><span class="mbin mtight">−</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mclose">)</span></span></span><span style="top:0.74em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em">P</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1∣</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span><span class="mbin mtight">−</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span><span class="mbin mtight">−</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">0</span><span class="mclose">)</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:4.1em"><span></span></span></span></span></span><span class="col-align-l"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:4.6em"><span style="top:-6.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">↔</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord mathnormal">an</span><span class="mord mathnormal">d</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span></span></span><span style="top:-5.26em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-left:-0.0037em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight"><span class="mord mathnormal mtight">u</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1645em"><span style="top:-2.357em;margin-left:0em;margin-right:0.0714em"><span class="pstrut" style="height:2.5em"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em">r</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.143em"><span></span></span></span></span></span></span><span class="mord mathnormal mtight" style="margin-right:0.0359em">q</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em"><span></span></span></span></span></span></span></span></span><span style="top:-3.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span></span></span><span style="top:-2.26em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">0</span></span></span><span style="top:-0.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">0</span></span></span><span style="top:0.74em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord">1</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:4.1em"><span></span></span></span></span></span></span></span><span class="tag"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:4.6em"><span style="top:-6.6em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:-5.1em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:-3.6em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:-2.1em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:-0.6em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:0.9em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:4.1em"><span></span></span></span></span></span></span></span></span>
<p>이외에도 DCM 등 다양한 클릭 모델들이 존재합니다.</p>
<p>이러한 클릭 모델은 슬롯별 CTR 예측에 사용되는데, 해당 모델들이 Eye Tracking 등 HCI 기반의 연구와 함께 이루어져 실제 유저의 행동패턴을 잘 반영한다는 특성때문에 클릭 모델과 MAB를 결합하려는 시도가 <a href="https://arxiv.org/pdf/1502.02763.pdf"><em>Cascading Bandits: Learning to Rank in the Cascade Model (ICML 2015)</em></a> 에서 이루어지게 됩니다. 이는 Click Model 중 가장 널리쓰이는 Cascade model과 MAB를 결합한 것이며, 논문에 CascadeUCB1, CascadeKL-UCB가 소개되어 있습니다.</p>
<p>또한 위에서 언급한 IMP-TS를 소개한 논문 Appendix에 Cascade 상황에서 생기는 bias를 해결하기 위해 MP-TS를 변형한 BC-MP-TS (Bias-Corrected MP TS)도 소개합니다.</p>
<p>해당 모델에 contextual 버전을 추가한 CascadeLinTS, CascadeLinUCB는 <a href="http://www.auai.org/uai2016/proceedings/papers/96.pdf">Cascading Bandits for Large-Scale Recommendation Problems (UAI 2016)</a>에 소개되었습니다.</p>
<h4 id="33-combinatorial-bandit"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#33-combinatorial-bandit">3.3 Combinatorial Bandit</a></h4>
<p><strong>Combinatorial bandit</strong> 은 클릭 모델을 이용하는 것보다 더 폭 넓은 관점의 bandit 알고리즘입니다.</p>
<p>이 밴딧 알고리즘의 활발한 후속 연구를 이끈 논문은 <a href="https://pdfs.semanticscholar.org/e79d/695182260feb634201a5dac71ba7bde049e8.pdf">Combinatorial MAB: General Framework, Results and Applications (ICML 2013)</a> 입니다. Cascade Bandit은 Vertical 방식으로 추천결과가 나열되면서, 같은 추천 결과에서 여러 번의 클릭이 일어날 수 있는 IR 분야에 적합한 밴딧 알고리즘이라고 한다면 Combinatorial 방식은 Super arm의 개념을 도입해 조금 더 범용적이고, 추천시스템에 적합한 multiple-draw 상황의 전략을 도출해냅니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/6oKt79U.png" alt="Combinatorial bandit diagram"/></center>
<p>여러 개의 arm을 draw해야 하는 상황에서, arm끼리 상관관계도 있어 개별 arm의 절대적 가치를 추정하는 것의 정당성이 흔들리니, 함께 draw 하는 arm의 <strong>조합</strong>을 super arm으로 정의하는 것입니다.</p>
<p>가장 단순하게 생각한다면, 존재할 수 있는 모든 super arm을 각각의 arm처럼 다룬다면 기존 Classical 1-draw bandit 방식으로 문제를 풀 수 있습니다. 하지만, arm이 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">m</span></span></span></span>개 존재한다면 존재 가능한 super arm은 최대 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6644em"></span><span class="mord"><span class="mord">2</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.6644em"><span style="top:-3.063em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">m</span></span></span></span></span></span></span></span></span></span></span> 개, L개를 draw하는 상황에서는 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em"></span><span class="mord"><span></span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em"><span style="top:-2.55em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">m</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">C</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em"><span style="top:-2.55em;margin-left:-0.0715em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">L</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span>개로 기존 arm의 개수보다 exponential 하게 증가하기 때문에 이러한 방식으로 문제를 풀어나갈 수는 없습니다.</p>
<p>그렇기 때문에, 위 논문에서는 적절한 arm들의 조합을 구성하기 위한 <strong>oracle</strong> 함수를 만들 것을 제안합니다. oracle 함수는 논문마다 그 방식이 다르지만, 지금까지 관찰된 arm들의 draw와 reward 데이터를 바탕으로 개별 arm (underlying arm)들의 Expectation vector를 구성해 이 벡터를 바탕으로 최적의 arm의 조합인 super arm을 구성해주는 함수입니다. 어떻게 매핑을 하냐가 가장 중요한 부분인데, 위 논문에서는 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em"></span><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em"></span><span class="mord mathnormal" style="margin-right:0.0528em">β</span></span></span></span>-Approximation 을 통해 super arm을 구성하고, <a href="https://ojs.aaai.org/index.php/AAAI/article/view/10939">Efficient Ordered Combinatorial Semi-Bandits for Whole-Page Recommendation (AAAI 2017)</a>에서는 Oracle 대신 Maximization 문제를 정의하고, 이를 integer programming으로 푸는 대신 연산 효율을 위해 다음과 같은 구조의 네트워크로 계산합니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/n5g5ZN2.png" alt="Ordered combinatorial semi-bandits network"/></center>
<p>Combinatorial Bandit을 늦게 공부하기 시작해 더 정리하지 못한 점이 아쉬운데, <a href="https://papers.nips.cc/paper/7586-contextual-combinatorial-multi-armed-bandits-with-volatile-arms-and-submodular-reward.pdf">Contextual Combinatorial MAB with Volatile Arms and Submodular Reward (NIPS 2018)</a>는 사용 가능한 arm의 풀이 지속적으로 변하는 (<strong>volatile arms</strong>) 상황을 고려한 combinatorial bandit 알고리즘인 <em>CC-MAB</em> 를 소개합니다.</p>
<h2 id="4-batched-update"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#4-batched-update">4. Batched Update</a></h2>
<br/>
<p><a href="https://research.yahoo.com/publications/8988/batched-multi-armed-bandit-approach-news-headline-testing">A Batched Multi-Armed Bandit Approach to News Headline Testing (IEEE BigData 2018)</a> 야후 리서치에서 이루어진 연구입니다. MAB는 arm을 draw하는 것과 reward를 업데이트 하는 것이 이벤트 단위로 순차적으로 바로 일어난다고 가정하고 있는데, 실제 서비스에서는 트래픽이 매우 빠른 속도로 들어와 batch 단위로 일어날 수 밖에 없는 상황을 고려한 bTS (batched Thompson Sampling) 알고리즘을 제안합니다.</p>
<p>해당 논문에서는 베타분포의 파라미터를 업데이트 하는 방법을 summation과 normalization을 제안했는데, normalization 업데이트 방식은 배치 단위로 트래픽이 쏠림으로서 발생하는 부작용을 해결하고자 제안된 방법입니다.</p>
<blockquote>
<p><strong>[Summation]</strong>
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : 배치 t의 click, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal">u</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : 배치 t의 unclick</p>
</blockquote>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:2.7em;vertical-align:-1.1em"></span><span class="mtable"><span class="col-align-r"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.6em"><span style="top:-3.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0037em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span><span class="mbin mtight">+</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span></span></span><span style="top:-2.26em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0528em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span><span class="mbin mtight">+</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.1em"><span></span></span></span></span></span><span class="col-align-l"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.6em"><span style="top:-3.76em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.0037em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span><span style="top:-2.26em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.0528em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord"><span class="mord mathnormal">u</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.1em"><span></span></span></span></span></span></span></span><span class="tag"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.6em"><span style="top:-3.6em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span><span style="top:-2.1em"><span class="pstrut" style="height:2.84em"></span><span class="eqn-num"></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.1em"><span></span></span></span></span></span></span></span></span>
<blockquote>
<p><strong>[Normalization]</strong>
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8333em;vertical-align:-0.15em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.109em">M</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.109em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span> : t 배치에서 해당 밴딧의 모든 arm들의 impression 수, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em"></span><span class="mord mathnormal" style="margin-right:0.0715em">K</span></span></span></span> : arm의 수</p>
</blockquote>
<span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:4.8964em;vertical-align:-2.1982em"></span><span class="mtable"><span class="col-align-r"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.6982em"><span style="top:-4.7879em"><span class="pstrut" style="height:3.45em"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0037em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span><span class="mbin mtight">+</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span></span></span><span style="top:-2.2018em"><span class="pstrut" style="height:3.45em"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3011em"><span style="top:-2.55em;margin-left:-0.0528em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span><span class="mbin mtight">+</span><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2083em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.1982em"><span></span></span></span></span></span><span class="col-align-l"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.6982em"><span style="top:-4.7879em"><span class="pstrut" style="height:3.45em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.0037em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.3603em"><span style="top:-2.314em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">K</span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.677em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.109em">M</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.109em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.686em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.1076em"><span style="top:-2.314em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord"><span class="mord mathnormal">u</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.677em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.836em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span><span style="top:-2.2018em"><span class="pstrut" style="height:3.45em"></span><span class="mord"><span class="mord"></span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.0528em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">t</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.3603em"><span style="top:-2.314em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0715em">K</span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.677em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.109em">M</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:-0.109em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.686em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mspace" style="margin-right:0.1667em"></span><span class="minner"><span class="mopen delimcenter" style="top:0em"><span class="delimsizing size3">(</span></span><span class="mord">1</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.1076em"><span style="top:-2.314em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em"></span><span class="mord"><span class="mord mathnormal">u</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span><span style="top:-3.23em"><span class="pstrut" style="height:3em"></span><span class="frac-line" style="border-bottom-width:0.04em"></span></span><span style="top:-3.677em"><span class="pstrut" style="height:3em"></span><span class="mord"><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em"><span class="pstrut" style="height:2.7em"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.836em"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mclose delimcenter" style="top:0em"><span class="delimsizing size3">)</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.1982em"><span></span></span></span></span></span></span></span><span class="tag"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.6982em"><span style="top:-4.7879em"><span class="pstrut" style="height:3.45em"></span><span class="eqn-num"></span></span><span style="top:-2.2018em"><span class="pstrut" style="height:3.45em"></span><span class="eqn-num"></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:2.1982em"><span></span></span></span></span></span></span></span></span>
<p>그러나, 해당 논문에서의 실험 결과는 summation 방식이 훨씬 좋은 성능을 보였는데 저자들은 normalization update를 하면서 발생하는 noise의 효과가 side-effect를 처리하는 효과보다 큰 것 같다고 분석했습니다.</p>
<h2 id="5-arm의-가치는-절대적인가-oltr"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#5-arm의-가치는-절대적인가-oltr">5. Arm의 가치는 절대적인가? (OLTR)</a></h2>
<p>이번엔 조금 큰 틀에서의 문제 제기입니다. Top L from N 문제에서 MAB의 기본 가정은 arm마다 절대적인 가치가 있다는 것입니다. (reward의 기댓값), 해당 가정을 이용하지 않는 방식으로 Online Learning to Rank (OLTR) 문제를 풀어나가는 방법 또한 간략히 조사해보았습니다.</p>
<p>Learning to Rank는 IR 분야에 뿌리를 두고 있습니다. 추천 시스템에서는 MAB 방식의 online learning 방식이 널리 사용되고는 있지만, IR 분야에서는 다른 방식과 MAB 방식의 learning to rank를 다음의 논문에서 비교한 적이 있었습니다. <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/www2015-poster-online-learning.pdf">Online Learning to Rank : Absolute vs Relative (WWW 2015)</a></p>
<p>Absolute 접근법은 일반적인 contextual bandit을 의미하는 것으로, document context, query, user context로부터 정보를 종합해 context features를 구성하고 이를 이용해 유저 피드백 데이터의 절대적 가치 (MAB 방식에서는 reward의 기댓값인 CTR)을 추정해 나가는 접근법입니다.</p>
<p>하지만, Relative 접근법은 특정 컨텐츠의 절대적 가치의 존재 자체에 의문을 제기합니다. 여러 개의 추천 결과를 노출하는 상황에서 유저 피드백 데이터로부터 얻는 정보는 해당 컨텐츠의 절대적 가치라기 보다는 같이 노출된 컨텐츠들 간의 상대적 우위(relative preference)일 뿐이라고 생각하는 것입니다. 이러한 상대적 접근법에 해당하는 모델들은 유저 피드백을 Stochastic Gradient Descent 방식으로 업데이트 함으로써 랭킹을 학습합니다.</p>
<p>위 논문에서는 LinUCB를 변형한 Lin-<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal">ϵ</span></span></span></span>과 Candidate Preselection(CPS) Learning to Rank 알고리즘을 비교했는데, 적은 숫자의 유저 쿼리와 문서(arm)이 있는 경우, 그리고 피드백에 노이즈가 없는 경우에는 MAB 방법론이 더 좋은 성능을 보이나, 유저 피드백에 노이즈가 많은 경우나 pool의 범위가 넓은 경우에는 Relative 접근법 (LTR)이 더 좋은 성능을 보인다고 결론지었습니다.</p>
<p>이런 Relative 접근 방법을 Bandit 알고리즘과 결합하려는 시도도 있었는데 이 분야의 Bandit 알고리즘은 Dueling Bandit이라 합니다. <a href="https://proceedings.mlr.press/v32/zoghi14.pdf">Relative UCB for the K-Armed Dueling Bandit Problem (ICML 2014)</a>에서는 Feedback으로 얻은 정보를 한 쌍의 arm으로 부터의 상대적 선호를 나타낸다는 relative 해석법을 결합한 RUCB 알고리즘을 소개하고 있고, <a href="https://homepages.dcc.ufmg.br/~rodrygo/wp-content/papercite-data/pdf/pereira2019recsys.pdf">Online Learning to Rank for Sequential Music Recommendation (RecSys 2019)</a>에서는 CDB 알고리즘을 소개하고 있습니다.</p>
<p>LTR 분야가 너무 폭 넓은 분야라 더 자세히 조사하지는 못했는데, SIGIR 2016에서 튜토리얼로 진행된 <a href="https://staff.fnwi.uva.nl/m.derijke/wp-content/uploads/sigir2016-tutorial.pdf">Online LTR for Information Retrieval</a> 자료가 IR 분야에서의 LTR, 특히 Online LTR에 대해 상세하게 다루고 있으니 읽어보시면 좋을 것 같습니다. 기회가 된다면 더 읽어보고 정리할 예정입니다.</p>
<center><img src="https://jerrylog.dev/assets/images/multi-armed-bandit/KyXXOL2.png" alt="Online learning to rank summary diagram"/></center>
<hr/>
<h2 id="우리는-정말-無에서-시작하는가"><a href="https://jerrylog.dev/ko/blog/multi-armed-bandit-history#우리는-정말-無에서-시작하는가">우리는 정말 無에서 시작하는가?</a></h2>
<p>Exploration, Exploitation 딜레마의 근본적인 가정은 우리가 실제 노출결과가 어찌될지 모르기 때문에 일단 노출시켜보면서 탐색하고 알아낸다는 것입니다. 그렇다면, 우리는 정말 노출결과가 어찌될지 전혀 모르는 상태로 시작하는 걸까요?</p>
<p><img src="https://jerrylog.dev/assets/images/reco_framework.png" alt="Recommendation framework diagram"/></p>
<p><em>(출처: <a href="https://static.googleusercontent.com/media/research.google.com/ko//pubs/archive/45530.pdf">Deep Neural networks for YouTube Recommendations (Recsys 16)</a>)</em></p>
<p>일반적인 추천시스템 구조에서 Candidate Generation과 Ranking 사이에는 Scoring, Ensemble 단계가 있습니다 (이를 품질 평가 모델이라 부르기도 하는 것 같습니다). 해당 scoring이 online 상황에서의 reward가 상관계수가 높다면, 그냥 해당 score를 초기값처럼 사용할 수도 있습니다. 요즘은 쓸 수 있는 feature도 잘 정제되어 있고, 모델이 계속 발전함에 따라 (deep! deeeeeep!) 조금 더 정밀한 사전 CTR Prediction이 가능하기 때문에, 오프라인 모델의 CTR 예측치를 MAB의 초기값으로 활용하는 방식으로 (ex. deep model의 컨텐츠 A에 대한 CTR 예측값이 0.1인 경우, Thompson Sampling의 컨텐츠 A에 대한 파라미터 초기값을 <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em"></span><span class="mord mathnormal" style="margin-right:0.0037em">α</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:0.6444em"></span><span class="mord">1</span></span></span></span>, <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em"></span><span class="mord mathnormal" style="margin-right:0.0528em">β</span><span class="mspace" style="margin-right:0.2778em"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em"></span></span><span class="base"><span class="strut" style="height:0.6444em"></span><span class="mord">9</span></span></span></span> 로 세팅한다.) MAB의 cold start 문제를 줄일 수도 있습니다.</p>]]></content:encoded>
      <pubDate>Sat, 19 Jun 2021 00:00:00 GMT</pubDate>
      <category><![CDATA[Learning to Rank]]></category><category><![CDATA[MAB]]></category>
    </item>
  </channel>
</rss>