๐ŸŽฌ ์˜ํ™” ํ๋ ˆ์ด์…˜ ๊ตฌ๋… ์„œ๋น„์Šค - ์ทจํ–ฅ ๊ธฐ๋ฐ˜ ์›”๊ฐ„ ์ถ”์ฒœ ํ”Œ๋žซํผ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ค‘๊ธ‰ PHP + JavaScript Laravel + React + Inertia.js AI: Claude

๐ŸŽฌ ์˜ํ™” ํ๋ ˆ์ด์…˜ ๊ตฌ๋… ์„œ๋น„์Šค - ์ทจํ–ฅ ๊ธฐ๋ฐ˜ ์›”๊ฐ„ ์ถ”์ฒœ ํ”Œ๋žซํผ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… movie-curation-subscription-service
์กฐํšŒ์ˆ˜ 120 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

ํ”„๋กœ์ ํŠธ ๊ฐœ์š”

์‚ฌ์šฉ์ž์˜ ์˜ํ™” ์ทจํ–ฅ์„ ๋ถ„์„ํ•˜์—ฌ ๋งค๋‹ฌ ๋งž์ถคํ˜• ์˜ํ™”๋ฅผ ์ถ”์ฒœํ•˜๋Š” ๊ตฌ๋… ์„œ๋น„์Šค ํ”Œ๋žซํผ์ž…๋‹ˆ๋‹ค. Netflix๋‚˜ Watcha์ฒ˜๋Ÿผ ๊ฐœ์ธํ™”๋œ ํ๋ ˆ์ด์…˜์„ ์ œ๊ณตํ•˜๋˜, ์›”๊ฐ„ ๊ตฌ๋… ๋ชจ๋ธ๋กœ ์šด์˜๋ฉ๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ๊ธฐ๋Šฅ

1. ์ทจํ–ฅ ํ”„๋กœํ•„ ์„ค์ •

  • ํšŒ์›๊ฐ€์ž… ์‹œ ์„ ํ˜ธ ์žฅ๋ฅด ์„ ํƒ (๋ณต์ˆ˜ ์„ ํƒ ๊ฐ€๋Šฅ)
  • ์ข‹์•„ํ•˜๋Š” ๊ฐ๋…/๋ฐฐ์šฐ ์ž…๋ ฅ
  • ์„ ํ˜ธ ๊ตญ๊ฐ€/์–ธ์–ด ์„ค์ •
  • ์˜ํ™” ๋ถ„์œ„๊ธฐ ์„ ํƒ (๊ฐ๋™, ์Šค๋ฆด, ์œ ์พŒํ•จ ๋“ฑ)

2. ๊ตฌ๋… ํ”Œ๋žœ ๊ด€๋ฆฌ

  • 3๊ฐ€์ง€ ๊ตฌ๋… ๋“ฑ๊ธ‰: Basic(์›” 5ํŽธ), Standard(์›” 10ํŽธ), Premium(์›” ๋ฌด์ œํ•œ)
  • ํ”Œ๋žœ๋ณ„ ๊ฐ€๊ฒฉ ๋น„๊ต ํ…Œ์ด๋ธ”
  • ๊ตฌ๋… ์‹ ์ฒญ ํผ (์ด๋ฆ„, ์ด๋ฉ”์ผ, ํ”Œ๋žœ ์„ ํƒ)
  • ๊ตฌ๋… ์ƒํƒœ ํ™•์ธ ํŽ˜์ด์ง€ (ํ™œ์„ฑํ™”/๋งŒ๋ฃŒ ์—ฌ๋ถ€)

3. ์›”๊ฐ„ ํ๋ ˆ์ด์…˜ ํŽ˜์ด์ง€

  • ์ด๋ฒˆ ๋‹ฌ ์ถ”์ฒœ ์˜ํ™” ๋ชฉ๋ก (ํฌ์Šคํ„ฐ ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ)
  • ๊ฐ ์˜ํ™”๋ณ„ ์ถ”์ฒœ ์ด์œ  ํ‘œ์‹œ ("๋‹น์‹ ์ด ์ข‹์•„ํ•˜๋Š” ๋ˆ„์•„๋ฅด ์žฅ๋ฅด", "๊ฐ๋… ๋ด‰์ค€ํ˜ธ์˜ ์‹ ์ž‘" ๋“ฑ)
  • ์˜ํ™” ์ƒ์„ธ ์ •๋ณด: ์ œ๋ชฉ, ๊ฐ๋…, ์ถœ์—ฐ์ง„, ์ค„๊ฑฐ๋ฆฌ, ๋Ÿฌ๋‹ํƒ€์ž„, ๊ฐœ๋ด‰๋…„๋„
  • ์‹œ์ฒญ ์™„๋ฃŒ ์ฒดํฌ ๊ธฐ๋Šฅ
  • ๋ณ„์  ํ‰๊ฐ€ (1~5์ )

4. ์‹œ์ฒญ ๊ธฐ๋ก & ํ†ต๊ณ„

  • ๋ณธ ์˜ํ™” ํžˆ์Šคํ† ๋ฆฌ ํƒ€์ž„๋ผ์ธ
  • ์›”๋ณ„ ์‹œ์ฒญ ํŽธ์ˆ˜ ๊ทธ๋ž˜ํ”„ (Chart.js ํ™œ์šฉ)
  • ๊ฐ€์žฅ ๋งŽ์ด ๋ณธ ์žฅ๋ฅด ํ†ต๊ณ„
  • ํ‰๊ท  ๋ณ„์  ํ‘œ์‹œ

5. ์˜ํ™” ๊ฒ€์ƒ‰ & ์œ„์‹œ๋ฆฌ์ŠคํŠธ

  • ์ œ๋ชฉ/๊ฐ๋…/๋ฐฐ์šฐ๋กœ ์˜ํ™” ๊ฒ€์ƒ‰
  • ์ถ”์ฒœ๋ฐ›๊ณ  ์‹ถ์€ ์˜ํ™” ์œ„์‹œ๋ฆฌ์ŠคํŠธ ์ถ”๊ฐ€
  • ์œ„์‹œ๋ฆฌ์ŠคํŠธ ๊ธฐ๋ฐ˜ ํ๋ ˆ์ด์…˜ ๋ฐ˜์˜

