글쓰기 프리뷰
    Notion API로 정적 블로그 시스템 구축하기

    Notion API로 정적 블로그 시스템 구축하기

    (수정: 2025년 12월 24일 오전 03:56)

    Notion API로 정적 블로그 시스템 구축하기

    Notion을 CMS(Content Management System)로 활용하여 GitHub Pages에 배포하는 블로그 시스템을 구축하는 방법을 정리합니다.

    왜 Notion을 CMS로?

    블로그를 운영하려면 콘텐츠 관리 시스템이 필요합니다. 선택지는 다양합니다:

    방식장점단점
    마크다운 파일 직접 관리단순함, Git 버전관리에디터 불편, 이미지 관리 번거로움
    Headless CMS (Contentful, Strapi)전문적인 CMS 기능설정 복잡, 유료 플랜 필요할 수 있음
    Notion익숙한 에디터, 무료, 협업 용이API 제약, 빌드 타임 의존

    저는 이미 Notion을 메모와 문서 작성에 사용하고 있었기 때문에, 별도 학습 없이 바로 글을 쓸 수 있다는 점에서 Notion을 선택했습니다.

    시스템 아키텍처

    전체 흐름

    아키텍처 흐름아키텍처 흐름

    왜 빌드 타임에 데이터를 가져오나?

    GitHub Pages는 정적 호스팅이므로 서버 사이드 로직을 실행할 수 없습니다. 따라서:

    • ❌ 런타임에 Notion API 호출 → CORS 에러, API 키 노출
    • ✅ 빌드 타임에 데이터를 JSON으로 저장 → 정적 파일로 서빙

    Notion 데이터베이스 설정

    1. 데이터베이스 생성

    Notion에서 새 데이터베이스를 만들고 다음 속성들을 추가합니다:

    속성명타입용도
    titleTitle글 제목 (기본 속성)
    slugTextURL 경로 (예: my-first-post)
    categorySelect카테고리 분류
    tagMulti-select태그들
    seriesSelect 또는 Text시리즈 이름
    descriptionText글 요약 설명
    createAtDate작성일
    updateAtDate수정일

    2. Integration 생성 및 연결

    1. Notion Developers에서 새 Integration 생성
    2. Internal Integration 선택
    3. 생성된 API Key 복사
    4. 데이터베이스 페이지에서 ••• → Add connections → 생성한 Integration 연결

    3. 환경 변수 설정

    # .env VITE_NOTION_API_KEY=secret_xxxxxxxxxxxxx VITE_NOTION_DATABASE_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

    Database ID는 데이터베이스 URL에서 확인할 수 있습니다: https://notion.so/myworkspace/{DATABASE_ID}?v=...

    데이터 가져오기 스크립트

    fetch-notion.js 구현

    import "dotenv/config"; import { Client } from "@notionhq/client"; import { writeFileSync } from "fs"; const notion = new Client({ auth: process.env.VITE_NOTION_API_KEY, }); const databaseId = process.env.VITE_NOTION_DATABASE_ID; async function fetchBlogPosts() { // 1. 데이터베이스에서 모든 페이지 검색 const allPages = []; let cursor; do { const response = await notion.search({ filter: { property: "object", value: "page" }, start_cursor: cursor, page_size: 100, }); allPages.push(...response.results); cursor = response.has_more ? response.next_cursor : undefined; } while (cursor); // 2. 해당 데이터베이스에 속한 페이지만 필터링 const databasePages = allPages.filter((page) => { if (page.in_trash || page.archived) return false; const parentId = page.parent?.database_id?.replace(/-/g, ""); return parentId === databaseId.replace(/-/g, ""); }); // 3. 각 페이지의 속성과 본문 가져오기 const posts = await Promise.all( databasePages.map(async (page) => { const properties = page.properties; const content = await getPageContent(page.id); return { id: page.id, title: getTitle(properties), slug: properties.slug?.rich_text?.[0]?.plain_text || page.id, category: properties.category?.select?.name || "", tags: properties.tag?.multi_select?.map((t) => t.name) || [], series: properties.series?.select?.name || null, description: properties.description?.rich_text?.[0]?.plain_text || null, createdAt: properties.createAt?.date?.start || page.created_time, updatedAt: properties.updateAt?.date?.start || page.last_edited_time, coverImage: page.cover?.external?.url || page.cover?.file?.url || null, content, }; }) ); // 4. 정렬 후 저장 posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); writeFileSync("src/data/posts.json", JSON.stringify(posts, null, 2)); console.log(`Fetched ${posts.length} posts`); }

    블록을 마크다운으로 변환

    Notion API는 페이지 본문을 "블록" 단위로 반환합니다. 이를 마크다운으로 변환해야 합니다:

    async function getPageContent(pageId) { const blocks = []; let cursor; do { const response = await notion.blocks.children.list({ block_id: pageId, start_cursor: cursor, }); blocks.push(...response.results); cursor = response.has_more ? response.next_cursor : undefined; } while (cursor); return blocks.map(blockToMarkdown).join("\n"); } function blockToMarkdown(block) { const type = block.type; const content = block[type]; switch (type) { case "paragraph": return richTextToMarkdown(content?.rich_text) + "\n"; case "heading_1": return `# ${richTextToMarkdown(content?.rich_text)}\n`; case "heading_2": return `## ${richTextToMarkdown(content?.rich_text)}\n`; case "heading_3": return `### ${richTextToMarkdown(content?.rich_text)}\n`; case "bulleted_list_item": return `- ${richTextToMarkdown(content?.rich_text)}\n`; case "numbered_list_item": return `1. ${richTextToMarkdown(content?.rich_text)}\n`; case "code": return `\`\`\`${content?.language || ""}\n${richTextToMarkdown(content?.rich_text)}\n\`\`\`\n`; case "quote": return `> ${richTextToMarkdown(content?.rich_text)}\n`; case "divider": return "---\n"; case "image": const url = content?.file?.url || content?.external?.url || ""; return `![image](${url})\n`; default: return ""; } } function richTextToMarkdown(richText) { if (!richText) return ""; return richText.map((text) => { let content = text.plain_text || ""; if (text.annotations?.bold) content = `**${content}**`; if (text.annotations?.italic) content = `*${content}*`; if (text.annotations?.code) content = `\`${content}\``; if (text.annotations?.strikethrough) content = `~~${content}~~`; if (text.href) content = `[${content}](${text.href})`; return content; }).join(""); }

    이미지 처리 전략

    Notion 내부 이미지 URL은 임시 서명이 포함되어 있어 일정 시간 후 만료됩니다. 두 가지 해결책이 있습니다:

    방법 1: 이미지 다운로드 (권장)

    import crypto from "crypto"; import { writeFileSync, existsSync } from "fs"; import https from "https"; const IMAGE_DIR = "public/images/blog"; async function downloadImage(url, pageId) { // URL 해시로 고유 파일명 생성 (서명 부분 제외) const urlWithoutQuery = url.split("?")[0]; const hash = crypto.createHash("md5").update(urlWithoutQuery).digest("hex").slice(0, 8); const ext = getExtension(url); const filename = `${pageId.slice(0, 8)}-${hash}${ext}`; const filepath = `${IMAGE_DIR}/${filename}`; // 이미 존재하면 스킵 if (existsSync(filepath)) { return `/images/blog/${filename}`; } // 다운로드 return new Promise((resolve, reject) => { https.get(url, (response) => { const chunks = []; response.on("data", (chunk) => chunks.push(chunk)); response.on("end", () => { writeFileSync(filepath, Buffer.concat(chunks)); resolve(`/images/blog/${filename}`); }); }).on("error", reject); }); }

    방법 2: 외부 이미지 사용

    Notion에서 이미지를 업로드하는 대신 외부 URL(Imgur, Cloudinary 등)을 사용하면 만료 문제가 없습니다.

    React에서 데이터 사용

    정적 import

    SSG 빌드에서 데이터가 포함되려면 JSON을 직접 import해야 합니다:

    // src/pages/Blog.tsx import posts from "@/data/posts.json"; import type { BlogPost } from "@/types"; const Blog = () => { const typedPosts = posts as BlogPost[]; return ( <div> {typedPosts.map((post) => ( <BlogPostCard key={post.id} post={post} /> ))} </div> ); };

    타입 정의

    // src/types/index.ts export interface BlogPost { id: string; title: string; slug: string; category: string; tags: string[]; series: string | null; description: string | null; createdAt: string; updatedAt: string; coverImage: string | null; content: string; }

    빌드 파이프라인 구성

    package.json 스크립트

    { "scripts": { "fetch:notion": "node scripts/fetch-notion.js", "generate:seo": "node scripts/generate-rss.js && node scripts/generate-sitemap.js", "prebuild": "yarn fetch:notion && yarn generate:seo", "build": "vite-react-ssg build", "deploy": "yarn build && gh-pages -d dist" } }

    빌드 순서

    1. fetch:notion → Notion API에서 데이터 가져와 posts.json 저장 2. generate:seo → RSS, Sitemap 생성 3. build → SSG로 정적 HTML 생성 4. deploy → GitHub Pages에 배포

    고려사항 및 한계

    장점

    • Notion의 편리한 에디터로 글 작성
    • 무료 (Notion 무료 플랜으로 충분)
    • Git으로 posts.json 버전 관리
    • 빠른 페이지 로드 (정적 파일)

    한계

    • 글 수정 시 재빌드 필요
    • Notion API 호출 제한 (Integration당 초당 3회)
    • 복잡한 Notion 블록 (토글, 데이터베이스 뷰 등) 변환 어려움
    • 실시간 미리보기 불가

    개선 아이디어

    • GitHub Actions로 자동 빌드 (Notion Webhook 또는 정기 스케줄)
    • 변경된 포스트만 업데이트하는 증분 빌드
    • 더 많은 Notion 블록 타입 지원

    마무리

    Notion을 CMS로 사용하는 블로그 시스템은 개인 블로그나 소규모 프로젝트에 적합합니다. 복잡한 CMS 설정 없이 익숙한 도구로 콘텐츠를 관리할 수 있다는 것이 가장 큰 장점입니다.

    다만 실시간 업데이트가 필요하거나 대규모 콘텐츠를 다루는 경우에는 전문 Headless CMS를 고려하는 것이 좋습니다.

    Dunde's Portfolio

    © 2026 Dunde. All rights reserved.

    Built with React, TypeScript, and Vite. Deployed on GitHub Pages.