Skip to content

U04 — 누적 목록 DOM을 실제 viewport 범위로 (2026-09-10)

  • 기간: 2026-09-10, 1세션. 오너: “바벨릭 #1535 진행해줘. 나한테 물어보지 말고 끝까지 완주하고”. 선행 전체의 실제 release 포함을 기다린 뒤 착수했다.
  • 랜딩: 앱 PR #1553 · af5ae625f6cb020e44b79cff1c5ec5ea89a7d38arelease/v0.18.0. Production 배포·이슈 종결은 하지 않았다. DB·Edge·migration 변경 없음.
  • 설계서: U04 #1535, Phase 4 채택 계획.
  • 정본: viewport·identity 계약, 앱 src/react/ui/shared/ViewportList.tsx 및 8개 화면 소비자.
  • 도구: 앱 tests/browser/u04/measure.mjs, identity.mjs, 실제 product component/CSS/host harness. 앱 test-results/는 gitignored이고 이 기록의 계측 자료에 사본을 남긴다.
  • 게이트: 내부 Phase마다 npm run check, U04 Chromium 21개, build, 필수 precheck, 자기 PR Merge Check. 일반 Merge Check는 full CI 성공이 아니다.
  • 버그리포트: 없음. 계획된 구조 개선이며 구현 중 발견한 경계 회귀는 같은 PR에서 재현·수리했다.
  • 계약: U04 공통 시각 기반 연결.

Phase 현황

Phase내용상태
Phase 1실제 긴 목록·scroll root·높이 기준선완료 f5798f3b
Phase 2viewport·canonical key·항목 투영 identity완료 82bd76c6
Phase 3추가·삭제·왕복·키보드·owner완료 c3cab238
Phase 4같은 workload·가변 폭/폰트·비용 인계완료 0ad84d71

1. 배경

기존 useListWindow는 60행부터 시작해 sentinel이 보일 때마다 더 많은 행을 계속 마운트했다. 로딩과 실제 DOM 생명주기가 분리되지 않아, 끝까지 이동하면 카탈로그의 960행이 전부 남았다. 이력과 양 플랫폼 피드는 로드된 모든 행을 처음부터 그렸다. 검색 세트 피드도 6행씩 누적했다.

A14 이력·A10/A11 resource pagination과 U02/U03 편집기·U07 CSS는 이미 분리돼 있었다. U04는 그 계약을 유지하며 표현 비용만 바꿨다. U02 #1523, U03 #1520, A10 #1501, A11 #1516, A14 #1503, U07 #1549와 앞 스텝 A15 #1550·D14 #1551, D11 #1547/#1548의 merge SHA가 시작 release 576dae9b의 ancestor임을 확인했다.

2. 문제 제기

끝까지 이동한 DOM이 전체 로드 건수만큼 남았다

실제 제품 화면·스타일·host로 960 catalog와 300 history/feed를 고정했다. 300 feed는 기존 서버 resource의 200개 상한을 바꾸지 않은 표현 stress fixture다. 첫 측정의 단순 끝 점프는 모바일 피커에서 120행에 머물러 전체 탐색 증거로 쓰지 않았다. 이후 두 버전 모두 같은 viewport 상대 이동과 sentinel 재진입 동작으로 전체 960행 도달을 확인했다.

canonical source가 같아도 view 객체는 새로 만들어졌다

페이지 추가 때 같은 resource 행을 다시 투영하면 기존 row identity가 바뀌었다. 카드 key의 배열 index 사용도 삭제·정렬 시 다른 세션을 같은 React 행으로 취급할 여지가 있었다. 가변 높이의 위치는 CSS 고정 높이로 대신할 수 없었다.

3. 해결 방안

원칙

D1(2026-09-10): 승인된 #1535를 선행 release 반영 후 끝까지 수행한다. 데이터 요청·cursor·owner와 편집 저장 상태는 기존 모듈에 두고, 긴 목록 DOM만 viewport 범위로 관리한다.

방안판단
설치된 TanStack Virtual 3.13.19의 key·실측·scroll API 사용채택. 신규 라이브러리·서버 계약 없이 실제 viewport와 가변 높이를 처리
페이지를 더 잘게 자르거나 CSS로 숨긴 채 DOM 유지기각. 누적 DOM 비용이 남음
짧은 editor·PR 요약까지 가상화기각. 병목 근거와 이슈 범위가 없음
가변 높이를 추정한 위치로 smooth-scroll·별도 timeout 반복기각. 실측이 끝나면 목표 위치가 달라짐; 설치 virtualizer의 index 이동 보정 사용

