임통 아이콘

임통 블로그

Next.js로 블로그 만들기

회고

2024년 10월 06일

Next.js로 블로그 만들기의 썸네일

계기

개발자에겐 그 동안 자신이 학습한 내용들과 겪었던 문제와 해결 과정을 기록하는 것이 정말 중요하다는 생각이 들었습니다.
이는 개발을 시작한 지 얼마 되지 않았을 때 부터 알고 있었지만, 아직까지도 잘 습관이 들지 않아 꾸준하게 포스트를 작성하지 못하고 있었습니다.

가끔씩 패스트캠퍼스 깃헙 조직을 통해 멘토님들의 깃헙을 둘러보는데 프로필에 개인 기술 블로그를 링크해놓으신 분들이 몇몇 계셨습니다.
블로그마다 각자의 개성이 보이고, UX를 고려하여 블로그를 개발하신 느낌이 들었습니다.

그러면 나도 웹 개발자인데 개인 블로그 하나쯤은 직접 개발해서 운영할 수 있지 않을까 ?

하는 생각이 바로 들었고 내가 원하는 디자인, 기능, 나만의 도메인으로 기술 블로그를 운영하게 된다면 더 애정이 갈 것이고 꾸준히 포스팅을 할 수 있을 거라는 생각이 들기 시작했습니다. 그래서 바로 개발을 시작하였습니다.

사용한 기술 & 스택

  • Next.js

우선, 가장 중요한 웹 개발 프레임워크로써 Next.js를 선택했습니다.
가장 익숙한 도구이기도 하고 이미지, 폰트 최적화 및 SEO까지 보장해주기 때문에 선택하게 되었습니다.

  • TypeScript

TypeScript는 정적 언어이므로 컴파일 단계에서 타입 에러를 미리 확인할 수 있어
실행 전에 버그를 사전에 방지할 수 있습니다. 따라서 코드에 안정성을 더하여 개발할 수 있기 때문에 선택하게 되었습니다.

  • Tailwind CSS

CSS 프레임워크로는 Tailwind CSS를 사용했습니다. 정의된 클래스명으로 빠르게 퍼블리싱이 가능하고 구성을 통해
디자인 시스템을 통합할 수 있습니다.

  • Zustand

상태 관리 라이브러리로는 Zustand를 사용했습니다. Zustand는 간결하고 직관적인 API를 제공하여 간단하고 빠르게 전역 상태 관리를 할 수 있어 선택했습니다.

개발 과정

퍼블리싱

Tailwind CSSshadcd/ui를 사용하여 레이아웃을 구축했습니다.
개발할 페이지는 메인페이지와 포스트 페이지, About 페이지였는데 각각의 페이지에 개발한 기능은 다음과 같습니다.

  • 메인페이지

    • Left sidebar: 전체 글을 카테고리 별로 조회
    • 전체 글을 카드 형식으로 조회
  • 포스트 페이지

    • Left Sidebar: 전체 글을 카테고리 별로 조회
    • Right Sidebar: 포스트 목차 조회, TOC(Table Of Contents)
    • MDX를 html로 변환하여 포스트 내용 조회
    • giscus를 사용한 댓글 기능
  • About 페이지

    • 개발자 프로필 조회
    • 개발자의 프로젝트와 자주 사용하는 기술 조회

각 페이지의 기능에 맞게 레이아웃을 구성하고 Tailwind CSS에서 제공하는 기본 Break Point에 맞추어 반응형 디자인을 구현했습니다.

MDX 변환

MDX 변환 관련 라이브러리는 다음과 같습니다.

  • next-mdx-remote

Next.js에서 MDX파일을 동적으로 불러와서 렌더링을 할 수 있게 해주는 라이브러리입니다.
next-mdx-remote와 함께 사용한 플러그인은 다음과 같습니다.

  • remark-gfm: 리터럴 자동 링크, 각주, 취소선, 표, 작업 목록을 지원합니다.

  • remark-breaks: markdown은 기본적으로 두 줄 개행인데, 한 줄로도 개행하게 해줍니다.

  • rehype-pretty-code: 코드 블록을 깔끔하게 하이라이팅하고, 코드에 스타일을 적용하기 위해 사용합니다.

  • rehype-slug: TOC를 구현하기 위해 사용한 플러그인으로, mdx문서에서 변환된 heading태그에 id값을 지정해줍니다.

