Hits
Svelte 2022. 9. 23. 오전 10:25:00

Svelte-Kit + Notion Blog

"SvelteKit에 Notion API적용하기"
sveltenotion-apiblog

Svelte-Kit + Notion API를 사용해서 블로그를 만들어보자!

Notion API Beta가 출시됐다는 얘기를 들은지는 꽤 됐지만 미루고 미루다 보니 이제야 사용해보게 되었습니다. 이번에는 Notion API를 사용해서 블로그를 만들어보려고 하는데요. NextJS를 사용해서 블로그를 만든 글은 본 기억이 있는데 Svelte를 이용한 사례는 안보이더군요. 개인적으로 Svelte를 만족스럽게 사용중인데 Svelte-Kit를 사용해보려고 하셨던 분이나 블로그를 만드시려는 분은 이 조합으로 사용해보시는 걸 추천드립니다. 이번 글에서는 Svelte-Kit프로젝트를 만들고 Notion-API 연결해서 노션 데이터를 불러오는 작업까지만 해보도록 하겠습니다.

💡 이 글은 Svelte문법에 대해 따로 다루지는 않지만 Svelte-Kit에 대한 지식이 낮다는걸 전제로 작성되었습니다. Notion API에 관해서는 Svelte-Kit에만 국한되지 않고 제가 아는 한에서 Notion API에 대한 사용방법을 최대한 상세히 기술하려 노력했습니다만 노션 사용법에 대해서는 따로 다루지 않습니다.

Svelte-Kit?

ReactNextJS가 있다면 SvelteSvelte-Kit이 있습니다. Svelte의 서버사이드 프레임워크라고 보시면 될거같습니다. Notion API가 CORS지원이 안된다는 이야기가 있어서(제가 확인해보지는 않았습니다.) 서버사이드에서 사용해야하고, 개인적으로 ReactSvelte만 쓰는것보다 NextJSSvelte-kit을 선호해서 이번에도 Svelte-Kit으로 개발했습니다. 다만 이 글이 작성된 현재 더이상의 breaking change는 없다고 해도 아직 1.0버전이 나오지는 않았기 때문에 이 점 유의하시기 바랍니다.

Notion API

개인적으로 Notion API를 사용해본 경험은 일단 beta라 그런지 자료가 부족하다는 아쉬움이 있습니다. 그래도 따로 백엔드를 구축하는것보다 간편하고 개인페이지의 경우 블록수가 무제한이기 때문에 개인 프로젝트 용도로는 충분하고, 블로그용으로 쓰기에는 Notion이라는 훌륭한 에디터를 사용할 수 있다는 점에서 큰 장점이 있었습니다. 리스트를 불러오는 것은 빨랐지만 포스트 컨텐츠를 불러오는게 느렸는데 이건 Notion API 문제보다는 제가 사용한 라이브러리에서 속도저하가 발생한 것 같습니다. 글을 작성한지 얼마 안됐거나 수정했을 경우는 페이지를 불러오는데 시간이 좀 걸리지만 서버에서 데이터를 미리 불러온 후에는 그나마 좀 빨라져서 블로그로 사용하기로는 무리없다고 생각합니다. 원래 블로그를 Gatsby + Github pages로 배포했는데 포스팅 할때마다 markdown문서를 작성하는것까지는 그렇다 치더라도 빌드하고 배포하는 과정을 거쳐야하는게 너무 불편했습니다. 하지만 이제는 노션을 이용해 어떤 기기에서든 틈틈이 포스트를 작성하고 Published 속성을 체크만 해주면 블로그에 올라갈 수 있도록 설정해서 포스팅이 매우 편해졌습니다.

Svelte Kit & Notion API init

Svelte-Kit 프로젝트 만들기

본격적인 작업에 들어가봅시다. 우선 Svelte-Kit 프로젝트를 만들어야합니다. 과정은 간단합니다.

  1. 아래의 명령어를 통해 프로젝트 생성을 시작합니다.
    npm create svelte@latest svelte-notion-blog
  2. 바로 따라오는 진행하겠냐는 질문에 y를 입력해주면 세가지 템플릿 선택지가 주어집니다. 두번째에 Skeleton Project를 선택해주도록 하겠습니다.
  3. 다음으로 Typescript, ESLint, Prettier, Playwright을 사용할지 선택하는데 이부분은 선택사항입니다. 저는 일단 다 사용하는걸로 선택했습니다. Playwright는 Cypress와 같은 테스트도구입니다.
  4. 프로젝트 생성이 완료됐으면 프로젝트 루트로 들어가 노드 모듈을 설치해줌으로써 프로젝트 생성은 마무리됩니다.
    cd svelte-notion-blog
    npm install

