Fitmealor 시스템 및 모델 설계서

김소은·2025년 11월 20일

Fitmealor 시스템 및 모델 설계서

버전: 1.0
작성일: 2025-11-20
프로젝트: AI 기반 개인 맞춤형 건강 식단 추천 시스템


목차

  1. 시스템 개요
  2. 시스템 아키텍처
  3. AI 모델 설계
  4. 데이터베이스 설계
  5. API 설계
  6. 프론트엔드 설계
  7. 보안 설계
  8. 배포 및 운영

1. 시스템 개요

1.1. 프로젝트 목표

Fitmealor는 AI 기술을 활용한 개인 맞춤형 건강 식단 추천 플랫폼으로, 다음 기능을 제공합니다:

  • 사용자 건강 프로필 기반 AI 식단 추천
  • OCR 기술을 활용한 식품 영양정보 자동 인식
  • 식단 히스토리 관리 및 즐겨찾기
  • 다국어 지원 (한국어/영어)

1.2. 핵심 기술 스택

계층기술버전선택 이유
FrontendReact18.x컴포넌트 기반 UI, 풍부한 생태계
TypeScript5.x타입 안정성, 개발 생산성 향상
Vite5.x빠른 빌드 속도, HMR 지원
Tailwind CSS3.x유틸리티 우선 CSS, 빠른 UI 개발
React Router6.xSPA 라우팅
i18next23.x다국어 지원
BackendFastAPI0.104+비동기 처리, 자동 API 문서화
Python3.10+AI/ML 생태계, 빠른 개발
SQLAlchemy2.xORM, 데이터베이스 추상화
Alembic1.x데이터베이스 마이그레이션
Pydantic2.x데이터 검증, 타입 안정성
DatabasePostgreSQL14+관계형 DB, ACID 보장
SQLite3.x개발/테스트 환경
AI/MLOpenAI GPT-4o-miniLatest번역, 영양정보 추출, 챗봇
OpenAI Vision APILatest이미지 기반 영양정보 추출
CLOVA OCRLatest한글 최적화 OCR
InfraDocker24+컨테이너화
GitHub Actions-CI/CD

1.3. 시스템 요구사항

기능적 요구사항

  1. 사용자 관리

    • 회원가입/로그인 (JWT 인증)
    • 건강 프로필 관리 (나이, 성별, 키, 몸무게, 목표)
  2. 식단 추천

    • 건강 프로필 기반 개인 맞춤 추천
    • 영양소 비율 자동 계산
    • 다양성 있는 추천 (랜덤 샘플링)
  3. OCR 스캔

    • 식품 라벨 이미지 업로드
    • 영양정보 자동 추출
    • 제품 등록 및 저장
  4. 히스토리 및 즐겨찾기

    • 추천 식단 히스토리 자동 저장
    • 즐겨찾기 추가/제거
    • 히스토리 조회 (페이지네이션)

비기능적 요구사항

  1. 성능

    • API 응답 시간 < 2초 (식단 추천)
    • OCR 처리 시간 < 3초
    • 동시 사용자 100명 지원
  2. 보안

    • JWT 토큰 기반 인증
    • HTTPS 통신
    • SQL Injection 방지
  3. 확장성

    • 수평 확장 가능 아키텍처
    • 데이터베이스 샤딩 고려
    • 캐시 레이어 추가 가능
  4. 가용성

    • Uptime 99% 이상
    • 에러 핸들링 및 폴백 메커니즘

2. 시스템 아키텍처

2.1. 전체 시스템 구조

┌─────────────────────────────────────────────────────────┐
│                    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 │    │              │    │              │
└──────────────┘    └──────────────┘    └──────────────┘

2.2. 계층별 상세 설계

2.2.1. Presentation Layer (Frontend)

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              # 라우팅 설정

주요 패턴:

  • 컴포넌트 기반 아키텍처: 재사용성 극대화
  • hooks 활용: useState, useEffect, useTranslation
  • 상태 관리: React Context (필요 시 Redux 추가 가능)

2.2.2. Application Layer (Backend)

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 연결 설정

주요 패턴:

  • 레이어드 아키텍처: API → Service → Repository
  • 의존성 주입: FastAPI Depends
  • 비동기 프로그래밍: async/await

2.2.3. Data Layer

  • PostgreSQL: 프로덕션 환경
  • SQLAlchemy ORM: 데이터베이스 추상화
  • Alembic: 스키마 마이그레이션
  • Connection Pooling: 성능 최적화

