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

디스코드 봇 만들기#30투표·설문(Poll) 봇 만들기! 네이티브 투표 + 버튼으로 실시간 집계까지 (파이썬 예제) 🗳️

목차10개 항목

안녕하세요! Mojangcam입니다 😎

지난 편에서는 화폐·상점(Economy) 시스템을 만들어서 코인을 벌고 아이템을 사는 봇을 완성했어요. 서버에 "놀거리"가 하나 생긴 셈이죠!

이번 편에서는 서버 운영에 진짜 자주 쓰이는 기능, 투표·설문(Poll) 봇을 만들어볼게요. 두 가지 방식을 다 다룹니다.

  1. 네이티브 투표 — 디스코드가 자체 제공하는 투표 UI를 봇이 띄우는 방식 (제일 간단!)
  2. 버튼 커스텀 투표 — 버튼으로 직접 만들어서 실시간 집계·중복 방지·투표 마감까지 내 맘대로 제어하는 방식

서버 이벤트 날짜 정하기, 다음 콘텐츠 정하기, 규칙 개정 찬반 묻기... 이런 거 매번 이모지 달아서 손으로 세고 계셨다면 오늘 글로 끝낼 수 있어요! 🙌

![](./img-01.png)

📌 준비물

  • Python 3.8+ (권장 3.10 이상)
  • discord.py 2.x — 네이티브 Poll은 2.5 이상이 필요해요
  • python-dotenv — 봇 토큰을 코드에 직접 쓰지 않기 위해 필수!
터미널
pip install -U "discord.py>=2.5" python-dotenv

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

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

그리고 봇 토큰은 절대 코드에 하드코딩하지 마세요. 프로젝트 폴더에 .env 파일을 만들어 주세요.

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

💡 팁: .gitignore에 .env 한 줄 꼭 추가하세요! 깃허브에 토큰이 올라가면 디스코드가 자동으로 토큰을 무효화시켜 버려요. (그나마 다행이지만요 😅)

🧩 인텐트(Intents) 설정

네이티브 투표의 투표 이벤트를 받으려면 polls 인텐트가 필요해요. 버튼 방식만 쓸 거면 없어도 됩니다.

Python
intents = discord.Intents.default()
intents.message_content = True
intents.polls = True  # 투표 이벤트(on_poll_vote_add 등)를 받으려면 필요

📌 1단계 - 네이티브 투표 띄우기 (제일 쉬운 길)

discord.py 2.5부터 discord.Poll 클래스가 생겨서, 디스코드 앱의 기본 투표 UI를 봇이 그대로 띄울 수 있어요.

Python
@bot.tree.command(name="투표", description="디스코드 기본 투표를 띄웁니다")
async def native_poll(interaction: discord.Interaction, 질문: str, 항목: str):
    # 항목은 쉼표로 구분해서 받아요: "치킨,피자,족발"
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:10]

    if len(choices) < 2:
        await interaction.response.send_message("항목은 2개 이상 넣어주세요!", ephemeral=True)
        return

    poll = discord.Poll(question=질문, duration=datetime.timedelta(hours=24))
    for c in choices:
        poll.add_answer(text=c)

    await interaction.response.send_message(poll=poll)

하나씩 뜯어볼게요 💡

  • discord.Poll(question, duration) : 투표 객체를 만들어요. question은 최대 300자, duration은 datetime.timedelta로 지정하며 최대 32일(768시간)까지 가능해요.
  • poll.add_answer(text=..., emoji=...) : 선택지를 추가해요. 최대 10개까지만 됩니다. emoji 인자로 이모지도 붙일 수 있어요.
  • send_message(poll=poll) : 메시지에 poll= 으로 넘기면 끝! 집계·마감·중복 방지는 디스코드가 알아서 해줍니다.
  • 참고로 봇에게 투표 만들기(Send Polls) 권한이 있어야 해요.

투표가 들어오고 나가는 순간을 잡고 싶다면 이벤트를 쓰면 돼요.

Python
@bot.event
async def on_poll_vote_add(user, answer):
    print(f"{user}님이 '{answer.text}'에 투표했어요")

💬 이 이벤트는 해당 메시지가 봇 캐시에 있을 때만 호출돼요. 오래된 메시지까지 잡으려면 on_raw_poll_vote_add를 쓰세요.

네이티브 투표는 정말 편하지만 한계가 있어요. 항목 10개 제한, 역할 제한 불가, 결과를 내 임베드에 예쁘게 못 그림, 투표 도중 집계를 가공할 수 없음. 그래서 2단계로 갑니다!


📌 2단계 - 버튼 커스텀 투표 (실시간 집계)

버튼 하나당 선택지 하나를 만들고, 누가 뭘 눌렀는지 딕셔너리에 저장하는 구조예요.

Python
class PollView(discord.ui.View):
    def __init__(self, question: str, choices: list[str], author_id: int, timeout: float = 3600):
        super().__init__(timeout=timeout)
        self.question = question
        self.choices = choices
        self.author_id = author_id
        self.votes: dict[int, int] = {}  # {유저ID: 선택지 index}

        for idx, label in enumerate(choices):
            self.add_item(VoteButton(label=label, index=idx))