Notion API 설정

이제 Notion API를 사용하기위해 아래 과정을 따라주시면 됩니다.

  1. 기본적으로 Notion API를 사용하기 위해서는 @notionhq/client라이브러리를 설치해주어야합니다. 추가로 한가지를 더 설치해주어야 하는데 이 글에서는 노션을 블로그 에디터 & 데이터베이스로 사용하고 Svelte-Kit에서 노션에 저장된 글을 markdown으로 가져와 가독성을 좋게 해주는 방식을 사용할 예정입니다. Notion API를 이용해 데이터를 불러오게 되면 json형태인데 이걸 parsing해서 markdown으로 바꿔줘야 합니다. 이를 위한 notion-to-md라는 좋은 라이브러리가 있어 설치해서 사용하도록 하겠습니다.
    npm install @notionhq/client notion-to-md
  2. 다음은 Notion과 블로그의 연동을 위해 Notion API integration을 세팅해주셔야하는데요, 링크로 들어가시면 아래와 같은 화면을 보실 수 있습니다. 저는 블로그를 만드는 과정에서 integration을 만들었는데 새 API 통합 만들기로 들어가서 새 integration을 만들어줍니다. 이름과 워크스페이스를 선택해주시고 기능을 선택하는데 이번에 블로그를 만드는 과정에서는 콘텐츠 읽기 기능만 사용할 예정입니다. 워크스페이스를 제외한 나머지 사항들은 수정이 가능하기 때문에 부담갖지 않고 설정해주셔도 됩니다.
  3. 이제 우리 블로그 글을 저장할 데이터베이스를 만들어야 합니다. 노션에 들어가서 새 페이지를 만들어주도록 합시다. 새페이지를 만들게 되면 아래와 같은 화면을 보실텐데요. 제목을 입력해주시고 아래에서 표 혹은 리스트를 선택해주도록 합시다. 뭐를 선택하시던 유형은 나중에도 변경 가능하기 때문에 상관 없지만 저는 블로그를 관리하는 목적으로는 리스트가 더 편했습니다. 우측 아래에 새 데이터베이스 생성을 눌러 새로운 데이터베이스를 만들어주도록 하겠습니다. 데이터베이스를 만들면 페이지1, 페이지2, 페이지3 데이터가 입력돼있는걸 볼 수 있습니다. 없으면 만들어주시면 됩니다. 제목이나 내용은 아무렇게나 입력하셔도 됩니다. 테스트를 위해 사용될 예정입니다. 그리고 오른쪽 위에 …을 누르고 연결>연결추가>만들어둔 integration을 눌러서 데이터베이스를 Notion API가 사용할 수 있도록 설정해줍니다.
  4. 우선 앞서 만든 Svelte-Kit프로젝트 루트디렉토리에 .env파일을 만들어줍시다. 2번 단계에서 integation을 만들어주셨으면 프라이빗 API 통합 토큰이라는걸 발급받으셨을 텐데요, 저는 NOTION_API_KEY라는 key에 넣어주도록 하겠습니다. 데이터베이스 아이디는 3번에서 만든 데이터베이스의 링크를 복사해서 나온 주소에 포함돼있습니다.

    📎 .https://www.notion.so/myworkspace/a8aec43384f447ed84390e8e42c2e089?v=...

    myworkspace/ 뒤부터 물음표 앞까지의 부분을 복사해주시면 됩니다. 저는 NOTION_DATABASE_ID에 넣어주었습니다.
    # .env
    NOTION_API_KEY=secret_******** # NOTION API 생성해서 나온 secret key
    NOTION_DATABASE_ID=********** # NOTION DATABASE 링크 복사해서 얻은 키

Notion API 사용하기

