Poll 앱 - 실시간 투표

초급 (Beginner)예상 1시간

동작하는 전체 코드: examples/poll/ — make test가 함께 돌리고, 릴리스 게이트(CI)가 태그마다 다시 돌리는 예제다.

이 튜토리얼에서는 실시간 투표 앱을 만들며 wireview의 렌더링 최적화를 학습합니다.

학습 목표#

  • skip_render() 최적화
  • {% class %} 조건부 CSS 클래스
  • {% cond %} 조건부 속성
  • 모델 구독과 mutation() 훅
  • .wireview-loading CSS 상태

완성 미리보기#

실시간으로 투표 결과가 업데이트되는 투표 앱입니다:

  • 옵션 클릭으로 투표
  • 투표 수와 퍼센트 실시간 표시
  • 다른 사용자의 투표도 실시간 반영
  • 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;
}

연습 문제#

  1. 복수 선택 투표: 여러 옵션을 선택할 수 있도록 수정해보세요
  2. 익명 투표: 투표 후 결과만 보이도록 수정해보세요
  3. 투표 마감: is_active=False일 때 투표 버튼 비활성화

다음 단계#

  • Rating 앱 - 별점 평가와 키보드 이벤트