<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://groou.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://groou.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-06-25T21:17:09+09:00</updated><id>https://groou.com/feed.xml</id><title type="html">Justin Kim</title><subtitle>Justin Kim의 개인 블로그. 뉴로-심볼릭 AI, 온톨로지, Datalog, RDF, GStreamer 등 연구 노트와 기술 에세이.</subtitle><author><name>Justin Kim</name><email>justin.joy.9to5@gmail.com</email></author><entry><title type="html">RDF Reification을 다시 보기: RDF 1.2와 Wirelog Compound Term</title><link href="https://groou.com/ontology/2026/06/23/rdf12-reification-wirelog-compound/" rel="alternate" type="text/html" title="RDF Reification을 다시 보기: RDF 1.2와 Wirelog Compound Term" /><published>2026-06-23T09:00:00+09:00</published><updated>2026-06-23T09:00:00+09:00</updated><id>https://groou.com/ontology/2026/06/23/rdf12-reification-wirelog-compound</id><content type="html" xml:base="https://groou.com/ontology/2026/06/23/rdf12-reification-wirelog-compound/"><![CDATA[<p>지난 <a href="/ontology/2026/02/24/rdf-reification/">RDF Reification 글</a>에서 김철수의 이직 기록을 예제로 들었습니다.</p>

<blockquote>
  <p>김철수는 2020년부터 2022년까지 삼성전자에서 근무했고, 2023년부터 현재까지 네이버에서 근무하고 있다.</p>
</blockquote>

<p>기본 RDF 트리플로는 이렇게 됩니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">ex</span><span class="p">:</span><span class="err">김철수</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="nt">worksAt</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="err">삼성전자</span><span class="w"> </span><span class="p">.</span><span class="w">
</span><span class="nn">ex</span><span class="p">:</span><span class="err">김철수</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="nt">worksAt</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="err">네이버</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>이 두 줄만 보면 너무 많은 것이 빠져 있습니다. 삼성전자에는 언제부터 언제까지 다녔는지, 네이버에는 지금도 다니는지, 이 정보가 어디에서 온 것인지 알 수 없습니다.</p>

<p>그래서 당시에는 RDF 1.1의 전통적인 Reification을 사용했습니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">ex</span><span class="p">:</span><span class="nt">stmt1</span><span class="w"> </span><span class="kt">a</span><span class="w"> </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">Statement</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">subject</span><span class="w">   </span><span class="nn">ex</span><span class="p">:</span><span class="err">김철수</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">predicate</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="nt">worksAt</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">object</span><span class="w">    </span><span class="nn">ex</span><span class="p">:</span><span class="err">삼성전자</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">startDate</span><span class="w">  </span><span class="s2">"2020-01-01"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">endDate</span><span class="w">    </span><span class="s2">"2022-12-31"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">source</span><span class="w">     </span><span class="nn">ex</span><span class="p">:</span><span class="nt">HR_Database</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>의미는 분명합니다. <code class="language-plaintext highlighter-rouge">ex:stmt1</code>은 “김철수가 삼성전자에서 근무한다”라는 말 자체를 가리키는 자원입니다. 이제 그 자원에 시작일, 종료일, 출처를 붙일 수 있습니다.</p>

<p>다만 문법은 꽤 무겁습니다. 원래 한 줄이면 충분했던 사실 하나가 <code class="language-plaintext highlighter-rouge">rdf:subject</code>, <code class="language-plaintext highlighter-rouge">rdf:predicate</code>, <code class="language-plaintext highlighter-rouge">rdf:object</code>를 포함한 여러 줄로 늘어납니다. “트리플에 대해 말하고 싶다”는 요구는 자연스러운데, 표현은 그렇지 않았습니다.</p>

<p>이번 글에서는 그 지점을 다시 보려 합니다. RDF 1.2에서는 표현이 어떻게 바뀌었는지 보고, 같은 생각을 <code class="language-plaintext highlighter-rouge">wirelog</code>의 compound term으로 옮기면 Datalog 코드가 어떻게 달라지는지도 보겠습니다.</p>

<h2 id="rdf-12-트리플이-term이-된다">RDF 1.2: 트리플이 term이 된다</h2>

<p>RDF 1.2<a class="citation" href="#rdf12concepts">[1]</a>에는 <strong>triple term</strong>이 들어옵니다. 이름 그대로 RDF 트리플을 하나의 term처럼 다루는 방식입니다. 트리플을 다른 트리플의 object 자리에 놓을 수 있습니다.</p>

<p>RDF 1.1의 reification은 트리플을 네 조각으로 분해했습니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">ex</span><span class="p">:</span><span class="nt">stmt1</span><span class="w"> </span><span class="kt">a</span><span class="w"> </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">Statement</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">subject</span><span class="w">   </span><span class="nn">ex</span><span class="p">:</span><span class="err">김철수</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">predicate</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="nt">worksAt</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">object</span><span class="w">    </span><span class="nn">ex</span><span class="p">:</span><span class="err">삼성전자</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>RDF 1.2에서는 같은 내용을 이렇게 씁니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">PREFIX</span><span class="w"> </span><span class="nn">rdf</span><span class="p">:</span><span class="w"> </span><span class="nl">&lt;http://www.w3.org/1999/02/22-rdf-syntax-ns#&gt;</span><span class="w">
</span><span class="kd">PREFIX</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="w">  </span><span class="nl">&lt;http://example.org/&gt;</span><span class="w">

</span><span class="nn">ex</span><span class="p">:</span><span class="nt">stmt1</span><span class="w"> </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">reifies</span><span class="w"> </span><span class="nl">&lt;&lt;( ex:김철수 ex:worksAt ex:삼성전자 )&gt;</span><span class="err">&gt;</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">&lt;&lt;( ... )&gt;&gt;</code> 안에 들어간 것이 triple term입니다. <code class="language-plaintext highlighter-rouge">ex:stmt1</code>은 이 트리플을 가리키는 reifier입니다. <code class="language-plaintext highlighter-rouge">rdf:reifies</code>는 둘을 이어 줍니다. RDF 1.2 Concepts 문서도 reifying triple을 이렇게 설명합니다. predicate는 <code class="language-plaintext highlighter-rouge">rdf:reifies</code>이고, object는 triple term인 트리플입니다.</p>

<p>이제 시간과 출처는 reifier에 붙입니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">ex</span><span class="p">:</span><span class="nt">stmt1</span><span class="w"> </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">reifies</span><span class="w"> </span><span class="nl">&lt;&lt;( ex:김철수 ex:worksAt ex:삼성전자 )&gt;</span><span class="err">&gt;</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">startDate</span><span class="w"> </span><span class="s2">"2020-01-01"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">endDate</span><span class="w">   </span><span class="s2">"2022-12-31"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">source</span><span class="w">    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">HR_Database</span><span class="w"> </span><span class="p">.</span><span class="w">

</span><span class="nn">ex</span><span class="p">:</span><span class="nt">stmt2</span><span class="w"> </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">reifies</span><span class="w"> </span><span class="nl">&lt;&lt;( ex:김철수 ex:worksAt ex:네이버 )&gt;</span><span class="err">&gt;</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">startDate</span><span class="w"> </span><span class="s2">"2023-03-01"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">source</span><span class="w">    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">HR_Database</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>겉으로는 문법 하나가 추가된 정도로 보입니다. 하지만 써보면 차이가 큽니다. 더 이상 트리플을 <code class="language-plaintext highlighter-rouge">rdf:subject</code>, <code class="language-plaintext highlighter-rouge">rdf:predicate</code>, <code class="language-plaintext highlighter-rouge">rdf:object</code>로 풀어헤치지 않아도 됩니다. 원래 트리플 모양을 거의 그대로 들고 갑니다.</p>

<p>Turtle 1.2<a class="citation" href="#rdf12turtle">[2]</a>에는 annotation syntax도 있습니다. 원래 트리플을 쓰고, 그 옆에 바로 메타데이터를 붙입니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">ex</span><span class="p">:</span><span class="err">김철수</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="nt">worksAt</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="err">삼성전자</span><span class="w"> </span><span class="p">{</span><span class="err">|</span><span class="w"> 
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">startDate</span><span class="w"> </span><span class="s2">"2020-01-01"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">endDate</span><span class="w">   </span><span class="s2">"2022-12-31"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">source</span><span class="w">    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">HR_Database</span><span class="w">
</span><span class="err">|</span><span class="p">}</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>이쪽이 사람이 읽기에는 훨씬 편합니다. 기본 사실과 그 사실의 맥락이 붙어 있기 때문입니다. 다만 모델 차원에서는 여전히 reifier가 있고, 그 reifier가 triple term을 <code class="language-plaintext highlighter-rouge">rdf:reifies</code>로 가리키며, 메타데이터는 reifier에 붙는 구조입니다.</p>

<p>비교하면 이렇습니다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>RDF 1.1 Reification</th>
      <th>RDF 1.2 Triple Term</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>트리플 참조 방식</td>
      <td><code class="language-plaintext highlighter-rouge">rdf:subject</code>, <code class="language-plaintext highlighter-rouge">rdf:predicate</code>, <code class="language-plaintext highlighter-rouge">rdf:object</code>로 분해</td>
      <td>트리플 자체를 term으로 사용</td>
    </tr>
    <tr>
      <td>reifier 연결</td>
      <td><code class="language-plaintext highlighter-rouge">rdf:Statement</code> 관용구</td>
      <td><code class="language-plaintext highlighter-rouge">rdf:reifies</code></td>
    </tr>
    <tr>
      <td>문법 크기</td>
      <td>장황함</td>
      <td>짧음</td>
    </tr>
    <tr>
      <td>메타데이터 위치</td>
      <td>statement 자원</td>
      <td>reifier</td>
    </tr>
  </tbody>
</table>

<p>여기까지가 RDF 쪽 이야기입니다. 이제 같은 문제를 Datalog 쪽에서 보겠습니다.</p>

<h2 id="datalog로-옮기면-무엇이-불편한가">Datalog로 옮기면 무엇이 불편한가</h2>

<p>RDF reification을 단순한 relation으로 옮기면 보통 이렇게 됩니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">reifies</span><span class="p">(</span><span class="ss">id</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">s</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">p</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">o</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">context</span><span class="p">(</span><span class="ss">id</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">start</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">end</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">source</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
</code></pre></div></div>

<p>Datalog의 사실(fact)은 이렇게 넣습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">reifies</span><span class="p">(</span><span class="s2">"stmt1"</span><span class="p">,</span> <span class="s2">"김철수"</span><span class="p">,</span> <span class="s2">"worksAt"</span><span class="p">,</span> <span class="s2">"삼성전자"</span><span class="p">).</span>
<span class="ss">context</span><span class="p">(</span><span class="s2">"stmt1"</span><span class="p">,</span> <span class="m">20200101</span><span class="p">,</span> <span class="m">20221231</span><span class="p">,</span> <span class="s2">"HR_Database"</span><span class="p">).</span>
</code></pre></div></div>

<p>쿼리도 그렇게 어렵지는 않습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">employment_at</span><span class="p">(</span><span class="ss">person</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">company</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">start</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">end</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">source</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>

<span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)</span> <span class="p">:-</span>
    <span class="ss">reifies</span><span class="p">(</span><span class="nv">ID</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="s2">"worksAt"</span><span class="p">,</span> <span class="nv">C</span><span class="p">),</span>
    <span class="ss">context</span><span class="p">(</span><span class="nv">ID</span><span class="p">,</span> <span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">).</span>
</code></pre></div></div>

<p>하지만 이 방식은 RDF 1.1 reification과 비슷한 맛이 납니다. 트리플 자체가 하나의 값이 아니라 네 개의 컬럼으로 펼쳐져 있습니다. 메타데이터도 별도 relation에 있으니 <code class="language-plaintext highlighter-rouge">ID</code>로 다시 조인해야 합니다.</p>

<p>작은 예제에서는 별 문제가 아닙니다. 그런데 “트리플에 대한 진술”이 계속 나오면 <code class="language-plaintext highlighter-rouge">ID</code>를 따라다니는 relation이 늘어납니다. 제가 쓰고 싶은 모양은 사실 이쪽에 가깝습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>statement(
  stmt(김철수, worksAt, 삼성전자),
  ctx(20200101, 20221231, HR_Database)
)
</code></pre></div></div>

<p>원래 트리플은 <code class="language-plaintext highlighter-rouge">stmt(...)</code>라는 값으로 두고 싶습니다. 시간과 출처는 <code class="language-plaintext highlighter-rouge">ctx(...)</code>라는 값으로 두고 싶습니다. RDF 1.2의 triple term과 꽤 비슷한 감각입니다.</p>

<h2 id="wirelog-compound-term으로-표현하기">Wirelog compound term으로 표현하기</h2>

<p><code class="language-plaintext highlighter-rouge">wirelog</code>의 compound term은 여러 값을 하나로 묶습니다. 예를 들어 <code class="language-plaintext highlighter-rouge">stmt(P, Pred, C)</code>는 이름이 <code class="language-plaintext highlighter-rouge">stmt</code>이고 인자가 세 개인 term입니다. <code class="language-plaintext highlighter-rouge">ctx(Start, End, Source)</code>도 같은 방식입니다.</p>

<p>이번 예제에서는 <code class="language-plaintext highlighter-rouge">employment</code> relation 하나에 reifier id, triple term, context term을 같이 넣겠습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">employment</span><span class="p">(</span><span class="ss">id</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">st</span><span class="o">:</span> <span class="ss">stmt</span><span class="o">/</span><span class="m">3</span> <span class="ss">side</span><span class="p">,</span> <span class="ss">ctx</span><span class="o">:</span> <span class="ss">ctx</span><span class="o">/</span><span class="m">3</span> <span class="ss">side</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">st</code> 컬럼은 <code class="language-plaintext highlighter-rouge">stmt/3</code> compound입니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stmt(person, predicate, company)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ctx</code> 컬럼은 <code class="language-plaintext highlighter-rouge">ctx/3</code> compound입니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ctx(start_date, end_date, source)
</code></pre></div></div>

<p>그러면 “김철수의 근무 이력”을 꺼내는 규칙은 이렇게 쓸 수 있습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">employment_at</span><span class="p">(</span><span class="ss">person</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">company</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">start</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">end</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">source</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>

<span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)</span> <span class="p">:-</span>
    <span class="ss">employment</span><span class="p">(</span><span class="nv">_</span><span class="p">,</span> <span class="ss">stmt</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="s2">"worksAt"</span><span class="p">,</span> <span class="nv">C</span><span class="p">),</span> <span class="ss">ctx</span><span class="p">(</span><span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)).</span>
</code></pre></div></div>

<p>여기서 볼 부분은 두 번째 줄입니다.</p>

<p><code class="language-plaintext highlighter-rouge">employment</code>의 두 번째 컬럼을 <code class="language-plaintext highlighter-rouge">stmt(P, "worksAt", C)</code> 패턴으로 구조분해하고, 세 번째 컬럼을 <code class="language-plaintext highlighter-rouge">ctx(Start, End, Source)</code> 패턴으로 구조분해합니다. 별도의 <code class="language-plaintext highlighter-rouge">reifies</code> relation도, <code class="language-plaintext highlighter-rouge">context</code> relation도, <code class="language-plaintext highlighter-rouge">ID</code> 조인도 없습니다.</p>

<p>현재 재직 회사를 묻는 규칙은 더 짧습니다. 여기서는 종료일이 <code class="language-plaintext highlighter-rouge">0</code>이면 현재 재직 중이라고 두겠습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">current_company</span><span class="p">(</span><span class="ss">person</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">company</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>

<span class="ss">current_company</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">_</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="nv">_</span><span class="p">).</span>
</code></pre></div></div>

<p>출처별 근무 기록도 같은 방식으로 꺼냅니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">from_source</span><span class="p">(</span><span class="ss">person</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">company</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">source</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>

<span class="ss">from_source</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">_</span><span class="p">,</span> <span class="nv">_</span><span class="p">,</span> <span class="nv">Source</span><span class="p">).</span>
</code></pre></div></div>

<p>전체 Datalog 프로그램은 다음과 같습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">employment</span><span class="p">(</span><span class="ss">id</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">st</span><span class="o">:</span> <span class="ss">stmt</span><span class="o">/</span><span class="m">3</span> <span class="ss">side</span><span class="p">,</span> <span class="ss">ctx</span><span class="o">:</span> <span class="ss">ctx</span><span class="o">/</span><span class="m">3</span> <span class="ss">side</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">employment_at</span><span class="p">(</span><span class="ss">person</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">company</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">start</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">end</span><span class="o">:</span> <span class="ss">int64</span><span class="p">,</span> <span class="ss">source</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">current_company</span><span class="p">(</span><span class="ss">person</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">company</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">from_source</span><span class="p">(</span><span class="ss">person</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">company</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">source</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>

<span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)</span> <span class="p">:-</span>
    <span class="ss">employment</span><span class="p">(</span><span class="nv">_</span><span class="p">,</span> <span class="ss">stmt</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="s2">"worksAt"</span><span class="p">,</span> <span class="nv">C</span><span class="p">),</span> <span class="ss">ctx</span><span class="p">(</span><span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)).</span>

<span class="ss">current_company</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">_</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="nv">_</span><span class="p">).</span>
<span class="ss">from_source</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">_</span><span class="p">,</span> <span class="nv">_</span><span class="p">,</span> <span class="nv">Source</span><span class="p">).</span>
</code></pre></div></div>

<p>RDF 1.2에서는 triple term 덕분에 reification이 덜 장황해졌습니다. compound term을 쓰면 Datalog relation 안에서도 비슷한 식으로 구조를 유지할 수 있습니다.</p>

<h2 id="pyrewire로-실행해보기">pyrewire로 실행해보기</h2>

<p>아래 코드는 로컬에서 <code class="language-plaintext highlighter-rouge">pyrewire</code>로 실행해 확인한 코드입니다. <code class="language-plaintext highlighter-rouge">stmt</code>와 <code class="language-plaintext highlighter-rouge">ctx</code>는 <code class="language-plaintext highlighter-rouge">side</code> compound로 선언했습니다. 그래서 Python 쪽에서는 <code class="language-plaintext highlighter-rouge">make_compound()</code>로 compound 값을 만들고, <code class="language-plaintext highlighter-rouge">employment</code> relation에는 그 handle을 넣습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">pyrewire</span> <span class="kn">import</span> <span class="n">EasySession</span><span class="p">,</span> <span class="n">ColumnType</span><span class="p">,</span> <span class="n">CompoundArg</span>

<span class="n">SRC</span> <span class="o">=</span> <span class="sh">"""</span><span class="s">
.decl employment(id: symbol, st: stmt/3 side, ctx: ctx/3 side)
.decl employment_at(person: symbol, company: symbol, start: int64, end: int64, source: symbol)
.decl current_company(person: symbol, company: symbol)
.decl from_source(person: symbol, company: symbol, source: symbol)

employment_at(P, C, Start, End, Source) :-
    employment(_, stmt(P, </span><span class="sh">"</span><span class="s">worksAt</span><span class="sh">"</span><span class="s">, C), ctx(Start, End, Source)).

current_company(P, C) :- employment_at(P, C, _, 0, _).
from_source(P, C, Source) :- employment_at(P, C, _, _, Source).
</span><span class="sh">"""</span>

<span class="k">def</span> <span class="nf">load</span><span class="p">(</span><span class="n">session</span><span class="p">):</span>
    <span class="n">works_at</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">intern</span><span class="p">(</span><span class="sh">"</span><span class="s">worksAt</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">hr</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">intern</span><span class="p">(</span><span class="sh">"</span><span class="s">HR_Database</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">person</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">intern</span><span class="p">(</span><span class="sh">"</span><span class="s">김철수</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">samsung</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">intern</span><span class="p">(</span><span class="sh">"</span><span class="s">삼성전자</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">naver</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">intern</span><span class="p">(</span><span class="sh">"</span><span class="s">네이버</span><span class="sh">"</span><span class="p">)</span>

    <span class="n">stmt1</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">make_compound</span><span class="p">(</span><span class="sh">"</span><span class="s">stmt</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">person</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">works_at</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">samsung</span><span class="p">),</span>
    <span class="p">])</span>
    <span class="n">ctx1</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">make_compound</span><span class="p">(</span><span class="sh">"</span><span class="s">ctx</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">INT64</span><span class="p">,</span> <span class="mi">20200101</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">INT64</span><span class="p">,</span> <span class="mi">20221231</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">hr</span><span class="p">),</span>
    <span class="p">])</span>

    <span class="n">stmt2</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">make_compound</span><span class="p">(</span><span class="sh">"</span><span class="s">stmt</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">person</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">works_at</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">naver</span><span class="p">),</span>
    <span class="p">])</span>
    <span class="n">ctx2</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">make_compound</span><span class="p">(</span><span class="sh">"</span><span class="s">ctx</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">INT64</span><span class="p">,</span> <span class="mi">20230301</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">INT64</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span>
        <span class="nc">CompoundArg</span><span class="p">(</span><span class="n">ColumnType</span><span class="p">.</span><span class="n">STRING</span><span class="p">,</span> <span class="n">hr</span><span class="p">),</span>
    <span class="p">])</span>

    <span class="n">session</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">employment</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="sh">"</span><span class="s">stmt1</span><span class="sh">"</span><span class="p">,</span> <span class="n">stmt1</span><span class="p">.</span><span class="n">handle</span><span class="p">,</span> <span class="n">ctx1</span><span class="p">.</span><span class="n">handle</span><span class="p">])</span>
    <span class="n">session</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">employment</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="sh">"</span><span class="s">stmt2</span><span class="sh">"</span><span class="p">,</span> <span class="n">stmt2</span><span class="p">.</span><span class="n">handle</span><span class="p">,</span> <span class="n">ctx2</span><span class="p">.</span><span class="n">handle</span><span class="p">])</span>

<span class="k">with</span> <span class="nc">EasySession</span><span class="p">(</span><span class="n">SRC</span><span class="p">)</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
    <span class="nf">load</span><span class="p">(</span><span class="n">session</span><span class="p">)</span>
    <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">employment_at =</span><span class="sh">"</span><span class="p">,</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">session</span><span class="p">.</span><span class="nf">snapshot</span><span class="p">(</span><span class="sh">"</span><span class="s">employment_at</span><span class="sh">"</span><span class="p">)))</span>
    <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">current_company =</span><span class="sh">"</span><span class="p">,</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">session</span><span class="p">.</span><span class="nf">snapshot</span><span class="p">(</span><span class="sh">"</span><span class="s">current_company</span><span class="sh">"</span><span class="p">)))</span>
    <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">from_source =</span><span class="sh">"</span><span class="p">,</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">session</span><span class="p">.</span><span class="nf">snapshot</span><span class="p">(</span><span class="sh">"</span><span class="s">from_source</span><span class="sh">"</span><span class="p">)))</span>
</code></pre></div></div>

<p>실행 결과는 다음과 같습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>employment_at = [
    ('김철수', '네이버', 20230301, 0, 'HR_Database'),
    ('김철수', '삼성전자', 20200101, 20221231, 'HR_Database')
]
current_company = [('김철수', '네이버')]
from_source = [
    ('김철수', '네이버', 'HR_Database'),
    ('김철수', '삼성전자', 'HR_Database')
]
</code></pre></div></div>

<p>직접 넣은 fact는 <code class="language-plaintext highlighter-rouge">employment</code> 두 건뿐입니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>employment("stmt1", stmt(김철수, worksAt, 삼성전자), ctx(20200101, 20221231, HR_Database))
employment("stmt2", stmt(김철수, worksAt, 네이버),   ctx(20230301, 0,        HR_Database))
</code></pre></div></div>

<p>나머지 <code class="language-plaintext highlighter-rouge">employment_at</code>, <code class="language-plaintext highlighter-rouge">current_company</code>, <code class="language-plaintext highlighter-rouge">from_source</code>는 규칙으로 나온 결과입니다. 특히 <code class="language-plaintext highlighter-rouge">current_company</code>는 <code class="language-plaintext highlighter-rouge">ctx(..., 0, ...)</code> 패턴만 보고 현재 회사를 찾아냅니다.</p>

<p>변경분만 보고 싶으면 <code class="language-plaintext highlighter-rouge">snapshot()</code> 대신 <code class="language-plaintext highlighter-rouge">step()</code>을 씁니다. <code class="language-plaintext highlighter-rouge">pyrewire</code>의 <code class="language-plaintext highlighter-rouge">EasySession</code>은 query 모드와 incremental 모드를 같은 세션에서 섞지 않습니다. 그래서 아래처럼 별도 세션을 열어 확인하는 편이 깔끔합니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">with</span> <span class="nc">EasySession</span><span class="p">(</span><span class="n">SRC</span><span class="p">)</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
    <span class="nf">load</span><span class="p">(</span><span class="n">session</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">relation</span><span class="p">,</span> <span class="n">row</span><span class="p">,</span> <span class="n">diff</span> <span class="ow">in</span> <span class="n">session</span><span class="p">.</span><span class="nf">step</span><span class="p">():</span>
        <span class="nf">print</span><span class="p">(</span><span class="n">relation</span><span class="p">,</span> <span class="n">row</span><span class="p">,</span> <span class="n">diff</span><span class="p">)</span>
</code></pre></div></div>

<p>출력은 다음과 같습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>employment_at ('김철수', '삼성전자', 20200101, 20221231, 'HR_Database') 1
employment_at ('김철수', '네이버', 20230301, 0, 'HR_Database') 1
current_company ('김철수', '네이버') 1
from_source ('김철수', '삼성전자', 'HR_Database') 1
from_source ('김철수', '네이버', 'HR_Database') 1
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">diff = 1</code>은 새 fact가 생겼다는 뜻입니다. 나중에 <code class="language-plaintext highlighter-rouge">employment</code> fact를 지우면 관련 결과는 <code class="language-plaintext highlighter-rouge">diff = -1</code>로 나옵니다. <a href="/research/2026/06/06/datalog-pkm-wirelog/">노트 그래프에 Datalog를 얹었던 글</a>에서 봤던 변경분 출력과 같은 방향입니다.</p>

<h2 id="왜-이-표현이-편한가">왜 이 표현이 편한가</h2>

<p>compound term을 쓰면 “트리플에 대한 메타데이터”를 다루는 Datalog 코드가 꽤 단순해집니다. 이유는 크게 세 가지입니다.</p>

<p>첫째, 원래 트리플 구조가 흩어지지 않습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stmt(P, "worksAt", C)
</code></pre></div></div>

<p>이 term 하나가 RDF의 triple term에 해당합니다. <code class="language-plaintext highlighter-rouge">P</code>, predicate, <code class="language-plaintext highlighter-rouge">C</code>가 별도 relation으로 흩어지지 않습니다.</p>

<p>둘째, 쿼리가 구조분해 패턴으로 바뀝니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">employment</span><span class="p">(</span><span class="nv">_</span><span class="p">,</span> <span class="ss">stmt</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="s2">"worksAt"</span><span class="p">,</span> <span class="nv">C</span><span class="p">),</span> <span class="ss">ctx</span><span class="p">(</span><span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">))</span>
</code></pre></div></div>

<p>이 한 줄은 <code class="language-plaintext highlighter-rouge">worksAt</code> 진술만 골라서, 그 주어와 목적어와 맥락을 꺼낸다는 뜻입니다. RDF/SPARQL로 치면 triple term과 annotation을 한 번에 펼치는 쿼리에 가깝습니다.</p>

<p>셋째, 애플리케이션 내부 표현으로 가볍습니다.</p>

<p>RDF를 외부와 교환해야 한다면 Turtle 1.2나 RDF dataset을 쓰는 편이 맞습니다. 표준 도구, triple store, SPARQL 생태계와 이어지기 때문입니다. 반대로 애플리케이션 내부에서 “진술과 그 맥락”을 빠르게 검사하고 조금씩 갱신하고 싶다면, compound term 쪽이 가볍습니다.</p>

<p>이 관계를 그림으로 보면 이렇습니다.</p>

<pre><code class="language-mermaid">graph LR
    A["RDF 1.1&lt;br/&gt;rdf:Statement"] --&gt; B["RDF 1.2&lt;br/&gt;triple term"]
    B --&gt; C["wirelog&lt;br/&gt;stmt(S, P, O)"]
    C --&gt; D["Datalog pattern&lt;br/&gt;stmt(P, worksAt, C)"]

    A2["rdf:subject&lt;br/&gt;rdf:predicate&lt;br/&gt;rdf:object"] --&gt; A
    B2["rdf:reifies&lt;br/&gt;&amp;lt;&amp;lt;(S P O)&amp;gt;&amp;gt;"] --&gt; B
    C2["compound column&lt;br/&gt;stmt/3 side"] --&gt; C
</code></pre>

<p>물론 둘이 같은 것은 아닙니다. RDF 1.2 triple term은 RDF abstract data model의 일부이고, 웹 표준입니다. <code class="language-plaintext highlighter-rouge">wirelog</code> compound term은 Datalog 엔진 안에서 쓰는 값입니다. 하나는 교환 포맷과 의미론의 문제이고, 다른 하나는 실행 엔진의 표현 문제입니다.</p>

<p>다만 이 예제에서는 비슷한 효과가 납니다. RDF 1.2에서는 <code class="language-plaintext highlighter-rouge">ex:김철수 ex:worksAt ex:삼성전자</code>라는 트리플을 <code class="language-plaintext highlighter-rouge">rdf:subject</code>, <code class="language-plaintext highlighter-rouge">rdf:predicate</code>, <code class="language-plaintext highlighter-rouge">rdf:object</code> 세 조각으로 풀지 않습니다. triple term 하나로 가리킵니다.</p>

<p><code class="language-plaintext highlighter-rouge">wirelog</code> 쪽에서도 마찬가지입니다. <code class="language-plaintext highlighter-rouge">김철수</code>, <code class="language-plaintext highlighter-rouge">worksAt</code>, <code class="language-plaintext highlighter-rouge">삼성전자</code>를 별도 relation에 흩어 놓고 <code class="language-plaintext highlighter-rouge">ID</code>로 다시 조인하지 않습니다. <code class="language-plaintext highlighter-rouge">stmt(김철수, worksAt, 삼성전자)</code>라는 compound term 안에 함께 둡니다. 그래서 규칙에서는 <code class="language-plaintext highlighter-rouge">stmt(P, "worksAt", C)</code>처럼 바로 꺼내 쓸 수 있습니다.</p>

<h2 id="언제-rdf-12를-쓰고-언제-compound-term을-쓰나">언제 RDF 1.2를 쓰고, 언제 compound term을 쓰나</h2>

<p>외부 지식 그래프와 주고받아야 한다면 RDF 1.2 표현이 자연스럽습니다. 여기서 “주고받는다”는 것은 다른 triple store, 다른 팀의 ontology, 또는 RDF를 입력으로 받는 분석 도구에 데이터를 넘긴다는 뜻입니다.</p>

<p>예를 들어 <code class="language-plaintext highlighter-rouge">wirelog</code> 안에서는 다음처럼 들고 있던 값을</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>employment("stmt1", stmt(김철수, worksAt, 삼성전자), ctx(20200101, 20221231, HR_Database))
</code></pre></div></div>

<p>외부로 내보낼 때는 RDF 1.2 Turtle로 바꿀 수 있습니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">ex</span><span class="p">:</span><span class="nt">stmt1</span><span class="w"> </span><span class="nn">rdf</span><span class="p">:</span><span class="nt">reifies</span><span class="w"> </span><span class="nl">&lt;&lt;( ex:김철수 ex:worksAt ex:삼성전자 )&gt;</span><span class="err">&gt;</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">startDate</span><span class="w"> </span><span class="s2">"2020-01-01"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">endDate</span><span class="w">   </span><span class="s2">"2022-12-31"</span><span class="o">^^</span><span class="nn">xsd</span><span class="p">:</span><span class="nt">date</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">source</span><span class="w">    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">HR_Database</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>이 형태라면 RDF 도구는 <code class="language-plaintext highlighter-rouge">ex:김철수</code>, <code class="language-plaintext highlighter-rouge">ex:worksAt</code>, <code class="language-plaintext highlighter-rouge">ex:삼성전자</code>, <code class="language-plaintext highlighter-rouge">ex:startDate</code>를 모두 IRI로 이해합니다. 다른 그래프와 합치거나, SPARQL로 조회하거나, ontology에 정의된 <code class="language-plaintext highlighter-rouge">ex:worksAt</code>의 의미를 따라갈 수 있습니다. 반대로 외부 RDF에서 이 데이터를 읽어와 내부 규칙 엔진으로 넘길 때는 <code class="language-plaintext highlighter-rouge">rdf:reifies</code>의 triple term을 읽어 <code class="language-plaintext highlighter-rouge">stmt(김철수, worksAt, 삼성전자)</code>로, 나머지 속성을 <code class="language-plaintext highlighter-rouge">ctx(...)</code>로 옮기면 됩니다.</p>

<p>반대로 애플리케이션 내부에서 추론하고, 쿼리 결과를 변경분으로 받아보고, Python 값으로 바로 넣고 빼고 싶다면 compound term이 더 직접적입니다. 이 경우에는 매번 Turtle 문자열을 만들고 RDF 파서에 태우는 단계가 필요 없습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">employment_at</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)</span> <span class="p">:-</span>
    <span class="ss">employment</span><span class="p">(</span><span class="nv">_</span><span class="p">,</span> <span class="ss">stmt</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="s2">"worksAt"</span><span class="p">,</span> <span class="nv">C</span><span class="p">),</span> <span class="ss">ctx</span><span class="p">(</span><span class="nv">Start</span><span class="p">,</span> <span class="nv">End</span><span class="p">,</span> <span class="nv">Source</span><span class="p">)).</span>
</code></pre></div></div>

<p>두 표현은 경쟁한다기보다 쓰임새가 다릅니다.</p>

<table>
  <thead>
    <tr>
      <th>목적</th>
      <th>적합한 표현</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>표준 RDF 데이터 교환</td>
      <td>RDF 1.2 Turtle / triple term</td>
    </tr>
    <tr>
      <td>triple store와 SPARQL 쿼리</td>
      <td>RDF 1.2 / SPARQL 1.2</td>
    </tr>
    <tr>
      <td>애플리케이션 내부 규칙 평가</td>
      <td>Datalog relation</td>
    </tr>
    <tr>
      <td>구조가 있는 진술을 relation 안에서 다루기</td>
      <td>wirelog compound term</td>
    </tr>
    <tr>
      <td>변경분만 보고 반응하기</td>
      <td>wirelog incremental <code class="language-plaintext highlighter-rouge">step()</code></td>
    </tr>
  </tbody>
</table>

<p>지난 글에서 Reification을 설명할 때의 결론은 “트리플 자체에 대해 말할 수 있어야 한다”였습니다. RDF 1.2는 이 요구를 표준 문법과 데이터 모델 안으로 더 가까이 끌어옵니다. <code class="language-plaintext highlighter-rouge">wirelog</code>의 compound term은 같은 요구를 Datalog 실행 모델 안에서 다룹니다.</p>

<p>문제는 결국 같습니다. 현실의 사실은 대개 벌거벗은 트리플 하나로 끝나지 않습니다. 언제, 누가, 어떤 근거로, 어느 기간 동안 참인지가 따라옵니다.</p>

<p>RDF 1.1에서는 그 맥락을 붙이려고 트리플을 분해했습니다. RDF 1.2에서는 트리플을 term으로 만들 수 있습니다. 그리고 <code class="language-plaintext highlighter-rouge">wirelog</code>에서는 그 term에 해당하는 값을 compound value로 넣고, Datalog 규칙에서 구조분해해 쿼리할 수 있습니다.</p>

<p>표현이 짧아졌다는 것은 단순히 글자 수가 줄었다는 뜻이 아닙니다. 사람이 생각하는 단위와 코드가 다루는 단위가 조금 가까워졌다는 뜻입니다. Reification이 어렵게 느껴졌던 이유도, 어쩌면 그 간격이 너무 컸기 때문인지 모릅니다.</p>

<hr />

<h3 id="참고">참고</h3>

