해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. Discord.js TypeScript Boilerplate
안녕하세요! 지난 시간에는 임베드 메시지와 버튼을 활용해서 봇과의 소통을 한층 풍부하게 만드는 방법을 배웠습니다. 이제 봇이 좀 더 세련되게 정보를 전달하고, 사용자와 간단한 상호작용도 할 수 있게 되었네요.
이번 시간에는 디스코드 봇 개발의 핵심 중 하나인 이벤트 핸들링(Event Handling)에 대해 깊이 있게 다뤄보려고 합니다. 단순히 명령어를 처리하는 것을 넘어, 서버에서 발생하는 다양한 상황에 봇이 능동적으로 반응하도록 만들 수 있는 강력한 기능이죠. 예를 들어, 새로운 멤버가 서버에 참여했을 때 환영 메시지를 보내거나, 메시지가 수정/삭제되었을 때 로그를 남기는 등의 동작을 구현할 수 있습니다.
이벤트란 무엇이고, 왜 중요할까요?
디스코드에서 '이벤트'란 서버나 사용자와 관련된 특정 사건이나 동작을 의미합니다. 예를 들면 다음과 같은 것들이 있죠.
- 사용자가 메시지를 보냈을 때 (
messageCreate
) - 사용자가 명령어를 입력했을 때 (
interactionCreate
- 이미 사용해봤죠!) - 새로운 멤버가 서버에 참여했을 때 (
guildMemberAdd
) - 멤버가 서버를 떠났을 때 (
guildMemberRemove
) - 메시지가 수정되었을 때 (
messageUpdate
) - 메시지가 삭제되었을 때 (
messageDelete
) - 봇이 준비되었을 때 (
ready
- 이것도 이미 사용해봤습니다!) - 사용자가 음성 채널 상태를 변경했을 때 (
voiceStateUpdate
)
이 외에도 정말 다양한 이벤트들이 존재합니다. (전체 목록은 Discord.js 공식 문서의 Events
페이지에서 확인할 수 있습니다.)
이벤트 핸들링이 중요한 이유는, 봇이 단순히 수동적으로 명령어에만 응답하는 것을 넘어, 서버 환경의 변화를 감지하고 그에 맞춰 자율적으로 동작하게 만들기 때문입니다. 잘 만들어진 이벤트 핸들러는 봇을 마치 살아있는 유기체처럼 느끼게 해주고, 서버 관리나 커뮤니티 운영에 큰 도움을 줄 수 있습니다.
client.on()
으로 이벤트 구독하기
Discord.js에서 이벤트를 처리하는 가장 기본적인 방법은 Client
객체의 .on()
메서드를 사용하는 것입니다. 이 메서드는 두 개의 인자를 받습니다.
- 이벤트 이름: 처리하고자 하는 이벤트의 이름을 문자열로 전달합니다.
discord.js
에서는Events
라는 열거형(Enum) 객체에 미리 정의된 이벤트 이름들을 제공하므로, 이를 사용하는 것이 좋습니다. (예:Events.MessageCreate
,Events.GuildMemberAdd
) - 리스너 함수 (Listener Function): 해당 이벤트가 발생했을 때 실행될 함수입니다. 이 함수는 이벤트의 종류에 따라 다양한 인자를 받을 수 있습니다. 예를 들어,
Events.MessageCreate
이벤트는 생성된Message
객체를 인자로 받고,Events.GuildMemberAdd
이벤트는 서버에 참여한GuildMember
객체를 인자로 받습니다.
기본적인 구조는 다음과 같습니다.
// src/index.ts
import { Client, Events, GatewayIntentBits } from 'discord.js';
import { token } from './config'; // 토큰 관리는 이전과 동일
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, // 메시지 내용을 읽기 위해 필요
GatewayIntentBits.GuildMembers, // 멤버 관련 이벤트를 위해 필요
// 필요한 다른 Intents 추가...
]
});
client.once(Events.ClientReady, c => {
console.log(\`로그인 성공! \\${c.user.tag}(으)로 로그인했어요.\`);
});
// 예시: 새로운 메시지가 생성될 때마다 콘솔에 로그 남기기
client.on(Events.MessageCreate, message => {
// 봇 자신의 메시지는 무시
if (message.author.bot) return;
console.log(\`[\\${message.guild?.name || 'DM'}] \\${message.author.tag}: \\${message.content}\`);
// 간단한 응답 예시 (명령어 처리와는 별개)
if (message.content.toLowerCase() === '안녕') {
message.reply('안녕하세요!');
}
});
// 예시: 새로운 멤버가 서버에 참여했을 때 환영 메시지 보내기
client.on(Events.GuildMemberAdd, member => {
console.log(\`\\${member.user.tag}님이 \\${member.guild.name} 서버에 참여했습니다.\`);
// 특정 채널에 환영 메시지 보내기 (채널 ID를 설정 파일이나 환경 변수로 관리하는 것이 좋습니다)
const welcomeChannelId = 'YOUR_WELCOME_CHANNEL_ID'; // 실제 채널 ID로 변경하세요!
const channel = member.guild.channels.cache.get(welcomeChannelId);
if (channel && channel.isTextBased()) {
channel.send(\`\\${member.displayName}님, \\${member.guild.name} 서버에 오신 것을 환영합니다! 🎉\`);
} else {
console.warn(\`환영 채널(ID: \\${welcomeChannelId})을 찾을 수 없거나 텍스트 채널이 아닙니다.\`);
}
});
// 예시: 멤버가 서버를 떠났을 때 작별 메시지 보내기
client.on(Events.GuildMemberRemove, member => {
console.log(\`\\${member.user.tag}님이 \\${member.guild.name} 서버를 떠났습니다.\`);
const goodbyeChannelId = 'YOUR_GOODBYE_CHANNEL_ID'; // 실제 채널 ID로 변경하세요!
const channel = member.guild.channels.cache.get(goodbyeChannelId);
if (channel && channel.isTextBased()) {
channel.send(\`잘 가요, \\${member.displayName}님... 다음에 또 만나요! 👋\`);
} else {
console.warn(\`작별 채널(ID: \\${goodbyeChannelId})을 찾을 수 없거나 텍스트 채널이 아닙니다.\`);
}
});
// 명령어 핸들러 (지난 시간 내용)
client.on(Events.InteractionCreate, async interaction => {
// ... (기존 명령어 처리 로직) ...
});
client.login(token);
중요: Intents 설정 확인!
각 이벤트를 제대로 수신하려면 Client
를 생성할 때 적절한 인텐트(Intents)를 활성화해야 합니다. 인텐트는 봇이 어떤 종류의 이벤트 정보를 받을 것인지를 디스코드 API에 알려주는 역할을 합니다.
Events.MessageCreate
에서 메시지 내용을 읽으려면GatewayIntentBits.MessageContent
가 필요합니다. (디스코드 개발자 포털에서 "Message Content Intent"도 활성화해야 합니다!)Events.GuildMemberAdd
,Events.GuildMemberRemove
와 같은 멤버 관련 이벤트를 수신하려면GatewayIntentBits.GuildMembers
가 필요합니다. (디스코드 개발자 포털에서 "Privileged Gateway Intents" 항목의 "Server Members Intent"도 활성화해야 합니다!)
필요한 인텐트를 누락하면 해당 이벤트가 발생해도 봇이 감지하지 못하니 주의해야 합니다.
이벤트 핸들러 파일 분리하기 (선택 사항, 하지만 권장)
봇의 기능이 많아지고 처리해야 할 이벤트가 늘어나면 src/index.ts
파일이 매우 길어질 수 있습니다. 이럴 때는 각 이벤트 핸들러를 별도의 파일로 분리하여 관리하는 것이 좋습니다. 이렇게 하면 코드가 더 깔끔해지고 유지보수도 쉬워집니다.
events
폴더 생성: src
폴더 아래에 events
라는 새 폴더를 만듭니다.
이벤트 파일 생성: 각 이벤트별로 파일을 만듭니다. 예를 들어, messageCreate.ts
, guildMemberAdd.ts
처럼요.
이벤트 핸들러 작성: 각 파일에는 해당 이벤트를 처리하는 로직을 작성합니다.
```typescript
// src/events/guildMemberAdd.ts
import { Events, GuildMember } from 'discord.js';
// 이벤트 이름과 실행 함수를 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 || 'YOUR_DEFAULT_WELCOME_CHANNEL_ID';
const channel = member.guild.channels.cache.get(welcomeChannelId);
if (channel && channel.isTextBased()) {
try {
await channel.send(\`\\${member.displayName}님, \\${member.guild.name} 서버에 오신 것을 환영합니다! (별도 파일에서 환영 🎉)\`);
} catch (error) {
console.error('환영 메시지 전송 실패:', error);
}
} else {
console.warn(\`환영 채널(ID: \\${welcomeChannelId})을 찾을 수 없거나 텍스트 채널이 아닙니다.\`);
}
}
```
index.ts
에서 이벤트 핸들러 동적 로딩: src/index.ts
파일에서 events
폴더 안의 파일들을 읽어와 각 이벤트를 동적으로 등록합니다.
```typescript
// src/index.ts
import fs from 'node:fs';
import path from 'node:path';
import { Client, Events, GatewayIntentBits, Collection } from 'discord.js'; // Collection 추가
import { token } from './config';
// 명령어 관련 import는 그대로 유지
import { commands } from './commands'; // 예시, 실제 명령어 로딩 방식에 따라 다를 수 있음
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
// ... 기타 필요한 Intents
]
});
// 명령어 로딩 (기존 방식대로)
// client.commands = new Collection(); // 만약 명령어를 Collection으로 관리한다면
// ... 명령어 파일 로딩 로직 ...
// 이벤트 핸들러 로딩
const eventsPath = path.join(__dirname, 'events');
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.ts') || file.endsWith('.js')); // .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) => event.execute(...args));
} else {
client.on(event.name, (...args) => event.execute(...args));
}
console.log(\`[이벤트 로드] \\${event.name} 이벤트 핸들러 로드 완료: \\${file}\`);
}
client.once(Events.ClientReady, c => {
console.log(\`로그인 성공! \\${c.user.tag}(으)로 로그인했어요.\`);
// 봇이 준비되면 명령어 등록 (deploy-commands.ts 실행 등)
// require('./deploy-commands'); // 예시
});
// InteractionCreate 이벤트는 여전히 중요하므로 직접 등록하거나, events 폴더 방식으로 관리 가능
client.on(Events.InteractionCreate, async interaction => {
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: '알 수 없는 명령어입니다.', ephemeral: true });
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: '명령어 실행 중 오류가 발생했어요!', ephemeral: true });
} else {
await interaction.reply({ content: '명령어 실행 중 오류가 발생했어요!', ephemeral: true });
}
}
});
client.login(token);
```
위의 동적 로딩 코드는 `require`를 사용하고 있는데, 순수 TypeScript 환경에서는 `import()` 동적 임포트나 컴파일 후의 JavaScript 파일을 기준으로 작성해야 할 수 있습니다. 또는 각 이벤트 파일을 `index.ts`에서 명시적으로 `import`하고 등록하는 방식을 사용할 수도 있습니다. 프로젝트 구조와 선호도에 따라 선택하세요.
**참고**: `require(filePath)` 방식은 CommonJS 모듈 시스템에서 주로 사용됩니다. ES 모듈을 사용하고 있다면 (tsconfig.json에서 `module: "ESNext"` 또는 유사한 설정), 동적 `import()`를 사용해야 합니다.
```typescript
// ES 모듈 방식의 동적 임포트 예시
for (const file of eventFiles) {
const filePath = path.join(eventsPath, file);
import(filePath).then(eventModule => {
if (eventModule.once) {
client.once(eventModule.name, (...args: any[]) => eventModule.execute(...args));
} else {
client.on(eventModule.name, (...args: any[]) => eventModule.execute(...args));
}
console.log(\`[이벤트 로드] \\${eventModule.name} 이벤트 핸들러 로드 완료: \\${file}\`);
}).catch(err => console.error(\`이벤트 파일 로드 실패 (\\${file}):\`, err));
}
```
이 경우, 각 이벤트 파일은 `export const name = ...;` 와 `export async function execute(...) {...}` 형태로 `name`과 `execute`를 명시적으로 export 해야 합니다.
유용한 이벤트 몇 가지 더 살펴보기
Events.MessageUpdate
: 메시지가 수정되었을 때 발생합니다.- 인자:
oldMessage: Message | PartialMessage
,newMessage: Message | PartialMessage
PartialMessage
는 메시지 정보가 부분적일 수 있음을 의미합니다. (예: 캐시되지 않은 오래된 메시지)- 수정 전후의 내용을 비교하여 로깅하거나 특정 패턴을 감지할 수 있습니다.
// src/events/messageUpdate.ts (예시) import { Events, Message, PartialMessage } from 'discord.js'; 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}\`); // 특정 채널에 로그를 남길 수 있습니다. }
- 인자:
Events.MessageDelete
: 메시지가 삭제되었을 때 발생합니다.- 인자:
message: Message | PartialMessage
- 삭제된 메시지의 내용을 로깅할 수 있습니다. (단,
MessageContent
인텐트가 활성화되어 있고 메시지가 캐시되어 있어야 내용 접근 가능)
// src/events/messageDelete.ts (예시) import { Events, Message, PartialMessage } from 'discord.js'; 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(\` - (내용을 가져올 수 없거나, 첨부파일/임베드만 있었을 수 있습니다.)\`); } // 특정 채널에 로그를 남길 수 있습니다. }
- 인자:
Events.VoiceStateUpdate
: 사용자가 음성 채널에 참여/퇴장하거나, 마이크/스피커 음소거 등의 상태를 변경했을 때 발생합니다.- 인자:
oldState: VoiceState
,newState: VoiceState
- 이를 활용해 특정 사용자가 음성 채널에 접속하면 알림을 주거나, 음성 채널 활동 로그를 기록할 수 있습니다.
// src/events/voiceStateUpdate.ts (예시) import { Events, VoiceState } from 'discord.js'; 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 && newChannel) { // 사용자가 음성 채널에 참여 console.log(\`\\${member.user.tag}님이 \\${newChannel.name} 음성 채널에 참여했습니다.\`); // 특정 텍스트 채널에 알림을 보낼 수 있습니다. // const notificationChannel = member.guild.channels.cache.get('YOUR_NOTIFICATION_CHANNEL_ID'); // if (notificationChannel?.isTextBased()) { // notificationChannel.send(\`🔊 \\${member.displayName}님이 \\${newChannel.name}에 접속했습니다!\`); // } } else if (oldChannel && !newChannel) { // 사용자가 음성 채널에서 퇴장 console.log(\`\\${member.user.tag}님이 \\${oldChannel.name} 음성 채널에서 퇴장했습니다.\`); } else if (oldChannel && newChannel && oldChannel.id !== newChannel.id) { // 사용자가 음성 채널을 이동 console.log(\`\\${member.user.tag}님이 \\${oldChannel.name}에서 \\${newChannel.name}으로 이동했습니다.\`); } // 마이크 음소거/해제, 서버 음소거/해제 등의 상태 변경도 감지 가능 // if (oldState.serverMute !== newState.serverMute) { ... } // if (oldState.selfMute !== newState.selfMute) { ... } }
- 인자:
마무리하며
이번 시간에는 client.on()
을 사용해 다양한 디스코드 이벤트를 구독하고 처리하는 방법, 그리고 코드를 체계적으로 관리하기 위해 이벤트 핸들러를 파일로 분리하는 방법에 대해 알아보았습니다. MessageCreate
, GuildMemberAdd/Remove
, MessageUpdate/Delete
, VoiceStateUpdate
등 유용한 이벤트들의 기본적인 활용법도 살펴봤습니다.
이벤트 핸들링을 통해 여러분의 봇은 이제 단순한 명령어 실행기를 넘어, 서버의 상황 변화에 지능적으로 반응하는 멋진 동반자가 될 수 있을 겁니다. 예를 들어, 특정 키워드가 포함된 메시지를 감지하여 자동으로 응답하거나, 새로운 멤버에게 역할(Role)을 자동으로 부여하는 등의 고급 기능도 구현할 수 있게 됩니다.
다음 시간에는 역할(Role) 관리와 권한(Permission) 제어에 대해 알아보겠습니다. 봇을 이용해 서버 멤버들의 역할을 관리하고, 특정 명령어는 특정 역할을 가진 사람만 사용하도록 제한하는 등 서버 운영의 효율성을 높이는 방법을 배우게 될 겁니다. 기대해주세요!
'DiscordJS 개발 튜토리얼' 카테고리의 다른 글
[DiscordJS 봇 개발 튜토리얼] 5. 임베드 메시지와 버튼 만들기: 봇과의 소통을 더 풍부하게! (0) | 2025.06.06 |
---|---|
[DiscordJS 봇 개발 튜토리얼] 4. 명령어 쿨타임과 안정적인 오류 처리 (1) | 2025.06.05 |
[DiscordJS 봇 개발 튜토리얼] 3. 슬래시 명령어: 옵션과 서브커맨드로 더욱 강력하게! (1) | 2025.06.04 |
[DiscordJS 봇 개발 튜토리얼] 2. 명령어 구조 만들기: 슬래시 명령어를 위한 첫걸음 (1) | 2025.06.03 |
[DiscordJS 봇 개발 튜토리얼] 1. 봇 상태 메시지 설정하기 (2) | 2025.06.02 |