왜 관리자 페이지가 필요한가

처음엔 팀 Webhook이 하나라 teams.json을 직접 수정해도 됐다. 그런데 팀이 늘어나고, 알림 타입을 팀별로 다르게 설정하고 싶어지고, 출근 알림 대상 멤버도 자주 바뀌면서 파일을 직접 건드리는 게 너무 번거로워졌다.

그래서 관리자 페이지를 만들었다. React 같은 프레임워크 없이 Express + 바닐라 JS로 만들었다. 관리자 혼자 쓰는 내부 툴이라 빌드 파이프라인 없이 심플하게 가는 게 낫다고 판단했다.


관리자 페이지 구성

관리자 페이지 전체 화면

패널 4개로 구성된다.

패널 기능
팀·Webhook 팀 추가/수정/삭제, 활성 토글, 알림 타입 배지 토글, 테스트 발송
출근알림 멤버 멤버 추가/수정/삭제, Slack 이름 자동완성
출근현황 카드뷰 출근/미출근 현황, 수동 알림 발송
크론 관리 크론 활성화 토글, 스케줄 표현식 수정
설정 Slack Bot Token 관리

팀·Webhook 패널

팀별로 6가지 알림 타입을 배지 형태로 토글할 수 있다.

// 알림 타입 배지 토글
document.querySelectorAll('.alert-badge').forEach(badge => {
  badge.addEventListener('click', async () => {
    const type = badge.dataset.type;
    const teamId = badge.closest('[data-team-id]').dataset.teamId;

    badge.classList.toggle('active');

    await fetch(`/api/teams/${teamId}/alert-types`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ type, enabled: badge.classList.contains('active') }),
    });
  });
});

테스트 버튼을 누르면 해당 Webhook으로 즉시 테스트 메시지를 발송해서 연결 여부를 확인할 수 있다.


출근 알림 멤버 관리

출근 미등록 DM을 보내려면 멤버의 사번과 Slack 유저 ID가 필요하다.

Slack 유저 이름 자동완성 기능을 넣었다. 입력하면 Bot Token으로 Slack 멤버 목록을 가져와서 필터링한다.

async function fetchSlackMembers() {
  const res = await fetch('https://slack.com/api/users.list', {
    headers: { Authorization: `Bearer ${BOT_TOKEN}` },
  });
  const data = await res.json();
  return data.members
    .filter(m => !m.is_bot && !m.deleted)
    .map(m => ({
      id: m.id,
      name: m.profile.display_name || m.real_name,
    }));
}

selectedIndex 버그

Slack 이름 자동완성에서 한 가지 버그가 있었다. 같은 팀에 동명이인이 있을 때 select.value = "targetValue"로 설정하면 항상 첫 번째 옵션으로 스냅되는 문제였다.

// 버그 있는 코드
select.value = member.slackId;  // 항상 첫 번째로 스냅됨

// 해결: selectedIndex 직접 지정
const idx = [...select.options].findIndex(o => o.value === member.slackId);
if (idx !== -1) select.selectedIndex = idx;

value 방식은 같은 value를 가진 옵션이 여러 개일 때 첫 번째로 스냅된다. selectedIndex를 직접 지정하면 원하는 옵션을 정확하게 선택할 수 있다.


크론 관리 패널

크론을 활성화/비활성화하고 스케줄 표현식을 수정하면 서버에서 실시간으로 크론을 재등록한다.

// 크론 재등록 API
app.patch('/api/crons/:id', async (req, res) => {
  const { id } = req.params;
  const { enabled, schedule } = req.body;

  // 기존 크론 중지
  if (cronJobs[id]) {
    cronJobs[id].stop();
    delete cronJobs[id];
  }

  // 설정 저장
  settings.crons[id] = { enabled, schedule };
  await saveSettings();

  // 활성화 상태면 재등록
  if (enabled) {
    cronJobs[id] = cron.schedule(schedule, cronHandlers[id]);
  }

  res.json({ ok: true });
});

재배포 없이 브라우저에서 바로 크론을 조정할 수 있어서 편하다.


Docker Compose 배포

docker-compose.yml