하나씩 뜯어볼게요 💡

  • self.votes : 핵심이에요. 키가 유저 ID라서 같은 사람이 다시 누르면 값이 덮어씌워져요. 즉 중복 투표가 구조적으로 막힙니다. 마음 바뀌면 다른 버튼 누르면 끝!
  • timeout : 이 시간(초)이 지나면 버튼이 자동으로 멈춰요. 3600 = 1시간.
  • author_id : 투표를 연 사람만 "조기 마감"을 누를 수 있게 하려고 저장해둡니다.

버튼 본체는 이렇게 생겼어요.

Python
class VoteButton(discord.ui.Button):
    def __init__(self, label: str, index: int):
        super().__init__(label=label, style=discord.ButtonStyle.secondary)
        self.index = index

    async def callback(self, interaction: discord.Interaction):
        view: PollView = self.view
        view.votes[interaction.user.id] = self.index
        await interaction.response.edit_message(embed=view.build_embed())
  • callback : 버튼을 누르는 순간 실행돼요. 표를 기록하고 바로 메시지를 다시 그립니다. 이게 "실시간 집계"의 정체예요!
  • edit_message : 새 메시지를 보내지 않고 기존 메시지를 갈아끼워요. 채팅창이 안 더러워집니다 👍

📌 3단계 - 결과를 막대그래프로 그리기

숫자만 보여주면 심심하니까, 블록 문자로 막대그래프를 만들어볼게요.

Python
def build_embed(self) -> discord.Embed:
    counts = [0] * len(self.choices)
    for choice_idx in self.votes.values():
        counts[choice_idx] += 1
    total = sum(counts)

    lines = []
    for label, cnt in zip(self.choices, counts):
        ratio = (cnt / total) if total else 0
        bar = "█" * round(ratio * 12) or "░"
        lines.append(f"`{bar:<12}` **{cnt}표** ({ratio*100:.0f}%) · {label}")

    embed = discord.Embed(title=f"🗳️ {self.question}", description="\n".join(lines))
    embed.set_footer(text=f"총 {total}표 · 버튼을 눌러 투표하세요 (다시 누르면 변경돼요)")
    return embed
  • bar:<12 : 백틱으로 감싸고 12칸으로 폭을 고정해서 막대 길이가 들쭉날쭉하지 않게 맞춰요.
  • or "░" : 0표일 때 빈 줄이 되지 않게 빈 칸 문자를 하나 넣어줍니다.
  • 총 투표수는 len(self.votes)와 같아요. 한 사람당 한 표니까요!

![](./img-02.png)


📌 전체 코드 (복붙 OK)

Python
# discord.py 2.5+ / Python 3.10+
# pip install -U "discord.py>=2.5" python-dotenv
import os
import datetime

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

load_dotenv()
token = os.getenv("token")

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

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

class VoteButton(discord.ui.Button):
    def __init__(self, label: str, index: int):
        super().__init__(label=label, style=discord.ButtonStyle.secondary)
        self.index = index

    async def callback(self, interaction: discord.Interaction):
        view: "PollView" = self.view
        if view.closed:
            await interaction.response.send_message("이미 마감된 투표예요!", ephemeral=True)
            return
        view.votes[interaction.user.id] = self.index
        await interaction.response.edit_message(embed=view.build_embed(), view=view)

class CloseButton(discord.ui.Button):
    def __init__(self):
        super().__init__(label="투표 마감", style=discord.ButtonStyle.danger, row=4)

    async def callback(self, interaction: discord.Interaction):
        view: "PollView" = self.view
        if interaction.user.id != view.author_id:
            await interaction.response.send_message("투표를 연 사람만 마감할 수 있어요!", ephemeral=True)
            return
        view.closed = True
        for item in view.children:
            item.disabled = True
        await interaction.response.edit_message(embed=view.build_embed(), view=view)
        view.stop()

class PollView(discord.ui.View):
    def __init__(self, question: str, choices: list[str], author_id: int, timeout: float = 3600):
        super().__init__(timeout=timeout)
        self.question = question
        self.choices = choices
        self.author_id = author_id
        self.votes: dict[int, int] = {}
        self.closed = False

        for idx, label in enumerate(choices):
            self.add_item(VoteButton(label=label, index=idx))
        self.add_item(CloseButton())

    def build_embed(self) -> discord.Embed:
        counts = [0] * len(self.choices)
        for choice_idx in self.votes.values():
            counts[choice_idx] += 1
        total = sum(counts)

        lines = []
        for label, cnt in zip(self.choices, counts):
            ratio = (cnt / total) if total else 0
            bar = "█" * round(ratio * 12) or "░"
            lines.append(f"`{bar:<12}` **{cnt}표** ({ratio * 100:.0f}%) · {label}")

        title = ("🔒 [마감] " if self.closed else "🗳️ ") + self.question
        embed = discord.Embed(title=title, description="\n".join(lines), color=0x5865F2)
        footer = f"총 {total}표"
        footer += " · 최종 결과입니다" if self.closed else " · 버튼을 눌러 투표하세요 (다시 누르면 변경돼요)"
        embed.set_footer(text=footer)
        return embed

    async def on_timeout(self):
        self.closed = True
        for item in self.children:
            item.disabled = True

