๐ŸŽธ ๋ฐด๋“œ ๋ฉค๋ฒ„ ๋ชจ์ง‘ ๊ณต๊ณ ํŒ - ํฌ์ง€์…˜๋ณ„ ์ง€์›์„œ ์ ‘์ˆ˜ & ์˜ค๋””์…˜ ์ผ์ • ๊ด€๋ฆฌ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ค‘๊ธ‰ PHP + JavaScript Laravel + React + Inertia.js AI: Claude

๐ŸŽธ ๋ฐด๋“œ ๋ฉค๋ฒ„ ๋ชจ์ง‘ ๊ณต๊ณ ํŒ - ํฌ์ง€์…˜๋ณ„ ์ง€์›์„œ ์ ‘์ˆ˜ & ์˜ค๋””์…˜ ์ผ์ • ๊ด€๋ฆฌ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… band-member-recruitment-board
์กฐํšŒ์ˆ˜ 43 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐ŸŽต ํ”„๋กœ์ ํŠธ ๊ฐœ์š”

๋ฐด๋“œ๋ฅผ ๊ตฌ์„ฑํ•˜๋ ค๋Š” ํŒ€์ด ๋ฉค๋ฒ„๋ฅผ ๋ชจ์ง‘ํ•˜๊ณ , ์ง€์›์ž๊ฐ€ ํฌ์ง€์…˜๋ณ„๋กœ ์ง€์›์„œ๋ฅผ ์ œ์ถœํ•˜๋Š” ํ”Œ๋žซํผ์ž…๋‹ˆ๋‹ค. Laravel 13.x + Inertia.js + React 19 ๋ชจ๋…ธ๋ฆฌ์Šค ํ’€์Šคํƒ์œผ๋กœ ๊ตฌํ˜„ํ•˜๋ฉฐ, ์‹ค์ œ ์Œ์•… ์”ฌ์—์„œ ํ™œ์šฉ ๊ฐ€๋Šฅํ•œ ์‹ค์šฉ์ ์ธ ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค.

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

1. ๋ฉค๋ฒ„ ๋ชจ์ง‘ ๊ณต๊ณ  ๊ด€๋ฆฌ

  • ๋ฐด๋“œ๋ช…, ์žฅ๋ฅด, ํ™œ๋™ ์ง€์—ญ, ๋ชจ์ง‘ ํฌ์ง€์…˜(๋ณด์ปฌ/๊ธฐํƒ€/๋ฒ ์ด์Šค/๋“œ๋Ÿผ/ํ‚ค๋ณด๋“œ ๋“ฑ) ์ž…๋ ฅ
  • ๋ฐด๋“œ ์†Œ๊ฐœ, ์ง€ํ–ฅํ•˜๋Š” ์Œ์•… ์Šคํƒ€์ผ, ์—ฐ์Šต ๋นˆ๋„ ๋“ฑ ์ƒ์„ธ ์ •๋ณด ์ž‘์„ฑ
  • ๋ชจ์ง‘ ๋งˆ๊ฐ์ผ ์„ค์ • ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ(๋ชจ์ง‘์ค‘/๋งˆ๊ฐ/์˜ค๋””์…˜ ์ง„ํ–‰์ค‘)
  • ๊ณต๊ณ ๋ณ„ ์กฐํšŒ์ˆ˜ ์นด์šดํŒ…

2. ํฌ์ง€์…˜๋ณ„ ์ง€์›์„œ ์ ‘์ˆ˜

  • ์ง€์›์ž ์ด๋ฆ„, ์—ฐ๋ฝ์ฒ˜, ์ง€์› ํฌ์ง€์…˜ ์„ ํƒ
  • ๊ฒฝ๋ ฅ ์‚ฌํ•ญ, ์ž๊ธฐ์†Œ๊ฐœ, ์—ฐ์ฃผ ๊ฐ€๋Šฅ ์žฅ๋ฅด ์ž…๋ ฅ
  • ์œ ํŠœ๋ธŒ/์‚ฌ์šด๋“œํด๋ผ์šฐ๋“œ ๋“ฑ ํฌํŠธํด๋ฆฌ์˜ค ๋งํฌ ์ฒจ๋ถ€
  • ์„ ํ˜ธํ•˜๋Š” ์˜ค๋””์…˜ ์ผ์ • ์„ ํƒ(๋ณต์ˆ˜ ์„ ํƒ ๊ฐ€๋Šฅ)

3. ์ง€์›์„œ ๊ด€๋ฆฌ ๋Œ€์‹œ๋ณด๋“œ

  • ๊ณต๊ณ ๋ณ„ ์ง€์›์ž ๋ชฉ๋ก ์กฐํšŒ(ํฌ์ง€์…˜๋ณ„ ํ•„ํ„ฐ๋ง)
  • ์ง€์›์„œ ์ƒ์„ธ๋ณด๊ธฐ(๋ชจ๋‹ฌ ๋˜๋Š” ๋ณ„๋„ ํŽ˜์ด์ง€)
  • ์ง€์› ์ƒํƒœ ๋ณ€๊ฒฝ(๊ฒ€ํ† ์ค‘/ํ•ฉ๊ฒฉ/๋ถˆํ•ฉ๊ฒฉ)
  • ํ•ฉ๊ฒฉ์ž์—๊ฒŒ ์˜ค๋””์…˜ ์ผ์ • ํ™•์ • ์•Œ๋ฆผ(์ด๋ฉ”์ผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜)

4. ๊ฒ€์ƒ‰ ๋ฐ ํ•„ํ„ฐ๋ง

  • ์žฅ๋ฅด๋ณ„, ์ง€์—ญ๋ณ„, ํฌ์ง€์…˜๋ณ„ ๊ณต๊ณ  ๊ฒ€์ƒ‰
  • ๋งˆ๊ฐ ์ž„๋ฐ• ๊ณต๊ณ  ํ•˜์ด๋ผ์ดํŠธ ํ‘œ์‹œ
  • ์ตœ์‹ ์ˆœ/์กฐํšŒ์ˆ˜์ˆœ ์ •๋ ฌ

5. ๋ฐ˜์‘ํ˜• UI

  • Tailwind CSS ํ™œ์šฉํ•œ ๋ชจ๋˜ํ•œ ๋””์ž์ธ
  • ๋ชจ๋ฐ”์ผ/ํƒœ๋ธ”๋ฆฟ/๋ฐ์Šคํฌํ†ฑ ๋Œ€์‘
  • ํฌ์ง€์…˜๋ณ„ ์ƒ‰์ƒ ๊ตฌ๋ถ„(๋ณด์ปฌ=ํŒŒ๋ž‘, ๊ธฐํƒ€=๋นจ๊ฐ• ๋“ฑ)
  • ์ง€์›์„œ ์ œ์ถœ ์‹œ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ

