Skip to content

종목별 핵심 기록 지표 직접 입력 — 풀업 22회·플랭크 90초·5km 25분을 1RM 직접 입력과 같은 자리에서 (2026-09-03)

  • 기간: 2026-09-03 (세션 1개, 오너 결정 원문 "서버 확장해야지. 종목마다 그 핵심 기록필드 있잖아. 그거를 입력할 수 있게 해야함. max rep이라던지 max hold라던지, 짧은 시간이라던지" + Q1 이름 유지 · Q2 거리+시간 한 쌍 · Q3 데스크톱도 적용)
  • 랜딩: PR #1206 (Phase 1~4, 0b545857) — 마이그레이션 20260907235900_manual_record_metrics_v1.sql, Production 적용은 릴리스 레인
  • 설계서: 없음 — 분석·Phase 계획 = 이슈 #1194 본문(#1184 D2에서 분리)
  • 정본: docs/contracts/record-metric.md §직접 입력 · 표 user_manual_record_metrics · record_metric_kind_v1 · save_manual_record_metric_v1/delete_manual_record_metric_v1 · 시간선 4개(user_exercise_max_rep_pr_timeline_v1·record_metric_hold_pr_timeline_v1·record_metric_cardio_longest_timeline_v1·record_metric_cardio_by_distance_v1)
  • 도구: 조사 서브에이전트 1회(1RM 표 읽기 8곳 지도), 워크트리 vite 하니스(커밋 제외), 랜딩 잠금(§15)
  • 게이트: pgTAP manual_record_metrics_v1.test.sql(41) · manualRecordMetrics.test.mjs(신설) · manualPrRecordsList·mobileExperienceMapperModules 재조준 · check:dual-key(snake 폴백 1건 제거) · limitsRegistry 장부 3행 · designContract 마커 prManualAddTime
  • 버그리포트: 없음
  • 계약: record-metric.md §직접 입력 신설 · writeContracts.ts ManualRecordMetricWriteDto·Save/DeleteManualRecordMetricInput · screenRpc.ts 실측 시간선 행 achieved_on null(manual만)·manual? · docs/data/limits-registry.md 3행 · docs/data/rpc-catalog.md 2행

Phase 현황

Phase내용상태
Phase 0조사 — 지표 4종의 서버 원천, 1RM 표 읽기 8곳의 kg 전제, 시간선이 읽을 때 계산됨을 확인 → 별도 표 설계✅ 이슈 #1194 본문
Phase 1서버: 표·RLS·트리거·지표 판정·저장/삭제 RPC✅ PR #1206 (50ae398c)
Phase 2서버: 시간선 4개 직접 입력 합류, payload manual 키, pgTAP 41✅ PR #1206 (50ae398c)
Phase 3클라이언트: 쓰기 계약·저장소/도메인/API·컨테이너 두 경로·검색 행 지표·모바일/데스크톱 폼·상세 행·문서✅ PR #1206 (9f4cad7b)
Phase 4로컬 게이트·하니스 실측·랜딩 잠금·번호 확정·CI·머지✅ PR #1206 (0b545857)
Phase 5릴리스 레인 Production 적용 → [반영완료]⬜ 릴리스 PR 대기

1. 배경

