# Notion 공식 API를 GitHub에 자동 백업하기: GitHub Actions 전체 구성

> Canonical: https://vxi.me/ko/posts/notion-api-github-actions-backup/
> Locale: ko-kr
> Published: 2026-03-27
> Updated: 2026-03-27
> Markdown: https://vxi.me/ko/posts/notion-api-github-actions-backup.md

Notion 백업을 오래 운영해 보면 보통 두 가지 문제를 만나게 됩니다. 하나는 `token_v2` 같은 비공식 방식이 어느 순간 만료되는 것이고, 다른 하나는 export 자체는 성공했는데 실제로는 최상위 페이지만 내려오고 하위 페이지, 하위 데이터베이스, 첨부 파일, 파일명이 원하는 형태로 남지 않는 것입니다.

저는 결국 Notion 공식 API를 사용하고 export 작업을 GitHub Actions에 맡기는 방식으로 정착했습니다. 최소한 세 가지가 깔끔해집니다. 인증을 Cookie에 의존하지 않고, 정기 실행 때문에 로컬 머신을 계속 켜둘 필요가 없으며, Markdown export 뒤에 재귀 탐색과 파일명 정리 로직을 추가할 수 있습니다.

Notion 자동 GitHub 백업, GitHub Actions 백업, Notion 공식 API Markdown export 같은 흐름을 찾고 있다면 이 구성이 그대로 적용됩니다.

## 왜 비공식 Token 방식에서 벗어났나

비공식 방식이 항상 쓸 수 없는 것은 아닙니다. 문제는 “언제 끊길지 모른다”는 불확실성입니다. 오늘은 정상인데 내일 갑자기 401이나 403이 나오거나, IP 변경, Cookie 만료, 로그인 상태 변화 때문에 자동 작업이 멈출 수 있습니다.

공식 API는 적어도 핵심 인증 계층을 더 명확하게 만듭니다.

- Token은 브라우저에서 가져온 임시 Cookie가 아니라 Internal Integration
- 어떤 페이지를 읽을 수 있는지 권한 모델이 분명함
- GitHub Actions로 정기 실행하면 로컬 머신에 의존하지 않음

실제로 필요한 것은 단순히 “API를 호출하는 방법”보다 공식 API, export 도구, 재귀 탐색을 **장기간 유지 가능한 백업 파이프라인으로 묶는 것**입니다.

## 전체 구조

먼저 흐름을 보면 구현이 훨씬 단순해집니다.

```text
Notion 최상위 페이지
      ↓
Internal Integration 권한 부여
      ↓
GitHub Actions 정기 실행
      ↓
Python에서 notion-client + notion2md 호출
      ↓
하위 페이지 / 하위 데이터베이스 재귀 탐색
      ↓
Markdown export + 첨부 파일 다운로드
      ↓
zip 자동 해제
      ↓
block id 파일명을 실제 제목으로 변경
      ↓
GitHub repository에 commit
```

바깥에서는 GitHub Actions가 움직이지만 실제 핵심은 중간의 Python script입니다. GitHub Actions는 scheduler에 가깝고, Notion 공식 SDK는 API client이며, `notion2md`는 주로 한 페이지를 Markdown으로 내보내는 역할을 합니다. “전체 트리를 재귀적으로 백업하고 사람이 읽기 좋은 디렉터리 구조를 만든다”는 부분은 직접 보완해야 합니다.

## 공식 권한부터 제대로 연결한다

첫 단계는 Notion 개발자 페이지에서 Internal Integration을 만드는 것입니다.

