카테고리: 디스코드 봇 만들기

디스코드 봇 개발자용 2026년 9월 API 변경 총정리 - 파일 20MiB·슬래시 커맨드 여러 줄 입력·연령 확인(Age Assurance)·초대 대상 유저·shard 필수, discord.py 2.7.1로 대응하는 법 (파이썬 예제) 🗞️

한눈에 보기

2026년 8월 말부터 9월까지 디스코드 개발자 체인지로그에 올라온 변경 7가지를 봇 개발자 기준으로 정리하고, 코드를 고쳐야 하는 것과 안 고쳐도 되는 것을 나눠요. discord.py 2.7.1에 아직 없는 기능은 엔드포인트를 직접 호출하는 예제로 대응합니다.

  • discord.py 2.7.1 · Python 3.13 · 2026-09-30 공식 체인지로그 기준으로 확인 (예제는 문법·import 검사까지, 실서버 실행은 사용자 확인 필요)
  • 코드를 꼭 고쳐야 하는 건 대형 봇 샤딩 앱의 shard 쿼리(9/15)와 PRUNE_REQUIRES_ADMIN 서버의 정리 권한(8/27) 두 가지뿐이에요
  • 파일 기본 한도는 20 MiB로 올랐지만 discord.py 2.7.1의 guild.filesize_limit는 아직 10 MiB를 돌려줘요 → interaction.filesize_limit를 믿으세요
  • 문자열 옵션에 줄바꿈이 들어올 수 있으니 splitlines()로 받고, 초대 대상 유저 엔드포인트는 bot.http.request(Route(...))로 직접 호출해요
목차15개 항목

안녕하세요! Mojangcam입니다 😎

지난 #24편에서 11월 16일에 터질 채널 난독화를 미리 밟아봤죠. 그 뒤로 디스코드 개발자 체인지로그에 굵직한 공지가 한 달 사이에 7개나 더 올라왔어요. 하나하나는 짧은데, 모아 놓고 보면 "우리 봇에서 뭘 고쳐야 하지?"가 헷갈리더라고요.

그래서 오늘은 새 기능을 만드는 대신 2026년 8월 말부터 9월까지의 변경을 봇 개발자 눈으로 한 번에 정리할게요. 각 항목마다 "내 봇에 영향이 있나?"와 "코드를 고쳐야 하나?"를 먼저 답하고, discord.py 2.7.1 기준 대응 코드를 붙였어요. 라이브러리에 아직 없는 기능은 엔드포인트를 직접 부르는 방법으로 메꿨습니다 🛠️

이번 글에서 배울 것

  • 9월 변경 7가지를 표 하나로 보는 법 (고칠 것 / 안 고쳐도 되는 것)
  • 파일 한도가 20 MiB로 올랐는데 라이브러리는 아직 10 MiB라고 말하는 함정
  • 슬래시 커맨드 문자열 옵션에 줄바꿈이 들어오기 시작한 뒤의 처리법
  • discord.py에 없는 초대 대상 유저(target user) 엔드포인트를 Route로 직접 호출하기
  • 대형 봇 샤딩 앱의 shard 쿼리, PRUNE_REQUIRES_ADMIN 권한 체크
  • 연령 확인(Age Assurance)·프로필 비공개로 커스텀 상태가 안 보일 때 안전하게 읽기

📌 한눈에 보기 - 9월 변경 7가지

날짜 변경 내 봇에 영향? 코드 수정?
8/27 서버 기능 PRUNE_REQUIRES_ADMIN (Breaking) 멤버 정리(prune) 기능을 쓰는 봇만 권한 체크 추가
9/3 파일 업로드 기본 한도 10 MiB → 20 MiB 파일을 보내는 봇 전부 (좋은 쪽으로) 한도 계산 코드가 있으면
9/11 슬래시 커맨드: 문자열 옵션 여러 줄 입력, 명령어 퍼지 검색, 채널 이동 시 명령 기억 문자열 옵션을 받는 봇 전부 줄바꿈 처리만
9/15 대형 봇 샤딩 앱은 GET /users/@me/guilds에 shard 필수 (Breaking) 대형 봇 샤딩 앱만 필수 (해당 시)
9/17 프로필 비공개 설정이면 커스텀 상태가 Presence에서 빠짐 상태를 읽는 봇 None 처리 확인
9/18 초대 대상 유저 엔드포인트 4개 추가 초대 링크를 만드는 봇 (선택) 쓰고 싶으면
9/22 연령 확인(Age Assurance) 전 세계 적용 없음 (동작 변화 관찰만) 없음

결론부터 말하면 꼭 고쳐야 하는 건 두 개뿐이에요. 대형 봇 샤딩을 쓰는 앱의 shard 쿼리, 그리고 멤버 정리 기능이 있는 봇의 권한 체크. 나머지는 알고 있으면 되는 변화거나, 오히려 봇이 편해지는 변화예요 😌

