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를 준비한 다음, 실시간 투표 화면을 만들어줘.

무엇이 포함되어 있나요?#
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_*→WIREVIEWdict - 템플릿 태그:
{% load reactor %}→{% load wireview %}
목차#
- django-reactor 대비 개선 사항
- 설치 및 설정
- 빠른 시작
- 예제
- 컴포넌트 라이프사이클
- 이벤트 바인딩
- URL 상태 관리
- 모델 구독
- Streams API
- Presence API
- 파일 업로드
- AsyncResult
- JS 명령 빌더
- JavaScript Hooks
- 컴포넌트 API 레퍼런스
- 템플릿 태그 레퍼런스
- JavaScript API
- 테스트
- 디버그 도구
- 설정
설치 및 설정#
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, # 개발 환경에서만
}
자세한 내용은 성능 가이드를 참조하세요.
문서#
- 아키텍처 - 내부 설계 및 패턴
- 배포 가이드 - 프로덕션 배포 설정
- 성능 가이드 - 성능 최적화 팁
- 튜토리얼 - 단계별 가이드
- 로드맵 - 향후 개발 계획
- 업그레이드 가이드 - 0.x, 1.0 릴리스 후보, 1.0에서 1.1로. 쓰던 버전별로 읽을 절과 보안 조치
- 호환성 정책 - 공개 API, 폐기 절차, 지원 범위
개발 및 기여#
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를 참조하세요.