이제 Notion API를 사용할 준비가 끝났습니다. 본격적으로 Svelte-Kit에서 Notion API로 쿼리를 날려보도록 하겠습니다.

Notion API 사용 준비

인스턴스 생성

저는 src/lib디렉토리에 notion.ts라는 이름의 파일을 만들고 여기서 Notion API 함수를 만들고 import하는 방식으로 사용했습니다. src디렉토리 하위에 lib디렉토리를 만들어주고 그 안에 notion.ts파일을 만들고 열어주도록 합시다.

우선은 노션 클라이언트 인스턴스를 생성해야합니다. 인스턴스를 생성할때에는 옵션값으로 auth에 integration에서 받은 secret key를 입력해주어야 합니다. Svelte-Kit에서는 환경변수값을 입력할 때 process.env를 사용하지 않습니다. 환경변수를 클라이언트에서 사용하는지, 서버에서 사용하는지로 갈리고 정적으로 입력하는지 동적으로 입력하는지에 따라 방법이 다른데 Notion API는 클라이언트에서 바로 요청할 경우 CORS에러가 발생하기 때문에 서버사이드에서 요청할 예정이고, 정적으로 입력해주기 때문에 $env/static/private에서 환경변수를 불러주면 됩니다.

> 💡 클라이언트에서 정적으로 환경변수를 주입할 경우 변수앞에 `PUBLIC_`이 붙어야하고 `$env/static/public`으로 주입해주어야 합니다.  
> 만약 IDE에서 환경변수를 인식하지 못한다면 `npm run dev`를 한번 실행해주면 해결됩니다.

그리고 함께 NotionToMarkdown(이하 n2m) 인스턴스도 생성해주도록 하겠습니다. n2m 인스턴스의 옵션으로는 노션 인스턴스를 주입해주어야 합니다.

// src/lib/notion.ts

import { Client } from '@notionhq/client';
import { NOTION_API_KEY } from '$env/static/private';
import { NotionToMarkdown } from 'notion-to-md';

const notion = new Client({ auth: NOTION_API_KEY });
const n2m = new NotionToMarkdown({ notionClient: notion });

Notion API로 포스트 리스트 불러오기

기본 조회 방법

블로그 상세페이지를 받아오기 전에 리스트를 먼저 받아와보도록 하겠습니다.

// src/lib/notion.ts

// ...imports

import { NOTION_DATABASE_ID } from '$env/static/private';

// notion, n2m instance

export const fetchPosts = async () => {
    const posts = await notion.databases.query({
        database_id: NOTION_DATABASE_ID,
        // ...filter, ...sorts, ...other conditions
    });
    console.log(posts);
    return posts;
};

노션 데이터베이스에 쿼리를 날리는 방법은 위와 같이 notion.databases.query메소드에 환경변수로 지정해둔 데이터베이스 아이디를 지정해주는 것입니다. filter나 sort, paging등이 필요하면 주석처리 한 부분에 조건을 추가해주면 되는데 이건 잠시후에 해보고 일단 쿼리를 날린 뒤 결과값을 받아보도록 하겠습니다.

// src/routes/+page.server.ts

import { fetchPosts } from '$lib/notion';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
    const posts = await fetchPosts();
    
    return { posts };
}

+page.server.tsNextJSgetServerSideProps()와 같은 역할을 합니다. 서버단에서 데이터를 먼저 받아서 클라이언트로 보내줍니다. ./$types파일과 PageServerLoad타입은 빌드 시 Svelte-Kit에서 자동 생성하기 때문에 따로 작업할 필요가 없습니다. src/routes디렉토리 하위에 +page.server.ts파일을 생성해 준뒤 위의 코드를 입력하고 npm run dev를 통해 서버를 실행시킵니다. Svelte-Kit은 따로 설정하지 않으면 기본으로 5173번 포트를 사용하기 때문에 브라우저 주소창에 localhost:5173을 입력해 접속해보겠습니다. +pages.server.ts파일은 서버환경에서 구동되기 때문에 console.log역시 브라우저가 아니라 터미널에서 확인할 수 있습니다.

