go to post list

내 블로그 내가 만들고 말지

2024. 02. 17 • 이 블로그는 Notion을 CMS로 사용하고 있습니다

계기

설 연휴 기간 동안 사이드 프로젝트를 하기로 했습니다. 프로젝트를 하면서 느낀 것들을 글로 몇 편 써놓긴 했지만, 노션 사이트를 배포해놓았다 하더라도 블로그처럼 열린 공간의 느낌이 아닌 정적 웹 문서에 더 가까웠기 때문에 좀 아쉬운 감이 있었던 차였습니다. 그렇다고 기존 블로그 플랫폼들이 성에 차지는 않아서, 어떻게 할까 고민을 하고 있던 차였는데… 노션을 CMS로 그대로 사용하면서 블로그 뷰만 제공할 수 있으면 좋겠다는 생각이 들었고, 노션 API를 찾아보니 사용하기 쉽게 잘 만들어져 있어서 블로그를 빠르게 만들 수 있겠다는 생각이 들었습니다.

기술적 목표

Notion API의 특성을 이용해 컴포넌트를 열심히 구현해 보는 것이 목표입니다. 정확히는 잘 설계된 API 응답 구조와 컴포넌트의 관계를 살펴 보려고 합니다. 딱히 기술적 목표가 있다기보다는 노션에 글만 쌓아두고 있으면 안 되겠다고 생각 하기도 했고, 이걸 한 번 잘 만들어 두면 나중에 라이브러리로 만들어볼 수도 있지 않을까 하는 생각을 했습니다.

Notion API 분석하기

거두절미하고, 노션에서 쓰는 API 기본 단위는 Block 입니다. 텍스트 문단 하나, 콜아웃, 임베드, 리스트 등 페이지 안에 있는 데이터베이스를 제외한 모든 요소들 은 모두 Block 오브젝트입니다. 심지어 페이지 그 자체도요. (그래서 CRUD API 중 Delete에 해당하는 API는 Block 에만 있습니다)

API Call로 받아볼 수 있는 Block object의 대략적인 생김새는 아래와 같습니다.

JSON

{
	"object": "block",
	"id": "c02fc1d3-db8b-45c5-a222-27595b15aea7",

	...,
	//블록의 메타 데이터

	"has_children": false,
	"type": "heading_2",
	"heading_2": {
		...,
		//블록의 실질적인 데이터
	}
}

저는 개발 공부를 시작하기 전에도 마크다운 편집 툴을 익숙하게 쓰고 있었지만 마크업 언어라는 개념에 대해서는 모르고 있었는데요, 노션이 마크다운을 지원하지만 마크다운보다 더 넓은 범위의 커스텀을 제공할 수 있는 것은 단순히 마크다운을 html로 파싱하는 것 이상의 설계가 있기 때문이라는 것을 이 API를 보면서 깨달았습니다. 아마 마크다운의 장점을 가져가면서도 표현의 한계 (다단 편집, 임베드 등 이것저것)를 해결하기 위해 이런 식의 설계를 한 것이 아닐까 예상했는데요, 여기저기 구글링을 하다가 노션 기술 블로그에 있는 API 설계 철학에 대해 쓴 문서를 발견했습니다.

💡However, the biggest problem with Markdown is that it is simply not expressive enough to support the use-cases that our users wanted an API to fulfill, such as custom importers and exporters to bring data into and out of Notion, or integrations using Notion as a CMS or backing datastore. People have likened Notion to a "blank canvas" and "a place to do messy thinking," because it’s so flexible and expressive. If our API could not replicate what users have spent valuable time creating in Notion, its power and usefulness would be impaired.

노션을 선호하는 유저들이 노션을 빈 캔버스처럼 이것저것 copy and paste하면서 이것저것 생각하고 표현해 보는 공간으로 사용하는 경향을 읽었는데요, API가 마크다운만 지원하면 그런 식의 니즈를 채우지 못할 것이라고 생각했다고 합니다. 실제로 노션은 표 형태의 자료를 웹에서 복사해서 붙여넣기하면 그대로 붙여 넣어지고, 양식이 있었다면 붙여넣기할 때 해당 양식을 대체로 유지합니다. 파일이나 임베드와 같은 형태도 지원하기 때문에 마크다운보다는 훨씬 확장성을 가지면서도 마크다운을 지원합니다. 더 자세한 내용은 여기(페이지 언어 설정을 영어로 바꾸어야 페이지에 접근이 가능합니다)에서 읽을 수 있습니다. 실제로 contentful이나 prismic 같은 headless cms 솔루션들이 있지만, markdown 편집을 지원하면서도 동시에 더 확장성 있는 편집을 할 수 있는 툴은 제가 아는 선에서는 본 적이 없는 것 같습니다.

