0biglife.

[Next.js] 정적 사이트로 기술 블로그 만들기

Frontend/Next.js

· 2025-03-29

[Next.js] 정적 사이트로 기술 블로그 만들기

들어가며

이번에는 Next.js로 정적 사이트 개발하는 것에 대해 이야기해볼까 한다. 시장 내 Next.js 기술에 대한 수요가 늘고 있는만큼, 기술 블로그를 만들어보는 기회를 통해 Next.js와 조금 친해질 수 있었다. 그렇다면 왜 Next.js였으며, 왜 App Router 방식과 왜 SSG 방식으로 개발을 하였고, 또 어떤 문제들을 마주하여 해결해냈는가?

개발 고려사항

기술 블로그를 만들기 위해 가장 크게 고려한 것은 다름아닌 빠른 속도검색엔진 최적화였다. 이 사이트 내에서는 읽히는 것이 주된 행위고, SEO는 찾아오기 위한 길을 뚫어주는 것이니 당연한 얘기다. 이 두 마리 토끼 모두 잘 잡아줄 무기로 Next.js를 결정하는 것은 비교적 쉬운 선택이었다.

Next.js

Next.js는 React 기반 프레임워크 중 가장 강력한 SSR(Server-Side Rendering)과 SSG(Static Site Generation)을 지원하는 Node.js 위에서 빌드된 오픈 소스 웹 개발 프레임워크다. 그 중에서도 기술 블로그에 적합한 프레임워크라고 판단한 이유는 다음과 같다.

정적 컨텐츠 미리 렌더링 가능(SSG)

모든 페이지를 빌드시 미리 HTML로 렌더링하여 올려두는 방식이다. 그렇기 때문에 방문할 때마다 서버가 페이지를 생성하지 않아도 되므로 속도 측면에서 유리할 수밖에 없다. 블로그 특성상 "작성 후 잘 바뀌지 않는 컨텐츠"에 유리한 셈이다. 게다가 서버가 필요없고 정적 자산만 있어 AWS Amplify, Vercel과 같은 서버리스 플랫폼과 궁합도 잘맞는다. 추가적으로, 정적 파일은 CDN을 통해 서빙되기 때문에 동시 접속자가 몰려도 병목 없이 처리할 수 있다.

SEO 친화적 HTML 구조 생성

검색 엔진 최적화는 기술 블로그에 절대 빼놓을 수 없다. Next.js를 선택한 이유는 단순히 SSG가 가능해서가 아니라, SEO를 위한 기능적 이점이 풍부하기 때문이라해도 과언이 아니다. 정리하면,

  • <head> 메타태그 조작이 metadata 또는 next/head로 쉽게 가능하다.
  • Open Graph, SNS Card 등 소셜 메타태그 선언이 간편하다.
  • 구조화된 HTML과 빠른 로딩 속도가 SEO 점수에 직접 영향을 준다.

이러한 내용을 바탕으로, Google의 Lighthouse 기준 SEO 점수를 100으로 안정적으로 운영 중에 있다.

React 생태계와의 통합

마지막으로, 기존 React가 익숙한 나에게는 빠르게 적용하고, React 기반 라이브러리도 그대로 활용 가능하다.

App Router

Next.js는 전통적으로 pages/ 디렉토리를 기반으로 라우팅을 해왔는데, 이젠(Next.js 13+) app/ 디렉토리 기반의 App Router 방식이 새로운 표준이 되었다. 이 방식이 기술 블로그와 어떻게 맞물릴지 고민한 사항을 정리해보면,

서버 컴포넌트 기반의 성능 최적화

App Router에서는 컴포넌트를 Server Component, Client Component로 구분짓는다. 그 덕에 Markdown Rendering, Code Highlight 같은 리소스가 무거운 작업은 서버에서 처리하도록 분리할 수 있고, 클라이언트에서는 필요한 JS만 내려보냄으로써 번들 사이즈 최적화까지 도모할 수 있다!

Slug 경로 관리

App Router는 동적 세그먼트를 활용해 블로그 게시글과 같은 동적인 경로 관리에 유용하다. 예를 들어, app/posts/[slug]/page.ts와 같은 slug 경로를 통해 경로를 동적으로 구성한다.

추가적으로, 이러한 Aoo Router가 안정화된 버전이 15.x 버전으로 알려져있어 현재 Next.js 15.2 버전으로 개발 중이다.

