๐ŸŽฌ ์˜ํ™” ๊ด€๋žŒ ์ธ์ฆ์ƒท ๊ฐค๋Ÿฌ๋ฆฌ - ํ‹ฐ์ผ“ ์‚ฌ์ง„์œผ๋กœ ๋งŒ๋“œ๋Š” ๋‚˜๋งŒ์˜ ๋ฌด๋น„๋กœ๊ทธ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ž…๋ฌธ PHP + JavaScript Laravel + React AI: Claude

๐ŸŽฌ ์˜ํ™” ๊ด€๋žŒ ์ธ์ฆ์ƒท ๊ฐค๋Ÿฌ๋ฆฌ - ํ‹ฐ์ผ“ ์‚ฌ์ง„์œผ๋กœ ๋งŒ๋“œ๋Š” ๋‚˜๋งŒ์˜ ๋ฌด๋น„๋กœ๊ทธ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… movie-ticket-photo-gallery
์กฐํšŒ์ˆ˜ 80 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

ํ”„๋กœ์ ํŠธ ๊ฐœ์š”

์˜ํ™”๊ด€์—์„œ ๋ฐ›์€ ํ‹ฐ์ผ“์„ ์‚ฌ์ง„์œผ๋กœ ์ฐ์–ด ์˜ฌ๋ฆฌ๊ณ , ํ•จ๊ป˜ ๋ณธ ์˜ํ™” ํฌ์Šคํ„ฐ์™€ ๊ฐ„๋‹จํ•œ ํ•œ์ค„ํ‰์„ ๋‚จ๊ธฐ๋Š” ๋ฌด๋น„๋กœ๊ทธ ๊ฐค๋Ÿฌ๋ฆฌ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์˜ํ™” ํŒฌ๋“ค์ด ์ž์‹ ์˜ ๊ด€๋žŒ ๊ธฐ๋ก์„ ์‹œ๊ฐ์ ์œผ๋กœ ์•„์นด์ด๋ธŒํ•˜๊ณ , ๋‹ค๋ฅธ ์‚ฌ๋žŒ๋“ค์˜ ์ทจํ–ฅ์„ ๊ตฌ๊ฒฝํ•  ์ˆ˜ ์žˆ๋Š” ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค.

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

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

๊ธฐ์ˆ  ์Šคํƒ

  • ๋ฐฑ์—”๋“œ: Laravel 13.x (RESTful API ์„œ๋ฒ„, SQLite DB)
  • ํ”„๋ก ํŠธ์—”๋“œ: React 19 (Vite ํ”„๋กœ์ ํŠธ, fetch API๋กœ Laravel ์—ฐ๋™)
  • ์Šคํƒ€์ผ๋ง: Tailwind CSS (๋ฐ˜์‘ํ˜• ๊ทธ๋ฆฌ๋“œ, ์นด๋“œ ๋””์ž์ธ, ๋ชจ๋‹ฌ ์• ๋‹ˆ๋ฉ”์ด์…˜)
  • ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ: Laravel Storage (public disk), ํŒŒ์ผ ์—…๋กœ๋“œ ํผ

