DailBit의 서비스들은 모두 서버 없이 정적 파일로 배포된다. Next.js로 만든 서비스 13개는 output: "export"로 HTML을 미리 만들고, Vite로 만든 서비스 5개는 사전 렌더링한 dist/를, 나머지 하나는 순수 HTML을 Cloudflare Pages에 올린다(2026-10-23 기준, 저장소의 package.json으로 구분했고 19개 서비스의 *.dailbit.com 주소가 모두 HTTP 200과 server: cloudflare 응답 헤더를 확인했다. 이 블로그인 dailbit.com도 Next.js static export다). 이 글은 그중 Next.js 서비스의 배포 설정을 정리한 것이다. 포모동과 인타바의 실제 설정 파일을 기준으로 설명한다.

왜 정적 배포인가

포모동(집중 타이머), 인타바(운동 타이머), 빙글(빙고판) 같은 서비스는 모든 기능이 브라우저 안에서 끝난다. 로그인도, 데이터베이스도 없다. 서버가 하는 일은 HTML·CSS·JS 파일을 전달하는 것뿐이다. 이런 서비스에 서버 런타임을 두면 비용과 관리할 것만 늘어난다.

정적 배포의 장점은 분명하다.

  • 서버 관리가 없다. 장애가 날 서버 프로세스 자체가 없다.
  • CDN에서 바로 응답한다. 파일이 전 세계 엣지에 캐시되어 첫 응답이 빠르다.
  • 검색에 유리하다. 빌드 시점에 HTML이 완성되어 있어 검색 엔진이 처음 받는 응답에 본문과 메타데이터가 들어 있다.

next.config: 세 줄이면 된다

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",           // next build 결과를 out/ 폴더의 정적 파일로
  trailingSlash: true,        // /about → /about/index.html 로 생성
  images: { unoptimized: true }, // 런타임 이미지 최적화 서버가 없으므로 끈다
};

export default nextConfig;

output: "export" 를 설정하면 next build가 out/ 폴더에 정적 파일을 만든다. 대신 요청 시점에 서버에서 실행되어야 하는 기능(쿠키나 헤더를 읽는 서버 컴포넌트, 서버 액션, 미들웨어, ISR 재검증 등)은 쓸 수 없다. 동적 경로는 generateStaticParams로 모든 값을 빌드 시점에 알려 줘야 한다.

trailingSlash: true 는 경로마다 폴더와 index.html을 만든다. /about이 about.html이 아니라 about/index.html이 된다. 정적 호스팅에서 폴더 + index.html 구조는 가장 호환성이 좋고, 캐노니컬 URL을 /about/처럼 슬래시로 끝나게 통일하기도 쉽다. 이 설정을 쓴다면 사이트맵, 캐노니컬, 내부 링크도 모두 슬래시로 끝나게 맞춰야 한다. 하나라도 섞이면 같은 페이지가 두 주소로 보일 수 있다.

Cloudflare Pages가 슬래시 없는 주소를 어떻게 처리하는지는 운영 중인 이 사이트에 curl로 직접 확인했다(2026-10-23 10:25 KST).

요청 응답
https://dailbit.com/about HTTP 308, Location: /about/
https://dailbit.com/about/ HTTP 200
https://dailbit.com/does-not-exist/ HTTP 404

슬래시 없는 주소는 슬래시가 있는 주소로 영구 이동하고, 없는 주소는 404를 돌려준다. 그래도 사이트맵, 캐노니컬, 내부 링크를 슬래시로 통일해 두는 편이 리다이렉트 한 번을 줄여 준다.

images: { unoptimized: true } 는 필수다. next/image의 기본 동작은 요청 시점에 이미지 크기를 바꿔 주는 서버를 필요로 한다. static export에서는 그 서버가 없으므로 꺼야 빌드가 된다. 대신 이미지는 원본 그대로 전달되므로, 미리 적당한 크기와 포맷으로 만들어 두는 것이 좋다.

배포 명령: wrangler pages deploy

포모동의 package.json에는 배포 스크립트가 이렇게 들어 있다.

{
  "scripts": {
    "build": "next build",
    "pages:build": "node scripts/generate-ads-txt.mjs && next build",
    "deploy": "pnpm pages:build && wrangler pages deploy out --project-name=pomodong"
  }
}

wrangler pages deploy out은 로컬에서 만든 out/ 폴더를 Cloudflare Pages 프로젝트에 그대로 올린다. Git 저장소를 연결해 Cloudflare가 빌드하게 하는 방법도 있지만, 직접 업로드 방식은 로컬에서 확인한 결과물이 그대로 올라간다는 점이 편했다. wrangler.toml은 최소한으로 둔다.

name = "pomodong"
compatibility_date = "2024-12-30"
# Cloudflare Pages (static export)
# Build output: out/

환경 변수는 빌드 시점에 굳는다

애드센스 게시자 ID처럼 브라우저에서 필요한 값은 NEXT_PUBLIC_ 접두사를 붙인 환경 변수로 넣는다. 여기서 자주 하는 실수가 있다. NEXT_PUBLIC_ 변수는 빌드할 때 자바스크립트 코드에 문자열로 박힌다. 정적 배포에는 런타임이 없으니, 배포한 뒤에 Cloudflare 대시보드에서 환경 변수를 바꿔도 이미 만들어진 파일에는 반영되지 않는다.

직접 업로드 방식이라면 빌드가 로컬에서 일어나므로, 로컬의 .env.local 값이 들어간다. Cloudflare 쪽 빌드를 쓴다면 Pages 프로젝트 설정의 환경 변수가 들어간다. 어느 쪽에서 빌드하는지 헷갈리면 "대시보드에 넣었는데 광고가 안 나온다" 같은 문제가 생긴다.

