LiveComponent - 중첩 컴포넌트

고급 (Advanced)예상 2시간

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

이 튜토리얼에서는 LiveComponent를 사용하여 독립적인 상태를 가진 중첩 컴포넌트를 만드는 방법을 학습합니다.

학습 목표#

  • LiveComponent vs Component 차이점 이해
  • 부모-자식 컴포넌트 통신
  • myself=True 이벤트 타겟팅
  • 실용적인 대시보드 예제

전제 조건#


Part 1: LiveComponent란?#

Component vs LiveComponent#

특성 Component LiveComponent
상태 독립적 독립적
WebSocket 페이지 연결에 직접 join join 없이 부모를 통해
렌더링 페이지 레벨 부모 내부
이벤트 타겟 가장 가까운 컴포넌트 가장 가까운 컴포넌트(자신). myself=True로 고정
용도 페이지 컴포넌트 재사용 가능 위젯

언제 사용하나요?#

LiveComponent 사용:

  • 여러 인스턴스가 필요한 재사용 가능 위젯
  • 부모와 통신이 필요한 자식 컴포넌트
  • 독립적인 상태를 가진 대시보드 카드

일반 Component 사용:

  • 페이지 레벨 컴포넌트
  • 단일 인스턴스만 필요한 경우
  • 완전히 독립적인 기능

Part 2: 기본 사용법#

2.1 LiveComponent 정의#

widgets/live.py:

from wireview import LiveComponent


class Counter(LiveComponent):
    """재사용 가능한 카운터 위젯."""

    class Meta:
        template_name = "widgets/counter.html"

    # 상태
    count: int = 0
    label: str = "Count"

    async def increment(self):
        """증가 버튼 핸들러."""
        self.count += 1

    async def decrement(self):
        """감소 버튼 핸들러."""
        self.count -= 1

2.2 템플릿 작성#

templates/widgets/counter.html:

{% load wireview %}

<div {% live_tag_header %} class="counter-widget">
  <h4>{{ label }}</h4>
  <div class="counter-controls">
    <button {% on "click" "decrement" myself=True %}>-</button>
    <span class="count">{{ count }}</span>
    <button {% on "click" "increment" myself=True %}>+</button>
  </div>
</div>

핵심 포인트:

  • {% live_tag_header %}: LiveComponent 전용 헤더 (data-parent 포함)
  • myself=True: 이벤트를 이 LiveComponent로 타겟팅

2.3 부모에서 사용#

dashboard/live.py:

from wireview import Component


class Dashboard(Component):
    class Meta:
        template_name = "dashboard/dashboard.html"

    title: str = "My Dashboard"

templates/dashboard/dashboard.html:

{% load wireview %}

<div {% tag_header %}>
  <h1>{{ title }}</h1>

  <div class="widgets">
    {% live_component "Counter" id="counter-1" label="방문자" count=100 %}
    {% live_component "Counter" id="counter-2" label="주문" count=42 %}
    {% live_component "Counter" id="counter-3" label="매출" count=1234 %}
  </div>
</div>

중요: 각 LiveComponent에는 고유한 id가 필요합니다.


Part 3: 부모-자식 통신#

3.1 자식 → 부모 (send_to_parent)#

자식이 부모에게 이벤트를 보내는 패턴입니다.

widgets/live.py:

class Counter(LiveComponent):
    class Meta:
        template_name = "widgets/counter.html"

    count: int = 0
    label: str = "Count"

    async def increment(self):
        self.count += 1
        await self._notify_parent()

    async def decrement(self):
        self.count -= 1
        await self._notify_parent()

    async def _notify_parent(self):
        # 부모에게 알림. `_`로 시작하므로 클라이언트가 부를 수 없다
        await self.send_to_parent(
            "counter_changed",
            counter_id=self.id,
            count=self.count
        )

dashboard/live.py:

class Dashboard(Component):
    class Meta:
        template_name = "dashboard/dashboard.html"

    title: str = "My Dashboard"
    # 자식이 알려 준 값. 초기값은 템플릿이 넘긴 count와 같다
    counts: dict[str, int] = {"counter-1": 100, "counter-2": 42, "counter-3": 1234}
    total: int = 1376

    async def counter_changed(self, counter_id: str, count: int):
        """자식 Counter가 변경되면 호출됨."""
        self.counts = {**self.counts, counter_id: count}
        self.total = sum(self.counts.values())

부모는 자식 인스턴스를 직접 읽지 않는다. 자식이 send_to_parent로 알려 준 값을 자기 상태로 들고 있는다. 자식의 상태는 자식 것이고, 둘 사이의 약속은 메시지뿐이다(examples/livecomp가 같은 방식이다).

