포스트

[이제와서 시작하는 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. 기초 (#1-3)
    • Next.js 기본 개념
    • 라우팅 시스템
    • Server/Client Components
  2. 데이터 (#4-5)
    • 캐싱과 최적화
    • Server Actions
  3. 최적화 (#6-8)
    • 이미지/폰트
    • 고급 라우팅
    • Proxy.ts
  4. 백엔드 (#9-10)
    • 인증/권한
    • 데이터베이스
  5. 운영 (#11-14)
    • 배포/CI/CD
    • 테스팅
    • Turbopack & React Compiler
  6. 실전 (#15)
    • 풀스택 프로젝트

🎓 다음 단계

  1. 심화 학습
    • 실시간 기능 (WebSocket)
    • 마이크로프론트엔드
    • 국제화 (i18n)
  2. 실전 프로젝트
    • 이커머스
    • SaaS 플랫폼
    • 소셜 미디어
  3. 커뮤니티
    • Next.js Discord
    • GitHub Discussions
    • 오픈소스 기여

📚 전체 시리즈

  1. #1 처음 시작하는 Next.js
  2. #2 App Router와 라우팅 시스템
  3. #3 Server Components와 데이터 페칭
  4. #4 캐싱과 성능 최적화
  5. #5 Server Actions과 폼 처리
  6. #6 이미지, 폰트, 메타데이터 최적화
  7. #7 동적 라우팅과 고급 패턴
  8. #8 Proxy.ts와 네트워크 제어
  9. #9 인증과 권한 관리
  10. #10 데이터베이스 연동 실전
  11. #11 배포 전략과 CI/CD
  12. #12 테스팅으로 안정성 확보하기
  13. #13 Turbopack으로 개발 속도 향상
  14. #14 React Compiler와 성능 최적화
  15. #15 실전 프로젝트: 풀스택 블로그 플랫폼 (현재)

“이제와서 시작했지만, 이제는 Next.js 마스터!” - 15편 완주를 진심으로 축하드립니다! 🎊

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.