본문 바로가기

Backend/Study

[Tip] Notion + GitHub로 개발 문서 자동화하기

반응형

 

Notion + GitHub로 개발 문서 자동화하기

아이디어가 떠오르면 Notion에 적고, 검토가 끝나면 GitHub로 자동 반영되어 사이트에 배포된다면 얼마나 편할까요? 이 문서에서는 Notion을 콘텐츠 소스로, GitHub를 검토·버전관리·배포 허브로 사용해 개발 문서를 자동화하는 설계를 처음부터 끝까지 안내합니다.

개요

  1. 문서 자동화의 목표와 전반 구조
  2. Notion 준비: 데이터 모델과 권한
  3. GitHub 준비: 저장소 레이아웃과 배포 대상
  4. 데이터 흐름 패턴: 단방향 vs. 양방향
  5. 구현 A안(가장 단순): 스케줄 동기화
  6. 구현 B안(변경 감지): 수동 트리거/PR 기반
  7. 콘텐츠 변환: Markdown, 프론트매터, 이미지
  8. 품질 게이트: 링크·맞춤법·스타일 체크
  9. 운영 팁: 권한, 보안, 성능, 로그
  10. 문제 해결: 자주 발생하는 오류와 대응
  11. 마이그레이션 & 확장: Docusaurus/MkDocs/Next
  12. 결론 요약 및 다음 단계
  13. FAQ (3)

문서 자동화의 목표와 전반 구조

개발 문서 자동화의 핵심은 한 곳에서 쓰고(작성), 다른 곳에서 자동으로 보여주는(배포) 것입니다. Notion은 작성 경험이 좋고, GitHub는 버전관리와 자동 배포에 최적화되어 있습니다. 두 도구를 연결하면 “작성 → 검토 → 배포”가 자연스러운 파이프라인으로 이어집니다.

아키텍처 요약

  • 작성: Notion 데이터베이스에서 페이지 작성/수정
  • 동기화: GitHub Actions가 Notion API로 콘텐츠를 가져와 Markdown으로 변환
  • 검토: Pull Request로 리뷰, 체크 통과 시 main에 병합
  • 배포: docs 프레임워크(Docusaurus/MkDocs/Next)로 사이트 자동 배포

왜 이 조합인가

  • 팀이 이미 Notion을 사용 중이라면 러닝커브가 낮습니다.
  • GitHub는 리뷰, 이력, 롤백, 권한 관리가 강력합니다.
  • 자동화(CI)로 일관성 있는 품질 게이트를 적용할 수 있습니다.
메모: 이 글은 Notion을 단일 소스로 두고 GitHub로 동기화하는 접근을 중심으로 설명합니다. 필요하면 반대로 “GitHub → Notion”도 확장할 수 있지만 복잡도가 큽니다.

Notion 준비: 데이터 모델과 권한

먼저 문서를 관리할 데이터베이스를 만듭니다. 페이지를 무분별하게 늘리지 않도록, 최소한의 필드를 표준화하세요.

필드 타입 설명/예시
Title Title 문서 제목
Slug Text 문서 경로(ex. guides/notion-github-sync)
Status Select Draft / Review / Published
Category Select Guide / Concept / Reference 등
Tags Multi-select api, actions, docusaurus...
Visibility Select Public / Internal
Owner People 작성/책임자
Updated Date 최종 수정 시각
  1. Notion에서 데이터베이스를 만들고 위 필드를 추가합니다.
  2. Notion 내 통합(Integration)을 생성해 비밀 토큰을 발급받습니다.
  3. 데이터베이스의 Share에서 해당 통합을 초대해 읽기 권한을 부여합니다.
  4. 데이터베이스 ID와 통합 토큰을 기록해둡니다.
팁: Status = Published 그리고 Visibility = Public 조건만 GitHub로 내보내도록 하면 내부 문서가 외부로 나갈 위험을 줄일 수 있습니다.

GitHub 준비: 저장소 레이아웃과 배포 대상

문서가 들어갈 저장소와 배포 방식을 결정합니다. 가장 단순한 선택지는 GitHub Pages + 정적 문서 프레임워크입니다.

추천 레이아웃

.
├─ .github/workflows/
│   └─ docs-sync.yml
├─ docs/
│   ├─ guides/
│   ├─ reference/
│   └─ assets/      # 이미지/첨부
├─ docusaurus.config.js (또는 mkdocs.yml / next.config.js)
└─ package.json

배포 대상

  • Docusaurus: 개발자 문서·버저닝에 강함
  • MkDocs + Material: 심플하고 빠른 레퍼런스
  • Next.js: 커스텀 요구사항이 많을 때 유연