๋ณด๋„ˆ์Šค ๊ธฐ๋Šฅ (์„ ํƒ)

  • ๐ŸŽจ ์˜ํ™” ์žฅ๋ฅด ํƒœ๊ทธ ์ถ”๊ฐ€ (์•ก์…˜, ๋กœ๋งจ์Šค, ์Šค๋ฆด๋Ÿฌ ๋“ฑ) & ํ•„ํ„ฐ๋ง
  • ๐ŸŒ™ ๋‹คํฌ๋ชจ๋“œ ํ† ๊ธ€
  • ๐Ÿ“Š ํ†ต๊ณ„ ํŽ˜์ด์ง€: ์˜ฌํ•ด ๋ณธ ์˜ํ™” ์ˆ˜, ๊ฐ€์žฅ ๋งŽ์ด ๋ณธ ์žฅ๋ฅด, ์›”๋ณ„ ๊ด€๋žŒ ๊ทธ๋ž˜ํ”„
  • ๐ŸŽž๏ธ TMDB API ์—ฐ๋™ํ•˜์—ฌ ์˜ํ™” ์ œ๋ชฉ ์ž…๋ ฅ ์‹œ ์ž๋™์œผ๋กœ ํฌ์Šคํ„ฐ ์ด๋ฏธ์ง€ ๊ฐ€์ ธ์˜ค๊ธฐ

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x + React 19 ํ’€์Šคํƒ ๊ตฌ์„ฑ (Laravel API ๋ฐฑ์—”๋“œ + React Vite ํ”„๋ก ํŠธ์—”๋“œ ๋ถ„๋ฆฌ)
  • โ€ข SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ฌ์šฉ (๊ฐ„๋‹จํ•œ ์„ค์ •)
  • โ€ข ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋Š” Laravel Storage์˜ public disk ์‚ฌ์šฉ (์‹ฌ๋ณผ๋ฆญ ๋งํฌ ์ƒ์„ฑ ํ•„์š”)
  • โ€ข React์—์„œ fetch ๋˜๋Š” axios๋กœ Laravel API ํ˜ธ์ถœ
  • โ€ข Tailwind CSS๋กœ ๋ฐ˜์‘ํ˜• ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ ๊ตฌํ˜„ (๋ชจ๋ฐ”์ผ/ํƒœ๋ธ”๋ฆฟ/๋ฐ์Šคํฌํ†ฑ ๋Œ€์‘)
  • โ€ข ๋ชจ๋‹ฌ์€ React state๋กœ ์ œ์–ด (๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ ๊ธˆ์ง€)
  • โ€ข ๋ณต์žกํ•œ ์ƒํƒœ๊ด€๋ฆฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ(Redux, Zustand ๋“ฑ) ์‚ฌ์šฉ ๊ธˆ์ง€ - useState๋งŒ ์‚ฌ์šฉ
  • โ€ข ์‹ค์‹œ๊ฐ„ ๊ธฐ๋Šฅ ์—†์Œ (WebSocket ๋ถˆํ•„์š”)

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

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

1
Step 1
mkdir movie-ticket-photo-gallery && cd movie-ticket-photo-gallery

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

## Laravel 13.x API ๋ฐฑ์—”๋“œ ์„ค์ •

1. Laravel 13.x ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ:
   ```bash
   composer create-project laravel/laravel backend
   cd backend
   ```

2. `.env` ํŒŒ์ผ์—์„œ SQLite ์„ค์ •:
   ```
   DB_CONNECTION=sqlite
   # DB_HOST, DB_PORT, DB_DATABASE ๋“ฑ ์ฃผ์„ ์ฒ˜๋ฆฌ
   ```

3. `database/database.sqlite` ๋นˆ ํŒŒ์ผ ์ƒ์„ฑ:
   ```bash
   touch database/database.sqlite
   ```

4. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ์ƒ์„ฑ (`database/migrations/xxxx_create_movie_logs_table.php`):
   ```php
   Schema::create('movie_logs', function (Blueprint $table) {
       $table->id();
       $table->string('movie_title');
       $table->date('watched_at');
       $table->text('review')->nullable();
       $table->string('ticket_image'); // ํ‹ฐ์ผ“ ์‚ฌ์ง„ ๊ฒฝ๋กœ
       $table->integer('likes')->default(0);
       $table->timestamps();
   });
   ```

5. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰:
   ```bash
   php artisan migrate
   ```

6. `app/Models/MovieLog.php` ๋ชจ๋ธ ์ƒ์„ฑ:
   ```php
   protected $fillable = ['movie_title', 'watched_at', 'review', 'ticket_image', 'likes'];
   ```

7. ์ด๋ฏธ์ง€ ์ €์žฅ์„ ์œ„ํ•œ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ ์ƒ์„ฑ:
   ```bash
   php artisan storage:link
   ```

