Next.js Static Export 실전 함정 모음 — 배포하다 만난 10가지 지뢰
Next.js 15 App Router + output: export 로 S3 배포하면서 부딪힌 실전 함정 10가지. 각 증상과 해결 패턴을 체크리스트 형태로.
시작 — 왜 static export 를 골랐나
이 블로그는 CDN 로드와 운영 비용을 최소화하려고 output: 'export' 모드를 선택했습니다. S3 + CloudFront 로 정적 파일만 배포되기 때문에 서버 유지비가 거의 없고 전 세계 어디서든 빠르게 로드됩니다.
좋은 선택이었지만, 편안한 길은 아니었습니다. App Router 의 서버 기능 상당수가 static export 환경과 충돌하고, 이를 우회하는 규칙이 곳곳에 숨어 있기 때문입니다. 이 포스트는 그 과정에서 실제로 부딪혔던 10가지 지뢰 를 정리했습니다.
1. rewrites() 는 dev 전용
// next.config.ts
async rewrites() {
return [{ source: '/api/exoplanet', destination: 'https://...' }];
}
증상: 로컬에서는 잘 되는데 배포하면 404.
원인: rewrites() 는 Next.js dev 서버 런타임 에서만 동작합니다. output: 'export' 빌드 산출물에는 아예 포함되지 않습니다.
해결: prod 에서는 CDN/Edge(CloudFront behavior, Vercel rewrites, nginx location 등)가 동일 경로 프록시를 대신 처리해야 합니다. 즉 repo 외부 인프라가 책임지는 부분.
2. dynamic(..., { ssr: false }) 는 Server Component 에서 금지
const Game = dynamic(() => import('./Game'), { ssr: false });
증상: 빌드 시 ssr: false is not allowed with next/dynamic in Server Components 에러.
해결: 대안으로 { loading: () => null } 패턴을 쓰거나, 해당 dynamic import 를 Client Component 로 옮깁니다. 이 프로젝트는 각 게임 page.tsx 에서 next/dynamic lazy import 를 하되 ssr: false 대신 loading fallback 을 쓰는 식으로 정리했습니다.
3. sitemap.ts 에는 export const dynamic = 'force-static' 필수
증상: 빌드 중 "sitemap route is dynamic by default" 경고 + 실제 sitemap.xml 미생성.
해결:
// app/sitemap.ts
export const dynamic = 'force-static';
export default function sitemap() {
/* ... */
}
이걸 빠뜨리면 sitemap 이 dynamic route 로 인식되어 static export 에서 제대로 나오지 않습니다.
4. getLocale() 사용 금지
next-intl 의 getLocale() 은 내부적으로 headers() 를 호출합니다. headers() 는 런타임이 필요한 API 라 static export 에서 빌드 실패.
해결: page.tsx 나 layout.tsx 에서 params 로 받습니다.
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
// ...
}
5. getTranslations('ns') 가 아니라 { locale, namespace } 형태
증상: 4번과 같은 이유로 빌드 실패.
해결:
// ❌
const t = await getTranslations('game');
// ✅
const t = await getTranslations({ locale, namespace: 'game' });
locale 을 명시적으로 전달하면 headers() 가 호출되지 않아 static export 호환됩니다.
6. metadata export 는 Server Component 에서만
증상: 'use client' 컴포넌트에 export const metadata 를 뒀더니 경고 + 적용 안 됨.
해결: page.tsx 는 Server Component 로 두고 metadata 를 export. 인터랙티브 UI 는 별도 컨테이너 컴포넌트('use client')로 분리하는 구조가 표준.
// app/[locale]/galaga/page.tsx — Server Component
export const metadata = { title: 'Galaga' };
export default function GalagaPage() {
return <GalagaContainer />; // 'use client' 컨테이너
}
7. generateStaticParams 없으면 dynamic route 는 빈 페이지
증상: /devlog/[slug] 같은 동적 라우트가 배포 후 전부 404.
해결: 해당 라우트의 page.tsx 에 generateStaticParams 를 정의해 빌드 시 가능한 모든 slug 를 반환.
export function generateStaticParams() {
return DEVLOG_SLUGS.map(slug => ({ slug }));
}
8. 외부 API CORS 는 CDN 이 책임
클라이언트가 브라우저에서 외부 API 를 직접 호출하면 거의 CORS 로 막힙니다. static export 환경엔 Next.js 프록시가 없으니 다음 중 하나가 필요.
- CloudFront behavior 로 프록시 (이 블로그 방식)
- Vercel Edge Functions
- 제3자 CORS 프록시 (권장 X — 신뢰도 문제)
9. 클라이언트 전용 코드는 useEffect 내부로
증상: localStorage, window.matchMedia 등을 컴포넌트 최상위에서 접근하면 빌드 시점 SSR 렌더에서 ReferenceError.
해결:
// ❌
const theme = localStorage.getItem('theme');
// ✅
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
setTheme(localStorage.getItem('theme'));
}, []);
주의할 점 하나 더: useMemo(() => localStorage.get(...), []) 패턴도 SSR 에서 null 로 계산된 뒤 hydration 후에 재실행되지 않아 영구 null 캐시 가 됩니다. 반드시 useEffect 로.
10. API route (route.ts) 는 원천 불가
증상: app/api/xxx/route.ts 를 만들어도 배포하면 동작 안 함.
해결: static export 는 서버 런타임이 없으므로 API route 가 작동하지 않습니다. 필요하면 Supabase·Firebase 같은 BaaS 를 쓰거나, 별도 Edge Function 을 설치해야 합니다.
요약 체크리스트
static export 시작할 때 이것만 한 번 훑어도 대부분 막힙니다.
-
rewrites()는 dev 전용 → prod 는 CDN 프록시 -
dynamic({ ssr: false })는 Client Component 에서만 -
sitemap.ts에force-static명시 -
getLocale()/getTranslations('ns')금지 →params경유 -
metadata는 Server Component 에서만 export - dynamic route 는
generateStaticParams필수 - 외부 API CORS 는 CDN 설정
- 브라우저 전용 API 는
useEffect내부로 -
useMemo(() => browserOnly(), [])금지 - API route 는 작동 안 함 — BaaS/Edge 대체
회고
처음 static export 를 골랐을 때만 해도 "SSR 기능만 안 쓰면 되겠지" 정도로 가볍게 봤습니다. 실제로는 App Router 의 많은 API 가 내부적으로 런타임을 요구하는 탓에 함정이 곳곳에 있었습니다.
반면 각 함정을 한 번 밟고 우회 패턴을 익히면 정말 편안한 배포 환경이 됩니다. S3 업로드 + CloudFront 캐시 invalidation 몇 초면 배포가 끝나고, 서버 비용은 거의 0 입니다.
Next.js 로 정적 배포를 고민하시는 분이라면, 위 체크리스트를 먼저 훑어보시길 권합니다. 한 번에 정리해두면 반복 디버깅 시간이 확 줄어듭니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...