Skip to content

달력 렌더 구조 정리 — 루트에 흩어진 원천 분기에서 뷰 선택자·키별 발행·로딩 신호 맵까지 (2026-08-24)

  • 기간: 2026-08-24 (세션 1개 — 렌더 입자성 종결 직후 오너 "달력 페이지 렌더 관련해서 구조적으로 더 깔끔한 디자인 추천" → 제안 4건 → "제안대로 1부터 4까지 쭈욱 중단없이 진행")
  • 랜딩: PR #630(d3a63438 — Phase당 커밋 1: Phase 1 01182d08 · Phase 2 118e649a · Phase 3 ce742108 · Phase 4 d3603d9f). 마이그레이션·엣지 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.ts buildCalendarUiProjectionByMonth
  • 도구: 렌더 입자성 계측기(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.mjscompletedSession() 모양(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. 이번 개선으로 향상된 것

달력이 무엇을 보는지가 한 줄로 읽힌다

calendarSourceread-model인지 bootstrap인지 — 달력 버그의 원인 후보가 한 결정으로 줄었고, 폴백 순서가 계약으로 잠겼다.

월 이동이 가벼워졌다

날짜 상세 하나가 도착해도 다른 달은 다시 계산하지 않고, 캐시 재사용 로드는 루트를 깨우지 않는다.

구조적으로 남는 것

  • calendarViewSelector 계약 5 — 원천 결정·병합 우선순위·폴백 순서.
  • 키별 맵 + 구조 공유 — 이후 어떤 달력 소비자도 달 단위로 memo할 수 있다.
  • 로딩 신호 맵 — "키 → 종류" 하나. 전경/배경 겹침 규칙이 계약.

남은 것

  • 부팅 파생 폴백 제거(홈에서 달 적재)는 홈 로딩 예산 판단 뒤에 — 하면 calendarSourceread-model 하나가 된다.
  • 프리페치 "발행 + 완료" 통지 한 쌍 병합(6 → 5)은 실익이 작아 보류.