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

디스코드 봇 만들기#18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제)

목차11개 항목

안녕하세요! Mojangcam입니다 😎

지난 #17편에서는 RAG로 우리 서버 전용 FAQ 답변봇을 만들었죠. 문서를 읽고 답해주는 똑똑한 봇이었는데요, 오늘은 분위기를 바꿔서 서버원들의 의견을 모으는 기능을 만들어볼게요. 바로 투표(Poll) 입니다!

이번 편에서 배울 것

  • 디스코드 네이티브 투표와 이모지 반응 투표의 차이
  • discord.Poll 로 투표 올리기 (기간·복수선택 설정)
  • 슬래시 명령어로 누구나 투표를 만들게 하기
  • 투표 결과를 그래프 임베드로 집계해서 발표하기
  • 누가 투표했는지 실시간으로 받아보기

서버 규칙 정할 때, 오늘 무슨 게임 돌릴지 정할 때, 이벤트 날짜 잡을 때... 투표 쓸 일 정말 많잖아요? 그때마다 봇이 "1️⃣ 눌러주세요~" 하고 이모지 반응을 세는 방식은 이제 그만! 디스코드가 공식 투표 기능을 API로 열어줬거든요 🎉

🧩 이모지 반응 투표 vs 네이티브 투표

예전에는 투표 봇을 이렇게 만들었어요. 메시지를 보내고 → 봇이 1️⃣2️⃣3️⃣ 이모지를 달고 → 나중에 반응 개수를 세는 방식이요. 동작은 하는데 문제가 좀 있었죠.

  • 봇이 아닌 사람이 다른 이모지를 마음대로 추가할 수 있어요
  • 봇 자신이 단 반응 1개를 빼고 세야 해서 집계가 헷갈려요
  • 자동 종료가 안 돼서 타이머를 직접 돌려야 해요
  • 모바일에서 투표처럼 안 보여요

반면 네이티브 투표는 디스코드가 직접 UI를 그려줘요. 남은 시간, 득표율 막대, 총 투표 수까지 전부 디스코드가 관리합니다. 우리는 질문과 선택지만 던져주면 끝이에요 👍

다만 디스코드가 정해둔 제한이 있어요. 이건 꼭 기억해주세요.

항목 제한
질문 길이 최대 300자
선택지 개수 최대 10개
선택지 하나 길이 최대 55자
진행 기간 1시간 ~ 32일(768시간), 시간 단위

📌 준비물

  • Python 3.8+
  • discord.py 2.5 이상 — 투표 기능은 2.4부터 들어왔고, 1위 답변을 알려주는 victor_answer 는 2.5부터예요
  • python-dotenv — 봇 토큰을 코드에 박지 않기 위해 필수!
터미널
pip install -U discord.py python-dotenv

설치된 버전은 이렇게 확인해요.

터미널
python -c "import discord; print(discord.__version__)"

그리고 프로젝트 폴더에 .env 파일을 만들어주세요.

텍스트
DISCORD_TOKEN=여기에_봇_토큰_붙여넣기

💡 .env 는 반드시 .gitignore 에 넣으세요. 깃허브에 토큰이 올라가면 디스코드가 자동으로 감지해서 토큰을 폐기해버립니다. (그나마 다행이지만, 봇은 멈춰요 😅)

📌 1단계 - 제일 단순한 투표 하나 올려보기

먼저 감을 잡아볼게요. 투표는 discord.Poll 객체를 만들고, 선택지를 붙이고, 메시지 보낼 때 poll= 으로 넘기면 끝이에요.

Python
import datetime
import discord

poll = discord.Poll(
    question="오늘 저녁 뭐 먹을까요?",
    duration=datetime.timedelta(hours=24),
    multiple=False,
)

poll.add_answer(text="치킨", emoji="🍗")
poll.add_answer(text="피자", emoji="🍕")
poll.add_answer(text="굶기", emoji="😇")

await channel.send(poll=poll)

하나씩 뜯어볼게요 💡

  • question : 투표 제목이에요. 문자열을 그대로 넣으면 됩니다. 최대 300자.
  • duration : datetime.timedelta 로 넘겨요. 중요한 건 시간(hour) 단위로만 인식한다는 점이에요. timedelta(minutes=30) 처럼 30분짜리를 만들면 0시간으로 계산돼서 에러가 납니다.
  • multiple : True 면 여러 개를 동시에 고를 수 있어요. 기본값은 False(하나만).
  • add_answer(text=, emoji=) : 선택지를 하나 붙여요. emoji 는 생략 가능하고, 유니코드 이모지 문자열이나 서버 커스텀 이모지 객체 둘 다 들어갑니다. 최대 10개까지예요.

