Discord REST API 직접 호출하기: 인증과 레이트 리밋

Discord REST API 직접 호출하기: 인증과 레이트 리밋

디스코드 봇을 만들어 서버에 초대해 뒀다면, 이제 그 봇으로 실제로 무언가를 시켜볼 차례인데요. 가장 먼저 해볼 일은 채널에 메시지 하나 보내는 겁니다. 보통은 discord.js 같은 라이브러리가 이걸 한 줄로 처리해 주지만, 이번 글에서는 라이브러리를 걷어내고 Discord REST API를 맨손으로 직접 호출해 보겠습니다.

굳이 왜 이걸 알아야 할까요? 우선 라이브러리가 무엇을 대신해 주는지 알면 문제가 생겼을 때 디버깅이 쉬워집니다. 또 Cloudflare Workers 같은 서버리스 환경에서는 무거운 라이브러리 없이 fetch 한 번으로 메시지를 보내고 싶을 때가 많거든요. 봇이 디스코드와 주고받는 방향 가운데 나가는 쪽(REST)을 이번에 직접 만져보는 셈입니다.

Discord REST API의 기본

모든 요청은 하나의 베이스 URL에서 출발합니다.

https://discord.com/api/v10

여기서 v10이 중요한데요. Discord API는 버전이 매겨져 있고, 경로에 버전을 명시적으로 박는 게 원칙입니다. 버전을 생략하면 기본값으로 처리되긴 하지만, 어느 날 기본 버전이 올라가면서 응답 형식이 바뀌어 코드가 깨질 수 있어요. v10처럼 고정해두면 그런 사고를 막을 수 있습니다.

인증: 봇 토큰을 헤더에 싣기

REST API에 “나는 이 봇이다”라고 증명하려면 Authorization 헤더에 토큰을 실어 보냅니다. 봇 토큰은 반드시 Bot 접두사를 붙여야 해요.

Authorization: Bot 여기에_봇_토큰

한 가지 의외로 자주 놓치는 게 User-Agent 헤더입니다. Discord는 User-Agent가 없거나 형식이 이상한 요청을 막아버리는데, 이때는 Discord가 아니라 Cloudflare가 먼저 차단해서 봇 토큰이 멀쩡한데도 요청이 거부되는 황당한 상황이 벌어집니다. 형식은 이렇습니다.

User-Agent: DiscordBot (https://내-프로젝트-url, 1.0)

첫 요청: 채널에 메시지 보내기

이제 실제로 메시지를 보내볼게요. 채널에 메시지를 보내는 엔드포인트는 POST /channels/{channel.id}/messages입니다. 채널 ID는 디스코드 앱에서 개발자 모드를 켜고 채널을 우클릭해 “채널 ID 복사”로 얻습니다. 봇을 초대할 때 Send Messages 권한을 줬으니 바로 보낼 수 있어요.

curl -X POST "https://discord.com/api/v10/channels/채널_ID/messages" \
  -H "Authorization: Bot $DISCORD_TOKEN" \
  -H "Content-Type: application/json" \
  -H "User-Agent: DiscordBot (https://example.com, 1.0)" \
  -d '{"content": "REST API로 직접 보낸 메시지예요!"}'

요청이 성공하면 Discord는 방금 만들어진 메시지 객체를 JSON으로 돌려줍니다. 똑같은 일을 자바스크립트 fetch로 하면 이렇게 되고요.

const res = await fetch(
  "https://discord.com/api/v10/channels/채널_ID/messages",
  {
    method: "POST",
    headers: {
      Authorization: `Bot ${process.env.DISCORD_TOKEN}`,
      "Content-Type": "application/json",
      "User-Agent": "DiscordBot (https://example.com, 1.0)",
    },
    body: JSON.stringify({ content: "REST API로 직접 보낸 메시지예요!" }),
  },
);

console.log(res.status); // 200

보시다시피 특별한 SDK 없이 표준 fetch만으로 끝납니다. 이게 서버리스 환경에서 REST API를 직접 쓰는 이유예요.

임베드로 리치 메시지 보내기

밋밋한 텍스트 대신 제목, 색상 띠, 필드가 있는 임베드(embed)를 보내고 싶을 때도 같은 엔드포인트를 씁니다. 페이로드의 contentembeds 배열로 바꾸기만 하면 돼요.

curl -X POST "https://discord.com/api/v10/channels/채널_ID/messages" \
  -H "Authorization: Bot $DISCORD_TOKEN" \
  -H "Content-Type: application/json" \
  -H "User-Agent: DiscordBot (https://example.com, 1.0)" \
  -d '{
    "embeds": [{
      "title": "배포 완료 🚀",
      "description": "v1.2.0이 프로덕션에 반영되었습니다.",
      "color": 5814783
    }]
  }'