ํ•„์ˆ˜ ๊ธฐ์ˆ  ์Šคํƒ

  • ๋ฐฑ์—”๋“œ: Laravel 13.x (์ปจํŠธ๋กค๋Ÿฌ, Eloquent ORM, ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜)
  • ํ”„๋ก ํŠธ์—”๋“œ: React 19 + Inertia.js (๋ชจ๋…ธ๋ฆฌ์Šค ๊ตฌ์กฐ)
  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค: SQLite (๊ฐœ๋ฐœ), MySQL/PostgreSQL (ํ”„๋กœ๋•์…˜ ๊ถŒ์žฅ)
  • ์Šคํƒ€์ผ๋ง: Tailwind CSS
  • ์ฐจํŠธ: Chart.js (์‹œ์ฒญ ํ†ต๊ณ„)

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค๊ณ„

  • users: ํšŒ์› ์ •๋ณด (์ด๋ฆ„, ์ด๋ฉ”์ผ, ๋น„๋ฐ€๋ฒˆํ˜ธ)
  • profiles: ์ทจํ–ฅ ํ”„๋กœํ•„ (user_id, ์„ ํ˜ธ ์žฅ๋ฅด, ๊ฐ๋…, ๋ฐฐ์šฐ ๋“ฑ JSON ์ €์žฅ)
  • subscriptions: ๊ตฌ๋… ์ •๋ณด (user_id, ํ”Œ๋žœ, ์‹œ์ž‘์ผ, ๋งŒ๋ฃŒ์ผ, ์ƒํƒœ)
  • movies: ์˜ํ™” ์ •๋ณด (์ œ๋ชฉ, ๊ฐ๋…, ์ถœ์—ฐ์ง„, ์žฅ๋ฅด, ์ค„๊ฑฐ๋ฆฌ, ํฌ์Šคํ„ฐ URL, ๊ฐœ๋ด‰๋…„๋„)
  • curations: ์›”๊ฐ„ ํ๋ ˆ์ด์…˜ (user_id, movie_id, ์ถ”์ฒœ ์ด์œ , ์›”)
  • watch_history: ์‹œ์ฒญ ๊ธฐ๋ก (user_id, movie_id, ๋ณ„์ , ์‹œ์ฒญ์ผ)
  • wishlists: ์œ„์‹œ๋ฆฌ์ŠคํŠธ (user_id, movie_id)

๋ณด๋„ˆ์Šค ๊ธฐ๋Šฅ

  • ์นœ๊ตฌ ์ดˆ๋Œ€ ์‹œ ๊ตฌ๋… 1๊ฐœ์›” ์—ฐ์žฅ ํ”„๋กœ๋ชจ์…˜
  • ์˜ํ™” ๋ฆฌ๋ทฐ ์ž‘์„ฑ & ๊ณต์œ  ๊ธฐ๋Šฅ
  • ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ํ๋ ˆ์ด์…˜ ์—ฟ๋ณด๊ธฐ (๊ณต๊ฐœ ์„ค์ • ์‹œ)
  • ์ด๋ฉ”์ผ ์•Œ๋ฆผ: ๋งค๋‹ฌ 1์ผ ์ƒˆ๋กœ์šด ํ๋ ˆ์ด์…˜ ๋ฐœ์†ก
  • ๊ตฌ๋… ์ž๋™ ๊ฐฑ์‹  ์•ˆ๋‚ด (๋งŒ๋ฃŒ 7์ผ ์ „ ์•Œ๋ฆผ)

ํ•™์Šต ๋ชฉํ‘œ

  • Laravel + Inertia.js + React ํ’€์Šคํƒ ๊ฐœ๋ฐœ ๊ฒฝํ—˜
  • ์ผ๋Œ€๋‹ค/๋‹ค๋Œ€๋‹ค ๊ด€๊ณ„ ์„ค๊ณ„ (User-Subscription, User-Movie)
  • JSON ์ปฌ๋Ÿผ ํ™œ์šฉ (์ทจํ–ฅ ํ”„๋กœํ•„ ์ €์žฅ)
  • ๋‚ ์งœ ๊ธฐ๋ฐ˜ ๋กœ์ง (๊ตฌ๋… ๋งŒ๋ฃŒ ์ฒดํฌ)
  • Chart.js๋กœ ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™”
  • Tailwind CSS ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ ๋งˆ์Šคํ„ฐ

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋…ธ๋ฆฌ์Šค ๊ตฌ์กฐ (๋ณ„๋„ API ๋ผ์šฐํŠธ ์—†์Œ)
  • โ€ข Laravel Breeze (React + Inertia) starter kit ์‚ฌ์šฉ ๊ถŒ์žฅ
  • โ€ข SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ฌ์šฉ (๊ฐœ๋ฐœ ํ™˜๊ฒฝ)
  • โ€ข Tailwind CSS๋กœ ๋ฐ˜์‘ํ˜• ๋””์ž์ธ ๊ตฌํ˜„
  • โ€ข Chart.js๋กœ ์‹œ์ฒญ ํ†ต๊ณ„ ์‹œ๊ฐํ™”
  • โ€ข Inertia::render()๋กœ Laravel ์ปจํŠธ๋กค๋Ÿฌ์—์„œ React ์ปดํฌ๋„ŒํŠธ์— ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
  • โ€ข ์‹ค์ œ ์˜ํ™” API ์—ฐ๋™ ์—†์ด ์‹œ๋“œ ๋ฐ์ดํ„ฐ๋กœ 50๊ฐœ ์ด์ƒ ์˜ํ™” ์ƒ์„ฑ
  • โ€ข ๊ตฌ๋… ํ”Œ๋žœ์€ ํ•˜๋“œ์ฝ”๋”ฉ (Basic/Standard/Premium)
  • โ€ข ๊ฒฐ์ œ ์—ฐ๋™ ์—†์Œ (๊ตฌ๋… ์‹ ์ฒญ์€ ํผ ์ œ์ถœ๋กœ ์ฒ˜๋ฆฌ)
  • โ€ข ์ด๋ฉ”์ผ ๋ฐœ์†ก์€ ๋กœ๊ทธ๋กœ ๋Œ€์ฒด (์‹ค์ œ SMTP ์„ค์ • ๋ถˆํ•„์š”)

ํ”„๋กฌํ”„ํŠธ ๋ ˆ์‹œํ”ผ

์•„๋ž˜ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ AI์—๊ฒŒ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๋ณต์‚ฌ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.

1
Step 1
mkdir movie-curation-subscription-service && cd movie-curation-subscription-service

์ด ํด๋”์—์„œ Laravel 13.x + Inertia.js + React 19 ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.

# ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ

๋‹ค์Œ ๋ช…๋ น์–ด๋กœ Laravel Breeze (React + Inertia) starter kit์„ ์„ค์น˜ํ•˜์„ธ์š”:

```bash
composer create-project laravel/laravel .
composer require laravel/breeze --dev
php artisan breeze:install react
npm install
npm run build
```

# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ •

`.env` ํŒŒ์ผ์„ ์—ด๊ณ  SQLite๋กœ ๋ณ€๊ฒฝ:

```
DB_CONNECTION=sqlite
# DB_HOST, DB_PORT, DB_DATABASE ๋“ฑ ์ฃผ์„ ์ฒ˜๋ฆฌ
```

`database/database.sqlite` ํŒŒ์ผ ์ƒ์„ฑ:

```bash
touch database/database.sqlite
```

# ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ƒ์„ฑ

๋‹ค์Œ ํ…Œ์ด๋ธ”์„ ์œ„ํ•œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ƒ์„ฑํ•˜์„ธ์š”:

