Next.js ๋ธ๋ก๊ทธ์ Notion ์ฐ๋ํ๊ธฐ
๋์ ๋์ ๊ธ์ ์์ฑํ๊ณ ๋ธ๋ก๊ทธ๋ก ๊ด๋ฆฌํ๋ค ๋ณด๋ฉด ๊ธ ์์ฑ์ ๋ ํธํ๊ฒ ํ๊ณ ์ถ์ ๋ง์์ด ์๊ธฐ๊ณค ํ๋ค.
๊ธฐ์กด์๋ ์ต์๋์ธ์ ์ด์ฉํด์ ๋งํฌ๋ค์ด ํ์ผ์ ์์ฑํ๊ณ Git์ ์ปค๋ฐํ๋ ๋ฐฉ์์ผ๋ก ๊ด๋ฆฌํ๋ค. ์ต์๋์ธ์ ๋ธ๋ก๊ทธ๋ฅผ ์ฒ์ ๋ง๋ค ๋น์๋ง ํ๋๋ผ๋ ์ต์๋์ธ์ ๊ฐ๋ฐ์ ์ฌ์ด์์ ๊ฝค ์ ์๋ฌธ์ ํ๋ ์์นด์ด๋น ๋๊ตฌ์๋ค.
์ด์ ํธ์นํด ๋๋ ์ต์๋์ธ์ ๋ฉ์ธ ์์นด์ด๋น ๋๊ตฌ๋ก ์ฌ์ฉํ์์ผ๋, ์ต์๋์ธ์ ํ์ฉํ๊ธฐ ์ํด์๋ ๋งํฌ๋ค์ด ํ์์ ์ต์ํด์ผํ๊ณ ์์ ๋๊ฐ ๋์ ๋๊ตฌ์ด๋ค ๋ณด๋ ๋์ ํ์๋ฅผ ์ธ์ธํ๊ธฐ ํ๋ฌ๊ทธ์ธ์ ํตํด์ ๋ง์ถฐ๊ฐ์ด์ผ ํ๋ค.
๋จ๋ค์ด ์ข๊ณ , ํธํ๋ค๋ ๋ง์ ์ต์ง๋ก ๋ง์ง๋ ์๋ ์ท์ ์ ๊ณ ์ข๋ค๊ณ , ํธํ๋ค๊ณ ์ต์ง๋ก ์์ ์ง๊ณ ์์๋ค. ๐ (๋ฌผ๋ก , ๋ด๊ฐ ์ ํ์ฉํ์ง ๋ชปํ๊ธด ํ๋ค.)
์ Notion์ ์ ํํ๋
๊ธฐ์กด ๋ธ๋ก๊ทธ๋ MDX ํ์ผ์ ๋ณ๋์ ํด๋๋ก ์ ์ฅํ๊ณ ๊ด๋ฆฌํ๋ค. ๊ทธ๋ ์ง๋ง ์๋์ ๊ฐ์ ๋จ์ ๋ค์ด ์์๋ค.
- ๋ก์ปฌ ํ๊ฒฝ์์๋ง ๊ธ์ ์์ฑํ ์ ์์
- ์ด๋ฏธ์ง ์ ๋ก๋๊ฐ ๋ฒ๊ฑฐ๋ก์
- ์๋ํฐ ๊ธฐ๋ฅ์ด ์ ํ์
- ์ฌ๋ฌ ๊ธฐ๊ธฐ์์ ์์ ํ๊ธฐ ์ด๋ ค์
์ ๋จ์ ์ ํด๊ฒฐํ๊ธฐ ์ํด ๋ค์ Notion์ผ๋ก ๋์์๋ค. ๋ ๋์๊ฐ ๋๋ ๊ธฐ์กด๊ณผ ๊ฐ์ด MDย ํ์ผ๋ก ๊ด๋ฆฌํ๊ธฐ ๋ณด๋ค๋ ์๋ํฐ๋ก ๊ด๋ฆฌํ๊ณ , ์์นด์ด๋น ๋ ์๋ฃ๋ค์ด ๋ธ๋ก๊ทธ์ ์๋์ผ๋ก ์ฌ๋ผ๊ฐ๊ธธ ์ํ๋ค.
ํ์ ๊ธฐ๋ฅ
Notion ์ฐ๋์ ์ํด ๋ค์๊ณผ ๊ฐ์ ๊ธฐ๋ฅ์ด ํ์ํ๋ค.
- Notion โ MDX ๋๊ธฐํ: Notion์์ ์์ฑํ ๊ธ์ MDX ํ์ผ๋ก ๋ณํ
- MDX โ Notion ์ ๋ก๋: ๊ธฐ์กด MDX ํ์ผ์ Notion์ผ๋ก ์ ๋ก๋
- ์๋ ๋น๋ ํตํฉ: ๋น๋ ์ ์๋์ผ๋ก ์ต์ ๊ธ ๋๊ธฐํ
์ ๊ธฐ๋ฅ์ ๊ตฌํํ๊ธฐ ์ํด์ ์ฐ์ ์ ์ผ๋ก ๊ธฐ๋ณธ์ ์ธ ์ค์ ์ด ํ์ํ๋ค.
Notion API ์ค์
๋จผ์ Notion Integration์ ์์ฑํ๊ณ ๋ธ๋ก๊ทธ ๊ธ์ ์์นด์ด๋นํ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ค์ ํ๋ค.
Integration ์์ฑ
- Notion Integrations ํ์ด์ง์์ ์ Integration ์์ฑ
- Internal Integration Token ๋ณต์ฌ

