개인 기술 블로그 만들기 — Blog
← Back
개인 기술 블로그 만들기
Properties
Date 2025.12.31
Summary 노션 데이터베이스를 기반으로 한 기록용 기술 블로그 만들기

컨버전스 1본부가 공동으로 사용하는 노션을 폐쇄하면서 이제까지 노션에 기록해둔 AI 스터디 기록을 공유할 장소가 필요해졌다.

개인 노션에서 페이지를 퍼블리싱해 웹상으로 보여줘도 무방하나, 개인 노션을 오픈하는 것도 꺼려지고 마음대로 디자인을 바꾸는 등의 장난질(?)을 칠만한 공간이 있으면 좋겠다는 생각이 들어 개인 기술 블로그를 만들게 되었다.

📄 Attached File

Study Blog

Notion을 CMS로 사용하는 Astro 기반 기술 블로그입니다.

🔗 Live Site: https://study-blog-jade.vercel.app


📸 Preview

| 메인 페이지 | 프로젝트 상세 |

|-------------|---------------|

| 미니멀 타이포그래피 중심 디자인 | Notion 블록 → HTML 렌더링 |


🛠 Tech Stack

Frontend

| 기술 | 버전 | 용도 |

|------|------|------|

| Astro | 5.x | 정적 사이트 생성 (SSG) |

| TypeScript | 5.x | 타입 안정성 |

| TailwindCSS | 4.x | 유틸리티 기반 스타일링 |

| Vanilla CSS | - | 커스텀 디자인 시스템 |

Backend / CMS

| 기술 | 용도 |

|------|------|

| Notion API | 콘텐츠 관리 (CMS) |

| Native Fetch | API 통신 (라이브러리 미사용) |

Deployment

| 서비스 | 용도 |

|--------|------|

| Vercel | 호스팅 및 자동 배포 |

| GitHub | 버전 관리 |


📁 Project Structure

study-blog/

├── docs/

│ ├── TROUBLESHOOTING.md # 트러블슈팅 가이드

│ └── NOTION_INTEGRATION_GUIDE.md # Notion 연동 가이드

├── src/

│ ├── layouts/

│ │ └── Layout.astro # 공통 레이아웃

│ ├── lib/

│ │ └── notion-client.ts # Notion API 클라이언트

│ ├── pages/

│ │ ├── index.astro # 메인 페이지

│ │ ├── projects/

│ │ │ ├── index.astro # 프로젝트 목록 (연도 필터)

│ │ │ └── [slug].astro # 프로젝트 상세

│ │ └── reports/

│ │ ├── index.astro # 리포트 목록 (연도 필터)

│ │ └── [slug].astro # 리포트 상세

│ └── styles/

│ └── global.css # 디자인 시스템

├── .env # 환경변수 (git 제외)

├── .gitignore

├── astro.config.mjs

├── package.json

├── tsconfig.json

└── README.md


🔨 Development Process

