๐ฌ ์ํ ์์ฒญ ๊ธฐ๋ก ํ์๋ผ์ธ - ๋ณธ ์ํ ํ์คํ ๋ฆฌ with ๊ฐ์ ๋ฉ๋ชจ
movie-watch-history-timeline
๋ฌธ์ ์ค๋ช
๐ฌ ์ํ ์์ฒญ ๊ธฐ๋ก ํ์๋ผ์ธ
์ํ๋ฅผ ๋ณผ ๋๋ง๋ค ๊ธฐ๋กํ๊ณ , ์ธ์ ์ด๋ค ์ํ๋ฅผ ๋ดค๋์ง ํ์๋ผ์ธ์ผ๋ก ํ์ธํ ์ ์๋ ๊ฐ๋จํ ํ์คํ ์ฑ์ ๋๋ค. Laravel 13.x API ๋ฐฑ์๋์ React 19 ํ๋ก ํธ์๋๋ก ๊ตฌ์ฑ๋๋ฉฐ, ์ํ ์ ๋ชฉ, ๊ด๋์ผ, ๋ณ์ , ๊ฐ๋จํ ๊ฐ์ ๋ฉ๋ชจ๋ฅผ ์ ์ฅํ๊ณ ์กฐํํ ์ ์์ต๋๋ค.
ํ์ ๊ธฐ๋ฅ
- ์ํ ๊ธฐ๋ก ์ถ๊ฐ: ์ ๋ชฉ, ๊ด๋์ผ, ๋ณ์ (1~5), ๊ฐ์ ๋ฉ๋ชจ ์ ๋ ฅ ํผ
- ํ์๋ผ์ธ ๋ชฉ๋ก ์กฐํ: ์ต์ ์์ผ๋ก ์ ๋ ฌ๋ ์ํ ์์ฒญ ๊ธฐ๋ก ์นด๋ ๋ฆฌ์คํธ
- ๋ณ์ ์๊ฐํ: ๋ณ ์์ด์ฝ์ผ๋ก ํ์ ํ์ (โญโญโญโญโญ)
- ๋ฐ์ํ ๋์์ธ: ๋ชจ๋ฐ์ผ/๋ฐ์คํฌํฑ ๋ชจ๋ ์ง์
- SQLite DB: ๊ฐ๋จํ ๋ก์ปฌ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ
์ ํ ์ถ๊ฐ ๊ธฐ๋ฅ (๋ณด๋์ค)
- ์ํ ํฌ์คํฐ URL ์ ๋ ฅ ๋ฐ ์ธ๋ค์ผ ํ์
- ์ฅ๋ฅด ํ๊ทธ ์ถ๊ฐ (์ก์ , ๋๋ผ๋ง, ์ฝ๋ฏธ๋ ๋ฑ)
- ๊ธฐ๋ก ์ญ์ ๊ธฐ๋ฅ
- ๋ณ์ ๋ณ ํํฐ๋ง (5์ ๋ง ๋ณด๊ธฐ, 3์ ์ดํ๋ง ๋ณด๊ธฐ ๋ฑ)
- ์๋ณ ํต๊ณ (์ด๋ฒ ๋ฌ ๋ณธ ์ํ ์)
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x (RESTful API)
- ํ๋ก ํธ์๋: React 19 + Vite
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite
- ์คํ์ผ๋ง: Tailwind CSS
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x ์ต์ ๋ฒ์ ์ฌ์ฉ
- โข React 19 ์ต์ ๋ฒ์ ์ฌ์ฉ
- โข ๋ฐฑ์๋์ ํ๋ก ํธ์๋๋ ๋ณ๋ ํด๋๋ก ๋ถ๋ฆฌ (backend/, frontend/)
- โข Laravel์ RESTful API ์๋ฒ๋ก๋ง ๋์ (Blade ํ ํ๋ฆฟ ์ฌ์ฉ ๊ธ์ง)
- โข React๋ Vite ๊ธฐ๋ฐ ํ๋ก์ ํธ๋ก ๊ตฌ์ฑ
- โข fetch ๋๋ axios๋ก API ํธ์ถ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๋ง์ด๊ทธ๋ ์ด์ ํ์)
- โข CORS ์ค์ ํ์ (Laravel์์ React ๋๋ฉ์ธ ํ์ฉ)
- โข ๋ณต์กํ ์ํ๊ด๋ฆฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ ๊ธ์ง (useState๋ง ์ฌ์ฉ)
- โข Tailwind CSS๋ก ์คํ์ผ๋ง (CDN ๋๋ npm ์ค์น)
- โข ๋ฐ์ํ ๋์์ธ ํ์
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir movie-watch-history-timeline && cd movie-watch-history-timeline
์ด ํด๋์์ ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค.
## Step 1: Laravel 13.x API ๋ฐฑ์๋ ์ด๊ธฐ ์ค์
**๋ชฉํ**: Laravel 13.x ํ๋ก์ ํธ๋ฅผ ์์ฑํ๊ณ SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ค์ ํฉ๋๋ค.
### ์์
๋ด์ฉ
1. `backend/` ํด๋์ Laravel 13.x ํ๋ก์ ํธ ์์ฑ
```bash
composer create-project laravel/laravel:^13.0 backend
cd backend
```
2. `.env` ํ์ผ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ SQLite๋ก ๋ณ๊ฒฝ
```
DB_CONNECTION=sqlite
# DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD ์ฃผ์ ์ฒ๋ฆฌ
```
3. `database/database.sqlite` ํ์ผ ์์ฑ (๋น ํ์ผ)
```bash
touch database/database.sqlite
```
4. `movies` ํ
์ด๋ธ ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ์์ฑ
```bash
php artisan make:migration create_movies_table
```
5. ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ์์ฑ (`database/migrations/xxxx_create_movies_table.php`)
```php
Schema::create('movies', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->date('watched_at');
$table->integer('rating'); // 1~5
$table->text('memo')->nullable();
$table->timestamps();
});
```
6. ๋ง์ด๊ทธ๋ ์ด์
์คํ
```bash
php artisan migrate
```
7. CORS ์ค์ (`config/cors.php`)
```php
'paths' => ['api/*'],
'allowed_origins' => ['http://localhost:5173'], // Vite ๊ธฐ๋ณธ ํฌํธ
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
```
### ํ์ธ ๋ฐฉ๋ฒ
- `database/database.sqlite` ํ์ผ์ด ์์ฑ๋๊ณ `movies` ํ
์ด๋ธ์ด ์กด์ฌํ๋์ง ํ์ธ
- `php artisan serve`๋ก ์๋ฒ ์คํ ํ `http://localhost:8000` ์ ์ ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x ํ๋ก์ ํธ๊ฐ backend/ ํด๋์ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ movies ํ ์ด๋ธ ๋ง์ด๊ทธ๋ ์ด์ ์ด ์๋ฃ๋ฉ๋๋ค. CORS ์ค์ ์ด ์๋ฃ๋์ด React ์ฑ์์ API ํธ์ถ์ด ๊ฐ๋ฅํ ์ํ๊ฐ ๋ฉ๋๋ค.
## Step 2: Laravel API ์๋ํฌ์ธํธ ๊ตฌํ
**๋ชฉํ**: ์ํ ๊ธฐ๋ก CRUD API๋ฅผ ๊ตฌํํฉ๋๋ค.
### ์์
๋ด์ฉ
1. Movie ๋ชจ๋ธ ์์ฑ
```bash
cd backend
php artisan make:model Movie
```
2. `app/Models/Movie.php` ์ค์
```php
protected $fillable = ['title', 'watched_at', 'rating', 'memo'];
protected $casts = ['watched_at' => 'date'];
```
3. API ๋ผ์ฐํธ ์ ์ (`routes/api.php`)
```php
use App\Models\Movie;
use Illuminate\Http\Request;
Route::get('/movies', function () {
return Movie::orderBy('watched_at', 'desc')->get();
});
Route::post('/movies', function (Request $request) {
$validated = $request->validate([
'title' => 'required|string|max:255',
'watched_at' => 'required|date',
'rating' => 'required|integer|min:1|max:5',
'memo' => 'nullable|string',
]);
$movie = Movie::create($validated);
return response()->json($movie, 201);
});
```
4. ํ
์คํธ ๋ฐ์ดํฐ ์๋ฉ (์ ํ์ฌํญ)
```bash
php artisan tinker
```
```php
App\Models\Movie::create([
'title' => '์ธํฐ์คํ
๋ผ',
'watched_at' => '2024-01-15',
'rating' => 5,
'memo' => '์๊ฐ๊ณผ ๊ณต๊ฐ์ ๋์ด์ ๊ฐ๋'
]);
```
### ํ์ธ ๋ฐฉ๋ฒ
- `php artisan serve` ์คํ ํ Postman/curl๋ก API ํ
์คํธ
- GET `http://localhost:8000/api/movies` โ ๋น ๋ฐฐ์ด ๋๋ ์๋ฉ ๋ฐ์ดํฐ ๋ฐํ
- POST `http://localhost:8000/api/movies` โ ์ ์ํ ๊ธฐ๋ก ์์ฑ ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel API ์๋ํฌ์ธํธ๊ฐ ์์ฑ๋์ด GET /api/movies (๋ชฉ๋ก ์กฐํ)์ POST /api/movies (๊ธฐ๋ก ์ถ๊ฐ)๊ฐ ์ ์ ์๋ํฉ๋๋ค. ์๋ฉ ๋ฐ์ดํฐ๋ฅผ ์ถ๊ฐํ ๊ฒฝ์ฐ GET ์์ฒญ ์ ๋ฐ์ดํฐ๊ฐ ๋ฐํ๋ฉ๋๋ค.
## Step 3: React 19 ํ๋ก ํธ์๋ ํ๋ก์ ํธ ์์ฑ ๋ฐ API ์ฐ๋
**๋ชฉํ**: React 19 + Vite ํ๋ก์ ํธ๋ฅผ ์์ฑํ๊ณ Laravel API์ ์ฐ๋ํฉ๋๋ค.
### ์์
๋ด์ฉ
1. ํ๋ก์ ํธ ๋ฃจํธ์์ React ํ๋ก์ ํธ ์์ฑ
```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';
const API_URL = 'http://localhost:8000/api/movies';
function App() {
const [movies, setMovies] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMovies();
}, []);
const fetchMovies = async () => {
try {
const response = await fetch(API_URL);
const data = await response.json();
setMovies(data);
} catch (error) {
console.error('Error fetching movies:', error);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-100 py-8 px-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-bold text-center mb-8 text-gray-800">
๐ฌ ์ํ ์์ฒญ ๊ธฐ๋ก ํ์๋ผ์ธ
</h1>
{loading ? (
<p className="text-center text-gray-600">๋ก๋ฉ ์ค...</p>
) : (
<div className="space-y-4">
{movies.length === 0 ? (
<p className="text-center text-gray-500">์์ง ๊ธฐ๋ก์ด ์์ต๋๋ค.</p>
) : (
movies.map((movie) => (
<div key={movie.id} className="bg-white p-6 rounded-lg shadow">
<h3 className="text-xl font-semibold">{movie.title}</h3>
<p className="text-gray-600">{movie.watched_at}</p>
<p className="text-yellow-500">{'โญ'.repeat(movie.rating)}</p>
{movie.memo && <p className="mt-2 text-gray-700">{movie.memo}</p>}
</div>
))
)}
</div>
)}
</div>
</div>
);
}
export default App;
```
6. ๊ฐ๋ฐ ์๋ฒ ์คํ
```bash
npm run dev
```
### ํ์ธ ๋ฐฉ๋ฒ
- `http://localhost:5173` ์ ์
- Laravel API์์ ๊ฐ์ ธ์จ ์ํ ๋ชฉ๋ก์ด ํ๋ฉด์ ํ์๋๋์ง ํ์ธ
- ์๋ฉ ๋ฐ์ดํฐ๊ฐ ์๋ค๋ฉด ์นด๋ ํํ๋ก ๋ ๋๋ง๋จ
๊ธฐ๋ ๊ฒฐ๊ณผ: React ํ๋ก ํธ์๋๊ฐ frontend/ ํด๋์ ์์ฑ๋๊ณ , Tailwind CSS๊ฐ ์ค์ ๋๋ฉฐ, Laravel API๋ก๋ถํฐ ์ํ ๋ชฉ๋ก์ ๊ฐ์ ธ์ ํ๋ฉด์ ํ์ํฉ๋๋ค. ๋ณ์ ์ด โญ ์ด๋ชจ์ง๋ก ์๊ฐํ๋ฉ๋๋ค.
## Step 4: ์ํ ์ถ๊ฐ ํผ ๊ตฌํ ๋ฐ ์์ฑ
**๋ชฉํ**: ์ฌ์ฉ์๊ฐ ์ ์ํ ๊ธฐ๋ก์ ์ถ๊ฐํ ์ ์๋ ํผ์ ๋ง๋ค๊ณ , ์ ์ฒด ๊ธฐ๋ฅ์ ์์ฑํฉ๋๋ค.
### ์์
๋ด์ฉ
1. `src/App.jsx`์ ํผ ์ํ ๋ฐ ์ ์ถ ํจ์ ์ถ๊ฐ
```jsx
const [formData, setFormData] = useState({
title: '',
watched_at: '',
rating: 5,
memo: ''
});
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
fetchMovies(); // ๋ชฉ๋ก ์๋ก๊ณ ์นจ
setFormData({ title: '', watched_at: '', rating: 5, memo: '' }); // ํผ ์ด๊ธฐํ
}
} catch (error) {
console.error('Error adding movie:', error);
}
};
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
```
2. JSX์ ํผ UI ์ถ๊ฐ (h1 ํ๊ทธ ์๋)
```jsx
<form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg shadow mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">์ํ ์ ๋ชฉ</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
required
className="w-full px-3 py-2 border rounded-lg"
placeholder="์: ์ธํฐ์คํ
๋ผ"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">๊ด๋์ผ</label>
<input
type="date"
name="watched_at"
value={formData.watched_at}
onChange={handleChange}
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">๋ณ์ </label>
<select
name="rating"
value={formData.rating}
onChange={handleChange}
className="w-full px-3 py-2 border rounded-lg"
>
{[1, 2, 3, 4, 5].map(num => (
<option key={num} value={num}>
{'โญ'.repeat(num)} ({num}์ )
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1">๊ฐ์ ๋ฉ๋ชจ</label>
<textarea
name="memo"
value={formData.memo}
onChange={handleChange}
className="w-full px-3 py-2 border rounded-lg"
rows="3"
placeholder="์ด ์ํ์ ๋ํ ์๊ฐ์ ์์ ๋กญ๊ฒ ์ ์ด๋ณด์ธ์..."
/>
</div>
</div>
<button
type="submit"
className="mt-4 w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition"
>
๊ธฐ๋ก ์ถ๊ฐ
</button>
</form>
```
3. ๋ ์ง ํฌ๋งท ๊ฐ์ (์ต์
)
- ์ํ ์นด๋์์ `watched_at`๋ฅผ `new Date(movie.watched_at).toLocaleDateString('ko-KR')`๋ก ํ์
### ํ์ธ ๋ฐฉ๋ฒ
- ํผ์์ ์ํ ์ ๋ชฉ, ๊ด๋์ผ, ๋ณ์ , ๋ฉ๋ชจ๋ฅผ ์
๋ ฅ ํ "๊ธฐ๋ก ์ถ๊ฐ" ๋ฒํผ ํด๋ฆญ
- ํ์ด์ง๊ฐ ์๋ก๊ณ ์นจ๋์ง ์๊ณ ํ์๋ผ์ธ์ ์ ์ํ ์นด๋๊ฐ ์ฆ์ ์ถ๊ฐ๋๋์ง ํ์ธ
- Laravel ์๋ฒ์ React ์ฑ์ด ๋ชจ๋ ์คํ ์ค์ธ์ง ํ์ธ (`php artisan serve`, `npm run dev`)
### ์ต์ข
์ฒดํฌ๋ฆฌ์คํธ
- [ ] ์ํ ์ถ๊ฐ ํผ์ด ์ ์ ์๋
- [ ] ํ์๋ผ์ธ์ ์ต์ ์์ผ๋ก ์ ๋ ฌ๋ ์ํ ๋ชฉ๋ก ํ์
- [ ] ๋ณ์ ์ด โญ ์ด๋ชจ์ง๋ก ์๊ฐํ
- [ ] ๋ฐ์ํ ๋์์ธ (๋ชจ๋ฐ์ผ/๋ฐ์คํฌํฑ ๋ชจ๋ ์ง์)
- [ ] CORS ์ค๋ฅ ์์ด API ํต์ ์ฑ๊ณต
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ์ถ๊ฐ ํผ์ด ์์ฑ๋์ด ์ฌ์ฉ์๊ฐ ์ ๋ชฉ, ๊ด๋์ผ, ๋ณ์ , ๋ฉ๋ชจ๋ฅผ ์ ๋ ฅํ๊ณ ์ ์ถํ๋ฉด Laravel API๋ก POST ์์ฒญ์ด ์ ์ก๋๊ณ , ์ฑ๊ณต ์ ํ์๋ผ์ธ์ ์ ์ํ ๊ธฐ๋ก์ด ์ฆ์ ์ถ๊ฐ๋ฉ๋๋ค. ์ ์ฒด ์ฑ์ด ์์ฑ๋์ด ์ค์ฉ์ ์ผ๋ก ์ฌ์ฉ ๊ฐ๋ฅํ ์ํ๊ฐ ๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!