버전: 1.0
작성일: 2025-11-20
프로젝트: AI 기반 개인 맞춤형 건강 식단 추천 시스템
Fitmealor는 AI 기술을 활용한 개인 맞춤형 건강 식단 추천 플랫폼으로, 다음 기능을 제공합니다:
| 계층 | 기술 | 버전 | 선택 이유 |
|---|---|---|---|
| Frontend | React | 18.x | 컴포넌트 기반 UI, 풍부한 생태계 |
| TypeScript | 5.x | 타입 안정성, 개발 생산성 향상 | |
| Vite | 5.x | 빠른 빌드 속도, HMR 지원 | |
| Tailwind CSS | 3.x | 유틸리티 우선 CSS, 빠른 UI 개발 | |
| React Router | 6.x | SPA 라우팅 | |
| i18next | 23.x | 다국어 지원 | |
| Backend | FastAPI | 0.104+ | 비동기 처리, 자동 API 문서화 |
| Python | 3.10+ | AI/ML 생태계, 빠른 개발 | |
| SQLAlchemy | 2.x | ORM, 데이터베이스 추상화 | |
| Alembic | 1.x | 데이터베이스 마이그레이션 | |
| Pydantic | 2.x | 데이터 검증, 타입 안정성 | |
| Database | PostgreSQL | 14+ | 관계형 DB, ACID 보장 |
| SQLite | 3.x | 개발/테스트 환경 | |
| AI/ML | OpenAI GPT-4o-mini | Latest | 번역, 영양정보 추출, 챗봇 |
| OpenAI Vision API | Latest | 이미지 기반 영양정보 추출 | |
| CLOVA OCR | Latest | 한글 최적화 OCR | |
| Infra | Docker | 24+ | 컨테이너화 |
| GitHub Actions | - | CI/CD |
사용자 관리
식단 추천
OCR 스캔
히스토리 및 즐겨찾기
성능
보안
확장성
가용성
┌─────────────────────────────────────────────────────────┐
│ Client Layer │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Web Browser (React + TypeScript) │ │
│ │ - 반응형 UI │ │
│ │ - SPA (Single Page Application) │ │
│ │ - PWA 지원 가능 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓ HTTPS / REST API
┌─────────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌──────────────────────────────────────────────────┐ │
│ │ FastAPI Backend (Async Python) │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ API Gateway (CORS, Rate Limiting) │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Authentication Middleware (JWT) │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Business Logic Layer │ │ │
│ │ │ - Recommendation Engine │ │ │
│ │ │ - OCR Service │ │ │
│ │ │ - Translation Service │ │ │
│ │ │ - History Service │ │ │
│ │ │ - Favorites Service │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ External │ │ Cache │
│ Database │ │ AI APIs │ │ Layer │
│ │ │ │ │ │
│ - Users │ │ - OpenAI │ │ - JSON Files │
│ - Meals │ │ - CLOVA OCR │ │ - Redis │
│ - History │ │ │ │ (Future) │
│ - Favorites │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
src/
├── components/ # 재사용 가능 컴포넌트
│ └── Layout.tsx # 전역 레이아웃 (네비게이션, 헤더)
├── pages/ # 페이지 컴포넌트
│ ├── Home.tsx # 식단 추천 메인
│ ├── HealthProfile.tsx # 건강 프로필 설정
│ ├── OCRScan.tsx # OCR 스캔
│ ├── History.tsx # 히스토리
│ ├── Favorites.tsx # 즐겨찾기
│ ├── Login.tsx # 로그인
│ └── Register.tsx # 회원가입
├── locales/ # 다국어 리소스
│ ├── en.json # 영어
│ └── ko.json # 한국어
├── config.ts # API 엔드포인트 설정
└── App.tsx # 라우팅 설정
주요 패턴:
app/
├── main.py # FastAPI 앱 진입점
├── core/
│ ├── config.py # 환경 설정
│ └── security.py # JWT, 암호화
├── api/ # API 라우터
│ ├── auth.py # 인증 API
│ ├── recommendations_simple.py # 추천 API
│ ├── ocr.py # OCR API
│ ├── history.py # 히스토리 API
│ ├── favorites.py # 즐겨찾기 API
│ ├── foods.py # 식품 등록 API
│ └── chatbot.py # 챗봇 API
├── models/ # SQLAlchemy 모델
│ ├── user.py # 사용자 모델
│ ├── recommendation_history.py # 히스토리 모델
│ ├── favorite.py # 즐겨찾기 모델
│ └── food_product.py # 식품 모델
├── services/ # 비즈니스 로직
│ └── ocr_service.py # OCR 처리 서비스
└── db/
└── database.py # DB 연결 설정
주요 패턴:
┌─────────────────────────────────────────────────────────┐
│ AI Model Layer │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. Recommendation Engine (Rule-based AI) │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Input: User Health Profile │ │ │
│ │ │ - Age, Gender, Height, Weight │ │ │
│ │ │ - Health Goal (lose/gain/maintain) │ │ │
│ │ │ - Activity Level │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Process: │ │ │
│ │ │ 1. Calculate target calories (BMR) │ │ │
│ │ │ 2. Determine macro ratios │ │ │
│ │ │ 3. Filter meals by calorie range │ │ │
│ │ │ 4. Score by macro similarity │ │ │
│ │ │ 5. Rank and sample top recommendations │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Output: List of 5 recommended meals │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 2. Translation Model (OpenAI GPT-4o-mini) │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Input: Korean meal name │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Cache Check: JSON file lookup │ │ │
│ │ │ - Hit: Return cached translation │ │ │
│ │ │ - Miss: Call GPT-4o-mini API │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Model: gpt-4o-mini │ │ │
│ │ │ - Temperature: 0.3 (consistency) │ │ │
│ │ │ - Max tokens: 100 │ │ │
│ │ │ - System: Professional translator │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Output: English meal name + cache save │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 3. OCR Model (CLOVA OCR + Vision API) │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Input: Food label image (JPEG/PNG/HEIC) │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Preprocessing: │ │ │
│ │ │ - HEIC → JPEG conversion │ │ │
│ │ │ - PDF → Image conversion │ │ │
│ │ │ - Base64 encoding │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Primary: CLOVA OCR (Korean optimized) │ │ │
│ │ │ - Korean food label specialized │ │ │
│ │ │ - High accuracy for Hangul │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Fallback: OpenAI Vision API │ │ │
│ │ │ - Model: gpt-4-vision │ │ │
│ │ │ - Extract nutrition JSON │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Output: Structured nutrition data │ │ │
│ │ │ {product_name, calories, protein, ...} │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 4. Chatbot Model (OpenAI GPT-4o-mini) │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Input: User question + context │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ System Prompt: Health expert persona │ │ │
│ │ │ - Nutrition guidance │ │ │
│ │ │ - Meal suggestions │ │ │
│ │ │ - Health tips │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Output: Personalized health advice │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
class UserHealthProfile(BaseModel):
age: int # 나이 (만)
gender: str # 성별 (male/female)
height_cm: float # 키 (cm)
weight_kg: float # 몸무게 (kg)
target_weight_kg: Optional[float] # 목표 체중 (kg)
activity_level: str # 활동량 (sedentary/moderate/active)
health_goal: str # 목표 (lose_weight/gain_muscle/maintain)
# Mifflin-St Jeor Equation
if gender == 'male':
BMR = (10 * weight_kg) + (6.25 * height_cm) - (5 * age) + 5
else:
BMR = (10 * weight_kg) + (6.25 * height_cm) - (5 * age) - 161
# Activity Multiplier
activity_multipliers = {
'sedentary': 1.2, # 거의 운동 안 함
'moderate': 1.55, # 주 3-5일 운동
'active': 1.9 # 주 6-7일 운동
}
TDEE = BMR * activity_multipliers[activity_level]
# Goal Adjustment
if health_goal == 'lose_weight':
target_calories = TDEE - 500 # 일주일에 0.5kg 감량
elif health_goal == 'gain_muscle':
target_calories = TDEE + 300 # 근육 증가
else:
target_calories = TDEE # 체중 유지
macro_ratios = {
'lose_weight': {
'protein': 0.333, # 33.3% - 포만감 유지
'carbohydrates': 0.534, # 53.4% - 에너지 공급
'fat': 0.133 # 13.3% - 최소화
},
'gain_muscle': {
'protein': 0.333, # 33.3% - 근육 합성
'carbohydrates': 0.534, # 53.4% - 운동 에너지
'fat': 0.133 # 13.3%
},
'maintain': {
'protein': 0.238, # 23.8% - 균형
'carbohydrates': 0.654, # 65.4% - 주 에너지원
'fat': 0.108 # 10.8%
}
}
def recommend_meals(user_profile, meal_database):
# 1단계: 칼로리 필터링
target_cal = calculate_target_calories(user_profile)
calorie_tolerance = 500 # ±500 kcal
filtered_meals = [
meal for meal in meal_database
if abs(meal.calories - target_cal) <= calorie_tolerance
]
# 2단계: 영양소 비율 점수 계산
target_ratios = macro_ratios[user_profile.health_goal]
for meal in filtered_meals:
meal_ratios = calculate_ratios(meal)
# 가중치 기반 유사도 점수
protein_diff = abs(meal_ratios['protein'] - target_ratios['protein'])
carb_diff = abs(meal_ratios['carbs'] - target_ratios['carbs'])
fat_diff = abs(meal_ratios['fat'] - target_ratios['fat'])
meal.score = (
protein_diff * 0.4 + # 단백질 중요도 높음
carb_diff * 0.3 +
fat_diff * 0.3
)
# 3단계: 상위 N개 선정
top_meals = sorted(filtered_meals, key=lambda x: x.score)[:50]
# 4단계: 랜덤 샘플링 (다양성 확보)
recommendations = random.sample(top_meals, min(5, len(top_meals)))
return recommendations
async def preprocess_image(image_bytes, file_type):
# HEIC 변환
if file_type == 'HEIC':
from pillow_heif import register_heif_opener
register_heif_opener()
image = Image.open(BytesIO(image_bytes))
output = BytesIO()
image.save(output, format='JPEG')
image_bytes = output.getvalue()
# PDF 변환
elif file_type == 'PDF':
from pdf2image import convert_from_bytes
images = convert_from_bytes(image_bytes)
output = BytesIO()
images[0].save(output, format='JPEG')
image_bytes = output.getvalue()
# Base64 인코딩
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
return image_base64
async def clova_ocr(image_base64):
request_json = {
'images': [{
'format': 'jpg',
'name': 'food_label',
'data': image_base64
}],
'requestId': str(uuid.uuid4()),
'version': 'V2',
'timestamp': int(time.time() * 1000)
}
async with httpx.AsyncClient() as client:
response = await client.post(
CLOVA_OCR_URL,
headers={'X-OCR-SECRET': CLOVA_OCR_SECRET},
json=request_json,
timeout=30.0
)
if response.status_code == 200:
data = response.json()
extracted_text = ' '.join([
field['inferText']
for image in data['images']
for field in image['fields']
])
return extracted_text
return None
async def openai_vision_extract(image_base64):
prompt = """
Extract nutrition information from this Korean food label.
Return JSON with this exact structure:
{
"product_name": "string or null",
"allergens": ["string"],
"nutrition_info": {
"calories": number or null,
"carbohydrates": number or null,
"protein": number or null,
"fat": number or null,
"sodium": number or null,
"sugar": number or null
}
}
Be accurate with numbers. If unclear, return null.
"""
response = await openai_client.chat.completions.create(
model="gpt-4-vision-preview",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": f"data:image/jpeg;base64,{image_base64}"
}}
]
}],
max_tokens=500
)
return json.loads(response.choices[0].message.content)
# 메모리 캐시 (앱 시작 시 로딩)
translation_cache: Dict[str, str] = load_translation_cache()
reverse_translation_cache: Dict[str, str] = {v: k for k, v in translation_cache.items()}
# 파일 캐시 (영속성)
CACHE_FILE = "/path/to/meal_name_translations.json"
def load_translation_cache() -> Dict[str, str]:
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
def save_translation_cache(cache: Dict[str, str]):
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
async def translate_korean_to_english(korean_name: str) -> str:
# 캐시 확인
if korean_name in reverse_translation_cache:
return reverse_translation_cache[korean_name]
# API 호출
response = await openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """You are a professional translator.
Translate Korean food dish names to English.
Return ONLY the English translation, nothing else.
Keep special characters and formatting (parentheses, etc).
Examples:
- 제육볶음 → Spicy Pork Stir-fry
- 된장찌개 → Soybean Paste Stew
"""
},
{
"role": "user",
"content": f"Translate this Korean dish name to English: {korean_name}"
}
],
temperature=0.3, # 일관성 보장
max_tokens=100
)
english_name = response.choices[0].message.content.strip()
# 양방향 캐시 저장
translation_cache[english_name] = korean_name
reverse_translation_cache[korean_name] = english_name
save_translation_cache(translation_cache)
return english_name
┌─────────────────────┐
│ Users │
├─────────────────────┤
│ id (PK) │◄─────┐
│ username │ │
│ email │ │
│ hashed_password │ │
│ created_at │ │
│ updated_at │ │
└─────────────────────┘ │
│ 1:N
│
┌─────────────────┴──────────────────┐
│ │
┌──────────▼────────────┐ ┌────────────▼──────────┐
│ RecommendationHistory │ │ Favorites │
├───────────────────────┤ ├───────────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ user_id (FK) │
│ meal_code │ │ meal_code │
│ meal_name_ko │ │ meal_name_ko │
│ meal_name_en │ │ meal_name_en │
│ nutrition_info (JSON)│ │ calories │
│ created_at │ │ carbohydrates │
└──────────────────────┘ │ protein │
│ fat │
│ sodium │
│ created_at │
│ updated_at │
└──────────────────────┘
┌─────────────────────┐
│ FoodProducts │
├─────────────────────┤
│ id (PK) │
│ user_id (FK) │◄───── (사용자가 OCR로 등록한 제품)
│ name │
│ calories │
│ carbohydrates │
│ protein │
│ fat │
│ sodium │
│ sugar │
│ allergens (JSON) │
│ created_at │
│ updated_at │
└─────────────────────┘
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
full_name VARCHAR(200),
-- 건강 프로필
age INTEGER,
gender VARCHAR(10),
height_cm DECIMAL(5, 2),
weight_kg DECIMAL(5, 2),
target_weight_kg DECIMAL(5, 2),
activity_level VARCHAR(20),
health_goal VARCHAR(50),
-- 메타데이터
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- 인덱스
INDEX idx_users_email (email),
INDEX idx_users_username (username)
);
CREATE TABLE recommendation_history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 식단 정보
meal_code VARCHAR(50) NOT NULL,
meal_name_ko VARCHAR(255) NOT NULL,
meal_name_en VARCHAR(255),
-- 영양 정보 (JSON)
nutrition_info JSONB NOT NULL,
-- 예시: {"calories": 500, "protein": 20, "carbs": 60, "fat": 15}
-- 추천 메타데이터
recommendation_reason VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- 인덱스
INDEX idx_history_user_id (user_id),
INDEX idx_history_created_at (created_at),
INDEX idx_history_user_created (user_id, created_at DESC)
);
CREATE TABLE favorites (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 식단 정보
meal_code VARCHAR(50) NOT NULL,
meal_name_ko VARCHAR(255) NOT NULL,
meal_name_en VARCHAR(255),
-- 영양 정보 스냅샷 (즐겨찾기 시점 데이터 보존)
calories INTEGER,
carbohydrates INTEGER,
protein INTEGER,
fat INTEGER,
sodium INTEGER,
-- 메타데이터
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- 제약조건
CONSTRAINT unique_user_meal_favorite UNIQUE (user_id, meal_code),
-- 인덱스
INDEX idx_favorites_user_id (user_id),
INDEX idx_favorites_created_at (created_at DESC)
);
CREATE TABLE food_products (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 제품 정보
name VARCHAR(255) NOT NULL,
-- 영양 정보
calories INTEGER,
carbohydrates DECIMAL(5, 2),
protein DECIMAL(5, 2),
fat DECIMAL(5, 2),
sodium INTEGER,
sugar DECIMAL(5, 2),
-- 알레르기 정보 (JSON 배열)
allergens JSONB,
-- 예시: ["우유", "대두", "토마토"]
-- 메타데이터
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- 인덱스
INDEX idx_products_user_id (user_id),
INDEX idx_products_name (name),
INDEX idx_products_created_at (created_at DESC)
);
-- 히스토리 조회 최적화 (사용자별 최신순)
CREATE INDEX idx_history_user_created
ON recommendation_history(user_id, created_at DESC);
-- 즐겨찾기 중복 방지 및 조회 최적화
CREATE UNIQUE INDEX idx_favorites_user_meal
ON favorites(user_id, meal_code);
-- 영양 정보 범위 검색
CREATE INDEX idx_history_nutrition_calories
ON recommendation_history((nutrition_info->>'calories')::integer);
-- 알레르기 정보 검색
CREATE INDEX idx_products_allergens
ON food_products USING GIN (allergens);
# alembic/versions/xxx_add_favorites_table.py
def upgrade() -> None:
op.create_table(
'favorites',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('meal_code', sa.String(), nullable=False),
sa.Column('meal_name_ko', sa.String(), nullable=False),
sa.Column('meal_name_en', sa.String(), nullable=True),
sa.Column('calories', sa.Integer(), nullable=True),
sa.Column('carbohydrates', sa.Integer(), nullable=True),
sa.Column('protein', sa.Integer(), nullable=True),
sa.Column('fat', sa.Integer(), nullable=True),
sa.Column('sodium', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True),
server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'meal_code',
name='unique_user_meal_favorite')
)
op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'])
op.create_index(op.f('ix_favorites_user_id'), 'favorites', ['user_id'])
def downgrade() -> None:
op.drop_index(op.f('ix_favorites_user_id'), table_name='favorites')
op.drop_index(op.f('ix_favorites_id'), table_name='favorites')
op.drop_table('favorites')
POST /api/v1/auth/register
Request:
{
"username": "string",
"email": "string",
"password": "string",
"full_name": "string"
}
Response (201):
{
"success": true,
"user": {
"id": 1,
"username": "user123",
"email": "user@example.com"
}
}
POST /api/v1/auth/login
Request:
{
"username": "string",
"password": "string"
}
Response (200):
{
"success": true,
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"user": {
"id": 1,
"username": "user123",
"email": "user@example.com"
}
}
GET /api/v1/auth/profile
Headers:
Authorization: Bearer {access_token}
Response (200):
{
"success": true,
"profile": {
"id": 1,
"username": "user123",
"age": 25,
"gender": "male",
"height_cm": 175,
"weight_kg": 70,
"health_goal": "lose_weight",
"activity_level": "moderate"
}
}
POST /api/v1/recommendations/recommend
Headers:
Authorization: Bearer {access_token}
Request:
{
"age": 25,
"gender": "male",
"height_cm": 175,
"weight_kg": 70,
"target_weight_kg": 65,
"activity_level": "moderate",
"health_goal": "lose_weight"
}
Response (200):
{
"success": true,
"recommendations": [
{
"meal_code": "M001",
"meal_name_ko": "닭가슴살 샐러드",
"meal_name_en": "Chicken Breast Salad",
"nutrition_info": {
"calories": 350,
"protein": 35,
"carbohydrates": 25,
"fat": 10,
"sodium": 400
},
"score": 0.85,
"reason": "High protein, low fat - ideal for weight loss"
},
// ... 4 more recommendations
],
"target_calories": 1800,
"macro_ratios": {
"protein": 0.333,
"carbohydrates": 0.534,
"fat": 0.133
}
}
POST /api/v1/ocr/analyze-with-ai
Headers:
Authorization: Bearer {access_token}
Content-Type: multipart/form-data
Request:
file: <binary image data>
Response (200):
{
"success": true,
"data": {
"product_name": "저지방 우유",
"allergens": ["우유"],
"nutrition_info": {
"calories": 130,
"carbohydrates": 18,
"protein": 8,
"fat": 3,
"sodium": 120,
"sugar": 16
}
},
"ocr_method": "clova",
"confidence": 0.95
}
GET /api/v1/history/all
Headers:
Authorization: Bearer {access_token}
Query Parameters:
skip: 0
limit: 20
Response (200):
{
"success": true,
"history": [
{
"id": 123,
"meal_code": "M001",
"meal_name_ko": "닭가슴살 샐러드",
"meal_name_en": "Chicken Breast Salad",
"nutrition_info": {
"calories": 350,
"protein": 35,
"carbohydrates": 25,
"fat": 10
},
"created_at": "2024-11-19T10:30:00Z"
}
],
"total": 45
}
POST /api/v1/favorites/add
Headers:
Authorization: Bearer {access_token}
Request:
{
"meal_code": "M001",
"meal_name_ko": "닭가슴살 샐러드",
"meal_name_en": "Chicken Breast Salad",
"calories": 350,
"carbohydrates": 25,
"protein": 35,
"fat": 10,
"sodium": 400
}
Response (200):
{
"success": true,
"favorite": {
"id": 45,
"meal_code": "M001",
"created_at": "2024-11-19T10:30:00Z"
}
}
DELETE /api/v1/favorites/remove/{meal_code}
Headers:
Authorization: Bearer {access_token}
Response (200):
{
"success": true,
"message": "Favorite removed successfully"
}
GET /api/v1/favorites/list
Headers:
Authorization: Bearer {access_token}
Query Parameters:
skip: 0
limit: 50
Response (200):
{
"success": true,
"favorites": [
{
"id": 45,
"meal_code": "M001",
"meal_name_ko": "닭가슴살 샐러드",
"meal_name_en": "Chicken Breast Salad",
"nutrition_info": {
"calories": 350,
"protein": 35,
"carbohydrates": 25,
"fat": 10,
"sodium": 400
},
"created_at": "2024-11-19T10:30:00Z"
}
],
"total": 12
}
// 400 Bad Request
{
"success": false,
"error": "Invalid request",
"detail": "Missing required field: age",
"code": "VALIDATION_ERROR"
}
// 401 Unauthorized
{
"success": false,
"error": "Unauthorized",
"detail": "Invalid or expired token",
"code": "AUTH_ERROR"
}
// 404 Not Found
{
"success": false,
"error": "Not found",
"detail": "Favorite not found",
"code": "NOT_FOUND"
}
// 500 Internal Server Error
{
"success": false,
"error": "Internal server error",
"detail": "An unexpected error occurred",
"code": "INTERNAL_ERROR"
}
1. 로그인 요청
POST /api/v1/auth/login
2. JWT 토큰 발급
Response: {"access_token": "...", "token_type": "bearer"}
3. 보호된 엔드포인트 요청
Headers: {"Authorization": "Bearer <token>"}
4. 토큰 검증
- Decode JWT
- Verify signature
- Check expiration
- Extract user_id
5. 사용자 조회 및 권한 확인
- Get user from database
- Check user.is_active
6. 응답 반환
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@app.post("/api/v1/auth/login")
@limiter.limit("5/minute") # 분당 5회 제한
async def login(request: Request, ...):
pass
@app.post("/api/v1/recommendations/recommend")
@limiter.limit("30/minute") # 분당 30회 제한
async def recommend(request: Request, ...):
pass
src/
├── components/
│ ├── Layout.tsx # 전역 레이아웃
│ ├── Navbar.tsx # 네비게이션 바
│ └── LanguageToggle.tsx # 언어 전환 버튼
│
├── pages/
│ ├── Home.tsx # 메인 추천 페이지
│ ├── HealthProfile.tsx # 건강 프로필 설정
│ ├── OCRScan.tsx # OCR 스캔
│ ├── History.tsx # 히스토리
│ ├── Favorites.tsx # 즐겨찾기
│ ├── Login.tsx # 로그인
│ └── Register.tsx # 회원가입
│
├── hooks/
│ ├── useAuth.ts # 인증 관련 hooks
│ ├── useRecommendations.ts # 추천 관련 hooks
│ └── useTranslation.ts # 다국어 hooks
│
├── services/
│ ├── api.ts # API 호출 함수
│ └── storage.ts # LocalStorage 관리
│
├── types/
│ ├── user.ts # 사용자 타입
│ ├── meal.ts # 식단 타입
│ └── nutrition.ts # 영양 정보 타입
│
└── utils/
├── validation.ts # 입력 검증
└── formatter.ts # 데이터 포맷팅
// 컴포넌트 내부 상태
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [meals, setMeals] = useState<Meal[]>([]);
// AuthContext.tsx
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (username: string, password: string) => {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
const data = await response.json();
setUser(data.user);
localStorage.setItem('token', data.access_token);
};
return (
<AuthContext.Provider value={{user, isAuthenticated: !!user, login, logout}}>
{children}
</AuthContext.Provider>
);
}
// App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route element={<ProtectedRoute />}>
<Route path="/home" element={<Home />} />
<Route path="/health-profile" element={<HealthProfile />} />
<Route path="/ocr-scan" element={<OCRScan />} />
<Route path="/history" element={<History />} />
<Route path="/favorites" element={<Favorites />} />
</Route>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
// Protected Route Component
function ProtectedRoute() {
const isAuthenticated = localStorage.getItem('isLoggedIn') === 'true';
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return (
<Layout>
<Outlet />
</Layout>
);
}
// i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import ko from './locales/ko.json';
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
ko: { translation: ko }
},
lng: 'ko',
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
});
export default i18n;
// locales/ko.json
{
"nav_home": "홈",
"nav_health_profile": "건강 프로필",
"nav_ocr_scan": "OCR 스캔",
"nav_history": "히스토리",
"nav_favorites": "즐겨찾기",
"nav_logout": "로그아웃"
}
// locales/en.json
{
"nav_home": "Home",
"nav_health_profile": "Health Profile",
"nav_ocr_scan": "OCR Scan",
"nav_history": "History",
"nav_favorites": "Favorites",
"nav_logout": "Logout"
}
# Token Payload
{
"sub": "user123", # Subject (username)
"user_id": 1, # User ID
"exp": 1700000000, # Expiration time
"iat": 1699999000, # Issued at
"type": "access" # Token type
}
# Token Generation
from datetime import datetime, timedelta
from jose import JWTError, jwt
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000", # 개발 환경
"https://fitmealor.com" # 프로덕션
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
from pydantic import BaseModel, validator, EmailStr
class UserRegister(BaseModel):
username: str
email: EmailStr
password: str
@validator('username')
def username_alphanumeric(cls, v):
assert v.isalnum(), 'must be alphanumeric'
assert len(v) >= 3, 'must be at least 3 characters'
return v
@validator('password')
def password_strength(cls, v):
assert len(v) >= 8, 'must be at least 8 characters'
assert any(c.isupper() for c in v), 'must contain uppercase'
assert any(c.islower() for c in v), 'must contain lowercase'
assert any(c.isdigit() for c in v), 'must contain digit'
return v
# app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# App
APP_NAME: str = "Fitmealor"
ENVIRONMENT: str = "development"
DEBUG: bool = True
# Database
DATABASE_URL: str
# Security
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# External APIs
OPENAI_API_KEY: str
CLOVA_OCR_URL: str
CLOVA_OCR_SECRET: str
# Redis (Optional)
REDIS_URL: str = "redis://localhost:6379"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# .env 파일 (절대 Git에 커밋하지 않음!)
DATABASE_URL=postgresql://user:password@localhost:5432/fitmealor
SECRET_KEY=your-super-secret-key-here-change-this-in-production
OPENAI_API_KEY=sk-...
CLOVA_OCR_URL=https://...
CLOVA_OCR_SECRET=...
# Dockerfile
FROM python:3.10-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY ./app ./app
COPY ./alembic ./alembic
COPY ./alembic.ini .
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Run migrations and start server
CMD alembic upgrade head && \
uvicorn app.main:app --host 0.0.0.0 --port 8000
# Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/fitmealor
- SECRET_KEY=${SECRET_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
depends_on:
- db
volumes:
- ./backend/data:/app/data
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
db:
image: postgres:14-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=fitmealor
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
restart: unless-stopped
volumes:
postgres_data:
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
cd backend
pip install -r requirements.txt
- name: Run tests
run: |
cd backend
pytest tests/ -v
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: |
cd frontend
npm ci
- name: Run tests
run: |
cd frontend
npm test
- name: Build
run: |
cd frontend
npm run build
deploy:
needs: [test-backend, test-frontend]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Deploy to production
run: |
# Deploy scripts here
echo "Deploying to production..."
# app/core/logging_config.py
import logging
from logging.handlers import RotatingFileHandler
def setup_logging():
logger = logging.getLogger("fitmealor")
logger.setLevel(logging.INFO)
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
console_handler.setFormatter(console_formatter)
# File handler
file_handler = RotatingFileHandler(
'logs/app.log',
maxBytes=10485760, # 10MB
backupCount=5
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
logger.addHandler(file_handler)
return logger
@app.get("/health")
async def health_check():
try:
# Check database connection
db = SessionLocal()
db.execute("SELECT 1")
db.close()
return {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"services": {
"database": "ok",
"api": "ok"
}
}
except Exception as e:
return {
"status": "unhealthy",
"error": str(e)
}
# Bad: N+1 query problem
favorites = db.query(Favorite).filter(Favorite.user_id == user_id).all()
for favorite in favorites:
meal = db.query(Meal).filter(Meal.code == favorite.meal_code).first()
# Good: Join or eager loading
favorites = db.query(Favorite).options(
joinedload(Favorite.meal)
).filter(Favorite.user_id == user_id).all()
from functools import lru_cache
import redis
# In-memory cache
@lru_cache(maxsize=1000)
def get_meal_by_code(meal_code: str):
return db.query(Meal).filter(Meal.code == meal_code).first()
# Redis cache
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def get_recommendations_cached(user_id: int):
cache_key = f"recommendations:{user_id}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
recommendations = calculate_recommendations(user_id)
redis_client.setex(cache_key, 3600, json.dumps(recommendations)) # 1 hour TTL
return recommendations
| 용어 | 설명 |
|---|---|
| ORM | Object-Relational Mapping: 객체와 데이터베이스 매핑 |
| JWT | JSON Web Token: 인증 토큰 표준 |
| OCR | Optical Character Recognition: 광학 문자 인식 |
| API | Application Programming Interface: 애플리케이션 인터페이스 |
| SPA | Single Page Application: 단일 페이지 애플리케이션 |
| BMR | Basal Metabolic Rate: 기초 대사량 |
| TDEE | Total Daily Energy Expenditure: 일일 총 에너지 소비량 |
| CORS | Cross-Origin Resource Sharing: 교차 출처 리소스 공유 |
문서 버전: 1.0
최종 수정일: 2024-11-19
작성자: Fitmealor Development Team