๐ŸŽฎ ๊ฒŒ์ž„ ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ํ”„๋ฆฌ๋ทฐ - ์‹ค์‹œ๊ฐ„ ์•„๋ฐ”ํƒ€ ๋นŒ๋” - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ž…๋ฌธ PHP + JavaScript Laravel + React AI: Claude

๐ŸŽฎ ๊ฒŒ์ž„ ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ํ”„๋ฆฌ๋ทฐ - ์‹ค์‹œ๊ฐ„ ์•„๋ฐ”ํƒ€ ๋นŒ๋”

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… game-avatar-customizer
์กฐํšŒ์ˆ˜ 85 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

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

๊ฒŒ์ž„ ์บ๋ฆญํ„ฐ๋ฅผ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•˜๊ณ  ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ฏธ๋ฆฌ๋ณด๊ธฐํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ„๋‹จํ•œ ํ’€์Šคํƒ ์•„๋ฐ”ํƒ€ ๋นŒ๋”๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ํ—ค์–ด์Šคํƒ€์ผ, ํ”ผ๋ถ€์ƒ‰, ์˜์ƒ, ์•ก์„ธ์„œ๋ฆฌ ๋“ฑ์„ ์„ ํƒํ•˜์—ฌ ์ž์‹ ๋งŒ์˜ ์บ๋ฆญํ„ฐ๋ฅผ ๋งŒ๋“ค๊ณ  ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ“‹ ํ•„์ˆ˜ ๊ธฐ๋Šฅ

  • Laravel 13.x REST API ๋ฐฑ์—”๋“œ (SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค)
  • React 19 ํ”„๋ก ํŠธ์—”๋“œ (Vite ๊ธฐ๋ฐ˜)
  • ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ์˜ต์…˜ ์„ ํƒ UI
    • ํ—ค์–ด์Šคํƒ€์ผ (5๊ฐ€์ง€ ์ด์ƒ)
    • ํ”ผ๋ถ€์ƒ‰ (5๊ฐ€์ง€ ์ด์ƒ)
    • ์ƒ์˜/ํ•˜์˜ (๊ฐ 3๊ฐ€์ง€ ์ด์ƒ)
    • ์•ก์„ธ์„œ๋ฆฌ (๋ชจ์ž, ์•ˆ๊ฒฝ ๋“ฑ 3๊ฐ€์ง€ ์ด์ƒ)
  • ์‹ค์‹œ๊ฐ„ ์บ๋ฆญํ„ฐ ํ”„๋ฆฌ๋ทฐ (์„ ํƒํ•œ ์˜ต์…˜์ด ์ฆ‰์‹œ ๋ฐ˜์˜)
  • ๋‚ด ์บ๋ฆญํ„ฐ ์ €์žฅ ๊ธฐ๋Šฅ (๋‹‰๋„ค์ž„๊ณผ ํ•จ๊ป˜)
  • ์ €์žฅ๋œ ์บ๋ฆญํ„ฐ ๊ฐค๋Ÿฌ๋ฆฌ (๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋“ค์˜ ์บ๋ฆญํ„ฐ ๊ตฌ๊ฒฝํ•˜๊ธฐ)
  • ์บ๋ฆญํ„ฐ๋ณ„ ์ข‹์•„์š” ๊ธฐ๋Šฅ

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

  • ๋žœ๋ค ์บ๋ฆญํ„ฐ ์ƒ์„ฑ ๋ฒ„ํŠผ (๋ชจ๋“  ์˜ต์…˜ ๋žœ๋ค ์„ ํƒ)
  • ์บ๋ฆญํ„ฐ ๊ณต์œ  URL ์ƒ์„ฑ (ํŠน์ • ์บ๋ฆญํ„ฐ ๋งํฌ ๋ณต์‚ฌ)
  • ์ธ๊ธฐ ์บ๋ฆญํ„ฐ TOP 10 (์ข‹์•„์š” ์ˆœ)
  • ์ƒ‰์ƒ ํŒ”๋ ˆํŠธ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• (์˜์ƒ ์ƒ‰์ƒ ์ง์ ‘ ์„ ํƒ)
  • ์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ (์บ๋ฆญํ„ฐ๊ฐ€ ์† ํ”๋“ค๊ธฐ, ์ ํ”„ํ•˜๊ธฐ ๋“ฑ)

