Skip to content

렌더 불변식 계약

이슈 #1610, 2026-09-15 오너 go. 적용 대상은 Barbelic 모바일 앱 전체(화면·바인딩·컨트롤러·스토어)와 이후 추가하는 화면이다. 관리자 패널·데스크톱은 이 계약의 다음 확장 대상이며 지금은 측정만 같은 도구로 한다.

1. 왜 이 계약인가 — 유저 A 의 하루로

유저 A 가 일지 탭에서 어제 세션 카드를 누른다. A 가 보는 것은 세션 상세 하나가 열리는 일이지만, 지금 앱은 그 순간 조립 루트(useBarbelicController, 1,614줄)를 다시 실행하고 홈·피드·리포트처럼 보이지도 않는 판(keep-alive 탭) 까지 다시 그린다. 리포트 개요 응답이 뒤늦게 도착하면 또 한 번 전체가 다시 실행된다. 화면이 느린 이유는 어느 한 컴포넌트가 무거워서가 아니라, "누가 다시 그려져도 되는가" 를 정해 둔 적이 없어서다.

이전 트랙(08-21 상태 재설계, 08-23 렌더 입자성, 08-24 탭 개편, A15, #1592, #1596)은 모두 그때 잰 가장 큰 비용만 고쳤고, 규칙·전수 장부·전수 게이트가 없어 같은 종류의 문제가 다른 화면에서 되풀이됐다. 이 계약은 그 셋을 한 번에 둔다.

2. 규칙 하나

상태는 오너 범위 스토어·자원에 살고, 화면 조각은 자기가 읽는 조각만 구독한다. 조립 루트는 계정·인증·연결·조립만 안다.

풀어 쓰면:

  1. 서버 응답·입력으로 바뀌는 상태는 조립 루트의 useState 가 아니라 오너 범위 스토어(controllers/ownerStores.ts)나 자원 스토어(resources/resourceStore.ts)에 둔다. 루트가 다시 실행되는 이유는 계정 전환·인증·연결 상태·조립뿐이다.
  2. 화면과 바인딩은 자기가 읽는 조각만 구독한다(useSyncExternalStore 조각 구독, 안정 콜백). 컨텍스트로 "큰 객체 하나" 를 내려보내 모든 소비자를 한 번에 깨우지 않는다.
  3. 보이지 않는 판(keep-alive 탭·닫힌 스택 항목)은 입력 1건에 다시 그려지지 않는다.
  4. 화면 안에서도 입력 1건에 다시 그려지는 범위는 그 입력이 바꾼 조각(세트 한 줄, 타이머 숫자)이다.

2-1. 바인딩 → 화면 계약 (Phase 4, 앱 app/featureView.ts · 정적 게이트 scripts/check-render-rules.mjs)

유저 A 가 일지에서 세션 카드 하나를 누르면 "세션 컨텍스트" 값이 바뀐다. 종전에는 React 컨텍스트가 그 값을 통째로 내려 그것을 읽는 파일 전부(홈·일지·피드·리포트 바인딩 17곳, 보이지 않는 판 포함)가 다시 그려졌다. 이제 컨텍스트는 값 대신 바뀌지 않는 스토어 손잡이 를 내리고, 읽는 쪽이 자기 조각만 구독한다.

#규칙어떻게게이트
5컨텍스트에는 값이 아니라 스토어 손잡이를 싣는다createFeatureContext<T>(name) 이 만든 Context·useXFeature·useXFeatureStore. 제공자는 계산한 값을 useFeatureViewStore(value) 로 싣는다 — 내용이 같은 키는 이전 참조, 함수 키는 한 번 만든 대리 함수(호출은 최신 함수), 아무 키도 안 바뀌면 아무도 깨우지 않는다R1(모바일 바인딩·화면·훅에서 인자 없는 useXFeature() = 값 전체 구독 금지)
6소비자는 키 목록 또는 선택자로 구독한다useSessionFeature(["selectedSession", "closeSelectedSession"]) — 키마다 Object.is 비교, 하나라도 바뀔 때만 깨어난다. 파생값 하나는 useHomeFeature((home) => !!home.homeProps.resume). 이벤트 시점에만 읽을 값은 usePrFeatureStore().get() (구독 없음)R1
7바인딩이 화면에 넘기는 prop 은 렌더마다 새로 만들지 않는다콜백 묶음은 useStableCommands({ onSelectDate: (d) => …, … }) 1회 생성(대리 함수, 동작은 최신 렌더), 파생 객체·목록은 useMemo, 빈 값은 모듈 상수(NO_SESSIONS). 조건부 콜백은 cond ? actions.onX : undefinedR2(바인딩 파일의 인라인 콜백 prop onX={(…) => · 인라인 객체 prop <code>x={{</code> = 0)
8화면·공용 컴포넌트는 React.memo 로 내보낸다ui/mobile/screens·ui/mobile/lib 의 export 컴포넌트 전부(const XMemo = React.memo(X); export { XMemo as X }, 파일 안 참조는 원 함수). 규칙 7 이 성립해야 memo 가 실제로 bail-out 한다R3
9탭 판 = 탭 바인딩 하나 + 그 탭의 스택 항목mobileApp.tsx 의 판 목록에는 <TabStackProvider tab><Mobile<탭>Binding /></TabStackProvider> 만 있고, 탭 바인딩 7개가 각각 <MobileStackEntries /> 를 정확히 1번 그린다(리포트 탭은 MobileVolumeBinding 래퍼)R4
10조립 루트 갱신원은 등록 목록 밖으로 늘지 않는다장부 B(루트가 부르는 훅의 useState/useSyncExternalStore)는 앱셸 상태 11개(인증·데이터 상태·계정·프로필 관문 키·오류 문구·앱셸 스냅샷·오너 개정·gymData/plans 적용·PC 탭·생성기 오탐 1)로 고정. 새 상태는 오너 범위 스토어·값 스토어로R5
11러너가 재는 입력 종류는 전부 불변식 표에 있다새 입력 종류·새 화면은 renderInvariants.ts 에 허용 경계·루트 재실행 허용치를 먼저 등록해야 통과한다R6
12닫힌 드로어·탭바는 탭 값·홈 데이터에 반응하지 않는다드로어는 열려 있을 때만 탭·도구 항목을 읽고, 탭바는 홈 데이터 중 복귀 카드 유무만 구독한다러너(tab.switch 허용 경계 = shell:tabs)
13화면이 소유하는 편집 스토어도 손잡이로 내리고, 화면 조각은 자기 조각만 구독한다 (Phase 5)운동 편집기 포트 MobileWorkoutEditorPortsubscribe/getSnapshot 손잡이(참조 불변)와 이벤트 시점 조회용 view getter 를 갖는다. 화면은 ui/shared/useEditorView(editor, 선택자, 비교) 로 자기 조각만 구독한다 — 흐름 셸 = 제목·종목 수·오류, 기록 화면 = 종목 목록·활성 종목·추가 중인 세트, 보드 작성 헤더 = 제목·종목 수, 완료 단계 = 초안 전체(그 단계에서만). 스토어는 발행마다 바뀐 조각만 새 참조(services/derivedIdentity.reuseUnchangedDeep)러너(workout.setInput 렌더 컴포넌트 = 기록 화면 1 + 세트 행 1)
14목록 행은 memo 컴포넌트, 행이 부르는 부모 동작은 참조 고정 묶음, key 는 데이터 정체 (Phase 5)세트 행(WorkoutRecordRows.tsx: 편집 행·보기 행)은 React.memo 이고 자기 세트(s)와 원시 플래그만 받는다. 부모 동작은 useRowCommands({...}) 한 묶음(대리 함수, 호출은 최신 렌더). key 는 세트 id — 재정렬해도 입력 포커스·한글 조합 상태가 다른 행에 붙지 않는다. 타이머처럼 홀로 바뀌는 값은 자기 컴포넌트(WfRestClock)가 tick 을 갖는다. 긴 전체 목록(종목 상세 전체 PR·전체 세션)은 ViewportList러너(workout.restTick 렌더 컴포넌트 = WfRestClock 1)
15숨은 판은 갱신을 받지 않는다 — 판 구독 게이트 (Phase 6)판 래퍼(mobileApp MobileFeaturePane)가 판마다 게이트 객체(ui/shared/paneGate)를 싣고, 활성 탭이 아니거나 운동 층이 덮으면 커밋 뒤(layout effect) hidden 으로 바꾼다. 판 안의 구독은 전부 usePaneGatedStore(subscribe, getSnapshot) 를 거친다 — feature 컨텍스트 훅(useXFeature)·자원 구독(useResourceSnapshot 3종)·판 바인딩의 직접 스토어 구독·탭 스택 항목·워드마크 알림. 숨은 동안 알림은 "밀림" 으로만 적고 마지막 값을 돌려주며, 판이 보일 때 밀린 알림을 한 번에 보내 최신 값으로 따라잡는다(같은 프레임, paint 전). 전환은 게이트 객체 안 플래그라 전환 자체로는 소비자가 다시 그려지지 않는다. 판 밖(셸·제공자·운동 층)에서는 게이트가 없어 그대로 구독러너(pr.open·stack.pushPop·workout.start 허용 경계 = 원점 판 + 스택 항목 + 셸/운동 층, 숨은 판 0)
16셸이 여러 판에 내리는 값은 데이터가 같으면 같은 참조, 컨텍스트에는 손잡이 (Phase 6)워드마크 알림(BrandNotifyContext)은 값이 아니라 스토어 손잡이(useFeatureViewStore)를 싣고, 알림 값은 알림 데이터(상태·초대 응답 중·숨긴 작성자)로만 memo — 셸이 다른 이유(운동 시작)로 다시 그려져도 판의 워드마크가 깨지 않는다. 읽는 쪽 useBrandNotify() 는 판 게이트를 거친다러너(workout.start)
17인증 리스너는 앱 마운트당 1회controllers/remoteDataController.ts 의 인증 runtime 조립 effect(createAuthSessionRuntime)는 의존성이 비어 있고 controller 도우미를 latestAuthRuntimeHelpersRef(최신값 ref)로 읽는다. 로그아웃 정리(오너 스토어 묶음 교체)가 도우미를 새 함수로 만들어도 리스너를 떼었다 붙이지 않는다 — 어기면 로그인 안 된 부팅이 "리스너 제거 → 재구독 → INITIAL_SESSION → 정리" 무한 반복이 된다(BUG-150, 2026-09-15 #1680). 같은 이유로 오너 스토어 host 는 오너가 그대로인 전환(없음→없음)에서 묶음을 새로 만들지 않는다R7

화면 로컬 상태의 자리(규칙 8 의 보충): 화면이 자기 조작에만 쓰는 상태(입력 중 문자열·펼침·시트 열림)는 화면 로컬 useState 다. 다른 판이나 스택 항목이 읽거나 판 재마운트 뒤에도 남아야 하는 상태(선택일·표시 달·리포트 기간·종목 선택·주 선택)는 오너 범위 스토어 또는 조각 구독 프로바이더(MobileReportControlsProvider·MobileCalendarJournalProvider)에 둔다. 두 판이 같은 상태를 각자 들지 않는다.

대리 함수의 한계: useFeatureViewStore·useStableCommands 의 함수 키는 참조가 고정되므로, 그 함수가 렌더 중에 읽히는 데이터를 닫아 두고 있으면(예: hydrateSessions 가 닫아 둔 읽기 모델) 부르는 쪽이 같은 데이터를 직접 구독해야 최신값이 보인다(일지 바인딩은 calendarFullById 를 직접 읽는다). 이벤트 시점에만 부르는 명령은 이 한계가 없다.

3. 계측 경계와 불변식 표

3-1. 경계 id

계측은 React Profiler 로 한다. 개발 서버에 ?lgProfile=1 로 들어가면 ui/shared/renderProfile.tsxProfileBoundary 가 아래 id 로 커밋을 window.__LG_PROFILE__ 에 남긴다(프로덕션 번들·일반 개발 실행에서는 자식만 그리고 아무것도 더하지 않는다).

경계 id자리무엇을 감싸나
rootvite/main.tsx앱 전체(전체 커밋 수·시간 기록용, 위반 판정에는 세지 않음)
shell:tabsmobileApp.tsx탭 리전(판 전부 + 탭바)
shell:drawer · shell:workoutNavmobileApp.tsx드로어 메뉴 · 운동 중 하단 내비게이션
workoutmobileApp.tsx운동 층(운동 기록·플로우·마무리)
pane:<탭>ui/mobile/shell/MobileShellFrames.tsx방문한 탭 판 하나(home·calendar·feed·volume·profile·search·group)
stack:<kind>features/navigation/MobileStackBindings.tsx탭 스택 항목 하나(kind = controllers/screenKinds 등록표: session·daySummary·prDetail·homeReport…)

자리표시자: pane:$origin = 입력이 일어난 판, stack:* = 어떤 스택 항목이든, * = 전부 허용.

3-2. 불변식 표 (정본 = 앱 src/react/contracts/render/renderInvariants.ts)

입력 1건에 다시 그려져도 되는 경계 집합(허용)과 조립 루트 재실행 허용 횟수. 표 밖의 경계가 update 커밋을 남기면 위반이다. 첫 방문의 mount 커밋은 판이 처음 그려지는 것이므로 허용한다.

자리표시자 pane:$origin = 입력이 일어난 판, pane:$from = 탭 전환에서 나가는 판(Phase 4: 판 활성 여부에 달린 적재 정책이 그 두 판 안에서 깨어난다), stack:* = 어떤 스택 항목이든. Phase 4 에서 더한 입력: boot.quietWindow(부팅 뒤 조용한 창 — 앱셸 응답 적용 1회 + 미리 받기 응답), drawer.pick(드로어를 거치는 탭 이동 — 드로어가 열리고 닫힌다), connectivity.offline/connectivity.online(온라인 복귀 = 앱셸 재적재라 계정 전환과 같은 급으로 전체 허용, 루트 2 = gymData·plans 적용 — gymData 해체 후속에서 0), workout.start(운동 모드 전환 — 셸 층·운동 층·출발 판).

입력유저 이야기허용 경계루트 재실행Phase 1 실측(기준선)Phase 4 실측(2026-09-15, 러너 p4-run7 40표본)
boot.quietWindow부팅 뒤 미리 받기 응답 창(입력 없음)pane:home, shell:drawer, shell:tabs1루트 13, 홈 판·드로어·탭 셸✅ 루트 1(앱셸 적용), 홈 판·드로어·탭바
response.volumeOverview리포트 개요 응답 도착pane:volume, stack:homeReport0부팅 창에 섞임부팅 창에 섞임(리포트 판 update 없음 — 홈·달력은 복구 상태 뷰만 구독, Phase 3 ①)
response.calendarMonth달력 월 요약 응답 도착pane:calendar0첫 방문 창 — 방문한 판 전부calendar.changeMonth 창에서 일지 판만
response.calendarDay달력 하루 상세 응답 도착pane:calendar, pane:home, stack:daySummary, stack:journalDay0날짜 선택 창(루트 3)calendar.selectDay 창에서 일지 판만(홈은 날짜 패널이 열려 있을 때만 구독)
response.exerciseCatalog종목 카탈로그 응답 도착workout0부팅 창(루트 13)부팅 창에 섞임(카탈로그 컨텍스트 소비자만)
response.feedPage피드 페이지 응답 도착pane:feed, stack:myFeed, stack:friendScope0피드 첫 방문 루트 4, 판 전부✅ 피드 첫 방문 창에서 피드 판(mount)만
response.prOverviewPR 개요 응답 도착pane:home, stack:prDetail, stack:recordsTool0부팅 창(루트 13)부팅 창에 섞임(홈 판)
response.profile프로필 응답 도착pane:profile, shell:drawer0부팅 창부팅 창에 섞임(드로어)
response.sessionSearch운동 기록 검색 응답 도착pane:search0미계측미계측(검색 화면은 러너 밖)
calendar.selectDay일지에서 날짜 하나를 누른다pane:calendar0루트 3, 달력·홈·리포트 + 탭 셸 + journalDay(허용 밖 4)✅ 루트 0, 일지 판만
calendar.changeMonth일지에서 이전/다음 달로 넘긴다pane:calendar0루트 6/1, 판 4개 + 드로어·탭 셸(허용 밖 5)✅ 루트 0, 일지 판만(4/4)
session.open세션 카드를 눌러 상세를 연다pane:$origin, stack:session, stack:friendSession0루트 4, 판 4개 + 드로어·탭 셸 + journalDay·session(허용 밖 6)✅ Phase 5(p5-run3): 루트 0, 일지 판 + session 만 — journalDay 허용 밖 해소(일지 화면의 하루 상세 콜백 참조 고정 + 스택 항목 요소 캐시)
session.close세션 상세를 닫는다(뒤로)pane:$origin, stack:session, stack:friendSession0루트 1, 같은 집합(허용 밖 6)✅ Phase 5(p5-run3): 루트 0, 같은 집합(허용 밖 0)
pr.open종목을 눌러 종목 상세를 연다pane:$origin, stack:prDetail0루트 13, 판 4개 + 드로어·탭 셸 + prDetail(허용 밖 5)루트 0, 홈 판 + prDetail + 리포트 판(허용 밖 1 — 숨은 판이 prProps 를 구독, Phase 6 숨은 판 갱신 수신)
tab.switch하단 탭을 누른다(재방문)shell:tabs, pane:$origin, pane:$from0루트 0~1, 판 4개 + 드로어(허용 밖 5)✅ 루트 0, 탭바만(재방문 8/8), 들어가는 판(첫 방문 mount·리포트 활성 적재)
drawer.pick드로어에서 탭을 고른다shell:tabs, shell:drawer, pane:$origin, pane:$from0(tab.switch 에 포함)✅ 루트 0, 드로어·탭바·들어가는 판(4/4)
stack.pushPop탭 스택 항목을 열고 닫는다pane:$origin, stack:*0하루 상세 pop 루트 0, 종목 상세 pop 루트 1(판 전부)하루 상세 pop ✅ · 종목 상세 pop 리포트 판(허용 밖 1, pr.open 과 같은 원인)
workout.start운동을 시작한다(운동 층 push)shell:tabs, shell:drawer, shell:workoutNav, workout, pane:$origin0루트 2, 판 전부 + 운동 층·운동 내비루트 0, 셸 층 + 운동 층(mount) + 숨은 판 3(허용 밖 — isWorkoutActive 를 판마다 읽음, Phase 6)
workout.setInput세트 무게·횟수를 한 글자 입력한다workout0성립(루트 0, 운동 층만)✅ 성립(8/8). Phase 5(p5-run1): 운동 층 안에서 기록 화면(종목 요약) 1개 + 그 세트 행 1개만 렌더(WfRecord·WfSetEditRow), 3.4~4.4ms@4x — 종전 운동 층 안 화면 전체
workout.restTick휴식 타이머가 0.5초마다 진행한다workout0미계측✅ Phase 5(p5-run1, 러너가 휴식 기록 옵트인을 켬): 2.5초 창 커밋 5 — 렌더된 컴포넌트는 시계 WfRestClock 1개뿐(종전 코드는 화면 전체 setTick)
report.detailArrival리포트 선택 기간 상세 응답 도착pane:volume, stack:homeReport0루트 0, 판 4개 + 드로어·탭 셸(허용 밖 5)drawer.pick(리포트 진입) 창에 포함 — 리포트 판만 ✅
connectivity.offline온라인 → 오프라인(없음)0루트 1✅ 루트 0, 경계 없음(배너만)
connectivity.online오프라인 → 온라인(앱셸 재적재)*2루트 2~8, 판 전부✅ 루트 2, 재적재된 판 전부(허용)
toast토스트 표시·소멸(없음)0첫 토스트 루트 12·판 전부(허용 밖 6)✅ 루트 0, 경계 없음(2/2)
owner.change계정 전환·로그아웃*제한 없음전체 재조립 허용(러너 밖)

실측 원본은 렌더 장부 §3(Phase 1)·§7(Phase 4)·§8(Phase 5). Phase 5 뒤 남은 허용 밖은 3건(전부 숨은 판, Phase 6). Phase 4 뒤 남은 허용 밖 5건 중 4건(pr.open·종목 상세 pop 의 리포트 판, workout.start 의 숨은 판 3)은 "숨은 판이 데이터 갱신을 받는다" 한 종류로 Phase 6(판 수명)의 대상이고, 1건(session.open/close 의 journalDay)은 Phase 5 초입에서 원인을 잡는다. 표의 "지금" 열은 Phase 5~6 이 지날 때마다 계약 정본(ts)과 함께 갱신한다.

판 예산의 축(Phase 6-3): DOM 노드 예산은 판 하나(domNodesHomeFanout = 홈 판, domNodesPaneMaxFanout = 어느 판이든)이고, 방문 판 전체(.tab-keep *)는 판 수 × 판 예산으로 읽는다. 팬아웃 러너가 부팅·4판 방문·리포트 재방문 세 시점에 판마다 노드 수를 기록하고 --gate 에서 판정한다. 실기기 프로브(perf_tab_switch·입력 응답)의 경로 토큰은 n{방문 판 전체}a{활성 판} 을 더해 두 축을 다 싣는다.

4. 판정 규칙 — 러너(Phase 1~3 기록)와 게이트(Phase 4 부터 문턱)

  1. 입력 1건 뒤 정착 창(내비게이션 1.8초 · 응답 3초 · 세트 입력 1.1초 · 조용한 창 7초) 안에 기록된 update 커밋의 경계 id 집합 O 는 허용 집합 Y 의 부분집합이어야 한다. root 는 세지 않는다.
  2. 조립 루트 재실행 수(window.__LG_PROFILE_CONTROLLER_COMMITS__ 증가분)는 표의 허용 횟수 이하여야 한다.
  3. 첫 방문(mount) 커밋은 허용한다. 재방문(update)만 판정한다.
  4. 러너: scripts/render/render-fanout.mjs — 일회용 계정을 만들어 12주 × 2세션을 넣고, 390×844 · CPU 4배 에뮬레이션으로 부팅 → 탭 전환(첫 방문·재방문) → 일지 날짜·세션 열기/닫기·월 이동 → 종목 상세 → 리포트 진입 → 오프라인/온라인 → 토스트 → 운동(종목 추가·세트 입력·완료·휴식·이탈) 순으로 입력을 넣고 입력마다 O·루트 재실행 수·위반을 기록한다. 개발 React(StrictMode)라 절대 시간은 전후 비교용이고 집합·횟수는 결정적이다.
  5. 게이트(Phase 4 부터): node --import tsx scripts/render/render-fanout.mjs --gate — 같은 러너가 violations 가 하나라도 있거나 루트 재실행 초과가 있으면 종료 코드 1, 시나리오를 끝까지 못 돌면 2. 브라우저·샌드박스가 필요하므로 check:static 이 아니라 승격 전 QA(Phase 7 QA2)·재측정 때 돌린다. 코드 모양은 정적 게이트 npm run check:render(§2-1 R1~R7, check:static 에 포함 — R7 은 2026-09-15 #1680)가 커밋마다 본다. 새 입력 종류·새 화면은 표에 줄을 더해야 통과한다(R6, 등록 강제). Phase 7 에서 게이트가 실제로 실패함을 확인했다 — 표에서 calendar.selectDay 허용 집합을 비우고 같은 러너를 돌리면 허용 밖 [pane:calendar] 1건으로 종료 코드 1(p7-neg2), 정적 게이트는 공용 컴포넌트 export 에서 React.memo 를 벗기면 R3 위반으로 종료 코드 1. 팬아웃 러너의 판·DOM 예산(6-3)도 같은 --gate 에서 판정한다.
  6. 실기기: Production 텔레메트리 perf_input_response(services/inputResponseProbe.ts)가 "누름→첫 반응 100ms 초과 · 마지막 데이터→공개 100ms 초과 · 누름→공개 2초 초과" 표본을 남긴다. 4배 CPU 에뮬레이션이 놓치는 회귀를 여기서 본다.

5. 장부와 완료 기준 — Phase 7 판정 — 불변식 표 전 입력 초록(§9-2 p6-run3 40표본 위반 0)·장부 A·B 100%·게이트 3겹 편입·게이트 실패 확인 완료, 20표본 × 2묶음 판정은 오너 지시로 미실행(장부 §10)

  • 장부 A(화면·바인딩 70) · 장부 B(루트 갱신원 60) = 렌더 장부. 생성기 scripts/render/render-ledger.mjs.
  • 트랙 완료 = 불변식 표 전 입력 초록(위반 0·루트 재실행 0) · 장부 A·B 100% 처리 · 전수 게이트(팬아웃 러너 --gate + 정적 게이트 check:render R1~R7 가 check:static) · Production 텔레메트리 kind 수집 확인.
  • Phase 4 뒤 장부 A 의 바인딩 27행: 키 구독 훅 134 · 값 전체 구독 0 · 인라인 콜백 prop 0 · 인라인 객체 prop 0. 화면·공용 44파일 중 memo 42(나머지 2 = 컴포넌트 없는 파일). 장부 B 11(앱셸 상태, R5 목록).

6. 새 화면·바인딩 체크리스트 (개발 규칙, Phase 7 정착)

새 화면·새 바인딩·새 입력 종류를 만들 때 순서대로 확인한다. 게이트가 잡는 항목은 게이트 이름을 적었고, 나머지는 코드 리뷰 항목이다.

#확인어떻게잡는 곳
1상태의 자리를 먼저 정한다다른 판·스택 항목이 읽거나 판 재마운트 뒤에도 남아야 하면 오너 범위 스토어(controllers/ownerStores) 또는 자원 스토어. 화면이 자기 조작에만 쓰는 상태(입력 중 문자열·펼침·시트 열림)만 화면 로컬 useState. 조립 루트에는 useState 를 더하지 않는다R5(장부 B 목록 밖 루트 갱신원)
2바인딩은 조각만 구독한다useXFeature(["키", …]) 또는 선택자. 값 전체 구독(useXFeature()) 금지. 이벤트 시점에만 읽는 값은 useXFeatureStore() 손잡이로R1
3바인딩이 화면에 넘기는 prop 은 렌더마다 새로 만들지 않는다콜백 묶음 useStableCommands, 파생 객체·목록 useMemo, 빈 값은 모듈 상수. 인라인 콜백·객체 prop 0R2
4화면·공용 컴포넌트는 React.memo 로 내보낸다const XMemo = React.memo(X); export { XMemo as X }. 섹션은 화면 전체가 아니라 자기 입력만 받는다R3
5목록 행은 memo 컴포넌트 + 참조 고정 명령 묶음 + 데이터 key행은 자기 항목과 원시 플래그만 받고, 부모 동작은 useRowCommands({...}) 한 묶음, key 는 항목 id(인덱스 금지). 긴 목록은 ViewportList코드 리뷰(장부 A 인덱스 key 열)
6화면이 소유하는 편집 스토어는 손잡이로 내린다포트에 subscribe/getSnapshot + 이벤트 조회용 view getter, 화면은 useEditorView(editor, 선택자)코드 리뷰(규칙 13)
7판 안의 직접 스토어 구독은 판 게이트를 거친다feature 훅·useResourceSnapshot 계열은 이미 거친다. 판 안에서 useSyncExternalStore 를 직접 쓰면 usePaneGatedStore(subscribe, getSnapshot) 로 감싼다. 판 밖(셸·제공자·운동 층)은 그대로코드 리뷰(규칙 15) · 러너(workout.start·pr.open 허용 경계)
8탭 판은 탭 바인딩 하나 + <MobileStackEntries /> 1번mobileApp.tsx 판 목록에 스택 항목·화면을 직접 두지 않는다R4
9새 입력 종류·새 스택 kind 는 불변식 표에 먼저 등록한다renderInvariants.ts 에 허용 경계·루트 재실행 허용치·기준선 문구를 적고, 팬아웃 러너에 그 입력 표본을 더한다. 표에 없는 입력 종류를 러너가 재면 실패R6 · 러너
10상시 애니메이션은 합성기 전용으로transform·opacity 만 움직이고(뼈대 반짝임은 ::after 오버레이 이동), background-position·box-shadow 키프레임 금지. 프레임마다 뛰는 요소는 will-change러너(표본별 스타일 재계산 횟수·ms 열)
11새 판·큰 화면은 판 DOM 예산 안판 하나 ≤ domNodesPaneMaxFanout(901, 여유 10%), 홈 판 ≤ domNodesHomeFanout(112)러너 --gate DOM 예산
12실기기 텔레메트리 토큰입력 응답 프로브(perf_input_response)의 경로 토큰(r{첫 반응} p{공개} … n{방문 판 전체} a{활성 판})을 새 입력 종류에도 그대로 쓴다pgTAP client_error_events.test.sql

검증 순서: ① npm run check:static(R1~R7 포함) → ② 샌드박스 + 개발 서버에서 팬아웃 러너 --gate(표본 40, 판·DOM 예산) → ③ 승격 전 판정 러너(20표본 × 2묶음, 1·4년 fixture, 조용한 기계)는 릴리스 담당이 원장 절차대로.