📌 준비물

  • Python 3.8 이상 (예제는 Python 3.13에서 문법 검사했어요)
  • discord.py 2.7.1 — 2026-09-30 기준 최신 버전이에요. 3월 이후 새 버전이 없어서 오늘 다루는 9월 변경은 라이브러리에 아직 반영되지 않았습니다
  • python-dotenv — 토큰을 .env로 분리하기 위해 필요해요
  • 봇을 초대해 둔 테스트 서버 (관리자 권한이면 편해요)
터미널
pip install discord.py==2.7.1 python-dotenv

프로젝트 폴더에 .env 파일을 만들고 토큰을 넣어주세요.

터미널
DISCORD_TOKEN=여기에_봇_토큰

💡 .env는 꼭 .gitignore에 추가하세요. 깃허브에 토큰이 올라가면 디스코드가 자동으로 감지해서 토큰을 폐기해버려요 😱

오늘 예제는 커스텀 상태를 읽는 부분 때문에 개발자 포털 → Bot 탭에서 Server Members Intent와 Presence Intent를 켜야 해요. 상태 읽기가 필요 없으면 intents.presences = True 줄을 지우고 포털 설정도 건너뛰어도 됩니다.

📌 1. 파일 업로드 기본 한도 20 MiB (9/3)

변경 내용 : 기본 파일 업로드 한도가 10 MiB에서 20 MiB로 올랐어요. 사용자뿐 아니라 봇·웹훅·인터랙션 응답에도 똑같이 적용됩니다.

내 봇에 영향? : 파일을 보내는 봇이라면 그냥 좋은 소식이에요. 부스트 없는 서버에서 15 MB짜리 로그 파일이나 이미지가 이제 올라갑니다.

그런데 함정이 하나 있어요. discord.py 2.7.1 소스를 열어 보면 기본 한도가 아직 옛날 값이에요.

Python
# discord/utils.py (2.7.1)
DEFAULT_FILE_SIZE_LIMIT_BYTES = 10485760   # 10 MiB

그래서 guild.filesize_limit나 ctx.filesize_limit는 부스트 없는 서버에서 여전히 10 MiB를 돌려줍니다. "한도보다 크면 안 보내기" 같은 방어 코드를 이 값으로 짰다면, 실제로는 보낼 수 있는 파일을 봇이 스스로 거절하고 있는 거예요 😅

정답은 디스코드가 인터랙션마다 실어 보내는 값을 쓰는 거예요.

Python
@bot.tree.command(name="파일한도", description="이 서버에서 봇이 올릴 수 있는 파일 크기를 보여줘요")
async def file_limit(interaction: discord.Interaction):
    live = interaction.filesize_limit          # 디스코드가 이번 상호작용에 실어 보낸 값
    cached = interaction.guild.filesize_limit if interaction.guild else None  # 라이브러리 표
    msg = f"디스코드가 알려준 한도: **{live / MIB:.0f} MiB**"
    if cached is not None:
        msg += f"\ndiscord.py 표 기준(guild.filesize_limit): {cached / MIB:.0f} MiB"
    await interaction.response.send_message(msg, ephemeral=True)

하나씩 뜯어볼게요 💡

  • interaction.filesize_limit : discord.py 2.6에서 추가된 속성이에요. 인터랙션 페이로드의 attachment_size_limit 값을 그대로 담아서, 라이브러리 표가 아니라 디스코드 서버가 지금 알려주는 한도예요. 20 MiB 변경이 여기엔 바로 반영됩니다.
  • guild.filesize_limit : 라이브러리 안에 하드코딩된 부스트 단계별 표를 봐요. 2.7.1에서는 0·1단계가 10 MiB, 2단계 50 MiB, 3단계 100 MiB입니다. 다음 버전에서 고쳐지기 전까지는 참고용으로만 보세요.
  • MIB = 1024 * 1024 : 디스코드 문서는 MB가 아니라 MiB(1,048,576바이트) 단위예요. 20 MB(20,000,000바이트)로 계산하면 약 1 MB 손해 봅니다.

📌 2. 슬래시 커맨드 개선 - 여러 줄 입력·퍼지 검색 (9/11)

변경 내용 세 가지예요.

  1. 문자열 옵션에 여러 줄 입력이 돼요. 사용자가 Shift+Enter로 줄을 바꿔서 보낼 수 있습니다.
  2. 명령어 고르기 창이 퍼지 검색을 지원해요. /r a만 쳐도 /role add가 뜹니다.
  3. 채널을 옮겨도 고르던 명령어를 기억해요.

내 봇에 영향? : 1번이 핵심이에요. 지금까지 문자열 옵션은 한 줄이라고 믿어도 됐는데, 이제 \n이 섞인 값이 들어옵니다. 옵션 값을 그대로 임베드 제목에 넣거나, 파일 이름·DB 키로 쓰거나, 한 줄 로그로 남기던 코드가 있다면 모양이 깨질 수 있어요.

처리는 간단해요. 줄 단위로 나눠서 받는 습관만 들이면 됩니다.

