Notion API로 블로그 만들기

Notion을 CMS로 이용하는 블로그 개발기.
Next.js, Notion API를 활용해서 블로그를 만들어 봤습니다.

April 18, 2023 · Cheolwan Park

블로그를 만들게 된 이유

최근에 글쓰기 능력이 부족한 걸 많이 느꼈습니다. 그래서 글쓰기 실력도 키우고 꾸준히 공부할 동기부여도 겸해서 블로그를 만들어봤습니다.

처음에는 여러가지 블로그 플랫폼을 알아봤는데 디자인이나 편집기가 맘에 안들었고 결국 시간은 좀 들겠지만 직접 만들기로 했습니다.

먼저 Jamstack Themes 에서 블로그 템플릿을 찾아봤습니다. 템플릿 중에는 PaperMod 가 마음에 들었습니다. 세팅도 간단해서 써보려고 했는데 Hugo 기반의 블로그라서 글 작성이 불편할 것 같았습니다. Hugo에서 글을 추가하려면 마크다운 파일을 서버에 추가하고 빌드해야합니다. 깃허브 액션을 구성해서 깃허브에 작성할 수도 있기는 한데 이것도 만족스러울 것 같지는 않았습니다.

조금 더 찾아보니 Notion을 CMS로 사용할 수 있는 방법이 있었습니다. 평소 Notion을 쓰면서 편집 방식이 정말 마음에 들었기 때문에 Notion을 CMS로 써서 PaperMod와 비슷한 디자인으로 블로그를 만들어보기로 했습니다. 프레임워크로는 언젠가 써보려고 생각했던 Next.js를 사용했습니다.

React Notion X vs Notion API

Notion을 CMS로 쓰려고 방법을 알아보니 크게 두가지 방법이 있었습니다. React Notion X 패키지를 이용해서 만드는 방법과, Notion API ( 공식 API )를 이용해서 만드는 방법입니다.

React Notion X는 Notion의 비공식 API를 이용해서 데이터를 불러오고, 불러온 데이터를 리액트 컴포넌트를 통해서 렌더링합니다. 그리고 공식 API 또한 비공식 API 포맷으로의 변환을 통해 간접적으로 지원합니다.

import { NotionAPI } from 'notion-client'

const notion = new NotionAPI()

const recordMap = await notion.getPage('067dd719a912471ea9a3ac10710e7fdf')

...

import { NotionRenderer } from 'react-notion-x'

export default ({ recordMap }) => (
  <NotionRenderer recordMap={recordMap} fullPage={true} darkMode={false} />
)
React Notion X를 이용한 노션 문서 렌더링

처음에는 React Notion X를 활용해서 만들어 보려고 했지만 몇가지 단점이 있어서 포기했습니다. React Notion X에서 스타일을 수정하거나 기능을 추가하려면 수정하고 싶은 블럭들을 따로 구현해서 주입해줘야합니다. 그런데 비공식 API 포맷이 직관적이지 않아서 원하는 대로 여러 블럭들을 수정해서 넣기 불편했습니다. 그리고 같은 개발자가 만든 nextjs-notion-starter-kit 에서 필요한 기능 위주로 구현되어 있어서인지 필요 없는 기능이 많아서 코드를 이해하기 어려웠습니다. 또한 공식 API를 사용하기 때문에 불안정하다는 점도 큰 단점으로 느껴졌습니다. 그래서 그냥 공식 API를 이용하고 렌더링하는 부분은 직접 구현했습니다.

Notion API

노션 공식 API를 활용하면 데이터베이스에 포함된 글을 불러올 수 있습니다. API가 나름 잘 만들어져 있고 json 형식으로 보내줘서 읽기도 쉽습니다. 하지만 몇가지 고려해야할 제한이 있었습니다.

