왜 만들게 됐나
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_log에 IP_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— 고유 식별자status—ONLINE/OFFLINE/UNKNOWNlast_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로 전환하면서 어떻게 달라졌는지를 다룬다.