어떤 프레임워크든 docs/ 아래 Markdown을 읽어 사이트를 생성하도록 설정하면 됩니다.

데이터 흐름 패턴: 단방향 vs. 양방향

패턴 장점 주의점 언제?
단방향 (Notion → GitHub) 단순, 충돌 적음, 운영 용이 GitHub에서 직접 수정 시 덮어쓸 수 있음 소스 오브 트루스가 Notion일 때
양방향 (GitHub ↔ Notion) 어디서나 수정 가능 동기화 충돌, 난도↑ 특수 요구·에디터 다양성이 중요할 때
권장: 초반에는 단방향으로 시작해 안정화한 뒤 필요한 경우에만 양방향을 설계하세요.

구현 A안(가장 단순): 스케줄 동기화

정해진 주기마다 GitHub Actions가 Notion을 조회하고 변경분을 Markdown으로 생성해 커밋합니다. 세팅이 간단하고 운영이 쉽습니다.

1) GitHub Secrets 설정

  • NOTION_TOKEN: Notion 통합 토큰
  • NOTION_DATABASE_ID: 대상 데이터베이스 ID
  • GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL: 커밋 메타

2) Actions 워크플로우 예시

# .github/workflows/docs-sync.yml
# ...

name: Docs Sync (Notion → GitHub)

on:
schedule:
- cron: "15 * * * *"   # 매시간 15분에 동기화
workflow_dispatch: {}     # 필요시 수동 실행

jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

# ...
  - name: Setup Node
    uses: actions/setup-node@v4
    with:
      node-version: "20"

  - name: Install Deps
    run: npm ci

  - name: Run Sync
    env:
      NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
      NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
    run: node scripts/notion-sync.js

  - name: Commit & Push
    env:
      GIT_AUTHOR_NAME: ${{ secrets.GIT_COMMITTER_NAME }}
      GIT_AUTHOR_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }}
      GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }}
      GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }}
    run: |
      if [[ -n "$(git status --porcelain)" ]]; then
        git add -A
        git commit -m "docs: sync from Notion"
        git push
      else
        echo "No changes"
      fi
# ...

 

3) Node 스크립트 기본 골격

Notion SDK로 페이지를 조회하고 Markdown으로 변환한 뒤 docs/ 디렉터리에 저장합니다.

// scripts/notion-sync.js

import fs from "node:fs";
import path from "node:path";
import {Client} from "@notionhq/client";
// 예: notion-to-md, 혹은 직접 변환 로직 작성
import {NotionToMarkdown} from "notion-to-md";

const notion = new Client({auth: process.env.NOTION_TOKEN});
const n2m = new NotionToMarkdown({notionClient: notion});

const DB_ID = process.env.NOTION_DATABASE_ID;
const DOCS_DIR = path.join(process.cwd(), "docs");

function toSlug(value) {
    return (value || "").trim().toLowerCase()
        .replace(/[^a-z0-9/-_ ]/g, "")
        .replace(/\s+/g, "-");
}

function frontmatter({title, slug, tags = [], category, updated, visibility}) {
    const lines = [
        "---",
        `title: "${title.replace(/"/g, '\\"')}"`,
        `slug: ${slug}`,
        `tags: [${tags.map(t => `"${t}"`).join(", ")}]`,
        `category: ${category || "Guide"}`,
        `updated: ${updated || new Date().toISOString()}`,
        `visibility: ${visibility || "Public"}`,
        "---",
        ""
    ];
    return lines.join("\n");
}

async function fetchPages() {
    const pages = [];
    let cursor = undefined;
    while (true) {
        const res = await notion.databases.query({
            database_id: DB_ID,
            start_cursor: cursor,
            filter: {
                and: [
                    {property: "Status", select: {equals: "Published"}},
                    {property: "Visibility", select: {equals: "Public"}}
                ]
            },
            sorts: [{property: "Updated", direction: "descending"}]
        });
        pages.push(...res.results);
        if (!res.has_more) break;
        cursor = res.next_cursor;
    }
    return pages;
}

async function pageToMarkdown(page) {
    const mdBlocks = await n2m.pageToMarkdown(page.id);
    const md = n2m.toMarkdownString(mdBlocks);
    return md.parent || md;
}

function prop(page, key) {
    const p = page.properties[key];
    if (!p) return null;
    if (p.type === "title") return p.title?.[0]?.plain_text || "";
    if (p.type === "select") return p.select?.name || "";
    if (p.type === "multi_select") return p.multi_select?.map(x => x.name) || [];
    if (p.type === "date") return p.date?.start || "";
    if (p.type === "people") return p.people?.map(x => x.name) || [];
    if (p.type === "rich_text") return p.rich_text?.[0]?.plain_text || "";
    return null;
}

