본문 바로가기
일상 & 여행

Obsidian에서 Notion으로 완벽하게 마이그레이션하기

by prographer J 2025. 12. 28.
728x90

들어가며

Obsidian은 로컬 마크다운 기반의 강력한 노트 앱이지만, 협업이나 모바일 접근성 면에서 Notion의 장점이 필요할 때가 있습니다. 이 글에서는 323개의 마크다운 파일로 구성된 Obsidian Vault를 Notion으로 완벽하게 마이그레이션한 과정을 공유합니다.

마이그레이션 목표

  • PARA 폴더 구조 완벽 보존
  • 마크다운 서식 (헤딩, 리스트, 인용문 등) 변환
  • HTML 태그 (<font color>, <span>, <u>) → Notion 서식 변환
  • 각주 → Callout 블록 변환
  • 해시태그 → 데이터베이스 Multi-select 속성으로 검색 가능하게

1단계: 기본 구조 마이그레이션

Notion API 설정

먼저 Notion Integrations에서 새 Integration을 생성하고 API 키를 발급받습니다.

const { Client } = require("@notionhq/client");

const NOTION_KEY = "your_notion_api_key";
const ROOT_PAGE_ID = "your_root_page_id"; // 마이그레이션할 상위 페이지

const notion = new Client({ auth: NOTION_KEY });

폴더 구조를 페이지 계층으로 변환

Obsidian의 폴더는 Notion의 하위 페이지(child_page)로 변환됩니다.

// 폴더 → Notion 페이지 생성
async function createFolderPage(parentId, folderName) {
  const response = await notion.pages.create({
    parent: { page_id: parentId },
    icon: { type: "emoji", emoji: "📁" },
    properties: {
      title: [{ type: "text", text: { content: folderName } }],
    },
  });
  return response.id;
}

// 마크다운 파일 → Notion 페이지 생성
async function createFilePage(parentId, fileName, blocks) {
  await notion.pages.create({
    parent: { page_id: parentId },
    icon: { type: "emoji", emoji: "📄" },
    properties: {
      title: [{ type: "text", text: { content: fileName } }],
    },
    children: blocks,
  });
}

재귀적 폴더 스캔

async function migrateFolder(localPath, notionParentId) {
  const items = fs.readdirSync(localPath);

  for (const item of items) {
    const fullPath = path.join(localPath, item);
    const stat = fs.statSync(fullPath);

    if (stat.isDirectory()) {
      // 폴더 → 하위 페이지 생성 후 재귀
      const pageId = await createFolderPage(notionParentId, item);
      await migrateFolder(fullPath, pageId);
    } else if (item.endsWith(".md")) {
      // 마크다운 파일 → 페이지 생성
      const content = fs.readFileSync(fullPath, "utf-8");
      const blocks = markdownToBlocks(content);
      await createFilePage(notionParentId, item.replace(".md", ""), blocks);
    }

    await sleep(350); // Rate limiting
  }
}

2단계: 마크다운 → Notion 블록 변환

Notion API는 콘텐츠를 "블록" 단위로 처리합니다. 마크다운의 각 요소를 해당하는 Notion 블록 타입으로 변환해야 합니다.

블록 타입 매핑

마크다운 Notion 블록 타입
# 제목 heading_1
## 제목 heading_2
### 제목 heading_3
- 항목 bulleted_list_item
1. 항목 numbered_list_item
> 인용 quote
- [ ] 할일 to_do
--- divider
일반 텍스트 paragraph

변환 코드

function markdownToBlocks(content) {
  const blocks = [];

  // YAML frontmatter 제거
  content = content.replace(/^---\n[\s\S]*?\n---\n?/, "");

  const lines = content.split("\n");

  for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed) continue;

    // Heading 1
    if (/^# /.test(line)) {
      blocks.push({
        object: "block",
        type: "heading_1",
        heading_1: {
          rich_text: [{ type: "text", text: { content: line.slice(2) } }]
        },
      });
    }
    // Heading 2
    else if (/^## /.test(line)) {
      blocks.push({
        object: "block",
        type: "heading_2",
        heading_2: {
          rich_text: [{ type: "text", text: { content: line.slice(3) } }]
        },
      });
    }
    // Bullet list
    else if (/^[-*]\s+/.test(line)) {
      blocks.push({
        object: "block",
        type: "bulleted_list_item",
        bulleted_list_item: {
          rich_text: [{ type: "text", text: { content: line.replace(/^[-*]\s+/, "") } }]
        },
      });
    }
    // Quote
    else if (/^>\s*/.test(line)) {
      blocks.push({
        object: "block",
        type: "quote",
        quote: {
          rich_text: [{ type: "text", text: { content: line.slice(2) } }]
        },
      });
    }
    // Paragraph
    else {
      blocks.push({
        object: "block",
        type: "paragraph",
        paragraph: {
          rich_text: [{ type: "text", text: { content: trimmed } }]
        },
      });
    }
  }

  return blocks.slice(0, 100); // Notion API 제한: 요청당 최대 100블록
}

