Skip to content

렌더 장부 — #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컨텍스트 훅uSESuseMemouseState인라인 fn prop인라인 obj prop인덱스 key레이아웃 읽기.map
src/react/ui/mobile/screens/DaySummaryScreen.tsxscreen373000000000
src/react/ui/mobile/screens/FeedScreen.tsxscreen31460011181109
src/react/ui/mobile/screens/GroupComposer.tsxscreen21550000237009
src/react/ui/mobile/screens/GroupScreen.tsxscreen836250useTabStack0106757024
src/react/ui/mobile/screens/HomeScreen.tsxscreen49250useTabStack0422434011
src/react/ui/mobile/screens/OnboardingScreen.tsxscreen36240005252005
src/react/ui/mobile/screens/ProfileScreen.tsxscreen52390006332004
src/react/ui/mobile/screens/PrTools.tsxscreen419400502900112
src/react/ui/mobile/screens/RecordsDetail.tsxscreen55750useTabStack0902137025
src/react/ui/mobile/screens/RecoveryScreen.tsxscreen1032000270007
src/react/ui/mobile/screens/ReportScreen.tsxscreen862235012129157034
src/react/ui/mobile/screens/SessionScreen.tsxscreen37020useTabStack002212305
src/react/ui/mobile/screens/SetSearch.tsxscreen1172003080002
src/react/ui/mobile/screens/TabSkeletons.tsxscreen922000004000
src/react/ui/mobile/screens/TrainingStyleScreen.tsxscreen941000240001
src/react/ui/mobile/screens/WorkoutExtras.tsxscreen22730012234038
src/react/ui/mobile/screens/WorkoutFinish.tsxscreen30630004221219
src/react/ui/mobile/screens/WorkoutFlow.tsxscreen508700074626005
src/react/ui/mobile/screens/WorkoutPicker.tsxscreen223800210171005
src/react/ui/mobile/screens/WorkoutRecord.tsxscreen160470088153249340
src/react/ui/mobile/screens/WorkoutRecordParts.tsxscreen4681700006831014
src/react/ui/mobile/lib/AccountDeleteFlow.tsxlib1191000342001
src/react/ui/mobile/lib/brandNotify.tsxlib590000000000
src/react/ui/mobile/lib/BrandRow.tsxlib1012000140021
src/react/ui/mobile/lib/CalendarCore.tsxlib421000031102
src/react/ui/mobile/lib/DayDetailBody.tsxlib39550010636013
src/react/ui/mobile/lib/DayOverlay.tsxlib432000020000
src/react/ui/mobile/lib/DaySheet.tsxlib702000030000
src/react/ui/mobile/lib/FeedModeration.tsxlib1074000060001
src/react/ui/mobile/lib/FeedPost.tsxlib26280003111238
src/react/ui/mobile/lib/FriendScopeBar.tsxlib351000021001
src/react/ui/mobile/lib/GrassCal.tsxlib1211000003609
src/react/ui/mobile/lib/IntensityGuide.tsxlib271000023000
src/react/ui/mobile/lib/ListOverlay.tsxlib391000000000
src/react/ui/mobile/lib/Modal.tsxlib361000000000
src/react/ui/mobile/lib/PhotoCard.tsxlib501000001001
src/react/ui/mobile/lib/PhotoComposer.tsxlib301001031000
src/react/ui/mobile/lib/PrcTierBadge.tsxlib321000000000
src/react/ui/mobile/lib/RecordIcons.tsxlib161000000000
src/react/ui/mobile/lib/SearchField.tsxlib531000050000
src/react/ui/mobile/lib/SessionSheetBody.tsxlib3428000214114010
src/react/ui/mobile/lib/StackScreen.tsxlib8310useTabStack00110000
src/react/ui/mobile/lib/StackSessionOverlay.tsxlib361000000000
src/react/ui/mobile/lib/WorkoutResumePrompt.tsxlib671000130000
src/react/features/active-workout/MobileGroupComposerBinding.tsxbinding151011101000
src/react/features/active-workout/MobileWorkoutBinding.tsxbinding6910useCalendarFeature useCatalogFeature usePrFeature useProfileFeature useShellNavigation useVolumeFeature useWorkoutFeature01010000
src/react/features/active-workout/MobileWorkoutRestoreBinding.tsxbinding1810useWorkoutFeature00030000
src/react/features/active-workout/WorkoutFlowBinding.tsxbinding22820useOwnerStores42202000
src/react/features/calendar/MobileCalendarBinding.tsxbinding8810useCalendarFeature useMobileCalendarJournal useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useShellNavigation useWorkoutFeature00080001
src/react/features/calendar/MobileCalendarJournalProvider.tsxbinding1910useCalendarFeature useMobileCalendarJournal00000000
src/react/features/calendar/MobileDaySummaryBinding.tsxbinding7010useCalendarFeature useMobileSessionActions useOwnerStores useProfileFeature useSessionFeature useShellNavigation useWorkoutFeature00050000
src/react/features/group/MobileGroupBinding.tsxbinding31110useCatalogFeature useGroupFeature useModerationFeature useOwnerStores usePrFeature useProfileFeature useShellNavigation useSocialFeature useTabStack useWorkoutFeature010382003
src/react/features/group/MobileNotificationShellBinding.tsxbinding5110useGroupFeature useModerationFeature useOwnerStores useShellNavigation useSocialFeature useWorkoutFeature10000000
src/react/features/home/MobileHomeBinding.tsxbinding15420useCalendarFeature useHomeFeature useMobileNavTab useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useShellNavigation useVolumeFeature useWorkoutFeature03130000
src/react/features/navigation/MobileDrawerBinding.tsxbinding7510useCatalogFeature useMobileReportControls useOwnerStores usePrFeature useProfileFeature useShellNavigation useWorkoutFeature00050000
src/react/features/navigation/MobileStackBindings.tsxbinding4420useTabStack00000001
src/react/features/navigation/MobileTabsRegion.tsxbinding7710useHomeFeature useMobileNavTab useOwnerStores useShellNavigation useWorkoutFeature20000000
src/react/features/navigation/mobileNavigationBindings.tsxbinding6210useMobileNavTab useOwnerStores useShellNavigation useWorkoutFeature30000000
src/react/features/pr/MobilePrDetailBinding.tsxbinding3810usePrFeature useProfileFeature01000000
src/react/features/pr/MobileRecordsToolsBinding.tsxbinding11310useCatalogFeature useOwnerStores usePrFeature useProfileFeature useRecoveryFeature12040002
src/react/features/profile/MobileProfileBinding.tsxbinding4710useCatalogFeature useModerationFeature useProfileFeature00010000
src/react/features/search/MobileSetSearchBinding.tsxbinding6620usePrFeature useProfileFeature useSearchFeature useSessionFeature02130000
src/react/features/session/MobileSessionOverlayBinding.tsxbinding4510useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useWorkoutFeature10000000
src/react/features/social/MobileFeedBinding.tsxbinding9210useGroupFeature useModerationFeature useOwnerStores useProfileFeature useSessionFeature useShellNavigation useSocialFeature21010000
src/react/features/social/MobileFriendScopeBinding.tsxbinding29310useModerationFeature useOwnerStores useProfileFeature useSessionFeature useShellNavigation useSocialFeature useWorkoutFeature012283005
src/react/features/social/MobileFriendSessionBinding.tsxbinding3310useProfileFeature useSessionFeature useShellNavigation useSocialFeature useWorkoutFeature00010000
src/react/features/social/MobileMyFeedBinding.tsxbinding5510useModerationFeature useProfileFeature useSessionFeature useShellNavigation useSocialFeature01040000
src/react/features/social/MobileSuspensionBinding.tsxbinding1010useModerationFeature00000000
src/react/features/volume/MobileReportBinding.tsxbinding13430useCalendarFeature useMobileCalendarJournal useMobileReportControls useMobileSessionActions useOwnerStores usePrFeature useProfileFeature useSessionFeature useShellNavigation useSocialFeature useVolumeFeature useWorkoutFeature12271000
src/react/mobileApp.tsxbinding8040useMobileNavTab useShellNavigation useWorkoutFeature00000001