1. `profiles` ํ…Œ์ด๋ธ”:
   - user_id (foreignId, users ํ…Œ์ด๋ธ” ์ฐธ์กฐ)
   - favorite_genres (json, nullable) - ["์•ก์…˜", "๋“œ๋ผ๋งˆ", "์Šค๋ฆด๋Ÿฌ"] ํ˜•์‹
   - favorite_directors (json, nullable) - ["๋ด‰์ค€ํ˜ธ", "ํฌ๋ฆฌ์Šคํ† ํผ ๋†€๋ž€"] ํ˜•์‹
   - favorite_actors (json, nullable)
   - preferred_countries (json, nullable) - ["ํ•œ๊ตญ", "๋ฏธ๊ตญ", "์ผ๋ณธ"] ํ˜•์‹
   - mood_preferences (json, nullable) - ["๊ฐ๋™", "์Šค๋ฆด", "์œ ์พŒํ•จ"] ํ˜•์‹
   - timestamps

2. `subscriptions` ํ…Œ์ด๋ธ”:
   - user_id (foreignId)
   - plan (string) - 'basic', 'standard', 'premium'
   - started_at (date)
   - expires_at (date)
   - status (string) - 'active', 'expired', 'canceled'
   - timestamps

3. `movies` ํ…Œ์ด๋ธ”:
   - title (string)
   - director (string)
   - cast (json) - ["๋ฐฐ์šฐ1", "๋ฐฐ์šฐ2"] ํ˜•์‹
   - genres (json) - ["์•ก์…˜", "๋“œ๋ผ๋งˆ"] ํ˜•์‹
   - plot (text) - ์ค„๊ฑฐ๋ฆฌ
   - poster_url (string, nullable)
   - release_year (integer)
   - runtime (integer) - ๋ถ„ ๋‹จ์œ„
   - country (string)
   - timestamps

4. `curations` ํ…Œ์ด๋ธ”:
   - user_id (foreignId)
   - movie_id (foreignId)
   - reason (text) - ์ถ”์ฒœ ์ด์œ 
   - month (string) - '2025-01' ํ˜•์‹
   - timestamps

5. `watch_history` ํ…Œ์ด๋ธ”:
   - user_id (foreignId)
   - movie_id (foreignId)
   - rating (integer, nullable) - 1~5์ 
   - watched_at (date)
   - timestamps

6. `wishlists` ํ…Œ์ด๋ธ”:
   - user_id (foreignId)
   - movie_id (foreignId)
   - timestamps

๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ƒ์„ฑ ๋ช…๋ น์–ด:

```bash
php artisan make:migration create_profiles_table
php artisan make:migration create_subscriptions_table
php artisan make:migration create_movies_table
php artisan make:migration create_curations_table
php artisan make:migration create_watch_history_table
php artisan make:migration create_wishlists_table
```

๊ฐ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ์„ ์—ด์–ด ์œ„ ์Šคํ‚ค๋งˆ๋Œ€๋กœ ์ž‘์„ฑํ•œ ํ›„ ์‹คํ–‰:

```bash
php artisan migrate
```

# ๋ชจ๋ธ ์ƒ์„ฑ

```bash
php artisan make:model Profile
php artisan make:model Subscription
php artisan make:model Movie
php artisan make:model Curation
php artisan make:model WatchHistory
php artisan make:model Wishlist
```

๊ฐ ๋ชจ๋ธ์— ๊ด€๊ณ„(hasMany, belongsTo) ์„ค์ •:

- User hasOne Profile
- User hasMany Subscriptions
- User hasMany WatchHistory
- User hasMany Wishlists
- Movie hasMany Curations
- Movie hasMany WatchHistory

๋ชจ๋ธ์—์„œ JSON ์ปฌ๋Ÿผ์€ `protected $casts = ['favorite_genres' => 'array', ...]` ์„ค์ •.

์™„๋ฃŒ ํ›„ `php artisan serve`๋กœ ์„œ๋ฒ„ ์‹คํ–‰ ํ™•์ธ.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Laravel Breeze (React + Inertia) ํ”„๋กœ์ ํŠธ๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , 6๊ฐœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์‹คํ–‰๋˜์–ด SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. ๋ชจ๋ธ ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜๊ณ  ๊ด€๊ณ„๊ฐ€ ์„ค์ •๋ฉ๋‹ˆ๋‹ค. `http://localhost:8000`์—์„œ ๊ธฐ๋ณธ ๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค.

2
Step 2
# ์‹œ๋“œ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ

์˜ํ™” ๋ฐ์ดํ„ฐ 50๊ฐœ๋ฅผ ์‹œ๋”ฉํ•˜์„ธ์š”.

`database/seeders/MovieSeeder.php` ์ƒ์„ฑ:

```bash
php artisan make:seeder MovieSeeder
```

`MovieSeeder.php` ๋‚ด์šฉ:

```php
use App\Models\Movie;
use Illuminate\Database\Seeder;

class MovieSeeder extends Seeder
{
    public function run()
    {
        $movies = [
            [
                'title' => '๊ธฐ์ƒ์ถฉ',
                'director' => '๋ด‰์ค€ํ˜ธ',
                'cast' => ['์†ก๊ฐ•ํ˜ธ', '์ด์„ ๊ท ', '์กฐ์—ฌ์ •'],
                'genres' => ['๋“œ๋ผ๋งˆ', '์Šค๋ฆด๋Ÿฌ'],
                'plot' => '์ „์› ๋ฐฑ์ˆ˜์ธ ๊ธฐํƒ ๊ฐ€์กฑ์ด ์‚ฌ๊ธฐ์™€ ์œ„์žฅ์œผ๋กœ ๋ฐ• ์‚ฌ์žฅ ๊ฐ€์กฑ์—๊ฒŒ ์นจํˆฌํ•˜๋Š” ์ด์•ผ๊ธฐ.',
                'poster_url' => 'https://via.placeholder.com/300x450?text=Parasite',
                'release_year' => 2019,
                'runtime' => 132,
                'country' => 'ํ•œ๊ตญ',
            ],
            [
                'title' => '์ธํ„ฐ์Šคํ…”๋ผ',
                'director' => 'ํฌ๋ฆฌ์Šคํ† ํผ ๋†€๋ž€',
                'cast' => ['๋งคํŠœ ๋งฅ์ปค๋„ˆํžˆ', '์•ค ํ•ด์„œ์›จ์ด'],
                'genres' => ['SF', '๋“œ๋ผ๋งˆ'],
                'plot' => '์ง€๊ตฌ์˜ ์ข…๋ง์„ ๋ง‰๊ธฐ ์œ„ํ•ด ์šฐ์ฃผ๋กœ ๋– ๋‚œ ํƒํ—˜๋Œ€์˜ ์ด์•ผ๊ธฐ.',
                'poster_url' => 'https://via.placeholder.com/300x450?text=Interstellar',
                'release_year' => 2014,
                'runtime' => 169,
                'country' => '๋ฏธ๊ตญ',
            ],
            // ... ์ด 50๊ฐœ ์˜ํ™” ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ (๋‹ค์–‘ํ•œ ์žฅ๋ฅด, ๊ฐ๋…, ๊ตญ๊ฐ€)
            // ์žฅ๋ฅด: ์•ก์…˜, ๋“œ๋ผ๋งˆ, ์ฝ”๋ฏธ๋””, ๋กœ๋งจ์Šค, ์Šค๋ฆด๋Ÿฌ, ๊ณตํฌ, SF, ์• ๋‹ˆ๋ฉ”์ด์…˜, ๋‹คํ๋ฉ˜ํ„ฐ๋ฆฌ, ๋ฒ”์ฃ„
            // ๊ฐ๋…: ๋ด‰์ค€ํ˜ธ, ๋ฐ•์ฐฌ์šฑ, ํฌ๋ฆฌ์Šคํ† ํผ ๋†€๋ž€, ์ฟ ์—”ํ‹ด ํƒ€๋ž€ํ‹ฐ๋…ธ, ์Šคํ‹ฐ๋ธ ์Šคํ•„๋ฒ„๊ทธ, ๋งˆํ‹ด ์Šค์ฝœ์„ธ์ง€, ์›จ์Šค ์•ค๋”์Šจ ๋“ฑ
            // ๊ตญ๊ฐ€: ํ•œ๊ตญ, ๋ฏธ๊ตญ, ์ผ๋ณธ, ํ”„๋ž‘์Šค, ์˜๊ตญ ๋“ฑ
        ];

        foreach ($movies as $movie) {
            Movie::create($movie);
        }
    }
}
```