๐Ÿ›  ๊ธฐ์ˆ  ์Šคํƒ

  • Backend: Laravel 13.x (API ๋ชจ๋“œ)
  • Frontend: React 19 + Vite
  • Database: SQLite
  • Styling: Tailwind CSS
  • HTTP Client: Axios

๐Ÿ’ก ํ•™์Šต ํฌ์ธํŠธ

  • Laravel API ์—”๋“œํฌ์ธํŠธ ์„ค๊ณ„ ๋ฐ CORS ์„ค์ •
  • React ์ƒํƒœ ๊ด€๋ฆฌ (useState)๋กœ ์‹ค์‹œ๊ฐ„ UI ์—…๋ฐ์ดํŠธ
  • RESTful API์™€ React ํ”„๋ก ํŠธ์—”๋“œ ํ†ต์‹ 
  • ์ด๋ฏธ์ง€/์•„์ด์ฝ˜ ๊ธฐ๋ฐ˜ UI ์ปดํฌ๋„ŒํŠธ ๊ตฌ์„ฑ
  • ๊ฐ„๋‹จํ•œ ์ข‹์•„์š” ๊ธฐ๋Šฅ ๊ตฌํ˜„

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x์™€ React 19 ์ตœ์‹  ๋ฒ„์ „ ํ•„์ˆ˜ ์‚ฌ์šฉ
  • โ€ข SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ฌ์šฉ (๋ณ„๋„ DB ์„œ๋ฒ„ ๋ถˆํ•„์š”)
  • โ€ข Laravel์€ ์ˆœ์ˆ˜ API ์„œ๋ฒ„๋กœ๋งŒ ๋™์ž‘ (Blade ํ…œํ”Œ๋ฆฟ ์‚ฌ์šฉ ๊ธˆ์ง€)
  • โ€ข React๋Š” ๋ณ„๋„ Vite ํ”„๋กœ์ ํŠธ๋กœ ๊ตฌ์„ฑ (ํฌํŠธ 5173)
  • โ€ข Laravel API๋Š” ํฌํŠธ 8000์—์„œ ์‹คํ–‰
  • โ€ข ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…/WebSocket ๊ธฐ๋Šฅ ์‚ฌ์šฉ ๊ธˆ์ง€
  • โ€ข ๋ณต์žกํ•œ ์ƒํƒœ๊ด€๋ฆฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ(Redux, Zustand ๋“ฑ) ์‚ฌ์šฉ ๊ธˆ์ง€, useState๋งŒ ์‚ฌ์šฉ
  • โ€ข ์บ๋ฆญํ„ฐ ์ด๋ฏธ์ง€๋Š” ์ด๋ชจ์ง€ ๋˜๋Š” SVG ์•„์ด์ฝ˜์œผ๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ํ‘œํ˜„
  • โ€ข ํ”„๋ก ํŠธ์—”๋“œ๋Š” ๋ฐ˜์‘ํ˜• ๋””์ž์ธ (๋ชจ๋ฐ”์ผ/๋ฐ์Šคํฌํ†ฑ ๋Œ€์‘)
  • โ€ข API ์‘๋‹ต์€ JSON ํ˜•์‹
  • โ€ข CORS ์„ค์ • ํ•„์ˆ˜ (Laravel์—์„œ React ๋„๋ฉ”์ธ ํ—ˆ์šฉ)

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

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

1
Step 1
mkdir game-avatar-customizer && cd game-avatar-customizer

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

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

1. Laravel 13.x ํ”„๋กœ์ ํŠธ๋ฅผ `backend` ํด๋”์— ์ƒ์„ฑํ•˜์„ธ์š”:
   ```bash
   composer create-project laravel/laravel:^13.0 backend
   cd backend
   ```

2. SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ •:
   - `.env` ํŒŒ์ผ์—์„œ DB ์„ค์ • ๋ณ€๊ฒฝ:
     ```
     DB_CONNECTION=sqlite
     # DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD ์ฃผ์„ ์ฒ˜๋ฆฌ
     ```
   - `database/database.sqlite` ํŒŒ์ผ ์ƒ์„ฑ:
     ```bash
     touch database/database.sqlite
     ```

3. CORS ์„ค์ •:
   - `config/cors.php`์—์„œ `'allowed_origins'`๋ฅผ `['http://localhost:5173']`๋กœ ์„ค์ •
   - `bootstrap/app.php`์—์„œ CORS ๋ฏธ๋“ค์›จ์–ด ํ™œ์„ฑํ™” ํ™•์ธ