{
  object: 'list',
  results: [
    {
      object: 'page',
      id: '',
      created_time: '2022-10-04T14:37:00.000Z',
      last_edited_time: '2022-10-04T14:37:00.000Z',
      created_by: [Object],
      last_edited_by: [Object],
      cover: null,
      icon: null,
      parent: [Object],
      archived: false,
      properties: [Object],
      url: 'https://www.notion.so/'
    },
    {
      object: 'page',
      id: '',
      created_time: '2022-10-04T14:37:00.000Z',
      last_edited_time: '2022-10-04T14:37:00.000Z',
      created_by: [Object],
      last_edited_by: [Object],
      cover: null,
      icon: null,
      parent: [Object],
      archived: false,
      properties: [Object],
      url: 'https://www.notion.so/'
    },
    {
      object: 'page',
      id: '',
      created_time: '2022-10-04T14:37:00.000Z',
      last_edited_time: '2022-10-04T14:37:00.000Z',
      created_by: [Object],
      last_edited_by: [Object],
      cover: null,
      icon: null,
      parent: [Object],
      archived: false,
      properties: [Object],
      url: 'https://www.notion.so/'
    }
  ],
  next_cursor: null,
  has_more: false,
  type: 'page',
  page: {}
}

id값, url일부는 지웠습니다. 구조가 이렇게 생겼다라는 것만 봐주시면 됩니다. 쿼리에 조건을 달아두지 않아서 이렇게 세개 모두 불러오는 모습을 확인할 수 있습니다. 마지막에 next_cursor, has_more 등으로 보아 Cursor based pagination도 지원하는 것 같지만 아직 paging은 필요하지 않기 때문에 사용하지 않겠습니다. 저희가 필요한건 results안에 있는 데이터입니다. 아까 만들어둔 fetchPages 함수를 살짝 수정하도록 하겠습니다.

// src/lib/notion.ts

export const fetchPosts = async () => {
    const { results } = await notion.databases.query({
        database_id: NOTION_DATABASE_ID,
        // ...filter, ...sorts, ...other conditions
    });
    return results;
};

Notion API 쿼리에 Filter적용

블로그에 포스팅을 하면 카테고리나 태그 등을 설정하기도 하는데요. 노션 데이터에서 속성을 활용해 이런 것들을 설정할 수 있습니다. 저는 요약, 카테고리, 태그, 게시여부를 속성으로 두어 관리하고 있습니다. 게시여부를 속성값으로 두게 되면 틈틈이 작업하다가 완성되면 블로그에 업로드 할 수 있습니다.

속성 추가를 눌러 이렇게 네개의 속성을 추가해보겠습니다. 속성은 모든 데이터가 공통적으로 갖기 때문에 하나의 데이터에서 만들어주면 나머지 데이터에도 자동적으로 추가됩니다. 값은 아무렇게나 입력하셔도 됩니다. 일단은 두개의 글만 Published를 체크해 Filter를 적용해보겠습니다. 저는 페이지1, 페이지3을 체크했습니다. 블로그엔 완성된 글만 올라갈 예정이기 때문에 앞서만든 함수를 수정해보겠습니다.

// src/lib/notion.ts

export const fetchPublishedPosts = async () => {
    const { results } = await notion.databases.query({
        database_id: NOTION_DATABASE_ID,
        filter: {
            property: 'Published',
            checkbox: {
                equals: true
            }
        }
    })
    console.log(results);
        return results;
}

Filter를 적용하는 방법은 이렇게 속성 이름과 속성의 종류, 선택할 속성 상태값을 입력해주면 됩니다. 체크박스는 boolean이기 때문에 체크된 상태, true값인 데이터만 받아오도록 하겠습니다. 이상태로 다시 서버를 실행시켜주면 아래와 같은 결과값을 얻을 수 있습니다.

[
  {
    object: 'page',
    id: '',
    created_time: '2022-10-04T14:37:00.000Z',
    last_edited_time: '2022-10-04T15:16:00.000Z',
    created_by: { object: 'user', id: '' },
    last_edited_by: { object: 'user', id: '' },
    cover: null,
    icon: null,
    parent: {
      type: 'database_id',
      database_id: ''
    },
    archived: false,
    properties: {
      Description: [Object],
      Category: [Object],
      Published: [Object],
      Tag: [Object],
      '이름': [Object]
    },
    url: 'https://www.notion.so/'
  },
  {
    object: 'page',
    id: '',
    created_time: '2022-10-04T14:37:00.000Z',
    last_edited_time: '2022-10-04T15:16:00.000Z',
    created_by: { object: 'user', id: '' },
    last_edited_by: { object: 'user', id: '' },
    cover: null,
    icon: null,
    parent: {
      type: 'database_id',
      database_id: ''
    },
    archived: false,
    properties: {
      Description: [Object],
      Category: [Object],
      Published: [Object],
      Tag: [Object],
      '이름': [Object]
    },
    url: 'https://www.notion.so/'
  }
]

