Tech

Notion API와 함께 정적 페이지로의 여정

2023. 01. 12

Notion API와 함께 정적 페이지로의 여정_화해

 

 

안녕하세요. 화해팀 프론트엔드 플랫폼 리더 박제훈입니다. Notion API와 함께 정적 페이지로의 여정

플랫폼에서 최근 논의 되었었던 정적 페이지를 notion으로 관리하는 방법으로 접근하기까지의 과정을 주제로 이야기를 풀어보려 합니다.

 

 


 

정적 페이지 자동화 논의

논의는 2022년 초 입사하신 프론트엔드 플랫폼 B님이 정적 페이지 관리에 대한 이야기를 꺼낸 것에서 시작되었습니다. 화해 서비스가 확장되면서 약관과 같은 단순한 정적 페이지들이 조금씩 늘어나기 시작했고, 관리 방법이 통일되지 않아 새로운 요청이나 수정 요청이 들어올 때마다 관리의 어려움을 느끼고 있었습니다. 당시 저희도 문제점으로 인식하고 있었는데 마침 B님이 관리 방안에 대한 아이디어를 정리해서 첫 제안을 주셨습니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

실제 운영 방법에 대한 아이디어도 있지만 적용 방식에 대부분 참조하여 반영 되어 자세한 내용은 생략하였습니다.

 

 

문제로 지적된 부분은 1) 약관을 관리하는 별다른 장치 없이 코드 레벨로 관리하고 있다는 것과 2) 어드민이나 cms 시스템을 활용해서 요청자가 관리 주체를 가져갈 수 없다는 것이었습니다. 당시에는 우선순위가 낮아 이 문제를 해결하기 위해 새로운 환경을 만들어 진행하기는 어려웠고, 새로운 약관이 추가될 때 유연한 markdown으로 관리 방법을 추가하는 정도로만 진행할 수 있었습니다.

 

이후 이 논의를 더 구체화하고 진행할 수 있도록 B님에게 온보딩 과제로 부탁드렸습니다. 첫 번째 논의에서 어드민 대신 notion 활용 방안이 나왔고 notion api를 검토하기 시작했습니다. 화해팀에서는 이미 문서 관리를 할 때 notion 활용을 잘하고 있기 때문에 개발팀이 아닌 타 부서 분들의 접근성도 좋을 것이라 예상할 수 있었습니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

시작이다.

 

 

화해 개발팀은 프로젝트를 진행할 때 테크스펙이라는 코드 설계를 리뷰하는 문화를 갖고 있습니다. 온보딩 과제로 notion api를 검토하면서 정적 파일 관리 방안에 대한 설계를 진행하고 해당 테크스펙의 내용을 바탕으로 사고를 확장하기 시작했습니다.

 

 

 

Notion API 소개

Notion API는 2021년 5월 경 퍼블릭 베타를 공개하였었습니다. 첫 공개가 되었을 때는 문서 기반 DB정도 활용 가능하리라 생각하고 넘어갔는데 이번에 다시 검토하면서 정적 페이지를 위한 저장소로 충분히 활용 가능해 보인다는 결론을 내렸습니다.

 

 

Notion의 block

Creating the notion apithe data model behind Notion’s flexibility notion의 두 개의 테크 블로그의 문서를 보면서 notion이 api의 설계를 어떤 방향으로 고민했는지 이해하는데 큰 도움이 되었습니다. Notion에서는 텍스트, 이미지, 리스트 그리고 페이지 그 자체까지 모두 block으로 표현하고 있습니다. notion 내에서 모두 다른 block 유형으로 변환되거나 이동 가능하고 이 요소들이 모여서 페이지가 만들어지고 LEGO와 같이 조합을 통해 더 큰 무언가를 만들어낸다고 이야기합니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

다 block 입니다.

 

 