프로젝트 구조

프로젝트 구조는 App Router에 맞게 구성하되, 정정 서빙될 컨텐츠는 public, content으로 나뉘어 작업되었다. 컨텐츠 운영과 유지보수를 고려하였으며, 구조는 아래처럼 꽤 간단하다. src 디렉토리 내부에는 app, components, lib으로 나뉘어져 있으며, 각각 페이지, 컴포넌트, 유틸리티 함수를 관리하고 있다. 하루 정도 잡고 publiccontent를 통합하는 작업을 진행하는 것 외에는 현재 구조대로 운영될 예정이다.

1/0biglife-blog
2 ├── /public  → 정적 파일 (이미지, favicon 등)
3 │    ├── /assets
4 │    │    ├── /posts
5 │    │    │    ├── thumnail.png
6 │    ├── favicon.ico
7 ├── /content  → 정적 블로그 게시글 저장 (MDX + 이미지 포함)
8 │    ├── /assets
9 │    │    ├── default-thumbnail.png
10 │    ├── /posts
11 │    │    └── /sample-post
12 │    │         ├── thumnail.png
13 │    │         └── index.mdx
14 ├── /src
15 │    ├── /app  → Next.js App Router 페이지 관리
16 │    │    ├── /posts
17 │    │    │    ├── /[slug]
18 │    │    │    │    ├── page.tsx
19 │    │    │    ├── page.tsx  → 블로그 목록 페이지
20 │    │    ├── /layout.tsx  → 사이트 전체 레이아웃 (헤더, 테마 전환 버튼 포함)
21 │    │    ├── /page.tsx  → 메인 페이지
22 │    ├── /components  → UI 및 공용 컴포넌트
23 │    │    ├── /ui
24 │    │    │    ├── ThemeToggle.tsx  → 다크모드 전환 버튼
25 │    │    │    ├── PostItem.tsx  → 블로그 목록의 개별 포스트 컴포넌트
26 │    │    │    ├── PostContent.tsx  → 블로그 상세 페이지 컴포넌트
27 │    │    ├── /...
28 │    ├── /lib  → 데이터 및 유틸리티 함수
29 │    │    ├── posts.ts  → MDX 파일을 읽고 HTML 변환하는 로직
30 ├── /next.config.js  → Next.js 설정 파일 (MDX 지원)
31 ├── /package.json

MDX 기반 컨텐츠 구성

블로그의 핵심은 "읽을거리"이며, 이 컨텐츠를 관리하기 위해 MDX를 사용하였다. MDX는 Markdown과 JSX를 결합한 문법으로, React 컴포넌트를 삽입할 수 있어 기술 블로그에 최적하다고 판단했다.

1---
2title: "[Next.js] 정적 사이트로 기술 블로그 만들기"
3date: "2025-03-28"
4description: "이번에는 Next.js로 정적 사이트 개발하는 것에 대해 이야기해볼까 한다. 시장 내 Next.js 기술에 대한 수요가 늘고 있는만큼, 기술 블로그를 만들어보는 기회를 통해 Next.js와 조금 친해질 수 있었다. 그렇다면 왜 Next.js였으며, 왜 App Router 방식과 왜 SSG 방식으로 개발을 하였고, 또 어떤 문제들을 마주하여 해결해냈는가?"
5thumbnail: "thumbnail.png"
6---
7
8...본론...

위와 같이 .mdx 파일 하나로 게시글의 모든 정보가 관리되며, 최상단에는 랜딩 페이지에 노출되기 위한 필드를 잡아주고 그 하단에는 마크다운으로 문서화하듯이 작성하였다.

정적 페이지 생성

MDX 컨텐츠 변환 처리

.mdx파일로 작성된 컨텐츠는 getPostBySlug() 라는 함수로 다음과 같이 처리한다.

  1. gray-matter를 사용하여 .mdx 파일에서 컨텐츠 데이터를 가져온다.
  2. 마크다운 내부 이미지 경로를 정적 경로로 변경한다.(transformImagePaths(content, slug) 함수로 ![]({image-name}.{format})assets/posts/...로 변경)
  3. next-mdx-remotecompileMDX()함수를 통해 서버에서 MDX -> HTML로 변환한다.
  4. 변환된 HTML은 서버 컴포넌트에서 post.content 형태로 클라이언트로 전달한다.

