<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Ag-Grid on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/ag-grid/</link>
    <description>Recent content in Ag-Grid on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Fri, 10 Apr 2026 11:00:00 +0900</lastBuildDate>
    <atom:link href="https://chanyeols.com/tags/ag-grid/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>사내 인사정보 관리 시스템 만들기 - 배치 변동 감지 &#43; React AG Grid UI (2편)</title>
      <link>https://chanyeols.com/posts/insa-02-batch-agrid/</link>
      <pubDate>Fri, 10 Apr 2026 11:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/insa-02-batch-agrid/</guid>
      <description>DB 비교로 입사/퇴사/부서이동을 자동 감지하는 배치 로직과 AG Grid &#43; TanStack Query로 인사 관리 UI를 만든 과정을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="배치-수집--변동-감지">배치 수집 + 변동 감지</h2>
<h3 id="전체-흐름">전체 흐름</h3>
<pre tabindex="0"><code>API에서 전체 인원 목록 가져오기
        │
DB 현재 상태와 비교
        │
        ├─ DB에 없는 usId → JOIN (입사)
        ├─ API에 없는 usId → LEAVE (퇴사)
        └─ 값이 다른 필드 → DEPT_CHANGE / ROLE_CHANGE
        │
        ▼
변동 건 → user_history 적재
현재 상태 → users 테이블 upsert
</code></pre><h3 id="배치-구현">배치 구현</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> runBatch() {
</span></span><span style="display:flex;"><span>  console.log(<span style="color:#a5d6ff">`[배치] 시작: </span><span style="color:#a5d6ff">${</span><span style="color:#ff7b72">new</span> Date().toLocaleString(<span style="color:#a5d6ff">&#39;ko-KR&#39;</span>)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 1. API에서 전체 인원 가져오기
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> apiUsers <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetchAllUsers();
</span></span><span style="display:flex;"><span>  console.log(<span style="color:#a5d6ff">`[배치] API 응답: </span><span style="color:#a5d6ff">${</span>apiUsers.length<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">명`</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 2. DB 현재 상태 가져오기
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> dbUsers <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> prisma.user.findMany();
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> dbMap <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Map(dbUsers.map(u =&gt; [u.usId, u]));
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> apiMap <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Map(apiUsers.map(u =&gt; [u.usId, u]));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> historyRecords <span style="color:#ff7b72;font-weight:bold">=</span> [];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 3. 입사 감지 (API에 있고 DB에 없음)
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> [usId, apiUser] <span style="color:#ff7b72">of</span> apiMap) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72;font-weight:bold">!</span>dbMap.has(usId)) {
</span></span><span style="display:flex;"><span>      historyRecords.push({
</span></span><span style="display:flex;"><span>        usId,
</span></span><span style="display:flex;"><span>        changeType<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;JOIN&#39;</span>,
</span></span><span style="display:flex;"><span>        fieldName<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">null</span>,
</span></span><span style="display:flex;"><span>        oldValue<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">null</span>,
</span></span><span style="display:flex;"><span>        newValue<span style="color:#ff7b72;font-weight:bold">:</span> apiUser.usName,
</span></span><span style="display:flex;"><span>      });
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 4. 퇴사 감지 (DB에 있고 API에 없음)
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> [usId, dbUser] <span style="color:#ff7b72">of</span> dbMap) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72;font-weight:bold">!</span>apiMap.has(usId)) {
</span></span><span style="display:flex;"><span>      historyRecords.push({
</span></span><span style="display:flex;"><span>        usId,
</span></span><span style="display:flex;"><span>        changeType<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;LEAVE&#39;</span>,
</span></span><span style="display:flex;"><span>        fieldName<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">null</span>,
</span></span><span style="display:flex;"><span>        oldValue<span style="color:#ff7b72;font-weight:bold">:</span> dbUser.usName,
</span></span><span style="display:flex;"><span>        newValue<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">null</span>,
</span></span><span style="display:flex;"><span>      });
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 5. 변동 감지 (둘 다 있는데 값이 다름)
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> TRACKED_FIELDS <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#a5d6ff">&#39;deptName&#39;</span>, <span style="color:#a5d6ff">&#39;usRollName&#39;</span>, <span style="color:#a5d6ff">&#39;usPosName&#39;</span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> [usId, apiUser] <span style="color:#ff7b72">of</span> apiMap) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> dbUser <span style="color:#ff7b72;font-weight:bold">=</span> dbMap.get(usId);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72;font-weight:bold">!</span>dbUser) <span style="color:#ff7b72">continue</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> field <span style="color:#ff7b72">of</span> TRACKED_FIELDS) {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> oldVal <span style="color:#ff7b72;font-weight:bold">=</span> dbUser[field] <span style="color:#ff7b72;font-weight:bold">??</span> <span style="color:#79c0ff">null</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> newVal <span style="color:#ff7b72;font-weight:bold">=</span> apiUser[field] <span style="color:#ff7b72;font-weight:bold">??</span> <span style="color:#79c0ff">null</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (oldVal <span style="color:#ff7b72;font-weight:bold">!==</span> newVal) {
</span></span><span style="display:flex;"><span>        historyRecords.push({
</span></span><span style="display:flex;"><span>          usId,
</span></span><span style="display:flex;"><span>          changeType<span style="color:#ff7b72;font-weight:bold">:</span> field <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;deptName&#39;</span> <span style="color:#ff7b72;font-weight:bold">?</span> <span style="color:#a5d6ff">&#39;DEPT_CHANGE&#39;</span> <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;ROLE_CHANGE&#39;</span>,
</span></span><span style="display:flex;"><span>          fieldName<span style="color:#ff7b72;font-weight:bold">:</span> field,
</span></span><span style="display:flex;"><span>          oldValue<span style="color:#ff7b72;font-weight:bold">:</span> oldVal,
</span></span><span style="display:flex;"><span>          newValue<span style="color:#ff7b72;font-weight:bold">:</span> newVal,
</span></span><span style="display:flex;"><span>        });
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 6. 이력 적재
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (historyRecords.length <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">0</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> prisma.userHistory.createMany({ data<span style="color:#ff7b72;font-weight:bold">:</span> historyRecords });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 7. 현재 상태 upsert
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> user <span style="color:#ff7b72">of</span> apiUsers) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> prisma.user.upsert({
</span></span><span style="display:flex;"><span>      where<span style="color:#ff7b72;font-weight:bold">:</span> { usId<span style="color:#ff7b72;font-weight:bold">:</span> user.usId },
</span></span><span style="display:flex;"><span>      update<span style="color:#ff7b72;font-weight:bold">:</span> { ...user, updatedAt<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">new</span> Date() },
</span></span><span style="display:flex;"><span>      create<span style="color:#ff7b72;font-weight:bold">:</span> user,
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 8. 퇴사자 DB에서 제거 (선택)
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> leaveIds <span style="color:#ff7b72;font-weight:bold">=</span> [...dbMap.keys()].filter(id =&gt; <span style="color:#ff7b72;font-weight:bold">!</span>apiMap.has(id));
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (leaveIds.length <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">0</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> prisma.user.deleteMany({ where<span style="color:#ff7b72;font-weight:bold">:</span> { usId<span style="color:#ff7b72;font-weight:bold">:</span> { <span style="color:#ff7b72">in</span><span style="color:#ff7b72;font-weight:bold">:</span> leaveIds } } });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  console.log(
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">`[배치] 완료 — 입사 </span><span style="color:#a5d6ff">${</span>historyRecords.filter(h =&gt; h.changeType <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;JOIN&#39;</span>).length<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, `</span> <span style="color:#ff7b72;font-weight:bold">+</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">`퇴사 </span><span style="color:#a5d6ff">${</span>historyRecords.filter(h =&gt; h.changeType <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;LEAVE&#39;</span>).length<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, `</span> <span style="color:#ff7b72;font-weight:bold">+</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">`변동 </span><span style="color:#a5d6ff">${</span>historyRecords.filter(h =&gt; <span style="color:#ff7b72;font-weight:bold">!</span>[<span style="color:#a5d6ff">&#39;JOIN&#39;</span>,<span style="color:#a5d6ff">&#39;LEAVE&#39;</span>].includes(h.changeType)).length<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="배치-스케줄">배치 스케줄</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 매일 오전 1시 실행 (TZ=Asia/Seoul 필수)
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;0 1 * * *&#39;</span>, () =&gt; {
</span></span><span style="display:flex;"><span>  runBatch().<span style="color:#ff7b72">catch</span>(err =&gt; console.error(<span style="color:#a5d6ff">&#39;[배치] 오류:&#39;</span>, err));
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>최초 배포 시에는 배치가 돌 때까지 DB가 비어있다. 수동으로 한 번 실행해줘야 한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec insa-server node -e <span style="color:#a5d6ff">&#34;require(&#39;./src/batch&#39;).runBatch().catch(console.error)&#34;</span>
</span></span></code></pre></div><hr>
<h2 id="react--ag-grid-ui">React + AG Grid UI</h2>
<h3 id="ag-grid-기본-세팅">AG Grid 기본 세팅</h3>
<p>AG Grid Community를 설치한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>npm install ag-grid-community ag-grid-react
</span></span></code></pre></div><p>기본 사용법:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { AgGridReact } from <span style="color:#a5d6ff">&#39;ag-grid-react&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#a5d6ff">&#39;ag-grid-community/styles/ag-grid.css&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#a5d6ff">&#39;ag-grid-community/styles/ag-theme-alpine.css&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">function</span> UserGrid() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> columnDefs <span style="color:#ff7b72;font-weight:bold">=</span> [
</span></span><span style="display:flex;"><span>    { field<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;usName&#39;</span>,     headerName<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;이름&#39;</span>,   width<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">100</span> },
</span></span><span style="display:flex;"><span>    { field<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;deptName&#39;</span>,   headerName<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;부서&#39;</span>,   flex<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">1</span> },
</span></span><span style="display:flex;"><span>    { field<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;usRollName&#39;</span>, headerName<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;직책&#39;</span>,   width<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">100</span> },
</span></span><span style="display:flex;"><span>    { field<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;usPosName&#39;</span>,  headerName<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;직위&#39;</span>,   width<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">100</span> },
</span></span><span style="display:flex;"><span>    { field<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;usMail1&#39;</span>,    headerName<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;이메일&#39;</span>, flex<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">1</span> },
</span></span><span style="display:flex;"><span>  ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> (
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#7ee787">div</span> className<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;ag-theme-alpine&#34;</span> style<span style="color:#ff7b72;font-weight:bold">=</span>{{ height<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">600</span> }}&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">AgGridReact</span>
</span></span><span style="display:flex;"><span>        rowData<span style="color:#ff7b72;font-weight:bold">=</span>{users}
</span></span><span style="display:flex;"><span>        columnDefs<span style="color:#ff7b72;font-weight:bold">=</span>{columnDefs}
</span></span><span style="display:flex;"><span>        pagination<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#79c0ff">true</span>}
</span></span><span style="display:flex;"><span>        paginationPageSize<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#a5d6ff">50</span>}
</span></span><span style="display:flex;"><span>      /&gt;
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#7ee787">div</span>&gt;
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="ag-grid-v33-테마-충돌">AG Grid v33 테마 충돌</h3>
<p>AG Grid를 설치하고 실행하면 아래 경고가 뜨면서 스타일이 깨진다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>AG Grid: As of v33, the grid uses a new Theming API by default.
</span></span><span style="display:flex;"><span>CSS file imports (ag-theme-alpine.css etc.) are not compatible...
</span></span></code></pre></div><p>v33부터 Theming API 방식이 기본으로 바뀌었는데, 기존 CSS 파일 import 방식과 충돌한다.</p>
<p><strong>해결:</strong> <code>theme=&quot;legacy&quot;</code> prop을 추가하면 기존 방식으로 동작한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span>&lt;<span style="color:#7ee787">AgGridReact</span>
</span></span><span style="display:flex;"><span>  theme<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;legacy&#34;</span>        <span style="color:#f85149">//</span> <span style="color:#f85149">←</span> 이거 추가
</span></span><span style="display:flex;"><span>  rowData<span style="color:#ff7b72;font-weight:bold">=</span>{users}
</span></span><span style="display:flex;"><span>  columnDefs<span style="color:#ff7b72;font-weight:bold">=</span>{columnDefs}
</span></span><span style="display:flex;"><span>  pagination<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#79c0ff">true</span>}
</span></span><span style="display:flex;"><span>  paginationPageSize<span style="color:#ff7b72;font-weight:bold">=</span>{<span style="color:#a5d6ff">50</span>}
</span></span><span style="display:flex;"><span>/&gt;
</span></span></code></pre></div><h3 id="필터링">필터링</h3>
<p>상단에 부서/이름/직책 필터를 두고, 조합 필터링이 되도록 구현했다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [filters, setFilters] <span style="color:#ff7b72;font-weight:bold">=</span> useState({ dept<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;&#39;</span>, name<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;&#39;</span>, role<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;&#39;</span> });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> filtered <span style="color:#ff7b72;font-weight:bold">=</span> useMemo(() =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> users.filter(u =&gt;
</span></span><span style="display:flex;"><span>    (<span style="color:#ff7b72;font-weight:bold">!</span>filters.dept <span style="color:#ff7b72;font-weight:bold">||</span> u.deptName<span style="color:#ff7b72;font-weight:bold">?</span>.includes(filters.dept)) <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>    (<span style="color:#ff7b72;font-weight:bold">!</span>filters.name <span style="color:#ff7b72;font-weight:bold">||</span> u.usName<span style="color:#ff7b72;font-weight:bold">?</span>.includes(filters.name)) <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>    (<span style="color:#ff7b72;font-weight:bold">!</span>filters.role <span style="color:#ff7b72;font-weight:bold">||</span> u.usRollName<span style="color:#ff7b72;font-weight:bold">?</span>.includes(filters.role))
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>}, [users, filters]);
</span></span></code></pre></div><hr>
<h2 id="tanstack-query로-서버-상태-관리">TanStack Query로 서버 상태 관리</h2>
<p>TanStack Query를 쓰면 로딩/에러/캐싱/리페치를 직접 관리하지 않아도 된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>npm install @tanstack/react-query
</span></span></code></pre></div><h3 id="인사-목록-조회">인사 목록 조회</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// api.js
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">const</span> fetchUsers <span style="color:#ff7b72;font-weight:bold">=</span> (params) =&gt;
</span></span><span style="display:flex;"><span>  fetch(<span style="color:#a5d6ff">`/api/users?</span><span style="color:#a5d6ff">${</span><span style="color:#ff7b72">new</span> URLSearchParams(params)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>).then(r =&gt; r.json());
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// UserGrid.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { data<span style="color:#ff7b72;font-weight:bold">:</span> users <span style="color:#ff7b72;font-weight:bold">=</span> [], isLoading, isError } <span style="color:#ff7b72;font-weight:bold">=</span> useQuery({
</span></span><span style="display:flex;"><span>  queryKey<span style="color:#ff7b72;font-weight:bold">:</span> [<span style="color:#a5d6ff">&#39;users&#39;</span>, filters],
</span></span><span style="display:flex;"><span>  queryFn<span style="color:#ff7b72;font-weight:bold">:</span> () =&gt; fetchUsers(filters),
</span></span><span style="display:flex;"><span>  staleTime<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">1000</span> <span style="color:#ff7b72;font-weight:bold">*</span> <span style="color:#a5d6ff">60</span> <span style="color:#ff7b72;font-weight:bold">*</span> <span style="color:#a5d6ff">5</span>,  <span style="color:#8b949e;font-style:italic">// 5분간 캐시 유지
</span></span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> (isLoading) <span style="color:#ff7b72">return</span> &lt;<span style="color:#7ee787">div</span>&gt;로딩 중...&lt;/<span style="color:#7ee787">div</span>&gt;;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> (isError)   <span style="color:#ff7b72">return</span> &lt;<span style="color:#7ee787">div</span>&gt;데이터를 불러올 수 없습니다.&lt;/<span style="color:#7ee787">div</span>&gt;;
</span></span></code></pre></div><h3 id="수정-뮤테이션">수정 뮤테이션</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> queryClient <span style="color:#ff7b72;font-weight:bold">=</span> useQueryClient();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> updateMutation <span style="color:#ff7b72;font-weight:bold">=</span> useMutation({
</span></span><span style="display:flex;"><span>  mutationFn<span style="color:#ff7b72;font-weight:bold">:</span> ({ usId, data }) =&gt;
</span></span><span style="display:flex;"><span>    fetch(<span style="color:#a5d6ff">`/api/users/</span><span style="color:#a5d6ff">${</span>usId<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>, {
</span></span><span style="display:flex;"><span>      method<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;PUT&#39;</span>,
</span></span><span style="display:flex;"><span>      headers<span style="color:#ff7b72;font-weight:bold">:</span> { <span style="color:#a5d6ff">&#39;Content-Type&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;application/json&#39;</span> },
</span></span><span style="display:flex;"><span>      body<span style="color:#ff7b72;font-weight:bold">:</span> JSON.stringify(data),
</span></span><span style="display:flex;"><span>    }).then(r =&gt; r.json()),
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  onSuccess<span style="color:#ff7b72;font-weight:bold">:</span> () =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// 수정 후 목록 자동 갱신
</span></span></span><span style="display:flex;"><span>    queryClient.invalidateQueries({ queryKey<span style="color:#ff7b72;font-weight:bold">:</span> [<span style="color:#a5d6ff">&#39;users&#39;</span>] });
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p><code>invalidateQueries</code>로 수정 완료 후 목록을 자동으로 다시 가져온다. 상태를 직접 업데이트할 필요가 없다.</p>
<hr>
<h2 id="csv-다운로드">CSV 다운로드</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">function</span> downloadCsv(rows, filename) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> headers <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#a5d6ff">&#39;이름&#39;</span>, <span style="color:#a5d6ff">&#39;부서&#39;</span>, <span style="color:#a5d6ff">&#39;직책&#39;</span>, <span style="color:#a5d6ff">&#39;직위&#39;</span>, <span style="color:#a5d6ff">&#39;이메일&#39;</span>, <span style="color:#a5d6ff">&#39;휴대폰&#39;</span>, <span style="color:#a5d6ff">&#39;내선번호&#39;</span>];
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> fields  <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#a5d6ff">&#39;usName&#39;</span>, <span style="color:#a5d6ff">&#39;deptName&#39;</span>, <span style="color:#a5d6ff">&#39;usRollName&#39;</span>, <span style="color:#a5d6ff">&#39;usPosName&#39;</span>, <span style="color:#a5d6ff">&#39;usMail1&#39;</span>, <span style="color:#a5d6ff">&#39;usCellno&#39;</span>, <span style="color:#a5d6ff">&#39;usTelno&#39;</span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> csv <span style="color:#ff7b72;font-weight:bold">=</span> [
</span></span><span style="display:flex;"><span>    headers.join(<span style="color:#a5d6ff">&#39;,&#39;</span>),
</span></span><span style="display:flex;"><span>    ...rows.map(row =&gt;
</span></span><span style="display:flex;"><span>      fields.map(f =&gt; <span style="color:#a5d6ff">`&#34;</span><span style="color:#a5d6ff">${</span>(row[f] <span style="color:#ff7b72;font-weight:bold">??</span> <span style="color:#a5d6ff">&#39;&#39;</span>).replace(<span style="color:#79c0ff">/&#34;/g</span>, <span style="color:#a5d6ff">&#39;&#34;&#34;&#39;</span>)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;`</span>).join(<span style="color:#a5d6ff">&#39;,&#39;</span>)
</span></span><span style="display:flex;"><span>    ),
</span></span><span style="display:flex;"><span>  ].join(<span style="color:#a5d6ff">&#39;\n&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> blob <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Blob([<span style="color:#a5d6ff">&#39;\uFEFF&#39;</span> <span style="color:#ff7b72;font-weight:bold">+</span> csv], { type<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;text/csv;charset=utf-8&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> url <span style="color:#ff7b72;font-weight:bold">=</span> URL.createObjectURL(blob);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> a <span style="color:#ff7b72;font-weight:bold">=</span> document.createElement(<span style="color:#a5d6ff">&#39;a&#39;</span>);
</span></span><span style="display:flex;"><span>  a.href <span style="color:#ff7b72;font-weight:bold">=</span> url;
</span></span><span style="display:flex;"><span>  a.download <span style="color:#ff7b72;font-weight:bold">=</span> filename;
</span></span><span style="display:flex;"><span>  a.click();
</span></span><span style="display:flex;"><span>  URL.revokeObjectURL(url);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>'\uFEFF'</code> (BOM)을 앞에 붙여야 Excel에서 한글이 깨지지 않는다.</p>
<hr>
<h2 id="insert-sql-생성">INSERT SQL 생성</h2>
<p>현재 필터 조건에 해당하는 인원의 INSERT SQL을 생성해서 클립보드로 복사하는 기능이다. 다른 DB에 동일한 데이터를 넣어야 할 때 편하다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 서버: INSERT SQL 생성 API
</span></span></span><span style="display:flex;"><span>app.post(<span style="color:#a5d6ff">&#39;/api/users/sql/insert&#39;</span>, <span style="color:#ff7b72">async</span> (req, res) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> { dept, name, role } <span style="color:#ff7b72;font-weight:bold">=</span> req.body;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> users <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> prisma.user.findMany({
</span></span><span style="display:flex;"><span>    where<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>      deptName<span style="color:#ff7b72;font-weight:bold">:</span>   dept <span style="color:#ff7b72;font-weight:bold">?</span> { contains<span style="color:#ff7b72;font-weight:bold">:</span> dept } <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">undefined</span>,
</span></span><span style="display:flex;"><span>      usName<span style="color:#ff7b72;font-weight:bold">:</span>     name <span style="color:#ff7b72;font-weight:bold">?</span> { contains<span style="color:#ff7b72;font-weight:bold">:</span> name } <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">undefined</span>,
</span></span><span style="display:flex;"><span>      usRollName<span style="color:#ff7b72;font-weight:bold">:</span> role <span style="color:#ff7b72;font-weight:bold">?</span> { contains<span style="color:#ff7b72;font-weight:bold">:</span> role } <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">undefined</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> values <span style="color:#ff7b72;font-weight:bold">=</span> users.map(u =&gt;
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">`  (&#39;</span><span style="color:#a5d6ff">${</span>u.usId<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#39;, &#39;</span><span style="color:#a5d6ff">${</span>u.usName<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#39;, </span><span style="color:#a5d6ff">${</span>nullable(u.deptId)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">${</span>nullable(u.deptName)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, `</span> <span style="color:#ff7b72;font-weight:bold">+</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">`</span><span style="color:#a5d6ff">${</span>nullable(u.usRollName)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">${</span>nullable(u.usPosName)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">${</span>nullable(u.usMail1)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, `</span> <span style="color:#ff7b72;font-weight:bold">+</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">`</span><span style="color:#a5d6ff">${</span>nullable(u.usCellno)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">${</span>nullable(u.usTelno)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">)`</span>
</span></span><span style="display:flex;"><span>  ).join(<span style="color:#a5d6ff">&#39;,\n&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> sql <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">`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</span><span style="color:#a5d6ff">${</span>values<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">\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;`</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  res.json({ sql });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> nullable <span style="color:#ff7b72;font-weight:bold">=</span> v =&gt; v <span style="color:#ff7b72;font-weight:bold">?</span> <span style="color:#a5d6ff">`&#39;</span><span style="color:#a5d6ff">${</span>v.replace(<span style="color:#79c0ff">/&#39;/g</span>, <span style="color:#a5d6ff">&#34;&#39;&#39;&#34;</span>)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#39;`</span> <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;NULL&#39;</span>;
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 클라이언트: 클립보드 복사
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { data } <span style="color:#ff7b72;font-weight:bold">=</span> useMutation({
</span></span><span style="display:flex;"><span>  mutationFn<span style="color:#ff7b72;font-weight:bold">:</span> () =&gt; fetch(<span style="color:#a5d6ff">&#39;/api/users/sql/insert&#39;</span>, {
</span></span><span style="display:flex;"><span>    method<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;POST&#39;</span>,
</span></span><span style="display:flex;"><span>    headers<span style="color:#ff7b72;font-weight:bold">:</span> { <span style="color:#a5d6ff">&#39;Content-Type&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;application/json&#39;</span> },
</span></span><span style="display:flex;"><span>    body<span style="color:#ff7b72;font-weight:bold">:</span> JSON.stringify(filters),
</span></span><span style="display:flex;"><span>  }).then(r =&gt; r.json()),
</span></span><span style="display:flex;"><span>  onSuccess<span style="color:#ff7b72;font-weight:bold">:</span> ({ sql }) =&gt; {
</span></span><span style="display:flex;"><span>    navigator.clipboard.writeText(sql);
</span></span><span style="display:flex;"><span>    alert(<span style="color:#a5d6ff">&#39;SQL이 클립보드에 복사됐습니다.&#39;</span>);
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><hr>
<h2 id="다크라이트-테마">다크/라이트 테마</h2>
<p>CSS 변수 기반으로 구현했다. <code>localStorage</code>에 저장해서 새로고침 후에도 유지된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">/* App.css */</span>
</span></span><span style="display:flex;"><span>:<span style="color:#d2a8ff;font-weight:bold">root</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--bg</span>: <span style="color:#a5d6ff">#ffffff</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--surface</span>: <span style="color:#a5d6ff">#f8f9fa</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--text</span>: <span style="color:#a5d6ff">#1a1a1a</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--border</span>: <span style="color:#a5d6ff">#e0e0e0</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72;font-weight:bold">[</span><span style="color:#7ee787">data-theme</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;dark&#34;</span><span style="color:#ff7b72;font-weight:bold">]</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--bg</span>: <span style="color:#a5d6ff">#1a1a1a</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--surface</span>: <span style="color:#a5d6ff">#242424</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--text</span>: <span style="color:#a5d6ff">#e0e0e0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#79c0ff">--border</span>: <span style="color:#a5d6ff">#3a3a3a</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">body</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">background</span>: <span style="color:#d2a8ff;font-weight:bold">var</span>(<span style="color:#ff7b72;font-weight:bold">--</span>bg);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">color</span>: <span style="color:#d2a8ff;font-weight:bold">var</span>(<span style="color:#ff7b72;font-weight:bold">--</span><span style="color:#79c0ff">text</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-jsx" data-lang="jsx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// App.jsx
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> [theme, setTheme] <span style="color:#ff7b72;font-weight:bold">=</span> useState(
</span></span><span style="display:flex;"><span>  () =&gt; localStorage.getItem(<span style="color:#a5d6ff">&#39;theme&#39;</span>) <span style="color:#ff7b72;font-weight:bold">||</span> <span style="color:#a5d6ff">&#39;light&#39;</span>
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>  document.documentElement.setAttribute(<span style="color:#a5d6ff">&#39;data-theme&#39;</span>, theme);
</span></span><span style="display:flex;"><span>  localStorage.setItem(<span style="color:#a5d6ff">&#39;theme&#39;</span>, theme);
</span></span><span style="display:flex;"><span>}, [theme]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> toggleTheme <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt;
</span></span><span style="display:flex;"><span>  setTheme(t =&gt; t <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;light&#39;</span> <span style="color:#ff7b72;font-weight:bold">?</span> <span style="color:#a5d6ff">&#39;dark&#39;</span> <span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;light&#39;</span>);
</span></span></code></pre></div><p><img alt="인사 목록 탭 - AG Grid 테이블" loading="lazy" src="/images/insa-02-grid.png"></p>
<p><img alt="변동 이력 탭" loading="lazy" src="/images/insa-02-history.png"></p>
<hr>
<p>다음 편에서는 Docker Compose로 db → server → client 시작 순서를 보장하는 방법과 운영하면서 겪은 트러블슈팅을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 인사정보 관리 시스템 만들기 - 아키텍처 &#43; Prisma 스키마 설계 (1편)</title>
      <link>https://chanyeols.com/posts/insa-01-architecture-schema/</link>
      <pubDate>Thu, 09 Apr 2026 10:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/insa-01-architecture-schema/</guid>
      <description>Node.js &#43; Prisma &#43; PostgreSQL &#43; React &#43; AG Grid &#43; Docker Compose로 사내 인사정보 관리 시스템을 만든 과정을 정리합니다. 전체 아키텍처와 Prisma 스키마 설계를 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-만들게-됐나">왜 만들게 됐나</h2>
<p>사내 그룹웨어에는 인사 정보가 있지만 조회 UI가 불편하고, 부서 이동이나 입퇴사 같은 변동 이력을 추적하는 기능이 없었다. 직접 관리하고 싶은 데이터가 생겼을 때 그때그때 SQL을 뽑아 쓸 수 있는 환경도 필요했다.</p>
<p>그래서 만들었다.</p>
<ul>
<li>그룹웨어 API에서 인사 데이터를 매일 자동 수집</li>
<li>이전 상태와 비교해서 입사/퇴사/부서이동/직책변경 자동 감지</li>
<li>React 웹 UI에서 조회·수정·삭제, CSV 다운로드, INSERT SQL 생성</li>
</ul>
<hr>
<h2 id="기술-스택-선택">기술 스택 선택</h2>
<table>
  <thead>
      <tr>
          <th>영역</th>
          <th>기술</th>
          <th>선택 이유</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>백엔드</td>
          <td>Node.js 20 + Express</td>
          <td>기존 Slack 봇과 동일 스택</td>
      </tr>
      <tr>
          <td>ORM</td>
          <td>Prisma</td>
          <td>타입 안전한 쿼리, 마이그레이션 자동화</td>
      </tr>
      <tr>
          <td>DB</td>
          <td>PostgreSQL 16</td>
          <td>JSON 파일로는 한계, 이력 테이블 필요</td>
      </tr>
      <tr>
          <td>스케줄러</td>
          <td>node-cron</td>
          <td>매일 1시 배치 실행</td>
      </tr>
      <tr>
          <td>프론트엔드</td>
          <td>React 18 + Vite</td>
          <td>빠른 개발 환경</td>
      </tr>
      <tr>
          <td>그리드</td>
          <td>AG Grid Community</td>
          <td>대용량 데이터 필터/정렬/페이지네이션</td>
      </tr>
      <tr>
          <td>서버 상태</td>
          <td>TanStack Query</td>
          <td>API 캐싱, 로딩/에러 상태 관리</td>
      </tr>
      <tr>
          <td>인프라</td>
          <td>Docker Compose</td>
          <td>3개 서비스(DB, 서버, 클라이언트) 일괄 관리</td>
      </tr>
  </tbody>
</table>
<p>기존 Slack 봇은 DB 없이 JSON 파일로 상태를 관리했는데, 인사 데이터는 수백 명 규모에 이력까지 쌓아야 하니 PostgreSQL이 필요했다.</p>
<p>AG Grid를 선택한 이유는 단순하다. 수백 건 데이터를 부서/이름/직책 기준으로 빠르게 필터링하고 정렬하는 기능을 직접 구현하기엔 공수가 크다. Community 버전이 무료라 부담 없이 쓸 수 있었다.</p>
<hr>
<h2 id="전체-아키텍처">전체 아키텍처</h2>
<pre tabindex="0"><code>[그룹웨어 API]
      │
      │ 매일 01:00 배치 실행
      ▼
[Node.js 서버]
      │
      ├─ 인사 데이터 수집
      ├─ DB 비교 → 변동 감지 (입사/퇴사/부서이동/직책변경)
      ├─ REST API (/api/users, /api/history, ...)
      └─ 크론 스케줄러
      │
      ▼
[PostgreSQL]
      │
      ▼
[React 클라이언트]  ←  nginx (API 프록시 + SPA 라우팅)
</code></pre><p>Docker Compose로 세 서비스를 한 번에 관리한다.</p>
<table>
  <thead>
      <tr>
          <th>서비스</th>
          <th>포트</th>
          <th>역할</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>db</td>
          <td>5432</td>
          <td>PostgreSQL 16</td>
      </tr>
      <tr>
          <td>server</td>
          <td>4000</td>
          <td>Express API + 배치 크론</td>
      </tr>
      <tr>
          <td>client</td>
          <td>3002</td>
          <td>React (nginx 서빙)</td>
      </tr>
  </tbody>
</table>
<p>클라이언트의 nginx가 <code>/api/</code> 요청을 server로 프록시하기 때문에 프론트에서 API URL을 하드코딩할 필요가 없다.</p>
<hr>
<h2 id="프로젝트-구조">프로젝트 구조</h2>
<pre tabindex="0"><code>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 # 변동 이력 패널
</code></pre><hr>
<h2 id="prisma-스키마-설계">Prisma 스키마 설계</h2>
<h3 id="테이블-구조">테이블 구조</h3>
<p>인사 데이터는 두 테이블로 관리한다.</p>
<ul>
<li><strong>users</strong> — 현재 인사 정보 (최신 상태만 유지)</li>
<li><strong>user_history</strong> — 변동 이력 (변동이 생길 때마다 누적)</li>
</ul>
<pre tabindex="0"><code class="language-prisma" data-lang="prisma">generator client {
  provider      = &#34;prisma-client-js&#34;
  binaryTargets = [&#34;native&#34;, &#34;debian-openssl-3.0.x&#34;]
}

datasource db {
  provider = &#34;postgresql&#34;
  url      = env(&#34;DATABASE_URL&#34;)
}

model User {
  id          Int      @id @default(autoincrement())
  usId        String   @unique @map(&#34;us_id&#34;)
  usName      String   @map(&#34;us_name&#34;)
  deptId      String?  @map(&#34;dept_id&#34;)
  deptName    String?  @map(&#34;dept_name&#34;)
  usRollName  String?  @map(&#34;us_roll_name&#34;)
  usPosName   String?  @map(&#34;us_pos_name&#34;)
  usMail1     String?  @map(&#34;us_mail1&#34;)
  usCellno    String?  @map(&#34;us_cellno&#34;)
  usTelno     String?  @map(&#34;us_telno&#34;)
  chiefYn     String?  @map(&#34;chief_yn&#34;)
  chiefUsId   String?  @map(&#34;chief_us_id&#34;)
  createdAt   DateTime @default(now()) @map(&#34;created_at&#34;)
  updatedAt   DateTime @updatedAt @map(&#34;updated_at&#34;)
  history     UserHistory[]

  @@map(&#34;users&#34;)
}

model UserHistory {
  id         Int      @id @default(autoincrement())
  usId       String   @map(&#34;us_id&#34;)
  changeType String   @map(&#34;change_type&#34;)  // JOIN | LEAVE | DEPT_CHANGE | ROLE_CHANGE
  fieldName  String?  @map(&#34;field_name&#34;)
  oldValue   String?  @map(&#34;old_value&#34;)
  newValue   String?  @map(&#34;new_value&#34;)
  detectedAt DateTime @default(now()) @map(&#34;detected_at&#34;)
  user       User     @relation(fields: [usId], references: [usId])

  @@map(&#34;user_history&#34;)
}
</code></pre><h3 id="설계-결정-왜-현재-상태와-이력을-분리했나">설계 결정: 왜 현재 상태와 이력을 분리했나</h3>
<p>한 테이블에 모든 이력을 쌓는 방식도 있지만, 두 테이블로 분리한 이유가 있다.</p>
<p><strong>현재 상태 조회 성능</strong>: 인사 목록 조회는 항상 &ldquo;현재&rdquo; 데이터만 보면 된다. 이력이 수천 건 쌓여도 <code>users</code> 테이블은 항상 현재 인원 수만큼만 유지된다.</p>
<p><strong>이력 추적 명확성</strong>: <code>user_history</code>에는 무슨 필드가 어떤 값에서 어떤 값으로 바뀌었는지가 명시적으로 기록된다. 쿼리 없이 바로 읽을 수 있다.</p>
<p><strong>관계 명확성</strong>: <code>usId</code>로 현재 정보와 이력을 연결한다. 특정 사람의 전체 변동 이력을 한 번에 조회할 수 있다.</p>
<h3 id="changetype-설계">changeType 설계</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> CHANGE_TYPES <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  JOIN<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;JOIN&#39;</span>,           <span style="color:#8b949e;font-style:italic">// 신규 입사 (DB에 없던 usId 등장)
</span></span></span><span style="display:flex;"><span>  LEAVE<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;LEAVE&#39;</span>,         <span style="color:#8b949e;font-style:italic">// 퇴사 (API 응답에서 usId 사라짐)
</span></span></span><span style="display:flex;"><span>  DEPT_CHANGE<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;DEPT_CHANGE&#39;</span>,   <span style="color:#8b949e;font-style:italic">// 부서 이동
</span></span></span><span style="display:flex;"><span>  ROLE_CHANGE<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;ROLE_CHANGE&#39;</span>,   <span style="color:#8b949e;font-style:italic">// 직책/직위 변경
</span></span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p><code>DEPT_CHANGE</code>와 <code>ROLE_CHANGE</code>는 <code>fieldName</code>, <code>oldValue</code>, <code>newValue</code>에 구체적으로 뭐가 바뀌었는지 기록한다.</p>
<pre tabindex="0"><code>fieldName: &#34;deptName&#34;
oldValue:  &#34;개발1팀&#34;
newValue:  &#34;개발2팀&#34;
</code></pre><h3 id="binarytargets-설정">binaryTargets 설정</h3>
<pre tabindex="0"><code class="language-prisma" data-lang="prisma">generator client {
  provider      = &#34;prisma-client-js&#34;
  binaryTargets = [&#34;native&#34;, &#34;debian-openssl-3.0.x&#34;]
}
</code></pre><p>이 설정이 없으면 Docker 배포 시 문제가 생긴다. 로컬(macOS/Windows)에서 생성된 Prisma Client는 Linux 환경에서 동작하지 않는다. <code>debian-openssl-3.0.x</code>를 추가하면 Linux 컨테이너에서도 정상 동작하는 바이너리가 함께 생성된다.</p>
<hr>
<h2 id="rest-api-목록">REST API 목록</h2>
<table>
  <thead>
      <tr>
          <th>Method</th>
          <th>Path</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GET</td>
          <td><code>/api/users</code></td>
          <td>인사 목록 (dept, name, role 쿼리 필터)</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/users/:usId</code></td>
          <td>단건 조회</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/users</code></td>
          <td>수동 추가</td>
      </tr>
      <tr>
          <td>PUT</td>
          <td><code>/api/users/:usId</code></td>
          <td>수정</td>
      </tr>
      <tr>
          <td>DELETE</td>
          <td><code>/api/users/:usId</code></td>
          <td>삭제</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/history</code></td>
          <td>변동 이력 (usId, type 쿼리 필터)</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/depts</code></td>
          <td>부서 목록</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/users/sql/insert</code></td>
          <td>필터 기반 INSERT SQL 생성</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/health</code></td>
          <td>헬스체크</td>
      </tr>
  </tbody>
</table>
<p><code>/health</code> 엔드포인트는 Docker Compose healthcheck에서 server가 준비됐는지 확인하는 데 쓴다. 3편에서 자세히 다룬다.</p>
<hr>
<p>다음 편에서는 그룹웨어 API에서 인사 데이터를 수집해서 DB와 비교하고, 변동을 감지하는 배치 로직과 React + AG Grid로 관리 UI를 만드는 과정을 다룬다.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
