<?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>네트워크 on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC/</link>
    <description>Recent content in 네트워크 on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Sun, 12 Apr 2026 10:00:00 +0900</lastBuildDate>
    <atom:link href="https://chanyeols.com/tags/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC/index.xml" rel="self" type="application/rss+xml" />
    <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>