위 과정을 통해서 .mdx 파일은 빌드 타임에 정적으로 HTML로 변환되고, 그 결과가 post.content에 담겨 최종 페이지에서 렌더링되는 것이다.!

이미지 경로 자동 변환 처리를

.mdx 내부 마크다운 이미지 구문는 lib/post.ts 경로에 transformImagePaths() 함수를 만들어 처리하도록 했다. 이 함수는 이미지 경로를 정적 경로로 바꾸고, 이미지를 public/assets/posts에 복사하는 역할을 한다.

1const transformImagePaths = (content: string, slug: string): string => {
2  return content.replace(
3    /!\[(.*?)\]\((?!https?:\/\/)(.*?)\.(jpg|jpeg|png)\)/g,
4    (match, alt, srcBase) => {
5      return `![${alt}](/assets/posts/${slug}/${srcBase}.webp)`;
6    }
7  );
8};

정적 경로 생성

Next.js App Router에서는 동적 경로 생성을 위해 generateStaticParams()를 사용한다. 필요한 정보는 공식 문서를 참고하자. 사용 중인 버전과 라우팅 방식에 맞는 내용이 있어 편리했다. 그리고 게시글이 보여지는 컴포넌트에서는 slub에 따라 동적으로 .mdx 파일을 불러와서 렌더링하는 방식으로 구현하였다.

1// src/app/posts/[slug]/page.tsx/**
2export async function generateStaticParams() {
3  const posts = await getAllPosts();
4  if (!posts || posts.length === 0) {
5    console.error("⚠️ No posts found! Check your content directory.");
6    return [];
7  }
8  return posts.map((post) => ({ slug: post.slug }));
9}
10
11// SEO 최적화를 위한 메타 데이터 설정
12export async function generateMetadata({ params }: { params: Params }) {
13  //
14}
15
16export default async function PostDetailPage({ params }: { params: Params }) {
17  const resolvedParams = await params;
18  const { slug } = resolvedParams;
19
20  if (!slug) return notFound();
21
22  // slug === undefined일 경우 런타임 오류 발생 가능 -> "" 추가
23  const decodedSlug = decodeURIComponent(slug ?? "");
24  const post = await getPostBySlug(decodedSlug);
25  if (!post) return notFound();
26
27  return (
28    <Box minW="300px">
29      ...
30      <Box className="prose lg:prose-lg" flex="1">
31        {post.content}
32      </Box>
33      ...
34    </Box>
35  );
36}

부딪힌 이슈와 주의할 점

1. next-mdx-remote vs @next/mdx 고민

next-mdx-remote@next/mdx는 둘 다 MDX를 HTML로 변환해주는 라이브러리이다. next-mdx-remote는 App Router 환경에서 서버/클라이언트 컴포넌트 구분이 까다롭다는 단점이 있으나 동적 컨텐츠 로딩, 전처리 커스터마이징 등 더 많은 기능을 제공하고, @next/mdx는 빌드 타임에 MDX를 직접 컴파일하여 빠르고 간편하지만 동적 MDX 컨텐츠 로딩 제한과 커스텀 컨텐츠 전처리가 안된다는 단점이 있어 결국 next-mdx-remote를 선택하였다. 현재는 next-mdx-remote/src를 사용해 서버 컴포넌트에서 처리하도록 했다.

2. params 객체의 비동기 처리 오류

Next.js 15를 기준으로 바뀐 부분이 꽤나 있었다. 특히 params 객체의 비동기 처리가 이전과 다르게 바뀌었다. Next.js 15에서는 params 객체가 비동기 로드된다. 아래와 같은 코드처럼 직접 params.slug를 쓰면 오류가 발생한다.

1const { slug } = params; // 오류 발생

따라서, 반드시 await으로 비동기적으로 처리해야한다. 공식 문서 참고

1const { slug } = await params; // 정상

3. 클라이언트 컴포넌트에서 MDX content 사용시 useState 오류

1TypeError: Cannot use 'useState' within a server component...

