안녕하세요! 오늘은 Next.js 공식 문서 중에서 정적 내보내기(Static Exports) 부분에 대해 함께 공부해 볼게요.
Next.js는 처음에는 정적 사이트나 싱글 페이지 애플리케이션(SPA)으로 가볍게 시작했다가, 프로젝트가 커지면서 나중에 서버가 필요한 기능들을 선택적으로 업그레이드해서 사용할 수 있도록 유연하게 설계되어 있어요.
우리가 터미널에서 next build 명령어를 실행하면, Next.js는 각각의 라우트(페이지 경로)마다 하나의 HTML 파일을 만들어냅니다. 하나의 거대한 SPA를 여러 개의 개별 HTML 파일들로 쪼개기 때문에, 사용자가 접속했을 때 클라이언트 측에서 불필요한 자바스크립트 코드를 불러오는 걸 방지할 수 있죠. 결과적으로 번들 크기도 줄어들고 페이지 로딩 속도도 훨씬 빨라진답니다.
💡 강사님의 부연 설명 및 팁:
여기서 '정적 내보내기'란 우리가 만든 React 코드를 단순히 브라우저가 바로 읽을 수 있는 순수한 HTML, CSS, JavaScript 파일들로 구워내는(빌드하는) 과정을 말해요.
이렇게 정적으로 내보낸 결과물은 Node.js 서버 없이도 돌아가기 때문에, 아주 저렴하거나 심지어 무료인 호스팅 서비스(예: GitHub Pages, AWS S3 등)에 간편하게 올릴 수 있다는 엄청난 장점이 있어요!
Next.js가 이런 정적 내보내기 기능을 완벽하게 지원하기 때문에, 생성된 결과물은 HTML/CSS/JS 같은 정적 자원(assets)을 제공할 수 있는 어떤 웹 서버에든 배포하고 호스팅할 수 있습니다.
정적 내보내기 기능을 켜려면 프로젝트의 next.config.js 파일 안에 있는 출력(output) 모드를 변경해 주면 됩니다. 아래 코드처럼요!
//filename="next.config.js" highlight={5}
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
output: 'export',
// Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html`
// trailingSlash: true,
// Optional: Prevent automatic `/me` -> `/me/`, instead preserve `href`
// skipTrailingSlashRedirect: true,
// Optional: Change the output directory `out` -> `dist`
// distDir: 'dist',
}
module.exports = nextConfig
이 설정을 마치고 next build를 실행하고 나면, Next.js가 여러분의 프로젝트 폴더 안에 애플리케이션의 HTML/CSS/JS 자원들이 예쁘게 담긴 out 폴더를 새로 만들어 줄 거예요.
Next.js의 핵심 기능들은 정적 내보내기를 완벽하게 지원하도록 설계되어 있답니다. 어떤 것들이 있는지 하나씩 살펴볼까요?
정적 내보내기를 위해 next build를 실행할 때, app 디렉토리 안에서 사용된 서버 컴포넌트들은 빌드하는 과정 중에 실행됩니다. 전통적인 정적 사이트 생성(SSG) 방식과 아주 비슷하죠.
그 결과로 만들어진 컴포넌트는 사용자가 처음 페이지를 열 때 보여줄 정적인 HTML로 변환되고, 라우트 간에 클라이언트 네비게이션을 할 때 쓸 정적인 페이로드(데이터)로 렌더링 됩니다. 만약 동적 서버 함수(dynamic server functions)를 사용하지 않는다면, 정적 내보내기를 쓴다고 해서 여러분이 작성해둔 서버 컴포넌트 코드를 수정할 필요는 전혀 없어요. 안심하셔도 됩니다!
💡 강사님의 부연 설명:
"서버 컴포넌트인데 서버가 없어도 돌아가나요?"라고 질문하실 수 있어요! 정적 내보내기를 하면, 넥스트가 빌드하는 바로 그 시점(개발자 PC나 CI/CD 서버 환경)을 '서버'로 간주하고 데이터를 한 번 다 가져온 다음 HTML로 고정시켜 버리는 원리랍니다.
//filename="app/page.tsx" switcher
export default async function Page() {
// This fetch will run on the server during `next build`
const res = await fetch('https://api.example.com/')
const data = await res.json()
return <main>...</main>
}
만약 빌드 시점이 아니라, 사용자가 접속한 클라이언트 쪽(브라우저)에서 동적으로 데이터를 가져오고 싶다면 어떻게 해야 할까요? 그럴 땐 클라이언트 컴포넌트를 만들고, SWR 같은 라이브러리를 활용해서 요청을 메모이제이션(기억) 하도록 구현하시면 됩니다.
//filename="app/other/page.tsx" switcher
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then((r) => r.json())
export default function Page() {
const { data, error } = useSWR(
`https://jsonplaceholder.typicode.com/posts/1`,
fetcher
)
if (error) return 'Failed to load'
if (!data) return 'Loading...'
return data.title
}
//filename="app/other/page.js" switcher
'use client'
import useSWR from 'swr'
const fetcher = (url) => fetch(url).then((r) => r.json())
export default function Page() {
const { data, error } = useSWR(
`https://jsonplaceholder.typicode.com/posts/1`,
fetcher
)
if (error) return 'Failed to load'
if (!data) return 'Loading...'
return data.title
}
라우트 간의 화면 전환이 클라이언트 측에서 이루어지기 때문에, 이건 우리가 아는 전통적인 SPA(Single Page Application)처럼 아주 부드럽게 동작합니다. 예를 들어, 아래의 인덱스 라우트(첫 화면) 코드를 보면 클라이언트 환경에서 다른 포스트로 네비게이션 할 수 있게 해줍니다.
//filename="app/page.tsx" switcher
import Link from 'next/link'
export default function Page() {
return (
<>
<h1>Index Page</h1>
<hr />
<ul>
<li>
<Link href="/post/1">Post 1</Link>
</li>
<li>
<Link href="/post/2">Post 2</Link>
</li>
</ul>
</>
)
}
//filename="app/page.js" switcher
import Link from 'next/link'
export default function Page() {
return (
<>
<h1>Index Page</h1>
<p>
<Link href="/other">Other Page</Link>
</p>
</>
)
}
next/image를 통한 이미지 최적화(Image Optimization) 기능도 정적 내보내기와 함께 사용할 수 있어요! 단, next.config.js 파일 안에 여러분만의 커스텀 이미지 로더(loader)를 정의해 주셔야 합니다. 예를 들어, Cloudinary 같은 서비스를 활용해서 이미지를 최적화할 수 있습니다.
💡 강사님의 팁:
Next.js의 기본 이미지 최적화 기능은 Node.js 서버(Next.js 자체 서버)의 연산 능력을 필요로 해요. 하지만 정적 내보내기를 하면 이 서버가 존재하지 않게 되죠. 그래서 외부 이미지 최적화 서비스(CDN 등)의 URL을 만들어주는 '커스텀 로더'를 직접 연결해 주어야 작동한답니다.
//filename="next.config.js"
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
loader: 'custom',
loaderFile: './my-loader.ts',
},
}
module.exports = nextConfig
이 커스텀 로더 파일에서는 원격 소스에서 어떻게 이미지를 가져올지 그 방법을 정의하게 됩니다. 아래 로더 코드는 Cloudinary 서비스에 맞춰서 URL을 만들어주는 훌륭한 예시에요.
//filename="my-loader.ts" switcher
export default function cloudinaryLoader({
src,
width,
quality,
}: {
src: string
width: number
quality?: number
}) {
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`]
return `https://res.cloudinary.com/demo/image/upload/${params.join(
','
)}${src}`
}
//filename="my-loader.js" switcher
export default function cloudinaryLoader({ src, width, quality }) {
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`]
return `https://res.cloudinary.com/demo/image/upload/${params.join(
','
)}${src}`
}
이렇게 세팅을 마쳤다면, 이제 여러분의 애플리케이션 안에서 평소처럼 next/image를 사용하시되, src 속성에는 Cloudinary에 있는 이미지의 상대 경로를 적어주시기만 하면 됩니다.
//filename="app/page.tsx" switcher
import Image from 'next/image'
export default function Page() {
return <Image alt="turtles" src="/turtles.jpg" width={300} height={300} />
}
//filename="app/page.js" switcher
import Image from 'next/image'
export default function Page() {
return <Image alt="turtles" src="/turtles.jpg" width={300} height={300} />
}
라우트 핸들러 역시 next build를 실행할 때 정적인 응답 결과를 만들어 냅니다. 단, 주의할 점은 HTTP 메서드 중에 오직 GET 요청만 지원된다는 거예요. 이 기능은 캐싱 된 데이터나 캐싱 되지 않은 데이터를 바탕으로 정적인 HTML, JSON, TXT 파일 등을 생성할 때 아주 유용하게 쓸 수 있어요.
//filename="app/data.json/route.ts" switcher
export async function GET() {
return Response.json({ name: 'Lee' })
}
//filename="app/data.json/route.js" switcher
export async function GET() {
return Response.json({ name: 'Lee' })
}
위에 작성한 app/data.json/route.ts 파일은 next build가 도는 동안 정적인 파일로 구워져서, 결과적으로 { name: 'Lee' }라는 내용을 담은 실제 data.json 파일이 만들어집니다.
하지만 만약 들어오는 요청(Request)에서 무언가 동적인 값을 읽어와야만 한다면, 이 정적 내보내기 방식은 사용할 수 없답니다. 서버가 없으니까요!
우리가 작성한 클라이언트 컴포넌트들은 next build 과정 중에 HTML로 미리 렌더링(pre-rendered) 됩니다. 그런데 문제가 하나 있어요. window, localStorage, navigator 같은 웹 API(Web APIs)들은 브라우저에만 존재하고 서버 환경에는 없거든요. 그래서 이런 API들에 접근할 때는 코드 안에서 오직 '브라우저에서 실행될 때만' 접근하도록 안전장치를 마련해 주어야 합니다. 예를 들면 아래처럼요.
💡 강사님의 특급 팁:
처음 프론트엔드를 하시는 분들이 가장 많이 겪는 에러 중 하나가 바로 이 부분이에요! Next.js는 기본적으로 코드를 먼저 서버(빌드 타임 포함)에서 한 번 실행해 보기 때문에, 무턱대고 바깥에window를 쓰면 "window is not defined" 에러가 뻥! 하고 터집니다.
반드시 컴포넌트가 화면에 마운트 된 이후에 실행되는useEffect안에서 사용하시거나, 조건문(typeof window !== 'undefined')을 사용해서 방어 코드를 짜주셔야 해요.
'use client';
import { useEffect } from 'react';
export default function ClientComponent() {
useEffect(() => {
// You now have access to `window`
console.log(window.innerHeight);
}, [])
return ...;
}
자, 이제 중요한 부분입니다. Node.js 서버가 꼭 필요하거나, 빌드 과정 중에는 도저히 미리 계산할 수 없는 동적인 로직이 들어간 기능들은 정적 내보내기 환경에서 지원되지 않습니다. 꼭 기억해 두세요!
dynamicParams: true 옵션을 사용한 동적 라우트(Dynamic Routes)generateStaticParams() 함수를 쓰지 않은 동적 라우트(Dynamic Routes)Request 객체에 의존하는 라우트 핸들러(Route Handlers)loader를 사용하는 이미지 최적화(Image Optimization)만약 이런 기능들을 사용한 채로 next dev 명령어를 실행하려고 시도한다면, 루트 레이아웃 파일에서 dynamic 옵션을 error로 강제 설정했을 때와 비슷하게 얄짤없이 에러가 발생하게 됩니다.
export const dynamic = 'error'
정적 내보내기 기능을 활용하면, Next.js 애플리케이션은 HTML/CSS/JS 정적 파일들을 제공할 수 있는 어떤 웹 서버에든지 배포하고 호스팅할 수 있다고 말씀드렸죠?
next build를 실행하면, Next.js가 빌드 결과물을 out 폴더 안에 예쁘게 정리해 줍니다. 예를 들어, 우리 프로젝트에 다음과 같은 라우트들이 있다고 가정해 볼게요.
//blog/[id]next build 명령어가 쓱싹 돌아가고 나면, Next.js는 아래와 같은 파일들을 짠! 하고 만들어 냅니다.
/out/index.html/out/404.html/out/blog/post-1.html/out/blog/post-2.html만약 실무에서 Nginx 같은 정적 호스트 웹 서버를 사용하고 계신다면, 사용자의 요청이 들어왔을 때 이 알맞은 html 파일들로 잘 찾아가도록 아래처럼 라우팅(리라이트) 설정을 추가해 주시면 됩니다.
💡 강사님의 팁:
사용자가 브라우저에서/blog/post-1주소로 들어왔을 때, 웹 서버가 폴더를 찾는 게 아니라 우리가 뽑아낸post-1.html파일을 서빙하도록 서버 설정을 잡아주는 과정이에요!
server {
listen 80;
server_name acme.com;
root /var/www/out;
location / {
try_files $uri $uri.html $uri/ =404;
}
# This is necessary when `trailingSlash: false`.
# You can omit this when `trailingSlash: true`.
location /blog/ {
rewrite ^/blog/(.*)$ /blog/$1.html break;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
| Version | Changes |
|---|---|
v14.0.0 | next export 명령어가 제거되었고, 대신 next.config.js에서 "output": "export" 설정을 사용하도록 변경되었습니다. |
v13.4.0 | 앱 라우터(App Router)가 안정화되면서 리액트 서버 컴포넌트(React Server Components)와 라우트 핸들러(Route Handlers) 사용을 포함하여 더욱 향상된 정적 내보내기 지원이 추가되었습니다. |
v13.3.0 | next export 명령어가 권장되지 않으며(deprecated), "output": "export"로 대체되었습니다. |
모든 문서의 의미론적 개요를 전체적으로 훑어보고 싶으시다면 사이트맵 문서(/docs/sitemap.md)를 확인해 주세요.
사용 가능한 모든 문서의 인덱스(목록)를 보시려면 llms.txt 파일(/docs/llms.txt)을 확인해 주시기 바랍니다.