💬 add_answer() 는 자기 자신을 돌려줘서 poll.add_answer(...).add_answer(...) 처럼 이어 쓸 수도 있어요. 취향껏 쓰시면 됩니다!

📌 2단계 - 슬래시 명령어로 누구나 투표 만들게 하기

매번 코드를 고칠 순 없으니, /투표 명령어를 만들어서 선택지를 쉼표로 받아 처리해볼게요.

Python
@bot.tree.command(name="투표", description="서버 투표를 만들어요")
@app_commands.describe(
    질문="투표에 띄울 질문 (최대 300자)",
    항목="선택지를 쉼표(,)로 구분해서 입력 (2~10개)",
    시간="진행 시간 (시간 단위, 기본 24)",
    복수선택="여러 개를 고를 수 있게 할지",
)
async def create_poll(
    interaction: discord.Interaction,
    질문: str,
    항목: str,
    시간: int = 24,
    복수선택: bool = False,
):
    labels = [x.strip() for x in 항목.split(",") if x.strip()]

    if not 2 <= len(labels) <= 10:
        await interaction.response.send_message(
            f"선택지는 2~10개여야 해요! (지금 {len(labels)}개)", ephemeral=True
        )
        return

    poll = discord.Poll(
        question=질문,
        duration=datetime.timedelta(hours=시간),
        multiple=복수선택,
    )
    for index, label in enumerate(labels):
        poll.add_answer(text=label, emoji=NUMBER_EMOJI[index])

    await interaction.response.send_message("투표를 올렸어요! 📊", ephemeral=True)
    await interaction.channel.send(poll=poll)

하나씩 뜯어볼게요 💡

  • 항목.split(",") : 치킨, 피자, 족발 처럼 입력받아 리스트로 쪼개요. strip() 으로 앞뒤 공백을 지우고, 빈 문자열은 걸러냅니다. 이걸 안 하면 치킨,,피자 같은 입력에서 빈 선택지가 생겨요.
  • ephemeral=True : 이 응답은 명령어를 친 사람에게만 보여요. 에러 안내를 채널에 도배하지 않으려고 쓰는 옵션입니다.
  • 응답 따로, 투표 따로 : 슬래시 명령어는 3초 안에 무조건 한 번 응답해야 해요. 그래서 먼저 짧게 답해주고(response.send_message), 투표 카드는 interaction.channel.send(poll=poll) 로 채널에 남깁니다. 이렇게 하면 투표가 모두에게 보이는 일반 메시지로 올라가요.

📌 3단계 - 결과를 임베드로 집계해서 발표하기

디스코드가 UI를 그려주긴 하지만, 봇이 직접 집계해서 예쁘게 정리해주면 훨씬 좋겠죠. 메시지를 가져와서 message.poll 을 들여다보면 됩니다.

Python
message = await interaction.channel.fetch_message(메시지id)
poll = message.poll

total = poll.total_votes

for answer in poll.answers:
    percent = (answer.vote_count / total * 100) if total else 0
    bar = "█" * round(percent / 10) or "░"
    print(f"{answer.text}: {bar} {answer.vote_count}표 ({percent:.1f}%)")

if poll.is_finalised() and poll.victor_answer:
    print(f"🏆 1위: {poll.victor_answer.text}")

하나씩 뜯어볼게요 💡

  • message.poll : 투표가 없는 메시지면 None 이 나와요. 꼭 확인하고 쓰세요.
  • poll.total_votes : 총 투표 수예요. 투표가 진행 중일 땐 근사치, 끝나면 정확한 값이 됩니다.
  • poll.answers : 선택지 목록(PollAnswer 리스트)이에요. 각각 .text, .emoji, .vote_count 를 갖고 있어요.
  • poll.is_finalised() : 투표가 끝났는지 알려줘요. (영국식 철자예요! is_finalized() 도 똑같이 동작합니다)
  • poll.victor_answer : 1위 선택지. 투표가 끝나기 전엔 항상 None 이니 조건 확인은 필수예요.
  • "█" * round(percent / 10) or "░" : 10%마다 블록을 하나씩 쌓아 막대 그래프를 흉내내요. 0%면 빈 문자열이 되니까 or 로 ░ 를 대신 보여줍니다.

