글을 쓰고 push하는 것 외에는 손이 갈 일이 없도록, frontmatter 검증부터 빌드, 배포, 알림까지를 GitHub Actions로 자동화합니다.
호스팅 플랫폼 선택
원래는 Vercel로 호스팅하고 있었습니다. 블로그 게시한지 1년이 넘었지만 이걸 이제하는 이유. 설정이 간편하고 무료 티어로 충분했는데, 블로그가 성장하면서 Hobby 플랜의 bandwidth 한도(100GB)에 슬슬 도달하기 시작했습니다. 초과 시 종량 과금($55/TB)이 발생하는 구조라, 장기적으로 불안했습니다.
GitHub Pages, Cloudflare Pages, Vercel을 비교 검토했습니다. (추후 달라질 수 있음에 유의)
| GitHub Pages | Cloudflare Pages (Free) | Vercel (Hobby) | |
|---|---|---|---|
| Bandwidth | 100GB soft | 무제한 | 100GB |
| 빌드 | 10회/h | 500회/mo | 6,000분/mo |
| 파일 수 | 제한 없음 | 20,000 | 제한 없음 |
| 초과 시 | 경고 후 서비스 제한 | 없음 (빌드만 제한) | 서비스 일시정지 |
각 플랫폼의 리스크를 정리하면 이렇습니다.
- Vercel - Hobby 플랜은 bandwidth 100GB 초과 시 서비스가 일시정지됩니다. Pro로 업그레이드하면 종량 과금으로 전환되지만, Hobby 단계에서는 추가 구매 옵션 없이 사이클 리셋까지 기다려야 합니다.
- GitHub Pages - 100GB는 soft limit으로, 초과 시 즉시 중단되지는 않지만 GitHub으로부터 경고를 받고 이후 서비스가 제한될 수 있습니다. 유료 과금 옵션이 없으므로 대응 수단이 제한적입니다.
- Cloudflare Pages - bandwidth가 무제한입니다. 대신 파일 수(20,000개)와 빌드 횟수(500회/월)에 제한이 있습니다. 파일 수는 현재 충분하고, 빌드 500회는 하루 약 16회로 넉넉합니다.
Cloudflare Pages가 매력적으로 다가온 핵심 이유는, 제한이 빌드 단계에서 발생한다는 점입니다. 빌드가 제한되면 새 배포를 못 할 뿐이지, 이미 배포된 사이트는 정상적으로 서빙됩니다. 반면 Vercel과 GitHub Pages는 서빙 단계에서 리스크가 발생합니다 - 서비스 다운이나 예상치 못한 과금은 당연히 더 큰 위험입니다.
워크플로우 개요
배포 흐름은 이렇습니다. 트리거 브랜치에 push하면 GitHub Actions가 자동으로 Quartz를 빌드하고, 결과물을 Cloudflare Pages에 배포합니다. 빌드 전에 frontmatter를 검증하고, 배포 후에는 결과를 Telegram으로 알립니다.
워크플로우 상세
jobs:
test: # frontmatter 검증
deploy: # 빌드 + Cloudflare Pages 배포
notify: # Telegram 알림test- 모든 마크다운 파일의 frontmatter에서 필수 필드가 존재하는지 검증합니다. 실패하면 이후 단계가 실행되지 않습니다.deploy-test가 성공한 경우에만 실행됩니다. Quartz 빌드 후 Cloudflare Pages에 배포합니다.notify- 성공/실패 무관하게 항상 실행됩니다. test와 deploy 결과를 조합하여 Telegram으로 알림을 보냅니다.
왜 Cloudflare Pages의 GitHub 연동 쓰지 않는 이유
Cloudflare Pages는 GitHub 레포를 직접 연결해 push마다 자동으로 빌드, 배포해주는 기능을 제공합니다. 편하지만, 이 방식은 배포 앞에 검증 단계를 끼워 넣을 수가 없습니다 - push하면 곧장 빌드해서 그대로 띄웁니다. 그래서 GitHub 연동 대신 워크플로우 안에서
wranglerCLI로 직접 배포합니다. deploy job이needs: test로 묶여 있어, frontmatter 검증(test)을 통과한 경우에만 배포가 실행됩니다. 깨진 글이 프로덕션에 올라가는 걸 막기 위한 선택입니다.
Self-hosted Runner
저는 빌드 효율을 위해 제 개인 서버의 Self-hosted Runner를 사용했습니다. 해당 환경은 nvm이 이미 설치되어 있으므로 별도의 Node.js 설치 액션은 생략했습니다. 레포 자체도 캐시되어 보통 액션 게시 후 2분 내로 모든 절차가 완료됩니다.
Frontmatter 검증 (test job)
블로그 글마다 title, published, description 필드가 frontmatter에 반드시 있어야 합니다. 이 필드들이 빠지면 빌드는 되더라도 목록 페이지에서 제목이나 날짜가 누락되는 문제가 생깁니다. 특히 제가 글을 Draft 단계에서 가볍게 관리하는 경향이 있어 누락하는 경우가 빈번했습니다. 이런건 빌드 전에 잡는 편이 낫습니다.
검증 로직은 셸 스크립트로 구현했습니다. find로 .md 파일을 순회하고, awk로 frontmatter 블록을 추출한 뒤, grep으로 필수 필드의 존재 여부를 확인합니다.
- name: Check frontmatter fields
run: |
IFS=',' read -ra FIELDS <<< "$REQUIRED_FIELDS"
errors=0
while IFS= read -r -d '' file; do
frontmatter=$(awk '/^---$/{if(++c==2)exit}c' "$file")
if [ -z "$frontmatter" ]; then
echo "::error file=$file::No frontmatter found"
errors=$((errors + 1))
continue
fi
for field in "${FIELDS[@]}"; do
field=$(echo "$field" | xargs)
if ! echo "$frontmatter" | grep -qE "^${field}:"; then
echo "::error file=$file::Missing required field: ${field}"
errors=$((errors + 1))
fi
done
done < <(find "$CONTENT_DIR" -name '*.md' -print0)awk '/^---$/{if(++c==2)exit}c'를 통해 첫 번째---와 두 번째---사이의 내용을 추출합니다. 카운터c가 2에 도달하면 즉시 종료하므로, 본문에---가 있어도 영향받지 않습니다.::error file=...::형식은 GitHub Actions의 어노테이션 문법입니다. 이렇게 출력하면 Actions UI에서 해당 파일의 에러가 인라인으로 표시되어, 어떤 파일에서 어떤 필드가 빠졌는지 한눈에 확인할 수 있습니다.
빌드와 배포 (deploy job)
test가 통과하면 deploy job이 실행됩니다. 빌드 환경 설정, Quartz 빌드, 불필요 파일 정리, Cloudflare 배포 순서로 진행됩니다.
- name: Build
run: npx quartz build -d ${{ env.CONTENT_DIR }}
- name: Clean build-only and unnecessary files
run: |
rm -f public/__RES/icons/*.zip
rm -rf public/__RES/scripts
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=${{ env.CF_PROJECT_NAME }}빌드 후 정리 단계가 있는 이유는 public/ 디렉터리에 배포할 필요 없는 파일들이 포함되기 때문입니다. 대표적으로 인라인 아이콘 기능의 __RES/icons/*.zip은 빌드 타임에 SVG를 추출하기 위한 아카이브로, 빌드가 끝나면 더 이상 필요 없습니다. __RES/scripts/도 Obsidian에서 사용하는 빌드 보조 스크립트 디렉터리이므로 배포 번들에서 제외합니다. 이렇게 정리하면 Cloudflare Pages에 업로드되는 파일 수와 용량이 줄어듭니다. 사실 zip 파일이 25MB가 초과되어 업로드 자체가 안 됩니다.
Telegram 알림 (notify job)
notify job은 always() 조건으로 실행되므로, test나 deploy가 실패하더라도 반드시 알림을 보냅니다. 배포가 성공했든 실패했든 즉시 알 수 있어야 대응이 빠릅니다.
상태 결정 로직은 test와 deploy 결과를 순서대로 확인하여 가장 적절한 메시지를 선택합니다.
- name: Determine status
id: status
run: |
if [ "${{ needs.test.result }}" = "failure" ]; then
echo "message=테스트 실패" >> $GITHUB_OUTPUT
elif [ "${{ needs.deploy.result }}" = "failure" ]; then
echo "message=배포 실패" >> $GITHUB_OUTPUT
elif [ "${{ needs.deploy.result }}" = "success" ]; then
echo "message=배포 성공" >> $GITHUB_OUTPUT
fi
- name: Send Telegram notification
run: |
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d parse_mode=Markdown \
-d "text=*${TITLE}* ${{ steps.status.outputs.message }}
Branch: \`${{ github.ref_name }}\`
Commit: \`${SHORT_SHA}\`
[View Run](run_url)"직접 api 요청하는 이유
Telegram 알림용 GitHub Action(
appleboy/telegram-action등)이 이미 존재하지만, 이들은 대부분 Linux 전용입니다. 셀프 호스트 러너가 macOS이므로 호환되지 않아,curl로 Telegram Bot API를 직접 호출하는 방식을 택했습니다.