렌더 구조 개선 — 응답이 올 때마다 화면을 통째로 다시 계산하던 구조에서 원천당 1회 계산·섹션 단위 그리기·계산 없는 비교까지 (2026-09-15)
- 기간: 2026-09-14 ~ 2026-09-15 (세션 2 — 이슈·5-Phase 계획은 #1592 세션
ccc9d751-3fd7-4472-b59a-308316f50038, Phase 1~5 는 세션b62bd6a9-3c52-4853-8c1d-a11c9f5692fe). 오너 지시 원문: "이슈 1596 진행해줘" → "디자인 원칙 따라서, 나한테 뭐 물어보지말고 끝까지 완주" → "일단 데스크톱 관련된거는 다 빼고, 모바일만 대상으로 다시 계획해서 진행해줘"(2026-09-14 22:40 KST). - 랜딩: 앱
feat/1596-render-structure— Phase 2d41671be→ release/v0.19.0 병합add4dd31→ QA1 고정6b7d5f9b→ Phase 33aa79098→ Phase 4bc935f35→ Phase 5 release/v0.19.0 병합588d0855(충돌 3파일 해소) → PR #1605 → release/v0.19.0 큐 병합0b004030(2026-09-15 00:45 KST, 큐 실행 34862736616). 문서 PR dekerd/Barbelic-docs#89df5b1e66. 마이그레이션·엣지 함수 없음. QA1qa1/1596-render-structure최종f2de2849(릴리스가 고정한 #1598 suite1117c373병합). Vercel 배포는 release 승격 뒤(이 트랙의 추적 범위 밖). - 설계서: 이슈 #1596 본문(5-Phase 계획·성공 기준·"예상 효과·개선사항" 표) + 재계획 댓글(모바일 전용, 2026-09-14 22:40).
- 정본: 그리기 비용 계약 ①·②·③ ·
src/react/services/derivedIdentity.ts(내용이 같으면 같은 참조 · 지연 파생 · 접근자는 읽지 않는 비교) ·src/react/app/useStableProviderValue.ts·src/react/ui/shared/useStableCallback.ts·src/react/ui/mobile/screens/ReportScreen.tsx(섹션 5개) ·src/react/services/screenRpcAdapters.ts(requireUtf8TextAtMost인코딩 없는 상한 판정). - 도구: 앱 작업 트리
test-results/phase1596/(gitignored) — 라벨별 production 빌드·지문(build.mjs), 소스맵 CPU 프로파일 러너(profile-runner.mjs·profile-analysis.mjs), 같은 시간대 A/B(profile-matrix.sh·compare.mjs), 판정 러너(#1592runner.mjs+--build-label,judge-matrix.sh·summarize.mjs), 시나리오(report-open-collide·*-quiet), 실험 스크립트. 사본과 원본 수치는evidence/1596-phase1/. - 게이트: QA1
derivedIdentity.test.mjs(계약 ①·③ 행동) ·exerciseSynonymMatcherLazy.test.mjs(색인 지연) ·reportSectionIdentity.test.mjs(상세 병합의 참조 유지) ·renderStructureGate.test.mjs(수리 구조 소스 핀) — 앱npm run check단위 묶음에 포함. 이관 원본 3건(appContainer·workoutFinishAutoSave·backgroundTokens)은 adaptation 사유 등재. 마이그레이션·pgTAP·e2e 변경 없음(precheck 는 verify 단계). 최종npm run check(release 병합본588d0855, QA1f2de2849): 정적 검사 13종 통과 + 단위 총 4,033 / 성공 3,884 / 실패 0 / 건너뜀 149(건너뜀은 통과로 세지 않음). - 버그리포트: 없음(오너 보고 결함이 아니라 #1592 계측이 남긴 미달 항목의 구조 수리).
- 계약: render-cost-contracts.md 신설. 입력·공개 계약 "검증·개발 완료 조건"에 그리기 비용 게이트(규칙 3개·QA1 검사 이름) 추가. 판정·근거는 검증 기록.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 원인 분해 실험 — 소스맵 CPU 프로파일로 창별 비용을 층으로 나누고 실험 4개(E1·E1b·E2·E4)를 같은 시간대 A/B 로 비교, 채택안 확정 | ✅ 문서 88915de·e5212a3 |
| Phase 2 | 계약 ① 파생 데이터는 원천 스냅샷당 1회·provider 값은 내용이 같으면 같은 참조(카탈로그 파생 목록 memo, 색인·PR 검색 항목 지연, 명령 묶음 1회, 컨텍스트 값 15개 안정화) | ✅ 앱 d41671be(+release 병합 add4dd31·QA1 고정 6b7d5f9b) |
| Phase 3 | 계약 ② 모바일 리포트를 섹션 5개로 나누고 섹션은 자기 입력만 받는다(콜백 참조 고정, 상세 병합의 참조 유지 검사). 실험 5(content-visibility) 미채택 | ✅ 앱 3aa79098 · QA1 6c14ae28 · 문서 a32e004 |
| Phase 4 | 계약 ③ 비교는 계산을 유발하지 않고(getter 를 읽지 않음), 응답 문자열 상한 검사는 인코딩 없이 판정. 실험 6(배경 채택 미루기) 계측 악화로 미채택, 워커는 보류 | ✅ 앱 bc935f35 · QA1 d256492a · 문서 b007d18 |
| Phase 5 | 판정 계측(#1592 러너 20표본, 최종 빌드 모바일 1·4년 23조합 — 합격선 표 판정 기록), 오너 결정으로 나머지 계측과 p95 꼬리 개선은 #1603으로 분리, 규칙 문서(계약 확정·입력·공개 계약 게이트 문단), 릴리스 병합, 이 기록 | ✅ 앱 588d0855 → PR #1605 → release 병합 0b004030 · QA1 f2de2849 · 문서 #89 df5b1e66 |
1. 배경
#1592(반응성 재설계)는 "무엇을 언제 보여줄지"를 정하고 4배 느린 CPU 에서 1·4·10년 규모를 재 봤다. 규칙(입력 즉시 반응, 완성본만 공개)은 지켜졌지만 모바일에서 세 항목이 합격선(100ms) 아래로 내려오지 않았다: 마지막 데이터가 도착한 뒤 완성 공개까지, 유효 캐시가 있는 재방문, cold 첫 반응. #1592 세션은 그 원인을 "응답이 도착할 때마다 앱 조립 루트가 다시 실행되고, 화면 하나가 통째로 다시 계산되고, 큰 응답 처리가 메인 스레드를 차지한다"는 세 가설로 남기고 이 이슈를 만들었다. 오너는 계획을 보고 "디자인 원칙대로, 묻지 말고 끝까지" 진행하라 했고, Phase 1 뒤 "데스크톱은 전부 빼고 모바일만" 으로 범위를 좁혔다(D1).
2. 문제 제기
종목 목록이 응답마다 새 배열로 만들어져, 그것을 입력으로 삼는 계산이 화면마다 처음부터 다시 돌았다
유저 A 가 앱을 켜고 곧바로 리포트를 누르면, 홈이 뜨는 사이 앱은 달력·그룹·리포트 개요(604KB)·카탈로그(814KB)를 미리 받아 둔다. 응답이 도착하는 순간 조립 루트가 다시 실행되는데, 종전에는 lgExerciseCatalogForWorkout(gymData)가 매 렌더 새 배열을 만들어 동의어 색인(105ms@4x)·PR 검색 항목·편집기 포트 등 소비자 memo 가 전부 풀렸다. 개요 도착 뒤 루트 재실행 288~312ms(Phase 1 관측 1). 컨텍스트 값 15개 중 12개가 매 루트 재실행마다 새 객체였다.
리포트 화면 하나(600줄)가 마지막 응답에 통째로 다시 계산돼 730칸 잔디까지 다시 그렸다
선택 기간 상세가 도착하면 총 볼륨·순위·육각형·분포·성장표만 바뀌는데 잔디·시간대까지 다시 그렸다. 개요가 클릭 뒤 도착한 표본에서 마지막 데이터→공개 657ms(Phase 1 E2 대조).
응답 검증이 문자열마다 인코딩을 돌리고, 비교가 계산을 강제했다
카탈로그 응답 검증에서 문자열마다 TextEncoder 119~134ms@4x, 바이트 측정 JSON.stringify 11~25ms. Phase 2 가 넣은 provider 값 안정화의 비교(shallowEqualDerived)는 평범한 객체를 한 단계 비교하면서 검색 컨트롤러 값의 items(첫 요청 때 계산하는 getter)를 읽어 검색 항목 정규화를 이전 값·새 값 두 번 강제했다(~100ms@4x 씩, Phase 3 겹침 표본 W1 에서 발견).
측정 자체의 함정 — 빨라진 빌드가 겹침 시나리오에서 느려 보였다
미리 받기가 일찍 끝나면 개요·카탈로그 응답 처리가 다음 화면의 공개 창 안에 들어와 "느려진 것처럼" 찍힌다(Phase 2 달력 91→478, Phase 4 달력 67→182). 표본별 RPC 완료 시각으로 겹침을 확인하고, 겹침 없는 *-quiet 시나리오와 매번 겹치는 report-open-collide 로 통제했다.
3. 해결 방안
원칙 (오너 결정)
- D1 (2026-09-14 22:40): "데스크톱 관련된거는 다 빼고, 모바일만 대상으로" — PC 판 유지(E1b)·PC 탭 상태 루트 밖 구독·
DkBarChart강제 layout·PC 계측·PC 재마운트는 범위 밖으로 남겼다(검증 기록 Phase 1 "범위 밖 — 오너 결정"). - 설계 원칙(AGENTS.md 23개): 근본 구조 개선(땜질 금지) — 원인이 "구조라서 가능했던 것"이면 계약으로 고정하고 검사로 잠근다. 필요 최소(단순성) — 실험으로 효과가 확인되지 않은 것은 채택하지 않는다.
접근
"상태가 바뀌면 누가 얼마나 다시 계산·그려지는가"를 계약 3개로 정하고, 각 계약을 정본 모듈 + QA1 행동 검사 + 소스 핀으로 잠갔다. 모든 후보는 되돌릴 수 있는 패치로 빌드해 기준과 같은 시간대에 번갈아 재는 A/B 로 비교했다(같은 PC 의 다른 세션 부하가 한쪽만 느리게 만든 첫 묶음은 폐기).
| 대안 | 판단 |
|---|---|
| 계약 ① 파생 데이터는 원천 스냅샷당 1회 + provider 값은 내용이 같으면 같은 참조(E4 + E1) | 채택(Phase 2). E4 단독으로 PC 달력 데이터→공개 −68%, 모바일 달력 월 이동 cold 110→51 |
| provider identity 안정화만(E1) | 단독 효과 0 — 파생 데이터가 매번 새로 만들어지면 참조 안정화는 무의미. ①의 일부로만 채택 |
| 계약 ② 화면을 섹션으로 나누고 섹션은 자기 입력만(E2 확장) | 채택(Phase 3). 겹침 열기 완성 637→491(1차)·724→642(2차) |
실험 5 화면 밖 섹션 content-visibility: auto | 기각 — 표본 엇갈림(잡음 안), 관측기 몫만 줄어듦, 스크롤·찾기 부작용 위험 |
| 계약 ③-1 비교는 계산을 유발하지 않는다 / ③-2 인코딩 없는 상한 판정 | 채택(Phase 4). 리포트 자연 cold 첫 반응 101→71, 완성 1,154→867 |
| 실험 6 배경 응답의 채택을 "한가할 때"로 미루기 | 기각 — 겹침 첫 반응 86→195 악화. 큰 동기 렌더를 없애지 않고 시점만 옮겨 입력과 다시 겹침 |
| 개요·카탈로그 fetch→parse→검증 워커 | 보류 — Phase 4 뒤 클릭 창에 응답 처리가 남지 않아 이득이 어댑터 몫(49~70ms@4x)으로 줌; 인증·오류 분류 재구현 비용. Phase 5 p95 로 재검토 조건 명시 |
| PC 판 유지(E1b)·PC 탭 루트 밖 구독·차트 ResizeObserver | 범위 밖(D1) |
4. 적용한 내용
Phase 1 — 원인 분해 실험 (문서 88915de·e5212a3)
소스맵 production 빌드 + CPU 프로파일(500µs) + 타임라인 트레이스로 창(입력→첫 반응 / 첫 반응→마지막 데이터 / 마지막 데이터→공개)마다 스택 맨 위 함수의 자기 시간을 층(react-core / app:ui / binding·controller / app:service / native / 러너 관측기)으로 나눴다. 실험 E1(provider 안정화)·E1b(PC 판 유지)·E2(잔디 memo)·E4(파생 데이터 memo + 색인 지연)를 같은 시간대 A/B 로 비교해 채택안을 정했다. 이 단계에서 #1592 러너의 DOM 관측기가 창마다 10~120ms 를 쓴다는 것을 알았다.
Phase 2 — 계약 ① (앱 d41671be, QA1 04c12944)
services/derivedIdentity.ts 신설(shallowEqualDerived·stabilizeProviderValue·createLazyDerivation), app/useStableProviderValue.ts 로 컨텍스트 값 15개 안정화, catalogBinding 의 파생 목록 memo, 동의어 매처 색인·PR 검색 항목의 첫 질의 때 계산, 운동 명령·import 명령·정책 바인딩 요소 1회 생성, 빈 목록 상수. release/v0.19.0(#1592 병합 7cf942d1)을 합치고 QA1 을 그 위로 옮겼다.
Phase 3 — 계약 ② (앱 3aa79098, QA1 6c14ae28)
ReportScreen 을 잔디·성장·훈련 종목/부위·강도·꾸준함 5개 React.memo 섹션으로 나누고(순위 기준·모달 상태는 섹션 안), yDays 의존을 쓰는 필드로 좁히고, 콜백은 useStableCallback 으로 참조 고정. 분리 전·후 정적 마크업이 6개 상태에서 문자 단위로 같음을 확인(markup-diff.mjs). 상세 병합이 바꾸지 않는 필드의 참조를 유지하는지 검사(reportSectionIdentity.test.mjs).
Phase 4 — 계약 ③ (앱 bc935f35, QA1 d256492a)
shallowEqualPlain 이 속성 기술자로 데이터 속성만 비교하고 접근자가 있는 객체는 참조로만 본다. requireUtf8TextAtMost 가 길이×3 ≤ 상한이면 인코딩 없이 통과, 길이 > 상한이면 초과, 그 사이만 인코딩한다. 실험 6(자원 저장소 background 차선 + mainThreadQuiet)은 계측 악화로 되돌리고 패치·테스트를 증거로만 남겼다.
Phase 5 — 판정 계측·규칙 정착 (앱 588d0855, QA1 f2de2849)
#1592 의 판정 러너에 라벨 빌드 지원(--build-label)을 더해 최종 빌드(bc935f35)를 모바일 1·4년 × 리포트 열기/달력 진입/달력 월 이동 × cold/warm × 묶음 1·2 로 재 봤다(24조합 중 23 완료). 기준 빌드 같은 날 재계측(12조합)은 시작 전에 오너 결정으로 #1603 에 넘겼다. 계약 문서 ①·②·③ 확정, 입력·공개 계약 "검증·개발 완료 조건"에 그리기 비용 게이트 문단(규칙 3개·QA1 검사 이름) 추가. release/v0.19.0(#1598 월간 카드·내 피드, #1600 데스크톱 온보딩)을 병합하고 충돌 3파일(조립 루트의 내 피드 필드 6개를 안정화된 provider 값에 추가 · 리포트 화면의 월간 카드 조건 조각 안에 memo 섹션 유지 · QA1 고정)을 해소했다. 10년 fixture 는 샌드박스 통계 계산 단계가 예산 60초를 매번 넘겨 수렴하지 않아 미측정.
주요 결정과 그 근거
- 계약을 코드 위치가 아니라 규칙으로 적었다 — "파생 데이터는 원천 스냅샷당 1회", "섹션은 자기 입력만", "비교는 계산을 유발하지 않는다". 같은 종류의 비용이 다른 화면에서 다시 나면 계약 위반으로 잡히도록 QA1 소스 핀(
renderStructureGate.test.mjs)이 수리 구조를 고정한다. - 효과가 계측으로 확인되지 않은 것은 넣지 않았다 — 실험 5·6 은 코드가 "그럴듯" 했지만 A/B 가 기각했다. 특히 실험 6 은 "미루면 안 겹친다"는 직관이 틀렸음을 보여 준다: 큰 동기 렌더는 옮겨도 어딘가의 입력과 겹친다.
- 워커는 보류 조건을 수치로 남겼다 — Phase 5 판정에서 겹침 첫 반응 p95 가 100 을 넘으면 재검토.
작업 중 드러난 것
- Phase 2 의 안정화가 Phase 3 계측에서 새 비용(getter 강제 계산)을 드러냈다 — 안정화 비교 자체가 계산을 유발할 수 있다는 점은 계약 ③ 규칙이 됐다.
- 빨라진 빌드가 겹침 시나리오에서 느려 보이는 함정이 두 번(Phase 2 달력, Phase 4 달력) 나왔다. 판정은 겹침 없는 시나리오로, 겹침은
report-open-collide로 매번 재현해서 본다. - 러너 관측기 비용(창마다 15~150ms, 빌드에 따라 다르게)이 절대값에 섞인다 — 해석기가
runner-observer층으로 분리한다. #1592 Phase 4 수치에도 같은 몫이 있다. - 10년 fixture 는 통계 계산 단계가 제품 예산 60초를 매번 넘겨 수렴하지 않았다(68회 실패, 적용 0/2668) — Phase 5 에서 미측정으로 기록. "0→전량 재계산이 60초 예산에 들지 않는 규모"라는 관찰은 통계 담당 몫으로 남긴다.
- QA1
prepare는 앱tests/에 지역 수정 사본이 있으면 거절한다 — 확인용으로 복사한 테스트는npm run check전에 지운다.
5. 적용 결과
모두 4배 느린 CPU·RTT +100ms, 모바일, 로컬 샌드박스 fixture. 실기기·Production 계측은 없다(#1592 와 같음).
같은 날 동시 실행 A/B (프로파일 러너, 1년, 5표본 중앙값 ms — Phase 2~4 의 채택 근거)
| 항목 | 전(기준 145923ff) → 후(Phase 4 bc935f35) | 근거 |
|---|---|---|
| 리포트 자연 cold 첫 반응 / 완성 | 625 → 71 / 1,225 → 867 | A/B 7차. 클릭 창의 루트 몫 400 → 2ms |
리포트 겹침 열기(report-open-collide) 첫 반응 / 완성 | 107 → 86 / 745 → 727 | A/B 7차 |
| 리포트 quiet 데이터→공개 / 완성 | 231 → 215 / 688 → 638 | A/B 7차(남은 비용 = 한 프레임 style/layout/paint + 관측기) |
| 달력 진입 quiet 데이터→공개 / 완성 | 206 → 148 / 362 → 276 | A/B 8차 |
| 달력 월 이동 cold 데이터→공개 / 완성 | 118 → 69 / 403 → 286 | A/B 8차 |
| 리포트 상세 도착 시 다시 그리는 섹션 | 화면 전체(730칸 잔디 포함) → 상세가 바꾸는 섹션만 | Phase 3 마크업 대조·reportSectionIdentity.test.mjs |
| 루트 재실행마다 검색 항목 정규화 | 2회(~100ms@4x씩) → 0회 | Phase 4-1 derivedIdentity.test.mjs |
판정 계측 (#1592 러너 20표본 p95 ms, 최종 빌드, 23조합 — 합격선 첫 반응 ≤100 · 동기 ≤50 · 공개 ≤100 · 완성 warm ≤100 / cold ≤2,000)
| 시나리오 · 캐시 | 1년(묶음 1 / 2) | 4년(묶음 1 / 2) | 판정 |
|---|---|---|---|
| 리포트 열기 cold (첫 반응 / 공개 / 완성) | 215 / 225 / 1,004 · 285 / 254 / 1,068 | 395 / 376 / 1,335(19/20 불완전) · 388 / 417 / 1,252 | 첫 반응·공개 미달, 동기·완성 ✓ |
| 리포트 재방문 warm | 121 · 120 | 105 · 88 | 1년 미달(20ms 초과), 4년 묶음 2 합격 |
| 달력 진입 cold (첫 반응 / 공개 / 완성) | 92 / 172 / 472 · 90 / 180 / 479 | 91 / 42 / 338 · 90 / 41 / 346 | 1년 공개 미달, 4년 합격 |
| 달력 진입 warm | 130 · 178 | 52 · 77 | 1년 미달, 4년 합격 |
| 달력 월 이동 cold (첫 반응 / 공개 / 완성) | 36 / 218 / 464 · 35 / 183 / 405 | 37 / 290 / 654 · 49 / 349 / 669 | 공개 미달, 나머지 ✓ |
| 달력 월 이동 warm | 24 · 23 | 24 · 미실행 | 합격 |
미달 항목의 원인은 한 가지다: 홈 진입 뒤 미리 받는 개요(604KB)·카탈로그(814KB) 응답 처리가 첫 탭 직전에 끝나는 표본이 p95 꼬리를 만든다(느린 표본은 RPC 목록에 개요·카탈로그가 클릭 전에 이미 있고, 빠른 표본은 클릭 뒤 도착). 계약 ①~③ 은 이 꼬리를 줄였지만 없애지 못했고, 실험 6(채택 미루기)은 악화였다. #1592 기록과의 참고 비교(다른 날, 순차): 리포트 cold 공개 515 → 225~254, 달력 진입 warm 277 → 130~178, 달력 월 이동 cold 공개 297 → 183~218; 리포트 재방문 1년은 106 → 120~121 로 커졌는데 같은 날 기준 재계측이 없어 회귀인지 기계 상태인지 판정할 수 없다. 기준 빌드 같은 날 재계측·4년 묶음 2·10년·재방문 원인 분해와 꼬리의 구조 개선은 #1603.
미검증: 실기기, Production, invalidated(저장→세대 갱신→재공개) 모드, 10년 fixture.
6. 이번 개선으로 향상된 것
앱을 켜고 바로 리포트를 눌러도 손가락이 기다리지 않는다
홈이 뜨는 사이 미리 받은 개요·카탈로그가 도착해도 조립 루트는 종목 목록·색인·검색 항목을 다시 만들지 않고, 리포트는 마지막 응답이 바꾸는 섹션만 다시 그린다. 4배 느린 CPU 기준 리포트 자연 cold 첫 반응 625 → 71ms, 완성 1,225 → 867ms(1년, 5표본 중앙값; 판정 p95 는 5절).
구조적으로 남는 것
- 그리기 비용 계약 ①·②·③ — 파생 데이터·섹션·비교의 규칙과 정본 모듈(
derivedIdentity.ts·useStableProviderValue·useStableCallback). - QA1 게이트 4개(행동 3 + 소스 핀 1)가
npm run check에 포함돼 수리 구조가 되돌아가면 실패한다. - 계측 도구·절차(라벨 빌드·같은 시간대 A/B·겹침 통제 시나리오·판정 러너 라벨 지원)와 원본 수치가 문서 저장소에 남아 다음 성능 트랙이 같은 방법으로 전·후를 잰다.
남은 것
- #1603 미리 받기 응답과 첫 탭이 겹치는 꼬리의 판정 계측과 구조 개선(오너 결정 2026-09-15 00:20, "계측은 별도 이슈로 분리"): 기준 빌드 같은 날 재계측·4년 묶음 2·리포트 재방문 120 의 원인 분해, 큰 미리 받기의 시작을 첫 입력 뒤로 옮기는 안, 응답 검증·투영의 워커(재검토 조건 충족: 겹침 첫 반응 p95 215~395 > 100), 관측기 비용 분리 보고.
- 데스크톱 항목 전부(D1 로 범위 밖): PC 판 유지(E1b, 재마운트 0 이지만 139ms 잔존), PC
tab루트 밖 구독,DkBarChart강제 layout(88ms) → ResizeObserver. - 10년 fixture 미측정(샌드박스 통계 계산 단계가 예산 60초를 넘겨 수렴 불가 — 통계 담당에게 "0→전량 재계산이 60초 예산에 안 드는 규모" 관찰 전달). invalidated(저장→세대 갱신→재공개) 모드는 러너로 재현 불가(#1592 와 같음).