Skip to content

"기록 보기"가 친구 세션·비슷한 이름 종목을 내 이력으로 보여 주던 것에서 서버 이력 RPC 한 곳·canonical 종목 id·색인 병합까지 — v0.18.0 A14 (2026-09-10)

  • 기간: 2026-09-09 ~ 2026-09-10 (세션 2개 — b55b728c-569d-4d7c-98ee-fe9e6cb1ea35 분석·계획·Phase 1 착수, 4b0f1d85-820d-4396-ad20-37eb6881b0f3 Phase 1 마무리~Phase 4). 오너 지시 "1414 진행해줘" → 분석·Phase 계획 게시 → "묻지 않고 Phase 끝까지 진행" → "#1414 이어서 작업해줘". 계획 ID A14 / Phase 3 스텝 3-2. 직접 선행 A02(#1329)·A03(#1330)·A06(#1407)·A09(#1342)는 착수 시점에 전부 release/v0.18.0(b074cbb1)에 병합돼 있었다.
  • 랜딩: 앱 PR #1503(base release/v0.18.0; Phase 1 099f3e0f · Phase 2 6edd7bc8 · Phase 3 19458bd8 · release 반영 병합 5611e77c·4e953796) → 큐 병합 커밋 209bbffd(2026-09-10 13:07 KST, 실행) — 마이그레이션 1건 20260914043000_exercise_history_set_atoms.sql(함수 재발행만, 표·열 변경·원본 DML 없음), 엣지 함수·Vercel 설정 변경 없음. release 통합·staging 확인·Production 배포는 서로 다른 상태다 — release/v0.18.0 통합은 끝났고(209bbffd), staging·Production 은 아래 §5 상태 표가 갱신한다.
  • 설계서: 없음 — 착수 분석·해결 방안·Phase 계획·"예상 효과·개선사항" 표는 #1414 착수 댓글(2026-09-10).
  • 정본: src/react/contracts/ports/exerciseHistoryDto.ts(요청·페이지·행·세트 typed 모양) · src/react/features/workout/history/**(계약 상수·loader·resource·병합·연도 창·view·port·binding) · src/react/services/domains/codecs/exerciseHistoryCodec.ts(서버 페이지 → typed 페이지) · 서버 supabase/definitions/pr/functions/get_exercise_pr_history_v3_core.sql · 이 문서 §4 "U02/U03 인계".
  • 도구: npm run db:explain -- --only pr.history_page(대표 조회 목록에 pr.history_page 등재) · scripts/performance/workload/load.mjs --workload history-10y(10년 fixture 적재, 레포 안) · 증거 파일은 레포 밖 C:\Users\USER\AppData\Local\Temp\claude-wt\1414-evidence\(gitignored 대상 아님 — 임시 폴더).
  • 게이트: 새 행동 테스트 6파일 40건 — exerciseHistoryRpcAtoms(4) · exerciseHistoryFeature(7) · exerciseHistoryResource(7) · exerciseHistoryMergeThroughput(1, 1/4/10년 처리량 로그) · exerciseHistoryBinding(5) · exerciseHistoryDrawer(5), pgTAP exercise_history_set_atoms_v1.test.sql(15 assert). 명부 등재 테스트 수정 1건(mobileExperienceViewMappers.test.mjs — 삭제된 매퍼 단언 제거, pending-changes.json 신고), 비명부 3파일 갱신(mobileExperienceMapperModules·workoutCalculations·pctBaseOneRmFallback). 로컬 pgTAP: 샌드박스 cil095e2658 에서 새 파일 15/15(2026-09-10 11:20 KST) · ci-local database 단계 137파일/2640 assert 중 새 파일 1건만 실패(plan 수 14→15 수정으로 해소). npm run check 3541 pass · Precheck(ci:precheck-local) 결과는 §5.
  • 버그리포트: 없음(구조 개선). 부수 수리: 달리기·플랭크 같은 종목의 세트 원자(거리·시간·칼로리·보조무게)가 이력 RPC 에 없어 이 RPC 를 원천으로 쓰면 "—" 로 보일 것을 Phase 1 에서 막았다.
  • 계약: app-screen-rpc-contract.md get_exercise_pr_history 절(세트 원자 4종·recording_fields 추가 전용, v4 유지) · 총괄 A14 카드 · HQ 운영 장부.

Phase 현황

Phase내용상태
Phase 1서버 계약 최소 보강 — 세트 토큰에 거리·시간·칼로리·보조무게, 항목에 기록 칸(recording_fields); 마이그레이션·스냅샷·registry·어댑터 검증·pgTAP·EXPLAIN 증거099f3e0f
Phase 2features/workout/history/** — typed 계약·A06 port 소비 loader·owner 범위 resource·Map 색인 병합·연도 창 정책·view/port; A06 결과에 원문 페이지 노출(추가 전용)6edd7bc8
Phase 3모바일 소비자 전환(운동 기록 드로어·그룹 보드 작성기), 이름 매칭·피드 창 재활용·세션 검색 선적재 배선 제거, 쓰기 답장의 이력 무효화, 테스트 갱신·신고19458bd8
Phase 4증거·기록 — 이 문서, RPC 계약 문서, 총괄·HQ 장부, U02/U03 인계✅ Barbelic-docs PR #32

1. 배경

운동 기록 화면에서 종목 옆 "기록 보기" 를 누르면 그 종목의 지난 훈련 목록이 뜬다(2026-07-30 도입, 2026-08-31 기록에서 "피드 창 + 세션 검색 병합"으로 범위를 넓혔다). 그때의 원칙은 "서버 변경 없이 이미 받아 둔 카드를 재활용"이었고, 종목을 고르는 열쇠는 종목 id 가 있으면 id, 없으면 이름이었다.

v0.18.0 리팩터링 총괄(A14)은 "이력의 정확도와 비용이 운동 이름이나 사용자가 방문한 피드 범위에 의존하지 않게 한다"를 목표로 잡았다. 직접 선행이 준 재료 — A02/A03 의 canonical 종목 id·codec, A06 의 통계 조회 port(loadPrExerciseHistoryData)와 응답 어댑터, A09 의 owner 범위 resource store 패턴 — 위에서 이력의 원천을 서버 RPC 한 곳으로 옮기는 트랙이다.

2. 문제 제기

친구의 세션이 내 이력으로 보였다

유저 A 가 B 를 팔로우하면 홈 피드에 B 의 세션 카드가 실린다. A 가 벤치프레스 "기록 보기"를 열면 B 의 벤치 200kg 세션이 A 의 이력으로 보였다. buildMobileExerciseHistory 는 카드의 소유자를 보지 않았다(재현: author 가 있는 카드를 넣으면 그대로 들어감 — 착수 세션 실측 friend bench in my history: ['friend-s1']).

비슷한 이름의 다른 종목이 섞였다

종목 id 가 없는 카드는 이름을 열쇠로 저장하고, 화면(legacyWorkoutHistoryValue)은 이름의 공백·대소문자를 지운 뒤 부분 문자열까지 맞췄다. "스쿼트"의 기록 보기에 "프론트 스쿼트" 세션이 나왔다(실측 lookup '스쿼트' -> ['내 세션'(프론트 스쿼트)]). 종목 이름을 바꾸거나 영문·한글 표기가 다르면 같은 종목이 갈라지거나 남의 종목과 합쳐졌다.

피드를 얼마나 봤느냐에 따라 결과가 달랐다

피드에서 본 세션은 세트 전량, 검색에서만 온 세션은 세트 5개·한 세션에 종목 6개까지만 실렸다. 앱을 다시 켜거나 피드를 더 내리면 같은 종목·같은 기간의 목록이 달라졌고 7번째 이후 종목은 아예 빠졌다.

열 때마다 전체 세션 검색을 통째로 내려받았다

onExerciseHistoryOpen → ensureSessionSearch 가 세션 검색(최대 40페이지 × 120건)을 전부 당겨 왔다. 10년치 유저는 드로어를 열 때마다 수천 건을 받았다.

합칠 때 행마다 배열 전체를 훑었다

rows.find(sessionId) 를 행마다 반복해 세션 수 × 종목 수에 비례하는 비용이었다.

서버 이력 RPC 에 유산소 세트 원자가 없었다

get_exercise_pr_history 의 세트 토큰은 load·stats_load_kg·reps·rest·memo 만 실었다. 그대로 원천을 바꾸면 달리기·플랭크의 기록 보기에서 거리·시간이 "—" 로 보인다.

3. 해결 방안

원칙 (오너 결정)

이번 트랙에 새 오너 결정은 없다(착수 댓글 "오너 결정 필요: 없음"). 전제: ① 부족한 세트 원자는 DB 담당 없이 이 세션이 최소 보강(추가 전용, 계약 v4 유지)으로 반영한다 ② 복합 종목 행의 "기록 보기" 비노출 정책은 유지한다(복합 안의 동작은 서버 롤업이 그 동작의 canonical id 로 세므로 단일 종목 이력에 포함된다) ③ 10년 창은 RPC 정책을 따르고 창 밖 여부를 화면 문구로 드러낸다 ④ 점수·PR 여부·합계는 서버 값을 그대로 쓰고 앱이 재계산하지 않는다(#1401 세트 스코어 DTO 보존).

접근

내용채택
A. 구조 개선원천을 서버 RPC(owner·canonical 종목 id·연도·keyset 커서) 한 곳으로; typed 계약·resource store·Map 색인 병합·연도 창 정책을 feature 로 두고 화면은 port 만 소비. 이름 매칭·피드 창 재활용·세션 검색 선적재 경로 제거채택 — 같은 종류 문제(소유자 혼입·이름 충돌·방문 범위 의존)가 다른 화면에서 재발하지 않고, U02/U03 이 같은 port 를 받는다
B. 땜질카드에 소유자 표시를 붙여 친구 카드만 거르고 이름 매칭에 정확 일치 조건을 더한다기각 — 방문 범위 의존·전체 검색 다운로드·O(n·m) 병합이 그대로 남는다
C. 세션 검색 RPC 에 종목 필터 추가서버에 새 조회를 만들어 드로어 전용으로 쓴다기각 — 이미 목적에 맞는 RPC(get_exercise_pr_history: owner·종목 id·연도·커서·색인 일치)가 있어 새 SQL 이 필요 없다. 부족한 것은 세트 원자 4종뿐(추가 전용)

4. 적용한 내용

Phase 1 — 서버 계약 최소 보강 (099f3e0f, 마이그레이션 20260914043000)

  • get_exercise_pr_history_v3_core: 세트 토큰에 distance_meters·duration_seconds·calories·assist_kg값이 있을 때만(jsonb_strip_nulls) 싣고, 항목마다 정렬된 recording_fields(sort_recording_fields_v1) 를 싣는다. 60세션 × 12세트 최대 페이지의 바이트 상한을 유지하려고 null 키를 만들지 않는다. 문(get_exercise_pr_history v4, 근력 지표 부착)에서도 같은 키가 남는다.
  • 앱 어댑터(prExerciseRpcAdapters.validateHistory)·타입(screenRpc.ts): 새 키 검증(0 이상 유한수, 기록 칸은 정렬된 1..3 원자·중복 금지), 옛 응답(키 없음)도 그대로 통과.
  • 스냅샷·registry·db-objects 재생성, deploymentManifest.EXPECTED_LATEST_MIGRATION 갱신, 대표 조회 목록에 pr.history_page 등재.
  • pgTAP exercise_history_set_atoms_v1(15): 달리기·어시스트 풀업·바벨 로우 fixture 에서 원자·기록 칸이 실리는지, 무게 종목엔 네 키가 없는지, 유산소만 한 종목도 롤업이 있어 페이지에 실리는지(피드 카드 의존 없음), 문 v4 에서도 남는지.

Phase 2 — 이력 feature (6edd7bc8)

  • contracts/ports/exerciseHistoryDto.ts: 요청(종목 id·연도·커서·크기·기준일)·페이지·행·세트의 typed 모양. services/domains/codecs/exerciseHistoryCodec.ts: 어댑터가 검증한 서버 페이지(snake) → typed 페이지. DB 행 모양(types/screenRpc)을 아는 것은 codec 층뿐이다(경계 테스트 contractsImportBoundaries 가 features 의 직접 import 를 막는다 — 처음 features 안에 두었다가 이 테스트에 걸려 옮겼다).
  • features/workout/history/: exerciseHistoryQuery.ts(A06 port 소비, 취소 신호·10초 제한, 다른 종목·연도·로그아웃 응답 거부) · exerciseHistoryResource.ts(키 = owner·get_exercise_pr_history·종목|연도|커서|크기·기준일·계약 버전; 시간으로 낡지 않고 통계 세대 stale·쓰기 답장 invalidateOwner·owner 떠남만) · exerciseHistoryMerge.ts(session_exercise_id Map 색인, 행 안 세트는 set_id 로 중복 제거; 우선순위 = 세트 안 잘린 행 > 통계 세대 높은 페이지 > 나중 페이지; 다른 종목 페이지는 오류) · exerciseHistoryWindow.ts(기준일 연도 첫 페이지 → 같은 연도 커서 → 이전 연도 … 창 10년; 열 때 15줄이 찰 때까지 최대 10페이지, "더 보기"는 빈 연도를 건너뛰며 다음 페이지) · exerciseHistoryView.ts(unresolved·pending·ready·error 와 port read/open/loadMore/close).
  • A06 추가 전용: PrExerciseHistoryResult 에 검증된 원문 페이지 history·exerciseRef 노출, 이력 조회에 취소 신호 전달(statsDomain·statsRepository).

Phase 3 — 소비자 전환·구 경로 제거 (19458bd8)

  • features/workout/history/exerciseHistoryBinding.ts: store·loader·연도 창을 port 하나로 묶는다. 종목마다 소비자 취소 신호 하나 — close 가 끊으면 열기 사슬이 그 자리에서 멈추고 마지막 소비자면 서버 요청도 끊긴다. read 는 store 가 바뀌지 않았으면 같은 view 참조(렌더마다 다시 합치지 않는다).
  • remoteDataController: 셸당 store·binding 하나(owner runtime 연결), invalidateSessionCollections(완료 기록 쓰기 답장) 에서 이력 전부 무효화. appController renderCtx 의 ensureSessionSearch 제거, exerciseHistory port 전달. mobileApp·WorkoutFlowBinding·WorkoutFlow·GroupComposer: exerciseHistory port 한 prop 만 전달(exerciseHistoryLoading·onExerciseHistoryOpen 제거).
  • WorkoutRecord: 드로어가 열려 있는 동안만 open(종목 id), 닫히거나 종목이 바뀌거나 화면이 사라지면 close. 본문은 WfExerciseHistoryBody(export) — canonical id 없는 행은 "종목이 연결되지 않아 기록을 조회할 수 없어요", 첫 페이지 대기는 스켈레톤, 실패는 문구+다시 시도, 줄은 그 줄의 기록 칸(없으면 종목의 칸)으로 세트를 표기하고 끝에 "더 보기" 또는 "최근 10년 기록을 모두 보여 드렸어요".
  • 제거: buildMobileExerciseHistory(피드+검색 병합 매퍼)·legacyWorkoutHistoryValue·historyLookupKey(이름 매칭)·ensureSessionSearch 배선. 세션 검색 자체는 세트 검색 화면이 계속 쓴다(보존).
  • 테스트: 명부 등재 mobileExperienceViewMappers.test.mjs 의 삭제된 매퍼 단언 2곳 제거(pending-changes.json 신고), 비명부 3파일 갱신. 새 테스트 exerciseHistoryBinding(5)·exerciseHistoryDrawer(5, 정적 렌더 + 원문 앵커).

주요 결정과 그 근거

  • 페이지 15행·연도 창 이어 읽기: 종전 드로어가 보여 주던 최대 15건과 같다. 첫 화면에 필요한 만큼만 읽고 전체 이력을 먼저 내려받지 않는다(이슈 완료 조건).
  • 같은 세션의 같은 종목 두 행은 두 줄: 서버가 session_exercise_id 단위로 세므로 그대로 둔다. 종전 매퍼는 같은 세션의 행을 세트 이어붙이기로 합쳤는데 그 규칙이 세트 중복 계산의 원인이었다.
  • 캐시 키에 기준일(asOf): 서버 연도 창의 기준이 바뀌면 다른 요청이다. 같은 (owner, 종목, 연도, 커서, 기준일) 요청은 같은 결과여야 한다(완료 조건 "피드를 열기 전후 결과 동일").
  • 무효화 단위 = owner 전부: 완료 기록 하나가 어느 종목의 이력을 바꿀지 앱이 추적하지 않는다(세트 스코어·PR 여부는 서버 계산). 열 때 15행 × 최대 10페이지라 재조회 비용이 작다.
  • 드로어 재렌더는 컨트롤러의 store 구독으로: port 는 셸당 하나로 안정적이어서 드로어의 열기/닫기 effect 가 페이지 도착마다 재실행되지 않는다(처음 memo 로 묶었다가 ESLint 억제 게이트에 걸려 memo 자체를 없앴다).

작업 중 드러난 것

  1. 문(v4) get_exercise_pr_history 가 10년치 실데이터(세션 2,609·세트 31,308·롤업 7,827)에서 3.6초 — core 함수는 18~75ms. 원인은 항목마다 불리는 set_score_session_v2(PR #1401 세트 스코어): 세션당 set_score_reference_v2 약 140ms + exercise_set_part 전체 해시 조인(31,308행 × 20항목 = 62만 행; with entries as materialized 가 행 수 추정을 3,000으로 막아 planner 가 인덱스 대신 해시 조인을 고른다). 앱은 이 문을 PostgREST(authenticated statement_timeout 8초)로 부르므로 10년 유저는 페이지 20행에 3.6초, 60행이면 시간 제한에 걸릴 수 있다. A14 는 페이지 15행으로 묶지만 근본 수리는 세트 스코어·DB 담당 몫 — 인계(제안: entries 를 not materialized 로 두거나 exercise_set_part(session_exercise_part_id) 인덱스 경로로 nested loop 유도, 또는 이력 문에서 세션당 점수 계산을 롤업 기반으로 대체). 증거 explain-a14-populated.json(읽은 행 1,180,795 · 중첩 문장 74,677 · 100ms 초과 22건) · door-explain.txt.
  2. 샌드박스 pg_cron 격리 계산(barbelic-stats-projection-compute, statement_timeout 60초)이 10년치 백필 1건을 완주하지 못해 통계 작업이 3회 실패로 멈췄다(부하 적재로 생긴 2,609건 세션 잡도 "추월됨"으로 전부 실패). cron 3개를 잠시 끄고 process_user_exercise_stats_refresh_jobs_for_user_now 인라인으로 정착시켜 증거를 확보했다. 긴 이력 유저의 첫 백필이 워커 시간 예산을 넘는 경우의 처리(분할·예산 상향)는 통계 워커 담당 인계.
  3. contractsImportBoundaries 경계 테스트: features/**types/screenRpc(DB 행 모양)를 직접 import 할 수 없다. codec 은 services/domains/codecs/ 에, typed 모양은 contracts/ports/ 에 둔다 — 다음 feature 이식(U02/U03·A10·A15)도 같은 자리.
  4. ESLint 억제 게이트: 새 eslint-disable 는 리뷰 없이 통과하지 않는다. 의존값이 남는 memo 대신 구조(안정적인 port + store 구독)로 푼다.
  5. pending-changes.json 은 같은 파일 항목의 reason 에 이어 쓴다(항목을 새로 만들지 않는다).
  6. 큐 요청은 precheck 직후에 바로 낸다. 12:26 precheck 뒤 문서 작업 동안 release 에 두 PR 이 들어와 첫 요청이 충돌로 실패했고, 그 뒤에도 한 PR 이 더 들어와 재번호를 두 번 했다. 같은 시각 마이그레이션 번호(…023000·…033000)가 세 트랙에서 겹쳤다 — 번호는 migrations:check -- --base origin/release/vX.Y.Z 로 목적 release 꼬리와 대조하고, 스냅샷 재생성은 샌드박스 스택이 떠 있어야 한다(ci:precheck-local 이 사용 후 스택을 내린다 — supabase --workdir <샌드박스> start -x … 로 띄운 뒤 schema:snapshot --reset). 스택이 내려간 채 sql:extract 를 돌리면 release 쪽 스냅샷에서 내 정의 파일 변경을 되돌리므로 순서는 반드시 스냅샷 → extract → model.

U02/U03 인계

  • port: ExerciseHistoryPort = { read(exerciseId) → ExerciseHistoryView; open(exerciseId); loadMore(exerciseId); close(exerciseId) } — 화면은 canonical 종목 id 만 넘긴다. 이름·행 id·카탈로그 조인은 받지 않는다.
  • view model: status: "unresolved" | "pending" | "ready" | "error", rows[](key=session_exercise_id·date·title·isPr·note·fields(그 줄의 기록 칸 또는 null)·sets[](camelCase 원자, 값 있을 때만 키)·setsTruncated), hasMore·loading·exhausted·windowYears·setsTruncatedRows·message. 일부 페이지를 전체 이력처럼 취급하지 않는다 — hasMore/exhausted 로 표시한다.
  • 취소 계약: 드로어를 열 때 open, 닫히거나 종목이 바뀌거나 화면이 사라질 때 close(React 라면 effect 정리에서). 계정 전환은 store 가 처리한다(별도 처리 없음).
  • 재렌더: useResourceStoreVersion(store) 를 셸(또는 binding)에서 구독하고 화면은 read 를 다시 부른다. port 를 memo 로 새 객체로 만들지 않는다.
  • 데스크톱: 지금 데스크톱에는 이 드로어에 해당하는 소비자가 없다(ui/desktop/** 에 exerciseHistory 없음). PR 화면의 ensurePrExerciseHistoryYear 는 A10 소유의 다른 화면이다. U03 이 데스크톱 편집기에 기록 보기를 붙이면 같은 port 를 WorkoutFlowBinding 과 같은 방식으로 받는다.

5. 적용 결과

항목전 → 후확인
친구 세션·비슷한 이름 종목이 내 이력에 섞임카드 소유자 무시·이름 부분 일치 → canonical 종목 id 로만 요청·병합, 다른 종목 페이지는 오류, id 없는 행은 unresolvedexerciseHistoryFeature "섞이지 않는다"(0건), exerciseHistoryDrawer unresolved
같은 (owner, 종목, 연도, 커서, 기준일) 결과 동일피드 방문 범위·앱 재시작에 따라 달라짐 → 캐시 키 동일·재요청 0exerciseHistoryFeature "열 때"·exerciseHistoryBinding open 재호출 시 서버 호출 0
열 때 내려받는 양전체 세션 검색(최대 40페이지 × 120건) → 15행 페이지(창 이어 읽기 최대 10회)연도 창 테스트(3페이지로 18줄)
병합 비용행마다 배열 find(O(n·m)) → Map 색인(O(n))1/4/10년 fixture(156/624/1,560행 + 중복 페이지 → 입력 201/783/1,962행) 행당 0.58 → 0.51 → 0.49µs, 10년 0.96ms
세트 중복·최신값 덮어쓰기같은 세션 행 세트 이어붙이기 → session_exercise_id·set_id 색인, 절삭<완전<세대 우선중복/역순 페이지·같은 날짜·같은 세션 두 행·절삭+상세 혼합 테스트
유산소·시간 종목 세트 표기RPC 에 원자 없음 → 거리·시간·칼로리·보조무게 + 기록 칸pgTAP 15/15 · 드로어 렌더 "5000m in 30:00"
서버 조회 실행계획— → core: Index Scan user_exercise_session_rollups_detail_cursor_idx, limit+1, 18~75ms(10년 실데이터)explain-a14-populated.json(샌드박스, Production 미실측)
문(v4) 응답 시간3.6초(10년 실데이터, 20행) — 미개선, 인계§4 "작업 중 드러난 것" 1
취소·계정 전환·늦은 응답취소 경로 없음(세션 검색 전체 대기) → 드로어 닫힘·종목 변경·owner 떠남에 요청 취소, 늦은 응답 폐기exerciseHistoryResource·exerciseHistoryBinding
로컬 게이트npm run check 3541 pass / 0 fail · Precheck 통과(2026-09-10 12:26 KST) — ci:precheck-local precheck · static 통과 · unit-1 통과 1861 passed / 0 failed / 13 conditional skip · unit-2 통과 1726 passed / 0 failed / 28 conditional skip · database 통과 db reset(마이그레이션 전체 적용): pass · schema.sql 스냅샷 --check: pass · pgTAP: pass 137파일/2640 assert · 동시 저장 세대·영수증: pass · dirty 범위·세대 병합: pass · 큰 이력 계산 격리·동시 CRUD·통계 수렴: pass · 6분 43초
release 통합 / staging / Production통합 완료 209bbffd / 미확인 / 미배포아래 상태 표

상태 표(작성 시점 2026-09-10; 승격 세션이 갱신한다):

단계상태
Precheck(ci:precheck-local)✅ 통과 2026-09-10 12:26 KST(첫 head 4d2290d2) · 재통과 13:06 KST(재번호 뒤 head 4e953796; 12:50 실행은 격리 수렴 probe 가 단위 2샤드와 CPU 경합으로 실패, probe 단독 2회 통과)(기준 release/v0.18.0 2ab89770 합침 → HEAD 4d2290d2; 첫 실행은 격리 수렴 probe 게시 단계 문장 시간 제한(57014, 재계산 37.9초 — 단위 2샤드와 CPU 경합)으로 실패, 재실행 통과) · PR #1503
Merge Check(merge:request) → release/v0.18.0✅ 병합 209bbffd 2026-09-10 13:07 KST(실행 34435877543). 첫 요청(f78b4132)은 #1501·#1502 선행 병합으로 생성 파일·remoteDataController 충돌 → 해결·같은 시각 마이그레이션(#1409·#1411) 뒤로 20260914043000 재번호·재생성·precheck 재통과 뒤 새 head 4e953796 로 재요청
release → staging 승격미실행
staging → main(Production)미실행

실기기 확인은 하지 않았다(§14 — 자동 증거로 닫는다). 시각·감각 품질(드로어 스켈레톤·더 보기 버튼 배치)은 사람 눈으로만 판단할 수 있다는 정보로만 남긴다.

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

내 기록만, 그 종목만

친구를 팔로우하든 이름이 비슷한 커스텀 종목을 만들었든, "기록 보기"에는 내가 그 종목(canonical id)으로 한 세션만 나온다. 종목 id 가 없는 옛 행은 이름으로 짐작하지 않고 "연결되지 않았다"고 말한다.

어디서 열어도 같은 목록

피드를 봤는지, 앱을 다시 켰는지와 무관하게 같은 종목·같은 기간의 목록이 같다. 통계가 아직 계산 중이면 페이지가 stale 로 표시되고 완료 기록을 저장하면 다음 열기가 서버를 다시 읽는다.

필요한 만큼만 읽는다

드로어를 열면 15줄이 찰 만큼만(최대 10페이지) 읽고 "더 보기"로 이어 읽는다. 10년 유저가 열 때마다 수천 세션을 받던 것이 사라졌다. 닫으면 진행 중 요청이 끊긴다.

구조적으로 남는 것

  • 종목 이력의 원천 = 서버 RPC 한 곳(owner·canonical 종목 id·연도·커서). 이름 기반 매칭 경로가 코드에 없다.
  • contracts/ports/exerciseHistoryDto.ts typed 계약과 features/workout/history feature(loader·resource·병합·연도 창·view·binding). U02/U03 이 그대로 받는 port.
  • codec 층만 DB 행 모양을 안다는 경계가 이 feature 에도 적용됐다(경계 테스트가 지킨다).
  • 서버 RPC 계약: 세트 원자 4종·기록 칸(추가 전용, v4 유지)과 대표 조회 pr.history_page 의 실행계획 증거.

남은 것

  • 문(v4) 근력 지표 부착의 세션당 비용(§4 "작업 중 드러난 것" 1) — 세트 스코어·DB 담당 인계. A14 가 페이지를 15행으로 묶었지만 10년 유저 2초대는 남는다.
  • 긴 이력 유저의 첫 통계 백필이 워커 60초 예산을 넘는 경우(같은 절 2) — 통계 워커 담당 인계.
  • G05 장부: mobileFeedViewMappers(피드 카드 전용으로 축소)·workoutCalculations(표시 도우미만) 의 소유 범위 갱신은 이 문서와 총괄 A14 카드에 적었고, 장부 표 재생성은 R01 종료 검사에서.
  • R04: 1/4/10년 병합 처리량·서버 EXPLAIN·페이지 한도(15행·10페이지)·측정 환경(샌드박스 cil095e2658, Postgres 17.6.1.127, 세션 2,609·세트 31,308)을 이 문서 §5 에서 넘긴다.
  • release 통합·staging·Production 상태는 승격 세션이 위 상태 표를 갱신한다.

세션: 4b0f1d85-820d-4396-ad20-37eb6881b0f3 (이전 b55b728c-569d-4d7c-98ee-fe9e6cb1ea35)