temporary_assigns - 메모리 최적화
렌더링 후 지정된 필드를 자동으로 초기화하여 서버 메모리를 절약합니다.
개요#
문제: 대용량 데이터의 메모리 점유#
일반적인 컴포넌트에서 대용량 리스트를 로드하면, 해당 데이터는 컴포넌트가 살아있는 동안 계속 메모리에 남아있습니다:
class MessageList(Component):
messages: list[Message] = []
async def joined(self):
# 10,000개의 메시지 로드 → ~10MB 메모리 점유
self.messages = [message async for message in Message.objects.all()[:10000]]
# 이 데이터는 컴포넌트가 종료될 때까지 메모리에 유지됨
WebSocket 연결이 유지되는 동안(사용자가 페이지에 머무르는 동안) 이 메모리는 해제되지 않습니다.
해결: temporary_assigns#
Meta.temporary_assigns를 사용하면 렌더링이 완료된 직후 지정된 필드가 기본값으로 자동 초기화됩니다:
class MessageList(Component):
class Meta:
temporary_assigns = {"messages"} # 이 필드는 렌더 후 초기화됨
messages: list[Message] = []
async def joined(self):
self.messages = [message async for message in Message.objects.all()[:10000]]
# 렌더링 완료 후 → self.messages = [] (메모리 해제)
클라이언트에는 이미 HTML이 전송되었으므로 화면에는 10,000개의 메시지가 표시됩니다.
다음 렌더가 목록과 무관한 이벤트 때문이어도 목록은 화면에 남는다. 초기화는 변경이 아니기 때문이다. Phoenix와 같다. 초기화된 필드만 읽은 부분은 다시 보내지 않으므로, 브라우저는 마지막으로 받은 HTML을 그대로 둔다. 자세한 규칙은 아래 "다음 렌더에서"에 있다.
서명 상태(data-state)에는 이 필드가 실리지 않는다. 그래서 재접속으로 다시 join하면 필드는 기본값에서
시작한다. 불러오는 코드는 joined()에 둔다. 위 예제가 그렇다.
작동 방식#
초기화 시점#
Meta.temporary_assigns에 지정된 필드는 매 렌더링 직후 초기화됩니다:
이벤트 발생 (예: joined, 버튼 클릭)
↓
컴포넌트 상태 변경
↓
템플릿 렌더링 (messages 데이터 사용)
↓
HTML diff 계산 및 클라이언트 전송
↓
★ Meta.temporary_assigns 필드 초기화 ★ ← 이 시점
↓
다음 이벤트 대기
다음 렌더에서#
초기화된 필드는 핸들러가 다시 대입하기 전까지 그대로인 상태다. 다음 렌더에서 템플릿의 각 동적
부분({{ … }}, {% for %}, {% if %})은 무엇을 읽었는지에 따라 이렇게 다뤄진다.
| 그 부분이 읽은 것 | 다음 렌더에서 |
|---|---|
초기화된 필드만 ({% for m in messages %}, {{ messages|length }}, this.messages, 그 필드만으로 계산하는 property) |
마지막 모습 그대로. 보내지 않는다 |
| 초기화된 필드와 다른 필드 | 지금 가진 값으로 다시 그린다. 초기화된 필드는 기본값으로 읽힌다 |
| 초기화된 필드를 읽지 않음 | 평소처럼 |
- 다시 대입하면 변경이다.
self.messages = [...]는 새 목록을 보낸다. 일부러 비우려면self.messages = []처럼 대입한다. - 제자리 변경도 변경이다.
self.messages.append(...)처럼 고친 목록은 기본값과 달라지므로 다시 그린다. {% if messages %}블록은 조건 말고 가지(branch) 안의 이름까지 본다. 가지 어디에든 다른 필드가 있으면({% if messages %}<b>{{ count }}</b>{% endif %}) 블록을 다시 그린다. 그러면 조건이 빈 목록으로 평가되어 가지가 빠진다. 다른 필드는 블록 밖에 둔다.- 남는 부분이 다른 컴포넌트나 슬롯을 그렸으면(
{% component %},{% component_block %},{% render_slot %},{% func %}템플릿 안의{% component %}) 그 그림은 이 컴포넌트의 필드가 정하지 않는다. 그래서 그 부분은 마지막 자기 렌더에서 그린 것이 그 뒤에 바뀌었을 때만 다시 그린다 — 그린 컴포넌트가 스스로 렌더해 화면에 다른 것을 그렸거나, 슬롯의 fill이 다른 내용이면. 그 컴포넌트의 서명 상태(data-state)에 실리지 않는 것 — 그것의 temporary assign,exclude_fields, property — 만 바뀌었어도 바뀐 것이다. 아무것도 바꾸지 않은 렌더는 세지 않는다. join에 답한 첫 렌더는 그 앞의 패스가 그린 것과 다를 때만 센다 —joined()가 필드를 바꾸거나 자기 temporary assign을 불러왔으면. 화면에 보이지 않는 필드도, 그 컴포넌트가 그리는 다른 컴포넌트에 넘기는 값도 서명 상태가 달라지므로 센다. 다시 그린 부분은 초기화된 값으로 그려져 목록이 빠진다. 행마다 표시용 컴포넌트를 그리는 목록 ({% for m in messages %}{% component 'Row' text=m %}{% endfor %})이나 고정된 fill을 그리는 블록은 그대로 남는다. 1.0 전에는 늘 남겨서, 행이 스스로 바뀐 뒤 무관한 렌더가 오면 그 행이 화면에서 바뀌기 전으로 되돌아가고 재연결하면 옛 상태로 join했다. 스스로 바뀌는 행의 목록을 남기려면 행을{% live_component %}로 그린다(아래 LiveComponent 항목). {% include %}한 템플릿은 그 자체로 한 부분이다. 초기화된 필드만 읽었으면 남는다. 블록처럼 그리지 않은 가지의 이름까지 보므로(포함된 템플릿 안의{% if messages %}…{{ count }}…{% endif %}) 다른 필드가 있으면 다시 그린다. 블록 안의{% include %}도 그 템플릿 안을 본다. 템플릿 이름을 렌더할 때 정하는{% include tpl %}는 무엇을 그릴지 미리 알 수 없으므로, 그것을 담은 블록은 다시 그린다. 1.0 전에는 포함된 템플릿의 출력이 정적 부분이라 그것이 바뀌면 전체 렌더가 나갔고, 초기화된 목록을 읽은 것은 다음 렌더에서 사라졌다.- LiveComponent(
{% live_component %})는 남는 부분 안에 있어도 남는다. 그 자리에는 LiveComponent의 이름만 있고 내용은 LiveComponent 자신이 그리므로, 그것이 바뀌어도 그 부분을 다시 그리지 않는다. 1.0 전에는 이번 렌더의 템플릿이 그 이름을 부르지 않았다고 서버가 LiveComponent를 내보내, 화면에 남은 LiveComponent의 이벤트가 처리되지 않았다. - 다른 목록의 항목 안에서 읽으면(
{% for r in rows %}{{ messages|length }}{% endfor %}) 그 루프는rows도 읽으므로 다시 그려진다. - 초기화된 목록을 도는 루프는 항목을 그대로 둔다. 항목 안의 다른 값(
{{ count }})도 마지막 값으로 멈춘다. Phoenix의phx-update="append"처럼 항목은 그린 순간의 모습이다. 항목이 그린 중첩 컴포넌트나 슬롯이 그 뒤에 바뀌었으면 위 항목처럼 루프를 다시 그린다.
다른 컴포넌트의 렌더 안에서#
중첩된 {% component %}는 둘러싼 컴포넌트가 렌더할 때마다 그 렌더 안에서 다시 그려진다.
{% component %}를 쓴 쪽(호스트)도, 그 컴포넌트를 슬롯에 담아 그리는 쪽({% component_block %}의
{% render_slot %})도 그렇다. 그 렌더의 diff가 중첩 컴포넌트의 요소까지 덮어쓴다.
그 그림에서도 위 표가 그대로다. 초기화된 필드만 읽은 부분은 그 컴포넌트가 스스로 마지막으로 렌더한 모습으로 그려진다. 그래서 중첩 컴포넌트의 이벤트가 불러온 목록은 호스트의 무관한 렌더 뒤에도 화면에 남는다. 1.0 전에는 그 규칙이 컴포넌트 자신의 렌더에만 적용되어, 호스트가 렌더하면 목록이 사라졌다.
- 그 컴포넌트가 스스로 렌더한 적이 없으면 맞춰 볼 것이 없으므로 지금 값으로 그린다.
- 마지막 렌더와 다른 가지(
{% if %})에 있는 부분도 지금 값으로 그린다. 그 컴포넌트의 다음 자기 렌더도 그렇게 그린다. - 호스트가 넘긴 값(
{% component 'X' count=this.n %})처럼 다른 필드를 읽는 부분은 평소처럼 바뀐다. {% component_block %}의 fill이 호스트의 값을 그려도({% fill body %}{{ this.title }}{% endfill %}) 목록은 남는다. fill 안에 일반{% component %}가 있어도 같다. 다만 그 렌더에서 fill이 그린 글자가 바뀌었으면 지금 값으로 그린다. 컴포넌트는 fill을 미리 그려진 글자로 받으므로, 바뀐 fill로 하는 그 컴포넌트의 다음 자기 렌더도 마지막 렌더와 맞춰 볼 수 없어 그렇게 그린다.- 호스트의 temporary assign을 fill이 읽어도(
{% fill body %}{{ notes|length }}{% endfill %}) 둘이 같은 값을 그린다. 호스트의 렌더는 위 표대로 그 부분을 마지막 모습으로 남기고, fill도 그 부분을 지난번에 넘긴 모습으로 넘긴다. 1.0 전에는 fill이 초기화된 값으로 넘어가, 호스트의 렌더가 남긴 값을 그 컴포넌트의 다음 자기 렌더가 지웠다.{% live_component_block %}의 fill도 같다. LiveComponent는 fill을 글자로 받아 스스로 렌더하므로, 그 값이 호스트의 무관한 렌더에서 사라졌다. - 되돌릴 부분 안에 LiveComponent(
{% live_component %})가 있으면 그 자리는 호스트의 렌더에서도 LiveComponent 자리로 남는다. 그런 부분은 동적 부분을 그대로 둔 채 들어가고, LiveComponent는 자리 표시로 들어가 화면에서 그 요소로 바뀐다. 1.0 전에는 그런 부분을 지금 값으로 그려 호스트의 렌더가 목록과 LiveComponent를 함께 지웠고, 그 컴포넌트의 다음 자기 렌더는 그 부분을 바뀌지 않았다고 보아 LiveComponent를 되돌리지 못했다. 같은 목록을 다시 불러와도 그랬다.
비용: 이렇게 되돌린 부분은 LiveComponent가 없으면 호스트의 렌더에 글자로 들어간다. 그래서 목록을 불러온 뒤 호스트의 첫 렌더는 이미 화면에 있는 목록을 HTML로 한 번 더 보낸다. 항목 500개에서 컴포넌트 자신의 렌더가 약 13.9KB, 그 뒤 호스트의 첫 렌더가 약 27.5KB였고 그다음부터는 바뀐 것만 간다(171B). 호스트의 렌더마다 그 부분을 다시 만들어 대조하므로 렌더 시간도 늘었다(같은 측정에서 0.39ms → 0.61ms). 호스트는 그 HTML을 다음 diff를 위해 들고 있다.
초기화 값 결정 규칙#
필드는 Pydantic 모델에서 정의한 기본값으로 초기화됩니다:
| 필드 정의 | 초기화 값 |
|---|---|
items: list[str] = [] |
[] (빈 리스트) |
data: dict = {} |
{} (빈 딕셔너리) |
count: int = 0 |
0 |
name: str = "default" |
"default" |
items: list[str] = Field(default_factory=list) |
[] (새 리스트 인스턴스) |
주의: 기본값이 없는 필드(items: list[str] - 기본값 없음)는 초기화되지 않습니다.
코드 내부 동작#
# wireview/core/component.py
def _clear_temporary_assigns(self) -> None:
for field_name in self._meta.temporary_assigns:
field_info = type(self).model_fields[field_name]
if field_info.is_required():
continue # 기본값이 없으면 건드리지 않는다
default_value = field_info.get_default(call_default_factory=True)
object.__setattr__(self, field_name, default_value)
사용법#
기본 사용#
from wireview import Component
class ProductList(Component):
class Meta:
template_name = "products/list.html"
temporary_assigns = {"products"} # set으로 필드명 지정
products: list[Product] = []
total_count: int = 0
current_page: int = 1
async def joined(self):
self.products = [product async for product in Product.objects.all()[:100]]
self.total_count = await Product.objects.acount()
결과:
products: 렌더 후[]로 초기화 (메모리 해제)total_count: 유지됨 (100)current_page: 유지됨 (1)
여러 필드 지정#
class Dashboard(Component):
class Meta:
temporary_assigns = {"orders", "analytics", "logs"}
orders: list[Order] = []
analytics: dict = {}
logs: list[LogEntry] = []
user_name: str = "" # 이 필드는 유지됨
이벤트 핸들러에서 다시 로드#
temporary_assigns 필드는 매번 새로 로드해야 합니다:
class MessageList(Component):
class Meta:
temporary_assigns = {"messages"}
messages: list[Message] = []
page: int = 1
async def joined(self):
await self._load_messages()
async def next_page(self):
self.page += 1
await self._load_messages() # 페이지 변경 시 다시 로드
async def _load_messages(self):
offset = (self.page - 1) * 50
self.messages = [message async for message in Message.objects.all()[offset:offset + 50]]
주의사항#
1. 기본값 필수#
Meta.temporary_assigns에 지정된 필드는 반드시 기본값이 있어야 합니다:
# ✅ 올바른 사용
class Good(Component):
class Meta:
temporary_assigns = {"items"}
items: list[str] = [] # 기본값 있음
# ❌ 작동하지 않음
class Bad(Component):
class Meta:
temporary_assigns = {"items"}
items: list[str] # 기본값 없음 - 초기화되지 않음
2. 렌더링마다 초기화됨#
skip_render()를 호출해도 Meta.temporary_assigns는 초기화됩니다:
async def some_handler(self):
self.items = [1, 2, 3]
self.skip_render() # 렌더링 스킵
# 하지만 items는 여전히 [] 로 초기화됨
그러면 [1, 2, 3]은 한 번도 그려지지 않는다. 다음 렌더에서 items는 초기화된 필드이므로 화면은
그 전의 모습으로 남는다.
3. Streams와의 차이#
| 기능 | temporary_assigns | Streams |
|---|---|---|
| 용도 | 단순 리스트 렌더링 후 메모리 해제 | 실시간 리스트 추가/삭제 |
| 데이터 위치 | 서버에서 렌더링 후 해제 | 클라이언트에서 관리 |
| 개별 항목 조작 | 불가능 (전체 재로드 필요) | 가능 (stream_insert, stream_delete) |
| 메모리 | 렌더링 시점에만 사용 | 서버 메모리 사용 안 함 |
선택 기준:
- 읽기 전용 목록 →
temporary_assigns - 실시간 추가/삭제 필요 →
Streams
4. 상속 시 동작#
class Parent(Component):
class Meta:
temporary_assigns = {"items"}
items: list = []
class Child(Parent):
# Parent의 Meta.temporary_assigns 상속됨 (Meta는 키 단위로 상속된다)
# items는 여전히 초기화됨
pass
class ChildOverride(Parent):
class Meta:
temporary_assigns = {"items", "extra"} # 재정의
extra: list = []
실제 사용 예시#
게시판 목록#
class BoardList(Component):
class Meta:
template_name = "board/list.html"
temporary_assigns = {"posts"}
posts: list[Post] = []
page: int = 1
total_pages: int = 1
per_page: int = 20
async def joined(self):
await self._load_posts()
async def go_to_page(self, page: int):
self.page = page
await self._load_posts()
async def _load_posts(self):
offset = (self.page - 1) * self.per_page
self.posts = [post async for post in Post.objects.order_by("-created")[offset:offset + self.per_page]]
total = await Post.objects.acount()
self.total_pages = (total + self.per_page - 1) // self.per_page
대시보드 위젯#
class AnalyticsDashboard(Component):
class Meta:
template_name = "dashboard/analytics.html"
temporary_assigns = {"chart_data", "recent_events"}
chart_data: list[dict] = []
recent_events: list[Event] = []
summary: dict = {} # 이건 유지 (작은 데이터)
async def joined(self):
await self._load_data()
async def refresh(self):
await self._load_data()
async def _load_data(self):
# 대용량 데이터
self.chart_data = await self._fetch_chart_data()
self.recent_events = [event async for event in Event.objects.order_by("-timestamp")[:100]]
# 작은 요약 데이터
self.summary = await self._calculate_summary()
메모리 절약 효과#
측정 예시#
10,000개 Message 객체 (각 1KB 가정)
temporary_assigns 미사용:
├── joined() 후: ~10MB
├── 1시간 후: ~10MB (유지)
└── 연결 종료 시: 해제
temporary_assigns 사용:
├── joined() 후: ~10MB
├── 렌더링 완료 후: ~0MB (즉시 해제)
└── GC 대상이 됨
줄어드는 것은 파이썬 객체(모델 인스턴스, dict)다. 렌더된 HTML 조각은 다음 diff를 위해 서버가 계속 들고 있다. 그래서 목록 1만 개면 그 HTML만큼은 남는다. 그것마저 줄여야 하면 Streams를 쓴다.
동시 접속자 기준#
동시 접속 1,000명, 각각 10,000개 메시지 표시 시:
미사용: 1,000 × 10MB = 10GB 메모리 필요
사용: 1,000 × ~0MB = 최소 메모리
관련 기능#
- Streams API - 실시간 리스트 조작
skip_render()- 불필요한 렌더링 방지- Performance Guide - 성능 최적화 가이드
이 기능은 Phoenix LiveView의 temporary_assigns 옵션을 참고하여 구현되었습니다.