완전한 Next.js 성능 및 SEO 최적화 가이드
Source: Dev.to
내장 컴포넌트 최적화
Next.js는 자동으로 성능 모범 사례를 적용하는 최적화된 내장 컴포넌트를 제공하여 수동 최적화가 필요 없게 하고 일반적인 함정을 줄여줍니다.
next/image
Image 컴포넌트는 Next.js에서 가장 강력한 성능 도구 중 하나입니다. 다음을 제공합니다:
- 자동 레이지 로딩
- 디바이스 기반 이미지 리사이징
- 포맷 최적화 (WebP, AVIF)
- 누적 레이아웃 이동(CLS) 방지
- 대역폭 사용량 감소
기본 구현
import Image from 'next/image';
export default function ProductCard() {
return (
);
}
고급 설정 (next.config.js)
module.exports = {
images: {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
domains: ['example.com', 'cdn.example.com'], // 허용된 외부 도메인
remotePatterns: [
{
protocol: 'https',
hostname: '**.example.com',
pathname: '/images/**',
},
],
formats: ['image/webp', 'image/avif'],
minimumCacheTTL: 60,
dangerouslyAllowSVG: true,
contentSecurityPolicy:
"default-src 'self'; script-src 'none'; sandbox;",
},
};
유의 사항
- 화면에 바로 보이는 이미지에는
priority={true}를 사용합니다. - 레이아웃 이동을 방지하려면 항상
width와height를 지정합니다. - 반응형 컨테이너에는
fillprop을 사용합니다. - 외부 이미지는 도메인 화이트리스트가 필요합니다.
- WebP 포맷은 JPEG보다 약 30 % 작은 파일 크기를 제공합니다.
next/link
전체 페이지 새로고침 없이 클라이언트‑사이드 네비게이션을 가능하게 합니다.
import Link from 'next/link';
export default function Navigation() {
return (
About
Blog
);
}
유의 사항
- 방문 가능성이 낮은 페이지 링크에는
prefetch={false}를 설정합니다.
스크립트 로딩 전략
서드파티 스크립트 로딩을 최적화하면 메인 스레드 차단을 방지하고 페이지 로드 성능을 향상시킵니다. Script 컴포넌트를 사용하면 언제, 어떻게 스크립트를 로드할지 세밀하게 제어할 수 있습니다.
중요한 스크립트 – beforeInteractive
Next.js 코드와 페이지 하이드레이션 이전에 로드됩니다. 사용자 인터랙션 전에 반드시 실행돼야 하는 스크립트에 사용합니다.
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
{children}
);
}
분석 및 비핵심 스크립트 – afterInteractive
페이지가 인터랙티브해진 뒤 로드됩니다. 분석, 광고 등 필수적이지 않은 스크립트에 적합합니다.
import Script from 'next/script';
export default function Analytics() {
return (
console.log('Analytics loaded')}
/>
);
}
레이지 로드 스크립트 – lazyOnload
브라우저가 유휴 상태일 때 로드됩니다. 채팅 위젯, 소셜 미디어 버튼 등 비핵심 스크립트에 이상적입니다.
import Script from 'next/script';
export default function ChatWidget() {
return (
console.log('Chat widget ready')}
/>
);
}
워커 전략 – worker
웹 워커에서 스크립트를 실행해 성능을 향상시킵니다.
import Script from 'next/script';
export default function Page() {
return (
);
}
유의 사항
- 정말 중요한 스크립트에만
beforeInteractive를 사용합니다. - 대부분의 분석 스크립트는
afterInteractive를 사용합니다. - 소셜 미디어 위젯과 채팅은
lazyOnload를 사용합니다. - 인라인 스크립트보다
onLoad/onReady콜백을 선호합니다.
의존성 관리
사용되지 않는 의존성을 제거하면 번들 크기가 줄고 빌드 시간이 단축되며 보안 취약점도 최소화됩니다.
사용되지 않는 패키지 식별
# depcheck 전역 설치
npm install -g depcheck # 또는: yarn global add depcheck
# 프로젝트 루트에서 실행
depcheck
# 옵션 사용
depcheck --ignores="eslint-*,@types/*"
# 디렉터리 지정
depcheck ./src --ignore-dirs=build,dist
예시 출력
Unused dependencies
* lodash
* moment
* axios
Missing dependencies
* react-icons
Unused devDependencies
* @testing-library/jest-dom
자동 정리 스크립트 (package.json)
{
"scripts": {
"deps:check": "depcheck",
"deps:clean": "depcheck --json | jq -r '.dependencies[]' | xargs npm uninstall"
}
}
유의 사항
depcheck를 정기적으로(월 1회 또는 주요 릴리즈 전) 실행합니다.- 설정 파일에만 사용되는 패키지는 제거 전에 반드시 확인합니다.
@types/*와 같은 타입 패키지는 검색에 나타나지 않을 수 있으니 필요하면 유지합니다.- 의존성을 제거한 뒤에는 충분히 테스트합니다.
devDependencies와dependencies를 명확히 구분합니다.- 겉보기에 사용되지 않는 패키지를 유지하는 이유를 문서화합니다.
캐싱 및 점진적 정적 재생성 (ISR)
스마트 캐싱 전략은 콘텐츠를 더 빠르게 제공하고 서버 부하를 줄이며 데이터를 최신 상태로 유지합니다. ISR은 빌드 이후에도 정적 페이지를 업데이트할 수 있게 해줍니다.
기본 ISR 구현
// app/blog/[slug]/page.js
export const revalidate = 3600; // 매시간 재검증
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(post => ({ slug: post.slug }));
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return (
## {post.title}
{post.content}
{post.publishedAt}
);
}
온‑디맨드 재검증
// app/api/revalidate/route.js
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request) {
const secret = request.nextUrl.searchParams.get('secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const path = request.nextUrl.searchParams.get('path');
if (path) {
revalidatePath(path);
return NextResponse.json({ revalidated: true, now: Date.now() });
}
return NextResponse.json({ message: 'Missing path' }, { status: 400 });
}
태그 기반 재검증
// Fetch with cache tags
export default async function ProductList() {
const products = await fetch('https://api.example.com/products', {
next: { tags: ['products'], revalidate: 3600 }
});
return {/* Render products */};
}
// app/api/revalidate-products/route.js
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST() {
revalidateTag('products');
return NextResponse.json({ revalidated: true });
}
Cache‑Control 헤더
// app/api/data/route.js
export async function GET() {
const data = await fetchData();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300'
}
});
}
유의 사항
- ISR은 블로그, 제품 목록 등 주기적으로 업데이트되는 콘텐츠에 이상적입니다.
- 자주 변하는 데이터는 짧은 재검증 간격(예: 60 s)을 사용합니다.
- 세밀한 캐시 무효화를 위해 ISR과 태그 기반 재검증을 결합합니다.
폰트 최적화
Next.js는 내장 next/font API를 사용할 때 폰트를 자동으로 최적화합니다.
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-inter',
});
export default function RootLayout({ children }) {
return (
{children}
);
}
팁
- 필요한 문자 서브셋만 로드합니다.
- 요청 수를 줄이기 위해 가변 폰트를 선호합니다.
- CSS에서
@import를 피하고next/fontAPI를 사용합니다.
레이지 로딩 및 코드 스플리팅
동적 임포트
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('../components/HeavyComponent'), {
loading: () =>
Loading…
,
ssr: false, // 클라이언트 사이드에서만 로드
});
- 차트, 지도 등 큰 라이브러리는 별도 청크로 분리합니다.
- 브라우저 API에 의존하는 컴포넌트는
ssr: false를 사용합니다.
리스트 가상화
긴 리스트는 react-window 또는 react-virtualized 같은 라이브러리를 사용해 화면에 보이는 항목만 렌더링합니다.
import { FixedSizeList as List } from 'react-window';
export default function VirtualizedList({ items }) {
return (
{({ index, style }) => (
{items[index].title}
)}
);
}
데이터 패칭 최적화
클라이언트‑사이드 패칭 with SWR
import useSWR from 'swr';
const fetcher = url => fetch(url).then(res => res.json());
export default function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000,
});
if (isLoading) return
Loading…
;
if (error) return
Error loading profile.
;
return {data.name};
}
- 자주 업데이트될 필요가 없는 데이터는
revalidateOnFocus를false로 설정합니다. - 요청 중복을 제어하려면
dedupingInterval을 조정합니다.
JavaScript 번들 최적화
- Tree‑shaking: 필요 없는 전체 모듈 대신 사용되는 함수만 임포트합니다(
import { foo } from 'lodash'vsimport _ from 'lodash'). - 번들 분석:
next build && next analyze혹은webpack-bundle-analyzer같은 도구를 활용합니다. - 자산 압축: CDN이나 Vercel Edge에서 gzip/Brotli 압축을 활성화합니다.
CSS 최적화
- CSS 모듈이나 styled‑components를 사용해 스타일을 스코프하고 전역 팽창을 방지합니다.
purgecss(Next.js에서는next-purgecss통합)와 같은 도구로 사용되지 않는 CSS를 제거합니다.- 반응형 디자인을 위해 별도 파일보다
@media쿼리를 선호합니다.
API 라우트 캐싱
비싼 API 응답을 엣지에서 캐시합니다.
// app/api/products/route.js
import { NextResponse } from 'next/server';
import { fetchProducts } from '@/lib/db';
export async function GET() {
const products = await fetchProducts();
return NextResponse.json(products, {
headers: {
'Cache-Control': 'public, s-maxage=120, stale-while-revalidate=300',
},
});
}
Edge Runtime
연산 집약적인 로직을 Edge Runtime에 배포해 지연 시간을 최소화합니다.
// app/api/geo/route.js
export const runtime = 'edge';
export async function GET(request) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
// 빠른 지리 위치 조회 수행
return new Response(`Your IP: ${ip}`);
}
Core Web Vitals
- Largest Contentful Paint (LCP): 화면 위쪽 콘텐츠를 최적화해 LCP를 낮게 유지합니다.
export default function BlogPost({ post }) {
return (
{post.title} – My Blog
{/* Post content */}
);
}
보안 최적화
- API 라우트나 미들웨어에서
Content‑Security‑Policy,X‑Content‑Type‑Options,Referrer‑Policy헤더를 설정합니다. next-safe등과 같은 라이브러리를 사용해 사용자 생성 HTML을 정제합니다.- 의존성을 최신 상태로 유지하고
npm audit를 정기적으로 실행합니다.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; img-src https: data:; script-src 'self' 'unsafe-inline';"
);
return response;
}