4. 적용한 내용

Phase 1 — 기준선

mobile screen/sheet-body, desktop document/피커 내부 scroll을 확인했다. catalog row는 19~57px, 메모 있는 이력/피드는 수백 px로 달랐다. 신규 SQL·전체 이력 선로딩은 만들지 않았다.

Phase 2 — viewport와 identity

기존 useListWindow의 네 소비자, 검색 세트 피드, A14 이력, 모바일/desktop 피드에 ViewportList를 연결했다. 60개 이하는 normal flow/virtualizer disabled, 긴 목록만 앞뒤 5행 overscan을 사용한다. canonical key와 immutable source 기반 WeakMap 투영을 연결하고, 이전 hook을 삭제했다.

Phase 3 — 위치와 포커스

DOM 변경 전 보이는 key와 화면 내 위치를 캡처해 페이지·삭제 후 복원한다. 삭제된 활성 행은 인접 control로 옮기고, 화면 밖에 남긴 focus도 Tab/Shift+Tab 순서를 따른다. 상위 상세/편집 상태는 DOM 생존에 의존하지 않는다. 실제 로드된 알림 대상이 가상화돼 있어도 해당 key를 먼저 렌더·이동한다.

Phase 4 — 가변 폭·폰트와 회귀

60→120 경계, 화면 위 행 삭제, offscreen Tab, 알림 이동을 실패로 재현하고 수리했다. 추가 검증에서 중간 이력의 폭 변경·폰트 로드 위치도 확인해, 폰트 loading 전 기준을 잡고 loadingdone에서 재측정·복원했다. 짧은 목록·원래 페이지 요청·hasMore/exhausted·retained tab·owner 전환을 포함했다.

작업 중 드러난 것

최초 check의 CRLF와 기존 index-key/필터 함수 모양 단언을 고쳤다. 단언을 없애고 성공 처리한 것이 아니라 실제 SSR 필터 결과와 browser canonical 선택/탐색으로 대체했으며 audit pending 사유를 남겼다. resize 보정은 여러 animation frame에 걸치므로 행동 검사는 임의 두 frame을 완료로 취급하지 않고 동일 key·3px 이내 위치라는 최종 조건을 기다린다. 별도 테스트 재시도는 0이다.

5. 적용 결과

Windows x64·Node24.16.0·Chromium·로컬 Vite 개발 빌드/React Profiler, 390 또는1280×800, CPU 제한 없음. 기준 코드 f5798f3b와 최종 코드 0ad84d71에 같은 data/host harness 및 220회 viewport 상대 이동(주기적 역방향 포함)을 적용했다. 각 표면은 동일 건수를 로드한 1-page 표현 fixture다. 초기 측정 단순 점프와 아래 전체 탐색 수치를 혼용하지 않는다.

화면로드/양쪽 도달최대 live행 전→후최대 DOM 전→후누적 render ms 전→후전체 page JS heap MiB 전→후
picker960/960·960960→252927→148112.9→176.19.45→7.50
desktop-picker960/960·960960→212914→119121.4→183.09.43→7.59
search960/960·960960→435797→339185.5→166.712.55→7.54
home-search960/960·960960→326778→315218.1→184.813.46→7.74
history300/300·300300→192654→21628.6→115.86.33→6.07
search-feed300/300·300300→233944→367362.1→113.810.40→6.38
feed300/300·300300→193111→25155.5→104.88.86→6.16
desktop-feed300/300·300300→193708→28638.5→103.87.06→6.09

모든 화면의 live DOM은 제한됐지만, picker·이력·양 플랫폼 피드의 누적 render 시간은 증가했다. 전에는 한 번 마운트한 행을 유지했고, 후에는 스크롤마다 측정/범위를 갱신하기 때문이다. 단일 개발 실행의 누적 값이며 p95 프레임 지연·Production 속도 개선율이 아니다. heap은 GC 후 전체 page/fixture/Profiler 배열을 포함하고 DOM native 메모리·resource cache만을 따로 재는 값은 아니다. 원본 전 · 원본 후.

