배치 수집 + 변동 감지

전체 흐름

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가 비어있다. 수동으로 한 번 실행해줘야 한다.

docker exec insa-server node -e "require('./src/batch').runBatch().catch(console.error)"

React + AG Grid UI

AG Grid 기본 세팅

AG Grid Community를 설치한다.

npm install ag-grid-community ag-grid-react

기본 사용법:

import { AgGridReact } from 'ag-grid-react';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';

function UserGrid() {
  const columnDefs = [
    { field: 'usName',     headerName: '이름',   width: 100 },
    { field: 'deptName',   headerName: '부서',   flex: 1 },
    { field: 'usRollName', headerName: '직책',   width: 100 },
    { field: 'usPosName',  headerName: '직위',   width: 100 },
    { field: 'usMail1',    headerName: '이메일', flex: 1 },
  ];

  return (
    <div className="ag-theme-alpine" style={{ height: 600 }}>
      <AgGridReact
        rowData={users}
        columnDefs={columnDefs}
        pagination={true}
        paginationPageSize={50}
      />
    </div>
  );
}

AG Grid v33 테마 충돌

AG Grid를 설치하고 실행하면 아래 경고가 뜨면서 스타일이 깨진다.

AG Grid: As of v33, the grid uses a new Theming API by default.
CSS file imports (ag-theme-alpine.css etc.) are not compatible...

v33부터 Theming API 방식이 기본으로 바뀌었는데, 기존 CSS 파일 import 방식과 충돌한다.

해결: theme="legacy" prop을 추가하면 기존 방식으로 동작한다.

<AgGridReact
  theme="legacy"        //  이거 추가
  rowData={users}
  columnDefs={columnDefs}
  pagination={true}
  paginationPageSize={50}
/>

필터링

상단에 부서/이름/직책 필터를 두고, 조합 필터링이 되도록 구현했다.

const [filters, setFilters] = useState({ dept: '', name: '', role: '' });

const filtered = useMemo(() => {
  return users.filter(u =>
    (!filters.dept || u.deptName?.includes(filters.dept)) &&
    (!filters.name || u.usName?.includes(filters.name)) &&
    (!filters.role || u.usRollName?.includes(filters.role))
  );
}, [users, filters]);

TanStack Query로 서버 상태 관리

TanStack Query를 쓰면 로딩/에러/캐싱/리페치를 직접 관리하지 않아도 된다.

npm install @tanstack/react-query

인사 목록 조회

// api.js
export const fetchUsers = (params) =>
  fetch(`/api/users?${new URLSearchParams(params)}`).then(r => r.json());
// UserGrid.jsx
const { data: users = [], isLoading, isError } = useQuery({
  queryKey: ['users', filters],
  queryFn: () => fetchUsers(filters),
  staleTime: 1000 * 60 * 5,  // 5분간 캐시 유지
});

if (isLoading) return <div>로딩 중...</div>;
if (isError)   return <div>데이터를 불러올 수 없습니다.</div>;

수정 뮤테이션

const queryClient = useQueryClient();

const updateMutation = useMutation({
  mutationFn: ({ usId, data }) =>
    fetch(`/api/users/${usId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    }).then(r => r.json()),

  onSuccess: () => {
    // 수정 후 목록 자동 갱신
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});

invalidateQueries로 수정 완료 후 목록을 자동으로 다시 가져온다. 상태를 직접 업데이트할 필요가 없다.


CSV 다운로드

function downloadCsv(rows, filename) {
  const headers = ['이름', '부서', '직책', '직위', '이메일', '휴대폰', '내선번호'];
  const fields  = ['usName', 'deptName', 'usRollName', 'usPosName', 'usMail1', 'usCellno', 'usTelno'];

  const csv = [
    headers.join(','),
    ...rows.map(row =>
      fields.map(f => `"${(row[f] ?? '').replace(/"/g, '""')}"`).join(',')
    ),
  ].join('\n');

  const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

'\uFEFF' (BOM)을 앞에 붙여야 Excel에서 한글이 깨지지 않는다.


INSERT SQL 생성

현재 필터 조건에 해당하는 인원의 INSERT SQL을 생성해서 클립보드로 복사하는 기능이다. 다른 DB에 동일한 데이터를 넣어야 할 때 편하다.

// 서버: INSERT SQL 생성 API
app.post('/api/users/sql/insert', async (req, res) => {
  const { dept, name, role } = req.body;

  const users = await prisma.user.findMany({
    where: {
      deptName:   dept ? { contains: dept } : undefined,
      usName:     name ? { contains: name } : undefined,
      usRollName: role ? { contains: role } : undefined,
    },
  });

  const values = users.map(u =>
    `  ('${u.usId}', '${u.usName}', ${nullable(u.deptId)}, ${nullable(u.deptName)}, ` +
    `${nullable(u.usRollName)}, ${nullable(u.usPosName)}, ${nullable(u.usMail1)}, ` +
    `${nullable(u.usCellno)}, ${nullable(u.usTelno)})`
  ).join(',\n');

  const sql = `INSERT INTO users (us_id, us_name, dept_id, dept_name, us_roll_name, us_pos_name, us_mail1, us_cellno, us_telno)\nVALUES\n${values}\nON CONFLICT (us_id) DO UPDATE SET\n  us_name = EXCLUDED.us_name,\n  dept_name = EXCLUDED.dept_name,\n  us_roll_name = EXCLUDED.us_roll_name;`;

  res.json({ sql });
});

const nullable = v => v ? `'${v.replace(/'/g, "''")}'` : 'NULL';
// 클라이언트: 클립보드 복사
const { data } = useMutation({
  mutationFn: () => fetch('/api/users/sql/insert', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(filters),
  }).then(r => r.json()),
  onSuccess: ({ sql }) => {
    navigator.clipboard.writeText(sql);
    alert('SQL이 클립보드에 복사됐습니다.');
  },
});

다크/라이트 테마

CSS 변수 기반으로 구현했다. localStorage에 저장해서 새로고침 후에도 유지된다.

/* App.css */
:root {
  --bg: #ffffff;
  --surface: #f8f9fa;
  --text: #1a1a1a;
  --border: #e0e0e0;
}

[data-theme="dark"] {
  --bg: #1a1a1a;
  --surface: #242424;
  --text: #e0e0e0;
  --border: #3a3a3a;
}

body {
  background: var(--bg);
  color: var(--text);
}
// App.jsx
const [theme, setTheme] = useState(
  () => localStorage.getItem('theme') || 'light'
);

useEffect(() => {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
}, [theme]);

const toggleTheme = () =>
  setTheme(t => t === 'light' ? 'dark' : 'light');

인사 목록 탭 - AG Grid 테이블

변동 이력 탭


다음 편에서는 Docker Compose로 db → server → client 시작 순서를 보장하는 방법과 운영하면서 겪은 트러블슈팅을 다룬다.