Wireview - Django를 위한 Phoenix LiveView

Wireview는 Django Channels를 사용하여 실시간 서버 렌더링 인터랙티브 UI를 구축할 수 있게 해주는 라이브러리입니다. Phoenix Framework의 LiveView와 유사합니다.

AI 에이전트로 시작하기#

Claude Code 같은 코딩 에이전트에게 아래 프롬프트를 주면, 에이전트가 llms.txt에서 문서를 찾아 설치하고, 앱 개발자용 스킬을 넘겨받아 작업을 이어 갑니다. 새 프로젝트에서도 이미 있는 Django 프로젝트에서도 같은 프롬프트를 쓰고, 끝부분만 만들고 싶은 것으로 바꿉니다.

https://itda.work/wireview/llms.txt 를 읽고 그 안내대로 django-wireview를 준비한 다음, 실시간 투표 화면을 만들어줘.

Wireview 아키텍처 개요

무엇이 포함되어 있나요?#

VueJS나 ReactJS를 대체하는 것은 아니지만, Django의 모든 잠재력을 활용하여 인터랙티브한 프론트엔드를 만들 수 있습니다. 모든 것이 서버 사이드에서 렌더링되므로, 첫 번째 요청에서 의미 있는 정보가 포함된 인터페이스가 제공됩니다. Django 템플릿과 ORM의 모든 기능을 컴포넌트에서 직접 사용하고, 이벤트 구독을 통해 실시간으로 인터페이스를 업데이트할 수 있습니다.

주요 기능:

  • 실시간 업데이트가 가능한 서버 사이드 렌더링 컴포넌트
  • 자동 검증이 포함된 Pydantic 기반 상태 관리
  • Django Channels를 통한 WebSocket 통신
  • 효율적인 대역폭 사용을 위한 HTML diff
  • 자동 UI 업데이트를 위한 모델 구독
  • 대규모 리스트를 효율적으로 처리하는 Streams API
  • 온라인 사용자 및 타이핑 표시를 위한 Presence 추적
  • 진행률 추적이 가능한 파일 업로드
  • Chart.js, Mapbox 등 서드파티 라이브러리 통합을 위한 JavaScript Hooks

django-reactor 대비 개선 사항#

Wireview는 django-reactor의 현대적인 진화 버전으로, 다음과 같은 중요한 개선 사항이 있습니다:

새로운 기능#

기능 reactor wireview 설명
Streams API - ✅ stream(), stream_insert(), stream_delete()로 메모리 효율적인 대규모 리스트 처리
Presence API - ✅ PresenceMixin, PresenceTrackerMixin으로 실시간 사용자 추적 및 타이핑 표시
파일 업로드 - ✅ 진행률 추적, 매직 바이트 검증이 포함된 청크 업로드
AsyncResult - ✅ 비동기 작업을 위한 로딩/성공/에러 상태 관리
JS 명령 - ✅ JS() 빌더로 Phoenix LiveView.JS 스타일의 클라이언트 사이드 명령
테스트 유틸리티 - ✅ WebSocket 없이 쉽게 컴포넌트 테스트를 위한 mount() 유틸리티
디버그 도구 - ✅ wireview.debug로 브라우저 콘솔 디버깅
JavaScript Hooks - ✅ Chart.js, Mapbox 등 서드파티 JavaScript 라이브러리 통합
live_session - ✅ 페이지 단위 인증 경계. 같은 술어가 뷰와 join 양쪽에서 돌고, 경계를 넘는 이동은 전체 페이지 로드가 된다 (문서)

아키텍처 개선#

항목 reactor wireview
Pydantic v1 (레거시) v2 (최신)
DOM Morphing morphdom idiomorph (더 나은 속성 보존)
Python ≥3.9 ≥3.12
Django 3.2+ 5.2, 6.0, 6.1
모듈 구조 플랫 체계적 (core/, features/)

새로운 컴포넌트 메서드#

# 라이프사이클
async def leaving(self):
    """컴포넌트 연결 해제 시 호출 - 정리 훅"""

# UI 제어
await self.scroll_into_view(element_id, behavior="smooth")
await self.push_js(JS().set_value("input", ""))

# Streams
await self.stream("items", items)
await self.stream_insert("items", item, at=0)
await self.stream_delete("items", item_id)

# Presence
await self.presence_join()
await self.presence_set_typing(True)

# 비동기 로딩
self.data = await self.assign_async(fetch_data())

# JavaScript Hooks
await self.push_event("update_chart", {"data": [1, 2, 3]})

reactor에서 마이그레이션#

대부분의 reactor 컴포넌트는 최소한의 변경으로 작동합니다:

# reactor
from reactor.component import Component

class XCounter(Component):
    class Meta:
        subscriptions = {"counter"}

# wireview (동일한 API)
from wireview import Component

class XCounter(Component):
    class Meta:
        subscriptions = {"counter"}

주요 차이점:

  • 패키지 이름: reactor → wireview
  • 설정 접두사: REACTOR_* → WIREVIEW dict
  • 템플릿 태그: {% load reactor %} → {% load wireview %}

목차#

