Poll 앱 - 실시간 투표
동작하는 전체 코드: examples/poll/ —
make test가 함께 돌리고, 릴리스 게이트(CI)가 태그마다 다시 돌리는 예제다.
이 튜토리얼에서는 실시간 투표 앱을 만들며 wireview의 렌더링 최적화를 학습합니다.
학습 목표#
skip_render()최적화{% class %}조건부 CSS 클래스{% cond %}조건부 속성- 모델 구독과
mutation()훅 .wireview-loadingCSS 상태
완성 미리보기#
실시간으로 투표 결과가 업데이트되는 투표 앱입니다:
- 옵션 클릭으로 투표
- 투표 수와 퍼센트 실시간 표시
- 다른 사용자의 투표도 실시간 반영
- URL로 투표 상태 저장
1. 모델 정의#
poll/models.py:
from django.db import models
class Poll(models.Model):
"""투표 질문"""
question = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
@property
def total_votes(self):
"""전체 투표 수"""
return sum(option.votes for option in self.options.all())
class Option(models.Model):
"""투표 옵션"""
poll = models.ForeignKey(Poll, on_delete=models.CASCADE, related_name="options")
text = models.CharField(max_length=200)
votes = models.PositiveIntegerField(default=0)
@property
def percentage(self):
"""퍼센트 계산"""
total = self.poll.total_votes
if total == 0:
return 0
return round((self.votes / total) * 100, 1)
2. AUTO_BROADCAST 설정#
모델 변경을 자동으로 컴포넌트에 브로드캐스트하려면 settings.py에 설정이 필요합니다:
from wireview import AutoBroadcast
WIREVIEW = {
"AUTO_BROADCAST": AutoBroadcast(
model=True, # 모델명 채널 활성화 (예: "poll.option")
model_pk=True, # 모델명.pk 채널 활성화 (예: "poll.option.5")
senders={("poll", "Option")}, # 알릴 모델. 비우면 아무것도 알리지 않는다
),
}
참고: 이 설정이 없거나
senders에 모델이 없으면Meta.subscriptions를 지정해도mutation()이 호출되지 않습니다.
3. 컴포넌트 정의#
poll/live.py:
from wireview import Component, ModelAction
from .models import Option, Poll
class XPoll(Component):
"""실시간 투표 컴포넌트"""
class Meta:
template_name = "poll/poll.html"
subscriptions = {"poll.option"} # poll 앱의 Option 모델 변경 구독
poll: Poll
voted_option_id: int | None = None # 투표한 옵션 ID
async def joined(self):
"""마운트 시 URL에서 투표 상태 복원"""
if voted_id := self.wire.params.get("voted"):
self.voted_option_id = int(voted_id)
async def mutation(self, channel: str, action: ModelAction, instance: Option):
"""Option 변경 시 리렌더링"""
if instance.poll_id == self.poll.id:
self.force_render()
async def vote(self, option_id: int):
"""투표 처리"""
if self.voted_option_id is not None:
self.skip_render() # 이미 투표함
return
# option_id는 클라이언트가 보낸 값이다. 이 투표의 옵션인지 확인한다
option = await Option.objects.filter(id=option_id, poll_id=self.poll.id).afirst()
if option is None:
self.skip_render()
return
option.votes += 1
await option.asave() # post_save가 "poll.option" 채널로 브로드캐스트된다
self.voted_option_id = option_id
self.wire.params["voted"] = str(option_id) # URL에 저장
self.wire.params에 넣은 값은 핸들러가 끝나면 브라우저 URL의 쿼리 문자열이 된다(?voted=3).
새로고침하면 joined()가 그 값을 다시 읽는다.
핸들러 인자는 클라이언트가 보낸 값이라 위조할 수 있다. Option.objects.aget(id=option_id)로만 찾으면 다른
투표의 옵션에도 표가 들어간다 — 조회에 poll_id=self.poll.id를 함께 건다.
4. 핵심 개념: skip_render()#
skip_render()는 불필요한 렌더링을 방지합니다:
async def vote(self, option_id: int):
if self.voted_option_id is not None:
# 이미 투표한 경우, 렌더링 건너뛰기
self.skip_render()
return
mutation()이 모델 변경을 감지하고 자동으로 리렌더링하므로,
투표 후에는 mutation()에 맡깁니다.
5. 템플릿#
poll/templates/poll/poll.html:
{% load wireview %}
<div {% tag_header %} class="poll-card">
<h2>{{ this.poll.question }}</h2>
<div class="poll-options">
{% for option in this.poll.options.all %}
<div class="poll-option">
<button
type="button"
{% class {'option-button': True, 'selected': voted_option_id == option.id} %}
{% cond {'disabled': voted_option_id} %}
{% on 'click' 'vote' option_id=option.id %}
>
<div class="progress-bar" style="width: {{ option.percentage }}%"></div>
<div class="option-content">
<span>{{ option.text }}</span>
<span>{{ option.votes }} votes ({{ option.percentage }}%)</span>
</div>
</button>
</div>
{% endfor %}
</div>
<p>Total votes: <strong>{{ this.poll.total_votes }}</strong></p>
</div>
6. 핵심 템플릿 태그#
{% class %} - 조건부 CSS 클래스#
{% class {'option-button': True, 'selected': voted_option_id == option.id} %}
출력: class="option-button selected" (조건이 참인 것만)
{% cond %} - 조건부 속성#
{% cond {'disabled': voted_option_id} %}
voted_option_id가 존재하면 disabled 출력
7. CSS 로딩 상태#
wireview는 서버 요청 중 자동으로 .wireview-loading 클래스를 추가합니다:
.option-button.wireview-loading {
opacity: 0.7;
pointer-events: none;
}
연습 문제#
- 복수 선택 투표: 여러 옵션을 선택할 수 있도록 수정해보세요
- 익명 투표: 투표 후 결과만 보이도록 수정해보세요
- 투표 마감:
is_active=False일 때 투표 버튼 비활성화
다음 단계#
- Rating 앱 - 별점 평가와 키보드 이벤트