Wireview - Django를 위한 Phoenix LiveView
⚠️ 실험적 프로젝트입니다 (Experimental). itda-work 가 실험 목적으로 공개하는 라이브러리입니다. API·동작·지원 범위가 예고 없이 바뀔 수 있으니, 운영 환경에 쓸 때는 버전을 고정하고 CHANGELOG 를 확인하세요.
Wireview는 화면의 상태를 서버의 Django 컴포넌트에 두고, 브라우저의 이벤트를 WebSocket으로 받아 다시 렌더한 HTML의 바뀐 부분만 내려보내는 Django 라이브러리입니다. Phoenix LiveView와 같은 방식이라 프런트엔드를 위한 API 계층을 따로 두지 않습니다.
코딩 에이전트로 바로 시작하려면 AI 에이전트로 시작하기로 갑니다.
{% load wireview %}
<div {% tag_header %}>
<h1>Hello, {{ name }}!</h1>
<input type="text" name="name" value="{{ name }}" {% on "input.debounce.300" "change_name" %}>
</div>
from wireview import Component
class XHello(Component):
class Meta:
template_name = "hello/hello.html"
name: str = "World"
async def change_name(self, name: str):
self.name = name
입력이 300ms 멈추면 change_name이 서버에서 돌고, 다시 렌더한 HTML의 바뀐 부분만 diff로 내려와 <h1>의 인사말이 바뀝니다. 스타터 템플릿이 만드는 첫 컴포넌트 그대로입니다.
이럴 때 쓰세요
Django로 백엔드를 만들고 있고 프런트엔드 전담이 없는 팀에 맞습니다. 사내 도구와 관리 화면을 만드는 Django 팀, Node와 번들러 없이 실시간 화면을 들이고 싶은 Python 조직, 코딩 에이전트와 혼자 제품을 만드는 개발자가 그렇습니다. 화면이 서버의 데이터에서 나오고 그 데이터를 이미 Django로 다루는 앱이면, 기존 프로젝트에는 뷰·템플릿을 그대로 두고 화면 하나씩 들입니다(이미 있는 프로젝트에 붙이기).
그런 팀이 자주 만드는 화면은 이렇습니다.
- Django 팀이 만드는 사내 도구·관리 화면·대시보드. 핸들러가 모델과 로그인 사용자를 바로 쓰고, 그 사이에 API 계층이 없습니다. 느린 집계는
assign_async로 미루고, 페이지의 인증 경계는 live_session이 지킵니다. 보기: Dashboard. - 입력하는 동안 서버 검증이 보이는 업무 폼. 핸들러에서 Django 폼의
is_valid()를 그대로 돌리고,wire-feedback-for가 건드린 칸의 오류만 보여 줍니다. 보기: Django 폼과 함께 쓰기. - 여러 사람이 같은 데이터를 보며 고치는 화면(알림, 진행 상황, 채팅, 투표, 접속자).
broadcast()와 모델 알림(AUTO_BROADCAST)이 열린 화면을 다시 그리고, Presence가 접속자를, Streams가 쌓이는 목록을 맡습니다. 보기: Chat, Poll, Notifications. - 검색·필터·탭이 주소에 남는 목록.
self.wire.params가 쿼리를 고치고, 같은 경로로 가는push_to는 연결을 유지한 채params_changed()만 부릅니다. 보기: Live Search의 검색어를 주소에 남기기, Dashboard의 탭 네비게이션. - 검색엔진과 JavaScript 없는 브라우저에도 보여야 하는 페이지. 첫 응답이 주소의 쿼리까지 반영해 끝까지 그린 HTML이고(
?q=가 있는 검색 결과도),action을 적은 폼은 JavaScript가 없으면 그 뷰로 갑니다. 보기: JavaScript 없는 첫 렌더. - 진행률과 검증이 필요한 파일 업로드. 확장자·크기·파일 시그니처를 검사하고 진행률을 컴포넌트가 그립니다. 청크 엔드포인트는 상태가 없어 워커가 여럿이어도 됩니다(청크 업로드). 보기: File Uploads.
이럴 땐 쓰지 마세요
모든 이벤트는 서버 왕복을 한 번 거칩니다. 로딩 클래스, wire-disabled-with, 즉시 실행되는 JS() 명령, debounce가 그 시간을 가리지만(Optimistic UI) 없애지는 못합니다.
- 오프라인에서도 돌아야 하는 앱. 끊긴 동안의 이벤트는 버립니다. 대신 로컬 저장소를 쓰는 PWA나 SPA.
- 매 프레임 반응해야 하는 상호작용(드래그, 캔버스, 게임). 대신 그 부분만 JavaScript 훅으로 브라우저에서 처리하고 결과만 서버로 보냅니다.
- 왕복 지연(RTT)이 큰 망의 사용자가 주 대상인 앱. 클릭마다의 체감이 RTT에 묶입니다. 대신 SPA.
- 서버리스·scale-to-zero 호스팅. 연결마다 WebSocket과 상태를 들고 있는 프로세스가 있어야 합니다. 대신 일반 Django 뷰(필요하면 htmx).
- WebSocket이 막힌 망(일부 기업 프록시·VPN). HTTP 폴백은 만들지 않기로 했습니다(결정 기록). 대신 htmx 같은 일반 HTTP.
- 클라이언트 상태가 본체인 앱(협업 편집기, 리치 에디터, 스프레드시트). 훅이 앱 전체가 됩니다. 대신 SPA와 CRDT(Yjs 등).
- 읽기만 하는 대량 구독 페이지(실시간 스코어 등). 브로드캐스트 하나가 구독한 연결 수만큼 다시 렌더됩니다. 대신 SSE와 CDN, 조작하는 부분만 wireview. 모두가 같은 화면이면
Meta.shared_render가, 목록에 항목을 넣거나 빼기만 하면Broadcast가 그 렌더를 한 번으로 줄입니다. - 다른 사이트에 심는 위젯. 소켓은 Origin이
ALLOWED_HOSTS에 있는 페이지에서만 열립니다. 대신 독립 JS 위젯.
외부 공개 API가 필요하면 DRF나 Django Ninja를 같은 모델·인증 위에 함께 둡니다. SPA 자산이 이미 큰 조직이라면 기술이 아니라 도입 비용의 문제입니다. 흔한 반론에 대한 답은 왜 wireview인가에 있습니다.
다른 선택지와 비교
같은 문제를 푸는 도구들과 구조를 비교합니다. 성능은 재지 않은 것을 적지 않습니다. wireview의 수치는 숫자에 있고 비교군은 FastAPI입니다.
| 전송 | 서버가 먼저 보내기 | 상태가 있는 곳 | 재연결·배포 | 브라우저 없는 테스트 | |
|---|---|---|---|---|---|
| wireview (Python, Django) | 상시 WebSocket | 내장. broadcast(), 모델 알림, Presence, Streams |
페이지의 서명된 data-state |
어느 워커에나 다시 join. sticky session 불필요 | mount(), manage.py check |
| Phoenix LiveView (Elixir) | 상시 WebSocket | 내장. PubSub, Presence, Streams | 서버 프로세스의 메모리 | 재연결 때 mount가 다시 돈다 |
LiveViewTest |
| Laravel Livewire (PHP) | 상호작용마다 HTTP 요청 | 따로. Echo와 Reverb | 응답에 실린 체크섬 스냅샷 | 연결이 없어 문제가 없다 | Livewire::test() |
| Rails Hotwire (Ruby) | HTTP(Turbo)와 WebSocket(Action Cable) | Turbo Streams 방송 | 컴포넌트 상태 없음. HTML 조각 | 페이지 단위 | 조각은 컨트롤러 테스트, 동작은 시스템 테스트 |
| htmx, django-unicorn | 상호작용마다 HTTP 요청 | 확장이나 따로 | 없음, 또는 응답에 실린 상태 | 연결이 없어 문제가 없다 | Django 테스트 클라이언트 |
| Vaadin Flow (Java) | HTTP, 푸시는 WebSocket | 내장. @Push |
서버 세션의 UI 객체 트리 | sticky session 필요 | UI unit 테스트 |
| Spring REST + SPA (Java) | HTTP, 필요하면 WebSocket | 직접 만든다. STOMP 등 | 클라이언트 | 무상태 API | API와 프런트를 따로 |
가장 가까운 것은 Phoenix LiveView입니다. wireview는 그 모델을 Django로 옮긴 것이라 기능표 147행 중 116행이 같습니다. 설계상 제외한 둘(중첩 LiveView의 프로세스 격리, long polling 폴백) 말고도, LiveView 1.2와 견주어 아직 없는 기능 15행과 일부만 있는 기능 14행이 GAP 번호와 이슈로 남아 있습니다 — JS 명령·훅·폼·업로드의 옵션, 테스트 헬퍼, 포털 같은 것들입니다(기능 갭). 고르는 기준은 언어와 팀입니다. 이미 Django의 모델·폼·admin·인증 위에 있으면 wireview, Elixir 팀이거나 BEAM의 프로세스 격리와 연결당 메모리가 필요하면 LiveView입니다.
Livewire, django-unicorn, htmx는 상호작용마다 HTTP 요청을 보냅니다. WebSocket 인프라 없이 어느 호스팅에서나 돌고 scale-to-zero가 됩니다. 대신 서버가 먼저 보내는 것(알림, 진행률, 접속자, 다른 사람의 변경)은 Livewire가 Echo와 Reverb를, htmx가 SSE·WebSocket 확장을 따로 붙여야 합니다. 화면의 핵심이 그것이면 wireview, 폼과 목록이 전부면 그쪽이 가볍습니다.
Hotwire는 HTML 조각을 바꾸는 모델이지 컴포넌트 모델이 아닙니다. 서버에 화면의 상태가 없고 동작은 Stimulus가 브라우저에서 맡습니다. wireview는 상태를 가진 컴포넌트가 이벤트를 핸들러 하나로 받습니다.
Spring에는 서버 주도 UI가 코어에 없습니다. 보통 REST + SPA이고, 가장 가까운 것은 Vaadin Flow입니다. Vaadin은 화면을 Java 객체 트리로 짜고 상태를 서버 세션에 두며, wireview는 Django 템플릿(HTML)과 페이지의 서명 상태입니다. 어느 쪽이든 선택은 언어가 정합니다. Python·Django 조직이 아니면 wireview를 고를 이유가 없습니다.
"우리 상황이면 어느 쪽인가"라는 물음의 답은 다른 프레임워크와 견줄 때에 있습니다.
숫자
같은 작은 화면(값 하나, 항목 50개 피드, 다른 사용자에게 가는 알림)을 wireview와 FastAPI로 한 번씩 만들어 같은 기계, 같은 서버(uvicorn 1프로세스), 같은 브라우저에서 쟀습니다. FastAPI 쪽 클라이언트는 React와 프레임워크 없는 손 JS 둘입니다.
클릭이 화면에 그려지기까지는 셋 다 한 프레임 안팎입니다. wireview 11.8 ms, FastAPI + React 10.5 ms, 손 JS 10.7 ms이고, 실제 사용자는 여기에 네트워크 왕복(RTT)이 더해집니다.
서버의 일은 wireview가 훨씬 많습니다. 메시지 하나에 0.85 ms(FastAPI 0.03 ms)가 들고, 화면을 바꾸는 브로드캐스트는 연결마다 다시 렌더하므로 연결 1,000개에 434 ms(FastAPI 20.7 ms)가 걸립니다. 모두에게 같은 항목을 넣을 때는 Broadcast가 한 번만 렌더해 보내므로 18.8 ms(FastAPI 12.0 ms)입니다.
첫 화면은 서버가 끝까지 그린 HTML이라 먼저 보입니다. 첫 페인트(FCP)가 44.0 ms(React 64.0 ms, 손 JS 60.0 ms)이고, 내려받는 HTML과 JS는 gzip으로 25.9 KB(React 68.5 KB, 손 JS 1.1 KB)입니다.
상호작용 하나의 WebSocket 전송량은 wireview가 더 큽니다. 값 하나를 바꾸면 보내고 받는 것이 201 B(FastAPI 48 B)입니다. wireview의 메시지는 컴포넌트 id, 핸들러 이름, diff 구조를 함께 싣고, FastAPI 쪽은 바뀐 값만 싣습니다.
Apple M5 Pro macOS, localhost, DB 없이 5회차의 중앙값입니다. 방법과 공정성 규칙, 원본 결과, 전체 표는 성능 가이드에 있습니다.
설치
pip install django-wireview daphne
새 프로젝트는 스타터 템플릿으로 시작하고, 이미 있는 프로젝트에는 설정 몇 곳을 손으로 더합니다. 둘 다 설치 및 설정에 있습니다.
AI 에이전트로 시작하기
AI가 코드를 써 줘도 그 코드를 고치고 책임지는 건 사람입니다. wireview는 프런트와 백엔드 사이의 API 계약을 없애 사람이 지켜야 할 코드를 Django 한 벌로 줄이고, AI가 쓴 결과를 브라우저 없이 mount() 테스트와 manage.py check로 바로 확인하게 합니다.
에이전트와 잘 맞는 이유는 네 가지이고 모두 구조에서 옵니다. 에이전트가 wireview를 다른 스택보다 잘 짠다는 실측은 아직 없습니다(생태계가 작다).
- 한 언어, 한 저장소. 화면 하나가 Django 템플릿 하나와 Python 클래스 하나입니다. 에이전트가 두 코드베이스의 API 계약을 맞출 일이 없고, 둘이 어긋나는 종류의 버그가 생길 자리가 없습니다.
- 브라우저 없이 스스로 확인합니다.
mount()로 핸들러를 부르고 렌더된 HTML을 보고,manage.py check가 조용히 실패하는 실수를 경고로 알리고,wireview_check_templates가 템플릿을 진단합니다. 짜고 확인하고 고치는 고리가 Python 안에서 닫혀, 에이전트가 사람이 브라우저를 열어 주기를 기다리지 않습니다. - 함정이 스킬에 들어 있습니다. 패키지에 실린 스킬이 async가 아닌 핸들러, 이름 충돌, 빠진
{% tag_header %}처럼 아무 신호 없이 실패하는 실수를 코드를 쓰기 전에 알립니다. - 잘못된 값이 조용히 지나가지 않습니다. 상태 필드는 Pydantic 타입이고 핸들러 인자는 타입 표기대로 검증됩니다. 에이전트의 실수가 화면이 아니라 오류로 드러납니다.
Claude Code 같은 코딩 에이전트에게 아래 프롬프트를 줍니다. 새 프로젝트에서도 이미 있는 Django 프로젝트에서도 같은 프롬프트를 쓰고, 끝부분만 만들고 싶은 것으로 바꿉니다.
https://itda.work/wireview/llms.txt 를 참고해 django-wireview를 설치하고, 실시간 투표 화면을 만들어줘.
llms.txt는 설치 명령, 프로젝트 준비 순서, 문서 페이지 목록을 담은 텍스트 파일입니다. 그 순서에 있는 python manage.py wireview_agent_setup은 패키지에 실린 앱 개발자용 스킬을 프로젝트의 .claude/skills/wireview/에 설치합니다. 스킬은 언제 무엇을 읽을지와 조용히 실패하는 함정을 담고, 설치한 뒤에는 네트워크 없이 읽힙니다. manage.py check는 그런 함정 24가지를 wireview.W* 경고로 알립니다(체크 목록).
무엇이 포함되어 있나요?
wireview는 검증된 Django·Channels 위의 얇은 층입니다. 템플릿·폼·ORM·인증·세션은 Django의 것이고, WebSocket과 채널 레이어는 Channels의 것입니다. 그 근거는 이렇습니다.
- 실험적 프로젝트라 다음 릴리스부터 PyPI 분류를
Development Status :: 3 - Alpha로 둡니다(1.2.0까지는5 - Production/Stable이었습니다). - CI가 Python 3.13·3.14와 Django 5.2·6.0·6.1의 여섯 조합, 그리고 Python 3.15와 Django 6.1을 돌립니다(지원 범위).
- 1.0부터 공개 API를 마이너·패치 릴리스에서 깨지 않고, 없앨 때는 경고를 거친 뒤 다음 메이저에서 없앱니다(호환성 정책).
make test가 5,000개가 넘는 테스트를 돌리고, 릴리스 태그는 이것과 NATS·Redis 위의 브라우저 E2E를 모두 통과해야 PyPI에 올라갑니다.
Node와 프런트엔드 빌드는 필요 없습니다. 다만 복잡도가 사라지는 것이 아니라 운영 쪽으로 옮겨 옵니다. ASGI 서버(daphne·uvicorn), WebSocket을 통과시키는 프록시, 프로세스가 둘 이상이면 채널 레이어의 브로커(NATS나 Redis)가 필요합니다(배포 가이드).
Django의 ORM과 인증도 그대로 쓰지만 규칙이 붙습니다. 핸들러와 라이프사이클 메서드는 이벤트 루프 위에서 도는 async 함수라, aget() 같은 async ORM API를 쓰거나 sync_to_async로 감쌉니다. 사용자와 세션은 WebSocket을 연결할 때 읽은 것이고, Django의 HTTP 미들웨어는 페이지 요청에만 돌고 WebSocket 이벤트에는 돌지 않습니다. 로그아웃하면 live_session 경계 안의 열린 소켓이 닫히지만, 로그아웃을 거치지 않는 권한 변경(is_staff를 떼는 것 등)은 다음 연결에서야 반영됩니다. 이벤트마다 확인할 권한은 핸들러에서 확인합니다.
주요 기능:
- 실시간 업데이트가 가능한 서버 사이드 렌더링 컴포넌트
- 자동 검증이 포함된 Pydantic 기반 상태 관리
- Django Channels를 통한 WebSocket 통신
- 효율적인 대역폭 사용을 위한 HTML diff
- 자동 UI 업데이트를 위한 모델 구독
- 대규모 리스트를 효율적으로 처리하는 Streams API
- 온라인 사용자 및 타이핑 표시를 위한 Presence 추적
- 진행률 추적이 가능한 파일 업로드
- Chart.js, Mapbox 등 서드파티 라이브러리 통합을 위한 JavaScript Hooks
목차
- 설치 및 설정
- 빠른 시작
- 예제
- 컴포넌트 라이프사이클
- 이벤트 바인딩
- URL 상태 관리
- 모델 구독
- Streams API
- Presence API
- 파일 업로드
- AsyncResult
- JS 명령 빌더
- JavaScript Hooks
- 컴포넌트 API 레퍼런스
- 템플릿 태그 레퍼런스
- JavaScript API
- 테스트
- 디버그 도구
- 설정
- django-reactor 대비 개선 사항
설치 및 설정
Wireview는 Python ≥3.13과 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 --reload-include '*.html'로 띄워도 됩니다(Windows에서는 이쪽입니다 — docs/DEPLOYMENT.md). uvicorn은 기본으로 *.py만 감시해서, --reload-include가 없으면 템플릿을 고쳐도 옛 내용이 그려집니다. 그 옵션은 watchfiles가 있어야 들으므로 uvicorn[standard]로 설치합니다. 그때는 아래 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>
예제
동작하는 예제 앱 14개가 examples/에 있습니다. 각 디렉터리가 개념 하나이고,
테스트와 README를 함께 가지고 있으며, make test가 함께 실행하고 릴리스 게이트(CI)가 태그마다 다시 실행합니다.
| 예제 | 개념 | 화면 |
|---|---|---|
| todo | 모델 구독으로 여러 탭이 같은 목록을 함께 본다 | |
| poll | 쓰기는 핸들러가, 다시 그리기는 브로드캐스트가 | |
| rating | 잠깐 쓰는 상태와 남는 상태를 갈라 둔다 | |
| search | 디바운스한 입력, 주소에 남는 검색어, 키보드로 고르는 결과 | |
| quiz | 컴포넌트 상태로 굴리는 상태 머신 | |
| chat | Streams와 Presence | |
| chat_lv2 | chat 2단계: 누가 말하는가를 서버가 정한다 (로그인 사용자, 방 멤버십, live_session) |
|
| chat_lv3 | chat 3단계: 스트림은 붙이기만 하는 목록이 아니다 (위로 스크롤해 과거 불러오기, 내 메시지 수정·삭제) | |
| chat_lv4 | chat 4단계: 한 사람에게만 가는 소식 (방 목록의 안 읽은 메시지 배지) | |
| dashboard | AsyncResult로 느린 조회를 미룬다 | |
| notifications | 알림은 한 사용자에게 간다. 저장하는 알림과 저장하지 않는 토스트 | |
| livecomp | 연결을 공유하는 중첩 컴포넌트 | |
| slots | 내용을 호출자가 채우는 레이아웃 컴포넌트 | |
| hooks | 브라우저만 할 수 있는 일을 컴포넌트에 붙인다 |
저장소를 받은 뒤 처음이면 아래 순서대로 한 줄씩 실행합니다. 미리 설치할 것은 uv와 git이고
(Node는 uv가 가상환경에 넣습니다), 명령은 Windows PowerShell·명령 프롬프트와 macOS·Linux 터미널에서 같습니다.
차례로 의존성, JS 빌드 도구, wireview.min.js(빌드 산출물이라 저장소에 없습니다), 예제 모델의 테이블(빠뜨리면
chat 시리즈·poll·rating·quiz 페이지가 500입니다), 그리고 http://localhost:8000 서버입니다. Windows에서 uv·git을 설치하는 법은
예제의 시작하기에, ARM Windows에서
첫 uv sync만 x64 Python으로 바꾸는 법은
Windows ARM에 있습니다.
uv sync --all-extras
uv run npm ci
uv run npm run build
uv run --directory tests python manage.py migrate
uv run --directory tests daphne testproj.asgi:application
make가 있으면 make install(앞의 두 줄), make build-js, make migrate, make run-daphne로 줄여 쓸 수 있습니다.
컴포넌트 라이프사이클
초기화 및 렌더링
컴포넌트는 템플릿에 포함될 때 초기화됩니다:
{% 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(name, items, at=-1, reset=False) |
화면의 것을 두고 여러 아이템을 at에 주어진 순서대로 삽입 (연산·프레임 하나, 1.6) |
stream_insert(name, item, at=-1) |
아이템 삽입 (-1=끝, 0=처음, n=인덱스) |
stream_insert(name, item, update_only=True) |
화면에 있는 아이템만 제자리에서 교체, 없으면 넣지 않음 (1.6) |
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")
모든 구독자에게 한 번에: Broadcast
새 항목을 같은 목록을 연 모든 페이지에 넣을 때는 Broadcast를 쓴다. 발행하는 곳에서 항목을 한 번 렌더하고,
받는 페이지마다 컴포넌트 id만 끼워 쓴다. 받는 쪽에서는 코드가 돌지 않으므로 항목 템플릿은 item만 읽을 수 있다.
from wireview import Broadcast
class MessageList(Component):
class Meta:
template_name = "chat/message_list.html"
subscriptions = {"chat"}
async def add_message(self, text: str):
message = await Message.objects.acreate(sender=self.user, text=text)
await Broadcast(MessageList, "chat").stream_insert("messages", message, at=-1).asend()
연결 1,000개에 항목 하나가 닿는 데 알림을 받아 연결마다 stream_insert()하면 276.3742.6 ms, 19.7 ms였다(프로세스 1개, InMemory·Redis·NATS). 규칙과 수치는 Broadcast에 있다.Broadcast는
18.0
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초 후 자동 해제
프로듀서는 id를 주어 그린다 — 탭의 Presence 항목이 그 id로 이름 지어진다. id 없이 그리면 그 태그가
TemplateSyntaxError로 실패한다. 렌더가 불러올 템플릿에서 클래스 이름을 따옴표로 적은 태그는 manage.py check의
wireview.W023이 미리 알린다(범위):
{% component 'ChatInput' id="composer" room_id=room.id username=request.user.username %}
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_users_in(topic) |
토픽 하나의 사용자 목록 (트래커가 토픽 여럿을 구독할 때) |
같은 사용자가 탭을 여럿 열면 탭마다 기록하고 목록에는 한 번만 나온다. 마지막 탭이 떠날 때 빠지고, 한 탭이라도
입력 중이면 입력 중이다. 다른 사람의 목록에 오르게 하는 것은 프로듀서(ChatInput)다 — 입장해 있는 동안 나중에
들어온 트래커의 sync 요청에 답한다. 트래커만 있는 페이지는 다른 사람의 목록에 오르지 않는다
(탭이 여럿인 사용자).
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", "saved-once").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를 비우면 명령이 붙은 엘리먼트 자신이 대상이다. 선택자는 맞는 엘리먼트 전부에 적용된다(focus·focus_first는
첫 엘리먼트). 명령이 바꾼 클래스·속성·표시 상태는 그 엘리먼트를 다시 그리는 렌더 뒤에도 남는다 — LiveView처럼
(JS 명령). * 뒤의 인자는 키워드로만 넘긴다.
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) |
shared_render |
False |
렌더가 보는 사람과 무관하다는 선언. 같은 브로드캐스트를 받은 연결들이 렌더를 한 번만 하고 함께 쓴다. 틀리면 남의 화면이 간다 (shared-render) |
메서드와 필드
오버라이드하는 콜백, 부르는 메서드, 필드의 전체 목록과 시그니처는 Component API에
있다. 거기 없는 멤버는 밑줄이 없어도 내부다. 내비게이션은 self.wire의 redirect_to·replace_to·push_to,
모듈 수준 브로드캐스트는 broadcast(channel, **kwargs)(sync)·abroadcast(channel, **kwargs)(async)다.
템플릿 태그 레퍼런스
{% load wireview %}
| 태그 | 설명 |
|---|---|
{% wireview_header %} |
필요한 JavaScript 포함 (약 93KB, gzip 약 30KB) |
{% 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, # 개발 환경에서만
}
자세한 내용은 성능 가이드를 참조하세요.
문서
- 왜 wireview인가 - 서버 렌더링에 대한 반론과 그 답
- 아키텍처 - 내부 설계 및 패턴
- 배포 가이드 - 프로덕션 배포 설정
- 성능 가이드 - 성능 최적화 팁
- 튜토리얼 - 단계별 가이드
- 로드맵 - 향후 개발 계획
- 업그레이드 가이드 - 0.x, 1.0 릴리스 후보, 1.0에서 1.1로, 1.1에서 1.2로, 1.2에서 1.3으로, 1.3에서 1.4로, 1.4에서 1.5로, 1.5에서 1.6으로. 쓰던 버전별로 읽을 절과 보안 조치
- 호환성 정책 - 공개 API, 폐기 절차, 지원 범위
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.13 |
| 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 %}
개발 및 기여
git clone https://github.com/itda-work/django-wireview.git
cd django-wireview
uv sync --all-extras
uv run npm ci
uv run npm run build
uv run pytest tests examples -m "not e2e and not slow"
uv run npm run build는 clone 직후와 wireview.js를 고친 뒤에 다시 합니다. 테스트 서버는 예제와 같은 서버입니다:
uv run --directory tests python manage.py migrate
uv run --directory tests daphne testproj.asgi:application
make가 있으면 같은 일을 make install, make build-js, make test, make migrate, make run-daphne로 줄여 씁니다.
나머지 명령은 make help에 있습니다.
라이선스
MIT 라이선스 - 자세한 내용은 LICENSE를 참조하세요.
Metadata
Release files for django-wireview 1.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_wireview-1.6.0.tar.gz | 724.4 kB | With provenance | Details for django_wireview-1.6.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_wireview-1.6.0-py3-none-any.whl | Python 3 | none | any | Details for django_wireview-1.6.0-py3-none-any.whl |
Total release size: 1.4 MB
Release files / django_wireview-1.6.0.tar.gz
| Download URL | django_wireview-1.6.0.tar.gz |
|---|---|
| Size | 724.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8e728b529e11188c687265077b5ac184b3f8e206d3324da17d70f94b4f2902a3
|
|
BLAKE2b-256 checksum How to use checksums |
38359964b5e3a656a10caa2721b4e91f3465c4eb2398e2a8a26f8e62dfddcc54
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.
Transparency logRelease files / django_wireview-1.6.0-py3-none-any.whl
| Download URL | django_wireview-1.6.0-py3-none-any.whl |
|---|---|
| Size | 671.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
01ba708a5a24796c01457e9814d15e8173a656f25b047738221d4512b875ecec
|
|
BLAKE2b-256 checksum How to use checksums |
ad1facc77077152c998f05861ed9f17764ca178a65243f395f0443cfb4488c4c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.
Transparency logSecurity for django-wireview 1.6.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for django_wireview-1.6.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.