8. API ๋ผ์šฐํŠธ ์„ค์ • (`routes/api.php`):
   ```php
   use App\Http\Controllers\MovieLogController;

   Route::get('/movie-logs', [MovieLogController::class, 'index']);
   Route::post('/movie-logs', [MovieLogController::class, 'store']);
   Route::get('/movie-logs/{id}', [MovieLogController::class, 'show']);
   Route::post('/movie-logs/{id}/like', [MovieLogController::class, 'like']);
   ```

9. ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ (`app/Http/Controllers/MovieLogController.php`):
   ```php
   public function index(Request $request) {
       $query = MovieLog::query();
       
       if ($request->has('search')) {
           $query->where('movie_title', 'like', '%' . $request->search . '%');
       }
       
       $sort = $request->get('sort', 'desc');
       $logs = $query->orderBy('watched_at', $sort)->get();
       
       return response()->json($logs);
   }

   public function store(Request $request) {
       $validated = $request->validate([
           'movie_title' => 'required|string|max:255',
           'watched_at' => 'required|date',
           'review' => 'nullable|string',
           'ticket_image' => 'required|image|max:2048'
       ]);

       $path = $request->file('ticket_image')->store('tickets', 'public');
       $validated['ticket_image'] = $path;

       $log = MovieLog::create($validated);
       return response()->json($log, 201);
   }

   public function show($id) {
       $log = MovieLog::findOrFail($id);
       return response()->json($log);
   }

   public function like($id) {
       $log = MovieLog::findOrFail($id);
       $log->increment('likes');
       return response()->json($log);
   }
   ```

10. CORS ์„ค์ • (`config/cors.php`):
    ```php
    'paths' => ['api/*'],
    'allowed_origins' => ['http://localhost:5173'],
    'allowed_methods' => ['*'],
    'allowed_headers' => ['*'],
    ```

