Skip to content

렌더 구조 개선 — 응답이 올 때마다 화면을 통째로 다시 계산하던 구조에서 원천당 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 2 d41671be → release/v0.19.0 병합 add4dd31 → QA1 고정 6b7d5f9b → Phase 3 3aa79098 → Phase 4 bc935f35 → 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#89 df5b1e66. 마이그레이션·엣지 함수 없음. QA1 qa1/1596-render-structure 최종 f2de2849(릴리스가 고정한 #1598 suite 1117c373 병합). 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), 판정 러너(#1592 runner.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, QA1 f2de2849): 정적 검사 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 → 867A/B 7차. 클릭 창의 루트 몫 400 → 2ms
리포트 겹침 열기(report-open-collide) 첫 반응 / 완성107 → 86 / 745 → 727A/B 7차
리포트 quiet 데이터→공개 / 완성231 → 215 / 688 → 638A/B 7차(남은 비용 = 한 프레임 style/layout/paint + 관측기)
달력 진입 quiet 데이터→공개 / 완성206 → 148 / 362 → 276A/B 8차
달력 월 이동 cold 데이터→공개 / 완성118 → 69 / 403 → 286A/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,068395 / 376 / 1,335(19/20 불완전) · 388 / 417 / 1,252첫 반응·공개 미달, 동기·완성 ✓
리포트 재방문 warm121 · 120105 · 881년 미달(20ms 초과), 4년 묶음 2 합격
달력 진입 cold (첫 반응 / 공개 / 완성)92 / 172 / 472 · 90 / 180 / 47991 / 42 / 338 · 90 / 41 / 3461년 공개 미달, 4년 합격
달력 진입 warm130 · 17852 · 771년 미달, 4년 합격
달력 월 이동 cold (첫 반응 / 공개 / 완성)36 / 218 / 464 · 35 / 183 / 40537 / 290 / 654 · 49 / 349 / 669공개 미달, 나머지 ✓
달력 월 이동 warm24 · 2324 · 미실행합격

미달 항목의 원인은 한 가지다: 홈 진입 뒤 미리 받는 개요(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 와 같음).