스마트 피트니스 앱: 데이터 구조, 데이터베이스 스키마, 서버 설계 사양
아티팩트이 아티팩트는 에피소드 2: 앱 시스템 사양 계획하기 (노 코드)에서 사용됩니다. 이 사양을 Cloud Firestore 컬렉션, 서버 엔드포인트, 3계층 에이전트 메모리를 구조화하는 데 참고할 수 있습니다.
이 아티팩트에 액세스하려면 로그인하세요.
Google 개발자 프로필로 로그인하여 Build It! 시리즈에 사용된 전체 아티팩트를 확인하고 복사하세요.
개발자 프로필 필요
개발자 프로필을 사용할 수 있는 요건을 충족하지 않습니다. Workspace 관리자에게 이 기능을 사용 설정하도록 요청하세요.
개발자 프로필 만들기
무료 Google 개발자 프로필을 만들어 모든 Build It! 프롬프트와 아티팩트를 사용해 보세요.
Builder Central에 가입하여 활용하기
클릭 한 번으로 무료 빌더 멤버십 배지를 받아 모든 Build It! 프롬프트와 아티팩트를 이용하세요.
상태: 진행 중 (Planning Companion으로 초안 작성 중)
대상 데이터베이스: Google Cloud Firestore
아키텍처 목표: AI Studio 스마트 피트니스 프로토타입을 깔끔하고 유지보수 가능하며 프로덕션에 즉시 사용 가능한 상태로 재빌드합니다.
0. 핵심 기술 스택 및 시스템 아키텍처 결정
프로덕션 안정성, 성능, 명확한 경계를 보장하기 위해 모든 구현에서 다음 아키텍처 기준이 고정됩니다.
- 프런트엔드 프레임워크: 프로그레시브 웹 앱 (PWA) 오프라인 애셋 캐싱 (Serwist /
@ducanh2912/next-pwa사용)을 사용하는 Next.js (App Router, React 19) - 스타일 지정 및 UI 구성요소: Tailwind CSS v4 + Tailwind Variants (변형 관리용) + Lucide React (아이콘)
- 클라이언트 상태 아키텍처:
- Zustand(
persist미들웨어 사용): 로컬, 촉각, 임시 클라이언트 상태 (스톱워치 타이머, 활성 세트 버퍼, 걸음 수 그래프, 저장되지 않은 체육관 입력)를 처리합니다. - TanStack Query (React Query): 모든 비동기 서버 상태, Firestore 문서 캐싱, 백그라운드 재검증, 요청 중복 삭제, 낙관적 변형을 처리합니다.
- Zustand(
- 백엔드 런타임 및 API 호스팅: 15ms 미만의 내부 지연 시간을 위해 Firestore 및 Gemini 컨텍스트 캐싱과 동일한 리전의 Google Cloud Run에서 호스팅되는 Next.js 경로 핸들러 (
app/api/...) - 인증: Firebase 인증 (Google 로그인, 이메일/비밀번호, 익명 온보딩 연결) 클라이언트는 모든 API 요청에
Authorization: Bearer <Firebase_ID_Token>를 연결하고 서버는firebase-admin.auth().verifyIdToken(token)를 사용하여 ID를 확인합니다. - 기본 데이터베이스: Google Cloud Firestore
1. 시스템 개요 및 핵심 사용자 여정
1.1 범위 및 아키텍처 원칙
이 사양은 스마트 피트니스 애플리케이션의 데이터 모델, Cloud Firestore 스키마, 서버 엔드포인트 계약, AI 에이전트 메모리 구조를 정의합니다. 이 문서는 모든 운동 계획, 과거 세션 로깅, 자율적인 계층형 코칭 메모리를 위한 단일 정보 소스를 설정하며, 프런트엔드 프레젠테이션 프레임워크 (전용 UI/UX 디자인 사양에서 다룸)와 완전히 분리되어 있습니다.
1.2 핵심 데이터 원칙
- 관심사 분리: 데이터 계약 (백엔드/데이터베이스)과 클라이언트 UI 디자인 간의 엄격한 경계
- 하이브리드 라이브러리 아키텍처: 정적이고 변경 불가능한 운동 백과사전 (
exercises)과 주간 일정의 동적 AI 구성 (weekly_plans)이 결합되어 있습니다. - 휴먼인더루프(Human-In-The-Loop) 방식의 단계적 변형: AI 생성 운동 변경사항은 단계적 제안으로 반환되며, 사용자가 명시적으로 수락한 경우에만 데이터베이스에 기록됩니다.
- 지속적인 3단계 라이브 메모리: AI가 영구적인 기본 정보, 만료되는 생리적 상태 (명시적인 TTL 포함), 일시적인 상황적 맥락 전반에서 사용자 정보를 자율적으로 캡처, 업데이트, 삭제합니다.
- 세분화된 단일 문서 읽기: 데이터가 최적화된 문서 (예: 주간 계획 스프링 노트, 주간 채팅 버킷)로 구조화되어 읽기 지연 시간을 최소화하고 중복 쿼리를 제거합니다.
2. 서버 엔드포인트 및 API 계약
구현 중에 에이전트 지원 엔드포인트를 적절하게 구성할 수 있도록 모든 엔드포인트는 응답 소스 (
AGENT,DATABASE또는HYBRID)를 지정해야 합니다.
POST /api/coach/chat
- 목적: AI 퍼스널 트레이너와의 대화형 상호작용, 코칭 질문 처리, 동기 부여 지원, 제안된 운동 수정 (일일 조정 및 주간 점검) 생성
- 트리거: 사용자가 코치 탭에서 메시지를 제출하거나 인라인 컨텍스트 기반 추천 수정 칩 (예: '아널드 프레스를 DB 숄더 프레스로 대체')을 탭합니다.
- 응답 소스:
AGENT— 구조화된 도구/함수 호출 및 스트리밍 기능을 갖춘 Gemini를 활용합니다. 데이터베이스를 직접 변경하지 않고 휴먼인더루프 검토를 위해 스테이징된 제안을 반환합니다. - 헤더:
Authorization: Bearer <Firebase_ID_Token>,Content-Type: application/json
요청 페이로드:
{
"message": "string (user input or selected prompt chip text)",
"context": {
"active_tab": "string (enum: today | week | coach)",
"week_start_date": "string (ISO date YYYY-MM-DD)",
"selected_day": "string (e.g. monday, optional)"
}
}응답 페이로드:
{
"message": "string (conversational coaching response displayed in chat bubble)",
"proposal": {
"id": "string (unique proposal ID, nullable)",
"type": "string (enum: NONE | UPDATE_DAY | UPDATE_WEEK)",
"target_day": "string (e.g. monday, nullable)",
"summary_diff": "string (concise summary of changes, e.g. 'Swapped Arnold Press ➔ DB Shoulder Press', nullable)",
"data": "map (full proposed workout day object or weekly plan object, nullable)"
},
"memory_mutations": [
{
"operation": "string (enum: CREATE | UPDATE | DELETE)",
"target_tier": "string (enum: tier_1_staples | tier_2_physiological | tier_3_situational)",
"memory_id": "string (required for UPDATE and DELETE)",
"entry": "map (memory payload with fact, category, expires_at, etc., required for CREATE and UPDATE)",
"reason": "string (concise rationale for why memory was mutated)"
}
]
}weekly_chats에 이중 쓰기: 각 차례에서 서버는FieldValue.arrayUnion()를 사용하여 수신되는 사용자 메시지 (text)와 코치의 대답 (text)을/users/{userId}/weekly_chats/{week_start_date}에 유지합니다.- Staged Mutation Pattern: 사용자가 [Accept Plan]을 탭할 때까지
/weekly_plans를 수정하지 않고 제안 페이로드를 반환합니다. - 지속적인 자율 메모리 관리:
memory_mutations를 실행하여 사용자의 3단계 프로필에서 항목을 생성, 업데이트 또는 삭제합니다.
POST /api/workouts/log
- 목적: 완료된 운동 세션을 사용자의 영구 운동 기록 보관소에 유지하고, 최종 운동량/반복 횟수 집계를 계산하고, 개인 기록 (PR)을 확인하고, 예약된 날짜를 완료로 표시합니다.
- 트리거: 사용자가 운동 기록 화면에서 '운동 완료'를 탭합니다.
- 응답 소스:
DATABASE— Cloud Firestore에 대한 직접 데이터베이스 트랜잭션/쓰기입니다.
요청 페이로드:
{
"plan_reference": {
"week_start_date": "string (ISO date YYYY-MM-DD)",
"day_of_week": "string (e.g. monday)"
},
"title": "string (e.g. Upper Body Hypertrophy)",
"started_at": "string (ISO timestamp)",
"completed_at": "string (ISO timestamp)",
"duration_seconds": "number (e.g. 3120)",
"exercises_logged": [
{
"exercise_id": "string (canonical slug, e.g. incline-db-bench-press)",
"exercise_name": "string (e.g. Incline DB Bench Press)",
"sets": [
{
"set_number": "number (e.g. 1)",
"weight_lbs": "number (e.g. 60)",
"reps_completed": "number (e.g. 12)",
"completed": "boolean"
}
]
}
],
"user_notes": "string (optional subjective feedback)",
"perceived_exertion": "number (optional 1-10 RPE rating)"
}응답 페이로드:
{
"success": "boolean",
"log_id": "string (newly created workout log document ID)",
"summary": {
"total_volume_lbs": "number",
"total_sets_completed": "number",
"total_reps_completed": "number"
},
"new_prs": [
{
"exercise_id": "string (canonical slug, e.g. incline-db-bench-press)",
"metric": "string (e.g. '10 Rep Max' or 'Estimated 1RM')",
"value": "string (e.g. '65 lbs' or '105 lbs')"
}
]
}3. Cloud Firestore 데이터베이스 아키텍처
소식 모음: exercises
계층 구조: 루트 컬렉션 (/exercises/{exercise_id}) • 목적: 멀티미디어, 자세 팁, 장비 요구사항, 안내가 포함된 정적 운동 백과사전입니다.
{
"id": "string (canonical slug matching document ID, e.g. incline-db-bench-press)",
"name": "string (e.g. Incline DB Bench Press)",
"category": "string (e.g. Strength, Hypertrophy, Cardio, Mobility)",
"target_muscles": {
"primary": ["string (e.g. Upper Chest)"],
"secondary": ["string (e.g. Front Deltoids, Triceps)"]
},
"media": {
"video_url": "string (URL to demonstration video)",
"poster_url": "string (URL to video poster / thumbnail image)"
},
"instructions": ["string (step-by-step execution guidance)"],
"form_tips": ["string (critical cues and common pitfalls)"],
"equipment_required": ["string (e.g. Dumbbells, Incline Bench)"]
}소식 모음: weekly_plans
계층 구조: /users/{userId}/weekly_plans/{week_start_date} • 목적: 전체 주간 운동 일정, 요약 측정항목, 개별 일일 운동이 포함된 '스프링 노트'입니다.
{
"week_start_date": "string (ISO date YYYY-MM-DD, e.g. 2026-09-07)",
"theme": "string (e.g. Hypertrophy Focus & Upper Body Overload)",
"summary": {
"total_time_minutes": "number (raw minutes, e.g. 180)",
"total_volume_lbs": "number (raw tonnage in lbs, e.g. 38400)",
"frequency": "number (active workout days, e.g. 4)"
},
"suggested_edits": ["string (pre-generated AI prompt suggestion)"],
"days": {
"monday": {
"date": "string (ISO date YYYY-MM-DD)",
"day_of_week": "string (e.g. Monday)",
"is_rest_day": "boolean",
"status": "string (enum: scheduled | completed | skipped)",
"completed_log_id": "string (nullable)",
"completed_at": "string (ISO timestamp, nullable)",
"title": "string (e.g. Upper Body Hypertrophy)",
"focus_tag": "string (e.g. Push & Chest Dominant)",
"duration_minutes": "number",
"estimated_volume_lbs": "number",
"coach_note": "string",
"muscle_focus": [{ "muscle": "string", "intensity": "number (0-100)" }],
"suggested_edits": ["string"],
"blocks": [
{
"label": "string (e.g. Main Compound Block)",
"exercises": [
{
"exercise_id": "string",
"name": "string",
"target_sets": "number",
"target_reps": "string or number",
"rest_seconds": "number"
}
]
}
]
}
}
}소식 모음: weekly_chats
계층 구조: /users/{userId}/weekly_chats/{week_start_date} • 목적: 단일 문서 읽기에서 특정 학습 주차의 전체 대화 기록이 포함된 '주간 채팅 버킷'입니다.
{
"week_start_date": "string (ISO date YYYY-MM-DD, e.g. 2026-09-07)",
"messages": [
{
"id": "string (unique message ID)",
"sender": "string (enum: user | coach)",
"text": "string (conversational message body)",
"created_at": "timestamp (server timestamp)",
"proposal": {
"id": "string (proposal UID, nullable)",
"type": "string (enum: UPDATE_DAY | UPDATE_WEEK, nullable)",
"target_day": "string (e.g. monday, nullable)",
"summary_diff": "string (nullable)",
"status": "string (enum: pending | accepted | dismissed)",
"data": "map (workout or week snapshot payload, nullable)"
}
}
]
}소식 모음: workout_logs
계층 구조: /users/{userId}/workout_logs/{log_id} • 목적: 완료된 운동 세션의 영구적이고 변경 불가능한 기록 보관소입니다.
{
"id": "string (document ID)",
"plan_reference": {
"week_start_date": "string (ISO date YYYY-MM-DD)",
"day_of_week": "string (e.g. monday)"
},
"title": "string (e.g. Upper Body Hypertrophy)",
"started_at": "timestamp",
"completed_at": "timestamp",
"duration_seconds": "number",
"summary": {
"total_volume_lbs": "number",
"total_sets_completed": "number",
"total_reps_completed": "number"
},
"exercises_logged": [
{
"exercise_id": "string",
"exercise_name": "string",
"sets": [
{
"set_number": "number",
"weight_lbs": "number",
"reps_completed": "number",
"completed": "boolean"
}
]
}
],
"user_notes": "string (nullable)",
"perceived_exertion": "number (1-10 RPE, nullable)"
}소식 모음: user_profiles
계층 구조: /users/{userId}/profile/main • 목적: 사용자의 3단계 메모리 프로필을 저장하는 'Living Client Chart'입니다.
{
"user_id": "string (document owner UID)",
"display_name": "string (e.g. Alex)",
"email": "string (e.g. alex@example.com)",
"preferences": {
"units": "string (enum: lbs | kg)",
"haptics": "boolean",
"sound": "boolean"
},
"equipment_ids": ["string (e.g. 'dumbbells', 'bench_adjustable', 'pull_up_bar')"],
"onboarding_status": "string (enum: not_started | in_progress | completed)",
"created_at": "timestamp",
"tier_1_staples": [
{
"id": "string (e.g. mem_01)",
"category": "string (enum: equipment | preference | goal | chronic_condition)",
"fact": "string (e.g. Home gym with dumbbells up to 50 lbs and adjustable bench)",
"source": "string (e.g. conversational_onboarding | manual_entry)"
}
],
"tier_2_physiological": [
{
"id": "string (e.g. mem_02)",
"body_part": "string (e.g. hamstrings, shoulder)",
"condition": "string (e.g. soreness, acute_fatigue, minor_strain)",
"fact": "string (e.g. Hamstrings are moderately sore from hill sprints)",
"effective_date": "string (ISO date YYYY-MM-DD)",
"expires_at": "timestamp"
}
],
"tier_3_situational": [
{
"id": "string (e.g. mem_03)",
"event": "string (e.g. golf_competition, travel)",
"fact": "string (e.g. Major golf tournament on Saturday; avoid excessive arm/lat fatigue on Friday)",
"week_start_date": "string (ISO date YYYY-MM-DD)",
"expires_at": "timestamp"
}
]
}소식 모음: prs
계층 구조: /users/{userId}/prs/{exercise_slug} • 목적: 역대 개인 기록, 예상 1회 최대 반복 횟수 기준, 반복 횟수별 기록을 저장합니다.
{
"exercise_id": "string (canonical slug, e.g. incline-db-bench-press)",
"exercise_name": "string (e.g. Incline DB Bench Press)",
"estimated_1rm": "number (calculated using Brzycki formula, e.g. 105)",
"max_weight_set": {
"weight_lbs": "number (e.g. 80)",
"reps": "number (e.g. 10)",
"achieved_at": "timestamp"
},
"rep_records": {
"10": 80,
"8": 85,
"5": 95
},
"last_updated": "timestamp"
}4. 데이터 수명 주기 및 에이전트 파이프라인
4.1 파이프라인 1: 코칭, 운동 생성, 단계별 수정
- 사용자 작업: 자유 형식 입력을 제출하거나 사전 생성된 문맥 수정 칩을 탭합니다.
- 컨텍스트 조합: 서버가 활성 3계층 메모리 프로필과 현재 주간 계획을 검색하고 만료된 메모리 항목을 정리합니다.
- 에이전트 추론: Gemini가 정형 도구/함수 호출을 사용하여 프롬프트와 컨텍스트를 처리합니다.
- 자율 메모리 변형: 추출된 사용자 사실이
/user_profiles/{userId}에 적용되고equipment_ids와 동기화됩니다. - Staged Proposal Staging: 제안된 운동 수정사항이
/weekly_chats/{week_start_date}내에proposal.status = "pending"와 함께 반환됩니다. - 사용자 해결 및 지속성: [계획 수락]을 탭하면 제안된 데이터가
/weekly_plans에 커밋되고 제안이"accepted"로 표시됩니다.
4.2 파이프라인 2: 운동 실행 및 보관처리 (정답)
- 실시간 세션 기록: 클라이언트가 로컬 UI 상태에서 실제 들어 올린 무게, 완료한 반복 횟수, 완료 플래그를 추적합니다.
- 세션 제출: 클라이언트가 '운동 완료' 시
POST /api/workouts/log를 호출합니다. - 집계 계산 및 검증: 서버가 누적 세션 볼륨을 계산하고 브르지키 공식을 사용하여 PR을 확인합니다.
- 불변 지속성:
/workout_logs에 불변 레코드를 작성하고,/weekly_plans에"completed"일을 표시하고,/prs에 새 PR을 업서트합니다.
4.3 파이프라인 3: 자율 메모리 수집 및 무효화
- 수집: 모든 대화 상호작용에서 사실이 지속적으로 추출됩니다.
- 분류: Tier 1 (필수품, 만료 없음), Tier 2 (생리적 상태, 3~4일 TTL), Tier 3 (상황적 맥락, 이벤트 기반 TTL)
- 정리 및 무효화: 만료된 항목 (
current_timestamp > expires_at)은 프롬프트 컨텍스트에서 필터링되고 지연 삭제됩니다.
5. Firestore 보안 규칙 및 복합 색인 사양
5.1 보안 규칙 (firestore.rules)
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
match /profile/{document=**} { allow read, write: if request.auth != null && request.auth.uid == userId; }
match /weekly_plans/{planId} { allow read, write: if request.auth != null && request.auth.uid == userId; }
match /weekly_chats/{chatId} { allow read, write: if request.auth != null && request.auth.uid == userId; }
match /prs/{exerciseSlug} { allow read, write: if request.auth != null && request.auth.uid == userId; }
match /workout_logs/{logId} {
allow read, create: if request.auth != null && request.auth.uid == userId;
allow update, delete: if false;
}
}
match /exercises/{exerciseId} {
allow read: if request.auth != null;
allow write: if false;
}
}
}5.2 복합 쿼리 색인 (firestore.indexes.json)
{
"indexes": [
{
"collectionGroup": "workout_logs",
"queryScope": "COLLECTION",
"fields": [{ "fieldPath": "completed_at", "order": "DESCENDING" }]
},
{
"collectionGroup": "weekly_plans",
"queryScope": "COLLECTION",
"fields": [{ "fieldPath": "week_start_date", "order": "DESCENDING" }]
},
{
"collectionGroup": "prs",
"queryScope": "COLLECTION",
"fields": [{ "fieldPath": "last_updated", "order": "DESCENDING" }]
}
]
}6. 변경 로그 및 확정된 결정
| 날짜 | 구성요소/결정 | 유형 | 잠금 설정자 |
|---|---|---|---|
| 초기 설정 | 빈 사양 스캐폴드가 초기화되었습니다. | 시스템 설정 | 팀 |
| 2026-09-04 | Firestore 컬렉션(exercises 및 weekly_plans) 추가 | 스키마 | 팀 |
| 2026-09-04 | POST /api/coach/chat 엔드포인트 및 chat_messages 컬렉션 추가 | 엔드포인트 및 스키마 | 팀 |
| 2026-09-04 | 채팅 스토리지를 weekly_chats 버킷 스키마로 리팩터링했습니다. | 스키마 | 팀 |
| 2026-09-04 | POST /api/workouts/log 엔드포인트 및 workout_logs 컬렉션 추가 | 엔드포인트 및 스키마 | 팀 |
| 2026-09-04 | user_profiles 3계층 메모리 스키마를 추가하고 memory_mutations를 사용하여 POST /api/coach/chat을 업데이트했습니다. | 엔드포인트 및 스키마 | 팀 |
| 2026-09-04 | 섹션 1 (시스템 범위 및 원칙) 및 섹션 4 (데이터 수명 주기 및 파이프라인) 완료 | 전체 사양 완료 | 팀 |
| 2026-09-22 | 조정된 사양: Next.js + Tailwind + Zustand 스택, Bearer 인증, PR 하위 컬렉션, Firestore 규칙, 표준 연습 슬러그 | 전체 사양 조화 | 아키텍처 검토 |