๐ŸŽ ์„ ํƒ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ (๋ณด๋„ˆ์Šค)

  • ๋ฐด๋“œ ๋กœ๊ณ  ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๊ธฐ๋Šฅ
  • ์ง€์›์ž ์ฆ๊ฒจ์ฐพ๊ธฐ(๋ถ๋งˆํฌ) ๊ธฐ๋Šฅ
  • ๊ณต๊ณ ๋ณ„ ๋Œ“๊ธ€/๋ฌธ์˜ ๊ฒŒ์‹œํŒ
  • ์˜ค๋””์…˜ ์ผ์ • ์บ˜๋ฆฐ๋” ๋ทฐ
  • ์ง€์› ํ†ต๊ณ„ ์ฐจํŠธ(ํฌ์ง€์…˜๋ณ„ ์ง€์›์ž ์ˆ˜ ์‹œ๊ฐํ™”)
  • ์ด๋ฉ”์ผ ์•Œ๋ฆผ ์‹ค์ œ ์ „์†ก(Mailtrap ์—ฐ๋™)

๐ŸŽฏ ํ•™์Šต ๋ชฉํ‘œ

  • Laravel 13.x + Inertia.js + React 19 ๋ชจ๋…ธ๋ฆฌ์Šค ์•„ํ‚คํ…์ฒ˜ ์ดํ•ด
  • Eloquent ORM์„ ํ™œ์šฉํ•œ ๋ณต์žกํ•œ ๊ด€๊ณ„ํ˜• ๋ฐ์ดํ„ฐ ๋ชจ๋ธ๋ง(๋ฐด๋“œ-๊ณต๊ณ -์ง€์›์„œ)
  • Inertia.js์˜ ์„œ๋ฒ„-ํด๋ผ์ด์–ธํŠธ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ํ•™์Šต
  • React ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌ ๋ฐ ์žฌ์‚ฌ์šฉ ํŒจํ„ด ์ตํžˆ๊ธฐ
  • Form ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ(Laravel Validation + React)
  • ๋ชจ๋‹ฌ, ๋“œ๋กญ๋‹ค์šด, ํ•„ํ„ฐ๋ง ๋“ฑ ์‹ค๋ฌด UI ํŒจํ„ด ๊ตฌํ˜„
  • SQLite ๊ธฐ๋ฐ˜ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ฐ ์‹œ๋” ํ™œ์šฉ

์ด ํ”„๋กœ์ ํŠธ๋ฅผ ํ†ตํ•ด ์‹ค์ œ ์„œ๋น„์Šค ์ˆ˜์ค€์˜ ํ’€์Šคํƒ CRUD ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ตฌ์ถ•ํ•˜๊ณ , Inertia.js์˜ ๊ฐ•๋ ฅํ•จ์„ ์ฒดํ—˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋…ธ๋ฆฌ์Šค ๊ตฌ์กฐ๋กœ ๊ตฌํ˜„ (๋ณ„๋„ API ๋ผ์šฐํŠธ ์—†์Œ)
  • โ€ข npx create laravel ๋˜๋Š” Laravel Breeze(React+Inertia) starter kit ์‚ฌ์šฉ
  • โ€ข ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” SQLite ์‚ฌ์šฉ (config/database.php์—์„œ ๊ธฐ๋ณธ๊ฐ’ ์„ค์ •)
  • โ€ข Tailwind CSS ํ•„์ˆ˜ ์‚ฌ์šฉ
  • โ€ข Inertia::render()๋ฅผ ํ†ตํ•ด Laravel ์ปจํŠธ๋กค๋Ÿฌ์—์„œ React ์ปดํฌ๋„ŒํŠธ๋กœ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
  • โ€ข ์ตœ์†Œ 3๊ฐœ ์ด์ƒ์˜ ๋ชจ๋ธ ๊ด€๊ณ„ ์„ค์ • (Band, Recruitment, Application)
  • โ€ข Form ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋Š” Laravel Request ํด๋ž˜์Šค ํ™œ์šฉ
  • โ€ข React ์ปดํฌ๋„ŒํŠธ๋Š” Pages/ ํด๋”์— ํŽ˜์ด์ง€ ๋‹จ์œ„๋กœ, Components/ ํด๋”์— ์žฌ์‚ฌ์šฉ ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌ
  • โ€ข ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ๊ธฐ๋Šฅ ๊ธˆ์ง€ (๋Œ“๊ธ€์€ HTTP ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„ ๊ฐ€๋Šฅ)
  • โ€ข ๋ชจ๋“  CRUD ์ž‘์—…์€ Inertia.js์˜ router.post/put/delete ์‚ฌ์šฉ

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

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

1
Step 1
mkdir band-member-recruitment-board && cd band-member-recruitment-board

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

# ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐ ์„ค์ •

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

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

์„ค์ • ์š”๊ตฌ์‚ฌํ•ญ:
1. `.env` ํŒŒ์ผ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ SQLite๋กœ ์„ค์ •:
   - `DB_CONNECTION=sqlite`
   - `DB_DATABASE` ํ•ญ๋ชฉ ์ฃผ์„ ์ฒ˜๋ฆฌ ๋˜๋Š” ์‚ญ์ œ
   - `database/database.sqlite` ํŒŒ์ผ ์ƒ์„ฑ: `touch database/database.sqlite`

2. Tailwind CSS๊ฐ€ Breeze์™€ ํ•จ๊ป˜ ์ž๋™ ์„ค์น˜๋˜์—ˆ๋Š”์ง€ ํ™•์ธ

3. ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ํ™•์ธ:
   - `resources/js/Pages/` - React ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ
   - `resources/js/Components/` - ์žฌ์‚ฌ์šฉ ์ปดํฌ๋„ŒํŠธ
   - `app/Http/Controllers/` - Inertia ์ปจํŠธ๋กค๋Ÿฌ

4. ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹คํ–‰:
   - ํ„ฐ๋ฏธ๋„ 1: `php artisan serve`
   - ํ„ฐ๋ฏธ๋„ 2: `npm run dev`

5. ๋ธŒ๋ผ์šฐ์ €์—์„œ `http://localhost:8000` ์ ‘์†ํ•˜์—ฌ Laravel Breeze ๊ธฐ๋ณธ ํ™”๋ฉด(Welcome ํŽ˜์ด์ง€) ํ™•์ธ

์™„๋ฃŒ ํ›„ ๋‹ค์Œ ๋‹จ๊ณ„๋ฅผ ์ง„ํ–‰ํ•  ์ค€๋น„๊ฐ€ ๋˜์—ˆ๋‹ค๊ณ  ์•Œ๋ ค์ฃผ์„ธ์š”.

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

2
Step 2
# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ฐ ๋ชจ๋ธ ์ƒ์„ฑ

๋ฐด๋“œ ๋ฉค๋ฒ„ ๋ชจ์ง‘ ๊ณต๊ณ ํŒ์˜ ํ•ต์‹ฌ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ์„ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค. 3๊ฐœ์˜ ์ฃผ์š” ๋ชจ๋ธ(Band, Recruitment, Application)๊ณผ ๊ด€๊ณ„๋ฅผ ์„ค์ •ํ•˜์„ธ์š”.

## 1. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ์ƒ์„ฑ

```bash
php artisan make:model Band -m
php artisan make:model Recruitment -m
php artisan make:model Application -m
```

## 2. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์Šคํ‚ค๋งˆ ์ž‘์„ฑ

### `database/migrations/xxxx_create_bands_table.php`
```php
Schema::create('bands', function (Blueprint $table) {
    $table->id();
    $table->string('name'); // ๋ฐด๋“œ๋ช…
    $table->string('genre'); // ์žฅ๋ฅด (๋ก, ์žฌ์ฆˆ, ํž™ํ•ฉ ๋“ฑ)
    $table->string('region'); // ํ™œ๋™ ์ง€์—ญ
    $table->text('description'); // ๋ฐด๋“œ ์†Œ๊ฐœ
    $table->string('logo_url')->nullable(); // ๋กœ๊ณ  ์ด๋ฏธ์ง€ URL (์„ ํƒ)
    $table->timestamps();
});
```

