๐ฌ ์ํ ํ๋ ์ด์ ๊ตฌ๋ ์๋น์ค - ์ทจํฅ ๊ธฐ๋ฐ ์๊ฐ ์ถ์ฒ ํ๋ซํผ
movie-curation-subscription-service
๋ฌธ์ ์ค๋ช
ํ๋ก์ ํธ ๊ฐ์
์ฌ์ฉ์์ ์ํ ์ทจํฅ์ ๋ถ์ํ์ฌ ๋งค๋ฌ ๋ง์ถคํ ์ํ๋ฅผ ์ถ์ฒํ๋ ๊ตฌ๋ ์๋น์ค ํ๋ซํผ์ ๋๋ค. 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์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
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`์์ ๊ธฐ๋ณธ ๋ก๊ทธ์ธ/ํ์๊ฐ์ ํ์ด์ง๊ฐ ๋ณด์ ๋๋ค.
# ์๋ ๋ฐ์ดํฐ ์์ฑ
์ํ ๋ฐ์ดํฐ 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๊ฐ ์ปจํธ๋กค๋ฌ๊ฐ ์์ฑ๋๊ณ ๊ธฐ๋ณธ ๋ฉ์๋๊ฐ ์ถ๊ฐ๋ฉ๋๋ค.
# ๋ผ์ฐํธ ์ค์
`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 ํ์์ผ๋ก ์ ์ฅ๋ฉ๋๋ค.
# 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` ํ ์ด๋ธ์ ๋ฐ์ดํฐ๊ฐ ์ ์ฅ๋๊ณ ํ์ฑํ ์ํ๊ฐ ํ์๋ฉ๋๋ค.
# 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` ํ์ด์ง์์ ์ฌ์ฉ์ ์ทจํฅ ๊ธฐ๋ฐ์ผ๋ก ํํฐ๋ง๋ ์ํ ๋ชฉ๋ก์ด ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ๋ณด์ ๋๋ค. ๊ฐ ์ํ ์นด๋์ ํฌ์คํฐ, ์ ๋ชฉ, ๊ฐ๋ , ์ฅ๋ฅด, ์ถ์ฒ ์ด์ ๊ฐ ํ์๋๊ณ , '์์ฒญ ์๋ฃ ํ์' ๋ฒํผ์ด ์๋ํฉ๋๋ค.
# 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)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!