{post.title}
{post.content}
이미지 로딩 중...
AI Generated
2025. 11. 8. · 4 Views
Next.js 애플리케이션을 프로덕션 환경에 배포하기 전에 반드시 알아야 할 빌드 최적화 기법을 다룹니다. 번들 사이즈 분석부터 코드 스플리팅, 트리 쉐이킹까지 실무에서 바로 적용할 수 있는 최적화 전략을 단계별로 안내합니다.
여러분이 Next.js 프로젝트를 개발하고 "이제 배포해야지!"라고 생각했을 때, 막상 빌드를 돌려보니 빌드 시간이 너무 오래 걸리거나 번들 사이즈가 예상보다 훨씬 큰 경험이 있으신가요? 로컬에서는 잘 돌아가던 애플리케이션이 프로덕션에서는 느려지는 것을 보면 당황스럽습니다.
이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 개발 모드와 프로덕션 모드는 완전히 다른 최적화 전략을 사용하기 때문에, 제대로 설정하지 않으면 사용자 경험이 크게 저하될 수 있습니다.
특히 번들 사이즈가 크면 초기 로딩 시간이 길어지고, SEO 점수도 낮아집니다. 바로 이럴 때 필요한 것이 next.config.js를 통한 프로덕션 빌드 최적화입니다.
올바른 설정만으로도 번들 사이즈를 30-50% 줄이고, 빌드 시간을 단축시킬 수 있습니다.
간단히 말해서, next.config.js는 Next.js 애플리케이션의 모든 빌드 동작을 제어하는 설정 파일입니다. 이 파일에서 컴파일러 옵션, 이미지 최적화, 코드 압축 등을 세밀하게 조정할 수 있습니다.
프로덕션 빌드를 위해서는 swcMinify, compress, productionBrowserSourceMaps 같은 옵션들을 적절히 설정해야 합니다. 이러한 설정들은 최종 번들의 크기와 성능에 직접적인 영향을 미칩니다.
예를 들어, 대용량 이미지가 많은 이커머스 사이트의 경우, 이미지 최적화 설정만으로도 페이지 로딩 속도를 2-3배 개선할 수 있습니다. 기존에는 webpack 설정을 직접 수정해야 했다면, Next.js 13 이후부터는 SWC 컴파일러를 기본으로 사용하여 더 빠르고 효율적인 빌드가 가능합니다.
SWC는 Rust로 작성되어 Babel보다 최대 17배 빠른 성능을 제공합니다. 핵심 특징으로는 자동 코드 압축(minification), 데드 코드 제거(tree shaking), 그리고 환경별 최적화 전략이 있습니다.
이러한 특징들이 중요한 이유는 최종 사용자가 다운로드해야 할 JavaScript 파일 크기를 최소화하여 빠른 페이지 로딩을 보장하기 때문입니다.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// SWC 미니파이 활성화 (Babel보다 17배 빠름)
swcMinify: true,
// gzip/brotli 압축 활성화
compress: true,
// 프로덕션에서 소스맵 비활성화 (보안 및 번들 크기 감소)
productionBrowserSourceMaps: false,
// 이미지 최적화 설정
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200],
},
// 불필요한 x-powered-by 헤더 제거
poweredByHeader: false,
}
module.exports = nextConfig
이것이 하는 일: 이 설정 파일은 Next.js 빌드 프로세스를 제어하여 프로덕션 환경에 최적화된 번들을 생성합니다. 개발 모드에서는 빠른 리로드를, 프로덕션에서는 최소 크기와 최고 성능을 목표로 합니다.
첫 번째로, swcMinify 옵션은 JavaScript 코드를 압축하여 변수명을 짧게 만들고 공백을 제거합니다. 이렇게 하는 이유는 네트워크를 통해 전송되는 데이터 양을 줄여 사용자의 초기 로딩 시간을 단축하기 위함입니다.
SWC 컴파일러는 Rust로 작성되어 있어 기존 Babel보다 월등히 빠른 처리 속도를 자랑합니다. 그 다음으로, compress와 images 설정이 실행되면서 정적 자산들을 최적화합니다.
compress는 gzip이나 brotli 압축을 활성화하여 HTML, CSS, JavaScript 파일의 크기를 60-80% 수준으로 줄입니다. images 설정에서는 최신 포맷인 AVIF와 WebP를 우선 사용하도록 지정하는데, 이는 JPEG 대비 50% 이상 작은 파일 크기를 제공합니다.
마지막으로, productionBrowserSourceMaps를 false로 설정하여 소스맵 파일 생성을 비활성화합니다. 소스맵은 개발 단계에서는 디버깅에 유용하지만, 프로덕션에서는 보안 취약점이 될 수 있고 번들 크기를 크게 증가시킵니다.
이를 비활성화하면 최종 빌드 크기가 20-30% 감소하며, 소스 코드 노출 위험도 사라집니다. 여러분이 이 설정을 사용하면 초기 페이지 로딩 시간이 30-50% 단축되고, Lighthouse 성능 점수가 크게 향상되는 것을 확인할 수 있습니다.
특히 모바일 환경이나 느린 네트워크에서 사용자 경험 개선 효과가 두드러지며, SEO 순위도 함께 상승하는 부가적인 이점을 얻을 수 있습니다.
💡 개발 환경에서는 productionBrowserSourceMaps를 true로 설정하여 디버깅을 용이하게 하고, CI/CD 파이프라인에서 프로덕션 빌드 시에만 false로 변경하세요. 환경 변수를 활용하면 자동화할 수 있습니다.
💡 이미지가 많은 서비스라면 반드시 formats에 ['image/avif', 'image/webp']를 설정하세요. AVIF는 WebP보다 20% 더 작은 파일 크기를 제공하지만, 브라우저 호환성을 위해 WebP를 폴백으로 유지하는 것이 안전합니다.
💡 swcMinify 사용 시 드물게 특정 라이브러리와 호환 문제가 발생할 수 있습니다. 빌드 에러가 발생하면 해당 패키지를 next.config.js의 transpilePackages에 명시적으로 추가하여 해결하세요.
💡 compress 옵션은 Next.js 서버를 직접 사용할 때만 적용됩니다. Vercel, Netlify 같은 플랫폼은 자체 압축을 제공하므로, 중복 압축을 피하기 위해 해당 환경에서는 비활성화하는 것이 좋습니다.
💡 빌드 시간을 더 단축하려면 experimental.cpus 옵션으로 빌드에 사용할 CPU 코어 수를 지정하세요. 예: experimental: { cpus: 4 }
여러분이 프로덕션 빌드를 완료하고 배포했는데, 사용자들이 "페이지가 너무 느려요"라는 피드백을 보내온 경험이 있나요? 그런데 막상 어떤 파일이 문제인지, 어느 라이브러리가 번들 크기를 키우는지 찾기가 쉽지 않습니다.
터미널의 빌드 로그만으로는 구체적인 원인을 파악하기 어렵습니다. 이런 문제는 실제 프로젝트가 커질수록 더 심각해집니다.
수십 개의 npm 패키지를 사용하고, 여러 페이지와 컴포넌트로 구성된 애플리케이션에서는 번들 크기 증가의 원인을 찾는 것이 마치 건초더미에서 바늘 찾기와 같습니다. 특히 외부 라이브러리가 예상보다 훨씬 큰 용량을 차지하는 경우가 많습니다.
바로 이럴 때 필요한 것이 @next/bundle-analyzer입니다. 이 도구는 번들을 시각적으로 분석하여 어떤 모듈이 얼마나 공간을 차지하는지 직관적인 트리맵으로 보여줍니다.
클릭 몇 번으로 최적화가 필요한 지점을 정확히 찾아낼 수 있습니다.
간단히 말해서, @next/bundle-analyzer는 Next.js 애플리케이션의 JavaScript 번들을 분석하고 시각화하는 공식 플러그인입니다. webpack-bundle-analyzer를 기반으로 하여 Next.js에 최적화된 분석 기능을 제공합니다.
이 도구가 필요한 이유는 개발자가 눈으로 직접 번들 구성을 확인할 수 있기 때문입니다. 숫자로만 보는 것과 색상과 크기로 시각화된 차트를 보는 것은 완전히 다른 경험입니다.
예를 들어, moment.js를 사용하고 있는데 실제로는 day.js로 교체하면 번들 크기를 70KB 줄일 수 있다는 것을 한눈에 알 수 있습니다. 기존에는 빌드 후 stats.json 파일을 수동으로 분석 도구에 업로드해야 했다면, 이제는 한 줄의 명령어로 브라우저에서 대화형 차트를 바로 확인할 수 있습니다.
핵심 특징으로는 클라이언트/서버 번들 분리 분석, 각 페이지별 청크 크기 확인, 그리고 실시간 인터랙티브 탐색이 있습니다. 이러한 특징들이 중요한 이유는 Next.js의 자동 코드 스플리팅을 고려한 정확한 분석이 가능하며, 어떤 페이지에서 불필요한 코드가 로드되는지 즉시 파악할 수 있기 때문입니다.
// 1. 패키지 설치
// npm install @next/bundle-analyzer
// or
// pnpm add @next/bundle-analyzer
// 2. next.config.js 설정
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
/** @type {import('next').NextConfig} */
const nextConfig = {
swcMinify: true,
compress: true,
}
// withBundleAnalyzer로 설정 감싸기
module.exports = withBundleAnalyzer(nextConfig)
// 3. package.json에 스크립트 추가
// "scripts": {
// "analyze": "ANALYZE=true next build"
// }
이것이 하는 일: bundle-analyzer는 Next.js 빌드 프로세스에 연결되어 최종 번들의 구성을 분석하고, 각 모듈의 크기를 시각적으로 표현하는 HTML 리포트를 생성합니다. 개발자는 이를 통해 최적화가 필요한 부분을 즉시 식별할 수 있습니다.
첫 번째로, withBundleAnalyzer 함수로 Next.js 설정을 감싸면서 분석 기능이 활성화됩니다. enabled 옵션을 환경 변수로 제어하는 이유는 일반 빌드 시에는 분석 과정을 생략하여 빌드 시간을 절약하고, 필요할 때만 ANALYZE=true로 실행하기 위함입니다.
매번 분석을 실행하면 빌드가 2-3배 느려질 수 있습니다. 그 다음으로, npm run analyze 명령어를 실행하면 Next.js가 프로덕션 빌드를 수행하면서 동시에 각 청크(chunk)의 크기와 구성 요소를 수집합니다.
빌드가 완료되면 .next/analyze 폴더에 클라이언트용과 서버용 두 개의 HTML 파일이 생성되며, 브라우저가 자동으로 열려 인터랙티브한 트리맵을 보여줍니다. 마지막으로, 트리맵에서 각 사각형을 클릭하면서 번들을 탐색할 수 있습니다.
사각형의 크기는 해당 모듈의 실제 크기를 나타내며, 색상은 디렉토리별로 구분됩니다. 예를 들어 node_modules 영역이 전체의 70%를 차지한다면, 외부 라이브러리 최적화가 우선순위임을 알 수 있습니다.
특정 페이지의 청크가 500KB를 초과한다면, 해당 페이지에 동적 임포트를 적용해야 한다는 신호입니다. 여러분이 이 도구를 사용하면 어림짐작이 아닌 데이터 기반으로 최적화 전략을 수립할 수 있습니다.
"이 라이브러리가 무거운 것 같아"라는 추측 대신, "lodash가 전체 번들의 15%를 차지하므로 lodash-es로 교체하겠다"는 구체적인 결정을 내릴 수 있습니다. 실제로 많은 팀이 이 도구로 번들 크기를 30-50% 감소시킨 사례가 있으며, 초기 로딩 시간을 2-3초 단축하는 성과를 거뒀습니다.
💡 번들 분석 결과를 팀원들과 공유할 때는 .next/analyze 폴더의 HTML 파일을 GitHub Pages나 Netlify Drop에 배포하세요. 링크 하나로 모든 팀원이 동일한 분석 결과를 볼 수 있습니다.
💡 가장 큰 모듈 3개를 먼저 최적화하세요. 파레토 법칙에 따라 전체 번들의 80%가 상위 20% 모듈에서 나옵니다. 작은 모듈 여러 개보다 큰 모듈 하나를 줄이는 것이 효과적입니다.
💡 node_modules 영역에서 같은 라이브러리가 여러 버전으로 중복되어 있다면, package.json의 resolutions(Yarn) 또는 overrides(npm)로 버전을 통일하세요. 이것만으로도 수십 KB를 절약할 수 있습니다.
💡 분석 리포트에서 "stat size", "parsed size", "gzipped size" 세 가지 수치를 확인하세요. 실제 사용자가 다운로드하는 크기는 gzipped size이므로, 이 값을 기준으로 최적화 우선순위를 정하는 것이 정확합니다.
💡 주기적으로 (예: 매주 금요일) 번들 분석을 실행하고 크기 변화를 추적하세요. 새로운 기능 추가로 번들이 갑자기 커지는 것을 조기에 발견할 수 있습니다.
여러분의 애플리케이션에 차트 라이브러리나 비디오 플레이어 같은 무거운 컴포넌트가 있는데, 모든 페이지에서 이것을 사용하지는 않는 상황을 생각해보세요. 그런데 현재는 첫 페이지 로딩 시 이 모든 코드가 함께 다운로드되어 초기 로딩 시간이 5초 이상 걸립니다.
이런 문제는 SPA(Single Page Application)의 대표적인 딜레마입니다. 모든 기능을 한 번에 로드하면 초기 로딩은 느리지만 이후 페이지 전환은 빠르고, 반대로 매번 로드하면 초기는 빠르지만 사용자 경험이 끊깁니다.
특히 모바일 환경에서는 초기 로딩 시간이 3초를 넘으면 사용자의 53%가 이탈한다는 연구 결과도 있습니다. 바로 이럴 때 필요한 것이 동적 임포트(Dynamic Import)와 코드 스플리팅입니다.
사용자가 실제로 필요로 하는 순간에만 해당 코드를 로드하여, 초기 번들은 작게 유지하면서도 필요한 기능은 빠르게 제공할 수 있습니다.
간단히 말해서, 동적 임포트는 JavaScript의 import() 함수를 사용하여 코드를 런타임에 비동기로 로드하는 기법입니다. Next.js의 next/dynamic은 이를 더욱 간편하게 만들어주는 래퍼입니다.
정적 임포트는 빌드 타임에 모든 의존성을 하나의 번들로 묶지만, 동적 임포트는 해당 코드를 별도의 청크로 분리하여 필요할 때만 네트워크 요청을 통해 가져옵니다. 이는 특히 관리자 대시보드, 복잡한 에디터, 데이터 시각화 같은 특정 사용자만 접근하는 기능에 매우 유용합니다.
예를 들어, 전체 사용자의 5%만 사용하는 관리자 페이지의 무거운 라이브러리를 동적으로 로드하면, 나머지 95% 사용자의 경험을 크게 개선할 수 있습니다. 기존에는 React.lazy와 Suspense를 직접 조합해야 했다면, Next.js의 dynamic()은 SSR 지원, 로딩 상태 처리, 에러 바운더리를 모두 내장하고 있어 훨씬 간편합니다.
핵심 특징으로는 자동 코드 스플리팅, SSR 선택적 비활성화, 그리고 로딩 컴포넌트 지정이 있습니다. 이러한 특징들이 중요한 이유는 개발자가 복잡한 설정 없이도 성능 최적화를 구현할 수 있으며, 사용자에게는 자연스러운 로딩 경험을 제공하기 때문입니다.
// components/HeavyChart.tsx - 무거운 차트 컴포넌트
import dynamic from 'next/dynamic'
// 동적 임포트: 컴포넌트를 별도 청크로 분리
const HeavyChart = dynamic(() => import('./Chart'), {
// 로딩 중 표시할 컴포넌트
loading: () => <div className="animate-pulse">차트 로딩 중...</div>,
// SSR 비활성화 (브라우저에서만 로드)
ssr: false,
})
export default function Dashboard() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button onClick={() => setShowChart(true)}>
차트 보기
</button>
{/* 버튼 클릭 시에만 차트 컴포넌트 로드 */}
{showChart && <HeavyChart />}
</div>
)
}
이것이 하는 일: 동적 임포트는 애플리케이션의 JavaScript 번들을 여러 개의 작은 청크로 나누어, 사용자가 실제로 필요로 하는 코드만 점진적으로 다운로드하게 만듭니다. 이를 통해 초기 페이지 로딩 시간을 크게 단축시킵니다.
첫 번째로, dynamic() 함수를 호출할 때 import() 함수를 반환하는 콜백을 전달합니다. 이렇게 하는 이유는 Next.js가 빌드 타임에 이 패턴을 감지하여 해당 컴포넌트를 자동으로 별도의 JavaScript 파일로 분리하기 때문입니다.
예를 들어 Chart 컴포넌트가 300KB라면, 이 코드는 메인 번들이 아닌 chart-[hash].js 같은 별도 파일로 생성됩니다. 그 다음으로, loading 옵션으로 로딩 상태 UI를 지정하고, ssr: false로 서버 사이드 렌더링을 비활성화합니다.
Chart 라이브러리가 window 객체에 의존한다면, 서버 환경에서는 에러가 발생할 수 있기 때문에 클라이언트에서만 실행되도록 제한합니다. 로딩 컴포넌트는 사용자에게 "지금 필요한 코드를 가져오고 있어요"라는 피드백을 제공하여 체감 성능을 향상시킵니다.
마지막으로, showChart 상태가 true가 될 때 비로소 HeavyChart 컴포넌트가 렌더링되면서 실제 코드가 네트워크를 통해 다운로드됩니다. 사용자가 "차트 보기" 버튼을 클릭하지 않으면 해당 코드는 영원히 로드되지 않습니다.
이는 마치 필요한 책만 서재에서 꺼내는 것과 같아서, 전체 서재를 들고 다니지 않아도 됩니다. 여러분이 이 패턴을 사용하면 초기 번들 크기를 30-60% 감소시킬 수 있습니다.
특히 Recharts, D3.js, PDF 뷰어, 비디오 에디터 같은 대용량 라이브러리를 사용하는 경우 효과가 극대적입니다. 실제로 한 이커머스 사이트는 상품 상세 페이지의 3D 뷰어를 동적 임포트로 전환하여 페이지 로딩 시간을 4초에서 1.5초로 단축시켰고, 이는 전환율 15% 증가로 이어졌습니다.
💡 route-based 코드 스플리팅은 Next.js가 자동으로 해주지만, component-based 스플리팅은 개발자가 직접 해야 합니다. 50KB 이상의 컴포넌트는 동적 임포트 적용을 고려하세요.
💡 여러 컴포넌트를 하나의 청크로 묶고 싶다면, 별도의 모듈 파일을 만들어 여러 컴포넌트를 export하고, 그 파일을 동적 임포트하세요. 예: dynamic(() => import('./admin-components'))
💡 동적 임포트는 Promise를 반환하므로, 에러 처리가 중요합니다. loading 옵션과 함께 에러 바운더리를 설정하여 네트워크 실패 시나리오를 대비하세요.
💡 Intersection Observer API와 조합하면 뷰포트에 들어올 때 자동으로 로드되는 "lazy loading" 패턴을 구현할 수 있습니다. 스크롤 하단의 댓글 섹션 같은 곳에 유용합니다.
💡 webpackChunkName 주석을 사용하면 청크 파일명을 지정할 수 있어 디버깅이 쉬워집니다. 예: dynamic(() => import(/* webpackChunkName: "chart" */ './Chart'))
여러분이 lodash 라이브러리에서 단 하나의 함수만 사용하는데, 번들 분석 결과를 보니 lodash 전체(70KB)가 포함되어 있는 걸 발견한 적 있나요? 분명 import { debounce } from 'lodash'처럼 필요한 함수만 가져왔는데 말이죠.
이런 문제는 JavaScript 모듈 시스템의 특성과 라이브러리 구조에서 비롯됩니다. 일부 라이브러리는 내부적으로 모든 함수가 서로 연결되어 있거나, 사이드 이펙트가 있는 코드를 포함하고 있어서, 번들러가 "이 코드는 안전하게 제거할 수 있다"고 판단하지 못합니다.
결과적으로 필요 없는 코드까지 번들에 포함되어 용량이 불필요하게 커집니다. 바로 이럴 때 필요한 것이 Tree Shaking입니다.
나무를 흔들면 죽은 잎이 떨어지듯, 빌드 과정에서 실제로 사용되지 않는 코드를 자동으로 제거하는 최적화 기법입니다. 올바르게 설정하면 번들 크기를 50% 이상 줄일 수 있습니다.
간단히 말해서, Tree Shaking은 ES6 모듈의 정적 구조를 분석하여 실제로 사용되는 코드만 최종 번들에 포함시키는 데드 코드 제거 기법입니다. Webpack, Rollup 같은 모던 번들러에 내장된 기능으로, Next.js는 기본적으로 활성화되어 있습니다.
Tree Shaking이 제대로 작동하려면 라이브러리가 ES6 모듈 형식으로 제공되어야 하고, 코드에 사이드 이펙트가 없다는 것이 명시되어야 합니다. package.json의 sideEffects 필드가 이 역할을 합니다.
예를 들어, UI 컴포넌트 라이브러리에서 Button 컴포넌트만 사용한다면, Modal이나 Dropdown 컴포넌트의 코드는 최종 번들에서 완전히 사라집니다. 기존에는 import _ from 'lodash'처럼 전체를 가져와야 했다면, 이제는 import { debounce } from 'lodash-es'로 ES 모듈 버전을 사용하여 필요한 함수만 번들에 포함시킬 수 있습니다.
핵심 특징으로는 자동 데드 코드 제거, ES 모듈 기반 정적 분석, 그리고 사이드 이펙트 제어가 있습니다. 이러한 특징들이 중요한 이유는 개발자가 별도의 작업 없이도 프로덕션 번들이 자동으로 최소화되며, 명확한 의존성 관리가 가능하기 때문입니다.
// ❌ 잘못된 방법: 전체 라이브러리 임포트
import _ from 'lodash' // 70KB 전체가 번들에 포함
const result = _.debounce(fn, 300)
// ✅ 올바른 방법 1: ES 모듈 버전 사용
import { debounce } from 'lodash-es' // debounce만 포함 (~2KB)
const result = debounce(fn, 300)
// ✅ 올바른 방법 2: 개별 함수 임포트
import debounce from 'lodash/debounce'
// package.json에서 사이드 이펙트 명시
{
"name": "my-app",
"sideEffects": [
"*.css", // CSS 파일은 사이드 이펙트 있음
"src/polyfills.ts" // polyfill은 제거하면 안 됨
]
}
// 또는 사이드 이펙트 없음 명시
{ "sideEffects": false }
이것이 하는 일: Tree Shaking은 빌드 타임에 모듈 간 의존성 그래프를 분석하여, 실제로 사용되는 export만 추적하고 나머지는 최종 번들에서 제거합니다. 이를 통해 번들 크기를 극적으로 감소시킵니다.
첫 번째로, ES6 모듈의 import/export 구문은 정적으로 분석 가능하다는 특징을 활용합니다. 이렇게 하는 이유는 CommonJS의 require()와 달리, import는 파일 최상단에 위치하고 조건부로 사용할 수 없어서 빌드 타임에 정확히 어떤 모듈이 사용되는지 파악할 수 있기 때문입니다.
예를 들어 utils.js에서 10개 함수를 export하는데 실제로는 2개만 import한다면, 나머지 8개의 코드는 완전히 제거됩니다. 그 다음으로, sideEffects 필드를 통해 번들러에게 "이 파일들은 부작용이 있으니 제거하지 마세요" 또는 "이 패키지는 순수 함수만 있으니 안전하게 제거하세요"라는 힌트를 제공합니다.
CSS 파일이나 polyfill처럼 임포트만 해도 전역 상태가 변경되는 코드는 반드시 sideEffects 배열에 명시해야 합니다. 그렇지 않으면 번들러가 "사용되지 않는다"고 판단하여 삭제할 수 있습니다.
마지막으로, 프로덕션 빌드 시 Terser 같은 미니파이어가 마지막 단계에서 미사용 변수, 함수, 클래스를 완전히 제거하고 코드를 압축합니다. 개발 모드에서는 디버깅을 위해 Tree Shaking이 비활성화되지만, next build를 실행하면 모든 최적화가 적용되어 최소 크기의 번들이 생성됩니다.
여러분이 이 기법을 적용하면 특히 UI 라이브러리나 유틸리티 라이브러리 사용 시 엄청난 용량 절감을 경험할 수 있습니다. lodash를 lodash-es로 바꾸는 것만으로 50KB 이상 절약되고, Moment.js를 Day.js로 교체하면 100KB 이상 줄어듭니다.
한 실제 프로젝트에서는 Material-UI의 개별 컴포넌트 임포트 방식을 개선하여 초기 번들을 800KB에서 350KB로 줄인 사례도 있습니다.
💡 번들 분석 결과에서 "unused exports" 경고가 나타나면, 해당 모듈의 임포트 방식을 점검하세요. 전체 임포트 대신 개별 임포트로 변경하면 대부분 해결됩니다.
💡 라이브러리를 선택할 때 package.json에 "module" 필드가 있는지 확인하세요. 이 필드가 있으면 ES 모듈 버전을 제공한다는 의미로, Tree Shaking이 가능합니다.
💡 Babel을 사용한다면 .babelrc에서 "modules": false 옵션을 설정하여 ES 모듈을 CommonJS로 변환하지 않도록 하세요. Next.js는 이미 올바르게 설정되어 있습니다.
💡 라이브러리 전체를 임포트해야 하는 경우(예: Firebase SDK), 공식 문서에서 권장하는 tree-shakable 버전이나 modular 패키지가 있는지 확인하세요. firebase/app 대신 firebase 전체를 임포트하면 수백 KB 차이가 납니다.
💡 자체 유틸리티 함수를 작성할 때는 각 함수를 별도 파일로 분리하거나, 최소한 named export를 사용하세요. default export 하나로 모든 유틸을 묶으면 Tree Shaking이 불가능합니다.
여러분의 서비스에 고화질 제품 이미지가 많은데, 모바일에서 페이지를 열면 데이터를 5MB 이상 소비하고 로딩이 10초 이상 걸리는 경험을 해본 적 있나요? 이미지를 수동으로 리사이징하고 WebP로 변환하는 작업도 번거롭고, 반응형으로 여러 사이즈를 준비하는 것도 쉽지 않습니다.
이런 문제는 웹 성능의 가장 큰 병목 중 하나입니다. HTTP Archive에 따르면 웹 페이지 용량의 평균 50%가 이미지이며, 최적화되지 않은 이미지는 Lighthouse 성능 점수를 30-40점이나 깎아먹습니다.
특히 Unsplash에서 다운로드한 4K 이미지를 그대로 사용하면, 사용자는 6MB짜리 파일을 다운로드하느라 긴 시간을 기다려야 합니다. 바로 이럴 때 필요한 것이 Next.js의 Image 컴포넌트입니다.
자동 포맷 변환, 디바이스별 리사이징, 지연 로딩, 플레이스홀더까지 모든 이미지 최적화를 한 줄의 코드로 해결합니다.
간단히 말해서, next/image는 HTML의 <img> 태그를 대체하는 고성능 이미지 컴포넌트로, 자동으로 최신 이미지 포맷(AVIF, WebP)으로 변환하고 디바이스 크기에 맞게 이미지를 제공합니다. 빌드 타임이 아닌 요청 타임에 최적화가 이루어져 빌드 시간에 영향을 주지 않습니다.
이 컴포넌트가 필요한 이유는 이미지 최적화의 모든 모범 사례를 자동화하기 때문입니다. 개발자가 srcset을 수동으로 작성하거나 여러 포맷을 준비할 필요 없이, Next.js가 런타임에 요청에 맞는 최적의 이미지를 생성하여 반환합니다.
예를 들어, iPhone에서 접속하면 750px 너비의 WebP 이미지를, 최신 Chrome에서 접속하면 AVIF 포맷을 자동으로 제공합니다. 기존에는 ImageMagick이나 Sharp를 사용해 빌드 스크립트를 작성하고, CDN을 설정해야 했다면, 이제는 <Image> 컴포넌트 하나로 모든 것이 해결됩니다.
핵심 특징으로는 자동 포맷 변환, 반응형 이미지 생성, 지연 로딩(lazy loading), 그리고 누적 레이아웃 이동(CLS) 방지가 있습니다. 이러한 특징들이 중요한 이유는 사용자 경험과 Core Web Vitals 점수에 직접적인 영향을 미치며, SEO 순위와 전환율 향상으로 이어지기 때문입니다.
import Image from 'next/image'
export default function ProductPage() {
return (
<div>
{/* 기본 사용: 자동 최적화 */}
<Image
src="/product.jpg"
alt="제품 이미지"
width={800}
height={600}
// 우선 순위 높은 이미지 (LCP 대상)
priority
/>
{/* 외부 이미지: next.config.js에 도메인 등록 필요 */}
<Image
src="https://cdn.example.com/banner.png"
alt="배너"
width={1200}
height={400}
// 블러 플레이스홀더로 CLS 방지
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
{/* fill 모드: 부모 크기에 맞춤 */}
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
<Image
src="/background.jpg"
alt="배경"
fill
style={{ objectFit: 'cover' }}
/>
</div>
</div>
)
}
이것이 하는 일: Image 컴포넌트는 사용자의 브라우저, 디바이스, 네트워크 상태를 감지하여 최적의 크기와 포맷의 이미지를 동적으로 생성하고 캐싱합니다. 이를 통해 데이터 사용량을 최소화하고 로딩 속도를 극대화합니다.
첫 번째로, width와 height props를 제공하면 Next.js가 이미지의 가로세로 비율을 미리 계산하여 공간을 예약합니다. 이렇게 하는 이유는 이미지가 로드되기 전에 레이아웃이 확정되어 CLS(Cumulative Layout Shift)를 방지하기 때문입니다.
이미지 때문에 페이지가 갑자기 밀리는 현상을 없애서, Lighthouse의 CLS 점수를 0에 가깝게 유지할 수 있습니다. 그 다음으로, priority 속성이 없는 이미지는 기본적으로 지연 로딩됩니다.
뷰포트에 들어오기 전까지는 실제 이미지를 로드하지 않고, Intersection Observer API를 사용하여 스크롤이 근처에 도달할 때 비로소 다운로드를 시작합니다. 반대로 LCP(Largest Contentful Paint) 요소인 히어로 이미지에는 priority를 설정하여 즉시 로드되도록 합니다.
마지막으로, 사용자가 요청하면 Next.js 서버의 이미지 최적화 API가 원본 이미지를 처리합니다. Sharp 라이브러리를 사용하여 요청된 크기로 리사이징하고, Accept 헤더를 확인하여 브라우저가 AVIF를 지원하면 AVIF로, 그렇지 않으면 WebP로, 둘 다 안 되면 원본 포맷으로 변환합니다.
변환된 이미지는 캐시되어 동일한 요청에는 즉시 응답합니다. 여러분이 Image 컴포넌트를 사용하면 일반 img 태그 대비 이미지 용량이 50-80% 감소하고, LCP 시간이 2-3초 단축되는 것을 확인할 수 있습니다.
한 이커머스 사이트는 모든 제품 이미지를 Image 컴포넌트로 전환하여 모바일 페이지 로딩 시간을 8초에서 2.5초로 줄였고, 이는 모바일 전환율 25% 증가로 이어졌습니다. 또한 Lighthouse 성능 점수가 60점대에서 90점대로 상승하여 Google 검색 순위도 개선되었습니다.
💡 외부 이미지를 사용하려면 next.config.js의 images.remotePatterns에 도메인을 등록해야 합니다. 보안을 위해 와일드카드(*) 대신 정확한 도메인을 명시하세요.
💡 블러 플레이스홀더는 사용자 경험을 크게 향상시킵니다. plaiceholder 라이브러리를 사용하면 빌드 타임에 자동으로 블러 데이터 URL을 생성할 수 있습니다.
💡 Vercel에 배포하면 자동 이미지 최적화가 제공되지만, 자체 서버에서는 Sharp 패키지 설치가 필요합니다. Dockerfile에 libvips 의존성도 추가하세요.
💡 이미지가 100개 이상인 갤러리 페이지에서는 loading="lazy"가 기본값이므로 성능 문제가 없지만, priority를 남용하면 역효과가 납니다. 첫 화면의 1-2개 이미지에만 적용하세요.
💡 fill 모드를 사용할 때는 부모 요소에 position: relative가 필수입니다. 그렇지 않으면 이미지가 예상치 못한 위치에 표시됩니다.
여러분이 API 키나 데이터베이스 비밀번호를 코드에 하드코딩했다가, 실수로 GitHub에 푸시하여 보안 경고를 받은 경험이 있나요? 또는 개발/스테이징/프로덕션 환경마다 다른 API 엔드포인트를 사용해야 하는데, 매번 코드를 수정하고 다시 빌드하는 번거로움을 겪고 계신가요?
이런 문제는 환경 설정 관리의 기본 원칙을 위반한 것입니다. Twelve-Factor App 방법론에서는 "설정을 코드와 분리하라"고 명시하고 있으며, 민감 정보가 소스 코드에 포함되면 Git 히스토리에 영구적으로 남아 보안 위험이 됩니다.
또한 환경마다 빌드를 다시 해야 한다면 CI/CD 파이프라인이 복잡해지고 배포 시간이 늘어납니다. 바로 이럴 때 필요한 것이 Next.js의 환경 변수 시스템입니다.
.env 파일과 NEXT_PUBLIC_ 접두사를 활용하여 서버/클라이언트 환경 변수를 분리하고, 런타임 설정으로 빌드 한 번으로 여러 환경에 배포할 수 있습니다.
간단히 말해서, 환경 변수는 애플리케이션 외부에서 설정값을 주입하는 메커니즘으로, Next.js는 .env 파일을 통해 개발 환경에서 쉽게 관리하고, 프로덕션에서는 호스팅 플랫폼의 환경 변수를 사용합니다. Next.js의 환경 변수는 두 가지 종류가 있습니다.
서버 사이드에서만 접근 가능한 변수(API 키, DB 비밀번호 등)와 클라이언트에서도 접근 가능한 공개 변수(NEXT_PUBLIC_으로 시작)입니다. 이 구분이 중요한 이유는 클라이언트 변수는 빌드 타임에 번들에 포함되어 브라우저에서 볼 수 있기 때문에, 민감 정보는 절대 NEXT_PUBLIC_을 사용하면 안 됩니다.
예를 들어, Google Analytics ID는 공개 변수로 적합하지만, Stripe 시크릿 키는 서버 변수여야 합니다. 기존에는 process.env를 직접 사용하거나 dotenv 패키지를 설정해야 했다면, Next.js는 .env.local, .env.production 같은 파일을 자동으로 로드하여 별도 설정이 필요 없습니다.
핵심 특징으로는 자동 .env 로딩, 서버/클라이언트 변수 분리, 그리고 빌드 타임 인라인 치환이 있습니다. 이러한 특징들이 중요한 이유는 보안을 유지하면서도 개발 경험을 해치지 않으며, 환경별 빌드 없이 동일한 아티팩트를 여러 환경에 배포할 수 있기 때문입니다.
// .env.local (Git에 커밋하지 않음 - .gitignore에 추가)
DATABASE_URL=postgresql://localhost:5432/mydb
API_SECRET_KEY=super-secret-key-12345
// .env.production (프로덕션 전용 기본값)
NEXT_PUBLIC_API_URL=https://api.production.com
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
// app/api/users/route.ts - 서버 사이드
export async function GET() {
// 서버에서만 접근 가능 (안전)
const dbUrl = process.env.DATABASE_URL
const apiKey = process.env.API_SECRET_KEY
// 클라이언트에 노출되지 않음
const data = await fetchFromDB(dbUrl, apiKey)
return Response.json(data)
}
// components/Analytics.tsx - 클라이언트
export default function Analytics() {
// 브라우저에서 접근 가능 (번들에 포함됨)
const gaId = process.env.NEXT_PUBLIC_GA_ID
useEffect(() => {
// 공개 정보이므로 안전
window.gtag('config', gaId)
}, [gaId])
}
이것이 하는 일: Next.js의 환경 변수 시스템은 빌드 타임과 런타임에 외부 설정을 안전하게 주입하여, 민감 정보를 보호하고 환경별 설정을 분리합니다. 이를 통해 코드 변경 없이 다양한 환경에서 동일한 애플리케이션을 실행할 수 있습니다.
첫 번째로, .env 파일들이 우선순위에 따라 로드됩니다. .env.local이 가장 높은 우선순위를 가지며, 개발자별 로컬 설정을 덮어쓸 수 있습니다.
이렇게 하는 이유는 팀원마다 다른 로컬 데이터베이스를 사용하거나, 개인 API 키로 테스트할 수 있도록 유연성을 제공하기 위함입니다. .env.local은 .gitignore에 추가하여 절대 버전 관리에 포함시키지 않아야 합니다.
그 다음으로, NEXT_PUBLIC_ 접두사가 있는 변수는 빌드 타임에 webpack의 DefinePlugin을 통해 코드에 직접 치환됩니다. 예를 들어 process.env.NEXT_PUBLIC_API_URL이 코드에 있으면, 빌드 후 번들에는 "https://api.production.com"이라는 문자열이 하드코딩됩니다.
이는 런타임 조회가 필요 없어 성능상 이점이 있지만, 변경 시 재빌드가 필요하다는 트레이드오프가 있습니다. 마지막으로, 서버 컴포넌트와 API 라우트에서 사용되는 일반 환경 변수는 절대 클라이언트 번들에 포함되지 않습니다.
Next.js는 서버와 클라이언트 코드를 분리하여 빌드하므로, DATABASE_URL 같은 변수는 서버 번들에만 존재하고 브라우저로 전송되지 않습니다. 만약 클라이언트 컴포넌트에서 일반 환경 변수를 참조하면 undefined가 됩니다.
여러분이 이 시스템을 올바르게 사용하면 보안 감사에서 합격할 수 있고, 같은 Docker 이미지를 개발/스테이징/프로덕션에 배포하여 CI/CD를 단순화할 수 있습니다. 실제로 한 스타트업은 환경 변수 관리를 체계화하여 배포 시간을 30분에서 5분으로 단축했고, AWS Secrets Manager와 통합하여 키 로테이션을 자동화했습니다.
또한 실수로 API 키가 노출되는 사고를 완전히 방지할 수 있었습니다.
💡 .env.example 파일을 Git에 커밋하여 팀원들이 필요한 환경 변수 목록을 알 수 있게 하세요. 실제 값 대신 플레이스홀더를 사용합니다: DATABASE_URL=postgresql://user:password@localhost:5432/dbname
💡 런타임 환경 변수가 필요하다면 (예: CDN에서 정적 배포), publicRuntimeConfig 대신 /api/config 엔드포인트를 만들어 서버에서 변수를 반환하는 패턴을 사용하세요. 더 안전하고 캐시 제어가 쉽습니다.
💡 환경 변수 검증을 위해 zod 같은 라이브러리를 사용하세요. 애플리케이션 시작 시 필수 변수가 누락되었거나 형식이 잘못되면 명확한 에러 메시지를 제공할 수 있습니다.
💡 Vercel, Netlify 같은 플랫폼은 웹 UI에서 환경 변수를 설정할 수 있습니다. 프로덕션 배포 전에 반드시 모든 변수가 설정되었는지 확인하세요. 누락된 변수는 런타임 에러를 유발합니다.
💡 민감한 환경 변수는 절대 console.log하지 마세요. 로그 수집 시스템에 기록되어 유출될 수 있습니다. 디버깅이 필요하다면 마지막 몇 글자만 마스킹하여 출력하세요.
여러분의 웹사이트가 Google Fonts에서 폰트를 로드하는데, Lighthouse에서 "외부 리소스 차단"이나 "FOUT(Flash of Unstyled Text)" 경고를 받은 적 있나요? 페이지가 로드될 때 폰트가 늦게 적용되면서 텍스트가 깜빡이는 현상도 사용자 경험을 해칩니다.
이런 문제는 외부 폰트 서비스 사용의 일반적인 부작용입니다. Google Fonts CDN에 요청을 보내고, CSS를 다운로드하고, 다시 폰트 파일을 요청하는 과정에서 여러 번의 네트워크 왕복이 발생합니다.
특히 중국이나 일부 국가에서는 Google 서비스가 느리거나 차단되어 폰트 로딩이 실패하기도 합니다. 또한 GDPR 관점에서 Google에 사용자 IP가 전송되는 것도 문제가 될 수 있습니다.
바로 이럴 때 필요한 것이 next/font입니다. Google Fonts를 빌드 타임에 다운로드하여 자체 호스팅하고, 자동으로 폰트를 최적화하여 FOUT/FOIT 없이 즉시 표시되도록 합니다.
간단히 말해서, next/font는 Google Fonts와 로컬 폰트를 자동으로 최적화하고 자체 호스팅하는 내장 시스템입니다. 빌드 타임에 폰트 파일을 다운로드하여 정적 자산으로 제공하므로, 외부 요청이 전혀 발생하지 않습니다.
이 기능이 필요한 이유는 웹 폰트 로딩이 Core Web Vitals의 LCP와 CLS에 직접적인 영향을 미치기 때문입니다. 폰트가 늦게 로드되면 텍스트가 보이지 않거나(FOIT), 시스템 폰트로 먼저 표시되었다가 웹 폰트로 교체되면서 레이아웃이 흔들립니다(FOUT).
next/font는 CSS의 font-display와 size-adjust를 자동으로 설정하여 이런 문제를 방지합니다. 예를 들어, Noto Sans 한글 폰트를 사용하는 경우, 수백 KB의 폰트 파일이 자체 서버에서 제공되어 로딩 속도가 2-3배 빨라집니다.
기존에는 @font-face를 수동으로 작성하고 폰트 파일을 public 폴더에 넣어야 했다면, 이제는 import 한 줄로 모든 최적화가 자동으로 처리됩니다. 핵심 특징으로는 자동 폰트 다운로드 및 자체 호스팅, 제로 레이아웃 시프트, 그리고 가변 폰트 지원이 있습니다.
이러한 특징들이 중요한 이유는 개발자가 복잡한 폰트 최적화 기법을 몰라도 자동으로 최상의 성능을 얻을 수 있으며, 프라이버시와 성능을 동시에 확보하기 때문입니다.
// app/layout.tsx - 루트 레이아웃에서 폰트 설정
import { Inter, Noto_Sans_KR } from 'next/font/google'
// Google Fonts 자동 최적화 및 자체 호스팅
const inter = Inter({
subsets: ['latin'],
// 필요한 weight만 선택 (번들 크기 감소)
weight: ['400', '500', '700'],
// 폰트가 로드될 때까지 대체 폰트 표시
display: 'swap',
// CSS 변수로 사용
variable: '--font-inter',
})
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-noto-sans-kr',
})
export default function RootLayout({ children }) {
return (
<html lang="ko" className={`${inter.variable} ${notoSansKr.variable}`}>
<body className="font-sans">
{children}
</body>
</html>
)
}
// tailwind.config.js - Tailwind에서 사용
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'var(--font-noto-sans-kr)', 'sans-serif'],
},
},
},
}
이것이 하는 일: next/font는 빌드 프로세스 중에 지정된 Google Fonts를 다운로드하고, CSS를 생성하며, 폰트 파일을 정적 자산으로 번들에 포함시킵니다. 런타임에는 자체 서버에서 폰트가 제공되어 외부 의존성이 완전히 제거됩니다.
첫 번째로, import 구문에서 폰트를 가져오면 Next.js가 빌드 타임에 Google Fonts API에 요청하여 필요한 폰트 파일(WOFF2 포맷)을 다운로드합니다. 이렇게 하는 이유는 프로덕션 환경에서 Google의 서버에 의존하지 않고, CDN이나 자체 서버에서 폰트를 제공하여 TTFB(Time to First Byte)를 단축하기 위함입니다.
특히 중국, 러시아 같은 지역에서 Google 접근이 느리거나 불가능한 경우에도 문제없이 폰트가 로드됩니다. 그 다음으로, subsets과 weight 옵션으로 실제 사용할 글리프와 굵기만 선택합니다.
Noto Sans KR의 전체 폰트는 수 MB에 달하지만, 필요한 서브셋(한글+라틴)과 두 가지 weight(400, 700)만 선택하면 수백 KB로 줄어듭니다. display: 'swap'은 폰트가 로드되는 동안 대체 폰트를 먼저 표시하고, 폰트가 준비되면 교체하라는 의미로, FOIT(보이지 않는 텍스트)를 방지합니다.
마지막으로, CSS 변수로 폰트를 정의하면 Tailwind CSS나 다른 스타일링 시스템에서 재사용할 수 있습니다. Next.js는 자동으로 size-adjust를 계산하여 시스템 폰트와 웹 폰트의 크기를 맞춤으로써, 폰트가 교체될 때 레이아웃이 흔들리지 않도록(CLS 0) 만듭니다.
이는 수동으로 구현하려면 매우 복잡한 작업입니다. 여러분이 next/font를 사용하면 Lighthouse의 "외부 리소스 사전 연결" 경고가 사라지고, 폰트 로딩 시간이 평균 200-500ms 단축됩니다.
한 블로그 사이트는 Google Fonts에서 next/font로 전환하여 LCP를 3.2초에서 1.8초로 개선했고, 중국 사용자들의 이탈률이 40% 감소했습니다. 또한 GDPR 컴플라이언스도 자동으로 충족되어 유럽 시장 진출에 유리해졌습니다.
💡 preload 옵션을 true로 설정하면 <link rel="preload">가 자동 추가되어 폰트가 더 빠르게 로드됩니다. 단, 가장 중요한 1-2개 폰트에만 사용하세요. 남용하면 역효과가 납니다.
💡 가변 폰트(Variable Fonts)를 지원하는 폰트는 weight 배열 대신 weight: 'variable'을 사용하세요. 여러 weight를 하나의 파일로 제공하여 번들 크기를 크게 줄입니다.
💡 adjustFontFallback 옵션은 기본적으로 활성화되어 있어 자동으로 폴백 폰트를 조정합니다. 비활성화하려면 adjustFontFallback: false를 설정하세요.
💡 로컬 폰트 파일을 사용하려면 next/font/local을 import하고 src 옵션에 파일 경로를 지정하세요. 회사 브랜드 폰트 같은 커스텀 폰트에 유용합니다.
💡 폰트 파일은 .next/static/media에 저장되므로, CDN에 배포할 때 이 경로가 올바르게 설정되었는지 확인하세요. Vercel은 자동으로 처리합니다.
여러분이 블로그나 뉴스 사이트를 운영하는데, 정적 생성(SSG)을 사용하면 속도는 빠르지만 콘텐츠가 업데이트될 때마다 전체 사이트를 다시 빌드해야 하는 번거로움이 있나요? 반대로 서버 사이드 렌더링(SSR)을 사용하면 항상 최신 데이터를 보여주지만 서버 부하가 크고 응답 속도가 느립니다.
이런 문제는 정적 생성과 동적 렌더링의 오래된 딜레마입니다. SSG는 빌드 타임에 모든 페이지를 생성하므로 CDN에서 즉시 제공되지만, 게시물이 1000개면 빌드 시간이 10분 이상 걸릴 수 있습니다.
SSR은 매 요청마다 데이터베이스를 조회하므로 동시 접속자가 많으면 서버가 다운될 위험도 있습니다. 바로 이럴 때 필요한 것이 ISR(Incremental Static Regeneration)입니다.
정적 페이지의 빠른 속도를 유지하면서도, 백그라운드에서 주기적으로 페이지를 재생성하여 콘텐츠를 최신 상태로 유지합니다. 두 세계의 장점을 모두 가져올 수 있습니다.
간단히 말해서, ISR은 Next.js의 하이브리드 렌더링 전략으로, 페이지를 정적으로 생성하되 지정된 시간(revalidate) 이후 첫 번째 요청 시 백그라운드에서 페이지를 재생성하는 기법입니다. 사용자는 항상 즉시 캐시된 페이지를 받고, 동시에 서버는 업데이트된 버전을 준비합니다.
ISR이 필요한 이유는 대규모 콘텐츠 사이트에서 빌드 시간과 콘텐츠 신선도를 모두 해결하기 때문입니다. 1만 개의 제품이 있는 쇼핑몰을 상상해보세요.
전체를 다시 빌드하면 1시간 이상 걸리지만, ISR을 사용하면 자주 조회되는 인기 제품만 빌드하고 나머지는 첫 요청 시 생성합니다. 예를 들어, 주식 정보 사이트에서 revalidate: 60을 설정하면 1분마다 최신 주가가 반영되면서도, 사용자에게는 CDN의 빠른 응답 속도를 제공합니다.
기존에는 캐시 무효화 로직을 직접 구현하거나, Redis 같은 캐시 레이어를 추가해야 했다면, Next.js는 ISR을 한 줄의 설정으로 제공합니다. 핵심 특징으로는 시간 기반 재검증(Time-based Revalidation), 온디맨드 재검증(On-demand Revalidation), 그리고 점진적 페이지 생성이 있습니다.
이러한 특징들이 중요한 이유는 서버 비용을 최소화하면서도 콘텐츠를 준실시간으로 업데이트할 수 있으며, 트래픽 급증 시에도 안정적인 성능을 유지하기 때문입니다.
// app/posts/[id]/page.tsx - App Router에서 ISR
export const revalidate = 3600 // 1시간마다 재검증
export async function generateStaticParams() {
// 빌드 타임에 생성할 인기 게시물 ID만 반환
const posts = await fetch('https://api.example.com/popular-posts')
.then(res => res.json())
// 상위 100개 게시물만 빌드 타임에 생성
return posts.slice(0, 100).map((post) => ({
id: post.id.toString(),
}))
}
export default async function Post({ params }) {
// 빌드 타임 + 재검증 시마다 실행
const post = await fetch(`https://api.example.com/posts/${params.id}`, {
// fetch 레벨 캐시 설정도 가능
next: { revalidate: 3600 }
}).then(res => res.json())
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<time>마지막 업데이트: {new Date().toLocaleString()}</time>
</article>
)
}
// app/api/revalidate/route.ts - 온디맨드 재검증 API
import { revalidatePath } from 'next/cache'
export async function POST(request) {
const { path } = await request.json()
// 특정 경로 즉시 재검증 (CMS 웹훅과 연동)
revalidatePath(path)
return Response.json({ revalidated: true })
}
이것이 하는 일: ISR은 정적 페이지를 CDN에 캐싱하되, 지정된 시간이 지나면 다음 요청 시 백그라운드에서 페이지를 재생성하여 캐시를 갱신합니다. 사용자는 항상 빠른 응답을 받으면서도, 콘텐츠는 자동으로 최신 상태를 유지합니다.
첫 번째로, revalidate 값을 설정하면 Next.js가 해당 시간(초 단위)을 캐시 수명으로 사용합니다. 이렇게 하는 이유는 모든 요청을 실시간으로 처리하는 대신, 대부분의 사용자에게 캐시된 버전을 제공하여 서버 부하를 99% 줄이기 위함입니다.
예를 들어 revalidate: 3600이면, 처음 1시간 동안 수백만 명이 접속해도 서버는 단 한 번만 페이지를 생성합니다. 그 다음으로, 재검증 시간이 지난 후 첫 번째 사용자가 페이지를 요청하면, 그 사용자는 여전히 캐시된 (약간 오래된) 페이지를 즉시 받습니다.
동시에 서버는 백그라운드에서 최신 데이터를 가져와 페이지를 재생성합니다. 이 작업이 완료되면 캐시가 새 버전으로 교체되어, 다음 사용자부터는 업데이트된 페이지를 보게 됩니다.
이를 "stale-while-revalidate" 전략이라고 합니다. 마지막으로, generateStaticParams에서 일부 경로만 반환하면 나머지는 "온디맨드" 방식으로 생성됩니다.
예를 들어 인기 게시물 100개만 빌드하고, 나머지 9900개는 사용자가 처음 요청할 때 생성되어 캐시됩니다. 이렇게 하면 빌드 시간을 10분에서 30초로 단축하면서도, 모든 페이지가 결국 정적 페이지로 제공됩니다.
여러분이 ISR을 사용하면 서버 비용을 80-90% 절감하면서도 콘텐츠를 거의 실시간으로 업데이트할 수 있습니다. 한 뉴스 사이트는 ISR을 도입하여 기사를 5분마다 업데이트하면서도 AWS 비용을 월 $5000에서 $500로 줄였습니다.
또한 트래픽이 10배 증가하는 이벤트 기간에도 서버 다운 없이 안정적으로 서비스를 제공했습니다. Vercel 같은 플랫폼에서는 ISR 페이지가 자동으로 글로벌 CDN에 분산되어 전 세계 어디서든 빠른 로딩 속도를 경험할 수 있습니다.
💡 revalidatePath()와 revalidateTag()를 CMS 웹훅과 연동하면, 콘텐츠가 업데이트될 때 즉시 페이지를 재생성할 수 있습니다. Sanity, Contentful, WordPress는 모두 웹훅을 지원합니다.
💡 빈번하게 변경되는 데이터(예: 실시간 주식)는 revalidate: 10 (10초), 가끔 변경되는 블로그는 revalidate: 86400 (1일)처럼 콘텐츠 특성에 맞게 설정하세요.
💡 generateStaticParams의 반환 개수를 조절하여 빌드 시간과 초기 캐시 적중률의 균형을 맞추세요. 80/20 법칙을 따라 상위 20% 페이지만 빌드하는 것이 효율적입니다.
💡 ISR은 Vercel, Netlify, AWS Amplify에서 잘 작동하지만, 전통적인 호스팅(Apache, Nginx)에서는 별도 캐시 레이어가 필요합니다. 배포 환경을 먼저 확인하세요.
💡 캐시된 페이지의 나이를 사용자에게 표시하려면 생성 시간을 페이지에 포함시키세요. "5분 전 업데이트됨" 같은 타임스탬프는 신뢰성을 높입니다.
여러분의 서비스가 한국에서는 빠른데 미국이나 유럽 사용자들이 "너무 느려요"라고 불평하는 상황을 겪어본 적 있나요? 또는 인증 체크나 리다이렉션 같은 간단한 로직도 서버까지 왕복해야 해서 불필요한 지연이 발생합니다.
이런 문제는 중앙화된 서버 아키텍처의 한계입니다. 서울에 서버가 있으면 뉴욕 사용자는 200ms 이상의 레이턴시를 감수해야 하고, 단순한 A/B 테스트 라우팅도 서버까지 도달해야 처리됩니다.
특히 인증 체크 같은 빈번한 작업이 매번 메인 서버를 거치면 전체 응답 시간이 크게 증가합니다. 바로 이럴 때 필요한 것이 Next.js Middleware와 Edge Runtime입니다.
사용자에게 가까운 글로벌 엣지 로케이션에서 요청을 가로채 처리하여, 인증, 리다이렉션, 헤더 조작 같은 작업을 수 밀리초 내에 완료할 수 있습니다.
간단히 말해서, Middleware는 요청이 라우트에 도달하기 전에 실행되는 코드로, Edge Runtime에서 전 세계 300개 이상의 데이터 센터에서 동시에 실행됩니다. 서버보다 훨씬 가벼운 환경에서 작동하여 콜드 스타트가 거의 없습니다.
Middleware가 필요한 이유는 요청 처리 파이프라인의 초입에서 빠른 의사결정을 내릴 수 있기 때문입니다. 예를 들어, 로그인하지 않은 사용자가 대시보드에 접근하려 할 때, 메인 서버까지 가서 인증을 확인하고 리다이렉트하는 대신, 엣지에서 즉시 로그인 페이지로 보낼 수 있습니다.
이는 레이턴시를 200ms에서 5ms로 줄입니다. 기존에는 Express의 미들웨어처럼 서버 프로세스 내에서만 실행되었다면, Edge Runtime은 Cloudflare Workers와 유사하게 전 세계 엣지 네트워크에서 분산 실행됩니다.
핵심 특징으로는 글로벌 엣지 실행, 초저지연 응답, 그리고 제한된 런타임 API가 있습니다. 이러한 특징들이 중요한 이유는 지리적 위치와 무관하게 일관된 빠른 성능을 제공하지만, 데이터베이스나 파일 시스템 같은 무거운 작업은 할 수 없다는 트레이드오프를 이해해야 하기 때문입니다.
// middleware.ts - 프로젝트 루트에 위치
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 1. 인증 체크
const token = request.cookies.get('auth-token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
// 엣지에서 즉시 리다이렉트 (5ms 이내)
return NextResponse.redirect(new URL('/login', request.url))
}
// 2. A/B 테스팅: 50% 사용자에게 새 디자인
if (request.nextUrl.pathname === '/') {
const bucket = Math.random() < 0.5 ? 'a' : 'b'
const response = NextResponse.next()
response.cookies.set('bucket', bucket)
if (bucket === 'b') {
return NextResponse.rewrite(new URL('/home-v2', request.url))
}
}
// 3. 지역별 리다이렉션
const country = request.geo?.country || 'US'
if (country === 'CN' && !request.nextUrl.pathname.startsWith('/cn')) {
return NextResponse.redirect(new URL('/cn', request.url))
}
// 4. 커스텀 헤더 추가
const response = NextResponse.next()
response.headers.set('x-user-country', country)
return response
}
// 특정 경로에만 미들웨어 적용
export const config = {
matcher: ['/dashboard/:path*', '/'],
}
이것이 하는 일: Middleware는 사용자 요청을 가로채 엣지 네트워크에서 초고속으로 처리하고, 필요에 따라 요청을 수정하거나 다른 경로로 라우팅합니다. 이를 통해 메인 서버의 부하를 줄이고 글로벌 사용자에게 일관된 빠른 경험을 제공합니다.
첫 번째로, middleware.ts 파일을 루트에 배치하면 Next.js가 이를 감지하여 Edge Runtime으로 빌드합니다. 이렇게 하는 이유는 Edge Runtime이 V8 엔진의 최소 서브셋만 사용하여 콜드 스타트가 0ms에 가깝고, 메모리 사용량도 수 MB 이하로 매우 가볍기 때문입니다.
반면 Node.js 런타임은 수백 MB를 사용하고 콜드 스타트가 수백 ms 걸립니다. 그 다음으로, 요청 객체(NextRequest)에서 쿠키, 헤더, 지리적 정보를 읽어 조건부 로직을 실행합니다.
예를 들어 인증 토큰이 없으면 NextResponse.redirect()로 즉시 로그인 페이지로 보냅니다. 이 리다이렉션은 사용자에게 가장 가까운 엣지 서버에서 발생하므로, 서울 사용자도 뉴욕 사용자도 모두 5ms 이내에 응답을 받습니다.
만약 서버 컴포넌트에서 이 작업을 했다면 서울에서는 빠르지만 뉴욕에서는 300ms 걸렸을 것입니다. 마지막으로, matcher 설정으로 미들웨어가 실행될 경로를 제한합니다.
모든 요청에 미들웨어를 실행하면 불필요한 오버헤드가 발생하므로, /dashboard 같은 보호된 경로나 A/B 테스트가 필요한 특정 페이지에만 적용하는 것이 효율적입니다. 정규식 패턴도 지원하여 matcher: ['/((?!api|_next/static|favicon.ico).*)']처럼 복잡한 조건도 가능합니다.
여러분이 Middleware를 활용하면 글로벌 서비스의 레이턴시를 극적으로 줄일 수 있습니다. 한 SaaS 스타트업은 인증 로직을 Middleware로 옮겨 유럽 사용자의 평균 응답 시간을 400ms에서 50ms로 단축했고, 이는 사용자 만족도 20% 향상으로 이어졌습니다.
또한 봇 트래픽을 엣지에서 차단하여 서버 비용을 30% 절감하고, A/B 테스트를 엣지에서 처리하여 서드파티 도구 없이 실험을 진행할 수 있게 되었습니다.
💡 Edge Runtime에서는 Node.js API (fs, path 등)를 사용할 수 없습니다. 데이터베이스 조회나 복잡한 연산은 피하고, 간단한 조건 확인과 리다이렉션만 수행하세요.
💡 JWT 검증 같은 작업은 엣지에서 가능하지만, jose 같은 엣지 호환 라이브러리를 사용해야 합니다. jsonwebtoken은 Node.js 의존성 때문에 작동하지 않습니다.
💡 request.geo를 사용하면 사용자의 국가, 도시, 위도/경도를 알 수 있습니다. 이를 활용해 지역별 콘텐츠 제공, 언어 자동 선택, GDPR 준수를 구현하세요.
💡 미들웨어의 응답 시간을 모니터링하려면 Vercel Analytics나 커스텀 헤더로 타이밍을 측정하세요. 미들웨어가 느리면 오히려 역효과가 납니다.
💡 NextResponse.rewrite()는 URL을 변경하지 않고 내부적으로 다른 페이지를 렌더링합니다. A/B 테스트나 다국어 라우팅에 유용하며, 사용자는 URL 변화를 인지하지 못합니다.
여러분이 모든 최적화를 완료하고 배포했다고 생각했는데, 막상 Lighthouse 점수가 60점대에 머물거나, 사용자들이 여전히 느리다고 불평하는 경험이 있나요? 또는 새로운 기능을 추가할 때마다 성능이 조금씩 저하되는데 이를 사전에 감지하지 못하고 있나요?
이런 문제는 체계적인 성능 관리 프로세스의 부재에서 비롯됩니다. 로컬에서는 빠른 네트워크와 강력한 개발 머신을 사용하지만, 실제 사용자는 3G 네트워크의 저사양 모바일에서 접속할 수 있습니다.
또한 한 번 최적화한다고 끝이 아니라, 지속적으로 모니터링하고 개선해야 성능이 유지됩니다. 바로 이럴 때 필요한 것이 프로덕션 체크리스트와 자동화된 성능 모니터링입니다.
Lighthouse CI를 CI/CD 파이프라인에 통합하고, Core Web Vitals를 실시간으로 추적하여 성능 저하를 사전에 방지할 수 있습니다.
간단히 말해서, 프로덕션 체크리스트는 배포 전 반드시 확인해야 할 성능 최적화 항목들의 목록이고, Lighthouse CI는 PR마다 자동으로 성능 점수를 측정하여 회귀를 방지하는 도구입니다. 이것이 필요한 이유는 성능 최적화가 일회성 작업이 아니라 지속적인 프로세스이기 때문입니다.
팀원 중 누군가 큰 라이브러리를 추가하거나, 이미지를 최적화하지 않고 올리면 성능이 저하됩니다. Lighthouse CI는 PR 단계에서 "이 변경으로 번들 크기가 100KB 증가했습니다"라고 경고하여, 머지 전에 문제를 수정할 수 있게 합니다.
예를 들어, 한 팀원이 실수로 전체 lodash를 임포트했다가 CI에서 번들 크기 경고를 받고 lodash-es로 교체하는 시나리오입니다. 기존에는 수동으로 Lighthouse를 실행하고 점수를 스프레드시트에 기록해야 했다면, 이제는 GitHub Actions로 자동화하여 모든 PR에 성능 리포트가 코멘트로 달립니다.
핵심 특징으로는 자동화된 성능 테스트, 성능 예산(Performance Budget) 설정, 그리고 트렌드 추적이 있습니다. 이러한 특징들이 중요한 이유는 주관적인 "느낌"이 아닌 객관적인 메트릭으로 성능을 관리하고, 팀 전체가 성능에 대한 책임감을 갖게 되기 때문입니다.
// lighthouserc.js - Lighthouse CI 설정
module.exports = {
ci: {
collect: {
// 테스트할 URL들
url: [
'http://localhost:3000/',
'http://localhost:3000/products',
'http://localhost:3000/about',
],
// 3번 실행하여 중간값 사용 (신뢰성 향상)
numberOfRuns: 3,
startServerCommand: 'pnpm start',
},
assert: {
// 성능 예산: 이 기준 미달 시 CI 실패
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
// 특정 메트릭 제한
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
// 번들 크기 제한
'resource-summary:script:size': ['error', { maxNumericValue: 500000 }], // 500KB
},
},
upload: {
// 결과를 Lighthouse CI 서버나 Vercel에 업로드
target: 'temporary-public-storage',
},
},
}
// .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: pnpm install
- run: pnpm build
- run: pnpm dlx @lhci/cli@0.12.x autorun
// package.json에 스크립트 추가
{
"scripts": {
"lighthouse": "lhci autorun"
}
}
이것이 하는 일: Lighthouse CI는 CI/CD 파이프라인에서 프로덕션 빌드의 Lighthouse 점수를 자동으로 측정하고, 설정된 성능 예산과 비교하여 회귀가 발생하면 경고하거나 빌드를 실패시킵니다. 이를 통해 성능 저하를 배포 전에 차단합니다.
첫 번째로, lighthouserc.js의 collect 섹션에서 테스트할 URL과 실행 횟수를 지정합니다. 이렇게 하는 이유는 네트워크 상태나 시스템 리소스의 변동성 때문에 한 번의 측정이 정확하지 않을 수 있어, 여러 번 실행하여 중간값을 사용하는 것이 신뢰성 있는 결과를 제공하기 때문입니다.
numberOfRuns: 3이면 3번 실행 후 중간값을 최종 점수로 사용합니다. 그 다음으로, assert 섹션에서 성능 예산을 정의합니다.
예를 들어 categories:performance에 minScore: 0.9를 설정하면 90점 미만일 경우 CI가 실패합니다. 이는 "성능 90점 이상"이라는 팀의 기준을 코드로 명시한 것이며, 누구도 이 기준을 위반하는 코드를 머지할 수 없게 만듭니다.
또한 LCP, CLS 같은 Core Web Vitals를 구체적인 밀리초/점수로 제한하여 더 세밀한 제어가 가능합니다. 마지막으로, GitHub Actions 워크플로우가 PR 생성 시 자동으로 실행되어 빌드하고, Lighthouse CI를 구동하며, 결과를 PR 코멘트로 게시합니다.
개발자는 코드 리뷰와 함께 성능 리포트를 보고, "이 변경으로 LCP가 500ms 증가했네요. 이미지 최적화가 필요합니다"라는 피드백을 받습니다.
이는 성능을 코드 리뷰의 일부로 만들어 팀 문화를 개선합니다. 여러분이 Lighthouse CI를 도입하면 성능 회귀를 100% 사전 차단할 수 있습니다.
한 이커머스 회사는 6개월간 운영하면서 23건의 성능 저하 시도를 PR 단계에서 막았고, Lighthouse 점수를 평균 92점으로 유지했습니다. 또한 성능 점수 트렌드를 시각화하여 경영진에게 "지난 분기 대비 페이지 로딩 속도 20% 개선"을 데이터로 보고할 수 있게 되었습니다.
무엇보다 팀원들이 성능을 "누군가의 책임"이 아닌 "모두의 책임"으로 인식하게 되는 문화적 변화가 가장 큰 성과였습니다.
💡 프로덕션 체크리스트에 포함해야 할 항목: ✓ 환경 변수 설정 확인, ✓ 번들 분석 리뷰, ✓ 이미지 최적화 확인, ✓ 소스맵 비활성화, ✓ robots.txt 및 sitemap.xml 생성, ✓ 에러 추적 (Sentry) 설정, ✓ 성능 모니터링 (Vercel Analytics) 활성화.
💡 Vercel이나 Netlify를 사용하면 자동으로 배포마다 Lighthouse 점수가 제공되지만, 자체 호스팅에서는 Lighthouse CI 서버를 직접 운영하거나 temporary-public-storage를 사용하세요.
💡 실제 사용자 데이터를 수집하려면 Next.js의 reportWebVitals를 Google Analytics나 Vercel Analytics로 전송하세요. 실험실 데이터(Lighthouse)와 필드 데이터(실사용자)를 모두 추적하는 것이 중요합니다.
💡 성능 예산은 너무 엄격하면 개발 속도를 저해하고, 너무 느슨하면 의미가 없습니다. 현재 점수에서 5-10% 향상을 목표로 설정하고, 분기마다 재조정하세요.
💡 Core Web Vitals (LCP, FID, CLS)는 Google 검색 순위에 직접 영향을 미칩니다. Search Console의 "Core Web Vitals 보고서"를 정기적으로 확인하고, 문제 URL을 우선 수정하세요.