모바일 탭 렌더 개편 — 운동하기 탭 0.5~1초 지연에서 표시 전환만 남기기까지 (2026-08-24)
- 기간: 2026-08-24 (1세션, 오너 보고: "모바일에서 운동하기 탭 눌렀을때 바로 딱 가지는게 아니라 한 0.5~1초정도 지연이 있는데 왜 그런지 확인해줄래?" + "프로필 사진 로딩 안될때도 있음")
- 랜딩: PR #653(Phase 0~3) · PR #657(Phase 4) — 마이그레이션 없음, Vercel 자동 배포
- 설계서: 없음(채팅 계획 — Phase 계획·예상 효과 표·오너 결정 D1~D3, 2026-08-24)
- 정본: 탭·드로어 반응 구독 =
src/react/mobileApp.tsx의MobileTabsRegion·MobileNavDrawerBinding(내비게이션 스토어 직접 구독), 탭 진입 정책 =src/react/controllers/screenPolicyBinding.tsx(정책 바인딩) + 각 컨트롤러의activateFor*(), 달력 read model 활성 = 호출 시점 썽크(activeTabRef), keep-alive 판 CSS =ui/mobile/styles/base/core.css의.tab-keep/.tab-pane - 도구:
e2e/profile/tabSwitch.profile.mjs(탭 전환 계측 — 기존?lgProfile=1리그 재사용, 레포 안) · 샌드박스 스택scratchpad/sbx-tabs(레포 밖, 세션 종료 시 정리) - 게이트:
tests/react/profilePropsStability.test.mjs(참조 안정성 계약 3) ·tests/react/navigationStore.test.mjs드로어 미러 계약 2 추가 · 기존 Playwright 회귀 15케이스가 keep-alive 중복 DOM을 실제로 잡음 - 버그리포트:
bug-report/bug-011-20260824.md(탭 지연) ·bug-report/bug-012-20260824.md(프로필 사진) - 계약: 스냅샷 HOME_MINIMIZED 래칫(avatarUrl 저장 금지)은 불변 유지 — 변경은 오너 결정 대기(아래 남은 것)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 탭 전환 계측 시나리오(첫 방문/재방문/드로어) | ✅ PR #653 (c456f9ab) |
| Phase 1 | profileProps 참조 안정화 + 죽은 홈 피드 투영 게이트 | ✅ PR #653 (fdac4c80) |
| Phase 2 | 탭 전환의 조립 루트 탈출(정책 바인딩·진입 디스패치·셸 직접 구독) | ✅ PR #653 (f7f6b499) |
| Phase 3 | 방문 탭 keep-alive 판(오너 D1: 전 탭) — 전환 = 표시 전환만 | ✅ PR #653 (9b9ed24b) |
| Phase 4 | 프로필 사진 간헐 미표시 수리(무소음 서명 실패 복구 + onError 폴백) | ✅ PR #657 |
1. 배경
오너가 실기기(모바일)에서 운동하기(홈) 탭을 누르면 0.5~1초 늦게 전환된다고 보고했다. 조사 결과 탭 탭 한 번이 "조립 루트(약 1,900줄) 전체 동기 재렌더 + 대상 화면 전체 재마운트"를 하나의 비중단 작업으로 물고 있었고, 홈은 eager라 스켈레톤조차 없어 지연이 통째로 멈춤으로 체감됐다. 함께 보고된 프로필 사진 간헐 미표시는 7일 서명 URL 파이프라인의 무소음 실패였다. 렌더 입자성 캠페인(08-23~24)이 세트 입력·토스트·로그테이블의 루트 커밋을 없앤 상태였고, 탭은 그 캠페인이 진입하지 않은 마지막 큰 표면이었다.
2. 문제 제기
탭 상태가 조립 루트에 살아 탭 전환마다 앱 전체가 다시 렌더됐다
useNavigationController가 스토어 스냅샷 전체를 구독(useSyncExternalStore — 동기·비중단)해, setTab 한 번에 루트 훅 전체 + renderCtx 재조립 + 모든 소비자가 재실행됐다. 계측(개발 React·CPU 4×·24세션): 전환마다 조립 루트 커밋 1, 드로어 개폐까지 1.
profileProps가 매 렌더 새 객체라 무거운 useMemo가 전부 무효화됐다
createProfileController가 렌더 본문에서 매번 lgProfilePropsFromRemote를 새로 계산 → 이 객체를 의존성으로 갖는 sessionDashboardData(부팅 범위 세션 전체 재구축)·mobileHomeSupplement(기록 보드·피드 포스트·게이미피케이션 재계산)가 모든 루트 커밋마다 다시 돌았다. HOME_FEED_UI=false인데 홈 피드 포스트 투영은 계속 계산되고 있었다.
홈은 탭을 떠나면 언마운트돼 복귀마다 전체 재마운트였다
조건부 렌더(tab==="home" && <UiHomeScreen/>)라 가장 무거운 화면(히어로·XP·PR 전광판·주간 그리드)이 복귀마다 DOM부터 다시 만들어졌고, eager라 fallback 골격도 없었다.
프로필 사진: 서명 실패가 무소음, 폴백 배선이 부분적이었다
profileWithSignedAvatar가 서명 실패를 삼켜 그 세션 내내 사진이 비었고(이미지가 없으니 onError 재시도도 발동 불가), 드로어·피드 친구·댓글 아바타는 onError 폴백이 없어 만료 URL이 깨진 이미지로 남았다.
3. 해결 방안
원칙 (오너 결정 D1~D3, 2026-08-24)
- D1: keep-alive 범위 = "방문탭 전부 적용".
- D2: "데스크톱은 나중에 별도 트랙으로" — 이번엔 모바일만(어댑터가 데스크톱에서만 tab 반응 유지).
- D3: "착수 승인. phase 0~3 중단없이 쭈욱" + "마지막 별도 phase로 프로필 사진 안뜨는 문제도 해결".
접근
- 채택: 렌더 입자성 캠페인의 기존 방향(무프레임워크 스토어 + 필요한 계층의 직접 구독) 연장 — 탭 구독을 셸 영역으로 내리고, 진입 정책은 탭을 스스로 구독하는 정책 바인딩으로.
- 기각: React 19 업그레이드+
<Activity>(별도 대형 트랙 — React 18.3이라 불가, keep-alive는 CSS 표시 전환으로 대체하되 추후 교체 가능한 경계로),startTransition래핑(uSES라 무효·일 총량 동일), 라우터 도입(상태 소유만 바뀜), 코드 분할 강화(이미 프리워밍 — 병목은 렌더). - 진입 정책 이관은 이펙트 재작성 대신 정책 호스트 컴포넌트(useScreenLoadPolicyController 본문 불변, tab 입력만 자체 구독) — 의미론 보존 우선.
4. 적용한 내용
Phase 0 — 계측 (#653 c456f9ab)
e2e/profile/tabSwitch.profile.mjs: 첫 방문/재방문 2라운드 × (달력·피드·볼륨·프로필) 왕복 + 드로어 개폐, 시드 24세션. 기존 ?lgProfile=1 Profiler + 조립 루트 커밋 카운터 재사용.
Phase 1 — 참조 안정화 (#653 fdac4c80)
profileProps를 조립 루트 useMemo(입력: remoteProfile·remoteUser·plans·todayDate·gymData)로 한 번 계산해 프로필 컨트롤러에 주입(컨트롤러는 그대로 반환). 홈 피드 비노출 동안 feedItems를 흘리지 않아 죽은 투영 제거. 계약 테스트 3(profilePropsStability).
Phase 2 — 탭 전환의 루트 탈출 (#653 f7f6b499)
- 어댑터(
navigationController) 필드별 구독화 —tab은 데스크톱에서만 반응(모바일은 비반응 상수),drawerOpen반환 은퇴. ScreenPolicyBinding(신규, null 렌더): 화면 로드 정책 이펙트·탭 직접 이동 스크롤 리셋·탭 진입 활성화 디스패치를 탭 자체 구독으로 — renderCtx에 실려 양쪽 셸이 렌더.- 프로필(소셜 연결·커스텀 종목)·피드(팔로잉)·달력(read model)의 진입 1회 로드가 렌더 입력
active에서activateFor*()메서드로 — 낡은 오너 리셋 포함이라 이펙트와 실행 순서 무관. - 달력 read model env의
active·allowExplicitDayLoad= 호출 시점 썽크(activeTabRef) — 렌더 시점 불리언은 루트가 안 다시 렌더되면 낡는다. ensureCalendarMonthData·retryVolumeReport는 renderCtx 소비자가 있어 조립 루트로 이관(탭 가드 = activeTabRef).GymMobileApp이 탭·드로어를 직접 구독, 드로어 미러(lgNavStateRef.drawerOpen)는 스토어(setDrawerOpen)가 직접 기록.
Phase 3 — 방문 탭 keep-alive (#653 9b9ed24b)
MobileTabsRegion: 방문 판 유지 + 요소 정체성 bailout — 화면 요소는 ctx가 바뀔 때만 GymMobileApp이 재생성하므로, 탭 전환 시 React가 화면 서브트리를 통째로 건너뛴다(재마운트도 재렌더도 없음).- 판 래퍼
display:contents(시각 불변)·숨김.off. 기록 도구·일 요약은 판을 가리는 레이어(종전 "대신 렌더" 의미 유지). .app/.screen레벨:has()게이트 14룰을:not(.tab-pane.off *)로 한정 — 숨은 판의 화면 마커·시트가 탭바 숨김/패딩 규칙을 오발화하지 않게.- 운동 뷰 탭바·드로어는 자체 구독 바인딩으로 교체. 운동 플로우 중 판 언마운트는 종전 유지(운동 종료 복귀 = fresh 마운트, 의미 불변).
Phase 4 — 프로필 사진 수리 (#657)
avatar_path는 있는데 서명 URL이 없으면 2초·12초 백오프로 재서명 최대 2회(성공 시 자동 종료).- 드로어 아바타 onError → 이니셜 폴백 + 재서명 트리거. 피드 친구 아바타 3곳·댓글 아바타 → 이니셜 폴백(
FaAvatar·FdCommentAvatar).
주요 결정과 그 근거
- 정책 이펙트 재작성 대신 호스트 이관 —
useScreenLoadPolicyController의 7종 이펙트 의미론(프리페치 코디네이터·날짜 롤오버·중복 발사 가드)이 풍부해, 본문 불변 + tab 공급원만 교체가 가장 안전했다. - 숨은 판은 항상 현재 요소를 렌더(스테일 캐시 없음) — 스테일 요소는 닫힌 세션 상세를 숨은 판에 남겨
:has()탭바 규칙을 오발화시킨다(설계 중 발견해 회피). - 세션 상세·댓글 시트는 연 원점의 판 하나만 렌더 — keep-alive로 여러 판이 동시에 마운트되자 루트 상태 직결 시트가 판마다 중복 렌더됐다(모바일 열기 계약: origin home·feed만 그 탭에 남고 나머지는 달력 탭). 로컬 Playwright(CASE-001·015)가 strict 로케이터로 잡아 수리.
작업 중 드러난 것
- 스냅샷 HOME_MINIMIZED 래칫: 부팅 스냅샷에 서명 URL을 동반하는 안을 구현했다가 철회 —
appShellSnapshotProjection.test.mjs가avatarUrl을 금지 키로 잠근 프라이버시 최소화 계약이었다. 코드화된 계약은 세션이 뒤집지 않는다(오너 결정 대기로 이관). - pending-changes는 manifest 등재 파일만: 미등재 테스트를 신고하면
check:test-manifest가 거꾸로 빨개진다("new files need no declaration") — 신고 전 manifest 멤버십 확인. - PR CI는 병합 커밋에서 돈다: 브랜치 작업 중 main에 #649(빈 세션 종료)·#654(CASE-011 저니 갱신)가 랜딩해 로컬 초록·CI 빨강이 났다 — main 재머지 + 로컬 스택
db reset(새 마이그레이션 4개)으로 정합. - PowerShell로 소스 치환 금지 재확인:
Get-Content -Raw | -replace | Set-Content가 한글 주석 전체를 깨뜨렸다(인코딩) — git 복원 후 Edit 도구로만 재적용. 다중행/한글은 Write·Edit 도구가 정본. - 계측 첫 런은 vite dev 재변환 노이즈가 첫 방문 수치에 섞인다 — 워밍 런으로 확정.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 운동하기 복귀(다른 탭 → 홈) 커밋 시간 | 36.9ms → 3.3ms (개발 React·CPU 4×·24세션, 워밍 런 avg) |
| 운동하기 복귀 조립 루트 커밋 | 1 → 0 |
| 재방문 탭 진입(직접 탭 4종) | 58~109ms → 2.4~4.9ms, 루트 커밋 1 → 0 |
| 드로어 열기/닫기 | 38.6ms → 1.7ms, 루트 커밋 1 → 0 |
| 첫 방문 탭 진입 | 345.7ms → 358.4ms avg(마운트+진입 로드 커밋, 부팅당 탭마다 1회 — dev 노이즈 포함, 커밋 수 5.75→5.75) |
| 검증 | node 1839/1839 · Playwright 회귀 15/15 · 뷰포트 매트릭스 11/11 · dead-css/unused/types/manifest 게이트 통과 · 로컬 브라우저 스모크(탭·드로어·상세·뒤로가기·시트 단일 렌더) |
| 미검증 | 오너 실기기 체감(0.5~1초 → ?) — 계측은 개발 React 상대 비교값. 프로필 사진 수리는 서명 실패·만료를 로컬에서 강제 재현하지 못해 코드 경로 검증 + 폴백 안전성만 확인. 로컬 eslint는 이 머신 node_modules 결손으로 미실행(CI가 판정) |
6. 이번 개선으로 향상된 것
탭 전환이 데이터 양과 무관해졌다
전환 = 셸 영역 재렌더 + 판 표시 전환뿐. 계정의 세션·기록이 늘어도 전환 비용이 자라지 않는 구조가 됐고, 방문 탭의 화면 상태(스크롤 제외 — "탭 직접 이동은 맨 위에서" 계약 유지)가 보존된다.
구조적으로 남는 것
- 탭 진입 로드 정책의 정본이 정책 바인딩 +
activateFor*()메서드로 모여, React 밖에서 발화 시점을 추론할 수 있다. profileProps참조 안정성 계약 테스트 — 루트 memo 오염 재발 방지.- 탭 전환 계측 시나리오 상주 — 다음 렌더 트랙의 전→후 비교 기반.
- keep-alive 판 아래서 "루트 상태 직결 오버레이는 원점 판 하나만"이라는 규칙(세션 상세·댓글 시트에 적용, 주간 드로어는 화면 내부 상태라 해당 없음).
남은 것
- 오너 실기기 체감 확인(운동하기 복귀·사진).
- 오너 결정 대기: 부팅 직후 사진이 이니셜로 잠깐 보이는 창을 없애려면 스냅샷 HOME_MINIMIZED 래칫(avatarUrl 저장 금지)을 바꿔야 한다 — 계약 변경 여부는 오너 몫.
- 데스크톱 동일 개편(D2: 별도 트랙 — 어댑터의 데스크톱 tab 반응 분기가 그 트랙의 시작점).
- 운동 플로우 중 판 언마운트(운동 종료 복귀는 fresh 마운트) — 증상 밖이라 종전 유지, 필요 시 후속.
HOME_FEED_UI부활 시 댓글 시트 원점(연 판) 추적 필요(코드 주석에 명기).