import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import remarkBreaks from "remark-breaks";
import { MdxComponents } from "@/components/mdx";
 
export default function PostBody({ content }: { content: string }) {
  return (
    <div className="pb-8">
      <MDXRemote
        source={content}
        options={{
          mdxOptions: {
            remarkPlugins: [remarkGfm, remarkBreaks],
            rehypePlugins: [
              [
                rehypePrettyCode,
                {
                  theme: "material-theme-darker",
                },
              ],
              rehypeSlug,
            ],
          },
        }}
        components={MdxComponents}
      />
    </div>
  );
}

하이라이팅된 MdxComponents는 MDX에서 변환된 html요소를 개발자가 커스텀하여 재구성해주는 컴포넌트입니다.
저는 아래와 같이 작성했습니다.

/* eslint-disable @typescript-eslint/no-explicit-any */
import { Image } from "./Image";
import { MDXComponents } from "mdx/types";
import { ExternalLink } from "./Link";
import { Callout } from "./Callout";
import * as Heading from "./Heading";
import { Ol, Ul } from "./List";
import { CodeBlock } from "./CodeBlock";
 
// 커스텀 스타일
export const MdxComponents: MDXComponents = {
  a: ExternalLink as any,
  img: Image as any,
  h1: Heading.h1 as any,
  h2: Heading.h2 as any,
  h3: Heading.h3 as any,
  h4: Heading.h4 as any,
  h5: Heading.h5 as any,
  h6: Heading.h6 as any,
  ul: Ul as any,
  ol: Ol as any,
  figure: CodeBlock as any,
  hr: () => <hr className="my-8 border-0 h-[1px] bg-neutral-600" />,
  p: ({ children }) => <p className="leading-7 my-4">{children}</p>,
  blockquote: Callout,
  Callout,
};

발생한 문제

여기까지 개발을 완료했을 때, 개발 환경에서는 원했던 기능 모두 아주 잘 동작하였습니다.
그런데 문제는 vercel에 배포했을 때 포스트 페이지가 SSR로 동작했기 때문에 서버에서 html을 구성하여 화면을 그릴 때 속도가 굉장히 느렸습니다.

개발 환경 포스트 상세 페이지 LCP 측정 결과개발 환경 포스트 상세 페이지 LCP 측정 결과

개발 환경 포스트 상세 페이지 성능 측정 결과개발 환경 포스트 상세 페이지 성능 측정 결과

배포 후 포스트 상세 페이지 LCP 측정 결과배포 후 포스트 상세 페이지 LCP 측정 결과

배포 후 포스트 상세 페이지 성능 측정 결과배포 후 포스트 상세 페이지 성능 측정 결과

원인

이제까지 개발한 경험을 떠올려보면 문제를 해결하기 위해 가장 먼저 해야하는 것은 정확한 원인을 분석하는 것 이었습니다.
다음은 포스트 페이지의 코드입니다.

export default async function PostDetailPage({ params }: PostDetailPageProps) {
  const { slug } = params;
  const post = await getPost(slug);
 
  return (
    <>
      <PostHeader post={post} />
      <PostBody content={post.content} />
      <Comments />
    </>
  );
}

처음 생각했던 원인은 getPost함수였습니다.

// 상세 포스트 조회
export const getPost = async (slug: string) => {
  const path = sync(`${POSTS_PATH}/**/${slug}/content.mdx`)[0];
  if (!path) return redirect("/");
  const post = await parsePost(path);
  return post;
};

혹시 로컬에 있는 MDX파일을 파싱하는데 시간이 오래걸리나? 라는 생각이 들었고, 그렇다면 Headless CMS를 사용하여 클라우드 상에 MDX를 띄워놓고 html로 변환하는 방식으로 구현해보면 될 것이라고 생각했습니다.

그래서 브랜치를 새로 생성하고 SanityRoute Handlers를 활용하여 데이터를 패칭하는 방식으로 해보았습니다.

/api/posts/[id]에 데이터 띄우기/api/posts/[id]에 데이터 띄우기

Sanity Studio로 데이터 관리하고 확인하기 1Sanity Studio로 데이터 관리하고 확인하기 1