Phase 1: 프로젝트 초기화

  1. Astro 프로젝트 생성 (npm create astro@latest)
  2. TailwindCSS 4.x 통합
  3. TypeScript 설정
  4. Phase 2: Notion API 연동

  5. Notion Integration 생성 및 API Key 발급
  6. 데이터베이스 스키마 설계 (Projects, Reports)
  7. Native Fetch 기반 API 클라이언트 구현
  8. 환경변수 설정 (.env)
  9. Phase 3: 페이지 구현

  10. 레이아웃 컴포넌트 (Layout.astro)
  11. 메인 페이지 - 최신 게시물 3개 표시
  12. 리스팅 페이지 - 연도별 필터 기능
  13. 상세 페이지 - Notion 블록 → HTML 변환
  14. Phase 4: 블록 렌더링 개선

  15. 연속 목록 그룹화: 번호 목록이 1,1,1 → 1,2,3 으로 정상 표시
  16. 재귀적 자식 블록 fetch: Column, Toggle, Callout 내부 콘텐츠 지원
  17. 다양한 블록 타입 지원: 테이블, 코드, 이미지, 파일, 북마크 등
  18. Phase 5: 디자인 시스템

  19. 타이포그래피 중심 미니멀 UI
  20. clamp() 기반 반응형 폰트
  21. 호버 애니메이션 및 트랜지션
  22. 연도 필터 버튼 UI
  23. Phase 6: 배포

  24. GitHub 레포지토리 생성
  25. Vercel 연동 및 자동 배포
  26. 환경변수 설정

  27. 🚀 Getting Started

    1. 클론 및 의존성 설치

    git clone https://github.com/Hmlee02/study-blog.git
    

    cd study-blog

    npm install

    2. 환경변수 설정

    .env 파일 생성:

    NOTION_API_KEY=secret_xxx
    

    NOTION_DATABASE_ID_PROJECTS=xxx

    NOTION_DATABASE_ID_REPORTS=xxx

    3. 개발 서버 실행

    npm run dev

    4. 빌드

    npm run build

    📝 Notion Database Schema

    Projects

    | 속성명 | 타입 | 필수 | 설명 |

    |--------|------|------|------|

    | 제목 | Title | ✅ | 프로젝트 제목 |

    | 게시여부 | Checkbox | ✅ | 블로그 노출 여부 |

    | Slug | Text | - | URL 경로 |

    | 설명 | Text | - | 요약 |

    | Date | Date | - | 프로젝트 날짜 |

    Reports

    | 속성명 | 타입 | 필수 | 설명 |

    |--------|------|------|------|

    | Name | Title | ✅ | "2025년 12월 5째 주" 형식 |

    | 주요 진행 내용 | Text | - | 진행 사항 |

    | 진행 결과 | Text | - | 결과 |

    | 다음 주 계획 | Text | - | 계획 |

    | 사용한 툴 및 기술 | Text | - | 기술 스택 |

    | 인사이트 및 회고 | Text | - | 회고 |


    📚 Documentation

    • 트러블슈팅 가이드
    • Notion API 연동 가이드

    • 🐛 Known Issues & Solutions

      자세한 내용은 TROUBLESHOOTING.md 참조.

      | 문제 | 원인 | 해결 |

      |------|------|------|

      | 번호 목록 1,1,1 표시 | 각 항목마다 별도 <ol> 생성 | 연속 항목 그룹핑 |

      | 중첩 콘텐츠 누락 | 자식 블록 미 fetch | 재귀적 children fetch |

      | attachment:// 오류 | Notion 내부 링크 | 사용자 안내 UI |

      | 봇 권한 오류 | Integration 미연결 | Connections 설정 |


      📄 License

      MIT


      👤 Author

      Hmlee02

    • GitHub: @Hmlee02

블로그의 기술 스택과 작업 과정은 README.md 파일에 자세히 기술해두었다.

간단히 요약해 설명하자면 프레임워크는 Astro, 디자인 UI 라이브러리는 shadcn/ui를 활용하고 노션에 있는 데이터베이스를 API로 끌고 와 보여주는 형식의 기술 블로그를 만들었다.

프레임워크를 이제까지 사용해온 Next.js가 아니라 Astro로 바꿔서 사용해보았다.

제대로 읽고 쓸 수 있는 언어가 html과 css 뿐인 사람에게 읽는 것도 수정도 용이한 프레임워크를 써보고 싶었기 때문이다.

Next.js는 React로 돌아가는 프레임워크다. React는 자바스크립트를 기반으로 작성하는데, 자바스크립트는 아직 읽는 법을 잘 몰라서 코드가 어떻게 짜여져 있는지 바로 이해하기도 어려웠고, 직접 수정하는 것도 많이 어려웠다. 그래서 html과 css만 아는 사람도 쉽게 이해하고 사용할 수 있는 프레임워크를 찾았다. Sevelt와 Astro 두 가지 선택지가 있었는데, Astro가 작은 프로젝트에 사용하기 좋다고 추천받아 Astro를 선택했다.

더해서, Next.js+React 외에도 다양한 프레임워크를 경험해보고 싶기도 했다. 다음번에는 Sevelt를 한번 사용해볼 생각이다.

블로그를 만들면서 Astro를 사용해봤을 때, 파일 구조를 html과 거의 동일하게 사용하고 있어 이해하고 수정하는 시간이 React를 쓸 때보다 짧았다. 작은 단위의 컴포넌트나 텍스트 사이즈를 수정할 때도 에이전트 토큰을 낭비하는 일 없이 알아서 빠르게 수정할 수 있었다.

📄 Attached File

Notion API 연동 가이드

이 문서는 Astro 프로젝트에서 Notion을 CMS로 사용하기 위한 연동 방법을 설명합니다.