- ๋ฐ์ดํฐ๋ฒ ์ด์ค์ Integration ์ฐ๊ฒฐ

๋ฐ์ดํฐ๋ฒ ์ด์ค ์์ฑ ๊ตฌ์ฑ
๋ธ๋ก๊ทธ ํฌ์คํธ์ ํ์ํ ์์ฑ๋ค์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ถ๊ฐํ๋ค
- Title (Title): ํฌ์คํธ ์ ๋ชฉ
- Date (Date): ๋ฐํ์ผ
- Tags (Multi-select): ํ๊ทธ ๋ชฉ๋ก
- Summary (Text): ์์ฝ
- Draft (Checkbox): ์ด์ ์ฌ๋ถ
- Authors (Multi-select): ์์ฑ์
์๊ฐ๋ณด๋ค ๊ฐ๋จํ๊ฒ ๊ธฐ๋ณธ์ ์ธ ์ค์ ์ ๋ง์ณค๋ค.
3๋จ๊ณ ์คํฌ๋ฆฝํธ ๊ตฌ์ฑ
1. Notion โ MDX ๋๊ธฐํ (sync-notion.mjs)
1๏ธโฃ ํ๊ฒฝ ์ค์ ๋ฐ Notion ํด๋ผ์ด์ธํธ ์ด๊ธฐํ
const NOTION_TOKEN = process.env.NOTION_TOKEN;
const NOTION_DATABASE_ID = process.env.NOTION_DATABASE_ID;
// Notion API ํด๋ผ์ด์ธํธ ์์ฑ
const notion = new Client({ auth: NOTION_TOKEN });
const n2m = new NotionToMarkdown({ notionClient: notion });
2๏ธโฃ Notion ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๊ฒ์๊ธ ์ฟผ๋ฆฌ
// Published = true์ธ ๊ธ๋ง ๊ฐ์ ธ์ค๊ณ , Date ๊ธฐ์ค ๋ด๋ฆผ์ฐจ์ ์ ๋ ฌ
const queryOptions = {
database_id: NOTION_DATABASE_ID,
filter: {
property: 'Published',
checkbox: { equals: true },
},
sorts: [
{
property: 'Date',
direction: 'descending',
},
],
};
const response = await fetch(
`https://api.notion.com/v1/databases/${NOTION_DATABASE_ID}/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${NOTION_TOKEN}`,
'Notion-Version': '2022-06-28',
},
body: JSON.stringify(queryOptions),
}
);
3๏ธโฃ Notion ์์ฑ๊ฐ ์ถ์ถ
function getPropertyValue(page, propertyName) {
const property = page.properties[propertyName];
switch (property.type) {
case 'title':
return property.title.map((t) => t.plain_text).join('');
case 'multi_select':
return property.multi_select.map((s) => s.name);
case 'date':
return property.date?.start || null;
case 'checkbox':
return property.checkbox;
case 'files':
return property.files.map((f) => f.file?.url || f.external?.url);
default:
return null;
}
}
4๏ธโฃ Notion ํ์ด์ง๋ฅผ ๋งํฌ๋ค์ด์ผ๋ก ๋ณํ
const mdBlocks = await n2m.pageToMarkdown(page.id);
const mdString = n2m.toMarkdownString(mdBlocks);
const markdownContent = mdString.parent || mdString || '';
5๏ธโฃ ์ด๋ฏธ์ง ์๋ ๋ค์ด๋ก๋ ๋ฐ ๊ฒฝ๋ก ์นํ
async function downloadAndReplaceImages(content, slug, imagesDir) {
// Notion S3 URL ์ฐพ๊ธฐ
const imageUrls = extractAllImageUrls(content);
const notionImages = imageUrls.filter((img) => isNotionS3Url(img.url));
// ๊ฐ ์ด๋ฏธ์ง ๋ค์ด๋ก๋
for (let i = 0; i < notionImages.length; i++) {
const fileName = generateImageFileName(url, i); // MD5 ๊ธฐ๋ฐ ํด์
const localPath = join(postImageDir, fileName);
const publicPath = `/images/blog/${slug}/${fileName}`;
// ์๋ณธ URL โ ๋ก์ปฌ ๊ฒฝ๋ก๋ก ์นํ
//  โ 
updatedContent = updatedContent.replace(
fullMatch,
``
);
}
}
6๏ธโฃ MDX ํ๋ก ํธ๋งคํฐ ์์ฑ ๋ฐ ํ์ผ ์ ์ฅ
const frontmatterLines = [
`title: '${title}'`,
`date: '${formatDate(date)}'`,
`tags: ${JSON.stringify(tags)}`,
`draft: ${draft}`,
`summary: '${summary}'`,
`images: ${JSON.stringify([downloadedImage])}`,
`authors: ${JSON.stringify(authors)}`,
];
const mdxContent = `---\n${frontmatterLines.join('\n')}\n---\n\n${processedContent}`;
writeFileSync(`/src/content/blog/${slug}.mdx`, mdxContent);
- MDX โ Notion ์ ๋ก๋ (upload-to-notion.mjs)
1๏ธโฃ ๊ธฐ์กด Notion ํ์ด์ง ๋ชฉ๋ก ์กฐํ
const existingPagesResponse = await fetch(
`https://api.notion.com/v1/databases/${NOTION_DATABASE_ID}/query`,
{
method: 'POST',
body: JSON.stringify({ page_size: 100 }),
}
);
const existingPages = await existingPagesResponse.json();
const existingTitles = new Set();
// ๊ธฐ์กด ์ ๋ชฉ์ Set์ ์ ์ฅ (์ค๋ณต ์ฒดํฌ์ฉ)
for (const page of existingPages.results) {
const pageDetails = await notion.pages.retrieve({ page_id: page.id });
const title =
pageDetails.properties?.Title?.title?.[0]?.plain_text || '';
existingTitles.add(title.toLowerCase().trim());
}
2๏ธโฃ MDX ํ์ผ ์ฝ๊ธฐ ๋ฐ ํ๋ก ํธ๋งคํฐ ํ์ฑ
import matter from 'gray-matter';
const fileContent = readFileSync(file.path, 'utf-8');
const { data: frontmatter, content } = matter(fileContent);
// ์ถ์ถ๋๋ ๋ฐ์ดํฐ:
// frontmatter = { title, date, tags, summary, draft, authors, ... }
// content = ์ค์ ๋งํฌ๋ค์ด ๋ณธ๋ฌธ
3๏ธโฃ ๋งํฌ๋ค์ด์ Notion ๋ธ๋ก์ผ๋ก ๋ณํ
function markdownToNotionBlocks(markdown) {
const blocks = [];
const lines = markdown.split('\n');
let inCodeBlock = false;
let codeLanguage = 'plain text';
let codeContent = [];
for (const line of lines) {
if (line.startsWith('```')) {
// ์ฝ๋๋ธ๋ก ์์/์ข
๋ฃ
if (inCodeBlock) {
blocks.push({
object: 'block',
type: 'code',
code: {
rich_text: [
{ type: 'text', text: { content: codeContent.join('\n') } },
],
language: normalizeLanguage(codeLanguage),
},
});
inCodeBlock = false;
codeContent = [];
} else {
codeLanguage = line.substring(3).trim();
inCodeBlock = true;
}
} else if (inCodeBlock) {
codeContent.push(line);
} else if (line.startsWith('# ')) {
// H1 โ heading_1
blocks.push({
object: 'block',
type: 'heading_1',
heading_1: {
rich_text: [
{ type: 'text', text: { content: line.substring(2) } },
],
},
});
} else if (line.startsWith('- ') || line.startsWith('* ')) {
// ๋ฆฌ์คํธ โ bulleted_list_item
blocks.push({
object: 'block',
type: 'bulleted_list_item',
bulleted_list_item: {
rich_text: [
{ type: 'text', text: { content: line.substring(2) } },
],
},
});
} else {
// ์ผ๋ฐ ํ
์คํธ โ paragraph
blocks.push({
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: line } }],
},
});
}
}
return blocks;
}
4๏ธโฃ Notion ํ์ด์ง ์์ฑ ๋งคํ
function createPropertyValue(type, value) {
switch (type) {
case 'title':
return { title: [{ text: { content: value } }] };
case 'date':
return { date: { start: value } };
case 'multi_select':
return { multi_select: value.map((item) => ({ name: item })) };
case 'checkbox':
return { checkbox: value || false };
case 'rich_text':
return { rich_text: [{ text: { content: value || '' } }] };
}
}
// ํ๋ก ํธ๋งคํฐ ๋ฐ์ดํฐ โ Notion ์์ฑ์ผ๋ก ๋ณํ
const properties = {};
properties.Title = createPropertyValue('title', frontmatter.title);
properties.Date = createPropertyValue('date', frontmatter.date);
properties.Tags = createPropertyValue('multi_select', frontmatter.tags);
properties.Draft = createPropertyValue('checkbox', frontmatter.draft);
properties.Authors = createPropertyValue('multi_select', frontmatter.authors);
5๏ธโฃ Notion ํ์ด์ง ์์ฑ (๋ธ๋ก์ 100๊ฐ์ฉ ๋ถํ )
// 100๊ฐ์ฉ ์ฒญํฌ๋ก ๋๋๊ธฐ
const maxBlocksPerRequest = 100;
const blockChunks = [];
for (let i = 0; i < allBlocks.length; i += maxBlocksPerRequest) {
blockChunks.push(allBlocks.slice(i, i + maxBlocksPerRequest));
}
// ์ฒซ๋ฒ์งธ ์ฒญํฌ๋ก ํ์ด์ง ์์ฑ
const response = await fetch('https://api.notion.com/v1/pages', {
method: 'POST',
headers: { Authorization: `Bearer ${NOTION_TOKEN}` },
body: JSON.stringify({
parent: { database_id: NOTION_DATABASE_ID },
properties: properties,
children: blockChunks[0], // ์ฒซ 100๊ฐ ๋ธ๋ก
}),
});
const page = await response.json();
const pageId = page.id;
// ๋๋จธ์ง ์ฒญํฌ๋ฅผ ํ์ด์ง์ ์ถ๊ฐ
for (let i = 1; i < blockChunks.length; i++) {
await fetch(`https://api.notion.com/v1/blocks/${pageId}/children`, {
method: 'PATCH',
body: JSON.stringify({ children: blockChunks[i] }),
});
}
3. ์๋ ๋น๋ ํตํฉ
๋น๋ํ ๋ ์๋์ผ๋ก Notion ๋๊ธฐํ๋ฅผ ๋จผ์ ์คํํ๋ฏ๋ก, ํญ์ ์ต์ ๊ธ๋ก ๋ฐฐํฌ๋ฉ๋๋ค.
package.json์ build ์คํฌ๋ฆฝํธ
{
"scripts": {
"dev": "astro dev",
"sync-notion": "node ./scripts/sync-notion.mjs",
"upload-to-notion": "node ./scripts/upload-to-notion.mjs",
"build": "npm run sync-notion && astro check && astro build"
}
}
ํ๊ฒฝ ๋ณ์
# .env.local ํ์ผ ์์ฑ
NOTION_TOKEN=ntn_your_token_here
NOTION_DATABASE_ID=your_database_id
Vercel
ํ๋ก์ ํธ ์ค์ โ Environment Variables์์ NOTION_TOKEN๊ณผ NOTION_DATABASE_ID๋ฅผ ๋ฑ๋กํฉ๋๋ค.
์ฒซ ๋ฒ์งธ ์ด์: ์ฝ๋ ๋ธ๋ก ์ธ์ด ํ์
MDX ํ์ผ์ ์ฝ๋ ๋ธ๋ก์ Notion์ผ๋ก ๋ณํํ ๋ ์ธ์ด ์ฝ๋๊ฐ Notion์ด ์ง์ํ๋ ํ์๊ณผ ๋ง์ง ์์์ต๋๋ค. ์๋ฅผ ๋ค์ด:
jsโjavascript๋ก ๋ณํ ํ์ts:file.tsโ ํ์ผ๋ช ์ ๊ฑฐํ๊ณtypescript๋ก ๋ณํtexโlatex๋ก ๋ณํ- ์ง์ํ์ง ์๋ ์ธ์ด โ
plain text๋ก ํด๋ฐฑ
ํด๊ฒฐ ๋ฐฉ๋ฒ
์ธ์ด ์ฝ๋๋ฅผ ์ ๊ทํํ๋ ํจ์๋ฅผ ๋ง๋ค์์ต๋๋ค:
function normalizeLanguage(lang) {
if (!lang) return 'plain text'
// ํ์ผ๋ช
ํ์ ์ ๊ฑฐ
lang = lang.split(':')[0].split('.')[0].toLowerCase().trim()
// ์ธ์ด ๋งคํ
const languageMap = {
'js': 'javascript',
'ts': 'typescript',
'py': 'python',
'tex': 'latex',
// ... ๋ ๋ง์ ๋งคํ
}
const normalized = languageMap[lang] || lang
// Notion์ด ์ง์ํ๋ ์ธ์ด์ธ์ง ํ์ธ
const supportedLanguages = ['javascript', 'typescript', 'python', ...]
return supportedLanguages.includes(normalized) ? normalized : 'plain text'
}
๋ ๋ฒ์งธ ์ด์: ๋ธ๋ก ๊ฐ์ ์ ํ
Notion API๋ ํ ๋ฒ์ ์ต๋ 100๊ฐ์ ๋ธ๋ก๋ง ์์ฑํ ์ ์์ต๋๋ค. ๊ธด ๊ธ์ ๊ฒฝ์ฐ ์ด ์ ํ์ ์ด๊ณผํ ์ ์์ต๋๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ
๋ธ๋ก์ 100๊ฐ์ฉ ๋๋ ์ ์ฒ๋ฆฌํ์ต๋๋ค:
const maxBlocksPerRequest = 100
const blockChunks = []
for (let i = 0; i < allBlocks.length; i += maxBlocksPerRequest) {
blockChunks.push(allBlocks.slice(i, i + maxBlocksPerRequest))
}
// ์ฒซ ๋ฒ์งธ ์ฒญํฌ๋ ํ์ด์ง ์์ฑ ์
const response = await fetch(`https://api.notion.com/v1/pages`, {
// ... ์ฒซ ๋ฒ์งธ ์ฒญํฌ ํฌํจ
})
// ๋๋จธ์ง ์ฒญํฌ๋ append_block_children API๋ก ์ถ๊ฐ
for (let i = 1; i < blockChunks.length; i++) {
await fetch(`https://api.notion.com/v1/blocks/${pageId}/children`, {
method: 'PATCH',
// ... ๋๋จธ์ง ์ฒญํฌ ์ถ๊ฐ
})
}
์ธ ๋ฒ์งธ ์ด์: ์ค๋ณต ์ ๋ก๋ ๋ฐฉ์ง
๊ธฐ์กด MDX ํ์ผ์ Notion์ผ๋ก ์ ๋ก๋ํ ๋, ๊ฐ์ ์ ๋ชฉ์ ํฌ์คํธ๊ฐ ์ด๋ฏธ ์กด์ฌํ๋ฉด ์ค๋ณต์ผ๋ก ์์ฑ๋๋ ๋ฌธ์ ๊ฐ ์์์ต๋๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ
์ ๋ก๋ ์ ์ ๊ธฐ์กด ํฌ์คํธ์ ์ ๋ชฉ์ ๋ฏธ๋ฆฌ ์กฐํํด์ ์ค๋ณต์ ์ฒดํฌํ์ต๋๋ค:
// ๊ธฐ์กด ํฌ์คํธ ์ ๋ชฉ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ
const existingPagesResponse = await fetch(/* ... */)
const existingTitles = new Set()
for (const page of existingPages.results) {
const title = page.properties?.Title?.title?.[0]?.plain_text || ''
if (title) {
existingTitles.add(title.toLowerCase().trim())
}
}
// ์
๋ก๋ ์ ์ค๋ณต ์ฒดํฌ
if (existingTitles.has(titleKey)) {
console.log(`โญ๏ธ "${title}" ์ ๋ชฉ์ด ์ด๋ฏธ ์กด์ฌํ์ฌ ๊ฑด๋๋๋๋ค.`)
continue
}