API 요청 받아보기

그러면 API 요청을 하고 응답을 받아와 보겠습니다. 저는 Next.js 13의 App Router를 사용했고, Incremental Static Regeneration을 이용해 페이지들을 정적 빌드하고 일정 주기마다 재생성할 예정입니다. notion api 엔드포인트로 요청을 하는 작업은 평균 초당 3회 정도로 제한이 걸려 있다고 하는데요, 딱 세 번 까지라는 식으로 엄격하진 않았습니다. 이 횟수를 초과하면 429 코드와 함께 시간이 요청 제한 시간이 헤더에 온다고 합니다. 포스트 여러 개에 대한 API를 빌드 시점에 많이 호출하는 것까지는 제한이 걸리지 않았고, dev 환경에서 호출 횟수를 확인해 보니 server side나 client side에서 여러 번 요청이 들어가게 되면 금방 요청 횟수를 넘겠다는 생각을 해 ISR을 택했습니다. 블로그 특성상 한 번 글이 작성되면 글 수정이 많지도 않을 뿐더러 방문자들이 빠르게 내용을 받아볼 수 있게 되기도 하고요.

Block 객체들에 대한 API 제한 사항들이 있었는데, 빡빡하지는 않은 것 같으나 중간에 글이 잘리거나 하는 일이 벌어질 여지는 충분하다고 생각했습니다. 제일 걸릴만한 제한은 한 문단에 plain text 2000자 제한인 것 같은데, 정상적인 글이라면 한 문단에 2000자까지 쓰지는 않겠지만 이런 제한이 있는 요청은 400 코드와 함께 응답이 온다고 하니까 알아두어야겠습니다. 그 외에도 특정 블록에는 특정 요청은 보낼 수 없다거나 하는 제한들도 있으니 혹시 노션 API로 무언가를 하실 분들은 꼭 이 페이지를 읽어보시길 바랍니다.

API 요청을 위해 @notionhq/client 라는 SDK가 잘 만들어져 있는 것 같아 우선 사용해 보았는데, API 응답 구조상 타입스크립트 에러를 피할 수 없었습니다. 제가 요청을 보내는 API 엔드포인트가 응답 타입에 명시적으로 지정되지 않은 필드를 응답으로 보내거나, 실질적으로 응답으로 가져온 데이터 타입 정의가 광범위하게 되어있어 assertion이나 narrowing하는 작업을 너무 자주 해야 했습니다. 제가 응답을 받고자 하는 형식은 특정 interface로 정의할 수 있었고, 그 이외의 응답은 받지 않을 것이었기 때문에 실제로 응답을 받아본 뒤, 블로그 데이터베이스 컬럼에 대응하는 인터페이스를 만들고 REST API 방식으로 모두 전환했습니다. (그리고 이 행동은 나중에 정말 큰 업보로 돌아오게 됩니다)

저는 정적 페이지를 빌드하기로 했으니 다음과 같은 흐름으로 API 요청을 보냅니다:

  1. 블로그 글 데이터베이스에서 ‘published’ 항목에 체크한 글만 시간순으로 쿼리합니다.
  2. 해당 데이터베이스에 적힌 page id들을 가지고 각 페이지의 블록들을 가져오는데요, 한 페이지의 모든 컨텐츠를 가져오려면 블록 단위의 엔드포인트에서 일정 단위(최대 100개)로 페이지네이션하여 가져옵니다.
  3. 각 페이지에서 페이지 메타데이터를 가져오기 위해 페이지 id를 가지고 페이지의 정보를 가져옵니다.

이렇게 했을 때 페이지 메타데이터를 만들기 위해 각각의 호출 함수를 만들다 보니 같은 API들을 너무 많이 호출하는 문제가 있습니다. 그래서 호출횟수를 어떻게든 줄여보려고 정말 귀여운 캐시를 만들어서, 가장 많이 호출되는 API인 1의 경우 0.5초 이내에 재호출될 경우 캐시를 사용하도록 했습니다. 모든 페이지의 revalidate 주기가 3600으로 1시간인데, 만약 이 0.5초 사이에 제가 어떤 글의 publish 상태를 변경하는 일이 벌어지면 동기화가 안 되는 일이 벌어질 수 있겠습니다.

