본문으로 건너뛰기

Next.js SSG 적용기

·10 min read

학습 메모로 흩어져 있던 SSG 노트를 정리했다. 기준은 Next.js Pages Router 시점이다. App Router로 옮겨가면서 generateStaticParams 같은 새 API가 들어왔지만, getStaticProps/getStaticPaths의 동작 원리를 한 번은 정리해두고 싶었다. 빌드 시점에 HTML을 생성한다는 개념 자체는 두 라우터 모두 같기 때문이다.

SSG, HTML을 빌드 시점에 생성하는 방식

SSG(Static Site Generation)는 빌드 시점에 HTML을 미리 만들어두고, 이후 요청마다 그 HTML을 재사용하는 방식이다.

  • HTML은 빌드 시점마다 갱신된다.
  • 생성된 정적 파일은 CDN에 캐시될 수 있다.
  • 빌드 결과물은 브라우저의 JS 번들에 포함되지 않는다.

요청이 올 때마다 서버에서 매번 렌더링하는 SSR과 달리, SSG는 한 번 생성해두면 같은 HTML을 계속 돌려쓴다. 흐름으로 그리면 이렇다.

데이터가 필요 없을 때, 그냥 두면 SSG

Next.js는 별도 설정 없이도 데이터 없이 정적 HTML을 만들어낸다. 페이지 하나당 HTML 한 개가 빌드 산출물로 생긴다. 가장 단순한 페이지는 자동으로 SSG가 된다.

function About() {
  return <div>About</div>
}
 
export default About

위 페이지는 빌드하면 /about 경로의 정적 HTML로 만들어진다. 별다른 함수도, 설정도 없다.

외부 데이터를 렌더링에 써야 하면 getStaticProps

페이지가 외부 API에서 가져온 데이터를 화면에 그려야 한다면 getStaticProps를 쓴다. 비동기 함수로 export 해두면 Next.js가 빌드 중에 한 번 실행해서 반환값(props)을 페이지 컴포넌트에 주입한다.

export default function Blog({ posts }) {
  // 빌드 시점에 받은 posts를 그대로 렌더
}
 
export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()
 
  return {
    props: {
      posts,
    },
  }
}

빌드 결과물은 HTML 하나로 끝나지 않았다. getStaticProps가 붙은 페이지는 HTML 외에 getStaticProps의 반환값을 담은 JSON 파일도 같이 생성된다.

이 JSON이 왜 필요하냐면, next/linknext/router로 클라이언트 사이드 라우팅이 일어날 때 페이지를 다시 통째로 받지 않고 이 JSON만 가져와 컴포넌트의 props로 하이드레이션하기 때문이다. SPA처럼 페이지 전환을 해도 getStaticProps가 매번 호출되는 게 아니라, 빌드 시점에 이미 계산된 JSON을 꺼내 쓰는 구조다.

빌드 산출물 기준으로는 .next/server/pages 같은 경로 아래에 페이지별 JSON이 생긴다.

경로 자체가 동적이면 getStaticPaths를 같이

/blog/[slug] 처럼 경로가 동적으로 생성되어야 한다면 getStaticProps 하나로는 부족하다. 빌드 시점에 어떤 slug들을 미리 생성할지 알려줘야 하기 때문이다. 이 역할이 getStaticPaths다. 보통 둘은 같이 쓴다.

getStaticPaths는 경로 목록을 반환하고, getStaticProps는 각 경로별 데이터를 반환한다. 그리고 getStaticPaths의 반환값 안에 있는 fallback 옵션이 빌드 시점에 미리 생성하지 못한 경로를 어떻게 처리할지 결정한다.

fallback 옵션 세 가지

fallback 값동작
false빌드 시점에 정의된 경로만 유효. 나머지는 404
true정의되지 않은 경로 요청 시 백그라운드에서 getStaticProps 실행, fallback UI 노출
'blocking'정의되지 않은 경로 요청 시 getStaticProps가 끝날 때까지 응답을 막고 HTML이 만들어지면 전달