services:
  slack-bot:
    build: .
    container_name: slack-bot
    ports:
      - "3001:3000"
    env_file:
      - .env
    volumes:
      - ./data:/app/data
    restart: unless-stopped

data/ 폴더를 볼륨으로 마운트해서 컨테이너를 재시작해도 teams.json, snapshot.json 등 데이터가 유지된다.

.env

LOGIN_ID=포털_아이디
LOGIN_PW=포털_비밀번호
PORT=3000
DATA_DIR=/app/data
TZ=Asia/Seoul

SLACK_BOT_TOKEN.env에 넣지 않고 관리자 페이지 설정 패널에서 입력하면 settings.json에 저장된다. 재배포 없이 토큰을 바꿀 수 있다.

Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

배포 명령어

# 최초 실행 또는 코드 변경 시
docker compose up -d --build

# .env만 변경 시
docker compose restart

# 로그 확인
docker logs slack-bot -f

Nginx 리버스 프록시

슬래시 커맨드 엔드포인트만 외부에 노출하고, 관리자 페이지는 Tailscale VPN에서만 접근한다.

# 외부 노출: 슬래시 커맨드, Slack 이벤트
location /slack/ {
    proxy_pass http://localhost:3001;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
}

# 관리자 페이지는 Nginx에 열지 않음
# → Tailscale VPN으로만 접근: http://100.109.108.36:3001/admin

트러블슈팅 모음

Docker 타임존 이슈

컨테이너 기본 타임존이 UTC라서 알림이 오전 9시가 아닌 오후 6시(UTC 09:00)에 발송됐다.

# .env에 추가
TZ=Asia/Seoul

한 줄 추가로 해결됐다. Docker 컨테이너 운영 시 타임존은 항상 명시적으로 설정하는 게 좋다.

같은 Webhook 중복 발송

여러 팀 엔트리가 같은 Webhook URL을 쓰는 경우, 팀 수만큼 동일한 알림이 발송됐다.

// 해결: webhook 기준으로 dedup
function dedupeByWebhook(teams) {
  const seen = new Set();
  return teams.filter(t => {
    if (seen.has(t.webhook)) return false;
    seen.add(t.webhook);
    return true;
  });
}

서버 재시작 시 스냅샷 유실

초기에 스냅샷을 메모리에만 들고 있었는데, 재시작하면 스냅샷이 초기화돼서 모든 항목이 “새로 추가됨"으로 감지되는 문제가 있었다.

// 서버 시작 시 snapshot.json 로드
async function loadSnapshot() {
  try {
    const raw = await fs.readFile(SNAPSHOT_PATH, 'utf8');
    return JSON.parse(raw);
  } catch {
    return { vacations: [], rooms: [], updatedAt: null };
  }
}

파일에 저장하고 시작 시 로드하는 것만으로 해결됐다.

PM2 → Docker 전환

초기에는 PM2로 프로세스를 관리했다. 그런데 Node.js 버전 관리, 환경변수 주입, 재시작 정책 등을 일관되게 관리하기가 번거로웠다.

Docker로 전환하면서 이 문제가 모두 해결됐다. restart: unless-stopped로 서버가 죽어도 자동 재시작되고, 환경변수는 .env로 관리하고, Node.js 버전은 FROM node:20-alpine으로 고정된다.


마무리

4편에 걸쳐 사내 Slack 알림 봇을 만든 과정을 정리했다.

내용
1편 기획 배경 + 전체 아키텍처
2편 SSO 세션 처리 + 내부 API 연동
3편 스냅샷 비교 변동 감지 + 스케줄러 + Slack 알림
4편 관리자 페이지 + Docker 배포 + 트러블슈팅 ← 지금 여기

DB 없이 JSON 파일만으로 상태를 관리한 게 핵심이다. 사내 툴은 오버엔지니어링할 필요가 없다. 실제로 필요한 기능만 빠르게 만들어서 쓰는 게 낫다.

현재 Slack 봇에서 관리하는 인사 데이터를 DB로 체계적으로 관리하는 시스템도 별도로 만들었다. 추후 두 시스템을 연동해서 인사 변동이 생기면 Slack으로 알림이 오는 흐름을 구성할 예정이다.