dailbit.com의 광고 컴포넌트는 이 값이 비어 있으면 아예 렌더링하지 않는다. 값이 없는 개발 환경에서 빈 광고 영역이 생기지 않게 하기 위해서다.

export default function AdSlot({ slot }: { slot?: string }) {
  const client = process.env.NEXT_PUBLIC_ADSENSE_CLIENT;
  if (!client || !slot) return null;
  return <ins className="adsbygoogle" data-ad-client={client} data-ad-slot={slot} /* ... */ />;
}

ads.txt를 빌드할 때 만들기

애드센스를 쓰는 사이트는 루트에 ads.txt가 있어야 한다. 서비스가 여러 개라 파일을 손으로 관리하면 실수하기 쉬워서, 빌드 전에 환경 변수로 생성하는 스크립트를 만들었다.

// scripts/generate-ads-txt.mjs
const client = (process.env.NEXT_PUBLIC_ADSENSE_CLIENT || "").trim();
const out = path.join(root, "public", "ads.txt");

if (!client) {
  // 값이 없으면 안내 주석만 남긴다 (실수로 잘못된 줄을 배포하지 않도록)
  fs.writeFileSync(out, `# Set NEXT_PUBLIC_ADSENSE_CLIENT and rebuild, or paste the AdSense ads.txt line.
# google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0
`);
  process.exit(0);
}

const pub = client.startsWith("ca-") ? client.slice(3) : client; // ca-pub-… → pub-…
fs.writeFileSync(out, `google.com, ${pub}, DIRECT, f08c47fec0942fa0\n`);

애드센스의 클라이언트 ID는 ca-pub-1234… 형식이지만, ads.txt에는 pub-1234… 형식으로 써야 한다. 이 변환을 스크립트가 처리한다. public/ 폴더에 쓴 파일은 Next.js가 빌드할 때 out/ 루트로 그대로 복사되므로 https://도메인/ads.txt로 제공된다. 배포한 뒤에는 꼭 브라우저나 curl로 이 주소를 열어 실제 내용을 확인하자. 애드센스 심사에서 가장 먼저 보는 파일 중 하나다.

_headers로 캐시 정책 정하기

Cloudflare Pages는 출력 폴더 루트의 _headers 파일을 읽어 응답 헤더를 붙인다. public/_headers에 두면 빌드 결과에 함께 복사된다.

/_next/static/*
  Cache-Control: public,max-age=31536000,immutable

/og-image.jpg
  Content-Type: image/jpeg
  Cache-Control: public, max-age=3600
  Access-Control-Allow-Origin: *

/favicon.png
  Cache-Control: public, max-age=86400

/_next/static/ 아래 파일은 이름에 내용 해시가 들어 있다. 내용이 바뀌면 이름도 바뀌므로 1년 동안 캐시하고 immutable로 표시해도 안전하다. 브라우저는 재방문 때 이 파일들을 다시 확인조차 하지 않는다. 반대로 HTML은 해시가 없으므로 오래 캐시하면 배포한 새 버전이 늦게 보인다. HTML에는 별도 규칙을 두지 않아 Pages의 기본 동작을 따르게 했다.

공유 미리보기 이미지는 SNS 크롤러가 가져가야 하므로 캐시를 짧게 두고, 다른 출처에서 읽을 수 있도록 CORS 헤더를 열어 두었다.

이 사이트의 빌드 규모

이 글을 쓰고 있는 dailbit.com을 클린 빌드(npm run clean && next build)해서 쟀다. Next.js 16.3.5(Turbopack), Node v22.22.2, macOS 26.6.2 arm64(Apple M5 Pro), 2026-10-23 측정이다.

항목 값
빌드 시간(npm run build, 벽시계) 4.00초
out/ 파일 수 440개 (HTML 51개)
out/ 용량 12.1MiB

글 수십 개 규모의 사이트는 빌드가 몇 초면 끝나기 때문에, 예약 글을 매일 재빌드해서 공개하는 방식도 부담이 없다. Cloudflare 대시보드의 설정 화면은 이 글에 싣지 않았다.

배포 후 확인 목록

정적 배포는 빌드가 성공하면 끝난 것처럼 느껴지지만, 실제 주소에서 확인해야 할 것들이 있다.

  1. https://도메인/ads.txt, /robots.txt, /sitemap.xml이 200으로 열리는지
  2. 페이지 소스에서 <title>, description, canonical, og:url이 그 페이지 주소를 가리키는지
  3. 슬래시 없는 주소(/about)로 들어갔을 때 슬래시 있는 주소로 한 번에 이동하는지
  4. /_next/static/ 파일 응답에 의도한 Cache-Control이 붙어 있는지
  5. 존재하지 않는 주소에서 404.html이 나오는지

특히 2번은 정적 사이트에서 의외로 자주 틀린다. 이 사이트(dailbit.com)도 블로그 목록의 og:url이 홈 주소로 나가고 있었는데, 원인은 Next.js 메타데이터의 상속 방식이었다. 그 이야기는 별도 글에 정리했다.

정리

항목 설정
정적 출력 output: "export" → out/
URL 형식 trailingSlash: true (폴더 + index.html)
이미지 images.unoptimized: true, 원본을 미리 최적화
배포 wrangler pages deploy out --project-name=…
공개 환경 변수 빌드 시점에 고정됨, 빌드하는 곳의 값이 들어감
ads.txt 빌드 전 스크립트로 생성, public/ → 루트
캐시 _headers로 해시 파일만 1년 immutable

서버가 없으니 신경 쓸 것이 줄어드는 대신, 빌드 시점에 모든 것이 결정된다는 사실을 항상 의식해야 한다. 환경 변수, 동적 경로, 메타데이터 모두 마찬가지다.