Chanyeol Dev

Java, Spring, Infra 분야의 기술 블로그입니다.

사내 인사정보 관리 시스템 만들기 - 배치 변동 감지 + 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가 비어있다. 수동으로 한 번 실행해줘야 한다. ...

April 10, 2026 · 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이 필요했다. ...

April 9, 2026 · 4 min · 760 words · Chanyeol

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

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

April 8, 2026 · 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 같은 식이다. ...

April 7, 2026 · 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단계: 대상 도메인에 토큰으로 접근 → 해당 도메인 세션 쿠키 획득 핵심은 각 단계에서 받은 쿠키를 다음 요청에 그대로 넘겨줘야 한다는 것이다. ...

April 6, 2026 · 4 min · 733 words · Chanyeol

사내 Slack 봇 만들기 - 기획 배경 + 전체 아키텍처 (1편)

왜 만들게 됐나 조직 개편 전에는 팀원들이 한 채널에 모여 있어서 누가 휴가인지, 어떤 회의실이 예약됐는지 슬쩍 보면 알 수 있었다. 개편 이후 팀이 분리되면서 연계 채널이 새로 생겼다. 그런데 서로 다른 팀 채널에 있다 보니 상대 팀의 휴가·회의실 정보를 알기가 불편해졌다. 매번 그룹웨어에 들어가서 확인하는 게 번거로웠다. 그래서 만들었다. 매일 아침 Slack으로 당일 휴가자와 회의실 예약 현황을 자동으로 보내주는 봇. 만들다 보니 기능이 붙었다. 출근 미등록자 DM 알림, 슬래시 커맨드로 날짜별 조회, 관리자 웹 페이지까지. ...

April 5, 2026 · 3 min · 468 words · Chanyeol

노트북 한 대로 홈서버 구축하기 - 12편 완전 정복 총정리

시작은 단순했다 클라우드 비용이 아깝고, NAS도 필요하고, 사이드 프로젝트 서버도 있으면 좋겠고. 마침 집에 안 쓰는 ThinkPad가 있었다. 그렇게 시작된 홈서버 구축기가 어느새 12편이 됐다. 최종 구성 기기: ThinkPad E15 Gen3 (Ryzen 5 5600U, RAM 16GB) OS: Ubuntu Server 24.04 LTS 네트워크: Tailscale VPN + OCI Nginx 리버스 프록시 도메인: yourdomain.com (Cloudflare) 스토리지: 256GB SSD (OS/Docker) + 1TB SSD (/mnt/data, NTFS) 운영 중인 서비스 전체 목록 포트 서비스 접근 방식 2283 Immich (사진 관리) 도메인 (OCI 프록시) 9090 Filebrowser (파일 관리) 도메인 (OCI 프록시) 11000 Vaultwarden (비밀번호) 도메인 (OCI 프록시) 13000 Grafana (모니터링) Tailscale VPN 19000 Portainer (Docker GUI) Tailscale VPN 19090 Prometheus Tailscale VPN 19100 Node Exporter 내부 수집용 23000 컨테이너 대시보드 (React) Tailscale VPN 28080 컨테이너 대시보드 (Spring) Tailscale VPN 12편 한눈에 보기 1편 — 왜 홈서버인가? + 전체 아키텍처 → 보러가기 ...

April 4, 2026 · 3 min · 504 words · Chanyeol

노트북으로 홈서버 구축하기 - 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE) (12편)

왜 직접 만들었나 Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다. Portainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다. 완성 화면 컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지) Start / Stop / Restart 원클릭 제어 Logs 버튼으로 실시간 로그 스트리밍 (SSE) 5초 주기 자동 갱신 ...

April 3, 2026 · 5 min · 978 words · Chanyeol

노트북으로 홈서버 구축하기 - TLP + thinkfan + Swap 튜닝으로 운영 최적화 (11편)

두 가지 문제 홈서버를 며칠 돌려보니 두 가지가 눈에 띄었다. Immich 썸네일 생성 같은 작업이 걸리면 CPU 온도가 85°C까지 치솟는다. 24시간 켜두는 서버라 장기적으로 하드웨어에 좋지 않다. Grafana 대시보드를 보니 RAM 사용률이 38%인데 Swap을 26%나 사용하고 있었다. RAM이 절반도 안 찼는데 Swap을 쓰는 건 비정상이다. 두 문제를 각각 TLP + thinkfan, swappiness 튜닝으로 해결했다. 1부: TLP + thinkfan으로 온도 낮추기 TLP 설치 TLP는 Linux용 전력 관리 도구다. 설치만 해도 기본값으로 어느 정도 효과가 있고, ThinkPad에 맞게 튜닝하면 훨씬 효과적이다. ...

April 2, 2026 · 3 min · 576 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만 하고 스크립트를 실행했더니 아래 에러가 났다. ...

April 1, 2026 · 2 min · 355 words · Chanyeol
1