설치 및 설정#

Wireview는 Python ≥3.12과 Django ≥5.2가 필요합니다 (Django 5.2 LTS, 6.0, 6.1 지원. 범위는 호환성 정책).

pip install django-wireview daphne

새 프로젝트라면 아래 설정을 옮겨 적는 대신 스타터 템플릿으로 시작할 수 있습니다. 이 절의 배선이 모두 들어간 프로젝트와 시작하기 튜토리얼의 첫 컴포넌트가 생기고, manage.py check는 아무것도 보고하지 않습니다. DEBUG에서는 Django가 뜰 때마다 wireview가 컴포넌트 모듈 옆에 타입 스텁(hello/live.pyi)을 만드는데, 스타터의 .gitignore가 그것과 db.sqlite3를 커밋에서 뺍니다.

macOS·Linux(bash, zsh):

django-admin startproject mysite --template "$(python -c "import wireview, os; print(os.path.join(os.path.dirname(wireview.__file__), 'project_template'))")"

Windows PowerShell:

django-admin startproject mysite --template (python -c "import wireview, os; print(os.path.join(os.path.dirname(wireview.__file__), 'project_template'))")

Windows 명령 프롬프트(cmd.exe)에는 명령 치환이 없으므로 두 단계로 합니다. 첫 줄이 출력한 경로를 --template 뒤에 붙여 넣습니다.

python -c "import wireview, os; print(os.path.join(os.path.dirname(wireview.__file__), 'project_template'))"
django-admin startproject mysite --template C:\...\wireview\project_template

daphne는 개발 서버용입니다. Django의 runserver는 WSGI 서버라 WebSocket을 받지 못하고, daphne 앱이 INSTALLED_APPS 맨 위에 있을 때에만 ASGI로 바뀝니다. 빠뜨려도 오류는 나지 않고 페이지가 반응 없이 남습니다(runserver 기동 로그의 wireview.W013 경고가 유일한 신호입니다). daphne 대신 uvicorn project_name.asgi:application --reload로 띄워도 됩니다(Windows에서는 이쪽입니다 — docs/DEPLOYMENT.md). 그때는 아래 asgi.py의 ASGIStaticFilesHandler 줄이 필요합니다. uvicorn은 정적 파일을 서빙하지 않아서, 빠뜨리면 wireview.min.js가 404이고 페이지는 그려지지만 어떤 컴포넌트도 살아나지 않습니다.

Wireview는 django-channels를 사용하고, 채널 레이어가 반드시 있어야 합니다. Channels에는 기본 레이어가 없어서 CHANNEL_LAYERS를 비워 두면 WebSocket 연결이 전부 거절됩니다(manage.py check의 wireview.W012). 개발과 단일 프로세스에는 아래 설정의 InMemory 레이어면 충분합니다. 다만 InMemory는 프로세스 하나 안에서만 통하므로, 프로세스를 여러 개 띄우면 브로드캐스트가 오류 없이 같은 프로세스의 연결에만 닿습니다. 프로덕션에서는 프로세스를 잇는 레이어를 씁니다.

  • channels-nats — 이 프로젝트가 목표로 하는 레이어입니다. NATS 서버는 Go 바이너리 하나이고 Linux·macOS·Windows 네이티브 빌드가 있어, Redis 없이 SQLite 단일 서버와 Windows까지 같은 구성으로 갑니다.
  • channels_redis — Redis가 이미 있다면 이쪽입니다. 실측상 성능은 대등합니다.

배포 구성은 docs/DEPLOYMENT.md, 두 레이어의 실측 비교는 docs/design/transport-abstraction.md §5-3에 있습니다.

Django 애플리케이션보다 먼저 wireview와 channels를 INSTALLED_APPS에 추가하세요:

INSTALLED_APPS = [
    'daphne',      # 맨 위. runserver가 WebSocket을 받게 합니다
    'wireview',
    'channels',
    ...
]

ASGI_APPLICATION = 'project_name.asgi.application'

# 개발·단일 프로세스용. 프로세스를 늘릴 때는 위의 channels-nats나 channels_redis로 바꿉니다.
CHANNEL_LAYERS = {
    'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'},
}

project_name/asgi.py를 수정하세요:

import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project_name.settings')

import django
django.setup()

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.conf import settings
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
from django.core.asgi import get_asgi_application
from wireview.urls import websocket_urlpatterns

http = get_asgi_application()
if settings.DEBUG:
    # runserver는 정적 파일을 스스로 서빙하지만 uvicorn은 이 application만 서빙한다
    http = ASGIStaticFilesHandler(http)

application = ProtocolTypeRouter({
    'http': http,
    'websocket': AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
})

wireview의 컨슈머는 소켓을 받기 전에 Origin 헤더를 ALLOWED_HOSTS와 대조한다. 다른 사이트의 페이지가 사용자의 쿠키로 소켓을 여는 것을 막기 위해서다(배포 가이드). 그래서 AllowedHostsOriginValidator로 따로 감쌀 필요는 없다.

