해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. Discord.js TypeScript Boilerplate
안녕하세요! 지난 시간에는 테스트 코드 작성과 디버깅 팁에 대해 알아봤습니다. 이제 우리 봇은 안정적이고 유지보수하기 쉬운 상태가 되었죠.
오늘은 좀 더 고급 주제를 다뤄보겠습니다. 바로 웹 기반 관리자 패널을 만드는 것입니다. 지금까지는 디스코드 명령어로만 봇을 관리해왔는데, 복잡한 설정이나 통계 확인 등은 웹 인터페이스가 훨씬 편리하거든요. 실제로 많은 대형 디스코드 봇들이 웹 대시보드를 제공하고 있습니다.
이번 시간에는 Express.js를 사용해서 간단한 관리자 패널을 만들어보겠습니다. 봇 통계 확인, 서버 설정 관리, 사용자 관리 등의 기능을 웹에서 할 수 있도록 해보죠.
왜 웹 관리자 패널이 필요할까요?
디스코드 명령어로만 봇을 관리하는 것에는 몇 가지 한계가 있습니다.
디스코드 명령어의 한계:
- 복잡한 데이터 표시가 어려움 (표, 그래프 등)
- 긴 텍스트나 설정 입력이 불편함
- 파일 업로드/다운로드가 제한적임
- 실시간 모니터링이 어려움
웹 패널의 장점:
- 직관적인 GUI로 편리한 관리
- 실시간 데이터 시각화 가능
- 복잡한 설정을 폼으로 쉽게 입력
- 파일 관리와 로그 확인이 편리
- 모바일에서도 접근 가능
물론 보안 측면에서 더 신경써야 할 부분이 있지만, 제대로 구현하면 봇 관리가 훨씬 편해집니다.
프로젝트 구조 설계하기
웹 패널을 추가하면서 기존 봇 코드와 잘 분리된 구조를 만들어보겠습니다.
src/
├── bot/ # 기존 봇 관련 코드
│ ├── index.ts
│ ├── commands/
│ └── events/
├── web/ # 웹 서버 관련 코드
│ ├── server.ts
│ ├── routes/
│ ├── middleware/
│ └── views/
├── shared/ # 공통으로 사용되는 코드
│ ├── database/
│ ├── config/
│ └── utils/
└── index.ts # 메인 진입점
필요한 패키지 설치하기
웹 서버를 위한 패키지들을 설치해보겠습니다.
npm install express cors helmet morgan
npm install -D @types/express @types/cors
각 패키지의 역할은 다음과 같습니다:
express
: Node.js 웹 프레임워크cors
: Cross-Origin Resource Sharing 설정helmet
: 보안 헤더 설정morgan
: HTTP 요청 로깅
기본 웹 서버 구현하기
먼저 Express 서버의 기본 틀을 만들어보겠습니다.
// src/web/server.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import path from "path";
import { Client } from "discord.js";
export class WebServer {
private app: express.Application;
private client: Client;
constructor(client: Client) {
this.app = express();
this.client = client;
this.setupMiddleware();
this.setupRoutes();
}
private setupMiddleware(): void {
// 보안 헤더 설정
this.app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https:"],
},
},
})
);
// CORS 설정
this.app.use(
cors({
origin: process.env.ALLOWED_ORIGINS?.split(",") || [
"http://localhost:3000",
],
credentials: true,
})
);
// 로깅
this.app.use(morgan("combined"));
// JSON 파싱
this.app.use(express.json({ limit: "10mb" }));
this.app.use(express.urlencoded({ extended: true }));
// 정적 파일 제공
this.app.use("/static", express.static(path.join(__dirname, "public")));
}
private setupRoutes(): void {
// API 라우트
this.app.use("/api", this.createApiRoutes());
// 메인 페이지
this.app.get("/", (req, res) => {
res.send(this.getMainPage());
});
// 404 처리
this.app.use("*", (req, res) => {
res.status(404).json({ error: "Not Found" });
});
}
private createApiRoutes(): express.Router {
const router = express.Router();
// 봇 상태 API
router.get("/status", (req, res) => {
const guilds = this.client.guilds.cache;
const users = this.client.users.cache;
res.json({
status: this.client.isReady() ? "online" : "offline",
guilds: guilds.size,
users: users.size,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
});
});
// 서버 목록 API
router.get("/guilds", (req, res) => {
const guilds = this.client.guilds.cache.map((guild) => ({
id: guild.id,
name: guild.name,
memberCount: guild.memberCount,
iconURL: guild.iconURL(),
owner: guild.ownerId,
}));
res.json(guilds);
});
// 특정 서버 정보 API
router.get("/guilds/:id", async (req, res) => {
try {
const guild = await this.client.guilds.fetch(req.params.id);
const channels = guild.channels.cache.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
}));
res.json({
id: guild.id,
name: guild.name,
memberCount: guild.memberCount,
iconURL: guild.iconURL(),
channels: channels,
roles: guild.roles.cache.map((role) => ({
id: role.id,
name: role.name,
color: role.hexColor,
members: role.members.size,
})),
});
} catch (error) {
res.status(404).json({ error: "Guild not found" });
}
});
return router;
}
private getMainPage(): string {
return `
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discord Bot 관리자 패널</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<nav class="navbar navbar-dark bg-dark">
<div class="container">
<span class="navbar-brand">Discord Bot 관리자 패널</span>
</div>
</nav>
<div class="container mt-4">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>봇 상태</h5>
</div>
<div class="card-body" id="bot-status">
로딩 중...
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>서버 목록</h5>
</div>
<div class="card-body" id="guild-list">
로딩 중...
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>메모리 사용량</h5>
</div>
<div class="card-body">
<canvas id="memory-chart" width="400" height="200"></canvas>
</div>
</div>
</div>
</div>
</div>
<script>
// 봇 상태 업데이트
async function updateBotStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
const uptimeHours = Math.floor(data.uptime / 3600);
const uptimeMinutes = Math.floor((data.uptime % 3600) / 60);
document.getElementById('bot-status').innerHTML = \`
<p><strong>상태:</strong> <span class="badge bg-\${data.status === 'online' ? 'success' : 'danger'}">\${data.status}</span></p>
<p><strong>서버 수:</strong> \${data.guilds}개</p>
<p><strong>사용자 수:</strong> \${data.users}명</p>
<p><strong>가동 시간:</strong> \${uptimeHours}시간 \${uptimeMinutes}분</p>
\`;
updateMemoryChart(data.memoryUsage);
} catch (error) {
console.error('상태 업데이트 실패:', error);
}
}
// 서버 목록 업데이트
async function updateGuildList() {
try {
const response = await fetch('/api/guilds');
const guilds = await response.json();
const html = guilds.map(guild => \`
<div class="mb-2">
<strong>\${guild.name}</strong><br>
<small>멤버: \${guild.memberCount}명</small>
</div>
\`).join('');
document.getElementById('guild-list').innerHTML = html;
} catch (error) {
console.error('서버 목록 업데이트 실패:', error);
}
}
// 메모리 차트
let memoryChart;
const memoryData = {
labels: [],
datasets: [{
label: 'RSS (MB)',
data: [],
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
};
function updateMemoryChart(memoryUsage) {
const now = new Date().toLocaleTimeString();
const rssInMB = Math.round(memoryUsage.rss / 1024 / 1024);
memoryData.labels.push(now);
memoryData.datasets[0].data.push(rssInMB);
// 최근 20개 데이터만 유지
if (memoryData.labels.length > 20) {
memoryData.labels.shift();
memoryData.datasets[0].data.shift();
}
if (memoryChart) {
memoryChart.update();
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
// 차트 초기화
const ctx = document.getElementById('memory-chart').getContext('2d');
memoryChart = new Chart(ctx, {
type: 'line',
data: memoryData,
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Memory (MB)'
}
}
}
}
});
// 초기 데이터 로드
updateBotStatus();
updateGuildList();
// 5초마다 업데이트
setInterval(updateBotStatus, 5000);
setInterval(updateGuildList, 30000);
});
</script>
</body>
</html>
`;
}
public start(port: number = 3000): void {
this.app.listen(port, () => {
console.log(`웹 서버가 포트 ${port}에서 시작되었습니다.`);
console.log(`관리자 패널: http://localhost:${port}`);
});
}
}
메인 진입점 수정하기
봇과 웹 서버를 함께 실행하도록 메인 파일을 수정해보겠습니다.
// src/index.ts
import { Client } from "discord.js";
import { WebServer } from "./web/server";
import config from "./config";
// ...기존 봇 코드...
client.once("ready", () => {
console.log(`봇이 ${client.user?.tag}로 로그인했습니다!`);
// 웹 서버 시작
const webServer = new WebServer(client);
webServer.start(process.env.WEB_PORT ? parseInt(process.env.WEB_PORT) : 3000);
});
client.login(config.DISCORD_TOKEN);
인증 시스템 추가하기
관리자 패널이니까 당연히 인증이 필요하겠죠. 간단한 세션 기반 인증을 구현해보겠습니다.
// src/web/middleware/auth.ts
import { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
username: string;
discriminator: string;
avatar?: string;
};
}
// 간단한 메모리 기반 세션 (실제 운영에서는 Redis 등 사용 권장)
const sessions = new Map<string, any>();
export function requireAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) {
const sessionId = req.headers.authorization?.replace("Bearer ", "");
if (!sessionId || !sessions.has(sessionId)) {
return res.status(401).json({ error: "Unauthorized" });
}
req.user = sessions.get(sessionId);
next();
}
export function createSession(user: any): string {
const sessionId = Math.random().toString(36).substring(2, 15);
sessions.set(sessionId, user);
// 24시간 후 세션 만료
setTimeout(() => {
sessions.delete(sessionId);
}, 24 * 60 * 60 * 1000);
return sessionId;
}
Discord OAuth2 로그인 구현하기
Discord 계정으로 로그인할 수 있도록 OAuth2를 구현해보겠습니다.
// src/web/routes/auth.ts
import express from "express";
import { createSession } from "../middleware/auth";
const router = express.Router();
router.get("/discord", (req, res) => {
const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${
process.env.DISCORD_CLIENT_ID
}&redirect_uri=${encodeURIComponent(
process.env.DISCORD_REDIRECT_URI!
)}&response_type=code&scope=identify`;
res.redirect(discordAuthUrl);
});
router.get("/discord/callback", async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).json({ error: "No code provided" });
}
try {
// Discord로부터 액세스 토큰 받기
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID!,
client_secret: process.env.DISCORD_CLIENT_SECRET!,
grant_type: "authorization_code",
code: code as string,
redirect_uri: process.env.DISCORD_REDIRECT_URI!,
}),
});
const tokenData = await tokenResponse.json();
// 사용자 정보 가져오기
const userResponse = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const userData = await userResponse.json();
// 관리자 권한 확인 (환경 변수에서 관리자 ID 목록 가져오기)
const adminIds = process.env.ADMIN_USER_IDS?.split(",") || [];
if (!adminIds.includes(userData.id)) {
return res.status(403).json({ error: "Access denied" });
}
// 세션 생성
const sessionId = createSession({
id: userData.id,
username: userData.username,
discriminator: userData.discriminator,
avatar: userData.avatar,
});
res.json({
sessionId,
user: {
id: userData.id,
username: userData.username,
discriminator: userData.discriminator,
avatar: userData.avatar,
},
});
} catch (error) {
console.error("Discord OAuth2 에러:", error);
res.status(500).json({ error: "Authentication failed" });
}
});
export default router;
환경 변수 설정
.env
파일에 웹 패널 관련 설정을 추가해보겠습니다.
# 기존 디스코드 봇 설정
DISCORD_TOKEN=your_bot_token
DISCORD_CLIENT_ID=your_client_id
# 웹 패널 설정
DISCORD_CLIENT_SECRET=your_client_secret
DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback
WEB_PORT=3000
ADMIN_USER_IDS=your_discord_user_id,another_admin_id
ALLOWED_ORIGINS=http://localhost:3000
보안 고려사항
웹 패널을 운영할 때 중요한 보안 사항들을 살펴보겠습니다.
1. HTTPS 사용
실제 운영 환경에서는 반드시 HTTPS를 사용해야 합니다.
// src/web/server.ts (HTTPS 버전)
import https from "https";
import fs from "fs";
// HTTPS 서버 설정
if (process.env.NODE_ENV === "production") {
const options = {
key: fs.readFileSync("/path/to/private-key.pem"),
cert: fs.readFileSync("/path/to/certificate.pem"),
};
https.createServer(options, this.app).listen(port, () => {
console.log(`HTTPS 서버가 포트 ${port}에서 시작되었습니다.`);
});
}
2. 레이트 리미팅
API 요청 제한을 걸어 DDoS 공격을 방지합니다.
npm install express-rate-limit
import rateLimit from "express-rate-limit";
// API 레이트 리미팅
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100, // 최대 100 요청
message: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
});
this.app.use("/api", apiLimiter);
3. 입력 검증
사용자 입력은 항상 검증해야 합니다.
npm install joi
npm install -D @types/joi
import Joi from "joi";
const guildIdSchema = Joi.string()
.pattern(/^\d{17,19}$/)
.required();
router.get("/guilds/:id", (req, res) => {
const { error } = guildIdSchema.validate(req.params.id);
if (error) {
return res.status(400).json({ error: "Invalid guild ID" });
}
// ...기존 코드...
});
마무리하며
이번 시간에는 Discord 봇과 연동되는 웹 기반 관리자 패널을 만들어봤습니다. Express.js를 사용한 기본적인 웹 서버부터 Discord OAuth2 인증, 실시간 데이터 표시까지 다뤄봤죠.
물론 이것은 시작일 뿐입니다. 실제 운영 환경에서는 더 많은 기능과 보안 강화가 필요하겠지만, 기본 틀은 이렇게 구성할 수 있어요. 데이터베이스 연동, 실시간 알림, 더 복잡한 권한 시스템 등을 추가하면 더욱 강력한 관리자 패널을 만들 수 있습니다.
다음 시간에는 마지막 튜토리얼로 스케줄러로 자동 메시지 보내기에 대해 알아보겠습니다. node-cron을 사용해서 정기적으로 작업을 수행하는 방법을 배워보겠어요. 많은 봇에서 필요한 기능이니까 꼭 알아두시면 좋을 거예요!
'DiscordJS 개발 튜토리얼' 카테고리의 다른 글
[DiscordJS 봇 개발 튜토리얼] 12. 다국어 지원 시스템 만들기 (i18n): 전 세계와 소통하자! (0) | 2025.06.13 |
---|---|
[DiscordJS 봇 개발 튜토리얼] 11. 사용자 반응(이모지) 기반 기능 만들기: 손쉬운 상호작용의 시작 (1) | 2025.06.12 |
[DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기 (7) | 2025.06.11 |
[DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자! (2) | 2025.06.11 |
[DiscordJS 봇 개발 튜토리얼] 8. Prisma로 SQLite, MySQL 연동하기 (2) | 2025.06.10 |