1. [Notion Integrations](https://www.notion.so/my-integrations) 열기
2. 새 integration 유형을 `Internal`로 선택
3. 최소 `Read content` 권한 부여
4. 생성 후 `secret_`으로 시작하는 Token 저장

Token을 만들었다고 모든 페이지를 읽을 수 있는 것은 아닙니다. Notion의 권한 모델상 integration을 실제 백업 대상 페이지에 연결해야 합니다.

웹에서 다음 순서로 설정합니다.

1. 백업 진입점으로 사용할 최상위 페이지 열기
2. 오른쪽 위 `...`
3. `Add connections`
4. 방금 만든 integration 추가

이 연결이 빠지면 다음과 같은 오류를 자주 보게 됩니다.

- `Could not find block with ID`
- page ID는 맞는 것 같은데 API가 내용을 읽지 못함

최상위 페이지의 `page_id`도 별도로 보관합니다. 보통 URL에 포함된 32자리 ID이며 하이픈을 제거한 형태도 사용할 수 있습니다.

## GitHub에는 Secrets 두 개만 준비한다

Token과 page ID를 workflow에 직접 하드코딩하지 않고 GitHub Secrets로 둡니다.

필요한 값은 두 개입니다.

- `NOTION_INTEGRATION_TOKEN`
- `NOTION_PAGE_ID`

repository에 다음 workflow를 만듭니다.

```text
.github/workflows/backup.yml
```

구조는 복잡할 필요가 없습니다. repository checkout, Python 준비, `notion-client`와 `notion2md` 설치, export script 실행, 변경 사항이 있을 때만 commit하는 정도면 됩니다.

```yaml
name: "Notion Official API Backup"

on:
  schedule:
    - cron: "0 0 * * *"
  workflow_dispatch:

jobs:
  backup:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.10"

      - name: Install dependencies
        run: pip install notion2md notion-client

      - name: Run export
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_INTEGRATION_TOKEN }}
          NOTION_PAGE_ID: ${{ secrets.NOTION_PAGE_ID }}
        run: python export_script.py

      - name: Commit changes
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action Backup"
          git add .
          if git diff --staged --quiet; then
            echo "No content changes"
          else
            git commit -m "Auto backup: $(date +'%Y-%m-%d %H:%M:%S')"
            git push
          fi
```

여기까지면 자동 실행의 뼈대는 완성입니다. 실제 백업 품질을 결정하는 것은 export script입니다.

## 핵심은 workflow보다 재귀 export 로직이다

공식 API는 지정한 페이지 아래의 전체 구조를 자동으로 “사이트 단위 export”해 주지 않습니다. 처음 실행하고 최상위 페이지만 내려왔다고 해서 API가 고장난 것은 아닙니다. 하위 구조를 직접 순회해야 합니다.

저는 Python wrapper가 최소한 네 가지를 처리하게 합니다.

- root page를 Markdown으로 export
- 모든 child page 순회
- child database를 만나면 그 안의 page도 순회
- export 후 block id 기반 파일명을 실제 title로 변경

핵심 골격은 다음 정도입니다.

```python
import os
import zipfile
from notion_client import Client
from notion2md.exporter.block import MarkdownExporter

token = os.environ["NOTION_TOKEN"]
root_id = os.environ["NOTION_PAGE_ID"].replace("-", "")
notion = Client(auth=token)

def sanitize_title(title: str) -> str:
    safe = "".join(
        c for c in title
        if c.isalnum() or c in (" ", "-", "_", "（", "）", "【", "】", "、", ".")
    ).rstrip()
    return safe or "untitled"

def export_recursive(block_id: str, output_dir: str, title: str):
    block_id = block_id.replace("-", "")
    safe_title = sanitize_title(title)
    os.makedirs(output_dir, exist_ok=True)

    MarkdownExporter(block_id=block_id, output_path=output_dir, download=True).export()

    zip_path = os.path.join(output_dir, f"{block_id}.zip")
    if os.path.exists(zip_path):
        with zipfile.ZipFile(zip_path, "r") as zip_ref:
            zip_ref.extractall(output_dir)
        os.remove(zip_path)

    md_path = os.path.join(output_dir, f"{block_id}.md")
    new_md_path = os.path.join(output_dir, f"{safe_title}.md")
    if os.path.exists(md_path):
      if os.path.exists(new_md_path):
          os.remove(new_md_path)
      os.rename(md_path, new_md_path)

    results = []
    next_cursor = None
    while True:
        res = notion.blocks.children.list(block_id=block_id, start_cursor=next_cursor)
        results.extend(res.get("results", []))
        next_cursor = res.get("next_cursor")
        if not next_cursor:
            break

    child_dir = os.path.join(output_dir, safe_title)

    for block in results:
        if block["type"] == "child_page":
            export_recursive(block["id"], child_dir, block["child_page"]["title"])
        elif block["type"] == "child_database":
            db_dir = os.path.join(child_dir, sanitize_title(block["child_database"]["title"]))
            pages = notion.databases.query(database_id=block["id"]).get("results", [])
            for page in pages:
                page_title = "untitled"
                for _, prop in page["properties"].items():
                    if prop["type"] == "title" and prop["title"]:
                        page_title = prop["title"][0]["plain_text"]
                        break
                export_recursive(page["id"], db_dir, page_title)
```

이 코드의 핵심은 화려한 구조가 아니라 실제 운영에서 불편한 문제를 한 번에 처리하는 데 있습니다.

- 공식 API가 자동으로 재귀 export하지 않는 문제
- 첨부 파일 export가 zip으로 남는 문제
- 파일명이 block id로만 만들어지는 문제
- 하위 database의 page가 기본 export에 포함되지 않는 문제

## 왜 `token_v2`보다 공식 API 구성이 장기 운영에 더 맞나

“공식이라 더 좋다”는 추상적인 이유보다 인증 모델 자체가 다릅니다.

- `token_v2`: 브라우저 로그인 상태를 자동화에 빌려 쓰는 방식에 가까움
- Internal Integration: 서비스 계정에 명시적으로 읽기 권한을 주는 방식에 가까움

비공식 token은 빠르게 실험하기에는 편할 수 있지만 장기간 무인 실행에서는 계속 불안 요소가 남습니다. 공식 integration은 처음 권한을 설정할 때 한 단계가 더 필요하지만 이후에는 자동화의 책임 경계가 더 명확합니다.

GitHub Actions도 “하루 한 번 실행하고, 실패하면 로그를 남기고, 성공하면 변경분을 commit한다”는 종류의 작업에 잘 맞습니다. 두 시스템을 조합하면 자연스러운 unattended backup이 됩니다.

## 자주 실패하는 지점

API 호출 문법보다 다음 문제를 먼저 확인하는 편이 좋습니다.

- Secrets의 Token에 공백이 들어갔거나 예전 `secret_...` 값을 사용함
- integration은 만들었지만 대상 page에 연결하지 않음
- page ID를 다른 block이나 database ID로 잘못 가져옴
- export는 됐지만 output root, rename, zip 해제 로직이 잘못됨

Notion API에는 rate limit도 있습니다. 페이지 수가 많고 첨부 파일이 크며 database 깊이가 깊다면 실행 시간이 길어지는 것은 정상일 수 있습니다.

GitHub Actions가 몇 분 동안 `Run` 상태라고 바로 실패라고 판단하지 말고, 로그가 계속 진행되는지 먼저 보는 편이 낫습니다.

## 장기간 유지하면서 확인할 것

백업은 한 번 성공하는 것보다 **변화가 생긴 뒤에도 계속 정상 산출되는지**가 더 중요합니다.

저라면 다음을 주기적으로 확인합니다.

- repository에 최근 백업 commit이 계속 생기는가
- 새 child page를 만들었을 때 recursive export에 포함되는가
- database의 새 entry가 같이 업데이트되는가
- 중국어, 영어, 특수 문자가 포함된 title에서 rename 로직이 깨지지 않는가

추가로 보완한다면 두 가지부터 권합니다.

1. 실패 시 핵심 exception을 Actions log에 남기기
2. 백업 repository의 날짜/디렉터리 구조를 일관되게 유지하기

## 잘 맞는 경우와 그렇지 않은 경우

이 방식이 잘 맞는 경우:

- Notion을 지식베이스로 쓰고 장기 보관하고 싶음
- 임시 Cookie 기반 export에서 벗어나고 싶음
- 이미 GitHub를 사용하고 있어 백업 history도 repository에 남기고 싶음

굳이 이 정도 자동화가 필요하지 않은 경우:

- 가끔 한두 번만 수동 export하면 됨
- GitHub Actions와 Python을 전혀 유지하고 싶지 않음
- attachment, database, recursive structure가 필요 없음

가끔 export 한 번이면 Notion의 공식 웹 export만으로 충분할 수 있습니다. 이 자동화는 노트 수가 늘고 “더 이상 매번 버튼을 누르고 싶지 않다”는 시점부터 가치가 커집니다.
