# LiveComponent - 중첩 컴포넌트

> 동작하는 전체 코드: [examples/livecomp/](https://github.com/itda-work/django-wireview/tree/v1.1.0/examples/livecomp) — `make test`가 함께 돌리고, 릴리스 게이트(CI)가 태그마다 다시 돌리는 예제다.

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

## 학습 목표

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

## 전제 조건

- [Counter 컴포넌트](/wireview/tutorial/counter-component/) 완료
- [Dashboard](/wireview/tutorial/dashboard/) 기본 지식

---

## Part 1: LiveComponent란?

### Component vs LiveComponent

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

### 언제 사용하나요?

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

**일반 Component 사용**:
- 페이지 레벨 컴포넌트
- 단일 인스턴스만 필요한 경우
- 완전히 독립적인 기능

---

## Part 2: 기본 사용법

### 2.1 LiveComponent 정의

`widgets/live.py`:

```python
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`:

```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`:

```python
from wireview import Component


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

    title: str = "My Dashboard"
```

`templates/dashboard/dashboard.html`:

```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`:

```python
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`:

```python
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를 믿지 않는다"](/wireview/tutorial/notifications/#브라우저가-보낸-id를-믿지-않는다)와 같은 원칙이다).

### 3.2 부모 → 자식 (send_update)

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

```python
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`:

```python
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`:

```python
from wireview import AutoBroadcast

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

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

`myapp/live.py`:

```python
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`:

```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`:

```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 스타일

```css
.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

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

### 5.2 동적 ID 생성

```html
{% 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() 콜백 활용

```python
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`가 부르는 부모 핸들러는 브라우저도 부를 수 있다. 인자를 믿지 않는다

---

## 다음 단계

- [docs/features/live-component.md](/wireview/reference/live-component/) - 상세 레퍼런스
- [Streams API 심화](/wireview/tutorial/streams-api/) - 여기서부터는 앞에서 쓴 API를 하나씩 깊이 다룹니다

---

## 완성 코드

전체 예제는 [examples/livecomp/](https://github.com/itda-work/django-wireview/tree/v1.1.0/examples/livecomp)에 있습니다.

---

[← 이전: Notifications](/wireview/tutorial/notifications/) | [목차](/wireview/tutorial/) | [다음: Streams API 심화 →](/wireview/tutorial/streams-api/)
