배경

이 블로그는 두 개의 레포지토리로 운영된다.

  • Laniel88/blog - Quartz v4 포크. 커스텀 컴포넌트, 플러그인, 스타일 등 프레임워크를 관리한다.
  • Laniel88/techblog - 마크다운 컨텐츠 레포. blog를 upstream으로 설정하여 프레임워크 변경을 pull로 가져온다.

프레임워크와 컨텐츠를 분리한 가장 큰 이유는 하나의 Quartz 시스템을 여러 블로그에서 공유하기 위해서다. 현재 이 기술 블로그 외에 별도의 일반 글쓰기 블로그를 준비하고 있는데, 주제와 독자층은 다르지만 블로그 엔진은 동일한 커스텀 Quartz를 사용하고 싶었다. 프레임워크를 독립 레포로 분리해두면, 각 컨텐츠 레포가 이를 upstream으로 설정하여 동일한 빌드 시스템, 플러그인, 스타일을 상속받을 수 있다.

graph TD
    A["Laniel88/blog<br>(프레임워크)"] -->|upstream| B["Laniel88/techblog<br>(기술 블로그)"]
    A -->|upstream| C["컨텐츠 레포 N<br>(일반 블로그 등)"]
    B -->|"push → build → deploy"| D["techblog.lanielpark.com"]
    C -->|"push → build → deploy"| E["*.lanielpark.com"]

각 컨텐츠 레포는 프레임워크 레포의 커스텀 컴포넌트, 플러그인, CI/CD 워크플로우를 git pull upstream으로 가져온다. 프레임워크를 업데이트하면 모든 블로그에 일괄 반영되는 구조다.

호스팅 플랫폼 선택

원래는 Vercel로 호스팅하고 있었다. 설정이 간편하고 무료 티어로 충분했는데, 블로그가 성장하면서 Hobby 플랜의 bandwidth 한도(100GB)에 슬슬 도달하기 시작했다. 초과 시 종량 과금($55/TB)이 발생하는 구조라, 장기적으로 불안했다.

GitHub Pages, Cloudflare Pages, Vercel을 비교 검토했다.

GitHub PagesCloudflare Pages (Free)Vercel (Hobby)
Bandwidth100GB soft무제한100GB
빌드10회/h500회/mo6,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에 배포한다. 워크플로우 YAML 자체는 프레임워크 레포에 정의되어 있으므로, 컨텐츠 레포가 upstream을 pull하면 CI/CD 설정도 함께 상속된다.

다만, 프레임워크 레포 자체에서는 배포가 실행되면 안 된다. 이를 위해 워크플로우 상단에 조건을 건다.

.github/workflows/test-deploy-blog.yaml
if: github.repository != 'Laniel88/blog'

이렇게 하면 프레임워크 레포에서 push가 발생해도 워크플로우가 스킵되고, 컨텐츠 레포에서만 실행된다.

워크플로우 상세

전체 파이프라인은 3개의 job으로 구성된다. 순서대로 test, deploy, notify이며, 각각 의존 관계를 갖는다.

.github/workflows/test-deploy-blog.yaml
jobs:
  test:     # frontmatter 검증
  deploy:   # 빌드 + Cloudflare Pages 배포
  notify:   # Telegram 알림
  • test - 모든 마크다운 파일의 frontmatter에서 필수 필드가 존재하는지 검증한다. 실패하면 이후 단계가 실행되지 않는다.
  • deploy - test가 성공한 경우에만 실행된다. Quartz 빌드 후 Cloudflare Pages에 배포한다.
  • notify - 성공/실패 무관하게 항상 실행된다. test와 deploy 결과를 조합하여 Telegram으로 알림을 보낸다.

Frontmatter 검증 (test job)

블로그 글마다 title, published, description 필드가 frontmatter에 반드시 있어야 한다. 이 필드들이 빠지면 빌드는 되더라도 목록 페이지에서 제목이나 날짜가 누락되는 문제가 생긴다. 빌드 전에 잡는 편이 낫다.