send_to_parent는 부모의 같은 이름 핸들러를 브라우저 이벤트와 같은 검사를 거쳐 부른다. 그래서 받는 쪽 counter_changed는 _ 없는 핸들러여야 하고, 같은 이유로 브라우저도 이 핸들러를 임의의 인자로 부를 수 있다. 자식 쪽 _notify_parent는 _로 시작해 클라이언트가 부를 수 없지만, 부모의 counter_changed는 그렇지 않다. 여기서는 화면에 보일 합계만 바뀌므로 괜찮다. 권한이나 저장이 걸린 일이면 받은 인자를 믿지 말고 서버에서 다시 확인한다(Notifications의 "브라우저가 보낸 id를 믿지 않는다"와 같은 원칙이다).

3.2 부모 → 자식 (send_update)#

부모가 자식의 상태를 업데이트하는 패턴입니다.

class Dashboard(Component):
    class Meta:
        template_name = "dashboard/dashboard.html"

    async def reset_all(self):
        """모든 카운터를 0으로 리셋."""
        for counter_id in self.counts:
            await self.send_update(counter_id, count=0)
        # send_update는 자식의 update()만 부른다. 자식은 부모에게 다시 알리지 않으므로 합계는 부모가 맞춘다
        self.counts = dict.fromkeys(self.counts, 0)
        self.total = 0

    async def set_counter(self, counter_id: str, value: int):
        """특정 카운터 값 설정."""
        await self.send_update(counter_id, count=value)

Part 4: 실습 - 대시보드 만들기#

4.1 프로젝트 구조#

myapp/
├── live.py
├── templates/
│   └── myapp/
│       ├── dashboard.html
│       └── stat_counter.html
└── urls.py

4.2 모델#

myapp/models.py:

from django.db import models


class Stat(models.Model):
    """통계 데이터."""
    name = models.CharField(max_length=100, unique=True)
    value = models.IntegerField(default=0)

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

4.3 LiveComponent 구현#

StatCounter는 myapp.stat 채널을 구독해 다른 곳에서 바뀐 값을 받는다. 그 채널에는 자동 브로드캐스트가 senders에 적힌 모델만 알린다. 적지 않으면 mutation()은 오류도 경고도 없이 불리지 않는다.

settings.py:

from wireview import AutoBroadcast

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

다른 튜토리얼을 같은 프로젝트에서 따라 했다면 AUTO_BROADCAST는 하나만 두고 senders를 합친다.

myapp/live.py:

from wireview import Component, LiveComponent
from .models import Stat


class StatCounter(LiveComponent):
    """통계 카운터 위젯 - DB와 동기화."""

    class Meta:
        template_name = "myapp/stat_counter.html"
        subscriptions = {"myapp.stat"}  # DB 변경 구독

    stat_name: str
    value: int = 0

    async def joined(self):
        """초기 데이터 로드."""
        await self._load_stat()

    async def _load_stat(self):
        """DB에서 통계 로드."""
        try:
            stat = await Stat.objects.aget(name=self.stat_name)
            self.value = stat.value
        except Stat.DoesNotExist:
            self.value = 0

    async def increment(self, amount: int = 1):
        """값 증가 및 DB 저장."""
        stat, _ = await Stat.objects.aget_or_create(
            name=self.stat_name,
            defaults={"value": 0}
        )
        stat.value += amount
        await stat.asave()

        self.value = stat.value

        # 부모에게 알림
        await self.send_to_parent("stat_updated", name=self.stat_name, value=self.value)

    async def mutation(self, channel, action, instance):
        """다른 곳에서 DB가 변경되면 업데이트."""
        if instance.name == self.stat_name:
            self.value = instance.value


class StatsDashboard(Component):
    """통계 대시보드."""

    class Meta:
        template_name = "myapp/dashboard.html"

    stats: list[str] = ["visitors", "orders", "revenue"]
    last_updated: str = ""

    async def stat_updated(self, name: str, value: int):
        """자식에서 통계가 업데이트되면 호출."""
        from datetime import datetime
        self.last_updated = f"{name}: {value} (at {datetime.now():%H:%M:%S})"

    async def reset_all(self):
        """모든 통계 리셋."""
        for stat_name in self.stats:
            counter_id = f"stat-{stat_name}"
            await self.send_update(counter_id, value=0)

        # DB도 리셋. 인스턴스마다 asave()해야 post_save가 나가 다른 탭에도 알린다(QuerySet의 aupdate()는 보내지 않는다)
        async for stat in Stat.objects.filter(name__in=self.stats):
            stat.value = 0
            await stat.asave()

