๐ฌ ์ํ ๊ด๋ ์ธ์ฆ์ท ๊ฐค๋ฌ๋ฆฌ - ํฐ์ผ ์ฌ์ง์ผ๋ก ๋ง๋๋ ๋๋ง์ ๋ฌด๋น๋ก๊ทธ
movie-ticket-photo-gallery
๋ฌธ์ ์ค๋ช
ํ๋ก์ ํธ ๊ฐ์
์ํ๊ด์์ ๋ฐ์ ํฐ์ผ์ ์ฌ์ง์ผ๋ก ์ฐ์ด ์ฌ๋ฆฌ๊ณ , ํจ๊ป ๋ณธ ์ํ ํฌ์คํฐ์ ๊ฐ๋จํ ํ์คํ์ ๋จ๊ธฐ๋ ๋ฌด๋น๋ก๊ทธ ๊ฐค๋ฌ๋ฆฌ๋ฅผ ๋ง๋ญ๋๋ค. ์ํ ํฌ๋ค์ด ์์ ์ ๊ด๋ ๊ธฐ๋ก์ ์๊ฐ์ ์ผ๋ก ์์นด์ด๋ธํ๊ณ , ๋ค๋ฅธ ์ฌ๋๋ค์ ์ทจํฅ์ ๊ตฌ๊ฒฝํ ์ ์๋ ์๋น์ค์ ๋๋ค.
ํ์ ๊ธฐ๋ฅ
- โ ํฐ์ผ ์ฌ์ง ์ ๋ก๋: ์ํ ํฐ์ผ์ ์ฐ์ ์ฌ์ง์ ์ ๋ก๋ํ๊ณ ์ํ ์ ๋ชฉ, ๊ด๋์ผ, ํ์คํ ์ ๋ ฅ
- โ ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋ ๋ทฐ: ์ ๋ก๋๋ ํฐ์ผ ์ฌ์ง๋ค์ ๊ทธ๋ฆฌ๋ ํํ๋ก ๋ณด๊ธฐ ์ข๊ฒ ๋์ด (์นด๋ ๋ ์ด์์)
- โ ์์ธ ๋ชจ๋ฌ: ํฐ์ผ ์นด๋ ํด๋ฆญ ์ ํฐ ์ด๋ฏธ์ง์ ์์ธ ์ ๋ณด(์ํ ์ ๋ชฉ, ๊ด๋์ผ, ํ์คํ, ์ ๋ก๋ ๋ ์ง) ํ์
- โ ๊ฐ๋จํ ํํฐ: ์ต์ ์/์ค๋๋์ ์ ๋ ฌ, ์ํ ์ ๋ชฉ ๊ฒ์
- โ ์ข์์ ๊ธฐ๋ฅ: ๋ง์์ ๋๋ ๋ฌด๋น๋ก๊ทธ์ ํํธ ๋๋ฅด๊ธฐ (์นด์ดํธ ํ์)
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: 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์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
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 ์ค์ ๊ณผ ์ฌ๋ณผ๋ฆญ ๋งํฌ๊ฐ ์๋ฃ๋ฉ๋๋ค.
## 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ํ๋ ๊ธฐ๋ณธ ๊ตฌ์กฐ๊ฐ ์์ฑ๋ฉ๋๋ค.
## ํฐ์ผ ์
๋ก๋ ํผ & ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋ 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์ด
์
๋ก๋ ํผ์ ํ์ด์ง ์๋จ์ ๋ฐฐ์นํ๊ณ , ๊ทธ ์๋์ ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋๋ฅผ ํ์ํ์ธ์. ํผ ์ ์ถ ์ ์๋ก์ด ๋ฌด๋น๋ก๊ทธ๊ฐ ๊ฐค๋ฌ๋ฆฌ์ ์ฆ์ ๋ฐ์๋๋๋ก ๊ตฌํํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ํฐ์ผ ์ฌ์ง์ ์ ๋ก๋ํ ์ ์๋ ํผ์ด ์์ฑ๋๊ณ , ์ ๋ก๋๋ ๋ฌด๋น๋ก๊ทธ๋ค์ด ๋ฐ์ํ ๊ทธ๋ฆฌ๋ ์นด๋ ๋ ์ด์์์ผ๋ก ํ์๋ฉ๋๋ค. ์ํ ์ ๋ชฉ ๊ฒ์๊ณผ ์ต์ ์/์ค๋๋์ ์ ๋ ฌ์ด ์๋ํ๋ฉฐ, ๊ฐ ์นด๋์ ํฐ์ผ ์ด๋ฏธ์ง, ์ํ ์ ๋ชฉ, ๊ด๋์ผ, ํ์คํ, ์ข์์ ์๊ฐ ํ์๋ฉ๋๋ค.
## ์์ธ ๋ชจ๋ฌ & ์ข์์ ๊ธฐ๋ฅ ๊ตฌํ
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)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!