async function main() {
    if (!fs.existsSync(DOCS_DIR)) fs.mkdirSync(DOCS_DIR, {recursive: true});
    const pages = await fetchPages();

    for (const page of pages) {
        const title = prop(page, "Title") || "Untitled";
        const rawSlug = prop(page, "Slug") || toSlug(title);
        const category = prop(page, "Category") || "Guide";
        const tags = prop(page, "Tags") || [];
        const visibility = prop(page, "Visibility") || "Public";
        const updated = prop(page, "Updated") || new Date().toISOString();


        // 서브폴더 지원 (예: guides/abc)
        const relPath = rawSlug.endsWith(".md") ? rawSlug : `${rawSlug}.md`;
        const outPath = path.join(DOCS_DIR, relPath);
        const outDir = path.dirname(outPath);
        if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, {recursive: true});

        const body = await pageToMarkdown(page);
        const fm = frontmatter({title, slug: `/${rawSlug}`, tags, category, updated, visibility});

        fs.writeFileSync(outPath, fm + body + "\n");
        console.log("Wrote:", outPath);


    }
}

main().catch(err => {
    console.error(err);
    process.exit(1);
});
실무에서는 이미지 다운로드, 내부 링크 변환, 표/콜아웃 스타일 처리 등 추가 후처리를 넣어 품질을 높입니다.

구현 B안(변경 감지): 수동 트리거/PR 기반

Notion 변경을 즉시 배포하고 싶거나, 배포 전 리뷰를 강제하고 싶다면 PR 기반이 좋습니다. 워크플로우는 같지만 결과물을 새 브랜치에 커밋하고 자동으로 PR을 열도록 합니다.

# Commit & PR 단계만 변경
- name: Create Pull Request
  uses: peter-evans/create-pull-request@v6
  with:
    commit-message: "docs: sync from Notion"
    title: "docs(notion): sync"
    branch: "chore/docs-sync"
    delete-branch: true

팀은 PR에서 미리보기 링크(예: Vercel Preview, Pages preview)를 보고 승인/수정 후 병합하면 됩니다.

콘텐츠 변환: Markdown, 프론트매터, 이미지

