Lighthouse 만점으로 증명하는 Next.js SEO 전략 (feat. 서버 컴포넌트)
🖐️ 서론: 기술 스택과 선정 이유
📚 이 포스팅은 실제로 블로그를 운영하며 SEO 및 성능 최적화를 진행한 후기입니다. 이 블로그는 Next.js 15(App Router)와 SCSS Modules로 구성되어 있습니다.
개인 블로그는 비교적 가벼운 프로젝트다 보니 구축 이후의 목표는 자연스럽게 Lighthouse 만점(400점) 도전으로 이어졌습니다. SEO나 웹 접근성을 위해 했던 시도들을 간단하게 회고해 보려고 합니다.
TL;DR
- 목표: Lighthouse 400점(만점) 달성하기
- 문제: react-syntax-highlighter 로 인한 게시글 상세 페이지 성능 저하 발생
- 해결: RSC + Shiki로 빌드 타임 하이라이팅
- 결과: Lighthouse 만점 달성
✅ SEO 뽀개기
1. SSR, SSG
블로그는 마크다운 파일을 기반으로 정적 콘텐츠를 서빙하도록 구성했습니다. 따라서 굳이 CSR을 사용할 필요 없이 모든 페이지를 빌드 시점에(SSG) 그릴 수 있습니다.
Route (app) Size First Load JS
┌ ○ / 804 B 111 kB
├ ○ /_not-found 1 kB 102 kB
├ ○ /about 2.16 kB 109 kB
├ ƒ /api/[...nextauth] 153 B 102 kB
├ ƒ /api/comments 153 B 102 kB
├ ƒ /api/og 153 B 102 kB
├ ○ /apple-icon.png 0 B 0 B
├ ƒ /atom 153 B 102 kB
├ ƒ /feed 153 B 102 kB
├ ○ /icon.png 0 B 0 B
├ ○ /manifest.webmanifest 153 B 102 kB
├ ƒ /post 3.37 kB 155 kB
├ ● /post/[postSlug] 4.42 kB 151 kB
├ ├ /post/seo-optimization-with-lighthouse
├ ├ /post/separating-ui-and-domain-components
├ ├ /post/ios-popup-policy-and-window-open
├ └ [+4 more paths]
├ ○ /robots.txt 153 B 102 kB
├ ƒ /rss 153 B 102 kB
└ ○ /sitemap.xml 153 B 102 kB
+ First Load JS shared by all 101 kB
├ chunks/4bd1b696-cc729d47eba2cee4.js 54.1 kB
├ chunks/5964-b14196516283122f.js 44 kB
└ other shared chunks (total) 3.39 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)2. RSS, 구조화 데이터 등
대부분의 블로그는 RSS 기능을 제공합니다. api 라우터를 통해서 RSS를 처리할 수 있습니다.
// rss 생성 코드
{
title: post.title,
id: post.slug,
link,
description: post.description,
content: [...post.content].slice(0, RSS_CONTENT_SIZE).join(''),
author: [master],
contributor: [master],
date: new Date(post.date),
category: post.tags?.map((tag) => {
return {
name: tag,
domain: siteConfig.url,
scheme: siteConfig.url,
term: tag,
};
}),
image: post.image || undefined,
}3. 웹 메타데이터
웹 표준 스펙으로 파비콘, apple icon, 구조화 데이터, sitemap 등이 있습니다. Next.js 공식 문서에 자세히 가이드 되어있으며 모든 스펙을 충족하도록 구현합니다. Next.js 메타데이터
// sitemap.ts 일부
[
{
url: getHref(pathsConfig.home),
lastModified: new Date(),
changeFrequency: Frequency.WEEKLY,
priority: 1,
},
{
url: getHref(pathsConfig.about),
lastModified: new Date(),
changeFrequency: Frequency.MONTHLY,
priority: 0.8,
},
{
url: getHref(pathsConfig.post),
lastModified: new Date(),
changeFrequency: Frequency.WEEKLY,
priority: 0.7,
},
...postSiteMaps,
];4. 웹 접근성
시맨틱 태그를 의식했습니다. 시맨틱 태그란 article, a태그처럼 의미가 있는 태그를 말합니다. 단순히 디자인을 나누기 위한 구획일 경우 div(인라인의 경우 span)를 사용하겠지만 의미를 나타낼 수 있는 경우 적절한 태그 (시맨틱 태그)를 사용해 접근성을 높일 수 있습니다.
예를 들어 게시글 목록 스타일 내부의 각 게시글 하나를 표시한다면 아래와 같은 구조가 나오도록 컴포넌트를 설계합니다.
<section>
<h3>게시글</h3>
<!-- 디자인을 위한 구역 -->
<div>
<ul>
<li>
<article>게시글 상세</article>
</li>
<li>...</li>
<li>...</li>
</ul>
</div>
</section>함께 일했던 신입 팀원이 컴포넌트를 재활용하면서도 태그를 바꿀 수는 없을지 질문해주셨던 기억이 있습니다. UI 컴포넌트 (참고: UI 컴포넌트 설계 관련 지난 포스팅)는 여러 페이지, 도메인에서 호출될 수 있어야 합니다. 컴포넌트 내부 분기 로직 없이도 다형성을 사용하면 시맨틱 태그를 살릴 수 있습니다. 예를 들어 제 블로그에서는 메인 페이지에 나오는 인기 게시글 제목에는 h2 태그를, 게시글 목록에 나오는 게시글 제목에는 h3 태그를 사용합니다. 둘은 같은 스타일로 그려지고 같은 컴포넌트를 사용합니다. 만약 PostItem을 그리는 요소에 태그가 분기문으로 들어가있으면 그 컴포넌트는 확장성이 제한되기에 좋지 못합니다. 그렇다고 매번 컴포넌트를 합성할 수는 없습니다. 따라서 이런 경우 제목에 사용되는 태그를 변경해 주기 위해 다형성을 활용할 수 있습니다. (props를 통한 재활용 역시 다형성의 일부입니다.)
// PostList.tsx
/**
* titleProps?: {
as: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; // 모든것을 허용하지 말고 용도에 맞게 제한하기
};
**/
{posts.map((post) => {
return <PostItem key={post.id} post={post} variant={variant} titleProps={{ as: 'h3' }} {...rest} />;
})}
- 그 외 태그의 계층 (제목 태그 순서) 지키기나 스크린 리더 대응 등 다른 수단들이 많지만 위 요소 정도만 지켜도 SEO와 접근성에서 높은 점수를 기대할 수 있습니다.