두개의 데이터만 받아왔습니다. properties를 보면 만들어둔 속성값이 들어있는 것을 볼 수 있는데요 제목이 ‘이름’으로 되어있네요. 노션 데이터베이스에서 위쪽에 리스트를 눌러서 레이아웃을 표로 바꾸면 제목 속성이름도 바꿀 수 있는데 코딩을 편리하게 하기 위해 Title로 바꾸도록 하겠습니다. 콘솔로 properties를 찍어보면 각각 페이지1페이지3이 불러와진 것을 확인할 수 있습니다.

Advanced Filter

카테고리를 설정했다면 카테고리에 따라 완성된 글만 볼 수 있는 쿼리를 사용해야합니다. 두개의 쿼리를 함께 사용한 코드는 길게 설명하지 않고 아래에 남기는 것으로 대체하겠습니다. 아래에서는 and를 사용했지만 or를 사용해 보다 다양한 Filter를 사용할 수도 있습니다. 중첩해서 사용할 수도 있는것으로 보입니다.

export const fetchByCategory = async (category: string) => {
    const { results } = await notion.databases.query({
        database_id: NOTION_DATABASE_ID,
        filter: {
            and: [
                {
                    property: 'Published',
                    checkbox: {
                        equals: true
                    }
                },
                {
                    property: 'Category',
                    select: {
                        equals: category
                    }
                }
            ]
        },
        sorts: [
            {
                timestamp: 'created_time',
                direction: 'descending'
            },
        ]
    })
    return results;
}

Notion 쿼리에 Sort적용

블로그에 계속 포스팅을 하면 최신글이 상단에 올라와야겠죠? 위에서 불러온 데이터를 보면 따로 설정해주지 않았음에도 created_timelast_edited_time값을 갖고 있는 걸 볼 수가 있습니다. 만약 게시일자를 수동으로 설정하고 싶으시다면 이것도 속성값으로 추가해서 할 수 있지만 저는 노션이 자체적으로 갖고 있는 데이터를 사용해 시간 역순 정렬을 해보겠습니다.

// src/lib/notion.ts

export const fetchPublishedPosts = async () => {
    const { results } = await notion.databases.query({
        database_id: NOTION_DATABASE_ID,
        filter: {
            property: 'Published',
            checkbox: {
                equals: true
            }
        },
        sorts: [
            {
                timestamp: 'created_time',
                direction: 'descending'
            }
        ]
    })
    console.log(results);
    return results;
}

데이터베이스와 함께 만들어진 데이터들은 만들어진 시간이 동일해 구분할 수 없지만 새롭게 추가된 데이터는 최신순으로 잘 정렬된 것을 보실 수 있습니다. sorts는 array로 값을 받기 때문에 정렬 기준을 복합적으로 설정 할 수 있습니다. 객체 안에 기준이되는 timestamp나 property를 지정하고 방향을 지정하면 그 기준에 맞춰 데이터가 정렬됩니다.

데이터 가공하기

이렇게 받아온 데이터는 바로 컴포넌트로 보내서 사용하기에는 복잡하고 불필요한 데이터들도 있어서 불편하기 때문에 한번 가공을 시켜줄 필요가 있습니다. 타입스크립트를 사용하기 때문에 타입지정부터 해주겠습니다. 타입스크립트를 사용하지 않는다면 이 과정은 건너뛰셔도 됩니다. 타입은 lib디렉토리에서 관리해주도록 하겠습니다. lib디렉토리 하위에 types.d.ts파일을 생성하겠습니다.

// src/lib/types.d.ts

interface Tag {
    color: string;
    id: string;
    name: string;
}

interface Category {
    color: string;
    id: string;
    name: string;
}

