U05 — 미방문 화면 선로딩 제거와 입력을 보존하는 복구 (2026-09-11)
- 기간: 2026-09-11, Codex 1세션. 오너 지시: “나한테 물어보지 말고 끝까지 완주”, 선행 release 반영 후 “시작해”.
- 랜딩: release/v0.18.0 반영완료. 앱 PR #1565의 Merge Check 성공 후
release/v0.18.0에 병합했다(2cfeac5327d5f3eb8eeb2ce7cb4e5e709127f6d5). staging·Production 반영은 별도다. 마이그레이션·Edge 변경 없음. - 설계서: #1536 Phase 계획. 기대 효과는 초기 요청/실행 비용 감소, 기능별 비용 분리, 초안 보존이다.
- 정본: 기능별 로딩 계약, 앱
mobileApp.tsx·desktopApp.tsx·app/lazyFeature.tsx·vite/staleChunkRecovery.ts. - 도구: 앱 저장소에 커밋한
scripts/performance/measure/feature-graph.mjs,feature-loading.mjs; 원본/집계는 문서 public evidence. - 게이트: 매 Phase
npm run check, 화면 import graph, 실제 브라우저 오류/보존8개, A15 7개·U04 21개. precheck 결과는 최종 검증 절에 기록한다. DB 변경 없음. - 버그리포트: 없음(기능 로딩 리팩터링).
- 계약: 기능별 로딩, 기존 G04 예산 변경 없음.
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 실제 초기 요청과 그래프 기준선 | 완료 3ccb9540 |
| Phase 2 | lazy 경계 후보 및 방문 상태 | 완료 0277a27f; Phase 4 실측으로 작은 binding 분할 철회 |
| Phase 3 | 기능 실패·안전한 재열기 | 완료 aca56e7e |
| Phase 4 | 요청 수 조정·재측정·문서/인계 | 완료; 최종 측정 beedaacf, 테스트 포함 후보 786e1d5e |
1. 배경
A15·U04·G04·A16의 실제 release 포함을 확인하고 517c538ef5ffd5014fa6d62f5a4ac3f2b112a06f에서 시작했다. 모바일은 화면이 lazy여도 부팅 뒤 모든 화면의 import를 예약했다. 데스크톱은 아직 방문하지 않은 binding의 구독을 시작했다. 명목상 app 폴더로 옮기는 대신 실제 호출자에서 수정했다.
2. 문제 제기
미방문 화면이 부팅 뒤 실제로 내려왔다
기준선 모바일55요청·gzip651,299bytes, 실제 전송669,839bytes였다. 기존 데이터를 이용한 negative control에서6/6 모바일 표본이 미방문 화면 요청을 검출했다. 단순 코드 내 import 표기만으로 부팅 비용을 판단하지 않았다.
청크 실패의 자동 재열기는 현재 입력을 끊을 수 있었다
기존 부팅 복구를 입력 가능한 문서에 계속 적용하지 않도록 경계를 분리했다. 브라우저는 실패한 ES module 주소를 기억하므로 같은 URL 재시도도 실제 복구가 되지 않았다. 초기 재시도 구현은 브라우저 재현 후 폐기했다.
3. 해결 방안
- 채택: 전체 화면 선로딩 삭제, 기존 화면 dynamic import 유지, 데스크톱 첫 방문 시 binding 마운트 후 유지. 상태 provider와 공유 owner는 보존한다.
- 채택: 기능 경계만 오류 안내, 기존 운동 초안 flush 성공 뒤 사용자가 요청한 문서 재열기. 저장 실패·추가 입력·계정 변경·경로 이탈이면 문서를 유지한다.
- 기각: 작은 binding까지 별도 chunk로 분할. 실제 중간 후보가89→135청크, 모바일55→64요청·데스크톱23→50요청을 만들었다. 최종은89청크를 유지한다.
- 기각: 새 loader 프레임워크·무한 재시도·cache-busting import·대형 vendor 강제 묶음. 필요하지 않은 Vite/package/lock 수정은 하지 않았다.
4. 적용한 내용
Phase 1 — 기준선
격리 로컬 DB의 일회용 계정과 G04 history-1y260세션, 동일 digest 36db8842f1c8b1c7b41904319b8497d63c2cbc208d50ed4b04756921e64a6fce. Windows·Node24.16.0·Chromium151.0.7922.34, CPU4배, 모바일390×844·데스크톱1440×1000. 각 cold/warm3회, HTTP preview, 홈 준비 뒤4초 유휴 포함이다.
Phase 2 — 화면 사용 시점과 상태 소유
모바일 visitedTabs와 기존 화면 module 경계를 유지했다. desktop visited 상태는 미방문 binding을 만들지 않고 방문한 binding을 유지한다. 즐겨찾기 provider처럼 다른 기능도 소비하는 상태는 원래 공용 소유에 둔다.
Phase 3 — 복구
실제 production-format browser에서 network abort·구형404 이후 재열기·뒤로가기·지속 실패·load 중 이탈·인증 보관 거부·IndexedDB 쓰기 오류·보관 중 새 입력/경로 이탈을 검증했다. 기존 IDB draft/outbox를 사용하며 원문 대조를 한다. 다른 폼은 먼저 저장하도록 안내하며 모든 임시 입력의 자동 복원을 약속하지 않는다. 새 문서의 workout 복원은 기존 계약이다.
Phase 4 — 요청 수를 늘리지 않는 최종 경계
작은 binding 분할을 철회하고 오류 경계를 route에 둔다. 그래프는 모바일4개·데스크톱5개 화면의 초기 정적 도달을 차단하는지 검사한다. 실제 측정은 부팅 뒤 미방문 화면 요청0을 별도로 단언한다. 네트워크 실패 이후 별도 네트워크 스택이나 SW 갱신기를 만들지 않는다. 실제로 home을 막은 외부 font CSS 요청은 로컬 선언으로 없애고 12회 모두 CSS 요청0·폰트200을 확인했다.
작업 중 드러난 것
- Docker Desktop은 오래 남은
run/dockerInference경로 때문에 기동 실패했다. Docker 프로세스만 종료하고 run 경로를 이름 변경해 보존한 뒤 재기동했다. DB/container data·factory reset은 하지 않았다. engine29.7.2와 자체 격리 스택 기동을 확인했다. - 초기 fixture 마련을 위한 기존
ci:full-local -- --only empty --keep는 부분 실행8/9였다. 기존get_pr_overviewgeneration0의55000(snapshot unavailable)1건은 제품 변경 전 관측이다. 같은 fixture 환경의260세션 준비/일반 화면 실측은 성공했다. 무관한 DB 수리로 확대하지 않았으며 R04에 인계한다. - Phase 3 첫 check의 검색 source 검사1건은 lazy 표현 변경에 맞춰 고쳤다. Phase 4에서 작은 binding 분할을 철회하며 최종 source 단언도 기존 표현으로 복원했다. 제품 행동 단언·skip·예산은 낮추지 않았다.
- 폰트 Resource Timing에 본문이 없던0을 개선으로 간주하지 않았다. CDP 응답/전송 보완 측정을 추가했다.
- 최종 중간 후보60dd69eb의 첫 cold는home3,640ms/load2,995.7ms로 G04를 넘었다. raw Resource Timing에서 기존 외부 글꼴 CSS 응답2,747.1ms를 확인했다. 같은 고정 font-face 선언을 index.html에 두어 CSS 네트워크 대기를 제거했다. 이 경계 변경 뒤 새 후보를12회 다시 측정했으며 과거 느린 표본을 같은 후보에서 제외하지 않았다. font 파일/서체 변경·네이티브 환경 변경은 없다.
5. 적용 결과
기준 앱 517c538e → 최종 측정 앱 beedaacf(측정 당시 clean). 아래는 각3회 중앙값, homeMax는3회 최대다. 적은 표본으로 통계적 유의성이나 전체 R04 합격을 주장하지 않는다.
| 항목 | 모바일 cold | 모바일 warm | 데스크톱 cold | 데스크톱 warm |
|---|---|---|---|---|
| 홈 준비 중앙값(ms) | 979 → 925 | 1,253 → 1,257 | 1,117 → 1,023 | 1,258 → 1,176 |
| 홈 준비 최대(ms) | 1,048 → 950 | 1,267 → 1,297 | 1,158 → 1,180 | 1,313 → 1,326 |
| load 중앙값(ms) | 269.7 → 259 | 130.5 → 124.8 | 238.1 → 243.1 | 116.4 → 126.4 |
| 초기 JS 요청 | 55 → 38 | 55 → 38 | 23 → 23 | 23 → 23 |
| 요청 JS gzip(bytes) | 651,299 → 552,758 | 651,299 → 552,758 | 499,996 → 500,776 | 499,996 → 500,776 |
| 실제 JS 전송 중앙값(bytes) | 669,839 → 566,048 | 16,500 → 11,400 | 507,544 → 508,324 | 6,900 → 6,900 |
| ScriptDuration 중앙값(ms) | 879.239 → 805.268 | 1,653.073 → 1,657.449 | 840.155 → 778.034 | 1,493.714 → 1,361.901 |
| 홈 DOM | 342 → 317 | 342 → 317 | 1,757 → 1,757 | 1,757 → 1,757 |
전체 JS gzip합계793,154→793,700bytes(+546), 청크89→89. 모바일 요청 gzip98,541bytes(-15.13%), 실제 cold 전송103,791bytes(-15.49%) 감소. 데스크톱 초기 gzip은780bytes(+0.16%) 늘었으며 바이트 절감으로 보고하지 않는다. 모바일 warm 홈1253→1257ms·ScriptDuration1653.073→1657.449ms도 개선으로 보고하지 않는다.
| 화면 표시 중앙값(ms) | 모바일 cold | 모바일 warm | 데스크톱 cold | 데스크톱 warm |
|---|---|---|---|---|
| 달력 첫 방문 | 306 → 291 | 310 → 315 | 571 → 541 | 566 → 629 |
| 리포트 첫 방문 | 633 → 621 | 681 → 590 | 205 → 185 | 202 → 201 |
| 달력 재방문 | 82 → 83 | 81 → 86 | 217 → 201 | 207 → 207 |
| 리포트 재방문 | 582 → 552 | 587 → 651 | 175 → 168 | 163 → 164 |
| 프로필 첫 방문 | 687 → 769 | 675 → 704 | 315 → 299 | 305 → 307 |
모바일 첫 달력/리포트/프로필 방문에 각각6,107/9,231/6,949gzip bytes가 추가로 내려오고, 재방문 달력/리포트의 새 JS는0이다. 이 비용을 부팅에서 방문 시점으로 옮긴 사실을 숨기지 않는다. 모바일 cold 프로필687→769ms, warm 리포트 재방문587→651ms, desktop warm 달력 첫 방문566→629ms는 증가 표본이다. 별도 숫자가 없는 feature 전환 예산을 만들어 통과 처리하지 않는다.
G04 비교: cold 홈 준비 최대 모바일950·데스크톱1,180ms는1,448ms 아래, load 최대277.3·248.3ms는456ms 아래다. 실제 cold script 전송566,048·508,324bytes는601,434bytes 아래다. 모바일 DOM342→317은 개선됐어도294 상한을 넘으므로 DOM 항목 불합격 유지다. 데스크톱1,757은 모바일 기준과 조건이 달라 비교값이며 전체 릴리스 합격이 아니다.
자산/폰트·플랫폼: 같은 WantedSansVariable.woff2 파일·v1.0.3·weight400~1000·display swap을 유지하고 선언만 index.html 안으로 옮겼다. cold CDP 전송은 약1.29MB, warm은disk cache에서0이다(HTTP 헤더 차이 포함). 기준선 Resource Timing만으로 폰트 전후 감소를 계산하지 않는다. build local 대상/비밀·source map 검사 통과, app graph의 관리자 모듈0, 라이브러리/환경/네이티브 셸 변경0. iOS·Android 실기기/컴파일·Production CDN·HTTPS SW 교체·V8 parse 단독 시간은 미측정이다. ScriptDuration은 실행을 포함한다.
원본: 기준 graph · 기준 browser · 최종 graph · 최종 browser · 집계 · negative control · 외부 font CSS 지연 후보 · binding 분할 후보 graph.
최종 로컬 검증
Phase4 npm run check: 3,617 pass/0 fail/92제외, 99.922s. 이후 font 선언/중복 경계를 정리한 후보는 관련 단위6/6·복구 browser8/8을 통과했으며 필수 precheck 최종 결과를 아래에 기록한다.
Precheck 이력: 83a344e4는1분57초 통과. font 선언 이동 뒤87a13a88은 기존 launch-paint의 삭제된 stylesheet 앵커 때문에3건 실패(2개 존재/범위 검사+1개 순서 단언)했다. 사전 검증 누락을 기록하고 같은 font-face 위치로 단언을 옮겨 관련44개 전체를 재검증했다. 실패 묶음1/1·케이스3/3 해결, 제품 코드는 측정 후보와 동일하다. 수리 기록. 최종 후보 786e1d5eadcab2af7eaf3de745df1236f1528cf2는 최신 release 517c538e를 실제 반영한 clean 트리에서 precheck 통과(2026-09-11 12:42:32 KST, 1분55초). static/unused/build/artifact 통과, unit-1 1903 pass/0 fail/40제외·unit-2 1714 pass/0 fail/52제외. 합계3617 pass/0 fail/92정책제외다. 서버 변경이 없어 DB 단계를 붙이지 않았고 브라우저는 별도8개 및 실측12회로 검증했다. precheck 요약. 이 결과는 Full CI가 아니다.
앱 PR #1565의 Merge Check 성공 후 release/v0.18.0에 병합했다(2cfeac5327d5f3eb8eeb2ce7cb4e5e709127f6d5). staging·Production 반영은 별도다. 문서 check345 routes·build·immutable assets31개 검증 통과. 자체 격리 DB와 preview는 종료했고 Docker engine29.7.2는 정상이다.
6. 이번 개선으로 향상된 것
처음 쓰는 화면에 필요한 코드만 요청한다
부팅 유휴 시간에 방문하지 않은 화면을 미리 내려받지 않는다. 기존 상태 공유와 screen 경계를 보존해 요청 파일이 잘게 늘지 않는다.
로딩 실패가 다른 화면의 입력을 끊지 않는다
사용자는 다른 화면으로 이동할 수 있다. 운동 초안은 기존 저장 확인 뒤에만 새 문서를 열고, 보관 실패와 추가 입력은 현재 문서에 남긴다.
후속 인계와 검증 범위
- 완료 범위: 2026-09-11 오너가 “원래 precheck 끝나면 릴리스 병합”으로 직접 정정했다. 로컬 완료에서 멈춘 이전 보고를 정정하고 자기 PR의 Merge Check·실제 release 병합까지 완료했다. 같은 범위에 재승인을 요구하지 않는 기준을 공통 지침에 반영했다.
- R01: 퇴역은
warmMobileScreenChunks와 mount 예약뿐이다. 작은 binding·공유 HostFrames의 실제 owner/runtime/ViewportList는 유지하며 파일 이름만으로 거대 DOM 모듈이라 단정하지 않는다.warmChunksSuppressingRecovery는 현재 production 호출0이며 기존 단위 계약만 남는다는 점을 인계한다. - R04: 같은 workload/원본·G04 비교·DOM 초과·초기 empty1건 및 전환 비용 증가 표본을 인계한다. 처리량·장기 soak·모든 프로필의 전체 합격은 이 측정 범위가 아니다.
- U06: 복구8개와 정상 화면12회 측정, native/HTTPS SW/실기기 미측정 범위를 구분한다. 관리자·와드업 제외 자동검사를 재도입하지 않았다.