풀스택 반려동물 산책 기록장
pet-walk-logger-fullstack
문제 설명
🐾 풀스택 반려동물 산책 기록장
반려동물과 함께한 산책을 기록하고 관리하는 웹 애플리케이션입니다. Laravel 13.x로 RESTful API 백엔드를 구축하고, React 19로 사용자 인터페이스를 만듭니다. 프로그래밍 입문자도 AI 도구의 도움으로 풀스택 개발 경험을 쌓을 수 있습니다.
필수 기능
- 반려동물 프로필 등록 (이름, 종류, 나이)
- 산책 기록 추가 (날짜, 시간, 거리, 메모)
- 산책 기록 목록 조회 (최신순 정렬)
- 산책 기록 수정 및 삭제
- 반려동물별 총 산책 거리 통계
- 반응형 디자인 (모바일/데스크톱)
보너스 기능
- 산책 날씨 아이콘 표시 (맑음/흐림/비)
- 주간/월간 산책 통계 차트
- 산책 기록 사진 첨부 기능
- 산책 경로 지도 표시 (구글맵 연동)
기술 스택
- 백엔드: Laravel 13.x (RESTful API)
- 프론트엔드: React 19 (Vite)
- 데이터베이스: SQLite
- 스타일링: Tailwind CSS
- HTTP 클라이언트: Axios
제약 조건
- • Laravel 13.x와 React 19 최신 버전 사용
- • 백엔드와 프론트엔드는 별도 폴더로 분리 (backend/, frontend/)
- • Laravel은 API 전용 (routes/api.php 사용)
- • React는 Vite로 프로젝트 생성
- • CORS 설정 필수 (Laravel에서 프론트엔드 origin 허용)
- • SQLite 데이터베이스 사용 (개발 편의성)
- • RESTful API 설계 원칙 준수 (GET, POST, PUT, DELETE)
- • React에서 useState만 사용 (복잡한 상태관리 라이브러리 금지)
- • Tailwind CSS로 깔끔한 UI 구현
- • 모든 API 응답은 JSON 형식
- • 에러 핸들링 포함 (400, 404, 500 등)
- • 반응형 디자인 필수
프롬프트 레시피
아래 프롬프트를 순서대로 AI에게 보내면 됩니다. 복사 버튼을 눌러 바로 사용하세요.
mkdir pet-walk-logger-fullstack && cd pet-walk-logger-fullstack
이 폴더에서 풀스택 프로젝트를 시작합니다.
# Laravel 13.x API 백엔드 생성
backend 폴더에 Laravel 13.x 프로젝트를 생성해주세요.
```bash
composer create-project laravel/laravel:^13.0 backend
cd backend
```
## 데이터베이스 설정
1. .env 파일을 SQLite로 설정:
```
DB_CONNECTION=sqlite
```
2. database/database.sqlite 파일 생성:
```bash
touch database/database.sqlite
```
## 모델 및 마이그레이션 생성
### Pet 모델 (반려동물)
```bash
php artisan make:model Pet -m
```
`database/migrations/xxxx_create_pets_table.php`:
```php
public function up()
{
Schema::create('pets', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('species'); // 종류 (강아지, 고양이 등)
$table->integer('age')->nullable();
$table->timestamps();
});
}
```
### Walk 모델 (산책 기록)
```bash
php artisan make:model Walk -m
```
`database/migrations/xxxx_create_walks_table.php`:
```php
public function up()
{
Schema::create('walks', function (Blueprint $table) {
$table->id();
$table->foreignId('pet_id')->constrained()->onDelete('cascade');
$table->date('walk_date');
$table->integer('duration_minutes'); // 산책 시간(분)
$table->decimal('distance_km', 5, 2); // 산책 거리(km)
$table->text('memo')->nullable();
$table->timestamps();
});
}
```
## 마이그레이션 실행
```bash
php artisan migrate
```
## CORS 설정
`config/cors.php`에서 프론트엔드 origin 허용:
```php
'paths' => ['api/*'],
'allowed_origins' => ['http://localhost:5173'],
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
```
프로젝트 구조와 데이터베이스가 준비되었는지 확인해주세요.
기대 결과: Laravel 13.x 프로젝트가 backend 폴더에 생성되고, SQLite 데이터베이스에 pets와 walks 테이블이 마이그레이션됨. CORS 설정 완료.
# Laravel API 컨트롤러 및 라우트 구현
## PetController 생성
```bash
php artisan make:controller Api/PetController --api
```
`app/Http/Controllers/Api/PetController.php`:
```php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Pet;
use Illuminate\Http\Request;
class PetController extends Controller
{
public function index()
{
return Pet::with('walks')->get();
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'species' => 'required|string|max:255',
'age' => 'nullable|integer|min:0',
]);
$pet = Pet::create($validated);
return response()->json($pet, 201);
}
public function show(Pet $pet)
{
return $pet->load('walks');
}
public function update(Request $request, Pet $pet)
{
$validated = $request->validate([
'name' => 'string|max:255',
'species' => 'string|max:255',
'age' => 'nullable|integer|min:0',
]);
$pet->update($validated);
return response()->json($pet);
}
public function destroy(Pet $pet)
{
$pet->delete();
return response()->json(null, 204);
}
}
```
## WalkController 생성
```bash
php artisan make:controller Api/WalkController --api
```
`app/Http/Controllers/Api/WalkController.php`:
```php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Walk;
use Illuminate\Http\Request;
class WalkController extends Controller
{
public function index()
{
return Walk::with('pet')->orderBy('walk_date', 'desc')->get();
}
public function store(Request $request)
{
$validated = $request->validate([
'pet_id' => 'required|exists:pets,id',
'walk_date' => 'required|date',
'duration_minutes' => 'required|integer|min:1',
'distance_km' => 'required|numeric|min:0',
'memo' => 'nullable|string',
]);
$walk = Walk::create($validated);
return response()->json($walk->load('pet'), 201);
}
public function show(Walk $walk)
{
return $walk->load('pet');
}
public function update(Request $request, Walk $walk)
{
$validated = $request->validate([
'walk_date' => 'date',
'duration_minutes' => 'integer|min:1',
'distance_km' => 'numeric|min:0',
'memo' => 'nullable|string',
]);
$walk->update($validated);
return response()->json($walk->load('pet'));
}
public function destroy(Walk $walk)
{
$walk->delete();
return response()->json(null, 204);
}
}
```
## 모델 관계 설정
`app/Models/Pet.php`:
```php
public function walks()
{
return $this->hasMany(Walk::class);
}
```
`app/Models/Walk.php`:
```php
protected $fillable = ['pet_id', 'walk_date', 'duration_minutes', 'distance_km', 'memo'];
public function pet()
{
return $this->belongsTo(Pet::class);
}
```
## API 라우트 등록
`routes/api.php`:
```php
use App\Http\Controllers\Api\PetController;
use App\Http\Controllers\Api\WalkController;
Route::apiResource('pets', PetController::class);
Route::apiResource('walks', WalkController::class);
```
## 서버 실행
```bash
php artisan serve
```
API가 http://localhost:8000/api 에서 작동하는지 확인해주세요.
기대 결과: Laravel API 엔드포인트가 완성됨. /api/pets와 /api/walks에서 CRUD 작업 가능. 모델 관계(Pet-Walk)가 설정되어 데이터 연동 준비 완료.
# React 19 프론트엔드 생성
프로젝트 루트로 돌아가서 frontend 폴더에 React 프로젝트를 생성합니다.
```bash
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install
```
## 필요한 패키지 설치
```bash
npm install axios
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```
## 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;
```
## 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;
```
## 기본 컴포넌트 구조 생성
`src/App.jsx`:
```jsx
import { useState } from 'react';
import PetList from './components/PetList';
import WalkList from './components/WalkList';
import AddPetForm from './components/AddPetForm';
import AddWalkForm from './components/AddWalkForm';
function App() {
const [activeTab, setActiveTab] = useState('pets');
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = () => {
setRefreshKey(prev => prev + 1);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="container mx-auto px-4 py-8">
<header className="text-center mb-8">
<h1 className="text-4xl font-bold text-indigo-900 mb-2">🐾 반려동물 산책 기록장</h1>
<p className="text-gray-600">우리 반려동물의 건강한 산책을 기록하세요</p>
</header>
<div className="flex justify-center mb-6">
<div className="bg-white rounded-lg shadow-md p-1 inline-flex">
<button
onClick={() => setActiveTab('pets')}
className={`px-6 py-2 rounded-md transition-colors ${
activeTab === 'pets'
? 'bg-indigo-600 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
반려동물 관리
</button>
<button
onClick={() => setActiveTab('walks')}
className={`px-6 py-2 rounded-md transition-colors ${
activeTab === 'walks'
? 'bg-indigo-600 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
산책 기록
</button>
</div>
</div>
<div className="max-w-4xl mx-auto">
{activeTab === 'pets' ? (
<div>
<AddPetForm onSuccess={handleRefresh} />
<PetList key={refreshKey} onUpdate={handleRefresh} />
</div>
) : (
<div>
<AddWalkForm onSuccess={handleRefresh} />
<WalkList key={refreshKey} onUpdate={handleRefresh} />
</div>
)}
</div>
</div>
</div>
);
}
export default App;
```
빈 컴포넌트 파일들을 생성해주세요:
- `src/components/PetList.jsx`
- `src/components/WalkList.jsx`
- `src/components/AddPetForm.jsx`
- `src/components/AddWalkForm.jsx`
각 파일에 기본 export 구조만 만들어주세요.
기대 결과: React 프로젝트가 frontend 폴더에 생성되고, Tailwind CSS와 Axios가 설치됨. App.jsx에 탭 전환 UI와 기본 레이아웃이 구현됨. 컴포넌트 파일 구조 준비 완료.
# 반려동물 관리 컴포넌트 구현
## AddPetForm 컴포넌트
`src/components/AddPetForm.jsx`:
```jsx
import { useState } from 'react';
import api from '../api/axios';
function AddPetForm({ onSuccess }) {
const [formData, setFormData] = useState({
name: '',
species: '',
age: '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await api.post('/pets', {
...formData,
age: formData.age ? parseInt(formData.age) : null,
});
setFormData({ name: '', species: '', age: '' });
onSuccess();
} catch (err) {
setError(err.response?.data?.message || '반려동물 등록에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-2xl font-bold text-gray-800 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.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="예: 뭉치"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">종류</label>
<input
type="text"
required
value={formData.species}
onChange={(e) => setFormData({ ...formData, species: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="예: 골든 리트리버"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">나이 (선택)</label>
<input
type="number"
min="0"
value={formData.age}
onChange={(e) => setFormData({ ...formData, age: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="예: 3"
/>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 disabled:bg-gray-400 transition-colors"
>
{loading ? '등록 중...' : '반려동물 등록'}
</button>
</form>
</div>
);
}
export default AddPetForm;
```
## PetList 컴포넌트
`src/components/PetList.jsx`:
```jsx
import { useState, useEffect } from 'react';
import api from '../api/axios';
function PetList({ onUpdate }) {
const [pets, setPets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchPets();
}, []);
const fetchPets = async () => {
try {
const response = await api.get('/pets');
setPets(response.data);
} catch (err) {
setError('반려동물 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
await api.delete(`/pets/${id}`);
onUpdate();
} catch (err) {
alert('삭제에 실패했습니다.');
}
};
const getTotalDistance = (walks) => {
return walks.reduce((sum, walk) => sum + parseFloat(walk.distance_km), 0).toFixed(2);
};
if (loading) return <div className="text-center py-8">로딩 중...</div>;
if (error) return <div className="text-red-600 text-center py-8">{error}</div>;
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">등록된 반려동물</h2>
{pets.length === 0 ? (
<p className="text-gray-500 text-center py-8">등록된 반려동물이 없습니다.</p>
) : (
<div className="grid gap-4 md:grid-cols-2">
{pets.map((pet) => (
<div key={pet.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-lg transition-shadow">
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-bold text-indigo-900">{pet.name}</h3>
<button
onClick={() => handleDelete(pet.id)}
className="text-red-600 hover:text-red-800 text-sm"
>
삭제
</button>
</div>
<p className="text-gray-600 mb-1">종류: {pet.species}</p>
{pet.age && <p className="text-gray-600 mb-2">나이: {pet.age}살</p>}
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-sm text-gray-500">총 산책 횟수: {pet.walks?.length || 0}회</p>
<p className="text-sm text-gray-500">총 산책 거리: {getTotalDistance(pet.walks || [])}km</p>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default PetList;
```
컴포넌트가 제대로 작동하는지 확인해주세요. 반려동물을 등록하고 목록에 표시되는지 테스트해주세요.
기대 결과: 반려동물 등록 폼과 목록이 작동함. 반려동물을 추가하면 실시간으로 목록에 표시되고, 삭제 기능도 작동함. 총 산책 통계가 표시됨.
# 산책 기록 컴포넌트 구현
## AddWalkForm 컴포넌트
`src/components/AddWalkForm.jsx`:
```jsx
import { useState, useEffect } from 'react';
import api from '../api/axios';
function AddWalkForm({ onSuccess }) {
const [pets, setPets] = useState([]);
const [formData, setFormData] = useState({
pet_id: '',
walk_date: new Date().toISOString().split('T')[0],
duration_minutes: '',
distance_km: '',
memo: '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetchPets();
}, []);
const fetchPets = async () => {
try {
const response = await api.get('/pets');
setPets(response.data);
} catch (err) {
console.error('반려동물 목록 조회 실패:', err);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await api.post('/walks', {
...formData,
pet_id: parseInt(formData.pet_id),
duration_minutes: parseInt(formData.duration_minutes),
distance_km: parseFloat(formData.distance_km),
});
setFormData({
pet_id: '',
walk_date: new Date().toISOString().split('T')[0],
duration_minutes: '',
distance_km: '',
memo: '',
});
onSuccess();
} catch (err) {
setError(err.response?.data?.message || '산책 기록 추가에 실패했습니다.');
} finally {
setLoading(false);
}
};
if (pets.length === 0) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6">
<p className="text-yellow-800">⚠️ 먼저 반려동물을 등록해주세요.</p>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">🚶 산책 기록 추가</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">반려동물 선택</label>
<select
required
value={formData.pet_id}
onChange={(e) => setFormData({ ...formData, pet_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="">선택하세요</option>
{pets.map((pet) => (
<option key={pet.id} value={pet.id}>
{pet.name} ({pet.species})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">산책 날짜</label>
<input
type="date"
required
value={formData.walk_date}
onChange={(e) => setFormData({ ...formData, walk_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">산책 시간 (분)</label>
<input
type="number"
required
min="1"
value={formData.duration_minutes}
onChange={(e) => setFormData({ ...formData, duration_minutes: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="예: 30"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">산책 거리 (km)</label>
<input
type="number"
required
min="0"
step="0.1"
value={formData.distance_km}
onChange={(e) => setFormData({ ...formData, distance_km: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="예: 2.5"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">메모 (선택)</label>
<textarea
value={formData.memo}
onChange={(e) => setFormData({ ...formData, memo: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
rows="3"
placeholder="오늘 산책 메모를 남겨보세요..."
/>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 disabled:bg-gray-400 transition-colors"
>
{loading ? '기록 중...' : '산책 기록 추가'}
</button>
</form>
</div>
);
}
export default AddWalkForm;
```
## WalkList 컴포넌트
`src/components/WalkList.jsx`:
```jsx
import { useState, useEffect } from 'react';
import api from '../api/axios';
function WalkList({ onUpdate }) {
const [walks, setWalks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchWalks();
}, []);
const fetchWalks = async () => {
try {
const response = await api.get('/walks');
setWalks(response.data);
} catch (err) {
setError('산책 기록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!confirm('이 산책 기록을 삭제하시겠습니까?')) return;
try {
await api.delete(`/walks/${id}`);
onUpdate();
} catch (err) {
alert('삭제에 실패했습니다.');
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
};
if (loading) return <div className="text-center py-8">로딩 중...</div>;
if (error) return <div className="text-red-600 text-center py-8">{error}</div>;
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">산책 기록 목록</h2>
{walks.length === 0 ? (
<p className="text-gray-500 text-center py-8">아직 산책 기록이 없습니다.</p>
) : (
<div className="space-y-4">
{walks.map((walk) => (
<div key={walk.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-lg transition-shadow">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-lg font-bold text-indigo-900">{walk.pet?.name}</h3>
<p className="text-sm text-gray-500">{walk.pet?.species}</p>
</div>
<button
onClick={() => handleDelete(walk.id)}
className="text-red-600 hover:text-red-800 text-sm"
>
삭제
</button>
</div>
<div className="grid grid-cols-3 gap-4 mb-3">
<div>
<p className="text-xs text-gray-500">날짜</p>
<p className="font-semibold">{formatDate(walk.walk_date)}</p>
</div>
<div>
<p className="text-xs text-gray-500">시간</p>
<p className="font-semibold">{walk.duration_minutes}분</p>
</div>
<div>
<p className="text-xs text-gray-500">거리</p>
<p className="font-semibold">{walk.distance_km}km</p>
</div>
</div>
{walk.memo && (
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-sm text-gray-700">{walk.memo}</p>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
export default WalkList;
```
## 개발 서버 실행
프론트엔드 서버를 실행해주세요:
```bash
npm run dev
```
http://localhost:5173 에서 애플리케이션이 작동하는지 확인해주세요. 백엔드(http://localhost:8000)도 함께 실행되어야 합니다.
전체 기능을 테스트해주세요:
1. 반려동물 등록
2. 산책 기록 추가
3. 목록 조회 및 삭제
4. 통계 확인
기대 결과: 산책 기록 추가 폼과 목록이 완성됨. 반려동물을 선택하여 산책 기록을 추가하고, 날짜/시간/거리가 카드 형태로 표시됨. 전체 풀스택 애플리케이션이 작동함.
제출된 작품 (0)
로그인 후 제출아직 제출된 작품이 없습니다. 첫 번째 도전자가 되어보세요!