### `database/migrations/xxxx_create_recruitments_table.php`
```php
Schema::create('recruitments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('band_id')->constrained()->onDelete('cascade');
    $table->json('positions'); // ๋ชจ์ง‘ ํฌ์ง€์…˜ ๋ฐฐ์—ด (๋ณด์ปฌ, ๊ธฐํƒ€, ๋ฒ ์ด์Šค ๋“ฑ)
    $table->text('details'); // ์ƒ์„ธ ๋ชจ์ง‘ ๋‚ด์šฉ
    $table->string('practice_frequency'); // ์—ฐ์Šต ๋นˆ๋„ (์ฃผ 1ํšŒ, ์ฃผ 2ํšŒ ๋“ฑ)
    $table->date('deadline'); // ๋ชจ์ง‘ ๋งˆ๊ฐ์ผ
    $table->enum('status', ['open', 'closed', 'audition'])->default('open'); // ๋ชจ์ง‘ ์ƒํƒœ
    $table->integer('views')->default(0); // ์กฐํšŒ์ˆ˜
    $table->timestamps();
});
```

### `database/migrations/xxxx_create_applications_table.php`
```php
Schema::create('applications', function (Blueprint $table) {
    $table->id();
    $table->foreignId('recruitment_id')->constrained()->onDelete('cascade');
    $table->string('name'); // ์ง€์›์ž ์ด๋ฆ„
    $table->string('email'); // ์—ฐ๋ฝ์ฒ˜
    $table->string('phone');
    $table->string('position'); // ์ง€์› ํฌ์ง€์…˜
    $table->text('experience'); // ๊ฒฝ๋ ฅ ์‚ฌํ•ญ
    $table->text('introduction'); // ์ž๊ธฐ์†Œ๊ฐœ
    $table->json('genres'); // ์—ฐ์ฃผ ๊ฐ€๋Šฅ ์žฅ๋ฅด ๋ฐฐ์—ด
    $table->string('portfolio_url')->nullable(); // ํฌํŠธํด๋ฆฌ์˜ค ๋งํฌ
    $table->json('preferred_dates')->nullable(); // ์„ ํ˜ธ ์˜ค๋””์…˜ ์ผ์ • ๋ฐฐ์—ด
    $table->enum('status', ['pending', 'reviewed', 'accepted', 'rejected'])->default('pending');
    $table->timestamps();
});
```

## 3. ๋ชจ๋ธ ๊ด€๊ณ„ ์„ค์ •

### `app/Models/Band.php`
```php
public function recruitments()
{
    return $this->hasMany(Recruitment::class);
}
```

### `app/Models/Recruitment.php`
```php
protected $casts = [
    'positions' => 'array',
    'deadline' => 'date',
];

public function band()
{
    return $this->belongsTo(Band::class);
}

public function applications()
{
    return $this->hasMany(Application::class);
}
```

### `app/Models/Application.php`
```php
protected $casts = [
    'genres' => 'array',
    'preferred_dates' => 'array',
];

public function recruitment()
{
    return $this->belongsTo(Recruitment::class);
}
```

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

```bash
php artisan migrate
```

## 5. ์‹œ๋” ๋ฐ์ดํ„ฐ ์ƒ์„ฑ (์„ ํƒ)

`database/seeders/DatabaseSeeder.php`์— ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€:

```php
use App\Models\Band;
use App\Models\Recruitment;

Band::factory()->create([
    'name' => 'The Rolling Codes',
    'genre' => '๋ก',
    'region' => '์„œ์šธ ํ™๋Œ€',
    'description' => '2000๋…„๋Œ€ ๋ธŒ๋ฆฟํŒ์„ ์ง€ํ–ฅํ•˜๋Š” 4์ธ์กฐ ๋ฐด๋“œ์ž…๋‹ˆ๋‹ค.',
]);

// Recruitment ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ๋„ ์ถ”๊ฐ€
```

๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜๋ฉด ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: 3๊ฐœ์˜ ๋ชจ๋ธ(Band, Recruitment, Application)๊ณผ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์ƒ์„ฑ๋˜๊ณ , ๊ด€๊ณ„๊ฐ€ ์„ค์ •๋ฉ๋‹ˆ๋‹ค. `php artisan migrate` ์‹คํ–‰ ์‹œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

3
Step 3
# ๋ชจ์ง‘ ๊ณต๊ณ  ๋ชฉ๋ก ํŽ˜์ด์ง€ ๊ตฌํ˜„

๋ฉ”์ธ ํ™”๋ฉด์—์„œ ๋ชจ๋“  ๋ชจ์ง‘ ๊ณต๊ณ ๋ฅผ ์นด๋“œ ํ˜•ํƒœ๋กœ ํ‘œ์‹œํ•˜๊ณ , ๊ฒ€์ƒ‰/ํ•„ํ„ฐ๋ง ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

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

```bash
php artisan make:controller RecruitmentController
```

### `app/Http/Controllers/RecruitmentController.php`

```php
use App\Models\Recruitment;
use Inertia\Inertia;

public function index(Request $request)
{
    $query = Recruitment::with('band')
        ->where('status', 'open')
        ->orderBy('created_at', 'desc');

    // ๊ฒ€์ƒ‰ ํ•„ํ„ฐ
    if ($request->genre) {
        $query->whereHas('band', function($q) use ($request) {
            $q->where('genre', $request->genre);
        });
    }

    if ($request->region) {
        $query->whereHas('band', function($q) use ($request) {
            $q->where('region', 'like', '%' . $request->region . '%');
        });
    }

    if ($request->position) {
        $query->whereJsonContains('positions', $request->position);
    }

    // ์ •๋ ฌ
    if ($request->sort === 'views') {
        $query->orderBy('views', 'desc');
    }

    $recruitments = $query->paginate(12);

    return Inertia::render('Recruitments/Index', [
        'recruitments' => $recruitments,
        'filters' => $request->only(['genre', 'region', 'position', 'sort']),
    ]);
}
```

## 2. ๋ผ์šฐํŠธ ์„ค์ •

### `routes/web.php`

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

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

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

### `resources/js/Pages/Recruitments/Index.jsx`

