<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Go on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/go/</link>
    <description>Recent content in Go on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Mon, 13 Apr 2026 11:00:00 +0900</lastBuildDate>
    <atom:link href="https://chanyeols.com/tags/go/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>사내 VM IP 관리 시스템 만들기 - Node.js 에이전트 → Go 에이전트 전환 (2편)</title>
      <link>https://chanyeols.com/posts/ipam-02-agent-go/</link>
      <pubDate>Mon, 13 Apr 2026 11:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/ipam-02-agent-go/</guid>
      <description>pkg로 번들한 Node.js 에이전트가 너무 무거워서 Go 단일 바이너리로 전환한 과정을 정리합니다. 크기 비교, 크로스 컴파일, NIC 정보 수집 구현을 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="처음엔-nodejs로-만들었다">처음엔 Node.js로 만들었다</h2>
<p>에이전트를 처음 만들 때 익숙한 Node.js를 썼다. 로직 자체는 단순하다.</p>
<ul>
<li>30초마다 <code>POST /api/heartbeat</code> 호출</li>
<li>호스트명, MAC 주소, IP 주소를 payload에 담아 전송</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> axios <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;axios&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> os <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;os&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> SERVER_URL <span style="color:#ff7b72;font-weight:bold">=</span> process.env.IPAM_SERVER_URL;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> INTERVAL_MS <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">30_000</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">function</span> getNics() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> interfaces <span style="color:#ff7b72;font-weight:bold">=</span> os.networkInterfaces();
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> result <span style="color:#ff7b72;font-weight:bold">=</span> [];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> [name, addrs] <span style="color:#ff7b72">of</span> Object.entries(interfaces)) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> addr <span style="color:#ff7b72">of</span> addrs) {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (addr.family <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;IPv4&#39;</span> <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> <span style="color:#ff7b72;font-weight:bold">!</span>addr.internal) {
</span></span><span style="display:flex;"><span>        result.push({
</span></span><span style="display:flex;"><span>          interfaceName<span style="color:#ff7b72;font-weight:bold">:</span> name,
</span></span><span style="display:flex;"><span>          macAddress<span style="color:#ff7b72;font-weight:bold">:</span> addr.mac,
</span></span><span style="display:flex;"><span>          ipAddress<span style="color:#ff7b72;font-weight:bold">:</span> addr.address,
</span></span><span style="display:flex;"><span>        });
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> result;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> sendHeartbeat() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> axios.post(<span style="color:#a5d6ff">`</span><span style="color:#a5d6ff">${</span>SERVER_URL<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">/api/heartbeat`</span>, {
</span></span><span style="display:flex;"><span>      hostname<span style="color:#ff7b72;font-weight:bold">:</span> os.hostname(),
</span></span><span style="display:flex;"><span>      networkInterfaces<span style="color:#ff7b72;font-weight:bold">:</span> getNics(),
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  } <span style="color:#ff7b72">catch</span> (e) {
</span></span><span style="display:flex;"><span>    console.error(<span style="color:#a5d6ff">&#39;[heartbeat] 실패:&#39;</span>, e.message);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sendHeartbeat();
</span></span><span style="display:flex;"><span>setInterval(sendHeartbeat, INTERVAL_MS);
</span></span></code></pre></div><p>동작은 잘 했다. 문제는 배포였다.</p>
<hr>
<h2 id="nodejs-에이전트의-문제-배포-크기">Node.js 에이전트의 문제: 배포 크기</h2>
<p>VM마다 Node.js 런타임을 설치하거나, <code>pkg</code>로 번들해서 배포해야 한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;scripts&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;build&#34;</span>: <span style="color:#a5d6ff">&#34;pkg src/index.js --target node18-win-x64 --output dist/ipam-agent.exe&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>pkg</code>를 쓰면 Node.js 런타임을 exe 안에 포함시켜서 단일 실행 파일로 만들 수 있다. 근데 크기가 문제였다.</p>
<pre tabindex="0"><code>ipam-agent.exe — 45 MB
</code></pre><p>Node.js 18 런타임 전체가 포함되니 이 크기가 나올 수밖에 없다. 에이전트를 30개 VM에 배포하면 총 1.3 GB를 올려야 한다. 업데이트할 때마다 다시 올려야 하고, 느린 내부 네트워크 환경에서는 부담이 된다.</p>
<hr>
<h2 id="go로-전환">Go로 전환</h2>
<p>Go는 런타임을 따로 설치하지 않아도 된다. 컴파일하면 의존성이 모두 포함된 단일 바이너리가 나온다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">package</span><span style="color:#6e7681"> </span>main<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span><span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;bytes&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;encoding/json&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;fmt&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;net&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;net/http&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;os&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">&#34;time&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span><span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>agentVersion<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;1.0.0&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>intervalSec<span style="color:#6e7681">  </span>=<span style="color:#6e7681"> </span><span style="color:#a5d6ff">30</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">type</span><span style="color:#6e7681"> </span>NicInfo<span style="color:#6e7681"> </span><span style="color:#ff7b72">struct</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>InterfaceName<span style="color:#6e7681"> </span><span style="color:#ff7b72">string</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">`json:&#34;interfaceName&#34;`</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>MacAddress<span style="color:#6e7681">    </span><span style="color:#ff7b72">string</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">`json:&#34;macAddress&#34;`</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>IpAddress<span style="color:#6e7681">     </span><span style="color:#ff7b72">string</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">`json:&#34;ipAddress&#34;`</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">type</span><span style="color:#6e7681"> </span>HeartbeatPayload<span style="color:#6e7681"> </span><span style="color:#ff7b72">struct</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>MacAddress<span style="color:#6e7681">        </span><span style="color:#ff7b72">string</span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">`json:&#34;macAddress&#34;`</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>Hostname<span style="color:#6e7681">          </span><span style="color:#ff7b72">string</span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">`json:&#34;hostname&#34;`</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>AgentVersion<span style="color:#6e7681">      </span><span style="color:#ff7b72">string</span><span style="color:#6e7681">    </span><span style="color:#a5d6ff">`json:&#34;agentVersion&#34;`</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>NetworkInterfaces<span style="color:#6e7681"> </span>[]NicInfo<span style="color:#6e7681"> </span><span style="color:#a5d6ff">`json:&#34;networkInterfaces&#34;`</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="nic-정보-수집">NIC 정보 수집</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">getNics</span>()<span style="color:#6e7681"> </span>[]NicInfo<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>ifaces,<span style="color:#6e7681"> </span>_<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>net.<span style="color:#d2a8ff;font-weight:bold">Interfaces</span>()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">var</span><span style="color:#6e7681"> </span>result<span style="color:#6e7681"> </span>[]NicInfo<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">for</span><span style="color:#6e7681"> </span>_,<span style="color:#6e7681"> </span>iface<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">range</span><span style="color:#6e7681"> </span>ifaces<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#8b949e;font-style:italic">// 루프백 제외</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>iface.Flags<span style="color:#ff7b72;font-weight:bold">&amp;</span>net.FlagLoopback<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">!=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">0</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span><span style="color:#ff7b72">continue</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>addrs,<span style="color:#6e7681"> </span>_<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>iface.<span style="color:#d2a8ff;font-weight:bold">Addrs</span>()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#ff7b72">for</span><span style="color:#6e7681"> </span>_,<span style="color:#6e7681"> </span>addr<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">range</span><span style="color:#6e7681"> </span>addrs<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span><span style="color:#ff7b72">var</span><span style="color:#6e7681"> </span>ip<span style="color:#6e7681"> </span>net.IP<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span><span style="color:#ff7b72">switch</span><span style="color:#6e7681"> </span>v<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>addr.(<span style="color:#ff7b72">type</span>)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span><span style="color:#ff7b72">case</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">*</span>net.IPNet:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>ip<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span>v.IP<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span><span style="color:#ff7b72">case</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">*</span>net.IPAddr:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>ip<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span>v.IP<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span><span style="color:#8b949e;font-style:italic">// IPv4만, 링크로컬 제외</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>ip<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">nil</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">||</span><span style="color:#6e7681"> </span>ip.<span style="color:#d2a8ff;font-weight:bold">IsLinkLocalUnicast</span>()<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">||</span><span style="color:#6e7681"> </span>ip.<span style="color:#d2a8ff;font-weight:bold">To4</span>()<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">nil</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span><span style="color:#ff7b72">continue</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>result<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span>append(result,<span style="color:#6e7681"> </span>NicInfo{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>InterfaceName:<span style="color:#6e7681"> </span>iface.Name,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>MacAddress:<span style="color:#6e7681">    </span>iface.HardwareAddr.<span style="color:#d2a8ff;font-weight:bold">String</span>(),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>IpAddress:<span style="color:#6e7681">     </span>ip.<span style="color:#d2a8ff;font-weight:bold">String</span>(),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>})<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>result<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="heartbeat-전송">heartbeat 전송</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#ff7b72">func</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">sendHeartbeat</span>(serverURL<span style="color:#6e7681"> </span><span style="color:#ff7b72">string</span>)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>nics<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">getNics</span>()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>len(nics)<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">0</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>fmt.<span style="color:#d2a8ff;font-weight:bold">Println</span>(<span style="color:#a5d6ff">&#34;[heartbeat] NIC 없음, 스킵&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#ff7b72">return</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>payload<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>HeartbeatPayload{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>MacAddress:<span style="color:#6e7681">        </span>nics[<span style="color:#a5d6ff">0</span>].MacAddress,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>Hostname:<span style="color:#6e7681">          </span><span style="color:#d2a8ff;font-weight:bold">getHostname</span>(),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>AgentVersion:<span style="color:#6e7681">      </span>agentVersion,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>NetworkInterfaces:<span style="color:#6e7681"> </span>nics,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>body,<span style="color:#6e7681"> </span>_<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>json.<span style="color:#d2a8ff;font-weight:bold">Marshal</span>(payload)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>resp,<span style="color:#6e7681"> </span>err<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>http.<span style="color:#d2a8ff;font-weight:bold">Post</span>(serverURL<span style="color:#ff7b72;font-weight:bold">+</span><span style="color:#a5d6ff">&#34;/api/heartbeat&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;application/json&#34;</span>,<span style="color:#6e7681"> </span>bytes.<span style="color:#d2a8ff;font-weight:bold">NewReader</span>(body))<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>err<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">!=</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">nil</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>fmt.<span style="color:#d2a8ff;font-weight:bold">Printf</span>(<span style="color:#a5d6ff">&#34;[heartbeat] 실패: %v\n&#34;</span>,<span style="color:#6e7681"> </span>err)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#ff7b72">return</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">defer</span><span style="color:#6e7681"> </span>resp.Body.<span style="color:#d2a8ff;font-weight:bold">Close</span>()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>fmt.<span style="color:#d2a8ff;font-weight:bold">Printf</span>(<span style="color:#a5d6ff">&#34;[heartbeat] %s → %d\n&#34;</span>,<span style="color:#6e7681"> </span>time.<span style="color:#d2a8ff;font-weight:bold">Now</span>().<span style="color:#d2a8ff;font-weight:bold">Format</span>(<span style="color:#a5d6ff">&#34;15:04:05&#34;</span>),<span style="color:#6e7681"> </span>resp.StatusCode)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">getHostname</span>()<span style="color:#6e7681"> </span><span style="color:#ff7b72">string</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>h,<span style="color:#6e7681"> </span>err<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>os.<span style="color:#d2a8ff;font-weight:bold">Hostname</span>()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>err<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">!=</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">nil</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;unknown&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>h<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">func</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">main</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>serverURL<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>os.<span style="color:#d2a8ff;font-weight:bold">Getenv</span>(<span style="color:#a5d6ff">&#34;IPAM_SERVER_URL&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>serverURL<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;&#34;</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>fmt.<span style="color:#d2a8ff;font-weight:bold">Println</span>(<span style="color:#a5d6ff">&#34;[에이전트] IPAM_SERVER_URL 환경변수가 설정되지 않았습니다.&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>os.<span style="color:#d2a8ff;font-weight:bold">Exit</span>(<span style="color:#a5d6ff">1</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>fmt.<span style="color:#d2a8ff;font-weight:bold">Printf</span>(<span style="color:#a5d6ff">&#34;[에이전트] 시작 — 서버: %s\n&#34;</span>,<span style="color:#6e7681"> </span>serverURL)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">sendHeartbeat</span>(serverURL)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>ticker<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>time.<span style="color:#d2a8ff;font-weight:bold">NewTicker</span>(intervalSec<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">*</span><span style="color:#6e7681"> </span>time.Second)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">for</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">range</span><span style="color:#6e7681"> </span>ticker.C<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#d2a8ff;font-weight:bold">sendHeartbeat</span>(serverURL)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><hr>
<h2 id="빌드">빌드</h2>
<h3 id="windows-대상">Windows 대상</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go build -ldflags<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;-s -w&#34;</span> -o ipam-agent.exe .
</span></span></code></pre></div><ul>
<li><code>-s</code> — 심볼 테이블 제거</li>
<li><code>-w</code> — DWARF 디버그 정보 제거</li>
<li>결과 크기: <strong>약 6 MB</strong></li>
</ul>
<p>Node.js 버전(45 MB) 대비 <strong>87% 감소</strong>다.</p>
<h3 id="크로스-컴파일">크로스 컴파일</h3>
<p>Go는 크로스 컴파일이 빌드 플래그 하나다. macOS나 Linux에서 Windows용 바이너리를 만들 수 있다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># Linux에서 Windows 64비트 빌드</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">GOOS</span><span style="color:#ff7b72;font-weight:bold">=</span>windows <span style="color:#79c0ff">GOARCH</span><span style="color:#ff7b72;font-weight:bold">=</span>amd64 go build -ldflags<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;-s -w&#34;</span> -o ipam-agent.exe .
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># Linux용 빌드</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">GOOS</span><span style="color:#ff7b72;font-weight:bold">=</span>linux <span style="color:#79c0ff">GOARCH</span><span style="color:#ff7b72;font-weight:bold">=</span>amd64 go build -ldflags<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;-s -w&#34;</span> -o ipam-agent .
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># ARM (Raspberry Pi 등)</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">GOOS</span><span style="color:#ff7b72;font-weight:bold">=</span>linux <span style="color:#79c0ff">GOARCH</span><span style="color:#ff7b72;font-weight:bold">=</span>arm64 go build -ldflags<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;-s -w&#34;</span> -o ipam-agent-arm64 .
</span></span></code></pre></div><p>Node.js <code>pkg</code>는 타겟별로 다른 빌드 프로세스가 필요했는데, Go는 환경변수 두 개로 끝난다.</p>
<hr>
<h2 id="에이전트-배포-방법">에이전트 배포 방법</h2>
<h3 id="windows-vm">Windows VM</h3>
<ol>
<li><code>ipam-agent.exe</code>를 VM에 복사</li>
<li>환경변수 설정</li>
<li>윈도우 서비스로 등록 (NSSM 사용)</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-batch" data-lang="batch"><span style="display:flex;"><span>nssm install IpamAgent <span style="color:#a5d6ff">&#34;C:\ipam-agent.exe&#34;</span>
</span></span><span style="display:flex;"><span>nssm set IpamAgent AppEnvironmentExtra <span style="color:#a5d6ff">&#34;IPAM_SERVER_URL=http://ipam-server:8080&#34;</span>
</span></span><span style="display:flex;"><span>nssm start IpamAgent
</span></span></code></pre></div><h3 id="linux-vm-systemd">Linux VM (systemd)</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># /etc/systemd/system/ipam-agent.service</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">[Unit]</span>
</span></span><span style="display:flex;"><span>Description<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">IPAM Agent</span>
</span></span><span style="display:flex;"><span>After<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">network.target</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">[Service]</span>
</span></span><span style="display:flex;"><span>ExecStart<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">/usr/local/bin/ipam-agent</span>
</span></span><span style="display:flex;"><span>Environment<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">IPAM_SERVER_URL=http://ipam-server:8080</span>
</span></span><span style="display:flex;"><span>Restart<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">always</span>
</span></span><span style="display:flex;"><span>RestartSec<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">5</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">[Install]</span>
</span></span><span style="display:flex;"><span>WantedBy<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">multi-user.target</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl enable --now ipam-agent
</span></span></code></pre></div><h3 id="systemd-재시작-정책">systemd 재시작 정책</h3>
<p><code>Restart=always</code>를 쓰면 에이전트가 예외로 종료돼도 자동 재시작된다. <code>RestartSec=5</code>는 재시작 전 대기 시간이다. 네트워크가 잠깐 끊겼다가 복구될 때 바로 heartbeat를 재개한다.</p>
<hr>
<h2 id="gomod">go.mod</h2>
<p>외부 의존성이 없다. 표준 라이브러리만 쓴다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span>module<span style="color:#6e7681"> </span>ipam<span style="color:#ff7b72;font-weight:bold">-</span>agent<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">go</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">1.22</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>net/http</code>, <code>encoding/json</code>, <code>net</code>, <code>os</code>, <code>time</code> — 전부 표준 라이브러리다. 의존성 관리가 필요 없어서 배포가 간단하다.</p>
<hr>
<h2 id="nodejs-vs-go-비교">Node.js vs Go 비교</h2>
<table>
  <thead>
      <tr>
          <th>항목</th>
          <th>Node.js + pkg</th>
          <th>Go</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>바이너리 크기</td>
          <td>~45 MB</td>
          <td>~6 MB</td>
      </tr>
      <tr>
          <td>런타임 필요</td>
          <td>없음 (번들)</td>
          <td>없음</td>
      </tr>
      <tr>
          <td>크로스 컴파일</td>
          <td>타겟별 다른 빌드</td>
          <td>환경변수 2개</td>
      </tr>
      <tr>
          <td>외부 의존성</td>
          <td>axios</td>
          <td>없음</td>
      </tr>
      <tr>
          <td>빌드 시간</td>
          <td>1~2분 (pkg 번들링)</td>
          <td>수초</td>
      </tr>
      <tr>
          <td>코드 복잡도</td>
          <td>낮음</td>
          <td>낮음</td>
      </tr>
  </tbody>
</table>
<p>에이전트처럼 단순한 로직을 반복 실행하는 프로그램에는 Go가 훨씬 적합하다.</p>
<hr>
<p>다음 편에서는 Spring Boot 백엔드에서 heartbeat를 처리하고, 이상을 감지해서 Slack으로 알림을 보내는 로직을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 VM IP 관리 시스템 만들기 - 기획 배경 &#43; 전체 아키텍처 &#43; DB 스키마 (1편)</title>
      <link>https://chanyeols.com/posts/ipam-01-architecture-schema/</link>
      <pubDate>Sun, 12 Apr 2026 10:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/ipam-01-architecture-schema/</guid>
      <description>VM IP를 수동으로 관리하다 생기는 충돌과 변동 추적 문제를 해결하기 위해 에이전트 기반 IPAM 시스템을 만든 과정을 정리합니다. 전체 아키텍처와 PostgreSQL DB 스키마 설계를 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-만들게-됐나">왜 만들게 됐나</h2>
<p>VM이 늘어나면서 IP 관리가 슬슬 문제가 됐다.</p>
<ul>
<li>스프레드시트에 IP 할당 현황을 수동으로 관리하는데, 담당자마다 업데이트 타이밍이 달라서 실제 상태와 항상 일치하지 않음</li>
<li>VM을 삭제하면서 IP 해제를 빠뜨리면, 나중에 같은 IP를 다른 VM에 할당해서 충돌이 발생</li>
<li>VM이 죽어도 스프레드시트에 살아있는 것처럼 남아있어서 &ldquo;이 서버 살아있어요?&rdquo; 같은 질문이 자주 옴</li>
<li>특정 VM의 IP 변동 이력을 알고 싶어도 추적 방법이 없음</li>
</ul>
<p>그래서 만들었다.</p>
<ul>
<li>VM에 경량 에이전트를 설치해서 주기적으로 heartbeat를 전송</li>
<li>서버에서 heartbeat를 분석해서 IP 충돌, IP 변경, 오프라인 등 이상 감지</li>
<li>이상 발생 시 Slack 알림</li>
<li>웹 UI에서 전체 VM 현황과 이벤트 로그 조회</li>
</ul>
<hr>
<h2 id="기술-스택">기술 스택</h2>
<table>
  <thead>
      <tr>
          <th>영역</th>
          <th>기술</th>
          <th>선택 이유</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>에이전트</td>
          <td>Go 1.22</td>
          <td>단일 바이너리, 런타임 불필요, 크기 소형</td>
      </tr>
      <tr>
          <td>백엔드</td>
          <td>Spring Boot 3 + JPA</td>
          <td>팀 표준 스택</td>
      </tr>
      <tr>
          <td>DB</td>
          <td>PostgreSQL 16</td>
          <td>이력 테이블 + 집계 쿼리</td>
      </tr>
      <tr>
          <td>캐시/dedup</td>
          <td>Redis 7</td>
          <td>Slack 알림 중복 방지</td>
      </tr>
      <tr>
          <td>프론트엔드</td>
          <td>React 18 + Vite</td>
          <td>30초 폴링 대시보드</td>
      </tr>
      <tr>
          <td>인프라</td>
          <td>Docker Compose</td>
          <td>4개 서비스 일괄 관리</td>
      </tr>
  </tbody>
</table>
<p>에이전트는 처음에 Node.js로 만들었다가 Go로 전환했다. 이유는 2편에서 다룬다.</p>
<hr>
<h2 id="전체-아키텍처">전체 아키텍처</h2>
<pre tabindex="0"><code>[VM 에이전트 (Go 바이너리)]
        │
        │ POST /api/heartbeat (30초마다)
        ▼
[Spring Boot 백엔드]
        │
        ├─ Heartbeat 처리: VM 등록/갱신
        ├─ 이상 감지: IP 충돌, IP 변경, 범위 이탈
        ├─ 오프라인 스케줄러: 10분 이상 heartbeat 없으면 OFFLINE
        ├─ Slack 알림 (Redis dedup)
        └─ REST API
        │
        ├─▶ [PostgreSQL]  — VM 상태, 이력, 이벤트 로그
        └─▶ [Redis]       — Slack 알림 dedup (30분 TTL)
                │
                ▼
        [React 대시보드]   ← 30초 폴링
</code></pre><p>서비스 구성:</p>
<table>
  <thead>
      <tr>
          <th>서비스</th>
          <th>포트</th>
          <th>역할</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>app</td>
          <td>8080</td>
          <td>Spring Boot API</td>
      </tr>
      <tr>
          <td>postgres</td>
          <td>5432</td>
          <td>PostgreSQL 16</td>
      </tr>
      <tr>
          <td>redis</td>
          <td>6379</td>
          <td>Redis 7</td>
      </tr>
      <tr>
          <td>frontend</td>
          <td>3000</td>
          <td>React (nginx)</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="vm-식별-기준-mac-주소">VM 식별 기준: MAC 주소</h2>
<p>IP 주소를 VM의 식별자로 쓰면 안 된다. DHCP 환경에서는 IP가 바뀔 수 있고, IP 변경 자체가 감지해야 할 이벤트이기 때문이다.</p>
<p>MAC 주소를 기본 키로 사용한다.</p>
<pre tabindex="0"><code>VM 최초 heartbeat → MAC 주소로 조회
├─ DB에 없음 → 신규 VM 등록
└─ DB에 있음 → 상태 갱신 (hostname, IP, lastSeenAt)
</code></pre><p>IP가 바뀌면 <code>ip_history</code>에 이전 IP를 기록하고 <code>event_log</code>에 <code>IP_CHANGED</code> 이벤트를 남긴다.</p>
<hr>
<h2 id="db-스키마">DB 스키마</h2>
<h3 id="team">team</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>team<span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>id<span style="color:#6e7681">          </span>SERIAL<span style="color:#6e7681"> </span><span style="color:#ff7b72">PRIMARY</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">KEY</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>name<span style="color:#6e7681">        </span>VARCHAR(<span style="color:#a5d6ff">100</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">NOT</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">NULL</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>description<span style="color:#6e7681"> </span>TEXT,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>created_at<span style="color:#6e7681">  </span>TIMESTAMPTZ<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>NOW()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>);<span style="color:#6e7681">
</span></span></span></code></pre></div><p>VM을 팀 단위로 그룹핑한다. 이상 감지 알림을 팀 채널로 보낼 때 쓴다.</p>
<h3 id="vm">vm</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>vm<span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>id<span style="color:#6e7681">              </span>SERIAL<span style="color:#6e7681"> </span><span style="color:#ff7b72">PRIMARY</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">KEY</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>mac_address<span style="color:#6e7681">     </span>VARCHAR(<span style="color:#a5d6ff">17</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">UNIQUE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">NOT</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">NULL</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>hostname<span style="color:#6e7681">        </span>VARCHAR(<span style="color:#a5d6ff">255</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>current_ip<span style="color:#6e7681">      </span>VARCHAR(<span style="color:#a5d6ff">15</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>team_id<span style="color:#6e7681">         </span>INTEGER<span style="color:#6e7681"> </span><span style="color:#ff7b72">REFERENCES</span><span style="color:#6e7681"> </span>team(id),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>status<span style="color:#6e7681">          </span>VARCHAR(<span style="color:#a5d6ff">20</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;UNKNOWN&#39;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>agent_version<span style="color:#6e7681">   </span>VARCHAR(<span style="color:#a5d6ff">50</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>last_seen_at<span style="color:#6e7681">    </span>TIMESTAMPTZ,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>created_at<span style="color:#6e7681">      </span>TIMESTAMPTZ<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>NOW(),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>updated_at<span style="color:#6e7681">      </span>TIMESTAMPTZ<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>NOW()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>);<span style="color:#6e7681">
</span></span></span></code></pre></div><ul>
<li><code>mac_address</code> — 고유 식별자</li>
<li><code>status</code> — <code>ONLINE</code> / <code>OFFLINE</code> / <code>UNKNOWN</code></li>
<li><code>last_seen_at</code> — 마지막 heartbeat 수신 시각. 오프라인 감지에 쓴다.</li>
</ul>
<h3 id="network_info">network_info</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>network_info<span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>id<span style="color:#6e7681">              </span>SERIAL<span style="color:#6e7681"> </span><span style="color:#ff7b72">PRIMARY</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">KEY</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>vm_id<span style="color:#6e7681">           </span>INTEGER<span style="color:#6e7681"> </span><span style="color:#ff7b72">REFERENCES</span><span style="color:#6e7681"> </span>vm(id),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>interface_name<span style="color:#6e7681">  </span>VARCHAR(<span style="color:#a5d6ff">50</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>mac_address<span style="color:#6e7681">     </span>VARCHAR(<span style="color:#a5d6ff">17</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>ip_address<span style="color:#6e7681">      </span>VARCHAR(<span style="color:#a5d6ff">15</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>updated_at<span style="color:#6e7681">      </span>TIMESTAMPTZ<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>NOW()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>);<span style="color:#6e7681">
</span></span></span></code></pre></div><p>VM의 NIC별 정보를 저장한다. NIC가 여러 개인 VM도 있기 때문에 별도 테이블로 분리했다.</p>
<h3 id="ip_history">ip_history</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>ip_history<span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>id<span style="color:#6e7681">          </span>SERIAL<span style="color:#6e7681"> </span><span style="color:#ff7b72">PRIMARY</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">KEY</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>vm_id<span style="color:#6e7681">       </span>INTEGER<span style="color:#6e7681"> </span><span style="color:#ff7b72">REFERENCES</span><span style="color:#6e7681"> </span>vm(id),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>old_ip<span style="color:#6e7681">      </span>VARCHAR(<span style="color:#a5d6ff">15</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>new_ip<span style="color:#6e7681">      </span>VARCHAR(<span style="color:#a5d6ff">15</span>),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>changed_at<span style="color:#6e7681">  </span>TIMESTAMPTZ<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>NOW()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>);<span style="color:#6e7681">
</span></span></span></code></pre></div><p>IP가 바뀔 때마다 이전 IP를 기록한다. 특정 VM의 IP 변동 이력을 추적할 수 있다.</p>
<h3 id="event_log">event_log</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>event_log<span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>id<span style="color:#6e7681">           </span>SERIAL<span style="color:#6e7681"> </span><span style="color:#ff7b72">PRIMARY</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">KEY</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>vm_id<span style="color:#6e7681">        </span>INTEGER<span style="color:#6e7681"> </span><span style="color:#ff7b72">REFERENCES</span><span style="color:#6e7681"> </span>vm(id),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>event_type<span style="color:#6e7681">   </span>VARCHAR(<span style="color:#a5d6ff">50</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">NOT</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">NULL</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>severity<span style="color:#6e7681">     </span>VARCHAR(<span style="color:#a5d6ff">20</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">NOT</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">NULL</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>message<span style="color:#6e7681">      </span>TEXT,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>detail<span style="color:#6e7681">       </span>JSONB,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>created_at<span style="color:#6e7681">   </span>TIMESTAMPTZ<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>NOW()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>);<span style="color:#6e7681">
</span></span></span></code></pre></div><p>이상 감지 이벤트를 모두 기록한다.</p>
<table>
  <thead>
      <tr>
          <th>event_type</th>
          <th>severity</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>IP_CONFLICT</code></td>
          <td>CRITICAL</td>
          <td>같은 IP를 쓰는 VM이 2개 이상</td>
      </tr>
      <tr>
          <td><code>IP_CHANGED</code></td>
          <td>WARNING</td>
          <td>VM의 IP가 바뀜</td>
      </tr>
      <tr>
          <td><code>IP_OUT_OF_RANGE</code></td>
          <td>WARNING</td>
          <td>허용 대역 외 IP 사용</td>
      </tr>
      <tr>
          <td><code>VM_OFFLINE</code></td>
          <td>WARNING</td>
          <td>10분 이상 heartbeat 없음</td>
      </tr>
      <tr>
          <td><code>VM_ONLINE</code></td>
          <td>INFO</td>
          <td>OFFLINE이었던 VM이 복귀</td>
      </tr>
  </tbody>
</table>
<p><code>detail</code> 컬럼(JSONB)에는 충돌 IP, 변경 전/후 IP 같은 추가 정보를 저장한다.</p>
<h3 id="ip_pool">ip_pool</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff7b72">CREATE</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">TABLE</span><span style="color:#6e7681"> </span>ip_pool<span style="color:#6e7681"> </span>(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>id<span style="color:#6e7681">          </span>SERIAL<span style="color:#6e7681"> </span><span style="color:#ff7b72">PRIMARY</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">KEY</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>cidr<span style="color:#6e7681">        </span>VARCHAR(<span style="color:#a5d6ff">18</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">NOT</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">NULL</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>description<span style="color:#6e7681"> </span>TEXT,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>team_id<span style="color:#6e7681">     </span>INTEGER<span style="color:#6e7681"> </span><span style="color:#ff7b72">REFERENCES</span><span style="color:#6e7681"> </span>team(id),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>created_at<span style="color:#6e7681">  </span>TIMESTAMPTZ<span style="color:#6e7681"> </span><span style="color:#ff7b72">DEFAULT</span><span style="color:#6e7681"> </span>NOW()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>);<span style="color:#6e7681">
</span></span></span></code></pre></div><p>팀별 허용 IP 대역을 관리한다. heartbeat로 들어온 IP가 이 대역에 속하지 않으면 <code>IP_OUT_OF_RANGE</code> 이벤트가 발생한다.</p>
<hr>
<h2 id="rest-api-목록">REST API 목록</h2>
<table>
  <thead>
      <tr>
          <th>Method</th>
          <th>Path</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>POST</td>
          <td><code>/api/heartbeat</code></td>
          <td>에이전트 → 서버 heartbeat 수신</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/vms</code></td>
          <td>VM 목록 (상태, 팀 필터)</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/vms/:id</code></td>
          <td>VM 단건 조회 + IP 이력</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/events</code></td>
          <td>이벤트 로그 (타입, 날짜 필터)</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/teams</code></td>
          <td>팀 목록</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/teams</code></td>
          <td>팀 추가</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/ip-pool</code></td>
          <td>IP 대역 목록</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/ip-pool</code></td>
          <td>IP 대역 추가</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/actuator/health</code></td>
          <td>헬스체크</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="프로젝트-구조">프로젝트 구조</h2>
<pre tabindex="0"><code>ipam-system/
├── docker-compose.yml
├── agent-go/              # Go 에이전트
│   ├── main.go
│   └── go.mod
├── agent/                 # Node.js 에이전트 (구버전)
│   └── src/index.js
├── backend/               # Spring Boot
│   └── src/main/java/com/ipam/
│       ├── controller/
│       │   └── HeartbeatController.java
│       ├── service/
│       │   ├── HeartbeatService.java
│       │   ├── AnomalyDetectorService.java
│       │   ├── OfflineSchedulerService.java
│       │   └── SlackNotifierService.java
│       └── entity/
│           ├── Vm.java
│           ├── EventLog.java
│           └── IpHistory.java
├── frontend/              # React
│   └── src/
│       ├── App.jsx
│       └── components/
│           ├── VmTable.jsx
│           └── EventLogPanel.jsx
└── sql/
    └── init.sql
</code></pre><hr>
<p>다음 편에서는 Node.js 에이전트가 왜 문제가 됐는지, Go로 전환하면서 어떻게 달라졌는지를 다룬다.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