`DatabaseSeeder.php`์—์„œ ํ˜ธ์ถœ:

```php
public function run()
{
    $this->call(MovieSeeder::class);
}
```

์‹คํ–‰:

```bash
php artisan db:seed --class=MovieSeeder
```

# ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ

```bash
php artisan make:controller ProfileController
php artisan make:controller SubscriptionController
php artisan make:controller CurationController
php artisan make:controller WatchHistoryController
php artisan make:controller WishlistController
```

๊ฐ ์ปจํŠธ๋กค๋Ÿฌ์— ๊ธฐ๋ณธ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€:

- ProfileController: `edit`, `update` (์ทจํ–ฅ ํ”„๋กœํ•„ ์ˆ˜์ •)
- SubscriptionController: `index`, `store` (๊ตฌ๋… ํ”Œ๋žœ ๋ณด๊ธฐ, ์‹ ์ฒญ)
- CurationController: `index` (์›”๊ฐ„ ํ๋ ˆ์ด์…˜ ๋ชฉ๋ก)
- WatchHistoryController: `index`, `store` (์‹œ์ฒญ ๊ธฐ๋ก, ๋ณ„์  ์ €์žฅ)
- WishlistController: `index`, `store`, `destroy` (์œ„์‹œ๋ฆฌ์ŠคํŠธ CRUD)

์™„๋ฃŒ ํ›„ `php artisan route:list`๋กœ ๋ผ์šฐํŠธ ํ™•์ธ.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: 50๊ฐœ ์˜ํ™” ๋ฐ์ดํ„ฐ๊ฐ€ `movies` ํ…Œ์ด๋ธ”์— ์‹œ๋”ฉ๋ฉ๋‹ˆ๋‹ค. 5๊ฐœ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ƒ์„ฑ๋˜๊ณ  ๊ธฐ๋ณธ ๋ฉ”์„œ๋“œ๊ฐ€ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค.

3
Step 3
# ๋ผ์šฐํŠธ ์„ค์ •

`routes/web.php`์— ๋‹ค์Œ ๋ผ์šฐํŠธ ์ถ”๊ฐ€:

```php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\CurationController;
use App\Http\Controllers\WatchHistoryController;
use App\Http\Controllers\WishlistController;

Route::middleware(['auth'])->group(function () {
    // ์ทจํ–ฅ ํ”„๋กœํ•„
    Route::get('/profile/preferences', [ProfileController::class, 'edit'])->name('profile.preferences.edit');
    Route::post('/profile/preferences', [ProfileController::class, 'update'])->name('profile.preferences.update');

    // ๊ตฌ๋… ๊ด€๋ฆฌ
    Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('subscriptions.index');
    Route::post('/subscriptions', [SubscriptionController::class, 'store'])->name('subscriptions.store');

    // ์›”๊ฐ„ ํ๋ ˆ์ด์…˜
    Route::get('/curations', [CurationController::class, 'index'])->name('curations.index');

    // ์‹œ์ฒญ ๊ธฐ๋ก
    Route::get('/watch-history', [WatchHistoryController::class, 'index'])->name('watch-history.index');
    Route::post('/watch-history', [WatchHistoryController::class, 'store'])->name('watch-history.store');

    // ์œ„์‹œ๋ฆฌ์ŠคํŠธ
    Route::get('/wishlist', [WishlistController::class, 'index'])->name('wishlist.index');
    Route::post('/wishlist', [WishlistController::class, 'store'])->name('wishlist.store');
    Route::delete('/wishlist/{movie}', [WishlistController::class, 'destroy'])->name('wishlist.destroy');
});
```

# ProfileController ๊ตฌํ˜„

`app/Http/Controllers/ProfileController.php`:

```php
use Inertia\Inertia;
use Illuminate\Http\Request;

class ProfileController extends Controller
{
    public function edit()
    {
        $profile = auth()->user()->profile;
        
        return Inertia::render('Profile/Preferences', [
            'profile' => $profile,
        ]);
    }

    public function update(Request $request)
    {
        $validated = $request->validate([
            'favorite_genres' => 'array',
            'favorite_directors' => 'array',
            'favorite_actors' => 'array',
            'preferred_countries' => 'array',
            'mood_preferences' => 'array',
        ]);

        auth()->user()->profile()->updateOrCreate(
            ['user_id' => auth()->id()],
            $validated
        );

        return redirect()->route('curations.index')->with('success', '์ทจํ–ฅ ํ”„๋กœํ•„์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
    }
}
```

# React ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ

`resources/js/Pages/Profile/Preferences.jsx` ์ƒ์„ฑ:

