사내 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 관리 시스템 만들기 - 기획 배경 + 전체 아키텍처 + 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

노트북으로 홈서버 구축하기 - PostgreSQL 자동 백업 (pg_dump + cron) (10편)

왜 백업이 필요한가 Immich에 사진을 올리기 시작하면서 DB가 날아가면 복구할 방법이 없다는 게 갑자기 걱정됐다. 사진 원본은 /mnt/data에 있으니 파일은 살아있더라도 Immich DB가 날아가면 앨범, 태그, 얼굴 인식 데이터가 전부 사라진다. 백업 전략은 단순하게 잡았다. 대상 방법 위치 Immich DB pg_dumpall /backup/immich_db_YYYYMMDD.sql 사진 원본 추후 rsync 추가 예정 외장하드 구매 후 OS SSD에 204GB 여유가 있어서 DB 덤프는 우선 거기에 보관한다. 백업 디렉토리 생성 sudo mkdir -p /backup sudo chown your-username:your-username /backup chown으로 소유권을 넘겨줘야 한다. 처음에 sudo mkdir만 하고 스크립트를 실행했더니 아래 에러가 났다. ...

2026년 4월 1일 · 2 min · 355 words · Chanyeol

Spring AI 프로젝트 마무리: 로컬 LLM 챗봇의 한계와 향후 발전 로드맵 (7편)

[Dev-Fortune] 시리즈 다시보기 1편: 기획부터 스택 선정까지 2편: 로컬 LLM Ollama 연동 3편: RAG와 Vector Store 구축 4편: 프롬프트 엔지니어링 실전 5편: 스트리밍 API 구현 6편: 전체 워크플로우 분석 1. 서론: 프로젝트를 마무리하며 시니컬한 개발자 챗봇 ‘Dev-Fortune’을 통해 Spring AI와 RAG의 가능성을 엿보았습니다. 마지막으로 이 프로젝트의 한계를 짚어보고 고도화 로드맵을 그려봅니다. 2. 미래 고도화 로드맵 (AS-IS vs TO-BE) 현재의 메모리 기반 구조에서 영구 저장소와 맥락 인지 능력을 갖춘 시스템으로의 진화 방향입니다. ...

2026년 3월 23일 · 1 min · 164 words · Chanyeol

PostgreSQL Full Text Search를 활용한 강력한 검색 기능 구현하기

1. 검색의 중요성: LIKE %keyword%의 한계 데이터가 많아질수록 데이터베이스의 LIKE 연산자는 성능 저하의 주범이 됩니다. 인덱스를 탈 수 없기 때문입니다. 하지만 별도의 검색 엔진(Elasticsearch 등)을 도입하기 부담스러운 규모라면, PostgreSQL이 제공하는 **Full Text Search(FTS)**는 매우 훌륭한 대안이 됩니다. 2. PostgreSQL FTS의 핵심 개념 tsvector: 검색 대상이 되는 텍스트를 단어 단위로 쪼개어(Lexemes) 저장하는 전용 타입. tsquery: 검색어에 대한 논리 연산(AND, OR, NOT 등)을 수행하는 타입. GIN Index: tsvector 전용 인덱스로, 대규모 데이터에서도 빠른 검색 속도를 보장. 3. 기본 검색 쿼리 예제 문장에서 특정 단어를 찾는 가장 단순한 형태입니다. ...

2026년 3월 5일 · 1 min · 198 words · Chanyeol
1