<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://e7217.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://e7217.github.io/" rel="alternate" type="text/html" /><updated>2026-05-23T03:48:25+09:00</updated><id>https://e7217.github.io/feed.xml</id><title type="html">e7217 Dev Log</title><subtitle>스마트팩토리와 AI를 중심으로 개인 프로젝트와 개발 과정을 기록합니다.</subtitle><author><name>e7217</name></author><entry><title type="html">여러 머신의 AI 에이전트를 위한 LLM 호출 경로 설계</title><link href="https://e7217.github.io/ai/engineering/doorae-embedded-llm-gateway/" rel="alternate" type="text/html" title="여러 머신의 AI 에이전트를 위한 LLM 호출 경로 설계" /><published>2026-05-19T02:20:00+09:00</published><updated>2026-05-19T02:20:00+09:00</updated><id>https://e7217.github.io/ai/engineering/doorae-embedded-llm-gateway</id><content type="html" xml:base="https://e7217.github.io/ai/engineering/doorae-embedded-llm-gateway/"><![CDATA[<p>AI 에이전트를 한 대의 개발 머신에서만 실행할 때는 provider API를 직접 호출해도 큰 문제가 없다. 하지만 여러 머신에서 여러 엔진을 동시에 실행하기 시작하면, 네트워크 경로와 시크릿 배포와 사용량 추적이 모두 흩어진다.</p>

