<?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>사내툴 on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/%EC%82%AC%EB%82%B4%ED%88%B4/</link>
    <description>Recent content in 사내툴 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/%EC%82%AC%EB%82%B4%ED%88%B4/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>사내 Slack 봇 만들기 - 관리자 페이지 &#43; Docker 배포 &#43; 트러블슈팅 (4편)</title>
      <link>https://chanyeols.com/posts/slack-04-admin-deploy/</link>
      <pubDate>Wed, 08 Apr 2026 15:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/slack-04-admin-deploy/</guid>
      <description>Express &#43; 바닐라 JS로 만든 관리자 페이지 구현과 Docker Compose 배포 방법, 실제 운영하면서 겪은 트러블슈팅을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-관리자-페이지가-필요한가">왜 관리자 페이지가 필요한가</h2>
<p>처음엔 팀 Webhook이 하나라 <code>teams.json</code>을 직접 수정해도 됐다. 그런데 팀이 늘어나고, 알림 타입을 팀별로 다르게 설정하고 싶어지고, 출근 알림 대상 멤버도 자주 바뀌면서 파일을 직접 건드리는 게 너무 번거로워졌다.</p>
<p>그래서 관리자 페이지를 만들었다. React 같은 프레임워크 없이 Express + 바닐라 JS로 만들었다. 관리자 혼자 쓰는 내부 툴이라 빌드 파이프라인 없이 심플하게 가는 게 낫다고 판단했다.</p>
<hr>
<h2 id="관리자-페이지-구성">관리자 페이지 구성</h2>
<p><img alt="관리자 페이지 전체 화면" loading="lazy" src="/images/slack-04-admin.png"></p>
<p>패널 4개로 구성된다.</p>
<table>
  <thead>
      <tr>
          <th>패널</th>
          <th>기능</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>팀·Webhook</td>
          <td>팀 추가/수정/삭제, 활성 토글, 알림 타입 배지 토글, 테스트 발송</td>
      </tr>
      <tr>
          <td>출근알림 멤버</td>
          <td>멤버 추가/수정/삭제, Slack 이름 자동완성</td>
      </tr>
      <tr>
          <td>출근현황</td>
          <td>카드뷰 출근/미출근 현황, 수동 알림 발송</td>
      </tr>
      <tr>
          <td>크론 관리</td>
          <td>크론 활성화 토글, 스케줄 표현식 수정</td>
      </tr>
      <tr>
          <td>설정</td>
          <td>Slack Bot Token 관리</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="팀webhook-패널">팀·Webhook 패널</h2>
