Skip to content

그리기 비용 계약

이슈 #1596, 2026-09-14. 적용 대상은 Barbelic 모바일 앱(오너 결정 2026-09-14: 데스크톱 항목은 이 트랙 범위 밖). 입력·공개 계약이 "무엇을 언제 보여줄지"를 정한 위에, 이 문서는 "상태가 바뀌면 누가 얼마나 다시 계산·그려지는가"를 정한다. 합격선은 입력·공개 계약 §성능 합격 기준 그대로다. 판정과 근거는 검증 기록.

왜 필요한가 (사용자 이야기)

유저 A 가 앱을 켜고 곧바로 리포트를 누른다. 홈이 뜨는 사이 앱은 달력·그룹·리포트 개요(604KB)를 미리 받아 두는데, 그 응답이 도착하는 순간 앱 조립 루트가 다시 실행된다. 종전에는 그때마다 종목 목록이 새 배열로 다시 만들어지고, 그 목록을 쓰는 화면들이 검색 색인 같은 무거운 계산을 처음부터 다시 했다(4배 느린 CPU 에서 288~312ms). A 의 손가락은 그 계산이 끝날 때까지 기다린다. 이 계약은 "같은 원천이면 다시 계산하지 않는다"를 규칙으로 만들어, 응답이 언제 도착하든 입력이 밀리지 않게 한다.

계약 ① — 파생 데이터는 원천 스냅샷당 한 번, 값은 내용이 같으면 같은 참조 (Phase 2)

