# 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](/wireview/reference/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](https://github.com/itda-work/django-wireview/blob/v1.1.0/docs/design/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](/wireview/reference/live-session/)을 선언한 페이지에만 있습니다), 서명자는 `get_signer("wireview.state.v2")`가 만드는 `TimestampSigner`입니다(`wireview/core/signing.py`, 키는 `SIGNING_KEY` 또는 `SECRET_KEY`). zlib 압축을 더한 base64라 속성값으로 들어가도 `&quot;`로 부풀지 않습니다. **서명일 뿐 암호화가 아니므로** 브라우저에서 base64만 풀면 상태 JSON이 그대로 보입니다. 사용자가 읽으면 안 되는 값은 상태 필드에 두지 않습니다([session](/wireview/reference/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` 명령으로 전체 로드를 시킵니다([배포 가이드](/wireview/deployment/)).

마커가 없는 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](https://github.com/itda-work/django-wireview/blob/v1.1.0/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](https://github.com/itda-work/django-wireview/blob/v1.1.0/docs/design/input-values.md), 회귀 테스트는 `tests/test_input_values_e2e.py`다.

## 주의사항

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

## 관련 기능

- [temporary_assigns](/wireview/reference/temporary-assigns/)
- [Streams 튜토리얼](/wireview/tutorial/streams-api/)
- [성능 가이드](/wireview/guide/performance/)
