LiveComponent

독립적인 상태를 가진 중첩 가능한 컴포넌트


개요#

LiveComponent는 부모 Component 내에서 렌더링되면서 자체 상태를 유지하는 컴포넌트입니다. Phoenix LiveView의 LiveComponent에서 영감을 받았습니다.

사용 사례:

  • 재사용 가능한 상태 컴포넌트 (Counter, Toggle, Modal)
  • 부분 업데이트가 필요한 복잡한 UI
  • 부모-자식 간 상태 분리
from wireview import LiveComponent

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

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

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

기본 사용법#

1. LiveComponent 정의#

# myapp/components.py
from wireview import LiveComponent

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

    count: int = 0

    async def increment(self, amount: int = 1):
        self.count += amount

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

2. LiveComponent 템플릿#

<!-- templates/myapp/counter.html -->
{% load wireview %}
<div {% live_tag_header %}>
    <span>{{ count }}</span>
    <button {% on "click" "decrement" myself=True %}>-</button>
    <button {% on "click" "increment" myself=True %}>+</button>
</div>

myself=True는 이 LiveComponent를 이벤트 대상으로 명시합니다. 생략하면 클라이언트가 가장 가까운 wireview-component 요소, 즉 이 LiveComponent 자신을 대상으로 삼으므로 위 예제에서는 결과가 같습니다. 명시해 두면 버튼이 슬롯 등으로 다른 컴포넌트 안에 놓여도 대상이 바뀌지 않습니다.

3. 부모 템플릿에서 사용#

<!-- templates/myapp/dashboard.html -->
{% load wireview %}
<div {% tag_header %}>
    <h1>Dashboard</h1>

    {% live_component "Counter" id="counter-1" count=10 %}
    {% live_component "Counter" id="counter-2" count=20 %}
</div>

id는 필수입니다. 각 LiveComponent는 고유한 ID를 가져야 합니다.

count=10 같은 나머지 인자는 부모가 넘기는 props입니다. 처음 만들 때 초기값이 되고, 그 뒤로는 부모가 재렌더할 때 직전에 넘긴 값과 달라진 것만 update()로 전달됩니다. 부모가 같은 값을 계속 넘기는 동안 자식이 스스로 바꾼 상태는 그대로 남습니다.


수명주기#

LiveComponent는 부모가 소유합니다. 부모 템플릿이 이름을 붙이는 동안 살고, 부모가 더는 그리지 않으면 사라집니다. 클라이언트가 자식을 따로 join하지 않으므로 초기화 경로는 하나입니다.