다음과 같은 에러가 반복적으로 떴는데, 이는 서버 컴포넌트에서 useState를 사용하면 발생하는 오류로, 클라이언트 컴포넌트에서만 사용하도록 주의해야한다. 이를 해결하기 위해서는 서버 컴포넌트에서 필요한 데이터를 미리 불러와서 클라이언트 컴포넌트로 넘겨주는 방식으로 해결하였다. 즉, app/page.tsx는 서버 컴포넌트로 만들고, app/postContent.tsx는 클라이언트 컴포넌트로 만들어서 데이터를 주고받는 방식으로 해결했다.

1// src/app/page.tsx
2export default async function MainPage() {
3  const posts = await getAllPosts();
4  const devLogs = await getAllDevLogs();
5
6  return (
7    <PostContent
8      posts={posts}
9      featuredPosts={featuredPosts}
10      devLogs={devLogs}
11    />
12  );
13}
14
15// src/app/postContent.tsx
16"use client";
17
18import dynamic from "next/dynamic";
19import {
20  BLOG_LEFT_TOP_CATEGORY,
21  BLOG_RIGHT_TOP_CATEGORY,
22} from "@/lib/constant";
23import { Post } from "@/lib/types";
24import { useEffect, useState } from "react";
25
26interface PostContentProps {
27  posts: Post[];
28  featuredPosts: Post[];
29  devLogs: DevLog[];
30}
31
32export default function PostContent({
33  posts,
34  featuredPosts,
35  devLogs,
36}: PostContentProps) {
37  const [views, setViews] = useState({ today: "0", total: "0" });
38
39  useEffect(() => {
40    fetchViews();
41  }, []);
42
43 ...
44}

4. dynamic 함수 사용시 ssr: false 사용 불가능

Next.js 15부터는 dynamic()함수에서 ssr: false 옵션이 제거되었다. 13부터 도입된 App Router의 서버/클라이언트 컴포넌트 구분을 명확하게 하고자 제거되었다고 한다. 따라서, dynamic() 함수 사용시 옵션란은 비워두고서 컴포넌트 동적 로딩을 추가하자.

1"use client";
2
3import dynamic from "next/dynamic";
4
5// Next.js 15 부터는 `ssr: false` 사용 불가능으로 클라이언트 컴포넌트 따로 분리
6const SliderContainer = dynamic(() =>
7  import("@/components/template/SliderContainer")
8);
9const LogContainer = dynamic(() =>
10  import("@/components/template/LogContainer")
11);
12const FilteredPostList = dynamic(() =>
13  import("@/components/template/FilteredPostList")
14);

번외로, 클라이언트 컴포넌트에는 최상단에 use client를 붙이고, 서버 컴포넌트은 Next.js에서는 기본이 서버 컴포넌트이기 때문에 특별히 뭘 붙이지 않아도 된다. 단, App Router 전용 기능으로는, import "server-only"를 통해 서버 전용 모듈임을 명시하여 클라이언트에서 잘못 import하지 못하도록 막을 수 있다. 서버/클라이언트 컴포넌트가 구분 없이 동작할 수 있기 때문에, 실수로 클라이언트에서 fs, path, process.env와 같은 Node 전용 모듈을 import하면 런타임 에러가 나기 때문에 이를 방지하기 위함이다.

1// src/lib/posts.ts
2import "server-only";
3
4export async function getAllPosts() {
5  // 서버에서만 파일 시스템 접근
6  const fs = require("fs");
7  ...
8}

추가로, MDX 공식 문서를 참고해서 MDX 사용시 겪은 문법, 파싱 오류, 라이브러리 충돌 문제 등을 조회해보자. 주로 acorn 파서 문제, 잘못된 JSX/HTML 문법, unexpected charater, AST 트랜스포머 문제 등에 유용하다.

빌드/배포 설정 옵션

자, 이제 로직 코드는 마쳤으니 실제 정적 페이지가 생기는지 확인해보자. Next.js에서 어떻게 앱을 빌드하고 배포할지 결정하는 빌드 설정 옵션은 next.config.ts에서 설정할 수 있다(next.config.ts는 기본적으로 루트에 위치하며, 빌드, 배포, 환경 변수 등을 설정할 수 있다). output: "export"output: "standalone"이 있다. 하나씩 살펴보자.

ouput: "export"

output: "export"는 정적 HTML만 생성하는 순수 SSG 전용 설정이다. npm run export를 실행하면 /out 디렉토리에 HTML, CSS, JS 등만 담긴 완전한 정적 사이트가 생성된다. 특징을 정리해보자면,

  • getStaticProps, generateStaticParams 같은 SSG 관련 함수만 지원한다.
  • API Routes 작동하지 않는다.
  • 서버 기능이 전혀 없는 환경에 적합하다.