컴포넌트 관점에서의 Notion API

Block 오브젝트의 모양을 다시 한 번 가져와 중요한 부분만 발췌해 봤습니다.

JSON

{
	"object": "block",
	"id": "c02fc1d3-db8b-45c5-a222-27595b15aea7",

	...,
	//블록의 메타 데이터

	"has_children": false,
	"type": "heading_2",
	"heading_2": {
		...,
		//블록의 실질적인 데이터
	}
}

제가 주목한 부분은 has_childrentype, heading_2 였는데요, type 의 value가 key로 오고, 그 안에 블록의 실질적인 내용과 데이터가 담겨 있었습니다. API 문서를 보면, type 필드의 종류는 다음과 같습니다.

(Block API에 대해서는 이 링크에서 자세히 확인하실 수 있습니다.)

이 정보를 가지고 컴포넌트를 어떻게 설계해야 하는지 생각해봤는데, 바로 생각난 것은 Block다형성을 가진다는 것이었습니다. API 필드에 기반해서 Block 컴포넌트 자체를 Polymorphic하게 구현하고, children Block을 가질 수 있는 일부 블록들은 재귀적으로 처리하면 될 것 같았습니다.

Polymorphic Component

버튼처럼 생긴 <a> 태그, 애플 홈페이지

버튼처럼 생긴 <a> 태그, 애플 홈페이지

눈으로 보기에는 버튼인데, 실제로 렌더링된 DOM 요소는 <a> 입니다. 처음 React를 공부할 때 저런 요소들을 어떻게 하면 멋지게 하나의 컴포넌트로 재사용할 수 있을까를 고민하면서 열심히 검색해 보다가 알게 된 개념이 다형 컴포넌트(Polymorphic Component)였습니다. 버튼 컴포넌트는 복잡한 동작을 처리하는 역할을 하기도 하지만, 단순히 어딘가로 이동시키는 역할만 했으면 할 때도 있습니다. 이때 버튼 컴포넌트를 그대로 사용하면서 <a> 태그로 렌더링하면 효율적일 것입니다. 이처럼 컴포넌트가 실제 어떤 DOM 요소(또는 컴포넌트)로 렌더링될지를 의존성으로 결정해 상황마다 다르게 렌더링하는 컴포넌트를 Polymorphic Component라고 합니다. 이걸 확장해 보면, 카드 컴포넌트가 <li> 로 렌더링되거나, <div> 로 만들어져 있던 박스 컴포넌트가 <article> 처럼 시맨틱한 마크업을 가질 수 있도록 할 수 있습니다. 저는 Notion의 Block을 이런 식으로 간단하게 구현할 수 있을 거라고 생각했고요, 아래 코드는 그 구현입니다. (타입 정의는 제거했습니다)

Typescript

//Block.tsx
export const Block = ({ block }) => {
  const Component = blockComponentMap[block.type];
  if (!Component) return <div />;
  else return <Component block={block} />;
};

BlockComponentMap은 notion이 지정한 block의 type property를 key로, 그에 매핑된 컴포넌트를 value로 가지고 있습니다. Block 컴포넌트는 API로부터 온 notion block data를 받아서 type property에 따라 그에 맞는 컴포넌트를 리턴하는 방식으로 구현했습니다.

또, Block 중에는 children key를 가질 수 있는 블록들이 있는데요, 대표적인 블록은 paragraph 입니다. Block[]로 제공되는 children 요소를 어떻게 처리할까 고민하다가, 모든 컴포넌트가 children 요소를 지원하는 것이 아니어서 해당 요소를 지원하는 컴포넌트 안쪽에 Block 컴포넌트를 렌더링해 재귀적인 모양을 갖추도록 했습니다. 자식 블록을 가질 수 있는 Paragraph 컴포넌트는 아래와 같은 모양을 가지고요,

Typescript

//Paragraph.tsx
export function Paragraph({ block }) {
  const id = useId();
  return (
    <div>
      {block.paragraph.rich_text.map((txt) => (
        <Txt key={id} as="p" richText={txt} />
      ))}
      {block.paragraph.children && <ChildrenBlocks block={block.paragraph.children} />}
    </div>
  );
}