<ul>
  <li><strong>RDF 1.2 Concepts and Abstract Data Model</strong>: <a href="https://www.w3.org/TR/rdf12-concepts/">https://www.w3.org/TR/rdf12-concepts/</a></li>
  <li><strong>RDF 1.2 Turtle</strong>: <a href="https://www.w3.org/TR/rdf12-turtle/">https://www.w3.org/TR/rdf12-turtle/</a></li>
  <li><strong>RDF 1.2 Primer</strong>: <a href="https://www.w3.org/TR/rdf12-primer/">https://www.w3.org/TR/rdf12-primer/</a></li>
  <li><strong>wirelog</strong>: <a href="https://github.com/semantic-reasoning/wirelog">semantic-reasoning/wirelog</a></li>
  <li><strong>PyreWire</strong>: <a href="https://github.com/semantic-reasoning/PyreWire">semantic-reasoning/PyreWire</a></li>
</ul>

<h3 id="관련-글">관련 글</h3>

<ul>
  <li><a href="/ontology/2026/02/24/rdf-reification/">시간이 흐르면 사실도 변한다: RDF Reification으로 맥락 기록하기</a></li>
  <li><a href="/ontology/2026/02/26/rdf-named-graphs/">같은 사실, 다른 출처: Named Graph로 신뢰의 경계를 긋다</a></li>
  <li><a href="/research/2026/06/06/datalog-pkm-wirelog/">WITH RECURSIVE로는 답답했던 질문들: 내 노트 그래프에 Datalog 얹기</a></li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="ontology" /><category term="RDF" /><category term="RDF-star" /><category term="Reification" /><category term="Datalog" /><category term="wirelog" /><category term="PyreWire" /><summary type="html"><![CDATA[예전 RDF reification 예제를 RDF 1.2의 triple term과 rdf:reifies로 다시 보고, 같은 생각을 wirelog compound term으로 옮겨 pyrewire 코드로 실행해봅니다.]]></summary></entry><entry><title type="html">Askitect의 SPARQL 루프를 Datalog로 바꾸기</title><link href="https://groou.com/research/2026/06/20/askitect-datalog-pyrewire/" rel="alternate" type="text/html" title="Askitect의 SPARQL 루프를 Datalog로 바꾸기" /><published>2026-06-20T09:00:00+09:00</published><updated>2026-06-20T09:00:00+09:00</updated><id>https://groou.com/research/2026/06/20/askitect-datalog-pyrewire</id><content type="html" xml:base="https://groou.com/research/2026/06/20/askitect-datalog-pyrewire/"><![CDATA[<p>지난번 <a href="/essay/ai/2026/02/13/askitect-prototype/">Askitect 프로토타입 글</a>에서는 LLM이 사용자의 자연어를 RDF 트리플로 바꾸고, 그 중간 상태를 SPARQL로 검사하는 구조를 구현했습니다. 사용자가 “친구랑 도쿄 가려고. 맛집이 제일 중요해.”라고 말하면 LLM은 <code class="language-plaintext highlighter-rouge">hasDestination</code>, <code class="language-plaintext highlighter-rouge">hasCompanionType</code>, <code class="language-plaintext highlighter-rouge">hasPriority</code>를 추출하고, SPARQL은 <code class="language-plaintext highlighter-rouge">hasDuration</code>과 <code class="language-plaintext highlighter-rouge">hasBudget</code>이 빠졌다는 사실을 찾아냈습니다.</p>

<p>그 글의 핵심은 LLM을 추론 엔진으로 쓰지 않는다는 점이었습니다. LLM은 자연어와 구조화된 데이터 사이를 오가는 번역가이고, “무엇이 빠졌는가”를 판단하는 일은 심볼릭 엔진이 맡았습니다. 당시에는 그 심볼릭 레이어를 RDF 그래프와 SPARQL로 구현했습니다.</p>

<p>이번 글에서는 같은 Askitect 루프를 <code class="language-plaintext highlighter-rouge">pyrewire</code>로 다시 구현해보려 합니다. 결론부터 말하면, 이 예제의 SPARQL은 Datalog로 충분히 대체할 수 있습니다. 더 정확히 말하면, Askitect가 필요로 하는 것은 “그래프 표준” 자체라기보다 <strong>필수 조건과 현재 사실을 비교해 새 사실을 도출하는 작은 규칙 엔진</strong>입니다.</p>

<h2 id="바꾸려는-부분">바꾸려는 부분</h2>

<p>기존 구조는 다음과 같았습니다.</p>

<pre><code class="language-mermaid">graph LR
    User(("User")) --&gt;|자연어| A["Semantic Parser&lt;br/&gt;(LLM)"]
    A --&gt;|Turtle| B[("Knowledge Graph&lt;br/&gt;(rdflib)")]
    B --&gt;|SPARQL| C{"Completeness&lt;br/&gt;Check"}
    C --&gt;|Missing| D["Question Generator&lt;br/&gt;(LLM)"]
    C --&gt;|Complete| E["Execute"]
    D --&gt;|질문| User
</code></pre>

<p>이번에는 가운데만 바꿉니다.</p>

<pre><code class="language-mermaid">graph LR
    User(("User")) --&gt;|자연어| A["Semantic Parser&lt;br/&gt;(LLM)"]
    A --&gt;|Python facts| B[("Datalog Facts&lt;br/&gt;(pyrewire)")]
    B --&gt;|Rules| C{"Completeness&lt;br/&gt;Check"}
    C --&gt;|Missing| D["Question Generator&lt;br/&gt;(LLM)"]
    C --&gt;|Complete| E["Execute"]
    D --&gt;|질문| User
</code></pre>

<p>LLM의 역할은 그대로입니다. 자연어에서 구조를 뽑고, 빠진 속성 목록을 자연스러운 질문으로 바꿉니다. 달라지는 것은 상태 표현과 검증 방식입니다. RDF 트리플과 SPARQL 대신, Datalog의 사실(fact)과 규칙(rule)을 씁니다.</p>

<h2 id="sparql-쿼리가-하던-일">SPARQL 쿼리가 하던 일</h2>

<p>먼저 기존 글의 완성도 검사를 다시 보겠습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">check_completeness</span><span class="p">(</span><span class="n">ontology</span><span class="p">:</span> <span class="n">Graph</span><span class="p">,</span> <span class="n">instance</span><span class="p">:</span> <span class="n">Graph</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]:</span>
    <span class="n">merged</span> <span class="o">=</span> <span class="n">ontology</span> <span class="o">+</span> <span class="n">instance</span>

    <span class="n">query</span> <span class="o">=</span> <span class="sh">"""</span><span class="s">
    PREFIX ex: &lt;http://example.org/travel/&gt;
    PREFIX rdfs: &lt;http://www.w3.org/2000/01/rdf-schema#&gt;

    SELECT ?prop ?label WHERE {
        ?prop rdfs:domain ex:TripPlan .
        ?prop rdfs:label ?label .
        FILTER(lang(?label) = </span><span class="sh">"</span><span class="s">ko</span><span class="sh">"</span><span class="s">)

        FILTER NOT EXISTS {
            ex:Request_001 ?prop ?value .
        }
    }
    </span><span class="sh">"""</span>
    <span class="n">results</span> <span class="o">=</span> <span class="n">merged</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">[{</span><span class="sh">"</span><span class="s">property</span><span class="sh">"</span><span class="p">:</span> <span class="nf">str</span><span class="p">(</span><span class="n">row</span><span class="p">.</span><span class="n">prop</span><span class="p">),</span> <span class="sh">"</span><span class="s">label</span><span class="sh">"</span><span class="p">:</span> <span class="nf">str</span><span class="p">(</span><span class="n">row</span><span class="p">.</span><span class="n">label</span><span class="p">)}</span>
            <span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">results</span><span class="p">]</span>
</code></pre></div></div>

<p>여기서 실제로 필요한 논리는 단순합니다.</p>

<ol>
  <li>어떤 속성은 여행 계획에 필수다.</li>
  <li>현재 요청에는 어떤 속성이 이미 들어 있다.</li>
  <li>필수인데 현재 요청에 없으면, 그것은 빠진 속성이다.</li>
</ol>

<p>SPARQL에서는 이 세 번째 문장을 <code class="language-plaintext highlighter-rouge">FILTER NOT EXISTS</code>로 표현했습니다. Datalog에서는 같은 생각을 <code class="language-plaintext highlighter-rouge">missing</code>이라는 새 사실을 도출하는 규칙으로 표현할 수 있습니다.</p>

<h2 id="스키마를-사실로-바꾸기">스키마를 사실로 바꾸기</h2>

<p>RDF 버전에서는 <code class="language-plaintext highlighter-rouge">rdfs:domain</code>과 <code class="language-plaintext highlighter-rouge">rdfs:label</code>을 온톨로지 그래프에 넣었습니다. Datalog 버전에서는 그것을 더 직접적인 사실로 둡니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">request</span><span class="p">(</span><span class="ss">req</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">required</span><span class="p">(</span><span class="ss">prop</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">property_label</span><span class="p">(</span><span class="ss">prop</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">label</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">present</span><span class="p">(</span><span class="ss">req</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">prop</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">value</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
</code></pre></div></div>

<p>각 관계의 뜻은 이렇습니다.</p>

<table>
  <thead>
    <tr>
      <th>관계</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">request(req)</code></td>
      <td>하나의 여행 계획 요청이 존재한다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">required(prop)</code></td>
      <td>모든 여행 계획에 필요한 속성이다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">property_label(prop, label)</code></td>
      <td>속성의 사용자 표시 이름이다</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">present(req, prop, value)</code></td>
      <td>현재 요청에 특정 속성 값이 들어 있다</td>
    </tr>
  </tbody>
</table>

<p>예를 들어 여행지, 동행인, 우선순위, 기간, 예산이 필수라면 Python에서는 이렇게 넣습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">REQUIRED</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasDestination</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">여행지</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasCompanionType</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">동행인 유형</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasPriority</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">여행 우선순위</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasDuration</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">여행 기간</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasBudget</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">예산 범위</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
</code></pre></div></div>

<p>RDF의 URI 대신 짧은 심볼을 썼습니다. 이 예제에서 중요한 것은 RDF 네임스페이스가 아니라, 속성 이름이 안정적인 식별자로 유지된다는 점입니다. 필요하다면 <code class="language-plaintext highlighter-rouge">"http://example.org/travel/hasBudget"</code>처럼 전체 URI를 그대로 <code class="language-plaintext highlighter-rouge">symbol</code> 값으로 넣어도 됩니다.</p>

<h2 id="datalog로-누락-정보-찾기">Datalog로 누락 정보 찾기</h2>

<p>이제 규칙을 작성합니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">required_for</span><span class="p">(</span><span class="ss">req</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">prop</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">label</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">present_prop</span><span class="p">(</span><span class="ss">req</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">prop</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">missing</span><span class="p">(</span><span class="ss">req</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">prop</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">label</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>

<span class="ss">required_for</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">request</span><span class="p">(</span><span class="nv">R</span><span class="p">),</span> <span class="ss">required</span><span class="p">(</span><span class="nv">P</span><span class="p">),</span> <span class="ss">property_label</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">).</span>
<span class="ss">present_prop</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">present</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">V</span><span class="p">).</span>
<span class="ss">missing</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">required_for</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">),</span> <span class="p">!</span><span class="ss">present_prop</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">).</span>
</code></pre></div></div>

<p>핵심은 마지막 줄입니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">missing</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">required_for</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">),</span> <span class="p">!</span><span class="ss">present_prop</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">).</span>
</code></pre></div></div>

<p>뜻은 거의 그대로 읽힙니다. 어떤 요청 <code class="language-plaintext highlighter-rouge">R</code>에 대해 속성 <code class="language-plaintext highlighter-rouge">P</code>가 필요하고, 그 속성의 표시 이름이 <code class="language-plaintext highlighter-rouge">L</code>인데, 현재 <code class="language-plaintext highlighter-rouge">present_prop(R, P)</code>가 없다면 <code class="language-plaintext highlighter-rouge">missing(R, P, L)</code>을 도출합니다.</p>

<p>여기서 <code class="language-plaintext highlighter-rouge">present_prop</code>를 따로 둔 이유가 있습니다. <code class="language-plaintext highlighter-rouge">present(R, P, V)</code>를 직접 부정하면 <code class="language-plaintext highlighter-rouge">V</code>가 부정 항에만 등장합니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">missing</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">required_for</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">),</span> <span class="p">!</span><span class="ss">present</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">V</span><span class="p">).</span>
</code></pre></div></div>

<p>이런 형태는 Datalog 관점에서 안전하지 않은 규칙이 되기 쉽습니다. 그래서 먼저 “값이 무엇이든 그 속성이 존재한다”는 <code class="language-plaintext highlighter-rouge">present_prop(R, P)</code>로 투영한 뒤, 그 관계를 부정합니다. 실무적으로도 이쪽이 더 읽기 쉽습니다. Askitect가 궁금한 것은 값 자체가 아니라, 그 속성이 채워졌는지 여부이기 때문입니다.</p>

<p>참고로 <code class="language-plaintext highlighter-rouge">wirelog</code>의 부정 문법은 <code class="language-plaintext highlighter-rouge">not rel(...)</code>이 아니라 <code class="language-plaintext highlighter-rouge">!rel(...)</code>입니다. 흔히 Datalog 설명에서는 <code class="language-plaintext highlighter-rouge">not</code>을 쓰지만, 현재 <code class="language-plaintext highlighter-rouge">wirelog 0.51.0</code>의 문법 문서와 실제 파서는 <code class="language-plaintext highlighter-rouge">!Rel(x)</code>를 사용합니다.</p>

<h2 id="pyrewire-코드">pyrewire 코드</h2>

<p>이제 Python에서 이 규칙을 실행합니다. 여기서는 이전 <a href="/research/2026/06/06/datalog-pkm-wirelog/">노트 그래프 글</a>과 마찬가지로 <code class="language-plaintext highlighter-rouge">EasySession</code>을 씁니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">pyrewire</span> <span class="kn">import</span> <span class="n">EasySession</span>

<span class="n">DL_SRC</span> <span class="o">=</span> <span class="sh">"""</span><span class="s">
.decl request(req: symbol)
.decl required(prop: symbol)
.decl property_label(prop: symbol, label: symbol)
.decl present(req: symbol, prop: symbol, value: symbol)

.decl required_for(req: symbol, prop: symbol, label: symbol)
.decl present_prop(req: symbol, prop: symbol)
.decl missing(req: symbol, prop: symbol, label: symbol)

required_for(R, P, L) :- request(R), required(P), property_label(P, L).
present_prop(R, P) :- present(R, P, V).
missing(R, P, L) :- required_for(R, P, L), !present_prop(R, P).
</span><span class="sh">"""</span>

<span class="n">REQUIRED</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasDestination</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">여행지</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasCompanionType</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">동행인 유형</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasPriority</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">여행 우선순위</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasDuration</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">여행 기간</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasBudget</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">예산 범위</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>

<span class="k">def</span> <span class="nf">check_completeness</span><span class="p">(</span><span class="n">facts</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]])</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]:</span>
    <span class="n">req</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Request_001</span><span class="sh">"</span>

    <span class="k">with</span> <span class="nc">EasySession</span><span class="p">(</span><span class="n">DL_SRC</span><span class="p">)</span> <span class="k">as</span> <span class="n">s</span><span class="p">:</span>
        <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">request</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="n">req</span><span class="p">])</span>

        <span class="k">for</span> <span class="n">prop</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="n">REQUIRED</span><span class="p">:</span>
            <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">required</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="n">prop</span><span class="p">])</span>
            <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">property_label</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="n">prop</span><span class="p">,</span> <span class="n">label</span><span class="p">])</span>

        <span class="k">for</span> <span class="n">prop</span><span class="p">,</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">facts</span><span class="p">:</span>
            <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">present</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="n">req</span><span class="p">,</span> <span class="n">prop</span><span class="p">,</span> <span class="n">value</span><span class="p">])</span>

        <span class="n">rows</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="nf">snapshot</span><span class="p">(</span><span class="sh">"</span><span class="s">missing</span><span class="sh">"</span><span class="p">)</span>

    <span class="k">return</span> <span class="p">[</span>
        <span class="p">{</span><span class="sh">"</span><span class="s">request</span><span class="sh">"</span><span class="p">:</span> <span class="n">r</span><span class="p">,</span> <span class="sh">"</span><span class="s">property</span><span class="sh">"</span><span class="p">:</span> <span class="n">p</span><span class="p">,</span> <span class="sh">"</span><span class="s">label</span><span class="sh">"</span><span class="p">:</span> <span class="n">label</span><span class="p">}</span>
        <span class="k">for</span> <span class="n">r</span><span class="p">,</span> <span class="n">p</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">rows</span><span class="p">)</span>
    <span class="p">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">facts</code>는 LLM 파서가 뽑아낸 현재 요청의 속성 목록입니다. 예를 들어 사용자가 “친구랑 도쿄 가려고. 맛집이 제일 중요해.”라고 말하면, 파서는 다음 정도의 구조를 반환한다고 가정합니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">facts</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasDestination</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Tokyo</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasCompanionType</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Friend</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">(</span><span class="sh">"</span><span class="s">hasPriority</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Gastronomy</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
</code></pre></div></div>

<p>이 값을 넣으면 Datalog 엔진은 다음 결과를 돌려줍니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">missing</span> <span class="o">=</span> <span class="nf">check_completeness</span><span class="p">(</span><span class="n">facts</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="n">missing</span><span class="p">)</span>
</code></pre></div></div>

<p>출력은 이렇습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[
    {'request': 'Request_001', 'property': 'hasBudget', 'label': '예산 범위'},
    {'request': 'Request_001', 'property': 'hasDuration', 'label': '여행 기간'}
]
</code></pre></div></div>

<p>SPARQL 버전에서 <code class="language-plaintext highlighter-rouge">FILTER NOT EXISTS</code>가 반환하던 결과와 같은 의미입니다. 다만 이번에는 “쿼리 결과”라기보다, <code class="language-plaintext highlighter-rouge">missing</code>이라는 새 사실이 규칙에 의해 도출된 것입니다.</p>

<h2 id="llm-파서는-꼭-rdf를-만들-필요가-없다">LLM 파서는 꼭 RDF를 만들 필요가 없다</h2>

<p>기존 구현에서는 LLM이 Turtle을 생성했습니다.</p>

<div class="language-turtle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@prefix</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="w"> </span><span class="nl">&lt;http://example.org/travel/&gt;</span><span class="w"> </span><span class="p">.</span><span class="w">

</span><span class="nn">ex</span><span class="p">:</span><span class="nt">Request_001</span><span class="w"> </span><span class="kt">a</span><span class="w"> </span><span class="nn">ex</span><span class="p">:</span><span class="nt">TripPlan</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">hasDestination</span><span class="w"> </span><span class="s2">"Tokyo"</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">hasCompanionType</span><span class="w"> </span><span class="s2">"Friend"</span><span class="w"> </span><span class="p">;</span><span class="w">
    </span><span class="nn">ex</span><span class="p">:</span><span class="nt">hasPriority</span><span class="w"> </span><span class="s2">"Gastronomy"</span><span class="w"> </span><span class="p">.</span><span class="w">
</span></code></pre></div></div>

<p>Datalog 버전에서는 LLM이 굳이 RDF 문법을 만들 필요가 없습니다. 애플리케이션 내부에서 필요한 것은 결국 속성-값 쌍입니다. 구조화 출력으로 다음과 같은 JSON을 받는 편이 더 단순합니다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"request_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Request_001"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"facts"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"property"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hasDestination"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Tokyo"</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"property"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hasCompanionType"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Friend"</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"property"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hasPriority"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Gastronomy"</span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>그러면 Python 쪽에서는 이 JSON을 <code class="language-plaintext highlighter-rouge">present</code> 사실로 넣기만 하면 됩니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">facts_from_llm</span><span class="p">(</span><span class="n">parsed</span><span class="p">:</span> <span class="nb">dict</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]]:</span>
    <span class="k">return</span> <span class="p">[</span>
        <span class="p">(</span><span class="n">item</span><span class="p">[</span><span class="sh">"</span><span class="s">property</span><span class="sh">"</span><span class="p">],</span> <span class="n">item</span><span class="p">[</span><span class="sh">"</span><span class="s">value</span><span class="sh">"</span><span class="p">])</span>
        <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">parsed</span><span class="p">[</span><span class="sh">"</span><span class="s">facts</span><span class="sh">"</span><span class="p">]</span>
    <span class="p">]</span>
</code></pre></div></div>

<p>이 변화는 작지만 중요합니다. RDF를 쓰면 표준 도구와 연결하기 쉽고, 외부 지식 그래프와 합치기 좋습니다. 반면 내부 대화 상태를 검증하는 작은 루프라면, RDF 직렬화와 파싱을 매 턴 거칠 필요가 없을 수 있습니다. Datalog는 이 중간 표현을 더 얇게 만들어줍니다.</p>

<h2 id="조건부-질문도-규칙으로-밀어-넣기">조건부 질문도 규칙으로 밀어 넣기</h2>

<p>SPARQL 버전의 프로토타입은 모든 필수 속성을 동등하게 다뤘습니다. 하지만 실제 대화에서는 조건부 요구사항이 자주 생깁니다.</p>

<p>예를 들어 해외여행이면 여권 여부를 물어봐야 하지만, 국내여행이면 필요하지 않습니다. 이런 규칙은 Datalog에서 자연스럽게 추가할 수 있습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="ss">decl</span> <span class="ss">trip_kind</span><span class="p">(</span><span class="ss">req</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">kind</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">requires_for_trip</span><span class="p">(</span><span class="ss">kind</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">prop</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>

<span class="ss">required_for</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">request</span><span class="p">(</span><span class="nv">R</span><span class="p">),</span> <span class="ss">required</span><span class="p">(</span><span class="nv">P</span><span class="p">),</span> <span class="ss">property_label</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">).</span>
<span class="ss">required_for</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">)</span> <span class="p">:-</span>
    <span class="ss">trip_kind</span><span class="p">(</span><span class="nv">R</span><span class="p">,</span> <span class="nv">K</span><span class="p">),</span>
    <span class="ss">requires_for_trip</span><span class="p">(</span><span class="nv">K</span><span class="p">,</span> <span class="nv">P</span><span class="p">),</span>
    <span class="ss">property_label</span><span class="p">(</span><span class="nv">P</span><span class="p">,</span> <span class="nv">L</span><span class="p">).</span>
</code></pre></div></div>

<p>Python에서는 조건부 요구사항을 사실로 넣습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">property_label</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="sh">"</span><span class="s">hasPassport</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">여권 여부</span><span class="sh">"</span><span class="p">])</span>
<span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">trip_kind</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="sh">"</span><span class="s">Request_001</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">international</span><span class="sh">"</span><span class="p">])</span>
<span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">requires_for_trip</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="sh">"</span><span class="s">international</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">hasPassport</span><span class="sh">"</span><span class="p">])</span>
</code></pre></div></div>

<p>그러면 <code class="language-plaintext highlighter-rouge">hasPassport</code>는 모든 여행에 필요한 속성은 아니지만, <code class="language-plaintext highlighter-rouge">international</code> 여행에는 필요한 속성이 됩니다. 이후 <code class="language-plaintext highlighter-rouge">missing</code> 규칙은 그대로입니다. 필요한 속성의 집합이 커졌을 뿐, 누락을 찾는 방식은 바뀌지 않습니다.</p>

<p>이 지점이 Datalog가 SPARQL보다 편하게 느껴지는 부분입니다. SPARQL로도 당연히 표현할 수 있습니다. 하지만 규칙이 늘어날수록 “조회 쿼리” 안에 조건을 계속 덧붙이는 느낌이 강해집니다. Datalog에서는 요구사항 자체를 <code class="language-plaintext highlighter-rouge">required_for</code>라는 관계로 먼저 도출하고, 그 다음 <code class="language-plaintext highlighter-rouge">missing</code>을 도출합니다. 단계가 이름을 갖기 때문에 읽는 사람이 중간 개념을 붙잡기 쉽습니다.</p>

<h2 id="순환-루프에-끼워-넣기">순환 루프에 끼워 넣기</h2>

<p>전체 Askitect 루프는 크게 달라지지 않습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">run_askitect</span><span class="p">():</span>
    <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Askitect: 여행 계획을 도와드릴게요. 어디로 가고 싶으세요?</span><span class="sh">"</span><span class="p">)</span>

    <span class="n">accumulated</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>

    <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
        <span class="n">user_input</span> <span class="o">=</span> <span class="nf">input</span><span class="p">(</span><span class="sh">"</span><span class="s">User: </span><span class="sh">"</span><span class="p">)</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">user_input</span><span class="p">:</span>
            <span class="k">break</span>

        <span class="n">parsed</span> <span class="o">=</span> <span class="nf">parse_to_structured_facts</span><span class="p">(</span><span class="n">user_input</span><span class="p">)</span>

        <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">parsed</span><span class="p">[</span><span class="sh">"</span><span class="s">facts</span><span class="sh">"</span><span class="p">]:</span>
            <span class="n">accumulated</span><span class="p">[</span><span class="n">item</span><span class="p">[</span><span class="sh">"</span><span class="s">property</span><span class="sh">"</span><span class="p">]]</span> <span class="o">=</span> <span class="n">item</span><span class="p">[</span><span class="sh">"</span><span class="s">value</span><span class="sh">"</span><span class="p">]</span>

        <span class="n">facts</span> <span class="o">=</span> <span class="nf">list</span><span class="p">(</span><span class="n">accumulated</span><span class="p">.</span><span class="nf">items</span><span class="p">())</span>
        <span class="n">missing</span> <span class="o">=</span> <span class="nf">check_completeness</span><span class="p">(</span><span class="n">facts</span><span class="p">)</span>

        <span class="k">if</span> <span class="ow">not</span> <span class="n">missing</span><span class="p">:</span>
            <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Askitect: 필요한 정보가 모두 모였습니다!</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">break</span>

        <span class="n">question</span> <span class="o">=</span> <span class="nf">generate_question</span><span class="p">(</span><span class="n">missing</span><span class="p">,</span> <span class="n">context</span><span class="o">=</span><span class="n">user_input</span><span class="p">)</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Askitect: </span><span class="si">{</span><span class="n">question</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">parse_to_structured_facts</code>와 <code class="language-plaintext highlighter-rouge">generate_question</code>은 여전히 LLM 호출입니다. 하지만 <code class="language-plaintext highlighter-rouge">check_completeness</code>는 LLM과 무관합니다. 같은 입력 사실을 넣으면 같은 <code class="language-plaintext highlighter-rouge">missing</code> 결과가 나옵니다.</p>

<p>대화 예시는 기존과 거의 같습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Askitect: 여행 계획을 도와드릴게요. 어디로 가고 싶으세요?
User: 친구랑 도쿄 가려고. 맛집이 제일 중요해.
Askitect: 도쿄 맛집 여행이라니 좋네요. 며칠 정도 다녀오실 계획이세요?
          그리고 대략적인 예산 범위도 알려주세요.
User: 3박 4일이고 인당 100만원 정도.
Askitect: 필요한 정보가 모두 모였습니다!
</code></pre></div></div>

<p>차이는 사용자에게 보이지 않습니다. 내부에서 누락 정보를 판단하는 엔진이 SPARQL에서 Datalog로 바뀌었을 뿐입니다.</p>

<h2 id="sparql을-버리자는-이야기는-아니다">SPARQL을 버리자는 이야기는 아니다</h2>

<p>이 글의 목적은 SPARQL을 대체 불가능한 표준에서 끌어내리자는 것이 아닙니다. RDF 그래프를 외부와 교환해야 하거나, 이미 온톨로지와 시맨틱 웹 도구 체인 위에서 시스템을 만들고 있다면 SPARQL은 여전히 자연스러운 선택입니다.</p>

<p>다만 Askitect 프로토타입에서 SPARQL이 맡았던 역할은 생각보다 좁았습니다. 그것은 거대한 지식 그래프 질의가 아니라, 현재 대화 상태가 스키마의 요구사항을 만족하는지 확인하는 일이었습니다. 이 정도 범위에서는 Datalog가 더 작고 직접적인 표현을 제공합니다.</p>

<p>비교하면 이렇습니다.</p>

<table>
  <thead>
    <tr>
      <th>관점</th>
      <th>SPARQL 버전</th>
      <th>Datalog 버전</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상태 표현</td>
      <td>RDF 트리플 그래프</td>
      <td>관계형 fact</td>
    </tr>
    <tr>
      <td>누락 검사</td>
      <td><code class="language-plaintext highlighter-rouge">FILTER NOT EXISTS</code></td>
      <td><code class="language-plaintext highlighter-rouge">missing</code> 규칙</td>
    </tr>
    <tr>
      <td>중간 개념</td>
      <td>쿼리 안의 패턴</td>
      <td>이름 붙은 관계</td>
    </tr>
    <tr>
      <td>외부 표준 연계</td>
      <td>강함</td>
      <td>약함</td>
    </tr>
    <tr>
      <td>애플리케이션 임베딩</td>
      <td>RDF 파싱 필요</td>
      <td>Python 값 직접 삽입</td>
    </tr>
  </tbody>
</table>

<p>Askitect를 지식 그래프 플랫폼의 일부로 만들고 싶다면 RDF/SPARQL이 좋은 출발점입니다. 반대로 하나의 애플리케이션 안에서 대화 상태를 빠르게 검증하고 싶다면, pyrewire 같은 임베디드 Datalog 엔진이 더 가벼운 선택지가 될 수 있습니다.</p>

<h2 id="마치며">마치며</h2>

<p>이번 실험에서 확인한 것은 Askitect의 핵심이 특정 표준 문법에 묶여 있지 않다는 점입니다. 중요한 것은 역할의 분리입니다.</p>

<p>LLM은 자연어를 구조화된 사실로 번역합니다. Datalog는 그 사실과 규칙을 결합해 <code class="language-plaintext highlighter-rouge">missing</code> 같은 새 사실을 도출합니다. 다시 LLM은 그 결과를 사용자에게 자연스러운 질문으로 바꿉니다.</p>

<p>지난 글에서는 이 구조를 RDF와 SPARQL로 구현했습니다. 이번에는 pyrewire와 Datalog로 같은 구조를 다시 만들었습니다. 두 구현은 표면 문법은 다르지만, 아키텍처의 원칙은 같습니다. 확률적 모델에게 언어의 유연함을 맡기고, 심볼릭 엔진에게 판단의 일관성을 맡기는 것. Askitect에서 중요한 것은 바로 그 경계입니다.</p>

<hr />

<h3 id="더-알아보기">더 알아보기</h3>

<ul>
  <li><strong>pyrewire</strong>: <code class="language-plaintext highlighter-rouge">pip install pyrewire</code> (Python 3.11+)</li>
  <li><strong>wirelog</strong>: <a href="https://github.com/semantic-reasoning/wirelog">semantic-reasoning/wirelog</a></li>
  <li><strong>관련 이슈</strong>: <a href="https://github.com/semantic-reasoning/wirelog/issues/920">Parser accepts unsafe variables in negated body atoms</a></li>
</ul>

<h3 id="관련-글">관련 글</h3>

<ul>
  <li><a href="/essay/ai/2026/02/13/askitect-prototype/">Askitect 프로토타입: 청사진에서 동작하는 코드로</a></li>
  <li><a href="/essay/ai/2026/02/01/introducing-datalog/">SPARQL의 SQL 유사성이 주는 함정, 그리고 Datalog</a></li>
  <li><a href="/essay/ai/2026/06/01/datalog-fact-vs-data/">Datalog에서 ‘데이터’ 대신 ‘사실(Fact)’이라고 부르는 이유</a></li>
  <li><a href="/research/2026/06/06/datalog-pkm-wirelog/">WITH RECURSIVE로는 답답했던 질문들: 내 노트 그래프에 Datalog 얹기</a></li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="research" /><category term="Askitect" /><category term="Datalog" /><category term="LLM" /><category term="Neuro-symbolic AI" /><summary type="html"><![CDATA[지난번 Askitect 프로토타입 글에서는 LLM이 사용자의 자연어를 RDF 트리플로 바꾸고, 그 중간 상태를 SPARQL로 검사하는 구조를 구현했습니다. 사용자가 “친구랑 도쿄 가려고. 맛집이 제일 중요해.”라고 말하면 LLM은 hasDestination, hasCompanionType, hasPriority를 추출하고, SPARQL은 hasDuration과 hasBudget이 빠졌다는 사실을 찾아냈습니다.]]></summary></entry><entry><title type="html">WITH RECURSIVE로는 답답했던 질문들: 내 노트 그래프에 Datalog 얹기</title><link href="https://groou.com/research/2026/06/06/datalog-pkm-wirelog/" rel="alternate" type="text/html" title="WITH RECURSIVE로는 답답했던 질문들: 내 노트 그래프에 Datalog 얹기" /><published>2026-06-06T09:00:00+09:00</published><updated>2026-06-06T09:00:00+09:00</updated><id>https://groou.com/research/2026/06/06/datalog-pkm-wirelog</id><content type="html" xml:base="https://groou.com/research/2026/06/06/datalog-pkm-wirelog/"><![CDATA[<p>지난 글 <a href="/essay/datalog/2026/03/15/datalog-everyday-use/">Datalog, 일상의 도구가 될 수 있을까</a>의 마지막에 이렇게 적어두었습니다. “다음에는 이 고민을 코드로 옮겨볼 생각입니다. 가벼운 Datalog 엔진을 실제 일상적인 문제에 적용해보는 실험을 해보려 합니다.” 이번 글은 그 문장을 실제 코드로 옮겨본 기록입니다.</p>

<p>그때 일상적 유즈케이스 후보로 네 가지를 꼽았습니다. 노트 간 관계, 의존성 분석, 권한 추론, 설정 검증이었습니다. 이 중에서 가장 먼저 손이 간 것은 <strong>개인 지식 관리(PKM) 노트 그래프</strong>였습니다. 이유는 단순합니다. 별도의 데이터를 새로 만들 필요가 없었기 때문입니다. Obsidian이든 Logseq든, 이미 노트를 쓰고 있다면 링크와 태그가 쌓여 있습니다. 그 자체가 작은 그래프입니다.</p>

<p>엔진은 그동안 여러 글에서 슬쩍슬쩍 언급만 했던 <code class="language-plaintext highlighter-rouge">wirelog</code>를 씁니다. 직접 만들고 있는 Datalog 엔진입니다. 마침 Python 바인딩인 <code class="language-plaintext highlighter-rouge">pyrewire</code>도 나왔습니다. 지난 글에서 “가벼운 임베디드 Datalog 엔진을 Python에서 한 줄로 부르는” 형태가 가장 그럴듯하다고 적었는데, 적어도 실험 수준에서는 그 그림을 실제로 해볼 수 있게 된 셈입니다.</p>

<h2 id="왜-노트-그래프인가-sql이-답답해지는-바로-그-지점">왜 노트 그래프인가: SQL이 답답해지는 바로 그 지점</h2>

<p>지난 글에서 저는 스스로를 한 번 반박했습니다. “재귀적 추론이 필요한 일상적 상황이 드물다”고요. Datalog가 아무리 보기 좋고, SQL의 <code class="language-plaintext highlighter-rouge">WITH RECURSIVE</code>가 아무리 답답해도, 정작 그런 질문을 자주 던지지 않는다면 도구를 바꿀 이유가 없습니다. 불편함이 있어도 그 불편함을 느끼는 순간이 드물면, 결국 익숙한 도구가 이깁니다.</p>

<p>그런데 노트 그래프는 그 드문 예외에 꽤 정확히 걸립니다. Obsidian이나 Logseq에서 <code class="language-plaintext highlighter-rouge">[[링크]]</code>로 노트를 잇다 보면, 처음에는 한 단계 백링크만 봐도 충분합니다. 이 글이 어디에서 참조되는지, 이 노트가 어디를 가리키는지 정도는 기본 UI로도 잘 보입니다. 문제는 노트가 조금 쌓인 뒤입니다. 그때부터 궁금해지는 질문은 대개 한 단계 링크가 아니라 <strong>전이적(transitive) 관계</strong>입니다.</p>

<ul>
  <li>“이 노트에서 출발해 링크를 따라가면 닿는 노트가 전부 뭐지?”</li>
  <li>“A와 B는 몇 단계 만에 연결되지?”</li>
  <li>“어디서도 참조되지 않는 고아 노트는?”</li>
</ul>

<p>첫 번째 질문을 SQL로 옮기면 대략 이렇게 됩니다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">WITH</span> <span class="k">RECURSIVE</span> <span class="n">reach</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">)</span> <span class="k">AS</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">src</span><span class="p">,</span> <span class="n">dst</span> <span class="k">FROM</span> <span class="n">link</span>
    <span class="k">UNION</span>
    <span class="k">SELECT</span> <span class="n">r</span><span class="p">.</span><span class="n">src</span><span class="p">,</span> <span class="n">l</span><span class="p">.</span><span class="n">dst</span>
    <span class="k">FROM</span> <span class="n">reach</span> <span class="n">r</span> <span class="k">JOIN</span> <span class="n">link</span> <span class="n">l</span> <span class="k">ON</span> <span class="n">r</span><span class="p">.</span><span class="n">dst</span> <span class="o">=</span> <span class="n">l</span><span class="p">.</span><span class="n">src</span>