프로젝트의 urls.py에 wireview의 HTTP 경로를 루트에 넣으세요. 파일 업로드가 이 경로로 청크를 보냅니다. 빠뜨려도 다른 기능은 모두 동작하고 업로드만 조용히 404가 납니다. 경로가 /__wireview_upload__/로 고정되어 있어 path("wireview/", ...)처럼 접두사 아래에 두어도 404입니다.

from django.urls import include, path

urlpatterns = [
    path("", include("wireview.urls")),
    ...
]

템플릿에 wireview JavaScript를 포함하세요:

{% load wireview %}
<!doctype html>
<html>
    <head>
        {% wireview_header %}
    </head>
    ...
</html>

빠른 시작#

x-counter.html 템플릿을 생성하세요:

{% load wireview %}
<div {% tag_header %}>
  {{ amount }}
  <button {% on 'click' 'inc' %}>+</button>
  <button {% on 'click' 'dec' %}>-</button>
  <button {% on 'click' 'set_to' amount=0 %}>reset</button>
</div>

live.py에 컴포넌트를 생성하세요:

from wireview import Component


class XCounter(Component):
    class Meta:
        template_name = 'x-counter.html'

    amount: int = 0

    async def inc(self):
        self.amount += 1

    async def dec(self):
        self.amount -= 1

    async def set_to(self, amount: int):
        self.amount = amount

뷰 템플릿에서 컴포넌트를 렌더링하세요:

{% load wireview %}
<!doctype html>
<html>
    <head>
        {% wireview_header %}
    </head>
    <body>
        {% component 'XCounter' %}
        {% component 'XCounter' amount=100 %}
    </body>
</html>

예제#

동작하는 예제 앱 11개가 examples/에 있습니다. 각 디렉터리가 개념 하나이고, 테스트와 README를 함께 가지고 있으며, make test가 함께 실행하고 릴리스 게이트(CI)가 태그마다 다시 실행합니다.

예제 개념
todo 모델 구독으로 여러 탭이 같은 목록을 함께 본다
poll 쓰기는 핸들러가, 다시 그리기는 브로드캐스트가
rating 잠깐 쓰는 상태와 남는 상태를 갈라 둔다
search 디바운스한 입력과 키보드로 고르는 결과
quiz 컴포넌트 상태로 굴리는 상태 머신
chat Streams와 Presence
dashboard AsyncResult로 느린 조회를 미룬다
notifications 이름 붙인 채널로 컴포넌트끼리 알린다
livecomp 연결을 공유하는 중첩 컴포넌트
slots 내용을 호출자가 채우는 레이아웃 컴포넌트
hooks 브라우저만 할 수 있는 일을 컴포넌트에 붙인다

저장소를 받은 뒤 처음이면 아래 순서대로 실행합니다. wireview.min.js는 빌드 산출물이라 저장소에 없고, 예제의 모델 테이블은 make migrate가 만듭니다 — 빠뜨리면 chat·poll·rating·quiz 페이지가 500입니다.

make install && npm ci   # Python·JS 의존성 (처음 한 번)
make build-js            # wireview.min.js
make migrate             # 예제 모델의 테이블
make run-daphne          # http://localhost:8000

컴포넌트 라이프사이클#

초기화 및 렌더링#

컴포넌트는 템플릿에 포함될 때 초기화됩니다:

{% component 'Component' param1=1 param2=2 %}

파라미터는 컴포넌트 인스턴스를 반환하는 Component.new()에 전달됩니다.

조인 (Joins)#

컴포넌트가 프론트엔드에 도달하면 WebSocket을 통해 백엔드에 "조인"합니다. 직렬화된 상태가 백엔드로 전송되고, 백엔드는 컴포넌트를 재구성하고 Component.joined()를 호출합니다.

class ChatRoom(Component):
    async def joined(self):
        # 컴포넌트가 WebSocket으로 연결될 때 호출됨
        await self.broadcast(f"room.{self.room_id}", action="joined", user=self.username)

퇴장 (Leaving)#

컴포넌트가 파괴되거나 WebSocket 연결이 닫히면 Component.leaving()이 호출됩니다. 정리 작업에 사용하세요:

class ChatRoom(Component):
    async def leaving(self):
        # 컴포넌트 연결이 해제될 때 호출됨
        await self.broadcast(f"room.{self.room_id}", action="left", user=self.username)

사용자 이벤트#

조인 후 컴포넌트는 {% on %} 템플릿 태그를 통해 사용자 이벤트를 받을 수 있습니다. 이벤트는 백엔드로 전송되고, 핸들러가 실행되며, 컴포넌트가 다시 렌더링됩니다.

모델 변경 알림#

컴포넌트는 모델 변경을 구독할 수 있습니다. 변경이 발생하면 Component.mutation()이 호출됩니다:

class TodoList(Component):
    class Meta:
        subscriptions = {"todo.item"}  # todo 앱의 Item 모델 변경 구독

    async def mutation(self, channel: str, action: ModelAction, instance):
        # 구독한 모델이 변경될 때 호출됨
        self.items = await self._load_items()

알림#

임의의 메시지에는 broadcast()와 notification()을 사용하세요:

