모든 글
약 6분 분량 학습 프로젝트/디스코드 봇

화면 공유를 켜야 출석으로 친다: 디스코드 스터디 출석봇

목차

0. 무엇을 만들었나

같이 코딩테스트를 준비하는 디스코드 스터디가 있습니다. 규칙은 단순합니다. 주 3회 이상 참여하고, 참여는 음성채널에서 화면 공유(Go Live)를 켠 채 각자 문제를 푸는 것으로 칩니다. 미달이면 경고, 경고 3회면 서버에서 내보냅니다(벌칙이라기보다 자리 정리에 가깝습니다. 나갔다가 여유가 생기면 다시 들어오면 되고, 경고도 초기화됩니다).

문제는 이 규칙을 사람이 집행하는 비용이었습니다. 누가 몇 시에 들어와서 얼마나 있었는지 매일 확인하고, 주말마다 주간 집계를 내고, 경고를 세고. 그래서 전부 봇(miracle-study-bot)에게 넘겼습니다. Node.js + discord.js v14 + better-sqlite3 + node-cron, 배포는 OCI 무료 VM 한 대입니다.

동작은 이렇습니다:

🕐 홍길동님 종일 출근 (09:02) ← 화면 공유를 켠 순간
🖥️ @홍길동 화면 공유가 꺼져 있어요 ← 채널엔 있는데 공유가 3분째 꺼져 있으면
🏁 홍길동님 종일 퇴근 (11:58) — 누적 2시간 56분 ✅ 출석 인정

핵심 규칙 몇 개만 표로 적어 두면:

항목
기록 조건음성채널 접속 + 화면 공유 중인 시간만 (끄면 일시정지, 켜면 이어서 합산)
출석 인정누적 60분 이상, 하루 1회
주간 점검일요일 밤, 그 주(월~일) 출석 3회 미만이면 경고 +1
자동 정리경고 3회면 Kick(재입장 가능, 경고 리셋)
예외운영진 / 가입 7일 미만 / /유예 등록자는 점검 제외

“채널에 들어와 있으면 출석”이 아니라 “공유 중인 시간만 출석”으로 잡은 건 규칙과 기록을 일치시키기 위해서입니다. 스터디 규칙이 애초에 “화면을 공유하며 푼다”인데 기록은 접속 시간으로 세면, 접속만 해 두고 자리를 비우는 경우와 구분이 안 됩니다. 대신 억울한 미기록이 없도록, 채널에 있는데 공유가 꺼진 채 3분이 지나면 봇이 본인을 멘션해서 알려 줍니다.

1. 설계에서 지킨 것: 공지는 실시간, 판정은 정산에서만

이 봇에서 제일 의식적으로 지킨 원칙 하나를 꼽으라면 이겁니다. 실시간 이벤트는 공지만 하고, 출석 판정은 하루 끝의 정산 크론에서만 한다.

디스코드 봇은 voiceStateUpdate 이벤트로 입장·퇴장·공유 on/off를 실시간으로 받습니다. 그 자리에서 바로 “60분 넘었네, 출석!”이라고 판정하고 싶어지는데, 그렇게 하면 상태가 봇 프로세스의 메모리에 걸립니다. 봇이 재시작되면(배포하면, VM이 재부팅되면) 세던 시간이 날아갑니다.

그래서 역할을 갈랐습니다:

  • 이벤트 핸들러는 SQLite에 구간(segment)만 적습니다. 공유를 켠 시각, 끈 시각. 판정하지 않습니다.
  • 정산 크론이 시간대가 끝날 때 그날의 구간들을 합산해서 출석 여부를 확정합니다. 유일한 판정 지점입니다.
  • 재시작하면 복구 루틴이 DB에 남은 미종료 구간을 닫고, 정산이 누락된 시간대가 있으면 늦게라도(공지 없이) 정산합니다.

퇴근 공지의 출석 인정/미달 표시는 그 시점 누적 기준의 안내일 뿐이고, DB에 남는 진짜 기록은 전부 정산이 담당합니다. 덕분에 배포하면서 봇을 몇 번을 죽여도 출석 기록은 안 깨집니다.

시간 처리도 같은 맥락에서 한 가지를 고정했습니다. 서버 OS의 시간대를 절대 믿지 않고, 모든 날짜·시각을 코드에서 Intl.DateTimeFormat으로 Asia/Seoul 기준 문자열(YYYY-MM-DD, HH:MM)로 변환해 씁니다. 크론에도 전부 { timezone: 'Asia/Seoul' }을 답니다. OCI VM이 UTC로 돌든 뭐로 돌든 상관없게 하려는 겁니다.

또 하나, 크론 스케줄을 하드코딩하지 않았습니다. 스터디 시간대는 설정 파일의 상수이고, 크론식은 거기서 파생됩니다:

// config.js — 시간대 정의가 곧 스케줄
export const SESSIONS = {
morning: { key: 'morning', start: '09:00', end: '12:00' },
evening: { key: 'evening', start: '19:00', end: '22:00' },
};
// scheduler.js — 'HH:MM' → 크론식. SESSIONS를 바꾸면 크론도 따라온다
function cronFromHm(hm) {
const [h, m] = hm.split(':').map(Number);
return `${m} ${h} * * *`;
}

