๐ŸŽช ์„œ์ปค์Šค ๊ณต์—ฐ ๊ด€๋žŒ๊ฐ ํ›„๊ธฐ & ์‚ฌ์ง„ ๊ฐค๋Ÿฌ๋ฆฌ - ๊ด€๊ฐ์ด ๋งŒ๋“œ๋Š” ๊ณต์—ฐ ์•„์นด์ด๋ธŒ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ค‘๊ธ‰ PHP + JavaScript Laravel + React + Inertia.js AI: Claude

๐ŸŽช ์„œ์ปค์Šค ๊ณต์—ฐ ๊ด€๋žŒ๊ฐ ํ›„๊ธฐ & ์‚ฌ์ง„ ๊ฐค๋Ÿฌ๋ฆฌ - ๊ด€๊ฐ์ด ๋งŒ๋“œ๋Š” ๊ณต์—ฐ ์•„์นด์ด๋ธŒ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… circus-performance-review-gallery
์กฐํšŒ์ˆ˜ 79 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐ŸŽช ์„œ์ปค์Šค ๊ณต์—ฐ ๊ด€๋žŒ๊ฐ ํ›„๊ธฐ & ์‚ฌ์ง„ ๊ฐค๋Ÿฌ๋ฆฌ

์„œ์ปค์Šค ๊ณต์—ฐ์„ ๊ด€๋žŒํ•œ ๊ด€๊ฐ๋“ค์ด ์ง์ ‘ ํ›„๊ธฐ์™€ ์‚ฌ์ง„์„ ๋‚จ๊ธฐ๋Š” ์ปค๋ฎค๋‹ˆํ‹ฐ ํ”Œ๋žซํผ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๊ด€๋žŒ๊ฐ๋“ค์€ ๊ณต์—ฐ๋ณ„๋กœ ๋ณ„์ , ํ…์ŠคํŠธ ํ›„๊ธฐ, ๊ด€๋žŒ ์ธ์ฆ์ƒท์„ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๋‹ค๋ฅธ ๊ด€๋žŒ๊ฐ๋“ค์€ ์ข‹์•„์š”์™€ ๋Œ“๊ธ€๋กœ ์†Œํ†ตํ•ฉ๋‹ˆ๋‹ค.

