Discord.js에서 Component V2 사용하기

텍스트 명령어로만 작동하는 디스코드 봇을 만들다 보면 사용자 입장에서 다소 아쉬운 순간들이 생기곤 합니다. 유저가 매번 복잡한 명령어나 인자값을 오타 없이 키보드로 쳐야 하고, 어떤 기능이 있는지 일일이 도움말을 뒤적여야 하기 때문인데요.
저 역시 처음에는 텍스트 기반 봇만 고집하다가, 사용자들이 명령어를 제대로 기억하지 못해 방치되는 모습을 보고 큰 고민에 빠졌습니다. 어떻게 하면 웹사이트를 쓰는 것처럼 쉽고 직관적인 UI를 제공할 수 있을까 고민하던 중, 디스코드의 메시지 컴포넌트 기능을 도입해 문제를 해결할 수 있었습니다.
이번 글에서는 자바스크립트 환경에서 discord.js v14 라이브러리를 활용해 화면에 깔끔한 버튼과 드롭다운 선택 메뉴를 구성하고, 이벤트 수집기 모듈을 통해 대규모 서버에서도 메모리 누수 없이 안정적으로 상호작용을 제어하는 고도화된 구현법까지 자세하게 공유해 드릴게요.
1. 컴포넌트의 도입 배경과 설계 구조의 이해
과거 디스코드 봇 개발에서는 접두사를 사용한 명령어 입력 방식이 표준이었습니다. 하지만 최근에는 슬래시 기호를 활용한 명령어와 함께, 버튼이나 드롭다운 같은 입체 컴포넌트를 제공하는 방식이 표준으로 자리 잡았는데요. 이렇게 구성하면 유저가 키보드를 만지지 않고도 마우스 클릭 몇 번으로 원하는 동작을 즉각 수행할 수 있어 사용자 경험이 획기적으로 개선됩니다.
디스코드에서 컴포넌트를 설계할 때 가장 먼저 이해해야 할 기초 원리는 바로 액션 로우라는 가상의 바구니 시스템입니다. 모든 컴포넌트는 단독으로 메시지에 담겨 전송될 수 없으며, 가로형 바구니 역할을 하는 액션 로우 안에 차곡차곡 담겨서 전송되어야 합니다.
- 하나의 메시지에는 최대 5개의 액션 로우를 포함할 수 있습니다.
- 하나의 액션 로우에는 최대 5개의 버튼을 담을 수 있습니다.
- 드롭다운 메뉴는 부피가 크기 때문에 하나의 액션 로우당 단 1개만 배치할 수 있습니다.
2. 버튼 생성 시 선택할 수 있는 5가지 스타일 속성
discord.js v14에서는 빌더 패턴을 사용하여 객체지향적인 방식으로 컴포넌트를 조립합니다. 버튼을 만들 때는 상황에 맞게 5가지 시각적 스타일 중 하나를 지정할 수 있는데요. 각 스타일의 특징을 테이블로 정리해 보았습니다.
| 스타일 종류 | 시각적 특징 | 주요 권장 용도 | 고유 식별자 설정 가능 여부 |
|---|---|---|---|
| Primary | 파란색 단추 | 긍정적인 행위 진행, 다음 단계 이동 | 가능 |
| Secondary | 회색 단추 | 일반적인 부가 옵션 선택, 뒤로 가기 | 가능 |
| Success | 초록색 단추 | 최종 승인, 구매 확정, 완료 처리 | 가능 |
| Danger | 빨간색 단추 | 삭제, 차단, 작업 거절, 위험 행위 | 가능 |
| Link | 회색 단추 + 화살표 | 외부 사이트나 웹 페이지 링크 연결 | 불가능 (URL 주소값만 매핑 가능) |
여기서 가장 중요한 기술적 차이는 Link 스타일 버튼입니다. 다른 네 가지 버튼은 클릭 시 봇 서버로 이벤트 정보를 전달하기 위한 고유 식별자값을 필수값으로 요구하지만, Link 버튼은 웹 링크로 바로 연결되므로 식별자값을 부여할 수 없습니다.
3. 실전 코딩: 이모지가 포함된 비활성화 버튼 구현하기
아래 예제는 명령어가 입력되었을 때, 동작을 수행할 수 있는 성공 버튼과 일시적으로 잠겨 있는 비활성화 버튼을 나란히 출력하는 예시 코드입니다.
const {
Client,
GatewayIntentBits,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.on('interactionCreate', async interaction => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === '버튼생성') {
// 활성화된 초록색 버튼
const activeBtn = new ButtonBuilder()
.setCustomId('btn_active')
.setLabel('신청하기')
.setStyle(ButtonStyle.Success);
// 자물쇠 아이콘이 들어가 있고 클릭이 막힌 버튼
const disabledBtn = new ButtonBuilder()
.setCustomId('btn_disabled')
.setLabel('마감됨')
.setStyle(ButtonStyle.Secondary)
.setDisabled(true); // 버튼 비활성화 설정
const row = new ActionRowBuilder().addComponents(activeBtn, disabledBtn);
await interaction.reply({
content: '아래 버튼을 클릭해 신청을 완료해 주세요.',
components: [row]
});
}
});
4. 다양한 드롭다운 컴포넌트의 활용
사용자에게 단순한 예/아니오 이상의 옵션을 제공하고 싶다면 선택 메뉴를 도입하는 것이 이상적입니다. 기존에는 문자열 기반의 선택 메뉴만 가능했지만, v14로 넘어오면서 디스코드의 특성에 최적화된 여러 형태의 전용 메뉴들이 대거 추가되었습니다.
- StringSelectMenuBuilder: 일반적인 옵션 명칭들을 텍스트 목록으로 제공합니다.
- UserSelectMenuBuilder: 서버에 소속된 멤버 목록을 드롭다운에 자동으로 불러와 보여줍니다.
- RoleSelectMenuBuilder: 서버에 등록된 역할 목록을 자동으로 채워 유저가 역할을 선택할 수 있게 돕습니다.
- ChannelSelectMenuBuilder: 채팅이나 보이스 채널 목록을 자동으로 불러옵니다.
역할이나 멤버 리스트를 직접 코드로 배열에 매핑해 줄 필요 없이, 전용 빌더 객체 하나만 선언하면 디스코드가 실시간 데이터를 바인딩하여 띄워주기 때문에 개발 편의성이 대단히 높습니다.
5. 이벤트 수집기 도입을 통한 메모리 누수 방지 기법
컴포넌트를 띄워준 뒤 이에 반응하는 핸들러를 전역 리스너에 등록해 두면 큰 문제가 생길 수 있습니다. 이미 시간이 한참 지난 오래된 메시지의 버튼 클릭 이벤트도 감지하기 위해 메모리가 계속해서 할당되어 있기 때문인데요. 봇 서비스의 부하를 줄이려면 특정 시간 동안만 작동하고 알아서 폐기되는 수집기를 사용해야 합니다.
client.on('interactionCreate', async interaction => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === '설문조사') {
const optionA = new ButtonBuilder()
.setCustomId('opt_a')
.setLabel('찬성')
.setStyle(ButtonStyle.Primary);
const optionB = new ButtonBuilder()
.setCustomId('opt_b')
.setLabel('반대')
.setStyle(ButtonStyle.Danger);
const row = new ActionRowBuilder().addComponents(optionA, optionB);
const response = await interaction.reply({
content: '이번 안건에 찬성하십니까?',
components: [row],
fetchReply: true // 응답 객체를 반환받아 수집기의 대상을 타겟팅하기 위해 설정
});
// 1. 해당 메시지 내의 인터랙션을 수집할 수집기 인스턴스 생성 (제한 시간 30초)
const collector = response.createMessageComponentCollector({
time: 30000
});
// 2. 유저가 버튼을 눌렀을 때의 동작 핸들링
collector.on('collect', async btnInteraction => {
// 본 설문을 호출한 당사자만 투표할 수 있도록 필터링 처리
if (btnInteraction.user.id !== interaction.user.id) {
return btnInteraction.reply({
content: '본인만 참여할 수 있는 투표입니다.',
ephemeral: true
});
}
const vote = btnInteraction.customId === 'opt_a' ? '찬성' : '반대';
await btnInteraction.update({
content: `투표가 정상 반영되었습니다. 선택: ${vote}`,
components: [] // 투표가 끝나면 화면에서 버튼을 지워 중복 제출 방지
});
});
// 3. 30초 시간 초과 시 작동할 엔드 이벤트 정의
collector.on('end', async collected => {
console.log(`총 ${collected.size}개의 응답이 수집되었습니다.`);
});
}
});
6. 트러블슈팅과 대기 신호 피드백 처리
인터랙션 버튼을 누른 사용자에게 봇이 응답을 전송할 때 3초 이내에 콜백을 반환하지 않으면 디스코드 클라이언트는 즉시 작동 중단 상태로 판단해 붉은색 오류 텍스트를 출력합니다.
이를 해결하기 위해 데이터베이스 접근이나 외부 API 호출 등 무거운 로직이 실행되기 전, 반드시 await btnInteraction.deferUpdate() 또는 await btnInteraction.deferReply()를 실행하여 클라이언트와의 세션을 연장해 주어야 합니다.
오늘 다룬 고급 수집기 로직과 액션 로우 컴포넌트 조합을 적재적소에 배치해 보신다면, 훨씬 정교하고 높은 수준의 성능을 자랑하는 봇을 탄탄하게 구축하실 수 있을 것입니다.