<?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>React on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/react/</link>
    <description>Recent content in React 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/react/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>
    <item>
      <title>노트북으로 홈서버 구축하기 - 직접 만든 컨테이너 대시보드 (Spring Boot &#43; React &#43; SSE) (12편)</title>
      <link>https://chanyeols.com/posts/part-12-dashboard/</link>
      <pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-12-dashboard/</guid>
      <description>Portainer 대신 Spring Boot &#43; React로 컨테이너 모니터링 대시보드를 직접 만들었다. Docker API 연동, SSE 실시간 로그 스트리밍, 멀티스테이지 Docker 빌드까지 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-직접-만들었나">왜 직접 만들었나</h2>
<p>Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다.</p>
<p>Portainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다.</p>
<hr>
<h2 id="완성-화면">완성 화면</h2>
<ul>
<li>컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지)</li>
<li>Start / Stop / Restart 원클릭 제어</li>
<li>Logs 버튼으로 실시간 로그 스트리밍 (SSE)</li>
<li>5초 주기 자동 갱신</li>
</ul>
<p><img alt="대시보드 메인 UI - 컨테이너 카드 목록" loading="lazy" src="/images/homeserver-12-dashboard-ui.png"></p>
<h2 id="기술-스택">기술 스택</h2>
<table>
  <thead>
      <tr>
          <th>역할</th>
          <th>기술</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>백엔드</td>
          <td>Spring Boot 3.5, Java 17, Maven</td>
      </tr>
      <tr>
          <td>Docker 연동</td>
          <td>docker-java 3.3.6 (zerodep transport)</td>
      </tr>
      <tr>
          <td>실시간 로그</td>
          <td>SSE (Server-Sent Events) + WebFlux</td>
      </tr>
      <tr>
          <td>프론트엔드</td>
          <td>React 19, plain CSS</td>
      </tr>
      <tr>
          <td>배포</td>
          <td>Docker Compose + nginx</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="백엔드-spring-boot">백엔드 (Spring Boot)</h2>