3단계: HTML 서식 → Notion 텍스트 스타일 변환

Obsidian에서 <font color>, <span style="background"> 같은 HTML 태그를 사용했다면, 이를 Notion의 rich_text annotations으로 변환해야 합니다.

색상 매핑 (다크 테마 → 라이트 테마)

Obsidian은 어두운 배경, Notion은 밝은 배경이므로 색상 조정이 필요합니다.

const TEXT_COLOR_MAP = {
  "#ffff00": "orange",  // 노란색 → 주황색 (흰 배경에서 가독성)
  "#ffc000": "orange",
  "#ff0000": "red",
  "#00ff00": "green",
  "#00b0f0": "blue",
  "#ff00ff": "purple",
};

const BG_COLOR_MAP = {
  "#fff88f": "yellow_background",
  "#d3f8b6": "green_background",
};

Rich Text 파싱

function parseRichText(text) {
  const segments = [];
  let remaining = text;

  while (remaining.length > 0) {
    // <font color="...">...</font> 찾기
    const fontMatch = remaining.match(/<font\s+color=["']([^"']+)["']>([^<]*)<\/font>/i);

    if (fontMatch && fontMatch.index === 0) {
      const color = fontMatch[1].toLowerCase();
      segments.push({
        type: "text",
        text: { content: fontMatch[2] },
        annotations: { color: TEXT_COLOR_MAP[color] || "default" },
      });
      remaining = remaining.slice(fontMatch[0].length);
    }
    // **bold** 찾기
    else if (remaining.startsWith("**")) {
      const endIdx = remaining.indexOf("**", 2);
      if (endIdx > 0) {
        segments.push({
          type: "text",
          text: { content: remaining.slice(2, endIdx) },
          annotations: { bold: true },
        });
        remaining = remaining.slice(endIdx + 2);
      }
    }
    // 일반 텍스트
    else {
      // 다음 특수 패턴까지의 텍스트 추출
      const nextSpecial = remaining.search(/<font|<span|\*\*/);
      const endIdx = nextSpecial > 0 ? nextSpecial : remaining.length;
      segments.push({
        type: "text",
        text: { content: remaining.slice(0, endIdx) },
      });
      remaining = remaining.slice(endIdx);
    }
  }

  return segments;
}

4단계: 각주 → Callout 블록 변환

Obsidian의 각주 문법 [^1]은 Notion에 직접 대응되지 않습니다. Callout 블록으로 변환하면 시각적으로 구분되면서도 정보를 보존할 수 있습니다.

function extractFootnotes(content) {
  const footnotes = {};

  // [^name]: 정의 내용 패턴 찾기
  const pattern = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
  let match;

  while ((match = pattern.exec(content)) !== null) {
    footnotes[match[1]] = match[2];
  }

  return footnotes;
}

function footnotesToCallouts(footnotes) {
  const blocks = [];

  // 구분선
  blocks.push({ object: "block", type: "divider", divider: {} });

  // 각주 헤딩
  blocks.push({
    object: "block",
    type: "heading_3",
    heading_3: {
      rich_text: [{ type: "text", text: { content: "📝 각주" } }]
    },
  });

  // 각 각주를 Callout으로
  for (const [key, value] of Object.entries(footnotes)) {
    blocks.push({
      object: "block",
      type: "callout",
      callout: {
        icon: { type: "emoji", emoji: "💡" },
        rich_text: [{
          type: "text",
          text: { content: `[${key}]: ${value}` }
        }],
        color: "gray_background",
      },
    });
  }

  return blocks;
}

5단계: 해시태그 → 데이터베이스 검색 기능

Obsidian의 해시태그 #투자, #기법 등을 Notion에서도 검색 가능하게 하려면 데이터베이스를 활용해야 합니다.

데이터베이스 생성