ChildrenBlocks 컴포넌트는 Block 을 한 번 더 리턴하는 구조입니다.

Typescript

//ChildrenBlocks.tsx
export function ChildrenBlocks({ block }) {
  const type = block.type;
  return (
    <>
      {block[type].children.map((block) => {
        return <Block block={block} />;
      })}
    </>
  );
}

글 전체 내용을 보여주는 블로그 포스트나 소개 페이지 컴포넌트에서는 아래와 같이 간단하게 모든 블록 요소들을 렌더링합니다.

Typescript

//blog/[slug]/page.tsx
...
return (
	<Article>
    <Title title={getTitle(meta)} />
    {blocks.map(block => (
      <Block key={block.id} block={block} />
    ))}
  </Article>
)

세부 컴포넌트 구현

RichText

세부 컴포넌트의 기반이 되는 구조는 RichText입니다. API 엔드포인트상의 타입으로는 RichTextItemResponse라고 정의되어 있는데요, 구체적으로는 멘션, 수식, 그리고 일반 텍스트 세 종류가 있습니다. 그 중 TextRichItemResponse를 살펴보면 다음과 같습니다.

Typescript

export type TextRichTextItemResponse = {
    type: "text";
    text: {
        content: string;
        link: {
            url: TextRequest;
        } | null;
    };
    annotations: AnnotationResponse;
    plain_text: string;
    href: string | null;
};

콜아웃이나 제목, 일반 텍스트 등 스타일링이 가능한 거의 모든 텍스트는 이 타입으로 API가 제공되는 것을 알 수 있습니다. 저는 이 타입과 호환되면서도 Notion API를 사용하지 않는 부분에서도 범용적으로 사용할 수 있는 텍스트 컴포넌트를 만들어야 했습니다. RichText의 안쪽에는 AnnotationResponse가 있고, 여기에 텍스트 스타일링에 대한 정보가 전부 들어 있는데요, AnnotationResponse는 이렇게 생겼습니다.

Typescript

type AnnotationResponse = {
    bold: boolean;
    italic: boolean;
    strikethrough: boolean;
    underline: boolean;
    code: boolean;
    color: "default" | "gray" | "brown" | "orange" | "yellow" | "green" | "blue" | "purple" | "pink" | "red" | "gray_background" | "brown_background" | "orange_background" | "yellow_background" | "green_background" | "blue_background" | "purple_background" | "pink_background" | "red_background";
};

시맨틱 마크업을 살리면서 스타일링 또한 다 적용할 수 있어야 해서 고민이 많았는데요, 궁리 끝에 조건에 따라 텍스트를 시맨틱 마크업으로 감싸는 구조를 만들기로 했습니다. 먼저 컴포넌트를 리턴하는 함수를 받아 렌더링하는 Wrapper 컴포넌트를 만들어 주고, 조건식에 따라 맞는 컴포넌트를 스타일링해 매치시켰습니다.

Typescript

export function Wrapper({ as, condition = true, children }: WrapperProps) {
  if (!condition) return children;
  else return cloneElement(as(), {}, children);
}

이렇게 바깥에서 감쌀 때 조건부 렌더링 대신 사용할 수 있습니다.

Typescript

<Wrapper condition={richText?.annotations.code || code} as={() => <Code />}>
	{richText?.plain_text : children}
</Wrapper>

다른 컴포넌트들

이제 각 블록의 사양에 맞춰 세부 컴포넌트를 구현할 차례입니다. 노션 블록 바깥에서도 쓸 수 있는 기본적인 컴포넌트들은 미리 구현해 놓았고, 블록에서 쓰이는 컴포넌트들을 구현하면서 기본 디자인을 override할 수 있도록 구성했습니다. unsupported 이나 embed 같이 블로그에서 사용할 수 없는 컴포넌트들은 제외했습니다. 컴포넌트들은 대체로 무난하게 만들 수 있었지만, 이슈가 있었던/있는 컴포넌트는 크게 두 가지 정도입니다.

1. Image - 이미지 만료 시간 문제