<span class="p">)</span>
<span class="k">SELECT</span> <span class="n">dst</span> <span class="k">FROM</span> <span class="n">reach</span> <span class="k">WHERE</span> <span class="n">src</span> <span class="o">=</span> <span class="s1">'Datalog 소개'</span><span class="p">;</span>
</code></pre></div></div>

<p>틀린 코드는 아닙니다. 실제로도 잘 동작합니다. 다만 이 코드를 매번 쓰고 싶지는 않습니다. <code class="language-plaintext highlighter-rouge">WITH RECURSIVE</code>의 앵커 항과 재귀 항을 나누어 적어야 하고, 어디서 재귀가 멈추는지, 중복은 어떻게 제거되는지, <code class="language-plaintext highlighter-rouge">UNION</code>을 쓸지 <code class="language-plaintext highlighter-rouge">UNION ALL</code>을 쓸지 계속 신경 써야 합니다. 여기에 조건이 하나 더 붙거나 관계가 두세 개로 늘어나면, 쿼리는 금방 “내가 지금 무엇을 묻고 있지?”라는 느낌을 줍니다.</p>

<p>Datalog에서는 같은 의미가 규칙 두 줄입니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">related</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">link</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">).</span>
<span class="ss">related</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">link</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Z</span><span class="p">),</span> <span class="ss">related</span><span class="p">(</span><span class="nv">Z</span><span class="p">,</span> <span class="nv">Y</span><span class="p">).</span>
</code></pre></div></div>

<p>“링크가 있으면 관련 있다. 그리고 한 다리 건너 관련된 것도 관련 있다.” 거의 말 그대로입니다. 여기서 제가 보고 싶었던 차이는 성능보다 표현 방식이었습니다. SQL에서는 재귀를 쿼리 안에 끼워 넣는 느낌이 강한 반면, Datalog에서는 재귀 관계 자체를 규칙으로 선언합니다. 종료는 고정점(fixed-point)에 도달했을 때 엔진이 판단합니다. 사람이 루프를 돌리는 것이 아니라, “이 규칙으로 더 이상 새 사실이 나오지 않을 때까지 적용하라”고 맡기는 쪽에 가깝습니다.</p>

<p>이번 실험의 핵심은 바로 이 차이를 실제 노트 데이터 위에서 확인하는 것이었습니다.</p>

<h2 id="실험-셋업-노트-볼트를-datalog-사실fact로-바꾸기">실험 셋업: 노트 볼트를 Datalog 사실(Fact)로 바꾸기</h2>

<p>먼저 노트 볼트를 스캔해 Datalog가 다룰 <strong>사실</strong>로 바꿉니다. 여기서는 일부러 복잡한 파서를 쓰지 않았습니다. 실험의 목적이 마크다운 파싱 자체가 아니라, 노트 그래프를 Datalog로 다뤄보는 데 있었기 때문입니다.</p>

<p>마크다운에서 뽑을 것은 두 가지뿐입니다. <code class="language-plaintext highlighter-rouge">[[위키링크]]</code>와 태그입니다. 위키링크는 노트 사이의 간선이 되고, 태그는 노트를 느슨하게 묶는 속성이 됩니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">re</span>
<span class="kn">from</span> <span class="n">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>

<span class="n">LINK</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">compile</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">\[\[([^\]|#]+)</span><span class="sh">"</span><span class="p">)</span>        <span class="c1"># [[제목]], [[제목|별칭]], [[제목#헤딩]] 모두 제목만
</span><span class="n">TAG</span>  <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">compile</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">(?:^|\s)#([A-Za-z0-9_\-/가-힣]+)</span><span class="sh">"</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">scan_vault</span><span class="p">(</span><span class="n">vault</span><span class="p">:</span> <span class="n">Path</span><span class="p">):</span>
    <span class="n">links</span><span class="p">,</span> <span class="n">tags</span> <span class="o">=</span> <span class="p">[],</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">md</span> <span class="ow">in</span> <span class="n">vault</span><span class="p">.</span><span class="nf">rglob</span><span class="p">(</span><span class="sh">"</span><span class="s">*.md</span><span class="sh">"</span><span class="p">):</span>
        <span class="n">title</span> <span class="o">=</span> <span class="n">md</span><span class="p">.</span><span class="n">stem</span>
        <span class="n">text</span> <span class="o">=</span> <span class="n">md</span><span class="p">.</span><span class="nf">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="sh">"</span><span class="s">utf-8</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">dst</span> <span class="ow">in</span> <span class="n">LINK</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
            <span class="n">links</span><span class="p">.</span><span class="nf">append</span><span class="p">((</span><span class="n">title</span><span class="p">,</span> <span class="n">dst</span><span class="p">.</span><span class="nf">strip</span><span class="p">()))</span>
        <span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">TAG</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
            <span class="n">tags</span><span class="p">.</span><span class="nf">append</span><span class="p">((</span><span class="n">title</span><span class="p">,</span> <span class="n">t</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">links</span><span class="p">,</span> <span class="n">tags</span>

<span class="n">links</span><span class="p">,</span> <span class="n">tags</span> <span class="o">=</span> <span class="nf">scan_vault</span><span class="p">(</span><span class="nc">Path</span><span class="p">(</span><span class="sh">"</span><span class="s">~/Notes</span><span class="sh">"</span><span class="p">).</span><span class="nf">expanduser</span><span class="p">())</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">notes linked: </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">links</span><span class="p">)</span><span class="si">}</span><span class="s"> edges, </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">tags</span><span class="p">)</span><span class="si">}</span><span class="s"> tag assignments</span><span class="sh">"</span><span class="p">)</span>
<span class="c1"># notes linked: 4123 edges, 1880 tag assignments
</span></code></pre></div></div>

<p>제 볼트는 노트 약 1,200개, 링크 4,123개, 태그 1,880건 규모였습니다. 아주 큰 그래프라고 하기는 어렵습니다. 하지만 사람이 백링크 패널을 열어 눈으로 따라가기에는 이미 충분히 큽니다. 직접 링크된 노트는 보이지만, 두 단계나 세 단계 뒤에 무엇이 있는지는 잘 보이지 않습니다. 딱 이 지점부터 “조회”보다 “도출”이 조금씩 의미를 갖기 시작합니다.</p>

<h2 id="규칙과-질의-pyrewire로-추론-돌리기">규칙과 질의: pyrewire로 추론 돌리기</h2>

<p><code class="language-plaintext highlighter-rouge">pyrewire</code>는 <code class="language-plaintext highlighter-rouge">wirelog</code>를 Python에서 쓰기 위한 바인딩입니다. Datalog 규칙을 문자열로 전달하고, Python 값으로 사실을 등록한 뒤, 추론 결과를 다시 Python 객체로 읽어올 수 있게 해줍니다. 이번 실험처럼 마크다운 파일을 Python으로 스캔하고, 그 결과를 Datalog 엔진에 전달한 다음, 다시 Python에서 후처리하는 흐름에는 이 형태가 잘 맞습니다.</p>

<p>이제 <code class="language-plaintext highlighter-rouge">pip install pyrewire</code>로 패키지를 설치한 뒤, 스키마와 규칙을 전달하고 위에서 추출한 사실을 등록합니다. 여기서는 <code class="language-plaintext highlighter-rouge">EasySession</code>을 사용했습니다. 이름 그대로 복잡한 런타임 설정을 직접 만지지 않고, 사실을 하나씩 <code class="language-plaintext highlighter-rouge">insert</code>하고 결과를 <code class="language-plaintext highlighter-rouge">snapshot</code>으로 읽는 형태입니다.</p>

<p>노트 제목에는 공백도 있고 한글도 있습니다. 이런 문자열을 Datalog 소스 문자열 안에 직접 이어 붙이면 따옴표와 이스케이프를 계속 신경 써야 합니다. 여기서는 Python 리스트로 값을 넘기기 때문에 그 문제를 피할 수 있습니다. 이 작은 편의가 실제 노트 데이터를 넣어볼 때는 꽤 중요합니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">pyrewire</span> <span class="kn">import</span> <span class="n">EasySession</span>

<span class="n">SRC</span> <span class="o">=</span> <span class="sh">"""</span><span class="s">
.decl link(src: symbol, dst: symbol)
.decl related(src: symbol, dst: symbol)

related(X, Y) :- link(X, Y).
related(X, Y) :- link(X, Z), related(Z, Y).
</span><span class="sh">"""</span>

<span class="k">with</span> <span class="nc">EasySession</span><span class="p">(</span><span class="n">SRC</span><span class="p">)</span> <span class="k">as</span> <span class="n">s</span><span class="p">:</span>
    <span class="k">for</span> <span class="n">src</span><span class="p">,</span> <span class="n">dst</span> <span class="ow">in</span> <span class="n">links</span><span class="p">:</span>
        <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">link</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">])</span>

    <span class="n">reach</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="nf">snapshot</span><span class="p">(</span><span class="sh">"</span><span class="s">related</span><span class="sh">"</span><span class="p">)</span>
    <span class="c1"># "Datalog 소개"에서 도달 가능한 모든 노트
</span>    <span class="n">seeds</span> <span class="o">=</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">y</span> <span class="nf">for </span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span> <span class="ow">in</span> <span class="n">reach</span> <span class="k">if</span> <span class="n">x</span> <span class="o">==</span> <span class="sh">"</span><span class="s">Datalog 소개</span><span class="sh">"</span><span class="p">)</span>
    <span class="nf">print</span><span class="p">(</span><span class="n">seeds</span><span class="p">)</span>
    <span class="c1"># ['ASP', 'Prolog', 'RDF', '고정점', '논리 프로그래밍', '재귀', ...]
</span></code></pre></div></div>

<p>실제로 실행하면 결과는 이런 식으로 나옵니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>['ASP', 'Prolog', 'RDF', '고정점', '논리 프로그래밍', '재귀', '추론', ...]
</code></pre></div></div>

<p>여기서 엔진에 직접 넣은 것은 <code class="language-plaintext highlighter-rouge">link</code>뿐입니다. 그런데 <code class="language-plaintext highlighter-rouge">snapshot("related")</code>를 읽어보면 직접 링크되지 않은 노트까지 들어옵니다. “Datalog 소개”에서 “논리 프로그래밍”으로 가고, 거기서 “Prolog”로 가고, 다시 “고정점”으로 이어지는 식의 사슬을 엔진이 바닥부터(bottom-up) 조립해 낸 것입니다.</p>

<p>이것이 <a href="/essay/ai/2026/06/01/datalog-fact-vs-data/">Datalog에서 ‘사실’이라고 부르는 이유</a>에서 이야기한 “도출(derivation)”입니다. 단순히 저장된 값을 꺼내는 조회가 아닙니다. 이미 알고 있는 사실과 규칙을 결합해서, 명시적으로 넣지 않았던 새 사실을 만들어 내는 일입니다. 노트 그래프에서는 <code class="language-plaintext highlighter-rouge">related("Datalog 소개", "고정점")</code> 같은 관계가 그렇게 생겨납니다.</p>

<p>전체 파이프라인을 그림으로 보면 이렇습니다.</p>

<pre><code class="language-mermaid">graph LR
    A[Obsidian 볼트&lt;br/&gt;*.md] --&gt;|regex 스캔| B[Facts&lt;br/&gt;link/2, tag/2]
    B --&gt;|EasySession.insert| C[wirelog 엔진&lt;br/&gt;고정점 평가]
    C --&gt;|snapshot / step| D[추론된 관계&lt;br/&gt;related, cotag]
    D --&gt; E[Python에서&lt;br/&gt;후처리·질의]
    style C fill:#e6f3ff,stroke:#0066cc,stroke-width:1px
</code></pre>

<h3 id="같은-태그로-묶인-노트-클러스터">같은 태그로 묶인 노트 클러스터</h3>

<p>링크만 보면 주로 “한 노트에서 다른 노트로 갈 수 있는가”를 묻게 됩니다. 여기에 태그를 사실로 더하면 조금 다른 것도 볼 수 있습니다. 예를 들어 “직접 링크하지 않았지만 같은 주제를 공유하는 노트는 무엇인가” 같은 것입니다.</p>

