Dashboard

고급 (Advanced)예상 2시간

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

이 튜토리얼에서는 대시보드를 만들며 AsyncResult와 복합 컴포넌트 패턴을 학습합니다.

학습 목표#

  • AsyncResult로 비동기 데이터 로딩 처리
  • 로딩/성공/에러 상태 UI
  • 복합 컴포넌트 구성
  • Streams와 페이지네이션 조합
  • URL 상태로 탭/필터 관리

완성 앱 미리보기#

  • 여러 통계 카드 (각각 비동기 로딩)
  • 탭 네비게이션
  • 활동 피드 (무한 스크롤)
  • 실시간 업데이트

Part 1: 기본 설정#

모델 정의#

dashboard/models.py:

from django.db import models


class Stat(models.Model):
    """통계 데이터"""
    name = models.CharField(max_length=100)
    value = models.IntegerField(default=0)
    change = models.FloatField(default=0)  # 변화율 (%)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"{self.name}: {self.value}"


class Activity(models.Model):
    """활동 로그"""
    ACTION_CHOICES = [
        ('create', 'Created'),
        ('update', 'Updated'),
        ('delete', 'Deleted'),
        ('login', 'Logged in'),
    ]

    user = models.CharField(max_length=100)
    action = models.CharField(max_length=20, choices=ACTION_CHOICES)
    target = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return f"{self.user} {self.action} {self.target}"

Part 2: AsyncResult - 비동기 로딩 상태#

개념#

AsyncResult는 비동기 작업의 상태를 추적합니다:

from wireview import AsyncResult
result.loading  # 로딩 중
result.ok       # 성공
result.failed   # 실패
result.done     # 완료 (성공 또는 실패)

# 값
result.result        # 성공 시 결과값
result.error         # 실패 시 예외
result.error_message # 에러 메시지

StatCard 컴포넌트#

dashboard/live.py:

from wireview import Component, AsyncResult
from .models import Stat
import asyncio


class XStatCard(Component):
    """통계 카드 - AsyncResult 사용"""

    class Meta:
        template_name = 'dashboard/stat_card.html'

    stat_name: str
    stat: AsyncResult[Stat] | None = None

    async def joined(self):
        """컴포넌트 연결 시 데이터 로드 시작"""
        # assign_async로 비동기 작업 시작
        self.stat = await self.assign_async(self._load_stat())

    async def _load_stat(self):
        """통계 데이터 로드 (시뮬레이션된 지연)"""
        # 실제 환경에서는 외부 API 호출 등
        await asyncio.sleep(0.5)  # 로딩 시뮬레이션
        return await Stat.objects.aget(name=self.stat_name)

    async def refresh(self):
        """수동 새로고침"""
        self.stat = await self.assign_async(self._load_stat())

_load_stat은 _로 시작한다. 밑줄 없는 메서드는 클라이언트가 이벤트로 부를 수 있는 핸들러가 된다.

stat의 타입은 AsyncResult[Stat] | None으로 적는다. 성공한 결과의 모델 인스턴스는 서명 상태에 pk로 실리고, 다시 join할 때 이 표기를 따라 Stat으로 다시 읽힌다. 실패했으면 예외 자체는 서버에 남고 error_message만 실린다. 뒤에 나오는 XDashboard의 stats: list[Stat]도 같은 방식으로 pk 목록이 된다(상세는 Todo 앱).

StatCard 템플릿#

dashboard/templates/dashboard/stat_card.html:

intcomma 필터는 django.contrib.humanize에 있다. INSTALLED_APPS에 추가하고 템플릿에서 humanize를 로드한다.

{% load wireview humanize %}
<div {% tag_header %} class="stat-card">
  {% if stat.loading %}
    <div class="loading">
      <div class="spinner"></div>
      <span>Loading...</span>
    </div>

  {% elif stat.ok %}
    <div class="stat-content">
      <h3 class="stat-name">{{ stat.result.name }}</h3>
      <div class="stat-value">{{ stat.result.value|intcomma }}</div>
      <div class="stat-change {% if stat.result.change >= 0 %}positive{% else %}negative{% endif %}">
        {% if stat.result.change >= 0 %}+{% endif %}{{ stat.result.change }}%
      </div>
    </div>
    <button class="refresh-btn" {% on "click" "refresh" %}>
      Refresh
    </button>

  {% elif stat.failed %}
    <div class="error">
      <span class="error-icon">⚠️</span>
      <span class="error-message">{{ stat.error_message }}</span>
      <button {% on "click" "refresh" %}>Retry</button>
    </div>
  {% endif %}
