동네 중고마켓 - 실시간 채팅 & 찜하기
local-secondhand-market-chat
문제 설명
🛒 동네 중고마켓 플랫폼
당근마켓, 번개장터처럼 사용자가 중고 물품을 등록하고 거래할 수 있는 풀스택 플랫폼을 만듭니다. Laravel 13.x + Inertia.js + React 19 모노리스 구조로, 상품 CRUD부터 실시간 채팅(Laravel Reverb), 찜하기, 검색/필터링까지 실제 서비스 수준의 기능을 구현합니다.
필수 기능
- 회원가입/로그인 (Laravel Breeze 기본 인증)
- 상품 등록/수정/삭제 (이미지 업로드 포함)
- 상품 목록 (카드 그리드 레이아웃, 무한 스크롤)
- 상품 상세 페이지 (판매자 정보, 상태 표시)
- 찜하기/찜 목록 (하트 아이콘 토글)
- 실시간 1:1 채팅 (Laravel Reverb + WebSocket)
- 카테고리별 필터링 (전자기기, 의류, 가구, 도서 등)
- 검색 기능 (제목/내용 기반)
- 반응형 디자인 (모바일 퍼스트)
보너스 기능
- 판매 완료 상태 전환
- 내 판매 물품 관리 페이지
- 읽지 않은 채팅 알림 뱃지
- 가격 제안 기능
- 거래 후기 시스템
기술 스택
- Backend: Laravel 13.x (Eloquent, SQLite)
- Frontend: React 19 + Inertia.js 2.x
- Real-time: Laravel Reverb (WebSocket)
- Styling: Tailwind CSS 4.x
- File Upload: Laravel Storage (public disk)
실제 사용자가 물건을 사고파는 경험을 구현하며, WebSocket 기반 실시간 채팅으로 인터랙션을 극대화합니다.
제약 조건
- • Laravel 13.x + Inertia.js 2.x + React 19 모노리스 구조 필수
- • Laravel Breeze React+Inertia starter kit으로 프로젝트 생성
- • 별도 REST API 라우트 없이 Inertia::render()로 데이터 전달
- • Laravel Reverb로 실시간 채팅 구현 (config/broadcasting.php 설정)
- • SQLite 데이터베이스 사용 (마이그레이션 파일로 스키마 정의)
- • 이미지 업로드는 Laravel Storage public disk 사용
- • Tailwind CSS 4.x로 스타일링 (모바일 퍼스트 반응형)
- • React 컴포넌트는 resources/js/Pages, resources/js/Components 디렉토리에 구성
- • 찜하기는 다대다 관계 (pivot table) 활용
- • 채팅 메시지는 Eloquent 모델로 저장 후 broadcast
- • 검색/필터는 Laravel Eloquent where/orWhere 쿼리 활용
- • 인증은 Laravel Sanctum (Breeze 기본 제공)
프롬프트 레시피
아래 프롬프트를 순서대로 AI에게 보내면 됩니다. 복사 버튼을 눌러 바로 사용하세요.
mkdir local-secondhand-market-chat && cd local-secondhand-market-chat 이 폴더에서 Laravel 13.x + Inertia.js + React 19 프로젝트를 시작합니다. **요구사항:** 1. Laravel Breeze React+Inertia starter kit으로 프로젝트 생성: ```bash composer create-project laravel/laravel . composer require laravel/breeze --dev php artisan breeze:install react npm install npm run build ``` 2. 데이터베이스를 SQLite로 설정 (.env 파일에서 DB_CONNECTION=sqlite, database/database.sqlite 파일 생성) 3. 마이그레이션 실행 (php artisan migrate) 4. 개발 서버 실행 확인 (php artisan serve, npm run dev) 5. 브라우저에서 http://localhost:8000 접속 시 Breeze 기본 페이지(로그인/회원가입) 표시 확인 **추가 설정:** - Tailwind CSS 4.x가 Breeze에 포함되어 있으므로 별도 설치 불필요 - Inertia.js 2.x도 Breeze에 포함됨 - resources/js/Pages 디렉토리에 React 페이지 컴포넌트가 있는지 확인 모든 설정이 완료되면 "Laravel Breeze + Inertia + React 프로젝트 생성 완료" 메시지를 출력하세요.
기대 결과: Laravel 13.x + Breeze + Inertia.js + React 19 프로젝트가 생성되고, SQLite 데이터베이스 연결 및 기본 인증 페이지가 작동하는 상태. http://localhost:8000에서 로그인/회원가입 페이지 확인 가능.
**상품(Product) 모델 및 마이그레이션 생성**
중고마켓의 핵심인 상품 데이터를 저장할 구조를 만듭니다.
1. Product 모델과 마이그레이션 생성:
```bash
php artisan make:model Product -m
```
2. 마이그레이션 파일 (database/migrations/xxxx_create_products_table.php) 스키마:
- id (primary key)
- user_id (외래키, users 테이블 참조 - 판매자)
- title (string, 상품 제목)
- description (text, 상품 설명)
- price (integer, 가격)
- category (string, 카테고리: 전자기기/의류/가구/도서/기타)
- status (string, 판매중/예약중/판매완료, 기본값: 판매중)
- image_path (string, nullable, 이미지 경로)
- timestamps (created_at, updated_at)
- 외래키 제약: user_id는 users.id 참조, onDelete('cascade')
3. Product 모델 (app/Models/Product.php):
- fillable 속성: ['user_id', 'title', 'description', 'price', 'category', 'status', 'image_path']
- belongsTo 관계: user() - User 모델과 연결
- belongsToMany 관계: favoritedByUsers() - User 모델과 다대다 (찜하기용, 나중에 pivot 테이블 생성)
4. User 모델 (app/Models/User.php)에 관계 추가:
- hasMany 관계: products() - Product 모델
- belongsToMany 관계: favoriteProducts() - Product 모델 (찜한 상품)
5. 마이그레이션 실행:
```bash
php artisan migrate
```
완료 후 "Product 모델 및 마이그레이션 생성 완료" 메시지 출력.
기대 결과: products 테이블이 데이터베이스에 생성되고, Product 모델이 User와 관계 설정된 상태. php artisan tinker로 Product::count() 실행 시 0 반환 확인.
**상품 등록 기능 구현 (이미지 업로드 포함)**
사용자가 중고 물품을 등록할 수 있는 폼과 컨트롤러를 만듭니다.
1. ProductController 생성:
```bash
php artisan make:controller ProductController
```
2. 라우트 등록 (routes/web.php):
```php
use App\Http\Controllers\ProductController;
Route::middleware('auth')->group(function () {
Route::get('/products/create', [ProductController::class, 'create'])->name('products.create');
Route::post('/products', [ProductController::class, 'store'])->name('products.store');
});
```
3. ProductController 메서드:
- create(): Inertia::render('Products/Create') 반환 (카테고리 목록 전달)
- store(Request $request):
- 검증: title(필수, 최대 100자), description(필수), price(필수, 정수, 최소 0), category(필수, in:전자기기,의류,가구,도서,기타), image(nullable, 이미지 파일, 최대 2MB)
- 이미지 업로드 처리: $request->file('image')->store('products', 'public') (Storage facade 사용)
- Product 생성: auth()->user()->products()->create([...]) (user_id 자동 설정)
- 리다이렉트: redirect()->route('products.index')->with('success', '상품이 등록되었습니다.')
4. React 페이지 컴포넌트 (resources/js/Pages/Products/Create.jsx):
- Inertia useForm 훅 사용:
```jsx
const { data, setData, post, processing, errors } = useForm({
title: '',
description: '',
price: '',
category: '전자기기',
image: null,
});
```
- 폼 필드: title(input), description(textarea), price(input number), category(select - 전자기기/의류/가구/도서/기타), image(file input)
- onSubmit: post(route('products.store')) (multipart/form-data 자동 처리)
- Tailwind로 스타일링 (카드 레이아웃, 폼 요소 간격, 버튼 디자인)
5. storage 링크 생성 (이미지 접근용):
```bash
php artisan storage:link
```
6. 네비게이션에 "상품 등록" 링크 추가 (resources/js/Layouts/AuthenticatedLayout.jsx):
```jsx
<Link href={route('products.create')}>상품 등록</Link>
```
완료 후 로그인한 사용자가 상품 등록 폼에 접근하여 제출 시 데이터베이스에 저장되는지 확인하세요.
기대 결과: 로그인한 사용자가 /products/create 페이지에서 상품 정보와 이미지를 입력하여 등록할 수 있음. 등록 후 products 테이블에 데이터 저장 확인.
**상품 목록 페이지 (카드 그리드 + 무한 스크롤)**
등록된 상품을 카드 형태로 보여주는 메인 페이지를 만듭니다.
1. 라우트 추가 (routes/web.php):
```php
Route::get('/', [ProductController::class, 'index'])->name('products.index');
Route::get('/products', [ProductController::class, 'index']); // 동일 페이지
```
2. ProductController::index() 메서드:
- 상품 목록 조회: Product::with('user')->latest()->paginate(12) (판매자 정보 eager loading)
- Inertia::render('Products/Index', ['products' => $products]) 반환
3. React 페이지 컴포넌트 (resources/js/Pages/Products/Index.jsx):
- props로 products 받기 (Inertia pagination 객체)
- 그리드 레이아웃: grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6
- 각 상품 카드:
- 이미지 (있으면 표시, 없으면 placeholder)
- 제목, 가격 (천 단위 콤마 포맷팅)
- 판매자 이름
- 상태 뱃지 (판매중: 초록, 예약중: 노랑, 판매완료: 회색)
- 카드 클릭 시 상세 페이지로 이동 (Link href={route('products.show', product.id)})
- 무한 스크롤 구현:
- IntersectionObserver 또는 react-infinite-scroll-component 라이브러리 사용
- products.next_page_url이 있으면 다음 페이지 로드
- router.reload() 또는 Inertia.get()으로 추가 데이터 가져오기
- Tailwind로 스타일링 (카드 그림자, 호버 효과, 반응형)
4. 이미지 표시:
- 이미지 경로: /storage/{image_path} (storage 링크 설정됨)
- 없으면 placeholder (예: https://via.placeholder.com/300x200?text=No+Image)
완료 후 메인 페이지에서 등록된 상품들이 카드 형태로 표시되고, 스크롤 시 추가 상품이 로드되는지 확인하세요.
기대 결과: / 경로에서 상품 목록이 카드 그리드로 표시되고, 무한 스크롤로 추가 상품이 로드됨. 각 카드에 이미지, 제목, 가격, 판매자, 상태가 표시됨.
**상품 상세 페이지 & 찜하기 기능**
상품 클릭 시 상세 정보를 보고 찜하기를 할 수 있는 페이지를 만듭니다.
1. 찜하기용 pivot 테이블 마이그레이션 생성:
```bash
php artisan make:migration create_favorites_table
```
- 스키마: user_id, product_id (복합 유니크 인덱스), timestamps
- 외래키: user_id → users.id, product_id → products.id (onDelete cascade)
- 마이그레이션 실행: php artisan migrate
2. User 모델에 favoriteProducts() 관계 추가:
```php
public function favoriteProducts()
{
return $this->belongsToMany(Product::class, 'favorites')->withTimestamps();
}
```
3. Product 모델에 favoritedByUsers() 관계 추가:
```php
public function favoritedByUsers()
{
return $this->belongsToMany(User::class, 'favorites')->withTimestamps();
}
```
4. 라우트 추가 (routes/web.php):
```php
Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
Route::middleware('auth')->group(function () {
Route::post('/products/{product}/favorite', [ProductController::class, 'toggleFavorite'])->name('products.favorite');
});
```
5. ProductController::show(Product $product) 메서드:
- 상품 로드: $product->load('user', 'favoritedByUsers')
- 현재 사용자가 찜했는지 확인: $isFavorited = auth()->check() && auth()->user()->favoriteProducts->contains($product->id)
- Inertia::render('Products/Show', ['product' => $product, 'isFavorited' => $isFavorited])
6. ProductController::toggleFavorite(Product $product) 메서드:
- auth()->user()->favoriteProducts()->toggle($product->id) (찜하기/취소 토글)
- return back()
7. React 페이지 컴포넌트 (resources/js/Pages/Products/Show.jsx):
- props로 product, isFavorited 받기
- 레이아웃: 이미지(왼쪽), 정보(오른쪽) - 모바일에서는 세로 배치
- 표시 정보: 제목, 가격, 카테고리, 상태, 설명, 판매자 이름, 등록일
- 찜하기 버튼:
- 하트 아이콘 (isFavorited면 빨간색 채움, 아니면 테두리만)
- 클릭 시 router.post(route('products.favorite', product.id))
- Inertia useForm 또는 router 사용
- 판매자가 본인이면 "수정/삭제" 버튼 표시 (추후 구현)
- 판매자가 타인이면 "채팅하기" 버튼 표시 (다음 단계에서 구현)
- Tailwind로 스타일링 (카드, 버튼, 반응형)
완료 후 상품 상세 페이지에서 찜하기 버튼 클릭 시 토글되고, favorites 테이블에 레코드가 생성/삭제되는지 확인하세요.
기대 결과: 상품 상세 페이지(/products/{id})에서 이미지, 정보, 찜하기 버튼이 표시되고, 찜하기 클릭 시 하트 색상이 토글되며 데이터베이스에 반영됨.
**실시간 1:1 채팅 기능 (Laravel Reverb + WebSocket)**
상품 상세 페이지에서 판매자와 1:1 채팅을 할 수 있는 기능을 구현합니다.
1. Laravel Reverb 설치 및 설정:
```bash
php artisan install:broadcasting
```
- .env 파일에서 BROADCAST_DRIVER=reverb 설정 확인
- config/broadcasting.php에서 reverb 설정 확인
- Reverb 서버 실행: php artisan reverb:start (별도 터미널)
2. Message 모델 및 마이그레이션 생성:
```bash
php artisan make:model Message -m
```
- 스키마: id, sender_id(외래키, users), receiver_id(외래키, users), product_id(외래키, products), content(text), read_at(timestamp, nullable), timestamps
- 외래키 제약: sender_id/receiver_id → users.id, product_id → products.id (onDelete cascade)
- 마이그레이션 실행
3. Message 모델 (app/Models/Message.php):
- fillable: ['sender_id', 'receiver_id', 'product_id', 'content', 'read_at']
- belongsTo 관계: sender(), receiver(), product()
4. MessageSent 이벤트 생성:
```bash
php artisan make:event MessageSent
```
- implements ShouldBroadcast
- public $message (Message 모델 인스턴스)
- broadcastOn(): PrivateChannel('chat.{receiver_id}')
- broadcastWith(): ['message' => $message->load('sender')]
5. 라우트 추가 (routes/web.php):
```php
Route::middleware('auth')->group(function () {
Route::get('/chat/{product}/{receiver}', [ChatController::class, 'show'])->name('chat.show');
Route::post('/chat/{product}/{receiver}', [ChatController::class, 'send'])->name('chat.send');
});
```
6. ChatController 생성 및 메서드:
```bash
php artisan make:controller ChatController
```
- show(Product $product, User $receiver): 해당 상품 관련 두 사용자 간 메시지 조회, Inertia::render('Chat/Show', ['product' => $product, 'receiver' => $receiver, 'messages' => $messages])
- send(Request $request, Product $product, User $receiver): 메시지 생성 후 MessageSent 이벤트 broadcast, return back()
7. React 페이지 컴포넌트 (resources/js/Pages/Chat/Show.jsx):
- props로 product, receiver, messages 받기
- 채팅 UI: 상단에 상품 정보 미리보기, 메시지 목록(스크롤 가능), 하단에 입력 폼
- 메시지 목록: 내가 보낸 메시지는 오른쪽 정렬(파란색), 받은 메시지는 왼쪽 정렬(회색)
- 입력 폼: useForm으로 content 전송, post(route('chat.send', [product.id, receiver.id]))
- Laravel Echo로 실시간 수신:
```jsx
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: false,
});
useEffect(() => {
Echo.private(`chat.${auth.user.id}`)
.listen('MessageSent', (e) => {
setMessages([...messages, e.message]);
});
return () => Echo.leave(`chat.${auth.user.id}`);
}, [messages]);
```
- Tailwind로 채팅 UI 스타일링 (말풍선, 스크롤, 반응형)
8. 상품 상세 페이지(Products/Show.jsx)에 "채팅하기" 버튼 추가:
- 판매자가 아닌 경우에만 표시
- 클릭 시 Link href={route('chat.show', [product.id, product.user.id])}
9. .env 파일에 Reverb 설정 추가:
```
REVERB_APP_ID=your-app-id
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http
```
완료 후 상품 상세 페이지에서 "채팅하기" 버튼 클릭 시 채팅 페이지로 이동하고, 메시지 전송 시 실시간으로 상대방 화면에 표시되는지 확인하세요 (Reverb 서버 실행 필수).
기대 결과: 상품 상세 페이지에서 "채팅하기" 버튼 클릭 시 1:1 채팅 페이지로 이동하고, 메시지 전송 시 WebSocket을 통해 실시간으로 상대방 화면에 표시됨. Reverb 서버가 실행 중이어야 함.
**검색 & 카테고리 필터링 기능**
상품 목록 페이지에 검색창과 카테고리 필터를 추가하여 사용자가 원하는 상품을 쉽게 찾을 수 있도록 합니다.
1. ProductController::index() 메서드 수정:
- 검색어(search), 카테고리(category), 상태(status) 쿼리 파라미터 받기
- Eloquent 쿼리:
```php
$query = Product::with('user')->latest();
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('title', 'like', '%'.$request->search.'%')
->orWhere('description', 'like', '%'.$request->search.'%');
});
}
if ($request->category) {
$query->where('category', $request->category);
}
if ($request->status) {
$query->where('status', $request->status);
}
$products = $query->paginate(12)->withQueryString(); // 쿼리 파라미터 유지
```
- Inertia::render()에 filters도 전달: ['products' => $products, 'filters' => $request->only(['search', 'category', 'status'])]
2. React 페이지 컴포넌트 (resources/js/Pages/Products/Index.jsx) 수정:
- props로 filters 받기
- 검색창 추가:
- input type="text" value={filters.search || ''}
- onChange 시 router.get(route('products.index'), { search: value }, { preserveState: true })
- debounce 적용 (lodash.debounce 또는 useEffect로 500ms 지연)
- 카테고리 필터 버튼:
- 전체/전자기기/의류/가구/도서/기타 버튼
- 클릭 시 router.get(route('products.index'), { category: value })
- 현재 선택된 카테고리는 강조 표시 (bg-blue-500 text-white)
- 상태 필터 버튼:
- 전체/판매중/예약중/판매완료 버튼
- 클릭 시 router.get(route('products.index'), { status: value })
- 필터 초기화 버튼:
- 클릭 시 router.get(route('products.index')) (모든 필터 제거)
- Tailwind로 스타일링 (검색창, 필터 버튼 그룹, 반응형)
3. 검색 결과 없을 때 메시지 표시:
- products.data.length === 0이면 "검색 결과가 없습니다." 표시
완료 후 상품 목록 페이지에서 검색어 입력 시 실시간으로 필터링되고, 카테고리/상태 버튼 클릭 시 해당 상품만 표시되는지 확인하세요.
기대 결과: 상품 목록 페이지 상단에 검색창과 카테고리/상태 필터 버튼이 표시되고, 검색어 입력 또는 필터 선택 시 URL 쿼리 파라미터가 변경되며 상품 목록이 실시간으로 필터링됨.
**내 찜 목록 & 내 판매 물품 페이지**
사용자가 찜한 상품과 본인이 등록한 상품을 관리할 수 있는 페이지를 만듭니다.
1. 라우트 추가 (routes/web.php):
```php
Route::middleware('auth')->group(function () {
Route::get('/favorites', [ProductController::class, 'favorites'])->name('products.favorites');
Route::get('/my-products', [ProductController::class, 'myProducts'])->name('products.my');
Route::patch('/products/{product}/status', [ProductController::class, 'updateStatus'])->name('products.updateStatus');
});
```
2. ProductController::favorites() 메서드:
- 현재 사용자가 찜한 상품 조회: auth()->user()->favoriteProducts()->with('user')->latest('favorites.created_at')->paginate(12)
- Inertia::render('Products/Favorites', ['products' => $products])
3. ProductController::myProducts() 메서드:
- 현재 사용자가 등록한 상품 조회: auth()->user()->products()->latest()->paginate(12)
- Inertia::render('Products/MyProducts', ['products' => $products])
4. ProductController::updateStatus(Request $request, Product $product) 메서드:
- 권한 확인: if ($product->user_id !== auth()->id()) abort(403)
- 검증: $request->validate(['status' => 'required|in:판매중,예약중,판매완료'])
- 상태 업데이트: $product->update(['status' => $request->status])
- return back()->with('success', '상태가 변경되었습니다.')
5. React 페이지 컴포넌트 (resources/js/Pages/Products/Favorites.jsx):
- Index.jsx와 유사한 카드 그리드 레이아웃
- 각 카드에 "찜 해제" 버튼 추가 (하트 아이콘 클릭 시 router.post(route('products.favorite', product.id)))
- 찜한 상품이 없으면 "찜한 상품이 없습니다." 메시지 표시
6. React 페이지 컴포넌트 (resources/js/Pages/Products/MyProducts.jsx):
- Index.jsx와 유사한 카드 그리드 레이아웃
- 각 카드에 상태 변경 드롭다운 추가:
- select 요소로 판매중/예약중/판매완료 선택
- onChange 시 router.patch(route('products.updateStatus', product.id), { status: value })
- "수정" 버튼 추가 (Link href={route('products.edit', product.id)}) - 다음 단계에서 구현
- 등록한 상품이 없으면 "등록한 상품이 없습니다." 메시지 표시
7. 네비게이션에 링크 추가 (resources/js/Layouts/AuthenticatedLayout.jsx):
- "내 찜 목록" 링크: Link href={route('products.favorites')}
- "내 판매 물품" 링크: Link href={route('products.my')}
완료 후 로그인한 사용자가 찜한 상품 목록과 본인이 등록한 상품 목록을 확인하고, 판매 상태를 변경할 수 있는지 확인하세요.
기대 결과: /favorites 페이지에서 찜한 상품 목록이 표시되고, /my-products 페이지에서 본인이 등록한 상품 목록과 상태 변경 드롭다운이 표시됨. 상태 변경 시 데이터베이스에 반영됨.
제출된 작품 (0)
로그인 후 제출아직 제출된 작품이 없습니다. 첫 번째 도전자가 되어보세요!