"내 1RM 기록 관리"는 무게(kg) 1RM만 받았다. 유저 A가 바벨릭을 쓰기 전에 풀업 22회, 플랭크 90초, 5km 25분을 했더라도 적을 곳이 없었다. 디자인 배송 v86(#1184)에 "최대 반복 횟수/회" 입력 분기가 들어왔지만 서버가 kg만 받아 잠가 둔 상태였고(#1184 D2), 오너가 "종목마다 정해진 핵심 기록 필드를 입력할 수 있게" 하라고 결정했다.

2. 문제 제기

2-1. 1RM 표에 다른 종류의 값을 섞을 수 없다

직접 입력 1RM 표(user_manual_pr_records)는 단위 CHECK가 kg 전용이고, 이 표를 읽는 서버 함수 8곳이 전부 "값 = kg 1RM"을 전제한다. 대표적으로 종목 통계는 직접 입력값이 추정 1RM보다 크면 대표값으로 승격시킨다. 플랭크 90초를 이 표에 넣으면 60kg 추정 1RM보다 "크다"고 판단해 대표값이 되는 식으로 통계가 오염된다. 클라이언트 어댑터 2곳도 직접 입력 1RM은 반드시 1회 반복이라고 단언한다.

2-2. 반복수·버티기·유산소 기록은 읽을 때 계산한다

최대 반복수는 관측 표에서, 최고 버티기·유산소 최장 거리·기준 거리별 최단 시간은 완료 세션의 세트에서 종목 상세를 열 때마다 계산한다(통계 표에 쌓지 않음). 즉 직접 입력 행을 그 계산에 합치기만 하면 되고, 1RM처럼 통계 재계산 큐를 탈 필요가 없다.

2-3. 지표 판정이 클라이언트에만 있었다

종목이 어느 지표인지(무게+횟수 → 1RM, 횟수만 → 최대 반복수, 시간만 → 버티기, 거리 → 유산소)는 클라이언트 recordMetricKindFor가 필수 입력 논리식으로 정한다. 서버가 저장을 받으려면 같은 판정이 서버에도 있어야 한다.

2-4. 직접 입력 행은 세션이 없고 날짜가 없을 수 있다

종목 상세 어댑터는 모든 기록 행에 날짜와 세트 id를 요구했다. 직접 입력 행은 세션·세트가 없고, 1RM 직접 입력처럼 "날짜 모름"을 허용해야 한다.

3. 해결 방안

원칙 (오너 결정 2026-09-03)

  • D2 서버 확장 — 종목마다 정해진 핵심 기록 필드를 직접 입력할 수 있게.
  • Q1 화면 이름 "내 1RM 기록 관리" 유지. Q2 유산소는 거리+시간 한 쌍을 받아 최장 거리·거리별 최단 시간 둘 다에 반영. Q3 데스크톱 PR 보드 모달도 같이.

접근

대안판단
A. 1RM 표에 metric_kind·단위 확장기각 — 읽기 8곳 전부 필터 추가, 누락 시 통계 오염. value ≤ 5000·numeric(8,2)도 거리(m)에 부족
B. 별도 표 + 읽을 때 세션 기록과 합침채택 — 1RM 체인 무변경, 반복수·버티기·유산소 계산 경로에 자연 합류
C. 직접 입력을 가짜 세션으로 저장기각 — 일지·피드·볼륨 통계에 유령 세션

4. 적용한 내용

Phase 1 — 서버 표·RPC

  • user_manual_record_metrics: metric_kind(max_reps·max_hold·distance_time) · value(회/초/m) · seconds(유산소 그 거리의 시간) · recorded_at(null = 날짜 모름). 종류별 CHECK(회 1~10,000 정수 · 초 ≤ 86,400 · m ≤ 1,000,000 + 초 필수), 소유자 RLS(select_own), 종목 정체성 트리거(세션·1RM 표와 같은 함수).
  • record_metric_kind_v1(exercise_id): 필수 입력 논리식의 "전부" 가지 잎/언급 잎을 모아 클라이언트와 같은 순서로 판정. save_manual_record_metric_v1은 이 판정으로 허용 지표를 정하고, 무게 1RM 종목은 "save_manual_pr를 쓰라"고 거부, 클라이언트가 보낸 지표가 다르면 거부. id 동반 = 수정, 같은 값·날짜 재시도 = 멱등(1RM과 같은 규칙).

Phase 2 — 읽을 때 합류

  • 직접 입력 행을 세션 세트와 같은 모양(record_metric_manual_rows_v1)으로 만들어 시간선 4개에 union. 정렬 열은 날짜, 날짜 모름은 모든 관측보다 앞('-infinity'), 출력은 null 유지. 반환 열에 manual boolean 추가(시그니처 변경 → drop 후 재생성). get_exercise_pr_detail 행마다 manual 키(추가만, 계약 버전 4 유지), 최신순에서 null은 맨 뒤.
  • pgTAP 41: 판정표 4종·거부 5종·멱등·id 수정·풀업(직접 입력 10회 → 세션 8회 기록 아님 → 12회 기록 +2)·버티기(날짜 모름 30초 맨 앞)·유산소(5.05km 25:00이 최장·5km 칸)·as_of 경계·payload 키·타 계정 삭제 거부·삭제 뒤 소멸.

Phase 3 — 클라이언트

  • 쓰기 계약·저장소(한도 사전검사 원인 코드 LG_RECORD_*)·도메인·API·appController(목록 상태, 저장·삭제 뒤 열린 종목 상세 재적재).
  • 모바일: 화면 입력의 지표로 두 저장 경로(무게 1RM / 기록 지표) 분기, 목록은 두 표를 합쳐 지표별 표기, 폼은 지표별 값 칸(회 / 분·초 / km + 분·초), 수정 프리필·삭제 경로 분기. 데스크톱: 모달 같은 분기(카탈로그 행의 metricKind로 종목 선택 시 값 칸 전환).
  • 검색 결과 행에 metricKind 공급 — 카탈로그 정규형(requiredInputs·fields)으로 판정해 toSearchItem까지. 어댑터는 manual 행만 날짜 null 허용, 매퍼는 manual 행을 날짜 없이도 통과, 종목 상세 PR 목록 행에 "직접 입력" 태그·"날짜 모름"·세션 열기 없음.

작업 중 드러난 것

  • 배송 v86의 반복수 분기는 검색 행에 지표가 없어 잠들어 있었다 — 이번에 카탈로그 정규형으로 지표를 실어 살렸다. snake 폴백(item.required_inputs)을 함께 두면 check:dual-key가 거부한다 — 정규형 한 형태만.
  • check:renumber는 브랜치가 main 꼬리(20260907005900)를 모르면 멈춘다 — 리베이스 뒤 실행. 생성 파일(schema.sql·manifest) 충돌은 상류를 받고 renumber가 재계산.
  • 디자인이 지운 모달 제목 "1RM 직접 입력"은 지표별로 바꿨다(최대 반복수 직접 입력 / 최고 버티기 수정 …). 화면 이름은 오너 Q1대로 유지.

5. 적용 결과

항목
직접 입력 가능 지표1종(kg 1RM)4종(kg · 회 · 초 · m+초)
1RM 통계 체인 변경0(표·읽기 8곳 무변경)
종목 상세 최고 기록에 직접 입력 반영없음시간선 합류(하니스·pgTAP: 풀업 10→12, 버티기 30→40, 유산소 5.05km 최장)
유닛 스위트2,463 pass · 실패 1(tabSwitchLatencyProbe, main 동일 CRLF)
pgTAP41 단언(CI에서 실행)

미검증: 데스크톱 모달 시각 품질(타입·앵커 테스트만), 실기기 입력 UX.

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

  • 유저가 앱 이전 기록을 종목 지표대로 적을 수 있고, 그 기록이 종목 상세의 최고 기록·PR 목록에 그대로 반영된다.
  • 지표 판정이 서버에도 생겨(record_metric_kind_v1) 클라이언트와 서버가 같은 표로 판단한다.
  • 1RM 표를 건드리지 않아 기존 통계 회귀 위험이 0이다.

남은 것

  • Phase 5: 릴리스 PR(main → production) 머지 시 Production 적용 → 이슈 [반영완료].
  • 데스크톱 종목 상세에는 반복수·버티기·유산소 기록 목록 자체가 없어(record-metric.md 잔여) 직접 입력 행도 보이지 않는다 — 별도 트랙.
  • 유산소 "거리별 최단 시간" 표는 기준 거리 ±3% 칸에만 들어간다(4.8km 입력은 어느 칸에도 없음) — 입력 폼에 기준 거리 안내는 후속.