Discord 권한과 역할 이해하기

Discord 권한과 역할 이해하기

버튼으로 “삭제” 같은 UI를 만들다 보면, 그 버튼을 아무나 누르면 곤란하다는 생각이 들죠. 봇을 운영하다 보면 “이 명령은 관리자만”, “이 채널에서는 봇이 말을 못 하네?” 같은 권한 문제를 반드시 마주칩니다. 이번 글에서는 Discord의 권한 체계를 라이브러리 없이 제대로 이해해 봅니다.

권한은 비트 플래그다

Discord의 권한은 하나하나가 비트(bit)입니다. 메시지 보내기, 멤버 차단하기, 채널 관리하기 같은 각 권한이 2진수의 한 자리를 차지하고, 여러 권한을 가지면 그 비트들을 OR 연산(|)으로 합쳐 하나의 정수로 표현해요. 권한을 확인할 때는 반대로 AND 연산(&)으로 특정 비트가 켜져 있는지 봅니다.

예를 들어 멤버 추방(KickMembers)이 값 2, 메시지 관리(ManageMessages)가 8192라면, 둘 다 가진 권한은 2 | 8192 = 8194가 됩니다. 이 값이 워낙 커질 수 있어서 Discord API는 권한을 문자열로 주고받아요(스노플레이크와 같은 이유로 정밀도 손실을 막기 위해서죠).

라이브러리는 PermissionFlagsBits.ManageMessages 같은 이름으로 이 비트를 감춰주지만, 라이브러리 없이 다루면 비트를 직접 만집니다. 값이 커서 자바스크립트에선 BigInt로 다뤄야 하는데(1n << 13nManageMessages), 이 원리를 알면 봇 초대 링크의 permissions=8 같은 숫자도 읽을 수 있게 돼요(8은 Administrator입니다).

역할이 권한을 나른다

서버에서 권한은 역할(role)을 통해 부여됩니다. 멤버가 실제로 가지는 기본 권한은, 모든 멤버가 자동으로 갖는 @everyone 역할의 권한에다, 그 멤버에게 부여된 모든 역할의 권한을 전부 OR로 합친 값이에요. 즉 역할을 여러 개 가지면 권한이 합쳐져 늘어납니다.

참고로 @everyone 역할은 서버의 기본 역할인데, 그 ID가 서버(길드) ID와 똑같습니다. 코드에서 권한을 다룰 때 알아두면 헷갈리지 않아요.

Administrator는 만능 열쇠

권한 중 딱 하나는 특별합니다. Administrator 권한을 가진 멤버는 다른 모든 권한을 자동으로 갖고, 심지어 뒤에 설명할 채널 오버라이트까지 전부 무시합니다. 말 그대로 만능 열쇠예요.

그래서 봇에게 권한을 줄 때 “편하니까 Administrator 하나만 주자”는 유혹이 생기는데, 이건 위험합니다. 봇 토큰이 유출되면 서버 전체가 통째로 넘어가니까요. 봇을 초대할 때 강조했듯 봇 권한은 항상 필요한 만큼만 최소로 주는 게 안전합니다.

채널 오버라이트

역할로 정해진 권한은 서버 전체에 적용되는 “기본값”인데요. 특정 채널에서만 예외를 두고 싶을 때 채널 오버라이트(overwrite)를 씁니다. “이 비공개 채널은 특정 역할만 보기”나 “이 공지 채널은 관리자만 쓰기” 같은 게 다 오버라이트예요.

오버라이트는 각 권한을 허용(allow)하거나 거부(deny)하는 식으로 동작하고, 다음 순서로 층층이 쌓여 최종 권한이 계산됩니다.

  1. 역할로 정해진 서버 기본 권한
  2. @everyone 채널 오버라이트
  3. 특정 역할들의 채널 오버라이트
  4. 특정 멤버의 채널 오버라이트

뒤 단계가 앞 단계를 덮어쓰기 때문에, 같은 권한이라도 어느 층에서 허용/거부했느냐에 따라 결과가 달라집니다. 봇이 “분명 권한을 줬는데 이 채널에선 말을 못 하는” 상황은 십중팔구 채널 오버라이트에서 거부돼 있는 경우예요.

코드에서 권한 확인하기

이제 코드로 넘어가 볼게요. 슬래시 커맨드를 실행한 사용자가 특정 권한을 가졌는지 확인할 때, 반가운 사실이 하나 있어요. 인터랙션 payload의 interaction.member.permissions디스코드가 그 채널에서 이미 계산해준 최종 권한 비트(문자열)가 담겨 옵니다. 위에서 본 역할 합산이나 오버라이트를 우리가 계산할 필요 없이, 이 문자열을 BigInt로 바꿔 비트 AND만 하면 돼요. (아래 코드는 서버리스 봇fetch 핸들러 안에서 돌아갑니다.)

