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