<p>이 글은 개인 프로젝트인 <strong>Doorae</strong>에서 여러 머신의 AI 에이전트가 LLM을 호출하는 경로를 어떻게 정리했는지에 대한 기록이다. 결론은 Doorae 서버가 LiteLLM 기반 게이트웨이를 관리하고, 에이전트 호출을 <code class="language-plaintext highlighter-rouge">/api/v1/llm/*</code>로 모으는 구조였다.</p>

<h2 id="요약">요약</h2>

<ul>
  <li>문제: agent subprocess가 각자 provider API를 직접 호출해 네트워크, 사용량, 시크릿 관리가 흩어졌다.</li>
  <li>결정: Doorae server가 LiteLLM proxy를 subprocess로 띄우고 reverse proxy로 감싼다.</li>
  <li>구현: gateway supervisor, draft-Apply 설정, agent manifest env 주입, token sentinel 치환을 연결했다.</li>
  <li>남은 과제: 실제 네트워크가 분리된 B-machine 환경에서 end-to-end 검증이 더 필요하다.</li>
</ul>

<h2 id="프로젝트-맥락">프로젝트 맥락</h2>

<p>Doorae의 초기 에이전트 구조에서는 각 agent subprocess가 직접 LLM provider API를 호출했다. Claude Code는 Anthropic으로, Codex는 OpenAI로, 각 엔진이 자기 방식대로 나가는 구조였다.</p>

<p>단일 개발 머신에서는 이 방식이 단순하다. 하지만 다음 같은 구성이 생기면 이야기가 달라진다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server machine
  - doorae-cluster
  - internet access

worker machine A
  - claude-code agent
  - internet access

worker machine B
  - codex or openhands agent
  - no outbound internet
</code></pre></div></div>

<p>worker machine B가 인터넷에 나갈 수 없다면 에이전트는 provider API를 직접 호출할 수 없다. 머신마다 별도 프록시를 세팅하고 API key를 배포할 수도 있지만, 운영 부담이 빠르게 커진다.</p>

<h2 id="문제">문제</h2>

<p>직접 호출 구조에는 세 가지 문제가 있었다.</p>

<p>첫째, 인터넷이 없는 머신에서는 에이전트를 돌리기 어렵다. 내부망에 있는 머신에서 Claude Code 에이전트를 띄우려면 그 머신이 <code class="language-plaintext highlighter-rouge">api.anthropic.com</code>에 나갈 수 있어야 한다.</p>

<p>둘째, 사용량 추적이 비어 있다. 호출이 Doorae 서버를 지나지 않기 때문에 어떤 룸의 어떤 에이전트가 어떤 모델을 얼마나 썼는지 알 수 없다.</p>

<p>셋째, 엔진별 프로토콜이 다르다. Anthropic <code class="language-plaintext highlighter-rouge">/v1/messages</code>와 OpenAI <code class="language-plaintext highlighter-rouge">/v1/chat/completions</code>를 모두 다루려면 단순 reverse proxy만으로는 부족하다.</p>

<h2 id="선택지">선택지</h2>

<table>
  <thead>
    <tr>
      <th>선택지</th>
      <th>장점</th>
      <th>단점</th>
      <th>판단</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>에이전트 직접 호출 유지</td>
      <td>가장 단순하다</td>
      <td>네트워크 없는 머신, 사용량 추적, 시크릿 분산 문제가 남는다</td>
      <td>제외</td>
    </tr>
    <tr>
      <td>LiteLLM을 별도 sidecar로 운영</td>
      <td>구현 공수가 낮다</td>
      <td>배포 아티팩트와 운영 문서가 늘어난다</td>
      <td>제외</td>
    </tr>
    <tr>
      <td>LiteLLM FastAPI app을 mount</td>
      <td>프로세스가 하나로 보인다</td>
      <td>LiteLLM 공식 경로가 아니고 lifespan/config 경계가 불안하다</td>
      <td>제외</td>
    </tr>
    <tr>
      <td>Doorae server가 LiteLLM subprocess를 관리</td>
      <td>단일 서버 운영 철학을 크게 해치지 않고 gateway를 얻는다</td>
      <td>supervision 코드가 필요하다</td>
      <td>선택</td>
    </tr>
  </tbody>
</table>

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

<p>선택한 구조는 Doorae server가 LiteLLM proxy를 subprocess로 관리하는 방식이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent
  -&gt; /api/v1/llm/*
  -&gt; doorae-server reverse proxy
  -&gt; litellm subprocess on 127.0.0.1:4001
  -&gt; upstream provider
</code></pre></div></div>

<p>LiteLLM은 외부에 노출하지 않고 <code class="language-plaintext highlighter-rouge">127.0.0.1:4001</code>에만 바인딩한다. 외부에서 접근 가능한 유일한 경로는 Doorae의 <code class="language-plaintext highlighter-rouge">/api/v1/llm/*</code> reverse proxy다.</p>

<p>이 구조의 장점은 Doorae의 단일 프로세스 운영 철학을 크게 깨지 않는다는 점이다. 별도 sidecar나 Postgres 기반 LiteLLM full feature를 도입하지 않고, Doorae server lifespan이 subprocess를 supervise한다.</p>

<h2 id="구현-포인트">구현 포인트</h2>

<h3 id="1-subprocess-supervisor">1. subprocess supervisor</h3>

<p>처음에는 LiteLLM FastAPI app을 Doorae server에 mount하는 방법도 생각할 수 있다. 하지만 이 방식은 LiteLLM의 공식 배포 경로가 아니고, lifespan, middleware, config loading 경계가 버전 업마다 깨질 수 있다.</p>

<p>반대로 CLI subprocess는 LiteLLM이 공식적으로 제공하는 실행 경로다. Doorae는 프로세스를 띄우고, health check하고, 죽으면 backoff로 재시작하는 책임만 가진다.</p>

<p>상태 머신은 단순하게 잡았다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INIT -&gt; STARTING -&gt; RUNNING
RUNNING -&gt; CRASHED -&gt; STARTING
RUNNING -&gt; RESTARTING -&gt; STOPPED -&gt; STARTING
STARTING -&gt; FAILED
</code></pre></div></div>

<p>설정 변경은 즉시 반영하지 않고 draft-Apply 패턴으로 처리한다. admin이 모델이나 시크릿을 수정해도 바로 respawn하지 않고, Apply를 누를 때 새 config를 렌더링하고 subprocess를 재시작한다.</p>

<h3 id="2-시크릿을-파일에-쓰지-않기">2. 시크릿을 파일에 쓰지 않기</h3>

<p>중요한 원칙은 API key 원문을 <code class="language-plaintext highlighter-rouge">litellm.yaml</code>에 쓰지 않는 것이다. config 파일에는 환경변수 참조만 들어간다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">model_list</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">model_name</span><span class="pi">:</span> <span class="s">claude-sonnet</span>
    <span class="na">litellm_params</span><span class="pi">:</span>
      <span class="na">model</span><span class="pi">:</span> <span class="s">anthropic/claude-sonnet</span>
      <span class="na">api_key</span><span class="pi">:</span> <span class="s">os.environ/DOORAE_LITELLM_ANTHROPIC_API_KEY</span>
</code></pre></div></div>

<p>실제 값은 Doorae DB에 암호화해 두고, subprocess spawn 시점에만 복호화해 env로 주입한다. 이렇게 하면 설정 파일, 백업, 로그에 키가 평문으로 남는 위험을 줄일 수 있다.</p>

<h3 id="3-에이전트-쪽-연결">3. 에이전트 쪽 연결</h3>

<p>게이트웨이만 있어서는 충분하지 않았다. 에이전트가 실제로 이 경로를 쓰도록 spawn manifest에도 값이 들어가야 했다.</p>

<p>예를 들어 <code class="language-plaintext highlighter-rouge">claude-code</code>에는 다음 값이 들어간다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ANTHROPIC_BASE_URL=&lt;server&gt;/api/v1/llm
ANTHROPIC_AUTH_TOKEN=@DOORAE_AGENT_TOKEN
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">codex</code>에는 OpenAI 호환 경로를 넣는다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OPENAI_BASE_URL=&lt;server&gt;/api/v1/llm/v1
OPENAI_API_KEY=@DOORAE_AGENT_TOKEN
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">@DOORAE_AGENT_TOKEN</code>은 sentinel이다. 서버는 agent token의 평문을 저장하지 않고 hash만 보관한다. 평문 token은 machine daemon이 spawn 시점에 알고 있으므로, machine 쪽에서 sentinel을 실제 token으로 치환한다.</p>

<p>agent process 안에서는 이 값들을 장시간 <code class="language-plaintext highlighter-rouge">os.environ</code>에 남기지 않는다. SDK 호출이 일어나는 짧은 구간에만 <code class="language-plaintext highlighter-rouge">secrets_in_env</code>로 노출하고, 호출이 끝나면 원래 환경으로 복원한다. Bash나 파일 읽기 도구가 <code class="language-plaintext highlighter-rouge">/proc/self/environ</code>을 통해 token을 볼 수 있는 시간을 줄이기 위한 조치다.</p>

<h2 id="검증">검증</h2>

<p>이 작업은 여러 패키지 경계가 맞물려 있어 단위별로 나누어 검증했다.</p>

<ul>
  <li>cluster: gateway feature flag가 켜졌을 때 spawn manifest에 엔진별 base URL과 token sentinel이 들어가는지 확인</li>
  <li>machine: <code class="language-plaintext highlighter-rouge">@DOORAE_AGENT_TOKEN</code> sentinel을 spawn 시점의 실제 agent token으로 정확히 치환하는지 확인</li>
  <li>agent: Claude Code와 Codex SDK 호출 구간에서만 env가 노출되고, 호출 후 기존 환경이 복원되는지 확인</li>
</ul>

<p>이 검증의 핵심은 “게이트웨이가 있다”가 아니라, 에이전트의 실제 SDK 호출이 Doorae 인증 토큰을 들고 <code class="language-plaintext highlighter-rouge">/api/v1/llm/*</code>로 들어가도록 전체 경로가 닫혔는지 확인하는 것이었다.</p>

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

<p>아직 남은 작업도 있다.</p>

<ul>
  <li>실제 인터넷이 없는 worker machine에서 end-to-end 호출 검증</li>
  <li>서버 전체 feature flag가 아니라 agent별 gateway 사용 여부 설정</li>
  <li>Usage 화면에서 모델별 가격표를 붙여 실제 비용 추정</li>
  <li>admin UI에서 secret test 버튼을 gateway 경로까지 연결</li>
</ul>

<h2 id="배운-점">배운 점</h2>

<p>LLM 게이트웨이는 단순 프록시 기능이 아니었다. 네트워크가 막힌 머신을 지원하고, 사용량을 추적하고, 여러 엔진의 프로토콜 차이를 흡수하고, 시크릿 노출면까지 줄여야 했다.</p>

<p>Doorae에서는 이 기능을 별도 거대한 인프라로 빼기보다, 서버가 관리하는 작은 subprocess와 reverse proxy로 시작했다. 초기 단계에서는 이 방식이 배포 단순성과 운영 통제 사이의 균형이 좋았다.</p>

<p>관련 자료:</p>

<ul>
  <li>저장소: <a href="https://github.com/e7217/doorae">github.com/e7217/doorae</a></li>
  <li>ADR: <a href="https://github.com/e7217/doorae/blob/main/docs/decisions/004-embedded-litellm-gateway.md">docs/decisions/004-embedded-litellm-gateway.md</a></li>
  <li>설계 문서: <a href="https://github.com/e7217/doorae/blob/main/docs/design/12-llm-gateway.md">docs/design/12-llm-gateway.md</a></li>
</ul>]]></content><author><name>e7217</name></author><category term="ai" /><category term="engineering" /><category term="doorae" /><category term="llm-gateway" /><category term="litellm" /><category term="proxy" /><category term="secrets" /><category term="multi-agent" /><summary type="html"><![CDATA[여러 머신에서 실행되는 AI 에이전트가 provider API를 직접 호출할 때 생기는 네트워크, 사용량 추적, 시크릿 문제를 LLM 게이트웨이로 정리한 기록.]]></summary></entry><entry><title type="html">Doorae 룸 공유 파일을 에이전트 메모리로 배포하기</title><link href="https://e7217.github.io/ai/project-log/doorae-room-shared-files-agent-memory/" rel="alternate" type="text/html" title="Doorae 룸 공유 파일을 에이전트 메모리로 배포하기" /><published>2026-05-19T02:10:00+09:00</published><updated>2026-05-19T02:10:00+09:00</updated><id>https://e7217.github.io/ai/project-log/doorae-room-shared-files-agent-memory</id><content type="html" xml:base="https://e7217.github.io/ai/project-log/doorae-room-shared-files-agent-memory/"><![CDATA[<p>Doorae에서 여러 에이전트와 협업하다 보면 자연스럽게 파일을 공유하고 싶어진다. 예를 들어 스펙 문서 하나를 올려두고 Claude Code와 Codex에게 동시에 의견을 받고 싶다. 하지만 초기 구조는 텍스트 메시지 중심이라, 사용자가 매번 문서 내용을 채팅에 복사해 넣어야 했다.</p>

<p>이 문제를 풀기 위해 룸 단위 공유 파일을 만들고, 그 파일을 참여 에이전트의 메모리 디렉터리로 복사 배포하는 구조를 잡았다.</p>

<h2 id="요구사항">요구사항</h2>

<p>목표는 단순한 파일 업로드가 아니었다.</p>

<ul>
  <li>룸에 파일을 업로드하고 목록/삭제할 수 있어야 한다.</li>
  <li>룸에 참여한 모든 에이전트가 같은 파일을 볼 수 있어야 한다.</li>
  <li>서버 DB가 파일 본문 때문에 커지면 안 된다.</li>
  <li>에이전트별 private memory와 룸 공유 자료의 생명주기를 분리해야 한다.</li>
  <li>머신이나 에이전트가 나중에 들어와도 backfill이 가능해야 한다.</li>
</ul>

<p>1차 범위는 텍스트 계열 파일로 제한했다. 크기도 256KB 이하로 잡았다. 이미지나 바이너리까지 바로 넣으면 토큰 예산과 저장소 정책이 동시에 복잡해진다.</p>

<h2 id="저장-구조">저장 구조</h2>

<p>파일 원문은 SQLite DB에 넣지 않았다. DB에는 metadata와 <code class="language-plaintext highlighter-rouge">sha256</code>만 저장하고, 원문은 <code class="language-plaintext highlighter-rouge">~/.doorae/room_files/&lt;room_id&gt;/</code> 아래에 둔다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.doorae/
├── doorae.db
├── room_files/
│   └── &lt;room_id&gt;/
│       ├── .tmp/
│       └── &lt;file_id&gt;
└── agents/
    └── &lt;agent_id&gt;/
        └── memory/
            ├── notes.md
            └── shared/
                └── spec.md
</code></pre></div></div>

<p>업로드는 임시 경로에 먼저 쓰고, sha256을 계산한 뒤 <code class="language-plaintext highlighter-rouge">os.replace</code>로 원자적 rename을 한다. DB commit이 실패하면 방금 쓴 파일을 지운다. 서버가 중간에 죽어 <code class="language-plaintext highlighter-rouge">.tmp</code>가 남아도, 부팅 시 <code class="language-plaintext highlighter-rouge">cleanup_orphans</code>가 정리한다.</p>

<p>이런 구조를 택한 이유는 명확하다. Doorae의 기본 저장소는 SQLite이고, 파일 원문을 DB에 넣으면 DB 파일이 빠르게 커지고 writer lock 부담도 커진다. 반면 <code class="language-plaintext highlighter-rouge">~/.doorae/</code>는 이미 agent memory와 machine 설정이 모이는 운영 루트라, 파일 저장소도 같은 루트 아래 두는 편이 관리하기 쉽다.</p>

<h2 id="에이전트로-fan-out">에이전트로 fan-out</h2>

<p>업로드가 끝나면 서버는 룸에 배치된 에이전트마다 machine frame을 보낸다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AgentMemorySharedFileWriteFrame
  agent_id
  storage_name
  content
  content_sha256
</code></pre></div></div>

<p>machine daemon은 이 frame을 받아 각 에이전트의 <code class="language-plaintext highlighter-rouge">memory/shared/&lt;storage_name&gt;</code>에 파일을 쓴다. 이미 같은 sha256의 파일이 있으면 skip한다. 이 덕분에 재전송이 멱등이 된다.</p>

<p>삭제는 반대 방향이다. 서버에서 파일 metadata와 원문을 지우고, 각 에이전트에게 delete frame을 보낸다.</p>

<h2 id="프롬프트-주입">프롬프트 주입</h2>

<p>파일이 디스크에만 있으면 에이전트가 모른다. 그래서 agent memory compose 단계에 <code class="language-plaintext highlighter-rouge">&lt;shared-context&gt;</code> 블록을 추가했다.</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;shared-context&gt;</span>
  <span class="nt">&lt;file</span> <span class="na">name=</span><span class="s">"spec.md"</span> <span class="na">sha256=</span><span class="s">"..."</span><span class="nt">&gt;</span>
    ...
  <span class="nt">&lt;/file&gt;</span>
<span class="nt">&lt;/shared-context&gt;</span>
</code></pre></div></div>

<p>기존 <code class="language-plaintext highlighter-rouge">notes.md</code>는 에이전트 개인 메모리다. 반면 <code class="language-plaintext highlighter-rouge">memory/shared/</code>는 룸이 소유하는 정적 공유 자료다. 두 생명주기를 같은 파일에 섞지 않은 것이 중요했다. 개인 메모리는 에이전트가 쓰고 서버와 sync할 수 있지만, 공유 파일은 서버가 쓰고 machine이 배포하는 one-way 자료다.</p>

<h2 id="메시지에서-파일-참조하기">메시지에서 파일 참조하기</h2>

<p>사용자는 메시지 입력창에서 파일을 첨부하거나 <code class="language-plaintext highlighter-rouge">$filename</code> 형태로 공유 파일을 참조할 수 있다. 클라이언트는 이를 <code class="language-plaintext highlighter-rouge">metadata.references[]</code>로 보내고, 서버는 현재 룸에 실제로 존재하는 파일인지 canonicalize한다.</p>

<p>에이전트에게는 전체 파일을 메시지마다 다시 붙이지 않는다. 대신 turn-local hint만 준다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;referenced-files&gt;
- spec.md: memory/shared/spec.md
&lt;/referenced-files&gt;
</code></pre></div></div>

<p>실제 내용은 이미 <code class="language-plaintext highlighter-rouge">memory/shared/</code>와 <code class="language-plaintext highlighter-rouge">&lt;shared-context&gt;</code> 경로로 들어간다. 이렇게 해야 같은 파일을 여러 번 참조해도 프롬프트 구조가 과하게 커지지 않는다.</p>

<h2 id="배운-점">배운 점</h2>

<p>멀티 에이전트 협업에서 파일 공유는 UI 기능처럼 보이지만, 실제로는 storage, machine protocol, prompt composition, access control이 함께 움직이는 기능이었다.</p>

<p>이번 작업에서 가장 중요한 결정은 DB가 아니라 디스크에 원문을 두고, 중앙 파일을 symlink하지 않고 에이전트별로 복사했다는 점이다. 저장 공간은 더 쓰지만, 각 에이전트의 작업 공간이 격리되고 실패 범위가 작아진다. MVP 단계에서는 이 단순함이 더 가치 있었다.</p>

<p>관련 저장소: <a href="https://github.com/e7217/doorae">github.com/e7217/doorae</a></p>]]></content><author><name>e7217</name></author><category term="ai" /><category term="project-log" /><category term="doorae" /><category term="multi-agent" /><category term="shared-files" /><category term="memory" /><category term="prompt-context" /><category term="fastapi" /><summary type="html"><![CDATA[Doorae에서 여러 에이전트와 협업하다 보면 자연스럽게 파일을 공유하고 싶어진다. 예를 들어 스펙 문서 하나를 올려두고 Claude Code와 Codex에게 동시에 의견을 받고 싶다. 하지만 초기 구조는 텍스트 메시지 중심이라, 사용자가 매번 문서 내용을 채팅에 복사해 넣어야 했다.]]></summary></entry><entry><title type="html">Doorae의 Cluster, Machine, Agent 구조 정리</title><link href="https://e7217.github.io/ai/engineering/doorae-cluster-machine-agent-architecture/" rel="alternate" type="text/html" title="Doorae의 Cluster, Machine, Agent 구조 정리" /><published>2026-05-19T02:00:00+09:00</published><updated>2026-05-19T02:00:00+09:00</updated><id>https://e7217.github.io/ai/engineering/doorae-cluster-machine-agent-architecture</id><content type="html" xml:base="https://e7217.github.io/ai/engineering/doorae-cluster-machine-agent-architecture/"><![CDATA[<p>Doorae는 여러 AI 에이전트가 같은 룸에서 대화하고 작업할 수 있게 만드는 멀티 에이전트 채팅 플랫폼이다. 겉으로 보면 채팅 UI지만, 내부 구조는 단순한 채팅 서버보다 조금 더 복잡하다. 에이전트는 같은 서버 프로세스 안에서 실행되지 않고, 별도 머신에서 subprocess로 뜬 뒤 서버와 WebSocket으로 통신한다.</p>

<p>이 구조를 잡을 때 가장 중요했던 구분은 <strong>데이터 평면과 제어 평면을 분리하는 것</strong>이었다.</p>

<h2 id="물리적-구성">물리적 구성</h2>

<p>Doorae의 기본 구성 요소는 세 가지다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>doorae-cluster
  채팅 서버, REST API, Web UI, 스케줄러

doorae-machine
  각 호스트에 상주하는 daemon
  서버의 명령을 받아 에이전트 subprocess를 spawn/kill

doorae-agent
  claude-code, codex, gemini-cli, openhands 같은 엔진 어댑터
  룸에 접속해 메시지를 읽고 답변
</code></pre></div></div>

<p>여기서 Machine은 단순 논리 단위가 아니라 실제로 서버와 다른 PC, VM, GPU 서버일 수 있다. 서버는 “어떤 머신이 어떤 엔진을 실행할 수 있는가”를 보고, 적절한 머신에 에이전트 생성을 명령한다.</p>

<h2 id="websocket-두-종류">WebSocket 두 종류</h2>

<p>서버에는 성격이 다른 WebSocket 경로가 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/ws/rooms/{id}
  유저와 에이전트가 메시지를 주고받는 데이터 평면

/ws/machines/{id}
  machine daemon이 spawn/kill 명령을 받는 제어 평면
</code></pre></div></div>

<p>Machine daemon은 <code class="language-plaintext highlighter-rouge">/ws/machines/{id}</code>에 연결해 자기 capability를 보고하고, 서버의 명령을 기다린다. 반면 에이전트 subprocess는 별도로 <code class="language-plaintext highlighter-rouge">/ws/rooms/{id}</code>에 연결한다. 즉 daemon의 제어 연결과 agent의 대화 연결은 분리되어 있다.</p>

<p>이 분리가 중요하다. daemon은 에이전트를 관리하지만, 대화 메시지를 대신 중계하지 않는다. 에이전트는 서버에 독립적으로 참여하는 room participant가 된다.</p>

<h2 id="왜-이렇게-나눴나">왜 이렇게 나눴나</h2>

<p>에이전트는 로컬 파일, MCP 서버, CLI 도구, 모델 SDK 등 다양한 런타임 자원에 접근한다. 이 자원을 모두 중앙 서버 안으로 끌어오면 서버가 너무 많은 책임을 갖게 된다.</p>

<p>반대로 에이전트를 각 머신에서 실행하면 다음 장점이 생긴다.</p>

<ul>
  <li>로컬 개발환경이나 GPU 서버처럼 머신별 특성을 활용할 수 있다.</li>
  <li>에이전트 엔진이 subprocess로 격리된다.</li>
  <li>서버는 메시지, 인증, 스케줄링, 상태 관리에 집중한다.</li>
  <li>머신이 끊겨도 서버는 그 사실을 presence로 감지하고 UI에 반영할 수 있다.</li>
</ul>

<p>최근에는 이 구조 위에서 <code class="language-plaintext highlighter-rouge">machine_online</code> 값을 agent response에 포함시키는 작업도 했다. DB에는 에이전트 상태가 <code class="language-plaintext highlighter-rouge">running</code>으로 남아 있어도, 실제 machine WebSocket이 끊긴 상태라면 UI에서는 unreachable/offline으로 보여야 하기 때문이다.</p>

<h2 id="스케줄러의-역할">스케줄러의 역할</h2>

<p>사용자가 에이전트를 생성하면 서버는 그 에이전트를 어느 Machine에 둘지 결정한다. 이때 서버는 Machine이 보고한 capability와 현재 배치 상태를 보고 spawn 명령을 보낸다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>user request
  -&gt; cluster scheduler
  -&gt; selected machine
  -&gt; machine daemon spawn
  -&gt; agent subprocess
  -&gt; room websocket join
</code></pre></div></div>

<p>이 흐름 덕분에 사용자는 “에이전트 생성”이라는 선언적 요청만 하고, 실제 프로세스 생성과 연결은 시스템이 처리한다.</p>

<h2 id="데이터와-제어를-섞지-않기">데이터와 제어를 섞지 않기</h2>

<p>이 아키텍처에서 가장 피하고 싶었던 것은 machine daemon이 모든 것을 대신 처리하는 구조였다. daemon이 메시지 중계까지 맡으면 장애 지점이 애매해지고, agent lifecycle과 room participant lifecycle이 섞인다.</p>

<p>그래서 daemon은 제어 평면만 담당한다.</p>

<ul>
  <li>에이전트 프로세스 생성</li>
  <li>에이전트 프로세스 종료</li>
  <li>메모리 파일 materialize</li>
  <li>공유 파일 write/delete</li>
  <li>머신 capability 보고</li>
</ul>

<p>실제 대화는 agent가 직접 room WebSocket에 붙어 처리한다.</p>

<h2 id="배운-점">배운 점</h2>

<p>멀티 에이전트 시스템을 만들 때 “에이전트를 어디서 실행할 것인가”는 초기에 정해야 하는 큰 결정이다. 중앙 서버 안에서 모두 실행하면 단순해 보이지만, 로컬 도구와 머신별 자원을 쓰기 시작하는 순간 한계가 온다.</p>

<p>Doorae는 서버를 오케스트레이터로 두고, Machine을 실행 단위로 두고, Agent를 대화 참여자로 분리했다. 이 구분이 이후 공유 파일, LLM 게이트웨이, OpenHands 엔진 추가 같은 작업의 기반이 되었다.</p>

<p>관련 저장소: <a href="https://github.com/e7217/doorae">github.com/e7217/doorae</a></p>]]></content><author><name>e7217</name></author><category term="ai" /><category term="engineering" /><category term="doorae" /><category term="multi-agent" /><category term="websocket" /><category term="scheduler" /><category term="architecture" /><summary type="html"><![CDATA[Doorae는 여러 AI 에이전트가 같은 룸에서 대화하고 작업할 수 있게 만드는 멀티 에이전트 채팅 플랫폼이다. 겉으로 보면 채팅 UI지만, 내부 구조는 단순한 채팅 서버보다 조금 더 복잡하다. 에이전트는 같은 서버 프로세스 안에서 실행되지 않고, 별도 머신에서 subprocess로 뜬 뒤 서버와 WebSocket으로 통신한다.]]></summary></entry><entry><title type="html">EDG 어댑터 SDK와 Modbus TCP 예제 확장기</title><link href="https://e7217.github.io/smart-factory/engineering/edg-adapter-sdk-and-modbus-reference/" rel="alternate" type="text/html" title="EDG 어댑터 SDK와 Modbus TCP 예제 확장기" /><published>2026-05-19T01:20:00+09:00</published><updated>2026-05-19T01:20:00+09:00</updated><id>https://e7217.github.io/smart-factory/engineering/edg-adapter-sdk-and-modbus-reference</id><content type="html" xml:base="https://e7217.github.io/smart-factory/engineering/edg-adapter-sdk-and-modbus-reference/"><![CDATA[<p>EDG에서 가장 오래 갈 인터페이스는 SDK가 아니라 wire contract라고 봤다. 어댑터가 결국 해야 하는 일은 정해진 NATS subject에 정해진 JSON payload를 publish하고, 필요한 경우 메타데이터 subject를 호출하는 것이다.</p>

<p>그렇다고 모든 어댑터 작성자가 매번 NATS 연결, 재시도, payload 모델, 관계 생성 코드를 직접 써야 하는 것은 아니다. 그래서 EDG는 <strong>wire contract를 우선으로 두고, SDK는 편의 계층으로 제공하는 방향</strong>을 택했다.</p>

<h2 id="왜-sdk보다-subject-계약을-먼저-봤나">왜 SDK보다 subject 계약을 먼저 봤나</h2>

<p>산업용 프로토콜은 언어 선택이 제각각이다. 어떤 현장은 Python 라이브러리가 편하고, 어떤 프로토콜은 Go나 C/C++ 바인딩이 더 낫다. 특정 SDK를 강제하면 어댑터 작성의 자유도가 줄어든다.</p>

<p>그래서 EDG의 기본 계약은 작게 유지했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>platform.data.asset
platform.data.validated
platform.meta.asset.*
platform.meta.relation.*
platform.meta.*.changed
</code></pre></div></div>

<p>이 subject와 payload 형식만 맞추면 Python SDK를 쓰든, Go SDK를 쓰든, 직접 NATS client를 쓰든 EDG와 통합할 수 있다.</p>

<p>SDK는 이 계약을 감싸는 도구다. 반복 작업을 줄이고, 재연결이나 collect loop 같은 공통 로직을 제공하지만, 플랫폼 통합의 유일한 입구는 아니다.</p>

<h2 id="python-sdk에서-go-sdk로">Python SDK에서 Go SDK로</h2>

<p>처음에는 Python SDK가 자연스러웠다. Modbus, BACnet, EtherNet/IP 같은 프로토콜을 다룰 때 Python 생태계가 빠르게 실험하기 좋기 때문이다.</p>

<p>하지만 엣지 환경에서는 단일 바이너리 배포나 Go-native 프로토콜 라이브러리가 더 편한 경우도 있다. 그래서 Go SDK를 추가하면서 Python SDK와 같은 표면을 맞추는 데 집중했다.</p>

<p>Go SDK가 제공해야 하는 기본 기능은 단순했다.</p>

<ul>
  <li>일정 주기로 값을 수집하는 <code class="language-plaintext highlighter-rouge">Collect</code> loop</li>
  <li><code class="language-plaintext highlighter-rouge">TagValue</code> 모델과 quality/unit 표현</li>
  <li>NATS publish</li>
  <li>자산과 관계 메타데이터 CRUD</li>
  <li>메타데이터 변경 이벤트 구독</li>
  <li>연결 실패와 재시도 처리</li>
</ul>

<p>핵심은 “Go SDK가 별도 제품처럼 느껴지지 않게 하는 것”이었다. 언어는 달라도 EDG 입장에서 어댑터가 말하는 wire format은 같아야 한다.</p>

<h2 id="modbus-tcp-reference-adapter">Modbus TCP reference adapter</h2>

<p>SDK만 있으면 여전히 추상적이다. 그래서 실제 현장에서 가장 흔하게 만나는 Modbus TCP 예제를 Python과 Go 양쪽에 넣었다.</p>

<p>예제 어댑터는 YAML mapping을 읽어 레지스터를 <code class="language-plaintext highlighter-rouge">TagValue</code>로 바꾼다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="m">1</span>
<span class="na">host</span><span class="pi">:</span> <span class="s">127.0.0.1</span>
<span class="na">port</span><span class="pi">:</span> <span class="m">502</span>
<span class="na">unit_id</span><span class="pi">:</span> <span class="m">1</span>
<span class="na">poll_interval</span><span class="pi">:</span> <span class="m">1.0</span>
<span class="na">timeout</span><span class="pi">:</span> <span class="m">1.0</span>

<span class="na">registers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">temperature</span>
    <span class="na">function</span><span class="pi">:</span> <span class="s">holding</span>
    <span class="na">address</span><span class="pi">:</span> <span class="m">0</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">int16</span>
    <span class="na">scale</span><span class="pi">:</span> <span class="m">0.1</span>
    <span class="na">unit</span><span class="pi">:</span> <span class="s2">"</span><span class="s">C"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">flow_rate</span>
    <span class="na">function</span><span class="pi">:</span> <span class="s">input</span>
    <span class="na">address</span><span class="pi">:</span> <span class="m">100</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">float32</span>
    <span class="na">word_order</span><span class="pi">:</span> <span class="s">CDAB</span>
    <span class="na">unit</span><span class="pi">:</span> <span class="s2">"</span><span class="s">L/min"</span>
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">word_order</code>가 중요했다. Modbus 장비는 32-bit 값을 다룰 때 word swap이 흔하고, 제조사 매뉴얼마다 표기가 다르다. <code class="language-plaintext highlighter-rouge">ABCD</code>, <code class="language-plaintext highlighter-rouge">CDAB</code>, <code class="language-plaintext highlighter-rouge">BADC</code>, <code class="language-plaintext highlighter-rouge">DCBA</code>를 명시적으로 지원하면 현장에서 디코딩 문제를 빨리 좁힐 수 있다.</p>

<p>Python 예제는 <code class="language-plaintext highlighter-rouge">pymodbus</code>, Go 예제는 <code class="language-plaintext highlighter-rouge">goburrow/modbus</code>를 사용한다. 둘 다 같은 YAML schema를 읽고 같은 EDG payload를 만든다.</p>

<h2 id="테스트가-잡아준-부분">테스트가 잡아준 부분</h2>

<p>Modbus 디코더는 보기보다 실수하기 쉽다. 특히 signed/unsigned, float32, scale, word order 조합이 섞이면 “값은 나오는데 틀린 값”이 된다.</p>

<p>그래서 decoder matrix test를 양쪽 구현에 넣었다. 같은 testdata를 두 언어에서 공유하면 Python과 Go 구현이 같은 의미를 유지하는지 확인할 수 있다.</p>

<p>또 어댑터 SDK 쪽에는 collect loop, backoff, relation 모델, backward compatibility 테스트를 붙였다. 어댑터는 현장에서 오래 돌아가야 하므로, 예외가 발생했을 때 조용히 죽거나 잘못된 quality를 내보내면 안 된다.</p>

<h2 id="배운-점">배운 점</h2>

<p>SDK를 먼저 크게 만들기보다, wire contract를 작게 고정하고 SDK를 그 위의 편의 계층으로 두는 편이 산업용 게이트웨이에는 더 잘 맞았다.</p>

<p>현장 프로토콜은 다양하고, 언어 선택도 상황마다 달라진다. EDG가 해야 할 일은 특정 언어를 강제하는 것이 아니라, 어떤 언어에서든 같은 subject와 payload로 들어올 수 있는 좁고 안정적인 입구를 제공하는 것이다.</p>

<p>관련 저장소: <a href="https://github.com/e7217/edg">github.com/e7217/edg</a></p>]]></content><author><name>e7217</name></author><category term="smart-factory" /><category term="engineering" /><category term="edg" /><category term="adapter" /><category term="sdk" /><category term="modbus" /><category term="go" /><category term="python" /><summary type="html"><![CDATA[EDG에서 가장 오래 갈 인터페이스는 SDK가 아니라 wire contract라고 봤다. 어댑터가 결국 해야 하는 일은 정해진 NATS subject에 정해진 JSON payload를 publish하고, 필요한 경우 메타데이터 subject를 호출하는 것이다.]]></summary></entry><entry><title type="html">EDG 자산 메타데이터와 변경 이벤트 설계</title><link href="https://e7217.github.io/smart-factory/project-log/edg-asset-metadata-events/" rel="alternate" type="text/html" title="EDG 자산 메타데이터와 변경 이벤트 설계" /><published>2026-05-19T01:10:00+09:00</published><updated>2026-05-19T01:10:00+09:00</updated><id>https://e7217.github.io/smart-factory/project-log/edg-asset-metadata-events</id><content type="html" xml:base="https://e7217.github.io/smart-factory/project-log/edg-asset-metadata-events/"><![CDATA[<p>EDG를 단순한 시계열 수집기로만 보면 <code class="language-plaintext highlighter-rouge">asset_id</code>와 태그 값만 있으면 충분해 보인다. 하지만 스마트팩토리나 디지털 트윈 쪽으로 확장하려면 설비가 어떤 계층에 속하는지, 어떤 설비와 연결되는지, 외부 시스템의 식별자와 어떻게 매칭되는지가 중요해진다.</p>

<p>그래서 EDG에서는 데이터 수집과 별도로 <strong>자산 메타데이터 모델</strong>을 키우는 작업이 필요했다.</p>

<h2 id="시작점">시작점</h2>

<p>초기 데이터 payload는 단순하다.</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">"asset_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sensor-001"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"values"</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><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"temperature"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"number"</span><span class="p">:</span><span class="w"> </span><span class="mf">25.5</span><span class="p">,</span><span class="w">
      </span><span class="nl">"unit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"C"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"quality"</span><span class="p">:</span><span class="w"> </span><span class="s2">"good"</span><span class="w">
    </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>이 구조는 수집에는 좋다. 그러나 운영 화면이나 상위 시스템에서는 “sensor-001이 어느 라인의 어떤 장비에 붙어 있는가”가 더 중요할 수 있다. 또 AAS, ECLASS, OPC UA node id 같은 외부 식별자를 보관할 자리도 필요하다.</p>

<p>그래서 자산에는 다음 개념을 추가했다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">source</code>: <code class="language-plaintext highlighter-rouge">manual</code>, <code class="language-plaintext highlighter-rouge">auto</code>, <code class="language-plaintext highlighter-rouge">modbus</code>, <code class="language-plaintext highlighter-rouge">opcua</code>, <code class="language-plaintext highlighter-rouge">mes</code>처럼 메타데이터 출처를 나타내는 값</li>
  <li><code class="language-plaintext highlighter-rouge">external_ids</code>: AAS, ECLASS, IRDI, OPC UA node id 같은 외부 식별자</li>
  <li><code class="language-plaintext highlighter-rouge">attributes</code>: 아직 색인까지는 필요 없는 어댑터별 부가 정보</li>
  <li>relations: <code class="language-plaintext highlighter-rouge">partOf</code>, <code class="language-plaintext highlighter-rouge">connectedTo</code>, <code class="language-plaintext highlighter-rouge">locatedIn</code> 같은 자산 관계</li>
</ul>

<p>이 모델은 “태그 값 모음”에서 “설비 그래프의 일부”로 넘어가기 위한 기반이다.</p>

<h2 id="자동-등록과-수동-거버넌스">자동 등록과 수동 거버넌스</h2>

<p>EDG Core는 처음 보는 <code class="language-plaintext highlighter-rouge">asset_id</code>의 데이터가 들어오면 자산을 자동 등록할 수 있다. 현장 PoC에서는 이 방식이 편하다. 설비나 센서를 먼저 수동 등록하지 않아도 데이터가 들어오는 순간 최소 메타데이터가 생긴다.</p>

<p>하지만 모든 환경에서 자동 등록이 맞지는 않다. 운영 조직이 자산 마스터를 엄격하게 관리하거나, 승인되지 않은 설비 ID가 시스템에 생기면 안 되는 경우도 있다.</p>

<p>그래서 자산 등록 모드를 설정으로 분리했다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">asset_registration</span><span class="pi">:</span>
  <span class="na">mode</span><span class="pi">:</span> <span class="s">auto</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">auto</code> 모드에서는 미등록 자산을 <code class="language-plaintext highlighter-rouge">source: auto</code>로 생성한다. <code class="language-plaintext highlighter-rouge">manual</code> 모드에서는 미등록 자산을 자동 생성하지 않고 로그만 남긴다. 이 작은 설정 하나가 PoC 친화성과 운영 거버넌스 사이의 균형점을 만든다.</p>

<h2 id="메타데이터-변경-이벤트">메타데이터 변경 이벤트</h2>

<p>자산 정보가 바뀌면 다른 어댑터나 사이드카도 그 사실을 알아야 한다. 예를 들어 OPC UA 어댑터가 자산 목록을 캐싱하고 있다면, 수동 등록이나 관계 변경을 감지해 로컬 뷰를 갱신해야 한다.</p>

<p>EDG는 메타데이터 변경을 NATS subject로 발행한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>platform.meta.asset.changed
platform.meta.relation.changed
</code></pre></div></div>

<p>payload는 변경 전후 스냅샷을 담는다.</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">"schema_version"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"event_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"created"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"entity_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"asset"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"entity_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sensor-001"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auto"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"before"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
  </span><span class="nl">"after"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sensor-001"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sensor-001"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auto"</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>여기서 중요한 점은 이 이벤트가 <strong>best-effort notification</strong>이라는 것이다. 데이터 플레인의 검증 스트림처럼 JetStream에 영구 저장되는 이벤트가 아니다. 구독자가 꺼져 있으면 놓칠 수 있다.</p>

<p>그래서 구독자는 시작할 때 먼저 <code class="language-plaintext highlighter-rouge">platform.meta.asset.list</code>로 현재 상태를 가져오고, 이후 <code class="language-plaintext highlighter-rouge">platform.meta.*.changed</code>를 구독해 증분만 적용해야 한다. 이벤트만 믿지 않고 항상 reconciliation 경로를 두는 방식이다.</p>

<h2 id="설계하면서-정리된-원칙">설계하면서 정리된 원칙</h2>

<p>이번 작업에서 정리된 원칙은 세 가지다.</p>

<p>첫째, 자산 메타데이터는 데이터 수집 payload와 분리한다. 수집 경로는 빠르고 단순해야 하고, 메타데이터는 점진적으로 풍부해져야 한다.</p>

<p>둘째, 자동 등록은 편의 기능이지 유일한 운영 모델이 아니다. 현장 실험에서는 자동 등록이 속도를 내지만, 실제 운영에서는 수동 거버넌스가 필요할 수 있다.</p>

<p>셋째, 메타데이터 이벤트는 상태 저장소가 아니라 알림이다. 이벤트를 놓쳐도 복구할 수 있도록 list API와 함께 써야 한다.</p>

<h2 id="배운-점">배운 점</h2>

<p>스마트팩토리 시스템에서 “설비 데이터”는 값 자체보다 맥락이 중요해지는 순간이 온다. 온도 25.5도라는 값은 그 센서가 어느 설비의 어느 공정에 속하는지 알 때 의미가 커진다.</p>

<p>EDG의 메타데이터 작업은 디지털 트윈을 바로 완성하는 작업은 아니지만, 나중에 그 방향으로 갈 수 있도록 데이터 모델의 방향을 잡은 작업이었다.</p>

<p>관련 저장소: <a href="https://github.com/e7217/edg">github.com/e7217/edg</a></p>]]></content><author><name>e7217</name></author><category term="smart-factory" /><category term="project-log" /><category term="edg" /><category term="asset-model" /><category term="metadata" /><category term="digital-twin" /><category term="nats" /><category term="event" /><summary type="html"><![CDATA[EDG를 단순한 시계열 수집기로만 보면 asset_id와 태그 값만 있으면 충분해 보인다. 하지만 스마트팩토리나 디지털 트윈 쪽으로 확장하려면 설비가 어떤 계층에 속하는지, 어떤 설비와 연결되는지, 외부 시스템의 식별자와 어떻게 매칭되는지가 중요해진다.]]></summary></entry><entry><title type="html">산업용 데이터 수집에서 저장 보장의 경계 정하기</title><link href="https://e7217.github.io/smart-factory/engineering/edg-data-plane-reliability/" rel="alternate" type="text/html" title="산업용 데이터 수집에서 저장 보장의 경계 정하기" /><published>2026-05-19T01:00:00+09:00</published><updated>2026-05-19T01:00:00+09:00</updated><id>https://e7217.github.io/smart-factory/engineering/edg-data-plane-reliability</id><content type="html" xml:base="https://e7217.github.io/smart-factory/engineering/edg-data-plane-reliability/"><![CDATA[<p>설비 데이터 수집에서 가장 위험한 착각은 “publish가 성공했으니 저장됐다”고 믿는 것이다. 센서 값 하나가 여러 홉을 지나기 때문에, 어느 지점부터 내구성이 보장되는지 명확히 하지 않으면 운영자와 어댑터 작성자가 서로 다른 기대를 갖게 된다.</p>

<p>이 글은 개인 프로젝트인 <strong>EDG Platform</strong>에서 데이터 플레인의 신뢰성 경계를 정리한 기록이다. 핵심은 <strong>at-least-once delivery가 어디서 시작되는지</strong>를 코드, 설정, 문서에 같은 언어로 남기는 것이었다.</p>

<h2 id="요약">요약</h2>

<ul>
  <li>문제: 어댑터 publish 성공과 저장 계층의 내구성 보장이 섞여 있었다.</li>
  <li>결정: <code class="language-plaintext highlighter-rouge">platform.data.validated</code>에 대한 JetStream publish ack 이후부터 at-least-once delivery로 정의했다.</li>
  <li>구현: 검증 스트림, dead-letter subject, expvar counter, ADR 문서를 함께 추가했다.</li>
  <li>남은 과제: 어댑터 앞단의 end-to-end ack나 로컬 버퍼링은 별도 설계가 필요하다.</li>
</ul>

<h2 id="프로젝트-맥락">프로젝트 맥락</h2>

<p>EDG의 데이터 흐름은 대략 이렇게 나뉜다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adapter
  -&gt; platform.data.asset
  -&gt; EDG Core
  -&gt; platform.data.validated
  -&gt; Telegraf
  -&gt; VictoriaMetrics
</code></pre></div></div>

<p>EDG는 산업용 설비 데이터를 엣지에서 수집하고 검증한 뒤 저장 계층으로 흘려보내는 게이트웨이다. 처음에는 전체 흐름을 통째로 “안정적으로 저장된다”고 설명하기 쉬웠다.</p>

<p>하지만 실제 구현을 보면 어댑터에서 코어로 들어오는 홉은 일반 NATS publish이고, 코어에서 검증된 데이터를 내보내는 홉은 NATS JetStream publish ack를 기다린다. 두 구간은 같은 신뢰성 모델이 아니다.</p>

<h2 id="문제">문제</h2>

<p>“신뢰성 있는 수집”이라는 말은 너무 넓다. 구체적으로는 다음 질문에 답해야 했다.</p>

<ul>
  <li>어댑터가 NATS에 publish하면 저장된 것으로 볼 수 있는가?</li>
  <li>코어가 검증한 payload는 어디에 내구적으로 남는가?</li>
  <li>저장 실패를 운영자가 어떻게 감지하는가?</li>
  <li>어댑터 작성자는 어느 지점까지 직접 재시도해야 하는가?</li>
</ul>

<p>이 질문에 답하지 않으면 운영자는 EDG가 전체 구간을 보장한다고 오해할 수 있고, 어댑터 작성자는 로컬 버퍼링을 생략할 수 있다.</p>

<h2 id="선택지">선택지</h2>

<table>
  <thead>
    <tr>
      <th>선택지</th>
      <th>장점</th>
      <th>단점</th>
      <th>판단</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>전체 구간을 best-effort로 둔다</td>
      <td>구현이 단순하다</td>
      <td>“신뢰성”을 설명할 수 없다</td>
      <td>제외</td>
    </tr>
    <tr>
      <td>어댑터부터 저장소까지 end-to-end ack를 만든다</td>
      <td>의미가 가장 명확하다</td>
      <td>request/reply, 로컬 큐, 어댑터 변경이 커진다</td>
      <td>이후 과제</td>
    </tr>
    <tr>
      <td>코어 이후를 JetStream 기준으로 보장한다</td>
      <td>현재 구조에서 명확한 경계를 만들 수 있다</td>
      <td>어댑터 앞단은 여전히 별도 책임이다</td>
      <td>선택</td>
    </tr>
  </tbody>
</table>

<p>이번 단계에서는 세 번째를 선택했다. EDG가 이미 코어 이후 validated stream을 갖고 있었고, JetStream publish ack는 운영자가 이해할 수 있는 명확한 경계였다.</p>

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

<p>ADR 0001에서는 EDG의 신뢰성 경계를 다음처럼 고정했다.</p>

<blockquote>
  <p>EDG의 at-least-once delivery는 코어가 <code class="language-plaintext highlighter-rouge">platform.data.validated</code>에 JetStream publish ack를 받은 뒤부터 시작한다.</p>
</blockquote>

<p>이 문장이 중요하다. 어댑터가 <code class="language-plaintext highlighter-rouge">platform.data.asset</code>에 publish했다고 해서 그 데이터가 내구적으로 저장됐다고 볼 수는 없다. 강한 보장이 필요한 어댑터는 그 앞단에서 재시도나 로컬 버퍼링을 직접 가져야 한다.</p>

<h2 id="jetstream-기본-정책">JetStream 기본 정책</h2>

<p>기본 스트림은 <code class="language-plaintext highlighter-rouge">PLATFORM_DATA</code>다. subject는 <code class="language-plaintext highlighter-rouge">platform.data.&gt;</code>를 잡고, 파일 스토리지 기반으로 7일 또는 1GiB까지 보관한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Stream    : PLATFORM_DATA
Subjects  : platform.data.&gt;
Storage   : file
Retention : limits
Max age   : 168h
Max bytes : 1 GiB
Replicas  : 1
Discard   : old
</code></pre></div></div>

<p>단일 노드 엣지 게이트웨이를 먼저 목표로 했기 때문에 replica는 1이다. 멀티 노드 복제는 배포, 스토리지, 업그레이드 모델까지 바꾸는 문제라 이번 결정에서는 제외했다.</p>

<p><code class="language-plaintext highlighter-rouge">DiscardOld</code>도 의도적인 선택이다. 스트림이 꽉 찼을 때 새 데이터를 거부하는 대신 오래된 데이터를 밀어낸다. 이 정책은 운영자가 저장소 압박을 모니터링해야 한다는 책임을 만든다. 그래서 설정값과 함께 관측 지표가 필요했다.</p>

<h2 id="실패를-드러내는-장치">실패를 드러내는 장치</h2>

<p>검증된 데이터를 JetStream으로 publish할 때 실패할 수 있다. 이때 단순히 로그만 남기면 운영자가 손실 가능성을 늦게 발견한다. 그래서 실패한 publish는 <code class="language-plaintext highlighter-rouge">platform.data.deadletter</code>로 감싼다.</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">"original_subject"</span><span class="p">:</span><span class="w"> </span><span class="s2">"platform.data.asset"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"target_subject"</span><span class="p">:</span><span class="w"> </span><span class="s2">"platform.data.validated"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"payload"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"..."</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>그리고 코어는 expvar 카운터를 노출한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>edg_core_jetstream_publish_failures
edg_core_jetstream_dead_letters
edg_core_jetstream_dead_letter_failures
</code></pre></div></div>

<p>이 카운터들은 “데이터가 잘 저장되고 있다”보다 “어디서 압력이 생기는지”를 보기 위한 장치다. 특히 dead-letter 자체도 publish에 실패할 수 있기 때문에 별도 실패 카운터를 둔 점이 중요했다.</p>

<h2 id="책임-경계">책임 경계</h2>

<p>이 결정 이후 어댑터 작성자의 책임이 더 분명해졌다.</p>

<ul>
  <li>일반 NATS publish 성공은 내구성 보장이 아니다.</li>
  <li>어댑터가 설비 값을 반드시 잃지 않아야 한다면 로컬 큐나 재시도 전략이 필요하다.</li>
  <li>코어 이후 구간은 JetStream ack를 기준으로 관측하고 복구한다.</li>
  <li>Telegraf는 durable consumer로 검증 스트림을 읽고, downstream write 이후 ack한다.</li>
</ul>

<p>즉, EDG가 모든 구간을 마법처럼 보장하는 것이 아니라, 보장하는 구간과 보장하지 않는 구간을 명확히 나눈다.</p>

<h2 id="검증">검증</h2>

<p>이 변경은 문서만으로 끝내지 않았다. 코어 테스트에는 다음 시나리오를 넣었다.</p>

<ul>
  <li>JetStream consumer가 나중에 붙어도 backlog를 회수하는지</li>
  <li>작은 <code class="language-plaintext highlighter-rouge">MaxBytes</code>에서 <code class="language-plaintext highlighter-rouge">DiscardOld</code> 정책이 의도대로 동작하는지</li>
  <li>같은 자산이 동시에 auto-registration될 때 중복 문제가 없는지</li>
  <li>validated publish 실패 시 dead-letter subject로 envelope가 나가는지</li>
</ul>

<p>이 테스트들은 신뢰성 경계가 말뿐인 문서가 아니라 실제 동작으로 유지되는지 확인하기 위한 회귀 테스트다.</p>

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

<p>이번 결정은 코어 이후 구간의 경계를 정한 것이다. 아직 남은 일도 분명하다.</p>

<ul>
  <li>어댑터에서 코어까지의 end-to-end acknowledgement</li>
  <li>네트워크가 불안정한 설비망에서의 로컬 buffer/flush 전략</li>
  <li>JetStream storage pressure를 운영 화면이나 알림으로 드러내는 방식</li>
  <li>멀티 노드 JetStream replication을 도입할 때의 배포 모델</li>
</ul>

<h2 id="배운-점">배운 점</h2>

<p>산업용 데이터 수집 시스템에서는 기능보다 경계가 더 중요할 때가 있다. “데이터를 받는다”, “저장한다”, “보장한다” 같은 표현은 구현 단계에서 구체적인 subject, ack, stream policy, failure counter로 내려와야 한다.</p>

<p>이번 ADR의 가치는 JetStream을 붙인 것 자체보다, EDG가 책임지는 구간을 운영자와 어댑터 작성자가 같은 언어로 이해하게 만든 데 있다.</p>

<p>관련 자료:</p>

<ul>
  <li>저장소: <a href="https://github.com/e7217/edg">github.com/e7217/edg</a></li>
  <li>ADR: <a href="https://github.com/e7217/edg/blob/main/docs/adr/0001-data-plane-reliability.md">docs/adr/0001-data-plane-reliability.md</a></li>
  <li>구현: <a href="https://github.com/e7217/edg/blob/main/internal/core/handler.go">internal/core/handler.go</a></li>
  <li>설정: <a href="https://github.com/e7217/edg/blob/main/internal/core/config.go">internal/core/config.go</a></li>
</ul>]]></content><author><name>e7217</name></author><category term="smart-factory" /><category term="engineering" /><category term="edg" /><category term="nats" /><category term="jetstream" /><category term="reliability" /><category term="dead-letter" /><category term="victoria-metrics" /><summary type="html"><![CDATA[설비 데이터 수집 파이프라인에서 publish 성공과 저장 보장은 같은 말이 아니다. 어느 지점부터 내구성을 보장할지 정리한 기록.]]></summary></entry><entry><title type="html">개발 기록용 기술 블로그를 시작하며</title><link href="https://e7217.github.io/project-log/starting-development-blog/" rel="alternate" type="text/html" title="개발 기록용 기술 블로그를 시작하며" /><published>2026-05-19T00:00:00+09:00</published><updated>2026-05-19T00:00:00+09:00</updated><id>https://e7217.github.io/project-log/starting-development-blog</id><content type="html" xml:base="https://e7217.github.io/project-log/starting-development-blog/"><![CDATA[<p>개인 프로젝트를 진행하면서 설계, 구현, 문제 해결 과정을 꾸준히 남기기 위해 기술 블로그를 정리한다.</p>

<p>초기 방향은 스마트팩토리와 AI를 중심에 두되, 개발 과정에서 필요한 백엔드, 프론트엔드, 데이터베이스, 배포, 트러블슈팅까지 함께 기록하는 것이다.</p>

<h2 id="운영-방식">운영 방식</h2>

<p>처음부터 카테고리를 많이 나누지 않는다. 큰 분류는 <code class="language-plaintext highlighter-rouge">project-log</code>, <code class="language-plaintext highlighter-rouge">smart-factory</code>, <code class="language-plaintext highlighter-rouge">ai</code>, <code class="language-plaintext highlighter-rouge">engineering</code>, <code class="language-plaintext highlighter-rouge">troubleshooting</code> 정도로 유지하고, 세부 주제는 태그로 관리한다.</p>

<p>글이 충분히 쌓이면 필요한 주제만 시리즈나 하위 분류로 분리한다. 이렇게 하면 글을 쓰기 전에 구조 관리가 먼저 복잡해지는 문제를 줄일 수 있다.</p>

<h2 id="앞으로-남길-기록">앞으로 남길 기록</h2>

<ul>
  <li>개인 프로젝트 설계와 구현 과정</li>
  <li>스마트팩토리, 제조 자동화, 설비 데이터 관련 실험</li>
  <li>AI, LLM, RAG, 자동화 도구 활용 기록</li>
  <li>개발 중 발생한 에러와 해결 과정</li>
  <li>배포와 운영 중 개선한 내용</li>
</ul>

<p>이 블로그는 완성된 결과물보다 실제로 어떤 문제를 마주했고 어떻게 판단했는지를 남기는 공간으로 운영한다.</p>]]></content><author><name>e7217</name></author><category term="project-log" /><category term="blog" /><category term="github-pages" /><category term="jekyll" /><category term="smart-factory" /><category term="ai" /><summary type="html"><![CDATA[개인 프로젝트를 진행하면서 설계, 구현, 문제 해결 과정을 꾸준히 남기기 위해 기술 블로그를 정리한다.]]></summary></entry></feed>