ouput: "standalone"

output: "standalone"은 정적/동적 페이지를 모두 포함하고, Next.js 서버 전체를 독립 실행 가능한 형태로 번들링하는 설정이다.

  • API Routes를 포함한 모든 서버 기능이 동작한다.
  • fetch("api/{route-name}") 같은 클라이언트 요청도 정상 작동한다.
  • SSR(getServerSideProps), 동적 렌더링, 미들웨어 등 지원한다.
  • AWS Amplify, Docker, Vercel 등 Node 런타임 기반 플랫폼에 적합하다.

이미 바로 윗문장을 보면 알곘지만, output: "standalone"을 선택했다. 이유는 추후 붙이게 될 조회수 API(Google Analytics 4)과 같은 서버 측 데이터 핸들링이 필요하였고 이를 위해 Next.js 15의 API Routes를 사용하고 싶어 output: "standalone"을 선택하였다. 그리고 AWS Amplify 의 환경 변수도 필요했기에 빌드 이후에 .env 값을 읽어올 수 있는 Node 서버가 없는 output: "export" 방식은 사용할 수 없었다.

생성된 정적 페이지 검증

이제 코드 작성과 설정까지 마쳤다면, 빌드 후에 정적 페이지가 잘 생성되었는지를 살펴보면서 마무리하자. 현재 package.json은 아래처럼 되어있다.

1{
2  "name": "0biglife-blog",
3  "version": "0.1.0",
4  "scripts": {
5    "dev": "next dev",
6    "start": "next start",
7    "lint": "next lint",
8    "serve": "npx serve out",
9    "optimize:images": "ts-node scripts/optimize-images.js",
10    "build": "npm run optimize:images && next build && npm run sitemap",
11    "sitemap": "npx next-sitemap"
12  },
13  ...
14}

npm run optimize:images 부분은 빌드와 동시에 모든 이미지들을 webp 포맷으로 변환해주는 용도고 npm run sitemap은 사이트맵을 생성해주는 용도이므로, 현재 게시글에서는 무시하자. next build를 실행하면 output: "standalone"을 감지하고 .next 경로에 Node.js 서버로 실행 가능한 번들을 만들고, 동시에 .next/static/ 내부에 정적 HTML과 JS 파일도 생성한다.

1# 실제 배포는 .next/standalone/server.js 등으로 수행
2npm run build
3npm start  # next start → standalone 모드로 서버 실행

마치며

이렇게 Next.js로 SSG 방식으로 기술 블로그를 어떻게 만들었는지 과정을 정리해보았다. Next.js의 강력한 SSG 기능과 App Router 방식을 활용하면, 빠르고 SEO 친화적인 사이트를 만들 수 있고, Next.js 15에서는 App Router가 안정화되어 서버/클라이언트 컴포넌트를 명확하게 구분할 수 있게 되었으며, 이를 통해 성능 최적화와 코드 유지보수가 더 쉬워졌다(이렇게 정리된 글과 패키지처럼 각자 기술 블로그를 쉽게 배포할 수 있는 기본 퍼블릭 리포지토리만 마련된다면 누구나 쉽게 개인 페이지를 만들 수 있지 않나 싶기도 하다). 이미지 로드가 굉장히 느려서 Next.js로 전체 마이그레이션하는 과정에서 발생한 것들을 글 하나로 정리하면서 뭔가 마음이 후련하다.

내가 만든 것에 대한 피드백이나 문의 사항에 대한 소통이 가능하려면 트래픽과 댓글 기능이 필요해보인다. 빠른 시일 내로 GA4도 붙이고 github 댓글 기능도 붙여보고자 한다.

이번 글이 도움이 되었기를 바라며, 다음 글에서 또 만나도록 하자.

Index

들어가며개발 고려사항Next.jsApp Router프로젝트 구조MDX 기반 컨텐츠 구성정적 페이지 생성MDX 컨텐츠 변환 처리이미지 경로 자동 변환 처리를정적 경로 생성부딪힌 이슈와 주의할 점빌드/배포 설정 옵션ouput: "export"ouput: "standalone"생성된 정적 페이지 검증마치며