```jsx
import { useState } from 'react';
import { useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Preferences({ auth, profile }) {
    const { data, setData, post, processing } = useForm({
        favorite_genres: profile?.favorite_genres || [],
        favorite_directors: profile?.favorite_directors || [],
        favorite_actors: profile?.favorite_actors || [],
        preferred_countries: profile?.preferred_countries || [],
        mood_preferences: profile?.mood_preferences || [],
    });

    const genres = ['์•ก์…˜', '๋“œ๋ผ๋งˆ', '์ฝ”๋ฏธ๋””', '๋กœ๋งจ์Šค', '์Šค๋ฆด๋Ÿฌ', '๊ณตํฌ', 'SF', '์• ๋‹ˆ๋ฉ”์ด์…˜'];
    const countries = ['ํ•œ๊ตญ', '๋ฏธ๊ตญ', '์ผ๋ณธ', 'ํ”„๋ž‘์Šค', '์˜๊ตญ', '์ค‘๊ตญ'];
    const moods = ['๊ฐ๋™', '์Šค๋ฆด', '์œ ์พŒํ•จ', '๊ธด์žฅ๊ฐ', '์ž”์ž”ํ•จ', '๋ชฐ์ž…๊ฐ'];

    const toggleArray = (array, value) => {
        if (array.includes(value)) {
            return array.filter(item => item !== value);
        }
        return [...array, value];
    };

    const submit = (e) => {
        e.preventDefault();
        post(route('profile.preferences.update'));
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <div className="max-w-4xl mx-auto py-12 px-4">
                <h1 className="text-3xl font-bold mb-8">์ทจํ–ฅ ํ”„๋กœํ•„ ์„ค์ •</h1>
                
                <form onSubmit={submit} className="space-y-8">
                    {/* ์„ ํ˜ธ ์žฅ๋ฅด */}
                    <div>
                        <h2 className="text-xl font-semibold mb-4">์ข‹์•„ํ•˜๋Š” ์žฅ๋ฅด (๋ณต์ˆ˜ ์„ ํƒ)</h2>
                        <div className="grid grid-cols-4 gap-3">
                            {genres.map(genre => (
                                <button
                                    key={genre}
                                    type="button"
                                    onClick={() => setData('favorite_genres', toggleArray(data.favorite_genres, genre))}
                                    className={`py-2 px-4 rounded-lg border-2 transition ${
                                        data.favorite_genres.includes(genre)
                                            ? 'bg-blue-500 text-white border-blue-500'
                                            : 'bg-white text-gray-700 border-gray-300 hover:border-blue-300'
                                    }`}
                                >
                                    {genre}
                                </button>
                            ))}
                        </div>
                    </div>

                    {/* ์„ ํ˜ธ ๊ตญ๊ฐ€ */}
                    <div>
                        <h2 className="text-xl font-semibold mb-4">์„ ํ˜ธ ๊ตญ๊ฐ€</h2>
                        <div className="grid grid-cols-3 gap-3">
                            {countries.map(country => (
                                <button
                                    key={country}
                                    type="button"
                                    onClick={() => setData('preferred_countries', toggleArray(data.preferred_countries, country))}
                                    className={`py-2 px-4 rounded-lg border-2 transition ${
                                        data.preferred_countries.includes(country)
                                            ? 'bg-green-500 text-white border-green-500'
                                            : 'bg-white text-gray-700 border-gray-300 hover:border-green-300'
                                    }`}
                                >
                                    {country}
                                </button>
                            ))}
                        </div>
                    </div>

                    {/* ๋ถ„์œ„๊ธฐ ์„ ํ˜ธ */}
                    <div>
                        <h2 className="text-xl font-semibold mb-4">์›ํ•˜๋Š” ๋ถ„์œ„๊ธฐ</h2>
                        <div className="grid grid-cols-3 gap-3">
                            {moods.map(mood => (
                                <button
                                    key={mood}
                                    type="button"
                                    onClick={() => setData('mood_preferences', toggleArray(data.mood_preferences, mood))}
                                    className={`py-2 px-4 rounded-lg border-2 transition ${
                                        data.mood_preferences.includes(mood)
                                            ? 'bg-purple-500 text-white border-purple-500'
                                            : 'bg-white text-gray-700 border-gray-300 hover:border-purple-300'
                                    }`}
                                >
                                    {mood}
                                </button>
                            ))}
                        </div>
                    </div>

                    {/* ์ข‹์•„ํ•˜๋Š” ๊ฐ๋… (ํ…์ŠคํŠธ ์ž…๋ ฅ) */}
                    <div>
                        <h2 className="text-xl font-semibold mb-4">์ข‹์•„ํ•˜๋Š” ๊ฐ๋… (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„)</h2>
                        <input
                            type="text"
                            value={data.favorite_directors.join(', ')}
                            onChange={(e) => setData('favorite_directors', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
                            className="w-full px-4 py-2 border border-gray-300 rounded-lg"
                            placeholder="์˜ˆ: ๋ด‰์ค€ํ˜ธ, ํฌ๋ฆฌ์Šคํ† ํผ ๋†€๋ž€, ๋ฐ•์ฐฌ์šฑ"
                        />
                    </div>

                    {/* ์ข‹์•„ํ•˜๋Š” ๋ฐฐ์šฐ */}
                    <div>
                        <h2 className="text-xl font-semibold mb-4">์ข‹์•„ํ•˜๋Š” ๋ฐฐ์šฐ (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„)</h2>
                        <input
                            type="text"
                            value={data.favorite_actors.join(', ')}
                            onChange={(e) => setData('favorite_actors', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
                            className="w-full px-4 py-2 border border-gray-300 rounded-lg"
                            placeholder="์˜ˆ: ์†ก๊ฐ•ํ˜ธ, ๋ ˆ์˜ค๋‚˜๋ฅด๋„ ๋””์นดํ”„๋ฆฌ์˜ค"
                        />
                    </div>

                    <button
                        type="submit"
                        disabled={processing}
                        className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition disabled:opacity-50"
                    >
                        ์ €์žฅํ•˜๊ธฐ
                    </button>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}
```

์™„๋ฃŒ ํ›„ `/profile/preferences` ์ ‘์†ํ•˜์—ฌ ์ทจํ–ฅ ํ”„๋กœํ•„ ์„ค์ • ํŽ˜์ด์ง€ ํ™•์ธ.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๋ผ์šฐํŠธ๊ฐ€ ์„ค์ •๋˜๊ณ , ProfileController์˜ edit/update ๋ฉ”์„œ๋“œ๊ฐ€ ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค. React ํŽ˜์ด์ง€ `/profile/preferences`์—์„œ ์žฅ๋ฅด/๊ตญ๊ฐ€/๋ถ„์œ„๊ธฐ/๊ฐ๋…/๋ฐฐ์šฐ ์„ ํƒ UI๊ฐ€ ๋ณด์ด๊ณ , ์ €์žฅ ์‹œ `profiles` ํ…Œ์ด๋ธ”์— JSON ํ˜•์‹์œผ๋กœ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

4
Step 4
# SubscriptionController ๊ตฌํ˜„

`app/Http/Controllers/SubscriptionController.php`:

