heartbeat 처리 흐름
에이전트가 30초마다 POST /api/heartbeat를 호출한다. 서버는 MAC 주소를 기준으로 VM을 식별하고 상태를 갱신한다.
heartbeat 수신
│
▼
MAC 주소로 VM 조회
│
├─ DB에 없음 → 신규 등록
└─ DB에 있음 → 상태 갱신 (hostname, last_seen_at)
│
▼
이상 감지 실행
│
├─ IP 변경 감지
├─ IP 충돌 감지
└─ IP 대역 이탈 감지
HeartbeatService
@Service
@RequiredArgsConstructor
@Transactional
public class HeartbeatService {
private final VmRepository vmRepo;
private final AnomalyDetectorService anomalyDetector;
public void process(HeartbeatRequest req) {
Vm vm = vmRepo.findByMacAddress(req.getMacAddress())
.orElseGet(() -> vmRepo.save(Vm.create(
req.getMacAddress(),
req.getHostname(),
req.getAgentVersion()
)));
vm.heartbeat(req.getHostname(), req.getAgentVersion());
anomalyDetector.detectAndUpdate(vm, req.getNetworkInterfaces());
}
}
Vm.create()는 신규 VM을 UNKNOWN 상태로 생성한다. vm.heartbeat()는 lastSeenAt을 현재 시각으로 갱신하고 상태를 ONLINE으로 바꾼다.
// Vm.java (Entity)
public void heartbeat(String hostname, String agentVersion) {
this.hostname = hostname;
this.agentVersion = agentVersion;
this.lastSeenAt = Instant.now();
if (this.status == VmStatus.OFFLINE) {
this.status = VmStatus.ONLINE;
// VM_ONLINE 이벤트는 AnomalyDetector에서 처리
} else {
this.status = VmStatus.ONLINE;
}
}
이상 감지
AnomalyDetectorService
@Service
@RequiredArgsConstructor
public class AnomalyDetectorService {
private final NetworkInfoRepository nicRepo;
private final EventLogRepository eventRepo;
private final IpPoolRepository ipPoolRepo;
private final VmRepository vmRepo;
private final SlackNotifierService slack;
@Transactional
public void detectAndUpdate(Vm vm, List<NicInfo> incomingNics) {
for (NicInfo nic : incomingNics) {
String newIp = nic.getIpAddress();
// 1. IP 변경 감지
NetworkInfo existing = nicRepo.findByMacAddress(nic.getMacAddress()).orElse(null);
if (existing != null && !existing.getIpAddress().equals(newIp)) {
saveEvent(vm, EventType.IP_CHANGED, Severity.WARNING,
String.format("%s IP 변경: %s → %s", vm.getHostname(), existing.getIpAddress(), newIp),
Map.of("oldIp", existing.getIpAddress(), "newIp", newIp));
slack.notify(EventType.IP_CHANGED, vm, Map.of("oldIp", existing.getIpAddress(), "newIp", newIp));
}
// 2. IP 충돌 감지
List<Vm> conflictVms = vmRepo.findByCurrentIpAndIdNot(newIp, vm.getId());
if (!conflictVms.isEmpty()) {
String conflictNames = conflictVms.stream()
.map(Vm::getHostname).collect(Collectors.joining(", "));
saveEvent(vm, EventType.IP_CONFLICT, Severity.CRITICAL,
String.format("IP 충돌: %s ← %s 외 %s", newIp, vm.getHostname(), conflictNames),
Map.of("conflictWith", conflictNames));
slack.notify(EventType.IP_CONFLICT, vm, Map.of("ip", newIp, "conflictWith", conflictNames));
}
// 3. IP 대역 이탈 감지
if (!isInAllowedRange(newIp, vm.getTeamId())) {
saveEvent(vm, EventType.IP_OUT_OF_RANGE, Severity.WARNING,
String.format("%s IP 대역 이탈: %s", vm.getHostname(), newIp),
Map.of("ip", newIp));
slack.notify(EventType.IP_OUT_OF_RANGE, vm, Map.of("ip", newIp));
}
// NIC 정보 갱신
nicRepo.save(nic.toEntity(vm));
vm.setCurrentIp(newIp);
}
}
private boolean isInAllowedRange(String ip, Long teamId) {
List<IpPool> pools = ipPoolRepo.findByTeamId(teamId);
if (pools.isEmpty()) return true; // 대역 설정 없으면 허용
return pools.stream().anyMatch(pool -> pool.contains(ip));
}
private void saveEvent(Vm vm, EventType type, Severity severity, String message, Map<String, Object> detail) {
eventRepo.save(EventLog.builder()
.vm(vm)
.eventType(type)
.severity(severity)
.message(message)
.detail(detail)
.build());
}
}
오프라인 감지 스케줄러
heartbeat가 10분 이상 없으면 OFFLINE으로 처리한다.
@Service
@RequiredArgsConstructor
public class OfflineSchedulerService {
private final VmRepository vmRepo;
private final EventLogRepository eventRepo;
private final SlackNotifierService slack;
@Value("${ipam.offline.threshold-minutes:10}")
private int thresholdMinutes;
@Scheduled(fixedDelayString = "${ipam.offline.check-interval-ms:120000}")
@Transactional
public void checkOfflineVms() {
Instant threshold = Instant.now().minus(thresholdMinutes, ChronoUnit.MINUTES);
List<Vm> candidates = vmRepo.findByStatusAndLastSeenAtBefore(VmStatus.ONLINE, threshold);
for (Vm vm : candidates) {
vm.setStatus(VmStatus.OFFLINE);
eventRepo.save(EventLog.builder()
.vm(vm)
.eventType(EventType.VM_OFFLINE)
.severity(Severity.WARNING)
.message(String.format("%s 오프라인 — 마지막 heartbeat: %s", vm.getHostname(), vm.getLastSeenAt()))
.build());
slack.notify(EventType.VM_OFFLINE, vm, Map.of());
}
}
}
fixedDelay는 이전 실행이 끝난 후부터 대기 시간을 계산한다. fixedRate를 쓰면 실행 중에 다음 실행이 겹칠 수 있어서 fixedDelay를 선택했다.
application.yml에서 임계값을 조정할 수 있다.
ipam:
offline:
threshold-minutes: 10
check-interval-ms: 120000
Slack 알림 — Redis dedup
같은 이상 상황이 반복해서 알림이 오면 알림 피로도가 높아진다. IP 충돌이 발생한 VM이 계속 heartbeat를 보내면 매 30초마다 같은 알림이 발송된다.
Redis를 이용해서 같은 이벤트가 30분 내에 중복 발송되지 않도록 dedup 처리한다.
@Service
@RequiredArgsConstructor
public class SlackNotifierService {
private final StringRedisTemplate redis;
private final WebClient webClient;
@Value("${ipam.slack.webhook-url:}")
private String webhookUrl;
@Value("${ipam.slack.dedup-ttl:30}")
private int dedupTtlMinutes;
public void notify(EventType type, Vm vm, Map<String, Object> detail) {
if (webhookUrl.isBlank()) return;
String dedupKey = String.format("slack:dedup:%s:%d", type.name(), vm.getId());
// 30분 내에 같은 이벤트가 발송됐으면 스킵
Boolean isNew = redis.opsForValue().setIfAbsent(
dedupKey, "1", Duration.ofMinutes(dedupTtlMinutes)
);
if (!Boolean.TRUE.equals(isNew)) return;
String message = buildMessage(type, vm, detail);
sendAsync(message);
}
private String buildMessage(EventType type, Vm vm, Map<String, Object> detail) {
return switch (type) {
case IP_CONFLICT -> String.format(":rotating_light: *IP 충돌* — `%s` (%s)\n충돌 VM: %s",
detail.get("ip"), vm.getHostname(), detail.get("conflictWith"));
case IP_CHANGED -> String.format(":arrows_counterclockwise: *IP 변경* — `%s`\n`%s` → `%s`",
vm.getHostname(), detail.get("oldIp"), detail.get("newIp"));
case IP_OUT_OF_RANGE -> String.format(":warning: *IP 대역 이탈* — `%s` (%s)",
vm.getHostname(), detail.get("ip"));
case VM_OFFLINE -> String.format(":red_circle: *VM 오프라인* — `%s`\n마지막 heartbeat: %s",
vm.getHostname(), vm.getLastSeenAt());
case VM_ONLINE -> String.format(":large_green_circle: *VM 복귀* — `%s`", vm.getHostname());
};
}
private void sendAsync(String message) {
webClient.post()
.uri(webhookUrl)
.bodyValue(Map.of("text", message))
.retrieve()
.toBodilessEntity()
.subscribe(
r -> {},
e -> System.err.println("[Slack] 발송 실패: " + e.getMessage())
);
}
}
setIfAbsent는 Redis의 SET NX EX 명령과 동일하다. 키가 없으면 설정하고 true를 반환, 이미 있으면 false를 반환한다. TTL이 만료되면 다시 알림이 발송된다.
VM 복귀 감지
OFFLINE 상태의 VM이 다시 heartbeat를 보내면 VM_ONLINE 이벤트를 발생시킨다.
// HeartbeatService.java
public void process(HeartbeatRequest req) {
Vm vm = vmRepo.findByMacAddress(req.getMacAddress())
.orElseGet(() -> vmRepo.save(Vm.create(...)));
boolean wasOffline = vm.getStatus() == VmStatus.OFFLINE;
vm.heartbeat(req.getHostname(), req.getAgentVersion());
if (wasOffline) {
eventRepo.save(EventLog.builder()
.vm(vm)
.eventType(EventType.VM_ONLINE)
.severity(Severity.INFO)
.message(String.format("%s 복귀", vm.getHostname()))
.build());
slack.notify(EventType.VM_ONLINE, vm, Map.of());
}
anomalyDetector.detectAndUpdate(vm, req.getNetworkInterfaces());
}
application.yml
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:ipam}
username: ${DB_USER:ipam}
password: ${DB_PASS:ipam1234}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
ipam:
offline:
threshold-minutes: 10
check-interval-ms: 120000
slack:
webhook-url: ${SLACK_WEBHOOK_URL:}
dedup-ttl: 30
ddl-auto: validate로 설정하면 애플리케이션 시작 시 엔티티와 DB 스키마가 일치하는지만 확인하고, 스키마를 자동으로 변경하지 않는다. 스키마는 init.sql로 별도 관리한다.
이벤트 조회 API
@GetMapping("/api/events")
public Page<EventLogDto> getEvents(
@RequestParam(required = false) String eventType,
@RequestParam(required = false) String severity,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return eventRepo.findByFilters(eventType, severity, pageable)
.map(EventLogDto::from);
}
프론트에서 이벤트 타입과 심각도로 필터링해서 조회한다.
트러블슈팅
IP 충돌 오탐: 자기 자신과의 충돌
처음 구현에서 findByCurrentIp(newIp)로 조회하면 자기 자신도 결과에 포함돼서 항상 충돌로 감지됐다.
// 버그: 자기 자신도 포함됨
List<Vm> conflictVms = vmRepo.findByCurrentIp(newIp);
// 수정: 자기 자신 제외
List<Vm> conflictVms = vmRepo.findByCurrentIpAndIdNot(newIp, vm.getId());
오프라인 스케줄러 중복 실행
초기에 @Scheduled(fixedRate = 120000)을 썼다. 스케줄러 실행 중에 DB 처리가 길어지면 이전 실행이 끝나기 전에 다음 실행이 시작되는 문제가 있었다. fixedDelay로 바꿔서 이전 실행 완료 후 대기하도록 수정했다.
Redis 연결 없을 때 알림 전체 중단
Redis가 다운되면 setIfAbsent가 예외를 던져서 Slack 알림이 전혀 발송되지 않는 문제가 있었다.
Boolean isNew;
try {
isNew = redis.opsForValue().setIfAbsent(dedupKey, "1", Duration.ofMinutes(dedupTtlMinutes));
} catch (Exception e) {
// Redis 장애 시 dedup 없이 발송
isNew = true;
}
if (!Boolean.TRUE.equals(isNew)) return;
Redis 장애 시에는 dedup 없이 발송하도록 fallback을 추가했다. 중복 발송이 발생할 수 있지만, 알림이 아예 안 오는 것보다 낫다.
다음 편에서는 React 대시보드와 Docker Compose로 4개 서비스를 배포하는 방법을 다룬다.