누가 어디에 투표했는지도 볼 수 있어요.

Python
async for voter in poll.answers[0].voters():
    print(f"{voter} 님이 투표했어요!")

📌 4단계 - 투표 이벤트 실시간으로 받기

누군가 투표하는 그 순간을 잡고 싶다면 이벤트를 쓰면 돼요.

Python
@bot.event
async def on_poll_vote_add(user, answer):
    print(f"[투표] {user}님 → '{answer.text}' (현재 {answer.vote_count}표)")

@bot.event
async def on_poll_vote_remove(user, answer):
    print(f"[취소] {user}님이 '{answer.text}' 투표를 취소했어요")

단, 이 이벤트를 받으려면 인텐트(Intents) 2개를 켜야 해요.

Python
intents = discord.Intents.default()
intents.message_content = True   # 개발자 포털에서도 켜야 하는 특권 인텐트
intents.polls = True             # 투표 이벤트용

💬 message_content 는 특권 인텐트라서 디스코드 개발자 포털 → Bot → Privileged Gateway Intents 에서도 따로 켜줘야 해요. polls 는 특권이 아니라서 코드만 고치면 됩니다.

📌 전체 코드 (복붙 OK)

Python
# discord.py 2.5+ / Python 3.8+
# 디스코드 봇 만들기 #18 - 투표(Poll) 기능

import os
import datetime

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

load_dotenv()
TOKEN = os.getenv("DISCORD_TOKEN")

NUMBER_EMOJI = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]

MAX_ANSWERS = 10        # 디스코드 제한
MAX_QUESTION_LEN = 300  # 디스코드 제한
MAX_ANSWER_LEN = 55     # 디스코드 제한
MAX_HOURS = 24 * 32     # 최대 32일

intents = discord.Intents.default()
intents.message_content = True
intents.polls = True

bot = commands.Bot(command_prefix="!", intents=intents)

@bot.event
async def on_ready():
    try:
        synced = await bot.tree.sync()
        print(f"슬래시 명령어 {len(synced)}개 동기화 완료")
    except Exception as error:
        print(f"동기화 실패: {error}")
    print(f"{bot.user} 로그인 성공!")

@bot.tree.command(name="투표", description="서버 투표를 만들어요")
@app_commands.describe(
    질문="투표에 띄울 질문 (최대 300자)",
    항목="선택지를 쉼표(,)로 구분해서 입력 (2~10개)",
    시간="진행 시간 (시간 단위, 기본 24 / 최대 768)",
    복수선택="여러 개를 고를 수 있게 할지",
)
async def create_poll(
    interaction: discord.Interaction,
    질문: str,
    항목: str,
    시간: int = 24,
    복수선택: bool = False,
):
    labels = [x.strip() for x in 항목.split(",") if x.strip()]

    if not 2 <= len(labels) <= MAX_ANSWERS:
        await interaction.response.send_message(
            f"선택지는 2개 이상 {MAX_ANSWERS}개 이하로 넣어주세요! (지금 {len(labels)}개)",
            ephemeral=True,
        )
        return

    if len(질문) > MAX_QUESTION_LEN:
        await interaction.response.send_message(
            f"질문은 {MAX_QUESTION_LEN}자까지만 가능해요!", ephemeral=True
        )
        return

    too_long = [x for x in labels if len(x) > MAX_ANSWER_LEN]
    if too_long:
        await interaction.response.send_message(
            f"선택지는 하나당 {MAX_ANSWER_LEN}자까지예요: {too_long[0][:20]}...",
            ephemeral=True,
        )
        return

    if not 1 <= 시간 <= MAX_HOURS:
        await interaction.response.send_message(
            f"진행 시간은 1~{MAX_HOURS}시간 사이여야 해요!", ephemeral=True
        )
        return

    poll = discord.Poll(
        question=질문,
        duration=datetime.timedelta(hours=시간),
        multiple=복수선택,
    )
    for index, label in enumerate(labels):
        poll.add_answer(text=label, emoji=NUMBER_EMOJI[index])

    await interaction.response.send_message("투표를 올렸어요! 📊", ephemeral=True)
    await interaction.channel.send(poll=poll)