4. Characters ํ…Œ์ด๋ธ” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ƒ์„ฑ:
   ```bash
   php artisan make:migration create_characters_table
   ```
   - ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ๋‚ด์šฉ:
     ```php
     Schema::create('characters', function (Blueprint $table) {
         $table->id();
         $table->string('nickname');
         $table->string('hairstyle');
         $table->string('skin_color');
         $table->string('top');
         $table->string('bottom');
         $table->string('accessory')->nullable();
         $table->integer('likes')->default(0);
         $table->timestamps();
     });
     ```

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

6. Character ๋ชจ๋ธ ์ƒ์„ฑ:
   ```bash
   php artisan make:model Character
   ```
   - `app/Models/Character.php`์—์„œ fillable ์†์„ฑ ์ถ”๊ฐ€:
     ```php
     protected $fillable = ['nickname', 'hairstyle', 'skin_color', 'top', 'bottom', 'accessory', 'likes'];
     ```

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

   Route::get('/characters', [CharacterController::class, 'index']);
   Route::post('/characters', [CharacterController::class, 'store']);
   Route::post('/characters/{id}/like', [CharacterController::class, 'like']);
   ```

8. CharacterController ์ƒ์„ฑ:
   ```bash
   php artisan make:controller CharacterController
   ```
   - ๊ธฐ๋ณธ CRUD ๋ฉ”์„œ๋“œ ๊ตฌํ˜„ (index, store, like)

9. ์„œ๋ฒ„ ์‹คํ–‰ ํ…Œ์ŠคํŠธ:
   ```bash
   php artisan serve
   ```
   - `http://localhost:8000/api/characters` ์ ‘์†ํ•˜์—ฌ ๋นˆ ๋ฐฐ์—ด `[]` ๋ฐ˜ํ™˜ ํ™•์ธ

์™„๋ฃŒ๋˜๋ฉด Laravel API ์„œ๋ฒ„๊ฐ€ ํฌํŠธ 8000์—์„œ ์‹คํ–‰๋˜๊ณ , SQLite DB์™€ Character ๋ชจ๋ธ์ด ์ค€๋น„๋œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Laravel 13.x ๋ฐฑ์—”๋“œ ํ”„๋กœ์ ํŠธ๊ฐ€ `backend` ํด๋”์— ์ƒ์„ฑ๋˜๊ณ , SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ, Characters ํ…Œ์ด๋ธ” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜, Character ๋ชจ๋ธ, API ๋ผ์šฐํŠธ, CharacterController๊ฐ€ ์ค€๋น„๋ฉ๋‹ˆ๋‹ค. `php artisan serve` ์‹คํ–‰ ์‹œ ํฌํŠธ 8000์—์„œ ์„œ๋ฒ„๊ฐ€ ๋™์ž‘ํ•˜๊ณ , `/api/characters` ์—”๋“œํฌ์ธํŠธ์— ์ ‘์†ํ•˜๋ฉด ๋นˆ ๋ฐฐ์—ด์ด ๋ฐ˜ํ™˜๋ฉ๋‹ˆ๋‹ค.

2
Step 2
# Laravel API ์—”๋“œํฌ์ธํŠธ ๊ตฌํ˜„

CharacterController์— ์บ๋ฆญํ„ฐ CRUD ๋กœ์ง์„ ๊ตฌํ˜„ํ•˜์„ธ์š”.

`app/Http/Controllers/CharacterController.php` ํŒŒ์ผ ๋‚ด์šฉ:

```php
namespace App\Http\Controllers;

use App\Models\Character;
use Illuminate\Http\Request;

class CharacterController extends Controller
{
    // ๋ชจ๋“  ์บ๋ฆญํ„ฐ ์กฐํšŒ (์ข‹์•„์š” ๋งŽ์€ ์ˆœ)
    public function index()
    {
        $characters = Character::orderBy('likes', 'desc')
            ->orderBy('created_at', 'desc')
            ->get();
        return response()->json($characters);
    }

    // ์ƒˆ ์บ๋ฆญํ„ฐ ์ €์žฅ
    public function store(Request $request)
    {
        $validated = $request->validate([
            'nickname' => 'required|string|max:50',
            'hairstyle' => 'required|string',
            'skin_color' => 'required|string',
            'top' => 'required|string',
            'bottom' => 'required|string',
            'accessory' => 'nullable|string',
        ]);

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

    // ์บ๋ฆญํ„ฐ ์ข‹์•„์š”
    public function like($id)
    {
        $character = Character::findOrFail($id);
        $character->increment('likes');
        return response()->json($character);
    }
}
```

**ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ•:**

1. Postman ๋˜๋Š” curl๋กœ ์บ๋ฆญํ„ฐ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ:
   ```bash
   curl -X POST http://localhost:8000/api/characters \
     -H "Content-Type: application/json" \
     -d '{
       "nickname": "TestHero",
       "hairstyle": "short",
       "skin_color": "light",
       "top": "tshirt",
       "bottom": "jeans",
       "accessory": "cap"
     }'
   ```

2. ์บ๋ฆญํ„ฐ ๋ชฉ๋ก ์กฐํšŒ:
   ```bash
   curl http://localhost:8000/api/characters
   ```

3. ์ข‹์•„์š” ์ถ”๊ฐ€:
   ```bash
   curl -X POST http://localhost:8000/api/characters/1/like
   ```

๋ชจ๋“  ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์ •์ƒ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: CharacterController์— index, store, like ๋ฉ”์„œ๋“œ๊ฐ€ ๊ตฌํ˜„๋˜์–ด ์บ๋ฆญํ„ฐ ์ƒ์„ฑ, ์กฐํšŒ, ์ข‹์•„์š” ๊ธฐ๋Šฅ์ด ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. API ํ…Œ์ŠคํŠธ ์‹œ JSON ์‘๋‹ต์ด ์ •์ƒ์ ์œผ๋กœ ๋ฐ˜ํ™˜๋˜๊ณ , SQLite DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

3
Step 3
# React 19 ํ”„๋ก ํŠธ์—”๋“œ ์ดˆ๊ธฐ ์„ค์ •

ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ(`game-avatar-customizer`)๋กœ ๋Œ์•„์™€์„œ React ํ”„๋ก ํŠธ์—”๋“œ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.

1. React + Vite ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ:
   ```bash
   cd ..
   npm create vite@latest frontend -- --template react
   cd frontend
   npm install
   ```

2. ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜:
   ```bash
   npm install axios
   npm install -D tailwindcss postcss autoprefixer
   npx tailwindcss init -p
   ```

3. Tailwind CSS ์„ค์ •:
   - `tailwind.config.js` ํŒŒ์ผ:
     ```js
     export default {
       content: [
         "./index.html",
         "./src/**/*.{js,ts,jsx,tsx}",
       ],
       theme: {
         extend: {},
       },
       plugins: [],
     }
     ```
   - `src/index.css` ํŒŒ์ผ ๋งจ ์œ„์— ์ถ”๊ฐ€:
     ```css
     @tailwind base;
     @tailwind components;
     @tailwind utilities;
     ```

4. Axios ๊ธฐ๋ณธ ์„ค์ • ํŒŒ์ผ ์ƒ์„ฑ (`src/api/axios.js`):
   ```js
   import axios from 'axios';

   const api = axios.create({
     baseURL: 'http://localhost:8000/api',
     headers: {
       'Content-Type': 'application/json',
     },
   });

   export default api;
   ```

5. ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹คํ–‰ ํ…Œ์ŠคํŠธ:
   ```bash
   npm run dev
   ```
   - ๋ธŒ๋ผ์šฐ์ €์—์„œ `http://localhost:5173` ์ ‘์†ํ•˜์—ฌ Vite ๊ธฐ๋ณธ ํ™”๋ฉด ํ™•์ธ

6. `src/App.jsx`๋ฅผ ๊ฐ„๋‹จํ•œ ํ—ฌ๋กœ์›”๋“œ๋กœ ์ˆ˜์ •ํ•˜์—ฌ ํ…Œ์ŠคํŠธ:
   ```jsx
   function App() {
     return (
       <div className="min-h-screen bg-gray-100 p-8">
         <h1 className="text-3xl font-bold text-center">๊ฒŒ์ž„ ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•</h1>
       </div>
     )
   }

   export default App
   ```

