
들어가며
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: { "제목": {...}, "태그": {...} }
전체 코드
전체 마이그레이션 스크립트는 아래 구성으로 이루어집니다:
- migrate.js - 기본 폴더/파일 구조 마이그레이션
- add_icons.js - 폴더(📁)/파일(📄) 아이콘 추가
- full_migrate.js - HTML 서식 완전 변환
- add_entries.js - 데이터베이스 엔트리 생성
GitHub에서 전체 코드를 확인할 수 있습니다: [링크]
마치며
Obsidian에서 Notion으로의 마이그레이션은 단순 복사가 아닌, 두 플랫폼의 데이터 구조 차이를 이해하고 변환하는 과정입니다.
핵심 포인트:
- 폴더 → 페이지 계층: Notion은 모든 것이 "페이지"
- 마크다운 → 블록: 각 요소를 해당 블록 타입으로 매핑
- HTML 서식 → Annotations: rich_text의 annotations 활용
- 해시태그 → 데이터베이스: 검색/필터링이 필요하면 데이터베이스 사용
이 가이드가 여러분의 마이그레이션에 도움이 되길 바랍니다!
댓글