LiveComponent - 중첩 컴포넌트
동작하는 전체 코드: examples/livecomp/ —
make test가 함께 돌리고, 릴리스 게이트(CI)가 태그마다 다시 돌리는 예제다.
이 튜토리얼에서는 LiveComponent를 사용하여 독립적인 상태를 가진 중첩 컴포넌트를 만드는 방법을 학습합니다.
학습 목표#
- LiveComponent vs Component 차이점 이해
- 부모-자식 컴포넌트 통신
myself=True이벤트 타겟팅- 실용적인 대시보드 예제
전제 조건#
- Counter 컴포넌트 완료
- Dashboard 기본 지식
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}")
요약#
핵심 포인트#
- LiveComponent 정의:
LiveComponent상속 - 템플릿 헤더:
{% live_tag_header %}사용 - 이벤트 타겟팅: 생략해도 가장 가까운 컴포넌트, 곧 이 LiveComponent가 대상이다.
myself=True는 슬롯 등 어디에 놓이든 대상을 고정한다 - 부모에서 사용:
{% live_component "Name" id="unique-id" %} - 자식→부모 통신:
await self.send_to_parent("event", **kwargs) - 부모→자식 통신:
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가 부르는 부모 핸들러는 브라우저도 부를 수 있다. 인자를 믿지 않는다
다음 단계#
- docs/features/live-component.md - 상세 레퍼런스
- Streams API 심화 - 여기서부터는 앞에서 쓴 API를 하나씩 깊이 다룹니다
완성 코드#
전체 예제는 examples/livecomp/에 있습니다.