๐๏ธ ๊ณต์ฐ ํฐ์ผ ๋ฆฌ์ ๋ง์ผํ๋ ์ด์ค - ์๋/๊ตฌ๋งค ๋งค์นญ ํ๋ซํผ
concert-ticket-resale-marketplace
๋ฌธ์ ์ค๋ช
๐๏ธ ๊ณต์ฐ ํฐ์ผ ๋ฆฌ์ ๋ง์ผํ๋ ์ด์ค
์ฝ์ํธ, ๋ฎค์ง์ปฌ, ์คํฌ์ธ ๊ฒฝ๊ธฐ ๋ฑ ๊ฐ์ข ๊ณต์ฐ ํฐ์ผ์ ์๋ํ๊ฑฐ๋ ๊ตฌ๋งคํ ์ ์๋ ์ค๊ณ ํฐ์ผ ๊ฑฐ๋ ํ๋ซํผ์ ๋ง๋ญ๋๋ค. Laravel 13.x + Inertia.js + React 19 ํ์คํ ๋ชจ๋ ธ๋ฆฌ์ค ์ํคํ ์ฒ๋ก ๊ตฌํํ๋ฉฐ, ์ค์ ํฐ์ผ ๊ฑฐ๋ ์๋น์ค์ ํต์ฌ ๊ธฐ๋ฅ์ ๊ฒฝํํ ์ ์์ต๋๋ค.
ํ์ ๊ธฐ๋ฅ
ํฐ์ผ ๋ฑ๋ก & ๊ด๋ฆฌ
- ๊ณต์ฐ๋ช , ๋ ์ง, ์ข์ ์ ๋ณด, ๊ฐ๊ฒฉ, ์ํ(ํ๋งค์ค/์์ฝ์ค/ํ๋งค์๋ฃ) ์ ๋ ฅ
- ํฐ์ผ ์ด๋ฏธ์ง ์ ๋ก๋ (์ข์ ์ฌ์ง, ์๋งค ํ์ธ์ ๋ฑ)
- ๋ฑ๋ก์ ๋ณธ์ธ๋ง ์์ /์ญ์ ๊ฐ๋ฅ
- ์นดํ ๊ณ ๋ฆฌ๋ณ ๋ถ๋ฅ (์ฝ์ํธ/๋ฎค์ง์ปฌ/์คํฌ์ธ /์ ์ ๋ฑ)
๊ฒ์ & ํํฐ๋ง
- ๊ณต์ฐ๋ช , ๋ ์ง, ์นดํ ๊ณ ๋ฆฌ, ๊ฐ๊ฒฉ๋๋ก ํํฐ๋ง
- ์ต์ ์/๊ฐ๊ฒฉ ๋ฎ์ ์/๋ง๊ฐ ์๋ฐ์ ์ ๋ ฌ
- ํ๋งค ์๋ฃ๋ ํฐ์ผ์ ๋ณ๋ ํ์
๊ตฌ๋งค ์์ฌ ํ์ & ์ํ ๊ด๋ฆฌ
- ๊ด์ฌ ์๋ ํฐ์ผ์ "๊ตฌ๋งค ํฌ๋ง" ๋ฒํผ ํด๋ฆญ
- ํ๋งค์๊ฐ ๊ตฌ๋งค์ ๋ชฉ๋ก ํ์ธ ํ "์์ฝ์ค" ์ํ๋ก ๋ณ๊ฒฝ ๊ฐ๋ฅ
- ๊ฑฐ๋ ์๋ฃ ์ "ํ๋งค์๋ฃ" ์ฒ๋ฆฌ
์ฌ์ฉ์ ์ธ์ฆ
- Laravel Breeze ๊ธฐ๋ณธ ์ธ์ฆ ์์คํ ํ์ฉ
- ๋ก๊ทธ์ธํ ์ฌ์ฉ์๋ง ํฐ์ผ ๋ฑ๋ก/๊ตฌ๋งค ํฌ๋ง ๊ฐ๋ฅ
๋ฐ์ํ UI
- ์นด๋ ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ํฐ์ผ ๋ชฉ๋ก ํ์
- ๋ชจ๋ฐ์ผ/ํ๋ธ๋ฆฟ/๋ฐ์คํฌํฑ ๋์
- Tailwind CSS๋ก ๊น๋ํ ๋์์ธ
๋ณด๋์ค ๊ธฐ๋ฅ
- ํฐ์ผ ์์ธ ํ์ด์ง์์ ํ๋งค์ ํ๋กํ ๋ณด๊ธฐ
- ์ฐํ๊ธฐ ๊ธฐ๋ฅ (์์๋ฆฌ์คํธ)
- ๊ฑฐ๋ ํ๊ธฐ ์์คํ (๋ณ์ & ๋๊ธ)
- ํ๋งค์์๊ฒ ๊ฐ๋จํ ์ชฝ์ง ๋ณด๋ด๊ธฐ (์ค์๊ฐ ์ฑํ ๋์ HTTP ๊ธฐ๋ฐ ๋ฉ์์ง)
- ๊ณต์ฐ D-day ์นด์ดํธ๋ค์ด ํ์
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x (์ปจํธ๋กค๋ฌ, Eloquent ORM, ๋ง์ด๊ทธ๋ ์ด์ , ์ธ์ฆ)
- ํ๋ก ํธ์๋: React 19 (ํจ์ํ ์ปดํฌ๋ํธ, Hooks)
- ํ์คํ ์ฐ๊ฒฐ: Inertia.js (์๋ฒ ์ฌ์ด๋ ๋ผ์ฐํ + React ๋ ๋๋ง)
- ์คํ์ผ๋ง: Tailwind CSS
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๊ฐ๋ฐ์ฉ) / MySQL (ํ๋ก๋์ ์ต์ )
- ํ์ผ ์ ๋ก๋: Laravel Storage (๋ก์ปฌ ๋๋ S3)
์ด ํ๋ก์ ํธ๋ฅผ ํตํด ์ค์ ์ด์ปค๋จธ์ค/๋ง์ผํ๋ ์ด์ค ์๋น์ค์ ํต์ฌ ๋ก์ง(์ํ ๋ฑ๋ก, ๊ฒ์ ํํฐ๋ง, ์ํ ๊ด๋ฆฌ, ์ฌ์ฉ์ ์ธ์ฆ)์ ์ตํ ์ ์์ผ๋ฉฐ, Inertia.js๋ฅผ ํ์ฉํ ๋ชจ๋ ํ์คํ ๊ฐ๋ฐ ๊ฒฝํ์ ์์ ์ ์์ต๋๋ค.
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ๊ตฌ์กฐ (๋ณ๋ API ๋ผ์ฐํธ ์์)
- โข Laravel Breeze (React + Inertia starter kit) ์ฌ์ฉ ๊ถ์ฅ
- โข Inertia::render()๋ก Laravel ์ปจํธ๋กค๋ฌ์์ React ์ปดํฌ๋ํธ์ ๋ฐ์ดํฐ ์ ๋ฌ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๊ฐํธํ ๋ก์ปฌ ๊ฐ๋ฐ)
- โข Tailwind CSS๋ก ์คํ์ผ๋ง
- โข ํ์ผ ์ ๋ก๋๋ Laravel Storage ๊ธฐ๋ณธ ๊ธฐ๋ฅ ํ์ฉ
- โข ์ค์๊ฐ ์ฑํ ๊ธ์ง (WebSocket ๋์ HTTP ๊ธฐ๋ฐ ์ชฝ์ง/๋๊ธ ์ฌ์ฉ)
- โข ๋ฐ์ํ ๋์์ธ ํ์ (๋ชจ๋ฐ์ผ ํผ์คํธ)
- โข Eloquent ORM์ผ๋ก DB ์ฟผ๋ฆฌ ์์ฑ
- โข ํผ ์ ํจ์ฑ ๊ฒ์ฌ๋ Laravel Request Validation ํ์ฉ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir concert-ticket-resale-marketplace && cd concert-ticket-resale-marketplace ์ด ํด๋์์ Laravel 13.x + Inertia.js + React 19 ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. **์๊ตฌ์ฌํญ:** 1. Laravel Breeze (React + Inertia stack)๋ฅผ ์ฌ์ฉํ์ฌ ํ๋ก์ ํธ ์ด๊ธฐํ 2. ๋ค์ ๋ช ๋ น์ด ์์๋๋ก ์คํ: - `composer create-project laravel/laravel .` (Laravel 13.x ์ค์น) - `composer require laravel/breeze --dev` - `php artisan breeze:install react` (React + Inertia ์คํ ์ ํ) - `npm install && npm run build` 3. `.env` ํ์ผ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ SQLite๋ก ์ค์ : ``` DB_CONNECTION=sqlite # DB_HOST, DB_PORT, DB_DATABASE ์ฃผ์ ์ฒ๋ฆฌ ``` 4. `database/database.sqlite` ๋น ํ์ผ ์์ฑ 5. `php artisan migrate` ์คํํ์ฌ ๊ธฐ๋ณธ ํ ์ด๋ธ ์์ฑ (users, password_resets ๋ฑ) 6. `php artisan serve`๋ก ์๋ฒ ์คํ ํ http://localhost:8000 ์ ์ ํ์ธ 7. Breeze ๊ธฐ๋ณธ ์ธ์ฆ ํ์ด์ง(๋ก๊ทธ์ธ/ํ์๊ฐ์ ) ์๋ ํ์ธ **์ถ๊ฐ ์ค์ :** - Tailwind CSS๊ฐ ์ด๋ฏธ ์ค์ ๋์ด ์๋์ง ํ์ธ (`tailwind.config.js` ์กด์ฌ) - `resources/js/app.jsx`์์ Inertia ์ด๊ธฐํ ํ์ธ - `resources/js/Pages/` ํด๋์ ๊ธฐ๋ณธ ํ์ด์ง ์ปดํฌ๋ํธ ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x + Breeze (React + Inertia) ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , ๊ธฐ๋ณธ ์ธ์ฆ ์์คํ (๋ก๊ทธ์ธ/ํ์๊ฐ์ )์ด ์๋ํ๋ ์ํ. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์๋ฃ. http://localhost:8000์์ Welcome ํ์ด์ง์ ๋ก๊ทธ์ธ ํ์ด์ง ํ์ธ ๊ฐ๋ฅ.
**ํฐ์ผ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ ๋ฐ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ**
`tickets` ํ
์ด๋ธ์ ๋ง๋ค์ด์ฃผ์ธ์.
**์๊ตฌ์ฌํญ:**
1. ๋ค์ ๋ช
๋ น์ด๋ก ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:
```bash
php artisan make:migration create_tickets_table
```
2. ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ์์ `tickets` ํ
์ด๋ธ ์คํค๋ง ์ ์:
- `id`: ๊ธฐ๋ณธ ํค
- `user_id`: ํ๋งค์ (users ํ
์ด๋ธ ์ธ๋ ํค)
- `title`: ๊ณต์ฐ๋ช
(๋ฌธ์์ด, ํ์)
- `category`: ์นดํ
๊ณ ๋ฆฌ (enum: 'concert', 'musical', 'sports', 'exhibition')
- `event_date`: ๊ณต์ฐ ๋ ์ง (date)
- `venue`: ๊ณต์ฐ ์ฅ์ (๋ฌธ์์ด)
- `seat_info`: ์ข์ ์ ๋ณด (๋ฌธ์์ด, nullable)
- `original_price`: ์๊ฐ (์ ์)
- `selling_price`: ํ๋งค ๊ฐ๊ฒฉ (์ ์)
- `status`: ์ํ (enum: 'available', 'reserved', 'sold', ๊ธฐ๋ณธ๊ฐ 'available')
- `image_path`: ํฐ์ผ ์ด๋ฏธ์ง ๊ฒฝ๋ก (๋ฌธ์์ด, nullable)
- `description`: ์์ธ ์ค๋ช
(ํ
์คํธ, nullable)
- `timestamps`: created_at, updated_at
3. ์ธ๋ ํค ์ ์ฝ ์กฐ๊ฑด:
```php
$table->foreignId('user_id')->constrained()->onDelete('cascade');
```
4. ๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
5. Ticket ๋ชจ๋ธ ์์ฑ:
```bash
php artisan make:model Ticket
```
6. `app/Models/Ticket.php`์์:
- `$fillable` ์์ฑ์ ๋ชจ๋ ์ปฌ๋ผ ์ถ๊ฐ (id, timestamps ์ ์ธ)
- User ๊ด๊ณ ์ ์: `belongsTo(User::class)`
- `$casts` ์์ฑ์์ `event_date`๋ฅผ `date`๋ก ์บ์คํ
7. `app/Models/User.php`์ Ticket ๊ด๊ณ ์ถ๊ฐ:
```php
public function tickets()
{
return $this->hasMany(Ticket::class);
}
```
๊ธฐ๋ ๊ฒฐ๊ณผ: `tickets` ํ ์ด๋ธ์ด ์์ฑ๋๊ณ , Ticket ๋ชจ๋ธ๊ณผ User ๋ชจ๋ธ ๊ฐ ๊ด๊ณ๊ฐ ์ ์๋จ. ๋ง์ด๊ทธ๋ ์ด์ ์คํ ํ `database/database.sqlite`์ tickets ํ ์ด๋ธ ํ์ธ ๊ฐ๋ฅ.
**ํฐ์ผ ๋ชฉ๋ก ํ์ด์ง ๊ตฌํ (๋ฉ์ธ ํ๋ฉด)**
Inertia.js๋ฅผ ํ์ฉํ์ฌ ํฐ์ผ ๋ชฉ๋ก์ ๋ณด์ฌ์ฃผ๋ ๋ฉ์ธ ํ์ด์ง๋ฅผ ๋ง๋ค์ด์ฃผ์ธ์.
**๋ฐฑ์๋ (Laravel):**
1. `TicketController` ์์ฑ:
```bash
php artisan make:controller TicketController
```
2. `app/Http/Controllers/TicketController.php`์์ `index` ๋ฉ์๋ ์์ฑ:
```php
public function index(Request $request)
{
$query = Ticket::with('user');
// ์นดํ
๊ณ ๋ฆฌ ํํฐ๋ง
if ($request->category) {
$query->where('category', $request->category);
}
// ์ํ ํํฐ๋ง (๊ธฐ๋ณธ: ํ๋งค์ค๋ง)
$status = $request->status ?? 'available';
if ($status !== 'all') {
$query->where('status', $status);
}
// ์ ๋ ฌ (๊ธฐ๋ณธ: ์ต์ ์)
$sort = $request->sort ?? 'latest';
if ($sort === 'price_low') {
$query->orderBy('selling_price', 'asc');
} elseif ($sort === 'price_high') {
$query->orderBy('selling_price', 'desc');
} elseif ($sort === 'event_date') {
$query->orderBy('event_date', 'asc');
} else {
$query->orderBy('created_at', 'desc');
}
$tickets = $query->paginate(12);
return Inertia::render('Tickets/Index', [
'tickets' => $tickets,
'filters' => $request->only(['category', 'status', 'sort'])
]);
}
```
3. `routes/web.php`์ ๋ผ์ฐํธ ์ถ๊ฐ:
```php
use App\Http\Controllers\TicketController;
Route::get('/', [TicketController::class, 'index'])->name('tickets.index');
```
**ํ๋ก ํธ์๋ (React):**
4. `resources/js/Pages/Tickets/Index.jsx` ํ์ผ ์์ฑ:
- Props๋ก `tickets`, `filters` ๋ฐ๊ธฐ
- ์๋จ์ ํํฐ UI (์นดํ
๊ณ ๋ฆฌ ๋ฒํผ, ์ ๋ ฌ ๋๋กญ๋ค์ด)
- ํฐ์ผ ์นด๋ ๊ทธ๋ฆฌ๋ ๋ ์ด์์ (3์ด, ๋ชจ๋ฐ์ผ 1์ด)
- ๊ฐ ์นด๋์ ํ์ํ ์ ๋ณด:
- ํฐ์ผ ์ด๋ฏธ์ง (์์ผ๋ฉด ๊ธฐ๋ณธ ์ด๋ฏธ์ง)
- ๊ณต์ฐ๋ช
(title)
- ๊ณต์ฐ ๋ ์ง (event_date)
- ์ฅ์ (venue)
- ๊ฐ๊ฒฉ (selling_price, ์๊ฐ ์ทจ์์ )
- ์ํ ๋ฐฐ์ง (ํ๋งค์ค/์์ฝ์ค/ํ๋งค์๋ฃ)
- Inertia Link๋ก ์์ธ ํ์ด์ง ์ฐ๊ฒฐ (์์ง ๋ฏธ๊ตฌํ)
- ํ์ด์ง๋ค์ด์
(Inertia ๊ธฐ๋ณธ ์ปดํฌ๋ํธ ํ์ฉ)
5. ํํฐ ๋ณ๊ฒฝ ์ Inertia.get()์ผ๋ก ์ฟผ๋ฆฌ์คํธ๋ง ์
๋ฐ์ดํธ:
```jsx
const handleFilterChange = (key, value) => {
Inertia.get(route('tickets.index'),
{ ...filters, [key]: value },
{ preserveState: true }
);
};
```
6. Tailwind CSS๋ก ์คํ์ผ๋ง:
- ์นด๋ hover ํจ๊ณผ
- ์ํ๋ณ ๋ฐฐ์ง ์์ (available: green, reserved: yellow, sold: gray)
- ๋ฐ์ํ ๊ทธ๋ฆฌ๋ (grid-cols-1 md:grid-cols-2 lg:grid-cols-3)
**ํ
์คํธ ๋ฐ์ดํฐ:**
7. Tinker๋ก ์ํ ํฐ์ผ 3~5๊ฐ ์์ฑ:
```bash
php artisan tinker
```
```php
$user = User::first();
Ticket::create([
'user_id' => $user->id,
'title' => '์์ด์ ์ฝ์ํธ 2025',
'category' => 'concert',
'event_date' => '2025-06-15',
'venue' => '์ฌ๋ฆผํฝ๊ณต์ ์ฒด์กฐ๊ฒฝ๊ธฐ์ฅ',
'seat_info' => 'A๊ตฌ์ญ 3์ด 15๋ฒ',
'original_price' => 150000,
'selling_price' => 120000,
'status' => 'available'
]);
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ํฐ์ผ ๋ชฉ๋ก ํ์ด์ง๊ฐ ์๋ํ๋ฉฐ, ์นดํ ๊ณ ๋ฆฌ/์ํ/์ ๋ ฌ ํํฐ๊ฐ ๋์ํจ. ์นด๋ ๊ทธ๋ฆฌ๋๋ก ํฐ์ผ์ด ํ์๋๊ณ , ๊ฐ ์นด๋์ ๊ณต์ฐ ์ ๋ณด์ ๊ฐ๊ฒฉ์ด ๋ณด์. ํ์ด์ง๋ค์ด์ ์๋ ํ์ธ.
**ํฐ์ผ ๋ฑ๋ก ํผ ๊ตฌํ (Create)**
๋ก๊ทธ์ธํ ์ฌ์ฉ์๊ฐ ์ ํฐ์ผ์ ๋ฑ๋กํ ์ ์๋ ํผ์ ๋ง๋ค์ด์ฃผ์ธ์.
**๋ฐฑ์๋:**
1. `TicketController`์ `create`, `store` ๋ฉ์๋ ์ถ๊ฐ:
```php
public function create()
{
return Inertia::render('Tickets/Create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'category' => 'required|in:concert,musical,sports,exhibition',
'event_date' => 'required|date|after:today',
'venue' => 'required|string|max:255',
'seat_info' => 'nullable|string|max:255',
'original_price' => 'required|integer|min:0',
'selling_price' => 'required|integer|min:0',
'description' => 'nullable|string',
'image' => 'nullable|image|max:2048' // 2MB
]);
$ticket = new Ticket($validated);
$ticket->user_id = auth()->id();
if ($request->hasFile('image')) {
$path = $request->file('image')->store('tickets', 'public');
$ticket->image_path = $path;
}
$ticket->save();
return redirect()->route('tickets.index')
->with('success', 'ํฐ์ผ์ด ๋ฑ๋ก๋์์ต๋๋ค.');
}
```
2. `routes/web.php`์ ๋ผ์ฐํธ ์ถ๊ฐ (์ธ์ฆ ํ์):
```php
Route::middleware('auth')->group(function () {
Route::get('/tickets/create', [TicketController::class, 'create'])->name('tickets.create');
Route::post('/tickets', [TicketController::class, 'store'])->name('tickets.store');
});
```
3. ์คํ ๋ฆฌ์ง ๋งํฌ ์์ฑ:
```bash
php artisan storage:link
```
**ํ๋ก ํธ์๋:**
4. `resources/js/Pages/Tickets/Create.jsx` ์์ฑ:
- Inertia `useForm` ํ
์ฌ์ฉ:
```jsx
import { useForm } from '@inertiajs/react';
const { data, setData, post, processing, errors } = useForm({
title: '',
category: 'concert',
event_date: '',
venue: '',
seat_info: '',
original_price: '',
selling_price: '',
description: '',
image: null
});
```
- ํผ ํ๋:
- ๊ณต์ฐ๋ช
(ํ
์คํธ ์
๋ ฅ)
- ์นดํ
๊ณ ๋ฆฌ (๋ผ๋์ค ๋ฒํผ ๋๋ ์
๋ ํธ)
- ๊ณต์ฐ ๋ ์ง (date input)
- ์ฅ์ (ํ
์คํธ ์
๋ ฅ)
- ์ข์ ์ ๋ณด (ํ
์คํธ ์
๋ ฅ, ์ ํ)
- ์๊ฐ (์ซ์ ์
๋ ฅ)
- ํ๋งค ๊ฐ๊ฒฉ (์ซ์ ์
๋ ฅ)
- ์์ธ ์ค๋ช
(textarea)
- ํฐ์ผ ์ด๋ฏธ์ง (ํ์ผ ์
๋ก๋, ๋ฏธ๋ฆฌ๋ณด๊ธฐ)
- ์ด๋ฏธ์ง ๋ฏธ๋ฆฌ๋ณด๊ธฐ:
```jsx
const [preview, setPreview] = useState(null);
const handleImageChange = (e) => {
const file = e.target.files[0];
setData('image', file);
setPreview(URL.createObjectURL(file));
};
```
- ํผ ์ ์ถ:
```jsx
const handleSubmit = (e) => {
e.preventDefault();
post(route('tickets.store'));
};
```
- ์ ํจ์ฑ ๊ฒ์ฌ ์๋ฌ ๋ฉ์์ง ํ์ (`errors` ๊ฐ์ฒด ํ์ฉ)
- Tailwind๋ก ๊น๋ํ ํผ ๋ ์ด์์
5. ๋ค๋น๊ฒ์ด์
์ "ํฐ์ผ ๋ฑ๋ก" ๋ฒํผ ์ถ๊ฐ (๋ก๊ทธ์ธ ์ํ์์๋ง ๋ณด์):
- `resources/js/Layouts/AuthenticatedLayout.jsx` ๋๋ ํค๋ ์ปดํฌ๋ํธ ์์
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ก๊ทธ์ธํ ์ฌ์ฉ์๊ฐ ํฐ์ผ ๋ฑ๋ก ํผ์ ์ ๊ทผ ๊ฐ๋ฅ. ๋ชจ๋ ํ๋ ์ ๋ ฅ ํ ์ ์ถ ์ ํฐ์ผ์ด DB์ ์ ์ฅ๋๊ณ , ์ด๋ฏธ์ง ์ ๋ก๋ ์ `storage/app/public/tickets/`์ ์ ์ฅ๋จ. ์ ํจ์ฑ ๊ฒ์ฌ ์๋ฌ ์ ์๋ฌ ๋ฉ์์ง ํ์. ๋ฑ๋ก ํ ๋ชฉ๋ก ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ.
**ํฐ์ผ ์์ธ ํ์ด์ง ๋ฐ ๊ตฌ๋งค ํฌ๋ง ๊ธฐ๋ฅ ๊ตฌํ**
๊ฐ๋ณ ํฐ์ผ์ ์์ธ ์ ๋ณด๋ฅผ ๋ณด์ฌ์ฃผ๊ณ , ๊ด์ฌ ์๋ ์ฌ์ฉ์๊ฐ "๊ตฌ๋งค ํฌ๋ง" ์์ฌ๋ฅผ ํ์ํ ์ ์๋ ๊ธฐ๋ฅ์ ๋ง๋ค์ด์ฃผ์ธ์.
**๋ฐ์ดํฐ๋ฒ ์ด์ค:**
1. `ticket_interests` ํ
์ด๋ธ ์์ฑ (๊ตฌ๋งค ํฌ๋ง์ ๊ธฐ๋ก):
```bash
php artisan make:migration create_ticket_interests_table
```
- ์ปฌ๋ผ: `id`, `ticket_id` (์ธ๋ ํค), `user_id` (์ธ๋ ํค), `message` (์ ํ์ ๋ฉ์์ง), `timestamps`
- ์ ๋ํฌ ์ ์ฝ: ํ ์ฌ์ฉ์๋ ๊ฐ์ ํฐ์ผ์ ํ ๋ฒ๋ง ๊ตฌ๋งค ํฌ๋ง ๊ฐ๋ฅ
```php
$table->unique(['ticket_id', 'user_id']);
```
2. TicketInterest ๋ชจ๋ธ ์์ฑ:
```bash
php artisan make:model TicketInterest
```
- Ticket, User ๊ด๊ณ ์ ์
3. Ticket ๋ชจ๋ธ์ interests ๊ด๊ณ ์ถ๊ฐ:
```php
public function interests()
{
return $this->hasMany(TicketInterest::class);
}
```
**๋ฐฑ์๋:**
4. `TicketController`์ `show` ๋ฉ์๋ ์ถ๊ฐ:
```php
public function show(Ticket $ticket)
{
$ticket->load(['user', 'interests.user']);
// ํ์ฌ ์ฌ์ฉ์๊ฐ ๊ตฌ๋งค ํฌ๋ง ํ์ํ๋์ง ํ์ธ
$hasInterest = auth()->check()
? $ticket->interests()->where('user_id', auth()->id())->exists()
: false;
return Inertia::render('Tickets/Show', [
'ticket' => $ticket,
'hasInterest' => $hasInterest,
'isOwner' => auth()->id() === $ticket->user_id
]);
}
```
5. ๊ตฌ๋งค ํฌ๋ง ๋ฑ๋ก ๋ฉ์๋:
```php
public function addInterest(Request $request, Ticket $ticket)
{
if ($ticket->user_id === auth()->id()) {
return back()->withErrors(['error' => '๋ณธ์ธ ํฐ์ผ์๋ ๊ตฌ๋งค ํฌ๋ง์ ํ ์ ์์ต๋๋ค.']);
}
if ($ticket->status !== 'available') {
return back()->withErrors(['error' => 'ํ๋งค์ค์ธ ํฐ์ผ๋ง ๊ตฌ๋งค ํฌ๋ง ๊ฐ๋ฅํฉ๋๋ค.']);
}
$validated = $request->validate([
'message' => 'nullable|string|max:500'
]);
TicketInterest::updateOrCreate(
['ticket_id' => $ticket->id, 'user_id' => auth()->id()],
['message' => $validated['message'] ?? null]
);
return back()->with('success', '๊ตฌ๋งค ํฌ๋ง์ด ๋ฑ๋ก๋์์ต๋๋ค.');
}
```
6. ๋ผ์ฐํธ ์ถ๊ฐ:
```php
Route::get('/tickets/{ticket}', [TicketController::class, 'show'])->name('tickets.show');
Route::post('/tickets/{ticket}/interest', [TicketController::class, 'addInterest'])
->middleware('auth')
->name('tickets.interest');
```
**ํ๋ก ํธ์๋:**
7. `resources/js/Pages/Tickets/Show.jsx` ์์ฑ:
- ํฐ์ผ ์ ๋ณด ํ์:
- ํฐ ์ด๋ฏธ์ง (์์ผ๋ฉด ๊ธฐ๋ณธ ์ด๋ฏธ์ง)
- ๊ณต์ฐ๋ช
, ์นดํ
๊ณ ๋ฆฌ, ๋ ์ง, ์ฅ์, ์ข์
- ๊ฐ๊ฒฉ ์ ๋ณด (์๊ฐ vs ํ๋งค๊ฐ)
- ์์ธ ์ค๋ช
- ํ๋งค์ ์ ๋ณด (์ด๋ฆ, ๊ฐ์
์ผ)
- ์ํ ๋ฐฐ์ง
- ๊ตฌ๋งค ํฌ๋ง ๋ฒํผ:
- ๋ก๊ทธ์ธ ์ ํ์ผ๋ฉด "๋ก๊ทธ์ธ ํ ์ด์ฉ ๊ฐ๋ฅ" ์๋ด
- ๋ณธ์ธ ํฐ์ผ์ด๋ฉด ๋ฒํผ ์จ๊น
- ์ด๋ฏธ ๊ตฌ๋งค ํฌ๋ง ํ์ผ๋ฉด "๊ตฌ๋งค ํฌ๋ง ์ทจ์" ๋ฒํผ
- ํ๋งค ์๋ฃ ์ํ๋ฉด ๋ฒํผ ๋นํ์ฑํ
- ๊ฐ๋จํ ๋ฉ์์ง ์
๋ ฅ๋ (์ ํ)
- ํ๋งค์์ธ ๊ฒฝ์ฐ:
- ๊ตฌ๋งค ํฌ๋ง์ ๋ชฉ๋ก ํ์ (์ด๋ฆ, ๋ฉ์์ง, ๋ฑ๋ก ์๊ฐ)
- "์์ฝ์ค์ผ๋ก ๋ณ๊ฒฝ" ๋ฒํผ
8. ๋ชฉ๋ก ํ์ด์ง ์นด๋์์ ์์ธ ํ์ด์ง ๋งํฌ ์ฐ๊ฒฐ:
```jsx
<Link href={route('tickets.show', ticket.id)}>...</Link>
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ํฐ์ผ ์์ธ ํ์ด์ง์์ ๋ชจ๋ ์ ๋ณด๊ฐ ํ์๋จ. ๋ก๊ทธ์ธํ ์ฌ์ฉ์๊ฐ ๊ตฌ๋งค ํฌ๋ง ๋ฒํผ ํด๋ฆญ ์ DB์ ๊ธฐ๋ก๋๊ณ , ํ๋งค์๋ ๊ตฌ๋งค ํฌ๋ง์ ๋ชฉ๋ก์ ํ์ธ ๊ฐ๋ฅ. ๋ณธ์ธ ํฐ์ผ์ด๋ ์ด๋ฏธ ๊ตฌ๋งค ํฌ๋งํ ํฐ์ผ์๋ ์ ์ ํ UI ํ์.
**ํฐ์ผ ์์ /์ญ์ ๋ฐ ์ํ ๊ด๋ฆฌ ๊ธฐ๋ฅ ๊ตฌํ**
ํ๋งค์๊ฐ ์์ ์ ํฐ์ผ์ ์์ ํ๊ฑฐ๋ ์ญ์ ํ๊ณ , ์ํ๋ฅผ ๋ณ๊ฒฝํ ์ ์๋ ๊ธฐ๋ฅ์ ์์ฑํด์ฃผ์ธ์.
**๋ฐฑ์๋:**
1. `TicketController`์ `edit`, `update`, `destroy`, `updateStatus` ๋ฉ์๋ ์ถ๊ฐ:
```php
public function edit(Ticket $ticket)
{
$this->authorize('update', $ticket); // Policy ์ฌ์ฉ
return Inertia::render('Tickets/Edit', ['ticket' => $ticket]);
}
public function update(Request $request, Ticket $ticket)
{
$this->authorize('update', $ticket);
$validated = $request->validate([
'title' => 'required|string|max:255',
'category' => 'required|in:concert,musical,sports,exhibition',
'event_date' => 'required|date',
'venue' => 'required|string|max:255',
'seat_info' => 'nullable|string|max:255',
'original_price' => 'required|integer|min:0',
'selling_price' => 'required|integer|min:0',
'description' => 'nullable|string',
'image' => 'nullable|image|max:2048'
]);
$ticket->fill($validated);
if ($request->hasFile('image')) {
// ๊ธฐ์กด ์ด๋ฏธ์ง ์ญ์
if ($ticket->image_path) {
Storage::disk('public')->delete($ticket->image_path);
}
$path = $request->file('image')->store('tickets', 'public');
$ticket->image_path = $path;
}
$ticket->save();
return redirect()->route('tickets.show', $ticket)
->with('success', 'ํฐ์ผ ์ ๋ณด๊ฐ ์์ ๋์์ต๋๋ค.');
}
public function destroy(Ticket $ticket)
{
$this->authorize('delete', $ticket);
if ($ticket->image_path) {
Storage::disk('public')->delete($ticket->image_path);
}
$ticket->delete();
return redirect()->route('tickets.index')
->with('success', 'ํฐ์ผ์ด ์ญ์ ๋์์ต๋๋ค.');
}
public function updateStatus(Request $request, Ticket $ticket)
{
$this->authorize('update', $ticket);
$validated = $request->validate([
'status' => 'required|in:available,reserved,sold'
]);
$ticket->update($validated);
return back()->with('success', 'ํฐ์ผ ์ํ๊ฐ ๋ณ๊ฒฝ๋์์ต๋๋ค.');
}
```
2. Policy ์์ฑ:
```bash
php artisan make:policy TicketPolicy --model=Ticket
```
- `app/Policies/TicketPolicy.php`์์ `update`, `delete` ๋ฉ์๋ ์ ์:
```php
public function update(User $user, Ticket $ticket)
{
return $user->id === $ticket->user_id;
}
public function delete(User $user, Ticket $ticket)
{
return $user->id === $ticket->user_id;
}
```
3. ๋ผ์ฐํธ ์ถ๊ฐ:
```php
Route::middleware('auth')->group(function () {
Route::get('/tickets/{ticket}/edit', [TicketController::class, 'edit'])->name('tickets.edit');
Route::put('/tickets/{ticket}', [TicketController::class, 'update'])->name('tickets.update');
Route::delete('/tickets/{ticket}', [TicketController::class, 'destroy'])->name('tickets.destroy');
Route::patch('/tickets/{ticket}/status', [TicketController::class, 'updateStatus'])->name('tickets.updateStatus');
});
```
**ํ๋ก ํธ์๋:**
4. `resources/js/Pages/Tickets/Edit.jsx` ์์ฑ:
- Create.jsx์ ์ ์ฌํ์ง๋ง ๊ธฐ์กด ๋ฐ์ดํฐ๋ก ํผ ์ด๊ธฐํ
- `useForm` ํ
์ `ticket` prop ๋ฐ์ดํฐ ์ ๋ฌ
- `put` ๋ฉ์๋๋ก ์ ์ถ:
```jsx
put(route('tickets.update', ticket.id))
```
5. ์์ธ ํ์ด์ง(Show.jsx)์์ ํ๋งค์ ์ ์ฉ ๋ฒํผ ์ถ๊ฐ:
- "์์ " ๋ฒํผ (Edit ํ์ด์ง๋ก ์ด๋)
- "์ญ์ " ๋ฒํผ (ํ์ธ ํ DELETE ์์ฒญ)
- ์ํ ๋ณ๊ฒฝ ๋๋กญ๋ค์ด (available/reserved/sold)
- ์ญ์ ํ์ธ ๋ชจ๋ฌ:
```jsx
const handleDelete = () => {
if (confirm('์ ๋ง ์ญ์ ํ์๊ฒ ์ต๋๊น?')) {
Inertia.delete(route('tickets.destroy', ticket.id));
}
};
```
6. Flash ๋ฉ์์ง ํ์:
- `resources/js/Layouts/AuthenticatedLayout.jsx`์์ ์ฑ๊ณต/์๋ฌ ๋ฉ์์ง ํ ์คํธ ์ถ๊ฐ
- Inertia `usePage` ํ
์ผ๋ก `flash` ๋ฐ์ดํฐ ์ ๊ทผ:
```jsx
const { flash } = usePage().props;
```
**์ต์ข
ํ
์คํธ:**
7. ๋ค์ํ ์๋๋ฆฌ์ค ํ
์คํธ:
- ํฐ์ผ ๋ฑ๋ก โ ์์ โ ์ํ ๋ณ๊ฒฝ โ ์ญ์
- ๋ค๋ฅธ ์ฌ์ฉ์๊ฐ ๊ตฌ๋งค ํฌ๋ง โ ํ๋งค์๊ฐ ์์ฝ์ค์ผ๋ก ๋ณ๊ฒฝ
- ํํฐ๋ง/์ ๋ ฌ/ํ์ด์ง๋ค์ด์
๋์ ํ์ธ
- ์ด๋ฏธ์ง ์
๋ก๋/๊ต์ฒด/์ญ์ ํ์ธ
- ๊ถํ ์๋ ์ฌ์ฉ์๊ฐ ์์ /์ญ์ ์๋ ์ ์๋ฌ ์ฒ๋ฆฌ
๊ธฐ๋ ๊ฒฐ๊ณผ: ํ๋งค์๊ฐ ์์ ์ ํฐ์ผ์ ์์ /์ญ์ /์ํ ๋ณ๊ฒฝ ๊ฐ๋ฅ. Policy๋ก ๊ถํ ๊ฒ์ฆ๋๋ฉฐ, ๋ค๋ฅธ ์ฌ์ฉ์๋ ์ ๊ทผ ๋ถ๊ฐ. ์ด๋ฏธ์ง ๊ต์ฒด ์ ๊ธฐ์กด ํ์ผ ์ญ์ ๋จ. Flash ๋ฉ์์ง๋ก ์ฌ์ฉ์ ํผ๋๋ฐฑ ์ ๊ณต. ์ ์ฒด CRUD ๊ธฐ๋ฅ๊ณผ ํํฐ๋ง/์ํ ๊ด๋ฆฌ๊ฐ ์์ ํ ์๋ํ๋ ํฐ์ผ ๋ฆฌ์ ๋ง์ผํ๋ ์ด์ค ์์ฑ.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!