TIL 컨셉의 정말 간단한 블로그를 새로 만들고 싶어졌다. 기존의 기술 블로그에는 정제된 글을 쓰고 싶다고 한다면 새로 만들 블로그에는 그날 그날의 한 일을 부담 없이 가볍게 편하게 적어보고 싶어졌다.

그래서 새로운 블로그를 만들어보기로 했다.

기술 블로그 구현을 재활용하는 것도 방법이지만, 기술 블로그와는 다르게 최대한 심플하게 구현하고 싶기도 했고 nextjs 의 정적 페이지 생성 기능에도 흥미가 있었기 때문에 이번에는 nextjs 로 새로 구현해보려고 한다.

작업은 두 단계로 진행한다.

  1. 먼저 마크다운 파일을 파싱해 정적 페이지를 만드는 기능을 구현한다.
  2. 그리고 그렇게 만들어진 정적 페이지를 GitHub Pages 에 배포한다.

참고로 21년 6월에 Next.js 로 GitHub Pages 배포하기를 이미 해 본 적이 있긴 하지만, 당시에 배포했던 앱은 블로그 용도가 아닌 당시 재직 중이었던 회사의 서비스의 특정 기능 프로토타이핑 용도였고 배포 방법 또한 다소 복잡했다. 이 글에서는 해당 글의 경험은 전혀 사용하지 않고 훨씬 간단한 새로운 방법으로 배포한다.

0. References

1. 정적 페이지 생성 기능 구현

1.1. 프로젝트 생성

먼저 next.js 프로젝트를 생성한다.

