> [!takeaway] 비즈니스/방향성 관점에서의 핵심 > [[사업-Lapie-피팅앱]] design의 **v0 4~6주 구체 구현 계획**. 7월 착수 직전 (6월 말 [[프로젝트-우리카드-AI숏폼-공모전]] 마감 후) 재수정 가능. 박재오는 RN 신규 영역이므로 모든 task는 bite-sized + TDD 가능 부분은 TDD / UI·카메라·합성은 manual test 절차 명시. 핵심 차별화 = **Task 9 자동 스케일** = v0 W3 발목 잡힐 가능성 가장 큰 곳. # 사업 — Lapie 피팅앱 v0 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** iOS 단독 RN 앱 v0 빌드 — 정면 전신 사진 등록 → 자동 마스킹(상/하/전신 3모드) → 카메라 라이브 합성(자동스케일+핀치) → 캡쳐·SNS 공유까지. 박재오 본인+아내+친구 5명이 사용 가능한 수준. **Architecture:** Expo bare workflow 위 React Native + TypeScript. 카메라·세그멘테이션·합성은 react-native-vision-camera + react-native-skia로 처리. iOS Vision Framework 직접 호출 부분은 Swift 네이티브 모듈로 분리. 상태는 Zustand로 최소화. **Tech Stack:** React Native (0.76+) / Expo (52+, bare) / TypeScript / react-native-vision-camera (v4) / react-native-skia (v1) / @shopify/react-native-skia / react-native-worklets-core / Vision Framework (Swift bridge) / Zustand / Jest (유닛) / Detox (선택, v1으로 미룸) **전제 조건:** - 박재오 가용시간: 주 15~20h. 7월 1주 착수 가정. - macOS + Xcode 15+ 필요 (현재 박재오 메인은 Windows이므로 Xcode 접근 방안 별도 확인 — 5번 액션 참고). - Apple Developer Program 가입 ($99/yr). - 워크스페이스: `C:\Users\jaeoh\Desktop\workspace\lapie\` 신설 (또는 macOS 경로). **중요:** 박재오 Windows 환경에서 RN iOS 빌드 불가. 다음 중 선택: - (a) macOS 기기 확보 (중고 Mac mini M1 ~50만) — Day 0 결정 항목 - (b) Expo EAS Build 클라우드 빌드 ($29/월) — Mac 없이 가능, 단 iOS 네이티브 모듈 디버깅 어려움 - (c) 처음부터 RN 포기, **Flutter or 웹 PWA** 재검토 → design 재논의 필요 → **이 plan은 (a) 또는 (b) 가정**. 7월 착수 전 결정 필요. --- ## Pre-Tasks: Day 0 차단성 액션 (착수 전, 비코딩) ### Task 0.1: 도메인·앱스토어·인스타·상표 검증 **Files:** 없음 (외부 검색) - [ ] **Step 1: 도메인 가용성 확인** - `lapie.app` / `lapie.kr` / `lapie.io` Namecheap·Cafe24에서 검색 - 결과 기록: [[사업-Lapie-피팅앱]] "Day 0 차단성 액션" 표에 ✅/❌ 표시 - 모두 사용 불가 시 대안: Wittu / Mirree / Geola 순으로 재검증 - [ ] **Step 2: 앱스토어 동명 앱 검색** - App Store (iOS) + Google Play (Android) 검색: "Lapie", "라피" - 동명 패션·피팅 앱 존재 시 → 네이밍 재검토 - [ ] **Step 3: 인스타 핸들 확보** - `@lapie` / `@lapie_app` / `@lapie_official` 검색 - 가능한 핸들 1개 즉시 선점 등록 (이메일·비밀번호만 필요, 정식 운영은 W4 직전) - [ ] **Step 4: 상표 검색 (KIPRIS + USPTO)** - KIPRIS (http://www.kipris.or.kr) "Lapie" 검색 — 의류·앱 류 - USPTO (https://tmsearch.uspto.gov) "Lapie" 검색 - 선출원 발견 시 → 네이밍 재검토 - [ ] **Step 5: 결과를 wiki에 반영** - [[사업-Lapie-피팅앱]] "Day 0 차단성 액션" 표에 검증 결과 갱신 - 충돌 발견 시 본 plan 머리의 brand name을 새 후보로 변경 ### Task 0.2: 개발 환경 결정 - [ ] **Step 1: macOS 접근 방안 결정** - (a) 중고 Mac mini M1 구매 (~50만) - (b) EAS Build 클라우드 ($29/월 = 7월~10월 약 12만) - (c) Flutter or PWA 재논의 (design 변경 필요) - 결정 → [[사업-Lapie-피팅앱]] 변경 이력에 한 줄 기록 - [ ] **Step 2: Apple Developer Program 가입** - https://developer.apple.com/programs/ $99/yr - 7월 착수 직전 가입 (그 전에 가입하면 1년 카운트가 일찍 시작됨) --- ## Workspace Setup ### Task 1: RN + Expo 프로젝트 초기화 **Files:** - Create: `C:/Users/jaeoh/Desktop/workspace/lapie/` (또는 mac 경로) - Create: `lapie/package.json` (Expo CLI가 생성) - Create: `lapie/app.json` - Create: `lapie/tsconfig.json` - [ ] **Step 1: Expo 프로젝트 생성** ```bash cd C:/Users/jaeoh/Desktop/workspace/ npx create-expo-app@latest lapie --template blank-typescript cd lapie ``` Expected: `lapie/` 디렉터리에 `App.tsx`, `package.json`, `tsconfig.json` 생성. - [ ] **Step 2: Bare workflow로 전환 (네이티브 모듈 필요)** ```bash npx expo prebuild --platform ios ``` Expected: `lapie/ios/` 디렉터리 생성, CocoaPods install. (Windows에서는 prebuild 실패 가능 — mac 환경 또는 EAS에서 실행). - [ ] **Step 3: 필수 의존성 설치** ```bash npm install react-native-vision-camera @shopify/react-native-skia react-native-worklets-core zustand react-native-safe-area-context npm install --save-dev jest @types/jest ts-jest ``` - [ ] **Step 4: 권한 설정 (`app.json`)** ```json { "expo": { "name": "Lapie", "slug": "lapie", "ios": { "bundleIdentifier": "com.lapie.app", "infoPlist": { "NSCameraUsageDescription": "옷을 카메라에 비춰 합성 미리보기에 사용합니다.", "NSPhotoLibraryUsageDescription": "정면 사진을 등록하고 캡쳐를 저장하기 위해 사용합니다.", "NSPhotoLibraryAddUsageDescription": "합성 결과 사진을 저장하기 위해 사용합니다." } } } } ``` - [ ] **Step 5: 첫 빌드 확인** ```bash npx expo run:ios ``` Expected: iOS 시뮬레이터에 기본 화면 표시. - [ ] **Step 6: Git 초기화 + 첫 커밋** ```bash cd lapie git init git add . git commit -m "chore: initial Expo + RN + TS setup with vision-camera/skia deps" ``` --- ## W1 — 카메라 + 세그멘테이션 PoC ### Task 2: Vision Camera 카메라 화면 + 권한 **Files:** - Create: `lapie/src/screens/CameraTestScreen.tsx` - Modify: `lapie/App.tsx` - [ ] **Step 1: 카메라 권한 hook 작성** `lapie/src/screens/CameraTestScreen.tsx`: ```tsx import { useEffect } from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera'; export function CameraTestScreen() { const { hasPermission, requestPermission } = useCameraPermission(); const device = useCameraDevice('back'); useEffect(() => { if (!hasPermission) requestPermission(); }, [hasPermission, requestPermission]); if (!hasPermission) { return 카메라 권한이 필요합니다; } if (!device) { return 카메라 디바이스 없음; } return ( ); } const styles = StyleSheet.create({ center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, }); ``` - [ ] **Step 2: App.tsx에서 호출** `lapie/App.tsx`: ```tsx import { CameraTestScreen } from './src/screens/CameraTestScreen'; export default function App() { return ; } ``` - [ ] **Step 3: 실기기 빌드 + 검증** (시뮬레이터는 카메라 없음) ```bash npx expo run:ios --device ``` Expected: 실기기에서 후방 카메라 실시간 프리뷰 표시. - [ ] **Step 4: 커밋** ```bash git add src/screens/CameraTestScreen.tsx App.tsx git commit -m "feat: vision-camera live preview with permission flow" ``` ### Task 3: iOS Vision Selfie Segmentation 네이티브 브릿지 **Files:** - Create: `lapie/modules/segmentation/ios/SegmentationModule.swift` - Create: `lapie/modules/segmentation/ios/SegmentationModule.m` - Create: `lapie/src/features/segmentation/index.ts` - Create: `lapie/src/features/segmentation/segmentImage.ts` - Test: `lapie/src/features/segmentation/__tests__/segmentation.test.ts` > Vision Framework의 `VNGeneratePersonSegmentationRequest`를 RN으로 노출. 입력: 이미지 URI, 출력: 마스크 PNG URI. - [ ] **Step 1: Swift 모듈 작성** `lapie/modules/segmentation/ios/SegmentationModule.swift`: ```swift import Foundation import Vision import UIKit @objc(SegmentationModule) class SegmentationModule: NSObject { @objc static func requiresMainQueueSetup() -> Bool { false } @objc(segmentPerson:resolver:rejecter:) func segmentPerson(_ imageUri: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { guard let url = URL(string: imageUri), let imageData = try? Data(contentsOf: url), let uiImage = UIImage(data: imageData), let cgImage = uiImage.cgImage else { rejecter("E_IMG", "Image load failed", nil) return } let request = VNGeneratePersonSegmentationRequest() request.qualityLevel = .accurate request.outputPixelFormat = kCVPixelFormatType_OneComponent8 let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) guard let result = request.results?.first else { rejecter("E_NO_RESULT", "No segmentation result", nil); return } // PixelBuffer → PNG 파일로 저장 let maskUri = try saveMaskPNG(result.pixelBuffer, originalSize: uiImage.size) resolver(maskUri) } catch { rejecter("E_SEG", error.localizedDescription, error) } } private func saveMaskPNG(_ pixelBuffer: CVPixelBuffer, originalSize: CGSize) throws -> String { let ciImage = CIImage(cvPixelBuffer: pixelBuffer) let scaleX = originalSize.width / ciImage.extent.width let scaleY = originalSize.height / ciImage.extent.height let scaled = ciImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) let context = CIContext() guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { throw NSError(domain: "Lapie", code: 1) } let uiImage = UIImage(cgImage: cgImage) let pngData = uiImage.pngData()! let path = NSTemporaryDirectory() + "mask_\(UUID().uuidString).png" try pngData.write(to: URL(fileURLWithPath: path)) return "file://" + path } } ``` - [ ] **Step 2: Objective-C 브릿지** `lapie/modules/segmentation/ios/SegmentationModule.m`: ```objc #import @interface RCT_EXTERN_MODULE(SegmentationModule, NSObject) RCT_EXTERN_METHOD(segmentPerson:(NSString *)imageUri resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @end ``` - [ ] **Step 3: TS 래퍼** `lapie/src/features/segmentation/index.ts`: ```ts import { NativeModules } from 'react-native'; const { SegmentationModule } = NativeModules; export async function segmentPerson(imageUri: string): Promise { if (!SegmentationModule?.segmentPerson) { throw new Error('SegmentationModule not linked'); } return SegmentationModule.segmentPerson(imageUri); } ``` - [ ] **Step 4: 유닛 테스트 (모킹)** `lapie/src/features/segmentation/__tests__/segmentation.test.ts`: ```ts import { segmentPerson } from '..'; jest.mock('react-native', () => ({ NativeModules: { SegmentationModule: { segmentPerson: jest.fn(async (uri: string) => `file://mask_for_${uri}.png`), }, }, })); describe('segmentPerson', () => { it('returns mask URI for input image', async () => { const result = await segmentPerson('file://input.jpg'); expect(result).toBe('file://mask_for_file://input.jpg.png'); }); }); ``` - [ ] **Step 5: 테스트 실행 (실패 → 통과)** ```bash npx jest src/features/segmentation ``` Expected: 1 passed (모킹된 호출 검증). - [ ] **Step 6: 실기기 manual test** - Photo Library에서 정면사진 1장 선택 → `segmentPerson(uri)` 호출 - 반환된 mask URI를 ``로 표시 - 시각 검증: 사람 영역이 흰색, 배경이 검은색인 마스크 확인 - [ ] **Step 7: CocoaPods 설치 + 커밋** ```bash cd ios && pod install && cd .. git add modules/segmentation src/features/segmentation git commit -m "feat: iOS Vision person segmentation native bridge" ``` --- ## W2 — 정면사진 등록 + Pose 키포인트 + 마스크 분할 ### Task 4: iOS Vision Pose Detection 네이티브 브릿지 **Files:** - Create: `lapie/modules/pose/ios/PoseModule.swift` - Create: `lapie/modules/pose/ios/PoseModule.m` - Create: `lapie/src/features/pose/index.ts` - Test: `lapie/src/features/pose/__tests__/pose.test.ts` - [ ] **Step 1: Swift 모듈 작성 (17개 keypoint 반환)** `lapie/modules/pose/ios/PoseModule.swift`: ```swift import Foundation import Vision import UIKit @objc(PoseModule) class PoseModule: NSObject { @objc static func requiresMainQueueSetup() -> Bool { false } @objc(detectPose:resolver:rejecter:) func detectPose(_ imageUri: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { guard let url = URL(string: imageUri), let data = try? Data(contentsOf: url), let uiImage = UIImage(data: data), let cgImage = uiImage.cgImage else { rejecter("E_IMG", "Image load failed", nil); return } let request = VNDetectHumanBodyPoseRequest() let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) guard let observation = request.results?.first else { rejecter("E_NO_POSE", "No pose detected", nil); return } let points = try observation.recognizedPoints(.all) let imageW = CGFloat(cgImage.width) let imageH = CGFloat(cgImage.height) var result: [String: [String: Any]] = [:] for (jointName, point) in points where point.confidence > 0.3 { // Vision 좌표: 좌하단 원점, normalized (0~1). UIKit으로 변환. result[jointName.rawValue.rawValue] = [ "x": point.location.x * imageW, "y": (1 - point.location.y) * imageH, "confidence": point.confidence, ] } resolver(result) } catch { rejecter("E_POSE", error.localizedDescription, error) } } } ``` - [ ] **Step 2: Obj-C 브릿지** `lapie/modules/pose/ios/PoseModule.m`: ```objc #import @interface RCT_EXTERN_MODULE(PoseModule, NSObject) RCT_EXTERN_METHOD(detectPose:(NSString *)imageUri resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @end ``` - [ ] **Step 3: TS 래퍼 + 타입** `lapie/src/features/pose/index.ts`: ```ts import { NativeModules } from 'react-native'; const { PoseModule } = NativeModules; export interface Keypoint { x: number; y: number; confidence: number; } export type Joint = | 'left_shoulder_joint' | 'right_shoulder_joint' | 'left_hip_joint' | 'right_hip_joint' | 'left_ankle_joint' | 'right_ankle_joint' | 'neck_1_joint' | 'root'; export type PoseResult = Partial>; export async function detectPose(imageUri: string): Promise { if (!PoseModule?.detectPose) throw new Error('PoseModule not linked'); return PoseModule.detectPose(imageUri); } ``` - [ ] **Step 4: 유닛 테스트 (모킹)** `lapie/src/features/pose/__tests__/pose.test.ts`: ```ts import { detectPose } from '..'; jest.mock('react-native', () => ({ NativeModules: { PoseModule: { detectPose: jest.fn(async () => ({ left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 }, right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 }, })), }, }, })); describe('detectPose', () => { it('returns keypoint map', async () => { const result = await detectPose('file://photo.jpg'); expect(result.left_shoulder_joint?.x).toBe(100); expect(result.right_shoulder_joint?.x).toBe(300); }); }); ``` - [ ] **Step 5: 실기기 manual test** - 정면사진 1장 입력 → 어깨·골반·발목 keypoint가 사진 위 정확한 위치에 표시되는지 시각 검증 - 임시 화면 `PoseDebugScreen.tsx`에 keypoint를 점으로 overlay - [ ] **Step 6: 커밋** ```bash git add modules/pose src/features/pose git commit -m "feat: iOS Vision human body pose native bridge (17 keypoints)" ``` ### Task 5: 정면사진 등록 화면 + 자세 가이드 오버레이 **Files:** - Create: `lapie/src/screens/PhotoCaptureScreen.tsx` - Create: `lapie/src/features/photoValidation/validatePose.ts` - Test: `lapie/src/features/photoValidation/__tests__/validatePose.test.ts` - [ ] **Step 1: 자세 검증 함수 (TDD)** `lapie/src/features/photoValidation/__tests__/validatePose.test.ts`: ```ts import { validatePose } from '../validatePose'; describe('validatePose', () => { it('passes when all required joints present with high confidence', () => { const pose = { left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 }, right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 }, left_hip_joint: { x: 120, y: 400, confidence: 0.85 }, right_hip_joint: { x: 280, y: 400, confidence: 0.85 }, left_ankle_joint: { x: 130, y: 700, confidence: 0.7 }, right_ankle_joint: { x: 270, y: 700, confidence: 0.7 }, }; const result = validatePose(pose); expect(result.score).toBeGreaterThanOrEqual(80); expect(result.passed).toBe(true); }); it('fails when shoulders missing', () => { const pose = { left_hip_joint: { x: 120, y: 400, confidence: 0.85 }, right_hip_joint: { x: 280, y: 400, confidence: 0.85 }, }; const result = validatePose(pose); expect(result.passed).toBe(false); expect(result.reasons).toContain('어깨가 보이지 않습니다'); }); it('fails when body tilted (shoulders y diff > 30px)', () => { const pose = { left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 }, right_shoulder_joint: { x: 300, y: 250, confidence: 0.9 }, left_hip_joint: { x: 120, y: 400, confidence: 0.85 }, right_hip_joint: { x: 280, y: 400, confidence: 0.85 }, left_ankle_joint: { x: 130, y: 700, confidence: 0.7 }, right_ankle_joint: { x: 270, y: 700, confidence: 0.7 }, }; const result = validatePose(pose); expect(result.passed).toBe(false); expect(result.reasons).toContain('몸이 기울어져 있습니다'); }); }); ``` - [ ] **Step 2: 실행 → 실패 확인** ```bash npx jest src/features/photoValidation ``` Expected: FAIL — validatePose not defined. - [ ] **Step 3: 구현** `lapie/src/features/photoValidation/validatePose.ts`: ```ts import type { PoseResult } from '../pose'; export interface ValidationResult { score: number; passed: boolean; reasons: string[]; } const REQUIRED = [ 'left_shoulder_joint', 'right_shoulder_joint', 'left_hip_joint', 'right_hip_joint', 'left_ankle_joint', 'right_ankle_joint', ] as const; export function validatePose(pose: PoseResult): ValidationResult { const reasons: string[] = []; for (const joint of REQUIRED) { if (!pose[joint] || pose[joint]!.confidence < 0.5) { const label = joint.includes('shoulder') ? '어깨' : joint.includes('hip') ? '골반' : '발목'; const msg = `${label}이 보이지 않습니다`; if (!reasons.includes(msg)) reasons.push(msg); } } if (pose.left_shoulder_joint && pose.right_shoulder_joint) { const yDiff = Math.abs(pose.left_shoulder_joint.y - pose.right_shoulder_joint.y); if (yDiff > 30) reasons.push('몸이 기울어져 있습니다'); } const score = Math.max(0, 100 - reasons.length * 25); return { score, passed: score >= 80, reasons }; } ``` - [ ] **Step 4: 테스트 통과 확인** ```bash npx jest src/features/photoValidation ``` Expected: 3 passed. - [ ] **Step 5: 사진 등록 화면 (UI)** `lapie/src/screens/PhotoCaptureScreen.tsx`: ```tsx import { useState } from 'react'; import { View, Text, Button, Image, StyleSheet } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { detectPose } from '../features/pose'; import { validatePose, ValidationResult } from '../features/photoValidation/validatePose'; export function PhotoCaptureScreen({ onPhotoReady }: { onPhotoReady: (uri: string) => void }) { const [photoUri, setPhotoUri] = useState(null); const [validation, setValidation] = useState(null); async function pickAndValidate() { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 0.8, }); if (result.canceled) return; const uri = result.assets[0].uri; setPhotoUri(uri); const pose = await detectPose(uri); const v = validatePose(pose); setValidation(v); } return ( 정면 전신 사진 • 어깨·골반·발목이 모두 보이도록{'\n'} • 정면을 응시{'\n'} • 단색 배경 권장