Python
@bot.tree.command(name="여러줄", description="여러 줄로 입력한 글을 줄 단위로 나눠서 보여줘요")
@app_commands.describe(text="Shift+Enter 로 줄을 바꿔서 입력해 보세요")
async def multiline(interaction: discord.Interaction, text: app_commands.Range[str, 1, 1000]):
    lines = [line.strip() for line in text.splitlines() if line.strip()]
    if not lines:
        await interaction.response.send_message("내용이 비어 있어요!", ephemeral=True)
        return
    body = "\n".join(f"{i}. {line}" for i, line in enumerate(lines, start=1))
    await interaction.response.send_message(f"총 **{len(lines)}줄** 받았어요.\n{body}")

하나씩 뜯어볼게요 💡

  • text.splitlines() : \n, \r\n 어느 쪽이 와도 줄 단위로 잘라줘요. split("\n")보다 안전합니다.
  • if line.strip() : 빈 줄을 버려요. 여러 줄 입력이 되면 사용자가 Enter를 두 번 누르는 경우가 꼭 생기거든요.
  • app_commands.Range[str, 1, 1000] : 문자열 길이 상한이에요. 여러 줄이 되면서 긴 글이 들어오기 쉬워졌으니 상한을 명시해 두는 게 좋아요. 디스코드 메시지 한 개는 2,000자까지라, 1,000자로 잡으면 번호를 붙여 되돌려줘도 안전합니다.
  • 한 줄이어야 하는 곳 : 제목·파일 이름·키로 쓸 값은 text.replace("\n", " ")로 한 줄로 눌러서 쓰세요.

2번 퍼지 검색은 명령어 이름을 고르는 클라이언트 쪽 기능이에요. #22편에서 만든 옵션 자동완성 콜백이 저절로 퍼지 검색으로 바뀌는 건 아닙니다. 다만 사용자는 이제 "대충 쳐도 찾아주는" 경험에 익숙해질 테니, 자동완성 필터를 startswith처럼 빡빡하게 두지 말고 #22처럼 in(부분 일치)으로 느슨하게 두는 걸 추천해요 🔍

📌 3. 대형 봇 샤딩 앱은 shard 쿼리 필수 (9/15, Breaking)

변경 내용 : 대형 봇 샤딩(large bot sharding) 을 쓰는 앱이 GET /users/@me/guilds(내 봇이 들어간 서버 목록)를 부를 때 shard 쿼리를 반드시 넣어야 해요. 없으면 400 Bad Request가 옵니다. 값은 0부터 max_concurrency - 1까지고, max_concurrency는 Get Gateway Bot 응답의 session_start_limit에 들어 있어요.

내 봇에 영향? : 대형 봇 샤딩은 디스코드가 서버 수가 아주 많은 봇에 따로 승인해 주는 방식이에요. 이 시리즈에서 만드는 규모의 봇은 해당 없습니다. 공식 문서도 "대형 봇 샤딩을 쓰지 않는 앱은 영향 없다"고 못 박았어요. 안심하세요 😌

그래도 해당되는 분을 위해 적어둘게요. discord.py 2.7.1의 client.fetch_guilds()는 shard 파라미터가 없어서 (내부적으로 limit·before·after·with_counts만 보내요) 엔드포인트를 직접 불러야 합니다.

Python
async def fetch_all_guilds_by_shard(client: discord.Client) -> list:
    """대형 봇 샤딩을 쓰는 앱용. 일반 봇은 그냥 client.fetch_guilds() 를 쓰면 돼요."""
    _, _, start_limit = await client.http.get_bot_gateway()
    max_concurrency = start_limit["max_concurrency"]
    guilds = []
    for shard in range(max_concurrency):
        route = Route("GET", "/users/@me/guilds")
        data = await client.http.request(route, params={"shard": shard, "limit": 200})
        guilds.extend(data)
    return guilds

하나씩 뜯어볼게요 💡

  • client.http.get_bot_gateway() : GET /gateway/bot을 호출해서 (shards, url, session_start_limit) 세 개를 돌려줘요. 세 번째 딕셔너리의 max_concurrency가 필요한 값입니다.
  • Route("GET", "/users/@me/guilds") : discord.http.Route는 라이브러리가 모든 요청에 쓰는 클래스예요. from discord.http import Route로 가져옵니다.
  • client.http.request(route, params=...) : 쿼리 문자열은 params로 넘겨요. 레이트리밋 처리는 라이브러리가 해줍니다. 반환값은 파싱된 JSON(서버 딕셔너리 목록)이에요.
  • 문서의 안내 : 전체 목록이 필요하면 모든 shard 값을 돌면서 합치라고 되어 있어요. 그래서 range(max_concurrency)로 돕니다.

⚠️ bot.http는 공개 API가 아니라 라이브러리 내부 객체예요. 다음 버전에서 시그니처가 바뀔 수 있으니, discord.py가 정식 메서드를 넣어주면 그쪽으로 갈아타세요.

📌 4. 커스텀 상태 숨김(9/17) + 연령 확인 Age Assurance(9/22)

두 개를 묶은 이유는 결과가 같아서예요. 둘 다 "봇이 보던 사용자 정보가 어느 날부터 안 보인다"입니다.