# 발신자
await self.broadcast("chat.room.1", message="Hello!", sender=self.username)

# 수신자 ("chat.room.1" 구독 중)
async def notification(self, channel: str, **kwargs):
    message = kwargs.get("message")
    sender = kwargs.get("sender")

이벤트 바인딩#

기본 문법#

{% on <event.modifiers> <handler> [kwargs] %}

예제:

<button {% on "click" "increment" %}>+1</button>
<button {% on "click" "increment" amount=5 %}>+5</button>
<button {% on "click.prevent" "submit" %}>제출</button>
<input {% on "keypress.enter" "search" %}>
<input {% on "input.debounce.300" "filter" %}>

사용 가능한 수정자#

수정자 설명
prevent event.preventDefault() 호출
stop event.stopPropagation() 호출
ctrl, alt, shift, meta 수정 키 필요
debounce.<ms> 이벤트 디바운스 (예: debounce.300)
throttle.<ms> 이벤트 쓰로틀 (예: throttle.100)
enter, tab, delete, backspace, esc, space 키 별칭
up, down, left, right 화살표 키 별칭
key.<이름> 특정 키, event.key의 이름 (예: key.escape)
key_code.<n> 특정 keyCode (예: key_code.27)

이 밖의 이름(away, once, self, escape 등)과, 인자가 없거나 정수가 아닌 debounce·throttle·key_code는 {% on %}이 렌더 때 ValueError로 거절합니다. 클라이언트는 모르는 수정자를 건너뛰므로 조용히 다른 이벤트에 반응하던 것을 쓴 자리에서 실패로 바꿉니다.

{% on %}은 인라인 JavaScript가 아니라 wire-on-<이벤트>[.<수정자>…] 데이터 속성을 렌더하고, 번들이 문서 루트에서 이벤트를 위임받아 처리합니다. 그래서 'unsafe-inline' 없는 Content Security Policy와 함께 돕니다. 수정자는 왼쪽부터 적용되므로 prevent는 debounce보다 앞에 둡니다. 한 요소에 keyup.enter와 keyup.esc처럼 같은 이벤트를 여러 번 걸 수 있습니다. 자세한 것은 CSP를 보세요.

암시적 인자#

컴포넌트 내의 폼 입력은 자동으로 인자로 전송됩니다:

<div {% tag_header %}>
  <input name="query">
  <button {% on "click" "search" %}>검색</button>
</div>
async def search(self, query: str):
    self.results = await self._do_search(query)

URL 상태 관리#

URL 쿼리 문자열에 컴포넌트 상태를 저장하세요:

class SearchList(Component):
    query: str = ""

    @classmethod
    def new(cls, wire, **kwargs):
        kwargs.setdefault("query", wire.params.get("query", ""))
        return cls(wire=wire, **kwargs)

    async def filter_results(self, query: str):
        self.query = query
        self.wire.params["query"] = query  # URL 업데이트

복잡한 값에는 .json 접미사를 사용하세요:

class TreeView(Component):
    expanded: bool = False

    @classmethod
    def new(cls, wire, id: str, **kwargs):
        kwargs["expanded"] = id in wire.params.get("expanded.json", [])
        return cls(wire=wire, id=id, **kwargs)

    async def toggle_expanded(self):
        self.expanded = not self.expanded
        expanded = self.wire.params.setdefault("expanded.json", [])
        if self.expanded:
            expanded.append(self.id)
        elif self.id in expanded:
            expanded.remove(self.id)

모델 구독#

자동 UI 업데이트를 위해 Django 모델 변경을 구독하세요:

class TodoList(Component):
    class Meta:
        subscriptions = {"todo.item"}  # {app_label}.{model_name} 형식

    async def mutation(self, channel: str, action: ModelAction, instance):
        if action == ModelAction.CREATED:
            self.items.append(instance)
        elif action == ModelAction.DELETED:
            self.items = [i for i in self.items if i.id != instance.id]

설정에서 자동 브로드캐스트를 활성화하세요:

WIREVIEW = {
    "AUTO_BROADCAST": AutoBroadcast(
        model=True,      # 모델 변경 시 브로드캐스트
        model_pk=True,   # 채널 이름에 PK 포함
        senders={("todo", "Item")},  # 알릴 모델. 비우면 아무것도 알리지 않는다
    ),
}

senders에 적은 모델만 알린다. 집합으로 적으면 인스턴스의 모든 필드가 직렬화되어 채널 레이어로 간다. 민감한 필드가 있는 모델(User 등)은 매핑으로 적어 보낼 필드를 고른다. 받는 쪽 인스턴스에서 적지 않은 필드는 deferred다(설정).

senders={("todo", "Item"): "__all__", ("auth", "User"): ("username",)}  # ()는 pk만

Streams API#

Streams는 아이템을 개별적으로 렌더링하고 증분 업데이트를 전송하여 대규모 리스트를 메모리 효율적으로 처리합니다.

기본 사용법#

스트림 컨테이너가 있는 템플릿. 컨테이너는 비워 둔다 — 항목은 컴포넌트 상태가 아니라 스트림으로 들어온다:

