<?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>Express on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/express/</link>
    <description>Recent content in Express on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Wed, 08 Apr 2026 15:00:00 +0900</lastBuildDate>
    <atom:link href="https://chanyeols.com/tags/express/index.xml" rel="self" type="application/rss+xml" />
    <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>
  </channel>
</rss>