3. AI 모델 설계

3.1. AI 모델 아키텍처

┌─────────────────────────────────────────────────────────┐
│                   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       │ │  │
│  │   └───────────────────────────────────────────┘ │  │
│  └─────────────────────────────────────────────────┘  │
│                                                         │
└─────────────────────────────────────────────────────────┘

3.2. 추천 엔진 상세 설계

3.2.1. 입력 데이터 구조

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)

3.2.2. 칼로리 계산 알고리즘

# 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  # 체중 유지

3.2.3. 영양소 비율 결정 로직

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%
    }
}

3.2.4. 추천 알고리즘 4단계 프로세스

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

3.3. OCR 모델 상세 설계

3.3.1. 이미지 전처리 파이프라인

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

3.3.2. CLOVA OCR 호출

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

3.3.3. Vision API 폴백

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)

3.4. 번역 모델 최적화

3.4.1. 캐시 전략

# 메모리 캐시 (앱 시작 시 로딩)
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)

3.4.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

4. 데이터베이스 설계

4.1. ERD (Entity-Relationship Diagram)

┌─────────────────────┐
│       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         │
└─────────────────────┘

4.2. 테이블 스키마 상세

4.2.1. users 테이블

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)
);

4.2.2. recommendation_history 테이블

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)
);

4.2.3. favorites 테이블

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)
);

4.2.4. food_products 테이블

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)
);

4.3. 인덱스 전략

4.3.1. 복합 인덱스

-- 히스토리 조회 최적화 (사용자별 최신순)
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);

4.3.2. JSONB 인덱스

-- 영양 정보 범위 검색
CREATE INDEX idx_history_nutrition_calories
ON recommendation_history((nutrition_info->>'calories')::integer);

-- 알레르기 정보 검색
CREATE INDEX idx_products_allergens
ON food_products USING GIN (allergens);

4.4. 마이그레이션 전략

Alembic 마이그레이션 예시

# 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')

5. API 설계

5.1. API 명세

5.1.1. 인증 API

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"
  }
}

5.1.2. 추천 API

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
  }
}

5.1.3. OCR API

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
}

5.1.4. 히스토리 API

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
}

5.1.5. 즐겨찾기 API

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
}

5.2. API 에러 응답 표준

// 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"
}

5.3. API 보안

5.3.1. JWT 인증 흐름

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. 응답 반환

5.3.2. Rate Limiting

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

6. 프론트엔드 설계

6.1. 컴포넌트 구조

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         # 데이터 포맷팅

6.2. 상태 관리 전략

6.2.1. Local State (useState)

// 컴포넌트 내부 상태
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [meals, setMeals] = useState<Meal[]>([]);

6.2.2. Context API

// 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>
  );
}

6.3. 라우팅 설계

// 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>
  );
}

6.4. 다국어 지원 (i18n)

// 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"
}

7. 보안 설계

7.1. 인증 및 권한 부여

7.1.1. JWT 토큰 구조

# 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

7.1.2. 비밀번호 해싱

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)

7.2. API 보안

7.2.1. CORS 설정

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"],
)

7.2.2. 입력 검증

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

7.3. 환경 변수 관리

# 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=...

8. 배포 및 운영

8.1. Docker 컨테이너화

8.1.1. Backend Dockerfile

# 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

8.1.2. Frontend Dockerfile

# 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;"]

8.1.3. docker-compose.yml

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:

8.2. CI/CD 파이프라인

# .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..."

8.3. 모니터링 및 로깅

8.3.1. 로깅 설정

# 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

8.3.2. 헬스 체크 엔드포인트

@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)
        }

8.4. 성능 최적화

8.4.1. 데이터베이스 쿼리 최적화

# 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()

8.4.2. 캐싱 전략

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

9. 부록

9.1. 기술 용어 정리

용어설명
ORMObject-Relational Mapping: 객체와 데이터베이스 매핑
JWTJSON Web Token: 인증 토큰 표준
OCROptical Character Recognition: 광학 문자 인식
APIApplication Programming Interface: 애플리케이션 인터페이스
SPASingle Page Application: 단일 페이지 애플리케이션
BMRBasal Metabolic Rate: 기초 대사량
TDEETotal Daily Energy Expenditure: 일일 총 에너지 소비량
CORSCross-Origin Resource Sharing: 교차 출처 리소스 공유

9.2. 참고 문서


문서 버전: 1.0
최종 수정일: 2024-11-19
작성자: Fitmealor Development Team

profile
개발자

0개의 댓글