2. 장부 B — 조립 루트 갱신원 (60)

Phase 1 기준선. Phase 2 뒤 11 — §5-1.

장부 B — 조립 루트 갱신원 60개 (useState 41 · useReducer 0 · useSyncExternalStore 19) · 파일 26

#파일:줄종류경로코드
1src/react/appController.tsx:960useStateappControllerconst [legalDocumentPath, setLegalDocumentPath] = React.useState<string | null>(null);
2src/react/controllers/navigationController.ts:24useSyncExternalStoreappController → useNavigationControllerconst daySummary = React.useSyncExternalStore(
3src/react/controllers/navigationController.ts:29useSyncExternalStoreappController → useNavigationControllerconst sessionDetailOrigin = React.useSyncExternalStore(
4src/react/controllers/navigationController.ts:34useSyncExternalStoreappController → useNavigationControllerconst workoutOriginTab = React.useSyncExternalStore(
5src/react/controllers/navigationController.ts:40useSyncExternalStoreappController → useNavigationControllerconst tab = React.useSyncExternalStore(
6src/react/features/home/homeSnapshotBinding.ts:7useStateappController → useHomeSnapshotBindingconst [shellGymData, setGymData] = React.useState<RuntimeGymData>(() => createRuntimeGymData());
7src/react/features/home/homeSnapshotBinding.ts:8useStateappController → useHomeSnapshotBindingconst [plans, setPlans] = React.useState<SessionPresentationInput[]>([]);
8src/react/features/calendar/calendarDateBinding.ts:5useStateappController → useCalendarDateBindingconst [calendarSelectedDate, setCalendarSelectedDate] = React.useState(todayDate);
9src/react/features/calendar/calendarDateBinding.ts:6useStateappController → useCalendarDateBindingconst [sessionDate, setSessionDate] = React.useState(todayDate);
10src/react/features/calendar/calendarDateBinding.ts:7useStateappController → useCalendarDateBindingconst [visibleMonth, setVisibleMonth] = React.useState(() => lgMonthFromDate(todayDate));
11src/react/controllers/remoteDataController.ts:524useStateappController → useRemoteDataControllerconst [authStatus, setAuthStatusState] = React.useState<AuthStatus>("loading");
12src/react/controllers/remoteDataController.ts:525useStateappController → useRemoteDataControllerconst [dataStatus, setDataStatusState] = React.useState<DataStatus>("idle");
13src/react/controllers/remoteDataController.ts:526useStateappController → useRemoteDataControllerconst [homeDataStatus, setHomeDataStatus] = React.useState<DataStatus>("idle");
14src/react/controllers/remoteDataController.ts:539useStateappController → useRemoteDataControllerconst [remoteUser, setRemoteUser] = React.useState<StatsAuthUser | null>(null);
15src/react/controllers/remoteDataController.ts:551useStateappController → useRemoteDataControllerconst [remoteError, setRemoteError] = React.useState<string>("");
16src/react/controllers/remoteDataController.ts:552useStateappController → useRemoteDataControllerconst [authSessionLifecycleRevision, setAuthSessionLifecycleRevision] = React.useState<number>(0);
17src/react/controllers/remoteDataController.ts:561useStateappController → useRemoteDataControllerconst [connectivityStatus, setConnectivityStatus] = React.useState<ConnectivityState["status"] | null>(null);
18src/react/controllers/remoteDataController.ts:581useStateappController → useRemoteDataControllerconst [appShellSnapshotState, setAppShellSnapshotState] = React.useState<AppShellSnapshotRuntimeState>(
19src/react/controllers/remoteDataController.ts:587useStateappController → useRemoteDataControllerconst [screenLoadingLabel, setScreenLoadingLabel] = React.useState<string>("");
20src/react/controllers/remoteDataController.ts:595useStateappController → useRemoteDataControllerconst [dataTick, setDataTick] = React.useState<number>(0);
21src/react/controllers/remoteDataController.ts:605useStateappController → useRemoteDataControllerconst [homeFragmentExpectation, setHomeFragmentExpectation] = React.useState(emptyHomeFragmentExpectation);
22src/react/controllers/remoteDataController.ts:728useStateappController → useRemoteDataControllerconst [statsRecoveryState, updateStatsRecoveryState] = React.useState<{ status: StatsNumberStatus; requestedVersion: number }>({ status: "re
23src/react/controllers/remoteDataController.ts:747useStateappController → useRemoteDataControllerconst [sessionSearchLoading, setSessionSearchLoading] = React.useState<boolean>(false);
24src/react/controllers/remoteDataController.ts:748useStateappController → useRemoteDataControllerconst [sessionSearchStatus, setSessionSearchStatus] = React.useState<DataStatus>("idle");
25src/react/controllers/remoteDataController.ts:749useStateappController → useRemoteDataControllerconst [sessionSearchError, setSessionSearchError] = React.useState("");
26src/react/resources/useResourceSnapshot.ts:30useSyncExternalStoreappController → useRemoteDataController → useResourceSnapshotconst snapshot = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
27src/react/resources/useResourceSnapshot.ts:48useSyncExternalStoreappController → useRemoteDataController → useResourceSnapshotreturn React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
28src/react/resources/useResourceSnapshot.ts:60useSyncExternalStoreappController → useRemoteDataController → useResourceSnapshotreturn React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
29src/react/features/pr/queries/prExerciseQueryBinding.ts:87useStateappController → useRemoteDataController → usePrExerciseQueryBindingconst [activePrExerciseId, setActivePrExerciseId] = React.useState<string>("");
30src/react/features/pr/queries/prExerciseQueryBinding.ts:143useSyncExternalStoreappController → useRemoteDataController → usePrExerciseQueryBindingconst prExerciseDetails = React.useSyncExternalStore(subscribePrExerciseStores, readPrExerciseDetails, readPrExerciseDetails);
31src/react/controllers/ownerScopeController.ts:12useStateappController → useOwnerScopeControllerconst [userScopedScreenStateRevision, setUserScopedScreenStateRevision] = React.useState(0);
32src/react/features/social/friendScopeController.ts:43useSyncExternalStoreappController → useFriendScopeControllerconst state = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
33src/react/controllers/workoutDraftController.ts:62useSyncExternalStoreappController → useWorkoutDraftControllerconst draftState = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
34src/react/controllers/socialIdentityController.ts:39useStateappController → useSocialIdentityControllerconst [connections, setConnections] = React.useState<readonly SocialIdentityConnection[]>(
35src/react/controllers/socialIdentityController.ts:42useStateappController → useSocialIdentityControllerconst [loading, setLoading] = React.useState(false);
36src/react/controllers/socialIdentityController.ts:43useStateappController → useSocialIdentityControllerconst [error, setError] = React.useState("");
37src/react/controllers/socialIdentityController.ts:44useStateappController → useSocialIdentityControllerconst [pendingProvider, setPendingProvider] = React.useState<SocialAuthProvider | "">("");
38src/react/controllers/calendarReadModelController.ts:67useSyncExternalStoreappController → useCalendarReadModelController} = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
39src/react/features/recovery/useRecoveryPanel.ts:22useStateappController → useRecoveryPanelconst [published, setPublished] = React.useState<{ revision: number; props: RecoveryPanelProps } | null>(null);
40src/react/controllers/pendingWorkoutSavesController.ts:118useSyncExternalStoreappController → usePendingWorkoutSavesControllerconst { pendingWorkoutSaves } = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
41src/react/controllers/prController.ts:60useStateappController → usePrViewBinding → usePrControllerconst [prExerciseId, setPrExerciseIdState] = React.useState<string | null>(null);
42src/react/controllers/prController.ts:129useStateappController → usePrViewBinding → usePrControllerconst [favoriteSnapshot, setFavoriteSnapshot] = React.useState<FavoriteSnapshot>(() => (
43src/react/controllers/prController.ts:133useStateappController → usePrViewBinding → usePrControllerconst [favoriteRead, setFavoriteRead] = React.useState<{ owner: string; status: "loading" | "ready" | "error" }>({ owner: "", status: "loadi
44src/react/controllers/prController.ts:134useStateappController → usePrViewBinding → usePrControllerconst [favoriteReadRevision, setFavoriteReadRevision] = React.useState(0);
45src/react/controllers/prController.ts:169useStateappController → usePrViewBinding → usePrControllerconst [prJump, setPrJump] = React.useState<PrSessionJump | null>(null);
46src/react/controllers/prController.ts:170useStateappController → usePrViewBinding → usePrControllerconst [prDetailReturnTab, setPrDetailReturnTabState] = React.useState<NavigationTab | null>(null);
47src/react/controllers/searchController.ts:95useStateappController → usePrViewBinding → usePrController → usePrExerciseSearchControllerconst [input, setInput] = React.useState("");
48src/react/controllers/searchController.ts:96useStateappController → usePrViewBinding → usePrController → usePrExerciseSearchControllerconst [query, setQuery] = React.useState("");
49src/react/controllers/searchController.ts:97useStateappController → usePrViewBinding → usePrController → usePrExerciseSearchControllerconst [isPending, setIsPending] = React.useState(false);
50src/react/controllers/sessionDetailController.ts:53useSyncExternalStoreappController → useSessionDetailControllerconst { selectedSession, sessionDetailPendingId, sessionDetailError } = React.useSyncExternalStore(
51src/react/features/catalog/catalogBinding.ts:63useStateappController → useCatalogFeatureBindingconst [exerciseSearchSignals, setExerciseSearchSignals] = React.useState<ExerciseSearchSignals | null>(null);
52src/react/controllers/customExerciseController.ts:35useSyncExternalStoreappController → useCatalogFeatureBinding → useOwnCustomExerciseControllerconst { error, exercises, loading, pendingId, dataStatus } = React.useSyncExternalStore(
53src/react/features/social/followingController.ts:35useSyncExternalStoreappController → useSocialRelationsBinding → useFeedFollowingControllerconst { following, results } = React.useSyncExternalStore(
54src/react/features/social/feedSocialController.ts:36useSyncExternalStoreappController → useSocialRelationsBinding → useFeedSocialControllerconst { socialBySession, likePendingIds, comments } = React.useSyncExternalStore(
55src/react/features/social/moderationController.ts:41useSyncExternalStoreappController → useSocialRelationsBinding → useModerationControllerconst moderation = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
56src/react/features/group/groupController.ts:36useSyncExternalStoreappController → useGroupControllerconst groupState = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
57src/react/controllers/planAdoptionController.ts:25useSyncExternalStoreappController → usePlanAdoptionControllerconst { friendCalendar, loading, error } = React.useSyncExternalStore(
58src/react/features/pr/manualRecordsBinding.ts:54useStateappController → useManualRecordsBindingconst [writePending, setWritePending] = React.useState<{ owner: string; id: string } | null>(null);
59src/react/features/import/importFeatureBinding.ts:31useStateappController → useImportOutcomeBinding → useImportFeatureBindingconst [selection, setSelection] = React.useState({ ownerId, jobId: restoreImportJobId(browserStorage(), ownerId) });
60src/react/features/import/importFeatureBinding.ts:32useStateappController → useImportOutcomeBinding → useImportFeatureBindingconst [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.volumeOverview11212pane:home, shell:drawer, shell:tabspane:home, shell:drawer, shell:tabs❌ 경계 위반 + 루트 초과
tab.switch14511pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer❌ 경계 위반 + 루트 초과
calendar.selectDay134pane:calendar, pane:home, pane:volume, shell:tabs, stack:journalDaypane:home, pane:volume, shell:tabs, stack:journalDay❌ 경계 위반 + 루트 초과
session.open147pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:sessionpane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay❌ 경계 위반 + 루트 초과
session.close113pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:sessionpane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay❌ 경계 위반 + 루트 초과
stack.pushPop326pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, stack:journalDay, stack:prDetail, workoutpane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workout❌ 경계 위반 + 루트 초과
calendar.changeMonth468pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:feed, pane:home, pane:volume, shell:drawer, shell:tabs❌ 경계 위반 + 루트 초과
pr.open11415pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetailpane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs❌ 경계 위반 + 루트 초과
report.detailArrival104pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, shell:drawer, shell:tabs❌ 경계 위반
connectivity.change2810pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs❌ 경계 위반 + 루트 초과
toast246pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs❌ 경계 위반 + 루트 초과
workout.setInput802workout없음✅ 성립
workout.restTick100없음없음✅ 성립

표본별 상세 (update = 다시 그려진 경계, mount = 처음 그려진 경계, 시간은 개발 React 기준 전후 비교용):

#입력 종류표본루트 재실행전체 커밋updatemount허용 밖창 안 응답 도착
1response.volumeOverview부팅 뒤 미리 받기 응답 창(7초, 입력 없음)1212 (222.6ms)pane:home, shell:drawer, shell:tabspane:home, shell:drawer, shell:tabsget_exercise_catalog@519ms/0k get_calendar_month_summary@68ms/0k get_calendar_day_summary@145ms/0k get_volume_overview@213ms/0k
2tab.switch첫 방문: home → calendar59 (93.8ms)pane:calendar, pane:home, shell:drawer, shell:tabspane:calendarpane:calendar, pane:home, shell:drawerget_calendar_month_summary@119ms/0k get_calendar_month_summary@125ms/0k
3tab.switch첫 방문: calendar → home02 (11.8ms)pane:calendar, pane:home, shell:drawer, shell:tabspane:calendar, pane:home, shell:drawerget_calendar_month_summary@1504ms/0k
4tab.switch첫 방문: home → feed411 (106.5ms)pane:calendar, pane:feed, pane:home, shell:drawer, shell:tabspane:feedpane:calendar, pane:feed, pane:home, shell:drawerget_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 …
5tab.switch첫 방문: feed → home02 (12.7ms)pane:calendar, pane:feed, pane:home, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, shell:drawerget_session_detail@91ms/0k get_session_detail@695ms/0k get_session_detail@1307ms/0k get_session_detail@1825ms/0k
6tab.switch첫 방문: home → volume (드로어)07 (132.9ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:volumepane:calendar, pane:feed, pane:home, pane:volume, shell:drawerget_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 …
7tab.switch첫 방문: volume → home02 (14.8ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawerget_session_detail@275ms/0k get_session_detail@881ms/0k get_session_detail@1342ms/0k get_session_detail@1773ms/0k
8tab.switch재방문: home → calendar03 (21.9ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawerget_session_detail@317ms/0k get_session_detail@903ms/0k get_session_detail@1372ms/0k get_calendar_month_summary@1918ms/0k
9tab.switch재방문: calendar → home02 (13.2ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawerget_session_detail@389ms/0k get_session_detail@871ms/0k get_calendar_month_summary@1494ms/0k
10tab.switch재방문: home → feed14 (20.1ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawerget_calendar_month_summary@8ms/0k get_calendar_month_summary@520ms/0k get_calendar_month_summary@1063ms/0k get_calendar_month_summary@1560ms/0k
11tab.switch재방문: feed → home02 (16.4ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawerget_calendar_month_summary@7ms/0k
12tab.switch재방문: home → volume (드로어)04 (25.5ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer
13tab.switch재방문: volume → home02 (14.4ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer
14calendar.selectDay일지에서 운동한 날짜 누름(하루 상세 push)34 (84.7ms)pane:calendar, pane:home, pane:volume, shell:tabs, stack:journalDaystack:journalDaypane:home, pane:volume, shell:tabs, stack:journalDayget_calendar_day_summary@148ms/0k
15session.open하루 상세에서 세션 카드 누름47 (189.1ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:sessionstack:sessionpane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDayget_session_detail@184ms/0k record_client_error_event@418ms/0k
16session.close세션 상세 뒤로13 (70.9ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:sessionpane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay
17stack.pushPop하루 상세 뒤로(pop)02 (20.9ms)pane:calendar, shell:tabs, stack:journalDayshell:tabs
18calendar.changeMonth이전 달 167 (180.5ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:feed, pane:home, pane:volume, shell:drawer, shell:tabsget_calendar_day_summary@301ms/0k get_calendar_month_summary@292ms/0k
19calendar.changeMonth이전 달 268 (159.7ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:feed, pane:home, pane:volume, shell:drawer, shell:tabsget_calendar_day_summary@243ms/0k get_calendar_month_summary@241ms/0k
20calendar.changeMonth다음 달 114 (89.2ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:feed, pane:home, pane:volume, shell:drawer, shell:tabs
21calendar.changeMonth다음 달 213 (59.7ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:feed, pane:home, pane:volume, shell:drawer, shell:tabs
22pr.open홈 PR 보드 행 누름(종목 상세 push)1415 (2752.7ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetailstack:prDetailpane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabsget_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
23stack.pushPop종목 상세 뒤로(pop)14 (119.5ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetailpane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs
24report.detailArrival드로어 리포트 진입(개요·상세 응답 도착)04 (18ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, shell:drawer, shell:tabs
25tab.switchvolume → home02 (12.7ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer
26tab.switch재방문: home → volume (드로어, 유효 캐시)04 (23.9ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer
27connectivity.change오프라인 전환12 (3.6ms)record_client_error_event@1230ms/0k
28connectivity.change온라인 복귀810 (557.7ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabsrecord_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
29toast토스트 표시+소멸 146 (163.7ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabspane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabsget_pr_overview@213ms/0k
30toast토스트 표시+소멸 202 (1.3ms)
31stack.pushPop운동 시작(운동 층 push)26 (160.7ms)pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workoutshell:workoutNav, workoutpane:calendar, pane:feed, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workoutsave_workout_draft_checkpoint_v2@436ms/0k record_client_error_event@468ms/0k
32workout.setInput세트 입력 1 (무게=60)01 (5.8ms)workout
33workout.setInput세트 입력 2 (횟수=6)01 (4.8ms)workout
34workout.setInput세트 입력 3 (무게=70)01 (5.9ms)workout
35workout.setInput세트 입력 4 (횟수=8)01 (5.1ms)workout
36workout.setInput세트 추가(편집 모드)01 (6.6ms)workout
37workout.setInput세트 2 입력(무게=70·횟수=8)01 (4.5ms)workout
38workout.setInput세트 완료 체크(휴식 시작 + RPE 카드)01 (9.7ms)workout
39workout.setInputRPE 선택(카드 닫힘)02 (8ms)workout
40workout.restTick휴식 타이머 진행 창(2.5초, 입력 없음)00 (0ms)

미계측·주의:

  • workout.restTick(휴식 타이머 0.5초 진행): 세트 완료 뒤 휴식이 시작되지 않아 창 안 커밋 0 — 완료 처리 조건(마지막 세트·뒤 세트 채움 여부·휴식 건너뛰기)에 걸린 것으로 보이며 Phase 5 가 운동 층 내부 계측에서 직접 잰다. 코드 판독은 화면 전체 재렌더(setTick 500ms).
  • 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재마운트판정
baselinecalendar-enterhistory-1ymobilecold320/2098 / 10441211 / 233535 / 5373불합격(dataToComplete)
finalcalendar-enterhistory-1ymobilecold320/20103 / 10344232 / 281534 / 6003불합격(meaningful,dataToComplete)
v0-19-2calendar-enterhistory-1ymobilecold320/2098 / 11338200 / 220501 / 5033불합격(dataToComplete)
baselinecalendar-enterhistory-1ymobilewarm320/2056 / 5626— / -∞56 / 5600회합격
finalcalendar-enterhistory-1ymobilewarm320/20142 / 1799— / -∞142 / 17900회불합격(meaningful,complete)
v0-19-2calendar-enterhistory-1ymobilewarm320/20164 / 1718— / -∞164 / 17100회불합격(meaningful,complete)
baselinecalendar-enterhistory-4ymobilecold320/2092 / 944048 / 114342 / 3503합격
v0-19-2calendar-enterhistory-4ymobilecold320/20104 / 1084141 / 68338 / 3543불합격(meaningful)
baselinecalendar-enterhistory-4ymobilewarm320/2069 / 9336— / -∞69 / 9300회합격
v0-19-2calendar-enterhistory-4ymobilewarm320/20133 / 13712— / -∞133 / 13700회불합격(meaningful,complete)
baselinecalendar-monthhistory-1ymobilecold320/2045 / 5430390 / 445745 / 7581불합격(dataToComplete)
finalcalendar-monthhistory-1ymobilecold320/2049 / 4918190 / 215467 / 4991불합격(dataToComplete)
v0-19-2calendar-monthhistory-1ymobilecold320/2047 / 5321178 / 185635 / 6401불합격(dataToComplete)
baselinecalendar-monthhistory-1ymobilewarm320/2038 / 3926— / -∞38 / 3900회합격
finalcalendar-monthhistory-1ymobilewarm320/2033 / 3515— / -∞33 / 3500회합격
v0-19-2calendar-monthhistory-1ymobilewarm320/2023 / 2417— / -∞23 / 2400회합격
baselinecalendar-monthhistory-4ymobilecold320/2048 / 4920522 / 563783 / 8171불합격(dataToComplete)
v0-19-2calendar-monthhistory-4ymobilecold320/2050 / 5020335 / 360658 / 6841불합격(dataToComplete)
baselinecalendar-monthhistory-4ymobilewarm320/2037 / 4031— / -∞37 / 4000회합격
v0-19-2calendar-monthhistory-4ymobilewarm320/2024 / 2412— / -∞24 / 2400회합격
baselinereport-openhistory-1ymobilecold320/2088 / 9817620 / 6491,501 / 1,5022불합격(dataToComplete)
finalreport-openhistory-1ymobilecold320/20260 / 28022340 / 3781,204 / 1,2532불합격(meaningful,dataToComplete)
v0-19-2report-openhistory-1ymobilecold320/20288 / 33322249 / 2951,150 / 1,2442불합격(meaningful,dataToComplete)
baselinereport-openhistory-1ymobilewarm320/20153 / 18126— / -∞153 / 18100회불합격(meaningful,complete)
finalreport-openhistory-1ymobilewarm320/20119 / 12111— / -∞119 / 12100회불합격(meaningful,complete)
v0-19-2report-openhistory-1ymobilewarm320/20122 / 12212— / -∞122 / 12200회불합격(meaningful,complete)
baselinereport-openhistory-4ymobilecold320/2086 / 23719696 / 7861,628 / 1,8182불합격(dataToComplete)
v0-19-2report-openhistory-4ymobilecold320/20340 / 41720409 / 5551,220 / 1,3452불합격(meaningful,dataToComplete)
baselinereport-openhistory-4ymobilewarm320/20223 / 22740— / -∞223 / 22700회불합격(meaningful,complete)
v0-19-2report-openhistory-4ymobilewarm320/20118 / 11911— / -∞118 / 11900회불합격(meaningful,complete)

요약(p95, 세 빌드 같은 날):

시나리오fixture캐시첫 반응 baseline → final → v0.19.2마지막 데이터→공개입력→완성판정(v0.19.2)
calendar-enter1년cold98 → 103 → 98211 → 232 → 200535 → 534 → 501불합격(공개 200 > 100)
calendar-enter1년warm56 → 142 → 16456 → 142 → 164불합격(회귀: 첫 반응 56 → 164)
calendar-enter4년cold92 → (묶음1·2 91·90) → 10448 → 42·41 → 41342 → 338·346 → 338불합격(첫 반응 104 > 100, 경계)
calendar-enter4년warm69 → (묶음1·2 52·77) → 133불합격(회귀)
calendar-month1년cold45 → 49 → 47390 → 190 → 178745 → 467 → 635불합격(공개 178 > 100)
calendar-month1년warm38 → 33 → 23합격
calendar-month4년cold48 → (묶음1·2 37·49) → 50522 → 290·349 → 335783 → 654·669 → 658불합격(공개)
calendar-month4년warm37 → (24·24) → 24합격
report-open1년cold88 → 260 → 288620 → 340 → 2491,501 → 1,204 → 1,150불합격(회귀: 첫 반응 88 → 288)
report-open1년warm153 → 119 → 122불합격(첫 반응 122)
report-open4년cold86 → (묶음2 388) → 340696 → 417 → 4091,628 → 1,252 → 1,220불합격(회귀)
report-open4년warm223 → (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.volumeOverview112 → 112 → 14pane:home, shell:drawer, shell:tabs❌ 루트 초과 · 경계 밖 3(Phase 4·6 몫)
tab.switch145 → 011 → 11pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫)
calendar.selectDay13 → 04 → 4pane:calendar, pane:home, pane:volume, shell:tabs, stack:journalDay✅ 루트 허용치 안 · 경계 밖 4(Phase 4·6 몫)
session.open14 → 07 → 5pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:journalDay, stack:session✅ 루트 허용치 안 · 경계 밖 6(Phase 4·6 몫)
session.close11 → 03 → 2pane:calendar, pane:feed, pane:home, pane:volume, shell:tabs, stack:journalDay, stack:session✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫)
stack.pushPop32 → 06 → 5pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, stack:journalDay, stack:prDetail, workout✅ 루트 허용치 안 · 경계 밖 7(Phase 4·6 몫)
calendar.changeMonth46 → 08 → 6pane:calendar, pane:home, pane:volume, shell:tabs✅ 루트 허용치 안 · 경계 밖 3(Phase 4·6 몫)
pr.open114 → 015 → 3pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, stack:prDetail✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫)
report.detailArrival10 → 04 → 4pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs✅ 루트 허용치 안 · 경계 밖 5(Phase 4·6 몫)
connectivity.change28 → 110 → 5pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs❌ 루트 초과 · 경계 밖 6(Phase 4·6 몫)
toast24 → 16 → 12pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs❌ 루트 초과 · 경계 밖 6(Phase 4·6 몫)
workout.setInput80 → 02 → 2workout✅ 루트 허용치 안 · 경계 성립
workout.restTick10 → 00 → 0없음✅ 루트 허용치 안 · 경계 성립

"경계 밖" 은 불변식 표의 허용 경계 밖 판(예: 탭 전환에 숨은 홈·피드·리포트 판이 다시 그려짐)이 아직 남아 있다는 뜻이다 — 화면 조각 구독(Phase 4)과 숨은 판 수명(Phase 6)의 몫이며 Phase 2 의 목표(루트 재실행·컨텍스트 값 변경)와는 다른 축이다.

5-1. 장부 B — Phase 2 뒤 (11)

장부 B — 조립 루트 갱신원 11개 (useState 8 · useReducer 0 · useSyncExternalStore 3) · 파일 5

#파일:줄종류경로코드
1src/react/controllers/navigationController.ts:28useSyncExternalStoreappController → useNavigationControllerconst tab = React.useSyncExternalStore(
2src/react/features/home/homeSnapshotBinding.ts:7useStateappController → useHomeSnapshotBindingconst [shellGymData, setGymData] = React.useState<RuntimeGymData>(() => createRuntimeGymData());
3src/react/features/home/homeSnapshotBinding.ts:8useStateappController → useHomeSnapshotBindingconst [plans, setPlans] = React.useState<SessionPresentationInput[]>([]);
4src/react/controllers/remoteDataController.ts:542useStateappController → useRemoteDataControllerconst [authStatus, setAuthStatusState] = React.useState<AuthStatus>("loading");
5src/react/controllers/remoteDataController.ts:543useStateappController → useRemoteDataControllerconst [dataStatus, setDataStatusState] = React.useState<DataStatus>("idle");
6src/react/controllers/remoteDataController.ts:559useStateappController → useRemoteDataControllerconst [remoteUser, setRemoteUser] = React.useState<StatsAuthUser | null>(null);
7src/react/controllers/remoteDataController.ts:572useSyncExternalStoreappController → useRemoteDataControllerconst profileGateKey = React.useSyncExternalStore(subscribeProfileGate, readProfileGateKey, readProfileGateKey);
8src/react/controllers/remoteDataController.ts:580useStateappController → useRemoteDataControllerconst [remoteError, setRemoteError] = React.useState<string>("");
9src/react/controllers/remoteDataController.ts:616useStateappController → useRemoteDataControllerconst [appShellSnapshotState, setAppShellSnapshotState] = React.useState<AppShellSnapshotRuntimeState>(
10src/react/controllers/ownerScopeController.ts:12useStateappController → useOwnerScopeControllerconst [userScopedScreenStateRevision, setUserScopedScreenStateRevision] = React.useState(0);
11src/react/controllers/calendarReadModelController.ts:178useSyncExternalStoreappController → 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·usePrOverviewSnapshot5e7c77a1
큰 미리 받기는 유저의 요청 사슬이 끝나고 화면이 그려진 뒤, 입력이 없는 창에서 시작(입력 700ms 없음 + 유저의 요청 진행 중 아님 + 마지막 요청 뒤 1.5초, 상한 6초)services/inputActivity.ts waitForInputQuiet — 탭 워밍 features/navigation/tabWarmBinding.ts, 홈 PR 개요·카탈로그 동기화 controllers/screenLoadPolicyController.tscddd0679a725afd3
응답 처리는 입력에 양보(검증·투영 앞 yieldIfInputBusy, 200KB 이상은 채택 앞 한 번 더), 배경 응답은 유저의 요청 사슬이 끝날 때까지 미룸(상한 3초)services/screenRpcRuntime.ts waitForForegroundQuietcddd0679fd15a765
투영은 응답이 바꾼 것만 — 리포트 개요 투영을 PR 보드·카드 계산과 분리features/pr/PrViewProvider.tsxcddd0679
워커 채택은 실측으로(6-1)채택하지 않음
요청의 출처 = 화면 공개가 기다리지 않는 순수 미리 받기(탭 워밍의 리포트 개요)만 호출자가 origin: "background" 로 표시, 표시가 없으면 유저의 요청(미룸 없음), 유저가 같은 자원을 기다리면 진행 중인 미리 받기를 승격services/inputActivity.ts beginScreenWork·promoteScreenWork, 저장소·자원 로더·remoteDataController.ts 로 origin 전달fd15a7655862b997da30b098
계측완료 기록 screen_rpc:completedadaptMs(재측정+검증·투영)·measureMs(바이트 재직렬화)·originservices/screenRpcRuntime.ts6b5909f0·fd15a765

왜 여섯 개가 됐나 — 실측이 고친 설계 세 번:

  1. 처음 ②는 "마지막 입력 뒤 700ms" 였다(p3 빌드). 판정 40표본에서 4년 fixture 의 달력 cold 첫 반응이 104 → 196ms 로 나빠졌고, CPU 프로필(6-1)에서 원인이 보였다 — 누른 화면의 응답 처리가 정확히 그 700ms 뒤에 일어나므로, 미리 받기의 시작이 그 처리와 겹쳤다(리포트 공개 창에 카탈로그 검증·저장 exerciseCatalogStore.put· screenRpcAdapters 문자열 검사·UTF-8 인코딩 약 80~150ms@4x). → "유저의 요청이 끝나고 화면이 그려진 뒤"(3-4).
  2. 시작을 미뤄도 이미 나간 미리 받기의 응답은 그 뒤 유저가 연 화면의 로딩 도중 도착한다(p3c 프로필: 홈에서 시작한 카탈로그 응답이 리포트 열기 −34 → 509ms 에 도착해 개요 투영·렌더와 설계 요청 사이에 끼어 설계 요청이 332 → 889ms 로 늦어짐). → 배경 응답의 처리를 유저의 요청 사슬 뒤로(3-5). 그리고 출처를 시작 시각만으로 판정하면 조용한 창 판정 뒤 실제 요청까지 수백 ms 걸리는 카탈로그 동기화가 입력 직후에 시작해 "유저의 요청" 으로 오판된다(p3d 프로필 #2) → 호출자 표시 + 합류 승격(3-6).
  3. 시작 시각 추정을 남겨 둔 채 판정하니(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)판정
p2report-open-collidehistory-1ycold420/2084 / 8414335 / 3461,237 / 1,284불합격(dataToComplete)
p3report-open-collidehistory-1ycold4·539/4071 / 8415239 / 3501,020 / 1,037불합격(dataToComplete) · 표본 1건 실패(러너)
p3ereport-open-collidehistory-1ycold4·540/4085 / 8616238 / 264868 / 903불합격(dataToComplete)
p3freport-open-collidehistory-1ycold4·540/40103 / 12319379 / 455997 / 1,240불합격(meaningful,dataToComplete)
p2report-open-collidehistory-4ycold420/2072 / 8615484 / 4891,308 / 1,383불합격(dataToComplete)
p3report-open-collidehistory-4ycold4·540/4072 / 8116444 / 4621,269 / 1,305불합격(dataToComplete)
p3ereport-open-collidehistory-4ycold4·540/4073 / 8513236 / 2751,005 / 1,020불합격(dataToComplete)
p3freport-open-collidehistory-4ycold4·540/4082 / 14926308 / 5941,158 / 1,916불합격(dataToComplete)
v0-19-2report-openhistory-1ycold320/20290 / 33322251 / 2951,154 / 1,244불합격(meaningful,dataToComplete)
p2report-openhistory-1ycold420/20101 / 11625361 / 3681,383 / 1,383불합격(meaningful,dataToComplete)
p3report-openhistory-1ycold4·540/4087 / 9627333 / 3691,076 / 1,202불합격(dataToComplete)
p3ereport-openhistory-1ycold4·540/40101 / 11225243 / 267837 / 1,112불합격(meaningful,dataToComplete)
p3freport-openhistory-1ycold4·540/40232 / 24760794 / 8751,922 / 2,017불합격(meaningful,sync,dataToComplete)
v0-19-2report-openhistory-1ywarm320/20122 / 12212122 / 122불합격(meaningful,complete)
p2report-openhistory-1ywarm420/20149 / 14916149 / 149불합격(meaningful,complete)
p3report-openhistory-1ywarm4·540/40147 / 15117147 / 151불합격(meaningful,complete)
p3ereport-openhistory-1ywarm4·540/40137 / 25226137 / 252불합격(meaningful,complete)
p3freport-openhistory-1ywarm4·540/40206 / 23921206 / 239불합격(meaningful,complete)
v0-19-2report-openhistory-4ycold320/20343 / 41720417 / 5551,226 / 1,345불합격(meaningful,dataToComplete)
p2report-openhistory-4ycold420/2072 / 7313522 / 5431,344 / 1,420불합격(dataToComplete)
p3report-openhistory-4ycold4·540/40166 / 21728516 / 6721,565 / 1,665불합격(meaningful,dataToComplete)
p3ereport-openhistory-4ycold4·540/4097 / 10030169 / 1911,069 / 1,099불합격(dataToComplete)
p3freport-openhistory-4ycold4·540/4097 / 10229182 / 1871,034 / 1,099불합격(dataToComplete)
v0-19-2report-openhistory-4ywarm320/20118 / 11911118 / 119불합격(meaningful,complete)
p2report-openhistory-4ywarm420/20137 / 15016137 / 150불합격(meaningful,complete)
p3report-openhistory-4ywarm4·540/40124 / 13415124 / 134불합격(meaningful,complete)
p3ereport-openhistory-4ywarm4·540/40136 / 14917136 / 149불합격(meaningful,complete)
p3freport-openhistory-4ywarm4·540/40284 / 33633284 / 336불합격(meaningful,complete)
v0-19-2calendar-enterhistory-1ycold320/2099 / 11338201 / 220501 / 503불합격(dataToComplete)
p2calendar-enterhistory-1ycold420/20104 / 12439192 / 192478 / 498불합격(meaningful,dataToComplete)
p3calendar-enterhistory-1ycold4·540/40101 / 1154655 / 70355 / 363불합격(meaningful)
p3ecalendar-enterhistory-1ycold4·540/40208 / 221911,717 / 1,8612,015 / 2,303불합격(meaningful,sync,dataToComplete,complete)
p3fcalendar-enterhistory-1ycold4·540/40103 / 1154747 / 51360 / 405불합격(meaningful)
v0-19-2calendar-enterhistory-1ywarm320/20164 / 1718164 / 171불합격(meaningful,complete)
p2calendar-enterhistory-1ywarm419/20157 / 19810157 / 198불합격(meaningful,complete) · 표본 1건 실패(러너)
p3calendar-enterhistory-1ywarm4·540/4051 / 671151 / 67합격
p3ecalendar-enterhistory-1ywarm4·540/4066 / 801366 / 80합격
p3fcalendar-enterhistory-1ywarm4·540/4049 / 651149 / 65합격
v0-19-2calendar-enterhistory-4ycold320/20104 / 1084143 / 68339 / 354불합격(meaningful)
p2calendar-enterhistory-4ycold420/2091 / 1083264 / 112338 / 349합격
p3calendar-enterhistory-4ycold4·540/40196 / 21282278 / 296702 / 778불합격(meaningful,sync,dataToComplete)
p3ecalendar-enterhistory-4ycold4·540/40113 / 125491,689 / 1,7601,992 / 2,069불합격(meaningful,dataToComplete)
p3fcalendar-enterhistory-4ycold4·540/40212 / 515105329 / 479816 / 948불합격(meaningful,sync,dataToComplete)
v0-19-2calendar-enterhistory-4ywarm320/20133 / 13712133 / 137불합격(meaningful,complete)
p2calendar-enterhistory-4ywarm420/20100 / 13721100 / 137불합격(meaningful,complete)
p3calendar-enterhistory-4ywarm4·540/4097 / 1221897 / 122합격
p3ecalendar-enterhistory-4ywarm4·540/4047 / 491247 / 49합격
p3fcalendar-enterhistory-4ywarm4·540/4051 / 671451 / 67합격

요약(p95, 묶음 합침):

시나리오fixture캐시첫 반응 p95 v0-19-2 → p2 → p3 → p3e → p3f마지막 데이터→공개 p95입력→완성 p95판정(p3f)
report-open-collide1년cold— → 84 → 71 → 85 → 103— → 335 → 239 → 238 → 379— → 1,237 → 1,020 → 868 → 997불합격(첫 반응 103, 공개 379)
report-open-collide4년cold— → 72 → 72 → 73 → 82— → 484 → 444 → 236 → 308— → 1,308 → 1,269 → 1,005 → 1,158불합격(공개 308)
report-open1년cold290 → 101 → 87 → 101 → 232251 → 361 → 333 → 243 → 7941,154 → 1,383 → 1,076 → 837 → 1,922불합격(첫 반응 232, 공개 794)
report-open1년warm122 → 149 → 147 → 137 → 206122 → 149 → 147 → 137 → 206불합격(첫 반응 206, 완성 206)
report-open4년cold343 → 72 → 166 → 97 → 97417 → 522 → 516 → 169 → 1821,226 → 1,344 → 1,565 → 1,069 → 1,034불합격(공개 182)
report-open4년warm118 → 137 → 124 → 136 → 284118 → 137 → 124 → 136 → 284불합격(첫 반응 284, 완성 284)
calendar-enter1년cold99 → 104 → 101 → 208 → 103201 → 192 → 55 → 1,717 → 47501 → 478 → 355 → 2,015 → 360불합격(첫 반응 103)
calendar-enter1년warm164 → 157 → 51 → 66 → 49164 → 157 → 51 → 66 → 49합격
calendar-enter4년cold104 → 91 → 196 → 113 → 21243 → 64 → 278 → 1,689 → 329339 → 338 → 702 → 1,992 → 816불합격(첫 반응 212, 공개 329)
calendar-enter4년warm133 → 100 → 97 → 47 → 51133 → 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년, "마지막 데이터 → 공개" 창:

빌드창 wallCPU 합 = 앱 + 러너 관측기앱 몫 상위창에 든 미리 받기 처리
v0-19-2-sm186ms185 = 121 + 63(program: 스타일·레이아웃·페인트) 84 · react-core 14 · app:service 5없음
p3b (①~④, 입력 700ms 창)411ms409 = 332 + 72app:service 78 · native:encode 29 · exerciseCatalogStore.put 26 · screenRpcAdapters 문자열 검사 25카탈로그 검증·저장 약 150ms
p3c (②=요청 끝난 뒤)289ms286 = 196 + 106(program) 125 · react-core 23없음 — 대신 설계 요청이 늦어짐(카탈로그 응답이 개요 투영·렌더 뒤에 끼어듦)
p3d (③=배경 응답 미룸)241ms240 = 150 + 87(program) 102 · react-core 15 · GC 13없음(표본 1건에서 입력 직후 시작한 카탈로그가 유저 요청으로 오판돼 끼어듦)
p3e (⑥=출처 표시·승격)263ms262 = 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_catalog736KB5947719
get_volume_overview(1년)497KB2024412
get_profile_feed75KB297113
get_pr_overview57KB41382
get_home_dashboard26KB28591
get_calendar_month_summary32KB13181
작은 응답 4종(1~2KB)1~20

(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~122Phase 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·6feature 컨텍스트 24개(값 15 + 루트 입력 9)가 값 대신 스토어 손잡이 를 내린다app/featureView.tscreateFeatureContext(손잡이 컨텍스트 + 키 목록/선택자 훅) · useFeatureViewStore(제공자가 커밋 뒤 publish, 내용 같은 키는 이전 참조·함수 키는 대리 함수) · useStableProviderValue 삭제. 모바일 소비자 116곳 키 목록 구독 전환, 리포트 조작·일지 주 선택 프로바이더도 같은 방식48834825
7바인딩 27개가 화면에 넘기는 prop 을 렌더마다 새로 만들지 않는다명령 묶음 useStableCommands 1회 생성, 파생 객체·목록 useMemo, 빈 값 모듈 상수 — 인라인 콜백 prop 101 → 0, 인라인 객체 prop 8 → 0. 그룹 판은 화면 종류별 컴포넌트 7개(목록·라운지·보드·완료 세션·작성·초대·생성)로 나눠 각자 자기 조각(선택자)만 구독bed1eaa8
8화면·공용 컴포넌트 65개 React.memoui/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, 운동 진입 취소 멱등, 워드마크 알림 컨텍스트 값 안정화, 리포트 화면 재시도 콜백 참조 고정, 주 미선택 합계 null4-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.quietWindow113 → 1 → 1pane:home, shell:drawer, shell:tabs
tab.switch115 → 0 → 0재방문: shell:tabs 만 · 첫 방문: + 들어가는 판 mount
drawer.pick4(tab.switch) → 0pane:volume, shell:drawer, shell:tabs
calendar.selectDay13 → 0 → 0pane:calendar
session.open14 → 0 → 0pane:calendar, stack:session, stack:journalDaystack:journalDay❌ 1
session.close11 → 0 → 0같음stack:journalDay❌ 1
stack.pushPop22 → 0 → 0하루 상세 pop: pane:calendar, stack:journalDay · 종목 상세 pop: pane:home, pane:volume, stack:prDetailpane:volume(종목 상세 pop)❌ 1(Phase 6)
calendar.changeMonth46 → 0 → 0pane:calendar
pr.open114 → 0 → 0pane:home, pane:volume, stack:prDetailpane:volume❌ 1(Phase 6)
toast24 → 1 → 0없음
connectivity.offline11 → 1 → 0없음
connectivity.online18 → 1 → 2(허용 2)재적재된 판 전부(허용 *)
workout.start12 → 0 → 0셸 층 + workout(mount) + 방문한 판 전부pane:calendar, pane:feed, pane:volume❌ 3(Phase 6)
workout.setInput80 → 0 → 0workout
workout.restTick10 → 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 운동 기록편집기 포트에 구독 손잡이 — 화면은 자기 조각만 구독MobileWorkoutEditorPortsubscribe/getSnapshot(참조 불변) + 이벤트 조회용 view getter, 바인딩(MobileEditorFlow·MobileGroupComposerBinding)은 포트를 스토어당 1회 생성. 화면은 ui/shared/useEditorView(editor, 선택자, 비교) — 흐름 셸 = 제목·날짜·종목 수·오류(selectFlowDraft), 기록 화면 = 종목 목록·활성 종목·추가 중인 세트(selectRecordView), 보드 작성 헤더 = 제목·종목 수, 완료 단계 = 초안 전체(WfFinishStep, 그 단계에서만). 종전엔 바인딩이 키 입력마다 {...actions, view} 새 객체를 만들어 흐름 셸부터 통째로 깨웠다e5f46069
5-1세트 행 memo 컴포넌트 + 참조 고정 명령 묶음 + key = 세트 idWorkoutRecordRows.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-31RM 직접 입력 폼 상태 위치(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.tsworkout.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.quietWindow11pane:home, shell:drawer, shell:tabs
tab.switch110pane:feed, pane:volume, shell:tabs
drawer.pick40pane:volume, shell:drawer, shell:tabs
calendar.selectDay10pane:calendar
session.open10pane:calendar, stack:session
session.close10pane:calendar, stack:session
stack.pushPop20pane:calendar, pane:home, pane:volume, stack:journalDay, stack:prDetailpane:volume❌ 1(Phase 6)
calendar.changeMonth40pane:calendar
pr.open10pane:home, pane:volume, stack:prDetailpane:volume❌ 1(Phase 6)
toast20없음
connectivity.offline10없음
connectivity.online12pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs
workout.start10pane:calendar, pane:feed, pane:home, pane:volume, shell:drawer, shell:tabs, shell:workoutNav, workoutpane:calendar, pane:feed, pane:volume❌ 3(Phase 6)
workout.setInput80workout
workout.restTick10workout

합계: 표본 40 · 위반 3(입력 종류 3, 전부 "숨은 판이 데이터 갱신을 받는다" — Phase 6) · 루트 재실행 초과 0. Phase 4(p4-run7) 위반 5 → 3: session.open·session.closestack: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.quietWindow11pane:home, shell:drawer, shell:tabs
tab.switch110pane:feed, pane:volume, shell:tabs
drawer.pick40pane:volume, shell:drawer, shell:tabs
calendar.selectDay10pane:calendar
session.open10pane:calendar, stack:session
session.close10pane:calendar, stack:session
stack.pushPop20pane:calendar, pane:home, stack:journalDay, stack:prDetail
calendar.changeMonth40pane:calendar
pr.open10pane:home, stack:prDetail
toast20
connectivity.offline10
connectivity.online12pane:home, shell:drawer, shell:tabs
workout.start10pane:home, shell:drawer, shell:tabs, shell:workoutNav, workout
workout.setInput80workout
workout.restTick10workout

스타일 재계산 비용(CDP Performance.getMetrics 차, CPU 4배) — 6-4 전(p6-run2) → 후(p6-run3):

표본스타일 재계산 전 → 후레이아웃 전 → 후커밋 수(후)
pr.open 홈 PR 보드 행 누름(종목 상세 push)230회/536.67ms → 18회/75.4ms3회/52.95ms → 3회/33.71ms3
stack.pushPop 종목 상세 뒤로(pop)32회/61.38ms → 18회/42.55ms1회/0.82ms → 1회/0.8ms3
workout.setInput 세트 입력 1 (무게=60)20회/15.18ms → 23회/4.87ms2회/3.32ms → 2회/1.81ms1
workout.setInput 세트 입력 2 (횟수=6)26회/19.47ms → 25회/5.68ms3회/11.34ms → 3회/4.66ms1
workout.setInput 세트 완료 체크(휴식 시작 + RPE 카드)15회/29.61ms → 15회/9ms3회/42.82ms → 3회/15.42ms3
tab.switch 재방문: home → feed5회/39.12ms → 5회/13.08ms1회/35.54ms → 1회/13.94ms4
drawer.pick 재방문: home → volume (드로어)25회/193.05ms → 30회/28.34ms3회/58.44ms → 3회/17.83ms5

9-3. 판·DOM 예산

시점일지피드리포트방문 판 합계문서 전체예산(홈 112·판 901, 여유 10%)
boot(home only)112208255
4 panes visited11218669890119962045
after report revisit11218669890119962046
  • 축 결정: 예산은 판 하나다. 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 로, 강조는 ::afteropacity 로, 뛰는 요소 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 묶음 120/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 R3ui/mobile/lib/DayOverlay.tsxReact.memo(UiDayOverlay) 를 벗김렌더 규칙 위반 1건 — R3 … React.memo(…) 로 감싼다, 종료 코드 1
팬아웃 러너 --gate불변식 표 calendar.selectDay 허용 집합을 []허용 밖 [pane:calendar] 1건, 렌더 불변식 위반 1건 (표본 40), 종료 코드 1(p7-neg2)
판 DOM 예산코드 변형 없음 — 러너 --gate 가 같은 종료 경로(domFailures)를 쓴다; 예산 상수는 appPerformanceBudgets.browser6-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.sqlperf_input_response 적재 케이스 1건(plan 19 → 20, perf_tab_switch 케이스와 같은 모양)을 더한 사본을 샌드박스에서 임시로 돌려 20/20 통과를 확인한 뒤 원복했고, 갱신은 10-4 목록에 넣었다. 실기기 프로브(inputResponseProbe.ts)는 느린 표본만 보내므로 4배 에뮬레이션 러너 창에서는 0건이 정상(p6-run3 record_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)가 조립 루트 경로에 더한 useState 2개(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 로 재생성, 마이그레이션 2026091514000020260915220100).

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 대로면 프로필 제공자 아래 값 스토어후속