9/17 변경 내용 : 사용자의 프로필 공개 설정이 친구만이거나, 친구와 작은 서버만인데 서버 멤버가 200명을 넘으면, 그 서버로 가는 Presence Update의 activities에서 커스텀 상태가 빠집니다. Playing·Streaming·Listening·Watching·Competing 같은 다른 활동은 그대로예요.

9/22 변경 내용 : 연령 확인이 전 세계로 롤아웃돼요. 공식 문서의 표현을 그대로 옮기면 "앱은 그대로 동작하지만" 네 가지가 달라 보일 수 있어요.

  1. 프로필 비공개 사용자는 Presence에서 커스텀 상태가 보이지 않을 수 있음 (위 9/17과 같은 이야기)
  2. 연령 제한이 걸린 Activity는 사용자에게 연령 그룹 확인을 요구할 수 있음
  3. 부모가 설정한 지출 한도 때문에 일부 구매가 실패할 수 있음
  4. Social SDK로 보내는 DM이 메시지 요청함으로 더 자주 분류됨

내 봇에 영향? : 코드를 고칠 건 없어요. 대신 member.activities에서 커스텀 상태를 꺼내 쓰는 코드가 있다면 없을 때를 정상 케이스로 다뤄야 합니다. 예전에는 "상태를 설정한 사람이면 반드시 있다"고 가정해도 됐지만, 이제는 아니에요.

Python
@bot.tree.command(name="상태보기", description="멤버의 커스텀 상태를 읽어요 (안 보일 수도 있어요)")
async def show_status(interaction: discord.Interaction, member: discord.Member):
    custom = next((a for a in member.activities if isinstance(a, discord.CustomActivity)), None)
    if custom is None:
        text = "커스텀 상태가 없거나, 프로필 비공개 설정으로 봇에게는 안 보여요."
    else:
        text = f"{custom.emoji or ''} {custom.name or ''}".strip() or "(이모지만 있는 상태)"
    await interaction.response.send_message(f"{member.display_name}: {text}", ephemeral=True)

하나씩 뜯어볼게요 💡

  • member.activities : 튜플이에요. 커스텀 상태·게임·스포티파이가 섞여 있어서 isinstance(a, discord.CustomActivity)로 골라냅니다. member.activity(단수)는 첫 번째 것만 주니까 커스텀 상태를 찾을 땐 쓰지 마세요.
  • next(..., None) : 없으면 None이에요. 이 None이 이제 "설정 안 함"과 "숨겨짐" 두 경우를 다 뜻하는데, 봇 입장에서는 구분할 방법이 없어요. 그래서 안내 문구를 둘 다 포함하게 썼어요.
  • custom.name / custom.emoji : 둘 다 Optional이에요. 이모지만 있는 상태도 있어서 or ''로 감쌌습니다.
  • Presence Intent : member.activities가 채워지려면 intents.presences = True와 개발자 포털의 Presence Intent가 둘 다 켜져 있어야 해요. 안 켜면 항상 빈 튜플이라 "숨겨짐"과 구분이 안 됩니다.

📌 5. 초대 대상 유저(target user) 엔드포인트 (9/18)

변경 내용 : 초대 링크에 "이 사람들만 쓸 수 있음" 목록을 붙이는 엔드포인트 4개가 생겼어요.

동작 메서드·경로 본문
대상 추가 PUT /invites/{invite.code}/target-users/{user.id} 없음
대상 제거 DELETE /invites/{invite.code}/target-users/{user.id} 없음
여러 명 추가 POST /invites/{invite.code}/target-users/bulk-add user_ids 배열 (최대 1,000명)
여러 명 제거 POST /invites/{invite.code}/target-users/bulk-delete user_ids 배열 (최대 1,000명)

네 개 모두 초대를 만든 사람이거나 MANAGE_GUILD(서버 관리) 권한이 있어야 하고, 성공하면 204(본문 없음) 이 옵니다. 체인지로그에는 초대를 만들 때부터 target_user_ids 배열을 함께 넘길 수 있다고도 적혀 있어요.

내 봇에 영향? : 안 써도 아무 문제 없어요. 다만 #33편 파티방처럼 "초대한 사람만 들어오는" 기능을 만들 때, 지금까지는 채널 권한 덮어쓰기로 막았다면 이제 초대 링크 자체를 특정인 전용으로 만들 수 있게 된 거예요.

discord.py 2.7.1의 create_invite()에는 이 옵션이 없어요. (target_user라는 파라미터가 있긴 한데, 그건 방송 보기 초대의 스트리머를 뜻하는 다른 기능이에요. 헷갈리기 딱 좋죠 😅) 그래서 초대는 평소처럼 만들고, 대상 지정만 직접 호출합니다.