```jsx
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';

export default function Index({ recruitments, filters }) {
    const [search, setSearch] = useState({
        genre: filters.genre || '',
        region: filters.region || '',
        position: filters.position || '',
        sort: filters.sort || 'latest',
    });

    const handleFilter = () => {
        router.get('/', search, { preserveState: true });
    };

    const positions = ['๋ณด์ปฌ', '๊ธฐํƒ€', '๋ฒ ์ด์Šค', '๋“œ๋Ÿผ', 'ํ‚ค๋ณด๋“œ'];
    const genres = ['๋ก', '์žฌ์ฆˆ', 'ํž™ํ•ฉ', '์ธ๋””', '๋ฉ”ํƒˆ', 'R&B'];

    return (
        <>
            <Head title="๋ฐด๋“œ ๋ฉค๋ฒ„ ๋ชจ์ง‘" />
            
            <div className="min-h-screen bg-gray-50">
                {/* ํ—ค๋” */}
                <header className="bg-gradient-to-r from-purple-600 to-pink-600 text-white py-12">
                    <div className="container mx-auto px-4">
                        <h1 className="text-4xl font-bold mb-2">๐ŸŽธ ๋ฐด๋“œ ๋ฉค๋ฒ„ ๋ชจ์ง‘</h1>
                        <p className="text-lg opacity-90">๋‹น์‹ ์˜ ์Œ์•…์„ ํ•จ๊ป˜ ๋งŒ๋“ค ๋™๋ฃŒ๋ฅผ ์ฐพ์•„๋ณด์„ธ์š”</p>
                    </div>
                </header>

                {/* ํ•„ํ„ฐ ์„น์…˜ */}
                <div className="bg-white shadow-sm border-b">
                    <div className="container mx-auto px-4 py-6">
                        <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
                            <select
                                value={search.genre}
                                onChange={(e) => setSearch({...search, genre: e.target.value})}
                                className="rounded-lg border-gray-300"
                            >
                                <option value="">๋ชจ๋“  ์žฅ๋ฅด</option>
                                {genres.map(g => <option key={g} value={g}>{g}</option>)}
                            </select>

                            <input
                                type="text"
                                placeholder="์ง€์—ญ ๊ฒ€์ƒ‰"
                                value={search.region}
                                onChange={(e) => setSearch({...search, region: e.target.value})}
                                className="rounded-lg border-gray-300"
                            />

                            <select
                                value={search.position}
                                onChange={(e) => setSearch({...search, position: e.target.value})}
                                className="rounded-lg border-gray-300"
                            >
                                <option value="">๋ชจ๋“  ํฌ์ง€์…˜</option>
                                {positions.map(p => <option key={p} value={p}>{p}</option>)}
                            </select>

                            <button
                                onClick={handleFilter}
                                className="bg-purple-600 text-white rounded-lg px-6 py-2 hover:bg-purple-700"
                            >
                                ๊ฒ€์ƒ‰
                            </button>
                        </div>
                    </div>
                </div>

                {/* ๊ณต๊ณ  ๋ชฉ๋ก */}
                <div className="container mx-auto px-4 py-8">
                    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                        {recruitments.data.map((recruitment) => (
                            <Link
                                key={recruitment.id}
                                href={`/recruitments/${recruitment.id}`}
                                className="bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow p-6 border-l-4 border-purple-500"
                            >
                                <div className="flex justify-between items-start mb-3">
                                    <h3 className="text-xl font-bold text-gray-800">
                                        {recruitment.band.name}
                                    </h3>
                                    <span className="text-sm text-gray-500">๐Ÿ‘๏ธ {recruitment.views}</span>
                                </div>

                                <div className="flex gap-2 mb-3">
                                    <span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm">
                                        {recruitment.band.genre}
                                    </span>
                                    <span className="px-3 py-1 bg-pink-100 text-pink-700 rounded-full text-sm">
                                        ๐Ÿ“ {recruitment.band.region}
                                    </span>
                                </div>

                                <div className="mb-4">
                                    <p className="text-sm text-gray-600 mb-2">๋ชจ์ง‘ ํฌ์ง€์…˜:</p>
                                    <div className="flex flex-wrap gap-2">
                                        {recruitment.positions.map((pos, i) => (
                                            <span key={i} className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm">
                                                {pos}
                                            </span>
                                        ))}
                                    </div>
                                </div>

                                <div className="text-sm text-gray-500 flex justify-between">
                                    <span>โฐ {recruitment.practice_frequency}</span>
                                    <span>๐Ÿ“… ๋งˆ๊ฐ: {new Date(recruitment.deadline).toLocaleDateString()}</span>
                                </div>
                            </Link>
                        ))}
                    </div>

                    {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ */}
                    <div className="mt-8 flex justify-center gap-2">
                        {recruitments.links.map((link, i) => (
                            <Link
                                key={i}
                                href={link.url}
                                className={`px-4 py-2 rounded ${
                                    link.active ? 'bg-purple-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'
                                }`}
                                dangerouslySetInnerHTML={{ __html: link.label }}
                            />
                        ))}
                    </div>
                </div>
            </div>
        </>
    );
}
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ๋ฉ”์ธ ํŽ˜์ด์ง€๋ฅผ ํ™•์ธํ•˜๊ณ , ํ•„ํ„ฐ๋ง์ด ์ž‘๋™ํ•˜๋Š”์ง€ ํ…Œ์ŠคํŠธํ•˜์„ธ์š”. (๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋‹ค๋ฉด Tinker๋‚˜ ์‹œ๋”๋กœ ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”.)

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๋ชจ์ง‘ ๊ณต๊ณ  ๋ชฉ๋ก ํŽ˜์ด์ง€๊ฐ€ ์นด๋“œ ํ˜•ํƒœ๋กœ ํ‘œ์‹œ๋˜๊ณ , ์žฅ๋ฅด/์ง€์—ญ/ํฌ์ง€์…˜๋ณ„ ํ•„ํ„ฐ๋ง ๋ฐ ์ •๋ ฌ ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. Inertia.js์˜ router.get()์„ ํ†ตํ•ด ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ ์—†์ด ํ•„ํ„ฐ๋ง์ด ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

4
Step 4
# ๊ณต๊ณ  ์ƒ์„ธ ํŽ˜์ด์ง€ ๋ฐ ์ง€์›์„œ ์ œ์ถœ ํผ ๊ตฌํ˜„

ํŠน์ • ๋ชจ์ง‘ ๊ณต๊ณ ๋ฅผ ํด๋ฆญํ•˜๋ฉด ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ , ํ•˜๋‹จ์— ์ง€์›์„œ ์ œ์ถœ ํผ์„ ๋ฐฐ์น˜ํ•ฉ๋‹ˆ๋‹ค.

## 1. ์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€

### `app/Http/Controllers/RecruitmentController.php`

```php
public function show(Recruitment $recruitment)
{
    // ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€
    $recruitment->increment('views');

    $recruitment->load('band', 'applications');

    return Inertia::render('Recruitments/Show', [
        'recruitment' => $recruitment,
        'applicationCount' => $recruitment->applications->count(),
    ]);
}
```

## 2. ๋ผ์šฐํŠธ ์ถ”๊ฐ€

### `routes/web.php`

```php
Route::get('/recruitments/{recruitment}', [RecruitmentController::class, 'show'])->name('recruitments.show');
```

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

### `resources/js/Pages/Recruitments/Show.jsx`

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

export default function Show({ recruitment, applicationCount }) {
    const [showForm, setShowForm] = useState(false);

    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
        email: '',
        phone: '',
        position: '',
        experience: '',
        introduction: '',
        genres: [],
        portfolio_url: '',
        preferred_dates: [],
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(`/recruitments/${recruitment.id}/apply`, {
            onSuccess: () => {
                reset();
                setShowForm(false);
                alert('์ง€์›์„œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ œ์ถœ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
            },
        });
    };

    const positionColors = {
        '๋ณด์ปฌ': 'bg-blue-500',
        '๊ธฐํƒ€': 'bg-red-500',
        '๋ฒ ์ด์Šค': 'bg-green-500',
        '๋“œ๋Ÿผ': 'bg-yellow-500',
        'ํ‚ค๋ณด๋“œ': 'bg-purple-500',
    };

    return (
        <>
            <Head title={`${recruitment.band.name} - ๋ฉค๋ฒ„ ๋ชจ์ง‘`} />

            <div className="min-h-screen bg-gray-50">
                {/* ํ—ค๋” */}
                <div className="bg-gradient-to-r from-purple-600 to-pink-600 text-white py-8">
                    <div className="container mx-auto px-4">
                        <Link href="/" className="text-sm hover:underline mb-2 block">โ† ๋ชฉ๋ก์œผ๋กœ</Link>
                        <h1 className="text-3xl font-bold">{recruitment.band.name}</h1>
                    </div>
                </div>

                <div className="container mx-auto px-4 py-8">
                    <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
                        {/* ๋ฉ”์ธ ์ปจํ…์ธ  */}
                        <div className="lg:col-span-2 space-y-6">
                            {/* ๋ฐด๋“œ ์ •๋ณด */}
                            <div className="bg-white rounded-lg shadow-md p-6">
                                <h2 className="text-2xl font-bold mb-4">๋ฐด๋“œ ์†Œ๊ฐœ</h2>
                                <div className="flex gap-3 mb-4">
                                    <span className="px-4 py-2 bg-purple-100 text-purple-700 rounded-full">
                                        {recruitment.band.genre}
                                    </span>
                                    <span className="px-4 py-2 bg-pink-100 text-pink-700 rounded-full">
                                        ๐Ÿ“ {recruitment.band.region}
                                    </span>
                                </div>
                                <p className="text-gray-700 leading-relaxed">{recruitment.band.description}</p>
                            </div>

                            {/* ๋ชจ์ง‘ ์ƒ์„ธ */}
                            <div className="bg-white rounded-lg shadow-md p-6">
                                <h2 className="text-2xl font-bold mb-4">๋ชจ์ง‘ ๋‚ด์šฉ</h2>
                                <div className="mb-4">
                                    <h3 className="font-semibold text-gray-700 mb-2">๋ชจ์ง‘ ํฌ์ง€์…˜</h3>
                                    <div className="flex flex-wrap gap-3">
                                        {recruitment.positions.map((pos, i) => (
                                            <span
                                                key={i}
                                                className={`px-4 py-2 ${positionColors[pos] || 'bg-gray-500'} text-white rounded-lg font-semibold`}
                                            >
                                                {pos}
                                            </span>
                                        ))}
                                    </div>
                                </div>

                                <div className="space-y-3 text-gray-700">
                                    <p><strong>์—ฐ์Šต ๋นˆ๋„:</strong> {recruitment.practice_frequency}</p>
                                    <p><strong>๋ชจ์ง‘ ๋งˆ๊ฐ:</strong> {new Date(recruitment.deadline).toLocaleDateString()}</p>
                                    <div>
                                        <strong>์ƒ์„ธ ๋‚ด์šฉ:</strong>
                                        <p className="mt-2 whitespace-pre-wrap">{recruitment.details}</p>
                                    </div>
                                </div>
                            </div>

                            {/* ์ง€์› ๋ฒ„ํŠผ */}
                            {!showForm && (
                                <button
                                    onClick={() => setShowForm(true)}
                                    className="w-full bg-gradient-to-r from-purple-600 to-pink-600 text-white py-4 rounded-lg font-bold text-lg hover:opacity-90 transition"
                                >
                                    ๐ŸŽธ ์ง€์›ํ•˜๊ธฐ
                                </button>
                            )}

                            {/* ์ง€์›์„œ ํผ */}
                            {showForm && (
                                <div className="bg-white rounded-lg shadow-md p-6">
                                    <h2 className="text-2xl font-bold mb-6">์ง€์›์„œ ์ž‘์„ฑ</h2>
                                    <form onSubmit={handleSubmit} className="space-y-4">
                                        <div>
                                            <label className="block text-sm font-semibold mb-1">์ด๋ฆ„ *</label>
                                            <input
                                                type="text"
                                                value={data.name}
                                                onChange={(e) => setData('name', e.target.value)}
                                                className="w-full rounded-lg border-gray-300"
                                                required
                                            />
                                            {errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
                                        </div>

                                        <div className="grid grid-cols-2 gap-4">
                                            <div>
                                                <label className="block text-sm font-semibold mb-1">์ด๋ฉ”์ผ *</label>
                                                <input
                                                    type="email"
                                                    value={data.email}
                                                    onChange={(e) => setData('email', e.target.value)}
                                                    className="w-full rounded-lg border-gray-300"
                                                    required
                                                />
                                            </div>
                                            <div>
                                                <label className="block text-sm font-semibold mb-1">์—ฐ๋ฝ์ฒ˜ *</label>
                                                <input
                                                    type="tel"
                                                    value={data.phone}
                                                    onChange={(e) => setData('phone', e.target.value)}
                                                    className="w-full rounded-lg border-gray-300"
                                                    required
                                                />
                                            </div>
                                        </div>

                                        <div>
                                            <label className="block text-sm font-semibold mb-1">์ง€์› ํฌ์ง€์…˜ *</label>
                                            <select
                                                value={data.position}
                                                onChange={(e) => setData('position', e.target.value)}
                                                className="w-full rounded-lg border-gray-300"
                                                required
                                            >
                                                <option value="">์„ ํƒํ•˜์„ธ์š”</option>
                                                {recruitment.positions.map((pos, i) => (
                                                    <option key={i} value={pos}>{pos}</option>
                                                ))}
                                            </select>
                                        </div>

                                        <div>
                                            <label className="block text-sm font-semibold mb-1">๊ฒฝ๋ ฅ ์‚ฌํ•ญ *</label>
                                            <textarea
                                                value={data.experience}
                                                onChange={(e) => setData('experience', e.target.value)}
                                                className="w-full rounded-lg border-gray-300"
                                                rows="3"
                                                placeholder="์—ฐ์ฃผ ๊ฒฝ๋ ฅ, ์ด์ „ ๋ฐด๋“œ ํ™œ๋™ ๋“ฑ"
                                                required
                                            />
                                        </div>

                                        <div>
                                            <label className="block text-sm font-semibold mb-1">์ž๊ธฐ์†Œ๊ฐœ *</label>
                                            <textarea
                                                value={data.introduction}
                                                onChange={(e) => setData('introduction', e.target.value)}
                                                className="w-full rounded-lg border-gray-300"
                                                rows="4"
                                                placeholder="์Œ์•… ์Šคํƒ€์ผ, ์ง€ํ–ฅ์ , ๊ฐ์˜ค ๋“ฑ"
                                                required
                                            />
                                        </div>

                                        <div>
                                            <label className="block text-sm font-semibold mb-1">ํฌํŠธํด๋ฆฌ์˜ค ๋งํฌ</label>
                                            <input
                                                type="url"
                                                value={data.portfolio_url}
                                                onChange={(e) => setData('portfolio_url', e.target.value)}
                                                className="w-full rounded-lg border-gray-300"
                                                placeholder="์œ ํŠœ๋ธŒ, ์‚ฌ์šด๋“œํด๋ผ์šฐ๋“œ ๋“ฑ"
                                            />
                                        </div>

                                        <div className="flex gap-4">
                                            <button
                                                type="submit"
                                                disabled={processing}
                                                className="flex-1 bg-purple-600 text-white py-3 rounded-lg font-semibold hover:bg-purple-700 disabled:opacity-50"
                                            >
                                                {processing ? '์ œ์ถœ ์ค‘...' : '์ง€์›์„œ ์ œ์ถœ'}
                                            </button>
                                            <button
                                                type="button"
                                                onClick={() => setShowForm(false)}
                                                className="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50"
                                            >
                                                ์ทจ์†Œ
                                            </button>
                                        </div>
                                    </form>
                                </div>
                            )}
                        </div>

                        {/* ์‚ฌ์ด๋“œ๋ฐ” */}
                        <div className="space-y-6">
                            <div className="bg-white rounded-lg shadow-md p-6">
                                <h3 className="font-bold text-lg mb-3">๐Ÿ“Š ์ง€์› ํ˜„ํ™ฉ</h3>
                                <p className="text-3xl font-bold text-purple-600">{applicationCount}๋ช…</p>
                                <p className="text-sm text-gray-500 mt-1">ํ˜„์žฌ๊นŒ์ง€ ์ง€์›์ž ์ˆ˜</p>
                            </div>

                            <div className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg p-6">
                                <h3 className="font-bold text-lg mb-3">๐Ÿ’ก ์ง€์› ํŒ</h3>
                                <ul className="text-sm text-gray-700 space-y-2">
                                    <li>โœ“ ํฌํŠธํด๋ฆฌ์˜ค ๋งํฌ๋ฅผ ๊ผญ ์ฒจ๋ถ€ํ•˜์„ธ์š”</li>
                                    <li>โœ“ ๋ฐด๋“œ์˜ ์Œ์•… ์Šคํƒ€์ผ์„ ๋ฏธ๋ฆฌ ํ™•์ธํ•˜์„ธ์š”</li>
                                    <li>โœ“ ์ง„์†”ํ•œ ์ž๊ธฐ์†Œ๊ฐœ๊ฐ€ ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค</li>
                                </ul>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </>
    );
}
```

