Next.js를 S3에 배포했다가 백지 화면이 떴다 (Static Export의 악몽)
1. "돈 좀 아껴보겠다고..."
처음엔 Vercel을 썼습니다. 정말 편하고 빨랐죠. git push만 하면 배포가 되니까요.
하지만 프로젝트가 커지고 트래픽이 늘어나자, Vercel의 청구서(Pro Plan, Bandwidth)가 무서워지기 시작했습니다.
"그냥 HTML로 빌드해서 AWS S3에 올리고 CloudFront 붙이면 천 원도 안 나오지 않나?"
저는 호기롭게 next.config.js에 한 줄을 추가했습니다.
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export', // "HTML로 다 뱉어내라!"
};
그리고 npm run build를 누르는 순간, 지옥문이 열렸습니다.
단순히 HTML 파일만 생기는 게 아니라, 제가 사랑하던 Next.js의 기능들이 하나둘씩 죽어나갔기 때문입니다.
2. 첫 번째 악몽 - 이미지 최적화 (Image Optimization)
빌드 로그에 빨간 글씨가 떴습니다.
Error: Image Optimization using Next.js default loader is not compatible with "next export".
원인 분석
Next.js의 <Image /> 컴포넌트는 사실 백엔드 서버가 필요합니다.
사용자가 브라우저에서 이미지를 요청하면 -> Next.js 서버가 원본 이미지를 가져와서 -> 그 즉시(On-demand) 리사이징하고 -> WebP/AVIF로 변환해서 줍니다.
하지만 S3는 서버가 아니라 그냥 '파일 창고'입니다. 리사이징해 줄 요리사가 없는 거죠.
해결책
두 가지 길이 있습니다.
1. 가난한 해결책: 최적화 끄기 화질과 로딩 속도를 포기하고, 그냥 원본 이미지를 보여주는 겁니다.
// next.config.js
module.exports = {
images: { unoptimized: true }
}
이렇게 하면 <Image /> 컴포넌트가 그냥 <img> 태그처럼 동작합니다. 배포는 되지만, 성능 점수(Lighthouse)는 바닥을 칩니다.
2. 부자 해결책: 외부 이미지 서비스 (Loader) 사용 Cloudinary, Imgix 같은 전문 이미지 CDN을 쓰는 겁니다.
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './my-loader.ts',
},
}
Next.js는 URL만 만들어주고, 실제 변환은 외부 서비스가 담당합니다. 돈은 들지만 성능은 유지됩니다.
3. 두 번째 악몽 - API 라우트 증발
빌드에 성공하고 S3에 올렸습니다. 메인 화면이 떴습니다! "오, 성공인가?" 하지만 로그인 버튼을 누르자마자 404 Not Found가 떴습니다.
/api/login 경로가 감쪽같이 사라진 겁니다.
원인 분석
Next.js의 API Routes (/app/api/...)는 Node.js 서버(또는 Edge Runtime)가 있어야 돌아갑니다.
output: 'export'를 하면 Next.js는 HTML, CSS, JS 파일만 남기고 서버 관련 코드를 다 버립니다.
당연히 API 핸들러 함수들도 다 삭제되죠. HTML 파일 안에 백엔드 로직을 넣을 수는 없으니까요.
해결책
"서버가 필요하면 서버를 따로 만들어야지." 결국 백엔드 로직을 AWS Lambda(Serverless)로 분리하거나, 별도의 Express/NestJS 서버로 옮겨야 했습니다. S3에 정적 배포하려다 일이 더 커졌습니다. "서버리스(Serverless)"와 "정적(Static)"은 엄연히 다릅니다.
4. 세 번째 악몽 - 미들웨어(Middleware)와 동적 라우팅
미들웨어 사망
middleware.ts도 작동하지 않습니다. 로그인 안 한 사용자를 리다이렉트 시키거나, 쿠키를 검사하는 로직이 서버가 없으니 돌아갈 리가 없죠.
대신 클라이언트 사이드(useEffect)에서 검사해야 하는데, 이러면 화면이 깜빡이는(Flash of Unauthenticated Content) 현상이 생깁니다.
동적 라우팅(Dynamic Routes) 깨짐
블로그 상세 페이지(/posts/[slug])에 들어갔는데 또 깨졌습니다.
Error: Page "/posts/[slug]" is missing "generateStaticParams"
정적 배포를 하려면 세상에 존재하는 모든 페이지의 주소를 빌드 타임에 알아야 합니다. 서버가 없으니, "사용자가 요청하면 그때 페이지 만들어서 줄게(SSR)"가 불가능하기 때문입니다.
결국 generateStaticParams (구 getStaticPaths)를 사용해서, 1번 글부터 100번 글까지 모든 URL을 미리 정의해 줘야 합니다.
주의: fallback: true 같은 건 못 씁니다. 미리 안 만들어둔 페이지는 영원히 404입니다.
5. 비교: Vercel vs Docker vs S3 (Static)
| 특징 | Vercel | Docker (Node.js) | S3 (Static Export) |
|---|---|---|---|
| 비용 | 비쌈 (트래픽당 과금) | 중간 (EC2/ECS 비용) | 매우 저렴 |
| 이미지 최적화 | O (자동) | O (sharp 라이브러리 필요) | X (외부 서비스 필요) |
| API Routes | O (Serverless) | O (서버 내장) | X (불가능) |
| Middleware | O | O | X |
| SSR / ISR | O | O | X (오직 SSG만) |
| 재배포 속도 | 빠름 | 느림 (컨테이너 빌드) | 빠름 |
6. 마무리 - "서버"가 뭔지 알고 쓰자
Next.js는 단순한 React 프레임워크가 아닙니다. 웹 서버 + React + 번들러가 합쳐진 풀스택 프레임워크입니다.
output: 'export'는 여기서 웹 서버라는 심장을 도려내고 껍데기(React)만 남기는 수술입니다.
당연히 팔다리(이미지 최적화, API, SSR, 미들웨어)가 잘려나갑니다.
언제 Export를 써야 하나?
- 랜딩 페이지: 내용이 거의 안 바뀜.
- 기술 블로그: 글이 추가될 때만 다시 빌드하면 됨.
- 내부 문서 사이트: 로그인 처리 등을 다른 Gateway에서 함.
언제 쓰면 망하나?
- 쇼핑몰/커뮤니티: 데이터가 실시간으로 변함 (SSR 필수).
- 개인화 대시보드: 사용자마다 다른 내용을 보여줘야 함.
- 이미지가 많은 사이트: 최적화 없으면 느려서 못 씀.
천 원 아끼려다가 제 주말을 다 날렸습니다. 여러분은 부디 용도에 맞게 선택하세요.
I Broke Production by Deploying Next.js to S3 (The Static Export Trap)
1. "I Just Wanted to Save a Few Bucks..."
At first, I used Vercel. It was incredibly fast and easy. Just git push and boom, deployed.
But as the project grew and traffic spiked, Vercel's bill (Pro Plan limits, Bandwidth overages) started looking scary.
"Wait, if I just build it as static HTML and put it on AWS S3 with CloudFront, it would cost pennies, right?"
I confidently added one line to next.config.js.
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export', // "Spit out everything as HTML!"
};
And the moment I hit npm run build, the gates of hell opened.
Running next export doesn't just create HTML files; it strips away the "Server" features that make Next.js great.
2. Nightmare #1: Image Optimization
Red text filled my build log immediately.
Error: Image Optimization using Next.js default loader is not compatible with "next export".
The Root Cause
Next.js's <Image /> component is deceptive. It looks like a UI component, but it requires a Backend Server.
When a user requests an image, the Next.js server fetches the original, resizes it on-demand using sharp, converts it to WebP/AVIF, and serves it.
But S3 is a "Object Storage", not a computing server. It cannot resize images. There is no chef in the warehouse.
The Solutions
You have two choices:
1. The Poor Fix: Turn off optimization Sacrifice quality and loading speed. Just serve the heavy original image.
// next.config.js
module.exports = {
images: { unoptimized: true }
}
This converts <Image /> back to a dumb <img> tag. Deployment works, but your Lighthouse score will tank.
2. The Rich Fix: Use External Loaders Use a dedicated Image CDN like Cloudinary, Imgix, or Akamai.
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './my-loader.ts',
},
}
Next.js generates the URL structure, and the external service handles the processing. It costs money but maintains performance.
3. Nightmare #2: Vanishing API Routes
I managed to build the app (by disabling images) and uploaded it to S3. The main page loaded! "Victory?" I thought. But when I clicked Login, I got a 404 Not Found.
The /api/login route had completely disappeared.
The Root Cause
Next.js API Routes (/app/api/...) require a Node.js runtime (or Edge runtime) to execute.
When you set output: 'export', Next.js acts as a static site generator. It keeps HTML, CSS, client-side JS, and deletes all server-side code.
You cannot embed a database connection or secret keys into a static HTML file.
The Fix
"If I need a server, I must build one." I had to migrate my backend logic to AWS Lambda (Serverless) or a separate Express/NestJS server. The complexity of my infrastructure exploded. I realized that "Serverless" and "Static" are completely different things.
4. Nightmare #3: Middleware & Dynamic Routes
The Death of Middleware
middleware.ts relies on a server intercepting requests before they reach the page.
In a Static Export, there is no interceptor. The request goes directly to the HTML file in S3.
Authentication checks, redirects, and header manipulation in Middleware simply do not exist.
Broken Dynamic Routes
I went to a blog post details page (/posts/[slug]) and it crashed during build.
Error: Page "/posts/[slug]" is missing "generateStaticParams"
To perform a static export, Every Single URL must be known at build time. Since there is no server to render pages on-the-fly (SSR), you must pre-render absolutely everything.
The Fix:
Use generateStaticParams (formerly getStaticPaths) to define every possible slug from your database.
Warning: You cannot use fallback: true or blocking. If a new post is added to the DB, it won't show up until you rebuild and redeploy the site.
5. Comparison: Vercel vs Docker vs S3 (Static)
| Feature | Vercel | Docker (Self-Hosted) | S3 (Static Export) |
|---|---|---|---|
| Cost | High (Pro plan + limits) | Medium (EC2 costs) | Extremely Low |
| Image Opt | ✅ Automatic | ✅ Requires sharp | ❌ External Service Only |
| API Routes | ✅ Serverless | ✅ Built-in Node server | ❌ Impossible |
| Middleware | ✅ Works | ✅ Works | ❌ Doesn't work |
| SSR / ISR | ✅ Yes | ✅ Yes | ❌ SSG Only |
| Heads-up | Vendor Lock-in? | Maintenance Overhead | Feature Limitations |
6. Conclusion: Know What You Are sacrificing
Next.js is not just a UI framework. It is a Fullstack Framework (Web Server + React + Bundler).
Using output: 'export' is essentially performing surgery to remove the Web Server heart and leaving only the React skin.
You lose critical limbs: Image Optimization, API Routes, SSR, Middleware.
When to use Export?
- Landing Pages: Content rarely changes.
- Tech Blogs: Only update when pushing new markdown.
- Documentation: Pure static content.
When to Avoid it?
- E-commerce/Social: Data changes in real-time (Need SSR).
- Dashboards: Personalized content per user.
- Dynamic Apps: If you rely heavily on the request object (
headers(),cookies()).
I wasted my weekend trying to save a few dollars. Don't be like me. Choose the deployment strategy that fits your features, not just your wallet.