Markdown 변환 체크리스트

  • 헤딩 레벨: Notion의 헤딩1/2/3 → #, ##, ###
  • 리스트/체크박스: GitHub 호환 마크다운으로
  • 코드 블록: 언어 태그 유지 (예: ```ts)
  • 경고/정보 콜아웃: 프론트엔드 테마의 Alert 구성요소로 매핑
  • 테이블: 파이프 테이블 생성 또는 MDX 컴포넌트 사용
  • 내부 링크: Notion 페이지 링크 → slug 기반 상대 경로로 치환

프론트매터 규칙(예시)

값 타입 용도
title string 페이지 제목
slug string 고정 URL 경로
tags string[] 관련 키워드 필터링
category string 사이드바 그룹
updated ISO string “최종 업데이트” 표기
visibility Public/Internal 내부/외부 구분

이미지와 자산

  • 다운로드 후 docs/assets/에 저장하고 상대경로로 참조합니다.
  • 파일명은 슬러그 기반 + 해시로 충돌을 방지합니다.
  • 너비/품질을 자동 리사이즈해 페이지 속도를 개선하세요.

간단한 이미지 처리 스니펫

// 예시: URL → 파일 다운로드 후 상대 경로 치환

import { createWriteStream } from "node:fs";
import fetch from "node-fetch";

async function downloadAsset(url, outPath) {
	const res = await fetch(url);
	if (!res.ok) throw new Error(`Download failed: ${res.status}`);
	await new Promise((resolve, reject) => {
		const file = createWriteStream(outPath);
		res.body.pipe(file);
		res.body.on("error", reject);
		file.on("finish", resolve);
	});
	return outPath;
}

품질 게이트: 링크·맞춤법·스타일 체크

코드처럼 문서에도 CI 품질 게이트를 걸어야 일관된 결과가 나옵니다.

  • 링크 검사: 내부/외부 링크 죽은 링크 감지
  • 맞춤법/스타일: 린터 혹은 사전 기반 검사
  • 파일 구조: 금지된 경로나 파일명 패턴 차단
  • 프론트매터 유효성: 필수 키 누락 방지

샘플 스크립트: 프론트매터 유효성

// scripts/validate-frontmatter.js

import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

const DOCS_DIR = path.join(process.cwd(), "docs");
const required = ["title", "slug", "updated"];

function walk(dir) {
    return fs.readdirSync(dir, {withFileTypes: true}).flatMap((ent) => {
        const p = path.join(dir, ent.name);
        return ent.isDirectory() ? walk(p) : p.endsWith(".md") || p.endsWith(".mdx") ? [p] : [];
    });
}

let ok = true;
for (const file of walk(DOCS_DIR)) {
    const raw = fs.readFileSync(file, "utf8");
    const {data} = matter(raw);
    for (const key of required) {
        if (!(key in data)) {
            console.error(`Missing "${key}" in ${file}`);
            ok = false;
        }
    }
}
if (!ok) process.exit(1);

 

워크플로우에 품질 단계 추가

- name: Validate Docs

run: node scripts/validate-frontmatter.js

* name: Check Links
  run: npx markdown-link-check -q -c .mlc.json "docs/**/*.md"

운영 팁: 권한, 보안, 성능, 로그

보안

  • Notion 토큰은 Repository secrets로만 보관
  • 공개 저장소라면 내부 문서 필터를 강하게 설정
  • PR 미리보기에서 비공개 자산 노출 금지

성능

  • 페이지 수백 개면 페이징/캐시 적용
  • 변경분만 커밋하도록 해 Git 기록을 깔끔하게
  • 이미지는 사이즈 제한과 중복 해시 관리

가시성

  • 동기화 로그를 아카이브해 회귀 분석
  • 성공/실패를 채널로 통지(Slack, Teams)

권한

  • 승인된 사람만 Published로 바꿀 수 있게 권한 분리
  • PR 리뷰 규칙과 CODEOWNERS로 책임 명확화

문제 해결: 자주 발생하는 오류와 대응

증상 원인 대응
동기화는 성공했는데 페이지가 비어 있음 변환 로직이 지원하지 않는 블록 타입 해당 블록을 안전한 Markdown 대체로 치환하거나 MDX 컴포넌트 사용
이미지가 깨짐 만료 URL 참조, 권한 문제 다운로드 후 로컬 자산으로 저장, 상대경로 사용
내부 문서가 외부에 노출 필터 누락 Status/Visibility 2중 필터로 보호
배포는 되었는데 스타일이 일관적이지 않음 프론트매터/헤딩 규칙 미준수 린터와 PR 체크로 강제

마이그레이션 & 확장: Docusaurus/MkDocs/Next

이미 운영 중인 사이트가 있다면 변환 스크립트에서 출력 포맷만 바꾸면 됩니다.

  • Docusaurus: 사이드바 자동 생성 규칙에 맞춰 sidebar_position 등 추가
  • MkDocs: mkdocs.ymlnav 업데이트 자동화
  • Next.js: getStaticProps로 Markdown을 읽어 페이지 생성

장기적으로는 다국어(i18n) 지원, 버전 문서, 검색 색인(예: Algolia)까지 파이프라인에 통합할 수 있습니다.

결론 요약 및 다음 단계

  • Notion을 작성의 단일 소스로 두고, GitHub로 자동 동기화하면 문서 품질과 배포 속도가 모두 올라갑니다.
  • 가장 단순한 A안(스케줄 동기화)으로 시작하고, 팀이 익숙해지면 PR 기반 검토를 추가하세요.
  • 프론트매터 규칙·이미지 처리·품질 게이트를 도입하면 확장성과 유지보수가 좋아집니다.

다음 단계 체크리스트

  • Notion DB 스키마 만들기(Title, Slug, Status, Visibility 등)
  • Notion 통합 토큰 발급 & 데이터베이스 공유
  • GitHub 저장소와 문서 프레임워크 선택
  • Actions 워크플로우와 변환 스크립트 배치
  • 품질 게이트와 PR 규칙 설정

FAQ

Q1. Notion에서 바로 배포를 누를 수 있나요?

A. 일반적으로는 GitHub Actions가 중간에서 변환·검증·배포를 담당합니다. Notion은 작성 도구, GitHub는 자동화와 배포 도구로 역할을 분리하는 편이 운영상 안정적입니다.

Q2. 내부 전용 문서와 외부 공개 문서를 동시에 관리할 수 있을까요?

A. 가능합니다. Visibility 필드를 기준으로 Public만 외부 저장소로 내보내고, Internal은 별도 저장소/브랜치로 동기화하거나 아예 동기화 대상에서 제외하세요.

Q3. 이미지나 첨부 파일 용량이 큰데 어떻게 처리하나요?

A. 동기화 시점을 기준으로 리사이즈/압축을 적용하고, 파일명을 슬러그+해시로 표준화하세요. 장기적으로는 CDN이나 저장 버킷을 연동해 성능과 비용을 최적화할 수 있습니다.

 

반응형