๋‹ค์Œ ๋‹จ๊ณ„์—์„œ ์ง€์›์„œ ์ œ์ถœ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ๊ตฌํ˜„ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๊ณต๊ณ  ์ƒ์„ธ ํŽ˜์ด์ง€๊ฐ€ ํ‘œ์‹œ๋˜๊ณ , ๋ฐด๋“œ ์ •๋ณด, ๋ชจ์ง‘ ์ƒ์„ธ ๋‚ด์šฉ, ์ง€์›์„œ ์ž‘์„ฑ ํผ์ด ๋ชจ๋‘ ๋ Œ๋”๋ง๋ฉ๋‹ˆ๋‹ค. '์ง€์›ํ•˜๊ธฐ' ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ํผ์ด ๋‚˜ํƒ€๋‚˜๊ณ , ์ทจ์†Œ ๋ฒ„ํŠผ์œผ๋กœ ์ˆจ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

5
Step 5
# ์ง€์›์„œ ์ œ์ถœ ์ฒ˜๋ฆฌ ๋ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ

์ง€์›์„œ ํผ ์ œ์ถœ์„ ์ฒ˜๋ฆฌํ•˜๊ณ , Laravel์˜ Form Request๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

## 1. Form Request ์ƒ์„ฑ

```bash
php artisan make:request StoreApplicationRequest
```