Python
@bot.tree.command(name="초대대상", description="특정 사람만 쓸 수 있는 초대 링크를 만들어요")
@app_commands.describe(member="초대 링크를 쓸 수 있게 할 멤버")
async def invite_target(interaction: discord.Interaction, member: discord.Member):
    channel = interaction.channel
    if not isinstance(channel, discord.TextChannel):
        await interaction.response.send_message("텍스트 채널에서 써주세요!", ephemeral=True)
        return

    await interaction.response.defer(ephemeral=True)
    invite = await channel.create_invite(max_age=3600, max_uses=1, unique=True,
                                         reason=f"{member} 전용 초대")

    # discord.py 2.7.1 에는 아직 메서드가 없어서 엔드포인트를 직접 호출해요
    route = Route("PUT", "/invites/{code}/target-users/{user_id}",
                  code=invite.code, user_id=member.id)
    await bot.http.request(route)   # 성공하면 204 (본문 없음)

    await interaction.followup.send(
        f"{member.mention} 전용 초대 링크를 만들었어요 (1시간, 1회): {invite.url}", ephemeral=True
    )

하나씩 뜯어볼게요 💡

  • channel.create_invite(max_age=3600, max_uses=1, unique=True) : 1시간짜리 1회용 초대예요. unique=True라서 기존 초대를 재사용하지 않고 새로 만듭니다. 이 초대의 생성자가 우리 봇이니까 봇이 곧 "초대를 만든 사람" 이 되어 대상 지정 권한을 갖게 돼요.
  • Route("PUT", "/invites/{code}/target-users/{user_id}", code=..., user_id=...) : 경로의 {code}·{user_id}는 키워드 인자로 채워져요. 위 코드로 만들어지는 주소는 https://discord.com/api/v10/invites/<코드>/target-users/<유저ID>입니다. (직접 찍어서 확인했어요.)
  • await bot.http.request(route) : 본문이 없는 PUT이에요. 여러 명이면 Route("POST", "/invites/{code}/target-users/bulk-add", code=...)에 json={"user_ids": [id1, id2]}를 넘기면 됩니다.
  • defer 먼저 : 초대 생성 + 대상 지정으로 HTTP 요청이 두 번이라 3초를 넘길 수 있어요. defer() 뒤에 followup.send()로 답합니다.

📌 6. 멤버 정리(prune)에 관리자 권한 (8/27, Breaking)

9월 글이지만 8월 마지막 주 변경이라 같이 챙겨요. 놓치기 쉬운 Breaking Change예요.

변경 내용 : 서버 주인이 PRUNE_REQUIRES_ADMIN 이라는 서버 기능을 켤 수 있게 됐어요. 이게 켜진 서버에서는 정리 예상 인원 조회(Get Guild Prune Count) 와 정리 실행(Begin Guild Prune) 에 ADMINISTRATOR 권한이 필요합니다. 안 켜진 서버는 지금처럼 MANAGE_GUILD + KICK_MEMBERS예요.

내 봇에 영향? : guild.prune_members()나 guild.estimate_pruned_members()를 쓰는 봇만요. 어느 날 갑자기 403 Forbidden이 나기 시작했다면 서버 주인이 이 기능을 켠 거예요.

Python
@bot.tree.command(name="정리예상", description="30일 동안 활동 없는 멤버가 몇 명인지 계산해요 (실제로 내보내지 않아요)")
@app_commands.describe(days="며칠 동안 활동이 없으면 대상으로 볼지 (1~30)")
async def prune_estimate(interaction: discord.Interaction, days: app_commands.Range[int, 1, 30] = 30):
    guild = interaction.guild
    if guild is None:
        await interaction.response.send_message("서버 안에서 써주세요!", ephemeral=True)
        return

    perms = guild.me.guild_permissions
    if "PRUNE_REQUIRES_ADMIN" in guild.features:
        if not perms.administrator:
            await interaction.response.send_message(
                "이 서버는 **PRUNE_REQUIRES_ADMIN** 이 켜져 있어서 봇에 관리자 권한이 필요해요.",
                ephemeral=True,
            )
            return
    elif not (perms.kick_members and perms.manage_guild):
        await interaction.response.send_message(
            "봇에 **멤버 추방 + 서버 관리** 권한이 있어야 계산할 수 있어요.", ephemeral=True
        )
        return

    await interaction.response.defer(ephemeral=True)
    count = await guild.estimate_pruned_members(days=days)
    await interaction.followup.send(f"{days}일 기준 정리 대상: **{count}명** (계산만 했어요)", ephemeral=True)

하나씩 뜯어볼게요 💡

  • guild.features : 서버가 켜둔 기능 이름의 문자열 목록이에요. "COMMUNITY", "NEWS" 같은 값이 들어 있고, 이번에 "PRUNE_REQUIRES_ADMIN"이 추가됐어요. 라이브러리가 새 값을 몰라도 문자열 그대로 오기 때문에 2.7.1에서도 바로 확인됩니다.
  • guild.me.guild_permissions : 서버 안에서 우리 봇의 최종 권한이에요. administrator·kick_members·manage_guild를 여기서 봅니다.
  • estimate_pruned_members(days=...) : 실제로 내보내지 않고 숫자만 돌려줘요. 예제에서는 일부러 이쪽만 썼어요. 진짜 내보내는 prune_members()는 되돌릴 수 없으니 확인 버튼을 한 번 더 두는 걸 추천합니다.
  • 왜 미리 체크하나 : 그냥 호출해도 403이 나면서 실패하긴 해요. 하지만 사용자에게 "봇에 관리자 권한을 주세요"라고 정확한 이유를 알려주는 것과 "오류가 났어요"만 보여주는 건 완전히 다르죠.

