Next.js로 블로그 만들기 #5 SEO와 배포 (마무리)

23 분 소요

지난 시간에는 댓글 기능까지 완성했습니다. 우리 블로그는 이제 글쓰기/보기/분류/검색/댓글까지 모든 핵심 기능을 갖췄어요. 마지막 글에서는 검색 엔진 최적화, sitemap/RSS, Vercel 배포까지 마무리하고, 이 시리즈와 리액트 콘텐츠 전체를 회고하며 정리하겠습니다.

Metadata API — 검색 엔진을 위한 정보

각 페이지에 적절한 <title>, <meta description>, OpenGraph 등이 있어야 검색 엔진과 SNS가 우리 글을 잘 표시해줍니다. Next.js의 Metadata API가 이 작업을 우아하게 처리합니다.

정적 metadata

src/app/layout.js의 metadata는 사이트 전체 기본값입니다.

src/app/layout.js
export const metadata = {
  metadataBase: new URL('https://your-blog.example.com'),
  title: {
    default: '나의 블로그',
    template: '%s | 나의 블로그',
  },
  description: '리액트와 웹 개발에 대한 글들',
  openGraph: {
    type: 'website',
    locale: 'ko_KR',
    url: 'https://your-blog.example.com',
    siteName: '나의 블로그',
  },
};

title.template은 자식 페이지에서 title을 정할 때 자동 적용되는 형식이에요. 자식이 '안녕'을 title로 두면 최종 title이 안녕 | 나의 블로그가 됩니다.

metadataBase는 OG 이미지 같은 절대 URL을 만들 때 기준이 되는 도메인. 배포 도메인으로 바꿔주세요.

동적 metadata — 글 상세 페이지

각 글 페이지의 metadata는 글의 frontmatter를 기반으로 동적으로 생성합니다. generateMetadata 함수를 export하면 됩니다.

src/app/posts/[slug]/page.js (추가)
export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
 
  if (!post) return {};
 
  return {
    title: post.frontmatter.title,
    description: post.frontmatter.description,
    openGraph: {
      title: post.frontmatter.title,
      description: post.frontmatter.description,
      type: 'article',
      publishedTime: post.frontmatter.date,
      tags: post.frontmatter.tags,
    },
  };
}

이제 각 글 페이지가 자기만의 title/description을 갖게 되고, 그게 그대로 검색 결과와 SNS 미리보기에 반영됩니다.

태그 페이지도

src/app/tags/[tag]/page.js (추가)
export async function generateMetadata({ params }) {
  const { tag } = await params;
  const decodedTag = decodeURIComponent(tag);
  return {
    title: `#${decodedTag}`,
    description: `${decodedTag} 태그가 붙은 글들`,
  };
}

목록 페이지는 layout의 default metadata가 그대로 쓰이니 별도 설정 없어도 OK.

Sitemap — 검색 엔진에 페이지 알리기

검색 엔진 크롤러가 사이트의 모든 페이지를 빠짐없이 찾아가도록 sitemap.xml을 제공합니다. App Router에서는 sitemap.js 파일로 자동 생성할 수 있어요.

src/app/sitemap.js:

src/app/sitemap.js
import { getAllPosts, getAllTags } from './lib/posts';
 
const SITE_URL = 'https://your-blog.example.com';
 
export default function sitemap() {
  const posts = getAllPosts();
  const tags = getAllTags();
 
  const staticPages = ['', '/tags', '/search'].map(path => ({
    url: `${SITE_URL}${path}`,
    lastModified: new Date(),
  }));
 
  const postPages = posts.map(post => ({
    url: `${SITE_URL}/posts/${post.slug}`,
    lastModified: new Date(post.frontmatter.date),
  }));
 
  const tagPages = tags.map(({ tag }) => ({
    url: `${SITE_URL}/tags/${encodeURIComponent(tag)}`,
    lastModified: new Date(),
  }));
 
  return [...staticPages, ...postPages, ...tagPages];
}

이 파일이 있으면 /sitemap.xml이 자동으로 동작합니다. Next.js가 함수 반환값을 XML로 변환해주거든요. 빌드해서 결과를 확인하면 표준 sitemap 포맷으로 출력됩니다.

Robots.txt

검색 엔진 크롤러에게 어떤 페이지를 크롤링해도 되는지 알리는 파일입니다.

src/app/robots.js:

src/app/robots.js
const SITE_URL = 'https://your-blog.example.com';
 
export default function robots() {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: `${SITE_URL}/sitemap.xml`,
  };
}

/robots.txt로 자동 노출됩니다. 모든 크롤러가 모든 페이지를 자유롭게 수집할 수 있게 허용하면서, sitemap 위치를 알려주는 표준 설정이에요.

RSS Feed

블로그를 구독하는 사용자를 위한 RSS feed도 만들어봅시다. RSS 리더에서 우리 글을 받아볼 수 있게 해주는 기능이에요.

App Router에는 RSS를 위한 자동 파일 컨벤션이 없으니, route handler로 직접 만듭니다.

src/app/feed.xml/route.js:

