HTML Diff

렌더 결과를 static/dynamic 파트로 나눠, 이벤트마다 바뀐 dynamic 값만 보낸다.

개요#

wireview는 Phoenix LiveView의 렌더 엔진을 본떠 템플릿 출력을 두 종류로 나눕니다.

  • static: 템플릿 리터럴. 구조가 같은 한 바뀌지 않습니다.
  • dynamic: {{ 변수 }} 출력과 컴포넌트의 서명 상태(data-state). 이벤트마다 바뀔 수 있습니다.

첫 렌더는 static과 dynamic을 모두 보내고, 이후 렌더는 바뀐 dynamic 값만 인덱스와 함께 보냅니다. 클라이언트는 static과 dynamic을 이어 붙여 HTML을 복원한 뒤 idiomorph로 DOM에 반영합니다.

작동 방식#

  1. render_with_markers()가 템플릿의 VariableNode를 MarkedVariableNode로 감쌉니다. 출력은 <!--$n-->값<!--/$n--> 형태의 마커로 둘러싸입니다 (wireview/template_engine.py).
  2. {% tag_header %}는 라이브 렌더에서 서명 상태도 같은 마커로 감쌉니다 (wireview/templatetags/wireview.py의 _signed_state). HTTP 렌더에서는 마커 없이 그대로 넣습니다. 속성 안의 마커 텍스트는 최초 join을 깨뜨리기 때문입니다.
  3. Rendered.from_marked_html()이 마커를 기준으로 static 목록과 dynamic 목록을 만들고, static 목록의 해시를 fingerprint로 삼습니다 (wireview/core/rendered.py).
  4. {% for %}는 ComprehensionNode가 감싸 루프 전체를 <!--$Cn-->, 각 반복을 <!--$In--> 마커로 표시합니다. 파서는 이를 comprehension 하나로 만듭니다. 항목 템플릿의 static은 한 번만, 항목마다 dynamic 목록만 갖는 구조라 항목이 늘거나 바뀌어도 부모 fingerprint는 그대로입니다 (GAP-025).
  5. {% if %}는 ConditionalNode가 감싸 <!--$Bn--> 마커로 표시합니다. 파서는 이를 자체 static과 dynamic을 가진 블록으로 만듭니다. 분기가 바뀌어도 부모 static은 그대로이고, 루프 항목 안의 조건문도 항목 템플릿을 흐트러뜨리지 않습니다. 이전에는 조건문 안의 변수에 마커가 붙지 않아 그 안의 어떤 변화도 전체 렌더였습니다.
  6. {% live_component %}는 라이브 렌더에서 자식의 HTML 대신 참조 <!--$n--><!--@wv:id--><!--/$n-->를 남깁니다. 파서는 이를 ComponentRef로 만들고 wire에서는 {"c": "id"}입니다. 값이 id뿐이라 부모가 몇 번 재렌더돼도 이 슬롯은 바뀌지 않고, 자식의 HTML은 자식 자신의 렌더로만 오갑니다. 클라이언트는 부모 HTML을 만들 때 참조 자리에 자식의 현재 HTML을 넣습니다 (live-component).
  7. WireviewMeta.render_diff()가 직전 Rendered와 비교합니다.
    • fingerprint가 다르면 전체 렌더 {"s": [...], "d": [...], "f": "..."}. d의 원소는 문자열, comprehension {"s": [...], "d": [[...], ...]}, 블록 {"r": [...], "d": [...]}, 참조 {"c": "id"} 중 하나입니다.
    • 같으면 바뀐 인덱스만. 값은 문자열, comprehension 전체(항목 템플릿이 바뀌었거나 처음 생겼을 때), 항목 갱신 {"u": {"<항목 인덱스>": [...]}, "n": 항목 수}, 항목 재배열 {"k": [...]}(아래), 블록 전체(분기가 바뀌었을 때), 블록 부분 갱신 {"p": {"<인덱스>": 값}}, 다른 자식으로 바뀐 참조입니다.
    • 바뀐 값이 없으면 None이고 아무것도 보내지 않습니다.
  8. 항목이 움직이면 움직인 만큼만 보냅니다 (GAP-030). 항목 갱신 {"u"}는 위치로 비교하므로 앞에 하나를 끼우면 뒤 항목이 전부 다시 나갑니다. 그래서 서버는 옛 목록과 새 목록의 항목을 렌더된 내용으로 짝짓고, 새 목록을 옛 목록의 구간 [시작, 길이]와 새 항목 {"d": [...]}의 나열로 보냅니다: {"k": [{"d": ["new"]}, [0, 50]]}. dynamics가 같은 항목은 HTML도 같으므로 템플릿에 키가 필요 없고, 같은 내용의 항목이 여럿이어도 어느 것과 짝지어도 결과가 같습니다. 두 형태 중 직렬화 바이트가 작은 쪽을 보내고(같으면 {"u"}), 제자리 편집·끝에 추가·끝에서 삭제는 늘 {"u"}라 이전과 바이트까지 같습니다. 클라이언트가 연결 URL에 ?vsn=2 이상을 붙였을 때만 씁니다. 설계와 버린 대안은 keyed-comprehension.md에 있습니다.

