django-wireview 아키텍처
지금의 구조다. 클릭 하나가 브라우저에서 서버의 컴포넌트를 거쳐 DOM 갱신으로 돌아오기까지의 흐름과, 그 길에 있는 클래스·기능 모듈·프로토콜을 설명한다.
모듈별 한 줄 지도는 저장소의
CLAUDE.md, 메시지 형태의 정본은 implementation/wire-protocol.md, 기능별 API는 features/에 있다. 이 문서는 그것들을 잇는 흐름이다.
1. 전체 구조#
1.1 구성 요소#
┌─────────────────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ wireview.js │ │
│ │ ├─ ServerConnection # WebSocket 연결, 재연결, 메시지 처리 │ │
│ │ │ ├─ open() # /__wireview__?vsn=<n> 연결 │ │
│ │ │ ├─ joinAllComponents # 페이지의 루트 컴포넌트 join │ │
│ │ │ └─ _processMessage # render·stream_op·exec_js ... 처리 │ │
│ │ ├─ WireviewComponent # 컴포넌트 하나의 렌더 상태 │ │
│ │ │ ├─ join() # 서명 상태(data-state)로 서버에 등록 │ │
│ │ │ ├─ applyDiff() # diff를 렌더 상태에 접고 다음 프레임에 morph │
│ │ │ ├─ dispatch() # user_event 전송 │ │
│ │ │ └─ serialize() # 폼 데이터 직렬화 │ │
│ │ ├─ 위임 리스너 # <html>이 wire-on-* 속성을 찾아 실행 │ │
│ │ ├─ HookManager, UploadManager, ViewportObserver, FeedbackManager │ │
│ │ └─ rendered.mjs 등 # diff 적용·HTML 복원 순수 함수 │ │
│ │ wireview-boost.js │ │
│ │ └─ morph() # idiomorph 래퍼, 내비게이션·히스토리 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ↕ WebSocket (JSON 프레임) │
│ Django Server │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ WireviewConsumer (consumer.py) # Channels 어댑터일 뿐 │ │
│ │ ├─ websocket_connect() # Origin·채널 레이어 검사 후 accept │ │
│ │ ├─ connect()/disconnect() # 세션 start()/stop() │ │
│ │ └─ receive_json() # → WireviewSession.handle_message() │ │
│ │ │ │
│ │ WireviewSession (session.py) # 세션 로직 전부. channels 없음 │ │
│ │ ├─ command_join() # 서명 상태 검증, 경계 검사, 마운트 │ │
│ │ ├─ command_user_event() # 사용자 이벤트 처리 │ │
│ │ ├─ component_*() # 컴포넌트가 보낸 세션 메일 │ │
│ │ ├─ model_mutation(), notification(), upload_*() # fan-out 수신 │ │
│ │ └─ send_render() # 부모 렌더 + 자식 LiveComponent 수명주기 │ │
│ │ │ │
│ │ ComponentRepository (repository.py) │ │
│ │ ├─ join() # 컴포넌트 인스턴스 생성·등록 │ │
│ │ ├─ dispatch_event() # 핸들러 호출 (판정: core/handlers.py) │ │
│ │ ├─ take_lifecycle() # LiveComponent joined/update/leaving 배치 │ │
│ │ └─ components_subscribed_to() # 구독 관리 │ │
│ │ │ │
│ │ Component (core/component.py) │ │
│ │ ├─ Pydantic v2 BaseModel │ │
│ │ ├─ wire: WireviewMeta # 렌더 상태, 클라이언트 명령 (core/meta.py) │ │
│ │ └─ 라이프사이클 훅 # joined, leaving, mutation, notification │ │
│ │ │ │
│ │ Rendered (core/rendered.py) # static/dynamic 분리 diff │ │
│ │ Outbound·Broker (core/transport.py) # 채널 레이어를 만지는 유일한 곳 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
1.2 파일별 역할#
| 파일 | 역할 |
|---|---|
__init__.py |
공개 API의 전부. 하위 모듈은 모두 내부다(COMPATIBILITY.md) |
core/component.py |
Component 베이스: 라이프사이클, 이벤트, streams·uploads·async·flash·hooks 메서드, class Meta: 해석(ComponentOptions) |
core/handlers.py |
클라이언트가 부를 수 있는 메서드의 판정 정본 is_client_callable. 디스패처·시스템 체크·validate_call 감싸기가 함께 쓴다 (#127) |
core/meta.py |
WireviewMeta (self.wire): 렌더 diff 계산, 내비게이션·flash·JS 등 클라이언트 명령 |
core/rendered.py |
동적 마커로 나눈 static/dynamic 구조와 diff |
core/state.py, core/signing.py |
data-state 서명·복원, 서명 키 |
core/model_state.py |
상태 안의 모델 인스턴스를 pk로 서명하고 필드 타입 표기를 따라 다시 읽는다 (#113) |
core/render_reads.py, core/render_gate.py |
초기화된 temporary assign을 읽은 동적 부분 찾기(#111), 워커 스레드 렌더 중 백그라운드 작업 미루기(#138) |
core/session.py |
SessionView: Django 세션의 읽기 전용 뷰. 소켓에서는 connect 때 한 번 읽는다 |
core/live_session.py |
페이지 경계(live_session)와 인증 세대 |
core/origin.py |
WebSocket Origin 검사 (#96) |
core/transport.py |
Outbound·Broker 인터페이스와 Channels 구현 |
template_engine.py |
템플릿 변수 출력에 diff 마커 주입 |
consumer.py |
WireviewConsumer: Channels WebSocket 어댑터. 소켓을 받고 세션을 시작·종료한다 |
session.py |
WireviewSession: 메시지 라우팅, 렌더 전송, 업로드·브로드캐스트 수신. Outbound로만 내보낸다 (#60) |
repository.py |
ComponentRepository: 연결당 컴포넌트 인스턴스, 핸들러 호출, LiveComponent 수명주기 배치 |
live_component.py |
LiveComponent: 부모 연결을 공유하는 중첩 상태 컴포넌트 |
js.py |
JS() 클라이언트 명령 빌더 |
features/ |
streams, presence, uploads(레지스트리·토큰·청크 저장소), hooks, toasts({% wireview_toasts %}) |
auto_broadcast.py |
Django 시그널 → 컴포넌트 mutation() |
templatetags/wireview.py |
템플릿 태그 전체 |
static/wireview/wireview.js |
클라이언트 연결, 컴포넌트, 이벤트 위임, 훅, 업로드 |
static/wireview/*.mjs |
diff 적용, 스트림, 이벤트 수정자, 입력값 보존 등 순수 함수 |
static/wireview/wireview-boost.js |
idiomorph 래퍼, 내비게이션, 히스토리 |
1.3 데이터 흐름#
사용자 클릭
│
▼
┌─────────────────┐
│ 위임 리스너 │ <html>이 click을 받아 wire-on-click 속성을 찾고
│ (wire-on-click) │ wireview.send(element, 'increment', {})
└────────┬────────┘
│
▼
┌──────────────────┐
│ WireviewComponent│ dispatch(command, args, formScope)
│ .dispatch() │ serialize() → 폼 데이터 수집
└────────┬─────────┘
│
▼
┌─────────────────┐
│ ServerConnection│ sendUserEvent(id, command, implicit, explicit, ref)
│ ._send() │ JSON.stringify → WebSocket.send
└────────┬────────┘
│
▼ WebSocket
┌──────────────────┐
│ WireviewConsumer │ receive_json → WireviewSession.handle_message
│ → WireviewSession│ → command_user_event(id, command, ...)
└────────┬─────────┘
│
▼
┌─────────────────┐
│ ComponentRepo │ dispatch_event(id, command, args, kwargs)
│ .dispatch_event │ await component.increment(**kwargs) (validate_call)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Component │ self.count += 1
│ .increment() │ (Pydantic 대입 검증)
└────────┬────────┘
│
▼
┌─────────────────┐
│ WireviewSession │ send_render(component, ref=...)
│ .send_render() │ → WireviewMeta.render_diff()
│ │ 템플릿 렌더(마커 포함) → Rendered.from_marked_html
│ │ → get_diff(이전 Rendered, vsn) → 바뀐 dynamic만
└────────┬────────┘
│
▼ WebSocket {"command": "render", "payload": {"id", "diff", "children"?, "ref"?}}
┌─────────────────┐
│ ServerConnection│ _processMessage → case "render"
└────────┬────────┘
│
▼
┌──────────────────┐
│ WireviewComponent│ applyDiff(diff) → applyPhoenixDiff (rendered.mjs)
│ .applyDiff() │ → currentHtml() → boost.morph(element, html)
└──────────────────┘
2. 핵심 클래스#
2.1 Component (core/component.py)#
class Component(BaseModel):
"""Pydantic v2 기반 컴포넌트"""
model_config = ConfigDict(
arbitrary_types_allowed=True,
validate_assignment=True, # 선언하지 않은 필드에 대입하면 ValidationError
ignored_types=(type,), # 클래스 값 속성(class Meta: 포함)은 필드가 아니다
)
# 클래스 레벨 레지스트리
_all: ClassVar[dict[str, type["Component"]]] = {}
_name: ClassVar[str]
_meta: ClassVar[ComponentOptions] # class Meta: 를 부모 것과 키 단위로 합친 결과 (#99)
# 인스턴스 상태 (user·wire·session은 Meta.exclude_fields와 무관하게 직렬화하지 않는다)
id: str = Field(default_factory=lambda: f"rx-{uuid4()}")
user: AnonymousUser | AbstractBaseUser
wire: WireviewMeta
session: SessionView
# 라이프사이클 (모두 async)
async def joined(self): ...
async def leaving(self): ...
async def mutation(self, channel, action, instance): ...
async def notification(self, channel, **kwargs): ...
async def params_changed(self, params, uri): ...
__init_subclass__가 클래스명으로 전역 등록하고(Component._all),class Meta:를ComponentOptions로 해석해cls._meta에 둔다.- 클라이언트가 부를 수 있는 것은
_로 시작하지 않고 사용자 코드에서 정의한 메서드뿐이다. 프레임워크와 Pydantic이 소유한 이름(joined,update,model_post_init등)은 오버라이드해도 빠진다. 판정의 정본은wireview/core/handlers.py의is_client_callable하나이고, 디스패처(ComponentRepository.dispatch_event)· 시스템 체크·validate_call감싸기가 모두 이 모듈의 같은 판정을 쓴다(#127). - 상태 필드는 렌더마다
{% tag_header %}의data-state에 서명되어 실리고, join 때 그것으로 복원된다 (core/state.py, v2 봉투).
2.2 WireviewMeta (core/meta.py)#
class WireviewMeta:
"""렌더링 상태 및 서버 통신 관리"""
_last_rendered: Rendered | None # 마지막으로 보낸 렌더 (diff 기준)
_is_frozen: bool # freeze() 뒤로 렌더 중지
_skip_render: bool # 다음 렌더 한 번 건너뜀
_redirected_to: str | None # 내비게이션이 예약됐으면 렌더하지 않음
# 렌더링
async def render_diff(self, component, repo) -> DiffPayload | None
def render(self, component, repo) -> SafeText | None
# 서버 → 클라이언트 명령
async def send(self, _command, **kwargs)
async def redirect_to(self, to, **kwargs)
async def push_to(self, to, **kwargs)
async def replace_to(self, to, **kwargs)
async def push_js(self, component_id, js)
async def put_flash(self, flash_type, message, ...)
render_diff()는 async 속성을 먼저 풀고 템플릿을 동기 컨텍스트에서 한 번 렌더한 뒤
Rendered.from_marked_html()로 static과 dynamic을 나누고, 이전 Rendered와 비교해 바뀐 dynamic만 담은
payload를 돌려준다. 바뀐 것이 없으면 None이다. 첫 렌더와 static이 바뀐 렌더는 {"s", "d", "f"} 전체를 보낸다.
형태는 features/html-diff.md.
joined() 동안의 명령은 대기열(enter_pending_mode/flush_pending)에 쌓였다가 첫 렌더 뒤에 나간다.
2.3 WireviewSession (session.py)과 WireviewConsumer (consumer.py)#
세션 로직은 전부 WireviewSession에 있고, 내보내는 것은 Outbound로만 한다. 이 모듈은 channels를 import하지
않는다(tests/test_session_extraction.py). WireviewConsumer는 그것을 상속한 Channels 어댑터로, 소켓을 받거나
거절하고(websocket_connect), 세션을 시작·종료하고(connect·disconnect → start·stop), JSON 프레임을
handle_message에 넘길 뿐이다(#60). 채널 레이어 메일은 Channels가 type의 이름으로 세션의 메서드에 보낸다.
class WireviewSession:
# 프론트엔드 명령: handle_message가 command_<name>(**payload)로 보낸다
async def command_join(self, name, state, children=None, ref=None): ...
async def command_leave(self, id): ...
async def command_user_event(self, id, command, implicit_args, explicit_args, ref=None): ...
async def command_hook_event(self, component_id, hook_id, event, payload, ref=None): ...
async def command_params_changed(self, params, uri): ...
async def command_upload_register(self, id, name, entries): ... # upload_cancel, upload_complete
# 컴포넌트 → 자기 세션 (message_from_component가 component_<command>로 보낸다)
async def component_remove(self, id, ref=None): ...
async def component_stream_op(self, op, stream, items, at, limit=0): ...
async def component_exec_js(self, id, commands): ...
async def component_crashed(self, id): ... # 백그라운드 작업이 던졌다
# ... url_change, title, flash, upload_op, dispatch_event, send_render 등
# fan-out 수신 (Broker.publish의 type)
async def model_mutation(self, data): ...
async def notification(self, data): ...
async def upload_progress(self, event): ... # upload_completed, upload_error
async def session_invalidated(self, event): ... # 로그아웃: 소켓을 4001로 닫는다
class WireviewConsumer(AsyncJsonWebsocketConsumer, WireviewSession): ...
- 컴포넌트 코드에서 난 예외는
_crashed()(이벤트)와_join_failed()(join)가 받는다. 소켓을 닫지 않고 그 컴포넌트만 버린다(features/errors.md). - 렌더를 보내는 경로는 모두
send_render()를 지난다. 부모 렌더 뒤에 자식 LiveComponent의joined()·update()·leaving()을 처리하고, 자식 diff를 같은render메시지의children으로 보낸다 (design/live-component-ownership.md).
3. 기능 모듈#
3.1 JS 명령 (js.py)#
JS()는 브라우저에서 실행할 명령을 체인으로 쌓는다. 각 명령은 {"cmd": <이름>, ...} 딕셔너리이고,
값이 None인 인자는 빠진다. transition은 문자열·(클래스, ms) 튜플·딕셔너리를 받아 {"transition", "time"?}
형태로 정규화한다.
>>> from wireview import JS
>>> JS().show("#modal", transition=("fade-in", 200)).push("opened").to_json()
'[{"cmd": "show", "to": "#modal", "transition": {"transition": "fade-in", "time": 200}}, {"cmd": "push", "event": "opened"}]'
템플릿은 인자를 받는 호출을 못 하므로 템플릿에서 쓰는 체인은 컴포넌트의 @property가 만들고
{% on "click" this.open_modal %}처럼 넘긴다. 서버에서 바로 실행하려면 await self.push_js(js)이고,
exec_js 명령(id, commands)으로 나간다. 상세는 implementation/js-commands.md와 features/optimistic-ui.md.
3.2 Streams (features/streams.py)#
await self.stream(name, items)는 항목마다 항목 템플릿(item 컨텍스트)을 렌더해 StreamOp를 만들고
stream_op 명령으로 보낸다. 항목은 컴포넌트 상태에 남지 않는다. stream_insert·stream_delete가 같은 경로를 쓴다.
@dataclass
class StreamOp:
op: Literal["reset", "insert", "delete"]
stream: str
items: list[StreamItem] = field(default_factory=list) # StreamItem(dom_id, html)
at: int = -1 # -1 끝, 0 앞, n 위치
limit: int = 0 # DOM에 남길 최대 항목 수 (0 = 제한 없음)
3.3 클라이언트 훅 이벤트#
await self.push_event(event, payload=None, hook_id=None)는 push_event 명령(component_id, hook_id,
event, payload)으로 나가고, hook_id가 없으면 그 컴포넌트의 모든 훅이 받는다. 훅이 보낸 이벤트는
hook_event로 들어와 handle_hook_event()에 닿는다(features/hooks.md).
3.4 업로드 (features/uploads.py, features/upload_store.py, views.py)#
joined()에서 self.allow_upload(name, accept=..., max_entries=..., max_file_size=...)로 설정한다. 브라우저가
upload_register를 보내면 서버가 검증하고 서명 토큰을 발급하며, 청크는 WebSocket이 아니라 HTTP 엔드포인트
UploadView로 간다. 엔드포인트는 상태가 없고 토큰에서 계산한 경로에 쓴다. 완료된 파일은
async for 로 consume_uploads()에서 꺼낸다. 상세는 features/chunked-uploads.md.
3.5 전송 계층 (core/transport.py)#
채널 레이어는 이 모듈에서만 만진다. 세션으로 가는 출력은 Outbound.send_command, 세션들 사이의 fan-out은
Broker.publish, 컴포넌트에서 자기 세션으로 보내는 메시지는 Broker.send_to_session이다. 다른 연결 계층은
이 두 인터페이스만 구현하면 된다(design/transport-abstraction.md).
4. 디렉터리 구조#
wireview/
├── __init__.py # 공개 API (_EXPORTS 표로 지연 로딩)
├── core/ # component, handlers, meta, rendered, render_reads, render_gate, state, signing,
│ # model_state, session(SessionView), live_session, origin, transport
├── features/ # streams, presence, uploads, upload_store, hooks, toasts
├── consumer.py session.py repository.py live_component.py function_components.py slots.py
├── template_engine.py event_transpiler.py js.py async_result.py auto_broadcast.py
├── views.py urls.py apps.py settings.py checks.py telemetry.py testing.py deprecation.py
├── schemas.py serializer.py utils.py log.py
├── component.py # 폐기 예정 re-export (2.0에서 제거)
├── debug/ # sync_detector (DEBUG_SYNC_TRANSITIONS)
├── templatetags/wireview.py
├── management/commands/ # wireview_stubs, wireview_lsp, wireview_agent_setup, wireview_upload_gc
├── project_template/ # startproject --template 스타터
├── templates/wireview_header.html templates/wireview/toasts.html
└── static/wireview/
├── wireview.js # 소스 (wireview.min.js는 make build-js의 산출물)
├── wireview-boost.js
├── rendered.mjs streams.mjs targets.mjs events.mjs values.mjs live-session.mjs ready.mjs reload.mjs
├── loading.mjs navigation.mjs reconnect.mjs uploads.mjs joins.mjs
└── types.d.ts
5. 프로토콜#
모든 프레임은 {"command": str, "payload": {...}} JSON이다. 명령 목록과 payload의 정본은
implementation/wire-protocol.md다. 요약하면:
// Client → Server
{ command: "join", payload: { name, state, children, ref? } }
{ command: "leave", payload: { id } }
{ command: "user_event", payload: { id, command, implicit_args, explicit_args, ref? } }
{ command: "hook_event", payload: { component_id, hook_id, event, payload, ref } } // ref: "hook-<n>" | null
{ command: "params_changed", payload: { params, uri } }
// upload_register, upload_cancel, upload_complete
// Server → Client
{ command: "render", payload: { id, diff, children?, ref?, vsn?, instances? } }
{ command: "remove", payload: { id, ref? } }
{ command: "joined", payload: { id, ref? } }
{ command: "error", payload: { id, during, ref? } }
{ command: "stream_op", payload: { op, stream, items, at, limit? } }
{ command: "exec_js", payload: { id, commands } }
{ command: "push_event", payload: { component_id, hook_id, event, payload } }
{ command: "url_change", payload: { command, url } }
// reload, focus_on, title, flash, upload_op, hook_reply, set_query_string ...
diff 형태를 새로 더하면 PROTOCOL_VERSION을 올리고, 서버는 클라이언트가 ?vsn=으로 말한 버전 이하의 형태만 보낸다.
6. 성능 고려사항#
6.1 메모리#
# 상태 필드: 렌더마다 서명되어 data-state에 실린다
class Feed(Component):
items: list[dict] = [] # 1000개면 1000개가 메모리와 data-state에 있다
# Streams: 항목은 렌더되어 클라이언트로 가고 상태에 남지 않는다
class Feed(Component):
async def joined(self):
items = [item async for item in Item.objects.all()[:1000]]
await self.stream("items", items)
6.2 대역폭#
템플릿의 변수 출력마다 마커가 붙고, 렌더는 static과 dynamic으로 나뉜다. 첫 렌더 뒤에는 바뀐 dynamic만 간다.
# 전체 렌더 (첫 렌더, static이 바뀐 렌더)
diff = {"s": ['<div class="count">', "</div>"], "d": ["5"], "f": "<fingerprint>"}
# 부분 렌더
diff = {"0": "6"}
6.3 렌더링#
# skip_render()로 불필요한 렌더 방지 (동기 메서드)
async def handle_click(self):
if not self.should_update:
self.skip_render()
return
# temporary_assigns로 렌더 후 메모리 해제
class Report(Component):
large_data: list[dict] = []
class Meta:
temporary_assigns = {"large_data"}
async def joined(self):
self.large_data = await get_large_data()
# 렌더 후 선언한 기본값([])으로 돌아간다. 기본값이 없는 필드는 건드리지 않는다
상세 수치는 PERFORMANCE.md.