<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>디스호스트 기술 블로그</title>
    <link>https://dishost.tistory.com/</link>
    <description>쉽고 안정적인 디스코드 봇 호스팅 서비스, 디스호스트의 기술 블로그입니다. 디스호스트는 24시간 구동되는 서버를 통해 디스코드 봇을 대신 구동시켜 드리는 서비스를 제공하고 있습니다.</description>
    <language>ko</language>
    <pubDate>Sun, 12 Apr 2026 07:25:19 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>디스호스트</managingEditor>
    <image>
      <title>디스호스트 기술 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/7936972/attach/c8df017a89a149ca918a8d2ea9a3e187</url>
      <link>https://dishost.tistory.com</link>
    </image>
    <item>
      <title>디스코드 공지 채널 운영법</title>
      <link>https://dishost.tistory.com/106</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;공지 채널이 있는데도 공지가 안 읽힌다면, 문제는 글솜씨보다 채널 운영 방식에 있습니다.&lt;br /&gt;&lt;br /&gt;일반 채팅이 섞이거나, 공지가 너무 길거나, 질문 동선이 없으면 공지는 전달이 아니라 배경 소음이 됩니다.&lt;br /&gt;&lt;br /&gt;이 글은 공지 문장을 잘 쓰는 법보다 공지가 실제로 소비되는 채널 구조를 만드는 데 초점을 둡니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 공지는 쓰는 채널이 아니라 소비되는 채널이어야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 멤버가 바로 답글을 쓰게 두면 공지가 금방 묻힙니다.&lt;br /&gt;&lt;br /&gt;초반 서버라면 &lt;code&gt;@everyone&lt;/code&gt;의 &lt;code&gt;Send Messages&lt;/code&gt;를 막고 운영진만 쓰게 두는 구성이 무난합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한 예시는 아래처럼 잡을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;@everyone
- View Channel: 허용
- Send Messages: 차단

운영진
- View Channel: 허용
- Send Messages: 허용

봇
- View Channel: 허용
- Send Messages: 허용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;봇 공지도 쓸 계획이라면 봇 역할 허용을 빼먹지 말아야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 공지 채널은 위쪽에 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 채널이 중간 아래에 있으면 실제로 거의 안 읽힙니다.&lt;br /&gt;&lt;br /&gt;신규 유저가 서버에 들어왔을 때 규칙과 함께 가장 위쪽에서 보여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조상 보통은 &lt;code&gt;시작하기&lt;/code&gt; 카테고리 안에 두는 경우가 많습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 공지 문장은 짧고 목적이 분명해야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 채널은 블로그 글처럼 길게 쓰는 곳이 아닙니다.&lt;br /&gt;&lt;br /&gt;무엇이 바뀌었는지, 언제 적용되는지, 유저가 뭘 해야 하는지만 빨리 전달하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 아래 정도가 읽기 쉽습니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;[공지] 이번 주 서버 점검 안내
- 점검 시간: 토요일 오후 10시
- 영향 범위: 봇 명령어 일시 중단
- 참고: 점검 후 로그 채널 구조가 일부 변경됩니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 구조는 운영진 입장에서도 반복 사용하기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 공지 유형은 섞지 말아야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업데이트 공지, 이벤트 공지, 규칙 변경 공지를 한 채널에 모두 넣어도 됩니다.&lt;br /&gt;&lt;br /&gt;다만 양이 많아지면 이벤트용 채널을 분리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 공지 채널 하나로 시작하고, 운영량이 늘면 &lt;code&gt;이벤트공지&lt;/code&gt;, &lt;code&gt;업데이트&lt;/code&gt; 정도를 분리하는 방식이 무난합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 핀 고정은 기준을 정하고 쓴다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 공지를 핀 고정하면 결국 핀 목록도 읽히지 않습니다.&lt;br /&gt;&lt;br /&gt;항상 봐야 하는 안내만 고정해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통은 아래 정도만 고정하면 충분합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 이용 핵심 공지&lt;/li&gt;
&lt;li&gt;규칙 변경 안내&lt;/li&gt;
&lt;li&gt;이벤트 참여 방식&lt;/li&gt;
&lt;li&gt;외부 링크 안내&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 공지 채널에 유저 반응이 필요하면 별도 채널을 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 아래에서 토론이 길어지면 채널이 바로 지저분해집니다.&lt;br /&gt;&lt;br /&gt;의견 수집이 필요하면 &lt;code&gt;공지댓글&lt;/code&gt;, &lt;code&gt;피드백&lt;/code&gt;, &lt;code&gt;질문&lt;/code&gt; 채널을 따로 두면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 채널은 전달, 토론 채널은 반응으로 나누는 방식이 관리가 편합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 봇 공지를 쓸 계획이라면 포맷을 통일한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업데이트 로그, 점검 알림, 유튜브 업로드 알림처럼 봇이 공지를 올릴 수 있습니다.&lt;br /&gt;&lt;br /&gt;이때는 제목 규칙과 임베드 스타일을 통일해 둬야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람이 쓴 공지와 봇 공지 톤이 너무 다르면 채널 인상이 산만해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 운영 기준도 같이 정해 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 정도는 운영진끼리 합의해 두면 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;누가 공지를 올릴 수 있는가&lt;/li&gt;
&lt;li&gt;긴급 공지와 일반 공지를 어떻게 구분할 것인가&lt;/li&gt;
&lt;li&gt;공지 후 질문은 어느 채널로 받을 것인가&lt;/li&gt;
&lt;li&gt;오래된 공지는 언제 정리할 것인가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 운영은 채널 하나보다 운영 습관에 더 가깝습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 공지가 보이기 시작하면 이제 입구 전체 흐름을 맞춘다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 채널이 읽히기 시작하면 다음에는 규칙, 공지, 질문 동선이 한 카테고리 안에서 자연스럽게 이어지는지 봐야 합니다.&lt;br /&gt;&lt;br /&gt;입구 전체를 같이 점검하려면 &lt;a href=&quot;https://blog.dishost.kr/105&quot;&gt;디스코드 서버 규칙 채널 작성법&lt;/a&gt;과 &lt;a href=&quot;https://blog.dishost.kr/103&quot;&gt;디스코드 서버 온보딩 채널 구성 예시&lt;/a&gt;를 같이 열어 두고 봐야 맞습니다.&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/106</guid>
      <comments>https://dishost.tistory.com/106#entry106comment</comments>
      <pubDate>Sat, 11 Apr 2026 16:46:04 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 서버 규칙 채널 작성법</title>
      <link>https://dishost.tistory.com/105</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 채널이 길고 무거우면 유저는 안 읽고, 짧기만 하면 운영진이 나중에 설명하다 지칩니다.&lt;br /&gt;&lt;br /&gt;좋은 규칙 채널은 많아 보이는 규칙보다 &quot;뭐가 금지인지, 어디까지 제재하는지, 어디에 문의하는지&quot;가 바로 보이는 문안입니다.&lt;br /&gt;&lt;br /&gt;이 글은 규칙을 많이 쓰는 법이 아니라, 읽히는 규칙 카피를 어떻게 쓰는지에 집중하는 글입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 규칙 채널은 법전이 아니라 행동 기준 요약본이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 채널은 운영진 기록용 문서가 아닙니다.&lt;br /&gt;&lt;br /&gt;신규 유저가 서버 분위기와 금지 행동을 짧은 시간 안에 파악하게 만드는 용도입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;길게 설명하는 것보다 아래 세 가지가 바로 보여야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하면 안 되는 행동&lt;/li&gt;
&lt;li&gt;제재 기준&lt;/li&gt;
&lt;li&gt;문의 위치&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 초반 규칙은 짧아야 읽힌다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 규칙 20개를 쓰면 거의 안 읽힙니다.&lt;br /&gt;&lt;br /&gt;실제 운영 초반에는 핵심 규칙 5~7개 정도면 충분한 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 아래처럼 시작할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 욕설, 비방, 분쟁 유도 금지
2. 광고, 도배, 스팸 금지
3. 개인정보 공유 금지
4. 불법 자료 공유 금지
5. 운영진 안내 우선 적용
6. 문의는 문의 채널 사용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 정도만 있어도 기본 방향은 충분히 전달됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 규칙 문장은 추상적이면 안 된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;서로 예의를 지켜 주세요&lt;/code&gt;처럼 너무 넓은 문장만 있으면 실제 제재 기준으로 쓰기 어렵습니다.&lt;br /&gt;&lt;br /&gt;어떤 행동이 금지되는지 조금 더 명확히 써야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;과도한 욕설, 타 유저 조롱, 싸움 유도 발언 금지&lt;/code&gt;처럼 쓰면 운영 기준이 선명해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 제재 기준을 아주 짧게라도 적어 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙을 어겼을 때 어떤 조치가 있는지 완전히 비워 두면 운영 일관성이 흔들립니다.&lt;br /&gt;&lt;br /&gt;길게 적을 필요는 없지만 기본 원칙은 남겨 둬야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 아래처럼 둘 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;경고 없이 메시지 삭제가 진행될 수 있습니다.
반복 위반은 타임아웃 또는 퇴장으로 이어질 수 있습니다.
악성 광고, 불법 자료 공유는 즉시 제재 대상입니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 정도면 운영진도 기준을 설명하기 쉬워집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 문의 위치를 반드시 적는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 해석에 이견이 생기거나 제재 문의가 들어올 수 있습니다.&lt;br /&gt;&lt;br /&gt;문의 채널이나 운영진 호출 위치가 없으면 잡담 채널에서 바로 분쟁이 시작됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 채널 하단에 문의 동선을 적어 둬야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 너무 딱딱한 문체만 고집할 필요는 없다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 채널이라고 해서 법률 문서처럼 쓰면 읽기 어려워집니다.&lt;br /&gt;&lt;br /&gt;건조한 문체는 유지하되, 문장을 짧게 자르고 항목형으로 정리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽히는 규칙 채널은 권위적인 문장보다 구조가 좋습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 인증 서버라면 연결 문구도 넣는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증을 거쳐야 활동이 가능한 서버라면 규칙 채널 마지막에 다음 동작을 적어 둬야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 아래처럼 둘 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;규칙을 확인했다면 역할선택 또는 인증 채널로 이동해 주세요.
인증이 끝나면 잡담과 질문 채널이 열립니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 문장 하나가 온보딩 이탈을 줄이는 경우가 많습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 운영자가 주기적으로 고칠 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 커지면 초반 규칙만으로 부족해질 수 있습니다.&lt;br /&gt;&lt;br /&gt;광고, 티켓 남용, 특정 이벤트 스팸처럼 실제로 자주 생기는 문제를 반영해서 규칙을 다듬어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 쓴 문장을 오래 방치하면 운영 현실과 안 맞아집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 규칙을 썼다면 이제 읽히는 위치와 전달 방식까지 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙 문안을 정리했다면 다음은 그 문구가 실제로 눈에 들어오는 위치에 놓여 있는지 봐야 합니다.&lt;br /&gt;&lt;br /&gt;입구 배치까지 같이 손보려면 &lt;a href=&quot;https://blog.dishost.kr/103&quot;&gt;디스코드 서버 온보딩 채널 구성 예시&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/102&quot;&gt;디스코드 서버 꾸미기, 채널 역할 배치 가이드&lt;/a&gt;를 같이 놓고 봐야 흐름이 맞습니다.&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/105</guid>
      <comments>https://dishost.tistory.com/105#entry105comment</comments>
      <pubDate>Fri, 10 Apr 2026 16:45:38 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 커뮤니티 서버 활성화 방법</title>
      <link>https://dishost.tistory.com/104</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;일반 서버로도 운영은 가능합니다.&lt;br /&gt;&lt;br /&gt;하지만 공지, 규칙, 포럼, 온보딩 같은 구조를 좀 더 정식으로 쓰려면 커뮤니티 서버 기능이 필요할 때가 있습니다.&lt;br /&gt;&lt;br /&gt;특히 공개형 서버나 운영팀이 있는 서버는 이 기능을 켜는 순간 관리 동선이 정리되는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티 기능은 켜는 순간 좋아지는 옵션이 아니라 입구 채널이 정리됐을 때 효과가 나는 설정입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 커뮤니티 서버를 켜기 전에 준비할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무조건 먼저 켜는 것이 답은 아닙니다.&lt;br /&gt;&lt;br /&gt;최소한 규칙 채널과 공지 채널은 준비해 둔 상태가 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드는 커뮤니티 전환 과정에서 이 두 채널을 기준으로 설정을 요구하는 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;기본 골격이 없다면 &lt;a href=&quot;https://blog.dishost.kr/54&quot;&gt;디스코드 서버 만드는 법 처음부터 끝까지, 채널 역할 기본 세팅 가이드&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/103&quot;&gt;디스코드 서버 온보딩 채널 구성 예시&lt;/a&gt;를 먼저 맞춰야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 활성화 위치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 설정으로 들어간 뒤 &lt;code&gt;커뮤니티 활성화&lt;/code&gt; 또는 관련 메뉴를 찾습니다.&lt;br /&gt;&lt;br /&gt;화면 안내를 따라가면 규칙 채널과 공지 채널을 지정하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 아래 두 채널이 필요합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;규칙 또는 가이드 채널
공지 또는 업데이트 채널&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;채널이 없다면 먼저 만들고 다시 들어가면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 왜 규칙과 공지가 먼저 필요한가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티 서버는 공개 운영을 전제로 한 기능이 많습니다.&lt;br /&gt;&lt;br /&gt;그래서 디스코드는 신규 유저가 최소한의 안내와 운영 기준을 볼 수 있도록 기본 채널을 먼저 요구하는 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계 없이 구조만 화려하게 만들면 서버 인상은 좋아 보여도 운영은 불안해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 활성화 후 바로 좋아지는 점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티 서버를 켜면 공지 구조가 더 선명해지고, 일부 관리 기능과 공개형 기능을 활용하기 쉬워집니다.&lt;br /&gt;&lt;br /&gt;포럼 채널, 서버 디렉터리 진입 준비, 온보딩 관련 설정도 이 흐름 위에서 보게 되는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공개형 서버를 운영할 생각이라면 일반 서버보다 훨씬 정돈된 느낌을 만들기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 바로 확인할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;켜고 끝내지 말고 아래를 봅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;규칙 채널이 실제로 위쪽에 잘 노출되는가&lt;/li&gt;
&lt;li&gt;공지 채널이 읽기 전용으로 잘 잡혀 있는가&lt;/li&gt;
&lt;li&gt;일반 유저가 어디서 활동을 시작해야 하는지 보이는가&lt;/li&gt;
&lt;li&gt;운영진 채널이 외부에 노출되지 않는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티 서버라고 해서 권한 구조가 자동으로 정리되는 것은 아닙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 활성화만 하고 서버가 좋아지지는 않는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분을 많이 착각합니다.&lt;br /&gt;&lt;br /&gt;커뮤니티 기능은 도구입니다.&lt;br /&gt;&lt;br /&gt;채널 구조, 역할 구조, 공지 운영이 받쳐 주지 않으면 그냥 설정 하나 더 생긴 수준으로 끝납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 온보딩과 공지 운영을 같이 다듬어야 체감이 생깁니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 이런 서버에 특히 잘 맞는다&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공개형 커뮤니티 서버&lt;/li&gt;
&lt;li&gt;스터디 / 정보 공유 서버&lt;/li&gt;
&lt;li&gt;운영진이 따로 있는 중형 서버&lt;/li&gt;
&lt;li&gt;이벤트와 공지가 자주 올라오는 서버&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 소규모 친구 서버는 굳이 바로 켤 필요가 없을 수도 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 자주 막히는 문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;규칙 채널, 공지 채널을 미리 안 만든 경우&lt;/li&gt;
&lt;li&gt;공지 채널 권한이 읽기 전용으로 안 잡힌 경우&lt;/li&gt;
&lt;li&gt;커뮤니티를 켰는데 기존 채널 구조가 너무 복잡해 여전히 동선이 헷갈리는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 켜기 전에 입구 채널을 정리해 둬야 하는 이유가 여기 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티 서버를 활성화했다면 규칙 채널과 공지 채널 운영 기준을 먼저 다듬어야 합니다.&lt;br /&gt;&lt;br /&gt;입구 구조를 다시 점검하려면 &lt;a href=&quot;https://blog.dishost.kr/103&quot;&gt;디스코드 서버 온보딩 채널 구성 예시&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/54&quot;&gt;디스코드 서버 만드는 법 처음부터 끝까지, 채널 역할 기본 세팅 가이드&lt;/a&gt;를 같이 열어 두면 됩니다.&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/104</guid>
      <comments>https://dishost.tistory.com/104#entry104comment</comments>
      <pubDate>Thu, 9 Apr 2026 16:45:12 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 서버 온보딩 채널 구성 예시</title>
      <link>https://dishost.tistory.com/103</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;새 멤버가 들어오자마자 &quot;그래서 어디부터 봐야 하죠&quot; 상태가 되면, 서버 퀄리티는 그 순간 이미 깎인 겁니다.&lt;br /&gt;&lt;br /&gt;온보딩의 핵심은 채널을 많이 만드는 게 아니라 첫 30초 안에 읽을 것, 누를 것, 말할 곳을 한 줄로 보이게 두는 데 있습니다.&lt;br /&gt;&lt;br /&gt;이 글은 신규 유저가 입장 직후 길을 잃지 않게 만드는 입구 IA 설계 글입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 서버 골격이 아직 흐릿하다면 &lt;a href=&quot;https://blog.dishost.kr/54&quot;&gt;디스코드 서버 만드는 법 처음부터 끝까지, 채널 역할 기본 세팅 가이드&lt;/a&gt;부터 같이 봐야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 입구에서 해야 할 행동은 세 개만 보여야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 절차를 너무 많이 넣으면 유저가 지칩니다.&lt;br /&gt;&lt;br /&gt;대부분의 서버는 아래 세 단계면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 규칙 확인
2. 역할 선택 또는 인증
3. 잡담 / 질문 채널 진입&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 흐름이 가장 단순하고 설명하기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 시작하기 카테고리 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;온보딩 전용 카테고리는 아래처럼 잡으면 무난합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;  시작하기
├─ 규칙
├─ 공지
├─ 역할선택
├─ 서버소개
└─ 처음온사람질문&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`처음온사람질문` 채널은 생각보다 중요합니다.&lt;br /&gt;처음 들어온 유저는 어디에 물어봐야 하는지 모를 때가 많기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 규칙 채널은 너무 길면 안 읽힌다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 것은 다 넣어야 하지만 문장을 길게 늘어놓으면 읽히지 않습니다.&lt;br /&gt;&lt;br /&gt;핵심 금지 항목, 제재 기준, 문의 위치 정도를 짧게 정리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 아래 정도로 시작하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 욕설 / 분쟁 유도 금지
2. 광고 / 도배 금지
3. 개인정보 공유 금지
4. 문의는 문의 채널 사용
5. 운영진 안내 우선 적용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;너무 많은 조항은 나중에 별도 문서나 운영 채널에서 관리해도 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 역할 선택은 가입 직후 가장 잘 보이는 곳에 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 역할, 게임 역할, 관심사 역할을 운영할 계획이라면 역할 선택 채널을 위에 둡니다.&lt;br /&gt;&lt;br /&gt;중간 아래로 밀리면 거의 안 눌립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼형 역할이나 반응 역할을 붙일 계획이 없더라도 우선 안내 문구라도 둬야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 서버소개 채널은 유저에게 방향을 알려 주는 용도다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버소개는 운영자 자기소개가 아닙니다.&lt;br /&gt;&lt;br /&gt;이 서버에서 무엇을 하는지, 어떤 채널부터 보면 되는지, 누구에게 맞는지 짧게 알려 주는 채널입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반 예시는 아래처럼 잡을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;이 서버는 디스코드 봇 개발과 서버 운영 정보를 나누는 공간입니다.
처음 들어왔다면 규칙과 공지를 먼저 확인해 주세요.
질문은 질문 채널에서, 봇 테스트는 봇명령어 채널에서 진행하면 됩니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;유저가 처음 30초 안에 이해할 수 있어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 질문 채널은 온보딩 직후 바로 보이게 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙을 읽고 역할을 고른 뒤 바로 말을 걸 수 있어야 활동이 붙습니다.&lt;br /&gt;&lt;br /&gt;질문 채널이 너무 아래 있거나 커뮤니티 채널 뒤에 묻히면 초반 대화가 줄어듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문 채널은 온보딩의 끝이 아니라 실제 활동으로 넘어가는 다리 역할입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 인증 서버라면 차단 구조를 명확히 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 전 유저가 볼 수 있는 채널과 인증 후 채널을 나눌 계획이라면 규칙 채널과 인증 채널만 먼저 노출하는 구조가 흔합니다.&lt;br /&gt;&lt;br /&gt;이 경우 인증 실패 시 어디로 문의할지도 같이 적어 둬야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증만 걸어 두고 실패 동선을 안 두면 유저가 바로 이탈합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 운영자가 직접 점검할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 계정으로 아래를 확인합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 입장 직후 규칙과 공지가 바로 보이는가&lt;/li&gt;
&lt;li&gt;역할 선택 위치가 눈에 띄는가&lt;/li&gt;
&lt;li&gt;질문 채널까지 자연스럽게 이어지는가&lt;/li&gt;
&lt;li&gt;인증 전후 채널 노출이 의도대로 바뀌는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 네 가지가 맞으면 온보딩 구조는 기본적으로 잘 잡힌 상태입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 입구 구조를 잡았다면 이제 문구와 배치를 조인다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입구 구조를 잡았다면 다음 단계는 채널 수를 더 늘리는 게 아니라, 규칙 문구와 공지 위치를 더 읽히게 다듬는 일입니다.&lt;br /&gt;&lt;br /&gt;입구 카테고리 배치를 다시 점검할 때는 &lt;a href=&quot;https://blog.dishost.kr/102&quot;&gt;디스코드 서버 꾸미기, 채널 역할 배치 가이드&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/54&quot;&gt;디스코드 서버 만드는 법 처음부터 끝까지, 채널 역할 기본 세팅 가이드&lt;/a&gt;를 같이 보면 좋습니다.&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/103</guid>
      <comments>https://dishost.tistory.com/103#entry103comment</comments>
      <pubDate>Wed, 8 Apr 2026 16:44:46 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 서버 꾸미기, 채널 역할 배치 가이드</title>
      <link>https://dishost.tistory.com/102</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 서버를 꾸민다고 하면 보통 배너, 이모지, 역할 색상부터 떠올립니다.&lt;br /&gt;&lt;br /&gt;실제로 서버 인상을 좌우하는 것은 채널 순서와 역할 배치입니다.&lt;br /&gt;&lt;br /&gt;처음 들어온 유저가 어디로 이동하는지, 운영진이 어디서 관리하는지, 봇이 어느 채널에서 일하는지가 한눈에 보여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치는 보기 좋은 것보다 운영 동선이 오래 버티는 쪽으로 잡아야 합니다.&lt;br /&gt;&lt;br /&gt;기본 서버 골격이 아직 없다면 &lt;a href=&quot;https://blog.dishost.kr/54&quot;&gt;디스코드 서버 만드는 법 처음부터 끝까지, 채널 역할 기본 세팅 가이드&lt;/a&gt;부터 먼저 맞춰 둬야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 꾸미기 전에 동선부터 정한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 들어온 유저가 밟는 순서를 먼저 정해야 합니다.&lt;br /&gt;&lt;br /&gt;서버 입구가 복잡하면 채널이 많아도 체감은 오히려 나빠집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반 동선은 보통 아래처럼 잡습니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;규칙 확인
-&amp;gt; 공지 확인
-&amp;gt; 역할 선택 또는 인증
-&amp;gt; 잡담 / 질문 / 봇 명령어 이동&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 흐름이 선명하면 채널 수가 좀 있어도 덜 복잡해 보입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 카테고리는 기능 단위로 나눈다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장식 기준으로 나누기보다 기능 기준으로 나눠야 관리가 쉽습니다.&lt;br /&gt;&lt;br /&gt;초반 서버라면 아래 정도가 가장 무난합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;  시작하기
  커뮤니티
  봇
  이벤트
  운영&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 다섯 묶음만 잘 잡아도 대부분의 중소형 서버는 충분히 굴러갑니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 채널 배치 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로는 아래처럼 두면 정리가 쉽습니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;  시작하기
├─ 규칙
├─ 공지
├─ 역할선택
└─ 서버소개

  커뮤니티
├─ 잡담
├─ 질문
└─ 자료공유

  봇
├─ 봇명령어
└─ 봇로그

  이벤트
├─ 이벤트공지
└─ 참여인증

  운영
├─ 관리자전용
├─ 문의관리
└─ 내부로그&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;꾸민다는 이유로 잡담 채널만 여러 개 늘리는 방식은 초반에 잘 맞지 않습니다.&lt;br /&gt;활동량이 적을 때는 채널 수보다 대화 밀도가 더 중요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 역할도 장식보다 기능이 먼저다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 색상부터 정하기 시작하면 구조가 쉽게 흐트러집니다.&lt;br /&gt;&lt;br /&gt;먼저 기능 역할을 두고, 장식 역할은 그다음에 붙여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반 역할 예시는 아래 정도면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;관리자
운영진
봇
인증됨
멤버
알림수신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`알림수신`처럼 목적이 분명한 역할은 실제 운영에 도움이 됩니다.&lt;br /&gt;반대로 이름만 멋있는 역할은 나중에 정리 대상이 되기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 채널 이름 톤도 맞춘다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널 이름 규칙이 제각각이면 구조가 더 복잡해 보입니다.&lt;br /&gt;&lt;br /&gt;한글 위주로 갈지, 영어 위주로 갈지, 이모지를 붙일지 먼저 정해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래처럼 통일감을 주면 읽기 편합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;공지
잡담
질문
봇명령어
관리자전용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;또는 아래처럼 이모지를 붙여도 됩니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt; -공지
 -잡담
❓-질문
 -봇명령어
 -관리자전용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;둘 다 괜찮지만 한 서버 안에서는 규칙을 섞지 말아야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 공지와 잡담은 최대한 멀리 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 채널이 잡담 채널 아래에 묻히면 실제로 안 읽힙니다.&lt;br /&gt;&lt;br /&gt;유저가 들어왔을 때 가장 먼저 규칙과 공지가 보여야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 꾸밀 때 시각적인 예쁨보다 위쪽 배치 우선순위를 먼저 정합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 봇 채널은 따로 분리해야 덜 엉킨다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 명령어와 일반 대화를 섞어 두면 채널이 금방 지저분해집니다.&lt;br /&gt;&lt;br /&gt;기본 봇만 있어도 &lt;code&gt;!핑&lt;/code&gt;, &lt;code&gt;/핑&lt;/code&gt;, 각종 테스트 로그가 쌓이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반부터 &lt;code&gt;봇명령어&lt;/code&gt; 하나를 따로 두면 이후 AI 봇, 티켓 봇, 로그 봇을 붙일 때도 편합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 운영 관점에서 꼭 봐야 할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 항목이 설명 가능해야 서버 구조가 잘 잡힌 상태입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새 유저는 어디부터 보는가&lt;/li&gt;
&lt;li&gt;공지는 누가 쓰는가&lt;/li&gt;
&lt;li&gt;봇은 어느 채널에서 동작하는가&lt;/li&gt;
&lt;li&gt;운영진은 어디서 내부 논의를 하는가&lt;/li&gt;
&lt;li&gt;문의가 들어오면 어디로 모이는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 질문에 바로 답이 안 나오면 구조를 다시 다듬어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 배치가 끝났다면 동선으로 다시 검증한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널과 역할 배치를 정리했다면 다음에는 신규 유저가 처음 들어왔을 때 어떤 화면을 보게 할지 온보딩 구조를 구체화해야 합니다.&lt;br /&gt;&lt;br /&gt;역할 구조를 더 손보고 싶다면 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 같이 보면 됩니다.&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/102</guid>
      <comments>https://dishost.tistory.com/102#entry102comment</comments>
      <pubDate>Tue, 7 Apr 2026 18:33:53 +0900</pubDate>
    </item>
    <item>
      <title>Privileged Intents not enabled 오류 해결법</title>
      <link>https://dishost.tistory.com/101</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 봇을 실행했는데 &lt;code&gt;Privileged Intents not enabled&lt;/code&gt; 경고나 오류가 뜨면 대부분 Portal 설정과 코드 설정이 어긋난 상태입니다.&lt;br /&gt;&lt;br /&gt;명령어가 안 먹히거나 이벤트가 아예 안 들어오는 경우가 같이 나타날 수 있습니다.&lt;br /&gt;&lt;br /&gt;접두사 명령어, 멤버 이벤트, 상태 추적 기능을 붙일수록 자주 보게 되는 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 오류는 이름만 보면 어렵게 느껴지지만 확인할 위치는 생각보다 단순합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Privileged Intents가 무엇인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드가 민감도가 높은 일부 이벤트를 별도 허용 항목으로 분리해 둔 것입니다.&lt;br /&gt;&lt;br /&gt;대표적으로 아래 항목이 자주 나옵니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Message Content Intent&lt;/li&gt;
&lt;li&gt;Server Members Intent&lt;/li&gt;
&lt;li&gt;Presence Intent&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문 단계에서는 보통 &lt;code&gt;Message Content Intent&lt;/code&gt;부터 많이 만납니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 코드에서 켰다고 끝이 아니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많이 하는 실수가 여기 있습니다.&lt;br /&gt;&lt;br /&gt;코드에서 인텐트를 켜 두어도 Developer Portal에서 허용하지 않으면 오류가 납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 아래와 같습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;intents = discord.Intents.default()
intents.message_content = True
intents.members = True&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이렇게만 적어 두고 Portal에서 체크하지 않으면 그대로 막힙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Portal에서 켜는 위치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Discord Developer Portal에서 애플리케이션을 연 뒤 &lt;code&gt;Bot&lt;/code&gt; 메뉴로 들어갑니다.&lt;br /&gt;&lt;br /&gt;아래로 내리면 &lt;code&gt;Privileged Gateway Intents&lt;/code&gt; 구간이 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 필요한 항목을 켜고 저장합니다.&lt;br /&gt;&lt;br /&gt;저장 버튼을 누르지 않고 닫는 경우도 생각보다 많습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 어떤 기능이 어떤 인텐트를 쓰는지 구분한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 기능에 모든 인텐트가 필요한 것은 아닙니다.&lt;br /&gt;&lt;br /&gt;필요 없는 인텐트까지 무조건 켜 둘 이유도 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대략 아래처럼 보면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;!핑 같은 메시지 명령어 -&amp;gt; Message Content Intent
멤버 입장/퇴장 감지 -&amp;gt; Server Members Intent
상태 정보 추적 -&amp;gt; Presence Intent&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음에는 필요한 것만 정확히 켜야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 코드는 맞는데 이벤트가 안 들어올 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러가 길게 안 나와도 이벤트가 조용할 수 있습니다.&lt;br /&gt;&lt;br /&gt;특히 &lt;code&gt;on_message&lt;/code&gt;, &lt;code&gt;on_member_join&lt;/code&gt; 같은 이벤트가 전혀 안 들어오면 인텐트부터 먼저 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 코드 디버깅보다 인텐트 확인이 더 빠른 경우가 많습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 빠른 점검 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 둘을 같이 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 코드에서 해당 intent를 True로 켰는가
2. Developer Portal의 Bot 메뉴에서 같은 intent를 켰는가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;둘 중 하나라도 빠지면 오류가 납니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. Message Content Intent와 가장 자주 연결된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문자 기준으로는 거의 여기서 걸립니다.&lt;br /&gt;&lt;br /&gt;&lt;code&gt;!핑&lt;/code&gt;, &lt;code&gt;!도움&lt;/code&gt;, 자동 응답, 키워드 감지 기능은 대부분 메시지 본문을 읽어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;Privileged Intents not enabled&lt;/code&gt;는 사실상 Message Content 설정 누락으로 많이 체감됩니다.&lt;br /&gt;&lt;br /&gt;세부 흐름은 &lt;a href=&quot;https://blog.dishost.kr/56&quot;&gt;Message Content Intent 설정법, 디스코드 봇이 명령어를 읽지 못할 때 먼저 볼 것&lt;/a&gt;에서 더 자세히 정리해 두었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 필요 없는 인텐트까지 무조건 켜지 말 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제가 생겼다고 전부 다 켜 버리는 경우가 있습니다.&lt;br /&gt;&lt;br /&gt;당장은 편해 보여도 나중에 왜 이 이벤트를 받는지 스스로 설명하기 어려워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요 기능 기준으로만 켜야 운영과 디버깅 둘 다 낫습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인텐트 설정까지 맞췄는데도 메시지 명령어가 안 읽힌다면 다시 접두사 구조와 봇 재시작 여부를 봐야 합니다.&lt;br /&gt;&lt;br /&gt;서버 권한 쪽까지 함께 점검하려면 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법, 50013과 50001을 막는 기본 구조 정리&lt;/a&gt;을 이어서 확인하면 됩니다.&lt;/p&gt;</description>
      <category>디스코드 봇 오류 해결</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/101</guid>
      <comments>https://dishost.tistory.com/101#entry101comment</comments>
      <pubDate>Mon, 6 Apr 2026 18:33:27 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 역할이 지급되지 않을 때 해결법</title>
      <link>https://dishost.tistory.com/100</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;인증 버튼은 눌렸고 봇도 응답했는데 역할만 안 붙으면, 새 멤버 입구가 그 자리에서 막힙니다.&lt;br /&gt;&lt;br /&gt;이 문제는 권한 일반론보다 역할 계층과 대상 역할 관리 가능 범위를 먼저 봐야 훨씬 빠릅니다.&lt;br /&gt;&lt;br /&gt;이 글은 역할 지급 실패만 따로 떼어, 왜 &lt;code&gt;Manage Roles&lt;/code&gt;만 보고 끝내면 안 되는지 정리하는 글입니다.&lt;br /&gt;&lt;br /&gt;기본 권한 구조는 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법, 50013과 50001을 막는 기본 구조 정리&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 같이 보면 흐름이 잘 맞습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 역할 지급 실패는 거의 항상 계층부터 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제가 가장 흔합니다.&lt;br /&gt;&lt;br /&gt;봇 역할이 지급하려는 대상 역할보다 아래에 있으면 &lt;code&gt;Manage Roles&lt;/code&gt; 권한이 있어도 역할 지급이 실패합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래 구조는 문제가 생길 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;관리자
운영진
멤버
봇&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;봇이 `멤버` 역할을 주려면 봇 역할이 그 위에 있어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 봇 역할에 &lt;code&gt;Manage Roles&lt;/code&gt;가 있는지 확인한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한이 빠져 있으면 역할 순서가 맞아도 실패합니다.&lt;br /&gt;&lt;br /&gt;서버 역할 설정에서 봇 역할의 &lt;code&gt;Manage Roles&lt;/code&gt;를 확인합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 권한을 켰다고 끝나는 것은 아닙니다.&lt;br /&gt;&lt;br /&gt;문제가 생긴 채널에서 봇이 막혀 있지 않은지도 같이 봅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 지급 대상 역할이 관리 불가능한 역할인지 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자 역할처럼 너무 높은 역할, 통합 구조상 직접 만지지 않게 설계한 역할은 봇이 건드리지 못하게 두는 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;운영 설계상 일부 역할은 봇 지급 대상에서 빼야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반 서버라면 &lt;code&gt;인증됨&lt;/code&gt;, &lt;code&gt;멤버&lt;/code&gt;, &lt;code&gt;알림수신&lt;/code&gt; 정도를 자동 지급 대상으로 두면 무난합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 코드에서 역할 ID를 잘못 잡는 경우도 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 서버에서 복사한 역할 ID를 운영 서버 코드에 그대로 둔 경우가 흔합니다.&lt;br /&gt;&lt;br /&gt;이 경우 봇은 역할을 못 찾거나 엉뚱한 역할을 참조합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 이름으로 찾는 방식도 이름이 겹치면 꼬이기 쉽습니다.&lt;br /&gt;&lt;br /&gt;가능하면 역할 ID를 정확히 확인합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 버튼 인증이나 반응 역할은 이벤트가 실제로 들어오는지도 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 지급 함수 자체는 맞는데 이벤트가 안 들어와서 지급이 안 되는 경우가 있습니다.&lt;br /&gt;&lt;br /&gt;버튼, 모달, 반응 역할은 트리거가 다르기 때문에 이벤트 로그를 같이 찍어 봐야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 지급 문제처럼 보여도 실제로는 이벤트 미수신일 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 점검 코드 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할을 실제로 찾는지부터 확인하는 로그는 아래 정도면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;role = interaction.guild.get_role(ROLE_ID)

if role is None:
    await interaction.response.send_message(&quot;대상 역할을 찾지 못했습니다.&quot;, ephemeral=True)
    return

await interaction.user.add_roles(role)
await interaction.response.send_message(f&quot;{role.name} 역할을 지급했습니다.&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`role is None`이면 ID부터 다시 봐야 합니다.&lt;br /&gt;역할은 찾는데 지급이 안 되면 순서와 권한 쪽입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 이런 경우를 특히 많이 본다&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;봇 역할이 &lt;code&gt;멤버&lt;/code&gt;보다 아래에 있는 경우&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Manage Roles&lt;/code&gt;를 안 켠 경우&lt;/li&gt;
&lt;li&gt;잘못된 역할 ID를 넣은 경우&lt;/li&gt;
&lt;li&gt;테스트 서버와 운영 서버 역할 구조가 다른 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 거의 항상 첫 번째나 세 번째에서 걸립니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 임시로 관리자 권한을 줘도 역할 순서 문제는 남는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분을 많이 놓칩니다.&lt;br /&gt;&lt;br /&gt;관리자 권한을 줘도 역할 계층 문제는 그대로 남을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 문제 확인용으로 권한을 넓혀도 역할 위치는 따로 봐야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 역할 지급만 안 되는지, 더 넓은 권한 문제인지 가른다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 지급만 유독 안 된다면 이 글처럼 역할 계층과 역할 ID를 끝까지 보는 게 맞습니다.&lt;br /&gt;&lt;br /&gt;메시지 삭제나 채널 수정까지 전반적으로 실패한다면 &lt;a href=&quot;https://blog.dishost.kr/98&quot;&gt;Missing Permissions(50013) 오류 해결법&lt;/a&gt; 쪽이 더 넓은 진단이고, 인증 입구 전체 권한표를 다시 보고 싶다면 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법, 50013과 50001을 막는 기본 구조 정리&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 같이 보면 자연스럽습니다.&lt;/p&gt;</description>
      <category>디스코드 봇 오류 해결</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/100</guid>
      <comments>https://dishost.tistory.com/100#entry100comment</comments>
      <pubDate>Sun, 5 Apr 2026 18:33:01 +0900</pubDate>
    </item>
    <item>
      <title>Missing Access(50001) 오류 해결법</title>
      <link>https://dishost.tistory.com/99</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;로그 채널에 보내라고 했는데 봇이 채널을 못 보고, 특정 포럼이나 카테고리만 유독 접근이 안 되면 이 오류를 먼저 의심해야 합니다.&lt;br /&gt;&lt;br /&gt;50013이 &quot;할 수는 보이는데 못 한다&quot;라면, 50001은 &quot;아예 그 대상에 못 들어간다&quot;에 가깝습니다.&lt;br /&gt;&lt;br /&gt;이 글은 작업 권한이 아니라 대상 접근 자체가 끊긴 상황만 따로 떼어 보는 글입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 대상 자체를 못 볼 때 나오는 쪽에 가깝다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇이 채널, 서버, 메시지, 역할 같은 대상에 접근할 수 없다는 뜻입니다.&lt;br /&gt;&lt;br /&gt;보통은 채널 보기 권한, 대상 존재 여부, 잘못된 ID 사용 문제에서 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래 상황에서 자주 나옵니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;봇이 못 보는 채널에 메시지를 보내려는 경우&lt;/li&gt;
&lt;li&gt;접근 권한이 없는 카테고리나 포럼을 읽으려는 경우&lt;/li&gt;
&lt;li&gt;이미 삭제된 채널 ID를 계속 참조하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 50013과 먼저 분리한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행동은 허용되는데 작업 권한이 부족한 것은 50013입니다.&lt;br /&gt;&lt;br /&gt;채널이나 대상 자체를 못 보는 것은 50001 쪽입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 둘을 섞으면 계속 엉뚱한 권한만 켜게 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 봇이 그 채널을 실제로 보고 있는지 확인한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 &lt;code&gt;View Channel&lt;/code&gt;을 확인합니다.&lt;br /&gt;&lt;br /&gt;채널 자체가 안 보이면 메시지 전송, 로그 기록, 히스토리 조회 모두 막힙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 아래 채널에서 자주 놓칩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;관리자 채널&lt;/li&gt;
&lt;li&gt;공지 채널&lt;/li&gt;
&lt;li&gt;로그 채널&lt;/li&gt;
&lt;li&gt;티켓 전용 카테고리&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 카테고리 권한 때문에 막히는 경우가 많다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널 하나를 허용했다고 생각했는데 상위 카테고리에서 이미 막혀 있는 경우가 있습니다.&lt;br /&gt;&lt;br /&gt;운영 카테고리나 인증 카테고리에서 자주 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 채널 설정만 봐서는 바로 안 보일 수 있습니다.&lt;br /&gt;&lt;br /&gt;상위 카테고리까지 같이 봐야 빠릅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 잘못된 ID를 참조하는지 확인한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에 채널 ID, 메시지 ID, 역할 ID를 하드코딩해 둔 경우 오래 지나면 다른 대상이 되어 있을 수 있습니다.&lt;br /&gt;&lt;br /&gt;삭제된 채널이나 테스트 서버 채널을 계속 참조하면 50001처럼 보이기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 서버와 테스트 서버를 오갈 때 특히 자주 생깁니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 접근 확인용 코드 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 해당 채널을 실제로 찾고 접근 가능한지 로그를 남겨 보면 빠릅니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;channel = bot.get_channel(CHANNEL_ID)

if channel is None:
    print(&quot;채널을 찾지 못했습니다.&quot;)
else:
    permissions = channel.permissions_for(channel.guild.me)
    print(f&quot;view_channel={permissions.view_channel}&quot;)
    print(f&quot;send_messages={permissions.send_messages}&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`channel is None`이면 ID부터 다시 볼 필요가 있습니다.&lt;br /&gt;채널은 잡히는데 `view_channel=False`면 권한 문제입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 봇 재초대가 필요한 경우도 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전 권한 구조로 초대된 봇을 계속 쓰는 경우가 있습니다.&lt;br /&gt;&lt;br /&gt;초대 링크 권한이 너무 좁으면 새 채널 구조에서 접근이 막힐 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 관리자용 카테고리, 로그 채널을 새로 만든 뒤 이 문제가 종종 나옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 점검 순서&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 대상 ID가 맞는가
2. 봇이 채널을 실제로 찾는가
3. View Channel 권한이 있는가
4. 상위 카테고리에서 막혀 있지 않은가
5. 초대 링크나 역할 구조가 너무 좁지 않은가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 순서로 보면 50001은 생각보다 빨리 좁혀집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 접근 실패와 작업 실패를 여기서 분리한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상 접근은 되는데 메시지 삭제나 역할 수정만 실패한다면 &lt;a href=&quot;https://blog.dishost.kr/98&quot;&gt;Missing Permissions(50013) 오류 해결법&lt;/a&gt; 쪽으로 다시 돌아가야 맞습니다.&lt;br /&gt;&lt;br /&gt;특정 멤버에게 채널 자체가 안 보이는 구조라면 &lt;a href=&quot;https://blog.dishost.kr/97&quot;&gt;디스코드 채널이 안 보일 때 권한 해결법&lt;/a&gt;과 같이 보고, 역할 계층까지 함께 다시 봐야 한다면 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 이어서 봐야 안전합니다.&lt;/p&gt;</description>
      <category>디스코드 봇 오류 해결</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/99</guid>
      <comments>https://dishost.tistory.com/99#entry99comment</comments>
      <pubDate>Sat, 4 Apr 2026 18:32:34 +0900</pubDate>
    </item>
    <item>
      <title>Missing Permissions(50013) 오류 해결법</title>
      <link>https://dishost.tistory.com/98</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/청소&lt;/code&gt;는 눌렸는데 메시지가 안 지워지고, 역할 지급 명령어도 응답은 오는데 실제 작업만 실패하면 거의 이 오류를 의심하게 됩니다.&lt;br /&gt;&lt;br /&gt;핵심은 &quot;봇이 죽은 것&quot;이 아니라 &quot;작업 권한만 부족한 것&quot;이라는 점입니다.&lt;br /&gt;&lt;br /&gt;이 글은 접근은 되는데 행동만 막히는 50013 상황을 빠르게 가르는 데 집중합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 들어가기는 되는데 행동만 막히는 상태다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 오류는 봇이 어떤 작업을 수행할 권한이 부족하다는 뜻입니다.&lt;br /&gt;&lt;br /&gt;채널에 접근 자체를 못 하는 문제와는 결이 다릅니다.&lt;br /&gt;&lt;br /&gt;접근은 되지만 행동 권한이 부족한 상태라고 보면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래 작업에서 자주 보입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메시지 삭제&lt;/li&gt;
&lt;li&gt;역할 지급 또는 제거&lt;/li&gt;
&lt;li&gt;닉네임 변경&lt;/li&gt;
&lt;li&gt;타임아웃 부여&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 봇 역할 자체에 권한이 있는지 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 서버 역할 화면에서 봇 역할을 확인합니다.&lt;br /&gt;&lt;br /&gt;해당 작업에 맞는 권한이 실제로 켜져 있어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 메시지 삭제라면 &lt;code&gt;Manage Messages&lt;/code&gt;, 역할 지급이라면 &lt;code&gt;Manage Roles&lt;/code&gt;가 필요합니다.&lt;br /&gt;&lt;br /&gt;코드에서 명령어를 잘 짜도 역할 권한이 없으면 바로 50013으로 끝납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 권한표가 아직 흔들린다면 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법&lt;/a&gt;과 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 같이 열어 둬야 빠릅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 채널 덮어쓰기에서 다시 막히는지 확인한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 역할 권한을 켜도 채널에서 막혀 있으면 작업이 실패할 수 있습니다.&lt;br /&gt;&lt;br /&gt;로그 채널, 공지 채널, 특정 문의 채널에서 자주 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 권한만 보고 끝내지 말고, 문제가 발생한 채널의 덮어쓰기까지 같이 확인합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 역할 지급 계열은 역할 순서를 먼저 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;50013에서 가장 자주 놓치는 부분입니다.&lt;br /&gt;&lt;br /&gt;봇 역할이 대상 역할보다 아래에 있으면 &lt;code&gt;Manage Roles&lt;/code&gt;를 갖고 있어도 역할 지급이 실패합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 아래처럼 정리합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;관리자
운영진
봇
인증됨
멤버&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;봇이 `멤버`나 `인증됨` 역할을 주게 할 계획이라면 봇 역할이 그 위에 있어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 명령어 실행자 권한도 함께 볼 필요가 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 권한만 충분해도 안 되는 경우가 있습니다.&lt;br /&gt;&lt;br /&gt;코드에서 명령어 실행자에게도 &lt;code&gt;manage_messages=True&lt;/code&gt; 같은 조건을 걸어 두었다면 유저 권한이 부족할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 아래 둘을 나눠서 봅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;봇이 해당 작업을 할 권한이 있는가&lt;/li&gt;
&lt;li&gt;명령어를 실행한 유저가 그 명령어를 호출할 권한이 있는가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 빠른 점검 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 삭제 명령어가 실패한다면 아래 순서가 빠릅니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 봇 역할에 Manage Messages가 있는가
2. 해당 채널에서 봇 역할이 차단되지 않았는가
3. 명령어 실행자에게 메시지 관리 권한이 있는가
4. 봇이 정말 그 채널을 보고 있는가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;역할 지급 명령어라면 마지막에 역할 순서 확인이 추가됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 테스트 코드로 권한을 확인하는 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 현재 채널 기준 권한을 찍어 보면 빠릅니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.command(name=&quot;권한체크&quot;)
async def permission_check(ctx):
    permissions = ctx.channel.permissions_for(ctx.guild.me)
    await ctx.send(
        &quot;\n&quot;.join(
            [
                f&quot;manage_messages={permissions.manage_messages}&quot;,
                f&quot;manage_roles={permissions.manage_roles}&quot;,
                f&quot;view_channel={permissions.view_channel}&quot;,
                f&quot;send_messages={permissions.send_messages}&quot;,
            ]
        )
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;채널마다 결과가 다를 수 있으니 문제가 생긴 채널에서 직접 실행합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 관리자 권한으로 임시 확인만 하고 끝내지 말 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 원인을 찾겠다고 봇에 &lt;code&gt;Administrator&lt;/code&gt;를 잠깐 주는 경우가 있습니다.&lt;br /&gt;&lt;br /&gt;원인 분리 용도로는 쓸 수 있지만 그대로 운영하는 것은 추천하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇이 부족했는지 끝까지 확인하고 다시 최소 권한 구조로 되돌려야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 50013인지 50001인지 헷갈릴 때 갈라보는 순서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널이나 대상 자체가 안 보인다면 50013보다 &lt;a href=&quot;https://blog.dishost.kr/97&quot;&gt;디스코드 채널이 안 보일 때 권한 해결법&lt;/a&gt; 쪽이 먼저일 수 있습니다.&lt;br /&gt;&lt;br /&gt;반대로 역할 지급만 유독 안 되면 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;으로 역할 계층부터 다시 보고, 기본 권한표부터 다시 확인하고 싶다면 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법&lt;/a&gt;을 먼저 다시 맞춰야 빠릅니다.&lt;/p&gt;</description>
      <category>디스코드 봇 오류 해결</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/98</guid>
      <comments>https://dishost.tistory.com/98#entry98comment</comments>
      <pubDate>Fri, 3 Apr 2026 18:32:09 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 채널이 안 보일 때 권한 해결법</title>
      <link>https://dishost.tistory.com/97</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;공지 채널이 안 보여요&quot;, &quot;운영 채널이 새 멤버에게 노출돼요&quot; 같은 문제는 메시지 권한이 아니라 채널 가시성 설계가 망가졌다는 신호입니다.&lt;br /&gt;&lt;br /&gt;이때 send_messages만 만지면 계속 헛손질하게 됩니다.&lt;br /&gt;&lt;br /&gt;이 글은 특정 멤버나 봇이 채널 존재 자체를 못 보는 상황만 따로 떼서, View Channel 기준으로 구조를 다시 읽는 글입니다.&lt;br /&gt;&lt;br /&gt;기본 채널 권한 구조를 먼저 정리하고 싶다면 &lt;a href=&quot;https://blog.dishost.kr/57&quot;&gt;디스코드 채널 권한 설정법, 공지 채널과 관리자 채널이 꼬이지 않게 만드는 법&lt;/a&gt;부터 같이 보면 흐름이 잘 맞습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 안 보이는 문제는 거의 항상 &lt;code&gt;View Channel&lt;/code&gt;부터 시작한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널이 안 보이는 문제는 대부분 &lt;code&gt;View Channel&lt;/code&gt; 권한에서 시작합니다.&lt;br /&gt;&lt;br /&gt;메시지 전송 권한이 아니라 보기 권한 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 권한이 막혀 있으면 멤버는 채널 이름조차 못 봅니다.&lt;br /&gt;&lt;br /&gt;관리자 계정으로는 정상처럼 보여서 더 늦게 발견되기도 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 서버 역할 권한과 채널 권한을 분리해서 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 역할에서 허용해도 채널 덮어쓰기에서 막을 수 있습니다.&lt;br /&gt;&lt;br /&gt;반대로 서버 역할에 아예 권한이 없으면 채널에서 살릴 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크 순서는 아래처럼 보면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 역할 자체에 View Channel이 있는가
2. 상위 카테고리에서 막혀 있지 않은가
3. 채널 개별 덮어쓰기에서 막혀 있지 않은가
4. @everyone 차단이 의도대로 들어갔는가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;대부분 2번과 3번을 놓칩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 카테고리 권한이 덮이는 경우가 많다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널 하나만 보면 멀쩡해 보여도 상위 카테고리에서 이미 막혀 있을 수 있습니다.&lt;br /&gt;&lt;br /&gt;운영 카테고리, 인증 전용 카테고리에서 자주 나오는 패턴입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;운영&lt;/code&gt; 카테고리에서 &lt;code&gt;@everyone&lt;/code&gt;의 &lt;code&gt;View Channel&lt;/code&gt;을 차단해 두면, 하위 채널도 그대로 영향을 받습니다.&lt;br /&gt;&lt;br /&gt;이 구조를 모르고 채널 한 곳만 만지면 해결이 안 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 역할을 너무 많이 나누면 누가 왜 안 보이는지 설명이 어려워진다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;신규&lt;/code&gt;, &lt;code&gt;인증됨&lt;/code&gt;, &lt;code&gt;멤버&lt;/code&gt;, &lt;code&gt;VIP&lt;/code&gt;, &lt;code&gt;운영진&lt;/code&gt;처럼 역할이 많아질수록 권한 설명도 어려워집니다.&lt;br /&gt;&lt;br /&gt;채널이 안 보이는 문제는 복잡한 서버일수록 더 자주 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 역할 수를 줄이고, 카테고리 기준 권한을 먼저 맞춰야 관리가 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 테스트는 일반 계정으로 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영자는 대부분 강한 권한을 갖고 있어서 실제 노출 상태를 놓치기 쉽습니다.&lt;br /&gt;&lt;br /&gt;가능하면 테스트용 일반 계정으로 직접 들어가 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 아래 채널은 꼭 봅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공지 채널&lt;/li&gt;
&lt;li&gt;잡담 채널&lt;/li&gt;
&lt;li&gt;봇 명령어 채널&lt;/li&gt;
&lt;li&gt;운영 전용 채널&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 네 군데만 봐도 권한 구조가 맞는지 감이 잡힙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 자주 나오는 실수&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@everyone&lt;/code&gt;을 막아 놓고 필요한 역할을 다시 안 푼 경우&lt;/li&gt;
&lt;li&gt;카테고리 권한을 수정했는데 하위 채널 예외가 남아 있는 경우&lt;/li&gt;
&lt;li&gt;인증 역할을 줬다고 생각했는데 실제로는 지급되지 않은 경우&lt;/li&gt;
&lt;li&gt;봇 역할은 보이는데 일반 멤버 역할은 막혀 있는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 인증 서버에서는 세 번째 문제가 자주 나옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 빠른 점검 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 순서대로 보면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 문제가 있는 유저가 어떤 역할을 갖고 있는지 확인
2. 해당 역할의 View Channel 권한 확인
3. 상위 카테고리 권한 확인
4. 채널 개별 덮어쓰기 확인
5. 테스트 계정으로 실제 노출 확인&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;한 번에 다 건드리기보다 한 단계씩 봐야 덜 꼬입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 봇도 채널이 안 보일 수 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저만 아니라 봇도 채널을 못 볼 수 있습니다.&lt;br /&gt;&lt;br /&gt;로그 채널, 공지 채널, 관리자 채널에서 봇이 조용하면 봇 역할의 &lt;code&gt;View Channel&lt;/code&gt;이 빠진 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널이 안 보이는 문제는 유저와 봇을 따로 확인해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 채널이 안 보이는 문제와 작업이 실패하는 문제를 분리한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널이 아예 안 보인다면 이 글처럼 가시성 구조를 계속 보는 게 맞습니다.&lt;br /&gt;&lt;br /&gt;채널은 보이는데 관리자 채널과 공지 채널 권한 설계가 더 헷갈린다면 &lt;a href=&quot;https://blog.dishost.kr/57&quot;&gt;디스코드 채널 권한 설정법, 공지 채널과 관리자 채널이 꼬이지 않게 만드는 법&lt;/a&gt;과 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 같이 보고, 슬래시 명령어 실행 권한처럼 다른 층위 문제는 &lt;a href=&quot;https://blog.dishost.kr/65&quot;&gt;디스코드 슬래시 명령어 권한 설정법&lt;/a&gt;으로 이어서 확인하면 빠릅니다.&lt;/p&gt;</description>
      <category>디스코드 봇 오류 해결</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/97</guid>
      <comments>https://dishost.tistory.com/97#entry97comment</comments>
      <pubDate>Thu, 2 Apr 2026 18:31:43 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 봇 슬래시 커맨드가 안 보일 때 해결법</title>
      <link>https://dishost.tistory.com/66</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/핑&lt;/code&gt;까지 다 만들었는데 채팅창에 명령어 목록이 아예 안 뜨면, 그때는 권한보다 등록과 초대 흐름을 먼저 의심해야 합니다.&lt;br /&gt;&lt;br /&gt;이 상태에서 코드를 계속 뜯어봐도 허탕인 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;지금 글은 슬래시 명령어가 &quot;실행 실패&quot;가 아니라 &quot;목록에 아예 안 보이는&quot; 상황만 빠르게 좁히는 글입니다.&lt;br /&gt;&lt;br /&gt;기본 슬래시 명령어 구조를 아직 안 만들었다면 &lt;a href=&quot;https://blog.dishost.kr/61&quot;&gt;슬래시 명령어 디스코드 봇 만드는 법, 파이썬 discord.py app_commands 입문&lt;/a&gt;부터 먼저 맞춰 둬야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 목록에 안 뜨면 등록 경로부터 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 확인할 항목입니다.&lt;br /&gt;&lt;br /&gt;이 스코프가 빠지면 서버에 봇은 들어와 있어도 슬래시 명령어가 등록되지 않거나 보이지 않을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대 링크에서 아래 둘을 같이 체크합니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;bot
applications.commands&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;초대 링크를 잘못 만들었다면 새 링크로 다시 승인하면 빠릅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 서버 단위 sync인지 글로벌 sync인지 구분한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문 단계에서 가장 많이 헷갈리는 부분입니다.&lt;br /&gt;&lt;br /&gt;서버 단위 sync는 반영이 빠릅니다.&lt;br /&gt;&lt;br /&gt;글로벌 sync는 시간이 꽤 걸릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 단계라면 보통 아래처럼 서버 ID를 넣고 동기화합니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;guild = discord.Object(id=GUILD_ID)

@client.event
async def on_ready():
    await tree.sync(guild=guild)
    print(&quot;sync 완료&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;코드는 맞는데 안 뜨는 경우, 실제로는 글로벌 반영을 기다리느라 시간을 버리는 사례가 많습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 봇을 재초대할 필요가 있는 경우도 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에 &lt;code&gt;bot&lt;/code&gt; 스코프만으로 초대해 둔 봇이라면 이후 코드만 바꿔도 명령어가 안 뜰 수 있습니다.&lt;br /&gt;&lt;br /&gt;이때는 새 권한과 스코프로 다시 초대해야 빠릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 봇을 그냥 두고 코드만 다시 실행해도 해결되지 않는 경우가 여기서 자주 나옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. GUILD_ID가 실제 테스트 서버와 맞는지 확인한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 단위 sync를 쓰면서 다른 서버 ID를 넣어 두는 실수가 흔합니다.&lt;br /&gt;&lt;br /&gt;특히 테스트 서버와 운영 서버를 둘 다 갖고 있으면 자주 꼬입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Developer Mode를 켠 뒤 서버 ID를 다시 복사해서 &lt;code&gt;.env&lt;/code&gt;와 비교합니다.&lt;br /&gt;&lt;br /&gt;한 자리만 달라도 명령어는 엉뚱한 서버에 등록됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. on_ready는 떴는데 sync 로그가 없는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;on_ready&lt;/code&gt;는 찍히는데 실제 sync가 실패하는 경우도 있습니다.&lt;br /&gt;&lt;br /&gt;이럴 때는 sync 직후 로그를 명확히 남겨 둬야 합니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@client.event
async def on_ready():
    synced = await tree.sync(guild=guild)
    print(f&quot;동기화된 명령어 수: {len(synced)}&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;로그가 없으면 어디서 끊겼는지 파악이 늦어집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 코드 파일은 바꿨는데 실행 중인 프로세스는 예전 것일 수 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VS Code에서 코드를 수정했어도 터미널에 떠 있는 봇 프로세스를 재시작하지 않으면 예전 코드가 계속 도는 경우가 있습니다.&lt;br /&gt;&lt;br /&gt;슬래시 명령어 쪽은 이 실수가 꽤 자주 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널을 끄고 다시 실행해서 sync 로그를 다시 봐야 가장 확실합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 체크 순서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 순서대로 보면 웬만한 경우는 금방 잡힙니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. applications.commands 스코프 확인
2. GUILD_ID 확인
3. tree.sync() 호출 여부 확인
4. 봇 재시작
5. 새 초대 링크로 재승인
6. 글로벌 sync인지 서버 sync인지 확인&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음에는 보통 1번, 2번, 3번에서 끝납니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 권한 문제와 등록 문제를 섞지 말 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령어가 아예 안 보이는 것은 등록 문제 쪽입니다.&lt;br /&gt;&lt;br /&gt;명령어는 보이는데 실행이 실패하는 것은 권한 문제일 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘을 섞어 보면 계속 엉뚱한 곳만 고치게 됩니다.&lt;br /&gt;&lt;br /&gt;실행 권한까지 같이 정리하려면 &lt;a href=&quot;https://blog.dishost.kr/65&quot;&gt;디스코드 슬래시 명령어 권한 설정법&lt;/a&gt;을 이어서 보면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 안 보이는 문제와 보이는데 실패하는 문제를 분리한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령어 목록이 아예 안 뜬다면 이 글처럼 등록 경로를 끝까지 보는 게 맞습니다.&lt;br /&gt;&lt;br /&gt;초대 링크, 기본 구조, 권한 설계는 &lt;a href=&quot;https://blog.dishost.kr/59&quot;&gt;디스코드 봇 초대 링크 생성법, 권한이 꼬이지 않게 링크 만드는 방법&lt;/a&gt;, &lt;a href=&quot;https://blog.dishost.kr/61&quot;&gt;슬래시 명령어 디스코드 봇 만드는 법, 파이썬 discord.py app_commands 입문&lt;/a&gt;, &lt;a href=&quot;https://blog.dishost.kr/65&quot;&gt;디스코드 슬래시 명령어 권한 설정법&lt;/a&gt;을 순서대로 보면 끊기지 않습니다.&lt;/p&gt;</description>
      <category>디스코드 봇 오류 해결</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/66</guid>
      <comments>https://dishost.tistory.com/66#entry66comment</comments>
      <pubDate>Wed, 1 Apr 2026 16:29:41 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 슬래시 명령어 권한 설정법</title>
      <link>https://dishost.tistory.com/65</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/청소&lt;/code&gt;는 운영진에게 보이는데 일반 멤버는 안 보이거나, 반대로 보여서는 안 될 명령어가 아무에게나 열려 있으면 그 순간부터 권한 설계가 무너진 겁니다.&lt;br /&gt;&lt;br /&gt;슬래시 명령어 권한 문제는 &quot;명령어가 있냐 없냐&quot;보다 &quot;누구에게 보여 주고 누가 실행할 수 있느냐&quot;를 분리해서 봐야 빨리 잡힙니다.&lt;br /&gt;&lt;br /&gt;이 글은 슬래시 명령어가 이미 등록된 상태에서, 노출 대상과 실행 대상을 어떻게 통제할지 정리하는 글입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 슬래시 명령어 봇부터 먼저 맞춰 두었다면 &lt;a href=&quot;https://blog.dishost.kr/61&quot;&gt;슬래시 명령어 디스코드 봇 만드는 법, 파이썬 discord.py app_commands 입문&lt;/a&gt; 흐름 위에서 바로 정리하면 됩니다.&lt;br /&gt;&lt;br /&gt;봇 역할 구조 자체가 불안하면 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법, 50013과 50001을 막는 기본 구조 정리&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 같이 봐야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 보이는데 누구는 되고 누구는 안 되는 이유부터 가른다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 명령어는 채널에서 입력만 되면 비교적 단순합니다.&lt;br /&gt;&lt;br /&gt;슬래시 명령어는 등록 상태, 노출 범위, 실행 권한을 따로 신경 써야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 아래 네 가지가 같이 작동합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;명령어가 서버에 정상 등록되었는가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;applications.commands&lt;/code&gt; 스코프로 초대되었는가&lt;/li&gt;
&lt;li&gt;봇과 유저가 해당 채널에서 필요한 권한을 갖는가&lt;/li&gt;
&lt;li&gt;명령어에 별도 권한 제한을 걸어 두지 않았는가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 초대 링크부터 다시 확인한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 명령어는 초대 링크에 &lt;code&gt;applications.commands&lt;/code&gt;가 빠지면 아예 출발부터 어긋납니다.&lt;br /&gt;&lt;br /&gt;봇은 서버에 들어와 있는데 명령어가 안 뜨는 경우가 여기서 많이 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대 링크 체크 조합은 보통 아래처럼 둡니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;bot
applications.commands&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 부분이 익숙하지 않다면 [디스코드 봇 초대 링크 생성법, 권한이 꼬이지 않게 링크 만드는 방법](https://blog.dishost.kr/59)을 먼저 맞춰 두면 빠릅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 관리자만 쓰게 할 명령어는 코드에서 막는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;/청소&lt;/code&gt;, &lt;code&gt;/밴&lt;/code&gt;, &lt;code&gt;/역할지급&lt;/code&gt; 같은 명령어는 아무나 쓰면 안 됩니다.&lt;br /&gt;&lt;br /&gt;이럴 때는 코드 쪽에서 권한 조건을 넣습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;discord.py&lt;/code&gt; 예시는 아래처럼 둘 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import discord
from discord import app_commands

@app_commands.default_permissions(manage_messages=True)
@tree.command(name=&quot;청소&quot;, description=&quot;최근 메시지를 정리합니다.&quot;)
async def clear(interaction: discord.Interaction):
    await interaction.response.send_message(&quot;권한 확인 완료&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`default_permissions`는 이 명령어를 기본적으로 어떤 권한 보유자에게 노출할지 정하는 용도입니다.&lt;br /&gt;메시지 관리 기능이면 `manage_messages=True`처럼 실제 목적에 맞는 권한을 거는 방식이 자연스럽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 채널 권한과 역할 순서는 별개로 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령어가 보여도 실행이 실패할 수 있습니다.&lt;br /&gt;&lt;br /&gt;예를 들어 &lt;code&gt;/청소&lt;/code&gt;가 보여도 봇에게 &lt;code&gt;Manage Messages&lt;/code&gt;가 없거나 채널에서 메시지 관리가 막혀 있으면 동작하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 지급 계열은 더 자주 막힙니다.&lt;br /&gt;&lt;br /&gt;봇 역할이 대상 역할보다 아래에 있으면 슬래시 명령어가 떠도 실제 지급은 실패합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 서버 전체 공개보다 채널별 운영이 더 중요할 때가 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 정보 조회 명령어는 대부분 공개해도 괜찮습니다.&lt;br /&gt;&lt;br /&gt;반대로 관리 명령어는 운영 채널이나 관리자 채널에서만 쓰게 둬야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때는 채널 권한을 같이 씁니다.&lt;br /&gt;&lt;br /&gt;예를 들어 봇 명령어 채널에서는 일반 명령어를 열고, 관리자 채널에서는 &lt;code&gt;/청소&lt;/code&gt;, &lt;code&gt;/경고&lt;/code&gt;, &lt;code&gt;/역할지급&lt;/code&gt;을 테스트하는 구조가 깔끔합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 관리자 권한으로 전부 덮는 방식은 오래 못 간다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 귀찮아서 봇에도 &lt;code&gt;Administrator&lt;/code&gt;, 운영진에게도 &lt;code&gt;Administrator&lt;/code&gt;를 주는 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;당장은 편하지만 어디가 실제로 막힌 것인지 추적이 어려워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 명령어 권한 문제는 최소 권한 구조에서 확인해야 빠릅니다.&lt;br /&gt;&lt;br /&gt;필요 기능만 정확히 열어 두면 나중에 문제를 좁히기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 빠르게 점검하는 체크 포인트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 항목을 순서대로 보면 대부분 정리됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 초대 링크에 applications.commands가 들어갔는가
2. tree.sync() 또는 명령어 등록이 정상 완료됐는가
3. 명령어 자체에 default_permissions 제한이 걸려 있는가
4. 유저에게 해당 권한이 실제로 있는가
5. 봇이 채널에서 필요한 작업 권한을 갖는가
6. 역할 지급 계열이면 봇 역할이 더 위에 있는가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 순서를 건너뛰면 자꾸 코드만 다시 보게 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 이런 명령어는 애초에 분리해 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 유저용 명령어와 운영진용 명령어를 한 파일에 뒤섞으면 관리가 어려워집니다.&lt;br /&gt;&lt;br /&gt;초기부터라도 조회형, 운영형, 관리자형 정도로 나눠 두면 권한 설계가 훨씬 깔끔해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 명령어는 UI가 깔끔한 대신, 어떤 사람에게 어떤 명령어를 보여 줄지 설계가 더 중요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 권한 문제는 등록 구조와 역할표를 같이 봐야 풀린다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령어가 특정 사람에게만 안 보인다면 이 글처럼 권한 노출 설계를 계속 보면 됩니다.&lt;br /&gt;&lt;br /&gt;명령어 등록 구조, 권한표, 역할 순서는 &lt;a href=&quot;https://blog.dishost.kr/61&quot;&gt;슬래시 명령어 디스코드 봇 만드는 법, 파이썬 discord.py app_commands 입문&lt;/a&gt;, &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법, 50013과 50001을 막는 기본 구조 정리&lt;/a&gt;, &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법&lt;/a&gt;을 차례로 보면 한 번에 정리됩니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/65</guid>
      <comments>https://dishost.tistory.com/65#entry65comment</comments>
      <pubDate>Tue, 31 Mar 2026 16:28:55 +0900</pubDate>
    </item>
    <item>
      <title>discord.js ChatGPT 디스코드 챗봇 만들기</title>
      <link>https://dishost.tistory.com/64</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;JS로 기본 디스코드 봇을 만든 뒤 가장 많이 붙여 보는 기능이 AI 응답입니다.&lt;br /&gt;&lt;br /&gt;질문을 받아 대답하는 구조 하나만 있어도 봇 체감이 크게 달라집니다.&lt;br /&gt;&lt;br /&gt;특히 운영 서버, 스터디 서버, 문의 서버에서는 자동 응답 봇 수요가 분명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘션 한 번에 답하는 최소 구조부터 붙여 두면 JS 봇 활용 범위가 바로 넓어집니다.&lt;br /&gt;&lt;br /&gt;기본 JS 봇 구조가 아직 없다면 &lt;a href=&quot;https://blog.dishost.kr/63&quot;&gt;discord.js v14 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;부터 먼저 맞춰 둬야 합니다.&lt;br /&gt;&lt;br /&gt;토큰과 키를 분리하는 방식은 &lt;a href=&quot;https://blog.dishost.kr/60&quot;&gt;파이썬 .env 환경변수로 디스코드 토큰 숨기기, 하드코딩 없이 안전하게 관리하는 법&lt;/a&gt;과 같은 원리입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. OpenAI API 키 발급&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI 플랫폼에 로그인한 뒤 &lt;code&gt;API keys&lt;/code&gt; 메뉴로 들어갑니다.&lt;br /&gt;&lt;br /&gt;새 비밀 키를 만들고 복사해 둡니다.&lt;br /&gt;&lt;br /&gt;이 키는 과금과 직접 연결되는 비밀번호입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메신저 대화창이나 메모 앱에 오래 남겨 두면 안 됩니다.&lt;br /&gt;&lt;br /&gt;노출이 의심되면 바로 폐기하고 새 키를 발급합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 패키지 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 JS 봇 프로젝트 폴더에서 아래 패키지를 설치합니다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install openai dotenv discord.js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`discord.js`와 `dotenv`가 이미 있다면 추가로 설치할 것은 `openai` 정도입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. .env 구성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루트의 &lt;code&gt;.env&lt;/code&gt; 파일을 아래처럼 맞춥니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DISCORD_TOKEN=디스코드_봇_토큰
OPENAI_API_KEY=sk-오픈에이아이_비밀키&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;OpenAI 키를 코드에 직접 적는 방식은 피합니다.&lt;br /&gt;저장소에 올라가면 비용 문제가 바로 생길 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. main.js 전체 코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 봇이 멘션된 경우에만 OpenAI로 질문을 보내고 답장을 돌려줍니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;const { Client, GatewayIntentBits } = require(&quot;discord.js&quot;);
const { OpenAI } = require(&quot;openai&quot;);
const dotenv = require(&quot;dotenv&quot;);

dotenv.config();

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
});

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

client.once(&quot;ready&quot;, () =&amp;gt; {
  console.log(`로그인 성공: ${client.user.tag}`);
});

client.on(&quot;messageCreate&quot;, async (message) =&amp;gt; {
  if (message.author.bot) {
    return;
  }

  if (!message.mentions.has(client.user)) {
    return;
  }

  const question = message.content.replace(`&amp;lt;@${client.user.id}&amp;gt;`, &quot;&quot;).trim();

  if (!question) {
    await message.reply(&quot;질문 내용을 같이 보내 주세요.&quot;);
    return;
  }

  try {
    await message.channel.sendTyping();

    const completion = await openai.chat.completions.create({
      model: &quot;gpt-4o-mini&quot;,
      messages: [
        {
          role: &quot;system&quot;,
          content: &quot;너는 디스코드 서버에서 질문에 답하는 운영 보조 챗봇이다. 짧고 명확하게 답한다.&quot;,
        },
        {
          role: &quot;user&quot;,
          content: question,
        },
      ],
    });

    const answer = completion.choices[0]?.message?.content || &quot;답변을 생성하지 못했습니다.&quot;;
    await message.reply(answer);
  } catch (error) {
    console.error(error);
    await message.reply(&quot;OpenAI 요청 처리 중 오류가 발생했습니다.&quot;);
  }
});

if (!process.env.DISCORD_TOKEN) {
  throw new Error(&quot;DISCORD_TOKEN 값이 없습니다.&quot;);
}

if (!process.env.OPENAI_API_KEY) {
  throw new Error(&quot;OPENAI_API_KEY 값이 없습니다.&quot;);
}

client.login(process.env.DISCORD_TOKEN);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;질문을 멘션 기반으로 받는 이유는 채널 전체 대화를 전부 AI로 보내는 실수를 줄이기 위해서입니다.&lt;br /&gt;초반에는 트리거를 좁게 둬야 비용과 관리 양쪽에서 안전합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 테스트 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 아래 명령어를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;node main.js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;서버에서 봇을 멘션하고 질문합니다.&lt;/p&gt;
&lt;pre class=&quot;gherkin&quot;&gt;&lt;code&gt;@내봇 오늘 공지 문구 한 줄 추천해줘
@내봇 이 서버 규칙을 짧게 요약해줘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;타이핑 표시 뒤에 답변이 오면 연결이 된 상태입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. Message Content Intent도 같이 확인한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘션 기반이라도 메시지 본문 자체를 읽어야 하므로 &lt;code&gt;Message Content Intent&lt;/code&gt;가 필요합니다.&lt;br /&gt;&lt;br /&gt;Portal과 코드 양쪽이 둘 다 맞아야 합니다.&lt;br /&gt;&lt;br /&gt;여기서 자주 멈추면 &lt;a href=&quot;https://blog.dishost.kr/56&quot;&gt;Message Content Intent 설정법, 디스코드 봇이 명령어를 읽지 못할 때 먼저 볼 것&lt;/a&gt;을 같이 보면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 초반에 자주 생기는 문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;401&lt;/code&gt;이나 인증 오류: OpenAI 키가 잘못 들어갔거나 폐기된 경우가 많습니다.&lt;/li&gt;
&lt;li&gt;봇이 멘션에 무반응: Intent 설정, 봇 재시작 누락, 멘션 문자열 처리 문제를 같이 봅니다.&lt;/li&gt;
&lt;li&gt;답변이 너무 길어 실패하는 경우: 디스코드 메시지 길이 제한을 넘길 수 있으니 필요하면 잘라서 보내는 처리도 넣어 둡니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 봇은 코드보다 운영 가드가 더 중요할 때가 많습니다.&lt;br /&gt;&lt;br /&gt;누가 언제 어떤 채널에서 쓰게 할지 먼저 정해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. system 프롬프트는 운영 성격에 맞춰 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;system&lt;/code&gt; 메시지 한 줄만 바꿔도 응답 성격이 달라집니다.&lt;br /&gt;&lt;br /&gt;문의 응대, 스터디 보조, 공지 초안 작성처럼 용도에 따라 문체와 범위를 제한해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무조건 친절하게만 두기보다, 답변 길이와 말투를 제한하는 문구를 같이 넣으면 채널이 덜 지저분합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JS AI 봇이 켜졌다면 이후에는 슬래시 명령어형으로 옮기거나 FAQ 전용 답변 흐름으로 좁히는 쪽이 자연스럽습니다.&lt;br /&gt;&lt;br /&gt;계속 내 컴퓨터에서만 켜 둘 생각이 아니라면 &lt;a href=&quot;https://blog.dishost.kr/47&quot;&gt;24시간 디스코드 봇 무료 호스팅, 디스호스트&lt;/a&gt; 같은 배포 흐름까지 이어서 봐야 합니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.js</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/64</guid>
      <comments>https://dishost.tistory.com/64#entry64comment</comments>
      <pubDate>Mon, 30 Mar 2026 16:27:22 +0900</pubDate>
    </item>
    <item>
      <title>discord.js v14 디스코드 봇 만들기 처음부터 끝까지</title>
      <link>https://dishost.tistory.com/63</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;파이썬 쪽 흐름을 한 번 익힌 뒤에는 Node.js 기반으로도 같은 구조를 만들 수 있어야 선택지가 넓어집니다.&lt;br /&gt;&lt;br /&gt;디스코드 봇 생태계에서는 &lt;code&gt;discord.js&lt;/code&gt; 수요가 여전히 큽니다.&lt;br /&gt;&lt;br /&gt;특히 버튼, 모달, 슬래시 명령어 예제를 찾다 보면 대부분 &lt;code&gt;discord.js v14&lt;/code&gt; 기준으로 만나는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js 쪽에서 가장 먼저 손에 익혀 둘 기본 골격은 &lt;code&gt;discord.js v14&lt;/code&gt;입니다.&lt;br /&gt;&lt;br /&gt;토큰 분리, 패키지 설치, 기본 명령어, 서버 초대, 실행 테스트까지 한 번에 정리합니다.&lt;br /&gt;&lt;br /&gt;파이썬 쪽 흐름과 비교해 보고 싶다면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지, discord.py 입문 가이드&lt;/a&gt;를 같이 열어 두면 구조 차이를 보기에 좋습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Node.js 버전부터 맞춘다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;discord.js v14&lt;/code&gt;는 너무 오래된 Node 버전에서는 문제를 일으킬 수 있습니다.&lt;br /&gt;&lt;br /&gt;처음에는 코드보다 실행 환경에서 더 자주 막힙니다.&lt;br /&gt;&lt;br /&gt;Node 18 이상을 맞춰 둬야 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 아래 명령어로 버전을 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;node -v
npm -v&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Node가 없거나 버전이 너무 낮다면 먼저 설치를 마칩니다.&lt;br /&gt;이 단계가 어긋나면 패키지 설치는 되는데 실행 시점에서 경고가 뜨는 경우가 나옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 프로젝트 폴더 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빈 폴더를 하나 만들고 터미널에서 이동합니다.&lt;br /&gt;&lt;br /&gt;그다음 &lt;code&gt;npm init&lt;/code&gt;으로 기본 프로젝트를 시작합니다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;mkdir my-discordjs-bot
cd my-discordjs-bot
npm init -y
npm install discord.js dotenv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`discord.js`는 디스코드 API 통신용 패키지입니다.&lt;br /&gt;`dotenv`는 토큰을 `.env`에 분리해서 읽어오는 용도입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Developer Portal에서 봇 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 Discord Developer Portal로 들어갑니다.&lt;br /&gt;&lt;br /&gt;새 애플리케이션을 만든 뒤 &lt;code&gt;Bot&lt;/code&gt; 메뉴에서 봇을 추가합니다.&lt;br /&gt;&lt;br /&gt;토큰을 복사할 수 있으면 일단 안전한 곳에 잠깐 보관합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;Message Content Intent&lt;/code&gt;도 같이 켜 둡니다.&lt;br /&gt;&lt;br /&gt;지금 단계에서는 접두사 명령어를 테스트할 예정이라 이 설정이 빠지면 &lt;code&gt;!핑&lt;/code&gt;이 반응하지 않습니다.&lt;br /&gt;&lt;br /&gt;이 문제는 &lt;a href=&quot;https://blog.dishost.kr/56&quot;&gt;Message Content Intent 설정법, 디스코드 봇이 명령어를 읽지 못할 때 먼저 볼 것&lt;/a&gt;에서 더 자세히 다뤘습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. .env 파일 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 루트에 &lt;code&gt;.env&lt;/code&gt; 파일을 만듭니다.&lt;br /&gt;&lt;br /&gt;내용은 아래처럼 두면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DISCORD_TOKEN=여기에_디스코드_봇_토큰
PREFIX=!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;토큰을 코드에 직접 적어 두면 화면 공유나 깃 업로드 때 그대로 노출될 수 있습니다.&lt;br /&gt;파이썬과 마찬가지로 JS 쪽도 처음부터 분리해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. main.js 작성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루트에 &lt;code&gt;main.js&lt;/code&gt; 파일을 만들고 아래 코드를 넣습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;const { Client, GatewayIntentBits, EmbedBuilder } = require(&quot;discord.js&quot;);
const dotenv = require(&quot;dotenv&quot;);

dotenv.config();

const token = process.env.DISCORD_TOKEN;
const prefix = process.env.PREFIX || &quot;!&quot;;

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
});

client.once(&quot;ready&quot;, () =&amp;gt; {
  console.log(`로그인 성공: ${client.user.tag}`);
});

client.on(&quot;messageCreate&quot;, async (message) =&amp;gt; {
  if (message.author.bot) {
    return;
  }

  if (!message.content.startsWith(prefix)) {
    return;
  }

  const args = message.content.slice(prefix.length).trim().split(/\s+/);
  const command = args.shift()?.toLowerCase();

  if (command === &quot;핑&quot;) {
    await message.reply(`퐁, 현재 지연 시간은 ${client.ws.ping}ms 입니다.`);
    return;
  }

  if (command === &quot;도움&quot;) {
    await message.reply([
      &quot;사용 가능한 명령어&quot;,
      `${prefix}핑 - 응답 속도 확인`,
      `${prefix}도움 - 명령어 목록 확인`,
      `${prefix}서버정보 - 현재 서버 정보 확인`,
    ].join(&quot;\n&quot;));
    return;
  }

  if (command === &quot;서버정보&quot;) {
    if (!message.guild) {
      await message.reply(&quot;이 명령어는 서버에서만 사용할 수 있습니다.&quot;);
      return;
    }

    const embed = new EmbedBuilder()
      .setTitle(&quot;서버 정보&quot;)
      .addFields(
        { name: &quot;서버 이름&quot;, value: message.guild.name, inline: false },
        { name: &quot;멤버 수&quot;, value: String(message.guild.memberCount), inline: true },
        { name: &quot;채널 수&quot;, value: String(message.guild.channels.cache.size), inline: true }
      );

    await message.reply({ embeds: [embed] });
  }
});

if (!token) {
  throw new Error(&quot;DISCORD_TOKEN 값이 없습니다.&quot;);
}

client.login(token);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;파이썬의 `commands.Bot`처럼 편하게 명령어를 다루는 구조도 만들 수 있지만, 처음에는 `messageCreate` 이벤트를 직접 보면 흐름 이해에 유리합니다.&lt;br /&gt;메시지가 들어오고, 접두사를 자르고, 명령어를 분기하는 방식이 눈에 바로 들어오기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 서버 초대 링크 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;OAuth2&lt;/code&gt; -&amp;gt; &lt;code&gt;URL Generator&lt;/code&gt;로 들어갑니다.&lt;br /&gt;&lt;br /&gt;&lt;code&gt;bot&lt;/code&gt; 스코프를 고르고, 최소한 아래 권한을 켭니다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;View Channels
Send Messages
Read Message History
Embed Links&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`!핑` 정도만 테스트할 때는 이 정도면 충분합니다.&lt;br /&gt;초대 링크 생성 흐름이 익숙하지 않다면 [디스코드 봇 초대 링크 생성법, 권한이 꼬이지 않게 링크 만드는 방법](https://blog.dishost.kr/59)을 먼저 봐야 빠릅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 실행하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 아래 명령어를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;node main.js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정상 실행되면 `로그인 성공` 로그가 뜹니다.&lt;br /&gt;그다음 서버 채널에서 아래 명령어를 넣어 봅니다.&lt;/p&gt;
&lt;pre class=&quot;diff&quot;&gt;&lt;code&gt;!핑
!도움
!서버정보&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;초반 테스트는 봇 명령어 전용 채널에서 해야 합니다.&lt;br /&gt;잡담 채널에서 바로 실험하면 로그가 섞여서 확인이 느려집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 여기서 자주 막히는 문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;messageCreate&lt;/code&gt;가 아예 안 들어오는 경우: Developer Portal의 &lt;code&gt;Message Content Intent&lt;/code&gt; 누락이 가장 흔합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Used disallowed intents&lt;/code&gt;나 관련 경고가 뜨는 경우: 코드에서는 &lt;code&gt;MessageContent&lt;/code&gt;를 켰는데 Portal에서는 저장하지 않은 경우가 많습니다.&lt;/li&gt;
&lt;li&gt;토큰 오류가 나는 경우: &lt;code&gt;.env&lt;/code&gt; 공백, 잘못 복사한 토큰, 재발급 후 예전 값 사용을 먼저 봅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JS 쪽은 코드 문법보다 환경값과 인텐트에서 더 자주 멈춥니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 JS 봇이 켜졌다면 다음에는 슬래시 명령어 구조로 넘어가거나 AI 기능을 붙이면 됩니다.&lt;br /&gt;&lt;br /&gt;지금 순서에서는 먼저 AI 챗봇 확장으로 이어지는 흐름이 자연스럽습니다.&lt;br /&gt;&lt;br /&gt;24시간 운영까지 바로 올릴 계획이라면 &lt;a href=&quot;https://blog.dishost.kr/47&quot;&gt;24시간 디스코드 봇 무료 호스팅, 디스호스트&lt;/a&gt;처럼 별도 서버에 올려 두는 단계가 필요합니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.js</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/63</guid>
      <comments>https://dishost.tistory.com/63#entry63comment</comments>
      <pubDate>Sun, 29 Mar 2026 16:23:08 +0900</pubDate>
    </item>
    <item>
      <title>슬래시 명령어 디스코드 봇 만드는 법, 파이썬 discord.py app_commands 입문</title>
      <link>https://dishost.tistory.com/61</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;discord.py&lt;/code&gt;로 슬래시 명령어를 붙이려는데 &lt;code&gt;/핑&lt;/code&gt;조차 안 뜨거나 &lt;code&gt;app_commands&lt;/code&gt; 구조가 헷갈리면 여기서 많이 막힙니다.&lt;br /&gt;&lt;br /&gt;이 글에서는 &lt;code&gt;discord.py&lt;/code&gt;의 &lt;code&gt;app_commands&lt;/code&gt; 기준으로 &lt;code&gt;/핑&lt;/code&gt;, &lt;code&gt;/서버정보&lt;/code&gt;, &lt;code&gt;/청소&lt;/code&gt; 세 개를 바로 테스트할 수 있는 형태까지 한 번에 정리합니다.&lt;br /&gt;&lt;br /&gt;즉 이 글 하나로 슬래시 명령어 등록, 서버 단위 sync, 기본 권한 체크 흐름까지 바로 잡을 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 먼저 준비할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 파이썬 봇 구조와 토큰 분리는 이미 끝났다고 가정합니다.&lt;br /&gt;&lt;br /&gt;아직 이 단계가 안 됐다면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/60&quot;&gt;.env 환경변수로 디스코드 토큰 숨기기, 하드코딩 없이 안전하게 관리하는 법&lt;/a&gt;부터 먼저 봐야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 초대 링크에서 &lt;code&gt;applications.commands&lt;/code&gt;를 체크한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 명령어는 일반 &lt;code&gt;bot&lt;/code&gt; 스코프만으로는 부족할 수 있습니다.&lt;br /&gt;&lt;br /&gt;초대 링크를 만들 때 &lt;code&gt;applications.commands&lt;/code&gt;도 같이 체크합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 아래 스코프 조합을 씁니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;bot
applications.commands&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 단계가 빠지면 코드가 맞아도 슬래시 명령어가 보이지 않을 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 폴더에서 아래 명령어를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;pip install -U discord.py python-dotenv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;기본 봇을 만든 상태라면 이미 설치되어 있을 가능성이 큽니다.&lt;br /&gt;버전이 애매하면 `-U` 옵션으로 한 번 더 올려야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. &lt;code&gt;.env&lt;/code&gt; 파일 예시&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DISCORD_TOKEN=여기에_봇_토큰
GUILD_ID=테스트할_서버_ID&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음에는 글로벌 명령어보다 특정 서버에만 등록하는 방식이 훨씬 빠릅니다.&lt;br /&gt;`GUILD_ID`를 넣어 두면 명령어 반영 속도가 빠릅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. main.py 전체 코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드를 그대로 넣으면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import os

import discord
from discord import app_commands
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.getenv(&quot;DISCORD_TOKEN&quot;)
GUILD_ID = int(os.getenv(&quot;GUILD_ID&quot;))

intents = discord.Intents.default()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
guild = discord.Object(id=GUILD_ID)


@client.event
async def on_ready():
    await tree.sync(guild=guild)
    print(f&quot;로그인 성공: {client.user}&quot;)
    print(&quot;슬래시 명령어 동기화 완료&quot;)


@tree.command(name=&quot;핑&quot;, description=&quot;봇 응답 속도를 확인합니다.&quot;, guild=guild)
async def ping(interaction: discord.Interaction):
    latency_ms = round(client.latency * 1000)
    await interaction.response.send_message(f&quot;퐁, 현재 지연 시간은 {latency_ms}ms 입니다.&quot;)


@tree.command(name=&quot;서버정보&quot;, description=&quot;현재 서버 정보를 보여줍니다.&quot;, guild=guild)
async def server_info(interaction: discord.Interaction):
    current_guild = interaction.guild

    if current_guild is None:
        await interaction.response.send_message(&quot;이 명령어는 서버에서만 사용할 수 있습니다.&quot;, ephemeral=True)
        return

    embed = discord.Embed(title=&quot;서버 정보&quot;, color=discord.Color.green())
    embed.add_field(name=&quot;서버 이름&quot;, value=current_guild.name, inline=False)
    embed.add_field(name=&quot;멤버 수&quot;, value=str(current_guild.member_count), inline=True)
    embed.add_field(name=&quot;채널 수&quot;, value=str(len(current_guild.channels)), inline=True)

    if current_guild.icon:
        embed.set_thumbnail(url=current_guild.icon.url)

    await interaction.response.send_message(embed=embed)


@tree.command(name=&quot;청소&quot;, description=&quot;최근 메시지를 정리합니다.&quot;, guild=guild)
@app_commands.describe(amount=&quot;삭제할 메시지 개수&quot;)
async def clear_messages(interaction: discord.Interaction, amount: int):
    if not interaction.user.guild_permissions.manage_messages:
        await interaction.response.send_message(
            &quot;메시지 관리 권한이 있는 사용자만 사용할 수 있습니다.&quot;,
            ephemeral=True,
        )
        return

    if amount &amp;lt; 1 or amount &amp;gt; 100:
        await interaction.response.send_message(
            &quot;삭제 개수는 1 이상 100 이하로 입력하세요.&quot;,
            ephemeral=True,
        )
        return

    await interaction.response.defer(ephemeral=True)
    deleted = await interaction.channel.purge(limit=amount)
    await interaction.followup.send(f&quot;메시지 {len(deleted)}개를 삭제했습니다.&quot;, ephemeral=True)


if not TOKEN:
    raise ValueError(&quot;DISCORD_TOKEN 값이 없습니다.&quot;)

client.run(TOKEN)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;슬래시 명령어 구조에서는 `commands.Bot` 대신 `CommandTree`를 중심으로 생각하면 됩니다.&lt;br /&gt;명령어를 등록한 뒤 `tree.sync()`를 호출해야 실제 서버에 반영됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 서버 ID 확인 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 앱에서 &lt;code&gt;사용자 설정&lt;/code&gt; -&amp;gt; &lt;code&gt;고급&lt;/code&gt; -&amp;gt; &lt;code&gt;개발자 모드&lt;/code&gt;를 켭니다.&lt;br /&gt;&lt;br /&gt;그다음 서버 아이콘을 우클릭해서 &lt;code&gt;ID 복사&lt;/code&gt;를 누르면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 글로벌 배포보다 서버 단위 테스트가 훨씬 빠릅니다.&lt;br /&gt;&lt;br /&gt;명령어가 1시간씩 안 보이는 상황을 피하기 좋습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 테스트하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 아래 명령어를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;python main.py&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;디스코드 채팅창에서 `/`를 입력하면 명령어 목록에 `/핑`, `/서버정보`, `/청소`가 보여야 합니다.&lt;br /&gt;안 보이면 아래 구간을 먼저 봅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초대 링크에 &lt;code&gt;applications.commands&lt;/code&gt;가 들어갔는가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tree.sync(guild=guild)&lt;/code&gt;가 실제로 호출되는가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GUILD_ID&lt;/code&gt;가 맞는가&lt;/li&gt;
&lt;li&gt;봇을 재시작했는가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 자주 막히는 문제&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;명령어가 안 보이는 경우: 서버 ID가 틀렸거나 sync가 안 된 경우가 많습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;This interaction failed&lt;/code&gt;가 뜨는 경우: 응답 시간을 넘겼거나, &lt;code&gt;interaction.response&lt;/code&gt;를 제대로 처리하지 않은 경우가 많습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/청소&lt;/code&gt;가 실패하는 경우: 사용자 권한이나 봇 권한 둘 중 하나가 부족할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 명령어 쪽은 코드보다 sync와 권한에서 많이 막힙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 메시지 명령어와 같이 써도 된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 완전히 갈아탈 필요는 없습니다.&lt;br /&gt;&lt;br /&gt;메시지 명령어 봇을 먼저 만든 뒤 슬래시 명령어를 일부 기능부터 옮겨 가는 방식도 흔합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입문 단계에서는 이 흐름이 더 편합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 명령어 기본형이 붙었다면 다음에는 초대 링크와 토큰 관리 쪽을 같이 맞춰야 막힘이 적습니다.&lt;br /&gt;&lt;br /&gt;바로 이어서 볼 글은 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법&lt;/a&gt;, &lt;a href=&quot;https://blog.dishost.kr/59&quot;&gt;디스코드 봇 초대 링크 생성법, 권한이 꼬이지 않게 링크 만드는 방법&lt;/a&gt;, &lt;a href=&quot;https://blog.dishost.kr/60&quot;&gt;.env 환경변수로 디스코드 토큰 숨기기, 하드코딩 없이 안전하게 관리하는 법&lt;/a&gt;입니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/61</guid>
      <comments>https://dishost.tistory.com/61#entry61comment</comments>
      <pubDate>Sat, 28 Mar 2026 16:19:12 +0900</pubDate>
    </item>
    <item>
      <title>파이썬 .env 환경변수로 디스코드 토큰 숨기기, 하드코딩 없이 안전하게 관리하는 법</title>
      <link>https://dishost.tistory.com/60</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 봇을 처음 만들 때 토큰을 코드에 그대로 적는 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;혼자 테스트할 때는 당장 편해 보이지만, 깃허브 업로드나 화면 공유 한 번으로 바로 사고가 날 수 있습니다.&lt;br /&gt;&lt;br /&gt;토큰은 봇의 통제권을 가진 비밀번호라서 처음부터 분리해야 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파이썬에서는 &lt;code&gt;.env&lt;/code&gt; 파일과 &lt;code&gt;python-dotenv&lt;/code&gt; 조합이 가장 단순합니다.&lt;br /&gt;&lt;br /&gt;초반 세팅도 빠르고, 이후 배포 단계까지 그대로 가져가기 좋습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 왜 &lt;code&gt;.env&lt;/code&gt;를 쓰는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에 토큰을 직접 적는 방식은 아래 문제가 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;깃허브에 실수로 업로드될 수 있음&lt;/li&gt;
&lt;li&gt;여러 환경에서 토큰을 바꾸기 번거로움&lt;/li&gt;
&lt;li&gt;화면 공유나 스크린샷에서 노출될 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 &lt;code&gt;.env&lt;/code&gt; 파일로 분리하면 코드와 비밀값을 따로 관리할 수 있습니다.&lt;br /&gt;&lt;br /&gt;입문 단계에서 가장 먼저 익혀 두면 좋은 습관입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 패키지 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 폴더에서 아래 명령어를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;pip install python-dotenv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;기본 봇을 이미 만들었다면 `discord.py`와 같이 설치했을 가능성이 큽니다.&lt;br /&gt;없다면 지금 같이 넣으면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;code&gt;.env&lt;/code&gt; 파일 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 폴더 루트에 &lt;code&gt;.env&lt;/code&gt; 파일을 만듭니다.&lt;br /&gt;&lt;br /&gt;내용은 아래처럼 잡으면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DISCORD_TOKEN=여기에_디스코드_봇_토큰
PREFIX=!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`DISCORD_TOKEN`에는 Developer Portal에서 복사한 실제 토큰 값을 넣습니다.&lt;br /&gt;큰따옴표는 굳이 넣지 않아도 됩니다.&lt;br /&gt;앞뒤 공백도 없어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 파이썬 코드에서 읽는 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;main.py&lt;/code&gt;에서는 아래처럼 불러오면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import os
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.getenv(&quot;DISCORD_TOKEN&quot;)
PREFIX = os.getenv(&quot;PREFIX&quot;, &quot;!&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;핵심은 `load_dotenv()`입니다.&lt;br /&gt;이 함수가 `.env` 파일을 읽고 환경변수로 올려 줍니다.&lt;br /&gt;그 뒤 `os.getenv()`로 값을 꺼내 쓰면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 실제 봇 코드에 붙이는 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 정도로 연결하면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import os

from dotenv import load_dotenv
from discord.ext import commands
import discord

load_dotenv()

TOKEN = os.getenv(&quot;DISCORD_TOKEN&quot;)
PREFIX = os.getenv(&quot;PREFIX&quot;, &quot;!&quot;)

intents = discord.Intents.default()
intents.message_content = True

bot = commands.Bot(command_prefix=PREFIX, intents=intents)

if not TOKEN:
    raise ValueError(&quot;DISCORD_TOKEN 값이 없습니다.&quot;)

bot.run(TOKEN)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;토큰 값이 없으면 바로 에러를 내도록 둬야 합니다.&lt;br /&gt;그냥 `None` 상태로 실행하면 어디서 잘못됐는지 찾기 어려워집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. &lt;code&gt;.env&lt;/code&gt;를 썼는데도 로그인 실패가 날 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래를 먼저 봅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰 앞뒤에 공백이 붙었는가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.env&lt;/code&gt; 파일 이름이 정확한가&lt;/li&gt;
&lt;li&gt;프로젝트 루트에 파일이 있는가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;load_dotenv()&lt;/code&gt;를 실제로 호출했는가&lt;/li&gt;
&lt;li&gt;토큰을 재발급한 뒤 예전 값을 그대로 두지 않았는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Improper token has been passed&lt;/code&gt;가 뜨면 거의 이 구간에서 문제를 찾을 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. &lt;code&gt;.gitignore&lt;/code&gt;도 같이 넣어 둬야 안전하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.env&lt;/code&gt;를 분리해도 깃 추적에서 빼지 않으면 결국 유출될 수 있습니다.&lt;br /&gt;&lt;br /&gt;프로젝트 루트에 &lt;code&gt;.gitignore&lt;/code&gt;가 있다면 아래 줄을 넣습니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;.env
.venv
__pycache__/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`.env`를 분리해 두고도 깃허브에 올려 버리는 경우가 많습니다.&lt;br /&gt;여기서 한 번 더 막아 둬야 맞습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 운영용 토큰과 테스트용 토큰을 나눌 수도 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇을 계속 키울 계획이라면 테스트 봇과 운영 봇을 따로 두는 경우도 많습니다.&lt;br /&gt;&lt;br /&gt;그때도 &lt;code&gt;.env&lt;/code&gt; 구조를 유지하면 관리가 편합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래처럼 변수를 분리할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DISCORD_TOKEN_DEV=개발용_토큰
DISCORD_TOKEN_PROD=운영용_토큰&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;입문 단계에서는 여기까지 갈 필요는 없지만, 구조를 알아 두면 나중에 편합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 지금 단계에서 가장 중요한 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰은 코드에 하드코딩하지 않는 것.&lt;br /&gt;&lt;code&gt;.env&lt;/code&gt; 파일을 깃에서 제외하는 것.&lt;br /&gt;토큰이 노출되면 즉시 재발급하는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 가지만 지켜도 초반 사고 대부분은 막을 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 봇을 아직 안 만들었다면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;부터 먼저 보고 와야 합니다.&lt;br /&gt;&lt;br /&gt;슬래시 명령어 구조로 넘어갈 준비가 됐다면 다음 글에서 바로 연결하면 됩니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/60</guid>
      <comments>https://dishost.tistory.com/60#entry60comment</comments>
      <pubDate>Fri, 27 Mar 2026 16:18:13 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 봇 초대 링크 생성법, 권한이 꼬이지 않게 링크 만드는 방법</title>
      <link>https://dishost.tistory.com/59</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 봇을 만들고도 서버에 못 넣는 경우가 자주 나옵니다.&lt;br /&gt;&lt;br /&gt;봇 코드보다 초대 링크 생성 과정에서 막히는 쪽이 더 흔할 때도 있습니다.&lt;br /&gt;&lt;br /&gt;특히 권한을 너무 적게 고르거나, 아예 잘못된 스코프를 선택해서 다시 초대하는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대 링크는 한 번만 대충 만들고 끝내는 작업이 아닙니다.&lt;br /&gt;&lt;br /&gt;기능이 늘어날수록 필요한 권한도 달라지기 때문에 구조를 이해해 둬야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 초대 링크를 만드는 위치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 &lt;a href=&quot;https://discord.com/developers/applications&quot;&gt;Discord Developer Portal&lt;/a&gt;에 접속합니다.&lt;br /&gt;&lt;br /&gt;봇 애플리케이션을 선택한 뒤 왼쪽 메뉴에서 &lt;code&gt;OAuth2&lt;/code&gt; -&amp;gt; &lt;code&gt;URL Generator&lt;/code&gt;로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 두 가지를 고릅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 스코프를 쓸지&lt;/li&gt;
&lt;li&gt;어떤 권한을 봇에 줄지&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 스코프는 보통 두 개를 본다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 많이 쓰는 것은 아래 두 개입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;bot&lt;/code&gt;: 일반 봇 계정을 서버에 초대할 때 필요합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;applications.commands&lt;/code&gt;: 슬래시 명령어를 사용할 때 필요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 명령어만 쓰는 아주 기초적인 봇이라면 &lt;code&gt;bot&lt;/code&gt;만으로도 시작할 수 있습니다.&lt;br /&gt;&lt;br /&gt;슬래시 명령어를 바로 쓸 계획이라면 둘 다 체크하는 경우가 보통입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 초반에 자주 쓰는 권한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 권한은 대부분의 기본 봇에서 자주 씁니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;View Channels
Send Messages
Read Message History
Embed Links
Attach Files
Manage Messages
Use Slash Commands
Manage Roles&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;권한을 너무 적게 주면 서버에 들어온 뒤 기능이 바로 막힙니다.&lt;br /&gt;반대로 너무 넓게 주면 관리가 불안해집니다.&lt;br /&gt;현재 넣을 기능 기준으로만 줘야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 기본 응답 봇과 관리 봇은 링크를 나눠 생각하면 편하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;!핑&lt;/code&gt;, &lt;code&gt;!도움&lt;/code&gt; 정도만 쓰는 봇이라면 읽기와 전송 권한 위주면 충분합니다.&lt;br /&gt;&lt;br /&gt;메시지 삭제, 역할 지급, 로그 기록까지 들어가면 &lt;code&gt;Manage Messages&lt;/code&gt;, &lt;code&gt;Manage Roles&lt;/code&gt;가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 아래 정도로 생각하면 편합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;응답형 봇: View Channels, Send Messages, Read Message History, Embed Links&lt;/li&gt;
&lt;li&gt;관리형 봇: 응답형 권한 + Manage Messages + Manage Roles + Use Slash Commands&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 실제로 생성되는 링크 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대 링크는 대략 이런 형태입니다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;https://discord.com/oauth2/authorize?client_id=애플리케이션_ID&amp;amp;scope=bot%20applications.commands&amp;amp;permissions=권한값&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서 `client_id`는 애플리케이션 ID입니다.&lt;br /&gt;`permissions`는 체크한 권한 조합에 따라 숫자로 자동 계산됩니다.&lt;br /&gt;직접 숫자를 외울 필요는 없습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 초대가 안 되거나 서버 목록이 안 뜰 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 아래 경우가 많습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;해당 서버에서 &lt;code&gt;서버 관리&lt;/code&gt; 권한이 없는 계정으로 시도한 경우&lt;/li&gt;
&lt;li&gt;잘못된 애플리케이션으로 링크를 만든 경우&lt;/li&gt;
&lt;li&gt;브라우저에 다른 디스코드 계정이 로그인되어 있는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대 페이지에서 서버 선택 목록이 비어 있으면 계정 권한부터 확인해야 빠릅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 초대 후에도 기능이 안 될 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에 들어왔다고 끝난 것은 아닙니다.&lt;br /&gt;&lt;br /&gt;권한이 충분하지 않거나 채널에서 막히면 기능은 여전히 안 돌아갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴 때는 아래를 같이 봅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초대 링크에 필요한 권한이 포함되어 있었는가&lt;/li&gt;
&lt;li&gt;봇 역할이 채널에서 차단되지 않았는가&lt;/li&gt;
&lt;li&gt;봇 역할이 대상 역할보다 위에 있는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대는 성공했는데 역할 지급이 안 되면 대부분 세 번째 문제입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 나중에 권한이 바뀌면 어떻게 하나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 기능을 늘리다가 새 권한이 필요해질 수 있습니다.&lt;br /&gt;&lt;br /&gt;그럴 때는 초대 링크를 다시 만들어서 서버에 다시 넣거나 권한을 재승인하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 링크를 너무 대충 만들면 안 되는 이유가 여기 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 운영에서 안전한 습관&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대 링크는 아무 데나 뿌리면 안 됩니다.&lt;br /&gt;&lt;br /&gt;테스트 봇과 운영 봇이 섞이면 엉뚱한 봇을 서버에 넣는 경우도 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 이름, 아이콘, 설명을 구분해 두면 실수를 줄이기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 봇이 이미 있다면 초대 링크를 만들고 바로 서버에서 테스트해 보면 됩니다.&lt;br /&gt;&lt;br /&gt;권한 구조를 아직 정리하지 않았다면 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법&lt;/a&gt;과 &lt;a href=&quot;https://blog.dishost.kr/58&quot;&gt;디스코드 역할 설정 완벽 가이드&lt;/a&gt;를 같이 보면서 맞춰야 안전합니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/59</guid>
      <comments>https://dishost.tistory.com/59#entry59comment</comments>
      <pubDate>Thu, 26 Mar 2026 16:16:36 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 역할 설정 완벽 가이드, 관리자 운영진 멤버 봇 역할을 처음부터 정리하는 법</title>
      <link>https://dishost.tistory.com/58</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 서버에서 권한 문제는 결국 역할 구조로 돌아옵니다.&lt;br /&gt;&lt;br /&gt;역할 이름만 그럴듯하게 만들고 순서와 권한을 대충 두면 나중에 봇도 막히고 운영진도 헷갈립니다.&lt;br /&gt;&lt;br /&gt;초반 역할 설계는 적게, 명확하게, 위계가 보이게 잡아야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반 서버는 화려한 역할보다 설명 가능한 역할 구조가 먼저입니다.&lt;br /&gt;&lt;br /&gt;서버 규모가 커져도 기본 뼈대는 거의 그대로 가져갈 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 역할은 많을수록 좋은 게 아니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 역할을 열 개 넘게 만드는 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;대부분은 멋있어 보이기만 하고 실제 운영에서는 오히려 혼란만 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 역할은 아래 정도면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;관리자
운영진
봇
인증됨
멤버&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 다섯 개만으로도 대부분의 입문 서버는 충분히 굴러갑니다.&lt;br /&gt;중요한 건 숫자가 아니라 역할별 책임이 겹치지 않는 구조입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;code&gt;관리자&lt;/code&gt;와 &lt;code&gt;운영진&lt;/code&gt;은 나눠야 안전하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 운영 인원에게 &lt;code&gt;Administrator&lt;/code&gt; 권한을 주면 관리가 편해 보입니다.&lt;br /&gt;&lt;br /&gt;실제로는 실수 한 번의 영향 범위가 너무 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통은 아래처럼 나눕니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;관리자&lt;/code&gt;:&lt;br /&gt;&lt;br /&gt;전체 설정 변경, 역할 수정, 채널 구조 변경까지 가능한 역할&lt;/li&gt;
&lt;li&gt;&lt;code&gt;운영진&lt;/code&gt;:&lt;br /&gt;&lt;br /&gt;메시지 관리, 제재, 문의 대응처럼 일상 운영에 필요한 권한만 가진 역할&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할을 나누면 누가 어디까지 건드릴 수 있는지가 훨씬 선명해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 봇 역할은 항상 대상 역할보다 위에 둔다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 지급, 닉네임 변경, 채널 관리 같은 작업을 봇에게 시킬 계획이라면 봇 역할 위치가 중요합니다.&lt;br /&gt;&lt;br /&gt;봇 역할이 대상 역할보다 아래에 있으면 기능이 막힙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안전한 기본 구조는 아래와 같습니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;관리자
운영진
봇
인증됨
멤버&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;봇이 `멤버`나 `인증됨` 역할을 주게 만들 계획이라면 이 순서가 편합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 역할 이름보다 더 중요한 것은 역할 목적&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할은 장식이 아니라 기능 단위로 봐야 합니다.&lt;br /&gt;&lt;br /&gt;예를 들면 아래처럼 나눌 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;접근 역할:&lt;br /&gt;&lt;br /&gt;인증됨, 멤버, 신규유저&lt;/li&gt;
&lt;li&gt;운영 역할:&lt;br /&gt;&lt;br /&gt;운영진, 관리자&lt;/li&gt;
&lt;li&gt;기능 역할:&lt;br /&gt;&lt;br /&gt;봇, 알림수신, 이벤트참여&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 목적별로 나누면 어떤 역할을 왜 만들었는지 설명하기 쉬워집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 색상 역할은 초반에는 최소화한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 색상은 서버를 보기 좋게 만들 수 있습니다.&lt;br /&gt;&lt;br /&gt;그런데 색상 역할을 너무 많이 만들면 이름만 다르고 기능은 없는 역할이 늘어납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 운영진 계열 색상만 구분하고, 일반 유저 역할은 기능 중심으로 두면 됩니다.&lt;br /&gt;&lt;br /&gt;유저 꾸미기 역할은 활동량이 어느 정도 붙은 뒤 추가해도 늦지 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 역할 권한은 필요 최소한만 준다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할을 만들 때 가장 위험한 습관은 일단 많이 켜 두는 것입니다.&lt;br /&gt;&lt;br /&gt;초반에는 아래처럼 필요한 것만 주면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;운영진
- Manage Messages
- Timeout Members
- Kick Members

관리자
- Administrator 또는 세부 관리 권한

봇
- Send Messages
- Manage Messages
- Manage Roles
- Read Message History&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;특히 `Administrator` 권한은 정말 필요한 역할에만 줍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 역할 배치 체크리스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할을 만든 뒤에는 아래만 확인해도 큰 사고를 많이 줄일 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;봇 역할이 지급 대상 역할보다 위에 있는가&lt;/li&gt;
&lt;li&gt;운영진 역할에 불필요한 관리자 권한이 들어가 있지 않은가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@everyone&lt;/code&gt; 권한이 너무 넓지 않은가&lt;/li&gt;
&lt;li&gt;신규 유저와 인증된 유저의 차이가 실제로 채널 노출에 반영되는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 화면에서 체크만 하지 말고 실제 일반 계정으로 들어가 직접 확인해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 자주 하는 실수&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;역할 이름만 그럴듯하고 실제 목적이 없는 경우&lt;/li&gt;
&lt;li&gt;봇 역할이 아래에 있어서 역할 지급이 안 되는 경우&lt;/li&gt;
&lt;li&gt;운영진에게 관리자 권한을 너무 넓게 준 경우&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@everyone&lt;/code&gt; 권한을 방치해서 사실상 모든 역할이 무의미한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 서버는 구조보다 운영 속도가 빠르게 바뀝니다.&lt;br /&gt;&lt;br /&gt;그래서 역할은 화려함보다 수정하기 쉬운 구조가 더 중요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 역할 구조가 정리된 뒤 좋은 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 구조가 안정되면 자동 역할, 반응 역할, 인증 봇 같은 기능을 붙이기 쉬워집니다.&lt;br /&gt;&lt;br /&gt;반대로 역할 구조가 엉킨 상태에서는 자동화 기능을 붙일수록 더 헷갈립니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. 역할표를 짰다면 권한 테스트로 끝낸다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할 구조를 정리했다면 봇 초대 링크와 권한을 맞춰서 실제 서버에 투입할 차례입니다.&lt;br /&gt;&lt;br /&gt;기본 봇이 없다면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;부터, 초대 링크 구조를 먼저 정리할 거라면 다음 글 흐름으로 넘어가면 됩니다.&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/58</guid>
      <comments>https://dishost.tistory.com/58#entry58comment</comments>
      <pubDate>Wed, 25 Mar 2026 16:15:57 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 채널 권한 설정법, 공지 채널과 관리자 채널이 꼬이지 않게 만드는 법</title>
      <link>https://dishost.tistory.com/57</link>
      <description>&lt;p&gt;디스코드 서버를 만들고 나면 채널은 금방 늘어납니다.&lt;br&gt;&lt;br&gt;문제는 채널 수보다 권한입니다.&lt;br&gt;&lt;br&gt;공지 채널에 일반 유저가 글을 쓰거나, 관리자 채널이 보이면 서버 구조가 바로 흐트러집니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;채널 권한은 한 번에 복잡하게 잡기보다 기준 패턴 몇 개를 정하면 훨씬 쉽습니다.&lt;br&gt;&lt;br&gt;공지 채널, 관리자 채널, 문의 채널, 봇 명령어 채널을 기준으로 가장 덜 꼬이는 배치를 먼저 잡아 봅니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;1. 채널 권한은 서버 권한 위에 덮어쓴다&lt;/h3&gt;
&lt;p&gt;서버 역할에서 권한을 켜 두어도 채널에서 막으면 접근이 제한됩니다.&lt;br&gt;&lt;br&gt;반대로 서버 역할에서 아예 없는 권한은 채널에서 살릴 수 없습니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;정리하면 아래 순서로 보면 됩니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;서버 역할 권한 설정
-&amp;gt; 채널별 권한 덮어쓰기
-&amp;gt; 역할 순서와 실제 노출 결과 확인&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
채널 권한이 꼬일 때는 채널 설정만 보지 말고 서버 역할 권한도 같이 확인해야 빠릅니다.&lt;br&gt;

&lt;h3&gt;2. 공지 채널은 읽기 전용으로 두는 게 보통&lt;/h3&gt;
&lt;p&gt;공지 채널은 대부분 아래 구조로 잡습니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;@everyone
- View Channel: 허용
- Send Messages: 차단

운영진
- View Channel: 허용
- Send Messages: 허용

봇
- View Channel: 허용
- Send Messages: 허용&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
여기서 봇 권한을 빼먹는 경우가 생각보다 많습니다.&lt;br&gt;
자동 공지나 로그를 보내는 봇이 있다면 봇 역할도 같이 허용해야 합니다.&lt;br&gt;

&lt;h3&gt;3. 관리자 채널은 &lt;code&gt;@everyone&lt;/code&gt;부터 막는다&lt;/h3&gt;
&lt;p&gt;관리자 채널은 특정 역할만 보게 만드는 구조가 핵심입니다.&lt;br&gt;&lt;br&gt;가장 안전한 방식은 &lt;code&gt;@everyone&lt;/code&gt;에서 &lt;code&gt;View Channel&lt;/code&gt;을 끄고, 필요한 역할만 다시 허용하는 방식입니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;예시는 아래처럼 잡습니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;@everyone
- View Channel: 차단

관리자
- View Channel: 허용
- Send Messages: 허용

운영진
- View Channel: 허용
- Send Messages: 허용&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
이 구조면 일반 유저는 채널 존재 자체를 보지 못합니다.&lt;br&gt;
운영 채널은 보이기만 해도 구조가 노출되므로 이 방식이 가장 깔끔합니다.&lt;br&gt;

&lt;h3&gt;4. 문의 채널과 봇 명령어 채널은 목적이 다르다&lt;/h3&gt;
&lt;p&gt;문의 채널은 유저가 글을 쓸 수 있어야 하고, 봇 명령어 채널은 봇 응답이 정상적으로 보여야 합니다.&lt;br&gt;&lt;br&gt;둘을 같은 패턴으로 잡으면 불편해집니다.&lt;br&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;문의 채널:&lt;br&gt;&lt;br&gt;유저 메시지 허용, 운영진 보기 허용, 봇 메시지 허용&lt;br&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;봇 명령어 채널:&lt;br&gt;&lt;br&gt;일반 유저 메시지 허용, 봇 응답 허용, 필요하면 파일 첨부와 링크 임베드 허용&lt;br&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;특히 임베드 메시지를 쓰는 봇은 &lt;code&gt;Embed Links&lt;/code&gt; 권한이 빠지면 메시지가 빈약하게 보일 수 있습니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;5. 카테고리 권한을 먼저 잡고 채널은 예외만 둔다&lt;/h3&gt;
&lt;p&gt;채널마다 권한을 전부 따로 잡기 시작하면 곧 관리가 어려워집니다.&lt;br&gt;&lt;br&gt;카테고리 권한을 먼저 잡고, 채널에서는 정말 필요한 예외만 둬야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;예를 들어 &lt;code&gt;운영&lt;/code&gt; 카테고리를 아래처럼 두면 됩니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;운영 카테고리
@everyone: View Channel 차단
운영진: 허용
관리자: 허용
봇: 허용&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
그 아래 `로그`, `문의관리`, `내부공지` 채널을 만들면 기본값이 깔려 있으니 수정량이 줄어듭니다.&lt;br&gt;

&lt;h3&gt;6. 권한 테스트는 관리자 계정이 아니라 일반 계정으로 한다&lt;/h3&gt;
&lt;p&gt;운영자는 대부분 강한 권한을 갖고 있어서 잘못 설정해도 문제를 놓치기 쉽습니다.&lt;br&gt;&lt;br&gt;가능하면 테스트용 일반 계정이나 친구 계정으로 확인해야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;최소한 아래는 직접 봅니다.&lt;br&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;공지 채널에 일반 멤버가 글을 쓸 수 없는가&lt;br&gt;&lt;/li&gt;
&lt;li&gt;관리자 채널이 일반 멤버에게 아예 안 보이는가&lt;/li&gt;
&lt;li&gt;문의 채널과 봇 명령어 채널은 정상적으로 보이는가&lt;/li&gt;
&lt;li&gt;봇이 공지 채널이나 로그 채널에 메시지를 보낼 수 있는가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;7. 자주 생기는 문제&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;채널은 보이는데 메시지를 못 쓰는 경우:&lt;br&gt;&lt;br&gt;&lt;code&gt;Send Messages&lt;/code&gt;가 꺼져 있는 상태입니다.&lt;br&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;채널 자체가 안 보이는 경우:&lt;br&gt;&lt;br&gt;&lt;code&gt;View Channel&lt;/code&gt;이 막혀 있거나 상위 카테고리 권한이 덮여 있을 가능성이 큽니다.&lt;br&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;봇만 특정 채널에서 반응이 없는 경우:&lt;br&gt;&lt;br&gt;봇 역할 채널 권한이 빠졌거나, 봇 초대 링크 권한이 처음부터 부족했을 가능성이 큽니다.&lt;br&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;8. 권한을 너무 세밀하게 쪼개지 말 것&lt;/h3&gt;
&lt;p&gt;초반 서버에서 역할과 채널 권한을 지나치게 세밀하게 나누면 운영자도 헷갈립니다.&lt;br&gt;&lt;br&gt;처음에는 공지, 커뮤니티, 운영 정도만 확실히 나누고, 실제 활동이 쌓일 때 세분화해야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;권한은 정교함보다 예측 가능성이 중요합니다.&lt;br&gt;&lt;br&gt;누가 어디를 볼 수 있는지 운영진이 바로 설명할 수 있어야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;9. 다음 단계&lt;/h3&gt;
&lt;p&gt;채널 권한을 정리했다면 역할 구조를 같이 손봐야 꼬이지 않습니다.&lt;br&gt;&lt;br&gt;역할 이름은 괜찮은데 우선순위와 실제 기능이 뒤죽박죽인 경우가 흔합니다.&lt;br&gt;&lt;br&gt;서버 역할 구조부터 다시 점검하려면 역할 순서와 목적을 먼저 표로 적어 봐야 빠릅니다.&lt;br&gt;&lt;br&gt;기본 봇을 이미 만들어 둔 상태라면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;와 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법&lt;/a&gt;을 같이 열어 두고 채널별로 하나씩 테스트하면 됩니다.&lt;br&gt;&lt;br&gt;이 작업을 해 두면 뒤에서 티켓 봇이나 로그 봇을 붙일 때 훨씬 수월합니다.&lt;br&gt;&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/57</guid>
      <comments>https://dishost.tistory.com/57#entry57comment</comments>
      <pubDate>Tue, 24 Mar 2026 16:15:28 +0900</pubDate>
    </item>
    <item>
      <title>Message Content Intent 설정법, 디스코드 봇이 명령어를 읽지 못할 때 먼저 볼 것</title>
      <link>https://dishost.tistory.com/56</link>
      <description>&lt;p&gt;봇이 온라인으로 보이는데 &lt;code&gt;!핑&lt;/code&gt;에 아무 반응도 없으면 코드부터 의심하게 됩니다.&lt;br&gt;&lt;br&gt;그런데 초반에는 코드보다 &lt;code&gt;Message Content Intent&lt;/code&gt; 설정이 빠진 경우가 훨씬 많습니다.&lt;br&gt;&lt;br&gt;이 옵션 하나 때문에 명령어가 통째로 먹히지 않는 경우가 자주 나옵니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;접두사 명령어 기반 봇이라면 거의 반드시 확인해야 하는 설정입니다.&lt;br&gt;&lt;br&gt;한 번만 구조를 이해해 두면 같은 문제로 다시 멈출 일이 크게 줄어듭니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;1. Message Content Intent가 하는 일&lt;/h3&gt;
&lt;p&gt;봇이 일반 메시지 내용을 읽을 수 있게 허용하는 옵션입니다.&lt;br&gt;&lt;br&gt;예를 들어 &lt;code&gt;!핑&lt;/code&gt;, &lt;code&gt;!청소&lt;/code&gt;, &lt;code&gt;!도움&lt;/code&gt;처럼 채팅창에 직접 입력하는 메시지 명령어는 이 권한이 있어야 읽을 수 있습니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;반대로 슬래시 명령어는 디스코드가 별도 인터랙션 형태로 전달하므로 항상 이 설정이 필요한 것은 아닙니다.&lt;br&gt;&lt;br&gt;그래도 메시지 기반 기능이 하나라도 들어가면 먼저 켜 둬야 안전합니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;2. 어떤 경우에 꼭 필요한가&lt;/h3&gt;
&lt;p&gt;아래 유형이면 거의 필요합니다.&lt;br&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;!핑&lt;/code&gt;, &lt;code&gt;?help&lt;/code&gt; 같은 접두사 명령어 사용&lt;br&gt;&lt;/li&gt;
&lt;li&gt;특정 단어 감지 후 자동 답장&lt;br&gt;&lt;/li&gt;
&lt;li&gt;욕설 필터, 키워드 감지, 자동 응답 기능&lt;br&gt;&lt;/li&gt;
&lt;li&gt;유저가 직접 입력한 일반 메시지를 분석하는 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;봇이 단순히 슬래시 명령어만 받고 일반 채팅 내용을 읽지 않는 구조라면 없어도 될 수 있습니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;3. Discord Developer Portal에서 켜는 위치&lt;/h3&gt;
&lt;p&gt;브라우저에서 &lt;a href=&quot;https://discord.com/developers/applications&quot;&gt;Discord Developer Portal&lt;/a&gt;에 접속합니다.&lt;br&gt;&lt;br&gt;애플리케이션을 선택한 뒤 왼쪽의 &lt;code&gt;Bot&lt;/code&gt; 메뉴로 들어갑니다.&lt;br&gt;&lt;br&gt;아래로 내리면 &lt;code&gt;Privileged Gateway Intents&lt;/code&gt; 구간이 보입니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;여기서 &lt;code&gt;Message Content Intent&lt;/code&gt;를 켭니다.&lt;br&gt;&lt;br&gt;저장 버튼이 보이면 저장까지 눌러야 합니다.&lt;br&gt;&lt;br&gt;생각보다 여기서 저장을 안 누르고 닫는 경우가 많습니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;4. 코드 쪽에서도 같이 켜야 한다&lt;/h3&gt;
&lt;p&gt;Portal에서만 켜고 코드에서 인텐트를 열지 않으면 여전히 명령어가 안 읽힐 수 있습니다.&lt;br&gt;&lt;br&gt;둘 다 맞아야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;discord.py&lt;/code&gt; 예시는 아래처럼 잡습니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import discord
from discord.ext import commands

intents = discord.Intents.default()
intents.message_content = True

bot = commands.Bot(command_prefix=&amp;quot;!&amp;quot;, intents=intents)&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
핵심은 `intents.message_content = True` 한 줄입니다.&lt;br&gt;
이 줄이 없으면 Portal에서 옵션을 켜도 코드에서 메시지 내용을 받지 못합니다.&lt;br&gt;

&lt;h3&gt;5. 빠르게 테스트하는 방법&lt;/h3&gt;
&lt;p&gt;가장 단순한 핑 명령어로 먼저 확인해야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@bot.command(name=&amp;quot;핑&amp;quot;)
async def ping(ctx):
    await ctx.send(&amp;quot;퐁&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
이 코드를 넣고 실행한 뒤 채널에서 `!핑`을 입력합니다.&lt;br&gt;
반응이 없으면 아래 순서대로 봅니다.&lt;br&gt;

&lt;ul&gt;
&lt;li&gt;Portal에서 &lt;code&gt;Message Content Intent&lt;/code&gt;를 켰는가&lt;br&gt;&lt;/li&gt;
&lt;li&gt;코드에서 &lt;code&gt;intents.message_content = True&lt;/code&gt;를 넣었는가&lt;br&gt;&lt;/li&gt;
&lt;li&gt;봇을 재시작했는가&lt;br&gt;&lt;/li&gt;
&lt;li&gt;접두사 문자를 실제 입력과 똑같이 맞췄는가&lt;br&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;6. 자주 헷갈리는 포인트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Portal만 켜고 코드에서 안 켠 경우&lt;br&gt;&lt;/li&gt;
&lt;li&gt;코드만 켜고 Portal에서 안 켠 경우&lt;br&gt;&lt;/li&gt;
&lt;li&gt;설정을 바꾼 뒤 봇을 재시작하지 않은 경우&lt;br&gt;&lt;/li&gt;
&lt;li&gt;슬래시 명령어와 메시지 명령어 차이를 섞어서 이해한 경우&lt;br&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;가장 흔한 패턴은 첫 번째와 두 번째입니다.&lt;br&gt;&lt;br&gt;둘 중 하나만 맞으면 될 것 같지만 실제로는 둘 다 맞아야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;7. 권한 문제와 혼동하지 말 것&lt;/h3&gt;
&lt;p&gt;Message Content Intent가 빠지면 봇은 메시지를 읽지 못합니다.&lt;br&gt;&lt;br&gt;채널 권한이 막힌 경우는 메시지를 읽거나 보내는 범위 자체가 제한됩니다.&lt;br&gt;&lt;br&gt;겉으로 보기에는 둘 다 &lt;code&gt;봇이 조용하다&lt;/code&gt;로 보이기 때문에 헷갈리기 쉽습니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;권한 구조까지 같이 확인하려면 &lt;a href=&quot;https://blog.dishost.kr/55&quot;&gt;디스코드 봇 권한 설정법&lt;/a&gt;을 같이 보면 됩니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;8. 슬래시 명령어만 쓸 거라면&lt;/h3&gt;
&lt;p&gt;슬래시 명령어 구조만 쓴다면 &lt;code&gt;Message Content Intent&lt;/code&gt; 의존도는 낮아집니다.&lt;br&gt;&lt;br&gt;다만 입문 단계에서는 메시지 명령어와 슬래시 명령어를 같이 테스트하는 경우가 많아서 미리 이해해 둬야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;실제로 처음 봇을 만들 때는 &lt;code&gt;!핑&lt;/code&gt;으로 확인하고, 나중에 슬래시 명령어로 넘어가는 흐름이 가장 편합니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;9. 이런 로그가 보이면 다시 확인&lt;/h3&gt;
&lt;p&gt;터미널에 에러가 아예 없는데도 반응이 없는 경우가 있습니다.&lt;br&gt;&lt;br&gt;이럴 때는 오히려 Intent 설정 문제일 가능성이 큽니다.&lt;br&gt;&lt;br&gt;코드는 돌아가지만 이벤트가 안 들어오는 상태라고 보면 됩니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;이 문제를 먼저 정리해 두면 뒤에서 ChatGPT 기능이나 자동 응답 기능을 붙일 때 덜 막힙니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;10. 다음 단계&lt;/h3&gt;
&lt;p&gt;기본 봇 코드가 아직 없다면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;부터 먼저 맞춰 둬야 합니다.&lt;br&gt;&lt;br&gt;슬래시 명령어 구조로 바꿀 계획이라면 그다음 글에서 바로 연결하면 됩니다.&lt;br&gt;&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/56</guid>
      <comments>https://dishost.tistory.com/56#entry56comment</comments>
      <pubDate>Mon, 23 Mar 2026 16:15:01 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 봇 권한 설정법, Missing Permissions/Missing Access 피하기</title>
      <link>https://dishost.tistory.com/55</link>
      <description>&lt;p&gt;디스코드 봇이 온라인인데 명령어가 안 되거나, 메시지를 못 보내거나, 역할을 못 주는 경우가 많습니다.&lt;br&gt;&lt;br&gt;원인은 대개 코드보다 권한 구조입니다.&lt;br&gt;&lt;br&gt;특히 &lt;code&gt;Missing Permissions(50013)&lt;/code&gt;과 &lt;code&gt;Missing Access(50001)&lt;/code&gt;는 봇을 처음 만들 때 거의 한 번씩 보게 됩니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;권한 설정은 감으로 맞추면 계속 꼬입니다.&lt;br&gt;&lt;br&gt;역할 순서, 채널 권한, 봇 초대 권한, 관리자 권한의 차이를 한 번에 이해해야 훨씬 빠릅니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;1. 먼저 알아둘 것&lt;/h3&gt;
&lt;p&gt;디스코드 권한은 크게 세 층으로 움직입니다.&lt;br&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버 역할 권한&lt;br&gt;&lt;/li&gt;
&lt;li&gt;채널별 덮어쓰기 권한&lt;br&gt;&lt;/li&gt;
&lt;li&gt;역할 순서에 따른 우선순위&lt;br&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;봇이 어떤 작업을 하려면 코드만 맞는다고 끝나지 않습니다.&lt;br&gt;&lt;br&gt;서버에서 해당 권한을 실제로 받아야 하고, 채널에서도 막히지 않아야 하며, 역할 순서도 대상보다 위에 있어야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;2. 가장 많이 헷갈리는 역할 순서&lt;/h3&gt;
&lt;p&gt;디스코드에서는 봇 역할이 대상 역할보다 아래에 있으면 역할 지급이나 닉네임 변경 같은 작업을 수행하지 못합니다.&lt;br&gt;&lt;br&gt;여기서 가장 많이 막힙니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;예를 들어 아래 순서라면 문제가 생깁니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;관리자
운영진
멤버
봇&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
이 상태에서 봇이 `멤버` 역할을 주려고 하면 실패할 수 있습니다.&lt;br&gt;
봇 역할을 최소한 `멤버`보다 위로 올려야 합니다.&lt;br&gt;

&lt;p&gt;안전한 예시는 아래와 같습니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;관리자
운영진
봇
멤버&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
역할 지급, 닉네임 변경, 채널 관리 같은 작업을 시킬 계획이라면 봇 역할 위치부터 먼저 확인합니다.&lt;br&gt;

&lt;h3&gt;3. 서버 권한과 채널 권한은 따로 본다&lt;/h3&gt;
&lt;p&gt;서버 역할에서 &lt;code&gt;Send Messages&lt;/code&gt;를 허용해도 특정 채널에서 막혀 있으면 봇은 메시지를 보내지 못합니다.&lt;br&gt;&lt;br&gt;반대로 서버 권한이 부족하면 채널에서 허용해도 동작하지 않을 수 있습니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;즉, 아래 둘을 같이 봐야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;봇 역할 자체에 필요한 권한이 들어 있는가&lt;br&gt;&lt;/li&gt;
&lt;li&gt;해당 채널에서 봇 역할이 차단되어 있지 않은가&lt;br&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;로그 채널, 공지 채널, 봇 명령어 채널은 특히 따로 확인해야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;4. 자주 쓰는 봇 권한&lt;/h3&gt;
&lt;p&gt;입문용 봇이라면 보통 아래 권한부터 시작합니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;View Channels
Send Messages
Read Message History
Manage Messages
Embed Links
Attach Files
Use Slash Commands
Manage Roles&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
모든 봇에 `Administrator` 권한을 주는 방식은 추천하지 않습니다.&lt;br&gt;
테스트는 편하지만, 권한 문제가 생겼을 때 어디서 막혔는지 파악하기가 오히려 어려워집니다.&lt;br&gt;

&lt;h3&gt;5. 봇 초대 링크에서 권한을 잘못 고르면 생기는 일&lt;/h3&gt;
&lt;p&gt;봇 초대 링크를 만들 때 필요한 권한을 너무 적게 체크하면 서버에 들어온 뒤 바로 기능이 제한됩니다.&lt;br&gt;&lt;br&gt;반대로 너무 많이 열면 필요 없는 권한까지 갖게 됩니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;초대 링크 생성 예시는 아래처럼 잡을 수 있습니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;기본 응답 봇
- View Channels
- Send Messages
- Read Message History

관리 기능 포함 봇
- Manage Messages
- Manage Roles
- Use Slash Commands&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
관리 기능을 넣을 계획이라면 초대 링크를 새로 만들어 다시 넣는 경우도 많습니다.&lt;br&gt;
처음부터 필요한 범위를 어느 정도 계산해 두면 편합니다.&lt;br&gt;

&lt;h3&gt;6. 50013과 50001 차이&lt;/h3&gt;
&lt;p&gt;둘은 비슷해 보이지만 결이 다릅니다.&lt;br&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Missing Permissions(50013)&lt;/code&gt;:&lt;br&gt;&lt;br&gt;작업 자체를 할 권한이 부족한 경우입니다.&lt;br&gt;&lt;br&gt;예를 들어 메시지 삭제, 역할 지급, 닉네임 변경처럼 행동 권한이 부족할 때 자주 뜹니다.&lt;br&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Missing Access(50001)&lt;/code&gt;:&lt;br&gt;&lt;br&gt;채널이나 서버 리소스에 접근 자체를 못 하는 경우입니다.&lt;br&gt;&lt;br&gt;채널 보기 권한이 막혀 있거나 접근 가능한 위치가 아닐 때 많이 뜹니다.&lt;br&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;실전에서는 역할 순서와 채널 보기 권한을 같이 확인하면 대부분 빨리 잡힙니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;7. 점검 순서&lt;/h3&gt;
&lt;p&gt;권한 문제가 생기면 아래 순서로 보면 빠릅니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 봇 역할이 서버에 실제로 붙어 있는가
2. 봇 역할에 필요한 서버 권한이 켜져 있는가
3. 채널 덮어쓰기에서 봇 역할이 막혀 있지 않은가
4. 봇 역할이 대상 역할보다 위에 있는가
5. 봇을 초대한 링크에 필요한 권한이 포함되어 있는가&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
이 다섯 개만 보면 웬만한 권한 문제는 정리됩니다.&lt;br&gt;

&lt;h3&gt;8. 테스트용 코드로 바로 확인하기&lt;/h3&gt;
&lt;p&gt;권한이 실제로 통하는지 보려면 아래처럼 간단한 테스트 명령어를 만들어 두면 편합니다.&lt;br&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@bot.command(name=&amp;quot;권한확인&amp;quot;)
async def check_permissions(ctx):
    permissions = ctx.channel.permissions_for(ctx.guild.me)
    await ctx.send(
        &amp;quot;\n&amp;quot;.join(
            [
                f&amp;quot;send_messages={permissions.send_messages}&amp;quot;,
                f&amp;quot;manage_messages={permissions.manage_messages}&amp;quot;,
                f&amp;quot;manage_roles={permissions.manage_roles}&amp;quot;,
                f&amp;quot;view_channel={permissions.view_channel}&amp;quot;,
            ]
        )
    )&lt;/code&gt;&lt;/pre&gt;
&lt;br&gt;
이 명령어를 봇 명령어 채널, 공지 채널, 로그 채널에서 각각 실행해 보면 채널마다 어느 권한이 막혀 있는지 바로 보입니다.&lt;br&gt;

&lt;h3&gt;9. 초반에 꼭 기억할 것&lt;/h3&gt;
&lt;p&gt;권한 문제는 코드 수정으로 해결되지 않는 경우가 더 많습니다.&lt;br&gt;&lt;br&gt;터미널 에러만 보지 말고 서버 설정 화면도 같이 보는 습관이 필요합니다.&lt;br&gt;&lt;/p&gt;
&lt;p&gt;특히 역할 순서와 채널 권한은 처음 한 번만 잘못 잡아도 뒤에서 계속 발목을 잡습니다.&lt;br&gt;&lt;br&gt;봇 기능을 더 붙이기 전에 여기부터 정리해 둬야 합니다.&lt;br&gt;&lt;/p&gt;
&lt;h3&gt;10. 다음 단계&lt;/h3&gt;
&lt;p&gt;기본 봇을 아직 만들지 않았다면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;부터 보고 와야 합니다.&lt;br&gt;&lt;br&gt;Message Content Intent 설정이 빠져 있으면 명령어가 아예 읽히지 않을 수 있으니 다음 글에서는 그 부분을 먼저 다루는 흐름이 자연스럽습니다.&lt;br&gt;&lt;/p&gt;</description>
      <category>디스코드 봇 오류 해결</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/55</guid>
      <comments>https://dishost.tistory.com/55#entry55comment</comments>
      <pubDate>Sun, 22 Mar 2026 16:14:32 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 서버 만드는 법 처음부터 끝까지, 채널 역할 기본 세팅 가이드</title>
      <link>https://dishost.tistory.com/54</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 서버를 처음 만들면 채널부터 늘어놓기 쉽습니다.&lt;br /&gt;&lt;br /&gt;그렇게 시작하면 며칠 뒤 역할 권한이 꼬이고, 공지 채널과 잡담 채널이 섞이고, 봇을 넣은 뒤에도 구조를 다시 뜯어고치게 됩니다.&lt;br /&gt;&lt;br /&gt;처음 세팅에서 큰 틀만 제대로 잡아도 이후 운영 난이도가 꽤 내려갑니다.&lt;br /&gt;&lt;br /&gt;봇까지 바로 붙일 계획이라면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;를 이어서 보면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 서버부터 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 앱이나 웹에서 왼쪽 사이드바의 &lt;code&gt;+&lt;/code&gt; 버튼을 누릅니다.&lt;br /&gt;&lt;br /&gt;&lt;code&gt;서버 만들기&lt;/code&gt;를 선택한 뒤 서버 이름과 아이콘을 설정합니다.&lt;br /&gt;&lt;br /&gt;아이콘은 나중에 바꿔도 되지만, 이름은 초반에 방향을 어느 정도 정해 둬야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 &lt;code&gt;커뮤니티 서버&lt;/code&gt; 기능을 켜는 것은 서두르지 않아도 됩니다.&lt;br /&gt;&lt;br /&gt;일반 서버 상태로 구조를 먼저 잡고, 공지 채널과 규칙 채널이 준비된 뒤 커뮤니티 기능을 켜야 덜 꼬입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 채널을 만들기 전에 카테고리부터 나누기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널을 바로 여러 개 만드는 방식보다 카테고리부터 먼저 정하는 방식이 관리가 쉽습니다.&lt;br /&gt;&lt;br /&gt;초반 서버라면 아래 정도만 있어도 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;  시작하기
├─ 규칙
├─ 공지
└─ 역할선택

  커뮤니티
├─ 잡담
├─ 질문
└─ 봇명령어

  운영
├─ 관리자전용
├─ 로그
└─ 문의&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;핵심은 유저가 처음 들어왔을 때 어디를 먼저 봐야 하는지가 바로 보이게 만드는 것입니다.&lt;br /&gt;규칙, 공지, 역할선택이 맨 위에 고정되어 있으면 서버 첫인상이 훨씬 정돈되어 보입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 처음부터 만들어 두면 좋은 채널&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 채널은 대부분의 서버에서 초반부터 필요합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;규칙&lt;/code&gt;:&lt;br /&gt;&lt;br /&gt;서버 규칙, 금지 항목, 제재 기준을 적는 채널입니다.&lt;br /&gt;&lt;br /&gt;나중에 인증 봇이나 규칙 동의 시스템을 붙일 때 기준 채널로 쓰이기도 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;공지&lt;/code&gt;:&lt;br /&gt;&lt;br /&gt;업데이트, 이벤트, 운영 안내를 올리는 채널입니다.&lt;br /&gt;&lt;br /&gt;일반 멤버가 채팅을 치지 못하게 읽기 전용으로 두는 경우가 많습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;잡담&lt;/code&gt;:&lt;br /&gt;&lt;br /&gt;가장 많이 쓰는 기본 채널입니다.&lt;br /&gt;&lt;br /&gt;초반 서버 활동량은 거의 여기서 결정됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;봇명령어&lt;/code&gt;:&lt;br /&gt;&lt;br /&gt;봇 테스트나 명령어 실행을 따로 모으는 채널입니다.&lt;br /&gt;&lt;br /&gt;잡담 채널이 봇 로그로 도배되는 상황을 막기 쉽습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;관리자전용&lt;/code&gt;:&lt;br /&gt;&lt;br /&gt;운영진끼리만 보는 채널입니다.&lt;br /&gt;&lt;br /&gt;권한 테스트, 공지 초안, 제재 기록 메모를 남길 때 편합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 역할은 적게 시작해야 덜 꼬인다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 역할을 열 개 넘게 만드는 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;대부분은 초반 활동량보다 역할 수가 더 많아지고, 권한 구조도 복잡해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 역할은 아래 정도로 시작하면 충분합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;관리자
운영진
봇
인증됨
멤버&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`관리자`와 `운영진`은 분리해야 합니다.&lt;br /&gt;모든 운영진에게 `Administrator` 권한을 주면 실수 한 번에 채널 전체 설정이 바뀔 수 있습니다.&lt;br /&gt;웬만하면 필요한 권한만 개별적으로 체크하는 방식이 안전합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 권한 구조는 이렇게 잡으면 덜 꼬인다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한은 &lt;code&gt;@everyone&lt;/code&gt;을 기준으로 막고, 필요한 역할에만 푸는 식으로 설계해야 관리가 쉽습니다.&lt;br /&gt;&lt;br /&gt;예를 들어 &lt;code&gt;관리자전용&lt;/code&gt; 채널은 &lt;code&gt;@everyone&lt;/code&gt;에서 보기 권한을 끄고, &lt;code&gt;관리자&lt;/code&gt;와 &lt;code&gt;운영진&lt;/code&gt; 역할만 허용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지 채널은 아래처럼 잡으면 무난합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@everyone: 보기 허용 / 메시지 전송 차단
운영진: 보기 허용 / 메시지 전송 허용
관리자: 보기 허용 / 메시지 전송 허용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;봇을 넣을 계획이라면 봇 역할도 공지 채널과 로그 채널을 볼 수 있게 풀어 둬야 합니다.&lt;br /&gt;이 부분이 빠지면 봇은 온라인인데도 특정 채널에서만 조용해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 초대 링크는 무기한 하나, 임시 링크는 따로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 만들고 나면 초대 링크부터 여기저기 뿌리게 됩니다.&lt;br /&gt;&lt;br /&gt;무기한 기본 링크 하나와, 이벤트용 임시 링크를 분리해 두면 나중에 추적하기 쉽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초대 링크를 만들 때는 아래처럼 설정하면 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본 링크:&lt;br /&gt;&lt;br /&gt;만료 없음, 사용 횟수 제한 없음&lt;/li&gt;
&lt;li&gt;이벤트 링크:&lt;br /&gt;&lt;br /&gt;7일 만료 또는 100회 제한&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나눠 두면 어떤 경로로 유저가 들어오는지 구분하기 쉽습니다.&lt;br /&gt;&lt;br /&gt;초대 링크 관리가 필요해지면 뒤에서 초대 추적 봇을 붙이는 흐름으로 이어가면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 서버를 만든 직후 바로 점검할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 구조를 대충 만든 뒤 바로 사람을 받기 시작하면 꼭 수정이 생깁니다.&lt;br /&gt;&lt;br /&gt;초반 점검은 아래 정도만 해도 충분합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새 계정으로 들어왔을 때 &lt;code&gt;규칙&lt;/code&gt;, &lt;code&gt;공지&lt;/code&gt;, &lt;code&gt;잡담&lt;/code&gt;이 정상적으로 보이는가&lt;/li&gt;
&lt;li&gt;일반 멤버가 공지 채널에 글을 쓰지 못하는가&lt;/li&gt;
&lt;li&gt;운영진만 관리자전용 채널을 볼 수 있는가&lt;/li&gt;
&lt;li&gt;봇을 넣었을 때 &lt;code&gt;봇명령어&lt;/code&gt; 채널에 메시지를 보낼 수 있는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 사용자 시점으로 한 번 들어가 보는 게 가장 빠릅니다.&lt;br /&gt;&lt;br /&gt;운영자 계정으로만 보면 권한 문제를 놓치기 쉽습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 처음 많이 하는 실수&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;채널을 너무 많이 만드는 실수:&lt;br /&gt;&lt;br /&gt;활동량이 적은 초반에는 채널 수보다 대화 밀도가 더 중요합니다.&lt;br /&gt;&lt;br /&gt;사람이 적은데 채널만 많으면 서버가 비어 보입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Administrator&lt;/code&gt; 권한을 남발하는 실수:&lt;br /&gt;&lt;br /&gt;관리 편하다고 다 열어 두면 나중에 누가 뭘 바꿨는지 추적하기도 어렵습니다.&lt;br /&gt;&lt;br /&gt;필요 권한만 따로 줘야 안전합니다.&lt;/li&gt;
&lt;li&gt;규칙과 공지가 아래로 묻히는 실수:&lt;br /&gt;&lt;br /&gt;새로 들어온 유저는 맨 위에서 서버 분위기를 판단합니다.&lt;br /&gt;&lt;br /&gt;필수 채널을 위쪽에 고정해 두는 게 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 여기서 한 번 더 점검할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 구조를 만들었다면 다음에는 봇을 붙이거나 권한 구조를 더 정교하게 다듬는 쪽으로 넘어가면 됩니다.&lt;br /&gt;&lt;br /&gt;특히 봇을 넣을 생각이라면 권한 구조부터 먼저 이해해야 안전합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. 24시간 운영까지 생각한다면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 채널만 정리한다고 운영이 끝나는 것은 아닙니다.&lt;br /&gt;&lt;br /&gt;자동 공지, 인증, 환영 메시지, 티켓 같은 기능은 결국 봇이 맡게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇을 직접 만들 계획이라면 &lt;a href=&quot;https://blog.dishost.kr/53&quot;&gt;파이썬 디스코드 봇 만들기 처음부터 끝까지&lt;/a&gt;에서 바로 이어가면 됩니다.&lt;br /&gt;&lt;br /&gt;완성한 봇을 24시간 돌릴 때는 &lt;a href=&quot;https://blog.dishost.kr/47&quot;&gt;24시간 디스코드 봇 무료 호스팅, 디스호스트&lt;/a&gt; 글처럼 별도 서버에 올려 두는 흐름이 필요합니다.&lt;/p&gt;</description>
      <category>디스코드 서버 운영</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/54</guid>
      <comments>https://dishost.tistory.com/54#entry54comment</comments>
      <pubDate>Sat, 21 Mar 2026 16:12:48 +0900</pubDate>
    </item>
    <item>
      <title>파이썬 디스코드 봇 만들기 처음부터 끝까지, discord.py 입문 가이드</title>
      <link>https://dishost.tistory.com/53</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;파이썬으로 디스코드 봇을 처음 만들 때 가장 많이 막히는 구간은 코드보다 세팅 쪽입니다.&lt;br /&gt;&lt;br /&gt;Developer Portal에서 봇 생성, 토큰 보관, 권한과 인텐트 설정, 서버 초대까지 흐름을 한 번에 잡아야 곧바로 실행으로 넘어갈 수 있습니다.&lt;br /&gt;&lt;br /&gt;디스코드 봇 개발의 전 과정을 한 번에 끝내는 입문 가이드입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;discord.py&lt;/code&gt; 라이브러리로 기본 명령어가 동작하는 파이썬 디스코드 봇 하나를 바로 만들어 보겠습니다.&lt;br /&gt;&lt;br /&gt;&lt;code&gt;!핑&lt;/code&gt;, &lt;code&gt;!서버정보&lt;/code&gt;, &lt;code&gt;!청소&lt;/code&gt; 명령어까지 넣어 두었기 때문에 파일만 만들고 실행하면 즉시 테스트 가능합니다.&lt;br /&gt;&lt;br /&gt;기본 구조를 익힌 뒤에는 AI 기능 붙이기, 권한 구조 정리, 슬래시 명령어 전환 순서로 넓혀 가면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 먼저 준비할 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 전에 파이썬 3.10 이상, 디스코드 계정, 코드를 작성할 폴더 하나를 준비합니다.&lt;br /&gt;&lt;br /&gt;운영체제가 macOS든 Windows든 흐름은 거의 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과물 구조는 아래와 같습니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;my-discord-bot/
├─ .env
└─ main.py&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`.env`는 토큰을 숨기는 파일, `main.py`는 실제 봇 코드가 들어가는 파일입니다.&lt;br /&gt;토큰을 코드에 그대로 적으면 나중에 깃허브 업로드나 화면 공유 중에 유출될 수 있으니 처음부터 분리하는 편이 안전합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Discord Developer Portal에서 봇 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 &lt;a href=&quot;https://discord.com/developers/applications&quot;&gt;Discord Developer Portal&lt;/a&gt;에 접속합니다.&lt;br /&gt;&lt;br /&gt;오른쪽 위의 &lt;code&gt;New Application&lt;/code&gt; 버튼을 누르고 봇 이름을 입력한 뒤 애플리케이션을 생성합니다.&lt;br /&gt;&lt;br /&gt;생성을 마쳤다면 왼쪽 메뉴에서 &lt;code&gt;Bot&lt;/code&gt; 탭으로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;Reset Token&lt;/code&gt; 또는 &lt;code&gt;Copy&lt;/code&gt; 버튼으로 봇 토큰을 발급받을 수 있습니다.&lt;br /&gt;&lt;br /&gt;토큰은 봇의 통제권을 가진 비밀번호입니다.&lt;br /&gt;&lt;br /&gt;깃허브 업로드, 화면 공유, 메신저 전송 과정에서 실수로 노출되지 않게 주의해야 합니다.&lt;br /&gt;&lt;br /&gt;유출이 의심되면 바로 &lt;code&gt;Reset Token&lt;/code&gt;을 눌러 기존 토큰을 폐기합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 화면에서 &lt;code&gt;Message Content Intent&lt;/code&gt;를 켜 둡니다.&lt;br /&gt;&lt;br /&gt;지금 단계에서는 &lt;code&gt;!핑&lt;/code&gt;, &lt;code&gt;!청소&lt;/code&gt; 같은 메시지 기반 명령어를 사용하므로 이 설정이 꺼져 있으면 봇이 명령어를 읽지 못합니다.&lt;br /&gt;&lt;br /&gt;이 단계에서 빠뜨리는 경우가 매우 많습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 봇을 서버에 초대할 링크 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왼쪽 메뉴에서 &lt;code&gt;OAuth2&lt;/code&gt; -&amp;gt; &lt;code&gt;URL Generator&lt;/code&gt;로 이동합니다.&lt;br /&gt;&lt;br /&gt;&lt;code&gt;SCOPES&lt;/code&gt;에서는 &lt;code&gt;bot&lt;/code&gt;을 체크합니다.&lt;br /&gt;&lt;br /&gt;지금 단계에서는 슬래시 명령어를 사용하지 않으므로 &lt;code&gt;applications.commands&lt;/code&gt;는 필수가 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 &lt;code&gt;BOT PERMISSIONS&lt;/code&gt;에서는 최소한 다음 권한을 체크합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;View Channels&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Send Messages&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Read Message History&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Manage Messages&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Manage Messages&lt;/code&gt;는 &lt;code&gt;!청소&lt;/code&gt; 명령어를 테스트할 때 필요합니다.&lt;br /&gt;&lt;br /&gt;이 권한이 없으면 봇이 온라인 상태여도 메시지 삭제는 수행하지 못합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면 아래쪽에 생성된 URL을 복사해서 브라우저에 붙여넣고, 봇을 추가할 서버를 선택합니다.&lt;br /&gt;&lt;br /&gt;초대까지 마치면 서버 멤버 목록에서 봇을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 프로젝트 폴더 만들고 패키지 설치하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VS Code에서 새 폴더를 열고 터미널을 실행합니다.&lt;br /&gt;&lt;br /&gt;가상환경까지 같이 만드는 흐름으로 잡아 두면 나중에 패키지 충돌이 적습니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;mkdir my-discord-bot
cd my-discord-bot
python3 -m venv .venv
source .venv/bin/activate
pip install -U discord.py python-dotenv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Windows에서는 가상환경 활성화 명령어만 다릅니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;python -m venv .venv
.venv\Scripts\activate
pip install -U discord.py python-dotenv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`discord.py`는 디스코드 API와 통신하는 핵심 라이브러리입니다.&lt;br /&gt;`python-dotenv`는 `.env` 파일에 저장한 토큰을 코드에서 읽어오는 용도입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. .env 파일에 토큰 보관하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 폴더 안에 &lt;code&gt;.env&lt;/code&gt; 파일을 만들고 아래처럼 작성합니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;DISCORD_TOKEN=여기에_Developer_Portal_토큰_붙여넣기
PREFIX=!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`PREFIX`는 명령어 앞에 붙는 문자입니다.&lt;br /&gt;`!핑`, `!청소`처럼 느낌표 기반 명령어를 사용할 계획이므로 `!`로 설정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.env&lt;/code&gt;를 제대로 읽지 못하면 &lt;code&gt;Improper token has been passed&lt;/code&gt;나 로그인 실패가 발생할 수 있습니다.&lt;br /&gt;&lt;br /&gt;공백이 같이 들어갔는지, 큰따옴표를 썼는지, 토큰 앞뒤에 줄바꿈이 붙었는지부터 확인하면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. main.py 전체 코드 작성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;main.py&lt;/code&gt; 파일을 만들고 아래 코드를 그대로 넣습니다.&lt;br /&gt;&lt;br /&gt;주석까지 포함한 완성본이라 바로 실행해도 됩니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import os

import discord
from discord.ext import commands
from dotenv import load_dotenv

# .env 파일에서 토큰과 접두사를 읽어옵니다.
load_dotenv()
TOKEN = os.getenv(&quot;DISCORD_TOKEN&quot;)
PREFIX = os.getenv(&quot;PREFIX&quot;, &quot;!&quot;)

# 메시지 기반 명령어를 읽으려면 message_content 인텐트가 필요합니다.
intents = discord.Intents.default()
intents.message_content = True

# commands.Bot은 명령어 처리 기능이 포함된 봇 클래스입니다.
bot = commands.Bot(command_prefix=PREFIX, intents=intents, help_command=None)


@bot.event
async def on_ready():
    print(f&quot;로그인 성공: {bot.user} (ID: {bot.user.id})&quot;)
    print(&quot;봇이 정상적으로 켜졌습니다.&quot;)


@bot.command(name=&quot;핑&quot;)
async def ping(ctx: commands.Context):
    latency_ms = round(bot.latency * 1000)
    await ctx.send(f&quot;퐁, 현재 지연 시간은 {latency_ms}ms 입니다.&quot;)


@bot.command(name=&quot;도움&quot;)
async def help_command(ctx: commands.Context):
    help_text = (
        &quot;사용 가능한 명령어\n&quot;
        f&quot;{PREFIX}핑 - 봇 응답 속도 확인\n&quot;
        f&quot;{PREFIX}서버정보 - 현재 서버 정보 확인\n&quot;
        f&quot;{PREFIX}청소 숫자 - 최근 메시지 정리\n&quot;
    )
    await ctx.send(f&quot;```\n{help_text}```&quot;)


@bot.command(name=&quot;서버정보&quot;)
async def server_info(ctx: commands.Context):
    guild = ctx.guild

    if guild is None:
        await ctx.send(&quot;이 명령어는 서버 채널에서만 사용할 수 있습니다.&quot;)
        return

    embed = discord.Embed(
        title=&quot;서버 정보&quot;,
        color=discord.Color.blue(),
    )
    embed.add_field(name=&quot;서버 이름&quot;, value=guild.name, inline=False)
    embed.add_field(name=&quot;멤버 수&quot;, value=str(guild.member_count), inline=True)
    embed.add_field(name=&quot;채널 수&quot;, value=str(len(guild.channels)), inline=True)
    embed.add_field(name=&quot;서버장&quot;, value=str(guild.owner), inline=False)

    if guild.icon:
        embed.set_thumbnail(url=guild.icon.url)

    await ctx.send(embed=embed)


@bot.command(name=&quot;청소&quot;)
@commands.has_permissions(manage_messages=True)
async def clear_messages(ctx: commands.Context, amount: int = 5):
    if amount &amp;lt; 1 or amount &amp;gt; 100:
        await ctx.send(&quot;청소 개수는 1 이상 100 이하로 입력하세요.&quot;)
        return

    deleted = await ctx.channel.purge(limit=amount + 1)
    notice = await ctx.send(f&quot;메시지 {len(deleted) - 1}개를 삭제했습니다.&quot;)
    await notice.delete(delay=3)


@clear_messages.error
async def clear_messages_error(ctx: commands.Context, error: Exception):
    if isinstance(error, commands.MissingPermissions):
        await ctx.send(&quot;이 명령어는 메시지 관리 권한이 있는 사용자만 사용할 수 있습니다.&quot;)
        return

    if isinstance(error, commands.BadArgument):
        await ctx.send(f&quot;사용 예시: {PREFIX}청소 10&quot;)
        return

    raise error


if not TOKEN:
    raise ValueError(&quot;DISCORD_TOKEN 값이 .env 파일에 없습니다.&quot;)

bot.run(TOKEN)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;핵심은 `commands.Bot`입니다.&lt;br /&gt;일반 `discord.Client`보다 명령어를 다루기 쉬워 입문 단계에서는 이 구조가 더 직관적입니다.&lt;br /&gt;`@bot.command()` 데코레이터를 붙이면 `!핑`, `!서버정보` 같은 명령어를 빠르게 추가할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;!청소&lt;/code&gt; 명령어에는 &lt;code&gt;@commands.has_permissions(manage_messages=True)&lt;/code&gt;를 붙였습니다.&lt;br /&gt;&lt;br /&gt;아무나 메시지를 지우지 못하게 막는 권한 조건입니다.&lt;br /&gt;&lt;br /&gt;봇 기능을 만들 때는 동작 자체보다 권한 조건을 같이 설계하는 습관이 중요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 봇 실행하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 아래 명령어를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;python main.py&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;macOS나 일부 환경에서는 아래 명령어가 더 잘 맞을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;python3 main.py&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정상 실행되면 터미널에 `로그인 성공` 문구가 출력됩니다.&lt;br /&gt;디스코드 서버 채널로 이동해서 아래 명령어를 하나씩 입력해 봅니다.&lt;/p&gt;
&lt;pre class=&quot;diff&quot;&gt;&lt;code&gt;!핑
!도움
!서버정보
!청소 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;`!청소 3`은 최근 메시지 3개를 삭제합니다.&lt;br /&gt;테스트는 일반 채널보다 별도 테스트 채널에서 진행하는 편이 안전합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 자주 막히는 구간&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 만들 때는 아래 구간에서 많이 멈춥니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Message Content Intent&lt;/code&gt; 활성화 누락:&lt;br /&gt;&lt;br /&gt;&lt;code&gt;Bot&lt;/code&gt; 탭에서 이 설정을 켜지 않으면 봇이 온라인으로 보여도 &lt;code&gt;!핑&lt;/code&gt; 같은 메시지 명령어에는 반응하지 않습니다.&lt;br /&gt;&lt;br /&gt;코드는 멀쩡한데 봇이 조용하면 가장 먼저 여기부터 확인하면 됩니다.&lt;/li&gt;
&lt;li&gt;봇 권한 부족:&lt;br /&gt;&lt;br /&gt;특히 &lt;code&gt;!청소&lt;/code&gt; 명령어는 봇에게 &lt;code&gt;Manage Messages&lt;/code&gt; 권한이 있어야 하고, 명령어를 실행하는 사용자도 메시지 관리 권한이 있어야 합니다.&lt;br /&gt;&lt;br /&gt;권한 구조가 헷갈리면 봇 역할보다 위에 관리자 역할을 올려 두지 않았는지도 같이 확인합니다.&lt;/li&gt;
&lt;li&gt;토큰 복사 오류:&lt;br /&gt;&lt;br /&gt;토큰 앞뒤 공백, 잘못 복사한 문자열, 재발급 후 예전 토큰을 그대로 둔 경우가 많습니다.&lt;br /&gt;&lt;br /&gt;&lt;code&gt;.env&lt;/code&gt; 값을 다시 붙여넣고 필요하면 포털에서 토큰을 재발급한 뒤 다시 테스트합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 여기서 다음으로 이어갈 만한 글&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 봇 세팅은 끝났습니다.&lt;br /&gt;&lt;br /&gt;다음 단계에서는 AI 답변 기능을 붙이거나, 슬래시 명령어 구조로 바꾸거나, 서버 권한 구조를 정리하는 쪽으로 확장하면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. 24시간 호스팅으로 실제 운영하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 만든 봇은 내 컴퓨터에서 터미널을 켠 동안만 돌아갑니다.&lt;br /&gt;&lt;br /&gt;노트북을 닫거나 전원을 끄면 봇도 같이 멈춥니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 유저들이 하루 종일 봇을 쓰게 하려면 별도 서버에 올려서 24시간 실행해야 합니다.&lt;br /&gt;&lt;br /&gt;배포 단계까지 바로 이어서 진행하려면 &lt;a href=&quot;https://blog.dishost.kr/47&quot;&gt;24시간 디스코드 봇 무료 호스팅, 디스호스트&lt;/a&gt; 글을 참고해서 방금 만든 &lt;code&gt;main.py&lt;/code&gt;와 &lt;code&gt;.env&lt;/code&gt;를 서버에 올리면 됩니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/53</guid>
      <comments>https://dishost.tistory.com/53#entry53comment</comments>
      <pubDate>Fri, 20 Mar 2026 11:37:17 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 AI 챗봇 만들기, 파이썬(discord.py)과 OpenAI 연결하기</title>
      <link>https://dishost.tistory.com/52</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 AI 챗봇 만들기, 파이썬(discord.py)과 OpenAI 연결하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 디스코드 서버에 대화 기능이 있는 AI 봇을 추가하는 서버가 많아졌습니다.&lt;br /&gt;&lt;br /&gt;정해진 명령어에만 답하는 봇보다, 유저의 질문을 파악하고 대답하는 챗봇이 인기가 많기 때문입니다.&lt;br /&gt;&lt;br /&gt;챗봇을 만들기 위해서는 보통 OpenAI의 API를 활용하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 파이썬의 discord.py 라이브러리를 사용해서 디스코드 메신저에 챗봇을 만드는 전체 과정을 순서대로 설명합니다.&lt;br /&gt;&lt;br /&gt;파이썬 기초 문법을 알고 있다면 누구나 직관적으로 따라 해볼 수 있도록 작성했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. OpenAI 플랫폼에서 API 키 발급하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 봇이 AI 모델과 통신하려면 OpenAI 서버의 접근 권한이 필요합니다.&lt;br /&gt;&lt;br /&gt;우선 인터넷 창을 열고 &lt;a href=&quot;https://platform.openai.com&quot;&gt;OpenAI 플랫폼 페이지&lt;/a&gt;에 로그인합니다.&lt;br /&gt;&lt;br /&gt;화면 왼쪽 메뉴에서 'API keys' 탭을 찾아서 클릭합니다.&lt;br /&gt;&lt;br /&gt;가운데 있는 [Create new secret key] 버튼을 누르면 영어와 숫자로 이루어진 긴 비밀 키(API 키)가 생성됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 키의 문자열은 타인에게 절대 노출되면 안 됩니다.&lt;br /&gt;&lt;br /&gt;만약 외부에 노출되어 타인이 이 키를 악용하면 요금이 과다 청구될 수 있기 때문입니다.&lt;br /&gt;&lt;br /&gt;화면에 뜬 키를 복사해서 본인의 텍스트 편집기에 임시로 붙여넣습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 파이썬 프로젝트 폴더 만들고 라이브러리 설치하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴퓨터에 봇 관련 파일들을 모아둘 빈 폴더를 하나 만듭니다.&lt;br /&gt;&lt;br /&gt;VS Code 같은 코딩 웹 에디터나 프로그램으로 방금 만든 폴더를 열고 터미널을 실행합니다.&lt;br /&gt;&lt;br /&gt;디스코드 API와 OpenAI API를 코드에서 사용할 수 있도록 아래 명령어를 터미널 창에 적고 엔터를 누르세요.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;pip install discord.py openai python-dotenv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 명령어는 디스코드 통신용 &lt;code&gt;discord.py&lt;/code&gt;, OpenAI 통신용 &lt;code&gt;openai&lt;/code&gt;, 그리고 아까 발급받은 비밀 키를 보관할 &lt;code&gt;python-dotenv&lt;/code&gt; 라이브러리를 설치합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 환경변수(.env) 보안 코드 작성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에 API 키를 그대로 적으면 나중에 깃허브 등에 코드를 올릴 때 키가 같이 유출될 위험이 큽니다.&lt;br /&gt;&lt;br /&gt;이 문제를 방지하기 위해 &lt;code&gt;.env&lt;/code&gt; 파일을 사용합니다. 폴더 안에 새 파일을 만들고 이름을 &lt;code&gt;.env&lt;/code&gt;로 설정합니다.&lt;br /&gt;&lt;br /&gt;파일을 열고 아래처럼 코드를 적어 넣습니다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;DISCORD_TOKEN=디스코드_포털에서_받은_토큰
OPENAI_API_KEY=sk-복사해둔_오픈에이아이_키&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 메인 파이썬 코드 (main.py) 작성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 봇을 실행할 &lt;code&gt;main.py&lt;/code&gt; 파일을 만들어 줍니다.&lt;br /&gt;&lt;br /&gt;빈 파일 안에 아래 파이썬 코드를 복사해서 붙여넣으세요.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import discord
from openai import AsyncOpenAI
import os
from dotenv import load_dotenv

# .env 파일에 저장해둔 비공개 키값들을 불러옵니다.
load_dotenv()

# 디스코드 봇이 메시지를 읽어올 수 있도록 설정(인텐트)을 변경합니다.
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)

# OpenAI 비동기 클라이언트를 실행합니다.
openai_client = AsyncOpenAI(api_key=os.getenv(&quot;OPENAI_API_KEY&quot;))

# 봇이 켜지고 접속을 마쳤을 때 터미널에 메시지를 출력합니다.
@client.event
async def on_ready():
    print(f&quot;로그인 성공: {client.user.name}&quot;)

# 서버에 새로운 메시지가 올라왔을 때 아래 코드가 실행됩니다.
@client.event
async def on_message(message):

    # 봇이 스스로 작성한 메시지는 무시합니다.
    if message.author.bot:
        return

    # 누군가 채팅창에서 봇을 태그했을 때만 반응합니다.
    if client.user in message.mentions:

        # 챗봇이 생각하는 동안 '입력 중...'이라는 표시를 띄워줍니다.
        async with message.channel.typing():

            # 유저의 메시지 내용 중에서 봇을 태그하는 코드를 지웁니다.
            user_text = message.content.replace(f'&amp;lt;@{client.user.id}&amp;gt;', '').strip()

            try:
                # OpenAI 서버로 질문 대화를 전송합니다.
                response = await openai_client.chat.completions.create(
                    model=&quot;gpt-4o-mini&quot;,
                    messages=[
                        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;너는 이 디스코드 서버의 매니저야. 친절하게 답변해줘.&quot;},
                        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_text}
                    ]
                )

                # 받아온 답변을 디스코드 채널에 답장으로 전송합니다.
                answer = response.choices[0].message.content
                await message.reply(answer)

            except Exception as e:
                # 에러 로그를 출력합니다.
                print(f&quot;오류 발생: {e}&quot;)
                await message.reply(&quot;에러가 발생해서 답변을 할 수 없습니다.&quot;)

# 설정해둔 디스코드 토큰으로 봇을 켭니다.
client.run(os.getenv(&quot;DISCORD_TOKEN&quot;))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에서 &lt;code&gt;system&lt;/code&gt; 역할을 맡은 안내 메시지를 수정하면 봇의 성격을 바꿀 수 있습니다.&lt;br /&gt;&lt;br /&gt;원하는 말투나 설정으로 문장을 수정하면 그에 맞게 봇의 반응이 즉각적으로 변합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 에러 확인 및 Message Content 권한 확인하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 &lt;code&gt;python main.py&lt;/code&gt;를 누르면 코드가 실행됩니다.&lt;br /&gt;&lt;br /&gt;디스코드 서버로 넘어가서 봇을 태그하고 질문을 하면 로딩 표시 이후에 봇이 대답을 전송합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 코드를 다 적었는데도 봇이 메시지에 반응을 하지 않고 오류가 나타난다면, 디스코드 개발자 포털(Discord Developer Portal)에서 서버 권한을 확인해야 합니다.&lt;br /&gt;&lt;br /&gt;내 봇을 선택한 뒤 Bot 메뉴로 들어가서 'Message Content Intent' 항목의 스위치가 파란색으로 활성화되어 있는지 확인하고 변경 사항을 저장하세요.&lt;br /&gt;&lt;br /&gt;이 기능 커져 있어야 봇이 유저의 대화 텍스트 데이터를 정상적으로 읽어올 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 디스코드 봇을 24시간 호스팅하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 모두 작성하고 작동까지 확인했다면 내 컴퓨터를 켤 때만 작업 환경에서 봇이 작동하는 상태일 것입니다.&lt;br /&gt;&lt;br /&gt;컴퓨터 전원을 끄거나 에디터 프로그램을 닫으면 파이썬 프로그램도 중단되기 때문에 봇도 멈춥니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 유저들이 새벽 시간대라도 봇을 사용하게 하려면 24시간 내내 작동할 별도의 호스팅 서버를 이용해야 합니다.&lt;br /&gt;&lt;br /&gt;관련된 무료 호스팅 배포 방법은 &lt;a href=&quot;https://blog.dishost.kr/47&quot;&gt;24시간 무료 봇 호스팅, 디스호스트&lt;/a&gt; 글을 참고하여, 방금 작성한 &lt;code&gt;main.py&lt;/code&gt;와 &lt;code&gt;.env&lt;/code&gt; 파일 등을 서버에 올려두고 작동시키면 됩니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/52</guid>
      <comments>https://dishost.tistory.com/52#entry52comment</comments>
      <pubDate>Thu, 19 Mar 2026 17:22:25 +0900</pubDate>
    </item>
    <item>
      <title>웹소설 표지 생성 AI, PYOZI AI 장단점, 기능, 가격 정리</title>
      <link>https://dishost.tistory.com/51</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹소설에서 표지의 중요성은 매우 중요합니다. 독자가 작품을 고를 때 가장 먼저 보는 요소이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 표지 작업은 비용과 시간이 많이 드는 과정이기도 합니다. 그래서 AI를 활용한 표지 생성 서비스가 점점 관심을 받고 있는데, 그중에서도 PYOZI AI는 웹소설 작가들에게 특히 맞춤형으로 설계된 표지 생성 도구입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 PYOZI AI가 어떤 서비스인지, 장점과 단점은 무엇인지, 기능과 가격은 어떻게 구성돼 있는지, 실제로 어떻게 쓰면 되는지를 한 번에 정리해 보겠습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PYOZI AI&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PYOZI AI는 웹소설 표지와 삽화를 만드는 데 초점을 맞춘 AI 생성 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 장르에 맞는 표지를 빠르게 뽑거나, 표지에 타이포그래피를 얹거나, 플랫폼 규격에 맞춰진 파일을 받거나, 상업적 라이선스 등의 번거로움을 해결해 줍니다. 즉 웹소설 표지 제작 프로세스를 한번에 해결해주는 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pyozi.com/&quot;&gt;PYOZI AI : https://pyozi.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1774259348204&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;PYOZI AI | 웹소설 표지 AI 생성 솔루션&quot; data-og-description=&quot;AI가 웹소설 표지와 삽화를 자동으로 생성합니다. 로맨스판타지, BL, 무협, 현대판타지 등 다양한 장르 지원. 전문 일러스트레이터 수준의 퀄리티를 몇 분 만에!&quot; data-og-host=&quot;pyozi.com&quot; data-og-source-url=&quot;https://pyozi.com/&quot; data-og-url=&quot;https://pyozi.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dIurfK/dJMb82eMr40/2hHkAsJdkMDGSuYikERrkk/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379,https://scrap.kakaocdn.net/dn/bAIes8/dJMb9g5aAtj/o36QMz9RrzVxCkc9z4BPB0/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379&quot;&gt;&lt;a href=&quot;https://pyozi.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://pyozi.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dIurfK/dJMb82eMr40/2hHkAsJdkMDGSuYikERrkk/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379,https://scrap.kakaocdn.net/dn/bAIes8/dJMb9g5aAtj/o36QMz9RrzVxCkc9z4BPB0/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;PYOZI AI | 웹소설 표지 AI 생성 솔루션&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;AI가 웹소설 표지와 삽화를 자동으로 생성합니다. 로맨스판타지, BL, 무협, 현대판타지 등 다양한 장르 지원. 전문 일러스트레이터 수준의 퀄리티를 몇 분 만에!&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;pyozi.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;장점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 웹소설 전용 이미지 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;범용 AI 이미지 생성기는 결과물이 웹소설 표지로 바로 이어지지 않는 경우가 많습니다. 원하는 그림체를 찾는 것이 어렵고, 캐릭터 구도, 제목 배치 등 단순 이미지 생성기로는 해결하기 힘든 부분이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 PYOZI AI는 처음부터 웹소설 표지와 삽화에 맞춰 설계된 서비스이기 때문에, 장르별 분위기와 표지용 결과물에 초점이 맞춰져 있다는 점이 첫 번째 장점입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 타이포그래피 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타 그림 AI는 타이포그래피를 생성할 수 없기 때문에, 결국 타이포그래피는 포토샵 등의 프로그램을 이용하여 직접 추가해야하는 단점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PYOZI AI는 타이포그래피 생성 기능이 포함돼 있어서, 그림 생성 이후 실제 표지를 바로 완성할 수 있습니다. 디자인 툴을 다루지 않는 작가에게 이 차이는 큽니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 저렴한 가격&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외주 표지는 한 번 맡기면 &lt;a href=&quot;https://pyozi.com/blog/cover-commission-prices&quot;&gt;적지 않은 비용&lt;/a&gt;이 들어갑니다. 일러스트 외주를 통해 웹소설 표지 외주를 맡기면 최소 30만원에서 많게는 150만원까지 소모되는데 비해, PYOZI AI는 9달러 정도로 훨씬 더 많은 표지를 생성할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 상업 사용 및 라이센스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외주나 커미션으로 표지 제작을 맡기게 되면, 외주 작가와 복잡한 라이센스 과정과 저작권 협의 과정을 거쳐야하고, 이 과정에서 많은 시간과 돈이 소모되게 됩니다. PYOZI AI는 유료 생성 결과물에 대한 모든 저작권을 행사하지 않기 때문에, 이러한 문제에서 안전하다고 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 각 플랫폼마다 AI 이미지와 관련된 정책이 다르니, 이를 따로 확인하는 편이 좋습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;단점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 아직은 못미치는 퀄리티&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 아직은 이미지 생성 AI가 완벽하지 않기 때문에, 일러스트레이터에게 고액의 외주를 맡겨 나오는 수준의 결과물을 기대하기는 어렵습니다. 가끔은 경이로운 퀄리티를 보여주기도 하지만, 눈동자의 초점과 동공 묘사가 일그러지거나 복잡한 옷감의 문양이나 머리카락과 옷의 경계선이 뭉개지는 등, 퀄리티가 완벽하지 않을 때가 있습니다. 배경에서도 물체가 서로 겹쳐 보이거나 중력을 거스르는 형태의 소품이 나타나는 AI 노이즈가 발생할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 아쉬운 디테일&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 특정 인물의 복장 디테일, 아주 미묘한 시대감, 소설 속 핵심 상징을 정확히 넣는 등의 픽셀 단위의 정교한 결과물을 위해서는 많은 시도가 필요합니다. 따라서 표지 내에 매우 디테일한 표현을 원하는 분들에게는 고액 일러스트가 더 나은 선택일 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PYOZI AI 주요 기능 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;장르 맞춤형 표지 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로판, BL, 판타지, 무협, 현대물 등 웹소설 장르에 맞는 분위기의 결과물을 빠르게 뽑는 데 초점이 맞춰져 있습니다. 범용 이미지 툴보다 웹소설 표지에 더 적합한 이미지를 생성할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;자동 타이포그래피 배치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표지 이미지 위에 제목을 올리는 기능이 따로 있습니다. 직접 텍스트 레이어를 생성할 필요없이, 스타일만 정하면 알아서 타이포그래피가 배치됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;변형 생성과 부분 수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완전히 처음부터 다시 뽑는 것만이 아니라, 변형 생성과 수정 기능도 들어가 있습니다. 마음에 드는 시안을 중심으로 조금씩 조정해 가는 방식이 가능합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다양한 그림체&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;21개 이상의 그림체가 있어서, 다양한 장르와 대상 독자에 맞는 그림체를 골라 표지를 제작할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;캐릭터 기억 기능 / 프로젝트 기반 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹소설 캐릭터를 생성 후 저장하여, 표지나 삽화에 동일한 캐릭터를 넣을 수 있습니다. 또한 내 웹소설들의 캐릭터, 삽화, 표지들을 프로젝트 기반으로 편리하게 관리할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;플랜&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단건 패키지: Premium Cover&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.9달러&lt;/li&gt;
&lt;li&gt;시안 생성 40장&lt;/li&gt;
&lt;li&gt;타이포그래피 생성 8회&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;정기 구독은 부담스럽고 표지를 하나만 만들고 싶다&amp;rdquo;는 작가님께 가장 적합합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Basic 플랜&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;월 9달러&lt;/li&gt;
&lt;li&gt;월 크레딧 800 (표지 80개 or 타이포 20개 생성)&lt;/li&gt;
&lt;li&gt;프로젝트 슬롯 3개&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 유료로 넘어가서 테스트해 보기 가장 무난한 단계입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Pro 플랜&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;월 19달러&lt;/li&gt;
&lt;li&gt;월 크레딧 2400 (표지 240개 or 타이포 60개 생성)&lt;/li&gt;
&lt;li&gt;프로젝트 슬롯 10개&lt;/li&gt;
&lt;li&gt;타이포그래피 사용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 대중적으로 쓰기 쉬운 구간입니다. 여러 작품을 동시에 만지거나, 한 작품에서 시안을 많이 비교해 보고 싶다면 이쪽이 더 현실적입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Studio 플랜&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;월 35달러&lt;/li&gt;
&lt;li&gt;월 크레딧 4500 (표지 450개 or 타이포 112개 생성)&lt;/li&gt;
&lt;li&gt;프로젝트 슬롯 사실상 무제한 수준&lt;/li&gt;
&lt;li&gt;타이포그래피 사용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 프로젝트를 동시에 돌리거나, 작업량이 꾸준히 많은 작가님께 맞는 플랜입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;크레딧 충전 상품&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;15달러: 1200 크레딧&lt;/li&gt;
&lt;li&gt;30달러: 2500 크레딧&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월 구독보다 &amp;ldquo;필요할 때만 충전해서 쓰고 싶다&amp;rdquo;는 작가님께 적합합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;추천 대상&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;좋아!&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;연재용 표지를 빠르게 만들고 싶은 웹소설 작가&lt;/li&gt;
&lt;li&gt;저예산으로 준수한 퀄리티의 표지를 뽑고 싶은 작가&lt;/li&gt;
&lt;li&gt;복잡한 외주 계약 등을 신경쓰고 싶지 않은 경우&lt;/li&gt;
&lt;li&gt;포토샵 없이 제목까지 한 번에 제작하고 싶은 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;나빠..&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음부터 100% 수작업 외주 퀄리티를 기대하는 경우&lt;/li&gt;
&lt;li&gt;AI 이미지 자체를 플랫폼 정책상 쓰기 어려운 연재처만 노리는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PYOZI AI는 단순 그림 AI라기보다는, 웹소설 표지에 맞춰진 표지 생성 AI입니다. 웹소설 표지를 빠르고 저렴하게, 준수한 퀄리티로 뽑고 싶은 작가님이시라면, 한 번 써볼 이유는 충분합니다. 특히 정기 구독이 부담되면 단건 패키지부터 테스트해 보는 방식도 괜찮습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pyozi.com/&quot;&gt;https://pyozi.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1774259398994&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;PYOZI AI | 웹소설 표지 AI 생성 솔루션&quot; data-og-description=&quot;AI가 웹소설 표지와 삽화를 자동으로 생성합니다. 로맨스판타지, BL, 무협, 현대판타지 등 다양한 장르 지원. 전문 일러스트레이터 수준의 퀄리티를 몇 분 만에!&quot; data-og-host=&quot;pyozi.com&quot; data-og-source-url=&quot;https://pyozi.com/&quot; data-og-url=&quot;https://pyozi.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dIurfK/dJMb82eMr40/2hHkAsJdkMDGSuYikERrkk/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379,https://scrap.kakaocdn.net/dn/bAIes8/dJMb9g5aAtj/o36QMz9RrzVxCkc9z4BPB0/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379&quot;&gt;&lt;a href=&quot;https://pyozi.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://pyozi.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dIurfK/dJMb82eMr40/2hHkAsJdkMDGSuYikERrkk/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379,https://scrap.kakaocdn.net/dn/bAIes8/dJMb9g5aAtj/o36QMz9RrzVxCkc9z4BPB0/img.png?width=1026&amp;amp;height=379&amp;amp;face=0_0_1026_379');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;PYOZI AI | 웹소설 표지 AI 생성 솔루션&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;AI가 웹소설 표지와 삽화를 자동으로 생성합니다. 로맨스판타지, BL, 무협, 현대판타지 등 다양한 장르 지원. 전문 일러스트레이터 수준의 퀄리티를 몇 분 만에!&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;pyozi.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/51</guid>
      <comments>https://dishost.tistory.com/51#entry51comment</comments>
      <pubDate>Fri, 6 Feb 2026 19:06:34 +0900</pubDate>
    </item>
    <item>
      <title>Discord.py Components V2 사용 가이드 (신형 임베드, 줄 나누기, 임베드 내에 버튼 등)</title>
      <link>https://dishost.tistory.com/50</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Components V2 아키텍처 및 LayoutView 시스템&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Discord API의 Components V2는 기존의 자동 레이아웃 방식에서 개발자가 직접 UI를 배치하는 수동 레이아웃 방식으로 바뀌었습니다.&lt;br /&gt;discord.py 2.6부터 추가된 discord.ui.LayoutView가 핵심이며, 기존 discord.ui.View의 자동 행 관리 방식을 완전히 바꿉니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 LayoutView 기본 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LayoutView는 Components V2에서 UI를 만들 때 사용하는 기본 컨테이너입니다. 기존 View는 컴포넌트를 추가하면 자동으로 5개의 Action Row에 배치했지만, LayoutView에서는 개발자가 직접 계층 구조와 배치를 정해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, UI 구성의 제어권이 라이브러리에서 개발자에게 넘어온 것입니다. 가장 중요한 제약은 컴포넌트 개수 제한인데, LayoutView 하나당 최대 40개까지 배치할 수 있습니다(중첩 포함). 기존 25개(5&amp;times;5)보다 늘어났지만, Container나 Section 같은 구조 컴포넌트도 카운트되므로 실제 사용 가능한 슬롯은 생각보다 적을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;b&gt;특성&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;discord.ui.View (V1)&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;discord.ui.LayoutView (V2)&lt;/b&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;기본 레이아웃&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;자동&lt;/td&gt;
&lt;td&gt;수동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;행(Row) 관리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;자동 처리&lt;/td&gt;
&lt;td&gt;ActionRow 명시 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;최대 컴포넌트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;25개 (5x5)&lt;/td&gt;
&lt;td&gt;40개 (중첩 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;계층 구조&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;평면적&lt;/td&gt;
&lt;td&gt;중첩 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Top-Level 요소&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Button, Select&lt;/td&gt;
&lt;td&gt;TextDisplay, Container, Section, ActionRow 등&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 클래스 기반 UI 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LayoutView는 클래스 속성으로 UI를 정의하는 방식을 사용합니다. 코드 가독성이 좋고 UI 구조를 한눈에 파악하기 쉽습니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;import discord
from discord import ui

class TechnicalSpecLayout(ui.LayoutView):
    # Top-Level Component 1: TextDisplay
    header = ui.TextDisplay(
        label=&quot;시스템 상태 모니터링&quot;,
        style=discord.TextStyle.heading
    )

    # Top-Level Component 2: Separator
    separator = ui.Separator(spacing=discord.SeparatorSpacing.small)

    # Top-Level Component 3: Container (Nested)
    status_box = ui.Container(
        ui.TextDisplay(&quot;정상 작동 중: 99.9% Uptime&quot;)
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TextDisplay, Separator, Container 같은 V2 컴포넌트는 LayoutView의 최상위에 직접 배치할 수 있습니다. 하지만 Button이나 Select Menu는 최상위에 둘 수 없고, 반드시 ActionRow 안에 넣어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 동적 아이템 관리와 ID 시스템&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Components V2의 모든 컴포넌트는 고유한 숫자형 id를 가집니다(custom_id와는 별개). 중첩된 구조에서 특정 컴포넌트를 찾으려면 find_item(id) 메서드를 사용하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# ID 기반 컴포넌트 탐색 예시
TRACKER_ID = 10101

class DynamicCounter(ui.LayoutView):
    def __init__(self):
        super().__init__()
        self.count = 0

    # ID를 명시적으로 할당하여 추후 검색 가능하게 설정
    display = ui.TextDisplay(&quot;Count: 0&quot;, id=TRACKER_ID)

    actions = ui.ActionRow()

    @actions.button(label=&quot;증가&quot;)
    async def increment(self, interaction: discord.Interaction, button: ui.Button):
        self.count += 1
        # ID를 사용하여 깊은 계층의 컴포넌트 탐색
        display_item = self.view.find_item(TRACKER_ID)
        if isinstance(display_item, ui.TextDisplay):
            display_item.label = f&quot;Count: {self.count}&quot;
        await interaction.response.edit_message(view=self)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 ID 시스템 덕분에 UI 상태가 바뀔 때 전체를 다시 만들지 않고 특정 컴포넌트만 수정할 수 있습니다. walk_children()로 모든 자식 컴포넌트를 순회할 수도 있어서 일괄 변경이나 검증 로직 구현에 유용합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 텍스트 표시 컴포넌트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 Embed로 텍스트를 표시했지만, Components V2에서는 TextDisplay라는 전용 컴포넌트가 생겼습니다. 메시지 본문과 별개로 UI 레이아웃 안에 텍스트를 넣을 수 있어서 더 유연합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 TextDisplay 사용법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ui.TextDisplay는 Discord 마크다운을 지원하며, UI 안에서 라벨, 설명, 경고 문구 등을 표시할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Markdown 지원:&lt;/b&gt; Bold(**), Italic(*), Underline(__), Codeblock(```) 등을 포함한 표준 Discord Markdown을 렌더링합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배치 제약:&lt;/b&gt; 최상위 레벨에 위치하거나 Container, Section 내부에 중첩될 수 있습니다. ActionRow 내부에는 배치할 수 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# TextDisplay 활용 패턴
class InfoLayout(ui.LayoutView):
    # 일반 텍스트
    simple_text = ui.TextDisplay(&quot;기본 안내 메시지입니다.&quot;)

    # 마크다운 활용 강조
    alert_text = ui.TextDisplay(
        &quot;**경고:** 이 작업은 되돌릴 수 없습니다.\n`System.reset()` 함수가 호출됩니다.&quot;
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TextDisplay는 클릭 같은 상호작용이 안 되는 정적 컴포넌트입니다. custom_id로 이벤트 콜백을 등록할 수 없고, 정보 표시용으로만 씁니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 Separator로 간격 조정하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ui.Separator는 컴포넌트 사이의 간격을 조정하는 요소입니다. UI에서 여백은 가독성에 중요한데, V2에서는 이걸 명시적인 컴포넌트로 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Separator는 spacing과 visible 두 가지 핵심 속성을 통해 제어됩니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;b&gt;속성 (Attribute)&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;타입 (Type)&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;설명 (Description)&lt;/b&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;spacing&lt;/td&gt;
&lt;td&gt;SeparatorSpacing&lt;/td&gt;
&lt;td&gt;여백의 크기. small 또는 large 값을 가집니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;td&gt;bool&lt;/td&gt;
&lt;td&gt;True일 경우 가로 구분선(Divider) 렌더링. False일 경우 투명 여백만 적용됩니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;class FormattingLayout(ui.LayoutView):
    title = ui.TextDisplay(&quot;설정 메뉴&quot;)

    # 가시적인 구분선 (Divider)
    line = ui.Separator(visible=True, spacing=discord.SeparatorSpacing.small)

    option_1 = ui.TextDisplay(&quot;옵션 A&quot;)

    # 투명한 큰 여백 (Spacer 역할)
    spacer = ui.Separator(visible=False, spacing=discord.SeparatorSpacing.large)

    footer = ui.TextDisplay(&quot;Copyright 2025&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;discord.SeparatorSpacing은 small과 large 두 가지가 있습니다. small은 항목 간 작은 간격에, large는 큰 구분에 씁니다. visible=True와 spacing=large를 같이 쓰면 섹션을 명확하게 나눌 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 컨테이너와 그룹화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Components V2에서는 Section과 Container로 컴포넌트를 그룹화할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 Section: 텍스트와 액세서리 조합&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ui.Section은 텍스트와 액세서리 컴포넌트 하나를 가로로 배치하는 레이아웃입니다. 설정 화면의 &quot;라벨 - 버튼&quot; 패턴을 만들 때 유용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Content:&lt;/b&gt; 섹션 좌측에 표시될 텍스트입니다. 문자열을 전달하면 내부적으로 TextDisplay로 변환되고, TextDisplay 객체를 직접 전달할 수도 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Accessory:&lt;/b&gt; 섹션 우측에 표시될 상호작용 요소입니다. 주로 Button이나 Thumbnail을 씁니다. Select Menu는 액세서리로 쓸 수 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;nsis&quot;&gt;&lt;code&gt;class SettingsLayout(ui.LayoutView):
    # 문자열 직접 전달 및 버튼 액세서리
    toggle_section = ui.Section(
        &quot;자동 응답 모드 활성화&quot;,
        accessory=ui.Button(label=&quot;ON&quot;, style=discord.ButtonStyle.success)
    )

    # TextDisplay 객체 전달 및 썸네일 액세서리
    profile_section = ui.Section(
        ui.TextDisplay(&quot;**User:** AbstractUmbra&quot;),
        accessory=ui.Thumbnail(url=&quot;[https://example.com/avatar.png](https://example.com/avatar.png)&quot;) # 가상의 Thumbnail 컴포넌트
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Section을 쓰면 ActionRow와 TextDisplay를 따로 배치하지 않아도 리스트 형태의 UI를 쉽게 만들 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 Container: 테두리가 있는 박스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ui.Container는 다른 컴포넌트들을 담을 수 있는 박스입니다. Embed처럼 테두리가 있고, accent_color로 테두리 색상을 바꿀 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Container는 중첩해서 복잡한 레이아웃을 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# Container 서브클래싱 패턴
class UserCard(ui.Container):
    def __init__(self, username: str, role_color: int):
        super().__init__(accent_color=role_color)

        # 컨테이너 내부 컴포넌트 정의
        self.name = ui.TextDisplay(f&quot;Name: {username}&quot;)
        self.divider = ui.Separator(visible=True)
        self.status = ui.TextDisplay(&quot;Status: Online&quot;)

class Dashboard(ui.LayoutView):
    # 인스턴스화하여 LayoutView에 배치
    card_1 = UserCard(&quot;Admin&quot;, 0xFF0000)

    # 빈 공간
    space = ui.Separator(spacing=discord.SeparatorSpacing.large)

    card_2 = UserCard(&quot;Moderator&quot;, 0x00FF00)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Container 안에 Container를 또 넣을 수도 있지만, 컴포넌트 제한(40개)과 모바일 가독성을 생각하면 중첩은 최소화하는 게 좋습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 상호작용 컴포넌트와 ActionRow&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Components V2에서 Button과 Select Menu의 동작 방식은 V1과 같지만, 배치 방식에는 제약이 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 ActionRow 필수 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;V1에서는 View에 버튼을 추가하면 자동으로 행이 생성됐지만, V2에서는 Button과 Select Menu를 최상위에 둘 수 없습니다. 반드시 ui.ActionRow 안에 넣어야 합니다. ActionRow 하나당 최대 5개 슬롯을 가집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Button:&lt;/b&gt; 1개 슬롯 차지 (최대 5개/Row)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Select Menu:&lt;/b&gt; 5개 슬롯 전체 차지 (일반적). 따라서 하나의 ActionRow에는 하나의 Select Menu만 배치 가능합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 데코레이터로 ActionRow 구성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ActionRow 인스턴스를 만들고, 메서드 데코레이터로 버튼과 셀렉트 메뉴를 추가하는 게 기본 패턴입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4.2.1 Button 구현&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;class InteractionLayout(ui.LayoutView):
    # ActionRow 선언
    actions = ui.ActionRow()

    # 데코레이터로 버튼 부착
    @actions.button(label=&quot;승인&quot;, style=discord.ButtonStyle.primary)
    async def approve(self, interaction: discord.Interaction, button: ui.Button):
        await interaction.response.send_message(&quot;승인되었습니다.&quot;, ephemeral=True)

    @actions.button(label=&quot;거절&quot;, style=discord.ButtonStyle.danger)
    async def deny(self, interaction: discord.Interaction, button: ui.Button):
        await interaction.response.send_message(&quot;거절되었습니다.&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4.2.2 Select Menu 구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;select 데코레이터를 사용하여 String Select, User Select, Role Select 등 다양한 드롭다운 메뉴를 구현합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;class SelectionLayout(ui.LayoutView):
    # Select 전용 Row
    select_row = ui.ActionRow()

    @select_row.select(
        placeholder=&quot;카테고리 선택&quot;,
        min_values=1,
        max_values=1,
        options=
    )
    async def category_select(self, interaction: discord.Interaction, select: ui.Select):
        chosen = select.values
        await interaction.response.send_message(f&quot;선택: {chosen}&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널이나 사용자를 선택하는 특수 셀렉트 메뉴는 cls 파라미터를 통해 타입을 지정합니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;    @select_row.select(cls=ui.ChannelSelect, channel_types=)
    async def channel_pick(self, interaction: discord.Interaction, select: ui.ChannelSelect):
        #... 구현...&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 동적으로 ActionRow 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데코레이터 대신 add_item 메서드로 런타임에 동적으로 ActionRow를 구성할 수도 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;def create_dynamic_row(labels: list[str]) -&amp;gt; ui.ActionRow:
    row = ui.ActionRow()
    for label in labels:
        btn = ui.Button(label=label)
        # 콜백 별도 지정 필요
        row.add_item(btn)
    return row

class DynamicLayout(ui.LayoutView):
    def __init__(self, options):
        super().__init__()
        # 초기화 시점에 동적 Row 추가
        self.add_item(create_dynamic_row(options))&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 미디어 갤러리 (MediaGallery)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Components V2에서는 ui.MediaGallery로 첨부 파일을 갤러리 형태로 보여줄 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.1 MediaGallery 구성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MediaGallery는 MediaGalleryItem 객체들의 집합으로 구성됩니다. 각 아이템은 URL을 통해 외부 이미지를 참조하거나, attachment:// 스키마를 통해 업로드된 파일을 참조할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;class GalleryLayout(ui.LayoutView):
    # 파일 객체 준비 (전송 시 files 파라미터에 포함되어야 함)
    # 주의: 레이아웃 정의 시점에는 파일이 업로드되지 않았으므로
    # 실제 전송 시점의 attachment 논리를 이해해야 함.

    gallery = ui.MediaGallery(
        # URL 기반 아이템
        discord.MediaGalleryItem(&quot;[https://example.com/image1.png](https://example.com/image1.png)&quot;, description=&quot;외부 이미지&quot;),

        # 첨부 파일 기반 아이템 (스포일러 설정 가능)
        discord.MediaGalleryItem(&quot;attachment://local_image.jpg&quot;, spoiler=True)
    )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;discord.File 객체를 MediaGalleryItem 생성자에 바로 전달할 수도 있지만, 메시지 전송 시 files=[...] 파라미터에는 여전히 포함시켜야 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. LayoutView 마이그레이션과 예제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 ui.View 코드를 ui.LayoutView로 바꿀 때는 다음 단계를 따르면 됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;상속 변경:&lt;/b&gt; class MyView(ui.View) -&amp;gt; class MyView(ui.LayoutView).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;버튼/셀렉트 래핑:&lt;/b&gt; 최상위 레벨의 @ui.button 데코레이터를 제거하고, ui.ActionRow() 인스턴스를 생성한 후 @action_row_instance.button 형태로 변경합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;레이아웃 요소 추가:&lt;/b&gt; TextDisplay나 Separator를 추가하여 기존 Embed description에 의존하던 텍스트를 컴포넌트로 이관합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6.1 종합 예제: 서버 관리 대시보드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 V2의 주요 컴포넌트를 모두 사용한 대시보드 코드입니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import discord
from discord.ext import commands
from discord import ui

# 봇 인스턴스 설정
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix=&quot;!&quot;, intents=intents)

class ServerControlPanel(ui.LayoutView):
    &quot;&quot;&quot;
    서버 관리를 위한 Components V2 기반 대시보드 레이아웃
    &quot;&quot;&quot;

    #  헤더 영역 (TextDisplay)
    header = ui.TextDisplay(
        &quot;##  ️ 서버 제어 패널&quot;, 
        style=discord.TextStyle.paragraph
    )

    #  시각적 분리 (Separator)
    sep_header = ui.Separator(visible=True, spacing=discord.SeparatorSpacing.small)

    #  상태 모니터링 섹션 (Container + Section)
    class StatusContainer(ui.Container):
        def __init__(self):
            super().__init__(accent_color=0x5865F2) # Blurple Color

            self.cpu_section = ui.Section(
                &quot;CPU 사용량&quot;,
                accessory=ui.Button(label=&quot;34%&quot;, disabled=True, style=discord.ButtonStyle.secondary)
            )
            self.ram_section = ui.Section(
                &quot;RAM 사용량&quot;,
                accessory=ui.Button(label=&quot;12GB / 16GB&quot;, disabled=True, style=discord.ButtonStyle.secondary)
            )

    status_box = StatusContainer()

    #  여백 (Spacer)
    spacer = ui.Separator(visible=False, spacing=discord.SeparatorSpacing.large)

    #  관리 기능 선택 (ActionRow + Select)
    menu_row = ui.ActionRow()

    @menu_row.select(
        placeholder=&quot;수행할 작업을 선택하세요...&quot;,
        options=
    )
    async def action_select(self, interaction: discord.Interaction, select: ui.Select):
        selection = select.values
        # interaction handling logic
        await interaction.response.send_message(f&quot;선택된 작업: {selection}&quot;, ephemeral=True)

    #  긴급 제어 (ActionRow + Button)
    control_row = ui.ActionRow()

    @control_row.button(label=&quot;서버 재시작&quot;, style=discord.ButtonStyle.danger)
    async def reboot(self, interaction: discord.Interaction, button: ui.Button):
        await interaction.response.send_message(&quot;서버 재시작 시퀀스를 시작합니다...&quot;, ephemeral=True)

    @control_row.button(label=&quot;새로고침&quot;, style=discord.ButtonStyle.secondary)
    async def refresh(self, interaction: discord.Interaction, button: ui.Button):
        # 뷰 업데이트 예시
        await interaction.response.edit_message(view=self)

@bot.command()
async def panel(ctx):
    &quot;&quot;&quot;대시보드 출력 명령어&quot;&quot;&quot;
    view = ServerControlPanel()
    await ctx.send(view=view)

# 봇 실행 (토큰은 환경변수 등으로 관리 권장)
# bot.run(&quot;YOUR_TOKEN_HERE&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6.2 코드 포인트&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;중첩 클래스:&lt;/b&gt; StatusContainer를 내부나 외부에 정의해서 모듈화된 UI를 만들 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비활성 버튼:&lt;/b&gt; disabled=True인 버튼은 뱃지나 상태 표시기처럼 쓸 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;레이아웃 흐름:&lt;/b&gt; 헤더 -&amp;gt; 구분선 -&amp;gt; 컨테이너 -&amp;gt; 공백 -&amp;gt; 셀렉트 -&amp;gt; 버튼 순으로 수직 배치됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 성능 고려사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Components V2 사용 시 주의할 점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Payload Size:&lt;/b&gt; LayoutView는 기존 View보다 복잡한 JSON을 생성합니다. 중첩된 컨테이너와 많은 TextDisplay는 API 요청 크기를 키우므로, 40개 제한 전에 페이로드 크기 제한에 걸릴 수 있습니다.3&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Rendering Cost:&lt;/b&gt; V2 컴포넌트는 렌더링 비용이 더 높습니다. MediaGallery나 복잡한 Container 중첩은 저사양 모바일에서 느려질 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Interaction Latency:&lt;/b&gt; LayoutView는 상태가 없어서 상호작용 시 오버헤드는 적지만, find_item을 너무 많이 쓰지 않는 게 좋습니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/50</guid>
      <comments>https://dishost.tistory.com/50#entry50comment</comments>
      <pubDate>Sat, 6 Dec 2025 16:57:10 +0900</pubDate>
    </item>
    <item>
      <title>Discord.js Components V2 사용 방법 (신형 임베드, 줄 나누기, 임베드 내에 버튼 등)</title>
      <link>https://dishost.tistory.com/49</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Component V2 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 디스코드 봇 개발에서는 Embed(임베드)를 주로 사용했습니다. 하지만 임베드는 제목, 본문, 썸네일 등의 위치가 고정되어 있어 레이아웃을 마음대로 바꾸기 어려웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Component V2&lt;/b&gt;는 이러한 제약 없이 개발자가 원하는 순서대로 텍스트, 버튼, 이미지 등을 배치할 수 있는 새로운 시스템입니다. Embed 대신 Container를 사용하며, 특히 텍스트 바로 옆에 버튼을 배치하는 등 더 자유로운 디자인이 가능합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 필수 설정 (플래그)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;V2 시스템을 사용하려면 코드를 작성할 때 반드시 플래그(Flag)를 설정해야 합니다. 이 설정이 없으면 코드가 정확해도 디스코드가 이를 V1(기존 방식)으로 인식하여 오류가 발생하거나 메시지가 보이지 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;필수 코드 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지를 전송하는 옵션에 flags: MessageFlags.IsComponentsV2를 포함해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { 
    Client, 
    MessageFlags, // [중요] V2 활성화를 위한 플래그
    InteractionReplyOptions,
    CommandInteraction
} from 'discord.js';

async function sendV2Message(interaction: CommandInteraction) {
    const payload: InteractionReplyOptions = {
        // 1. V2 모드 활성화 (필수)
        flags: MessageFlags.IsComponentsV2,

        // 2. 컴포넌트 배열
        // V2가 활성화되면 'content'나 'embeds' 필드는 사용할 수 없으며,
        // 오직 'components' 배열에만 내용을 담아야 합니다.
        components: [ /* 생성한 컴포넌트들을 이곳에 넣습니다 */ ]
    };

    return payload;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 핵심 컴포넌트 4가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;V2 레이아웃을 구성하는 주요 클래스들입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Container (컨테이너)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존의 Embed를 대체하는 최상위 컴포넌트입니다.&lt;/li&gt;
&lt;li&gt;이 안에 텍스트, 섹션, 구분선 등을 담아서 한 번에 보냅니다.&lt;/li&gt;
&lt;li&gt;setAccentColor로 좌측의 색상 띠를 설정할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TextDisplay (텍스트 디스플레이)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면에 글자를 표시하는 컴포넌트입니다.&lt;/li&gt;
&lt;li&gt;# 제목, **굵게** 등 마크다운 문법을 지원합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Section (섹션)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;텍스트와 &quot;액세서리(버튼 또는 썸네일)&quot;를 가로 한 줄에 배치할 때 사용합니다.&lt;/li&gt;
&lt;li&gt;텍스트 우측에 바로 버튼을 둘 수 있어 공간 활용에 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Separator (구분선)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컴포넌트 사이에 가로선을 긋거나, 여백(공백)을 추가하여 간격을 조절합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실전 예제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제 1: 기본 컨테이너 (기존 임베드 대체)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기본적인 형태입니다. Container 안에 TextDisplay를 넣어 메시지를 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { 
    ContainerBuilder, 
    TextDisplayBuilder, 
    MessageFlags 
} from 'discord.js';

export async function sendSimpleNotice(interaction: any) {
    // 1. 표시할 텍스트 생성
    const noticeText = new TextDisplayBuilder()
      .setContent(&quot;#   공지사항\n이번 업데이트로 UI가 변경되었습니다.&quot;);

    // 2. 컨테이너 생성 및 텍스트 추가
    const container = new ContainerBuilder()
      .setAccentColor(0x0099FF) // 파란색 띠 설정
      .addTextDisplayComponents(noticeText); // 위에서 만든 텍스트를 컨테이너에 추가

    // 3. 전송
    await interaction.reply({
        components: [container],
        flags: MessageFlags.IsComponentsV2 // [필수] 플래그 설정
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제 2: 섹션 활용 (텍스트 옆에 버튼 배치)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설명 텍스트 바로 오른쪽에 버튼을 배치하는 방법입니다. SectionBuilder를 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;import { 
    ContainerBuilder, 
    SectionBuilder, 
    TextDisplayBuilder, 
    ButtonBuilder, 
    ButtonStyle, 
    MessageFlags 
} from 'discord.js';

export async function sendInlineButton(interaction: any) {
    // 1. 섹션 좌측에 들어갈 설명 텍스트
    const description = new TextDisplayBuilder()
      .setContent(&quot;**디스코드 봇 호스팅**\n월 5,000원에 이용해보세요.&quot;);

    // 2. 우측에 배치할 버튼
    const subscribeBtn = new ButtonBuilder()
      .setCustomId('sub_plan_basic')
      .setLabel('구독하기')
      .setStyle(ButtonStyle.Primary); // 보라색 버튼

    // 3. 섹션(Section) 생성 및 조합
    // 텍스트와 버튼을 하나의 '섹션'으로 묶습니다.
    const productSection = new SectionBuilder()
      .addTextDisplayComponents(description) // 텍스트 추가
      .setPrimaryButtonAccessory(subscribeBtn); // 버튼을 액세서리로 설정

    // 4. 컨테이너에 담기
    const container = new ContainerBuilder()
      .setAccentColor(0x5865F2)
      .addSectionComponents(productSection); // 텍스트 대신 섹션을 추가

    await interaction.reply({
        components: [container],
        flags: MessageFlags.IsComponentsV2
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제 3: 복합 레이아웃 (드롭다운과 구분선)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 컴포넌트를 조합하는 방법입니다. 드롭다운 메뉴(Select Menu)는 Section 내부에 넣을 수 없으므로, 별도의 ActionRow에 담아 Container에 추가해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { 
    ContainerBuilder, 
    TextDisplayBuilder, 
    SeparatorBuilder, 
    SeparatorSpacingSize,
    ActionRowBuilder,
    StringSelectMenuBuilder,
    MessageFlags 
} from 'discord.js';

export async function sendComplexLayout(interaction: any) {
    // 1. 제목 텍스트
    const title = new TextDisplayBuilder().setContent(&quot;###  ️ 설정 메뉴&quot;);

    // 2. 구분선 (실선)
    const line = new SeparatorBuilder()
      .setSpacing(SeparatorSpacingSize.Small) // 좁은 간격
      .setDivider(true); // true면 실선이 보임

    // 3. 드롭다운 메뉴 생성 (ActionRow 필요)
    const selectMenu = new StringSelectMenuBuilder()
      .setCustomId('config_menu')
      .setPlaceholder('변경할 설정을 선택하세요')
      .addOptions(
            { label: '알림 설정', value: 'noti' },
            { label: '언어 설정', value: 'lang' }
        );

    // 드롭다운은 ActionRow로 감싸야 컨테이너에 넣을 수 있습니다.
    const menuRow = new ActionRowBuilder().addComponents(selectMenu);

    // 4. 여백 (투명한 공백)
    const spacer = new SeparatorBuilder()
      .setSpacing(SeparatorSpacingSize.Large) // 넓은 간격
      .setDivider(false); // false면 선 없이 공백만 생김

    // 5. 바닥글 텍스트
    const footer = new TextDisplayBuilder()
      .setContent(&quot;설정은 즉시 저장됩니다.&quot;);

    // 6. 컨테이너에 순서대로 추가
    const container = new ContainerBuilder()
      .addTextDisplayComponents(title)       // 제목
      .addSeparatorComponents(line)          // 선
      .addActionRowComponents(menuRow)       // 드롭다운 메뉴
      .addSeparatorComponents(spacer)        // 공백
      .addTextDisplayComponents(footer);     // 바닥글

    await interaction.reply({
        components: [container],
        flags: MessageFlags.IsComponentsV2
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 자주 묻는 질문 (FAQ)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. 기존 EmbedBuilder는 사용할 수 없나요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A. 네, flags: MessageFlags.IsComponentsV2를 사용하면 기존 embeds 필드는 무시됩니다. V2 레이아웃을 사용하려면 ContainerBuilder를 사용해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. 섹션(Section) 안에 드롭다운 메뉴도 넣을 수 있나요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A. 불가능합니다. 섹션의 액세서리 자리에는 버튼과 **썸네일(작은 이미지)**만 배치할 수 있습니다. 드롭다운 메뉴는 ActionRow에 담아서 컨테이너에 별도로 추가해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. 큰 이미지는 어떻게 넣나요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A. MediaGallery 컴포넌트를 사용합니다. 컨테이너의 addMediaGalleryComponents() 메서드를 사용하여 이미지 URL들을 배열로 추가하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. 모바일 화면 호환성은 어떤가요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A. PC에서는 Section 내부의 텍스트와 버튼이 가로로 나란히 보이지만, 화면이 좁은 모바일 기기에서는 텍스트 아래로 버튼이 줄바꿈 되어 표시될 수 있습니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.js</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/49</guid>
      <comments>https://dishost.tistory.com/49#entry49comment</comments>
      <pubDate>Sat, 6 Dec 2025 15:58:40 +0900</pubDate>
    </item>
    <item>
      <title>디스호스트 봇 리스트 - 한국 디스코드 봇을 위한 통합 플랫폼</title>
      <link>https://dishost.tistory.com/48</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇을 만들었지만 사용자 유입이 없다면, 그건 기술의 문제가 아니라 발견성의 문제입니다. 디스호스트 리스트는 한국 디스코드 봇 생태계를 위한 통합 디렉토리 플랫폼으로, 검색 엔진 최적화와 커뮤니티 기반 평가 시스템을 통해 봇의 가시성을 높이는 데 집중하고 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디스호스트 봇&amp;nbsp;리스트는 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트 봇&amp;nbsp;리스트는 한국어 디스코드 봇을 위한 오픈 디렉토리입니다. 단순히 봇을 나열하는 것을 넘어, SEO 최적화, 사용자 기반 평가 시스템, 카테고리별 큐레이션을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://list.dishost.kr/&quot;&gt;https://list.dishost.kr/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762604388813&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;디스호스트 봇 리스트 - 최고의 디스코드 봇을 찾아보세요&quot; data-og-description=&quot;디스코드 봇 추천 및 검색 플랫폼. 관리, 음악, 게임 등 다양한 카테고리의 디스코드 봇을 탐색하고 투표하세요.&quot; data-og-host=&quot;list.dishost.kr&quot; data-og-source-url=&quot;https://list.dishost.kr/&quot; data-og-url=&quot;https://list.dishost.kr&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Vn6iV/hyZMtIj8i1/0Hix4LyrrLtYvYteTTnUck/img.png?width=1212&amp;amp;height=1212&amp;amp;face=0_0_1212_1212&quot;&gt;&lt;a href=&quot;https://list.dishost.kr/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://list.dishost.kr/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Vn6iV/hyZMtIj8i1/0Hix4LyrrLtYvYteTTnUck/img.png?width=1212&amp;amp;height=1212&amp;amp;face=0_0_1212_1212');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;디스호스트 봇 리스트 - 최고의 디스코드 봇을 찾아보세요&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;디스코드 봇 추천 및 검색 플랫폼. 관리, 음악, 게임 등 다양한 카테고리의 디스코드 봇을 탐색하고 투표하세요.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;list.dishost.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 등록해야 하는가&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 검색 엔진 노출&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트 리스트는 검색 엔진 최적화를 기본으로 설계되었습니다. 각 봇의 상세 페이지는 독립적인 메타데이터를 가지며, 구조화된 데이터를 통해 검색 엔진이 콘텐츠를 이해할 수 있도록 합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SEO 구현 방식&lt;/h4&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;// app/bots/[discordBotId]/page.tsx
export async function generateMetadata({ params }): Promise&amp;lt;Metadata&amp;gt; {
  const bot = await fetchBotData(params.discordBotId);

  return {
    title: `${bot.name} - 디스코드 봇 | 디스호스트 리스트`,
    description: bot.shortDescription,
    keywords: [bot.name, &quot;디스코드 봇&quot;, ...bot.tags],
    openGraph: {
      title: `${bot.name} - 디스코드 봇`,
      description: bot.shortDescription,
      images: [{ url: bot.avatar }],
      type: &quot;website&quot;,
    },
    twitter: {
      card: &quot;summary_large_image&quot;,
      title: `${bot.name}`,
      description: bot.shortDescription,
      images: [bot.avatar],
    },
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Schema.org 구조화 데이터&lt;/h4&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;const schemaData = {
  &quot;@context&quot;: &quot;https://schema.org&quot;,
  &quot;@type&quot;: &quot;SoftwareApplication&quot;,
  name: bot.name,
  description: bot.longDescription,
  applicationCategory: &quot;BotApplication&quot;,
  operatingSystem: &quot;Discord&quot;,
  offers: {
    &quot;@type&quot;: &quot;Offer&quot;,
    price: &quot;0&quot;,
    priceCurrency: &quot;KRW&quot;,
  },
  aggregateRating:
    bot.totalVotes &amp;gt; 0
      ? {
          &quot;@type&quot;: &quot;AggregateRating&quot;,
          ratingValue: &quot;5&quot;,
          reviewCount: bot.totalVotes,
        }
      : undefined,
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 설정은 &quot;디스코드 음악 봇&quot;, &quot;디스코드 관리 봇&quot; 같은 검색어에서 봇 상세 페이지가 자연스럽게 노출될 수 있도록 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 투표 기반 순위 시스템&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트 리스트는 사용자 투표를 기반으로 봇의 순위를 결정합니다. 투표 시스템은 단순하지만 몇 가지 중요한 제약을 두고 있습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;투표수의 영향&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;투표수는 두 가지 방식으로 활용됩니다. 첫째, 봇 목록 페이지에서 정렬 기준으로 사용할 수 있습니다. 둘째, Schema.org의 &lt;code&gt;aggregateRating&lt;/code&gt;에 투표수가 반영되어 검색 엔진에 신뢰도 지표로 전달됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;투표 기능은 단순히 숫자를 늘리는 것이 아니라, 실제 사용자의 평가를 반영하는 메커니즘으로 작동합니다. 봇 개발자 입장에서는 양질의 서비스를 제공하면 자연스럽게 투표가 증가하는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 투표 API를 통해 자신의 디스코드 봇에서 보상을 제공하는 등 다양한 활용이 가능합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 봇 팩을 통한 큐레이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 팩(Bot Packs)은 용도별로 봇을 묶어 제공하는 큐레이션 시스템입니다. 사용자가 &quot;게임 커뮤니티 서버팩&quot;이나 &quot;음악 감상 서버팩&quot; 같은 팩을 선택하면, 해당 목적에 맞는 봇들이 한 번에 표시됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 제공되는 봇 팩:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;게임 커뮤니티 서버팩&lt;/li&gt;
&lt;li&gt;음악 감상 서버팩&lt;/li&gt;
&lt;li&gt;스트리머 &amp;amp; 팬 서버팩&lt;/li&gt;
&lt;li&gt;친목 &amp;amp; 소통 서버팩&lt;/li&gt;
&lt;li&gt;미니게임 &amp;amp; 경제 서버팩&lt;/li&gt;
&lt;li&gt;대형 서버 관리팩&lt;/li&gt;
&lt;li&gt;기업 &amp;amp; 커뮤니티 서버팩&lt;/li&gt;
&lt;li&gt;보이스 활동 서버팩&lt;/li&gt;
&lt;li&gt;학습 &amp;amp; 스터디 서버팩&lt;/li&gt;
&lt;li&gt;신규 서버 스타터팩&lt;/li&gt;
&lt;li&gt;글로벌 다국어 서버팩&lt;/li&gt;
&lt;li&gt;올인원 다기능 서버팩&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 팩에 포함되면 개별 검색 없이도 주제별 페이지에서 노출되므로, 타겟 사용자에게 도달하기 쉬워집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;봇 등록 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;웹 인터페이스를 통한 등록&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://list.dishost.kr/submit&quot;&gt;https://list.dishost.kr/submit&lt;/a&gt; 접속&lt;/li&gt;
&lt;li&gt;Discord OAuth2 로그인&lt;/li&gt;
&lt;li&gt;봇 Discord ID 입력&lt;/li&gt;
&lt;li&gt;카테고리 선택 (MUSIC, MODERATION, UTILITY 등)&lt;/li&gt;
&lt;li&gt;태그 선택 (최대 10개)&lt;/li&gt;
&lt;li&gt;설명 작성 (짧은 설명 필수, 긴 설명 선택)&lt;/li&gt;
&lt;li&gt;링크 입력 (지원 서버, GitHub, 웹사이트)&lt;/li&gt;
&lt;li&gt;제출 후 소유권 인증&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개발자를 위한 API&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트 리스트는 봇 개발자를 위한 두 가지 주요 API를 제공합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 서버 수 업데이트 API&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇의 서버 수를 자동으로 업데이트할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;POST https://listapi.dishost.kr/bots/stats
Headers:
  X-API-Key: YOUR_API_KEY
  Content-Type: application/json

Body:
{
  &quot;server_count&quot;: 1234
}

Response:
{
  &quot;message&quot;: &quot;통계가 업데이트되었습니다.&quot;,
  &quot;isCertified&quot;: true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용 예제 (Discord.js):&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;const axios = require(&quot;axios&quot;);

client.on(&quot;ready&quot;, () =&amp;gt; {
  // 봇 시작 시 서버 수 업데이트
  updateStats(client.guilds.cache.size);

  // 1시간마다 자동 업데이트
  setInterval(() =&amp;gt; {
    updateStats(client.guilds.cache.size);
  }, 60 * 60 * 1000);
});

async function updateStats(serverCount) {
  try {
    await axios.post(
      &quot;https://listapi.dishost.kr/bots/stats&quot;,
      { server_count: serverCount },
      {
        headers: {
          &quot;X-API-Key&quot;: &quot;YOUR_API_KEY&quot;,
          &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
      }
    );
  } catch (error) {
    console.error(&quot;통계 업데이트 실패:&quot;, error.message);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 투표 여부 확인 API&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 유저가 오늘 투표했는지 확인할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;GET https://listapi.dishost.kr/bots/check-vote?user_id={DISCORD_USER_ID}
Headers:
  X-API-Key: YOUR_API_KEY

Response:
{
  &quot;voted&quot;: true,
  &quot;votedAt&quot;: &quot;2025-11-08T12:34:56Z&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용 예제 (투표 리워드 시스템):&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;client.on(&quot;messageCreate&quot;, async (message) =&amp;gt; {
  if (message.content === &quot;!리워드&quot;) {
    const response = await fetch(
      `https://listapi.dishost.kr/bots/check-vote?user_id=${message.author.id}`,
      {
        headers: { &quot;X-API-Key&quot;: &quot;YOUR_API_KEY&quot; },
      }
    );
    const data = await response.json();

    if (data.voted) {
      message.reply(&quot;투표 감사합니다! 리워드가 지급되었습니다.&quot;);
      // 리워드 지급 로직
    } else {
      message.reply(
        &quot;먼저 투표해주세요! https://list.dishost.kr/bots/YOUR_BOT_ID&quot;
      );
    }
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;API 키 발급 방법&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;디스코드로 로그인&lt;/li&gt;
&lt;li&gt;봇 등록 페이지에서 봇 ID 입력&lt;/li&gt;
&lt;li&gt;발급받은 인증 코드를 봇 프로필의 &quot;About Me&quot;에 추가&lt;/li&gt;
&lt;li&gt;&quot;내 봇&quot; 페이지에서 &quot;인증&quot; 버튼 클릭&lt;/li&gt;
&lt;li&gt;API 키 발급 (한 번만 표시되므로 안전하게 보관)&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;디스호스트 인증 배지&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트 호스팅의 IP 주소에서 통계를 제출하면 자동으로 &quot;디스호스트 인증&quot; 배지가 부여됩니다. 3일 이상 통계를 업데이트하지 않으면 인증이 해제됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 자세한 API 문서는 &lt;a href=&quot;https://list.dishost.kr/docs/api&quot;&gt;https://list.dishost.kr/docs/api&lt;/a&gt; 에서 확인할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SEO 최적화 - 기술적 세부사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Dynamic Sitemap 생성&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/sitemap.ts
export default async function sitemap(): Promise&amp;lt;MetadataRoute.Sitemap&amp;gt; {
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || &quot;https://list.dishost.kr&quot;;

  // 모든 봇 목록 가져오기
  const botsResponse = await fetch(`${apiUrl}/bots?limit=1000`, {
    next: { revalidate: 3600 }, // 1시간마다 갱신
  });
  const bots = await botsResponse.json();

  // 봇 상세 페이지 URL 생성
  const botUrls = bots.items.map((bot) =&amp;gt; ({
    url: `${baseUrl}/bots/${bot.discordBotId}`,
    lastModified: new Date(bot.updatedAt),
    changeFrequency: &quot;daily&quot; as const,
    priority: 0.8,
  }));

  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: &quot;daily&quot;,
      priority: 1,
    },
    ...botUrls,
    // ... 봇팩 URL도 동일하게 추가
  ];
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ISR (Incremental Static Regeneration)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 60초마다 봇 데이터 자동 갱신
const botData = await fetch(`${apiUrl}/bots/${discordBotId}`, {
  next: { revalidate: 60 },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 설정을 통해 구글 검색 결과에 봇 상세 페이지가 노출되며, 리치 스니펫도 지원됩니다. 소셜 미디어에 공유할 때는 자동으로 프리뷰 이미지와 설명이 표시됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발자 대시보드 기능 (예정)&lt;/h2&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커뮤니티 생태계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개발자를 위한 리소스&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API 문서: RESTful API 레퍼런스&lt;/li&gt;
&lt;li&gt;개발자 디스코드: 봇 개발자 커뮤니티&lt;/li&gt;
&lt;li&gt;블로그: 디스코드 봇 개발 관련 글&lt;/li&gt;
&lt;li&gt;튜토리얼: 봇 개발 가이드&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용자를 위한 기능&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카테고리별 검색: MUSIC, MODERATION, UTILITY 등 9개 카테고리&lt;/li&gt;
&lt;li&gt;태그 시스템: 세부 기능별 필터링&lt;/li&gt;
&lt;li&gt;봇팩: 용도별 추천 봇 묶음&lt;/li&gt;
&lt;li&gt;투표 시스템: 커뮤니티 평가 기반 순위&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트 리스트는 검색 엔진 최적화, 투표 시스템, API 통합을 기본으로 제공하는 봇 디렉토리 플랫폼입니다. 등록은 무료이며, 모든 기능을 제한 없이 사용할 수 있습니다.&lt;/p&gt;</description>
      <category>디스코드</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/48</guid>
      <comments>https://dishost.tistory.com/48#entry48comment</comments>
      <pubDate>Sat, 8 Nov 2025 21:19:51 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 봇 24시간 무료 호스팅, 디스호스트</title>
      <link>https://dishost.tistory.com/47</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;안녕하세요, 디스호스트입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스코드 봇을 개발했지만 24시간 안정적으로 운영할 방법을 찾지 못해 고민하고 계신가요? PC를 계속 켜두는 것은 비효율적이고, 유료 호스팅 서비스는 부담스러우실 겁니다. 디스호스트는 이러한 문제를 해결하기 위해 탄생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;a href=&quot;https://dishost.kr/&quot;&gt;https://dishost.kr/&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762595884710&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;디스호스트 - 쉽고 안정적인 디스코드 봇 호스팅 서비스&quot; data-og-description=&quot;누구나 무료로디스코드 봇을 만들 수 있어요 가입만 하면 즉시 무료로 디스코드 봇을 호스팅할 수 있습니다. 신용카드도, 복잡한 설정도 필요 없습니다.&quot; data-og-host=&quot;dishost.kr&quot; data-og-source-url=&quot;https://dishost.kr/&quot; data-og-url=&quot;https://dishost.kr/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://dishost.kr/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dishost.kr/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;디스호스트 - 쉽고 안정적인 디스코드 봇 호스팅 서비스&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;누구나 무료로디스코드 봇을 만들 수 있어요 가입만 하면 즉시 무료로 디스코드 봇을 호스팅할 수 있습니다. 신용카드도, 복잡한 설정도 필요 없습니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dishost.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;1. 디스호스트란?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트는 &lt;b&gt;디스코드 봇 전용 무료 호스팅 플랫폼&lt;/b&gt;입니다. 개인 개발자부터 소규모 커뮤니티까지, 누구나 자신의 봇을 안정적으로 운영할 수 있도록 지원합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2614&quot; data-origin-height=&quot;1382&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpECUX/dJMcaf5ZyXc/ySYRyl0hekr0UgmTvJOMCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpECUX/dJMcaf5ZyXc/ySYRyl0hekr0UgmTvJOMCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpECUX/dJMcaf5ZyXc/ySYRyl0hekr0UgmTvJOMCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpECUX%2FdJMcaf5ZyXc%2FySYRyl0hekr0UgmTvJOMCK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2614&quot; height=&quot;1382&quot; data-origin-width=&quot;2614&quot; data-origin-height=&quot;1382&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;핵심 장점&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;완전 무료 호스팅&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;기본 호스팅은 완전 무료입니다. 포인트 시스템을 통해 커뮤니티 활동만으로도 충분히 서비스를 이용하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;99.9%의 안정성&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;지난 6개월간 99.9%의 업타임을 기록했습니다. 봇이 예기치 않게 종료되더라도 자동 재시작 기능으로 서비스 연속성을 보장합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;간편한 배포 프로세스&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;복잡한 서버 설정 없이 몇 번의 클릭만으로 봇을 배포할 수 있습니다. Pterodactyl 기반의 직관적인 패널을 제공합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;한국어 완벽 지원&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;모든 인터페이스와 문서가 한국어로 제공됩니다. 국내 사용자 커뮤니티를 통해 빠른 지원을 받을 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;템플릿 마켓플레이스&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;검증된 봇 템플릿을 제공하여 코딩 경험이 없어도 즉시 봇을 운영할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;다양한 언어 지원&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;Python과 Node.js 프로젝트를 모두 지원하며, SQLite 데이터베이스를 기본으로 제공합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;2. 디스호스트 시작하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트를 시작하는 과정은 간단합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;회원가입 및 인증&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;디스코드 OAuth 로그인&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트 웹사이트에서 디스코드 계정으로 간편하게 로그인하세요.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;디스호스트 디스코드 서버 가입&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;서비스 이용을 위해 공식 디스코드 서버에 가입해야 합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1864&quot; data-origin-height=&quot;644&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c3TyBU/dJMcafEVbA8/iUZJvvq7E30XOuZnEa7jv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c3TyBU/dJMcafEVbA8/iUZJvvq7E30XOuZnEa7jv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c3TyBU/dJMcafEVbA8/iUZJvvq7E30XOuZnEa7jv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc3TyBU%2FdJMcafEVbA8%2FiUZJvvq7E30XOuZnEa7jv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1864&quot; height=&quot;644&quot; data-origin-width=&quot;1864&quot; data-origin-height=&quot;644&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;3&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;휴대폰 인증&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;서비스 보안과 어뷰징 방지를 위해 휴대폰 인증이 필요합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Pterodactyl 패널 계정 생성&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;봇을 관리할 Pterodactyl 패널 계정을 생성합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2844&quot; data-origin-height=&quot;1250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Bcigi/dJMcahbFs80/STbkTnrkzxtwmU78JsOY30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Bcigi/dJMcahbFs80/STbkTnrkzxtwmU78JsOY30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Bcigi/dJMcahbFs80/STbkTnrkzxtwmU78JsOY30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBcigi%2FdJMcahbFs80%2FSTbkTnrkzxtwmU78JsOY30%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2844&quot; height=&quot;1250&quot; data-origin-width=&quot;2844&quot; data-origin-height=&quot;1250&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;3. 패널 사용방법 및 첫 봇 배포&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;봇 생성 방법&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트는 두 가지 봇 생성 방법을 제공합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2196&quot; data-origin-height=&quot;1072&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bD1iuP/dJMcahiqZ3Z/Nzey5jkkGmmiYxKZdKfaV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bD1iuP/dJMcahiqZ3Z/Nzey5jkkGmmiYxKZdKfaV1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bD1iuP/dJMcahiqZ3Z/Nzey5jkkGmmiYxKZdKfaV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbD1iuP%2FdJMcahiqZ3Z%2FNzey5jkkGmmiYxKZdKfaV1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2196&quot; height=&quot;1072&quot; data-origin-width=&quot;2196&quot; data-origin-height=&quot;1072&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;방법 1: 대시보드에서 직접 생성&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;대시보드에서 '내 봇 생성하기'를 클릭하여 새 봇을 만들 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2182&quot; data-origin-height=&quot;910&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DLNuD/dJMcakzsJZW/peq0Ru199DrvUyghlAlVg1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DLNuD/dJMcakzsJZW/peq0Ru199DrvUyghlAlVg1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DLNuD/dJMcakzsJZW/peq0Ru199DrvUyghlAlVg1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDLNuD%2FdJMcakzsJZW%2Fpeq0Ru199DrvUyghlAlVg1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2182&quot; height=&quot;910&quot; data-origin-width=&quot;2182&quot; data-origin-height=&quot;910&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;방법 2: 마켓플레이스에서 템플릿 선택&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;검증된 템플릿을 선택하여 즉시 봇을 생성할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;서버 관리 기능&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2126&quot; data-origin-height=&quot;1568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dCRJ7I/dJMb99MpBiU/f6WqGZLDiTm7NgzwCMlmB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dCRJ7I/dJMb99MpBiU/f6WqGZLDiTm7NgzwCMlmB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dCRJ7I/dJMb99MpBiU/f6WqGZLDiTm7NgzwCMlmB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdCRJ7I%2FdJMb99MpBiU%2Ff6WqGZLDiTm7NgzwCMlmB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2126&quot; height=&quot;1568&quot; data-origin-width=&quot;2126&quot; data-origin-height=&quot;1568&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;2026년부터 도입된 내장형 서버 관리 시스템을 통해, 외부 사이트로 가지 않고도 모든 기능을 이용하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2164&quot; data-origin-height=&quot;1600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biWtcD/dJMcagSlhZR/jCqK7ktn8ScCZnMVeuKimk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biWtcD/dJMcagSlhZR/jCqK7ktn8ScCZnMVeuKimk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biWtcD/dJMcagSlhZR/jCqK7ktn8ScCZnMVeuKimk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiWtcD%2FdJMcagSlhZR%2FjCqK7ktn8ScCZnMVeuKimk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2164&quot; height=&quot;1600&quot; data-origin-width=&quot;2164&quot; data-origin-height=&quot;1600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;메인 화면에서는 서버 로그, 서버 전원 관리, 리소스 사용량 등을 보실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;또한 오른쪽 상단의 가이드 버튼을 통해 &lt;b&gt;인터렉티브 가이드&lt;/b&gt;를 확인하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2104&quot; data-origin-height=&quot;736&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxozvM/dJMcahDGoXG/AtLJKtZ4ibQ51uqjzk9KGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxozvM/dJMcahDGoXG/AtLJKtZ4ibQ51uqjzk9KGk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxozvM/dJMcahDGoXG/AtLJKtZ4ibQ51uqjzk9KGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxozvM%2FdJMcahDGoXG%2FAtLJKtZ4ibQ51uqjzk9KGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2104&quot; height=&quot;736&quot; data-origin-width=&quot;2104&quot; data-origin-height=&quot;736&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;파일 탭에서 파일을 업로드하거나 수정하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2094&quot; data-origin-height=&quot;532&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bS9m8H/dJMcaf6WOUJ/Gj9uJ9vSG5aEQMZFrjd3P1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bS9m8H/dJMcaf6WOUJ/Gj9uJ9vSG5aEQMZFrjd3P1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bS9m8H/dJMcaf6WOUJ/Gj9uJ9vSG5aEQMZFrjd3P1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbS9m8H%2FdJMcaf6WOUJ%2FGj9uJ9vSG5aEQMZFrjd3P1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2094&quot; height=&quot;532&quot; data-origin-width=&quot;2094&quot; data-origin-height=&quot;532&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;스퀘줄 탭에서는 정해진 시각마다 재부팅 등의 동작을 하도록 설정하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;4. 포인트 시스템&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트는 공정한 리소스 분배를 위해 포인트 시스템을 운영합니다. 포인트는 커뮤니티 활동을 통해 충분히 획득할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2176&quot; data-origin-height=&quot;1086&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PZrMC/dJMcacamiXd/p46QkJZ5Q9mQZBJxKtBNeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PZrMC/dJMcacamiXd/p46QkJZ5Q9mQZBJxKtBNeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PZrMC/dJMcacamiXd/p46QkJZ5Q9mQZBJxKtBNeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPZrMC%2FdJMcacamiXd%2Fp46QkJZ5Q9mQZBJxKtBNeK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2176&quot; height=&quot;1086&quot; data-origin-width=&quot;2176&quot; data-origin-height=&quot;1086&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;포인트 획득 방법&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;채팅 참여&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;#자유-채팅 등에서 메시지를 보낼 때마다 +10P&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;1분 쿨타임 적용&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;하루 최대 50회(500P)까지 적립&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;서버 태그&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;매일 자정, 디스호스트 서버 태그를 달고 있으면 +80P&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;주간 갱신&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;봇을 갱신할 때마다 주차별로 포인트 지급&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;1주차 갱신: +200P&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;2주차 갱신: +300P&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;3주차 갱신: +400P&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;4주차 이상 갱신: +500P&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;답변 채택&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;#질문과-답변 채널에서 답변이 채택되면 +800P&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;추천인 등록&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;다른 사용자가 본인을 추천인으로 등록하면 +2,000P&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;서버 부스팅&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스코드 서버 부스팅 시 +5,000P&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;등급 시스템&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;보유 포인트에 따라 자동으로 등급이 상승합니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;새싹 개발자&lt;/b&gt;: 0P 이상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;주니어 개발자&lt;/b&gt;: 3,000P 이상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;시니어 개발자&lt;/b&gt;: 10,000P 이상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;베테랑&lt;/b&gt;: 20,000P 이상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;마스터&lt;/b&gt;: 50,000P 이상&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;포인트 사용처&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2174&quot; data-origin-height=&quot;1018&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8Ooug/dJMcae0jFtJ/vkjO15kl8hJOb2oLEfhdAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8Ooug/dJMcae0jFtJ/vkjO15kl8hJOb2oLEfhdAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8Ooug/dJMcae0jFtJ/vkjO15kl8hJOb2oLEfhdAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8Ooug%2FdJMcae0jFtJ%2FvkjO15kl8hJOb2oLEfhdAk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2174&quot; height=&quot;1018&quot; data-origin-width=&quot;2174&quot; data-origin-height=&quot;1018&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;리소스 업그레이드&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;항목&lt;/span&gt;&lt;/th&gt;
&lt;th&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;효과&lt;/span&gt;&lt;/th&gt;
&lt;th&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;기간&lt;/span&gt;&lt;/th&gt;
&lt;th&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;포인트&lt;/span&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;메모리 부스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;+128MB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;7일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;1,000P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;메모리 부스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;+128MB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;30일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;3,500P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;CPU 부스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;+0.25 Core&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;7일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;800P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;CPU 부스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;+0.25 Core&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;30일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;3,000P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스크 부스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;+512MB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;7일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;700P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스크 부스트&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;+512MB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;30일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;2,500P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;봇 슬롯 확장&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;기본적으로 1개의 봇을 무료로 호스팅할 수 있습니다. 추가 봇이 필요한 경우:&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;항목&lt;/span&gt;&lt;/th&gt;
&lt;th&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;기간&lt;/span&gt;&lt;/th&gt;
&lt;th&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;포인트&lt;/span&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;추가 봇 슬롯&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;7일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;2,000P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;추가 봇 슬롯&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;30일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;7,000P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;추가 봇 슬롯&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;90일&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;18,000P&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;5. 템플릿 마켓플레이스&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2192&quot; data-origin-height=&quot;922&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LWzE9/dJMcahW2xVi/0lwdNcy4DTM9LqQSRQloGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LWzE9/dJMcahW2xVi/0lwdNcy4DTM9LqQSRQloGk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LWzE9/dJMcahW2xVi/0lwdNcy4DTM9LqQSRQloGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLWzE9%2FdJMcahW2xVi%2F0lwdNcy4DTM9LqQSRQloGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2192&quot; height=&quot;922&quot; data-origin-width=&quot;2192&quot; data-origin-height=&quot;922&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트는 즉시 사용 가능한 검증된 봇 템플릿을 제공합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;티켓 시스템 봇&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;1:1 문의 티켓 시스템 템플릿입니다. 고객 지원, 외주 문의 등 다양한 용도로 활용 가능하며, JSON 파일 기반 상태 관리를 사용합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;주요 기능&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;티켓 생성 및 관리&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;카테고리별 분류&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;관리자 전용 명령어&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;티켓 로그 기록&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스코드 관리 봇&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;서버 관리에 필요한 핵심 기능을 모두 갖춘 템플릿입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;주요 기능&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;환영 메시지 &amp;amp; 자동 역할 부여&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;반응 역할 시스템&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;기본 관리 명령어 (킥, 밴, 뮤트 등)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;서버 설정 관리&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;고급 로그 봇&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;모듈형 로깅 봇 템플릿입니다. 서버의 거의 모든 활동을 상세히 기록할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;주요 기능&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;메시지 수정/삭제 로그&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;멤버 입장/퇴장 로그&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;역할 변경 로그&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;채널 설정 변경 로그&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;개별 이벤트 ON/OFF 기능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;추첨(Giveaway) 봇&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;슬래시 커맨드 기반의 추첨 봇 템플릿입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;주요 기능&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;커맨드로 추첨 생성&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;다중 추첨 관리&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;봇 재시작 시 자동 복구&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;당첨자 자동 추첨 및 공지&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;6. 디스코드 커뮤니티&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2254&quot; data-origin-height=&quot;790&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vhxlA/dJMcaiPbk4F/rdOQOHiUvV5y6Egb9WT59k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vhxlA/dJMcaiPbk4F/rdOQOHiUvV5y6Egb9WT59k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vhxlA/dJMcaiPbk4F/rdOQOHiUvV5y6Egb9WT59k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvhxlA%2FdJMcaiPbk4F%2FrdOQOHiUvV5y6Egb9WT59k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2254&quot; height=&quot;790&quot; data-origin-width=&quot;2254&quot; data-origin-height=&quot;790&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트는 활발한 디스코드 커뮤니티를 운영하고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;커뮤니티 채널 구성&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;공지 카테고리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;공지&lt;/b&gt;: 서비스 주요 업데이트 및 공지사항&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;시작-가이드&lt;/b&gt;: 서비스 이용 가이드 및 튜토리얼&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;규칙&lt;/b&gt;: 커뮤니티 이용 규칙&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;포인트-랭킹&lt;/b&gt;: 실시간 포인트 랭킹 확인&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;디스호스트 호스팅 카테고리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;업데이트&lt;/b&gt;: 호스팅 서비스 업데이트 내역&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;서비스-상태&lt;/b&gt;: 실시간 서비스 상태 확인&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;봇-명령어&lt;/b&gt;: 디스호스트 봇 명령어 안내&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;템플릿-공유&lt;/b&gt;: 사용자 간 봇 템플릿 공유&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;디스호스트 커뮤니티 카테고리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;자유-채팅&lt;/b&gt;: 자유로운 소통 공간 (포인트 획득 가능)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;코딩-채팅&lt;/b&gt;: 개발 관련 대화&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;질문과-답변&lt;/b&gt;: 호스팅 및 개발 질문 (답변 채택 시 포인트 지급)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;구인구직&lt;/b&gt;: 외주 및 협업 구인/구직&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;봇-쇼케이스&lt;/b&gt;: 봇 홍보 및 소개&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;봇-서포트&lt;/b&gt;: 봇 관련 지원 요청&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;커뮤니티 활용 방법&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1094&quot; data-origin-height=&quot;1272&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbVZL5/dJMcaestISN/eq6ohNov8zQdl1cBuNMbT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbVZL5/dJMcaestISN/eq6ohNov8zQdl1cBuNMbT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbVZL5/dJMcaestISN/eq6ohNov8zQdl1cBuNMbT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbVZL5%2FdJMcaestISN%2Feq6ohNov8zQdl1cBuNMbT1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1094&quot; height=&quot;1272&quot; data-origin-width=&quot;1094&quot; data-origin-height=&quot;1272&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;기술 지원&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;호스팅 문제나 코딩 관련 질문을 #질문과-답변 채널에서 받을 수 있습니다. 답변이 채택되면 800P를 획득하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;네트워킹&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;다른 개발자들과 경험을 공유하고, 협업 기회를 찾을 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;봇 홍보&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;#봇-쇼케이스 채널에서 자신의 봇을 홍보하고 사용자를 모집할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;외주 구하기&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;#구인구직 채널에서 봇 개발 의뢰를 받거나, 개발자를 구할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;포인트 획득&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;커뮤니티 활동만으로도 충분한 포인트를 획득하여 서비스를 이용할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;7. 자주 묻는 질문&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 정말 무료인가요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. 네, 기본 봇 1개는 완전 무료로 호스팅할 수 있습니다. 추가 리소스나 봇 슬롯이 필요한 경우 커뮤니티 활동으로 획득한 포인트를 사용하시면 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 어떤 프로그래밍 언어를 지원하나요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. 현재 Python과 Node.js(JavaScript, TypeScript)를 지원합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 데이터베이스를 사용할 수 있나요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. SQLite 데이터베이스를 기본으로 제공합니다. 봇 파일 시스템 내에서 자유롭게 사용하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 업타임은 얼마나 보장되나요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. 지난 6개월간 99.9%의 업타임을 기록했습니다. 자동 재시작 기능으로 봇의 연속적인 운영을 보장합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 여러 개의 봇을 동시에 호스팅할 수 있나요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. 네, 가능합니다. 기본적으로 1개의 봇 슬롯이 제공되며, 추가 봇 슬롯은 포인트를 사용하여 확장할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 봇이 자동으로 재시작되나요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. 네, 오류로 인해 봇이 종료되어도 자동으로 재시작됩니다. 안정적인 운영을 위해 자동 재시작 기능이 기본으로 활성화되어 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 커뮤니티 활동으로 얼마나 포인트를 벌 수 있나요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. 매일 채팅(최대 500P), 서버 태그(80P), 주간 갱신(200~500P)만으로도 기본 운영에 충분한 포인트를 획득할 수 있습니다. 추천인 등록이나 답변 채택으로 더 많은 포인트를 얻을 수도 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;&lt;b&gt;Q. 패널 사용이 어렵지 않나요?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;A. Pterodactyl 기반의 직관적인 패널을 제공하며, #시작-가이드 채널에 상세한 튜토리얼이 준비되어 있습니다. 궁금한 점은 언제든 커뮤니티에서 질문하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트와 함께 시작하세요&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;470&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfvPf5/dJMcabWPasl/aP8Fksc8cJiFUxYUTxEQPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfvPf5/dJMcabWPasl/aP8Fksc8cJiFUxYUTxEQPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfvPf5/dJMcabWPasl/aP8Fksc8cJiFUxYUTxEQPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfvPf5%2FdJMcabWPasl%2FaP8Fksc8cJiFUxYUTxEQPK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;470&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;470&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;디스호스트는 국내 디스코드 봇 개발자들을 위한 최적의 무료 호스팅 솔루션입니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;복잡한 서버 설정 없이 간편하게 시작&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;99.9%의 높은 안정성으로 안심하고 운영&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;활발한 한국어 커뮤니티에서 실시간 지원&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;포인트 시스템으로 부담 없는 확장&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Nanum Gothic';&quot;&gt;지금 바로 &lt;a href=&quot;https://dishost.kr&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;디스호스트 공식 웹사이트&lt;/a&gt;를 방문하여 무료 호스팅을 시작해보세요.&lt;/span&gt;&lt;/p&gt;</description>
      <category>디스코드</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/47</guid>
      <comments>https://dishost.tistory.com/47#entry47comment</comments>
      <pubDate>Sat, 8 Nov 2025 19:11:24 +0900</pubDate>
    </item>
    <item>
      <title>디스호스트 뮤직봇 템플릿 사용 가이드</title>
      <link>https://dishost.tistory.com/45</link>
      <description>&lt;p&gt;이 가이드는 디스호스트에서 뮤직봇 템플릿을 사용하여 Discord 뮤직봇 서버를 생성하고 설정하는 방법을 설명합니다.&lt;/p&gt;
&lt;h2&gt;개요&lt;/h2&gt;
&lt;p&gt;디스호스트 뮤직봇 템플릿을 사용하면 YouTube 음악을 재생할 수 있는 Discord 봇을 쉽게 배포할 수 있습니다. 자신만의 음악 봇을 통해 안정적인 음악 스트리밍 환경을 구축할 수 있습니다.&lt;/p&gt;
&lt;h2&gt;준비사항&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Discord 봇 토큰&lt;/strong&gt;: Discord Developer Portal에서 생성&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.dishost.kr/15&quot;&gt;디스코드 봇 생성 및 초대 링크 만들기&lt;/a&gt; 해당 글을 참고하여 봇 토큰을 생성합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firefox 브라우저&lt;/strong&gt;: 쿠키 추출을 위해 필요&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;디스호스트 계정&lt;/strong&gt;: 서버 생성을 위해 필요&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;단계별 설정 가이드&lt;/h2&gt;
&lt;h3&gt;1단계: 뮤직봇 템플릿으로 서버 생성&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;디스호스트 대시보드 접속&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://dishost.kr&quot;&gt;디스호스트 웹사이트&lt;/a&gt;에 로그인합니다.&lt;/li&gt;
&lt;li&gt;대시보드에서 &amp;quot;새 서버 생성&amp;quot; 버튼을 클릭합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;템플릿 선택&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;템플릿 목록에서 뮤직봇 템플릿을 선택합니다.&lt;/li&gt;
&lt;li&gt;서버 생성을 완료합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;서버 확인&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;생성된 서버가 대시보드에 표시되는지 확인합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;2단계: Firefox로 YouTube 쿠키 추출&lt;/h3&gt;
&lt;p&gt;YouTube의 지역 제한을 우회하기 위해 쿠키 파일이 필요합니다.&lt;/p&gt;
&lt;h4&gt;1단계: 브라우저 확장 프로그램 설치&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Chrome&lt;/strong&gt;: &lt;a href=&quot;https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc&quot;&gt;Get cookies.txt LOCALLY&lt;/a&gt; 확장 프로그램 설치&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firefox&lt;/strong&gt;: &lt;a href=&quot;https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/&quot;&gt;cookies.txt&lt;/a&gt; 확장 프로그램 설치&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;⚠️ &lt;strong&gt;주의&lt;/strong&gt;: &amp;quot;Get cookies.txt&amp;quot; (not &amp;quot;LOCALLY&amp;quot;) Chrome 확장 프로그램은 악성코드로 신고되어 Chrome 웹스토어에서 제거되었습니다. 반드시 &amp;quot;LOCALLY&amp;quot; 버전을 사용하세요.&lt;/p&gt;
&lt;h4&gt;2단계: 쿠키 추출 과정&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;시크릿/사생활 보호 모드&lt;/strong&gt;로 새 창을 엽니다.&lt;/li&gt;
&lt;li&gt;해당 창에서 &lt;strong&gt;YouTube에 로그인&lt;/strong&gt;합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;같은 창, 같은 탭&lt;/strong&gt;에서 &lt;code&gt;https://www.youtube.com/robots.txt&lt;/code&gt;로 이동합니다.&lt;ul&gt;
&lt;li&gt;이 단계가 매우 중요합니다! 반드시 robots.txt 페이지에서 쿠키를 추출하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;확장 프로그램을 사용하여 &lt;strong&gt;youtube.com 쿠키를 추출&lt;/strong&gt;합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cookies.txt&lt;/code&gt; 파일로 저장합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;시크릿 창을 완전히 닫습니다&lt;/strong&gt; (세션이 다시 열리지 않도록)&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;3단계: 쿠키 파일 업로드&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;디스호스트 파일 관리자 접속&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버 대시보드에서 &amp;quot;파일 관리자&amp;quot; 탭을 클릭합니다.&lt;/li&gt;
&lt;li&gt;루트 디렉터리가 표시됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;쿠키 파일 업로드&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. &amp;quot;업로드&amp;quot; 버튼을 클릭합니다.
2. 앞서 다운로드한 cookies.txt 파일을 선택합니다.
3. 업로드가 완료될 때까지 기다립니다.&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;파일 위치 확인&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;업로드된 &lt;code&gt;cookies.txt&lt;/code&gt; 파일이 루트 디렉터리에 있는지 확인합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;4단계: Discord 봇 토큰 설정&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;환경 변수 파일 편집&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;파일 관리자에서 &lt;code&gt;.env&lt;/code&gt; 파일을 찾아 더블클릭하여 편집합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;봇 토큰 추가&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# Discord 봇 토큰을 입력하세요
DISCORD_TOKEN=여기에_봇_토큰을_입력하세요&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;파일 저장&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;5단계: 서버 실행&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;서버 시작&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버 대시보드의 &amp;quot;콘솔&amp;quot; 탭으로 이동합니다&lt;/li&gt;
&lt;li&gt;&amp;quot;시작&amp;quot; 버튼을 클릭하여 서버를 실행합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;실행 로그 확인&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;콘솔에서 다음과 같은 메시지가 표시되면 성공입니다:

[INFO] - dishost_musicbot - Bot is ready!
[INFO] - discord.client - Logged in as YourBotName#1234&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;오류 해결&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;토큰 오류: &lt;code&gt;.env&lt;/code&gt; 파일의 &lt;code&gt;DISCORD_TOKEN&lt;/code&gt; 값을 확인합니다&lt;/li&gt;
&lt;li&gt;쿠키 오류: &lt;code&gt;cookies.txt&lt;/code&gt; 파일이 올바르게 업로드되었는지 확인합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;  고급 설정&lt;/h2&gt;
&lt;h3&gt;쿠키 갱신&lt;/h3&gt;
&lt;p&gt;YouTube 쿠키는 시간이 지나면 만료됩니다. 주기적으로 갱신해야 합니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Firefox에서 새로운 쿠키 파일을 추출합니다&lt;/li&gt;
&lt;li&gt;기존 &lt;code&gt;cookies.txt&lt;/code&gt; 파일을 삭제합니다&lt;/li&gt;
&lt;li&gt;새로운 쿠키 파일을 업로드합니다&lt;/li&gt;
&lt;li&gt;서버를 재시작합니다&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;봇 권한 설정&lt;/h3&gt;
&lt;p&gt;Discord 서버에서 봇이 정상적으로 작동하려면 다음 권한이 필요합니다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;메시지 보내기&lt;/li&gt;
&lt;li&gt;슬래시 명령어 사용&lt;/li&gt;
&lt;li&gt;음성 채널 연결&lt;/li&gt;
&lt;li&gt;음성 채널에서 말하기&lt;/li&gt;
&lt;li&gt;활동 상태 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;❓ 문제 해결&lt;/h2&gt;
&lt;h3&gt;자주 발생하는 문제&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;봇이 음성 채널에 연결되지 않는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;봇 권한을 확인합니다&lt;/li&gt;
&lt;li&gt;음성 채널에 다른 사용자가 있는지 확인합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube 영상이 재생되지 않는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;쿠키 파일이 최신인지 확인합니다&lt;/li&gt;
&lt;li&gt;지역 제한된 영상인지 확인합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;봇이 응답하지 않는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버 콘솔에서 오류 로그를 확인합니다&lt;/li&gt;
&lt;li&gt;봇 토큰이 올바른지 확인합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;로그 확인&lt;/h3&gt;
&lt;p&gt;서버 콘솔에서 실시간으로 봇의 상태를 확인할 수 있습니다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
[INFO] - 정상 작동 메시지
[WARNING] - 주의가 필요한 상황
[ERROR] - 오류 발생
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;  지원&lt;/h2&gt;
&lt;p&gt;문제가 해결되지 않는 경우:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://discord.gg/dishost&quot;&gt;디스호스트 디스코드 서버&lt;/a&gt;에서 도움 요청&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.dishost.kr&quot;&gt;디스호스트 블로그&lt;/a&gt;에서 추가 가이드 확인&lt;/li&gt;
&lt;li&gt;서버 콘솔의 오류 로그를 첨부하여 문의&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;이 가이드는 디스호스트 뮤직봇 템플릿 v2.0 기준으로 작성되었습니다.&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>디스호스트 가이드</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/45</guid>
      <comments>https://dishost.tistory.com/45#entry45comment</comments>
      <pubDate>Wed, 30 Jul 2025 10:28:43 +0900</pubDate>
    </item>
    <item>
      <title>The Patch 디스코드 봇 : 게임 패치노트의 AI 요약을 빠르게! (게임 19종 지원)</title>
      <link>https://dishost.tistory.com/44</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;게임 커뮤니티를 운영하다 보면 가장 어려운 일 중 하나가 바로 최신 정보를 놓치지 않고 전달하는 것입니다. 새로운 패치가 나왔는데 뒤늦게 알게 되거나, 중요한 업데이트 소식을 놓쳐서 커뮤니티 구성원들이 불만을 표하는 경우가 생기기 마련입니다. 특히 여러 게임을 다루는 대형 서버라면 더욱 복잡해집니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;1050&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Jtxzf/btsO8Y7xsro/AmolYeVEmFpEh2ILybVP7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Jtxzf/btsO8Y7xsro/AmolYeVEmFpEh2ILybVP7K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Jtxzf/btsO8Y7xsro/AmolYeVEmFpEh2ILybVP7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJtxzf%2FbtsO8Y7xsro%2FAmolYeVEmFpEh2ILybVP7K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1050&quot; height=&quot;1050&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;1050&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게임 정보 전달의 현실적인 문제들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 커뮤니티 운영자라면 누구나 경험해본 상황들이 있습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;매일 수십 개의 게임 공식 사이트와 공지사항을 확인해야 하는 번거로움&lt;/li&gt;
&lt;li&gt;새벽에 올라온 패치노트를 놓쳐서 커뮤니티 반응이 늦어지는 상황&lt;/li&gt;
&lt;li&gt;패치노트가 너무 길고 복잡해서 핵심 내용을 파악하기 어려운 경우&lt;/li&gt;
&lt;li&gt;게임별로 다른 형식의 정보를 일관성 있게 전달하기 어려운 문제&lt;/li&gt;
&lt;li&gt;여러 게임을 동시에 관리해야 하는 운영진의 업무 부담&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 문제들을 해결하기 위해 개발된 것이 바로 &lt;b&gt;The Patch&lt;/b&gt; 디스코드 봇입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;The Patch, 무엇이 다른가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Patch는 단순한 알림 봇을 넘어선 지능형 게임 뉴스 전달 시스템입니다. 현재 &lt;b&gt;2,800개 이상의 서버&lt;/b&gt;에서 &lt;b&gt;30만 명 이상의 사용자&lt;/b&gt;가 활용하고 있으며, &lt;b&gt;99.9%의 높은 업타임&lt;/b&gt;을 자랑합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2208&quot; data-origin-height=&quot;1280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYB9Lb/btsO8829zsf/AT32yqvYzmUxwkKTr73Xk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYB9Lb/btsO8829zsf/AT32yqvYzmUxwkKTr73Xk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYB9Lb/btsO8829zsf/AT32yqvYzmUxwkKTr73Xk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYB9Lb%2FbtsO8829zsf%2FAT32yqvYzmUxwkKTr73Xk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2208&quot; height=&quot;1280&quot; data-origin-width=&quot;2208&quot; data-origin-height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 포괄적인 게임 지원&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Patch는 현재 19개의 인기 게임을 지원하며, 각 게임의 공식 채널을 24시간 모니터링합니다. 지원하는 게임들을 카테고리별로 살펴보면:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;메이저 게임:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;리그 오브 레전드&lt;/li&gt;
&lt;li&gt;로스트아크&lt;/li&gt;
&lt;li&gt;배틀그라운드&lt;/li&gt;
&lt;li&gt;발로란트&lt;/li&gt;
&lt;li&gt;오버워치 2&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;넥슨 게임:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메이플스토리&lt;/li&gt;
&lt;li&gt;FC온라인&lt;/li&gt;
&lt;li&gt;마비노기 모바일&lt;/li&gt;
&lt;li&gt;던전앤파이터&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;호요버스 게임:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;원신&lt;/li&gt;
&lt;li&gt;붕괴: 스타레일&lt;/li&gt;
&lt;li&gt;붕괴 3rd&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스팀 게임:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이터널 리턴&lt;/li&gt;
&lt;li&gt;레인보우 식스 시즈&lt;/li&gt;
&lt;li&gt;VRChat&lt;/li&gt;
&lt;li&gt;DJMAX RESPECT V&lt;/li&gt;
&lt;li&gt;에이펙스 레전드&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2364&quot; data-origin-height=&quot;926&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmaowX/btsO8Y0OIm3/GdnkvVPwQ3JfJQIKSENd1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmaowX/btsO8Y0OIm3/GdnkvVPwQ3JfJQIKSENd1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmaowX/btsO8Y0OIm3/GdnkvVPwQ3JfJQIKSENd1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdmaowX%2FbtsO8Y0OIm3%2FGdnkvVPwQ3JfJQIKSENd1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2364&quot; height=&quot;926&quot; data-origin-width=&quot;2364&quot; data-origin-height=&quot;926&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. AI 기반 스마트 요약&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패치노트를 읽다 보면 개발자들이 기술적인 내용을 자세히 설명하다 보니 핵심 내용을 파악하기 어려운 경우가 많습니다. The Patch는 이런 문제를 AI를 활용해 해결합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;1232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QEwGc/btsO9IJVm9c/QdnSx5DFIM1dS2pVcnP3uk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QEwGc/btsO9IJVm9c/QdnSx5DFIM1dS2pVcnP3uk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QEwGc/btsO9IJVm9c/QdnSx5DFIM1dS2pVcnP3uk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQEwGc%2FbtsO9IJVm9c%2FQdnSx5DFIM1dS2pVcnP3uk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1350&quot; height=&quot;1232&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;1232&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 긴 패치노트에서 중요한 변경사항만 골라내어 &lt;b&gt;핵심 내용을 추출&lt;/b&gt;하고, 기술적인 용어를 일반 사용자도 이해할 수 있도록 &lt;b&gt;이해하기 쉬운 한국어로 변환&lt;/b&gt;합니다. 또한 밸런스 변경, 신규 콘텐츠, 버그 수정 등 항목별로 분류해서 전달하는 &lt;b&gt;중요도 분류&lt;/b&gt; 기능을 제공합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 채널별 맞춤 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대형 게임 커뮤니티에서는 게임별로 전용 채널을 운영하는 경우가 많습니다. The Patch는 이런 구조에 최적화되어 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1804&quot; data-origin-height=&quot;876&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mEZr0/btsPa45ZlaA/5xHNbSH0mQzvjfX3OczUUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mEZr0/btsPa45ZlaA/5xHNbSH0mQzvjfX3OczUUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mEZr0/btsPa45ZlaA/5xHNbSH0mQzvjfX3OczUUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmEZr0%2FbtsPa45ZlaA%2F5xHNbSH0mQzvjfX3OczUUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1804&quot; height=&quot;876&quot; data-origin-width=&quot;1804&quot; data-origin-height=&quot;876&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 명령어:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/채널 설정&lt;/code&gt; - 특정 게임의 알림을 받을 채널 설정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/채널 해제&lt;/code&gt; - 특정 게임의 알림 해제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/채널 목록&lt;/code&gt; - 현재 설정된 모든 알림 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/테스트 패치노트&lt;/code&gt; - 패치노트 발송 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시:&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;/채널 설정 게임:리그오브레전드 채널:#lol-news
/채널 설정 게임:로스트아크 채널:#lostark-updates
/채널 설정 게임:발로란트 채널:#valorant-patch&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 안전한 권한 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇의 설정 변경은 채널 관리 권한을 가진 사용자만 할 수 있도록 제한되어 있습니다. 이는 서버 운영진의 의도와 다르게 설정이 변경되는 것을 방지하며, 무분별한 설정 변경으로 인한 혼란을 막습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;24시간 무중단 서비스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Patch는 99.9%의 높은 업타임을 유지하며 24시간 무중단으로 서비스를 제공합니다. 이는 매시간 각 게임의 공식 채널을 &lt;b&gt;자동으로 모니터링&lt;/b&gt;하고, 새로운 업데이트 발견 시 즉시 설정된 채널에 전달하는 &lt;b&gt;즉시 알림&lt;/b&gt; 기능, 그리고 클라우드 기반 인프라로 &lt;b&gt;안정적인 서버&lt;/b&gt; 환경을 제공하는 기술적 특징 때문입니다.&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설정 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Patch를 서버에 추가하고 설정하는 방법은 매우 간단합니다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;봇 초대&lt;/b&gt;: &lt;a href=&quot;https://discord.com/oauth2/authorize?client_id=1234567890&amp;amp;permissions=2048&amp;amp;scope=bot%20applications.commands&quot;&gt;The Patch 봇 초대 링크&lt;/a&gt;를 클릭하여 서버에 추가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;권한 설정&lt;/b&gt;: 봇에 필요한 권한 부여 (메시지 전송, 슬래시 명령어 사용 등)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;채널 설정&lt;/b&gt;: &lt;code&gt;/채널 설정&lt;/code&gt; 명령어로 각 게임별 알림 채널 지정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;완료&lt;/b&gt;: 설정 완료 후 즉시 서비스 이용 가능&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커뮤니티와 지원&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Patch는 활발한 커뮤니티와 지원 시스템을 운영하고 있습니다. 사용법 문의, 피드백, 건의사항 등을 논의할 수 있는 &lt;a href=&quot;https://discord.gg/thepatch&quot;&gt;The Patch 지원 서버&lt;/a&gt;를 운영하며, 한국 디스코드 봇 플랫폼인 &lt;a href=&quot;https://koreanbots.dev/bots/thepatch&quot;&gt;Korean Bots&lt;/a&gt;에 등록되어 있습니다. 또한 사용자 피드백을 바탕으로 한 &lt;b&gt;지속적인 업데이트&lt;/b&gt;를 통해 기능을 개선하고 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;앞으로의 계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Patch는 현재도 지속적으로 발전하고 있습니다. 더 많은 게임을 지원하도록 &lt;b&gt;지원 게임을 확대&lt;/b&gt;하고, 더욱 정확하고 유용한 요약을 제공하는 &lt;b&gt;AI 요약 기능을 개선&lt;/b&gt;할 예정입니다. 또한 개인별 알림 설정 옵션을 추가하는 &lt;b&gt;사용자 맞춤 설정&lt;/b&gt; 기능과, 웹에서 설정을 관리할 수 있는 &lt;b&gt;웹 대시보드&lt;/b&gt; 개발을 계획하고 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 커뮤니티 운영은 결코 쉬운 일이 아닙니다. 하지만 The Patch와 같은 도구를 활용하면 운영진의 부담을 크게 줄이면서도 커뮤니티 구성원들에게 더 나은 서비스를 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 30만 명 이상의 사용자가 The Patch를 통해 게임 정보를 받아보고 있으며, 매일 수많은 새로운 서버가 The Patch를 도입하고 있습니다. 이는 The Patch가 단순한 봇을 넘어 게임 커뮤니티 운영의 필수 도구로 자리잡았음을 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 커뮤니티 운영에 어려움을 겪고 있다면, The Patch를 한 번 시도해보는 것을 추천합니다. 몇 분의 설정으로 운영진의 업무 효율성을 크게 향상시킬 수 있을 것입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;The Patch는 무료로 제공되는 서비스입니다. 자세한 정보와 사용법은 &lt;a href=&quot;https://thepatch.bot&quot;&gt;The Patch 공식 웹사이트&lt;/a&gt;에서 확인하실 수 있습니다.&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;관련 링크&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;공식 웹사이트&lt;/b&gt;: &lt;a href=&quot;https://thepatch.notepads.kr&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://thepatch.notepads.kr&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;봇 초대 링크&lt;/b&gt;: &lt;a href=&quot;https://discord.com/oauth2/authorize?client_id=931227185509982288&amp;amp;permissions=448824596544&amp;amp;scope=bot%20applications.commands&quot;&gt;The Patch 초대하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지원 서버&lt;/b&gt;: &lt;a href=&quot;https://discord.gg/NqZ2YKDEnY&quot;&gt;The Patch 지원 서버&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;한국 디스코드 리스트&lt;/b&gt;: &lt;a href=&quot;https://koreanbots.dev/bots/931227185509982288&quot;&gt;한국 디스코드 리스트에서 The Patch 확인하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>디스코드</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/44</guid>
      <comments>https://dishost.tistory.com/44#entry44comment</comments>
      <pubDate>Wed, 9 Jul 2025 10:29:24 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 16. 스케줄러로 자동 메시지 보내기 (node-cron): 봇이 알아서 일하게 만들자!</title>
      <link>https://dishost.tistory.com/42</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 드디어 우리 Discord.js 봇 개발 튜토리얼의 마지막 시간이 되었습니다. 지난 시간에는 웹 기반 관리자 패널을 만들어봤는데, 이제 정말 완성도 높은 봇의 모습을 갖추게 되었죠.&lt;/p&gt;
&lt;p&gt;마지막으로 다룰 주제는 &lt;strong&gt;스케줄링&lt;/strong&gt;입니다. 매일 정해진 시간에 알림을 보내거나, 주기적으로 데이터를 백업하거나, 특정 조건에 따라 자동으로 작업을 수행하는 기능 말이에요.&lt;/p&gt;
&lt;p&gt;node-cron이라는 라이브러리를 사용해서 다양한 스케줄링 기능을 구현해보겠습니다. 리눅스의 cron과 비슷한 문법을 사용하지만, JavaScript 환경에서 더 유연하게 활용할 수 있어요.&lt;/p&gt;
&lt;h2&gt;node-cron이란?&lt;/h2&gt;
&lt;p&gt;node-cron은 Node.js에서 cron 작업을 수행할 수 있게 해주는 라이브러리입니다. 정해진 시간이나 주기에 따라 함수를 자동으로 실행할 수 있어요.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cron 표현식:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;* * * * * *
| | | | | |
| | | | | +-- 요일 (0-7, 0과 7은 일요일)
| | | | +---- 월 (1-12)
| | | +------ 일 (1-31)
| | +-------- 시 (0-23)
| +---------- 분 (0-59)
+------------ 초 (0-59, 선택사항)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;자주 사용되는 패턴들:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&amp;#39;0 0 * * *&amp;#39;&lt;/code&gt; - 매일 자정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;#39;0 9 * * *&amp;#39;&lt;/code&gt; - 매일 오전 9시&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;#39;0 0 * * 1&amp;#39;&lt;/code&gt; - 매주 월요일 자정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;#39;*/30 * * * *&amp;#39;&lt;/code&gt; - 30분마다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;#39;0 */2 * * *&amp;#39;&lt;/code&gt; - 2시간마다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;실제 봇에서 어떻게 활용할 수 있는지 차근차근 알아보겠습니다.&lt;/p&gt;
&lt;h2&gt;패키지 설치 및 기본 설정&lt;/h2&gt;
&lt;p&gt;먼저 필요한 패키지를 설치해보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install node-cron
npm install -D @types/node-cron&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;간단한 스케줄러 구현&lt;/h2&gt;
&lt;p&gt;보일러플레이트의 &lt;code&gt;scheduler.ts&lt;/code&gt; 파일을 간단하게 수정해보겠습니다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/scheduler.ts
import cron from &amp;quot;node-cron&amp;quot;;
import { Client, TextChannel, EmbedBuilder } from &amp;quot;discord.js&amp;quot;;

export function startScheduledJobs(client: Client) {
  // 매일 오전 9시에 일일 체크인 메시지
  cron.schedule(&amp;quot;0 9 * * *&amp;quot;, async () =&amp;gt; {
    await sendDailyMessage(client);
  });

  // 매 시간마다 봇 상태 업데이트
  cron.schedule(&amp;quot;0 * * * *&amp;quot;, async () =&amp;gt; {
    await updateBotActivity(client);
  });

  // 매주 월요일 오전 10시에 주간 리포트
  cron.schedule(&amp;quot;0 10 * * 1&amp;quot;, async () =&amp;gt; {
    await sendWeeklyReport(client);
  });

  console.log(&amp;quot;스케줄러가 시작되었습니다.&amp;quot;);
}

// 일일 메시지 발송
async function sendDailyMessage(client: Client) {
  try {
    const channelId = &amp;quot;1234567890123456789&amp;quot;; // 실제 채널 ID로 변경
    const channel = client.channels.cache.get(channelId) as TextChannel;

    if (!channel) {
      console.log(&amp;quot;채널을 찾을 수 없습니다.&amp;quot;);
      return;
    }

    const embed = new EmbedBuilder()
      .setTitle(&amp;quot;  좋은 아침!&amp;quot;)
      .setDescription(&amp;quot;새로운 하루가 시작되었습니다!&amp;quot;)
      .setColor(0x00ff00)
      .setTimestamp();

    await channel.send({ embeds: [embed] });
    console.log(&amp;quot;일일 메시지를 발송했습니다.&amp;quot;);
  } catch (error) {
    console.error(&amp;quot;일일 메시지 발송 실패:&amp;quot;, error);
  }
}

// 봇 상태 업데이트
async function updateBotActivity(client: Client) {
  try {
    const activities = [
      &amp;quot;공부 시간 측정 중&amp;quot;,
      `${client.guilds.cache.size}개 서버 관리 중`,
      `${client.users.cache.size}명의 사용자와 함께`,
      &amp;quot;24시간 대기 중&amp;quot;,
    ];

    const randomActivity =
      activities[Math.floor(Math.random() * activities.length)];
    client.user?.setActivity(randomActivity, { type: 3 }); // 3: Watching

    console.log(`봇 상태 업데이트: ${randomActivity}`);
  } catch (error) {
    console.error(&amp;quot;봇 상태 업데이트 실패:&amp;quot;, error);
  }
}

// 주간 리포트 발송
async function sendWeeklyReport(client: Client) {
  try {
    const channelId = &amp;quot;1234567890123456789&amp;quot;; // 실제 채널 ID로 변경
    const channel = client.channels.cache.get(channelId) as TextChannel;

    if (!channel) {
      console.log(&amp;quot;채널을 찾을 수 없습니다.&amp;quot;);
      return;
    }

    const embed = new EmbedBuilder()
      .setTitle(&amp;quot;  주간 리포트&amp;quot;)
      .setDescription(&amp;quot;이번 주 활동 요약입니다&amp;quot;)
      .setColor(0x0099ff)
      .addFields(
        {
          name: &amp;quot;서버 수&amp;quot;,
          value: client.guilds.cache.size.toString(),
          inline: true,
        },
        {
          name: &amp;quot;사용자 수&amp;quot;,
          value: client.users.cache.size.toString(),
          inline: true,
        },
        { name: &amp;quot;응답 시간&amp;quot;, value: `${client.ws.ping}ms`, inline: true }
      )
      .setTimestamp();

    await channel.send({ embeds: [embed] });
    console.log(&amp;quot;주간 리포트를 발송했습니다.&amp;quot;);
  } catch (error) {
    console.error(&amp;quot;주간 리포트 발송 실패:&amp;quot;, error);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기존 &lt;code&gt;index.ts&lt;/code&gt;에서는 이미 스케줄러를 호출하고 있으니 그대로 두면 됩니다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts에서 이미 호출 중
startScheduledJobs(client);&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;더 많은 예시들&lt;/h2&gt;
&lt;h3&gt;특정 시간에 알림 보내기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 매일 오후 6시에 퇴근 알림
cron.schedule(&amp;quot;0 18 * * *&amp;quot;, async () =&amp;gt; {
  const channel = client.channels.cache.get(&amp;quot;채널ID&amp;quot;) as TextChannel;
  if (channel) {
    await channel.send(&amp;quot;  퇴근 시간입니다! 오늘도 수고하셨어요!&amp;quot;);
  }
});

// 매주 금요일 오후 5시에 주말 인사
cron.schedule(&amp;quot;0 17 * * 5&amp;quot;, async () =&amp;gt; {
  const channel = client.channels.cache.get(&amp;quot;채널ID&amp;quot;) as TextChannel;
  if (channel) {
    await channel.send(&amp;quot;  즐거운 주말 되세요!&amp;quot;);
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;정기적인 서버 관리&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 매일 자정에 서버 정리
cron.schedule(&amp;quot;0 0 * * *&amp;quot;, async () =&amp;gt; {
  console.log(&amp;quot;일일 서버 정리 시작...&amp;quot;);

  // 캐시 정리
  client.users.cache.sweep((user) =&amp;gt; user.id !== client.user?.id);

  console.log(&amp;quot;서버 정리 완료&amp;quot;);
});

// 30분마다 상태 확인
cron.schedule(&amp;quot;*/30 * * * *&amp;quot;, async () =&amp;gt; {
  console.log(
    `서버 상태: ${client.guilds.cache.size}개 서버, 핑: ${client.ws.ping}ms`
  );
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;사용할 때 주의사항&lt;/h2&gt;
&lt;h3&gt;1. 채널 ID 설정하기&lt;/h3&gt;
&lt;p&gt;실제 사용하려면 채널 ID를 올바르게 설정해야 해요:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const channelId = &amp;quot;1234567890123456789&amp;quot;; // 이 부분을 실제 채널 ID로 변경&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 시간대 설정&lt;/h3&gt;
&lt;p&gt;한국 시간 기준으로 작업하려면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# .env 파일에 추가
TZ=Asia/Seoul&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 에러 처리&lt;/h3&gt;
&lt;p&gt;각 함수에서 에러가 발생해도 봇이 멈추지 않도록 try-catch를 사용했어요.&lt;/p&gt;
&lt;h3&gt;4. 개발 중일 때는 짧은 주기로 테스트&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 개발할 때는 1분마다 테스트
if (process.env.NODE_ENV === &amp;quot;development&amp;quot;) {
  cron.schedule(&amp;quot;* * * * *&amp;quot;, async () =&amp;gt; {
    console.log(&amp;quot;테스트 메시지&amp;quot;);
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;이렇게 간단하게 node-cron을 사용해서 스케줄링 기능을 구현할 수 있어요! 복잡한 관리 기능이 필요하지 않다면 이 방법이 훨씬 간단하고 이해하기 쉽습니다.&lt;/p&gt;
&lt;p&gt;중요한 것은 실제 채널 ID를 올바르게 설정하고, 적절한 에러 처리를 해주는 것이에요. 그러면 봇이 자동으로 정해진 시간에 메시지를 보내고 상태를 업데이트해줄 거예요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/42</guid>
      <comments>https://dishost.tistory.com/42#entry42comment</comments>
      <pubDate>Sun, 15 Jun 2025 17:18:42 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 15. 관리자 패널 만들기 (웹 연동 기본): 웹으로 봇을 편리하게 관리하자!</title>
      <link>https://dishost.tistory.com/41</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 지난 시간에는 테스트 코드 작성과 디버깅 팁에 대해 알아봤습니다. 이제 우리 봇은 안정적이고 유지보수하기 쉬운 상태가 되었죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 좀 더 고급 주제를 다뤄보겠습니다. 바로 &lt;b&gt;웹 기반 관리자 패널&lt;/b&gt;을 만드는 것입니다. 지금까지는 디스코드 명령어로만 봇을 관리해왔는데, 복잡한 설정이나 통계 확인 등은 웹 인터페이스가 훨씬 편리하거든요. 실제로 많은 대형 디스코드 봇들이 웹 대시보드를 제공하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 Express.js를 사용해서 간단한 관리자 패널을 만들어보겠습니다. 봇 통계 확인, 서버 설정 관리, 사용자 관리 등의 기능을 웹에서 할 수 있도록 해보죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 웹 관리자 패널이 필요할까요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 명령어로만 봇을 관리하는 것에는 몇 가지 한계가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;디스코드 명령어의 한계:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복잡한 데이터 표시가 어려움 (표, 그래프 등)&lt;/li&gt;
&lt;li&gt;긴 텍스트나 설정 입력이 불편함&lt;/li&gt;
&lt;li&gt;파일 업로드/다운로드가 제한적임&lt;/li&gt;
&lt;li&gt;실시간 모니터링이 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;웹 패널의 장점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;직관적인 GUI로 편리한 관리&lt;/li&gt;
&lt;li&gt;실시간 데이터 시각화 가능&lt;/li&gt;
&lt;li&gt;복잡한 설정을 폼으로 쉽게 입력&lt;/li&gt;
&lt;li&gt;파일 관리와 로그 확인이 편리&lt;/li&gt;
&lt;li&gt;모바일에서도 접근 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 보안 측면에서 더 신경써야 할 부분이 있지만, 제대로 구현하면 봇 관리가 훨씬 편해집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 구조 설계하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 패널을 추가하면서 기존 봇 코드와 잘 분리된 구조를 만들어보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;src/
├── bot/              # 기존 봇 관련 코드
│   ├── index.ts
│   ├── commands/
│   └── events/
├── web/              # 웹 서버 관련 코드
│   ├── server.ts
│   ├── routes/
│   ├── middleware/
│   └── views/
├── shared/           # 공통으로 사용되는 코드
│   ├── database/
│   ├── config/
│   └── utils/
└── index.ts          # 메인 진입점&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;필요한 패키지 설치하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 서버를 위한 패키지들을 설치해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;npm install express cors helmet morgan
npm install -D @types/express @types/cors&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 패키지의 역할은 다음과 같습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;express&lt;/code&gt;: Node.js 웹 프레임워크&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cors&lt;/code&gt;: Cross-Origin Resource Sharing 설정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;helmet&lt;/code&gt;: 보안 헤더 설정&lt;/li&gt;
&lt;li&gt;&lt;code&gt;morgan&lt;/code&gt;: HTTP 요청 로깅&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기본 웹 서버 구현하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Express 서버의 기본 틀을 만들어보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// src/web/server.ts
import express from &quot;express&quot;;
import cors from &quot;cors&quot;;
import helmet from &quot;helmet&quot;;
import morgan from &quot;morgan&quot;;
import path from &quot;path&quot;;
import { Client } from &quot;discord.js&quot;;

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: [&quot;'self'&quot;],
            styleSrc: [&quot;'self'&quot;, &quot;'unsafe-inline'&quot;, &quot;https://cdn.jsdelivr.net&quot;],
            scriptSrc: [&quot;'self'&quot;, &quot;https://cdn.jsdelivr.net&quot;],
            imgSrc: [&quot;'self'&quot;, &quot;data:&quot;, &quot;https:&quot;],
          },
        },
      })
    );

    // CORS 설정
    this.app.use(
      cors({
        origin: process.env.ALLOWED_ORIGINS?.split(&quot;,&quot;) || [
          &quot;http://localhost:3000&quot;,
        ],
        credentials: true,
      })
    );

    // 로깅
    this.app.use(morgan(&quot;combined&quot;));

    // JSON 파싱
    this.app.use(express.json({ limit: &quot;10mb&quot; }));
    this.app.use(express.urlencoded({ extended: true }));

    // 정적 파일 제공
    this.app.use(&quot;/static&quot;, express.static(path.join(__dirname, &quot;public&quot;)));
  }

  private setupRoutes(): void {
    // API 라우트
    this.app.use(&quot;/api&quot;, this.createApiRoutes());

    // 메인 페이지
    this.app.get(&quot;/&quot;, (req, res) =&amp;gt; {
      res.send(this.getMainPage());
    });

    // 404 처리
    this.app.use(&quot;*&quot;, (req, res) =&amp;gt; {
      res.status(404).json({ error: &quot;Not Found&quot; });
    });
  }

  private createApiRoutes(): express.Router {
    const router = express.Router();

    // 봇 상태 API
    router.get(&quot;/status&quot;, (req, res) =&amp;gt; {
      const guilds = this.client.guilds.cache;
      const users = this.client.users.cache;

      res.json({
        status: this.client.isReady() ? &quot;online&quot; : &quot;offline&quot;,
        guilds: guilds.size,
        users: users.size,
        uptime: process.uptime(),
        memoryUsage: process.memoryUsage(),
      });
    });

    // 서버 목록 API
    router.get(&quot;/guilds&quot;, (req, res) =&amp;gt; {
      const guilds = this.client.guilds.cache.map((guild) =&amp;gt; ({
        id: guild.id,
        name: guild.name,
        memberCount: guild.memberCount,
        iconURL: guild.iconURL(),
        owner: guild.ownerId,
      }));

      res.json(guilds);
    });

    // 특정 서버 정보 API
    router.get(&quot;/guilds/:id&quot;, async (req, res) =&amp;gt; {
      try {
        const guild = await this.client.guilds.fetch(req.params.id);
        const channels = guild.channels.cache.map((channel) =&amp;gt; ({
          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) =&amp;gt; ({
            id: role.id,
            name: role.name,
            color: role.hexColor,
            members: role.members.size,
          })),
        });
      } catch (error) {
        res.status(404).json({ error: &quot;Guild not found&quot; });
      }
    });

    return router;
  }

  private getMainPage(): string {
    return `
&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;Discord Bot 관리자 패널&amp;lt;/title&amp;gt;
    &amp;lt;link href=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css&quot; rel=&quot;stylesheet&quot;&amp;gt;
    &amp;lt;script src=&quot;https://cdn.jsdelivr.net/npm/chart.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;nav class=&quot;navbar navbar-dark bg-dark&quot;&amp;gt;
        &amp;lt;div class=&quot;container&quot;&amp;gt;
            &amp;lt;span class=&quot;navbar-brand&quot;&amp;gt;Discord Bot 관리자 패널&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/nav&amp;gt;

    &amp;lt;div class=&quot;container mt-4&quot;&amp;gt;
        &amp;lt;div class=&quot;row&quot;&amp;gt;
            &amp;lt;div class=&quot;col-md-6&quot;&amp;gt;
                &amp;lt;div class=&quot;card&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-header&quot;&amp;gt;
                        &amp;lt;h5&amp;gt;봇 상태&amp;lt;/h5&amp;gt;
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div class=&quot;card-body&quot; id=&quot;bot-status&quot;&amp;gt;
                        로딩 중...
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;col-md-6&quot;&amp;gt;
                &amp;lt;div class=&quot;card&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-header&quot;&amp;gt;
                        &amp;lt;h5&amp;gt;서버 목록&amp;lt;/h5&amp;gt;
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div class=&quot;card-body&quot; id=&quot;guild-list&quot;&amp;gt;
                        로딩 중...
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;div class=&quot;row mt-4&quot;&amp;gt;
            &amp;lt;div class=&quot;col-12&quot;&amp;gt;
                &amp;lt;div class=&quot;card&quot;&amp;gt;
                    &amp;lt;div class=&quot;card-header&quot;&amp;gt;
                        &amp;lt;h5&amp;gt;메모리 사용량&amp;lt;/h5&amp;gt;
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div class=&quot;card-body&quot;&amp;gt;
                        &amp;lt;canvas id=&quot;memory-chart&quot; width=&quot;400&quot; height=&quot;200&quot;&amp;gt;&amp;lt;/canvas&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;script&amp;gt;
        // 봇 상태 업데이트
        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 = \`
                    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;상태:&amp;lt;/strong&amp;gt; &amp;lt;span class=&quot;badge bg-\${data.status === 'online' ? 'success' : 'danger'}&quot;&amp;gt;\${data.status}&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
                    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;서버 수:&amp;lt;/strong&amp;gt; \${data.guilds}개&amp;lt;/p&amp;gt;
                    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;사용자 수:&amp;lt;/strong&amp;gt; \${data.users}명&amp;lt;/p&amp;gt;
                    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;가동 시간:&amp;lt;/strong&amp;gt; \${uptimeHours}시간 \${uptimeMinutes}분&amp;lt;/p&amp;gt;
                \`;

                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 =&amp;gt; \`
                    &amp;lt;div class=&quot;mb-2&quot;&amp;gt;
                        &amp;lt;strong&amp;gt;\${guild.name}&amp;lt;/strong&amp;gt;&amp;lt;br&amp;gt;
                        &amp;lt;small&amp;gt;멤버: \${guild.memberCount}명&amp;lt;/small&amp;gt;
                    &amp;lt;/div&amp;gt;
                \`).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 &amp;gt; 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);
        });
    &amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
        `;
  }

  public start(port: number = 3000): void {
    this.app.listen(port, () =&amp;gt; {
      console.log(`웹 서버가 포트 ${port}에서 시작되었습니다.`);
      console.log(`관리자 패널: http://localhost:${port}`);
    });
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;메인 진입점 수정하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇과 웹 서버를 함께 실행하도록 메인 파일을 수정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/index.ts
import { Client } from &quot;discord.js&quot;;
import { WebServer } from &quot;./web/server&quot;;
import config from &quot;./config&quot;;

// ...기존 봇 코드...

client.once(&quot;ready&quot;, () =&amp;gt; {
  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);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;인증 시스템 추가하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자 패널이니까 당연히 인증이 필요하겠죠. 간단한 세션 기반 인증을 구현해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/web/middleware/auth.ts
import { Request, Response, NextFunction } from &quot;express&quot;;

export interface AuthenticatedRequest extends Request {
  user?: {
    id: string;
    username: string;
    discriminator: string;
    avatar?: string;
  };
}

// 간단한 메모리 기반 세션 (실제 운영에서는 Redis 등 사용 권장)
const sessions = new Map&amp;lt;string, any&amp;gt;();

export function requireAuth(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
) {
  const sessionId = req.headers.authorization?.replace(&quot;Bearer &quot;, &quot;&quot;);

  if (!sessionId || !sessions.has(sessionId)) {
    return res.status(401).json({ error: &quot;Unauthorized&quot; });
  }

  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(() =&amp;gt; {
    sessions.delete(sessionId);
  }, 24 * 60 * 60 * 1000);

  return sessionId;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Discord OAuth2 로그인 구현하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Discord 계정으로 로그인할 수 있도록 OAuth2를 구현해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/web/routes/auth.ts
import express from &quot;express&quot;;
import { createSession } from &quot;../middleware/auth&quot;;

const router = express.Router();

router.get(&quot;/discord&quot;, (req, res) =&amp;gt; {
  const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${
    process.env.DISCORD_CLIENT_ID
  }&amp;amp;redirect_uri=${encodeURIComponent(
    process.env.DISCORD_REDIRECT_URI!
  )}&amp;amp;response_type=code&amp;amp;scope=identify`;
  res.redirect(discordAuthUrl);
});

router.get(&quot;/discord/callback&quot;, async (req, res) =&amp;gt; {
  const { code } = req.query;

  if (!code) {
    return res.status(400).json({ error: &quot;No code provided&quot; });
  }

  try {
    // Discord로부터 액세스 토큰 받기
    const tokenResponse = await fetch(&quot;https://discord.com/api/oauth2/token&quot;, {
      method: &quot;POST&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/x-www-form-urlencoded&quot;,
      },
      body: new URLSearchParams({
        client_id: process.env.DISCORD_CLIENT_ID!,
        client_secret: process.env.DISCORD_CLIENT_SECRET!,
        grant_type: &quot;authorization_code&quot;,
        code: code as string,
        redirect_uri: process.env.DISCORD_REDIRECT_URI!,
      }),
    });

    const tokenData = await tokenResponse.json();

    // 사용자 정보 가져오기
    const userResponse = await fetch(&quot;https://discord.com/api/users/@me&quot;, {
      headers: {
        Authorization: `Bearer ${tokenData.access_token}`,
      },
    });

    const userData = await userResponse.json();

    // 관리자 권한 확인 (환경 변수에서 관리자 ID 목록 가져오기)
    const adminIds = process.env.ADMIN_USER_IDS?.split(&quot;,&quot;) || [];
    if (!adminIds.includes(userData.id)) {
      return res.status(403).json({ error: &quot;Access denied&quot; });
    }

    // 세션 생성
    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(&quot;Discord OAuth2 에러:&quot;, error);
    res.status(500).json({ error: &quot;Authentication failed&quot; });
  }
});

export default router;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;환경 변수 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.env&lt;/code&gt; 파일에 웹 패널 관련 설정을 추가해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# 기존 디스코드 봇 설정
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&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안 고려사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 패널을 운영할 때 중요한 보안 사항들을 살펴보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. HTTPS 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 운영 환경에서는 반드시 HTTPS를 사용해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/web/server.ts (HTTPS 버전)
import https from &quot;https&quot;;
import fs from &quot;fs&quot;;

// HTTPS 서버 설정
if (process.env.NODE_ENV === &quot;production&quot;) {
  const options = {
    key: fs.readFileSync(&quot;/path/to/private-key.pem&quot;),
    cert: fs.readFileSync(&quot;/path/to/certificate.pem&quot;),
  };

  https.createServer(options, this.app).listen(port, () =&amp;gt; {
    console.log(`HTTPS 서버가 포트 ${port}에서 시작되었습니다.`);
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 레이트 리미팅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 요청 제한을 걸어 DDoS 공격을 방지합니다.&lt;/p&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;npm install express-rate-limit&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;import rateLimit from &quot;express-rate-limit&quot;;

// API 레이트 리미팅
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 100, // 최대 100 요청
  message: &quot;너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.&quot;,
});

this.app.use(&quot;/api&quot;, apiLimiter);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 입력 검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 입력은 항상 검증해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;npm install joi
npm install -D @types/joi&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;moonscript&quot;&gt;&lt;code&gt;import Joi from &quot;joi&quot;;

const guildIdSchema = Joi.string()
  .pattern(/^\d{17,19}$/)
  .required();

router.get(&quot;/guilds/:id&quot;, (req, res) =&amp;gt; {
  const { error } = guildIdSchema.validate(req.params.id);
  if (error) {
    return res.status(400).json({ error: &quot;Invalid guild ID&quot; });
  }

  // ...기존 코드...
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 Discord 봇과 연동되는 웹 기반 관리자 패널을 만들어봤습니다. Express.js를 사용한 기본적인 웹 서버부터 Discord OAuth2 인증, 실시간 데이터 표시까지 다뤄봤죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 이것은 시작일 뿐입니다. 실제 운영 환경에서는 더 많은 기능과 보안 강화가 필요하겠지만, 기본 틀은 이렇게 구성할 수 있어요. 데이터베이스 연동, 실시간 알림, 더 복잡한 권한 시스템 등을 추가하면 더욱 강력한 관리자 패널을 만들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 시간에는 마지막 튜토리얼로 &lt;b&gt;스케줄러로 자동 메시지 보내기&lt;/b&gt;에 대해 알아보겠습니다. node-cron을 사용해서 정기적으로 작업을 수행하는 방법을 배워보겠어요. 많은 봇에서 필요한 기능이니까 꼭 알아두시면 좋을 거예요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/41</guid>
      <comments>https://dishost.tistory.com/41#entry41comment</comments>
      <pubDate>Sat, 14 Jun 2025 17:16:13 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 12. 다국어 지원 시스템 만들기 (i18n): 전 세계와 소통하자!</title>
      <link>https://dishost.tistory.com/40</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 지난 시간에는 반응 기반 기능들을 만들어서 사용자와의 상호작용을 더욱 직관적으로 만들어봤습니다. 이제 우리 봇이 점점 완성도가 높아지고 있죠?&lt;/p&gt;
&lt;p&gt;오늘은 봇을 &lt;strong&gt;국제적으로&lt;/strong&gt; 사용할 수 있도록 다국어 지원 시스템을 구축해보겠습니다. 한국어로만 동작하는 봇도 좋지만, 영어, 일본어, 중국어 등 다양한 언어를 지원한다면 훨씬 더 많은 사용자들이 편리하게 사용할 수 있겠죠?&lt;/p&gt;
&lt;p&gt;실제로 Discord 자체도 Discord의 언어 설정에 따라 슬래시 명령어의 이름과 설명이 자동으로 번역되어 표시됩니다. 우리도 이런 기능을 활용해서 진정한 글로벌 봇을 만들어보겠습니다.&lt;/p&gt;
&lt;h2&gt;i18next 라이브러리 설치하기&lt;/h2&gt;
&lt;p&gt;다국어 지원을 위해 널리 사용되는 &lt;code&gt;i18next&lt;/code&gt; 라이브러리를 사용하겠습니다. 이 라이브러리는 번역 파일 관리부터 언어 변환까지 모든 것을 편리하게 처리해줍니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install i18next i18next-fs-backend
npm install @types/i18next --save-dev&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;번역 파일 구조 설정하기&lt;/h2&gt;
&lt;p&gt;먼저 번역 파일들을 저장할 폴더 구조를 만들어보겠습니다.&lt;/p&gt;
&lt;h3&gt;locales 폴더 구조&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;src/
  locales/
    ko/
      common.json
      commands.json
      errors.json
    en/
      common.json
      commands.json
      errors.json
    ja/
      common.json
      commands.json
      errors.json&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;src/locales/ko/common.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;welcome&amp;quot;: &amp;quot;환영합니다!&amp;quot;,
  &amp;quot;goodbye&amp;quot;: &amp;quot;안녕히 가세요!&amp;quot;,
  &amp;quot;success&amp;quot;: &amp;quot;성공적으로 완료되었습니다!&amp;quot;,
  &amp;quot;error&amp;quot;: &amp;quot;오류가 발생했습니다.&amp;quot;,
  &amp;quot;loading&amp;quot;: &amp;quot;처리 중입니다...&amp;quot;,
  &amp;quot;yes&amp;quot;: &amp;quot;예&amp;quot;,
  &amp;quot;no&amp;quot;: &amp;quot;아니오&amp;quot;,
  &amp;quot;cancel&amp;quot;: &amp;quot;취소&amp;quot;,
  &amp;quot;confirm&amp;quot;: &amp;quot;확인&amp;quot;,
  &amp;quot;save&amp;quot;: &amp;quot;저장&amp;quot;,
  &amp;quot;delete&amp;quot;: &amp;quot;삭제&amp;quot;,
  &amp;quot;edit&amp;quot;: &amp;quot;편집&amp;quot;,
  &amp;quot;back&amp;quot;: &amp;quot;뒤로&amp;quot;,
  &amp;quot;next&amp;quot;: &amp;quot;다음&amp;quot;,
  &amp;quot;previous&amp;quot;: &amp;quot;이전&amp;quot;,
  &amp;quot;page&amp;quot;: &amp;quot;페이지&amp;quot;,
  &amp;quot;total&amp;quot;: &amp;quot;총&amp;quot;,
  &amp;quot;none&amp;quot;: &amp;quot;없음&amp;quot;,
  &amp;quot;unknown&amp;quot;: &amp;quot;알 수 없음&amp;quot;,
  &amp;quot;admin&amp;quot;: &amp;quot;관리자&amp;quot;,
  &amp;quot;moderator&amp;quot;: &amp;quot;모더레이터&amp;quot;,
  &amp;quot;member&amp;quot;: &amp;quot;멤버&amp;quot;,
  &amp;quot;bot&amp;quot;: &amp;quot;봇&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/locales/en/common.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;welcome&amp;quot;: &amp;quot;Welcome!&amp;quot;,
  &amp;quot;goodbye&amp;quot;: &amp;quot;Goodbye!&amp;quot;,
  &amp;quot;success&amp;quot;: &amp;quot;Successfully completed!&amp;quot;,
  &amp;quot;error&amp;quot;: &amp;quot;An error occurred.&amp;quot;,
  &amp;quot;loading&amp;quot;: &amp;quot;Processing...&amp;quot;,
  &amp;quot;yes&amp;quot;: &amp;quot;Yes&amp;quot;,
  &amp;quot;no&amp;quot;: &amp;quot;No&amp;quot;,
  &amp;quot;cancel&amp;quot;: &amp;quot;Cancel&amp;quot;,
  &amp;quot;confirm&amp;quot;: &amp;quot;Confirm&amp;quot;,
  &amp;quot;save&amp;quot;: &amp;quot;Save&amp;quot;,
  &amp;quot;delete&amp;quot;: &amp;quot;Delete&amp;quot;,
  &amp;quot;edit&amp;quot;: &amp;quot;Edit&amp;quot;,
  &amp;quot;back&amp;quot;: &amp;quot;Back&amp;quot;,
  &amp;quot;next&amp;quot;: &amp;quot;Next&amp;quot;,
  &amp;quot;previous&amp;quot;: &amp;quot;Previous&amp;quot;,
  &amp;quot;page&amp;quot;: &amp;quot;Page&amp;quot;,
  &amp;quot;total&amp;quot;: &amp;quot;Total&amp;quot;,
  &amp;quot;none&amp;quot;: &amp;quot;None&amp;quot;,
  &amp;quot;unknown&amp;quot;: &amp;quot;Unknown&amp;quot;,
  &amp;quot;admin&amp;quot;: &amp;quot;Admin&amp;quot;,
  &amp;quot;moderator&amp;quot;: &amp;quot;Moderator&amp;quot;,
  &amp;quot;member&amp;quot;: &amp;quot;Member&amp;quot;,
  &amp;quot;bot&amp;quot;: &amp;quot;Bot&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/locales/ko/commands.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;ping&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;핑&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;봇의 응답 시간을 확인합니다&amp;quot;,
    &amp;quot;response&amp;quot;: &amp;quot;  퐁! 지연시간: {{latency}}ms&amp;quot;
  },
  &amp;quot;help&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;도움말&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;사용 가능한 명령어 목록을 표시합니다&amp;quot;,
    &amp;quot;title&amp;quot;: &amp;quot;  도움말&amp;quot;,
    &amp;quot;noCommands&amp;quot;: &amp;quot;사용 가능한 명령어가 없습니다.&amp;quot;
  },
  &amp;quot;userinfo&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;유저정보&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;사용자 정보를 표시합니다&amp;quot;,
    &amp;quot;options&amp;quot;: {
      &amp;quot;user&amp;quot;: &amp;quot;정보를 확인할 사용자&amp;quot;
    },
    &amp;quot;embed&amp;quot;: {
      &amp;quot;title&amp;quot;: &amp;quot;  사용자 정보&amp;quot;,
      &amp;quot;username&amp;quot;: &amp;quot;사용자명&amp;quot;,
      &amp;quot;id&amp;quot;: &amp;quot;ID&amp;quot;,
      &amp;quot;createdAt&amp;quot;: &amp;quot;계정 생성일&amp;quot;,
      &amp;quot;joinedAt&amp;quot;: &amp;quot;서버 가입일&amp;quot;,
      &amp;quot;roles&amp;quot;: &amp;quot;역할&amp;quot;,
      &amp;quot;isBot&amp;quot;: &amp;quot;봇 여부&amp;quot;
    }
  },
  &amp;quot;language&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;언어&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;봇의 언어를 변경합니다&amp;quot;,
    &amp;quot;options&amp;quot;: {
      &amp;quot;lang&amp;quot;: &amp;quot;사용할 언어&amp;quot;
    },
    &amp;quot;changed&amp;quot;: &amp;quot;언어가 {{language}}로 변경되었습니다!&amp;quot;,
    &amp;quot;invalid&amp;quot;: &amp;quot;올바르지 않은 언어입니다. 사용 가능한 언어: {{languages}}&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/locales/en/commands.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;ping&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;ping&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Check the bot&amp;#39;s response time&amp;quot;,
    &amp;quot;response&amp;quot;: &amp;quot;  Pong! Latency: {{latency}}ms&amp;quot;
  },
  &amp;quot;help&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;help&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Show available commands&amp;quot;,
    &amp;quot;title&amp;quot;: &amp;quot;  Help&amp;quot;,
    &amp;quot;noCommands&amp;quot;: &amp;quot;No commands available.&amp;quot;
  },
  &amp;quot;userinfo&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;userinfo&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Show user information&amp;quot;,
    &amp;quot;options&amp;quot;: {
      &amp;quot;user&amp;quot;: &amp;quot;User to get information about&amp;quot;
    },
    &amp;quot;embed&amp;quot;: {
      &amp;quot;title&amp;quot;: &amp;quot;  User Information&amp;quot;,
      &amp;quot;username&amp;quot;: &amp;quot;Username&amp;quot;,
      &amp;quot;id&amp;quot;: &amp;quot;ID&amp;quot;,
      &amp;quot;createdAt&amp;quot;: &amp;quot;Account Created&amp;quot;,
      &amp;quot;joinedAt&amp;quot;: &amp;quot;Joined Server&amp;quot;,
      &amp;quot;roles&amp;quot;: &amp;quot;Roles&amp;quot;,
      &amp;quot;isBot&amp;quot;: &amp;quot;Is Bot&amp;quot;
    }
  },
  &amp;quot;language&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;language&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Change the bot&amp;#39;s language&amp;quot;,
    &amp;quot;options&amp;quot;: {
      &amp;quot;lang&amp;quot;: &amp;quot;Language to use&amp;quot;
    },
    &amp;quot;changed&amp;quot;: &amp;quot;Language changed to {{language}}!&amp;quot;,
    &amp;quot;invalid&amp;quot;: &amp;quot;Invalid language. Available languages: {{languages}}&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/locales/ko/errors.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;generic&amp;quot;: &amp;quot;예기치 않은 오류가 발생했습니다.&amp;quot;,
  &amp;quot;permission&amp;quot;: &amp;quot;이 명령어를 사용할 권한이 없습니다.&amp;quot;,
  &amp;quot;cooldown&amp;quot;: &amp;quot;이 명령어는 {{time}}초 후에 다시 사용할 수 있습니다.&amp;quot;,
  &amp;quot;userNotFound&amp;quot;: &amp;quot;사용자를 찾을 수 없습니다.&amp;quot;,
  &amp;quot;roleNotFound&amp;quot;: &amp;quot;역할을 찾을 수 없습니다.&amp;quot;,
  &amp;quot;channelNotFound&amp;quot;: &amp;quot;채널을 찾을 수 없습니다.&amp;quot;,
  &amp;quot;invalidArgument&amp;quot;: &amp;quot;올바르지 않은 인수입니다.&amp;quot;,
  &amp;quot;missingArgument&amp;quot;: &amp;quot;필수 인수가 누락되었습니다.&amp;quot;,
  &amp;quot;databaseError&amp;quot;: &amp;quot;데이터베이스 오류가 발생했습니다.&amp;quot;,
  &amp;quot;networkError&amp;quot;: &amp;quot;네트워크 오류가 발생했습니다.&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/locales/en/errors.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;generic&amp;quot;: &amp;quot;An unexpected error occurred.&amp;quot;,
  &amp;quot;permission&amp;quot;: &amp;quot;You don&amp;#39;t have permission to use this command.&amp;quot;,
  &amp;quot;cooldown&amp;quot;: &amp;quot;You can use this command again in {{time}} seconds.&amp;quot;,
  &amp;quot;userNotFound&amp;quot;: &amp;quot;User not found.&amp;quot;,
  &amp;quot;roleNotFound&amp;quot;: &amp;quot;Role not found.&amp;quot;,
  &amp;quot;channelNotFound&amp;quot;: &amp;quot;Channel not found.&amp;quot;,
  &amp;quot;invalidArgument&amp;quot;: &amp;quot;Invalid argument.&amp;quot;,
  &amp;quot;missingArgument&amp;quot;: &amp;quot;Missing required argument.&amp;quot;,
  &amp;quot;databaseError&amp;quot;: &amp;quot;Database error occurred.&amp;quot;,
  &amp;quot;networkError&amp;quot;: &amp;quot;Network error occurred.&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;i18n 시스템 초기화하기&lt;/h2&gt;
&lt;h3&gt;src/utils/i18n.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import i18next from &amp;quot;i18next&amp;quot;;
import Backend from &amp;quot;i18next-fs-backend&amp;quot;;
import path from &amp;quot;path&amp;quot;;

export const supportedLanguages = {
  ko: &amp;quot;한국어&amp;quot;,
  en: &amp;quot;English&amp;quot;,
  ja: &amp;quot;日本語&amp;quot;,
} as const;

export type SupportedLanguage = keyof typeof supportedLanguages;

export async function initializeI18n(): Promise&amp;lt;void&amp;gt; {
  await i18next.use(Backend).init({
    lng: &amp;quot;ko&amp;quot;, // 기본 언어
    fallbackLng: &amp;quot;en&amp;quot;, // 번역이 없을 때 사용할 언어
    debug: process.env.NODE_ENV === &amp;quot;development&amp;quot;,

    backend: {
      loadPath: path.join(__dirname, &amp;quot;../locales/{{lng}}/{{ns}}.json&amp;quot;),
    },

    ns: [&amp;quot;common&amp;quot;, &amp;quot;commands&amp;quot;, &amp;quot;errors&amp;quot;], // 네임스페이스
    defaultNS: &amp;quot;common&amp;quot;,

    interpolation: {
      escapeValue: false, // React가 아니므로 XSS 걱정 없음
    },

    returnObjects: true, // 객체 반환 허용
  });
}

export function createI18nFunction(language: SupportedLanguage = &amp;quot;ko&amp;quot;) {
  return (key: string, options?: any) =&amp;gt; {
    return i18next.t(key, { lng: language, ...options });
  };
}

export function isValidLanguage(lang: string): lang is SupportedLanguage {
  return Object.keys(supportedLanguages).includes(lang);
}

export function getLanguageDisplay(lang: SupportedLanguage): string {
  return supportedLanguages[lang];
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;사용자별 언어 설정 저장하기&lt;/h2&gt;
&lt;p&gt;사용자가 선택한 언어를 데이터베이스에 저장해야 합니다.&lt;/p&gt;
&lt;h3&gt;prisma/schema.prisma에 추가&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;model UserSettings {
  id       String @id @default(cuid())
  userId   String @unique
  guildId  String
  language String @default(&amp;quot;ko&amp;quot;)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map(&amp;quot;user_settings&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;마이그레이션 실행:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate dev --name add-user-settings&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/utils/userSettings.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { PrismaClient } from &amp;quot;@prisma/client&amp;quot;;
import { SupportedLanguage } from &amp;quot;./i18n&amp;quot;;

const prisma = new PrismaClient();

export async function getUserLanguage(
  userId: string,
  guildId: string
): Promise&amp;lt;SupportedLanguage&amp;gt; {
  try {
    const settings = await prisma.userSettings.findUnique({
      where: { userId },
    });

    return (settings?.language as SupportedLanguage) || &amp;quot;ko&amp;quot;;
  } catch (error) {
    console.error(&amp;quot;언어 설정 조회 오류:&amp;quot;, error);
    return &amp;quot;ko&amp;quot;;
  }
}

export async function setUserLanguage(
  userId: string,
  guildId: string,
  language: SupportedLanguage
): Promise&amp;lt;void&amp;gt; {
  try {
    await prisma.userSettings.upsert({
      where: { userId },
      update: { language },
      create: {
        userId,
        guildId,
        language,
      },
    });
  } catch (error) {
    console.error(&amp;quot;언어 설정 저장 오류:&amp;quot;, error);
    throw error;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;다국어 지원 명령어 만들기&lt;/h2&gt;
&lt;h3&gt;src/commands/language.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { SlashCommandBuilder, EmbedBuilder, LocalizationMap } from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;
import {
  createI18nFunction,
  supportedLanguages,
  isValidLanguage,
  SupportedLanguage,
  getLanguageDisplay,
} from &amp;quot;../utils/i18n&amp;quot;;
import { getUserLanguage, setUserLanguage } from &amp;quot;../utils/userSettings&amp;quot;;

export const language: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;language&amp;quot;)
    .setDescription(&amp;quot;Change the bot&amp;#39;s language&amp;quot;)
    .setNameLocalizations({
      ko: &amp;quot;언어&amp;quot;,
      ja: &amp;quot;言語&amp;quot;,
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: &amp;quot;봇의 언어를 변경합니다&amp;quot;,
      ja: &amp;quot;ボットの言語を変更します&amp;quot;,
    } as LocalizationMap)
    .addStringOption((option) =&amp;gt;
      option
        .setName(&amp;quot;lang&amp;quot;)
        .setDescription(&amp;quot;Language to use&amp;quot;)
        .setNameLocalizations({
          ko: &amp;quot;언어&amp;quot;,
          ja: &amp;quot;言語&amp;quot;,
        } as LocalizationMap)
        .setDescriptionLocalizations({
          ko: &amp;quot;사용할 언어&amp;quot;,
          ja: &amp;quot;使用する言語&amp;quot;,
        } as LocalizationMap)
        .setRequired(true)
        .addChoices(
          { name: &amp;quot;한국어&amp;quot;, value: &amp;quot;ko&amp;quot; },
          { name: &amp;quot;English&amp;quot;, value: &amp;quot;en&amp;quot; },
          { name: &amp;quot;日本語&amp;quot;, value: &amp;quot;ja&amp;quot; }
        )
    ),

  async execute(interaction) {
    const selectedLang = interaction.options.getString(&amp;quot;lang&amp;quot;, true);
    const currentLang = await getUserLanguage(
      interaction.user.id,
      interaction.guildId!
    );
    const t = createI18nFunction(currentLang);

    if (!isValidLanguage(selectedLang)) {
      const availableLanguages = Object.entries(supportedLanguages)
        .map(([code, name]) =&amp;gt; `${name} (${code})`)
        .join(&amp;quot;, &amp;quot;);

      return interaction.reply({
        content: t(&amp;quot;commands:language.invalid&amp;quot;, {
          languages: availableLanguages,
        }),
        ephemeral: true,
      });
    }

    try {
      await setUserLanguage(
        interaction.user.id,
        interaction.guildId!,
        selectedLang
      );

      // 새로운 언어로 t 함수 생성
      const newT = createI18nFunction(selectedLang);

      const embed = new EmbedBuilder()
        .setTitle(&amp;quot;  &amp;quot; + newT(&amp;quot;commands:language.name&amp;quot;))
        .setDescription(
          newT(&amp;quot;commands:language.changed&amp;quot;, {
            language: getLanguageDisplay(selectedLang),
          })
        )
        .setColor(0x00ae86)
        .setTimestamp();

      await interaction.reply({
        embeds: [embed],
        ephemeral: true,
      });
    } catch (error) {
      console.error(&amp;quot;언어 변경 오류:&amp;quot;, error);
      await interaction.reply({
        content: t(&amp;quot;errors:generic&amp;quot;),
        ephemeral: true,
      });
    }
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;기존 명령어에 다국어 지원 추가하기&lt;/h2&gt;
&lt;h3&gt;src/commands/ping.ts (수정)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { SlashCommandBuilder, LocalizationMap } from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;
import { createI18nFunction } from &amp;quot;../utils/i18n&amp;quot;;
import { getUserLanguage } from &amp;quot;../utils/userSettings&amp;quot;;

export const ping: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;ping&amp;quot;)
    .setDescription(&amp;quot;Check the bot&amp;#39;s response time&amp;quot;)
    .setNameLocalizations({
      ko: &amp;quot;핑&amp;quot;,
      ja: &amp;quot;ピン&amp;quot;,
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: &amp;quot;봇의 응답 시간을 확인합니다&amp;quot;,
      ja: &amp;quot;ボットの応答時間を確認します&amp;quot;,
    } as LocalizationMap),

  async execute(interaction) {
    const userLang = await getUserLanguage(
      interaction.user.id,
      interaction.guildId!
    );
    const t = createI18nFunction(userLang);

    const sent = await interaction.reply({
      content: t(&amp;quot;common:loading&amp;quot;),
      fetchReply: true,
    });

    const latency = sent.createdTimestamp - interaction.createdTimestamp;

    await interaction.editReply({
      content: t(&amp;quot;commands:ping.response&amp;quot;, { latency }),
    });
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/commands/userinfo.ts (수정)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { SlashCommandBuilder, EmbedBuilder, LocalizationMap } from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;
import { createI18nFunction } from &amp;quot;../utils/i18n&amp;quot;;
import { getUserLanguage } from &amp;quot;../utils/userSettings&amp;quot;;

export const userinfo: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;userinfo&amp;quot;)
    .setDescription(&amp;quot;Show user information&amp;quot;)
    .setNameLocalizations({
      ko: &amp;quot;유저정보&amp;quot;,
      ja: &amp;quot;ユーザー情報&amp;quot;,
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: &amp;quot;사용자 정보를 표시합니다&amp;quot;,
      ja: &amp;quot;ユーザー情報を表示します&amp;quot;,
    } as LocalizationMap)
    .addUserOption((option) =&amp;gt;
      option
        .setName(&amp;quot;user&amp;quot;)
        .setDescription(&amp;quot;User to get information about&amp;quot;)
        .setNameLocalizations({
          ko: &amp;quot;사용자&amp;quot;,
          ja: &amp;quot;ユーザー&amp;quot;,
        } as LocalizationMap)
        .setDescriptionLocalizations({
          ko: &amp;quot;정보를 확인할 사용자&amp;quot;,
          ja: &amp;quot;情報を確認するユーザー&amp;quot;,
        } as LocalizationMap)
        .setRequired(false)
    ),

  async execute(interaction) {
    const userLang = await getUserLanguage(
      interaction.user.id,
      interaction.guildId!
    );
    const t = createI18nFunction(userLang);

    const targetUser = interaction.options.getUser(&amp;quot;user&amp;quot;) || interaction.user;
    const member = await interaction.guild?.members.fetch(targetUser.id);

    if (!member) {
      return interaction.reply({
        content: t(&amp;quot;errors:userNotFound&amp;quot;),
        ephemeral: true,
      });
    }

    const roles =
      member.roles.cache
        .filter((role) =&amp;gt; role.name !== &amp;quot;@everyone&amp;quot;)
        .map((role) =&amp;gt; role.toString())
        .join(&amp;quot;, &amp;quot;) || t(&amp;quot;common:none&amp;quot;);

    const embed = new EmbedBuilder()
      .setTitle(t(&amp;quot;commands:userinfo.embed.title&amp;quot;))
      .setThumbnail(targetUser.displayAvatarURL())
      .addFields([
        {
          name: t(&amp;quot;commands:userinfo.embed.username&amp;quot;),
          value: targetUser.tag,
          inline: true,
        },
        {
          name: t(&amp;quot;commands:userinfo.embed.id&amp;quot;),
          value: targetUser.id,
          inline: true,
        },
        {
          name: t(&amp;quot;commands:userinfo.embed.isBot&amp;quot;),
          value: targetUser.bot ? t(&amp;quot;common:yes&amp;quot;) : t(&amp;quot;common:no&amp;quot;),
          inline: true,
        },
        {
          name: t(&amp;quot;commands:userinfo.embed.createdAt&amp;quot;),
          value: `&amp;lt;t:${Math.floor(targetUser.createdTimestamp / 1000)}:F&amp;gt;`,
          inline: false,
        },
        {
          name: t(&amp;quot;commands:userinfo.embed.joinedAt&amp;quot;),
          value: member.joinedAt
            ? `&amp;lt;t:${Math.floor(member.joinedAt.getTime() / 1000)}:F&amp;gt;`
            : t(&amp;quot;common:unknown&amp;quot;),
          inline: false,
        },
        {
          name: t(&amp;quot;commands:userinfo.embed.roles&amp;quot;),
          value: roles,
          inline: false,
        },
      ])
      .setColor(member.displayColor || 0x00ae86)
      .setTimestamp();

    await interaction.reply({ embeds: [embed] });
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;중앙집중식 번역 미들웨어 만들기&lt;/h2&gt;
&lt;p&gt;모든 명령어에서 매번 언어를 가져오는 것은 비효율적입니다. 미들웨어를 만들어서 자동으로 처리하도록 하겠습니다.&lt;/p&gt;
&lt;h3&gt;src/utils/interactionHelper.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import {
  CommandInteraction,
  ButtonInteraction,
  SelectMenuInteraction,
} from &amp;quot;discord.js&amp;quot;;
import { createI18nFunction, SupportedLanguage } from &amp;quot;./i18n&amp;quot;;
import { getUserLanguage } from &amp;quot;./userSettings&amp;quot;;

export interface LocalizedInteraction extends CommandInteraction {
  t: (key: string, options?: any) =&amp;gt; string;
  userLanguage: SupportedLanguage;
}

export async function addLocalization&amp;lt;T extends CommandInteraction&amp;gt;(
  interaction: T
): Promise&amp;lt;
  T &amp;amp; {
    t: (key: string, options?: any) =&amp;gt; string;
    userLanguage: SupportedLanguage;
  }
&amp;gt; {
  const userLanguage = await getUserLanguage(
    interaction.user.id,
    interaction.guildId!
  );
  const t = createI18nFunction(userLanguage);

  return Object.assign(interaction, { t, userLanguage });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 명령어에서 더 간단하게 사용할 수 있습니다:&lt;/p&gt;
&lt;h3&gt;개선된 ping 명령어&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { SlashCommandBuilder, LocalizationMap } from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;
import { addLocalization } from &amp;quot;../utils/interactionHelper&amp;quot;;

export const ping: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;ping&amp;quot;)
    .setDescription(&amp;quot;Check the bot&amp;#39;s response time&amp;quot;)
    .setNameLocalizations({
      ko: &amp;quot;핑&amp;quot;,
      ja: &amp;quot;ピン&amp;quot;,
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: &amp;quot;봇의 응답 시간을 확인합니다&amp;quot;,
      ja: &amp;quot;ボットの応답時間を確認します&amp;quot;,
    } as LocalizationMap),

  async execute(interaction) {
    const localizedInteraction = await addLocalization(interaction);
    const { t } = localizedInteraction;

    const sent = await interaction.reply({
      content: t(&amp;quot;common:loading&amp;quot;),
      fetchReply: true,
    });

    const latency = sent.createdTimestamp - interaction.createdTimestamp;

    await interaction.editReply({
      content: t(&amp;quot;commands:ping.response&amp;quot;, { latency }),
    });
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;일본어 번역 파일 추가하기&lt;/h2&gt;
&lt;h3&gt;src/locales/ja/common.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;welcome&amp;quot;: &amp;quot;ようこそ！&amp;quot;,
  &amp;quot;goodbye&amp;quot;: &amp;quot;さようなら！&amp;quot;,
  &amp;quot;success&amp;quot;: &amp;quot;正常に完了しました！&amp;quot;,
  &amp;quot;error&amp;quot;: &amp;quot;エラーが発生しました。&amp;quot;,
  &amp;quot;loading&amp;quot;: &amp;quot;処理中です...&amp;quot;,
  &amp;quot;yes&amp;quot;: &amp;quot;はい&amp;quot;,
  &amp;quot;no&amp;quot;: &amp;quot;いいえ&amp;quot;,
  &amp;quot;cancel&amp;quot;: &amp;quot;キャンセル&amp;quot;,
  &amp;quot;confirm&amp;quot;: &amp;quot;確認&amp;quot;,
  &amp;quot;save&amp;quot;: &amp;quot;保存&amp;quot;,
  &amp;quot;delete&amp;quot;: &amp;quot;削除&amp;quot;,
  &amp;quot;edit&amp;quot;: &amp;quot;編集&amp;quot;,
  &amp;quot;back&amp;quot;: &amp;quot;戻る&amp;quot;,
  &amp;quot;next&amp;quot;: &amp;quot;次へ&amp;quot;,
  &amp;quot;previous&amp;quot;: &amp;quot;前へ&amp;quot;,
  &amp;quot;page&amp;quot;: &amp;quot;ページ&amp;quot;,
  &amp;quot;total&amp;quot;: &amp;quot;合計&amp;quot;,
  &amp;quot;none&amp;quot;: &amp;quot;なし&amp;quot;,
  &amp;quot;unknown&amp;quot;: &amp;quot;不明&amp;quot;,
  &amp;quot;admin&amp;quot;: &amp;quot;管理者&amp;quot;,
  &amp;quot;moderator&amp;quot;: &amp;quot;モデレーター&amp;quot;,
  &amp;quot;member&amp;quot;: &amp;quot;メンバー&amp;quot;,
  &amp;quot;bot&amp;quot;: &amp;quot;ボット&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/locales/ja/commands.json&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;ping&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;ピン&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;ボットの応答時間を確認します&amp;quot;,
    &amp;quot;response&amp;quot;: &amp;quot;  ポン！レイテンシ: {{latency}}ms&amp;quot;
  },
  &amp;quot;userinfo&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;ユーザー情報&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;ユーザー情報を表示します&amp;quot;,
    &amp;quot;options&amp;quot;: {
      &amp;quot;user&amp;quot;: &amp;quot;情報を確認するユーザー&amp;quot;
    },
    &amp;quot;embed&amp;quot;: {
      &amp;quot;title&amp;quot;: &amp;quot;  ユーザー情報&amp;quot;,
      &amp;quot;username&amp;quot;: &amp;quot;ユーザー名&amp;quot;,
      &amp;quot;id&amp;quot;: &amp;quot;ID&amp;quot;,
      &amp;quot;createdAt&amp;quot;: &amp;quot;アカウント作成日&amp;quot;,
      &amp;quot;joinedAt&amp;quot;: &amp;quot;サーバー参加日&amp;quot;,
      &amp;quot;roles&amp;quot;: &amp;quot;ロール&amp;quot;,
      &amp;quot;isBot&amp;quot;: &amp;quot;ボットかどうか&amp;quot;
    }
  },
  &amp;quot;language&amp;quot;: {
    &amp;quot;name&amp;quot;: &amp;quot;言語&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;ボットの言語を変更します&amp;quot;,
    &amp;quot;options&amp;quot;: {
      &amp;quot;lang&amp;quot;: &amp;quot;使用する言語&amp;quot;
    },
    &amp;quot;changed&amp;quot;: &amp;quot;言語が{{language}}に変更されました！&amp;quot;,
    &amp;quot;invalid&amp;quot;: &amp;quot;無効な言語です。使用可能な言語: {{languages}}&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;봇 시작 시 i18n 초기화하기&lt;/h2&gt;
&lt;h3&gt;src/index.ts (수정)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { Client, GatewayIntentBits } from &amp;quot;discord.js&amp;quot;;
import { initializeI18n } from &amp;quot;./utils/i18n&amp;quot;;
// ...기존 import들...

async function startBot() {
  try {
    // i18n 초기화
    await initializeI18n();
    console.log(&amp;quot;  다국어 시스템이 초기화되었습니다.&amp;quot;);

    // 봇 클라이언트 생성
    const client = new Client({
      intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.GuildMessageReactions,
      ],
    });

    // ...기존 코드...

    await client.login(process.env.DISCORD_TOKEN);
  } catch (error) {
    console.error(&amp;quot;봇 시작 오류:&amp;quot;, error);
    process.exit(1);
  }
}

startBot();&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;오늘은 i18next를 활용해서 완전한 다국어 지원 시스템을 구축해봤습니다. 이제 우리 봇은 한국어, 영어, 일본어를 지원하며, 필요에 따라 더 많은 언어를 쉽게 추가할 수 있습니다.&lt;/p&gt;
&lt;p&gt;다국어 지원의 핵심은 다음과 같습니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;번역 파일 체계적 관리&lt;/strong&gt; - 네임스페이스별로 분리하여 관리의 편의성 증대&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;사용자별 언어 설정&lt;/strong&gt; - 개인의 선호에 따라 언어 선택 가능&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discord 로컬라이제이션&lt;/strong&gt; - Discord 자체 언어 설정과 연동&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;중앙집중식 관리&lt;/strong&gt; - 미들웨어를 통한 효율적인 번역 처리&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;다음 시간에는 봇의 성능을 최적화하고 캐시를 효율적으로 관리하는 방법에 대해 알아보겠습니다. 사용자가 많아질수록 중요해지는 부분이니까 기대해주세요!&lt;/p&gt;
&lt;p&gt;글로벌 봇을 만드는 것은 정말 뿌듯한 일입니다. 여러분의 봇이 전 세계 사용자들에게 사랑받기를 바랍니다!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/40</guid>
      <comments>https://dishost.tistory.com/40#entry40comment</comments>
      <pubDate>Fri, 13 Jun 2025 17:15:50 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 11. 사용자 반응(이모지) 기반 기능 만들기: 손쉬운 상호작용의 시작</title>
      <link>https://dishost.tistory.com/39</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 지난 시간에는 봇을 실제 서버에 배포하는 방법을 알아봤습니다. 이제 우리 봇이 24시간 안정적으로 돌아가고 있다면, 더 재미있고 유용한 기능들을 추가해볼 시간입니다.&lt;/p&gt;
&lt;p&gt;오늘은 Discord의 가장 직관적인 상호작용 방식 중 하나인 &lt;strong&gt;반응(이모지)&lt;/strong&gt;을 활용한 기능들을 만들어보겠습니다. 투표 시스템, 역할 부여, 간단한 게임까지 다양한 활용법을 살펴볼 예정이에요.&lt;/p&gt;
&lt;p&gt;반응 기반 기능의 장점은 사용자가 별도의 명령어를 외울 필요 없이 직관적으로 이모지만 클릭하면 된다는 점입니다. 특히 모바일 환경에서 타이핑보다 훨씬 편리하죠.&lt;/p&gt;
&lt;h2&gt;기본 반응 시스템 구현하기&lt;/h2&gt;
&lt;p&gt;먼저 가장 기본적인 반응 수집기(Reaction Collector)부터 구현해보겠습니다.&lt;/p&gt;
&lt;h3&gt;src/commands/vote.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import {
  SlashCommandBuilder,
  EmbedBuilder,
  AttachmentBuilder,
} from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;

export const vote: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;투표&amp;quot;)
    .setDescription(&amp;quot;투표를 생성합니다&amp;quot;)
    .addStringOption((option) =&amp;gt;
      option.setName(&amp;quot;제목&amp;quot;).setDescription(&amp;quot;투표 제목&amp;quot;).setRequired(true)
    )
    .addStringOption((option) =&amp;gt;
      option
        .setName(&amp;quot;내용&amp;quot;)
        .setDescription(&amp;quot;투표 내용 (선택사항)&amp;quot;)
        .setRequired(false)
    )
    .addIntegerOption((option) =&amp;gt;
      option
        .setName(&amp;quot;시간&amp;quot;)
        .setDescription(&amp;quot;투표 시간 (분 단위, 기본: 30분)&amp;quot;)
        .setMinValue(1)
        .setMaxValue(1440)
        .setRequired(false)
    ),

  async execute(interaction) {
    const title = interaction.options.getString(&amp;quot;제목&amp;quot;, true);
    const content = interaction.options.getString(&amp;quot;내용&amp;quot;) || &amp;quot;&amp;quot;;
    const timeMinutes = interaction.options.getInteger(&amp;quot;시간&amp;quot;) || 30;

    const embed = new EmbedBuilder()
      .setTitle(`  투표: ${title}`)
      .setDescription(content || &amp;quot;찬성/반대로 투표해주세요!&amp;quot;)
      .setColor(0x00ae86)
      .addFields([
        { name: &amp;quot;✅ 찬성&amp;quot;, value: &amp;quot;0표&amp;quot;, inline: true },
        { name: &amp;quot;❌ 반대&amp;quot;, value: &amp;quot;0표&amp;quot;, inline: true },
        {
          name: &amp;quot;⏰ 종료 시간&amp;quot;,
          value: `&amp;lt;t:${Math.floor(
            (Date.now() + timeMinutes * 60000) / 1000
          )}:R&amp;gt;`,
          inline: false,
        },
      ])
      .setFooter({ text: &amp;quot;아래 이모지를 클릭해서 투표하세요!&amp;quot; })
      .setTimestamp();

    const message = await interaction.reply({
      embeds: [embed],
      fetchReply: true,
    });

    // 투표 이모지 추가
    await message.react(&amp;quot;✅&amp;quot;);
    await message.react(&amp;quot;❌&amp;quot;);

    // 반응 수집기 설정
    const filter = (reaction: any, user: any) =&amp;gt; {
      return [&amp;quot;✅&amp;quot;, &amp;quot;❌&amp;quot;].includes(reaction.emoji.name) &amp;amp;&amp;amp; !user.bot;
    };

    const collector = message.createReactionCollector({
      filter,
      time: timeMinutes * 60000, // 분을 밀리초로 변환
    });

    // 투표 현황 저장
    const voteData = new Map();
    voteData.set(&amp;quot;✅&amp;quot;, new Set());
    voteData.set(&amp;quot;❌&amp;quot;, new Set());

    collector.on(&amp;quot;collect&amp;quot;, async (reaction, user) =&amp;gt; {
      const emoji = reaction.emoji.name;

      // 다른 투표에서 사용자 제거 (한 번만 투표 가능)
      if (emoji === &amp;quot;✅&amp;quot;) {
        voteData.get(&amp;quot;❌&amp;quot;).delete(user.id);
        voteData.get(&amp;quot;✅&amp;quot;).add(user.id);
      } else if (emoji === &amp;quot;❌&amp;quot;) {
        voteData.get(&amp;quot;✅&amp;quot;).delete(user.id);
        voteData.get(&amp;quot;❌&amp;quot;).add(user.id);
      }

      // 임베드 업데이트
      const updatedEmbed = EmbedBuilder.from(embed).setFields([
        {
          name: &amp;quot;✅ 찬성&amp;quot;,
          value: `${voteData.get(&amp;quot;✅&amp;quot;).size}표`,
          inline: true,
        },
        {
          name: &amp;quot;❌ 반대&amp;quot;,
          value: `${voteData.get(&amp;quot;❌&amp;quot;).size}표`,
          inline: true,
        },
        {
          name: &amp;quot;⏰ 종료 시간&amp;quot;,
          value: `&amp;lt;t:${Math.floor(
            (Date.now() +
              (collector.options.time - (Date.now() - collector.startAt))) /
              1000
          )}:R&amp;gt;`,
          inline: false,
        },
      ]);

      await message.edit({ embeds: [updatedEmbed] });
    });

    collector.on(&amp;quot;end&amp;quot;, async () =&amp;gt; {
      const yesVotes = voteData.get(&amp;quot;✅&amp;quot;).size;
      const noVotes = voteData.get(&amp;quot;❌&amp;quot;).size;
      const totalVotes = yesVotes + noVotes;

      let result = &amp;quot;&amp;quot;;
      if (yesVotes &amp;gt; noVotes) {
        result = &amp;quot;✅ 찬성이 승리했습니다!&amp;quot;;
      } else if (noVotes &amp;gt; yesVotes) {
        result = &amp;quot;❌ 반대가 승리했습니다!&amp;quot;;
      } else {
        result = &amp;quot;  무승부입니다!&amp;quot;;
      }

      const finalEmbed = EmbedBuilder.from(embed)
        .setTitle(`  투표 종료: ${title}`)
        .setColor(0xff6b6b)
        .setFields([
          {
            name: &amp;quot;✅ 찬성&amp;quot;,
            value: `${yesVotes}표 (${
              totalVotes &amp;gt; 0 ? Math.round((yesVotes / totalVotes) * 100) : 0
            }%)`,
            inline: true,
          },
          {
            name: &amp;quot;❌ 반대&amp;quot;,
            value: `${noVotes}표 (${
              totalVotes &amp;gt; 0 ? Math.round((noVotes / totalVotes) * 100) : 0
            }%)`,
            inline: true,
          },
          { name: &amp;quot;  결과&amp;quot;, value: result, inline: false },
        ])
        .setFooter({ text: &amp;quot;투표가 종료되었습니다.&amp;quot; });

      await message.edit({ embeds: [finalEmbed] });
    });
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;다중 선택 투표 시스템&lt;/h2&gt;
&lt;p&gt;숫자 이모지를 활용해서 더 복잡한 투표 시스템도 만들 수 있습니다.&lt;/p&gt;
&lt;h3&gt;src/commands/multipoll.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { SlashCommandBuilder, EmbedBuilder } from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;

export const multipoll: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;다중투표&amp;quot;)
    .setDescription(&amp;quot;여러 선택지로 투표를 생성합니다&amp;quot;)
    .addStringOption((option) =&amp;gt;
      option.setName(&amp;quot;제목&amp;quot;).setDescription(&amp;quot;투표 제목&amp;quot;).setRequired(true)
    )
    .addStringOption((option) =&amp;gt;
      option
        .setName(&amp;quot;선택지&amp;quot;)
        .setDescription(&amp;quot;선택지들을 |로 구분해서 입력하세요 (최대 9개)&amp;quot;)
        .setRequired(true)
    )
    .addIntegerOption((option) =&amp;gt;
      option
        .setName(&amp;quot;시간&amp;quot;)
        .setDescription(&amp;quot;투표 시간 (분 단위, 기본: 30분)&amp;quot;)
        .setMinValue(1)
        .setMaxValue(1440)
        .setRequired(false)
    ),

  async execute(interaction) {
    const title = interaction.options.getString(&amp;quot;제목&amp;quot;, true);
    const choicesText = interaction.options.getString(&amp;quot;선택지&amp;quot;, true);
    const timeMinutes = interaction.options.getInteger(&amp;quot;시간&amp;quot;) || 30;

    const choices = choicesText
      .split(&amp;quot;|&amp;quot;)
      .map((choice) =&amp;gt; choice.trim())
      .filter((choice) =&amp;gt; choice.length &amp;gt; 0);

    if (choices.length &amp;lt; 2) {
      return interaction.reply({
        content: &amp;quot;❌ 최소 2개 이상의 선택지가 필요합니다!&amp;quot;,
        ephemeral: true,
      });
    }

    if (choices.length &amp;gt; 9) {
      return interaction.reply({
        content: &amp;quot;❌ 최대 9개까지만 선택지를 만들 수 있습니다!&amp;quot;,
        ephemeral: true,
      });
    }

    const numberEmojis = [&amp;quot;1️⃣&amp;quot;, &amp;quot;2️⃣&amp;quot;, &amp;quot;3️⃣&amp;quot;, &amp;quot;4️⃣&amp;quot;, &amp;quot;5️⃣&amp;quot;, &amp;quot;6️⃣&amp;quot;, &amp;quot;7️⃣&amp;quot;, &amp;quot;8️⃣&amp;quot;, &amp;quot;9️⃣&amp;quot;];

    const embed = new EmbedBuilder()
      .setTitle(`  다중 투표: ${title}`)
      .setColor(0x00ae86)
      .addFields([
        ...choices.map((choice, index) =&amp;gt; ({
          name: `${numberEmojis[index]} ${choice}`,
          value: &amp;quot;0표&amp;quot;,
          inline: true,
        })),
        {
          name: &amp;quot;⏰ 종료 시간&amp;quot;,
          value: `&amp;lt;t:${Math.floor(
            (Date.now() + timeMinutes * 60000) / 1000
          )}:R&amp;gt;`,
          inline: false,
        },
      ])
      .setFooter({ text: &amp;quot;아래 숫자 이모지를 클릭해서 투표하세요!&amp;quot; })
      .setTimestamp();

    const message = await interaction.reply({
      embeds: [embed],
      fetchReply: true,
    });

    // 선택지만큼 이모지 추가
    for (let i = 0; i &amp;lt; choices.length; i++) {
      await message.react(numberEmojis[i]);
    }

    const filter = (reaction: any, user: any) =&amp;gt; {
      return (
        numberEmojis.slice(0, choices.length).includes(reaction.emoji.name) &amp;amp;&amp;amp;
        !user.bot
      );
    };

    const collector = message.createReactionCollector({
      filter,
      time: timeMinutes * 60000,
    });

    const voteData = new Map();
    for (let i = 0; i &amp;lt; choices.length; i++) {
      voteData.set(numberEmojis[i], new Set());
    }

    collector.on(&amp;quot;collect&amp;quot;, async (reaction, user) =&amp;gt; {
      const emoji = reaction.emoji.name;

      // 다른 모든 투표에서 사용자 제거 (한 번만 투표 가능)
      for (const [emojiKey, voters] of voteData) {
        if (emojiKey !== emoji) {
          voters.delete(user.id);
        }
      }

      voteData.get(emoji).add(user.id);

      // 임베드 업데이트
      const updatedEmbed = EmbedBuilder.from(embed).setFields([
        ...choices.map((choice, index) =&amp;gt; ({
          name: `${numberEmojis[index]} ${choice}`,
          value: `${voteData.get(numberEmojis[index]).size}표`,
          inline: true,
        })),
        {
          name: &amp;quot;⏰ 종료 시간&amp;quot;,
          value: `&amp;lt;t:${Math.floor(
            (Date.now() +
              (collector.options.time - (Date.now() - collector.startAt))) /
              1000
          )}:R&amp;gt;`,
          inline: false,
        },
      ]);

      await message.edit({ embeds: [updatedEmbed] });
    });

    collector.on(&amp;quot;end&amp;quot;, async () =&amp;gt; {
      const results = choices.map((choice, index) =&amp;gt; ({
        choice,
        emoji: numberEmojis[index],
        votes: voteData.get(numberEmojis[index]).size,
      }));

      results.sort((a, b) =&amp;gt; b.votes - a.votes);
      const totalVotes = results.reduce((sum, result) =&amp;gt; sum + result.votes, 0);

      const finalEmbed = EmbedBuilder.from(embed)
        .setTitle(`  투표 종료: ${title}`)
        .setColor(0xff6b6b)
        .setFields([
          ...results.map((result, index) =&amp;gt; ({
            name: `${
              index === 0
                ? &amp;quot; &amp;quot;
                : index === 1
                ? &amp;quot; &amp;quot;
                : index === 2
                ? &amp;quot; &amp;quot;
                : &amp;quot; &amp;quot;
            } ${result.emoji} ${result.choice}`,
            value: `${result.votes}표 (${
              totalVotes &amp;gt; 0 ? Math.round((result.votes / totalVotes) * 100) : 0
            }%)`,
            inline: true,
          })),
          {
            name: &amp;quot;  우승자&amp;quot;,
            value:
              results[0].votes &amp;gt; 0
                ? `${results[0].emoji} ${results[0].choice}`
                : &amp;quot;투표 없음&amp;quot;,
            inline: false,
          },
        ])
        .setFooter({ text: &amp;quot;투표가 종료되었습니다.&amp;quot; });

      await message.edit({ embeds: [finalEmbed] });
    });
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;반응 기반 역할 부여 시스템&lt;/h2&gt;
&lt;p&gt;관리자가 설정한 메시지에 반응하면 자동으로 역할을 부여받는 시스템을 만들어보겠습니다.&lt;/p&gt;
&lt;h3&gt;src/commands/reactionrole.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import {
  SlashCommandBuilder,
  EmbedBuilder,
  PermissionFlagsBits,
} from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;
import { PrismaClient } from &amp;quot;@prisma/client&amp;quot;;

const prisma = new PrismaClient();

export const reactionrole: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;반응역할&amp;quot;)
    .setDescription(&amp;quot;반응으로 역할을 부여하는 메시지를 생성합니다&amp;quot;)
    .addRoleOption((option) =&amp;gt;
      option.setName(&amp;quot;역할&amp;quot;).setDescription(&amp;quot;부여할 역할&amp;quot;).setRequired(true)
    )
    .addStringOption((option) =&amp;gt;
      option.setName(&amp;quot;이모지&amp;quot;).setDescription(&amp;quot;사용할 이모지&amp;quot;).setRequired(true)
    )
    .addStringOption((option) =&amp;gt;
      option.setName(&amp;quot;제목&amp;quot;).setDescription(&amp;quot;메시지 제목&amp;quot;).setRequired(false)
    )
    .addStringOption((option) =&amp;gt;
      option.setName(&amp;quot;설명&amp;quot;).setDescription(&amp;quot;메시지 설명&amp;quot;).setRequired(false)
    )
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles),

  async execute(interaction) {
    const role = interaction.options.getRole(&amp;quot;역할&amp;quot;, true);
    const emoji = interaction.options.getString(&amp;quot;이모지&amp;quot;, true);
    const title = interaction.options.getString(&amp;quot;제목&amp;quot;) || &amp;quot;역할 부여&amp;quot;;
    const description =
      interaction.options.getString(&amp;quot;설명&amp;quot;) ||
      `${emoji}를 클릭하여 ${role.name} 역할을 받으세요!`;

    // 봇이 해당 역할을 관리할 수 있는지 확인
    if (
      !interaction.guild?.members.me?.permissions.has(
        PermissionFlagsBits.ManageRoles
      )
    ) {
      return interaction.reply({
        content: &amp;quot;❌ 봇에게 역할 관리 권한이 없습니다!&amp;quot;,
        ephemeral: true,
      });
    }

    const embed = new EmbedBuilder()
      .setTitle(title)
      .setDescription(description)
      .setColor(role.color || 0x00ae86)
      .addFields([
        { name: &amp;quot;역할&amp;quot;, value: role.toString(), inline: true },
        { name: &amp;quot;이모지&amp;quot;, value: emoji, inline: true },
      ])
      .setFooter({
        text: &amp;quot;이모지를 클릭하여 역할을 받거나 제거할 수 있습니다.&amp;quot;,
      })
      .setTimestamp();

    const message = await interaction.reply({
      embeds: [embed],
      fetchReply: true,
    });

    try {
      await message.react(emoji);

      // 데이터베이스에 반응 역할 정보 저장
      await prisma.reactionRole.create({
        data: {
          guildId: interaction.guildId!,
          channelId: interaction.channelId,
          messageId: message.id,
          roleId: role.id,
          emoji: emoji,
        },
      });

      await interaction.followUp({
        content: &amp;quot;✅ 반응 역할 메시지가 성공적으로 생성되었습니다!&amp;quot;,
        ephemeral: true,
      });
    } catch (error) {
      console.error(&amp;quot;반응 추가 오류:&amp;quot;, error);
      await interaction.followUp({
        content:
          &amp;quot;❌ 이모지 추가에 실패했습니다. 올바른 이모지인지 확인해주세요.&amp;quot;,
        ephemeral: true,
      });
    }
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;반응 역할 이벤트 처리: src/events/messageReactionAdd.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { Events } from &amp;quot;discord.js&amp;quot;;
import { Event } from &amp;quot;../types&amp;quot;;
import { PrismaClient } from &amp;quot;@prisma/client&amp;quot;;

const prisma = new PrismaClient();

export const messageReactionAdd: Event = {
  name: Events.MessageReactionAdd,
  async execute(reaction, user) {
    // 부분적으로 로드된 메시지 처리
    if (reaction.partial) {
      try {
        await reaction.fetch();
      } catch (error) {
        console.error(&amp;quot;반응 페치 오류:&amp;quot;, error);
        return;
      }
    }

    // 봇의 반응은 무시
    if (user.bot) return;

    try {
      // 데이터베이스에서 반응 역할 정보 조회
      const reactionRole = await prisma.reactionRole.findFirst({
        where: {
          messageId: reaction.message.id,
          emoji: reaction.emoji.name || reaction.emoji.toString(),
        },
      });

      if (!reactionRole) return;

      const guild = reaction.message.guild;
      if (!guild) return;

      const member = await guild.members.fetch(user.id);
      const role = guild.roles.cache.get(reactionRole.roleId);

      if (!role) {
        console.error(&amp;quot;역할을 찾을 수 없습니다:&amp;quot;, reactionRole.roleId);
        return;
      }

      // 역할 부여
      if (!member.roles.cache.has(role.id)) {
        await member.roles.add(role);
        console.log(`${user.tag}에게 ${role.name} 역할을 부여했습니다.`);
      }
    } catch (error) {
      console.error(&amp;quot;반응 역할 부여 오류:&amp;quot;, error);
    }
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;src/events/messageReactionRemove.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { Events } from &amp;quot;discord.js&amp;quot;;
import { Event } from &amp;quot;../types&amp;quot;;
import { PrismaClient } from &amp;quot;@prisma/client&amp;quot;;

const prisma = new PrismaClient();

export const messageReactionRemove: Event = {
  name: Events.MessageReactionRemove,
  async execute(reaction, user) {
    if (reaction.partial) {
      try {
        await reaction.fetch();
      } catch (error) {
        console.error(&amp;quot;반응 페치 오류:&amp;quot;, error);
        return;
      }
    }

    if (user.bot) return;

    try {
      const reactionRole = await prisma.reactionRole.findFirst({
        where: {
          messageId: reaction.message.id,
          emoji: reaction.emoji.name || reaction.emoji.toString(),
        },
      });

      if (!reactionRole) return;

      const guild = reaction.message.guild;
      if (!guild) return;

      const member = await guild.members.fetch(user.id);
      const role = guild.roles.cache.get(reactionRole.roleId);

      if (!role) return;

      // 역할 제거
      if (member.roles.cache.has(role.id)) {
        await member.roles.remove(role);
        console.log(`${user.tag}에게서 ${role.name} 역할을 제거했습니다.`);
      }
    } catch (error) {
      console.error(&amp;quot;반응 역할 제거 오류:&amp;quot;, error);
    }
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;간단한 반응 게임 만들기&lt;/h2&gt;
&lt;p&gt;마지막으로 반응을 활용한 간단한 게임을 만들어보겠습니다.&lt;/p&gt;
&lt;h3&gt;src/commands/reactiongame.ts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { SlashCommandBuilder, EmbedBuilder } from &amp;quot;discord.js&amp;quot;;
import { Command } from &amp;quot;../types&amp;quot;;

export const reactiongame: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;반응게임&amp;quot;)
    .setDescription(&amp;quot;빠른 반응 속도를 겨루는 게임을 시작합니다&amp;quot;),

  async execute(interaction) {
    const embed = new EmbedBuilder()
      .setTitle(&amp;quot;  빠른 반응 게임&amp;quot;)
      .setDescription(
        &amp;quot;곧 이모지가 나타납니다!\n가장 먼저 클릭하는 사람이 승리합니다!&amp;quot;
      )
      .setColor(0xffff00)
      .setFooter({ text: &amp;quot;잠시만 기다려주세요...&amp;quot; });

    const message = await interaction.reply({
      embeds: [embed],
      fetchReply: true,
    });

    // 랜덤 시간 후에 게임 시작 (3-10초 사이)
    const delay = Math.random() * 7000 + 3000;

    setTimeout(async () =&amp;gt; {
      const gameEmojis = [&amp;quot; &amp;quot;, &amp;quot;⚡&amp;quot;, &amp;quot; &amp;quot;, &amp;quot; &amp;quot;, &amp;quot; &amp;quot;, &amp;quot; &amp;quot;];
      const randomEmoji =
        gameEmojis[Math.floor(Math.random() * gameEmojis.length)];

      const gameEmbed = new EmbedBuilder()
        .setTitle(&amp;quot;  지금!&amp;quot;)
        .setDescription(`${randomEmoji}를 클릭하세요!`)
        .setColor(0x00ff00)
        .setFooter({ text: &amp;quot;가장 먼저 클릭하는 사람이 승리!&amp;quot; });

      await message.edit({ embeds: [gameEmbed] });
      await message.react(randomEmoji);

      const startTime = Date.now();

      const filter = (reaction: any, user: any) =&amp;gt; {
        return reaction.emoji.name === randomEmoji &amp;amp;&amp;amp; !user.bot;
      };

      const collector = message.createReactionCollector({
        filter,
        time: 30000,
        max: 1,
      });

      collector.on(&amp;quot;collect&amp;quot;, async (reaction, user) =&amp;gt; {
        const reactionTime = Date.now() - startTime;

        const winEmbed = new EmbedBuilder()
          .setTitle(&amp;quot;  승리!&amp;quot;)
          .setDescription(`${user}님이 승리했습니다!`)
          .addFields([
            { name: &amp;quot;반응 시간&amp;quot;, value: `${reactionTime}ms`, inline: true },
            { name: &amp;quot;승리자&amp;quot;, value: user.toString(), inline: true },
          ])
          .setColor(0xffd700)
          .setFooter({ text: &amp;quot;축하합니다!&amp;quot; });

        await message.edit({ embeds: [winEmbed] });
      });

      collector.on(&amp;quot;end&amp;quot;, async (collected) =&amp;gt; {
        if (collected.size === 0) {
          const timeoutEmbed = new EmbedBuilder()
            .setTitle(&amp;quot;⏰ 시간 초과&amp;quot;)
            .setDescription(&amp;quot;아무도 반응하지 않았습니다!&amp;quot;)
            .setColor(0xff0000)
            .setFooter({ text: &amp;quot;다음에 다시 도전해보세요!&amp;quot; });

          await message.edit({ embeds: [timeoutEmbed] });
        }
      });
    }, delay);
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Prisma 스키마 업데이트&lt;/h2&gt;
&lt;p&gt;반응 역할 기능을 위해 데이터베이스 스키마에 새로운 모델을 추가해야 합니다.&lt;/p&gt;
&lt;h3&gt;prisma/schema.prisma&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;// ...기존 코드...

model ReactionRole {
  id        String   @id @default(cuid())
  guildId   String
  channelId String
  messageId String
  roleId    String
  emoji     String
  createdAt DateTime @default(now())

  @@unique([messageId, emoji])
  @@map(&amp;quot;reaction_roles&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;스키마를 업데이트했다면 마이그레이션을 실행해주세요:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate dev --name add-reaction-roles&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;오늘은 Discord의 반응 시스템을 활용해서 다양한 인터랙티브 기능들을 만들어봤습니다. 투표부터 역할 부여, 게임까지 정말 다양한 활용법이 있죠?&lt;/p&gt;
&lt;p&gt;반응 기반 기능의 가장 큰 장점은 사용자가 별도의 명령어를 기억할 필요 없이 직관적으로 이모지만 클릭하면 된다는 점입니다. 특히 모바일 환경에서 타이핑보다 훨씬 편리하고, 시각적으로도 더 매력적이에요.&lt;/p&gt;
&lt;p&gt;다음 시간에는 봇의 다국어 지원 시스템을 구축해서 전 세계 사용자들이 편리하게 사용할 수 있도록 만들어보겠습니다. 국제적인 봇을 만들기 위해서는 꼭 필요한 기능이니까 기대해주세요!&lt;/p&gt;
&lt;p&gt;반응 기반 기능들을 잘 활용하면 사용자 경험을 크게 향상시킬 수 있습니다. 여러분만의 창의적인 아이디어로 더 재미있는 기능들을 만들어보세요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/39</guid>
      <comments>https://dishost.tistory.com/39#entry39comment</comments>
      <pubDate>Thu, 12 Jun 2025 17:15:26 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기</title>
      <link>https://dishost.tistory.com/38</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 지난 시간에는 봇을 실제 서버에 배포해서 24/7 운영하는 방법을 알아봤습니다. 이제 우리 봇은 안정적으로 서비스를 제공할 수 있게 되었네요.&lt;/p&gt;
&lt;p&gt;이번 시간에는 사용자와의 상호작용을 한층 더 풍부하게 만들어줄 &lt;strong&gt;셀렉트 메뉴(Select Menu)&lt;/strong&gt;와 &lt;strong&gt;모달(Modal)&lt;/strong&gt;에 대해 알아보겠습니다. 지금까지는 주로 슬래시 명령어와 버튼을 사용했는데, 이제는 드롭다운 메뉴로 여러 선택지를 제공하거나, 팝업 창으로 복잡한 정보를 입력받을 수 있게 될 거예요.&lt;/p&gt;
&lt;p&gt;특히 설정 메뉴나 다중 선택이 필요한 상황에서 이런 UI 컴포넌트들은 정말 유용합니다. 사용자 경험도 훨씬 좋아지고, 봇이 더욱 전문적으로 보이게 만들어주는 요소들이죠.&lt;/p&gt;
&lt;h2&gt;셀렉트 메뉴(Select Menu) 기본 이해하기&lt;/h2&gt;
&lt;p&gt;셀렉트 메뉴는 드롭다운 형태로 여러 선택지를 제공하는 UI 컴포넌트입니다. 버튼과 비슷하지만 공간을 절약하면서도 더 많은 옵션을 제공할 수 있어요.&lt;/p&gt;
&lt;p&gt;셀렉트 메뉴에는 여러 종류가 있습니다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;StringSelectMenu&lt;/strong&gt;: 일반적인 텍스트 선택지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UserSelectMenu&lt;/strong&gt;: 사용자 선택&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RoleSelectMenu&lt;/strong&gt;: 역할 선택&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ChannelSelectMenu&lt;/strong&gt;: 채널 선택&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MentionableSelectMenu&lt;/strong&gt;: 멘션 가능한 대상 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;가장 많이 사용하는 StringSelectMenu부터 알아보겠습니다.&lt;/p&gt;
&lt;h3&gt;기본 셀렉트 메뉴 만들기&lt;/h3&gt;
&lt;p&gt;먼저 간단한 음식 선택 메뉴를 만들어보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/food-menu.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  StringSelectMenuBuilder,
  ActionRowBuilder,
  EmbedBuilder,
} from &amp;quot;discord.js&amp;quot;;

export const data = new SlashCommandBuilder()
  .setName(&amp;quot;음식메뉴&amp;quot;)
  .setDescription(&amp;quot;오늘 먹을 음식을 선택해보세요!&amp;quot;);

export async function execute(interaction: ChatInputCommandInteraction) {
  const selectMenu = new StringSelectMenuBuilder()
    .setCustomId(&amp;quot;food-select&amp;quot;)
    .setPlaceholder(&amp;quot;음식을 선택해주세요...&amp;quot;)
    .addOptions([
      {
        label: &amp;quot;한식&amp;quot;,
        description: &amp;quot;김치찌개, 불고기, 비빔밥 등&amp;quot;,
        value: &amp;quot;korean&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;중식&amp;quot;,
        description: &amp;quot;짜장면, 탕수육, 마파두부 등&amp;quot;,
        value: &amp;quot;chinese&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;일식&amp;quot;,
        description: &amp;quot;초밥, 라멘, 돈카츠 등&amp;quot;,
        value: &amp;quot;japanese&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;양식&amp;quot;,
        description: &amp;quot;파스타, 피자, 스테이크 등&amp;quot;,
        value: &amp;quot;western&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;분식&amp;quot;,
        description: &amp;quot;떡볶이, 순대, 어묵 등&amp;quot;,
        value: &amp;quot;snack&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
    ]);

  const row = new ActionRowBuilder&amp;lt;StringSelectMenuBuilder&amp;gt;().addComponents(
    selectMenu
  );

  const embed = new EmbedBuilder()
    .setTitle(&amp;quot; ️ 오늘의 음식 선택&amp;quot;)
    .setDescription(&amp;quot;아래 메뉴에서 원하는 음식 종류를 선택해주세요!&amp;quot;)
    .setColor(0xffb347);

  await interaction.reply({
    embeds: [embed],
    components: [row],
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;셀렉트 메뉴 상호작용 처리하기&lt;/h3&gt;
&lt;p&gt;이제 사용자가 셀렉트 메뉴에서 선택했을 때의 동작을 처리해야 합니다. 기존의 InteractionCreate 이벤트 핸들러를 확장해보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts (또는 기존 InteractionCreate 핸들러)
client.on(&amp;quot;interactionCreate&amp;quot;, async (interaction) =&amp;gt; {
  // 기존 슬래시 명령어 처리
  if (interaction.isChatInputCommand()) {
    // ...existing code...
  }

  // 셀렉트 메뉴 상호작용 처리
  if (interaction.isStringSelectMenu()) {
    if (interaction.customId === &amp;quot;food-select&amp;quot;) {
      await handleFoodSelection(interaction);
    }
  }
});

async function handleFoodSelection(interaction: StringSelectMenuInteraction) {
  const selectedValue = interaction.values[0]; // 선택된 첫 번째 값

  const foodRecommendations = {
    korean: [&amp;quot;김치찌개&amp;quot;, &amp;quot;불고기&amp;quot;, &amp;quot;비빔밥&amp;quot;, &amp;quot;삼겹살&amp;quot;, &amp;quot;냉면&amp;quot;],
    chinese: [&amp;quot;짜장면&amp;quot;, &amp;quot;탕수육&amp;quot;, &amp;quot;마파두부&amp;quot;, &amp;quot;깐풍기&amp;quot;, &amp;quot;짬뽕&amp;quot;],
    japanese: [&amp;quot;초밥&amp;quot;, &amp;quot;라멘&amp;quot;, &amp;quot;돈카츠&amp;quot;, &amp;quot;우동&amp;quot;, &amp;quot;규동&amp;quot;],
    western: [&amp;quot;파스타&amp;quot;, &amp;quot;피자&amp;quot;, &amp;quot;스테이크&amp;quot;, &amp;quot;햄버거&amp;quot;, &amp;quot;리조또&amp;quot;],
    snack: [&amp;quot;떡볶이&amp;quot;, &amp;quot;순대&amp;quot;, &amp;quot;어묵&amp;quot;, &amp;quot;김밥&amp;quot;, &amp;quot;튀김&amp;quot;],
  };

  const recommendations =
    foodRecommendations[selectedValue as keyof typeof foodRecommendations];
  const randomFood =
    recommendations[Math.floor(Math.random() * recommendations.length)];

  const embed = new EmbedBuilder()
    .setTitle(&amp;quot;  음식 추천 결과&amp;quot;)
    .setDescription(`오늘은 **${randomFood}** 어떠세요?`)
    .setColor(0x57f287)
    .setFooter({ text: &amp;quot;맛있는 식사 되세요!&amp;quot; });

  await interaction.update({
    embeds: [embed],
    components: [], // 컴포넌트 제거
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;다중 선택 가능한 셀렉트 메뉴&lt;/h3&gt;
&lt;p&gt;셀렉트 메뉴는 여러 항목을 동시에 선택할 수도 있습니다. 취미를 선택하는 메뉴를 만들어보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/hobby-select.ts
export const data = new SlashCommandBuilder()
  .setName(&amp;quot;취미선택&amp;quot;)
  .setDescription(&amp;quot;관심있는 취미를 선택해보세요! (복수 선택 가능)&amp;quot;);

export async function execute(interaction: ChatInputCommandInteraction) {
  const selectMenu = new StringSelectMenuBuilder()
    .setCustomId(&amp;quot;hobby-select&amp;quot;)
    .setPlaceholder(&amp;quot;취미를 선택해주세요... (최대 3개)&amp;quot;)
    .setMinValues(1) // 최소 선택 개수
    .setMaxValues(3) // 최대 선택 개수
    .addOptions([
      {
        label: &amp;quot;게임&amp;quot;,
        description: &amp;quot;컴퓨터, 모바일, 콘솔 게임&amp;quot;,
        value: &amp;quot;gaming&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;독서&amp;quot;,
        description: &amp;quot;소설, 에세이, 전문서적&amp;quot;,
        value: &amp;quot;reading&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;운동&amp;quot;,
        description: &amp;quot;헬스, 러닝, 요가, 수영&amp;quot;,
        value: &amp;quot;exercise&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;음악&amp;quot;,
        description: &amp;quot;듣기, 연주, 작곡&amp;quot;,
        value: &amp;quot;music&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;요리&amp;quot;,
        description: &amp;quot;베이킹, 한식, 양식&amp;quot;,
        value: &amp;quot;cooking&amp;quot;,
        emoji: &amp;quot; ‍ &amp;quot;,
      },
      {
        label: &amp;quot;여행&amp;quot;,
        description: &amp;quot;국내외 여행, 캠핑&amp;quot;,
        value: &amp;quot;travel&amp;quot;,
        emoji: &amp;quot;✈️&amp;quot;,
      },
    ]);

  const row = new ActionRowBuilder&amp;lt;StringSelectMenuBuilder&amp;gt;().addComponents(
    selectMenu
  );

  const embed = new EmbedBuilder()
    .setTitle(&amp;quot;  취미 선택&amp;quot;)
    .setDescription(&amp;quot;아래 메뉴에서 관심있는 취미를 선택해주세요!&amp;quot;)
    .setColor(0x57f287);

  await interaction.reply({
    embeds: [embed],
    components: [row],
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;모달(Modal) 활용하기&lt;/h2&gt;
&lt;p&gt;모달은 팝업 창 형태로 사용자로부터 복잡한 정보를 입력받을 수 있는 컴포넌트입니다. 여러 개의 텍스트 입력 필드를 포함할 수 있어서 설문조사나 피드백 수집 등에 아주 유용해요.&lt;/p&gt;
&lt;h3&gt;기본 모달 만들기&lt;/h3&gt;
&lt;p&gt;사용자 프로필을 설정하는 모달을 만들어보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/profile-setup.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  ModalBuilder,
  TextInputBuilder,
  ActionRowBuilder,
  TextInputStyle,
} from &amp;quot;discord.js&amp;quot;;

export const data = new SlashCommandBuilder()
  .setName(&amp;quot;프로필설정&amp;quot;)
  .setDescription(&amp;quot;사용자 프로필을 설정합니다&amp;quot;);

export async function execute(interaction: ChatInputCommandInteraction) {
  const modal = new ModalBuilder()
    .setCustomId(&amp;quot;profile-modal&amp;quot;)
    .setTitle(&amp;quot;프로필 설정&amp;quot;);

  // 닉네임 입력 필드
  const nicknameInput = new TextInputBuilder()
    .setCustomId(&amp;quot;nickname-input&amp;quot;)
    .setLabel(&amp;quot;표시할 닉네임&amp;quot;)
    .setStyle(TextInputStyle.Short) // 한 줄 입력
    .setMaxLength(20)
    .setRequired(true)
    .setPlaceholder(&amp;quot;예: 디스호스트&amp;quot;);

  // 자기소개 입력 필드
  const bioInput = new TextInputBuilder()
    .setCustomId(&amp;quot;bio-input&amp;quot;)
    .setLabel(&amp;quot;자기소개&amp;quot;)
    .setStyle(TextInputStyle.Paragraph) // 여러 줄 입력
    .setMaxLength(500)
    .setRequired(false)
    .setPlaceholder(&amp;quot;자신을 소개해보세요...&amp;quot;);

  // 나이 입력 필드
  const ageInput = new TextInputBuilder()
    .setCustomId(&amp;quot;age-input&amp;quot;)
    .setLabel(&amp;quot;나이&amp;quot;)
    .setStyle(TextInputStyle.Short)
    .setMaxLength(3)
    .setRequired(false)
    .setPlaceholder(&amp;quot;예: 25&amp;quot;);

  // 지역 입력 필드
  const locationInput = new TextInputBuilder()
    .setCustomId(&amp;quot;location-input&amp;quot;)
    .setLabel(&amp;quot;거주 지역&amp;quot;)
    .setStyle(TextInputStyle.Short)
    .setMaxLength(50)
    .setRequired(false)
    .setPlaceholder(&amp;quot;예: 서울특별시&amp;quot;);

  // 취미 입력 필드
  const hobbiesInput = new TextInputBuilder()
    .setCustomId(&amp;quot;hobbies-input&amp;quot;)
    .setLabel(&amp;quot;취미/관심사&amp;quot;)
    .setStyle(TextInputStyle.Paragraph)
    .setMaxLength(200)
    .setRequired(false)
    .setPlaceholder(&amp;quot;예: 게임, 독서, 영화감상&amp;quot;);

  // ActionRow로 감싸기 (한 row당 하나의 TextInput만 가능)
  const firstRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    nicknameInput
  );
  const secondRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    bioInput
  );
  const thirdRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    ageInput
  );
  const fourthRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    locationInput
  );
  const fifthRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    hobbiesInput
  );

  modal.addComponents(firstRow, secondRow, thirdRow, fourthRow, fifthRow);

  await interaction.showModal(modal);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;모달 제출 처리하기&lt;/h3&gt;
&lt;p&gt;사용자가 모달을 제출했을 때의 처리 로직을 추가해보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts (InteractionCreate 이벤트 핸들러에 추가)
client.on(&amp;quot;interactionCreate&amp;quot;, async (interaction) =&amp;gt; {
  // ...existing code...

  // 모달 제출 처리
  if (interaction.isModalSubmit()) {
    if (interaction.customId === &amp;quot;profile-modal&amp;quot;) {
      await handleProfileModal(interaction);
    }
  }
});

async function handleProfileModal(interaction: ModalSubmitInteraction) {
  // 입력된 값들 가져오기
  const nickname = interaction.fields.getTextInputValue(&amp;quot;nickname-input&amp;quot;);
  const bio =
    interaction.fields.getTextInputValue(&amp;quot;bio-input&amp;quot;) || &amp;quot;자기소개가 없습니다.&amp;quot;;
  const age = interaction.fields.getTextInputValue(&amp;quot;age-input&amp;quot;) || &amp;quot;비공개&amp;quot;;
  const location =
    interaction.fields.getTextInputValue(&amp;quot;location-input&amp;quot;) || &amp;quot;비공개&amp;quot;;
  const hobbies =
    interaction.fields.getTextInputValue(&amp;quot;hobbies-input&amp;quot;) ||
    &amp;quot;특별한 취미가 없습니다.&amp;quot;;

  // 데이터베이스에 저장 (Prisma 사용 예시)
  try {
    await prisma.userProfile.upsert({
      where: { discordId: interaction.user.id },
      create: {
        discordId: interaction.user.id,
        nickname,
        bio,
        age: age !== &amp;quot;비공개&amp;quot; ? parseInt(age) : null,
        location: location !== &amp;quot;비공개&amp;quot; ? location : null,
        hobbies: hobbies !== &amp;quot;특별한 취미가 없습니다.&amp;quot; ? hobbies : null,
      },
      update: {
        nickname,
        bio,
        age: age !== &amp;quot;비공개&amp;quot; ? parseInt(age) : null,
        location: location !== &amp;quot;비공개&amp;quot; ? location : null,
        hobbies: hobbies !== &amp;quot;특별한 취미가 없습니다.&amp;quot; ? hobbies : null,
      },
    });

    const embed = new EmbedBuilder()
      .setTitle(&amp;quot;✅ 프로필 설정 완료&amp;quot;)
      .setDescription(&amp;quot;프로필이 성공적으로 업데이트되었습니다!&amp;quot;)
      .addFields([
        { name: &amp;quot;닉네임&amp;quot;, value: nickname, inline: true },
        { name: &amp;quot;나이&amp;quot;, value: age, inline: true },
        { name: &amp;quot;지역&amp;quot;, value: location, inline: true },
        { name: &amp;quot;자기소개&amp;quot;, value: bio },
        { name: &amp;quot;취미/관심사&amp;quot;, value: hobbies },
      ])
      .setColor(0x57f287)
      .setThumbnail(interaction.user.displayAvatarURL())
      .setTimestamp();

    await interaction.reply({ embeds: [embed], ephemeral: true });
  } catch (error) {
    console.error(&amp;quot;프로필 저장 중 오류:&amp;quot;, error);

    await interaction.reply({
      content: &amp;quot;프로필 저장 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.&amp;quot;,
      ephemeral: true,
    });
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;User/Role/Channel Select Menu 활용하기&lt;/h2&gt;
&lt;p&gt;StringSelectMenu 외에도 Discord의 다른 요소들을 선택할 수 있는 특별한 메뉴들이 있습니다.&lt;/p&gt;
&lt;h3&gt;역할 관리 메뉴 만들기&lt;/h3&gt;
&lt;p&gt;관리자가 사용자에게 역할을 부여하는 기능을 만들어보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/role-manager.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  UserSelectMenuBuilder,
  RoleSelectMenuBuilder,
  ActionRowBuilder,
  PermissionsBitField,
} from &amp;quot;discord.js&amp;quot;;

export const data = new SlashCommandBuilder()
  .setName(&amp;quot;역할관리&amp;quot;)
  .setDescription(&amp;quot;사용자에게 역할을 부여하거나 제거합니다&amp;quot;)
  .setDefaultMemberPermissions(PermissionsBitField.Flags.ManageRoles);

export async function execute(interaction: ChatInputCommandInteraction) {
  if (!interaction.inGuild()) {
    return interaction.reply({
      content: &amp;quot;이 명령어는 서버에서만 사용할 수 있습니다.&amp;quot;,
      ephemeral: true,
    });
  }

  // 1단계: 사용자 선택
  const userSelect = new UserSelectMenuBuilder()
    .setCustomId(&amp;quot;role-user-select&amp;quot;)
    .setPlaceholder(&amp;quot;역할을 변경할 사용자를 선택하세요...&amp;quot;);

  const userRow = new ActionRowBuilder&amp;lt;UserSelectMenuBuilder&amp;gt;().addComponents(
    userSelect
  );

  await interaction.reply({
    content: &amp;quot;먼저 역할을 변경할 사용자를 선택해주세요:&amp;quot;,
    components: [userRow],
    ephemeral: true,
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;채널 선택 메뉴&lt;/h3&gt;
&lt;p&gt;공지사항을 보낼 채널을 선택하는 기능을 만들어보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/announcement.ts
export const data = new SlashCommandBuilder()
  .setName(&amp;quot;공지작성&amp;quot;)
  .setDescription(&amp;quot;공지사항을 작성하고 채널을 선택합니다&amp;quot;)
  .addStringOption((option) =&amp;gt;
    option.setName(&amp;quot;내용&amp;quot;).setDescription(&amp;quot;공지 내용&amp;quot;).setRequired(true)
  )
  .setDefaultMemberPermissions(PermissionsBitField.Flags.ManageMessages);

export async function execute(interaction: ChatInputCommandInteraction) {
  const content = interaction.options.getString(&amp;quot;내용&amp;quot;, true);

  const channelSelect = new ChannelSelectMenuBuilder()
    .setCustomId(&amp;quot;announcement-channel-select&amp;quot;)
    .setPlaceholder(&amp;quot;공지를 보낼 채널을 선택하세요...&amp;quot;)
    .setChannelTypes([ChannelType.GuildText, ChannelType.GuildAnnouncement]);

  const row = new ActionRowBuilder&amp;lt;ChannelSelectMenuBuilder&amp;gt;().addComponents(
    channelSelect
  );

  // 공지 내용을 임시 저장하기 위해 customId에 포함시키거나 별도 저장소 사용
  await interaction.reply({
    content: `다음 공지를 보낼 채널을 선택해주세요:\n\`\`\`${content}\`\`\``,
    components: [row],
    ephemeral: true,
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;복합적인 상호작용 시나리오&lt;/h2&gt;
&lt;p&gt;셀렉트 메뉴와 모달을 조합해서 더 복잡한 상호작용을 만들어보겠습니다. 티켓 시스템을 예로 들어볼게요.&lt;/p&gt;
&lt;h3&gt;티켓 생성 시스템&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/ticket.ts
export const data = new SlashCommandBuilder()
  .setName(&amp;quot;티켓&amp;quot;)
  .setDescription(&amp;quot;고객 지원 티켓을 생성합니다&amp;quot;);

export async function execute(interaction: ChatInputCommandInteraction) {
  const categorySelect = new StringSelectMenuBuilder()
    .setCustomId(&amp;quot;ticket-category-select&amp;quot;)
    .setPlaceholder(&amp;quot;문의 유형을 선택해주세요...&amp;quot;)
    .addOptions([
      {
        label: &amp;quot;일반 문의&amp;quot;,
        description: &amp;quot;서비스 이용 관련 일반적인 질문&amp;quot;,
        value: &amp;quot;general&amp;quot;,
        emoji: &amp;quot;❓&amp;quot;,
      },
      {
        label: &amp;quot;기술 지원&amp;quot;,
        description: &amp;quot;봇 오류나 기술적 문제&amp;quot;,
        value: &amp;quot;technical&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;신고&amp;quot;,
        description: &amp;quot;부적절한 행동이나 규칙 위반 신고&amp;quot;,
        value: &amp;quot;report&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
      {
        label: &amp;quot;제안&amp;quot;,
        description: &amp;quot;새로운 기능이나 개선사항 제안&amp;quot;,
        value: &amp;quot;suggestion&amp;quot;,
        emoji: &amp;quot; &amp;quot;,
      },
    ]);

  const row = new ActionRowBuilder&amp;lt;StringSelectMenuBuilder&amp;gt;().addComponents(
    categorySelect
  );

  const embed = new EmbedBuilder()
    .setTitle(&amp;quot;  티켓 생성&amp;quot;)
    .setDescription(&amp;quot;문의하실 내용의 유형을 선택해주세요.&amp;quot;)
    .setColor(0x5865f2);

  await interaction.reply({
    embeds: [embed],
    components: [row],
    ephemeral: true,
  });
}

// 카테고리 선택 후 모달 표시
async function handleTicketCategorySelect(
  interaction: StringSelectMenuInteraction
) {
  const category = interaction.values[0];

  const modal = new ModalBuilder()
    .setCustomId(`ticket-modal-${category}`)
    .setTitle(&amp;quot;티켓 상세 정보&amp;quot;);

  const titleInput = new TextInputBuilder()
    .setCustomId(&amp;quot;ticket-title&amp;quot;)
    .setLabel(&amp;quot;제목&amp;quot;)
    .setStyle(TextInputStyle.Short)
    .setMaxLength(100)
    .setRequired(true);

  const descriptionInput = new TextInputBuilder()
    .setCustomId(&amp;quot;ticket-description&amp;quot;)
    .setLabel(&amp;quot;상세 설명&amp;quot;)
    .setStyle(TextInputStyle.Paragraph)
    .setMaxLength(1000)
    .setRequired(true);

  const priorityInput = new TextInputBuilder()
    .setCustomId(&amp;quot;ticket-priority&amp;quot;)
    .setLabel(&amp;quot;우선순위 (낮음/보통/높음/긴급)&amp;quot;)
    .setStyle(TextInputStyle.Short)
    .setValue(&amp;quot;보통&amp;quot;)
    .setRequired(true);

  const firstRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    titleInput
  );
  const secondRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    descriptionInput
  );
  const thirdRow = new ActionRowBuilder&amp;lt;TextInputBuilder&amp;gt;().addComponents(
    priorityInput
  );

  modal.addComponents(firstRow, secondRow, thirdRow);

  await interaction.showModal(modal);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;상호작용 제한시간과 에러 처리&lt;/h2&gt;
&lt;p&gt;UI 컴포넌트들은 15분의 제한시간이 있습니다. 이를 고려한 에러 처리도 중요해요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 상호작용 만료 처리
setTimeout(async () =&amp;gt; {
  try {
    await interaction.editReply({
      content: &amp;quot;이 상호작용은 만료되었습니다.&amp;quot;,
      components: [],
    });
  } catch (error) {
    // 이미 다른 상호작용으로 업데이트되었거나 삭제된 경우
    console.log(&amp;quot;상호작용 만료 처리 중 오류:&amp;quot;, error);
  }
}, 15 * 60 * 1000); // 15분

// 에러 핸들링 래퍼 함수
async function safeInteractionReply(interaction: any, options: any) {
  try {
    if (interaction.replied || interaction.deferred) {
      await interaction.editReply(options);
    } else {
      await interaction.reply(options);
    }
  } catch (error) {
    console.error(&amp;quot;상호작용 응답 중 오류:&amp;quot;, error);

    // 백업 응답 시도
    try {
      await interaction.followUp({
        content: &amp;quot;응답 처리 중 오류가 발생했습니다.&amp;quot;,
        ephemeral: true,
      });
    } catch (followUpError) {
      console.error(&amp;quot;백업 응답도 실패:&amp;quot;, followUpError);
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;이번 시간에는 셀렉트 메뉴와 모달을 활용해서 사용자와 더욱 풍부한 상호작용을 구현하는 방법을 알아봤습니다. 이런 UI 컴포넌트들을 잘 활용하면 봇의 사용성을 크게 향상시킬 수 있어요.&lt;/p&gt;
&lt;p&gt;특히 설정 메뉴, 설문조사, 티켓 시스템 등에서 이런 요소들이 정말 빛을 발합니다. 사용자 입장에서도 명령어를 일일이 입력하는 것보다 클릭 몇 번으로 원하는 작업을 할 수 있으니 훨씬 편리하죠.&lt;/p&gt;
&lt;p&gt;다음 시간에는 &lt;strong&gt;사용자 반응(이모지) 기반 기능 만들기&lt;/strong&gt;에 대해 알아보겠습니다. 메시지에 이모지 반응을 추가하거나 제거할 때 특정 동작을 수행하는 기능들을 구현해보겠어요. 역할 부여, 투표 시스템 등 다양한 활용 방법이 있으니 기대해주세요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/38</guid>
      <comments>https://dishost.tistory.com/38#entry38comment</comments>
      <pubDate>Wed, 11 Jun 2025 17:15:04 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자!</title>
      <link>https://dishost.tistory.com/37</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 지난 시간에는 Prisma를 사용해서 데이터베이스 연동을 해봤습니다. 이제 우리 봇은 데이터를 영구적으로 저장할 수 있게 되었고, 꽤나 완성도 있는 모습을 갖추게 되었죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 아직 우리 봇은 로컬 컴퓨터에서만 동작하고 있습니다. 컴퓨터를 끄면 봇도 함께 꺼지고, 다른 사람들이 우리 봇을 사용하려면 항상 컴퓨터를 켜두고 있어야 하죠. 이번 시간에는 봇을 실제 서버에 배포해서 24시간 내내 안정적으로 운영하는 방법을 알아보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 호스팅에는 여러 가지 방법이 있는데, 각각의 장단점과 비용, 그리고 설정 방법까지 차근차근 살펴보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;봇 호스팅 방법들 비교하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇을 호스팅하는 방법은 크게 몇 가지로 나눌 수 있습니다. 각각의 특징을 먼저 알아보고, 본인의 상황에 맞는 방법을 선택해보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. VPS (Virtual Private Server)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPS는 가상 사설 서버로, 클라우드 환경에서 독립적인 서버 환경을 제공받는 방식입니다. AWS EC2, Google Cloud Compute Engine, 가비아, 호스팅케이알 등이 대표적이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;완전한 서버 제어권을 가질 수 있어요&lt;/li&gt;
&lt;li&gt;다양한 프로그램과 서비스를 함께 운영할 수 있어요&lt;/li&gt;
&lt;li&gt;리눅스 서버 관리 경험을 쌓을 수 있어요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 관리에 대한 기본 지식이 필요해요&lt;/li&gt;
&lt;li&gt;보안 설정, 업데이트 등을 직접 관리해야 해요&lt;/li&gt;
&lt;li&gt;초기 설정이 복잡할 수 있어요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 디스코드 봇 전용 호스팅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트 같은 디스코드 봇 전용 호스팅 서비스입니다. 봇 운영에 최적화되어 있어서 별도의 서버 관리 없이도 쉽게 봇을 호스팅할 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GUI 패널로 쉽게 관리할 수 있어요&lt;/li&gt;
&lt;li&gt;디스코드 봇에 특화된 기능들을 제공해요&lt;/li&gt;
&lt;li&gt;한국어 지원과 빠른 고객 지원을 받을 수 있어요&lt;/li&gt;
&lt;li&gt;복잡한 서버 설정 없이 바로 사용 가능해요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;봇 운영 외의 다른 용도로는 사용하기 어려워요&lt;/li&gt;
&lt;li&gt;제공업체의 정책에 따라 제약이 있을 수 있어요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 클라우드 플랫폼 (PaaS)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Heroku, Railway, Render 등의 PaaS(Platform as a Service) 서비스를 이용하는 방법입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Git 연동으로 자동 배포가 가능해요&lt;/li&gt;
&lt;li&gt;서버 관리 부담이 적어요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제약이 많아요 (시간 제한, 슬립 모드 등)&lt;/li&gt;
&lt;li&gt;데이터베이스 등 추가 서비스 비용이 발생할 수 있어요&lt;/li&gt;
&lt;li&gt;일부 서비스는 한국에서 속도가 느릴 수 있어요&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디스호스트로 간편하게 배포하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPS 설정이 복잡하다고 느끼신다면, 디스호스트 같은 전용 호스팅 서비스를 사용하는 것도 좋은 선택입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 디스호스트는 모든 서비스를 &lt;b&gt;무료&lt;/b&gt;로 제공하고 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;디스호스트 가입하기&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://dishost.kr&quot;&gt;디스호스트 홈페이지&lt;/a&gt;에 접속합니다&lt;/li&gt;
&lt;li&gt;이용 약관에 동의합니다.&lt;/li&gt;
&lt;li&gt;Pterodactyl 패널 연결을 위한 정보를 입력합니다&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;봇 업로드 및 실행&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Pterodactyl 패널에 접속합니다&lt;/li&gt;
&lt;li&gt;파일 매니저를 통해 봇 파일들을 업로드합니다&lt;/li&gt;
&lt;li&gt;환경 변수를 설정합니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;npm start&lt;/code&gt; 또는 &lt;code&gt;node dist/index.js&lt;/code&gt;로 봇을 실행합니다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세한 과정은 &lt;a href=&quot;https://blog.dishost.kr/47&quot;&gt;디스호스트 공식 가이드&lt;/a&gt;를 참고하시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 시 주의사항들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 환경 변수 보안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;절대로 &lt;code&gt;.env&lt;/code&gt; 파일을 Git에 커밋하지 마세요. &lt;code&gt;.gitignore&lt;/code&gt; 파일에 다음을 추가해주세요:&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;.env
.env.local
.env.production
node_modules/
dist/
*.log&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 에러 로깅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로덕션 환경에서는 에러 로깅이 매우 중요합니다. 간단한 로깅 시스템을 추가해보세요:&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/utils/logger.ts
export const logger = {
  info: (message: string) =&amp;gt; {
    console.log(`[INFO] ${new Date().toISOString()}: ${message}`);
  },
  error: (message: string, error?: Error) =&amp;gt; {
    console.error(`[ERROR] ${new Date().toISOString()}: ${message}`);
    if (error) {
      console.error(error.stack);
    }
  },
};

// 사용 예시
import { logger } from &quot;./utils/logger&quot;;

client.on(&quot;ready&quot;, () =&amp;gt; {
  logger.info(`봇이 ${client.user?.tag}로 로그인했습니다!`);
});

client.on(&quot;error&quot;, (error) =&amp;gt; {
  logger.error(&quot;Discord.js 클라이언트 에러:&quot;, error);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 모니터링&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇이 정상적으로 작동하는지 모니터링하는 것도 중요합니다. 간단한 헬스체크 기능을 추가해보세요:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// src/utils/healthcheck.ts
export function startHealthCheck() {
  setInterval(() =&amp;gt; {
    if (client.isReady()) {
      logger.info(&quot;봇 상태 정상&quot;);
    } else {
      logger.error(&quot;봇 연결 끊어짐 감지&quot;);
      // 필요시 재연결 로직 추가
    }
  }, 60000); // 1분마다 체크
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 후 할 일들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;슬래시 명령어 전역 등록&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 중에는 특정 서버에만 명령어를 등록했지만, 배포 후에는 전역으로 등록하는 것을 고려해보세요:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// src/deploy-commands.ts 수정
const rest = new REST().setToken(config.DISCORD_TOKEN);

// 전역 등록 (guild_id 없이)
await rest.put(Routes.applicationCommands(config.DISCORD_CLIENT_ID), {
  body: commands,
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 상태 명령어 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇의 상태를 확인할 수 있는 명령어를 추가하면 유용합니다:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// src/commands/status.ts
export const data = new SlashCommandBuilder()
  .setName(&quot;상태&quot;)
  .setDescription(&quot;봇의 현재 상태를 확인합니다&quot;);

export async function execute(interaction: ChatInputCommandInteraction) {
  const uptime = process.uptime();
  const hours = Math.floor(uptime / 3600);
  const minutes = Math.floor((uptime % 3600) / 60);

  const embed = new EmbedBuilder()
    .setTitle(&quot;  봇 상태&quot;)
    .addFields(
      { name: &quot;업타임&quot;, value: `${hours}시간 ${minutes}분`, inline: true },
      {
        name: &quot;서버 수&quot;,
        value: `${interaction.client.guilds.cache.size}개`,
        inline: true,
      },
      {
        name: &quot;사용자 수&quot;,
        value: `${interaction.client.users.cache.size}명`,
        inline: true,
      }
    )
    .setColor(0x00ae86)
    .setTimestamp();

  await interaction.reply({ embeds: [embed] });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 Discord.js 봇을 실제 서버에 배포하여 24/7 운영하는 방법을 알아봤습니다. VPS를 사용한 직접 배포부터 디스호스트 같은 전용 호스팅 서비스까지, 다양한 선택지가 있으니 본인의 상황에 맞는 방법을 선택하시면 됩니다. 에러 로깅과 모니터링, 백업 등의 운영 요소들도 함께 고려하면 더욱 견고한 봇 서비스를 만들 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 시간에는 &lt;b&gt;대화형 UI: 셀렉트 메뉴와 모달 활용하기&lt;/b&gt;에 대해 알아보겠습니다. 사용자와 더욱 풍부한 상호작용을 할 수 있는 고급 UI 컴포넌트들을 활용하는 방법을 배워보겠어요. 기대해주세요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/37</guid>
      <comments>https://dishost.tistory.com/37#entry37comment</comments>
      <pubDate>Wed, 11 Jun 2025 17:14:41 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 8. Prisma로 SQLite, MySQL 연동하기</title>
      <link>https://dishost.tistory.com/36</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 지난 시간에는 역할과 권한 체크를 통해 봇의 보안을 강화하는 방법을 알아봤습니다. 이번 시간에는 드디어 많은 분들이 기다리셨을 데이터베이스 연동, 그중에서도 Prisma를 사용해서 SQLite와 MySQL을 다루는 방법을 배워보겠습니다.&lt;/p&gt;
&lt;p&gt;봇을 운영하다 보면 데이터를 저장하고 불러와야 하는 경우가 정말 많습니다. 예를 들어 유저별 레벨 시스템, 경고 횟수, 서버별 설정 등등... 이런 데이터를 효과적으로 관리하려면 데이터베이스가 필수적이죠. Prisma는 타입스크립트와 아주 잘 맞는 ORM(Object-Relational Mapper)이라서, 복잡한 SQL 쿼리 없이도 자바스크립트/타입스크립트 코드로 데이터베이스를 다룰 수 있게 해줍니다.&lt;/p&gt;
&lt;h2&gt;Prisma란 무엇일까요?&lt;/h2&gt;
&lt;p&gt;Prisma는 데이터베이스 작업을 쉽게 만들어주는 도구입니다. 우리가 직접 SQL 쿼리문을 작성하는 대신, Prisma가 제공하는 API를 사용해서 마치 객체를 다루듯이 데이터베이스와 상호작용할 수 있게 해줘요. 특히 타입스크립트와 함께 사용하면 자동완성 기능이나 타입 체크 같은 이점을 누릴 수 있어서 개발 경험이 훨씬 좋아집니다.&lt;/p&gt;
&lt;p&gt;Prisma는 크게 세 가지 요소로 구성됩니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Prisma Client&lt;/strong&gt;: 자동 생성되는 쿼리 빌더입니다. 타입스크립트 환경에서 데이터베이스에 접근할 때 사용합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prisma Migrate&lt;/strong&gt;: 데이터베이스 스키마를 관리하고 마이그레이션하는 도구입니다. 스키마 변경 사항을 안전하게 데이터베이스에 적용할 수 있게 도와줍니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prisma Studio&lt;/strong&gt;: 데이터베이스의 데이터를 시각적으로 보고 편집할 수 있는 GUI 도구입니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Prisma 프로젝트에 추가하기&lt;/h2&gt;
&lt;p&gt;자, 그럼 우리 봇 프로젝트에 Prisma를 설치하고 설정해봅시다.&lt;/p&gt;
&lt;p&gt;먼저 필요한 패키지들을 설치해야 합니다. 터미널을 열고 프로젝트 루트 디렉토리에서 다음 명령어를 실행해주세요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install prisma --save-dev
npm install @prisma/client&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;prisma&lt;/code&gt;는 Prisma CLI 도구를 설치하는 것이고, &lt;code&gt;@prisma/client&lt;/code&gt;는 실제로 코드에서 사용할 Prisma Client 라이브러리입니다.&lt;/p&gt;
&lt;p&gt;설치가 완료되면 Prisma를 초기화해줍니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma init&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 명령어를 실행하면 프로젝트 루트에 &lt;code&gt;prisma&lt;/code&gt;라는 폴더가 생기고, 그 안에 &lt;code&gt;schema.prisma&lt;/code&gt; 파일과 &lt;code&gt;.env&lt;/code&gt; 파일이 생성됩니다. &lt;code&gt;.env&lt;/code&gt; 파일에는 데이터베이스 연결 정보가 들어갈 거예요. &lt;code&gt;schema.prisma&lt;/code&gt; 파일은 데이터베이스 스키마를 정의하는 곳입니다.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;schema.prisma&lt;/code&gt; 파일 설정하기&lt;/h2&gt;
&lt;p&gt;이제 &lt;code&gt;prisma/schema.prisma&lt;/code&gt; 파일을 열어서 데이터베이스 연결 설정을 해봅시다. Prisma는 다양한 데이터베이스를 지원하는데요, 이번 튜토리얼에서는 개발 편의성을 위해 SQLite를 먼저 사용해보고, 나중에 MySQL로 변경하는 방법도 알아볼게요.&lt;/p&gt;
&lt;h3&gt;SQLite 설정&lt;/h3&gt;
&lt;p&gt;SQLite는 별도의 서버 설치 없이 파일 기반으로 동작하는 가벼운 데이터베이스입니다. 간단한 테스트나 로컬 개발 환경에 아주 유용하죠.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;schema.prisma&lt;/code&gt; 파일의 &lt;code&gt;datasource db&lt;/code&gt; 부분을 다음과 같이 수정합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;// schema.prisma

datasource db {
  provider = &amp;quot;sqlite&amp;quot;
  url      = env(&amp;quot;DATABASE_URL&amp;quot;)
}

generator client {
  provider = &amp;quot;prisma-client-js&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 프로젝트 루트의 &lt;code&gt;.env&lt;/code&gt; 파일을 열어서 &lt;code&gt;DATABASE_URL&lt;/code&gt;을 설정해줍니다. SQLite의 경우, 데이터베이스 파일의 경로를 지정해주면 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env
DATABASE_URL=&amp;quot;file:./dev.db&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 하면 프로젝트 루트에 &lt;code&gt;dev.db&lt;/code&gt;라는 파일로 SQLite 데이터베이스가 생성될 거예요.&lt;/p&gt;
&lt;h3&gt;MySQL 설정 (나중에 해볼 것)&lt;/h3&gt;
&lt;p&gt;MySQL을 사용하고 싶다면 &lt;code&gt;datasource db&lt;/code&gt; 부분을 이렇게 바꿀 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;// schema.prisma (MySQL 예시)

datasource db {
  provider = &amp;quot;mysql&amp;quot;
  url      = env(&amp;quot;DATABASE_URL&amp;quot;)
}

generator client {
  provider = &amp;quot;prisma-client-js&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 &lt;code&gt;.env&lt;/code&gt; 파일에는 MySQL 연결 정보를 넣어주면 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env (MySQL 예시)
DATABASE_URL=&amp;quot;mysql://USER:PASSWORD@HOST:PORT/DATABASE&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예를 들어 사용자 이름이 &lt;code&gt;root&lt;/code&gt;, 비밀번호가 &lt;code&gt;password&lt;/code&gt;, 호스트가 &lt;code&gt;localhost&lt;/code&gt;, 포트가 &lt;code&gt;3306&lt;/code&gt;, 데이터베이스 이름이 &lt;code&gt;mybotdb&lt;/code&gt;라면 이렇게 되겠죠.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;DATABASE_URL=&amp;quot;mysql://root:password@localhost:3306/mybotdb&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;일단은 SQLite로 진행하고, MySQL 설정은 참고만 해주세요.&lt;/p&gt;
&lt;h2&gt;첫 번째 모델 만들기&lt;/h2&gt;
&lt;p&gt;Prisma에서는 데이터베이스 테이블을 &amp;#39;모델(Model)&amp;#39;이라는 개념으로 정의합니다. 예를 들어, 유저의 정보를 저장하는 &lt;code&gt;User&lt;/code&gt; 모델을 만들어볼까요?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;schema.prisma&lt;/code&gt; 파일에 다음 내용을 추가합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;// schema.prisma

// ... (datasource db, generator client 부분은 그대로 둡니다)

model User {
  id        String   @id @default(cuid()) // 고유 ID (기본키)
  discordId String   @unique             // 디스코드 유저 ID (고유값)
  username  String                       // 디스코드 유저 이름
  level     Int      @default(1)         // 레벨 (기본값 1)
  xp        Int      @default(0)         // 경험치 (기본값 0)
  createdAt DateTime @default(now())     // 생성 시각
  updatedAt DateTime @updatedAt          // 업데이트 시각
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 필드에 대한 설명은 주석을 참고해주세요. &lt;code&gt;@id&lt;/code&gt;는 기본키, &lt;code&gt;@unique&lt;/code&gt;는 고유값, &lt;code&gt;@default()&lt;/code&gt;는 기본값을 의미합니다. &lt;code&gt;@updatedAt&lt;/code&gt;은 레코드가 업데이트될 때마다 자동으로 현재 시각으로 갱신해줍니다.&lt;/p&gt;
&lt;h2&gt;데이터베이스 마이그레이션&lt;/h2&gt;
&lt;p&gt;스키마를 정의했으니, 이제 이 변경사항을 실제 데이터베이스에 적용해야 합니다. 이걸 &amp;#39;마이그레이션(Migration)&amp;#39;이라고 해요. Prisma Migrate는 이 과정을 아주 쉽게 만들어줍니다.&lt;/p&gt;
&lt;p&gt;터미널에서 다음 명령어를 실행하세요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate dev --name init&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--name init&lt;/code&gt;은 이번 마이그레이션의 이름을 &lt;code&gt;init&lt;/code&gt;으로 지정한다는 의미입니다. 처음 마이그레이션할 때는 보통 &lt;code&gt;init&lt;/code&gt;이라고 많이 씁니다.&lt;/p&gt;
&lt;p&gt;이 명령어를 실행하면 Prisma가 &lt;code&gt;schema.prisma&lt;/code&gt; 파일의 변경사항을 감지하고, SQL 마이그레이션 파일을 생성한 다음, 데이터베이스에 적용해줍니다. SQLite를 사용하고 있다면 &lt;code&gt;prisma&lt;/code&gt; 폴더 아래에 &lt;code&gt;migrations&lt;/code&gt; 폴더가 생기고, 그 안에 마이그레이션 파일과 함께 &lt;code&gt;dev.db&lt;/code&gt; 파일도 생성된 것을 볼 수 있을 거예요.&lt;/p&gt;
&lt;h2&gt;Prisma Client 생성 및 사용 준비&lt;/h2&gt;
&lt;p&gt;마이그레이션까지 마쳤다면, 이제 Prisma Client를 생성할 차례입니다. Prisma Client는 우리가 정의한 모델(예: &lt;code&gt;User&lt;/code&gt; 모델)을 기반으로 타입 세이프한 데이터베이스 접근 코드를 자동으로 만들어줍니다.&lt;/p&gt;
&lt;p&gt;터미널에서 다음 명령어를 실행하세요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma generate&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 명령어를 실행하면 &lt;code&gt;node_modules/@prisma/client&lt;/code&gt;에 우리 스키마에 맞는 Prisma Client 코드가 생성(또는 업데이트)됩니다. 보통 &lt;code&gt;prisma migrate dev&lt;/code&gt;를 실행하면 자동으로 &lt;code&gt;prisma generate&lt;/code&gt;도 같이 실행되지만, 스키마만 수정하고 마이그레이션을 하지 않았을 경우 등 수동으로 실행해야 할 때도 있습니다.&lt;/p&gt;
&lt;p&gt;이제 코드에서 Prisma Client를 사용할 준비가 거의 다 됐습니다!&lt;/p&gt;
&lt;p&gt;봇 코드에서 Prisma Client를 사용하려면, 먼저 클라이언트 인스턴스를 만들어야 합니다. 보통 &lt;code&gt;src&lt;/code&gt; 폴더 아래에 &lt;code&gt;prisma.ts&lt;/code&gt; 같은 파일을 만들어서 관리하는 것이 좋습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;src/prisma.ts&lt;/code&gt; 파일을 만들고 다음 내용을 작성해주세요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/prisma.ts
import { PrismaClient } from &amp;quot;@prisma/client&amp;quot;;

const prisma = new PrismaClient();

export default prisma;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 하면 프로젝트 어디에서든 &lt;code&gt;prisma&lt;/code&gt; 객체를 import 해서 사용할 수 있게 됩니다.&lt;/p&gt;
&lt;h2&gt;봇 명령어에서 Prisma 사용하기&lt;/h2&gt;
&lt;p&gt;자, 이제 실제로 봇 명령어에서 Prisma를 사용해서 데이터를 읽고 써봅시다. 간단하게 유저가 처음으로 특정 명령어를 사용했을 때 유저 정보를 데이터베이스에 저장하고, 다시 사용하면 정보를 보여주는 명령어를 만들어볼게요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;src/commands&lt;/code&gt; 폴더에 &lt;code&gt;profile.ts&lt;/code&gt; 라는 이름으로 새 명령어 파일을 만들어봅시다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/profile.ts
import { SlashCommandBuilder } from &amp;quot;discord.js&amp;quot;;
import prisma from &amp;quot;../prisma&amp;quot;; // Prisma Client 인스턴스 가져오기
import { Command } from &amp;quot;../interfaces/Command&amp;quot;; // Command 인터페이스 (만약 사용하고 있다면)

export const profile: Command = {
  data: new SlashCommandBuilder()
    .setName(&amp;quot;프로필&amp;quot;)
    .setDescription(&amp;quot;당신의 프로필을 보여주거나 생성합니다.&amp;quot;),
  async execute(interaction) {
    const userId = interaction.user.id;
    const username = interaction.user.username;

    try {
      let user = await prisma.user.findUnique({
        where: { discordId: userId },
      });

      if (!user) {
        // 유저가 없으면 새로 생성
        user = await prisma.user.create({
          data: {
            discordId: userId,
            username: username,
            // level과 xp는 기본값이 설정되어 있으므로 생략 가능
          },
        });
        await interaction.reply(
          `환영합니다, ${username}님! 프로필이 생성되었어요. 현재 레벨: ${user.level}, XP: ${user.xp}`
        );
      } else {
        // 유저가 있으면 정보 보여주기
        // 경험치를 조금 올려볼까요?
        user = await prisma.user.update({
          where: { discordId: userId },
          data: { xp: user.xp + 10 }, // XP 10 증가
        });
        await interaction.reply(
          `${username}님의 프로필 - 레벨: ${user.level}, XP: ${user.xp}`
        );
      }
    } catch (error) {
      console.error(&amp;quot;프로필 명령어 처리 중 오류 발생:&amp;quot;, error);
      await interaction.reply({
        content:
          &amp;quot;프로필을 처리하는 중 오류가 발생했어요. 나중에 다시 시도해주세요.&amp;quot;,
        ephemeral: true,
      });
    }
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 코드에서는 다음 작업들을 수행합니다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;prisma&lt;/code&gt; 인스턴스를 import 합니다.&lt;/li&gt;
&lt;li&gt;명령어를 실행한 유저의 &lt;code&gt;discordId&lt;/code&gt;와 &lt;code&gt;username&lt;/code&gt;을 가져옵니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prisma.user.findUnique()&lt;/code&gt;를 사용해서 &lt;code&gt;discordId&lt;/code&gt;로 유저를 검색합니다.&lt;/li&gt;
&lt;li&gt;만약 유저가 존재하지 않으면 (&lt;code&gt;!user&lt;/code&gt;), &lt;code&gt;prisma.user.create()&lt;/code&gt;를 사용해서 새로운 유저 정보를 데이터베이스에 저장합니다.&lt;/li&gt;
&lt;li&gt;유저가 이미 존재하면, &lt;code&gt;prisma.user.update()&lt;/code&gt;를 사용해서 XP를 10 증가시키고 업데이트된 정보를 보여줍니다.&lt;/li&gt;
&lt;li&gt;혹시 모를 오류에 대비해 &lt;code&gt;try...catch&lt;/code&gt; 블록으로 감싸줍니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;새로운 명령어를 만들었으니 &lt;code&gt;src/commands/index.ts&lt;/code&gt; 파일에도 추가해줘야겠죠?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/index.ts
// ... 다른 명령어들 import
import { profile } from &amp;quot;./profile&amp;quot;;

export const commands = [
  // ... 다른 명령어들
  profile,
];&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 &lt;code&gt;src/deploy-commands.ts&lt;/code&gt;를 실행해서 슬래시 명령어를 디스코드 서버에 등록하는 것도 잊지 마세요!&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run deploy
# 또는
# npx ts-node src/deploy-commands.ts&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 봇을 실행하고 디스코드에서 &lt;code&gt;/프로필&lt;/code&gt; 명령어를 사용해보세요. 처음 실행하면 프로필이 생성되었다는 메시지가 나오고, 다시 실행하면 XP가 오른 것을 확인할 수 있을 겁니다! &lt;code&gt;prisma/dev.db&lt;/code&gt; 파일을 Prisma Studio나 다른 SQLite 뷰어로 열어보면 실제 데이터가 저장된 것도 볼 수 있어요.&lt;/p&gt;
&lt;p&gt;Prisma Studio를 사용하려면 터미널에서 다음 명령어를 실행하면 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma studio&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;웹 브라우저에서 Prisma Studio가 열리고, &lt;code&gt;User&lt;/code&gt; 모델과 저장된 데이터를 확인할 수 있습니다.&lt;/p&gt;
&lt;h2&gt;MySQL로 전환하기 (선택 사항)&lt;/h2&gt;
&lt;p&gt;만약 SQLite 대신 MySQL을 사용하고 싶다면, 앞서 언급했던 것처럼 &lt;code&gt;schema.prisma&lt;/code&gt; 파일과 &lt;code&gt;.env&lt;/code&gt; 파일을 수정하면 됩니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MySQL 서버 준비&lt;/strong&gt;: 로컬이나 원격에 MySQL 서버가 설치되어 있고, 접속 정보(사용자, 비밀번호, 호스트, 포트, 데이터베이스 이름)를 알고 있어야 합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;.env&lt;/code&gt; 파일 수정&lt;/strong&gt;: &lt;code&gt;DATABASE_URL&lt;/code&gt;을 MySQL 연결 문자열로 변경합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;DATABASE_URL=&amp;quot;mysql://USER:PASSWORD@HOST:PORT/DATABASE&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;schema.prisma&lt;/code&gt; 파일 수정&lt;/strong&gt;: &lt;code&gt;datasource db&lt;/code&gt;의 &lt;code&gt;provider&lt;/code&gt;를 &lt;code&gt;&amp;quot;mysql&amp;quot;&lt;/code&gt;로 변경합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;datasource db {
  provider = &amp;quot;mysql&amp;quot;
  url      = env(&amp;quot;DATABASE_URL&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;마이그레이션 재실행&lt;/strong&gt;: 새로운 데이터베이스에 스키마를 적용하기 위해 마이그레이션을 다시 실행해야 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate dev --name init_mysql&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(기존 SQLite 마이그레이션과 구분하기 위해 다른 이름을 사용했습니다.)&lt;/p&gt;
&lt;p&gt;만약 기존 &lt;code&gt;prisma/migrations&lt;/code&gt; 폴더와 &lt;code&gt;dev.db&lt;/code&gt; 파일이 충돌을 일으킨다면, 해당 폴더와 파일을 삭제하거나 백업한 후 진행하는 것이 안전할 수 있습니다. (주의: 실제 운영 중인 데이터베이스에서는 매우 신중해야 합니다!)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prisma Client 재생성&lt;/strong&gt;: &lt;code&gt;npx prisma generate&lt;/code&gt;를 실행합니다 (보통 &lt;code&gt;migrate dev&lt;/code&gt;에 포함되어 실행됩니다).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이렇게 하면 Prisma가 MySQL 데이터베이스에 연결되고, 기존에 작성했던 &lt;code&gt;profile.ts&lt;/code&gt; 명령어 코드는 변경 없이 그대로 MySQL에서 동작할 겁니다. 이게 바로 ORM의 장점 중 하나죠!&lt;/p&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;이번 시간에는 Prisma를 사용해서 Discord.js 봇에 데이터베이스 연동 기능을 추가하는 방법을 알아봤습니다. SQLite로 간편하게 시작해서, 필요하다면 MySQL 같은 다른 데이터베이스로 확장할 수 있는 유연성도 확인했죠.&lt;/p&gt;
&lt;p&gt;Prisma를 사용하면 데이터 모델링, 마이그레이션, 실제 데이터 CRUD 작업까지 타입스크립트 환경에서 매우 편리하게 처리할 수 있습니다. 앞으로 여러분의 봇에 다양한 기능을 추가할 때 데이터 저장이 필요하다면 Prisma를 적극적으로 활용해보세요!&lt;/p&gt;
&lt;p&gt;다음 시간에는 봇을 실제로 운영 환경에 배포하고 호스팅하는 방법에 대해 알아보겠습니다. 기대해주세요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/36</guid>
      <comments>https://dishost.tistory.com/36#entry36comment</comments>
      <pubDate>Tue, 10 Jun 2025 17:14:13 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 7. 역할과 권한 체크 구현하기: 봇에게 질서를 부여하자!</title>
      <link>https://dishost.tistory.com/35</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 지난 시간에는 이벤트 핸들링을 통해 봇이 서버의 다양한 상황에 능동적으로 반응하도록 만들었습니다. 덕분에 우리 봇은 이제 단순한 명령어를 넘어, 서버와 좀 더 긴밀하게 상호작용할 수 있게 되었죠.&lt;/p&gt;
&lt;p&gt;이번에는 봇과 서버의 질서를 유지하는 데 아주 중요한 &lt;strong&gt;역할(Role) 관리&lt;/strong&gt;와 &lt;strong&gt;권한(Permission) 체크&lt;/strong&gt;에 대해 알아보겠습니다. 모든 사용자가 모든 명령어를 사용하거나, 봇이 모든 기능을 아무에게나 제공한다면 서버가 혼란스러워질 수 있겠죠? 특정 명령어는 관리자만 사용하도록 하거나, 특정 역할을 가진 유저에게만 특별한 기능을 제공하는 방법을 배워봅시다.&lt;/p&gt;
&lt;h2&gt;왜 역할과 권한 체크가 필요할까요?&lt;/h2&gt;
&lt;p&gt;서버를 운영하다 보면 다양한 등급의 사용자가 생기기 마련입니다. 예를 들어, 일반 멤버, 모더레이터, 관리자 등이 있겠죠. 각 그룹마다 접근할 수 있는 기능이나 정보가 달라야 할 필요가 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;보안&lt;/strong&gt;: 서버 관리 명령어 (예: 멤버 추방, 채널 삭제)는 아무나 사용해서는 안 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;기능 제한&lt;/strong&gt;: 특정 이벤트 공지 명령어는 이벤트 담당 역할에게만 허용하고 싶을 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;사용자 경험&lt;/strong&gt;: 사용자에게 필요 없는 기능이나 권한이 없는 기능을 애초에 시도하지 못하게 안내하는 것이 좋습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Discord.js는 사용자의 역할과 권한을 쉽게 확인하고 이를 바탕으로 명령어 접근을 제어할 수 있는 강력한 기능을 제공합니다.&lt;/p&gt;
&lt;h2&gt;사용자의 역할(Role) 확인하기&lt;/h2&gt;
&lt;p&gt;디스코드에서 역할은 사용자에게 특정 권한을 부여하거나 그룹을 나타내는 데 사용됩니다. 봇 명령어 실행 시, 해당 명령어를 실행한 사용자가 어떤 역할을 가지고 있는지 확인하는 것은 매우 일반적인 작업입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ChatInputCommandInteraction&lt;/code&gt; 객체에서 &lt;code&gt;member&lt;/code&gt; 속성을 통해 명령어 사용자(&lt;code&gt;GuildMember&lt;/code&gt;) 정보에 접근할 수 있고, 이 &lt;code&gt;GuildMember&lt;/code&gt; 객체는 &lt;code&gt;roles&lt;/code&gt;라는 속성을 가집니다. &lt;code&gt;member.roles&lt;/code&gt;는 &lt;code&gt;GuildMemberRoleManager&lt;/code&gt; 타입으로, 사용자가 가진 역할들을 관리하는 여러 메서드와 속성을 제공합니다.&lt;/p&gt;
&lt;p&gt;가장 흔하게 사용되는 것은 &lt;code&gt;member.roles.cache&lt;/code&gt;로, 사용자가 가진 역할들을 &lt;code&gt;Collection&lt;/code&gt; 형태로 가지고 있습니다. 이 &lt;code&gt;Collection&lt;/code&gt;을 이용해 특정 역할이 있는지 확인할 수 있습니다.&lt;/p&gt;
&lt;h3&gt;특정 역할이 있는지 확인하는 방법&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;역할 ID로 확인&lt;/strong&gt;: 가장 정확하고 권장되는 방법입니다. 역할 이름은 변경될 수 있지만, ID는 고유합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;역할 이름으로 확인&lt;/strong&gt;: 편리하지만, 이름이 변경되거나 중복될 가능성이 있어 주의해야 합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 예시: 명령어 실행 부분에서 역할 확인
// import { ChatInputCommandInteraction, GuildMemberRoleManager } from &amp;#39;discord.js&amp;#39;;

export async function execute(interaction: ChatInputCommandInteraction) {
  if (!interaction.inGuild()) {
    await interaction.reply({
      content: &amp;quot;이 명령어는 서버에서만 사용할 수 있습니다.&amp;quot;,
      ephemeral: true,
    });
    return;
  }

  // interaction.member가 GuildMember 타입임을 확신할 수 있습니다.
  const memberRoles = interaction.member.roles as GuildMemberRoleManager;

  const adminRoleId = &amp;quot;YOUR_ADMIN_ROLE_ID&amp;quot;; // 실제 관리자 역할 ID로 변경
  const moderatorRoleName = &amp;quot;Moderator&amp;quot;; // 예시 역할 이름

  // 1. 역할 ID로 확인
  if (memberRoles.cache.has(adminRoleId)) {
    console.log(&amp;quot;이 사용자는 관리자 역할을 가지고 있습니다.&amp;quot;);
    // 관리자 전용 로직 수행
  } else {
    console.log(&amp;quot;이 사용자는 관리자 역할이 없습니다.&amp;quot;);
  }

  // 2. 역할 이름으로 확인 (대소문자 구분)
  if (memberRoles.cache.some((role) =&amp;gt; role.name === moderatorRoleName)) {
    console.log(&amp;quot;이 사용자는 Moderator 역할을 가지고 있습니다.&amp;quot;);
    // 모더레이터 관련 로직 수행
  } else {
    console.log(&amp;quot;이 사용자는 Moderator 역할이 없습니다.&amp;quot;);
  }

  // ... (명령어 기본 로직)
  await interaction.reply({
    content: &amp;quot;역할 확인 테스트 완료!&amp;quot;,
    ephemeral: true,
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;팁&lt;/strong&gt;: 역할 ID는 디스코드 클라이언트에서 개발자 모드를 활성화한 후, 서버 설정 &amp;gt; 역할 메뉴에서 해당 역할을 우클릭하여 &amp;quot;ID 복사하기&amp;quot;를 통해 얻을 수 있습니다.&lt;/p&gt;
&lt;h2&gt;사용자의 권한(Permission) 확인하기&lt;/h2&gt;
&lt;p&gt;역할이 사용자 그룹핑과 특정 권한 묶음을 제공한다면, 권한은 더 세부적인 개별 행동에 대한 허용 여부를 나타냅니다. 예를 들어, &amp;quot;메시지 관리&amp;quot;, &amp;quot;멤버 추방&amp;quot;, &amp;quot;채널 관리&amp;quot; 등이 각각의 권한입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;GuildMember&lt;/code&gt; 객체의 &lt;code&gt;permissions&lt;/code&gt; 속성(&lt;code&gt;PermissionsBitField&lt;/code&gt;)을 통해 사용자가 가진 권한을 확인할 수 있습니다. &lt;code&gt;permissions.has()&lt;/code&gt; 메서드에 확인하고자 하는 권한 플래그를 전달하여 해당 권한이 있는지 여부를 &lt;code&gt;boolean&lt;/code&gt; 값으로 얻을 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PermissionsBitField&lt;/code&gt;에서 사용할 수 있는 주요 권한 플래그들은 다음과 같습니다 (전체 목록은 공식 문서 참고):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.Administrator&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.KickMembers&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.BanMembers&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.ManageChannels&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.ManageGuild&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.ManageMessages&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.SendMessages&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PermissionsBitField.Flags.ViewChannel&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;특정 권한이 있는지 확인하는 방법&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 예시: 명령어 실행 부분에서 권한 확인
// import { ChatInputCommandInteraction, PermissionsBitField, GuildMember } from &amp;#39;discord.js&amp;#39;;

export async function execute(interaction: ChatInputCommandInteraction) {
  if (!interaction.inGuild()) {
    await interaction.reply({
      content: &amp;quot;이 명령어는 서버에서만 사용할 수 있습니다.&amp;quot;,
      ephemeral: true,
    });
    return;
  }

  const member = interaction.member as GuildMember; // 타입 단언

  // 관리자 권한 확인
  if (member.permissions.has(PermissionsBitField.Flags.Administrator)) {
    console.log(&amp;quot;이 사용자는 서버 관리자 권한을 가지고 있습니다.&amp;quot;);
    // 관리자 전용 로직
  } else {
    console.log(&amp;quot;이 사용자는 서버 관리자 권한이 없습니다.&amp;quot;);
  }

  // 여러 권한 중 하나라도 있는지 확인 (배열 전달)
  if (
    member.permissions.has([
      PermissionsBitField.Flags.KickMembers,
      PermissionsBitField.Flags.BanMembers,
    ])
  ) {
    console.log(
      &amp;quot;이 사용자는 멤버 추방 또는 차단 권한 중 하나 이상을 가지고 있습니다.&amp;quot;
    );
  } else {
    console.log(&amp;quot;이 사용자는 멤버 추방 및 차단 권한이 모두 없습니다.&amp;quot;);
  }

  // 특정 채널에서의 권한 확인 (더 복잡한 시나리오)
  // const channel = interaction.channel;
  // if (channel &amp;amp;&amp;amp; member.permissionsIn(channel).has(PermissionsBitField.Flags.SendMessages)) {
  // console.log(&amp;#39;이 사용자는 현재 채널에 메시지를 보낼 수 있습니다.&amp;#39;);
  // }

  // ... (명령어 기본 로직)
  await interaction.reply({
    content: &amp;quot;권한 확인 테스트 완료!&amp;quot;,
    ephemeral: true,
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;명령어 접근 제어하기&lt;/h2&gt;
&lt;p&gt;이제 역할과 권한을 확인하는 방법을 알았으니, 이를 활용해 명령어 접근을 제어해 봅시다.&lt;/p&gt;
&lt;h3&gt;1. 명령어 실행 로직 내에서 직접 확인&lt;/h3&gt;
&lt;p&gt;가장 유연한 방법은 각 명령어의 &lt;code&gt;execute&lt;/code&gt; 함수 시작 부분에서 필요한 역할이나 권한을 확인하고, 조건에 맞지 않으면 사용자에게 알리고 명령 실행을 중단하는 것입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/adminOnlyCommand.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  GuildMember,
  PermissionsBitField,
} from &amp;quot;discord.js&amp;quot;;

export const data = new SlashCommandBuilder()
  .setName(&amp;quot;관리자전용&amp;quot;)
  .setDescription(&amp;quot;서버 관리자만 사용할 수 있는 명령어입니다.&amp;quot;);

export async function execute(interaction: ChatInputCommandInteraction) {
  if (!interaction.inGuild()) {
    return interaction.reply({
      content: &amp;quot;이 명령어는 서버에서만 사용할 수 있습니다.&amp;quot;,
      ephemeral: true,
    });
  }

  const member = interaction.member as GuildMember;

  // 관리자 권한이 없으면 실행 거부
  if (!member.permissions.has(PermissionsBitField.Flags.Administrator)) {
    return interaction.reply({
      content: &amp;quot;이 명령어를 사용할 권한이 없습니다. (관리자 권한 필요)&amp;quot;,
      ephemeral: true,
    });
  }

  // 관리자만 실행할 수 있는 로직
  await interaction.reply({
    content: &amp;quot;관리자 전용 명령어가 성공적으로 실행되었습니다!&amp;quot;,
    ephemeral: true,
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. &lt;code&gt;default_member_permissions&lt;/code&gt; 사용하기&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SlashCommandBuilder&lt;/code&gt;는 &lt;code&gt;setDefaultMemberPermissions()&lt;/code&gt; 메서드를 제공합니다. 여기에 필요한 권한을 설정하면, 디스코드 클라이언트 자체에서 해당 권한이 없는 사용자에게는 명령어가 회색으로 비활성화되어 보이거나, 실행 시 디스코드 시스템 메시지로 권한 없음을 알려줍니다. 즉, 봇의 &lt;code&gt;InteractionCreate&lt;/code&gt; 이벤트까지 도달하기 전에 디스코드가 1차적으로 필터링해줍니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/kick.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  GuildMember,
  PermissionsBitField,
} from &amp;quot;discord.js&amp;quot;;

export const data = new SlashCommandBuilder()
  .setName(&amp;quot;추방&amp;quot;)
  .setDescription(&amp;quot;서버에서 멤버를 추방합니다. (추방 권한 필요)&amp;quot;)
  .addUserOption((option) =&amp;gt;
    option
      .setName(&amp;quot;대상&amp;quot;)
      .setDescription(&amp;quot;추방할 멤버를 선택하세요.&amp;quot;)
      .setRequired(true)
  )
  .addStringOption((option) =&amp;gt;
    option.setName(&amp;quot;사유&amp;quot;).setDescription(&amp;quot;추방 사유 (선택 사항)&amp;quot;)
  )
  .setDefaultMemberPermissions(PermissionsBitField.Flags.KickMembers); // 멤버 추방 권한 필요

export async function execute(interaction: ChatInputCommandInteraction) {
  if (!interaction.inGuild()) {
    return interaction.reply({
      content: &amp;quot;이 명령어는 서버에서만 사용할 수 있습니다.&amp;quot;,
      ephemeral: true,
    });
  }

  // setDefaultMemberPermissions를 사용했더라도, 추가적인 안전장치로 한번 더 확인하는 것이 좋습니다.
  // 또는 더 복잡한 역할 기반 로직을 여기에 추가할 수 있습니다.
  const member = interaction.member as GuildMember;
  if (!member.permissions.has(PermissionsBitField.Flags.KickMembers)) {
    return interaction.reply({
      content: &amp;quot;이 명령어를 사용할 권한이 없습니다. (멤버 추방 권한 필요)&amp;quot;,
      ephemeral: true,
    });
  }

  const targetUser = interaction.options.getUser(&amp;quot;대상&amp;quot;, true);
  const reason = interaction.options.getString(&amp;quot;사유&amp;quot;) || &amp;quot;사유 없음&amp;quot;;

  const targetMember = await interaction
    .guild!.members.fetch(targetUser.id)
    .catch(() =&amp;gt; null);

  if (!targetMember) {
    return interaction.reply({
      content: &amp;quot;추방할 멤버를 서버에서 찾을 수 없습니다.&amp;quot;,
      ephemeral: true,
    });
  }

  if (!targetMember.kickable) {
    return interaction.reply({
      content: &amp;quot;봇이 이 멤버를 추방할 권한이 없습니다. (역할 순서 등 확인)&amp;quot;,
      ephemeral: true,
    });
  }

  try {
    await targetMember.kick(reason);
    await interaction.reply({
      content: `${targetUser.tag}님을 성공적으로 추방했습니다. 사유: ${reason}`,
      ephemeral: false,
    });
  } catch (error) {
    console.error(&amp;quot;멤버 추방 중 오류 발생:&amp;quot;, error);
    await interaction.reply({
      content: &amp;quot;멤버를 추방하는 중 오류가 발생했습니다.&amp;quot;,
      ephemeral: true,
    });
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;setDefaultMemberPermissions&lt;/code&gt; 와 수동 체크의 차이점 및 사용 시기:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;setDefaultMemberPermissions&lt;/code&gt;&lt;/strong&gt;: 명령어 등록 시점에 디스코드에 &amp;quot;이 명령어는 이 권한이 필요해&amp;quot;라고 알려주는 것과 같습니다. 사용 편의성이 좋고, 봇의 부하를 줄일 수 있습니다. 기본적인 권한 제한에 적합합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;수동 체크 (&lt;code&gt;execute&lt;/code&gt; 내부)&lt;/strong&gt;: 더 복잡한 로직 (예: 특정 역할 조합, 특정 채널에서의 권한, 커스텀 조건)을 구현할 때 필요합니다. &lt;code&gt;setDefaultMemberPermissions&lt;/code&gt;를 사용했더라도 중요한 명령어는 이중으로 체크하는 것이 안전합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;역할 관리 (간단 소개)&lt;/h2&gt;
&lt;p&gt;봇을 사용하여 사용자에게 역할을 부여하거나 제거하는 것도 가능합니다. &lt;code&gt;GuildMember&lt;/code&gt; 객체의 &lt;code&gt;roles&lt;/code&gt; 매니저를 통해 &lt;code&gt;add()&lt;/code&gt;, &lt;code&gt;remove()&lt;/code&gt; 등의 메서드를 사용할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 예시: 특정 사용자에게 역할 부여
// const roleToGive = interaction.guild.roles.cache.get(&amp;#39;YOUR_ROLE_ID_TO_GIVE&amp;#39;);
// if (targetMember &amp;amp;&amp;amp; roleToGive) {
// try {
// await targetMember.roles.add(roleToGive);
// console.log(`${targetMember.user.tag}에게 ${roleToGive.name} 역할을 부여했습니다.`);
// } catch (error) {
// console.error(&amp;#39;역할 부여 실패:&amp;#39;, error);
//     }
// }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;역할 관리는 멤버의 상태 변경, 특정 조건 달성 시 보상 등 다양한 자동화 기능을 구현하는 데 활용될 수 있습니다. 이 부분은 더 심화된 주제이므로, 필요에 따라 공식 문서를 참고하여 탐색해 보세요.&lt;/p&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;이번 시간에는 사용자의 역할과 권한을 확인하고, 이를 바탕으로 명령어 접근을 제어하는 방법에 대해 배웠습니다. &lt;code&gt;member.roles.cache&lt;/code&gt;를 통해 역할을 확인하고, &lt;code&gt;member.permissions.has()&lt;/code&gt;로 권한을 체크하며, &lt;code&gt;setDefaultMemberPermissions&lt;/code&gt;로 명령어의 기본 접근 권한을 설정하는 방법을 익혔습니다.&lt;/p&gt;
&lt;p&gt;이러한 기능들을 활용하면 여러분의 봇은 훨씬 더 체계적이고 안전하게 서버 기능을 제공할 수 있게 됩니다. 관리자 전용 명령어, 특정 역할 전용 기능 등을 구현하여 서버 운영의 효율성을 높여보세요.&lt;/p&gt;
&lt;p&gt;다음 시간에는 드디어 &lt;strong&gt;데이터베이스 연동&lt;/strong&gt;에 대해 알아볼 차례입니다! Prisma ORM을 사용하여 SQLite나 MySQL 같은 데이터베이스에 봇의 데이터를 저장하고 불러오는 방법을 배우게 됩니다. 사용자 정보, 서버 설정, 경고 횟수 등 다양한 정보를 영구적으로 관리할 수 있게 될 거예요. 기대하셔도 좋습니다!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/35</guid>
      <comments>https://dishost.tistory.com/35#entry35comment</comments>
      <pubDate>Mon, 9 Jun 2025 17:13:31 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 6. 이벤트 핸들링 마스터하기: 봇을 살아 움직이게 만드는 비법</title>
      <link>https://dishost.tistory.com/34</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 지난 시간에는 임베드 메시지와 버튼을 활용해서 봇과의 소통을 한층 풍부하게 만드는 방법을 배웠습니다. 이제 봇이 좀 더 세련되게 정보를 전달하고, 사용자와 간단한 상호작용도 할 수 있게 되었네요.&lt;/p&gt;
&lt;p&gt;이번 시간에는 디스코드 봇 개발의 핵심 중 하나인 &lt;strong&gt;이벤트 핸들링(Event Handling)&lt;/strong&gt;에 대해 깊이 있게 다뤄보려고 합니다. 단순히 명령어를 처리하는 것을 넘어, 서버에서 발생하는 다양한 상황에 봇이 능동적으로 반응하도록 만들 수 있는 강력한 기능이죠. 예를 들어, 새로운 멤버가 서버에 참여했을 때 환영 메시지를 보내거나, 메시지가 수정/삭제되었을 때 로그를 남기는 등의 동작을 구현할 수 있습니다.&lt;/p&gt;
&lt;h2&gt;이벤트란 무엇이고, 왜 중요할까요?&lt;/h2&gt;
&lt;p&gt;디스코드에서 &amp;#39;이벤트&amp;#39;란 서버나 사용자와 관련된 특정 사건이나 동작을 의미합니다. 예를 들면 다음과 같은 것들이 있죠.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;사용자가 메시지를 보냈을 때 (&lt;code&gt;messageCreate&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;사용자가 명령어를 입력했을 때 (&lt;code&gt;interactionCreate&lt;/code&gt; - 이미 사용해봤죠!)&lt;/li&gt;
&lt;li&gt;새로운 멤버가 서버에 참여했을 때 (&lt;code&gt;guildMemberAdd&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;멤버가 서버를 떠났을 때 (&lt;code&gt;guildMemberRemove&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;메시지가 수정되었을 때 (&lt;code&gt;messageUpdate&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;메시지가 삭제되었을 때 (&lt;code&gt;messageDelete&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;봇이 준비되었을 때 (&lt;code&gt;ready&lt;/code&gt; - 이것도 이미 사용해봤습니다!)&lt;/li&gt;
&lt;li&gt;사용자가 음성 채널 상태를 변경했을 때 (&lt;code&gt;voiceStateUpdate&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 외에도 정말 다양한 이벤트들이 존재합니다. (전체 목록은 &lt;a href=&quot;https://discord.js.org/docs/packages/discord.js/main/Events:Enum&quot;&gt;Discord.js 공식 문서의 &lt;code&gt;Events&lt;/code&gt; 페이지&lt;/a&gt;에서 확인할 수 있습니다.)&lt;/p&gt;
&lt;p&gt;이벤트 핸들링이 중요한 이유는, 봇이 단순히 수동적으로 명령어에만 응답하는 것을 넘어, &lt;strong&gt;서버 환경의 변화를 감지하고 그에 맞춰 자율적으로 동작&lt;/strong&gt;하게 만들기 때문입니다. 잘 만들어진 이벤트 핸들러는 봇을 마치 살아있는 유기체처럼 느끼게 해주고, 서버 관리나 커뮤니티 운영에 큰 도움을 줄 수 있습니다.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;client.on()&lt;/code&gt;으로 이벤트 구독하기&lt;/h2&gt;
&lt;p&gt;Discord.js에서 이벤트를 처리하는 가장 기본적인 방법은 &lt;code&gt;Client&lt;/code&gt; 객체의 &lt;code&gt;.on()&lt;/code&gt; 메서드를 사용하는 것입니다. 이 메서드는 두 개의 인자를 받습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;이벤트 이름&lt;/strong&gt;: 처리하고자 하는 이벤트의 이름을 문자열로 전달합니다. &lt;code&gt;discord.js&lt;/code&gt;에서는 &lt;code&gt;Events&lt;/code&gt;라는 열거형(Enum) 객체에 미리 정의된 이벤트 이름들을 제공하므로, 이를 사용하는 것이 좋습니다. (예: &lt;code&gt;Events.MessageCreate&lt;/code&gt;, &lt;code&gt;Events.GuildMemberAdd&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;리스너 함수 (Listener Function)&lt;/strong&gt;: 해당 이벤트가 발생했을 때 실행될 함수입니다. 이 함수는 이벤트의 종류에 따라 다양한 인자를 받을 수 있습니다. 예를 들어, &lt;code&gt;Events.MessageCreate&lt;/code&gt; 이벤트는 생성된 &lt;code&gt;Message&lt;/code&gt; 객체를 인자로 받고, &lt;code&gt;Events.GuildMemberAdd&lt;/code&gt; 이벤트는 서버에 참여한 &lt;code&gt;GuildMember&lt;/code&gt; 객체를 인자로 받습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;기본적인 구조는 다음과 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts
import { Client, Events, GatewayIntentBits } from &amp;#39;discord.js&amp;#39;;
import { token } from &amp;#39;./config&amp;#39;; // 토큰 관리는 이전과 동일

const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent, // 메시지 내용을 읽기 위해 필요
        GatewayIntentBits.GuildMembers,   // 멤버 관련 이벤트를 위해 필요
        // 필요한 다른 Intents 추가...
    ]
});

client.once(Events.ClientReady, c =&amp;gt; {
    console.log(\`로그인 성공! \\${c.user.tag}(으)로 로그인했어요.\`);
});

// 예시: 새로운 메시지가 생성될 때마다 콘솔에 로그 남기기
client.on(Events.MessageCreate, message =&amp;gt; {
    // 봇 자신의 메시지는 무시
    if (message.author.bot) return;

    console.log(\`[\\${message.guild?.name || &amp;#39;DM&amp;#39;}] \\${message.author.tag}: \\${message.content}\`);

    // 간단한 응답 예시 (명령어 처리와는 별개)
    if (message.content.toLowerCase() === &amp;#39;안녕&amp;#39;) {
        message.reply(&amp;#39;안녕하세요!&amp;#39;);
    }
});

// 예시: 새로운 멤버가 서버에 참여했을 때 환영 메시지 보내기
client.on(Events.GuildMemberAdd, member =&amp;gt; {
    console.log(\`\\${member.user.tag}님이 \\${member.guild.name} 서버에 참여했습니다.\`);

    // 특정 채널에 환영 메시지 보내기 (채널 ID를 설정 파일이나 환경 변수로 관리하는 것이 좋습니다)
    const welcomeChannelId = &amp;#39;YOUR_WELCOME_CHANNEL_ID&amp;#39;; // 실제 채널 ID로 변경하세요!
    const channel = member.guild.channels.cache.get(welcomeChannelId);

    if (channel &amp;amp;&amp;amp; channel.isTextBased()) {
        channel.send(\`\\${member.displayName}님, \\${member.guild.name} 서버에 오신 것을 환영합니다!  \`);
    } else {
        console.warn(\`환영 채널(ID: \\${welcomeChannelId})을 찾을 수 없거나 텍스트 채널이 아닙니다.\`);
    }
});

// 예시: 멤버가 서버를 떠났을 때 작별 메시지 보내기
client.on(Events.GuildMemberRemove, member =&amp;gt; {
    console.log(\`\\${member.user.tag}님이 \\${member.guild.name} 서버를 떠났습니다.\`);

    const goodbyeChannelId = &amp;#39;YOUR_GOODBYE_CHANNEL_ID&amp;#39;; // 실제 채널 ID로 변경하세요!
    const channel = member.guild.channels.cache.get(goodbyeChannelId);

    if (channel &amp;amp;&amp;amp; channel.isTextBased()) {
        channel.send(\`잘 가요, \\${member.displayName}님... 다음에 또 만나요!  \`);
    } else {
        console.warn(\`작별 채널(ID: \\${goodbyeChannelId})을 찾을 수 없거나 텍스트 채널이 아닙니다.\`);
    }
});


// 명령어 핸들러 (지난 시간 내용)
client.on(Events.InteractionCreate, async interaction =&amp;gt; {
    // ... (기존 명령어 처리 로직) ...
});


client.login(token);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;중요: Intents 설정 확인!&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;각 이벤트를 제대로 수신하려면 &lt;code&gt;Client&lt;/code&gt;를 생성할 때 적절한 &lt;strong&gt;인텐트(Intents)&lt;/strong&gt;를 활성화해야 합니다. 인텐트는 봇이 어떤 종류의 이벤트 정보를 받을 것인지를 디스코드 API에 알려주는 역할을 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Events.MessageCreate&lt;/code&gt;에서 메시지 내용을 읽으려면 &lt;code&gt;GatewayIntentBits.MessageContent&lt;/code&gt;가 필요합니다. (디스코드 개발자 포털에서 &amp;quot;Message Content Intent&amp;quot;도 활성화해야 합니다!)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Events.GuildMemberAdd&lt;/code&gt;, &lt;code&gt;Events.GuildMemberRemove&lt;/code&gt;와 같은 멤버 관련 이벤트를 수신하려면 &lt;code&gt;GatewayIntentBits.GuildMembers&lt;/code&gt;가 필요합니다. (디스코드 개발자 포털에서 &amp;quot;Privileged Gateway Intents&amp;quot; 항목의 &amp;quot;Server Members Intent&amp;quot;도 활성화해야 합니다!)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;필요한 인텐트를 누락하면 해당 이벤트가 발생해도 봇이 감지하지 못하니 주의해야 합니다.&lt;/p&gt;
&lt;h2&gt;이벤트 핸들러 파일 분리하기 (선택 사항, 하지만 권장)&lt;/h2&gt;
&lt;p&gt;봇의 기능이 많아지고 처리해야 할 이벤트가 늘어나면 &lt;code&gt;src/index.ts&lt;/code&gt; 파일이 매우 길어질 수 있습니다. 이럴 때는 각 이벤트 핸들러를 별도의 파일로 분리하여 관리하는 것이 좋습니다. 이렇게 하면 코드가 더 깔끔해지고 유지보수도 쉬워집니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;events&lt;/code&gt; 폴더 생성&lt;/strong&gt;: &lt;code&gt;src&lt;/code&gt; 폴더 아래에 &lt;code&gt;events&lt;/code&gt;라는 새 폴더를 만듭니다.&lt;br&gt;&lt;strong&gt;이벤트 파일 생성&lt;/strong&gt;: 각 이벤트별로 파일을 만듭니다. 예를 들어, &lt;code&gt;messageCreate.ts&lt;/code&gt;, &lt;code&gt;guildMemberAdd.ts&lt;/code&gt; 처럼요.&lt;br&gt;&lt;strong&gt;이벤트 핸들러 작성&lt;/strong&gt;: 각 파일에는 해당 이벤트를 처리하는 로직을 작성합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;```typescript
// src/events/guildMemberAdd.ts
import { Events, GuildMember } from &amp;#39;discord.js&amp;#39;;

// 이벤트 이름과 실행 함수를 export 합니다.
export const name = Events.GuildMemberAdd;
export async function execute(member: GuildMember) {
    console.log(\`\\${member.user.tag}님이 \\${member.guild.name} 서버에 참여했습니다. (별도 파일에서 처리)\`);

    const welcomeChannelId = process.env.WELCOME_CHANNEL_ID || &amp;#39;YOUR_DEFAULT_WELCOME_CHANNEL_ID&amp;#39;;
    const channel = member.guild.channels.cache.get(welcomeChannelId);

    if (channel &amp;amp;&amp;amp; channel.isTextBased()) {
        try {
            await channel.send(\`\\${member.displayName}님, \\${member.guild.name} 서버에 오신 것을 환영합니다! (별도 파일에서 환영  )\`);
        } catch (error) {
            console.error(&amp;#39;환영 메시지 전송 실패:&amp;#39;, error);
        }
    } else {
        console.warn(\`환영 채널(ID: \\${welcomeChannelId})을 찾을 수 없거나 텍스트 채널이 아닙니다.\`);
    }
}
```&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;index.ts&lt;/code&gt;에서 이벤트 핸들러 동적 로딩&lt;/strong&gt;: &lt;code&gt;src/index.ts&lt;/code&gt; 파일에서 &lt;code&gt;events&lt;/code&gt; 폴더 안의 파일들을 읽어와 각 이벤트를 동적으로 등록합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;```typescript
// src/index.ts
import fs from &amp;#39;node:fs&amp;#39;;
import path from &amp;#39;node:path&amp;#39;;
import { Client, Events, GatewayIntentBits, Collection } from &amp;#39;discord.js&amp;#39;; // Collection 추가
import { token } from &amp;#39;./config&amp;#39;;
// 명령어 관련 import는 그대로 유지
import { commands } from &amp;#39;./commands&amp;#39;; // 예시, 실제 명령어 로딩 방식에 따라 다를 수 있음

const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.GuildMembers,
        // ... 기타 필요한 Intents
    ]
});

// 명령어 로딩 (기존 방식대로)
// client.commands = new Collection(); // 만약 명령어를 Collection으로 관리한다면
// ... 명령어 파일 로딩 로직 ...


// 이벤트 핸들러 로딩
const eventsPath = path.join(__dirname, &amp;#39;events&amp;#39;);
const eventFiles = fs.readdirSync(eventsPath).filter(file =&amp;gt; file.endsWith(&amp;#39;.ts&amp;#39;) || file.endsWith(&amp;#39;.js&amp;#39;)); // .js도 고려

for (const file of eventFiles) {
    const filePath = path.join(eventsPath, file);
    const event = require(filePath); // TypeScript에서는 import를 사용하거나, 컴파일된 JS 기준 require 사용
    if (event.once) {
        client.once(event.name, (...args) =&amp;gt; event.execute(...args));
    } else {
        client.on(event.name, (...args) =&amp;gt; event.execute(...args));
    }
    console.log(\`[이벤트 로드] \\${event.name} 이벤트 핸들러 로드 완료: \\${file}\`);
}


client.once(Events.ClientReady, c =&amp;gt; {
    console.log(\`로그인 성공! \\${c.user.tag}(으)로 로그인했어요.\`);
    // 봇이 준비되면 명령어 등록 (deploy-commands.ts 실행 등)
    // require(&amp;#39;./deploy-commands&amp;#39;); // 예시
});

// InteractionCreate 이벤트는 여전히 중요하므로 직접 등록하거나, events 폴더 방식으로 관리 가능
client.on(Events.InteractionCreate, async interaction =&amp;gt; {
    if (!interaction.isChatInputCommand()) return;

    const command = commands[interaction.commandName as keyof typeof commands];

    if (!command) {
        console.error(\`No command matching \\${interaction.commandName} was found.\`);
        await interaction.reply({ content: &amp;#39;알 수 없는 명령어입니다.&amp;#39;, ephemeral: true });
        return;
    }

    try {
        await command.execute(interaction);
    } catch (error) {
        console.error(error);
        if (interaction.replied || interaction.deferred) {
            await interaction.followUp({ content: &amp;#39;명령어 실행 중 오류가 발생했어요!&amp;#39;, ephemeral: true });
        } else {
            await interaction.reply({ content: &amp;#39;명령어 실행 중 오류가 발생했어요!&amp;#39;, ephemeral: true });
        }
    }
});


client.login(token);
```

위의 동적 로딩 코드는 `require`를 사용하고 있는데, 순수 TypeScript 환경에서는 `import()` 동적 임포트나 컴파일 후의 JavaScript 파일을 기준으로 작성해야 할 수 있습니다. 또는 각 이벤트 파일을 `index.ts`에서 명시적으로 `import`하고 등록하는 방식을 사용할 수도 있습니다. 프로젝트 구조와 선호도에 따라 선택하세요.

**참고**: `require(filePath)` 방식은 CommonJS 모듈 시스템에서 주로 사용됩니다. ES 모듈을 사용하고 있다면 (tsconfig.json에서 `module: &amp;quot;ESNext&amp;quot;` 또는 유사한 설정), 동적 `import()`를 사용해야 합니다.

```typescript
// ES 모듈 방식의 동적 임포트 예시
for (const file of eventFiles) {
    const filePath = path.join(eventsPath, file);
    import(filePath).then(eventModule =&amp;gt; {
        if (eventModule.once) {
            client.once(eventModule.name, (...args: any[]) =&amp;gt; eventModule.execute(...args));
        } else {
            client.on(eventModule.name, (...args: any[]) =&amp;gt; eventModule.execute(...args));
        }
        console.log(\`[이벤트 로드] \\${eventModule.name} 이벤트 핸들러 로드 완료: \\${file}\`);
    }).catch(err =&amp;gt; console.error(\`이벤트 파일 로드 실패 (\\${file}):\`, err));
}
```

이 경우, 각 이벤트 파일은 `export const name = ...;` 와 `export async function execute(...) {...}` 형태로 `name`과 `execute`를 명시적으로 export 해야 합니다.&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;유용한 이벤트 몇 가지 더 살펴보기&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Events.MessageUpdate&lt;/code&gt;&lt;/strong&gt;: 메시지가 수정되었을 때 발생합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;인자: &lt;code&gt;oldMessage: Message | PartialMessage&lt;/code&gt;, &lt;code&gt;newMessage: Message | PartialMessage&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PartialMessage&lt;/code&gt;는 메시지 정보가 부분적일 수 있음을 의미합니다. (예: 캐시되지 않은 오래된 메시지)&lt;/li&gt;
&lt;li&gt;수정 전후의 내용을 비교하여 로깅하거나 특정 패턴을 감지할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/events/messageUpdate.ts (예시)
import { Events, Message, PartialMessage } from &amp;#39;discord.js&amp;#39;;

export const name = Events.MessageUpdate;
export async function execute(oldMessage: Message | PartialMessage, newMessage: Message | PartialMessage) {
    // 봇 메시지나 내용 변경 없는 임베드 업데이트 등은 무시
    if (oldMessage.author?.bot || oldMessage.content === newMessage.content) return;

    console.log(\`메시지 수정 감지: [\\${newMessage.guild?.name}] \\${newMessage.author?.tag}\`);
    console.log(\` - 이전 내용: \\${oldMessage.content}\`);
    console.log(\` - 새 내용: \\${newMessage.content}\`);
    // 특정 채널에 로그를 남길 수 있습니다.
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Events.MessageDelete&lt;/code&gt;&lt;/strong&gt;: 메시지가 삭제되었을 때 발생합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;인자: &lt;code&gt;message: Message | PartialMessage&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;삭제된 메시지의 내용을 로깅할 수 있습니다. (단, &lt;code&gt;MessageContent&lt;/code&gt; 인텐트가 활성화되어 있고 메시지가 캐시되어 있어야 내용 접근 가능)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/events/messageDelete.ts (예시)
import { Events, Message, PartialMessage } from &amp;#39;discord.js&amp;#39;;

export const name = Events.MessageDelete;
export async function execute(message: Message | PartialMessage) {
    if (message.author?.bot) return; // 봇 메시지 삭제는 무시 (선택 사항)

    console.log(\`메시지 삭제 감지: [\\${message.guild?.name}] \\${message.author?.tag}\`);
    // message.content는 캐시 상태에 따라 null일 수 있음
    if (message.content) {
        console.log(\` - 내용: \\${message.content}\`);
    } else {
        console.log(\` - (내용을 가져올 수 없거나, 첨부파일/임베드만 있었을 수 있습니다.)\`);
    }
    // 특정 채널에 로그를 남길 수 있습니다.
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Events.VoiceStateUpdate&lt;/code&gt;&lt;/strong&gt;: 사용자가 음성 채널에 참여/퇴장하거나, 마이크/스피커 음소거 등의 상태를 변경했을 때 발생합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;인자: &lt;code&gt;oldState: VoiceState&lt;/code&gt;, &lt;code&gt;newState: VoiceState&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이를 활용해 특정 사용자가 음성 채널에 접속하면 알림을 주거나, 음성 채널 활동 로그를 기록할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/events/voiceStateUpdate.ts (예시)
import { Events, VoiceState } from &amp;#39;discord.js&amp;#39;;

export const name = Events.VoiceStateUpdate;
export async function execute(oldState: VoiceState, newState: VoiceState) {
    const member = newState.member; // 또는 oldState.member
    if (!member) return;

    const oldChannel = oldState.channel;
    const newChannel = newState.channel;

    if (!oldChannel &amp;amp;&amp;amp; newChannel) {
        // 사용자가 음성 채널에 참여
        console.log(\`\\${member.user.tag}님이 \\${newChannel.name} 음성 채널에 참여했습니다.\`);
        // 특정 텍스트 채널에 알림을 보낼 수 있습니다.
        // const notificationChannel = member.guild.channels.cache.get(&amp;#39;YOUR_NOTIFICATION_CHANNEL_ID&amp;#39;);
        // if (notificationChannel?.isTextBased()) {
        // notificationChannel.send(\`  \\${member.displayName}님이 \\${newChannel.name}에 접속했습니다!\`);
        // }
    } else if (oldChannel &amp;amp;&amp;amp; !newChannel) {
        // 사용자가 음성 채널에서 퇴장
        console.log(\`\\${member.user.tag}님이 \\${oldChannel.name} 음성 채널에서 퇴장했습니다.\`);
    } else if (oldChannel &amp;amp;&amp;amp; newChannel &amp;amp;&amp;amp; oldChannel.id !== newChannel.id) {
        // 사용자가 음성 채널을 이동
        console.log(\`\\${member.user.tag}님이 \\${oldChannel.name}에서 \\${newChannel.name}으로 이동했습니다.\`);
    }

    // 마이크 음소거/해제, 서버 음소거/해제 등의 상태 변경도 감지 가능
    // if (oldState.serverMute !== newState.serverMute) { ... }
    // if (oldState.selfMute !== newState.selfMute) { ... }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;이번 시간에는 &lt;code&gt;client.on()&lt;/code&gt;을 사용해 다양한 디스코드 이벤트를 구독하고 처리하는 방법, 그리고 코드를 체계적으로 관리하기 위해 이벤트 핸들러를 파일로 분리하는 방법에 대해 알아보았습니다. &lt;code&gt;MessageCreate&lt;/code&gt;, &lt;code&gt;GuildMemberAdd/Remove&lt;/code&gt;, &lt;code&gt;MessageUpdate/Delete&lt;/code&gt;, &lt;code&gt;VoiceStateUpdate&lt;/code&gt; 등 유용한 이벤트들의 기본적인 활용법도 살펴봤습니다.&lt;/p&gt;
&lt;p&gt;이벤트 핸들링을 통해 여러분의 봇은 이제 단순한 명령어 실행기를 넘어, 서버의 상황 변화에 지능적으로 반응하는 멋진 동반자가 될 수 있을 겁니다. 예를 들어, 특정 키워드가 포함된 메시지를 감지하여 자동으로 응답하거나, 새로운 멤버에게 역할(Role)을 자동으로 부여하는 등의 고급 기능도 구현할 수 있게 됩니다.&lt;/p&gt;
&lt;p&gt;다음 시간에는 &lt;strong&gt;역할(Role) 관리&lt;/strong&gt;와 &lt;strong&gt;권한(Permission) 제어&lt;/strong&gt;에 대해 알아보겠습니다. 봇을 이용해 서버 멤버들의 역할을 관리하고, 특정 명령어는 특정 역할을 가진 사람만 사용하도록 제한하는 등 서버 운영의 효율성을 높이는 방법을 배우게 될 겁니다. 기대해주세요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/34</guid>
      <comments>https://dishost.tistory.com/34#entry34comment</comments>
      <pubDate>Sun, 8 Jun 2025 17:13:02 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 5. 임베드 메시지와 버튼 만들기: 봇과의 소통을 더 풍부하게!</title>
      <link>https://dishost.tistory.com/33</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 지난 시간에는 명령어에 쿨타임을 설정하고 예상치 못한 오류를 효과적으로 처리하는 방법을 배웠습니다. 덕분에 우리 봇은 한층 더 안정적이고 사용자 친화적으로 발전했네요.&lt;/p&gt;
&lt;p&gt;이번 시간에는 봇이 보내는 메시지를 훨씬 더 보기 좋고 다채롭게 만들어주는 &lt;strong&gt;임베드(Embed) 메시지&lt;/strong&gt;와, 사용자와의 상호작용을 한 단계 끌어올릴 수 있는 &lt;strong&gt;버튼(Button)&lt;/strong&gt; 컴포넌트에 대해 알아보겠습니다. 단순한 텍스트 응답을 넘어, 봇과의 대화가 더욱 즐거워질 준비, 되셨나요?&lt;/p&gt;
&lt;h2&gt;평범한 메시지는 이제 그만! 임베드(Embed) 메시지 활용하기&lt;/h2&gt;
&lt;p&gt;임베드 메시지는 디스코드에서 일반 텍스트 메시지보다 훨씬 풍부한 정보를 담을 수 있는 특별한 형식의 메시지입니다. 제목, 설명, 색상, 이미지, 썸네일, 필드, 푸터 등 다양한 요소를 활용하여 마치 잘 디자인된 카드처럼 정보를 표현할 수 있습니다.&lt;/p&gt;
&lt;p&gt;봇이 중요한 정보를 전달하거나, 명령어의 결과를 깔끔하게 보여주고 싶을 때 임베드는 아주 강력한 도구가 됩니다. &lt;code&gt;discord.js&lt;/code&gt;에서는 &lt;code&gt;EmbedBuilder&lt;/code&gt; 클래스를 사용하여 쉽게 임베드를 만들고 커스터마이징할 수 있습니다.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;EmbedBuilder&lt;/code&gt; 기본 사용법&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;EmbedBuilder&lt;/code&gt;는 여러 메서드를 체이닝 방식으로 호출하여 임베드의 각 부분을 설정합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;setTitle(title)&lt;/code&gt;: 임베드의 제목을 설정합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setDescription(description)&lt;/code&gt;: 임베드의 주요 내용을 설정합니다. Markdown도 지원합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setColor(color)&lt;/code&gt;: 임베드 좌측에 표시될 세로줄의 색상을 설정합니다. 16진수 색상 코드, 특정 색상 이름 (예: &amp;#39;Random&amp;#39;, &amp;#39;Blue&amp;#39;) 등을 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setAuthor({ name, iconURL, url })&lt;/code&gt;: 임베드 상단에 작성자 정보를 표시합니다. 이름, 아이콘 URL, 클릭 시 이동할 URL을 설정할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setThumbnail(url)&lt;/code&gt;: 임베드 우측 상단에 작은 이미지를 표시합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setImage(url)&lt;/code&gt;: 임베드 본문에 큰 이미지를 표시합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addFields(...fields)&lt;/code&gt;: 여러 개의 필드를 추가합니다. 각 필드는 &lt;code&gt;{ name: string, value: string, inline?: boolean }&lt;/code&gt; 형태의 객체입니다. &lt;code&gt;inline: true&lt;/code&gt;로 설정하면 여러 필드가 한 줄에 나란히 표시될 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setFooter({ text, iconURL })&lt;/code&gt;: 임베드 하단에 푸터 텍스트와 아이콘을 표시합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setTimestamp(timestamp?)&lt;/code&gt;: 임베드 푸터 옆에 타임스탬프를 표시합니다. 인자를 생략하면 현재 시간으로 설정됩니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setURL(url)&lt;/code&gt;: 제목을 클릭했을 때 이동할 URL을 설정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;예제: &lt;code&gt;/정보&lt;/code&gt; 명령어에 임베드 적용하기&lt;/h3&gt;
&lt;p&gt;지난 시간에 만들었던 &lt;code&gt;/정보 서버&lt;/code&gt; 명령어를 임베드를 사용하여 더 보기 좋게 만들어 봅시다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;명령어 파일 수정&lt;/strong&gt;: &lt;code&gt;src/commands/info.ts&lt;/code&gt; 파일을 열고 &lt;code&gt;execute&lt;/code&gt; 함수 내 &lt;code&gt;서버&lt;/code&gt; 서브커맨드 부분을 수정합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/info.ts
import { SlashCommandBuilder, ChatInputCommandInteraction, Guild, EmbedBuilder } from &amp;#39;discord.js&amp;#39;; // EmbedBuilder 추가

// ... (data 정의는 동일) ...

export async function execute(interaction: ChatInputCommandInteraction) {
    const subcommand = interaction.options.getSubcommand();

    if (subcommand === &amp;#39;사용자&amp;#39;) {
        // ... (사용자 정보 로직 - 이전과 동일하게 두거나, 여기도 임베드로 개선 가능) ...
        const user = interaction.options.getUser(&amp;#39;대상&amp;#39;, true);
        const member = interaction.guild?.members.cache.get(user.id);

        if (!member) {
            await interaction.reply({ content: &amp;#39;해당 사용자를 찾을 수 없습니다.&amp;#39;, ephemeral: true });
            return;
        }

        const userEmbed = new EmbedBuilder()
            .setColor(&amp;#39;Random&amp;#39;) // 랜덤 색상
            .setTitle(\`  \${user.username}님의 정보\`)
            .setThumbnail(user.displayAvatarURL({ dynamic: true })) // 동적 프로필 이미지 (gif 등 지원)
            .addFields(
                { name: &amp;#39;ID&amp;#39;, value: user.id, inline: true },
                { name: &amp;#39;태그&amp;#39;, value: user.tag, inline: true },
                { name: &amp;#39;계정 생성일&amp;#39;, value: user.createdAt.toLocaleDateString(&amp;#39;ko-KR&amp;#39;), inline: false },
                { name: &amp;#39;서버 참여일&amp;#39;, value: member.joinedAt ? member.joinedAt.toLocaleDateString(&amp;#39;ko-KR&amp;#39;) : &amp;#39;알 수 없음&amp;#39;, inline: false }
            )
            .setTimestamp()
            .setFooter({ text: \`요청자: \${interaction.user.username}\`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) });

        await interaction.reply({ embeds: [userEmbed] });

    } else if (subcommand === &amp;#39;서버&amp;#39;) {
        const guild = interaction.guild as Guild;

        if (!guild) {
            await interaction.reply({ content: &amp;#39;서버 정보를 가져올 수 없습니다. (DM에서는 사용 불가)&amp;#39;, ephemeral: true });
            return;
        }

        await guild.members.fetch(); // 멤버 수 정확히 가져오기
        const owner = await guild.fetchOwner();

        const serverEmbed = new EmbedBuilder()
            .setColor(0x0099FF) // 16진수 색상 코드 (파란색 계열)
            .setTitle(\`  \${guild.name} 서버 정보\`)
            .setThumbnail(guild.iconURL({ dynamic: true })) // 서버 아이콘
            .addFields(
                { name: &amp;#39;ID&amp;#39;, value: guild.id, inline: true },
                { name: &amp;#39;서버 주인&amp;#39;, value: owner.user.tag, inline: true },
                { name: &amp;#39;멤버 수&amp;#39;, value: \`총 \${guild.memberCount}명\`, inline: true }, // (온라인: \${guild.presences.cache.filter(p =&amp;gt; p.status !== &amp;#39;offline&amp;#39;).size}명) 등 추가 가능
                { name: &amp;#39;채널 수&amp;#39;, value: \`텍스트: \${guild.channels.cache.filter(c =&amp;gt; c.isTextBased()).size}개, 음성: \${guild.channels.cache.filter(c =&amp;gt; c.isVoiceBased()).size}개\`, inline: false },
                { name: &amp;#39;역할 수&amp;#39;, value: \`\${guild.roles.cache.size}개\`, inline: true },
                { name: &amp;#39;이모지 수&amp;#39;, value: \`\${guild.emojis.cache.size}개\`, inline: true },
                { name: &amp;#39;생성일&amp;#39;, value: guild.createdAt.toLocaleDateString(&amp;#39;ko-KR&amp;#39;), inline: false }
            )
            .setTimestamp()
            .setFooter({ text: \`요청자: \${interaction.user.username}\`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) });

        await interaction.reply({ embeds: [serverEmbed] });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;봇 실행 및 테스트&lt;/strong&gt;: 봇을 재시작하고 디스코드에서 &lt;code&gt;/정보 서버&lt;/code&gt;와 &lt;code&gt;/정보 사용자&lt;/code&gt; 명령어를 실행해보세요. 이전보다 훨씬 깔끔하고 보기 좋은 형태로 정보가 표시될 겁니다!&lt;/p&gt;
&lt;p&gt;응답을 보낼 때는 &lt;code&gt;interaction.reply()&lt;/code&gt;의 &lt;code&gt;embeds&lt;/code&gt; 속성에 &lt;code&gt;EmbedBuilder&lt;/code&gt; 인스턴스를 배열로 담아 전달합니다. (&lt;code&gt;{ embeds: [myEmbed] }&lt;/code&gt;)&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;사용자와의 다음 단계: 버튼 (Buttons) 추가하기&lt;/h2&gt;
&lt;p&gt;버튼은 사용자가 메시지 아래에 있는 버튼을 클릭하여 봇과 상호작용할 수 있게 하는 기능입니다. 예를 들어, &amp;quot;예/아니오&amp;quot; 선택, 페이지 넘기기, 특정 작업 실행 등을 버튼으로 구현할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;discord.js&lt;/code&gt;에서는 &lt;code&gt;ButtonBuilder&lt;/code&gt;를 사용하여 버튼을 만들고, &lt;code&gt;ActionRowBuilder&lt;/code&gt;를 사용하여 이 버튼들을 한 줄에 배치합니다. 버튼 클릭 이벤트는 &lt;code&gt;Events.InteractionCreate&lt;/code&gt; 이벤트 핸들러에서 &lt;code&gt;interaction.isButton()&lt;/code&gt;으로 감지하여 처리합니다.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;ButtonBuilder&lt;/code&gt; 기본 사용법&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;setCustomId(id)&lt;/code&gt;: 버튼을 식별하는 고유 ID를 설정합니다. 이 ID를 통해 어떤 버튼이 클릭되었는지 구분합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setLabel(label)&lt;/code&gt;: 버튼에 표시될 텍스트를 설정합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setStyle(style)&lt;/code&gt;: 버튼의 스타일(색상)을 설정합니다. &lt;code&gt;ButtonStyle&lt;/code&gt; 열거형 값을 사용합니다.&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ButtonStyle.Primary&lt;/code&gt;: 파란색 (기본)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ButtonStyle.Secondary&lt;/code&gt;: 회색&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ButtonStyle.Success&lt;/code&gt;: 초록색&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ButtonStyle.Danger&lt;/code&gt;: 빨간색&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ButtonStyle.Link&lt;/code&gt;: URL로 이동하는 링크 버튼 (이 경우 &lt;code&gt;setURL()&lt;/code&gt;도 필요)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setEmoji(emoji)&lt;/code&gt;: 버튼에 이모지를 추가합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setDisabled(disabled)&lt;/code&gt;: 버튼을 비활성화할지 여부를 설정합니다. (기본값: &lt;code&gt;false&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setURL(url)&lt;/code&gt;: &lt;code&gt;ButtonStyle.Link&lt;/code&gt; 스타일일 때, 클릭 시 이동할 URL을 설정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;예제: &lt;code&gt;/투표&lt;/code&gt; 명령어 만들기 (버튼 활용)&lt;/h3&gt;
&lt;p&gt;간단한 찬반 투표 기능을 가진 &lt;code&gt;/투표&lt;/code&gt; 명령어를 만들어 봅시다. 사용자가 투표 주제를 입력하면, 봇이 임베드 메시지와 함께 &amp;quot;찬성&amp;quot;과 &amp;quot;반대&amp;quot; 버튼을 표시하고, 각 버튼이 클릭될 때마다 콘솔에 로그를 남기는 예제입니다. (실제 투표 수 집계는 다음 단계에서 다룰 수 있습니다.)&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;명령어 파일 생성&lt;/strong&gt;: &lt;code&gt;src/commands/vote.ts&lt;/code&gt; 파일을 새로 만듭니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/vote.ts
import {
    SlashCommandBuilder,
    ChatInputCommandInteraction,
    EmbedBuilder,
    ButtonBuilder,
    ButtonStyle,
    ActionRowBuilder,
    ComponentType // ActionRowBuilder에 버튼을 담을 때 필요
} from &amp;#39;discord.js&amp;#39;;

export const data = new SlashCommandBuilder()
    .setName(&amp;#39;투표&amp;#39;)
    .setDescription(&amp;#39;간단한 찬반 투표를 시작합니다.&amp;#39;)
    .addStringOption(option =&amp;gt;
        option.setName(&amp;#39;주제&amp;#39;)
            .setDescription(&amp;#39;투표할 주제를 입력해주세요.&amp;#39;)
            .setRequired(true));

export async function execute(interaction: ChatInputCommandInteraction) {
    const topic = interaction.options.getString(&amp;#39;주제&amp;#39;, true);

    const voteEmbed = new EmbedBuilder()
        .setColor(0x5865F2) // 디스코드 보라색
        .setTitle(\` ️ 투표: \${topic}\`)
        .setDescription(&amp;#39;아래 버튼을 눌러 찬성 또는 반대 의견을 표시해주세요!&amp;#39;)
        .setTimestamp()
        .setFooter({ text: \`투표 시작자: \${interaction.user.username}\`, iconURL: interaction.user.displayAvatarURL() });

    // 버튼 생성
    const approveButton = new ButtonBuilder()
        .setCustomId(&amp;#39;vote_approve&amp;#39;) // 각 버튼을 구별할 고유 ID
        .setLabel(&amp;#39;찬성  &amp;#39;)
        .setStyle(ButtonStyle.Success); // 초록색 버튼

    const disapproveButton = new ButtonBuilder()
        .setCustomId(&amp;#39;vote_disapprove&amp;#39;)
        .setLabel(&amp;#39;반대  &amp;#39;)
        .setStyle(ButtonStyle.Danger); // 빨간색 버튼

    // ActionRowBuilder에 버튼들을 추가 (한 줄에 최대 5개까지 가능)
    // ActionRowBuilder&amp;lt;ButtonBuilder&amp;gt; 타입 명시
    const row = new ActionRowBuilder&amp;lt;ButtonBuilder&amp;gt;()
        .addComponents(approveButton, disapproveButton);

    await interaction.reply({
        embeds: [voteEmbed],
        components: [row] // components 속성에 ActionRow 배열을 전달
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;명령어 등록&lt;/strong&gt;: &lt;code&gt;src/commands/index.ts&lt;/code&gt; 파일에 &lt;code&gt;vote&lt;/code&gt; 명령어를 추가합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/index.ts
import * as ping from &amp;quot;./ping&amp;quot;;
import * as greet from &amp;quot;./greet&amp;quot;;
import * as info from &amp;quot;./info&amp;quot;;
import * as vote from &amp;quot;./vote&amp;quot;; // vote 명령어 import 추가

export const commands = {
  ping,
  greet,
  info,
  vote, // commands 객체에 vote 추가
};&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;버튼 상호작용 처리&lt;/strong&gt;: &lt;code&gt;src/index.ts&lt;/code&gt; 파일의 &lt;code&gt;InteractionCreate&lt;/code&gt; 이벤트 핸들러를 수정하여 버튼 클릭을 감지하고 처리합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts (일부 발췌)
// ... (client, commands 등 기존 코드) ...

client.on(Events.InteractionCreate, async (interaction) =&amp;gt; {
    try {
        if (interaction.isChatInputCommand()) {
            const command = commands[interaction.commandName as keyof typeof commands];
            if (!command) {
                console.error(\`No command matching \${interaction.commandName} was found.\`);
                await interaction.reply({ content: &amp;#39;알 수 없는 명령어입니다.&amp;#39;, ephemeral: true });
                return;
            }
            await command.execute(interaction);

        } else if (interaction.isButton()) {
            // 버튼 상호작용 처리
            const customId = interaction.customId;

            if (customId === &amp;#39;vote_approve&amp;#39;) {
                // ephemeral: true 로 설정하면 클릭한 사용자에게만 보임
                await interaction.reply({ content: &amp;#39;찬성표를 던졌습니다!&amp;#39;, ephemeral: true });
                console.log(\`\${interaction.user.tag}님이 &amp;#39;\${interaction.message.embeds[0]?.title}&amp;#39; 투표에 찬성했습니다.\`);
            } else if (customId === &amp;#39;vote_disapprove&amp;#39;) {
                await interaction.reply({ content: &amp;#39;반대표를 던졌습니다!&amp;#39;, ephemeral: true });
                console.log(\`\${interaction.user.tag}님이 &amp;#39;\${interaction.message.embeds[0]?.title}&amp;#39; 투표에 반대했습니다.\`);
            }
            // 실제 투표 데이터를 저장하고 업데이트하는 로직은 여기에 추가될 수 있습니다.
            // 예를 들어, interaction.message.edit()을 사용하여 임베드나 버튼을 수정할 수 있습니다.
        }
        // 여기에 다른 타입의 상호작용(SelectMenu, Modal 등) 처리 로직을 추가할 수 있습니다.

    } catch (error) {
        console.error(&amp;#39;Error handling interaction:&amp;#39;, error);
        if (interaction.isRepliable()) {
            const replyMethod = interaction.replied || interaction.deferred ? &amp;#39;followUp&amp;#39; : &amp;#39;reply&amp;#39;;
            await interaction[replyMethod]({
                content: &amp;#39;명령어 처리 중 알 수 없는 오류가 발생했습니다.&amp;#39;,
                ephemeral: true
            }).catch(e =&amp;gt; console.error(&amp;#39;오류 응답 전송 실패:&amp;#39;, e));
        }
    }
});

// ... (봇 로그인) ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;중요&lt;/strong&gt;: 버튼 클릭에 대한 응답(`interaction.reply`, `interaction.update`, `interaction.deferUpdate` 등)은 &lt;strong&gt;반드시 3초 이내에&lt;/strong&gt; 이루어져야 합니다. 그렇지 않으면 상호작용 실패로 간주됩니다. 만약 응답 준비에 시간이 더 걸린다면, 먼저 `interaction.deferUpdate()` (버튼 상태는 그대로 두고 로딩 상태만 표시) 또는 `interaction.deferReply()` (새로운 메시지로 응답할 것임을 알리고 로딩 상태 표시)를 호출해야 합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;봇 실행 및 테스트&lt;/strong&gt;: 봇을 재시작하고 &lt;code&gt;/투표 주제:새로운 기능 추가&lt;/code&gt; 와 같이 명령어를 실행해보세요. 임베드 메시지와 함께 찬성/반대 버튼이 나타나고, 버튼을 클릭하면 콘솔에 로그가 찍히고 사용자에게 ephemeral 메시지가 표시될 겁니다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;오늘은 &lt;code&gt;EmbedBuilder&lt;/code&gt;를 사용하여 정보를 시각적으로 아름답게 표현하는 방법과, &lt;code&gt;ButtonBuilder&lt;/code&gt; 및 &lt;code&gt;ActionRowBuilder&lt;/code&gt;를 활용하여 사용자와의 상호작용을 한층 끌어올리는 버튼 컴포넌트를 만드는 방법을 배웠습니다. 이제 여러분의 봇은 단순한 텍스트를 넘어, 풍부한 UI를 통해 사용자와 소통할 수 있게 되었습니다.&lt;/p&gt;
&lt;p&gt;다음 시간에는 디스코드 봇 개발에서 빼놓을 수 없는 중요한 부분인 &lt;strong&gt;이벤트 핸들링&lt;/strong&gt;에 대해 더 깊이 파고들어 보겠습니다. 단순히 명령어 입력(`InteractionCreate`)뿐만 아니라, 메시지 수정, 삭제, 멤버의 서버 참여/퇴장 등 다양한 서버 내 활동들을 감지하고 그에 맞는 동작을 수행하는 방법을 배우게 될 겁니다. 봇이 더욱 능동적으로 서버 환경에 반응하도록 만들어 봅시다!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/33</guid>
      <comments>https://dishost.tistory.com/33#entry33comment</comments>
      <pubDate>Fri, 6 Jun 2025 17:12:29 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 4. 명령어 쿨타임과 안정적인 오류 처리</title>
      <link>https://dishost.tistory.com/32</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;지금까지 우리는 슬래시 명령어의 구조를 잡고, 옵션과 서브커맨드를 활용하여 다양한 기능을 가진 명령어를 만드는 방법을 배웠습니다. 이제 우리 봇은 제법 여러 가지 일을 할 수 있게 되었죠. 하지만 사용자가 너무 짧은 시간 안에 명령어를 반복해서 사용하거나, 예상치 못한 오류가 발생했을 때 봇이 불안정해지거나 서버에 부담을 줄 수 있습니다.&lt;/p&gt;
&lt;p&gt;이번 시간에는 이러한 문제들을 방지하고 봇을 더욱 안정적으로 운영하기 위한 두 가지 중요한 주제, 바로 &lt;strong&gt;명령어 쿨타임(Cooldown)&lt;/strong&gt;과 &lt;strong&gt;오류 처리(Error Handling)&lt;/strong&gt;에 대해 깊이 있게 알아보겠습니다.&lt;/p&gt;
&lt;h2&gt;명령어 남용 방지: 쿨타임 구현하기&lt;/h2&gt;
&lt;p&gt;쿨타임은 특정 명령어를 한 번 사용한 후, 일정 시간이 지나야만 다시 사용할 수 있도록 제한하는 기능입니다. 예를 들어, 외부 API를 호출하여 정보를 가져오는 명령어의 경우, 너무 잦은 호출은 API 제공처에 부담을 주거나 계정 사용량 제한에 걸릴 수 있습니다. 또는 단순히 특정 명령어가 채팅창을 도배하는 것을 막기 위해서도 쿨타임은 유용합니다.&lt;/p&gt;
&lt;h3&gt;쿨타임 기본 아이디어&lt;/h3&gt;
&lt;p&gt;쿨타임을 구현하는 가장 기본적인 방법은 다음과 같습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;사용자가 명령어를 사용할 때마다, 해당 사용자와 명령어 조합에 대한 마지막 사용 시간을 기록합니다.&lt;/li&gt;
&lt;li&gt;다음에 같은 사용자가 같은 명령어를 사용하려고 할 때, 현재 시간과 마지막 사용 시간을 비교합니다.&lt;/li&gt;
&lt;li&gt;미리 설정된 쿨타임 시간보다 짧은 시간 안에 다시 사용하려고 하면, 명령어 실행을 막고 사용자에게 알림을 보냅니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Discord.js에서는 &lt;code&gt;Collection&lt;/code&gt; (JavaScript의 &lt;code&gt;Map&lt;/code&gt;과 유사하지만 추가 기능이 있음)을 사용하여 이러한 정보를 효율적으로 관리할 수 있습니다.&lt;/p&gt;
&lt;h3&gt;쿨타임 적용하기&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;에는 아직 쿨타임 기능이 직접적으로 구현되어 있지 않으므로, 우리가 직접 추가해보겠습니다. &lt;code&gt;src/index.ts&lt;/code&gt; 파일이나, 각 명령어 파일 내에서 쿨타임을 관리할 수 있습니다. 여기서는 각 명령어별로 쿨타임을 설정하고 관리하는 방식을 예시로 들어보겠습니다.&lt;/p&gt;
&lt;p&gt;먼저, 명령어 파일에 쿨타임 정보를 추가할 수 있도록 구조를 확장해봅시다. 예를 들어, &lt;code&gt;ping.ts&lt;/code&gt; 명령어에 5초의 쿨타임을 주고 싶다고 가정해봅시다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/ping.ts
import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  Collection,
} from &amp;quot;discord.js&amp;quot;;

// 이 명령어에 대한 쿨타임 정보를 저장할 Collection
// Key: 사용자 ID (string), Value: 마지막 사용 시간 (timestamp, number)
const cooldowns = new Collection&amp;lt;string, number&amp;gt;();
const COOLDOWN_SECONDS = 5; // 5초 쿨타임

export const data = new SlashCommandBuilder()
  .setName(&amp;quot;핑&amp;quot;)
  .setDescription(&amp;quot;봇의 응답 속도를 확인합니다.&amp;quot;);

export async function execute(interaction: ChatInputCommandInteraction) {
  const userId = interaction.user.id;
  const now = Date.now();

  if (cooldowns.has(userId)) {
    const lastUsage = cooldowns.get(userId)!;
    const expirationTime = lastUsage + COOLDOWN_SECONDS * 1000;

    if (now &amp;lt; expirationTime) {
      const timeLeft = (expirationTime - now) / 1000;
      return interaction.reply({
        content: `너무 빨리 명령어를 사용하셨어요! \\${timeLeft.toFixed(
          1
        )}초 뒤에 다시 시도해주세요.`,
        ephemeral: true, // 사용자에게만 보이는 메시지
      });
    }
  }

  // 쿨타임이 지났거나 처음 사용하는 경우, 현재 시간을 기록
  cooldowns.set(userId, now);
  // 쿨타임이 만료된 후에는 메모리에서 해당 사용자 정보를 삭제 (선택적 최적화)
  setTimeout(() =&amp;gt; cooldowns.delete(userId), COOLDOWN_SECONDS * 1000);

  // 기존 명령어 로직
  const sentMessage = await interaction.reply({
    content: &amp;quot;퐁! 응답 속도를 계산하고 있어요...&amp;quot;,
    fetchReply: true,
  });
  const latency = sentMessage.createdTimestamp - interaction.createdTimestamp;
  await interaction.editReply(
    `퐁!   응답 속도는 \\${latency}ms 입니다. API 지연 시간은 약 \\${Math.round(
      interaction.client.ws.ping
    )}ms 입니다.`
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;코드 설명:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;cooldowns&lt;/code&gt;: &lt;code&gt;Collection&lt;/code&gt;을 생성하여 사용자 ID와 마지막 명령어 사용 시간을 저장합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COOLDOWN_SECONDS&lt;/code&gt;: 이 명령어의 쿨타임 시간을 초 단위로 설정합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;execute&lt;/code&gt; 함수 시작 시:&lt;ul&gt;
&lt;li&gt;현재 사용자의 ID와 현재 시간을 가져옵니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cooldowns&lt;/code&gt;에 해당 사용자 ID가 있는지 확인합니다.&lt;ul&gt;
&lt;li&gt;있다면, 마지막 사용 시간과 현재 시간을 비교하여 쿨타임이 지나지 않았으면 사용자에게 남은 시간을 알리고 명령어 실행을 중단합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;쿨타임이 지났거나 처음 사용하는 경우, &lt;code&gt;cooldowns&lt;/code&gt;에 현재 사용 시간을 기록합니다.&lt;/li&gt;
&lt;li&gt;(선택 사항) &lt;code&gt;setTimeout&lt;/code&gt;을 사용하여 쿨타임이 만료된 후에는 &lt;code&gt;cooldowns&lt;/code&gt;에서 해당 사용자 정보를 삭제하여 메모리를 절약할 수 있습니다. 이 방식은 사용자가 매우 많을 때 유용할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이후 기존의 명령어 로직을 실행합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 방식은 각 명령어 파일마다 &lt;code&gt;cooldowns&lt;/code&gt; Collection과 관련 로직을 추가해야 합니다. 만약 모든 명령어에 일괄적으로 적용하거나, 좀 더 중앙에서 관리하고 싶다면 &lt;code&gt;src/index.ts&lt;/code&gt;의 &lt;code&gt;InteractionCreate&lt;/code&gt; 이벤트 핸들러에서 처리하는 방법도 있습니다. 이 경우, 어떤 명령어가 호출되었는지, 해당 명령어의 쿨타임은 얼마인지 등의 정보를 추가로 관리해야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;의 &lt;code&gt;src/commands/index.ts&lt;/code&gt;에서 각 명령어 모듈이 &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;execute&lt;/code&gt; 외에 &lt;code&gt;cooldown&lt;/code&gt; (숫자, 초 단위) 같은 속성을 추가로 export 하도록 하고, &lt;code&gt;src/index.ts&lt;/code&gt;에서 이 값을 읽어와 쿨타임을 적용하는 것도 좋은 방법입니다.&lt;/p&gt;
&lt;h2&gt;예상치 못한 상황에 대비: 오류 처리&lt;/h2&gt;
&lt;p&gt;아무리 코드를 꼼꼼하게 작성해도 예상치 못한 오류는 발생할 수 있습니다. 네트워크 문제, 외부 API의 예기치 않은 응답, 코드의 논리적 결함 등 원인은 다양합니다. 중요한 것은 오류가 발생했을 때 봇이 완전히 멈춰버리거나 사용자에게 혼란을 주지 않도록 적절히 처리하는 것입니다.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;try...catch&lt;/code&gt; 기본&lt;/h3&gt;
&lt;p&gt;JavaScript와 TypeScript에서 오류를 처리하는 가장 기본적인 방법은 &lt;code&gt;try...catch&lt;/code&gt; 구문입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;try {
  // 오류가 발생할 가능성이 있는 코드
  // 예를 들어, await someAsyncFunction();
} catch (error) {
  // 오류가 발생했을 때 실행될 코드
  console.error(&amp;quot;오류 발생:&amp;quot;, error);
  // 사용자에게 알림을 보낼 수도 있습니다.
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;명령어 실행 중 오류 처리&lt;/h3&gt;
&lt;p&gt;각 명령어의 &lt;code&gt;execute&lt;/code&gt; 함수 내부에서 중요한 로직은 &lt;code&gt;try...catch&lt;/code&gt;로 감싸는 것이 좋습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/어떤명령어.ts
// ... (data 정의) ...
export async function execute(interaction: ChatInputCommandInteraction) {
  try {
    // 명령어의 핵심 로직
    await interaction.reply(&amp;quot;명령어가 성공적으로 실행되었습니다!&amp;quot;);
  } catch (error) {
    console.error(`[\\${interaction.commandName}] 명령어 실행 중 오류:`, error);

    // 사용자에게 오류를 알립니다.
    // 이미 응답을 보냈는지 (replied) 또는 응답을 수정한 적이 있는지 (deferred) 확인하여
    // 적절한 응답 방식을 사용합니다.
    if (interaction.replied || interaction.deferred) {
      await interaction.followUp({
        content: &amp;quot;명령을 처리하는 동안 문제가 발생했어요.  &amp;quot;,
        ephemeral: true,
      });
    } else {
      await interaction.reply({
        content: &amp;quot;명령을 처리하는 동안 문제가 발생했어요.  &amp;quot;,
        ephemeral: true,
      });
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;코드 설명:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;execute&lt;/code&gt; 함수의 주요 로직을 &lt;code&gt;try&lt;/code&gt; 블록 안에 넣습니다.&lt;/li&gt;
&lt;li&gt;오류 발생 시 &lt;code&gt;catch&lt;/code&gt; 블록에서 &lt;code&gt;console.error&lt;/code&gt;로 서버 로그에 오류를 기록합니다. 이때 어떤 명령어에서 오류가 발생했는지 함께 기록하면 디버깅에 도움이 됩니다.&lt;/li&gt;
&lt;li&gt;사용자에게도 오류가 발생했음을 알립니다.&lt;ul&gt;
&lt;li&gt;&lt;code&gt;interaction.replied&lt;/code&gt; 또는 &lt;code&gt;interaction.deferred&lt;/code&gt; 속성을 확인하여, 이미 봇이 한 번 응답을 했거나 응답을 지연시킨 상태인지 체크합니다.&lt;/li&gt;
&lt;li&gt;이미 응답한 상태라면 &lt;code&gt;interaction.followUp()&lt;/code&gt;을 사용하여 추가 메시지를 보내고, 그렇지 않다면 &lt;code&gt;interaction.reply()&lt;/code&gt;를 사용합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ephemeral: true&lt;/code&gt; 옵션으로 오류 메시지는 명령어를 사용한 당사자에게만 보이도록 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;전역 오류 처리 (&lt;code&gt;src/index.ts&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;의 &lt;code&gt;src/index.ts&lt;/code&gt; 파일에 있는 &lt;code&gt;InteractionCreate&lt;/code&gt; 이벤트 핸들러에도 이미 &lt;code&gt;try...catch&lt;/code&gt; 블록이 있습니다. 이는 개별 명령어에서 미처 처리하지 못한 오류나, 명령어 자체를 찾는 과정에서의 오류 등을 잡아내는 최후의 방어선 역할을 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts (일부 발췌)
client.on(Events.InteractionCreate, async (interaction) =&amp;gt; {
  try {
    if (!interaction.isChatInputCommand()) return;
    const command = commands[interaction.commandName as keyof typeof commands];
    if (!command) {
      // ... 명령어를 찾지 못한 경우 처리 ...
      return;
    }
    await command.execute(interaction); // 여기서 발생한 오류가 command.execute 내부에서 처리되지 않으면 아래 catch로 넘어감
  } catch (error) {
    console.error(&amp;quot;전역 인터랙션 핸들러 오류:&amp;quot;, error);
    if (interaction.isRepliable()) {
      // isRepliable()은 replied 또는 deferred가 false일 때 true
      // 이미 응답했는지 여부에 따라 reply 또는 followUp을 선택하는 로직이 더 안전할 수 있습니다.
      const replyMethod =
        interaction.replied || interaction.deferred ? &amp;quot;followUp&amp;quot; : &amp;quot;reply&amp;quot;;
      await interaction[replyMethod]({
        content: &amp;quot;명령어 처리 중 알 수 없는 오류가 발생했습니다.&amp;quot;,
        ephemeral: true,
      }).catch((e) =&amp;gt; console.error(&amp;quot;오류 응답 전송 실패:&amp;quot;, e)); // 오류 응답 전송 자체도 실패할 수 있음
    }
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 전역 핸들러는 모든 명령어 실행을 감싸고 있기 때문에, 각 명령어 파일에서 &lt;code&gt;try...catch&lt;/code&gt;를 잘 구현했더라도 이중으로 안전장치를 마련하는 효과가 있습니다.&lt;/p&gt;
&lt;h3&gt;특정 오류에 대한 구체적인 처리&lt;/h3&gt;
&lt;p&gt;때로는 발생할 수 있는 특정 오류 유형을 미리 알고 있고, 그에 따라 다른 방식으로 처리하고 싶을 수 있습니다. 예를 들어, 디스코드 API 권한 부족으로 인한 오류(&lt;code&gt;DiscordAPIError&lt;/code&gt;의 특정 코드)가 발생하면 사용자에게 &amp;quot;봇에게 필요한 권한이 없어요!&amp;quot;라고 알려줄 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { DiscordAPIError } from &amp;#39;discord.js&amp;#39;;

// ... execute 함수 내에서 ...
} catch (error) {
    if (error instanceof DiscordAPIError &amp;amp;&amp;amp; error.code === 50013) { // 50013: Missing Permissions
        console.warn(`[\\${interaction.commandName}] 권한 부족 오류: \\${error.message}`);
        await interaction.reply({ content: &amp;#39;이 명령을 실행하기 위한 봇의 권한이 부족해요. 서버 관리자에게 문의해주세요.&amp;#39;, ephemeral: true });
    } else {
        console.error(`[\\${interaction.commandName}] 명령어 실행 중 오류:`, error);
        // 일반적인 오류 메시지
        const replyMethod = interaction.replied || interaction.deferred ? &amp;#39;followUp&amp;#39; : &amp;#39;reply&amp;#39;;
        await interaction[replyMethod]({ content: &amp;#39;명령을 처리하는 동안 문제가 발생했어요.&amp;#39;, ephemeral: true });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;이번 시간에는 명령어 쿨타임을 설정하여 봇의 남용을 방지하는 방법과, &lt;code&gt;try...catch&lt;/code&gt;를 활용하여 예상치 못한 오류에 효과적으로 대응하는 방법을 배웠습니다. 이러한 기능들은 사용자 경험을 향상시키고 봇을 더욱 안정적이고 견고하게 만드는 데 필수적입니다.&lt;/p&gt;
&lt;p&gt;다음 시간에는 봇의 응답을 더욱 풍부하고 보기 좋게 만들어주는 &lt;strong&gt;임베드(Embed) 메시지&lt;/strong&gt;와, 사용자와의 상호작용을 한 단계 끌어올릴 수 있는 &lt;strong&gt;버튼(Button)&lt;/strong&gt; 컴포넌트를 만드는 방법에 대해 알아보겠습니다. 봇과의 대화가 더욱 즐거워질 거예요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/32</guid>
      <comments>https://dishost.tistory.com/32#entry32comment</comments>
      <pubDate>Thu, 5 Jun 2025 17:11:49 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 3. 슬래시 명령어: 옵션과 서브커맨드로 더욱 강력하게!</title>
      <link>https://dishost.tistory.com/31</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;지난 시간에는 명령어들을 체계적으로 관리하기 위한 폴더 구조를 만들고, 첫 번째 슬래시 명령어인 &lt;code&gt;/핑&lt;/code&gt;을 등록해보았습니다. 이제 우리 봇은 단순한 텍스트 기반 명령어가 아닌, 디스코드 인터페이스에 깔끔하게 통합되는 슬래시 명령어를 사용할 준비가 되었죠.&lt;/p&gt;
&lt;p&gt;이번 시간에는 여기서 한 걸음 더 나아가, 슬래시 명령어를 더욱 강력하고 유용하게 만들어주는 &lt;strong&gt;옵션(Options)&lt;/strong&gt;과 &lt;strong&gt;서브커맨드(Subcommands)&lt;/strong&gt;에 대해 자세히 알아보겠습니다. 사용자와 더 다양한 방식으로 상호작용하고, 복잡한 기능도 깔끔하게 구현할 수 있게 될 거예요.&lt;/p&gt;
&lt;h2&gt;슬래시 명령어에 날개를 달아주는 &amp;#39;옵션&amp;#39;&lt;/h2&gt;
&lt;p&gt;지금까지 만든 &lt;code&gt;/핑&lt;/code&gt; 명령어는 사용자가 단순히 &lt;code&gt;/핑&lt;/code&gt;이라고 입력하면 정해진 응답을 보내는 방식이었습니다. 하지만 많은 경우, 명령어에 추가적인 정보를 함께 전달하고 싶을 때가 있습니다. 예를 들어, &amp;quot;/인사 [이름]&amp;quot;처럼 특정 사용자에게 인사하거나, &amp;quot;/검색 [키워드]&amp;quot;처럼 특정 내용을 검색하는 명령어를 만들고 싶을 수 있죠. 이럴 때 사용하는 것이 바로 &amp;#39;옵션&amp;#39;입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SlashCommandBuilder&lt;/code&gt;는 다양한 종류의 옵션을 추가할 수 있는 메서드들을 제공합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;addStringOption()&lt;/code&gt;: 문자열 입력&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addIntegerOption()&lt;/code&gt;: 정수 입력&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addNumberOption()&lt;/code&gt;: 숫자 (정수 또는 실수) 입력&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addBooleanOption()&lt;/code&gt;: 참/거짓 선택&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addUserOption()&lt;/code&gt;: 사용자 멘션&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addChannelOption()&lt;/code&gt;: 채널 선택&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addRoleOption()&lt;/code&gt;: 역할 선택&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addMentionableOption()&lt;/code&gt;: 사용자 또는 역할 멘션&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addAttachmentOption()&lt;/code&gt;: 파일 첨부&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;각 옵션 메서드는 체이닝(chaining) 방식으로 설정할 수 있으며, 공통적으로 다음과 같은 세부 설정을 할 수 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;setName(name)&lt;/code&gt;: 옵션의 이름 (코드에서 이 이름으로 값을 가져옴, 영어 소문자 권장)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setDescription(description)&lt;/code&gt;: 옵션에 대한 설명 (사용자에게 표시됨)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setRequired(boolean)&lt;/code&gt;: 이 옵션이 필수인지 아닌지 설정 (기본값은 &lt;code&gt;false&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;예제: 사용자에게 인사하는 명령어 만들기&lt;/h3&gt;
&lt;p&gt;그럼 &lt;code&gt;/인사&lt;/code&gt;라는 명령어를 만들고, &lt;code&gt;user&lt;/code&gt; 타입의 옵션과 &lt;code&gt;message&lt;/code&gt; 타입의 옵션을 추가해보겠습니다. &lt;code&gt;user&lt;/code&gt; 옵션은 필수로, &lt;code&gt;message&lt;/code&gt; 옵션은 선택으로 설정해볼게요.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;src/commands/인사.ts&lt;/code&gt; (또는 &lt;code&gt;greet.ts&lt;/code&gt;) 파일을 새로 만들고 아래와 같이 작성합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/인사.ts
import { SlashCommandBuilder, ChatInputCommandInteraction, User } from &amp;#39;discord.js&amp;#39;;

export const data = new SlashCommandBuilder()
    .setName(&amp;#39;인사&amp;#39;)
    .setDescription(&amp;#39;지정한 사용자에게 인사 메시지를 보냅니다.&amp;#39;)
    .addUserOption(option =&amp;gt;
        option.setName(&amp;#39;대상&amp;#39;) // 옵션 이름
            .setDescription(&amp;#39;인사할 대상을 선택하세요.&amp;#39;) // 옵션 설명
            .setRequired(true) // 필수 옵션으로 설정
    )
    .addStringOption(option =&amp;gt;
        option.setName(&amp;#39;메시지&amp;#39;)
            .setDescription(&amp;#39;함께 전달할 메시지 (선택 사항)&amp;#39;)
            .setRequired(false) // 선택적 옵션
    );

export async function execute(interaction: ChatInputCommandInteraction) {
    // 옵션 값 가져오기
    const targetUser = interaction.options.getUser(&amp;#39;대상&amp;#39;); // &amp;#39;대상&amp;#39; 옵션에서 User 객체를 가져옴
    const customMessage = interaction.options.getString(&amp;#39;메시지&amp;#39;); // &amp;#39;메시지&amp;#39; 옵션에서 문자열을 가져옴

    if (!targetUser) {
        // 혹시 모를 경우에 대비 (setRequired(true)로 설정했으므로 보통은 null이 아님)
        return interaction.reply({ content: &amp;#39;인사할 대상을 찾을 수 없어요.&amp;#39;, ephemeral: true });
    }

    let replyMessage = \`\\${targetUser.toString()}님, 안녕하세요!  \\\`;

    if (customMessage) {
        replyMessage += `\\n&amp;gt; \\${customMessage}\`;
    }

    await interaction.reply(replyMessage);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;코드 설명:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;addUserOption(option =&amp;gt; ...)&lt;/code&gt;: &lt;code&gt;대상&lt;/code&gt;이라는 이름의 사용자 옵션을 추가합니다. &lt;code&gt;setRequired(true)&lt;/code&gt;로 필수 입력하도록 했습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addStringOption(option =&amp;gt; ...)&lt;/code&gt;: &lt;code&gt;메시지&lt;/code&gt;라는 이름의 문자열 옵션을 추가하고, 선택 사항으로 남겨둡니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;interaction.options.getUser(&amp;#39;대상&amp;#39;)&lt;/code&gt;: &lt;code&gt;execute&lt;/code&gt; 함수 내에서 &lt;code&gt;interaction.options&lt;/code&gt; 객체의 &lt;code&gt;getUser()&lt;/code&gt; 메서드를 사용해 &amp;#39;대상&amp;#39; 옵션으로 전달된 &lt;code&gt;User&lt;/code&gt; 객체를 가져옵니다. 다른 타입의 옵션도 &lt;code&gt;getString()&lt;/code&gt;, &lt;code&gt;getInteger()&lt;/code&gt;, &lt;code&gt;getBoolean()&lt;/code&gt; 등으로 가져올 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;customMessage&lt;/code&gt;가 있다면 인사 메시지에 추가합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 파일을 만들었다면, &lt;code&gt;src/commands/index.ts&lt;/code&gt;에 &lt;code&gt;인사&lt;/code&gt; 명령어를 추가하고, &lt;code&gt;npm run dev&lt;/code&gt; (또는 &lt;code&gt;npm start&lt;/code&gt;)로 봇을 재시작하여 디스코드에 명령어가 잘 등록되는지 확인합니다. (boilerplate는 봇 시작 시 자동으로 명령어를 배포합니다.)&lt;/p&gt;
&lt;p&gt;이제 디스코드에서 &lt;code&gt;/인사&lt;/code&gt;를 입력하면 &lt;code&gt;대상&lt;/code&gt;을 선택하는 필드가 나타나고, 선택적으로 &lt;code&gt;메시지&lt;/code&gt;도 입력할 수 있게 됩니다.&lt;/p&gt;
&lt;h3&gt;옵션에 선택지(Choices) 추가하기&lt;/h3&gt;
&lt;p&gt;때로는 사용자가 특정 값들 중에서만 선택하도록 하고 싶을 수 있습니다. 이럴 때 &lt;code&gt;addChoices()&lt;/code&gt; 메서드를 사용합니다.&lt;/p&gt;
&lt;p&gt;예를 들어, &lt;code&gt;/음식추천&lt;/code&gt;이라는 명령어를 만들고, 음식 종류를 &amp;#39;한식&amp;#39;, &amp;#39;중식&amp;#39;, &amp;#39;일식&amp;#39;, &amp;#39;양식&amp;#39; 중에서 고르도록 해봅시다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/음식추천.ts
import { SlashCommandBuilder, ChatInputCommandInteraction } from &amp;#39;discord.js&amp;#39;;

export const data = new SlashCommandBuilder()
    .setName(&amp;#39;음식추천&amp;#39;)
    .setDescription(&amp;#39;선택한 종류의 음식을 추천해줍니다.&amp;#39;)
    .addStringOption(option =&amp;gt;
        option.setName(&amp;#39;종류&amp;#39;)
            .setDescription(&amp;#39;원하는 음식 종류를 선택하세요.&amp;#39;)
            .setRequired(true)
            .addChoices(
                { name: &amp;#39;  한식&amp;#39;, value: &amp;#39;korean&amp;#39; },
                { name: &amp;#39;  중식&amp;#39;, value: &amp;#39;chinese&amp;#39; },
                { name: &amp;#39;  일식&amp;#39;, value: &amp;#39;japanese&amp;#39; },
                { name: &amp;#39;  양식&amp;#39;, value: &amp;#39;western&amp;#39; }
            )
    );

export async function execute(interaction: ChatInputCommandInteraction) {
    const foodType = interaction.options.getString(&amp;#39;종류&amp;#39;);
    let recommendation = &amp;#39;&amp;#39;;

    switch (foodType) {
        case &amp;#39;korean&amp;#39;:
            recommendation = &amp;#39;오늘은 뜨끈한 국밥 어떠세요?&amp;#39;;
            break;
        case &amp;#39;chinese&amp;#39;:
            recommendation = &amp;#39;짜장면과 탕수육 조합은 진리죠!&amp;#39;;
            break;
        case &amp;#39;japanese&amp;#39;:
            recommendation = &amp;#39;신선한 초밥이나 라멘이 좋겠네요.&amp;#39;;
            break;
        case &amp;#39;western&amp;#39;:
            recommendation = &amp;#39;피자나 파스타로 든든하게 채워보세요!&amp;#39;;
            break;
        default:
            recommendation = &amp;#39;음... 뭘 먹어야 할까요?&amp;#39;;
    }

    await interaction.reply(\`오늘의 추천 메뉴: \\${recommendation}\`);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;addChoices()&lt;/code&gt;에는 &lt;code&gt;{ name: &amp;#39;사용자에게 보여질 이름&amp;#39;, value: &amp;#39;코드에서 사용할 값&amp;#39; }&lt;/code&gt; 형태의 객체 배열을 전달합니다. 사용자가 &amp;#39;  한식&amp;#39;을 선택하면, 코드에서는 &lt;code&gt;interaction.options.getString(&amp;#39;종류&amp;#39;)&lt;/code&gt;의 결과로 &lt;code&gt;&amp;#39;korean&amp;#39;&lt;/code&gt; 값을 받게 됩니다.&lt;/p&gt;
&lt;h2&gt;하나의 명령어, 여러 기능: 서브커맨드와 서브커맨드 그룹&lt;/h2&gt;
&lt;p&gt;명령어의 기능이 점점 복잡해지면, 관련된 여러 기능을 하나의 상위 명령어 아래에 묶고 싶을 때가 있습니다. 예를 들어, &lt;code&gt;/정보&lt;/code&gt;라는 명령어가 있고, 이 아래에 &lt;code&gt;유저 정보 보기&lt;/code&gt;, &lt;code&gt;서버 정보 보기&lt;/code&gt; 같은 하위 기능들을 두고 싶을 수 있죠. 이럴 때 사용하는 것이 &lt;strong&gt;서브커맨드(Subcommand)&lt;/strong&gt;와 &lt;strong&gt;서브커맨드 그룹(Subcommand Group)&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;서브커맨드&lt;/strong&gt;: &lt;code&gt;/명령어 subcommand&lt;/code&gt; 형태로 사용됩니다. (예: &lt;code&gt;/정보 유저&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;서브커맨드 그룹&lt;/strong&gt;: &lt;code&gt;/명령어 group subcommand&lt;/code&gt; 형태로, 서브커맨드를 한 단계 더 그룹화할 수 있습니다. (예: &lt;code&gt;/설정 메시지 알림&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;서브커맨드 만들기&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SlashCommandBuilder&lt;/code&gt;에서 &lt;code&gt;addSubcommand()&lt;/code&gt; 메서드를 사용하여 서브커맨드를 정의할 수 있습니다. 각 서브커맨드는 자신만의 이름, 설명, 그리고 옵션을 가질 수 있습니다.&lt;/p&gt;
&lt;p&gt;예제: &lt;code&gt;/정보&lt;/code&gt; 명령어에 &lt;code&gt;유저&lt;/code&gt;와 &lt;code&gt;서버&lt;/code&gt; 서브커맨드 만들기&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/정보.ts
import { SlashCommandBuilder, ChatInputCommandInteraction, GuildMember, Guild } from &amp;#39;discord.js&amp;#39;;

export const data = new SlashCommandBuilder()
    .setName(&amp;#39;정보&amp;#39;)
    .setDescription(&amp;#39;사용자 또는 서버의 정보를 보여줍니다.&amp;#39;)
    .addSubcommand(subcommand =&amp;gt;
        subcommand
            .setName(&amp;#39;유저&amp;#39;)
            .setDescription(&amp;#39;선택한 사용자의 정보를 보여줍니다.&amp;#39;)
            .addUserOption(option =&amp;gt; option.setName(&amp;#39;대상&amp;#39;).setDescription(&amp;#39;정보를 볼 사용자&amp;#39;).setRequired(true))
    )
    .addSubcommand(subcommand =&amp;gt;
        subcommand
            .setName(&amp;#39;서버&amp;#39;)
            .setDescription(&amp;#39;현재 서버의 정보를 보여줍니다.&amp;#39;)
    );

export async function execute(interaction: ChatInputCommandInteraction) {
    const subcommand = interaction.options.getSubcommand(); // 사용된 서브커맨드의 이름을 가져옵니다.

    if (subcommand === &amp;#39;유저&amp;#39;) {
        const user = interaction.options.getUser(&amp;#39;대상&amp;#39;);
        if (!user) return interaction.reply({ content: &amp;#39;사용자를 찾을 수 없습니다.&amp;#39;, ephemeral: true });

        // 서버 멤버 정보를 가져오기 위해 interaction.member를 사용하거나, fetchMember를 고려할 수 있습니다.
        const member = interaction.guild?.members.cache.get(user.id) || await interaction.guild?.members.fetch(user.id);

        await interaction.reply(
            `사용자 이름: \\${user.username}\\n` +
            `ID: \\${user.id}\\n` +
            (member instanceof GuildMember ? \`서버 합류일: \\${member.joinedAt?.toLocaleDateString()}\\n\` : &amp;#39;&amp;#39;) +
            `계정 생성일: \\${user.createdAt.toLocaleDateString()}`
        );
    } else if (subcommand === &amp;#39;서버&amp;#39;) {
        const guild = interaction.guild;
        if (!guild) return interaction.reply({ content: &amp;#39;서버 정보를 가져올 수 없습니다. DM에서는 사용할 수 없어요.&amp;#39;, ephemeral: true });

        await interaction.reply(
            `서버 이름: \\${guild.name}\\n` +
            `총 멤버 수: \\${guild.memberCount}\\n` +
            `서버 생성일: \\${guild.createdAt.toLocaleDateString()}\\n` +
            `서버 주인: \\${(await guild.fetchOwner()).user.tag}`
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;코드 설명:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.addSubcommand(subcommand =&amp;gt; ...)&lt;/code&gt;: 각 서브커맨드를 정의합니다. &lt;code&gt;유저&lt;/code&gt; 서브커맨드는 &lt;code&gt;대상&lt;/code&gt;이라는 사용자 옵션을 추가로 받습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;interaction.options.getSubcommand()&lt;/code&gt;: &lt;code&gt;execute&lt;/code&gt; 함수 내에서 어떤 서브커맨드가 사용되었는지 그 이름을 문자열로 가져옵니다.&lt;/li&gt;
&lt;li&gt;이후 &lt;code&gt;if/else if&lt;/code&gt; 문을 사용하여 각 서브커맨드에 맞는 로직을 실행합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;서브커맨드 그룹 만들기 (간단 소개)&lt;/h3&gt;
&lt;p&gt;서브커맨드 그룹은 &lt;code&gt;addSubcommandGroup()&lt;/code&gt; 메서드를 사용합니다. 그룹 안에 다시 &lt;code&gt;addSubcommand()&lt;/code&gt;를 사용하여 실제 서브커맨드들을 정의할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 예시: /config group subcommand
new SlashCommandBuilder()
  .setName(&amp;quot;config&amp;quot;)
  .setDescription(&amp;quot;봇 설정을 관리합니다.&amp;quot;)
  .addSubcommandGroup((group) =&amp;gt;
    group
      .setName(&amp;quot;prefix&amp;quot;)
      .setDescription(&amp;quot;접두사 관련 설정을 관리합니다.&amp;quot;)
      .addSubcommand((subcommand) =&amp;gt;
        subcommand
          .setName(&amp;quot;set&amp;quot;)
          .setDescription(&amp;quot;새로운 접두사를 설정합니다.&amp;quot;)
          .addStringOption((option) =&amp;gt;
            option
              .setName(&amp;quot;value&amp;quot;)
              .setDescription(&amp;quot;새 접두사&amp;quot;)
              .setRequired(true)
          )
      )
      .addSubcommand((subcommand) =&amp;gt;
        subcommand
          .setName(&amp;quot;view&amp;quot;)
          .setDescription(&amp;quot;현재 설정된 접두사를 봅니다.&amp;quot;)
      )
  );
// execute 함수에서는 interaction.options.getSubcommandGroup() 와 interaction.options.getSubcommand() 를 함께 사용합니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;서브커맨드 그룹은 명령어가 매우 복잡해질 때 유용하지만, 너무 많은 단계를 거치면 사용자 경험이 나빠질 수 있으니 신중하게 사용하는 것이 좋습니다.&lt;/p&gt;
&lt;h2&gt;명령어 배포의 중요성: &lt;code&gt;deploy-commands.ts&lt;/code&gt; 다시 보기&lt;/h2&gt;
&lt;p&gt;지난 시간에 잠깐 언급했듯이, 슬래시 명령어(옵션이나 서브커맨드를 포함하여)를 만들거나 수정했다면, 변경된 내용을 디스코드에 알려주어야 합니다. 이 역할을 하는 것이 &lt;code&gt;src/deploy-commands.ts&lt;/code&gt; 스크립트입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;에서는 봇이 시작될 때(&lt;code&gt;Events.ClientReady&lt;/code&gt;) 연결된 모든 서버에 대해 자동으로 명령어를 배포(갱신)합니다. 이는 개발 중에는 매우 편리합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;deploy-commands.ts&lt;/code&gt;는 &lt;code&gt;src/commands/index.ts&lt;/code&gt;에서 가져온 모든 명령어들의 &lt;code&gt;data&lt;/code&gt; 속성 (즉, &lt;code&gt;SlashCommandBuilder&lt;/code&gt;로 정의된 명령어 구조)을 &lt;code&gt;JSON&lt;/code&gt; 형태로 변환하여 디스코드 API로 전송합니다. 디스코드는 이 정보를 바탕으로 사용자 인터페이스에 슬래시 명령어를 표시하고, 옵션 입력 필드를 제공하며, 서브커맨드 선택지를 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;명령어 업데이트 시 주의사항:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;명령어의 이름, 설명, 옵션, 서브커맨드 구조 등을 변경했다면, 봇을 재시작하여 &lt;code&gt;deploy-commands&lt;/code&gt; 로직이 다시 실행되도록 해야 합니다.&lt;/li&gt;
&lt;li&gt;때로는 디스코드 클라이언트 자체에 명령어 정보가 캐시되어 즉시 반영되지 않는 것처럼 보일 수 있습니다. 이 경우, 몇 분 정도 기다리거나, 디스코드 클라이언트를 완전히 재시작(Ctrl/Cmd + R)해보는 것이 좋습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;글로벌 명령어 vs 서버(Guild) 명령어&lt;/strong&gt;: &lt;code&gt;deploy-commands.ts&lt;/code&gt;에서 &lt;code&gt;Routes.applicationGuildCommands(clientId, guildId)&lt;/code&gt;를 사용하면 특정 서버에만 명령어를 등록합니다. 개발 및 테스트 시에는 이 방식이 빠르고 편리합니다. 모든 서버에 명령어를 등록하려면 &lt;code&gt;Routes.applicationCommands(clientId)&lt;/code&gt;를 사용하며, 이를 &amp;#39;글로벌 명령어&amp;#39;라고 합니다. 글로벌 명령어는 모든 서버에 적용되지만, 전파되는 데 최대 1시간까지 걸릴 수 있습니다. Boilerplate는 각 서버에 개별적으로 등록하는 방식을 사용하고 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;이번 시간에는 슬래시 명령어에 옵션을 추가하여 사용자로부터 다양한 입력을 받고, 서브커맨드를 사용하여 관련된 기능들을 하나의 명령어로 묶는 방법을 배웠습니다. 이를 통해 훨씬 더 유연하고 강력한 명령어를 설계할 수 있게 되었습니다.&lt;/p&gt;
&lt;p&gt;다음 시간에는 명령어 사용에 제한을 두는 &amp;#39;쿨타임&amp;#39; 기능과, 명령어 실행 중 발생할 수 있는 다양한 &amp;#39;오류&amp;#39;들을 어떻게 효과적으로 처리하고 사용자에게 피드백을 줄 수 있는지에 대해 알아보겠습니다. 안정적인 봇 운영을 위한 필수적인 내용이니 기대해주세요!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/31</guid>
      <comments>https://dishost.tistory.com/31#entry31comment</comments>
      <pubDate>Wed, 4 Jun 2025 17:11:18 +0900</pubDate>
    </item>
    <item>
      <title>Discord.py로 디스코드 음악 봇 만들기: 디스호스트로 24시간 호스팅까지!</title>
      <link>https://dishost.tistory.com/43</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 혹시 나만의 디스코드 음악 봇을 갖고 싶다는 생각, 한 번쯤 해보셨나요? 이 가이드를 통해 Python과 &lt;code&gt;discord.py&lt;/code&gt; 라이브러리를 활용해 강력한 음악 봇을 뚝딱 만들어낼 수 있습니다. 특히 &lt;code&gt;yt-dlp&lt;/code&gt; 라이브러리와 YouTube 쿠키를 사용해서 완성된 봇을 디스호스트 플랫폼에 안정적으로 호스팅하는 방법까지 자세히 설명드릴게요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;음악 봇을 직접 만들고, 호스팅하면 다음과 같은 이점이 있어요!&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;끊김 없는 재생&lt;/b&gt;: 봇이 차단되거나 사라질 걱정 없이, 안정적으로 음악봇을 이용할 수 있어요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;두 채널 이상 동시 재생&lt;/b&gt;: 음악봇을 여러 개 호스팅하여, 두 개 채널 이상에서 동시에 음악을 재생할 수 있어요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확장성&lt;/b&gt;: 기본 음악 기능 외에도 다양한 명령어를 추가하여 봇을 확장할 수 있어요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제공되는 &lt;code&gt;bot.py&lt;/code&gt; 예제 코드를 중심으로 차근차근 설명해 드릴 테니, 코딩이 처음이시거나 Python이 익숙하지 않으셔도 너무 걱정하지 마세요. 이 코드를 잘 활용하면 디스코드 서버에서 음악을 스트리밍하고, 다양한 명령어를 통해 사용자와 소통하는 멋진 봇을 만들 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;필수 준비물&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본 가이드를 진행하기 위해 다음의 준비물이 필요합니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Python 3.8 이상 버전&lt;/b&gt;: Python 공식 웹사이트에서 설치할 수 있습니다. Discord.py는 Python 3.8 이상에서만 작동하기 때문에, 이 버전 이상이 필요합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Discord 계정 및 서버&lt;/b&gt;: 봇을 테스트하고 운영할 Discord 계정과 개인 서버가 필요합니다. 서버는 직접 생성하거나 관리자 권한이 있는 기존 서버를 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;텍스트 편집기 또는 IDE&lt;/b&gt;: 코드를 작성하고 수정하기 위한 도구입니다. Visual Studio Code를 추천드립니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;yt-dlp 및 FFmpeg&lt;/b&gt;: &lt;code&gt;yt-dlp&lt;/code&gt;는 YouTube 및 기타 사이트에서 오디오/비디오 정보를 추출하는 데 사용됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;YouTube 쿠키 파일 (&lt;code&gt;cookies.txt&lt;/code&gt;)&lt;/b&gt;: 원격 서버에서 Youtube에 접속하기 위해 필요합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;디스호스트 계정&lt;/b&gt;: 봇을 24시간 안정적으로 호스팅하기 위해 필요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Discord 봇 생성 및 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Discord 봇을 운영하기 위해서는 먼저 Discord 개발자 포털에서 애플리케이션을 생성하고 봇 사용자를 설정해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 글을 통해 Discord 봇을 생성하는 방법을 자세히 알아보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.dishost.kr/15&quot;&gt;Discord 봇 생성하기&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;쿠키 추출 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YouTube와 같은 사이트에서 로그인 필요한 콘텐츠나 연령 제한이 있는 콘텐츠를 재생하기 위해서는 쿠키가 필요합니다. 파이어폭스 브라우저와 &lt;code&gt;cookies.txt&lt;/code&gt; 확장 프로그램을 사용하여 쿠키를 추출할 수 있습니다. (크롬 브라우저에서는 쿠키 추출이 잘 되지 않는 것을 확인했습니다.)&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;파이어폭스 브라우저를 열고, &lt;a href=&quot;https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/&quot;&gt;cookies.txt&lt;/a&gt; 확장 프로그램을 설치합니다.&lt;/li&gt;
&lt;li&gt;YouTube에 로그인합니다. (로그인 상태여야 합니다.)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.youtube.com/robot.txt&quot;&gt;https://www.youtube.com/robot.txt&lt;/a&gt; 페이지로 이동합니다.&lt;/li&gt;
&lt;li&gt;확장 프로그램 아이콘을 클릭하고 &quot;Current container&quot; 버튼을 눌러 &lt;code&gt;cookies.txt&lt;/code&gt; 파일을 다운로드합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 추출한 쿠키를 사용할 준비가 되었습니다. 이 쿠키는 나중에 봇 코드를 작성할 때 필요합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;봇 코드 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;code&gt;bot.py&lt;/code&gt; 파일의 전체 코드를 단계별로 살펴보겠습니다. 이 설명을 따라하면 여러분도 직접 음악 봇 코드를 작성할 수 있을 거예요. 걱정 마세요! 코드가 길어 보일 수 있지만, 각 부분이 어떤 역할을 하는지 이해하면 금방 익숙해질 겁니다. 전체 코드는 맨 마지막에 첨부되어 있으니, 안심하시고 천천히 따라서 작성하세요!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 필요한 라이브러리 가져오기 (Imports)&lt;/h3&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;# filepath: /Users/kochanhyun/Documents/GitHub/discordjs_tutorial/bot.py
import discord
from discord import app_commands
from discord.ext import commands
import os
import asyncio
import yt_dlp
import logging
from dotenv import load_dotenv
from typing import Dict, List, Optional, Any, Union&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드의 시작은 언제나 필요한 도구들을 챙기는 것과 같아요. 각 라이브러리가 하는 일은 다음과 같습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;discord&lt;/code&gt;, &lt;code&gt;discord.app_commands&lt;/code&gt;, &lt;code&gt;discord.ext.commands&lt;/code&gt;: &lt;code&gt;discord.py&lt;/code&gt; 라이브러리의 핵심 부분들입니다. 봇을 만들고, 슬래시 명령어를 처리하고, 봇의 기능을 확장하는 데 사용돼요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;os&lt;/code&gt;: 운영체제와 상호작용할 때 필요합니다. 주로 환경 변수에서 봇 토큰 같은 설정을 읽어올 때 사용해요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;asyncio&lt;/code&gt;: 비동기 프로그래밍을 위한 라이브러리입니다. 여러 작업을 동시에 처리해야 하는 봇에게 필수적이죠. 예를 들어, 음악을 재생하면서 다른 명령어도 받아야 하니까요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yt_dlp&lt;/code&gt;: YouTube를 포함한 다양한 웹사이트에서 오디오/비디오 정보를 가져오고 스트리밍 URL을 추출하는 데 사용됩니다. 이 봇의 핵심 기능인 음악 재생을 담당해요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;logging&lt;/code&gt;: 봇이 작동하면서 어떤 일이 일어나는지 기록(로그)을 남기는 데 사용됩니다. 문제가 생겼을 때 원인을 찾거나, 봇의 상태를 확인할 때 유용해요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dotenv&lt;/code&gt;: &lt;code&gt;.env&lt;/code&gt; 파일에서 환경 변수를 불러오는 데 사용됩니다. 봇 토큰처럼 민감한 정보를 코드에 직접 적는 대신, 별도의 파일에 안전하게 보관할 수 있게 해줘요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;typing&lt;/code&gt;: 타입 힌트를 제공하여 코드의 가독성을 높이고, 개발 중에 발생할 수 있는 오류를 줄이는 데 도움을 줍니다. &lt;code&gt;Dict&lt;/code&gt;, &lt;code&gt;List&lt;/code&gt; 등이 그 예시입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 로깅 및 환경 변수 설정&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('디스호스트_musicbot')

# .env 파일 로드 (Discord 토큰 등 환경변수)
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN')&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;로깅 설정&lt;/b&gt;: &lt;code&gt;logging.basicConfig(...)&lt;/code&gt;는 로그 메시지의 형식과 레벨을 설정합니다. &lt;code&gt;INFO&lt;/code&gt; 레벨 이상의 모든 로그가 지정된 형식으로 출력될 거예요. &lt;code&gt;logger = logging.getLogger(...)&lt;/code&gt;는 이 봇을 위한 전용 로거를 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;.env 파일 로드&lt;/b&gt;: &lt;code&gt;load_dotenv()&lt;/code&gt; 함수는 프로젝트 루트 디렉터리에 있는 &lt;code&gt;.env&lt;/code&gt; 파일을 찾아 그 안의 변수들을 환경 변수로 로드합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TOKEN = os.getenv('DISCORD_TOKEN')&lt;/code&gt;: 로드된 환경 변수 중에서 &lt;code&gt;DISCORD_TOKEN&lt;/code&gt;이라는 이름의 값을 가져와 &lt;code&gt;TOKEN&lt;/code&gt; 변수에 저장합니다. 이 토큰이 바로 여러분의 봇을 Discord 서버에 연결해주는 열쇠입니다!&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 봇 기본 설정 (Intents 및 Bot 객체 생성)&lt;/h3&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# 봇 설정
intents = discord.Intents.default()
intents.message_content = True  # 메시지 내용 읽기 권한
intents.voice_states = True     # 음성 상태 추적 권한
# 기본 help 명령어 비활성화
bot = commands.Bot(command_prefix='/', intents=intents, help_command=None)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;intents&lt;/code&gt;: 봇이 Discord로부터 어떤 종류의 이벤트 알림을 받을지 정하는 '의도'입니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;discord.Intents.default()&lt;/code&gt;: 기본적인 인텐트들을 가져옵니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;intents.message_content = True&lt;/code&gt;: 봇이 메시지 내용을 읽을 수 있도록 허용합니다. (예전에는 기본이었지만, 이제는 명시적으로 켜줘야 해요!)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;intents.voice_states = True&lt;/code&gt;: 사용자가 음성 채널에 들어오거나 나가는 등의 음성 상태 변경을 감지할 수 있게 합니다. 음악 봇에게는 필수죠!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bot = commands.Bot(...)&lt;/code&gt;: 실제 봇 객체를 생성합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;command_prefix='/'&lt;/code&gt;: 명령어 앞에 붙는 접두사를 설정합니다. 여기서는 슬래시 명령어(&lt;code&gt;/play&lt;/code&gt; 등)를 주로 사용하므로, 텍스트 기반 명령어 접두사도 &lt;code&gt;/&lt;/code&gt;로 설정했지만, 슬래시 명령어 시스템에서는 이 &lt;code&gt;command_prefix&lt;/code&gt;가 직접적으로 사용되지는 않습니다. (하단에 텍스트 명령어 호환성 부분에서 사용됩니다.)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;intents=intents&lt;/code&gt;: 위에서 설정한 인텐트를 봇에 적용합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;help_command=None&lt;/code&gt;: &lt;code&gt;discord.py&lt;/code&gt;가 기본으로 제공하는 &lt;code&gt;help&lt;/code&gt; 명령어를 비활성화합니다. 우리는 직접 커스텀 &lt;code&gt;/help&lt;/code&gt; 명령어를 만들 거예요.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. YT-DLP 및 FFmpeg 옵션 설정&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# YT-DLP 설정 (쿠키 기반)
ytdlp_format_options = {
    'format': 'bestaudio[ext=webm]/bestaudio/best[ext=webm]/best',
    'no_playlist': True,
    'noplaylist': True,
    'quiet': True,
    'no_warnings': True,
    'default_search': 'auto',
    'source_address': '0.0.0.0', # IPv4 강제 또는 특정 IP 사용 시
    'geo_bypass': True,
    'extractor_retries': 3,
    'nocheckcertificate': True,
    'age_limit': 99,
    'extract_flat': False,
    'ignoreerrors': True,
    'cookiefile': 'cookies.txt',  # 쿠키 파일 사용
    'http_headers': { # 필요시 User-Agent 등 헤더 설정
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'en-us,en;q=0.5',
        'Accept-Encoding': 'gzip,deflate',
        'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
        'Keep-Alive': '300',
        'Connection': 'keep-alive',
    },
}

ffmpeg_options = {
    'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5',
    'options': '-vn -filter:a &quot;volume=0.5&quot;' # 오디오 볼륨 50%로 설정
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 음악 재생의 핵심 설정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;ytdlp_format_options&lt;/code&gt;: &lt;code&gt;yt-dlp&lt;/code&gt;가 YouTube (또는 다른 사이트)에서 정보를 가져올 때 사용할 옵션들입니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;'format': 'bestaudio[ext=webm]/bestaudio/best[ext=webm]/best'&lt;/code&gt;: 오디오 품질을 최상으로 설정합니다. webm 형식을 우선으로 하되, 없으면 다른 최상 품질 오디오를 선택합니다. 기본적으로 유튜브 영상은 webm 형식으로 제공되므로, webm 형식을 사용하면 ffmpeg로 변환할 필요가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'no_playlist': True, 'noplaylist': True&lt;/code&gt;: 재생목록 URL을 주더라도 단일 영상만 처리하도록 합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'quiet': True, 'no_warnings': True&lt;/code&gt;: &lt;code&gt;yt-dlp&lt;/code&gt;가 터미널에 내보내는 메시지를 최소화합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'default_search': 'auto'&lt;/code&gt;: 검색어를 입력하면 자동으로 YouTube에서 검색합니다 (&lt;code&gt;ytsearch:&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'source_address': '0.0.0.0'&lt;/code&gt;: 특정 IP 주소를 사용하도록 강제할 수 있습니다. (네트워크 환경에 따라 필요할 수 있음)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'geo_bypass': True&lt;/code&gt;: 지역 제한을 우회하려고 시도합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'cookiefile': 'cookies.txt'&lt;/code&gt;: &lt;b&gt;매우 중요!&lt;/b&gt; 이 부분이 바로 쿠키 파일을 사용하도록 설정하는 곳입니다. 프로젝트 루트 디렉터리에 &lt;code&gt;cookies.txt&lt;/code&gt;라는 이름으로 저장된 쿠키 파일을 &lt;code&gt;yt-dlp&lt;/code&gt;가 사용하게 됩니다. 이렇게 하면 연령 제한 콘텐츠나 로그인 필요한 콘텐츠에 접근할 가능성이 높아집니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'http_headers'&lt;/code&gt;: 요청 시 사용할 HTTP 헤더입니다. 때때로 특정 User-Agent가 필요할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ffmpeg_options&lt;/code&gt;: FFmpeg는 오디오/비디오를 처리하는 강력한 도구입니다. &lt;code&gt;discord.py&lt;/code&gt;는 내부적으로 FFmpeg를 사용하여 오디오를 Discord 음성 채널로 스트리밍합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5'&lt;/code&gt;: 네트워크 연결이 불안정할 때 스트림 재연결을 시도하는 옵션입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;'options': '-vn -filter:a &quot;volume=0.5&quot;'&lt;/code&gt;: &lt;code&gt;-vn&lt;/code&gt;은 비디오를 사용하지 않음(오디오만)을 의미하고, &lt;code&gt;-filter:a &quot;volume=0.5&quot;&lt;/code&gt;는 오디오 볼륨을 50%로 줄입니다. (기본 볼륨이 너무 클 수 있어서 조절)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 전역 변수 (Global Variables)&lt;/h3&gt;
&lt;pre class=&quot;julia&quot;&gt;&lt;code&gt;# 전역 변수들
music_queues: Dict[int, List[Dict[str, Any]]] = {}
voice_clients: Dict[int, discord.VoiceClient] = {}
current_songs: Dict[int, Dict[str, Any]] = {}
loop_modes: Dict[int, int] = {}  # 0: 반복없음, 1: 현재곡 반복, 2: 큐 반복&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇이 여러 서버(길드)에서 동시에 작동할 수 있도록, 각 서버별로 음악 큐, 음성 연결 상태, 현재 재생 곡, 반복 모드 등을 저장해야 합니다. 이 딕셔너리들이 그 역할을 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;music_queues&lt;/code&gt;: 각 서버(길드 ID가 key)의 음악 대기열(재생할 노래 목록)을 저장합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Dict[int, List[Dict[str, Any]]]&lt;/code&gt;는 &quot;길드 ID(정수)를 키로 하고, 노래 정보 딕셔너리들의 리스트를 값으로 하는 딕셔너리&quot;라는 뜻입니다.&lt;/li&gt;
&lt;li&gt;노래 정보 딕셔너리에는 &lt;code&gt;'title'&lt;/code&gt;, &lt;code&gt;'url'&lt;/code&gt;, &lt;code&gt;'duration'&lt;/code&gt; 등이 들어갑니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;voice_clients&lt;/code&gt;: 각 서버의 음성 연결 객체(&lt;code&gt;discord.VoiceClient&lt;/code&gt;)를 저장합니다. 봇이 어떤 음성 채널에 연결되어 있는지 관리합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;current_songs&lt;/code&gt;: 각 서버에서 현재 재생 중인 노래 정보를 저장합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loop_modes&lt;/code&gt;: 각 서버의 반복 모드를 저장합니다. (0: 반복 없음, 1: 현재 곡 반복, 2: 큐 전체 반복)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. UI 버튼을 위한 &lt;code&gt;MusicView&lt;/code&gt; 클래스&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;class MusicView(discord.ui.View):
    def __init__(self, guild_id: int):
        super().__init__(timeout=None) # timeout=None으로 버튼 영구 활성화
        self.guild_id = guild_id

    @discord.ui.button(label='⏸️ 일시정지', style=discord.ButtonStyle.secondary)
    async def pause_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer() # 응답 대기
        if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
            voice_clients[self.guild_id].pause()
            await interaction.followup.send(&quot;⏸️ 음악이 일시정지되었습니다.&quot;, ephemeral=True)
        else:
            await interaction.followup.send(&quot;❌ 재생 중인 음악이 없습니다.&quot;, ephemeral=True)

    @discord.ui.button(label='▶️ 재생', style=discord.ButtonStyle.secondary)
    async def resume_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer()
        if self.guild_id in voice_clients and voice_clients[self.guild_id].is_paused():
            voice_clients[self.guild_id].resume()
            await interaction.followup.send(&quot;▶️ 음악이 재개되었습니다.&quot;, ephemeral=True)
        else:
            await interaction.followup.send(&quot;❌ 일시정지된 음악이 없습니다.&quot;, ephemeral=True)

    @discord.ui.button(label='⏭️ 스킵', style=discord.ButtonStyle.secondary)
    async def skip_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer()
        if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
            voice_clients[self.guild_id].stop() # stop()을 호출하면 after 콜백(play_next)이 실행됨
            await interaction.followup.send(&quot;⏭️ 다음 곡으로 스킵합니다.&quot;, ephemeral=True)
        else:
            await interaction.followup.send(&quot;❌ 재생 중인 음악이 없습니다.&quot;, ephemeral=True)

    @discord.ui.button(label='⏹️ 정지', style=discord.ButtonStyle.danger)
    async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer()
        if self.guild_id in music_queues:
            music_queues[self.guild_id].clear()
        if self.guild_id in voice_clients:
            voice_clients[self.guild_id].stop()
        if self.guild_id in current_songs:
            del current_songs[self.guild_id]
        await interaction.followup.send(&quot;⏹️ 음악이 정지되고 큐가 초기화되었습니다.&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Discord의 버튼 UI를 만들기 위한 클래스입니다. &lt;code&gt;discord.ui.View&lt;/code&gt;를 상속받아 만들어요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;__init__(self, guild_id: int)&lt;/code&gt;: 뷰가 생성될 때 어떤 서버에 속한 뷰인지 &lt;code&gt;guild_id&lt;/code&gt;를 저장합니다. &lt;code&gt;super().__init__(timeout=None)&lt;/code&gt;은 버튼이 특정 시간 후에 비활성화되지 않고 계속 작동하도록 합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@discord.ui.button(...)&lt;/code&gt;: 각 버튼을 정의하는 데코레이터입니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;label&lt;/code&gt;: 버튼에 표시될 텍스트 (이모티콘 포함 가능)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;style&lt;/code&gt;: 버튼의 색상과 모양 (예: &lt;code&gt;discord.ButtonStyle.secondary&lt;/code&gt;는 회색, &lt;code&gt;danger&lt;/code&gt;는 빨간색)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;각 버튼 함수 (&lt;code&gt;pause_button&lt;/code&gt;, &lt;code&gt;resume_button&lt;/code&gt; 등):
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;interaction: discord.Interaction&lt;/code&gt;: 사용자가 버튼을 눌렀을 때 발생하는 상호작용 객체입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await interaction.response.defer()&lt;/code&gt;: &quot;일단 알겠어, 처리 중이니 잠시만 기다려줘!&quot; 라는 의미입니다. 바로 응답하지 않으면 상호작용이 실패 처리될 수 있어서 필요해요.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ephemeral=True&lt;/code&gt;: 이 옵션을 사용하면 버튼 클릭 결과 메시지가 버튼을 누른 사용자에게만 보이게 됩니다. 채팅창을 깔끔하게 유지하는 데 도움이 되죠.&lt;/li&gt;
&lt;li&gt;나머지 로직은 각 버튼의 기능(일시정지, 재개, 스킵, 정지)을 수행합니다. 전역 변수인 &lt;code&gt;voice_clients&lt;/code&gt;, &lt;code&gt;music_queues&lt;/code&gt; 등을 사용하여 해당 서버의 상태를 변경합니다.&lt;/li&gt;
&lt;li&gt;스킵 버튼의 경우 &lt;code&gt;voice_clients[self.guild_id].stop()&lt;/code&gt;을 호출하면, &lt;code&gt;play_song&lt;/code&gt; 함수에서 &lt;code&gt;after&lt;/code&gt; 콜백으로 등록된 &lt;code&gt;play_next&lt;/code&gt; 함수가 자동으로 실행되어 다음 곡을 재생하게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7. 헬퍼 함수 (Helper Functions)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자주 사용되는 기능들을 별도의 함수로 만들어두면 코드가 깔끔해지고 재사용하기 좋습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;get_queue_embed(guild_id: int)&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def get_queue_embed(guild_id: int) -&amp;gt; discord.Embed:
    &quot;&quot;&quot;현재 큐 상태를 임베드로 생성&quot;&quot;&quot;
    embed = discord.Embed(title=&quot;  음악 큐&quot;, color=0x3498db)

    if guild_id in current_songs:
        current = current_songs[guild_id]
        embed.add_field(
            name=&quot;  현재 재생 중&quot;,
            value=f&quot;**{current['title']}**\n⏱️ {current.get('duration', 'Unknown')}\n  {current.get('uploader', 'Unknown')}&quot;,
            inline=False
        )

    if guild_id in music_queues and music_queues[guild_id]:
        queue_text = &quot;&quot;
        for i, song in enumerate(music_queues[guild_id][:5]):  # 최대 5개만 표시
            queue_text += f&quot;{i+1}. **{song['title']}** ({song.get('duration', 'Unknown')})\n&quot;

        if len(music_queues[guild_id]) &amp;gt; 5:
            queue_text += f&quot;... 그리고 {len(music_queues[guild_id]) - 5}곡 더&quot;

        embed.add_field(name=&quot;  대기열&quot;, value=queue_text, inline=False)
    else:
        embed.add_field(name=&quot;  대기열&quot;, value=&quot;비어있음&quot;, inline=False)

    # 반복 모드 표시
    loop_mode = loop_modes.get(guild_id, 0) # guild_id가 없으면 기본값 0 (반복 없음)
    loop_text = [&quot;  반복 없음&quot;, &quot;  현재 곡 반복&quot;, &quot;  큐 반복&quot;][loop_mode]
    embed.add_field(name=&quot;  반복 모드&quot;, value=loop_text, inline=True)

    embed.set_footer(text=&quot;YT-DLP (쿠키) &amp;bull; 디스호스트 음악봇 v2.0&quot;)
    return embed&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 음악 큐 상태를 보기 좋게 Discord Embed 메시지로 만들어 반환합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;discord.Embed&lt;/code&gt;: Discord에서 사용하는 특별한 형식의 메시지입니다. 제목, 설명, 필드, 색상, 푸터 등을 설정할 수 있어요.&lt;/li&gt;
&lt;li&gt;현재 재생 중인 곡, 대기열 (최대 5곡 표시), 반복 모드 정보를 담아서 보여줍니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.get('duration', 'Unknown')&lt;/code&gt;: 딕셔너리에서 값을 가져올 때, 해당 키가 없으면 기본값('Unknown')을 사용하도록 합니다. 프로그램이 오류로 멈추는 것을 방지해줘요.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;search_youtube(query: str)&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;async def search_youtube(query: str) -&amp;gt; Optional[Dict[str, Any]]:
    &quot;&quot;&quot;YouTube에서 검색 (쿠키 기반)&quot;&quot;&quot;
    try:
        with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
            # URL이면 직접 처리, 검색어면 ytsearch: 접두사 사용
            if query.startswith(('http://', 'https://')):
                search_query = query
            else:
                search_query = f&quot;ytsearch:{query}&quot; # ytsearch: 뒤에 검색어를 붙이면 유튜브에서 검색

            info = ydl.extract_info(search_query, download=False) # download=False는 실제 다운로드는 안 함

            if not info:
                return None

            # 검색 결과에서 첫 번째 항목 가져오기
            if 'entries' in info and info['entries']: # 재생목록 검색 결과 처리
                video_info = info['entries'][0]
            else: # 단일 영상 결과 처리
                video_info = info

            return {
                'title': video_info.get('title', 'Unknown Title'),
                'url': video_info.get('webpage_url', video_info.get('url', '')), # 웹페이지 URL 우선
                'duration': str(video_info.get('duration_string', video_info.get('duration', 'Unknown'))), # 초 단위보다는 문자열 형식 우선
                'uploader': video_info.get('uploader', 'Unknown'),
                'thumbnail': video_info.get('thumbnail', ''),
                'view_count': video_info.get('view_count', 0)
            }

    except Exception as e:
        logger.error(f&quot;YouTube 검색 중 오류: {e}&quot;)
        return None&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 입력한 검색어(또는 URL)를 바탕으로 &lt;code&gt;yt-dlp&lt;/code&gt;를 사용해 YouTube에서 노래 정보를 검색합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;query.startswith(('http://', 'https://'))&lt;/code&gt;: 입력이 URL인지 단순 검색어인지 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;search_query = f&quot;ytsearch:{query}&quot;&lt;/code&gt;: 단순 검색어면 &lt;code&gt;ytsearch:&lt;/code&gt; 접두사를 붙여 &lt;code&gt;yt-dlp&lt;/code&gt;가 YouTube에서 검색하도록 합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ydl.extract_info(search_query, download=False)&lt;/code&gt;: 실제 오디오를 다운로드하지 않고, 메타데이터(제목, URL, 길이 등)만 가져옵니다.&lt;/li&gt;
&lt;li&gt;결과에서 필요한 정보(제목, 원본 URL, 길이, 업로더, 썸네일 등)를 추출하여 딕셔너리 형태로 반환합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;duration_string&lt;/code&gt;이 있으면 그걸 쓰고, 없으면 &lt;code&gt;duration&lt;/code&gt;(초 단위 숫자)을 문자열로 바꿔 씁니다. &lt;code&gt;duration_string&lt;/code&gt;이 &quot;3:15&quot;처럼 보기 좋은 형태이기 때문입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;play_song(voice_client: discord.VoiceClient, song_info: Dict[str, Any], guild_id: int)&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def play_song(voice_client: discord.VoiceClient, song_info: Dict[str, Any], guild_id: int):
    &quot;&quot;&quot;YT-DLP를 사용하여 음악 재생 (쿠키 기반)&quot;&quot;&quot;
    try:
        # YT-DLP로 스트림 URL 추출 (한 번 더 호출하여 최신 스트림 URL 확보)
        with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
            # song_info['url']은 webpage_url이므로, 이걸로 다시 extract_info를 호출해야 스트리밍 URL을 얻음
            info = ydl.extract_info(song_info['url'], download=False)
            if not info:
                logger.error(&quot;YT-DLP에서 정보를 가져올 수 없습니다 (play_song)&quot;)
                # 다음 곡 시도 또는 오류 메시지
                await play_next(guild_id)
                return False

            # 포맷 중에서 실제 오디오 스트림 URL 찾기
            # 때로는 info['url']에 바로 있기도 하고, formats 안에 있기도 함
            stream_url = None
            if 'url' in info: # 직접 URL이 있는 경우
                 stream_url = info['url']
            elif 'formats' in info: # formats 리스트에서 찾아야 하는 경우
                for f in reversed(info['formats']): # 고화질 오디오가 뒤에 있는 경우가 많음
                    if f.get('acodec') != 'none' and f.get('vcodec') == 'none': # 오디오 코덱 있고 비디오 코덱 없는 것
                        if f.get('url'):
                            stream_url = f['url']
                            break
                if not stream_url: # 못찾았으면 그냥 첫번째 format의 url (최후의 수단)
                    stream_url = info['formats'][0]['url'] if info['formats'] else None

            if not stream_url:
                logger.error(&quot;스트림 URL을 찾을 수 없습니다 (play_song)&quot;)
                await play_next(guild_id)
                return False

        # 음악 재생
        source = discord.FFmpegPCMAudio(stream_url, **ffmpeg_options)
        voice_client.play(source, after=lambda e: asyncio.run_coroutine_threadsafe(
            play_next(guild_id), bot.loop).result() if not e else logger.error(f'Player error: {e}'))

        current_songs[guild_id] = song_info # 현재 재생 곡 정보 업데이트
        logger.info(f&quot;재생 시작: {song_info['title']} (서버: {guild_id})&quot;)
        return True

    except Exception as e:
        logger.error(f&quot;음악 재생 중 오류 (play_song): {e} - 곡: {song_info.get('title', '알 수 없음')}&quot;)
        # 오류 발생 시 다음 곡 재생 시도
        await play_next(guild_id)
        return False&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 노래를 재생하는 함수입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;search_youtube&lt;/code&gt;에서 얻은 &lt;code&gt;song_info&lt;/code&gt; (특히 &lt;code&gt;song_info['url']&lt;/code&gt;은 웹페이지 URL)를 사용해 &lt;code&gt;yt-dlp&lt;/code&gt;로 다시 한번 &lt;code&gt;extract_info&lt;/code&gt;를 호출합니다. 이번에는 실제 오디오 스트림 URL을 얻기 위함입니다. YouTube의 스트림 URL은 시간이 지나면 만료될 수 있어서, 재생 직전에 다시 얻는 것이 안정적입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;info&lt;/code&gt; 딕셔너리에서 실제 스트리밍 가능한 URL (&lt;code&gt;stream_url&lt;/code&gt;)을 찾습니다. 때로는 &lt;code&gt;info['url']&lt;/code&gt;에 바로 있기도 하고, &lt;code&gt;info['formats']&lt;/code&gt; 리스트 안에서 적절한 오디오 포맷을 찾아야 할 수도 있습니다. 여기서는 오디오 코덱이 있고 비디오 코덱이 없는 포맷을 우선적으로 찾습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;discord.FFmpegPCMAudio(stream_url, **ffmpeg_options)&lt;/code&gt;: 찾은 스트림 URL과 FFmpeg 옵션을 사용해 오디오 소스를 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;voice_client.play(source, after=lambda e: ...)&lt;/code&gt;: 음성 클라이언트에서 오디오 소스를 재생합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;after&lt;/code&gt;: 이 부분이 중요합니다! 노래 재생이 끝나거나, &lt;code&gt;voice_client.stop()&lt;/code&gt;으로 중지되면 &lt;code&gt;after&lt;/code&gt;에 지정된 함수가 호출됩니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lambda e: asyncio.run_coroutine_threadsafe(play_next(guild_id), bot.loop).result() if not e else logger.error(f'Player error: {e}')&lt;/code&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;재생 중 오류(&lt;code&gt;e&lt;/code&gt;)가 없었다면, &lt;code&gt;play_next(guild_id)&lt;/code&gt; 함수를 비동기적으로 안전하게 실행하여 다음 곡을 재생합니다.&lt;/li&gt;
&lt;li&gt;오류가 있었다면 로그를 남깁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;current_songs[guild_id] = song_info&lt;/code&gt;: 현재 재생 중인 곡 정보를 업데이트합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;play_next(guild_id: int)&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def play_next(guild_id: int):
    &quot;&quot;&quot;다음 곡 재생 또는 큐/현재곡 반복 처리&quot;&quot;&quot;
    try:
        if guild_id not in voice_clients or not voice_clients[guild_id].is_connected():
            logger.info(f&quot;play_next 호출: {guild_id} 음성 클라이언트 없음 또는 연결 끊김. 정리 시도.&quot;)
            if guild_id in current_songs: del current_songs[guild_id]
            if guild_id in music_queues: music_queues[guild_id].clear()
            return

        voice_client = voice_clients[guild_id]

        # 현재 곡 정보 초기화 (재생이 끝났으므로)
        # 단, 현재곡 반복 모드가 아닐 때만. 현재곡 반복이면 play_song에서 다시 설정됨.
        loop_mode = loop_modes.get(guild_id, 0)
        if loop_mode != 1 and guild_id in current_songs: # 현재곡 반복이 아니면 현재곡 정보 삭제
             del current_songs[guild_id]

        if loop_mode == 1:  # 현재 곡 반복
            # current_songs에 이전 곡 정보가 남아있어야 함. play_song에서 다시 설정될 것임.
            # 하지만 play_song을 호출하기 전까지 current_songs[guild_id]가 남아있도록 보장해야 함.
            # -&amp;gt; current_songs는 play_song이 시작 시점에 설정되므로, 여기서 다시 가져올 필요가 없음.
            #    하지만 current_songs가 이미 위에서 삭제되었을 수 있으므로,
            #    current_songs는 play_song이 성공적으로 실행될 때만 업데이트하는 것이 좋음.
            # 따라서, 반복 재생할 곡 정보를 임시 변수에 저장해두는 것이 안전.

            song_to_replay = None # 임시로 곡 정보를 저장할 변수
            # current_songs는 play_song이 설정됨에 따라, play_next에서 참조할 필요가 없음.
            # 대신, play_song이 호출되기 전까지 current_songs가 비워지지 않도록 해야함.
            # -&amp;gt; current_songs는 play_song이 성공적으로 실행될 때만 업데이트.
            # -&amp;gt; 반복 모드일 경우, play_song에 전달할 song_info가 필요.
            #    이 song_info는 직전에 재생했던 곡의 정보여야 함.
            #    current_songs 딕셔너리는 서버별로 현재 &quot;재생 완료된&quot; 또는 &quot;재생 중인&quot; 곡을 가리킴.
            #    따라서, play_song을 다시 호출할 때, 이전에 current_songs에 저장했던 값을 사용해야 함.

            # current_songs는 play_song이 시작될 때 설정됨.
            # play_next는 곡이 &quot;끝난 후&quot; 호출됨.
            # 따라서 loop_mode == 1일 때, &quot;방금 끝난 곡&quot;을 다시 재생해야 함.
            # 이 정보는 current_songs[guild_id]에 있었어야 함.
            # 위의 current_songs 삭제 로직을 loop_mode == 1이 아닐 때만 수행하도록 수정.

            if guild_id in current_songs: # 방금 끝난 곡 정보가 남아있다면
                song_to_replay_info = current_songs[guild_id]
                logger.info(f&quot;현재 곡 반복: {song_to_replay_info['title']} (서버: {guild_id})&quot;)
                await play_song(voice_client, song_to_replay_info, guild_id)
                return # 현재 곡 반복 재생 시작했으므로 함수 종료
            else: # 현재 곡 정보가 없으면 (오류 상황이거나 첫 곡 재생 실패 후 등)
                logger.warning(f&quot;현재 곡 반복 모드이나, current_songs에 곡 정보 없음 (서버: {guild_id})&quot;)
                # 그냥 다음 곡으로 넘어가도록 처리 (아래 로직으로 이어짐)

        # 큐에서 다음 곡 가져오기
        if guild_id in music_queues and music_queues[guild_id]:
            next_song_info = music_queues[guild_id].pop(0) # 큐의 맨 앞에서 곡을 꺼냄

            if loop_mode == 2: # 큐 반복 모드
                music_queues[guild_id].append(next_song_info) # 꺼낸 곡을 다시 큐의 맨 뒤에 추가
                logger.info(f&quot;큐 반복: {next_song_info['title']} 다시 큐에 추가 (서버: {guild_id})&quot;)

            logger.info(f&quot;큐에서 다음 곡 재생: {next_song_info['title']} (서버: {guild_id})&quot;)
            await play_song(voice_client, next_song_info, guild_id)
        else:
            # 큐가 비어있으면 (더 이상 재생할 곡이 없으면)
            logger.info(f&quot;큐가 비어있음 (서버: {guild_id}). 현재 곡 정보 삭제.&quot;)
            if guild_id in current_songs: # 확실히 현재 재생 중인 곡이 없도록 정리
                del current_songs[guild_id]
            # 필요하다면 여기서 음성 채널 자동 퇴장 로직 추가 가능
            # 예: await asyncio.sleep(300) # 5분 대기 후
            # if guild_id in voice_clients and not voice_clients[guild_id].is_playing() and not music_queues.get(guild_id):
            #     await voice_clients[guild_id].disconnect()
            #     del voice_clients[guild_id]
            #     logger.info(f&quot;음악 없음, {guild_id} 음성 채널 자동 퇴장&quot;)

    except Exception as e:
        logger.error(f&quot;다음 곡 재생 중 오류 (play_next): {e} (서버: {guild_id})&quot;)
        # 오류 발생 시에도 current_songs 정리 시도
        if guild_id in current_songs:
            del current_songs[guild_id]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 곡 재생이 끝나면 호출되어 다음 곡을 재생하거나 반복 설정을 처리합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;반복 모드 확인&lt;/b&gt;: &lt;code&gt;loop_modes&lt;/code&gt;를 보고 현재 서버의 반복 설정을 가져옵니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;현재 곡 반복 (&lt;code&gt;loop_mode == 1&lt;/code&gt;)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;current_songs[guild_id]&lt;/code&gt;에 저장된 (방금 재생이 끝난) 곡 정보를 가져와 다시 &lt;code&gt;play_song&lt;/code&gt;을 호출합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;중요&lt;/b&gt;: &lt;code&gt;current_songs&lt;/code&gt;는 &lt;code&gt;play_song&lt;/code&gt;이 성공적으로 시작될 때 업데이트됩니다. &lt;code&gt;play_next&lt;/code&gt;는 곡이 끝난 &lt;i&gt;후&lt;/i&gt; 호출되므로, &lt;code&gt;loop_mode == 1&lt;/code&gt;일 때는 &lt;code&gt;current_songs&lt;/code&gt;에 아직 이전 곡 정보가 남아있어야 합니다. 그래서 &lt;code&gt;current_songs&lt;/code&gt; 삭제 로직은 &lt;code&gt;loop_mode != 1&lt;/code&gt;일 때만 수행됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;큐에서 다음 곡 재생&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;music_queues[guild_id].pop(0)&lt;/code&gt;: 큐의 맨 앞에 있는 곡을 꺼냅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;큐 반복 (&lt;code&gt;loop_mode == 2&lt;/code&gt;)&lt;/b&gt;: 꺼낸 곡을 다시 큐의 맨 뒤에 추가합니다.&lt;/li&gt;
&lt;li&gt;꺼낸 곡 정보로 &lt;code&gt;play_song&lt;/code&gt;을 호출하여 재생합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;큐가 비었을 때&lt;/b&gt;: 더 이상 재생할 곡이 없으면 &lt;code&gt;current_songs&lt;/code&gt;에서 현재 곡 정보를 삭제합니다. (필요하다면 여기서 일정 시간 후 음성 채널 자동 퇴장 같은 기능을 추가할 수 있습니다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8. 봇 이벤트 핸들러 (&lt;code&gt;on_ready&lt;/code&gt;)&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.event
async def on_ready():
    logger.info(f'{bot.user}가 로그인했습니다!')

    # 로컬 명령어 확인 (디버깅용)
    # local_commands = bot.tree.get_commands()
    # logger.info(f&quot;  로컬에 정의된 명령어 수: {len(local_commands)}&quot;)
    # for cmd in local_commands:
    #    logger.info(f&quot;  ✓ {cmd.name}: {cmd.description}&quot;)

    guild_count = len(bot.guilds)
    logger.info(f&quot;  봇이 속한 서버 수: {guild_count}&quot;)

    # 명령어 동기화 (봇이 시작될 때마다 실행)
    # 개발 중에는 길드 명령어로 빠르게 테스트하고, 배포 시에는 글로벌로 전환 고려
    # 여기서는 모든 길드에 즉시 적용되도록 길드별 동기화를 사용합니다.
    logger.info(&quot;  명령어 동기화 시작...&quot;)
    for guild in bot.guilds:
        try:
            # 특정 길드에만 명령어 복사 및 동기화
            bot.tree.copy_global_to(guild=guild) # 글로벌 명령어를 길드 명령어로 복사
            synced = await bot.tree.sync(guild=guild)
            logger.info(f&quot;✅ {guild.name} ({guild.id}) 서버에 {len(synced)}개 명령어 동기화 완료.&quot;)
        except Exception as e:
            logger.error(f&quot;❌ {guild.name} ({guild.id}) 서버 명령어 동기화 실패: {e}&quot;)

    # 만약 글로벌로 동기화하고 싶다면:
    # try:
    #     synced = await bot.tree.sync()
    #     logger.info(f&quot;  글로벌 명령어 {len(synced)}개 동기화 완료.&quot;)
    # except Exception as e:
    #     logger.error(f&quot;❌ 글로벌 명령어 동기화 실패: {e}&quot;)

    await bot.change_presence(
        activity=discord.Activity(
            type=discord.ActivityType.listening,
            name=&quot;음악 | /play&quot; # 봇 상태 메시지
        )
    )
    logger.info(&quot;  봇 상태 메시지 설정 완료.&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇이 준비되고 Discord에 성공적으로 로그인했을 때 실행되는 이벤트입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;logger.info(f'{bot.user}가 로그인했습니다!')&lt;/code&gt;: 봇이 어떤 이름으로 로그인했는지 로그를 남깁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;명령어 동기화 (Slash Commands Sync)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;슬래시 명령어는 Discord에 등록(동기화)되어야 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bot.tree.copy_global_to(guild=guild)&lt;/code&gt;: 코드에 정의된 (글로벌 범위의) 슬래시 명령어들을 특정 길드(서버)의 명령어로 복사합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await bot.tree.sync(guild=guild)&lt;/code&gt;: 해당 길드에 명령어들을 동기화합니다. 길드 명령어는 즉시 적용되는 장점이 있어 개발 중에 유용합니다.&lt;/li&gt;
&lt;li&gt;주석 처리된 부분은 모든 서버에 적용되는 글로벌 명령어 동기화 방법입니다. 글로벌 명령어는 적용되기까지 최대 1시간이 걸릴 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await bot.change_presence(...)&lt;/code&gt;: 봇의 상태 메시지(예: &quot;음악 듣는 중 | /play&quot;)를 설정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;9. 슬래시 명령어 (Slash Commands)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 사용자가 실제로 상호작용할 명령어들을 정의합니다. 모든 슬래시 명령어는 &lt;code&gt;@bot.tree.command(...)&lt;/code&gt; 데코레이터를 사용해 만듭니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/play [query]&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;play&quot;, description=&quot;음악을 재생하거나 큐에 추가합니다.&quot;)
@app_commands.describe(query=&quot;재생할 음악의 제목이나 YouTube URL&quot;)
async def play_slash(interaction: discord.Interaction, query: str):
    await interaction.response.defer() # 응답 지연

    if not interaction.user.voice: # 사용자가 음성 채널에 있는지 확인
        await interaction.followup.send(&quot;❌ 음성 채널에 먼저 접속해주세요!&quot;, ephemeral=True)
        return

    channel = interaction.user.voice.channel
    guild_id = interaction.guild.id

    # 봇이 음성 채널에 연결되어 있지 않으면 연결
    if guild_id not in voice_clients or not voice_clients[guild_id].is_connected():
        try:
            # 이미 다른 채널에 있다면 이동, 없으면 새로 연결
            if guild_id in voice_clients and voice_clients[guild_id].channel != channel:
                 await voice_clients[guild_id].move_to(channel)
                 logger.info(f&quot;{guild_id} 음성 채널 이동: {channel.name}&quot;)
            else:
                voice_client = await channel.connect()
                voice_clients[guild_id] = voice_client
                logger.info(f&quot;{guild_id} 음성 채널 연결: {channel.name}&quot;)
        except Exception as e:
            logger.error(f&quot;음성 채널 연결 실패 ({guild_id}): {e}&quot;)
            await interaction.followup.send(f&quot;❌ 음성 채널 연결에 실패했어요: {e}&quot;, ephemeral=True)
            return
    # 이미 연결된 voice_client 사용
    voice_client = voice_clients[guild_id]

    song_info = await search_youtube(query)
    if not song_info:
        await interaction.followup.send(f&quot;❌ '{query}'에 대한 검색 결과를 찾을 수 없어요.&quot;, ephemeral=True)
        return

    if guild_id not in music_queues:
        music_queues[guild_id] = []

    # 현재 재생 중인 곡이 없거나, voice_client가 아무것도 재생하고 있지 않으면 바로 재생
    if guild_id not in current_songs or not voice_client.is_playing():
        # current_songs에 항목이 있어도, 실제로는 재생이 끝났을 수 있음 (play_next에서 정리되기 전)
        # 따라서 voice_client.is_playing()도 함께 확인
        success = await play_song(voice_client, song_info, guild_id)
        if success:
            embed = discord.Embed(
                title=&quot;▶️ 지금 재생&quot;,
                description=f&quot;**[{song_info['title']}]({song_info['url']})**&quot;,
                color=0x3498db
            )
            embed.add_field(name=&quot;⏱️ 길이&quot;, value=song_info.get('duration', 'N/A'), inline=True)
            embed.add_field(name=&quot;  요청자&quot;, value=interaction.user.mention, inline=True)
            if song_info.get('thumbnail'):
                embed.set_thumbnail(url=song_info['thumbnail'])
            embed.set_footer(text=f&quot;업로더: {song_info.get('uploader', 'N/A')}&quot;)

            await interaction.followup.send(embed=embed, view=MusicView(guild_id))
        else:
            await interaction.followup.send(f&quot;❌ '{song_info['title']}' 재생에 실패했어요.&quot;, ephemeral=True)
    else:
        music_queues[guild_id].append(song_info)
        embed = discord.Embed(
            title=&quot;  큐에 추가됨&quot;,
            description=f&quot;**[{song_info['title']}]({song_info['url']})**&quot;,
            color=0x9b59b6 # 보라색 계열
        )
        embed.add_field(name=&quot;⏱️ 길이&quot;, value=song_info.get('duration', 'N/A'), inline=True)
        embed.add_field(name=&quot;  대기열 순서&quot;, value=f&quot;{len(music_queues[guild_id])}번째&quot;, inline=True)
        if song_info.get('thumbnail'):
            embed.set_thumbnail(url=song_info['thumbnail'])
        await interaction.followup.send(embed=embed)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음악을 재생하거나 큐에 추가합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;await interaction.response.defer()&lt;/code&gt;: 명령어 처리에 시간이 걸릴 수 있으므로, Discord에게 &quot;처리 중&quot;임을 알립니다. &lt;code&gt;ephemeral=True&lt;/code&gt;를 사용하면 이 메시지는 명령어 사용자에게만 보입니다. (여기서는 &lt;code&gt;defer&lt;/code&gt;만 하고 실제 응답은 &lt;code&gt;followup.send&lt;/code&gt;로 합니다.)&lt;/li&gt;
&lt;li&gt;사용자가 음성 채널에 있는지 확인합니다. 없으면 오류 메시지를 보냅니다.&lt;/li&gt;
&lt;li&gt;봇을 사용자의 음성 채널에 연결합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미 다른 채널에 연결되어 있다면 그 채널로 이동합니다.&lt;/li&gt;
&lt;li&gt;아직 연결되어 있지 않다면 새로 연결하고 &lt;code&gt;voice_clients&lt;/code&gt;에 저장합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await search_youtube(query)&lt;/code&gt;: 입력받은 &lt;code&gt;query&lt;/code&gt;로 YouTube를 검색합니다.&lt;/li&gt;
&lt;li&gt;검색 결과(&lt;code&gt;song_info&lt;/code&gt;)가 없으면 오류 메시지를 보냅니다.&lt;/li&gt;
&lt;li&gt;해당 서버의 큐(&lt;code&gt;music_queues[guild_id]&lt;/code&gt;)가 없으면 새로 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재생 로직&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 재생 중인 곡이 없거나(&lt;code&gt;guild_id not in current_songs&lt;/code&gt;) 또는 음성 클라이언트가 실제로 아무것도 재생하고 있지 않으면(&lt;code&gt;not voice_client.is_playing()&lt;/code&gt;), &lt;code&gt;await play_song(voice_client, song_info, guild_id)&lt;/code&gt;을 호출하여 바로 재생합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;재생 성공 시, 현재 재생 정보를 Embed로 만들어 &lt;code&gt;MusicView&lt;/code&gt; 버튼들과 함께 보여줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;그렇지 않으면 (이미 뭔가 재생 중이면), &lt;code&gt;music_queues[guild_id].append(song_info)&lt;/code&gt;로 큐에 추가하고 추가되었다는 Embed 메시지를 보냅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/queue&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;queue&quot;, description=&quot;현재 음악 큐를 보여줍니다.&quot;)
async def queue_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id
    embed = get_queue_embed(guild_id) # 위에서 만든 헬퍼 함수 사용
    await interaction.response.send_message(embed=embed, view=MusicView(guild_id), ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 음악 큐를 보여줍니다. &lt;code&gt;get_queue_embed&lt;/code&gt; 헬퍼 함수를 사용해 Embed를 만들고, &lt;code&gt;MusicView&lt;/code&gt; 버튼들과 함께 전송합니다. &lt;code&gt;ephemeral=True&lt;/code&gt;로 설정하여 명령어 사용자에게만 보이도록 합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/skip&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;skip&quot;, description=&quot;현재 재생 중인 곡을 건너뜁니다.&quot;)
async def skip_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id
    if guild_id in voice_clients and voice_clients[guild_id].is_playing():
        voice_clients[guild_id].stop() # stop()은 play_next를 호출하지만, 큐가 비었으므로 아무것도 안 함
        await interaction.response.send_message(&quot;⏭️ 다음 곡으로 건너뛰었어요.&quot;, ephemeral=True)
    else:
        await interaction.response.send_message(&quot;❌ 지금은 건너뛸 곡이 없어요.&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 곡을 건너뜁니다. &lt;code&gt;voice_clients[guild_id].stop()&lt;/code&gt;을 호출하면 &lt;code&gt;play_song&lt;/code&gt;의 &lt;code&gt;after&lt;/code&gt; 콜백으로 등록된 &lt;code&gt;play_next&lt;/code&gt; 함수가 실행되어 다음 곡이 재생됩니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/pause&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;pause&quot;, description=&quot;음악 재생을 일시정지합니다.&quot;)
async def pause_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id
    if guild_id in voice_clients and voice_clients[guild_id].is_playing():
        voice_clients[guild_id].pause()
        await interaction.response.send_message(&quot;⏸️ 음악을 일시정지했어요.&quot;, ephemeral=True)
    else:
        await interaction.response.send_message(&quot;❌ 일시정지할 음악이 없어요.&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음악을 일시정지합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/resume&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;resume&quot;, description=&quot;일시정지된 음악을 다시 재생합니다.&quot;)
async def resume_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id
    if guild_id in voice_clients and voice_clients[guild_id].is_paused(): # is_paused()로 확인
        voice_clients[guild_id].resume()
        await interaction.response.send_message(&quot;▶️ 음악을 다시 재생할게요.&quot;, ephemeral=True)
    else:
        await interaction.response.send_message(&quot;❌ 다시 재생할 음악이 없는데요?&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일시정지된 음악을 다시 재생합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/stop&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;stop&quot;, description=&quot;음악 재생을 멈추고 큐를 비웁니다.&quot;)
async def stop_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id

    # 큐 비우기
    if guild_id in music_queues:
        music_queues[guild_id].clear()

    # 현재 곡 정보 삭제
    if guild_id in current_songs:
        del current_songs[guild_id]

    # 음성 클라이언트 정지 (재생 중인 것이 있다면)
    if guild_id in voice_clients and voice_clients[guild_id].is_playing():
        voice_clients[guild_id].stop() # stop()은 play_next를 호출하지만, 큐가 비었으므로 아무것도 안 함

    await interaction.response.send_message(&quot;⏹️ 모든 음악을 멈추고 큐를 깨끗하게 비웠어요!&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음악 재생을 완전히 멈추고 큐도 비웁니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/join&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;join&quot;, description=&quot;봇을 현재 음성 채널로 불러옵니다.&quot;)
async def join_slash(interaction: discord.Interaction):
    if not interaction.user.voice:
        await interaction.response.send_message(&quot;❌ 먼저 음성 채널에 들어가주세요!&quot;, ephemeral=True)
        return

    channel = interaction.user.voice.channel
    guild_id = interaction.guild.id

    if guild_id in voice_clients and voice_clients[guild_id].is_connected():
        if voice_clients[guild_id].channel == channel:
            await interaction.response.send_message(&quot;✅ 이미 여기 있어요!&quot;, ephemeral=True)
        else:
            await voice_clients[guild_id].move_to(channel)
            await interaction.response.send_message(f&quot;슝! **{channel.name}** 채널로 이동했어요.&quot;, ephemeral=True)
    else:
        try:
            vc = await channel.connect()
            voice_clients[guild_id] = vc
            await interaction.response.send_message(f&quot;안녕하세요! **{channel.name}** 채널에 왔어요.&quot;, ephemeral=True)
        except Exception as e:
            logger.error(f&quot;음성 채널 참가 실패 ({guild_id}, {channel.name}): {e}&quot;)
            await interaction.response.send_message(f&quot;❌ 이런, **{channel.name}** 채널에 들어갈 수가 없네요: {e}&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇을 사용자가 있는 음성 채널로 불러옵니다. 이미 다른 채널에 있다면 이동합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/leave&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;leave&quot;, description=&quot;봇을 음성 채널에서 내보냅니다.&quot;)
async def leave_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id
    if guild_id in voice_clients and voice_clients[guild_id].is_connected():
        await voice_clients[guild_id].disconnect()
        # 연결 해제 후 관련 정보 정리
        if guild_id in voice_clients: del voice_clients[guild_id]
        if guild_id in music_queues: del music_queues[guild_id] # 큐도 함께 정리
        if guild_id in current_songs: del current_songs[guild_id]
        if guild_id in loop_modes: del loop_modes[guild_id]

        await interaction.response.send_message(&quot;  안녕히 계세요! 다음에 또 만나요.&quot;, ephemeral=True)
    else:
        await interaction.response.send_message(&quot;❌ 저 지금 음성 채널에 없는데요?&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇을 음성 채널에서 내보내고 관련 데이터(큐, 현재 곡 정보 등)를 정리합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/loop&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;loop&quot;, description=&quot;반복 모드를 설정합니다.&quot;)
@app_commands.describe(mode=&quot;설정할 반복 모드&quot;)
@app_commands.choices(mode=[
    app_commands.Choice(name=&quot;반복 없음&quot;, value=0),
    app_commands.Choice(name=&quot;현재 곡 반복&quot;, value=1),
    app_commands.Choice(name=&quot;큐 전체 반복&quot;, value=2)
])
async def loop_slash(interaction: discord.Interaction, mode: app_commands.Choice[int]):
    guild_id = interaction.guild.id
    loop_modes[guild_id] = mode.value # Choice 객체에서 실제 값을 가져옴

    mode_text = mode.name # Choice 객체에서 선택된 이름을 가져옴
    await interaction.response.send_message(f&quot;  반복 모드를 **{mode_text}**(으)로 설정했어요.&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복 모드를 설정합니다. &lt;code&gt;@app_commands.choices&lt;/code&gt;를 사용해 사용자에게 선택지를 제공합니다.&lt;br /&gt;&lt;code&gt;mode: app_commands.Choice[int]&lt;/code&gt; 타입 힌트를 사용하면, &lt;code&gt;mode.value&lt;/code&gt;로 정수 값을, &lt;code&gt;mode.name&lt;/code&gt;으로 선택한 항목의 이름을 가져올 수 있습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/clear&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;clear&quot;, description=&quot;음악 큐를 모두 지웁니다.&quot;)
async def clear_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id
    if guild_id in music_queues and music_queues[guild_id]:
        cleared_count = len(music_queues[guild_id])
        music_queues[guild_id].clear()
        await interaction.response.send_message(f&quot; ️ 큐에 있던 {cleared_count}곡을 모두 치웠어요!&quot;, ephemeral=True)
    else:
        await interaction.response.send_message(&quot;❌ 큐가 원래 비어있었어요.&quot;, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음악 큐를 모두 지웁니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/help&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;help&quot;, description=&quot;봇 명령어 도움말을 보여줍니다.&quot;)
async def help_slash(interaction: discord.Interaction):
    embed = discord.Embed(
        title=&quot;  디스호스트 음악봇 도움말&quot;,
        description=&quot;안녕하세요! 제가 할 수 있는 일들을 알려드릴게요.&quot;,
        color=0x00aff4 # 하늘색
    )
    embed.add_field(
        name=&quot;  음악 재생&quot;,
        value=&quot;`/play [노래 제목 또는 YouTube URL]` : 음악을 틀거나 큐에 넣어요.\n&quot;
              &quot;`/queue` : 현재 대기열을 보여줘요.\n&quot;
              &quot;`/skip` : 지금 나오는 노래를 건너뛰어요.\n&quot;
              &quot;`/pause` : 잠깐 멈춤!\n&quot;
              &quot;`/resume` : 다시 재생 시작!\n&quot;
              &quot;`/stop` : 모든 걸 멈추고 큐도 비워요.\n&quot;
              &quot;`/loop [모드]` : 반복 설정을 바꿔요 (없음, 현재 곡, 전체 큐).\n&quot;
              &quot;`/clear` : 대기열을 싹 비워요.&quot;,
        inline=False
    )
    embed.add_field(
        name=&quot;  봇 관련&quot;,
        value=&quot;`/join` : 저를 음성 채널로 불러주세요.\n&quot;
              &quot;`/leave` : 제가 음성 채널에서 나갈게요.\n&quot;
              &quot;`/status` : 제 상태가 어떤지 알려줘요.\n&quot;
              &quot;`/help` : 지금 보시는 이 도움말이에요!&quot;,
        inline=False
    )
    embed.set_footer(text=&quot;문의사항은 관리자에게 | 디스호스트 음악봇 v2.0&quot;)
    embed.set_thumbnail(url=bot.user.display_avatar.url) # 봇 프로필 사진을 썸네일로

    await interaction.response.send_message(embed=embed, ephemeral=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 명령어 도움말을 Embed 메시지로 보여줍니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;code&gt;/status&lt;/code&gt;&lt;/h4&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;@bot.tree.command(name=&quot;status&quot;, description=&quot;봇과 YouTube 연결 상태를 확인합니다.&quot;)
async def status_slash(interaction: discord.Interaction):
    await interaction.response.defer(ephemeral=True) # 응답 지연, 사용자에게만 보이도록

    embed = discord.Embed(
        title=&quot;  디스호스트 음악봇 상태 체크!&quot;,
        color=0x2ecc71 # 초록색 계열
    )

    # 기본 정보
    embed.add_field(name=&quot;✅ 봇 상태&quot;, value=&quot;온라인&quot;, inline=True)
    embed.add_field(name=&quot;  서버 수&quot;, value=f&quot;{len(bot.guilds)}개&quot;, inline=True)
    embed.add_field(name=&quot;  음성 연결&quot;, value=f&quot;{len(bot.voice_clients)}개&quot;, inline=True)

    # 쿠키 파일 상태
    cookie_file_exists = os.path.exists('cookies.txt')
    cookie_status = &quot;  있음 (정상 작동 기대!)&quot; if cookie_file_exists else &quot;⚠️ 없음 (일부 기능 제한 가능)&quot;
    embed.add_field(name=&quot;  쿠키 파일&quot;, value=cookie_status, inline=True)

    # YouTube 연결 테스트 (간단한 검색 시도)
    youtube_connection_status = &quot;❓ 확인 중...&quot;
    try:
        # asyncio.to_thread를 사용하여 동기 함수인 ydl.extract_info를 비동기적으로 실행
        test_info = await asyncio.to_thread(
            yt_dlp.YoutubeDL({**ytdlp_format_options, 'quiet': True, 'no_warnings': True, 'extract_flat': True, 'skip_download': True}).extract_info,
            &quot;ytsearch:test&quot;, # 간단한 검색어로 테스트
            download=False
        )
        if test_info and ('entries' in test_info and test_info['entries']) or 'id' in test_info :
            youtube_connection_status = &quot;  정상 (YT-DLP 작동 중)&quot;
        else:
            youtube_connection_status = &quot;  불안정 (YT-DLP 응답 확인 필요)&quot;
    except Exception as e:
        logger.error(f&quot;YouTube 연결 테스트 오류: {e}&quot;)
        youtube_connection_status = f&quot;❌ 오류 ({type(e).__name__})&quot;

    embed.add_field(name=&quot;  YouTube 연결&quot;, value=youtube_connection_status, inline=True)

    # 현재 재생 정보 (명령어를 실행한 서버 기준)
    guild_id = interaction.guild.id
    current_song_title = &quot;없음&quot;
    if guild_id in current_songs:
        current_song_title = current_songs[guild_id]['title']
    embed.add_field(name=&quot;  현재 재생 (이 서버)&quot;, value=current_song_title[:100], inline=True) # 너무 길면 자르기

    queue_length = 0
    if guild_id in music_queues:
        queue_length = len(music_queues[guild_id])
    embed.add_field(name=&quot;  대기열 (이 서버)&quot;, value=f&quot;{queue_length}곡&quot;, inline=True)

    loop_mode_text = &quot;반복 없음&quot;
    if guild_id in loop_modes:
        loop_mode_text = [&quot;반복 없음&quot;, &quot;현재 곡 반복&quot;, &quot;큐 전체 반복&quot;][loop_modes[guild_id]]
    embed.add_field(name=&quot;  반복 모드 (이 서버)&quot;, value=loop_mode_text, inline=True)

    embed.set_footer(text=f&quot;지연시간: {round(bot.latency * 1000)}ms&quot;)
    await interaction.followup.send(embed=embed)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇의 현재 상태, 쿠키 파일 존재 여부, YouTube 연결 상태 등을 확인하여 Embed로 보여줍니다.&lt;br /&gt;&lt;code&gt;asyncio.to_thread&lt;/code&gt;를 사용하여 &lt;code&gt;yt_dlp.YoutubeDL().extract_info&lt;/code&gt; 같은 동기 함수를 비동기 컨텍스트에서 안전하게 실행합니다. 이는 봇이 다른 작업을 처리하는 동안 해당 함수가 블로킹하는 것을 방지합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10. 텍스트 명령어 (호환성용, 선택 사항)&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# 텍스트 명령어들 (슬래시 명령어와 동일한 기능, 호환성을 위해 유지 또는 제거 가능)
# 예시: /play 명령어의 텍스트 버전
@bot.command(name='play', aliases=['p'])
async def play_text(ctx: commands.Context, *, query: str):
    # 이 부분은 위의 play_slash 함수와 매우 유사하게 작성됩니다.
    # 다만, interaction 대신 ctx (Context 객체)를 사용하고,
    # interaction.response.defer() 대신 ctx.typing() 또는 그냥 진행,
    # interaction.followup.send() 대신 ctx.send()를 사용합니다.
    # 음성 채널 연결 로직 등도 거의 동일합니다.
    # 여기서는 설명을 위해 간략히 구조만 남깁니다.

    if not ctx.author.voice:
        await ctx.send(&quot;❌ 음성 채널에 먼저 접속해주세요!&quot;)
        return

    channel = ctx.author.voice.channel
    guild_id = ctx.guild.id

    # ... (play_slash와 유사한 음성 채널 연결 로직) ...
    if guild_id not in voice_clients or not voice_clients[guild_id].is_connected():
        # ... connect or move ...
        try:
            if guild_id in voice_clients and voice_clients[guild_id].channel != channel:
                 await voice_clients[guild_id].move_to(channel)
            else:
                vc = await channel.connect()
                voice_clients[guild_id] = vc
        except Exception as e:
            await ctx.send(f&quot;❌ 음성 채널 연결 실패: {e}&quot;)
            return

    voice_client = voice_clients[guild_id]
    song_info = await search_youtube(query)

    if not song_info:
        await ctx.send(f&quot;❌ '{query}' 검색 결과 없음.&quot;)
        return

    if guild_id not in music_queues:
        music_queues[guild_id] = []

    if guild_id not in current_songs or not voice_client.is_playing():
        success = await play_song(voice_client, song_info, guild_id)
        if success:
            await ctx.send(f&quot;▶️ 지금 재생: **{song_info['title']}**&quot;)
            # 텍스트 명령어에서는 MusicView를 직접 보내기 어려우므로,
            # 상태 변경 메시지만 보내거나, 혹은 별도의 반응(reaction) 기반 UI를 고려할 수 있습니다.
        else:
            await ctx.send(f&quot;❌ '{song_info['title']}' 재생 실패.&quot;)
    else:
        music_queues[guild_id].append(song_info)
        await ctx.send(f&quot;  큐에 추가: **{song_info['title']}** ({len(music_queues[guild_id])}번째)&quot;)

# 다른 텍스트 명령어들 (skip, queue, pause, resume, stop, join, leave 등)도
# 각각의 슬래시 명령어와 유사한 로직으로 구현할 수 있습니다.
# @bot.command(name='skip', aliases=['s'])
# async def skip_text(ctx: commands.Context): ...

# @bot.command(name='queue', aliases=['q'])
# async def queue_text(ctx: commands.Context):
#     embed = get_queue_embed(ctx.guild.id)
#     await ctx.send(embed=embed) # 텍스트 명령어에서도 Embed 전송은 가능

# (이하 생략)
# 제공된 bot.py에는 모든 텍스트 명령어가 구현되어 있으니 참고하세요.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 명령어가 대세이지만, 예전 방식의 텍스트 명령어(&lt;code&gt;!play&lt;/code&gt; 등)를 지원하고 싶다면 이렇게 추가할 수 있습니다. 로직은 슬래시 명령어와 거의 동일하며, &lt;code&gt;interaction&lt;/code&gt; 대신 &lt;code&gt;ctx&lt;/code&gt; (Context) 객체를 사용하고, 응답 방식이 조금 다릅니다. 제공된 &lt;code&gt;bot.py&lt;/code&gt;에는 이 텍스트 명령어들이 모두 구현되어 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11. 봇 실행&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;if __name__ == &quot;__main__&quot;:
    if not TOKEN:
        logger.error(&quot;환경변수에서 Discord 토큰을 찾을 수 없어요! .env 파일을 확인해주세요.&quot;)
    else:
        try:
            bot.run(TOKEN)
        except discord.LoginFailure:
            logger.error(&quot;Discord 로그인 실패! 토큰이 정확한지 확인해주세요.&quot;)
        except Exception as e:
            logger.error(f&quot;봇 실행 중 알 수 없는 오류 발생: {e}&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드디어 마지막입니다! 이 코드는 스크립트가 직접 실행될 때(&lt;code&gt;python bot.py&lt;/code&gt; 처럼)만 작동합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;if not TOKEN&lt;/code&gt;: Discord 봇 토큰이 제대로 로드되었는지 확인합니다. 없으면 오류 메시지를 출력합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bot.run(TOKEN)&lt;/code&gt;: 이 한 줄이 실제로 봇을 Discord 서버에 연결하고 실행시킵니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;try...except&lt;/code&gt; 블록: 로그인 실패 등 봇 실행 중에 발생할 수 있는 주요 오류들을 잡아 적절한 로그를 남기도록 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;code&gt;bot.py&lt;/code&gt; 파일의 전체 구조와 각 부분의 역할을 이해하셨을 겁니다! 이 코드를 기반으로 여러분만의 기능을 추가하거나 수정해보세요. 예를 들어, SoundCloud 지원, 가사 검색 기능, 관리자 전용 명령어 등을 만들어볼 수 있겠죠?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 섹션에서는 이 봇을 디스호스트에 올려서 24시간 돌아가도록 만드는 방법을 알아볼게요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;13. 풀 코드&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;import discord
from discord import app_commands
from discord.ext import commands
import os
import asyncio
import yt_dlp
import logging
from dotenv import load_dotenv
from typing import Dict, List, Optional, Any, Union

# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('dishost_musicbot')

# .env 파일 로드 (Discord 토큰 등 환경변수)
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN')

# 봇 설정
intents = discord.Intents.default()
intents.message_content = True  # 메시지 내용 읽기 권한
intents.voice_states = True     # 음성 상태 추적 권한
# 기본 help 명령어 비활성화
bot = commands.Bot(command_prefix='/', intents=intents, help_command=None)

# YT-DLP 설정 (쿠키 기반)
ytdlp_format_options = {
    'format': 'bestaudio[ext=webm]/bestaudio/best[ext=webm]/best',
    'no_playlist': True,
    'noplaylist': True,
    'quiet': True,
    'no_warnings': True,
    'default_search': 'auto',
    'source_address': '0.0.0.0',
    'geo_bypass': True,
    'extractor_retries': 3,
    'nocheckcertificate': True,
    'age_limit': 99,
    'extract_flat': False,
    'ignoreerrors': True,
    'cookiefile': 'cookies.txt',  # 쿠키 파일 사용
    'http_headers': {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'en-us,en;q=0.5',
        'Accept-Encoding': 'gzip,deflate',
        'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
        'Keep-Alive': '300',
        'Connection': 'keep-alive',
    },
}

ffmpeg_options = {
    'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5',
    'options': '-vn -filter:a &quot;volume=0.5&quot;'
}

# 전역 변수들
music_queues: Dict[int, List[Dict[str, Any]]] = {}
voice_clients: Dict[int, discord.VoiceClient] = {}
current_songs: Dict[int, Dict[str, Any]] = {}
loop_modes: Dict[int, int] = {}  # 0: 반복없음, 1: 현재곡 반복, 2: 큐 반복

class MusicView(discord.ui.View):
    def __init__(self, guild_id: int):
        super().__init__(timeout=None)
        self.guild_id = guild_id

    @discord.ui.button(label='⏸️ 일시정지', style=discord.ButtonStyle.secondary)
    async def pause_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer()
        if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
            voice_clients[self.guild_id].pause()
            await interaction.followup.send(&quot;⏸️ 음악이 일시정지되었습니다.&quot;, ephemeral=True)
        else:
            await interaction.followup.send(&quot;❌ 재생 중인 음악이 없습니다.&quot;, ephemeral=True)

    @discord.ui.button(label='▶️ 재생', style=discord.ButtonStyle.secondary)
    async def resume_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer()
        if self.guild_id in voice_clients and voice_clients[self.guild_id].is_paused():
            voice_clients[self.guild_id].resume()
            await interaction.followup.send(&quot;▶️ 음악이 재개되었습니다.&quot;, ephemeral=True)
        else:
            await interaction.followup.send(&quot;❌ 일시정지된 음악이 없습니다.&quot;, ephemeral=True)

    @discord.ui.button(label='⏭️ 스킵', style=discord.ButtonStyle.secondary)
    async def skip_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer()
        if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
            voice_clients[self.guild_id].stop()
            await interaction.followup.send(&quot;⏭️ 다음 곡으로 스킵합니다.&quot;, ephemeral=True)
        else:
            await interaction.followup.send(&quot;❌ 재생 중인 음악이 없습니다.&quot;, ephemeral=True)

    @discord.ui.button(label='⏹️ 정지', style=discord.ButtonStyle.danger)
    async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer()
        if self.guild_id in music_queues:
            music_queues[self.guild_id].clear()
        if self.guild_id in voice_clients:
            voice_clients[self.guild_id].stop()
        if self.guild_id in current_songs:
            del current_songs[self.guild_id]
        await interaction.followup.send(&quot;⏹️ 음악이 정지되고 큐가 초기화되었습니다.&quot;, ephemeral=True)

def get_queue_embed(guild_id: int) -&amp;gt; discord.Embed:
    &quot;&quot;&quot;현재 큐 상태를 임베드로 생성&quot;&quot;&quot;
    embed = discord.Embed(title=&quot;  음악 큐&quot;, color=0x3498db)

    if guild_id in current_songs:
        current = current_songs[guild_id]
        embed.add_field(
            name=&quot;  현재 재생 중&quot;,
            value=f&quot;**{current['title']}**\n⏱️ {current.get('duration', 'Unknown')}\n  {current.get('uploader', 'Unknown')}&quot;,
            inline=False
        )

    if guild_id in music_queues and music_queues[guild_id]:
        queue_text = &quot;&quot;
        for i, song in enumerate(music_queues[guild_id][:5]):  # 최대 5개만 표시
            queue_text += f&quot;{i+1}. **{song['title']}** ({song.get('duration', 'Unknown')})\n&quot;

        if len(music_queues[guild_id]) &amp;gt; 5:
            queue_text += f&quot;... 그리고 {len(music_queues[guild_id]) - 5}곡 더&quot;

        embed.add_field(name=&quot;  대기열&quot;, value=queue_text, inline=False)
    else:
        embed.add_field(name=&quot;  대기열&quot;, value=&quot;비어있음&quot;, inline=False)

    # 반복 모드 표시
    loop_mode = loop_modes.get(guild_id, 0)
    loop_text = [&quot;  반복 없음&quot;, &quot;  현재 곡 반복&quot;, &quot;  큐 반복&quot;][loop_mode]
    embed.add_field(name=&quot;  반복 모드&quot;, value=loop_text, inline=True)

    embed.set_footer(text=&quot;YT-DLP (쿠키) &amp;bull; 디스호스트 음악봇 v2.0&quot;)
    return embed

async def play_song(voice_client: discord.VoiceClient, song_info: Dict[str, Any], guild_id: int):
    &quot;&quot;&quot;YT-DLP를 사용하여 음악 재생 (쿠키 기반)&quot;&quot;&quot;
    try:
        # YT-DLP로 스트림 URL 추출
        with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
            info = ydl.extract_info(song_info['url'], download=False)
            if not info:
                logger.error(&quot;YT-DLP에서 정보를 가져올 수 없습니다&quot;)
                return False

            url = info.get('url')
            if not url:
                logger.error(&quot;스트림 URL을 찾을 수 없습니다&quot;)
                return False

        # 음악 재생
        source = discord.FFmpegPCMAudio(url, **ffmpeg_options)
        voice_client.play(source, after=lambda e: asyncio.run_coroutine_threadsafe(
            play_next(guild_id), bot.loop).result() if not e else logger.error(f'Player error: {e}'))

        current_songs[guild_id] = song_info
        logger.info(f&quot;재생 시작: {song_info['title']}&quot;)
        return True

    except Exception as e:
        logger.error(f&quot;음악 재생 중 오류: {e}&quot;)
        return False

async def play_next(guild_id: int):
    &quot;&quot;&quot;다음 곡 재생&quot;&quot;&quot;
    try:
        if guild_id not in voice_clients:
            return

        voice_client = voice_clients[guild_id]

        # 반복 모드 확인
        loop_mode = loop_modes.get(guild_id, 0)

        if loop_mode == 1:  # 현재 곡 반복
            if guild_id in current_songs:
                await play_song(voice_client, current_songs[guild_id], guild_id)
                return

        if guild_id in music_queues and music_queues[guild_id]:
            next_song = music_queues[guild_id].pop(0)

            # 큐 반복 모드면 곡을 다시 큐 끝에 추가
            if loop_mode == 2:
                music_queues[guild_id].append(next_song)

            await play_song(voice_client, next_song, guild_id)
        else:
            # 큐가 비어있으면 현재 곡 정보 제거
            if guild_id in current_songs:
                del current_songs[guild_id]

    except Exception as e:
        logger.error(f&quot;다음 곡 재생 중 오류: {e}&quot;)

async def search_youtube(query: str) -&amp;gt; Optional[Dict[str, Any]]:
    &quot;&quot;&quot;YouTube에서 검색 (쿠키 기반)&quot;&quot;&quot;
    try:
        with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
            # URL이면 직접 처리, 검색어면 ytsearch: 접두사 사용
            if query.startswith(('http://', 'https://')):
                search_query = query
            else:
                search_query = f&quot;ytsearch:{query}&quot;

            info = ydl.extract_info(search_query, download=False)

            if not info:
                return None

            # 검색 결과에서 첫 번째 항목 가져오기
            if 'entries' in info and info['entries']:
                video_info = info['entries'][0]
            else:
                video_info = info

            return {
                'title': video_info.get('title', 'Unknown Title'),
                'url': video_info.get('webpage_url', video_info.get('url', '')),
                'duration': str(video_info.get('duration', 'Unknown')),
                'uploader': video_info.get('uploader', 'Unknown'),
                'thumbnail': video_info.get('thumbnail', ''),
                'view_count': video_info.get('view_count', 0)
            }

    except Exception as e:
        logger.error(f&quot;YouTube 검색 중 오류: {e}&quot;)
        return None

@bot.event
async def on_ready():
    logger.info(f'{bot.user}가 로그인했습니다!')

    # 로컬 명령어 확인
    local_commands = bot.tree.get_commands()
    logger.info(f&quot;  로컬에 정의된 명령어 수: {len(local_commands)}&quot;)
    for cmd in local_commands:
        logger.info(f&quot;  ✓ {cmd.name}: {cmd.description}&quot;)

    # 봇이 속한 길드 수 확인
    guild_count = len(bot.guilds)
    logger.info(f&quot;  봇이 속한 서버 수: {guild_count}&quot;)
    logger.info(&quot;  길드별 명령어로 동기화합니다&quot;)
    await sync_guild_commands()

    # 봇 상태 설정
    await bot.change_presence(
        activity=discord.Activity(
            type=discord.ActivityType.listening, 
            name=&quot;디스호스트 음악봇 | /play로 음악 재생&quot;
        )
    )

async def sync_global_commands():
    &quot;&quot;&quot;글로벌 명령어 동기화&quot;&quot;&quot;
    max_retries = 3
    for attempt in range(max_retries):
        try:
            logger.info(f&quot;  글로벌 명령어 동기화 시도 {attempt + 1}/{max_retries}...&quot;)

            # 기존 글로벌 명령어 정리
            logger.info(&quot;  기존 글로벌 명령어 정리 중...&quot;)
            bot.tree.clear_commands(guild=None)

            # 글로벌 동기화 (최대 60초 대기)
            synced = await asyncio.wait_for(
                bot.tree.sync(), 
                timeout=60.0
            )

            logger.info(f&quot;✅ 글로벌 동기화 완료: {len(synced)}개 명령어&quot;)
            for cmd in synced:
                logger.info(f&quot;  - /{cmd.name}: {cmd.description}&quot;)

            logger.info(&quot;⚠️  글로벌 명령어는 적용까지 최대 1시간이 걸릴 수 있습니다.&quot;)
            return True

        except asyncio.TimeoutError:
            logger.error(f&quot;⏰ 글로벌 동기화 타임아웃 (60초)&quot;)
        except Exception as e:
            logger.error(f&quot;❌ 글로벌 동기화 실패 (시도 {attempt + 1}): {e}&quot;)

        if attempt &amp;lt; max_retries - 1:
            wait_time = 2 ** attempt  # 지수 백오프: 1초, 2초, 4초
            logger.info(f&quot;  {wait_time}초 후 재시도합니다...&quot;)
            await asyncio.sleep(wait_time)

    logger.error(&quot;❌ 글로벌 명령어 동기화가 모두 실패했습니다.&quot;)
    return False

async def sync_guild_commands():
    &quot;&quot;&quot;길드별 명령어 동기화&quot;&quot;&quot;
    max_retries = 3
    successful_guilds = 0
    failed_guilds = 0

    for attempt in range(max_retries):
        try:
            logger.info(f&quot;  길드별 명령어 동기화 시도 {attempt + 1}/{max_retries}...&quot;)

            # 각 길드별로 동기화
            for guild in bot.guilds:
                try:
                    logger.info(f&quot;  {guild.name}({guild.id}) 동기화 중...&quot;)

                    # 기존 길드 명령어 정리
                    bot.tree.clear_commands(guild=guild)

                    # 길드 동기화 (길드당 45초 타임아웃)
                    synced = await asyncio.wait_for(
                        bot.tree.sync(guild=guild), 
                        timeout=45.0
                    )

                    successful_guilds += 1
                    logger.info(f&quot;✅ {guild.name}: {len(synced)}개 명령어 동기화 완료&quot;)

                    # 길드 간 간격을 두어 레이트 리미트 방지
                    await asyncio.sleep(1)

                except asyncio.TimeoutError:
                    failed_guilds += 1
                    logger.error(f&quot;⏰ {guild.name} 동기화 타임아웃 (45초)&quot;)
                except Exception as guild_error:
                    failed_guilds += 1
                    logger.error(f&quot;❌ {guild.name} 동기화 실패: {guild_error}&quot;)

            if successful_guilds &amp;gt; 0:
                logger.info(f&quot;  성공: {successful_guilds}개 서버, 실패: {failed_guilds}개 서버&quot;)
                return True
            else:
                logger.error(&quot;❌ 모든 길드 동기화가 실패했습니다.&quot;)

        except Exception as e:
            logger.error(f&quot;❌ 길드 동기화 과정에서 오류 발생: {e}&quot;)

        if attempt &amp;lt; max_retries - 1:
            wait_time = 3 * (attempt + 1)  # 3초, 6초, 9초
            logger.info(f&quot;  {wait_time}초 후 재시도합니다...&quot;)
            await asyncio.sleep(wait_time)

    # 길드 동기화가 모두 실패한 경우 글로벌로 폴백
    logger.warning(&quot;⚠️  길드 동기화 실패, 글로벌 명령어로 폴백합니다...&quot;)
    return await sync_global_commands()

@bot.tree.command(name=&quot;play&quot;, description=&quot;디스호스트 음악봇 - 음악을 재생합니다&quot;)
@app_commands.describe(query=&quot;재생할 음악의 제목이나 YouTube URL&quot;)
async def play_slash(interaction: discord.Interaction, query: str):
    await interaction.response.defer()

    # 사용자가 음성 채널에 있는지 확인
    if not interaction.user.voice:
        await interaction.followup.send(&quot;❌ 음성 채널에 먼저 접속해주세요!&quot;)
        return

    channel = interaction.user.voice.channel
    guild_id = interaction.guild.id

    # 봇이 음성 채널에 연결되어 있지 않으면 연결
    if guild_id not in voice_clients:
        try:
            voice_client = await channel.connect()
            voice_clients[guild_id] = voice_client
        except Exception as e:
            await interaction.followup.send(f&quot;❌ 음성 채널 연결 실패: {e}&quot;)
            return

    # YouTube에서 검색
    song_info = await search_youtube(query)
    if not song_info:
        await interaction.followup.send(&quot;❌ 검색 결과를 찾을 수 없습니다.&quot;)
        return

    # 큐에 추가
    if guild_id not in music_queues:
        music_queues[guild_id] = []

    # 현재 재생 중이 아니면 바로 재생, 아니면 큐에 추가
    if guild_id not in current_songs or not voice_clients[guild_id].is_playing():
        success = await play_song(voice_clients[guild_id], song_info, guild_id)
        if success:
            embed = discord.Embed(
                title=&quot;  재생 시작&quot;,
                description=f&quot;**{song_info['title']}**&quot;,
                color=0x00ff00
            )
            embed.add_field(name=&quot;⏱️ 재생 시간&quot;, value=song_info['duration'], inline=True)
            embed.add_field(name=&quot;  업로더&quot;, value=song_info['uploader'], inline=True)
            if song_info['thumbnail']:
                embed.set_thumbnail(url=song_info['thumbnail'])

            view = MusicView(guild_id)
            await interaction.followup.send(embed=embed, view=view)
        else:
            await interaction.followup.send(&quot;❌ 음악 재생에 실패했습니다.&quot;)
    else:
        music_queues[guild_id].append(song_info)
        embed = discord.Embed(
            title=&quot;➕ 큐에 추가됨&quot;,
            description=f&quot;**{song_info['title']}**&quot;,
            color=0x3498db
        )
        embed.add_field(name=&quot;  대기열 위치&quot;, value=f&quot;{len(music_queues[guild_id])}번째&quot;, inline=True)
        embed.add_field(name=&quot;⏱️ 재생 시간&quot;, value=song_info['duration'], inline=True)
        if song_info['thumbnail']:
            embed.set_thumbnail(url=song_info['thumbnail'])

        await interaction.followup.send(embed=embed)

@bot.tree.command(name=&quot;queue&quot;, description=&quot;디스호스트 음악봇 - 현재 음악 큐를 확인합니다&quot;)
async def queue_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id
    embed = get_queue_embed(guild_id)
    view = MusicView(guild_id)
    await interaction.response.send_message(embed=embed, view=view)

@bot.tree.command(name=&quot;skip&quot;, description=&quot;디스호스트 음악봇 - 현재 곡을 스킵합니다&quot;)
async def skip_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id

    if guild_id in voice_clients and voice_clients[guild_id].is_playing():
        voice_clients[guild_id].stop()
        await interaction.response.send_message(&quot;⏭️ 다음 곡으로 스킵합니다.&quot;)
    else:
        await interaction.response.send_message(&quot;❌ 재생 중인 음악이 없습니다.&quot;)

@bot.tree.command(name=&quot;pause&quot;, description=&quot;디스호스트 음악봇 - 음악을 일시정지합니다&quot;)
async def pause_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id

    if guild_id in voice_clients and voice_clients[guild_id].is_playing():
        voice_clients[guild_id].pause()
        await interaction.response.send_message(&quot;⏸️ 음악이 일시정지되었습니다.&quot;)
    else:
        await interaction.response.send_message(&quot;❌ 재생 중인 음악이 없습니다.&quot;)

@bot.tree.command(name=&quot;resume&quot;, description=&quot;디스호스트 음악봇 - 일시정지된 음악을 재개합니다&quot;)
async def resume_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id

    if guild_id in voice_clients and voice_clients[guild_id].is_paused():
        voice_clients[guild_id].resume()
        await interaction.response.send_message(&quot;▶️ 음악이 재개되었습니다.&quot;)
    else:
        await interaction.response.send_message(&quot;❌ 일시정지된 음악이 없습니다.&quot;)

@bot.tree.command(name=&quot;stop&quot;, description=&quot;디스호스트 음악봇 - 음악을 정지하고 큐를 초기화합니다&quot;)
async def stop_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id

    if guild_id in music_queues:
        music_queues[guild_id].clear()

    if guild_id in voice_clients:
        voice_clients[guild_id].stop()

    if guild_id in current_songs:
        del current_songs[guild_id]

    await interaction.response.send_message(&quot;⏹️ 음악이 정지되고 큐가 초기화되었습니다.&quot;)

@bot.tree.command(name=&quot;join&quot;, description=&quot;디스호스트 음악봇 - 음성 채널에 참가합니다&quot;)
async def join_slash(interaction: discord.Interaction):
    # 사용자가 음성 채널에 있는지 확인
    if not interaction.user.voice:
        await interaction.response.send_message(&quot;❌ 음성 채널에 먼저 접속해주세요!&quot;)
        return

    channel = interaction.user.voice.channel
    guild_id = interaction.guild.id

    # 이미 연결되어 있는지 확인
    if guild_id in voice_clients:
        if voice_clients[guild_id].channel == channel:
            await interaction.response.send_message(&quot;✅ 이미 해당 음성 채널에 연결되어 있습니다.&quot;)
            return
        else:
            # 다른 채널에 연결되어 있으면 새 채널로 이동
            await voice_clients[guild_id].move_to(channel)
            await interaction.response.send_message(f&quot;  **{channel.name}** 채널로 이동했습니다.&quot;)
            return

    # 음성 채널에 연결
    try:
        voice_client = await channel.connect()
        voice_clients[guild_id] = voice_client
        await interaction.response.send_message(f&quot;✅ **{channel.name}** 채널에 참가했습니다!&quot;)
    except Exception as e:
        await interaction.response.send_message(f&quot;❌ 음성 채널 연결 실패: {e}&quot;)

@bot.tree.command(name=&quot;leave&quot;, description=&quot;디스호스트 음악봇 - 음성 채널에서 나갑니다&quot;)
async def leave_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id

    if guild_id in voice_clients:
        await voice_clients[guild_id].disconnect()
        del voice_clients[guild_id]

        if guild_id in music_queues:
            del music_queues[guild_id]
        if guild_id in current_songs:
            del current_songs[guild_id]
        if guild_id in loop_modes:
            del loop_modes[guild_id]

        await interaction.response.send_message(&quot;  음성 채널에서 나갔습니다.&quot;)
    else:
        await interaction.response.send_message(&quot;❌ 음성 채널에 연결되어 있지 않습니다.&quot;)

@bot.tree.command(name=&quot;loop&quot;, description=&quot;디스호스트 음악봇 - 반복 모드를 설정합니다&quot;)
@app_commands.describe(mode=&quot;반복 모드 (0: 반복없음, 1: 현재곡 반복, 2: 큐 반복)&quot;)
@app_commands.choices(mode=[
    app_commands.Choice(name=&quot;반복 없음&quot;, value=0),
    app_commands.Choice(name=&quot;현재 곡 반복&quot;, value=1),
    app_commands.Choice(name=&quot;큐 반복&quot;, value=2)
])
async def loop_slash(interaction: discord.Interaction, mode: int):
    guild_id = interaction.guild.id
    loop_modes[guild_id] = mode

    mode_text = [&quot;  반복 없음&quot;, &quot;  현재 곡 반복&quot;, &quot;  큐 반복&quot;][mode]
    await interaction.response.send_message(f&quot;반복 모드가 **{mode_text}**로 설정되었습니다.&quot;)

@bot.tree.command(name=&quot;clear&quot;, description=&quot;디스호스트 음악봇 - 음악 큐를 모두 지웁니다&quot;)
async def clear_slash(interaction: discord.Interaction):
    guild_id = interaction.guild.id

    if guild_id in music_queues:
        cleared_count = len(music_queues[guild_id])
        music_queues[guild_id].clear()
        await interaction.response.send_message(f&quot; ️ 큐에서 {cleared_count}곡이 제거되었습니다.&quot;)
    else:
        await interaction.response.send_message(&quot;❌ 큐가 이미 비어있습니다.&quot;)

@bot.tree.command(name=&quot;help&quot;, description=&quot;디스호스트 음악봇 - 도움말을 표시합니다&quot;)
async def help_slash(interaction: discord.Interaction):
    embed = discord.Embed(title=&quot;  디스호스트 음악봇 도움말&quot;, color=0x3498db)

    commands_text = &quot;&quot;&quot;
    `/play &amp;lt;검색어 또는 URL&amp;gt;` - 음악 재생 또는 큐에 추가
    `/join` - 음성 채널에 참가
    `/queue` - 현재 큐 확인
    `/skip` - 현재 곡 스킵
    `/pause` - 일시정지
    `/resume` - 재생 재개
    `/stop` - 정지 및 큐 초기화
    `/loop &amp;lt;모드&amp;gt;` - 반복 모드 설정
    `/clear` - 큐 비우기
    `/leave` - 음성 채널에서 나가기
    `/status` - 봇 상태 및 YouTube 연결 확인
    `/help` - 이 도움말 표시
    &quot;&quot;&quot;

    embed.add_field(name=&quot;  명령어 목록&quot;, value=commands_text, inline=False)
    embed.add_field(name=&quot;  반복 모드&quot;, value=&quot;0: 반복없음\n1: 현재곡 반복\n2: 큐 반복&quot;, inline=True)
    embed.add_field(name=&quot;  지원 플랫폼&quot;, value=&quot;YouTube (쿠키 기반)&quot;, inline=True)
    embed.set_footer(text=&quot;YT-DLP (쿠키) &amp;bull; 디스호스트 음악봇 v2.0&quot;)

    await interaction.response.send_message(embed=embed)

@bot.tree.command(name=&quot;status&quot;, description=&quot;디스호스트 음악봇 - 봇과 YouTube 연결 상태를 확인합니다&quot;)
async def status_slash(interaction: discord.Interaction):
    await interaction.response.defer()

    embed = discord.Embed(
        title=&quot;  디스호스트 음악봇 상태&quot;,
        color=0x3498db
    )

    # 기본 정보
    embed.add_field(name=&quot;  봇 상태&quot;, value=&quot;✅ 온라인&quot;, inline=True)
    embed.add_field(name=&quot;  지연시간&quot;, value=f&quot;{round(bot.latency * 1000)}ms&quot;, inline=True)
    embed.add_field(name=&quot;  음성 연결&quot;, value=f&quot;{len(bot.voice_clients)}개 서버&quot;, inline=True)

    # 쿠키 파일 상태 확인
    import os
    cookie_status = &quot;✅ 사용 가능&quot; if os.path.exists('cookies.txt') else &quot;❌ 없음&quot;
    embed.add_field(name=&quot;  쿠키 파일&quot;, value=cookie_status, inline=True)

    # YouTube 연결 테스트
    try:
        test_options = ytdlp_format_options.copy()
        test_options['quiet'] = True
        test_options['no_warnings'] = True

        with yt_dlp.YoutubeDL(test_options) as ydl:
            # 간단한 YouTube 비디오로 테스트
            test_info = await asyncio.to_thread(ydl.extract_info, &quot;ytsearch:test&quot;, download=False)

        if test_info and 'entries' in test_info and test_info['entries']:
            youtube_status = &quot;✅ 정상&quot;
            extraction_method = &quot;YT-DLP (쿠키)&quot;
        else:
            youtube_status = &quot;⚠️ 제한적&quot;
            extraction_method = &quot;제한된 접근&quot;

    except Exception as e:
        youtube_status = &quot;❌ 오류&quot;
        extraction_method = f&quot;오류: {str(e)[:50]}&quot;

    embed.add_field(name=&quot;  YouTube 상태&quot;, value=youtube_status, inline=True)
    embed.add_field(name=&quot;  추출 방법&quot;, value=extraction_method, inline=True)

    # 서버별 재생 상태
    active_players = len([guild_id for guild_id in current_songs.keys()])
    embed.add_field(name=&quot;  활성 플레이어&quot;, value=f&quot;{active_players}개&quot;, inline=True)

    # 큐 정보
    total_queued = sum(len(queue) for queue in music_queues.values())
    embed.add_field(name=&quot;  대기열 총 곡수&quot;, value=f&quot;{total_queued}곡&quot;, inline=True)

    embed.set_footer(text=&quot;디스호스트 음악봇 v2.0 - YT-DLP (쿠키) 기반&quot;)

    await interaction.followup.send(embed=embed)

# 텍스트 명령어들 (호환성을 위해 유지)
@bot.command(name='play', aliases=['p'])
async def play_text(ctx, *, query):
    &quot;&quot;&quot;텍스트 명령어로 음악 재생&quot;&quot;&quot;
    if not ctx.author.voice:
        await ctx.send(&quot;❌ 음성 채널에 먼저 접속해주세요!&quot;)
        return

    channel = ctx.author.voice.channel
    guild_id = ctx.guild.id

    # 봇이 음성 채널에 연결되어 있지 않으면 연결
    if guild_id not in voice_clients:
        try:
            voice_client = await channel.connect()
            voice_clients[guild_id] = voice_client
        except Exception as e:
            await ctx.send(f&quot;❌ 음성 채널 연결 실패: {e}&quot;)
            return

    # YouTube에서 검색
    song_info = await search_youtube(query)
    if not song_info:
        await ctx.send(&quot;❌ 검색 결과를 찾을 수 없습니다.&quot;)
        return

    # 큐에 추가
    if guild_id not in music_queues:
        music_queues[guild_id] = []

    # 현재 재생 중이 아니면 바로 재생, 아니면 큐에 추가
    if guild_id not in current_songs or not voice_clients[guild_id].is_playing():
        success = await play_song(voice_clients[guild_id], song_info, guild_id)
        if success:
            await ctx.send(f&quot;  재생 시작: **{song_info['title']}**&quot;)
        else:
            await ctx.send(&quot;❌ 음악 재생에 실패했습니다.&quot;)
    else:
        music_queues[guild_id].append(song_info)
        await ctx.send(f&quot;➕ 큐에 추가됨: **{song_info['title']}** (대기열 {len(music_queues[guild_id])}번째)&quot;)

@bot.command(name='skip', aliases=['s'])
async def skip_text(ctx):
    guild_id = ctx.guild.id
    if guild_id in voice_clients and voice_clients[guild_id].is_playing():
        voice_clients[guild_id].stop()
        await ctx.send(&quot;⏭️ 다음 곡으로 스킵합니다.&quot;)
    else:
        await ctx.send(&quot;❌ 재생 중인 음악이 없습니다.&quot;)

@bot.command(name='queue', aliases=['q'])
async def queue_text(ctx):
    guild_id = ctx.guild.id
    embed = get_queue_embed(guild_id)
    await ctx.send(embed=embed)

@bot.command(name='pause')
async def pause_text(ctx):
    guild_id = ctx.guild.id
    if guild_id in voice_clients and voice_clients[guild_id].is_playing():
        voice_clients[guild_id].pause()
        await ctx.send(&quot;⏸️ 음악이 일시정지되었습니다.&quot;)
    else:
        await ctx.send(&quot;❌ 재생 중인 음악이 없습니다.&quot;)

@bot.command(name='resume')
async def resume_text(ctx):
    guild_id = ctx.guild.id
    if guild_id in voice_clients and voice_clients[guild_id].is_paused():
        voice_clients[guild_id].resume()
        await ctx.send(&quot;▶️ 음악이 재개되었습니다.&quot;)
    else:
        await ctx.send(&quot;❌ 일시정지된 음악이 없습니다.&quot;)

@bot.command(name='stop')
async def stop_text(ctx):
    guild_id = ctx.guild.id

    if guild_id in music_queues:
        music_queues[guild_id].clear()

    if guild_id in voice_clients:
        voice_clients[guild_id].stop()

    if guild_id in current_songs:
        del current_songs[guild_id]

    await ctx.send(&quot;⏹️ 음악이 정지되고 큐가 초기화되었습니다.&quot;)

@bot.command(name='join', aliases=['connect'])
async def join_text(ctx):
    &quot;&quot;&quot;텍스트 명령어로 음성 채널 참가&quot;&quot;&quot;
    if not ctx.author.voice:
        await ctx.send(&quot;❌ 음성 채널에 먼저 접속해주세요!&quot;)
        return

    channel = ctx.author.voice.channel
    guild_id = ctx.guild.id

    # 이미 연결되어 있는지 확인
    if guild_id in voice_clients:
        if voice_clients[guild_id].channel == channel:
            await ctx.send(&quot;✅ 이미 해당 음성 채널에 연결되어 있습니다.&quot;)
            return
        else:
            # 다른 채널에 연결되어 있으면 새 채널로 이동
            await voice_clients[guild_id].move_to(channel)
            await ctx.send(f&quot;  **{channel.name}** 채널로 이동했습니다.&quot;)
            return

    # 음성 채널에 연결
    try:
        voice_client = await channel.connect()
        voice_clients[guild_id] = voice_client
        await ctx.send(f&quot;✅ **{channel.name}** 채널에 참가했습니다!&quot;)
    except Exception as e:
        await ctx.send(f&quot;❌ 음성 채널 연결 실패: {e}&quot;)

@bot.command(name='leave', aliases=['disconnect'])
async def leave_text(ctx):
    guild_id = ctx.guild.id

    if guild_id in voice_clients:
        await voice_clients[guild_id].disconnect()
        del voice_clients[guild_id]

        if guild_id in music_queues:
            del music_queues[guild_id]
        if guild_id in current_songs:
            del current_songs[guild_id]
        if guild_id in loop_modes:
            del loop_modes[guild_id]

        await ctx.send(&quot;  음성 채널에서 나갔습니다.&quot;)
    else:
        await ctx.send(&quot;❌ 음성 채널에 연결되어 있지 않습니다.&quot;)

if __name__ == &quot;__main__&quot;:
    if not TOKEN:
        logger.error(&quot;Discord 토큰이 설정되지 않았습니다. .env 파일을 확인해주세요.&quot;)
    else:
        bot.run(TOKEN)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디스호스트에 봇 호스팅하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 완성된 봇을 디스호스트에 호스팅해보겠습니다. 디스호스트는 Discord 봇을 쉽게 호스팅할 수 있는 플랫폼입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 디스호스트 계정 생성 및 로그인&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;754&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GYfJE/btsOokRd3H8/mqOJ3EnELHFXAEYIciZA50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GYfJE/btsOokRd3H8/mqOJ3EnELHFXAEYIciZA50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GYfJE/btsOokRd3H8/mqOJ3EnELHFXAEYIciZA50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGYfJE%2FbtsOokRd3H8%2FmqOJ3EnELHFXAEYIciZA50%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1026&quot; height=&quot;754&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;754&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 디스호스트에 로그인해야합니다. 디스호스트 계정이 없다면 &lt;a href=&quot;https://dishost.kr/&quot;&gt;디스호스트 공식 웹사이트&lt;/a&gt;에서 계정을 생성하세요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 크레딧 충전&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트에서 봇을 24시간 운영하려면 크레딧을 충전해야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2274&quot; data-origin-height=&quot;748&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oGLgh/btsOpfOU18F/Q8GEldpDXrPPdhpqMT6Q50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oGLgh/btsOpfOU18F/Q8GEldpDXrPPdhpqMT6Q50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oGLgh/btsOpfOU18F/Q8GEldpDXrPPdhpqMT6Q50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoGLgh%2FbtsOpfOU18F%2FQ8GEldpDXrPPdhpqMT6Q50%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2274&quot; height=&quot;748&quot; data-origin-width=&quot;2274&quot; data-origin-height=&quot;748&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;대시보드 접속&lt;/b&gt;: &lt;a href=&quot;https://dishost.kr&quot;&gt;디스호스트 웹사이트&lt;/a&gt;에 로그인하여 대시보드로 이동합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;크레딧 추가 메뉴&lt;/b&gt;: 왼쪽 사이드바에서 '크레딧 추가' 메뉴를 클릭합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;충전 금액 선택&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사전 설정된 금액(10,000원, 50,000원, 100,000원 등) 중 선택하거나&lt;/li&gt;
&lt;li&gt;직접 원하는 금액을 입력합니다 (최소 1,000원)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;충전 방법 선택&lt;/b&gt;: 현재 지원되는 충전 방법은 다음과 같습니다:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;계좌이체&lt;/b&gt;: 실시간 계좌이체 (수수료 없음)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입금자명을 정확히 입력&lt;/li&gt;
&lt;li&gt;표시된 계좌로 정확한 금액을 5분 이내에 입금&lt;/li&gt;
&lt;li&gt;'입금 완료 확인하기' 버튼 클릭하여 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;문화상품권&lt;/b&gt;: 문화상품권 핀번호 입력 (10% 수수료 적용)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상품권 핀번호를 정확히 입력&lt;/li&gt;
&lt;li&gt;실제 충전 크레딧 = 상품권 금액의 90%&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 봇 관리 패널 연동&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스호스트에서 봇을 관리하기 위해서는 봇 관리 패널 계정을 연동해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패널 연동 절차&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2252&quot; data-origin-height=&quot;582&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VccuC/btsOoEOYd69/mo1coqno2GwGFENuKjQ1Y0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VccuC/btsOoEOYd69/mo1coqno2GwGFENuKjQ1Y0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VccuC/btsOoEOYd69/mo1coqno2GwGFENuKjQ1Y0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVccuC%2FbtsOoEOYd69%2Fmo1coqno2GwGFENuKjQ1Y0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2252&quot; height=&quot;582&quot; data-origin-width=&quot;2252&quot; data-origin-height=&quot;582&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;대시보드 접속&lt;/b&gt;: 디스호스트 웹사이트 로그인 후 대시보드 진입&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 정보 탭&lt;/b&gt;: 좌측 사이드바에서 '사용자 정보' 메뉴 선택&lt;/li&gt;
&lt;li&gt;&lt;b&gt;계정 연동 설정&lt;/b&gt;: 'Pterodactyl 계정 연동' 섹션에서 다음 정보 입력
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;이메일&lt;/b&gt;: Pterodactyl 계정 전용 이메일 주소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자명&lt;/b&gt;: 패널 로그인 ID&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비밀번호&lt;/b&gt;: 강력한 보안 비밀번호 (특수문자, 대소문자, 숫자 조합)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;계정 생성&lt;/b&gt;: '계정 생성' 버튼 클릭하여 연동 완료&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;중요 보안 고려사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;경고&lt;/b&gt;: Pterodactyl 계정은 연동 해제가 불가능하며, 로그인 정보는 대시보드에서 재확인할 수 없습니다. 입력한 정보를 안전한 장소에 기록하여 보관하시기 바랍니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 봇 서버 생성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크레딧 충전과 패널 연동이 완료된 후 봇 서버를 생성합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 생성 절차&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;596&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/daMxOe/btsOpFNkkHk/oS1iYpItwkfVbL2FFqApf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/daMxOe/btsOpFNkkHk/oS1iYpItwkfVbL2FFqApf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/daMxOe/btsOpFNkkHk/oS1iYpItwkfVbL2FFqApf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdaMxOe%2FbtsOpFNkkHk%2FoS1iYpItwkfVbL2FFqApf1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1584&quot; height=&quot;596&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;596&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;봇 관리 페이지&lt;/b&gt;: 대시보드에서 '내 봇' 탭으로 이동&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새 서버 추가&lt;/b&gt;: '새 서버 추가하기' 버튼 클릭&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개발 환경 선택&lt;/b&gt;: Python 언어 환경 선택&lt;/li&gt;
&lt;li&gt;&lt;b&gt;리소스 플랜 선택&lt;/b&gt;: 봇 규모에 적합한 플랜 선택
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Starter&lt;/b&gt;: 소규모 봇을 위한 기본 플랜&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Standard&lt;/b&gt;: 중간 규모 봇을 위한 표준 플랜&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Enterprise&lt;/b&gt;: 대규모 봇을 위한 고급 플랜&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버 구매&lt;/b&gt;: '봇 구매하기' 버튼을 통해 결제 진행&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1882&quot; data-origin-height=&quot;1234&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQjjMQ/btsOqftxrBs/Tuk5vanrxK3JkcBFUfn5Mk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQjjMQ/btsOqftxrBs/Tuk5vanrxK3JkcBFUfn5Mk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQjjMQ/btsOqftxrBs/Tuk5vanrxK3JkcBFUfn5Mk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQjjMQ%2FbtsOqftxrBs%2FTuk5vanrxK3JkcBFUfn5Mk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1882&quot; height=&quot;1234&quot; data-origin-width=&quot;1882&quot; data-origin-height=&quot;1234&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서버 생성 대기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 생성 프로세스는 일반적으로 1-3분 소요되며, 완료 시 성공 메시지가 표시됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Pterodactyl 패널을 통한 봇 배포&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패널 접속&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;패널 접속&lt;/b&gt;: &lt;a href=&quot;https://panel.dishost.kr&quot;&gt;https://panel.dishost.kr&lt;/a&gt;로 이동&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로그인&lt;/b&gt;: 앞서 설정한 사용자명과 비밀번호로 인증&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버 선택&lt;/b&gt;: 생성된 봇 서버를 목록에서 선택&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;봇 코드 업로드&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;업로드 절차&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1338&quot; data-origin-height=&quot;380&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cuKQXQ/btsOo8vShF1/7GOQOjrv0GGWyxBzD8BgN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cuKQXQ/btsOo8vShF1/7GOQOjrv0GGWyxBzD8BgN0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cuKQXQ/btsOo8vShF1/7GOQOjrv0GGWyxBzD8BgN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcuKQXQ%2FbtsOo8vShF1%2F7GOQOjrv0GGWyxBzD8BgN0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1338&quot; height=&quot;380&quot; data-origin-width=&quot;1338&quot; data-origin-height=&quot;380&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;파일 관리자&lt;/b&gt;: 서버 관리 페이지에서 'Files' 탭 선택&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;774&quot; data-origin-height=&quot;222&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDkju5/btsOpg8hOzJ/gjVOrZn2gL76K3FonI66r1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDkju5/btsOpg8hOzJ/gjVOrZn2gL76K3FonI66r1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDkju5/btsOpg8hOzJ/gjVOrZn2gL76K3FonI66r1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDkju5%2FbtsOpg8hOzJ%2FgjVOrZn2gL76K3FonI66r1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;774&quot; height=&quot;222&quot; data-origin-width=&quot;774&quot; data-origin-height=&quot;222&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파일 생성&lt;/b&gt;: 새 파일 버튼 클릭하여 파일 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;봇 붙여넣기&lt;/b&gt;: 만든 봇을 붙여넣기&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;796&quot; data-origin-height=&quot;430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/olPKV/btsOqszwz3v/uzOOkky03JdXN4VNoY2uoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/olPKV/btsOqszwz3v/uzOOkky03JdXN4VNoY2uoK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/olPKV/btsOqszwz3v/uzOOkky03JdXN4VNoY2uoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FolPKV%2FbtsOqszwz3v%2FuzOOkky03JdXN4VNoY2uoK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;796&quot; height=&quot;430&quot; data-origin-width=&quot;796&quot; data-origin-height=&quot;430&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;저장&lt;/b&gt;: 파일 생성 버튼을 눌러 저장, 이름은 app.py로 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;봇 시작 설정&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2456&quot; data-origin-height=&quot;974&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mC2Ri/btsOoNrXf1Q/nFmXl9wQK08IljUeSkavsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mC2Ri/btsOoNrXf1Q/nFmXl9wQK08IljUeSkavsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mC2Ri/btsOoNrXf1Q/nFmXl9wQK08IljUeSkavsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmC2Ri%2FbtsOoNrXf1Q%2FnFmXl9wQK08IljUeSkavsK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2456&quot; height=&quot;974&quot; data-origin-width=&quot;2456&quot; data-origin-height=&quot;974&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Startup 탭&lt;/b&gt;: 서버 관리 페이지에서 'Startup' 탭 선택&lt;/li&gt;
&lt;li&gt;&lt;b&gt;시작 파일 설정&lt;/b&gt;: 'STARTUP FILE' 필드에 메인 Python 파일명 입력 (예: app.py)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;의존성 설정&lt;/b&gt;: 'Additional Python packages' 필드에 필요한 패키지 입력
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;discord.py&lt;/li&gt;
&lt;li&gt;yt-dlp&lt;/li&gt;
&lt;li&gt;PyNaCl&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설정 저장&lt;/b&gt;: 'Save' 버튼으로 변경사항 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;봇 실행&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;콘솔 접속&lt;/b&gt;: 'Console' 탭으로 이동&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버 시작&lt;/b&gt;: 'Start' 버튼 클릭하여 봇 실행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로그 모니터링&lt;/b&gt;: 콘솔 출력을 통해 정상 시작 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태 검증&lt;/b&gt;: Discord에서 봇의 온라인 상태 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;운영 고려사항&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;자동 연장 없음&lt;/b&gt;: 봇 서버는 자동 연장되지 않으므로 만료 전 수동 연장 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;만료 후 유예기간&lt;/b&gt;: 서버 데이터는 만료 후 7일간 보존&lt;/li&gt;
&lt;li&gt;&lt;b&gt;연장 권장시점&lt;/b&gt;: 만료일 최소 3일 전 연장 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 실행되면 봇이 온라인 상태로 표시되며, Discord 서버에서 슬래시 명령어를 사용할 수 있습니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.py</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/43</guid>
      <comments>https://dishost.tistory.com/43#entry43comment</comments>
      <pubDate>Wed, 4 Jun 2025 15:42:10 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 2. 명령어 구조 만들기: 슬래시 명령어를 위한 첫걸음</title>
      <link>https://dishost.tistory.com/30</link>
      <description>&lt;p&gt;해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. &lt;a href=&quot;https://github.com/kochanhyun/discordjs_typescript_boilerplate&quot;&gt;Discord.js TypeScript Boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;지난 시간에는 우리 봇에게 간단한 상태 메시지를 설정해서 조금이나마 생기를 불어넣어 봤습니다. 아직 우리 봇은 &amp;quot;핑&amp;quot;이라고 말을 걸면 &amp;quot;퐁!&amp;quot;하고 대답하는 정도의 아주 기본적인 기능만 가지고 있죠. 봇의 기능이 점점 많아지면 &lt;code&gt;index.ts&lt;/code&gt; 파일 하나에 모든 코드를 다 넣는 건 좋은 생각이 아닙니다. 코드가 길어지고 복잡해지면 관리하기가 여간 어려운 게 아니거든요.&lt;/p&gt;
&lt;p&gt;그래서 이번 시간에는 앞으로 우리가 만들 다양한 명령어들을 효과적으로 관리할 수 있도록 &amp;#39;명령어 구조&amp;#39;를 잡아보는 시간을 갖겠습니다. 특히 이 구조는 다음 시간에 다룰 &amp;#39;슬래시 명령어&amp;#39;를 깔끔하게 처리하기 위한 중요한 밑거름이 될 겁니다.&lt;/p&gt;
&lt;h2&gt;왜 명령어 구조를 잡아야 할까요?&lt;/h2&gt;
&lt;p&gt;봇을 처음 만들 때는 한두 가지 간단한 명령어로 시작하겠지만, 기능을 하나둘 추가하다 보면 &lt;code&gt;index.ts&lt;/code&gt; 파일이 금방 수백, 수천 줄로 늘어날 수 있습니다. 이렇게 되면 다음과 같은 문제들이 생길 수 있어요.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;가독성 저하&lt;/strong&gt;: 원하는 코드를 찾기가 너무 힘들어집니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;유지보수 어려움&lt;/strong&gt;: 코드 한 부분을 수정했을 때 다른 부분에 어떤 영향을 줄지 파악하기 어렵습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;협업의 걸림돌&lt;/strong&gt;: 여러 명이 함께 봇을 개발한다면, 파일 하나를 계속 같이 수정하는 건 충돌의 연속일 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;확장성 문제&lt;/strong&gt;: 새로운 명령어를 추가할 때마다 &lt;code&gt;index.ts&lt;/code&gt; 파일을 건드려야 하고, 점점 더 복잡해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 문제들을 해결하기 위해, 각 명령어 로직을 별도의 파일로 분리하고, 이를 체계적으로 불러와 사용하는 구조를 만드는 것이 중요합니다. 우리가 참고하고 있는 &lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;가 바로 이런 구조를 잘 보여주고 있습니다.&lt;/p&gt;
&lt;h2&gt;명령어 파일의 기본 뼈대: &lt;code&gt;commands&lt;/code&gt; 폴더와 파일 구조&lt;/h2&gt;
&lt;p&gt;우리 프로젝트의 &lt;code&gt;src&lt;/code&gt; 폴더 안에 &lt;code&gt;commands&lt;/code&gt; 라는 새 폴더를 만들어줍시다. 앞으로 모든 명령어 관련 파일들은 이 폴더 안에 차곡차곡 정리할 거예요. 이미 &lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;를 사용하고 있다면 이 폴더가 존재할 겁니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 터미널에서 프로젝트 루트 경로로 이동한 후 (src 폴더가 없다면 생성)
# mkdir src
cd src
mkdir commands # 이미 있다면 이 명령어는 생략
cd ..&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 명령어 파일은 TypeScript 파일(&lt;code&gt;.ts&lt;/code&gt;)로 만들고, 특정한 형식을 따르도록 할 겁니다. &lt;code&gt;discordjs_typescript_boilerplate/src/commands/ping.ts&lt;/code&gt; 파일을 살펴보면 좋은 예시가 됩니다. 모든 명령어 파일은 기본적으로 다음 두 가지 요소를 내보내야(export) 합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;data&lt;/code&gt;: 해당 명령어가 어떤 이름과 설명을 가졌는지, 어떤 옵션들을 받을 수 있는지 등을 정의하는 부분입니다. 슬래시 명령어에서는 &lt;code&gt;SlashCommandBuilder&lt;/code&gt;를 사용해서 이 부분을 구성합니다.&lt;br&gt;&lt;code&gt;execute&lt;/code&gt;: 명령어가 실제로 실행될 때 호출될 함수입니다. 이 함수는 사용자의 입력(&lt;code&gt;interaction&lt;/code&gt;)을 받아 적절한 응답을 처리합니다.&lt;/p&gt;
&lt;p&gt;예를 들어, &lt;code&gt;ping&lt;/code&gt; 명령어를 위한 &lt;code&gt;src/commands/ping.ts&lt;/code&gt; 파일은 다음과 같은 모습일 겁니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/ping.ts
import { SlashCommandBuilder, ChatInputCommandInteraction } from &amp;quot;discord.js&amp;quot;;

// 명령어의 기본 정보를 정의합니다.
export const data = new SlashCommandBuilder()
  .setName(&amp;quot;핑&amp;quot;) // 슬래시 명령어 이름 (한글도 가능하지만, 보통 영어 소문자를 권장)
  .setDescription(&amp;quot;봇의 응답 속도를 확인합니다.&amp;quot;); // 명령어에 대한 설명

// 명령어가 실행될 때 호출될 함수입니다.
export async function execute(interaction: ChatInputCommandInteraction) {
  // interaction.reply()는 명령어에 대한 첫 응답을 보냅니다.
  // ephemeral: true 옵션을 주면 명령어 사용자에게만 보이는 메시지를 보낼 수 있습니다.
  const sentMessage = await interaction.reply({
    content: &amp;quot;퐁! 응답 속도를 계산하고 있어요...&amp;quot;,
    fetchReply: true,
  });

  // fetchReply: true로 응답 메시지 객체를 받아온 후, editReply로 내용을 수정할 수 있습니다.
  // 여기서는 실제 응답 속도를 계산해서 보여줍니다.
  const latency = sentMessage.createdTimestamp - interaction.createdTimestamp;
  await interaction.editReply(
    `퐁!   응답 속도는 \\${latency}ms 입니다. API 지연 시간은 약 \\${Math.round(
      interaction.client.ws.ping
    )}ms 입니다.`
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 코드에서 &lt;code&gt;setName&lt;/code&gt;에 한글을 사용했지만, 디스코드의 정책이나 호환성을 위해 일반적으로 영어 소문자만 사용하는 것을 권장합니다. 여기서는 설명을 위해 한글 이름을 사용했습니다.&lt;/p&gt;
&lt;h2&gt;명령어들을 한곳에 모으기: &lt;code&gt;src/commands/index.ts&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;개별 명령어 파일들을 만들었다면, 이제 이들을 한 곳에서 관리하고 쉽게 불러올 수 있도록 해야 합니다. &lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;의 &lt;code&gt;src/commands/index.ts&lt;/code&gt; 파일이 이 역할을 합니다.&lt;/p&gt;
&lt;p&gt;이 파일은 &lt;code&gt;commands&lt;/code&gt; 폴더 안에 있는 모든 개별 명령어 모듈들을 가져와서(&lt;code&gt;import&lt;/code&gt;) 하나의 객체로 묶어 내보냅니다(&lt;code&gt;export&lt;/code&gt;). 이렇게 하면 나중에 봇의 메인 파일(&lt;code&gt;src/index.ts&lt;/code&gt;)이나 명령어 배포 스크립트(&lt;code&gt;src/deploy-commands.ts&lt;/code&gt;)에서 모든 명령어에 쉽게 접근할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate/src/commands/index.ts&lt;/code&gt;는 다음과 같이 작성되어 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/commands/index.ts
import * as ping from &amp;quot;./ping&amp;quot;;
// 만약 다른 명령어가 있다면 여기에 추가합니다.
// import * as serverInfo from &amp;quot;./serverInfo&amp;quot;;

export const commands = {
  ping,
  // serverInfo,
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;새로운 명령어를 추가할 때마다 이 파일에 해당 명령어를 &lt;code&gt;import&lt;/code&gt;하고 &lt;code&gt;commands&lt;/code&gt; 객체에 추가해주면 됩니다.&lt;/p&gt;
&lt;h2&gt;메인 파일에서 명령어 처리하기: &lt;code&gt;src/index.ts&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;이제 &lt;code&gt;src/index.ts&lt;/code&gt; 파일에서 사용자의 상호작용(Interaction)이 발생했을 때, 우리가 정의한 명령어를 찾아 실행하도록 만들어야 합니다. 슬래시 명령어는 &lt;code&gt;Events.InteractionCreate&lt;/code&gt; 이벤트를 통해 처리됩니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate/src/index.ts&lt;/code&gt; 파일의 &lt;code&gt;client.on(Events.InteractionCreate, ...)&lt;/code&gt; 부분을 보면 이 과정을 확인할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts (일부 발췌)
// ... (클라이언트 생성 및 다른 이벤트 핸들러) ...

client.on(Events.InteractionCreate, async (interaction) =&amp;gt; {
    try {
        // 슬래시 커맨드인지 확인합니다.
        if (!interaction.isChatInputCommand()) return;

        // commands 객체에서 명령어 이름으로 해당 명령어 모듈을 가져옵니다.
        const command = commands[interaction.commandName as keyof typeof commands];

        // 명령어가 존재하지 않으면 아무것도 하지 않습니다.
        if (!command) {
            console.error(\`No command matching \\${interaction.commandName} was found.\\`);
            return;
        }

        // 명령어의 execute 함수를 실행합니다.
        await command.execute(interaction);

    } catch (error) {
        console.error(&amp;#39;Error handling interaction:&amp;#39;, error);
        // 사용자에게 오류 메시지를 보낼 수도 있습니다.
        if (interaction.isRepliable()) {
            await interaction.reply({ content: &amp;#39;명령어 실행 중 오류가 발생했습니다.&amp;#39;, ephemeral: true });
        }
    }
});

// ... (봇 로그인) ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 코드는 사용자가 슬래시 명령어를 입력하면, 해당 명령어 이름과 일치하는 모듈을 &lt;code&gt;commands&lt;/code&gt; 객체에서 찾아 &lt;code&gt;execute&lt;/code&gt; 함수를 실행합니다. 오류가 발생하면 콘솔에 기록하고, 사용자에게 간단한 오류 메시지를 보낼 수도 있습니다.&lt;/p&gt;
&lt;h2&gt;슬래시 명령어 등록: &lt;code&gt;src/deploy-commands.ts&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;이렇게 명령어 파일을 만들고 &lt;code&gt;index.ts&lt;/code&gt;에 연결했다고 해서 바로 디스코드에서 슬래시 명령어를 사용할 수 있는 것은 아닙니다. 슬래시 명령어는 우리가 만든 명령어 정보를 디스코드 서버에 &amp;quot;우리 봇은 이런 명령어들을 가지고 있어요!&amp;quot;라고 &lt;strong&gt;등록&lt;/strong&gt;해주는 과정이 필요합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt; 프로젝트의 &lt;code&gt;src/deploy-commands.ts&lt;/code&gt; 파일이 바로 이 역할을 합니다. 이 스크립트는 &lt;code&gt;src/commands/index.ts&lt;/code&gt;에 정의된 모든 명령어들의 &lt;code&gt;data&lt;/code&gt; 부분을 읽어서 디스코드 API를 통해 특정 서버 또는 모든 서버에 명령어들을 등록합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;deploy-commands.ts&lt;/code&gt;의 핵심 로직은 다음과 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/deploy-commands.ts (일부 발췌)
import { REST, Routes } from &amp;quot;discord.js&amp;quot;;
import { config } from &amp;quot;./config&amp;quot;; // DISCORD_TOKEN, DISCORD_CLIENT_ID 등이 담긴 설정 파일
import { commands } from &amp;quot;./commands&amp;quot;; // 우리가 정의한 명령어 모듈들

// 등록할 명령어들의 data 속성만 추출합니다.
const commandsData = Object.values(commands).map((command) =&amp;gt; command.data.toJSON());

const rest = new REST({ version: &amp;quot;10&amp;quot; }).setToken(config.DISCORD_TOKEN);

export async function deployCommands({ guildId }: { guildId: string }) {
    try {
        console.log(\`Started refreshing application (/) commands for guild: \\${guildId}\\`);

        // 특정 서버에 명령어를 등록합니다.
        // 모든 서버에 등록하려면 Routes.applicationCommands(config.DISCORD_CLIENT_ID)를 사용합니다.
        await rest.put(
            Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID, guildId),
            {
                body: commandsData,
            }
        );

        console.log(\`Successfully reloaded application (/) commands for guild: \\${guildId}\\`);
    } catch (error) {
        console.error(error);
    }
}

// boilerplate의 index.ts에서는 봇이 준비될 때 각 서버에 명령어를 배포합니다.
// 별도로 실행하고 싶다면 아래와 같이 특정 guildId를 지정하여 호출할 수 있습니다.
// deployCommands({ guildId: &amp;#39;YOUR_TEST_SERVER_ID&amp;#39; });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;discordjs_typescript_boilerplate&lt;/code&gt;의 &lt;code&gt;src/index.ts&lt;/code&gt;에서는 봇이 준비될 때(`Events.ClientReady`) 연결된 모든 서버에 대해 이 &lt;code&gt;deployCommands&lt;/code&gt; 함수를 호출하여 명령어를 자동으로 갱신합니다. 개발 중에는 이 방식이 편리할 수 있습니다.&lt;/p&gt;
&lt;p&gt;만약 명령어를 수동으로, 또는 특정 서버에만 배포하고 싶다면 &lt;code&gt;package.json&lt;/code&gt;에 다음과 같이 스크립트를 추가할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// package.json
{
  // ... 다른 내용 ...
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;start&amp;quot;: &amp;quot;ts-node src/index.ts&amp;quot;,
    &amp;quot;dev&amp;quot;: &amp;quot;ts-node-dev --respawn src/index.ts&amp;quot;,
    &amp;quot;deploy:guild&amp;quot;: &amp;quot;ts-node src/deploy-commands-script.ts&amp;quot; // 예시 스크립트 이름
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 &lt;code&gt;src/deploy-commands-script.ts&lt;/code&gt; (또는 원하는 이름으로) 파일을 만들어 특정 서버 ID를 하드코딩하거나 환경 변수에서 읽어와 &lt;code&gt;deployCommands&lt;/code&gt;를 호출하도록 구성할 수 있습니다. 하지만 boilerplate의 자동 배포 방식이 대부분의 경우에 더 편리합니다.&lt;/p&gt;
&lt;h2&gt;실행하고 테스트하기&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;src/commands/ping.ts&lt;/code&gt; 파일이 위 예시처럼 작성되었는지 확인합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;src/commands/index.ts&lt;/code&gt; 파일에 &lt;code&gt;ping&lt;/code&gt; 명령어가 올바르게 포함되었는지 확인합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;src/config.ts&lt;/code&gt; 파일에 &lt;code&gt;DISCORD_TOKEN&lt;/code&gt;과 &lt;code&gt;DISCORD_CLIENT_ID&lt;/code&gt;가 정확히 설정되어 있는지 확인합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;봇을 실행합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run dev
# 또는 npm start&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;봇이 실행되고 콘솔에 &amp;quot;Successfully reloaded application (/) commands.&amp;quot;와 유사한 메시지가 뜨면, 디스코드 서버에서 &lt;code&gt;/&lt;/code&gt;를 입력해보세요. 우리가 만든 &lt;code&gt;핑&lt;/code&gt; 명령어가 목록에 나타나고, 실행했을 때 응답 속도 메시지가 잘 나오는지 확인합니다.&lt;/p&gt;
&lt;h2&gt;마무리하며&lt;/h2&gt;
&lt;p&gt;오늘은 앞으로 만들어갈 수많은 명령어들을 담을 그릇, 즉 명령어 구조를 만드는 방법에 대해 알아봤습니다. 각 명령어를 별도의 파일로 분리하고, &lt;code&gt;SlashCommandBuilder&lt;/code&gt;를 사용해 명령어의 정보를 정의하며, 이를 &lt;code&gt;commands/index.ts&lt;/code&gt;를 통해 모으고, &lt;code&gt;deploy-commands.ts&lt;/code&gt;를 통해 디스코드에 등록하는 전체적인 흐름을 살펴보았습니다.&lt;/p&gt;
&lt;p&gt;아직은 &lt;code&gt;핑&lt;/code&gt; 명령어 하나뿐이지만, 이 구조 덕분에 앞으로 새로운 명령어를 추가하는 작업이 훨씬 수월해질 겁니다. 다음 시간에는 드디어 슬래시 명령어의 세계로 본격적으로 뛰어들어 보겠습니다. 슬래시 명령어에 옵션을 추가하는 방법, 서브 커맨드를 만드는 방법 등 더 다양하고 강력한 기능을 사용하는 방법을 자세히 파헤쳐 볼 예정입니다. 기대하셔도 좋습니다!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/30</guid>
      <comments>https://dishost.tistory.com/30#entry30comment</comments>
      <pubDate>Tue, 3 Jun 2025 17:10:25 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 1. 봇 상태 메시지 설정하기</title>
      <link>https://dishost.tistory.com/29</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 시간에는 Discord.js 봇 개발을 위한 기본적인 환경 설정부터 프로젝트 생성, 그리고 아주 간단한 &quot;핑퐁&quot; 봇을 실행하는 것까지 함께 해봤습니다. TypeScript로 프로젝트를 설정하고, 봇 토큰을 안전하게 관리하는 방법도 살짝 맛봤죠. 아직은 우리 봇이 조금 심심해 보일 수 있습니다. 그래서 이번 시간에는 지난번에 만들었던 코드를 다시 한번 살펴보고, 우리 봇에게 개성을 더해줄 수 있는 '상태 메시지'를 설정하는 방법을 알아보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;봇에게 생명을 불어넣는 '상태 메시지'&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드에서 친구 목록이나 서버 멤버 목록을 보면, 각 사용자 이름 아래에 &quot;온라인&quot;, &quot;자리 비움&quot; 같은 상태와 함께 &quot;OOO 하는 중&quot;, &quot;XXX 듣는 중&quot; 같은 문구를 본 적이 있을 겁니다. 이게 바로 '상태 메시지' 또는 '활동(Activity)'이라고 불리는 기능입니다. 우리 봇도 이런 상태 메시지를 가질 수 있습니다. 이걸 설정해주면 봇이 좀 더 살아있는 것처럼 보이고, 봇이 어떤 역할을 하는지 간략하게 알려줄 수도 있죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 메시지는 봇이 성공적으로 로그인하고 준비가 완료되었을 때, 즉 &lt;code&gt;client.once(Events.ClientReady, ...)&lt;/code&gt; 이벤트 핸들러 안에서 설정하는 것이 일반적입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;setActivity&lt;/code&gt; 함수 사용법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Discord.js에서 봇의 상태 메시지를 설정하는 데 사용하는 함수는 &lt;code&gt;client.user.setActivity()&lt;/code&gt;입니다. 이 함수는 두 개의 인자를 받을 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;name&lt;/code&gt; (필수)&lt;/b&gt;: 표시될 활동의 이름입니다. 예를 들어 &quot;게임 플레이 중&quot;이라면 게임 이름이 되겠죠.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;options&lt;/code&gt; (선택)&lt;/b&gt;: 활동의 유형(&lt;code&gt;type&lt;/code&gt;)이나 스트리밍 URL 등을 설정하는 객체입니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 &lt;code&gt;options.type&lt;/code&gt;에는 다음과 같은 값들이 들어갈 수 있습니다. (Discord.js v14 기준, &lt;code&gt;ActivityType&lt;/code&gt; enum을 사용하는 것이 좋습니다.)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;ActivityType.Playing&lt;/code&gt;: &quot;~을(를) 하는 중&quot; (예: &lt;code&gt;Playing '리그 오브 레전드'&lt;/code&gt;) - 기본값입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ActivityType.Streaming&lt;/code&gt;: &quot;~을(를) 방송 중&quot; (예: &lt;code&gt;Streaming '개발 방송'&lt;/code&gt;) - 이 경우 &lt;code&gt;options.url&lt;/code&gt;에 방송 주소(Twitch 또는 YouTube)를 지정해야 제대로 표시됩니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ActivityType.Listening&lt;/code&gt;: &quot;~을(를) 듣는 중&quot; (예: &lt;code&gt;Listening to 'Spotify'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ActivityType.Watching&lt;/code&gt;: &quot;~을(를) 시청 중&quot; (예: &lt;code&gt;Watching 'YouTube'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ActivityType.Competing&lt;/code&gt;: &quot;~에서 경쟁 중&quot; (예: &lt;code&gt;Competing in '코딩 대회'&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다양한 상태 메시지 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제로 코드를 작성해봅시다. &lt;code&gt;client.once(Events.ClientReady, ...)&lt;/code&gt; 블록 안에 아래와 같이 &lt;code&gt;setActivity&lt;/code&gt;를 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;// src/index.ts
// ... (import 구문 및 토큰, 클라이언트 설정은 동일) ...

client.once(Events.ClientReady, (readyClient) =&amp;gt; {
    console.log(\`로그인 성공! \${readyClient.user.tag} (으)로 접속 완료!\`);
    console.log('봇이 준비되었습니다. 달려봅시다!');

    // 예시 1: &quot;명령어 입력 대기 중&quot;을 플레이하는 것으로 설정
    // readyClient.user.setActivity('명령어 입력 대기 중'); // type을 지정하지 않으면 기본값은 Playing

    // 예시 2: &quot;유튜브 시청 중&quot;으로 설정
    // readyClient.user.setActivity('유튜브', { type: ActivityType.Watching });

    // 예시 3: &quot;음악 감상 중&quot;으로 설정
    // readyClient.user.setActivity('좋아하는 노래', { type: ActivityType.Listening });

    // 예시 4: &quot;개발 방송 중&quot; (Twitch 링크와 함께)
    // readyClient.user.setActivity('코딩 라이브', {
    //     type: ActivityType.Streaming,
    //     url: 'https://www.twitch.tv/yourchannel' // 실제 트위치 채널 URL로 변경해주세요
    // });

    // 예시 5: &quot;서버 관리 중&quot; 경쟁 상태로 설정
    readyClient.user.setActivity('서버 관리', { type: ActivityType.Competing });

    console.log(\`상태 메시지가 &quot;\${readyClient.user.presence.activities[0]?.name || '없음'}\&quot; (으)로 설정되었습니다.\`);
});

// ... (messageCreate 이벤트 핸들러 및 client.login은 동일) ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서 &lt;code&gt;ActivityType&lt;/code&gt;을 사용하기 위해 &lt;code&gt;discord.js&lt;/code&gt; import 구문에 &lt;code&gt;ActivityType&lt;/code&gt;을 추가해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;import { Client, Events, GatewayIntentBits, ActivityType } from &quot;discord.js&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 예시를 주석으로 남겨두었으니, 원하는 상태 메시지로 바꿔보세요. 마지막 줄의 &lt;code&gt;console.log&lt;/code&gt;는 실제로 어떤 상태 메시지가 설정되었는지 확인하기 위한 용도입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실행하고 확인하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 수정했다면, 터미널에서 다시 봇을 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;npx ts-node src/index.ts&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇이 성공적으로 로그인하고 나면, 디스코드 클라이언트에서 봇의 프로필을 확인해보세요. 방금 설정한 상태 메시지가 예쁘게 표시될 겁니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bomeVW/btsOlxPsBqA/YGvspumv5my3EVbkPULsu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bomeVW/btsOlxPsBqA/YGvspumv5my3EVbkPULsu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bomeVW/btsOlxPsBqA/YGvspumv5my3EVbkPULsu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbomeVW%2FbtsOlxPsBqA%2FYGvspumv5my3EVbkPULsu0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;468&quot; height=&quot;182&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;182&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 봇의 상태 메시지를 설정하는 방법을 알아봤습니다. 아주 간단한 변경이지만, 봇에게 개성을 부여하고 사용자들에게 봇의 현재 상태나 역할을 알려주는 데 꽤 유용합니다. 여러 가지 &lt;code&gt;ActivityType&lt;/code&gt;을 시도해보면서 여러분의 봇에게 가장 어울리는 상태 메시지를 찾아보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 시간에는 드디어 봇의 핵심 기능이라고 할 수 있는 '명령어'를 좀 더 체계적으로 만들고 관리하는 방법에 대해 깊이 파고들어 보겠습니다. 기대하셔도 좋습니다!&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/29</guid>
      <comments>https://dishost.tistory.com/29#entry29comment</comments>
      <pubDate>Mon, 2 Jun 2025 17:29:17 +0900</pubDate>
    </item>
    <item>
      <title>[DiscordJS 봇 개발 튜토리얼] 0. 프로젝트, 봇 생성하기</title>
      <link>https://dishost.tistory.com/28</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 봇, 직접 만들어보고 싶다는 생각 한 번쯤 해보셨을 겁니다. Discord.js는 Node.js 기반으로 돌아가는 라이브러리인데, 이를 통해 Discord API를 직접 사용하지 않고도 디스코드 봇을 만들 수 있습니다. 앞으로 몇 개의 글에 걸쳐서 봇 개발의 A부터 Z까지는 아니더라도, 필요한 핵심 내용들을 쭉 훑어보려고 합니다. 그 첫 단계로, 오늘은 코딩을 시작하기 전에 필요한 준비물들을 챙기고 첫 프로젝트를 세팅하는 과정을 같이 해보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Node.js 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 디스코드 봇이 뛰어놀 수 있는 환경, 바로 Node.js를 설치해야 합니다. Node.js는 자바스크립트 코드를 웹 브라우저 바깥에서도 실행할 수 있게 해주는 친구라고 생각하시면 됩니다. Discord.js 자체가 Node.js 위에서 돌아가기 때문에 이게 없으면 시작조차 할 수 없죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치는 간단합니다. 먼저 &lt;a href=&quot;https://nodejs.org/&quot;&gt;Node.js 공식 홈페이지&lt;/a&gt;에 접속해주세요. 홈페이지에 들어가면 LTS 버전과 Current 버전이 보일 텐데, 특별한 이유가 없다면 &lt;b&gt;LTS (Long Term Support) 버전을 받는 것이 좋습니다.&lt;/b&gt; 사용하시는 운영체제(윈도우, 맥 등)에 맞는 파일을 내려받아 설치를 진행하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;905&quot; data-origin-height=&quot;675&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvRhXo/btsOlWnRvzo/xkclwtPqheH0jo5xRID2RK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvRhXo/btsOlWnRvzo/xkclwtPqheH0jo5xRID2RK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvRhXo/btsOlWnRvzo/xkclwtPqheH0jo5xRID2RK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvRhXo%2FbtsOlWnRvzo%2FxkclwtPqheH0jo5xRID2RK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;905&quot; height=&quot;675&quot; data-origin-width=&quot;905&quot; data-origin-height=&quot;675&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치가 잘 끝났는지 확인하려면 터미널을 열어야 합니다. 맥에서는 Terminal, 윈도우에서는 CMD나 PowerShell을 실행하고 아래 명령어를 한 줄씩 입력해보세요.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;node -v
npm -v&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각 &lt;code&gt;vXX.X.X&lt;/code&gt; 하는 버전 정보가 뜨면 성공입니다. &lt;code&gt;npm&lt;/code&gt;은 Node.js를 설치할 때 같이 딸려오는 패키지 매니저인데, 앞으로 라이브러리를 설치하거나 할 때 계속 사용하게 될 겁니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;VS Code 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 작성할 편집기가 필요하겠죠? 물론 메모장으로도 코딩은 가능하지만... 정신 건강을 위해 좋은 도구를 쓰는 게 좋습니다. 저는 &lt;b&gt;Visual Studio Code (VS Code)&lt;/b&gt;를 추천합니다. 마이크로소프트에서 만들었고, 무료인데다 기능도 강력하고, 사양도 많이 타지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://code.visualstudio.com/&quot;&gt;VS Code 공식 홈페이지&lt;/a&gt;에서 본인 운영체제에 맞는 설치 파일을 받아 설치하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1271&quot; data-origin-height=&quot;438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckSscq/btsOmVuZfnw/54wlrMnJJ8gXev0MyxekSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckSscq/btsOmVuZfnw/54wlrMnJJ8gXev0MyxekSK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckSscq/btsOmVuZfnw/54wlrMnJJ8gXev0MyxekSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckSscq%2FbtsOmVuZfnw%2F54wlrMnJJ8gXev0MyxekSK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1271&quot; height=&quot;438&quot; data-origin-width=&quot;1271&quot; data-origin-height=&quot;438&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VS Code를 설치했다면, 몇 가지 유용한 확장 프로그램을 깔아두면 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ESLint&lt;/b&gt;: 우리가 작성하는 코드의 문법 오류를 잡아주거나, 정해진 코딩 스타일을 따르도록 도와줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Prettier - Code formatter&lt;/b&gt;: 코드 줄 바꿈이나 띄어쓰기 같은 걸 예쁘게 자동으로 정리해줍니다. 일명 '코드 포맷터'죠.&lt;/li&gt;
&lt;li&gt;Node.js 관련 스니펫: VS Code 마켓플레이스에서 &lt;code&gt;Node.js Snippets&lt;/code&gt; 같은 키워드로 검색해보면 코드 자동완성이나 템플릿을 제공하는 확장 프로그램들이 있습니다. 이런 걸 사용하면 반복적인 코드 작성을 줄일 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Discord.js 프로젝트 생성하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자, 이제 Node.js도 깔았고 VS Code도 준비됐으니 진짜 프로젝트를 만들어볼 시간입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 봇 프로젝트를 담아둘 폴더를 하나 만들어야겠죠. 터미널에서 아래 명령어를 사용하거나, 그냥 파일 탐색기에서 새 폴더를 만들어도 됩니다. 폴더 이름은 &lt;code&gt;discordjs_tutorial&lt;/code&gt;으로 했는데, 원하시는 대로 바꿔도 상관없습니다.&lt;/p&gt;
&lt;pre class=&quot;dos&quot;&gt;&lt;code&gt;mkdir discordjs_tutorial
cd discordjs_tutorial&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 이 폴더 안에서 Node.js 프로젝트를 시작한다는 신호를 줘야 합니다. &lt;code&gt;package.json&lt;/code&gt;이라는 파일을 만드는 과정인데, 이 파일에는 프로젝트 이름, 버전, 그리고 어떤 라이브러리들을 사용하고 있는지 같은 정보들이 기록됩니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;npm init -y&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;-y&lt;/code&gt; 옵션을 붙이면 이것저것 물어보지 않고 기본값으로 빠르게 &lt;code&gt;package.json&lt;/code&gt; 파일을 만들어줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 오늘의 주인공, Discord.js 라이브러리를 설치할 차례입니다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install discord.js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 중요한 정보, 예를 들어 봇 토큰 같은 민감한 데이터를 코드에 직접 적어두는 건 별로 좋은 생각이 아닙니다. 이런 정보들을 별도의 파일에 안전하게 보관하고 쉽게 불러다 쓸 수 있게 해주는 &lt;code&gt;dotenv&lt;/code&gt;라는 라이브러리도 같이 설치해줍시다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install dotenv&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TypeScript 설정: 더 강력하고 안전한 코딩을 위해&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript도 훌륭하지만, TypeScript를 사용하면 코드의 타입을 명시적으로 지정하여 개발 과정에서 발생할 수 있는 많은 오류를 미리 방지하고, 코드 자동 완성 기능도 더 강력하게 활용할 수 있습니다. 앞으로의 튜토리얼은 TypeScript를 기반으로 진행될 예정이니, 함께 설정해봅시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;필수 패키지 설치&lt;/b&gt;: TypeScript와 &lt;code&gt;ts-node&lt;/code&gt; (TypeScript 코드를 바로 실행해주는 도구), 그리고 Node.js 타입 정의를 설치합니다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;```bash
npm install typescript ts-node @types/node --save-dev
```

`--save-dev` (또는 `-D`) 옵션은 개발 시에만 필요한 패키지임을 명시합니다. 이 패키지들은 실제 봇이 실행될 때는 직접적으로 필요하지 않고, 개발 과정에만 도움을 줍니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;tsconfig.json 생성&lt;/b&gt;: TypeScript 컴파일러의 설정을 담는 &lt;code&gt;tsconfig.json&lt;/code&gt; 파일을 생성합니다. 프로젝트 루트에서 다음 명령어를 실행하면 기본적인 설정 파일이 만들어집니다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;```bash
npx tsc --init
```

이 명령으로 `tsconfig.json` 파일이 생성됩니다. 처음에는 기본 설정으로도 충분하지만, 좀 더 체계적인 프로젝트 관리를 위해 몇 가지 옵션을 손보는 것이 좋습니다. 예를 들어, 우리 TypeScript 소스 코드는 `src` 폴더에, TypeScript가 JavaScript로 변환한 결과물(컴파일된 코드)은 `dist` 폴더에 저장하도록 설정할 수 있습니다. `tsconfig.json` 파일을 열고 `compilerOptions` 객체 안에 아래 내용을 참고하여 수정하거나 추가해보세요 (지금 당장 필수는 아니지만, 알아두면 좋습니다):

```json
{
  &quot;compilerOptions&quot;: {
    // ... 다른 기본 옵션들 ...
    &quot;target&quot;: &quot;ES2020&quot;, // 어떤 버전의 JavaScript로 컴파일할지 (Node.js LTS 버전에 맞춰 조정 가능)
    &quot;module&quot;: &quot;commonjs&quot;, // 모듈 시스템 (Node.js 환경에서는 commonjs가 일반적)
    &quot;rootDir&quot;: &quot;./src&quot;, // TypeScript 소스 파일들이 있는 루트 폴더
    &quot;outDir&quot;: &quot;./dist&quot;, // 컴파일된 JavaScript 파일들이 저장될 폴더
    &quot;esModuleInterop&quot;: true, // ES 모듈과 CommonJS 모듈 간의 호환성을 높여줌
    &quot;strict&quot;: true, // 모든 엄격한 타입 검사 옵션을 활성화 (권장)
    &quot;skipLibCheck&quot;: true, // 모든 의존성 라이브러리의 타입 검사를 건너뛰어 빌드 속도 향상
    &quot;forceConsistentCasingInFileNames&quot;: true, // 파일 이름의 대소문자를 일관되게 사용하도록 강제
    &quot;moduleResolution&quot;: &quot;node&quot; // 모듈 해석 전략
    // ...
  },
  &quot;include&quot;: [&quot;src/**/*&quot;], // 컴파일할 파일들의 패턴 (src 폴더 아래 모든 .ts 파일)
  &quot;exclude&quot;: [&quot;node_modules&quot;, &quot;**/*.spec.ts&quot;] // 컴파일에서 제외할 파일/폴더
}
```&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;src 폴더 생성&lt;/b&gt;: TypeScript 소스 코드를 모아둘 &lt;code&gt;src&lt;/code&gt; 폴더를 프로젝트 루트에 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;```bash
mkdir src
```&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 번째 봇 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 기본 세팅은 끝났습니다. 이제 봇을 실제로 움직이게 할 코드를 작성해볼게요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;디스코드 봇 토큰: 봇의 신분증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 짜기 전에, 우리 봇이 디스코드 서버에 접속할 때 필요한 '봇 토큰'이라는 걸 발급받아야 합니다. 이를 위해 &lt;a href=&quot;https://dishost.tistory.com/15&quot;&gt;이 글&lt;/a&gt;을 참고해주세요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;.env 파일: 비밀은 소중하니까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아까 복사한 봇 토큰을 프로젝트에 안전하게 넣어둘 차례입니다. 프로젝트 폴더 최상단 (아까 &lt;code&gt;package.json&lt;/code&gt; 파일 만든 곳)에 &lt;code&gt;.env&lt;/code&gt; 라는 이름으로 새 파일을 만드세요. 그리고 그 안에 아래와 같이 내용을 적습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# .env
DISCORD_TOKEN=봇_토큰&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;src/index.ts: 봇의 두뇌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 진짜 코드를 작성할 파일을 만들겠습니다. 위에서 생성한 &lt;code&gt;src&lt;/code&gt; 폴더 안에 &lt;code&gt;index.ts&lt;/code&gt;라는 이름으로 파일을 만들고 (다른 이름, 예를 들어 &lt;code&gt;bot.ts&lt;/code&gt;로 해도 됩니다) 아래 코드를 붙여넣어 보세요. 이 코드는 TypeScript를 사용하며, 기본적인 봇의 동작을 정의합니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;// src/index.ts
import { Client, Events, GatewayIntentBits } from 'discord.js';
import dotenv from 'dotenv';

// .env 파일에서 환경 변수를 로드합니다.
dotenv.config();

const token = process.env.DISCORD_TOKEN;

// 토큰이 설정되지 않았으면 오류를 출력하고 종료합니다.
if (!token) {
    console.error('오류: DISCORD_TOKEN이 .env 파일에 설정되어 있지 않습니다. .env 파일을 확인해주세요.');
    process.exit(1);
}

// 새로운 디스코드 클라이언트 인스턴스를 생성합니다.
// 인텐트(Intents)는 봇이 어떤 종류의 이벤트에 접근할 수 있는지를 명시합니다.
const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,          // 서버 관련 이벤트 (봇 추가/제거, 서버 정보 변경 등)
        GatewayIntentBits.GuildMessages,   // 서버 내 메시지 생성/수정/삭제 이벤트
        GatewayIntentBits.MessageContent,  // 메시지의 내용을 읽기 위한 권한 (중요! Discord Developer Portal에서 활성화 필요)
    ],
});

// 클라이언트(봇)가 준비되었을 때 한 번 실행되는 이벤트 리스너입니다.
// readyClient는 로그인된 클라이언트, 즉 우리 봇 자신을 가리킵니다.
client.once(Events.ClientReady, (readyClient) =&amp;gt; {
    console.log(\`로그인 성공! \${readyClient.user.tag} (으)로 접속 완료!\`);
    console.log('봇이 준비되었습니다. 달려봅시다!');
    // 봇의 활동 상태를 설정합니다. 예: &quot;메시지 기다리는 중&quot; (WATCHING)
    // type: 0 (Playing), 1 (Streaming), 2 (Listening), 3 (Watching), 5 (Competing)
    readyClient.user.setActivity('메시지 기다리는 중', { type: 3 /* Watching */ });
});

// 서버에서 메시지가 생성될 때마다 실행되는 이벤트 리스너입니다.
client.on(Events.MessageCreate, async (message) =&amp;gt; {
    // 메시지를 보낸 이가 봇이라면 아무 작업도 하지 않고 반환합니다 (무한 루프 방지).
    if (message.author.bot) return;

    // 사용자가 &quot;핑&quot; 이라고 메시지를 보내면 &quot;퐁!&quot; 이라고 답장합니다.
    if (message.content === '핑') {
        message.reply('퐁!');
    }

    // 사용자가 &quot;안녕&quot; 이라고 메시지를 보내면, 해당 유저를 언급하며 인사합니다.
    // toLowerCase()를 사용하여 대소문자 구분 없이 처리합니다.
    if (message.content.toLowerCase() === '안녕') {
        message.channel.send(\`안녕하세요, \${message.author.toString()}님! 반가워요.\`);
    }
});

// .env 파일에서 가져온 토큰을 사용하여 Discord에 로그인합니다.
client.login(token)
    .then(() =&amp;gt; {
        console.log('봇이 Discord에 성공적으로 연결되었습니다.');
    })
    .catch((error) =&amp;gt; {
        console.error('봇 로그인 중 오류 발생:', error);
    });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 봇이 켜지면 콘솔에 로그를 남기고, 사용자가 &quot;핑&quot;이라고 치면 &quot;퐁!&quot;으로, &quot;안녕&quot;이라고 치면 반갑게 인사해주는 아주 간단한 녀석입니다. &lt;code&gt;GatewayIntentBits.MessageContent&lt;/code&gt; 인텐트가 포함되어 있으니, Discord Developer Portal에서 해당 설정을 켜주는 것을 잊지 마세요!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;봇 실행&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드디어 봇을 실행시켜 볼 시간입니다. TypeScript로 코드를 작성했으니, &lt;code&gt;ts-node&lt;/code&gt;를 사용해서 별도의 컴파일 과정 없이 바로 실행할 수 있습니다. 터미널에 아래 명령어를 입력하세요.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;npx ts-node src/index.ts&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 &lt;code&gt;tsconfig.json&lt;/code&gt;에서 &lt;code&gt;rootDir&lt;/code&gt;을 &lt;code&gt;./src&lt;/code&gt;로 설정했고, &lt;code&gt;index.ts&lt;/code&gt; 파일이 &lt;code&gt;src&lt;/code&gt; 폴더 안에 있다면 위 명령어가 잘 동작할 겁니다.&lt;br /&gt;터미널에 &lt;code&gt;로그인 성공! 봇이름#1234 (으)로 접속 완료!&lt;/code&gt; 와 &lt;code&gt;봇이 준비되었습니다. 달려봅시다!&lt;/code&gt; 같은 메시지가 보이면 성공입니다! 이제 이 봇을 여러분의 디스코드 서버로 초대해서 테스트해볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 초대된 서버에서 &quot;핑&quot;이나 &quot;안녕&quot;을 입력해서 봇이 잘 대답하는지 확인해보세요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫걸음을 떼며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 잘 따라오셨다면, 디스코드 봇 개발의 첫 단추를 성공적으로 끼운 겁니다. 아직은 아주 간단한 기능밖에 없지만, 이 기본 틀 위에서 앞으로 더 복잡하고 재미있는 기능들을 하나씩 붙여나갈 수 있을 거예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 오늘 만든 이 프로젝트의 폴더 구조를 좀 더 깔끔하게 정리하고, 명령어를 좀 더 체계적으로 관리하는 방법에 대해 이야기해보겠습니다.&lt;/p&gt;</description>
      <category>DiscordJS 개발 튜토리얼</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/28</guid>
      <comments>https://dishost.tistory.com/28#entry28comment</comments>
      <pubDate>Sun, 1 Jun 2025 17:21:20 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 봇 명령어, 길드 커맨드와 글로벌 커맨드 차이점, 사용 방법</title>
      <link>https://dishost.tistory.com/27</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Discord.js v13부터 슬래시 커맨드(Slash Commands)가 정식으로 도입되었습니다. 슬래시 커맨드는 사용자가 채팅창에 '/'를 입력하면 나타나는 명령어들로, Discord API를 통해 등록하고 관리할 수 있습니다. 이전 Discord.js 버전에서는 메시지 기반의 명령어가 주로 사용되었지만, Message Contant Intent가 필요한다는 점과 리소스 소모가 크다는 단점 때문에 슬래시 커맨드가 더 선호되고 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Global Commands&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Global Commands는 봇이 접근할 수 있는 모든 서버(길드)에서 사용 가능한 슬래시 커맨드입니다.&lt;br /&gt;봇이 초대된 모든 서버에서 사용할 수 있는 명령어로, 한 번 등록하면 모든 서버에 적용됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;한 번 등록으로 모든 서버에 적용됨&lt;br /&gt;이 부분은 discord API의 rate limit와 관련되어 있습니다. 이후 설명드릴 Guild Commands로 명령어를 배포하게 되면, 각 서버마다 별도로 등록해야 하기 때문에 Discord API의 rate limit에 걸릴 수 있습니다.&lt;/li&gt;
&lt;li&gt;중앙 집중식 관리가 가능&lt;/li&gt;
&lt;li&gt;봇 사용자에게 일관된 경험 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;변경사항이 반영되는 데 시간이 오래 걸림 -&amp;gt; 테스트 시에는 불편함이 존재합니다.&lt;br /&gt;Discord API의 캐싱 메커니즘으로 인해, 명령어 등록 후 최대 1시간까지 반영되지 않을 수 있습니다.&lt;/li&gt;
&lt;li&gt;특정 서버에 맞춤형 명령어를 제공하기 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Guild Commands&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Guild Commands는 특정 서버(길드)에서만 사용 가능한 슬래시 커맨드입니다.&lt;br /&gt;이 명령어는 봇이 초대된 특정 서버에만 적용되며, 각 서버마다 별도로 등록해야 합니다.&lt;br /&gt;Guild Commands는 개발 및 테스트 환경에서 주로 사용되며, 서버별로 맞춤형 명령어를 제공할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;변경사항이 즉시 반영됨&lt;br /&gt;이후 설명드릴 코드를 통해 특정 길드에 명령어를 등록하면, 해당 길드에서 즉시 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;개발 중 빠른 테스트 가능&lt;br /&gt;Discord API의 캐싱과 관계없이 바로 적용됩니다.&lt;/li&gt;
&lt;li&gt;서버별 맞춤형 명령어 제공 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 서버마다 별도로 등록해야 함&lt;br /&gt;이로 인해 앞서 설명드린 Discord API의 rate limit에 걸릴 수 있습니다.&lt;/li&gt;
&lt;li&gt;많은 서버에서 관리하기 복잡함&lt;/li&gt;
&lt;li&gt;서버별 다른 명령어 세트로 인한 사용자 혼란 가능성&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;차이점 비교&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특성&lt;/th&gt;
&lt;th&gt;Global Commands&lt;/th&gt;
&lt;th&gt;Guild Commands&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;가용성&lt;/td&gt;
&lt;td&gt;모든 서버&lt;/td&gt;
&lt;td&gt;특정 서버만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;업데이트 시간&lt;/td&gt;
&lt;td&gt;최대 1시간&lt;/td&gt;
&lt;td&gt;즉시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;등록 API 경로&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/applications/:id/commands&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/applications/:id/guilds/:guild_id/commands&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;적합한 환경&lt;/td&gt;
&lt;td&gt;프로덕션&lt;/td&gt;
&lt;td&gt;개발/테스트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;명령어 수 제한&lt;/td&gt;
&lt;td&gt;100개&lt;/td&gt;
&lt;td&gt;서버당 100개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;봇 퇴장 시&lt;/td&gt;
&lt;td&gt;명령어 유지&lt;/td&gt;
&lt;td&gt;명령어 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드 예시&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Global Commands 등록하기&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import { REST, Routes } from &quot;discord.js&quot;;
import { config } from &quot;./config&quot;;
import { commands } from &quot;./commands&quot;;

const commandsData = Object.values(commands).map((command) =&amp;gt; command.data);

const rest = new REST({ version: &quot;10&quot; }).setToken(config.DISCORD_TOKEN);

type DeployCommandsProps = {
  guildId?: string; // guildId를 선택적으로 변경
};

export async function deployCommands({ guildId }: DeployCommandsProps = {}) {
  try {
    if (guildId) {
      // 길드별 명령어 배포 (서버별 배포)
      console.log(`길드 ${guildId}에 명령어 배포 중...`);
      await rest.put(
        Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID, guildId),
        {
          body: commandsData,
        }
      );
      console.log(`길드 ${guildId}에 명령어 배포 완료`);
    } else {
      // 글로벌 명령어 배포 (모든 서버에 적용)
      console.log(&quot;글로벌 명령어 배포 중...&quot;);
      await rest.put(Routes.applicationCommands(config.DISCORD_CLIENT_ID), {
        body: commandsData,
      });
      console.log(
        &quot;글로벌 명령어 배포 완료 (최대 1시간 내에 모든 서버에 적용됨)&quot;
      );
    }
  } catch (error) {
    console.error(&quot;명령어 배포 중 오류 발생:&quot;, error);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Guild Commands 등록하기&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import { REST, Routes } from &quot;discord.js&quot;;
import { config } from &quot;./config&quot;;
import { commands } from &quot;./commands&quot;;

const commandsData = Object.values(commands).map((command) =&amp;gt; command.data);

const rest = new REST({ version: &quot;10&quot; }).setToken(config.DISCORD_TOKEN);

type DeployCommandsProps = {
  guildId: string;
};

export async function deployCommands({ guildId }: DeployCommandsProps) {
  try {
    await rest.put(
      Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID, guildId),
      {
        body: commandsData,
      }
    );
  } catch (error) {
    console.error(error);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;명령어 삭제하기&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Global Commands 삭제&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;import { REST, Routes } from &quot;discord.js&quot;;
import { config } from &quot;./config&quot;;

const rest = new REST({ version: &quot;10&quot; }).setToken(config.DISCORD_TOKEN);

// 모든 글로벌 명령어 삭제
await rest.put(Routes.applicationCommands(config.DISCORD_CLIENT_ID), {
  body: [],
});

// 특정 글로벌 명령어 삭제
await rest.delete(
  Routes.applicationCommand(config.DISCORD_CLIENT_ID, &quot;command_id&quot;)
);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Guild Commands 삭제&lt;/h4&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;import { REST, Routes } from &quot;discord.js&quot;;
import { config } from &quot;./config&quot;;

const rest = new REST({ version: &quot;10&quot; }).setToken(config.DISCORD_TOKEN);

// 특정 서버의 모든 명령어 삭제
await rest.put(
  Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID, &quot;GUILD_ID&quot;),
  { body: [] }
);

// 특정 서버의 특정 명령어 삭제
await rest.delete(
  Routes.applicationGuildCommand(
    config.DISCORD_CLIENT_ID,
    &quot;GUILD_ID&quot;,
    &quot;command_id&quot;
  )
);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최적의 사용 사례&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Global Commands 사용이 적합한 경우&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;안정적인 프로덕션 기능&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;충분히 테스트되고 안정적인 명령어&lt;/li&gt;
&lt;li&gt;모든 서버에서 동일하게 동작해야 하는 핵심 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기본 명령어&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/help&lt;/code&gt;, &lt;code&gt;/info&lt;/code&gt;, &lt;code&gt;/settings&lt;/code&gt;와 같은 기본적인 봇 명령어&lt;/li&gt;
&lt;li&gt;모든 사용자가 접근해야 하는 일반적인 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대규모 봇&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;많은 서버에 배포되는 대형 봇의 경우 Discord API의 rate limit을 고려하여 Global Commands를 사용하는 것이 효율적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Guild Commands 사용이 적합한 경우&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;개발 및 테스트&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새로운 기능 개발 중&lt;/li&gt;
&lt;li&gt;버그 수정 및 테스트 과정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버 특화 기능&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;특정 서버에만 필요한 맞춤형 명령어&lt;/li&gt;
&lt;li&gt;특정 서버의 관리 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;베타 테스트&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제한된 사용자 그룹에게만 제공하는 베타 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://v13.discordjs.guide/interactions/slash-commands.html#registering-slash-commands&quot;&gt;Discord.js 공식 가이드 - 슬래시 커맨드 등록&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://discord.com/developers/docs/interactions/application-commands&quot;&gt;Discord 개발자 문서 - 애플리케이션 명령어&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>봇 개발 팁/Discord.js</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/27</guid>
      <comments>https://dishost.tistory.com/27#entry27comment</comments>
      <pubDate>Sat, 31 May 2025 15:19:43 +0900</pubDate>
    </item>
    <item>
      <title>Sharding이란? 대규모 봇에서의 필수 구조 이해</title>
      <link>https://dishost.tistory.com/26</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드 봇이 많은 서버에 참여하게 되면, 한 프로세스에서 모든 이벤트를 처리하기 어렵습니다. 이때 필요한 구조가 바로 'Sharding'입니다. 샤딩은 봇이 여러 개의 프로세스(혹은 인스턴스)로 나뉘어 각각 일부 서버만 담당하도록 하는 방식입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 필요한가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스코드는 공식적으로 1,000개 이상의 서버에 참여하는 봇은 반드시 샤딩을 적용하도록 권장합니다. 한 프로세스가 처리할 수 있는 서버 수에 한계가 있기 때문입니다. 샤딩을 적용하면 각 샤드가 독립적으로 이벤트를 처리해, 성능 저하나 API 제한 문제를 줄일 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;적용 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;discord.js 등 주요 라이브러리에서는 샤딩 매니저를 제공합니다. 아래는 간단한 예시입니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;const { ShardingManager } = require('discord.js');
const manager = new ShardingManager('./index.js', { token: '여기에_봇_토큰' });

manager.on('shardCreate', shard =&amp;gt; {
  console.log(`${shard.id}번 샤드가 생성되었습니다.`);
});

manager.spawn();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 여러 샤드(프로세스)를 자동으로 생성해, 각각이 일부 서버만 담당하도록 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드 리뷰 및 설명&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ShardingManager&lt;/b&gt;: 봇의 메인 파일이 여러 번 실행되도록 관리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;shardCreate 이벤트&lt;/b&gt;: 각 샤드가 정상적으로 생성되었는지 확인할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;spawn()&lt;/b&gt;: 샤드를 실제로 실행합니다. 서버 수에 따라 샤드 개수는 자동으로 조정됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 팁&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;샤딩을 적용하면, 각 샤드 간 데이터 공유가 필요할 수 있습니다. 이때는 데이터베이스나 IPC(프로세스 간 통신)를 활용합니다.&lt;/li&gt;
&lt;li&gt;개발 단계에서는 샤딩 없이 테스트하다가, 실제 배포 시 샤딩을 적용하는 경우가 많습니다.&lt;/li&gt;
&lt;li&gt;샤딩 구조에서는 로그 관리와 모니터링이 중요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대규모 봇을 운영한다면, 샤딩 구조를 꼭 이해하고 적용하는 것이 안정적인 서비스의 핵심입니다. 처음에는 어렵게 느껴질 수 있지만, 라이브러리의 지원을 잘 활용하면 비교적 쉽게 도입할 수 있습니다.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.js</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/26</guid>
      <comments>https://dishost.tistory.com/26#entry26comment</comments>
      <pubDate>Fri, 23 May 2025 14:16:28 +0900</pubDate>
    </item>
    <item>
      <title>디스코드 봇에서 유저 포인트 시스템 구현하기</title>
      <link>https://dishost.tistory.com/25</link>
      <description>&lt;p&gt;유저의 활동에 따라 포인트를 부여하는 시스템은 봇의 재미와 참여도를 높여줍니다. 간단한 구조로 시작해볼 수 있습니다. 여기서는 메시지 활동에 따라 포인트를 올려주는 기본적인 예시를 다룹니다.&lt;/p&gt;
&lt;h2&gt;기본 구조&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;메시지를 보낼 때마다 포인트를 1씩 올려줍니다.&lt;/li&gt;
&lt;li&gt;데이터베이스(SQLite 등)에 유저별 포인트를 저장합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;!포인트&lt;/code&gt; 명령어로 자신의 점수를 확인할 수 있게 합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;예제 코드&lt;/h2&gt;
&lt;p&gt;아래는 Prisma를 사용해 포인트를 저장하고 불러오는 함수 예시입니다. 데이터베이스 연결 및 Prisma 설정 방법은 &lt;a href=&quot;./24&quot;&gt;디스코드 봇에 SQLite 연동하기 - Prisma로 쉽게 시작하기&lt;/a&gt;를 참고하세요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { PrismaClient } from &amp;#39;@prisma/client&amp;#39;;
const prisma = new PrismaClient();

export async function addPoint(userId) {
  await prisma.user.upsert({
    where: { id: userId },
    update: { points: { increment: 1 } },
    create: { id: userId, points: 1 }
  });
}

export async function getPoint(userId) {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  return user ? user.points : 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;디스코드 봇 이벤트에서 사용하는 예시:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;client.on(&amp;#39;messageCreate&amp;#39;, async msg =&amp;gt; {
  if (msg.author.bot) return;
  await addPoint(msg.author.id);
  if (msg.content === &amp;#39;!포인트&amp;#39;) {
    const points = await getPoint(msg.author.id);
    msg.reply(`${msg.author.username}님의 포인트: ${points}`);
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;코드 리뷰 및 설명&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;addPoint 함수&lt;/strong&gt;: 메시지를 보낼 때마다 해당 유저의 포인트를 1 증가시킵니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;getPoint 함수&lt;/strong&gt;: 유저의 현재 포인트를 데이터베이스에서 불러옵니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;봇 메시지 무시&lt;/strong&gt;: 봇끼리 포인트를 올리지 않도록 반드시 체크해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;명령어 구조&lt;/strong&gt;: &lt;code&gt;!포인트&lt;/code&gt; 외에도 다양한 명령어로 확장할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;확장 아이디어&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;포인트 랭킹, 일일 미션, 보상 시스템 등 다양한 기능으로 발전시킬 수 있습니다.&lt;/li&gt;
&lt;li&gt;포인트 감소, 초기화, 관리자 명령어 등도 추가 가능합니다.&lt;/li&gt;
&lt;li&gt;데이터베이스를 MySQL, MongoDB 등으로 교체해 대규모로 확장할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;포인트 시스템은 봇의 기본 기능 중 하나로, 유저들의 참여를 유도하는 데 큰 역할을 합니다. 기본 구조를 익힌 뒤, 다양한 아이디어로 발전시켜보세요.&lt;/p&gt;</description>
      <category>봇 개발 팁/Discord.js</category>
      <author>디스호스트</author>
      <guid isPermaLink="true">https://dishost.tistory.com/25</guid>
      <comments>https://dishost.tistory.com/25#entry25comment</comments>
      <pubDate>Thu, 22 May 2025 14:15:41 +0900</pubDate>
    </item>
  </channel>
</rss>