// 데이터베이스 생성 (curl 사용 - Node.js 클라이언트 버그 우회)
const dbPayload = {
  parent: { type: "page_id", page_id: ROOT_PAGE_ID },
  icon: { type: "emoji", emoji: "📊" },
  title: [{ type: "text", text: { content: "투자 기법 Database" } }],
  properties: {
    "Name": { title: {} },
    "Tags": {
      multi_select: {
        options: [
          { name: "기법", color: "blue" },
          { name: "투자", color: "yellow" },
          { name: "용어", color: "gray" },
        ],
      },
    },
    "Category": {
      select: {
        options: [
          { name: "기법", color: "blue" },
          { name: "용어", color: "gray" },
        ],
      },
    },
  },
};

태그 추출

function extractTags(content) {
  const tags = [];

  // YAML frontmatter에서 태그 추출
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
  if (frontmatterMatch) {
    const tagLines = frontmatterMatch[1].match(/tags:\n((?:\s+-\s+.+\n?)+)/);
    if (tagLines) {
      const tagMatches = tagLines[1].matchAll(/\s+-\s+(.+)/g);
      for (const match of tagMatches) {
        tags.push(match[1].trim());
      }
    }
  }

  // 본문의 인라인 해시태그 추출
  const inlineTags = content.match(/#([가-힣a-zA-Z0-9_]+)/g);
  if (inlineTags) {
    for (const tag of inlineTags) {
      const cleanTag = tag.slice(1); // # 제거
      if (!tags.includes(cleanTag)) {
        tags.push(cleanTag);
      }
    }
  }

  return tags;
}

데이터베이스 엔트리 생성

async function createDatabaseEntry(databaseId, file) {
  const content = fs.readFileSync(file.path, "utf-8");
  const tags = extractTags(content);
  const blocks = contentToBlocks(content);

  await notion.pages.create({
    parent: { database_id: databaseId },
    icon: { type: "emoji", emoji: "📈" },
    properties: {
      "Name": {
        title: [{ type: "text", text: { content: file.name } }],
      },
      "Tags": {
        multi_select: tags.map(tag => ({ name: tag })),
      },
      "Category": {
        select: { name: file.category },
      },
    },
    children: blocks,
  });
}

마이그레이션 결과

통계

항목 수량
총 마이그레이션 파일 229개
HTML 서식 변환 파일 54개
데이터베이스 엔트리 28개
실패 2개 (페이지 미발견)

변환 전후 비교

Obsidian (원본)

## <font color="#ffff00">중요한 개념</font>

이것은 **핵심 내용**입니다.[^1]

[^1]: 각주 설명

Notion (변환 후)

  • 헤딩 2 + 주황색 텍스트
  • 볼드 서식 적용된 문단
  • 💡 Callout 블록으로 각주 표시

트러블슈팅

1. Notion API Rate Limiting

Notion API는 초당 3회 요청 제한이 있습니다. 각 요청 사이에 딜레이를 추가해야 합니다.

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 사용
await createPage(...);
await sleep(350); // 350ms 대기

2. SSL 인증서 오류

macOS에서 Node.js 실행 시 SSL 오류가 발생할 수 있습니다.

NODE_TLS_REJECT_UNAUTHORIZED=0 node migrate.js

3. 블록 제한 (100개)

Notion API는 한 번에 최대 100개 블록만 추가할 수 있습니다. 긴 문서는 분할 처리가 필요합니다.

const blocks = markdownToBlocks(content);
return blocks.slice(0, 100);

4. 한글 속성명 문제

Notion API에서 한글 속성명이 제대로 인식되지 않는 경우가 있습니다. 영문 속성명 사용을 권장합니다.

// 권장
properties: { "Name": {...}, "Tags": {...} }

// 비권장 (문제 발생 가능)
properties: { "제목": {...}, "태그": {...} }

전체 코드

전체 마이그레이션 스크립트는 아래 구성으로 이루어집니다:

  1. migrate.js - 기본 폴더/파일 구조 마이그레이션
  2. add_icons.js - 폴더(📁)/파일(📄) 아이콘 추가
  3. full_migrate.js - HTML 서식 완전 변환
  4. add_entries.js - 데이터베이스 엔트리 생성

GitHub에서 전체 코드를 확인할 수 있습니다: [링크]


마치며

Obsidian에서 Notion으로의 마이그레이션은 단순 복사가 아닌, 두 플랫폼의 데이터 구조 차이를 이해하고 변환하는 과정입니다.

핵심 포인트:

  • 폴더 → 페이지 계층: Notion은 모든 것이 "페이지"
  • 마크다운 → 블록: 각 요소를 해당 블록 타입으로 매핑
  • HTML 서식 → Annotations: rich_text의 annotations 활용
  • 해시태그 → 데이터베이스: 검색/필터링이 필요하면 데이터베이스 사용

이 가이드가 여러분의 마이그레이션에 도움이 되길 바랍니다!

 

반응형

댓글