// interaction.member.permissions = 그 채널에서 계산된 권한 비트(문자열)
const perms = BigInt(interaction.member.permissions);
const MANAGE_MESSAGES = 1n << 13n; // 8192

if ((perms & MANAGE_MESSAGES) !== MANAGE_MESSAGES) {
  return Response.json({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: {
      content: "이 명령은 메시지 관리 권한이 있어야 쓸 수 있어요.",
      flags: 64, // ephemeral
    },
  });
}
// 여기서부터는 권한이 있는 사용자만 도달합니다

봇 자신의 권한이 궁금하면 interaction.app_permissions를 보면 됩니다. 똑같이 그 채널에서 봇이 가진 권한 비트가 계산돼 담겨 오거든요. “내가 이 채널에서 메시지를 보낼 수는 있나?”를 미리 점검할 때 유용해요.

역할 위계라는 함정

권한을 분명히 줬는데도 봇이 특정 멤버를 차단하지 못하는 경우가 있어요. 십중팔구 역할 위계 때문입니다. 서버 설정의 역할 목록 순서가 곧 위계인데, 봇은 자기보다 높은 역할을 가진 멤버를 건드릴 수 없습니다. 봇에게 차단 권한이 있어도, 대상의 가장 높은 역할이 봇의 가장 높은 역할보다 위에 있으면 차단이 거부돼요. 그래서 봇 역할을 관리 대상들보다 위로 올려두는 게 중요합니다.

라이브러리를 쓰면 member.bannable처럼 권한 비트와 위계를 한 번에 계산해주는 속성이 있지만, 라이브러리 없이는 그걸 재현해야 해요. 서버의 모든 역할 position을 가져오고 봇과 대상의 최상위 역할을 비교하는 식으로요. 꽤 번거롭죠. 그래서 raw에서는 흔히 미리 계산하는 대신, 일단 차단을 시도하고 디스코드가 돌려주는 결과로 판단합니다. 멤버 차단은 REST 요청 하나거든요.

const targetId = interaction.data.options.find((o) => o.name === "user").value;

const res = await fetch(
  `https://discord.com/api/v10/guilds/${interaction.guild_id}/bans/${targetId}`,
  {
    method: "PUT",
    headers: {
      Authorization: `Bot ${env.DISCORD_TOKEN}`,
      "User-Agent": "DiscordBot (https://example.com, 1.0)",
    },
  },
);

if (res.status === 403) {
  // 봇에게 차단 권한이 없거나, 대상의 역할이 봇보다 높아 위계에 막힌 경우
  return Response.json({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: { content: "이 멤버는 차단할 수 없어요.", flags: 64 },
  });
}

디스코드가 위계나 권한 때문에 막으면 403을 돌려주니, 그걸 잡아 사용자에게 안내하면 됩니다. member.bannable 같은 사전 검사는 “시도하기 전에” 알려주는 UX 이점이 있지만, 그게 없을 땐 이렇게 디스코드의 응답에 기대는 게 라이브러리 없이 가는 현실적인 길이에요.

커맨드에 아예 권한을 걸어버리기

방금처럼 코드 안에서 일일이 확인하는 것도 되지만, 더 깔끔한 방법이 있습니다. 커맨드를 등록할 때 default_member_permissions에 필요한 권한 비트를 박아두면, 그 권한이 없는 사용자에게는 커맨드가 아예 보이지 않습니다. 이 값도 권한 비트를 문자열로 담아요.

const commands = [
  {
    name: "ban",
    description: "멤버를 차단합니다",
    default_member_permissions: String(1 << 2), // BanMembers = "4"
  },
];

이러면 차단 권한이 없는 일반 멤버는 /ban을 입력해도 자동완성 목록에 뜨지 않아요. 코드가 실행되기 전에 Discord UI 차원에서 막아주니, 사용성과 보안 둘 다 챙기는 셈입니다. 이건 어디까지나 “기본값”이라 서버 관리자가 서버 설정 > 연동에서 서버별로 다시 조정할 수 있다는 점도 알아두면 좋고요.

마치며

권한이 비트 플래그라는 토대부터, 역할로 권한이 합쳐지는 방식, Administrator의 특별함, 채널 오버라이트의 계층, 그리고 코드에서 권한을 다루는 법까지 살펴봤습니다. 라이브러리 없이도, 디스코드가 interaction.member.permissions이미 계산해준 비트BigInt로 확인하고, 위계처럼 까다로운 건 디스코드의 403에 기대면 된다는 게 핵심이었어요. “분명 권한을 줬는데 안 되는” 문제 대부분은 역할 권한과 채널 오버라이트의 상호작용에서 나오니, 이 그림을 기억해 두면 디버깅이 한결 수월해질 거예요.

봇을 넘어 사용자를 직접 인증하는 OAuth2로 시선을 넓히면, “Discord로 로그인”과 봇 초대 링크가 사실 같은 뿌리에서 나온다는 걸 보게 됩니다.

더 자세한 내용은 Discord 권한 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

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

Discord