뒤로가기
Next.js를 S3에 배포하면서 겪은 삽질 모음

January 7, 2023

Next.jsAWSdeployment

이 블로그는 Next.js로 만들고 S3에 올린다. Vercel에 올리면 5분이면 끝나는 걸 굳이 S3에 올리는 이유는, 이미 회사에서 쓰는 AWS 인프라가 있어서 거기 얹는 게 관리 포인트가 줄었기 때문이다. 근데 그 "5분이면 끝나는 걸"을 S3에서 하려고 하니까 이틀을 날렸다.

Vercel이 얼마나 많은 걸 자동으로 해주고 있었는지, S3에 직접 배포해봐야 체감된다.

output: "export" 설정했더니 빌드가 터진다#

Next.js를 S3에 올리려면 정적 파일로 내보내야 한다. next.config.js에 이 한 줄을 추가하면 된다.

js
// next.config.js
module.exports = {
  output: "export",
};

간단해 보인다. next build를 실행하면 out/ 폴더에 HTML, CSS, JS가 생기고, 그걸 S3에 통째로 올리면 된다. 나도 그렇게 생각했다. 그런데 빌드를 돌리자마자 이런 에러가 떴다.

text
Error: API Routes cannot be used with "output: export".
  Route "/api/og" is using API Routes.

당연하다. S3는 정적 파일 호스팅이니까 서버 사이드 코드를 실행할 수 없다. API route가 존재하면 빌드 자체가 실패한다. 나는 OG 이미지 생성용으로 /api/og 라우트를 하나 두고 있었는데, 이걸 깜빡하고 있었다.

해결법은 두 가지다.

  1. API route를 아예 삭제한다
  2. 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.

js
// next.config.js
module.exports = {
  output: "export",
  trailingSlash: true, // 이거 하나로 해결
};

trailingSlash: true를 넣으면 Next.js가 /blog/my-post/index.html 형태로 파일을 생성한다. S3가 /blog/my-post/ 요청을 받으면 그 폴더 안의 index.html을 찾아서 반환한다.

빌드 결과물을 비교해보면 차이가 확실하다.

text
# 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에서 빌드할 경로 목록을 반환해야 한다.

tsx
// 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"을 쓰면 이런 에러가 뜬다.

text
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 설정을 명시적으로 잡아줘야 할 수도 있다.

tsx
// 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가 안 되는 이유#

이건 에러 메시지가 친절해서 금방 해결했다.

text
Error: Image Optimization using the default loader is not
compatible with `output: export`.

Next.js의 <Image> 컴포넌트는 기본적으로 서버에서 이미지를 리사이즈하고 WebP로 변환한다. S3에는 그 서버가 없다. 설정 하나 추가하면 된다.

js
// 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는 모른다. 명시적으로 캐시를 무효화해줘야 한다.

bash
# CloudFront 캐시 무효화
aws cloudfront create-invalidation \
  --distribution-id E1234567890 \
  --paths "/*"

"/*"는 모든 경로의 캐시를 날리겠다는 뜻이다. 특정 파일만 무효화할 수도 있지만, 블로그 배포에서는 어차피 여러 파일이 동시에 바뀌니까 전체 무효화가 편하다. 참고로 CloudFront는 매달 1,000건의 무효화 경로를 무료로 제공하고, "/*"는 1건으로 친다.

배포 스크립트에 넣어두면 까먹을 일이 없다.

bash
#!/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를 쓸 수 없으니까, 빌드 타임에 미리 만들어야 한다.

satorisharp를 쓰면 된다. satori는 JSX를 SVG로 변환하고, sharp는 SVG를 PNG로 변환한다. 사실 @vercel/og도 내부적으로 satori를 쓰고 있어서 결과물이 거의 동일하다.

bash
pnpm add -D satori sharp @types/sharp

빌드 스크립트를 하나 만든다.

tsx
// 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에 스크립트를 추가한다.

json
{
  "scripts": {
    "generate:og": "tsx scripts/generate-og-images.mts",
    "build": "pnpm generate:og && next build"
  }
}

그리고 각 페이지의 metadata에서 생성된 이미지를 참조한다.

tsx
// 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다.

js
// next.config.js
module.exports = {
  output: "export",
  trailingSlash: true,
  images: {
    unoptimized: true,
  },
};

세 줄이다. 이 세 줄에 도달하는 데 이틀이 걸렸다. 각각의 설정이 왜 필요한지 모르면 하나씩 빼보고 싶은 유혹이 생기는데, 빼면 터진다.

S3 배포가 Vercel 대비 확실히 손이 많이 간다. 근데 한 번 세팅해두면 월 호스팅 비용이 거의 0에 수렴하고, AWS 인프라에 이미 익숙하다면 오히려 관리가 편할 수도 있다. 이 블로그도 한 번 세팅한 뒤로는 글 쓰고 푸시하면 끝이니까. 삽질은 처음 한 번이면 충분하다.