💡 8월에 놓쳤을 수 있는 것

  • 8/12 채널 난독화 — 11/16부터 봇이 못 보는 채널이 가려져요. #24편에서 자세히 다뤘습니다.
  • 8/5 첨부파일 옵션의 file_types — ATTACHMENT 옵션과 파일 업로드 컴포넌트에 "이 확장자만 받기" 필터가 생겼어요. discord.py 2.7.1에는 아직 없어서 (소스에 file_types가 없어요) 라이브러리 업데이트를 기다려야 합니다.
  • 8/5 채널의 application_id가 null 가능 — 채널 객체의 application_id가 항상 ID라고 가정한 코드가 있으면 None 처리를 넣으세요.

📌 전체 코드 (복붙 OK)

sept_check.py 파일 하나로 위 명령어 5개가 다 돌아가요. 그대로 복사해서 쓰세요!

Python
# discord.py 2.7.1 / Python 3.8+
# 2026년 9월 디스코드 API 변경 점검 봇 (sept_check.py)
import os

import discord
from discord import app_commands
from discord.ext import commands
from discord.http import Route
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.getenv("DISCORD_TOKEN")
if not TOKEN:
    raise SystemExit("❌ .env 파일에 DISCORD_TOKEN 이 없어요!")

intents = discord.Intents.default()
intents.members = True      # 멤버 정보 (개발자 포털에서 켜야 해요)
intents.presences = True    # 커스텀 상태 확인용 (개발자 포털에서 켜야 해요)
bot = commands.Bot(command_prefix="!", intents=intents)

MIB = 1024 * 1024


@bot.event
async def on_ready():
    await bot.tree.sync()
    print(f"✅ 로그인: {bot.user} / discord.py {discord.__version__}")


# ① 9/11 - 문자열 옵션 여러 줄 입력
@bot.tree.command(name="여러줄", description="여러 줄로 입력한 글을 줄 단위로 나눠서 보여줘요")
@app_commands.describe(text="Shift+Enter 로 줄을 바꿔서 입력해 보세요")
async def multiline(interaction: discord.Interaction, text: app_commands.Range[str, 1, 1000]):
    lines = [line.strip() for line in text.splitlines() if line.strip()]
    if not lines:
        await interaction.response.send_message("내용이 비어 있어요!", ephemeral=True)
        return
    body = "\n".join(f"{i}. {line}" for i, line in enumerate(lines, start=1))
    await interaction.response.send_message(f"총 **{len(lines)}줄** 받았어요.\n{body}")


# ② 9/3 - 파일 업로드 기본 한도 20 MiB
@bot.tree.command(name="파일한도", description="이 서버에서 봇이 올릴 수 있는 파일 크기를 보여줘요")
async def file_limit(interaction: discord.Interaction):
    live = interaction.filesize_limit          # 디스코드가 이번 상호작용에 실어 보낸 값
    cached = interaction.guild.filesize_limit if interaction.guild else None  # 라이브러리 표
    msg = f"디스코드가 알려준 한도: **{live / MIB:.0f} MiB**"
    if cached is not None:
        msg += f"\ndiscord.py 표 기준(guild.filesize_limit): {cached / MIB:.0f} MiB"
    await interaction.response.send_message(msg, ephemeral=True)


# ③ 8/27 - PRUNE_REQUIRES_ADMIN 서버 기능
@bot.tree.command(name="정리예상", description="30일 동안 활동 없는 멤버가 몇 명인지 계산해요 (실제로 내보내지 않아요)")
@app_commands.describe(days="며칠 동안 활동이 없으면 대상으로 볼지 (1~30)")
async def prune_estimate(interaction: discord.Interaction, days: app_commands.Range[int, 1, 30] = 30):
    guild = interaction.guild
    if guild is None:
        await interaction.response.send_message("서버 안에서 써주세요!", ephemeral=True)
        return

    perms = guild.me.guild_permissions
    if "PRUNE_REQUIRES_ADMIN" in guild.features:
        if not perms.administrator:
            await interaction.response.send_message(
                "이 서버는 **PRUNE_REQUIRES_ADMIN** 이 켜져 있어서 봇에 관리자 권한이 필요해요.",
                ephemeral=True,
            )
            return
    elif not (perms.kick_members and perms.manage_guild):
        await interaction.response.send_message(
            "봇에 **멤버 추방 + 서버 관리** 권한이 있어야 계산할 수 있어요.", ephemeral=True
        )
        return

    await interaction.response.defer(ephemeral=True)
    count = await guild.estimate_pruned_members(days=days)
    await interaction.followup.send(f"{days}일 기준 정리 대상: **{count}명** (계산만 했어요)", ephemeral=True)


