<?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>Tanstack-Query on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/tanstack-query/</link>
    <description>Recent content in Tanstack-Query 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/tanstack-query/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>
  </channel>
</rss>