<p>이 경우에는 재귀가 필요하지 않습니다. 같은 태그를 가진 두 노트를 묶으면 됩니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">SRC</span> <span class="o">=</span> <span class="sh">"""</span><span class="s">
.decl tag(note: symbol, t: symbol)
.decl cotag(a: symbol, b: symbol)
cotag(A, B) :- tag(A, T), tag(B, T).
</span><span class="sh">"""</span>

<span class="k">with</span> <span class="nc">EasySession</span><span class="p">(</span><span class="n">SRC</span><span class="p">)</span> <span class="k">as</span> <span class="n">s</span><span class="p">:</span>
    <span class="k">for</span> <span class="n">note</span><span class="p">,</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">tags</span><span class="p">:</span>
        <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">tag</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="n">note</span><span class="p">,</span> <span class="n">t</span><span class="p">])</span>
    <span class="n">pairs</span> <span class="o">=</span> <span class="p">[(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="nf">for </span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="ow">in</span> <span class="n">s</span><span class="p">.</span><span class="nf">snapshot</span><span class="p">(</span><span class="sh">"</span><span class="s">cotag</span><span class="sh">"</span><span class="p">)</span> <span class="k">if</span> <span class="n">a</span> <span class="o">&lt;</span> <span class="n">b</span><span class="p">]</span>  <span class="c1"># 자기 자신·중복쌍 제거
</span>    <span class="nf">print</span><span class="p">(</span><span class="n">pairs</span><span class="p">[:</span><span class="mi">5</span><span class="p">])</span>
</code></pre></div></div>

<p>실행 결과는 대략 이런 모양입니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[('Datalog 소개', '논리 프로그래밍'),
 ('Datalog 소개', 'Prolog와 Datalog'),
 ('RDF 그래프', '시맨틱 태깅'),
 ('증분 계산', 'Differential Dataflow'),
 ('TBox와 ABox', 'LLM 추출 파이프라인')]
</code></pre></div></div>

<p>결과의 각 튜플은 같은 태그를 공유하는 노트 쌍입니다. 두 노트가 직접 링크되어 있다는 뜻은 아닙니다. 같은 태그를 통해 느슨하게 같은 주제권에 들어온다는 뜻에 가깝습니다.</p>

<p>여기서 <code class="language-plaintext highlighter-rouge">a &lt; b</code> 필터는 일부러 Datalog가 아니라 Python에서 했습니다. <code class="language-plaintext highlighter-rouge">cotag(A, B)</code> 규칙만 쓰면 같은 노트끼리의 쌍(<code class="language-plaintext highlighter-rouge">A = A</code>)도 나오고, <code class="language-plaintext highlighter-rouge">(A, B)</code>와 <code class="language-plaintext highlighter-rouge">(B, A)</code>가 함께 나옵니다. 이걸 Datalog 안에서 더 정교하게 처리할 수도 있겠지만, 여기서는 그럴 필요를 느끼지 못했습니다. 결과를 받아온 뒤 Python에서 한 줄로 걸러내는 편이 더 읽기 쉽습니다.</p>

<p>이 대목이 의외로 중요했습니다. Datalog를 쓰기 시작하면 모든 문제를 규칙으로 옮기고 싶어집니다. 하지만 그 순간 도구가 목적을 앞서기 쉽습니다. 이번 실험에서 제가 얻은 감각은 “어디까지 Datalog로 두고, 어디부터 Python으로 빼야 하는가”에 가까웠습니다.</p>

<h3 id="고아-노트는-datalog로-풀지-않았다">고아 노트는 Datalog로 풀지 않았다</h3>

<p>“어디서도 참조되지 않는 노트”도 처음에는 Datalog로 풀어볼까 생각했습니다. 하지만 이 질문은 부정(negation)이 필요합니다. 전체 노트 집합에서, 링크의 목적지로 등장한 노트를 빼면 됩니다.</p>

<p>그리고 다시 생각해보면, 이건 재귀도 아니고 전이도 아닙니다. 단순한 집합 차집합입니다. 그래서 그냥 Python에 맡겼습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">all_notes</span> <span class="o">=</span> <span class="p">{</span><span class="n">md</span><span class="p">.</span><span class="n">stem</span> <span class="k">for</span> <span class="n">md</span> <span class="ow">in</span> <span class="nc">Path</span><span class="p">(</span><span class="sh">"</span><span class="s">~/Notes</span><span class="sh">"</span><span class="p">).</span><span class="nf">expanduser</span><span class="p">().</span><span class="nf">rglob</span><span class="p">(</span><span class="sh">"</span><span class="s">*.md</span><span class="sh">"</span><span class="p">)}</span>
<span class="n">linked_to</span> <span class="o">=</span> <span class="p">{</span><span class="n">dst</span> <span class="nf">for </span><span class="p">(</span><span class="n">_src</span><span class="p">,</span> <span class="n">dst</span><span class="p">)</span> <span class="ow">in</span> <span class="n">links</span><span class="p">}</span>
<span class="n">orphans</span> <span class="o">=</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">all_notes</span> <span class="o">-</span> <span class="n">linked_to</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">orphans: </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">orphans</span><span class="p">)</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="n">orphans</span><span class="p">[:</span><span class="mi">10</span><span class="p">])</span>
</code></pre></div></div>

<p>제 볼트에서는 이런 식의 목록이 나왔습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orphans: 137
['2026년 읽을 논문', 'Askitect 메모', 'Datalog 실험 노트', 'GStreamer TODO',
 'Magic Sets 초안', 'PKM 정리', 'RDF 용어', '논문 아이디어', '블로그 초안',
 '회의 메모']
</code></pre></div></div>

<p>여기서 고아 노트는 “어디에서도 링크의 목적지로 등장하지 않은 노트”입니다. 노트 자체가 쓸모없다는 뜻은 아닙니다. 아직 다른 노트에서 참조하지 않았거나, 제목이 바뀌면서 링크가 끊겼거나, 단순히 독립적인 메모일 수 있습니다.</p>

<p>지난 글에서 “도구가 주는 가치가 전환 비용을 넘지 못하면 채택은 일어나지 않는다”고 적었는데, 그 기준을 스스로에게 적용한 셈입니다. 차집합 한 줄로 끝나는 일에 엔진을 부르는 건 과합니다. 이 정도 작업은 Python의 집합 연산이 더 직접적이고, 더 읽기 쉽습니다.</p>

<p>제 결론은 조금 좁습니다. <strong>Datalog는 재귀·전이가 등장하는 순간에만 제값을 합니다.</strong> 그 밖의 일까지 억지로 끌어안기 시작하면, 오히려 일상 도구로 쓰기 어려워집니다.</p>

<h2 id="wirelog가-일반-datalog와-다른-점-증분">wirelog가 일반 Datalog와 다른 점: 증분</h2>

<p>여기까지는 사실 어떤 Datalog 엔진으로도 됩니다. 링크를 넣고, 전이 폐쇄를 구하고, 결과를 읽는 정도라면 특별히 <code class="language-plaintext highlighter-rouge">wirelog</code>여야 할 이유는 없습니다. 제가 <code class="language-plaintext highlighter-rouge">wirelog</code>를 굳이 만들고 있는 이유는 <a href="/essay/ai/2026/02/14/differential-dataflow/">증분 계산</a> 쪽에 있습니다.</p>

<p>노트는 한 번에 완성되는 데이터가 아닙니다. 오늘 한 장 쓰고, 내일 한 장 고치고, 어떤 날은 링크 하나만 추가합니다. 이런 변화는 전형적인 “사실 하나 추가” 또는 “사실 하나 제거” 상황입니다. 나이브한 엔진이라면 이때 전체 추론을 처음부터 다시 돌립니다. 그래프가 작을 때는 괜찮지만, 데이터가 계속 자라면 금방 부담이 됩니다.</p>

<p><code class="language-plaintext highlighter-rouge">wirelog</code>는 timely-differential dataflow 위에서 <strong>바뀐 부분만</strong> 전파합니다.</p>

<p><code class="language-plaintext highlighter-rouge">step()</code>은 그 전파되는 델타를 <code class="language-plaintext highlighter-rouge">(relation, row, diff)</code>로 그대로 보여줍니다. <code class="language-plaintext highlighter-rouge">diff = +1</code>은 새로 도출된 사실이고, <code class="language-plaintext highlighter-rouge">-1</code>은 철회된 사실입니다. 전체 결과를 다시 덤프하는 것이 아니라, 이번 변경 때문에 무엇이 새로 생겼고 무엇이 사라졌는지를 보는 방식입니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">with</span> <span class="nc">EasySession</span><span class="p">(</span><span class="n">SRC_RELATED</span><span class="p">)</span> <span class="k">as</span> <span class="n">s</span><span class="p">:</span>
    <span class="k">for</span> <span class="n">src</span><span class="p">,</span> <span class="n">dst</span> <span class="ow">in</span> <span class="n">links</span><span class="p">:</span>
        <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">link</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">])</span>
    <span class="n">s</span><span class="p">.</span><span class="nf">snapshot</span><span class="p">(</span><span class="sh">"</span><span class="s">related</span><span class="sh">"</span><span class="p">)</span>               <span class="c1"># 초기 그래프 확정
</span>
    <span class="c1"># 새 노트 한 장: "Magic Sets" → "Datalog 소개" 로 링크
</span>    <span class="n">s</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="sh">"</span><span class="s">link</span><span class="sh">"</span><span class="p">,</span> <span class="p">[</span><span class="sh">"</span><span class="s">Magic Sets</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Datalog 소개</span><span class="sh">"</span><span class="p">])</span>
    <span class="k">for</span> <span class="n">relation</span><span class="p">,</span> <span class="n">row</span><span class="p">,</span> <span class="n">diff</span> <span class="ow">in</span> <span class="n">s</span><span class="p">.</span><span class="nf">step</span><span class="p">():</span>
        <span class="nf">print</span><span class="p">(</span><span class="n">relation</span><span class="p">,</span> <span class="n">row</span><span class="p">,</span> <span class="n">diff</span><span class="p">)</span>
    <span class="c1"># related ('Magic Sets', 'Datalog 소개') 1
</span>    <span class="c1"># related ('Magic Sets', 'Prolog') 1
</span>    <span class="c1"># related ('Magic Sets', '고정점') 1
</span>    <span class="c1"># ...   (Magic Sets에서 새로 닿게 된 노트만 +1로 출력)
</span></code></pre></div></div>

<p>출력은 전체 <code class="language-plaintext highlighter-rouge">related</code> 관계가 아니라, 이번 변경으로 새로 생긴 사실만 보여줍니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>related ('Magic Sets', 'Datalog 소개') 1
related ('Magic Sets', 'ASP') 1
related ('Magic Sets', 'Prolog') 1
related ('Magic Sets', 'RDF') 1
related ('Magic Sets', '고정점') 1
related ('Magic Sets', '논리 프로그래밍') 1
related ('Magic Sets', '재귀') 1
</code></pre></div></div>

<p>이 예제에서는 새 노트 “Magic Sets”가 “Datalog 소개”를 가리키는 링크 하나를 추가했습니다. 그러면 “Magic Sets”에서 직접 닿는 노트뿐 아니라, “Datalog 소개”를 거쳐 닿을 수 있는 노트들이 <code class="language-plaintext highlighter-rouge">related</code> 사실로 새로 생깁니다.</p>

<p>중요한 점은 기존 4천여 개의 관계를 다시 계산하지 않는다는 것입니다. “Magic Sets”가 새로 닿게 된 노트들만 증분으로 튀어나옵니다. 노트를 한 장 쓸 때마다 그래프 전체를 재평가하지 않아도 된다는 것. PKM처럼 끊임없이 조금씩 자라는 데이터에서는 이 성질이 특히 잘 맞습니다.</p>

<h2 id="한계와-남은-과제">한계와 남은 과제</h2>

<p>여기까지 해보고 나니, Datalog를 노트 도구 위에 얹을 때의 경계도 어느 정도 보였습니다. 노트 전체를 Datalog로 다루겠다는 접근은 과합니다. 직접 링크와 단순 검색은 기존 도구로 충분하고, Datalog가 필요한 지점은 생각보다 좁았습니다.</p>

<ul>
  <li><strong>대부분의 질문에는 여전히 백링크 패널과 <code class="language-plaintext highlighter-rouge">grep</code>이면 충분합니다.</strong> 특정 노트가 어디에서 언급되는지, 특정 단어가 어느 파일에 들어 있는지 확인하는 일은 기존 도구가 더 빠릅니다. Datalog가 이기는 구간은 “전이적 관계”와 “조금씩 갱신되는 그래프”로 꽤 좁습니다. 그 밖에서는 전환 비용이 가치를 넘지 못한다는 지난 글의 결론이 그대로 유효했습니다.</li>
  <li><strong>사실 추출의 품질이 전부입니다.</strong> 이번 실험에서는 정규식으로 <code class="language-plaintext highlighter-rouge">[[ ]]</code>와 태그를 뽑았습니다. 간단해서 좋지만, 코드블록 안의 <code class="language-plaintext highlighter-rouge">[[ ]]</code>나 별칭·헤딩 링크에서 어긋나기 쉽습니다. 결국 <a href="/essay/knowledge-graph/2026/03/22/semantic-tagging/">시맨틱 태깅</a>에서 이야기한 “문자열이 아닌 개념으로 잇기” 문제로 되돌아옵니다.</li>
  <li><strong>부정·집계는 의도적으로 Python에 남겼습니다.</strong> 이번 글에서 검증한 것은 재귀·전이까지입니다. 고아 노트 찾기나 클러스터 후처리까지 엔진 안으로 옮길 수도 있지만, 아직은 그게 더 좋은 선택인지 확신하지 못했습니다. 이 부분은 다음 과제로 둡니다.</li>
  <li><strong>엔진 성숙도.</strong> <code class="language-plaintext highlighter-rouge">wirelog</code>는 아직 0.x이고, <code class="language-plaintext highlighter-rouge">pyrewire</code>도 막 1.0.0이 나온 참입니다. 일상 도구라기보다 “일상 도구가 될 수 있는지 확인하는 실험대”에 가깝습니다.</li>
</ul>

<h2 id="마치며">마치며</h2>

<p>“특수한 도메인에서만 빛난다”던 Datalog가, 적어도 제 노트 위에서는 일상의 도구에 한 발 다가왔습니다. 다만 그 방식은 거창하지 않았습니다. Obsidian을 대체하거나, 노트 시스템 전체를 논리 프로그래밍으로 다시 만드는 쪽이 아니었습니다.</p>

<p>핵심은 Datalog로 모든 걸 하려 들지 않는 것이었습니다. 재귀와 전이가 나오는 자리에만 규칙 두 줄을 얹고, 나머지는 Python에 맡겼습니다. 그렇게 하니 지난 글에서 그렸던 “기존 워크플로우에 가볍게 얹히는 형태”가 그럭저럭 모양을 갖췄습니다.</p>

<p>다음에는 같은 엔진을 두 번째 후보였던 <strong>의존성/영향 분석</strong>에 적용해 보려 합니다. “이 모듈을 고치면 무엇이 깨지나”라는 질문은 노트 그래프보다 더 노골적으로 전이 관계를 요구합니다. 그리고 그동안 배경으로만 등장하던 <code class="language-plaintext highlighter-rouge">wirelog</code>의 내부 구조도 한 번 제대로 풀어볼 생각입니다. Datalog를 어떻게 nanoarrow 컬럼과 실행 계획으로 컴파일하는지에 대한 이야기입니다.</p>

<hr />

<h3 id="더-알아보기">더 알아보기</h3>

<ul>
  <li><strong>pyrewire</strong>: <code class="language-plaintext highlighter-rouge">pip install pyrewire</code> (Python 3.11+)</li>
  <li><strong>wirelog (엔진 본체)</strong>: <a href="https://github.com/semantic-reasoning/wirelog">semantic-reasoning/wirelog</a></li>
</ul>

<h3 id="관련-글">관련 글</h3>

<ul>
  <li><a href="/essay/datalog/2026/03/15/datalog-everyday-use/">Datalog, 일상의 도구가 될 수 있을까</a></li>
  <li><a href="/essay/knowledge-graph/2026/03/22/semantic-tagging/">시맨틱 태깅, 단순한 키워드를 넘어 지식의 연결로</a></li>
  <li><a href="/essay/ai/2026/06/01/datalog-fact-vs-data/">Datalog에서 ‘데이터’ 대신 ‘사실(Fact)’이라고 부르는 이유</a></li>
  <li><a href="/essay/ai/2026/02/14/differential-dataflow/">Datalog의 증분 계산</a></li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="research" /><category term="Datalog" /><category term="wirelog" /><category term="PyreWire" /><category term="Knowledge Graph" /><summary type="html"><![CDATA[지난 글 Datalog, 일상의 도구가 될 수 있을까의 마지막에 이렇게 적어두었습니다. “다음에는 이 고민을 코드로 옮겨볼 생각입니다. 가벼운 Datalog 엔진을 실제 일상적인 문제에 적용해보는 실험을 해보려 합니다.” 이번 글은 그 문장을 실제 코드로 옮겨본 기록입니다.]]></summary></entry><entry><title type="html">Datalog에서 ‘데이터’ 대신 ‘사실(Fact)’이라고 부르는 이유</title><link href="https://groou.com/essay/ai/2026/06/01/datalog-fact-vs-data/" rel="alternate" type="text/html" title="Datalog에서 ‘데이터’ 대신 ‘사실(Fact)’이라고 부르는 이유" /><published>2026-06-01T09:00:00+09:00</published><updated>2026-06-01T09:00:00+09:00</updated><id>https://groou.com/essay/ai/2026/06/01/datalog-fact-vs-data</id><content type="html" xml:base="https://groou.com/essay/ai/2026/06/01/datalog-fact-vs-data/"><![CDATA[<p>Datalog나 Prolog 같은 논리 프로그래밍 언어를 이야기하다 보면, 자연스럽게 “이건 데이터가 아니라 사실(fact)과 규칙(rule)으로 이루어져 있다”고 말하게 됩니다. 그런데 생각해보면 표에 들어있는 값이나 그래프에 들어있는 노드나 똑같은 ‘데이터’로 보일 수 있습니다. 그럼에도 굳이 ‘사실’이라는 다른 단어를 가져와서 쓰는 데에는 시스템의 전제와 관련된 분명한 이유가 있습니다. 이 작은 단어의 차이는 궁극적으로 시스템이 단순한 ‘조회’를 넘어 ‘추론(Inference)’과 ‘추리(Reasoning)’로 나아가기 위한 출발점이 됩니다.</p>

<h2 id="데이터data와-사실fact의-미묘한-차이">데이터(Data)와 사실(Fact)의 미묘한 차이</h2>

<p>우리가 보통 ‘데이터’라고 부를 때는 구조나 그릇을 먼저 떠올립니다. 관계형 데이터베이스(RDB)에서는 테이블의 행(row) 하나가 데이터입니다. <code class="language-plaintext highlighter-rouge">student</code> 테이블에 <code class="language-plaintext highlighter-rouge">(1, '홍길동', '컴퓨터공학')</code>이라는 레코드가 들어있다고 해보죠. 이 자체로는 그냥 값의 묶음입니다. 이 값이 무슨 의미인지는 테이블의 스키마와 애플리케이션의 로직이 알고 있습니다. 테이블이나 구조 밖으로 꺼내면 데이터는 의미를 잃기 쉽습니다. <code class="language-plaintext highlighter-rouge">(1, '홍길동', '컴퓨터공학')</code>만 떼어놓고 보면 이것이 학생 정보인지, 교수 정보인지, 동명이인인지 알기 어렵습니다.</p>

<p>반면 ‘사실(Fact)’은 명제(proposition)입니다. 참(true)이라고 선언된 완결된 문장에 가깝습니다. Datalog에서 이를 표현하면 <code class="language-plaintext highlighter-rouge">student(1, "홍길동", "컴퓨터공학").</code>이 됩니다. 이 문장은 그 자체로 하나의 주장을 담고 있습니다. 술어(predicate)인 <code class="language-plaintext highlighter-rouge">student</code>가 정보의 맥락을 감싸고 있기 때문입니다.</p>

<p>이 둘은 겉보기엔 비슷해 보이지만, 시스템이 이를 다루는 전제가 다릅니다. 데이터베이스에서 레코드는 “저장된 정보”이지만, Datalog에서 fact는 “참으로 알려진 명제”입니다.</p>

<h2 id="사실에서-출발하는-기계적-도출-추론inference">사실에서 출발하는 기계적 도출: 추론(Inference)</h2>

<p>‘사실(Fact)’이라는 명제가 준비되면, 시스템은 이를 바탕으로 연산을 시작할 수 있습니다. 여기서 등장하는 것이 ‘규칙(Rule)’입니다. 사실과 규칙이 만나면 시스템은 ‘추론(Inference)’을 수행합니다.</p>

<p>데이터베이스에서는 쿼리를 통해 데이터를 ‘조회’하거나 필터링합니다. 하지만 논리 프로그래밍 시스템은 규칙이라는 논리적 뼈대 위에서 기존의 사실들을 결합해 ‘새로운 사실’을 기계적으로 만들어냅니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">engineering_student</span><span class="p">(</span><span class="nv">Name</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">student</span><span class="p">(</span><span class="nv">ID</span><span class="p">,</span> <span class="nv">Name</span><span class="p">,</span> <span class="s2">"컴퓨터공학"</span><span class="p">).</span>
</code></pre></div></div>

<p>이 규칙은 “어떤 ID와 Name을 가진 사람이 컴퓨터공학 student라는 사실이 참이라면, 그 Name은 engineering_student라는 사실 또한 참이다”라는 선언입니다. Datalog 엔진(추론기, Inference Engine)은 이 규칙을 기존 사실들에 적용하여, 데이터베이스에는 명시적으로 저장되어 있지 않던 <code class="language-plaintext highlighter-rouge">engineering_student("홍길동").</code>이라는 새로운 사실을 연역해 냅니다.</p>

<p>추론(Inference)은 수학적이고 기계적인 과정입니다. 주어진 전제(사실)와 논리적 규칙(룰)에 어긋남 없이, 참이라고 보장할 수 있는 새로운 명제들을 바닥부터 쌓아 올리는 작업입니다. 필터링이 아니라 도출(derivation)에 가깝습니다.</p>

<h2 id="추론inference을-넘어선-추리reasoning">추론(Inference)을 넘어선 추리(Reasoning)</h2>

<p>그렇다면 추론(Inference)이 모이면 무엇이 될까요? 우리는 종종 추론을 넘어 ‘추리(Reasoning)’라는 단어를 마주하게 됩니다. 두 단어는 혼용되기도 하지만, 인공지능과 지식 표현의 관점에서 보면 그 층위가 조금 다릅니다.</p>

<p>추론(Inference)이 규칙을 적용해 명제를 하나하나 연역해 내는 미시적이고 기계적인 ‘과정(Process)’이라면, 추리(Reasoning)는 그러한 추론들을 엮어서 복잡한 문제를 해결하거나 결론에 도달하는 거시적인 ‘행위(Activity)’에 가깝습니다.</p>

<p>예를 들어, “홍길동은 어느 캠퍼스에서 수업을 듣는가?”라는 질문에 답하기 위해 시스템은 여러 단계의 도약을 거쳐야 합니다.</p>
<ol>
  <li>홍길동이 컴퓨터공학 학생이라는 사실을 찾습니다.</li>
  <li>컴퓨터공학 학생은 공과대학 소속이라는 규칙을 적용해 새로운 사실을 추론합니다.</li>
  <li>공과대학 소속 학과는 제2캠퍼스를 사용한다는 사실과 결합합니다.</li>
  <li>이 모든 기계적 추론(Inference)의 사슬을 엮어, 최종적으로 “홍길동은 제2캠퍼스에서 수업을 듣는다”는 결론을 냅니다.</li>
</ol>

<p>이 전체의 과정, 즉 목적을 가지고 여러 논리적 도출 단계를 설계하고 연결하여 해답을 찾아내는 행위 자체가 바로 추리(Reasoning)입니다. 단순히 A에서 B가 나온다는 기계적 단계를 넘어, 맥락을 이해하고 여러 사실과 규칙 사이의 경로를 탐색하는 인지적 성격을 띠게 됩니다.</p>

<h2 id="추론과-추리가-층위를-가진-것일까">추론과 추리가 층위를 가진 것일까?</h2>

<p>앞서 추론과 추리의 층위가 다르다고 언급했지만, 과연 이 둘 사이에 명확한 상하 관계나 층위가 존재하는지는 다시 생각해 볼 문제입니다. 이 차이를 명확히 보기 위해 Datalog 코드 두 개를 비교해 보겠습니다.</p>

<p>먼저 전형적인 <strong>추론(Inference)</strong>의 예제입니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">% 사실 (Fact)</span>
<span class="ss">parent</span><span class="p">(</span><span class="s2">"영희"</span><span class="p">,</span> <span class="s2">"철수"</span><span class="p">).</span>
<span class="ss">parent</span><span class="p">(</span><span class="s2">"철수"</span><span class="p">,</span> <span class="s2">"민수"</span><span class="p">).</span>

<span class="c1">% 규칙 (Rule)</span>
<span class="ss">grandparent</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">parent</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Z</span><span class="p">),</span> <span class="ss">parent</span><span class="p">(</span><span class="nv">Z</span><span class="p">,</span> <span class="nv">Y</span><span class="p">).</span>
</code></pre></div></div>

<p>엔진은 단순히 조부모 규칙에 맞춰 <code class="language-plaintext highlighter-rouge">grandparent("영희", "민수").</code>라는 새로운 사실을 기계적으로 도출합니다. 이것은 주어진 명제와 규칙에 따른 가장 기초적인 연역 과정입니다. Datalog 엔진의 관점에서 보면, 시스템 내부에는 목적 의식이나 거시적인 방향성이 존재하지 않으며 오직 규칙에 따른 이러한 평면적인 연산(Inference)만이 일어납니다.</p>

<p>반면, 이 시스템을 설계하고 질문을 던지는 사람의 관점이 개입되면 이것은 <strong>추리(Reasoning)</strong>의 영역으로 넘어갑니다. “사내망에서 발생한 보안 규정 위반자를 찾아라”라는 목적을 가진 다음의 예제를 보시죠.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">% 사실 (Fact)</span>
<span class="ss">employee</span><span class="p">(</span><span class="s2">"홍길동"</span><span class="p">,</span> <span class="s2">"개발팀"</span><span class="p">).</span>
<span class="ss">access_log</span><span class="p">(</span><span class="s2">"홍길동"</span><span class="p">,</span> <span class="s2">"보안서버A"</span><span class="p">,</span> <span class="s2">"2026-06-01"</span><span class="p">).</span>
<span class="ss">policy</span><span class="p">(</span><span class="s2">"보안서버A"</span><span class="p">,</span> <span class="s2">"보안팀"</span><span class="p">).</span>

<span class="c1">% 규칙 (Rule): 단계별 논리 설계</span>
<span class="c1">% 1. 인가된 접근 권한 정의</span>
<span class="ss">authorized_access</span><span class="p">(</span><span class="nv">Person</span><span class="p">,</span> <span class="nv">Server</span><span class="p">)</span> <span class="p">:-</span> 
    <span class="ss">employee</span><span class="p">(</span><span class="nv">Person</span><span class="p">,</span> <span class="nv">Dept</span><span class="p">),</span> 
    <span class="ss">policy</span><span class="p">(</span><span class="nv">Server</span><span class="p">,</span> <span class="nv">Dept</span><span class="p">).</span>

<span class="c1">% 2. 보안 위반 탐지</span>
<span class="ss">security_violation</span><span class="p">(</span><span class="nv">Person</span><span class="p">,</span> <span class="nv">Server</span><span class="p">,</span> <span class="nv">Date</span><span class="p">)</span> <span class="p">:-</span> 
    <span class="ss">access_log</span><span class="p">(</span><span class="nv">Person</span><span class="p">,</span> <span class="nv">Server</span><span class="p">,</span> <span class="nv">Date</span><span class="p">),</span> 
    <span class="ss">not</span> <span class="ss">authorized_access</span><span class="p">(</span><span class="nv">Person</span><span class="p">,</span> <span class="nv">Server</span><span class="p">).</span>
</code></pre></div></div>

<p>엔진 입장에서는 이전 예제와 똑같이 주어진 규칙(<code class="language-plaintext highlighter-rouge">authorized_access</code>와 <code class="language-plaintext highlighter-rouge">security_violation</code>)을 기계적으로 순차 적용하여 <code class="language-plaintext highlighter-rouge">security_violation("홍길동", "보안서버A", "2026-06-01").</code>이라는 사실을 하나 더 만들어냈을 뿐입니다.</p>

<p>하지만 사람의 눈으로 보면 이야기가 다릅니다. 직원 정보, 서버 정책, 접속 로그라는 각기 다른 맥락의 사실들을 엮고, ‘권한 확인’과 ‘위반 탐지’라는 다단계 논리 경로를 설계하여 “홍길동이 보안을 위반했다”는 해답에 도달했습니다. 이것은 거시적 목적을 가진 추리(Reasoning)입니다.</p>

<p>결국 추론과 추리의 층위는 시스템 자체의 논리적 구조라기보다, 그것을 바라보는 ‘관점의 차이’에서 비롯됩니다. 밑바닥에서 연산이 돌아가는 기계의 눈으로 보면 모든 것이 동등한 추론(Inference)이지만, 질문을 던지고 해답을 해석하는 사람의 눈으로 보면 그것은 목적을 가진 추리(Reasoning)로 묶입니다. 단순한 연역 엔진이 지식 표현(Knowledge Representation)의 강력한 도구가 되는 이유는, 이 평면적인 연산 과정에 인간이 의미와 맥락을 부여해 입체적인 추리의 과정으로 이끌어낼 수 있기 때문입니다.</p>

<p><img src="/images/2026-06-01/inference-vs-reasoning.svg" alt="추론과 추리의 구조적 차이" /></p>

<h2 id="닫힌-데이터에서-열린-지식으로">닫힌 데이터에서 열린 지식으로</h2>

<p>단순히 값을 기록하고 꺼내보는 것이 목적이라면 ‘데이터’라는 이름으로도 충분했을 것입니다. 데이터 대신 굳이 ‘사실(Fact)’이라는 단어를 밑바탕에 두는 것은, 이 시스템의 목적지가 단순한 조회(Query)가 아니라 지식의 확장(Reasoning)에 있기 때문입니다.</p>

<p>참거짓을 판별할 수 있는 명제들을 선언하고(Fact), 그 명제들을 규칙(Rule)으로 엮어 새로운 명제를 기계적으로 도출하며(Inference), 마침내 복잡한 문제의 해답을 찾아가는 과정(Reasoning). 표의 칸을 채우는 데이터 관리(Data Management)의 관점을 벗어나, 사실에서 출발해 논리를 전개하는 지식 표현(Knowledge Representation)의 세계관이 이 작은 용어 차이에 담겨 있습니다.</p>

<hr />

<h3 id="관련-글">관련 글</h3>

<ul>
  <li><a href="/essay/ai/2026/02/01/introducing-datalog/">SPARQL의 SQL 유사성이 주는 함정, 그리고 Datalog</a></li>
  <li><a href="/essay/ai/2026/02/09/symbolic-ai/">기호주의(Symbolism) 인공지능: 블랙박스를 여는 열쇠</a></li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="essay" /><category term="ai" /><category term="Datalog" /><category term="Logic Programming" /><category term="Knowledge Representation" /><category term="Inference" /><category term="Reasoning" /><summary type="html"><![CDATA[Datalog나 Prolog 같은 논리 프로그래밍 언어를 이야기하다 보면, 자연스럽게 “이건 데이터가 아니라 사실(fact)과 규칙(rule)으로 이루어져 있다”고 말하게 됩니다. 그런데 생각해보면 표에 들어있는 값이나 그래프에 들어있는 노드나 똑같은 ‘데이터’로 보일 수 있습니다. 그럼에도 굳이 ‘사실’이라는 다른 단어를 가져와서 쓰는 데에는 시스템의 전제와 관련된 분명한 이유가 있습니다. 이 작은 단어의 차이는 궁극적으로 시스템이 단순한 ‘조회’를 넘어 ‘추론(Inference)’과 ‘추리(Reasoning)’로 나아가기 위한 출발점이 됩니다.]]></summary></entry><entry><title type="html">TBox로 LLM 환각을 줄이는 방법: Datalog Facts 수준의 스키마 제약</title><link href="https://groou.com/research/2026/05/13/datalog-tbox-llm-hallucination/" rel="alternate" type="text/html" title="TBox로 LLM 환각을 줄이는 방법: Datalog Facts 수준의 스키마 제약" /><published>2026-05-13T16:00:00+09:00</published><updated>2026-05-13T16:00:00+09:00</updated><id>https://groou.com/research/2026/05/13/datalog-tbox-llm-hallucination</id><content type="html" xml:base="https://groou.com/research/2026/05/13/datalog-tbox-llm-hallucination/"><![CDATA[<p>LLM으로 비정형 텍스트에서 구조화된 데이터를 뽑아내다 보면 결국 한 가지 문제가 남습니다. 바로 환각(hallucination)입니다. 모델은 입력에 없는 관계를 만들어내거나, 스스로 새로운 클래스를 정의하거나, 원문에 없는 속성값을 그럴듯하게 채워 넣습니다.</p>

<p>이번 글에서는 이 문제를 프롬프트만으로 억제하는 대신, TBox와 Datalog facts를 이용해 구조적으로 제약하는 방법을 정리해 보았습니다. 완전히 새로운 기법이라기보다는, 온톨로지와 논리 프로그래밍 쪽에서 이미 익숙한 구분을 LLM 추출 파이프라인에 적용해 보는 시도에 가깝습니다.</p>

<h2 id="문제-llm은-스키마도-데이터도-동시에-생성한다">문제: LLM은 스키마도 데이터도 동시에 생성한다</h2>

<p>LLM에게 “이 텍스트에서 지식 그래프를 뽑아줘”라고 요청하면, 모델은 단순히 사실(fact)만 추출하지 않습니다. 어떤 개념을 클래스로 잡을지, 어떤 관계를 속성(property)으로 모델링할지, 심지어 어떤 계층 구조를 둘지까지 즉석에서 결정합니다. 스키마 설계와 데이터 채움을 한 번에 처리하는 셈입니다.</p>

<p>문제는 여기서부터 시작됩니다. LLM은 입력 텍스트에 실제로 존재하는 정보와, 문맥상 있을 법하다고 판단한 정보를 늘 엄격하게 구분하지 않습니다. 예를 들어 <code class="language-plaintext highlighter-rouge">Person</code>이라는 클래스를 만들고 나면, 이어서 <code class="language-plaintext highlighter-rouge">hasJob</code>이나 <code class="language-plaintext highlighter-rouge">livesIn</code> 같은 속성도 자연스럽게 붙일 수 있다고 생각합니다. 원문에 그런 정보가 없어도 말입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>입력: "홍길동은 2024년 한국에서 태어났다."

LLM 출력 (환각 포함):
Person(홍길동)
bornIn(홍길동, 한국)
bornYear(홍길동, 2024)       # OK
hasNationality(홍길동, 한국)  # 애매함
hasJob(홍길동, ???)          # 원문에 없음
age(홍길동, 1)               # 계산 결과이지 추출된 사실은 아님
</code></pre></div></div>

<p>스키마와 데이터를 동시에 생성하게 두면 이런 식의 오염이 쉽게 섞입니다.</p>

<h2 id="tbox와-abox의-구분">TBox와 ABox의 구분</h2>

<p>Description Logic(DL)이나 OWL에서 온톨로지는 보통 두 레이어로 나누어 설명합니다.</p>

<ul>
  <li><strong>TBox (Terminological Box)</strong>: 클래스, 속성, 계층 관계, 제약을 정의하는 스키마 레이어입니다. “Person은 Agent의 하위 클래스다”, “hasAge의 치역(range)은 xsd:integer다” 같은 내용이 여기에 들어갑니다.</li>
  <li><strong>ABox (Assertional Box)</strong>: 실제 개별 인스턴스에 대한 사실 레이어입니다. “홍길동은 Person이다”, “홍길동의 나이는 30이다” 같은 주장이 여기에 해당합니다.</li>
</ul>

<p>Datalog 관점으로 옮겨 보면 TBox는 주로 규칙(rule)과 제약에 가깝고, ABox는 facts에 해당합니다. 규칙은 새로운 사실을 추론하는 데 쓰이고, facts는 추론의 출발점이 되는 기저 데이터입니다.</p>

<h2 id="tbox를-추출-제약으로-제공하기">TBox를 추출 제약으로 제공하기</h2>

<p>실제로 프롬프트에 스키마를 명시하는 방식은 이미 많이 쓰입니다. 예를 들어 “<code class="language-plaintext highlighter-rouge">Person</code>, <code class="language-plaintext highlighter-rouge">Organization</code>, <code class="language-plaintext highlighter-rouge">Event</code> 클래스만 사용하고, 속성은 <code class="language-plaintext highlighter-rouge">hasName</code>, <code class="language-plaintext highlighter-rouge">worksFor</code>, <code class="language-plaintext highlighter-rouge">occursAt</code>만 허용한다”는 식으로 모델에게 작업 범위를 알려주는 방식입니다.</p>

<p>이 방식은 어느 정도 효과가 있습니다. 허용된 클래스와 속성을 명시하면, 모델이 임의의 클래스를 새로 만들어내는 빈도는 줄어듭니다. 다만 이것만으로 충분하다고 보기는 어렵습니다.</p>

<h2 id="왜-부분적으로만-효과가-있는가">왜 부분적으로만 효과가 있는가</h2>

<p>문제는 LLM이 프롬프트 지시를 항상 완벽하게 따르지는 않는다는 점입니다. 특히 다음과 같은 상황에서 스키마 밖의 출력이 섞입니다.</p>

<p><strong>1. 스키마가 커질수록 준수율이 떨어집니다</strong></p>

<p>TBox가 50개, 100개의 클래스와 속성을 포함하기 시작하면 모델이 컨텍스트 안에서 이를 모두 안정적으로 따라가기가 어려워집니다. 주의가 분산되고, 어느 순간 스키마에 없는 표현이 섞여 들어옵니다.</p>

<p><strong>2. 모호한 입력에서 스키마를 스스로 확장합니다</strong></p>

<p>텍스트에 기존 스키마로 매핑하기 어려운 개념이 등장하면, LLM은 가장 가까워 보이는 표현을 새로 만들어내려는 경향을 보입니다. TBox에 없는 속성을 추가하거나, 기존 클래스를 임의로 세분화하는 식입니다.</p>

<p><strong>3. TBox 자체의 해석에서도 드리프트가 생깁니다</strong></p>

<p>“TBox를 줬으니 그대로 따르겠지”라고 기대하지만, 모델은 그 TBox를 자기 방식으로 해석합니다. <code class="language-plaintext highlighter-rouge">hasJob</code>이 없으면 <code class="language-plaintext highlighter-rouge">worksAt</code>을 쓰거나, <code class="language-plaintext highlighter-rouge">occupation</code>을 만들어내기도 합니다.</p>

<p>결국 TBox를 프롬프트로 제공하는 것만으로는 스키마 일탈을 안정적으로 막기 어렵습니다.</p>

<h2 id="핵심-아이디어-tbox는-사람이-만들고-llm은-abox만-채운다">핵심 아이디어: TBox는 사람이 만들고, LLM은 ABox만 채운다</h2>

<p>여기서는 역할을 나누는 편이 낫습니다.</p>

<p><img src="/assets/images/posts/2026-05-13-datalog-tbox-llm-hallucination/tbox-abox-role.svg" alt="TBox와 ABox 역할 분리" style="width: 100%; margin: 24px 0;" /></p>

<p>이 구성에서 LLM은 TBox를 생성하지 않습니다. 이미 만들어진 TBox를 참조 스키마로 받아 보고, 그 범위 안에서만 facts를 추출합니다. 작은 차이처럼 보이지만 실제 파이프라인에서는 큰 차이를 만듭니다.</p>

<p>모델에게 주어지는 작업도 단순해집니다.</p>

<ul>
  <li><del>“이 텍스트에서 온톨로지를 설계하고 인스턴스를 추출해”</del> (스키마 설계 + 데이터 추출)</li>
  <li><strong>“이 TBox에 정의된 클래스와 속성만 써서, 텍스트에 실제로 나오는 사실만 Datalog facts로 뽑아줘”</strong> (데이터 추출만)</li>
</ul>

<p>작업 범위가 ABox 채우기로 좁혀지면, 모델이 임의로 결정할 수 있는 영역도 같이 줄어듭니다.</p>

<h2 id="datalog-facts-수준에서의-추출">Datalog Facts 수준에서의 추출</h2>

<p>이 접근에서는 LLM의 출력 형식을 Datalog facts로 제한하는 것이 중요합니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">% TBox (사전에 정의됨, LLM이 수정 불가)</span>
<span class="ss">subClassOf</span><span class="p">(</span><span class="nv">Person</span><span class="p">,</span> <span class="nv">Agent</span><span class="p">).</span>
<span class="ss">subClassOf</span><span class="p">(</span><span class="nv">Organization</span><span class="p">,</span> <span class="nv">Agent</span><span class="p">).</span>
<span class="ss">domain</span><span class="p">(</span><span class="ss">bornIn</span><span class="p">,</span> <span class="nv">Person</span><span class="p">).</span>
<span class="ss">range</span><span class="p">(</span><span class="ss">bornIn</span><span class="p">,</span> <span class="nv">Place</span><span class="p">).</span>
<span class="ss">domain</span><span class="p">(</span><span class="ss">hasName</span><span class="p">,</span> <span class="nv">Agent</span><span class="p">).</span>
<span class="ss">range</span><span class="p">(</span><span class="ss">hasName</span><span class="p">,</span> <span class="ss">xsd_string</span><span class="p">).</span>

<span class="c1">% LLM이 추출하는 ABox (facts만)</span>
<span class="nv">Person</span><span class="p">(</span><span class="err">홍길동</span><span class="p">).</span>
<span class="nv">Place</span><span class="p">(</span><span class="err">한국</span><span class="p">).</span>
<span class="ss">bornIn</span><span class="p">(</span><span class="err">홍길동</span><span class="p">,</span> <span class="err">한국</span><span class="p">).</span>
<span class="ss">hasName</span><span class="p">(</span><span class="err">홍길동</span><span class="p">,</span> <span class="s2">"홍길동"</span><span class="p">).</span>
</code></pre></div></div>

<p>여기서 LLM이 <code class="language-plaintext highlighter-rouge">hasJob(홍길동, 의사)</code>를 만들어내려 해도, TBox에 <code class="language-plaintext highlighter-rouge">hasJob</code>이 없으면 출력 검증 단계에서 걸러낼 수 있습니다. 단순히 프롬프트로 “이것만 써라”라고 지시하는 것이 아니라, 파싱과 검증 레벨에서 TBox 밖의 심볼을 차단하는 방식입니다.</p>

<p>Datalog facts 형식은 이런 검증을 구현하기에 적합합니다. 각 fact가 <code class="language-plaintext highlighter-rouge">predicate(arg1, arg2, ...)</code> 형태이므로, predicate name이 TBox에 등록되어 있는지 확인하는 과정은 단순한 집합 조회(set lookup)로 처리할 수 있습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">allowed_predicates</span> <span class="o">=</span> <span class="p">{</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">tbox</span><span class="p">.</span><span class="nf">get_all_predicates</span><span class="p">()}</span>

<span class="k">def</span> <span class="nf">validate_fact</span><span class="p">(</span><span class="n">fact</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="n">predicate</span> <span class="o">=</span> <span class="nf">parse_predicate_name</span><span class="p">(</span><span class="n">fact</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">predicate</span> <span class="ow">in</span> <span class="n">allowed_predicates</span>
</code></pre></div></div>

<p>TBox에 없는 predicate는 이 단계에서 바로 거부됩니다. 모델이 아무리 그럴듯한 표현을 만들어내도, 스키마 밖이면 파이프라인 안으로 들어오지 못합니다.</p>

<h2 id="실제로-어떻게-생겼나">실제로 어떻게 생겼나</h2>

<p>프롬프트 구조는 대략 다음과 같이 잡을 수 있습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>당신은 텍스트에서 Datalog facts를 추출하는 시스템입니다.

[스키마 (TBox)]
허용된 클래스: Person, Organization, Place, Event
허용된 속성:
  - hasName(Agent, xsd:string)
  - bornIn(Person, Place)
  - worksFor(Person, Organization)
  - occursAt(Event, Place)
  - occursOn(Event, xsd:date)

[규칙]
1. 위 클래스와 속성 외에는 어떤 것도 사용하지 마세요.
2. 텍스트에 명시적으로 언급된 사실만 추출하세요.
3. 추론이나 상식적 보완은 하지 마세요.
4. 확실하지 않으면 출력하지 마세요.

[입력 텍스트]
...

[출력 형식]
Datalog facts만 출력:
Person(개체명).
hasName(개체명, "이름").
...
</code></pre></div></div>

<p>이때 모델은 TBox를 참고해 허용된 클래스와 속성 안에서만 facts를 만들어야 합니다. 물론 이 판단을 모델에게만 맡기면 다시 같은 문제가 생기므로, 출력 이후에는 반드시 검증 단계를 둡니다.</p>

<h2 id="fallback으로-unmapped를-둔다">Fallback으로 unmapped를 둔다</h2>

<p>실제로는 TBox에 딱 맞지 않지만 바로 버리기 어려운 정보가 자주 나옵니다. 이때 LLM에게 신규 predicate를 만들 권한을 주면 다시 원래 문제로 돌아갑니다. 대신 어디에도 맞지 않는 후보를 임시로 담아두는 fallback 슬롯을 둘 수 있습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">% === TBox: 허용된 vocabulary ===</span>

<span class="c1">% 엔티티 타입</span>
<span class="p">.</span><span class="ss">type</span> <span class="nv">Paper</span>
<span class="p">.</span><span class="ss">type</span> <span class="nv">Concept</span>
<span class="p">.</span><span class="ss">type</span> <span class="nv">Model</span> <span class="o">&lt;:</span> <span class="nv">Concept</span>
<span class="p">.</span><span class="ss">type</span> <span class="nv">Person</span>
<span class="p">.</span><span class="ss">type</span> <span class="nv">Dataset</span>

<span class="c1">% 관계 시그니처</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">authored_by</span><span class="p">(</span><span class="ss">paper</span><span class="o">:</span> <span class="nv">Paper</span><span class="p">,</span> <span class="ss">person</span><span class="o">:</span> <span class="nv">Person</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">introduced_in</span><span class="p">(</span><span class="ss">concept</span><span class="o">:</span> <span class="nv">Concept</span><span class="p">,</span> <span class="ss">paper</span><span class="o">:</span> <span class="nv">Paper</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">published_in</span><span class="p">(</span><span class="ss">paper</span><span class="o">:</span> <span class="nv">Paper</span><span class="p">,</span> <span class="ss">year</span><span class="o">:</span> <span class="ss">number</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">trained_on</span><span class="p">(</span><span class="ss">model</span><span class="o">:</span> <span class="nv">Model</span><span class="p">,</span> <span class="ss">dataset</span><span class="o">:</span> <span class="nv">Dataset</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">extends</span><span class="p">(</span><span class="ss">child</span><span class="o">:</span> <span class="nv">Model</span><span class="p">,</span> <span class="ss">parent</span><span class="o">:</span> <span class="nv">Model</span><span class="p">)</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">cites</span><span class="p">(</span><span class="ss">citing</span><span class="o">:</span> <span class="nv">Paper</span><span class="p">,</span> <span class="ss">cited</span><span class="o">:</span> <span class="nv">Paper</span><span class="p">)</span>

<span class="c1">% fallback buffer</span>
<span class="p">.</span><span class="ss">decl</span> <span class="ss">unmapped</span><span class="p">(</span><span class="ss">subject</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">raw_predicate</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">object</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">,</span> <span class="ss">source</span><span class="o">:</span> <span class="ss">symbol</span><span class="p">)</span>
</code></pre></div></div>

<p>프롬프트에서는 다음과 같이 지시합니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>추출은 위 타입과 관계만 사용한다.
다른 술어가 필요해 보이면 새 술어를 만들지 말고 unmapped에 넣는다.
unmapped의 source에는 근거가 된 원문 일부를 그대로 둔다.
TBox를 수정하거나 확장할 권한은 없다.
</code></pre></div></div>

<p>예를 들어 논문 요약에서 “Transformer was pretrained on WMT 2014 English-German”이라는 문장이 나왔는데, 현재 TBox에 <code class="language-plaintext highlighter-rouge">evaluated_on</code>이나 <code class="language-plaintext highlighter-rouge">pretrained_on</code>이 없다면 모델은 새 predicate를 만들지 않습니다. 대신 다음과 같이 남깁니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">unmapped</span><span class="p">(</span><span class="ss">model_transformer</span><span class="p">,</span> <span class="ss">pretrained_on</span><span class="p">,</span> <span class="ss">dataset_wmt_2014_en_de</span><span class="p">,</span>
         <span class="s2">"Transformer was pretrained on WMT 2014 English-German"</span><span class="p">).</span>
</code></pre></div></div>

<p>이렇게 하면 LLM은 ABox 추출자로만 일하고, 나머지는 사람이 통제합니다. <code class="language-plaintext highlighter-rouge">unmapped</code>에 비슷한 패턴이 계속 쌓이면 사람이 보고 판단할 수 있습니다. “이건 자주 등장하는 관계이니 TBox에 추가하자”라고 결정할 수도 있고, “이건 문맥상 노이즈에 가깝다”라고 버릴 수도 있습니다. 중요한 점은 LLM이 직접 TBox를 건드리지 않는다는 것입니다.</p>

<p>엔티티 ID에도 타입 prefix를 강제하면 검증이 조금 더 쉬워집니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>paper_attention_2017      (Paper)
concept_self_attention    (Concept)
model_transformer         (Model)
person_vaswani            (Person)
dataset_wmt_2014_en_de    (Dataset)
</code></pre></div></div>

<p>모델이 새 엔티티를 만들 때 prefix를 고르게 하면 자연스럽게 타입 분류를 한 번 더 하게 됩니다. 잘못된 타입 추론도 나중에 <code class="language-plaintext highlighter-rouge">grep</code>이나 간단한 validator로 잡아낼 수 있습니다.</p>

<p>이 구성의 장점은 에러가 조용히 사라지지 않는다는 데 있습니다. TBox가 없으면 LLM은 적당해 보이는 관계를 만들어 fact를 계속 채워 넣습니다. 어떤 것이 원문에 근거한 추출이고 어떤 것이 환각인지 나중에 구분하기 어렵습니다. 반면 TBox와 <code class="language-plaintext highlighter-rouge">unmapped</code> fallback을 같이 쓰면, 추출하지 못한 후보가 한곳에 모입니다. 그 목록은 단순한 실패 로그가 아니라, 현재 위키나 온톨로지의 빈 곳이 어디인지 보여주는 지도에 가깝습니다.</p>

<p>결국 TBox는 LLM이 모르는 것을 모른다고 표시하게 만드는 장치입니다. 환각을 완전히 막는다기보다는, 환각으로 번질 수 있는 지점을 드러나게 만드는 쪽에 가깝습니다. provenance를 함께 남기는 이유도 같은 맥락입니다.</p>

<h2 id="한계와-남은-과제">한계와 남은 과제</h2>

<p>물론 이 접근이 모든 문제를 해결하지는 못합니다.</p>

<p><strong>TBox가 커버하지 못하는 정보는 정식 fact가 되지 못합니다.</strong> <code class="language-plaintext highlighter-rouge">unmapped</code>를 두면 완전히 잃어버리지는 않지만, 쿼리 가능한 지식으로 바로 들어오지는 않습니다. 도메인을 미리 잘 정의해 두지 않으면 recall이 떨어지고, 결국 TBox 설계와 갱신 주기가 병목이 됩니다.</p>

<p><strong>같은 클래스 안에서의 환각은 여전히 가능합니다.</strong> 예를 들어 <code class="language-plaintext highlighter-rouge">Person</code>이 허용되어 있을 때, 텍스트에 언급되지 않은 사람 이름을 만들어내는 것은 TBox만으로 막기 어렵습니다. 개체 자체를 조작하는 ABox 수준의 환각에는 별도의 NER 기반 검증이나 텍스트 어라인먼트가 필요합니다.</p>

<p><strong>TBox 설계 자체도 쉽지 않습니다.</strong> 도메인 전문가가 스키마를 미리 설계해야 하므로 초기 비용이 듭니다. 다만 한 번 잘 만들어 두면 여러 추출 작업에서 재사용할 수 있다는 장점도 있습니다.</p>

<h2 id="왜-datalog인가">왜 Datalog인가</h2>

<p>OWL Full처럼 표현력이 높은 온톨로지 언어 대신 Datalog를 택하는 데는 몇 가지 이유가 있습니다.</p>

<ul>
  <li><strong>LLM 출력이 파싱하기 쉽습니다.</strong> Datalog facts는 문법이 단순해서, 모델이 생성한 텍스트를 파싱하고 검증하기 쉽습니다.</li>
  <li><strong>표현력과 decidability 사이의 균형이 좋습니다.</strong> Datalog는 재귀를 허용하면서도 decidable합니다. 추출된 facts 위에 규칙 기반 추론을 적용할 수 있습니다.</li>
  <li><strong>기존 Datalog 엔진과 바로 연동됩니다.</strong> 추출된 facts를 Soufflé나 DLV 같은 엔진에 바로 넣으면 TBox의 규칙으로 추론을 돌릴 수 있습니다.</li>
</ul>

<p>이렇게 구성하면 파이프라인도 비교적 단순해집니다.</p>

<p><img src="/assets/images/posts/2026-05-13-datalog-tbox-llm-hallucination/extraction-pipeline.svg" alt="LLM과 Datalog 추출 파이프라인" style="width: 100%; margin: 24px 0;" /></p>

<p>LLM은 facts 추출만 담당하고, 추론은 Datalog 엔진이 담당합니다. 각 컴포넌트의 책임이 분리되기 때문에 디버깅도 쉬워집니다. 문제가 생겼을 때 모델이 잘못 추출한 것인지, TBox 규칙이 잘못된 것인지, 검증기가 느슨한 것인지 따로 확인할 수 있습니다.</p>

<h2 id="마치며">마치며</h2>

<p>이 아이디어의 본질은 LLM이 할 수 있는 결정의 자유도를 줄이는 것입니다. 스키마 설계는 모델 밖에서 이루어지고, 모델은 “이 텍스트에 이 사실이 실제로 존재하는가?”만 판단합니다. 환각을 없애는 방법이라기보다는, 환각이 발생할 수 있는 공간을 구조적으로 좁히는 접근에 가깝습니다.</p>

<p>완벽한 해결책은 아닙니다. 개체 수준의 환각을 막으려면 추가 검증이 필요하고, TBox를 미리 잘 만들어야 한다는 전제도 있습니다. 다만 <code class="language-plaintext highlighter-rouge">unmapped</code> 같은 fallback을 함께 두면 실패가 조용히 사라지지 않고 검토 가능한 형태로 남습니다. LLM에게 스키마와 데이터를 동시에 만들게 하는 방식보다는 훨씬 안정적인 파이프라인을 구성할 수 있다고 생각합니다.</p>

<p>특히 법률, 의학, 금융처럼 도메인 스키마가 비교적 잘 정의되어 있고 환각의 비용이 큰 분야에서는 이런 접근이 실용적인 대안이 될 수 있습니다. TBox 설계에 초기 비용을 투자하고, 그 스키마 안에서 LLM을 facts 추출기로 사용하는 방식입니다.</p>

<hr />

<h2 id="관련-키워드">관련 키워드</h2>

<ul>
  <li><strong>Datalog</strong>: 재귀적 쿼리를 지원하는 논리 프로그래밍 언어. 지식 표현과 추론에 자주 사용됩니다.</li>
  <li><strong>TBox (T-Box)</strong>: Description Logic에서 개념(클래스)과 역할(속성)의 계층 구조 및 제약을 정의하는 스키마 레이어입니다.</li>
  <li><strong>ABox (A-Box)</strong>: 개별 인스턴스에 대한 사실 주장(assertion)을 담는 데이터 레이어입니다.</li>
  <li><strong>Hallucination</strong>: LLM이 입력 근거 없이 사실처럼 보이는 정보를 생성하는 현상입니다.</li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="research" /><category term="Datalog" /><category term="TBox" /><category term="LLM" /><category term="Hallucination" /><category term="Knowledge Graph" /><category term="Description Logic" /><summary type="html"><![CDATA[LLM으로 비정형 텍스트에서 구조화된 데이터를 뽑아내다 보면 결국 한 가지 문제가 남습니다. 바로 환각(hallucination)입니다. 모델은 입력에 없는 관계를 만들어내거나, 스스로 새로운 클래스를 정의하거나, 원문에 없는 속성값을 그럴듯하게 채워 넣습니다.]]></summary></entry><entry><title type="html">KSUID와 UUIDv7을 동시에 지원하는 경량 UID 생성기: libchronoid 이야기</title><link href="https://groou.com/research/2026/05/02/ksuid-uuidv7-simd-neon-uid/" rel="alternate" type="text/html" title="KSUID와 UUIDv7을 동시에 지원하는 경량 UID 생성기: libchronoid 이야기" /><published>2026-05-02T13:00:00+09:00</published><updated>2026-05-02T13:00:00+09:00</updated><id>https://groou.com/research/2026/05/02/ksuid-uuidv7-simd-neon-uid</id><content type="html" xml:base="https://groou.com/research/2026/05/02/ksuid-uuidv7-simd-neon-uid/"><![CDATA[<p>Datalog 엔진을 개발하던 중, 목적에 맞는 ID 생성기가 필요해 직접 구현하게 되었습니다. 단순 난수 기반으로 중복만 피하는 것이 아니라, 생성 순서를 보존하면서 DB 정렬 성능에 악영향을 주지 않아야 했습니다. 동시에 외부 시스템과의 호환성도 중요했습니다. 기존 UID 라이브러리들은 C 외부 의존성이 많아 임베디드 환경이나 서버 확장 모듈에 넣기 부담스러웠기 때문에, 결국 <strong>C11 규격 안에서 완전히 통제 가능한 경량 UID 생성기</strong>를 직접 만들기로 결정했습니다.</p>

<p>처음에는 <code class="language-plaintext highlighter-rouge">KSUID</code>만 지원하려고 했지만, 외부 API나 로그 시스템과 연동하다 보니 범용적인 <code class="language-plaintext highlighter-rouge">UUIDv7</code>의 필요성을 느꼈습니다. 어차피 두 형식 모두 타임스탬프 기반이므로, 하나의 라이브러리에서 같이 제공하는 편이 합리적이었습니다.</p>

<p>이 글에서는 <code class="language-plaintext highlighter-rouge">libchronoid</code>를 설계하면서 고민했던 부분들과 대량 ID 생성 성능을 끌어올리기 위해 <code class="language-plaintext highlighter-rouge">SIMD</code>/<code class="language-plaintext highlighter-rouge">NEON</code>을 적용한 과정을 정리해 보았습니다.</p>

<h2 id="왜-ksuid와-uuidv7을-같이-쓸까요">왜 KSUID와 UUIDv7을 같이 쓸까요?</h2>

<p><code class="language-plaintext highlighter-rouge">KSUID</code>는 20바이트 크기에 4바이트 타임스탬프와 16바이트 페이로드를 담습니다. 타임스탬프가 맨 앞에 위치해 생성 순서대로 정렬하기 좋습니다. 반면 <code class="language-plaintext highlighter-rouge">UUIDv7</code>은 2021년 이후 표준화된 시간 기반 UUID로, 128비트 레이아웃 안에 타임스탬프와 랜덤 값을 섞어 넣습니다.</p>

<p>굳이 두 가지를 모두 구현한 이유는 다음과 같습니다.</p>

<ul>
  <li>내부 저장소 인덱싱이나 로그 추출 시에는 정렬이 보장되는 <code class="language-plaintext highlighter-rouge">KSUID</code>가 압도적으로 편합니다.</li>
  <li>하지만 외부 서비스나 클라이언트와 통신할 때는 범용 표준인 <code class="language-plaintext highlighter-rouge">UUIDv7</code>을 내려주는 것이 호환성 면에서 낫습니다.</li>
  <li>두 포맷 모두 근본적으로는 ‘시간’을 기반으로 하므로, 타임스탬프 원천(source-of-truth)만 동일하게 유지하면 사실상 같은 ID나 다름없습니다.</li>
</ul>

<p>결론적으로, 내부 식별자로는 <code class="language-plaintext highlighter-rouge">KSUID</code>를 사용하되 외부 API 연동 시에는 <code class="language-plaintext highlighter-rouge">UUIDv7</code>을 제공하는 투트랙 전략이 가장 합리적이라고 판단했습니다.</p>

<h2 id="라이브러리-설계-목표-작고-가볍게">라이브러리 설계 목표: 작고 가볍게</h2>

<p><strong>GitHub 저장소</strong>: <a href="https://github.com/semantic-reasoning/libchronoid">semantic-reasoning/libchronoid</a></p>

<p>라이브러리를 설계하며 세운 원칙은 단순했습니다.</p>

<ul>
  <li>외부 의존성 완전 배제</li>
  <li>모듈식 C11 구조 (19개 C 파일, 15개 헤더, 형식별 독립 구현)</li>
  <li>동적 메모리 할당 최소화</li>
  <li>범용 컴파일러 호환 (GCC/Clang, MSVC)</li>
  <li>SIMD 가속은 <code class="language-plaintext highlighter-rouge">bulk</code> 연산에만 선택적으로 적용</li>
</ul>

<p>KSUID와 UUIDv7 로직을 디렉토리 레벨에서 명확히 분리하여, 필요한 모듈만 떼어내 소스 레벨에서 통합 빌드할 수 있게 구성했습니다. 무거운 동적 라이브러리(.so/.dll)를 따로 관리할 필요가 없어서, C 확장이 필요한 서버 프레임워크나 리소스가 빡빡한 임베디드 환경에서도 부담 없이 가져다 쓸 수 있습니다.</p>

<h2 id="내부-레이아웃-16바이트-단위-정렬">내부 레이아웃: 16바이트 단위 정렬</h2>

<p>KSUID의 기본 구조는 다음과 같습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">uint32_t timestamp</code>  : 생성 시각 (초)</li>
  <li><code class="language-plaintext highlighter-rouge">uint8_t payload[16]</code> : 임의 데이터</li>
</ul>

<p>UUIDv7 구조는 다음과 같습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">uint64_t time_hi</code>   : 48비트 타임스탬프 + 버전</li>
  <li><code class="language-plaintext highlighter-rouge">uint64_t time_lo</code>   : 시퀀스/랜덤</li>
</ul>

<p>이 두 구조 모두 16바이트 단위로 정렬 가능하고, 대량 생성 시 128비트 로드/스토어를 활용하기 유리합니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">timestamp_be</span><span class="p">;</span>
    <span class="kt">uint8_t</span>  <span class="n">payload</span><span class="p">[</span><span class="mi">16</span><span class="p">];</span>
<span class="p">}</span> <span class="n">ksuid_t</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint64_t</span> <span class="n">hi</span><span class="p">;</span>
    <span class="kt">uint64_t</span> <span class="n">lo</span><span class="p">;</span>
<span class="p">}</span> <span class="n">uuid7_t</span><span class="p">;</span>
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">timestamp_be</code>를 big-endian으로 저장하는 이유는 정렬 순서를 메모리 레이아웃과 일치시키기 위해서입니다. 이렇게 하면 CPU 아키텍처(Endianness)와 무관하게 언제나 동일한 정렬 결과를 보장할 수 있습니다.</p>

<h2 id="simd-적용-포맷팅과-검증-경로에서-성능-최적화">SIMD 적용: 포맷팅과 검증 경로에서 성능 최적화</h2>

<p><code class="language-plaintext highlighter-rouge">libchronoid</code>의 SIMD 최적화는 ID <strong>생성</strong>이 아니라 <strong>포맷팅(문자열 변환)과 검증</strong>에 집중했습니다. 생성 함수(<code class="language-plaintext highlighter-rouge">chronoid_ksuid_new()</code>, <code class="language-plaintext highlighter-rouge">chronoid_uuidv7_new()</code>)는 스칼라 코드로 유지하고, 수천 개의 ID를 한 번에 직렬화하는 벌크 함수(<code class="language-plaintext highlighter-rouge">chronoid_ksuid_string_batch()</code>, <code class="language-plaintext highlighter-rouge">chronoid_uuidv7_string_batch()</code>)에서 SIMD로 성능을 끌어올렸습니다.</p>

<p>그 이유는 다음과 같습니다.</p>

<ul>
  <li><strong>생성 경로</strong>: 단일 ID 생성 자체는 스칼라 연산만으로도 충분히 가볍고 빨라야 합니다.</li>
  <li><strong>포맷팅 경로</strong>: 병목은 주로 대규모 데이터를 문자열로 직렬화하거나 역직렬화할 때 발생하므로, 여기서 SIMD 효과가 가장 크게 나타납니다.</li>
</ul>

<h3 id="ksuid-base62-포맷팅과-입력-검증">KSUID: base62 포맷팅과 입력 검증</h3>

<p><strong>포맷팅 (base62 인코딩)</strong>: x86_64의 AVX2 8-wide 커널</p>
<ul>
  <li>파일: <code class="language-plaintext highlighter-rouge">chronoid/ksuid/encode_avx2.c</code></li>
  <li>기법: Granlund-Möller 나눗셈 (floor reciprocal multiply divide-by-62)</li>
  <li>8개의 KSUID를 병렬로 base62로 인코딩하여 27자 문자열 생성</li>
</ul>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* AVX2 8-wide chronoid_ksuid_string_batch 커널
 * 기법: Granlund-Möller multiply-high를 활용한 나눗셈
 * 각 KSUID 20바이트를 5개의 32비트 limb로 분해
 * 벡터화된 64비트 곱셈-고위수(mulhi64)로 base62 변환
 */</span>
<span class="k">static</span> <span class="kr">inline</span> <span class="n">__m256i</span>
<span class="nf">chronoid_ksuid_mulhi64_avx2</span> <span class="p">(</span><span class="n">__m256i</span> <span class="n">a</span><span class="p">,</span> <span class="n">__m256i</span> <span class="n">b</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// 64x64 → 128 multiply-high, 4-lane wide</span>
    <span class="c1">// Schoolbook 방식: a*b의 상위 64비트를 벡터로 계산</span>
    <span class="n">__m256i</span> <span class="n">ll</span> <span class="o">=</span> <span class="n">_mm256_mul_epu32</span> <span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">);</span>      <span class="c1">// low*low</span>
    <span class="n">__m256i</span> <span class="n">lh</span> <span class="o">=</span> <span class="n">_mm256_mul_epu32</span> <span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b_hi</span><span class="p">);</span>   <span class="c1">// low*high</span>
    <span class="n">__m256i</span> <span class="n">hl</span> <span class="o">=</span> <span class="n">_mm256_mul_epu32</span> <span class="p">(</span><span class="n">a_hi</span><span class="p">,</span> <span class="n">b</span><span class="p">);</span>   <span class="c1">// high*low</span>
    <span class="n">__m256i</span> <span class="n">hh</span> <span class="o">=</span> <span class="n">_mm256_mul_epu32</span> <span class="p">(</span><span class="n">a_hi</span><span class="p">,</span> <span class="n">b_hi</span><span class="p">);</span> <span class="c1">// high*high</span>
    <span class="c1">// ... 중간 항 누적으로 최종 high 계산</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>입력 검증</strong>: ARM NEON과 SSE2 16-byte 병렬 검증</p>
<ul>
  <li>파일: <code class="language-plaintext highlighter-rouge">chronoid/ksuid/base62_neon.c</code>, <code class="language-plaintext highlighter-rouge">chronoid/ksuid/base62_sse2.c</code></li>
  <li>기법: 3가지 범위 테스트 (0-9, A-Z, a-z)를 병렬 수행</li>
  <li>16개 base62 문자를 동시에 검증해 파싱 속도 향상</li>
</ul>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* ARM NEON base62 16-byte translate-and-validate
 * 파일: chronoid/ksuid/base62_neon.c
 */</span>
<span class="kt">int</span> <span class="nf">chronoid_base62_translate16_neon</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="n">out</span><span class="p">[</span><span class="mi">16</span><span class="p">],</span> <span class="k">const</span> <span class="kt">uint8_t</span> <span class="n">in</span><span class="p">[</span><span class="mi">16</span><span class="p">])</span> <span class="p">{</span>
    <span class="n">uint8x16_t</span> <span class="n">v</span> <span class="o">=</span> <span class="n">vld1q_u8</span> <span class="p">(</span><span class="n">in</span><span class="p">);</span>
    
    <span class="c1">// 범위 1: 0-9 → 0-9</span>
    <span class="n">uint8x16_t</span> <span class="n">d</span> <span class="o">=</span> <span class="n">vsubq_u8</span> <span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="sc">'0'</span><span class="p">));</span>
    <span class="n">uint8x16_t</span> <span class="n">d_mask</span> <span class="o">=</span> <span class="n">vcleq_u8</span> <span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="mi">9</span><span class="p">));</span>
    <span class="n">uint8x16_t</span> <span class="n">d_val</span> <span class="o">=</span> <span class="n">vandq_u8</span> <span class="p">(</span><span class="n">d_mask</span><span class="p">,</span> <span class="n">d</span><span class="p">);</span>
    
    <span class="c1">// 범위 2: A-Z → 10-35</span>
    <span class="n">uint8x16_t</span> <span class="n">u</span> <span class="o">=</span> <span class="n">vsubq_u8</span> <span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="sc">'A'</span><span class="p">));</span>
    <span class="n">uint8x16_t</span> <span class="n">u_mask</span> <span class="o">=</span> <span class="n">vcleq_u8</span> <span class="p">(</span><span class="n">u</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="mi">25</span><span class="p">));</span>
    <span class="n">uint8x16_t</span> <span class="n">u_val</span> <span class="o">=</span> <span class="n">vandq_u8</span> <span class="p">(</span><span class="n">u_mask</span><span class="p">,</span> <span class="n">vaddq_u8</span> <span class="p">(</span><span class="n">u</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="mi">10</span><span class="p">)));</span>
    
    <span class="c1">// 범위 3: a-z → 36-61</span>
    <span class="n">uint8x16_t</span> <span class="n">l</span> <span class="o">=</span> <span class="n">vsubq_u8</span> <span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="sc">'a'</span><span class="p">));</span>
    <span class="n">uint8x16_t</span> <span class="n">l_mask</span> <span class="o">=</span> <span class="n">vcleq_u8</span> <span class="p">(</span><span class="n">l</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="mi">25</span><span class="p">));</span>
    <span class="n">uint8x16_t</span> <span class="n">l_val</span> <span class="o">=</span> <span class="n">vandq_u8</span> <span class="p">(</span><span class="n">l_mask</span><span class="p">,</span> <span class="n">vaddq_u8</span> <span class="p">(</span><span class="n">l</span><span class="p">,</span> <span class="n">vdupq_n_u8</span> <span class="p">(</span><span class="mi">36</span><span class="p">)));</span>
    
    <span class="c1">// 결과 병합: 16개 문자를 한 번에 처리</span>
    <span class="n">uint8x16_t</span> <span class="n">values</span> <span class="o">=</span> <span class="n">vorrq_u8</span> <span class="p">(</span><span class="n">vorrq_u8</span> <span class="p">(</span><span class="n">d_val</span><span class="p">,</span> <span class="n">u_val</span><span class="p">),</span> <span class="n">l_val</span><span class="p">);</span>
    <span class="n">vst1q_u8</span> <span class="p">(</span><span class="n">out</span><span class="p">,</span> <span class="n">values</span><span class="p">);</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="uuidv7-hex-포맷팅">UUIDv7: Hex 포맷팅</h3>

<p><strong>포맷팅</strong>: x86_64의 SSSE3/AVX2 커널</p>
<ul>
  <li>파일: <code class="language-plaintext highlighter-rouge">chronoid/uuidv7/hex_ssse3.c</code>, <code class="language-plaintext highlighter-rouge">chronoid/uuidv7/hex_avx2.c</code></li>
  <li>기법: PSHUFB (Packed Shuffle Bytes) 니블→ASCII LUT</li>
  <li>SSSE3: 1 UUID당 한 번, AVX2: 4 UUID 병렬 처리</li>
</ul>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* AVX2 4-wide chronoid_uuidv7_string_batch 커널
 * 파일: chronoid/uuidv7/hex_avx2.c
 * 전략: 4 UUID (64 바이트) → 144 hex chars (36*4)
 * VPSHUFB로 니블 추출 후 LUT 변환, 하이픈 삽입
 */</span>
<span class="k">static</span> <span class="kr">inline</span> <span class="kt">void</span>
<span class="nf">chronoid_uuidv7_hex32_avx2</span> <span class="p">(</span><span class="kt">char</span> <span class="n">out64</span><span class="p">[</span><span class="mi">64</span><span class="p">],</span> <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">in32</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">static</span> <span class="k">const</span> <span class="kt">uint8_t</span> <span class="n">kHexLowerLut</span><span class="p">[</span><span class="mi">16</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
        <span class="sc">'0'</span><span class="p">,</span> <span class="sc">'1'</span><span class="p">,</span> <span class="sc">'2'</span><span class="p">,</span> <span class="sc">'3'</span><span class="p">,</span> <span class="sc">'4'</span><span class="p">,</span> <span class="sc">'5'</span><span class="p">,</span> <span class="sc">'6'</span><span class="p">,</span> <span class="sc">'7'</span><span class="p">,</span>
        <span class="sc">'8'</span><span class="p">,</span> <span class="sc">'9'</span><span class="p">,</span> <span class="sc">'a'</span><span class="p">,</span> <span class="sc">'b'</span><span class="p">,</span> <span class="sc">'c'</span><span class="p">,</span> <span class="sc">'d'</span><span class="p">,</span> <span class="sc">'e'</span><span class="p">,</span> <span class="sc">'f'</span><span class="p">,</span>
    <span class="p">};</span>
    
    <span class="c1">// 32 바이트 (2 UUID) 로드</span>
    <span class="n">__m256i</span> <span class="n">v</span> <span class="o">=</span> <span class="n">_mm256_loadu_si256</span> <span class="p">((</span><span class="k">const</span> <span class="n">__m256i</span> <span class="o">*</span><span class="p">)</span> <span class="n">in32</span><span class="p">);</span>
    
    <span class="c1">// LUT를 두 128-bit 레인에 broadcast</span>
    <span class="n">__m256i</span> <span class="n">lut</span> <span class="o">=</span> <span class="n">_mm256_broadcastsi128_si256</span> <span class="p">(</span>
        <span class="n">_mm_loadu_si128</span> <span class="p">((</span><span class="k">const</span> <span class="n">__m128i</span> <span class="o">*</span><span class="p">)</span> <span class="n">kHexLowerLut</span><span class="p">)</span>
    <span class="p">);</span>
    
    <span class="c1">// 니블 추출: 각 바이트에서 상위/하위 4비트를 분리</span>
    <span class="n">__m256i</span> <span class="n">mask_low</span> <span class="o">=</span> <span class="n">_mm256_set1_epi8</span> <span class="p">(</span><span class="mh">0x0F</span><span class="p">);</span>
    <span class="n">__m256i</span> <span class="n">lo</span> <span class="o">=</span> <span class="n">_mm256_and_si256</span> <span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">mask_low</span><span class="p">);</span>
    <span class="n">__m256i</span> <span class="n">hi</span> <span class="o">=</span> <span class="n">_mm256_and_si256</span> <span class="p">(</span><span class="n">_mm256_srli_epi16</span> <span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="mi">4</span><span class="p">),</span> <span class="n">mask_low</span><span class="p">);</span>
    
    <span class="c1">// VPSHUFB로 LUT 변환 (각 128-bit 레인 독립)</span>
    <span class="n">__m256i</span> <span class="n">lo_hex</span> <span class="o">=</span> <span class="n">_mm256_shuffle_epi8</span> <span class="p">(</span><span class="n">lut</span><span class="p">,</span> <span class="n">lo</span><span class="p">);</span>
    <span class="n">__m256i</span> <span class="n">hi_hex</span> <span class="o">=</span> <span class="n">_mm256_shuffle_epi8</span> <span class="p">(</span><span class="n">lut</span><span class="p">,</span> <span class="n">hi</span><span class="p">);</span>
    
    <span class="c1">// 결과를 interleave하여 출력 (상위 니블이 먼저)</span>
    <span class="c1">// hi_hex | lo_hex → "0A" "0B" ... 형태로 저장</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="성능-특성-요약">성능 특성 요약</h3>

<table>
  <thead>
    <tr>
      <th>연산</th>
      <th>포맷</th>
      <th>스칼라</th>
      <th>SIMD 지원</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>KSUID 인코딩</td>
      <td>base62</td>
      <td>1 ID</td>
      <td>8-wide AVX2 (x86_64만)</td>
    </tr>
    <tr>
      <td>KSUID 파싱</td>
      <td>base62 범위 테스트</td>
      <td>1 ID</td>
      <td>16-byte NEON/SSE2 (ARM/x86 모두)</td>
    </tr>
    <tr>
      <td>UUIDv7 인코딩</td>
      <td>hex</td>
      <td>1 ID</td>
      <td>4-wide AVX2, 1-wide SSSE3 (x86_64만)</td>
    </tr>
  </tbody>
</table>

<p>결론적으로 SIMD 최적화를 생성 로직 본체에 억지로 밀어 넣지 않고, 문자열을 다루는 직렬화/검증 레이어에만 적용되도록 관심사를 분리했습니다. 단일 ID 생성기 자체는 외부 의존성 없이 최대한 담백하게 두는 편이 맞다고 판단했습니다.</p>

<h2 id="실제-사용-ksuid와-uuidv7-생성하기">실제 사용: KSUID와 UUIDv7 생성하기</h2>

<h3 id="ksuid-생성">KSUID 생성</h3>

<p>가장 간단한 형태는 현재 시간으로 KSUID를 생성하고 문자열로 변환하는 것입니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;chronoid/ksuid.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">chronoid_ksuid_t</span> <span class="n">ksuid</span><span class="p">;</span>
    
    <span class="c1">// 현재 시간으로 새 KSUID 생성</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">chronoid_ksuid_new</span><span class="p">(</span><span class="o">&amp;</span><span class="n">ksuid</span><span class="p">)</span> <span class="o">!=</span> <span class="n">CHRONOID_KSUID_OK</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">fprintf</span><span class="p">(</span><span class="n">stderr</span><span class="p">,</span> <span class="s">"Failed to generate KSUID</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="c1">// 27자 base62 문자열로 포맷팅</span>
    <span class="kt">char</span> <span class="n">s</span><span class="p">[</span><span class="n">CHRONOID_KSUID_STRING_LEN</span> <span class="o">+</span> <span class="mi">1</span><span class="p">];</span>
    <span class="n">chronoid_ksuid_format</span><span class="p">(</span><span class="o">&amp;</span><span class="n">ksuid</span><span class="p">,</span> <span class="n">s</span><span class="p">);</span>
    <span class="n">s</span><span class="p">[</span><span class="n">CHRONOID_KSUID_STRING_LEN</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'\0'</span><span class="p">;</span>
    
    <span class="n">printf</span><span class="p">(</span><span class="s">"Generated KSUID: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">s</span><span class="p">);</span>
    <span class="c1">// Output: Generated KSUID: 0ujtsYcgvSTl8PAuAdqWYSMnLOv</span>
    
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>생성된 KSUID에서 타임스탬프 정보를 추출할 수도 있습니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// KSUID에서 Unix 시간(초) 추출</span>
<span class="kt">int64_t</span> <span class="n">unix_seconds</span> <span class="o">=</span> <span class="n">chronoid_ksuid_time_unix</span><span class="p">(</span><span class="o">&amp;</span><span class="n">ksuid</span><span class="p">);</span>
<span class="n">printf</span><span class="p">(</span><span class="s">"Generated at: %lld</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">unix_seconds</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="uuidv7-생성">UUIDv7 생성</h3>

<p>UUIDv7도 유사한 인터페이스를 제공합니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;chronoid/uuidv7.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">chronoid_uuidv7_t</span> <span class="n">uuid</span><span class="p">;</span>
    
    <span class="c1">// 현재 시간으로 새 UUIDv7 생성</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">chronoid_uuidv7_new</span><span class="p">(</span><span class="o">&amp;</span><span class="n">uuid</span><span class="p">)</span> <span class="o">!=</span> <span class="n">CHRONOID_UUIDV7_OK</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">fprintf</span><span class="p">(</span><span class="n">stderr</span><span class="p">,</span> <span class="s">"Failed to generate UUIDv7</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="c1">// 36자 정규 하이픈 형식으로 포맷팅</span>
    <span class="kt">char</span> <span class="n">s</span><span class="p">[</span><span class="n">CHRONOID_UUIDV7_STRING_LEN</span> <span class="o">+</span> <span class="mi">1</span><span class="p">];</span>
    <span class="n">chronoid_uuidv7_format</span><span class="p">(</span><span class="o">&amp;</span><span class="n">uuid</span><span class="p">,</span> <span class="n">s</span><span class="p">);</span>
    <span class="n">s</span><span class="p">[</span><span class="n">CHRONOID_UUIDV7_STRING_LEN</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'\0'</span><span class="p">;</span>
    
    <span class="n">printf</span><span class="p">(</span><span class="s">"Generated UUIDv7: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">s</span><span class="p">);</span>
    <span class="c1">// Output: Generated UUIDv7: 019de2b2-7c56-7e59-98be-2ed3cffbd12e</span>
    
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>UUIDv7에서 밀리초 단위 타임스탬프도 손쉽게 추출할 수 있습니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// UUIDv7에서 Unix 시간(밀리초) 추출</span>
<span class="kt">int64_t</span> <span class="n">unix_ms</span> <span class="o">=</span> <span class="n">chronoid_uuidv7_unix_ms</span><span class="p">(</span><span class="o">&amp;</span><span class="n">uuid</span><span class="p">);</span>
<span class="n">printf</span><span class="p">(</span><span class="s">"Generated at: %lld ms</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">unix_ms</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="대량-생성-벌크-포맷팅">대량 생성: 벌크 포맷팅</h3>

<p>한 번에 많은 ID를 생성하고 포맷팅할 때는 벌크 경로를 사용합니다. 이때 SIMD 최적화가 제 몫을 톡톡히 합니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;chronoid/ksuid.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdlib.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">size_t</span> <span class="n">count</span> <span class="o">=</span> <span class="mi">1000</span><span class="p">;</span>
    
    <span class="c1">// 1. 많은 KSUID 생성</span>
    <span class="n">chronoid_ksuid_t</span> <span class="o">*</span><span class="n">ids</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="n">count</span> <span class="o">*</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">chronoid_ksuid_t</span><span class="p">));</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">size_t</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">count</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">chronoid_ksuid_new</span><span class="p">(</span><span class="o">&amp;</span><span class="n">ids</span><span class="p">[</span><span class="n">i</span><span class="p">]);</span>
    <span class="p">}</span>
    
    <span class="c1">// 2. 벌크 포맷팅 (SIMD 최적화됨)</span>
    <span class="c1">// 각 KSUID는 27바이트를 차지하고, NUL 종료자는 없음</span>
    <span class="kt">char</span> <span class="o">*</span><span class="n">out</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="n">count</span> <span class="o">*</span> <span class="n">CHRONOID_KSUID_STRING_LEN</span><span class="p">);</span>
    <span class="n">chronoid_ksuid_string_batch</span><span class="p">(</span><span class="n">ids</span><span class="p">,</span> <span class="n">out</span><span class="p">,</span> <span class="n">count</span><span class="p">);</span>
    
    <span class="c1">// 3. 결과 출력</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">size_t</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">10</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>  <span class="c1">// 처음 10개만 출력</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"%.*s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">CHRONOID_KSUID_STRING_LEN</span><span class="p">,</span> 
               <span class="n">out</span> <span class="o">+</span> <span class="n">i</span> <span class="o">*</span> <span class="n">CHRONOID_KSUID_STRING_LEN</span><span class="p">);</span>
    <span class="p">}</span>
    
    <span class="n">free</span><span class="p">(</span><span class="n">out</span><span class="p">);</span>
    <span class="n">free</span><span class="p">(</span><span class="n">ids</span><span class="p">);</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이 방식은 일반적인 스칼라 루프보다 훨씬 빠릅니다. x86_64 AVX2 환경에서는 체감상 8배 가까운 성능 향상을 보여주었습니다.</p>

<h2 id="uuidv7-생성-메모리-레이아웃과-호환성">UUIDv7 생성: 메모리 레이아웃과 호환성</h2>

<p><code class="language-plaintext highlighter-rouge">UUIDv7</code>은 내부적으로 48비트 타임스탬프와 74비트 랜덤/시퀀스 영역으로 나뉩니다. <code class="language-plaintext highlighter-rouge">libchronoid</code>에서는 <code class="language-plaintext highlighter-rouge">uuid7_t</code>를 이렇게 정의했습니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">union</span> <span class="p">{</span>
    <span class="kt">uint8_t</span>  <span class="n">bytes</span><span class="p">[</span><span class="mi">16</span><span class="p">];</span>
    <span class="k">struct</span> <span class="p">{</span>
        <span class="kt">uint64_t</span> <span class="n">hi</span><span class="p">;</span>
        <span class="kt">uint64_t</span> <span class="n">lo</span><span class="p">;</span>
    <span class="p">}</span> <span class="n">parts</span><span class="p">;</span>
<span class="p">}</span> <span class="n">uuid7_t</span><span class="p">;</span>
</code></pre></div></div>

<p>생성 과정은 다음과 같습니다.</p>

<ol>
  <li>타임스탬프를 48비트로 계산</li>
  <li>버전 필드(0x7)를 제 위치에 삽입</li>
  <li>남은 공간을 랜덤 또는 시퀀스 바이트로 채움</li>
  <li>네트워크 바이트 오더(Big-Endian)로 변환</li>
</ol>

<p>여기서도 벌크 경로는 큰 차이를 만들어냅니다. 타임스탬프와 버전 비트 삽입을 16바이트 청크 단위로 한 번에 밀어버릴 수 있기 때문입니다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kr">inline</span> <span class="kt">void</span> <span class="nf">set_uuid7_timestamp</span><span class="p">(</span><span class="n">uuid7_t</span><span class="o">*</span> <span class="n">u</span><span class="p">,</span> <span class="kt">uint64_t</span> <span class="n">ts_ms</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">uint64_t</span> <span class="n">top</span> <span class="o">=</span> <span class="p">(</span><span class="n">ts_ms</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFFFFFFULL</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">16</span><span class="p">;</span>
    <span class="n">top</span> <span class="o">|=</span> <span class="mh">0x7000ULL</span><span class="p">;</span> <span class="c1">// 버전 7</span>
    <span class="n">u</span><span class="o">-&gt;</span><span class="n">parts</span><span class="p">.</span><span class="n">hi</span> <span class="o">=</span> <span class="n">htobe64</span><span class="p">(</span><span class="n">top</span> <span class="o">|</span> <span class="p">((</span><span class="kt">uint64_t</span><span class="p">)</span><span class="n">rand</span><span class="p">()</span> <span class="o">&amp;</span> <span class="mh">0xFFFF</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이후 <code class="language-plaintext highlighter-rouge">lo</code> 필드는 그대로 랜덤/시퀀스 값으로 채우고 <code class="language-plaintext highlighter-rouge">htobe64()</code>로 네트워크 바이트 오더를 맞춥니다. 이렇게 메모리 상에 배치해 두면, 외부 시스템에서 UUID를 다룰 때 기대하는 표준 바이트 배열 포맷과 정확히 일치하게 됩니다.</p>

<h2 id="코어-스택-지향-의존성을-덜어내다">코어 스택 지향: 의존성을 덜어내다</h2>

<p>이 라이브러리를 만들면서 지키고자 했던 철학은, 불필요한 기능까지 엮인 ‘풀 스택’ 프레임워크가 아니라 필요한 기능만 쏙 빼서 쓸 수 있는 ‘코어 스택’을 제공하는 것이었습니다.</p>

<ul>
  <li>헤더에는 작은 구조체와 함수 선언만 노출</li>
  <li>난수 생성은 <code class="language-plaintext highlighter-rouge">xoroshiro128+</code> 또는 <code class="language-plaintext highlighter-rouge">xorshift</code> 같은 가벼운 알고리즘 채택</li>
  <li>무거운 외부 암호화(Crypto) 라이브러리 배제</li>
  <li>SIMD 관련 코드는 매크로로 분리해 필요 없을 땐 빌드에서 제외 가능</li>
</ul>

<p>실제로 <code class="language-plaintext highlighter-rouge">libchronoid</code>를 <code class="language-plaintext highlighter-rouge">-Os</code> 옵션으로 빌드하면 바이너리 크기가 10KB 안팎에 불과합니다. 벌크 연산 루틴마저 제외하면 실행 코드는 훨씬 더 가벼워집니다.</p>

<h2 id="실무-적용-후기">실무 적용 후기</h2>

<p>이 라이브러리를 실제 Datalog 엔진에 연동해 보며 느낀 점은 다음과 같습니다.</p>

<ul>
  <li><strong>ID 생성이 병목이 되는 경우는 드물었습니다.</strong> 보통은 네트워크나 DB I/O에서 먼저 막힙니다. 하지만 데이터를 Batch Insert로 한꺼번에 밀어 넣을 때는 SIMD로 최적화된 포맷팅 로직 덕분에 직렬화 오버헤드가 눈에 띄게 줄어들었습니다.</li>
  <li><strong>UUIDv7 지원은 신의 한 수였습니다.</strong> 내부적으로 KSUID가 아무리 편하더라도, 로그 수집기나 메시지 큐 등 외부 시스템과 연동할 때는 다들 표준 UUID 포맷을 기대합니다. 이를 기본 지원하니 연동 과정에서 겪는 호환성 스트레스가 사라졌습니다.</li>
  <li><strong>내부 인덱싱에는 역시 KSUID가 깡패입니다.</strong> 앞부분 4바이트가 타임스탬프라 굳이 복잡하게 쿼리하지 않아도 시간순 정렬이 보장됩니다. DB에 인덱스 태우거나 시계열 히트맵을 뽑아낼 때 정말 편했습니다.</li>
</ul>

<h2 id="마치며">마치며</h2>

<p>시스템 아키텍처를 설계할 때 억지로 단일 ID 포맷만 고집할 필요는 없습니다. 목적에 맞게 내부용으론 KSUID를, 외부 인터페이스용으론 UUIDv7을 제공하는 유연한 접근이 실무에서는 훨씬 유리했습니다.</p>

<p>또한 무거운 라이브러리를 통째로 끌어다 쓰는 대신, 필요한 핵심 로직만 C11로 가볍게 구현해 내재화한 덕분에 장기적인 유지보수 측면에서도 골칫거리를 덜어낸 기분입니다.</p>

<h3 id="릴리즈-계획">릴리즈 계획</h3>

<p>현재 <code class="language-plaintext highlighter-rouge">libchronoid</code> 은 1.0.1 상태입니다. 이미 두 포맷 모두 충분히 안정화되었고 CI/CD 커버리지도 든든하게 갖추었습니다. 2026-05-02부로 1.0 stable 릴리즈 하였습니다.</p>

<hr />

<h2 id="더-알아보기">더 알아보기</h2>

<ul>
  <li><strong>GitHub 저장소</strong>: https://github.com/semantic-reasoning/libchronoid</li>
  <li><strong>라이선스</strong>: LGPL-3.0-or-later (KSUID 부분 MIT 호환)</li>
  <li><strong>현재 버전</strong>: 1.0.1 (stable)</li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="research" /><category term="UID" /><category term="KSUID" /><category term="UUIDv7" /><category term="SIMD" /><category term="NEON" /><category term="C Language" /><category term="Optimization" /><summary type="html"><![CDATA[Datalog 엔진을 개발하던 중, 목적에 맞는 ID 생성기가 필요해 직접 구현하게 되었습니다. 단순 난수 기반으로 중복만 피하는 것이 아니라, 생성 순서를 보존하면서 DB 정렬 성능에 악영향을 주지 않아야 했습니다. 동시에 외부 시스템과의 호환성도 중요했습니다. 기존 UID 라이브러리들은 C 외부 의존성이 많아 임베디드 환경이나 서버 확장 모듈에 넣기 부담스러웠기 때문에, 결국 C11 규격 안에서 완전히 통제 가능한 경량 UID 생성기를 직접 만들기로 결정했습니다.]]></summary></entry><entry><title type="html">순수 소프트웨어로 구현하는 비디오 스태빌라이저: GStreamer 파이프라인의 이해</title><link href="https://groou.com/engineering/gstreamer/2026/04/30/gstreamer-stabilizer/" rel="alternate" type="text/html" title="순수 소프트웨어로 구현하는 비디오 스태빌라이저: GStreamer 파이프라인의 이해" /><published>2026-04-30T00:00:00+09:00</published><updated>2026-04-30T00:00:00+09:00</updated><id>https://groou.com/engineering/gstreamer/2026/04/30/gstreamer-stabilizer</id><content type="html" xml:base="https://groou.com/engineering/gstreamer/2026/04/30/gstreamer-stabilizer/"><![CDATA[<h1 id="순수-소프트웨어로-구현하는-비디오-스태빌라이저-gstreamer-파이프라인의-이해">순수 소프트웨어로 구현하는 비디오 스태빌라이저: GStreamer 파이프라인의 이해</h1>

<h2 id="왜-소프트웨어-스태빌라이저인가">왜 소프트웨어 스태빌라이저인가</h2>

<p>손으로 촬영한 영상은 항상 떨립니다. 손 떨림, 바람, 움직임—대부분의 영상에는 이런 흔들림이 있죠. 비디오 스태빌라이제이션은 이 문제를 푸는 방법인데, 기술에 따라 세 가지로 나뉩니다.</p>

<p><strong>OIS(Optical Image Stabilization)</strong>는 렌즈나 센서 자체를 움직여서 보정하고, <strong>EIS(Electronic Image Stabilization)</strong>는 자이로 센서 데이터를 써서 보정합니다. 둘 다 하드웨어 지원이 필요하고 성능도 좋습니다. 하지만 우리가 항상 이런 고급 센서에 접근할 수 있는 건 아닙니다.</p>

<p>예를 들어 이미 촬영이 끝난 예전 영상, 자이로 없는 저가형 웹캠, 혹은 센서 정보가 전혀 없는 산업용 카메라를 보정해야 한다면? 이 경우 순수하게 영상의 <strong>프레임 정보만으로</strong> 흔들림을 감지하고 보정하는 소프트웨어 방식(<strong>DIS, Digital Image Stabilization</strong>)이 유일한 방법입니다.</p>

<p>이 글에서는 이 소프트웨어 방식을 GStreamer로 구현하는 방법을 살펴봅니다.</p>

<p><strong>세 가지 방식 비교:</strong></p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>OIS</th>
      <th>EIS</th>
      <th>소프트웨어 (DIS)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>효과</strong></td>
      <td>최고</td>
      <td>우수</td>
      <td>중상</td>
    </tr>
    <tr>
      <td><strong>지연</strong></td>
      <td>거의 없음</td>
      <td>낮음</td>
      <td>수십 프레임</td>
    </tr>
    <tr>
      <td><strong>하드웨어 의존성</strong></td>
      <td>높음</td>
      <td>중간</td>
      <td>없음</td>
    </tr>
    <tr>
      <td><strong>기존 영상 적용</strong></td>
      <td>불가</td>
      <td>불가</td>
      <td>가능</td>
    </tr>
    <tr>
      <td><strong>비용</strong></td>
      <td>높음</td>
      <td>중간</td>
      <td>낮음</td>
    </tr>
  </tbody>
</table>

<p><strong>언제 소프트웨어 방식을 쓸까?</strong></p>

<p>실제로 마주치는 상황들:</p>

<ol>
  <li><strong>기존 영상의 후처리</strong>: 예전에 찍은 흔들리는 영상을 다시 살려내야 할 때</li>
  <li><strong>고정 카메라의 흔들림</strong>: 야외의 보안 카메라나 중계 카메라가 강풍에 흔들릴 때</li>
  <li><strong>센서가 없는 장비</strong>: 스마트폰처럼 자이로를 쓸 수 없는 산업용 카메라의 영상 분석</li>
</ol>

<p><strong>핵심 아이디어는 간단합니다.</strong> 4K 같은 고해상도로 촬영한 뒤, 1080p 같은 낮은 해상도로 출력하는 겁니다. 그러면 가장자리에 화소의 ‘마진’이 생기는데, 카메라가 흔들릴 때마다 이 마진 안에서 화면을 움직여가며 잘라내면 흔들림이 상쇄됩니다.</p>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" style="max-width:100%;height:auto;display:block;margin:20px auto;">
  <!-- Step 1: Input -->
  <rect x="100" y="20" width="300" height="70" rx="8" fill="#e3f2fd" stroke="#1976d2" stroke-width="2" />
  <text x="250" y="50" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="13" font-weight="600" fill="#1565c0">원본 프레임</text>
  <text x="250" y="72" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#455a64">(4K)</text>
  
  <!-- Arrow 1 -->
  <line x1="250" y1="90" x2="250" y2="120" stroke="#666" stroke-width="2" />
  <polygon points="250,130 245,120 255,120" fill="#666" />
  
  <!-- Step 2: Motion Analysis -->
  <rect x="100" y="130" width="300" height="70" rx="8" fill="#f3e5f5" stroke="#7b1fa2" stroke-width="2" />
  <text x="250" y="160" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="13" font-weight="600" fill="#6a1b9a">화면 흔들림 분석</text>
  <text x="250" y="182" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="11" fill="#534494">Motion Estimation</text>
  
  <!-- Arrow 2 -->
  <line x1="250" y1="200" x2="250" y2="230" stroke="#666" stroke-width="2" />
  <polygon points="250,240 245,230 255,230" fill="#666" />
  
  <!-- Step 3: Safe Zone Calculation -->
  <rect x="70" y="240" width="360" height="70" rx="8" fill="#fff3e0" stroke="#e65100" stroke-width="2" />
  <text x="250" y="270" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="13" font-weight="600" fill="#d84315">잘라낼 안전 영역 계산</text>
  <text x="250" y="292" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="11" fill="#bf360c">(Safe Zone)</text>
  
  <!-- Arrow 3 -->
  <line x1="250" y1="310" x2="250" y2="340" stroke="#666" stroke-width="2" />
  <polygon points="250,350 245,340 255,340" fill="#666" />
  
  <!-- Step 4: Output -->
  <rect x="50" y="350" width="400" height="80" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="2" />
  <text x="250" y="380" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="13" font-weight="600" fill="#1b5e20">안전 영역만 1080p로 잘라서 출력</text>
  <text x="250" y="405" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="11" fill="#2e7d32">(안정된 화면)</text>
</svg>

<p>과거에는 이런 무거운 연산을 실시간으로 처리하는 것이 불가능했지만, 멀티코어 CPU와 GPU의 성능 향상, 그리고 GStreamer와 OpenCV와 같은 도구들 덕분에 이제는 소프트웨어를 활용한 실시간 스태빌라이제이션이 가능하게 되었습니다.</p>

<pre><code class="language-mermaid">flowchart LR
    A["📷 4K 입력&lt;br/&gt;(3840 × 2160)"]
    B["🔍 흔들림 감지&lt;br/&gt;Motion Estimation"]
    C["🔄 안정화 변환&lt;br/&gt;Homography 적용"]
    D["✂️ Safe Zone Crop&lt;br/&gt;안전 영역 추출"]
    E["📺 1080p 출력&lt;br/&gt;(1920 × 1080)"]

    A --&gt; B --&gt; C --&gt; D --&gt; E

    subgraph margin["← 해상도 마진 활용 구간 (약 15~20% 화소 손실) →"]
        B
        C
        D
    end

    style A fill:#1d3557,color:#fff,stroke:#0d2035
    style E fill:#1b4332,color:#fff,stroke:#0a2218
    style B fill:#457b9d,color:#fff,stroke:#1d3557
    style C fill:#457b9d,color:#fff,stroke:#1d3557
    style D fill:#457b9d,color:#fff,stroke:#1d3557
    style margin fill:#fff8e1,stroke:#f9a825,stroke-dasharray:6 4
</code></pre>

<hr />

<h2 id="흔들림의-모델-3축-기반-접근">흔들림의 모델: 3축 기반 접근</h2>

<p>카메라는 3차원 공간에서 세 방향의 흔들림이 있을 수 있습니다. 각각을 다음과 같이 부릅니다:</p>

<ul>
  <li><strong>Yaw(좌우)</strong>: Y축을 중심으로 회전. 카메라가 옆으로 흔들립니다.</li>
  <li><strong>Pitch(상하)</strong>: X축을 중심으로 회전. 카메라가 위아래로 기울립니다.</li>
  <li><strong>Roll(회전)</strong>: Z축(렌즈 중심축)을 중심으로 회전. 카메라가 시계/반시계 방향으로 회전합니다.</li>
</ul>

<p>이 세 가지 움직임을 모두 추적해야 전체 흔들림을 보정할 수 있습니다. 3차원 회전을 2차원 이미지 평면으로 표현할 때는 <strong>호모그래피(Homography)</strong> 변환을 사용합니다.</p>

<p>프레임 간 변환의 핵심은 세 가지 요소입니다:</p>

<ul>
  <li>$C_t$: $t$번째 프레임에서 측정된 누적 모션 행렬 (카메라가 움직인 량)</li>
  <li>$S_t$: $t$번째 프레임에서의 목표 궤적(smooth trajectory). 저주파 모션만 포함.</li>
  <li>$B_t$: 보정 변환. $B_t = S_t \cdot C_t^{-1}$</li>
</ul>

<p>보정 변환 $B_t$를 각 프레임에 적용하면, 카메라의 누적 모션 $C_t$가 목표 궤적 $S_t$로 변환됩니다.</p>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 120" style="max-width:100%;height:auto;display:block;margin:20px auto;">
  <!-- Left box -->
  <rect x="30" y="20" width="140" height="80" rx="8" fill="#e8f4f8" stroke="#2980b9" stroke-width="2" />
  <text x="100" y="55" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="14" font-weight="600" fill="#1a5276">원본 프레임 좌표</text>
  <text x="100" y="80" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#34495e">(흔들린 상태)</text>
  
  <!-- Arrow and transformation -->
  <line x1="180" y1="60" x2="520" y2="60" stroke="#2980b9" stroke-width="3" />
  <polygon points="530,60 515,52 515,68" fill="#2980b9" />
  
  <!-- Transform label -->
  <rect x="340" y="25" width="60" height="35" rx="4" fill="#f8f9fa" stroke="#2980b9" stroke-width="1.5" />
  <text x="370" y="50" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="14" font-weight="600" fill="#2980b9">[<tspan font-style="italic">B</tspan><tspan font-style="italic" font-size="11" baseline-shift="sub">t</tspan>]</text>
  
  <!-- Right box -->
  <rect x="530" y="20" width="140" height="80" rx="8" fill="#e8f8f0" stroke="#27ae60" stroke-width="2" />
  <text x="600" y="55" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="14" font-weight="600" fill="#145a32">안정화된 좌표</text>
  <text x="600" y="80" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#34495e">(부드러운 상태)</text>
</svg>

<p><strong>호모그래피 행렬</strong> $H$는 $3 \times 3$ 행렬로, 2D 이미지 평면에서의 임의의 단사 사영 변환을 나타냅니다:</p>

\[\begin{pmatrix} x' \\ y' \\ 1 \end{pmatrix} = H \begin{pmatrix} x \\ y \\ 1 \end{pmatrix} = \begin{pmatrix} h_{11} &amp; h_{12} &amp; h_{13} \\ h_{21} &amp; h_{22} &amp; h_{23} \\ h_{31} &amp; h_{32} &amp; h_{33} \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \end{pmatrix}\]

<p>이 행렬 하나가 3차원 회전, 스케일 변화, 평행이동 등을 모두 포함할 수 있습니다.</p>

<p><strong>저주파 vs 고주파 분리</strong>:</p>

<p>측정된 모션 $C_t$ 중에서:</p>
<ul>
  <li><strong>저주파(Low Frequency)</strong>: 카메라 워크(deliberate movement). 3~5프레임에 걸친 부드러운 움직임.</li>
  <li><strong>고주파(High Frequency)</strong>: 손떨림(jitter). 1~2프레임 단위의 빠른 진동.</li>
</ul>

<p>필터(예: Gaussian blur)를 사용해 저주파만 추출하면 $S_t$를 얻을 수 있고, 이를 통해 고주파만 제거할 수 있습니다.</p>

<p><strong>호모그래피의 기하학적 의미</strong></p>

<p>호모그래피 행렬 $H$는 단순히 수치의 집합이 아니라 기하학적 의미를 지닙니다. 이를 분해하면:</p>

\[H = K' R K^{-1}\]

<p>여기서:</p>
<ul>
  <li>$K$: 카메라 내부 파라미터(focal length, principal point).</li>
  <li>$R$: 회전 행렬(3×3).</li>
  <li>$K’$: 변환 후 카메라 파라미터.</li>
</ul>

<p>실제로는 $H$를 직접 사용하되, 때로는 이를 회전, 스케일, 평행이동 성분으로 분해하기도 합니다. 예를 들어, roll을 과도하게 보정하는 것을 피하기 위해 roll 성분만 추출하여 제한할 수 있습니다.</p>

<p><strong>평활화 전략</strong></p>

<p>목표 궤적 $S_t$를 계산하는 방법은 여러 가지가 있습니다:</p>

<ol>
  <li><strong>이동 평균(Moving Average)</strong>: $S_t = \frac{1}{N} \sum_{i=t-N}^{t} C_i$</li>
  <li><strong>Gaussian 필터</strong>: 최근 프레임에 높은 가중치, 과거로 갈수록 낮은 가중치.</li>
  <li><strong>Kalman 필터</strong>: 시스템 모델(상수 속도 가정)과 측정치를 결합.</li>
  <li><strong>Spline 보간</strong>: 부드러운 곡선으로 궤적 모델링.</li>
</ol>

<p>Kalman 필터가 가장 정확하지만 계산량이 많고, 이동 평균이 가장 간단하지만 지연이 늘어납니다. 실무에서는 보통 <strong>Gaussian 필터 with lookahead(향후 정보 활용)</strong>를 선택합니다.</p>

<p>(D2 도식 참조 - 3축 회전(Yaw/Pitch/Roll) 시각화)</p>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 430" style="max-width:100%;height:auto;display:block;margin:0 auto;" role="img" aria-label="카메라 3축 흔들림 모델: Yaw, Pitch, Roll">
  <!-- Background -->
  <rect width="700" height="430" fill="#f8f9fa" rx="14" stroke="#dee2e6" stroke-width="1.5" />

  <!-- Title -->
  <text x="350" y="40" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="20" font-weight="700" fill="#212529">카메라 3축 흔들림 모델</text>
  <text x="350" y="62" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="13" fill="#6c757d">Yaw (좌우) · Pitch (상하) · Roll (비틀림)</text>

  <!-- ===== COORDINATE AXES ===== -->
  <!-- X axis — red, horizontal right -->
  <line x1="310" y1="235" x2="510" y2="235" stroke="#e74c3c" stroke-width="3" stroke-linecap="round" />
  <polygon points="522,235 505,227 505,243" fill="#e74c3c" />
  <text x="532" y="231" font-family="Manrope,Arial,sans-serif" font-size="16" fill="#e74c3c" font-weight="700">X</text>
  <text x="530" y="250" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#e74c3c">(좌우)</text>
  <text x="410" y="220" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#e74c3c" font-weight="600" font-style="italic">Pitch</text>

  <!-- Y axis — green, vertical up -->
  <line x1="310" y1="235" x2="310" y2="82" stroke="#27ae60" stroke-width="3" stroke-linecap="round" />
  <polygon points="310,70 302,87 318,87" fill="#27ae60" />
  <text x="322" y="75" font-family="Manrope,Arial,sans-serif" font-size="16" fill="#27ae60" font-weight="700">Y</text>
  <text x="320" y="94" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#27ae60">(상하)</text>
  <text x="265" y="155" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#27ae60" font-weight="600" font-style="italic">Yaw</text>

  <!-- Z axis — blue, diagonal (depth/into screen) -->
  <line x1="310" y1="235" x2="168" y2="348" stroke="#2980b9" stroke-width="3" stroke-linecap="round" />
  <polygon points="160,356 170,337 182,349" fill="#2980b9" />
  <text x="115" y="368" font-family="Manrope,Arial,sans-serif" font-size="16" fill="#2980b9" font-weight="700">Z</text>
  <text x="108" y="388" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#2980b9">(앞 / 뒤)</text>
  <text x="235" y="285" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#2980b9" font-weight="600" font-style="italic">Roll</text>

  <!-- ===== CAMERA BODY ===== -->
  <!-- Main body -->
  <rect x="270" y="206" width="80" height="58" rx="8" fill="#495057" stroke="#212529" stroke-width="2" />
  <!-- Viewfinder bump -->
  <rect x="284" y="196" width="32" height="13" rx="4" fill="#343a40" stroke="#212529" stroke-width="1.5" />
  <!-- Shutter button -->
  <circle cx="338" cy="205" r="5.5" fill="#868e96" stroke="#6c757d" stroke-width="1.2" />
  <!-- Lens ring outer -->
  <circle cx="310" cy="235" r="24" fill="#343a40" stroke="#212529" stroke-width="2.5" />
  <!-- Lens glass -->
  <circle cx="310" cy="235" r="16" fill="#2471a3" opacity="0.85" />
  <!-- Lens highlight -->
  <circle cx="310" cy="235" r="8" fill="#85c1e9" opacity="0.9" />
  <circle cx="305" cy="230" r="3" fill="#fff" opacity="0.55" />
  <!-- Grip side -->
  <rect x="346" y="210" width="6" height="48" rx="3" fill="#6c757d" />

  <!-- ===== YAW ROTATION ARROW (Y axis) ===== -->
  <!-- YAW — arc around Y axis (horizontal plane, left↔right sweep) -->
  <path d="M 334 180 A 24 9 0 0 1 286 180" fill="none" stroke="#27ae60" stroke-width="2.8" stroke-dasharray="9,5" />
  <polygon points="286,180 298,176 296,188" fill="#27ae60" />

  <!-- ===== PITCH ROTATION ARROW (X axis) ===== -->
  <!-- PITCH — arc around X axis (vertical plane, up↕down sweep) -->
  <path d="M 450 210 A 9 24 0 0 1 450 260" fill="none" stroke="#e74c3c" stroke-width="2.8" stroke-dasharray="9,5" />
  <polygon points="450,260 446,248 454,250" fill="#e74c3c" />

  <!-- ===== ROLL ROTATION ARROW (Z axis) ===== -->
  <!-- ROLL — arc around Z axis (circular arc around Z axis center) -->
  <path d="M 239 268 A 24 24 0 0 1 263 292" fill="none" stroke="#2980b9" stroke-width="2.8" stroke-dasharray="9,5" />
  <polygon points="263,292 255,283 259,300" fill="#2980b9" />

  <!-- ===== LEGEND BAR ===== -->
  <rect x="18" y="398" width="664" height="24" rx="6" fill="#e9ecef" stroke="#dee2e6" stroke-width="1" />
  <circle cx="44" cy="410" r="6" fill="#e74c3c" />
  <text x="56" y="414" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#495057">Yaw = 수평 좌우 회전</text>
  <circle cx="228" cy="410" r="6" fill="#27ae60" />
  <text x="240" y="414" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#495057">Pitch = 수직 상하 기울기</text>
  <circle cx="430" cy="410" r="6" fill="#2980b9" />
  <text x="442" y="414" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#495057">Roll = 광축 기준 비틀림</text>
</svg>

<hr />

<h2 id="motion-estimation-움직임의-측정">Motion Estimation: 움직임의 측정</h2>

<p>두 프레임 간의 움직임을 측정하는 방법은 크게 두 가지입니다.</p>

<p><strong>방식 1: 특징점 기반 방식(Feature-Based)</strong></p>

<p>프레임에서 특징적인 점(코너, 엣지 등)을 찾아서:</p>
<ol>
  <li>현재와 이전 프레임에서 특징점 검출 (ORB, FAST 등)</li>
  <li>두 프레임의 특징점을 매칭 (어떤 점이 어디로 이동했는지 찾기)</li>
  <li>RANSAC으로 잘못된 매칭 제거</li>
  <li>남은 점들로 호모그래피 계산</li>
</ol>

<p>장점은 빠르고 간단하다는 것입니다. 밝기가 조금 변해도 잘 작동합니다. 단점은 특징점이 없는 영역(밤하늘, 흰 벽 같은)에서 무너질 수 있다는 점입니다.</p>

<p><strong>방식 2: Dense Optical Flow</strong></p>

<p>모든 픽셀에 대해 움직임을 추정합니다.</p>
<ul>
  <li><strong>Farnebäck</strong>: 다항식 계수 기반. 빠름, 중간 정확도.</li>
  <li><strong>DIS (Dense Inverse Search)</strong>: 실시간 비디오용. Farnebäck보다 빠름.</li>
  <li><strong>RAFT</strong>: 최신 신경망 기반. 정확하지만 느림(5~10 fps @ 1080p).</li>
</ul>

<p><strong>장점</strong>: 밀도 높은 정보, 특징점 의존성 없음.
<strong>단점</strong>: 계산량 많음, 그래픽 카드 필요(RAFT의 경우).</p>

<p><strong>방식 3: 하이브리드 접근</strong></p>

<p>최근 추세는 두 방식을 결합하는 것입니다:</p>

<ol>
  <li><strong>조대(Coarse) 단계</strong>: Dense optical flow로 전체 그림 파악.</li>
  <li><strong>세밀(Fine) 단계</strong>: Feature-based로 중요 영역 정밀화.</li>
</ol>

<p>이렇게 하면 특징점이 없는 영역도 처리하고, 계산량도 줄일 수 있습니다.</p>

<p><strong>호모그래피 vs 어파인(Affine) 변환</strong></p>

<p>간단한 경우(평행이동, 회전, 스케일)에는 <strong>Affine 변환</strong>으로 충분합니다:</p>

\[\begin{pmatrix} x' \\ y' \end{pmatrix} = \begin{pmatrix} a &amp; b \\ c &amp; d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} + \begin{pmatrix} e \\ f \end{pmatrix}\]

<p>매개변수는 6개(a, b, c, d, e, f)이므로 3개 점이 필요합니다. 호모그래피는 원근 변환까지 포함하므로 8개 매개변수이고 4개 점이 필요합니다.</p>

<p>실무에서는 <strong>호모그래피를 기본으로 사용</strong>하되, 카메라가 렌즈 중심축에 대해 큰 각도로 회전하지 않는 한 근사적으로 Affine처럼 동작합니다.</p>

<p><strong>성능 팁: 다운샘플 추정</strong></p>

<p>4K(3840×2160) 영상에서 매 프레임마다 호모그래피를 추정하는 것은 느립니다. 대신:</p>

<ol>
  <li>원본을 540p로 다운샘플.</li>
  <li>540p에서 호모그래피 $H_{540}$ 추정.</li>
  <li>$H_{4K} = \text{scale}(3840/540) \cdot H_{540}$ 로 스케일 조정.</li>
  <li>$H_{4K}$를 원본 4K 프레임에 적용.</li>
</ol>

<p>이 방식으로 계산량을 <strong>대략 50배</strong> 줄일 수 있습니다.</p>

<p><strong>실패 케이스와 Fallback</strong></p>

<p>호모그래피 추정이 실패할 수 있습니다:</p>
<ul>
  <li>장면 컷(Scene cut): 이전 프레임과 완전히 다른 내용.</li>
  <li>대규모 움직임(Fast pan): 특징점이 화면을 벗어남.</li>
  <li>움직이는 피사체: 배경 특징점만 감지하여 잘못된 추정.</li>
</ul>

<p>이 경우 <strong>confidence score</strong>를 계산하고, 신뢰도가 낮으면:</p>
<ul>
  <li>직전 프레임의 모션 재사용.</li>
  <li>또는 항등 변환(Identity transform) 적용 (보정 없음).</li>
</ul>

<p><strong>신뢰도 점수 계산 방법</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>confidence = (RANSAC 내부점 수) / (전체 매칭점 수)
</code></pre></div></div>

<p>예를 들어, 1000개 점 중 800개가 호모그래피를 만족하면 confidence = 0.8입니다. 보통 임계값(threshold):</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">confidence &gt; 0.7</code>: 신뢰 가능, 호모그래피 적용.</li>
  <li><code class="language-plaintext highlighter-rouge">0.3 &lt; confidence &lt;= 0.7</code>: 경고, 큰 움직임 감지, Fallback 고려.</li>
  <li><code class="language-plaintext highlighter-rouge">confidence &lt;= 0.3</code>: 신뢰 불가, Fallback (이전 모션 또는 항등 변환).</li>
</ul>

<p><strong>코드 샘플 1: Python + OpenCV로 호모그래피 추정하기</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">cv2</span>
<span class="kn">import</span> <span class="n">numpy</span> <span class="k">as</span> <span class="n">np</span>

<span class="k">def</span> <span class="nf">estimate_homography</span><span class="p">(</span><span class="n">prev_frame</span><span class="p">,</span> <span class="n">curr_frame</span><span class="p">,</span> <span class="n">downsample_factor</span><span class="o">=</span><span class="mi">4</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">두 프레임 간 흔들림을 호모그래피로 구합니다.
    
    4K 프레임 전체를 매번 분석하면 너무 느리므로, 해상도를 줄여서 빠르게 계산합니다.
    </span><span class="sh">"""</span>
    <span class="n">prev_gray</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">cvtColor</span><span class="p">(</span><span class="n">prev_frame</span><span class="p">,</span> <span class="n">cv2</span><span class="p">.</span><span class="n">COLOR_BGR2GRAY</span><span class="p">)</span>
    <span class="n">curr_gray</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">cvtColor</span><span class="p">(</span><span class="n">curr_frame</span><span class="p">,</span> <span class="n">cv2</span><span class="p">.</span><span class="n">COLOR_BGR2GRAY</span><span class="p">)</span>
    
    <span class="c1"># 다운샘플