분기를 그림으로 옮기면 이렇다.

fallback: true/'blocking'이 필요한 경우는 두 가지다. 하나는 모든 경로를 빌드 타임에 다 알 수 없을 때. 다른 하나는 페이지 수가 너무 많아 빌드 시간이 과도하게 늘어날 때다. 인기 있는 일부 경로만 미리 생성하고, 나머지는 사용자 접근 시점에 생성하도록 미룬다.

반대로 getStaticPaths에 특정 경로만 넣고 fallback: false로 두면, 나열되지 않은 경로는 그대로 404가 난다.

revalidate는 백그라운드 갱신

getStaticProps의 반환값에 revalidate 옵션을 넣으면, 지정한 초가 지난 뒤 다음 요청이 들어왔을 때 getStaticProps를 백그라운드에서 다시 실행해 HTML을 새로 만들어둔다. ISR(Incremental Static Regeneration) 동작이다. SSG와 SSR 사이의 중간 지점에 가깝다.

next.config로 정적 export 켜기

빌드 결과물을 정적 호스팅 전용으로 뽑고 싶으면 next.config.jsoutput: 'export'를 켠다. out 폴더에 정적 HTML/CSS/JS가 생성된다.

module.exports = {
  output: 'export',
  trailingSlash: true,
 
  async rewrites() {
    return []
  },
 
  images: {
    unoptimized: true,
  },
}

각 옵션의 의미를 빠르게 짚으면 이렇다.

  • output: 'export' — 빌드 시 out/ 폴더에 정적 산출물 생성.
  • trailingSlash: true/me 링크를 /me/로 출력하고 /me.html/me/index.html로 내보낸다. 정적 호스팅에서 디렉토리 인덱싱과 맞물려 쓰기 편하다.
  • async rewrites() — 정적 export 모드에서는 API Routes를 쓸 수 없다. rewrites도 동적 동작이라 정적 모드와 맞지 않아 빈 배열로 둔다.
  • images.unoptimized: true — Next.js의 이미지 최적화는 런타임 서버가 필요하다. 정적 export에는 서버가 없으니 최적화를 끄고 원본 그대로 내보낸다.

빌드 산출물을 로컬에서 확인할 때는 serve

정적 export로 만든 out/을 로컬에서 확인하려면 HTTP 서버 하나가 필요하다. Vercel이 만든 serve 패키지를 글로벌이나 로컬로 설치하면 끝난다.

npm install -g serve
 
serve            # 현재 디렉토리 서빙
serve ./out      # 특정 폴더 서빙
serve -p 3000    # 포트 지정

스크립트로 묶어두면 pnpm start:ssg 하나로 빌드 + 서빙까지 한 번에 돌릴 수 있다.

{
  "dependencies": {
    "serve": "^14.2.0"
  },
  "scripts": {
    "build:ssg": "next build && next export",
    "start:ssg": "pnpm build:ssg && npx serve out"
  }
}

짚어둘 한 가지, ssr:false는 빌드 산출물에 안 들어간다

마지막으로 한 가지 더. SSG는 빌드 시점에 HTML을 만들어내기 때문에, dynamic(() => ..., { ssr: false }) 같은 식으로 서버 렌더를 꺼둔 컴포넌트는 정적 HTML에 포함되지 않는다. 빌드 산출물의 HTML에는 자리만 비어 있고, 실제 내용은 클라이언트 하이드레이션 이후에 채워진다.

윈도우 객체에 의존하거나 클라이언트 전용 라이브러리를 쓰는 컴포넌트를 ssr: false로 감싸는 패턴은 자주 쓰는데, SSG와 만나면 "이 컨텐츠는 초기 HTML에는 없다"는 점을 기억해둬야 한다. SEO나 첫 페인트가 중요한 영역이라면 이 패턴을 그대로 두면 안 된다는 뜻이다.

다음에 App Router 기준으로 같은 내용을 정리할 때는 generateStaticParamsdynamicParams, revalidate 의 위치가 어떻게 바뀌었는지부터 보면 좋겠다.