Next.js 15 App Router 기반의 한국 개발자들을 위한 프로젝트 쇼케이스 플랫폼입니다.
- Frontend: Next.js 15 (App Router), TypeScript, Tailwind CSS
- Database: PostgreSQL + Prisma ORM
- Storage: Supabase Storage
- UI Components: shadcn/ui, Radix UI
- Editor: TipTap (Rich Text Editor)
- Styling: Tailwind CSS, CSS Variables, Dark Mode
git clone <repository-url>
cd vivvers-nextjs
npm install.env.local 파일을 생성하고 다음 변수들을 설정하세요:
# Database
DATABASE_URL="postgresql://username:password@localhost:5432/vivvers"
# Supabase (파일 업로드용)
NEXT_PUBLIC_SUPABASE_URL="your_supabase_project_url"
NEXT_PUBLIC_SUPABASE_ANON_KEY="your_supabase_anon_key"
# Next.js
NEXTAUTH_SECRET="your_nextauth_secret"
NEXTAUTH_URL="http://localhost:3000"# Prisma 클라이언트 생성
npm run db:generate
# 데이터베이스 스키마 적용
npm run db:push
# 또는 마이그레이션 사용
npm run db:migratenpm run devhttp://localhost:3000 에서 애플리케이션을 확인할 수 있습니다.
프로젝트 이미지 업로드와 사용자 아바타 기능을 위해 Supabase Storage 버킷과 정책을 설정해야 합니다.
Supabase 대시보드 > Storage > Buckets에서 다음 버킷들을 생성하세요:
- Bucket name:
avatars - Public bucket: ✅ 체크 (공개 버킷)
- File size limit: 10MB
- Allowed MIME types:
image/*(이미지만 허용)
- Bucket name:
projects - Public bucket: ✅ 체크 (공개 버킷)
- File size limit: 50MB
- Allowed MIME types:
image/*(이미지만 허용)
- Bucket name:
community-posts - Public bucket: ✅ 체크 (공개 버킷)
- File size limit: 20MB
- Allowed MIME types:
image/*(이미지만 허용)
Supabase 대시보드 > SQL Editor에서 다음 모든 정책들을 한 번에 실행하세요:
-- =============================================
-- 기존 정책 삭제 (재실행 시 필요)
-- =============================================
-- Avatar 버킷 정책 삭제
DROP POLICY IF EXISTS "Anyone can view avatars" ON storage.objects;
DROP POLICY IF EXISTS "Users can upload own avatars" ON storage.objects;
DROP POLICY IF EXISTS "Users can update own avatars" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete own avatars" ON storage.objects;
-- Projects 버킷 정책 삭제
DROP POLICY IF EXISTS "Anyone can view projects" ON storage.objects;
DROP POLICY IF EXISTS "Users can upload own projects" ON storage.objects;
DROP POLICY IF EXISTS "Users can update own projects" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete own projects" ON storage.objects;
-- Community Posts 버킷 정책 삭제
DROP POLICY IF EXISTS "Anyone can view community posts" ON storage.objects;
DROP POLICY IF EXISTS "Users can upload own community posts" ON storage.objects;
DROP POLICY IF EXISTS "Users can update own community posts" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete own community posts" ON storage.objects;
-- =============================================
-- Avatar 버킷 정책 (4개)
-- =============================================
-- 정책 1: Avatar 읽기 권한 (모든 사용자)
CREATE POLICY "Anyone can view avatars"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'avatars');
-- 정책 2: Avatar 업로드 권한 (인증된 사용자, 본인 폴더만)
CREATE POLICY "Users can upload own avatars"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- 정책 3: Avatar 업데이트 권한 (본인 파일만)
CREATE POLICY "Users can update own avatars"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- 정책 4: Avatar 삭제 권한 (본인 파일만)
CREATE POLICY "Users can delete own avatars"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- =============================================
-- Projects 버킷 정책 (4개)
-- =============================================
-- 정책 1: 프로젝트 파일 읽기 권한 (모든 사용자)
CREATE POLICY "Anyone can view projects"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'projects');
-- 정책 2: 프로젝트 파일 업로드 권한 (인증된 사용자, 본인 폴더만)
CREATE POLICY "Users can upload own projects"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'projects'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- 정책 3: 프로젝트 파일 업데이트 권한 (본인 파일만)
CREATE POLICY "Users can update own projects"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'projects'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- 정책 4: 프로젝트 파일 삭제 권한 (본인 파일만)
CREATE POLICY "Users can delete own projects"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'projects'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- =============================================
-- Community Posts 버킷 정책 (4개)
-- =============================================
-- 정책 1: 커뮤니티 게시물 읽기 권한 (모든 사용자)
CREATE POLICY "Anyone can view community posts"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'community-posts');
-- 정책 2: 커뮤니티 게시물 업로드 권한 (인증된 사용자, 본인 폴더만)
CREATE POLICY "Users can upload own community posts"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'community-posts'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- 정책 3: 커뮤니티 게시물 업데이트 권한 (본인 파일만)
CREATE POLICY "Users can update own community posts"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'community-posts'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);
-- 정책 4: 커뮤니티 게시물 삭제 권한 (본인 파일만)
CREATE POLICY "Users can delete own community posts"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'community-posts'
AND auth.uid()::text = (string_to_array(name, '/'))[1]
);avatars/ # 버킷 이름
├── {userId}/ # 사용자 UUID
│ ├── avatar-timestamp.webp # 프로필 이미지
│ └── avatar-timestamp2.jpg
└── {otherUserId}/ # 다른 사용자
└── avatar-timestamp.png
projects/ # 버킷 이름
├── {userId}/ # 사용자 UUID
│ ├── project-image-uuid.jpg # 프로젝트 이미지
│ ├── screenshot-uuid.png
│ └── demo-uuid.webp
└── {otherUserId}/ # 다른 사용자
└── project-image-uuid.jpg
community-posts/ # 버킷 이름
├── {userId}/ # 사용자 UUID
│ ├── abc123-uuid.jpg # 커뮤니티 게시물 이미지
│ ├── def456-uuid.png
│ └── ghi789-uuid.webp
예시:
avatars/
├── 6ba7b810-9dad-11d1-80b4-00c04fd430c8/ # 사용자 ID
│ ├── avatar_1640995200000.webp
│ └── profile_pic_1640995300000.jpg
└── 123e4567-e89b-12d3-a456-426614174000/ # 다른 사용자 ID
└── avatar_1640995400000.png
projects/
├── 6ba7b810-9dad-11d1-80b4-00c04fd430c8/ # 사용자 ID
│ ├── project_screenshot_1640995200000.jpg
│ ├── project_demo_1640995300000.png
│ └── project_thumbnail_1640995400000.webp
└── 123e4567-e89b-12d3-a456-426614174000/ # 다른 사용자 ID
└── project_image_1640995500000.jpg
community-posts/
├── 6ba7b810-9dad-11d1-80b4-00c04fd430c8/ # 사용자 ID
│ ├── community_post_1640995200000.jpg
│ └── community_post_1640995400000.png
└── 123e4567-e89b-12d3-a456-426614174000/ # 다른 사용자 ID
└── community_post_1640995500000.webp
- 읽기 권한: 모든 사용자가 다른 사용자의 아바타를 볼 수 있습니다.
- 업로드 권한: 인증된 사용자만 자신의 폴더에 아바타를 업로드할 수 있습니다.
- 수정/삭제 권한: 사용자는 자신의 아바타만 수정하거나 삭제할 수 있습니다.
- 경로 보안: 파일 경로에서 사용자 ID를 추출하여 소유권을 검증합니다.
- 읽기 권한: 모든 사용자(로그인하지 않은 사용자 포함)가 프로젝트 이미지를 볼 수 있습니다.
- 업로드 권한: 인증된 사용자만 자신의 폴더에 프로젝트 파일을 업로드할 수 있습니다.
- 수정/삭제 권한: 사용자는 자신의 프로젝트 파일만 수정하거나 삭제할 수 있습니다.
- 경로 보안: 파일 경로에서 사용자 ID를 추출하여 소유권을 검증합니다.
- 읽기 권한: 모든 사용자가 커뮤니티 게시물 이미지를 볼 수 있습니다.
- 업로드 권한: 인증된 사용자만 자신의 폴더에 커뮤니티 게시물 이미지를 업로드할 수 있습니다.
- 수정/삭제 권한: 사용자는 자신이 업로드한 커뮤니티 게시물 이미지만 수정하거나 삭제할 수 있습니다.
- 경로 보안: 파일 경로에서 사용자 ID를 추출하여 소유권을 검증합니다.
- 로컬 Supabase 사용 시 위 정책들을 동일하게 적용
- 테스트용 더미 데이터로 권한 테스트 권장
- 파일 크기 제한 및 보안 정책 재검토
- CDN 캐싱 설정 고려
- 모니터링 및 로깅 설정
src/
├── app/ # Next.js 15 App Router
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/ # shadcn/ui 컴포넌트
│ ├── forms/ # 폼 관련 컴포넌트
│ ├── project/ # 프로젝트 관련 컴포넌트
│ └── common/ # 공통 컴포넌트
├── hooks/ # 커스텀 훅
│ ├── use-project-upload.ts # 프로젝트 업로드 훅
│ └── ...
├── lib/
│ ├── storage/ # Storage 관련 유틸리티
│ │ ├── project-upload.ts # 프로젝트 업로드 서비스
│ │ └── ...
│ ├── prisma/ # Prisma 설정
│ ├── data/ # Mock 데이터
│ ├── validations/ # Zod 스키마
│ └── utils.ts
├── types/ # TypeScript 타입 정의
└── ...
# 개발 서버 실행
npm run dev
# 프로덕션 빌드
npm run build
# 타입 체크
npm run typecheck
# 린트 체크
npm run lint
# 데이터베이스 관리
npm run db:generate # Prisma 클라이언트 생성
npm run db:push # 스키마를 DB에 적용
npm run db:migrate # 마이그레이션 생성 및 적용
npm run db:studio # Prisma Studio 실행
npm run db:reset # 데이터베이스 리셋import { AvatarUploadButton } from '@/components/onboarding/avatar-upload-button'
import { AvatarCropModal } from '@/components/onboarding/avatar-crop-modal'
function OnboardingForm({ user }) {
const [avatarUrl, setAvatarUrl] = useState('')
const [showCropModal, setShowCropModal] = useState(false)
const handleAvatarUpload = async (croppedBlob: Blob) => {
const result = await replaceAvatarImage(user.id, croppedBlob, avatarUrl)
if (result.success) {
setAvatarUrl(result.url)
}
}
return (
<>
<AvatarUploadButton
src={avatarUrl}
alt="프로필 이미지"
name={user.email}
onFileSelect={(file) => setShowCropModal(true)}
/>
<AvatarCropModal
isOpen={showCropModal}
onCropComplete={handleAvatarUpload}
// ... 기타 props
/>
</>
)
}import { useProjectUpload } from '@/hooks/use-project-upload'
import { supabase } from '@/lib/supabase'
function ProjectUploader({ projectId, userId }) {
const { uploadFiles, isUploading, getUploadedUrls } = useProjectUpload(
supabase,
projectId,
userId
)
const handleUpload = async (files: File[]) => {
const urls = await uploadFiles(files)
console.log('업로드된 URLs:', urls)
// Prisma DB의 Project.images 배열에 URLs 저장
}
return (
<input
type="file"
multiple
accept="image/*"
onChange={(e) => handleUpload(Array.from(e.target.files || []))}
disabled={isUploading}
/>
)
}- 파일 크기 제한: 클라이언트와 서버 양쪽에서 검증
- 파일 타입 제한: 이미지 파일만 허용
- 권한 검증: Supabase RLS로 업로드/삭제 권한 제어
- SQL 인젝션 방지: Prisma ORM 사용으로 자동 방지
- XSS 방지: 적절한 HTML 이스케이핑
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
이 프로젝트는 MIT 라이선스 하에 배포됩니다. 자세한 내용은 LICENSE 파일을 참조하세요.
프로젝트에 대한 문의사항이나 버그 리포트는 GitHub Issues를 통해 제출해주세요.