렌더 입자성 캠페인 — 세트 하나 입력에 앱 전체가 다시 실행되던 구조에서 오너 스토어 세트·화면 바인딩까지 (2026-08-24)
- 기간: 2026-08-23 ~ 2026-08-24 (세션 1개 — 오너 "렌더 입자성 캠페인 관련해서 팔로업해줘" → 전체 그림(a10c4ec1) 코드 검토 → 작업계획서 → D1 (가) → 실측 보고 → D2 (나) → 결제 차단 해소 후 재트리거·순서 머지 → D4)
- 랜딩: PR #604(Phase 0,
cb7a4d47) · #609(Phase 1,872915d2) · #614(Phase 2,336e9b3a) · #617(Phase 3,233596a0) · #622(Phase 4-1,8037fac0— #618이 base 브랜치 삭제로 자동 닫혀 main 리베이스 후 재오픈) · #619(Phase 4-3,10eccf13). 마이그레이션·엣지 0건, 웹은 Vercel 자동 배포 - 설계서: 작업계획서 artifact
2bf2105f("예상 효과·개선사항" 절 포함, v4 종결 현황) — 선행 전체 그림 artifacta10c4ec1(08-21) - 정본: 오너 스토어 세트
src/react/controllers/ownerStores.ts(createOwnerStores·leaveOwnerStores·adoptOwnerStores), 컨텍스트src/react/controllers/ownerStoresContext.ts(useOwnerStores), 바인딩 템플릿src/react/features/active-workout/WorkoutFlowBinding.tsx·src/react/features/toast/ToastBinding.tsx, 드래프트 통지 계약tests/react/workoutDraftStoreGranularity.test.mjs, 핫 키 규칙(아래 6절) - 도구: dev 전용 커밋 계측기
src/react/vite/main.tsx(?lgProfile=1→window.__LG_PROFILE__) ·src/react/app.tsx(조립 루트 커밋 카운터__LG_PROFILE_CONTROLLER_COMMITS__, 스토어 손잡이__LG_PROFILE_STORES__) · 시나리오 스크립트e2e/profile/renderGranularity.profile.mjs(레포 안,npm test패턴 밖, 로컬 스택 + vite dev 서버 필요, 산출test-results/*.json은 gitignored) - 게이트:
workoutDraftStoreGranularity.test.mjs(7) ·ownerStores.test.mjs(4) · 재조준 계약 9파일(controllerBoundaries· backgroundTokens·homeBootLoadingOrder·appShellSnapshotIntegrationContract·logTableMonthCache·mobileCssBoundaries· workoutDraftStore·workoutWriteCommands·eslint-baseline). 전부 verify 레인 - 버그리포트: 없음 — Phase 1에서 잠복 결함 1건 복원(#338에서 빠진 내비 미러
flow키, 당시 영향 없음) - 계약: 없음(ui/** 무접촉 — D4로 허용됐으나 필요하지 않았다)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 계측 기반선 — 통지 기반선 테스트 · dev Profiler · 3 시나리오 스크립트 | ✅ PR #604 (cb7a4d47) |
| Phase 1 | 핫패스 격리 — 드래프트 스토어 emit 위생 · 스냅샷 비반응화 · 이벤트 시점 소비자 ref 전환 | ✅ PR #609 (872915d2) |
| Phase 2 | 오너 스토어 세트 + Provider(구 W5 편입) — 생성 지점 12 → 1, adoptOwner 세트 교체 | ✅ PR #614 (336e9b3a) |
| Phase 3 | 파일럿 바인딩 — WorkoutFlowBinding, renderCtx 11키 은퇴 | ✅ PR #617 (233596a0) |
| Phase 4-1 | 토스트 바인딩 — 루트 구독 완전 분리 | ✅ PR #622 (8037fac0) |
| Phase 4-2 | 달력 — 이관 안 함(중단 규칙) → 별도 트랙 달력 렌더 구조 정리 | ⬜ 판정 |
| Phase 4-3 | 로그테이블 — 루트 구독 분리, 5키 은퇴 | ✅ PR #619 (10eccf13) |
| Phase 5 | 화면 내부 세밀 구독(ui/**) | ⬜ 미진입 — 실측상 대상 없음(D4) |
Phase별 상세 기록: phase0 · phase1 · phase2 · phase3 · phase4-1 · phase4-3.
1. 배경
상태 재설계 로드맵은 (c) 군집 경계(08-21) → (a) 스토어 확산(08-21) → 렌더 입자성이었다. (a)가 끝나며 스토어 10종이 subscribe 표면을 갖췄지만 구독은 조립 루트(useBarbelicController, 약 1,900줄) 한 곳에 몰려 있었다. 루트가 매 렌더 renderCtx(실측 176키)를 새로 조립해 셸에 내려주므로, 어떤 스토어가 바뀌든 루트 전체가 다시 실행되고 활성 화면 트리가 재diff됐다. 08-21 전체 그림 문서는 "측정된 통증은 없다"는 전제로 측정 우선·중단 가능 설계를 제안하고 발주 전 상태로 남아 있었다. 08-23 오너가 팔로업을 지시했다.
2. 문제 제기
라이브 운동의 세트 편집 1회가 조립 루트를 2번 다시 실행했다
세트 편집 표의 입력은 변경마다 드래프트를 갱신하고 스냅샷을 즉시 1회 + 700ms 디바운스 1회 발행한다. 발행 1회의 스토어 통지는 3건 — commitWorkoutSnapshot(항상 새 상태 객체), setWorkoutEditorLocked(잠금이 그대로여도 {...current} 새 객체), setLiveTitle(같은 제목도 통지). React 18 자동 배칭이 한 틱의 3건을 루트 커밋 1회로 합쳐, 실측은 세트 입력당 루트 커밋 2회·커밋 시간 합 77~137ms(개발 React, CPU 4×)였다. 전체 그림의 "통증 없음" 전제가 이 경로에서 틀렸다.
설계 관용구가 "루트는 모든 변화에 재렌더한다"에 기대고 있었다 (stale-env)
(a) 캠페인의 "렌더마다 setEnv 최신 주입(latest-ref)"은 루트가 늘 다시 렌더된다는 전제 위에 서 있었다. 입자성이 그 전제를 지우면 이벤트 시점 소비자가 낡은 값을 읽는다: mobileApp.leaveWorkoutForTab의 ctx.workoutSnapshot(탭 이탈 persist), 쓰기 커맨드 env의 workoutSnapshot(폐기)·liveTitle(저장 제목). 성능이 아니라 정확성 리스크였고 전체 그림의 리스크 표에 없었다.
계정 전환 리셋이 지휘자의 손수 목록이었다
스토어 12개가 어댑터 훅마다 useRef로 따로 태어나고, 계정 전환은 appController의 리셋 문장 20개(스토어 몫 8~10)로 처리됐다. 새 스토어를 더하면 목록에 손수 추가해야 했다.
토스트·로그테이블처럼 루트가 값을 쓰지도 않는 스토어가 루트를 깨웠다
토스트 표시·소멸은 루트 전체를 재실행했고(1회, 31~70ms), 데스크톱 로그테이블 셀 열기는 2회였다. 루트는 두 값을 렌더에 쓰지 않고 셸로 전달만 했다.
3. 해결 방안
원칙 (오너 결정 D1~D4, 2026-08-23~24)
- D1 발주 범위: "(가)" — Phase 0~1 즉시, Phase 2~4는 Phase 0 실측 보고 후 판정. 채택.
- D2 중단 기준선: 실측 표 보고 후 "(나)로 Phase 2~4 진행해줘". 채택 — 달력(4-2)은 중단 규칙으로 이관하지 않음.
- D3 Phase 순서: 핫패스 격리(Phase 1)를 오너 세트(Phase 2)보다 앞에 — D1 (가)로 해소.
- D4 Phase 5 방침: "ui/**는 '실제 눈으로 보이는 디자인이 하나도 바뀌지 않아야 한다'는 전제조건만 지키면 수정해도 된다". 허용은 받았으나 실측상 대상이 없어 미진입.
접근
| 대안 | 판단 |
|---|---|
| 전체 그림 순서(오너 세트 → 실측 → 파일럿) | 핫패스는 구조 증명이 이미 있어 실측을 기다릴 이유가 없다 → Phase 1을 앞으로 |
| 바인딩 신설로 핫패스 해결 | 루트가 게이트(flow·locked) 때문에 드래프트 구독을 유지해야 하므로 바인딩만으로는 커밋이 안 줄어든다 → 스토어 emit 위생 + 스냅샷 비반응화가 정답, 바인딩은 템플릿·키 은퇴 용도 |
| 루트의 드래프트 구독을 셀렉터로 좁히기 | 쓰기 커맨드 env의 드래프트 값 전부를 ref로 바꿔야 해 범위가 커짐 — 실측상 편집당 루트 커밋이 이미 0이라 기각 |
| 달력 바인딩 | 달력 모델은 홈·일 요약·쓰기 env까지 루트 소비자가 넓고 통지 6건이 전부 정당한 전이 → 이관 안 함, 구조 정리는 별도 트랙 |
| Phase 5 화면 내부 구독 | 남은 세트 편집당 커밋 1회는 화면 자체 state 렌더(3~8ms@4×, 16ms 기준 미달) → 미진입 |
4. 적용한 내용
Phase 0 — 계측 기반선 (#604)
tests/react/workoutDraftStoreGranularity.test.mjs가 "콘텐츠 발행 1회당 구독자 통지 수 = 3"을 고정. main.tsx가 ?lgProfile=1일 때만 루트를 React.Profiler로 감싸고(프로덕션 번들 제외), app.tsx가 조립 루트 커밋을 센다. e2e/profile/renderGranularity.profile.mjs가 ① 라이브 세트 입력 ② 달력 월 이동 ③ 로그테이블 셀 열기를 구동해 상호작용 1회당 루트 커밋·커밋 시간을 낸다. 테스트 스위트에 DOM 라이브러리가 없어 자동 계약은 스토어 통지 수(React 없이)로, 커밋 계측은 dev Profiler로 나눴다.
Phase 1 — 핫패스 격리 (#609)
setWorkoutEditorLocked가 잠금 불변이면 같은 객체 반환, 스토어 set()이 동일 값(Object.is) 패치를 통지 생략, workoutSnapshot을 반응 상태에서 빼고 workoutSnapshotRef 단일 저장(어댑터는 렌더 시점 ref 읽기로 키 계약 유지), leaveWorkoutForTab·discardActiveWorkout은 ref를 읽고 write env에서 workoutSnapshot 값 제거. 내비 미러에서 스냅샷 제거 + flow 키 복원.
Phase 2 — 오너 스토어 세트 + Provider (#614)
createOwnerStores({ closeLayerStateOnlyRef })가 스토어 10 + 조율자 2를 한 번에 만들고, adoptOwnerStores = leaveOwnerStores(드래프트 보존이 먼저 → 비행 중 작업 무효화 10종) → 새 세트. 어댑터 12개는 store를 입력으로 받는다. OwnerStoresContext를 app.tsx가 제공(세 상태 분기 모두 Provider > 키 Fragment 동일 모양). 지휘자는 첫 마운트를 제외한 전환에서 세트를 교체하고 자기 소유 React 상태만 리셋, 교체 뒤 revision 재발화로 새 세트에서 드래프트 복원.
Phase 3 — 파일럿 바인딩 (#617)
WorkoutFlowBinding이 useOwnerStores().workoutDraft를 직접 구독해 운동 화면 props를 조립(lazy 화면·계획 초안·스냅샷 정규화·연간 통계 verbatim 이동). mobileApp은 드래프트 밖 입력과 커맨드만 건넨다. renderCtx 11키 은퇴, 루트의 buildWorkoutPlanData 호출 제거(exerciseCatalog만 남김).
Phase 4-1 토스트 (#622) · 4-3 로그테이블 (#619)
토스트: ToastBinding이 단독 구독, 어댑터는 커맨드만, 양쪽 셸 <ToastBinding/>. 로그테이블: 어댑터 구독 제거, 데스크톱 셸이 useOwnerStores().logTable 직접 구독, 5키 은퇴. 달력(4-2)은 통지 시간선 6건(클릭·일 상세 시작·프리페치 시작·발행· 완료·일 상세 발행)이 전부 별개 전이임을 확인하고 이관하지 않았다.
주요 결정과 그 근거
- 스냅샷을 반응 상태에서 빼는 것(uSES 밖 ref)이 안전한 이유: 렌더 시점 소비자는 화면 마운트 props 조립뿐이고 그 렌더는 인텐트 전이(반응 상태)가 선행한다. 이벤트 시점 소비자는 ref로.
- 세트 교체 직후 같은 이펙트에서 새 세트를 쓰지 않는다 — 어댑터
setEnv전이라requireEnv()가 던진다. 복원은 revision 재발화의 비전환 경로에서. - 이전 세트 떠나기에서 세대 bump 리셋을 그대로 돌린다(새 인스턴스로 갈라져도 env 세터로 쓰는 경로가 있을 수 있어 이중 안전망).
작업 중 드러난 것
- 발행당 통지 3건은 React 18 배칭으로 루트 커밋 1회로 합쳐진다 → 구조 예측 상한 6은 실측 2로 수렴.
- 세트 편집 표 입력(
[data-lg-field="workout.set.load"]는 input 자체)은 변경마다 드래프트 발행, 빠른 추가 컴포저는 로컬 state. - GitHub Actions 결제 실패(08-23 저녁, 08-21 재발)로 #617부터 잡 미시작 → verify=pass 없이는 머지 안 함 규칙으로 스택 PR 대기 → 오너 정리 후
gh run rerun. 스택 PR은 base 브랜치를 먼저 지우면 자동 닫힌다(#618) — 다음 PR의gh pr edit --base main이 먼저. - 전후 비교용 부분
git stash push -- 파일→pop에서 인덱스 전체가 삭제로 올라감(작업 트리 무손상,git reset으로 복구). 기준선은git show HEAD:파일로. preview_start는 세션 루트에서 서버를 띄워 워크트리 코드를 보지 않는다 → 워크트리 dev 서버는 Bash 백그라운드.
5. 적용 결과
계측기·개발 React·StrictMode·CPU 4×, 커밋 수는 런 간 결정적.
| 항목 | 결과 |
|---|---|
| ① 세트 입력 1회 루트 커밋 | 2 → 0 (커밋 시간 합 77~137ms → 3~8ms) |
| ① 세트 완료 체크 | 2 → 0 |
| ④ 토스트 표시+소멸 | 1 → 0 (31~70ms → 0.6~1.3ms) |
| ③ 로그테이블 셀 열기 / 탭 진입 | 2 → 0 / 4 → 1 (154~162ms → 53~63ms / 270 → 142ms) |
| ② 달력 미적재 월 이동 | 6 → 6 (이관 안 함 — 별도 트랙에서 커밋당 비용 ≈300 → 200ms) |
| 발행 1회당 스토어 통지 | 3 → 0 (잠금·제목 불변 시) |
| 낡은 값을 읽을 수 있는 이벤트 지점 | 3 → 0 |
| renderCtx 키 | 176 → 159 |
| 스토어·조율자 생성 지점 / 지휘자 리셋 문장 | 12 → 1 / 20 → 13 |
| exhaustive-deps 리뷰 억제 | 11 → 10 |
| 검증 | 전 체인 1771~1810/전부 초록 · 로컬 e2e 8/8 ×3 · 브라우저 회귀 CASE 누적 17건 · 페이지 안 계정 전환·계획 편집 스모크 · CI verify=pass 6건 |
| 미검증 | 실기기(저사양 안드로이드) 체감 — 절대 시간은 개발 React 값이라 상대 비교만 |
6. 이번 개선으로 향상된 것
라이브 운동 중 앱 전체 재실행이 사라졌다
세트를 고칠 때마다 조립 루트와 셸 트리가 두 번 다시 돌던 것이 0이 됐다. 남은 1회는 운동 화면 자체의 state 렌더다.
토스트·로그테이블이 루트를 깨우지 않는다
모든 저장·삭제·오류 피드백에 붙는 토스트가 바인딩 한 컴포넌트 재렌더로 끝난다. 데스크톱 로그테이블도 같은 모양.
구조적으로 남는 것
- 통지 수 계약: "콘텐츠 발행은 구독자를 깨우지 않는다"가 행동 테스트로 잠겼다.
- 핫 키 규칙: env/ctx에 고빈도 값은 값이 아니라 ref·getter로 넘긴다 — (a) 캠페인 setEnv 관용구의 단서 조항.
- 오너 스토어 세트: 스토어가 어디서 태어나고 언제 교체되는지가
ownerStores.ts한 파일. 새 스토어는 세트에 넣으면 교체로 구조적 리셋(떠나기 부수효과만leaveOwnerStores에 추가). - 바인딩 템플릿: 구독 → 조립 → 같은 props(
WorkoutFlowBinding·ToastBinding). 새 화면은 renderCtx에 키를 더하는 대신 바인딩을 더한다. - 계측기: dev Profiler + 시나리오 스크립트. 이후 렌더 변경은 같은 숫자로 비교한다.
- ui/ 수정 허용 조건(D4)**: "눈에 보이는 디자인이 하나도 안 바뀔 것" — 이후 필요한 트랙에서 같은 전제로 재사용.
남은 것
- 루트가 드래프트 스토어 전체를 구독하는 구조는 그대로(게이트 필요). 제목 편집은 루트를 깨운다(홈 이어하기 카드의 원천).
- 달력은 달력 렌더 구조 정리 트랙으로 이어짐(#630).
- 피드·PR은 훅 형태(스토어 아님, (a) 명부 제외)라 대상이 아니었다.