@bot.tree.command(name="투표결과", description="투표 메시지의 현재 집계를 보여줘요")
@app_commands.describe(메시지id="투표가 달린 메시지의 ID")
async def poll_result(interaction: discord.Interaction, 메시지id: str):
    await interaction.response.defer(ephemeral=True)

    try:
        message = await interaction.channel.fetch_message(int(메시지id))
    except (ValueError, discord.NotFound):
        await interaction.followup.send(
            "그런 메시지를 찾지 못했어요. ID를 다시 확인해주세요!", ephemeral=True
        )
        return

    poll = message.poll
    if poll is None:
        await interaction.followup.send("이 메시지에는 투표가 없어요!", ephemeral=True)
        return

    total = poll.total_votes
    embed = discord.Embed(
        title=f"📊 {poll.question}",
        color=discord.Color.blurple(),
    )

    for answer in poll.answers:
        percent = (answer.vote_count / total * 100) if total else 0
        bar = "█" * round(percent / 10) or "░"
        name = f"{answer.emoji or ''} {answer.text}".strip()
        embed.add_field(
            name=name,
            value=f"`{bar}` **{answer.vote_count}표** ({percent:.1f}%)",
            inline=False,
        )

    status = "종료됨 ✅" if poll.is_finalised() else "진행 중 🟢"
    embed.set_footer(text=f"총 {total}표 · {status}")

    if poll.is_finalised() and poll.victor_answer is not None:
        embed.description = f"🏆 1위: **{poll.victor_answer.text}**"

    await interaction.followup.send(embed=embed, ephemeral=True)

@bot.event
async def on_poll_vote_add(user, answer):
    print(f"[투표] {user}님 → '{answer.text}' (현재 {answer.vote_count}표)")

@bot.event
async def on_poll_vote_remove(user, answer):
    print(f"[취소] {user}님이 '{answer.text}' 투표를 취소했어요")

bot.run(TOKEN)

📌 실행 결과

터미널에서 python bot.py 로 실행한 뒤, 디스코드에서 이렇게 쳐보세요.

텍스트
/투표 질문:오늘 밤 뭐 돌릴까요? 항목:마인크래프트, 발로란트, 롤, 그냥 수다 시간:12 복수선택:False

채널에 투표 카드가 뜨고, 사람들이 클릭하면 막대가 실시간으로 차오릅니다. 터미널에는 이렇게 찍혀요.

텍스트
슬래시 명령어 2개 동기화 완료
MojangBot#1234 로그인 성공!
[투표] 모장캠님 → '마인크래프트' (현재 1표)
[투표] 감자칩님 → '롤' (현재 1표)
[취소] 감자칩님이 '롤' 투표를 취소했어요

/투표결과 로 집계를 보면 이런 임베드가 나와요.

텍스트
📊 오늘 밤 뭐 돌릴까요?
1️⃣ 마인크래프트   ██████ 6표 (60.0%)
2️⃣ 발로란트      ██ 2표 (20.0%)
3️⃣ 롤           █ 1표 (10.0%)
4️⃣ 그냥 수다      █ 1표 (10.0%)
총 10표 · 진행 중 🟢

📌 자주 나는 에러

1. AttributeError: module 'discord' has no attribute 'Poll'

  • 원인 : discord.py 버전이 낮아요. 투표는 2.4부터 들어왔습니다.
  • 해결 : pip install -U discord.py 로 업그레이드하세요. python -c "import discord; print(discord.__version__)" 로 2.5 이상인지 확인!

2. HTTPException: 400 Bad Request (error code: 50035) — duration 관련

  • 원인 : 투표 기간을 1시간 미만이나 32일 초과로 넣었어요. timedelta(minutes=30) 은 0시간으로 계산돼서 거절당합니다.
  • 해결 : timedelta(hours=N) 형태로, N은 1 이상 768 이하 정수로 넣어주세요.

3. ClientException: Cannot append answers to a poll that is active

  • 원인 : 이미 채널에 보내진(=활성화된) 투표에 add_answer() 를 또 호출했어요.
  • 해결 : 선택지는 보내기 전에 전부 붙이세요. 이미 올라간 투표를 손보려면 poll.copy() 로 상태 없는 사본을 만든 뒤 수정해야 합니다.

