블로그 개발기 (1): Next.js SSG로 블로그 만들기

정적 사이트 생성으로 빠르고 저렴한 블로그 구축하기

작성일: 2024. 10. 06최종 수정: 2024. 10. 063 min

왜 SSG인가

블로그를 만들 때 가장 먼저 결정해야 하는 건 렌더링 방식이다. Next.js는 SSR, SSG, ISR을 지원하는데, 블로그에는 SSG가 가장 적합했다.

블로그 글은 한 번 작성하면 자주 바뀌지 않는다. 매 요청마다 서버에서 렌더링할 필요가 없다. 빌드 타임에 모든 페이지를 미리 생성해두면 CDN에서 정적 파일로 서빙할 수 있다. 서버 비용이 거의 들지 않고, 응답 속도도 빠르다.

ISR은 "주기적으로 재생성"이라는 개념이 블로그와 맞지 않았다. 글을 수정하면 즉시 반영되어야 하는데, ISR의 revalidation 주기 동안 구버전이 보일 수 있다. 차라리 수정할 때마다 빌드를 새로 돌리는 게 명확하다.

핵심 구조

Next.js Page Router에서 SSG를 구현하는 핵심은 두 함수다.

getStaticPaths

빌드 타임에 어떤 페이지들을 생성할지 알려준다.

typescript
export const getStaticPaths: GetStaticPaths = async () => {
  const posts = getSortedPostsData();
  const paths = posts.map((post) => ({
    params: { id: post.id },
  }));

  return {
    paths,
    fallback: false,
  };
};

fallback: false는 정의되지 않은 경로로 접근하면 404를 반환한다는 의미다. 블로그는 빌드 시점에 모든 글이 확정되므로 이 설정이 적합하다. fallback: true'blocking'은 동적으로 새 페이지를 생성할 때 쓴다.

getStaticProps

각 페이지에 필요한 데이터를 빌드 타임에 가져온다.

typescript
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const id = params?.id as string;
  const postData = await getPostData(id);
  const headings = extractHeadings(postData.content);
  const adjacentPosts = getAdjacentPosts(id);
  const relatedPosts = getRelatedPosts(id, postData.tags);
  const readingTime = calculateReadingTime(postData.content);

  return {
    props: {
      postData: { ...postData, headings },
      adjacentPosts,
      relatedPosts,
      readingTime,
    },
  };
};

여기서 반환하는 props가 페이지 컴포넌트의 props로 전달된다. 빌드 타임에 실행되므로 파일 시스템 접근, 데이터베이스 쿼리 등 서버 사이드 작업이 가능하다.

데이터 레이어 설계

마크다운 파일에서 데이터를 추출하는 로직은 src/lib/posts.ts에 모아뒀다.

파일 시스템 기반 CMS

typescript
const postsDirectory = path.join(process.cwd(), "posts");

export function getPostsList(): PostListItem[] {
  const fileNames = fs.readdirSync(postsDirectory);
  const allPostsData = fileNames
    .filter((fileName) => fileName.endsWith(".md"))
    .flatMap((fileName) => {
      const id = fileName.replace(/\.md$/, "");
      const fullPath = path.join(postsDirectory, fileName);
      const fileContents = fs.readFileSync(fullPath, "utf8");
      const matterResult = matter(fileContents);
      const data = matterResult.data as PostFrontmatter;

      if (data.published !== true) return [];

      return [
        {
          id,
          title: data.title,
          created: toDateString(data.created),
          tags: data.tags || [],
          excerpt: generateExcerpt(matterResult.content),
        },
      ];
    });

  return allPostsData.sort((a, b) => (a.created < b.created ? 1 : -1));
}

fs.readdirSync로 posts 폴더의 파일 목록을 읽고, 각 파일을 파싱해서 데이터를 추출한다. gray-matter 라이브러리가 frontmatter와 본문을 분리해준다.

published !== true인 글은 flatMap에서 빈 배열을 반환해 필터링한다. filter + map 조합보다 한 번에 처리할 수 있어서 간결하다.

Frontmatter 타입 정의

typescript
export interface PostFrontmatter {
  title: string;
  created: string | Date;
  updated?: string | Date;
  published?: boolean;
  tags?: string[];
}

gray-matter는 날짜 문자열을 자동으로 Date 객체로 파싱한다. "2025-01-15" 형식을 Date로 변환해주는데, 문제는 UTC로 파싱한다는 것이다. 한국 시간대에서 작성한 날짜가 하루 전으로 표시될 수 있다.

typescript
function toDateString(value: string | Date): string {
  if (value instanceof Date) {
    // gray-matter가 UTC로 파싱하므로 UTC 메서드 사용
    const year = value.getUTCFullYear();
    const month = String(value.getUTCMonth() + 1).padStart(2, "0");
    const day = String(value.getUTCDate()).padStart(2, "0");
    return `${year}-${month}-${day}`;
  }
  return value;
}

getUTCFullYear(), getUTCDate() 등 UTC 메서드를 사용해서 파싱된 그대로의 날짜를 문자열로 변환한다. 로컬 시간대 메서드(getFullYear())를 쓰면 시간대 변환이 일어나서 날짜가 어긋날 수 있다.

빌드 타임 최적화

SSG에서 중요한 건 빌드 타임이다. 글이 많아질수록 빌드 시간이 길어지는데, 몇 가지 최적화를 적용했다.

코드 하이라이팅 캐싱

코드 블록 하이라이팅은 비용이 크다. 매 빌드마다 모든 코드 블록을 하이라이팅하면 시간이 오래 걸린다.

typescript
export const getStaticProps: GetStaticProps = async ({ params }) => {
  // ...
  const codeBlockRegex = /

getStaticProps에서 코드 블록을 미리 하이라이팅해서 props로 전달한다. 클라이언트에서 하이라이팅하지 않으므로 번들 크기도 줄고, 사용자가 보는 시점에는 이미 처리가 끝나 있다.

날짜순 정렬 한 번만

typescript
  const codeBlocks: { [key: string]: string } = {};
  let match;
  let blockIndex = 0;

  while ((match = codeBlockRegex.exec(postData.content)) !== null) {
    const language = match[1]?.trim() || "text";
    const code = match[2];
    const blockKey = `block-${blockIndex++}`;
    codeBlocks[blockKey] = await highlightCode(code, language);
  }
  // ...
};

정렬은 getPostsList 함수 내에서 한 번만 수행한다. 호출하는 쪽에서 매번 정렬하지 않아도 된다. 최신 글이 먼저 오도록 내림차순 정렬한다.

실제 빌드 결과

현재 블로그는 빌드 시 약 120개 페이지를 생성한다. Vercel에서 빌드 시간은 1분 내외다.

text
return allPostsData.sort((a, b) => (a.created < b.created ? 1 : -1));

SSG의 장점은 배포 후 비용이 거의 없다는 것이다. Vercel 무료 티어로 충분하다. 서버리스 함수 호출이 없으니 사용량 걱정도 없다.