```php
use Inertia\Inertia;
use Illuminate\Http\Request;
use App\Models\Subscription;

class SubscriptionController extends Controller
{
    public function index()
    {
        $currentSubscription = auth()->user()->subscriptions()->latest()->first();
        
        $plans = [
            [
                'name' => 'Basic',
                'price' => '9,900์›/์›”',
                'limit' => '์›” 5ํŽธ',
                'features' => ['๊ธฐ๋ณธ ํ๋ ˆ์ด์…˜', '์‹œ์ฒญ ๊ธฐ๋ก', '์œ„์‹œ๋ฆฌ์ŠคํŠธ'],
            ],
            [
                'name' => 'Standard',
                'price' => '14,900์›/์›”',
                'limit' => '์›” 10ํŽธ',
                'features' => ['๊ณ ๊ธ‰ ํ๋ ˆ์ด์…˜', 'ํ†ต๊ณ„ ๋ถ„์„', '์šฐ์„  ์ง€์›'],
            ],
            [
                'name' => 'Premium',
                'price' => '19,900์›/์›”',
                'limit' => '๋ฌด์ œํ•œ',
                'features' => ['VIP ํ๋ ˆ์ด์…˜', '์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ', '์ „๋‹ด ํ๋ ˆ์ดํ„ฐ'],
            ],
        ];

        return Inertia::render('Subscriptions/Index', [
            'plans' => $plans,
            'currentSubscription' => $currentSubscription,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'plan' => 'required|in:basic,standard,premium',
        ]);

        // ๊ธฐ์กด ๊ตฌ๋… ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ
        auth()->user()->subscriptions()->where('status', 'active')->update(['status' => 'expired']);

        // ์ƒˆ ๊ตฌ๋… ์ƒ์„ฑ
        Subscription::create([
            'user_id' => auth()->id(),
            'plan' => $validated['plan'],
            'started_at' => now(),
            'expires_at' => now()->addMonth(),
            'status' => 'active',
        ]);

        return redirect()->route('curations.index')->with('success', '๊ตฌ๋…์ด ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
    }
}
```

# React ํŽ˜์ด์ง€ ์ƒ์„ฑ

`resources/js/Pages/Subscriptions/Index.jsx`:

```jsx
import { useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Index({ auth, plans, currentSubscription }) {
    const { data, setData, post, processing } = useForm({
        plan: 'standard',
    });

    const submit = (e) => {
        e.preventDefault();
        post(route('subscriptions.store'));
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <div className="max-w-6xl mx-auto py-12 px-4">
                <h1 className="text-4xl font-bold text-center mb-4">๊ตฌ๋… ํ”Œ๋žœ ์„ ํƒ</h1>
                <p className="text-center text-gray-600 mb-12">๋‹น์‹ ์—๊ฒŒ ๋งž๋Š” ํ”Œ๋žœ์„ ์„ ํƒํ•˜์„ธ์š”</p>

                {currentSubscription && currentSubscription.status === 'active' && (
                    <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-8">
                        ํ˜„์žฌ <strong>{currentSubscription.plan.toUpperCase()}</strong> ํ”Œ๋žœ ๊ตฌ๋… ์ค‘์ž…๋‹ˆ๋‹ค. (๋งŒ๋ฃŒ์ผ: {new Date(currentSubscription.expires_at).toLocaleDateString()})
                    </div>
                )}

                <div className="grid grid-cols-3 gap-8 mb-12">
                    {plans.map((plan) => (
                        <div
                            key={plan.name}
                            className={`border-2 rounded-xl p-6 transition hover:shadow-lg ${
                                data.plan === plan.name.toLowerCase()
                                    ? 'border-blue-500 bg-blue-50'
                                    : 'border-gray-300'
                            }`}
                        >
                            <h2 className="text-2xl font-bold mb-2">{plan.name}</h2>
                            <p className="text-3xl font-bold text-blue-600 mb-1">{plan.price}</p>
                            <p className="text-gray-600 mb-4">{plan.limit}</p>
                            <ul className="space-y-2 mb-6">
                                {plan.features.map((feature, index) => (
                                    <li key={index} className="flex items-center">
                                        <span className="text-green-500 mr-2">โœ“</span>
                                        {feature}
                                    </li>
                                ))}
                            </ul>
                            <button
                                type="button"
                                onClick={() => setData('plan', plan.name.toLowerCase())}
                                className={`w-full py-2 rounded-lg font-semibold transition ${
                                    data.plan === plan.name.toLowerCase()
                                        ? 'bg-blue-600 text-white'
                                        : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                                }`}
                            >
                                {data.plan === plan.name.toLowerCase() ? '์„ ํƒ๋จ' : '์„ ํƒํ•˜๊ธฐ'}
                            </button>
                        </div>
                    ))}
                </div>

                <form onSubmit={submit} className="max-w-md mx-auto">
                    <button
                        type="submit"
                        disabled={processing}
                        className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition disabled:opacity-50"
                    >
                        ๊ตฌ๋… ์‹œ์ž‘ํ•˜๊ธฐ
                    </button>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}
```

์™„๋ฃŒ ํ›„ `/subscriptions` ์ ‘์†ํ•˜์—ฌ ๊ตฌ๋… ํ”Œ๋žœ ์„ ํƒ ํŽ˜์ด์ง€ ํ™•์ธ.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: SubscriptionController๊ฐ€ ๊ตฌํ˜„๋˜๊ณ , `/subscriptions` ํŽ˜์ด์ง€์—์„œ 3๊ฐ€์ง€ ํ”Œ๋žœ(Basic/Standard/Premium)์ด ์นด๋“œ ํ˜•ํƒœ๋กœ ๋ณด์ž…๋‹ˆ๋‹ค. ํ”Œ๋žœ ์„ ํƒ ํ›„ '๊ตฌ๋… ์‹œ์ž‘ํ•˜๊ธฐ' ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ `subscriptions` ํ…Œ์ด๋ธ”์— ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋˜๊ณ  ํ™œ์„ฑํ™” ์ƒํƒœ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

5
Step 5
# CurationController ๊ตฌํ˜„ (์›”๊ฐ„ ์ถ”์ฒœ ์˜ํ™”)

`app/Http/Controllers/CurationController.php`:

```php
use Inertia\Inertia;
use App\Models\Movie;
use App\Models\Curation;

class CurationController extends Controller
{
    public function index()
    {
        $user = auth()->user();
        $profile = $user->profile;
        $subscription = $user->subscriptions()->where('status', 'active')->latest()->first();

        if (!$subscription) {
            return redirect()->route('subscriptions.index')->with('error', '๊ตฌ๋…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.');
        }

        // ์ด๋ฒˆ ๋‹ฌ ํ๋ ˆ์ด์…˜ ํ™•์ธ
        $currentMonth = now()->format('Y-m');
        $existingCurations = Curation::where('user_id', $user->id)
            ->where('month', $currentMonth)
            ->with('movie')
            ->get();

        // ํ๋ ˆ์ด์…˜์ด ์—†์œผ๋ฉด ์ƒ์„ฑ
        if ($existingCurations->isEmpty()) {
            $limit = match($subscription->plan) {
                'basic' => 5,
                'standard' => 10,
                'premium' => 20,
            };

            // ์ทจํ–ฅ ๊ธฐ๋ฐ˜ ์˜ํ™” ํ•„ํ„ฐ๋ง
            $query = Movie::query();

            if ($profile && !empty($profile->favorite_genres)) {
                $query->where(function($q) use ($profile) {
                    foreach ($profile->favorite_genres as $genre) {
                        $q->orWhereJsonContains('genres', $genre);
                    }
                });
            }

            $movies = $query->inRandomOrder()->limit($limit)->get();

            foreach ($movies as $movie) {
                $reason = $this->generateReason($movie, $profile);
                Curation::create([
                    'user_id' => $user->id,
                    'movie_id' => $movie->id,
                    'reason' => $reason,
                    'month' => $currentMonth,
                ]);
            }

            $existingCurations = Curation::where('user_id', $user->id)
                ->where('month', $currentMonth)
                ->with('movie')
                ->get();
        }

        // ์‹œ์ฒญ ๊ธฐ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
        $watchedMovieIds = $user->watchHistory()->pluck('movie_id')->toArray();

        return Inertia::render('Curations/Index', [
            'curations' => $existingCurations,
            'watchedMovieIds' => $watchedMovieIds,
        ]);
    }

    private function generateReason($movie, $profile)
    {
        $reasons = [];

        if ($profile && !empty($profile->favorite_genres)) {
            $matchedGenres = array_intersect($movie->genres, $profile->favorite_genres);
            if (!empty($matchedGenres)) {
                $reasons[] = "๋‹น์‹ ์ด ์ข‹์•„ํ•˜๋Š” " . implode(', ', $matchedGenres) . " ์žฅ๋ฅด";
            }
        }

        if ($profile && in_array($movie->director, $profile->favorite_directors ?? [])) {
            $reasons[] = "์ข‹์•„ํ•˜๋Š” ๊ฐ๋… {$movie->director}์˜ ์ž‘ํ’ˆ";
        }

        if (empty($reasons)) {
            $reasons[] = "ํ‰์  ๋†’์€ ์ถ”์ฒœ์ž‘";
        }

        return implode(' | ', $reasons);
    }
}
```

