왜 만들게 됐나

사내 그룹웨어에는 인사 정보가 있지만 조회 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_CHANGEROLE_CHANGEfieldName, 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를 만드는 과정을 다룬다.