라이프사이클 훅
컴포넌트 사이에 공통 동작을 나눠 쓰는 장치다. 초기화 시점의 Meta.on_mount와, 이후 단계를 가로채는
attach_hook 둘로 나뉜다.
| 기능 | 뜻 |
|---|---|
Meta.on_mount |
클래스 단위 훅. joined()보다 먼저 돈다 |
attach_hook() |
인스턴스 단위 훅. 이벤트를 가로챈다 |
detach_hook() |
붙인 훅을 뗀다 |
live_session |
페이지 단위 경계. 세션 훅이 컴포넌트 훅보다 먼저 돈다 |
on_mount 훅#
컴포넌트 초기화 중에 도는, 재사용 가능한 훅이다.
훅 정의#
class AuthHook:
"""Ensure the user is authenticated."""
@staticmethod
async def on_mount(component, params, session):
if not component.user.is_authenticated:
await component.wire.redirect_to("/login")
return {"halt": True}
return {"cont": True}
class TrackingHook:
"""Track page views."""
@staticmethod
async def on_mount(component, params, session):
await analytics.track_page_view(
user=component.user,
path=params.get("path", "/"),
)
return {"cont": True}
훅 사용#
from wireview import Component
class ProtectedDashboard(Component):
class Meta:
template_name = "dashboard.html"
on_mount = [AuthHook, TrackingHook]
data: dict = {} # 대입할 상태는 필드로 선언한다 (선언하지 않은 이름에 대입하면 ValidationError)
async def joined(self):
# on_mount 훅이 전부 {"cont": True}를 돌려줬을 때만 실행된다
self.data = await self._load_dashboard_data()
async def _load_dashboard_data(self) -> dict:
# 밑줄로 시작하는 메서드는 클라이언트가 부를 수 없다
return {"orders": await Order.objects.filter(user=self.user).acount()}
훅이 도는 자리#
사용자가 볼 수 있는 컴포넌트 인스턴스를 만드는 모든 경로에서 돈다. 일부만 덮는 인가 훅은
경계가 아니기 때문이다. #75 이전에는 이 호출 지점들이 아예 없어서 Meta.on_mount가 아무 일도 하지
않았다.
| 경로 | 언제 | 뒤이어 joined()? |
|---|---|---|
| HTTP(dead) 렌더 | {% component %} / {% live_component %}가 첫 HTML을 그릴 때 |
아니다 — dead 렌더는 joined()를 부르지 않는다 |
| WebSocket join | ComponentRepository.join(), 인스턴스를 만든 직후 |
예 (halt하지 않았다면) |
| LiveComponent 자식 | 부모 렌더가 지목한 자식을 컨슈머가 정착시킬 때 | 예 (halt하지 않았다면) |
wireview.testing.mount() |
joined() 직전 — 단위 테스트도 훅을 지난다 |
예 (halt하지 않았다면) |
dead 렌더가 가장 중요하다. 보호 대상 HTML은 WebSocket이 붙기 한참 전에, 첫 응답과 함께 나간다. join만 지키면 페이지를 한 번 보내 놓고 나서 리다이렉트하는 꼴이 된다.
템플릿 렌더는 동기이므로, HTTP 경로는 동기 코드에서 async 콜백을 불러내야 하고 그 방법이 스레드에
따라 갈린다. 동기 뷰나 템플릿 렌더를 sync_to_async로 감싼 async 뷰는 일반 워커 스레드에서 그리며,
거기서는 asgiref.sync.async_to_sync가 올바른 다리다. render()를 직접 부르는 async def 뷰는
이벤트 루프 스레드 위에서 그리는데 거기서는 async_to_sync가 예외를 던지므로, 훅은 보조 스레드에
자기 루프를 만들어 돈다. 어느 쪽이든 페이지는 그려진다. 그 보조 스레드에서 ORM을 건드리는 훅은
자기 커넥션을 새로 연다.
Meta.on_mount가 비어 있고 페이지에 경계도 없는 컴포넌트는 이 경로에 들어가지도 않는다.
중첩된 일반 Component#
다른 컴포넌트 템플릿 안의 {% component %}도 훅을 지난다 (#58부터). 부모의 템플릿 렌더가
그것을 인라인으로 그리기 직전에 훅이 돌고, halt하면 그 자리에 아무것도 그려지지 않는다.
그 전에는 이 경로가 라이브 렌더에서 훅을 건너뛰었고, 이유는 "join이 이미 페이지를 덮었다"였다. join이 덮은 것은 페이지였다 — 특정 자식 하나를 거절하려던 세션 훅은 돌지 않았고, 그 컴포넌트는 마크업뿐 아니라 이벤트 대상까지 되었다.
비용#
동기 템플릿 패스 안에서 async 훅을 부르려면 다리를 건너야 한다. 아래는 훅이 아무 일도 하지 않을 때 그 다리 자체의 값이다 — 훅이 DB나 외부를 건드리면 그 비용이 따로 붙는다.
실측 조건: Apple Silicon, 워밍업 50회 뒤 200회 × 7세트의 중앙값, 시간만(할당 추적은 5배쯤 부풀린다). 재현기는 이 표의 근거일 뿐 저장소의 벤치 스위트가 아니다.
| 중첩 컴포넌트 하나 | µs |
|---|---|
| 경계도 훅도 없음 — 다리를 안 건넌다 | 88 |
| 경계 안, 첫 렌더 (다리를 건넌다) | 308 |
| 경계 안, 재렌더 | 56 (경계 밖 53과 사실상 같다) |
인스턴스당 한 번이라는 것이 핵심이다. 부모는 마운트보다 훨씬 자주 재렌더하므로, "이미 마운트됐나"를
다리 앞에서 물어야 한다 — _mount도 같은 질문을 플래그로 답하지만 그때는 이미 다리를 건넌 뒤다.
회귀는 tests/test_live_session_contract.py의 TestTheBridgeIsCrossedOncePerInstance가 막는다.
Meta.on_mount가 비어 있고 페이지에 경계도 둘 다 없어야 다리를 건너지 않는다. 경계를 쓰지 않아도
Meta.on_mount를 쓰는 컴포넌트는 건넌다 — 그 전에도 HTTP 렌더에서는 건너던 다리다.
인스턴스당 한 번#
훅은 렌더마다가 아니라 인스턴스마다 한 번 돈다. 다시 렌더하거나, 같은 렌더 안에서 같은 id를
가리키는 {% component %}가 하나 더 있거나, 부모가 다시 렌더하는 LiveComponent에서는 다시 돌지
않는다.
재join은 다르다. 이미 join한 id로 두 번째 join이 오면 그 인스턴스를 물러나게 하고 새 인스턴스를
mount하므로, 새 인스턴스가 훅을 돈다. HTTP 렌더와 그 뒤의 WebSocket join은 저장소가 다른 두
인스턴스이므로 양쪽에서 각각 돈다.
실행 순서#
- 페이지가
live_session안이면 그 세션의 훅이 먼저 돈다 - 그다음
Meta.on_mount에 적은 순서대로 돈다 - 어느 하나가
{"halt": True}를 돌려주면 나머지는 건너뛴다 - halt하면
joined()는 호출되지 않고, 컴포넌트는 렌더되지 않는다 - 훅 안의 예외는 halt와 같은 거절이다. 예외 자체는 경로에 따라 올라가거나(HTTP 렌더는 500)
로그에 남지만(WebSocket join은 중단되고 클라이언트는
error를 받아 요소에wireview-error를 붙인다 — errors. LiveComponent 자식은 로그만), 어느 쪽이든 컴포넌트는 렌더되지 않고 저장소에도 남지 않는다. 인가 조회가 DB 오류로 실패하는 것이 거절보다 통과하기 쉬워서는 안 되기 때문이다
halt는 마크업까지 막는다 (#58부터). 거절된 컴포넌트는 HTML도 data-state도 내보내지 않고,
저장소에서도 지워져 그 id로 이벤트를 보내도 처리되지 않는다. 그 전에는 halt가 joined()만
건너뛰고 컴포넌트는 평소대로 그려졌는데, 그러면 가드가 막으려던 HTML이 그대로 나갔다.
리다이렉트는 halt와 같이 쓴다. wire.redirect_to()를 부른 뒤 halt하면 WebSocket에서는
클라이언트가 url_change를 받고, 소켓이 없는 HTTP 렌더에서는 WireviewMeta.render()가 컴포넌트
HTML 대신 <meta http-equiv="refresh" content="0; url=...">를 내보낸다. halt만 하면 아무것도
없는 자리가 남을 뿐이므로, 사용자가 어디로 가야 하는지는 훅이 말해 줘야 한다.
훅 시그니처#
async def on_mount(
component: Component,
params: dict[str, Any],
session: dict[str, Any],
) -> dict[str, bool]:
"""
Args:
component: The component instance being mounted
params: URL parameters, as the repository holds them
session: The request session, when the call site has one
Returns:
{"cont": True} to continue, {"halt": True} to stop
"""
session은 호출 지점이 건넬 수 있었던 값이다 — HTTP 렌더에서는 request.session, WebSocket에서는
scope["session"], testing.mount()에서는 session= 인자, 없으면 빈 세션. 훅이 받는 것은 컴포넌트가
self.session으로 보는 것과 같은 읽기 전용 객체다. 상세는 세션 읽기.
훅 점검#
manage.py check는 wireview가 부를 수 없는 Meta.on_mount 항목을 wireview.W007로 보고한다.
on_mount 메서드가 없는 클래스(런타임이 조용히 건너뛰어 컴포넌트가 무방비가 된다)나, async가
아닌 on_mount가 그것이다. 시스템 체크 참고.
attach_hook#
mount 이후의 특정 단계를 가로챈다.
| 단계 | 언제 | 쓰임새 |
|---|---|---|
handle_event |
이벤트 핸들러 직전 | 이벤트 로깅, 검증 |
handle_params |
params_changed 직전 |
URL 추적, 가드 |
after_render |
컴포넌트 렌더 직후 | 분석, 정리 |
붙이기#
class EventLoggingHook:
@staticmethod
async def on_mount(component, params, session):
async def log_events(event: str, params: dict):
logger.info("Event: %s", event, extra=params)
return {"cont": True}
component.attach_hook("event_logger", "handle_event", log_events)
return {"cont": True}
단계별 시그니처#
handle_event
async def hook(event: str, params: dict) -> dict:
# {"halt": True}면 이벤트 핸들러가 실행되지 않는다
# {"cont": True}면 계속한다
return {"cont": True}
halt해도 그 이벤트에 대한 응답은 간다. 버튼의 로딩 표시와 wire-disabled-with가 풀린다.
handle_params
async def hook(params: dict, uri: str) -> dict:
# {"halt": True}면 params_changed를 건너뛴다
return {"cont": True}
after_render
async def hook() -> None:
# 반환값이 필요 없다
pass
떼기#
# 이름으로 전부 뗀다
component.detach_hook("event_logger")
# 특정 단계에서만 뗀다
component.detach_hook("event_logger", stage="handle_event")
자주 쓰는 형태#
인증 가드#
class RequireAuth:
"""Redirect unauthenticated users to the login page."""
@staticmethod
async def on_mount(component, params, session):
if not component.user.is_authenticated:
# 돌아올 곳을 기억해 둔다
await component.wire.redirect_to(
f"/login?next={params.get('path', '/')}"
)
return {"halt": True}
return {"cont": True}
class RequireAdmin:
"""Redirect users who are not staff."""
@staticmethod
async def on_mount(component, params, session):
if not component.user.is_staff:
await component.wire.redirect_to("/forbidden")
return {"halt": True}
return {"cont": True}
이벤트 추적#
class GoogleAnalytics:
"""Track every event to Google Analytics."""
@staticmethod
async def on_mount(component, params, session):
async def track_event(event: str, params: dict):
await ga.track_event(
category="component",
action=event,
label=component.__class__.__name__,
)
return {"cont": True}
component.attach_hook("ga_tracking", "handle_event", track_event)
return {"cont": True}
속도 제한#
{% on "input.debounce.300" %}·.throttle은 브라우저에서만 돈다. 서버는 이벤트 빈도에도 메시지
크기에도 상한을 두지 않으므로, 소켓에 직접 프레임을 보내는 클라이언트는 그 수정자를 거치지 않는다.
서버에서 막아야 하는 핸들러(쓰기, 외부 API 호출, 비싼 질의)가 있으면 handle_event 훅으로 건다.
아래는 컴포넌트마다 초당 RATE개, 몰아서 BURST개까지 받는 토큰 버킷이다.
import time
class RateLimitHook:
"""Accept RATE events a second per component, with bursts of up to BURST."""
RATE = 10
BURST = 20
@staticmethod
async def on_mount(component, params, session):
tokens = float(RateLimitHook.BURST)
last = time.monotonic()
async def check_rate_limit(event: str, params: dict):
nonlocal tokens, last
now = time.monotonic()
tokens = min(RateLimitHook.BURST, tokens + (now - last) * RateLimitHook.RATE)
last = now
if tokens < 1:
return {"halt": True} # 핸들러를 부르지 않는다. 상태는 그대로다
tokens -= 1
return {"cont": True}
component.attach_hook("rate_limit", "handle_event", check_rate_limit)
return {"cont": True}
- 어디에 붙이나. 컴포넌트 하나면
Meta.on_mount, 페이지 전체면live_session(..., on_mount=[RateLimitHook])(live_session). 세션 훅은 그 페이지의 모든 컴포넌트에 붙는다. - 무엇을 세나. 카운터가 훅의 클로저에 있으므로 컴포넌트 인스턴스 하나의 빈도다. 연결을 여러 개 열면
한도도 여러 벌이 된다. 사용자 단위로 세려면
cache.aadd·cache.aincr로user.pk를 키로 센다(원자적인 캐시가 필요하다. 예시는 배포 가이드의 연결 제한). 연결 수 자체는 배포 가이드의 미들웨어가 막는다. - halt는 조용하다. 거절된 이벤트는 핸들러를 부르지 않을 뿐 오류를 보내지 않는다. 알려야 하면 halt 전에
await component.put_flash(...)같은 명령을 보낸다. - 메시지 크기. 프레임 하나의 상한은 ASGI 서버가 정한다. uvicorn은
--ws-max-size(기본 16MB)다. 이벤트 인자로 큰 값을 받을 일이 없으면 줄여 둔다. 파일은 WebSocket이 아니라 청크 업로드 엔드포인트로 오고UPLOAD_MAX_FILE_SIZE가 따로 막는다.
이 패턴의 동작은 tests/test_lifecycle_hooks.py의 TestRateLimitExample이 위 코드 블록을 그대로 실행해 확인한다.
감사 로그#
class AuditLog:
"""Record every user action for compliance."""
@staticmethod
async def on_mount(component, params, session):
async def audit_event(event: str, params: dict):
await AuditEntry.objects.acreate(
user=component.user,
action=event,
component=component.__class__.__name__,
data=params,
)
return {"cont": True}
component.attach_hook("audit", "handle_event", audit_event)
return {"cont": True}
Phoenix LiveView 대응#
| 기능 | Phoenix LiveView | django-wireview |
|---|---|---|
| 클래스 훅 | on_mount: [Hook] |
Meta.on_mount = [Hook] |
| 인스턴스 훅 | attach_hook/4 |
attach_hook() |
| 훅 떼기 | detach_hook/3 |
detach_hook() |
| 단계 | :handle_event, :handle_params, :handle_info, :handle_async, :after_render |
handle_event, handle_params, after_render |
| 반환값 | {:cont, socket} / {:halt, socket} |
{"cont": True} / {"halt": True} |
| 세션 접근 | 세션 전체 | 인자로 전달 |
live_session과의 역할 구분#
셋은 서로 다른 질문에 답한다. 하나가 통과했다고 다음이 통과하는 것이 아니다.
| 무엇을 판정하나 | 어디서 | 통과하면 | |
|---|---|---|---|
live_session의 authorize |
이 사용자가 이 페이지에 들어올 수 있는가 | 뷰(첫 바이트 전)와 join(마운트 전) | 페이지의 컴포넌트들이 만들어지기 시작한다 |
Meta.on_mount / 세션 on_mount |
이 컴포넌트를 마운트할 때 무엇을 먼저 하나 | 컴포넌트를 만드는 모든 경로 | joined()가 돌고 컴포넌트가 렌더된다 |
| 이벤트 인가 | 이 사용자가 이 객체를 건드릴 수 있는가 | 핸들러 안 (직접 쓴다) | 그 한 번의 조작이 일어난다 |
페이지에 들어왔다는 것이 그 안의 객체를 건드릴 권한을 뜻하지 않는다. live_session은 페이지의
문이지 행마다 붙는 자물쇠가 아니다. 남의 주문서 id로 이벤트를 보내는 것은 여전히 핸들러가 막아야
한다.
live_session을 쓸 때 Meta.on_mount가 없어지지는 않는다. 페이지 전체에 걸리는 것(인증, 감사 로그)은
세션의 on_mount로 올리고, 컴포넌트 하나에만 해당하는 것은 그대로 Meta.on_mount에 둔다. 상세는
live_session.
관련#
- live_session — 페이지 단위 경계. 세션 훅이 컴포넌트 훅보다 먼저 돈다
- 시스템 체크 — 부를 수 없는
Meta.on_mount항목을 잡는wireview.W007, 경계와 어긋난 선언을 잡는wireview.W010 - LiveComponent — 중첩 컴포넌트와 그 수명주기