Skip to content

Calendar Read-Model Architecture

Updated: 2026-09-10

Goal

운동 일지는 canonical workout write model과 화면 조회 모델을 분리한다. 날짜 선택은 전체 운동 원본, 전체 달력 상태, 홈 화면 데이터를 다시 조립하지 않아야 한다.

text
Canonical writes
  session -> session_exercise -> session_exercise_part -> exercise_set -> exercise_set_part
  (완료·계획·그룹 운동 계획이 같은 다섯 층; 계획은 status = planned 행 — 이슈 #1215, 2026-09-04)
          |
          v existing dirty-range stats queue
Derived read model
  user_calendar_summary_dirty_dates
          |
          v
  user_calendar_day_summaries
          |
          +-> get_calendar_month_summary(month)
          +-> get_calendar_day_summary(date)

Full edit detail
  get_session_detail(session_id)
  get_planned_session_detail(planned_session_id)

user_calendar_day_summaries는 진실 원본이 아니다. 원본에서 언제든 재생성할 수 있는 파생 캐시다.

Read Contracts

CalendarMonthSummary

  • 요청한 한 달의 최대 31개 날짜 합계
  • 날짜별 완료/계획 세션 수, 세트, 반복, 볼륨, 시간, PR
  • 월 카드용 완료 세션 요약과 sets_complete:false 계획 세션 카드
  • 계획 카드는 세트 수/종목 수/예상 볼륨/완전한 exercise id 목록만 포함하고 nested sets 제외
  • 완료 세트 상세 제외

CalendarDaySummary

  • 선택 날짜 합계와 condition
  • 같은 날짜의 완료 세션 최대 12개와 계획 카드 최대 12개
  • 각각 초과 시 sessions_truncated/planned_sessions_truncated; 날짜 합계와 분포는 전체 기준
  • 완료 세션의 화면 표시용 축약 계층은 세션당 entry 16개, entry당 set 12개, exercise id 24개로 제한
  • session/entry 텍스트, recording fields, composite metadata도 UTF-8/shape 상한과 하위 truncation flag를 가짐
  • 화면 배열이 잘려도 날짜/세션/entry 집계 scalar는 전체 canonical 원본 기준
  • 계획 세션은 월/Home과 같은 sets_complete:false 카드이며 set 수/종목 수/예상 볼륨/완전한 exercise id 목록만 포함
  • 계획 nested sets와 프론트 재계산 제외
  • top set, intensity, RPE 분포
  • 편집용 raw detail 제외

SessionDetail

  • 사용자가 세션을 열 때만 요청
  • 편집과 내보내기에 필요한 전체 exercise/set 계층
  • session id별 별도 캐시

PlannedSessionDetail

  • 사용자가 계획 편집을 시작할 때만 요청
  • auth.uid() owner-bound 단일 계획과 최대 24개의 exact editable sets
  • Home/month/day 카드와 달리 sets_complete:true; 읽기 시 truncate하지 않음
  • get_calendar_day_summary는 선택일 전체 표시, 이 RPC는 하나의 계획 편집을 담당
  • 수정·삭제는 상세의 server_revisionexpected_revision으로 요구하는 compare-and-swap이며 stale revision은 40001(이슈 #1215 전에는 updated_at)
  • session 행 직접 DML은 금지하고 save_session_v5/delete_session_v5만 허용(옛 save_plan_v3/delete_planned_session_v2는 LG426 스텁으로 폐기, 이슈 #1215 2026-09-04)

Plan adoption (계획 반영, 2026-08-23 · 20260821490000)

폐기(이슈 #1215, 2026-09-04) — 아래 계획 반영 기능은 통째로 제거됐다. planned_session_adoptions 테이블은 삭제되고 adopt_planned_session_v1/unadopt_planned_session_v1은 LG426 스텁만 남았으며, 달력·홈 응답에서 plan_owner·adopted 카드는 더 이상 나오지 않는다. 아래 문단은 과거 동작의 기록이다.

  • (과거) 팔로우한 사람의 계획을 내 달력에 링크로 반영했다(planned_session_adoptions(user_id, planned_session_id, owner_user_id, completed_at)). 복사가 아니라 링크라서 원작자의 수정은 그대로 보이고, 받은 사람은 수정할 수 없었다.
  • 달력 월/일 요약과 Home current_month.plans는 본인 카드 뒤에 반영 카드를 **같은 카드 모양 + plan_owner{id, display_name, handle}·adopted·adopted_at**으로 섞는다(활성 정의의 마지막 단계에서 합류: 카드 상한은 본인 카드가 남긴 자리만, 일별/요약 계획 카운트와 source_version에 더함). 완료된 반영(completed_at)은 빠진다.
  • get_planned_session_detail은 가시성 helper planned_session_visible_v1(본인·팔로워 — 반영자 구분은 폐기와 함께 사라짐)로 읽히고 비소유자 응답은 editable:false. (과거) save_workout_v4는 호출자 소유가 아닌 planned_session_id를 엔진에 넘기기 전에 떼고, 성공 후 반영 행만 완료 처리했다. 지금은 계획을 완료하면 save_session_v5같은 sessionplanned → completed로 바꾼다.
  • 친구 달력 읽기 get_following_calendar_month_v1(p_user_id, p_from, p_to)(≤42일)는 달력 읽기 모델 밖의 소셜 RPC다(계약은 rpc-catalog.md). 반영/취소 adopt_planned_session_v1/unadopt_planned_session_v1는 폐기(LG426 스텁).
  • (과거) 클라이언트: 월/일 카드 어댑터가 planOwner/readOnly/adopted를 싣고, planAdoptionStore가 친구 달력·반영/취소를 소유했다. 이 앱 코드도 이슈 #1215 Phase 6에서 제거됐다.

Client Data Flow

calendarReadModelController가 달력 조회 상태를 독점한다.

  • month cache key: calendar-month:{userId}:{yyyy-MM}에 해당하는 사용자별 컨트롤러 인스턴스
  • day cache key: calendar-day:{userId}:{yyyy-MM-dd}에 해당하는 사용자별 컨트롤러 인스턴스
  • month stale time: 5분, 최대 18개월
  • day stale time: 2분, 최대 90일
  • 동일 key 요청 dedupe
  • 날짜 변경 시 이전 day 요청 abort
  • 키별 generation으로 무효화 전 응답의 stale commit 차단
  • 사용자 전환/로그아웃 시 모든 cache, queue, 요청 제거

캐시가 있으면 즉시 기존 값을 유지하고 stale 데이터만 background refresh한다. 선택 날짜 응답은 request revision이 최신인 경우에만 화면에 반영한다.

<a id="journal-date-boundary"></a>

일지 선택 날짜의 경계 (#1529, v0.17.10)

2026-09-10 오너 결정: “일지에서 선택한 날짜는 일지 탭 내에서만 영향력이 있도록 제한”. 예를 들어 오늘 운동을 시작한 뒤 일지에서 지난달을 보고 돌아와도, 진행 중인 운동은 시작한 오늘 날짜로 저장한다.

calendarSelectedDate는 일지의 선택 표시·선택일 조회·월 탐색만 소유한다. 이 값을 메인·그룹 탭의 날짜, 진행 중 운동 날짜, 저장 날짜의 공통 상태나 대체값으로 사용하지 않는다. 다른 탭에서 운동을 시작하거나 이어가도 일지의 선택 날짜를 운동 날짜에 맞추기 위해 바꾸지 않는다.

진입 경로작성할 날짜의 원천
메인·그룹 보드에서 새 운동 시작시작 시점에 계산한 오늘을 draft.date로 확정
일지에서 과거 기록·계획 추가작성 시작 시 선택 날짜를 편집기로 한 번 명시적으로 전달
기존 기록·계획 수정선택한 원본 기록의 날짜
운동 이어하기·앱 재시작 복원저장된 초안/복원 봉투의 운동 날짜

일지에서 작성을 시작할 때 날짜를 전달하는 것은 허용하지만, 작성 중인 초안은 이후의 날짜 선택·월 이동을 구독하지 않는다. 자동 저장·최종 저장·기기 보존·서버 체크포인트의 날짜 보존 규칙은 운동 초안 보호를 따른다. 화이트보드 출처 날짜는 출처 정보이며 운동 날짜의 대체값이 아니다.

이 계약은 앱 PR #1539, 4d9705c6으로 release/v0.17.10에 반영됐다(2026-09-10). Production은 미적용이며 적용 기록에서 검증·release 병합·운영 배포를 구분한다.

Virtualization And Prefetch

UI의 월 가상화는 DOM 수명만 관리하고 read-model cache는 데이터 수명을 관리한다.

  • viewport와 overscan 월만 DOM에 존재
  • UI onEnsureMonth가 렌더 범위 월만 queue에 넣음
  • 현재 월의 이전/다음 월은 background prefetch
  • month queue 동시 실행 2개
  • 최신 요청이 높은 priority
  • DOM에서 사라진 월도 bounded LRU cache에는 유지
  • 스크롤 속도를 인위적으로 제한하지 않음

Write Invalidation

운동 저장, 수정, 삭제, 계획 변경, import 완료 후 달력은 app-shell 전체 재조회에 의존하지 않는다.

  1. 변경 session detail cache 무효화
  2. 원본 session/exercise/set/PR trigger가 실제 변경 날짜를 dirty-date table에 dedupe
  3. stats job이 dirty 날짜의 summary만 재계산
  4. 클라이언트는 기존 month/day 값을 유지한 채 stale 처리
  5. 해당 yyyy-MM month와 현재 선택된 day만 background 갱신

날짜가 이동된 수정은 이전 날짜와 새 날짜를 모두 무효화한다. 오프라인 queue 재전송도 성공한 날짜와 session id를 모아 동일한 경로를 사용한다.

홈/PR 등 아직 독립 mutation invalidation이 없는 화면을 맞추기 위한 app-shell summary 갱신은 과도기적으로 남아 있다. 달력 데이터의 정합성과 로딩은 이 갱신 결과에 의존하지 않는다.

Recovery And Integrity

  • refresh_user_calendar_day_summaries(user, from, to): 내부 dirty-range 재계산
  • user_calendar_summary_dirty_dates: 원본 변경 trigger가 실제 영향 날짜만 기록하는 private queue
  • repair_calendar_read_models(from, to)/check_calendar_read_model_integrity(from, to): 회수됨 (20260820120000, 오너 결정 2026-08-20 — 클라이언트·SQL 양쪽에 호출자가 없었다)
  • 드리프트 복구는 이제 표준 큐 경로 하나다: 마이그레이션 DO 블록에서 enqueue_user_exercise_stats_refresh + process_user_exercise_stats_refresh_jobs_for_user (20260812001400 패턴)
  • 최초 migration은 기존 완료 세션 사용자를 idempotent backfill
  • 기존 stats refresh queue의 retry와 dirty event 흐름을 재사용

복구 함수는 다른 사용자 id를 입력받지 않으며 RLS와 auth.uid() 소유권을 유지한다.

Observability And Budgets

개발 계측은 최근 200개 이벤트를 bounded ring buffer로 보관한다.

  • cache hit/miss
  • request dedupe
  • request complete/fail/abort latency
  • selected day ready latency

예산:

  • month summary: 1 RPC, 120KB, 500ms target
  • day summary: 1 RPC, 정상 120KB/350ms target, 구조적 hard ceiling 1.2MB
  • selected date 정상 경로: session-detail N+1 0회

Rollout

  1. 선행 00300 비동기 통계 migration 배포
  2. 선행 00400 idempotent workout write migration 배포
  3. 00500 화면 read model, backfill/정합성 검사, 계획 CAS RPC 배포
  4. get_calendar_month가 제거된 것을 확인
  5. v2 read model만 사용하는 프론트 배포
  6. 운영 계측에서 payload/latency 예산과 truncation 비율 확인

개발 단계 hard cutover이므로 미배포 RPC를 raw 월/세션 조회로 우회하는 fallback은 두지 않습니다.

Claude Design Handoff

src/react/ui/**의 시각 디자인은 Claude Design 소유다. Codex는 read-model props와 loading 계약뿐 아니라 승인된 디자인을 바꾸지 않는 최소 기능 배선을 소유한다. 상세 R&R은 docs/process/claude-free-design-contract.md를 따른다.

Claude 작업:

  • 날짜 셀에는 해당 날짜 summary와 selection boolean만 전달
  • 모든 월에 전체 sessionsByDate와 전역 selected object 전달 금지
  • 월 memo comparator는 month summary identity와 loading key만 비교
  • 선택 스타일은 크기/배치를 바꾸지 않는 paint-only 속성 사용
  • 우측 상세 loading overlay는 고정 크기 영역에 표시해 달력 측정을 건드리지 않음
  • selectedDayReadModel 직접 소비로 전환 후 legacy projection 제거 가능 여부 표시

완료 기준:

  • 같은 주 날짜 클릭에서 월 DOM 재측정 0회
  • 날짜 클릭당 day RPC 최대 1회
  • 빠른 날짜 연속 클릭에서 마지막 날짜만 표시
  • 월 스크롤 중 전체 카드 또는 앱 shell loading overlay가 나타나지 않음
  • 120개월 논리 범위에서도 viewport DOM 월 수가 bounded 상태 유지