본문으로 바로가기
mark-lab

Notion → GitHub 자동 배포 파이프라인 구축기

1. 배경

블로그에 글을 올리려면 매번 마크다운을 직접 작성하고, 이미지를 저장하고, 커밋·푸시를 해야 했습니다. 이미 노션에서 글을 쓰고 있었기 때문에 이 과정이 고스란히 중복이었습니다.

목표는 간단했습니다.

노션 페이지의 StatusPublished로 바꾸는 것만으로 블로그에 자동 배포된다.


2. 전체 파이프라인 흐름

두 개의 워크플로우가 순차적으로 실행됩니다. sync-notion.yml이 마크다운을 커밋하면, main 브랜치 push를 감지한 cd.yaml이 이어서 배포를 수행합니다.


3. 사전 준비

3.1 Notion Integration 생성

  1. https://www.notion.so/my-integrations 접속
  2. New Integration 클릭
  3. 이름 입력 후 생성 → Internal Integration Secret 복사
  4. 글을 관리할 데이터베이스를 열고 우측 상단 ···Connect to → 방금 만든 Integration 연결

3.2 Notion 데이터베이스 속성 구성

속성 이름타입역할
titletitle포스트 제목
statusstatusdraft / published / synced
postmulti_select카테고리 (폴더명으로 사용, 예: dev, book)
날짜date발행일
descriptionrich_text포스트 요약 (SEO 및 목록 노출)
Slugrich_textURL 슬러그 (비워두면 제목에서 자동 생성)

status 속성을 Notion 기본 Status 타입으로 만들고, 옵션에 draft, published, synced를 추가합니다.

3.3 데이터베이스 ID 확인

데이터베이스를 브라우저에서 열면 URL이 다음과 같습니다.

https://www.notion.so/{workspace}/{database_id}?v=...

database_id 부분(32자리 hex)을 복사합니다.

3.4 GitHub Secrets 등록

리포지토리 Settings → Secrets and variables → Actions 에서 아래 두 개를 등록합니다.

Secret 이름
NOTION_TOKEN3.1에서 복사한 Integration Secret
NOTION_DATABASE_ID3.3에서 복사한 데이터베이스 ID

4. 핵심 파일 구조

mark-blog/
├── .github/
│   └── workflows/
│       ├── sync-notion.yml   # Notion → 마크다운 변환 및 커밋
│       ├── cd.yaml           # Next.js 빌드 → Docker → 배포
│       └── ci.yml            # PR 린트·빌드 검증
├── scripts/
│   ├── sync_notion.py        # 변환 스크립트 본체
│   └── requirements.txt      # requests
└── posts/
    ├── dev/                  # 카테고리 = post 속성값
    └── book/

5. GitHub Actions 워크플로우

5.1 sync-notion.yml

노션 페이지를 마크다운으로 변환하고, 변경이 있을 경우 커밋·푸시까지 수행합니다.

name: Sync Notion Posts
 
on:
  workflow_dispatch:        # 수동 트리거
    inputs:
      dry_run:
        description: 'Dry run (커밋/푸시 없이 변환만)'
        type: boolean
        default: false
 
jobs:
  sync:
    runs-on: ubuntu-latest
    permissions:
      contents: write
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
 
      - run: pip install -r scripts/requirements.txt
 
      - name: Run Notion sync script
        id: sync
        continue-on-error: true
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
          POSTS_DIR: posts
          PUBLIC_DIR: public
          DRY_RUN: ${{ inputs.dry_run || 'false' }}
        run: python scripts/sync_notion.py
 
      - name: Commit and push changes
        id: push
        if: steps.sync.outcome == 'success' && inputs.dry_run != true
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add posts/ public/
          if git diff --cached --quiet; then
            echo "No changes to commit."
            echo "pushed=false" >> $GITHUB_OUTPUT
          else
            git commit -m "chore: sync notion posts"
            git push
            echo "pushed=true" >> $GITHUB_OUTPUT
          fi
 
      - name: Update Notion status to synced
        if: steps.push.outputs.pushed == 'true'
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
          SYNC_MODE: update-notion
        run: python scripts/sync_notion.py

핵심 설계 포인트: git push 성공 후에만 노션 Status를 synced로 업데이트합니다. push가 실패하면 Status가 published로 유지되어 다음 실행 시 재처리됩니다.

5.2 cd.yaml (배포 파이프라인)

main 브랜치에 push가 발생하면 자동으로 실행됩니다. sync 워크플로우가 커밋을 push하면 이 CD 파이프라인이 이어서 기동됩니다.

name: CD Pipeline
 
on:
  push:
    branches: [main]
  workflow_dispatch:
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
 
      - uses: pnpm/action-setup@v2
        with: { version: latest }
 
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
 
      - uses: docker/setup-buildx-action@v3
 
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=latest,enable={{is_default_branch}}
            type=sha,prefix={{branch}}-
 
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  deploy:
    needs: build-and-push
    runs-on: self-hosted       # 홈서버에 설치한 runner
 
    steps:
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Deploy
        run: |
          docker stop mark-blog 2>/dev/null || true
          docker rm   mark-blog 2>/dev/null || true
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          docker run -d \
            --name mark-blog \
            --restart unless-stopped \
            -p 3000:3000 \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          sleep 10
          curl -f http://localhost:3000