시점 호출 비고
부모 렌더에 처음 등장 joined() 인스턴스당 한 번. 자식의 첫 HTML은 이 뒤에 렌더된다. 여기서 보낸 스트림·push_js·push_event는 자식 요소가 패치된 뒤 적용된다
부모가 다른 props를 넘김 update(**changed) 값이 달라진 props만. send_update()는 항상 호출
부모가 더는 그리지 않음 leaving() 서버가 제거한다. 조건부로 사라진 자식은 상태를 잃는다. 클라이언트는 이 자식에 leave를 보내지 않는다(#140)
부모가 화면에서 사라짐 (leave) leaving() 부모에서 자식으로 cascade. 이동한 페이지에서 같은 id가 루트 컴포넌트여도 된다 — 클라이언트는 떠난 것의 leave를 새 것의 join보다 먼저 보낸다(#146)
연결 종료 leaving() Component와 같다

leaving()은 joined()와 짝입니다. joined()가 돈 인스턴스만 leaving()을 받습니다. 부모의 패스가 만들었지만 joined() 전에 사라진 자식 — 같은 id의 페이지로 boost 이동할 때 루트의 join 렌더가 그린 자식을, 바로 뒤의 부모 join이 새 인스턴스로 바꾸는 경우가 그렇다 — 은 leaving() 없이 지워집니다(비동기 작업은 취소됩니다). joined()에서 등록하고 leaving()에서 해제하는 코드(Presence, 카운터)는 짝이 맞지 않는 해제를 하지 않습니다.

부모가 {% component %}로 그려진 일반 컴포넌트이고 그것을 그린 컴포넌트가 넘긴 prop에 따라 자식을 숨기거나 보이거나 다른 props를 넘길 때도 같습니다. 그 변화는 그린 쪽의 렌더 안에서 드러나므로, 자식의 leaving()·joined()·update()와 렌더도 그린 쪽의 render 메시지에 실립니다. 부모를 페이지가 아직 join하지 않았으면 그 join이 정리합니다. boost 이동으로 같은 id의 두 컴포넌트를 새로 받은 페이지도 둘을 다시 join하므로, 그린 쪽의 첫 렌더는 부모의 join에 맡깁니다.

라이브 렌더에서 부모 diff에는 자식의 HTML이 아니라 참조 {"c": "counter-1"}만 들어갑니다. 자식의 diff는 부모의 render 메시지 안 children으로 함께 오고, 클라이언트가 부모 HTML을 만들 때 참조 자리에 자식의 현재 HTML을 넣습니다. 그래서 부모가 재렌더돼도 자식 마크업은 다시 전송되지 않고, 자식만 바뀌면 자식 diff만 갑니다 (html-diff, 설계는 live-component-ownership).

joined()가 쌓아 둔 작업(스트림, push_js, push_event)은 부모의 render 메시지 바로 뒤에 나가고, 그 뒤에 그 자식의 joined 메시지가 갑니다. 클라이언트는 렌더를 다음 애니메이션 프레임에 패치하므로, 아직 요소가 없는 자식의 작업은 그 프레임까지 기다렸다가 자식 순서대로 적용합니다. 자식의 wire-viewport-*는 자식의 것이라 자식의 핸들러를 부르고, 판단은 그 joined가 온 뒤, 곧 joined()가 보낸 첫 페이지가 화면에 들어온 뒤에 시작합니다 — 루트 컴포넌트와 같은 규칙입니다(#112).

HTTP 최초 응답은 dead render입니다. 자식이 인라인으로 그려지고 joined()는 호출되지 않습니다. WebSocket이 붙으면 새 인스턴스가 만들어지고 joined()가 한 번 돕니다. Component와 같은 계약입니다.


@myself 타겟팅#

myself=True는 이벤트 대상을 이 LiveComponent로 고정합니다.

<!-- 대상을 명시 -->
<button {% on "click" "save" myself=True %}>Save</button>

<!-- 생략: 가장 가까운 wireview-component 요소가 대상. LiveComponent 안이면 곧 자신 -->
<button {% on "click" "save" %}>Save</button>

동작 방식#

myself 타겟
True 현재 LiveComponent (어디에 놓이든)
False / 없음 버튼을 감싸는 가장 가까운 wireview-component 요소

부모에게 알릴 일은 핸들러 안에서 send_to_parent()로 합니다. myself를 생략하는 것은 부모 통신 수단이 아닙니다.


부모-자식 통신#

부모 → 자식 (send_update)#

부모 Component에서 자식 LiveComponent의 상태를 업데이트합니다. await는 세션에 메시지를 넣는 데서 끝나며, 자식의 update()와 렌더는 현재 핸들러가 끝난 뒤 처리됩니다. 호출 직후 자식 상태를 읽어도 아직 바뀌지 않았을 수 있습니다.

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

    async def reset_all(self):
        """모든 카운터를 0으로 리셋."""
        await self.send_update("counter-1", count=0)
        await self.send_update("counter-2", count=0)

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

자식 → 부모 (send_to_parent)#

LiveComponent에서 부모에게 이벤트를 전송합니다.

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

    count: int = 0

    async def increment(self):
        self.count += 1
        # 부모에게 변경 알림
        await self.send_to_parent("counter_changed", counter_id=self.id, count=self.count)

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

    total: int = 0

    async def counter_changed(self, counter_id: str, count: int):
        """자식 카운터 변경 시 호출됨."""
        # 총합 재계산 등의 로직
        self.total = await self._calculate_total()

update() 콜백#

부모가 재렌더하면서 다른 props를 넘기거나 send_update()를 호출하면 LiveComponent의 update() 콜백이 호출됩니다. 템플릿 경로에서는 값이 달라진 props만, send_update()에서는 넘긴 assigns 전부가 전달됩니다.

class Counter(LiveComponent):
    count: int = 0
    label: str = "Count"

    async def update(self, **assigns):
        """props가 변경될 때 호출됨."""
        old_count = self.count

        # 기본 동작: assigns를 상태에 반영
        await super().update(**assigns)

        # 커스텀 로직
        if self.count != old_count:
            await self._on_count_changed()

    async def _on_count_changed(self):
        """count 변경 시 추가 로직. `_`로 시작하므로 클라이언트가 부를 수 없다."""
        pass

update()는 부모가 호출하는 콜백이지 이벤트 핸들러가 아닙니다. 오버라이드해도 클라이언트가 {% on %}으로 직접 부를 수 없습니다. send_to_parent()도 마찬가지로 서버에서만 호출합니다 — 클라이언트가 부를 수 있으면 자식이 보낸 것처럼 위장한 부모 이벤트를 만들 수 있기 때문입니다.

이벤트 핸들러로 노출되는 것은 _로 시작하지 않으면서 사용자 코드에서 정의한 이름 뿐입니다. wireview 패키지와 Pydantic이 소유한 이름(joined, leaving, update, update_many, send_to_parent, handle_async, mutation, notification, params_changed, on_upload_complete, model_post_init, model_dump ...)은 서브클래스에서 오버라이드해도 노출되지 않습니다. 부모에 알릴 일이 있으면 사용자 핸들러 안에서 send_to_parent()를 호출하세요.

mount는 프레임워크 이름이 아닙니다. wireview에는 mount 라이프사이클이 없어서 async def mount(self)를 쓰면 프레임워크는 부르지 않고, 클라이언트가 부를 수 있는 이벤트 핸들러가 하나 생깁니다. 초기화는 필드 기본값, new(), joined()에서 합니다.


update_many(): 같은 클래스 자식을 한 번에#

목록의 행마다 LiveComponent가 있고 각자 update()에서 자기 데이터를 조회하면, 부모가 한 번 렌더할 때 조회가 행 수만큼 돈다. update_many()를 오버라이드하면 부모 렌더 한 번에 값이 바뀐 같은 클래스 자식 전부를 한 호출로 받으므로 한 번의 조회로 채울 수 있다. Phoenix의 update_many/1이다.

class Row(LiveComponent):
    class Meta:
        template_name = "rows/row.html"

    item_id: int
    title: str = ""

    @classmethod
    async def update_many(cls, updates):
        await super().update_many(updates)   # 각자의 update()로 새 값을 반영한다
        ids = [component.item_id for component, _assigns in updates]
        titles = {pk: title async for pk, title in Item.objects.filter(pk__in=ids).values_list("pk", "title")}
        for component, _assigns in updates:
            component.title = titles.get(component.item_id, "")
  • updates는 (component, assigns)의 목록이다. assigns는 update()가 받았을 바뀐 값만이다.
  • 기본 구현은 각자의 update()를 부른다. 오버라이드하고 super().update_many()를 부르지 않으면 값 반영도 직접 한다.
  • 오버라이드하지 않은 클래스는 지금처럼 자식마다 update()가 불리고, 한 자식이 예외를 던져도 나머지는 갱신된다. 오버라이드한 update_many()가 던지면 그 클래스의 호출 전체가 로그에 남는다.
  • 새로 생긴 자식은 update_many()가 아니라 joined()를 받는다. 첫 렌더의 N+1은 부모가 미리 불러 props로 넘겨 피한다.
  • send_update()로 한 자식에게 보낸 값은 그 자식의 update()로 간다.

템플릿 태그#

{% live_component %}#

LiveComponent를 렌더링합니다.

{% load wireview %}
{% live_component "Counter" id="my-counter" count=10 label="My Counter" %}
파라미터 필수 설명
이름 ✅ LiveComponent 클래스 이름
id ✅ 고유 식별자
기타 ❌ 부모가 넘기는 props. 처음엔 초기값, 이후엔 달라진 것만 update()로

{% live_component_block %}#

슬롯을 전달하는 블록 형태입니다. {% fill %}과 {% render_slot %}, let: 바인딩은 Slots와 같습니다.

{% live_component_block "Modal" id="m1" title="설정" %}
    {% fill header %}<h2>{{ page_title }}</h2>{% endfill %}
    본문은 기본 슬롯이 됩니다.
{% endlive_component %}
  • let: 없는 fill과 기본 슬롯은 부모의 컨텍스트에서 부모 렌더 때 렌더되고, 그 텍스트가 자식에게 전달됩니다. 부모가 재렌더해 이 텍스트가 바뀌면 자식도 다시 렌더됩니다. 바뀌지 않으면 자식은 건드리지 않습니다. props와 같은 규칙입니다.
  • let: fill은 자식이 렌더될 때 {% render_slot %}이 넘긴 값으로 렌더됩니다. 자식의 컨텍스트에서 렌더되므로 부모 변수는 보이지 않습니다.
  • 자식이 자기 이벤트로 재렌더돼도 슬롯 내용은 유지됩니다. 슬롯 안에 둔 {% live_component %}도 유지됩니다. 그 LiveComponent의 부모는 슬롯을 채운 컴포넌트이고, 자식의 렌더는 그 자리를 가리킬 뿐 수명주기 메서드를 부르지 않습니다. 자식이 슬롯을 숨겼다 다시 보여도 같은 인스턴스가 그 상태로 돌아옵니다. {% component_block %}의 슬롯도 같고, 슬롯 안의 {% component %}가 지금 상태로 그려지는 것, 다시 join한 컴포넌트가 슬롯을 잃지 않는 것은 슬롯의 문제 해결에 있습니다.
  • Meta.slots에 required: True로 선언한 슬롯이 빠지면 TemplateSyntaxError입니다.

{% live_tag_header %}#

LiveComponent의 루트 엘리먼트에 필요한 속성을 생성합니다.

<div {% live_tag_header %}>
    <!-- LiveComponent 내용 -->
</div>

생성되는 속성:

  • id: 컴포넌트 ID
  • data-name: 컴포넌트 이름
  • data-state: 서명된 상태. 재연결 때 부모의 join에 실려 자식의 상태를 복원한다
  • data-is-live: live 여부
  • data-parent: 부모 컴포넌트 ID
  • wireview-component, wireview-live: 불리언 속성(CSS 클래스가 아니다). wireview-live가 있는 요소는 클라이언트가 따로 join하지 않는다

Component vs LiveComponent#

한 페이지의 모든 컴포넌트는 WebSocket 연결 하나를 공유합니다. 차이는 연결이 아니라 누가 수명을 쥐는가입니다.

특성 Component LiveComponent
수명 클라이언트가 join·leave 부모가 그리는 동안
상태 독립 독립. 부모가 넘기는 props는 부모가 진실
템플릿 안 중첩 가능 ({% component %}) 가능 ({% live_component %})
초기화 자기 join 뒤 joined() 부모 렌더 뒤 joined(), 부모의 render 메시지에 함께
부모 통신 없음 send_to_parent(), send_update()

선택 가이드#

  • Component: 페이지 최상위, 또는 부모와 무관하게 스스로 join·leave해야 하는 조각
  • LiveComponent: 부모가 만들고 props를 넘기는 재사용 가능한 상태 컴포넌트

예제#

Toggle 컴포넌트#

class Toggle(LiveComponent):
    class Meta:
        template_name = "components/toggle.html"

    is_on: bool = False
    label: str = ""

    async def toggle(self):
        self.is_on = not self.is_on
        await self.send_to_parent("toggled", toggle_id=self.id, is_on=self.is_on)
<!-- toggle.html -->
{% load wireview %}
<label {% live_tag_header %} class="toggle">
    <input type="checkbox"
           {% if is_on %}checked{% endif %}
           {% on "change" "toggle" myself=True %}>
    <span>{{ label }}</span>
</label>

모달 컴포넌트#

class Modal(LiveComponent):
    class Meta:
        template_name = "components/modal.html"

    is_open: bool = False
    title: str = ""

    async def open(self):
        self.is_open = True

    async def close(self):
        self.is_open = False
        await self.send_to_parent("modal_closed", modal_id=self.id)
<!-- modal.html -->
{% load wireview %}
<div {% live_tag_header %} class="modal {% if is_open %}is-open{% endif %}">
    <div class="modal-backdrop" {% on "click" "close" myself=True %}></div>
    <div class="modal-content">
        <header>
            <h2>{{ title }}</h2>
            <button {% on "click" "close" myself=True %}>&times;</button>
        </header>
        <div class="modal-body">
            {% render_slot %}
        </div>
    </div>
</div>
<!-- 부모 템플릿: 본문을 기본 슬롯으로 넘긴다 -->
{% live_component_block "Modal" id="settings-modal" title="설정" %}
    <p>{{ this.settings_help }}</p>
{% endlive_component %}

제한사항#

  1. ID 필수: 모든 LiveComponent는 고유한 id가 필요합니다. 같은 id를 다른 클래스가 쓰면 이전 인스턴스는 leaving() 뒤 교체됩니다.
  2. 중첩 깊이: LiveComponent 안의 LiveComponent도 같은 절차로 초기화·렌더됩니다(손자식은 부모의 render 메시지에 평면으로 함께 옵니다). 깊이는 8까지이며, 그보다 깊으면 로그를 남기고 더 그리지 않습니다.
  3. 슬롯: 심플 태그 {% live_component %}는 슬롯을 전달하지 않습니다. 슬롯이 필요하면 {% live_component_block %}을 씁니다.

참고#


마지막 업데이트: 2026-09-09