플랫폼 공통 Workout headless editor 계약 (v0.18.0 A12)
규칙 한 문장 — 운동 편집(입력 판정·완료 판정·복합 동작·초안·저장 intent)의 뜻은
features/workout/editor/**한 곳이 정하고, 모바일·데스크톱 화면은 그 상태를 그리고 사용자의 행동을 command 로 넘길 뿐이다. 편집기는 React·DOM·네트워크·IndexedDB·CSS 를 모르며, 시각·행 키·카탈로그·1RM 은 ports 로 받는다.
- 이슈: #1339 (계획 ID A12, Phase 2 스텝 2-3). 선행: A02 저장소·codec · A03 카탈로그·프로필 · S03 저장 준비.
- 코드:
src/react/features/workout/editor/{model,numericInput,commands,reducer,validation,viewModel,draft,saveIntent,index}.ts· adapteradapters/{ports,saveGuard,mobileFlowAdapter,desktopPlanEditorAdapter}.ts· 공통 순수 정책src/react/contracts/workout/{setTypes,measureFields,compositeParts,loadUnits,bodyweight,writeProjection}.ts. - 검사:
tests/react/workoutEditorCore.test.mjs(11) ·workoutEditorSave.test.mjs(6) ·workoutEditorAdapters.test.mjs(4). 기존 행동 테스트(workoutFlowPayloadSerializer·workoutCompositeSetGate·workoutFlowEmptyFinish·workoutFlowDraftSafety·입력 한도·duration·계산)는 그대로 돈다. - 관련 계약: G03 도메인 계약 §6 입력 중 상태·§3 식별자·§12 명령 · 운동 화면 props · 쓰기 원천 · S08 초안 lifecycle.
1. 유저 이야기 — 이 계약이 지키는 것
유저 A 가 모바일에서 스쿼트 무게 칸에 12. 까지 치고 [세트 완료]를 누른다. 유저 B 는 데스크톱 작성기에서 같은 세트를 적는다. 두 화면은 "이 칸이 비어 있는가, 0 인가, 아직 치는 중인가, 틀린 값인가" 를 같은 함수로 판정해야 한다.
A12 전에는 그 판정이 모바일 기록 화면(WorkoutRecord.tsx 1,640줄) 안의 Number(x)·mHasValue 조건문과 데스크톱 모델(DesktopPlanEditorModel.ts)의 다른 조건문에 흩어져 있었다. 단위 변환(lb→kg·%→kg·분:초·km)도 화면 안에서 일어났고, 행 키(x8f2k1)와 서버 id 가 같은 필드를 썼으며, 저장 payload 는 컨트롤러가 네 단계로 다시 정리한 뒤에야 S03 조립기에 닿았다. 편집기 쪽에는 "저장 가능한가·무엇이 막는가" 를 묻는 자리가 없어 오류는 alert 로 터졌다.
지금은 편집 중인 운동 한 건이 상태 하나(WorkoutEditorState)이고, 사용자가 한 일은 command(set.input·set.toggleDone·exercise.reorder …)이며, 그 결과는 순수 reducer 가 만든다. 같은 초기 DTO·같은 카탈로그·같은 command 열이면 어느 화면이든 같은 상태, 같은 validation, 같은 CompletedSessionWriteInput, 같은 S03 요청 지문이 나온다(workoutEditorAdapters.test.mjs ①).
2. 상태 모델 (model.ts)
| 개념 | 타입 | 뜻 |
|---|---|---|
| 편집 종류 | WorkoutEditorIntent = live | backfill | edit{sessionId, expectedRevision, writeSource} | plan{planId} | 진행 중 운동 / 지난 기록 작성 / 완료 기록 수정 / 계획. 저장 규칙(완료 세트만 vs 전부, 원천 사본 필요)이 여기서 갈린다 |
| 종목 행 | ExerciseRow{key: LocalRowKey, rowId, kind, exerciseId, fields, composite, sets, …, extra} | exerciseId 는 canonical uuid 만(자유 기록은 ""). fields 는 명시 프로필만 — 비어 있으면 판정·저장 시점에 카탈로그 → measurementType 순으로 푼다(effectiveFields) |
| 세트 행 | SetRow{key, rowId, type, cells: SetCells, loadUnit, loadRaw, loadLb, loadPct, parts, done, rest, …, extra} | 숫자 칸 여섯 개(load·assistKg·reps·durationSeconds·distanceMeters·calories)는 전부 InputCell<number>. extra 는 뜻을 모르는 통과 필드(서버 투영값·세트 스코어 재료) |
| 행 키 | LocalRowKey(local:<rowId>) | G03 §3. 서버 id 와 섞이지 않는다. rowId 는 화면·저장 답장(세트 스코어)이 쓰는 종전 임시 id |
| ports | WorkoutEditorPorts{clock.nowMs, nextRowId, catalog(exerciseId), oneRmKg(exerciseId)} | 편집기가 바깥에서 받는 전부. 테스트는 고정 시계·순번 키·표 카탈로그를 넣는다 |
exercisesDirty(state) 는 완료 기록 수정에서 "종목을 고쳤는가" — 열 때의 지문(확정값 기준)과 비교한다. 원문 공백 차이는 변경이 아니다.
3. 숫자 입력 정책 (numericInput.ts) — G03 §6 의 첫 소비자
numericCell(field, raw, { composing? }) → InputCell<number>| 사용자가 한 것 | 셀 | 저장 |
|---|---|---|
| 아무것도 안 적음 / 지움 / 공백 | empty | null |
0 | valid(0) — 0 은 값(맨몸 세트) | 0 |
12.·-·1e | typing(raw) | 금지(input_incomplete), 원문 보존 |
| 비수치·정밀도(kg 소수 3자리)·상한·음수·보조 무게 0 | invalid(reason) | 금지, 이유 코드(unit_precision·unit_above_max·unit_below_min…) |
IME 조합 중(composing: true) | 원문만 typing 으로 보관 — 판정하지 않는다 | 조합이 끝나면 그때 판정 |
원문 → 셀 판정(단위 파서·정밀도·상한)은 운동·계획 공유 policy features/editing/numericInput.ts(A13 이 만들고 A12 와 공동 소유)의 numericInputCell 하나다 — 이 파일은 세트 칸 이름(assistKg·durationSeconds …)을 그 원자 이름(assist·duration …)에 대응시키고 IME 조합 중 처리만 얹는다. 정밀도는 DB 자릿수(무게·보조·파운드·% 2자리, 거리·시간 3자리, 칼로리 2자리, 횟수 정수), 상한은 A02 한도 정본(COMPLETED_WORKOUT_WRITE_LIMITS)에서 — 같은 숫자를 두 곳에 두지 않는다. 보조 무게는 0 초과(한도 정본, A12 합류 시 EDITOR_INPUT_BOUNDS.assist 에 반영).
정규값과 표시값의 분리(loadUnits.ts): 무게 칸은 loadUnit(kg·lb·%)을 갖고 사용자가 친 원문은 loadRaw 에 남는다. 정규값은 언제나 cells.load(kg)다 — lb 는 kgFromPounds(0.01kg 단위), % 는 기준 1RM × %(kgFromPercent, 0.5kg/5kg 반올림) — 둘 다 공유 policy features/editing/loadUnits.ts 이고 그 밑의 수식(poundsToKilograms·roundWorkoutLoadKg)은 contracts/workout/loadUnits.ts 다. % 입력은 0 초과 150 이하(EDITOR_INPUT_BOUNDS.loadPct). 기준 1RM 이 없는 % 는 % 만 보관하고 kg 을 비우며 관문이 pct_base_missing 으로 막는다(#837 D2). 시간은 초(minSecPartsOf/minSecCombine), 거리는 m(kmDisplayOf/kmToMetersText)이 정규값이다. 화면은 viewModel.loadFieldView·durationFieldView·distanceFieldView 가 준 문자열을 그릴 뿐 환산하지 않는다.
4. command 와 reducer (commands.ts·reducer.ts)
reduceWorkoutEditor(state, command, ports) → { state, rejection }. 거절은 상태를 바꾸지 않고 코드만 돌려준다(WorkoutEditorRejection{code, path?, detail?}) — 화면이 문구를 고른다.
| 묶음 | command | 종전 자리 |
|---|---|---|
| 종목 | exercise.add(canonical id 만) · addComposite(동작 2개 이상, 이름 = compositeExerciseName) · addNote · swap(세트·디테일 보존) · remove · reorder · patch · setActive · confirmSets("세트 설정/작성 완료" — 진행 중은 내용 확정만, 작성·계획·수정은 종목·세트 완료) · finish · reopen | WorkoutFlow.addExercise/addCompositeExercise/removeExercise/reorderExercise, WorkoutRecord 저장 버튼 |
| 세트 | set.add/insert(직전 세트 잇기: 무게·횟수·시간·거리·유형·단위, 복합은 동작별 값) · remove · reorder(같은 유형 안, moveSetWithinType) · setType(앞 세트보다 앞설 수 없고 뒤 세트는 따라 올라감) · input(칸 원문) · loadInput(단위 있는 무게) · switchLoadUnit(kg/lb 로 가면 % 지움, % 로 가면 kg 에서 % 실체화) · partInput(복합 동작별 값 + D2 뒤 동작 무게 디폴트) · patch(휴식·메모·RPE·목표·결과·실패) · fillDown · toggleDone(관문 통과 시만, 완료 패치는 실패 마킹 정리 — #927) · applyScores | addRow/insertSet/updSet/rmSet/reorderSet/setTypeAt/toggleDone/fillDown, patchCompositePartDraft |
| 세션 | session.patch · setIntent(일반 훈련으로 돌아오면 RM 필드 지움) · finishEarly(완료 세트 있는 종목 = done+manualDone, 0개 = 제외, 전부 0개 = 유지) · reopenLast("한 세트만 더할게요") | mobileWorkoutFinishEarlyExercises, 완료 화면 onBack |
한도(종목 48·종목당 세트 64·총 세트 240)는 추가 시점에 limit_* 로 거절한다. 뜻이 바뀌는 입력(유형·무게·횟수·RPE·목표·결과)은 세트 extra 의 서버 투영값을 비운다(STRENGTH_PROJECTION_RESET).
5. 판정 (validation.ts)
| 함수 | 뜻 | 종전 자리 |
|---|---|---|
effectiveFields(exercise, ports) | 명시 프로필 → 카탈로그 행 → measurementType | makeFlowExercise·dkpFlowExercise·lgNormalizeWorkoutExercises 세 곳이 각자 |
setInputIssues | typing/invalid 칸 → input_incomplete·이유 코드(path 포함) | 없음(저장 시 Number() 추측) |
setSatisfied | 단일 = 프로필 논리식(mProfileSatisfied, 명시 requiredInputs → 카탈로그 → 기본), 복합 이종 = 동작별 논리식(compositeSetSatisfied), 무게×횟수 복합 = 동작별 횟수 + 공유 무게 | toggleDone·hasValidRepInput·dkpMissingRecordingFields |
setGate | 입력 → 프로필 → RM 시도(rmAttemptValidation 코드) → % 기준 부재 | 흩어짐 |
exerciseRecorded / finishGate | 기록된 종목(완료 플래그·완료 세트·본문 있는 자유 기록) / 종료 관문: discard{cause} · confirm_empty · note_incomplete{key} · unfinished · ok | mobileWorkoutExerciseRecorded·attemptFinish |
sessionIssues / canSave | 저장 전 전체 판정(행 수 상한·칸·프로필·RM·%). 진행 중 운동은 완료 세트만, 작성·수정·계획은 전 세트 | 없음 |
6. view model (viewModel.ts)
cellView·setFieldView·loadFieldView·durationFieldView·distanceFieldView(칸 표시값과 상태·이유 코드), setRowView(저장 가능·막는 이유·입력 문제 수), exerciseRowView(기록 여부·진행·다음 세트), sessionProgress, resumeNeedsSetEditReopen(#1010 재개 판정). CSS·마크업은 없다.
7. 초안 왕복 (draft.ts)
기기 초안(S08 store)·서버 백업·화면 스냅샷은 종전 WorkoutDraft 모양(값이 문자열·숫자·null 로 섞인 평면 행)이다. 이 파일이 그 모양 ⇄ 편집기 상태의 유일한 변환 지점이다.
- 초안 → 상태(
editorStateFromDraft): 값은numericCell로 판정 — 원문12.는typing으로 살아난다. 종목 id 가 canonical 이 아니면Result오류(exercise_id_not_canonical, 종전 throw 대신). 통과 필드는extra에 보존. - 상태 → 초안(
draftFromEditorState): 확정값은 숫자, 입력 중/틀린 값은 원문, 빈 값은"". 다시 읽으면 같은 상태(왕복 안정점 테스트). - 화면 전용 상태(step·활성 세트·휴식 타이머·완료 화면 입력)는 편집기 밖 —
snapshotFromEditorState(state, screen)봉투에 그대로 실린다.intentFromDraft(draft, mode)가editSessionId·expectedRevision·writeSource로 수정 intent 를 만든다. - 초안 TTL·보관·서버 백업 순번은 S08 몫이다. 편집기는 시각을
ports.clock으로만 읽는다.
8. 저장 intent → S03 (saveIntent.ts)
saveIntentOf(state, ports, deps) → Result<create|update|plan, editor_invalid|…>
prepareEditorSave(state, ports, {userId, base?, sourceRef?, preparation}) → Result<{prepared: PreparedMutation}, …>sessionIssues가 하나라도 있으면editor_invalid{issues}— 대기열에 넣지 않고 상태는 그대로다(원문 보존).exercisesForSave: 저장용id는 uuid(영수증 줄 번호표 대조·세트 스코어 키) — 행 키(rowId)는 그대로 두고 주입된 공장(clientIds)이 만든다(테스트는 순번 uuid). 실패 세트 별칭(failed/failReps)은 canonical(setResult=rep_failure·targetReps·reps)로. 복합 동작 프로필은 카탈로그 재수화, 복합 종목의 체중 계수는 0(동작별 반영은 서버 fan-out). 작성·수정·계획은 관문을 지나는 세트가 곧 기록(done), 진행 중은 사용자가 완료한 세트만.- 그 다음은 S03
prepareCompletedSessionCreate/Update다 — 멱등 키(초안operationId)·지문·개정번호·쓰기 가능 사본 검사는 거기서. 표시용 사본(display)은 S03 validator 가write_base_not_writable로 거부한다. 계획 저장은 A13 조립기 몫이라 여기서 준비하지 않는다(plan_not_prepared_here). - 편집기는 mutation identity·재시도·outbox 를 소유하지 않는다.
9. 플랫폼 adapter 와 실제 저장 경로 (adapters/**)
| adapter | 받는 것 | 주는 것 |
|---|---|---|
editorPortsFromProps({exerciseCatalog, oneRms}) | 화면 props(카탈로그 행 배열·1RM 표) | WorkoutEditorPorts |
openMobileWorkoutFlow({mode, backfill, initialDraft, initialSnapshot}) / mobileFlowSnapshot / mobileSaveIntent / guardMobileSavePayload | 모바일 화면의 initialDraft·재개 스냅샷·buildSession payload | 편집기 상태 / 스냅샷 봉투 / 저장 intent / 통과한 payload |
openDesktopWorkoutEditor(draft, request) / desktopSaveIntent / guardDesktopSavePayload | 데스크톱 작성기의 계획 모델 행(A13 PlanRow, 작성 모드는 완료 기록 필드 동반)·onSave(draft) | 같음 |
실제 경로(2026-09-08 현재): WorkoutFlowBinding(모바일)과 desktopApp(데스크톱)이 저장 콜백 앞에 guard*SavePayload 를 끼운다. 입력 중·틀린 값이 남은 세트가 있으면 LG_EDITOR_INPUT_INCOMPLETE("아직 입력 중이거나 잘못된 값이 있는 세트가 있어요…")로 그 자리에서 돌아오고 payload 원문은 불변, 통과하면 같은 참조를 그대로 컨트롤러로 넘긴다. 컨트롤러의 조립(buildLocalWorkoutSaveInput → S03)은 바뀌지 않았다.
A09 가 이어받은 경계(2026-09-08, 이슈 #1342 이행): 컨트롤러의 lgCanonicalizeWorkoutUiAliases → lgNormalizeWorkoutExercises → ensureWorkoutChildIds 세 단계를 adapters/controllerSave.ts 의 prepareEditorExercises(payload → 편집기 상태 → exercisesForSave → 세션 직렬화)로 대체했다 — 동치 검사 a09EditorSaveParity(같은 payload → 같은 S03 requestHash, live·backfill). 모바일 payload 에는 실패 세트 별칭이 없어 최신 초안 스냅샷에서 별칭만 병합한다(mergeDraftSetAliases). guard*SavePayload 는 화면 관문으로 남고 컨트롤러 쪽 editor_invalid 판정이 fail-closed 뒷받침이다 — 관문이 곧 편집기가 되는 것은 화면이 편집기 상태를 직접 들 때(U02/U03). 알려진 차이 2건(자유 기록 bwFactor null·진행 중 미체크 세트)은 첫 수직 통합 계약 §8. 그 뒤 화면 컴포넌트를 editor 상태·view model 로 갈아 끼우는 것은 U02(모바일)·U03(데스크톱), Phase 3 이다.
10. 공통 순수 정책의 자리 (contracts/workout/**)
ui/shared 는 features 가 읽을 수 없고, features 는 ui 가 읽을 수 없다(허용표 §2). 두 층이 함께 읽는 순수 정책은 contracts/workout/** 에 둔다(attendancePolicy·gamificationPolicy 선례). 종전 ui/shared/* 파일은 같은 이름을 재수출한다 — 호출부·테스트는 바꾸지 않았다.
두 층의 관계(A13 #1340 과 직렬 합류, 2026-09-08): 운동·계획 편집기가 함께 쓰는 편집 policy(숫자 입력 셀·kg/lb/% 저장·표시 단위·분:초 칸 정리·세트 타입 단조·필수 입력 판정)는 A13 이 만든 features/editing/** 이 정본이고(plan-editor-model.md §2), 그 밑의 순수 수식·값 모델(세트 유형 순서 토큰·정렬, 측정 원자 판정, 복합 parts, lb⇄kg·분:초⇄초·km⇄m 환산, 체중 계수, 직렬화)은 contracts/workout/** 이다. features/editing/minSecInput·setTypeOrder 는 contracts 를 재수출하고, loadUnits·recordingInputs 는 ui/shared 재수출을 거쳐 contracts 의 수식을 부른다. 운동 편집기는 features/editing 을 import 한다(§3·§4·§5).
| 파일 | 내용 | 종전 자리 |
|---|---|---|
setTypes.ts | 세트 유형 순서(warmup → main → top, barbelicCopy 는 라벨만)·정렬·투영 무효화·RM 시도 검사(코드+문구) | ui/shared/setSemantics.ts |
measureFields.ts | 원자 ↔ 세트 칸 대응·값 있음 판정·프로필 논리식 평가·합계 | ui/shared/measureSemantics.ts(표기 함수는 남음) |
compositeParts.ts | 동작별 값 묶음·게이트·패치·공유 무게 편집기 판정·복합 이름 | ui/shared/compositeSemantics.ts(표기 함수는 남음) |
loadUnits.ts | lb⇄kg·%⇄kg·분:초⇄초·km⇄m | ui/shared/workoutCalculations.ts·WorkoutRecordParts.tsx |
bodyweight.ts | 체중 계수·유효 무게·lb 복제 원문 | ui/shared/bodyweightSemantics.ts(라벨은 남음) |
writeProjection.ts | 초안 → 저장 payload 직렬화(세션/계획 모드) | ui/shared/workoutFlowPayloadSerializer.ts |
11. 같은 sequence fixture
workoutEditorAdapters.test.mjs ①: 스쿼트 2세트(웜업 60×8·메인 100×5) + 러닝(3000m·900초) + 복합(풀업 8회 + 홀드 20초)을 모바일 initialDraft 모양과 데스크톱 dkpFlowExercise 모양으로 열고, 같은 12개 command(lb 입력·횟수·세트 추가·유형·유산소 원자 비우기·동작별 시간·RPE·종목 정렬·세트 확정)를 넣는다. 저장 입력·validation·S03 요청 지문이 같아야 한다. 잘못된 입력(12.)도 같은 issue 를 낸다.
12. 제거한 중복
| 중복 | 전 | 후 |
|---|---|---|
| "값이 있다" 판정 | mHasValue(모바일)·dkpRecordingFieldValueIsValid(데스크톱)·lgWorkoutSetHasValidRecordingValue(컨트롤러) | contracts/workout/measureFields.mHasValue 하나(데스크톱은 위임, 컨트롤러 것은 A09 제거 대상) |
| 완료 가능 판정 | mProfileSatisfied 호출 + 데스크톱의 별도 requirement 평가 | mProfileSatisfied 하나(데스크톱 위임) |
| lb/%/분:초/km 환산 | WorkoutRecord·WorkoutRecordParts·workoutCalculations | contracts/workout/loadUnits |
| 세트 유형 순서 토큰 | barbelicCopy.SET_TYPE_OPTIONS 에서 파생 | contracts/workout/setTypes.SET_TYPE_KEYS 정본, 라벨만 copy |
| 복합 이름 규칙 | wfxCompName(모바일)·dkpCompName(데스크톱) | compositeExerciseName(편집기가 사용; 화면 둘은 그대로 — U02/U03 정리) |
| 종목 프로필 해석 | makeFlowExercise·dkpFlowExercise·lgNormalizeWorkoutExercises | effectiveFields |
| A13 합류로 접은 것 | features/editing/minSecInput.ts(분해·합성 사본)·setTypeOrder.ts(순서 색인·정렬 사본) · 편집기 numericInput 의 자체 파서 표 · contracts/workout/loadUnits.percentToLoadKg/loadKgToPercent · 편집기 validation 의 자체 복합/프로필 판정 | contracts/workout 재수출 · numericInputCell(A13) · kgFromPercent/percentFromKg(A13) · missingRecordingFields/compositeFieldsListOf/compositeSharedLoadOf(A13) |
13. 인계
- A09 (#1342) — 이행(2026-09-08): §9 경계 — 컨트롤러 정규화 →
exercisesForSave(prepareEditorExercises).WorkoutWritePort결과 타입 좁히기는 남았다(A15). - A13 (#1340): 공유 편집 policy 는
features/editing/**(공동 소유), 그 밑의 수식·값 모델은contracts/workout/**— §10. 운동 편집기와 계획 편집기는 한 엔진으로 합치지 않는다.planintent 의 저장 조립은 A13planSaveIntent. - U02/U03: 화면을
WorkoutEditorState·view model 소비자로 —WorkoutRecord의 복합 초안 두 벌(compositeRepDrafts·compositePartDrafts)과 단위 오버라이드 상태는set.partInput·set.switchLoadUnit로 대체된다. 도크·타이머·모달은 화면 전용 상태로 남는다(§7 봉투). - S08 (#1326) 인계 미해결: 호출부의 호환 ref 대입 → 전이 함수 직접 호출은 A09 가 컨트롤러를 옮길 때 함께 한다(편집기는
operationId를 상태에 들고만 있다). - A16:
ui/shared재수출 경로 정리(정본은contracts/workout).
U03 실제 Desktop 소비·원문 초안 (2026-09-10)
#1421은 완료 기록을 DesktopWorkoutEditorAdapter → A12 command/view model로 연결했다. 계획 상태와 완료 기록 상태를 겸용하던 dkpOpenEditor/backfill extra 조립은 활성 화면에서 제거됐다. 입력 중 원문은 draft의 formattedInputs(분·초/km 문자열) 및 performanceInputRaw(휴식·목표/실패 횟수·RPE 문자열)로 저장한다. 복원은 저장된 typed cell의 kind/value를 신뢰하지 않고 공통 parser로 재검증한다. 2분+3.초 복원 후 3.5초 입력은 123.5초가 된다.
기존 m:ss 휴식 문자열은 Desktop 어댑터에서만 이전 초안 호환으로 초 단위로 변환한다. 신규 모델 원문은 이 갈래를 쓰지 않는다. UI-specific 표시 모드만 남으며 저장 전에 extra로 실패/휴식을 다시 보정하지 않는다. U03 계약, 검증 기록을 따른다.
U02 모바일 적용 (2026-09-10, #1420)
모바일의 실제 입력은 WorkoutFlowBinding.MobileEditorFlow → mobileWorkoutEditor의 동기 명령과 typed view로 전환했다. guardMobileSavePayload를 매번 다시 열어 검사하던 실제 화면 경로는 제거했다. 원문/IME와 단위/복합/실패/완료 관문은 A12, 초안과 저장 수명은 S08/A09를 사용한다. §12의 모바일 makeFlowSet/Exercise/Draft, 수정 여부·조기 종료·완료 시각 판단 사본과 UI debounce는 제거됐다. 분:초와 performance 원문 직렬화는 공통 draft.ts, 모바일 조합/표시 토큰은 mobileInputDraft.ts가 보완한다. 실제 경로·보존/확정 경계와 U03/U07/U06 인계는 모바일 편집 연결을 따른다.