Motra 인입 트랙 — 1년치 export에서 관리자 인입 페이지까지 (2026-08-24)
- 기간: 2026-08-24 (세션 3개 — 분석 구 세션 · 팔로업 세션 Phase 0~3 · 페이지 세션 Phase 4. 오너 지시: "모트라 데이터 인입 페이지 별도로", 역할 조정 "Phase 3까지 결과물 이용, 충돌 시 팔로업 세션 것 사용, 페이지+유저 지정 저장만")
- 랜딩: Phase 0 #640(570000) · Phase 1 #646(590000) · Phase 2 #651(600000) · Phase 3 #652 · Phase 4 #645(
a6afc12c, 620000) — Vercel 자동 배포(admin 번들 포함) - 설계서: 없음 — 판정·결정은
scripts/motra-import/motra-mapping.json(정본)과 PR 본문 - 정본:
scripts/motra-import/(변환기·판정 매니페스트·README) ·supabase/migrations/20260821620000_motra_import.sql(쓰기 경로 RPC·불변식) ·src/react/services/motraNormalizedUpload.ts(사전검사) ·docs/data/import-pipeline.md"모트라 인입 — 변환기 산출물의 동기 기입" 절 - 도구: 변환기
node scripts/motra-import/convert-motra-blocks.mjs— 작업 원본·산출물은 레포 밖D:\LiftGuild-2026\Claude\motra-import-data\(유저 데이터, 원본 xlsx는 기기에서 소실 → 파싱본 sha256 기록 보관) - 게이트: pgTAP
motra_import_v1.test.sql(18) ·tests/react/motraNormalizedUpload.test.mjs(6) ·tests/react/motraImportController.test.mjs(7) · Phase 1의 pgTAP 40(유산소 3케이스) - 버그리포트:
bug-013-20260824.md— Production 인입 2차 실패(①safeupdate bare UPDATE 차단·②8초 타임아웃 위험) → #671(630000)·#677(640000) Production - 계약:
desktop-screens-props.mdUiDesktopAdminMotraImportBody 절 ·rpc-catalog.mdimport_motra_workouts_v1행 ·import-pipeline.md모트라 절 · designContract 마커 13종
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 별칭 교차 해소 (Push-Up/Pull-Up 분리) | ✅ #640 (570000 Production) |
| Phase 1 | 유산소 완료 세트 3케이스 허용 (시간/거리/거리+시간, 4층 완화) | ✅ #646 (590000 Production) |
| Phase 2 | 카탈로그 준비 — 신설 15종 + 모트라 표기 별칭 53건 | ✅ #651 (600000 Production) |
| Phase 3 | 변환기 정본 — 판정 매니페스트 113종 + jsonl 산출 | ✅ #652 |
| Phase 4 | 인입 실행 — 관리자 "모트라 인입" 탭 + import_motra_workouts_v1 | ✅ #645 (620000) |
1. 배경
모트라(Motra) 앱 1년치 export(331세션 · 웨이트 2,558세트 · 종목 113종 · 2025-08-19~2026-08-21)를 바벨릭 일지로 인입하는 트랙. 외부 인입은 와드업 비동기 파이프라인 하나뿐이었고, 새 프로바이더는 source 불변식(['barbelic','wodup'] 고정)에 막혀 기입 자체가 불가능했다. 원본 xlsx는 작업 중 기기에서 사라져 검증된 파싱본(motra_blocks.json, sha256 기록)이 작업 원본이 됐다.
2. 문제 제기
유산소 기록이 데이터 모델에 들어갈 수 없었다
유산소 172블록은 세트가 없고 시간·칼로리·심박존만 있다. 완료 세트는 "시간×거리" 강제 (4층: DB 검증기·클라 매퍼·UI 게이트·백필)라 시간 단독 세트가 거부됐다.
113종 영문 종목명이 카탈로그와 이어져 있지 않았다
자동 이름 매칭은 오매핑 위험이 있고, 하이킹 등 카탈로그에 없는 종목도 있었다.
인입 실행 수단이 없었다
변환기가 정규화까지 끝내도 ①대상 계정 지정 ②custom 실체화 경로 ③provider motra 쓰기 경로가 비어 있었다 (Phase 4 잔여).
두 세션이 같은 요청을 두 갈래로 진행했다
오너의 "인입 페이지" 지시(이 세션)와 Motra 트랙(팔로업 세션)이 병렬로 진행돼, 이 세션이 XLSX 파서+placeholder 인입을 독자 구현했다가 오너 판정으로 역할을 재조정했다 — 정규화 정본 = 팔로업 트랙 변환기, 이 세션 = 페이지+저장만. 독자 구현분(XLSX 파서·placeholder 정책)은 폐기.
3. 해결 방안
원칙 (오너 결정, 2026-08-24)
- D1 장비/변형 다르면 일단 신설, 진짜 의심의 여지가 없는 매핑이 아니면 커스텀 → 판정 매니페스트 113종 전수(map 94 · new 15 · custom 5).
- D2 유산소도 인입 — 제목 분류(조정→로잉머신 61·달리기→러닝 44·수영 44·걷기 12·하이킹 1), 종목 1+시간 단독 세트, 기능성 근력 10세션은 자유 기록, 칼로리·심박존은
entry_review. - D3 유저 데이터로 원본 전 필드 보존(세트 소요초 포함).
- 역할 조정: "Phase 3까지의 결과물을 이용(충돌 시 팔로업 세션 것 사용), 페이지 제작 + 유저 지정 저장까지".
접근
| 대안 | 판정 |
|---|---|
| 와드업 파이프라인 확장(스테이징·엣지에 motra provider) | 기각 — 850KB 파일에 비동기 인프라 불필요 |
| 서비스 전용 배치(수동 SQL 인입) | 기각 — 대상 계정 선택·재실행이 매번 수작업 |
| 변환기(정본) + 관리자 페이지 업로드 + 동기 RPC 1회 | 채택 — 정규화는 변환기, 페이지는 검사·대상 지정·기입만 |
4. 적용한 내용
Phase 0~3 — 팔로업 세션 (#640 · #646 · #651 · #652)
별칭 교차 해소(570000) → 유산소 3케이스 4층 완화(590000, isCardioOnlyProfile 한정 "하나 이상") → 카탈로그 신설 15종+별칭 53건(600000) → 변환기 정본(scripts/motra-import/): 판정 매니페스트 113종 + 유산소 제목 분류 규칙, 검증된 산출물 motra-normalized.jsonl(331세션 = 웨이트 159 + 유산소 162 + 기능성 10, 세트 2,720 = 2,558 + 시간 단독 162, 미등재 0).
Phase 4 — 인입 실행, 이 세션 (#645, 620000)
- 서버: source 불변식 4곳
'motra'추가(자동 read-only·remove_import_data_v1('motra')호환),import_motra_workouts_v1(p_payload, p_user_id?)— 변환기 행 배열을 그대로 받아 map/new는 slug로 카탈로그 직결(미해석 slug 즉시 실패), custom 5종은 대상 유저 스코프 실체화 (brid:exercise:external:<owner>:motra:<slug>, 재인입 시 소유 행 재사용), 세션 타이밍·체중 스냅샷 인라인 기입,session_review→세션 note·entry_review→엔트리 컬럼(D2), 원문은 raw_payload.normalized(D3), one-shot 게이트·대리 인입(JWT sub 스왑)·통계 인라인 갱신. 하이킹 프로필 보정 — #651 신설분이['distance']단독이라 시간 단독 세트가 스크럽됨 → 다른 유산소 4종과 같은['distance','duration']으로. - 클라: "모트라 인입" 탭(
motraImport) — jsonl 사전검사 5종(motraNormalizedUpload.ts, 재정규화 없음) → 인입 대상 계정 검색·선택(와드업ImportTargetCard재사용, 시작 시점 고정) → RPC 1회 → 결과(웨이트/유산소/자유 기록·카탈로그/custom 타일 + custom 실체화 카드). CSS.adm-imp-*전량 재사용(신규 클래스 0).
주요 결정과 그 근거
- placeholder를 만들지 않는다 — 판정 매니페스트가 정본이므로 와드업식 "인입 후 매핑 검수" 단계가 없다. 미해석 slug는 조용히 placeholder가 되는 대신 즉시 실패한다.
- custom 5종은 전역 매핑 테이블에 싣지 않는다 — 유저 스코프 identity를 전역
exercise_external_mappings에 mapped로 박으면 다른 유저 인입이 그 유저 종목으로 오염된다. 소유 행 lookup으로만 멱등을 확보. - 웨이트 세션의
session_review(칼로리·심박존)는 세션 note로 — 트랙 잔여 판단 ④를 "전 세션 동일 규칙(변환기 문자열 그대로)"으로 해소.
작업 중 드러난 것
- 마이그레이션 번호 5연속 선점 — 570000 개발분이 #643(580000)·#646(590000)·#651(600000)· #655(610000)에 연속 선점당해 620000으로 확정. 같은 날 병렬 세션이 많을 때는 PR 제목이 아니라 PR의 마이그레이션 파일 경로로 선점을 확인하고, schema.sql은 손 병합 대신 마이그레이션 전량 concat 재생성이 안전하다.
- PR 생성 직후 GitHub가 pull_request 이벤트를 흘리는 상태(mergeable UNKNOWN 고착·체크 0)가 있었다 — 빈 커밋·close/reopen이 아니라 리베이스+강제 푸시가 새 런을 태웠다.
npm run check(config.toml 잠금 테스트 포함)와 임시 config.toml 변경(격리 스택)을 병렬로 돌리면 위양성 실패가 난다 — 순차로.- read-excel-file 은 이 파일의 sharedStrings XML 엔티티를 풀지 않는다(
Captain's …) — 파싱본(openpyxl)과의 등가성 대조에서만 드러났다. 스캐너가 엔티티를 디코딩한다. 심박존 없는 블록의 zones 는 파싱본과 동일하게 null, effort 숫자 셀은 원시 숫자로 보존해야 원문(D3)이 파싱본과 일치한다. - check:migrations 린트는 함수 안 임시 테이블
alter table add column에도if not exists를 요구한다(텍스트 검사). - 프라이머리 node_modules가 불완전(@babel 부재)해 lint가 죽었다 — 워크트리 자체
npm ci.
5. 적용 결과
| 항목 | 결과 |
|---|---|
정적 게이트(npm run check+unused) | 전부 통과 |
| JS 테스트 | 1,852건 전부 통과(신규 13건 포함) |
| pgTAP | 54파일 845건 전부 통과(신규 18건 포함) |
| 실데이터 종단(로컬 DB, 롤백 트랜잭션) | motra-normalized.jsonl 331세션 투입 = 변환기 summary 전 수치 일치 — 세션 331(159+162+10) · 엔트리 777 · 세트 2,720 · 시간 단독 162 · custom 실체화 5 · entry_review 172 · 통계 116행 인라인 재계산 · 오류 0 |
| 인입 가능한 프로바이더 | wodup 1종 → wodup + motra 2종 |
| 모트라 쓰기 경로 인프라 | 스토리지·엣지·스테이징 0개 (와드업 1·2·4개) |
| Production | 마이그레이션 20260821620000 적용 완료(push + 원격 프로브: RPC·하이킹 프로필·이벤트 타입·불변식 확인) |
| 실제 인입 | 완료 (2026-08-24) — 봉천곰돌이 계정에 331세션 인입 성공. 본인 계정 오인입 1회는 remove_import_data_v1('motra')로 정리(331세션·custom 5종 삭제, 통계 재계산 큐 — 제거 경로 Production 실증) |
| Production 인입 시도 | 2회 실패 → 수리 2단 → 성공 — BUG-013: ①safeupdate가 임시 테이블 전행 UPDATE 차단(주원인, 640000 CTAS 파생으로 제거) ②8초 타임아웃 위험(630000 통계 비동기화, 기입 1.05초·통계는 매분 크론). 오너 재시도 대기 |
6. 이번 개선으로 향상된 것
모트라 1년치 기록을 계정 지정 한 번으로 일지에 넣을 수 있다
관리자가 변환기 산출물을 올리고 대상 계정을 골라 "인입 시작"을 누르면 세션·세트·타이밍·체중 스냅샷·리뷰까지 읽기 전용으로 들어오고 PR/Volume 통계가 즉시 재계산된다.
구조적으로 남는 것
- 두 번째 프로바이더 인입의 전 과정 선례 — 판정 매니페스트(정본) → 변환기 → 동기 RPC → 관리자 페이지, 그리고 source 불변식·one-shot·read-only 계약이 코드와
import-pipeline.md에 남았다. - 유산소 3케이스 계약(Phase 1)은 모트라 밖 일반 기록에도 적용되는 영구 완화다.
- 대상 계정 카드(
ImportTargetCard)가 공용 부품으로 승격됐다.
남은 것
실제 인입 실행— 완료(2026-08-24, 봉천곰돌이 331세션).- Press-Up 판정(서스펜션 푸쉬업↔일반 푸쉬업 겹침 잔존) — 트랙 잔여 오너 결정.
- 웨이트 세트 소요초(
set_seconds)는 정본 원자 밖(raw 보존만) — 원자 승격은 별도 결정. - 인입 대상 계정 검색이 이메일·표시명만 매칭(
admin_search_users_v1) — 핸들(@grant)로는 검색 불가(Apple 비공개 릴레이 이메일 계정은 표시명으로만 찾아진다). 오너 "나중에" 판정, 핸들 매칭 추가는 후속.