원래 의도는 테스트였습니다. 시간대를 5분짜리로 줄이면 출근→리마인드→퇴근→정산 전체 흐름을 몇 분 만에 검증할 수 있으니까요. 그런데 이 설계가 오늘 다른 데서 값을 했습니다.

2. “그냥 24시간 돌리면 안 돼?”: 설정 한 줄인 줄 알았던 변경

스터디를 돌려 보니 오전 9~12시, 저녁 7~10시라는 창이 생각보다 빡빡했습니다. 새벽에 공부하는 사람, 오후에 시간이 나는 사람이 있는데 그 시간은 아무리 공유를 켜도 0분입니다. 그래서 일단 임시로 시간대 제한을 풀고 24시간 아무 때나 누적 60분이면 인정하는 걸로 바꾸기로 했습니다.

크론이 설정에서 파생되니 SESSIONS를 하루 전체 하나로 바꾸면 끝이라고 생각했는데, 막상 바꾸려고 보니 함정이 세 개 있었습니다.

함정 ① 24:00은 크론에 없는 시각입니다. 하루 전체면 start: '00:00', end: '24:00'이 자연스러운데, end는 정산 크론식으로 변환됩니다. 24:000 24 * * *이 되고, 시(hour) 필드에 24는 유효하지 않아서 봇이 크론 등록에서 죽습니다. 그럼 23:59? 됩니다만, 다음 함정 때문에 더 당겨야 했습니다.

함정 ② 주간 점검이 일요일 출석을 못 봅니다. 출석 레코드는 정산 시점에만 DB에 들어갑니다. 기존 주간 점검이 일요일 22:05였던 건 저녁 정산(22:00) “직후”라는 계산이 깔린 시각입니다. 그 시점이면 그 주 출석은 더 변하지 않으니까요. 그런데 정산을 23:59로 옮기면 22:05 점검 때 일요일 기록이 아직 없습니다. 일요일에만 공부한 사람이 억울하게 경고를 받게 됩니다. 그래서 정산을 23:50, 주간 점검을 23:55로 잡아 순서를 다시 맞췄습니다. 하루 마지막 10분은 기록이 안 되는데, 임시 운영의 비용으로 받아들였습니다(23:59 정산 + 같은 분(分)에 점검을 욱여넣는 방법도 있지만, 같은 분에 걸린 크론 둘의 실행 순서에 규칙의 공정성을 걸고 싶지 않았습니다).

함정 ③ 설정에서 지운 키가 DB에는 남아 있습니다. 이게 제일 미묘했습니다. 구간 레코드에는 세션 키(morning/evening)가 문자열로 저장됩니다. 재시작 복구 루틴은 DB에 남은 미종료 구간을 닫을 때 SESSIONS[seg.session].end로 그 시간대의 종료 시각을 찾는데, SESSIONS에서 morning/evening을 지우고 allday 하나로 바꾸면 이 조회가 undefined를 뱉고 봇이 시작하자마자 죽습니다. 정산이 다 끝난 밤에 전환하면 안 밟을 수도 있는 지뢰지만, “운이 좋으면 안 터진다”는 고치라는 뜻이라서 옛 시간대를 LEGACY_SESSIONS로 남기고 조회 지점을 폴백으로 바꿨습니다:

// 현행에 없으면 과거 정의에서 찾는다. DB에 저장된 키 해석은 반드시 이걸로.
export function sessionByKey(key) {
return SESSIONS[key] ?? LEGACY_SESSIONS[key] ?? null;
}

결국 diff는 설정 교체 + 조회 지점 두 곳 수정으로 끝났고, 판정·공지·랭킹·레벨 코드는 한 줄도 안 건드렸습니다. 출석이 “하루 1회”인 건 그대로라 주 3회·경고 3회 규칙도 손댈 게 없었습니다.

3. 배운 것

설정인 줄 알았던 게 데이터였습니다. SESSIONS는 분명 설정 파일의 상수인데, 그 키가 DB 레코드에 문자열로 박히는 순간 더 이상 마음대로 지울 수 있는 값이 아니게 됩니다. 설정 삭제가 사실상 스키마 변경이 되는 거죠. 함정 ③이 정확히 그 사례였고, 앞으로 “설정만 바꾸면 되는 변경”을 검토할 때는 그 설정이 데이터에 새어 들어간 곳부터 찾게 될 것 같습니다.

크론식을 시간대 정의에서 파생시켜 둔 구조는 이번에 제대로 값을 했습니다. 24시간 운영이라는 정책 변경이 코드 수술 없이 상수 교체로 끝났으니까요. 원래는 테스트 편의로 넣은 구조였는데, 테스트하기 쉬운 구조는 바꾸기도 쉽다는 걸 다시 확인했습니다.

그리고 22:05라는 어중간한 점검 시각이 사실 “마지막 정산 직후”라는 불변식의 표현이었다는 걸, 옮기려고 보니 새삼 확인했습니다. 정산만 옮기고 점검을 같이 안 옮겼다면 조용히 불공정한 봇이 될 뻔했습니다. 스케줄 상수들 사이의 순서 의존은 주석으로라도 명시해 두는 게 다음 사람(대개 미래의 나)을 살립니다.

지금은 24시간 운영으로 며칠 돌려 보면서 참여 패턴이 어떻게 달라지는지 보는 중입니다. 시간대를 되돌릴지, 아예 24시간을 정식으로 갈지는 그 데이터를 보고 정하려고 합니다.

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.