Skip to content

모바일 렌더링 구조 근본 점검 — 상태는 스토어에, 화면은 자기 조각만, 숨은 판은 조용히 (2026-09-15)

  • 기간: 2026-09-15 (세션 3 — 조사·7-Phase 계획·Phase 1~4 는 b5378327-8326-48fa-b21a-add49cf73237 이전 세션들, Phase 5~7 은 39789c0a-2d71-41c4-b2c0-8bf521e283d8). 오너 지시 원문: "렌더링(모바일)이 디자인 원칙대로 근본적으로 효율·튼튼·확장·모듈러하게 설계돼 있는지 점검" → "Phase 1부터 진행해줘. 나한테 물어보지 말고 phase 끝까지 완주해".
  • 랜딩: 앱 feat/1610-render-invariants(base release/v0.19.2) — Phase 1 235690c0 · Phase 2 d4588a1e~96c0ab82(9) · Phase 3 cddd0679~da30b098(7) · Phase 4 48834825·bed1eaa8·f4d96513 · Phase 5 e5f46069·56d17a5a·a0ba4126·245a7725 · Phase 6 f50ca049·c783bedd·10123a2c·47733ca3·ceea8e6c · Phase 7 754d0532·d7ef4a0e(release/v0.19.3 병합) → PR #1657 → release/v0.19.3 병합 커밋 ad0457ef(2026-09-15 19:36 KST, 큐 실행 34958710441). 마이그레이션 1건 20260915140000_perf_input_response_kind(텔레메트리 kind 추가). Vercel 배포 없음(앱 릴리스 경로).
  • 설계서: 이슈 #1610 본문(판정 1·2절, 7-Phase 계획, "예상 효과·개선사항" 표, 완료 조건 6개) + 범위 판정 댓글(오너 결정 D1 (가): #1603 흡수).
  • 정본: 렌더 불변식 계약(규칙 1~16 · 불변식 표 · 판정 규칙 · 새 화면 체크리스트) · 앱 src/react/contracts/render/renderInvariants.ts(불변식 표 정본) · app/featureView.ts(컨텍스트 = 스토어 손잡이) · ui/shared/paneGate.ts(판 구독 게이트) · ui/shared/useEditorView.ts · services/derivedIdentity.ts(reuseUnchangedDeep) · services/appPerformanceBudgets.ts(domNodesHomeFanout·domNodesPaneMaxFanout).
  • 도구: 앱 scripts/render/render-fanout.mjs(팬아웃 러너·게이트 — React Profiler 경계, 렌더된 컴포넌트 진단, CDP 스타일·레이아웃 비용, 판별 DOM 수) · scripts/render/render-ledger.mjs(장부 A·B 생성기) · scripts/check-render-rules.mjs(정적 게이트 R1~R6). 작업 트리 test-results/phase1610/(gitignored) — 실측 원본 json/md·로그·러너 체인 run-fanout-p5.sh·판정 체인 judge-p7.sh, test-results/phase1610j/(#1592 판정 러너 사본 + 1·4년 fixture).
  • 게이트: npm run check:render(check:static 편입) · 팬아웃 러너 --gate(위반·루트 재실행 초과·판 DOM 예산 초과면 종료 코드 1) · pgTAP client_error_events.test.sqlperf_input_response 적재 케이스는 QA1 갱신 목록(장부 §10-4; 사본을 샌드박스에서 돌려 20/20 확인) · 로컬 검증 check:static 통과(병합 트리) · ci:precheck-local 오너 지시로 미실행 · QA2 오너 지시로 미실행(승격 ① Full CI 의 QA2 lane 이 대신).
  • 버그리포트: 없음(오너 보고 결함이 아니라 구조 점검 트랙).
  • 계약: 렌더 불변식 계약 신설(규칙 1~16·§6 체크리스트), 셸 프레임 계약 §2 keep-alive 에 판 구독 게이트, 자원 통합 계약 §12 응답 처리 층, 입력·공개 계약 검증 조건에 렌더 불변식 게이트 문단, 그리기 비용 계약 검증에 소스 핀 → 전수 규칙 대체. 판정·수치 원본은 렌더 장부.

Phase 현황

Phase내용상태
Phase 1불변식 표(입력 21종)·장부 A(표면 70)·장부 B(루트 갱신원 60)·팬아웃 러너·입력 응답 텔레메트리 kind, 구 #1603 계측 나머지(세 빌드 같은 날 판정)✅ 앱 235690c0 · 문서 56720aa·4226046
Phase 2상태 모델 한 벌 — 셸·홈/달력·리포트/PR/검색·피드/소셜/그룹/프로필·운동/세션/계획·마무리(gymData 슬롯·dataTick·이중 저장 삭제): 루트 재실행 12~14 → 0~1✅ 앱 9커밋(d4588a1e~96c0ab82) · 문서 b3cffba
Phase 3응답 처리 층(구 #1603) — 채택 알림은 그 키 구독자만, 큰 미리 받기는 조용한 창에서, 응답 처리는 입력에 양보, 요청 출처 판정✅ 앱 7커밋(cddd0679~da30b098) · 문서 b22b9e7·f5481e0
Phase 4바인딩→화면 계약(컨텍스트 = 스토어 손잡이, 명령 묶음 1회, memo 65개) + 정적 게이트 R1~R6 + 러너 진단: 허용 밖 41 → 5✅ 앱 48834825·bed1eaa8·f4d96513 · 문서 1b5c8d3
Phase 5화면 내부 전수 — 세트 행 memo·휴식 시계 분리·구조 공유·가상화·폼 상태 위치·합성기 애니메이션: 세트 타자 재렌더 = 행 1 + 요약 1✅ 앱 e5f46069·56d17a5a·a0ba4126·245a7725 · 문서 aebba12
Phase 6판 수명·CSS/DOM — 숨은 판 구독 게이트, 워드마크 알림 손잡이, 판 DOM 예산 축, CSS 구조 결정(합친 트리 유지 + 애니메이션 합성기 전용): 허용 밖 3 → 0✅ 앱 f50ca049·c783bedd·10123a2c·47733ca3·ceea8e6c · 문서 835bb7d
Phase 7전수 판정(20표본 × 2묶음 × 1·4년, 조용한 기계)·게이트 실패 확인·텔레메트리 pgTAP·계약/체크리스트/작업 기록·QA2·Precheck✅ 판정은 오너 지시로 중단(1.5셀), 나머지 완료 — 장부 §10

1. 배경

바벨릭 모바일은 2026-08-21 상태 재설계, 08-23 렌더 입자성, 08-24 탭 개편, A15, #1592(반응성), #1596(그리기 비용) 여섯 트랙을 거쳤다. 각 트랙은 그때 잰 가장 큰 비용을 고쳤지만 "어떤 상태 변화에 누가 다시 그려져도 되는가"라는 규칙과 그것을 전 화면에 강제하는 검사는 만든 적이 없었다. 오너가 2026-09-15 "렌더링이 디자인 원칙대로 근본적으로 설계돼 있는지 점검"을 지시했고, 조사 결과(이슈 1·2절) 최근 개편이 닿은 층(화면 스택·하단 크롬·오너 범위 자원·운동 편집기·코드 분할)은 원칙대로 서 있었으나 그 사이(조립 루트 ↔ 화면)가 비어 있었다.

2. 문제 제기

세션 카드 하나를 누르면 앱 전체가 다시 계산됐다

유저 A 가 일지에서 어제 세션 카드를 누르면 조립 루트(useBarbelicController, 1,614줄)가 4번 다시 실행되고, 보이지도 않는 홈·피드·리포트 판과 드로어·탭 셸·하루 상세까지 8개 경계가 다시 그려졌다(장부 §3 기준선: session.open 루트 4·경계 8, pr.open 루트 14). 원인은 상태가 루트의 useState 68개에 살고, 컨텍스트가 값을 통째로 내려 소비자 17파일을 한 번에 깨우는 구조였다.

세트를 적을 때 운동 화면 전체가 다시 그려졌다

세트 무게 한 글자에 운동 기록 화면(1,181줄)이 통째로 재렌더됐고 휴식 타이머 500ms tick 마다도 같았다. 화면이 편집기 뷰 객체 전체를 prop 으로 받고, 세트 행이 memo 가 아니며, 키 입력마다 초안 전체를 정규화했기 때문이다.

방문했던 탭이 늘수록 입력이 비싸졌다

keep-alive 판은 CSS 로만 숨을 뿐 React 갱신을 그대로 받았다. 운동을 시작하면 숨은 일지·피드·리포트 판 3개가 다시 그려졌고(workout.start 허용 밖 3), 종목 상세를 열면 숨은 리포트 판 40개 컴포넌트가 깨어났다.

규칙도, 전수 검사도 없었다

렌더 횟수를 판정하는 자동 게이트가 0개였고(커밋 카운터는 숫자만 출력), 있는 검사는 화면 1개짜리 소스 핀(renderStructureGate.test.mjs)뿐이라 다른 화면에서 같은 문제가 되풀이됐다.

3. 해결 방안

원칙 (오너 결정)

  • 2026-09-15 D1 (가): #1603(응답 처리 나머지)을 이 트랙에 흡수, 완료 조건 = "불변식이 전 화면·전 입력에서 성립 + 상태 모델 한 벌 + 전수 게이트". 오너: "Phase 1부터 진행해줘. 나한테 물어보지 말고 phase 끝까지 완주해".
  • 오너 지시: 앱 안 tests/(QA1 준비본)는 이 트랙에서 손대지 않는다(필요한 갱신은 목록으로 남긴다). 디자인 불변(마크업·클래스 그대로).

접근

대안내용판정
발견 목록 소진(종전 방식)러너가 잡은 큰 비용부터 고친다기각 — 여섯 트랙이 이 방식이었고 같은 종류가 되풀이됐다
규칙 하나 + 불변식 표 + 전수 장부 + 전수 게이트"상태는 오너 범위 스토어·자원에 살고, 화면 조각은 자기 조각만 구독한다. 루트는 계정·인증·연결·조립만" 을 입력 21종 × 허용 경계 표로 고정하고, 장부 A(표면 70)·B(루트 갱신원 60)를 100% 처리하며, 정적 게이트 R1~R6 + 팬아웃 러너 --gate + 실기기 텔레메트리로 잠근다채택
숨은 판 LRU 언마운트판을 내리고 스크롤·포커스를 스토어에 저장·복원기각(Phase 6) — 구독 게이트가 keep-alive 계약을 바꾸지 않고 같은 효과(숨은 판 재렌더 0)
판마다 .screen CSS 분리 + contain루트 :has() 47개의 대상 축소기각(Phase 6 실측) — 판 전환 스타일 재계산은 판 수와 무관하게 평평(10~12회·25ms@4x). 비용은 메인 스레드 애니메이션이었고 contain 은 판 밖으로 나가는 배지·오버레이를 자른다
응답 검증·투영 워커fetch 는 메인, parse→검증→투영은 워커보류(Phase 3) — 남은 어댑터 몫이 워커 왕복 비용보다 작아 수치로 기록만

4. 적용한 내용

Phase 1 — 기준선·불변식·장부·게이트 (앱 235690c0)

불변식 표(renderInvariants.ts, 입력 21종), 장부 생성기, 팬아웃 러너(React Profiler 경계 root·shell:*·pane:<탭>·stack:<kind>·workout), 입력 응답 텔레메트리 perf_input_response(마이그레이션 1건). 구 #1603 계측 나머지 — 세 빌드(#1596 이전·#1596 결과·v0.19.2)를 같은 날 같은 PC 에서 판정(장부 §4). 10년 fixture 는 샌드박스 통계 계산 60초 예산 초과로 "측정된 한계".

Phase 2 — 상태 모델 한 벌 (앱 9커밋)

루트 useState 68 → 앱셸 상태 11(인증·데이터 상태·계정·프로필 관문·오류 문구 등). 일지 날짜·표시 달·리포트 조작·종목 선택·검색 상태를 오너 범위 스토어로, 읽기 모델·자원 구독을 루트 → 바인딩으로, gymData 슬롯 22 → 0, dataTick 폐지, 카탈로그 이중 저장 삭제. 결과: 루트 재실행 pr.open 14 → 0, session.open 4 → 0(장부 §5).

Phase 3 — 응답 처리 층 (앱 7커밋)

자원 채택은 그 키 구독자만 깨운다. 큰 미리 받기(개요 604KB·카탈로그 814KB)는 유저의 요청 사슬이 끝나고 입력이 없는 창에서 시작. 응답 검증·투영은 입력에 양보하고 배경 응답은 유저의 요청이 끝날 때까지 미룬다. 요청 출처는 호출자 표시로만(표시 없음 = 유저의 요청). 겹침 사례 입력 지연 0 충족(장부 §6).

Phase 4 — 바인딩→화면 계약 + 전수 게이트 (앱 48834825·bed1eaa8·f4d96513)

feature 컨텍스트 24개가 값 대신 스토어 손잡이를 내리고(createFeatureContext·useFeatureViewStore), 소비자 116곳이 키 목록/선택자로 구독. 바인딩 27개 명령 묶음 1회 생성(인라인 콜백 prop 101 → 0), 화면·공용 65개 React.memo, 탭 판 = 바인딩 하나 + <MobileStackEntries />. 정적 게이트 check:render R1~R6 를 check:static 에 편입. 러너에 "실제로 렌더된 컴포넌트 이름·스토어가 바꾼 키" 진단을 붙여 조각 구독만으로 안 잡히던 원인 9건을 찾았다(장부 §7-3).

Phase 5 — 화면 내부 전수 (앱 4커밋)

운동 편집기 포트에 subscribe/getSnapshot 손잡이 + useEditorView 조각 구독, 세트 행 memo(WorkoutRecordRows.tsx) + useRowCommands, WfRestClock 자체 tick, 뷰 구조 공유(reuseUnchangedDeep), 내보내기 상태당 1회. 일 상세·달력·잔디·리포트 월 카드 memo, 종목 상세 목록 가상화(ViewportList)·데이터 key, PR 도구 폼 상태를 모달로. 스택 항목 호스트의 항목 요소 캐시(WeakMap). 결과: 세트 타자 = WfRecord + WfSetEditRow, 휴식 tick = WfRestClock 1개(장부 §8).

Phase 6 — 판 수명·CSS/DOM (앱 5커밋)

판 구독 게이트(paneGate.ts): 숨은 동안 알림을 밀려 두고 판이 보일 때 한 번에 따라잡는다 — feature 훅·자원 구독·직접 구독 9곳·탭 스택·워드마크 알림 편입. 워드마크 알림 컨텍스트는 손잡이 + 데이터 memo. DOM 예산 축 = 판 하나(홈 112·판 최대 901, 여유 10%), 러너가 세 시점 × 판별 판정, 실기기 프로브에 활성 판 토큰 a{n}. CSS 구조는 실측 뒤 합친 트리 유지, 뼈대·강조 애니메이션을 합성기 전용으로(종목 상세 열기 스타일 재계산 230회/537ms → 18회/75ms@4x). 허용 밖 3 → 0(장부 §9).

Phase 7 — 전수 판정·규칙 정착 (앱 release/v0.19.3 병합 커밋(§4 Phase 7))

게이트 실패 확인(정적 R3·팬아웃 --gate 둘 다 종료 코드 1, 장부 §10-2), 텔레메트리 kind 적재 확인(샌드박스 사본 20/20, QA1 목록 §10-4), 계약 문서 개정(렌더 불변식 §4·§6 체크리스트, 입력·공개 계약 게이트 문단, 그리기 비용 계약 소스 핀 대체), 작업 기록. 전수 판정은 1·4년 fixture 적재 → 최종 커밋 production 빌드 → 조용한 기계 판정으로 시작했으나 1.5셀에서 오너 지시로 중단(장부 §10-1). 병합 준비: release/v0.19.3(70커밋 앞섬 — 디자인 세션·동의 구조·#1649 dispose 수리)을 합쳐 충돌 8파일 해소, 마이그레이션 번호를 꼬리 뒤(20260915220100)로, check:static 통과.

주요 결정과 그 근거

  • 컨텍스트에는 값이 아니라 손잡이: React 컨텍스트 전파는 값이 바뀌면 소비자 전부를 깨운다. 손잡이(참조 불변)를 싣고 소비자가 키 목록으로 구독하면 바뀐 키를 읽는 곳만 깨어난다 — 규칙 5·6·16.
  • 숨은 판은 언마운트가 아니라 구독 게이트: 언마운트는 스크롤·포커스·시트 상태를 스토어로 옮긴 뒤에만 가능했고 keep-alive 계약을 바꾼다. 게이트는 마운트를 유지한 채 갱신만 미룬다 — 탭 전환 루트 커밋 0 유지.
  • 판 DOM 예산의 축은 판 하나: 방문 판 전체를 예산으로 두면 판을 더 방문했다는 이유로 실패한다 — keep-alive 를 벌하는 꼴. 판 하나 예산 + 전체 = 판 수 × 판 예산.
  • CSS 구조는 실측 뒤 결정: 계획은 "판마다 .screen vs 합친 트리 + contain" 두 안이었으나 러너에 붙인 CDP 스타일·레이아웃 비용이 "판 구조가 아니라 애니메이션" 을 가리켰다.
  • QA1 은 손대지 않는다(오너 지시): 편집기 포트 계약 변화로 맞지 않게 된 QA1 fixture·핀 테스트는 목록으로 남긴다(장부 §10-4).

작업 중 드러난 것

  • 진단 없이는 원인이 보이지 않았다: 경계 id 만으로는 "판 안의 누가 깨어났는가" 가 안 보여 러너에 렌더된 컴포넌트 이름·스토어 publish 키 진단을 붙였고(Phase 4), 그 뒤 원인 9건(멱등하지 않은 닫기, 판 안의 탭 구독, 셸 요소 재생성, 컨텍스트 값 재생성 등)이 잡혔다.
  • 같은 증상에 원인이 둘인 경우가 반복됐다: 세션 열기의 하루 상세 재렌더(부모 memo 는 조용한데 스택 호스트가 Profiler 요소를 새로 만듦), 운동 시작의 숨은 판(게이트 뒤에도 워드마크 알림 컨텍스트가 값을 새로 만듦). "게이트 뒤 재실측" 을 매번 했기에 잡혔다.
  • 판정 표본은 같은 PC 의 release 큐 러너 작업과 겹치면 2~3배 흔들린다(Phase 3 p3f). 그래서 Phase 7 판정은 "CPU 부하 15% 미만 연속 3회 + 진행 중 Actions 없음" 일 때만 셀을 시작하는 judge-quiet.sh 로 돌렸다.
  • 러너 체인 스크립트가 echo "[$(date)] exit $?" 로 종료 코드를 적어 date 의 0 을 기록하고 있었다 — Phase 7 게이트 실패 확인 때 발견, rc=$? 로 고쳤다. 게이트 자체는 처음부터 종료 코드 1 을 냈다.
  • 컨텍스트 스토어의 함수 키는 참조가 고정되므로, 렌더 중에 읽히는 데이터를 닫아 둔 함수(hydrateSessions)는 부르는 쪽이 같은 데이터를 직접 구독해야 최신값이 보인다(계약 §2-1 "대리 함수의 한계").
  • 10년 fixture 는 샌드박스 통계 계산이 60초 예산을 넘겨 수렴하지 않는다 — 통계 담당에 관찰로 전달, 이 트랙은 통계 예산을 바꾸지 않았다.
  • release/v0.19.3 과의 병합에서 #1649(React effect 정리 함수의 영구 dispose() 제거)가 내 Phase 2 코드와 같은 줄을 만졌다 — 내 쪽 구조(제공자 구독)를 두고 dispose 정리만 걷어냈고, QA1 storeLifetimeContract 검사가 병합 트리에서 통과했다.

5. 적용 결과

항목전(기준선, 장부 §3·§4)근거
입력 1건당 조립 루트 재실행(최대)pr.open 14 · response.volumeOverview 12 · connectivity 8 · calendar.changeMonth 6 · session.open 4전부 0(온라인 복귀 재적재만 허용 2)팬아웃 러너 p6-run3 40표본
허용 밖 재렌더 경계입력 종류 11/12 위반(합 41)0같은 러너, --gate 통과
세트 타자 1회 재렌더운동 화면 전체(1,181줄)WfRecord + WfSetEditRow 행 1러너 렌더된 컴포넌트 진단
휴식 tick 재렌더화면 전체 500ms 마다WfRestClock 1같음
숨은 판 재렌더(운동 시작·종목 상세)3 · 10 · 0같음
루트 갱신원(장부 B)6813(앱셸 상태 11 + release/v0.19.3 병합으로 들어온 동의 관문 2, R5 목록)render-ledger.mjs
값 전체 구독 훅 · 인라인 콜백 prop(바인딩)15 컨텍스트 값 · 1010 · 0장부 A
종목 상세 열기 스타일 재계산(4배 CPU)230회/537ms18회/75ms러너 CDP 열
판 DOM(홈/일지/피드/리포트)예산 없음112/186/698/901, 예산 판정 통과러너 DOM 예산
렌더 판정 게이트0(소스 핀 1)정적 R1~R6 + 팬아웃 --gate + 판 예산 + 텔레메트리 kindcheck:static, 러너, 마이그레이션
합격선 판정(20표본 × 2묶음, 1·4년)Phase 3 §6: 겹침 입력 지연 0 충족, cold 첫 반응 91~113 경계최종 커밋 재판정 미실행(오너 지시)장부 §10-1

미검증·한계: 실기기 텔레메트리(perf_input_response)의 Production 수집은 릴리스 뒤 7일 관찰(별도). 10년 fixture 는 측정된 한계. QA1 갱신 목록(장부 §10-4)은 QA1 담당 몫이라 이 트랙에서 실행하지 않았다. 시각적 품질(애니메이션 합성기 전환의 눈에 보이는 결과)은 마크업·클래스 불변으로 보장했고 눈으로 본 판정은 없다.

6. 이번 개선으로 향상된 것

세션 카드를 눌러도, 운동을 시작해도 보이는 것만 다시 그려진다

유저 A 가 일지에서 세션 카드를 누르면 세션 상세 하나만 열린다 — 조립 루트도, 숨은 홈·피드·리포트 판도 조용하다. 세트를 적으면 그 행과 요약만, 휴식 타이머는 숫자 하나만 바뀐다. 오래 쓰며 탭을 다 방문한 사용자도 입력 비용이 늘지 않는다.

구조적으로 남는 것

  • 규칙 하나와 불변식 표(입력 21종 × 허용 경계) — 새 입력 종류·새 화면은 표에 먼저 등록해야 러너를 통과한다.
  • 장부 A·B(다음 트랙이 같은 표를 채운다)와 생성기.
  • 전수 게이트 3겹: 정적 R1~R6(check:static), 팬아웃 러너 --gate(+ 판 DOM 예산), 실기기 텔레메트리 kind.
  • 새 화면·바인딩 체크리스트 12항목(계약 §6)과 판정 절차(체인 스크립트·조용한 기계 판정).
  • 이 판정 수치는 4배 CPU 에뮬레이션·특정 PC 의 값이다. 릴리스 뒤에는 텔레메트리(perf_tab_switch·perf_input_response)로 같은 축을 본다.

남은 것

항목상태어디서
QA1 갱신(편집기 fixture 손잡이, 훅 러너의 useSyncExternalStore 모의, renderStructureGate 핀 3개 대체)목록 = 장부 §10-4QA1 담당
Production 텔레메트리 7일 관찰(perf_input_response 수집·a{n} 토큰)릴리스 뒤릴리스 담당
데스크톱 바인딩·desktopApp 의 값 전체 구독계약 대상 밖(모바일), 게이트 R1 도 데스크톱 제외데스크톱 확장 트랙
셸 컴포넌트(MobileTabFrame·BottomNav·NavDrawer) memoR3 대상(screens·lib) 밖후속
리포트 판 901 노드회귀 방지선으로만 등재별도 이슈 필요 시
10년 fixture 판정측정된 한계(통계 60초 예산)통계 담당
전수 판정·QA2오너 지시로 미실행 — 체인(judge-p7.sh)·fixture·빌드 재사용 가능필요 시 후속