export interface PostProperty {
    id: string;
    createdAt: string;
    title: string;
    description: string;
    tags: Tag[];
    category: Category;
}

저는 이 조합으로 사용하고 있습니다만 원하시는대로 타입을 추가하거나 빼시면 됩니다. select나 multi_select 속성은 이름과 함께 색 데이터도 갖고 있어서 이걸 그대로 가져와서 css에 활용하려고 넣었습니다. Tag와 Category타입은 구성이 동일해서 하나로 통일하셔도 됩니다. notion.ts 맨 아래에 불러온 데이터를 가공하는 코드를 집어넣도록 하겠습니다.

// src/lib/notion.ts

import type { PostProperty } from '$lib/types';

// ...codes

const convertDataToPostProperty = (data: any): PostProperty => {
    const createdDate = new Date(data.created_time);
    return {
        id: data.id,
        createdAt: createdDate.toLocaleString('ko'),
        title: data.properties.Title.title[0].plain_text,
        description: data.properties.Description.rich_text[0]?.plain_text,
        category: data.properties.Category.select,
        tags: data.properties.Tag.multi_select
    };
};

data를 받아서 필요한 데이터만 뽑아서 타입에 맞게 return해주는 함수입니다. Notion API로 호출한 데이터를 바탕으로 추가하거나 빼시면 됩니다. 이제 앞서 만들었던 코드를 수정해보겠습니다.

// src/lib/notion.ts

export const fetchPublishedPosts = async (): Promise<PostProperty[]> => {
    const { results } = await notion.databases.query({
        database_id: NOTION_DATABASE_ID,
        filter: {
            property: 'Published',
            checkbox: {
                equals: true
            }
        },
        sorts: [
            {
                timestamp: 'created_time',
                direction: 'descending'
            },
        ]
    })
    return results.map(result => convertDataToPostProperty(result));
}

타입을 추가하고 결과를 map으로 변환시켜서 return하도록 했습니다.

데이터 활용하기

이제 가져온 데이터를 활용해 페이지에 리스트를 만들어봅시다. 이번 글에서는 css나 스타일링은 따로 다루지 않겠습니다. 먼저 앞서 만든 +page.server.ts를 다시 보겠습니다.

// src/routes/+page.server.ts

import type { PageServerLoad } from './$types';
import { fetchPublishedPosts } from "../lib/notion";

export const load: PageServerLoad = async () => {
    const posts = await fetchPublishedPosts();
    return { posts }
}

함수명을 바꿨기 때문에 함수명만 수정해주도록 하겠습니다. 다음은 이렇게 가져온 데이터를 클라이언트에서 받아서 활용할 차례입니다. routes디렉토리 하위에 +page.svelte파일이 메인페이지의 역할을 합니다. 파일을 열어 기존 내용을 지우고 아래처럼 바꿔줍시다.

// src/routes/page.svelte

<script lang="ts">
    import type { PageServerData } from "./$types";

    export let data: PageServerData;
</script>