4. on_poll_vote_add 가 아예 안 불려요

  • 원인 : 인텐트가 빠졌거나, 투표 메시지가 봇 캐시에 없어요. 봇을 켜기 전에 올라간 투표는 캐시에 없어서 이벤트가 안 옵니다.
  • 해결 : intents.message_content = True + intents.polls = True 를 켜고, 개발자 포털에서 Message Content Intent도 활성화하세요. 캐시와 무관하게 받고 싶다면 on_raw_poll_vote_add(payload) 를 쓰면 됩니다.

5. 슬래시 명령어가 디스코드에 안 보여요

  • 원인 : bot.tree.sync() 를 안 했거나, 전역 동기화라 반영이 느려요(최대 1시간).
  • 해결 : 테스트 중이라면 특정 서버에만 즉시 동기화하는 게 편해요. await bot.tree.sync(guild=discord.Object(id=서버ID)) 를 쓰면 바로 뜹니다.

6. TypeError: send() got an unexpected keyword argument 'poll'

  • 원인 : 1번과 같은 버전 문제예요.
  • 해결 : discord.py를 2.5 이상으로 올려주세요.

📌 마무리

오늘은 discord.Poll 로 디스코드 네이티브 투표를 만들고, 슬래시 명령어로 아무나 투표를 열 수 있게 하고, 결과를 막대 그래프 임베드로 집계까지 해봤어요 🙌

오늘의 요약

  • 투표는 discord.Poll(question, duration, multiple) → add_answer() → send(poll=poll)
  • 기간은 시간 단위 정수(1~768시간), 선택지는 최대 10개
  • 결과는 message.poll.answers 와 total_votes 로 집계
  • 실시간 이벤트는 intents.polls 를 켜야 들어와요

다음 #19편에서는 Cog로 봇 코드 쪼개기를 다뤄볼게요. 지금까지 만든 기능들이 한 파일에 계속 쌓이면서 슬슬 스크롤 지옥이 시작됐잖아요? 기능별로 파일을 나누고, 봇을 껐다 켜지 않고도 코드를 다시 불러오는 핫리로드까지 만들어봅니다. 여기서부터 시리즈 최종 프로젝트인 커뮤니티 통합 관리 봇의 뼈대가 잡혀요. 기대해주세요! 🙌

💬 오늘 만든 create_poll 은 최종 프로젝트에서 cogs/poll.py 로 이사갈 예정이에요. 코드 잘 보관해두세요!

📚 디스코드 봇 만들기 시리즈

💬 궁금한 점은 댓글로 남겨주세요. 다음 편에서 만나요!

연재 · 32편 중 18번째

디스코드 봇 만들기

  1. #1디스코드 봇 만들기 - Slash Command 완전 정복!
  2. #2Slash Command에 옵션(Arguments) 추가하기!
  3. #3Slash Command에 서브 커맨드(Subcommand) 넣기!
  4. #4Slash Command로 임베드 전송하기!
  5. #5버튼(Button) 상호작용 구현하기!
  6. #6드롭다운(Select Menu) 메뉴 구현하기!
  7. #7모달(Modal)로 입력 받기 구현하기 (파이썬 예제)
  8. #8임베드(Embed) 이미지·필드로 꾸미기 (파이썬 예제)
  9. #9AI(Claude·ChatGPT API) 연동하기! (파이썬 예제)
  10. #10다국어(i18n) 지원하기! (파이썬 예제)
  11. #11플라스크(Flask)로 웹 대시보드 만들기! (파이썬 예제)
  12. #12명령어 쿨다운(Cooldown)·권한(Permission) 관리하기! (파이썬 예제)
  13. #13역할·채널 자동화로 서버 관리하기! (파이썬 예제)
  14. #14데이터 저장하기! JSON vs SQLite (파이썬 예제)
  15. #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제)
  16. #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제)
  17. #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제)
  18. #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제)지금 읽는 글
  19. #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제)
  20. #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제)
  21. #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제)
  22. #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제)
  23. #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제)
  24. #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제)
  25. #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제)
  26. #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제)
  27. #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제)
  28. #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제)
  29. #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제)
  30. #30투표·설문(Poll) 봇 만들기! 네이티브 투표 + 버튼으로 실시간 집계까지 (파이썬 예제) 🗳️
  31. #32서버 로그·감사(Audit Log) 채널 만들기! 메시지 삭제·수정·입퇴장·추방까지 자동 기록 (파이썬 예제) 🕵️
  32. #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
전체 32편 보기접기

글쓴이

Mojangcam

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