정의
아웃라이너·중첩 댓글처럼 깊은 트리 UI에서 맨 아래 한 줄만 고쳤는데 화면 전체가 다시 그려질 때, 그 리렌더 범위를 한 행으로 좁히려고 쓰는 패턴이다. Normalized render entry projection(정규화 렌더 엔트리 투영)은 불변 트리(또는 그래프)를 write model(값을 바꾸는 원본, 진짜 데이터)로 두고, 화면 렌더 전용으로 정규화 맵 {id → entry}(중첩을 펼쳐 id로 바로 찾게 만든 평평한 맵)를 파생 read-model(읽기 전용 사본)로 따로 유지한다. 그리고 각 행이 자기 id만 useSyncExternalStore(React 밖 상태를 컴포넌트에 구독시키는 훅)로 구독하게 해서, 노드 하나를 편집할 때 리렌더 대상을 경로 길이에 비례(O(depth))하던 것에서 한 행(O(1)) 으로 줄인다. 데이터를 바꾸는 쪽(write)과 읽어 보여주는 쪽(read)을 분리하는 CQRS(Command Query Responsibility Segregation) 발상에서, 읽기 모델을 UI 구독 단위로 잘게 쪼갠 형태다.
맥락에서 이해하기
중첩 블록·댓글 스레드처럼 트리 UI에서 맨 아래 한 줄만 고쳐도, 불변 업데이트 때문에 부모·조부모까지 새 객체가 된다. 트리 전체를 props로 내리면 “한 방 전등만 켰는데 같은 전선의 다른 방까지 깜빡이는” 것처럼 먼 형제·조상 행까지 리렌더된다. 이 패턴은 원본 트리는 그대로 두고, 화면용 {id → entry} 맵만 갱신한 뒤 각 행이 자기 id만 듣게 해 cascade를 끊는다.
왜 필요한가
불변 업데이트(structural sharing)는 변경된 리프뿐 아니라 조상 노드도 새 객체 참조를 받는다. 트리를 props나 Context value로 내리면, 한 필드 타이핑마다 루트까지 경로상 모든 행이 리렌더된다. React.memo는 해당 행의 entry 참조가 안정할 때만 막는다 — 조상 props가 바뀌면 memo도 무력화된다. 중첩 리스트·아웃라이너·트리 에디터에서 입력 지연이 여기서 온다.
동작 원리
- Write model: 불변 트리가 source of truth — 정렬, undo/redo, 직렬화는 여기서 처리한다.
- Projection: 패치마다
syncTree가{id → RenderEntry}맵을 갱신한다. - 값-동등 안정화:
deriveEntry가 내용이 같으면 이전 entry 객체 참조를 재사용한다(reselectresultEqualityCheck와 유사). 입력 노드 ref만 키로 쓰는 ref-memo는 조상마다 miss되어 O(depth)가 그대로다. - Id별 구독: 각 행은
useBlockEntry(id)= 해당 id 리스너만 가진 외부 스토어 +useSyncExternalStore. childrenIds안정화: 자손 텍스트만 바뀌면 부모의 id 리스트 구독자는 스냅샷이 동일하게 유지된다.- Publish choke point: React
setState직전에 동기syncTree— 렌더 중 notify 금지,useLayoutEffectsync는 삽입 노드가 mount되기 전 entry가 없어 null 렌더 race가 난다. - 파생 상태 투영: 형제 ordinal, column ratio 등은 reconcile 시 entry에 미리 투영한다 — 자식 props만 읽는 행이 stale해지지 않게.
실무 적용
- 트리·아웃라이너·댓글 스레드처럼 깊은 중첩 + 잦은 리프 편집 UI에 적합하다.
- Context에 전체 트리를 넣지 말고, runtime context는 stable handler·meta만 유지한다.
- 이동(indent/outdent) 시 GC는 “부모 자식 목록에 없으면 삭제”가 아니라 다음 트리 전체 id 집합에 없을 때만 drop한다.
- 회귀는 id별 render count 테스트로 far branch 0 증가를 고정한다.
트레이드오프
- 맵 동기화·ordinal 투영·GC 로직이 props drilling 대비 복잡하다.
- 파생 상태(번호 매기기, 비율)가 많을수록 projection 규칙이 늘어난다.
- Zustand selector·React Compiler만으로 충분한 작은 트리에는 과한 설계일 수 있다.
- undo/redo가 스냅샷 ref 동일성을 요구하면 write model은 계속 불변 객체로 유지해야 한다.
사용하면 안 되는 경우
- 얕은 폼(필드 5개 이하) — projection 비용이 이득보다 크다.
- 트리 전체가 한 번에 바뀌는 읽기 전용 뷰 — 단순 memo로 충분하다.
- 서버 컴포넌트만으로 렌더되는 정적 목록.
흔한 실수
- entry 캐시를 입력 노드 ref로만 memo → 구조 공유 조상에서 매번 miss.
- 외부 스토어 sync를
useLayoutEffect에만 둠 → 삽입/redo 직후 null 렌더. - indent 시 부모 단위 GC로 이동된 노드 삭제.
- gutter·ARIA가 props 비율만 읽어 리사이즈 후 stale.
- 넓은 Context value로 entry 맵 전체를 push → id 구독 이점 상실.
관련 개념
- react-context-render-granularity — 컨테이너가 live state를 소유하면 mount 외 리렌더 불가피
- usesyncexternalstore-snapshot-identity — 스냅샷
Object.is·단조 revision - two-stack-inverse-undo — 불변 트리 + 패치 choke point
- list-virtualization-windowing — 행 수가 많을 때 렌더·DOM 절감 보완