이 글은 여기서 더 멋지게 볼 수 있습니다.
블로그 레포지토리는 여기서 확인하실 수 있습니다.
notion
을 통한 기존의 에디터 경험은 살릴 수 있으면서도 블로그 CMS로서 유용한 기능들을 제공할 수 있도록 추가한 다음의 기능들에 대해 공유 해보고자 한다.
이를 구축해놓으면 notion
을 에디터를 넘어서 편리한 블로그 CMS가 될 수 있도록 만들 수 있다.
notion
에 작성한 블로그 글을 데이터로서 활용하기 위해 데이터베이스를 구축하는 작업이다. 여기서는 notion
에서 데이터베이스라는 block
을 활용한다.
이 데이터베이스 블록을 통해 블로그 데이터를 아래와 같이 표의 형태로 만들어낼 수 있다. 구축하려는 사람의 목적마다 다르겠으나 나의 경우에는 다음과 같이 테이블의 컬럼들을 생성하였다.
id
: 게시글 별 고유 IDname
: 게시글 제목description
: 블로그 게시글 요약 문장tags
: 게시글 토픽과 관련한 주제 태그들releasable
: 초안이 아니라 블로그에서 보여줄 수 있는 글인지를 표시하는 데이터featured
: 인기 게시글로 구분되는 글인지를 표시하는 데이터createdAt
: 블로그 게시글 생성일자updatedAt
: 게시글 업데이트 일자thumbnailUrl
: 블로그 게시글 썸네일 이미지prevArticleId
: 이전 게시글로 연결될 게시글 고유 IDnextArticleId
: 다음 게시글로 연결될 게시글 고유 ID여기서 관련된 가이드 내용이 자세히 설명되어 있지만 API
를 통해 데이터를 추출하려면 사전 준비가 필요하다.
로그인 한 뒤 https://www.notion.so/profile/integrations 로 이동해서 API 통합을 추가해주어야 한다.
또한 콘텐츠 기능과 관련한 권한들을 모두 사용할 수 있게 모두 허용해주면 준비가 완료된다. 통합 시크릿 키는 API를 사용해야할때 쓰일 것이다.
notion
API를 직접 활용할수도 있으나 노션에서 공식적으로 제공하는 sdk
(@notionhq/client
) 를 활용하면 보다 쉽게 데이터를 추출할 수 있다.
import { Client } from '@notionhq/client'
export const notion = new Client({
auth: process.env.NOTION_TOKEN, // 아까 통합 생성시 발급되는 시크릿 키
})
우선 앞서 통합을 생성하면서 만들어진 API 시크릿 키를 활용해 다음과 같이 클라이언트 인스턴스를 생성해줘야 한다.
API가 추출할 수 있는 데이터는 크게 두가지로 페이지 자체에 대한 데이터를 추출하는 query
와 데이터베이스의 메타데이터를 추출할 수 있는 retrieve
가 있다. 쉽게 설명할 수 있게 예를 들어 설명하자면,
내가 작성한 모든 게시글들 중 블로그들에 표시될 수 있는 게시글 데이터들을 뽑아오기 위해서 다음과 같이 query
를 사용해볼 수 있다. 아래에서는 releasable
컬럼이 true
값인 게시글들을 모아 createdAt
컬럼을 오름차순으로 정렬하여 뽑아낸다.
// 블로그의 모든 게시글들 뽑아내기
const queryResponse = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID!, // notion에서 참조하고 있는 데이터베이스 ID
filter: {
and: [
{
property: 'releasable',
checkbox: {
equals: true,
},
},
],
},
sorts: [
{
property: 'createdAt',
direction: 'descending',
},
],
})
반면, 내가 아래와 같이 데이터베이스에서 설정한 tag
들의 목록을 뽑아내래면 retrieve
를 활용할 수 있다. retrieve
는 주로 데이터베이스의 입력된 row
들 보단 column
자체와 관련된 정보들을 뽑아낼 때 사용한다.
const metaDataResponse = await notion.databases.retrieve({
database_id: process.env.NOTION_DATABASE_ID!,
}).properties.tags.select_options
노션 API로부터 추출된 데이터를 HTML로 렌더링 하는 방법에는 크게 두가지가 존재했다.
import { NotionAPI } from 'notion-client'
import { NotionRenderer } from 'react-notion-x'
const notion = new NotionAPI()
const recordMap = await notion.getPage('067dd719a912471ea9a3ac10710e7fdf')
<NotionRenderer recordMap={recordMap} fullPage={true} darkMode={false} />
react-notion-x
라는 라이브러리를 활용하면 간단한 코드 몇줄로 마크다운 데이터를 HTML
페이지로 렌더링 해준다. 노션 데이터를 불러오는 것은 notion-client
라는 라이브러리가 대신 수행하며 이 값을 NotionRenderer
컴포넌트에 넘겨주면, 아래와 같은 화면이 완성된다.
노션과 완전히 동일한 화면이 출력되는 것을 볼 수 있다. 이것이 장점이자 단점이 되었다. 나는 보라색 테마의 블로그를 디자인했고 이에 맞게 렌더링 하고 싶었는데 react-notion-x
는 이를 커스텀하기가 다소 어려운 구조였다. 이 때문에 이 방식을 내가 사용하기에는 어려웠다.
두번째 방법은 notion
데이터를 마크다운으로 변환하고 이를 또 한번 HTML
로 변환하는 방식이다. 이는 앞선 react-notion-x
와 다르게 높은 자유도를 제공한다.
우선은 마크다운으로 변환할 수 있도록 하는 notion-to-md
라이브러리를 사용한다.
import { Client } from '@notionhq/client'
import { NotionToMarkdown } from 'notion-to-md'
export const notion = new Client({
auth: process.env.NOTION_SECRET_ID!,
})
export const n2m = new NotionToMarkdown({
notionClient: notion,
})
export const fetchArticleContent = async (pageId: string) => {
const mdBlocks = await n2m.pageToMarkdown(pageId)
return n2m.toMarkdownString(mdBlocks)
}
노션의 sdk
인 @notionhq/client
를 통해 받아온 데이터를 NotionToMarkdown
을 통해 마크다운 문자열 형태로 파싱하는 방식이다.
다음은 파싱된 마크다운을 HTML
로 변환하는 과정이다. 여기서는 마크다운을 리액트 컴포넌트로 렌더링 시켜주는 react-markdown
라이브러리를 활용한다. 앞서 언급했듯, 자유도가 높은 방식이기 때문에 내가 일일이 어떻게 렌더링 해야할지 지정해줘야해서 여러가지 플러그인들과 커스텀 컴포넌트들을 사용해야 한다.
import ReactMarkdown from 'react-markdown'
// 플러그인들
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import remarkToc from 'remark-toc'
import rehypeHighlight from 'rehype-highlight'
import remarkRehype from 'remark-rehype'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import 'highlight.js/styles/base16/dracula.min.css'
const { parent: content } = await fetchArticleContent()
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkToc, remarkRehype]}
rehypePlugins={[
rehypeRaw,
rehypeHighlight,
rehypeSlug,
rehypeAutolinkHeadings,
]}
...
>
{content}
</ReactMarkdown>
플러그인들 부분부터 살펴보자. 우선, 플러그인은 크게 2가지 종류로 나뉘며 각각의 역할이 다르다.
remark
: 마크다운 파싱 역할을 하는 플러그인들table
, todo-list
과 같은 요소를 알맞게 파싱해주는 플러그인rehype
가 호환되게 주어진 마크다운을 html
로 파싱하는 플러그인rehype
: html
로 파싱하는 역할을 하는 플러그인들html
로 파싱하는 역할을 하는 플러그인 (ex - <br/>
<em/>
등을 썻을때 html
과 같은 효과를 내게 공백을 조정)highlight.js
를 활용해 마크다운 내 코드 block
을 알맞은 형태로 변환 해주는 플러그인rehype-slug
와 연계해서 heading
요소에 링크 연결 버튼 같은 것을 생성하도록 해준다.이외에도 수많은 플러그인들이 있으니 용도에 맞게 사용하면 될것이다.
export const Table = ({
className,
...props
}: DetailedHTMLProps<TableHTMLAttributes<HTMLTableElement>, HTMLTableElement>) => {
return <table className={clsx(className, styles.table)} {...props} />
}
<ReactMarkdown
...
components={{
h1: props => <Heading as="h1" {...props} />,
...
hr: Divider,
img: Image,
ul: props => <List as="ul" {...props} />,
ol: props => <List as="ol" {...props} />,
li: ({ children }) => (
<Text as="li" variant="body2" color="textPrimary">
{children}
</Text>
),
a: Anchor,
p: Paragraph,
blockquote: BlockQuote,
code: CodeBlock,
table: Table,
th: Th,
td: Td,
}}
>
{content}
</ReactMarkdown>
다음은 HTML
로 파싱되어 렌더링될때 어떤 스타일링을 입혀 표시할지 커스텀하는 부분이다. 헤더, 이미지, listbox
, anchor
, code
등 렌더링 시 사용되는 많은 html
태그들을 원하는 스타일링이 적용된 컴포넌트로 치환해주어야 한다.
컴포넌트를 넣어주기 전과 후를 비교하면 얼마나 큰 역할을 하는지 체감할 수 있을 것이다.
notion
자체 이미지는 글을 작성하면서 업로드 하기만 바로 연동이 되어 제공이 되기에 매우 편리하다. 하지만, notion
의 업로드하는 이미지는 링크로 제공될때 만료기간이 존재한다.
{
"url": "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/9bc6c6e0-32b8-4d55-8c12-3ae931f43a01/brocolli.jpeg?...",
"expiry_time": "2020-03-17T19:10:04.968Z"
}
실제 노션에 업로드된 이미지를 API를 통해 block
형태로 조회하면 다음과 같이 url
과 만료기간이 함께 제공된다. 그리고 공식문서에 따르면 해당 파일의 공개 URL
은 1시간마다 만료된다. 이 때문에 노션 API로 불러온 이미지 데이터는 1시간마다 만료되어 제대로 된 이미지를 렌더링하지 못한다.
이러한 문제를 해결할 수 있도록 블로그 게시글을 추가하는 과정에서 이미지 블락들의 찾아내어 notion에 호스팅된 이미지 URL을 외부 저장소를 통해 이미지를 업로드 하는 작업이 필요하다.
위와 같은 이미지 변환 작업의 과정들을 정리해보자면 다음과 같다.
notion
API 를 이용해서 페이지 내에 있는 이미지 블록들을 모두 조회하는 과정이다. 이에 앞서 모든 블록들 부터 불러와야 한다.
export const fetchAllBlocksInPage = cache(
async (blockOrPageId: string): Promise<GetBlockResponse[]> => {
let hasMore = true
let nextCursor: string | null = null
const blocks: GetBlockResponse[] = []
// 요청당 불러올 수 있는 블락 응답의 크기가 한정되어 있어 모든 블록들을 불러올때 까지
// notion 페이지의 block들을 불러오는 과정들을 반복해야 한다
while (hasMore) {
const result: ListBlockChildrenResponse = await notion.blocks.children.list({
block_id: blockOrPageId,
start_cursor: nextCursor ?? undefined,
})
blocks.push(...result.results)
hasMore = result.has_more
nextCursor = result.next_cursor
if (hasMore) {
console.log('load more blocks in page...')
}
}
// 블록들 중 자식요소로 있는 블록들을 찾아 이 역시 블러온다
// 예를 들면 toggle block이 있을 것이다.
const childBlocks = await Promise.all(
blocks
.filter(block => 'has_children' in block && block.has_children)
.map(async block => {
const childBlocks = await fetchAllBlocksInPage(block.id)
return childBlocks
}),
)
// 참고로 block들의 순서쌍은 보장되지 않는다
// 어차피 렌더링 할때 사용되는 것은 아니므로 순서쌍이 보장될 필요는 없다
return [...blocks, ...childBlocks.flat()]
},
)
이렇게 모든 블록들을 불러왔으면 이미지 블록들만 간추려야 한다. 이때 block
의 속성 중 type
이 image
에 해당하는 것들만 찾아내면 된다.
export const fetchAllImageBlocksInPage = cache(async (pageId: string) => {
const allBlocks = await fetchAllBlocksInPage(pageId)
return allBlocks.filter(
block => 'type' in block && block.type === 'image',
) as ImageBlockObjectResponse[]
})
이제 notion으로부터 불러온 이미지를 업로드할 차례 이다. 우선 내가 예시로 들어 사용해볼 서비스는 cloudinary
이다. 이를 선택한 이유는 무료로 이미지를 업로드할 수 있으며 손쉽게 사용 가능한 자바스크립트 sdk를 오픈소스로 제공하고 있기 때문이었다.
이를 업로드 하기에 앞서, cloudinary
에 가입하는 절차가 필요하다.
가입하고 나서 위의 이미지 처럼 assets
탭을 선택하고 상단에서 folders
를 선택한 뒤에 원하는 이름으로 폴더를 하나 생성해준다.
또한, 설정 페이지에서 API key를와 API secret 값을 알아와야 한다. 그리고 아래와 같이 cloudinary
를 이용한 이미지 업로드 코드를 작성하면 된다
// 1. notion에 호스팅된 이미지를 base64 형태로 다운로드
// 2. cloudinary에 업로드
import https from 'https'
import { type UploadApiOptions, v2 as cloudinary } from 'cloudinary'
class CloudinaryApi {
// 생성자를 통한 cloundinary sdk config 초기화
constructor() {
// cloundinary url 값
const cloudinaryUrl = process.env.CLOUDINARY_URL!
const urlRegex = /^cloudinary:\/\/([a-z0-9-_]+):([a-z0-9-_]+)@([a-z0-9-_]+)$/i
if (!urlRegex.test(cloudinaryUrl)) {
throw new Error(`Invalid Cloudinary URL provided. It should match ${urlRegex.toString()}`)
}
const [, apiKey, apiSecret, cloudName] = cloudinaryUrl.match(urlRegex) ?? []
cloudinary.config({
secure: true,
api_key: apiKey,
api_secret: apiSecret,
cloud_name: cloudName,
})
}
// 호스팅된 이미지를 다운로드 하여 base64 문자열로 변환
private downloadImageToBase64(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = https.request(url, response => {
const chunks: unknown[] = []
response.on('data', function (chunk) {
chunks.push(chunk)
})
response.on('end', function () {
const result = Buffer.concat(chunks as ReadonlyArray<Uint8Array>)
resolve(result.toString('base64'))
})
})
req.on('error', reject)
req.end()
})
}
// cloudinary uploader를 통해 이미지 업로드
private uploadImage(image: string, options: UploadApiOptions = {}): Promise<{ url: string }> {
return cloudinary.uploader
.upload(image, options)
.then(result => ({
url: result.secure_url,
}))
.catch(error => {
console.error(error)
return { url: '' }
})
}
// notion image를 영구 이미지로 변환
async convertToPermanentImage(notionImageUrl: string, title: string) {
const imgBase64 = await this.downloadImageToBase64(notionImageUrl)
const { url: cloudinaryUrl } = await this.uploadImage(`data:image/jpeg;base64,${imgBase64}`, {
// 앞서 cloudinary에서 생성했던 폴더명
folder: process.env.CLOUDINARY_UPLOAD_FOLDER!,
// 업로드되는 이미지의 제목 제목과
public_id: title.split(' ').join('_').trim(),
overwrite: true,
})
return cloudinaryUrl
}
}
export const cloudinaryApi = new CloudinaryApi()
이러한 이미지 업로드 코드를 게시글 page
를 조회하는 코드와 notion block
을 업데이트 하는 코드를 결합해 사용하면 다음의 코드가 완성된다.
// 주어진 pageId를 기반으로 페이지 내의 모든 이미지 블록들의 이미지 url을 변경하는 함수
export const updateImageBlocks = async (pageId: string) => {
const allImageBlocks = await fetchAllImageBlocksInPage(pageId)
// 모든 이미지 블록들에 대해서 다음의 과정들을 수행
for (const [index, imageBlock] of allImageBlocks.entries()) {
const { image, id: blockId } = imageBlock
// notion에 직접 업로드된 이미지 파일들만 cloudinary에 업로드하여 변환
if ('type' in image && image.type === 'file') {
const convertedImageUrl = await cloudinaryApi.convertToPermanentImage(
(image as FileImageBlock).file.url,
`${pageId}_imageblock_${index + 1}`,
)
// cloudinary에 업로드 된 이미지 url로 이미지 블록들을 업데이트 한다
await notion.blocks.update({
block_id: blockId,
image: {
external: {
url: convertedImageUrl,
},
},
})
}
}
}
참고로, 노션에 직접적으로 호스팅된 이미지는 image.type
값이 file
로 나타나며, 외부 이미지는 external
값을 가진다. 또한 한번 cloudinary
에 업로드 된 이미지는 위의 조건문에 따라 cloudinary로 변환되어 업로드 되는 과정이 발생하지 않게 된다. 이로써 notion
에 업로드된 이미지도 영구적으로 호스팅 될 수 있게 되었다.
정적인 형태로 제공하는 블로그 서비스에서 수정된 내용을 반영하기 위한 조치가 필요하다. 가장 간단하게는 다시 빌드해서 배포하는 방법이 있지만 이는 매우매우 비효율적이다. 내가 사용한 Next.js
프레임워크에서는 여러가지 방식으로 정적 컨텐츠를 업데이트 하는 방법들을 제공해주고 있다.
// app/blog/page.tsx
// 3600초 = 60분마다 업데이트 진행
export const revalidate = 6000
export default function Blog() {
return (
...
)
}
일정 주기마다 정적으로 생성해둔 페이지를 업데이트 하는 방법이다. 위와 같이 페이지 컴포넌트 내에서 revalidate
라는 값을 export
해주면 일정 주기마다 업데이트가 적용된다.
이렇게 일정시간 마다 업데이트 시키면, 컨텐츠가 업데이트 될 뿐만 아니라 앞서 언급한 notion
이미지 만료 문제의 경우, 매번 새로운 노션 이미지로 교체되기 때문에 해결해볼 수 있지 않을까 싶을 수 있다. 하지만 내가 외부 저장소로 이미지를 업로드를 해보기 전에 주기적인 업데이트 방법을 적용해봐도 제대로 이미지 URL이 갱신되지 않아서 간혹 이미지를 불러올때 502
에러가 주기적으로 발생했다.
이 뿐만 아니라 업데이트 된 내용이 실제로는 존재하지 않으면서도 전체적인 페이지 업데이트가 적용되는다는 점이 매우 비효율적이다.
export default async function Page() {
const pageId = '...'
// 페이지에서 불러오는 fetch 함수 호출 부분에 'collection' 태그를 추가해놓는다
const res = await fetch('https://...', { next: { tags: [pageId] } })
const data = await res.json()
// ...
}
// app/api/revalidate/route.ts
// tag 갱신 endpoint -> 여기로 POST 요청을 보내면 페이지 데이터가 갱신된다
import { revalidateTag } from 'next/cache'
export async function POST(request: NextRequest) {
const { pageId } = await request.json()
revalidateTag([pageId])
return Response.json({ revalidated: true })
}
또 다른 방법으로 on-demand revalidation
전략을 제공해주고 있다. 간단하게 서버 컴포넌트에서 불러온 데이터에 tag
를 바인딩하고 필요한 경우 nextjs
의 엔드포인트를 생성해서 해당 태그의 데이터를 갱신해주는 작업을 해줄 수 있다.
하지만 나같은 경우, 블로그 데이터를 불러올때 fetch
가 아닌 notion
의 sdk
를 사용한 데이터를 사용하고 있었다. 이런 경우, fetch
가 아닌 unstable_cache
를 활용하여 데이터를 불러오는 부분을 감싸면 tag
바인딩이 가능하다.
import { unstable_cache } from 'next/cache'
export const fetchArticlePageContent = (pageId: string) => {
const cacheKey = ARTICLE_CONTENT(pageId) // `${pageId}_content`
// unstable_cache
return unstable_cache(
async (pageId: string) => {
await updateImageBlocks(pageId)
const mdBlocks = await n2m.pageToMarkdown(pageId)
return n2m.toMarkdownString(mdBlocks)
},
[cacheKey],
{
tags: [cacheKey],
},
)(pageId)
}
export const ArticleDetailContentSection = async ({ pageId }: ArticleDetailContentSectionProps) => {
const { parent } = await fetchArticlePageContent(pageId)
return <ArticleContentRenderer content={parent as string} />
}
이렇게 바인딩 해놓은 상태로 정적인 배포를 적용하고 업데이트가 필요한 시점에 revalidateTag
를 수행하는 API를 호출하면 페이지 업데이트를 손쉽게 진행할 수 있다.
💡 참고로
revalidateTag
를 실행하면 바로 업데이트가 수행되는 것이 아니라,revalidateTag
를 실행한뒤 페이지 방문이 발생하면 그때서야 업데이트가 수행된다. 자세한 내용은 공식문서를 참고해주기를 바란다.
위의 방식으로 페이지 내용을 업데이트 하는 방법은 마련했지만, 이를 내가 직접 수행해줘야 한다는 번거러움이 있다. 누군가 노션 데이터베이스에서 업데이트 내역을 알아서 확인해서 페이지를 업데이트하는 API를 호출해줄 수 는 없는 것일까? 그것을 해주는 도구가 바로 zapier
이다.
zapier
는 여러가지 웹 앱들(Slack
, Trello
, Google Docs
등)을 통합해 자동화 할 수 있는 여러 기능들을 제공한다. 여기서 notion
통합을 이용하면 우리가 번거롭게 여기던 업데이트 과정을 자동화하도록 만들수 있다. 여기서 내가 만들어낼 작업을 다음과 같다
notion
데이터베이스의 업데이트 내역을 포착한다pageId
를 알아낸다.pageId
로 업데이트 API 요청을 보낸다우선 zapier
에서 zap
이라는걸 생성한뒤 노션의 업데이트를 감지하는 action
을 선택해주어야 한다.
이후에는 파악한 업데이트 내역을 바탕으로 API 호출을 할 수 있도록 하는 코드 액션을 선택해주면 된다. 여기서 아래와 같은 코드를 작성해 주면 된다. 여기서 주의해서 봐야할 것은 inputData
라는 변수는 앞서 액션에서 제공해주는 output
값이다.
// 이전 단계에서 불러온 database item
const item = inputData;
// database item의 releasable 속성이 false인 경우 함수 실행 중단
if (item.releasable === 'False') {
console.log('impossible to revalidate')
output = { revalidated: false, pageId: null }
return;
}
// database item의 releasable 속성이 true인 경우
// 해당 item의 id를 추출하여 api 요청을 보낸다
const pageId = item.pageId;
// GET 요청을 보내기 위한 옵션 설정
const options = {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pageId,
revalidateKey: "xxxxxxxxxxxxx",
})
};
// api 요청을 보내기 위한 url 설정
const url = `https://...../api/revalidate?pageId=${pageId}`;
let responseData = null
try {
// fetch 함수를 사용하여 GET 요청을 보낸다
const res = await fetch(url, options);
// 요청에 대한 응답을 출력한다
responseData = await res.json()
if(responseData.revalidated){
console.log('success to revalidate')
}
else {
console.log('fail to revalidate')
}
}
catch(err) {
console.log(err)
console.log('fail to request')
}
// 응답 결과를 객체로 출력한다
output = { pageId, response: responseData, revalidated: responseData?.revalidated ?? false };
이렇게 자바스크립트 코드를 작성하여 action
을 완성한뒤 zap
을 완성해주면 자동화하는 과정이 완성된다.
간단할 수 있는 블로그 프로젝트이지만, 나의 취향에 맞는 그리고 정말 편리한 블로그를 만들려다보니 여러가지 수고로운 작업들을 수행해야 했다. 하지만, 이러한 작업들 덕분에 나의 편리한 글쓰기 경험을 제공하기 위한 블로그를 완성할 수 있었다. 관련하여 궁금한 점이 있다면 언제든 댓글을 남겨주길 바란다.
잘 보고 갑니다 최근 블로그를 만드는 것에 대해 많은 관심을 갖고 있었는데 많은 인사이트 얻어 갑니다 :)