검증 로직은 셸 스크립트로 구현했다. find.md 파일을 순회하고, awk로 frontmatter 블록을 추출한 뒤, grep으로 필수 필드의 존재 여부를 확인한다.

.github/workflows/test-deploy-blog.yaml
- 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 배포 순서로 진행된다.

Node.js 버전은 .nvmrc 파일을 기반으로 nvm이 관리한다. 셀프 호스트 러너에 nvm이 이미 설치되어 있으므로 별도의 Node.js 설치 액션이 필요 없다.

.github/workflows/test-deploy-blog.yaml
- 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/도 빌드 보조 스크립트 디렉터리이므로 배포 번들에서 제외한다. 이렇게 정리하면 Cloudflare Pages에 업로드되는 파일 수와 용량이 줄어든다.

CF_PROJECT_NAME은 환경변수로 정의되어 있다. 프레임워크 레포에서는 기본값을 설정하고, 컨텐츠 레포에서 자신의 Cloudflare Pages 프로젝트명으로 오버라이드하는 구조다.

Telegram 알림 (notify job)

notify job은 always() 조건으로 실행되므로, test나 deploy가 실패하더라도 반드시 알림을 보낸다. 배포가 성공했든 실패했든 즉시 알 수 있어야 대응이 빠르다.

상태 결정 로직은 test와 deploy 결과를 순서대로 확인하여 가장 적절한 메시지를 선택한다.

.github/workflows/test-deploy-blog.yaml
- 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)"

Telegram 알림용 GitHub Action(appleboy/telegram-action 등)이 이미 존재하지만, 이들은 대부분 Linux 전용이다. 셀프 호스트 러너가 macOS이므로 호환되지 않아, curl로 Telegram Bot API를 직접 호출하는 방식을 택했다. sendMessage 엔드포인트에 마크다운 형식으로 메시지를 전송한다. 메시지에는 브랜치명, 커밋 해시(축약), Actions 실행 링크가 포함되어 있어서, 알림을 받으면 바로 상황을 파악하고 필요시 로그를 확인할 수 있다.

TELEGRAM_BOT_TOKENTELEGRAM_CHAT_ID는 레포지토리 시크릿으로 관리한다.

Self-hosted Runner

이 워크플로우는 GitHub이 제공하는 호스트 러너가 아닌 셀프 호스트 러너에서 실행된다.

.github/workflows/test-deploy-blog.yaml
runs-on: self-hosted-mac

셀프 호스트 러너를 사용하는 이유는 세 가지다.

  • 비용 - GitHub Actions의 무료 티어에는 월간 사용 시간 제한이 있다. 블로그처럼 빈번하게 push하는 프로젝트에서는 셀프 호스트 러너로 이 제한을 우회할 수 있다.
  • checkout 속도 - 레포지토리가 러너에 캐시되어 있어 actions/checkout이 수초 내로 완료된다. GitHub 호스트 러너는 매번 새 환경을 프로비저닝하므로 이 이점이 없다.
  • 환경 고정 - nvm, Node.js, npm이 미리 설치되어 있으므로 actions/setup-node 같은 설정 스텝이 불필요하고, 빌드 재현성도 높다.

마무리

프레임워크-컨텐츠 분리 구조에서 워크플로우를 프레임워크 레포에 정의해두면, 컨텐츠 레포가 upstream을 pull할 때 CI/CD 설정도 함께 따라온다. 만약 같은 프레임워크를 사용하는 컨텐츠 레포가 여러 개라면, 모두 동일한 파이프라인을 상속받게 되는 셈이다.

파이프라인의 각 단계는 역할이 명확하다. frontmatter 검증으로 빌드 전에 구조적 문제를 잡고, Quartz 빌드 후 불필요 파일을 정리하여 배포 번들을 최적화하며, Telegram 알림으로 배포 상태를 즉시 확인한다. 세 단계 모두 단순하지만, 자동화되어 있기 때문에 글을 push하는 것 외에 신경 쓸 것이 없다.