# ④ 9/18 - 초대 대상 유저(target user) 엔드포인트
@bot.tree.command(name="초대대상", description="특정 사람만 쓸 수 있는 초대 링크를 만들어요")
@app_commands.describe(member="초대 링크를 쓸 수 있게 할 멤버")
async def invite_target(interaction: discord.Interaction, member: discord.Member):
    channel = interaction.channel
    if not isinstance(channel, discord.TextChannel):
        await interaction.response.send_message("텍스트 채널에서 써주세요!", ephemeral=True)
        return

    await interaction.response.defer(ephemeral=True)
    invite = await channel.create_invite(max_age=3600, max_uses=1, unique=True,
                                         reason=f"{member} 전용 초대")

    # discord.py 2.7.1 에는 아직 메서드가 없어서 엔드포인트를 직접 호출해요
    route = Route("PUT", "/invites/{code}/target-users/{user_id}",
                  code=invite.code, user_id=member.id)
    await bot.http.request(route)   # 성공하면 204 (본문 없음)

    await interaction.followup.send(
        f"{member.mention} 전용 초대 링크를 만들었어요 (1시간, 1회): {invite.url}", ephemeral=True
    )


# ⑤ 9/17·9/22 - 커스텀 상태가 안 보일 수 있어요
@bot.tree.command(name="상태보기", description="멤버의 커스텀 상태를 읽어요 (안 보일 수도 있어요)")
async def show_status(interaction: discord.Interaction, member: discord.Member):
    custom = next((a for a in member.activities if isinstance(a, discord.CustomActivity)), None)
    if custom is None:
        text = "커스텀 상태가 없거나, 프로필 비공개 설정으로 봇에게는 안 보여요."
    else:
        text = f"{custom.emoji or ''} {custom.name or ''}".strip() or "(이모지만 있는 상태)"
    await interaction.response.send_message(f"{member.display_name}: {text}", ephemeral=True)


# ⑥ 9/15 - 대형 봇 샤딩 앱은 shard 쿼리 필수
async def fetch_all_guilds_by_shard(client: discord.Client) -> list:
    """대형 봇 샤딩을 쓰는 앱용. 일반 봇은 그냥 client.fetch_guilds() 를 쓰면 돼요."""
    _, _, start_limit = await client.http.get_bot_gateway()
    max_concurrency = start_limit["max_concurrency"]
    guilds = []
    for shard in range(max_concurrency):
        route = Route("GET", "/users/@me/guilds")
        data = await client.http.request(route, params={"shard": shard, "limit": 200})
        guilds.extend(data)
    return guilds


bot.run(TOKEN)

💡 #19편의 cogs/ 구조를 쓰고 계시다면 명령어 부분만 떼어 cogs/sept_check.py로 옮기셔도 똑같이 동작해요.

📌 실행 결과

터미널
python sept_check.py

봇이 켜지면 서버에서 명령어를 하나씩 쳐보세요. 전부 나만 보이는(ephemeral) 답이라 테스트 서버가 지저분해지지 않아요.

예시 화면: /파일한도 실행 결과 - 부스트 없는 서버에서 "디스코드가 알려준 한도: 20 MiB / discord.py 표 기준: 10 MiB" 두 줄이 다르게 나온 화면

/파일한도 는 부스트 없는 서버라면 위처럼 두 숫자가 다르게 나와야 정상이에요. 위 줄(20 MiB)이 진짜 한도고, 아래 줄(10 MiB)이 라이브러리 표가 낡았다는 증거예요.

예시 화면: /여러줄 명령어에 Shift+Enter로 세 줄 입력하는 중인 화면과, "총 3줄 받았어요" 뒤에 1. 2. 3. 번호가 붙어 되돌아온 답장

/여러줄 은 입력창에서 Shift+Enter를 눌러 줄을 바꿔 보세요. 봇이 줄 수를 세서 번호를 붙여 돌려줍니다.

[이미지: /초대대상 으로 만든 전용 초대 링크 답장과, 대상이 아닌 다른 계정으로 그 링크를 열었을 때 나오는 안내 화면 (부계정 시점)]

/초대대상 은 링크를 대상이 아닌 계정으로 열어봐야 진짜 확인이 돼요. 대상이 아니면 참여가 막혀야 정상입니다.

예시 화면: /정리예상 결과 임베드 - "30일 기준 정리 대상: N명 (계산만 했어요)" 답장

⚠️ 솔직하게 말씀드리면, 이 글의 코드는 파이썬 3.13에서 문법·import 검사와 Route 주소 조립까지만 확인했고 실제 서버에서 명령어를 돌려본 건 아니에요. 특히 /초대대상 은 9/18에 새로 생긴 엔드포인트라 실제 응답을 꼭 확인해 보세요. 안 되면 댓글 대신 문의로 알려주시면 글을 고칠게요.

📌 자주 나는 에러