여기서 color는 16진수 색상을 10진수 정수로 변환한 값이에요. 예를 들어 #58B9FF5814783이 됩니다. 이렇게 한 메시지에 임베드를 최대 10개까지 담을 수 있어서, 릴리스 공지나 모니터링 알림을 보기 좋게 꾸밀 때 유용합니다.

스노플레이크 ID는 왜 문자열일까

요청을 주고받다 보면 채널 ID, 메시지 ID, 사용자 ID가 전부 1234567890123456789 같은 긴 숫자인 걸 보게 됩니다. 이걸 스노플레이크(Snowflake) ID라고 부르는데, 트위터가 만든 방식을 가져온 거예요. 64비트 정수 안에 생성 시각 같은 정보가 인코딩돼 있어서, ID만 봐도 언제 만들어졌는지 계산할 수 있습니다.

그런데 Discord API는 이 ID를 항상 문자열로 돌려줍니다. 이유가 있어요. 자바스크립트의 Number는 약 2의 53제곱까지만 정수를 안전하게 표현하는데, 64비트 스노플레이크는 그 범위를 훌쩍 넘깁니다. 숫자로 다루면 뒷자리가 뭉개지는 정밀도 손실이 생기죠. 그래서 문자열로 주고받는 거고, 우리도 ID를 숫자로 변환하지 말고 문자열 그대로 다뤄야 합니다.

레이트 리밋: 직접 호출의 가장 큰 함정

라이브러리 없이 API를 직접 두드릴 때 반드시 만나는 벽이 레이트 리밋(rate limit)입니다. Discord는 스팸과 과부하를 막으려고 요청 횟수를 제한하는데, 구조가 두 겹이에요.

하나는 엔드포인트별 제한입니다. 응답 헤더를 보면 현재 상태를 알 수 있어요.

  • X-RateLimit-Remaining: 이 버킷에서 앞으로 몇 번 더 보낼 수 있는지
  • X-RateLimit-Reset-After: 몇 초 뒤에 한도가 초기화되는지(소수점 포함)
  • X-RateLimit-Bucket: 이 제한이 묶인 버킷의 식별자

흥미로운 점은 제한이 최상위 리소스(channel_id, guild_id 등) 단위로 따로 적용된다는 거예요. 즉 A 채널에 메시지를 많이 보내 한도가 찼어도, B 채널로는 멀쩡히 보낼 수 있습니다. 같은 엔드포인트라도 리소스가 다르면 별개의 버킷이거든요.

다른 하나는 글로벌 제한입니다. 모든 봇은 전체 엔드포인트를 통틀어 초당 50회까지만 요청할 수 있어요. 이걸 넘기면 봇 전체가 막힙니다.

한도를 넘기면 429 Too Many Requests 응답이 오는데, 본문에 얼마나 기다려야 하는지가 담겨 있습니다.

{
  "message": "You are being rate limited.",
  "retry_after": 1.337,
  "global": false
}

가장 중요한 건 retry_after(초)만큼 반드시 기다렸다가 재시도하는 것입니다. 429를 무시하고 계속 두드리면 더 큰 문제가 생기는데요. Discord는 401, 403, 429 같은 잘못된 요청이 10분에 1만 건을 넘으면 IP 자체를 일시 차단합니다. 그러니 에러가 났을 때 무한 재시도를 도는 코드는 절대 금물이에요.

가장 안전한 패턴은 429를 만나기 전에 헤더로 미리 속도를 조절하는 것입니다.

if (res.status === 429) {
  const { retry_after } = await res.json();
  console.log(`레이트 리밋! ${retry_after}초 후 재시도`);
  await new Promise((r) => setTimeout(r, retry_after * 1000));
  // ...여기서 같은 요청을 다시 보냅니다
}

특권 인텐트: 전송은 되는데 조회가 막힐 때

지금까지 본 메시지 전송은 봇 토큰과 권한만 맞으면 곧바로 됐는데요. 그런데 모든 REST 엔드포인트가 평등하진 않습니다. 서버의 전체 멤버 목록을 가져오는 GET /guilds/{guild.id}/members 같은 엔드포인트는, 봇 토큰이 멀쩡해도 그냥은 응답을 주지 않아요. Developer Portal의 Server Members Intent 토글을 켜야 비로소 열립니다.

