왜 만들게 됐나
사내 그룹웨어에는 인사 정보가 있지만 조회 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이 필요했다.
AG Grid를 선택한 이유는 단순하다. 수백 건 데이터를 부서/이름/직책 기준으로 빠르게 필터링하고 정렬하는 기능을 직접 구현하기엔 공수가 크다. Community 버전이 무료라 부담 없이 쓸 수 있었다.
전체 아키텍처
[그룹웨어 API]
│
│ 매일 01:00 배치 실행
▼
[Node.js 서버]
│
├─ 인사 데이터 수집
├─ DB 비교 → 변동 감지 (입사/퇴사/부서이동/직책변경)
├─ REST API (/api/users, /api/history, ...)
└─ 크론 스케줄러
│
▼
[PostgreSQL]
│
▼
[React 클라이언트] ← nginx (API 프록시 + SPA 라우팅)
Docker Compose로 세 서비스를 한 번에 관리한다.
| 서비스 | 포트 | 역할 |
|---|---|---|
| db | 5432 | PostgreSQL 16 |
| server | 4000 | Express API + 배치 크론 |
| client | 3002 | React (nginx 서빙) |
클라이언트의 nginx가 /api/ 요청을 server로 프록시하기 때문에 프론트에서 API URL을 하드코딩할 필요가 없다.
프로젝트 구조
unipost_insa/
├── docker-compose.yml
├── server/
│ ├── Dockerfile
│ ├── entrypoint.sh # DB 대기 → prisma db push → 서버 시작
│ ├── .env
│ ├── prisma/
│ │ └── schema.prisma
│ └── src/
│ ├── index.js # 서버 진입점 + 크론
│ ├── api.js # REST API 라우터
│ ├── batch.js # 인사 수집 + 변동 감지
│ └── login.js # SSO 로그인
└── client/
├── Dockerfile
├── nginx.conf
├── .env
└── src/
├── App.jsx
├── api.js
└── components/
├── UserGrid.jsx # AG Grid 테이블
├── UserModal.jsx # 추가/수정 모달
├── SqlModal.jsx # INSERT SQL 모달
└── HistoryPanel.jsx # 변동 이력 패널
Prisma 스키마 설계
테이블 구조
인사 데이터는 두 테이블로 관리한다.
- users — 현재 인사 정보 (최신 상태만 유지)
- user_history — 변동 이력 (변동이 생길 때마다 누적)
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
usId String @unique @map("us_id")
usName String @map("us_name")
deptId String? @map("dept_id")
deptName String? @map("dept_name")
usRollName String? @map("us_roll_name")
usPosName String? @map("us_pos_name")
usMail1 String? @map("us_mail1")
usCellno String? @map("us_cellno")
usTelno String? @map("us_telno")
chiefYn String? @map("chief_yn")
chiefUsId String? @map("chief_us_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
history UserHistory[]
@@map("users")
}
model UserHistory {
id Int @id @default(autoincrement())
usId String @map("us_id")
changeType String @map("change_type") // JOIN | LEAVE | DEPT_CHANGE | ROLE_CHANGE
fieldName String? @map("field_name")
oldValue String? @map("old_value")
newValue String? @map("new_value")
detectedAt DateTime @default(now()) @map("detected_at")
user User @relation(fields: [usId], references: [usId])
@@map("user_history")
}
설계 결정: 왜 현재 상태와 이력을 분리했나
한 테이블에 모든 이력을 쌓는 방식도 있지만, 두 테이블로 분리한 이유가 있다.
현재 상태 조회 성능: 인사 목록 조회는 항상 “현재” 데이터만 보면 된다. 이력이 수천 건 쌓여도 users 테이블은 항상 현재 인원 수만큼만 유지된다.
이력 추적 명확성: user_history에는 무슨 필드가 어떤 값에서 어떤 값으로 바뀌었는지가 명시적으로 기록된다. 쿼리 없이 바로 읽을 수 있다.
관계 명확성: usId로 현재 정보와 이력을 연결한다. 특정 사람의 전체 변동 이력을 한 번에 조회할 수 있다.
changeType 설계
const CHANGE_TYPES = {
JOIN: 'JOIN', // 신규 입사 (DB에 없던 usId 등장)
LEAVE: 'LEAVE', // 퇴사 (API 응답에서 usId 사라짐)
DEPT_CHANGE: 'DEPT_CHANGE', // 부서 이동
ROLE_CHANGE: 'ROLE_CHANGE', // 직책/직위 변경
};
DEPT_CHANGE와 ROLE_CHANGE는 fieldName, oldValue, newValue에 구체적으로 뭐가 바뀌었는지 기록한다.
fieldName: "deptName"
oldValue: "개발1팀"
newValue: "개발2팀"
binaryTargets 설정
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
이 설정이 없으면 Docker 배포 시 문제가 생긴다. 로컬(macOS/Windows)에서 생성된 Prisma Client는 Linux 환경에서 동작하지 않는다. debian-openssl-3.0.x를 추가하면 Linux 컨테이너에서도 정상 동작하는 바이너리가 함께 생성된다.
REST API 목록
| Method | Path | 설명 |
|---|---|---|
| GET | /api/users |
인사 목록 (dept, name, role 쿼리 필터) |
| GET | /api/users/:usId |
단건 조회 |
| POST | /api/users |
수동 추가 |
| PUT | /api/users/:usId |
수정 |
| DELETE | /api/users/:usId |
삭제 |
| GET | /api/history |
변동 이력 (usId, type 쿼리 필터) |
| GET | /api/depts |
부서 목록 |
| POST | /api/users/sql/insert |
필터 기반 INSERT SQL 생성 |
| GET | /health |
헬스체크 |
/health 엔드포인트는 Docker Compose healthcheck에서 server가 준비됐는지 확인하는 데 쓴다. 3편에서 자세히 다룬다.
다음 편에서는 그룹웨어 API에서 인사 데이터를 수집해서 DB와 비교하고, 변동을 감지하는 배치 로직과 React + AG Grid로 관리 UI를 만드는 과정을 다룬다.