이 블로그는 Next.js로 만들고 S3에 올린다. Vercel에 올리면 5분이면 끝나는 걸 굳이 S3에 올리는 이유는, 이미 회사에서 쓰는 AWS 인프라가 있어서 거기 얹는 게 관리 포인트가 줄었기 때문이다. 근데 그 "5분이면 끝나는 걸"을 S3에서 하려고 하니까 이틀을 날렸다.
Vercel이 얼마나 많은 걸 자동으로 해주고 있었는지, S3에 직접 배포해봐야 체감된다.
output: "export" 설정했더니 빌드가 터진다
Next.js를 S3에 올리려면 정적 파일로 내보내야 한다. next.config.js에 이 한 줄을 추가하면 된다.
// next.config.js
module.exports = {
output: "export",
};간단해 보인다. next build를 실행하면 out/ 폴더에 HTML, CSS, JS가 생기고, 그걸 S3에 통째로 올리면 된다. 나도 그렇게 생각했다. 그런데 빌드를 돌리자마자 이런 에러가 떴다.
Error: API Routes cannot be used with "output: export".
Route "/api/og" is using API Routes.
당연하다. S3는 정적 파일 호스팅이니까 서버 사이드 코드를 실행할 수 없다. API route가 존재하면 빌드 자체가 실패한다. 나는 OG 이미지 생성용으로 /api/og 라우트를 하나 두고 있었는데, 이걸 깜빡하고 있었다.
해결법은 두 가지다.
- API route를 아예 삭제한다
- API route가 하던 일을 빌드 타임으로 옮긴다
나는 2번을 택했다. OG 이미지 생성을 빌드 타임에 하는 방법은 마지막 섹션에서 다룬다.
중요한 건, output: "export"를 켜는 순간 사용할 수 없는 기능이 생각보다 많다는 점이다. API routes 말고도 middleware.ts, rewrites, headers 같은 서버 의존적 기능이 전부 빠진다. 공식 문서에 지원되지 않는 기능 목록이 있으니까 output: "export" 넣기 전에 한 번 훑어보는 게 좋다.
trailingSlash를 안 넣으면 S3에서 404가 뜬다
빌드가 성공했다. out/ 폴더를 S3에 올렸다. 홈페이지는 잘 나온다. 블로그 목록도 나온다. 그런데 블로그 글을 클릭하면 404다.
이건 좀 해맸다. 로컬에서는 멀쩡하거든.
원인은 S3의 파일 탐색 방식에 있다. Next.js가 trailingSlash 설정 없이 빌드하면 /blog/my-post 경로에 대해 blog/my-post.html 파일을 생성한다. 근데 브라우저가 https://example.com/blog/my-post를 요청하면, S3는 이걸 blog/my-post라는 폴더로 해석하고 그 안에서 index.html을 찾는다. blog/my-post.html이 아니라. 당연히 없으니까 404.
// next.config.js
module.exports = {
output: "export",
trailingSlash: true, // 이거 하나로 해결
};trailingSlash: true를 넣으면 Next.js가 /blog/my-post/index.html 형태로 파일을 생성한다. S3가 /blog/my-post/ 요청을 받으면 그 폴더 안의 index.html을 찾아서 반환한다.
빌드 결과물을 비교해보면 차이가 확실하다.
# trailingSlash: false (기본값)
out/blog/my-post.html
# trailingSlash: true
out/blog/my-post/index.html
이 한 줄 때문에 한 시간을 썼다. S3 버킷 설정을 만지고, 에러 페이지 설정도 바꿔보고, CloudFront 설정도 건드렸는데 전부 헛짓이었다. next.config.js가 문제였다.
dynamic routes에서 fallback: false가 필수다
블로그 같은 사이트에는 dynamic route가 있다. /blog/[slug] 형태로 글마다 다른 URL을 만드는 거다. Next.js Pages Router 기준으로 getStaticPaths에서 빌드할 경로 목록을 반환해야 한다.
// pages/blog/[slug].tsx
export async function getStaticPaths() {
const posts = getAllPosts();
return {
paths: posts.map((post) => ({
params: { slug: post.slug },
})),
fallback: false, // 이게 핵심
};
}fallback에는 false, true, "blocking" 세 가지 옵션이 있는데, output: "export"에서는 반드시 false여야 한다. true나 "blocking"을 쓰면 이런 에러가 뜬다.
Error: Pages with `fallback` enabled in `getStaticPaths`
can not be exported. See more info here:
https://nextjs.org/docs/messages/ssg-fallback-true-export
이유를 생각해보면 당연하다. fallback: true는 빌드 시점에 생성하지 않은 페이지를 런타임에 서버가 생성하겠다는 뜻이다. S3에 서버가 없으니 불가능하다. fallback: "blocking"도 마찬가지로 서버 사이드 렌더링이 필요하다.
App Router를 쓴다면 generateStaticParams에서 비슷한 역할을 한다. 그리고 dynamic 설정을 명시적으로 잡아줘야 할 수도 있다.
// app/blog/[slug]/page.tsx
export const dynamic = "error";
// 빌드 타임에 생성되지 않은 경로는 에러 처리
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}dynamic = "error"는 Pages Router의 fallback: false와 같은 의미다. 빌드 타임에 생성된 경로만 허용하고, 나머지는 전부 404로 보낸다.
next/image가 안 되는 이유
이건 에러 메시지가 친절해서 금방 해결했다.
Error: Image Optimization using the default loader is not
compatible with `output: export`.
Next.js의 <Image> 컴포넌트는 기본적으로 서버에서 이미지를 리사이즈하고 WebP로 변환한다. S3에는 그 서버가 없다. 설정 하나 추가하면 된다.
// next.config.js
module.exports = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
};이러면 <Image> 컴포넌트가 최적화 없이 원본 이미지를 그대로 사용한다. "그러면 이미지 최적화를 아예 포기해야 하나?" 하는 생각이 드는데, 몇 가지 대안이 있다.
빌드 타임에 sharp 라이브러리로 이미지를 미리 최적화해두거나, CloudFront 앞에 Lambda@Edge를 붙여서 이미지를 변환하거나, 아예 외부 이미지 CDN 서비스를 쓰는 방법이 있다. 이 블로그는 이미지가 많지 않아서 빌드 시에 수동으로 WebP 변환해서 올리고 있다. 완벽한 해결책은 아닌데, 개인 블로그에 Lambda@Edge까지 붙이는 건 과하다고 판단했다.
CloudFront 캐시 무효화를 까먹으면 생기는 일
배포 파이프라인을 다 만들었다. S3에 파일 올리고, 사이트를 확인하고, 잘 된다. 글을 하나 수정하고 다시 배포했다. S3에 파일이 업데이트된 걸 확인했다. 근데 사이트에서는 안 바뀌어 있다.
캐시다.
CloudFront는 S3 앞에 붙는 CDN이다. 한 번 요청된 파일을 엣지 서버에 캐싱해두고, 같은 요청이 오면 S3까지 가지 않고 캐시된 파일을 반환한다. 그래서 S3의 파일을 바꿔도 CloudFront는 모른다. 명시적으로 캐시를 무효화해줘야 한다.
# CloudFront 캐시 무효화
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*""/*"는 모든 경로의 캐시를 날리겠다는 뜻이다. 특정 파일만 무효화할 수도 있지만, 블로그 배포에서는 어차피 여러 파일이 동시에 바뀌니까 전체 무효화가 편하다. 참고로 CloudFront는 매달 1,000건의 무효화 경로를 무료로 제공하고, "/*"는 1건으로 친다.
배포 스크립트에 넣어두면 까먹을 일이 없다.
#!/bin/bash
# deploy.sh
BUCKET="my-blog-bucket"
DISTRIBUTION_ID="E1234567890"
echo "Building..."
pnpm build
echo "Uploading to S3..."
aws s3 sync out/ s3://$BUCKET --delete
echo "Invalidating CloudFront cache..."
aws cloudfront create-invalidation \
--distribution-id $DISTRIBUTION_ID \
--paths "/*"
echo "Done!"나는 이걸 CI/CD에 넣어두고 main 브랜치에 푸시하면 자동으로 실행되게 해뒀다. GitHub Actions에서 돌리고 있는데, AWS credentials만 시크릿으로 넣어두면 된다.
이 문제로 하루를 날린 적이 있다. 분명 S3에 새 파일이 올라간 걸 확인했는데, 사이트에서 변경이 안 보이니까 빌드에 문제가 있나 싶어서 out/ 폴더를 열어보고, 그래도 안 돼서 next.config.js를 의심하고, 한참 삽질하다가 "아 캐시" 하는 순간이 왔다. CloudFront를 쓰고 있다는 사실 자체를 잊고 있었다.
OG 이미지를 빌드 타임에 생성하기
처음에는 /api/og 라우트에서 @vercel/og를 써서 OG 이미지를 동적으로 생성하고 있었다. 근데 output: "export"를 켜면 API route를 쓸 수 없으니까, 빌드 타임에 미리 만들어야 한다.
satori와 sharp를 쓰면 된다. satori는 JSX를 SVG로 변환하고, sharp는 SVG를 PNG로 변환한다. 사실 @vercel/og도 내부적으로 satori를 쓰고 있어서 결과물이 거의 동일하다.
pnpm add -D satori sharp @types/sharp빌드 스크립트를 하나 만든다.
// scripts/generate-og-images.mts
import satori from "satori";
import sharp from "sharp";
import { readFileSync, mkdirSync, writeFileSync } from "fs";
import { getAllPosts } from "../src/lib/posts";
const font = readFileSync("./public/fonts/PretendardBold.otf");
async function generateOgImage(title: string, slug: string) {
const svg = await satori(
{
type: "div",
props: {
style: {
width: "1200px",
height: "630px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "60px",
backgroundColor: "#0a0a0a",
color: "#fafafa",
},
children: [
{
type: "div",
props: {
style: {
fontSize: "52px",
fontWeight: 700,
lineHeight: 1.3,
wordBreak: "keep-all",
},
children: title,
},
},
{
type: "div",
props: {
style: {
fontSize: "24px",
color: "#a1a1aa",
marginTop: "24px",
},
children: "tech-donut.com",
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [
{
name: "Pretendard",
data: font,
weight: 700,
style: "normal",
},
],
}
);
const png = await sharp(Buffer.from(svg)).png().toBuffer();
const dir = `./public/og`;
mkdirSync(dir, { recursive: true });
writeFileSync(`${dir}/${slug}.png`, png);
}
async function main() {
const posts = getAllPosts();
for (const post of posts) {
await generateOgImage(post.title, post.slug);
console.log(`Generated: ${post.slug}.png`);
}
}
main();package.json에 스크립트를 추가한다.
{
"scripts": {
"generate:og": "tsx scripts/generate-og-images.mts",
"build": "pnpm generate:og && next build"
}
}그리고 각 페이지의 metadata에서 생성된 이미지를 참조한다.
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
const post = getPostBySlug(params.slug);
return {
openGraph: {
images: [`/og/${params.slug}.png`],
},
};
}이렇게 하면 next build 전에 OG 이미지가 public/og/에 생성되고, 빌드 결과물에 포함된다. API route 없이도 각 포스트마다 커스텀 OG 이미지를 가질 수 있다.
주의할 점 하나. satori는 JSX를 HTML처럼 해석하지만, CSS 지원이 완벽하지 않다. flexbox는 되지만 grid는 안 되고, position: absolute는 되지만 transform은 일부만 된다. 레이아웃이 복잡하면 결과물이 예상과 다를 수 있어서, 간단하게 만드는 게 낫다.
최종 설정
여기까지 겪은 삽질을 전부 반영한 next.config.js다.
// next.config.js
module.exports = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
};세 줄이다. 이 세 줄에 도달하는 데 이틀이 걸렸다. 각각의 설정이 왜 필요한지 모르면 하나씩 빼보고 싶은 유혹이 생기는데, 빼면 터진다.
S3 배포가 Vercel 대비 확실히 손이 많이 간다. 근데 한 번 세팅해두면 월 호스팅 비용이 거의 0에 수렴하고, AWS 인프라에 이미 익숙하다면 오히려 관리가 편할 수도 있다. 이 블로그도 한 번 세팅한 뒤로는 글 쓰고 푸시하면 끝이니까. 삽질은 처음 한 번이면 충분하다.