# Server Members Intent 토글을 켜지 않으면 이 요청은 거부됩니다
curl "https://discord.com/api/v10/guilds/서버_ID/members?limit=100" \
  -H "Authorization: Bot $DISCORD_TOKEN" \
  -H "User-Agent: DiscordBot (https://example.com, 1.0)"

이게 게이트웨이 글에서 본 특권 인텐트의 나머지 반쪽입니다. ‘인텐트’라는 이름 탓에 게이트웨이(실시간 이벤트) 전용 개념처럼 보이지만, 특권 인텐트 세 가지(Server Members, Presence, Message Content)는 게이트웨이와 무관하게 일부 REST 엔드포인트 접근까지 함께 잠급니다. 멤버 목록뿐 아니라, Message Content 토글이 꺼져 있으면 봇을 멘션하지 않은 다른 사용자의 메시지 본문(content)이 REST 응답에서도 비어서 옵니다.

핵심은 방향이에요. 우리가 앞에서 한 메시지 전송은 특권 인텐트와 상관없습니다. 민감한 데이터를 읽는 쪽만 잠기거든요. 그래서 게이트웨이를 아예 열지 않는 서버리스 봇이라도, 멤버 목록을 REST로 조회하려면 이 토글이 필요해요. User-Agent 함정처럼 토큰은 멀쩡한데 조회가 막힌다면, 이 토글부터 확인해 보세요.

라이브러리가 대신해 주던 것

여기까지 오면 discord.js 같은 라이브러리가 한 줄로 처리해 주는 게 사실 얼마나 많은 일인지 보입니다. discord.js의 내부 REST 클라이언트는 인증 헤더와 User-Agent를 자동으로 붙이고, 버킷별로 요청 큐를 관리하면서, 429를 만나면 retry_after만큼 알아서 기다렸다가 재시도합니다. 스노플레이크도 문자열로 안전하게 다루고요.

실제로 앞에서 fetch로 짠 메시지 전송을, discord.js가 제공하는 그 REST 클라이언트로 바꾸면 이렇게 짧아져요. 게이트웨이에 연결할 필요 없이 REST 요청만 보내면 되니, 봇을 상시 띄우지 않고 한 번씩 메시지를 쏘는 스크립트에도 잘 맞습니다.

import { REST, Routes } from "discord.js";

const rest = new REST().setToken(process.env.DISCORD_TOKEN);

// 앞의 fetch와 똑같은 POST /channels/{channel.id}/messages 요청이에요
await rest.post(Routes.channelMessages("채널_ID"), {
  body: { content: "discord.js REST 클라이언트로 보낸 메시지예요!" },
});

Routes.channelMessages("채널_ID")/channels/{channel.id}/messages 경로를 만들어주고, rest.postBot 인증 헤더와 User-Agent, 레이트 리밋 처리까지 전부 알아서 챙깁니다. 우리가 앞에서 손으로 넣던 헤더 세 개가 통째로 사라진 셈이죠. (평소 channel.send() 같은 고수준 메서드도 안에서는 결국 이 REST 클라이언트를 호출합니다.)

그래서 평범한 봇이라면 라이브러리에 맡기는 게 맞습니다. 다만 서버리스처럼 라이브러리를 통째로 올리기 부담스러운 환경에서는, 오늘 본 것처럼 fetch 한 번에 헤더 세 개만 챙기면 충분해요. 언제 라이브러리를 쓰고 언제 직접 호출할지 고를 수 있게 된 것, 그게 이 글의 수확입니다.

마치며

라이브러리를 걷어내고 Discord REST API를 직접 호출하면서 인증, User-Agent, 스노플레이크, 그리고 레이트 리밋까지 “나가는 방향”의 토대를 다졌습니다. 이제 디스코드에 무언가를 시키는 법은 익혔으니, 이번엔 반대로 들어오는 방향을 볼 차례예요.

디스코드가 보내오는 슬래시 커맨드와 인터랙션을 받아 처리하는 쪽도 이어서 보면 좋은데, 커맨드를 등록하는 것 역시 오늘 배운 REST 요청이라는 걸 확인하게 될 거예요.

더 자세한 내용은 Discord API 레퍼런스를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord