왜 만들게 됐나

VM이 늘어나면서 IP 관리가 슬슬 문제가 됐다.

  • 스프레드시트에 IP 할당 현황을 수동으로 관리하는데, 담당자마다 업데이트 타이밍이 달라서 실제 상태와 항상 일치하지 않음
  • VM을 삭제하면서 IP 해제를 빠뜨리면, 나중에 같은 IP를 다른 VM에 할당해서 충돌이 발생
  • VM이 죽어도 스프레드시트에 살아있는 것처럼 남아있어서 “이 서버 살아있어요?” 같은 질문이 자주 옴
  • 특정 VM의 IP 변동 이력을 알고 싶어도 추적 방법이 없음

그래서 만들었다.

  • VM에 경량 에이전트를 설치해서 주기적으로 heartbeat를 전송
  • 서버에서 heartbeat를 분석해서 IP 충돌, IP 변경, 오프라인 등 이상 감지
  • 이상 발생 시 Slack 알림
  • 웹 UI에서 전체 VM 현황과 이벤트 로그 조회

기술 스택

영역 기술 선택 이유
에이전트 Go 1.22 단일 바이너리, 런타임 불필요, 크기 소형
백엔드 Spring Boot 3 + JPA 팀 표준 스택
DB PostgreSQL 16 이력 테이블 + 집계 쿼리
캐시/dedup Redis 7 Slack 알림 중복 방지
프론트엔드 React 18 + Vite 30초 폴링 대시보드
인프라 Docker Compose 4개 서비스 일괄 관리

에이전트는 처음에 Node.js로 만들었다가 Go로 전환했다. 이유는 2편에서 다룬다.


전체 아키텍처

[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초 폴링

서비스 구성:

서비스 포트 역할
app 8080 Spring Boot API
postgres 5432 PostgreSQL 16
redis 6379 Redis 7
frontend 3000 React (nginx)

VM 식별 기준: MAC 주소

IP 주소를 VM의 식별자로 쓰면 안 된다. DHCP 환경에서는 IP가 바뀔 수 있고, IP 변경 자체가 감지해야 할 이벤트이기 때문이다.

MAC 주소를 기본 키로 사용한다.

VM 최초 heartbeat → MAC 주소로 조회
├─ DB에 없음 → 신규 VM 등록
└─ DB에 있음 → 상태 갱신 (hostname, IP, lastSeenAt)

IP가 바뀌면 ip_history에 이전 IP를 기록하고 event_logIP_CHANGED 이벤트를 남긴다.


DB 스키마

team

CREATE TABLE team (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    description TEXT,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

VM을 팀 단위로 그룹핑한다. 이상 감지 알림을 팀 채널로 보낼 때 쓴다.

vm

CREATE TABLE vm (
    id              SERIAL PRIMARY KEY,
    mac_address     VARCHAR(17) UNIQUE NOT NULL,
    hostname        VARCHAR(255),
    current_ip      VARCHAR(15),
    team_id         INTEGER REFERENCES team(id),
    status          VARCHAR(20) DEFAULT 'UNKNOWN',
    agent_version   VARCHAR(50),
    last_seen_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);
  • mac_address — 고유 식별자
  • statusONLINE / OFFLINE / UNKNOWN
  • last_seen_at — 마지막 heartbeat 수신 시각. 오프라인 감지에 쓴다.

network_info

CREATE TABLE network_info (
    id              SERIAL PRIMARY KEY,
    vm_id           INTEGER REFERENCES vm(id),
    interface_name  VARCHAR(50),
    mac_address     VARCHAR(17),
    ip_address      VARCHAR(15),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

VM의 NIC별 정보를 저장한다. NIC가 여러 개인 VM도 있기 때문에 별도 테이블로 분리했다.

ip_history

CREATE TABLE ip_history (
    id          SERIAL PRIMARY KEY,
    vm_id       INTEGER REFERENCES vm(id),
    old_ip      VARCHAR(15),
    new_ip      VARCHAR(15),
    changed_at  TIMESTAMPTZ DEFAULT NOW()
);

IP가 바뀔 때마다 이전 IP를 기록한다. 특정 VM의 IP 변동 이력을 추적할 수 있다.

event_log

CREATE TABLE event_log (
    id           SERIAL PRIMARY KEY,
    vm_id        INTEGER REFERENCES vm(id),
    event_type   VARCHAR(50) NOT NULL,
    severity     VARCHAR(20) NOT NULL,
    message      TEXT,
    detail       JSONB,
    created_at   TIMESTAMPTZ DEFAULT NOW()
);

이상 감지 이벤트를 모두 기록한다.

event_type severity 설명
IP_CONFLICT CRITICAL 같은 IP를 쓰는 VM이 2개 이상
IP_CHANGED WARNING VM의 IP가 바뀜
IP_OUT_OF_RANGE WARNING 허용 대역 외 IP 사용
VM_OFFLINE WARNING 10분 이상 heartbeat 없음
VM_ONLINE INFO OFFLINE이었던 VM이 복귀

detail 컬럼(JSONB)에는 충돌 IP, 변경 전/후 IP 같은 추가 정보를 저장한다.

ip_pool

CREATE TABLE ip_pool (
    id          SERIAL PRIMARY KEY,
    cidr        VARCHAR(18) NOT NULL,
    description TEXT,
    team_id     INTEGER REFERENCES team(id),
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

팀별 허용 IP 대역을 관리한다. heartbeat로 들어온 IP가 이 대역에 속하지 않으면 IP_OUT_OF_RANGE 이벤트가 발생한다.


REST API 목록

Method Path 설명
POST /api/heartbeat 에이전트 → 서버 heartbeat 수신
GET /api/vms VM 목록 (상태, 팀 필터)
GET /api/vms/:id VM 단건 조회 + IP 이력
GET /api/events 이벤트 로그 (타입, 날짜 필터)
GET /api/teams 팀 목록
POST /api/teams 팀 추가
GET /api/ip-pool IP 대역 목록
POST /api/ip-pool IP 대역 추가
GET /actuator/health 헬스체크

프로젝트 구조

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

다음 편에서는 Node.js 에이전트가 왜 문제가 됐는지, Go로 전환하면서 어떻게 달라졌는지를 다룬다.