Streams API 심화
Streams API의 고급 사용법과 성능 최적화를 다룹니다.
학습 목표#
- Streams의 내부 동작 이해
- DOM ID 전략
- 커스텀 템플릿 사용
- 성능 최적화 기법
- 트러블슈팅
Streams vs 일반 렌더링#
일반 렌더링의 문제#
class BadExample(Component):
items: list = [] # 1000개 아이템
async def add_item(self, item):
self.items.append(item)
# 전체 리스트 HTML 다시 렌더링 후 전송
문제점:
- 매번 전체 HTML 생성 (~50KB)
- 대역폭 낭비
- DOM 전체 업데이트
- 메모리 사용량 증가
Streams의 해결책#
class GoodExample(Component):
items: list = []
async def add_item(self, item):
# 새 아이템 HTML만 전송 (~500B)
await self.stream_insert("items", item)
장점:
- 변경된 부분만 전송
- 최소한의 DOM 업데이트
- 메모리 효율적
- 빠른 응답
QuerySet은 그대로 넘긴다#
stream()은 QuerySet이나 async iterable을 async for로 읽는다. 핸들러는 이벤트 루프 위에서 돌고,
거기서 QuerySet을 동기로 반복하면 Django가 SynchronousOnlyOperation으로 막는다. 그래서 목록으로
미리 바꿀 필요가 없다.
async def joined(self):
await self.stream("items", Item.objects.order_by("-created_at")[:100])
직접 만든 동기 제너레이터는 그대로 반복된다. 그 안에서 ORM을 부르면 막힌다.
DOM ID 전략#
기본 규칙#
Stream 아이템의 DOM ID는 {stream_name}-{pk} 형식입니다:
<li id="messages-123">...</li>
<li id="messages-124">...</li>
커스텀 DOM ID#
함수로 지정#
async def joined(self):
await self.stream(
"items",
items,
dom_id=lambda item: f"item-{item.uuid}"
)
UUID 사용#
stream()과 stream_insert()는 같은 dom_id를 써야 한다. 삽입과 초기 로딩이 다른 규칙으로 id를 만들면
같은 항목이 두 번 들어간다. 헬퍼 하나로 묶어 둔다. 이름은 _로 시작해야 한다 — stream_insert를
오버라이드하면 자기 자신을 부르게 된다.
async def _insert(self, name, item, **kwargs):
await self.stream_insert(
name,
item,
dom_id=lambda i: f"{name}-{i.uuid}",
**kwargs,
)
같은 페이지의 여러 스트림#
스트림 이름은 컴포넌트 안에서만 고유하면 된다. 클라이언트는 wire-stream 컨테이너를 스트림을 보낸 컴포넌트의
요소 안에서 찾고, 그 안에 중첩된 컴포넌트의 같은 이름 컨테이너보다 자기 것을 먼저 고른다. 그래서 같은 컴포넌트를
두 번 놓아 둘 다 "items"를 써도 각자의 목록에 들어간다.
새로 나타난 컴포넌트(부모의 렌더가 처음 그린 LiveComponent 등)가 joined()에서 보낸 스트림도 그 목록에 들어간다.
그 연산은 컴포넌트를 처음 그리는 렌더 바로 뒤에 오지만, 페이지는 렌더를 다음 애니메이션 프레임에 패치하므로
연산이 도착한 순간에는 컨테이너가 아직 없다. 컨테이너를 못 찾은 연산은 다음 프레임(이미 예약된 패치 뒤)까지
기다렸다가 다시 찾는다. 같은 컴포넌트가 그 뒤에 보낸 연산도 함께 기다려 서버가 보낸 순서를 지키고, 다른
컴포넌트의 연산은 기다리지 않고 바로 적용된다. push_js와 push_event도 같은 줄에 서므로 스트림 연산과의
순서도 지켜진다. 한 프레임 뒤에도 없으면(컴포넌트가 그 사이 떠났으면) 경고를 남기고 버린다. 그래서 보류한
연산은 그 컴포넌트가 다음 프레임까지 보낸 것뿐이다 — 프레임이 돌지 않는 백그라운드 탭에서도 다른 컴포넌트의
스트림이 쌓이지 않는다. 보류한 연산 하나가 실패하면(console.error) 그 연산만 빠지고 나머지는 적용된다.
항목의 id는 HTML id라 페이지에서 고유해야 하는 것은 그대로다. 기본값이 {스트림 이름}-{pk}이므로 같은 항목이
두 목록에 함께 나올 수 있으면 dom_id에 컴포넌트 id를 넣는다.
class XList(Component):
async def joined(self):
await self.stream(
"items",
[item async for item in Item.objects.all()],
dom_id=lambda item: f"{self.id}-item-{item.pk}",
)
커스텀 템플릿#
기본 템플릿 경로#
컴포넌트 템플릿 이름에 _item을 붙인 것이다. 스트림 이름과는 무관하다.
{컴포넌트 template_name에서 확장자를 뺀 것}_item.html
예: 컴포넌트의 template_name이 chat/x_chat.html이면 chat/x_chat_item.html. 한 컴포넌트에 스트림이
둘 이상이면 기본 경로를 함께 쓰게 되므로 template=으로 나눈다. 항목 템플릿의 컨텍스트 변수는 item이다.
명시적 템플릿 지정#
await self.stream(
"messages",
messages,
template="chat/custom_message.html"
)
await self.stream_insert(
"messages",
message,
template="chat/highlighted_message.html"
)
조건부 템플릿#
async def add_message(self, message):
template = (
"chat/system_message.html"
if message.is_system
else "chat/user_message.html"
)
await self.stream_insert("messages", message, template=template)
삽입 위치 제어#
at 파라미터#
| 값 | 동작 |
|---|---|
-1 (기본) |
끝에 추가 (append) |
0 |
처음에 추가 (prepend) |
n |
n번째 위치에 삽입 |
사용 예#
# 새 메시지를 끝에 추가
await self.stream_insert("messages", message, at=-1)
# 새 알림을 맨 위에 추가
await self.stream_insert("notifications", notif, at=0)
# 특정 위치에 삽입
await self.stream_insert("items", item, at=5)
이미 화면에 있는 항목이면 제자리 갱신#
stream_insert의 dom id가 이미 DOM에 있으면 at과 무관하게 그 자리에서 교체됩니다.
생성과 갱신을 한 갈래로 쓸 수 있습니다.
async def mutation(self, channel, action, instance):
if action == ModelAction.DELETED:
await self.stream_delete("items", f"items-{instance.pk}")
else:
# 새 항목이면 맨 위에, 이미 있는 항목이면 제자리 갱신
await self.stream_insert("items", instance, at=0)
핸들러와 mutation() 양쪽에서 넣지 마세요. 모델을 구독하고 있으면 저장 신호가 자기
연결로도 돌아옵니다. 두 곳에서 넣으면 같은 항목이 두 번 들어갑니다 — 구독 중이라면
삽입은 mutation() 한 곳에서만 하고 핸들러는 저장만 합니다.
재렌더는 스트림을 지우지 않는다#
컴포넌트 템플릿은 빈 컨테이너만 렌더합니다. 그래서 wire-stream 컨테이너는 DOM 패치
대상에서 제외되고, 상태를 바꾼 뒤 다시 스트리밍하는 핸들러(필터·정렬 전환)가 정상 동작합니다.
async def set_filter(self, filter: str):
self.filter = filter # 재렌더가 일어나도
await self.stream("items", self.queryset) # 이 결과가 남는다
컨테이너 자체의 속성(class 등)은 이 때문에 서버 렌더로 갱신되지 않습니다. 컨테이너 속성을 바꿔야 한다면 바깥 엘리먼트에 두세요.
렌더가 컨테이너 앞에 새 요소를 그려도(새 LiveComponent, {% if %}로 나타난 다른 목록) 목록은 남습니다.
morph는 id가 없는 요소를 자리로 짝짓기 때문에, 그대로 두면 새 요소가 컨테이너 자리를 차지하고 컨테이너는
항목과 함께 지워졌습니다. 그래서 페이지는 패치하기 전에 렌더의 컨테이너에 화면의 컨테이너와 같은 id를 붙입니다.
템플릿이 컨테이너에 id를 달았으면 그 id를 쓰고, 없으면 wire-stream-{컴포넌트 id}-{스트림 이름}을 붙입니다.
그 id는 페이지에만 붙고 서버가 보내는 HTML은 그대로입니다.
성능 최적화#
1. 배치 삽입#
여러 아이템을 한 번에 추가:
# 비효율적
for item in items:
await self.stream_insert("items", item)
# 효율적 - stream()으로 리셋
await self.stream("items", items)
2. 초기 로딩 최적화#
async def joined(self):
# 최근 50개만 로드
messages = [m async for m in Message.objects.order_by('-id')[:50]]
await self.stream("messages", list(reversed(messages)))
async def load_more(self):
# 추가 로딩
older = await self._load_older()
for msg in older:
await self.stream_insert("messages", msg, at=0)
3. 스크롤 최적화#
async def add_message(self, message):
await self.stream_insert("messages", message)
# 부드러운 스크롤
await self.scroll_into_view(
f"messages-{message.pk}",
behavior="smooth",
block="end"
)
4. 불필요한 렌더링 방지#
async def add_item(self, item):
await self.stream_insert("items", item)
self.skip_render() # 컴포넌트 전체 렌더링 방지
트러블슈팅#
아이템이 표시되지 않음#
-
wire-stream 속성 확인
<ul wire-stream="items"> <!-- 이름 일치 확인 --> -
DOM ID 확인 — 항목 템플릿에
id를 적지 않아도 된다. 클라이언트가dom_id(기본{name}-{item.pk})로 붙인다.pk가 없는 항목(dict 등)이면dom_id=를 넘겨야 한다. -
템플릿 경로 확인
- 기본은 컴포넌트 템플릿 이름 +
_item.html(예:chat/x_chat.html→chat/x_chat_item.html)
- 기본은 컴포넌트 템플릿 이름 +
순서가 잘못됨#
at 파라미터 확인:
# 최신이 위로
await self.stream_insert("items", item, at=0)
# 최신이 아래로
await self.stream_insert("items", item, at=-1)
메모리 누수#
화면에 남길 개수는 limit으로 정한다. 넘치면 클라이언트가 반대쪽 끝부터 지운다. 서버는 항목을 들고
있지 않으므로 컴포넌트 상태에 목록을 따로 둘 필요가 없다.
async def add_message(self, message):
# 최신 1000개만 화면에 남긴다
await self.stream_insert("messages", message, limit=1000)
무한 스크롤 (wire-viewport-top / wire-viewport-bottom)#
목록 아래(또는 위)에 둔 요소가 화면에 들어오면 그 속성에 적은 핸들러가 불린다. 아래로 스크롤하다
바닥 요소가 보이면 wire-viewport-bottom, 위로 스크롤하다 꼭대기 요소가 보이면 wire-viewport-top이다.
<ul wire-stream="rows"></ul>
<div wire-viewport-bottom="load_more">불러오는 중...</div>
async def load_more(self):
self.page += 1
async for row in Row.objects.all()[self.page * 20:(self.page + 1) * 20]:
await self.stream_insert("rows", row)
스크롤 방향과 맞을 때만 불린다. 페이지를 열었을 때 이미 보이는 요소는 부르지 않지만, 요소가 화면
가운데를 이미 지나 있으면(목록이 짧을 때) 방향과 무관하게 부른다. 그 구분이 필요하면 핸들러가
_overran 인자를 받는다. 받지 않는 핸들러에는 넘어가지 않는다.
이 판단은 join이 끝난 뒤에 한다. joined()가 보낸 첫 페이지가 화면에 들어온 다음이다. 그 전에는
목록이 비어 바닥 요소가 늘 화면 위쪽에 있으므로, 목록이 길어도 한 페이지를 더 부르곤 했다(#112).
재연결 뒤에도 같다. 끊긴 연결의 판단은 그 연결과 함께 끝나고, 재연결의 joined가 새로 시작한다.
바인딩은 그것을 감싼 가장 가까운 컴포넌트의 것이다. LiveComponent 안의 wire-viewport-bottom은 그
LiveComponent의 핸들러를 부르고, 부모의 같은 이름 핸들러는 부르지 않는다. 중첩된 {% component %}
안의 것도 그 컴포넌트의 것이다. 부모의 렌더가 새로 그린 LiveComponent나 {% component %}도 자기
joined()가 보낸 첫 페이지가 들어온 뒤에 판단을 시작한다.
고급 패턴#
Virtual Scrolling 준비#
class XVirtualList(Component):
visible_items: list = []
scroll_top: int = 0
item_height: int = 50
async def on_scroll(self, scroll_top: int):
self.scroll_top = scroll_top
start = scroll_top // self.item_height
end = start + 20 # 화면에 20개
# 표시할 아이템만 로드
self.visible_items = await self._load_range(start, end)
드래그 앤 드롭 순서 변경#
async def reorder(self, item_id: int, new_index: int):
item = await Item.objects.aget(id=item_id)
# DB 순서 업데이트
await self._update_order(item, new_index)
# 스트림에서 제거 후 새 위치에 삽입
await self.stream_delete("items", item_id)
await self.stream_insert("items", item, at=new_index)