1. Notion Integration 생성

Step 1: Integration 생성

  1. Notion Developers 접속
  2. + New integration 클릭
  3. 이름 입력 (예: Study Blog)
  4. 워크스페이스 선택
  5. Submit 클릭
  6. Step 2: API Key 복사

    • 생성된 Integration의 Internal Integration Secret 복사
    • secret_ 또는 ntn_으로 시작하는 키

    • 2. 데이터베이스 설정

      Step 1: 데이터베이스 생성

      Notion에서 새 데이터베이스 생성 (Inline 또는 Full Page)

      Step 2: Integration 연결

    • 데이터베이스 페이지 열기
    • 우측 상단 ...Connections → Integration 추가
    • 생성한 Integration 선택
    • Step 3: 데이터베이스 ID 확인

      URL에서 데이터베이스 ID 추출:

      https://www.notion.so/workspace/1234567890abcdef1234567890abcdef?v=...
      

      └─────────── Database ID ───────────┘


      3. 환경변수 설정

      로컬 개발 환경

      프로젝트 루트에 .env 파일 생성:

      # Notion API
      

      NOTION_API_KEY=secret_xxxxxxxxxxxxxxxxxxxxxxx

      Database IDs

      NOTION_DATABASE_ID_PROJECTS=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

      NOTION_DATABASE_ID_REPORTS=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

      Vercel 배포 환경

    • Vercel 프로젝트 → Settings → Environment Variables
    • 위 변수들을 동일하게 추가
    • Redeploy 실행

    • 4. API 클라이언트 구현

      기본 구조

      // src/lib/notion-client.ts
      
      

      const NOTION_API_KEY = import.meta.env.NOTION_API_KEY;

      const NOTION_VERSION = "2022-06-28";

      // Fetch Helper

      async function fetchNotion(endpoint: string, method = "GET", body?: any) {

      const response = await fetch(https://api.notion.com/v1${endpoint}, {

      method,

      headers: {

      "Authorization": Bearer ${NOTION_API_KEY},

      "Notion-Version": NOTION_VERSION,

      "Content-Type": "application/json",

      },

      body: body ? JSON.stringify(body) : undefined,

      });

      if (!response.ok) {

      throw new Error(Notion API Error: ${response.status});

      }

      return response.json();

      }

      데이터베이스 쿼리

      export async function getProjects() {
      

      const databaseId = import.meta.env.NOTION_DATABASE_ID_PROJECTS;

      const response = await fetchNotion(/databases/${databaseId}/query, "POST", {

      filter: {

      property: "게시여부",

      checkbox: { equals: true },

      },

      sorts: [

      { timestamp: "created_time", direction: "descending" },

      ],

      });

      return response.results.map(page => ({

      id: page.id,

      title: page.properties.제목?.title[0]?.plain_text || "",

      // ... 기타 속성 매핑

      }));

      }

      페이지 콘텐츠 가져오기

      export async function getPageContent(pageId: string) {
      

      const response = await fetchNotion(/blocks/${pageId}/children);

      // 블록 → HTML 변환

      return blocksToHtml(response.results);

      }


      5. 속성 타입별 데이터 추출

      Title

      page.properties.Name?.title[0]?.plain_text || ""

      Rich Text

      page.properties.설명?.rich_text[0]?.plain_text || ""

      Date

      page.properties.Date?.date?.start || ""

      Checkbox

      page.properties.게시여부?.checkbox || false

      Select

      page.properties.Category?.select?.name || ""

      Multi-select

      page.properties.Tags?.multi_select.map(t => t.name) || []

      URL

      page.properties.Link?.url || ""

      6. 블록 타입별 렌더링

      지원 블록 타입

      | 블록 타입 | 설명 | HTML 변환 |

      |-----------|------|-----------|

      | paragraph | 문단 | <p> |

      | heading_1 | 제목 1 | <h1> |

      | heading_2 | 제목 2 | <h2> |

      | heading_3 | 제목 3 | <h3> |

      | bulleted_list_item | 점 목록 | <ul><li> |

      | numbered_list_item | 번호 목록 | <ol><li> |

      | image | 이미지 | <figure><img> |

      | code | 코드 블록 | <pre><code> |

      | quote | 인용 | <blockquote> |

      | divider | 구분선 | <hr> |

      | callout | 콜아웃 | <div> (styled) |

      | toggle | 토글 | <details><summary> |

      | table | 테이블 | <table> |

      | file | 파일 | <a> (다운로드 링크) |

      | bookmark | 북마크 | <a> |

      | embed | 임베드 | <iframe> |

      | video | 비디오 | <a> 또는 <video> |

      중첩 블록 처리

      column_list, toggle, callout 등은 has_children: true 속성을 가지며,

      자식 블록을 별도로 fetch해야 합니다.


      7. 커스텀 도메인 및 SEO

      Astro에서 메타 태그 설정

      ---
      

      // src/layouts/Layout.astro

      const { title, description } = Astro.props;


      <html>

      <head>

      <title>{title}</title>

      <meta name="description" content={description} />

      <meta property="og:title" content={title} />

      <meta property="og:description" content={description} />

      </head>

      <body>

      <slot />

      </body>

      </html>


      8. 캐싱 전략

      SSG (Static Site Generation)

    • npm run build 시 모든 페이지가 정적 HTML로 빌드됨
    • Notion 데이터가 변경되면 재빌드 필요
    • ISR (Incremental Static Regeneration)

      Vercel에서 ISR 활성화:

      // astro.config.mjs
      

      export default defineConfig({

      output: 'hybrid', // 또는 'server'

      });

      Webhook으로 자동 재빌드

    • Notion Automation 또는 Zapier 설정
    • Vercel Deploy Hook URL로 POST 요청

    • 참고 링크

    • Notion API 공식 문서
    • Notion API Reference
    • Astro 공식 문서
    • Vercel 배포 가이드