모바일 피드 전 · 모바일 피드 후: 끝부분의 같은 카드·긴 메모·행 경계를 유지한다.

200행 투영, warmup20/실행500median ms 전→후p95 ms 전→후같은 source 참조 전→후한 행 수정 후 나머지 재사용
mobile feed0.2193→0.00900.5474→0.01600→2000→199
desktop feed0.1071→0.00270.1341→0.00460→2000→199
history0.0080→0.00270.0128→0.00320→2000→199

이는 동일 immutable 객체에 대한 반복 투영 비용이다. 변경된 객체/새 owner의 첫 투영 비용이나 전체 RPC·화면 시간으로 일반화하지 않는다. identity 전 · identity 후.

로컬 최적화 빌드의 전체 JS는 80→80 chunks, 2,662,861→2,683,179 bytes, 파일별 gzip(level9) 합계 788,051→794,316 bytes(+6,265)다. 마지막 after 값은 필수 precheck 빌드 산출물이다. 공통 HostFrames chunk에 ViewportList/TanStack 정적 import가 합쳐졌다. 이는 전체 JS 합계이며 cold 첫 화면 전송량으로 간주하지 않는다. chunk별 원본.

필수 검증과 한계

검증결과
Phase 1 check3,579 pass / 0 fail / 92 conditional skip, 106.33초
Phase 2 check3,581 pass / 0 fail / 92 conditional skip, 101.01초
Phase 3 check3,581 pass / 0 fail / 92 conditional skip, 100.11초
Phase 4 check3,581 pass / 0 fail / 92 conditional skip, 106.43초
U04 browser21/21, Chromium·실제 제품 component/CSS/host. retries=0
Precheckstatic·unused·build/artifact 통과, unit-1 1,613 pass/40 skip + unit-2 1,968 pass/52 skip, 실패0. 2분25초(22:01 KST). base576dae9b 포함, HEAD0ad84d71
Merge Checkrun34480203089 성공, 22:03 KST. base576dae9b + own HEAD0ad84d71의 merge af5ae625가 실제 release에 포함됨을 fetch/ancestor 검사로 확인
Productionpublic GET200만 사전 확인. 인증 여정·운영 성능·배포 미실행

#1478의 와드업·관리자 제외를 복귀시키지 않았다. 일반 Merge Check·부분 브라우저·로컬 개발 계측을 full CI나 native 실기기 성공으로 표현하지 않는다. 상세 왕복은 실제 list와 fixture native dialog의 focus lifecycle이며, 앱 전체 route·screen reader·safe area·native 여정은 여기서 실행하지 않았다.

6. 이번 개선으로 향상된 것

기록이 늘어도 화면에 남는 행은 일정 범위다

데이터를 모두 버리거나 추가 로딩을 끊지 않고 live DOM을 제한한다. 데이터 cache·크기 cache·배열 투영 비용은 별도로 드러내므로 DOM 감소를 전체 메모리·CPU 감소로 오해하지 않는다.

돌아올 항목과 수정된 항목을 구분한다

canonical key, 동일 source의 item identity, 행 삭제 시 인접 focus, 가변 높이 재측정을 하나의 화면 계약으로 남겼다. 그 계약을 실제 DOM/선택/페이지 동작 검사로 유지한다.

남은 것

  • U05: ViewportList 정적 import와 공통 HostFrames chunk 증가, 전체 gzip 크기 전후를 받는다. 별도 lazy/import 재구성은 이 이슈에 섞지 않았다.
  • U06: keyboard/가변 폭·폰트/retained tab/owner·overlay 검사 입력을 받는다. native·screen reader·safe area 및 앱 전체 상세 라우팅 검증은 미실행이다.
  • R04: 동일 220-step 자료, 초기/누적 렌더와 heap, 실제 data cap을 분리한 조건을 받는다. G04 인증·CPU4x·Production 전체 workload 측정과 혼합하지 않는다.
  • R01: useListWindow 삭제·네 소비자 이전·새 runtime 참조 없음이 퇴역 근거다. 기존 A14/A10/A11 resource와 U02/U03 editor는 유지한다.
  • release→staging full·승격·Production 배포·이슈 종결은 Phase 5 및 별도 승인 범위다.