Skip to content

통계 중앙화 — 자주 쓰는 통계의 정본을 DB로, 화면은 표시기로 (2026-08-22)

  • 기간: 2026-08-21 ~ 08-22 (세션 1개 — 감사·설계서 → Phase 1~4-4 연속 진행, 컨텍스트 압축 2회)
  • 랜딩: PR #555(Phase 1) · #558(Phase 2) · #561(Phase 3, 마이그레이션 20260821290000· 300000) · #563(Phase 4-1, 340000) · #567(Phase 4-2, 350000) · #573(Phase 4-3, 410000) · #581(Phase 4-4, 420000). 마이그레이션 7건 Production 적용, 매번 check:remote-schema 0
  • 설계서: Phase 0 전수 지도 + 오너 결정 D1~D6(artifact bc37dff2) — 본 문서가 완성본
  • 도구: scripts/generate-strength-standard-cutlines.mjs(근력 등급 컷라인 오프라인 생성기, --write/--check)
  • 계약: docs/data/app-screen-rpc-contract.md(get_calendar_range_summary 절, Home profile_summary.volume_level, PR 오버뷰 cutlines/profession_standards, Volume v4 출석 저장값 절), docs/data/rpc-catalog.md

1. 배경

오너가 2026-08-21 물었다 — "달력에서 통계를 따로 계산하고, 리포트에서 따로 계산하고 그러고 있나? 자주 쓰고 중요한 통계들은 가운데에서 관리하고, 프론트에서 직접 계산하는 건 최소화했으면 좋겠다."

레포의 원칙 자체는 이미 그렇게 쓰여 있었다. app-screen-rpc-contract.md는 "DB가 계산하고 프론트는 재계산하지 않는다"를 명시하고, 1차 화면(Home·달력 월/일·PR 오버뷰·볼륨 리포트· 로그테이블 셀)은 materialized 프로젝션을 화면 RPC로 읽는 구조가 잡혀 있었다. 전수 감사는 그 원칙이 2차 화면과 정책 영역에서 새고 있었음을 드러냈다.

2. 문제 제기

"폴백"이 정본처럼 쓰이고 있었다

같은 UiDayDetailBody가 일지에서는 서버 일별 요약을 받는데, 리포트에서는 summary를 안 넘겨 전량 프론트 재계산으로 그려졌다(mobileApp.tsx:830 한 줄 누락). 세션 상세 시트는 서버 per-set 프로젝션(get_session_detail v5의 set_intensity_percent·stimulus_class)이 있는데도 local-epley로 e1RM을 다시 추정했고, 그 Epley 공식은 서버의 rep-curve(Nuzzo) v2와 다른 답을 냈다. 인접한 두 화면의 도넛이 다른 기준으로 돌고 있었다.

같은 계산이 세 벌이었다

모바일 DayDetailBody·데스크톱 DesktopSessionDetailParts/lib/rangeView·서버가 합산· 세트 목적 존·탑세트를 독립 구현하고 있었고(주석에 "코드 참조/공유 없음"), 컴포짓 랩 규칙·잔디 레벨 컷(.34/.67 vs .4/.75)·계획 세트수(전 세트 vs 메인만)가 갈려 있었다. 세션 볼륨/메인 세트수 매퍼도 4벌이었다.

서버 값이 있는데 프론트가 다시 세고 있었다

PR 종목 상세의 YoY·마지막 PR 경과일·올해 PR 수는 오버뷰에 yoy_1rm/last_pr_at이 있는데도 RecordsDetail.tsx·DesktopPrDetailParts.tsx가 기록을 순회해 구했다. 세트 목적 존은 summary.*_set_count가 있는데도 프론트가 분류를 다시 돌렸다.

정책이 프론트에만 살았다

