안녕하세요. Next.js 14로 블로그 만들기 프로젝트를 시작합니다! 세상에는 이미 좋은 블로그 플랫폼이 많이 있습니다. 그 중에는 개발자를 위한 플랫폼도 있고 별다른 설정이나 노력 없이 계정만 만들면 몇 분 안에 바로 글쓰기를 시작할 수 있습니다. 접근성도 좋고 안정적이고 편하죠. 그럼에도 자신만의 블로그를 가지고 있고 오래 운영해본다는 것은 꽤 매력적인 경험입니다. 이 시리즈에서는 밑바닥부터 블로그를 만들며 구현한 기능들, 해결했던 문제들 새로 알게된 내용 등등 여러가지를 공유해 보려고 합니다. Next.js 14.2.15 버전을 사용하고 있고 전체 코드는 footer의 깃헙 아이콘이나 제 repo에서 확인하실 수 있습니다.
블로그를 직접 만드는 이유
블로그를 직접 만들면 생각보다 만들어야 할 기능이 많습니다. 그럼에도 직접 만드는 이유는
- 내가 원하는 기능을 넣기 위해서
- 나만의 디자인을 위해서
- 멋있으니까
- 공부
정도가 있겠네요. 이미 좋은 서비스가 많이 있지만 그 사이트의 성격이나 기능, 디자인 등 제가 원하는 분위기와 100% 맞을 수 없고 원하는 무언가를 바로 제약 없이 만들 수 있다는 장점이 있습니다. 그리고 공부도 꽤 됩니다. 사실 이 블로그는 3번째 재개발하고 있는데요. 전에도 똑같이 Next.js를 이용해 블로그를 만드려고 했었는데 첫번째 프로젝트는 아예 글이 읽어지지 않았고 두번째 시도는 기능은 전부 정상적이었지만 글 로딩이 15초까지 걸릴 정도로 성능이 너무 느렸습니다. 그래서 무려 3번 프로젝트를 갈아엎고 이 페이지를 보고 있는데 정말 감격스럽습니다. 지금부터 제 블로그를 만든 과정을 제 블로그에 써보려고 합니다. 이 글을 쓰는 지금은 아직 개발이 완전히 되지는 않아서 댓글 기능도 없고 많이 부족하지만 곧 생길테니 디자인, 기능, 오타, 내용오류 등 아무 피드백이나 칭찬, 비판 모두 편하게 댓글로 남겨주세요! 소스코드는 페이지 맨 밑 푸터에 있는 깃헙 아이콘을 통해 이동할 수 있습니다.
기술 스택
Typescript를 사용한 Next.js 14와 Tailwind CSS를 통해 사이트를 만드려고 합니다. 이 외에도 필요한 라이브러리가 몇 개 있지만 해당 파트에서 그때그때 언급하겠습니다. 그리고 프로젝트 전반에 걸쳐 사용되는 tailwind 관련 유틸 함수가 있습니다.
export function cn(...classNames: (string | undefined)[]): string {
return classNames.filter(Boolean).join(" ");
}
cn
함수를 꽤 자주 사용하고 원하시는 분이 많아 npm 패키지로 배포하게 되었습니다.npm install @yeahx4/cn
으로 다운 받아 쓰실 수 있습니다. 사용 방법은 같습니다. 자세한 내용은 npm이나 Github에서 보실 수 있습니다.
테일윈드를 쓰면 className이 굉장히 길어지기 때문에 배열로 클레스네임을 전달해서 합쳐주는 유틸 함수입니다.
배포는 Next.js로 개발된 만큼 편하게 Vercel을 통해서 배포하려고 합니다.
Why Next.js?
제가 Next.js를 이용해서 블로그를 만들기로 한 이유는 Next.js가 이 블로그에서 필요한 기능에 완벽하게 맞기 때문입니다. 저는 가능한 비용이 발생할 수 있는 DB나 백엔드 서버는 원치 않았기 때문에 로컬 파일에 직접적으로 접근할 수 있는 라이브러리가 필요했습니다. 그래서 사실상 풀스택 프레임워크인 Next.js는 굉장히 매력적으로 다가왔습니다. 그 외에도 Next.js의 정말 강력한 서버 사이드 렌더링은 블로그에 정말 중요한 역할을 합니다. 처음부터 빈 페이지가 아닌 contentful한 페이지를 받을 수 있다는 것은 블로그의 핵심 기능입니다. SEO도 챙길 수 있고요.
그 외에도 Vercel과의 좋은 호환성, 다른 라이브러리와의 호환성, 이미지 최적화 등등 정말 많은 기능들을 편하게 사용할 수 있기 때문에 완벽한 프레임워크라고 생각했습니다.
블로그 구조
블로그의 핵심적인 기능은 글을 쓰고 URL에 맞게 불러오는 것입니다. Next.js 13부터 server component가 들어왔기 때문에 로컬 파일에 직접 접근할 수 있습니다. 그래서 글을 mdx 형식으로 /posts
폴더에 작성해 두고 /posts/post-name
URL로 요청이 들어오면 실제로 글이 있는지 검사하고 있으면 글을 보여주는 구조를 만드려고 합니다.
사실 그냥 server component에서 fs로 파일을 읽어와도 되긴 하는데요, 처음엔 로컬에 파일을 저장할지 확실하지 않았고 나중에 블로그의 규모가 너무 커질 경우 외부 DB나 서버를 도입할 가능성이 있었기 때문에 HTTP 요청을 통해 글을 읽어오게 했습니다. Next.js의 API Routes 기능을 사용해서 프론트에서 api로 요청을 보내고 api에서 글을 읽어서 보내주는 형식을 갖게 되었습니다.
그리고 불러온 글을 지금 보시는 것처럼 화면에 보여주기만 하면 기본적인 블로그가 완성됩니다. 근데 제가 원했던 블로그는 단순히 글만을 쓸 수 있는 블로그가 아니라 원하는 리엑트 컴포넌트나 부가적인 기능을 넣고 싶었기 때문에 마크다운 렌더러를 직접 작성하게 되었습니다. Next.js로 블로그를 만드는 다른 블로그 글을 보시면 next-mdx-remote를 사용해서 mdx를 JSX로 변환하곤 하는데, 생각보다 너무 느렸어요. 그냥 읽어서 raw 데이터를 보여줄 때는 길어봐야 0.7초 정도 걸렸던 LCP(Last Contentful Paint)가 mdx-remote로 렌더링을 하면 4~15초 까지 걸렸습니다. 리드미에 이런 문구가 적혀있습니다.
Data has shown that 99% of use cases for all developer tooling are building unnecessarily complex personal blogs. Just kidding. But seriously, if you are trying to build a blog for personal or small business use, consider just using normal HTML and CSS. You definitely do not need to be using a heavy full-stack JavaScript framework to make a simple blog. You'll thank yourself later when you return to make an update in a couple years and there haven't been 10 breaking releases to all of your dependencies.
어쩃든 제 목적과 완벽히 부합하는 렌더러를 찾지 못했고 직접 구현하기로 마음먹었습니다. 구현을 어느정도 마친 지금은 훨씬 빠른 속도로(로컬 환경에서 0.2s 미만의 LCP를 보여주고 있습니다) 원하는 대로 렌더링을 수행할 수 있게 되었습니다. 이 부분은 따로 자세히 다뤄 보도록 할게요.
구현할 기능들
블로그는 생각보다 기능이 많습니다. 직접 구현하다 보면 다들 이미 좋은 서비스 쓰는 이유가 있구나 싶긴 합니다. 제가 다른 블로그에서 느꼈던 아쉬운 점들과 원하는 점들을 모두 합쳐 기능들을 구현해 보려고 합니다. 우선 각 글들이 서로 독립되어서 이어지는 글을 못 쓰는게 아쉬웠기 때문에 시리즈 기능을 구현할 예정입니다. 그 외에도 태그, 검색 기능도 있어야 하고 검색 엔진을 위한 sitemap.xml 이나 robots.txt 그리고 RSS 같은 기능도 필요합니다. 이 부분도 각각 포스트로 어떻게 구현했는지 얘기해 볼게요. 언제나 피드백은 환영입니다.
글 불러오기
위에서 언급한 대로 API Routes를 통해 글을 불러와야 합니다. /app/api/posts/route.ts
를 만들고 요청을 처리하는 코드를 작성해 주겠습니다. GET 요청을 통해 단순히 브라우저에서 접속하기만 해도 raw 데이터가 표시되는걸 막기 위해 POST를 사용했습니다.
import { postExists, readContent } from "@/lib/post/posts";
import { NextRequest, NextResponse } from "next/server";
export async function POST(reqeust: NextRequest) {
try {
const { path } = (await reqeust.json()) as { path: string };
if (!postExists(path)) {
return NextResponse.json({ message: "Not Found", path });
}
return NextResponse.json({
message: "Ok",
path,
content: await readContent(path),
});
} catch (e) {
return NextResponse.json({ e }, { status: 500 });
}
}
사실 여기에 크게 중요한 코드는 없고 readContent
가 진짜입니다.
const cache = new Map<string, string>();
export const isDev = process.env.NODE_ENV === "development";
export const readContent = async (slug: string) => {
if (!isDev && cache.has(slug)) {
return cache.get(slug) || "Unabled to read content";
}
console.log(
"Reading file:",
getPostUrl(slug),
"Current cache length:",
cache.size
);
const content = await readFile(getPostUrl(slug), "utf-8");
cache.set(slug, content);
return content;
};
참고로 readFile
은 fs/promises
의 함수입니다. production 빌드에서 글은 변하지 않기 때문에 굳이 계속 새로 읽을 필요가 없습니다. 그래서 cache에 저장해두고 처음 한 번만 읽게 구현했습니다. 서버 컴포넌트에서 fetch나 다른 방법을 통해 /api/posts
로 POST 요청에 path를 담아 전달하면 포스트의 내용을 얻을 수 있습니다. 참고로 Next.js의 fetch에 전달되는 URL은 https://example.com/api/posts
처럼 absolute URL이어야 하기 때문에 URL을 하드코딩 하거나 현재 host를 가져와야 합니다. 그리고 SSR을 위해 서버사이드에서 처리하고 싶었기 때문에 header를 읽어 처리하도록 구현했습니다.
import { headers } from "next/headers";
import { join } from "path";
export const getBaseUrl = () => {
const headersList = headers();
const host = headersList.get("host");
const protocol = headersList.get("x-forwarded-proto") ?? "http";
return `${protocol}://${host}`;
};
export const getUrl = (path: string) => {
return join(getBaseUrl(), path);
};
이제 POST 요청을 보내면 mdx파일에 적힌 내용이 raw string으로 전달됩니다. 여기서 글 정보를 담고 있는 frontmatter를 파싱하고 마크다운을 JSX로 렌더링하면 지금 보고 계시는 페이지가 완성됩니다. 이 부분은 내용이 너무 길어질 것 같으니 시리즈의 다른 포스트에서 다뤄 보도록 하겠습니다. 글 제목 위나 아래 네비게이션 메뉴를 통해 이동하실 수 있습니다.
Next.js 15 그리고 그 이후
이 글을 쓰고 있는 이 시점에선 이미 Next.js 15가 stable로 릴리즈 되었습니다. 지금 당장 올리기엔 아직 필수적인 기능조차 개발이 완료되지 않았고 100% 신뢰할 수 없기 때문에 업그레이드를 진행하지 않고 있습니다. 좀 더 나아가서 Next.js 15의 신뢰성이 확보되고 새롭게 생긴 기능이 이 블로그에 도움이 되면 업그레이드와 신기능을 사용해서 어떤 변화가 생겼는지도 이 블로그에 정리해 보도록 하겠습니다. 정말 한 달만 지나도 급변하는 요새 개발 트렌드에 맞게 대응하는 다른 내용들도 정리해볼 예정입니다. 모름지기 사이드 프로젝트는 처음 써보는 기능으로 놀아야죠.