Cloudflare Workers로 서버리스 Discord 봇 만들기

게이트웨이로 봇을 띄우면 한 가지 숙명이 따라옵니다. 봇 프로세스가 24시간 살아 있어야 한다는 거예요. 노트북을 끄면 봇도 오프라인이 되니, 늘 켜져 있는 서버나 컨테이너에 올려둬야 하죠. “간단한 슬래시 커맨드 하나 만들려는 건데 서버까지 빌려야 하나?” 싶은 순간이 옵니다.
그런데 슬래시 커맨드나 버튼에만 반응하는 봇이라면, 굳이 항상 켜둘 필요가 없습니다. 요청이 올 때만 깨어나는 서버리스로 만들 수 있거든요. 이번 글에서는 Cloudflare Workers에 슬래시 커맨드에 응답하는 봇을 올려서, 호스팅 걱정 없이 배포하는 데까지 해보겠습니다.
왜 서버리스가 되는가
게이트웨이 봇이 항상 켜져 있어야 하는 이유는, 봇이 디스코드로 나가는 WebSocket 연결을 계속 열어두기 때문입니다. 내가 먼저 전화를 걸어 통화를 유지하는 구조라, 전화를 든 프로세스가 죽으면 끝이에요.
HTTP 인터랙션은 정반대입니다. 사용자가 슬래시 커맨드를 부를 때만, 디스코드가 내가 등록한 URL로 요청을 POST해 줍니다. 평소엔 아무도 나를 부르지 않으니 꺼져 있어도 돼요. 요청이 들어오는 순간에만 코드가 실행되는 서버리스와 완벽히 맞아떨어지는 이유입니다.
| 구분 | 게이트웨이 | HTTP 인터랙션(서버리스) |
|---|---|---|
| 연결 방향 | 봇이 여는 상시 WebSocket(아웃바운드) | 디스코드가 부르는 HTTP(인바운드) |
| 호스팅 | 24시간 상시 실행 | 요청이 올 때만 실행 |
| 받는 것 | 인터랙션 + 서버 이벤트(메시지, 입장, 리액션 등) | 인터랙션만 |
| 멤버 목록 | 온라인 | 오프라인(회색) |
표의 마지막 두 줄이 서버리스의 대가입니다. 이 방식으로 받을 수 있는 건 인터랙션(슬래시 커맨드, 버튼, 모달)뿐이라, 멤버 입장이나 메시지 같은 서버 이벤트는 여전히 게이트웨이라야 받을 수 있어요. 그리고 게이트웨이 연결이 없으니 봇은 멤버 목록에 오프라인(회색)으로 표시됩니다. 다만 커맨드는 멀쩡히 동작하니, “오프라인인데 잘 되는 봇”이 되는 거죠.
Workers 프로젝트 만들기
C3(create-cloudflare)로 빈 워커를 하나 만듭니다.
npm create cloudflare@latest -- my-discord-bot
프롬프트에서 “Hello World” 예제와 언어를 고르면 기본 골격이 생깁니다. 이때 만들어지는 wrangler.jsonc가 워커의 설정 파일이에요.
{
"name": "my-discord-bot",
"main": "src/index.js",
"compatibility_date": "2026-07-01",
}
그리고 서명 검증에 쓸 라이브러리 하나만 추가합니다.
npm install discord-interactions
서명 검증이 왜 필수인가
게이트웨이 봇은 토큰으로 자신을 증명했지만, 서버리스 봇의 엔드포인트, 그러니까 우리가 배포한 워커 주소(https://...workers.dev)는 인터넷에 공개돼 있어서 디스코드뿐 아니라 누구나 POST를 던질 수 있습니다. 그래서 디스코드는 보내는 모든 요청에 Ed25519 서명을 붙이고, 우리 워커가 그게 진짜 디스코드가 보낸 요청인지 검증해야 해요.
그런데 헷갈리기 쉬운 게, 검증은 우리 워커가 하는데 등록을 거부하는 건 디스코드라는 점이에요. 엔드포인트 URL을 저장하는 순간, 디스코드가 우리 워커로 PING 하나와 일부러 서명을 틀리게 만든 요청 몇 개를 보내 시험합니다. 우리 워커가 PING엔 PONG으로 답하고 잘못된 서명은 401로 걷어내지 못하면, 디스코드가 그 URL 저장을 거부해요. 디스코드가 서명을 대신 검사해주는 게 아니라, 우리가 검증을 제대로 하는지를 등록 때 확인하는 셈입니다.
discord-interactions의 verifyKey가 이 검증을 대신해 줍니다. 여기서 딱 하나 조심할 게 있는데, 검증에 넘기는 본문은 JSON.parse를 거치지 않은 원본 문자열이어야 해요. 파싱하면서 공백 하나만 달라져도 서명이 어긋나거든요.
워커 코드
src/index.js를 다음처럼 작성합니다.
import {
verifyKey,
InteractionType,
InteractionResponseType,
} from "discord-interactions";
export default {
async fetch(request, env) {
// 1. 서명 검증
const signature = request.headers.get("X-Signature-Ed25519");
const timestamp = request.headers.get("X-Signature-Timestamp");
const body = await request.text(); // 파싱 전 원본 문자열이어야 합니다
const isValid =
signature &&
timestamp &&
(await verifyKey(body, signature, timestamp, env.DISCORD_PUBLIC_KEY));
if (!isValid) return new Response("잘못된 서명", { status: 401 });
const interaction = JSON.parse(body);
// 2. 디스코드가 URL 등록 시 보내는 PING엔 PONG으로 답합니다
if (interaction.type === InteractionType.PING) {
return Response.json({ type: InteractionResponseType.PONG });
}
// 3. 슬래시 커맨드에 응답합니다
if (interaction.type === InteractionType.APPLICATION_COMMAND) {
return Response.json({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: "퐁! 🏓" },
});
}
return new Response("알 수 없는 인터랙션", { status: 400 });
},
};
게이트웨이 봇의 client.on(Events.InteractionCreate, ...)와 비교하면, 결국 하는 일은 같아요. 다른 점은 디스코드가 HTTP로 찔러주고 우리는 HTTP 응답으로 답한다는 것뿐입니다. interaction.data.name으로 어떤 커맨드인지 구분하는 것도 게이트웨이 방식과 똑같고요.
배포하고 엔드포인트 등록하기
이제 공개 URL로 띄울 차례입니다. 먼저 Public Key를 시크릿으로 넣습니다. 개발자 포털 General Information(일반 정보)의 Public Key(공개 키) 값이에요.
npx wrangler secret put DISCORD_PUBLIC_KEY
그리고 배포합니다.
npx wrangler deploy
배포가 끝나면 https://my-discord-bot.<계정>.workers.dev 같은 주소가 나옵니다. 이 주소를 개발자 포털 General Information의 Interactions Endpoint URL에 붙여넣고 저장하면, 디스코드가 곧바로 그 주소로 PING을 보내요. 위 코드가 PONG으로 답하니 저장이 통과되면 연결이 완성된 겁니다.
커맨드 등록은 여전히 REST
한 가지 헷갈리기 쉬운 지점이 있어요. 방금 만든 워커는 커맨드가 실행됐을 때 응답하는 쪽이고, /ping 같은 커맨드를 디스코드에 등록하는 건 별개의 작업입니다. 이 등록은 서버리스든 아니든 똑같이 REST 요청 한 번으로 하는데, 슬래시 커맨드를 등록할 때 쓴 deploy-commands 스크립트를 그대로 쓰면 돼요. 로컬에서 한 번 실행해 커맨드를 올려두면, 그다음부터 사용자가 부를 때마다 디스코드가 우리 워커를 호출합니다.
3초 규칙을 서버리스에서 푸는 법
인터랙션에는 받은 뒤 3초 안에 응답해야 한다는 규칙이 있습니다. 데이터베이스 조회나 외부 API 호출로 3초를 넘길 것 같으면, 게이트웨이 봇은 deferReply()로 시간을 벌었죠. 서버리스에서도 원리는 같지만, “응답을 먼저 보내고 뒷일을 처리”하는 방식이 조금 특별합니다.
먼저 “생각 중” 상태인 지연 응답을 즉시 돌려보내 3초 규칙을 만족시키고, 실제 무거운 작업은 응답을 보낸 뒤에 이어서 합니다. Cloudflare Workers는 ctx.waitUntil()로 “응답은 이미 보냈지만 이 작업은 끝까지 실행해줘”를 표현할 수 있어요.
export default {
async fetch(request, env, ctx) {
// ...서명 검증과 파싱은 위와 동일...
if (interaction.type === InteractionType.APPLICATION_COMMAND) {
// 1. "생각 중" 지연 응답을 즉시 반환해 3초 규칙을 통과합니다
ctx.waitUntil(handleSlowWork(interaction, env));
return Response.json({
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
});
}
},
};
// 2. 응답을 보낸 뒤 백그라운드에서 결과를 채워 넣습니다
async function handleSlowWork(interaction, env) {
const data = await fetchSomethingSlow();
// 원래 응답을 수정하는 팔로업 요청 (인터랙션 토큰이 인증을 대신합니다)
await fetch(
`https://discord.com/api/v10/webhooks/${env.DISCORD_APPLICATION_ID}/${interaction.token}/messages/@original`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: `결과: ${data}` }),
},
);
}
핵심은 ctx.waitUntil()이에요. 이게 없으면 워커는 return 즉시 종료되면서 뒷작업이 잘려버립니다. 그리고 팔로업 요청에는 봇 토큰이 필요 없다는 점도 눈여겨보세요. 인터랙션 토큰(interaction.token) 자체가 “이 인터랙션에 답할 권한”을 담고 있어서, URL에 실어 보내면 됩니다(유효 시간은 15분이에요). 참고로 DISCORD_APPLICATION_ID는 민감한 값이 아니라 시크릿 대신 wrangler.jsonc의 vars에 넣어도 됩니다.
마치며
Cloudflare Workers에 슬래시 커맨드 봇을 올리면서 서버리스 Discord 봇의 뼈대를 세웠습니다. 공개 URL이라 반드시 필요한 Ed25519 서명 검증, wrangler로 배포하고 Interactions Endpoint URL에 등록하는 흐름, 그리고 3초 규칙을 waitUntil로 우회하는 서버리스 특유의 패턴까지요.
정리하면 선택 기준은 뚜렷합니다. 멤버 입장 감지나 메시지 반응 같은 이벤트 기반 봇이라면 게이트웨이가 답이고, 슬래시 커맨드와 버튼으로 충분하고 호스팅을 관리하기 싫다면 오늘 만든 서버리스가 훨씬 홀가분합니다. 둘이 인터랙션을 받는 두 갈래라는 걸 기억해두면, 상황에 맞게 고를 수 있어요.
더 자세한 내용은 Discord 인터랙션 문서와 Cloudflare Workers 문서를 참고하세요.
This work is licensed under CC BY 4.0