볼륨 레벨/XP 표(gamificationPolicy.ts+volumeLevel.ts), 근력 등급 컷라인·백분위 (strengthStandards.ts — 앵커 5점에서 런타임마다 정규분위 2차 적합), 직업 합산 컷라인, 출석 정책(주 3일)·스트릭은 서버에 대응물이 없어 프론트가 사실상 정본이었다. 클라이언트가 둘 이상이면 같은 숫자가 다른 레벨·등급으로 보일 수 있는 구조였다.

DB 읽기 경로도 중복·사체가 있었다

PR 카운트를 네 곳이 따로 셌고, get_calendar_day_summary는 intensity/rpe 분포를 계산한 뒤 버렸다. user_exercise_monthly_stats는 쓰기만 있고 읽는 RPC가 0, user_exercise_pr_records는 작성자가 0인데 get_exercise_pr_history.is_pr가 읽어 상시 false, user_exercise_estimated_1rm_records는 빈 테이블이었다.

3. 해결 방안

원칙 (오너 결정 D1~D6, 2026-08-21)

  • D1 — 서버 프로젝션이 없으면 "—": 프론트가 대신 계산하지 않는다. 폴백은 표시 공백이지 대체 계산이 아니다.
  • D2 — 게이미피케이션 정책도 DB로: 레벨/XP·등급 컷라인·직업 합산·출석·스트릭.
  • D3 — 시각 변화가 없으면 ui/** 수정 허용.
  • D4 — 주/월 범위 합계는 서버 RPC.
  • D5 — 죽은 프로젝션은 드롭.
  • D6 — 순서는 Phase 1→2→3→4, 중단 없이.

접근

  • Additive 우선: 화면 RPC에는 필드만 더한다(계약 버전 불변). 새 RPC는 독립 계약으로 등재(get_calendar_range_summary v1).
  • 함수는 전체 본문 재발행, postcheck는 손댄 객체만: pg_proc.prosrc로 배선을 단언하고 전역 불변식은 걸지 않는다(새 DB에서만 참인 불변식이 Production push를 죽인 전례).
  • 계산을 옮기는 대신 값을 옮길 수 있으면 값을 옮긴다: 근력 컷라인은 오너 정정에 따라 SQL로 재구현하지 않고 오프라인 생성기가 한 번 계산한 값을 데이터로 보관했다(§4 Phase 4-3).
  • 번호는 Production 꼬리 위로: push 직전 migration list --linked 꼬리 확인, 아래면 다음 빈 번호로 재번호(두 번 있었다: 310000→340000, 390000→410000).

4. 적용한 내용

Phase 1 — 최단 수리 (#555)

  • 리포트 → 주 드로어에 서버 selectedWeekSummary 전달(mobileApp.tsx + ReportScreen.tsx 포워딩 2줄), 계획 세트수 메인만(sessionController.ts).

Phase 2 — 폴백 지위 재정의 (#558)

  • ui/shared/clientStrengthFallback.ts에서 Epley 제거 — 서버 per-set set_intensity_percent 만 투영(projectedSets·unprojectedSetCount·averageProjectedIntensityPercent). 세션 시트 존은 서버 강도·stimulus_class, 일 드로어 매퍼도 서버 stimulus_class 전용.
  • services/sessionAggregateFormulas.ts 신설로 합산 공식 4벌을 1벌로(isMainSet· completedSetVolumeKg·mainSetCount·plannedSetsVolumeKg 등). dead exports 4개 은퇴.

Phase 3 — DB 읽기 경로 정리 + 죽은 프로젝션 은퇴 (#561, 290000·300000)

  • user_pr_event_count_v1(uuid, date, date) 한 곳 — get_home_dashboard·get_pr_overview가 사용. get_calendar_day_summary의 폐기 분포 블록 제거. get_exercise_pr_history_v3_core.is_pruser_exercise_pr_events로.
  • 테이블 3종 드롭(user_exercise_monthly_stats·user_exercise_pr_records· user_exercise_estimated_1rm_records) + 참조 함수 5개 전체 본문 재발행 + validate_user_exercise_stats_integrity_epley_legacy 드롭. 클라이언트 디버그 로더·타입 동반 제거.

Phase 4-1 — 범위 합계 RPC + PR 상세 서버 필드 (#563, 340000)

  • get_calendar_range_summary(p_from, p_to): user_calendar_day_summaries 가산(≤93일, 22023 가드, 가중 평균 강도, planned_session_count, days[], payload ≤64,000B). 프론트는 계약·어댑터·예산(calendarRangeSummary)·레포·도메인·API·스토어(rangeModels· ensureRange)·컨트롤러(summarizeCalendarRange 서버 우선, 없으면 aggregateCalendarDaySummaries 폴백)·컨테이너 ensure 효과(모바일 주·월, 데스크톱 주)까지 배선.
  • build_exercise_pr_detail_core_v3 summary에 year_pr_count·last_1rm_pr_at·yoy_1rm additive → RecordsDetail.tsx·DesktopPrDetailParts.tsx의 기록 순회 재계산 제거.

Phase 4-2 — 볼륨 레벨/XP 정책 DB 승격 (#567, 350000)

  • volume_level_progress_v1(numeric) immutable — Lv.1~30 비용표·Lv.31 100,000kg·+500/레벨, level·next_level_at_kg·progress_percent 등 10키. get_home_dashboard.profile_summary.volume_level additive. 프론트 volumeLevelProgressFromServer는 매핑만, 홈 카드·PR 레벨은 서버 값만 (없으면 비움). 참조 구현은 계약 테스트의 기대값 생성용으로만 남김.

Phase 4-3 — 근력 등급 컷라인 사전 계산 (#573, 410000)

  • 처음 보류했던 항목이다 — "JS 적합·반올림을 SQL로 옮기면 반올림 경계에서 등급이 1칸 어긋날 수 있어 시각 불변을 증명하기 어렵다"고 보고했고, 오너가 정정했다: "한 번 오프라인으로 싹 계산한 다음 그걸 들고 있어야지, strengthlevel 데이터에서 매번 추출하면 어떡해."
  • scripts/generate-strength-standard-cutlines.mjs: 레포 seed(앵커 28행 + pr_summary 멤버십 10키)를 읽어 프론트가 쓰던 바로 그 함수(deriveStrengthStandardCutlinesFromAnchors) 로 종목 28행 + 직업 합산 8행(4직업 × 2성별)을 계산해 마이그레이션 마커 사이에 찍는다. UPDATE는 앵커 값까지 where에 넣어 DB 앵커가 seed와 다르면 기입되지 않는다.
  • exercise_strength_standards.cutlines + stale 가드 트리거(앵커가 바뀌면 null), strength_profession_standards, get_pr_overviewstrength_standards[].cutlines· profession_standards additive. 런타임 strengthStandardCutlines는 서버 값만(9칸·백분위 사다리·단조 검증), 직업 합산 폴백 2종 제거. 등급 판정·등급 내 백분위 보간(비교·선형보간)은 저장값 위에서 클라이언트가 계속 한다.

Phase 4-4 — 출석 저장값 + 스트릭 제거 (#581, 420000)

  • 오너 지시: "주/월마다 매번 계산하지 말고, 한 주·한 달이 지났으면 출석수를 세서 저장해 놓고 갖다 쓰자. 스트릭은 안 쓸 거니까 없애도 된다. 종목 필터 리포트는 저런 통계를 쓰면 안 될 것 같으니 빼자."
  • user_training_period_stats(주·월·분기·년 운동일 수가 이미 materialized)에 저장 컬럼: week 행 attendance_met, month/quarter/year 행 attendance_week_count/attendance_week_total. apply_training_attendance_policy_v1(user, from)이 채우고 refresh_user_training_period_stats_from이 행 재구축 끝에 같은 증분 창으로 호출, 기존 유저 전원 1회 백필. 정책 상수 attendance_weekly_target_days_v1()=3·attendance_monthly_target_days_v1()=12. get_volume_overview v4 training_periods 행에 additive.
  • 한 주는 목요일이 속한 기간에만 귀속(ISO 8601). 모바일 reportConsistency는 선택 기간 행의 저장값만 쓰고 일별 재묶기·월요일/ISO 주 헬퍼·스트릭(maxRun)을 전부 제거, 종목 필터 리포트는 출석 통계를 내지 않는다. ReportScreen은 분모가 없으면 셀을 비운다.

주요 결정과 그 근거

  • 폴백은 "—"(D1): 대체 계산이 남아 있으면 그게 정본이 된다. Phase 2 이후 프론트에 e1RM 공식이 없고, Phase 4-3 이후 컷라인 적합이, Phase 4-4 이후 출석 계산이 없다.
  • 컷라인은 계산이 아니라 데이터: 값이 곧 오늘 화면 값이라 JS↔SQL 수치 등가 문제가 사라진다. 앵커가 바뀌면 트리거가 null로 떨구고 생성기를 다시 돌린다.
  • ISO 목요일 귀속: 주를 월·분기·년에 저장하려면 한 주가 정확히 한 기간에만 속해야 한다. 종전 모바일 월 리포트는 "달과 겹치는 월요일 주 전부"를 셌기 때문에(2026-08 = 6주) 저장값(4주)과 분모가 달라진다 — PR 본문에 정직 보고.
  • 종목 필터에는 출석 없음: 출석은 사용자 전체 기준 지표라 종목별로 쪼개지 않는다(오너). user_exercise_period_statsday_count가 없어 서버도 줄 수 없었다.
  • day summary의 세션-종목 원본 재계산은 유지: 계약("제한 전 전체 원본을 DB에서 계산")상 의도된 즉시성 — 폐기 분포 블록만 제거. calendar_strength_session_metrics_map_v1의 rollups 전환은 e1RM 정책 계약 테스트("관측 한 번 집계")와 충돌해 제외.
  • 시각이 바뀌는 통일은 Design 몫으로 보류: 컴포짓 랩 규칙(모바일 vs 데스크톱), 잔디 레벨 컷(.34/.67 vs .4/.75).

작업 중 드러난 것

  • CI verify에는 npm run check 밖에 check:unused(TS unused 래칫) 스텝이 따로 있다. 로컬 전 게이트 초록인데 CI 레드가 한 번 났다(isDatedPrRecord 미사용). push 전 npm run check && npm run check:unused.
  • 매니페스트 리터럴을 단언하는 테스트(#560의 EXPECTED_LATEST_MIGRATION = "…330000")는 다음 마이그레이션마다 깨진다 — >= 하한으로(#566이 먼저 완화).
  • main 경주: #563은 머지 시도 사이에 main이 두 번 움직여(#560 330000·#566 335000) 리베이스 3회, #573은 #572(390000·400000)에 번호를 선점당해 410000으로 재번호. 충돌은 늘 schema.sql(재생성)·deploymentManifest.ts(main 판 + 내 번호). 이 레포는 auto-merge가 꺼져 있어(enablePullRequestAutoMerge 거부) CI 초록 즉시 수동 머지가 유일한 대응.
  • tests/support/schemaSql.mjstableExists는 따옴표 덤프 형식("public"."t")만 인식 — 소문자 DDL 테이블은 정규식으로. pgTAP jsonb_array_elements(...) with ordinality는 열 별칭(entry(cutline, ordinality)) 없이 entry->>를 쓰면 record ->> unknown.
  • dual-key 게이트는 증가도 거부 → 서버 snake 키만 읽는다. 새 파일의 explicit anyts-boundary가 거부. JsonValue 타입에서 requireObject를 쓰려면 payload 타입에 필드를 먼저 선언해야 한다(HomeDashboardVolumeLevelPayload).
  • 직업 멤버 10키 → 종목 ID 매핑은 home_benchmark(5키)가 아니라 exercise_business_role_membershipspr_summary 역할에 있다. 볼륨 오버뷰는 v1_core→v2_core→v3_core→annual_v3→v4 다섯 겹 — 기간 행 additive는 v4 래퍼의 stats 조인으로 충분했다.
  • HQ 세션으로의 SendMessage는 4회 모두 닿지 않았다(세션 목록 미표시) — 랜딩 통지는 트랜스크립트에 남기고, 08-22 저녁 HQ 종료 통지 이후 번호 규칙은 자율 적용.

5. 적용 결과

  • 마이그레이션 7건(290000·300000·340000·350000·410000·420000 + Phase 4-1의 재번호 이력) Production 적용, 꼬리 20260821420000, check:remote-schema 매회 0.
  • DB: 함수 신설 7(user_pr_event_count_v1·get_calendar_range_summary·volume_level_progress_v1· exercise_strength_standards_cutlines_guard_v1·attendance_weekly/monthly_target_days_v1· apply_training_attendance_policy_v1), 테이블 드롭 3·신설 1(strength_profession_standards), 컬럼 5(cutlines·cutlines_generated_at·attendance_met·attendance_week_count· attendance_week_total), 화면 RPC 1 신설 + 4 additive(Home·PR 오버뷰·PR 상세·Volume v4).
  • Production 실측: 컷라인 28행 기입·누락 0·직업 8행(남성 스쿼트 [0,100,…,290], 남성 파워리프터 [0,295,…,835] — 종전 프론트 값 그대로), volume_level_progress_v1(307500) = Lv.10 50%, 출석 주 행 403건 전부 채움·월/분기/년 공백 0·2026-08 분모 4주.
  • 프론트: e1RM 공식·컷라인 적합·출석 계산·스트릭이 런타임 경로에서 사라짐, 합산 공식 1벌, dead exports 4 은퇴, 테스트 신규 7파일(statsCentralizationPhase1/2/3SqlContract/4/4_2/4_3/4_4)
    • pgTAP 3(volume_level_policy_v1·strength_standard_cutlines_v1·attendance_policy_v1), 최종 전체 게이트 1,711/1,711.

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

정본이 한 곳이다

레벨·등급·출석·범위 합계·PR 카운트의 답이 DB 한 곳에서 나온다. 모바일·데스크톱·관리자· 추후 알림이 같은 숫자를 읽고, 정책을 바꾸는 일은 마이그레이션 한 건이다.

화면은 표시기다

서버 값이 없으면 비우지 대신 계산하지 않는다(D1). 배포 전환 중 화면마다 다른 레벨이 보이는 창이 닫혔다.

읽기 경로가 가벼워졌다

PR 카운트 1곳, 폐기 분포 제거, 죽은 테이블 3개·레거시 검증기 드롭. 리포트 드로어는 서버 summary를 받고, 주/월 합계는 RPC 1회다.

비싼 계산은 한 번만 한다

컷라인은 생성기가, 출석은 주·월이 지날 때 DB가 센다. 런타임 적합·일별 재묶기가 없다.

남은 것

  • 데스크톱 자체 계산: 볼륨 리포트 "F" 섹션의 출석·"최장 연속" 셀, 홈 "주 3회 이상 N/52주" KPI는 데스크톱이 스스로 세고 있다 — 오너: 추후 fade out.
  • 등급 판정·등급 내 백분위 보간은 저장 컷라인 위의 비교·선형보간으로 클라이언트에 남아 있다(서버로 옮기려면 PR 오버뷰 종목 요약에 유저 PR 기준 등급을 싣는 확장).
  • 시각 통일(Design 몫): 컴포짓 랩 규칙, 잔디 레벨 컷.
  • 모바일 월 리포트 분모 변경(겹침 주 → ISO 귀속) — 사용자 공지 여부는 오너 판단.
  • 종목별 출석 통계는 제품에서 제외(오너 결정). 재도입하려면 user_exercise_period_statsday_count부터.