#1596 렌더 구조 개선 검증 기록
이슈 #1596. #1592 가 정한 합격선(입력·공개 계약 §성능 합격 기준, #1592 검증 기록 Phase 1 표)은 바꾸지 않는다. 이 문서는 Phase 별 관측·실험·판정을 쌓는 장부다. 전체 상태는 진행 중이며, 단계별 결과를 앱 전체 달성이나 Production 반영으로 읽지 않는다.
Phase 1 — 원인 분해 실험 (2026-09-14)
목표: #1592 Phase 4 가 남긴 합격선 미달 3종(마지막 데이터→완성 공개 · 유효 캐시 재방문 · cold 첫 반응)의 원인 1·2·3 비용 몫을 추정에서 관측으로 바꾸고, 대안을 같은 기준으로 비교해 채택안을 정한다.
조건
| 항목 | 값 |
|---|---|
| 앱 소스 | feat/1596-render-structure = 145923ff(#1592 브랜치의 release/v0.19.0 병합 커밋). 실험은 되돌릴 수 있는 패치로 넣고 빌드 뒤 되돌렸다 — 각 빌드의 head·tracked diff sha·패치 diff 를 evidence 에 남겼다 |
| 빌드 | production(npm run build, BARBELIC_TARGET=local) + 소스맵(함수 이름 복원용; 번들 코드 동일). 라벨별 출력 폴더·지문 파일(build-fingerprint-<label>.json), 러너가 번들 sha 를 대조 |
| 서버 | 로컬 샌드박스 cil0c062380(마이그레이션 전체 적용, schema 스냅샷·pgTAP 122파일/2,453 assert 통과). #1592 와 같은 생성기(seed 1284)의 1년 fixture(260회) 를 실제 저장 RPC 로 적재, 통계 cron 자연 수렴 |
| 브라우저 | Chromium(Playwright) CPU 4배·RTT +100ms, 모바일 390×844 / PC 1440×1000 — #1592 Phase 4 와 같은 판정식(클릭 capture t0 → 첫 의미 있는 반응 t1 → 마지막 필수 RPC 수신 t2 → 완성 DOM 공개 t3) |
| 추가 계측 | CPU 프로파일(표본 500µs) + 타임라인 트레이스(devtools.timeline) + performance.mark(t0·t1·t3). 창(t0→t1, t1→t2, t2→t3)마다 스택 맨 위 함수의 자기 시간을 소스맵으로 되돌려 층(react-core / app:ui / app:binding / app:controller / app:service / native / 러너 관측기)으로 나눈다. 트레이스에서 Layout·Paint·UpdateLayoutTree·FunctionCall 을 같은 창으로 자른다 |
| 표본 | 조합당 5회, 중앙값. 프로파일러가 켜진 수치라 합격선 판정용이 아니다 — 판정은 Phase 5 의 #1592 러너(20표본)가 한다 |
| A/B | 기준 빌드와 실험 빌드를 별도 폴더·별도 포트(4596~)로 동시에 띄우고 조합마다 라벨을 번갈아 잰다. 같은 PC 에서 다른 세션(#1592 세션)이 함께 도는 동안 한쪽만 느려진 첫 묶음(evidence/run1-loaded/)은 비교에 쓰지 않는다 |
관측 1 — 기준 비용 분해 (1년, 5표본 중앙값 ms, 기계가 비교적 한가했던 첫 묶음)
창별로 "누가 CPU 를 썼는가"(스택 맨 위 함수의 층) 와 트레이스의 브라우저 단계. 루트 몫 = 스택 어딘가에 조립 루트(GymAppController)가 있는 표본 = 루트 재실행이 끌고 들어온 화면 재렌더·파생 계산.
| 시나리오 | 창 | 창 길이 | 앱 CPU | 루트 몫 | react-core | app:ui | binding·controller | app:service | (program) | Layout / Style / Paint | 러너 관측기 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 리포트 PC cold | 입력→첫 반응 | 147 | 142 | 45 | 25 | 8 | 33 | 6 | 29 | 4 / 4 / 10 | 3 |
| 리포트 PC cold | 마지막 데이터→공개 | 579 | 577 | 298 | 31 | 103 | 34 | 295 | 52 | 89 / 2 / 6 | 0 |
| 리포트 M cold(개요 캐시됨) | 입력→첫 반응 | 112 | 97 | 15 | 16 | 1 | 17 | 8 | 37 | 3 / 12 / 3 | 13 |
| 리포트 M cold(개요 캐시됨) | 마지막 데이터→공개 | 109 | 60 | 0 | 16 | 6 | 2 | 4 | 28 | 25 / 18 / 24 | 43 |
| 달력 PC cold | 입력→첫 반응 | 181 | 172 | 56 | 30 | 11 | 50 | 10 | 31 | 4 / 3 / 9 | 4 |
| 달력 PC cold | 마지막 데이터→공개 | 308 | 265 | 30 | 22 | 11 | 21 | 13 | 19 | 42 / 1 / 6 | 44 |
| 달력 M cold | 입력→첫 반응 | 90 | 75 | 5 | 17 | 3 | 9 | 1 | 30 | 5 / 14 / 3 | 18 |
| 달력 M cold | 마지막 데이터→공개 | 156 | 127 | 58 | 12 | 3 | 7 | 14 | 22 | 21 / 4 / 9 | 26 |
| 리포트 PC warm(재마운트) | 입력→완성 | 135 | 132 | 27 | 17 | 23 | 23 | 3 | 42 | 19 / 4 / 16 | 3 |
| 리포트 M warm | 입력→완성 | 102 | 56 | 0 | 10 | 4 | 0 | 0 | 38 | 24 / 20 / 20 | 43 |
| 달력 PC warm | 입력→완성 | 158 | 146 | 27 | 14 | 7 | 23 | 4 | 23 | 9 / 2 / 8 | 12 |
| 달력 M warm | 입력→완성 | 56 | 40 | 16 | 4 | 1 | 13 | 1 | 17 | 6 / 6 / 6 | 11 |
리포트 PC cold 의 마지막 데이터→공개 577ms 안쪽(가장 안쪽 앱 함수 기준): 종목 동의어 색인 createExerciseSynonymMatcher(카탈로그 전체 정규화·자모 분해) 105 · DkBarChart layout effect 의 clientWidth/clientHeight 측정(강제 layout, 차트 2개) 88 · 응답 해석 callScreenRpc(어댑터 검증·문자열마다 TextEncoder·바이트 측정용 JSON.stringify·종목 id 검증) 51 · PR 카드 투영 23 · 카탈로그→운동 목록 재생성 lgExerciseCatalogForWorkout 13 · react-core 조정 31 · Layout 89. 모바일 리포트 cold 에서 개요(604KB)가 클릭 뒤에 도착한 표본: 첫 반응 444 = 루트 재실행 288(그 안에 동의어 색인 92·PR 카드 투영 15·카탈로그 목록 10) + 응답 해석 49 + GC 18.
원문: evidence/1596-phase1/run1-loaded/profile-*.json(첫 묶음) · evidence/1596-phase1/profile-*-ab1.json(A/B).
관측 2 — 조립 루트 재실행과 provider 값 변경 (E1 diag)
| 동작 | 루트 재실행 | 값이 바뀐 provider | 실제로 내용이 바뀐 필드 | 매번 새 객체인데 내용은 같은 필드 |
|---|---|---|---|---|
| 모바일 탭 전환(warm, 달력↔홈·리포트↔홈) | 0회 | 0/15 | — | — |
| 모바일 달력 cold 진입 | 5회(응답 도착마다) | 12/15 | calendarPublication·calendarLoadingMonthKeys·calendarDaySummariesByDate·sessionsByDate | 함수 필드 전부(예 selectDashboardTab()·openDrawer()…) · screenPolicyBinding(React 요소) · activeWorkoutCommands · exerciseCatalog(카탈로그 배열 재생성) · profileImport · 내용 같은 새 객체 gymData·manualPrRecords·selectedDateSessions |
| PC 탭 전환(warm) | 2회/전환(tab 1회 + 지연 값 1회) | 12/15 | tab | 위와 같음 |
| PC 리포트 cold 진입 | 6회 | 12/15 | volumeOverview·volumeReportStatus·prProps… | 위와 같음 |
원문: evidence/1596-phase1/churn-{mobile,desktop}-history-1y.json.
실험 결과 (같은 시간대 A/B, 5표본 중앙값 ms; 기계 부하가 커 절대값은 첫 묶음보다 높다 — 라벨 간 차이만 읽는다)
| 실험 | 바꾼 것 | 시나리오 | 기준 → 실험 (첫 반응 / 동기 / 마지막 데이터→공개 / 완성) | 판정 |
|---|---|---|---|---|
| E4 파생 데이터 | exerciseCatalog 를 원천 배열 identity 기준 memo + 동의어 색인을 첫 질의 때 지연 구축 | 달력 PC cold | 215 / 130 / 430 / 886 → 146 / 68 / 137 / 459 | 마지막 데이터→공개 −68%, 완성 −48% |
| 달력 PC warm | 204 / 139 → 70 / 22 | −66% | ||
| 리포트 PC cold | 235 / 112 / 941 / 1,205 → 145 / 47 / 757 / 945 | 동기 −58%, 공개 −20%(색인 135 남음 — PR 검색 항목 buildPrExerciseSearchItems 가 개요 도착마다 재구축) | ||
| 리포트 PC warm(재마운트) | 185 / 42 → 104 / 18 | −44% | ||
| 리포트 M warm | 121 → 119 | 변화 없음(비용 본체 = 판 전환 style/layout + 관측기) | ||
| E1 stable 프로바이더 안정화 | 15개 provider 값: 함수는 최신 참조 대리, 내용 같은 객체는 이전 참조 유지 | 리포트 PC cold | 179 / 85 / 734 / 966 → 169 / 79 / 716 / 943 | 차이 2~7%(표본 편차 안) |
| 리포트 PC warm(재마운트) | 168 / 40 → 138 / 35 | −18%(재마운트 중 소비자 재렌더 일부 감소) | ||
| 달력 PC warm | 218 / 144 → 219 / 139 | 변화 없음 | ||
| E1b PC 판 유지 | 리포트·홈 바인딩의 return null → 숨김(display:none + inert) | 리포트 PC warm | 168, 재마운트 5/5 → 139, 재마운트 0/5 | 재마운트는 없어지지만 숨은 판이 탭 전환의 루트 재실행으로 다시 그려져 139 남음 — E4·탭 구독 이전과 함께 써야 100 안 |
| 리포트 PC cold | 179 / 85 / 734 → 205 / 91 / 819 | 악화 폭은 편차 안(래퍼 1개 추가) | ||
| E2 잔디 섹션 분리 | yDays 의존을 r.days 로 좁히고 730칸 잔디를 memo 컴포넌트로 | 리포트 M cold, 개요가 클릭 뒤 도착(n=2) | 데이터→공개 657 → 274, W3 앱 CPU 548 → 179 | 마지막 응답 뒤 전체 재렌더 중 잔디 몫이 큼 — 섹션 분리가 유효 |
| 리포트 M cold, 개요 캐시됨(n=3~5) | 데이터→공개 233 → 193, W3 앱 CPU 147 → 123 | −16% | ||
| E3 축소판 전송 비용 | 개요 응답(617,892바이트·객체 49,783개)을 CPU 4배 브라우저에서 7회 | JSON.parse 5.3 · JSON.stringify 3.3 · TextEncoder 6.2 · structuredClone 10.3 · 워커 echo 왕복(복제 2회) 8.0 · 문자열 전송+결과 객체 복제 2.6 | 워커로 fetch→parse→검증을 옮기면 메인 스레드 50~58ms 중 전송 비용 3~10ms 만 남는다(순이익 ≈40~50ms/큰 응답, 4배 CPU) |
판정 — 원인 세 개의 실제 모양
계획서의 원인 1·2·3 은 관측으로 다음처럼 고쳐 읽는다.
- 원인 2(상태 전달) 의 본체는 "provider 값이 새 객체" 가 아니라 "루트 렌더마다 파생 데이터를 다시 만든다" 이다. 루트가 재실행되면 12/15 provider 가 새 객체가 되지만(E1 diag), 값 identity 를 안정화해도(E1 stable) 비용은 줄지 않았다(AB2 표). 줄인 것은 파생 데이터의 identity 였다(E4):
lgExerciseCatalogForWorkout(gymData)가 매 루트 렌더에 카탈로그 전체를 새 배열·새 객체로 만들고, 그 배열을 입력으로 삼는 memo(동의어 색인·PR 검색 항목·편집기 포트·PC 화면들)가 전부 무효화돼 화면마다 100ms 급 계산을 반복했다. 같은 계열:screenPolicyBinding요소·activeWorkoutCommands·profileImport를 렌더마다 재생성, PR 검색 항목을 개요 도착마다 재구축,DkBarChart가 커밋 직후 layout effect 에서 크기를 재며 강제 layout. - 원인 1(전체 재렌더 + 첫 그리기) 은 PC 리포트에서 그대로 성립한다. 마지막 응답 뒤 루트 하위 재렌더 298 중 위 파생 계산을 빼면 화면 자체의 조정·DOM 생성 ~60 + Layout 89 + Paint 6 이 남는다. 모바일 리포트는 개요가 캐시된 경우 마지막 데이터→공개의 앱 비용이 60ms(관측기 43 별도)로 이미 합격선 근처이고, 개요가 클릭 뒤 도착한 경우에만 루트 재실행 288~312 가 앞선다 — 이것도 1 의 파생 계산이 본체.
- 원인 3(응답 처리) 은 큰 응답 1건당 50~58ms(어댑터 검증 + 문자열마다 TextEncoder + 바이트 측정 JSON.stringify + 종목 id 검증) + 기기 사본 IDB 쓰기 14 로, 1·2 보다 작지만 홈 진입 뒤 선조회(달력→그룹→개요 순차)가 첫 탭과 겹치는 시점을 결정한다. 워커 이전의 순이익은 E3 축소판의 전송 비용으로 판정한다(아래).
- 모바일 탭 전환(warm) 은 루트를 깨우지 않는다(0회). 남은 warm 비용은 판 표시 전환의 style/layout/paint(각 20ms 대) 와 관측기(43) — 합격선(100) 안에 이미 있거나, 관측기를 빼면 안에 든다. PC 는 탭 전환마다 루트가 2회 재실행되고(
tab이 루트 상태), 리포트·홈·PR·검색·기록표·그룹 판은 비활성 때 내려가 재방문이 재마운트다(달력만 숨김 유지).
채택안 — 그리기 비용 계약 세 개의 내용을 이렇게 정한다
| 계약 | 내용 | 검사 |
|---|---|---|
| ① 파생 데이터는 원천 스냅샷당 한 번 | 카탈로그 파생 목록·동의어 색인·PR 검색 항목·PR 카드 투영·리포트 투영은 원천(카탈로그 버전·개요 스냅샷) identity 당 한 번 계산해 같은 참조를 유지하고, 검색 색인류는 첫 질의 때 만든다. 명령 묶음(activeWorkoutCommands·screenPolicyBinding·import)은 생성 1회. provider 값은 내용이 같으면 같은 참조 | 단위: 조립 루트를 같은 입력으로 두 번 렌더했을 때 내용 같은 provider 필드의 identity 변경 0 (E1 diag 를 검사로 승격) · 파생 함수 호출 수 = 원천 변경 수 |
| ② 화면은 섹션 단위로, 판은 내리지 않고 숨긴다 | PC 탭 상태는 루트 밖에서 구독(모바일과 같게) → 탭 전환에 루트 재실행 0. PC 판은 방문 뒤 숨김 유지(inert·display:none, 갱신은 받음, DOM 상한은 러너 domNodeCount 로 기록). 화면은 섹션 컴포넌트로 나누고 섹션 입력 identity 를 유지해 마지막 응답이 바꾸는 섹션만 다시 그린다. DkBarChart 크기 측정은 ResizeObserver 콜백에서만(커밋 직후 강제 layout 0) | 단위: 탭 변경 시 무관 화면 재렌더 0 · 상세 도착 시 다시 그려지는 섹션 = 상세가 바꾸는 섹션만(React Profiler 커밋 검사) · 러너 warm 재마운트 0 |
| ③ 큰 응답 처리는 메인 스레드를 한 번에 점유하지 않는다 | 어댑터의 바이트 측정은 전송 계층의 바이트 수를 쓰고(JSON.stringify 제거), 문자열 검사는 길이 상한 선검사 뒤 필요할 때만 인코딩. 개요·카탈로그처럼 큰 응답은 E3 결과에 따라 워커(fetch→parse→검증→DTO 1회 복제) 또는 분할 처리. 선조회는 첫 입력과 겹치지 않게 입력 뒤로 양보 | 러너: 홈 진입 직후 입력의 첫 반응 p95 ≤100 · 긴 작업(≥50ms) 0 |
기각·보류: (a) provider 값 memo 단독 — 효과 없음(E1). (b) 변경 빈도별 컨텍스트 분리 — 루트 재실행 자체가 비용의 본체가 아니므로 ①·② 뒤에 남는 몫이 있을 때만. (c) 저장소 조각 구독(useSyncExternalStore selector) 전면 이전 — 같은 이유로 보류; PC tab 구독 한 곳만 적용. (d) React Compiler — 파생 데이터의 원천 identity 문제(카탈로그 배열 재생성)는 컴파일러 memo 로도 원천이 바뀐 것으로 보이므로 해결되지 않는다. 미채택. canvas 잔디·DOM 축소는 ② 뒤 첫 그리기(Layout 89) 가 남을 때.
Phase 2~4 의 내용을 이 판정대로 조정한다(Phase 수 5 는 그대로).
범위 조정 — 모바일만(오너 결정 2026-09-14 22:40 KST, 이슈 댓글): 데스크톱 항목(PC 판 유지 E1b·PC 탭 상태 루트 밖 구독·DkBarChart 강제 layout·PC 리포트/달력 계측·PC 재방문 재마운트)은 이 트랙 범위에서 뺀다. 위 관측·실험의 PC 수치는 기록으로만 남는다. Phase 2 = 계약 ①(파생 데이터·provider identity; 플랫폼 공통 코드) — 앱을 켜고 바로 리포트를 눌러 개요 응답 처리와 겹치는 경우의 루트 재실행 288~312ms 가 대상. Phase 3 = 계약 ②를 모바일 리포트 섹션(잔디→히스토그램·육각형·순위·성장표) 에, 달력·홈은 계측이 가리키는 자리만. Phase 4 = 계약 ③(홈 진입 뒤 미리 받기 순서·양보, 개요·카탈로그 워커, 어댑터 경량화). Phase 5 = 모바일 1·4·10년 전수 계측·규칙 정착. 계측용 시나리오 report-open-collide(모바일)를 추가한다 — 홈 진입 뒤 개요 요청이 시작된 직후 리포트를 눌러 겹침 사례를 매번 재현.
Phase 2 — 파생 데이터·provider identity 계약 ① (2026-09-14, 모바일)
검증(로컬): check:static 통과 · 단위 총 4,027 / 성공 3,878 / 실패 0 / skip 149(88.8초; 첫 실행의 실패 22건 중 19건은 기준 QA1 0ea37cf4 와 release/v0.19.0(#1474) 의 어긋남이었고 release 병합·QA1 a0e0fad 위 재고정으로 소거, 3건은 이 변경이 옮긴 소스 핀을 재조준). 앱 커밋 d41671be → add4dd31(release/v0.19.0 7cf942d1 병합) → 6b7d5f9b(QA1 04c12944 고정).
적용: services/derivedIdentity.ts(정본) · app/useStableProviderValue.ts · appController.tsx(컨텍스트 값 15개 안정화, 정책 객체·요소 memo) · catalogBinding.ts(카탈로그 파생 목록 memo) · exerciseSynonymSearch.ts(색인 지연) · searchController.ts(PR 검색 항목 지연 파생) · prController.ts(빈 목록 상수) · activeWorkoutDispatchBinding.ts·importFeatureBinding.ts(명령 묶음 1회). 계약 문서 그리기 비용 계약 ①. 검사: QA1 derivedIdentity.test.mjs·exerciseSynonymMatcherLazy.test.mjs·renderStructureGate.test.mjs.
A/B (모바일, 1년, 기준 vs Phase 2 빌드 동시 실행, 5표본 중앙값 ms)
| 시나리오 | 기준 → Phase 2 (첫 반응 / 동기 / 마지막 데이터→공개 / 완성) | 읽기 |
|---|---|---|
| 달력 월 이동 cold | 53 / 31 / 110 / 387 → 21 / 13 / 51 / 240 | 데이터→공개 −54%, 완성 −38% |
| 달력 월 이동 warm | 34 → 22 | −35% |
| 달력 진입 warm | 56 / 21 → 39 / 7 | −30% |
| 리포트 cold, 개요가 클릭 뒤 도착(n=3→2) | 첫 반응 272 → 254 · 데이터→공개 893 → 444 · W3 앱 CPU 717 → 369 | 루트 재실행 안의 파생 계산이 사라진 몫 −49% |
| 리포트 cold, 개요 캐시됨(n=2→3) | 첫 반응 1,076 → 533 · 데이터→공개 328 → 202 · 완성 2,176 → 966 | (표본 적음·기계 부하 큼 — 방향만) |
리포트 겹침(report-open-collide) | 95 / 26 / 176 / 684 → 75 / 14 / 199 / 607 | 첫 반응 −21%, 데이터→공개는 편차 안 |
| 리포트 warm | 90 → 90 | 변화 없음(판 전환 style/layout + 관측기) |
| 달력 진입 cold | 86 / 35 / 91 / 349 → 94 / 43 / 478 / 591 | 겹침 산물: Phase 2 로 미리 받기가 빨라져 개요(604KB)·카탈로그(814KB) 응답이 달력의 마지막 데이터→공개 창 안에 들어오고, 그 검증(requireUtf8TextAtMost TextEncoder 문자열마다 119~134 · 바이트 측정 25 · 투영)이 창을 채웠다(W3 RPC 해석 9 → 211). 기준에서는 같은 응답이 ~100ms 뒤(공개 뒤)에 왔다. 코드가 느려진 것이 아니라 원인 3(응답 처리)이 자리를 옮겨 보인 것 — Phase 4 대상. 공정 비교용으로 미리 받기가 끝난 뒤 누르는 *-quiet 변형을 추가 |
겹침을 제거한 *-quiet 변형(홈 진입 뒤 미리 받기가 끝나고 메인 스레드가 한가해진 뒤 클릭, 5표본 중앙값):
| 시나리오 | 기준 → Phase 2 (첫 반응 / 동기 / 마지막 데이터→공개 / 완성) | 읽기 |
|---|---|---|
| 달력 진입 quiet | 92 / 40 / 181 / 343 → 73 / 24 / 127 / 264 | 데이터→공개 −30%, 완성 −23%; W3 루트 몫 63 → 15 |
| 리포트 quiet(개요 캐시됨) | 72 / 11 / 173 / 553 → 71 / 11 / 202 / 669 | 변화 없음(관측기 53→75 차이 안) — 리포트 화면 자체의 렌더가 남은 비용, Phase 3 대상 |
시도했다가 접은 것: report-open-hold(CDP Fetch 로 개요 응답을 클릭 직후까지 붙들기) — 응답 전달이 0.5~1초 늦어져 자연스러운 겹침(응답이 클릭 50~200ms 뒤 도착)과 다른 경우가 되고, 한 표본에서 401 콘솔 오류가 났다(보류·미채택). 겹침 사례는 report-open-collide(요청 시작 직후 클릭) 와 report-open 표본의 개요 캐시 유무 분리로 본다.
원문: evidence/1596-phase1/runs/profile-*-ab3.json·profile-*-ab4.json, compare.md, 시나리오 scenario-quiet.mjs·scenario-report-open-collide.mjs·scenario-report-open-hold.mjs.
Phase 3 — 화면 섹션 구조 계약 ② (2026-09-14~15, 모바일 리포트)
적용: ui/mobile/screens/ReportScreen.tsx — 잔디·성장·훈련 종목/부위·강도·꾸준함 5개를 React.memo 섹션으로 분리(자기 입력만 받음; 순위 기준·기타 모달·카테고리 모달 상태는 섹션 안), yDays 의존을 r.days·r.rangeText·unit 로 좁힘, 콜백은 ui/shared/useStableCallback.ts 로 참조 고정. 마크업은 분리 전(HEAD)·후를 6개 상태(연간 ready·분기·월 pending·error·빈 데이터·종목 필터)로 정적 렌더해 문자 단위로 같음을 확인(experiments/markup-diff.mjs). 계약 문서 그리기 비용 계약 ②. 검사: QA1 reportSectionIdentity.test.mjs(상세 병합의 참조 유지·필터 목록·멱등) · renderStructureGate.test.mjs(섹션 memo·입력·콜백 핀).
A/B 1차 (모바일 리포트, 1년, 기준 vs Phase 2 vs Phase 3 동시 실행, 5표본 중앙값 ms)
| 시나리오 | 기준 → Phase 2 → Phase 3 (첫 반응 / 마지막 데이터→공개 / 완성) | 읽기 |
|---|---|---|
| 리포트 quiet(개요 캐시됨, 미리 받기 끝난 뒤) | 70 / 177 / 555 → 72 / 174 / 572 → 69 / 154 / 555 | 데이터→공개 −13%; W3 앱 CPU 119 → 112 → 97. 남은 것 = 공개 순간 전체 본문의 style/layout/paint(30/48/60@4x) + 관측기(~55) |
리포트 겹침(report-open-collide) | 88 / 123 / 637 → 69 / 186 / 553 → 66 / 140 / 491 | 완성 −23%; 데이터→공개는 겹침 시점 편차 안 |
| 리포트 cold(우연 겹침, 표본 갈라 봄) | 개요 캐시된 표본의 첫 반응 470 / 524 / 545 — 세 빌드 같음 | "개요 RPC 가 클릭 전에 끝남" 표본에서도 카탈로그 채택 등 미리 받기의 처리 꼬리가 클릭과 겹친다 — Phase 4 대상 |
A/B 2차 — 재현 + 실험 5 (기준 vs Phase 3 vs Phase 3+실험 5 동시 실행, 5표본 중앙값 ms)
| 시나리오 | 기준 → Phase 3 → Phase 3 + 실험 5 (첫 반응 / 마지막 데이터→공개 / 완성) | 읽기 |
|---|---|---|
| 리포트 quiet | 69 / 184 / 636 → 72 / 183 / 571 → 71 / 146 / 654 | 이번 묶음에서는 섹션 분리의 데이터→공개 이득이 0(1차 −13%). 조용한 열기의 남은 비용은 한 프레임의 style/layout/paint 와 관측기라 섹션 memo 가 줄일 몫이 작다. 완성 −10% |
| 리포트 겹침 | 91 / 151 / 724 → 74 / 125 / 642 → 68 / 136 / 519 | 섹션 분리: 데이터→공개 −17%, 완성 −11%(1차 −23%) — 겹침에서의 이득은 두 묶음에서 재현 |
실험 5 = .rp-full > .rp-sec:not(.rp-ahead) 에 content-visibility: auto; contain-intrinsic-size: auto 520px(화면 밖 섹션의 layout·paint 를 스크롤할 때까지 미룸, experiments/apply-e5-cv.mjs). 결과가 엇갈린다 — quiet 는 데이터→공개 −20% 인데 완성 +15%, 겹침은 데이터→공개 +9% 인데 완성 −19%. 잡음 범위 안이고, 이 빌드에서만 관측기 몫이 47ms(다른 빌드 127~141)로 작게 나와 "완성" 차이의 상당분이 도구 쪽이다. 스크롤 위치 튐·페이지 내 찾기 부작용을 감수할 근거가 되지 않아 미채택(CSS 는 되돌림, 빌드 p3-cv 지문만 증거로 남김).
작업 중 드러난 것: 겹침 표본의 W1(클릭 뒤 첫 처리 창)에서 앱 조립 루트가 250~319ms 를 쓰고 그 안에 PR 검색 항목 만들기(exerciseSearch.ts 정규화 97~108ms)가 아직 있다. 원인은 Phase 2 의 provider 안정화(shallowEqualDerived)가 평범한 객체를 한 단계 비교하면서 검색 컨트롤러 값의 items 접근자(첫 요청 때 계산하는 getter)를 읽어 계산을 강제하는 것 — 비교가 계산을 유발하면 지연 파생이 무효가 된다. Phase 4-1 에서 "비교는 데이터 속성만, 접근자가 있는 객체는 참조로만"으로 고치고 겹침 시나리오로 다시 잰다.
원문: evidence/1596-phase1/runs/profile-*-ab5.json·profile-*-ab6.json, compare.md, build-fingerprint-p3-sections.json·build-fingerprint-p3-cv.json, patch-p3-sections.diff, experiments/apply-e5-cv.mjs·refactor-report-sections.mjs·markup-diff.mjs·projection-keys.mjs.
측정 도구에서 드러난 것
- #1592 러너의 DOM 관측기(
getBoundingClientRect·rAF tick)가 창마다 10~120ms 를 쓴다(마지막 데이터→공개 창에서 앱 60ms 에 관측기 43ms). #1592 Phase 4 의 수치에도 같은 몫이 섞여 있다. Phase 5 재계측 때 관측기 비용을 분리 보고하거나 관측기를 가볍게 바꾼 뒤 전·후를 같은 도구로 잰다. - 모바일 리포트 cold 는 두 분포다: 홈 진입 뒤 선조회(달력→그룹→개요 순차)가 클릭 전에 끝났으면 개요가 캐시돼 데이터→공개 ~100ms, 클릭 뒤 도착하면 개요 처리(루트 재실행 288~312 + 응답 해석 49~58)가 입력·공개 앞에 선다. #1592 의 p95 는 이 두 번째 경우다.
- invalidated(저장→세대 갱신→재공개)는 이 러너로 재현할 수 없어 미측정(#1592 와 같음).
Phase 4 — 응답 처리 계약 ③ (2026-09-15, 모바일)
적용(빌드 p4a): ① services/derivedIdentity.ts — 안정화 비교가 데이터 속성만 읽는다(접근자가 있는 객체는 참조로만). Phase 3 에서 드러난 "비교가 PR 검색 항목의 지연 계산 getter 를 읽어 루트 재실행마다 정규화 ~100ms@4x(이전 값·새 값 두 번)" 를 없앤다. ② services/screenRpcAdapters.ts — 문자열 바이트 상한 검사(requireUtf8TextAtMost)는 길이×3 ≤ 상한이면 인코딩 없이 통과, 길이 > 상한이면 인코딩 없이 초과로 판정하고 그 사이에서만 인코딩한다(응답 문자열마다 돌던 TextEncoder — 카탈로그 814KB 응답에서 119~134ms@4x). 계약 문서 그리기 비용 계약 ③. 검사: QA1 derivedIdentity.test.mjs(비교가 getter 를 읽지 않음) · renderStructureGate.test.mjs(소스 핀).
A/B 7차 (모바일, 1년, 기준 vs Phase 3 vs Phase 4(p4a) vs 실험 6(p4-lane) 동시 실행, 5표본 중앙값 ms)
| 시나리오 | 기준 → Phase 3 → Phase 4 → 실험 6 (첫 반응 / 마지막 데이터→공개 / 완성) | 읽기 |
|---|---|---|
| 리포트 cold(자연 겹침) | 625 / 222 / 1,225 → 101 / 217 / 1,154 → 71 / 214 / 867 → 98 / 218 / 948 | Phase 3 대비 첫 반응 −30%, 완성 −25%. 클릭 창(W1)의 루트 몫 400 → 5 → 2ms — 개요·카탈로그 도착 뒤 루트 재실행에서 getter 가 강제하던 계산이 사라졌다 |
리포트 겹침(report-open-collide) | 107 / 149 / 745 → 85 / 159 / 748 → 86 / 151 / 727 → 195 / 223 / 924 | Phase 4 = Phase 3 수준 유지, 첫 반응 중앙값 ≤100 충족. 클릭 창에 응답 처리 프레임이 없다 — 남은 것 = (program)·러너 관측기·react-core |
| 리포트 quiet | 72 / 231 / 688 → 85 / 216 / 605 → 72 / 215 / 638 → 72 / 161 / 689 | 변화 없음(겹침이 없으면 영향 없음) |
| 달력 진입 cold | 86 / 79 / 338 → 85 / 67 / 319 → 86 / 182 / 455 → 72 / 285 / 413 | 겹침 시점 이동: Phase 4 는 미리 받기가 더 빨리 끝나 개요 응답(604KB)이 달력의 공개 창 안에 도착한 표본이 5 중 4(기준 2, Phase 3 0~1; 표본별 RPC 완료 시각으로 확인). 겹친 표본끼리는 Phase 4 가 더 빠르다(데이터→공개 434~450 vs 기준 497~771). 화면 자체의 비용은 8차(겹침 없는 진입)로 판정 |
A/B 8차 — 달력 화면 자체 비용 (겹침 없는 진입 calendar-enter-quiet · 월 이동 calendar-month, 기준 vs Phase 3 vs Phase 4)
| 시나리오 | 기준 → Phase 3 → Phase 4 (첫 반응 / 마지막 데이터→공개 / 완성) | 읽기 |
|---|---|---|
| 달력 진입 quiet(미리 받기 끝난 뒤) | 95 / 206 / 362 → 78 / 130 / 264 → 78 / 148 / 276 | Phase 4 = Phase 3 수준(편차 안). 기준 대비 완성 −24%. 7차의 달력 cold 182 는 화면이 아니라 겹침 시점의 문제로 확정 |
달력 월 이동(calendar-month) | 53 / 118 / 403 → 41 / 67 / 288 → 36 / 69 / 286 | Phase 4 = Phase 3 수준. 기준 대비 완성 −29% |
실험 6 — 배경 요청의 채택을 "메인 스레드가 한가할 때" 로 미루기 (미채택)
내용(빌드 p4-lane = Phase 4 + 실험 6): 자원 저장소 fetch 에 background 옵션을 두고, 배경 요청(미리 받기 개요·달력 월, 힌트가 이끈 카탈로그 동기화)의 응답은 requestIdleCallback(상한 500ms) 뒤에 채택하며, 전경 소비자가 같은 키에 합류하면 즉시 채택. 결과는 악화 — 겹침 첫 반응 86 → 195ms(클릭 창 루트 몫 3 → 108ms), 완성 727 → 924; 자연 cold 완성 867 → 948; 달력 데이터→공개 182 → 285. 원인: 미루기는 큰 동기 렌더(채택 → useSyncExternalStore 구독 화면의 재렌더)를 없애지 않고 시점만 옮긴다. 상한에 걸린 idle 콜백은 일반 태스크로 실행돼 그 직후 입력을 막았고 이 시나리오에서는 그 시점이 클릭 창과 겹쳤다. 또 화면이 진행 중 요청을 구독으로만 기다리는 경우(저장소 fetch 에 합류하지 않음) 필요한 데이터가 대기만큼 늦어진다. 결론: 배경 작업이 입력 앞에 서지 않게 하는 길은 "옮기기" 가 아니라 "줄이기"(계약 ①·②·③) 또는 "메인 스레드 밖"(워커)이다. 원본 patch-e6-background-lane.diff, 테스트 experiments/e6-mainThreadQuiet.test.mjs·e6-resourceStoreBackgroundLane.test.mjs, 빌드 지문 build-fingerprint-p4-lane.json.
계획에서 뺀 것과 이유
- 개요·카탈로그 fetch→parse→검증 워커 — 미채택(보류). Phase 4 뒤 겹침 표본의 클릭 창에 응답 처리 프레임이 없고(첫 반응 중앙값 86), 남은 응답 처리 비용(어댑터 49~70 + 바이트 측정 11~25ms@4x, 큰 응답당)은 첫 반응 뒤 창(W2)에서 완성 시각을 미는 몫이다. 워커로 옮기면 그 몫을 줄일 수 있지만, fetch 까지 워커에서 하려면 supabase 전송(인증 헤더·세션 갱신·오류 분류)을 워커에 다시 구현해야 하고, 해석된 응답만 넘기는 방식은 해석(JSON.parse, 네이티브)이 메인에 남아 이득이 어댑터 몫으로 준다. Phase 5 의 20표본 판정에서 겹침 첫 반응 p95 가 100 을 넘으면 재검토한다.
- 어댑터의 바이트 재측정(JSON.stringify) 제거 — 유지. 페이로드 예산 계약이 정확한 UTF-8 JSON 바이트를 요구하고, 전송 계층(supabase-js)은 원문 크기를 주지 않는다(gzip content-length ≠ JSON 바이트). 큰 응답당 11~25ms@4x.
원문: evidence/1596-phase1/runs/profile-*-ab7.json·profile-*-ab8.json, compare.md, patch-p4a.diff, build-fingerprint-p4a.json.
Phase 5 — 판정 계측·규칙 정착 (2026-09-15, 모바일)
조건
| 항목 | 값 |
|---|---|
| 판정 도구 | #1592 Phase 4 의 판정 러너 runner.mjs(프로파일러 없음, DOM 변경 프레임 관측, 표본 20/조합) — --build-label 로 라벨 빌드를 잰다(번들 지문 대조; 워크트리 소스 일치는 기록). 합격선 대조는 summarize.mjs(입력→첫 반응 p95 ≤100 · 동기 처리 max ≤50 · 마지막 데이터→공개 p95 ≤100 · 입력→완성 p95 warm ≤100 / cold ≤2,000(1·4년)). 완전한 묶음(20/20 관측)에만 판정 |
| 빌드 | 최종 final = 앱 bc935f354ddc(Phase 4 커밋, 작업 트리 diff 없음) · 기준 baseline = 145923ff(#1592 병합 = 트랙 시작점). 같은 샌드박스·같은 fixture, 별도 포트(4596·4601), 판정 러너는 절대값 판정이므로 번갈아 재지 않고 순차 실행(A/B 차이는 Phase 2~4 의 동시 실행 프로파일이 정본) |
| fixture | 1년(260회)·4년(1,043회) — #1592 와 같은 생성기(seed 1284). 10년은 미측정: 샌드박스에서 통계 계산 단계가 제품 예산 stats_projection_statement_budget_v1('compute') = 60초를 매번 넘겨(실패 68회, 적용 0/2,668) 수렴하지 않았다. 기계가 조용한 상태에서도 같아 부하 문제가 아니다 — "0→전량 재계산이 60초 예산에 들지 않는 규모" 라는 관찰은 통계 담당 몫으로 남긴다 |
| 조합 | 계획: 최종 24(1·4년 × 3 시나리오 × cold/warm × 묶음 1·2) + 기준 12(1년 × 3 × 2 × 묶음 1·2). 실행: 최종 23/24(달력 월 이동 4년 warm 묶음 2 미실행), 기준 0/12 — 오너 결정(2026-09-15 00:20 KST, "계측은 별도 이슈로 분리")으로 나머지 계측과 p95 꼬리의 구조 개선은 #1603이 맡는다 |
| 관측기 | 판정 러너의 DOM 관측기 비용은 절대값에 포함된다(#1592 와 같은 조건). 그 몫은 Phase 2~4 프로파일 러너의 runner-observer 층으로 따로 잰 값(창마다 15~150ms@4x, 빌드에 따라 다름)을 아래 판정 읽기에 병기한다 |
판정 표 — 최종 빌드 (p95 ms, 동기 처리는 max; evidence/1596-phase1/judge/summary.md)
cold 는 첫 반응 / 동기 / 마지막 데이터→공개 / 완성, warm 은 첫 반응(= 완성) / 동기. 합격선: 첫 반응 ≤100 · 동기 ≤50 · 공개 ≤100 · 완성 warm ≤100 / cold ≤2,000.
| 시나리오 · 캐시 | 1년 묶음 1 | 1년 묶음 2 | 4년 묶음 1 | 4년 묶음 2 | 판정 |
|---|---|---|---|---|---|
| 리포트 열기 · cold | 215 / 19 / 225 / 1,004 | 285 / 18 / 254 / 1,068 | 395 / 24 / 376 / 1,335 (19/20 관측 — 표본 1개가 완성 대기 90초를 넘김, 불완전) | 388 / 21 / 417 / 1,252 | 첫 반응·공개 미달, 동기·완성 ✓ |
| 리포트 열기 · warm(재방문) | 121 / 10 | 120 / 11 | 105 / 12 | 88 / 9 | 1년 미달(20~21ms 초과) · 4년 묶음 2 합격, 묶음 1 5ms 초과 |
| 달력 진입 · cold | 92 / 38 / 172 / 472 | 90 / 39 / 180 / 479 | 91 / 34 / 42 / 338 | 90 / 39 / 41 / 346 | 1년 공개 미달(172~180) · 4년 두 묶음 합격 |
| 달력 진입 · warm | 130 / 11 | 178 / 10 | 52 / 12 | 77 / 19 | 1년 미달 · 4년 합격 |
| 달력 월 이동 · cold | 36 / 19 / 218 / 464 | 35 / 18 / 183 / 405 | 37 / 18 / 290 / 654 | 49 / 21 / 349 / 669 | 공개 미달(1년 183~218 · 4년 290~349), 첫 반응·동기·완성 ✓ |
| 달력 월 이동 · warm | 24 / 19 | 23 / 16 | 24 / 15 | 미실행 | 합격 |
읽기:
- 합격: 달력 진입 4년(cold·warm 두 묶음), 달력 월 이동 warm(1·4년), 리포트 재방문 4년 묶음 2. 동기 처리(≤50)와 cold 완성(≤2,000)은 전 조합 통과.
- 미달의 원인은 미리 받기와의 겹침 꼬리다. 리포트 cold 의 느린 표본(1년 묶음 1: 243·215·135ms / 묶음 2: 318·285·267·191ms)은 RPC 목록에 개요·카탈로그가 없다 = 클릭 전에 이미 도착해 그 처리(검증 49~70ms@4x·바이트 측정·투영·채택 뒤 재렌더·GC)가 클릭과 겹친 표본이고, 빠른 표본(50~86ms)은 개요가 클릭 뒤에 도착한 경우다(클릭이 먼저 처리되고 완성만 ~1,000ms). 달력 1년 진입의 공개 172~180 과 달력 월 이동 cold 의 공개도 같은 겹침(개요·카탈로그 응답이 공개 창 안에 도착)이다 — 4년은 응답 시각이 달라 겹치지 않아 합격한다. 계약 ①~③ 은 이 꼬리를 줄였지만(아래 전 → 후) 없애지 못했다. 실험 6(채택 미루기)이 기각된 뒤 남은 길은 큰 미리 받기의 시작을 첫 입력 뒤로 옮기는 것 또는 워커이며, #1603이 맡는다.
- 리포트 재방문(warm) 1년 120~121 은 #1592 기록 106 보다 크다 — 같은 날 기준 재계측이 없어 회귀인지 기계 상태(관측기 몫 포함)인지 판정할 수 없다(#1603).
- 판정 러너의 DOM 관측기 비용(창마다 15~150ms@4x)이 절대값에 포함된다 — warm 첫 반응처럼 작은 값에서 비중이 크다.
전 → 후 (참고 — #1592 기록 vs Phase 5 최종, p95 ms)
같은 날 기준 빌드 재계측은 #1603 으로 이관됐다. 아래는 #1592 Phase 4 기록(앱 1d88578c, 2026-09-14, 1년 묶음 2 · 4년 묶음 1)과의 비교로, 다른 날 같은 PC 에서 순차로 잰 값이라 참고용이다.
| 항목 | #1592 기록(전) 1년 · 4년 | Phase 5 최종(후) 1년 · 4년 | 읽기 |
|---|---|---|---|
| 리포트 cold 첫 반응 | 360 · 369 | 215~285 · 388~395 | 1년 −20~40%, 4년 변화 없음 |
| 리포트 cold 공개 | 515 · 574 | 225~254 · 376~417 | −50~56% · −27~34% |
| 리포트 cold 완성 | 1,252 · 1,385 | 1,004~1,068 · 1,252~1,335 | −15~20% · −4~10% |
| 리포트 재방문(warm) | 106 · 106 | 120~121 · 88~105 | 1년 +13% (판정 불가, #1603) |
| 달력 진입 cold 공개 | 165 · 34 | 172~180 · 41~42 | 변화 없음(겹침 꼬리) |
| 달력 진입 warm | 277 · 222 | 130~178 · 52~77 | −36~53% · −65~77% |
| 달력 월 이동 cold 공개 | 297 · 353 | 183~218 · 290~349 | −27~38% · 변화 없음~−18% |
| 달력 월 이동 cold 완성 | 620 · 555 | 405~464 · 654~669 | 1년 −25~35%, 4년 +18~21%(판정 불가, #1603) |
| 달력 월 이동 warm | 38 · 37 | 23~24 · 24 | −35% |
같은 날 동시 실행 A/B(프로파일 러너, 5표본 중앙값)의 전 → 후는 Phase 2~4 절의 표가 정본이다(리포트 자연 cold 첫 반응 625 → 71, 완성 1,225 → 867 · 달력 진입 quiet 완성 362 → 276 · 달력 월 이동 완성 403 → 286).
규칙 정착
- 계약 문서 그리기 비용 계약 ①·②·③ 확정. 입력·공개 계약 "검증·개발 완료 조건" 에 그리기 비용 게이트 문단(규칙 3개·QA1 검사 이름) 추가 — 새 코드가 따를 규칙과 그것을 잠근 검사가 한 곳에서 읽힌다.
- 게이트: QA1
derivedIdentity.test.mjs·exerciseSynonymMatcherLazy.test.mjs·reportSectionIdentity.test.mjs·renderStructureGate.test.mjs— 앱npm run check의 단위 묶음에 포함(QA1 고정f2de2849, #1598 suite1117c373병합). 성능 러너 자체는npm run check에 넣지 않는다(브라우저·샌드박스·fixture 가 필요) — 정기 실행은 이 문서의 절차(evidence README "재현")로 한다. - 작업 기록 2026-09-15-render-structure-1596.md.