notion은 api를 설계할 때 다양한 형식이 존재하는 사용자 콘텐츠를 어떠한 방식으로 제공할지 고민했습니다. markdown은 일반 텍스트 서식으로 많이 사용하지만 markdown 표준이 상대적으로 제한된 범위로 표현되며 테이블이나 인라인 취소선과 같이 제공되지 않는 표준을 제공하기 위해 다양한 종류(dialect)로 확장했습니다. 예를 들어 GFM(Github-Flavored Markdown), MultiMarkdown, CommonMark 등이 있는데 markdown으로 표현할 수 없는 notion의 콘텐츠를 최대한 충실히 보존하기 위해 json 형태의 object로 디자인하였다고 합니다. 공식 블로그 Creating nthe notion api 글에는 json object와 markdown을 선택할 때 장단점을 각각 비교한 글이 있습니다.

 

대신 notion은 markdown을 다른 방향으로 제공하고 있는데 콘텐츠를 다른 에디터로 복사 붙여넣기하거나 export 기능을 활용해서 markdown으로 콘텐츠를 얻어올 수 있습니다. 아래는 위 예제를 markdown으로 export 한 예제입니다.

 


이 text도 block 이고

- 이 toggle list도 block이며
    - [ ]  내부의 todo list도 block이면서

[이 page도 블럭입니다.](https://www.notion.so/page-1-페이지-아이디)

 

 

이렇게 notion으로부터 데이터를 얻을 수 있는 형태가 json, markdown 두 가지라는 것을 확인하고 두 형태의 차이를 이해하고 활용할 수 있는 방안들을 검토해보기로 하였습니다. 기본 markdown으로 표현하기 어려운 내용들은 여러 markdown 종류를 고려해 납득 가능한 수준으로 변환해주는 것을 확인할 수 있었는데, 다만 breadcrumb와 같이 markdown으로 표준이 없는 advanced block은 아예 export가 안되었습니다.

 

 

 

Notion 문서와 markdown export 결과 비교

아래는 notion의 기본/확장 block들을 작성하였습니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

 

 

그리고 markdown으로 변환하였습니다.


# 제목1
## 제목2
### 제목3

| 이름 | 나이 |
| --- | --- |
| 통키 | 23 |
| 맹태 | 24 |

- 순서 없는 리스트
    - 내용
1. 순서 있는 리스트
    1. 내용
- 토글
    토글 내용
> 인용
---
<aside>
💡 call out 입니다.
</aside>

사용자 멘션 @박제훈 
문서 멘션 [정책 정적 데이터 DB](https://www.notion.so/DB-경로) 
2022년 11월 29일 날짜
😄
수식을 표현합니다.  $x = mc^2$
![구글밋_07.png](https://이미지-주소)
(여기에 breadcrumb이 들어가야하는데 없습니다!)
$$
\int_{-1}^{N} \frac{2}{4}x + 1
$$

 

 

이제 notion 서비스에서 markdown 데이터를 얻어올 수 있을 거란 기대감을 갖고 notion api를 보았습니다. markdown을 얻을 수 있는지 확인했던 이유는 notion 테크 블로그에도 설명이 되어있듯이 포맷 자체가 유연성이 높아 추후 notion을 저장소로 사용하지 않고 저희 내부 cms로 이관하여 관리하거나 다른 문서 도구로 이관했을 때 유연하게 대처할 수 있을 거라 생각해 문서의 markdown 데이터를 notion api를 통해 획득하는 것을 목표로 출발하였습니다.

 

 

 

Notion API 사용 준비

Notion API의 버전 관리는 release 된 당시의 날짜를 활용하는데 작성일 기준 공식적인 마지막 버전은 2022-06-28입니다. 이전 버전과 호환되지 않는 API 변경이 있을 경우에 새로운 버전이 release 되는데 이번 글에서는 이 버전 기준으로 설명을 시작하겠습니다.

 

Notion api를 활용하려면 getting started 문서를 참고하여 notion integration을 만들어서 문서에 연결해줘야 합니다. integration 이후 Internal integration token을 얻을 수 있는데 이 token을 활용하여 notion api를 통해 데이터를 획득할 수 있습니다. getting started 문서를 보면 권한 관리에 관한 내용도 있는데 자세한 설명은 문서를 참고 부탁드립니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

연결이 완료된 상태입니다.

 

 

그리고 javascript를 사용할 때는 아래와 같이 사용합니다.

 


const { Client } = require("@notionhq/client");

async function awesomeFunction() {
  const token = process.env.YOUR_NOTION_TOKEN;

  const notion = new Client({
    auth: token,
    notionVersion: '2022-06-28'
  });

  // 이후 notion 객체를 활용해서 api를 사용합시다.
}

 

위 예제는 공통적으로 사용 예정이라 뒤의 예제부터는 생략하겠습니다.

 

 

현재 공식적으로 제공되는 notion api의 큰 카테고리는 databases, pages, blocks, comments, users, searchs입니다. 위 카테고리 중 주로 검토했던 카테고리는 databases, pages, blocks입니다.

 

 

Database

Database는 notion에서 block을 생성할 때 database 카테고리로 묶여있는 block 집합을 의미합니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

이렇게 생겼습니다.

 

 

Database의 정보를 얻어올 때는 속성 스키마 정보를 얻어올 수 있는 retrieve api와 database에 포함된 page 목록을 가져올 수 있는 query api 가 있습니다. 그 외에 create, update api가 있지만 이 글에서는 생략하겠습니다. Notion url은 https://www.notion.so/{workspace_name}/{database_id}?v={view_id}와 같은 형태로 이루어져 있는데 database_id를 활용하여 아래와 같이 정보를 얻어올 수 있습니다.


const databaseId = 'database-id';
const dbObjects = await notion.databases.retrieve({ database_id: databaseId });
const dbQueryData = await notion.databases.query({ database_id: databaseId });

 

 

Database Object는 database를 구성하는 속성들로 구성이 되어있고 query로 획득 가능하며, 페이지들은 query 메소드를 통해서 획득 가능합니다.

 

아래는 retrieve 응답값의 일부입니다.

{
  ...
  "object": "database",
  "properties": {
    "Tags": {
      "id": "%3FsVu",
      "name": "Tags",
      "type": "select",
      "select": {
        "options": [{
          "id": "8230ce9f-801a-4dde-b2fd-5f39d93b99f8",
          "name": "화해 앱",
          "color": "blue"
	      }],
      },
    },
  ...
}
💡모든 응답값에서 공통적으로 등장하는 object 속성은 해당 응답이 어떤 object인지 알려주는 고윳값으로 항상 같은 값을 전달해 줍니다. 예를 들어 database object는 { “object”: “database” }, pagination object는 { “object”: “list” }, block object는 { “object”: “block” }과 같은 형태입니다. 앞으로 설명은 생략하겠습니다.

 

 

그리고 query를 통해 데이터를 받아오면 아래와 같이 page object 데이터를 받아볼 수 있습니다.

{
  ...,
  "object": "list",
  "has_more": false,
  "next_cursor": null,
  "result": [{
    "object": "page",
    "archived": false,
    "id": "page-id",
    "properties": { "Tags": {...}, "이름": {...}, ... },
  }, ... ],
}

 

여기서 list라는 object가 등장하는데 notion은 데이터의 페이지 매김(paginating)을 offset 기반과 cursor 기반 중 cursor 기반으로 선택하였습니다. 그래서 query 메소드를 호출할 때 list object가 응답값으로 오기 때문에 아래와 같이 start_cursor와 next_cursor를 활용하여 페이징 된 데이터를 얻어올 수 있습니다. 아래 코드는 notion-to-md 오픈소스의 이 코드를 조금 수정하여 작성했습니다.

let startCursor = undefined;

do {
  const list = await notion.blocks.children.list({
    start_cursor: startCursor,
    block_id: blockId,
  });
  // list로 무언가 무언가 하고
  startCursor = response?.next_cursor;
} while (startCursor !== null);

더 자세한 내용은 pagination 문서에서 확인할 수 있습니다.

 

또한 database object를 통해서 우리는 database의 콘텐츠를 필터링해서 가져올 수 있습니다. Notion 서비스에서 database를 필터링하는 것과 동일합니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

필터도 가능한데 정렬도 가능합니다. 물론 정렬한 page 데이터도 획득 가능합니다.

 

 

본래 필터링된 linked database를 관리하는 방법도 선택지에 있었지만 사용할 수는 없었는데, 대신 원하는 page 데이터만 획득하려 한다면 아래와 같이 filter 속성을 추가해 원하는 결과를 얻을 수 있습니다.

const dbQueryData = await notion.databases.query({
  database_id: databaseId,
  filter: { // 이름 속성중 Terms of Use를 포함
    property: '이름',
    'rich_text': {
      contains: 'Terms of Use',
    },
  },
  sorts: [{ // Tags 프로퍼티를 오름차순 정렬
    property: 'Tags',
    direction: 'ascending',
  }],
);

 

 

이제 page object를 가져왔으니 page를 알아봅시다.

 

 

Pages

Page api도 database api와 유사하게 Page Object를 조회할 수 있는 retrieve api가 있습니다. 아래는 notion 블로그에서 이미지를 가져왔는데 page properties라고 되어있는 영역의 데이터를 획득합니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해
notion 공식 블로그에서 발췌하였습니다.

 

 

동일하게 create, update가 있으나 생략하겠습니다. 위 database query api에서 획득한 page object의 id를 통해서도 조회가 가능하고 notion url로 조회할 경우 https://www.notion.so/{workspace_name}/{page_id}/code>의 page_id를 활용해도 됩니다. 위 속성에서 page의 title과 같은 속성값들을 얻어올 수 있습니다. 이제 database, page의 속성 스키마들을 가져왔으니 본격적으로 page content의 내용을 가져와 봅시다.

 

 

Block

앞서 설명드린 것과 같이 notion의 구성은 모든 것이 block object로 표현됩니다. Block type을 확인해보면 child page blockschild database blocks와 같이 콘텐츠를 구성하는 종단 page, database의 정보도 block object로서 데이터를 얻을 수 있습니다. Block api는 아래와 같이 사용합니다.

const blockId = 'page-id'; // page_id, database_id, block object의 id 다 가능합니다.
const blockData = await notion.blocks.retrieve({ block_id: blockId });

 

notion api는 block object 트리를 구성하면서 중첩된 하위 block이나 page들을 제공할 때 성능상의 이유로 너비 우선 페이지 매김 (paginate breadth-first)을 선택하였습니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해
notion 공식 블로그에서 발췌하였습니다.

 

 

그래서 page를 구성하는 block들은 has_children이라는 속성을 활용해서 중첩된 요소들을 검색할 수 있게 응답값을 내려줍니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

 

 

has_children은 들여쓰기가 된 block 요소 바로 상위 block에게 지정됩니다. 예를 들어 위 그림의 텍스트입니다. block 바로 아래 들여쓰기 한 텍스트입니다. 가 위 block의 children으로 지정된 것입니다. children은 아래 코드와 같이 blocks.children.list로 획득할 수 있습니다.

const blockChildData = await notion.blocks.children.list({ block_id: blockId });

 

 

그래서 위 이미지의 페이지의 block 데이터를 모두 획득하려면 아래와 같은 과정을 거쳐야 합니다.

const pageId = 'page-id';
// # 1. child_page에 해당하는 block 데이터를 가져옵니다. has_children은 true입니다.
const blockData = await notion.blocks.retrieve({ block_id: pageId });
// # 2. child_page의 children list를 가져옵니다.
// [child database block(정책 DB), paragraph block(텍스트입니다)]를 results로 받습니다.
const blockChildData = await notion.blocks.children.list({ block_id: pageId });
// # 3. paragraph의 children paragraph block을 가져옵니다.
// [paragraph block(들여쓰기 한 텍스트입니다.)]를 results로 받습니다.
const blockChild2DepthData = await notion.blocks.children.list({
  block_id: blockChildData.results[1].id,
});

 

 

block object는 아래와 같은 기본 구조로 설계가 되어있습니다.

{
  "object": "block",
  "id": "UUIDv4로 만들어진 id",
  "type": "Type of Block. ex) paragraph, heading_1, unsupported 등등..",
  ...,
  {type}: Block Type Object
}

 

 

위의 {type}은 block들이 갖는 type 값을 키로 사용하는 속성들을 갖고 있습니다. 해당 속성에 저희가 원하는 block의 실제 내용들이 들어있습니다. 아래는 paragraph block의 응답값의 예제입니다.

{
  "object": "block",
  "type": "paragraph",
  "paragraph": {
    "rich_text": [
      {
        "type": "text",
        "text": {
          "content": "들여쓰기 한 텍스트입니다.",
          "link": null
        },
        "annotations": {
          "bold": false,
          "italic": false,
          "strikethrough": false,
          "underline": false,
          "code": false,
          "color": "default"
        },
        "plain_text": "들여쓰기 한 텍스트입니다.",
        "href": null
      }
    ],
    "color": "default",
  },
}

 

 

Limitations

가장 먼저 보인 것은 heading이 3까지만 존재한다는 것이었습니다. 약관과 같은 정적 페이지를 구성할 때 h4, h5까지 사용되는 경우가 종종 있었어서 이 부분이 많이 아쉬웠었습니다. 다만 heading 텍스트에 prefix를 붙이거나 타이틀을 h1으로 쓰고 시작하는 heading 레벨을 1이 아닌 2나 3으로 변경하는 방식들로 데이터를 파싱할 때 해결은 가능해 보여서 현재는 한계점을 인지만 해둔 상태입니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해
좋은 방법은 아닌 것 같지만… h3이 마지막입니다.

 

 

Database api를 사용할 때 한 가지 큰 제약사항이 있는데 현재 버전의 notion api에서는 linked database는 정보를 가져올 수 없다는 것입니다. 이와 관련해 Working with databases 문서에 기술되어있으며 그 외 database를 활용하는 방법들에 대해서도 자세히 작성되어 있습니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해
위가 원본 Database이며 아래 이름 앞에 화살표가 있는 database가 위의 “정책 DB”를 참조로 한 linked database입니다. 처음에 페이지를 필터 된 DB로 나눠서 관리하려 했지만 포기했습니다.

 

 

page property에 대한 limitaions들도 있습니다. 그리고 notion api 요청에 대한 limit들도 존재합니다. 리퀘스트 요청은 초당 평균 3개의 요청으로 제한이 걸립니다. 다만 속도 제한은 앞으로 변경될 여지를 두었습니다.

 

 

Rate limits may change

In the future, Notion plans to adjust rate limits to balance for demand and reliability. Notion may also introduce distinct rate limits for workspaces in different pricing plans.

 

 

그 외 rich text object와 같은 속성 값들의 크기 제한들이 있으니 참고하시면 좋을 것 같습니다. 제약 사항들이 notion API 사용에 있어서 큰 문제가 될 수도 있어서 최대한 모아놓았습니다.

 

 

이제 데이터를 얻어오는 주요한 내용들은 다 확인을 하였습니다. 얻은 데이터를 통해서 페이지를 구성하면 됩니다. 더 많은 활용 가능한 기능들이 있지만 이 글에서는 데이터 획득이 목적이라 여기까지 알아보겠습니다. 이제 설계 후 구현만 하면 될 것 같은데 여기까지 도달했지만 아직 못 찾은 것이 있습니다.

 

바로 markdown을 얻는 방법입니다.

 

 

 

Unofficial notion api

Notion api는 json object로 다양한 포맷들의 문서를 충실하게 제공해주고 있고 위에서 알아본 바와 같이 notion으로는 markdown을 획득할 수 있는 방법이 존재합니다. 그러나 markdown을 얻을 수 있는 방법이 공식적인 api에는 존재하지 않습니다. 심지어 notion api roadmap에서도 markdown에 관련된 내용은 찾아볼 수 없었습니다(혹시 제가 못 찾은 것이라면 제발…!).

 

Github에서 오픈소스를 찾다 보니 notion-exporter라는 notion 문서를 markdown이나 csv로 export해주는 오픈소스가 있었습니다. notion으로 export 테스트하면서 봤던 기억을 떠올려봅니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해
notion의 export 기능입니다. 기능이 똑같네요?

 

 

처음엔 별 의심 없이 notion 문서를 export 할 수 있는 방법이 알고 싶어서 readme를 다 읽어보지 않고 notion-exporter의 기능 테스트 해보았는데 굉장히 잘 동작하였습니다. 그 후 놓친 게 있었나 공식 api 문서를 다 읽고 다시 찾아봐도 export 기능이 없어서 readme를 다시 읽어봤습니다. 저처럼 헤매지 말고 Readme를 항상 잘 읽읍시다.

 

 

Note on Stability

 

This tool completely relies on the export/download feature of the official but internalNotion.so API. The advantage is, that we do not generate any markup ourselves, just download and extract some ZIPs. While the download feature seems to be pretty stable, keep in mind that it still is an internal API, so it may break anytime.

 

 

공식 기능인데 internal Notion.so API? 조금 안정적인데 언제든 깨질 수 있다니? 설마 싶어서 코드를 바로 확인해 보았습니다.

export class NotionExporter {
  protected readonly client: AxiosInstance;

  ...

  async getTaskId(idOrUrl: string): Promise<string> {
    const id = validateUuid(blockIdFromUrl(idOrUrl))
    if (!id) return Promise.reject(`Invalid URL or blockId: ${idOrUrl}`)

    const res = await this.client.post("enqueueTask", {
      task: {
        eventName: "exportBlock",
        request: {
          block: { id },
          recursive: false,
          exportOptions: {
            exportType: "markdown",
            timeZone: "Europe/Zurich",
            locale: "en",
          },
        },
      },
    })
    return res.data.taskId
  }
</string>

 

Notion 서비스에서 실제로 export를 하고 네트워크 탭을 열어서 확인해봤습니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

 

 

그렇습니다. Notion 서비스의 request들을 그대로 사용하였습니다. 실제로 해당 요청을 보내려면 token_v2라는 token값이 필요합니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

chrome dev tool > application > cookie 에서 직접 복사해 오면 됩니다.

 

 

현재 notion 서비스 내부에서 사용 중인 api들은 token_v2를 요청 헤더에 함께 보내면 서비스 기능을 대부분 사용 가능한 것으로 보였습니다. 18년도 글이라 현재는 조금 다른 것 같지만 Hidden notion api라고 올린 83개의 메소드가 정리된 글도 있고, 실제로 notion internal api를 사용하는 것을 모아 정리한 오픈소스도 있었습니다.

 

아래는 notion internal api를 통해서 notion block data를 얻어오는 코드 중 일부입니다.

public async getBlocks(
  blockIds: string[],
  gotOptions?: OptionsOfJSONResponseBody
) {
  return this.fetch<notion.pagechunk>({
    endpoint: 'syncRecordValues',
    body: {
      requests: blockIds.map((blockId) => ({
        // TODO: when to use table 'space' vs 'block'?
        table: 'block',
        id: blockId,
        version: -1
      }))
    },
    gotOptions
  })
}

</notion.pagechunk>

 

아래의 block 데이터를 notion 서비스에서 페이지로 구성할 때는 실제로 https://www.notion.so/api/v3/syncRecordValues 요청 주소를 활용해서 요청하고 있습니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

 

 

이렇게 notion 서비스 내부 api를 활용할 수 있다는 것을 확인한 이후 사용 가능한 내부 api를 활용할지 유혹에 빠졌었는데요 내부 api인 enqueueTask를 활용하면 요청 한 번으로 전체 markdown을 획득할 수 있고 내부 api라서 인터페이스의 큰 변화가 자주 있지는 않을 것 같았습니다. 다른 자료를 찾아보니 실제로 2년 이상 잘 쓰이고 있는 것 같습니다. (2020년 7월 글 참고)

 

하지만 notion에서 사용되는 내부 api 인터페이스가 언제 변경될지도 모르고 token_v2를 관리하는 방식도 개인 프로젝트가 아니기 때문에 유효하지 않다고 판단했습니다. 다만 유지보수가 크게 문제가 안 되는 가벼운 개인 프로젝트나 노션 개인 작업용으로 사용한다면 활용해볼 만할 것 같습니다.

 

공식 notion api를 사용해서 markdown 데이터를 얻는 것은 현재 불가능하다는 판단이 든 이후 어떻게 대응할지 고민해 보았습니다.

 

 

 

Notion block data를 markdown으로 변환하기

react-notion-x의 Text 컴포넌트와 같이 notion api를 통해 받은 block 데이터를 그대로 react 컴포넌트로 파싱해서 사용할 수도 있습니다. 실제로 notion api를 리서치해주셨던 B님의 테크스펙도 최초에는 위 방식으로 접근을 하였습니다. 아래는 테크스펙을 작성하면서 진행했던 POC(proof of concept) 코드의 일부입니다.

// plain_text와 href, annotations는 notion block 데이터에서
// 직접 가져와서 Text 컴포넌트를 생성합니다.
export default function Text({ plain_text, href, annotations }: NotionText) {
  return href ? (
    <a href="{href}" target="_blank" rel="noopener noreferrer">
      {plain_text}
    </a>
  ) : (
    <styledtext annotations="">{plain_text}</styledtext>
  );
}

 

데이터를 markdown으로 관리하려 했던 이유는 텍스트 서식 언어로서 다양한 환경에서 사용되는 유연함이 코드 레벨에서도 똑같이 유연함을 발휘하기 때문이었습니다. Javascript 환경에서 markdown을 html로 변환하거나 react component로 변경할 때 많이 사용되는 오픈소스가 있습니다. 콘텐츠를 구조화된 AST(Abstract Syntax Tree)로 관리하고 텍스트를 특정 언어로 변환해주는 unified라는 인터페이스 기반 플러그인 remarkrehype입니다. remark는 markdown으로, rehype는 html으로 텍스트를 변환하는 플러그인입니다.

 

unified는 아래와 같은 단계를 거쳐서 텍스트를 markdown에서 html로 혹은 markdown에서 html로 변환해 줍니다. unified에서 활용 가능한 AST 중 많이 활용되는 것은 mdast, hast, nlcst(natural language concrete syntax tree) 정도인 것 같습니다.

| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|

          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                              X
                              |
                       +--------------+
                       | Transformers |
                       +--------------+

// from https://github.com/unifiedjs/unified#overview

 

Parser는 텍스트를 AST로 변환해주고 이 약속된 AST 기반에서 compiler를 통해서 다른 텍스트로 변환해 줍니다. 예를 들어 remark의 remark-parse 패키지는 markdown을 mdast(markdown AST)로 변환합니다. 우리는 데이터를 notion이라는 종속성이 강한 인터페이스로 관리하기보다는 위와 같이 범용적으로 활용할 수 있는 언어의 형태로 관리하는 것이 향후 유지보수 관점에서 유연함을 가질 거라 생각했습니다.

 

코드로 확인해보면 notion의 block 데이터를 markdown으로 파싱 하였다고 가정하면 아래와 같이 unified를 이용하여 html로 변환할 수 있습니다.

import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

...

// notion data -> markdown
const markdownData = parseNotion2MD(notionData);

const load = await unified()
  .use(remarkParse) // markdown을 mdast로 변환 
  .use(remarkGfm) // remark가 GFM도 지원 가능하도록
  .use(remarkBreaks) // remark가 line-break도 지원 가능하도록
  .use(remarkRehype, { allowDangerousHtml: true }) // mdast를 hast로 변환
  .use(rehypeStringify, {allowDangerousHtml: true}) // hast를 html 변환
  .process(markdownData);

// 위 과정을 markdownData를 input으로 해서 최종적으로 html로 변환합니다.

 

위 동작에서 심플하게 markdown을 직렬화할 때 사용하는 remark의 코드는 아래와 같습니다.

// https://github.com/remarkjs/remark/blob/main/packages/remark/index.js
export const remark = unified().use(remarkParse).use(remarkStringify).freeze()

 

이렇게 mdast, hast 기반으로 활용되는 것처럼 Notion은 AST가 없는지 궁금해졌습니다. Notion 데이터롤 Notion AST로 관리한다면 rehype를 통해 바로 html로 변환하면 되고, markdown으로 익스포트 해야 한다면 Notion AST를 remark로 변환해주면 markdown으로 변환을 고민할 필요가 없을 거라 생각했습니다.

 

검색해 보았을 때 현재 공개는 되지 않은 것 같지만 2년 전부터 진행하고 있고 사용하고 있을 거라 유추되는 ntast가 확인이 되었고, notion에서 rehype를 통해 html로 바로 변환해주는 notion-rehype도 있었습니다. remark-notion이라는 markdown에서 notion block data로 변환해 주는 오픈소스 라이브러리도 있었지만 제가 원하는 오픈소스 라이브러리는 아직 찾지 못했습니다. Notion AST를 직접 만드는 것이 가장 유연하게 관리할 수 있는 방법일 수 있겠다는 생각도 했습니다. 지만 가볍게 진행할 수 있는 수준은 아니기에 차선책을 선택해야 했습니다.

 

그래서 현재는 notion-to-md라는 오픈소스의 코드를 활용해서 markdown으로 변환하고 remark, rehype로 최종 결과물을 얻는 방식으로 가닥을 잡고 진행하고 있습니다.

 

notion-to-md는 공식 api를 통해 block데이터를 얻어서 markdown으로 직접 파싱합니다. 본래 파싱하는 부분을 직접 구현할까 생각했다가 이미 생각한 내용들이 거의 원하는 형태로 구현되어있어서 현재는 이 오픈소스 라이브러리를 사용하려 합니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

노션 데이터입니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

브라우저에 렌더링 된 결과입니다.

 

 

Notion API와 함께 정적 페이지로의 여정_화해

변경된 html입니다.

 

 

지금까지 작성한 내용을 기반으로 POC를 진행하였고, 위와 같이 잘 동작하는 것을 확인했습니다.

 

 

 

끝으로

본격적인 논의 시작 후 약 3주 간 리서치한 과정을 글로 옮겨보았습니다. 사실 설계를 하며 이 글을 작성했기 때문에 실제 진행 과정에서 변경의 여지가 있습니다. 과제 완성 후 최종 결과물을 공유하는 것도 좋지만 결과물이 완성되어가는 과정을 기록하고 소개하는 것 또한 의미 있는 일이라 생각했습니다.

 

아직도 좋은 설계나 유연한 설계에 대한 고민, 그리고 현재 선택한 방법이 정말 최선인지 항상 고민하면서 다음을 향해 나아가고 있습니다. 항상 어려운 일이지만 플랫폼 분들과 지금까지 정리된 내용으로 또 다른 논의 내용이나 좋은 방향들을 이야기하고 발전시켜 더 멋진 결과물을 만들어 낼 수 있을 거라는 확신이 있습니다. 지금 이 아이디어나 방향성도 저 혼자 결정한 것이 아니라 프론트엔드 플랫폼 분들의 손을 거쳐가면서 논의하고 발전된 결과물이기 때문입니다.

 

 

우리가 찾아야 할 것을 찾거나 해야 할 일을 해냈을 때 기분이 좋은 이유는 도파민 때문이라고 합니다. 이 내용도 작은 문제의식에서 출발해 목표를 설정하고 해결 방법을 향해 계속 향해가고 있습니다. 지금도 내일도 내년도 도파민이 계속 생성될 수 있는 플랫폼이 되어갔으면 하는 마음으로 글을 마무리합니다.

 

 


이 글이 마음에 드셨다면 다른 콘텐츠도 확인해보세요!

보쌈 먹으며 프론트엔드와 친해지기

React Hook Form의 isDirty와 dirtyFields를 알아보자

  • 프론트엔드
  • Notion API
  • 정적페이지
avatar image

박제훈 | Front-end Developer

화해팀에서 Front-end 리딩을 맡고 있습니다.

연관 아티클