### `app/Http/Requests/StoreApplicationRequest.php`

```php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreApplicationRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'name' => 'required|string|max:100',
            'email' => 'required|email|max:255',
            'phone' => 'required|string|max:20',
            'position' => 'required|string',
            'experience' => 'required|string|min:50',
            'introduction' => 'required|string|min:100',
            'genres' => 'nullable|array',
            'portfolio_url' => 'nullable|url',
            'preferred_dates' => 'nullable|array',
        ];
    }

    public function messages()
    {
        return [
            'name.required' => '์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.',
            'email.required' => '์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.',
            'email.email' => '์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.',
            'phone.required' => '์—ฐ๋ฝ์ฒ˜๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.',
            'position.required' => '์ง€์› ํฌ์ง€์…˜์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.',
            'experience.required' => '๊ฒฝ๋ ฅ ์‚ฌํ•ญ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.',
            'experience.min' => '๊ฒฝ๋ ฅ ์‚ฌํ•ญ์€ ์ตœ์†Œ 50์ž ์ด์ƒ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”.',
            'introduction.required' => '์ž๊ธฐ์†Œ๊ฐœ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.',
            'introduction.min' => '์ž๊ธฐ์†Œ๊ฐœ๋Š” ์ตœ์†Œ 100์ž ์ด์ƒ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”.',
            'portfolio_url.url' => '์˜ฌ๋ฐ”๋ฅธ URL ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.',
        ];
    }
}
```

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

```bash
php artisan make:controller ApplicationController
```

### `app/Http/Controllers/ApplicationController.php`

```php
namespace App\Http\Controllers;

use App\Models\Recruitment;
use App\Models\Application;
use App\Http\Requests\StoreApplicationRequest;
use Illuminate\Http\Request;

class ApplicationController extends Controller
{
    public function store(StoreApplicationRequest $request, Recruitment $recruitment)
    {
        // ๋งˆ๊ฐ์ผ ์ฒดํฌ
        if ($recruitment->deadline < now()) {
            return back()->withErrors(['error' => '์ด๋ฏธ ๋งˆ๊ฐ๋œ ๊ณต๊ณ ์ž…๋‹ˆ๋‹ค.']);
        }

        // ์ง€์›์„œ ์ƒ์„ฑ
        $application = $recruitment->applications()->create([
            'name' => $request->name,
            'email' => $request->email,
            'phone' => $request->phone,
            'position' => $request->position,
            'experience' => $request->experience,
            'introduction' => $request->introduction,
            'genres' => $request->genres ?? [],
            'portfolio_url' => $request->portfolio_url,
            'preferred_dates' => $request->preferred_dates ?? [],
            'status' => 'pending',
        ]);

        // ์ด๋ฉ”์ผ ์•Œ๋ฆผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ (์‹ค์ œ ๋ฐœ์†ก์€ Mailtrap ๋“ฑ์œผ๋กœ ๊ตฌํ˜„ ๊ฐ€๋Šฅ)
        \Log::info('์ง€์›์„œ ์ œ์ถœ ์•Œ๋ฆผ', [
            'recruitment_id' => $recruitment->id,
            'applicant' => $request->name,
            'email' => $request->email,
        ]);

        return redirect()->route('recruitments.show', $recruitment)
            ->with('success', '์ง€์›์„œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ œ์ถœ๋˜์—ˆ์Šต๋‹ˆ๋‹ค! ๊ณง ์—ฐ๋ฝ๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค.');
    }
}
```

## 3. ๋ผ์šฐํŠธ ์ถ”๊ฐ€

### `routes/web.php`

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

Route::post('/recruitments/{recruitment}/apply', [ApplicationController::class, 'store'])
    ->name('applications.store');
```

## 4. Show ํŽ˜์ด์ง€์— ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ

### `resources/js/Pages/Recruitments/Show.jsx` ์ƒ๋‹จ์— ์ถ”๊ฐ€

```jsx
import { Head, Link, useForm, usePage } from '@inertiajs/react';