11. Laravel ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹คํ–‰:
    ```bash
    php artisan serve
    ```
    (๊ธฐ๋ณธ ํฌํŠธ: http://localhost:8000)

์™„๋ฃŒ ํ›„ ๋‹ค์Œ ๋‹จ๊ณ„๋กœ ์ง„ํ–‰ํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Laravel 13.x API ์„œ๋ฒ„๊ฐ€ http://localhost:8000์—์„œ ์‹คํ–‰๋˜๊ณ , SQLite DB์— movie_logs ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋˜๋ฉฐ, `/api/movie-logs` ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์ค€๋น„๋ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ Storage ์„ค์ •๊ณผ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๊ฐ€ ์™„๋ฃŒ๋ฉ๋‹ˆ๋‹ค.

2
Step 2
## React 19 ํ”„๋ก ํŠธ์—”๋“œ ์„ค์ •

1. ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ(`movie-ticket-photo-gallery`)๋กœ ๋Œ์•„๊ฐ€์„œ React + Vite ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ:
   ```bash
   cd ..
   npm create vite@latest frontend -- --template react
   cd frontend
   npm install
   ```

2. Tailwind CSS ์„ค์น˜:
   ```bash
   npm install -D tailwindcss postcss autoprefixer
   npx tailwindcss init -p
   ```

3. `tailwind.config.js` ์„ค์ •:
   ```js
   export default {
     content: ["./index.html", "./src/**/*.{js,jsx}"],
     theme: { extend: {} },
     plugins: [],
   }
   ```

4. `src/index.css`์— Tailwind ๋””๋ ‰ํ‹ฐ๋ธŒ ์ถ”๊ฐ€:
   ```css
   @tailwind base;
   @tailwind components;
   @tailwind utilities;
   ```

5. `src/App.jsx` ๊ธฐ๋ณธ ๊ตฌ์กฐ ์ž‘์„ฑ:
   ```jsx
   import { useState, useEffect } from 'react';

   function App() {
     const [movieLogs, setMovieLogs] = useState([]);
     const [loading, setLoading] = useState(true);
     const [searchTerm, setSearchTerm] = useState('');
     const [sortOrder, setSortOrder] = useState('desc');

     useEffect(() => {
       fetchMovieLogs();
     }, [searchTerm, sortOrder]);

     const fetchMovieLogs = async () => {
       setLoading(true);
       const params = new URLSearchParams();
       if (searchTerm) params.append('search', searchTerm);
       params.append('sort', sortOrder);

       const response = await fetch(`http://localhost:8000/api/movie-logs?${params}`);
       const data = await response.json();
       setMovieLogs(data);
       setLoading(false);
     };

     return (
       <div className="min-h-screen bg-gray-50">
         <header className="bg-purple-600 text-white py-6 shadow-lg">
           <div className="container mx-auto px-4">
             <h1 className="text-3xl font-bold">๐ŸŽฌ ๋ฌด๋น„๋กœ๊ทธ ๊ฐค๋Ÿฌ๋ฆฌ</h1>
             <p className="text-purple-200 mt-1">ํ‹ฐ์ผ“ ์‚ฌ์ง„์œผ๋กœ ๋งŒ๋“œ๋Š” ๋‚˜๋งŒ์˜ ์˜ํ™” ๊ธฐ๋ก</p>
           </div>
         </header>

         <main className="container mx-auto px-4 py-8">
           {/* ๊ฒ€์ƒ‰ & ์ •๋ ฌ UI๋Š” ๋‹ค์Œ ๋‹จ๊ณ„์—์„œ ์ถ”๊ฐ€ */}
           <div className="text-center text-gray-500">
             {loading ? '๋กœ๋”ฉ ์ค‘...' : `${movieLogs.length}๊ฐœ์˜ ๋ฌด๋น„๋กœ๊ทธ`}
           </div>
         </main>
       </div>
     );
   }

   export default App;
   ```

6. ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹คํ–‰:
   ```bash
   npm run dev
   ```
   (๊ธฐ๋ณธ ํฌํŠธ: http://localhost:5173)

๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:5173 ์ ‘์† ์‹œ ํ—ค๋”๊ฐ€ ๋ณด์ด๊ณ , Laravel API์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ fetchํ•˜๋Š” ๊ตฌ์กฐ๊ฐ€ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: React 19 + Vite ํ”„๋ก ํŠธ์—”๋“œ๊ฐ€ http://localhost:5173์—์„œ ์‹คํ–‰๋˜๊ณ , Tailwind CSS๊ฐ€ ์ ์šฉ๋œ ํ—ค๋”๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. Laravel API(`http://localhost:8000/api/movie-logs`)์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ fetchํ•˜๋Š” ๊ธฐ๋ณธ ๊ตฌ์กฐ๊ฐ€ ์™„์„ฑ๋ฉ๋‹ˆ๋‹ค.

3
Step 3
## ํ‹ฐ์ผ“ ์—…๋กœ๋“œ ํผ & ๊ฐค๋Ÿฌ๋ฆฌ ๊ทธ๋ฆฌ๋“œ UI ๊ตฌํ˜„

`src/App.jsx`๋ฅผ ํ™•์žฅํ•˜์—ฌ ๋‹ค์Œ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”:

1. **์—…๋กœ๋“œ ํผ ์ปดํฌ๋„ŒํŠธ**:
   - ์˜ํ™” ์ œ๋ชฉ ์ž…๋ ฅ (ํ…์ŠคํŠธ)
   - ๊ด€๋žŒ์ผ ์„ ํƒ (date input)
   - ํ•œ์ค„ํ‰ ์ž…๋ ฅ (textarea)
   - ํ‹ฐ์ผ“ ์‚ฌ์ง„ ์—…๋กœ๋“œ (file input, accept="image/*")
   - ์ œ์ถœ ๋ฒ„ํŠผ

2. **ํผ ์ œ์ถœ ๋กœ์ง**:
   ```jsx
   const handleSubmit = async (e) => {
     e.preventDefault();
     const formData = new FormData();
     formData.append('movie_title', movieTitle);
     formData.append('watched_at', watchedAt);
     formData.append('review', review);
     formData.append('ticket_image', ticketImage);

     await fetch('http://localhost:8000/api/movie-logs', {
       method: 'POST',
       body: formData,
     });

     // ํผ ์ดˆ๊ธฐํ™” & ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
     setMovieTitle('');
     setWatchedAt('');
     setReview('');
     setTicketImage(null);
     fetchMovieLogs();
   };
   ```