Sanity Studio로 데이터 관리하고 확인하기 2Sanity Studio로 데이터 관리하고 확인하기 2

이렇게 데이터를 담아두고 로컬에 있는 MDX파일이 아닌 띄워져 있는 데이터를 받아와서 랜더링하는 방식으로 변경하고, 다시 배포를 해보았습니다.
MDX파일과 썸네일을 로컬에서 관리하는 것이 아니라 클라우드 상에 데이터로 관리하는 점에서 프로젝트 폴더와 게시물을 독립적으로 관리할 수 있다는 장점이 있긴 했지만,
결과적으로 여전히 렌더링 속도는 개선되지 않았습니다...😇

그래서 저는 다른 이유를 찾기 시작했습니다. 이번에는 getPost함수가 아닌 포스트 페이지의 각 3개의 컴포넌트에 집중했습니다.

export default async function PostDetailPage({ params }: PostDetailPageProps) {
  const { slug } = params;
  const post = await getPost(slug);
 
  return (
    <>
      <PostHeader post={post} />
      <PostBody content={post.content} />
      <Comments />
    </>
  );
}

결국 포스트 페이지의 렌더링이 늦어지는 것이므로 PostHeader, PostBody, Comments 각각의 컴포넌트들을 차례로 제거해보며 테스트를 시작해보았습니다.
그런데 배포환경에서는 렌더링 속도가 늦어지는 오류를 알 수 없었기 때문에 코드를 수정하고 배포해서 테스트 결과를 확인하고를 반복했습니다.
이러한 테스트 방법은 절대로 옳지 않다고 생각하지만, yarn build && yarn start를 통해 배포 환경을 테스트 했을 때에도 vercel에 배포했을 때와 다른 결과를 보여주었기 때문에 현재 저의 능력으로는 이 방법으로 할 수 밖에 없었습니다. 역시 개발자는 문제를 맞닥뜨리면서 깨져봐야 자신의 기술의 한계를 파악하고 더 성장할 수 있게 되는 것 같습니다...😢

어쨌든, 결론적으로 랜더링 속도 저하의 문제는 PostBody컴포넌트에 있었다는 것을 알게 되었습니다.
PostBody컴포넌트에서는 next-mdx-remote 라이브러리를 사용하여 MDX문서를 HTML로 파싱하는 작업을 하는데 이 과정이 오래걸린다고 판단했습니다.
구글링을 통해 next-mdx-remote를 사용한 블로그에서 저와 같은 렌더링 속도 저하 문제를 겪은 이슈가 있는 지 알아보았지만, 큰 도움이 될만한 정보는 얻지 못했습니다.

2~3일간 고민하던 중 최적의 렌더링 방식이 무엇일까하는 고민에 들어서기 시작했습니다. 제가 개발중인 블로그는 정적인 웹사이트의 가장 대표적인 예시이고, 그렇다면 정적 렌더링 방식을 적용하면 사용자가 빠르게 웹사이트를 확인할 수 있지 않을까하고 생각했습니다. SSG 랜더링 관련 Next.js 공식문서를 읽어보며 프로젝트에 적용하였습니다.

generateStaticParams을 사용한 정적 페이지 생성 SSG
export default async function PostDetailPage({ params }: PostDetailPageProps) {
  const { slug } = params;
  const post = await getPost(slug);
 
  return (
    <>
      <PostHeader post={post} />
      <PostBody content={post.content} />
      <Comments />
    </>
  );
}
 