</div>

AsyncResult 메서드#

# 결과 변환
mapped = stat.map(lambda s: s.value * 2)

# 기본값으로 가져오기
value = stat.get_or(default_value)

# 결과 또는 예외 발생
value = stat.get_or_raise()

# 직접 상태 설정
stat = AsyncResult.loading_state()
stat = AsyncResult.success(value)
stat = AsyncResult.failure(exception)

Part 3: 대시보드 메인 컴포넌트#

탭 네비게이션#

from wireview import WireviewMeta


class XDashboard(Component):
    """대시보드 메인 컴포넌트"""

    class Meta:
        template_name = 'dashboard/dashboard.html'

    active_tab: str = "overview"
    stats: list[Stat] = []

    @classmethod
    def new(cls, wire: WireviewMeta, **kwargs):
        # URL에서 탭 상태 복원
        kwargs.setdefault("active_tab", wire.params.get("tab", "overview"))
        return cls(wire=wire, **kwargs)

    async def joined(self):
        self.stats = [s async for s in Stat.objects.all()]

    async def set_tab(self, tab: str):
        """탭 변경"""
        self.active_tab = tab
        self.wire.params["tab"] = tab

대시보드 템플릿#

dashboard/templates/dashboard/dashboard.html:

{% load wireview %}
<div {% tag_header %} class="dashboard">
  <header class="dashboard-header">
    <h1>Dashboard</h1>
    <nav class="tabs">
      <button
        {% class {'active': active_tab == 'overview'} %}
        {% on "click" "set_tab" tab="overview" %}
      >Overview</button>
      <button
        {% class {'active': active_tab == 'activity'} %}
        {% on "click" "set_tab" tab="activity" %}
      >Activity</button>
      <button
        {% class {'active': active_tab == 'settings'} %}
        {% on "click" "set_tab" tab="settings" %}
      >Settings</button>
    </nav>
  </header>

  <main class="dashboard-content">
    {% if active_tab == 'overview' %}
      <section class="stats-grid">
        {% for stat in stats %}
          {% component 'XStatCard' id="stat-"|concat:stat.name stat_name=stat.name %}
        {% endfor %}
      </section>

    {% elif active_tab == 'activity' %}
      {% component 'XActivityFeed' id="activity-feed" %}

    {% elif active_tab == 'settings' %}
      <section class="settings">
        <p>Settings content here...</p>
      </section>
    {% endif %}
  </main>
</div>

중첩 컴포넌트마다 id를 준다. 그래야 부모가 다시 그려도 같은 카드가 이어진다(Todo 앱 Part 5).

Part 4: Activity Feed - Streams + 페이지네이션#

ActivityFeed 컴포넌트#

from wireview import ModelAction
from .models import Activity


class XActivityFeed(Component):
    """활동 피드 - Streams + 무한 스크롤"""

    class Meta:
        template_name = 'dashboard/activity_feed.html'
        # 자동 브로드캐스트 채널: {app_label}.{model_name}
        subscriptions = {"dashboard.activity"}

    # 스트림 항목은 상태에 두지 않는다. 다음 페이지의 기준점만 기억한다
    oldest_id: int | None = None
    has_more: bool = True

    async def joined(self):
        activities = await self._load_activities()
        await self.stream("activities", activities)
        self._advance(activities)

    async def _load_activities(self, before_id: int | None = None, limit: int = 20):
        """활동 로드 (id 역순)"""
        qs = Activity.objects.order_by('-id')
        if before_id is not None:
            qs = qs.filter(id__lt=before_id)
        return [a async for a in qs[:limit]]

    def _advance(self, activities: list, limit: int = 20):
        """불러온 페이지로 기준점과 has_more를 갱신"""
        if activities:
            self.oldest_id = activities[-1].id
        self.has_more = len(activities) >= limit

    async def load_more(self):
        """더 불러오기"""
        if not self.has_more:
            return

        new_activities = await self._load_activities(before_id=self.oldest_id)
        for activity in new_activities:
            await self.stream_insert("activities", activity, at=-1)
        self._advance(new_activities)

    async def mutation(self, channel: str, action: ModelAction, instance):
        """새 활동 실시간 수신"""
        if action == ModelAction.CREATED:
            await self.stream_insert("activities", instance, at=0)

