사내 VM IP 관리 시스템 만들기 - React 대시보드 + Docker Compose 배포 (4편)

React 대시보드 대시보드는 탭 2개로 구성된다. VM 목록 — 전체 VM 현황, 상태별 필터, ONLINE/OFFLINE/UNKNOWN 배지 이벤트 로그 — IP 충돌, IP 변경, 오프라인 이벤트 타임라인 WebSocket이 아닌 30초 폴링으로 구현했다. VM 상태가 초 단위로 바뀌지 않고, 운영 대시보드 특성상 약간의 지연은 허용된다. 심플하게 가는 게 낫다고 판단했다. 30초 폴링 훅 // usePolling.js import { useEffect, useRef } from 'react'; export function usePolling(callback, intervalMs = 30_000) { const callbackRef = useRef(callback); useEffect(() => { callbackRef.current = callback; }); useEffect(() => { callbackRef.current(); // 마운트 시 즉시 1회 실행 const id = setInterval(() => callbackRef.current(), intervalMs); return () => clearInterval(id); }, [intervalMs]); } // App.jsx const [vms, setVms] = useState([]); const [loading, setLoading] = useState(true); usePolling(async () => { const data = await fetch('/api/vms').then(r => r.json()); setVms(data); setLoading(false); }, 30_000); callbackRef를 쓰는 이유는 setInterval 클로저가 최초 등록 시점의 callback을 계속 참조하는 문제를 피하기 위해서다. useRef로 항상 최신 콜백을 가리키도록 한다. ...

2026년 4월 15일 · 5 min · 958 words · Chanyeol

사내 VM IP 관리 시스템 만들기 - Spring Boot Heartbeat 처리 + 이상 감지 + Slack 알림 (3편)

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으로 바꾼다. ...

2026년 4월 14일 · 5 min · 988 words · Chanyeol

사내 VM IP 관리 시스템 만들기 - Node.js 에이전트 → Go 에이전트 전환 (2편)

처음엔 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); 동작은 잘 했다. 문제는 배포였다. ...

2026년 4월 13일 · 4 min · 731 words · Chanyeol

사내 VM IP 관리 시스템 만들기 - 기획 배경 + 전체 아키텍처 + DB 스키마 (1편)

왜 만들게 됐나 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편에서 다룬다. ...

2026년 4월 12일 · 4 min · 759 words · Chanyeol

사내 인사정보 관리 시스템 만들기 - Docker Compose 멀티 서비스 배포 + 트러블슈팅 (3편)

Docker Compose 멀티 서비스 구성 세 서비스가 올바른 순서로 떠야 한다. db (PostgreSQL) → server (Express + Prisma) → client (React + nginx) server가 DB보다 먼저 뜨면 Prisma 연결이 실패하고, client가 server보다 먼저 뜨면 API 프록시가 동작하지 않는다. docker-compose.yml services: db: image: postgres:16-alpine container_name: insa-db environment: POSTGRES_DB: unipost_insa POSTGRES_USER: insa POSTGRES_PASSWORD: insa1234 volumes: - ./data/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U insa -d unipost_insa"] interval: 5s timeout: 5s retries: 10 server: build: ./server container_name: insa-server ports: - "4000:4000" env_file: - ./server/.env environment: DATABASE_URL: postgresql://insa:insa1234@db:5432/unipost_insa depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:4000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped client: build: ./client container_name: insa-client ports: - "3002:80" depends_on: server: condition: service_healthy restart: unless-stopped depends_on에 condition: service_healthy를 쓰면 단순히 컨테이너가 “시작됐는지"가 아니라 “준비됐는지"를 확인하고 다음 서비스를 시작한다. ...

2026년 4월 11일 · 5 min · 862 words · Chanyeol

사내 인사정보 관리 시스템 만들기 - 배치 변동 감지 + React AG Grid UI (2편)