6. 변환 스크립트 (sync_notion.py)

6.1 Notion API 직접 호출

notion-client 라이브러리 대신 requests로 REST API를 직접 호출합니다. 외부 라이브러리 의존성을 최소화하고 버전 이슈를 방지하기 위한 선택입니다.

HEADERS = {
    'Authorization': f'Bearer {NOTION_TOKEN}',
    'Notion-Version': '2022-06-28',
    'Content-Type': 'application/json',
}
 
def fetch_published_pages() -> list[dict]:
    body = {
        'filter': {
            'property': 'status',
            'status': {'equals': 'published'},
        },
    }
    data = notion_post(f'databases/{DATABASE_ID}/query', body)
    return data.get('results', [])

6.2 블록 → 마크다운 변환

Python 3.10의 match 문으로 블록 타입별 변환 로직을 깔끔하게 분리했습니다.

def block_to_md(block: dict, image_dir: Path, depth: int = 0) -> str | None:
    bt = block['type']
    data = block.get(bt, {})
    rich = data.get('rich_text', [])
 
    match bt:
        case 'heading_1': return f'\n# {rich_to_md(rich)}'
        case 'heading_2': return f'\n## {rich_to_md(rich)}'
        case 'heading_3': return f'\n### {rich_to_md(rich)}'
        case 'paragraph':
            text = rich_to_md(rich)
            return f'\n{text}' if text else ''
        case 'bulleted_list_item':
            return f'{"  " * depth}- {rich_to_md(rich)}'
        case 'code':
            lang = data.get('language', '')
            return f'\n```{lang}\n{get_plain_text(rich)}\n```'
        case 'quote':
            return f'\n> {rich_to_md(rich)}'
        case 'image':
            local_path = download_image(url, image_dir)
            rel = os.path.relpath(local_path, PUBLIC_DIR)
            return f'\n![{caption}](/{rel})'
        case 'table':
            return render_table(block)
        case _:
            return None

지원하는 블록 타입:

  • 헤딩 (h1 ~ h3), 단락, 인용구, 구분선
  • 불릿·번호 목록, 체크박스, 토글
  • 코드 블록 (언어 자동 적용), 이미지, 표
  • 컬럼 레이아웃

6.3 이미지 로컬화

노션 이미지 URL은 만료 기간이 있습니다. 동기화 시점에 이미지를 직접 다운로드하여 public/ 디렉토리에 저장합니다.

public/
  dev/
    assets/
      b648951665fa4c22b6890a96c771390d/   ← page_id (하이픈 제거)
        a48e201ce382.webp
def download_image(url: str, save_dir: Path) -> str | None:
    resp = requests.get(url, timeout=15)
    ext = url.split('?')[0].rsplit('.', 1)[-1].lower()
    name = hashlib.md5(url.encode()).hexdigest()[:12] + '.' + ext
    save_dir.mkdir(parents=True, exist_ok=True)
    (save_dir / name).write_bytes(resp.content)
    return str(save_dir / name)

Next.js는 public/ 폴더를 정적 파일로 서빙하므로 별도 설정 없이 /dev/assets/.../image.webp 경로로 접근할 수 있습니다.

6.4 생성되는 프론트매터

---
title: '[HTTP] 인터넷과 네트워크'
published: false
date: 2026.05.12
description: 'IP 프로토콜의 한계와 TCP, UDP의 차이...'
---

title은 YAML 특수문자([, : 등)를 포함할 수 있으므로 항상 작은따옴표로 감쌉니다.


7. Dry Run으로 안전하게 검증하기

배포 없이 변환 결과만 미리 확인할 수 있습니다.

  1. GitHub 리포지토리 → Actions
  2. Sync Notion Posts 워크플로우 선택
  3. Run workflowDry run 체크 → 실행

Dry run 모드에서는:

  • 마크다운 변환 및 이미지 다운로드 실행
  • git push 없음
  • 노션 Status 변경 없음
  • 워크플로우 로그에서 git status로 변경될 파일 미리 확인 가능

8. 사용 방법 요약

  1. 노션에서 글 작성 완료
  2. status 속성을 published로 변경
  3. GitHub Actions → Sync Notion PostsRun workflow 클릭
  4. 약 2~5분 후 블로그 반영 완료

워크플로우가 완료되면 노션의 Status가 자동으로 synced로 변경됩니다.


9. 마치며

이제 노션에서 글을 완성하고 Status를 바꾸면 별도의 작업 없이 블로그에 반영됩니다.

  • 이미지 만료 문제 해결 — 동기화 시점에 로컬 저장
  • 마크다운 직접 관리 불필요 — 노션이 CMS 역할
  • push 실패 시 재처리 보장 — Status를 push 성공 후에만 업데이트
  • Dry run — 실제 배포 전 변환 결과 검증 가능