<?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>IPAM VM 관리 시스템 on Chanyeol Dev</title>
    <link>https://chanyeols.com/categories/ipam-vm-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C/</link>
    <description>Recent content in IPAM VM 관리 시스템 on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Wed, 15 Apr 2026 15:00:00 +0900</lastBuildDate>
    <atom:link href="https://chanyeols.com/categories/ipam-vm-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>사내 VM IP 관리 시스템 만들기 - React 대시보드 &#43; Docker Compose 배포 (4편)</title>
      <link>https://chanyeols.com/posts/ipam-04-frontend-deploy/</link>
      <pubDate>Wed, 15 Apr 2026 15:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/ipam-04-frontend-deploy/</guid>
      <description>30초 폴링으로 VM 현황을 실시간 갱신하는 React 대시보드 구현과 Spring Boot &#43; PostgreSQL &#43; Redis &#43; React 4개 서비스를 Docker Compose로 배포하는 방법을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="react-대시보드">React 대시보드</h2>
<p>대시보드는 탭 2개로 구성된다.</p>
<ul>
<li><strong>VM 목록</strong> — 전체 VM 현황, 상태별 필터, ONLINE/OFFLINE/UNKNOWN 배지</li>
<li><strong>이벤트 로그</strong> — IP 충돌, IP 변경, 오프라인 이벤트 타임라인</li>
</ul>
<p>WebSocket이 아닌 30초 폴링으로 구현했다. VM 상태가 초 단위로 바뀌지 않고, 운영 대시보드 특성상 약간의 지연은 허용된다. 심플하게 가는 게 낫다고 판단했다.</p>
<hr>
<h2 id="30초-폴링-훅">30초 폴링 훅</h2>
<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:#8b949e;font-style:italic">// usePolling.js
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { useEffect, useRef } from <span style="color:#a5d6ff">&#39;react&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> usePolling(callback, intervalMs <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">30_000</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> callbackRef <span style="color:#ff7b72;font-weight:bold">=</span> useRef(callback);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>    callbackRef.current <span style="color:#ff7b72;font-weight:bold">=</span> callback;
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>    callbackRef.current(); <span style="color:#8b949e;font-style:italic">// 마운트 시 즉시 1회 실행
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> id <span style="color:#ff7b72;font-weight:bold">=</span> setInterval(() =&gt; callbackRef.current(), intervalMs);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> () =&gt; clearInterval(id);
</span></span><span style="display:flex;"><span>  }, [intervalMs]);
</span></span><span style="display:flex;"><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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// App.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [vms, setVms] <span style="color:#ff7b72;font-weight:bold">=</span> useState([]);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [loading, setLoading] <span style="color:#ff7b72;font-weight:bold">=</span> useState(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>usePolling(<span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> data <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetch(<span style="color:#a5d6ff">&#39;/api/vms&#39;</span>).then(r =&gt; r.json());
</span></span><span style="display:flex;"><span>  setVms(data);
</span></span><span style="display:flex;"><span>  setLoading(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span>}, <span style="color:#a5d6ff">30_000</span>);
</span></span></code></pre></div><p><code>callbackRef</code>를 쓰는 이유는 <code>setInterval</code> 클로저가 최초 등록 시점의 <code>callback</code>을 계속 참조하는 문제를 피하기 위해서다. <code>useRef</code>로 항상 최신 콜백을 가리키도록 한다.</p>
<hr>
<h2 id="vm-목록-테이블">VM 목록 테이블</h2>
<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-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// VmTable.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> STATUS_BADGE <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  ONLINE<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;ONLINE&#39;</span>,  className<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;badge-green&#39;</span>  },
</span></span><span style="display:flex;"><span>  OFFLINE<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;OFFLINE&#39;</span>, className<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;badge-red&#39;</span>    },
</span></span><span style="display:flex;"><span>  UNKNOWN<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;UNKNOWN&#39;</span>, className<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;badge-gray&#39;</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">export</span> <span style="color:#ff7b72">function</span> VmTable({ vms, filter }) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> filtered <span style="color:#ff7b72;font-weight:bold">=</span> useMemo(() =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> vms.filter(vm =&gt; {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (filter.status <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> vm.status <span style="color:#ff7b72;font-weight:bold">!==</span> filter.status) <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (filter.team <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> vm.teamId <span style="color:#ff7b72;font-weight:bold">!==</span> filter.team) <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (filter.search) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">const</span> q <span style="color:#ff7b72;font-weight:bold">=</span> filter.search.toLowerCase();
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>          vm.hostname<span style="color:#ff7b72;font-weight:bold">?</span>.toLowerCase().includes(q) <span style="color:#ff7b72;font-weight:bold">||</span>
</span></span><span style="display:flex;"><span>          vm.currentIp<span style="color:#ff7b72;font-weight:bold">?</span>.includes(q)
</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> <span style="color:#79c0ff">true</span>;
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  }, [vms, filter]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#7ee787">table</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;vm-table&#34;</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">thead</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;<span style="color:#7ee787">tr</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;호스트명&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;IP&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;팀&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;상태&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;마지막 heartbeat&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">th</span>&gt;에이전트 버전&lt;/<span style="color:#7ee787">th</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;/<span style="color:#7ee787">tr</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;/<span style="color:#7ee787">thead</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">tbody</span>&gt;
</span></span><span style="display:flex;"><span>        {filtered.map(vm =&gt; {
</span></span><span style="display:flex;"><span>          <span style="color:#ff7b72">const</span> badge <span style="color:#ff7b72;font-weight:bold">=</span> STATUS_BADGE[vm.status] <span style="color:#ff7b72;font-weight:bold">??</span> STATUS_BADGE.UNKNOWN;
</span></span><span style="display:flex;"><span>          <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>            &lt;<span style="color:#7ee787">tr</span> key<span style="color:#ff7b72;font-weight:bold">=</span>{vm.id}&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{vm.hostname}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;&lt;<span style="color:#7ee787">code</span>&gt;{vm.currentIp}&lt;/<span style="color:#7ee787">code</span>&gt;&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{vm.teamName <span style="color:#ff7b72;font-weight:bold">??</span> <span style="color:#a5d6ff">&#39;-&#39;</span>}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;&lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#a5d6ff">`badge </span><span style="color:#a5d6ff">${</span>badge.className<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>}&gt;{badge.label}&lt;/<span style="color:#7ee787">span</span>&gt;&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{formatRelative(vm.lastSeenAt)}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>              &lt;<span style="color:#7ee787">td</span>&gt;{vm.agentVersion}&lt;/<span style="color:#7ee787">td</span>&gt;
</span></span><span style="display:flex;"><span>            &lt;/<span style="color:#7ee787">tr</span>&gt;
</span></span><span style="display:flex;"><span>          );
</span></span><span style="display:flex;"><span>        })}
</span></span><span style="display:flex;"><span>      &lt;/<span style="color:#7ee787">tbody</span>&gt;
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#7ee787">table</span>&gt;
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="이벤트-로그-패널">이벤트 로그 패널</h2>
<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-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// EventLogPanel.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> SEVERITY_CLASS <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  CRITICAL<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;event-critical&#39;</span>,
</span></span><span style="display:flex;"><span>  WARNING<span style="color:#ff7b72;font-weight:bold">:</span>  <span style="color:#a5d6ff">&#39;event-warning&#39;</span>,
</span></span><span style="display:flex;"><span>  INFO<span style="color:#ff7b72;font-weight:bold">:</span>     <span style="color:#a5d6ff">&#39;event-info&#39;</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">const</span> EVENT_ICON <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  IP_CONFLICT<span style="color:#ff7b72;font-weight:bold">:</span>    <span style="color:#a5d6ff">&#39;🔴&#39;</span>,
</span></span><span style="display:flex;"><span>  IP_CHANGED<span style="color:#ff7b72;font-weight:bold">:</span>     <span style="color:#a5d6ff">&#39;🔄&#39;</span>,
</span></span><span style="display:flex;"><span>  IP_OUT_OF_RANGE<span style="color:#ff7b72;font-weight:bold">:</span><span style="color:#a5d6ff">&#39;⚠️&#39;</span>,
</span></span><span style="display:flex;"><span>  VM_OFFLINE<span style="color:#ff7b72;font-weight:bold">:</span>     <span style="color:#a5d6ff">&#39;🔴&#39;</span>,
</span></span><span style="display:flex;"><span>  VM_ONLINE<span style="color:#ff7b72;font-weight:bold">:</span>      <span style="color:#a5d6ff">&#39;🟢&#39;</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">export</span> <span style="color:#ff7b72">function</span> EventLogPanel({ events }) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#7ee787">div</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-log&#34;</span>&gt;
</span></span><span style="display:flex;"><span>      {events.map(evt =&gt; (
</span></span><span style="display:flex;"><span>        &lt;<span style="color:#7ee787">div</span> key<span style="color:#ff7b72;font-weight:bold">=</span>{evt.id} className<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#a5d6ff">`event-item </span><span style="color:#a5d6ff">${</span>SEVERITY_CLASS[evt.severity]<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>}&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-icon&#34;</span>&gt;{EVENT_ICON[evt.eventType]}&lt;/<span style="color:#7ee787">span</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-time&#34;</span>&gt;{formatDate(evt.createdAt)}&lt;/<span style="color:#7ee787">span</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">span</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;event-msg&#34;</span>&gt;{evt.message}&lt;/<span style="color:#7ee787">span</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;/<span style="color:#7ee787">div</span>&gt;
</span></span><span style="display:flex;"><span>      ))}
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#7ee787">div</span>&gt;
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="다크라이트-테마">다크/라이트 테마</h2>
<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-css" data-lang="css"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">/* App.css */</span>
</span></span><span style="display:flex;"><span>:<span style="color:#d2a8ff;font-weight:bold">root</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--bg</span>: <span style="color:#a5d6ff">#ffffff</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--surface</span>: <span style="color:#a5d6ff">#f8f9fa</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--text</span>: <span style="color:#a5d6ff">#1a1a1a</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--border</span>: <span style="color:#a5d6ff">#e0e0e0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--badge-green</span>: <span style="color:#a5d6ff">#22c55e</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--badge-red</span>: <span style="color:#a5d6ff">#ef4444</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--badge-gray</span>: <span style="color:#a5d6ff">#9ca3af</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;font-weight:bold">[</span><span style="color:#7ee787">data-theme</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;dark&#34;</span><span style="color:#ff7b72;font-weight:bold">]</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--bg</span>: <span style="color:#a5d6ff">#0f172a</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--surface</span>: <span style="color:#a5d6ff">#1e293b</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--text</span>: <span style="color:#a5d6ff">#e2e8f0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--border</span>: <span style="color:#a5d6ff">#334155</span>;
</span></span><span style="display:flex;"><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-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// App.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [theme, setTheme] <span style="color:#ff7b72;font-weight:bold">=</span> useState(
</span></span><span style="display:flex;"><span>  () =&gt; localStorage.getItem(<span style="color:#a5d6ff">&#39;ipam-theme&#39;</span>) <span style="color:#ff7b72;font-weight:bold">||</span> <span style="color:#a5d6ff">&#39;light&#39;</span>
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>  document.documentElement.setAttribute(<span style="color:#a5d6ff">&#39;data-theme&#39;</span>, theme);
</span></span><span style="display:flex;"><span>  localStorage.setItem(<span style="color:#a5d6ff">&#39;ipam-theme&#39;</span>, theme);
</span></span><span style="display:flex;"><span>}, [theme]);
</span></span></code></pre></div><p><img alt="IPAM 대시보드 - VM 목록" loading="lazy" src="/images/ipam-04-dashboard.png"></p>
<p><img alt="IPAM 이벤트 로그" loading="lazy" src="/images/ipam-04-events.png"></p>
<hr>
<h2 id="client-nginx-설정">client nginx 설정</h2>
<p>React SPA를 서빙하면서 <code>/api/</code> 요청을 Spring Boot로 프록시한다.</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-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">root</span> <span style="color:#a5d6ff">/usr/share/nginx/html</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">index</span> <span style="color:#a5d6ff">index.html</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/api/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://app:8080</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Host</span> <span style="color:#79c0ff">$host</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Real-IP</span> <span style="color:#79c0ff">$remote_addr</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">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">try_files</span> <span style="color:#79c0ff">$uri</span> <span style="color:#79c0ff">$uri/</span> <span style="color:#a5d6ff">/index.html</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="docker-compose">Docker Compose</h2>
<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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">postgres</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgres:16-alpine</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-postgres</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_DB</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_USER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">POSTGRES_PASSWORD</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam1234</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./data/postgres:/var/lib/postgresql/data</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./sql/init.sql:/docker-entrypoint-initdb.d/init.sql</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;pg_isready -U ipam -d ipam&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</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 style="color:#7ee787">redis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">redis:7-alpine</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-redis</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;redis-cli&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;ping&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">3s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5</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 style="color:#7ee787">app</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">./backend</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-app</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;8080:8080&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_HOST</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">postgres</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_NAME</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_USER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">DB_PASS</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam1234</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">REDIS_HOST</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">redis</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">SLACK_WEBHOOK_URL</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${SLACK_WEBHOOK_URL:-}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">postgres</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">redis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">healthcheck</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;curl -f http://localhost:8080/actuator/health || exit 1&#34;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">timeout</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">5s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">retries</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">12</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</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 style="color:#7ee787">frontend</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">./frontend</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">ipam-frontend</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;3000:80&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">app</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">condition</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">service_healthy</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>init.sql</code>을 <code>/docker-entrypoint-initdb.d/</code>에 마운트하면 PostgreSQL 컨테이너 최초 실행 시 자동으로 실행된다. 스키마를 자동으로 생성해주므로 별도로 <code>CREATE TABLE</code>을 실행하지 않아도 된다.</p>
<hr>
<h2 id="backend-dockerfile">backend Dockerfile</h2>
<p>Spring Boot는 빌드가 필요하다. 멀티스테이지 빌드로 최종 이미지에 Maven/JDK 빌드 도구가 포함되지 않도록 한다.</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-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 빌드 스테이지</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">maven:3.9-eclipse-temurin-21</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> pom.xml .<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> mvn dependency:go-offline -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> src ./src<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> mvn package -DskipTests -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 실행 스테이지</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">eclipse-temurin:21-jre-alpine</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>build /app/target/*.jar app.jar<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">8080</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">ENTRYPOINT</span> [<span style="color:#a5d6ff">&#34;java&#34;</span>, <span style="color:#a5d6ff">&#34;-jar&#34;</span>, <span style="color:#a5d6ff">&#34;app.jar&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><p><code>dependency:go-offline</code>을 먼저 실행해서 Maven 의존성을 레이어로 캐싱한다. 소스 변경 시 의존성 다운로드를 재실행하지 않아서 빌드가 빠르다.</p>
<hr>
<h2 id="frontend-dockerfile">frontend Dockerfile</h2>
<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-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:20-alpine</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> package*.json ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm ci --silent<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> . .<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm run build<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">nginx:alpine</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>build /app/dist /usr/share/nginx/html<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> nginx.conf /etc/nginx/conf.d/default.conf<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">80</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">CMD</span> [<span style="color:#a5d6ff">&#34;nginx&#34;</span>, <span style="color:#a5d6ff">&#34;-g&#34;</span>, <span style="color:#a5d6ff">&#34;daemon off;&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><hr>
<h2 id="배포-명령어">배포 명령어</h2>
<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"># 환경변수 파일 작성</span>
</span></span><span style="display:flex;"><span>cat &gt; .env <span style="color:#a5d6ff">&lt;&lt; EOF
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">EOF</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 최초 배포</span>
</span></span><span style="display:flex;"><span>cd ipam-system
</span></span><span style="display:flex;"><span>docker compose up -d --build
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 로그 확인</span>
</span></span><span style="display:flex;"><span>docker logs ipam-app -f
</span></span><span style="display:flex;"><span>docker logs ipam-frontend -f
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 백엔드만 재배포</span>
</span></span><span style="display:flex;"><span>docker compose up -d --build app
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 프론트엔드만 재배포</span>
</span></span><span style="display:flex;"><span>docker compose up -d --build frontend
</span></span></code></pre></div><hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="spring-boot-healthcheck-curl-없음">Spring Boot healthcheck curl 없음</h3>
<p><code>eclipse-temurin:21-jre-alpine</code>에는 <code>curl</code>이 없어서 <code>CMD curl -f ...</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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 실패</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;curl&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;-f&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;http://localhost:8080/actuator/health&#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:#8b949e;font-style:italic"># 해결: CMD-SHELL로 변경 (sh -c 경유)</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">test</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#34;CMD-SHELL&#34;</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;curl -f http://localhost:8080/actuator/health || exit 1&#34;</span>]<span style="color:#6e7681">
</span></span></span></code></pre></div><p>alpine 이미지에는 sh가 있어서 <code>CMD-SHELL</code>을 쓰면 sh를 통해 명령이 실행된다. curl 설치를 Dockerfile에 추가하거나, wget이 있다면 wget으로 대체하는 방법도 있다.</p>
<h3 id="spring-boot-시작-시간--healthcheck">Spring Boot 시작 시간 + healthcheck</h3>
<p>Spring Boot가 완전히 뜨기까지 20~30초 걸린다. <code>retries: 12</code>, <code>interval: 10s</code>로 설정해서 최대 2분을 대기한다. 이 시간 안에 spring이 안 뜨면 컨테이너가 unhealthy로 전환된다.</p>
<h3 id="initsql-재실행-안-됨">init.sql 재실행 안 됨</h3>
<p>PostgreSQL 컨테이너는 <code>/var/lib/postgresql/data</code>가 비어있을 때만 <code>initdb</code>를 실행한다. 볼륨이 남아있으면 <code>init.sql</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-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 볼륨 삭제 후 재시작 (데이터 초기화 주의)</span>
</span></span><span style="display:flex;"><span>docker compose down -v
</span></span><span style="display:flex;"><span>docker compose up -d --build
</span></span></code></pre></div><hr>
<h2 id="마무리">마무리</h2>
<p>4편에 걸쳐 IPAM VM 관리 시스템을 만든 과정을 정리했다.</p>
<table>
  <thead>
      <tr>
          <th>편</th>
          <th>내용</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1편</td>
          <td>기획 배경 + 전체 아키텍처 + DB 스키마</td>
      </tr>
      <tr>
          <td>2편</td>
          <td>Node.js 에이전트 → Go 에이전트 전환</td>
      </tr>
      <tr>
          <td>3편</td>
          <td>Spring Boot Heartbeat 처리 + 이상 감지 + Slack 알림</td>
      </tr>
      <tr>
          <td>4편</td>
          <td>React 대시보드 + Docker Compose 배포 ← 지금 여기</td>
      </tr>
  </tbody>
</table>
<p>핵심은 두 가지다.</p>
<p><strong>MAC 주소 기반 VM 식별</strong> — IP는 바뀌지만 MAC은 고정이다. IP를 기준으로 삼으면 VM이 IP를 바꿀 때마다 새 VM으로 등록되는 문제가 생긴다.</p>
<p><strong>에이전트는 최대한 가볍게</strong> — Go 단일 바이너리 6 MB로 Node.js 45 MB 대비 크기를 87% 줄였다. 배포하기 쉬울수록 VM이 늘어나도 관리가 편하다.</p>
<p>현재 인사정보 시스템과 연동해서 VM 담당자가 퇴사했을 때 자동으로 알림이 오는 흐름을 구성할 예정이다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 VM IP 관리 시스템 만들기 - Spring Boot Heartbeat 처리 &#43; 이상 감지 &#43; Slack 알림 (3편)</title>
      <link>https://chanyeols.com/posts/ipam-03-backend/</link>
      <pubDate>Tue, 14 Apr 2026 13:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/ipam-03-backend/</guid>
      <description>Spring Boot 백엔드에서 에이전트 heartbeat를 처리하고, IP 충돌/변경/오프라인을 감지해서 Redis dedup으로 Slack 알림을 보내는 구현을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="heartbeat-처리-흐름">heartbeat 처리 흐름</h2>
<p>에이전트가 30초마다 <code>POST /api/heartbeat</code>를 호출한다. 서버는 MAC 주소를 기준으로 VM을 식별하고 상태를 갱신한다.</p>
<pre tabindex="0"><code>heartbeat 수신
      │
      ▼
MAC 주소로 VM 조회
      │
      ├─ DB에 없음 → 신규 등록
      └─ DB에 있음 → 상태 갱신 (hostname, last_seen_at)
                          │
                          ▼
                   이상 감지 실행
                          │
                          ├─ IP 변경 감지
                          ├─ IP 충돌 감지
                          └─ IP 대역 이탈 감지
</code></pre><h3 id="heartbeatservice">HeartbeatService</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Service</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@RequiredArgsConstructor</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Transactional</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">HeartbeatService</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><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>VmRepository<span style="color:#6e7681"> </span>vmRepo;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>AnomalyDetectorService<span style="color:#6e7681"> </span>anomalyDetector;<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">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">process</span>(HeartbeatRequest<span style="color:#6e7681"> </span>req)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>Vm<span style="color:#6e7681"> </span>vm<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>vmRepo.findByMacAddress(req.getMacAddress())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.orElseGet(()<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>vmRepo.save(Vm.create(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>req.getMacAddress(),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>req.getHostname(),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>req.getAgentVersion()<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>vm.heartbeat(req.getHostname(),<span style="color:#6e7681"> </span>req.getAgentVersion());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>anomalyDetector.detectAndUpdate(vm,<span style="color:#6e7681"> </span>req.getNetworkInterfaces());<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><p><code>Vm.create()</code>는 신규 VM을 <code>UNKNOWN</code> 상태로 생성한다. <code>vm.heartbeat()</code>는 <code>lastSeenAt</code>을 현재 시각으로 갱신하고 상태를 <code>ONLINE</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// Vm.java (Entity)</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">heartbeat</span>(String<span style="color:#6e7681"> </span>hostname,<span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>agentVersion)<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">this</span>.hostname<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>hostname;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">this</span>.agentVersion<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>agentVersion;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">this</span>.lastSeenAt<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>Instant.now();<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">if</span><span style="color:#6e7681"> </span>(<span style="color:#ff7b72">this</span>.status<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span>VmStatus.OFFLINE)<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">this</span>.status<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>VmStatus.ONLINE;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#8b949e;font-style:italic">// VM_ONLINE 이벤트는 AnomalyDetector에서 처리</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681"> </span><span style="color:#ff7b72">else</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">this</span>.status<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>VmStatus.ONLINE;<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="anomalydetectorservice">AnomalyDetectorService</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Service</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@RequiredArgsConstructor</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">AnomalyDetectorService</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><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>NetworkInfoRepository<span style="color:#6e7681"> </span>nicRepo;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>EventLogRepository<span style="color:#6e7681"> </span>eventRepo;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>IpPoolRepository<span style="color:#6e7681"> </span>ipPoolRepo;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>VmRepository<span style="color:#6e7681"> </span>vmRepo;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>SlackNotifierService<span style="color:#6e7681"> </span>slack;<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:#d2a8ff;font-weight:bold">@Transactional</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">detectAndUpdate</span>(Vm<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>NicInfo<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>incomingNics)<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">for</span><span style="color:#6e7681"> </span>(NicInfo<span style="color:#6e7681"> </span>nic<span style="color:#6e7681"> </span>:<span style="color:#6e7681"> </span>incomingNics)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>String<span style="color:#6e7681"> </span>newIp<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>nic.getIpAddress();<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:#8b949e;font-style:italic">// 1. IP 변경 감지</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>NetworkInfo<span style="color:#6e7681"> </span>existing<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>nicRepo.findByMacAddress(nic.getMacAddress()).orElse(<span style="color:#79c0ff">null</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>(existing<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">!=</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">null</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">!</span>existing.getIpAddress().equals(newIp))<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>saveEvent(vm,<span style="color:#6e7681"> </span>EventType.IP_CHANGED,<span style="color:#6e7681"> </span>Severity.WARNING,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>String.format(<span style="color:#a5d6ff">&#34;%s IP 변경: %s → %s&#34;</span>,<span style="color:#6e7681"> </span>vm.getHostname(),<span style="color:#6e7681"> </span>existing.getIpAddress(),<span style="color:#6e7681"> </span>newIp),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>Map.of(<span style="color:#a5d6ff">&#34;oldIp&#34;</span>,<span style="color:#6e7681"> </span>existing.getIpAddress(),<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;newIp&#34;</span>,<span style="color:#6e7681"> </span>newIp));<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>slack.notify(EventType.IP_CHANGED,<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>Map.of(<span style="color:#a5d6ff">&#34;oldIp&#34;</span>,<span style="color:#6e7681"> </span>existing.getIpAddress(),<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;newIp&#34;</span>,<span style="color:#6e7681"> </span>newIp));<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><span style="color:#8b949e;font-style:italic">// 2. IP 충돌 감지</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>Vm<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>conflictVms<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>vmRepo.findByCurrentIpAndIdNot(newIp,<span style="color:#6e7681"> </span>vm.getId());<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>(<span style="color:#ff7b72;font-weight:bold">!</span>conflictVms.isEmpty())<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>String<span style="color:#6e7681"> </span>conflictNames<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>conflictVms.stream()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>.map(Vm::getHostname).collect(Collectors.joining(<span style="color:#a5d6ff">&#34;, &#34;</span>));<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>saveEvent(vm,<span style="color:#6e7681"> </span>EventType.IP_CONFLICT,<span style="color:#6e7681"> </span>Severity.CRITICAL,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>String.format(<span style="color:#a5d6ff">&#34;IP 충돌: %s ← %s 외 %s&#34;</span>,<span style="color:#6e7681"> </span>newIp,<span style="color:#6e7681"> </span>vm.getHostname(),<span style="color:#6e7681"> </span>conflictNames),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>Map.of(<span style="color:#a5d6ff">&#34;conflictWith&#34;</span>,<span style="color:#6e7681"> </span>conflictNames));<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>slack.notify(EventType.IP_CONFLICT,<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>Map.of(<span style="color:#a5d6ff">&#34;ip&#34;</span>,<span style="color:#6e7681"> </span>newIp,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;conflictWith&#34;</span>,<span style="color:#6e7681"> </span>conflictNames));<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><span style="color:#8b949e;font-style:italic">// 3. IP 대역 이탈 감지</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>(<span style="color:#ff7b72;font-weight:bold">!</span>isInAllowedRange(newIp,<span style="color:#6e7681"> </span>vm.getTeamId()))<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>saveEvent(vm,<span style="color:#6e7681"> </span>EventType.IP_OUT_OF_RANGE,<span style="color:#6e7681"> </span>Severity.WARNING,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>String.format(<span style="color:#a5d6ff">&#34;%s IP 대역 이탈: %s&#34;</span>,<span style="color:#6e7681"> </span>vm.getHostname(),<span style="color:#6e7681"> </span>newIp),<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>Map.of(<span style="color:#a5d6ff">&#34;ip&#34;</span>,<span style="color:#6e7681"> </span>newIp));<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>slack.notify(EventType.IP_OUT_OF_RANGE,<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>Map.of(<span style="color:#a5d6ff">&#34;ip&#34;</span>,<span style="color:#6e7681"> </span>newIp));<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><span style="color:#8b949e;font-style:italic">// NIC 정보 갱신</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>nicRepo.save(nic.toEntity(vm));<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>vm.setCurrentIp(newIp);<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></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">boolean</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">isInAllowedRange</span>(String<span style="color:#6e7681"> </span>ip,<span style="color:#6e7681"> </span>Long<span style="color:#6e7681"> </span>teamId)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>IpPool<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>pools<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>ipPoolRepo.findByTeamId(teamId);<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>(pools.isEmpty())<span style="color:#6e7681"> </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">true</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">return</span><span style="color:#6e7681"> </span>pools.stream().anyMatch(pool<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>pool.contains(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></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">saveEvent</span>(Vm<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>EventType<span style="color:#6e7681"> </span>type,<span style="color:#6e7681"> </span>Severity<span style="color:#6e7681"> </span>severity,<span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>message,<span style="color:#6e7681"> </span>Map<span style="color:#ff7b72;font-weight:bold">&lt;</span>String,<span style="color:#6e7681"> </span>Object<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>detail)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>eventRepo.save(EventLog.builder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.vm(vm)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.eventType(type)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.severity(severity)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.message(message)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.detail(detail)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.build());<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>
<p>heartbeat가 10분 이상 없으면 OFFLINE으로 처리한다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Service</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@RequiredArgsConstructor</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">OfflineSchedulerService</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><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>VmRepository<span style="color:#6e7681"> </span>vmRepo;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>EventLogRepository<span style="color:#6e7681"> </span>eventRepo;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>SlackNotifierService<span style="color:#6e7681"> </span>slack;<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:#d2a8ff;font-weight:bold">@Value</span>(<span style="color:#a5d6ff">&#34;${ipam.offline.threshold-minutes:10}&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>thresholdMinutes;<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:#d2a8ff;font-weight:bold">@Scheduled</span>(fixedDelayString<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;${ipam.offline.check-interval-ms:120000}&#34;</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">@Transactional</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">checkOfflineVms</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>Instant<span style="color:#6e7681"> </span>threshold<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>Instant.now().minus(thresholdMinutes,<span style="color:#6e7681"> </span>ChronoUnit.MINUTES);<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>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>Vm<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>candidates<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>vmRepo.findByStatusAndLastSeenAtBefore(VmStatus.ONLINE,<span style="color:#6e7681"> </span>threshold);<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>(Vm<span style="color:#6e7681"> </span>vm<span style="color:#6e7681"> </span>:<span style="color:#6e7681"> </span>candidates)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>vm.setStatus(VmStatus.OFFLINE);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>eventRepo.save(EventLog.builder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.vm(vm)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.eventType(EventType.VM_OFFLINE)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.severity(Severity.WARNING)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.message(String.format(<span style="color:#a5d6ff">&#34;%s 오프라인 — 마지막 heartbeat: %s&#34;</span>,<span style="color:#6e7681"> </span>vm.getHostname(),<span style="color:#6e7681"> </span>vm.getLastSeenAt()))<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.build());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>slack.notify(EventType.VM_OFFLINE,<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>Map.of());<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></span></code></pre></div><p><code>fixedDelay</code>는 이전 실행이 끝난 후부터 대기 시간을 계산한다. <code>fixedRate</code>를 쓰면 실행 중에 다음 실행이 겹칠 수 있어서 <code>fixedDelay</code>를 선택했다.</p>
<p><code>application.yml</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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">ipam</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">offline</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">threshold-minutes</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">check-interval-ms</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">120000</span><span style="color:#6e7681">
</span></span></span></code></pre></div><hr>
<h2 id="slack-알림--redis-dedup">Slack 알림 — Redis dedup</h2>
<p>같은 이상 상황이 반복해서 알림이 오면 알림 피로도가 높아진다. IP 충돌이 발생한 VM이 계속 heartbeat를 보내면 매 30초마다 같은 알림이 발송된다.</p>
<p>Redis를 이용해서 같은 이벤트가 30분 내에 중복 발송되지 않도록 dedup 처리한다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Service</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@RequiredArgsConstructor</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">SlackNotifierService</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><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>StringRedisTemplate<span style="color:#6e7681"> </span>redis;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">final</span><span style="color:#6e7681"> </span>WebClient<span style="color:#6e7681"> </span>webClient;<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:#d2a8ff;font-weight:bold">@Value</span>(<span style="color:#a5d6ff">&#34;${ipam.slack.webhook-url:}&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>webhookUrl;<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:#d2a8ff;font-weight:bold">@Value</span>(<span style="color:#a5d6ff">&#34;${ipam.slack.dedup-ttl:30}&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>dedupTtlMinutes;<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">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">notify</span>(EventType<span style="color:#6e7681"> </span>type,<span style="color:#6e7681"> </span>Vm<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>Map<span style="color:#ff7b72;font-weight:bold">&lt;</span>String,<span style="color:#6e7681"> </span>Object<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>detail)<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">if</span><span style="color:#6e7681"> </span>(webhookUrl.isBlank())<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></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>String<span style="color:#6e7681"> </span>dedupKey<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>String.format(<span style="color:#a5d6ff">&#34;slack:dedup:%s:%d&#34;</span>,<span style="color:#6e7681"> </span>type.name(),<span style="color:#6e7681"> </span>vm.getId());<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:#8b949e;font-style:italic">// 30분 내에 같은 이벤트가 발송됐으면 스킵</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>Boolean<span style="color:#6e7681"> </span>isNew<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>redis.opsForValue().setIfAbsent(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>dedupKey,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;1&#34;</span>,<span style="color:#6e7681"> </span>Duration.ofMinutes(dedupTtlMinutes)<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">if</span><span style="color:#6e7681"> </span>(<span style="color:#ff7b72;font-weight:bold">!</span>Boolean.TRUE.equals(isNew))<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></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>String<span style="color:#6e7681"> </span>message<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>buildMessage(type,<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>detail);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>sendAsync(message);<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><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">buildMessage</span>(EventType<span style="color:#6e7681"> </span>type,<span style="color:#6e7681"> </span>Vm<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>Map<span style="color:#ff7b72;font-weight:bold">&lt;</span>String,<span style="color:#6e7681"> </span>Object<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>detail)<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:#ff7b72">switch</span><span style="color:#6e7681"> </span>(type)<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>IP_CONFLICT<span style="color:#6e7681">  </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>String.format(<span style="color:#a5d6ff">&#34;:rotating_light: *IP 충돌* — `%s` (%s)\n충돌 VM: %s&#34;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>detail.get(<span style="color:#a5d6ff">&#34;ip&#34;</span>),<span style="color:#6e7681"> </span>vm.getHostname(),<span style="color:#6e7681"> </span>detail.get(<span style="color:#a5d6ff">&#34;conflictWith&#34;</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>IP_CHANGED<span style="color:#6e7681">   </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>String.format(<span style="color:#a5d6ff">&#34;:arrows_counterclockwise: *IP 변경* — `%s`\n`%s` → `%s`&#34;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>vm.getHostname(),<span style="color:#6e7681"> </span>detail.get(<span style="color:#a5d6ff">&#34;oldIp&#34;</span>),<span style="color:#6e7681"> </span>detail.get(<span style="color:#a5d6ff">&#34;newIp&#34;</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>IP_OUT_OF_RANGE<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>String.format(<span style="color:#a5d6ff">&#34;:warning: *IP 대역 이탈* — `%s` (%s)&#34;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>vm.getHostname(),<span style="color:#6e7681"> </span>detail.get(<span style="color:#a5d6ff">&#34;ip&#34;</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>VM_OFFLINE<span style="color:#6e7681">   </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>String.format(<span style="color:#a5d6ff">&#34;:red_circle: *VM 오프라인* — `%s`\n마지막 heartbeat: %s&#34;</span>,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>vm.getHostname(),<span style="color:#6e7681"> </span>vm.getLastSeenAt());<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>VM_ONLINE<span style="color:#6e7681">    </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>String.format(<span style="color:#a5d6ff">&#34;:large_green_circle: *VM 복귀* — `%s`&#34;</span>,<span style="color:#6e7681"> </span>vm.getHostname());<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></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">sendAsync</span>(String<span style="color:#6e7681"> </span>message)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>webClient.post()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.uri(webhookUrl)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.bodyValue(Map.of(<span style="color:#a5d6ff">&#34;text&#34;</span>,<span style="color:#6e7681"> </span>message))<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.retrieve()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.toBodilessEntity()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.subscribe(<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>r<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>{},<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>e<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>System.err.println(<span style="color:#a5d6ff">&#34;[Slack] 발송 실패: &#34;</span><span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">+</span><span style="color:#6e7681"> </span>e.getMessage())<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></span></code></pre></div><p><code>setIfAbsent</code>는 Redis의 <code>SET NX EX</code> 명령과 동일하다. 키가 없으면 설정하고 <code>true</code>를 반환, 이미 있으면 <code>false</code>를 반환한다. TTL이 만료되면 다시 알림이 발송된다.</p>
<hr>
<h2 id="vm-복귀-감지">VM 복귀 감지</h2>
<p>OFFLINE 상태의 VM이 다시 heartbeat를 보내면 VM_ONLINE 이벤트를 발생시킨다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// HeartbeatService.java</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">process</span>(HeartbeatRequest<span style="color:#6e7681"> </span>req)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>Vm<span style="color:#6e7681"> </span>vm<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>vmRepo.findByMacAddress(req.getMacAddress())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>.orElseGet(()<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>vmRepo.save(Vm.create(...)));<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">boolean</span><span style="color:#6e7681"> </span>wasOffline<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>vm.getStatus()<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span>VmStatus.OFFLINE;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>vm.heartbeat(req.getHostname(),<span style="color:#6e7681"> </span>req.getAgentVersion());<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">if</span><span style="color:#6e7681"> </span>(wasOffline)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>eventRepo.save(EventLog.builder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.vm(vm)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.eventType(EventType.VM_ONLINE)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.severity(Severity.INFO)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.message(String.format(<span style="color:#a5d6ff">&#34;%s 복귀&#34;</span>,<span style="color:#6e7681"> </span>vm.getHostname()))<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.build());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>slack.notify(EventType.VM_ONLINE,<span style="color:#6e7681"> </span>vm,<span style="color:#6e7681"> </span>Map.of());<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>anomalyDetector.detectAndUpdate(vm,<span style="color:#6e7681"> </span>req.getNetworkInterfaces());<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="applicationyml">application.yml</h2>
<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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">spring</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">datasource</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">url</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:ipam}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">username</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${DB_USER:ipam}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">password</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${DB_PASS:ipam1234}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">jpa</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">hibernate</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">ddl-auto</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">validate</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">show-sql</span>:<span style="color:#6e7681"> </span><span style="color:#79c0ff">false</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">data</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">redis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">host</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${REDIS_HOST:localhost}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">port</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">6379</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:#7ee787">ipam</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">offline</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">threshold-minutes</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">10</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">check-interval-ms</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">120000</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">slack</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">webhook-url</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">${SLACK_WEBHOOK_URL:}</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">dedup-ttl</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">30</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>ddl-auto: validate</code>로 설정하면 애플리케이션 시작 시 엔티티와 DB 스키마가 일치하는지만 확인하고, 스키마를 자동으로 변경하지 않는다. 스키마는 <code>init.sql</code>로 별도 관리한다.</p>
<hr>
<h2 id="이벤트-조회-api">이벤트 조회 API</h2>
<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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@GetMapping</span>(<span style="color:#a5d6ff">&#34;/api/events&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>Page<span style="color:#ff7b72;font-weight:bold">&lt;</span>EventLogDto<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">getEvents</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">@RequestParam</span>(required<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">false</span>)<span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>eventType,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">@RequestParam</span>(required<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">false</span>)<span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>severity,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">@RequestParam</span>(defaultValue<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;0&#34;</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>page,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">@RequestParam</span>(defaultValue<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;50&#34;</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>size<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>Pageable<span style="color:#6e7681"> </span>pageable<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>PageRequest.of(page,<span style="color:#6e7681"> </span>size,<span style="color:#6e7681"> </span>Sort.by(<span style="color:#a5d6ff">&#34;createdAt&#34;</span>).descending());<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>eventRepo.findByFilters(eventType,<span style="color:#6e7681"> </span>severity,<span style="color:#6e7681"> </span>pageable)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>.map(EventLogDto::from);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><p>프론트에서 이벤트 타입과 심각도로 필터링해서 조회한다.</p>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="ip-충돌-오탐-자기-자신과의-충돌">IP 충돌 오탐: 자기 자신과의 충돌</h3>
<p>처음 구현에서 <code>findByCurrentIp(newIp)</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 버그: 자기 자신도 포함됨</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>Vm<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>conflictVms<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>vmRepo.findByCurrentIp(newIp);<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:#8b949e;font-style:italic">// 수정: 자기 자신 제외</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>Vm<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span>conflictVms<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>vmRepo.findByCurrentIpAndIdNot(newIp,<span style="color:#6e7681"> </span>vm.getId());<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="오프라인-스케줄러-중복-실행">오프라인 스케줄러 중복 실행</h3>
<p>초기에 <code>@Scheduled(fixedRate = 120000)</code>을 썼다. 스케줄러 실행 중에 DB 처리가 길어지면 이전 실행이 끝나기 전에 다음 실행이 시작되는 문제가 있었다. <code>fixedDelay</code>로 바꿔서 이전 실행 완료 후 대기하도록 수정했다.</p>
<h3 id="redis-연결-없을-때-알림-전체-중단">Redis 연결 없을 때 알림 전체 중단</h3>
<p>Redis가 다운되면 <code>setIfAbsent</code>가 예외를 던져서 Slack 알림이 전혀 발송되지 않는 문제가 있었다.</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-java" data-lang="java"><span style="display:flex;"><span>Boolean<span style="color:#6e7681"> </span>isNew;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">try</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>isNew<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>redis.opsForValue().setIfAbsent(dedupKey,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;1&#34;</span>,<span style="color:#6e7681"> </span>Duration.ofMinutes(dedupTtlMinutes));<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681"> </span><span style="color:#ff7b72">catch</span><span style="color:#6e7681"> </span>(Exception<span style="color:#6e7681"> </span>e)<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">// Redis 장애 시 dedup 없이 발송</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>isNew<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#79c0ff">true</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">if</span><span style="color:#6e7681"> </span>(<span style="color:#ff7b72;font-weight:bold">!</span>Boolean.TRUE.equals(isNew))<span style="color:#6e7681"> </span><span style="color:#ff7b72">return</span>;<span style="color:#6e7681">
</span></span></span></code></pre></div><p>Redis 장애 시에는 dedup 없이 발송하도록 fallback을 추가했다. 중복 발송이 발생할 수 있지만, 알림이 아예 안 오는 것보다 낫다.</p>
<hr>
<p>다음 편에서는 React 대시보드와 Docker Compose로 4개 서비스를 배포하는 방법을 다룬다.</p>
]]></content:encoded>
    </item>
    <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>
