[DiscordJS 봇 개발 튜토리얼] 15. 관리자 패널 만들기 (웹 연동 기본): 웹으로 봇을 편리하게 관리하자!

2025. 6. 14. 17:16·DiscordJS 개발 튜토리얼

해당 글은, 제가 작성한 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
'DiscordJS 개발 튜토리얼' 카테고리의 다른 글
  • [DiscordJS 봇 개발 튜토리얼] 12. 다국어 지원 시스템 만들기 (i18n): 전 세계와 소통하자!
  • [DiscordJS 봇 개발 튜토리얼] 11. 사용자 반응(이모지) 기반 기능 만들기: 손쉬운 상호작용의 시작
  • [DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기
  • [DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자!
디스호스트
디스호스트
쉽고 안정적인 디스코드 봇 호스팅 서비스, 디스호스트의 기술 블로그입니다. 디스호스트는 24시간 구동되는 서버를 통해 디스코드 봇을 대신 구동시켜 드리는 서비스를 제공하고 있습니다.
  • 디스호스트
    디스호스트 기술 블로그
    디스호스트
  • 블로그 메뉴

    • 홈
    • 디스호스트 사용 가이드
    • 디스코드 봇 호스팅, 24시간 서버 구동
    • 분류 전체보기 (41) N
      • 디스코드 (6)
      • 디스호스트 가이드 (11)
      • 봇 개발 팁 (10)
        • Discord.js (8)
        • Discord.py (1)
      • DiscordJS 개발 튜토리얼 (14) N
  • 링크

    • 디스호스트
    • 디스호스트 패널
  • hELLO· Designed By정상우.v4.10.3
디스호스트
[DiscordJS 봇 개발 튜토리얼] 15. 관리자 패널 만들기 (웹 연동 기본): 웹으로 봇을 편리하게 관리하자!
상단으로

티스토리툴바