Notion → GitHub 자동 배포 파이프라인 구축기
1. 배경
블로그에 글을 올리려면 매번 마크다운을 직접 작성하고, 이미지를 저장하고, 커밋·푸시를 해야 했습니다. 이미 노션에서 글을 쓰고 있었기 때문에 이 과정이 고스란히 중복이었습니다.
목표는 간단했습니다.
노션 페이지의
Status를 Published로 바꾸는 것만으로 블로그에 자동 배포된다.
2. 전체 파이프라인 흐름
두 개의 워크플로우가 순차적으로 실행됩니다. sync-notion.yml이 마크다운을 커밋하면, main 브랜치 push를 감지한 cd.yaml이 이어서 배포를 수행합니다.
3. 사전 준비
3.1 Notion Integration 생성
- https://www.notion.so/my-integrations 접속
- New Integration 클릭
- 이름 입력 후 생성 → Internal Integration Secret 복사
- 글을 관리할 데이터베이스를 열고 우측 상단
···→ Connect to → 방금 만든 Integration 연결
3.2 Notion 데이터베이스 속성 구성
| 속성 이름 | 타입 | 역할 |
|---|---|---|
title | title | 포스트 제목 |
status | status | draft / published / synced |
post | multi_select | 카테고리 (폴더명으로 사용, 예: dev, book) |
날짜 | date | 발행일 |
description | rich_text | 포스트 요약 (SEO 및 목록 노출) |
Slug | rich_text | URL 슬러그 (비워두면 제목에서 자동 생성) |
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_TOKEN | 3.1에서 복사한 Integration Secret |
NOTION_DATABASE_ID | 3.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:30006. 변환 스크립트 (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'
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으로 안전하게 검증하기
배포 없이 변환 결과만 미리 확인할 수 있습니다.
- GitHub 리포지토리 → Actions 탭
- Sync Notion Posts 워크플로우 선택
- Run workflow → Dry run 체크 → 실행
Dry run 모드에서는:
- 마크다운 변환 및 이미지 다운로드 실행
git push없음- 노션 Status 변경 없음
- 워크플로우 로그에서
git status로 변경될 파일 미리 확인 가능
8. 사용 방법 요약
- 노션에서 글 작성 완료
status속성을 published로 변경- GitHub Actions → Sync Notion Posts → Run workflow 클릭
- 약 2~5분 후 블로그 반영 완료
워크플로우가 완료되면 노션의 Status가 자동으로 synced로 변경됩니다.
9. 마치며
이제 노션에서 글을 완성하고 Status를 바꾸면 별도의 작업 없이 블로그에 반영됩니다.
- 이미지 만료 문제 해결 — 동기화 시점에 로컬 저장
- 마크다운 직접 관리 불필요 — 노션이 CMS 역할
- push 실패 시 재처리 보장 — Status를 push 성공 후에만 업데이트
- Dry run — 실제 배포 전 변환 결과 검증 가능