๋ธŒ๋ผ์šฐ์ €์— "๊ฒŒ์ž„ ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•" ์ œ๋ชฉ์ด ํ‘œ์‹œ๋˜๋ฉด ์„ฑ๊ณต์ž…๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: React 19 + Vite ํ”„๋กœ์ ํŠธ๊ฐ€ `frontend` ํด๋”์— ์ƒ์„ฑ๋˜๊ณ , Tailwind CSS์™€ Axios๊ฐ€ ์„ค์น˜๋ฉ๋‹ˆ๋‹ค. `npm run dev` ์‹คํ–‰ ์‹œ ํฌํŠธ 5173์—์„œ ๊ฐœ๋ฐœ ์„œ๋ฒ„๊ฐ€ ๋™์ž‘ํ•˜๊ณ , ๋ธŒ๋ผ์šฐ์ €์— ๊ฐ„๋‹จํ•œ ์ œ๋ชฉ์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

4
Step 4
# React ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• UI ๊ตฌํ˜„

์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ์˜ต์…˜์„ ์„ ํƒํ•˜๊ณ  ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ”„๋ฆฌ๋ทฐ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋Š” UI๋ฅผ ๋งŒ๋“œ์„ธ์š”.

`src/App.jsx` ํŒŒ์ผ์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ž‘์„ฑ:

