달력 렌더 구조 정리 — 루트에 흩어진 원천 분기에서 뷰 선택자·키별 발행·로딩 신호 맵까지 (2026-08-24)
- 기간: 2026-08-24 (세션 1개 — 렌더 입자성 종결 직후 오너 "달력 페이지 렌더 관련해서 구조적으로 더 깔끔한 디자인 추천" → 제안 4건 → "제안대로 1부터 4까지 쭈욱 중단없이 진행")
- 랜딩: PR #630(
d3a63438— Phase당 커밋 1: Phase 101182d08· Phase 2118e649a· Phase 3ce742108· Phase 4d3603d9f). 마이그레이션·엣지 0건, 웹은 Vercel 자동 배포 - 설계서: 없음 — 채팅 제안 4건(가치순)을 오너가 그대로 승인. 선행 맥락은 렌더 입자성 캠페인 Phase 4-2 판정
- 정본: 원천 결정
src/react/services/calendarViewSelector.ts(selectCalendarSource·selectCalendarView·resolveDaySummarySessions), 주/월 저널 훅src/react/features/calendar/useCalendarJournalSelection.ts, 키별 발행src/react/controllers/calendarReadModelStore.ts(dayModelsByDate·monthModelsByKey·rangeModelsByKey·monthLoadByKey), 달 단위 memo 투영src/react/services/calendarReadModelMapper.tsbuildCalendarUiProjectionByMonth - 도구: 렌더 입자성 계측기(
e2e/profile/renderGranularity.profile.mjs) 재사용 - 게이트:
calendarViewSelector.test.mjs(5) ·calendarProjectionByMonth.test.mjs(3) ·calendarMonthLoadSignal.test.mjs(2) · 재조준 계약(statsCentralizationPhase4·homeBootLoadingOrder·backgroundTokens·calendarReadModelStore). verify 레인 - 버그리포트: 없음
- 계약: 없음(ui/** 무접촉)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 달력 뷰 선택자 단일화 — 루트의 삼항 4·병합 2·폴백 1 → 결정 1개(calendarSource) | ✅ #630 (01182d08) |
| Phase 2 | 주/월 저널 선택 훅 — GymMobileApp의 달력 몫 이동, 세트 목적 구간 memo | ✅ #630 (118e649a) |
| Phase 3 | 키별 발행 + 달 단위 memo 투영 — 구조 공유·무변화 무통지·바뀐 달만 재계산 | ✅ #630 (ce742108) |
| Phase 4 | 월 로딩 신호 통합 — 배열 2·카운트 맵 2 → 맵 1 | ✅ #630 (d3603d9f) |
1. 배경
렌더 입자성 캠페인 Phase 4-2에서 달력은 이관하지 않았다 — 미적재 월 이동의 루트 커밋 6회가 전부 화면이 보여야 하는 별개 전이였고, 달력 모델은 홈·일 요약·쓰기 커맨드 env까지 루트 소비자가 넓어 구독을 뗄 수 없었다. 오너가 "그러면 구조적으로 더 깔끔한 디자인이 있느냐"고 물었고, 캠페인 동안 본 달력 경로의 구조 문제 넷을 가치순으로 제안해 전부 승인받았다. 렌더 횟수가 아니라 원천·조립 위치·발행 단위·신호 모델을 정리하는 트랙이다.
2. 문제 제기
같은 화면이 상황에 따라 다른 원천을 봤다 — 분기가 네 군데 흩어져
조립 루트는 sessionsByDate·conditionByDate·selectedDateSessions·monthSummary를 각각 삼항으로 골랐다: 읽기 모델 (calendarReadModelStore)이 보이는 달을 가졌으면 그것, 아니면 plans 배열 파생(이름만 derived). 여기에 대기 저장 병합 2종과 일 요약의 원본 세션 폴백(읽기 모델 상세 → plans.find → 요약 카드)이 더해져, 한 결정이 일곱 곳에 복제돼 있었다.
조사 결과 plans = 앱 셸 부팅 RPC의 PLANNED_WORKOUTS로 완료+계획 세션 카드 전부(레거시 이름)이고, 읽기 모델은 일지 탭에서만 달을 적재한다(active: tab === "calendar"). 그래서 홈 탭의 달력 값은 부팅 목록 파생이 맞고, 폴백을 없애려면 홈에서도 달을 적재해야 해서(부팅 RPC +1) 홈 로딩 예산 판단이 먼저다. 일지 탭은 적재 전 pending 스켈레톤이라 부팅 값이 보이지 않는다.
셸에 달력 몫이 60줄 섞여 있었다
GymMobileApp에 주/월 드로어 state, 주·월 세션 합성, 범위 합계, 범위 적재 이펙트, isoDatesFrom이 있었고 세트 목적 구간은 memo 없이 셸 렌더마다 5번 재계산됐다.
스토어가 배열 통째를 발행해 한 날짜가 와도 전 달이 재투영됐다
publishModels는 캐시가 조금만 바뀌어도 dayModels·monthModels·rangeModels 세 배열을 새로 만들고, 어댑터는 배열 정체성이 바뀌었으니 buildCalendarUiProjection으로 캐시된 달 전부(최대 MAX_CACHED_MONTHS)를 다시 걸었다. 캐시 재사용 로드도 새 배열 = 통지였다.
로딩 신호가 세 벌이었다
loadingMonthKeys·foregroundMonthKeys 두 배열 + 카운트 맵 둘 + syncMonthLoading의 정렬·비교 코드(그리고 별개 자원인 selectedDayLoadingDate).
3. 해결 방안
원칙 (오너 결정, 2026-08-24)
- D1 "제안대로 1부터 4까지 쭈욱 중단없이 진행" — 채택. Phase당 커밋 1, PR 1.
- 렌더 입자성 D4("눈에 보이는 디자인 불변 전제로 ui/** 수정 가능")가 유효했으나 필요하지 않았다 — 화면 무접촉.
접근
| 대안 | 판단 |
|---|---|
| 부팅 파생 폴백을 없애고 읽기 모델 단일 원천 | 홈에서 달 적재 = 부팅 RPC +1 → 홈 로딩 예산 트랙과 충돌. 기각(조건부 후속) |
| 분기를 한 결정으로 모으고 이름을 정직하게 | 채택 — calendarSource + bootstrap* 개명, 순수 모듈 + 계약 |
| 달력 화면 바인딩 컴포넌트 | 주 선택을 리포트 탭도 써서 화면 바인딩이 맞지 않음 → 훅으로 |
| 배열 발행 유지 + 어댑터 memo만 | 배열 정체성이 매번 바뀌어 memo가 못 걸림 → 키별 맵 + 구조 공유가 전제 |
| 로딩 신호에 선택일까지 합치기 | 키 공간이 다름(월 vs 날짜) — 월 신호만 통합 |
4. 적용한 내용
Phase 1 — 달력 뷰 선택자 단일화 (services/calendarViewSelector.ts)
selectCalendarSource(readModel) → read-model | bootstrap. mergePendingWorkoutSessionsByDate(appController에서 이전)· mergePendingWorkoutSessionsById(대기 없으면 같은 객체 — memo 정체성), selectCalendarView(컨디션은 부팅 위에 읽기 모델을 덮음, 선택일은 병합 목록 우선, 월 요약은 원천 따름), resolveDaySummarySessions(읽기 모델 상세 → 부팅 목록 → 요약 카드). 루트는 derived* → bootstrap*, 삼항 4개 → 호출 1개.
Phase 2 — 주/월 저널 선택 훅 (features/calendar/useCalendarJournalSelection.ts)
verbatim 이동 + 주·월 세트 목적 구간 memo. 계약 앵커 2건 재조준(범위 적재 이펙트 위치, 프리로드 슬라이스 종료 앵커).
Phase 3 — 키별 발행 + 달 단위 memo 투영
스토어: sharedModelMap이 캐시 키별로 맵을 만들되 항목이 전부 같은 객체면 이전 맵을 돌려주고, 세 맵이 모두 같으면 publishModels는 통지하지 않는다. rangeKeyOf export. 매퍼: buildCalendarUiProjectionByMonth가 날 모델을 달별로 묶어 buildCalendarUiProjection을 달 단위로 돌리고 얕게 병합(날짜·id가 서로소). memo 키 = (달 모델 정체성, 그 달의 날 모델 정체성들, 보이는 달 여부). 어댑터는 memo를 스토어(오너)당 하나 들고, 범위 조회는 rangeModelsByKey[rangeKeyOf(from,to)].
Phase 4 — 월 로딩 신호 통합
monthLoadCounts: Map<key, {foreground, background}> + monthLoadByKey: Record<key, "foreground"|"background">. 같은 달을 전경과 배경이 겹쳐 요청하면 전경. 큐 대기 키는 배경. 어댑터가 loadingMonthKeys/foregroundMonthKeys를 파생해 renderCtx 표면(데스크톱 월 스켈레톤)은 그대로.
주요 결정과 그 근거
- 부팅 파생 폴백은 없애지 않고 "이름 붙은 결정 한 곳"으로 고정 — 홈 로딩 예산이 먼저다.
- Phase 3의 동등성은 계약으로 증명했다: 같은 입력에 대해 배열 통째 투영과 달 단위 병합 결과가
deepEqual. - memo 키에
visibleMonthKey를 넣었다 — 월 요약을 보이는 달이 소유하므로, 보이는 달이 바뀌면 두 달이 재계산되는 것이 정상.
작업 중 드러난 것
publishModels가 무변화 시 통지를 생략하므로 "로드하면 반드시 통지"를 가정한 테스트는 깨진다 — 변화 시 통지로 읽을 것.- 매퍼 픽스처는
calendarReadModelMapper.test.mjs의completedSession()모양(exerciseIds·strength 집계 필드)이 있어야completedSessionToCalendarEntry가 죽지 않는다. - bash
node -e "..."안의 백틱·${}는 셸이 명령 치환으로 먹는다 — 템플릿 리터럴이 든 파일은 Write 툴로.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 달력 원천 분기(루트) | 삼항 4 + 병합 2 + 폴백 1 산재 → 결정 1(calendarSource) + 순수 모듈, 계약 5 |
| GymMobileApp 달력 몫 | ~60줄 혼재 → 훅 1, 세트 목적 구간 memo |
| 스토어 발행 | 배열 통째·항상 통지 → 키별 맵·구조 공유·무변화 무통지 |
| 투영 재계산 | 캐시된 달 전부 매번 → 바뀐 달만 |
| 월 로딩 신호 | 배열 2 + 카운트 맵 2 + 정렬 비교 → 맵 1 + 종류별 비행 수 |
| 미적재 월 이동 커밋 시간 합(개발 React, CPU 4×) | ≈300ms → ≈200ms(런 간 편차 있음), 적재 월 52 → 31ms — 커밋 수 6은 설계대로 불변 |
| 검증 | 전 체인 1820/1820 · 로컬 e2e 8/8 · 브라우저 회귀 CASE-001·002·004·008·011·016(5/5 ×2) · 일지 월/주 드로어 스모크(2/2 세션·3,040kg, 1/1·1,440kg, 오류 0) · CI verify=pass |
| 미검증 | 실기기 체감(절대 시간은 개발 React 값) |
6. 이번 개선으로 향상된 것
달력이 무엇을 보는지가 한 줄로 읽힌다
calendarSource가 read-model인지 bootstrap인지 — 달력 버그의 원인 후보가 한 결정으로 줄었고, 폴백 순서가 계약으로 잠겼다.
월 이동이 가벼워졌다
날짜 상세 하나가 도착해도 다른 달은 다시 계산하지 않고, 캐시 재사용 로드는 루트를 깨우지 않는다.
구조적으로 남는 것
calendarViewSelector계약 5 — 원천 결정·병합 우선순위·폴백 순서.- 키별 맵 + 구조 공유 — 이후 어떤 달력 소비자도 달 단위로 memo할 수 있다.
- 로딩 신호 맵 — "키 → 종류" 하나. 전경/배경 겹침 규칙이 계약.
남은 것
- 부팅 파생 폴백 제거(홈에서 달 적재)는 홈 로딩 예산 판단 뒤에 — 하면
calendarSource가read-model하나가 된다. - 프리페치 "발행 + 완료" 통지 한 쌍 병합(6 → 5)은 실익이 작아 보류.