</span>    <span class="n">h</span><span class="p">,</span> <span class="n">w</span> <span class="o">=</span> <span class="n">prev_gray</span><span class="p">.</span><span class="n">shape</span>
    <span class="n">down_h</span><span class="p">,</span> <span class="n">down_w</span> <span class="o">=</span> <span class="n">h</span> <span class="o">//</span> <span class="n">downsample_factor</span><span class="p">,</span> <span class="n">w</span> <span class="o">//</span> <span class="n">downsample_factor</span>
    <span class="n">prev_down</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">resize</span><span class="p">(</span><span class="n">prev_gray</span><span class="p">,</span> <span class="p">(</span><span class="n">down_w</span><span class="p">,</span> <span class="n">down_h</span><span class="p">))</span>
    <span class="n">curr_down</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">resize</span><span class="p">(</span><span class="n">curr_gray</span><span class="p">,</span> <span class="p">(</span><span class="n">down_w</span><span class="p">,</span> <span class="n">down_h</span><span class="p">))</span>
    
    <span class="c1"># ORB 특징점 검출 및 매칭
</span>    <span class="n">orb</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nc">ORB_create</span><span class="p">(</span><span class="n">nfeatures</span><span class="o">=</span><span class="mi">5000</span><span class="p">)</span>
    <span class="n">kp1</span><span class="p">,</span> <span class="n">des1</span> <span class="o">=</span> <span class="n">orb</span><span class="p">.</span><span class="nf">detectAndCompute</span><span class="p">(</span><span class="n">prev_down</span><span class="p">,</span> <span class="bp">None</span><span class="p">)</span>
    <span class="n">kp2</span><span class="p">,</span> <span class="n">des2</span> <span class="o">=</span> <span class="n">orb</span><span class="p">.</span><span class="nf">detectAndCompute</span><span class="p">(</span><span class="n">curr_down</span><span class="p">,</span> <span class="bp">None</span><span class="p">)</span>
    
    <span class="k">if</span> <span class="n">des1</span> <span class="ow">is</span> <span class="bp">None</span> <span class="ow">or</span> <span class="n">des2</span> <span class="ow">is</span> <span class="bp">None</span> <span class="ow">or</span> <span class="nf">len</span><span class="p">(</span><span class="n">kp1</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">4</span> <span class="ow">or</span> <span class="nf">len</span><span class="p">(</span><span class="n">kp2</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">4</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="nf">eye</span><span class="p">(</span><span class="mi">3</span><span class="p">),</span> <span class="mf">0.0</span>
    
    <span class="c1"># Brute-force 매칭
</span>    <span class="n">bf</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nc">BFMatcher</span><span class="p">(</span><span class="n">cv2</span><span class="p">.</span><span class="n">NORM_HAMMING</span><span class="p">,</span> <span class="n">crossCheck</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">matches</span> <span class="o">=</span> <span class="n">bf</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="n">des1</span><span class="p">,</span> <span class="n">des2</span><span class="p">)</span>
    
    <span class="k">if</span> <span class="nf">len</span><span class="p">(</span><span class="n">matches</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">4</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="nf">eye</span><span class="p">(</span><span class="mi">3</span><span class="p">),</span> <span class="mf">0.0</span>
    
    <span class="c1"># 매칭점 추출
</span>    <span class="n">src_pts</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">float32</span><span class="p">([</span><span class="n">kp1</span><span class="p">[</span><span class="n">m</span><span class="p">.</span><span class="n">queryIdx</span><span class="p">].</span><span class="n">pt</span> <span class="k">for</span> <span class="n">m</span> <span class="ow">in</span> <span class="n">matches</span><span class="p">]).</span><span class="nf">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
    <span class="n">dst_pts</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">float32</span><span class="p">([</span><span class="n">kp2</span><span class="p">[</span><span class="n">m</span><span class="p">.</span><span class="n">trainIdx</span><span class="p">].</span><span class="n">pt</span> <span class="k">for</span> <span class="n">m</span> <span class="ow">in</span> <span class="n">matches</span><span class="p">]).</span><span class="nf">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
    
    <span class="c1"># RANSAC으로 호모그래피 추정
</span>    <span class="n">H_down</span><span class="p">,</span> <span class="n">mask</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">findHomography</span><span class="p">(</span><span class="n">src_pts</span><span class="p">,</span> <span class="n">dst_pts</span><span class="p">,</span> <span class="n">cv2</span><span class="p">.</span><span class="n">RANSAC</span><span class="p">,</span> <span class="mf">5.0</span><span class="p">)</span>
    
    <span class="k">if</span> <span class="n">H_down</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="nf">eye</span><span class="p">(</span><span class="mi">3</span><span class="p">),</span> <span class="mf">0.0</span>
    
    <span class="c1"># 신뢰도 계산: RANSAC 내부점 비율
</span>    <span class="n">inlier_count</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">sum</span><span class="p">(</span><span class="n">mask</span><span class="p">)</span>
    <span class="n">confidence</span> <span class="o">=</span> <span class="nf">min</span><span class="p">(</span><span class="mf">1.0</span><span class="p">,</span> <span class="n">inlier_count</span> <span class="o">/</span> <span class="nf">len</span><span class="p">(</span><span class="n">matches</span><span class="p">))</span>
    
    <span class="c1"># 스케일 조정: 다운샘플 → 원본 해상도
</span>    <span class="n">scale_matrix</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">array</span><span class="p">([</span>
        <span class="p">[</span><span class="n">downsample_factor</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">],</span>
        <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="n">downsample_factor</span><span class="p">,</span> <span class="mi">0</span><span class="p">],</span>
        <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">]</span>
    <span class="p">])</span>
    <span class="n">H</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="nf">inv</span><span class="p">(</span><span class="n">scale_matrix</span><span class="p">)</span> <span class="o">@</span> <span class="n">H_down</span> <span class="o">@</span> <span class="n">scale_matrix</span>
    
    <span class="k">return</span> <span class="n">H</span><span class="p">,</span> <span class="n">confidence</span>
