<?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%9E%90%EB%8F%99%ED%99%94/</link>
    <description>Recent content in 자동화 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/%EC%9E%90%EB%8F%99%ED%99%94/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>
    <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 봇 만들기 - 기획 배경 &#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>