3. **๊ฒ€์ƒ‰ & ์ •๋ ฌ UI**:
   - ๊ฒ€์ƒ‰์ฐฝ: `<input type="text" placeholder="์˜ํ™” ์ œ๋ชฉ ๊ฒ€์ƒ‰" />`
   - ์ •๋ ฌ ๋ฒ„ํŠผ: ์ตœ์‹ ์ˆœ/์˜ค๋ž˜๋œ์ˆœ ํ† ๊ธ€

4. **๊ฐค๋Ÿฌ๋ฆฌ ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ**:
   - Tailwind ๊ทธ๋ฆฌ๋“œ: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6`
   - ๊ฐ ์นด๋“œ:
     ```jsx
     <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition">
       <img 
         src={`http://localhost:8000/storage/${log.ticket_image}`}
         alt={log.movie_title}
         className="w-full h-48 object-cover"
       />
       <div className="p-4">
         <h3 className="font-bold text-lg">{log.movie_title}</h3>
         <p className="text-gray-500 text-sm">{log.watched_at}</p>
         <p className="text-gray-700 mt-2 line-clamp-2">{log.review}</p>
         <div className="flex items-center mt-3 text-red-500">
           <span>โค๏ธ {log.likes}</span>
         </div>
       </div>
     </div>
     ```

5. **๋ฐ˜์‘ํ˜• ๋””์ž์ธ**:
   - ๋ชจ๋ฐ”์ผ: 1์—ด
   - ํƒœ๋ธ”๋ฆฟ: 2์—ด
   - ๋ฐ์Šคํฌํ†ฑ: 3์—ด

์—…๋กœ๋“œ ํผ์„ ํŽ˜์ด์ง€ ์ƒ๋‹จ์— ๋ฐฐ์น˜ํ•˜๊ณ , ๊ทธ ์•„๋ž˜์— ๊ฐค๋Ÿฌ๋ฆฌ ๊ทธ๋ฆฌ๋“œ๋ฅผ ํ‘œ์‹œํ•˜์„ธ์š”. ํผ ์ œ์ถœ ์‹œ ์ƒˆ๋กœ์šด ๋ฌด๋น„๋กœ๊ทธ๊ฐ€ ๊ฐค๋Ÿฌ๋ฆฌ์— ์ฆ‰์‹œ ๋ฐ˜์˜๋˜๋„๋ก ๊ตฌํ˜„ํ•˜์„ธ์š”.

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

4
Step 4
## ์ƒ์„ธ ๋ชจ๋‹ฌ & ์ข‹์•„์š” ๊ธฐ๋Šฅ ๊ตฌํ˜„

1. **๋ชจ๋‹ฌ ์ƒํƒœ ๊ด€๋ฆฌ**:
   ```jsx
   const [selectedLog, setSelectedLog] = useState(null);
   const [showModal, setShowModal] = useState(false);

   const openModal = (log) => {
     setSelectedLog(log);
     setShowModal(true);
   };

   const closeModal = () => {
     setShowModal(false);
     setSelectedLog(null);
   };
   ```

2. **์นด๋“œ ํด๋ฆญ ์‹œ ๋ชจ๋‹ฌ ์—ด๊ธฐ**:
   - ๊ฐ ์นด๋“œ์— `onClick={() => openModal(log)}` ์ถ”๊ฐ€

3. **๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ**:
   ```jsx
   {showModal && selectedLog && (
     <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={closeModal}>
       <div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
         <div className="relative">
           <button 
             onClick={closeModal}
             className="absolute top-4 right-4 text-white bg-black bg-opacity-50 rounded-full w-8 h-8 flex items-center justify-center hover:bg-opacity-70"
           >
             โœ•
           </button>
           <img 
             src={`http://localhost:8000/storage/${selectedLog.ticket_image}`}
             alt={selectedLog.movie_title}
             className="w-full h-96 object-contain bg-gray-100"
           />
         </div>
         <div className="p-6">
           <h2 className="text-2xl font-bold mb-2">{selectedLog.movie_title}</h2>
           <p className="text-gray-500 mb-4">๐Ÿ“… ๊ด€๋žŒ์ผ: {selectedLog.watched_at}</p>
           <div className="bg-gray-50 p-4 rounded-lg mb-4">
             <p className="text-gray-700">{selectedLog.review || 'ํ•œ์ค„ํ‰์ด ์—†์Šต๋‹ˆ๋‹ค.'}</p>
           </div>
           <button 
             onClick={() => handleLike(selectedLog.id)}
             className="bg-red-500 text-white px-6 py-2 rounded-lg hover:bg-red-600 transition"
           >
             โค๏ธ ์ข‹์•„์š” {selectedLog.likes}
           </button>
         </div>
       </div>
     </div>
   )}
   ```

4. **์ข‹์•„์š” ๊ธฐ๋Šฅ**:
   ```jsx
   const handleLike = async (id) => {
     await fetch(`http://localhost:8000/api/movie-logs/${id}/like`, {
       method: 'POST',
     });
     
     // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
     setMovieLogs(movieLogs.map(log => 
       log.id === id ? { ...log, likes: log.likes + 1 } : log
     ));
     
     if (selectedLog && selectedLog.id === id) {
       setSelectedLog({ ...selectedLog, likes: selectedLog.likes + 1 });
     }
   };
   ```

5. **๋ชจ๋‹ฌ ์• ๋‹ˆ๋ฉ”์ด์…˜ (์„ ํƒ)**:
   - Tailwind์˜ `transition-opacity`์™€ `scale` ํ™œ์šฉ
   - ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ํŽ˜์ด๋“œ์ธ + ์Šค์ผ€์ผ์—… ํšจ๊ณผ

6. **ESC ํ‚ค๋กœ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ**:
   ```jsx
   useEffect(() => {
     const handleEsc = (e) => {
       if (e.key === 'Escape') closeModal();
     };
     if (showModal) {
       window.addEventListener('keydown', handleEsc);
       return () => window.removeEventListener('keydown', handleEsc);
     }
   }, [showModal]);
   ```

์นด๋“œ ํด๋ฆญ ์‹œ ํฐ ์ด๋ฏธ์ง€์™€ ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๋ชจ๋‹ฌ์ด ์—ด๋ฆฌ๊ณ , ์ข‹์•„์š” ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์นด์šดํŠธ๊ฐ€ ์ฆ๊ฐ€ํ•˜๋„๋ก ๊ตฌํ˜„ํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๊ฐค๋Ÿฌ๋ฆฌ ์นด๋“œ๋ฅผ ํด๋ฆญํ•˜๋ฉด ์ „์ฒด ํ™”๋ฉด ๋ชจ๋‹ฌ์ด ์—ด๋ฆฌ๊ณ , ํ‹ฐ์ผ“ ์ด๋ฏธ์ง€๋ฅผ ํฌ๊ฒŒ ๋ณผ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์˜ํ™” ์ œ๋ชฉ, ๊ด€๋žŒ์ผ, ํ•œ์ค„ํ‰, ์ข‹์•„์š” ๋ฒ„ํŠผ์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. ์ข‹์•„์š” ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์นด์šดํŠธ๊ฐ€ ์ฆ๊ฐ€ํ•˜๊ณ , ๋ชจ๋‹ฌ ์™ธ๋ถ€ ํด๋ฆญ ๋˜๋Š” ESC ํ‚ค๋กœ ๋ชจ๋‹ฌ์„ ๋‹ซ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ข‹์•„์š” ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ์ฆ‰์‹œ ๊ฐค๋Ÿฌ๋ฆฌ์™€ ๋ชจ๋‹ฌ์— ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค.

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

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

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

๋Œ“๊ธ€ (0)

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