Skip to content

반응성 재설계 — 앱 전체 입력 즉시 반응·완성 데이터 공개와 리포트 통계 재확인 상태 보존 (2026-09-14)

  • 기간: 2026-09-14 (Codex Define responsive loading principles 대화 Phase 1~3 → 이어받은 세션 ccc9d751-3fd7-4472-b59a-308316f50038 Phase 3 통합·Phase 4). 오너 지시 원문: "버튼을 누르면 즉시 반응하고, 로딩이 필요한 영역은 스켈레톤으로, 데이터가 완전히 준비된 완성본을 적용하면서 걷고, 준비 시간도 관리한다"(#1592 본문 2026-09-14 원칙 4개).
  • 랜딩: 앱 feat/1592-responsive-loading(Phase 2 6fcf1eaa → Phase 3 402d016c → release/v1.0.0 병합 2bfa56bd → QA1 고정 908e7b29 → Phase 4 7e4f37621e3f42531d88578c → release/v0.19.0 병합 145923ff → QA1 핀 eae61116 → #1597 포함 base 병합 ee846213) · QA1 qa1/1592-responsive-loading(0bb392c0765de17467de12ac8d0e48f5a0e0fad1) · 문서 docs/1592-responsive-loading. QA1 게시(8d0e48f5)·앱 PR #1599 → release/v0.19.0 큐 요청(Merge Check; 1차 run 34844134531은 요청 뒤 base가 #1597로 앞서가 qa1.lock.json 충돌로 중단 → base 병합 후 재요청 run 34845690717 통과) → release/v0.19.0 병합 완료 7cf942d1(2026-09-14 21:52) — 오너 지시. staging·Production 반영 없음(승격 대기). 마이그레이션 1건 20260915093000_report_design_freshness.sql(low/function).
  • 설계서: 이슈 #1592 본문(최종 목표·구현 방법·합격 기준·"예상 효과·개선사항" 표).
  • 정본: 입력 반응과 완성 데이터 공개 계약 · src/react/contracts/dataPublication.ts(pending | ready | error) · src/react/ui/shared/DataPublicationBoundary.tsx · 기능별 *Publication.ts(home·calendar·profile) · 리포트 계약.
  • 도구: QA1 suite/tests/react/*Publication*.browser.mjs(실제 store→binding→UI, 제어 응답) · 성능 계측 러너(앱 작업 트리 test-results/phase4/, gitignored — runner·scenario·prepare-fixtures·build·summarize) · 워크로드 scripts/performance/workload/generate.mjs(history-1y/4y/10y, seed 1284).
  • 게이트: publicationBoundaryGate.test.mjs(신규 화면 게이트) · responsivenessPhase4Gate.test.mjs·reportDetailMerge.test.mjs(Phase 4 성능 회귀 게이트) · dataPublication.test.mjs(상태 의미) · browser 검사 16개(persistence-browser lane) · pgTAP report_design_distributions_v1.test.sql · DB reportDesignSnapshot.test.mjs. 로컬 precheck: 1차 ci:precheck-local --release origin/release/v1.0.0 unit-1 1,984/0/64skip · unit-2 1,871/0/85skip · database 통과(pgTAP·DB node) · static은 check:unused 1건으로 실패 후 수리. 최종 2차(앱 1d88578c, 2026-09-14 17:53, 7분 51초): 검증: ci:precheck-local precheck · static 통과 · unit-1 통과 2073 passed / 0 failed / 64 conditional skip · unit-2 통과 1790 passed / 0 failed / 85 conditional skip · database 통과 db reset(마이그레이션 전체 적용): pass · schema.sql 스냅샷 --check: pass · pgTAP: pass 122파일/2453 assert · 동시 저장 세대·영수증: pass · dirty 범위·세대 병합: pass · 순차 체크포인트·기간/달력 동치: pass · worker 임대·울타리·한 유저 트랜잭션: pass · 큰 이력 계산 격리·동시 CRUD·통계 수렴: pass. 최종 3차(release/v0.19.0 base b622cc80 병합 후, 앱 eae61116, 21:32, 8분 31초): static·unit-1 2,073/0/64 skip·unit-2 1,790/0/85 skip·database 같은 항목 통과. 직전 실행에서는 designDeliveryPrimitives.test.mjs가 4레인 동시 실행 중 단언 정보 없이 파일 단위로 1회 실패했고 단독 실행·재실행은 통과했다(단언·skip 변경 없음). 4차(#1597 포함 base fdc88206, 앱 ee846213, 21:48, 9분 12초): 전 레인 통과(unit-1 2,074/0/64 · unit-2 1,790/0/85 · database).
  • 버그리포트: 없음(오너 보고 결함은 이슈 #1592 본문에 포함, 별도 bug-report 번호 없음).
  • 계약: responsive-publication.md 신설, 60행 상태표, 검증 기록.

Phase 현황

Phase내용상태
Phase 1전체 적용 대상표(입력 32·공개 26→28), 입력/공개 경계, 성능 기준선·최종 합격선 확정✅ 문서 cf3164ac
Phase 2공통 준비·공개 계약 + 리포트 상태 의미 소실 수리(개요·기간 상세 동일 snapshot freshness)✅ 앱 6fcf1eaa · 문서 214fefb3
Phase 3모바일·PC 전체 대상 적용, 입력 반응 경로 분리, 동시성·실패·캐시 보존 연결✅ 앱 402d016c(+release 병합 2bfa56bd·QA1 고정 908e7b29) · 문서 240a792
Phase 4성능 예산 계측(1/4/10년), 신규 개발 규칙·회귀 게이트, 전체 요구 대조, precheck✅ 앱 7e4f37621e3f42531d88578c · QA1 67de12ac · 문서 1fb6641 이후 · precheck 4차(#1597 포함 base, 앱 ee846213) 통과 · PR #1599 release/v0.19.0 병합 7cf942d1 — §4·§5-1. 합격선 미달 3종은 §남은 것(#1596)

1. 배경

리포트 상단 총 볼륨·운동일·운동 개수가 캐시 무효화 뒤 새 요청이 시작되기 전 구간에 이전 값을 그대로 보여 주는 결함이 사용자 조사(2026-09-14)에서 재현됐다. 통계 조회 저장소는 무효화 때 응답을 보존하면서 상태만 invalidated로 바꾸고, 화면 상태 함수는 stale을 내는데, 컨트롤러가 화면 상태를 4가지로 줄이면서 stale을 기본 분기의 ready로 바꿔 숫자 공개를 허용했다(main 1711ae2e). 같은 날 오너는 이 결함을 포함해 앱 전체의 입력 반응·완성 데이터 공개 원칙 4개를 최종 목표로 정했다.

2. 문제 제기

  • 화면마다 "데이터가 있으면 보여 준다"는 자기 규칙이 있었다(Boolean(resource.peek), items.length, RPC 하나 완료). 무효화·재확인·오류가 데이터 존재로 가려졌다.
  • 편집·운동 시작은 서버 상세를 기다린 뒤에야 화면이 열렸다(E02/E03/N07/N08). 그 사이 사용자는 눌렀는지 알 수 없었다.
  • 리포트 개요와 기간 상세가 서로 다른 세대일 수 있는데 이를 판정할 근거(기간 상세의 freshness)가 없었다.
  • PC 저장 중에는 닫기가 막혔고, 그룹 보드·알림·검색·프로필 등은 필수 응답 하나가 남아도 부분 본문을 공개했다.

3. 해결 방안

  • 원칙(오너 결정 2026-09-14): ① 입력 즉시 의미 있는 시각 반응 ② 필요한 영역만 스켈레톤 ③ 필수 데이터·계산·반영이 끝난 완성본을 적용하면서 걷기 ④ 실제 준비 시간 관리. 설명·설계·실행은 최종 목표 중심.
  • 접근: 입력·화면 틀 / 데이터 준비 / 본문 공개의 책임을 나누고, 캐시 보존과 공개 권한을 분리한다. 공개 상태는 pending | ready | error 한 계약으로만 흐르며, stale·invalidated·취소를 ready로 바꾸는 기본 분기를 없앤다. 리포트는 개요와 기간 상세의 owner·기간·필터·기준일·적용 세대가 같을 때만 세 숫자를 함께 공개한다.
대안판단
입력·준비·공개 책임 분리 + 캐시 유효성 의미 보존(공통 경계)채택
stale 한 분기만 수정하고 종료필요하지만 전체 목표 미충족 — Phase 2에 포함하고 전체 적용까지 진행
고정 시간 뒤 공개·투명도·애니메이션기각 — 완료 증거 없음, 부분 공개 재발
캐시를 공개한 뒤 Effect에서 숨김기각 — 무효화 직후 노출 구간 남음
모든 로딩을 전역 대기로 묶음기각 — 무관한 요청이 입력을 막음
모든 입력을 낙관적으로 성공 처리기각 — 서버 확정·복구와 충돌

4. 적용한 내용

Phase 1 — 대상표·합격선 (Codex)

입력 32개 경로군, 공개 26행(뒤에 PC 기록표·PC 운동 그룹 2행 보완 → 28), 모바일 stack kind 19종, PC 루트 binding 8종을 소스와 대조했다. 최종 성능 합격선(입력→첫 반응 p95 100ms·동기 50ms, 유효 캐시 재방문 p95 100ms, 마지막 데이터→공개 p95 100ms, 새 조회 1·4년 p95 2초/10년 5초, production 빌드·CPU 4배·RTT +100ms·390×844/1440×1000·20회×2묶음)을 구현 전에 고정했다.

Phase 2 — 공통 상태와 리포트 (Codex)

resource의 stale/refreshing/error/empty 의미를 공개 상태로 보존하는 resourceDataStatusOf·combineDataPublication·dataPublicationStatusOf를 두고, 리포트 개요·기간 상세의 owner/기간/필터/기준일/적용 세대를 대조해 세 숫자를 함께 공개한다. 기간 상세 RPC(get_report_design_v1)가 같은 SQL snapshot의 freshness를 돌려주도록 마이그레이션을 추가했다. 실제 resource→hook→모바일/PC DOM 브라우저 검사 16개, pgTAP 36 assert, 두 DB 연결 경합 검사 7개.

Phase 3 — 앱 전체 적용 (Codex → 이어받은 세션 통합)

  • 공통 DataPublicationBoundary/StatDataRegion: pending·error에서 보존 본문은 mounted + inert + visibility:hidden·aria-hidden, pending만 스켈레톤, error는 재시도. ready에는 overlay 없음.
  • 입력 준비 셸: 완료 기록 수정·계획 수정·세션/그룹 보드에서 운동 시작은 조회 전에 닫을 수 있는 셸을 열고 같은 intent 번호로 canonical detail·카탈로그·custom 등록을 기다린 뒤 초안을 공개한다.
  • 적용 영역: 홈(M/PC, 연간 포함)·홈 날짜·달력 월/일/기간(M/PC)·PC 리포트 날짜/기간·피드·검색(M/PC)·프로필/연결/커스텀/차단·그룹 목록/라운지/보드/기록/상세·알림·수동 기록·회복·PC 기록표·PC 운동 그룹·종목 상세(PR 조각·연도·보관)·PC 저장 중 닫기.
  • 이어받은 세션: 통합 단위 4,001건 중 소스 앵커 검사 2건 재조준(실패 0), 목적 release release/v1.0.0(#1591 QA1 이관 포함) 병합, 테스트 80개를 QA1 suite로 옮기고 migration-adaptations.json(원본과 달라진 39개)과 qa1.lock.json 갱신.

Phase 4 — 성능 계측·게이트·최종 대조 (이어받은 세션)

  • 신규 화면 게이트 publicationBoundaryGate.test.mjs: 화면 파일이 공통 경계 또는 계약 상태 prop을 쓰거나 서버 데이터 영역이 없는 파일로 이유와 함께 등록돼야 한다. 상태 타입 단일 정의·staleready 금지·경계의 타이머 없는 은닉을 함께 고정.
  • 친구 화면(홈 세 원천·피드·세션) 실제 binding 브라우저 검사 추가. 이 검사가 실제 결함(친구 세션 오버레이의 소유자 표기가 뒤로가기 버튼을 덮음)을 잡아 primitives.css에서 수리했다.
  • 성능 계측: 로컬 샌드박스 DB(마이그레이션 전체 적용)에 실제 계정·실제 저장 RPC로 1년(260)·4년(1,043)·10년(2,609) 기록을 적재하고, production 빌드를 미리보기로 띄워 CPU 4배·RTT +100ms에서 클릭 capture→첫 반응 rAF→마지막 필수 RPC 수신→완성 DOM 공개를 잰다. 러너·시나리오·적재·요약 도구 원본은 evidence/1592-phase4에 있다(키·계정 파일 제외). 결과는 §5-1.
  • 계측이 드러낸 예산 초과·부분 공개 4곳을 구조로 수리했다(앱 1e3f4253, 회귀 게이트 QA1 responsivenessPhase4Gate.test.mjs·reportDetailMerge.test.mjs, QA1 67de12ac):
    • 리포트(모바일): 기간 상세(get_report_design_v1)가 마지막 필수 응답인데, 도착하면 개요 투영 전체를 다시 계산하고 화면 전체를 다시 그려 "마지막 응답→완성 공개"가 4배 CPU에서 약 500ms였다. 이제 이미 공개된 개요 투영에 상세만 얹는다(applyReportDesignDetail). 결과는 처음부터 상세를 넣고 만든 투영과 같고 입력을 바꾸지 않는다는 것을 계약 검사로 고정했다.
    • 모바일 달력 월 이동: 표시 달·선택일이 앱 루트 상태라 클릭 안에서 앱 전체가 다시 그려져 동기 처리가 67~99ms였다. 화면이 자기 로컬 상태로 제목·격자를 먼저 바꾸고 루트 상태는 transition으로 뒤따른다. 그 사이 새 달의 숫자는 그 달의 공개 상태(pending/ready)를 그대로 따른다.
    • PC 탭 전환: 판 전환이 클릭 처리 안에서 mounted된 바인딩 전부를 다시 그려 동기 처리가 87~144ms였다. 모바일 탭바(#1152)와 같은 구조로 사이드바 강조는 누른 탭을, 본문 판은 한 렌더 뒤의 지연 값(useDeferredValue)을 쓰고, 판 상태 정리는 같은 transition에 묶었다. 재계측에서 첫 방문(청크 미적재)의 첫 반응이 900ms대로 밀리는 회귀가 드러났다 — 바깥 Suspense 경계 하나가 이미 내용을 보이고 있으면 React가 지연 렌더 안의 대기(suspend)를 이전 판을 붙든 채 청크 도착까지 미룬다. 판마다 자기 Suspense 경계를 두어 지연 렌더 안에서 처음 마운트되는 경계가 fallback(로딩 오버레이)을 바로 그리게 했다(앱 1d88578c).
    • PC 달력 주간 합계: 달 경계에 걸친 주(예: 9월 1주차 = 8/31~9/6)가 표시 달만 준비된 채 보이는 날짜만 더해 18,388kg으로 공개됐다가 이웃 달이 도착하면 22,431kg으로 바뀌었다(늦은 정정 = 부분 공개). 주 행의 공개 상태를 그 주가 걸친 달 전부의 상태를 합쳐 판정하고, 준비 전에는 스켈레톤을 둔다.

작업 중 드러난 것

  • 목적 release에 앱 테스트 전체가 QA1로 이관돼(#1591) 테스트 변경을 별도 저장소로 옮겨야 했다. 원본 39개의 조정 사유를 migration-adaptations.json에 남겼다.
  • 브라우저 날짜를 fixture 끝날짜로 고정하면 서버의 PR 개요 일별 스냅샷(실제 오늘 기준)이 없어 get_pr_overview가 55000으로 실패한다 — 계측은 실제 시계로 한다.
  • 샌드박스에서 10년 계정의 첫 통계 계산이 worker compute_timeout_ms 60초 안에 끝나지 않아 자연 수렴하지 않았다(57014 statement timeout 반복). 계측 준비를 위해 샌드박스 설정만 lease 900초·compute 600초로 올렸다(Production 설정 변경 아님). 실제 사용자는 10년을 한 번에 적재하지 않지만, 대량 인입 경로의 초기 계산 시간은 별도 확인 대상이다.
  • PC 리포트 재방문은 재조회·스켈레톤 없이 100ms 안에 다시 그려지지만 화면이 재마운트된다(PC 탭은 달력만 mounted 유지). 계약의 "재마운트 0회"와 어긋나는 항목으로 기록한다.
  • 계측 러너 자체의 오류 세 가지를 고쳤다: CORS preflight(OPTIONS)를 필수 RPC로 세어 "필수 응답 미관측"으로 판정하던 것, PC 재방문 재마운트를 실패로 끊던 것(기록 항목으로 변경), 그리고 관측 루프가 매 프레임 강제 layout(getBoundingClientRect)을 해 4배 CPU에서 프레임당 70~130ms를 쓰며 앱의 지연 렌더를 굶기던 것(추적에서 러너의 rAF tick이 가장 긴 작업으로 잡혔다 → DOM이 바뀐 프레임에서만 판정을 다시 계산, 판정식은 그대로). 계측 결과 판정에 합격선을 맞추는 방향의 수정은 없다.
  • 탭 전환 동기 비용의 남은 원인: 앱 컨트롤러가 provider 값을 매 렌더 새 객체(inline)로 만들어, 탭 하나가 바뀌어도 mounted된 바인딩 전부가 다시 그려진다(4배 CPU에서 100ms대). 판 전환을 지연 렌더로 옮겨도 이 몫은 남는다. 이 트랙 범위 밖의 구조 항목으로 §남은 것에 둔다.
  • 목적 release 변경(오너 지시 2026-09-14 저녁): release/v1.0.0 대신 새로 만든 release/v0.19.0(main b622cc80)에 통합한다. main에는 #1594(완료 피드백 공통 규격·리포트 복귀 경로)가 들어와 있어 4개 파일이 충돌했다 — 리포트 화면은 이 트랙의 공개 상태 렌더(UiReportScreen: 보존 본문 + 상태 컨텍스트)를 유지하고 #1594의 리포트 뒤로가기(RpBrandBack/onClose)를 받았고, 수동 기록·프로필 토스트는 #1594의 통일 문구를 따랐다.
  • #1594는 main에 [skip ci]로 직행하며 앱 문구와 토스트 배지 마크업을 바꿨지만 고정 QA1 스위트를 갱신하지 않아, 병합 트리 precheck에서 감사 대상 14건이 옛 문구를 기대하며 실패했다. 이 트랙의 QA1 브랜치(8d0e48f5)에서 13개 파일의 기대값을 통일 문구에 맞추고 migration-adaptations.json에 사유를 적었다. 별도로, QA1 main(#1474 origin 계약 테스트)을 합치면 앱 main에 없는 변경을 기대하는 검사 5건이 실패하므로 그때는 합치지 않았다(앱 main이 고정한 QA1 커밋은 이미 이 브랜치의 조상).
  • 큐 1차 요청 뒤 release/v0.19.0에 #1597(#1474 도메인 분리)이 먼저 병합돼 qa1.lock.json 충돌로 Merge Check가 멈췄다. #1597이 고정한 QA1 커밋(e9622131, #1474 origin 검사 포함)을 이 트랙의 QA1 브랜치에 합쳐(a0e0fad1) 다시 고정하고, base 병합 커밋(ee846213)으로 precheck를 다시 통과시킨 뒤 재요청했다.
  • 첫 계측(앱 7e4f3762)에서 예산을 넘긴 곳은 전부 "마지막 응답 뒤 전체 재계산·재렌더" 또는 "앱 루트 상태를 클릭 안에서 동기 갱신"이라는 같은 구조였다. 그래서 §4 Phase 4의 네 수리는 자리별 조건문이 아니라 증분 병합·로컬 urgent 상태+transition·지연 판·원천 상태 결합이라는 구조로 했고, 같은 구조를 게이트로 고정했다.
  • 이 PC의 Docker Desktop이 이전 실행이 남긴 소켓 파일(Docker\run, docker-secrets-engine)을 지우지 못해 크래시했다. 폴더 이름을 바꿔 치우고 재시작했다(반복 증상).

5. 적용 결과

항목검증
리포트 상단 세 숫자무효화 직후 이전 값 노출(26,000kg 재현)무효화 순간부터 스켈레톤, 개요+기간 상세 같은 세대 갖춰진 뒤 함께 공개reportResourcePublication.browser 등 16/16
편집·운동 시작상세 응답 뒤 화면 열림즉시 준비 셸(닫기 가능) → 완성 초안 공개workoutEntryPublication.browser 6/6, 단위 18
홈·달력·그룹·알림·검색·프로필·기록표·종목 상세데이터 존재/RPC 하나로 부분 공개필수 응답·세대 갖춰진 완성본만 공개, 실패는 재시도로 종료60행 상태표(동작 증거 42행, 부분 18행)
통합 단위 검사3,947/3,799(기준선)4,001/3,852/실패 0/skip 149 (release 병합 후)npm run check
성능 합격선미실측§5-1 계측 표phase4 러너

5-1. 성능 계측 (production 빌드 · Chromium · CPU 4배 · RTT +100ms · 로컬 샌드박스)

합격선은 Phase 1 그대로다(입력→첫 반응 p95 100ms · 동기 50ms · 유효 캐시 재방문 100ms · 마지막 필수 데이터→완성 공개 100ms · 새 조회 입력→완성 1·4년 2,000ms/10년 5,000ms). 세 차례 계측(전 7e4f3762 → 1차 1e3f4253 → 2차 1d88578c)의 항목별 수치와 원인 분석은 검증 기록 Phase 4에 있다. 1년 이력·묶음 1 기준 요약:

경로전 → 후 (p95 ms)합격선 대비
모바일 달력 월 이동첫 반응 83 → 38 · 동기 83 → 28 · 재방문 73 → 39재방문 합격, cold는 마지막 데이터→공개 244(미달)만 남음
PC 달력 진입(cold)늦은 정정 10/20회 → 0회, 첫 반응 62 → 101, 동기 48 → 53, 공개 251 → 268완성 550 ✓, 첫 반응·동기 각 1~3ms 초과, 공개 미달
PC 리포트 열기동기 87 → 38(warm), 첫 반응 44 → 80(cold), 공개 386 → 550(cold)cold 완성 714 ✓, 공개 미달, warm 재방문 139(재마운트)
모바일 리포트 열기첫 반응 360 → 339(cold), 공개 657 → 562, 재방문 136 → 106완성 1,286 ✓, 첫 반응·공개·재방문 미달
모바일 달력 진입첫 반응 87 → 80, 동기 36 → 30, 공개 206 → 172, 완성 504 → 467공개 미달, 나머지 ✓; warm 재방문 140~298(편차)

남은 미달의 원인은 세 가지 구조 항목이다(검증 기록 Phase 4 "남은 미달과 원인"): ① 마지막 응답 뒤 화면 전체 재렌더+첫 공개 layout/paint(4배 CPU에서 100ms 초과) ② 앱 컨트롤러 provider 값의 매 렌더 재생성으로 탭 전환마다 mounted 바인딩 전부 재렌더, PC 비활성 판 unmount(재방문=재마운트) ③ 홈 진입 직후 대용량 응답(개요 618KB·카탈로그 814KB) 처리와 입력이 겹침. 4년·10년·1년 묶음 2의 표는 검증 기록에 이어 적는다.

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

  • 재확인이 필요한 숫자가 이전 값으로 보이는 구간이 구조적으로 사라졌다(캐시 보존 ≠ 공개 권한).
  • 모든 서버 데이터 화면이 한 계약(pending/ready/error)으로 준비 상태를 받으며, 새 화면은 게이트 검사가 강제한다.
  • 편집·운동 시작·닫기·뒤로가기가 네트워크와 분리돼 즉시 반응한다.

남은 것

  • 합격선 미달 3종의 구조 수리 → 후속 트랙 #1596 렌더 구조 개선(조사중): 그리기 비용의 계약 세 개(상태 조각 구독·통로 값 안정, 섹션 단위 그리기·identity 유지, 큰 응답 처리 분리·원자적 공개)를 동작 검사로 굳힌다. Phase 1은 원인별 비용 몫을 재는 되돌릴 수 있는 실험이고, 대안(컨텍스트 memo화 / 조각 구독 이전 / React Compiler / canvas 잔디 / 서버 화면 단위 응답 / 워커)은 같은 기준으로 비교한 뒤 채택한다. 합격선은 바꾸지 않는다.
  • invalidated 모드(저장→통계 세대 갱신→재공개) 실측 — 현재 러너로 재현 불가, 러너 확장 필요. 부분 증거 18행의 실제 binding 검사.
  • release→staging 승격 · Production 확인 — 오너 지시 범위에서 실행. QA1 게시·앱 PR #1599·merge:request는 2026-09-14 오너 지시로 실행했다.