정적 사이트에 예약 발행 붙이기

이 블로그는 정적 사이트다. 빌드해서 HTML 을 올려두는 방식이라 요청이 올 때 도는 서버가 없다.

그런데 예약 발행이 필요했다. 글 여러 편을 미리 써두고 정해진 시각에 하나씩 나가게 하고 싶었다.

만들 것

1. 빌드 시각보다 미래인 글은 페이지를 안 만든다
2. 발행하고 싶은 시각에 빌드를 돌린다

그게 전부다. 예약이라는 상태를 따로 저장하지 않는다. 글의 발행 시각과 빌드 시각을 비교하는 것뿐이다.

Astro 기준으로 쓰지만 발상은 어느 정적 사이트 생성기에나 같다.

전제

  • 콘텐츠에 발행 시각 필드가 있어야 한다. 여기서는 pubDate
  • 자동으로 빌드를 돌릴 수단이 있어야 한다. 여기서는 GitHub Actions
  • 배포가 명령 한 줄로 끝나야 한다. 여기서는 wrangler deploy

1단계: 발행 필터

목록과 상세 페이지가 같은 기준을 써야 하니 함수 하나로 뺐다.

// src/lib/posts.ts
import { getCollection } from "astro:content";

export async function publishedPosts() {
  const now = Date.now();
  const posts = await getCollection(
    "blog",
    ({ data }) => !data.draft && data.pubDate.getTime() <= now,
  );
  return posts.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
}

Date.now()빌드가 도는 시점의 시각이다. 이게 이 구조의 전부다.

목록에서 쓴다.

const posts = await publishedPosts();

상세 페이지에도 같은 함수를 넣는다. 여기가 중요하다.

export async function getStaticPaths() {
  const posts = await publishedPosts();
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}

목록에서만 거르면 글이 목록에는 안 보이는데 주소를 직접 치면 열린다. getStaticPaths 에서 걸러야 페이지 자체가 안 만들어진다.

확인

미래 날짜 글을 하나 넣고 빌드해본다.

printf -- '---\ntitle: gate-test\npubDate: 2099-01-01\n---\n\nx\n' > src/content/blog/__gate-test.md
npx astro build
ls dist/blog/

__gate-test 디렉터리가 없으면 통과다. 확인했으면 지운다.

여기서 디렉터리가 생기면 상세 페이지가 다른 필터를 쓰고 있다. getStaticPaths 부터 다시 본다.

2단계: 정해진 시각에 빌드

GitHub Actions 로 돌린다.

# .github/workflows/publish.yml
name: publish

on:
  schedule:
    - cron: "0 0,4,8,12 * * *"
  workflow_dispatch:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run build
      - run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

workflow_dispatch 를 넣어두면 Actions 탭에서 수동으로 돌릴 수 있다. 처음 붙일 때 이게 없으면 확인 한 번 하려고 매번 커밋을 해야 한다.

cron 은 UTC다

여기서 한 번 틀렸다. KST 09/13/17/21 에 돌리고 싶으면 UTC 로 00/04/08/12 다. 9를 더하는 게 아니라 빼는 쪽이다.

KST 09:00 = UTC 00:00
KST 21:00 = UTC 12:00

확인

수동으로 한 번 돌린다.

gh workflow run publish.yml
gh run watch $(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')

여기서 시크릿 관련으로 죽으면 등록된 이름부터 확인한다. gh secret list 로 이름만 볼 수 있다. 없는 시크릿을 참조하면 오류 없이 빈 문자열이 들어간다.

3단계: 발행됐는지 확인

배포 직후 한 번 찍고 판단하면 안 된다.

for i in 1 2 3 4 5 6; do
  curl -sL -o /dev/null -w "%{http_code} " "https://내도메인/blog/글주소?cb=$i"
done

CDN 은 엣지가 여러 곳에 흩어져 있고 배포한 버전이 동시에 반영되지 않는다. 전파 중에는 같은 주소가 200 과 404 를 오간다. 여러 번 찍어서 전부 같은 답이 나올 때까지 판단을 미룬다.

쿼리 문자열은 캐시 우회용이다.

실제 동작

이 블로그에서 확인한 결과다. 17:29 에 워크플로가 돌았을 때다.

동기화 완료: 11편          원본은 11편
9 page(s) built            그중 발행 시각 지난 것만 페이지 생성
Deployed

라이브에서 찍으면 이렇게 나온다.

발행된 글   200
미래 글     404

수동 배포했을 때 2편이던 목록이 워크플로가 돈 뒤 3편이 됐다. 17:00 글이 시각을 넘겨 추가됐다.

안 되면

미래 글이 목록에 없는데 주소로 열린다. 상세 페이지의 getStaticPaths 가 필터를 안 쓰고 있다.

미래 글이 sitemap 에 남는다. sitemap 은 대개 생성된 페이지에서 만들어진다. 위를 고치면 같이 해결된다. 안 고쳐지면 sitemap 설정이 콘텐츠 목록을 따로 읽고 있는지 본다.

정한 시각에 안 뜬다. GitHub cron 은 정시 보장이 아니다. 이 블로그에서 실측한 실행 시각은 00:32, 04:23, 08:14, 12:14 였다. 최대 32분 늦었다. 분 단위가 중요하면 이 방식은 안 맞는다.

빌드는 성공했는데 사이트가 그대로다. 배포 단계가 실제로 돌았는지 로그를 본다. 돌았으면 엣지 전파 중이다.

이 방식의 한계

빌드가 안 돌면 발행이 안 된다. 시각이 지나도 자동으로 뜨지 않는다. 빌드가 발행이다.

과거 글을 내리는 건 별도다. 이 필터는 미래만 거른다. 내리려면 draft 같은 필드가 따로 필요하다.

빌드 시각이 곧 기준 시각이다. 09:00 cron 이 09:14 에 돌면 09:10 발행 글도 같이 나간다. 분 단위 제어는 안 된다.

이걸 감당할 수 있으면 서버 없이 예약 발행이 된다. 감당이 안 되면 요청 시점에 판단하는 구조가 필요하다. 그건 정적 사이트가 아니게 된다.

← 블로그 목록