규칙내용정본·검사
파생 목록은 원천 identity 당 1회카탈로그 파생 목록(lgExerciseCatalogForWorkout)은 원천 배열(gymData.EXERCISES)이 바뀔 때만 다시 만든다. 렌더마다 만들면 그것을 입력으로 삼는 모든 memo 가 풀린다features/catalog/catalogBinding.ts · 소스 핀 renderStructureGate.test.mjs
색인·검색 항목은 첫 질의 때동의어 매처(createExerciseSynonymMatcher)의 색인, PR 검색 항목(buildPrExerciseSearchItems)은 만들기만 해서는 계산하지 않고 첫 matches()·첫 목록 요청 때 계산한다. 입력 참조가 같으면 다시 계산하지 않는다exerciseSynonymSearch.ts · controllers/searchController.ts(createLazyDerivation) · exerciseSynonymMatcherLazy.test.mjs · derivedIdentity.test.mjs
명령 묶음은 인스턴스당 1회운동 명령(activeWorkoutCommands)·import 명령·정책 바인딩 요소(screenPolicyBinding)는 한 번 만들고, 호출 시 최신 구현으로 위임한다(latest-ref)activeWorkoutDispatchBinding.ts · importFeatureBinding.ts · appController.tsx(screenPolicy·screenPolicyBinding memo)
provider 값은 내용이 같으면 같은 참조feature 컨텍스트 값 15개는 훅 본문에서 useStableProviderValue 로 통과시킨다: 바뀐 필드만 새 값, 내용 같은 배열·평범한 객체·요소 모양은 이전 참조, 함수 필드는 한 번 만든 대리 함수(최신 함수로 위임). 아무것도 바뀌지 않으면 같은 객체services/derivedIdentity.ts(정본, React 무관) · app/useStableProviderValue.ts · derivedIdentity.test.mjs
빈 값은 고정 상수`

의미 보존: 이 계약은 계산 시점과 참조 identity 만 바꾼다. 계산 결과·저장 의미·공개 판정(pending/ready/error)은 그대로다. 대리 함수는 항상 마지막 렌더의 함수를 부르므로, 함수 참조 변화에 기대어 효과를 다시 돌리던 코드가 있으면 그 기대는 성립하지 않는다(그런 의존은 원천 값 의존으로 바꾼다).

계약 ② — 화면은 섹션 단위, 섹션은 자기 입력만 (Phase 3)

유저 A 의 리포트에 마지막 응답(선택 기간 상세)이 도착하면 총 볼륨·운동일·순위·육각형·분포·성장표는 바뀌지만, 잔디(하루별 기록 여부)와 시간대는 바뀌지 않는다. 종전에는 화면 하나(600줄)가 통째로 다시 계산돼 730칸 잔디까지 다시 그렸다(4배 CPU 에서 개요가 클릭 뒤 도착한 경우 마지막 데이터→공개 657ms). 이 계약은 화면을 섹션으로 나누고 섹션이 자기 입력만 받게 해, 마지막 응답이 바꾸는 섹션만 다시 그리게 한다.

규칙내용정본·검사
섹션은 자기 입력만 받는 memo 컴포넌트모바일 리포트: 잔디(RpGrassSection: days·yDays·unit·lens) · 성장(RpGrowthSection: growth·즐겨찾기 수) · 훈련 종목·부위(RpTrainedSection: byExercise·byPart·byFunc; 순위 기준·기타 모달·카테고리 모달 상태는 섹션 안) · 강도(RpIntensitySection: 분포 4종·eStats·intensity) · 꾸준함(RpConsistencySection: durZones·slots). 섹션에 report 전체를 넘기지 않는다ui/mobile/screens/ReportScreen.tsx · 소스 핀 renderStructureGate.test.mjs
바뀌지 않은 입력은 같은 참조상세 병합(applyReportDesignDetail)은 바꾸는 필드만 새 값이고 days·slots·rangeLabel·rangeText·fStats·intensity 등은 참조를 유지한다 — 그래야 섹션 memo 가 성립한다. 파생 memo 의존은 쓰는 필드로 좁힌다(yDays: r.days·r.rangeText·unit)services/mobileReportViewMappers.ts · reportSectionIdentity.test.mjs
섹션이 받는 콜백은 참조 고정컨테이너가 인라인 람다로 넘기는 콜백(종목·날짜·주차 열기)은 useStableCallback 으로 감싼다 — 호출은 최신 콜백으로, 참조는 고정. undefined 는 undefined 로 유지(조건부 상호작용 보존)ui/shared/useStableCallback.ts
마크업 불변섹션 분리는 렌더 경계만 바꾼다. 분리 전·후 정적 마크업이 문자 단위로 같아야 한다(디자인 불변, 이슈 #1596 Phase 3 에서 6개 상태로 대조)evidence/1596-phase1/experiments/markup-diff.mjs

범위: 모바일 리포트. 달력(SessionScreen)은 마지막 데이터→공개의 남은 비용이 화면 렌더가 아니라 루트 재실행·응답 매핑·한 프레임의 layout/paint 라 이 계약의 적용 대상이 아니다(검증 기록 Phase 3). PC 화면은 트랙 범위 밖(오너 결정 2026-09-14).

계약 ③ — 응답 처리는 계산을 늘리지 않는다: 비교는 계산을 유발하지 않고, 검증은 인코딩 없이 판정한다 (Phase 4)

유저 A 가 홈에 들어오면 앱은 달력·그룹·리포트 개요(604KB)·종목 카탈로그(814KB)를 미리 받아 둔다. 그 응답이 A 의 첫 탭과 같은 순간에 도착하면 응답 처리가 탭의 첫 반응 앞에 선다. Phase 3 까지의 실측에서 그 처리 안에 "안 해도 되는 계산" 두 가지가 있었다: ① provider 값 안정화의 비교가 검색 컨트롤러 값의 items(첫 요청 때 계산하는 getter)를 읽어 검색 항목 정규화를 이전 값·새 값 두 번 강제(~100ms@4x씩) ② 응답의 문자열마다 TextEncoder 로 바이트를 세는 상한 검사(카탈로그 응답에서 119~134ms@4x). 이 계약은 그 둘을 규칙으로 막는다. 처리를 "한가할 때로 미루는" 방식은 실험 6 에서 악화로 확인돼 채택하지 않았다(검증 기록 Phase 4).

규칙내용정본·검사
비교는 계산을 유발하지 않는다안정화 비교(shallowEqualDerived)는 데이터 속성만 읽는다(속성 기술자로 본다). 접근자(getter/setter)가 있는 객체는 참조로만 본다 — 첫 요청 때 계산하는 값(PR 검색 항목 목록 등)이 비교 때문에 계산되지 않는다. 지연 파생(계약 ①)을 드는 객체를 provider 값에 실을 때의 전제services/derivedIdentity.ts · derivedIdentity.test.mjs
응답 검증은 인코딩 없는 상한 판정 먼저문자열 바이트 상한 검사(requireUtf8TextAtMost)는 길이×3 ≤ 상한이면 통과, 길이 > 상한이면 초과로 판정하고 그 사이에서만 실제로 인코딩한다(UTF-16 유닛 1개 = UTF-8 1~3바이트, 대리쌍은 2유닛 = 4바이트). 판정 결과는 종전과 같다services/screenRpcAdapters.ts · 소스 핀 renderStructureGate.test.mjs
배경 응답의 채택을 미루지 않는다자원 저장소는 응답을 받으면 종전대로 즉시 채택한다. "한가할 때" 로 미루는 차선(실험 6)은 큰 동기 렌더를 없애지 않고 시점만 옮겨 입력과 다시 겹쳤고(겹침 첫 반응 86 → 195ms), 구독으로만 기다리는 화면의 데이터를 늦췄다resources/resourceStore.ts(미루기 없음, 소스 핀)

의미 보존: 계산 결과·판정 결과·채택 시점은 종전과 같다. 바뀐 것은 "하지 않아도 같은 결과가 나오는 계산" 을 하지 않는 것뿐이다. 남은 응답 처리 비용(어댑터 49~70 + 페이로드 바이트 측정 11~25ms@4x, 큰 응답당)은 첫 반응 뒤 창에서 완성 시각을 미는 몫이며, 메인 스레드 밖(워커)으로 옮기는 안은 Phase 5 의 20표본 판정 뒤 재검토한다(검증 기록 "계획에서 뺀 것과 이유").

검증

  • 단위(QA1 suite): 계약 ① derivedIdentity.test.mjs(정본 행동) · exerciseSynonymMatcherLazy.test.mjs(색인 지연) · 계약 ② reportSectionIdentity.test.mjs(상세 병합의 참조 유지·필터 목록·멱등) · 소스 핀 renderStructureGate.test.mjs(계약 ①·② 의 수리 구조). npm run check 에 포함.
  • 계측: #1592 러너 + 프로파일 러너(evidence/1596-phase1). 겹침 사례는 report-open-collide(개요 요청 시작 직후 클릭), 조용한 열기는 report-open-quiet·calendar-enter-quiet(미리 받기가 끝난 뒤 클릭)로 잰다. 판정·수치는 검증 기록.
  • 전수 규칙으로 대체(#1610 Phase 4·7): 위 소스 핀 renderStructureGate.test.mjs 가 고정하던 구조(컨텍스트 값 안정화·정책 바인딩 요소·PR 검색 항목 지연 파생)는 #1610 이 구조를 바꿔(컨텍스트 = 스토어 손잡이, 값은 useFeatureViewStore; PR 검색은 오너 스토어 자원) 핀 3개가 더 이상 소스와 맞지 않는다. 대체 = 정적 게이트 check:render R1~R6(전 바인딩·전 화면) + 팬아웃 러너 --gate. QA1 쪽 갱신 목록은 렌더 장부 §10-4.