핸들러가 도는 동안 렌더는 한 번도 나가지 않는다 — 렌더는 핸들러가 끝난 뒤 한 번이다. 그래서 핸들러 앞머리에서 is_loading = True를 세워도 화면에는 보이지 않는다. 로딩 표시는 클라이언트가 맡는다. 이벤트를 보낸 요소에는 응답이 올 때까지 wireview-loading 클래스가 붙는다(아래 스타일).

4.4 템플릿#

templates/myapp/stat_counter.html:

{% load wireview %}

<div {% live_tag_header %} class="stat-card">
  <h3>{{ stat_name|title }}</h3>
  <div class="stat-value">{{ value }}</div>
  <div class="stat-actions">
    <button {% on "click" "increment" amount=-1 myself=True %}>-1</button>
    <button {% on "click" "increment" myself=True %}>+1</button>
    <button {% on "click" "increment" amount=10 myself=True %}>+10</button>
  </div>
</div>

templates/myapp/dashboard.html:

{% load wireview %}

<div {% tag_header %} class="stats-dashboard">
  <header>
    <h1>Statistics Dashboard</h1>
    <button {% on "click" "reset_all" %}>Reset All</button>
  </header>

  {% if last_updated %}
    <p class="last-updated">Last update: {{ last_updated }}</p>
  {% endif %}

  <div class="stats-grid">
    {% for stat_name in stats %}
      {% live_component "StatCounter" id="stat-"|add:stat_name stat_name=stat_name %}
    {% endfor %}
  </div>
</div>

4.5 스타일#

.stats-dashboard {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

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

.stat-card {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  transition: opacity 0.2s;
}

.stat-actions button.wireview-loading {
  opacity: 0.6;
  pointer-events: none;
}

.stat-value {
  font-size: 2.5rem;
  font-weight: bold;
  text-align: center;
  margin: 20px 0;
}

.stat-actions {
  display: flex;
  gap: 10px;
  justify-content: center;
}

.stat-actions button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  background: #007bff;
  color: white;
}

Part 5: 고급 패턴#

5.1 조건부 LiveComponent#

{% if show_advanced_stats %}
  {% live_component "AdvancedStatCounter" id="advanced-stats" %}
{% endif %}

5.2 동적 ID 생성#

{% for item in items %}
  {% live_component "ItemWidget" id="item-"|concat:item.pk item_id=item.pk %}
{% endfor %}

숫자를 붙일 때는 add가 아니라 concat을 쓴다. Django의 add는 "item-"과 정수를 더하지 못하면 빈 문자열을 돌려주므로 모든 위젯의 id가 ""로 겹친다. 문자열끼리라면("stat-"|add:stat_name) add도 된다.

5.3 update() 콜백 활용#

class Counter(LiveComponent):
    count: int = 0
    previous_count: int = 0

    async def update(self, **assigns):
        """부모가 props를 변경할 때 호출."""
        self.previous_count = self.count
        await super().update(**assigns)

        # 변경 감지
        if self.count != self.previous_count:
            await self._on_count_changed()

    async def _on_count_changed(self):
        """count가 변경되면 호출. `_`가 없으면 클라이언트가 부를 수 있는 핸들러가 된다."""
        print(f"Count changed: {self.previous_count} → {self.count}")

요약#

핵심 포인트#

  1. LiveComponent 정의: LiveComponent 상속
  2. 템플릿 헤더: {% live_tag_header %} 사용
  3. 이벤트 타겟팅: 생략해도 가장 가까운 컴포넌트, 곧 이 LiveComponent가 대상이다. myself=True는 슬롯 등 어디에 놓이든 대상을 고정한다
  4. 부모에서 사용: {% live_component "Name" id="unique-id" %}
  5. 자식→부모 통신: await self.send_to_parent("event", **kwargs)
  6. 부모→자식 통신: await self.send_update("child-id", **kwargs)

라이프사이클#

1. 부모 렌더링
2. {% live_component %} → LiveComponent 생성
3. 부모 렌더 완료
4. LiveComponent.joined() 호출
5. LiveComponent 렌더링
6. (re-render 시) LiveComponent.update(**changed_props) 호출

주의사항#

  • 각 LiveComponent에는 고유한 id 필요
  • myself를 생략해도 이벤트는 부모가 아니라 이 LiveComponent로 간다. 부모에게 알리려면 send_to_parent를 쓴다
  • send_to_parent가 부르는 부모 핸들러는 브라우저도 부를 수 있다. 인자를 믿지 않는다

다음 단계#


완성 코드#

전체 예제는 examples/livecomp/에 있습니다.