@bot.event
async def on_ready():
    await bot.tree.sync()
    print(f"{bot.user} 준비 완료!")

@bot.tree.command(name="투표", description="버튼 투표를 시작합니다 (항목은 쉼표로 구분)")
async def poll_command(interaction: discord.Interaction, 질문: str, 항목: str, 시간: int = 1):
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:5]
    if len(choices) < 2:
        await interaction.response.send_message("항목은 2개 이상 넣어주세요! 예) 치킨,피자,족발", ephemeral=True)
        return

    view = PollView(질문, choices, interaction.user.id, timeout=시간 * 3600)
    await interaction.response.send_message(embed=view.build_embed(), view=view)

@bot.tree.command(name="기본투표", description="디스코드 네이티브 투표를 띄웁니다")
async def native_poll(interaction: discord.Interaction, 질문: str, 항목: str, 시간: int = 24):
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:10]
    if len(choices) < 2:
        await interaction.response.send_message("항목은 2개 이상 넣어주세요!", ephemeral=True)
        return

    poll = discord.Poll(question=질문, duration=datetime.timedelta(hours=시간))
    for c in choices:
        poll.add_answer(text=c)

    await interaction.response.send_message(poll=poll)

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

bot.run(token)

📌 실행 결과

봇을 켜고 서버에서 이렇게 써보세요.

텍스트
/투표 질문:오늘 저녁 뭐 먹을까요? 항목:치킨,피자,족발 시간:2

버튼 3개와 "투표 마감" 버튼이 달린 임베드가 뜹니다. 누군가 버튼을 누르는 즉시 막대그래프가 갱신돼요. 같은 사람이 다른 버튼을 누르면 표가 이동하고, 같은 버튼을 다시 눌러도 표가 늘어나지 않아요.

투표를 연 사람이 "투표 마감"을 누르면 모든 버튼이 비활성화되고 제목이 🔒 [마감]으로 바뀝니다. 아무도 안 눌러도 시간(기본 1시간)이 지나면 on_timeout이 알아서 잠가줘요.

네이티브 투표는 /기본투표로 확인해보세요. 디스코드 앱의 익숙한 그 투표 UI가 그대로 뜹니다!

![](./img-03.png)


📌 자주 나는 에러

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

  • 원인: discord.py 버전이 2.5보다 낮아요. Poll 클래스는 2.5에서 추가됐습니다.
  • 해결: pip install -U "discord.py>=2.5" 후 python -c "import discord; print(discord.__version__)"로 확인하세요.

2. AttributeError: 'Intents' object has no attribute 'polls'

  • 원인: 위와 같은 버전 문제예요. polls 인텐트도 2.5에서 들어왔어요.
  • 해결: 업그레이드하거나, 버튼 방식만 쓸 거면 intents.polls = True 줄을 지우면 됩니다.

3. 투표 버튼을 눌렀는데 "상호작용이 실패했습니다"가 떠요

  • 원인: 봇을 재시작했기 때문이에요. View 객체는 메모리에 살아있는 동안만 동작하거든요.
  • 해결: 짧은 투표는 그냥 다시 열면 되고, 봇이 꺼져도 유지되는 투표를 원하면 timeout=None + custom_id를 지정한 영구 View(persistent view)로 만들고 표를 DB(SQLite)에 저장해야 해요. 표를 메모리 딕셔너리에만 두면 재시작 시 전부 날아갑니다!

4. Missing Permissions — 네이티브 투표가 안 올라가요

  • 원인: 봇 역할에 투표 만들기(Send Polls) 권한이 없어요.
  • 해결: 서버 설정 → 역할 → 봇 역할에서 해당 권한을 켜주세요. 채널별 권한 덮어쓰기도 같이 확인하고요.

5. 슬래시 명령어가 서버에 안 보여요

  • 원인: 전역 동기화(bot.tree.sync())는 반영까지 최대 1시간 걸릴 수 있어요.
  • 해결: 테스트할 땐 await bot.tree.sync(guild=discord.Object(id=서버ID))로 특정 서버에만 동기화하면 즉시 반영됩니다.

📌 마무리

오늘은 투표·설문 봇을 두 가지 방식으로 만들어봤어요 🙌

  • 네이티브 투표 — 코드 5줄이면 끝. 집계·마감을 디스코드가 다 해줘서 제일 편해요
  • 버튼 커스텀 투표 — 실시간 막대그래프, 중복 방지, 조기 마감까지 내 맘대로 제어 가능

"빠르게 물어보고 싶다" → 네이티브, "결과를 예쁘게 보여주고 조건을 걸고 싶다" → 버튼. 이렇게 기억하시면 돼요!

다음 편에서는 이 투표 결과를 데이터베이스에 저장해서 봇을 재시작해도 안 날아가게 만드는 방법을 다뤄볼게요. 위 3번 에러의 근본 해결편이 되겠네요. 기대해주세요! 😎

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

연재 · 32편 중 30번째

디스코드 봇 만들기

  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 실전 기록 블로그