2. 성능 개선
1. 문제점
현재 블로그는 총 4 구역으로 나눠져있습니다. 홈, 게시글 목록, 게시글 상세, 내 정보입니다. 문제는 게시글 상세 페이지였습니다.

2. 트러블 슈팅
- Step 1. 원인 파악
성능 점수가 낮은 원인으로 TBT가 300ms 정도로 나왔습니다. 원인으로는 코드 하이라이팅에 사용중이던 react-syntax-highlighter(2.2mb)의 번들이 꽤나 컸습니다.
- Step 2. 해결책 고민
첫 번째 생각은 react-syntax-highlighter를 가볍게 하기 위해서 필요한 언어만 가져오는 방법입니다. 문제는 저는 다양한 프로그래밍언어를 체험해 보는 것을 좋아한다는 것입니다. 다른 언어 관련해서 글을 써보고 싶을 경우 해당 언어를 추가해 주면 되지만, 개발 블로그에서 그런 방식으로 의존성을 관리하고 싶지 않았습니다. (회사 일이었다면 어땠을지도 고민해 봤었는데, 블로그를 담당하는 담당자가 프론트엔드 개발자가 아닐 가능성이 높기에 업무였다고 하더라도 좋은 해결책은 아닐 듯합니다.)
그래서 든 생각이 서버에서 하이라이트 처리 후 클라이언트로 내려버리면 문제가 없지 않을까 하는 생각이었습니다. 모든 페이지를 SSG로 처리하고 있었기에 가능할 것 같았습니다. 이 생각으로 서치해 보니 next-mdx-remote나 rehype-pretty-code의 조합으로 제가 생각했던 의도를 구현할 수 있을 것 같았습니다.
- Step 3. 적용
라이브러리 교체를 적용합니다. react-markdown + react-syntax-highlighter -> next-mdx-remote/rsc와 rehype-pretty-code(Shiki)를 도입했습니다.
결과
- AS-IS (Client): 사용자가 접속 → JS 라이브러리 다운로드 → 브라우저가 코드 분석 → 색상 입힘 (느림, TBT 발생)
- TO-BE (Server): 빌드 타임에 서버가 코드 분석 → 코드 하이라이팅이 포함된 문서 클라이언트에 전송 (빠름, TBT 해소)
Next.js의 서버 컴포넌트(RSC) 환경에서 미리 렌더링 된 HTML을 받아와서 클라이언트의 TBT를 개선했습니다. 다만 **Mermaid(다이어그램)**는 여전히 고민거리였습니다. 서버에서 SVG로 구워버릴 수도 있지만, Mermaid 라이브러리가 DOM API에 의존적이라 클라이언트 실행이 불가피했습니다. 따라서 Mermaid가 필요한 포스팅에서만 로드되도록 next/dynamic을 적용해 초기 로딩 속도를 방어했습니다.
'use client';
import dynamic from 'next/dynamic';
import { Size } from '@/constants/size'
// 기존 Mermaid의 구현 코드는 MermaidContent로 이동합니다.
// ssr: false로 설정하여 클라이언트에서만 로드되도록 처리
const MermaidContent = dynamic(() => import('./MermaidContent'), {
ssr: false,
loading: () => <Loading size={Size.Lg} />,
});
export default function Mermaid({ chart }: { chart: string }) {
return <MermaidContent chart={chart} />;
}그 외 기타
- Framer Motion: 애니메이션이 성능에 영향을 줄까 걱정되어 전후 비교를 해보았으나, 다행히 단순한 인터랙션 수준에서는 성능 감점이 거의 없었습니다. (포스팅을 하는 현재는 제거된 상태입니다.)
- 이미지 로딩 priority 지정, 이미지 밀도 최적화, 트리 쉐이킹 개선(Next.js) 등도 적용했습니다.
📊 결과 및 회고
위의 과정을 거쳐 현재 블로그의 모든 페이지에 대해서 Lighthouse 전 항목 100점(400점)을 달성했습니다.
댓글(Giscus), 검색, 테마, GA 등 꽤 무거운 기능들이 포함되어 있음에도 만점이 가능했던 건, **"클라이언트 처리 로직을 서버로 최대한 미뤘기 때문"**입니다.
현실적으로 회사에서 운영하는 프로덕션에서는 성능 지표 100점은 어렵지 않을까 생각합니다. 그래도 접근성, SEO, 웹 권장사항 정도는 충분히 지킬 수 있습니다.
성능의 경우에도 청크를 나누거나 lazy loading, 이미지 밀도 최적화 등의 작업을 어떻게 하는지에 따라 충분히 달라질 수 있습니다.
Next.js라는 프레임워크는 그 자체로 꽤 무겁습니다. 하지만 잘 활용하면 클라이언트의 부하를 서버로 옮겨서, 순수한 HTML + CSS 수준으로 개선할 수 있습니다. (물론 바닐라 보다 빠르지는 못합니다.)
결국, 가장 빠른 웹사이트는 '잘 짜인 HTML'이라는 오래된 진리를 다시 한번 확인한 프로젝트였습니다.
-
홈

-
마이 페이지

-
게시글 목록

- 게시글 상세
