처음엔 Node.js로 만들었다

에이전트를 처음 만들 때 익숙한 Node.js를 썼다. 로직 자체는 단순하다.

  • 30초마다 POST /api/heartbeat 호출
  • 호스트명, MAC 주소, IP 주소를 payload에 담아 전송
const axios = require('axios');
const os = require('os');

const SERVER_URL = process.env.IPAM_SERVER_URL;
const INTERVAL_MS = 30_000;

function getNics() {
  const interfaces = os.networkInterfaces();
  const result = [];

  for (const [name, addrs] of Object.entries(interfaces)) {
    for (const addr of addrs) {
      if (addr.family === 'IPv4' && !addr.internal) {
        result.push({
          interfaceName: name,
          macAddress: addr.mac,
          ipAddress: addr.address,
        });
      }
    }
  }
  return result;
}

async function sendHeartbeat() {
  try {
    await axios.post(`${SERVER_URL}/api/heartbeat`, {
      hostname: os.hostname(),
      networkInterfaces: getNics(),
    });
  } catch (e) {
    console.error('[heartbeat] 실패:', e.message);
  }
}

sendHeartbeat();
setInterval(sendHeartbeat, INTERVAL_MS);

동작은 잘 했다. 문제는 배포였다.


Node.js 에이전트의 문제: 배포 크기

VM마다 Node.js 런타임을 설치하거나, pkg로 번들해서 배포해야 한다.

{
  "scripts": {
    "build": "pkg src/index.js --target node18-win-x64 --output dist/ipam-agent.exe"
  }
}

pkg를 쓰면 Node.js 런타임을 exe 안에 포함시켜서 단일 실행 파일로 만들 수 있다. 근데 크기가 문제였다.

ipam-agent.exe — 45 MB

Node.js 18 런타임 전체가 포함되니 이 크기가 나올 수밖에 없다. 에이전트를 30개 VM에 배포하면 총 1.3 GB를 올려야 한다. 업데이트할 때마다 다시 올려야 하고, 느린 내부 네트워크 환경에서는 부담이 된다.


Go로 전환

Go는 런타임을 따로 설치하지 않아도 된다. 컴파일하면 의존성이 모두 포함된 단일 바이너리가 나온다.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net"
    "net/http"
    "os"
    "time"
)

const (
    agentVersion = "1.0.0"
    intervalSec  = 30
)

type NicInfo struct {
    InterfaceName string `json:"interfaceName"`
    MacAddress    string `json:"macAddress"`
    IpAddress     string `json:"ipAddress"`
}

type HeartbeatPayload struct {
    MacAddress        string    `json:"macAddress"`
    Hostname          string    `json:"hostname"`
    AgentVersion      string    `json:"agentVersion"`
    NetworkInterfaces []NicInfo `json:"networkInterfaces"`
}

NIC 정보 수집

func getNics() []NicInfo {
    ifaces, _ := net.Interfaces()
    var result []NicInfo

    for _, iface := range ifaces {
        // 루프백 제외
        if iface.Flags&net.FlagLoopback != 0 {
            continue
        }
        addrs, _ := iface.Addrs()
        for _, addr := range addrs {
            var ip net.IP
            switch v := addr.(type) {
            case *net.IPNet:
                ip = v.IP
            case *net.IPAddr:
                ip = v.IP
            }
            // IPv4만, 링크로컬 제외
            if ip == nil || ip.IsLinkLocalUnicast() || ip.To4() == nil {
                continue
            }
            result = append(result, NicInfo{
                InterfaceName: iface.Name,
                MacAddress:    iface.HardwareAddr.String(),
                IpAddress:     ip.String(),
            })
        }
    }
    return result
}

heartbeat 전송