{% load wireview %}
<div {% tag_header %}>
  <ul wire-stream="messages"></ul>
</div>

아이템 템플릿 (chat/message_list_item.html). 기본 경로는 컴포넌트의 template_name에 _item을 붙인 것이고, 항목은 item이라는 이름으로 들어온다:

<li id="messages-{{ item.pk }}">
  <strong>{{ item.sender }}:</strong> {{ item.text }}
</li>

컴포넌트:

class MessageList(Component):
    class Meta:
        template_name = "chat/message_list.html"

    async def joined(self):
        # 스트림으로 초기 로드. 항목은 컴포넌트 상태가 아니라 클라이언트에 남는다
        messages = [m async for m in Message.objects.order_by('-created')[:50]]
        await self.stream("messages", reversed(messages))

    async def add_message(self, text: str):
        message = await Message.objects.acreate(sender=self.user, text=text)
        await self.stream_insert("messages", message, at=-1)  # 끝에 추가
        await self.scroll_into_view(f"messages-{message.pk}")

    async def delete_message(self, message_id: int):
        await Message.objects.filter(id=message_id).adelete()
        await self.stream_delete("messages", message_id)

Stream 메서드#

메서드 설명
stream(name, items) 스트림 초기화/리셋
stream_insert(name, item, at=-1) 아이템 삽입 (-1=끝, 0=처음, n=인덱스)
stream_delete(name, dom_id) DOM ID 또는 PK로 아이템 삭제

DOM ID 규칙#

기본적으로 DOM ID는 {stream_name}-{item.pk} 패턴을 따릅니다. 커스텀 ID 함수:

await self.stream("items", items, dom_id=lambda item: f"item-{item.uuid}")

커스텀 아이템 템플릿#

await self.stream_insert("messages", message, template="chat/special_message.html")

Presence API#

온라인 사용자와 타이핑 표시를 실시간으로 추적합니다.

PresenceMixin (프로듀서)#

자신의 프레즌스를 브로드캐스트하는 컴포넌트용:

from wireview import Component, PresenceMixin


class ChatInput(PresenceMixin, Component):
    class Meta:
        template_name = "chat/input.html"

    room_id: int
    username: str

    def _presence_topic(self) -> str:
        return f"room.{self.room_id}"

    def _presence_user_id(self) -> str:
        return str(self.user.pk)

    def _presence_username(self) -> str:
        return self.username

    async def joined(self):
        await self.presence_join()

    async def leaving(self):
        await self.presence_leave()

    async def on_typing(self):
        await self.presence_set_typing(True)  # 3초 후 자동 해제

PresenceTrackerMixin (컨슈머)#

다른 사용자의 프레즌스를 표시하는 컴포넌트용:

from wireview import PresenceTrackerMixin


class OnlineUsers(PresenceTrackerMixin, Component):
    class Meta:
        template_name = "chat/online_users.html"

    room_id: int
    username: str

    def _presence_topic(self) -> str:
        return f"room.{self.room_id}"

    def _presence_my_user_id(self) -> str:
        return str(self.user.pk)

    def get_subscriptions(self) -> set[str]:
        return {self._presence_channel()}

    async def joined(self):
        await self.presence_track_self(username=self.username)

템플릿:

{% load wireview %}
<div {% tag_header %}>
  <h3>온라인 ({{ this.presence_online_count }})</h3>
  <ul>
    {% for user in this.presence_users %}
      <li>
        {{ user.username }}
        {% if user.is_typing %}<span class="typing">입력 중...</span>{% endif %}
      </li>
    {% endfor %}
  </ul>
</div>

Presence 속성#

속성 설명
presence_users 모든 추적된 사용자 목록
presence_online_count 온라인 사용자 수
presence_typing_users 현재 타이핑 중인 사용자 목록

Presence 설정#

from wireview import PresenceConfig

class MyComponent(PresenceMixin, Component):
    class Meta:
        presence = PresenceConfig(
            typing_timeout=3.0,     # 타이핑 자동 해제까지 초
            sync_on_join=True,      # 조인 시 다른 사용자에게 동기화 요청
            channel_prefix="presence",
        )

파일 업로드#

진행률 추적과 검증이 포함된 파일 업로드를 처리합니다.

기본 설정#

from wireview import Component


class FileUploader(Component):
    class Meta:
        template_name = "uploader.html"

    avatar_url: str = ""

    async def joined(self):
        self.allow_upload(
            "avatar",
            accept=[".jpg", ".png", ".gif"],
            max_file_size=5 * 1024 * 1024,  # 5MB
        )

    async def save_avatar(self):
        async for upload in self.consume_uploads("avatar"):
            path = await upload.save_to("avatars/", filename=f"{self.user.pk}.jpg")
            self.avatar_url = str(path)

템플릿:

{% load wireview %}
<div {% tag_header %}>
  <input type="file" wire-upload="avatar" accept=".jpg,.png,.gif">

  {% for entry in this.uploads.avatar %}
    <div class="upload-entry">
      {{ entry.client_name }} - {{ entry.progress }}%
      {% if entry.errors %}
        <span class="error">{{ entry.errors|join:", " }}</span>
      {% endif %}
    </div>
  {% endfor %}

  <button {% on "click" "save_avatar" %}>저장</button>
