Metadata-Version: 2.5
Name: django-wireview
Version: 1.3.0
Summary: Brings LiveView from Phoenix framework into Django
Project-URL: Homepage, https://github.com/itda-work/django-wireview
Project-URL: Repository, https://github.com/itda-work/django-wireview
Project-URL: Documentation, https://itda.work/wireview/
Project-URL: Changelog, https://github.com/itda-work/django-wireview/blob/v1.3.0/CHANGELOG.md
Project-URL: Issues, https://github.com/itda-work/django-wireview/issues
Author-email: Eddy Ernesto del Valle Pino <eddy@edelvalle.me>
Maintainer-email: Chinseok Lee <dev@itda.work>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: channels<5,>=4.2.1
Requires-Dist: django>=5.2
Requires-Dist: pydantic!=2.9.0,<3,>=2.7
Provides-Extra: dev
Requires-Dist: channels-nats<0.7; extra == 'dev'
Requires-Dist: channels-redis; extra == 'dev'
Requires-Dist: cssselect; extra == 'dev'
Requires-Dist: daphne; extra == 'dev'
Requires-Dist: django-stubs; extra == 'dev'
Requires-Dist: django-stubs-ext; extra == 'dev'
Requires-Dist: djlint; extra == 'dev'
Requires-Dist: hatchling; extra == 'dev'
Requires-Dist: ipython; extra == 'dev'
Requires-Dist: lxml; extra == 'dev'
Requires-Dist: pre-commit; extra == 'dev'
Requires-Dist: psutil>=7.2.2; extra == 'dev'
Requires-Dist: pyright; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest-django>=4.5; extra == 'dev'
Requires-Dist: pytest-playwright>=0.4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Requires-Dist: uvicorn; extra == 'dev'
Requires-Dist: websockets; extra == 'dev'
Requires-Dist: whitenoise; extra == 'dev'
Description-Content-Type: text/markdown

# Wireview - Django를 위한 Phoenix LiveView

Wireview는 화면의 상태를 서버의 Django 컴포넌트에 두고, 브라우저의 이벤트를 WebSocket으로 받아 다시 렌더한 HTML의 바뀐 부분만 내려보내는 Django 라이브러리입니다. Phoenix LiveView와 같은 방식이라 프런트엔드를 위한 API 계층을 따로 두지 않습니다.