$ npx create-next-app@latest
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … No
✔ Would you like your code inside a `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to use Turbopack for `next dev`? … Yes
✔ Would you like to customize the import alias (`@/*` by default)? … Yes
✔ What import alias would you like configured? … ~/*

1.2. 마크다운 파일 파싱 기능 구현

그리고 아래 라이브러리들을 설치한 후

$ npm install gray-matter \
    unified \
    remark-gfm \
    remark-parse \
    remark-rehype \
    rehype-stringify \
    rehype-autolink-headings \
    rehype-slug \
    @leafac/rehype-shiki \
    shiki

src/lib/api.ts 파일을 생성해 마크다운 파일 파싱을 위한 기능을 작성한다.

src/lib/api.ts
import fs from 'fs';
import path from 'path';

import matter from 'gray-matter';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';

const postsDirectory = path.join(process.cwd(), '_posts');

function getPostFiles() {
  return fs.readdirSync(postsDirectory);
}

function getParser() {
  return unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(remarkGfm)
    .use(rehypeStringify)
    .use(rehypeStringify)
    .use(rehypeSlug)
    .use(rehypeAutolinkHeadings, {
      content: arg => ({
        type: 'element',
        tagName: 'a',
        properties: {
          href: `#${String(arg.properties?.id)}`,
          style: 'margin-right: 10px',
        },
        children: [{ type: 'text', value: '#' }],
      }),
    });
}

const parser = getParser();

// 특정 글의 데이터를 가져온다
export async function getPostById(id: string) {
  const realId = id.replace(/\.md$/, '');
  const fullPath = path.join(postsDirectory, `${realId}.md`);
  const { data, content } = matter(await fs.promises.readFile(fullPath, 'utf8'));

  const html = await parser.process(content);
  const date = data.date as Date;

  return {
    ...data,
    title: data.title as string,
    id: realId,
    date: `${date.toISOString().slice(0, 10)}`,
    html: html.value.toString(),
  };
}

// 모든 글의 데이터를 가져온다.
export async function getAllPosts() {
  const posts = await Promise.all(getPostFiles().map(id => getPostById(id)));
  // 날짜 내림차순 정렬
  return posts.sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
}

1.3. 글 목록 페이지 구현

파싱해온 데이터로 글 목록 페이지를 구현해보자.

유의할 점은 현재 구현하려는 블로그가 "TIL 컨셉의 심플한 블로그" 이기 때문에, 글 목록 페이지에서 각 글의 내용도 전부 보여줄 것이다. 만약 글 목록에서 각 글의 내용을 초반부 약간만 보여주고 싶거나 아예 보여주고 싶지 않다면 코드 수정이 필요할 것이다.

src/app/page.tsx
import Link from 'next/link';

import { getAllPosts } from '~/lib/api';

import styles from './page.module.css';

const Page = async () => {
  const posts = await getAllPosts();

  return (
    <div className={styles.container}>
      <h1>Today I Learned</h1>
      <div>
        {posts.map(({ id, date, title, html }) => (
          <article className={styles.post} key={id}>
            <h2 className={styles.postTitle}>
              <Link href={`/posts/${id}`}>
                {date}
              </Link>
              <Link href={`/posts/${id}`}>
                {title}
              </Link>
            </h2>
            <div
              className={styles.postContent}
              dangerouslySetInnerHTML={{ __html: html }}
            />
          </article>
        ))}
      </div>
    </div>
  );
};

export default Page;

(./page.module.css 코드 내용은 생략한다.)

1.4. 글 상세 페이지 구현

글 목록 페이지를 만들었으니 글 상세 페이지도 만들어보자.

(사실 글 목록 페이지에서 각 글의 모든 내용을 보여주고 있으므로 글 상세 페이지는 없어도 그만이긴 하다.)

src/app/posts/[id]/page.tsx
import { getPostById, getAllPosts } from '~/lib/api';

type Props = {
  params: Promise<{ id: string }>
}
const Post = async ({ params }: Props) => {
  const { id } = await params;
  const { html, title, date } = await getPostById(id);
  return (
    <article>
      <h1>{title}</h1>
      <h4>{date}</h4>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  );
};

export default Post;

export async function generateStaticParams() {
  const posts = await getAllPosts();

  return posts.map(post => ({
    id: post.id,
  }));
}

export async function generateMetadata({ params }: Props) {
  const { id } = await params;
  const { title } = await getPostById(id);
  return {
    title,
  };
}

1.5. 마크다운 글 작성

마크다운 글은 _posts 폴더를 만들어 해당 폴더에 .md 파일을 만들어 작성한다.

_posts/my-first-post.md
---
title: "TIL - Next.js 프로젝트 생성"
date: 2025-02-10
---

내용...

유의할 점은 date 항목이 "2025-02-10" 이 아니라 2025-02-10 이어야 한다는 것이다.

글 작성까지 완료했다면 이제 정적 페이지 생성 기능으로 블로그 구현을 완료한 것이다.

yarn dev 로 개발 서버를 실행해서 글이 제대로 표시되는지 확인할 수 있다.

2. Github Pages 배포

정적 페이지 생성 기능 구현은 끝났으니 이제 생성한 정적 페이지를 Github Pages 에 배포를 해보자.

배포는 Making a static site blog with the Next.js 'app' directory 글의 내용을 따르지 않고 nextjs-github-pages 레포지토리의 내용을 따를 것이다.

nextjs-github-pages 에 워낙 잘 설명되어있긴 하지만 리마인드 개념으로 이 글에도 간단히 정리한다.

2.1. Custom Domain 지정

일단 이 글에서는 레포지토리의 Settings - Pages 에서 Custom Domian 을 지정한다고 가정한다.

Custom Domain 지정
Custom Domain 지정

Custom Domain 을 지정하지 않아도 설정이 크게 바뀌지는 않는다. 다만 바뀌어야 하는 부분이 있기는 하므로, 그 경우에는 nextjs-github-pages 를 확인해보자.

2.2. next.config.ts 수정

next.config.ts 파일도 간단히 수정하자.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  basePath: "",
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

2.3. .nojekyll 파일 생성

그리고 public 디렉토리에 .nojekyll 파일을 생성한다. 이는 GitHub Pages 에게 "우리는 Jekyll 을 사용하지 않는다"는 것을 명시하기 위함이며, 내용 없이 빈 파일이면 된다.

2.4. GitHub Actions 설정

마지막으로 .github/workflows/deploy.yml 파일을 생성해 https://github.com/gregrickaby/nextjs-github-pages/blob/main/.github/workflows/deploy.yml 파일 내용을 붙여넣자.

2.5. 배포

지금까지의 모든 과정을 모두 완료했다면 main 브랜치로 지금까지 작업한 모든 내용을 푸시하자. 직접 푸시해도 되고 PR 로 머지해도 된다. main 에 코드가 올라가면 GitHub Actions 가 자동으로 배포를 시작한다.

배포가 끝난 뒤 Custom Domain 으로 지정한 주소에 접속하면 블로그가 제대로 배포된 것을 확인할 수 있다.

3. 결과

코드 및 배포 결과는 ricale/game-dev-study-log 에서 확인할 수 있다.

4. 이슈

위 과정에서 제대로 챙기지 못한 이슈가 남아있다.

  • 글 상세 페이지에 스타일을 지정하지 않음
    • 글 목록 페이지와 스타일을 통일하고 싶다면 공통 컴포넌트 작성이 필요
  • 글 상세 페이지에서 새로고침 시 markdown 파일에 포함된 이미지 파일이 보이지 않는 현상이 있음
  • 글 목록 페이지에서 정렬 순서를 변경할 수 없음
    • 이건 일반적인 블로그 및 TIL 에서 필수 기능은 아니지만, 개인적으로 넣고 싶은 기능이다.

이슈들은 이후 시간이 되면 적용해볼 예정이다. 다만 우선순위가 굉장히 낮아서 언제 진행할지는 미지수이다.