[이제와서 시작하는 Next.js 마스터하기 #15] 실전 프로젝트: 풀스택 블로그 플랫폼
“배운 것을 모두 모아서, 작게 끝까지 완성해봅니다.” - 풀스택 블로그 플랫폼을 MVP부터 운영 체크까지 설계합니다.
🎯 프로젝트 개요
만들 것: 인증, 글 작성, 공개 목록, 댓글, 관리자 검수까지 갖춘 블로그 플랫폼 MVP
📚 이 포스트의 학습 방식
이 포스트는 프로젝트 설계와 아키텍처 가이드입니다.
포함 내용:
- ✅ 프로젝트 구조 설계
- ✅ 데이터베이스 스키마
- ✅ 핵심 기능 구현 예제 (인증, CRUD)
- ✅ 파일 구조와 폴더 구성
- ✅ Best Practices
- ✅ 완성 기준과 배포 전 점검표
실제 구현:
- 각 기능의 상세 구현은 이전 포스트들(#1-14)의 내용을 조합합니다
- 예시: 인증(#9) + 데이터베이스(#10) + Server Actions(#5)
- 완전한 소스코드는 GitHub 저장소에서 확인 가능
학습 목표: “어떻게 구성하고 어디까지 만들면 완성인가”를 배우고, 실제 구현은 이전 포스트의 패턴을 활용
예상 소요 시간: 40분 (읽기) + 프로젝트 구현은 개인 속도에 따라 5-10시간
주요 기능
| 범위 | 기능 | 완료 기준 |
|---|---|---|
| 인증 | 이메일/OAuth 로그인 | 보호 페이지 접근 시 로그인으로 이동 |
| 글 관리 | 포스트 CRUD, 임시저장/게시 | 작성자/관리자만 수정 가능 |
| 공개 페이지 | 목록, 상세, 태그 | published 글만 노출 |
| 댓글 | 댓글 작성/삭제 | 로그인 사용자만 작성, 작성자/관리자만 삭제 |
| 관리자 | 글/댓글 검수 | 관리자 role 확인 후 접근 |
| 검색 | 제목/요약/본문 검색 | 빈 검색어, 결과 없음 상태 처리 |
| SEO | metadata, sitemap, OG | 글별 title/description 생성 |
처음부터 모든 기능을 “완벽하게” 만들려고 하면 프로젝트가 끝나지 않습니다. 1차 목표는 작지만 배포 가능한 블로그입니다. 이미지 업로드, 실시간 알림, 고급 에디터는 MVP 이후로 미루어도 됩니다.
🧱 아키텍처 원칙
최종 프로젝트에서는 기능보다 경계가 중요합니다.
| 계층 | 책임 | 예시 |
|---|---|---|
app/ | 라우팅, 페이지, layout, metadata | 목록/상세/관리자 화면 |
app/actions/ | 사용자 입력으로 발생하는 서버 변경 | 글 생성, 댓글 작성 |
lib/ | 권한, 데이터 접근, 유틸리티 | requireUser, getPostForUser |
components/ | 재사용 UI | PostCard, MarkdownEditor |
prisma/ | 스키마와 마이그레이션 | User, Post, Comment |
좋은 기준은 단순합니다.
- 페이지는 데이터를 보여주는 데 집중한다.
- Server Action은 입력 검증과 변경 작업을 맡는다.
- 데이터 접근 함수는 권한 조건과
select를 포함한다. - Client Component는 에디터, 폼 상태, 토글처럼 상호작용이 필요한 곳에만 쓴다.
- 인증/권한 로직은 화면마다 복사하지 말고 공통 함수로 모은다.
📁 프로젝트 구조
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[]
category Category? @relation(fields: [categoryId], references: [id])
categoryId String?
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[]
}
model Category {
id String @id @default(cuid())
name String @unique
posts Post[]
}
enum Role {
USER
ADMIN
}
실전에서는 스키마도 보안 설계의 일부입니다.
published로 공개/비공개 글을 명확히 나눈다.authorId,postId를 기준으로 권한 조건을 걸 수 있게 둔다.slug는 unique로 잡아 URL 충돌을 막는다.- 댓글에는 moderation 상태가 필요하면
approved Boolean @default(false)를 추가한다. - 태그/카테고리는 처음에는 단순하게 두고, 필요해질 때 정렬/설명/색상 필드를 추가한다.
🔑 핵심 기능 구현
1. 포스트 작성
먼저 Server Action부터 만듭니다. 폼은 조작될 수 있으므로 서버에서 title/content/published를 다시 검증해야 합니다.
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
// app/actions/posts.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { requireRole } from '@/lib/authz';
import { slugify } from '@/lib/slugify';
const postSchema = z.object({
title: z.string().min(2).max(120),
excerpt: z.string().max(240).optional(),
content: z.string().min(20),
published: z.enum(['true', 'false']).default('false'),
});
export async function createPost(formData: FormData) {
const user = await requireRole('ADMIN');
const parsed = postSchema.safeParse({
title: formData.get('title'),
excerpt: formData.get('excerpt') || undefined,
content: formData.get('content'),
published: formData.get('published') || 'false',
});
if (!parsed.success) {
return { error: '입력값을 다시 확인해주세요' };
}
const post = await prisma.post.create({
data: {
title: parsed.data.title,
slug: slugify(parsed.data.title),
excerpt: parsed.data.excerpt,
content: parsed.data.content,
published: parsed.data.published === 'true',
authorId: user.id,
},
select: { slug: true },
});
redirect(`/blog/${post.slug}`);
}
이제 폼은 이 action을 호출하는 얇은 UI가 됩니다.
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>
);
}
상세 페이지에서 주의할 점은 두 가지입니다.
- 공개 페이지에서는
where: { slug, published: true }조건을 우선 검토한다. - 관리자 미리보기라면 별도 route나 권한 확인을 둔다.
ReactMarkdown을 사용할 때는 XSS도 고려해야 합니다. 사용자 입력 Markdown을 그대로 HTML로 허용하지 말고, 필요한 플러그인만 선택하고 sanitize 전략을 세우세요.
✅ 완성 기준
최종 프로젝트는 기능을 “많이” 넣는 것보다, 아래 시나리오가 끝까지 통과하는지가 중요합니다.
사용자 시나리오
- 방문자는 공개 글 목록과 상세 글을 볼 수 있다.
- 로그인하지 않은 사용자가 댓글을 쓰려고 하면 로그인 페이지로 이동한다.
- 로그인 사용자는 댓글을 작성할 수 있다.
- 관리자는 글을 작성하고 임시저장/게시를 선택할 수 있다.
- 일반 사용자는 관리자 페이지에 접근할 수 없다.
- 존재하지 않는 slug는 404를 보여준다.
기술 체크리스트
npm run build가 통과한다.- 데이터베이스 migration이 재현 가능하다.
.env.example에 필요한 환경 변수가 정리되어 있다.- Server Action과 Route Handler에서 권한을 다시 확인한다.
- 목록 페이지는 pagination 또는 limit을 둔다.
- 글 상세 metadata가 title/description을 생성한다.
- Playwright smoke test가 홈, 글 목록, 로그인 redirect를 확인한다.
🚀 배포
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
배포 전에는 아래 항목을 확인하세요.
| 항목 | 확인 방법 |
|---|---|
| 환경 변수 | AUTH_SECRET, OAuth secret, DATABASE_URL이 Production에 있는지 확인 |
| 마이그레이션 | prisma migrate deploy가 CI/CD 또는 배포 전 단계에서 실행되는지 확인 |
| 권한 | 일반 사용자 계정으로 /admin 접근이 막히는지 확인 |
| SEO | 대표 글의 <title>, description, OG 이미지 확인 |
| 오류 페이지 | 없는 slug와 없는 API resource가 404를 반환하는지 확인 |
| 성능 | 목록 페이지가 모든 글을 한 번에 불러오지 않는지 확인 |
| 백업 | DB 백업/복구 절차가 있는지 확인 |
첫 배포는 기능 확장보다 관찰 가능성이 중요합니다. 로그, 에러 추적, analytics, DB 백업을 먼저 붙여두면 이후 기능 추가가 훨씬 편합니다.
🎯 시리즈 완주를 축하합니다! 🎉
배운 내용 총정리
- 기초 (#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
- 오픈소스 기여
- 프로젝트 확장
- 이미지 업로드를 S3/R2 같은 object storage로 분리
- 댓글 신고/승인 workflow 추가
- 태그별 RSS 또는 sitemap 확장
- 관리자용 audit log 추가
- 검색을 PostgreSQL full-text 또는 외부 검색 서비스로 확장
📚 전체 시리즈
- #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 실전 프로젝트: 풀스택 블로그 플랫폼 (현재)
“이제와서 시작했지만, 작게 배포 가능한 서비스를 끝까지 만들 수 있게 됐습니다.” 🎊