ํ•„์ˆ˜ ๊ธฐ๋Šฅ

  • ๊ณต์—ฐ ๋ชฉ๋ก ํŽ˜์ด์ง€: ํ˜„์žฌ ์ƒ์˜ ์ค‘์ธ ์„œ์ปค์Šค ๊ณต์—ฐ ๋ชฉ๋ก (์ œ๋ชฉ, ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€, ํ‰๊ท  ๋ณ„์ )
  • ๊ณต์—ฐ ์ƒ์„ธ ํŽ˜์ด์ง€: ๊ณต์—ฐ ์ •๋ณด + ๊ด€๋žŒ๊ฐ ํ›„๊ธฐ ๋ชฉ๋ก (์ตœ์‹ ์ˆœ/๋ณ„์ ์ˆœ ์ •๋ ฌ)
  • ํ›„๊ธฐ ์ž‘์„ฑ ํผ: ๋ณ„์  ์„ ํƒ(1~5), ํ…์ŠคํŠธ ํ›„๊ธฐ, ์‚ฌ์ง„ ์—…๋กœ๋“œ (์ตœ๋Œ€ 3์žฅ)
  • ํ›„๊ธฐ ์ƒ์„ธ ๋ชจ๋‹ฌ: ํ›„๊ธฐ ํด๋ฆญ ์‹œ ๋ชจ๋‹ฌ๋กœ ์ „์ฒด ๋‚ด์šฉ + ์‚ฌ์ง„ ๊ฐค๋Ÿฌ๋ฆฌ ์Šฌ๋ผ์ด๋”
  • ์ข‹์•„์š” ๊ธฐ๋Šฅ: ํ›„๊ธฐ์— ์ข‹์•„์š” ๋ˆ„๋ฅด๊ธฐ (์ค‘๋ณต ๋ฐฉ์ง€)
  • ๋Œ“๊ธ€ ๊ธฐ๋Šฅ: ํ›„๊ธฐ์— ๋Œ“๊ธ€ ๋‹ฌ๊ธฐ
  • ๋งˆ์ดํŽ˜์ด์ง€: ๋‚ด๊ฐ€ ์“ด ํ›„๊ธฐ ๋ชฉ๋ก, ์ข‹์•„์š”ํ•œ ํ›„๊ธฐ ๋ชฉ๋ก

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

  • ์‚ฌ์ง„ ์ธ๋„ค์ผ ์ž๋™ ์ƒ์„ฑ (Intervention Image ์‚ฌ์šฉ)
  • ํ›„๊ธฐ ์‹ ๊ณ  ๊ธฐ๋Šฅ (๋ถ€์ ์ ˆํ•œ ์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ)
  • ๊ณต์—ฐ๋ณ„ ํƒœ๊ทธ ์‹œ์Šคํ…œ (#๊ฐ๋™์ ์ธ #์–ด๋ฆฐ์ด์ถ”์ฒœ #ํ™”๋ คํ•œ)
  • ํ›„๊ธฐ ์ž‘์„ฑ ์‹œ ์Šคํฌ์ผ๋Ÿฌ ๊ฒฝ๊ณ  ์ฒดํฌ๋ฐ•์Šค
  • ๊ด€๋žŒ ๋‚ ์งœ ์ž…๋ ฅ & ๊ณต์—ฐ๋ณ„ ๊ด€๋žŒ ํ†ต๊ณ„ (๋‚ ์งœ๋ณ„ ํ›„๊ธฐ ์ˆ˜ ์ฐจํŠธ)

๊ธฐ์ˆ  ์Šคํƒ

  • ๋ฐฑ์—”๋“œ: Laravel 13.x (Eloquent ORM, File Storage, Validation)
  • ํ”„๋ก ํŠธ์—”๋“œ: React 19 (Hooks, Context API)
  • ํ’€์Šคํƒ ์—ฐ๊ฒฐ: Inertia.js (SPA ๊ฒฝํ—˜ + Laravel ๋ผ์šฐํŒ…)
  • ์Šคํƒ€์ผ๋ง: Tailwind CSS
  • ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ: Laravel Storage (public disk)
  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค: SQLite (๊ฐœ๋ฐœ์šฉ) / MySQL (ํ”„๋กœ๋•์…˜)

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

  • Laravel ๋ชจ๋ธ ๊ด€๊ณ„ ์„ค๊ณ„ (1:N, N:M)
  • Inertia.js๋กœ Laravel โ†” React ๋ฐ์ดํ„ฐ ํ๋ฆ„ ์ดํ•ด
  • ํŒŒ์ผ ์—…๋กœ๋“œ & ์Šคํ† ๋ฆฌ์ง€ ๊ด€๋ฆฌ
  • React ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌ & ์žฌ์‚ฌ์šฉ
  • ๋ชจ๋‹ฌ, ์Šฌ๋ผ์ด๋” ๋“ฑ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ UI ๊ตฌํ˜„

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋…ธ๋ฆฌ์Šค ํ’€์Šคํƒ ํ”„๋กœ์ ํŠธ
  • โ€ข Laravel Breeze (React + Inertia) starter kit ์‚ฌ์šฉ
  • โ€ข ๋ณ„๋„ API ๋ผ์šฐํŠธ ์—†์ด Inertia::render()๋กœ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
  • โ€ข SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ฌ์šฉ (๊ฐœ๋ฐœ ํŽธ์˜์„ฑ)
  • โ€ข Tailwind CSS๋กœ ์Šคํƒ€์ผ๋ง
  • โ€ข ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋Š” Laravel Storage์˜ public disk ์‚ฌ์šฉ
  • โ€ข Eloquent ๊ด€๊ณ„: Performance hasMany Review, Review belongsTo Performance, Review hasMany Comment, User hasMany Review/Comment
  • โ€ข ์ข‹์•„์š”๋Š” ์ค‘๊ฐ„ ํ…Œ์ด๋ธ” (review_likes) ์‚ฌ์šฉํ•˜์—ฌ ์ค‘๋ณต ๋ฐฉ์ง€
  • โ€ข ํ”„๋ก ํŠธ์—”๋“œ ์ƒํƒœ๊ด€๋ฆฌ๋Š” useState/useReducer๋งŒ ์‚ฌ์šฉ (์™ธ๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๊ธˆ์ง€)
  • โ€ข ๋ฐ˜์‘ํ˜• ๋””์ž์ธ (๋ชจ๋ฐ”์ผ ์šฐ์„ )
  • โ€ข ์ด๋ฏธ์ง€ ํŒŒ์ผ ํฌ๊ธฐ ์ œํ•œ (์ตœ๋Œ€ 2MB per file)
  • โ€ข ํ›„๊ธฐ ์ž‘์„ฑ ์‹œ ๋ณ„์  ํ•„์ˆ˜, ํ…์ŠคํŠธ ์ตœ์†Œ 10์ž ์ด์ƒ

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

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

1
Step 1
mkdir circus-performance-review-gallery && cd circus-performance-review-gallery

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

๋‹ค์Œ ๋ช…๋ น์œผ๋กœ 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 dev
```

๊ทธ ๋‹ค์Œ, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ SQLite๋กœ ์„ค์ •ํ•˜๊ณ  ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์‹คํ–‰ํ•˜์„ธ์š”:

1. `.env` ํŒŒ์ผ์—์„œ DB_CONNECTION์„ sqlite๋กœ ๋ณ€๊ฒฝ
2. `database/database.sqlite` ํŒŒ์ผ ์ƒ์„ฑ (touch database/database.sqlite)
3. `php artisan migrate`๋กœ ๊ธฐ๋ณธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๋งˆ์ง€๋ง‰์œผ๋กœ ๊ฐœ๋ฐœ ์„œ๋ฒ„๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”:

```bash
php artisan serve
```

๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000 ์ ‘์†ํ•˜์—ฌ Laravel Breeze ๊ธฐ๋ณธ ํ™”๋ฉด(๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…)์ด ์ •์ƒ์ ์œผ๋กœ ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Laravel Breeze (React + Inertia) ํ”„๋กœ์ ํŠธ๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๊ฐ€ ์„ค์ •๋˜๋ฉฐ, ๊ฐœ๋ฐœ ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ธฐ๋ณธ ์ธ์ฆ ํŽ˜์ด์ง€๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

2
Step 2
์„œ์ปค์Šค ๊ณต์—ฐ(Performance)๊ณผ ํ›„๊ธฐ(Review), ๋Œ“๊ธ€(Comment), ์ข‹์•„์š”(ReviewLike) ๋ชจ๋ธ์„ ์ƒ์„ฑํ•˜๊ณ  ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ž‘์„ฑํ•˜์„ธ์š”.

**๋ชจ๋ธ & ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ƒ์„ฑ:**

```bash
php artisan make:model Performance -m
php artisan make:model Review -m
php artisan make:model Comment -m
php artisan make:model ReviewLike -m
```

**๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ์ž‘์„ฑ:**

1. `create_performances_table.php`:
   - id (bigIncrements)
   - title (string) - ๊ณต์—ฐ ์ œ๋ชฉ
   - description (text) - ๊ณต์—ฐ ์„ค๋ช…
   - poster_image (string, nullable) - ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ
   - start_date (date) - ๊ณต์—ฐ ์‹œ์ž‘์ผ
   - end_date (date) - ๊ณต์—ฐ ์ข…๋ฃŒ์ผ
   - timestamps

2. `create_reviews_table.php`:
   - id (bigIncrements)
   - performance_id (foreignId) - performances ํ…Œ์ด๋ธ” ์ฐธ์กฐ (onDelete cascade)
   - user_id (foreignId) - users ํ…Œ์ด๋ธ” ์ฐธ์กฐ (onDelete cascade)
   - rating (integer) - ๋ณ„์  (1~5)
   - content (text) - ํ›„๊ธฐ ๋‚ด์šฉ
   - watched_at (date, nullable) - ๊ด€๋žŒ ๋‚ ์งœ
   - is_spoiler (boolean, default false) - ์Šคํฌ์ผ๋Ÿฌ ์—ฌ๋ถ€
   - timestamps

3. `create_comments_table.php`:
   - id (bigIncrements)
   - review_id (foreignId) - reviews ํ…Œ์ด๋ธ” ์ฐธ์กฐ (onDelete cascade)
   - user_id (foreignId) - users ํ…Œ์ด๋ธ” ์ฐธ์กฐ (onDelete cascade)
   - content (text) - ๋Œ“๊ธ€ ๋‚ด์šฉ
   - timestamps

4. `create_review_likes_table.php`:
   - id (bigIncrements)
   - review_id (foreignId) - reviews ํ…Œ์ด๋ธ” ์ฐธ์กฐ (onDelete cascade)
   - user_id (foreignId) - users ํ…Œ์ด๋ธ” ์ฐธ์กฐ (onDelete cascade)
   - timestamps
   - unique(['review_id', 'user_id']) - ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€

**๋ชจ๋ธ ๊ด€๊ณ„ ์„ค์ •:**

- `Performance.php`: `public function reviews() { return $this->hasMany(Review::class); }`
- `Review.php`: `public function performance() { return $this->belongsTo(Performance::class); }`, `public function user() { return $this->belongsTo(User::class); }`, `public function comments() { return $this->hasMany(Comment::class); }`, `public function likes() { return $this->hasMany(ReviewLike::class); }`
- `Comment.php`: `public function review() { return $this->belongsTo(Review::class); }`, `public function user() { return $this->belongsTo(User::class); }`
- `ReviewLike.php`: `public function review() { return $this->belongsTo(Review::class); }`, `public function user() { return $this->belongsTo(User::class); }`
- `User.php`์— `public function reviews() { return $this->hasMany(Review::class); }`์™€ `public function comments() { return $this->hasMany(Comment::class); }` ์ถ”๊ฐ€

๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰:

```bash
php artisan migrate
```

**ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์‹œ๋”ฉ:**

`database/seeders/DatabaseSeeder.php`์—์„œ ๋”๋ฏธ ๊ณต์—ฐ ๋ฐ์ดํ„ฐ 3๊ฐœ ์ƒ์„ฑ:

```php
Performance::create([
    'title' => 'ํ™˜์ƒ์˜ ์„œ์ปค์Šค: ๋น›๊ณผ ๊ทธ๋ฆผ์ž',
    'description' => '์„ธ๊ณ„์ ์ธ ์„œ์ปค์Šค๋‹จ์ด ์„ ๋ณด์ด๋Š” ํ™”๋ คํ•œ ๊ณต์ค‘ ๊ณก์˜ˆ์™€ ๋งˆ์ˆ  ์‡ผ',
    'poster_image' => 'https://picsum.photos/seed/circus1/400/600',
    'start_date' => '2024-01-01',
    'end_date' => '2024-06-30',
]);
// 2๊ฐœ ๋” ์ถ”๊ฐ€...
```

์‹œ๋”ฉ ์‹คํ–‰:

```bash
php artisan db:seed
```

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Performance, Review, Comment, ReviewLike ๋ชจ๋ธ๊ณผ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์ƒ์„ฑ๋˜๊ณ , ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. ๋ชจ๋ธ ๊ฐ„ ๊ด€๊ณ„๊ฐ€ ์„ค์ •๋˜๊ณ , ๋”๋ฏธ ๊ณต์—ฐ ๋ฐ์ดํ„ฐ 3๊ฐœ๊ฐ€ ์‹œ๋”ฉ๋ฉ๋‹ˆ๋‹ค.

3
Step 3
๊ณต์—ฐ ๋ชฉ๋ก ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์„ธ์š”.

**Laravel ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ:**

```bash
php artisan make:controller PerformanceController
```

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

```php
public function index()
{
    $performances = Performance::withCount('reviews')
        ->withAvg('reviews', 'rating')
        ->get()
        ->map(function ($performance) {
            return [
                'id' => $performance->id,
                'title' => $performance->title,
                'description' => $performance->description,
                'poster_image' => $performance->poster_image,
                'start_date' => $performance->start_date,
                'end_date' => $performance->end_date,
                'reviews_count' => $performance->reviews_count,
                'average_rating' => round($performance->reviews_avg_rating, 1) ?? 0,
            ];
        });

    return Inertia::render('Performances/Index', [
        'performances' => $performances,
    ]);
}
```

**๋ผ์šฐํŠธ ์ถ”๊ฐ€** (`routes/web.php`):

```php
use App\Http\Controllers\PerformanceController;

Route::get('/performances', [PerformanceController::class, 'index'])->name('performances.index');
```

**React ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ** (`resources/js/Pages/Performances/Index.jsx`):

```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';

export default function Index({ auth, performances }) {
    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title="๊ณต์—ฐ ๋ชฉ๋ก" />
            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <h1 className="text-3xl font-bold mb-8">๐ŸŽช ์„œ์ปค์Šค ๊ณต์—ฐ ๋ชฉ๋ก</h1>
                    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                        {performances.map((performance) => (
                            <Link
                                key={performance.id}
                                href={`/performances/${performance.id}`}
                                className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition"
                            >
                                <img
                                    src={performance.poster_image}
                                    alt={performance.title}
                                    className="w-full h-64 object-cover"
                                />
                                <div className="p-4">
                                    <h2 className="text-xl font-bold mb-2">{performance.title}</h2>
                                    <p className="text-gray-600 text-sm mb-3 line-clamp-2">
                                        {performance.description}
                                    </p>
                                    <div className="flex items-center justify-between">
                                        <div className="flex items-center">
                                            <span className="text-yellow-500 text-lg mr-1">โญ</span>
                                            <span className="font-semibold">
                                                {performance.average_rating || 'ํ‰๊ฐ€ ์—†์Œ'}
                                            </span>
                                        </div>
                                        <span className="text-gray-500 text-sm">
                                            ํ›„๊ธฐ {performance.reviews_count}๊ฐœ
                                        </span>
                                    </div>
                                </div>
                            </Link>
                        ))}
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

๋ธŒ๋ผ์šฐ์ €์—์„œ `/performances` ์ ‘์†ํ•˜์—ฌ ๊ณต์—ฐ ๋ชฉ๋ก์ด ์นด๋“œ ํ˜•ํƒœ๋กœ ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๊ณต์—ฐ ๋ชฉ๋ก ํŽ˜์ด์ง€๊ฐ€ ๊ตฌํ˜„๋˜๊ณ , ๊ฐ ๊ณต์—ฐ์ด ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€, ์ œ๋ชฉ, ํ‰๊ท  ๋ณ„์ , ํ›„๊ธฐ ๊ฐœ์ˆ˜์™€ ํ•จ๊ป˜ ์นด๋“œ ํ˜•ํƒœ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

4
Step 4
๊ณต์—ฐ ์ƒ์„ธ ํŽ˜์ด์ง€์™€ ํ›„๊ธฐ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์„ธ์š”.

**์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€** (`PerformanceController.php`):

```php
public function show($id)
{
    $performance = Performance::with([
        'reviews' => function ($query) {
            $query->with(['user', 'comments.user', 'likes'])
                  ->withCount('likes')
                  ->latest();
        }
    ])->findOrFail($id);

    $reviews = $performance->reviews->map(function ($review) {
        return [
            'id' => $review->id,
            'rating' => $review->rating,
            'content' => $review->content,
            'watched_at' => $review->watched_at,
            'is_spoiler' => $review->is_spoiler,
            'created_at' => $review->created_at->diffForHumans(),
            'user' => [
                'name' => $review->user->name,
            ],
            'likes_count' => $review->likes_count,
            'comments' => $review->comments->map(function ($comment) {
                return [
                    'id' => $comment->id,
                    'content' => $comment->content,
                    'created_at' => $comment->created_at->diffForHumans(),
                    'user' => ['name' => $comment->user->name],
                ];
            }),
        ];
    });

    return Inertia::render('Performances/Show', [
        'performance' => [
            'id' => $performance->id,
            'title' => $performance->title,
            'description' => $performance->description,
            'poster_image' => $performance->poster_image,
            'start_date' => $performance->start_date,
            'end_date' => $performance->end_date,
        ],
        'reviews' => $reviews,
    ]);
}
```

**๋ผ์šฐํŠธ ์ถ”๊ฐ€:**

```php
Route::get('/performances/{id}', [PerformanceController::class, 'show'])->name('performances.show');
```

**React ์ปดํฌ๋„ŒํŠธ** (`resources/js/Pages/Performances/Show.jsx`):

```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';

export default function Show({ auth, performance, reviews }) {
    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title={performance.title} />
            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white rounded-lg shadow-md p-6 mb-8">
                        <div className="flex flex-col md:flex-row gap-6">
                            <img
                                src={performance.poster_image}
                                alt={performance.title}
                                className="w-full md:w-1/3 rounded-lg"
                            />
                            <div className="flex-1">
                                <h1 className="text-3xl font-bold mb-4">{performance.title}</h1>
                                <p className="text-gray-700 mb-4">{performance.description}</p>
                                <p className="text-sm text-gray-500">
                                    ๊ณต์—ฐ ๊ธฐ๊ฐ„: {performance.start_date} ~ {performance.end_date}
                                </p>
                                <Link
                                    href={`/performances/${performance.id}/reviews/create`}
                                    className="mt-6 inline-block bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700"
                                >
                                    ํ›„๊ธฐ ์ž‘์„ฑํ•˜๊ธฐ
                                </Link>
                            </div>
                        </div>
                    </div>

                    <h2 className="text-2xl font-bold mb-4">๊ด€๋žŒ๊ฐ ํ›„๊ธฐ ({reviews.length})</h2>
                    <div className="space-y-4">
                        {reviews.map((review) => (
                            <div key={review.id} className="bg-white rounded-lg shadow p-6">
                                <div className="flex items-center justify-between mb-3">
                                    <div className="flex items-center gap-2">
                                        <span className="font-semibold">{review.user.name}</span>
                                        <span className="text-yellow-500">
                                            {'โญ'.repeat(review.rating)}
                                        </span>
                                    </div>
                                    <span className="text-sm text-gray-500">{review.created_at}</span>
                                </div>
                                <p className="text-gray-700 mb-3">{review.content}</p>
                                <div className="flex items-center gap-4 text-sm text-gray-500">
                                    <button className="flex items-center gap-1 hover:text-red-500">
                                        โค๏ธ {review.likes_count}
                                    </button>
                                    <span>๐Ÿ’ฌ {review.comments.length}</span>
                                </div>
                            </div>
                        ))}
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ณต์—ฐ ์ƒ์„ธ ํŽ˜์ด์ง€ ์ ‘์†ํ•˜์—ฌ ํ›„๊ธฐ ๋ชฉ๋ก์ด ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๊ณต์—ฐ ์ƒ์„ธ ํŽ˜์ด์ง€๊ฐ€ ๊ตฌํ˜„๋˜๊ณ , ๊ณต์—ฐ ์ •๋ณด์™€ ํ•จ๊ป˜ ํ›„๊ธฐ ๋ชฉ๋ก์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. ๊ฐ ํ›„๊ธฐ๋Š” ์ž‘์„ฑ์ž, ๋ณ„์ , ๋‚ด์šฉ, ์ข‹์•„์š” ์ˆ˜, ๋Œ“๊ธ€ ์ˆ˜๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.

5
Step 5
ํ›„๊ธฐ ์ž‘์„ฑ ํผ์„ ๊ตฌํ˜„ํ•˜์„ธ์š”. ๋ณ„์  ์„ ํƒ, ํ…์ŠคํŠธ ์ž…๋ ฅ, ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ(์ตœ๋Œ€ 3์žฅ) ๊ธฐ๋Šฅ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.

**์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€** (`ReviewController` ์ƒ์„ฑ):

```bash
php artisan make:controller ReviewController
```

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

```php
public function create($performanceId)
{
    $performance = Performance::findOrFail($performanceId);

    return Inertia::render('Reviews/Create', [
        'performance' => [
            'id' => $performance->id,
            'title' => $performance->title,
        ],
    ]);
}

public function store(Request $request, $performanceId)
{
    $validated = $request->validate([
        'rating' => 'required|integer|min:1|max:5',
        'content' => 'required|string|min:10',
        'watched_at' => 'nullable|date',
        'is_spoiler' => 'boolean',
        'images.*' => 'nullable|image|max:2048', // ์ตœ๋Œ€ 2MB
    ]);

    $review = Review::create([
        'performance_id' => $performanceId,
        'user_id' => auth()->id(),
        'rating' => $validated['rating'],
        'content' => $validated['content'],
        'watched_at' => $validated['watched_at'] ?? null,
        'is_spoiler' => $validated['is_spoiler'] ?? false,
    ]);

    // ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ (๋‹ค์Œ ๋‹จ๊ณ„์—์„œ ๊ตฌํ˜„)

    return redirect()->route('performances.show', $performanceId)
        ->with('success', 'ํ›„๊ธฐ๊ฐ€ ์ž‘์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
}
```

**๋ผ์šฐํŠธ ์ถ”๊ฐ€:**

```php
use App\Http\Controllers\ReviewController;

Route::middleware('auth')->group(function () {
    Route::get('/performances/{id}/reviews/create', [ReviewController::class, 'create'])->name('reviews.create');
    Route::post('/performances/{id}/reviews', [ReviewController::class, 'store'])->name('reviews.store');
});
```

**React ์ปดํฌ๋„ŒํŠธ** (`resources/js/Pages/Reviews/Create.jsx`):

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

export default function Create({ auth, performance }) {
    const { data, setData, post, processing, errors } = useForm({
        rating: 5,
        content: '',
        watched_at: '',
        is_spoiler: false,
        images: [],
    });

    const [previewImages, setPreviewImages] = useState([]);

    const handleImageChange = (e) => {
        const files = Array.from(e.target.files).slice(0, 3); // ์ตœ๋Œ€ 3์žฅ
        setData('images', files);

        const previews = files.map((file) => URL.createObjectURL(file));
        setPreviewImages(previews);
    };

    const handleSubmit = (e) => {
        e.preventDefault();
        post(route('reviews.store', performance.id));
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title="ํ›„๊ธฐ ์ž‘์„ฑ" />
            <div className="py-12">
                <div className="max-w-3xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white rounded-lg shadow-md p-6">
                        <h1 className="text-2xl font-bold mb-6">
                            {performance.title} ํ›„๊ธฐ ์ž‘์„ฑ
                        </h1>
                        <form onSubmit={handleSubmit} className="space-y-6">
                            <div>
                                <label className="block text-sm font-medium mb-2">๋ณ„์ </label>
                                <div className="flex gap-2">
                                    {[1, 2, 3, 4, 5].map((star) => (
                                        <button
                                            key={star}
                                            type="button"
                                            onClick={() => setData('rating', star)}
                                            className="text-3xl"
                                        >
                                            {star <= data.rating ? 'โญ' : 'โ˜†'}
                                        </button>
                                    ))}
                                </div>
                                {errors.rating && (
                                    <p className="text-red-500 text-sm mt-1">{errors.rating}</p>
                                )}
                            </div>

                            <div>
                                <label className="block text-sm font-medium mb-2">ํ›„๊ธฐ ๋‚ด์šฉ</label>
                                <textarea
                                    value={data.content}
                                    onChange={(e) => setData('content', e.target.value)}
                                    rows="6"
                                    className="w-full border rounded-lg p-3"
                                    placeholder="๊ณต์—ฐ์„ ๊ด€๋žŒํ•œ ์†Œ๊ฐ์„ ์ž์œ ๋กญ๊ฒŒ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š” (์ตœ์†Œ 10์ž)"
                                />
                                {errors.content && (
                                    <p className="text-red-500 text-sm mt-1">{errors.content}</p>
                                )}
                            </div>

                            <div>
                                <label className="block text-sm font-medium mb-2">๊ด€๋žŒ ๋‚ ์งœ (์„ ํƒ)</label>
                                <input
                                    type="date"
                                    value={data.watched_at}
                                    onChange={(e) => setData('watched_at', e.target.value)}
                                    className="border rounded-lg p-2"
                                />
                            </div>

                            <div>
                                <label className="flex items-center gap-2">
                                    <input
                                        type="checkbox"
                                        checked={data.is_spoiler}
                                        onChange={(e) => setData('is_spoiler', e.target.checked)}
                                    />
                                    <span className="text-sm">์Šคํฌ์ผ๋Ÿฌ ํฌํ•จ</span>
                                </label>
                            </div>

                            <div>
                                <label className="block text-sm font-medium mb-2">
                                    ์‚ฌ์ง„ ์—…๋กœ๋“œ (์ตœ๋Œ€ 3์žฅ)
                                </label>
                                <input
                                    type="file"
                                    accept="image/*"
                                    multiple
                                    onChange={handleImageChange}
                                    className="block"
                                />
                                <div className="flex gap-2 mt-3">
                                    {previewImages.map((src, index) => (
                                        <img
                                            key={index}
                                            src={src}
                                            alt={`Preview ${index + 1}`}
                                            className="w-24 h-24 object-cover rounded"
                                        />
                                    ))}
                                </div>
                            </div>

                            <button
                                type="submit"
                                disabled={processing}
                                className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
                            >
                                ํ›„๊ธฐ ๋“ฑ๋กํ•˜๊ธฐ
                            </button>
                        </form>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ํ›„๊ธฐ ์ž‘์„ฑ ํผ์ด ์ •์ƒ์ ์œผ๋กœ ํ‘œ์‹œ๋˜๊ณ , ๋ณ„์  ์„ ํƒ๊ณผ ํผ ์ œ์ถœ์ด ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ํ›„๊ธฐ ์ž‘์„ฑ ํผ์ด ๊ตฌํ˜„๋˜๊ณ , ๋ณ„์  ์„ ํƒ, ํ…์ŠคํŠธ ์ž…๋ ฅ, ๊ด€๋žŒ ๋‚ ์งœ, ์Šคํฌ์ผ๋Ÿฌ ์ฒดํฌ๋ฐ•์Šค, ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. ํผ ์ œ์ถœ ์‹œ ๋ฐ์ดํ„ฐ๊ฐ€ Laravel ์ปจํŠธ๋กค๋Ÿฌ๋กœ ์ „์†ก๋ฉ๋‹ˆ๋‹ค.

6
Step 6
์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๊ธฐ๋Šฅ์„ ์™„์„ฑํ•˜๊ณ , ํ›„๊ธฐ์— ์ด๋ฏธ์ง€๋ฅผ ์ฒจ๋ถ€ํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌํ˜„ํ•˜์„ธ์š”.

**ReviewImage ๋ชจ๋ธ ์ƒ์„ฑ:**

```bash
php artisan make:model ReviewImage -m
```

**๋งˆ์ด๊ทธ๋ ˆ์ด์…˜** (`create_review_images_table.php`):

```php
Schema::create('review_images', function (Blueprint $table) {
    $table->id();
    $table->foreignId('review_id')->constrained()->onDelete('cascade');
    $table->string('path'); // ์ด๋ฏธ์ง€ ์ €์žฅ ๊ฒฝ๋กœ
    $table->timestamps();
});
```

๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰:

```bash
php artisan migrate
```

**๋ชจ๋ธ ๊ด€๊ณ„ ์ถ”๊ฐ€:**

- `Review.php`: `public function images() { return $this->hasMany(ReviewImage::class); }`
- `ReviewImage.php`: `public function review() { return $this->belongsTo(Review::class); }`

**์Šคํ† ๋ฆฌ์ง€ ๋งํฌ ์ƒ์„ฑ:**

```bash
php artisan storage:link
```

**์ปจํŠธ๋กค๋Ÿฌ ์ˆ˜์ •** (`ReviewController::store`):

```php
public function store(Request $request, $performanceId)
{
    $validated = $request->validate([
        'rating' => 'required|integer|min:1|max:5',
        'content' => 'required|string|min:10',
        'watched_at' => 'nullable|date',
        'is_spoiler' => 'boolean',
        'images.*' => 'nullable|image|max:2048',
    ]);

    $review = Review::create([
        'performance_id' => $performanceId,
        'user_id' => auth()->id(),
        'rating' => $validated['rating'],
        'content' => $validated['content'],
        'watched_at' => $validated['watched_at'] ?? null,
        'is_spoiler' => $validated['is_spoiler'] ?? false,
    ]);

    // ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ
    if ($request->hasFile('images')) {
        foreach ($request->file('images') as $image) {
            $path = $image->store('review-images', 'public');
            $review->images()->create(['path' => $path]);
        }
    }

    return redirect()->route('performances.show', $performanceId)
        ->with('success', 'ํ›„๊ธฐ๊ฐ€ ์ž‘์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
}
```

**๊ณต์—ฐ ์ƒ์„ธ ํŽ˜์ด์ง€์—์„œ ์ด๋ฏธ์ง€ ํ‘œ์‹œ** (`PerformanceController::show` ์ˆ˜์ •):

```php
$reviews = $performance->reviews->map(function ($review) {
    return [
        'id' => $review->id,
        'rating' => $review->rating,
        'content' => $review->content,
        'watched_at' => $review->watched_at,
        'is_spoiler' => $review->is_spoiler,
        'created_at' => $review->created_at->diffForHumans(),
        'user' => ['name' => $review->user->name],
        'likes_count' => $review->likes_count,
        'images' => $review->images->map(fn($img) => asset('storage/' . $img->path)),
        'comments' => $review->comments->map(function ($comment) {
            return [
                'id' => $comment->id,
                'content' => $comment->content,
                'created_at' => $comment->created_at->diffForHumans(),
                'user' => ['name' => $comment->user->name],
            ];
        }),
    ];
});
```

**Show.jsx์—์„œ ์ด๋ฏธ์ง€ ํ‘œ์‹œ:**

```jsx
{review.images && review.images.length > 0 && (
    <div className="flex gap-2 mt-3">
        {review.images.map((imgUrl, index) => (
            <img
                key={index}
                src={imgUrl}
                alt={`ํ›„๊ธฐ ์ด๋ฏธ์ง€ ${index + 1}`}
                className="w-32 h-32 object-cover rounded cursor-pointer"
            />
        ))}
    </div>
)}
```

ํ›„๊ธฐ ์ž‘์„ฑ ํ›„ ์ด๋ฏธ์ง€๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์—…๋กœ๋“œ๋˜๊ณ  ์ƒ์„ธ ํŽ˜์ด์ง€์— ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๊ธฐ๋Šฅ์ด ์™„์„ฑ๋˜๊ณ , ํ›„๊ธฐ ์ž‘์„ฑ ์‹œ ์ตœ๋Œ€ 3์žฅ์˜ ์ด๋ฏธ์ง€๋ฅผ ์ฒจ๋ถ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ณต์—ฐ ์ƒ์„ธ ํŽ˜์ด์ง€์—์„œ ํ›„๊ธฐ์— ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๊ฐ€ ์ธ๋„ค์ผ ํ˜•ํƒœ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

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

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

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

๋Œ“๊ธ€ (0)

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