포스트

[이제와서 시작하는 Next.js 마스터하기 #15] 실전 프로젝트: 풀스택 블로그 플랫폼

[이제와서 시작하는 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. 기초 (#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
    • 오픈소스 기여
  4. 프로젝트 확장
    • 이미지 업로드를 S3/R2 같은 object storage로 분리
    • 댓글 신고/승인 workflow 추가
    • 태그별 RSS 또는 sitemap 확장
    • 관리자용 audit log 추가
    • 검색을 PostgreSQL full-text 또는 외부 검색 서비스로 확장

📚 전체 시리즈

  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 실전 프로젝트: 풀스택 블로그 플랫폼 (현재)

“이제와서 시작했지만, 작게 배포 가능한 서비스를 끝까지 만들 수 있게 됐습니다.” 🎊

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