나는 노션으로 개발 블로그를 운영한다
kidow
@kidow
태그
Notion
Nextjs
App Router
설명
React 개발자들에게는 희소식일겁니다. DB, 저장소 필요없습니다.
배포
배포
수정일
Nov 15, 2023 04:13 PM
생성일
Aug 30, 2023 03:45 AM
웹 개발자가 개발 블로그를 직접 만들어 운영하는 것은 낭만적인 일입니다. 그 것이 자기가 밥 벌어먹고 사는 능력으로 만드는 것이니까요.
다만 제 경험상 블로그를 구축하는 과정이 생각보다 쉽지 않고, 또 만들고 나서도 운영하기가 여간 번거로운 것이 아닌데요, 개발 블로그 글들을 직접 관리하는 방식은 보통 두 가지 경우로 나뉘게 됩니다.
- 블로그 글 데이터를 데이터베이스와 스토리지까지 구축해서 직접 저장하고 불러온다.
- 마크다운(.md)을 사용하여 파일 시스템을 구축해서 관리한다.
전자의 경우는 솔루션을 통해 데이터베이스를 빌려오는 경우일텐데요, 이 경우는 시작부터 피곤해지게 될 염려가 있습니다. 데이터베이스 지식을 어느 정도는 알아야 하는 것은 물론이며, 가장 큰 문제는 솔루션이 영구 무료가 아니기 때문에 용량이 찬다면 어느 수준부터는 돈을 내야한다는 것입니다.
후자의 경우 대표적인 예시로 Github Pages나 GatsbyJS가 있는데요, 전자에 비해서 돈이 들지 않는다는 점이 큰 장점이지만 마크다운에 익숙해질 필요가 있고 콘텐츠를 작성할 때마다 배포를 매번 해야한다는 피로감이 여전히 존재합니다.
저는 두 케이스를 모두 겪어보았던 사람인데요, 어느 순간 제 3의 새로운 방식이 있다는 것을 알게 되고 무릎을 탁 쳤던 경험을 공유해보려고 합니다.
노션 블로그의 장점
이제 우리에겐 너무 익숙한 생산성 앱인 Notion입니다. 만약 노션의 강력한 인프라를 이용하여 개발 블로그를 운영할 수 있다면 어떨까요? 저는 이미 이 글을 노션을 통해 작성하고 있습니다. 스크롤 하단에 있는 댓글 버튼을 누르시면 본래의 노션 페이지로 이동하실 수 있습니다. 노션으로 블로그를 운영하게 된다면 다음과 같은 장점들을 가져갈 수 있습니다.
- 개인 사용자는 무제한으로 페이지와 블록을 생성할 수 있기에 평생 무료다. 이미지 업로드 역시 마찬가지.
- 노션에 내장된 기능들을 그대로 활용하여 글 작성이 가능하다.
- 웹, 앱, 데스크탑 앱을 모두 지원하기 때문에 그냥 글만 작성하면 된다.
구축하는 방법
이렇게 좋은 노션 개발 블로그를 어떻게 하면 만들 수 있는 지 지금부터 다뤄보겠습니다. 개요는 이렇습니다.
- 노션 데이터베이스 생성
- 노션 API KEY 생성
- 노션 데이터베이스와 노션 API 연결
- 필요한 NPM 라이브러리 다운
- 적용
참고로 프론트엔드는 Next.js App Router를 기준으로 작성합니다.
1. 노션 데이터베이스 생성
여러분의 노션 페이지 아무 곳에서 다음과 같이 “데이터베이스 - 전체 페이지”를 생성합니다. 이 데이터베이스가 여러분들의 블로그 글들을 저장하는 창고가 될 겁니다.
아무데서나 생성해도 어차피 페이지를 이동시킬 수 있으니 상관없습니다. 이제 생성된 데이터베이스의 속성들을 간단하게 꾸며봅시다.
실제 제 블로그 데이터베이스의 속성들입니다. SEO를 염두하여 설계한 것인데요,
- 설명(텍스트): seo의 description
- 태그(다중 선택): seo의 keywords
- 배포(체크박스): 제일 중요. 작성이 완료된 콘텐츠만 외부에 노출시키도록 차후에 프로그래밍
- 생성일(생성 일시): 데이터 생성 시 자동으로 추가되는 값입니다. 선택이지만 보통 블로그에서는 작성일자를 보여주는 경우가 대부분입니다.
- 수정일(최종 편집 일시): 선택입니다. 언제 이 글을 마지막에 수정했는지 알 수 있습니다.
이 외에도 여러분들의 기호에 맞게 자유롭게 속성을 추가하실 수 있습니다. 이러한 속성들은 API를 통해 모두 가져올 수 있습니다.
2. 노션 API KEY 생성
이제 이 데이터베이스 데이터들을 불러오기 위한 API 작업이 필요합니다. 내 API 통합에 들어갑니다.
새 API 통합을 누릅니다.
이름을 짓고 로고를 업로드한 뒤 제출합니다.
노션 블로그를 구축할 때 필요한 환경 변수가 있습니다. 여기서 프라이빗 API 통합 시크릿 키를 따로 보관합니다.
다음과 같이 기능을 활성화해줍니다. 노션으로 블로그를 만들면 댓글 기능도 노션으로 구현할 수 있는데요, 완벽하진 않습니다.
API 통합을 공개로 설정할 필요는 없습니다. 나만 쓸 것이기 때문이니까요.
3. 노션 데이터베이스와 노션 API 연결
여기서 한 가지 추가 작업이 필요합니다. 위에서 생성한 데이터베이스와 API를 서로 연결해야 합니다. 데이터베이스 페이지로 이동합니다.
오른쪽 상단 위에 ‘⋯’ 버튼을 누르고 ‘연결 추가’를 누릅니다. 그리고 생성한 API 이름을 찾아서 연결합니다. 이렇게 하면 연결이 완료되었고 API를 통해 이 데이터베이스 데이터를 제어할 수 있게 됩니다.
마지막으로 이 데이터베이스의 id를 따로 저장할 필요가 있는데요, 다음과 같이 url에서 구할 수 있습니다.
4. 필요한 NPM 라이브러리 다운
이제 개발을 시작할 차례입니다. 다음 2가지 라이브러리들을 설치합니다.
npm install @notionhq/client react-notion-x notion-client notion-utils prismjs
- @notionhq/client: 노션 데이터베이스에 접근하고 글 목록을 가져옵니다.
- react-notion-x: 노션 페이지를 렌더링해줍니다.
- notion-client: 상세 페이지에 대한 글을 가져옵니다.
- notion-utils: 상세 페이지에 대한 속성값들을 가져옵니다.
- prismjs: 코드 블록 하이라이팅
/blog라는 경로로 글 목록을 보여주도록 하겠습니다. 글 목록은 @notionhq/client를 사용합니다.
// app/blog/page.tsx import type { Metadata } from 'next' import Link from 'next/link' import { Client } from '@notionhq/client' import dayjs from 'dayjs' const notion = new Client({ auth: '[노션 API 시크릿 키]' }) export default async function Page(): Promise<JSX.Element> { const { results, has_more, next_cursor } = await notion.databases.query({ database_id: '[노션 데이터베이스 ID]', sorts: [{ property: '생성일', direction: 'descending' }], page_size: 20, ...(process.env.NODE_ENV === 'production' ? { filter: { property: '배포', checkbox: { equals: true } } } : {}) }) return ( <ul> {results.map(page => ( <li key={page.id}> <Link href={`/blog/${page.id}`}> <img src={page.cover?.external?.url} /> <div>{page.properties?.제목?.title[0]?.plain_text}</div> <p>{page.properties?.설명?.rich_text[0]?.plain_text}</p> <time dateTime={page.created_time}>{dayjs(page.created_time).locale('ko').format('YYYY년 M월 D일')}</time> </Link> </li> ))} </ul> ) }
위 코드는 블로그 글 목록을 구현하는 과정을 최대한 간단하게 표현한 것입니다.
- Client는 API를 생성할 때 보았던 시크릿 키를 인자로 받아서 인스턴스를 생성합니다.
notion.databases.query
메소드를 통해 데이터베이스의 데이터들을 가져옵니다.- database_id: 데이터를 가져올 데이터베이스의 id입니다.
- sorts: 데이터를 최신 순으로 가져옵니다.
- page_size: 한 번에 가져올 데이터 수입니다.
- filter: 프로덕션 모드에서만 ‘배포’ 체크박스가 true일 때 가져옵니다.
notion.databases.query
메소드는 다음과 같은 값들을 반환합니다.- results: 글 목록
- next_cursor: 다음 글 목록을 불러올 때 필요합니다. 메소드에
start_cursor
인자에 넣으면 됩니다. - has_more: 더 불러올 수 있는지에 대한 Boolean 값입니다.
- 각 데이터에는 다음과 같은 값들을 추출할 수 있습니다.
id
: 데이터베이스의 idcover.external.url
: 커버 url. seo 이미지 설정 시 유용properties.제목.title[0].plain_text
: 타이틀properties.설명.rich_text[0].plain_text
: 디스크립션created_time
: 생성 일시
이제 상세 페이지를 구현할 차례입니다. 이번에도 최대한 간단하게 코드로 구현하자면 다음과 같습니다.
// app/blog/[id]/page.tsx import { NotionAPI } from 'notion-client' import Renderer from './renderer' import 'prismjs/themes/prism-tomorrow.css' import type { Metadata } from 'next' import Image from 'next/image' import { Client } from '@notionhq/client' import { getPageProperty, getPageTitle } from 'notion-utils' const api = new NotionAPI() const notion = new Client({ auth: '[노션 API 시크릿 키]' }) export const revalidate = 60 * 60 * 24 export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> { const recordMap = await api.getPage(params.id) const schema = Object.values(recordMap.collection)[0]?.value?.schema const title = getPageTitle(recordMap) const metadata = recordMap.block[params.id].value const keys = Object.keys(recordMap?.block || {}) const block = recordMap?.block?.[keys[0]]?.value const description = getPageProperty<string>('설명', block, recordMap) let keywords: string = '' Object.entries(schema).forEach(([id, value]) => { if (value.type === 'multi_select') { keywords = metadata.properties[id][0][0] } }) const url = `https://kidow.me/blog/${params.id}` return { title, description, keywords, alternates: { canonical: url }, openGraph: { title, description, type: 'article', publishedTime: new Date(metadata.created_time).toISOString(), authors: 'kidow', url, images: metadata.format?.page_cover }, twitter: { title, description, images: metadata.format?.page_cover, creator: '__kidow__', card: 'summary_large_image' } } } export async function generateStaticParams(): Promise<Array<{ id: string }>> { async function* getList() { let isFirst = true let nextCursor: string while (isFirst || nextCursor) { const { results, next_cursor } = await notion.databases.query({ database_id: '[노션 데이터베이스 ID]', start_cursor: nextCursor, ...(process.env.NODE_ENV === 'production' ? { filter: { property: '배포', checkbox: { equals: true } } } : {}) }) nextCursor = next_cursor if (isFirst) isFirst = false yield results } } const results: any[] = [] for await (const arr of getList()) { results.push(...arr) } return results.map((item) => ({ id: item.id })) } export default async function Page({ params }: { params: { id: string }}): Promise<JSX.Element> { const recordMap = await api.getPage(params.id) const schema = Object.values(recordMap.collection)[0]?.value?.schema let tags: string[] = [] const metadata = recordMap.block[params.id].value Object.entries(schema).forEach(([id, value]) => { if (value.type === 'multi_select') { tags = metadata.properties[id][0][0].split(',') } }) return ( <> <time dateTime={new Date(metadata.created_time).toISOString()}> {new Intl.DateTimeFormat('ko', { dateStyle: 'long' }).format( metadata.created_time )} </time> <h1>{metadata.properties.title[0]}</h1> <img src={metadata.format?.page_cover} alt={metadata.properties.title[0]} /> <Renderer recordMap={recordMap} /> </> ) }
Renderer 컴포넌트를 따로 분리합니다. 분리하는 이유는 page.tsx는 서버 컴포넌트이지만 Renderer는 클라이언트 컴포넌트이여야하기 때문입니다.
// app/blog/[id]/renderer.tsx 'use client' import { useMemo } from 'react' import dynamic from 'next/dynamic' import Image from 'next/image' import Link from 'next/link' import { NotionRenderer } from 'react-notion-x' import type { NotionComponents } from 'react-notion-x' const Code = dynamic(() => import('react-notion-x/build/third-party/code').then(async (m) => { await Promise.allSettled([ import('prismjs/components/prism-markup.js'), import('prismjs/components/prism-bash.js'), import('prismjs/components/prism-diff.js'), import('prismjs/components/prism-git.js'), import('prismjs/components/prism-markup'), import('prismjs/components/prism-markdown.js'), import('prismjs/components/prism-python.js'), import('prismjs/components/prism-sql.js'), import('prismjs/components/prism-yaml.js'), import('prismjs/components/prism-typescript.js'), import('prismjs/components/prism-css.js'), import('prismjs/components/prism-javascript.js'), import('prismjs/components/prism-json.js'), import('prismjs/components/prism-jsx.js'), import('prismjs/components/prism-tsx.js') ]) return m.Code }) ) const Collection = dynamic(() => import('react-notion-x/build/third-party/collection').then( (m) => m.Collection ) ) export default function Renderer({ ...props }: any) { const components: Partial<NotionComponents> = useMemo( () => ({ Code, nextImage: Image, nextLink: Link, Collection }), [] ) return <NotionRenderer {...props} components={components} /> }
위 코드들을 하나하나 다 설명하기는 힘들어서 간략하게 설명하자면
- NotionAPI: 글 상세 데이터를 가져오기 위함.
getPage
메소드 호출. recordMap은 JSON 형식으로 되어 있으며getPageTitle
이나getPageProperty
같은 함수들로 파싱
getList
: 글 목록은 한 번에 최대 100개를 불러올 수 있기 때문에 제너레이터를 사용하여 재귀로 모든 글을 불러옴
- Renderer: 노션 페이지의 블록 컴포넌트들을 어떻게 렌더링할 것인지 제어.
저 같은 경우 직접 스타일링하고 싶어 따로 추가하진 않았지만 노션 UI와 똑같이 css를 적용하고 싶다면 다음 한 줄을 추가하면 됩니다.
import 'react-notion-x/src/styles.css'
마무리
생각보다 간단하진 않죠? 그렇지만 한 번만 시간내서 적용해놓으면 이후부터는 정말정말 편합니다. 이제 노션으로만 운영하면 끝이니까요. 더 자세한 내용이 궁금하다면 다음 링크들을 참고하세요.
참고
- 저의 블로그 소스코드: https://github.com/kidow/kidorepo
- react-notion-x: https://github.com/NotionX/react-notion-x
- 노션 API 문서: https://developers.notion.com/docs
- Nextjs 문서: https://nextjs.org/docs
- Notion
- Nextjs
- App Router