```jsx
import { useState } from 'react'
import api from './api/axios'

function App() {
  // ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ์ƒํƒœ
  const [character, setCharacter] = useState({
    nickname: '',
    hairstyle: 'short',
    skin_color: 'light',
    top: 'tshirt',
    bottom: 'jeans',
    accessory: 'none'
  })

  const [savedCharacters, setSavedCharacters] = useState([])
  const [loading, setLoading] = useState(false)

  // ์˜ต์…˜ ๋ฐ์ดํ„ฐ
  const options = {
    hairstyle: ['short', 'long', 'curly', 'bald', 'ponytail'],
    skin_color: ['light', 'medium', 'tan', 'dark', 'pale'],
    top: ['tshirt', 'hoodie', 'jacket', 'tank'],
    bottom: ['jeans', 'shorts', 'skirt', 'pants'],
    accessory: ['none', 'cap', 'glasses', 'headband', 'earrings']
  }

  // ์˜ต์…˜ ๋ณ€๊ฒฝ ํ•ธ๋“ค๋Ÿฌ
  const handleChange = (category, value) => {
    setCharacter(prev => ({ ...prev, [category]: value }))
  }

  // ์บ๋ฆญํ„ฐ ์ €์žฅ
  const handleSave = async () => {
    if (!character.nickname.trim()) {
      alert('๋‹‰๋„ค์ž„์„ ์ž…๋ ฅํ•˜์„ธ์š”!')
      return
    }

    setLoading(true)
    try {
      const response = await api.post('/characters', character)
      alert('์บ๋ฆญํ„ฐ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!')
      setCharacter({ ...character, nickname: '' })
      fetchCharacters()
    } catch (error) {
      alert('์ €์žฅ ์‹คํŒจ: ' + error.message)
    } finally {
      setLoading(false)
    }
  }

  // ์ €์žฅ๋œ ์บ๋ฆญํ„ฐ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ
  const fetchCharacters = async () => {
    try {
      const response = await api.get('/characters')
      setSavedCharacters(response.data)
    } catch (error) {
      console.error('์บ๋ฆญํ„ฐ ๋กœ๋”ฉ ์‹คํŒจ:', error)
    }
  }

  return (
    <div className="min-h-screen bg-gradient-to-br from-purple-400 to-pink-300 p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-4xl font-bold text-white text-center mb-8">๐ŸŽฎ ๊ฒŒ์ž„ ์บ๋ฆญํ„ฐ ๋นŒ๋”</h1>
        
        <div className="grid md:grid-cols-2 gap-8">
          {/* ์บ๋ฆญํ„ฐ ํ”„๋ฆฌ๋ทฐ */}
          <div className="bg-white rounded-lg shadow-xl p-6">
            <h2 className="text-2xl font-bold mb-4">์บ๋ฆญํ„ฐ ํ”„๋ฆฌ๋ทฐ</h2>
            <div className="bg-gray-100 rounded-lg p-8 min-h-[300px] flex items-center justify-center">
              <div className="text-center">
                <div className="text-6xl mb-4">
                  {character.hairstyle === 'short' && '๐Ÿ‘ฆ'}
                  {character.hairstyle === 'long' && '๐Ÿ‘ฉ'}
                  {character.hairstyle === 'curly' && '๐Ÿ‘จโ€๐Ÿฆฑ'}
                  {character.hairstyle === 'bald' && '๐Ÿ‘จโ€๐Ÿฆฒ'}
                  {character.hairstyle === 'ponytail' && '๐Ÿ‘ฑโ€โ™€๏ธ'}
                </div>
                <div className="text-sm text-gray-600 space-y-1">
                  <p>ํ—ค์–ด: {character.hairstyle}</p>
                  <p>ํ”ผ๋ถ€: {character.skin_color}</p>
                  <p>์ƒ์˜: {character.top}</p>
                  <p>ํ•˜์˜: {character.bottom}</p>
                  <p>์•…์„ธ์„œ๋ฆฌ: {character.accessory}</p>
                </div>
              </div>
            </div>
          </div>

          {/* ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ์˜ต์…˜ */}
          <div className="bg-white rounded-lg shadow-xl p-6">
            <h2 className="text-2xl font-bold mb-4">์ปค์Šคํ„ฐ๋งˆ์ด์ง•</h2>
            
            <div className="space-y-4">
              {/* ๋‹‰๋„ค์ž„ ์ž…๋ ฅ */}
              <div>
                <label className="block text-sm font-medium mb-2">๋‹‰๋„ค์ž„</label>
                <input
                  type="text"
                  value={character.nickname}
                  onChange={(e) => setCharacter(prev => ({ ...prev, nickname: e.target.value }))}
                  className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
                  placeholder="๋ฉ‹์ง„ ๋‹‰๋„ค์ž„์„ ์ž…๋ ฅํ•˜์„ธ์š”"
                />
              </div>

              {/* ๊ฐ ์˜ต์…˜ ์„ ํƒ */}
              {Object.entries(options).map(([category, values]) => (
                <div key={category}>
                  <label className="block text-sm font-medium mb-2 capitalize">
                    {category.replace('_', ' ')}
                  </label>
                  <div className="flex flex-wrap gap-2">
                    {values.map(value => (
                      <button
                        key={value}
                        onClick={() => handleChange(category, value)}
                        className={`px-4 py-2 rounded-lg border-2 transition ${
                          character[category] === value
                            ? 'bg-purple-500 text-white border-purple-600'
                            : 'bg-white text-gray-700 border-gray-300 hover:border-purple-400'
                        }`}
                      >
                        {value}
                      </button>
                    ))}
                  </div>
                </div>
              ))}

              {/* ์ €์žฅ ๋ฒ„ํŠผ */}
              <button
                onClick={handleSave}
                disabled={loading}
                className="w-full bg-purple-600 text-white py-3 rounded-lg font-bold hover:bg-purple-700 transition disabled:opacity-50"
              >
                {loading ? '์ €์žฅ ์ค‘...' : '๐Ÿ’พ ์บ๋ฆญํ„ฐ ์ €์žฅ'}
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

export default App
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ์˜ต์…˜ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ์บ๋ฆญํ„ฐ ์ •๋ณด๊ฐ€ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์—…๋ฐ์ดํŠธ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: React ์•ฑ์— ์บ๋ฆญํ„ฐ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• UI๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. ํ—ค์–ด์Šคํƒ€์ผ, ํ”ผ๋ถ€์ƒ‰, ์˜์ƒ, ์•ก์„ธ์„œ๋ฆฌ ์˜ต์…˜์„ ์„ ํƒํ•˜๋ฉด ํ”„๋ฆฌ๋ทฐ ์˜์—ญ์— ์„ ํƒํ•œ ๊ฐ’์ด ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. ๋‹‰๋„ค์ž„ ์ž…๋ ฅ ํ•„๋“œ์™€ ์ €์žฅ ๋ฒ„ํŠผ์ด ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

5
Step 5
# ์ €์žฅ๋œ ์บ๋ฆญํ„ฐ ๊ฐค๋Ÿฌ๋ฆฌ ๋ฐ ์ข‹์•„์š” ๊ธฐ๋Šฅ ๊ตฌํ˜„

`src/App.jsx` ํŒŒ์ผ์— ์บ๋ฆญํ„ฐ ๊ฐค๋Ÿฌ๋ฆฌ ์„น์…˜์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

๊ธฐ์กด `App` ์ปดํฌ๋„ŒํŠธ์— ๋‹ค์Œ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€:

1. ์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์‹œ ์บ๋ฆญํ„ฐ ๋ชฉ๋ก ๋ถˆ๋Ÿฌ์˜ค๊ธฐ:
   ```jsx
   import { useState, useEffect } from 'react'

   // ... ๊ธฐ์กด ์ฝ”๋“œ ...

   useEffect(() => {
     fetchCharacters()
   }, [])
   ```

2. ์ข‹์•„์š” ํ•ธ๋“ค๋Ÿฌ ์ถ”๊ฐ€:
   ```jsx
   const handleLike = async (id) => {
     try {
       await api.post(`/characters/${id}/like`)
       fetchCharacters() // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
     } catch (error) {
       console.error('์ข‹์•„์š” ์‹คํŒจ:', error)
     }
   }
   ```

3. ๊ฐค๋Ÿฌ๋ฆฌ UI๋ฅผ `return` ๋ฌธ ๋งˆ์ง€๋ง‰์— ์ถ”๊ฐ€:
   ```jsx
   return (
     <div className="min-h-screen bg-gradient-to-br from-purple-400 to-pink-300 p-8">
       {/* ... ๊ธฐ์กด ์ปค์Šคํ„ฐ๋งˆ์ด์ง• UI ... */}

       {/* ์บ๋ฆญํ„ฐ ๊ฐค๋Ÿฌ๋ฆฌ */}
       <div className="max-w-6xl mx-auto mt-12">
         <h2 className="text-3xl font-bold text-white text-center mb-6">๐Ÿ‘ฅ ์บ๋ฆญํ„ฐ ๊ฐค๋Ÿฌ๋ฆฌ</h2>
         
         {savedCharacters.length === 0 ? (
           <div className="bg-white rounded-lg shadow-xl p-8 text-center text-gray-500">
             ์•„์ง ์ €์žฅ๋œ ์บ๋ฆญํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ์บ๋ฆญํ„ฐ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”! ๐ŸŽจ
           </div>
         ) : (
           <div className="grid md:grid-cols-3 gap-6">
             {savedCharacters.map(char => (
               <div key={char.id} className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition">
                 <div className="text-center mb-4">
                   <div className="text-5xl mb-2">
                     {char.hairstyle === 'short' && '๐Ÿ‘ฆ'}
                     {char.hairstyle === 'long' && '๐Ÿ‘ฉ'}
                     {char.hairstyle === 'curly' && '๐Ÿ‘จโ€๐Ÿฆฑ'}
                     {char.hairstyle === 'bald' && '๐Ÿ‘จโ€๐Ÿฆฒ'}
                     {char.hairstyle === 'ponytail' && '๐Ÿ‘ฑโ€โ™€๏ธ'}
                   </div>
                   <h3 className="text-xl font-bold text-gray-800">{char.nickname}</h3>
                 </div>
                 
                 <div className="text-sm text-gray-600 space-y-1 mb-4">
                   <p>๐ŸŽจ {char.hairstyle} / {char.skin_color}</p>
                   <p>๐Ÿ‘• {char.top} + {char.bottom}</p>
                   <p>โœจ {char.accessory}</p>
                 </div>

                 <button
                   onClick={() => handleLike(char.id)}
                   className="w-full bg-pink-500 text-white py-2 rounded-lg font-bold hover:bg-pink-600 transition flex items-center justify-center gap-2"
                 >
                   โค๏ธ ์ข‹์•„์š” {char.likes}
                 </button>
               </div>
             ))}
           </div>
         )}
       </div>
     </div>
   )
   ```

**ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค:**

1. ์บ๋ฆญํ„ฐ๋ฅผ ์—ฌ๋Ÿฌ ๊ฐœ ๋งŒ๋“ค์–ด ์ €์žฅ
2. ๊ฐค๋Ÿฌ๋ฆฌ์— ์ €์žฅ๋œ ์บ๋ฆญํ„ฐ๋“ค์ด ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธ
3. ์ข‹์•„์š” ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์ˆซ์ž๊ฐ€ ์ฆ๊ฐ€ํ•˜๋Š”์ง€ ํ™•์ธ
4. ์ข‹์•„์š”๊ฐ€ ๋งŽ์€ ์บ๋ฆญํ„ฐ๊ฐ€ ์ƒ๋‹จ์— ์ •๋ ฌ๋˜๋Š”์ง€ ํ™•์ธ

๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ •์ƒ ์ž‘๋™ํ•˜๋ฉด ์™„์„ฑ์ž…๋‹ˆ๋‹ค! ๐ŸŽ‰

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

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

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

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

๋Œ“๊ธ€ (0)

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