<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-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">&lt;!-- Docker Java --&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;dependency&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;groupId&gt;</span>com.github.docker-java<span style="color:#7ee787">&lt;/groupId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;artifactId&gt;</span>docker-java<span style="color:#7ee787">&lt;/artifactId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;version&gt;</span>3.3.6<span style="color:#7ee787">&lt;/version&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;/dependency&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;dependency&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;groupId&gt;</span>com.github.docker-java<span style="color:#7ee787">&lt;/groupId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;artifactId&gt;</span>docker-java-transport-zerodep<span style="color:#7ee787">&lt;/artifactId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;version&gt;</span>3.3.6<span style="color:#7ee787">&lt;/version&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;/dependency&gt;</span>
</span></span></code></pre></div><p>transport 선택이 중요하다. <code>httpclient5</code> 트랜스포트는 Unix 소켓을 제대로 처리하지 못해서 아래 에러가 난다.</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>Connect to unix://localhost:2375 failed
</span></span></code></pre></div><p><strong><code>zerodep</code> 트랜스포트</strong>를 써야 <code>/var/run/docker.sock</code> 연결이 정상 동작한다.</p>
<h3 id="dockerclient-빈-설정">DockerClient 빈 설정</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Configuration</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">DockerConfig</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">@Value</span>(<span style="color:#a5d6ff">&#34;${docker.host:unix:///var/run/docker.sock}&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>dockerHost;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">@Bean</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>DockerClient<span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">dockerClient</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>DockerClientConfig<span style="color:#6e7681"> </span>config<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>DefaultDockerClientConfig.createDefaultConfigBuilder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withDockerHost(dockerHost)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.build();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>DockerHttpClient<span style="color:#6e7681"> </span>httpClient<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>ZerodepDockerHttpClient.Builder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.dockerHost(config.getDockerHost())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.sslConfig(config.getSSLConfig())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.build();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>DockerClientImpl.getInstance(config,<span style="color:#6e7681"> </span>httpClient);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>application.properties</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-plain" data-lang="plain"><span style="display:flex;"><span>docker.host=unix:///var/run/docker.sock
</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>ContainerSummaryDto<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">listContainers</span>(<span style="color:#ff7b72">boolean</span><span style="color:#6e7681"> </span>all)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>dockerClient.listContainersCmd()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.withShowAll(all)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.exec()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.stream()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.map(<span style="color:#ff7b72">this</span>::toDto)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.toList();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="실시간-로그-스트리밍-sse">실시간 로그 스트리밍 (SSE)</h3>
<p>이 프로젝트의 핵심이다. docker-java의 콜백 기반 API를 WebFlux의 <code>Flux</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>Flux<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">streamLogs</span>(String<span style="color:#6e7681"> </span>containerId,<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>tail)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>Flux.create(sink<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>dockerClient.logContainerCmd(containerId)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withStdOut(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withStdErr(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withFollowStream(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withTail(tail)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withTimestamps(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.exec(<span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>ResultCallback.Adapter<span style="color:#ff7b72;font-weight:bold">&lt;&gt;</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onNext</span>(Frame<span style="color:#6e7681"> </span>frame)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.next(<span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>String(frame.getPayload()).stripTrailing());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onError</span>(Throwable<span style="color:#6e7681"> </span>throwable)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.error(throwable);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onComplete</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.complete();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>});<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>});<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><p>컨트롤러에서 SSE로 내보낸다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@GetMapping</span>(value<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;/{id}/logs&#34;</span>,<span style="color:#6e7681"> </span>produces<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>MediaType.TEXT_EVENT_STREAM_VALUE)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>Flux<span style="color:#ff7b72;font-weight:bold">&lt;</span>ServerSentEvent<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">streamLogs</span>(<span style="color:#d2a8ff;font-weight:bold">@PathVariable</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>id,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                                                 </span><span style="color:#d2a8ff;font-weight:bold">@RequestParam</span>(defaultValue<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;100&#34;</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>tail)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>containerService.streamLogs(id,<span style="color:#6e7681"> </span>tail)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.map(line<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>ServerSentEvent.<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;</span>builder().data(line).build());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="api-목록">API 목록</h3>
<table>
  <thead>
      <tr>
          <th>Method</th>
          <th>Endpoint</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GET</td>
          <td><code>/api/containers?all=true</code></td>
          <td>컨테이너 목록</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/containers/{id}/logs?tail=100</code></td>
          <td>SSE 로그 스트리밍</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/start</code></td>
          <td>시작</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/stop</code></td>
          <td>정지</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/restart</code></td>
          <td>재시작</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="프론트엔드-react">프론트엔드 (React)</h2>
<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-plain" data-lang="plain"><span style="display:flex;"><span>src/
</span></span><span style="display:flex;"><span>  api.js               # API 호출 함수
</span></span><span style="display:flex;"><span>  App.js               # 루트 컴포넌트 (목록 + 폴링)
</span></span><span style="display:flex;"><span>  App.css              # 전체 스타일 (다크 테마)
</span></span><span style="display:flex;"><span>  components/
</span></span><span style="display:flex;"><span>    ContainerCard.js   # 카드 UI + 액션 버튼
</span></span><span style="display:flex;"><span>    LogViewer.js       # SSE 실시간 로그 모달
</span></span></code></pre></div><h3 id="5초-폴링">5초 폴링</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> POLL_INTERVAL <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">5000</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">default</span> <span style="color:#ff7b72">function</span> App() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> [containers, setContainers] <span style="color:#ff7b72;font-weight:bold">=</span> useState([]);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> [logTarget, setLogTarget] <span style="color:#ff7b72;font-weight:bold">=</span> useState(<span style="color:#79c0ff">null</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> load <span style="color:#ff7b72;font-weight:bold">=</span> useCallback(<span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> data <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetchContainers();
</span></span><span style="display:flex;"><span>    setContainers(data);
</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>    load();
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> timer <span style="color:#ff7b72;font-weight:bold">=</span> setInterval(load, POLL_INTERVAL);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> () =&gt; clearInterval(timer);
</span></span><span style="display:flex;"><span>  }, [load]);
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// ...
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>setInterval</code>을 <code>useEffect</code> cleanup에서 <code>clearInterval</code>로 정리해야 컴포넌트 언마운트 시 폴링이 멈춘다.</p>
<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">const</span> STATE_META <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  running<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Running&#39;</span>, color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#22c55e&#39;</span> },
</span></span><span style="display:flex;"><span>  exited<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Exited&#39;</span>,  color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#ef4444&#39;</span> },
</span></span><span style="display:flex;"><span>  paused<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Paused&#39;</span>,  color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#f59e0b&#39;</span> },
</span></span><span style="display:flex;"><span>  created<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Created&#39;</span>, color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#6b7280&#39;</span> },
</span></span><span style="display:flex;"><span>  dead<span style="color:#ff7b72;font-weight:bold">:</span>    { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Dead&#39;</span>,    color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#991b1b&#39;</span> },
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><h3 id="sse-실시간-로그">SSE 실시간 로그</h3>
<p>브라우저 내장 <code>EventSource</code> API를 사용한다. WebSocket보다 단방향 스트리밍에 적합하고 서버 구현도 단순하다.</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>useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> es <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> EventSource(getLogUrl(containerId));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onopen <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt; setConnected(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onmessage <span style="color:#ff7b72;font-weight:bold">=</span> (e) =&gt; {
</span></span><span style="display:flex;"><span>    setLines((prev) =&gt; {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> next <span style="color:#ff7b72;font-weight:bold">=</span> [...prev, e.data];
</span></span><span style="display:flex;"><span>      <span style="color:#8b949e;font-style:italic">// 메모리 관리: 최대 2000줄 유지
</span></span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">return</span> next.length <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">2000</span> <span style="color:#ff7b72;font-weight:bold">?</span> next.slice(<span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">2000</span>) <span style="color:#ff7b72;font-weight:bold">:</span> next;
</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>  es.onerror <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt; {
</span></span><span style="display:flex;"><span>    setConnected(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span>    setError(<span style="color:#a5d6ff">&#39;Connection lost.&#39;</span>);
</span></span><span style="display:flex;"><span>    es.close();
</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> () =&gt; es.close(); <span style="color:#8b949e;font-style:italic">// 모달 닫으면 SSE 연결 종료
</span></span></span><span style="display:flex;"><span>}, [containerId]);
</span></span></code></pre></div><p>cleanup에서 <code>es.close()</code>를 빠뜨리면 모달을 닫아도 서버와 연결이 계속 유지된다. 꼭 넣어야 한다.</p>
<p><img alt="Logs 모달 - SSE 실시간 로그 스트리밍" loading="lazy" src="/images/homeserver-12-dashboard-logs.png"></p>
<hr>
<h2 id="배포">배포</h2>
<h3 id="백엔드-dockerfile-멀티스테이지-빌드">백엔드 Dockerfile (멀티스테이지 빌드)</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-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">eclipse-temurin:17-jdk</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">builder</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> .mvn/ .mvn/<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> mvnw pom.xml ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> ./mvnw dependency:go-offline -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> src/ src/<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> ./mvnw package -DskipTests -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">eclipse-temurin:17-jre</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>builder /app/target/*.jar app.jar<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">ENTRYPOINT</span> [<span style="color:#a5d6ff">&#34;java&#34;</span>, <span style="color:#a5d6ff">&#34;-jar&#34;</span>, <span style="color:#a5d6ff">&#34;app.jar&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><h3 id="프론트엔드-dockerfile-멀티스테이지-빌드">프론트엔드 Dockerfile (멀티스테이지 빌드)</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-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:20-alpine</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> package.json package-lock.json* ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm ci --silent<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> . .<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm run build<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">nginx:alpine</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>build /app/build /usr/share/nginx/html<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> nginx.conf /etc/nginx/conf.d/default.conf<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">80</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">CMD</span> [<span style="color:#a5d6ff">&#34;nginx&#34;</span>, <span style="color:#a5d6ff">&#34;-g&#34;</span>, <span style="color:#a5d6ff">&#34;daemon off;&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><h3 id="nginxconf--sse-버퍼링-비활성화가-핵심">nginx.conf — SSE 버퍼링 비활성화가 핵심</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-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">root</span> <span style="color:#a5d6ff">/usr/share/nginx/html</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">index</span> <span style="color:#a5d6ff">index.html</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/api/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://100.109.108.36:28080</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_http_version</span> <span style="color:#a5d6ff">1</span><span style="color:#a5d6ff">.1</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Connection</span> <span style="color:#a5d6ff">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_buffering</span> <span style="color:#79c0ff;font-weight:bold">off</span>;       <span style="color:#8b949e;font-style:italic"># SSE 필수 설정
</span></span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_cache</span> <span style="color:#79c0ff;font-weight:bold">off</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">chunked_transfer_encoding</span> <span style="color:#79c0ff;font-weight:bold">on</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">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">try_files</span> <span style="color:#79c0ff">$uri</span> <span style="color:#79c0ff">$uri/</span> <span style="color:#a5d6ff">/index.html</span>;  <span style="color:#8b949e;font-style:italic"># SPA 라우팅
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>proxy_buffering off</code>가 없으면 nginx가 SSE 응답을 버퍼에 쌓았다가 한꺼번에 보내서 실시간성이 깨진다. 삽질 포인트다.</p>
<h3 id="배포-플로우">배포 플로우</h3>
<p>서버에서 직접 빌드하면 node_modules 설치에 시간이 오래 걸린다. 로컬에서 이미지를 만들고 tar로 전송하는 방식을 선택했다.</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><span style="color:#8b949e;font-style:italic"># 로컬에서 이미지 빌드</span>
</span></span><span style="display:flex;"><span>docker build -t dashboard-front ./frontend
</span></span><span style="display:flex;"><span>docker build -t dashboard-back ./backend
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># tar로 저장</span>
</span></span><span style="display:flex;"><span>docker save dashboard-front | gzip &gt; dashboard-front.tar.gz
</span></span><span style="display:flex;"><span>docker save dashboard-back | gzip &gt; dashboard-back.tar.gz
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 서버로 전송</span>
</span></span><span style="display:flex;"><span>scp dashboard-front.tar.gz dashboard-back.tar.gz user@홈서버IP:~/dashboard/
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 서버에서 로드 &amp; 실행</span>
</span></span><span style="display:flex;"><span>ssh user@홈서버IP
</span></span><span style="display:flex;"><span>cd ~/dashboard
</span></span><span style="display:flex;"><span>docker load &lt; dashboard-front.tar.gz
</span></span><span style="display:flex;"><span>docker load &lt; dashboard-back.tar.gz
</span></span><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><h3 id="docker-composeyml">docker-compose.yml</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-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">dashboard-back</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-back</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-back</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;28080:8080&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">/var/run/docker.sock:/var/run/docker.sock</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">dashboard-front</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-front</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-front</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;23000:80&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p>백엔드 컨테이너에도 <code>/var/run/docker.sock</code>을 마운트해서 호스트의 Docker 데몬에 접근한다.</p>
<hr>
<h2 id="구현하면서-신경-쓴-것들">구현하면서 신경 쓴 것들</h2>
<ul>
<li><strong>SSE cleanup</strong>: <code>useEffect</code> return에서 <code>es.close()</code> 호출 필수. 안 하면 모달 닫아도 연결 유지</li>
<li><strong>메모리 관리</strong>: 로그 줄 수를 2000줄로 제한해 장시간 열어둬도 브라우저가 버벅이지 않게 처리</li>
<li><strong>nginx SSE 설정</strong>: <code>proxy_buffering off</code> 없으면 로그가 실시간으로 안 온다</li>
<li><strong>zerodep transport</strong>: docker-java에서 Unix 소켓 연결 시 반드시 zerodep 사용</li>
</ul>
<p>Stop 버튼을 누르면 실제로 컨테이너가 정지되는 걸 Portainer에서 교차 확인했다.</p>
<p><img alt="Portainer에서 Stop 확인 - 컨테이너 정지 상태" loading="lazy" src="/images/homeserver-12-portainer-confirm.png"></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>12편에 걸쳐 ThinkPad 노트북 한 대로 홈서버를 구축한 과정을 정리했다.</p>
<p>처음엔 파일 서버 하나 만들려고 시작했는데 어쩌다 보니 사진 관리, 비밀번호 관리, 모니터링, 보안, 자동 백업에 커스텀 대시보드까지 만들어버렸다. 홈서버는 공부할 게 계속 생긴다는 게 가장 큰 장점이자 단점이다.</p>
<h2 id="시리즈-구성">시리즈 구성</h2>
<p>이 구축기는 총 12편으로 구성된다.</p>
<ol>
<li><a href="/posts/part-01-intro">왜 홈서버인가? + 전체 아키텍처</a></li>
<li><a href="/posts/part-02-ubuntu-tailscale">Ubuntu Server 설치 + Tailscale VPN</a></li>
<li><a href="/posts/part-03-ssd-filebrowser">외장 SSD 마운트 + Filebrowser 원격 파일 관리</a></li>
<li><a href="/posts/part-04-immich">Immich로 구글 포토 대체하기</a></li>
<li><a href="/posts/part-05-vaultwarden">Vaultwarden으로 비밀번호 자체 호스팅</a></li>
<li><a href="/posts/part-06-portainer">Portainer CE로 Docker GUI 관리</a></li>
<li><a href="/posts/part-07-monitoring">Grafana + Prometheus로 홈서버 모니터링</a></li>
<li><a href="/posts/part-08-fail2ban">Fail2ban으로 SSH 브루트포스 차단</a></li>
<li><a href="/posts/part-09-ssl">certbot &ndash;expand로 SSL 서브도메인 추가</a></li>
<li><a href="/posts/part-10-postgresql-backup">PostgreSQL 자동 백업 (pg_dump + cron)</a></li>
<li><a href="/posts/part-11-tuning">TLP + thinkfan + Swap 튜닝으로 운영 최적화</a></li>
<li><a href="/posts/part-12-dashboard">직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE)</a> ← 지금 여기</li>
</ol>
]]></content:encoded>
    </item>
  </channel>
</rss>