stream()은 template=이 없으면 {컴포넌트 템플릿}_item.html, 여기서는 dashboard/activity_feed_item.html을 항목 템플릿으로 쓴다. 그 템플릿은 항목을 item으로 받는다.

ActivityFeed 템플릿#

dashboard/templates/dashboard/activity_feed.html:

{% load wireview %}
<div {% tag_header %} class="activity-feed">
  <h2>Recent Activity</h2>

  <ul wire-stream="activities" class="activity-list"></ul>

  {% if has_more %}
    <div class="load-more">
      <button {% on "click" "load_more" %} wire-disabled-with="Loading...">
        Load More
      </button>
    </div>
  {% endif %}
</div>

로딩 표시는 서버 필드가 아니라 클라이언트가 한다. 핸들러 안에서 loading_more = True로 바꿨다가 끝나기 전에 되돌리면 화면에는 한 번도 그려지지 않는다. 렌더는 핸들러가 끝난 뒤 한 번 나가기 때문이다. wire-disabled-with는 클릭하는 즉시 버튼을 비활성화하고 문구를 바꾸며, 응답이 오면 되돌린다. 그동안 버튼에는 wireview-click-loading 클래스도 붙는다(Optimistic UI).

dashboard/templates/dashboard/activity_feed_item.html:

<li id="activities-{{ item.pk }}" class="activity-item">
  <div class="activity-icon {{ item.action }}">
    {% if item.action == 'create' %}➕
    {% elif item.action == 'update' %}✏️
    {% elif item.action == 'delete' %}🗑️
    {% elif item.action == 'login' %}🔑
    {% endif %}
  </div>
  <div class="activity-content">
    <span class="user">{{ item.user }}</span>
    <span class="action">{{ item.get_action_display|lower }}</span>
    <span class="target">{{ item.target }}</span>
  </div>
  <time class="activity-time">{{ item.created_at|timesince }} ago</time>
</li>

Part 5: 실시간 업데이트#

통계 자동 갱신#

Stat이 저장되면 자동 브로드캐스트가 dashboard.stat 채널에, Activity가 저장되면 dashboard.activity 채널에 알린다. 알릴 모델은 senders에 적는다. 적지 않은 모델은 저장돼도 아무에게도 알리지 않고, mutation()도 불리지 않는다. 오류도 경고도 없다.

settings.py:

from wireview import AutoBroadcast

WIREVIEW = {
    "AUTO_BROADCAST": AutoBroadcast(
        model=True,
        senders={("dashboard", "Stat"), ("dashboard", "Activity")},
    ),
}

Todo 앱을 같은 프로젝트에서 따라 했다면 AUTO_BROADCAST는 하나만 두고 senders를 합친다. 카드는 이름으로 구분되므로 모델 채널을 구독하고 mutation()에서 자기 통계만 고른다.

class XStatCard(Component):
    class Meta:
        template_name = 'dashboard/stat_card.html'
        subscriptions = {"dashboard.stat"}

    stat_name: str
    stat: AsyncResult[Stat] | None = None

    async def mutation(self, channel: str, action: ModelAction, instance):
        """통계 업데이트 수신"""
        if instance.name == self.stat_name:
            # 로딩 없이 직접 업데이트
            self.stat = AsyncResult.success(instance)

수동 브로드캐스트#

모델 저장 없이 값이 바뀌는 경우(외부 집계 등)에는 직접 알린다. broadcast()/abroadcast()가 보낸 알림은 mutation()이 아니라 notification()으로 온다. 인자는 채널 레이어를 지나므로 모델 인스턴스가 아니라 JSON 직렬화 가능한 값을 넘긴다. async 코드에서는 abroadcast(), 동기 코드에서는 broadcast()를 쓴다.

from wireview import abroadcast


async def refresh_stat(name: str):
    await abroadcast("dashboard-stats", name=name)
class XStatCard(Component):
    class Meta:
        template_name = 'dashboard/stat_card.html'
        subscriptions = {"dashboard.stat", "dashboard-stats"}

    # ...

    async def notification(self, channel: str, **kwargs):
        if kwargs.get("name") == self.stat_name:
            await self.refresh()

Part 6: 완성된 코드#

전체 live.py#

from wireview import Component, AsyncResult, WireviewMeta, ModelAction
from .models import Stat, Activity
import asyncio