클라이언트는 wireview/static/wireview/rendered.mjs의 순수 함수로 diff를 적용하고 HTML을 복원합니다. node --test tests/js/로 검증합니다.

서명 상태는 wireview.core.state가 만듭니다. 서명 대상은 상태만이 아니라 v2 봉투 {"v": 2, "n": <클래스 FQN>, "s": <live_session>, "a": <인증 세대>, "d": <상태>}이고(s·a는 live_session을 선언한 페이지에만 있습니다), 서명자는 get_signer("wireview.state.v2")가 만드는 TimestampSigner입니다(wireview/core/signing.py, 키는 SIGNING_KEY 또는 SECRET_KEY). zlib 압축을 더한 base64라 속성값으로 들어가도 &quot;로 부풀지 않습니다. 서명일 뿐 암호화가 아니므로 브라우저에서 base64만 풀면 상태 JSON이 그대로 보입니다. 사용자가 읽으면 안 되는 값은 상태 필드에 두지 않습니다(session). 봉투가 클래스를 담는 이유는 join 프레임에서 클래스 이름이 토큰 옆에 따로 실려 오기 때문입니다 — 묶여 있지 않으면 A용 서명을 필드가 맞는 B로 제출할 수 있습니다(#76). 경계와 인증 세대를 담는 이유는 공개 페이지에서 정당하게 서명된 상태를 보호된 페이지에 들고 가거나, 로그아웃 전에 발급된 상태를 계속 쓰지 못하게 하기 위해서입니다(#58). join은 max_age=STATE_MAX_AGE(기본 14일)로 만료를 검사하고, 클라이언트가 보낸 이름과 봉투 안의 클래스를 둘 다 resolve해 다르면 거절합니다.

토큰 재사용이 diff 안정성을 지킵니다. 타임스탬프가 매 렌더마다 갱신되면 상태가 같아도 data-state가 달라지고, 이 값은 dynamic 파트이므로 무변경 렌더가 매번 diff를 만듭니다. 그래서 sign_state()는 상태 JSON이 같고 토큰이 STATE_REFRESH_AFTER(기본 STATE_MAX_AGE // 2)보다 젊으면 같은 토큰을 그대로 돌려줍니다. STATE_MAX_AGE - STATE_REFRESH_AFTER마다 한 번이라도 렌더되는 컴포넌트는 페이지가 열려 있는 동안 만료되지 않습니다. 회귀 테스트는 tests/test_diff_stability.py와 tests/test_signed_state.py입니다.

v2 봉투 이전의 형식은 읽지 않습니다(#99). 그런 토큰으로 join하면 서버는 reload 명령으로 전체 로드를 시킵니다(배포 가이드).

마커가 없는 HTML(다른 템플릿 엔진이나 HTML 압축기가 주석을 지운 경우)은 정적 조각 하나짜리 렌더로 다뤄집니다. 바뀌면 컴포넌트 HTML 전체가 나가고, 같으면 아무것도 나가지 않습니다. 예전의 토큰 단위 diff는 #99에서 없어졌습니다.

실측#

make bench-compare BASE=997ee59의 출력입니다 (2026-09-08, macOS, Python 3.12, Django 6.0). 997ee59는 GAP-024 이전의 main이고, 항목 50개 리스트 컴포넌트는 항목마다 {% if %}가 있습니다. 같은 명령으로 누구나 다시 잴 수 있습니다 (bench/README.md).

지표 997ee59 현재 변화
리스트, 항목 하나 값 변경 (B) 8,015 595 −93%
리스트, 항목 하나 추가 (B) 8,161 604 −93%
리스트, 항목 안 조건 토글 (B) 8,164 607 −93%
리스트, 최상위 조건 켜기 (B) 8,215 639 −92%
리스트, 첫 렌더 (B) 8,015 2,218 −72%
플랫, 값 하나 변경 (B) 676 194 −71%
WebSocket render 페이로드, 항목 50개 (B) 8,012 624 −92%
이벤트당 CPU, 리스트 (ms) 0.406 0.543 +34%
그중 템플릿 렌더 (ms) 0.339 0.371 +9%
컴포넌트 메모리, 항목 50개 (KB) 31.1 25.6 −18%
연결당 서버 RSS, 항목 50개 (KB) 78.7 72.4 −8%
이벤트 처리량, 항목 50개 (/s, 프로세스당) 1,844 1,504 −18%

읽는 법: 이전에는 서명 상태가 static 파트에 들어 있어 부분 diff가 한 번도 발동하지 않았고, 어떤 이벤트든 HTML 전체를 보냈습니다. 지금 남은 600 B의 대부분은 재연결용 서명 상태이고 diff 자체는 70 B 안팎입니다. 대가는 이벤트당 CPU 약 0.13 ms입니다. 마커가 늘어 템플릿 렌더가 조금 느려졌고, 마커 파싱이 0.12 ms를 씁니다. 회귀 테스트는 tests/test_diff_stability.py와 tests/test_comprehension.py입니다.

목록 편집 (GAP-030)#

make bench-compare BASE=e3815b1의 출력입니다 (2026-09-19, macOS, Python 3.12, Django 6.0). e3815b1은 목록 편집 시나리오를 더한 직후, 이 변경 직전의 main입니다. 바이트는 서명 상태를 포함한 diff 객체 JSON이고, 시간은 편집 한 번의 핸들러 + 렌더 + diff입니다.

지표 e3815b1 현재 변화
항목 50개, 앞에 삽입 (B) 2,171 682 −69%
항목 50개, 가운데 삽입 (B) 1,438 692 −52%
항목 50개, 첫 항목 삭제 (B) 2,084 627 −70%
항목 50개, 마지막을 맨 앞으로 (B) 2,120 641 −70%
항목 50개, 뒤집기 (B) 2,102 1,046 −50%
항목 500개, 앞에 삽입 (B) 20,269 3,934 −81%
항목 500개, 첫 항목 삭제 (B) 20,185 3,880 −81%
항목 500개, 가운데 두 항목 맞바꾸기 (B) 3,948 3,913 −1%
항목 500개, 뒤집기 (B) 20,015 8,563 −57%
항목 하나 값 변경·끝에 추가 (B) 656·664 657·666 같음(서명 상태의 흔들림)
항목 500개, 앞 삽입·삭제 번갈아 (ms) 3.90 4.13 +6%
항목 500개, 회전 (ms) 3.93 4.29 +9%
항목 500개, 모두 같은 항목의 회전 (ms) 3.69 3.97 +8%
이벤트당 CPU, 항목 50개 (ms) 0.536 0.532 −1%

읽는 법: 항목 500개의 3.9 KB는 거의 전부 서명 상태이고 diff 자체는 60~70 B입니다. 항목을 상태에 들고 있는 컴포넌트는 목록을 서명 상태에서 빼는 편이 더 큰 절감입니다. Meta.exclude_fields나 Meta.temporary_assigns에 넣은 필드는 서명 상태에 실리지 않습니다(#111). 시간의 +6~9%는 항목을 내용으로 짝짓는 비용(500개 회전에서 약 0.15 ms)이고, 같은 실행에서 무관한 flat.event_ms가 +7% 흔들린 폭 안팎입니다. 전송이 16 KB 줄어 컨슈머의 JSON 직렬화가 가벼워지는 몫은 이 벤치에 들어 있지 않습니다. 회귀 테스트는 tests/test_comprehension_moves.py, tests/test_diff_roundtrip.py, 브라우저 비교는 tests/test_comprehension_moves_e2e.py입니다.

입력 중인 값#

morph는 새 HTML의 값을 입력칸에 옮긴다. 그대로 두면 서버가 아직 모르는 값, 즉 사용자가 치고 있는 값이 아무 렌더에나 지워진다(#91). 그래서 사용자가 고친 입력칸(value가 서버가 마지막으로 렌더한 값과 다른 텍스트 입력과 textarea)은 렌더를 건너 값을 지킨다. 예외는 둘이다.

  • 그 칸이나 그 칸의 폼에서 온 확정 액션에 대한 응답. 확정 액션은 submit, change, blur·focusout, Enter로 걸러진 키 이벤트(keypress.enter, keydown.key.enter, key_code.13), 폼의 submit 버튼 click이다. 그 응답의 값이 결과이므로, 서버가 값을 렌더하지 않는 입력칸은 Enter 뒤에 비고 폼은 submit 뒤에 빈다. 그 밖의 이벤트(input, 방향키·Escape, 폼 밖의 click)는 확정이 아니다. 검색창의 방향키는 선택만 옮기므로, 아직 보내지 않은 검색어를 되돌리지 않는다.
    • 응답은 이벤트와 ref로 짝지어진다. 다른 렌더(먼저 보낸 이벤트의 늦은 응답, 브로드캐스트)는 이 표시를 쓰지 못한다.
    • 응답이 덮는 것은 보낸 값까지다. 액션을 보낸 뒤 사용자가 더 치거나 지운 칸은, 포커스가 떠났든 서버가 새 값을 보냈든 값을 지킨다.
    • 표시되는 칸은 그 이벤트가 실제로 보내는 칸이다. myself로 자식을 부르면 자식 범위만 보내므로 조상 폼의 다른 칸은 건드리지 않는다.
    • IME가 조합 중인 키에는 키 수정자가 반응하지 않는다. 한글을 조합하며 누른 Enter는 keydown.enter 핸들러를 부르지 않는다.
    • {% on "keypress.enter" this.chain %}의 JS().push도 같은 규칙을 따른다. window.wireview.send(el, name, args, {eventType})는 eventType으로 판정하고, {commit: true}를 넘기면 확정이 된다.
  • 포커스가 없는 칸에 서버가 새 값을 렌더했을 때. 포커스된 칸은 커서 아래에서 바뀌지 않는다(Phoenix LiveView와 같다).

서버가 입력칸을 확실히 비우거나 바꾸려면 push_js(JS().set_value(...))를 쓴다. morph를 거치지 않으므로 이 규칙과 무관하다. examples/chat이 메시지를 보낸 뒤 이렇게 비운다. 규칙의 정본은 wireview/static/wireview/values.mjs, 설계는 input-values.md, 회귀 테스트는 tests/test_input_values_e2e.py다.

주의사항#

  • 루프는 항목 단위로 diff됩니다. 항목 추가·삭제·변경·이동은 해당 항목의 dynamic만 보냅니다(8번). 바뀐 항목은 dynamics 전체를 보내고, 항목 안의 일부만 보내지는 않습니다. 항목마다 구조가 달라지는 구성({% cycle %}처럼 마커 없이 static을 바꾸는 태그)은 루프 전체가 문자열 하나로 취급됩니다. {% include %}로 항목마다 다른 템플릿을 골라도 그 출력은 항목 안의 한 블록이라 루프는 항목 단위 diff를 유지합니다. 대량 목록은 Streams를 쓰세요.
  • 항목에 위치 의존 값이 있으면 이동의 이득이 없습니다. {{ forloop.counter }}는 앞에 하나를 끼우면 뒤 항목의 내용을 모두 바꿉니다. 내용이 바뀐 항목은 짝지어지지 않습니다.
  • DOM 요소를 옮기려면 항목 루트에 id를 두세요. diff는 HTML 문자열을 복원할 뿐이고, 어느 요소가 옮겨 갔는지는 idiomorph가 id로 판단합니다. id가 없으면 요소는 위치대로 morph됩니다.
  • {% include %}한 템플릿의 출력은 그 자체로 한 블록입니다. 그 안의 변수에는 마커가 없어서, 출력이 바뀌면 그 블록이 통째로 갑니다. 페이지의 나머지는 부분 diff 그대로입니다. 1.0 전에는 그 출력이 부모의 static이라 바뀔 때마다 전체 렌더가 나갔습니다. 초기화된 temporary assign을 읽는 경우는 Temporary Assigns를 보세요.
  • HTML 압축기는 마커를 지웁니다. 주석을 제거하면 부분 diff가 꺼지고 바뀔 때마다 HTML 전체가 나갑니다. django-hmin 연동(USE_HMIN)은 그래서 #100에서 없어졌습니다. WebSocket 압축(permessage-deflate)은 연결마다 메모리를 크게 쓰고 작은 diff는 거의 줄지 않으므로, 큰 HTML을 자주 보내는 앱만 재 보고 켜세요(배포 가이드).
  • 서명 상태는 상태가 바뀔 때마다 다시 전송됩니다. 재연결 시 클라이언트가 이 값을 돌려보내 컴포넌트를 복원하기 때문입니다. 렌더에 필요 없는 큰 필드는 Meta.exclude_fields로 빼세요. 렌더에만 필요한 큰 목록은 Meta.temporary_assigns에 넣으면 서명 상태에서 빠지고 렌더 후 비워지며, 다음 렌더가 그 목록을 화면에서 지우지 않습니다.
  • HTTP 렌더에는 마커가 없습니다. is_live가 아닌 렌더는 strip_markers()를 거칩니다. 예전에는 value="<!--$0-->…"처럼 속성 안에 마커 텍스트가 남아 WebSocket 연결 전까지 입력값과 링크가 깨졌습니다.

관련 기능#