</code></pre></div></div>

<p>(D3 도식 참조 - Feature 검출→매칭→이상치 제거→호모그래피 파이프라인)</p>

<pre><code class="language-mermaid">flowchart TD
    A["🎬 Frame t"]
    B["🎬 Frame t+1"]
    C["Step 1 · 두 프레임 수신"]
    D["Step 2 · Feature Detection&lt;br/&gt;🔎 ORB / FAST 특징점 검출"]
    E["Step 3 · Feature Matching&lt;br/&gt;🔗 KNN 매칭 (k = 2, ratio test)"]
    F["Step 4 · Outlier Rejection&lt;br/&gt;🎯 RANSAC 이상치 제거"]
    G["Step 5 · Homography 계산&lt;br/&gt;📐 최소자승법 (DLT 알고리즘)"]
    H["✅ H 행렬 출력&lt;br/&gt;3 × 3 투시 변환"]

    A --&gt; C
    B --&gt; C
    C --&gt; D --&gt; E --&gt; F --&gt; G --&gt; H

    style A fill:#6c3483,color:#fff,stroke:#5b2c6f
    style B fill:#6c3483,color:#fff,stroke:#5b2c6f
    style C fill:#34495e,color:#fff,stroke:#2c3e50
    style D fill:#1a5276,color:#fff,stroke:#154360
    style E fill:#1a5276,color:#fff,stroke:#154360
    style F fill:#784212,color:#fff,stroke:#6e2f1a
    style G fill:#1a5276,color:#fff,stroke:#154360
    style H fill:#145a32,color:#fff,stroke:#0e3d22
</code></pre>

<hr />

<h2 id="gstreamer-파이프라인-구현">GStreamer 파이프라인 구현</h2>

<p>이제 실제로 이 스태빌라이저를 GStreamer로 구현해봅시다. GStreamer는 오픈소스 멀티미디어 프레임워크로, 작은 부품(Elements)들을 레고처럼 조립해서 영상 처리 파이프라인을 만듭니다.</p>

<p><strong>전체 구조는 이렇습니다:</strong></p>

<p>입력(카메라나 파일) → 디코딩 → 분기 → [모션 추정 경로] + [변환 경로] → 합성 → 인코딩 → 출력</p>

<p>분기점에서 두 가지 경로로 나뉩니다:</p>
<ul>
  <li><strong>추정 경로</strong>: 빠른 계산을 위해 해상도를 낮춰서 호모그래피 계산</li>
  <li><strong>변환 경로</strong>: 원본 해상도에 계산된 호모그래피를 적용하고 자르기</li>
</ul>

<p>두 경로가 다시 만나서 최종 영상을 만듭니다.</p>

<p><strong>상세 구조:</strong></p>

<pre><code class="language-mermaid">graph LR
    SRC["📹 v4l2src&lt;br/&gt;또는 filesrc"]
    PARSE["h264parse"]
    DEC["avdec_h264&lt;br/&gt;디코더"]
    TEE["⬡ tee&lt;br/&gt;분기"]

    subgraph b1["Branch 1 — 모션 추정 경로 (다운샘플)"]
        SCALE["videoscale&lt;br/&gt;720p 다운샘플"]
        VC1["videoconvert&lt;br/&gt;RGB 변환"]
        STAB["⚙️ gststabilizer&lt;br/&gt;Motion Estimation"]
        META["H 행렬&lt;br/&gt;GstMeta 첨부"]
    end

    subgraph b2["Branch 2 — 변환·Crop 경로 (풀 해상도)"]
        QUEUE["queue&lt;br/&gt;버퍼 동기화"]
        VC2["videoconvert"]
        WARP["✂️ gstwarpcrop&lt;br/&gt;Warp + Crop → 1920×1080"]
    end

    COMP["compositor&lt;br/&gt;합성"]
    ENC["x264enc&lt;br/&gt;인코더"]
    MUX["mp4mux"]
    SINK["📁 filesink&lt;br/&gt;출력"]

    SRC --&gt; PARSE --&gt; DEC --&gt; TEE
    TEE --&gt;|"src_0"| SCALE
    SCALE --&gt; VC1 --&gt; STAB --&gt; META
    TEE --&gt;|"src_1"| QUEUE
    QUEUE --&gt; VC2 --&gt; WARP
    META -.-&gt;|"H 행렬 GstMeta"| WARP
    META --&gt; COMP
    WARP --&gt; COMP
    COMP --&gt; ENC --&gt; MUX --&gt; SINK

    style SRC fill:#2c3e50,color:#fff,stroke:#1a252f
    style PARSE fill:#34495e,color:#fff,stroke:#2c3e50
    style DEC fill:#34495e,color:#fff,stroke:#2c3e50
    style TEE fill:#e67e22,color:#fff,stroke:#d35400
    style SCALE fill:#5d6d7e,color:#fff,stroke:#4a5568
    style VC1 fill:#5d6d7e,color:#fff,stroke:#4a5568
    style STAB fill:#2980b9,color:#fff,stroke:#1a5276
    style META fill:#27ae60,color:#fff,stroke:#1e8449
    style QUEUE fill:#5d6d7e,color:#fff,stroke:#4a5568
    style VC2 fill:#5d6d7e,color:#fff,stroke:#4a5568
    style WARP fill:#2980b9,color:#fff,stroke:#1a5276
    style COMP fill:#8e44ad,color:#fff,stroke:#6c3483
    style ENC fill:#34495e,color:#fff,stroke:#2c3e50
    style MUX fill:#34495e,color:#fff,stroke:#2c3e50
    style SINK fill:#2c3e50,color:#fff,stroke:#1a252f
</code></pre>

<blockquote>
  <p><strong>데이터 흐름 범례:</strong> 실선(→) = 비디오 프레임 흐름 / 점선(-.→) = H 행렬 메타데이터 채널<br />
<strong>커스텀 element:</strong> <code class="language-plaintext highlighter-rouge">gststabilizer</code> (모션 추정), <code class="language-plaintext highlighter-rouge">gstwarpcrop</code> (워프·크롭)</p>
</blockquote>

<p><strong>왜 이러한 구조인가?</strong></p>

<p>GStreamer의 <strong>tee(분배)</strong> 요소는 한 개의 입력을 여러 출력으로 복제합니다. 우리는 이를 활용하여:</p>

<ol>
  <li><strong>저해상도 추정 분기</strong>: 540p로 다운스케일하여 빠르게 호모그래피 계산.</li>
  <li><strong>버퍼 분기</strong>: 추정이 끝날 때까지 버퍼에 프레임 저장.</li>
  <li><strong>합성 단계</strong>: 추정된 호모그래피를 원본 프레임에 적용.</li>
</ol>

<p>이 구조의 장점:</p>
<ul>
  <li><strong>병렬 처리</strong>: 추정과 버퍼링이 동시에 진행.</li>
  <li><strong>유연성</strong>: queue의 깊이를 조정하여 지연과 품질의 트레이드오프 제어.</li>
  <li><strong>확장성</strong>: YOLO/SAM 등 추가 분석을 쉽게 삽입 가능.</li>
</ul>

<p><strong>커스텀 Element 설계</strong></p>

<p>GStreamer의 강점은 <strong>커스텀 Element</strong>를 작성할 수 있다는 점입니다. 두 가지 요소를 정의합니다:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">gststabilizer</code></strong>:
    <ul>
      <li>입력: 프레임 스트림</li>
      <li>처리: 호모그래피 추정, 부드러운 궤적 계산</li>
      <li>출력: 동일 프레임 + GStreamer metadata(<code class="language-plaintext highlighter-rouge">GstStabilizerMotionMeta</code>)로 $H$ 행렬과 confidence 첨부</li>
      <li>상태: 이전 프레임 저장, 누적 모션 추적</li>
    </ul>
  </li>
  <li><strong><code class="language-plaintext highlighter-rouge">gstwarpcrop</code></strong>:
    <ul>
      <li>입력: 프레임 + <code class="language-plaintext highlighter-rouge">GstStabilizerMotionMeta</code></li>
      <li>처리: 호모그래피 변환 적용 (<code class="language-plaintext highlighter-rouge">cv2.warpPerspective</code>), 안전 영역만 crop</li>
      <li>출력: 안정화된 출력 프레임</li>
    </ul>
  </li>
