Next.js로 블로그 만들기 #2 글 목록과 상세 페이지

15 분 소요

지난 시간에는 데이터 모델과 폴더 구조를 잡고 첫 MDX 글들을 만들었습니다. 이번에는 진짜 화면을 그립니다 — 홈에 글 목록, /posts/[slug]에 글 상세, 두 페이지를 완성하는 게 목표입니다.

글 목록 페이지

홈(/)에 글 목록을 그립시다. Server Component이므로 fs 모듈을 그대로 사용할 수 있어요.

src/app/page.js:

src/app/page.js
import Link from 'next/link';
import { getAllPosts } from './lib/posts';
 
export default function HomePage() {
  const posts = getAllPosts();
 
  return (
    <main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h1>블로그</h1>
      {posts.length === 0 ? (
        <p>아직 글이 없습니다.</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {posts.map(post => (
            <li key={post.slug} style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
              <h2 style={{ margin: 0 }}>
                <Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
              </h2>
              <small style={{ color: '#888' }}>{post.frontmatter.date}</small>
              <p>{post.frontmatter.description}</p>
              <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
                {(post.frontmatter.tags ?? []).map(tag => (
                  <span key={tag} style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px' }}>
                    {tag}
                  </span>
                ))}
              </div>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

저장하고 http://localhost:3000에 접속하면 posts/에 만들어둔 글들이 목록으로 나타납니다 (draft는 제외). 인터넷이 빠르든 느리든 첫 화면이 즉시 보일 거예요 — Server Component가 빌드/요청 시점에 미리 HTML을 만들어 보내기 때문입니다.

콘솔이 어디 찍히는지 확인하기

지난 시리즈에서 강조했던 멘탈 모델을 다시 확인해봅시다. 파일 맨 위에 console.log를 박아보세요.

실험
import Link from 'next/link';
import { getAllPosts } from './lib/posts';
 
export default function HomePage() {
  const posts = getAllPosts();
  console.log('포스트 개수:', posts.length);
  // ...
}

브라우저 콘솔이 아니라 dev 서버 터미널에 찍힙니다. Server Component가 서버에서 실행된다는 게 다시 한번 명확해지죠.

글 상세 페이지 — 동적 라우트

/posts/hello-world 같은 URL이 동작해야 합니다. App Router에서는 폴더명을 [param]으로 만들어 동적 라우트를 표현합니다.

src/app/posts/[slug]/page.js:

src/app/posts/[slug]/page.js
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllSlugs } from '../../lib/posts';
import { compileMDX } from 'next-mdx-remote/rsc';
 
export async function generateStaticParams() {
  return getAllSlugs().map(slug => ({ slug }));
}
 
export default async function PostPage({ params }) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
 
  if (!post || post.frontmatter.draft) {
    notFound();
  }
 
  const { content } = await compileMDX({
    source: post.content,
  });
 
  return (
    <article style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h1>{post.frontmatter.title}</h1>
      <small style={{ color: '#888' }}>{post.frontmatter.date}</small>
      <p style={{ color: '#555' }}>{post.frontmatter.description}</p>
      <hr />
      {content}
    </article>
  );
}

핵심 요소를 풀어보겠습니다.

params는 Promise

const { slug } = await params;

Next.js 15부터 params는 Promise라서 await을 거쳐야 값을 꺼낼 수 있습니다. 이전 버전을 따라하는 자료에서는 그냥 params.slug처럼 썼을 수 있는데, 최신 기준으로는 await params가 정석입니다.

compileMDX로 본문 변환

const { content } = await compileMDX({
  source: post.content,
});

next-mdx-remote/rsccompileMDX가 마크다운 본문 문자열을 React 컴포넌트로 변환해줍니다. 결과의 content는 실제 JSX 트리이고, 그대로 화면에 그릴 수 있어요.

이 변환은 서버에서 일어납니다. 브라우저에 가는 건 변환된 HTML뿐이고, MDX 컴파일러 자체는 클라이언트 번들에 포함되지 않습니다 — Server Component의 또 하나의 강점이에요.

notFound() — 글이 없을 때

if (!post || post.frontmatter.draft) {
  notFound();
}

notFound()는 Next.js가 제공하는 함수로, 호출 즉시 페이지 렌더를 중단하고 404 화면을 보여줍니다. draft인 글에 직접 URL로 접근해도 404로 처리되니 의도치 않게 노출되지 않아요.

404 화면을 커스터마이즈하려면 src/app/not-found.js를 추가하면 됩니다 (지금은 Next.js 기본 화면 사용).

generateStaticParams — 빌드 시점 정적 생성

export async function generateStaticParams() {
  return getAllSlugs().map(slug => ({ slug }));
}

이 함수가 있으면 Next.js는 빌드 시점에 모든 슬러그에 대해 페이지를 미리 생성합니다. 결과적으로 모든 글이 정적 HTML로 변환되어 CDN에 올라가요. 런타임에 fs를 다시 읽지도, MDX를 다시 컴파일하지도 않아 매우 빠릅니다.

이 함수가 없으면 페이지는 동적으로 렌더됩니다 — 매 요청마다 fs를 읽고 컴파일하므로 좀 더 느리지만 새 글 추가가 즉시 반영됩니다.

블로그처럼 글이 자주 바뀌지 않는 경우 정적 생성이 거의 항상 더 좋은 선택입니다. 글을 추가하면 다시 빌드하면 되니까요(Vercel은 git push 시 자동).

노트

정적 생성과 동적 렌더의 선택은 한 번에 정하지 않아도 됩니다. App Router는 두 모드를 자동으로 잘 결정해줍니다 — generateStaticParams가 있고 페이지 안에서 동적 함수(쿠키 읽기, 헤더 읽기, searchParams 사용 등)를 안 쓰면 정적, 그 외엔 동적. 지금은 "이렇게 쓰면 정적이다" 정도만 알아두세요.

동작 확인

저장하고 다음을 확인합니다.

  1. http://localhost:3000 — 글 목록이 발행일 내림차순으로 표시
  2. 각 글 제목 클릭 → 상세 페이지로 이동
  3. 마크다운 본문이 HTML로 잘 렌더되는지
  4. http://localhost:3000/posts/draft-not-shown — 404 화면
  5. http://localhost:3000/posts/존재하지않는글 — 404 화면
  6. 페이지 소스 보기(우클릭 → 페이지 소스) — 본문이 이미 HTML로 들어 있음 (CSR이었다면 빈 div만 있을 부분)

마지막 6번이 인상적인 부분입니다. SEO 친화적이고, 검색 엔진이 콘텐츠를 그대로 인덱싱할 수 있어요.

마크다운 확장 — remark-gfm

기본 마크다운만으로는 테이블, 체크박스, URL 자동 링크 같은 GitHub 스타일 문법이 안 됩니다. remark-gfm을 추가합시다.

설치
npm install remark-gfm

compileMDX 호출에 옵션을 추가합니다.

src/app/posts/[slug]/page.js (수정)
import remarkGfm from 'remark-gfm';
 
// ...
 
const { content } = await compileMDX({
  source: post.content,
  options: {
    mdxOptions: {
      remarkPlugins: [remarkGfm],
    },
  },
});

이제 글 본문에 다음 같은 표를 써도 잘 렌더됩니다.

| 이름 | 역할 |
|---|---|
| Server Component | 서버에서 실행 |
| Client Component | 브라우저에서도 실행 |

체크박스도:

- [x] 1단계 완료
- [ ] 2단계 (진행 중)
- [ ] 3단계

코드 블록 신택스 하이라이팅 — rehype-pretty-code

기술 블로그라면 코드 블록 하이라이팅이 거의 필수입니다.

설치
npm install rehype-pretty-code shiki
src/app/posts/[slug]/page.js (수정)
import remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
 
// ...
 
const { content } = await compileMDX({
  source: post.content,
  options: {
    mdxOptions: {
      remarkPlugins: [remarkGfm],
      rehypePlugins: [
        [rehypePrettyCode, { theme: 'github-light' }],
      ],
    },
  },
});

이제 본문 안 코드 블록에 색이 입혀집니다. 언어를 명시(```js)하면 그 언어 문법에 맞춰 하이라이트되고요.

조금 더 다듬으려면:

  • 코드 블록 위에 파일명/캡션 넣기 (```js title="example.js")
  • 언어별 아이콘 표시 (이 사이트가 하는 방식, 별도 CSS 필요)

이 시리즈에서는 기본 옵션만 사용합니다. 실제로 다듬는 건 본인 취향대로.

레이아웃 — 헤더 추가

지금은 모든 페이지가 본문만 있고 공통 영역이 없습니다. layout.js에 간단한 헤더를 둡시다.

src/app/layout.js:

src/app/layout.js
import Link from 'next/link';
import './globals.css';
 
export const metadata = {
  title: '나의 블로그',
  description: 'Next.js로 만든 블로그',
};
 
export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        <header style={{ padding: '12px 24px', background: '#222', color: '#fff', display: 'flex', gap: '16px' }}>
          <Link href="/" style={{ color: '#fff', textDecoration: 'none', fontWeight: 'bold' }}>홈</Link>
          <Link href="/tags" style={{ color: '#fff', textDecoration: 'none' }}>태그</Link>
          <Link href="/search" style={{ color: '#fff', textDecoration: 'none' }}>검색</Link>
        </header>
        {children}
      </body>
    </html>
  );
}

/tags/search는 #3에서 만들 페이지입니다. 미리 링크만 걸어두고, 클릭하면 지금은 404가 뜨겠죠. 다음 글에서 채울 거예요.

작은 리팩터 — PostCard 컴포넌트

홈 페이지의 글 카드 부분이 슬슬 길어집니다. 별도 컴포넌트로 분리합시다.

src/app/PostCard.jsx:

src/app/PostCard.jsx
import Link from 'next/link';
 
export default function PostCard({ post }) {
  return (
    <li style={{ marginBottom: '24px', borderBottom: '1px solid #eee', paddingBottom: '16px' }}>
      <h2 style={{ margin: 0 }}>
        <Link href={`/posts/${post.slug}`}>{post.frontmatter.title}</Link>
      </h2>
      <small style={{ color: '#888' }}>{post.frontmatter.date}</small>
      <p>{post.frontmatter.description}</p>
      <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
        {(post.frontmatter.tags ?? []).map(tag => (
          <Link
            key={tag}
            href={`/tags/${tag}`}
            style={{ fontSize: '12px', padding: '2px 8px', background: '#f0f0f0', borderRadius: '12px', textDecoration: 'none', color: '#333' }}
          >
            #{tag}
          </Link>
        ))}
      </div>
    </li>
  );
}

홈 페이지가 짧아집니다.

src/app/page.js (정리 후)
import { getAllPosts } from './lib/posts';
import PostCard from './PostCard';
 
export default function HomePage() {
  const posts = getAllPosts();
 
  return (
    <main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h1>블로그</h1>
      {posts.length === 0 ? (
        <p>아직 글이 없습니다.</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {posts.map(post => (
            <PostCard key={post.slug} post={post} />
          ))}
        </ul>
      )}
    </main>
  );
}

태그 클릭 시 /tags/[tag]로 이동하도록 미리 링크를 박아두었습니다 (실제 동작은 #3에서).

동작 확인 — 종합

다음을 확인하면 #2 마무리:

  1. 홈 페이지에 글 목록이 잘 표시
  2. 각 글 카드에 제목/날짜/요약/태그
  3. 글 상세 페이지의 마크다운 본문이 잘 렌더 (코드 블록 하이라이팅 포함)
  4. 표(remark-gfm)가 동작
  5. draft인 글은 목록에 없고 직접 접근 시 404
  6. 헤더의 "홈" 링크가 항상 동작 (다른 두 링크는 #3에서)

마무리

이번 글에서는 블로그의 두 핵심 페이지를 완성했습니다.

  • — Server Component가 fs로 글 목록 읽고 표시
  • 글 상세 — 동적 라우트 + compileMDX로 본문 컴파일
  • generateStaticParams로 빌드 시점 정적 생성
  • notFound()로 없는 글/draft 처리
  • remark-gfm, rehype-pretty-code 플러그인 도입
  • 작은 리팩터로 PostCard 분리

다음 글인 "Next.js로 블로그 만들기 #3 태그와 검색"에서는 헤더에 미리 걸어둔 /tags/search를 채웁니다 — 태그별 글 모음, 그리고 URL 쿼리 파라미터를 활용한 검색 기능을 만들어보겠습니다.