# React ํŽ˜์ด์ง€ ์ƒ์„ฑ

`resources/js/Pages/Curations/Index.jsx`:

```jsx
import { router } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Index({ auth, curations, watchedMovieIds }) {
    const markAsWatched = (movieId) => {
        router.post(route('watch-history.store'), {
            movie_id: movieId,
            watched_at: new Date().toISOString().split('T')[0],
        });
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <div className="max-w-7xl mx-auto py-12 px-4">
                <h1 className="text-4xl font-bold mb-2">์ด๋ฒˆ ๋‹ฌ ํ๋ ˆ์ด์…˜</h1>
                <p className="text-gray-600 mb-8">๋‹น์‹ ์„ ์œ„ํ•ด ์„ ๋ณ„ํ•œ {curations.length}ํŽธ์˜ ์˜ํ™”</p>

                <div className="grid grid-cols-4 gap-6">
                    {curations.map((curation) => (
                        <div key={curation.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition">
                            <img
                                src={curation.movie.poster_url}
                                alt={curation.movie.title}
                                className="w-full h-64 object-cover"
                            />
                            <div className="p-4">
                                <h3 className="font-bold text-lg mb-1">{curation.movie.title}</h3>
                                <p className="text-sm text-gray-600 mb-2">{curation.movie.director} | {curation.movie.release_year}</p>
                                <p className="text-xs text-blue-600 mb-3">{curation.reason}</p>
                                <div className="flex gap-1 mb-3">
                                    {curation.movie.genres.map((genre, index) => (
                                        <span key={index} className="text-xs bg-gray-200 px-2 py-1 rounded">
                                            {genre}
                                        </span>
                                    ))}
                                </div>
                                {watchedMovieIds.includes(curation.movie.id) ? (
                                    <span className="text-green-600 text-sm font-semibold">โœ“ ์‹œ์ฒญ ์™„๋ฃŒ</span>
                                ) : (
                                    <button
                                        onClick={() => markAsWatched(curation.movie.id)}
                                        className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition text-sm"
                                    >
                                        ์‹œ์ฒญ ์™„๋ฃŒ ํ‘œ์‹œ
                                    </button>
                                )}
                            </div>
                        </div>
                    ))}
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

์™„๋ฃŒ ํ›„ `/curations` ์ ‘์†ํ•˜์—ฌ ์›”๊ฐ„ ํ๋ ˆ์ด์…˜ ๋ชฉ๋ก ํ™•์ธ.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: CurationController๊ฐ€ ๊ตฌํ˜„๋˜๊ณ , `/curations` ํŽ˜์ด์ง€์—์„œ ์‚ฌ์šฉ์ž ์ทจํ–ฅ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•„ํ„ฐ๋ง๋œ ์˜ํ™” ๋ชฉ๋ก์ด ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ๋ณด์ž…๋‹ˆ๋‹ค. ๊ฐ ์˜ํ™” ์นด๋“œ์— ํฌ์Šคํ„ฐ, ์ œ๋ชฉ, ๊ฐ๋…, ์žฅ๋ฅด, ์ถ”์ฒœ ์ด์œ ๊ฐ€ ํ‘œ์‹œ๋˜๊ณ , '์‹œ์ฒญ ์™„๋ฃŒ ํ‘œ์‹œ' ๋ฒ„ํŠผ์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.

6
Step 6
# WatchHistoryController & ์‹œ์ฒญ ๊ธฐ๋ก ํŽ˜์ด์ง€ ๊ตฌํ˜„

`app/Http/Controllers/WatchHistoryController.php`:

```php
use Inertia\Inertia;
use Illuminate\Http\Request;
use App\Models\WatchHistory;
use Illuminate\Support\Facades\DB;

class WatchHistoryController extends Controller
{
    public function index()
    {
        $user = auth()->user();
        
        // ์‹œ์ฒญ ๊ธฐ๋ก ํƒ€์ž„๋ผ์ธ
        $history = WatchHistory::where('user_id', $user->id)
            ->with('movie')
            ->orderBy('watched_at', 'desc')
            ->get();

        // ํ†ต๊ณ„ ๊ณ„์‚ฐ
        $totalWatched = $history->count();
        $averageRating = $history->whereNotNull('rating')->avg('rating');
        
        // ์›”๋ณ„ ์‹œ์ฒญ ํŽธ์ˆ˜
        $monthlyStats = WatchHistory::where('user_id', $user->id)
            ->select(
                DB::raw('DATE_FORMAT(watched_at, "%Y-%m") as month'),
                DB::raw('COUNT(*) as count')
            )
            ->groupBy('month')
            ->orderBy('month')
            ->get();

        // ์žฅ๋ฅด๋ณ„ ํ†ต๊ณ„
        $genreStats = [];
        foreach ($history as $record) {
            foreach ($record->movie->genres as $genre) {
                if (!isset($genreStats[$genre])) {
                    $genreStats[$genre] = 0;
                }
                $genreStats[$genre]++;
            }
        }
        arsort($genreStats);

        return Inertia::render('WatchHistory/Index', [
            'history' => $history,
            'totalWatched' => $totalWatched,
            'averageRating' => round($averageRating, 1),
            'monthlyStats' => $monthlyStats,
            'genreStats' => $genreStats,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'movie_id' => 'required|exists:movies,id',
            'rating' => 'nullable|integer|min:1|max:5',
            'watched_at' => 'required|date',
        ]);

        WatchHistory::updateOrCreate(
            [
                'user_id' => auth()->id(),
                'movie_id' => $validated['movie_id'],
            ],
            [
                'rating' => $validated['rating'] ?? null,
                'watched_at' => $validated['watched_at'],
            ]
        );

        return back()->with('success', '์‹œ์ฒญ ๊ธฐ๋ก์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
    }
}
```

# React ํŽ˜์ด์ง€ ์ƒ์„ฑ

`resources/js/Pages/WatchHistory/Index.jsx`:

```jsx
import { useState } from 'react';
import { router } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Line, Bar } from 'react-chartjs-2';
import {
    Chart as ChartJS,
    CategoryScale,
    LinearScale,
    PointElement,
    LineElement,
    BarElement,
    Title,
    Tooltip,
    Legend,
} from 'chart.js';

