이 글에서 다루는 것
React로 중첩된 문서·댓글·아웃라이너 같은 트리 UI를 만들다 보면, 깊숙한 한 줄만 타이핑해도 화면 전체가 다시 그려지는 일이 생긴다. 원인은 불변 트리 patch가 조상까지 새 참조를 내리면서 경로 위 행이 전부 다시 그려지는 것이다. “빠르게 느껴지게”가 아니라 렌더 횟수를 계약으로 박고 그다음 아키텍처를 바꾸는 순서를 지켰다. 각 작업이 어떤 상황에서 무엇을 목표로 어떻게 풀렸고 무엇을 배웠는지를 작업별로 정리한다. 구현 세부는 아래 파생 지식 문서로 이어진다.
배경으로, 화면은 블록이 중첩된 트리다. 섹션 안에 블록, 블록 안에 또 블록… 최대 여러 단계 깊이로 이어진다. 데이터는 불변 트리로 관리하므로, 리프(맨 아래 블록) 하나를 고치면 그 블록뿐 아니라 부모·조부모·…·루트까지 새 객체 참조를 받는다. 이건 버그가 아니라 structural sharing(구조 공유)의 정상 동작이다. 문제는 렌더링 방식이다: 트리 전체(또는 큰 덩어리)를 props나 Context로 아래로 내리면, 참조가 바뀐 조상을 기준으로 경로 위의 행이 전부 다시 그려진다. 한 방의 전등만 켰는데 같은 전선에 묶인 집 전체 전등이 같이 깜빡이는 상황에 가깝다.
하루 요약
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| 측정 | 깊은 블록만 바꿨을 때 먼 형제·조상 추가 리렌더 0회 고정 | 행 래퍼를 jest.mock으로 감싸 id별 렌더 횟수 기록, focus 후 baseline 스냅샷 | 5단 깊이 far branch baseline 대비 +0을 테스트로 고정 |
| 행 단위 | 한 블록 수정 cascade를 O(depth) → O(1)로 | write model(불변 트리) / read model({id→entry}) 분리, 행마다 자기 id만 useSyncExternalStore 구독, 값-동등 캐시 | 변경된 id 1행만 리렌더, 조상도 baseline 유지 |
| 컨테이너 | 루트 컨테이너가 commit마다 도는 것 차단 | Context value를 불변 store로, 자주 바뀌는 조각마다 useSyncExternalStore 구독 | 루트 mount 1회 수준, render === 0 검증 |
| ref 타이밍 | 빈 문서 플래그를 부모 ref DOM에 안전하게 쓰기 | child-first commit 때문에 useLayoutEffect → useEffect로 미룸 | ref.current === null로 속성 안 붙던 버그 해소 |
1. 리렌더 격리를 먼저 테스트로 고정
배경지식 (개념)
- 리렌더: React가 컴포넌트 함수를 다시 호출해 화면을 맞추는 것.
- 불변 업데이트: 데이터를 고칠 때 기존 객체를 바꾸지 않고 새 객체를 만드는 방식. undo/redo에 유리하지만, 참조가 바뀌면 React는 “바뀌었다”고 본다.
어떤 상황이었나
최적화 전에 “깊은 노드만 바꿨을 때 먼 형제는 0회 추가 렌더인가”를 계약으로 박아 두어야 개선 여부를 숫자로 볼 수 있다. 기존 테스트는 평평한 3형제 격리와 재귀 깊이를 각각 다뤘지만, “중첩 트리 + 값 변경 + 먼 가지 격리” 를 동시에 보는 케이스가 비어 있었다.
핵심 작업
그 교집합만 새 테스트로 채웠다.
- 행 래퍼를
jest.mock으로 감싸 id별 렌더 횟수를 외부Map에 기록한다. 원본 컴포넌트는 그대로 실행되므로 동작은 유지된다. - 타이핑처럼 비결정적인 입력 대신 Enter로 줄 나누기처럼 한 번에 끝나는 액션을 쓴다.
- 블록을 포커스한 직후 baseline을 찍는다. static 뷰 → 편집기로 바뀔 때 생기는 1회 리렌더를 “값 변경” 측정에서 빼기 위함이다.
결과적으로 5단 깊이 far branch가 baseline 대비 +0임을 테스트로 고정했고, 이후 O(1) read-model을 넣었을 때 조상 행도 baseline 그대로인지로 개선 여부를 바로 볼 수 있게 됐다.
교훈
- 최적화 전에 테스트로 “나쁜 상태”를 고정하라. “형제는 안 그려져야 한다”를 먼저 RED로 만들어 두면, 리팩터 후 GREEN이 곧 증명이 된다.
→ React render count isolation testing
2. O(1) read-model — CQRS projection
배경지식 (개념)
- props / Context: 부모가 자식에게 넘기는 값. 위에서 통째로 바뀌면 아래도 따라 리렌더되기 쉽다.
- CQRS projection: 쓰기용 모델과 읽기용 모델을 분리하는 패턴. 여기서는 읽기 전용 projection을 UI 단위로 쪼갠다.
어떤 상황이었나
한 블록을 고칠 때 경로 위 조상 행이 전부 리렌더되는 O(depth) cascade를 O(1)로 줄여야 했다.
핵심 작업
write model(불변 트리, undo/redo·직렬화의 기준)과 read model(화면용 정규화 맵 {id → entry})을 분리했다.
| 축 | 내용 |
|---|---|
| 구독 | 각 행이 자기 id만 useSyncExternalStore로 구독 |
| 캐시 | 값-동등 — 내용이 같으면 이전 entry 객체 참조 재사용 (입력 ref memo는 조상마다 miss) |
| sync | React setState 직전 choke point에서 동기 syncTree (useLayoutEffect는 삽입 race로 새 블록이 null로 보일 수 있음) |
| 파생 | 순서 번호·열 비율 등은 reconcile 시 entry에 미리 계산해 넣음 |
| GC | 옮겨진 id를 부모 단위로 지우지 않고, 다음 트리의 id 집합 기준으로 정리 |
리팩터 후 같은 render count 테스트에서 조상도 baseline 유지가 되어, 행 단위 O(1)이 숫자로 증명됐다.
교훈
- 불변 트리와 React props는 잘 맞지만, 통째로 내리면 같이 흔들린다. write model과 read model을 나누고 행마다 자기 id만 구독하게 한다.
- 메모이제이션은 “출력이 같으면 같은 참조”가 핵심이다. 입력 노드 ref만 키로 쓰면 조상마다 miss된다.
→ Normalized render entry projection
3. 컨테이너 리렌더 0 — Context를 불변 store로
배경지식 (개념)
- external store 구독: Context value를 불변 store로 두고
useSyncExternalStore로 자주 바뀌는 조각만 구독하는 방식.Object.is스냅샷 비교로 리렌더를 건너뛴다.
어떤 상황이었나
행 cascade를 없앤 뒤에도 최상단 컨테이너가 live doc state를 useState로 들고 있으면, 입력·blur·토글마다 Provider가 다시 그려진다. “행은 안 흔들리는데 껍데기는 매번 도는” 마지막 꼬리였다.
핵심 작업
자주 바뀌는 조각마다 external store 구독자를 두었다.
- 루트 자식 목록 → root entry의
childrenIds만 구독 (구조가 바뀔 때만 변함) - 목차·파생 통계 → 불변
DocContentStore+useSyncExternalStore - 빈 문서 플래그 → 내용이 없을 때
return null인 리프가 containerref에data-*속성을 그림
undo/redo는 매 스냅샷마다 서로 다른 doc 객체 참조가 필요하므로, 패치마다 새 doc을 만드는 write model은 유지하고, Context value만 고정 store로 우회했다. 결과적으로 루트가 mount 1회 수준으로 내려가 render === 0이 검증됐다.
교훈
- Context Provider도 state를 소유하면 리렌더된다. 자주 바뀌는 조각은 고정된 store +
useSyncExternalStore로 빼낸다.
→ React Context render granularity · useSyncExternalStore 스냅샷 동일성
4. ref 타이밍 — child-first commit
배경지식 (개념)
- child-first commit: React commit은 보통 자식 DOM부터 붙인다. 그래서 자식 layout effect가 돌 때 부모 ref가 아직 연결되지 않았을 수 있다.
어떤 상황이었나
빈 문서 여부를 자식 useLayoutEffect에서 부모 ref DOM에 쓰려 했더니 아무 속성도 안 붙는 현상이 있었다. store 스냅샷은 있는데 ref.current === null인 로그가 남았다.
핵심 작업
useEffect로 한 틱 미루면 commit이 끝난 뒤라 안전하게 붙는다. 대신 속성 반영이 최대 한 프레임 늦을 수 있다는 tradeoff가 있다. 결과적으로 ref.current === null로 속성이 안 붙던 버그가 해소됐다.
교훈
- ref 타이밍은 “빠른 effect”가 항상 이기지 않는다. 조상 DOM에 쓸 일은
useEffect로 미루거나 자기 ref를 쓴다.
→ React commit child-first ref timing
파생 지식
이 TIL에서 더 깊게 읽을 수 있는 개념 문서다.
- Normalized render entry projection — 불변 트리 →
{id → entry}projection과 id별 구독 - React render count isolation testing — jest mock 프로브와 baseline 스냅샷으로 격리 계약화
- React Context render granularity — 넓은 Context value가 memo를 깨는 이유와 store 분리
- useSyncExternalStore 스냅샷 동일성 —
Object.is스냅샷 비교와 참조 안정화 - React commit child-first ref timing — layout effect vs effect와 ref 부착 순서