src/app/feed.xml/route.js
import { getAllPosts } from '../lib/posts';
 
const SITE_URL = 'https://your-blog.example.com';
const SITE_TITLE = '나의 블로그';
const SITE_DESCRIPTION = '리액트와 웹 개발에 대한 글들';
 
function escapeXml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}
 
export async function GET() {
  const posts = getAllPosts().slice(0, 50);
  const buildDate = new Date().toUTCString();
 
  const items = posts.map(post => `
    <item>
      <title>${escapeXml(post.frontmatter.title)}</title>
      <link>${SITE_URL}/posts/${post.slug}</link>
      <guid isPermaLink="true">${SITE_URL}/posts/${post.slug}</guid>
      <pubDate>${new Date(post.frontmatter.date).toUTCString()}</pubDate>
      <description>${escapeXml(post.frontmatter.description ?? '')}</description>
    </item>
  `).join('\n');
 
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>${escapeXml(SITE_TITLE)}</title>
    <link>${SITE_URL}</link>
    <description>${escapeXml(SITE_DESCRIPTION)}</description>
    <language>ko</language>
    <lastBuildDate>${buildDate}</lastBuildDate>
    <atom:link href="${SITE_URL}/feed.xml" rel="self" type="application/rss+xml" />
    ${items}
  </channel>
</rss>
`;
 
  return new Response(xml, {
    headers: {
      'Content-Type': 'application/rss+xml; charset=utf-8',
    },
  });
}

/feed.xml로 접근하면 RSS 형식의 XML이 반환됩니다.

route.js 파일은 페이지가 아니라 API 엔드포인트를 정의하는 특별한 파일입니다. 우리 사이트의 app/feed.xml/route.ts도 거의 같은 패턴으로 RSS를 제공해요.

레이아웃의 <head>에 RSS 자동 발견 링크도 추가하면 RSS 리더가 알아서 찾아갑니다.

src/app/layout.js (추가)
export const metadata = {
  // ...기존 설정...
  alternates: {
    types: {
      'application/rss+xml': '/feed.xml',
    },
  },
};

빌드해서 확인하기

이제 한 번 production 빌드를 돌려봅시다.

빌드
npm run build

빌드 로그에 각 페이지가 어떻게 생성됐는지(정적/동적), 페이지 크기, 어떤 라우트들이 만들어졌는지가 출력됩니다. /sitemap.xml, /robots.txt, /feed.xml이 라우트에 포함된 게 보일 거예요.

빌드 결과를 로컬에서 띄워보려면:

production 모드 실행
npm start

http://localhost:3000이 production 모드로 동작합니다. dev 모드보다 훨씬 빠르고 가볍게 느껴질 겁니다 — 모든 정적 페이지가 미리 생성됐고 자바스크립트가 최소화돼 있으니까요.

Vercel 배포

이제 진짜로 인터넷에 띄울 차례입니다. Vercel이 Next.js의 공식 호스팅 플랫폼이라 배포가 가장 매끄러워요.

1. GitHub에 코드 푸시

git 셋업
cd my-blog
git init
git add .
git commit -m "Initial blog"

GitHub에 빈 저장소를 만들고 push합니다.

원격 연결 + push
git remote add origin https://github.com/<>/my-blog.git
git branch -M main
git push -u origin main

2. Vercel 가입 + 저장소 연결

https://vercel.com에 GitHub 계정으로 가입하고, "New Project" → 방금 푸시한 저장소를 선택하면 끝입니다.

Vercel은 Next.js 프로젝트를 자동으로 감지해 적절한 빌드 설정을 적용합니다. 추가 설정 없이 첫 배포가 진행돼요.

배포가 끝나면 your-blog.vercel.app 형태의 도메인이 생기고, 그곳에서 우리 블로그가 동작합니다. 인스턴스 살아있는 동안만 댓글이 유지된다는 점은 메모리 저장소 한계 그대로지만, 글 표시는 완벽하게 동작해요.

3. 도메인 연결 (선택)

자기 도메인이 있다면 Vercel 프로젝트 설정의 "Domains" 탭에서 연결할 수 있습니다. DNS 안내가 단계별로 나와서 따라하면 됩니다.

4. 글 추가 워크플로우

이제 새 글을 쓰는 흐름은 이렇게 됩니다.

  1. posts/new-article.mdx 파일 작성
  2. git commit -m "feat(content): publish ..."
  3. git push
  4. Vercel이 자동 감지 → 자동 빌드 → 자동 배포

git push 한 번으로 새 글이 인터넷에 올라가는 셈이에요. 이게 파일 기반 블로그의 큰 매력 중 하나입니다.

환경별 설정 — 개발 vs 프로덕션

지금까지 사이트 URL을 your-blog.example.com으로 하드코딩했는데, 환경에 따라 동적으로 설정하는 게 좋습니다.

.env.local:

.env.local
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Vercel 프로젝트 설정의 Environment Variables에는:

Vercel 환경 변수
NEXT_PUBLIC_SITE_URL=https://your-actual-domain.com

코드에서 사용:

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';

이렇게 분리해두면 dev/preview/production 환경마다 적절한 URL이 적용됩니다.

시리즈 회고 — Next.js로 블로그 만들기

이 시리즈에서 우리는 빈 프로젝트에서 시작해 실제 운영 가능한 수준의 블로그까지 만들었습니다.

#추가된 기능등장한 핵심 패턴/도구
1데이터 모델, 셋업MDX 셋업, frontmatter, 폴더 구조
2글 목록 + 상세Server Component fs 직접 읽기, compileMDX, generateStaticParams
3태그 + 검색동적 폴더 vs searchParams, decodeURIComponent
4댓글Server Actions, useActionState, useFormStatus, bind, revalidatePath
5SEO + 배포Metadata API, sitemap, robots, RSS, Vercel

같은 5편 분량인데 Todo 시리즈와 다른 톤이었죠. Todo는 클라이언트 사이드 패턴 위주였고, 블로그는 서버 사이드(RSC + Actions)가 중심이었습니다. 두 빌드를 모두 따라오신 분은 클라이언트와 서버 양쪽의 실전 패턴이 손에 잡혔을 거예요.

리액트 콘텐츠 31편 전체 회고

이 시리즈를 마치며 리액트 카테고리 전체 그림을 정리해보겠습니다.

A. 리액트 기초 강좌 (#1~#15) — 클라이언트 사이드 펀더멘털

JSX, 컴포넌트, props, state, useEffect, 커스텀 훅, 성능 최적화, 라우팅까지. 이게 이 위에 쌓이는 모든 것의 베이스입니다.

B. 리액트로 Todo 앱 만들기 (#1~#5) — 첫 실전 빌드

기초를 점진적으로 합쳐 작은 인터랙티브 앱을 만드는 경험. 추가 / 토글 / 필터 / 편집 / 영속화까지 자연스러운 진화 흐름.

C. 모던 리액트 + Next.js (#1~#6) — 서버/클라이언트 패러다임

Server Components, Server Actions, Suspense, streaming까지. "이 코드는 어디서 실행되나?"라는 멘탈 모델 전환.

D. Next.js로 블로그 만들기 (#1~#5) — 두 번째 실전 빌드 (이번 시리즈)

A~C에서 배운 모든 것을 합친 풀스택 블로그. 운영 가능한 수준의 결과물 + 실제 배포까지.

총 31편. 이걸 다 따라오신 분은 React 생태계의 거의 모든 핵심 흐름을 한 번씩은 만나본 셈입니다.

다음으로 갈 만한 곳

이제 본인 프로젝트를 시작할 베이스가 충분히 갖춰졌습니다. 다음 단계로 갈 만한 방향들:

즉시 도전할 만한 것

  • 이 블로그를 자기 것으로 — 글을 쓰고 운영하기. 가장 빠른 학습은 진짜로 사용해보는 것
  • TypeScript 마이그레이션.js.tsx. 큰 코드베이스 안전성 ↑
  • 테스팅 도입 — Vitest + React Testing Library로 핵심 동작 단위 테스트

좀 더 큰 영역

  • DB 연동 — Prisma 또는 Drizzle, Supabase 또는 Vercel Postgres
  • 인증 — NextAuth.js / Clerk / Lucia
  • 상태 관리 라이브러리 — 큰 앱에서 Zustand, Jotai, Redux Toolkit
  • 데이터 페칭 라이브러리 — 클라이언트 사이드 페칭이 필요할 때 TanStack Query

또 다른 빌드

  • 쇼핑몰 — 카트, 결제, 재고, 주문 — 가장 종합적인 도전
  • 소셜 앱 — 게시판, DM, 알림 — 실시간성과 데이터 정합성
  • 대시보드/관리자 — 차트, 테이블, 필터 — 데이터 시각화

마무리

여기까지 따라와주셔서 정말 감사합니다. 이 사이트의 기존 콘텐츠 위에 31편의 리액트 글이 더해졌습니다. 작은 컴포넌트 하나에서 시작해, 클래스 컴포넌트도 안 쓰고 (현대 React는 함수 컴포넌트 + 훅이 표준), 클라이언트 사이드의 모든 기본기 → 모던 RSC 패러다임 → 두 종류의 실전 앱까지 다뤘어요.

기억하실 한 가지가 있다면 — **"본인이 만들고 싶은 작은 것을 직접 만들어보세요"**입니다. 이 블로그를 가져다 자기 것으로 운영해도 좋고, 일상 도구를 React로 만들어봐도 좋습니다. 무엇을 만들든 막히는 곳에서 진짜 학습이 일어나요.

리액트는 빠르게 진화하는 라이브러리지만, 이 시리즈에서 다룬 펀더멘털(컴포넌트 단위 사고, 단방향 데이터 흐름, 선언적 UI, "코드가 어디서 실행되나")은 변하지 않습니다. 그 위에 새 도구가 계속 쌓일 뿐이에요. 본질을 알고 있으면 어떤 새 도구가 나와도 빠르게 익힐 수 있습니다.

다음 글에서 또 만나요!