[이제와서 시작하는 Next.js 마스터하기 #15] 실전 프로젝트: 풀스택 블로그 플랫폼
[이제와서 시작하는 Next.js 마스터하기 #15] 실전 프로젝트: 풀스택 블로그 플랫폼
“배운 것을 모두 모아서!” - 완전한 풀스택 블로그 플랫폼을 만들어봅시다!
🎯 프로젝트 개요
만들 것: 완전한 기능의 블로그 플랫폼
📚 이 포스트의 학습 방식
이 포스트는 프로젝트 설계와 아키텍처 가이드입니다.
포함 내용:
- ✅ 프로젝트 구조 설계
- ✅ 데이터베이스 스키마
- ✅ 핵심 기능 구현 예제 (인증, CRUD)
- ✅ 파일 구조와 폴더 구성
- ✅ Best Practices
실제 구현:
- 각 기능의 상세 구현은 이전 포스트들(#1-14)의 내용을 조합합니다
- 예시: 인증(#9) + 데이터베이스(#10) + Server Actions(#5)
- 완전한 소스코드는 GitHub 저장소에서 확인 가능
학습 목표: “어떻게 구성하는가”를 배우고, 실제 구현은 이전 포스트의 패턴을 활용
예상 소요 시간: 40분 (읽기) + 프로젝트 구현은 개인 속도에 따라 5-10시간
주요 기능
- ✅ 사용자 인증 (이메일, OAuth)
- ✅ 포스트 CRUD
- ✅ 마크다운 에디터
- ✅ 댓글 시스템
- ✅ 태그와 카테고리
- ✅ 검색 기능
- ✅ 관리자 대시보드
- ✅ 이미지 업로드
- ✅ SEO 최적화
📁 프로젝트 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
blog-platform/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ └── signup/
│ ├── (main)/
│ │ ├── page.tsx # 홈
│ │ ├── blog/
│ │ │ ├── page.tsx # 포스트 목록
│ │ │ └── [slug]/
│ │ │ └── page.tsx # 포스트 상세
│ │ └── profile/
│ ├── (admin)/
│ │ └── dashboard/
│ ├── api/
│ │ ├── posts/
│ │ ├── comments/
│ │ └── upload/
│ └── actions/
├── components/
├── lib/
├── prisma/
└── public/
🗃️ 데이터베이스 스키마
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
posts Post[]
comments Comment[]
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
tags Tag[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(cuid())
content String
post Post @relation(fields: [postId], references: [id])
postId String
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
}
enum Role {
USER
ADMIN
}
🔑 핵심 기능 구현
1. 포스트 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// app/(admin)/dashboard/new/page.tsx
'use client';
import { useState } from 'react';
import { createPost } from '@/app/actions/posts';
import MarkdownEditor from '@/components/MarkdownEditor';
export default function NewPostPage() {
const [content, setContent] = useState('');
async function handleSubmit(formData: FormData) {
formData.set('content', content);
await createPost(formData);
}
return (
<form action={handleSubmit} className="max-w-4xl mx-auto p-8">
<input
name="title"
placeholder="제목"
required
className="w-full text-3xl font-bold mb-4 p-2 border-b"
/>
<input
name="excerpt"
placeholder="요약"
className="w-full mb-4 p-2 border rounded"
/>
<MarkdownEditor
value={content}
onChange={setContent}
/>
<div className="mt-4 flex gap-4">
<button
type="submit"
name="published"
value="true"
className="px-6 py-2 bg-blue-600 text-white rounded"
>
게시
</button>
<button
type="submit"
name="published"
value="false"
className="px-6 py-2 bg-gray-600 text-white rounded"
>
임시저장
</button>
</div>
</form>
);
}
2. 포스트 목록 (SSR + 페이지네이션)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// app/(main)/blog/page.tsx
import { prisma } from '@/lib/prisma';
import PostCard from '@/components/PostCard';
import Pagination from '@/components/Pagination';
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page = '1' } = await searchParams;
const currentPage = parseInt(page);
const pageSize = 10;
const [posts, total] = await Promise.all([
prisma.post.findMany({
where: { published: true },
include: {
author: {
select: { name: true, email: true },
},
_count: {
select: { comments: true },
},
},
orderBy: { createdAt: 'desc' },
skip: (currentPage - 1) * pageSize,
take: pageSize,
}),
prisma.post.count({ where: { published: true } }),
]);
const totalPages = Math.ceil(total / pageSize);
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">블로그</h1>
<div className="space-y-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<Pagination currentPage={currentPage} totalPages={totalPages} />
</div>
);
}
3. 포스트 상세 + 댓글
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// app/(main)/blog/[slug]/page.tsx
import { prisma } from '@/lib/prisma';
import { notFound } from 'next/navigation';
import ReactMarkdown from 'react-markdown';
import CommentSection from '@/components/CommentSection';
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await prisma.post.findUnique({
where: { slug },
});
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
};
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await prisma.post.findUnique({
where: { slug },
include: {
author: true,
tags: true,
comments: {
include: { author: true },
orderBy: { createdAt: 'desc' },
},
},
});
if (!post) notFound();
return (
<article className="max-w-4xl mx-auto p-8">
<h1 className="text-5xl font-bold mb-4">{post.title}</h1>
<div className="flex gap-4 text-gray-600 mb-8">
<span>{post.author.name}</span>
<span>{new Date(post.createdAt).toLocaleDateString('ko-KR')}</span>
</div>
<div className="prose max-w-none mb-12">
<ReactMarkdown>{post.content}</ReactMarkdown>
</div>
<div className="flex gap-2 mb-12">
{post.tags.map((tag) => (
<span key={tag.id} className="px-3 py-1 bg-gray-200 rounded-full text-sm">
{tag.name}
</span>
))}
</div>
<CommentSection postId={post.id} comments={post.comments} />
</article>
);
}
🚀 배포
1
2
3
4
5
6
7
8
9
10
11
# 1. 환경 변수 설정
cp .env.example .env
# 2. 데이터베이스 마이그레이션
npx prisma migrate deploy
# 3. 빌드
npm run build
# 4. Vercel 배포
vercel --prod
🎯 시리즈 완주를 축하합니다! 🎉
배운 내용 총정리
- 기초 (#1-3)
- Next.js 기본 개념
- 라우팅 시스템
- Server/Client Components
- 데이터 (#4-5)
- 캐싱과 최적화
- Server Actions
- 최적화 (#6-8)
- 이미지/폰트
- 고급 라우팅
- Proxy.ts
- 백엔드 (#9-10)
- 인증/권한
- 데이터베이스
- 운영 (#11-14)
- 배포/CI/CD
- 테스팅
- Turbopack & React Compiler
- 실전 (#15)
- 풀스택 프로젝트
🎓 다음 단계
- 심화 학습
- 실시간 기능 (WebSocket)
- 마이크로프론트엔드
- 국제화 (i18n)
- 실전 프로젝트
- 이커머스
- SaaS 플랫폼
- 소셜 미디어
- 커뮤니티
- Next.js Discord
- GitHub Discussions
- 오픈소스 기여
📚 전체 시리즈
- #1 처음 시작하는 Next.js
- #2 App Router와 라우팅 시스템
- #3 Server Components와 데이터 페칭
- #4 캐싱과 성능 최적화
- #5 Server Actions과 폼 처리
- #6 이미지, 폰트, 메타데이터 최적화
- #7 동적 라우팅과 고급 패턴
- #8 Proxy.ts와 네트워크 제어
- #9 인증과 권한 관리
- #10 데이터베이스 연동 실전
- #11 배포 전략과 CI/CD
- #12 테스팅으로 안정성 확보하기
- #13 Turbopack으로 개발 속도 향상
- #14 React Compiler와 성능 최적화
- #15 실전 프로젝트: 풀스택 블로그 플랫폼 (현재)
“이제와서 시작했지만, 이제는 Next.js 마스터!” - 15편 완주를 진심으로 축하드립니다! 🎊
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.