๐ญ ๋ผ์ด๋ธ ์ฐ๊ทน ํฐ์ผ ํตํฉ ์๋งค ํ๋ซํผ - ๋ค์ค ๊ทน์ฅ & ๊ณต์ฐ ์ค์๊ฐ ์ข์ ์์ฝ
theater-multi-venue-booking-platform
๋ฌธ์ ์ค๋ช
ํ๋ก์ ํธ ๊ฐ์
์ฌ๋ฌ ๊ทน์ฅ๊ณผ ๊ณต์ฐ์ ํตํฉ ๊ด๋ฆฌํ๋ ์ ๋ฌธ ํฐ์ผ ์๋งค ํ๋ซํผ์ ๊ตฌ์ถํฉ๋๋ค. Laravel 13.x + Blade๋ก ๊ตฌํํ๋ฉฐ, ์ค๋ฌด ์์ค์ ๋ณต์กํ ๋น์ฆ๋์ค ๋ก์ง๊ณผ ์ต์ ํ ๊ธฐ๋ฒ์ ์ ์ฉํฉ๋๋ค.
ํต์ฌ ๊ธฐ๋ฅ
1. ๋ค์ค ๊ทน์ฅ/๊ณต์ฐ ๊ด๋ฆฌ
- ๊ทน์ฅ(Venue) ๋ฑ๋ก: ์ด๋ฆ, ์ฃผ์, ์ข์ ๋ฐฐ์น๋ ์ด๋ฏธ์ง
- ๊ณต์ฐ(Show) ๋ฑ๋ก: ์ ๋ชฉ, ์ฅ๋ฅด, ๋ฌ๋ํ์, ์ฐ๋ น์ ํ, ํฌ์คํฐ
- ํ์ฐจ(Showtime) ๊ด๋ฆฌ: ๋ ์ง, ์๊ฐ, ๊ทน์ฅ ์ฐ๊ฒฐ, ์ข์ ๊ตฌ์ฑ
- ๊ทน์ฅ๋ณ ์ข์ ๊ตฌ์ญ(Section): VIP์, R์, S์, A์ ๋ฑ ๋ค๋จ๊ณ ๊ฐ๊ฒฉ
2. ์ค์๊ฐ ์ข์ ์์ฝ ์์คํ
- ์ข์ ๋ฐฐ์น๋ ์๊ฐํ (Blade + Tailwind CSS ๊ทธ๋ฆฌ๋)
- ์ค์๊ฐ ์ข์ ์ํ: ์์ฝ ๊ฐ๋ฅ(available), ์ ํ ์ค(pending), ์์ฝ ์๋ฃ(booked)
- ๋์ ์ ์์ ์ถฉ๋ ๋ฐฉ์ง: DB ํธ๋์ญ์ + ๋๊ด์ ์ ๊ธ(Optimistic Locking)
- 5๋ถ ํ์ด๋จธ: ์ ํ ํ ๊ฒฐ์ ๋ฏธ์๋ฃ ์ ์๋ ํด์ (Laravel Queue + Job)
3. ์์ฝ ์ํฌํ๋ก์ฐ
- ๊ณต์ฐ ๊ฒ์/ํํฐ๋ง: ์ฅ๋ฅด, ๋ ์ง, ๊ทน์ฅ, ๊ฐ๊ฒฉ๋
- ํ์ฐจ ์ ํ โ ์ข์ ์ ํ โ ์์ฝ์ ์ ๋ณด ์ ๋ ฅ โ ๊ฒฐ์
- ์์ฝ ํ์ธ ์ด๋ฉ์ผ ๋ฐ์ก (Laravel Mail + Markdown ํ ํ๋ฆฟ)
- ์์ฝ ๋ด์ญ ์กฐํ: ์์ฝ๋ฒํธ๋ก ํฐ์ผ ํ์ธ & QR ์ฝ๋ ์์ฑ
4. ๊ด๋ฆฌ์ ๊ธฐ๋ฅ (๋ณ๋ ์ด๋๋ฏผ ๋ผ์ฐํธ)
- ๊ทน์ฅ/๊ณต์ฐ/ํ์ฐจ CRUD
- ์์ฝ ํํฉ ๋์๋ณด๋: ํ์ฐจ๋ณ ๋งค์ถ, ์ข์ ์ ์ ์จ, ์ทจ์์จ
- ์์ฝ ์ทจ์/ํ๋ถ ์ฒ๋ฆฌ
- ํต๊ณ: ์ผ๋ณ/์ฃผ๋ณ/์๋ณ ๋งค์ถ ์ฐจํธ (Chart.js)
5. ๊ณ ๊ธ ๊ธฐ๋ฅ
- ์บ์ฑ: Redis๋ก ๊ณต์ฐ ๋ชฉ๋ก, ์ข์ ์ํ ์บ์ฑ (์ฑ๋ฅ ์ต์ ํ)
- ํ/์ก: ์์ฝ ํ์์์ ์ฒ๋ฆฌ, ์ด๋ฉ์ผ ๋ฐ์ก์ ๋ฐฑ๊ทธ๋ผ์ด๋ ์์ ์ผ๋ก
- ์์ ๋ค์ด๋ก๋: ์์ฝ ๋ด์ญ Export (Laravel Excel)
- ๊ฒ์ ์ต์ ํ: DB ์ธ๋ฑ์ค ์ค๊ณ, Eager Loading์ผ๋ก N+1 ์ฟผ๋ฆฌ ๋ฐฉ์ง
- ๋ค๋๋ค ๊ด๊ณ: ๊ณต์ฐ-๋ฐฐ์ฐ(Actor) ๊ด๊ณ, ๊ณต์ฐ-์ฅ๋ฅด(Genre) ํ๊ทธ
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x + Blade Templates
- ํ๋ก ํธ์๋: Tailwind CSS 4.x, Alpine.js (๊ฒฝ๋ ์ธํฐ๋์ )
- DB: MySQL/PostgreSQL (๋ณต์กํ ๊ด๊ณํ ๋ฐ์ดํฐ)
- ์บ์ฑ: Redis
- ํ: Laravel Queue (database driver)
- ์ด๋ฉ์ผ: Laravel Mail + Mailtrap(๊ฐ๋ฐ), SMTP(ํ๋ก๋์ )
- ์ฐจํธ: Chart.js
- QR ์ฝ๋: SimpleSoftwareIO/simple-qrcode
- ์์ : Maatwebsite/Laravel-Excel
๋ณด๋์ค ๊ธฐ๋ฅ
- ๊ฒฐ์ ์ฐ๋ (Stripe/Toss Payments API ์๋ฎฌ๋ ์ด์ )
- ํ์์ ํ ์ธ: ๋ฑ๊ธ๋ณ ํ ์ธ์จ (์ผ๋ฐ/VIP/VVIP)
- ๊ณต์ฐ ๋ฆฌ๋ทฐ ์์คํ : ๊ด๋ ํ ๋ณ์ & ํ๊ธฐ ์์ฑ
- ์๋ฆผ ์์คํ : ๊ณต์ฐ ์คํ ์๋ฆผ, ์ข์ ํด์ ์๋ฆผ (Laravel Notification)
- ์ข์ ์ถ์ฒ ์๊ณ ๋ฆฌ์ฆ: ๊ฐ๊ฒฉ๋/์์น ๊ธฐ๋ฐ ์ต์ ์ข์ ์ ์
- ๋ชจ๋ฐ์ผ ๋ฐ์ํ ๋์์ธ
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x ํ์ (composer create-project laravel/laravel ๋๋ ์ต์ stable ๋ฒ์ )
- โข Blade ํ ํ๋ฆฟ ์์ง ์ฌ์ฉ (React/Vue ๊ธ์ง)
- โข Tailwind CSS 4.x (CDN ๋๋ npm ์ค์น)
- โข Alpine.js๋ก ๊ฒฝ๋ ํ๋ก ํธ์๋ ์ธํฐ๋์ (์ข์ ์ ํ ํ ๊ธ, ํ์ด๋จธ ๋ฑ)
- โข MySQL ๋๋ PostgreSQL (SQLite๋ ๋์์ฑ ์ ์ด ํ๊ณ๋ก ๋น์ถ์ฒ)
- โข Redis ์ค์น ๋ฐ ์บ์ ๋๋ผ์ด๋ฒ ์ค์ ํ์
- โข Laravel Queue ์ค์ (database driver ์ฌ์ฉ, supervisor๋ก ์์ปค ์คํ)
- โข ๋ง์ด๊ทธ๋ ์ด์ ์ผ๋ก ํ ์ด๋ธ ์ค๊ณ: venues, shows, showtimes, seats, bookings, users ๋ฑ
- โข Eloquent ๊ด๊ณ: hasMany, belongsTo, belongsToMany ํ์ฉ
- โข ํธ๋์ญ์ & ๋๊ด์ ์ ๊ธ์ผ๋ก ์ข์ ์ค๋ณต ์์ฝ ๋ฐฉ์ง
- โข Policy/Gate๋ก ๊ด๋ฆฌ์ ๊ถํ ์ ์ด
- โข Form Request Validation์ผ๋ก ์ ๋ ฅ ๊ฒ์ฆ
- โข Seeders/Factories๋ก ๋๋ฏธ ๋ฐ์ดํฐ ์์ฑ (ํ ์คํธ์ฉ)
- โข ํ๊ฒฝ ๋ณ์(.env)๋ก ๋ฉ์ผ/Redis/DB ์ค์ ๊ด๋ฆฌ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir theater-multi-venue-booking-platform && cd theater-multi-venue-booking-platform
์ด ํด๋์์ ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค.
Laravel 13.x ํ๋ก์ ํธ๋ฅผ ์์ฑํ๊ณ ๊ธฐ๋ณธ ์ค์ ์ ์๋ฃํ์ธ์.
```bash
composer create-project laravel/laravel .
php artisan key:generate
```
.env ํ์ผ ์ค์ :
- DB_CONNECTION=mysql (๋๋ pgsql)
- DB_DATABASE=theater_booking
- CACHE_DRIVER=redis
- QUEUE_CONNECTION=database
- MAIL_MAILER=smtp (๊ฐ๋ฐ ์ค์๋ log๋ก ์ค์ ๊ฐ๋ฅ)
Tailwind CSS 4.x ์ค์น:
```bash
npm install -D tailwindcss@latest postcss autoprefixer
npx tailwindcss init -p
```
tailwind.config.js ์ค์ :
```javascript
module.exports = {
content: [
'./resources/**/*.blade.php',
'./resources/**/*.js',
],
theme: { extend: {} },
plugins: [],
}
```
resources/css/app.css:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
Alpine.js CDN ์ถ๊ฐ (resources/views/layouts/app.blade.php):
```html
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
```
๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
php artisan queue:table
php artisan migrate
```
Redis ์ค์น ํ์ธ (๋ก์ปฌ ํ๊ฒฝ):
```bash
redis-cli ping # PONG ์๋ต ํ์ธ
```
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x ํ๋ก์ ํธ ์์ฑ ์๋ฃ. Tailwind CSS, Alpine.js ์ค์ ์๋ฃ. .env์ DB/Redis/Queue ์ค์ ์๋ฃ. ๊ธฐ๋ณธ ๋ง์ด๊ทธ๋ ์ด์ ์คํ ์๋ฃ.
๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๋ฅผ ์ค๊ณํ๊ณ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ ์์ฑํ์ธ์. ํ์ํ ํ ์ด๋ธ: 1. venues (๊ทน์ฅ) - id, name, address, city, seat_map_image, created_at, updated_at 2. shows (๊ณต์ฐ) - id, title, description, genre, runtime_minutes, age_rating, poster_image, created_at, updated_at 3. showtimes (ํ์ฐจ) - id, show_id (FK), venue_id (FK), show_date, show_time, total_seats, available_seats, created_at, updated_at - ์ธ๋ฑ์ค: show_id, venue_id, show_date 4. seats (์ข์) - id, showtime_id (FK), section (VIP/R/S/A), row, number, price, status (available/pending/booked), locked_at (ํ์์์ ์ฒดํฌ์ฉ), locked_by_session, created_at, updated_at - ์ธ๋ฑ์ค: showtime_id, status - ์ ๋ํฌ ์ ์ฝ: showtime_id + section + row + number 5. bookings (์์ฝ) - id, user_id (FK, nullable), showtime_id (FK), seat_ids (JSON), booker_name, booker_email, booker_phone, total_price, status (pending/confirmed/cancelled), payment_method, booking_number (unique), qr_code, created_at, updated_at - ์ธ๋ฑ์ค: booking_number, user_id, showtime_id 6. actors (๋ฐฐ์ฐ) - ๋ณด๋์ค - id, name, bio, photo, created_at, updated_at 7. actor_show (ํผ๋ฒ ํ ์ด๋ธ) - ๋ณด๋์ค - id, show_id, actor_id ๋ง์ด๊ทธ๋ ์ด์ ์์ฑ ๋ช ๋ น: ```bash php artisan make:migration create_venues_table php artisan make:migration create_shows_table php artisan make:migration create_showtimes_table php artisan make:migration create_seats_table php artisan make:migration create_bookings_table php artisan make:migration create_actors_table php artisan make:migration create_actor_show_table ``` ๊ฐ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ ์คํค๋ง ์ ์ ํ ์คํ: ```bash php artisan migrate ```
๊ธฐ๋ ๊ฒฐ๊ณผ: 7๊ฐ ํ ์ด๋ธ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ ์์ฑ ์๋ฃ. ์ธ๋ํค, ์ธ๋ฑ์ค, ์ ๋ํฌ ์ ์ฝ ์กฐ๊ฑด ํฌํจ. php artisan migrate ์ฑ๊ณต.
Eloquent ๋ชจ๋ธ์ ์์ฑํ๊ณ ๊ด๊ณ๋ฅผ ์ ์ํ์ธ์. ```bash php artisan make:model Venue php artisan make:model Show php artisan make:model Showtime php artisan make:model Seat php artisan make:model Booking php artisan make:model Actor ``` ๊ฐ ๋ชจ๋ธ์ ๊ด๊ณ ์ ์: **Venue ๋ชจ๋ธ**: - hasMany(Showtime::class) **Show ๋ชจ๋ธ**: - hasMany(Showtime::class) - belongsToMany(Actor::class) (๋ณด๋์ค) **Showtime ๋ชจ๋ธ**: - belongsTo(Show::class) - belongsTo(Venue::class) - hasMany(Seat::class) - hasMany(Booking::class) **Seat ๋ชจ๋ธ**: - belongsTo(Showtime::class) - $casts = ['locked_at' => 'datetime']; - ์ค์ฝํ ์ถ๊ฐ: - scopeAvailable($query): status๊ฐ 'available'์ธ ์ข์ - scopeExpiredLocks($query): locked_at์ด 5๋ถ ์ด์ ์ง๋ pending ์ข์ **Booking ๋ชจ๋ธ**: - belongsTo(User::class, 'user_id')->nullable() - belongsTo(Showtime::class) - $casts = ['seat_ids' => 'array']; **Actor ๋ชจ๋ธ** (๋ณด๋์ค): - belongsToMany(Show::class) $fillable ์์ฑ์ ๊ฐ ๋ชจ๋ธ์ ์ถ๊ฐํ์ฌ ๋๋ ํ ๋น ํ์ฉ.
๊ธฐ๋ ๊ฒฐ๊ณผ: 6๊ฐ Eloquent ๋ชจ๋ธ ์์ฑ ์๋ฃ. hasMany, belongsTo, belongsToMany ๊ด๊ณ ์ ์ ์๋ฃ. ์ค์ฝํ ๋ฉ์๋ ์ถ๊ฐ ์๋ฃ.
Seeder์ Factory๋ฅผ ์์ฑํ์ฌ ๋๋ฏธ ๋ฐ์ดํฐ๋ฅผ ๋ง๋์ธ์.
```bash
php artisan make:factory VenueFactory
php artisan make:factory ShowFactory
php artisan make:factory ShowtimeFactory
php artisan make:factory SeatFactory
php artisan make:seeder DatabaseSeeder
```
**VenueFactory**: ๊ทน์ฅ 5๊ฐ ์์ฑ (name, address, city)
**ShowFactory**: ๊ณต์ฐ 20๊ฐ ์์ฑ (title, description, genre, runtime_minutes, age_rating)
**ShowtimeFactory**: ๊ฐ ๊ณต์ฐ๋น 3~5๊ฐ ํ์ฐจ ์์ฑ (show_date๋ ํฅํ 30์ผ ๋ด ๋๋ค)
**SeatFactory**: ๊ฐ ํ์ฐจ๋น ์ข์ 100์ ์์ฑ
- section: VIP(10์), R(30์), S(30์), A(30์)
- row: A~J (๋๋ค)
- number: 1~10 (๋๋ค)
- price: VIP(100000), R(80000), S(60000), A(40000)
- status: 'available'
**DatabaseSeeder**:
```php
Venue::factory(5)->create()->each(function ($venue) {
Show::factory(4)->create()->each(function ($show) use ($venue) {
Showtime::factory(3)->create([
'show_id' => $show->id,
'venue_id' => $venue->id,
])->each(function ($showtime) {
Seat::factory(100)->create(['showtime_id' => $showtime->id]);
});
});
});
```
์คํ:
```bash
php artisan db:seed
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋๋ฏธ ๋ฐ์ดํฐ ์์ฑ ์๋ฃ. ๊ทน์ฅ 5๊ฐ, ๊ณต์ฐ 20๊ฐ, ํ์ฐจ 60๊ฐ, ์ข์ 6000์ ์์ฑ. ๋ฐ์ดํฐ ํ์ธ ๊ฐ๋ฅ.
์ฌ์ฉ์ ๊ณต์ฐ ๋ชฉ๋ก ํ์ด์ง๋ฅผ ๊ตฌํํ์ธ์.
**๋ผ์ฐํธ (routes/web.php)**:
```php
Route::get('/', [ShowController::class, 'index'])->name('home');
Route::get('/shows', [ShowController::class, 'index'])->name('shows.index');
Route::get('/shows/{show}', [ShowController::class, 'show'])->name('shows.show');
```
**ShowController**:
```bash
php artisan make:controller ShowController
```
index() ๋ฉ์๋:
- ๊ณต์ฐ ๋ชฉ๋ก ์กฐํ (ํ์ด์ง๋ค์ด์
12๊ฐ์ฉ)
- ํํฐ๋ง: ์ฅ๋ฅด(genre), ๋ ์ง ๋ฒ์(date_from, date_to), ๊ทน์ฅ(venue_id)
- ์บ์ฑ: Redis์ 5๋ถ๊ฐ ์บ์
```php
$shows = Cache::remember('shows_list', 300, function () {
return Show::with('showtimes.venue')->paginate(12);
});
```
show() ๋ฉ์๋:
- ํน์ ๊ณต์ฐ ์์ธ ์ ๋ณด
- ํ์ฐจ ๋ชฉ๋ก (ํฅํ ๋ ์ง๋ง, ๋ ์ง์ ์ ๋ ฌ)
- Eager Loading์ผ๋ก N+1 ์ฟผ๋ฆฌ ๋ฐฉ์ง
```php
$show = Show::with(['showtimes' => function ($query) {
$query->where('show_date', '>=', now())
->orderBy('show_date')
->orderBy('show_time')
->with('venue');
}])->findOrFail($id);
```
**Blade ๋ทฐ (resources/views/shows/index.blade.php)**:
- Tailwind CSS ๊ทธ๋ฆฌ๋ ๋ ์ด์์ (grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6)
- ๊ณต์ฐ ์นด๋: ํฌ์คํฐ ์ด๋ฏธ์ง, ์ ๋ชฉ, ์ฅ๋ฅด, ๋ฌ๋ํ์
- ํํฐ ํผ: ์ฅ๋ฅด ์
๋ ํธ, ๋ ์ง ๋ฒ์ input, ๊ทน์ฅ ์
๋ ํธ
- ํ์ด์ง๋ค์ด์
({{ $shows->links() }})
**Blade ๋ทฐ (resources/views/shows/show.blade.php)**:
- ๊ณต์ฐ ์์ธ ์ ๋ณด: ํฌ์คํฐ, ์ ๋ชฉ, ์ค๋ช
, ์ฅ๋ฅด, ๋ฌ๋ํ์, ์ฐ๋ น์ ํ
- ํ์ฐจ ๋ชฉ๋ก: ๋ ์ง, ์๊ฐ, ๊ทน์ฅ๋ช
, ์์ฌ์, [์์ฝํ๊ธฐ] ๋ฒํผ
- [์์ฝํ๊ธฐ] ๋ฒํผ ํด๋ฆญ ์ ์ข์ ์ ํ ํ์ด์ง๋ก ์ด๋ (route('showtimes.seats', $showtime->id))
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ณต์ฐ ๋ชฉ๋ก ํ์ด์ง์ ์์ธ ํ์ด์ง ์์ฑ. ํํฐ๋ง ๋ฐ ์บ์ฑ ์ ์ฉ. Tailwind CSS๋ก ๋ฐ์ํ UI ๊ตฌํ. ํ์ฐจ๋ณ ์์ฝ ๋ฒํผ ๋์.
์ข์ ์ ํ ํ์ด์ง๋ฅผ ๊ตฌํํ์ธ์.
**๋ผ์ฐํธ**:
```php
Route::get('/showtimes/{showtime}/seats', [BookingController::class, 'selectSeats'])->name('showtimes.seats');
Route::post('/showtimes/{showtime}/lock-seats', [BookingController::class, 'lockSeats'])->name('showtimes.lock');
```
**BookingController**:
```bash
php artisan make:controller BookingController
```
selectSeats() ๋ฉ์๋:
- ํ์ฐจ ์ ๋ณด ์กฐํ (with show, venue)
- ์ข์ ๋ชฉ๋ก ์กฐํ (section๋ณ ๊ทธ๋ฃนํ)
```php
$showtime = Showtime::with('show', 'venue')->findOrFail($id);
$seats = Seat::where('showtime_id', $showtime->id)
->orderBy('section')
->orderBy('row')
->orderBy('number')
->get()
->groupBy('section');
```
**Blade ๋ทฐ (resources/views/bookings/select-seats.blade.php)**:
- ํ์ฐจ ์ ๋ณด ํ์: ๊ณต์ฐ๋ช
, ๋ ์ง, ์๊ฐ, ๊ทน์ฅ๋ช
- ์ข์ ๋ฐฐ์น๋ (section๋ณ ๊ทธ๋ฆฌ๋)
- VIP/R/S/A ๊ตฌ์ญ๋ณ๋ก div ๋ถ๋ฆฌ
- ์ข์ ๋ฒํผ: Alpine.js๋ก ์ ํ ํ ๊ธ
```html
<div x-data="{ selectedSeats: [] }">
@foreach($seats as $section => $sectionSeats)
<div class="mb-8">
<h3 class="text-lg font-bold mb-2">{{ $section }}์</h3>
<div class="grid grid-cols-10 gap-2">
@foreach($sectionSeats as $seat)
<button
@click="toggleSeat({{ $seat->id }})"
:class="{
'bg-gray-300': '{{ $seat->status }}' === 'booked',
'bg-yellow-300': '{{ $seat->status }}' === 'pending',
'bg-green-500': selectedSeats.includes({{ $seat->id }}),
'bg-blue-500': '{{ $seat->status }}' === 'available' && !selectedSeats.includes({{ $seat->id }})
}"
:disabled="'{{ $seat->status }}' !== 'available'"
class="w-10 h-10 rounded text-xs font-semibold"
>
{{ $seat->row }}{{ $seat->number }}
</button>
@endforeach
</div>
</div>
@endforeach
<button @click="lockSeats()" class="mt-4 bg-purple-600 text-white px-6 py-3 rounded">์ ํ ์๋ฃ</button>
</div>
```
- Alpine.js ๋ฉ์๋:
- toggleSeat(seatId): selectedSeats ๋ฐฐ์ด์ ์ถ๊ฐ/์ ๊ฑฐ
- lockSeats(): AJAX๋ก POST /showtimes/{showtime}/lock-seats (์ ํํ ์ข์ ID ์ ์ก)
lockSeats() ๋ฉ์๋ (BookingController):
- ์ ํํ ์ข์๋ค์ status๋ฅผ 'pending'์ผ๋ก ๋ณ๊ฒฝ
- locked_at = now(), locked_by_session = session()->getId()
- DB ํธ๋์ญ์
์ฌ์ฉ
- ์ค๋ณต ์์ฝ ๋ฐฉ์ง: ๋๊ด์ ์ ๊ธ (whereIn + where status = 'available')
```php
DB::transaction(function () use ($seatIds, $sessionId) {
$updated = Seat::whereIn('id', $seatIds)
->where('status', 'available')
->update([
'status' => 'pending',
'locked_at' => now(),
'locked_by_session' => $sessionId,
]);
if ($updated !== count($seatIds)) {
throw new \Exception('์ผ๋ถ ์ข์์ด ์ด๋ฏธ ์์ฝ๋์์ต๋๋ค.');
}
});
```
- ์ฑ๊ณต ์ ์์ฝ ์ ๋ณด ์
๋ ฅ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ (route('bookings.create', ['showtime' => $showtime->id, 'seats' => implode(',', $seatIds)]))
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ข์ ์ ํ ํ์ด์ง ์์ฑ. Alpine.js๋ก ์ธํฐ๋ํฐ๋ธ ์ข์ ํ ๊ธ ๊ตฌํ. AJAX๋ก ์ข์ ์ ๊ธ ์ฒ๋ฆฌ. DB ํธ๋์ญ์ ์ผ๋ก ๋์์ฑ ์ ์ด ์๋ฃ.
์์ฝ ์ ๋ณด ์
๋ ฅ ๋ฐ ํ์ ํ์ด์ง๋ฅผ ๊ตฌํํ์ธ์.
**๋ผ์ฐํธ**:
```php
Route::get('/bookings/create', [BookingController::class, 'create'])->name('bookings.create');
Route::post('/bookings', [BookingController::class, 'store'])->name('bookings.store');
Route::get('/bookings/{booking}', [BookingController::class, 'show'])->name('bookings.show');
```
create() ๋ฉ์๋:
- ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก showtime_id, seats (์ฝค๋ง ๊ตฌ๋ถ ์ข์ ID) ๋ฐ๊ธฐ
- ์ข์ ์ ๋ณด ์กฐํ (locked_by_session์ด ํ์ฌ ์ธ์
์ธ์ง ํ์ธ)
```php
$seatIds = explode(',', request('seats'));
$seats = Seat::whereIn('id', $seatIds)
->where('locked_by_session', session()->getId())
->where('status', 'pending')
->get();
if ($seats->count() !== count($seatIds)) {
return redirect()->route('shows.index')->with('error', '์ข์ ์ ํ์ด ๋ง๋ฃ๋์์ต๋๋ค.');
}
```
- ์ด ๊ธ์ก ๊ณ์ฐ: $totalPrice = $seats->sum('price');
**Blade ๋ทฐ (resources/views/bookings/create.blade.php)**:
- ํ์ฐจ ์ ๋ณด, ์ ํํ ์ข์ ๋ชฉ๋ก, ์ด ๊ธ์ก ํ์
- ์์ฝ์ ์ ๋ณด ํผ: ์ด๋ฆ, ์ด๋ฉ์ผ, ์ ํ๋ฒํธ, ๊ฒฐ์ ๋ฐฉ๋ฒ (์นด๋/๊ณ์ข์ด์ฒด/ํ์ฅ๊ฒฐ์ )
- 5๋ถ ํ์ด๋จธ ํ์ (Alpine.js)
```html
<div x-data="{ timeLeft: 300 }" x-init="setInterval(() => { timeLeft--; if (timeLeft <= 0) alert('์๊ฐ ์ด๊ณผ! ๋ค์ ์ ํํด์ฃผ์ธ์.'); }, 1000)">
<p>๋จ์ ์๊ฐ: <span x-text="Math.floor(timeLeft / 60)"></span>:<span x-text="(timeLeft % 60).toString().padStart(2, '0')"></span></p>
</div>
```
- [์์ฝ ํ์ ] ๋ฒํผ
store() ๋ฉ์๋:
- Form Request Validation ์์ฑ
```bash
php artisan make:request StoreBookingRequest
```
StoreBookingRequest:
```php
public function rules() {
return [
'booker_name' => 'required|string|max:100',
'booker_email' => 'required|email',
'booker_phone' => 'required|string|max:20',
'payment_method' => 'required|in:card,bank_transfer,on_site',
'seat_ids' => 'required|array',
];
}
```
- DB ํธ๋์ญ์
์ผ๋ก ์์ฝ ์์ฑ
```php
$booking = DB::transaction(function () use ($validated, $showtimeId) {
// ์ข์ ์ํ๋ฅผ 'booked'๋ก ๋ณ๊ฒฝ
Seat::whereIn('id', $validated['seat_ids'])
->where('status', 'pending')
->where('locked_by_session', session()->getId())
->update(['status' => 'booked']);
// ์์ฝ ์์ฑ
$booking = Booking::create([
'showtime_id' => $showtimeId,
'seat_ids' => $validated['seat_ids'],
'booker_name' => $validated['booker_name'],
'booker_email' => $validated['booker_email'],
'booker_phone' => $validated['booker_phone'],
'total_price' => Seat::whereIn('id', $validated['seat_ids'])->sum('price'),
'status' => 'confirmed',
'payment_method' => $validated['payment_method'],
'booking_number' => 'BK' . strtoupper(Str::random(10)),
]);
return $booking;
});
```
- QR ์ฝ๋ ์์ฑ (SimpleSoftwareIO/simple-qrcode ํจํค์ง)
```bash
composer require simplesoftwareio/simple-qrcode
```
```php
$qrCode = QrCode::size(200)->generate(route('bookings.show', $booking->id));
$booking->update(['qr_code' => $qrCode]);
```
- ์์ฝ ํ์ธ ์ด๋ฉ์ผ ๋ฐ์ก (Queue๋ก ๋น๋๊ธฐ ์ฒ๋ฆฌ)
```bash
php artisan make:mail BookingConfirmationMail --markdown=emails.booking-confirmation
```
```php
Mail::to($booking->booker_email)->queue(new BookingConfirmationMail($booking));
```
- ์์ฝ ์๋ฃ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ (route('bookings.show', $booking->id))
๊ธฐ๋ ๊ฒฐ๊ณผ: ์์ฝ ์ ๋ณด ์ ๋ ฅ ํผ ์์ฑ. Form Request Validation ์ ์ฉ. DB ํธ๋์ญ์ ์ผ๋ก ์์ฝ ํ์ ์ฒ๋ฆฌ. QR ์ฝ๋ ์์ฑ ๋ฐ ์ด๋ฉ์ผ ๋ฐ์ก ์๋ฃ.
์์ฝ ํ์ธ ํ์ด์ง์ ํ์์์ ์๋ ํด์ Job์ ๊ตฌํํ์ธ์.
**์์ฝ ํ์ธ ํ์ด์ง (BookingController::show)**:
```php
public function show(Booking $booking) {
$booking->load('showtime.show', 'showtime.venue');
$seats = Seat::whereIn('id', $booking->seat_ids)->get();
return view('bookings.show', compact('booking', 'seats'));
}
```
**Blade ๋ทฐ (resources/views/bookings/show.blade.php)**:
- ์์ฝ ๋ฒํธ, ์์ฝ์ ์ ๋ณด, ๊ณต์ฐ ์ ๋ณด, ํ์ฐจ, ๊ทน์ฅ, ์ข์ ๋ชฉ๋ก, ์ด ๊ธ์ก
- QR ์ฝ๋ ์ด๋ฏธ์ง ํ์
```html
<div class="text-center">
{!! $booking->qr_code !!}
<p class="mt-2 text-sm text-gray-600">์
์ฅ ์ QR ์ฝ๋๋ฅผ ์ ์ํด์ฃผ์ธ์.</p>
</div>
```
- [์์ฝ ์ทจ์] ๋ฒํผ (์ ํ ๊ธฐ๋ฅ)
**ํ์์์ ์๋ ํด์ Job ์์ฑ**:
```bash
php artisan make:job ReleasePendingSeatsJob
```
ReleasePendingSeatsJob:
```php
public function handle() {
Seat::where('status', 'pending')
->where('locked_at', '<', now()->subMinutes(5))
->update([
'status' => 'available',
'locked_at' => null,
'locked_by_session' => null,
]);
}
```
**์ค์ผ์ค๋ฌ ๋ฑ๋ก (app/Console/Kernel.php)**:
```php
protected function schedule(Schedule $schedule) {
$schedule->job(new ReleasePendingSeatsJob)->everyMinute();
}
```
**ํฌ๋ก ์ค์ (๋ก์ปฌ ๊ฐ๋ฐ ์ค์๋ `php artisan schedule:work` ์คํ)**:
```bash
php artisan schedule:work
```
๋๋ ์์ปค ์คํ:
```bash
php artisan queue:work
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ์์ฝ ํ์ธ ํ์ด์ง ์์ฑ. QR ์ฝ๋ ํ์ ์๋ฃ. ํ์์์ ์๋ ํด์ Job ์์ฑ ๋ฐ ์ค์ผ์ค๋ฌ ๋ฑ๋ก ์๋ฃ. 5๋ถ ํ pending ์ข์ ์๋ ํด์ ๋์ ํ์ธ.
๊ด๋ฆฌ์ ๋์๋ณด๋๋ฅผ ๊ตฌํํ์ธ์.
**๊ด๋ฆฌ์ ์ธ์ฆ ๋ฏธ๋ค์จ์ด ์์ฑ**:
```bash
php artisan make:middleware AdminMiddleware
```
AdminMiddleware:
```php
public function handle($request, Closure $next) {
if (!auth()->check() || !auth()->user()->is_admin) {
abort(403, 'Unauthorized');
}
return $next($request);
}
```
app/Http/Kernel.php์ ๋ฑ๋ก:
```php
protected $routeMiddleware = [
'admin' => \App\Http\Middleware\AdminMiddleware::class,
];
```
users ํ
์ด๋ธ์ is_admin ์ปฌ๋ผ ์ถ๊ฐ ๋ง์ด๊ทธ๋ ์ด์
:
```bash
php artisan make:migration add_is_admin_to_users_table
```
```php
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false);
});
```
**๊ด๋ฆฌ์ ๋ผ์ฐํธ (routes/web.php)**:
```php
Route::prefix('admin')->middleware(['auth', 'admin'])->group(function () {
Route::get('/dashboard', [AdminController::class, 'dashboard'])->name('admin.dashboard');
Route::resource('venues', VenueController::class);
Route::resource('shows', AdminShowController::class);
Route::resource('showtimes', ShowtimeController::class);
Route::get('/bookings', [AdminBookingController::class, 'index'])->name('admin.bookings.index');
Route::delete('/bookings/{booking}', [AdminBookingController::class, 'destroy'])->name('admin.bookings.destroy');
});
```
**AdminController::dashboard**:
```bash
php artisan make:controller Admin/AdminController
```
```php
public function dashboard() {
$totalBookings = Booking::where('status', 'confirmed')->count();
$totalRevenue = Booking::where('status', 'confirmed')->sum('total_price');
$upcomingShowtimes = Showtime::where('show_date', '>=', now())->count();
$recentBookings = Booking::with('showtime.show')->latest()->take(10)->get();
// ์ผ๋ณ ๋งค์ถ ์ฐจํธ ๋ฐ์ดํฐ (์ต๊ทผ 7์ผ)
$dailyRevenue = Booking::where('status', 'confirmed')
->where('created_at', '>=', now()->subDays(7))
->selectRaw('DATE(created_at) as date, SUM(total_price) as revenue')
->groupBy('date')
->orderBy('date')
->get();
return view('admin.dashboard', compact('totalBookings', 'totalRevenue', 'upcomingShowtimes', 'recentBookings', 'dailyRevenue'));
}
```
**Blade ๋ทฐ (resources/views/admin/dashboard.blade.php)**:
- ํต๊ณ ์นด๋: ์ด ์์ฝ ์, ์ด ๋งค์ถ, ์์ ๋ ํ์ฐจ ์
- ์ต๊ทผ ์์ฝ ๋ชฉ๋ก (ํ
์ด๋ธ)
- ์ผ๋ณ ๋งค์ถ ์ฐจํธ (Chart.js)
```html
<canvas id="revenueChart"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('revenueChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: @json($dailyRevenue->pluck('date')),
datasets: [{
label: '์ผ๋ณ ๋งค์ถ',
data: @json($dailyRevenue->pluck('revenue')),
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
}
});
</script>
```
**๊ทน์ฅ/๊ณต์ฐ/ํ์ฐจ CRUD ์ปจํธ๋กค๋ฌ ์์ฑ**:
```bash
php artisan make:controller Admin/VenueController --resource
php artisan make:controller Admin/AdminShowController --resource
php artisan make:controller Admin/ShowtimeController --resource
php artisan make:controller Admin/AdminBookingController
```
๊ฐ ์ปจํธ๋กค๋ฌ์ CRUD ๋ฉ์๋ ๊ตฌํ (index, create, store, edit, update, destroy).
Blade ๋ทฐ๋ Tailwind CSS๋ก ํผ ์คํ์ผ๋ง.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ด๋ฆฌ์ ๋์๋ณด๋ ์์ฑ. ํต๊ณ ์นด๋, ์ต๊ทผ ์์ฝ ๋ชฉ๋ก, ์ผ๋ณ ๋งค์ถ ์ฐจํธ ํ์. ๊ทน์ฅ/๊ณต์ฐ/ํ์ฐจ CRUD ๊ธฐ๋ฅ ๊ตฌํ. ๊ด๋ฆฌ์ ๋ฏธ๋ค์จ์ด๋ก ์ ๊ทผ ์ ์ด ์๋ฃ.
์ฑ๋ฅ ์ต์ ํ ๋ฐ ๋ณด๋์ค ๊ธฐ๋ฅ์ ์ถ๊ฐํ์ธ์.
**1. ์บ์ฑ ์ต์ ํ**:
- ๊ณต์ฐ ๋ชฉ๋ก ์บ์ฑ (์ด๋ฏธ ๊ตฌํ)
- ํ์ฐจ๋ณ ์ข์ ์ํ ์บ์ฑ (Redis)
```php
$seats = Cache::remember("seats_showtime_{$showtimeId}", 60, function () use ($showtimeId) {
return Seat::where('showtime_id', $showtimeId)->get();
});
```
- ์ข์ ์ํ ๋ณ๊ฒฝ ์ ์บ์ ๋ฌดํจํ
```php
Cache::forget("seats_showtime_{$showtimeId}");
```
**2. ์ธ๋ฑ์ค ์ต์ ํ**:
- ์์ฃผ ์กฐํ๋๋ ์ปฌ๋ผ์ ์ธ๋ฑ์ค ์ถ๊ฐ (์ด๋ฏธ ๋ง์ด๊ทธ๋ ์ด์
์ ํฌํจ)
- Explain ์ฟผ๋ฆฌ๋ก ์ฑ๋ฅ ํ์ธ
**3. Eager Loading**:
- N+1 ์ฟผ๋ฆฌ ๋ฐฉ์ง (์ด๋ฏธ ๊ตฌํ)
**4. ์์
๋ค์ด๋ก๋ ๊ธฐ๋ฅ**:
```bash
composer require maatwebsite/excel
```
์์ฝ ๋ด์ญ Export:
```bash
php artisan make:export BookingsExport --model=Booking
```
BookingsExport:
```php
public function collection() {
return Booking::with('showtime.show')->where('status', 'confirmed')->get();
}
```
๊ด๋ฆฌ์ ๋ผ์ฐํธ:
```php
Route::get('/admin/bookings/export', [AdminBookingController::class, 'export'])->name('admin.bookings.export');
```
AdminBookingController::export:
```php
public function export() {
return Excel::download(new BookingsExport, 'bookings.xlsx');
}
```
**5. ๊ณต์ฐ ๋ฆฌ๋ทฐ ์์คํ
(๋ณด๋์ค)**:
```bash
php artisan make:migration create_reviews_table
```
reviews ํ
์ด๋ธ:
- id, booking_id (FK), rating (1~5), comment, created_at, updated_at
Review ๋ชจ๋ธ ์์ฑ ๋ฐ ๊ด๊ณ ์ ์.
๊ณต์ฐ ์์ธ ํ์ด์ง์ ๋ฆฌ๋ทฐ ๋ชฉ๋ก ํ์.
**6. ์๋ฆผ ์์คํ
(๋ณด๋์ค)**:
```bash
php artisan make:notification ShowOpenNotification
```
๊ณต์ฐ ์คํ ์ ๊ตฌ๋
์์๊ฒ ์๋ฆผ ๋ฐ์ก (Queue๋ก ์ฒ๋ฆฌ).
**7. ๋ชจ๋ฐ์ผ ๋ฐ์ํ ๋์์ธ**:
- Tailwind CSS ๋ฐ์ํ ํด๋์ค ํ์ฉ (sm:, md:, lg:)
- ์ข์ ์ ํ ํ์ด์ง ๋ชจ๋ฐ์ผ ์ต์ ํ (์์ ํ๋ฉด์์ ๊ทธ๋ฆฌ๋ ์กฐ์ )
**8. ํ
์คํธ ์์ฑ (์ ํ)**:
```bash
php artisan make:test BookingTest
```
์์ฝ ์์ฑ, ์ข์ ์ ๊ธ, ํ์์์ ํด์ ๋ฑ ์ฃผ์ ๊ธฐ๋ฅ ํ
์คํธ.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์บ์ฑ, ์ธ๋ฑ์ค ์ต์ ํ ์๋ฃ. ์์ ๋ค์ด๋ก๋ ๊ธฐ๋ฅ ์ถ๊ฐ. ๊ณต์ฐ ๋ฆฌ๋ทฐ ์์คํ ๊ตฌํ (์ ํ). ์๋ฆผ ์์คํ ์ถ๊ฐ (์ ํ). ๋ชจ๋ฐ์ผ ๋ฐ์ํ ๋์์ธ ์์ฑ. ํ ์คํธ ์์ฑ ์๋ฃ (์ ํ). ํ๋ก์ ํธ ์์ฑ!
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!