</div>

UploadConfig 옵션#

옵션 기본값 설명
name 필수 업로드 필드 식별자
accept [] 허용된 확장자 (예: [".jpg", ".png"])
max_entries 1 최대 동시 업로드 수
max_file_size 10MB 최대 파일 크기 (바이트)
chunk_size 64KB 업로드 청크 크기
auto_upload True 선택 시 즉시 업로드 시작

ConsumedUpload 메서드#

메서드 설명
read() 전체 파일을 메모리로 읽기
open(mode="rb") 파일 핸들 열기
save_to(directory, filename=None) Django 스토리지에 저장
name 원본 파일명
size 파일 크기 (바이트)
content_type MIME 타입

보안#

Wireview는 확장자 위조를 방지하기 위해 저장 전에 파일 시그니처(매직 바이트)를 검증합니다.

AsyncResult와 비동기 작업#

로딩/에러 상태와 함께 비동기 데이터 로딩을 처리합니다:

from wireview import Component, AsyncResult


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

    stats: AsyncResult = None

    async def joined(self):
        self.stats = await self.assign_async(self._load_stats())

    async def _load_stats(self):
        return await Stats.objects.aget()

템플릿:

{% if stats.loading %}
  <div class="spinner">로딩 중...</div>
{% elif stats.ok %}
  <div>총계: {{ stats.result.total }}</div>
{% elif stats.failed %}
  <div class="error">{{ stats.error_message }}</div>
{% endif %}

AsyncResult 속성#

속성 설명
loading 작업 진행 중이면 True
ok 작업 성공이면 True
failed 작업 실패면 True
done 완료되면 True (성공 또는 실패)
result 결과 값 (성공 시)
error 예외 (실패 시)
error_message 에러의 문자열 표현

AsyncResult 메서드#

메서드 설명
map(func) 결과 값 변환
get_or(default) 결과 또는 기본값 가져오기
get_or_raise() 결과 가져오기 또는 에러 발생

JS 명령 빌더#

서버 왕복 없이 실행되는 클라이언트 사이드 명령를 빌드합니다:

템플릿은 인자를 받는 호출을 쓸 수 없으므로, 체인은 컴포넌트의 속성이 만들고 템플릿은 그 이름을 쓴다:

from wireview import JS, Component


class Toolbar(Component):
    @property
    def toggle_modal(self) -> JS:
        return JS().toggle("#modal")

    @property
    def save_with_feedback(self) -> JS:
        # 명령 체이닝: 클래스를 바로 붙이고, 이어서 서버 핸들러를 부른다
        return JS().add_class("#btn", "loading").push("save")

    @property
    def fade_away(self) -> JS:
        # 트랜지션과 함께: (클래스, 밀리초)
        return JS().hide(transition=("fade-out", 300))

    async def save(self):
        ...
<button {% on "click" this.toggle_modal %}>모달 토글</button>
<button id="btn" {% on "click" this.save_with_feedback %}>저장</button>
<div {% on "click" this.fade_away %}></div>

서버에서 JS 푸시#

이벤트 핸들러에서 JS 명령 전송:

async def clear_input(self):
    await self.push_js(JS().set_value("input[name=search]", ""))

사용 가능한 명령#

selector를 비우면 명령이 붙은 엘리먼트 자신이 대상이다. * 뒤의 인자는 키워드로만 넘긴다. transition은 클래스 이름 문자열 "fade-out", 시간을 함께 주는 튜플 ("fade-out", 300), 또는 dict {"transition": "fade-out", "time": 300}이다. 문자열은 클래스 이름뿐이라 "fade-out 300ms"처럼 시간을 섞으면 ValueError다 — 시간은 튜플이나 dict로 준다.

표시:

  • show(selector=None, *, transition=None, display=None)
  • hide(selector=None, *, transition=None)
  • toggle(selector=None, *, show_transition=None, hide_transition=None, display=None)

CSS 클래스:

  • add_class(selector=None, classes=None, *, transition=None)
  • remove_class(selector=None, classes=None, *, transition=None)
  • toggle_class(selector=None, classes=None, *, transition=None)

속성:

  • set_attr(selector=None, attr=None, value=None)
  • remove_attr(selector=None, attr=None)
  • set_value(selector=None, value='') - 입력 값 설정

포커스:

  • focus(selector=None)
  • focus_first(selector=None, *, input_only=False)

트랜지션:

  • transition(selector=None, transition=None, *, time=None)

서버 통신:

  • push(event, *, value=None, target=None) - 서버로 이벤트 전송. value는 dict다

네비게이션:

  • navigate(url, *, replace=False)
  • dispatch(event, *, to=None, detail=None, bubbles=True)

로딩 클래스#

서버 요청 중 다음 클래스가 자동으로 추가됩니다:

클래스 설명
wireview-loading 모든 요청 중에 추가
wireview-click-loading 클릭 이벤트에 추가
wireview-submit-loading 제출 이벤트에 추가
.wireview-loading {
  opacity: 0.5;
  pointer-events: none;
}

