๐ฌ ์ํ ๊ฐ์ ํ๋ ์ด์ - ๋ณธ ์ํ ํ์ & ์ถ์ฒ ๋ฆฌ์คํธ
movie-rating-recommendation-list
๋ฌธ์ ์ค๋ช
ํ๋ก์ ํธ ๊ฐ์
์ํ๋ฅผ ์ข์ํ๋ ์ฌ๋๋ค์ ์ํ ๊ฐ์ธ ์ํ ๊ฐ์ ๊ธฐ๋ก ํ๋ซํผ์ ๋๋ค. ๋ณธ ์ํ์ ํ์ ๊ณผ ๊ฐ๋จํ ์ฝ๋ฉํธ๋ฅผ ๋จ๊ธฐ๊ณ , ๋ณด๊ณ ์ถ์ ์ํ๋ฅผ ์์๋ฆฌ์คํธ์ ์ถ๊ฐํ ์ ์์ต๋๋ค. Laravel API์ React ํ๋ก ํธ์๋๋ฅผ ์ฐ๋ํ์ฌ ํ์คํ ๊ฐ๋ฐ์ ๊ธฐ์ด๋ฅผ ๋ฐฐ์ธ ์ ์๋ ์ ๋ฌธ์์ฉ ํ๋ก์ ํธ์ ๋๋ค.
ํ์ ๊ธฐ๋ฅ
- ์ํ ๋ชฉ๋ก ํ์ด์ง: ๋ด๊ฐ ๋ณธ ์ํ ๋ฆฌ์คํธ๋ฅผ ์นด๋ ํํ๋ก ํ์ (์ ๋ชฉ, ํฌ์คํฐ ์ด๋ฏธ์ง, ํ์ )
- ์ํ ์ถ๊ฐ ํผ: ์ํ ์ ๋ชฉ, ํฌ์คํฐ URL, ์ฅ๋ฅด, ๊ฐ์ ํ์ (1~5์ ), ํ์คํ ์ ๋ ฅ
- ํ์ ํ์: ๋ณ์ ์์ด์ฝ์ผ๋ก ์๊ฐํ (โ โ โ โ โ)
- ์์๋ฆฌ์คํธ: ๋ณด๊ณ ์ถ์ ์ํ๋ฅผ ๋ณ๋ ํญ/ํ์ด์ง๋ก ๊ด๋ฆฌ
- ํํฐ๋ง: ์ฅ๋ฅด๋ณ, ํ์ ๋ณ ํํฐ ๊ธฐ๋ฅ
- ์ญ์ ๊ธฐ๋ฅ: ์ํ ํญ๋ชฉ ์ญ์
์ ํ ์ถ๊ฐ ๊ธฐ๋ฅ (๋ณด๋์ค)
- ์ํ ํฌ์คํฐ ์ด๋ฏธ์ง ์ ๋ก๋ ๊ธฐ๋ฅ (Laravel Storage ํ์ฉ)
- ๊ฐ์์ผ ๊ธฐ๋ก ๋ฐ ์ต์ ์/์ค๋๋์ ์ ๋ ฌ
- ์ด ๊ฐ์ ์ํ ์, ํ๊ท ํ์ ํต๊ณ ํ์
- ๋คํฌ ๋ชจ๋ UI
- ์ํ ๊ฒ์ ๊ธฐ๋ฅ (์ ๋ชฉ ๊ฒ์)
- TMDB API ์ฐ๋ํ์ฌ ์ํ ์ ๋ณด ์๋ ์์ฑ
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x (RESTful API, SQLite)
- ํ๋ก ํธ์๋: React 19 (Vite)
- ์คํ์ผ๋ง: Tailwind CSS
- HTTP ํด๋ผ์ด์ธํธ: Axios
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x ์ต์ ๋ฒ์ ์ฌ์ฉ
- โข React 19 ์ต์ ๋ฒ์ ์ฌ์ฉ
- โข Laravel์ API ์๋ฒ ์ญํ (routes/api.php ์ฌ์ฉ)
- โข React๋ ๋ณ๋ Vite ํ๋ก์ ํธ๋ก ๊ตฌ์ฑ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๊ฐ๋จํ ์ค์ )
- โข CORS ์ค์ ํ์ (Laravel์์ React ์์ฒญ ํ์ฉ)
- โข RESTful API ์ค๊ณ ์์น ์ค์ (GET, POST, DELETE)
- โข React์์ fetch ๋๋ axios๋ก API ํธ์ถ
- โข useState ํ ๋ง ์ฌ์ฉ (๋ณต์กํ ์ํ๊ด๋ฆฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๊ธ์ง)
- โข Tailwind CSS๋ก ๋ฐ์ํ ๋์์ธ ๊ตฌํ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir movie-rating-recommendation-list && cd movie-rating-recommendation-list
์ด ํด๋์์ Laravel 13.x + React 19 ํ์คํ ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค.
**๋ฐฑ์๋ ์ค์ :**
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. ์ํ ๋ชจ๋ธ ๋ฐ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:
```bash
php artisan make:model Movie -m
```
5. `database/migrations/xxxx_create_movies_table.php` ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ์์ :
```php
Schema::create('movies', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('poster_url')->nullable();
$table->string('genre');
$table->integer('rating'); // 1~5์
$table->text('comment')->nullable();
$table->boolean('is_wishlist')->default(false); // ์์๋ฆฌ์คํธ ์ฌ๋ถ
$table->timestamps();
});
```
6. ๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
7. CORS ์ค์ (`config/cors.php`):
```php
'paths' => ['api/*'],
'allowed_origins' => ['http://localhost:5173'],
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
```
8. API ๋ผ์ฐํธ ์ค์ (`routes/api.php`):
```php
use App\Http\Controllers\MovieController;
Route::get('/movies', [MovieController::class, 'index']);
Route::post('/movies', [MovieController::class, 'store']);
Route::delete('/movies/{id}', [MovieController::class, 'destroy']);
```
9. MovieController ์์ฑ ๋ฐ ๊ตฌํ:
```bash
php artisan make:controller MovieController
```
`app/Http/Controllers/MovieController.php`:
```php
namespace App\Http\Controllers;
use App\Models\Movie;
use Illuminate\Http\Request;
class MovieController extends Controller
{
public function index()
{
return Movie::orderBy('created_at', 'desc')->get();
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'poster_url' => 'nullable|url',
'genre' => 'required|string',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'nullable|string',
'is_wishlist' => 'boolean',
]);
return Movie::create($validated);
}
public function destroy($id)
{
Movie::findOrFail($id)->delete();
return response()->json(['message' => 'Movie deleted']);
}
}
```
10. `app/Models/Movie.php`์ fillable ์ถ๊ฐ:
```php
protected $fillable = ['title', 'poster_url', 'genre', 'rating', 'comment', 'is_wishlist'];
```
11. Laravel ๊ฐ๋ฐ ์๋ฒ ์คํ:
```bash
php artisan serve
```
(๊ธฐ๋ณธ ํฌํธ: http://localhost:8000)
๋ฐฑ์๋ API๊ฐ ์ค๋น๋์์ต๋๋ค. ๋ค์ ๋จ๊ณ์์ React ํ๋ก ํธ์๋๋ฅผ ๊ตฌ์ฑํฉ๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel ๋ฐฑ์๋๊ฐ http://localhost:8000์์ ์คํ๋๋ฉฐ, /api/movies GET/POST/DELETE ์๋ํฌ์ธํธ๊ฐ ์๋ํฉ๋๋ค. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ movies ํ ์ด๋ธ์ด ์์ฑ๋๊ณ , CORS๊ฐ ์ค์ ๋์ด React์์ API๋ฅผ ํธ์ถํ ์ ์์ต๋๋ค.
**ํ๋ก ํธ์๋ ์ค์ :**
ํ๋ก์ ํธ ๋ฃจํธ ํด๋(movie-rating-recommendation-list)๋ก ๋์์์ React ํ๋ก ํธ์๋๋ฅผ ์์ฑํฉ๋๋ค.
1. React + Vite ํ๋ก์ ํธ ์์ฑ:
```bash
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install
```
2. Axios ๋ฐ Tailwind CSS ์ค์น:
```bash
npm install axios
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```
3. `tailwind.config.js` ์์ :
```js
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
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'
import axios from 'axios'
import './App.css'
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 axios.get(API_URL)
setMovies(response.data)
} catch (error) {
console.error('Error fetching movies:', error)
} finally {
setLoading(false)
}
}
const renderStars = (rating) => {
return 'โ
'.repeat(rating) + 'โ'.repeat(5 - rating)
}
if (loading) {
return <div className="flex justify-center items-center h-screen text-xl">Loading...</div>
}
return (
<div className="min-h-screen bg-gray-100 py-8">
<div className="max-w-6xl mx-auto px-4">
<header className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">๐ฌ ์ํ ๊ฐ์ ํ๋ ์ด์
</h1>
<p className="text-gray-600">๋ณธ ์ํ ํ์ & ์ถ์ฒ ๋ฆฌ์คํธ</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{movies.length === 0 ? (
<div className="col-span-full text-center text-gray-500 py-12">
์์ง ๋ฑ๋ก๋ ์ํ๊ฐ ์์ต๋๋ค. ์ฒซ ์ํ๋ฅผ ์ถ๊ฐํด๋ณด์ธ์!
</div>
) : (
movies.map((movie) => (
<div key={movie.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow">
{movie.poster_url && (
<img src={movie.poster_url} alt={movie.title} className="w-full h-64 object-cover" />
)}
<div className="p-4">
<h3 className="text-xl font-semibold text-gray-800 mb-2">{movie.title}</h3>
<p className="text-sm text-gray-500 mb-2">์ฅ๋ฅด: {movie.genre}</p>
<div className="text-yellow-500 text-2xl mb-2">{renderStars(movie.rating)}</div>
{movie.comment && (
<p className="text-gray-700 text-sm italic">"{movie.comment}"</p>
)}
{movie.is_wishlist && (
<span className="inline-block mt-2 px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">์์๋ฆฌ์คํธ</span>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
)
}
export default App
```
6. React ๊ฐ๋ฐ ์๋ฒ ์คํ:
```bash
npm run dev
```
(๊ธฐ๋ณธ ํฌํธ: http://localhost:5173)
๋ธ๋ผ์ฐ์ ์์ http://localhost:5173์ ์ด๋ฉด ์ํ ๋ชฉ๋ก์ด ํ์๋ฉ๋๋ค (ํ์ฌ๋ ๋น์ด ์์).
๊ธฐ๋ ๊ฒฐ๊ณผ: React ํ๋ก ํธ์๋๊ฐ http://localhost:5173์์ ์คํ๋๋ฉฐ, Tailwind CSS๊ฐ ์ ์ฉ๋ ์ํ ๋ชฉ๋ก ํ์ด์ง๊ฐ ํ์๋ฉ๋๋ค. Laravel API์์ ์ํ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ ์นด๋ ํํ๋ก ๋ ๋๋งํฉ๋๋ค. ์์ง ์ํ๊ฐ ์์ผ๋ฏ๋ก ๋น ์ํ ๋ฉ์์ง๊ฐ ํ์๋ฉ๋๋ค.
**์ํ ์ถ๊ฐ ํผ ๊ตฌํ:**
์ฌ์ฉ์๊ฐ ์๋ก์ด ์ํ๋ฅผ ์ถ๊ฐํ ์ ์๋ ํผ์ ๋ง๋ญ๋๋ค.
`src/App.jsx` ํ์ผ์ ์์ ํ์ฌ ์ํ ์ถ๊ฐ ํผ๊ณผ POST ์์ฒญ ๋ก์ง์ ์ถ๊ฐํฉ๋๋ค:
```jsx
import { useState, useEffect } from 'react'
import axios from 'axios'
import './App.css'
const API_URL = 'http://localhost:8000/api/movies'
function App() {
const [movies, setMovies] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [formData, setFormData] = useState({
title: '',
poster_url: '',
genre: '',
rating: 3,
comment: '',
is_wishlist: false,
})
useEffect(() => {
fetchMovies()
}, [])
const fetchMovies = async () => {
try {
const response = await axios.get(API_URL)
setMovies(response.data)
} catch (error) {
console.error('Error fetching movies:', error)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
await axios.post(API_URL, formData)
setFormData({ title: '', poster_url: '', genre: '', rating: 3, comment: '', is_wishlist: false })
setShowForm(false)
fetchMovies()
} catch (error) {
console.error('Error adding movie:', error)
alert('์ํ ์ถ๊ฐ์ ์คํจํ์ต๋๋ค.')
}
}
const handleDelete = async (id) => {
if (!confirm('์ด ์ํ๋ฅผ ์ญ์ ํ์๊ฒ ์ต๋๊น?')) return
try {
await axios.delete(`${API_URL}/${id}`)
fetchMovies()
} catch (error) {
console.error('Error deleting movie:', error)
}
}
const renderStars = (rating) => {
return 'โ
'.repeat(rating) + 'โ'.repeat(5 - rating)
}
if (loading) {
return <div className="flex justify-center items-center h-screen text-xl">Loading...</div>
}
return (
<div className="min-h-screen bg-gray-100 py-8">
<div className="max-w-6xl mx-auto px-4">
<header className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">๐ฌ ์ํ ๊ฐ์ ํ๋ ์ด์
</h1>
<p className="text-gray-600">๋ณธ ์ํ ํ์ & ์ถ์ฒ ๋ฆฌ์คํธ</p>
</header>
<div className="text-center mb-6">
<button
onClick={() => setShowForm(!showForm)}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-6 rounded-lg shadow transition"
>
{showForm ? '์ทจ์' : '+ ์ํ ์ถ๊ฐ'}
</button>
</div>
{showForm && (
<div className="bg-white rounded-lg shadow-md p-6 mb-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-semibold mb-4">์ ์ํ ์ถ๊ฐ</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">์ํ ์ ๋ชฉ *</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">ํฌ์คํฐ URL</label>
<input
type="url"
value={formData.poster_url}
onChange={(e) => setFormData({ ...formData, poster_url: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com/poster.jpg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">์ฅ๋ฅด *</label>
<select
required
value={formData.genre}
onChange={(e) => setFormData({ ...formData, genre: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">์ ํํ์ธ์</option>
<option value="์ก์
">์ก์
</option>
<option value="์ฝ๋ฏธ๋">์ฝ๋ฏธ๋</option>
<option value="๋๋ผ๋ง">๋๋ผ๋ง</option>
<option value="SF">SF</option>
<option value="๋ก๋งจ์ค">๋ก๋งจ์ค</option>
<option value="์ค๋ฆด๋ฌ">์ค๋ฆด๋ฌ</option>
<option value="์ ๋๋ฉ์ด์
">์ ๋๋ฉ์ด์
</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">ํ์ *</label>
<div className="flex items-center space-x-2">
<input
type="range"
min="1"
max="5"
value={formData.rating}
onChange={(e) => setFormData({ ...formData, rating: parseInt(e.target.value) })}
className="flex-1"
/>
<span className="text-2xl text-yellow-500">{renderStars(formData.rating)}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">ํ์คํ</label>
<textarea
value={formData.comment}
onChange={(e) => setFormData({ ...formData, comment: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows="3"
placeholder="์ด ์ํ์ ๋ํ ์งง์ ๊ฐ์์ ๋จ๊ฒจ์ฃผ์ธ์"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="wishlist"
checked={formData.is_wishlist}
onChange={(e) => setFormData({ ...formData, is_wishlist: e.target.checked })}
className="mr-2"
/>
<label htmlFor="wishlist" className="text-sm text-gray-700">์์๋ฆฌ์คํธ์ ์ถ๊ฐ (์์ง ์ ๋ณธ ์ํ)</label>
</div>
<button
type="submit"
className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold py-2 rounded-lg transition"
>
์ถ๊ฐํ๊ธฐ
</button>
</form>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{movies.length === 0 ? (
<div className="col-span-full text-center text-gray-500 py-12">
์์ง ๋ฑ๋ก๋ ์ํ๊ฐ ์์ต๋๋ค. ์ฒซ ์ํ๋ฅผ ์ถ๊ฐํด๋ณด์ธ์!
</div>
) : (
movies.map((movie) => (
<div key={movie.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow">
{movie.poster_url && (
<img src={movie.poster_url} alt={movie.title} className="w-full h-64 object-cover" />
)}
<div className="p-4">
<h3 className="text-xl font-semibold text-gray-800 mb-2">{movie.title}</h3>
<p className="text-sm text-gray-500 mb-2">์ฅ๋ฅด: {movie.genre}</p>
<div className="text-yellow-500 text-2xl mb-2">{renderStars(movie.rating)}</div>
{movie.comment && (
<p className="text-gray-700 text-sm italic mb-3">"{movie.comment}"</p>
)}
{movie.is_wishlist && (
<span className="inline-block mb-3 px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">์์๋ฆฌ์คํธ</span>
)}
<button
onClick={() => handleDelete(movie.id)}
className="w-full bg-red-500 hover:bg-red-600 text-white text-sm py-1 rounded transition"
>
์ญ์
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
)
}
export default App
```
์ด์ "+ ์ํ ์ถ๊ฐ" ๋ฒํผ์ ํด๋ฆญํ๋ฉด ํผ์ด ๋ํ๋๊ณ , ์ํ ์ ๋ณด๋ฅผ ์
๋ ฅํ์ฌ ์ถ๊ฐํ ์ ์์ต๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ์ถ๊ฐ ํผ์ด ํ์๋๋ฉฐ, ์ ๋ชฉ/ํฌ์คํฐURL/์ฅ๋ฅด/ํ์ /ํ์คํ/์์๋ฆฌ์คํธ ์ฌ๋ถ๋ฅผ ์ ๋ ฅํ ์ ์์ต๋๋ค. ํผ ์ ์ถ ์ Laravel API๋ก POST ์์ฒญ์ด ์ ์ก๋๊ณ , ์ฑ๊ณตํ๋ฉด ์ํ ๋ชฉ๋ก์ด ์๋ก๊ณ ์นจ๋์ด ์ถ๊ฐ๋ ์ํ๊ฐ ์นด๋๋ก ํ์๋ฉ๋๋ค. ๊ฐ ์ํ ์นด๋์ ์ญ์ ๋ฒํผ์ด ์์ด ํด๋ฆญ ์ ํด๋น ์ํ๊ฐ ์ญ์ ๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!