</ol>

<p><strong>Latency 전략</strong></p>

<p>실시간성과 품질 사이의 트레이드오프가 있습니다:</p>

<ul>
  <li><strong>Lookahead</strong>: 현재 프레임의 보정을 계산하기 위해 향후 N프레임을 봅니다. N이 크면 더 좋은 궤적 계산 가능하지만 지연 증가.
    <ul>
      <li>N=0 (Causality only): 지연 최소, 품질 낮음.</li>
      <li>N=5: 대략 5프레임(60fps 기준 83ms) 지연, 품질 우수.</li>
      <li>N=15: 대략 15프레임(250ms) 지연. 대부분의 실시간 애플리케이션에는 부담.</li>
    </ul>
  </li>
  <li><strong>Queue 깊이</strong>: <code class="language-plaintext highlighter-rouge">gststabilizer</code> 이후 queue 크기를 조정하여 버퍼링 제어.</li>
</ul>

<p><strong>Crop Window 산수</strong></p>

<p>4K 원본을 1080p로 출력할 때의 crop 계산:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>원본 크기: 3840 × 2160
출력 크기: 1920 × 1080 (1080p)

스케일 비율: 3840 / 1920 = 2
따라서 원본에서 4×4 픽셀은 출력에서 2×2 픽셀

카메라 떨림 마진(예: 10%):
- 원본 너비 마진: 3840 × 0.1 = 384 픽셀
- 원본 높이 마진: 2160 × 0.1 = 216 픽셀

안전 영역(Safe Zone):
- x: [192, 3648] (양쪽 192px 제거)
- y: [108, 2052] (위아래 108px 제거)
- 크기: 3456 × 1944

이 안전 영역을 1080p로 스케일:
- 최종 출력: 1920 × 1080
</code></pre></div></div>

<p>호모그래피로 인한 왜곡으로 프레임의 모서리가 손실될 수 있으므로, 더 보수적인 마진(예: 15%)을 설정하는 것이 일반적입니다.</p>

<p><strong>마진 선택의 트레이드오프</strong></p>

<p>마진이 클수록:</p>
<ul>
  <li>더 많은 떨림 보정 가능 (큰 움직임 수용).</li>
  <li>하지만 최종 해상도가 더 떨어짐.</li>
</ul>

<p>마진이 작을수록:</p>
<ul>
  <li>최종 해상도 유지.</li>
  <li>하지만 떨림 보정 범위 제한.</li>
</ul>

<p>실무 권장값:</p>
<ul>
  <li><strong>일반</strong>: 10~15% (가장 흔함).</li>
  <li><strong>높은 안정성 필요</strong>: 15~20% (드론, 자동차 촬영).</li>
  <li><strong>해상도 우선</strong>: 5~10% (정적 카메라 또는 약한 떨림).</li>
</ul>

<p>예를 들어, 마진 10%이면 3840×2160 (4K)이 3456×1944 안전 영역이 되고, 이를 1920×1080 (1080p)로 스케일하면 대략 1.8배 다운샘플링으로 약간의 선명도 손실이 발생합니다.</p>

<p>(D5 도식 참조 - Crop window 시각화: 입력 프레임 + safe zone + 출력)</p>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 460" style="max-width:100%;height:auto;display:block;margin:0 auto;" role="img" aria-label="Crop Window 계산: 4K에서 마진을 제외하고 1080p를 추출하는 구조">
  <!-- Background -->
  <rect width="640" height="460" fill="#f8f9fa" rx="14" stroke="#dee2e6" stroke-width="1.5" />

  <!-- Title -->
  <text x="320" y="38" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="19" font-weight="700" fill="#212529">Crop Window 계산 — 마진 구조 분석</text>

  <!-- ===== OUTER: 4K FRAME ===== -->
  <rect x="30" y="58" width="580" height="326" rx="10" fill="#fff3cd" stroke="#f9a825" stroke-width="3" />
  <text x="320" y="84" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="15" font-weight="700" fill="#856404">4K 입력 프레임: 3840 × 2160</text>

  <!-- Shake guard margin labels (top, left) -->
  <!-- Left margin brace -->
  <line x1="30" y1="220" x2="100" y2="220" stroke="#f39c12" stroke-width="1.5" stroke-dasharray="4,3" />
  <line x1="30" y1="215" x2="30" y2="225" stroke="#f39c12" stroke-width="2" />
  <line x1="100" y1="215" x2="100" y2="225" stroke="#f39c12" stroke-width="2" />
  <text x="65" y="213" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="11" fill="#b7770d" font-weight="600">≈300px</text>
  <text x="65" y="236" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="10" fill="#b7770d">흔들림 여유</text>

  <!-- Right margin brace -->
  <line x1="540" y1="220" x2="610" y2="220" stroke="#f39c12" stroke-width="1.5" stroke-dasharray="4,3" />
  <line x1="540" y1="215" x2="540" y2="225" stroke="#f39c12" stroke-width="2" />
  <line x1="610" y1="215" x2="610" y2="225" stroke="#f39c12" stroke-width="2" />
  <text x="575" y="213" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="11" fill="#b7770d" font-weight="600">≈300px</text>

  <!-- ===== MIDDLE: SAFE ZONE ===== -->
  <rect x="100" y="108" width="440" height="246" rx="7" fill="#dfe6e9" stroke="#74b9ff" stroke-width="2.5" stroke-dasharray="10,5" />
  <text x="320" y="128" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="13" fill="#2d3436" font-weight="600">Safe Zone — 안정화 가능 영역</text>
  <text x="320" y="146" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="11" fill="#636e72">(Crop Window가 이동 가능한 최대 범위)</text>

  <!-- Top margin brace -->
  <line x1="320" y1="58" x2="320" y2="108" stroke="#f39c12" stroke-width="1.5" stroke-dasharray="4,3" />
  <line x1="315" y1="58" x2="325" y2="58" stroke="#f39c12" stroke-width="2" />
  <line x1="315" y1="108" x2="325" y2="108" stroke="#f39c12" stroke-width="2" />
  <text x="338" y="87" font-family="Manrope,Arial,sans-serif" font-size="11" fill="#b7770d" font-weight="600">≈300px</text>

  <!-- ===== INNER: 1080p CROP WINDOW ===== -->
  <rect x="170" y="166" width="300" height="169" rx="6" fill="#81ecec" stroke="#00b894" stroke-width="3" />
  <text x="320" y="246" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="14" fill="#00635a" font-weight="700">출력: 1920 × 1080</text>
  <text x="320" y="267" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="12" fill="#00635a">Crop 영역 (최종 출력)</text>

  <!-- Safe → Crop margin arrows (left) -->
  <line x1="100" y1="251" x2="170" y2="251" stroke="#00b894" stroke-width="1.5" stroke-dasharray="4,3" />
  <line x1="100" y1="246" x2="100" y2="256" stroke="#00b894" stroke-width="2" />
  <line x1="170" y1="246" x2="170" y2="256" stroke="#00b894" stroke-width="2" />
  <text x="135" y="244" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="10" fill="#00635a">최대 이동</text>
  <text x="135" y="268" text-anchor="middle" font-family="Manrope,Arial,sans-serif" font-size="10" fill="#00635a">±660px</text>

  <!-- ===== FORMULA BOX ===== -->
  <rect x="30" y="398" width="580" height="50" rx="8" fill="#2d3436" />
  <text x="320" y="420" text-anchor="middle" font-family="'Roboto Mono',monospace,sans-serif" font-size="12" fill="#fdcb6e" font-weight="600">마진(좌우) = (3840 − 1920) / 2 = 960 px  |  마진(상하) = (2160 − 1080) / 2 = 540 px</text>
  <text x="320" y="440" text-anchor="middle" font-family="'Roboto Mono',monospace,sans-serif" font-size="11" fill="#b2bec3">흔들림 보정 여유 ≈ 300 px  →  실제 Crop 이동 여유 = 960 − 300 = 660 px (좌우 각 방향)</text>
</svg>

<p><strong>코드 샘플 2: gst-launch-1.0 Prototyping</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># USB 카메라를 안정화하여 H.264로 인코딩하고 파일로 저장하는 예</span>
gst-launch-1.0 <span class="nt">-e</span> <span class="se">\</span>
  v4l2src <span class="nv">device</span><span class="o">=</span>/dev/video0 <span class="o">!</span> <span class="se">\</span>
  video/x-raw,width<span class="o">=</span>3840,height<span class="o">=</span>2160,framerate<span class="o">=</span>30/1 <span class="o">!</span> <span class="se">\</span>
  videoscale <span class="o">!</span> video/x-raw,width<span class="o">=</span>1920,height<span class="o">=</span>1080 <span class="o">!</span> <span class="se">\</span>
  gststabilizer <span class="nv">lookahead</span><span class="o">=</span>5 <span class="nv">downsample</span><span class="o">=</span>4 <span class="o">!</span> <span class="se">\</span>
  gstwarpcrop <span class="nv">margin</span><span class="o">=</span>0.15 <span class="o">!</span> <span class="se">\</span>
  x264enc <span class="o">!</span> <span class="se">\</span>
  h264parse <span class="o">!</span> <span class="se">\</span>
  mp4mux <span class="o">!</span> <span class="se">\</span>
  filesink <span class="nv">location</span><span class="o">=</span>stabilized.mp4
</code></pre></div></div>

<p><strong>코드 샘플 3: Python + gi.repository GStreamer 바인딩</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">gi</span>
<span class="n">gi</span><span class="p">.</span><span class="nf">require_version</span><span class="p">(</span><span class="sh">'</span><span class="s">Gst</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">1.0</span><span class="sh">'</span><span class="p">)</span>
<span class="kn">from</span> <span class="n">gi.repository</span> <span class="kn">import</span> <span class="n">Gst</span><span class="p">,</span> <span class="n">GLib</span>
<span class="kn">import</span> <span class="n">cv2</span>
<span class="kn">import</span> <span class="n">numpy</span> <span class="k">as</span> <span class="n">np</span>

<span class="k">class</span> <span class="nc">GStreamerStabilizer</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">
    GStreamer 파이프라인에 통합되는 스태빌라이저 클래스.
    gststabilizer 커스텀 element의 골격.
    </span><span class="sh">"""</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">downsample</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">lookahead</span><span class="o">=</span><span class="mi">5</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">downsample</span> <span class="o">=</span> <span class="n">downsample</span>
        <span class="n">self</span><span class="p">.</span><span class="n">lookahead</span> <span class="o">=</span> <span class="n">lookahead</span>
        <span class="n">self</span><span class="p">.</span><span class="n">prev_frame</span> <span class="o">=</span> <span class="bp">None</span>
        <span class="n">self</span><span class="p">.</span><span class="n">motion_history</span> <span class="o">=</span> <span class="p">[]</span>  <span class="c1"># 모션 벡터 히스토리
</span>        <span class="n">self</span><span class="p">.</span><span class="n">smooth_trajectory</span> <span class="o">=</span> <span class="bp">None</span>  <span class="c1"># S_t
</span>    
    <span class="k">def</span> <span class="nf">process_frame</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">frame</span><span class="p">):</span>
        <span class="sh">"""</span><span class="s">
        한 프레임을 처리합니다.
        
        Args:
            frame: (H, W, 3) BGR numpy array
        
        Returns:
            H: 3x3 호모그래피 행렬
            confidence: 신뢰도
        </span><span class="sh">"""</span>
        <span class="k">if</span> <span class="n">self</span><span class="p">.</span><span class="n">prev_frame</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
            <span class="n">self</span><span class="p">.</span><span class="n">prev_frame</span> <span class="o">=</span> <span class="n">frame</span>
            <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="nf">eye</span><span class="p">(</span><span class="mi">3</span><span class="p">),</span> <span class="mf">1.0</span>
        
        <span class="c1"># 호모그래피 추정 (§3의 코드 샘플 1 사용)
</span>        <span class="n">H</span><span class="p">,</span> <span class="n">confidence</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_estimate_homography</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">prev_frame</span><span class="p">,</span> <span class="n">frame</span><span class="p">)</span>
        
        <span class="c1"># 모션 히스토리 저장
</span>        <span class="n">self</span><span class="p">.</span><span class="n">motion_history</span><span class="p">.</span><span class="nf">append</span><span class="p">((</span><span class="n">H</span><span class="p">,</span> <span class="n">confidence</span><span class="p">))</span>
        <span class="k">if</span> <span class="nf">len</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">motion_history</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">self</span><span class="p">.</span><span class="n">lookahead</span><span class="p">:</span>
            <span class="n">self</span><span class="p">.</span><span class="n">motion_history</span><span class="p">.</span><span class="nf">pop</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
        
        <span class="c1"># 부드러운 궤적 계산 (Gaussian 필터)
</span>        <span class="c1"># 실제 구현에서는 Kalman 필터나 더 정교한 방식 사용
</span>        <span class="n">smoothed_H</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_compute_smooth_trajectory</span><span class="p">(</span><span class="n">H</span><span class="p">)</span>
        
        <span class="n">self</span><span class="p">.</span><span class="n">prev_frame</span> <span class="o">=</span> <span class="n">frame</span><span class="p">.</span><span class="nf">copy</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">smoothed_H</span><span class="p">,</span> <span class="n">confidence</span>
    
    <span class="k">def</span> <span class="nf">_estimate_homography</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">prev</span><span class="p">,</span> <span class="n">curr</span><span class="p">):</span>
        <span class="sh">"""</span><span class="s">호모그래피 추정. 코드 샘플 1의 함수 사용.</span><span class="sh">"""</span>
        <span class="c1"># (여기서는 생략, 실제로는 코드 샘플 1의 함수 호출)
</span>        <span class="k">pass</span>
    
    <span class="k">def</span> <span class="nf">_compute_smooth_trajectory</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">H</span><span class="p">):</span>
        <span class="sh">"""</span><span class="s">
        현재 호모그래피와 히스토리를 이용해 부드러운 궤적을 계산합니다.
        간단한 예: 최근 N프레임의 평균.
        </span><span class="sh">"""</span>
        <span class="k">if</span> <span class="nf">len</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">motion_history</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">H</span>
        
        <span class="c1"># 호모그래피를 행렬로 축적할 수 없으므로, 
</span>        <span class="c1"># 실제로는 회전/스케일/평행이동 성분을 분해 후 평균
</span>        <span class="c1"># 여기서는 개념 시연용으로 직접 평균 (부정확함)
</span>        <span class="n">avg_H</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">mean</span><span class="p">([</span><span class="n">h</span> <span class="k">for</span> <span class="n">h</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="n">self</span><span class="p">.</span><span class="n">motion_history</span><span class="p">],</span> <span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">avg_H</span>
    
    <span class="k">def</span> <span class="nf">warp_and_crop</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">frame</span><span class="p">,</span> <span class="n">H</span><span class="p">,</span> <span class="n">margin</span><span class="o">=</span><span class="mf">0.15</span><span class="p">):</span>
        <span class="sh">"""</span><span class="s">
        호모그래피를 적용하고 마진만큼 crop합니다.
        
        Args:
            frame: 입력 프레임
            H: 호모그래피 행렬
            margin: 마진 비율 (0.15 = 15%)
        
        Returns:
            warped_frame: 변환 및 crop된 프레임
        </span><span class="sh">"""</span>
        <span class="n">h</span><span class="p">,</span> <span class="n">w</span> <span class="o">=</span> <span class="n">frame</span><span class="p">.</span><span class="n">shape</span><span class="p">[:</span><span class="mi">2</span><span class="p">]</span>
        
        <span class="c1"># 호모그래피 변환 적용
</span>        <span class="n">warped</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">warpPerspective</span><span class="p">(</span><span class="n">frame</span><span class="p">,</span> <span class="n">H</span><span class="p">,</span> <span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="n">h</span><span class="p">))</span>
        
        <span class="c1"># Crop 계산
</span>        <span class="n">crop_x</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">w</span> <span class="o">*</span> <span class="n">margin</span> <span class="o">/</span> <span class="mi">2</span><span class="p">)</span>
        <span class="n">crop_y</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">h</span> <span class="o">*</span> <span class="n">margin</span> <span class="o">/</span> <span class="mi">2</span><span class="p">)</span>
        <span class="n">crop_w</span> <span class="o">=</span> <span class="n">w</span> <span class="o">-</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">crop_x</span>
        <span class="n">crop_h</span> <span class="o">=</span> <span class="n">h</span> <span class="o">-</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">crop_y</span>
        
        <span class="n">cropped</span> <span class="o">=</span> <span class="n">warped</span><span class="p">[</span><span class="n">crop_y</span><span class="p">:</span><span class="n">crop_y</span> <span class="o">+</span> <span class="n">crop_h</span><span class="p">,</span> <span class="n">crop_x</span><span class="p">:</span><span class="n">crop_x</span> <span class="o">+</span> <span class="n">crop_w</span><span class="p">]</span>
        
        <span class="c1"># 다시 원본 크기로 스케일 (또는 고정된 출력 해상도)
</span>        <span class="n">output_h</span><span class="p">,</span> <span class="n">output_w</span> <span class="o">=</span> <span class="n">h</span> <span class="o">//</span> <span class="mi">2</span><span class="p">,</span> <span class="n">w</span> <span class="o">//</span> <span class="mi">2</span>  <span class="c1"># 예: 4K → 1080p
</span>        <span class="n">final</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">resize</span><span class="p">(</span><span class="n">cropped</span><span class="p">,</span> <span class="p">(</span><span class="n">output_w</span><span class="p">,</span> <span class="n">output_h</span><span class="p">))</span>
        
        <span class="k">return</span> <span class="n">final</span>

<span class="c1"># GStreamer 파이프라인 예
</span><span class="k">def</span> <span class="nf">create_stabilizer_pipeline</span><span class="p">():</span>
    <span class="n">Gst</span><span class="p">.</span><span class="nf">init</span><span class="p">(</span><span class="bp">None</span><span class="p">)</span>
    
    <span class="n">pipeline</span> <span class="o">=</span> <span class="n">Gst</span><span class="p">.</span><span class="n">Pipeline</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="sh">"</span><span class="s">stabilizer-pipeline</span><span class="sh">"</span><span class="p">)</span>
    
    <span class="c1"># 요소 생성
</span>    <span class="n">source</span> <span class="o">=</span> <span class="n">Gst</span><span class="p">.</span><span class="n">ElementFactory</span><span class="p">.</span><span class="nf">make</span><span class="p">(</span><span class="sh">"</span><span class="s">v4l2src</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">source</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">decoder</span> <span class="o">=</span> <span class="n">Gst</span><span class="p">.</span><span class="n">ElementFactory</span><span class="p">.</span><span class="nf">make</span><span class="p">(</span><span class="sh">"</span><span class="s">decodebin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">decoder</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">encoder</span> <span class="o">=</span> <span class="n">Gst</span><span class="p">.</span><span class="n">ElementFactory</span><span class="p">.</span><span class="nf">make</span><span class="p">(</span><span class="sh">"</span><span class="s">x264enc</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">encoder</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">sink</span> <span class="o">=</span> <span class="n">Gst</span><span class="p">.</span><span class="n">ElementFactory</span><span class="p">.</span><span class="nf">make</span><span class="p">(</span><span class="sh">"</span><span class="s">filesink</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">sink</span><span class="sh">"</span><span class="p">)</span>
    
    <span class="k">if</span> <span class="ow">not</span> <span class="nf">all</span><span class="p">([</span><span class="n">source</span><span class="p">,</span> <span class="n">decoder</span><span class="p">,</span> <span class="n">encoder</span><span class="p">,</span> <span class="n">sink</span><span class="p">]):</span>
        <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Failed to create elements</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">None</span>
    
    <span class="c1"># 속성 설정
</span>    <span class="n">source</span><span class="p">.</span><span class="nf">set_property</span><span class="p">(</span><span class="sh">"</span><span class="s">device</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">/dev/video0</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">sink</span><span class="p">.</span><span class="nf">set_property</span><span class="p">(</span><span class="sh">"</span><span class="s">location</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">stabilized.mp4</span><span class="sh">"</span><span class="p">)</span>
    
    <span class="c1"># 파이프라인에 추가
</span>    <span class="n">pipeline</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">decoder</span><span class="p">,</span> <span class="n">encoder</span><span class="p">,</span> <span class="n">sink</span><span class="p">)</span>
    
    <span class="c1"># 링크 (실제로는 더 많은 캡스 필터링 필요)
</span>    <span class="n">source</span><span class="p">.</span><span class="nf">link</span><span class="p">(</span><span class="n">decoder</span><span class="p">)</span>
    <span class="n">decoder</span><span class="p">.</span><span class="nf">link</span><span class="p">(</span><span class="n">encoder</span><span class="p">)</span>
    <span class="n">encoder</span><span class="p">.</span><span class="nf">link</span><span class="p">(</span><span class="n">sink</span><span class="p">)</span>
    
    <span class="k">return</span> <span class="n">pipeline</span>
</code></pre></div></div>

<hr />

<h2 id="ai-기반-고급-보정">AI 기반 고급 보정</h2>

<p>기본 호모그래피 기반 스태빌라이저도 효과적이지만, AI 기반 보정으로 한 단계 더 나아갈 수 있습니다.</p>

<p><strong>조명 변화 보정</strong></p>

<p>카메라가 떨릴 때 장면의 밝기가 급격히 변할 수 있습니다. Auto-exposure(자동 노출) 조정이 느려서 일부 프레임이 너무 밝거나 어두워집니다. 이를 <strong>조명 flicker</strong>라고 부릅니다.</p>

<p>해결 방법:</p>
<ul>
  <li><strong>CNN 기반 denoising</strong>: 밝기 변화 패턴을 학습한 신경망으로 평활화(smoothing).</li>
  <li><strong>LUT(Lookup Table) 기반</strong>: 프레임 간 밝기 차이를 분석하여 히스토그램 매칭.</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 간단한 예: 프레임 간 평균 밝기 차이로 보정
</span><span class="k">def</span> <span class="nf">compensate_exposure</span><span class="p">(</span><span class="n">frame</span><span class="p">,</span> <span class="n">prev_frame</span><span class="p">,</span> <span class="n">alpha</span><span class="o">=</span><span class="mf">0.8</span><span class="p">):</span>
    <span class="n">curr_brightness</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">mean</span><span class="p">(</span><span class="n">cv2</span><span class="p">.</span><span class="nf">cvtColor</span><span class="p">(</span><span class="n">frame</span><span class="p">,</span> <span class="n">cv2</span><span class="p">.</span><span class="n">COLOR_BGR2GRAY</span><span class="p">))</span>
    <span class="n">prev_brightness</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">mean</span><span class="p">(</span><span class="n">cv2</span><span class="p">.</span><span class="nf">cvtColor</span><span class="p">(</span><span class="n">prev_frame</span><span class="p">,</span> <span class="n">cv2</span><span class="p">.</span><span class="n">COLOR_BGR2GRAY</span><span class="p">))</span>
    
    <span class="c1"># 지수 이동 평균으로 부드럽게 보정
</span>    <span class="n">target_brightness</span> <span class="o">=</span> <span class="n">alpha</span> <span class="o">*</span> <span class="n">prev_brightness</span> <span class="o">+</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="n">alpha</span><span class="p">)</span> <span class="o">*</span> <span class="n">curr_brightness</span>
    <span class="n">adjustment</span> <span class="o">=</span> <span class="n">target_brightness</span> <span class="o">/</span> <span class="p">(</span><span class="n">curr_brightness</span> <span class="o">+</span> <span class="mf">1e-5</span><span class="p">)</span>
    
    <span class="k">return</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">convertScaleAbs</span><span class="p">(</span><span class="n">frame</span><span class="p">,</span> <span class="n">alpha</span><span class="o">=</span><span class="n">adjustment</span><span class="p">,</span> <span class="n">beta</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>피사체 인식 기반 안정화</strong></p>

<p>일반적인 스태빌라이저는 모든 픽셀을 동등하게 취급합니다. 하지만 <strong>메인 피사체(main subject)</strong>가 있다면, 그 영역을 우선적으로 안정화할 수 있습니다.</p>

<ul>
  <li><strong>YOLO(You Only Look Once)</strong>: 빠른 물체 감지. 실시간 비디오에 적합.</li>
  <li><strong>SAM(Segment Anything Model)</strong>: 프롬프트 기반 세분화. 특정 피사체만 추출.</li>
</ul>

<p>알고리즘:</p>
<ol>
  <li>YOLO로 메인 객체(사람, 자동차 등) 감지.</li>
  <li>해당 BBox(Bounding Box) 내의 특징점에 더 높은 가중치.</li>
  <li>가중치가 반영된 호모그래피 추정.</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 의사 코드: 가중치 기반 호모그래피
</span><span class="k">def</span> <span class="nf">weighted_homography</span><span class="p">(</span><span class="n">src_pts</span><span class="p">,</span> <span class="n">dst_pts</span><span class="p">,</span> <span class="n">weights</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">
    가중치가 적용된 RANSAC.
    </span><span class="sh">"""</span>
    <span class="c1"># 실제로는 weighted RANSAC 또는 Huber loss 사용
</span>    <span class="n">H</span><span class="p">,</span> <span class="n">mask</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">findHomography</span><span class="p">(</span><span class="n">src_pts</span><span class="p">,</span> <span class="n">dst_pts</span><span class="p">,</span> <span class="n">cv2</span><span class="p">.</span><span class="n">RANSAC</span><span class="p">,</span> <span class="mf">5.0</span><span class="p">)</span>
    <span class="c1"># 가중치는 outlier rejection에 반영
</span>    <span class="k">return</span> <span class="n">H</span>
</code></pre></div></div>

<p><strong>Edge Inpainting</strong></p>

<p>호모그래피 변환과 crop으로 인해 프레임의 가장자리가 손실됩니다. 이 부분을 복원(inpainting)할 수 있습니다.</p>