배치 수집 + 변동 감지 전체 흐름 API에서 전체 인원 목록 가져오기 │ DB 현재 상태와 비교 │ ├─ DB에 없는 usId → JOIN (입사) ├─ API에 없는 usId → LEAVE (퇴사) └─ 값이 다른 필드 → DEPT_CHANGE / ROLE_CHANGE │ ▼ 변동 건 → user_history 적재 현재 상태 → users 테이블 upsert 배치 구현 async function runBatch() { console.log(`[배치] 시작: ${new Date().toLocaleString('ko-KR')}`); // 1. API에서 전체 인원 가져오기 const apiUsers = await fetchAllUsers(); console.log(`[배치] API 응답: ${apiUsers.length}명`); // 2. DB 현재 상태 가져오기 const dbUsers = await prisma.user.findMany(); const dbMap = new Map(dbUsers.map(u => [u.usId, u])); const apiMap = new Map(apiUsers.map(u => [u.usId, u])); const historyRecords = []; // 3. 입사 감지 (API에 있고 DB에 없음) for (const [usId, apiUser] of apiMap) { if (!dbMap.has(usId)) { historyRecords.push({ usId, changeType: 'JOIN', fieldName: null, oldValue: null, newValue: apiUser.usName, }); } } // 4. 퇴사 감지 (DB에 있고 API에 없음) for (const [usId, dbUser] of dbMap) { if (!apiMap.has(usId)) { historyRecords.push({ usId, changeType: 'LEAVE', fieldName: null, oldValue: dbUser.usName, newValue: null, }); } } // 5. 변동 감지 (둘 다 있는데 값이 다름) const TRACKED_FIELDS = ['deptName', 'usRollName', 'usPosName']; for (const [usId, apiUser] of apiMap) { const dbUser = dbMap.get(usId); if (!dbUser) continue; for (const field of TRACKED_FIELDS) { const oldVal = dbUser[field] ?? null; const newVal = apiUser[field] ?? null; if (oldVal !== newVal) { historyRecords.push({ usId, changeType: field === 'deptName' ? 'DEPT_CHANGE' : 'ROLE_CHANGE', fieldName: field, oldValue: oldVal, newValue: newVal, }); } } } // 6. 이력 적재 if (historyRecords.length > 0) { await prisma.userHistory.createMany({ data: historyRecords }); } // 7. 현재 상태 upsert for (const user of apiUsers) { await prisma.user.upsert({ where: { usId: user.usId }, update: { ...user, updatedAt: new Date() }, create: user, }); } // 8. 퇴사자 DB에서 제거 (선택) const leaveIds = [...dbMap.keys()].filter(id => !apiMap.has(id)); if (leaveIds.length > 0) { await prisma.user.deleteMany({ where: { usId: { in: leaveIds } } }); } console.log( `[배치] 완료 — 입사 ${historyRecords.filter(h => h.changeType === 'JOIN').length}, ` + `퇴사 ${historyRecords.filter(h => h.changeType === 'LEAVE').length}, ` + `변동 ${historyRecords.filter(h => !['JOIN','LEAVE'].includes(h.changeType)).length}` ); } 배치 스케줄 // 매일 오전 1시 실행 (TZ=Asia/Seoul 필수) cron.schedule('0 1 * * *', () => { runBatch().catch(err => console.error('[배치] 오류:', err)); }); 최초 배포 시에는 배치가 돌 때까지 DB가 비어있다. 수동으로 한 번 실행해줘야 한다. ...

2026년 4월 10일 · 6 min · 1076 words · Chanyeol

사내 인사정보 관리 시스템 만들기 - 아키텍처 + Prisma 스키마 설계 (1편)

왜 만들게 됐나 사내 그룹웨어에는 인사 정보가 있지만 조회 UI가 불편하고, 부서 이동이나 입퇴사 같은 변동 이력을 추적하는 기능이 없었다. 직접 관리하고 싶은 데이터가 생겼을 때 그때그때 SQL을 뽑아 쓸 수 있는 환경도 필요했다. 그래서 만들었다. 그룹웨어 API에서 인사 데이터를 매일 자동 수집 이전 상태와 비교해서 입사/퇴사/부서이동/직책변경 자동 감지 React 웹 UI에서 조회·수정·삭제, CSV 다운로드, INSERT SQL 생성 기술 스택 선택 영역 기술 선택 이유 백엔드 Node.js 20 + Express 기존 Slack 봇과 동일 스택 ORM Prisma 타입 안전한 쿼리, 마이그레이션 자동화 DB PostgreSQL 16 JSON 파일로는 한계, 이력 테이블 필요 스케줄러 node-cron 매일 1시 배치 실행 프론트엔드 React 18 + Vite 빠른 개발 환경 그리드 AG Grid Community 대용량 데이터 필터/정렬/페이지네이션 서버 상태 TanStack Query API 캐싱, 로딩/에러 상태 관리 인프라 Docker Compose 3개 서비스(DB, 서버, 클라이언트) 일괄 관리 기존 Slack 봇은 DB 없이 JSON 파일로 상태를 관리했는데, 인사 데이터는 수백 명 규모에 이력까지 쌓아야 하니 PostgreSQL이 필요했다. ...

2026년 4월 9일 · 4 min · 760 words · Chanyeol

사내 Slack 봇 만들기 - 관리자 페이지 + Docker 배포 + 트러블슈팅 (4편)

왜 관리자 페이지가 필요한가 처음엔 팀 Webhook이 하나라 teams.json을 직접 수정해도 됐다. 그런데 팀이 늘어나고, 알림 타입을 팀별로 다르게 설정하고 싶어지고, 출근 알림 대상 멤버도 자주 바뀌면서 파일을 직접 건드리는 게 너무 번거로워졌다. 그래서 관리자 페이지를 만들었다. React 같은 프레임워크 없이 Express + 바닐라 JS로 만들었다. 관리자 혼자 쓰는 내부 툴이라 빌드 파이프라인 없이 심플하게 가는 게 낫다고 판단했다. 관리자 페이지 구성 패널 4개로 구성된다. 패널 기능 팀·Webhook 팀 추가/수정/삭제, 활성 토글, 알림 타입 배지 토글, 테스트 발송 출근알림 멤버 멤버 추가/수정/삭제, Slack 이름 자동완성 출근현황 카드뷰 출근/미출근 현황, 수동 알림 발송 크론 관리 크론 활성화 토글, 스케줄 표현식 수정 설정 Slack Bot Token 관리 팀·Webhook 패널 팀별로 6가지 알림 타입을 배지 형태로 토글할 수 있다. ...

2026년 4월 8일 · 4 min · 821 words · Chanyeol

사내 Slack 봇 만들기 - 스냅샷 비교 변동 감지 + 스케줄러 + Slack 알림 (3편)

핵심 아이디어: 스냅샷 비교 변동 감지의 핵심은 단순하다. “방금 가져온 데이터"와 “지난번에 저장해둔 데이터"를 비교한다. 새로 생긴 항목 → 추가 알림 사라진 항목 → 취소 알림 이 상태를 snapshot.json에 저장해두면 서버가 재시작돼도 이전 상태를 그대로 복구할 수 있다. 스냅샷 구조 { "vacations": [ { "id": "unique-key", "name": "홍길동", "date": "2026-04-03", "type": "연차" } ], "rooms": [ { "id": "unique-key", "title": "주간 회의", "room": "중회의실", "start": "2026-04-03T10:00:00", "end": "2026-04-03T11:00:00" } ], "updatedAt": "2026-04-03T08:50:00.000Z" } 변동 감지 로직 function detectChanges(prev, curr, keyFn) { const prevMap = new Map(prev.map(item => [keyFn(item), item])); const currMap = new Map(curr.map(item => [keyFn(item), item])); const added = curr.filter(item => !prevMap.has(keyFn(item))); const removed = prev.filter(item => !currMap.has(keyFn(item))); return { added, removed }; } keyFn으로 각 항목의 고유 키를 만든다. 휴가는 사번+날짜, 회의실은 예약ID 같은 식이다. ...

2026년 4월 7일 · 5 min · 937 words · Chanyeol

사내 Slack 봇 만들기 - SSO 세션 처리 + 내부 API 연동 (2편)

문제: 그룹웨어 API를 어떻게 호출하나 사내 그룹웨어는 브라우저에서 로그인한 세션 쿠키로 API를 호출하는 구조다. 공개 API키 같은 게 없고, 그냥 브라우저처럼 로그인해서 쿠키를 들고 API를 쳐야 한다. Node.js에서 이걸 하려면 axios로 로그인 과정을 그대로 흉내내야 한다. SSO 로그인 흐름 대부분의 사내 SSO는 아래 패턴을 따른다. 1단계: 로그인 페이지 GET → 초기 세션 쿠키 획득 2단계: 로그인 form POST → 인증 처리 3단계: SSO 토큰 발급 페이지 GET → 리다이렉트로 토큰 획득 4단계: 대상 도메인에 토큰으로 접근 → 해당 도메인 세션 쿠키 획득 핵심은 각 단계에서 받은 쿠키를 다음 요청에 그대로 넘겨줘야 한다는 것이다. ...

2026년 4월 6일 · 4 min · 733 words · Chanyeol
1