디스코드 봇 만들기#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= 으로 넘기면 끝이에요.
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단계 - 슬래시 명령어로 누구나 투표 만들게 하기
매번 코드를 고칠 순 없으니, /투표 명령어를 만들어서 선택지를 쉼표로 받아 처리해볼게요.
@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 을 들여다보면 됩니다.
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로░를 대신 보여줍니다.
누가 어디에 투표했는지도 볼 수 있어요.
async for voter in poll.answers[0].voters():
print(f"{voter} 님이 투표했어요!")📌 4단계 - 투표 이벤트 실시간으로 받기
누군가 투표하는 그 순간을 잡고 싶다면 이벤트를 쓰면 돼요.
@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개를 켜야 해요.
intents = discord.Intents.default()
intents.message_content = True # 개발자 포털에서도 켜야 하는 특권 인텐트
intents.polls = True # 투표 이벤트용💬
message_content는 특권 인텐트라서 디스코드 개발자 포털 → Bot → Privileged Gateway Intents 에서도 따로 켜줘야 해요.polls는 특권이 아니라서 코드만 고치면 됩니다.
📌 전체 코드 (복붙 OK)
# 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로 이사갈 예정이에요. 코드 잘 보관해두세요!
📚 디스코드 봇 만들기 시리즈
- #5 - 버튼(Button) 상호작용 구현하기
- #6 - 드롭다운(Select Menu) 메뉴 구현하기
- #7 - 모달(Modal)로 입력 받기
- #8 - 임베드(Embed) 이미지·필드로 꾸미기
- #9 - AI(Claude·ChatGPT API) 연동하기
- #10 - 다국어(i18n) 지원하기
- #11 - 플라스크(Flask)로 웹 대시보드 만들기
- #12 - 명령어 쿨다운(Cooldown)·권한(Permission) 관리하기
- #13 - 역할·채널 자동화로 서버 관리하기
- #14 - 데이터 저장하기! JSON vs SQLite
- #15 - 슬래시 명령어 그룹(Group)으로 명령어 정리하기
- #16 - 리마인더·알림 기능 만들기
- #17 - RAG로 문서 기반 답변봇 만들기
- #18 - 투표(Poll) 기능 만들기 ← 지금 이 글!
- #19 - Cog로 코드 쪼개기 (예정)
💬 궁금한 점은 댓글로 남겨주세요. 다음 편에서 만나요!
연재 · 32편 중 18번째
디스코드 봇 만들기
- #1디스코드 봇 만들기 - Slash Command 완전 정복!
- #2Slash Command에 옵션(Arguments) 추가하기!
- #3Slash Command에 서브 커맨드(Subcommand) 넣기!
- #4Slash Command로 임베드 전송하기!
- #5버튼(Button) 상호작용 구현하기!
- #6드롭다운(Select Menu) 메뉴 구현하기!
- #7모달(Modal)로 입력 받기 구현하기 (파이썬 예제)
- #8임베드(Embed) 이미지·필드로 꾸미기 (파이썬 예제)
- #9AI(Claude·ChatGPT API) 연동하기! (파이썬 예제)
- #10다국어(i18n) 지원하기! (파이썬 예제)
- #11플라스크(Flask)로 웹 대시보드 만들기! (파이썬 예제)
- #12명령어 쿨다운(Cooldown)·권한(Permission) 관리하기! (파이썬 예제)
- #13역할·채널 자동화로 서버 관리하기! (파이썬 예제)
- #14데이터 저장하기! JSON vs SQLite (파이썬 예제)
- #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제)
- #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제)
- #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제)
- #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제)지금 읽는 글
- #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제)
- #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제)
- #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제)
- #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제)
- #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제)
- #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제)
- #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제)
- #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제)
- #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제)
- #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제)
- #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제)
- #30투표·설문(Poll) 봇 만들기! 네이티브 투표 + 버튼으로 실시간 집계까지 (파이썬 예제) 🗳️
- #32서버 로그·감사(Audit Log) 채널 만들기! 메시지 삭제·수정·입퇴장·추방까지 자동 기록 (파이썬 예제) 🕵️
- #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉
