나는 노션으로 개발 블로그를 운영한다

avatar
kidow
@kidow
나는 노션으로 개발 블로그를 운영한다
태그
Notion
Nextjs
App Router
설명
React 개발자들에게는 희소식일겁니다. DB, 저장소 필요없습니다.
배포
배포
수정일
Nov 15, 2023 04:13 PM
생성일
Aug 30, 2023 03:45 AM
웹 개발자가 개발 블로그를 직접 만들어 운영하는 것은 낭만적인 일입니다. 그 것이 자기가 밥 벌어먹고 사는 능력으로 만드는 것이니까요.
다만 제 경험상 블로그를 구축하는 과정이 생각보다 쉽지 않고, 또 만들고 나서도 운영하기가 여간 번거로운 것이 아닌데요, 개발 블로그 글들을 직접 관리하는 방식은 보통 두 가지 경우로 나뉘게 됩니다.
  1. 블로그 글 데이터를 데이터베이스와 스토리지까지 구축해서 직접 저장하고 불러온다.
  1. 마크다운(.md)을 사용하여 파일 시스템을 구축해서 관리한다.
전자의 경우는 솔루션을 통해 데이터베이스를 빌려오는 경우일텐데요, 이 경우는 시작부터 피곤해지게 될 염려가 있습니다. 데이터베이스 지식을 어느 정도는 알아야 하는 것은 물론이며, 가장 큰 문제는 솔루션이 영구 무료가 아니기 때문에 용량이 찬다면 어느 수준부터는 돈을 내야한다는 것입니다.
후자의 경우 대표적인 예시로 Github Pages나 GatsbyJS가 있는데요, 전자에 비해서 돈이 들지 않는다는 점이 큰 장점이지만 마크다운에 익숙해질 필요가 있고 콘텐츠를 작성할 때마다 배포를 매번 해야한다는 피로감이 여전히 존재합니다.
저는 두 케이스를 모두 겪어보았던 사람인데요, 어느 순간 제 3의 새로운 방식이 있다는 것을 알게 되고 무릎을 탁 쳤던 경험을 공유해보려고 합니다.

노션 블로그의 장점

노션의 요금제
노션의 요금제
이제 우리에겐 너무 익숙한 생산성 앱인 Notion입니다. 만약 노션의 강력한 인프라를 이용하여 개발 블로그를 운영할 수 있다면 어떨까요? 저는 이미 이 글을 노션을 통해 작성하고 있습니다. 스크롤 하단에 있는 댓글 버튼을 누르시면 본래의 노션 페이지로 이동하실 수 있습니다. 노션으로 블로그를 운영하게 된다면 다음과 같은 장점들을 가져갈 수 있습니다.
  1. 개인 사용자는 무제한으로 페이지와 블록을 생성할 수 있기에 평생 무료다. 이미지 업로드 역시 마찬가지.
  1. 노션에 내장된 기능들을 그대로 활용하여 글 작성이 가능하다.
  1. 웹, 앱, 데스크탑 앱을 모두 지원하기 때문에 그냥 글만 작성하면 된다.
 

구축하는 방법

이렇게 좋은 노션 개발 블로그를 어떻게 하면 만들 수 있는 지 지금부터 다뤄보겠습니다. 개요는 이렇습니다.
  1. 노션 데이터베이스 생성
  1. 노션 API KEY 생성
  1. 노션 데이터베이스와 노션 API 연결
  1. 필요한 NPM 라이브러리 다운
  1. 적용
💡
참고로 프론트엔드는 Next.js App Router를 기준으로 작성합니다.
 

1. 노션 데이터베이스 생성

여러분의 노션 페이지 아무 곳에서 다음과 같이 “데이터베이스 - 전체 페이지”를 생성합니다. 이 데이터베이스가 여러분들의 블로그 글들을 저장하는 창고가 될 겁니다.
notion image
아무데서나 생성해도 어차피 페이지를 이동시킬 수 있으니 상관없습니다. 이제 생성된 데이터베이스의 속성들을 간단하게 꾸며봅시다.
notion image
실제 제 블로그 데이터베이스의 속성들입니다. SEO를 염두하여 설계한 것인데요,
  • 설명(텍스트): seo의 description
  • 태그(다중 선택): seo의 keywords
  • 배포(체크박스): 제일 중요. 작성이 완료된 콘텐츠만 외부에 노출시키도록 차후에 프로그래밍
  • 생성일(생성 일시): 데이터 생성 시 자동으로 추가되는 값입니다. 선택이지만 보통 블로그에서는 작성일자를 보여주는 경우가 대부분입니다.
  • 수정일(최종 편집 일시): 선택입니다. 언제 이 글을 마지막에 수정했는지 알 수 있습니다.
이 외에도 여러분들의 기호에 맞게 자유롭게 속성을 추가하실 수 있습니다. 이러한 속성들은 API를 통해 모두 가져올 수 있습니다.
 

2. 노션 API KEY 생성

이제 이 데이터베이스 데이터들을 불러오기 위한 API 작업이 필요합니다. 내 API 통합에 들어갑니다.
새 API 통합을 누릅니다.
notion image
이름을 짓고 로고를 업로드한 뒤 제출합니다.
notion image
노션 블로그를 구축할 때 필요한 환경 변수가 있습니다. 여기서 프라이빗 API 통합 시크릿 키를 따로 보관합니다.
notion image
다음과 같이 기능을 활성화해줍니다. 노션으로 블로그를 만들면 댓글 기능도 노션으로 구현할 수 있는데요, 완벽하진 않습니다.
notion image
API 통합을 공개로 설정할 필요는 없습니다. 나만 쓸 것이기 때문이니까요.
 

3. 노션 데이터베이스와 노션 API 연결

여기서 한 가지 추가 작업이 필요합니다. 위에서 생성한 데이터베이스와 API를 서로 연결해야 합니다. 데이터베이스 페이지로 이동합니다.
notion image
오른쪽 상단 위에 ‘’ 버튼을 누르고 ‘연결 추가’를 누릅니다. 그리고 생성한 API 이름을 찾아서 연결합니다. 이렇게 하면 연결이 완료되었고 API를 통해 이 데이터베이스 데이터를 제어할 수 있게 됩니다.
마지막으로 이 데이터베이스의 id를 따로 저장할 필요가 있는데요, 다음과 같이 url에서 구할 수 있습니다.
데이터베이스 id 알아내는 법
데이터베이스 id 알아내는 법
 

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 : 데이터베이스의 id
    • cover.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'
 

마무리

생각보다 간단하진 않죠? 그렇지만 한 번만 시간내서 적용해놓으면 이후부터는 정말정말 편합니다. 이제 노션으로만 운영하면 끝이니까요. 더 자세한 내용이 궁금하다면 다음 링크들을 참고하세요.
 

참고

 
  • Notion
  • Nextjs
  • App Router