ChartJS.register(
    CategoryScale,
    LinearScale,
    PointElement,
    LineElement,
    BarElement,
    Title,
    Tooltip,
    Legend
);

export default function Index({ auth, history, totalWatched, averageRating, monthlyStats, genreStats }) {
    const [selectedMovie, setSelectedMovie] = useState(null);
    const [rating, setRating] = useState(0);

    const updateRating = (movieId, newRating) => {
        router.post(route('watch-history.store'), {
            movie_id: movieId,
            rating: newRating,
            watched_at: new Date().toISOString().split('T')[0],
        });
    };

    const monthlyChartData = {
        labels: monthlyStats.map(stat => stat.month),
        datasets: [
            {
                label: '์‹œ์ฒญ ํŽธ์ˆ˜',
                data: monthlyStats.map(stat => stat.count),
                borderColor: 'rgb(59, 130, 246)',
                backgroundColor: 'rgba(59, 130, 246, 0.5)',
            },
        ],
    };

    const genreChartData = {
        labels: Object.keys(genreStats).slice(0, 5),
        datasets: [
            {
                label: '์‹œ์ฒญ ํšŸ์ˆ˜',
                data: Object.values(genreStats).slice(0, 5),
                backgroundColor: 'rgba(34, 197, 94, 0.5)',
            },
        ],
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <div className="max-w-7xl mx-auto py-12 px-4">
                <h1 className="text-4xl font-bold mb-8">์‹œ์ฒญ ๊ธฐ๋ก</h1>

                {/* ํ†ต๊ณ„ ์š”์•ฝ */}
                <div className="grid grid-cols-3 gap-6 mb-8">
                    <div className="bg-white p-6 rounded-lg shadow">
                        <p className="text-gray-600 mb-2">์ด ์‹œ์ฒญ ํŽธ์ˆ˜</p>
                        <p className="text-4xl font-bold text-blue-600">{totalWatched}ํŽธ</p>
                    </div>
                    <div className="bg-white p-6 rounded-lg shadow">
                        <p className="text-gray-600 mb-2">ํ‰๊ท  ๋ณ„์ </p>
                        <p className="text-4xl font-bold text-yellow-500">{'โญ'.repeat(Math.round(averageRating))} {averageRating}/5</p>
                    </div>
                    <div className="bg-white p-6 rounded-lg shadow">
                        <p className="text-gray-600 mb-2">๊ฐ€์žฅ ๋งŽ์ด ๋ณธ ์žฅ๋ฅด</p>
                        <p className="text-2xl font-bold text-green-600">{Object.keys(genreStats)[0] || 'N/A'}</p>
                    </div>
                </div>

                {/* ์›”๋ณ„ ์‹œ์ฒญ ๊ทธ๋ž˜ํ”„ */}
                <div className="bg-white p-6 rounded-lg shadow mb-8">
                    <h2 className="text-2xl font-bold mb-4">์›”๋ณ„ ์‹œ์ฒญ ํŽธ์ˆ˜</h2>
                    <Line data={monthlyChartData} options={{ responsive: true }} />
                </div>

                {/* ์žฅ๋ฅด๋ณ„ ํ†ต๊ณ„ */}
                <div className="bg-white p-6 rounded-lg shadow mb-8">
                    <h2 className="text-2xl font-bold mb-4">์žฅ๋ฅด๋ณ„ ์‹œ์ฒญ ํšŸ์ˆ˜ TOP 5</h2>
                    <Bar data={genreChartData} options={{ responsive: true }} />
                </div>

                {/* ์‹œ์ฒญ ๊ธฐ๋ก ํƒ€์ž„๋ผ์ธ */}
                <div className="bg-white p-6 rounded-lg shadow">
                    <h2 className="text-2xl font-bold mb-4">์‹œ์ฒญ ํƒ€์ž„๋ผ์ธ</h2>
                    <div className="space-y-4">
                        {history.map((record) => (
                            <div key={record.id} className="flex items-center gap-4 border-b pb-4">
                                <img
                                    src={record.movie.poster_url}
                                    alt={record.movie.title}
                                    className="w-16 h-24 object-cover rounded"
                                />
                                <div className="flex-1">
                                    <h3 className="font-bold text-lg">{record.movie.title}</h3>
                                    <p className="text-sm text-gray-600">{record.movie.director} | {record.watched_at}</p>
                                </div>
                                <div className="flex gap-1">
                                    {[1, 2, 3, 4, 5].map((star) => (
                                        <button
                                            key={star}
                                            onClick={() => updateRating(record.movie.id, star)}
                                            className={`text-2xl ${
                                                star <= (record.rating || 0) ? 'text-yellow-400' : 'text-gray-300'
                                            }`}
                                        >
                                            โญ
                                        </button>
                                    ))}
                                </div>
                            </div>
                        ))}
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

์ฐจํŠธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜:

```bash
npm install react-chartjs-2 chart.js
```

์™„๋ฃŒ ํ›„ `/watch-history` ์ ‘์†ํ•˜์—ฌ ์‹œ์ฒญ ๊ธฐ๋ก ๋ฐ ํ†ต๊ณ„ ํ™•์ธ.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: WatchHistoryController๊ฐ€ ๊ตฌํ˜„๋˜๊ณ , `/watch-history` ํŽ˜์ด์ง€์—์„œ ์‹œ์ฒญ ๊ธฐ๋ก ํƒ€์ž„๋ผ์ธ, ์›”๋ณ„ ์‹œ์ฒญ ํŽธ์ˆ˜ ๊ทธ๋ž˜ํ”„(Line), ์žฅ๋ฅด๋ณ„ ํ†ต๊ณ„ ์ฐจํŠธ(Bar)๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ๊ฐ ์˜ํ™”์— ๋ณ„์ ์„ ํด๋ฆญํ•˜์—ฌ ํ‰๊ฐ€ํ•  ์ˆ˜ ์žˆ๊ณ , ํ†ต๊ณ„๊ฐ€ ์‹ค์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ๋ฉ๋‹ˆ๋‹ค.

์ œ์ถœ๋œ ์ž‘ํ’ˆ (0)

๋กœ๊ทธ์ธ ํ›„ ์ œ์ถœ

์•„์ง ์ œ์ถœ๋œ ์ž‘ํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋„์ „์ž๊ฐ€ ๋˜์–ด๋ณด์„ธ์š”!

๋Œ“๊ธ€ (0)

๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!