<p>팀별로 6가지 알림 타입을 배지 형태로 토글할 수 있다.</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">// 알림 타입 배지 토글
</span></span></span><span style="display:flex;"><span>document.querySelectorAll(<span style="color:#a5d6ff">&#39;.alert-badge&#39;</span>).forEach(badge =&gt; {
</span></span><span style="display:flex;"><span>  badge.addEventListener(<span style="color:#a5d6ff">&#39;click&#39;</span>, <span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> type <span style="color:#ff7b72;font-weight:bold">=</span> badge.dataset.type;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> teamId <span style="color:#ff7b72;font-weight:bold">=</span> badge.closest(<span style="color:#a5d6ff">&#39;[data-team-id]&#39;</span>).dataset.teamId;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    badge.classList.toggle(<span style="color:#a5d6ff">&#39;active&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> fetch(<span style="color:#a5d6ff">`/api/teams/</span><span style="color:#a5d6ff">${</span>teamId<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">/alert-types`</span>, {
</span></span><span style="display:flex;"><span>      method<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;PATCH&#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({ type, enabled<span style="color:#ff7b72;font-weight:bold">:</span> badge.classList.contains(<span style="color:#a5d6ff">&#39;active&#39;</span>) }),
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>테스트 버튼을 누르면 해당 Webhook으로 즉시 테스트 메시지를 발송해서 연결 여부를 확인할 수 있다.</p>
<hr>
<h2 id="출근-알림-멤버-관리">출근 알림 멤버 관리</h2>
<p>출근 미등록 DM을 보내려면 멤버의 사번과 Slack 유저 ID가 필요하다.</p>
<p>Slack 유저 이름 자동완성 기능을 넣었다. 입력하면 Bot Token으로 Slack 멤버 목록을 가져와서 필터링한다.</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:#ff7b72">async</span> <span style="color:#ff7b72">function</span> fetchSlackMembers() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> res <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetch(<span style="color:#a5d6ff">&#39;https://slack.com/api/users.list&#39;</span>, {
</span></span><span style="display:flex;"><span>    headers<span style="color:#ff7b72;font-weight:bold">:</span> { Authorization<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">`Bearer </span><span style="color:#a5d6ff">${</span>BOT_TOKEN<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:#ff7b72">const</span> data <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> res.json();
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> data.members
</span></span><span style="display:flex;"><span>    .filter(m =&gt; <span style="color:#ff7b72;font-weight:bold">!</span>m.is_bot <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> <span style="color:#ff7b72;font-weight:bold">!</span>m.deleted)
</span></span><span style="display:flex;"><span>    .map(m =&gt; ({
</span></span><span style="display:flex;"><span>      id<span style="color:#ff7b72;font-weight:bold">:</span> m.id,
</span></span><span style="display:flex;"><span>      name<span style="color:#ff7b72;font-weight:bold">:</span> m.profile.display_name <span style="color:#ff7b72;font-weight:bold">||</span> m.real_name,
</span></span><span style="display:flex;"><span>    }));
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="selectedindex-버그">selectedIndex 버그</h3>
<p>Slack 이름 자동완성에서 한 가지 버그가 있었다. 같은 팀에 동명이인이 있을 때 <code>select.value = &quot;targetValue&quot;</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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 버그 있는 코드
</span></span></span><span style="display:flex;"><span>select.value <span style="color:#ff7b72;font-weight:bold">=</span> member.slackId;  <span style="color:#8b949e;font-style:italic">// 항상 첫 번째로 스냅됨
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 해결: selectedIndex 직접 지정
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> idx <span style="color:#ff7b72;font-weight:bold">=</span> [...select.options].findIndex(o =&gt; o.value <span style="color:#ff7b72;font-weight:bold">===</span> member.slackId);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> (idx <span style="color:#ff7b72;font-weight:bold">!==</span> <span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">1</span>) select.selectedIndex <span style="color:#ff7b72;font-weight:bold">=</span> idx;
</span></span></code></pre></div><p><code>value</code> 방식은 같은 value를 가진 옵션이 여러 개일 때 첫 번째로 스냅된다. <code>selectedIndex</code>를 직접 지정하면 원하는 옵션을 정확하게 선택할 수 있다.</p>
<hr>
<h2 id="크론-관리-패널">크론 관리 패널</h2>
<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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 크론 재등록 API
</span></span></span><span style="display:flex;"><span>app.patch(<span style="color:#a5d6ff">&#39;/api/crons/:id&#39;</span>, <span style="color:#ff7b72">async</span> (req, res) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> { id } <span style="color:#ff7b72;font-weight:bold">=</span> req.params;
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> { enabled, schedule } <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:#8b949e;font-style:italic">// 기존 크론 중지
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (cronJobs[id]) {
</span></span><span style="display:flex;"><span>    cronJobs[id].stop();
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">delete</span> cronJobs[id];
</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">// 설정 저장
</span></span></span><span style="display:flex;"><span>  settings.crons[id] <span style="color:#ff7b72;font-weight:bold">=</span> { enabled, schedule };
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> saveSettings();
</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>  <span style="color:#ff7b72">if</span> (enabled) {
</span></span><span style="display:flex;"><span>    cronJobs[id] <span style="color:#ff7b72;font-weight:bold">=</span> cron.schedule(schedule, cronHandlers[id]);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  res.json({ ok<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">true</span> });
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>재배포 없이 브라우저에서 바로 크론을 조정할 수 있어서 편하다.</p>
<hr>
<h2 id="docker-compose-배포">Docker Compose 배포</h2>
<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">slack-bot</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">build</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">.</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">slack-bot</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;3001:3000&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">env_file</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">.env</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">./data:/app/data</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>data/</code> 폴더를 볼륨으로 마운트해서 컨테이너를 재시작해도 <code>teams.json</code>, <code>snapshot.json</code> 등 데이터가 유지된다.</p>
<h3 id="env">.env</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-env" data-lang="env"><span style="display:flex;"><span><span style="color:#79c0ff">LOGIN_ID</span><span style="color:#ff7b72;font-weight:bold">=</span>포털_아이디
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">LOGIN_PW</span><span style="color:#ff7b72;font-weight:bold">=</span>포털_비밀번호
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">PORT</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">3000</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">DATA_DIR</span><span style="color:#ff7b72;font-weight:bold">=</span>/app/data
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">TZ</span><span style="color:#ff7b72;font-weight:bold">=</span>Asia/Seoul
</span></span></code></pre></div><p><code>SLACK_BOT_TOKEN</code>은 <code>.env</code>에 넣지 않고 관리자 페이지 설정 패널에서 입력하면 <code>settings.json</code>에 저장된다. 재배포 없이 토큰을 바꿀 수 있다.</p>
<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:#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 ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm ci --omit<span style="color:#ff7b72;font-weight:bold">=</span>dev<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">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">3000</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">CMD</span> [<span style="color:#a5d6ff">&#34;node&#34;</span>, <span style="color:#a5d6ff">&#34;server.js&#34;</span>]<span style="color:#f85149">
</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-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 compose up -d --build
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># .env만 변경 시</span>
</span></span><span style="display:flex;"><span>docker compose restart
</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>docker logs slack-bot -f
</span></span></code></pre></div><h3 id="nginx-리버스-프록시">Nginx 리버스 프록시</h3>
<p>슬래시 커맨드 엔드포인트만 외부에 노출하고, 관리자 페이지는 Tailscale VPN에서만 접근한다.</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-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 외부 노출: 슬래시 커맨드, Slack 이벤트
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/slack/</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://localhost:3001</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Host</span> <span style="color:#79c0ff">$host</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Real-IP</span> <span style="color:#79c0ff">$remote_addr</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Forwarded-Proto</span> <span style="color:#79c0ff">$scheme</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"># 관리자 페이지는 Nginx에 열지 않음
</span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># → Tailscale VPN으로만 접근: http://100.109.108.36:3001/admin
</span></span></span></code></pre></div><hr>
<h2 id="트러블슈팅-모음">트러블슈팅 모음</h2>
<h3 id="docker-타임존-이슈">Docker 타임존 이슈</h3>
<p>컨테이너 기본 타임존이 UTC라서 알림이 오전 9시가 아닌 오후 6시(UTC 09:00)에 발송됐다.</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-env" data-lang="env"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># .env에 추가</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">TZ</span><span style="color:#ff7b72;font-weight:bold">=</span>Asia/Seoul
</span></span></code></pre></div><p>한 줄 추가로 해결됐다. Docker 컨테이너 운영 시 타임존은 항상 명시적으로 설정하는 게 좋다.</p>
<h3 id="같은-webhook-중복-발송">같은 Webhook 중복 발송</h3>
<p>여러 팀 엔트리가 같은 Webhook URL을 쓰는 경우, 팀 수만큼 동일한 알림이 발송됐다.</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">// 해결: webhook 기준으로 dedup
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">function</span> dedupeByWebhook(teams) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> seen <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Set();
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> teams.filter(t =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">if</span> (seen.has(t.webhook)) <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span>;
</span></span><span style="display:flex;"><span>    seen.add(t.webhook);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</span>;
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="서버-재시작-시-스냅샷-유실">서버 재시작 시 스냅샷 유실</h3>
<p>초기에 스냅샷을 메모리에만 들고 있었는데, 재시작하면 스냅샷이 초기화돼서 모든 항목이 &ldquo;새로 추가됨&quot;으로 감지되는 문제가 있었다.</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">// 서버 시작 시 snapshot.json 로드
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> loadSnapshot() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> raw <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fs.readFile(SNAPSHOT_PATH, <span style="color:#a5d6ff">&#39;utf8&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> JSON.parse(raw);
</span></span><span style="display:flex;"><span>  } <span style="color:#ff7b72">catch</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> { vacations<span style="color:#ff7b72;font-weight:bold">:</span> [], rooms<span style="color:#ff7b72;font-weight:bold">:</span> [], updatedAt<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></code></pre></div><p>파일에 저장하고 시작 시 로드하는 것만으로 해결됐다.</p>
<h3 id="pm2--docker-전환">PM2 → Docker 전환</h3>
<p>초기에는 PM2로 프로세스를 관리했다. 그런데 Node.js 버전 관리, 환경변수 주입, 재시작 정책 등을 일관되게 관리하기가 번거로웠다.</p>
<p>Docker로 전환하면서 이 문제가 모두 해결됐다. <code>restart: unless-stopped</code>로 서버가 죽어도 자동 재시작되고, 환경변수는 <code>.env</code>로 관리하고, Node.js 버전은 <code>FROM node:20-alpine</code>으로 고정된다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>4편에 걸쳐 사내 Slack 알림 봇을 만든 과정을 정리했다.</p>
<table>
  <thead>
      <tr>
          <th>편</th>
          <th>내용</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1편</td>
          <td>기획 배경 + 전체 아키텍처</td>
      </tr>
      <tr>
          <td>2편</td>
          <td>SSO 세션 처리 + 내부 API 연동</td>
      </tr>
      <tr>
          <td>3편</td>
          <td>스냅샷 비교 변동 감지 + 스케줄러 + Slack 알림</td>
      </tr>
      <tr>
          <td>4편</td>
          <td>관리자 페이지 + Docker 배포 + 트러블슈팅 ← 지금 여기</td>
      </tr>
  </tbody>
</table>
<p>DB 없이 JSON 파일만으로 상태를 관리한 게 핵심이다. 사내 툴은 오버엔지니어링할 필요가 없다. 실제로 필요한 기능만 빠르게 만들어서 쓰는 게 낫다.</p>
<p>현재 Slack 봇에서 관리하는 인사 데이터를 DB로 체계적으로 관리하는 시스템도 별도로 만들었다. 추후 두 시스템을 연동해서 인사 변동이 생기면 Slack으로 알림이 오는 흐름을 구성할 예정이다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 Slack 봇 만들기 - 스냅샷 비교 변동 감지 &#43; 스케줄러 &#43; Slack 알림 (3편)</title>
      <link>https://chanyeols.com/posts/slack-03-snapshot-scheduler/</link>
      <pubDate>Tue, 07 Apr 2026 13:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/slack-03-snapshot-scheduler/</guid>
      <description>JSON 스냅샷으로 이전 상태를 저장하고 비교해서 휴가·회의실 변동을 감지하는 로직과 node-cron 스케줄러, Slack Webhook·슬래시 커맨드 구현을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="핵심-아이디어-스냅샷-비교">핵심 아이디어: 스냅샷 비교</h2>
<p>변동 감지의 핵심은 단순하다. <strong>&ldquo;방금 가져온 데이터&quot;와 &ldquo;지난번에 저장해둔 데이터&quot;를 비교한다.</strong></p>
<ul>
<li>새로 생긴 항목 → 추가 알림</li>
<li>사라진 항목 → 취소 알림</li>
</ul>
<p>이 상태를 <code>snapshot.json</code>에 저장해두면 서버가 재시작돼도 이전 상태를 그대로 복구할 수 있다.</p>
<hr>
<h2 id="스냅샷-구조">스냅샷 구조</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-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;vacations&#34;</span>: [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;id&#34;</span>: <span style="color:#a5d6ff">&#34;unique-key&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;name&#34;</span>: <span style="color:#a5d6ff">&#34;홍길동&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;date&#34;</span>: <span style="color:#a5d6ff">&#34;2026-04-03&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;type&#34;</span>: <span style="color:#a5d6ff">&#34;연차&#34;</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">&#34;rooms&#34;</span>: [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;id&#34;</span>: <span style="color:#a5d6ff">&#34;unique-key&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;title&#34;</span>: <span style="color:#a5d6ff">&#34;주간 회의&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;room&#34;</span>: <span style="color:#a5d6ff">&#34;중회의실&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;start&#34;</span>: <span style="color:#a5d6ff">&#34;2026-04-03T10:00:00&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;end&#34;</span>: <span style="color:#a5d6ff">&#34;2026-04-03T11:00:00&#34;</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">&#34;updatedAt&#34;</span>: <span style="color:#a5d6ff">&#34;2026-04-03T08:50:00.000Z&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="변동-감지-로직">변동 감지 로직</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> detectChanges(prev, curr, keyFn) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> prevMap <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Map(prev.map(item =&gt; [keyFn(item), item]));
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> currMap <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Map(curr.map(item =&gt; [keyFn(item), item]));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> added   <span style="color:#ff7b72;font-weight:bold">=</span> curr.filter(item =&gt; <span style="color:#ff7b72;font-weight:bold">!</span>prevMap.has(keyFn(item)));
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> removed <span style="color:#ff7b72;font-weight:bold">=</span> prev.filter(item =&gt; <span style="color:#ff7b72;font-weight:bold">!</span>currMap.has(keyFn(item)));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> { added, removed };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>keyFn</code>으로 각 항목의 고유 키를 만든다. 휴가는 <code>사번+날짜</code>, 회의실은 <code>예약ID</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-javascript" data-lang="javascript"><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> vacationChanges <span style="color:#ff7b72;font-weight:bold">=</span> detectChanges(
</span></span><span style="display:flex;"><span>  snapshot.vacations,
</span></span><span style="display:flex;"><span>  freshVacations,
</span></span><span style="display:flex;"><span>  v =&gt; <span style="color:#a5d6ff">`</span><span style="color:#a5d6ff">${</span>v.id<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">_</span><span style="color:#a5d6ff">${</span>v.date<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><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> roomChanges <span style="color:#ff7b72;font-weight:bold">=</span> detectChanges(
</span></span><span style="display:flex;"><span>  snapshot.rooms,
</span></span><span style="display:flex;"><span>  freshRooms,
</span></span><span style="display:flex;"><span>  r =&gt; r.id
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><hr>
<h2 id="회의실-변동-감지-주의점">회의실 변동 감지 주의점</h2>
<p>회의실은 한 가지 함정이 있다. API가 요청한 날짜 범위보다 더 넓은 데이터를 반환하는 경우가 있어서, 날짜가 바뀌는 시점에 오탐이 발생했다.</p>
<p>예를 들어 오늘 오후 11시에 내일 예약이 새로 잡혔는데, 내일 예약은 아직 어제 스냅샷에 없으니 &ldquo;추가됨&quot;으로 오탐하는 것이다.</p>
<p><strong>해결:</strong> 날짜가 바뀐 경우 회의실 비교를 건너뛴다.</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:#ff7b72">const</span> snapshotDate <span style="color:#ff7b72;font-weight:bold">=</span> snapshot.updatedAt<span style="color:#ff7b72;font-weight:bold">?</span>.slice(<span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">10</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> today <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Date().toISOString().slice(<span style="color:#a5d6ff">0</span>, <span style="color:#a5d6ff">10</span>);
</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><span style="color:#ff7b72">if</span> (snapshotDate <span style="color:#ff7b72;font-weight:bold">===</span> today) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> roomChanges <span style="color:#ff7b72;font-weight:bold">=</span> detectChanges(snapshot.rooms, freshRooms, r =&gt; r.id);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (roomChanges.added.length <span style="color:#ff7b72;font-weight:bold">||</span> roomChanges.removed.length) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> notifyRoomChanges(roomChanges);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="스냅샷-저장">스냅샷 저장</h2>
<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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> saveSnapshot(vacations, rooms) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> snapshot <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>    vacations,
</span></span><span style="display:flex;"><span>    rooms,
</span></span><span style="display:flex;"><span>    updatedAt<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">new</span> Date().toISOString(),
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> fs.writeFile(SNAPSHOT_PATH, JSON.stringify(snapshot, <span style="color:#79c0ff">null</span>, <span style="color:#a5d6ff">2</span>));
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="node-cron-스케줄러-구성">node-cron 스케줄러 구성</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">const</span> cron <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;node-cron&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 10분마다: 변동 감지
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;*/10 * * * *&#39;</span>, () =&gt; syncAndCheckChanges());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 월요일 09시: 이번 주 전체 휴가자
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;0 9 * * 1&#39;</span>, () =&gt; sendVacationWeekly());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 화~금 09시: 당일 휴가자
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;0 9 * * 2-5&#39;</span>, () =&gt; sendVacationDaily());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 월~금 09시: 당일 회의실 예약
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;0 9 * * 1-5&#39;</span>, () =&gt; sendRoomDaily());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 월~금 09시: 출근 미등록자 DM
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;0 9 * * 1-5&#39;</span>, () =&gt; sendCommuteAlert());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 30분마다: 세션 keepalive
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;*/30 * * * *&#39;</span>, () =&gt; keepSession());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 월요일 00시: 로그 초기화
</span></span></span><span style="display:flex;"><span>cron.schedule(<span style="color:#a5d6ff">&#39;0 0 * * 1&#39;</span>, () =&gt; resetLog());
</span></span></code></pre></div><blockquote>
<p><strong>타임존 주의</strong>: node-cron의 기본 타임존은 시스템 타임존을 따른다. Docker 컨테이너 기본값은 UTC라서 <code>.env</code>에 <code>TZ=Asia/Seoul</code>을 설정하지 않으면 알림이 9시간 늦게 발송된다.</p>
</blockquote>
<hr>
<h2 id="크론-설정을-파일로-관리">크론 설정을 파일로 관리</h2>
<p>크론 스케줄을 코드에 하드코딩하면 수정할 때마다 재배포해야 한다. <code>settings.json</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-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;crons&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;sync&#34;</span>:           { <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,  <span style="color:#7ee787">&#34;schedule&#34;</span>: <span style="color:#a5d6ff">&#34;*/10 * * * *&#34;</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;vacationWeekly&#34;</span>: { <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,  <span style="color:#7ee787">&#34;schedule&#34;</span>: <span style="color:#a5d6ff">&#34;0 9 * * 1&#34;</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;vacationDaily&#34;</span>:  { <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,  <span style="color:#7ee787">&#34;schedule&#34;</span>: <span style="color:#a5d6ff">&#34;0 9 * * 2-5&#34;</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;roomDaily&#34;</span>:      { <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,  <span style="color:#7ee787">&#34;schedule&#34;</span>: <span style="color:#a5d6ff">&#34;0 9 * * 1-5&#34;</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;commute&#34;</span>:        { <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,  <span style="color:#7ee787">&#34;schedule&#34;</span>: <span style="color:#a5d6ff">&#34;0 9 * * 1-5&#34;</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;sessionKeep&#34;</span>:    { <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,  <span style="color:#7ee787">&#34;schedule&#34;</span>: <span style="color:#a5d6ff">&#34;*/30 * * * *&#34;</span> },
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;logReset&#34;</span>:       { <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,  <span style="color:#7ee787">&#34;schedule&#34;</span>: <span style="color:#a5d6ff">&#34;0 0 * * 1&#34;</span> }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> settings <span style="color:#ff7b72;font-weight:bold">=</span> JSON.parse(<span style="color:#ff7b72">await</span> fs.readFile(SETTINGS_PATH));
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> cronJobs <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:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> [id, config] <span style="color:#ff7b72">of</span> Object.entries(settings.crons)) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72;font-weight:bold">!</span>config.enabled) <span style="color:#ff7b72">continue</span>;
</span></span><span style="display:flex;"><span>  cronJobs[id] <span style="color:#ff7b72;font-weight:bold">=</span> cron.schedule(config.schedule, cronHandlers[id]);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="slack-webhook-알림-발송">Slack Webhook 알림 발송</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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> sendWebhook(webhookUrl, message) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> axios.post(webhookUrl, { text<span style="color:#ff7b72;font-weight:bold">:</span> message });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="팀별-알림-타입-필터링">팀별 알림 타입 필터링</h3>
<p>팀마다 받고 싶은 알림 타입이 다를 수 있다. <code>teams.json</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-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;teams&#34;</span>: [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;name&#34;</span>: <span style="color:#a5d6ff">&#34;개발팀&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;webhook&#34;</span>: <span style="color:#a5d6ff">&#34;https://hooks.slack.com/...&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;enabled&#34;</span>: <span style="color:#79c0ff">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;alertTypes&#34;</span>: {
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;vacationChange&#34;</span>: <span style="color:#79c0ff">true</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;vacationDaily&#34;</span>: <span style="color:#79c0ff">true</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;roomChange&#34;</span>: <span style="color:#79c0ff">false</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;roomDaily&#34;</span>: <span style="color:#79c0ff">true</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;commute&#34;</span>: <span style="color:#79c0ff">false</span>
</span></span><span style="display:flex;"><span>      },
</span></span><span style="display:flex;"><span>      <span style="color:#7ee787">&#34;deptIds&#34;</span>: [<span style="color:#a5d6ff">&#34;0001&#34;</span>, <span style="color:#a5d6ff">&#34;0002&#34;</span>]
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> notifyByType(type, message) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> teams <span style="color:#ff7b72;font-weight:bold">=</span> settings.teams.filter(
</span></span><span style="display:flex;"><span>    t =&gt; t.enabled <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> t.alertTypes[type]
</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> team <span style="color:#ff7b72">of</span> teams) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> sendWebhook(team.webhook, message);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="같은-webhook-중복-발송-방지">같은 Webhook 중복 발송 방지</h3>
<p>여러 팀이 같은 Webhook URL을 쓰는 경우가 있다. 그냥 발송하면 같은 채널에 동일한 알림이 여러 번 온다.</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:#ff7b72">function</span> dedupeByWebhook(teams) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> seen <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Set();
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> teams.filter(team =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">if</span> (seen.has(team.webhook)) <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">false</span>;
</span></span><span style="display:flex;"><span>    seen.add(team.webhook);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">true</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">async</span> <span style="color:#ff7b72">function</span> notifyByType(type, message) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> teams <span style="color:#ff7b72;font-weight:bold">=</span> dedupeByWebhook(
</span></span><span style="display:flex;"><span>    settings.teams.filter(t =&gt; t.enabled <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> t.alertTypes[type])
</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> team <span style="color:#ff7b72">of</span> teams) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> sendWebhook(team.webhook, message);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="slack-슬래시-커맨드">Slack 슬래시 커맨드</h2>
<p>슬래시 커맨드는 Slack이 POST 요청을 서버로 보내는 방식이다. 3초 안에 응답하지 않으면 타임아웃이 난다.</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>app.post(<span style="color:#a5d6ff">&#39;/slack/command&#39;</span>, <span style="color:#ff7b72">async</span> (req, res) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> { command, text, channel_id } <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:#8b949e;font-style:italic">// Slack에 일단 빈 응답 먼저 → 3초 타임아웃 방지
</span></span></span><span style="display:flex;"><span>  res.json({ response_type<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;ephemeral&#39;</span>, text<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:#8b949e;font-style:italic">// 실제 처리는 비동기로
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> result <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> handleCommand(command, text, channel_id);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> axios.post(req.body.response_url, {
</span></span><span style="display:flex;"><span>    response_type<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;in_channel&#39;</span>,
</span></span><span style="display:flex;"><span>    text<span style="color:#ff7b72;font-weight:bold">:</span> result,
</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:#ff7b72">function</span> parseDate(text) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72;font-weight:bold">!</span>text <span style="color:#ff7b72;font-weight:bold">||</span> text <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;오늘&#39;</span>) <span style="color:#ff7b72">return</span> [today(), today()];
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (text <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;내일&#39;</span>) <span style="color:#ff7b72">return</span> [tomorrow(), tomorrow()];
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (text <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#39;이번주&#39;</span>) <span style="color:#ff7b72">return</span> [monday(), friday()];
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (text.includes(<span style="color:#a5d6ff">&#39;~&#39;</span>)) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> [start, end] <span style="color:#ff7b72;font-weight:bold">=</span> text.split(<span style="color:#a5d6ff">&#39;~&#39;</span>).map(s =&gt; s.trim());
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> [start, end];
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 날짜 직접 입력: &#39;2026-04-03&#39;
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> [text.trim(), text.trim()];
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="채널-기반-팀-매칭">채널 기반 팀 매칭</h3>
<p>채널 ID로 어느 팀 채널인지 파악해서 해당 팀 휴가자만 보여준다.</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:#ff7b72">function</span> getTeamByChannel(channelId) {
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 1. 채널 ID로 팀 매칭
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> team <span style="color:#ff7b72;font-weight:bold">=</span> teams.find(t =&gt; t.channelId <span style="color:#ff7b72;font-weight:bold">===</span> channelId);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (team) <span style="color:#ff7b72">return</span> team;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 2. 실패 시 → 사용자 Slack 이름에서 팀 추출
</span></span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 3. 그것도 실패 시 → 전체 팀 그룹핑해서 표시
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">null</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="메시지-포맷-예시">메시지 포맷 예시</h2>
<h3 id="당일-휴가자">당일 휴가자</h3>
<p><img alt="Slack 오전 정기 알림 - 당일 휴가자" loading="lazy" src="/images/slack-01-daily.png"></p>
<h3 id="회의실-예약-현황">회의실 예약 현황</h3>
<p><img alt="Slack 회의실 예약 현황 알림" loading="lazy" src="/images/slack-01-daily.png"></p>
<h3 id="휴가-변동-알림">휴가 변동 알림</h3>
<p><img alt="Slack 휴가 변동 알림" loading="lazy" src="/images/slack-01-vacation-change.png"></p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>스냅샷 비교로 변동 감지 → DB 없이 JSON 파일만으로 충분</li>
<li>회의실 변동은 날짜가 바뀌는 시점에 오탐 주의, 날짜 동일할 때만 비교</li>
<li>node-cron 타임존은 반드시 확인, Docker면 <code>TZ=Asia/Seoul</code> 필수</li>
<li>슬래시 커맨드는 3초 타임아웃 때문에 즉시 응답 후 비동기 처리</li>
<li>같은 Webhook URL 중복 발송은 dedup 처리</li>
</ul>
<p>다음 편에서는 관리자 페이지 구현과 Docker 배포, 실제 겪은 트러블슈팅을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 Slack 봇 만들기 - SSO 세션 처리 &#43; 내부 API 연동 (2편)</title>
      <link>https://chanyeols.com/posts/slack-02-sso/</link>
      <pubDate>Mon, 06 Apr 2026 11:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/slack-02-sso/</guid>
      <description>사내 그룹웨어 SSO 로그인을 axios로 처리하는 방법과 쿠키 기반 세션 유지 패턴을 정리합니다. 도메인별 세션 분리, 수동 리다이렉트 처리 등 실제 겪은 함정들을 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="문제-그룹웨어-api를-어떻게-호출하나">문제: 그룹웨어 API를 어떻게 호출하나</h2>
<p>사내 그룹웨어는 브라우저에서 로그인한 세션 쿠키로 API를 호출하는 구조다. 공개 API키 같은 게 없고, 그냥 브라우저처럼 로그인해서 쿠키를 들고 API를 쳐야 한다.</p>
<p>Node.js에서 이걸 하려면 axios로 로그인 과정을 그대로 흉내내야 한다.</p>
<hr>
<h2 id="sso-로그인-흐름">SSO 로그인 흐름</h2>
<p>대부분의 사내 SSO는 아래 패턴을 따른다.</p>
<pre tabindex="0"><code>1단계: 로그인 페이지 GET → 초기 세션 쿠키 획득
2단계: 로그인 form POST → 인증 처리
3단계: SSO 토큰 발급 페이지 GET → 리다이렉트로 토큰 획득
4단계: 대상 도메인에 토큰으로 접근 → 해당 도메인 세션 쿠키 획득
</code></pre><p>핵심은 <strong>각 단계에서 받은 쿠키를 다음 요청에 그대로 넘겨줘야</strong> 한다는 것이다.</p>
<hr>
<h2 id="axios로-sso-구현하기">axios로 SSO 구현하기</h2>
<h3 id="쿠키-파싱-헬퍼">쿠키 파싱 헬퍼</h3>
<p>axios는 브라우저와 달리 쿠키를 자동으로 관리해주지 않는다. 응답 헤더에서 직접 파싱해야 한다.</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:#ff7b72">function</span> parseCookies(setCookieHeader) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72;font-weight:bold">!</span>setCookieHeader) <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> cookies <span style="color:#ff7b72;font-weight:bold">=</span> Array.isArray(setCookieHeader)
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72;font-weight:bold">?</span> setCookieHeader
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72;font-weight:bold">:</span> [setCookieHeader];
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> cookies
</span></span><span style="display:flex;"><span>    .map(c =&gt; c.split(<span style="color:#a5d6ff">&#39;;&#39;</span>)[<span style="color:#a5d6ff">0</span>])  <span style="color:#8b949e;font-style:italic">// &#39;name=value; Path=/&#39; → &#39;name=value&#39;
</span></span></span><span style="display:flex;"><span>    .join(<span style="color:#a5d6ff">&#39;; &#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="1단계-초기-세션-쿠키-획득">1단계: 초기 세션 쿠키 획득</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> initRes <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> axios.get(<span style="color:#a5d6ff">&#39;https://portal.company.com/login&#39;</span>, {
</span></span><span style="display:flex;"><span>  maxRedirects<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">0</span>,
</span></span><span style="display:flex;"><span>  validateStatus<span style="color:#ff7b72;font-weight:bold">:</span> s =&gt; s <span style="color:#ff7b72;font-weight:bold">&lt;</span> <span style="color:#a5d6ff">400</span>,
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">let</span> cookie <span style="color:#ff7b72;font-weight:bold">=</span> parseCookies(initRes.headers[<span style="color:#a5d6ff">&#39;set-cookie&#39;</span>]);
</span></span></code></pre></div><h3 id="2단계-로그인">2단계: 로그인</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> loginRes <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> axios.post(
</span></span><span style="display:flex;"><span>  <span style="color:#a5d6ff">&#39;https://portal.company.com/login/check&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">new</span> URLSearchParams({
</span></span><span style="display:flex;"><span>    id<span style="color:#ff7b72;font-weight:bold">:</span> process.env.LOGIN_ID,
</span></span><span style="display:flex;"><span>    password<span style="color:#ff7b72;font-weight:bold">:</span> process.env.LOGIN_PW,
</span></span><span style="display:flex;"><span>  }),
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    headers<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>      Cookie<span style="color:#ff7b72;font-weight:bold">:</span> cookie,
</span></span><span style="display:flex;"><span>      <span style="color:#a5d6ff">&#39;Content-Type&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;application/x-www-form-urlencoded&#39;</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    maxRedirects<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">0</span>,
</span></span><span style="display:flex;"><span>    validateStatus<span style="color:#ff7b72;font-weight:bold">:</span> s =&gt; s <span style="color:#ff7b72;font-weight:bold">&lt;</span> <span style="color:#a5d6ff">400</span>,
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>cookie <span style="color:#ff7b72;font-weight:bold">=</span> parseCookies(loginRes.headers[<span style="color:#a5d6ff">&#39;set-cookie&#39;</span>]) <span style="color:#ff7b72;font-weight:bold">||</span> cookie;
</span></span></code></pre></div><p>로그인 요청은 form-data(<code>application/x-www-form-urlencoded</code>) 방식으로 보내야 한다. <code>axios.post</code>에 객체를 그냥 넘기면 JSON으로 보내져서 로그인이 안 된다. <code>URLSearchParams</code>로 감싸줘야 한다.</p>
<h3 id="3단계-sso-토큰-발급">3단계: SSO 토큰 발급</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> ssoRes <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> axios.get(<span style="color:#a5d6ff">&#39;https://portal.company.com/sso-redirect&#39;</span>, {
</span></span><span style="display:flex;"><span>  headers<span style="color:#ff7b72;font-weight:bold">:</span> { Cookie<span style="color:#ff7b72;font-weight:bold">:</span> cookie },
</span></span><span style="display:flex;"><span>  maxRedirects<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">0</span>,  <span style="color:#8b949e;font-style:italic">// 리다이렉트 수동으로 따라가야 함
</span></span></span><span style="display:flex;"><span>  validateStatus<span style="color:#ff7b72;font-weight:bold">:</span> s =&gt; s <span style="color:#ff7b72;font-weight:bold">&lt;</span> <span style="color:#a5d6ff">400</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">// 리다이렉트 URL에서 토큰 추출
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> ssoUrl <span style="color:#ff7b72;font-weight:bold">=</span> ssoRes.headers[<span style="color:#a5d6ff">&#39;location&#39;</span>];
</span></span></code></pre></div><p><code>maxRedirects: 0</code>이 중요하다. axios가 자동으로 리다이렉트를 따라가면 쿠키가 유실된다. 리다이렉트를 수동으로 처리해야 각 단계의 쿠키를 직접 챙길 수 있다.</p>
<h3 id="4단계-대상-도메인-세션-획득">4단계: 대상 도메인 세션 획득</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> domainRes <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> axios.get(ssoUrl, {
</span></span><span style="display:flex;"><span>  maxRedirects<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">5</span>,  <span style="color:#8b949e;font-style:italic">// 여기서는 따라가도 됨
</span></span></span><span style="display:flex;"><span>  validateStatus<span style="color:#ff7b72;font-weight:bold">:</span> s =&gt; s <span style="color:#ff7b72;font-weight:bold">&lt;</span> <span style="color:#a5d6ff">400</span>,
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> domainCookie <span style="color:#ff7b72;font-weight:bold">=</span> parseCookies(domainRes.headers[<span style="color:#a5d6ff">&#39;set-cookie&#39;</span>]);
</span></span></code></pre></div><p>이 <code>domainCookie</code>가 실제 API 호출에 쓰는 세션이다.</p>
<hr>
<h2 id="도메인별-세션-분리">도메인별 세션 분리</h2>
<p>우리 그룹웨어는 기능별로 서브도메인이 달랐다. 예를 들면 휴가는 <code>leave.company.com</code>, 회의실은 <code>gw.company.com</code> 식이다.</p>
<p>문제는 <strong>도메인이 다르면 쿠키가 공유되지 않는다</strong>는 거다. 각 도메인에 별도로 SSO 로그인을 해야 한다.</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">// 서버 시작 시 두 도메인 모두 로그인
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">let</span> avsCookie <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> login(<span style="color:#a5d6ff">&#39;leave&#39;</span>);  <span style="color:#8b949e;font-style:italic">// 휴가 도메인
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">let</span> gwCookie  <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> login(<span style="color:#a5d6ff">&#39;gw&#39;</span>);     <span style="color:#8b949e;font-style:italic">// 회의실 도메인
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// API 호출 시 해당 도메인 쿠키 사용
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> getVacation(date) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> axios.post(VACATION_API_URL, payload, {
</span></span><span style="display:flex;"><span>    headers<span style="color:#ff7b72;font-weight:bold">:</span> { Cookie<span style="color:#ff7b72;font-weight:bold">:</span> avsCookie, ...customHeaders },
</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">async</span> <span style="color:#ff7b72">function</span> getMeetingRooms(date) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> axios.post(ROOM_API_URL, payload, {
</span></span><span style="display:flex;"><span>    headers<span style="color:#ff7b72;font-weight:bold">:</span> { Cookie<span style="color:#ff7b72;font-weight:bold">:</span> gwCookie, ...customHeaders },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="커스텀-헤더-문제">커스텀 헤더 문제</h2>
<p>API를 호출했는데 404나 의미 없는 오류 코드가 계속 반환됐다. 로그인도 됐고 쿠키도 맞는데 왜 안 되나 한참 삽질했다.</p>
<p>브라우저 Network 탭을 열어서 실제 요청을 비교해보니 커스텀 헤더들이 빠져있었다.</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:#ff7b72">const</span> customHeaders <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a5d6ff">&#39;__service_id__&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;SERVICE_NAME&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a5d6ff">&#39;__view_id__&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;view-identifier&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a5d6ff">&#39;__menu_id__&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;MENU_CODE&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a5d6ff">&#39;ajax&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;true&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a5d6ff">&#39;x-requested-with&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;XMLHttpRequest&#39;</span>,
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>그룹웨어 API는 이런 커스텀 헤더로 어떤 서비스/메뉴에서 요청이 왔는지 검증한다. 빠지면 요청이 거부된다.</p>
<p><strong>해결법:</strong> 브라우저 Network 탭에서 실제 API 요청을 찾아 Request Headers를 전부 복사해서 동일하게 맞춰줬다.</p>
<hr>
<h2 id="세션-유지-30분마다-keepalive">세션 유지 (30분마다 keepalive)</h2>
<p>SSO 세션은 일정 시간 요청이 없으면 만료된다. 봇이 새벽에 아무것도 안 하다가 아침에 알림을 보내려 하면 세션이 끊겨있는 상황이 생긴다.</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>cron.schedule(<span style="color:#a5d6ff">&#39;*/30 * * * *&#39;</span>, <span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// 세션 페이지에 GET 요청으로 keepalive
</span></span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> axios.get(SESSION_KEEP_URL, {
</span></span><span style="display:flex;"><span>      headers<span style="color:#ff7b72;font-weight:bold">:</span> { Cookie<span style="color:#ff7b72;font-weight:bold">:</span> avsCookie },
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> axios.get(SESSION_KEEP_URL_GW, {
</span></span><span style="display:flex;"><span>      headers<span style="color:#ff7b72;font-weight:bold">:</span> { Cookie<span style="color:#ff7b72;font-weight:bold">:</span> gwCookie },
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  } <span style="color:#ff7b72">catch</span> (err) {
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// 실패 시 재로그인
</span></span></span><span style="display:flex;"><span>    avsCookie <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> login(<span style="color:#a5d6ff">&#39;leave&#39;</span>);
</span></span><span style="display:flex;"><span>    gwCookie  <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> login(<span style="color:#a5d6ff">&#39;gw&#39;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>keepalive 실패 시 재로그인하도록 해두면 세션이 끊겨도 자동 복구된다.</p>
<hr>
<h2 id="메모리-캐시로-api-호출-줄이기">메모리 캐시로 API 호출 줄이기</h2>
<p>같은 날짜 데이터를 10분마다 계속 API에서 가져오면 서버에 부담이 된다. 메모리 캐시로 불필요한 호출을 줄였다.</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:#ff7b72">const</span> cache <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Map();
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> CACHE_TTL <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">15</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">1000</span>; <span style="color:#8b949e;font-style:italic">// 15분
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> fetchWithCache(key, fetchFn) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> cached <span style="color:#ff7b72;font-weight:bold">=</span> cache.get(key);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (cached <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> Date.now() <span style="color:#ff7b72;font-weight:bold">-</span> cached.time <span style="color:#ff7b72;font-weight:bold">&lt;</span> CACHE_TTL) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> cached.data;
</span></span><span style="display:flex;"><span>  }
</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> fetchFn();
</span></span><span style="display:flex;"><span>  cache.set(key, { data, time<span style="color:#ff7b72;font-weight:bold">:</span> Date.now() });
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> data;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="트러블슈팅-정리">트러블슈팅 정리</h2>
<table>
  <thead>
      <tr>
          <th>증상</th>
          <th>원인</th>
          <th>해결</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>로그인 후 API 호출 시 인증 오류</td>
          <td>axios가 리다이렉트 자동 처리 중 쿠키 유실</td>
          <td><code>maxRedirects: 0</code>으로 수동 처리</td>
      </tr>
      <tr>
          <td>API 404 / 의미 없는 오류 코드</td>
          <td>커스텀 헤더 누락</td>
          <td>브라우저 Network 탭에서 헤더 전부 확인 후 동일하게 설정</td>
      </tr>
      <tr>
          <td>아침 알림 시 세션 만료</td>
          <td>SSO 세션 TTL 초과</td>
          <td>30분마다 keepalive + 실패 시 재로그인</td>
      </tr>
      <tr>
          <td>같은 도메인인데 API마다 세션 다름</td>
          <td>서브도메인별 쿠키 분리</td>
          <td>도메인별 별도 로그인 세션 관리</td>
      </tr>
      <tr>
          <td>form 로그인 안 됨</td>
          <td>axios POST에 JSON으로 전송됨</td>
          <td><code>URLSearchParams</code>로 감싸서 form-data 형식으로 전송</td>
      </tr>
  </tbody>
</table>
<hr>
<p>다음 편에서는 수집한 데이터를 스냅샷으로 관리하고, 이전 상태와 비교해서 변동을 감지하는 로직과 Slack 알림 발송 구현을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>사내 Slack 봇 만들기 - 기획 배경 &#43; 전체 아키텍처 (1편)</title>
      <link>https://chanyeols.com/posts/slack-01-intro/</link>
      <pubDate>Sun, 05 Apr 2026 10:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/slack-01-intro/</guid>
      <description>조직 개편으로 생긴 불편함을 해결하기 위해 휴가·회의실·출근 알림 Slack 봇을 만든 배경과 전체 아키텍처를 소개합니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-만들게-됐나">왜 만들게 됐나</h2>
<p>조직 개편 전에는 팀원들이 한 채널에 모여 있어서 누가 휴가인지, 어떤 회의실이 예약됐는지 슬쩍 보면 알 수 있었다.</p>
<p>개편 이후 팀이 분리되면서 연계 채널이 새로 생겼다. 그런데 서로 다른 팀 채널에 있다 보니 상대 팀의 휴가·회의실 정보를 알기가 불편해졌다. 매번 그룹웨어에 들어가서 확인하는 게 번거로웠다.</p>
<p>그래서 만들었다. 매일 아침 Slack으로 당일 휴가자와 회의실 예약 현황을 자동으로 보내주는 봇.</p>
<p>만들다 보니 기능이 붙었다. 출근 미등록자 DM 알림, 슬래시 커맨드로 날짜별 조회, 관리자 웹 페이지까지.</p>
<hr>
<h2 id="기술-스택">기술 스택</h2>
<table>
  <thead>
      <tr>
          <th>영역</th>
          <th>기술</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Runtime</td>
          <td>Node.js 20</td>
      </tr>
      <tr>
          <td>Framework</td>
          <td>Express.js</td>
      </tr>
      <tr>
          <td>스케줄러</td>
          <td>node-cron</td>
      </tr>
      <tr>
          <td>HTTP 클라이언트</td>
          <td>axios</td>
      </tr>
      <tr>
          <td>배포</td>
          <td>Docker Compose</td>
      </tr>
      <tr>
          <td>인프라</td>
          <td>OCI 서버 (홈서버에서 OCI로 이전)</td>
      </tr>
      <tr>
          <td>리버스 프록시</td>
          <td>Nginx</td>
      </tr>
      <tr>
          <td>데이터 저장</td>
          <td>JSON 파일 (DB 없음)</td>
      </tr>
  </tbody>
</table>
<p>DB 없이 JSON 파일로 상태를 관리한 이유는 단순하다. 저장해야 할 데이터가 &ldquo;마지막으로 확인한 휴가/회의실 목록&rdquo; 하나뿐이라 PostgreSQL까지 쓸 이유가 없었다.</p>
<hr>
<h2 id="전체-아키텍처">전체 아키텍처</h2>
<pre tabindex="0"><code>[그룹웨어 API]
      │
      │ SSO 인증 → 휴가 / 회의실 / 출근 데이터 수집
      ▼
[Node.js 봇 서버]  ←→  [JSON 파일]
      │                  teams.json     (팀·Webhook 설정)
      │                  members.json   (출근 알림 대상)
      │                  settings.json  (Slack 토큰, 크론 설정)
      │                  snapshot.json  (마지막 상태 저장)
      │
      ├─ 크론 스케줄 → Slack Webhook 알림
      ├─ 슬래시 커맨드 → Slack API 응답
      └─ /admin 관리자 페이지
</code></pre><p>외부에서 <code>/admin</code>은 Tailscale VPN으로만 접근하고, 슬래시 커맨드 엔드포인트(<code>/slack/command</code>)만 Nginx를 통해 외부에 열어둔다.</p>
<hr>
<h2 id="전체-워크플로우">전체 워크플로우</h2>
<p><img alt="전체 워크플로우 다이어그램" loading="lazy" src="/images/slack-01-workflow.png"></p>
<p>크게 세 가지 흐름으로 동작한다.</p>
<p><strong>1. 서버 시작 시</strong></p>
<ul>
<li><code>snapshot.json</code> 로드 (재시작 후에도 이전 상태 복구)</li>
<li>그룹웨어 SSO 로그인 → 세션 취득</li>
<li>마지막 스냅샷이 10분 초과됐으면 즉시 동기화 실행</li>
</ul>
<p><strong>2. 매 10분 정기 동기화 (<code>sync</code> 크론)</strong></p>
<ul>
<li>휴가·회의실 데이터를 API에서 새로 가져옴</li>
<li>이전 스냅샷과 비교해서 추가/취소된 건 감지</li>
<li>변동이 있으면 Slack Webhook으로 즉시 알림 발송</li>
<li>스냅샷 갱신 후 <code>snapshot.json</code> 저장</li>
</ul>
<p><strong>3. 오전 9시 정기 발송</strong></p>
<ul>
<li>월요일: 이번 주 전체 휴가자 + 당일 회의실 예약</li>
<li>화~금: 당일 휴가자 + 당일 회의실 예약</li>
<li>출근 미등록자 → 개인 Slack DM 발송</li>
</ul>
<hr>
<h2 id="알림-종류">알림 종류</h2>
<h3 id="휴가-변동-알림">휴가 변동 알림</h3>
<p>휴가가 새로 등록되거나 취소되면 즉시 알림이 온다.</p>
<p><img alt="Slack 휴가 변동 알림" loading="lazy" src="/images/slack-01-vacation-change.png"></p>
<h3 id="오전-정기-알림">오전 정기 알림</h3>
<p>매일 아침 9시에 당일 휴가자와 회의실 예약 현황을 한번에 보내준다.</p>
<p><img alt="Slack 오전 정기 알림 - 휴가자 + 회의실" loading="lazy" src="/images/slack-01-daily.png"></p>
<h3 id="출근-미등록-dm">출근 미등록 DM</h3>
<p>출근 미등록자에게는 채널 알림 대신 개인 DM으로 조용히 보낸다.</p>
<p><img alt="출근 미등록 Slack DM" loading="lazy" src="/images/slack-01-commute-dm.png"></p>
<h3 id="슬래시-커맨드">슬래시 커맨드</h3>
<p>Slack에서 직접 조회도 된다.</p>
<p><img alt="/휴가 슬래시 커맨드 결과" loading="lazy" src="/images/slack-01-slash.png"></p>
<hr>
<h2 id="파일-구조">파일 구조</h2>
<pre tabindex="0"><code>~/slackbot/
├── Dockerfile
├── docker-compose.yml
├── .env
├── server.js
├── package.json
└── data/                ← 볼륨 마운트 (재시작 시 유지)
    ├── teams.json        (팀·Webhook 설정)
    ├── members.json      (출근 알림 대상 멤버)
    ├── settings.json     (Slack Bot Token, 크론 설정)
    ├── snapshot.json     (마지막 상태 저장)
    └── server.log
</code></pre><p><code>data/</code> 폴더를 볼륨으로 마운트해서 컨테이너를 재시작해도 설정과 상태가 유지된다.</p>
<hr>
<h2 id="크론-스케줄-전체-목록">크론 스케줄 전체 목록</h2>
<table>
  <thead>
      <tr>
          <th>크론 ID</th>
          <th>시간</th>
          <th>내용</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>sync</code></td>
          <td>10분마다</td>
          <td>휴가/회의실 변동 감지 → 알림</td>
      </tr>
      <tr>
          <td><code>vacationWeekly</code></td>
          <td>월요일 09시</td>
          <td>이번 주 전체 휴가자</td>
      </tr>
      <tr>
          <td><code>vacationDaily</code></td>
          <td>화~금 09시</td>
          <td>당일 휴가자</td>
      </tr>
      <tr>
          <td><code>roomDaily</code></td>
          <td>월~금 09시</td>
          <td>당일 회의실 예약 현황</td>
      </tr>
      <tr>
          <td><code>commute</code></td>
          <td>월~금 09시</td>
          <td>출근 미등록자 DM</td>
      </tr>
      <tr>
          <td><code>sessionKeep</code></td>
          <td>30분마다</td>
          <td>세션 유지 ping</td>
      </tr>
      <tr>
          <td><code>logReset</code></td>
          <td>월요일 00시</td>
          <td>로그 파일 초기화</td>
      </tr>
  </tbody>
</table>
<p>모든 크론은 관리자 페이지에서 활성화/비활성화하고 스케줄 표현식도 수정할 수 있다.</p>
<hr>
<p>다음 편에서는 그룹웨어 SSO 로그인 세션을 처리하는 방법을 다룬다. 쿠키 기반 SSO를 axios로 처리할 때 빠지기 쉬운 함정들을 정리한다.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