export async function generateStaticParams() {
  const posts = await getPostList();
 
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

코드를 수정하고 배포를 하여 결과를 확인해보았습니다. SSG 렌더링 방식은 빌드시점에 지정한 페이지의 HTML을 미리 생성하기 때문에 전보다 훨씬 빠르게 포스트 페이지를 확인할 수 있었습니다. LCP가 무려 3000ms에서 200ms로 대략 93% 감소되었습니다. 하지만 저의 고민은 여기서 끝나지 않았습니다.

만약 데이터(포스트의 내용)가 변경된다면 ?

실제로 테스트할 포스트를 추가한 뒤 빌드를 해보니 다음과 같은 에러가 발생하였습니다.

빌드 에러 메시지빌드 에러 메시지

GROQ을 사용하여 sanity에서 데이터를 가져오는 getPost 함수
export const getPost = async (postId: string): Promise<FullPost | null> => {
  const post = await client.fetch(
    `
      *[_type == "post" && _id == "${postId}"] {
        ${fullPost}
      }[0]
    `,
    {},
    { cache: "no-store" }
  );
 
  if (!post) return null;
 
  return {
    ...post,
    date: dayjs(post.date).locale("ko").format("YYYY년 MM월 DD일"),
  };
};

{ cache: "no-store" }옵션을 주었기 때문에 Sanity studio에서 데이터가 변경되면 즉시 변경된 데이터를 /api/posts/[id] 경로로 띄웁니다.

Route handlers를 사용하여 해당 id값의 포스트 데이터 띄우기
// /api/posts/[id]/route.ts
import { getPostDetail } from "@/service/post";
 
type Context = {
  params: {
    id: string;
  };
};
 
export const GET = async (_: Request, context: Context) => {
  const { id } = context.params;
 
  const post = await getPost(id);
 
  return Response.json(post);
};
포스트 페이지에서 fetch하여 렌더링
export default async function page({ params }: { params: { id: string } }) {
  const { id } = params;
  const post: FullPost = await fetch(
    `${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${id}`
  ).then((res) => res.json());
 
  if (!post) return redirect("/");
 
  return (
    <>
      <PostHeader post={post} />
      <PostBody content={post.content} />
      <Comments />
    </>
  );
}
 
export async function generateStaticParams() {
  const posts: SimplePost[] = await fetch(
    `${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`
  ).then((res) => res.json());
 
  return posts.map((post) => ({
    id: post.id,
  }));
}

위 코드의 흐름을 살펴보면 getPost함수는 { cache: "no-store" }라는 옵션을 적용하여 Sanity studio의 데이터가 변경될 떄 즉시, 변경된 데이터를 반영하여 /api/posts/[id] 경로로 데이터를 띄워줍니다.
/api/posts/[id] 경로에 띄워진 데이터를 바탕으로 포스트 페이지에서 fetch메서드를 사용하여 데이터를 받아옵니다. 하지만 이 때의 fetch메서드는 캐시된 데이터를 받아오고 있으므로 실제 변경된 데이터와의 차이가 발생하면서 정적 파라미터를 생성할 수 없다고 생각했습니다.

이 문제를 해결하기 위해 MDX파일을 다시 로컬파일로 관리하는 로직으로 수정했고, 이제는 문제없이 정적 파라미터를 생성할 수 있었고 배포 시 SSG 랜더링도 성공적으로 할 수 있었습니다.

마무리

개인 블로그 개발은 데이터가 수시로 변경되는 웹사이트가 아니다보니까 쉽게 개발할 수 있을 것이라 생각했는데, 정말 예상치 못하게 랜더링 속도가 너무 느려서 이를 해결하고 개선하기 위해 여러 고민과 노력들을 하며 많이 배우게 되었던 프로젝트라고 생각합니다.

아무튼 가장 기본적인 블로그의 구색은 갖추어 개발을 완료할 수 있어서 저는 개인적으로 굉장히 뿌듯한 경험을 한 프로젝트였습니다.
마지막으로 새로 배우게 된 점과 개선할 점에 대해서 정리하며 포스팅을 마무리하도록 하겠습니다.

새로 배우게된 점

  • 개발자 도구를 통한 성능 검사
  • 랜더링 속도 최적화
  • SSR, ISR, SSG, CSR 각각의 정의와 활용
  • IntersectionObserver 인스턴스
  • next.js에서 fetch의 데이터 캐싱 활용

개선할 점

  • 포스트와 프로젝트를 분리하여 독립적으로 포스팅을 하기위해 Headless CMS를 사용하거나 AWSS3 Bucket을 사용하여 클라우드 상에서 MDX파일과 에셋을 관리하기
  • 포스트 내용에 변경사항(추가, 삭제, 수정 등)이 생기면 바로 웹사이트에 적용할 수 있도록 CI/CD 구축
  • 이미지 최적화
  • 포스트의 개수가 많아짐에 따라 메인페이지의 LCP 시간 단축하기 위한 방법 고민

해당 글은 목차가 없습니다.