func sendHeartbeat(serverURL string) {
    nics := getNics()
    if len(nics) == 0 {
        fmt.Println("[heartbeat] NIC 없음, 스킵")
        return
    }

    payload := HeartbeatPayload{
        MacAddress:        nics[0].MacAddress,
        Hostname:          getHostname(),
        AgentVersion:      agentVersion,
        NetworkInterfaces: nics,
    }

    body, _ := json.Marshal(payload)
    resp, err := http.Post(serverURL+"/api/heartbeat", "application/json", bytes.NewReader(body))
    if err != nil {
        fmt.Printf("[heartbeat] 실패: %v\n", err)
        return
    }
    defer resp.Body.Close()
    fmt.Printf("[heartbeat] %s → %d\n", time.Now().Format("15:04:05"), resp.StatusCode)
}

func getHostname() string {
    h, err := os.Hostname()
    if err != nil {
        return "unknown"
    }
    return h
}

func main() {
    serverURL := os.Getenv("IPAM_SERVER_URL")
    if serverURL == "" {
        fmt.Println("[에이전트] IPAM_SERVER_URL 환경변수가 설정되지 않았습니다.")
        os.Exit(1)
    }

    fmt.Printf("[에이전트] 시작 — 서버: %s\n", serverURL)
    sendHeartbeat(serverURL)

    ticker := time.NewTicker(intervalSec * time.Second)
    for range ticker.C {
        sendHeartbeat(serverURL)
    }
}

빌드

Windows 대상

go build -ldflags="-s -w" -o ipam-agent.exe .
  • -s — 심볼 테이블 제거
  • -w — DWARF 디버그 정보 제거
  • 결과 크기: 약 6 MB

Node.js 버전(45 MB) 대비 87% 감소다.

크로스 컴파일

Go는 크로스 컴파일이 빌드 플래그 하나다. macOS나 Linux에서 Windows용 바이너리를 만들 수 있다.

# Linux에서 Windows 64비트 빌드
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ipam-agent.exe .

# Linux용 빌드
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ipam-agent .

# ARM (Raspberry Pi 등)
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ipam-agent-arm64 .

Node.js pkg는 타겟별로 다른 빌드 프로세스가 필요했는데, Go는 환경변수 두 개로 끝난다.


에이전트 배포 방법

Windows VM

  1. ipam-agent.exe를 VM에 복사
  2. 환경변수 설정
  3. 윈도우 서비스로 등록 (NSSM 사용)
nssm install IpamAgent "C:\ipam-agent.exe"
nssm set IpamAgent AppEnvironmentExtra "IPAM_SERVER_URL=http://ipam-server:8080"
nssm start IpamAgent

Linux VM (systemd)

# /etc/systemd/system/ipam-agent.service
[Unit]
Description=IPAM Agent
After=network.target

[Service]
ExecStart=/usr/local/bin/ipam-agent
Environment=IPAM_SERVER_URL=http://ipam-server:8080
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now ipam-agent

systemd 재시작 정책

Restart=always를 쓰면 에이전트가 예외로 종료돼도 자동 재시작된다. RestartSec=5는 재시작 전 대기 시간이다. 네트워크가 잠깐 끊겼다가 복구될 때 바로 heartbeat를 재개한다.


go.mod

외부 의존성이 없다. 표준 라이브러리만 쓴다.

module ipam-agent

go 1.22

net/http, encoding/json, net, os, time — 전부 표준 라이브러리다. 의존성 관리가 필요 없어서 배포가 간단하다.


Node.js vs Go 비교

항목 Node.js + pkg Go
바이너리 크기 ~45 MB ~6 MB
런타임 필요 없음 (번들) 없음
크로스 컴파일 타겟별 다른 빌드 환경변수 2개
외부 의존성 axios 없음
빌드 시간 1~2분 (pkg 번들링) 수초
코드 복잡도 낮음 낮음

에이전트처럼 단순한 로직을 반복 실행하는 프로그램에는 Go가 훨씬 적합하다.


다음 편에서는 Spring Boot 백엔드에서 heartbeat를 처리하고, 이상을 감지해서 Slack으로 알림을 보내는 로직을 다룬다.