이미지 컴포넌트를 만들면서 노션의 이미지 URL에는 만료 시간이 붙어 있었다는 것을 알게 되었는데요, 그것도 1시간 정도로 꽤 짧았습니다. 만료 시간이 지나면 해당 링크로는 그 이미지에 접근할 수 없고 다른 링크로 접근해야 하는 것인데요, SSR이었다면 별 문제가 되지 않았겠지만 API 콜 수 제한 때문에 ISR을 채택하고 있었기에 링크가 만료된 후의 몇몇 방문자는 페이지가 다시 만들어지기 전까지 이미지 로드에 실패하게 됩니다. 페이지가 다시 만들어진 후에도 첫 방문자는 이미지 로드에 실패하게 되고요. 배포를 vercel에서 하는 김에 이미지 최적화 서비스를 사용해 볼까도 했는데 1000장 까지만 무료고 그 다음부터는 요금을 내야 해서, 이미지 컴포넌트는 클라이언트 사이드에서만 처리하기로 했습니다. (나중에 알아보니 next/Image 컴포넌트를 사용하면 자동으로 1000장까지는 vercel에서 최적화를 처리합니다.) S3 같은 외부 클라우드에 연결하고 그 이미지를 사용하게 할까 했지만, 어차피 static file이 아니기 때문에 사용자 경험이 눈에 띄게 좋아지는 것은 아니라고 생각했고, 이미지 로드에 실패하면 해당 이미지 링크를 다시 받아오는 정도의 구현만 해야겠다는 생각을 했습니다. 다행히 모든 블록 컴포넌트는 해당 블록의 ID를 가지고 있기 때문에 next/image 컴포넌트의 onError prop을 사용해, 클라이언트에서 이미지 로드에 실패한다면 이미지 블록 정보를 노션에서 다시 쿼리해 가져오도록 구현했습니다. API 엔드포인트를 하나 만들어, 클라이언트 컴포넌트에서 해당 엔드포인트로 요청을 보내면 Next.js 백엔드에서 Notion으로 쿼리를 보내 이미지 블록을 다시 가져오고, 이미지 컴포넌트는 새 URL을 전달받아 이미지를 리로드합니다. 이미지가 리로드될 때 로딩 이미지를 띄워놓는 정도로만 구현했습니다.

2. Bookmark - 외부 사이트 메타데이터 미제공 문제

북마크 요소가 외부 사이트의 링크 정보만 제공하기 때문에, 라이브러리를 써서 외부 사이트의 메타데이터를 가져와야 했습니다. unfurl.js 라는 라이브러리를 통해 대상 사이트의 메타데이터를 가져와 간단한 북마크 컴포넌트를 구현할 수 있었습니다. 아래 구글의 예시처럼, 북마크를 해 놓으면 빌드 시에 생성되어 html로 렌더링됩니다.

Google Search Console 등록

구글 서치 콘솔에 사이트를 등록하고 모니터링을 하고 있습니다. 생각보다 노출과 클릭수가 조금씩 나오고 있고, 어떤 검색어로 노출되는지도 보여서 신기합니다. 90일이 지나면 코어 웹 바이탈 같은 지표도 볼 수 있고요. 아직 블로그가 만들어진 지 얼마 되지 않아서 글도 많이 없고, 아직 90일이 지나지 않아서 나중에 지표가 쌓이면 한 번 공유해 보도록 하겠습니다.

또 노션 데이터의 변동에 따라 동적으로 페이지가 생성되기 때문에 RSS나 사이트맵도 동적으로 생성시켜야 해서 이걸 어떻게 구현해야 하나 싶었는데, 사이트맵이나 RSS를 생성하는 라이브러리나 가 잘 나와 있어서 간단하게 해결할 수 있었습니다. 구글 서치 콘솔에서도 서버 응답 결과로 주는 동적 사이트맵을 잘 읽어서, 노션으로 글을 썼을 때 동적으로 생성되는 페이지들이 잘 등록되는 것을 확인할 수 있었습니다.

후기

막상 블로그를 배포해놓고 보니 부족한 부분들이 계속 보이고, 고치고 싶은 부분들이 너무 많이 보입니다. 그래도 배포를 하고 공개적인 장소에 올린 게 성과라고 생각합니다. 공부한 내용이나 하다못해 짧은 생각 같은 것이라도 무언가를 공유하는 건 생각보다 많은 용기가 필요한 일이었다는 걸 이제 깨닫습니다. 이 블로그의 전체 코드는 여기에서 보실 수 있습니다. 또, 이 블로그는 계속 업데이트 중입니다. 자잘한 css 수정부터 제목별로 링크를 공유할 수 있는 기능이나 검색같은 업데이트도 종종 하면서 더 발전시킬 예정입니다.

다른 글