코딩 에이전트로 바로 시작하려면 [AI 에이전트로 시작하기](#ai-에이전트로-시작하기)로 갑니다.

```html
{% load wireview %}
<div {% tag_header %}>
  <h1>Hello, {{ name }}!</h1>
  <input type="text" name="name" value="{{ name }}" {% on "input.debounce.300" "change_name" %}>
</div>
```

```python
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로 다루는 앱에 맞습니다. 기존 프로젝트에는 뷰·템플릿을 그대로 두고 화면 하나씩 들입니다([이미 있는 프로젝트에 붙이기](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/01-getting-started.md#2-이미-있는-프로젝트에-붙이기)).

- **Django 팀이 만드는 사내 도구·관리 화면·대시보드.** 핸들러가 모델과 로그인 사용자를 바로 쓰고, 그 사이에 API 계층이 없습니다. 느린 집계는 [`assign_async`](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/async-operations.md)로 미루고, 페이지의 인증 경계는 [live_session](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/live-session.md)이 지킵니다. 보기: [Dashboard](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/05-dashboard.md).
- **입력하는 동안 서버 검증이 보이는 업무 폼.** 핸들러에서 Django 폼의 `is_valid()`를 그대로 돌리고, [`wire-feedback-for`](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/form-feedback.md)가 건드린 칸의 오류만 보여 줍니다. 보기: [Django 폼과 함께 쓰기](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/form-feedback.md#django-폼과-함께-쓰기).
- **여러 사람이 같은 데이터를 보며 고치는 화면**(알림, 진행 상황, 채팅, 투표, 접속자). `broadcast()`와 [모델 알림](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/settings.md#모델-알림)(`AUTO_BROADCAST`)이 열린 화면을 다시 그리고, [Presence](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/07-presence-api.md)가 접속자를, [Streams](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/06-streams-api.md)가 쌓이는 목록을 맡습니다. 보기: [Chat](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/04-chat-app.md), [Poll](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/10-poll-app.md), [Notifications](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/14-notifications.md).
- **검색·필터·탭이 주소에 남는 목록.** `self.wire.params`가 쿼리를 고치고, 같은 경로로 가는 [`push_to`](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/navigation.md)는 연결을 유지한 채 `params_changed()`만 부릅니다. 보기: Live Search의 [검색어를 주소에 남기기](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/12-live-search.md#5-검색어를-주소에-남기기), Dashboard의 [탭 네비게이션](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/05-dashboard.md#탭-네비게이션).
- **검색엔진과 JavaScript 없는 브라우저에도 보여야 하는 페이지.** 첫 응답이 주소의 쿼리까지 반영해 끝까지 그린 HTML이고(`?q=`가 있는 검색 결과도), `action`을 적은 폼은 JavaScript가 없으면 그 뷰로 갑니다. 보기: [JavaScript 없는 첫 렌더](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/dead-view.md).
- **진행률과 검증이 필요한 파일 업로드.** 확장자·크기·파일 시그니처를 검사하고 진행률을 컴포넌트가 그립니다. 청크 엔드포인트는 상태가 없어 워커가 여럿이어도 됩니다([청크 업로드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/chunked-uploads.md)). 보기: [File Uploads](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/tutorials/08-file-uploads.md).

## 이럴 땐 쓰지 마세요

모든 이벤트는 서버 왕복을 한 번 거칩니다. 로딩 클래스, `wire-disabled-with`, 즉시 실행되는 `JS()` 명령, debounce가 그 시간을 가리지만([Optimistic UI](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/optimistic-ui.md)) 없애지는 못합니다.

- **오프라인에서도 돌아야 하는 앱.** 끊긴 동안의 이벤트는 버립니다. 대신 로컬 저장소를 쓰는 PWA나 SPA.
- **매 프레임 반응해야 하는 상호작용**(드래그, 캔버스, 게임). 대신 그 부분만 [JavaScript 훅](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/hooks.md)으로 브라우저에서 처리하고 결과만 서버로 보냅니다.
- **왕복 지연(RTT)이 큰 망의 사용자가 주 대상인 앱.** 클릭마다의 체감이 RTT에 묶입니다. 대신 SPA.
- **서버리스·scale-to-zero 호스팅.** 연결마다 WebSocket과 상태를 들고 있는 프로세스가 있어야 합니다. 대신 일반 Django 뷰(필요하면 htmx).
- **WebSocket이 막힌 망**(일부 기업 프록시·VPN). HTTP 폴백은 만들지 않기로 했습니다([결정 기록](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/design/longpolling-fallback.md)). 대신 htmx 같은 일반 HTTP.
- **클라이언트 상태가 본체인 앱**(협업 편집기, 리치 에디터, 스프레드시트). 훅이 앱 전체가 됩니다. 대신 SPA와 CRDT(Yjs 등).
- **읽기만 하는 대량 구독 페이지**(실시간 스코어 등). 브로드캐스트 하나가 구독한 연결 수만큼 다시 렌더됩니다. 대신 SSE와 CDN, 조작하는 부분만 wireview. 모두가 같은 화면이면 `Meta.shared_render`가, 목록에 항목을 넣거나 빼기만 하면 `Broadcast`가 그 렌더를 한 번으로 줄입니다.
- **다른 사이트에 심는 위젯.** 소켓은 Origin이 `ALLOWED_HOSTS`에 있는 페이지에서만 열립니다. 대신 독립 JS 위젯.

외부 공개 API가 필요하면 DRF나 Django Ninja를 같은 모델·인증 위에 함께 둡니다. SPA 자산이 이미 큰 조직이라면 기술이 아니라 도입 비용의 문제입니다. 흔한 반론에 대한 답은 [왜 wireview인가](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/WHY.md)에 있습니다.

## 숫자

같은 작은 화면(값 하나, 항목 50개 피드, 다른 사용자에게 가는 알림)을 wireview와 FastAPI로 한 번씩 만들어 같은 기계, 같은 서버(uvicorn 1프로세스), 같은 브라우저에서 쟀습니다. FastAPI 쪽 클라이언트는 React와 프레임워크 없는 손 JS 둘입니다.

![클릭에서 화면까지: wireview와 FastAPI](https://raw.githubusercontent.com/itda-work/django-wireview/v1.3.0/docs/images/bench-fastapi-latency.svg)

클릭이 화면에 그려지기까지는 셋 다 한 프레임 안팎입니다. wireview 11.8 ms, FastAPI + React 10.5 ms, 손 JS 10.7 ms이고, 실제 사용자는 여기에 네트워크 왕복(RTT)이 더해집니다.

![서버가 하는 일: wireview와 FastAPI](https://raw.githubusercontent.com/itda-work/django-wireview/v1.3.0/docs/images/bench-fastapi-server.svg)

서버의 일은 wireview가 훨씬 많습니다. 메시지 하나에 0.85 ms(FastAPI 0.03 ms)가 들고, 화면을 바꾸는 브로드캐스트는 연결마다 다시 렌더하므로 연결 1,000개에 434 ms(FastAPI 20.7 ms)가 걸립니다. 모두에게 같은 항목을 넣을 때는 `Broadcast`가 한 번만 렌더해 보내므로 18.8 ms(FastAPI 12.0 ms)입니다.

![첫 화면: wireview와 FastAPI](https://raw.githubusercontent.com/itda-work/django-wireview/v1.3.0/docs/images/bench-fastapi-first-load.svg)

첫 화면은 서버가 끝까지 그린 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)입니다.

![상호작용 하나의 전송량: wireview와 FastAPI](https://raw.githubusercontent.com/itda-work/django-wireview/v1.3.0/docs/images/bench-fastapi-bytes.svg)

상호작용 하나의 WebSocket 전송량은 wireview가 더 큽니다. 값 하나를 바꾸면 보내고 받는 것이 201 B(FastAPI 48 B)입니다. wireview의 메시지는 컴포넌트 id, 핸들러 이름, diff 구조를 함께 싣고, FastAPI 쪽은 바뀐 값만 싣습니다.

Apple M5 Pro macOS, localhost, DB 없이 5회차의 중앙값입니다. 방법과 공정성 규칙, 원본 결과, 전체 표는 [성능 가이드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/PERFORMANCE.md#fastapi와-비교)에 있습니다.

## 설치

```bash
pip install django-wireview daphne
```

새 프로젝트는 스타터 템플릿으로 시작하고, 이미 있는 프로젝트에는 설정 몇 곳을 손으로 더합니다. 둘 다 [설치 및 설정](#설치-및-설정)에 있습니다.

## AI 에이전트로 시작하기

AI가 코드를 써 줘도 그 코드를 고치고 책임지는 건 사람입니다. wireview는 프런트와 백엔드 사이의 API 계약을 없애 사람이 지켜야 할 코드를 Django 한 벌로 줄이고, AI가 쓴 결과를 브라우저 없이 `mount()` 테스트와 `manage.py check`로 바로 확인하게 합니다.

Claude Code 같은 코딩 에이전트에게 아래 프롬프트를 줍니다. 새 프로젝트에서도 이미 있는 Django 프로젝트에서도 같은 프롬프트를 쓰고, 끝부분만 만들고 싶은 것으로 바꿉니다.

```text
https://itda.work/wireview/llms.txt 를 참고해 django-wireview를 설치하고, 실시간 투표 화면을 만들어줘.
```

[llms.txt](https://itda.work/wireview/llms.txt)는 설치 명령, 프로젝트 준비 순서, 문서 페이지 목록을 담은 텍스트 파일입니다. 그 순서에 있는 `python manage.py wireview_agent_setup`은 패키지에 실린 앱 개발자용 [스킬](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/agent-skill.md)을 프로젝트의 `.claude/skills/wireview/`에 설치합니다. 스킬은 언제 무엇을 읽을지와 조용히 실패하는 함정을 담고, 설치한 뒤에는 네트워크 없이 읽힙니다. `manage.py check`는 그런 함정 17가지를 `wireview.W*` 경고로 알립니다([체크 목록](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/checks.md)).

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

wireview는 검증된 Django·Channels 위의 얇은 층입니다. 템플릿·폼·ORM·인증·세션은 Django의 것이고, WebSocket과 채널 레이어는 Channels의 것입니다. 그 근거는 이렇습니다.

- 1.2.0은 PyPI에 `Development Status :: 5 - Production/Stable`로 올라 있습니다.
- CI가 Python 3.12·3.13·3.14와 Django 5.2·6.0·6.1의 아홉 조합을 모두 돌립니다([지원 범위](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/COMPATIBILITY.md#지원-범위)).
- 1.0부터 공개 API를 마이너·패치 릴리스에서 깨지 않고, 없앨 때는 경고를 거친 뒤 다음 메이저에서 없앱니다([호환성 정책](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/COMPATIBILITY.md)).
- `make test`가 5,000개가 넘는 테스트를 돌리고, 릴리스 태그는 이것과 NATS·Redis 위의 브라우저 E2E를 모두 통과해야 PyPI에 올라갑니다.

Node와 프런트엔드 빌드는 필요 없습니다. 다만 복잡도가 사라지는 것이 아니라 운영 쪽으로 옮겨 옵니다. ASGI 서버(daphne·uvicorn), WebSocket을 통과시키는 프록시, 프로세스가 둘 이상이면 채널 레이어의 브로커(NATS나 Redis)가 필요합니다([배포 가이드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/DEPLOYMENT.md)).

Django의 ORM과 인증도 그대로 쓰지만 규칙이 붙습니다. 핸들러와 라이프사이클 메서드는 이벤트 루프 위에서 도는 async 함수라, `aget()` 같은 async ORM API를 쓰거나 `sync_to_async`로 감쌉니다. 사용자와 세션은 WebSocket을 연결할 때 읽은 것이고, Django의 HTTP 미들웨어는 페이지 요청에만 돌고 WebSocket 이벤트에는 돌지 않습니다. 로그아웃하면 [live_session](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/live-session.md#로그아웃과-기존-연결) 경계 안의 열린 소켓이 닫히지만, 로그아웃을 거치지 않는 권한 변경(`is_staff`를 떼는 것 등)은 다음 연결에서야 반영됩니다. 이벤트마다 확인할 권한은 핸들러에서 확인합니다.

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

![Wireview 아키텍처 개요](https://raw.githubusercontent.com/itda-work/django-wireview/v1.3.0/overview.jpg)

## 목차

- [설치 및 설정](#설치-및-설정)
- [빠른 시작](#빠른-시작)
- [예제](#예제)
- [컴포넌트 라이프사이클](#컴포넌트-라이프사이클)
- [이벤트 바인딩](#이벤트-바인딩)
- [URL 상태 관리](#url-상태-관리)
- [모델 구독](#모델-구독)
- [Streams API](#streams-api)
- [Presence API](#presence-api)
- [파일 업로드](#파일-업로드)
- [AsyncResult](#asyncresult와-비동기-작업)
- [JS 명령 빌더](#js-명령-빌더)
- [JavaScript Hooks](#javascript-hooks)
- [컴포넌트 API 레퍼런스](#컴포넌트-api-레퍼런스)
- [템플릿 태그 레퍼런스](#템플릿-태그-레퍼런스)
- [JavaScript API](#프론트엔드-api)
- [테스트](#컴포넌트-테스트)
- [디버그 도구](#디버그-도구)
- [설정](#설정)
- [django-reactor 대비 개선 사항](#django-reactor-대비-개선-사항)

## 설치 및 설정

Wireview는 Python ≥3.12과 Django ≥5.2가 필요합니다 (Django 5.2 LTS, 6.0, 6.1 지원. 범위는 [호환성 정책](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/COMPATIBILITY.md#지원-범위)).

```bash
pip install django-wireview daphne
```

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

macOS·Linux(bash, zsh):

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

Windows PowerShell:

```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` 뒤에 붙여 넣습니다.

```bat
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](https://github.com/itda-work/django-wireview/blob/v1.3.0/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](https://github.com/itda-work/channels-nats) — 이 프로젝트가 목표로 하는 레이어입니다. NATS 서버는 Go 바이너리 하나이고 Linux·macOS·Windows 네이티브 빌드가 있어, Redis 없이 SQLite 단일 서버와 Windows까지 같은 구성으로 갑니다.
- [channels_redis](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) — Redis가 이미 있다면 이쪽입니다. 실측상 성능은 대등합니다.

배포 구성은 [docs/DEPLOYMENT.md](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/DEPLOYMENT.md), 두 레이어의 실측 비교는 [docs/design/transport-abstraction.md](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/design/transport-abstraction.md) §5-3에 있습니다.

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

```python
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`를 수정하세요:

```python
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`와 대조한다. 다른 사이트의 페이지가 사용자의 쿠키로 소켓을 여는 것을 막기 위해서다([배포 가이드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/DEPLOYMENT.md#websocket의-origin)). 그래서 `AllowedHostsOriginValidator`로 따로 감쌀 필요는 없다.

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

```python
from django.urls import include, path

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

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

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

## 빠른 시작

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

```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`에 컴포넌트를 생성하세요:

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

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

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

## 예제

동작하는 예제 앱 11개가 [examples/](https://github.com/itda-work/django-wireview/tree/v1.3.0/examples)에 있습니다. 각 디렉터리가 개념 하나이고,
테스트와 README를 함께 가지고 있으며, `make test`가 함께 실행하고 릴리스 게이트(CI)가 태그마다 다시 실행합니다.

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

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

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

## 컴포넌트 라이프사이클

### 초기화 및 렌더링

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

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

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

### 조인 (Joins)

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

```python
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()`이 호출됩니다. 정리 작업에 사용하세요:

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

### 사용자 이벤트

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

### 모델 변경 알림

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

```python
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()`을 사용하세요:

```python
# 발신자
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")
```

## 이벤트 바인딩

### 기본 문법

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

예제:

```html
<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](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/csp.md)를 보세요.

### 암시적 인자

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

```html
<div {% tag_header %}>
  <input name="query">
  <button {% on "click" "search" %}>검색</button>
</div>
```

```python
async def search(self, query: str):
    self.results = await self._do_search(query)
```

## URL 상태 관리

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

```python
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` 접미사를 사용하세요:

```python
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 모델 변경을 구독하세요:

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

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

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

`senders`에 적은 모델만 알린다. 집합으로 적으면 인스턴스의 모든 필드가 직렬화되어 채널 레이어로 간다.
민감한 필드가 있는 모델(`User` 등)은 매핑으로 적어 보낼 필드를 고른다. 받는 쪽 인스턴스에서 적지 않은 필드는
deferred다([설정](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/settings.md#모델-알림)).

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

## Streams API

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

### 기본 사용법

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

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

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

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

컴포넌트:

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

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

### 커스텀 아이템 템플릿

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

### 모든 구독자에게 한 번에: `Broadcast`

새 항목을 같은 목록을 연 **모든** 페이지에 넣을 때는 `Broadcast`를 쓴다. 발행하는 곳에서 항목을 한 번 렌더하고,
받는 페이지마다 컴포넌트 id만 끼워 쓴다. 받는 쪽에서는 코드가 돌지 않으므로 항목 템플릿은 `item`만 읽을 수 있다.

```python
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.3~742.6 ms, `Broadcast`는
18.0~19.7 ms였다(프로세스 1개, InMemory·Redis·NATS). 규칙과 수치는 [Broadcast](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/broadcast.md)에 있다.

## Presence API

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

### PresenceMixin (프로듀서)

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

```python
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 (컨슈머)

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

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

템플릿:

```html
{% 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 설정

```python
from wireview import PresenceConfig

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

## 파일 업로드

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

### 기본 설정

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

템플릿:

```html
{% 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와 비동기 작업

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

```python
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()
```

템플릿:

```html
{% 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 명령 빌더

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

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

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

```html
<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 명령 전송:

```python
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` | 제출 이벤트에 추가 |

```css
.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](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/hooks.md#훅-파일을-어디에-두나).

```javascript
// 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');
  }
};
```

### 템플릿에서 사용

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

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

```javascript
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)

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

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

### 서버 사이드 핸들러

```python
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 문서](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/hooks.md)를 참조하세요.

## 컴포넌트 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](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/boost.md)) |
| `shared_render` | `False` | 렌더가 보는 사람과 무관하다는 선언. 같은 브로드캐스트를 받은 연결들이 렌더를 한 번만 하고 함께 쓴다. 틀리면 남의 화면이 간다 ([shared-render](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/shared-render.md)) |

### 메서드와 필드

오버라이드하는 콜백, 부르는 메서드, 필드의 전체 목록과 시그니처는 [Component API](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/component-api.md)에
있다. 거기 없는 멤버는 밑줄이 없어도 내부다. 내비게이션은 `self.wire`의 `redirect_to`·`replace_to`·`push_to`,
모듈 수준 브로드캐스트는 `broadcast(channel, **kwargs)`(sync)·`abroadcast(channel, **kwargs)`(async)다.

## 템플릿 태그 레퍼런스

```html
{% load wireview %}
```

| 태그 | 설명 |
|------|------|
| `{% wireview_header %}` | 필요한 JavaScript 포함 (약 70KB, gzip 약 22KB) |
| `{% wireview_toasts %}` | `toast(user, ...)`로 보낸 토스트를 받아 띄운다. 레이아웃에 한 번 ([플래시와 토스트](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/flash.md)) |
| `{% 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

```javascript
// 컴포넌트에 이벤트 전송. 옵션: 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 없이 컴포넌트 테스트:

```python
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()` | 전송 메시지 초기화 |

## 디버그 도구

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

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

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

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

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

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

## 설정

`settings.WIREVIEW`의 키 전부와 기본값은 [설정 레퍼런스](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/settings.md)에 있다. 설정은 쓰는 시점에
읽으므로 테스트에서는 `override_settings(WIREVIEW={...})`로 바꾸고, 모르는 키는 `manage.py check`가
`wireview.W014`로 알려 준다.

```python
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()`는 트랜잭션이 커밋된 뒤에 보낸다

```python
WIREVIEW = {
    "DEBUG_SYNC_TRANSITIONS": True,  # 개발 환경에서만
}
```

자세한 내용은 [성능 가이드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/PERFORMANCE.md)를 참조하세요.

## 문서

- [왜 wireview인가](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/WHY.md) - 서버 렌더링에 대한 반론과 그 답
- [아키텍처](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/ARCHITECTURE.md) - 내부 설계 및 패턴
- [배포 가이드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/DEPLOYMENT.md) - 프로덕션 배포 설정
- [성능 가이드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/PERFORMANCE.md) - 성능 최적화 팁
- [튜토리얼](https://github.com/itda-work/django-wireview/tree/v1.3.0/docs/tutorials) - 단계별 가이드
- [로드맵](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/ROADMAP.md) - 향후 개발 계획
- [업그레이드 가이드](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/UPGRADING.md) - 0.x, 1.0 릴리스 후보, 1.0에서 1.1로, 1.1에서 1.2로, 1.2에서 1.3으로. 쓰던 버전별로 읽을 절과 보안 조치
- [호환성 정책](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/COMPATIBILITY.md) - 공개 API, 폐기 절차, 지원 범위

## django-reactor 대비 개선 사항

Wireview는 [django-reactor](https://github.com/edelvalle/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 양쪽에서 돌고, 경계를 넘는 이동은 전체 페이지 로드가 된다 ([문서](https://github.com/itda-work/django-wireview/blob/v1.3.0/docs/features/live-session.md)) |

### 아키텍처 개선

| 항목 | 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/`) |

### 새로운 컴포넌트 메서드

```python
# 라이프사이클
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 컴포넌트는 최소한의 변경으로 작동합니다:

```python
# 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 %}`

## 개발 및 기여

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

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

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

## 라이선스

MIT 라이선스 - 자세한 내용은 [LICENSE](https://github.com/itda-work/django-wireview/blob/v1.3.0/LICENSE)를 참조하세요.
