사내 인사정보 관리 시스템 만들기 - 배치 변동 감지 + 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
1