1. discord.errors.Forbidden: 403 Forbidden (error code: 50013): Missing Permissions — /정리예상

  • 원인 : 서버가 PRUNE_REQUIRES_ADMIN을 켰는데 봇에 관리자 권한이 없거나, 안 켠 서버에서 멤버 추방·서버 관리 권한이 빠졌어요.
  • 해결 : 위 코드처럼 guild.features를 먼저 보고 권한을 안내하세요. 봇 역할에 필요한 권한을 준 뒤 다시 시도합니다.

2. discord.errors.Forbidden: 403 — /초대대상 의 PUT 요청

  • 원인 : 대상 유저 지정은 초대를 만든 사람이거나 서버 관리 권한이 있어야 해요. 다른 사람이 만든 초대 코드를 넣었거나 봇에 초대 만들기(Create Invite) 권한이 없으면 실패합니다.
  • 해결 : 예제처럼 봇이 직접 만든 초대에만 대상을 붙이거나, 봇 역할에 서버 관리 권한을 주세요.

3. discord.errors.HTTPException: 400 Bad Request — GET /users/@me/guilds

  • 원인 : 대형 봇 샤딩 앱이 shard 쿼리 없이 서버 목록을 불렀어요. 9/15부터 400이 옵니다.
  • 해결 : fetch_all_guilds_by_shard()처럼 params={"shard": n}을 넣어 0부터 max_concurrency - 1까지 돌리세요. 일반 봇은 이 에러가 나지 않아요.

4. /상태보기 가 항상 "커스텀 상태가 없거나..." 만 나와요

  • 원인 : intents.presences = True를 안 넣었거나, 개발자 포털에서 Presence Intent를 안 켰어요. 이러면 member.activities가 항상 빈 튜플이에요.
  • 해결 : 코드와 포털 둘 다 켜고 봇을 재시작하세요. 둘 다 켰는데도 안 보이면 그 사용자가 프로필 비공개(9/17) 인 거라 봇은 볼 수 없어요. 그게 정상입니다.

5. 파일을 보냈는데 413 Payload Too Large / Request entity too large

  • 원인 : 20 MiB는 부스트 없는 서버의 기본값이에요. 그보다 큰 파일은 여전히 안 되고, MB(1,000,000)와 MiB(1,048,576)를 섞어 계산하면 경계에서 튕깁니다.
  • 해결 : 보내기 전에 os.path.getsize()와 interaction.filesize_limit를 비교하세요. guild.filesize_limit(2.7.1 기준 10 MiB)로 비교하면 보낼 수 있는 파일도 거절하니 쓰지 마세요.

6. /초대대상 에서 discord.errors.NotFound: 404 (error code: 10062): Unknown interaction

  • 원인 : 3초 안에 응답하지 못했어요. HTTP 요청이 두 번이라 느릴 수 있습니다.
  • 해결 : 무거운 작업 전에 await interaction.response.defer()를 먼저 부르고, 그 뒤에는 interaction.followup.send()로 보내세요.

💬 오늘 꼭 기억할 3가지

  1. 고쳐야 하는 건 둘. 대형 봇 샤딩 앱의 shard 쿼리, 멤버 정리 봇의 PRUNE_REQUIRES_ADMIN 권한 체크. 나머지는 알고만 있으면 돼요.
  2. 파일 한도는 interaction.filesize_limit. 라이브러리 표(guild.filesize_limit)는 아직 10 MiB로 낡았어요.
  3. 문자열 옵션에 줄바꿈이 들어와요. splitlines()로 받고, 한 줄이어야 하는 곳은 눌러서 쓰세요. 커스텀 상태는 없는 게 정상 케이스가 됐고요.

📌 마무리

오늘은 새 기능 대신 9월 한 달치 디스코드 API 변경을 한 번에 훑었어요 🗞️

정리하면, 파일 한도가 20 MiB로 넉넉해졌고(라이브러리 값은 믿지 말 것), 슬래시 커맨드 문자열에 여러 줄이 들어오기 시작했고, 초대 링크를 특정인 전용으로 만드는 엔드포인트가 생겼어요. 연령 확인과 프로필 비공개 때문에 커스텀 상태는 안 보이는 경우가 늘었고, 대형 봇 샤딩 앱은 shard 쿼리를, 멤버 정리 봇은 PRUNE_REQUIRES_ADMIN을 챙겨야 합니다. discord.py 2.7.1은 3월 이후 새 버전이 없어서 새 엔드포인트는 Route로 직접 부르는 방법으로 메꿨어요 ✅

그리고 잊지 마세요. 11월 16일 채널 난독화가 제일 큰 변경이에요. 아직 #24편의 /채널점검을 안 돌려보셨다면 이번 주말에 한 번 돌려두세요 💣

디스코드 봇 만들기 시리즈는 #33 파티방에 이어 다음 편에서 봇을 Docker로 배포하기(예정)를 다뤄볼 예정이에요. 기대해주세요! 🙌

📚 같이 보면 좋은 글

글쓴이

Mojangcam

직접 코드를 짜고, 서버를 돌리고, 막힌 에러를 해결하며 배운 것들을 기록하는 개발·IT 실전 기록 블로그