블로그 개발기 (1): Next.js SSG로 블로그 만들기
정적 사이트 생성으로 빠르고 저렴한 블로그 구축하기
왜 SSG인가
블로그를 만들 때 가장 먼저 결정해야 하는 건 렌더링 방식이다. Next.js는 SSR, SSG, ISR을 지원하는데, 블로그에는 SSG가 가장 적합했다.
블로그 글은 한 번 작성하면 자주 바뀌지 않는다. 매 요청마다 서버에서 렌더링할 필요가 없다. 빌드 타임에 모든 페이지를 미리 생성해두면 CDN에서 정적 파일로 서빙할 수 있다. 서버 비용이 거의 들지 않고, 응답 속도도 빠르다.
ISR은 "주기적으로 재생성"이라는 개념이 블로그와 맞지 않았다. 글을 수정하면 즉시 반영되어야 하는데, ISR의 revalidation 주기 동안 구버전이 보일 수 있다. 차라리 수정할 때마다 빌드를 새로 돌리는 게 명확하다.
핵심 구조
Next.js Page Router에서 SSG를 구현하는 핵심은 두 함수다.
getStaticPaths
빌드 타임에 어떤 페이지들을 생성할지 알려준다.
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
각 페이지에 필요한 데이터를 빌드 타임에 가져온다.
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
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 타입 정의
export interface PostFrontmatter {
title: string;
created: string | Date;
updated?: string | Date;
published?: boolean;
tags?: string[];
}
gray-matter는 날짜 문자열을 자동으로 Date 객체로 파싱한다. "2025-01-15" 형식을 Date로 변환해주는데, 문제는 UTC로 파싱한다는 것이다. 한국 시간대에서 작성한 날짜가 하루 전으로 표시될 수 있다.
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에서 중요한 건 빌드 타임이다. 글이 많아질수록 빌드 시간이 길어지는데, 몇 가지 최적화를 적용했다.
코드 하이라이팅 캐싱
코드 블록 하이라이팅은 비용이 크다. 매 빌드마다 모든 코드 블록을 하이라이팅하면 시간이 오래 걸린다.
export const getStaticProps: GetStaticProps = async ({ params }) => {
// ...
const codeBlockRegex = /getStaticProps에서 코드 블록을 미리 하이라이팅해서 props로 전달한다. 클라이언트에서 하이라이팅하지 않으므로 번들 크기도 줄고, 사용자가 보는 시점에는 이미 처리가 끝나 있다.
날짜순 정렬 한 번만
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분 내외다.
return allPostsData.sort((a, b) => (a.created < b.created ? 1 : -1));
SSG의 장점은 배포 후 비용이 거의 없다는 것이다. Vercel 무료 티어로 충분하다. 서버리스 함수 호출이 없으니 사용량 걱정도 없다.