렌더 장부 — #1610 Phase 1 기준 상태
한 문장 — 모바일 렌더 불변식 트랙(이슈 #1610)의 시작 시점 장부다. 화면·바인딩 70개(장부 A)와 조립 루트를 다시 실행시키는 상태 60개(장부 B)를 한 줄도 빠짐없이 적고, 입력 21종마다 실제로 누가 다시 그려지는지(팬아웃 기준선)를 잰 값이다. Phase 2~6 이 이 장부의 줄을 하나씩 지워 나가고, Phase 7 이 100% 를 확인한다. 계약 = 렌더 불변식 계약.
- 기준 커밋: 앱
e94335aa(release/v0.19.2 = v0.19.1 태그, 2026-09-15). 장부 생성 시각 2026-09-15 02:35 KST. - 생성기:
node scripts/render/render-ledger.mjs [--app <checkout>] [--out <dir>]→ledger-{a,b}.{json,md}(정적 판독. 앱 저장소scripts/render/, 이 트랙 PR 에 포함). - 팬아웃 러너:
E2E_SUPABASE_URL=… E2E_SUPABASE_ANON_KEY=… E2E_SUPABASE_SERVICE_ROLE_KEY=… E2E_APP_URL=http://127.0.0.1:<vite dev 포트> node --import tsx scripts/render/render-fanout.mjs— 개발 서버에?lgProfile=1로 들어가 React Profiler 경계(ProfileBoundary)의 커밋을 입력 종류별로 묶는다(러너 절차와 판정 규칙은 계약 문서 §4). - 읽는 법: 장부 A 의
memo는 그 파일에React.memo가 있는가,인라인 fn prop은 렌더마다 새로 만들어 자식에게 넘기는 콜백 수,인덱스 key는 배열 인덱스를 key 로 쓰는 곳,레이아웃 읽기는getBoundingClientRect·offsetHeight류 강제 레이아웃,uSES는 조각 구독(useSyncExternalStore). 장부 B 의경로는appController.tsx에서 그 훅까지 import 를 따라간 길이다 — 이 줄의 상태가 바뀌면 조립 루트(1,614줄 훅)가 통째로 다시 실행된다.
1. 장부 A — 화면·바인딩 표면 (70)
장부 A — 표면 70개 (화면 21 · lib 23 · 바인딩 26) · memo 있는 파일 1 · 인라인 콜백 prop 799 · 인덱스 key 60 · 레이아웃 읽기 13 · 조각 구독하는 바인딩 9
| 파일 | 층 | 줄 | 컴포넌트 | memo | 컨텍스트 훅 | uSES | useMemo | useState | 인라인 fn prop | 인라인 obj prop | 인덱스 key | 레이아웃 읽기 | .map |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| src/react/ui/mobile/screens/DaySummaryScreen.tsx | screen | 37 | 3 | 0 | — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/screens/FeedScreen.tsx | screen | 314 | 6 | 0 | — | 0 | 1 | 1 | 18 | 1 | 1 | 0 | 9 |
| src/react/ui/mobile/screens/GroupComposer.tsx | screen | 215 | 5 | 0 | — | 0 | 0 | 0 | 23 | 7 | 0 | 0 | 9 |
| src/react/ui/mobile/screens/GroupScreen.tsx | screen | 836 | 25 | 0 | useTabStack | 0 | 1 | 0 | 67 | 5 | 7 | 0 | 24 |
| src/react/ui/mobile/screens/HomeScreen.tsx | screen | 492 | 5 | 0 | useTabStack | 0 | 4 | 2 | 24 | 3 | 4 | 0 | 11 |
| src/react/ui/mobile/screens/OnboardingScreen.tsx | screen | 362 | 4 | 0 | — | 0 | 0 | 5 | 25 | 2 | 0 | 0 | 5 |
| src/react/ui/mobile/screens/ProfileScreen.tsx | screen | 523 | 9 | 0 | — | 0 | 0 | 6 | 33 | 2 | 0 | 0 | 4 |
| src/react/ui/mobile/screens/PrTools.tsx | screen | 419 | 4 | 0 | — | 0 | 5 | 0 | 29 | 0 | 0 | 1 | 12 |
| src/react/ui/mobile/screens/RecordsDetail.tsx | screen | 557 | 5 | 0 | useTabStack | 0 | 9 | 0 | 21 | 3 | 7 | 0 | 25 |
| src/react/ui/mobile/screens/RecoveryScreen.tsx | screen | 103 | 2 | 0 | — | 0 | 0 | 2 | 7 | 0 | 0 | 0 | 7 |
| src/react/ui/mobile/screens/ReportScreen.tsx | screen | 862 | 23 | 5 | — | 0 | 12 | 1 | 29 | 15 | 7 | 0 | 34 |
| src/react/ui/mobile/screens/SessionScreen.tsx | screen | 370 | 2 | 0 | useTabStack | 0 | 0 | 2 | 21 | 2 | 3 | 0 | 5 |
| src/react/ui/mobile/screens/SetSearch.tsx | screen | 117 | 2 | 0 | — | 0 | 3 | 0 | 8 | 0 | 0 | 0 | 2 |
| src/react/ui/mobile/screens/TabSkeletons.tsx | screen | 92 | 2 | 0 | — | 0 | 0 | 0 | 0 | 4 | 0 | 0 | 0 |
| src/react/ui/mobile/screens/TrainingStyleScreen.tsx | screen | 94 | 1 | 0 | — | 0 | 0 | 2 | 4 | 0 | 0 | 0 | 1 |
| src/react/ui/mobile/screens/WorkoutExtras.tsx | screen | 227 | 3 | 0 | — | 0 | 1 | 2 | 23 | 4 | 0 | 3 | 8 |
| src/react/ui/mobile/screens/WorkoutFinish.tsx | screen | 306 | 3 | 0 | — | 0 | 0 | 4 | 22 | 1 | 2 | 1 | 9 |
| src/react/ui/mobile/screens/WorkoutFlow.tsx | screen | 508 | 7 | 0 | — | 0 | 0 | 7 | 46 | 26 | 0 | 0 | 5 |
| src/react/ui/mobile/screens/WorkoutPicker.tsx | screen | 223 | 8 | 0 | — | 0 | 2 | 10 | 17 | 1 | 0 | 0 | 5 |
| src/react/ui/mobile/screens/WorkoutRecord.tsx | screen | 1604 | 7 | 0 | — | 0 | 8 | 8 | 153 | 24 | 9 | 3 | 40 |
| src/react/ui/mobile/screens/WorkoutRecordParts.tsx | screen | 468 | 17 | 0 | — | 0 | 0 | 0 | 68 | 3 | 1 | 0 | 14 |
| src/react/ui/mobile/lib/AccountDeleteFlow.tsx | lib | 119 | 1 | 0 | — | 0 | 0 | 3 | 4 | 2 | 0 | 0 | 1 |
| src/react/ui/mobile/lib/brandNotify.tsx | lib | 59 | 0 | 0 | — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/BrandRow.tsx | lib | 101 | 2 | 0 | — | 0 | 0 | 1 | 4 | 0 | 0 | 2 | 1 |
| src/react/ui/mobile/lib/CalendarCore.tsx | lib | 42 | 1 | 0 | — | 0 | 0 | 0 | 3 | 1 | 1 | 0 | 2 |
| src/react/ui/mobile/lib/DayDetailBody.tsx | lib | 395 | 5 | 0 | — | 0 | 1 | 0 | 6 | 3 | 6 | 0 | 13 |
| src/react/ui/mobile/lib/DayOverlay.tsx | lib | 43 | 2 | 0 | — | 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/DaySheet.tsx | lib | 70 | 2 | 0 | — | 0 | 0 | 0 | 3 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/FeedModeration.tsx | lib | 107 | 4 | 0 | — | 0 | 0 | 0 | 6 | 0 | 0 | 0 | 1 |
| src/react/ui/mobile/lib/FeedPost.tsx | lib | 262 | 8 | 0 | — | 0 | 0 | 3 | 11 | 1 | 2 | 3 | 8 |
| src/react/ui/mobile/lib/FriendScopeBar.tsx | lib | 35 | 1 | 0 | — | 0 | 0 | 0 | 2 | 1 | 0 | 0 | 1 |
| src/react/ui/mobile/lib/GrassCal.tsx | lib | 121 | 1 | 0 | — | 0 | 0 | 0 | 0 | 3 | 6 | 0 | 9 |
| src/react/ui/mobile/lib/IntensityGuide.tsx | lib | 27 | 1 | 0 | — | 0 | 0 | 0 | 2 | 3 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/ListOverlay.tsx | lib | 39 | 1 | 0 | — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/Modal.tsx | lib | 36 | 1 | 0 | — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/PhotoCard.tsx | lib | 50 | 1 | 0 | — | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 |
| src/react/ui/mobile/lib/PhotoComposer.tsx | lib | 30 | 1 | 0 | — | 0 | 1 | 0 | 3 | 1 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/PrcTierBadge.tsx | lib | 32 | 1 | 0 | — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/RecordIcons.tsx | lib | 16 | 1 | 0 | — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/SearchField.tsx | lib | 53 | 1 | 0 | — | 0 | 0 | 0 | 5 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/SessionSheetBody.tsx | lib | 342 | 8 | 0 | — | 0 | 0 | 2 | 14 | 11 | 4 | 0 | 10 |
| src/react/ui/mobile/lib/StackScreen.tsx | lib | 83 | 1 | 0 | useTabStack | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/StackSessionOverlay.tsx | lib | 36 | 1 | 0 | — | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/ui/mobile/lib/WorkoutResumePrompt.tsx | lib | 67 | 1 | 0 | — | 0 | 0 | 1 | 3 | 0 | 0 | 0 | 0 |
| src/react/features/active-workout/MobileGroupComposerBinding.tsx | binding | 15 | 1 | 0 | — | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| src/react/features/active-workout/MobileWorkoutBinding.tsx | binding | 69 | 1 | 0 | useCalendarFeature useCatalogFeature usePrFeature useProfileFeature useShellNavigation useVolumeFeature useWorkoutFeature | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 |
| src/react/features/active-workout/MobileWorkoutRestoreBinding.tsx | binding | 18 | 1 | 0 | useWorkoutFeature | 0 | 0 | 0 | 3 | 0 | 0 | 0 | 0 |
| src/react/features/active-workout/WorkoutFlowBinding.tsx | binding | 228 | 2 | 0 | useOwnerStores | 4 | 2 | 2 | 0 | 2 | 0 | 0 | 0 |
| src/react/features/calendar/MobileCalendarBinding.tsx | binding | 88 | 1 | 0 | useCalendarFeature useMobileCalendarJournal useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useShellNavigation useWorkoutFeature | 0 | 0 | 0 | 8 | 0 | 0 | 0 | 1 |
| src/react/features/calendar/MobileCalendarJournalProvider.tsx | binding | 19 | 1 | 0 | useCalendarFeature useMobileCalendarJournal | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/features/calendar/MobileDaySummaryBinding.tsx | binding | 70 | 1 | 0 | useCalendarFeature useMobileSessionActions useOwnerStores useProfileFeature useSessionFeature useShellNavigation useWorkoutFeature | 0 | 0 | 0 | 5 | 0 | 0 | 0 | 0 |
| src/react/features/group/MobileGroupBinding.tsx | binding | 311 | 1 | 0 | useCatalogFeature useGroupFeature useModerationFeature useOwnerStores usePrFeature useProfileFeature useShellNavigation useSocialFeature useTabStack useWorkoutFeature | 0 | 1 | 0 | 38 | 2 | 0 | 0 | 3 |
| src/react/features/group/MobileNotificationShellBinding.tsx | binding | 51 | 1 | 0 | useGroupFeature useModerationFeature useOwnerStores useShellNavigation useSocialFeature useWorkoutFeature | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/features/home/MobileHomeBinding.tsx | binding | 154 | 2 | 0 | useCalendarFeature useHomeFeature useMobileNavTab useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useShellNavigation useVolumeFeature useWorkoutFeature | 0 | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
| src/react/features/navigation/MobileDrawerBinding.tsx | binding | 75 | 1 | 0 | useCatalogFeature useMobileReportControls useOwnerStores usePrFeature useProfileFeature useShellNavigation useWorkoutFeature | 0 | 0 | 0 | 5 | 0 | 0 | 0 | 0 |
| src/react/features/navigation/MobileStackBindings.tsx | binding | 44 | 2 | 0 | useTabStack | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
| src/react/features/navigation/MobileTabsRegion.tsx | binding | 77 | 1 | 0 | useHomeFeature useMobileNavTab useOwnerStores useShellNavigation useWorkoutFeature | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/features/navigation/mobileNavigationBindings.tsx | binding | 62 | 1 | 0 | useMobileNavTab useOwnerStores useShellNavigation useWorkoutFeature | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/features/pr/MobilePrDetailBinding.tsx | binding | 38 | 1 | 0 | usePrFeature useProfileFeature | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/features/pr/MobileRecordsToolsBinding.tsx | binding | 113 | 1 | 0 | useCatalogFeature useOwnerStores usePrFeature useProfileFeature useRecoveryFeature | 1 | 2 | 0 | 4 | 0 | 0 | 0 | 2 |
| src/react/features/profile/MobileProfileBinding.tsx | binding | 47 | 1 | 0 | useCatalogFeature useModerationFeature useProfileFeature | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
| src/react/features/search/MobileSetSearchBinding.tsx | binding | 66 | 2 | 0 | usePrFeature useProfileFeature useSearchFeature useSessionFeature | 0 | 2 | 1 | 3 | 0 | 0 | 0 | 0 |
| src/react/features/session/MobileSessionOverlayBinding.tsx | binding | 45 | 1 | 0 | useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useWorkoutFeature | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/features/social/MobileFeedBinding.tsx | binding | 92 | 1 | 0 | useGroupFeature useModerationFeature useOwnerStores useProfileFeature useSessionFeature useShellNavigation useSocialFeature | 2 | 1 | 0 | 1 | 0 | 0 | 0 | 0 |
| src/react/features/social/MobileFriendScopeBinding.tsx | binding | 293 | 1 | 0 | useModerationFeature useOwnerStores useProfileFeature useSessionFeature useShellNavigation useSocialFeature useWorkoutFeature | 0 | 12 | 2 | 8 | 3 | 0 | 0 | 5 |
| src/react/features/social/MobileFriendSessionBinding.tsx | binding | 33 | 1 | 0 | useProfileFeature useSessionFeature useShellNavigation useSocialFeature useWorkoutFeature | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
| src/react/features/social/MobileMyFeedBinding.tsx | binding | 55 | 1 | 0 | useModerationFeature useProfileFeature useSessionFeature useShellNavigation useSocialFeature | 0 | 1 | 0 | 4 | 0 | 0 | 0 | 0 |
| src/react/features/social/MobileSuspensionBinding.tsx | binding | 10 | 1 | 0 | useModerationFeature | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| src/react/features/volume/MobileReportBinding.tsx | binding | 134 | 3 | 0 | useCalendarFeature useMobileCalendarJournal useMobileReportControls useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useShellNavigation useSocialFeature useVolumeFeature useWorkoutFeature | 1 | 2 | 2 | 7 | 1 | 0 | 0 | 0 |
| src/react/mobileApp.tsx | binding | 80 | 4 | 0 | useMobileNavTab useShellNavigation useWorkoutFeature | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
2. 장부 B — 조립 루트 갱신원 (60)
Phase 1 기준선. Phase 2 뒤 11 — §5-1.
장부 B — 조립 루트 갱신원 60개 (useState 41 · useReducer 0 · useSyncExternalStore 19) · 파일 26
| # | 파일:줄 | 종류 | 경로 | 코드 |
|---|---|---|---|---|
| 1 | src/react/appController.tsx:960 | useState | appController | const [legalDocumentPath, setLegalDocumentPath] = React.useState<string | null>(null); |
| 2 | src/react/controllers/navigationController.ts:24 | useSyncExternalStore | appController → useNavigationController | const daySummary = React.useSyncExternalStore( |
| 3 | src/react/controllers/navigationController.ts:29 | useSyncExternalStore | appController → useNavigationController | const sessionDetailOrigin = React.useSyncExternalStore( |
| 4 | src/react/controllers/navigationController.ts:34 | useSyncExternalStore | appController → useNavigationController | const workoutOriginTab = React.useSyncExternalStore( |
| 5 | src/react/controllers/navigationController.ts:40 | useSyncExternalStore | appController → useNavigationController | const tab = React.useSyncExternalStore( |
| 6 | src/react/features/home/homeSnapshotBinding.ts:7 | useState | appController → useHomeSnapshotBinding | const [shellGymData, setGymData] = React.useState<RuntimeGymData>(() => createRuntimeGymData()); |
| 7 | src/react/features/home/homeSnapshotBinding.ts:8 | useState | appController → useHomeSnapshotBinding | const [plans, setPlans] = React.useState<SessionPresentationInput[]>([]); |
| 8 | src/react/features/calendar/calendarDateBinding.ts:5 | useState | appController → useCalendarDateBinding | const [calendarSelectedDate, setCalendarSelectedDate] = React.useState(todayDate); |
| 9 | src/react/features/calendar/calendarDateBinding.ts:6 | useState | appController → useCalendarDateBinding | const [sessionDate, setSessionDate] = React.useState(todayDate); |
| 10 | src/react/features/calendar/calendarDateBinding.ts:7 | useState | appController → useCalendarDateBinding | const [visibleMonth, setVisibleMonth] = React.useState(() => lgMonthFromDate(todayDate)); |
| 11 | src/react/controllers/remoteDataController.ts:524 | useState | appController → useRemoteDataController | const [authStatus, setAuthStatusState] = React.useState<AuthStatus>("loading"); |
| 12 | src/react/controllers/remoteDataController.ts:525 | useState | appController → useRemoteDataController | const [dataStatus, setDataStatusState] = React.useState<DataStatus>("idle"); |
| 13 | src/react/controllers/remoteDataController.ts:526 | useState | appController → useRemoteDataController | const [homeDataStatus, setHomeDataStatus] = React.useState<DataStatus>("idle"); |
| 14 | src/react/controllers/remoteDataController.ts:539 | useState | appController → useRemoteDataController | const [remoteUser, setRemoteUser] = React.useState<StatsAuthUser | null>(null); |
| 15 | src/react/controllers/remoteDataController.ts:551 | useState | appController → useRemoteDataController | const [remoteError, setRemoteError] = React.useState<string>(""); |
| 16 | src/react/controllers/remoteDataController.ts:552 | useState | appController → useRemoteDataController | const [authSessionLifecycleRevision, setAuthSessionLifecycleRevision] = React.useState<number>(0); |
| 17 | src/react/controllers/remoteDataController.ts:561 | useState | appController → useRemoteDataController | const [connectivityStatus, setConnectivityStatus] = React.useState<ConnectivityState["status"] | null>(null); |
| 18 | src/react/controllers/remoteDataController.ts:581 | useState | appController → useRemoteDataController | const [appShellSnapshotState, setAppShellSnapshotState] = React.useState<AppShellSnapshotRuntimeState>( |
| 19 | src/react/controllers/remoteDataController.ts:587 | useState | appController → useRemoteDataController | const [screenLoadingLabel, setScreenLoadingLabel] = React.useState<string>(""); |
| 20 | src/react/controllers/remoteDataController.ts:595 | useState | appController → useRemoteDataController | const [dataTick, setDataTick] = React.useState<number>(0); |
| 21 | src/react/controllers/remoteDataController.ts:605 | useState | appController → useRemoteDataController | const [homeFragmentExpectation, setHomeFragmentExpectation] = React.useState(emptyHomeFragmentExpectation); |
| 22 | src/react/controllers/remoteDataController.ts:728 | useState | appController → useRemoteDataController | const [statsRecoveryState, updateStatsRecoveryState] = React.useState<{ status: StatsNumberStatus; requestedVersion: number }>({ status: "re |
| 23 | src/react/controllers/remoteDataController.ts:747 | useState | appController → useRemoteDataController | const [sessionSearchLoading, setSessionSearchLoading] = React.useState<boolean>(false); |
| 24 | src/react/controllers/remoteDataController.ts:748 | useState | appController → useRemoteDataController | const [sessionSearchStatus, setSessionSearchStatus] = React.useState<DataStatus>("idle"); |
| 25 | src/react/controllers/remoteDataController.ts:749 | useState | appController → useRemoteDataController | const [sessionSearchError, setSessionSearchError] = React.useState(""); |
| 26 | src/react/resources/useResourceSnapshot.ts:30 | useSyncExternalStore | appController → useRemoteDataController → useResourceSnapshot | const snapshot = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); |
| 27 | src/react/resources/useResourceSnapshot.ts:48 | useSyncExternalStore | appController → useRemoteDataController → useResourceSnapshot | return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); |
| 28 | src/react/resources/useResourceSnapshot.ts:60 | useSyncExternalStore | appController → useRemoteDataController → useResourceSnapshot | return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); |
| 29 | src/react/features/pr/queries/prExerciseQueryBinding.ts:87 | useState | appController → useRemoteDataController → usePrExerciseQueryBinding | const [activePrExerciseId, setActivePrExerciseId] = React.useState<string>(""); |
| 30 | src/react/features/pr/queries/prExerciseQueryBinding.ts:143 | useSyncExternalStore | appController → useRemoteDataController → usePrExerciseQueryBinding | const prExerciseDetails = React.useSyncExternalStore(subscribePrExerciseStores, readPrExerciseDetails, readPrExerciseDetails); |
| 31 | src/react/controllers/ownerScopeController.ts:12 | useState | appController → useOwnerScopeController | const [userScopedScreenStateRevision, setUserScopedScreenStateRevision] = React.useState(0); |
| 32 | src/react/features/social/friendScopeController.ts:43 | useSyncExternalStore | appController → useFriendScopeController | const state = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); |
| 33 | src/react/controllers/workoutDraftController.ts:62 | useSyncExternalStore | appController → useWorkoutDraftController | const draftState = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); |
| 34 | src/react/controllers/socialIdentityController.ts:39 | useState | appController → useSocialIdentityController | const [connections, setConnections] = React.useState<readonly SocialIdentityConnection[]>( |
| 35 | src/react/controllers/socialIdentityController.ts:42 | useState | appController → useSocialIdentityController | const [loading, setLoading] = React.useState(false); |
| 36 | src/react/controllers/socialIdentityController.ts:43 | useState | appController → useSocialIdentityController | const [error, setError] = React.useState(""); |
| 37 | src/react/controllers/socialIdentityController.ts:44 | useState | appController → useSocialIdentityController | const [pendingProvider, setPendingProvider] = React.useState<SocialAuthProvider | "">(""); |
| 38 | src/react/controllers/calendarReadModelController.ts:67 | useSyncExternalStore | appController → useCalendarReadModelController | } = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); |
| 39 | src/react/features/recovery/useRecoveryPanel.ts:22 | useState | appController → useRecoveryPanel | const [published, setPublished] = React.useState<{ revision: number; props: RecoveryPanelProps } | null>(null); |
| 40 | src/react/controllers/pendingWorkoutSavesController.ts:118 | useSyncExternalStore | appController → usePendingWorkoutSavesController | const { pendingWorkoutSaves } = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); |
| 41 | src/react/controllers/prController.ts:60 | useState | appController → usePrViewBinding → usePrController | const [prExerciseId, setPrExerciseIdState] = React.useState<string | null>(null); |
| 42 | src/react/controllers/prController.ts:129 | useState | appController → usePrViewBinding → usePrController | const [favoriteSnapshot, setFavoriteSnapshot] = React.useState<FavoriteSnapshot>(() => ( |
| 43 | src/react/controllers/prController.ts:133 | useState | appController → usePrViewBinding → usePrController | const [favoriteRead, setFavoriteRead] = React.useState<{ owner: string; status: "loading" | "ready" | "error" }>({ owner: "", status: "loadi |
| 44 | src/react/controllers/prController.ts:134 | useState | appController → usePrViewBinding → usePrController | const [favoriteReadRevision, setFavoriteReadRevision] = React.useState(0); |
| 45 | src/react/controllers/prController.ts:169 | useState | appController → usePrViewBinding → usePrController | const [prJump, setPrJump] = React.useState<PrSessionJump | null>(null); |
| 46 | src/react/controllers/prController.ts:170 | useState | appController → usePrViewBinding → usePrController | const [prDetailReturnTab, setPrDetailReturnTabState] = React.useState<NavigationTab | null>(null); |
| 47 | src/react/controllers/searchController.ts:95 | useState | appController → usePrViewBinding → usePrController → usePrExerciseSearchController | const [input, setInput] = React.useState(""); |
| 48 | src/react/controllers/searchController.ts:96 | useState | appController → usePrViewBinding → usePrController → usePrExerciseSearchController | const [query, setQuery] = React.useState(""); |
| 49 | src/react/controllers/searchController.ts:97 | useState | appController → usePrViewBinding → usePrController → usePrExerciseSearchController | const [isPending, setIsPending] = React.useState(false); |
| 50 | src/react/controllers/sessionDetailController.ts:53 | useSyncExternalStore | appController → useSessionDetailController | const { selectedSession, sessionDetailPendingId, sessionDetailError } = React.useSyncExternalStore( |
| 51 | src/react/features/catalog/catalogBinding.ts:63 | useState | appController → useCatalogFeatureBinding | const [exerciseSearchSignals, setExerciseSearchSignals] = React.useState<ExerciseSearchSignals | null>(null); |
| 52 | src/react/controllers/customExerciseController.ts:35 | useSyncExternalStore | appController → useCatalogFeatureBinding → useOwnCustomExerciseController | const { error, exercises, loading, pendingId, dataStatus } = React.useSyncExternalStore( |
| 53 | src/react/features/social/followingController.ts:35 | useSyncExternalStore | appController → useSocialRelationsBinding → useFeedFollowingController | const { following, results } = React.useSyncExternalStore( |
| 54 | src/react/features/social/feedSocialController.ts:36 | useSyncExternalStore | appController → useSocialRelationsBinding → useFeedSocialController | const { socialBySession, likePendingIds, comments } = React.useSyncExternalStore( |
| 55 | src/react/features/social/moderationController.ts:41 | useSyncExternalStore | appController → useSocialRelationsBinding → useModerationController | const moderation = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); |
| 56 | src/react/features/group/groupController.ts:36 | useSyncExternalStore | appController → useGroupController | const groupState = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); |
| 57 | src/react/controllers/planAdoptionController.ts:25 | useSyncExternalStore | appController → usePlanAdoptionController | const { friendCalendar, loading, error } = React.useSyncExternalStore( |
| 58 | src/react/features/pr/manualRecordsBinding.ts:54 | useState | appController → useManualRecordsBinding | const [writePending, setWritePending] = React.useState<{ owner: string; id: string } | null>(null); |
| 59 | src/react/features/import/importFeatureBinding.ts:31 | useState | appController → useImportOutcomeBinding → useImportFeatureBinding | const [selection, setSelection] = React.useState({ ownerId, jobId: restoreImportJobId(browserStorage(), ownerId) }); |
| 60 | src/react/features/import/importFeatureBinding.ts:32 | useState | appController → useImportOutcomeBinding → useImportFeatureBinding | const [local, setLocal] = React.useState({ phase: "", error: "", paused: false, retry: 0 }); |
3. 팬아웃 기준선 — 입력 21종별 실측
러너 scripts/render/render-fanout.mjs 9차 실행(2026-09-15 03:0x KST, 앱 e94335aa + Phase 1 계측 코드, vite dev 5610 · 프로파일 모드는 StrictMode 없이 그림 · 샌드박스 cil0c062380). 일회용 계정에 12주 × 2세션을 넣고 390×844 · CPU 4배로 부팅 → 탭 전환 → 일지 → 종목 상세 → 리포트 → 오프라인/온라인 → 토스트 → 운동 순으로 입력을 넣었다. 판정 = 계약 §4 규칙(update 커밋의 경계 집합 ⊆ 허용 집합, 루트 재실행 ≤ 허용 횟수). 원본 JSON·MD = wt1610/test-results/phase1610/render-fanout-run9.{json,md}(저장소 밖).
읽는 법 — 유저 A 의 하루로: A 가 일지 탭에서 어제 세션 카드를 누르면(session.open) 조립 루트가 4번 다시 실행되고, 보이지 않는 홈·피드·리포트 판과 드로어·탭 셸·하루 상세까지 8개 경계가 다시 그려진다. 허용은 달력 판 + 세션 상세 둘뿐이다. 반대로 운동 중 세트 무게를 한 글자 치면(workout.setInput) 루트 0·운동 층 하나만 다시 그려진다 — 이것이 전 입력이 도달해야 하는 모양이다. 측정: 2026-09-14T18:10:57.168Z · http://127.0.0.1:5610 · CPU 4배 · development (StrictMode) · 시드 세션 24 · 상태 failed-or-incomplete (locator.click: Timeout 30000ms exceeded.) · 표본 40
| 입력 종류 | 표본 수 | 루트 재실행(최대) | 전체 커밋(최대) | 다시 그려진 경계(합집합) | 허용 밖(합집합) | 판정 |
|---|---|---|---|---|---|---|
response.volumeOverview | 1 | 12 | 12 | pane:home, shell:drawer, shell:tabs | pane:home, shell:drawer, shell:tabs | ❌ 경계 위반 + 루트 초과 |
tab.switch | 14 | 5 | 11 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | ❌ 경계 위반 + 루트 초과 |
calendar.selectDay | 1 | 3 | 4 | pane:calendar, pane:home, pane:volume, shell:tabs, stack:journalDay | pane:home, pane:volume, shell:tabs, stack:journalDay | ❌ 경계 위반 + 루트 초과 |
session.open | 1 | 4 | 7 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:session | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay | ❌ 경계 위반 + 루트 초과 |
session.close | 1 | 1 | 3 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:session | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay | ❌ 경계 위반 + 루트 초과 |
stack.pushPop | 3 | 2 | 6 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, stack:journalDay, stack:prDetail, workout | pane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workout | ❌ 경계 위반 + 루트 초과 |
calendar.changeMonth | 4 | 6 | 8 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | ❌ 경계 위반 + 루트 초과 |
pr.open | 1 | 14 | 15 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetail | pane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs | ❌ 경계 위반 + 루트 초과 |
report.detailArrival | 1 | 0 | 4 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | pane:calendar, pane:feed, pane:home, shell:drawer, shell:tabs | ❌ 경계 위반 |
connectivity.change | 2 | 8 | 10 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | ❌ 경계 위반 + 루트 초과 |
toast | 2 | 4 | 6 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | ❌ 경계 위반 + 루트 초과 |
workout.setInput | 8 | 0 | 2 | workout | 없음 | ✅ 성립 |
workout.restTick | 1 | 0 | 0 | 없음 | 없음 | ✅ 성립 |
표본별 상세 (update = 다시 그려진 경계, mount = 처음 그려진 경계, 시간은 개발 React 기준 전후 비교용):
| # | 입력 종류 | 표본 | 루트 재실행 | 전체 커밋 | update | mount | 허용 밖 | 창 안 응답 도착 |
|---|---|---|---|---|---|---|---|---|
| 1 | response.volumeOverview | 부팅 뒤 미리 받기 응답 창(7초, 입력 없음) | 12 | 12 (222.6ms) | pane:home, shell:drawer, shell:tabs | — | pane:home, shell:drawer, shell:tabs | get_exercise_catalog@519ms/0k get_calendar_month_summary@68ms/0k get_calendar_day_summary@145ms/0k get_volume_overview@213ms/0k |
| 2 | tab.switch | 첫 방문: home → calendar | 5 | 9 (93.8ms) | pane:calendar, pane:home, shell:drawer, shell:tabs | pane:calendar | pane:calendar, pane:home, shell:drawer | get_calendar_month_summary@119ms/0k get_calendar_month_summary@125ms/0k |
| 3 | tab.switch | 첫 방문: calendar → home | 0 | 2 (11.8ms) | pane:calendar, pane:home, shell:drawer, shell:tabs | — | pane:calendar, pane:home, shell:drawer | get_calendar_month_summary@1504ms/0k |
| 4 | tab.switch | 첫 방문: home → feed | 4 | 11 (106.5ms) | pane:calendar, pane:feed, pane:home, shell:drawer, shell:tabs | pane:feed | pane:calendar, pane:feed, pane:home, shell:drawer | get_session_detail@21ms/0k list_following_v1@87ms/0k get_session_detail@593ms/0k get_session_detail@1152ms/0k get_session_detail@1773ms/0k get_session_detail@2314ms/0k … |
| 5 | tab.switch | 첫 방문: feed → home | 0 | 2 (12.7ms) | pane:calendar, pane:feed, pane:home, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, shell:drawer | get_session_detail@91ms/0k get_session_detail@695ms/0k get_session_detail@1307ms/0k get_session_detail@1825ms/0k |
| 6 | tab.switch | 첫 방문: home → volume (드로어) | 0 | 7 (132.9ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | pane:volume | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | get_session_detail@299ms/0k get_report_design_v1@736ms/0k get_session_detail@858ms/0k get_session_detail@1444ms/0k get_session_detail@2125ms/0k get_session_detail@2711ms/0k … |
| 7 | tab.switch | 첫 방문: volume → home | 0 | 2 (14.8ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | get_session_detail@275ms/0k get_session_detail@881ms/0k get_session_detail@1342ms/0k get_session_detail@1773ms/0k |
| 8 | tab.switch | 재방문: home → calendar | 0 | 3 (21.9ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | get_session_detail@317ms/0k get_session_detail@903ms/0k get_session_detail@1372ms/0k get_calendar_month_summary@1918ms/0k |
| 9 | tab.switch | 재방문: calendar → home | 0 | 2 (13.2ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | get_session_detail@389ms/0k get_session_detail@871ms/0k get_calendar_month_summary@1494ms/0k |
| 10 | tab.switch | 재방문: home → feed | 1 | 4 (20.1ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | get_calendar_month_summary@8ms/0k get_calendar_month_summary@520ms/0k get_calendar_month_summary@1063ms/0k get_calendar_month_summary@1560ms/0k |
| 11 | tab.switch | 재방문: feed → home | 0 | 2 (16.4ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | get_calendar_month_summary@7ms/0k |
| 12 | tab.switch | 재방문: home → volume (드로어) | 0 | 4 (25.5ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | — |
| 13 | tab.switch | 재방문: volume → home | 0 | 2 (14.4ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | — |
| 14 | calendar.selectDay | 일지에서 운동한 날짜 누름(하루 상세 push) | 3 | 4 (84.7ms) | pane:calendar, pane:home, pane:volume, shell:tabs, stack:journalDay | stack:journalDay | pane:home, pane:volume, shell:tabs, stack:journalDay | get_calendar_day_summary@148ms/0k |
| 15 | session.open | 하루 상세에서 세션 카드 누름 | 4 | 7 (189.1ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:session | stack:session | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay | get_session_detail@184ms/0k record_client_error_event@418ms/0k |
| 16 | session.close | 세션 상세 뒤로 | 1 | 3 (70.9ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:session | — | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay | — |
| 17 | stack.pushPop | 하루 상세 뒤로(pop) | 0 | 2 (20.9ms) | pane:calendar, shell:tabs, stack:journalDay | — | shell:tabs | — |
| 18 | calendar.changeMonth | 이전 달 1 | 6 | 7 (180.5ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | get_calendar_day_summary@301ms/0k get_calendar_month_summary@292ms/0k |
| 19 | calendar.changeMonth | 이전 달 2 | 6 | 8 (159.7ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | get_calendar_day_summary@243ms/0k get_calendar_month_summary@241ms/0k |
| 20 | calendar.changeMonth | 다음 달 1 | 1 | 4 (89.2ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — |
| 21 | calendar.changeMonth | 다음 달 2 | 1 | 3 (59.7ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — |
| 22 | pr.open | 홈 PR 보드 행 누름(종목 상세 push) | 14 | 15 (2752.7ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetail | stack:prDetail | pane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs | get_exercise_pr_detail@366ms/0k get_exercise_pr_detail_year@1209ms/0k get_exercise_pr_records@1431ms/0k get_exercise_pr_history@1478ms/0k record_client_error_event@2728ms/0k |
| 23 | stack.pushPop | 종목 상세 뒤로(pop) | 1 | 4 (119.5ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetail | — | pane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs | — |
| 24 | report.detailArrival | 드로어 리포트 진입(개요·상세 응답 도착) | 0 | 4 (18ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, shell:drawer, shell:tabs | — |
| 25 | tab.switch | volume → home | 0 | 2 (12.7ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | — |
| 26 | tab.switch | 재방문: home → volume (드로어, 유효 캐시) | 0 | 4 (23.9ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer | — |
| 27 | connectivity.change | 오프라인 전환 | 1 | 2 (3.6ms) | — | — | — | record_client_error_event@1230ms/0k |
| 28 | connectivity.change | 온라인 복귀 | 8 | 10 (557.7ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | record_client_error_event@99ms/0k record_client_error_event@367ms/0k get_profile_workspace@1451ms/0k get_onboarding_state@1455ms/0k get_home_dashboard@1542ms/0k |
| 29 | toast | 토스트 표시+소멸 1 | 4 | 6 (163.7ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | get_pr_overview@213ms/0k |
| 30 | toast | 토스트 표시+소멸 2 | 0 | 2 (1.3ms) | — | — | — | — |
| 31 | stack.pushPop | 운동 시작(운동 층 push) | 2 | 6 (160.7ms) | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workout | shell:workoutNav, workout | pane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workout | save_workout_draft_checkpoint_v2@436ms/0k record_client_error_event@468ms/0k |
| 32 | workout.setInput | 세트 입력 1 (무게=60) | 0 | 1 (5.8ms) | workout | — | — | — |
| 33 | workout.setInput | 세트 입력 2 (횟수=6) | 0 | 1 (4.8ms) | workout | — | — | — |
| 34 | workout.setInput | 세트 입력 3 (무게=70) | 0 | 1 (5.9ms) | workout | — | — | — |
| 35 | workout.setInput | 세트 입력 4 (횟수=8) | 0 | 1 (5.1ms) | workout | — | — | — |
| 36 | workout.setInput | 세트 추가(편집 모드) | 0 | 1 (6.6ms) | workout | — | — | — |
| 37 | workout.setInput | 세트 2 입력(무게=70·횟수=8) | 0 | 1 (4.5ms) | workout | — | — | — |
| 38 | workout.setInput | 세트 완료 체크(휴식 시작 + RPE 카드) | 0 | 1 (9.7ms) | workout | — | — | — |
| 39 | workout.setInput | RPE 선택(카드 닫힘) | 0 | 2 (8ms) | workout | — | — | — |
| 40 | workout.restTick | 휴식 타이머 진행 창(2.5초, 입력 없음) | 0 | 0 (0ms) | — | — | — | — |
미계측·주의:
workout.restTick(휴식 타이머 0.5초 진행): 세트 완료 뒤 휴식이 시작되지 않아 창 안 커밋 0 — 완료 처리 조건(마지막 세트·뒤 세트 채움 여부·휴식 건너뛰기)에 걸린 것으로 보이며 Phase 5 가 운동 층 내부 계측에서 직접 잰다. 코드 판독은 화면 전체 재렌더(setTick500ms).response.*8종은 이 러너에서 따로 떼어 재지 않았다 — 부팅 조용한 창(카탈로그·달력 월/일·리포트 개요 도착)과 첫 방문 창에 섞여 들어 있다. 창 안 응답 도착 열이 어느 응답이 그 창에 왔는지 보여 준다. Phase 3(응답 처리 층)이 응답 1건 단위로 분리해 잰다.response.sessionSearch: 검색 화면은 러너 경로 밖(Phase 3 에서 추가).owner.change: 전체 재조립 허용이라 재지 않는다.- 마지막 표본(운동 중 탭바로 홈 이탈)은 운동 층 탭바 클릭이 30초 안에 되지 않아 실패 — 상태
failed-or-incomplete는 그 한 단계뿐이며 앞 40표본은 완결이다. - 같은 절차를 7·8·9차 세 번 돌려 경계 집합·루트 재실행 수가 같았다(결정적). 시간(ms)은 개발 React 라 전후 비교용.
4. 구 #1603 계측 나머지 — 세 빌드 같은 날 판정
구 #1603 이 남긴 계측: 세 빌드를 같은 날(2026-09-15 03:12~03:52 KST)·같은 PC·같은 샌드박스(cil0c062380)·같은 fixture 계정(1년·4년) 에서 #1592 판정 러너(runner.mjs, 20표본, 4배 CPU)로 잰 묶음 3. baseline = #1596 이전 145923ff, final = #1596 결과 bc935f35, v0-19-2 = 지금 코드 e94335aa(Phase 1 계측 코드 없음, wt1610-base 깨끗한 체크아웃). 합격선 = 반응·공개 계약(입력→첫 반응 p95 ≤100ms, 마지막 데이터→공개 ≤100ms). 묶음 1·2 는 앞선 날(#1596 Phase 5)의 값이라 비교용으로만 남긴다.
읽는 법 — 유저 A 의 하루로: A 가 앱을 켜고 바로 리포트를 열면(report-open cold) v0.19.2 에서 손가락이 반응을 보기까지 p95 288ms, 숫자가 다 갖춰지기까지 1,150ms 다. #1596 이전(baseline)에는 첫 반응이 88ms 였고 완성이 1,501ms 였다 — #1596 은 완성을 350ms 당겼지만 첫 반응을 200ms 늦췄다. 일지 재방문(calendar-enter warm)도 56ms → 164ms 로 느려졌다. 이것이 Phase 3(응답 처리 층)이 되돌려야 할 두 회귀다.
| 빌드 | 시나리오 | fixture | 화면 | 캐시 | 묶음 | 표본 관측/전체 | 입력→첫 반응 p95/max(ms) | 동기 처리 max(ms) | 마지막 데이터→공개 p95/max(ms) | 입력→완성 p95/max(ms) | 필수 RPC | 재마운트 | 판정 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| baseline | calendar-enter | history-1y | mobile | cold | 3 | 20/20 | 98 / 104 | 41 | 211 / 233 | 535 / 537 | 3 | — | 불합격(dataToComplete) |
| final | calendar-enter | history-1y | mobile | cold | 3 | 20/20 | 103 / 103 | 44 | 232 / 281 | 534 / 600 | 3 | — | 불합격(meaningful,dataToComplete) |
| v0-19-2 | calendar-enter | history-1y | mobile | cold | 3 | 20/20 | 98 / 113 | 38 | 200 / 220 | 501 / 503 | 3 | — | 불합격(dataToComplete) |
| baseline | calendar-enter | history-1y | mobile | warm | 3 | 20/20 | 56 / 56 | 26 | — / -∞ | 56 / 56 | 0 | 0회 | 합격 |
| final | calendar-enter | history-1y | mobile | warm | 3 | 20/20 | 142 / 179 | 9 | — / -∞ | 142 / 179 | 0 | 0회 | 불합격(meaningful,complete) |
| v0-19-2 | calendar-enter | history-1y | mobile | warm | 3 | 20/20 | 164 / 171 | 8 | — / -∞ | 164 / 171 | 0 | 0회 | 불합격(meaningful,complete) |
| baseline | calendar-enter | history-4y | mobile | cold | 3 | 20/20 | 92 / 94 | 40 | 48 / 114 | 342 / 350 | 3 | — | 합격 |
| v0-19-2 | calendar-enter | history-4y | mobile | cold | 3 | 20/20 | 104 / 108 | 41 | 41 / 68 | 338 / 354 | 3 | — | 불합격(meaningful) |
| baseline | calendar-enter | history-4y | mobile | warm | 3 | 20/20 | 69 / 93 | 36 | — / -∞ | 69 / 93 | 0 | 0회 | 합격 |
| v0-19-2 | calendar-enter | history-4y | mobile | warm | 3 | 20/20 | 133 / 137 | 12 | — / -∞ | 133 / 137 | 0 | 0회 | 불합격(meaningful,complete) |
| baseline | calendar-month | history-1y | mobile | cold | 3 | 20/20 | 45 / 54 | 30 | 390 / 445 | 745 / 758 | 1 | — | 불합격(dataToComplete) |
| final | calendar-month | history-1y | mobile | cold | 3 | 20/20 | 49 / 49 | 18 | 190 / 215 | 467 / 499 | 1 | — | 불합격(dataToComplete) |
| v0-19-2 | calendar-month | history-1y | mobile | cold | 3 | 20/20 | 47 / 53 | 21 | 178 / 185 | 635 / 640 | 1 | — | 불합격(dataToComplete) |
| baseline | calendar-month | history-1y | mobile | warm | 3 | 20/20 | 38 / 39 | 26 | — / -∞ | 38 / 39 | 0 | 0회 | 합격 |
| final | calendar-month | history-1y | mobile | warm | 3 | 20/20 | 33 / 35 | 15 | — / -∞ | 33 / 35 | 0 | 0회 | 합격 |
| v0-19-2 | calendar-month | history-1y | mobile | warm | 3 | 20/20 | 23 / 24 | 17 | — / -∞ | 23 / 24 | 0 | 0회 | 합격 |
| baseline | calendar-month | history-4y | mobile | cold | 3 | 20/20 | 48 / 49 | 20 | 522 / 563 | 783 / 817 | 1 | — | 불합격(dataToComplete) |
| v0-19-2 | calendar-month | history-4y | mobile | cold | 3 | 20/20 | 50 / 50 | 20 | 335 / 360 | 658 / 684 | 1 | — | 불합격(dataToComplete) |
| baseline | calendar-month | history-4y | mobile | warm | 3 | 20/20 | 37 / 40 | 31 | — / -∞ | 37 / 40 | 0 | 0회 | 합격 |
| v0-19-2 | calendar-month | history-4y | mobile | warm | 3 | 20/20 | 24 / 24 | 12 | — / -∞ | 24 / 24 | 0 | 0회 | 합격 |
| baseline | report-open | history-1y | mobile | cold | 3 | 20/20 | 88 / 98 | 17 | 620 / 649 | 1,501 / 1,502 | 2 | — | 불합격(dataToComplete) |
| final | report-open | history-1y | mobile | cold | 3 | 20/20 | 260 / 280 | 22 | 340 / 378 | 1,204 / 1,253 | 2 | — | 불합격(meaningful,dataToComplete) |
| v0-19-2 | report-open | history-1y | mobile | cold | 3 | 20/20 | 288 / 333 | 22 | 249 / 295 | 1,150 / 1,244 | 2 | — | 불합격(meaningful,dataToComplete) |
| baseline | report-open | history-1y | mobile | warm | 3 | 20/20 | 153 / 181 | 26 | — / -∞ | 153 / 181 | 0 | 0회 | 불합격(meaningful,complete) |
| final | report-open | history-1y | mobile | warm | 3 | 20/20 | 119 / 121 | 11 | — / -∞ | 119 / 121 | 0 | 0회 | 불합격(meaningful,complete) |
| v0-19-2 | report-open | history-1y | mobile | warm | 3 | 20/20 | 122 / 122 | 12 | — / -∞ | 122 / 122 | 0 | 0회 | 불합격(meaningful,complete) |
| baseline | report-open | history-4y | mobile | cold | 3 | 20/20 | 86 / 237 | 19 | 696 / 786 | 1,628 / 1,818 | 2 | — | 불합격(dataToComplete) |
| v0-19-2 | report-open | history-4y | mobile | cold | 3 | 20/20 | 340 / 417 | 20 | 409 / 555 | 1,220 / 1,345 | 2 | — | 불합격(meaningful,dataToComplete) |
| baseline | report-open | history-4y | mobile | warm | 3 | 20/20 | 223 / 227 | 40 | — / -∞ | 223 / 227 | 0 | 0회 | 불합격(meaningful,complete) |
| v0-19-2 | report-open | history-4y | mobile | warm | 3 | 20/20 | 118 / 119 | 11 | — / -∞ | 118 / 119 | 0 | 0회 | 불합격(meaningful,complete) |
요약(p95, 세 빌드 같은 날):
| 시나리오 | fixture | 캐시 | 첫 반응 baseline → final → v0.19.2 | 마지막 데이터→공개 | 입력→완성 | 판정(v0.19.2) |
|---|---|---|---|---|---|---|
| calendar-enter | 1년 | cold | 98 → 103 → 98 | 211 → 232 → 200 | 535 → 534 → 501 | 불합격(공개 200 > 100) |
| calendar-enter | 1년 | warm | 56 → 142 → 164 | — | 56 → 142 → 164 | 불합격(회귀: 첫 반응 56 → 164) |
| calendar-enter | 4년 | cold | 92 → (묶음1·2 91·90) → 104 | 48 → 42·41 → 41 | 342 → 338·346 → 338 | 불합격(첫 반응 104 > 100, 경계) |
| calendar-enter | 4년 | warm | 69 → (묶음1·2 52·77) → 133 | — | — | 불합격(회귀) |
| calendar-month | 1년 | cold | 45 → 49 → 47 | 390 → 190 → 178 | 745 → 467 → 635 | 불합격(공개 178 > 100) |
| calendar-month | 1년 | warm | 38 → 33 → 23 | — | — | 합격 |
| calendar-month | 4년 | cold | 48 → (묶음1·2 37·49) → 50 | 522 → 290·349 → 335 | 783 → 654·669 → 658 | 불합격(공개) |
| calendar-month | 4년 | warm | 37 → (24·24) → 24 | — | — | 합격 |
| report-open | 1년 | cold | 88 → 260 → 288 | 620 → 340 → 249 | 1,501 → 1,204 → 1,150 | 불합격(회귀: 첫 반응 88 → 288) |
| report-open | 1년 | warm | 153 → 119 → 122 | — | — | 불합격(첫 반응 122) |
| report-open | 4년 | cold | 86 → (묶음2 388) → 340 | 696 → 417 → 409 | 1,628 → 1,252 → 1,220 | 불합격(회귀) |
| report-open | 4년 | warm | 223 → (105·88) → 118 | — | — | 불합격(118) |
측정된 한계·관찰:
- 10년 fixture: 샌드박스의 통계 계산이 60초 예산(statement timeout)을 넘겨 세대가 영원히 수렴하지 않는다 — 판정 불가(측정된 한계). 이 트랙은 통계 예산을 바꾸지 않으며 통계 담당에 관찰로 전달한다(구 #1603 항목 그대로).
- 관측기 비용: 러너는 DOM 이 바뀐 프레임에서만 판정을 다시 계산한다(2026-09-14 재계측 방식). 세 빌드가 같은 관측기를 쓰므로 비교는 유효하되 절대값에는 관측 비용이 섞여 있다.
- #1596 의 '리포트 재방문 120ms' 원인 분해는 이 묶음에서 warm report-open 119~122ms 로 재현됐다 — Phase 3 이 응답 채택·리포트 판 재렌더 비용을 분리해 다룬다.
5. Phase 2 결과 — 상태 모델 한 벌 뒤 재실측 (2026-09-15 11:40 KST)
Phase 2(2-1~2-6b, 앱 커밋 d4588a1e·26ed0986·6bf827ae·c7f82975·38b76974·54426ca4·a28965b2·64c5c113·96c0ab82) 뒤 같은 절차(러너 scripts/render/render-fanout.mjs, vite dev 5610 프로파일 모드, 샌드박스 cil0c062380, CPU 4배, 시드 24세션)로 다시 쟀다. 측정 2026-09-15T02:40:03.137Z · 상태 failed-or-incomplete(마지막 표본의 운동 종료 클릭 시간 초과 — §3 과 같은 한 단계, 앞 40표본 완결).
읽는 법 — 유저 A 의 하루로: A 가 일지에서 어제 세션 카드를 누르면(session.open) 조립 루트는 다시 실행되지 않고(4 → 0), 종목 상세를 열어도(pr.open, 14 → 0) 달을 넘겨도(calendar.changeMonth, 6 → 0) 마찬가지다. 앱을 켜고 7초 안에 도착하는 개요·피드·카탈로그 응답 묶음은 루트를 12번 깨우던 것이 1번(앱셸 데이터 적용)으로 줄었다. 아직 루트가 한 번 깨는 입력은 앱셸 상태(인증·데이터 상태, 앱셸 데이터 적용, 온라인 복귀 뒤 재적재)뿐이다.
| 입력 종류 | 표본 | 루트 재실행(최대) 기준선 → Phase 2 | 전체 커밋(최대) 기준선 → Phase 2 | 다시 그려진 경계(Phase 2, 합집합) | 판정 |
|---|---|---|---|---|---|
response.volumeOverview | 1 | 12 → 1 | 12 → 14 | pane:home, shell:drawer, shell:tabs | ❌ 루트 초과 · 경계 밖 3(Phase 4·6 몫) |
tab.switch | 14 | 5 → 0 | 11 → 11 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | ✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫) |
calendar.selectDay | 1 | 3 → 0 | 4 → 4 | pane:calendar, pane:home, pane:volume, shell:tabs, stack:journalDay | ✅ 루트 허용치 안 · 경계 밖 4(Phase 4·6 몫) |
session.open | 1 | 4 → 0 | 7 → 5 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:session | ✅ 루트 허용치 안 · 경계 밖 6(Phase 4·6 몫) |
session.close | 1 | 1 → 0 | 3 → 2 | pane:calendar, pane:feed, pane:home, pane:volume, shell:tabs, stack:journalDay, stack:session | ✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫) |
stack.pushPop | 3 | 2 → 0 | 6 → 5 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, stack:journalDay, stack:prDetail, workout | ✅ 루트 허용치 안 · 경계 밖 7(Phase 4·6 몫) |
calendar.changeMonth | 4 | 6 → 0 | 8 → 6 | pane:calendar, pane:home, pane:volume, shell:tabs | ✅ 루트 허용치 안 · 경계 밖 3(Phase 4·6 몫) |
pr.open | 1 | 14 → 0 | 15 → 3 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetail | ✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫) |
report.detailArrival | 1 | 0 → 0 | 4 → 4 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | ✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫) |
connectivity.change | 2 | 8 → 1 | 10 → 5 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | ❌ 루트 초과 · 경계 밖 6(Phase 4·6 몫) |
toast | 2 | 4 → 1 | 6 → 12 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | ❌ 루트 초과 · 경계 밖 6(Phase 4·6 몫) |
workout.setInput | 8 | 0 → 0 | 2 → 2 | workout | ✅ 루트 허용치 안 · 경계 성립 |
workout.restTick | 1 | 0 → 0 | 0 → 0 | 없음 | ✅ 루트 허용치 안 · 경계 성립 |
"경계 밖" 은 불변식 표의 허용 경계 밖 판(예: 탭 전환에 숨은 홈·피드·리포트 판이 다시 그려짐)이 아직 남아 있다는 뜻이다 — 화면 조각 구독(Phase 4)과 숨은 판 수명(Phase 6)의 몫이며 Phase 2 의 목표(루트 재실행·컨텍스트 값 변경)와는 다른 축이다.
5-1. 장부 B — Phase 2 뒤 (11)
장부 B — 조립 루트 갱신원 11개 (useState 8 · useReducer 0 · useSyncExternalStore 3) · 파일 5
| # | 파일:줄 | 종류 | 경로 | 코드 |
|---|---|---|---|---|
| 1 | src/react/controllers/navigationController.ts:28 | useSyncExternalStore | appController → useNavigationController | const tab = React.useSyncExternalStore( |
| 2 | src/react/features/home/homeSnapshotBinding.ts:7 | useState | appController → useHomeSnapshotBinding | const [shellGymData, setGymData] = React.useState<RuntimeGymData>(() => createRuntimeGymData()); |
| 3 | src/react/features/home/homeSnapshotBinding.ts:8 | useState | appController → useHomeSnapshotBinding | const [plans, setPlans] = React.useState<SessionPresentationInput[]>([]); |
| 4 | src/react/controllers/remoteDataController.ts:542 | useState | appController → useRemoteDataController | const [authStatus, setAuthStatusState] = React.useState<AuthStatus>("loading"); |
| 5 | src/react/controllers/remoteDataController.ts:543 | useState | appController → useRemoteDataController | const [dataStatus, setDataStatusState] = React.useState<DataStatus>("idle"); |
| 6 | src/react/controllers/remoteDataController.ts:559 | useState | appController → useRemoteDataController | const [remoteUser, setRemoteUser] = React.useState<StatsAuthUser | null>(null); |
| 7 | src/react/controllers/remoteDataController.ts:572 | useSyncExternalStore | appController → useRemoteDataController | const profileGateKey = React.useSyncExternalStore(subscribeProfileGate, readProfileGateKey, readProfileGateKey); |
| 8 | src/react/controllers/remoteDataController.ts:580 | useState | appController → useRemoteDataController | const [remoteError, setRemoteError] = React.useState<string>(""); |
| 9 | src/react/controllers/remoteDataController.ts:616 | useState | appController → useRemoteDataController | const [appShellSnapshotState, setAppShellSnapshotState] = React.useState<AppShellSnapshotRuntimeState>( |
| 10 | src/react/controllers/ownerScopeController.ts:12 | useState | appController → useOwnerScopeController | const [userScopedScreenStateRevision, setUserScopedScreenStateRevision] = React.useState(0); |
| 11 | src/react/controllers/calendarReadModelController.ts:178 | useSyncExternalStore | appController → useCalendarReadModelCommands | } = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); |
- 60 → 11. 남은 11 = 앱셸 상태 6(인증 상태·데이터 상태·remoteUser·오류 문구·앱셸 스냅샷 상태·오너 개정 번호) + 앱셸 데이터 2(
gymData·plans, 앱셸 RPC 응답 적용) + 프로필 관문 키 1(온보딩 필요 여부·표시 이름·사진·id 만 원시 키로 — 그 밖의 프로필 변경은 루트를 깨우지 않음) + PC 탭 1(데스크톱 셸이 ctx.tab 으로 화면을 고름, 모바일은 비반응) + 생성기 오탐 1(useCalendarReadModelView가 루트가 부르는 명령 훅과 같은 파일에 있어 잡힘 — 실제 루트 구독 아님). - 계획의 "≤5" 에 못 미친 것은
gymData·plans(앱셸 데이터)와 오류 문구·프로필 관문 키다.gymData해체(슬롯 18개 → 자원, 소비 파일 36)는 Phase 2 안에 넣지 않았다 — 앱셸 응답 적용은 부팅·재적재 때만 일어나는 루트 갱신이라 팬아웃 목표(응답·날짜·세션·종목·연결 변화의 루트 재실행 0)에는 이미 영향이 없고, 슬롯별 소비자 이동은 별도 트랙 크기다(남은 것으로 이관, §5-3).
5-2. 상태의 사는 곳 — Phase 2 뒤
- 오너 범위 스토어(
ownerStores)에 새로 든 것: 일지 화면 상태(calendarScreen: 선택일·세션 날짜·표시 달), PR 화면 상태(prScreen: 종목 id·활성 종목·PR 점프·복귀 탭·즐겨찾기 읽음 상태). - 값 스토어(
controllers/valueStore.ts)로 든 것: 화면 로딩 라벨, 데이터 개정 번호, 세션 검색 결과·상태, 소셜 로그인 연결 상태, 통계 복구 상태, 홈 힌트, 홈 데이터 상태, 연결 상태, 인증 세션 생애주기 개정 번호, 법적 문서 경로. - 뷰 제공자(셸 아래, 스토어를 구독해 종전 컨텍스트 모양을 그대로 제공): Workout·Catalog·Profile·Session·Pr(+Volume)·Home·Social(+Moderation·Group)·Search·Recovery. 루트가 내리는 것은 각 제공자의 입력 컨텍스트(스토어 손잡이·명령·드물게 바뀌는 앱셸 값)뿐이다.
- 루트 명령은 값을 렌더 시점에 닫아 두지 않고 호출 시점 게터로 읽는다(
readDraft·readSelectedSession·readWorkoutOriginTab·readPendingWorkoutSessions·readStatsRecovery·readRemoteProfile…).
5-3. Phase 2 에서 남긴 것
| 항목 | 상태 | 어디서 |
|---|---|---|
gymData 해체(슬롯 18 → 0)와 plans 자원화 | 미착수 — 앱셸 응답 적용 때만 루트를 깨우므로 팬아웃 목표에 영향 없음 | 별도 트랙(이 이슈 마무리 때 후속 이슈로) |
카탈로그 이중 저장(gymData.EXERCISES ↔ 카탈로그 자원) 삭제 | 미착수 — 위와 같은 묶음 | 위와 같음 |
컨텍스트 15개 → 손잡이·명령만, useStableProviderValue 층 삭제 | 루트 쪽은 완료(입력 컨텍스트 = 손잡이·명령). 소비자 쪽 값 컨텍스트와 안정화 층은 화면이 조각 구독으로 바뀌는 Phase 4 와 함께 | Phase 4 |
| 장부 생성기가 같은 파일의 뷰 훅을 루트 구독으로 오인(1건) | 기록만 | Phase 7 게이트 정리 때 훅 단위로 |
6. Phase 3 결과 — 응답 처리 층 (2026-09-15 14:30 KST)
읽는 법 — 유저 A 의 하루로: A 가 앱을 켜고 홈이 뜨자마자 리포트를 누른다. 그 순간 뒤에서는 리포트 개요(1년 약 600KB·4년 약 1.2MB)·종목 카탈로그(약 800KB) 미리 받기가 도착해 검증·투영되고 있다. v0.19.2 에서는 그 처리가 A 의 누름과 같은 차례에 끼어 첫 반응이 p95 288ms(1년)·340ms(4년)였다. Phase 2 가 조립 루트 재실행을 없애 첫 반응을 100/72ms 로 만들었고, Phase 3 은 응답 처리 층의 규칙 여섯 개(계약 §12)로 미리 받기가 A 의 누름과 A 가 연 화면의 로딩을 방해하지 않게 했다: 겹침 사례(report-open-collide)에서 첫 반응 p95 103/82ms·100ms 초과 표본 0, 일지 재방문 164 → 49ms, 일지 첫 진입의 "데이터 도착 → 공개" 201 → 47ms. 남은 것은 리포트의 "데이터 도착 → 공개"(794/182ms)와 리포트 재방문(206/284ms)이며, 6-1 의 CPU 내역대로 리포트 판 자체의 렌더·관측 비용이라 Phase 5(화면 내부)의 몫이다.
적용한 것 (계약 = 자원 통합 계약 §12):
| # | 규칙 | 코드 | 커밋 |
|---|---|---|---|
| ① | 채택 알림은 그 키 구독자에게만, 구독자는 자기가 쓰는 조각만 — 홈·달력은 복구 상태 뷰(상태·요청 세대)만 구독 | controllers/statsResources.ts useStatsRecoveryView·usePrOverviewSnapshot | 5e7c77a1 |
| ② | 큰 미리 받기는 유저의 요청 사슬이 끝나고 화면이 그려진 뒤, 입력이 없는 창에서 시작(입력 700ms 없음 + 유저의 요청 진행 중 아님 + 마지막 요청 뒤 1.5초, 상한 6초) | services/inputActivity.ts waitForInputQuiet — 탭 워밍 features/navigation/tabWarmBinding.ts, 홈 PR 개요·카탈로그 동기화 controllers/screenLoadPolicyController.ts | cddd0679 → a725afd3 |
| ③ | 응답 처리는 입력에 양보(검증·투영 앞 yieldIfInputBusy, 200KB 이상은 채택 앞 한 번 더), 배경 응답은 유저의 요청 사슬이 끝날 때까지 미룸(상한 3초) | services/screenRpcRuntime.ts waitForForegroundQuiet | cddd0679 → fd15a765 |
| ④ | 투영은 응답이 바꾼 것만 — 리포트 개요 투영을 PR 보드·카드 계산과 분리 | features/pr/PrViewProvider.tsx | cddd0679 |
| ⑤ | 워커 채택은 실측으로(6-1) | — | 채택하지 않음 |
| ⑥ | 요청의 출처 = 화면 공개가 기다리지 않는 순수 미리 받기(탭 워밍의 리포트 개요)만 호출자가 origin: "background" 로 표시, 표시가 없으면 유저의 요청(미룸 없음), 유저가 같은 자원을 기다리면 진행 중인 미리 받기를 승격 | services/inputActivity.ts beginScreenWork·promoteScreenWork, 저장소·자원 로더·remoteDataController.ts 로 origin 전달 | fd15a765 → 5862b997 → da30b098 |
| 계측 | 완료 기록 screen_rpc:completed 에 adaptMs(재측정+검증·투영)·measureMs(바이트 재직렬화)·origin | services/screenRpcRuntime.ts | 6b5909f0·fd15a765 |
왜 여섯 개가 됐나 — 실측이 고친 설계 세 번:
- 처음 ②는 "마지막 입력 뒤 700ms" 였다(p3 빌드). 판정 40표본에서 4년 fixture 의 달력 cold 첫 반응이 104 → 196ms 로 나빠졌고, CPU 프로필(6-1)에서 원인이 보였다 — 누른 화면의 응답 처리가 정확히 그 700ms 뒤에 일어나므로, 미리 받기의 시작이 그 처리와 겹쳤다(리포트 공개 창에 카탈로그 검증·저장
exerciseCatalogStore.put·screenRpcAdapters문자열 검사·UTF-8 인코딩 약 80~150ms@4x). → "유저의 요청이 끝나고 화면이 그려진 뒤"(3-4). - 시작을 미뤄도 이미 나간 미리 받기의 응답은 그 뒤 유저가 연 화면의 로딩 도중 도착한다(p3c 프로필: 홈에서 시작한 카탈로그 응답이 리포트 열기 −34 → 509ms 에 도착해 개요 투영·렌더와 설계 요청 사이에 끼어 설계 요청이 332 → 889ms 로 늦어짐). → 배경 응답의 처리를 유저의 요청 사슬 뒤로(3-5). 그리고 출처를 시작 시각만으로 판정하면 조용한 창 판정 뒤 실제 요청까지 수백 ms 걸리는 카탈로그 동기화가 입력 직후에 시작해 "유저의 요청" 으로 오판된다(p3d 프로필 #2) → 호출자 표시 + 합류 승격(3-6).
- 시작 시각 추정을 남겨 둔 채 판정하니(p3e) 반대 방향의 오판이 나왔다 — 탭 워밍이 조용한 창에 시작한 달력 이번달 요청(추정 = 배경)에 유저의 달력 진입이 합류했는데 승격 자리가 없어 응답 채택이 유저의 요청 사슬 뒤(1.5초 + 상한)까지 미뤄졌다(달력 cold 데이터→공개 201 → 1,717ms, 40표본 중 20건). 그리고 카탈로그 동기화·홈 PR 개요는 화면 공개가 그 응답을 기다리므로(카탈로그 공개 = 동기화 진행 중이면 pending) 배경이 아니다. → 출처는 호출자 표시로만, 표시 없음 = 유저의 요청, 배경은 순수 미리 받기(탭 워밍의 리포트 개요)에만, 배경 응답 미룸에 입력 조용함 추가(3-7).
판정 — #1592 판정 러너(runner.mjs, 20표본, 4배 CPU), 같은 PC·같은 샌드박스(cil0c062380)·같은 fixture(1년·4년), 라벨 빌드를 자기 포트의 preview 로. v0-19-2 = e94335aa(묶음 3, §4), p2 = Phase 2 결과 96c0ab82(묶음 4), p3 = ②(입력 700ms만)·③·④·계측 6b5909f0(묶음 4·5), p3e = ①~⑥ 5862b997(묶음 4·5, 소스맵 빌드). 합격선 = 반응·공개 계약(첫 반응 p95 ≤100, 마지막 데이터→공개 p95 ≤100, warm 완성 ≤100). 묶음을 합친(pooled) p95.
| 빌드 | 시나리오 | fixture | 캐시 | 묶음 | 표본 관측/전체 | 입력→첫 반응 p95/max(ms) | 동기 처리 max(ms) | 마지막 데이터→공개 p95/max(ms) | 입력→완성 p95/max(ms) | 판정 |
|---|---|---|---|---|---|---|---|---|---|---|
| p2 | report-open-collide | history-1y | cold | 4 | 20/20 | 84 / 84 | 14 | 335 / 346 | 1,237 / 1,284 | 불합격(dataToComplete) |
| p3 | report-open-collide | history-1y | cold | 4·5 | 39/40 | 71 / 84 | 15 | 239 / 350 | 1,020 / 1,037 | 불합격(dataToComplete) · 표본 1건 실패(러너) |
| p3e | report-open-collide | history-1y | cold | 4·5 | 40/40 | 85 / 86 | 16 | 238 / 264 | 868 / 903 | 불합격(dataToComplete) |
| p3f | report-open-collide | history-1y | cold | 4·5 | 40/40 | 103 / 123 | 19 | 379 / 455 | 997 / 1,240 | 불합격(meaningful,dataToComplete) |
| p2 | report-open-collide | history-4y | cold | 4 | 20/20 | 72 / 86 | 15 | 484 / 489 | 1,308 / 1,383 | 불합격(dataToComplete) |
| p3 | report-open-collide | history-4y | cold | 4·5 | 40/40 | 72 / 81 | 16 | 444 / 462 | 1,269 / 1,305 | 불합격(dataToComplete) |
| p3e | report-open-collide | history-4y | cold | 4·5 | 40/40 | 73 / 85 | 13 | 236 / 275 | 1,005 / 1,020 | 불합격(dataToComplete) |
| p3f | report-open-collide | history-4y | cold | 4·5 | 40/40 | 82 / 149 | 26 | 308 / 594 | 1,158 / 1,916 | 불합격(dataToComplete) |
| v0-19-2 | report-open | history-1y | cold | 3 | 20/20 | 290 / 333 | 22 | 251 / 295 | 1,154 / 1,244 | 불합격(meaningful,dataToComplete) |
| p2 | report-open | history-1y | cold | 4 | 20/20 | 101 / 116 | 25 | 361 / 368 | 1,383 / 1,383 | 불합격(meaningful,dataToComplete) |
| p3 | report-open | history-1y | cold | 4·5 | 40/40 | 87 / 96 | 27 | 333 / 369 | 1,076 / 1,202 | 불합격(dataToComplete) |
| p3e | report-open | history-1y | cold | 4·5 | 40/40 | 101 / 112 | 25 | 243 / 267 | 837 / 1,112 | 불합격(meaningful,dataToComplete) |
| p3f | report-open | history-1y | cold | 4·5 | 40/40 | 232 / 247 | 60 | 794 / 875 | 1,922 / 2,017 | 불합격(meaningful,sync,dataToComplete) |
| v0-19-2 | report-open | history-1y | warm | 3 | 20/20 | 122 / 122 | 12 | — | 122 / 122 | 불합격(meaningful,complete) |
| p2 | report-open | history-1y | warm | 4 | 20/20 | 149 / 149 | 16 | — | 149 / 149 | 불합격(meaningful,complete) |
| p3 | report-open | history-1y | warm | 4·5 | 40/40 | 147 / 151 | 17 | — | 147 / 151 | 불합격(meaningful,complete) |
| p3e | report-open | history-1y | warm | 4·5 | 40/40 | 137 / 252 | 26 | — | 137 / 252 | 불합격(meaningful,complete) |
| p3f | report-open | history-1y | warm | 4·5 | 40/40 | 206 / 239 | 21 | — | 206 / 239 | 불합격(meaningful,complete) |
| v0-19-2 | report-open | history-4y | cold | 3 | 20/20 | 343 / 417 | 20 | 417 / 555 | 1,226 / 1,345 | 불합격(meaningful,dataToComplete) |
| p2 | report-open | history-4y | cold | 4 | 20/20 | 72 / 73 | 13 | 522 / 543 | 1,344 / 1,420 | 불합격(dataToComplete) |
| p3 | report-open | history-4y | cold | 4·5 | 40/40 | 166 / 217 | 28 | 516 / 672 | 1,565 / 1,665 | 불합격(meaningful,dataToComplete) |
| p3e | report-open | history-4y | cold | 4·5 | 40/40 | 97 / 100 | 30 | 169 / 191 | 1,069 / 1,099 | 불합격(dataToComplete) |
| p3f | report-open | history-4y | cold | 4·5 | 40/40 | 97 / 102 | 29 | 182 / 187 | 1,034 / 1,099 | 불합격(dataToComplete) |
| v0-19-2 | report-open | history-4y | warm | 3 | 20/20 | 118 / 119 | 11 | — | 118 / 119 | 불합격(meaningful,complete) |
| p2 | report-open | history-4y | warm | 4 | 20/20 | 137 / 150 | 16 | — | 137 / 150 | 불합격(meaningful,complete) |
| p3 | report-open | history-4y | warm | 4·5 | 40/40 | 124 / 134 | 15 | — | 124 / 134 | 불합격(meaningful,complete) |
| p3e | report-open | history-4y | warm | 4·5 | 40/40 | 136 / 149 | 17 | — | 136 / 149 | 불합격(meaningful,complete) |
| p3f | report-open | history-4y | warm | 4·5 | 40/40 | 284 / 336 | 33 | — | 284 / 336 | 불합격(meaningful,complete) |
| v0-19-2 | calendar-enter | history-1y | cold | 3 | 20/20 | 99 / 113 | 38 | 201 / 220 | 501 / 503 | 불합격(dataToComplete) |
| p2 | calendar-enter | history-1y | cold | 4 | 20/20 | 104 / 124 | 39 | 192 / 192 | 478 / 498 | 불합격(meaningful,dataToComplete) |
| p3 | calendar-enter | history-1y | cold | 4·5 | 40/40 | 101 / 115 | 46 | 55 / 70 | 355 / 363 | 불합격(meaningful) |
| p3e | calendar-enter | history-1y | cold | 4·5 | 40/40 | 208 / 221 | 91 | 1,717 / 1,861 | 2,015 / 2,303 | 불합격(meaningful,sync,dataToComplete,complete) |
| p3f | calendar-enter | history-1y | cold | 4·5 | 40/40 | 103 / 115 | 47 | 47 / 51 | 360 / 405 | 불합격(meaningful) |
| v0-19-2 | calendar-enter | history-1y | warm | 3 | 20/20 | 164 / 171 | 8 | — | 164 / 171 | 불합격(meaningful,complete) |
| p2 | calendar-enter | history-1y | warm | 4 | 19/20 | 157 / 198 | 10 | — | 157 / 198 | 불합격(meaningful,complete) · 표본 1건 실패(러너) |
| p3 | calendar-enter | history-1y | warm | 4·5 | 40/40 | 51 / 67 | 11 | — | 51 / 67 | 합격 |
| p3e | calendar-enter | history-1y | warm | 4·5 | 40/40 | 66 / 80 | 13 | — | 66 / 80 | 합격 |
| p3f | calendar-enter | history-1y | warm | 4·5 | 40/40 | 49 / 65 | 11 | — | 49 / 65 | 합격 |
| v0-19-2 | calendar-enter | history-4y | cold | 3 | 20/20 | 104 / 108 | 41 | 43 / 68 | 339 / 354 | 불합격(meaningful) |
| p2 | calendar-enter | history-4y | cold | 4 | 20/20 | 91 / 108 | 32 | 64 / 112 | 338 / 349 | 합격 |
| p3 | calendar-enter | history-4y | cold | 4·5 | 40/40 | 196 / 212 | 82 | 278 / 296 | 702 / 778 | 불합격(meaningful,sync,dataToComplete) |
| p3e | calendar-enter | history-4y | cold | 4·5 | 40/40 | 113 / 125 | 49 | 1,689 / 1,760 | 1,992 / 2,069 | 불합격(meaningful,dataToComplete) |
| p3f | calendar-enter | history-4y | cold | 4·5 | 40/40 | 212 / 515 | 105 | 329 / 479 | 816 / 948 | 불합격(meaningful,sync,dataToComplete) |
| v0-19-2 | calendar-enter | history-4y | warm | 3 | 20/20 | 133 / 137 | 12 | — | 133 / 137 | 불합격(meaningful,complete) |
| p2 | calendar-enter | history-4y | warm | 4 | 20/20 | 100 / 137 | 21 | — | 100 / 137 | 불합격(meaningful,complete) |
| p3 | calendar-enter | history-4y | warm | 4·5 | 40/40 | 97 / 122 | 18 | — | 97 / 122 | 합격 |
| p3e | calendar-enter | history-4y | warm | 4·5 | 40/40 | 47 / 49 | 12 | — | 47 / 49 | 합격 |
| p3f | calendar-enter | history-4y | warm | 4·5 | 40/40 | 51 / 67 | 14 | — | 51 / 67 | 합격 |
요약(p95, 묶음 합침):
| 시나리오 | fixture | 캐시 | 첫 반응 p95 v0-19-2 → p2 → p3 → p3e → p3f | 마지막 데이터→공개 p95 | 입력→완성 p95 | 판정(p3f) |
|---|---|---|---|---|---|---|
| report-open-collide | 1년 | cold | — → 84 → 71 → 85 → 103 | — → 335 → 239 → 238 → 379 | — → 1,237 → 1,020 → 868 → 997 | 불합격(첫 반응 103, 공개 379) |
| report-open-collide | 4년 | cold | — → 72 → 72 → 73 → 82 | — → 484 → 444 → 236 → 308 | — → 1,308 → 1,269 → 1,005 → 1,158 | 불합격(공개 308) |
| report-open | 1년 | cold | 290 → 101 → 87 → 101 → 232 | 251 → 361 → 333 → 243 → 794 | 1,154 → 1,383 → 1,076 → 837 → 1,922 | 불합격(첫 반응 232, 공개 794) |
| report-open | 1년 | warm | 122 → 149 → 147 → 137 → 206 | — | 122 → 149 → 147 → 137 → 206 | 불합격(첫 반응 206, 완성 206) |
| report-open | 4년 | cold | 343 → 72 → 166 → 97 → 97 | 417 → 522 → 516 → 169 → 182 | 1,226 → 1,344 → 1,565 → 1,069 → 1,034 | 불합격(공개 182) |
| report-open | 4년 | warm | 118 → 137 → 124 → 136 → 284 | — | 118 → 137 → 124 → 136 → 284 | 불합격(첫 반응 284, 완성 284) |
| calendar-enter | 1년 | cold | 99 → 104 → 101 → 208 → 103 | 201 → 192 → 55 → 1,717 → 47 | 501 → 478 → 355 → 2,015 → 360 | 불합격(첫 반응 103) |
| calendar-enter | 1년 | warm | 164 → 157 → 51 → 66 → 49 | — | 164 → 157 → 51 → 66 → 49 | 합격 |
| calendar-enter | 4년 | cold | 104 → 91 → 196 → 113 → 212 | 43 → 64 → 278 → 1,689 → 329 | 339 → 338 → 702 → 1,992 → 816 | 불합격(첫 반응 212, 공개 329) |
| calendar-enter | 4년 | warm | 133 → 100 → 97 → 47 → 51 | — | 133 → 100 → 97 → 47 → 51 | 합격 |
판정 읽기 — 어느 표본을 믿는가: p3f 묶음 4·5 는 같은 PC 의 release 큐 러너 작업(13:44·14:09, 다른 세션의 PR Merge Check·fixture export)과 겹쳐 셀별로 2~3배 흔들렸다 — 묶음 4 의 달력 4년 cold(첫 반응 245, 클릭 동기 처리 105ms)·리포트 4년 warm(328), 묶음 5 의 리포트 1년 cold(245, 동기 60ms)· 리포트 1년 warm(209)·겹침 1년(공개 412). 같은 셀의 다른 묶음은 91·109·87·123·313 이고, 클릭 동기 처리(코드가 같은 경로)가 24 → 60~105ms 로 늘어난 것이 기계 부하의 증거다. 러너 큐가 비지 않아 "기계가 조용할 때만 셀을 시작하는" 재판정(judge-quiet.sh)은 시작하지 못했고 오너 지시로 중단했다 — 최종 판정은 Phase 7 전수 판정에서 최종 커밋으로 다시 잰다. 아래 판단은 오염되지 않은 묶음(p3e 전부 + p3f 의 겹치지 않은 셀)으로 한다.
합격선 대비:
- 겹침 사례 입력 지연 0 — 충족: report-open-collide 첫 반응 p95 85/73(p3e, 1·4년), 100ms 초과 표본 0/80. (p3f 103/82 는 러너와 겹친 묶음 5 의 표본 3건이 섞인 값.)
- 리포트·달력 cold 첫 반응 p95 ≤100 — 경계: 리포트 4년 97 ✓, 리포트 1년 101, 달력 1년 103, 달력 4년 91~113(p3f 묶음 5 = 91 ✓). 남은 것은 판 진입 렌더(Phase 5).
- 마지막 데이터→공개 p95 ≤100 — 달력 충족, 리포트 미충족: 달력 1년 201 → 47 ✓·4년 43 → 47 ✓. 리포트 1년 251 → 243(p3e)·4년 417 → 169~182 — 응답 처리 몫은 창 밖으로 나갔고(6-1: 창 안 미리 받기 처리 0), 남은 것은 리포트 판이 마지막 데이터 뒤에 만드는 렌더·DOM 변경(Phase 5).
- 완성 시간 비악화 — 충족: 리포트 cold 1,154 → 837·1,226 → 1,034, 달력 cold 501 → 360·339 → 357, 일지 재방문 164 → 49·133 → 51.
- 리포트 재방문(warm) — 미충족(v0.19.2 도 미충족): 122 → 137(p3e)·118 → 136. 판 재표시의 스타일·레이아웃·페인트(Phase 5·6).
- 워커 채택 여부와 근거 — 기록(6-1): 채택하지 않음.
6-1. 응답 처리 비용 분해 — 어댑터 남은 몫(④)과 워커 판정(⑤)
CPU 프로필(profile-runner.mjs, 5표본 중앙값, 4배 CPU, 소스맵 빌드) — 리포트 열기 cold 1년, "마지막 데이터 → 공개" 창:
| 빌드 | 창 wall | CPU 합 = 앱 + 러너 관측기 | 앱 몫 상위 | 창에 든 미리 받기 처리 |
|---|---|---|---|---|
| v0-19-2-sm | 186ms | 185 = 121 + 63 | (program: 스타일·레이아웃·페인트) 84 · react-core 14 · app:service 5 | 없음 |
| p3b (①~④, 입력 700ms 창) | 411ms | 409 = 332 + 72 | app:service 78 · native:encode 29 · exerciseCatalogStore.put 26 · screenRpcAdapters 문자열 검사 25 | 카탈로그 검증·저장 약 150ms |
| p3c (②=요청 끝난 뒤) | 289ms | 286 = 196 + 106 | (program) 125 · react-core 23 | 없음 — 대신 설계 요청이 늦어짐(카탈로그 응답이 개요 투영·렌더 뒤에 끼어듦) |
| p3d (③=배경 응답 미룸) | 241ms | 240 = 150 + 87 | (program) 102 · react-core 15 · GC 13 | 없음(표본 1건에서 입력 직후 시작한 카탈로그가 유저 요청으로 오판돼 끼어듦) |
| p3e (⑥=출처 표시·승격) | 263ms | 262 = 144 + 87 | (program) 92 · react-core 19 · GC 11 | 없음 |
- v0.19.2 와 p3e 의 차이(창 wall +77ms 중앙값, 5표본)는 앱 몫 +23(GC 11·react 5·스타일/레이아웃 7)과 러너 관측기 +24 다 — 응답 처리 층이 아니라 리포트 판이 마지막 데이터 뒤에 만드는 DOM 변경량(관측기는 DOM 이 바뀐 프레임마다 판정을 다시 계산한다)의 문제로, Phase 5 에서 리포트 판의 커밋 수·변경 범위를 줄일 때 함께 본다.
응답 하나(리포트 개요)를 메인 스레드가 처리하는 비용 — 브라우저 실측(adapt-probe.mjs, p3e, 4배 CPU, 5표본 중앙값):
| RPC(응답) | 크기 | 전송+파싱(durationMs) | 응답 처리 adaptMs(재측정+검증·투영) | 그중 바이트 재측정 measureMs |
|---|---|---|---|---|
get_exercise_catalog | 736KB | 594 | 77 | 19 |
get_volume_overview(1년) | 497KB | 202 | 44 | 12 |
get_profile_feed | 75KB | 297 | 11 | 3 |
get_pr_overview | 57KB | 413 | 8 | 2 |
get_home_dashboard | 26KB | 285 | 9 | 1 |
get_calendar_month_summary | 32KB | 131 | 8 | 1 |
| 작은 응답 4종(1~2KB) | — | — | 1~2 | 0 |
(4년 fixture 의 리포트 개요는 1,154KB — 프로필의 응답 처리 구간으로 약 90ms@4x.)
node 1x 재현(679KB 개요형 페이로드): JSON.parse 2.6ms · JSON 재직렬화+UTF-8 4.1ms(≈16ms@4x) · 원문 TextEncoder 1.2ms(≈5ms@4x) · structuredClone 5.9ms(≈24ms@4x) · 필드 walk 0.6ms.
워커(⑤) 판정 — 채택하지 않는다. 워커로 옮길 수 있는 것은 파싱·바이트 재측정·검증·투영이고(1년 개요 adaptMs 44ms@4x + 파싱 약 10ms@4x), 대신 결과를 메인으로 돌려받는 구조적 복제가 약 24ms@4x(1년)·45ms@4x(4년) 든다. 순이득 약 20ms@4x(1년, 44 − 24)·45ms@4x(4년, 90 − 45)는 (a) 겹침 사례의 입력 지연이 ②③⑥ 만으로 이미 0 이고 (b) 남은 병목은 리포트 판의 렌더·DOM 변경(6-1 표)이며 (c) fetch 를 메인에 두고 원문을 워커로 넘기려면 supabase-js rpc() 를 우회하는 전송 층 개편(인증·오류 분류 경로가 갈라짐)이 필요하다는 세 가지 앞에서 값이 없다. 계약 §12 규칙 5 대로 수치만 남긴다.
어댑터의 남은 비용(④): 바이트 재측정(JSON 재직렬화 + UTF-8 인코딩, services/screenRpcPayloadMetrics.ts)이 응답당 12ms@4x(1년 개요)·19ms@4x(카탈로그) — 예산 검사·관측용이며, 전송 층에서 원문 길이를 세면(원문 encode 는 재직렬화의 1/3) 없앨 수 있으나 supabase-js 를 거치는 전송 층에 원문 접근이 없어 별도 작업으로 남긴다.
6-2. Phase 3 에서 남긴 것
| 항목 | 상태 | 어디서 |
|---|---|---|
| 리포트 cold "데이터 도착 → 공개" 794/182ms(합격선 100) | 리포트 판의 렌더·DOM 변경량(6-1) — 응답 처리 몫은 창 밖으로 나갔다 | Phase 5 |
| 리포트 재방문(warm) 206/284ms(합격선 100) | 같은 원인(판 재표시 때의 스타일·레이아웃·페인트 약 50ms + 러너 관측기) — v0.19.2 도 118~122 | Phase 5·6 |
| 달력 cold 첫 반응 ~100(경계) | 달력 판 진입 렌더 — v0.19.2 와 같음 | Phase 5 |
| 바이트 재측정을 전송 층 원문 길이로 대체(응답당 12~19ms@4x) | 기록만 | 후속 |
판정 러너 표본 실패 ERR_NO_BUFFER_SPACE(200표본에 1~2건) | 윈도 소켓 버퍼, 앱 무관 — 해당 묶음은 "관측 19/20" 으로 기록 | 기록만 |
| p3f(최종 커밋) 판정이 같은 PC 의 release 큐 러너 작업과 겹쳐 오염(묶음 4·5 의 5셀) | judge-quiet.sh(셀마다 CPU 부하·Actions 실행 확인) 준비, 러너 큐가 비지 않아 미실행 — Phase 7 전수 판정에서 최종 커밋으로 다시 잰다 | Phase 7 |
| 판정 중 같은 기계의 빌드·프로필 실행 금지, 백그라운드 판정 루프는 프로세스 트리째 종료 | 절차로 기록(메모리) | — |
7. Phase 4 결과 — 바인딩→화면 계약·전수 게이트 (2026-09-15 15:40 KST)
읽는 법 — 유저 A 의 하루로: A 가 하단 탭을 눌러 일지로 갔다가 홈으로 돌아오면(tab.switch 재방문) 이제 탭바만 다시 그려진다(8/8 표본, 종전에는 방문한 판 4개 + 드로어). 일지에서 날짜를 누르거나(calendar.selectDay) 달을 넘기면(calendar.changeMonth) 일지 판만 다시 그려진다(종전 루트 재실행 3~6 + 홈·리포트 판·탭 셸). 토스트가 뜨고 사라져도 아무 판도 깨어나지 않는다(종전 루트 12·판 전부). 세트 입력은 종전대로 운동 층만이다. 아직 남은 것은 숨은 판이 자기 데이터 갱신을 받는 것(종목 상세를 열면 숨은 리포트 판이 prProps 를, 운동을 시작하면 숨은 판들이 isWorkoutActive 를 받아 다시 그려짐 — Phase 6 판 수명의 대상) 4건과, 세션을 열 때 그 아래 하루 상세 화면이 다시 그려지는 1건(원인 추적 중)이다.
7-1. 적용한 것 (계약 §2-1 규칙 5~12)
| # | 무엇 | 어떻게 | 커밋 |
|---|---|---|---|
| 5·6 | feature 컨텍스트 24개(값 15 + 루트 입력 9)가 값 대신 스토어 손잡이 를 내린다 | app/featureView.ts — createFeatureContext(손잡이 컨텍스트 + 키 목록/선택자 훅) · useFeatureViewStore(제공자가 커밋 뒤 publish, 내용 같은 키는 이전 참조·함수 키는 대리 함수) · useStableProviderValue 삭제. 모바일 소비자 116곳 키 목록 구독 전환, 리포트 조작·일지 주 선택 프로바이더도 같은 방식 | 48834825 |
| 7 | 바인딩 27개가 화면에 넘기는 prop 을 렌더마다 새로 만들지 않는다 | 명령 묶음 useStableCommands 1회 생성, 파생 객체·목록 useMemo, 빈 값 모듈 상수 — 인라인 콜백 prop 101 → 0, 인라인 객체 prop 8 → 0. 그룹 판은 화면 종류별 컴포넌트 7개(목록·라운지·보드·완료 세션·작성·초대·생성)로 나눠 각자 자기 조각(선택자)만 구독 | bed1eaa8 |
| 8 | 화면·공용 컴포넌트 65개 React.memo | ui/mobile/screens 21파일 + ui/mobile/lib 23파일의 export 컴포넌트 전부(memo 파일 1 → 42/44, 나머지 2 = 컴포넌트 없는 파일) | bed1eaa8 |
| 9 | 탭 판 = 탭 바인딩 하나 + <MobileStackEntries /> | 리포트 탭 판 래퍼 MobileVolumeBinding, mobileApp.tsx 판 목록은 바인딩 이름만 | 48834825 |
| 10·11 | 정적 게이트 npm run check:render R1~R6 를 check:static 에 편입 | scripts/check-render-rules.mjs — 장부 판독기(scripts/render/renderLedgerLib.mjs)를 생성기와 공유. 위반 예: 새 바인딩이 useXFeature() 로 값 전체를 구독 / 인라인 콜백 prop / memo 없는 화면 export / 판 목록에 스택 항목 / 루트 경로에 새 useState / 표에 없는 입력 종류 | bed1eaa8 |
| 12·경계 | 셸 층 정리 | 셸 요소(드로어·탭 리전·운동 층·운동 내비)를 모듈 상수로(루트 재실행에 새 요소 없음), shell:tabs 계측 경계 = 탭바만, pane:<탭> 경계 = 판 내용만(활성 래퍼 밖), 닫힌 드로어는 탭 값에 비반응, 탭바는 홈 데이터 중 복귀 카드 유무만 구독, 달력 적재 정책(CalendarLoadBinding)은 셸 층에서, 홈은 열린 세션을 손잡이로 읽고 날짜 패널이 열려 있을 때만 달력 읽기 모델을 구독, 홈 날짜 닫기 멱등, 탭 스택 컨텍스트를 명령/항목으로 분리(스택 화면 껍데기는 push/pop 에 비반응), 스택 항목·엔트리 호스트·리포트 상세 활성 판정(불리언) memo, 운동 진입 취소 멱등, 워드마크 알림 컨텍스트 값 안정화, 리포트 화면 재시도 콜백 참조 고정, 주 미선택 합계 null | 4-3 커밋 |
7-2. 팬아웃 재실측 — 러너 p4-run7 (2026-09-15, vite dev 5610 프로파일 모드, 샌드박스 sbx1610=cil1f47cae5, CPU 4배, 시드 24세션, 40표본, --gate)
| 입력 종류 | 표본 | 루트 재실행(최대) 기준선 → Phase 2 → Phase 4 | 다시 그려진 경계(Phase 4, 합집합) | 허용 밖 | 판정 |
|---|---|---|---|---|---|
boot.quietWindow | 1 | 13 → 1 → 1 | pane:home, shell:drawer, shell:tabs | — | ✅ |
tab.switch | 11 | 5 → 0 → 0 | 재방문: shell:tabs 만 · 첫 방문: + 들어가는 판 mount | — | ✅ |
drawer.pick | 4 | (tab.switch) → 0 | pane:volume, shell:drawer, shell:tabs | — | ✅ |
calendar.selectDay | 1 | 3 → 0 → 0 | pane:calendar | — | ✅ |
session.open | 1 | 4 → 0 → 0 | pane:calendar, stack:session, stack:journalDay | stack:journalDay | ❌ 1 |
session.close | 1 | 1 → 0 → 0 | 같음 | stack:journalDay | ❌ 1 |
stack.pushPop | 2 | 2 → 0 → 0 | 하루 상세 pop: pane:calendar, stack:journalDay · 종목 상세 pop: pane:home, pane:volume, stack:prDetail | pane:volume(종목 상세 pop) | ❌ 1(Phase 6) |
calendar.changeMonth | 4 | 6 → 0 → 0 | pane:calendar | — | ✅ |
pr.open | 1 | 14 → 0 → 0 | pane:home, pane:volume, stack:prDetail | pane:volume | ❌ 1(Phase 6) |
toast | 2 | 4 → 1 → 0 | 없음 | — | ✅ |
connectivity.offline | 1 | 1 → 1 → 0 | 없음 | — | ✅ |
connectivity.online | 1 | 8 → 1 → 2(허용 2) | 재적재된 판 전부(허용 *) | — | ✅ |
workout.start | 1 | 2 → 0 → 0 | 셸 층 + workout(mount) + 방문한 판 전부 | pane:calendar, pane:feed, pane:volume | ❌ 3(Phase 6) |
workout.setInput | 8 | 0 → 0 → 0 | workout | — | ✅ |
workout.restTick | 1 | 0 → 0 → 0 | 없음(휴식 미시작) | — | 미계측 |
합계: 표본 40 · 위반 5(입력 종류 4) · 루트 재실행 초과 0. 러너의 마지막 표본(운동 중 탭바로 이탈)은 Phase 1 부터 클릭이 잡히지 않아 판정 제외(기록만). 같은 절차를 run6·run7 두 번 돌려 경계 집합이 같았다.
원본: wt1610/test-results/phase1610/render-fanout-p4-run7.{json,md}, 로그 fanout-p4-run7.log(표본마다 실제로 렌더된 컴포넌트 이름과 컨텍스트 스토어가 바꾼 키 목록 — Phase 4 에서 러너에 붙인 진단).
7-3. 실측이 고친 설계 — 진단 없이는 보이지 않던 것
경계 id(판·스택 항목) 만으로는 "판 안의 누가 깨어났는가" 가 보이지 않아, 러너에 두 진단을 붙였다: ① 정착 창 안에 실제로 렌더 함수를 실행한 fiber(개발 React 의 actualStartTime + PerformedWork 플래그)의 컴포넌트 이름 목록 ② 컨텍스트 스토어가 publish 하며 바꾼 키 목록. 그 결과로 찾은 원인(조각 구독만으로는 안 잡히던 것):
| 원인 | 증상 | 조치 |
|---|---|---|
홈 바인딩이 탭 값을 구독하고, 탭을 떠날 때 closeHomeDay 가 이미 닫힌 상태를 새 객체로 다시 써서 홈 판 재렌더 | 모든 탭 전환에 pane:home | 닫기 멱등(같은 상태면 쓰지 않음), 탭 구독은 판 밖 제공자에서 |
CalendarLoadBinding(적재 정책)이 일지 판 안에서 탭을 구독 | 모든 탭 전환에 pane:calendar | 셸 층으로 이동 |
| 리포트 판의 상세 활성 판정이 탭 문자열을 구독 | 다른 탭끼리 전환에도 pane:volume | 참/거짓(이 표면이 활성인가)만 구독 |
| 라우트 호스트가 다시 실행되면 드로어·탭 리전·운동 층 요소가 새로 만들어져 memo 가 풀림 | 부팅 창·운동 시작에 shell:* 전부 | 셸 요소 모듈 상수 |
shell:tabs 경계가 판을 포함 | 모든 판 update 가 shell:tabs 로도 집계 | 경계 = 탭바만 |
| 홈이 열린 세션·일지 선택일·달력 읽기 모델을 항상 구독 | 세션 열기·날짜 선택·달 이동에 pane:home | 세션은 손잡이(이벤트 시점), 선택일은 고정, 읽기 모델은 날짜 패널이 열려 있을 때만 |
| 탭 스택 컨텍스트 값이 push/pop 마다 새 객체 | 같은 탭에 항목이 열릴 때 아래 항목 껍데기 전부 | 명령/항목 컨텍스트 분리, 스택 항목·엔트리 호스트 memo |
| 주 미선택 상태의 합계가 읽기 모델 갱신마다 새 객체 | 달 이동에 리포트 판 | 미선택이면 null 상수 |
| 온라인 복귀 재적재 응답이 다음 표본(토스트) 창에 섞임 | 토스트에 판 3개 | 러너 순서(토스트 → 연결 전환) |
7-4. Phase 4 에서 남긴 것
| 항목 | 상태 | 어디서 |
|---|---|---|
숨은 판이 데이터 갱신을 받는다(종목 상세 열기의 리포트 판 prProps, 운동 시작의 isWorkoutActive) — 허용 밖 4건 | 판 수명 규칙(숨은 판은 갱신을 받지 않는다 — 구독 게이트 또는 LRU 언마운트)의 대상 | Phase 6 |
세션 열기/닫기에 아래 하루 상세 화면(stack:journalDay)이 다시 그려짐 — 허용 밖 1건 | 부모(스택 항목 호스트)는 memo 로 조용한데 화면 껍데기·본문이 자체 갱신으로 깨어난다(원인 미확정, 진단 ! 표기로 추적) | Phase 5 초입 |
| 팬아웃 러너의 마지막 표본(운동 중 탭바 이탈) 클릭 실패 | Phase 1 부터 같음 — 판정 제외로 표기 | Phase 5(운동 층 내부 계측과 함께) |
데스크톱 바인딩·desktopApp 은 값 전체 구독(useXFeature()) 그대로 | 계약 대상 밖(모바일), 게이트 R1 도 데스크톱 파일 제외 | 후속(데스크톱 확장 때) |
계측 경계 밖 셸 컴포넌트(MobileTabFrame·BottomNav·NavDrawer)의 memo | 탭바·드로어는 자기 입력에만 반응하도록 정리했으나 ui 셸 파일은 R3 대상(screens·lib) 밖 | 후속 |
8. Phase 5 결과 — 화면 내부 전수 (2026-09-15 18:15 KST)
읽는 법 — 유저 A 의 하루로: A 가 운동 중 세트 무게를 한 글자 치면 이제 기록 화면(종목 요약) 1개와 그 세트 행 1개만 다시 그려진다(WfRecord·WfSetEditRow, 3.4~4.7ms@4x) — 종전엔 운동 헤더·모달·다른 종목·다른 세트 행까지 운동 층 안 화면 전체(1,180줄)였다. 휴식 중 0.5초마다 도는 시계는 시계 컴포넌트 1개(WfRestClock)만 깨운다(2.5초 창 커밋 5~6, 창 합계 2.5~4.1ms) — 종전 코드는 화면 전체 setTick. 일지에서 세션을 열고 닫아도 아래 하루 상세 화면은 다시 그려지지 않는다(Phase 4 가 남긴 허용 밖 1건 해소). 종목 상세에서 "전체 PR"·"전체 세션"을 열면 화면 밖 행은 그리지 않고, 1RM 직접 입력 폼에 값을 칠 때 직접 입력 목록은 그대로다.
8-1. 적용한 것 (계약 §2-1 규칙 13·14 신설)
| 하위 | 무엇 | 어떻게 | 커밋 |
|---|---|---|---|
| 5-1 운동 기록 | 편집기 포트에 구독 손잡이 — 화면은 자기 조각만 구독 | MobileWorkoutEditorPort 에 subscribe/getSnapshot(참조 불변) + 이벤트 조회용 view getter, 바인딩(MobileEditorFlow·MobileGroupComposerBinding)은 포트를 스토어당 1회 생성. 화면은 ui/shared/useEditorView(editor, 선택자, 비교) — 흐름 셸 = 제목·날짜·종목 수·오류(selectFlowDraft), 기록 화면 = 종목 목록·활성 종목·추가 중인 세트(selectRecordView), 보드 작성 헤더 = 제목·종목 수, 완료 단계 = 초안 전체(WfFinishStep, 그 단계에서만). 종전엔 바인딩이 키 입력마다 {...actions, view} 새 객체를 만들어 흐름 셸부터 통째로 깨웠다 | e5f46069 |
| 5-1 | 세트 행 memo 컴포넌트 + 참조 고정 명령 묶음 + key = 세트 id | WorkoutRecordRows.tsx: 편집 행 WfSetEditRow·보기 행 WfSetViewRow(자기 세트 s + 원시 플래그만 받음), 복합 동작 입력 2개 이동. 행이 부르는 부모 동작 13개는 useRowCommands({...}) 한 묶음(대리 함수, 호출은 최신 렌더). 행 key 인덱스 → 세트 id(재정렬해도 포커스·한글 조합 상태가 다른 행에 붙지 않음). 기록 항목 프로필·입력 칸 목록은 종목의 기록 칸(fields) 기준 memo(세트가 바뀌어도 참조 유지) | 〃 |
| 5-1 | 휴식 시계 분리 | WfRestClock(0.5초 tick 을 자기 상태로) — 도크의 나머지·세트 행은 tick 에 반응하지 않음 | 〃 |
| 5-1 | 편집기: 바뀐 조각만 새 참조, 세트 관문 종목당 1회, 내보내기·직렬화 상태당 1회 | services/derivedIdentity.reuseUnchangedDeep(발행마다 화면 값 전체를 만들되 바뀌지 않은 하위 객체·배열은 이전 참조) — 운동·계획·보드 편집기 공통. 세트 관문(exerciseRowView)을 세트마다 종목 전체 재계산하던 것(세트 수 제곱)을 종목당 1회. exportDraft·비교용 content() 를 상태 참조당 1회(종전 키 입력당 각 2회 — 코드 판독, 시간 실측 안 함) | 〃 |
| 5-2 일지·하루 상세 | 세션 열기·닫기의 하루 상세 재렌더 원인 | 일지 화면(SessionScreen)이 하루 상세 오버레이(memo)에 렌더마다 새 onStep 콜백과 새 [] 를 넘겼다 → useStableCallback·모듈 상수 NO_SESSIONS. 오버레이의 날짜 제목 요소 memo(DayOverlay) | 56d17a5a |
| 5-2 | 하루 상세 본문 3중 순회(F11) | deriveDayDetail(순수 함수) + useMemo([date, sessions, summary, zones, wk, mo]) — 종전엔 세션×종목×세트 순회를 부모 갱신마다 반복. 일지 월 통계 서브라인도 useMemo. 잔디 월 모드 12×365 filter → 1회 집계(GrassCal) | 〃 |
| 5-2 | 피드 기록 첨부 카드 맞춤(F10) | FdFitSlide: 스타일 쓰기→scrollHeight 읽기 3회를 children 참조가 바뀔 때마다(부모 렌더마다) 반복 → ResizeObserver 두 대상(박스·내용)으로 실제 크기 변화에만, 배율 같으면 setState 없음 | 〃 |
| 5-2 | 리포트 월간 카드(F12 구멍) | RpMonthCard memo + 컨테이너가 onStepPeriod·onDownloadMonthCard·onRetry 참조 고정 + 상위 5종목 정렬 memo | 〃 |
| 5-3 종목 상세·PR 도구 | 전체 목록 가상화 + 데이터 key(F11) | "전체 PR"·"전체 세션" 오버레이를 ViewportList 로(창 밖 행 미렌더, 60행 이하는 평범한 흐름), 행 key 인덱스 → 데이터 정체(withUniqueKeys, 겹치면 #n). 감싸개(listitem) 안에서 첫 행 규칙이 전부에 걸리므로 CSS 첫 행 규칙을 감싸개 기준으로 다시 걸어 모양 유지 | a0ba4126 |
| 5-3 | 1RM 직접 입력 폼 상태 위치(F11) | 폼 상태 12개를 페이지 최상위에서 모달 컴포넌트 PrtManualForm 로 — 값 한 글자에 직접 입력 목록 전체가 다시 그려지던 것을 폼만. 닫혀도 마운트를 유지해 "저장 중 닫았다 다시 열기" 동작 유지, 수정 진입은 key 로 새로 채움 | 〃 |
| 5-3 | 세트 진행 중 헤일로 펄스(F15) | box-shadow 키프레임(프레임마다 페인트) → 가상 요소 ::after 의 투명도 펄스(합성 전용). 링 모양(테두리 밖 4.5px·액센트 25%·모서리 7+4.5px)은 같고, 나타나는 방식만 spread 가 자라는 것 → 투명도. 나머지 F15 3종(endbarPulse·allsetGlow·sieDown)은 사용처 없는 키프레임(dead) — 손대지 않음 | 〃 |
| 5-4 스택 호스트 | 항목 요소를 항목 객체당 1회 | MobileStackEntries 가 렌더마다 <ProfileBoundary><MobileStackOverlay/></ProfileBoundary> 새 요소를 만들어, 다른 항목이 열리고 닫힐 때 바뀌지 않은 항목의 Profiler 가 빈 update 를 기록했다(session.open 의 stack:journalDay — 이 호스트가 그리지 않는 종류라 경계 안이 비어 있었다). WeakMap 캐시로 같은 항목 = 같은 요소 → React 가 통째로 건너뜀 | 245a7725 |
| 5-4 | 데이터 목록 key 2곳 | 홈 PR 전광판 행·세션 시트 신기록 행: 인덱스 → 종목·날짜·값 | 〃 |
| 5-4 | 불변식 표 "지금" 갱신 | renderInvariants.ts 의 workout.setInput·workout.restTick·session.open·session.close baseline | 〃 |
러너 변경: 휴식 기록이 옵트인(헤더 "휴식 기록" 체크, 기본 생략)이라 세트 완료 표본에서 휴식이 시작되지 않아 workout.restTick 이 0 커밋이었다 → 완료 전에 체크를 켠다(render-fanout.mjs).
8-2. 팬아웃 재실측 — 러너 p5-run3 (2026-09-15, vite dev 5610 프로파일 모드, 샌드박스 sbx1610=cil1f47cae5, CPU 4배, 시드 24세션, 40표본, --gate)
| 입력 종류 | 표본 | 루트 재실행(최대) | 다시 그려진 경계(합집합) | 허용 밖 | 판정 |
|---|---|---|---|---|---|
boot.quietWindow | 1 | 1 | pane:home, shell:drawer, shell:tabs | — | ✅ |
tab.switch | 11 | 0 | pane:feed, pane:volume, shell:tabs | — | ✅ |
drawer.pick | 4 | 0 | pane:volume, shell:drawer, shell:tabs | — | ✅ |
calendar.selectDay | 1 | 0 | pane:calendar | — | ✅ |
session.open | 1 | 0 | pane:calendar, stack:session | — | ✅ |
session.close | 1 | 0 | pane:calendar, stack:session | — | ✅ |
stack.pushPop | 2 | 0 | pane:calendar, pane:home, pane:volume, stack:journalDay, stack:prDetail | pane:volume | ❌ 1(Phase 6) |
calendar.changeMonth | 4 | 0 | pane:calendar | — | ✅ |
pr.open | 1 | 0 | pane:home, pane:volume, stack:prDetail | pane:volume | ❌ 1(Phase 6) |
toast | 2 | 0 | 없음 | — | ✅ |
connectivity.offline | 1 | 0 | 없음 | — | ✅ |
connectivity.online | 1 | 2 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs | — | ✅ |
workout.start | 1 | 0 | pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workout | pane:calendar, pane:feed, pane:volume | ❌ 3(Phase 6) |
workout.setInput | 8 | 0 | workout | — | ✅ |
workout.restTick | 1 | 0 | workout | — | ✅ |
합계: 표본 40 · 위반 3(입력 종류 3, 전부 "숨은 판이 데이터 갱신을 받는다" — Phase 6) · 루트 재실행 초과 0. Phase 4(p4-run7) 위반 5 → 3: session.open·session.close 의 stack:journalDay 해소. 러너의 마지막 표본(운동 중 탭바 이탈)은 종전대로 판정 제외.
렌더된 컴포넌트(진단): workout.setInput 세트 입력 = WfRecord!(구독 자체 갱신) + WfSetEditRow(+ 그 안 WfTypeSelect·아이콘) · workout.restTick = WfRestClock! 1개(2.5초 창 커밋 5) · session.open = 세션 상세(UiSessionDetail·UiStackScreen)와 일지 판만, 하루 상세 본문(UiDayOverlay·UiDayDetailBody) 없음(run1 에는 있었다).
원본: wt1610/test-results/phase1610/render-fanout-p5-run{1,2,3}.{json,md}, 로그 fanout-p5-run{1,2,3}.log(표본마다 렌더된 컴포넌트 이름). run1 = 5-1 만, run2 = 5-1~5-3, run3 = 5-1~5-4(최종).
8-3. 장부 A 행별 판정 — 화면 22·lib 23 (생성기 render-ledger-p5)
생성기 합계(Phase 4 → 5): 표면 71 → 72(WorkoutRecordRows.tsx 추가) · memo 파일 47 → 48 · 인덱스 key 60 → 54 · 레이아웃 읽기 13 → 14(FdFitSlide 의 관찰 대상 2 — 읽기는 크기 변화 때만) · 인라인 콜백 prop 707 → 698(화면 안 host 요소의 핸들러 — 계약 대상은 바인딩→화면 prop 뿐, 게이트 R2). 장부 B 11 그대로.
| 행 | 판정 | 근거 |
|---|---|---|
screens/WorkoutRecord.tsx(1,610 → 1,394줄) · WorkoutRecordRows.tsx(신설 369줄) · WorkoutRecordParts.tsx | 수리 | 5-1. 남은 것: 화면 자체(종목 요약)는 세트 입력마다 1회 렌더 — 불변식 "행 1 + 요약 1" 의 요약 몫 |
screens/WorkoutFlow.tsx · GroupComposer.tsx · WorkoutFinish.tsx | 수리 | 5-1 조각 구독(흐름 셸·보드 헤더·완료 단계). WorkoutFinish 의 layout read 1 = 열릴 때 선택값 센터 스크롤(clientHeight, open 1회) — 유지 |
screens/SessionScreen.tsx · lib/DayOverlay.tsx · lib/DayDetailBody.tsx · lib/GrassCal.tsx | 수리 | 5-2. SessionScreen:64 렌더 중 setShownMonth(null) 는 유지 — 컨테이너 표시 달이 로컬 앞선 달을 따라잡는 순간 1회(월 이동당 1회, 입력마다 아님) |
lib/FeedPost.tsx | 수리 | 5-2 FdFitSlide. 인덱스 key 2 = 토큰·스켈레톤(정적 파생 목록, 상태 없음) — 유지 |
screens/ReportScreen.tsx | 수리 | 5-2 월간 카드. 인덱스 key 7 = 존 세그먼트·레전드·축·빈 줄(정적 파생 목록) — 유지. 섹션 5 memo + 콜백 고정은 #1596 그대로 |
screens/RecordsDetail.tsx · PrTools.tsx | 수리 | 5-3. 남은 인덱스 key(그래프 눈금·점·거리표·탭)는 정적 파생 목록. PrTools layout read 1 = 즐겨찾기 드래그의 elementFromPoint(포인터 이벤트 시점, 렌더 밖) |
screens/HomeScreen.tsx · lib/SessionSheetBody.tsx | 수리 | 5-4 데이터 key 2곳. 나머지 인덱스 key(출석 7칸·레벨 세그먼트 10칸·힌트 줄)는 고정 길이 정적 목록 |
features/navigation/MobileStackBindings.tsx | 수리 | 5-4 항목 요소 캐시 |
screens/FeedScreen.tsx · GroupScreen.tsx · HomeScreen.tsx 의 목록 | 유지 | 시트가 입력을 소유(F12), ViewportList 사용, 그룹 판은 Phase 4 에서 화면 종류별 7개로 분리. 인덱스 key = 토큰·순위·스켈레톤(정적) |
screens/OnboardingScreen.tsx(useState 5) · ProfileScreen.tsx(6) · WorkoutPicker.tsx(10) · TrainingStyleScreen.tsx(2) · RecoveryScreen.tsx(2) · SetSearch.tsx · DaySummaryScreen.tsx · TabSkeletons.tsx | 유지 | 상태 = 화면 로컬 조작 상태(단계·입력 중 문자열·검색어·필터·모달 열림) — 계약 §2-1 "화면 로컬 상태의 자리" 그대로. 검색어 입력에 결과 목록이 다시 그려지는 것은 그 입력의 결과 |
screens/WorkoutExtras.tsx(layout read 3) | 유지 | 디테일 프리셋 드래그 스냅 — 포인터 이벤트 시점 읽기, 렌더 밖 |
lib/BrandRow.tsx(layout read 2) | 유지 | 알림 팝오버 위치 — 열릴 때·리사이즈 때만(useLayoutEffect([open])) |
lib/fitInline.ts(RecordsDetail 히어로 숫자 맞춤) | 유지 | 텍스트 키가 바뀔 때만 측정(useLayoutEffect([key])) |
ui/shared/ViewportList.tsx | 유지(기록) | 마운트 직후 스크롤 컨테이너·여백 상태 2개를 한 layout effect 에서 설정(React 18 자동 배칭 = 추가 렌더 1회, 마운트 1회뿐). virtualRows 가 매 렌더 새 배열이라 복원 effect 가 매 렌더 돌지만 대기 중인 복원·포커스·공개 키가 없으면 즉시 반환. 데스크톱·모바일 공용이라 이 트랙에서 구조를 바꾸지 않음 |
lib/AccountDeleteFlow · brandNotify · CalendarCore · DaySheet · FeedModeration · FriendScopeBar · IntensityGuide · ListOverlay · Modal · PhotoCard · PhotoComposer · PrcTierBadge · RecordIcons · SearchField · StackScreen · StackSessionOverlay · WorkoutResumePrompt | 유지 | 작은 순수 표시 컴포넌트(memo 내보내기 Phase 4), 상태 0~3, 목록 없음 또는 정적 |
8-4. Phase 5 에서 남긴 것
| 항목 | 상태 | 어디서 |
|---|---|---|
숨은 판이 데이터 갱신을 받는다(종목 상세 열기·닫기의 리포트 판 prProps 2건, 운동 시작의 isWorkoutActive 3건) — 허용 밖 5건(run3 기준) | 판 수명 규칙의 대상 | Phase 6 |
| 리포트 cold "데이터 도착 → 공개"·재방문(warm)·달력 cold 첫 반응 경계(§6-2) | 이 Phase 는 팬아웃(누가 다시 그려지는가)을 잠갔고, 판 렌더·DOM 변경량의 시간 판정은 Phase 7 전수 판정(20표본×2묶음, 조용한 기계)에서 잰다 | Phase 7 |
키 입력당 초안 정규화(handleWorkoutSnapshot 의 별칭 정리·정체 부여) | 내보내기·직렬화는 상태당 1회로 줄였고(5-1), 정규화 자체는 스냅샷 ref 가 매 편집에 신선해야 하는 계약(#887 강제 종료 즉시 저장) 때문에 유지. 실측 시간 없음(코드 판독) | 기록만 |
앱 안 tests/(QA1 준비본)의 렌더 fixture mobileEditorRenderFixtures.mjs 와 훅 러너 2개(designBranchWorkoutFeedInteractions·workoutFlowSessionDate)가 새 포트 계약(subscribe/getSnapshot, 화면이 exercises/activeId prop 대신 조각 구독)에 맞지 않음 | QA1 은 오너 지시로 이 트랙에서 손대지 않음 — Phase 4 의 memo 내보내기로 이미 깨진 함수 호출식 러너와 같은 묶음. 필요한 변경: fixture 의 editor 객체에 subscribe: store.subscribe, getSnapshot: store.getSnapshot, get view(), 러너의 React.useSyncExternalStore 모의((sub, get) => get()) | Phase 7 보고 목록 |
SessionScreen 월 이동 로컬 앞선 달의 렌더 중 setState | 월 이동당 1회 — 유지 | 기록만 |
ViewportList 마운트 추가 렌더 1회·복원 effect 매 렌더 실행(즉시 반환) | 데스크톱 공용 — 유지 | 후속 |
| 팬아웃 러너 마지막 표본(운동 중 탭바 이탈) 클릭 실패 | Phase 1 부터 같음, 판정 제외 | 기록만 |
9. Phase 6 결과 — 판 수명·CSS/DOM (2026-09-15 18:48 KST)
읽는 법 — 유저 A 의 하루로: A 가 홈에서 운동을 시작하면 이제 방문했던 일지·피드·리포트 판은 다시 그려지지 않는다(운동 층 뒤에 숨은 채로 알림만 밀려 두고, 운동을 끝내고 돌아올 때 한 번에 따라잡는다). 홈 PR 보드에서 종목 상세를 열고 닫아도 숨은 리포트 판은 조용하다(종전엔 리포트 화면 전체 40개 컴포넌트가 다시 그려졌다). 허용 밖 3 → 0.
9-1. 적용한 것 (계약 §2-1 규칙 15·16 신설, keep-alive 계약 개정)
| 하위 | 무엇 | 어떻게 | 커밋 |
|---|---|---|---|
| 6-1 숨은 판 구독 게이트 | 판이 숨어 있는 동안 판 안의 구독은 알림을 밀려 두고, 보일 때 따라잡는다 | ui/shared/paneGate.ts(판당 게이트 객체 + usePaneGatedStore), MobileFeaturePane 이 활성 탭·운동 층 덮임(TabPanesCoveredContext)으로 커밋 뒤 전환. 편입: app/featureView.useFeature, resources/useResourceSnapshot 3종, 판 바인딩 직접 구독 9곳(일지 화면 상태·선택일·오프라인 큐·하루 상세 항목·리포트 복귀 탭·피드 내 피드/알림 대상·기록 도구·세션 오버레이), TabStackProvider. 리포트 판은 PR 값 중 종목 카탈로그만 구독(규칙 6) | f50ca049 |
| 6-2 워드마크 알림 | 셸이 운동 시작으로 다시 그려질 때 알림 항목 배열을 새로 만들어 모든 판의 워드마크(UiBrandMark)가 깨던 것 | 알림 값을 알림 데이터로만 memo(항목 탭 콜백은 참조 고정), 컨텍스트에는 useFeatureViewStore 손잡이, useBrandNotify() 는 판 게이트를 거침 | c783bedd |
| 6-3 DOM·판 예산 축 | 예산 = 판 하나, 전체 = 판 수 × 판 예산 | 러너가 부팅·4판 방문·리포트 재방문에서 판별 노드 수를 기록하고 예산(RELEASE_PERFORMANCE_BUDGETS.browser.domNodesHomeFanout·domNodesPaneMaxFanout)으로 판정. 프로브 2종에 a{활성 판 노드} 토큰 | c783bedd · 10123a2c |
| 6-4 CSS 구조 실측·결정 | 합친 판 트리 + 루트 :has() 47개 vs 판마다 .screen | 표본마다 브라우저 비용(스타일 재계산·레이아웃·스크립트, CDP) 기록 → 판 구조는 합친 트리 유지, 애니메이션만 합성기 전용으로 | 47733ca3 |
| 6-5 기준선 갱신 | 불변식 표의 pr.open·stack.pushPop·workout.start 기준선을 Phase 6 실측으로 | renderInvariants.ts baseline 문구만(허용 경계는 그대로) | ceea8e6c |
9-2. 팬아웃 재실측 — 러너 p6-run3 (2026-09-15, vite dev 5610 프로파일 모드, 샌드박스 sbx1610=cil1f47cae5, CPU 4배, 시드 24세션, 40표본, --gate + DOM 예산)
| 입력 종류 | 표본 | 루트 재실행(최대) | 다시 그려진 경계(합집합) | 허용 밖 | 판정 |
|---|---|---|---|---|---|
boot.quietWindow | 1 | 1 | pane:home, shell:drawer, shell:tabs | — | ✅ |
tab.switch | 11 | 0 | pane:feed, pane:volume, shell:tabs | — | ✅ |
drawer.pick | 4 | 0 | pane:volume, shell:drawer, shell:tabs | — | ✅ |
calendar.selectDay | 1 | 0 | pane:calendar | — | ✅ |
session.open | 1 | 0 | pane:calendar, stack:session | — | ✅ |
session.close | 1 | 0 | pane:calendar, stack:session | — | ✅ |
stack.pushPop | 2 | 0 | pane:calendar, pane:home, stack:journalDay, stack:prDetail | — | ✅ |
calendar.changeMonth | 4 | 0 | pane:calendar | — | ✅ |
pr.open | 1 | 0 | pane:home, stack:prDetail | — | ✅ |
toast | 2 | 0 | — | — | ✅ |
connectivity.offline | 1 | 0 | — | — | ✅ |
connectivity.online | 1 | 2 | pane:home, shell:drawer, shell:tabs | — | ✅ |
workout.start | 1 | 0 | pane:home, shell:drawer, shell:tabs, shell:workoutNav, workout | — | ✅ |
workout.setInput | 8 | 0 | workout | — | ✅ |
workout.restTick | 1 | 0 | workout | — | ✅ |
스타일 재계산 비용(CDP Performance.getMetrics 차, CPU 4배) — 6-4 전(p6-run2) → 후(p6-run3):
| 표본 | 스타일 재계산 전 → 후 | 레이아웃 전 → 후 | 커밋 수(후) |
|---|---|---|---|
pr.open 홈 PR 보드 행 누름(종목 상세 push) | 230회/536.67ms → 18회/75.4ms | 3회/52.95ms → 3회/33.71ms | 3 |
stack.pushPop 종목 상세 뒤로(pop) | 32회/61.38ms → 18회/42.55ms | 1회/0.82ms → 1회/0.8ms | 3 |
workout.setInput 세트 입력 1 (무게=60) | 20회/15.18ms → 23회/4.87ms | 2회/3.32ms → 2회/1.81ms | 1 |
workout.setInput 세트 입력 2 (횟수=6) | 26회/19.47ms → 25회/5.68ms | 3회/11.34ms → 3회/4.66ms | 1 |
workout.setInput 세트 완료 체크(휴식 시작 + RPE 카드) | 15회/29.61ms → 15회/9ms | 3회/42.82ms → 3회/15.42ms | 3 |
tab.switch 재방문: home → feed | 5회/39.12ms → 5회/13.08ms | 1회/35.54ms → 1회/13.94ms | 4 |
drawer.pick 재방문: home → volume (드로어) | 25회/193.05ms → 30회/28.34ms | 3회/58.44ms → 3회/17.83ms | 5 |
9-3. 판·DOM 예산
| 시점 | 홈 | 일지 | 피드 | 리포트 | 방문 판 합계 | 문서 전체 | 예산(홈 112·판 901, 여유 10%) |
|---|---|---|---|---|---|---|---|
| boot(home only) | 112 | — | — | — | 208 | 255 | ✅ |
| 4 panes visited | 112 | 186 | 698 | 901 | 1996 | 2045 | ✅ |
| after report revisit | 112 | 186 | 698 | 901 | 1996 | 2046 | ✅ |
- 축 결정: 예산은 판 하나다.
domNodesHomeFanout(홈 판 112, 천장 123)·domNodesPaneMaxFanout(어느 판이든 901, 천장 991)을RELEASE_PERFORMANCE_BUDGETS.browser에 등재하고 러너--gate가 세 시점 × 판마다 판정한다. 방문 판 전체(1996)는 판 수 × 판 예산으로 읽고 예산으로 두지 않는다 — 판을 더 방문했다고 실패하면 keep-alive 자체를 벌하는 꼴이라서. - 실기기 텔레메트리(
perf_tab_switch·입력 응답 프로브)의 경로 토큰에a{활성 판 노드}를 더해 Production 에서도 같은 축(활성 판)으로 읽을 수 있게 했다. 종전n{…}은 방문 판 전체라 그대로 둔다. - 리포트 판 901 이 가장 크다(월 카드 × 종목 top5 × 차트). 판 예산 901 은 "지금 수준을 넘지 않는다" 는 회귀 방지선이지 목표치가 아니다.
9-4. CSS 구조 — 실측과 결정
실측 방법: 러너가 표본마다 브라우저 쪽 비용(스타일 재계산 횟수·시간, 레이아웃, 스크립트)을 CDP 로 재서 표에 붙였다(위 9-2 두 번째 표). React 커밋 수만으로는 "합친 판 트리 + 루트 :has() 47개" 가 얼마나 드는지 보이지 않아서다.
드러난 것 (6-4 전, p6-run2): 판 전환은 스타일 재계산 10~12회·평균 25ms(4배 느린 CPU)로 판 수와 무관하게 평평했다 — 숨은 판은 display:none 이라 재계산 대상에서 빠진다. 비용이 튄 곳은 판 구조가 아니라 애니메이션이었다: 종목 상세 열기(pr.open) 230회/537ms, 세트 입력 키 하나에 20~32회. 원인은 ① 로딩 뼈대(.skeleton·.lgd-sk)와 ② 세트 강조(.setHalo)가 background-position·box-shadow 를 프레임마다 바꾸는 메인 스레드 애니메이션이라 매 프레임 스타일 재계산을 일으킨 것. 종목 상세는 뼈대 7개가 동시에 뛰었다.
결정: 판 구조는 합친 판 트리 유지(판마다 .screen 으로 쪼개지 않음, contain 도 넣지 않음 — 배지·오버레이가 판 밖으로 나가야 해서 contain: layout/paint 가 잘린다). 대신 애니메이션을 합성기 전용으로 바꿨다: 뼈대는 ::after 400% 폭 오버레이를 transform: translateX 로, 강조는 ::after 의 opacity 로, 뛰는 요소 7종에 will-change — 마크업·클래스·시각 결과는 그대로(디자인 불변). 결과는 9-2 두 번째 표(전 → 후).
9-5. Phase 6 에서 남긴 것
| 항목 | 상태 | 어디서 |
|---|---|---|
| 판 예산 축 텔레메트리 확인 | Production 에 a{n} 토큰이 실리는지는 릴리스 뒤 perf_tab_switch 행으로 확인 | Phase 7 텔레메트리 수집 확인 |
| 리포트 판 901 노드 | 회귀 방지선으로만 등재. 줄이는 작업은 이 트랙 밖 | 별도 이슈 필요 시 |
| QA1 고정 테스트 갱신 목록 | §8-4 와 같음 + 판 게이트 편입 훅을 쓰는 화면 테스트는 PaneGateContext 없이(게이트 없음 = 종전 동작) 그대로 돈다 | Phase 7 QA2 |
10. Phase 7 결과 — 전수 판정·규칙 정착 (2026-09-15 19:40 KST)
읽는 법 — 유저 A 의 하루로: 이 절은 "고친 구조가 숫자로도 합격선을 넘는가" 를 최종 커밋으로, 같은 PC 가 조용할 때만, 20표본 × 2묶음으로 다시 잰 기록이다. A 가 앱을 켜고 바로 리포트나 일지를 눌렀을 때 손가락이 기다리는 시간(첫 반응)·숫자가 갖춰지는 시간(공개)이 100ms 합격선 안인지가 핵심이다. 오너 지시로 판정을 1.5셀에서 중단했고(아래 10-1), 이 절은 그 사실과 게이트 실패 확인·텔레메트리·QA1 목록·병합 준비를 기록한다.
10-1. 전수 판정 — 판정 러너 p7(최종 커밋 ceea8e6c production 빌드, preview 4620, 샌드박스 sbx1610=cil1f47cae5 에 1·4년 fixture 새로 적재, CPU 4배, 20표본 × 묶음 1·2, judge-quiet.sh = CPU 부하 15% 미만 연속 3회 + 진행 중 Actions 없음일 때만 셀 시작)
| 셀 | 상태 |
|---|---|
report-open-collide 1년 cold 묶음 1 | 20/20 관측(증거 phase1610j/evidence/browser-report-open-collide-history-1y-mobile-cold-p7-batch1.json) |
report-open-collide 4년 cold 묶음 1 | 진행 중 중단 |
| 나머지 8셀 × 묶음 1·2 | 미실행 |
중단 사유: 오너 지시(2026-09-15 19:20 KST, "판정 같은 거 하지 말고, 작업 끝났으면 CI도 생략하고 그냥 릴리스 브랜치에 머지해줘"). 합격선 표의 최종 판정은 Phase 3(§6, p3e·p3f 오염되지 않은 묶음)과 Phase 6 팬아웃(§9-2, 40표본 위반 0)을 근거로 두고, 20표본 × 2묶음 전수 판정은 미실행으로 남긴다 — 완료 조건 4(구 #1603 합격선 1·4년 판정 기록)는 §4·§6 기록으로 갈음, 10년은 측정된 한계(§4).
원본: wt1610/test-results/phase1610/judge-p7-batch12.log, 체인 judge-p7.sh(fixture 적재 → p7 빌드 → preview 4620 → judge-quiet.sh). 다음에 이어서 재려면 같은 체인을 bash test-results/phase1610/judge-p7.sh 1 2 로 돌리면 fixture·빌드는 재사용된다.
10-2. 게이트가 실제로 실패하는가 (완료 조건 3)
| 게이트 | 변형 | 결과 |
|---|---|---|
정적 check:render R3 | ui/mobile/lib/DayOverlay.tsx 의 React.memo(UiDayOverlay) 를 벗김 | 렌더 규칙 위반 1건 — R3 … React.memo(…) 로 감싼다, 종료 코드 1 |
팬아웃 러너 --gate | 불변식 표 calendar.selectDay 허용 집합을 [] 로 | 허용 밖 [pane:calendar] 1건, 렌더 불변식 위반 1건 (표본 40), 종료 코드 1(p7-neg2) |
| 판 DOM 예산 | 코드 변형 없음 — 러너 --gate 가 같은 종료 경로(domFailures)를 쓴다; 예산 상수는 appPerformanceBudgets.browser | 6-3 에서 판정 경로 편입, p6-run3·p7 통과 |
부수 발견: 체인 스크립트가 종료 코드를 echo "[$(date)] exit $?" 로 적어 date 의 0 을 기록하고 있었다(p5·p6 로그의 fanout exit 0 은 이 오기). rc=$? 로 고쳤고 p7-neg2 부터 실제 코드가 남는다.
10-3. 텔레메트리 수집 확인 (완료 조건 5)
- 마이그레이션
20260915140000_perf_input_response_kind(kind 제약 + 인입 함수 재선언)는 샌드박스db reset에 적용됐다. pgTAP 는 QA1 소관(supabase/tests/는 앱에서 gitignore, QA1 준비본)이라 이 트랙이 고치지 않고 —client_error_events.test.sql에perf_input_response적재 케이스 1건(plan 19 → 20,perf_tab_switch케이스와 같은 모양)을 더한 사본을 샌드박스에서 임시로 돌려 20/20 통과를 확인한 뒤 원복했고, 갱신은 10-4 목록에 넣었다. 실기기 프로브(inputResponseProbe.ts)는 느린 표본만 보내므로 4배 에뮬레이션 러너 창에서는 0건이 정상(p6-run3record_client_error_event도착 0). Production 수집은 릴리스 뒤 7일 관찰(완료 조건 5 의 "별도").
10-4. QA1 갱신 목록 (오너 지시로 이 트랙은 tests/ 를 손대지 않음)
| QA1 파일 | 왜 맞지 않나 | 필요한 갱신 |
|---|---|---|
tests/support/mobileEditorRenderFixtures.mjs | 편집기 포트에 subscribe/getSnapshot 손잡이 + view getter 가 생김(규칙 13) | fixture 편집기에 세 멤버 추가(구독은 no-op, getSnapshot 은 뷰 반환) |
tests/react/designBranchWorkoutFeedInteractions.test.mjs · tests/react/workoutFlowSessionDate.test.mjs | 화면이 exercises/activeId prop 대신 useEditorView 조각 구독(React.useSyncExternalStore) | 훅 러너에 useSyncExternalStore 모의(getSnapshot 즉시 호출) |
tests/react/renderStructureGate.test.mjs 핀 1·2·5 | 컨텍스트 값 15개 useStableProviderValue → 스토어 손잡이(useFeatureViewStore), 정책 바인딩 요소 → 셸 직접 마운트, PR 검색 항목 → 오너 스토어 자원(EMPTY_SEARCH_ROWS 삭제) | 핀 3개 삭제, 대체 = check:render R1~R6(check:static) + 팬아웃 --gate. 핀 3·4·6·7·8 은 그대로 통과 |
supabase/tests/database/client_error_events.test.sql | 새 kind perf_input_response 의 적재 케이스가 없다 | perf_tab_switch 케이스(#1160)와 같은 모양으로 1건 추가, select plan(19) → 20. 샌드박스 실행 결과 20/20(이 트랙에서 확인) |
| 화면 memo 내보내기(Phase 4) | export 이름은 같고 React.memo 래퍼 — displayName 을 보는 검사가 있으면 조정 | 확인만 |
10-5. 검증·정착
- 정적 게이트
npm run check:static(R1~R6 포함) — Phase 6-5 트리ceea8e6c통과, release/v0.19.3 병합 트리에서 다시 통과(로그check-static-p7-merge.log). - QA1
storeLifetimeContract.test.mjs(#1628 수리 계약, 병합으로 들어온 검사) — 병합 트리 통과. - QA2
qa:ci·ci:precheck-local·판정 러너 — 오너 지시로 미실행. release 통합은 큐의 Merge Check(충돌·마이그레이션 번호·head 확인)만 거친다. - 병합 뒤 정적 게이트가 잡은 것 2건: release 쪽 새 컴포넌트
FdOwnPostMenuSheet(내 포스트 ⋯ 시트)를 R3 대로 memo 내보내기로, 동의 구조(#1625)가 조립 루트 경로에 더한useState2개(consentRefreshDeferred·consentSettingPendingType)는 인증 관문과 같은 앱셸 상태로 보고 R5 목록에 등재(장부 B 11 → 13). 후자의 값 스토어 이동은 10-6. - 병합 base 이동: 트랙은
release/v0.19.2에서 갈라졌으나 그 사이 디자인 세션·동의 구조·#1649(effect 정리 함수의 영구 dispose 제거) 등 70커밋이release/v0.19.3에 쌓여 PR base 를 v0.19.3 으로 두고 병합했다. 충돌 8파일: 조립 루트(동의문 전문 열기openLegalDocumentPath를 스토어 setter 로), 달력·원격 데이터 컨트롤러(내 Phase 2 구조 + #1649 의 dispose 제거), 내 정보 바인딩(새 키 3개를 키 목록 구독에), 피드 포스트·온보딩 화면(디자인 변경 + memo 내보내기 유지), 배포 manifest·schema.sql(migrations:renumber --base origin/release/v0.19.3로 재생성, 마이그레이션20260915140000→20260915220100).
10-6. Phase 7 에서 남긴 것
| 항목 | 상태 | 어디서 |
|---|---|---|
| 전수 판정 20표본 × 2묶음 × 1·4년 | 오너 지시로 미실행(1.5셀에서 중단). 체인·fixture·빌드는 재사용 가능 | 필요 시 후속 |
QA2 qa:ci 1회 | 오너 지시로 미실행 — 승격 ① GitHub Full CI 의 QA2 lane 이 대신 본다 | 승격 담당 |
Production 텔레메트리 perf_input_response 수집 확인 | 릴리스 뒤 7일 관찰 | 릴리스 담당 |
| QA1 갱신 목록(10-4) | 목록으로 인계 | QA1 담당 |
동의 설정 진행 표시 consentSettingPendingType(#1625) 의 값 스토어 이동 | 병합 시 R5 등재로 통과 — 규칙 1 대로면 프로필 제공자 아래 값 스토어 | 후속 |