JavaScript Hooks#

JavaScript Hooks를 사용하면 Chart.js, Mapbox, CodeMirror 등 서드파티 JavaScript 라이브러리를 wireview 컴포넌트와 통합할 수 있습니다. Phoenix LiveView의 Hooks API를 따릅니다.

Hook 정의#

훅 파일은 앱의 static/<앱 라벨>/hooks/ 아래에 둔다. {% wireview_header %}가 설치된 앱 전부에서 이 디렉터리를 찾아 defer로 싣고, wireview는 그 파일들이 다 돈 뒤에 컴포넌트를 join한다. 템플릿에 인라인 <script>로 두지 않는다 — wireview보다 먼저 실행되어 window.wireview가 아직 없고, boost 이동으로 들어간 페이지에서는 아예 실행되지 않는다. 상세는 JavaScript Hooks.

// myapp/static/myapp/hooks/chart.js
window.wireview.hooks.ChartHook = {
  mounted() {
    // 엘리먼트가 페이지에 추가되면 호출
    const config = JSON.parse(this.el.dataset.config);
    this.chart = new Chart(this.el, config);
  },

  updated() {
    // DOM 업데이트 후 호출
    this.chart.update();
  },

  destroyed() {
    // 엘리먼트가 제거되면 호출
    this.chart.destroy();
  },

  disconnected() {
    // WebSocket 연결이 끊기면 호출
    this.el.classList.add('offline');
  },

  reconnected() {
    // WebSocket이 재연결되면 호출
    this.el.classList.remove('offline');
  }
};

템플릿에서 사용#

<div wire-hook="ChartHook" data-config='{"type": "line", "data": {...}}'>
</div>

서버로 이벤트 전송 (pushEvent)#

window.wireview.hooks.InfiniteScroll = {
  mounted() {
    this.page = 1;
    this.observer = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting) {
        this.loadMore();
      }
    });
    this.observer.observe(this.el.querySelector('.sentinel'));
  },

  loadMore() {
    this.pushEvent("load_more", { page: this.page }, (response) => {
      if (response.hasMore) {
        this.page++;
      } else {
        this.observer.disconnect();
      }
    });
  },

  destroyed() {
    this.observer.disconnect();
  }
};

서버에서 이벤트 받기 (handleEvent)#

window.wireview.hooks.Notification = {
  mounted() {
    this.handleEvent("show_toast", ({ message, type }) => {
      this.showToast(message, type);
    });
  },

  showToast(message, type) {
    // 토스트 표시 구현
  }
};

서버 사이드 핸들러#

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

    async def handle_hook_event(self, hook_id: str, event: str, payload: dict):
        """JavaScript Hook에서 보낸 이벤트 처리"""
        if event == "load_more":
            items = await self._fetch_items(payload.get("page", 1))
            return {"hasMore": len(items) == 20}
        return None

    async def _update_chart(self, data: list):
        """모든 Hook에 이벤트 전송. 서버 코드가 부르는 헬퍼라 `_`로 시작한다"""
        await self.push_event("update_data", {"values": data})

Hook 라이프사이클#

콜백 호출 시점
mounted() 엘리먼트가 조인되고 첫 렌더링 후
beforeUpdate() DOM morph 전 (동기)
updated() DOM morph 완료 후
destroyed() 엘리먼트가 DOM에서 제거될 때
disconnected() WebSocket 연결이 닫힐 때
reconnected() WebSocket이 재연결될 때

Hook 컨텍스트#

속성/메서드 설명
this.el Hook이 연결된 DOM 엘리먼트
this.pushEvent(event, payload, callback) 서버로 이벤트 전송
this.handleEvent(event, callback) 서버 이벤트 핸들러 등록

자세한 내용은 JavaScript Hooks 문서를 참조하세요.

컴포넌트 API 레퍼런스#

class Meta:#

컴포넌트 설정은 클래스 안의 class Meta:에 둔다. 하위 클래스는 자기 Meta에 적지 않은 키를 부모에게서 물려받는다. 모르는 키는 TypeError다.

키 기본값 설명
template_name 필수 템플릿 경로
subscriptions set() 구독할 채널. 상태에 따라 달라지면 get_subscriptions()를 오버라이드한다
temporary_assigns set() 렌더 뒤 기본값으로 되돌릴 필드
exclude_fields user·wire·session 서명 상태에서 뺄 필드. user·wire·session은 항상 빠진다. 서명 상태는 암호화되지 않아 브라우저에서 읽힌다 — 비밀은 여기로 뺀다
slots {} 슬롯 정의
on_mount [] joined() 전에 도는 훅
live_sessions set() 마운트될 수 있는 live_session 이름
presence None PresenceMixin 설정(PresenceConfig)
sticky False boost 이동으로 같은 id가 있는 페이지에 가면 인스턴스·DOM·훅이 이어진다 (boost)

메서드와 필드#