{#each data.posts as post}
    <a href={`/${post.id}`}>{post.title}</a>
{/each}

data에는 +page.server.ts에서 반환한 값이 들어있습니다. 즉, PostProperty타입의 배열이 posts라는 키에 들어있습니다. 각 포스트는 아이디를 활용한 dynamic routing을 할 예정이기 때문에 href는 id를 활용했습니다. 일단은 제목만 가져와서 식별 가능할 정도로만 만들어보겠습니다. 이제 Svelte-Kit을 실행시키고 브라우저로 이동하면 아래처럼 리스트가 나오는 것을 볼 수 있습니다.

Notion API로 포스트 불러오기

지금까지 받아왔던 것들은 속성값들입니다. 단순 데이터베이스용도로만 사용한다면 여기까지만 해도 상관없지만 지금은 블로그용이기 때문에 가독성을 위해 페이지 속성과 함께 마크다운으로 작성된 페이지를 불러올 계획입니다. 일단 포스트 페이지를 만들어보도록 하겠습니다. routes디렉토리 하위에 [id]디렉토리를 생성해줍니다. 양쪽 대괄호가 중요합니다. 디렉토리를 대괄호로 감싸야 Svelte-Kitdynamic route가 적용이 됩니다. [id]디렉토리 하위에 +page.svelte파일과 +page.server.ts파일을 생성해주도록 합시다. 코드 작성은 나중에 하도록 하겠습니다.

Notion 페이지 속성 불러오기

포스트 페이지에 쓸 데이터는 두가지 입니다. 앞서 리스트에 활용했던 것과 같은 제목, 요약, 카테고리와 같은 속성, 그리고 마크다운으로 된 포스트 컨텐츠입니다. 먼저 속성부터 불러와보겠습니다.

// src/lib/notion.ts

// ...codes

export const getContent = async (pageId: string) => {
    const property = await notion.pages.retrieve({
        page_id: pageId
    })
    console.log(property);
}

속성을 불러오는 방법이 조금 다릅니다. 앞에서는 notion의 database기능을 썼지만 이번엔 pages 기능을 쓰고있습니다. database 기능은 말 그대로 데이터들의 filtering, sorting 등을 통한 데이터베이스 기본 기능들을 수행합니다. notion 클라이언트를 뜯어보면 database 뿐만 아니라 pages, users, comments, blocks 등의 기능들이 들어있는 걸 볼 수 있습니다. 각각을 통해 개별 데이터의 CRUD를 할 수 있습니다. 여기서는 page의 아이디를 기반으로 정보를 조회할 예정이기 때문에 pages기능을 사용합니다. 모두 공통적으로 조회를 위해서는 retrieve 함수를 사용합니다. 콘솔로 찍어보기 위해 앞서 만든 포스트 페이지의 서버단에서 함수를 호출해보겠습니다.

// src/routes/[id]/+page.server.ts

import type { PageServerLoad } from "./$types";
import { getContent } from "../../lib/notion";

export const load: PageServerLoad = async ({ params }) => {
    getContent(params.id);
}

+page.server.tsload함수에서 params를 통해 url의 파라미터를 가져올 수 있습니다. 여기서는 id를 파라미터로 사용했기 때문에 params.id를 통해 id값을 받아왔습니다. 메인페이지에서 링크를 통해 페이지로 들어오면 터미널에서 받아온 데이터를 볼 수 있습니다.

{
  object: 'page',
  id: '',
  created_time: '2022-10-04T14:37:00.000Z',
  last_edited_time: '2022-10-05T17:28:00.000Z',
  created_by: { object: 'user', id: '' },
  last_edited_by: { object: 'user', id: '' },
  cover: null,
  icon: null,
  parent: {
    type: 'database_id',
    database_id: ''
  },
  archived: false,
  properties: {
    Description: { id: 'O%7DDt', type: 'rich_text', rich_text: [Array] },
    Category: { id: 'QK%7C%3D', type: 'select', select: [Object] },
    Published: { id: 'e%7Cvd', type: 'checkbox', checkbox: true },
    Tags: { id: 'ffn%7D', type: 'multi_select', multi_select: [Array] },
    Title: { id: 'title', type: 'title', title: [Array] }
  },
  url: 'https://www.notion.so/'
}

잘 불러와 졌습니다. 이제 앞서 만든 함수를 사용해 데이터를 가공하도록 코드를 수정하겠습니다.

export const getContent = async (pageId: string) => {
    const data = await notion.pages.retrieve({
        page_id: pageId
    })
    const property = convertDataToPostProperty(data);
}

Notion 페이지 컨텐츠 불러오기

다음으로는 포스트 내용을 마크다운으로 변환해 불러오는 과정을 해보겠습니다. 우선 포스트에 내용을 작성해봅시다. 저는 아래와 같이 작성했습니다.

노션에서 페이지의 내용은 블록으로 구성됩니다. Notion API의 blocks를 사용해서 블록들을 불러올 수 있지만 그대로 가져와서 사용하려면 복잡한 과정을 거쳐야 합니다. 이런 과정들을 건너뛰고 바로 마크다운으로 가져오는 NotionToMarkdown라이브러리를 사용하도록 하겠습니다. n2m인스턴스를 미리 생성해두었기 때문에 바로 사용하면 됩니다.

// src/lib/notion.ts

export const getContent = async (pageId: string) => {
    const data = await notion.pages.retrieve({
        page_id: pageId
    })
    const property = convertDataToPostProperty(data);
    const page = await n2m.pageToMarkdown(pageId);
    console.log(page);
}

페이지 내용을 마크다운으로 받아오기 전에 먼저 n2m의 pageToMarkdown 함수를 사용해 노션 페이지를 불러와야합니다. 파라미터로 page의 id를 넣어주면 됩니다. 일단 이 상태로 출력해보겠습니다.

[
    { type: 'heading_1', parent: '# Hello World!', children: [] },
  {
    type: 'paragraph',
    parent: 'Svelte + Notion API 블로그',
    children: []
  },
  {
    type: 'code',
    parent: "```typescript\nconsole.log('Hello World!');\n```",
    children: []
  },
  { type: 'paragraph', parent: '', children: [] }
]

구조는 쉽게 파악이 됩니다. type에 블록의 종류, parent에 블록의 내용이 마크다운 문법으로 들어있습니다. children에는 해당 블록 하위에 들여쓰기된 블록들이 들어갑니다. 저는 위에서 들여쓰기를 하지 않았기 때문에 모든 children이 비어있습니다. pageToMarkdown함수는 이런식으로 노션 페이지의 블록들을 마크다운으로 변형시켜 MdBlock이라는 이름의 타입으로 반환해줍니다. 이제 이걸 합쳐서 string으로 변환해주도록 하겠습니다.

// src/lib/notion.ts

export const getContent = async (pageId: string) => {
    const data = await notion.pages.retrieve({
        page_id: pageId
    })
    const property = convertDataToPostProperty(data);
    const page = await n2m.pageToMarkdown(pageId);
    const content = n2m.toMarkdownString(page);
        console.log(content);
}

toMarkdownString은 MdBlock의 배열을 파라미터로 받아서 마크다운 문법의 string으로 변환해줍니다. 터미널에서 보면 마크다운 문서처럼 찍혀있는걸 볼 수 있습니다.

# Hello World!


Svelte + Notion API 블로그


```typescript
console.log('Hello World!');
``` //

이렇게 하면 저희는 노션페이지를 작성했을 뿐이지만 마크다운파일을 작성한 것 처럼 되었습니다. 이제 마무리를 해봅시다. 타입을 추가하고 데이터를 합쳐서 클라이언트로 전달하는 과정까지 한번에 진행하겠습니다.

// src/lib/types.d.ts

export interface BlogPost {
    property: PostProperty;
    content: string;
}
// src/lib/notion.ts

export const getContent = async (pageId: string): Promise<BlogPost> => {
    const data = await notion.pages.retrieve({
        page_id: pageId
    })
    const property = convertDataToPostProperty(data);
    const page = await n2m.pageToMarkdown(pageId);
    const content = n2m.toMarkdownString(page);
    
    return {
        property,
        content
    }
}
// src/routes/[id]/+page.server.ts

import type {PageServerLoad} from "./$types";
import {getContent} from "../../lib/notion";

export const load: PageServerLoad = async ({params}) => {
    return await getContent(params.id);
}
// src/routes/[id]/+page.svelte

<script lang="ts">
    import type { PageServerData } from "./$types";
    import type { BlogPost } from "$lib/types";

    export let data: PageServerData;

    const { property, content }: BlogPost = data;
</script>

<svelte:head>
    <title>{property.title}</title>
</svelte:head>

{content}

페이지 타이틀을 포스트 제목으로 띄우고 화면에 불러온 포스트 마크다운을 그대로 보여지도록 했습니다. 이상태로 브라우저로 들어가면 아래와 같은 화면을 볼 수 있습니다.

보시다시피 마크다운을 그대로 출력한다고 가독성 좋은 글이 되지 않습니다. html에서는 css를 해주지 않으면 javascript의 \n을 무시하기 때문에 줄바꿈도 되지 않습니다. 이렇게 불러온 markdown을 html로 파싱해주는 작업이 필요한데 라이브러리를 사용하면 간편합니다. 해당 라이브러리의 사용은 다음글에서 css적용과 함께 알아보도록 하겠습니다. 여기까지 Svelte-Kit과 Notion API를 사용해서 데이터를 불러오고 클라이언트로 보내주는 작업까지 해보았습니다. 위에서 사용한 코드는 깃허브에서 보실 수 있습니다.

참고자료