<ul>
  <li><strong>전통적</strong>: 인접 픽셀 복제 또는 선형 보간.</li>
  <li><strong>AI 기반</strong>: 조건부 생성 모델(Conditional GAN) 또는 확산 모델(Diffusion Model)로 자연스러운 콘텐츠 생성.</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">inpaint_edges</span><span class="p">(</span><span class="n">frame</span><span class="p">,</span> <span class="n">mask</span><span class="p">,</span> <span class="n">kernel_size</span><span class="o">=</span><span class="mi">5</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">
    마스크된 영역을 Telea 알고리즘으로 inpaint합니다.
    </span><span class="sh">"""</span>
    <span class="k">return</span> <span class="n">cv2</span><span class="p">.</span><span class="nf">inpaint</span><span class="p">(</span><span class="n">frame</span><span class="p">,</span> <span class="n">mask</span><span class="p">,</span> <span class="n">kernel_size</span><span class="p">,</span> <span class="n">cv2</span><span class="p">.</span><span class="n">INPAINT_TELEA</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>GStreamer 통합</strong></p>

<p>AI 모듈을 GStreamer에 통합하는 방법:</p>

<ul>
  <li><strong>gst-nvinfer</strong> (NVIDIA DeepStream): NVIDIA GPU에서 추론 실행. 가장 빠름.</li>
  <li><strong>ONNX Runtime 커스텀 element</strong>: 다양한 백엔드(CPU, GPU) 지원. 더 유연.</li>
  <li><strong>TensorFlow Lite</strong>: 경량 모델용.</li>
</ul>

<p>⚠️ <strong>GPU 기반 추론은 별도 글에서 자세히 다룰 예정입니다.</strong> 여기서는 CPU 기반 처리에 집중합니다.</p>

<p><strong>실제 구현 고려사항</strong></p>

<p>AI 모듈을 추가할 때 주의할 점:</p>

<ol>
  <li><strong>모델 크기</strong>: 경량 모델(MobileNet, ShuffleNet) 우선. 1080p 프레임당 10~50ms 내로 제약.</li>
  <li><strong>배치 처리</strong>: 1개 프레임씩이 아니라 4~8개 프레임을 모아 처리하여 GPU 활용도 증가.</li>
  <li><strong>비동기 처리</strong>: 메인 스트림은 이전 프레임 결과를 사용하고, 추론은 별도 스레드.</li>
  <li><strong>폴백(Fallback)</strong>: 모델 오류 시 기본 호모그래피로 복귀.</li>
</ol>

<p>예를 들어, YOLO 기반 피사체 인식을 추가하려면:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 의사 코드
</span><span class="k">class</span> <span class="nc">AIStabilizerModule</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">yolo_model</span> <span class="o">=</span> <span class="nf">load_yolo_model</span><span class="p">(</span><span class="sh">"</span><span class="s">yolov8n.pt</span><span class="sh">"</span><span class="p">)</span>  <span class="c1"># nano 버전
</span>        <span class="n">self</span><span class="p">.</span><span class="n">prev_features</span> <span class="o">=</span> <span class="bp">None</span>
    
    <span class="k">def</span> <span class="nf">process</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">frame</span><span class="p">):</span>
        <span class="c1"># YOLO 추론 (GPU 또는 CPU)
</span>        <span class="n">detections</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">yolo_model</span><span class="p">(</span><span class="n">frame</span><span class="p">)</span>
        
        <span class="c1"># 메인 피사체 추출 (신뢰도 &gt; 0.5)
</span>        <span class="n">main_objects</span> <span class="o">=</span> <span class="p">[</span><span class="n">d</span> <span class="k">for</span> <span class="n">d</span> <span class="ow">in</span> <span class="n">detections</span> <span class="k">if</span> <span class="n">d</span><span class="p">.</span><span class="n">confidence</span> <span class="o">&gt;</span> <span class="mf">0.5</span><span class="p">]</span>
        
        <span class="k">if</span> <span class="n">main_objects</span><span class="p">:</span>
            <span class="c1"># BBox 내 특징점에 높은 가중치
</span>            <span class="n">weights</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_compute_weights</span><span class="p">(</span><span class="n">frame</span><span class="p">.</span><span class="n">shape</span><span class="p">,</span> <span class="n">main_objects</span><span class="p">)</span>
            <span class="n">H</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_weighted_homography</span><span class="p">(</span><span class="n">frame</span><span class="p">,</span> <span class="n">weights</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="c1"># 폴백: 기본 호모그래피
</span>            <span class="n">H</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_default_homography</span><span class="p">(</span><span class="n">frame</span><span class="p">)</span>
        
        <span class="k">return</span> <span class="n">H</span>
</code></pre></div></div>

<p>이 방식으로 사람이 많은 환경에서도 주요 피사체(예: 강사, 발표자)의 안정성을 우선할 수 있습니다.</p>

<p><strong>성능 트레이드오프</strong></p>

<p>AI 보정을 추가하면 다음과 같은 트레이드오프가 발생합니다:</p>

<table>
  <thead>
    <tr>
      <th>측면</th>
      <th>CPU 기본</th>
      <th>AI 기반</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>정확도</strong></td>
      <td>중간</td>
      <td>높음</td>
    </tr>
    <tr>
      <td><strong>지연(Latency)</strong></td>
      <td>~50ms</td>
      <td>~200ms+</td>
    </tr>
    <tr>
      <td><strong>리소스</strong></td>
      <td>낮음</td>
      <td>매우 높음</td>
    </tr>
    <tr>
      <td><strong>실시간성</strong></td>
      <td>1080p@30fps 가능</td>
      <td>1080p@5fps (CPU)</td>
    </tr>
  </tbody>
</table>

<p>(D6 도식 참조 - 기본 파이프라인 + AI 모듈 추가 시 변경 토폴로지)</p>

<pre><code class="language-mermaid">graph LR
    SRC["📹 v4l2src"]
    DEC["avdec_h264&lt;br/&gt;디코더"]
    TEE["⬡ tee"]
    STAB["⚙️ gststabilizer&lt;br/&gt;모션 추정"]
    WARP["✂️ gstwarpcrop&lt;br/&gt;Warp + Crop"]
    COMP["compositor&lt;br/&gt;합성"]
    ENC["x264enc&lt;br/&gt;mp4mux"]
    SINK["📁 filesink"]

    EXP["💡 gstexposuresmoothing&lt;br/&gt;조명 변화 보정"]
    SAL["👁️ gstsaliencydetector&lt;br/&gt;YOLO / SAM2 피사체 감지"]
    EDGE["🖌️ gstedgeinpainter&lt;br/&gt;경계 복원 인페인팅"]

    subgraph aimodules["🤖 AI 확장 모듈 (심화 편 예정)"]
        EXP
        SAL
        EDGE
    end

    SRC --&gt; DEC --&gt; TEE
    TEE --&gt;|"추정 경로"| STAB
    TEE --&gt;|"변환 경로"| WARP
    STAB -.-&gt;|"H 행렬"| WARP
    STAB --&gt; COMP
    WARP --&gt; EXP --&gt; SAL --&gt; EDGE --&gt; COMP
    COMP --&gt; ENC --&gt; SINK

    style SRC fill:#2c3e50,color:#fff,stroke:#1a252f
    style DEC fill:#34495e,color:#fff,stroke:#2c3e50
    style TEE fill:#e67e22,color:#fff,stroke:#d35400
    style STAB fill:#27ae60,color:#fff,stroke:#1e8449
    style WARP fill:#27ae60,color:#fff,stroke:#1e8449
    style COMP fill:#8e44ad,color:#fff,stroke:#6c3483
    style ENC fill:#34495e,color:#fff,stroke:#2c3e50
    style SINK fill:#2c3e50,color:#fff,stroke:#1a252f
    style EXP fill:#1a5276,color:#fff,stroke:#154360
    style SAL fill:#1a5276,color:#fff,stroke:#154360
    style EDGE fill:#1a5276,color:#fff,stroke:#154360
    style aimodules fill:#d6eaf8,stroke:#2980b9,stroke-dasharray:6 4
</code></pre>

<blockquote>
  <p><strong>색상 범례:</strong><br />
🟢 녹색 박스 = 기본 CPU element (<code class="language-plaintext highlighter-rouge">gststabilizer</code>, <code class="language-plaintext highlighter-rouge">gstwarpcrop</code>)<br />
🔵 파란 박스 = AI 가속 element (별도 글에서 상세 설명 예정)<br />
🟠 주황 = tee 분기점 / 🟣 보라 = compositor</p>
</blockquote>

<hr />

<h2 id="통합-및-성능-고려사항">통합 및 성능 고려사항</h2>

<p><strong>최종 한 줄 파이프라인</strong></p>

<p>지금까지의 내용을 종합하면:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gst-launch-1.0 <span class="se">\</span>
  v4l2src <span class="nv">device</span><span class="o">=</span>/dev/video0 <span class="o">!</span> <span class="se">\</span>
  videoconvert <span class="o">!</span> <span class="se">\</span>
  gststabilizer <span class="nv">lookahead</span><span class="o">=</span>5 <span class="nv">downsample</span><span class="o">=</span>4 <span class="o">!</span> <span class="se">\</span>
  gstwarpcrop <span class="nv">margin</span><span class="o">=</span>0.15 <span class="o">!</span> <span class="se">\</span>
  x264enc <span class="nv">bitrate</span><span class="o">=</span>5000 <span class="o">!</span> <span class="se">\</span>
  mp4mux <span class="o">!</span> <span class="se">\</span>
  filesink <span class="nv">location</span><span class="o">=</span>stabilized.mp4
</code></pre></div></div>

<p>이 한 줄이 다음을 수행합니다:</p>
<ol>
  <li>USB 카메라에서 영상 획득.</li>
  <li>호모그래피 추정으로 모션 분석.</li>
  <li>부드러운 궤적 계산(lookahead=5프레임).</li>
  <li>호모그래피 변환 적용 및 15% 마진으로 crop.</li>
  <li>H.264로 인코딩하여 MP4로 저장.</li>
</ol>

<p><strong>성능 고려사항 (정성적)</strong></p>

<ol>
  <li><strong>CPU 부하</strong>:
    <ul>
      <li>호모그래피 추정: 프레임당 10~50ms (해상도와 특징점 수에 따라).</li>
      <li>멀티스레드 활용: 추정과 인코딩을 별도 스레드에서 병렬 처리.</li>
      <li>다운샘플링: 계산량을 N배 감소 (우리 예에서 N=16).</li>
      <li><strong>최적화</strong>: SIMD 명령어(SSE, AVX) 활용으로 추가 5~10배 가속 가능.</li>
    </ul>
  </li>
  <li><strong>메모리 사용</strong>:
    <ul>
      <li>버퍼 크기: lookahead N프레임 + queue 깊이.</li>
      <li>4K@30fps로 5프레임 lookahead = 대략 600MB (YUV420 기준).</li>
      <li>임베디드 시스템에서는 lookahead를 0~3으로 제한 권장.</li>
      <li><strong>누수 방지</strong>: GStreamer의 <code class="language-plaintext highlighter-rouge">gst-play</code> 도구로 메모리 프로파일링.</li>
    </ul>
  </li>
  <li><strong>실시간성</strong>:
    <ul>
      <li>목표: 프레임 처리 시간 &lt; 33ms (30fps 기준).</li>
      <li>방법: 호모그래피 추정을 비동기로 수행, 메인 렌더링은 버퍼된 이전 결과 사용.</li>
      <li><strong>측정</strong>: GStreamer의 <code class="language-plaintext highlighter-rouge">GST_DEBUG=3</code> 환경 변수로 성능 로그 수집.</li>
    </ul>
  </li>
</ol>

<p><strong>실측 가능성</strong></p>

<p>실제 성능은 다음 변수들에 따라 결정됩니다:</p>

<table>
  <thead>
    <tr>
      <th>변수</th>
      <th>저사양</th>
      <th>중사양</th>
      <th>고사양</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>CPU</strong></td>
      <td>Celeron</td>
      <td>i5</td>
      <td>i9/Ryzen9</td>
    </tr>
    <tr>
      <td><strong>코어 수</strong></td>
      <td>2</td>
      <td>4~6</td>
      <td>8+</td>
    </tr>
    <tr>
      <td><strong>해상도</strong></td>
      <td>720p</td>
      <td>1080p</td>
      <td>4K</td>
    </tr>
    <tr>
      <td><strong>특징점 수</strong></td>
      <td>500</td>
      <td>2000</td>
      <td>5000</td>
    </tr>
    <tr>
      <td><strong>처리 시간</strong></td>
      <td>50~100ms</td>
      <td>10~30ms</td>
      <td>5~15ms</td>
    </tr>
  </tbody>
</table>

<p><strong>벤치마크 부재</strong></p>

<p>원래 계획에서는 성능 벤치마크 표를 포함하려 했으나, 실제 측정 데이터가 없으므로 정성적 설명으로 대신합니다. 실측 성능은 다음 요인에 크게 좌우됩니다:</p>

<ul>
  <li>카메라 해상도 및 프레임 레이트.</li>
  <li>CPU 모델 및 코어 수.</li>
  <li>호모그래피 추정 방식(Feature-based vs Dense optical flow).</li>
  <li>버퍼링 및 큐 설정.</li>
</ul>

<p><strong>확장 가능성</strong></p>

<p>이 기본 구조에서 다음으로 확장할 수 있습니다:</p>

<ol>
  <li><strong>GPU 가속</strong>: CUDA 기반 호모그래피 추정으로 10배 속도 향상. (별도 글 예정)</li>
  <li><strong>멀티스레딩</strong>: 추정과 렌더링을 분리하여 지연 최소화.</li>
  <li><strong>6DoF 모션</strong>: 3축 회전뿐만 아니라 깊이 정보(Depth) 활용.</li>
  <li><strong>End-to-End 학습</strong>: 전체 파이프라인을 신경망으로 학습(LSTM, Transformer).</li>
  <li><strong>분산 처리</strong>: 고해상도 라이브 스트림을 여러 머신에서 처리.</li>
</ol>

<p><strong>오픈소스 참고</strong></p>

<p>스태빌라이저 구현 시 참고할 만한 프로젝트들:</p>

<ul>
  <li><strong>vid.stab</strong> (FFmpeg): 전통적 Feature-based 스태빌라이저. FFmpeg의 vidstabdetect/vidstabtransform 필터로 사용.</li>
  <li><strong>OpenCV의 <code class="language-plaintext highlighter-rouge">cv2.warpPerspective</code></strong>: 호모그래피 변환의 기준 구현.</li>
  <li><strong>GStreamer gst-opencv</strong>: OpenCV와 GStreamer 통합.</li>
  <li><strong>RAFT (Optical Flow)</strong>: 최신 광학 흐름 추정. PyTorch 기반.</li>
</ul>

<p><strong>30분 안에 직접 돌려보기</strong></p>

<p>최소한의 스태빌라이저를 직접 실행해보려면:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 설치</span>
pip <span class="nb">install </span>opencv-python opencv-contrib-python
<span class="nb">sudo </span>apt-get <span class="nb">install </span>gstreamer1.0-tools

<span class="c"># 2. Python 스크립트로 동영상 처리</span>
python stabilize.py input.mp4 output.mp4

<span class="c"># 3. GStreamer로 실시간 카메라</span>
gst-launch-1.0 v4l2src <span class="o">!</span> videoconvert <span class="o">!</span> autovideosink
</code></pre></div></div>

<p>실제 코드는 몇십 줄 이내로 작성 가능합니다. 이미지 처리의 기초(특징점 매칭, 호모그래피)를 이해하면, 나머지는 OpenCV와 GStreamer API를 따라가기만 하면 됩니다.</p>

<hr />

<h2 id="결론">결론</h2>

<p>순수 소프트웨어 스태빌라이저는 <strong>하드웨어에 의존하지 않으면서도</strong> 효과적인 흔들림 제거를 제공합니다. 호모그래피 기반의 기하학적 모델은 간단하면서도 대부분의 실제 상황(손떨림, 풍선 진동, 드론 요동)에 잘 작동합니다.</p>

<hr />

<p><strong>참고 도식</strong></p>

<ul>
  <li><strong>D1</strong>: 개념도 (Mermaid flowchart) - OIS/EIS/순수 SW 비교, 사용 시나리오</li>
  <li><strong>D2</strong>: 3축 회전 (SVG 인라인) - Yaw/Pitch/Roll 시각화</li>
  <li><strong>D3</strong>: Motion Estimation 파이프라인 (Mermaid flowchart) - Feature 검출→매칭→RANSAC→호모그래피</li>
  <li><strong>D4</strong>: GStreamer 메인 파이프라인 (Mermaid graph) - tee/queue/커스텀 element 토폴로지</li>
  <li><strong>D5</strong>: Crop window 계산 시각화 (SVG 인라인) - 입력 프레임 + safe zone + 출력 영역</li>
  <li><strong>D6</strong>: AI 모듈 추가 토폴로지 (Mermaid graph) - 기본 파이프라인 + CNN/YOLO 분기</li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="engineering" /><category term="gstreamer" /><category term="GStreamer" /><category term="Stabilization" /><category term="OpenCV" /><summary type="html"><![CDATA[순수 소프트웨어로 구현하는 비디오 스태빌라이저: GStreamer 파이프라인의 이해]]></summary></entry><entry><title type="html">Symbolic AI에서 Datalog가 필요한 이유</title><link href="https://groou.com/essay/datalog/2026/03/26/prolog-datalog-symbolic-ai/" rel="alternate" type="text/html" title="Symbolic AI에서 Datalog가 필요한 이유" /><published>2026-03-26T12:00:00+09:00</published><updated>2026-03-26T12:00:00+09:00</updated><id>https://groou.com/essay/datalog/2026/03/26/prolog-datalog-symbolic-ai</id><content type="html" xml:base="https://groou.com/essay/datalog/2026/03/26/prolog-datalog-symbolic-ai/"><![CDATA[<p>Datalog와 Prolog는 논리 프로그래밍(Logic Programming)이라는 동일한 뿌리에서 파생되어 시각적으로 매우 유사한 문법을 공유하고 있습니다. 때문에 Datalog를 처음 접하게 되면 이미 익숙하고 범용적인 Prolog와의 실질적인 차이를 체감하기 쉽지 않습니다.</p>

<p>하지만 그 동작 원리와 목적을 자세히 들여다보면 이 둘이 지향하는 방향은 완전히 다릅니다. 이 글에서는 겉보기에 비슷해 보이는 두 언어의 근본적인 차이점을 구체적으로 비교하고, 최근 기호주의(Symbolic) AI 및 지식 그래프 영역에서 왜 범용적인 Prolog를 제쳐두고 Datalog가 더욱 각광받고 있는지 그 명확한 이유를 정리해 보겠습니다.</p>

<h2 id="너무-완벽해서-위험한-prolog">너무 완벽해서 위험한 Prolog</h2>

<p>Prolog는 1970년대에 등장한 논리 프로그래밍의 근간이 되는 언어입니다. 일반적인 프로그래밍 언어들이 보통 “A를 수행하고, 그다음 B를 수행하라”와 같이 순차적인 절차를 지시한다면, Prolog는 “이것은 사실(Fact)이고, 저것은 규칙(Rule)이다”라고 선언하는 데 그칩니다. 그러면 추론 엔진이 자체적으로 연산을 반복하며 해답을 도출해 냅니다.</p>

<p>Prolog는 완전한 튜링 머신(Turing-complete)이기 때문에 구현자가 구상하는 거의 모든 형태의 논리를 구현할 수 있습니다. 리스트와 같은 복잡한 자료구조를 자유롭게 다루며, 복잡한 재귀 알고리즘 또한 작성할 수 있습니다.</p>

<p>하지만 이처럼 ‘모든 것을 할 수 있다’는 점은 대규모 지식을 다뤄야 할 때 역설적으로 큰 제약으로 작용합니다. Prolog는 위에서 아래로 한 길만 끝까지 파고드는 깊이 우선 탐색(DFS) 방식으로 작동합니다. 그러다 보니 구현자가 규칙의 순서를 조금만 잘못 작성해도 시스템이 영원히 답을 찾지 못하고 무한 루프에 빠지기 일쑤입니다. 데이터 패턴이 수억 건에 달하는 방대한 지식 그래프 환경에서 이러한 예측 불가능성은 시스템의 치명적인 약점이 됩니다.</p>

<h2 id="제약을-걸어-안전성을-확보한-datalog">제약을 걸어 안전성을 확보한 Datalog</h2>

<p>이러한 문제를 해결하기 위해 등장한 언어가 바로 Datalog입니다. Datalog는 직관적으로 표현하자면 ‘Light-weighted Prolog’라고 할 수 있습니다.</p>

<p>리스트 연산이나 복잡한 함수와 같은 강력한 기능들을 과감히 배제했습니다. 완전한 튜링 머신이 아니기 때문에 범용적인 애플리케이션을 처음부터 끝까지 독립적으로 구현해 낼 수는 없습니다. 하지만 이 강력한 기능들을 제외한 대신 Datalog는 매우 중요한 장점을 얻게 되었는데, 그것이 바로 <strong>종료의 보장(Termination)</strong> 입니다.</p>

<p>아무리 복잡한 질의(Query)를 수행하더라도 언젠가는 반드시 결론을 도출하고 시스템이 종료된다는 사실이 수학적으로 증명되어 있습니다. 따라서 작성자가 실수로 질의를 잘못 작성하더라도 서버 자원을 고갈시키는 무한 루프를 우려할 필요가 전혀 없습니다.</p>

<h2 id="구체적인-예시-그래프에서-경로path-찾기">구체적인 예시: 그래프에서 경로(Path) 찾기</h2>

<p>이 차이를 가장 명확하게 보여주는 예시가 바로 방향 그래프(Directed Graph)에서 두 지점 간의 경로를 탐색하는 문제입니다.</p>

<p>예를 들어, <code class="language-plaintext highlighter-rouge">a -&gt; b</code>, <code class="language-plaintext highlighter-rouge">b -&gt; c</code>, <code class="language-plaintext highlighter-rouge">c -&gt; a</code>로 무한히 순환(Cycle)하는 그래프 구조가 존재한다고 가정해 보겠습니다. 이 상태에서 ‘X에서 Y로 가는 경로가 존재하는가?’를 확인하는 규칙을 작성해 보겠습니다.</p>

<p><strong>Prolog의 경우 (무한 루프 발생)</strong></p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">path</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">edge</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">).</span>
<span class="ss">path</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">path</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Z</span><span class="p">),</span> <span class="ss">edge</span><span class="p">(</span><span class="nv">Z</span><span class="p">,</span> <span class="nv">Y</span><span class="p">).</span>
</code></pre></div></div>

<p><img src="/images/2026-03-26/prolog_loop.gif" alt="Prolog 무한루프 시뮬레이션" /></p>

<p>Prolog에서 위와 같은 ‘좌측 재귀(Left-recursion)’ 형태로 규칙을 작성하고 순환 그래프를 탐색시키면 큰 문제가 발생합니다. Prolog는 위에서 아래로, 왼쪽부터 파고들기 때문에 끝없이 <code class="language-plaintext highlighter-rouge">path(X, Z)</code>를 재귀 호출하며 <code class="language-plaintext highlighter-rouge">a -&gt; b -&gt; c -&gt; a -&gt; b...</code> 순으로 영원히 연산을 반복하게 됩니다. 구현자가 이 함정을 회피하기 위해서는 규칙의 순서를 수동으로 조작하거나, 이미 방문한 노드를 일일이 리스트에 기록해 두는 등 복잡한 예외 처리 코드를 추가해야만 합니다.</p>

<p><strong>Datalog의 경우 (항상 안전하게 종료)</strong></p>

<p>Datalog의 문법은 Prolog와 동일하므로 앞선 코드를 그대로 사용할 수 있습니다.</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">path</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">edge</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">).</span>
<span class="ss">path</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Y</span><span class="p">)</span> <span class="p">:-</span> <span class="ss">path</span><span class="p">(</span><span class="nv">X</span><span class="p">,</span> <span class="nv">Z</span><span class="p">),</span> <span class="ss">edge</span><span class="p">(</span><span class="nv">Z</span><span class="p">,</span> <span class="nv">Y</span><span class="p">).</span>
</code></pre></div></div>

<p><img src="/images/2026-03-26/datalog_termination.gif" alt="Datalog의 고정점(Fixed-point) 종료 시뮬레이션" /></p>

<p>하지만 Datalog 엔진은 전혀 다른 방식으로 작동합니다. 위에서 아래로 무작정 파고드는 대신, 이미 확보한 <code class="language-plaintext highlighter-rouge">edge</code>라는 사실(Fact)들로부터 출발하여 도출할 수 있는 모든 <code class="language-plaintext highlighter-rouge">path</code>를 바닥에서부터 한 번에 조립해 나갑니다(상향식 평가, Bottom-up Evaluation). 지속적으로 관계를 유추하다가 “더 이상 새로운 경로가 도출되지 않는” 순간(고정점, Fixed-point 도달) 시스템이 스스로 계산을 종료합니다.</p>

<p>순환 구조의 존재 여부나 질의의 작성 순서는 아무런 상관이 없습니다. <strong>“단순히 논리만 선언해 두면 알아서 실행을 멈추고 답을 찾아주는”</strong> 특성이 바로 Datalog가 가진 진정한 가치입니다.</p>

<h2 id="지식-기반-ai가-datalog를-선택하는-이유">지식 기반 AI가 Datalog를 선택하는 이유</h2>

<p>최근 다시 부상하고 있는 Symbolic AI 생태계, 특히 전문가 시스템이나 룰 베이스, 대규모 지식 그래프 상에서 추론을 수행해야 할 때 기술적 대안으로 Datalog가 자주 언급됩니다. 그 이유는 아주 직관적(Intuitive)입니다.</p>

<p>첫째, 대규모 데이터 처리 구조에 매우 적합합니다. 현대 AI가 다루는 지식은 수천, 수만 건 단위에 그치지 않습니다. 시스템 로그, 소셜 데이터, 웹 스크랩 데이터 등 수백억 건의 데이터를 다룹니다. Prolog는 이러한 방대한 데이터를 연산하기엔 구조적인 성능 한계가 명확합니다. 반면 Datalog는 태생부터 대용량 데이터베이스와의 결합을 목적으로 설계되었습니다. 기본 사실들을 기반으로 차곡차곡 상위 논리를 유추해 내는 상향식 평가를 사용하여 대규모 데이터 셋에서의 복잡한 재귀 조인(Recursive Join)을 훨씬 더 효율적으로 처리합니다.</p>

<p>둘째, 구현자가 철저하게 ‘무엇(What)’을 도출할지에만 집중할 수 있게 해줍니다. 규칙이 수천 개로 늘어나면 사람이 이를 일일이 제어하며 무한 루프를 피하기란 불가능에 가깝습니다. Datalog 환경에서는 탐색의 순서나 실행 방법과 같은 ‘어떻게(How)’에 대한 부분을 엔진 내부의 질의 플래너(Query Planner)가 독자적으로 최적화합니다. 구현자는 데이터 간의 논리적 규칙만 선언형 텍스트로 명시해 두면 충분합니다.</p>

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

<p>Prolog가 인간의 완벽한 논리 추론 프로세스를 코드로 이식하고자 했던 이상적인 도구였다면, Datalog는 현실의 방대한 데이터 속에서 시스템 다운 없이 빠르고 정확하게 지식을 추출해 내기 위해 다양한 안전장치를 더한 실용적인 도구라 할 수 있습니다.</p>

<p>딥러닝이나 거대 언어 모델과 같은 통계적 방법론이 정답의 ‘확률’을 높이는 데 특화되어 있다면, 의료, 금융, 복잡한 접근 제어 모델링 등 100% 확실한 논리와 설명 가능성(Explainability)이 요구되는 영역에서는 제한적인 유연함을 대가로 완벽한 안정성을 취한 Datalog가 자신만의 확고한 입지를 구축하고 있습니다.</p>]]></content><author><name>Justin Kim</name></author><category term="essay" /><category term="datalog" /><category term="Datalog" /><category term="Symbolic AI" /><category term="Logic Programming" /><category term="AI" /><summary type="html"><![CDATA[Datalog와 Prolog는 논리 프로그래밍(Logic Programming)이라는 동일한 뿌리에서 파생되어 시각적으로 매우 유사한 문법을 공유하고 있습니다. 때문에 Datalog를 처음 접하게 되면 이미 익숙하고 범용적인 Prolog와의 실질적인 차이를 체감하기 쉽지 않습니다.]]></summary></entry><entry><title type="html">캐시 히트율을 위한 Radix Sort 도입 기대</title><link href="https://groou.com/research/datalog/2026/03/25/radix-sort-in-datalog/" rel="alternate" type="text/html" title="캐시 히트율을 위한 Radix Sort 도입 기대" /><published>2026-03-25T00:00:00+09:00</published><updated>2026-03-25T00:00:00+09:00</updated><id>https://groou.com/research/datalog/2026/03/25/radix-sort-in-datalog</id><content type="html" xml:base="https://groou.com/research/datalog/2026/03/25/radix-sort-in-datalog/"><![CDATA[<p>Datalog 엔진을 구현하다 보면 성능 병목을 해결하기 위해 다양한 최적화 기법을 도입하게 됩니다. 특히 다량의 데이터를 처리해야 하는 데이터베이스나 논리 프로그래밍 엔진에서는 어떤 정렬 알고리즘을 선택하느냐에 따라 전체적인 성능 차이가 극명하게 나타날 수 있습니다.</p>

<p>이번 글에서는 기수 정렬(Radix Sort)의 기본 개념에 대해 알아보고, 왜 Datalog 엔진에서 정렬 알고리즘이 필수적인지, 그리고 기존의 <code class="language-plaintext highlighter-rouge">qsort</code>에서 Radix Sort로 전환하여 어떤 성능적 이점을 기대하고 있는지 정리해 보았습니다.</p>

<h2 id="기수-정렬-radix-sort이란">기수 정렬 (Radix Sort)이란?</h2>

<p>기수 정렬(Radix Sort)은 요소를 비교하지 않고 분산(Distribution)하여 정렬하는 알고리즘입니다. 일반적인 비교 기반 정렬 알고리즘(예: Quick Sort, Merge Sort)이 최소 $O(N \log N)$의 시간 복잡도를 가지는 반면, 기수 정렬은 정렬할 키의 크기가 제한적일 때 $O(dN)$ (이때 $d$는 데이터의 최대 자릿수)의 선형 시간에 가까운 속도로 정렬을 완료할 수 있습니다.</p>

<h3 id="작동-방식">작동 방식</h3>

<p>기수 정렬은 데이터의 가장 낮은 자릿수(LSD, Least Significant Digit)부터 가장 높은 자릿수(MSD, Most Significant Digit)까지, 혹은 그 반대로 각 자릿수를 기준으로 정렬을 반복합니다.</p>

<p><img src="/images/2026-03-25/radix_sort_diagram.svg" alt="Radix Sort Process" style="width: 100%; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); margin: 25px 0;" /></p>

<ol>
  <li><strong>초기화</strong>: 0부터 9까지(혹은 진법에 따른 기수만큼)의 버킷(Queue 등)을 준비합니다.</li>
  <li><strong>분배</strong>: 정렬할 데이터의 일의 자리를 기준으로 각 버킷에 데이터를 넣습니다.</li>
  <li><strong>병합</strong>: 0번 버킷부터 순서대로 데이터를 다시 가져옵니다.</li>
  <li><strong>반복</strong>: 십의 자리, 백의 자리 등 가장 큰 자릿수까지 위 과정을 반복합니다.</li>
</ol>

<p>이렇게 요소 간의 직접적인 크기 비교 없이 자릿수 기반의 버킷 배치를 통해 정렬을 수행하기 때문에, 정수나 문자열 같은 일정한 형식의 키를 가진 데이터를 정렬할 때 매우 강력한 성능을 발휘합니다.</p>

<h2 id="datalog-엔진-구현에서-정렬이-필요한-이유">Datalog 엔진 구현에서 정렬이 필요한 이유</h2>

<p>Datalog는 선언형 논리 프로그래밍 언어로, 사실(Fact)과 규칙(Rule)을 기반으로 새로운 사실을 추론해 냅니다. 엔진 내부에서는 이러한 추론 과정이 주로 관계형 대수(Relational Algebra) 연산으로 변환되어 실행됩니다.</p>

<p>Datalog 엔진에서 데이터를 정렬해야 하는 주된 이유는 다음과 같습니다.</p>

<ol>
  <li>
    <p><strong>중복 제거 (Deduplication)</strong>
Datalog의 평가 과정(특히 Bottom-up 방식의 Semi-naïve Evaluation)에서는 매 반복마다 새롭게 생성된 데이터(Fact) 중에서 기존의 데이터와 겹치는 중복을 제거해야 합니다. 데이터가 정렬되어 있다면 선형 탐색 한 번만으로 중복된 튜플을 쉽게 걸러낼 수 있습니다.</p>
  </li>
  <li>
    <p><strong>조인 연산 (Join)</strong>
보통 하나의 Rule은 여러 개의 Predicate(조건)가 엮여 있으며, 이를 모두 만족하는 해를 찾기 위해 조인 연산이 연속해서 일어납니다. 해시 조인(Hash Join)을 사용할 수도 있지만, 메모리 사용량을 예측 가능하게 유지하면서 높은 성능을 내기 위해 정렬 병합 조인(Sort-Merge Join)이 자주 활용됩니다. 데이터를 조인 키 기준으로 미리 정렬해두면 조인 처리가 매우 효율적입니다.</p>
  </li>
</ol>

<p>결과적으로 Datalog 엔진에서 데이터를 릴레이션 형태로 보관하고 연산할 때, 빠른 정렬은 엔진의 핵심 성능을 좌우하는 중요한 요소입니다.</p>

<h2 id="qsort에서-radix-sort로의-전환-캐시-히트율-향상을-향한-기대">QSort에서 Radix Sort로의 전환: 캐시 히트율 향상을 향한 기대</h2>

<p>초기 <a href="https://github.com/justinjoy/wirelog">Datalog 엔진(wirelog)</a> 구현에서는 라이브러리에서 기본 제공하는 <strong>퀵 정렬(<code class="language-plaintext highlighter-rouge">qsort</code> 또는 <code class="language-plaintext highlighter-rouge">std::sort</code>)</strong>을 사용해왔습니다. Quick Sort는 평균적으로 $O(N \log N)$의 우수한 시간 복잡도를 가지며 범용적으로 가장 널리 쓰이는 정렬 방식입니다.</p>

<p>하지만 처리해야 할 일의 양과 사실(Fact) 데이터의 개수가 기하급수적으로 늘어나면서 Quick Sort의 한계가 나타나기 시작했습니다. 가장 큰 문제는 바로 <strong>캐시 히트율(Cache Hit Rate)</strong> 이었습니다.</p>

<p>Quick Sort는 피벗(Pivot)을 기준으로 배열의 양 끝에서부터 스왑(Swap) 연산을 수행하면서 재귀적으로 범위를 좁혀 들어갑니다. 데이터가 커져서 한 번에 CPU 캐시에 담을 수 있는 크기를 넘어서게 되면 메모리 접근 패턴은 무작위(Random)에 가까워집니다. 이로 인해 심각한 <strong>Cache Miss</strong>가 발생하기 시작하고, 결국 CPU 연산 시간보다 메모리에서 데이터를 퍼오는 데 걸리는 대기 시간이 정렬 연산의 병목이 됩니다.</p>

<p>반면, <strong>Radix Sort</strong>는 데이터를 버킷에 순차적으로 분배하고 다시 순차적으로 거두어들이는 패턴을 가집니다. 특히 데이터 엔진에서 주로 다루는 고정 크기의 정수형 키를 정렬할 때, 메모리에 대해서 <strong>연속적이고 예측 가능한 접근(Sequential Memory Access)</strong> 을 가능하게 합니다.</p>

<p>이러한 특성 덕분에 하드웨어 프리패처(Hardware Prefetcher) 동작에 매우 유리하며 다음 메모리 블록을 미리 캐시에 올려둘 수 있어, 결과적으로 <strong>캐시 히트율이 획기적으로 상승</strong>합니다.</p>

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

<p>현재 Datalog 엔진의 핵심 병목 중 하나였던 대용량 튜플의 정렬 단계를 기존의 <code class="language-plaintext highlighter-rouge">qsort</code>에서 Radix Sort 기반으로 교체하는 작업을 진행하고 있습니다(관련 이슈: <a href="https://github.com/justinjoy/wirelog/issues/308">justinjoy/wirelog#308</a>). 연산 복잡도 자체의 감소($O(N \log N) \rightarrow O(dN)$)도 의미가 있지만, 무엇보다 메모리 계층 구조에 친화적인(Cache-friendly) 특성 덕분에 실제 하드웨어 최우선 과제인 캐시 히트율을 대폭 향상시켜 훨씬 더 빠른 쿼리 응답 속도를 얻을 수 있을 것으로 기대하고 있습니다. 향후 최적화가 완료되면 벤치마킹을 통해 얼마나 속도가 개선되었는지 다시 정리해 보도록 하겠습니다.</p>]]></content><author><name>Justin Kim</name></author><category term="research" /><category term="datalog" /><category term="Datalog" /><category term="Algorithm" /><category term="Optimization" /><summary type="html"><![CDATA[Datalog 엔진을 구현하다 보면 성능 병목을 해결하기 위해 다양한 최적화 기법을 도입하게 됩니다. 특히 다량의 데이터를 처리해야 하는 데이터베이스나 논리 프로그래밍 엔진에서는 어떤 정렬 알고리즘을 선택하느냐에 따라 전체적인 성능 차이가 극명하게 나타날 수 있습니다.]]></summary></entry><entry><title type="html">시맨틱 태깅, 단순한 키워드를 넘어 지식의 연결로</title><link href="https://groou.com/essay/knowledge-graph/2026/03/22/semantic-tagging/" rel="alternate" type="text/html" title="시맨틱 태깅, 단순한 키워드를 넘어 지식의 연결로" /><published>2026-03-22T00:00:00+09:00</published><updated>2026-03-22T00:00:00+09:00</updated><id>https://groou.com/essay/knowledge-graph/2026/03/22/semantic-tagging</id><content type="html" xml:base="https://groou.com/essay/knowledge-graph/2026/03/22/semantic-tagging/"><![CDATA[<p>기록이 쌓일수록 고민도 깊어집니다. 우리는 매일 수많은 노트를 작성하고, 나중에 찾기 쉽게 ‘태그’를 답니다. <code class="language-plaintext highlighter-rouge">#datalog</code>, <code class="language-plaintext highlighter-rouge">#pkm</code>, <code class="language-plaintext highlighter-rouge">#ai</code> 같은 키워드들이 그 예입니다. 하지만 시간이 흘러 노트가 수백, 수천 개가 되었을 때, 이 태그들이 정말 우리에게 의미 있는 ‘지식’으로 기능하고 있는지는 돌이켜볼 문제입니다.</p>

<h2 id="키워드-태깅의-한계-단순한-문자열의-나열">키워드 태깅의 한계: 단순한 문자열의 나열</h2>

<p>우리가 흔히 쓰는 태깅은 텍스트를 있는 그대로 대조하는 <strong>문자열(String) 매칭</strong>에 불과합니다. 내가 ‘Datalog’라는 태그를 달았다고 해서 기계가 그것을 ‘선언형 논리 프로그래밍 언어’나 ‘Prolog의 일종’으로 이해하지는 않습니다. 그저 ‘D-a-t-a-l-o-g’라는 7개의 알파벳 조합으로 무미건조하게 받아들일 뿐이죠.</p>

<p>문자가 의미를 제대로 담지 못할 때 여러 답답한 상황이 벌어집니다. 당장 ‘Apple’이라는 태그만 봐도 이것이 과실을 뜻하는지 거대 IT 기업을 의미하는지 문맥 없이는 알 길이 없습니다. 게다가 ‘Datalog’와 ‘Logic Programming’이 서로 어떤 포함 관계인지도 파악하지 못하기 때문에, “논리 프로그래밍에 관한 노트를 다 찾아줘”라고 검색하면 정작 ‘Datalog’ 태그가 달린 핵심 노트들은 모조리 누락되고 맙니다. 기계 입장에서는 두 문자열이 완전히 배타적이기 때문입니다.</p>

<h2 id="시맨틱-태깅이란">시맨틱 태깅이란?</h2>

<p>시맨틱 태깅(Semantic Tagging)은 텍스트를 단순한 문자열이 아닌 <strong>개념(Concept)과 개체(Entity)</strong>로 연결하는 과정입니다. 태그를 다는 행위가 단순히 ‘이름표’를 붙이는 것이 아니라, 전 세계적으로 정의된 지식 체계(Ontology)나 개인의 지식 그래프에 해당 노드를 <strong>‘위치’시키는 작업</strong>이 됩니다.</p>

<p>예를 들어, “Datalog”라는 단어에 시맨틱 태깅을 한다는 것은 다음과 같은 정보를 포함하는 것을 의미합니다.</p>
<ul>
  <li><strong>URI</strong>: <code class="language-plaintext highlighter-rouge">https://www.wikidata.org/wiki/Q1191141</code> (Wikidata의 Datalog 항목)</li>
  <li><strong>Type</strong>: Programming Language</li>
  <li><strong>Subclass of</strong>: Declarative Programming, Logic Programming</li>
</ul>

<p>이렇게 태깅된 데이터는 더 이상 고립된 섬이 아닙니다. 이미 정의된 거대한 지식의 네트워크(Linked Data)와 연결됩니다.</p>

<h2 id="지식-그래프의-기초-태그가-엣지가-되는-순간">지식 그래프의 기초: 태그가 엣지가 되는 순간</h2>

<p>시맨틱 태깅을 거치면, 우리의 노트 테이킹은 조금 다르게 동작하게 됩니다. 태그는 단순한 분류 도구가 아니라, 지식 그래프의 <strong>엣지(Edge)</strong>가 됩니다.</p>

<p><img src="/images/2026-03-22/semantic-tagging-graph.svg" alt="시맨틱 태깅 지식 그래프" /></p>

<p>이 연결망이 구축되면, 우리는 “Datalog”라고 직접 태그하지 않은 노트라 할지라도, 그것이 논리 프로그래밍과 관련되어 있다는 사실을 시스템을 통해 찾아낼 수 있습니다. 태깅이 <strong>분류(Classification)에서 연결(Connection)</strong>로 진화하는 것입니다.</p>

<h2 id="흩어진-메모가-지식의-그물망이-될-때">흩어진 메모가 지식의 그물망이 될 때</h2>

<p>시맨틱 태깅이 적용된 환경에서는 검색의 차원이 달라집니다. “사과”를 검색하더라도 이것이 과일인지 기업인지 명확히 구분하여, 작성자의 본래 의도(Intent)에 부합하는 결과를 얻을 수 있습니다. 단순한 키워드 매칭이 아닌, 개념 기반의 탐색이 이루어지기 때문입니다.</p>

<p>이러한 맥락의 연결은 자연스럽게 자동화된 추론의 기반이 됩니다. 이 블로그에서 자주 다루는 Datalog나 RDF 같은 도구들을 사용할 수 밖에 없는 부분입니다. 데이터가 정보의 그물망 위에 시맨틱하게 엮여 있다면, 단방향 검색을 넘어 “20세기 후반에 등장한 논리 프로그래밍 언어 중, 현재 내가 학습 중인 것들은 무엇인가”와 같은 복합적인 질의를 시스템에 넘길 수 있게 됩니다.</p>

<p>Obsidian이나 Logseq처럼 개인 지식 관리(PKM) 도구를 깊게 활용하는 사람들에게도 이는 중요한 시사점을 던집니다. 매번 태그를 꼼꼼히 관리하고 노트를 수동으로 ‘분류’하는 수고를 크게 덜어주기 때문이죠. 새로운 메모를 올바른 개념 공간에 연결해두기만 하면, 시간이 흘러 쌓인 지식의 연결망 속에서 의미 있는 통찰이 자연스럽게 출현(Emergence)하게 됩니다.</p>

<h2 id="llm이-낮춘-지식-연결의-문턱">LLM이 낮춘 지식 연결의 문턱</h2>

<p>그렇다면 시맨틱 태깅은 어떻게 실천할 수 있을까요? 예전에는 사용자가 직접 복잡한 온톨로지(Ontology) 구조를 학습하고, 지루한 수작업으로 일일이 메타데이터를 입력해야만 했습니다. 개념의 정합성을 맞추는 일 자체가 거대한 노동이었죠.</p>

<p>이 견고했던 장벽은 거대 언어 모델(LLM)의 등장으로 빠르게 허물어지고 있습니다. LLM은 다듬어지지 않은 일상적인 글에서 핵심 개념을 짚어내고, 이를 적절한 지식 베이스의 URI와 매핑하는 작업에 놀라운 적성을 보입니다. 글쓴이가 무심코 메모를 남기기만 해도, 모델이 문맥을 소화하여 백그라운드에서 지식의 엣지를 이어붙이는 식입니다. 시맨틱 그래프를 엮어내는 비용이 극적으로 낮아진 셈입니다.</p>

<h2 id="마치며-정리를-넘어-추론을-향해">마치며: ‘정리’를 넘어 ‘추론’을 향해</h2>

<p>결국 시맨틱 태깅은 ‘내 기록을 어떻게 다룰 것인가’에 대한 관점의 전환입니다. 정성껏 쓴 메모들을 그저 서랍장에 쌓아두는 데 만족할 것인지, 서로 관계를 맺고 새로운 결론을 엮어내는 동적인 지식 기반으로 키울 것인지의 선택이기도 합니다.</p>

<p>기계적인 키워드 분류는 언젠가 분명한 한계에 부딪히게 마련입니다. 파편화된 단어들의 늪에서 헤매지 않으려면, 이제는 단순 문자열의 나열이 아닌 ‘연결 가능한 의미’를 기록하는 연습을 시작해봐야 할 것입니다.</p>

<hr />

<h3 id="관련-글">관련 글</h3>

<ul>
  <li><a href="/essay/datalog/2026/03/15/datalog-everyday-use/">Datalog, 일상의 도구가 될 수 있을까</a></li>
  <li><a href="/essay/ai/2026/02/01/introducing-datalog/">Datalog 소개</a></li>
</ul>]]></content><author><name>Justin Kim</name></author><category term="essay" /><category term="knowledge-graph" /><category term="Knowledge Graph" /><category term="Linked Data" /><category term="Ontology" /><summary type="html"><![CDATA[기록이 쌓일수록 고민도 깊어집니다. 우리는 매일 수많은 노트를 작성하고, 나중에 찾기 쉽게 ‘태그’를 답니다. #datalog, #pkm, #ai 같은 키워드들이 그 예입니다. 하지만 시간이 흘러 노트가 수백, 수천 개가 되었을 때, 이 태그들이 정말 우리에게 의미 있는 ‘지식’으로 기능하고 있는지는 돌이켜볼 문제입니다.]]></summary></entry></feed>