class XDashboard(Component):
    """대시보드 메인 컴포넌트"""

    class Meta:
        template_name = 'dashboard/dashboard.html'

    active_tab: str = "overview"
    stats: list[Stat] = []

    @classmethod
    def new(cls, wire: WireviewMeta, **kwargs):
        kwargs.setdefault("active_tab", wire.params.get("tab", "overview"))
        return cls(wire=wire, **kwargs)

    async def joined(self):
        self.stats = [s async for s in Stat.objects.all()]

    async def set_tab(self, tab: str):
        self.active_tab = tab
        self.wire.params["tab"] = tab


class XStatCard(Component):
    """통계 카드 컴포넌트"""

    class Meta:
        template_name = 'dashboard/stat_card.html'
        subscriptions = {"dashboard.stat"}

    stat_name: str
    stat: AsyncResult[Stat] | None = None

    async def joined(self):
        self.stat = await self.assign_async(self._load_stat())

    async def _load_stat(self):
        await asyncio.sleep(0.3)  # 로딩 시뮬레이션
        return await Stat.objects.aget(name=self.stat_name)

    async def refresh(self):
        self.stat = await self.assign_async(self._load_stat())

    async def mutation(self, channel: str, action: ModelAction, instance):
        if instance.name == self.stat_name:
            self.stat = AsyncResult.success(instance)


class XActivityFeed(Component):
    """활동 피드 컴포넌트"""

    class Meta:
        template_name = 'dashboard/activity_feed.html'
        subscriptions = {"dashboard.activity"}

    oldest_id: int | None = None
    has_more: bool = True

    async def joined(self):
        activities = await self._load_activities()
        await self.stream("activities", activities)
        self._advance(activities)

    async def _load_activities(self, before_id: int | None = None, limit: int = 20):
        qs = Activity.objects.order_by('-id')
        if before_id is not None:
            qs = qs.filter(id__lt=before_id)
        return [a async for a in qs[:limit]]

    def _advance(self, activities: list, limit: int = 20):
        if activities:
            self.oldest_id = activities[-1].id
        self.has_more = len(activities) >= limit

    async def load_more(self):
        if not self.has_more:
            return

        new_activities = await self._load_activities(before_id=self.oldest_id)
        for activity in new_activities:
            await self.stream_insert("activities", activity, at=-1)
        self._advance(new_activities)

    async def mutation(self, channel: str, action: ModelAction, instance):
        if action == ModelAction.CREATED:
            await self.stream_insert("activities", instance, at=0)

CSS 예제#

.dashboard {
  padding: 2rem;
  max-width: 1200px;
  margin: 0 auto;
}

.tabs {
  display: flex;
  gap: 1rem;
  margin-bottom: 2rem;
}

.tabs button {
  padding: 0.5rem 1rem;
  border: none;
  background: #f0f0f0;
  cursor: pointer;
  border-radius: 4px;
}

.tabs button.active {
  background: #007bff;
  color: white;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 1.5rem;
}

.stat-card {
  background: white;
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  min-height: 150px;
}

.stat-card .loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100px;
}

.spinner {
  width: 30px;
  height: 30px;
  border: 3px solid #f0f0f0;
  border-top-color: #007bff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.stat-value {
  font-size: 2.5rem;
  font-weight: bold;
}

.stat-change {
  font-size: 0.9rem;
}

.stat-change.positive { color: green; }
.stat-change.negative { color: red; }

.activity-list {
  list-style: none;
  padding: 0;
}

.activity-item {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1rem;
  border-bottom: 1px solid #eee;
}

.activity-icon {
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f0f0f0;
  border-radius: 50%;
}

.activity-content {
  flex: 1;
}

.activity-time {
  color: #999;
  font-size: 0.9rem;
}

.load-more {
  text-align: center;
  padding: 1rem;
}

.load-more button.wireview-click-loading {
  opacity: 0.7;
}

연습 문제#

  1. 차트 컴포넌트: Chart.js를 통합해 통계 시각화
  2. 날짜 필터: 활동 피드에 날짜 범위 필터 추가
  3. 검색: 활동 피드 검색 기능
  4. 알림: 새 활동 발생 시 브라우저 알림

다음 단계#

Dashboard를 통해 AsyncResult, 복합 컴포넌트, Streams + 페이지네이션을 학습했습니다.

다음 튜토리얼에서는 사용자마다 따로 받는 알림 센터를 만들며 사용자별 채널과 broadcast를 배웁니다.