{
  //...other keys excluded
  "type": "paragraph",
  //...other keys excluded
  "paragraph": {
    "rich_text": [{
      "type": "text",
      "text": {
        "content": "Lacinato kale",
        "link": null
      }
    }],
    "color": "default"
}
Paragraph 블럭의 json 형식

Rate Limits

Notion API에는 초당 평균 3회 정도의 요청이 가능합니다. 가끔 요청 횟수가 튀는 것은 괜찮다고 써 있기는 하지만 문제가 되는 상황이 전혀 없을거라고 생각하기는 어렵습니다.

문서에는 Rate Limits 도달 시 429 status code로 응답이 오고, Retry-After 헤더 값만큼 기다리고 다시 요청을 보내면 문제를 해결할 수 있다고 쓰여 있습니다. 그래서 노션 API 호출 함수를 감싸는 에러 핸들러를 만들어서 자동으로 429 응답이 오면 기다렸다 재요청하도록 구현 했습니다.

Notion Hosted Files

링크를 통해 임베딩 한 이미지, 영상등의 파일은 괜찮지만, 노션을 통해 업로드한 파일들은 API를 통해 가져온 링크에 만료 기한 이 있습니다.

별로 상관없을 것 같지만, Vercel의 Image Optimization을 활용하려고 할 때 문제가 있었습니다. 이 내용은 Image 블럭 렌더링에 대해 이야기 할 때 더 자세히 설명하겠습니다.

문서 렌더링

Notion API에서 불러온 각각의 블럭들을 블럭 타입에 맞는 컴포넌트에 연결해서 전체 문서를 렌더링했습니다.

export const Block = ({
  block,
  blocks,
  idx,
}: {
  block: BlockWithChildren;
  blocks: BlockWithChildren[];
  idx: number;
}) => {
  switch (block.type) {
    case "paragraph":
      return <Paragraph block={block} />;
    case "heading_1":
      return <Heading1 block={block} />;
    case "heading_2":
      return <Heading2 block={block} />;
    case "heading_3":
      return <Heading3 block={block} />;
    case "quote":
      return <Quote block={block} />;
    ...
  }
}
블럭 정보를 받아서 타입에 맞는 컴포넌트로 연결해주는 Block 컴포넌트

각각의 블럭들은 이런식으로 구현합니다.

import { ExtendBlock } from "@/services/notion/types/block";
import { ParagraphBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
import classNames from "classnames";
import { getColorClass } from "./colors";
import { RichText } from "./richtext";

export const Paragraph = ({
  block,
}: {
  block: ExtendBlock<ParagraphBlockObjectResponse>;
}) => {
  return (
    <p className={classNames(getColorClass(block.paragraph.color))}>
      <RichText richTexts={block.paragraph.rich_text} />
    </p>
  );
};
Paragraph 블럭을 렌더링하는 컴포넌트

대부분의 블럭들은 간단하게 구현할 수 있었지만 몇몇 블럭은 추가적인 패키지가 필요하거나 고민해봐야 할 부분이 있었습니다.

Image Block

Blur Placeholder, 이미지 사이즈 문제

Next.js에서 제공하는 Image 컴포넌트를 이용하면 Lazy Loading, Blur Placeholder를 간단하게 구현할 수 있습니다. 이 기능은 프로젝트에 포함된 이미지를 사용하는 경우 추가적인 세팅이 필요 없지만 외부 이미지를 불러와서 쓰는 경우에는 미리 이미지의 크기와 Placeholder로 사용할 Blur 이미지를 미리 제공해야합니다.

Plaiceholder 패키지를 이용하면 Blur 이미지와 원본 이미지 크기를 간단하게 얻어 올 수 있어서 Notion API로 데이터를 불러올 때 이 정보를 추가하도록 해서 Image 컴포넌트를 사용할 수 있었습니다.

링크 만료 문제

노션에서 호스팅하는 파일 링크에는 만료 기한이 있습니다. 글을 쓰는 시점 기준으로는 1시간인데, 처음에는 링크의 만료 기한이 있더라도 Vercel의 Image Optimization을 이용하면 캐싱이 되니까 캐싱 기간을 늘리면 상관없다고 생각했습니다. 하지만 실제로 구현해보니 시간이 지난 후 다른 기기로 접속하면 이미지 로딩이 계속 실패했습니다. 왜 그런지 생각을 해 보니 두가지 문제가 있었습니다.

  1. 기기에 따라 필요한 이미지 사이즈가 달라 요청 링크가 다르다
  2. 캐싱은 요청이 발생한 Edge Network에만 된다

그래서 만약 데스크탑에서 2x 이미지를 요청해서 캐싱이 됐는데 2시간 후에 모바일에서 1x 이미지를 요청하면 1x 이미지는 캐시가 없어서 만료된 원본 링크에 요청이 가고, 당연히 이미지는 불러올 수 없습니다.

그러면 1시간마다 새로 링크를 받아서 Image Optimization을 적용하고 캐싱해서 보여주면 된다고 생각할 수도 있습니다. 하지만 Vercel에서는 Hobby 플랜의 경우 월 1000개의 이미지 링크만 최적화해주고 1000개를 초과하면 차단해버리기때문에 1시간마다 새 링크를 받는 방법은 쓰기 어렵습니다. 그리고 응답시간을 줄이기 위해 ISR을 이용하고 있어서 결국 Stale 상태에 방문하는 일부 방문자는 만료된 링크를 볼 수 밖에 없습니다.

계속 고민을 했지만 중간에 이미지 캐싱용 서버를 두거나, Image Optimization 개수 제한을 늘리는 것 처럼 돈이 드는 방법 외에는 깔끔하게 해결할 방법이 생각이 나지 않았습니다.

그래서 만료되는 이미지도 최적화 할 건지 정하는 옵션을 만들어 두고 일단 만료되는 이미지들에 대해서는 최적화하지 않도록 해서 이미지 표시는 가능하도록 했습니다. 그리고 Stale 상태의 방문자들이 이미지를 보지 못하는 문제를 해결하기 위해서 이미지 로딩에 실패하면 API를 통해서 새 링크를 받아오도록 했습니다.

Bookmark Block

공식 문서 를 보면 Bookmark 블럭에는 URL 정보만 포함되어 있습니다.

{
  //...other keys excluded
  "type": "bookmark",
  //...other keys excluded
  "bookmark": {
    "caption": [],
    "url": "https://companywebsite.com"
  }
}
Bookmark 블럭 형식

하지만 Bookmark 블럭을 렌더링하려면 페이지의 제목, 설명, 아이콘 등의 메타데이터가 필요하기때문에 메타데이터를 받아올 수 있는 패키지가 필요했습니다. 여러가지 패키지가 있었는데 그 중에서 간단하게 정보를 가져올 수 있는 unfurl 패키지를 활용했습니다. 그리고 Image 블럭과 마찬가지로 Notion API로 데이터를 불러올 때 Bookmark 블럭에 웹페이지의 정보를 추가하도록 구현했습니다.

KaTex

Equation 블럭과 RichText에는 KaTex를 이용해 표현된 수식이 포함될 수 있습니다.

KaTex 패키지를 이용하면 렌더링 할 수 있지만, 패키지 용량이 커서 초기 로딩에 포함시켜버리면 포스트 페이지에서 로드해야할 js 파일 용량이 너무 커졌습니다. 비슷한 문제가 있는 prism.js 를 이용하는 Code 블럭은 dynamic import를 통해 해결했지만 dynamic import를 쓰면 최종 렌더링 결과물의 크기를 모듈을 불러오기 전까지는 알 수 없기 때문에 Layout Shifting이 일어났습니다. KaTex의 경우 문서의 대부분을 차지하는 RichText에서 사용해야 해서 같은 방식으로 해결할 수 없었습니다.

그래서 Image 블럭, Bookmark 블럭을 구현할 때 했던 것 처럼 렌더링 된 KaTex html을 Notion API로 데이터를 불러올 때 추가하는 방식으로 하기로 했습니다. 하지만 RichText는 Image 블럭이나 Bookmark 블럭과 달리 온갖 다양한 방식으로 블럭들에 포함돼있어서 불러온 데이터의 모든 속성들을 재귀적으로 순회하면서 Equation에 해당하는 RichText인 경우만 정보를 추가하는 방식으로 구현했습니다.

const renderAllEquations = (obj: any): any => {
  if (Array.isArray(obj)) {
    return obj.map(renderAllEquations);
  } else if (obj && typeof obj === "object") {
    if (isRichTextWithEquation(obj)) {
      return {
        ...obj,
        katexHtml: renderKatex(obj.equation.expression) || "",
      };
    }
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) obj[key] = renderAllEquations(obj[key]);
    }
    return obj;
  } else {
    return obj;
  }
};
RichText 안의 모든 Equation을 렌더링하는 함수

Open Graph Image Generation

@vercel/og 패키지를 사용하면 Open Graph 이미지를 생성할 수 있습니다. JSX를 이용해서 어떻게 렌더링할지 정의하면 그대로 이미지로 만들어주는 방식이라 공식문서 만 읽으면 쉽게 사용할 수 있습니다.

하지만 또 의외의 부분에서 문제가 있었습니다.

@vercel/og는 Vercel의 Edge Network에서 동작하는데, Hobby 플랜의 경우 Edge Network에 올릴 수 있는 프로그램 크기가 1MB로 제한됩니다. 한글 폰트를 쓰면 상용한글 2400자만 포함하도록 해도 계속 1MB를 넘겨서 업로드 할 수 없었습니다. 그래서 수작업으로 사용하지 않을법한 글자들을 제거했고, 어찌저찌 1MB 밑으로 만들어서 겨우 업로드 할 수 있었습니다.

마무리

블로그는 처음 만들어 봤습니다. 웹개발은 별로 좋아하지 않아서 별로 기대하지 않았는데 생각보다 재밌었습니다. 고민할 부분도 많았고 Next.js를 이용한 개발 경험도 굉장히 좋았습니다. 특히 Next.js는 정말 마음에 들어서 앞으로 웹개발을 해야 할 때면 꼭 Next.js를 사용할 것 같습니다. Notion API도 아직은 약간 부족한 부분이 있지만 조금 더 업데이트 되면 다른 복잡한 서비스에서도 CMS로 활용할 수 있을 것 같아서 가끔 확인해봐야겠습니다.

만약 블로그 소스코드를 보고싶은 분들은 깃허브를 확인해주세요.

GitHub - cheolwanpark/next-notion-blog

Contribute to cheolwanpark/next-notion-blog development by creating an account on GitHub.

https://github.com/Cheolwan-Park/next-notion-blog