블로그에 올라가는 게시물은 노션에 Database 페이지를 만들어 노션에 있는 데이터를 그대로 끌고 오는 방법을 선택했다. 이미 노션에 기록물이 많이 있어 일일이 새로운 데이터를 만들기 번거로운 이유가 컸다.

노션 데이터베이스는 API 키를 생성하고 데이터베이스 ID값을 넣기만 하면 연동이 가능했다.

API 사용권한 설정이나 데이터베이스 테이블 값을 맞추는 과정에서 좀 헤맸지만, 결국 연동에 성공했다.

📄 Attached File

Notion API 트러블슈팅 가이드

이 문서는 Astro + Notion 블로그 개발 과정에서 발생한 문제들과 해결 방법을 정리합니다.


1. 블록 콘텐츠가 렌더링되지 않음

증상

  • 프로젝트 상세 페이지에서 일부 텍스트, 이미지가 보이지 않음
  • Notion에서는 보이지만 블로그에서는 누락됨
  • 원인

    Notion API의 /blocks/{block_id}/children 엔드포인트는 1단계 블록만 반환합니다.

    만약 블록이 has_children: true 속성을 가지고 있다면 (예: Column, Toggle, Callout 등), 해당 블록의 자식 콘텐츠는 별도의 API 호출로 가져와야 합니다.

    해결

    재귀적으로 자식 블록을 fetch하는 함수 구현:

    // 블록에 자식이 있으면 재귀적으로 가져오기
    

    async function fetchChildrenHtml(blockId: string): Promise<string> {

    const response = await fetchNotion(/blocks/${blockId}/children, "GET");

    if (response.results && response.results.length > 0) {

    return await blocksToHtml(response.results);

    }

    return "";

    }

    // 각 블록 처리 시 has_children 체크

    if (block.has_children) {

    html += await fetchChildrenHtml(block.id);

    }

    영향받는 블록 타입

  • column_list / column
  • toggle
  • callout
  • synced_block
  • 중첩된 bulleted_list_item / numbered_list_item

  • 2. 번호 목록이 1, 1, 1로 표시됨

    증상

    1. 첫 번째 항목
    
    1. 두 번째 항목
    2. 세 번째 항목

    번호가 증가하지 않고 모두 1로 시작함.

    원인

    기존 코드에서 각 numbered_list_item마다 별도의 <ol> 태그를 생성:

    // 잘못된 코드
    

    case "numbered_list_item":

    html += <ol><li>${text}</li></ol>; // 각각 독립된 <ol>

    break;

    HTML에서 <ol> 태그는 시작할 때마다 번호가 1부터 다시 시작합니다.

    해결

    연속된 목록 항목들을 하나의 <ol> 태그로 그룹핑:

    // 올바른 코드
    

    if (block.type === "numbered_list_item") {

    html += "<ol>";

    while (i < blocks.length && blocks[i].type === "numbered_list_item") {

    const text = blocks[i].numbered_list_item.rich_text.map(t => t.plain_text).join("");

    html += <li>${text}</li>;

    i++;

    }

    html += "</ol>";

    continue;

    }

    동일한 로직이 bulleted_list_item에도 적용됩니다.


    3. 파일/임베드 링크 오류 (attachment://)

    증상

  • 파일 블록이나 임베드 블록에서 링크가 작동하지 않음
  • 브라우저 콘솔에 attachment:// 관련 오류
  • 원인

    Notion 내부에서 드래그 앤 드롭으로 첨부한 파일은 attachment:// 스킴을 사용합니다.

    이 링크는 Notion 앱 내부에서만 유효하며, 외부(API, 웹)에서는 접근할 수 없습니다.

    해결

  • URL 스킴 체크 후 사용자에게 안내 메시지 표시:
  • if (fileUrl.startsWith("attachment:")) {
    

    html += <div class="warning">

    ⚠️ Notion 내부 링크(attachment://)는 블로그에서 열 수 없습니다.

    Notion에서 '/file' 명령어로 파일을 직접 업로드해주세요.

    </div>;

    } else {

    // 정상 처리

    }

  • Notion에서의 올바른 파일 첨부 방법:
  • - /file 또는 /pdf 명령어 사용

    - 파일을 직접 업로드 (드래그 앤 드롭 X)

    - 외부 URL 사용 (Google Drive, S3 등)


    4. Notion 봇 권한 오류

    증상

    Error: Could not find database with ID: xxx

    또는

    Error: Unauthorized

    원인

    Notion API Integration(봇)이 해당 데이터베이스에 접근 권한이 없음.

    해결

  • Notion 페이지/데이터베이스 열기
  • 우측 상단 ... 메뉴 클릭
  • Connections (또는 연결) 클릭
  • 생성한 Integration 추가
  • !Integration 추가

    확인 방법

    curl -X POST 'https://api.notion.com/v1/databases/{database_id}/query' \
    

    -H 'Authorization: Bearer secret_xxx' \

    -H 'Notion-Version: 2022-06-28'


    5. 환경변수 미설정

    증상

  • 개발 환경에서는 작동하지만 Vercel 배포 후 데이터가 안 보임
  • 빈 페이지 또는 오류 메시지
  • 원인

    .env 파일은 .gitignore에 포함되어 있어 Git에 푸시되지 않습니다.

    Vercel에서 별도로 환경변수를 설정해야 합니다.

    해결

  • Vercel 프로젝트 설정 → Settings → Environment Variables
  • 환경변수 추가:
  • - NOTION_API_KEY

    - NOTION_DATABASE_ID_PROJECTS

    - NOTION_DATABASE_ID_REPORTS

  • Redeploy 실행

  • 6. 마크다운 파일 fetch 실패

    증상

    Notion에 첨부된 .md 파일 내용이 블로그에서 보이지 않음.

    원인

  • 파일 URL이 attachment:// 스킴인 경우 (3번 참조)
  • Notion 파일 URL은 시간 제한이 있어 만료될 수 있음
  • 해결

    서버 사이드에서 빌드 시점에 파일 fetch:

    if (fileName.endsWith(".md")) {
    

    try {

    const response = await fetch(fileUrl);

    if (response.ok) {

    const content = await response.text();

    html += <pre>${escapeHtml(content)}</pre>;

    }

    } catch (e) {

    html += <p>⚠️ 파일을 불러올 수 없습니다.</p>;

    }

    }

    ⚠️ 주의: Notion 파일 URL은 약 1시간 후 만료됩니다. 정적 빌드 시 영구 저장이 필요하면 파일을 다운로드하여 저장하거나, 외부 스토리지(S3, Cloudinary 등)를 사용하세요.


    참고 자료

  • Notion API 공식 문서
  • Block Object Reference
  • Astro 공식 문서

API 연동 작업을 하며 겪었던 오류와 해결 과정은 별도 마크다운 문서로 작성해 저장해두었다. 또 노션 데이터베이스를 연동할 일이 있을 때 참고자료로 쓸 예정이다.

© LHM. 2025. ALL RIGHTS RESERVED.
Built with Astro & Notion
LHM Blog 95