오버라이드하는 콜백, 부르는 메서드, 필드의 전체 목록과 시그니처는 Component API에 있다. 거기 없는 멤버는 밑줄이 없어도 내부다. 내비게이션은 self.wire의 redirect_to·replace_to·push_to, 모듈 수준 브로드캐스트는 broadcast(channel, **kwargs)(sync)·abroadcast(channel, **kwargs)(async)다.

템플릿 태그 레퍼런스#

{% load wireview %}
태그 설명
{% wireview_header %} 필요한 JavaScript 포함 (약 70KB, gzip 약 22KB)
{% wireview_toasts %} toast(user, ...)로 보낸 토스트를 받아 띄운다. 레이아웃에 한 번 (플래시와 토스트)
{% component 'Name' kwarg=value %} 컴포넌트 렌더링
{% on 'event.modifiers' 'handler' kwargs %} 이벤트 핸들러 바인딩. myself와 _target은 예약 인자라 핸들러 인자 이름으로 쓸 수 없다
{% tag_header %} 루트 요소에 컴포넌트 속성 추가
{% cond {'hidden': is_hidden} %} 조건부 속성
{% class {'active': is_active} %} 조건부 CSS 클래스
{{ value|str }}, {{ a|concat:b }} 문자열로 바꾸기, 이어 붙이기 ({% on %} 인자를 만들 때)

프론트엔드 API#

// 컴포넌트에 이벤트 전송. 옵션: eventType(로딩 클래스), commit, target(LiveComponent id)
wireview.send(element, 'handler_name', {arg1: value1})
wireview.send(element, 'save', {}, {eventType: 'submit'})

// 링크처럼 이동 (BOOST_PAGES면 전체 로드 없이)
wireview.visit('/rooms/3/')

// Hook 정의
wireview.hooks.MyHook = {
  mounted() { /* ... */ },
  updated() { /* ... */ },
  destroyed() { /* ... */ }
}

// 디버그 유틸리티
wireview.debug.enable()
wireview.debug.disable()
wireview.debug.status()

컴포넌트 테스트#

WebSocket 없이 컴포넌트 테스트:

import pytest
from wireview import mount


@pytest.mark.asyncio
async def test_counter_increment():
    view = await mount(Counter, count=0)
    await view.call("increment", amount=5)
    assert view.component.count == 5


@pytest.mark.asyncio
async def test_redirect():
    view = await mount(MyComponent)
    await view.call("do_redirect", url="/dashboard")
    assert view.redirected_to == "/dashboard"
    assert view.is_frozen

테스트 API#

메서드/속성 설명
mount(ComponentClass, **kwargs) 테스트용 컴포넌트 마운트
view.component 컴포넌트 인스턴스 접근
view.call(handler, **kwargs) 이벤트 핸들러 호출
view.sent_messages 전송될 메시지들
view.redirected_to 리다이렉트 URL (있는 경우)
view.is_frozen 컴포넌트 동결 여부
view.clear_messages() 전송 메시지 초기화

디버그 도구#

// 디버그 로깅 활성화
wireview.debug.enable()

// 디버그 로깅 비활성화
wireview.debug.disable()

// 네트워크 지연 시뮬레이션
wireview.debug.latency(500)  // 500ms 지연

// 연결 상태 표시
wireview.debug.status()

// 모든 컴포넌트 나열
wireview.debug.components()

// 특정 컴포넌트 가져오기
wireview.debug.component("rx-123")

설정#

settings.WIREVIEW의 키 전부와 기본값은 설정 레퍼런스에 있다. 설정은 쓰는 시점에 읽으므로 테스트에서는 override_settings(WIREVIEW={...})로 바꾸고, 모르는 키는 manage.py check가 wireview.W014로 알려 준다.

from wireview import AutoBroadcast

WIREVIEW = {
    "BOOST_PAGES": True,
    "AUTO_BROADCAST": AutoBroadcast(model=True, model_pk=True, senders={("todo", "Item")}),
}

성능 최적화#

최적의 성능을 위해:

  • uvloop 사용: Uvicorn에서 --loop uvloop 옵션으로 더 나은 비동기 성능 달성
  • 개발 중 전환 추적: DEBUG_SYNC_TRANSITIONS=True로 중첩 async/sync 전환 감지
  • 알림은 공개 API로 보낸다: 컴포넌트 안에서는 await self.broadcast(...), 컴포넌트 밖의 async 코드에서는 await abroadcast(...), 동기 코드(시그널 수신자, 뷰)에서는 broadcast(...)(from wireview import abroadcast, broadcast). 동기 broadcast()는 트랜잭션이 커밋된 뒤에 보낸다
WIREVIEW = {
    "DEBUG_SYNC_TRANSITIONS": True,  # 개발 환경에서만
}

자세한 내용은 성능 가이드를 참조하세요.

문서#

개발 및 기여#

git clone git@github.com:itda-work/django-wireview.git
cd django-wireview
make install && npm ci
make build-js     # wireview.min.js. clone 직후와 wireview.js를 고친 뒤
make test

테스트 서버 실행(예제와 같은 서버입니다):

make migrate
make run-daphne   # http://localhost:8000

라이선스#

MIT 라이선스 - 자세한 내용은 LICENSE를 참조하세요.