
프로젝트 배경과 문제 정의
해결하려던 실제 문제
글쓰기 스터디 그룹을 운영하면서 매주 반복되는 수작업이 있었습니다. 매주 월요일마다 디스코드 포럼에 "이번 주 기록 공유" 포스트를 만들고, 참여자들이 자신의 글 링크를 공유할 수 있는 공간을 제공해야 했습니다. 24주 동안 지속되는 프로젝트에서 이 작업을 빼먹지 않고 정확한 주차 정보와 함께 일관된 형식으로 만드는 것이 생각보다 까다로웠습니다.
기능 요구사항 정의
- 정확한 스케줄링: 매주 월요일 자정(한국시간)에 자동 실행
- 일관된 포맷: "꾸씁-클 X주차 기록 (MM.DD ~ MM.DD)" 형식
- 시각적 요소: 주차별 고유 이미지 첨부
- 사용자 참여 유도: 자동 이모지 리액션으로 참여 분위기 조성
- 관리 편의성: 테스트 및 디버깅을 위한 수동 명령어
- 확장 가능성: 향후 링크 트래킹 기능 추가를 위한 구조
개발 환경 준비
필수 도구 설치
개발을 시작하기 전에 필요한 도구들을 설치합니다.
# Node.js 프로젝트 초기화
npm init -y
# 핵심 라이브러리 설치
npm install discord.js # 디스코드 API 클라이언트
npm install dotenv # 환경변수 관리
npm install node-cron # 스케줄링
Discord 개발자 포털 설정
- Discord Developer Portal에서 새 애플리케이션 생성
- Bot 섹션에서 봇 토큰 생성 (이후 환경변수로 사용)
- OAuth2 URL Generator에서 필요한 권한 선택하여 초대 URL 생성
환경변수 파일 구성
.env 파일을 프로젝트 루트에 생성:
DISCORD_TOKEN=your_bot_token_here
FORUM_CHANNEL_ID=your_forum_channel_id
ADMIN_USER_ID=your_discord_user_id
NODE_ENV=development
중요: .env 파일은 절대 Git에 커밋하지 말고 .gitignore에 추가해야 합니다.
1단계: 기본 봇 구조 구현
핵심 아키텍처 이해
디스코드 봇은 WebSocket 연결을 통해 디스코드 서버와 실시간으로 통신합니다. HTTP 요청-응답 방식이 아니라 지속적인 연결을 유지하며 이벤트를 수신하고 처리하는 구조입니다.
require('dotenv').config();
const { Client, GatewayIntentBits, ChannelType, AttachmentBuilder } = require('discord.js');
const cron = require('node-cron');
const path = require('path');
const client = new Client({
intents: [
GatewayIntentBits.Guilds, // 서버 정보 접근
GatewayIntentBits.GuildMessages, // 메시지 이벤트 수신
GatewayIntentBits.MessageContent // 메시지 내용 읽기 (중요!)
]
});
인텐트(Intent) 시스템 이해
Discord.js v13부터 도입된 인텐트 시스템은 봇이 수신할 이벤트 타입을 명시적으로 선언해야 합니다. 이는 봇의 권한을 최소화하고 불필요한 데이터 수신을 방지하기 위함입니다.
GatewayIntentBits.Guilds: 서버 정보, 채널 목록 등 기본 정보GatewayIntentBits.GuildMessages: 메시지 생성, 삭제 등 메시지 이벤트GatewayIntentBits.MessageContent: 실제 메시지 내용 (필수!)
봇 준비 완료 이벤트
client.once('clientReady', () => {
console.log(`${client.user.tag} 봇이 준비되었습니다!`);
console.log(`서버 연결 수: ${client.guilds.cache.size}`);
console.log(`현재 시간: ${new Date().toISOString()}`);
});
2단계: 주차 계산 로직 구현
날짜 계산의 핵심 원리
자바스크립트에서 날짜 계산은 밀리초 단위로 이루어집니다. 두 날짜 간의 차이를 구하고 이를 일, 주 단위로 변환하는 과정이 필요합니다.
function getWeekInfo(date = new Date()) {
// 기준점: 7주차 시작일 (2025년 9월 1일)
const week7StartDate = new Date(2025, 8, 1); // 월은 0부터 시작 (8 = 9월)
// 밀리초 단위 시간 차이 계산
const diffTime = date.getTime() - week7StartDate.getTime();
// 일 단위로 변환 (1일 = 24시간 × 60분 × 60초 × 1000밀리초)
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
// 주 단위로 변환하여 주차 계산 (7주차부터 시작)
let weekNumber = Math.floor(diffDays / 7) + 7;
// 방어 로직: 시작일 이전이면 7주차로 고정 (테스트용)
if (diffDays < 0) {
weekNumber = 7;
}
// 해당 주의 월요일과 일요일 계산
const monday = new Date(week7StartDate);
monday.setDate(week7StartDate.getDate() + (weekNumber - 7) * 7);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
// 날짜 포맷팅 함수 (MM.DD 형식)
const formatDate = (date) => {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}.${day}`;
};
return {
week: weekNumber,
period: `${formatDate(monday)} ~ ${formatDate(sunday)}`
};
}
날짜 계산에서 주의할 점
- JavaScript 월 인덱스: 0부터 시작 (0=1월, 8=9월)
- 시간대 고려: 서버 시간(UTC)과 로컬 시간(KST) 차이
- 엣지 케이스: 음수 날짜, 범위 초과 등 예외 상황 처리
3단계: 포스트 생성 함수 구현
포럼 채널과 스레드 시스템 이해
디스코드의 포럼 채널은 일반 텍스트 채널과 달리 스레드(게시글) 중심의 구조입니다. 각 스레드는 독립된 대화 공간이며, 제목과 초기 메시지로 구성됩니다.
async function createWeeklyPost() {
try {
// 1. 채널 객체 가져오기
const channel = client.channels.cache.get(process.env.FORUM_CHANNEL_ID);
// 2. 채널 유효성 검증
if (!channel) {
console.error('채널을 찾을 수 없습니다. FORUM_CHANNEL_ID를 확인하세요.');
return;
}
if (channel.type !== ChannelType.GuildForum) {
console.error('지정된 채널이 포럼 채널이 아닙니다.');
console.error(`현재 채널 타입: ${channel.type}, 필요한 타입: ${ChannelType.GuildForum}`);
return;
}
// 3. 주차 정보 계산
const weekInfo = getWeekInfo();
console.log(`생성할 주차: ${weekInfo.week}주차 (${weekInfo.period})`);
// 4. 이미지 파일 경로 계산
const imageIndex = (weekInfo.week - 7) % weeklyImages.length;
const imageName = weeklyImages[imageIndex];
// 방어 로직: 이미지 파일 존재 여부 확인
if (!imageName) {
console.error('해당 주차의 이미지를 찾을 수 없습니다.');
return;
}
const imagePath = path.join(__dirname, 'images', imageName);
// 5. 첨부파일 객체 생성
const attachment = new AttachmentBuilder(imagePath, {
name: `week${weekInfo.week}-image.png`
});
// 6. 포럼 스레드 생성
const thread = await channel.threads.create({
name: `꾸씁-클 ${weekInfo.week}주차 기록 (${weekInfo.period})`,
message: {
content: `**기록 공유와 피드백 방법**
- 글을 발행했다면 링크와 짧은 코멘트를 함께 공유해주세요.
- 마음이 가는 글이 있다면 읽고 가볍게 피드백을 남겨주세요.
- 이모지 하나만 눌러줘도 글쓴이에게 큰 힘이 됩니다.
- 💜❤️🩷💛💙🩵🧡`,
files: [attachment]
}
});
console.log(`포스트 생성 완료: ${thread.name}`);
// 7. 자동 리액션 추가 (사용자 참여 유도)
const reactions = ['❤️', '✍️', '🔥', '👍', '💪', '📝', '✨'];
for (const emoji of reactions) {
try {
await thread.lastMessage?.react(emoji);
// API 호출 간격 조절 (Rate Limit 방지)
await new Promise(resolve => setTimeout(resolve, 500));
} catch (reactionError) {
console.warn(`리액션 추가 실패 (${emoji}):`, reactionError.message);
}
}
} catch (error) {
console.error('포스트 생성 중 오류:', error);
console.error('오류 스택:', error.stack);
}
}
비동기 처리와 에러 핸들링
이 함수에서 중요한 점은 여러 비동기 작업들을 순차적으로 처리한다는 것입니다:
- 채널 정보 가져오기 (동기)
- 스레드 생성하기 (비동기)
- 리액션 추가하기 (비동기, 반복)
각 단계에서 실패할 수 있으므로 적절한 에러 핸들링과 로깅이 필수입니다.
4단계: 자동 스케줄링 구현
Cron 표현식 이해
node-cron은 Unix cron과 같은 방식으로 스케줄을 정의합니다:
분 시 일 월 요일
0 0 * * 1
0 0: 0시 0분 (자정)- : 모든 일, 모든 월
1: 월요일 (0=일요일, 1=월요일, ..., 6=토요일)
cron.schedule('0 0 * * 1', () => {
const now = new Date();
const startDate = new Date(2025, 8, 1); // 프로젝트 시작일
console.log(`크론 작업 실행됨: ${now.toISOString()}`);
console.log(`한국 시간: ${new Date(now.getTime() + 9 * 60 * 60 * 1000).toISOString()}`);
// 프로젝트 시작일 이후에만 포스트 생성
if (now >= startDate) {
console.log('주간 포스트 생성 시작...');
createWeeklyPost();
} else {
console.log(`대기 중... 시작일: ${startDate.toLocaleDateString()}`);
}
}, {
timezone: "Asia/Seoul" // 한국 시간 기준으로 실행
});
시간대 처리의 중요성
서버가 UTC 시간으로 운영되더라도 timezone 옵션을 통해 한국 시간 기준으로 스케줄을 실행할 수 있습니다. 이는 사용자 경험 측면에서 중요한 고려사항입니다.
5단계: 관리 명령어 시스템
메시지 이벤트 처리
디스코드 봇은 메시지가 생성될 때마다 messageCreate 이벤트를 받습니다. 이 이벤트에서 특정 패턴의 메시지를 감지하여 명령어로 처리합니다.
client.on('messageCreate', message => {
// 봇 자신의 메시지는 무시
if (message.author.bot) return;
// 관리자 권한 확인
const isAdmin = message.author.id === process.env.ADMIN_USER_ID;
// 디버깅용: 모든 메시지 로깅 (개발 시에만)
if (process.env.NODE_ENV === 'development') {
console.log(`메시지 수신: "${message.content}" from ${message.author.tag}`);
console.log(`관리자 여부: ${isAdmin}`);
}
// 주차 정보 조회 명령어
if (message.content === '!weekinfo' && isAdmin) {
const weekInfo = getWeekInfo();
message.reply(`현재: 꾸씁-클 ${weekInfo.week}주차 기록 (${weekInfo.period})`);
return;
}
// 수동 포스트 생성 명령어
if (message.content === '!test' && isAdmin) {
console.log('수동 포스트 생성 명령 실행');
createWeeklyPost()
.then(() => {
message.reply('테스트 포스트가 생성되었습니다!');
})
.catch(error => {
console.error('포스트 생성 실패:', error);
message.reply('포스트 생성 중 오류가 발생했습니다.');
});
return;
}
// 특정 날짜 기준 주차 계산
if (message.content.startsWith('!testdate ') && isAdmin) {
const dateString = message.content.split(' ')[1];
try {
const testDate = new Date(dateString);
if (isNaN(testDate.getTime())) {
message.reply('올바른 날짜 형식을 입력해주세요. (예: 2025-09-08)');
return;
}
const weekInfo = getWeekInfo(testDate);
message.reply(`${dateString}: 꾸씁-클 ${weekInfo.week}주차 기록 (${weekInfo.period})`);
} catch (error) {
message.reply('날짜 파싱 중 오류가 발생했습니다.');
}
return;
}
});
권한 시스템 설계
관리자만 특정 명령어를 사용할 수 있도록 ADMIN_USER_ID 환경변수를 통해 권한을 제어합니다. Discord의 사용자 ID는 고유한 숫자 문자열이므로 정확한 비교가 가능합니다.
완성된 코드 구조
전체 봇 코드
require('dotenv').config();
const { Client, GatewayIntentBits, ChannelType, AttachmentBuilder } = require('discord.js');
const cron = require('node-cron');
const path = require('path');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent
]
});
// 주간 이미지 배열 (7주차부터 24주차까지)
const weeklyImages = [
'week7.png', 'week8.png', 'week9.png', 'week10.png',
'week11.png', 'week12.png', 'week13.png', 'week14.png',
'week15.png', 'week16.png', 'week17.png', 'week18.png',
'week19.png', 'week20.png', 'week21.png', 'week22.png',
'week23.png', 'week24.png'
];
// 주차 계산 함수
function getWeekInfo(date = new Date()) {
const week7StartDate = new Date(2025, 8, 1);
const diffTime = date.getTime() - week7StartDate.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
let weekNumber = diffDays < 0 ? 7 : Math.floor(diffDays / 7) + 7;
const monday = new Date(week7StartDate);
monday.setDate(week7StartDate.getDate() + (weekNumber - 7) * 7);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const formatDate = (date) => {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}.${day}`;
};
return {
week: weekNumber,
period: `${formatDate(monday)} ~ ${formatDate(sunday)}`
};
}
// 주간 포스트 생성 함수
async function createWeeklyPost() {
try {
const channel = client.channels.cache.get(process.env.FORUM_CHANNEL_ID);
if (!channel || channel.type !== ChannelType.GuildForum) {
console.error('Forum channel not found.');
return;
}
const weekInfo = getWeekInfo();
const imageIndex = (weekInfo.week - 7) % weeklyImages.length;
const imageName = weeklyImages[imageIndex];
const imagePath = path.join(__dirname, 'images', imageName);
const attachment = new AttachmentBuilder(imagePath, {
name: `week${weekInfo.week}-image.png`
});
const thread = await channel.threads.create({
name: `꾸씁-클 ${weekInfo.week}주차 기록 (${weekInfo.period})`,
message: {
content: `**기록 공유와 피드백 방법**
- 글을 발행했다면 링크와 짧은 코멘트를 함께 공유해주세요.
- 마음이 가는 글이 있다면 읽고 가볍게 피드백을 남겨주세요.
- 이모지 하나만 눌러줘도 글쓴이에게 큰 힘이 됩니다.
- 💜❤️🩷💛💙🩵🧡`,
files: [attachment]
}
});
const reactions = ['❤️', '✍️', '🔥', '👍', '💪', '📝', '✨'];
for (const emoji of reactions) {
await thread.lastMessage?.react(emoji);
await new Promise(resolve => setTimeout(resolve, 500));
}
} catch (error) {
console.error('Failed to create weekly post:', error);
}
}
// 자동 스케줄링
cron.schedule('0 0 * * 1', () => {
const now = new Date();
const startDate = new Date(2025, 8, 1);
if (now >= startDate) {
createWeeklyPost();
}
}, {
timezone: "Asia/Seoul"
});
// 관리자 명령어
client.on('messageCreate', message => {
if (message.author.bot) return;
const isAdmin = message.author.id === process.env.ADMIN_USER_ID;
if (message.content === '!weekinfo' && isAdmin) {
const weekInfo = getWeekInfo();
message.reply(`현재: 꾸씁-클 ${weekInfo.week}주차 기록 (${weekInfo.period})`);
}
if (message.content === '!test' && isAdmin) {
createWeeklyPost();
message.reply('테스트 포스트가 생성되었습니다!');
}
});
client.once('clientReady', () => {
console.log(`${client.user.tag} 봇이 준비되었습니다!`);
});
client.login(process.env.DISCORD_TOKEN);
배포 과정과 클라우드 환경 이해
Heroku 배포 단계별 과정
1. Heroku CLI 설치 및 로그인
# macOS
brew tap heroku/brew && brew install heroku
# 로그인
heroku login # 브라우저가 열리며 웹에서 인증
2. Git 저장소 연결
git init
heroku create your-app-name
heroku git:remote -a your-app-name
3. 환경변수 설정
# 명령어로 설정
heroku config:set DISCORD_TOKEN=your_token
heroku config:set FORUM_CHANNEL_ID=your_channel_id
# 또는 웹 대시보드에서 설정
# Settings → Config Vars → 직접 입력
4. 배포 실행
git add .
git commit -m "Initial discord bot deployment"
git push heroku main
5. 실행 상태 확인
# 로그 실시간 모니터링
heroku logs --tail
# Dyno 상태 확인
heroku ps
환경변수 관리의 핵심
로컬 개발과 클라우드 배포에서 환경변수 관리 방식이 다릅니다:
- 로컬: .env 파일 → dotenv 라이브러리로 로드
- Heroku: Config Vars → 플랫폼 차원에서 환경변수 주입
두 환경 모두에서 같은 코드(process.env.VARIABLE_NAME)로 접근할 수 있지만, 설정 방법이 다릅니다.
배포 운영과 비용 고려사항
Heroku 플랜 선택 과정
무료 플랜의 한계:
- 30분 비활성 시 자동 슬립 모드
- 월 550시간 제한 (약 23일)
- 24시간 연속 운영 불가능
유료 플랜 검토:
- Basic/Eco Dyno: 월 $5, 슬립 모드 없음, 1000시간
- Standard: 월 $25 이상, 고성능
결정: Eco Dyno 플랜 선택 (주 1회 사용으로 1000시간 충분)
운영 모니터링 설정
# 실시간 로그 모니터링
heroku logs --tail
# 앱 상태 확인
heroku ps
# 다이노 재시작
heroku restart