export default function Show({ recruitment, applicationCount }) {
    const { flash } = usePage().props;
    
    // ... ๊ธฐ์กด ์ฝ”๋“œ ...

    return (
        <>
            {/* ... ๊ธฐ์กด ํ—ค๋” ... */}

            <div className="container mx-auto px-4 py-8">
                {/* ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ */}
                {flash.success && (
                    <div className="bg-green-100 border border-green-400 text-green-700 px-6 py-4 rounded-lg mb-6">
                        โœ“ {flash.success}
                    </div>
                )}

                {/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */}
                {flash.error && (
                    <div className="bg-red-100 border border-red-400 text-red-700 px-6 py-4 rounded-lg mb-6">
                        โœ— {flash.error}
                    </div>
                )}

                {/* ... ๊ธฐ์กด ์ปจํ…์ธ  ... */}
            </div>
        </>
    );
}
```

## 5. ํ…Œ์ŠคํŠธ

1. ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ณต๊ณ  ์ƒ์„ธ ํŽ˜์ด์ง€ ์ ‘์†
2. '์ง€์›ํ•˜๊ธฐ' ๋ฒ„ํŠผ ํด๋ฆญ
3. ํผ ์ž‘์„ฑ (์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ํ…Œ์ŠคํŠธ: ์ด๋ฉ”์ผ ํ˜•์‹ ์˜ค๋ฅ˜, ์ตœ์†Œ ๊ธ€์ž ์ˆ˜ ๋ฏธ๋‹ฌ ๋“ฑ)
4. ์ œ์ถœ ํ›„ ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ ํ™•์ธ
5. `storage/logs/laravel.log`์—์„œ ์ง€์›์„œ ์ œ์ถœ ๋กœ๊ทธ ํ™•์ธ

๋‹ค์Œ ๋‹จ๊ณ„์—์„œ ์ง€์›์„œ ๊ด€๋ฆฌ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

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

6
Step 6
# ์ง€์›์„œ ๊ด€๋ฆฌ ๋Œ€์‹œ๋ณด๋“œ ๊ตฌํ˜„

๋ฐด๋“œ ์šด์˜์ž๊ฐ€ ์ œ์ถœ๋œ ์ง€์›์„œ๋ฅผ ํ™•์ธํ•˜๊ณ , ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝ(๊ฒ€ํ† ์ค‘/ํ•ฉ๊ฒฉ/๋ถˆํ•ฉ๊ฒฉ)ํ•  ์ˆ˜ ์žˆ๋Š” ๊ด€๋ฆฌ ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

## 1. ์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€

### `app/Http/Controllers/RecruitmentController.php`

```php
public function applications(Recruitment $recruitment)
{
    $recruitment->load(['band', 'applications' => function($query) {
        $query->latest();
    }]);

    // ํฌ์ง€์…˜๋ณ„ ํ†ต๊ณ„
    $stats = $recruitment->applications
        ->groupBy('position')
        ->map(fn($group) => $group->count());

    return Inertia::render('Recruitments/Applications', [
        'recruitment' => $recruitment,
        'applications' => $recruitment->applications,
        'stats' => $stats,
    ]);
}
```

### `app/Http/Controllers/ApplicationController.php`์— ์ถ”๊ฐ€

```php
public function updateStatus(Request $request, Application $application)
{
    $request->validate([
        'status' => 'required|in:pending,reviewed,accepted,rejected',
    ]);

    $application->update([
        'status' => $request->status,
    ]);

    return back()->with('success', '์ง€์› ์ƒํƒœ๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
```

## 2. ๋ผ์šฐํŠธ ์ถ”๊ฐ€

### `routes/web.php`

```php
Route::get('/recruitments/{recruitment}/applications', [RecruitmentController::class, 'applications'])
    ->name('recruitments.applications');

Route::patch('/applications/{application}/status', [ApplicationController::class, 'updateStatus'])
    ->name('applications.update-status');
```

## 3. React ์ง€์›์„œ ๊ด€๋ฆฌ ํŽ˜์ด์ง€

### `resources/js/Pages/Recruitments/Applications.jsx`

```jsx
import { Head, Link, router, usePage } from '@inertiajs/react';
import { useState } from 'react';

export default function Applications({ recruitment, applications, stats }) {
    const { flash } = usePage().props;
    const [filter, setFilter] = useState('all');
    const [selectedApp, setSelectedApp] = useState(null);

    const statusColors = {
        pending: 'bg-yellow-100 text-yellow-800',
        reviewed: 'bg-blue-100 text-blue-800',
        accepted: 'bg-green-100 text-green-800',
        rejected: 'bg-red-100 text-red-800',
    };

    const statusLabels = {
        pending: '๊ฒ€ํ†  ๋Œ€๊ธฐ',
        reviewed: '๊ฒ€ํ†  ์™„๋ฃŒ',
        accepted: 'โœ“ ํ•ฉ๊ฒฉ',
        rejected: 'โœ— ๋ถˆํ•ฉ๊ฒฉ',
    };

    const filteredApplications = filter === 'all'
        ? applications
        : applications.filter(app => app.status === filter);

    const handleStatusChange = (applicationId, newStatus) => {
        if (confirm(`์ƒํƒœ๋ฅผ "${statusLabels[newStatus]}"๋กœ ๋ณ€๊ฒฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?`)) {
            router.patch(`/applications/${applicationId}/status`, {
                status: newStatus,
            }, {
                preserveScroll: true,
            });
        }
    };

    return (
        <>
            <Head title={`${recruitment.band.name} - ์ง€์›์„œ ๊ด€๋ฆฌ`} />

            <div className="min-h-screen bg-gray-50">
                {/* ํ—ค๋” */}
                <div className="bg-gradient-to-r from-purple-600 to-pink-600 text-white py-8">
                    <div className="container mx-auto px-4">
                        <Link href={`/recruitments/${recruitment.id}`} className="text-sm hover:underline mb-2 block">
                            โ† ๊ณต๊ณ  ์ƒ์„ธ๋กœ
                        </Link>
                        <h1 className="text-3xl font-bold">{recruitment.band.name} - ์ง€์›์„œ ๊ด€๋ฆฌ</h1>
                        <p className="text-sm opacity-90 mt-1">์ด {applications.length}๋ช…์˜ ์ง€์›์ž</p>
                    </div>
                </div>

                <div className="container mx-auto px-4 py-8">
                    {/* ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ */}
                    {flash.success && (
                        <div className="bg-green-100 border border-green-400 text-green-700 px-6 py-4 rounded-lg mb-6">
                            โœ“ {flash.success}
                        </div>
                    )}

                    {/* ํ†ต๊ณ„ ์นด๋“œ */}
                    <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
                        <div className="bg-white rounded-lg shadow p-6">
                            <p className="text-sm text-gray-600">์ „์ฒด ์ง€์›์ž</p>
                            <p className="text-3xl font-bold text-purple-600">{applications.length}</p>
                        </div>
                        <div className="bg-white rounded-lg shadow p-6">
                            <p className="text-sm text-gray-600">๊ฒ€ํ†  ๋Œ€๊ธฐ</p>
                            <p className="text-3xl font-bold text-yellow-600">
                                {applications.filter(a => a.status === 'pending').length}
                            </p>
                        </div>
                        <div className="bg-white rounded-lg shadow p-6">
                            <p className="text-sm text-gray-600">ํ•ฉ๊ฒฉ</p>
                            <p className="text-3xl font-bold text-green-600">
                                {applications.filter(a => a.status === 'accepted').length}
                            </p>
                        </div>
                        <div className="bg-white rounded-lg shadow p-6">
                            <p className="text-sm text-gray-600">๋ถˆํ•ฉ๊ฒฉ</p>
                            <p className="text-3xl font-bold text-red-600">
                                {applications.filter(a => a.status === 'rejected').length}
                            </p>
                        </div>
                    </div>

                    {/* ํฌ์ง€์…˜๋ณ„ ํ†ต๊ณ„ */}
                    <div className="bg-white rounded-lg shadow p-6 mb-8">
                        <h2 className="text-xl font-bold mb-4">๐Ÿ“Š ํฌ์ง€์…˜๋ณ„ ์ง€์› ํ˜„ํ™ฉ</h2>
                        <div className="flex flex-wrap gap-4">
                            {Object.entries(stats).map(([position, count]) => (
                                <div key={position} className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg">
                                    <span className="font-semibold">{position}</span>: {count}๋ช…
                                </div>
                            ))}
                        </div>
                    </div>

                    {/* ํ•„ํ„ฐ ๋ฒ„ํŠผ */}
                    <div className="bg-white rounded-lg shadow p-4 mb-6">
                        <div className="flex gap-2">
                            {['all', 'pending', 'reviewed', 'accepted', 'rejected'].map(status => (
                                <button
                                    key={status}
                                    onClick={() => setFilter(status)}
                                    className={`px-4 py-2 rounded-lg transition ${
                                        filter === status
                                            ? 'bg-purple-600 text-white'
                                            : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
                                    }`}
                                >
                                    {status === 'all' ? '์ „์ฒด' : statusLabels[status]}
                                </button>
                            ))}
                        </div>
                    </div>

                    {/* ์ง€์›์„œ ๋ชฉ๋ก */}
                    <div className="space-y-4">
                        {filteredApplications.length === 0 ? (
                            <div className="bg-white rounded-lg shadow p-12 text-center text-gray-500">
                                ์ง€์›์„œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
                            </div>
                        ) : (
                            filteredApplications.map((app) => (
                                <div key={app.id} className="bg-white rounded-lg shadow hover:shadow-lg transition p-6">
                                    <div className="flex justify-between items-start mb-4">
                                        <div>
                                            <h3 className="text-xl font-bold text-gray-800">{app.name}</h3>
                                            <p className="text-sm text-gray-600">
                                                {app.email} | {app.phone}
                                            </p>
                                        </div>
                                        <div className="flex gap-2 items-center">
                                            <span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full font-semibold">
                                                {app.position}
                                            </span>
                                            <span className={`px-3 py-1 rounded-full text-sm font-semibold ${statusColors[app.status]}`}>
                                                {statusLabels[app.status]}
                                            </span>
                                        </div>
                                    </div>

                                    <div className="mb-4 space-y-2">
                                        <div>
                                            <p className="text-sm font-semibold text-gray-700">๊ฒฝ๋ ฅ ์‚ฌํ•ญ:</p>
                                            <p className="text-gray-600 text-sm whitespace-pre-wrap">{app.experience}</p>
                                        </div>
                                        <div>
                                            <p className="text-sm font-semibold text-gray-700">์ž๊ธฐ์†Œ๊ฐœ:</p>
                                            <p className="text-gray-600 text-sm whitespace-pre-wrap">{app.introduction}</p>
                                        </div>
                                        {app.portfolio_url && (
                                            <div>
                                                <p className="text-sm font-semibold text-gray-700">ํฌํŠธํด๋ฆฌ์˜ค:</p>
                                                <a
                                                    href={app.portfolio_url}
                                                    target="_blank"
                                                    rel="noopener noreferrer"
                                                    className="text-purple-600 hover:underline text-sm"
                                                >
                                                    {app.portfolio_url}
                                                </a>
                                            </div>
                                        )}
                                    </div>

                                    {/* ์ƒํƒœ ๋ณ€๊ฒฝ ๋ฒ„ํŠผ */}
                                    <div className="flex gap-2 pt-4 border-t">
                                        <button
                                            onClick={() => handleStatusChange(app.id, 'reviewed')}
                                            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
                                        >
                                            ๊ฒ€ํ†  ์™„๋ฃŒ
                                        </button>
                                        <button
                                            onClick={() => handleStatusChange(app.id, 'accepted')}
                                            className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm"
                                        >
                                            ํ•ฉ๊ฒฉ
                                        </button>
                                        <button
                                            onClick={() => handleStatusChange(app.id, 'rejected')}
                                            className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
                                        >
                                            ๋ถˆํ•ฉ๊ฒฉ
                                        </button>
                                    </div>

                                    <p className="text-xs text-gray-400 mt-3">
                                        ์ง€์›์ผ: {new Date(app.created_at).toLocaleDateString()}
                                    </p>
                                </div>
                            ))
                        )}
                    </div>
                </div>
            </div>
        </>
    );
}
```

## 4. Show ํŽ˜์ด์ง€์— ๊ด€๋ฆฌ ๋ฒ„ํŠผ ์ถ”๊ฐ€

### `resources/js/Pages/Recruitments/Show.jsx` ์‚ฌ์ด๋“œ๋ฐ”์— ์ถ”๊ฐ€

```jsx
{/* ์‚ฌ์ด๋“œ๋ฐ” ๋‚ด๋ถ€์— ์ถ”๊ฐ€ */}
<Link
    href={`/recruitments/${recruitment.id}/applications`}
    className="block w-full bg-purple-600 text-white text-center py-3 rounded-lg font-semibold hover:bg-purple-700"
>
    ๐Ÿ“‹ ์ง€์›์„œ ๊ด€๋ฆฌ
</Link>
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ์ง€์›์„œ ๊ด€๋ฆฌ ํŽ˜์ด์ง€์— ์ ‘์†ํ•˜์—ฌ ํ•„ํ„ฐ๋ง, ์ƒํƒœ ๋ณ€๊ฒฝ ๊ธฐ๋Šฅ์„ ํ…Œ์ŠคํŠธํ•˜์„ธ์š”. ์ด์ œ ์™„์„ฑ๋œ ํ’€์Šคํƒ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค!

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์ง€์›์„œ ๊ด€๋ฆฌ ๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ํ‘œ์‹œ๋˜๊ณ , ํฌ์ง€์…˜๋ณ„ ํ†ต๊ณ„, ์ƒํƒœ๋ณ„ ํ•„ํ„ฐ๋ง, ์ง€์›์„œ ์ƒ์„ธ๋ณด๊ธฐ, ์ƒํƒœ ๋ณ€๊ฒฝ ๋ฒ„ํŠผ์ด ๋ชจ๋‘ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. Inertia.js์˜ preserveScroll ์˜ต์…˜์œผ๋กœ ์ƒํƒœ ๋ณ€๊ฒฝ ์‹œ ์Šคํฌ๋กค ์œ„์น˜๊ฐ€ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค.

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

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

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

๋Œ“๊ธ€ (0)

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