Notion API로 블로그 만들기 (with 삽질) - 2

Notion API로 블로그 만들기 (with 삽질) - 2

1편에서 "유틸 코드와 테스트 코드 작성은 다음 편에서!"라고 했는데... 벌써 1년이 지나버렸다.

그 사이에 라이브러리는 이미 다 만들었고, 작년 6월쯤부터 내 블로그에 적용해서 잘 쓰고 있다. 지금 보고 있는 이 글도 그 라이브러리로 렌더링되고 있다.

그래도 만들면서 겪었던 삽질들을 기록하려고 늦었지만 2편을 써본다.

본격적으로 만들어보자

1탄에서 기술 스택(pnpm, turborepo, tsup 등)을 정하고 번들링 설정까지 마쳤다. 이제 진짜 코드를 짜볼 차례다.

목표는 react-notion-x에서 쓰는 notion-clientnotion-utils를 대체하는 것이었다. Notion 공식 SDK가 있긴 한데, 블로그 만들 때 자주 쓰는 기능들을 매번 직접 구현하기 귀찮았다.

그래서 공식 SDK의 Client를 상속받고, 자주 쓰는 기능들은 내가 직접 만들어 메서드로 추가하기로 했다.

import { Client as NotionClient } from '@notionhq/client';

export class Client extends NotionClient {
  getPageBlocks = (pageId) => getPageBlocksFunc(this, pageId);
  getPageProperties = (pageId, keys, extractValues) => ...
  getFileUrl = (pageId, propertyKey) => ...
}

이렇게 하면 기존 SDK 기능도 그대로 쓸 수 있고, 추가한 메서드도 같은 인스턴스에서 쓸 수 있다. 근데 이 메서드들을 만들면서 삽질을 좀 했다.

이미지가 왜 깨지지?

본격적인 구현 얘기 전에, Notion API가 데이터를 어떻게 주는지부터 알아야 한다.

Notion에서 페이지는 "블록"의 집합이다. 문단 하나가 paragraph 블록, 이미지 하나가 image 블록, 코드 하나가 code 블록, 이런 식으로 페이지 전체가 블록들로 구성되어 있다.

노션 페이지
├── heading_1 블록: "제목입니다"
├── paragraph 블록: "본문 내용..."
├── image 블록: { url: "https://..." }
├── bulleted_list_item 블록: "리스트 항목 1"
└── code 블록: "console.log('hello')"

API로 블록 목록을 가져오면 이런 구조가 온다:

{
  "object": "block",
  "type": "image",
  "image": {
    "type": "file",
    "file": {
      "url": "https://prod-files-secure.s3.us-west-2.amazonaws.com/...",
      "expiry_time": "2024-01-15T12:00:00.000Z"
    }
  }
}

블록 가져오는 건 간단했다.

const response = await client.blocks.children.list({ block_id: pageId });

근데 이미지 블록의 URL을 보니까 뭔가 이상했다.

https://prod-files-secure.s3.us-west-2.amazonaws.com/cd7314a5.../image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Expires=3600...

AWS S3 URL인데, 인증 파라미터가 덕지덕지 붙어있다. X-Amz-Expires=3600... 1시간 후에 만료된다는 뜻이다. 블로그에 올려놓으면 시간 지나서 이미지 깨지는 거 확정이었다.

노션 웹은 어떻게 하고 있을까?

개발자 도구를 열어서 노션 웹에서 이미지 URL을 까봤다.

https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2F...?table=block&id=xxx&cache=v2

오.. S3 URL을 통째로 인코딩해서 notion.so/image/ 뒤에 붙이고 있었다. AWS 파라미터는 제거하고, 대신 table=block&id=블록ID&cache=v2를 붙여서.

이렇게 하면 노션 서버가 프록시 역할을 해서 이미지를 영구적으로 제공해주는 것 같았다.

해결: formatNotionImageUrl

export const formatNotionImageUrl = (url: string, blockId?: string): string => {
  // 이미 notion.so 형식이면 그대로 반환
  if (url.includes('notion.so/image/')) {
    return url;
  }

  // AWS 파라미터 제거 (? 이후 부분)
  const baseUrl = url.includes('?') ? url.split('?')[0] : url;

  // URL 인코딩해서 notion.so 형식으로 변환
  const encodedUrl = encodeURIComponent(baseUrl);
  let formattedUrl = `https://www.notion.so/image/${encodedUrl}`;

  // 블록 ID 추가
  if (blockId) {
    formattedUrl += `?table=block&id=${formatNotionId(blockId)}&cache=v2`;
  }

  return formattedUrl;
};

핵심은 간단했다:

  1. AWS 파라미터 제거
  2. URL 인코딩
  3. notion.so/image/ 앞에 붙이기

이제 이미지가 안 깨진다!

이미지 크기를 어떻게 알지?

이미지 URL 문제는 해결했다. 근데 또 다른 문제가 있었다.

블로그에 이미지를 렌더링하려면 크기를 알아야 한다. 크기를 모르면 이미지가 로딩될 때 레이아웃이 덜컥덜컥 밀리는 현상(Layout Shift)이 생긴다.

<!-- 크기 없이 렌더링하면 -->
<img src="..." />  <!-- 이미지 로드 전: 높이 0 -->
                   <!-- 이미지 로드 후: 높이 500px → 레이아웃 밀림! -->

<!-- 크기를 알면 -->
<img src="..." width="800" height="500" />  <!-- 처음부터 공간 확보 -->

근데 Notion API 응답에는 이미지 크기 정보가 없다.

{
  "type": "image",
  "image": {
    "file": {
      "url": "https://prod-files-secure.s3..."
    }
  }
  // width, height 같은 거 없음!
}

노션에서 보여지는 크기를 알고 싶은데...

사실 내가 원했던 건 "노션에서 보여지는 크기"였다. 노션에서는 이미지를 드래그해서 크기를 조절할 수 있는데, 그 크기 정보가 API에 없었다.

GitHub에 이슈도 올려봤는데 답이 없더라.

https://github.com/makenotion/notion-sdk-js/issues/560

일단 이미지 원본 크기로

어쩔 수 없이 이미지 원본 크기를 가져오기로 했다. probe-image-size라는 라이브러리는 이미지 전체를 다운로드하지 않고, 헤더만 읽어서 크기를 알아낸다.

import probe from 'probe-image-size';

async function getImageMetadata(url: string) {
  try {
    const result = await probe(url);
    return {
      block_width: result.width,
      block_height: result.height,
      block_aspect_ratio: result.width / result.height,
    };
  } catch {
    // 실패해도 괜찮음 - 선택적 기능
    return null;
  }
}

노션에서 설정한 크기랑은 다를 수 있지만, 최소한 Layout Shift는 막을 수 있다. 나중에 API가 지원되면 그때 바꾸면 되지 뭐...

토글 안에 내용이 안 보여?

이미지 처리는 끝났다. 근데 이번엔 토글이 문제였다.

블록을 가져와서 렌더링했는데, 토글을 열어보니 안에 내용이 없었다. 노션에서는 분명 토글 안에 텍스트가 있는데?

▶ 토글 제목
   └── 여기 내용이 있어야 하는데... 없다

API 응답을 다시 봤다.

{
  "type": "toggle",
  "id": "abc123",
  "has_children": true,
  "toggle": { "rich_text": [...] }
}

has_children: true는 있는데, 실제 children 데이터는 없다.

알고 보니 Notion API는 하위 블록을 자동으로 안 준다. 토글, 리스트, 컬럼 같은 블록은 has_children: true라는 플래그만 주고, 실제 children은 따로 요청해야 한다.

해결: 재귀적으로 가져오기

어쩔 수 없다. has_children: true인 블록마다 children을 따로 요청해야 했다. 그리고 그 children 안에 또 has_children: true인 블록이 있을 수 있으니까, 재귀적으로 처리해야 한다.

async function fetchBlockChildren(client, blockId) {
  const response = await client.blocks.children.list({ block_id: blockId });
  
  const blocksWithChildren = await Promise.all(
    response.results.map(async (block) => {
      // 하위 블록이 있으면 재귀적으로 가져오기
      if (block.has_children) {
        const children = await fetchBlockChildren(client, block.id);
        return { ...block, children };
      }
      return block;
    })
  );

  return blocksWithChildren;
}

아... 이래서 react-notion-x가 비공식 API를 쓴 건가 싶었다. 비공식 API는 한 번 요청으로 하위 블록까지 다 가져올 수 있다..

북마크가 썰렁해

노션에서 북마크 블록을 만들면 제목, 설명, 사이트 이름, 파비콘까지 이렇게 예쁘게 보인다.

근데 API 응답은?

{
  "type": "bookmark",
  "bookmark": {
    "url": "https://github.com/makenotion/notion-sdk-js"
  }
}

URL만 덜렁 있다.

제목이나 설명 같은 건 노션이 내부적으로 URL에서 긁어온 건데, 그 정보는 API로 안 준다. 그래서 직접 긁어와야 했다.

Open Graph 메타데이터

웹사이트들은 보통 <meta> 태그에 Open Graph 정보를 넣어둔다.

<meta property="og:title" content="GitHub - makenotion/notion-sdk-js" />
<meta property="og:description" content="Official Notion API SDK..." />
<meta property="og:image" content="https://..." />

이걸 긁어오면 된다. open-graph-scraper라는 라이브러리를 썼다.

import ogs from 'open-graph-scraper';

async function fetchOGMetadata(url: string) {
  try {
    const { result } = await ogs({ url });
    return {
      title: result.ogTitle || '',
      description: result.ogDescription || '',
      image: result.ogImage?.[0]?.url || '',
      siteName: result.ogSiteName || '',
    };
  } catch {
    return null;
  }
}

실패하면 어떡해?

근데 모든 사이트가 OG 태그를 가지고 있는 건 아니다. 긁어오기 실패할 수도 있고, 봇을 차단하는 사이트도 있다.

그래서 fallback을 만들었다.

function createBookmarkMetadata(url: string, ogResult?: OGScraperResult) {
  const domain = extractDomain(url);  // URL에서 도메인 추출

  // 1. OG 데이터가 있으면 사용
  if (ogResult?.ogTitle) {
    return {
      title: ogResult.ogTitle,
      description: ogResult.ogDescription || '',
      image: ogResult.ogImage?.[0]?.url || '',
      siteName: ogResult.ogSiteName || domain,
      favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
    };
  }

  // 2. 도메인이라도 있으면 도메인으로 표시
  if (domain) {
    return {
      title: domain,
      description: '',
      image: '',
      siteName: domain,
      favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
    };
  }

  // 3. 최악의 경우 URL 그대로
  return {
    title: url,
    description: '',
    image: '',
    siteName: '',
    favicon: '',
  };
}

OG 실패해도 최소한 도메인은 보여주고, 파비콘은 Google Favicon API로 가져온다. 완벽하진 않아도 썰렁한 URL만 덩그러니 있는 것보단 낫다.

속성값이 너무 깊이 묻혀있어

블록 얘기는 여기까지. 이제 페이지 속성(properties) 이야기를 해보자.

Notion 데이터베이스를 쓰면 각 페이지에 속성을 붙일 수 있다. 제목, 날짜, 태그 같은 것들. 블로그로 치면 메타데이터인 셈이다.

문제는 이 속성값을 꺼내는 게 생각보다 귀찮다는 것.

API 응답을 보면 이렇게 생겼다:

{
  "Title": {
    "id": "title",
    "type": "title",
    "title": [
      {
        "type": "text",
        "text": { "content": "제목입니다" },
        "plain_text": "제목입니다",
        "annotations": { ... }
      }
    ]
  },
  "Published": {
    "id": "abc123",
    "type": "checkbox",
    "checkbox": true
  }
}

제목 하나 꺼내려면 properties.Title.title[0].plain_text를 써야 한다. 체크박스는 properties.Published.checkbox. 타입마다 접근 경로가 다 다르다.

매번 이렇게 쓰기 싫어서 유틸 함수를 만들었다:

export const extractValuesFromProperties = (
  properties: Record<string, NotionProperty>
): Record<string, ExtractedValue> => {
  return Object.entries(properties).reduce(
    (acc, [key, property]) => {
      const extractor = extractors[property.type];
      const value = extractor ? extractor(property) : property;
      return { ...acc, [key]: value };
    },
    {} as Record<string, ExtractedValue>
  );
};

타입별로 값을 꺼내는 함수를 미리 매핑해두고, 속성 타입에 맞는 함수를 자동으로 찾아서 실행한다.

결과는 이렇게 깔끔해진다:

// 변환 전
const title = page.properties.Title.title[0]?.plain_text ?? '';
const published = page.properties.Published.checkbox;

// 변환 후
const { Title, Published } = extractValuesFromProperties(page.properties);
// Title = "제목입니다", Published = true

지원하지 않는 타입은 원본 그대로 반환해서 기존 코드가 깨지지 않게 했다. 점진적으로 extractor를 추가하면 된다.

마무리

여기까지가 notion-to-utils의 핵심 기능들이다.

정리하면:

  • 이미지 URL 만료 → notion.so 프록시로 우회
  • 이미지 크기 정보 없음 → probe-image-size로 헤더에서 추출
  • 하위 블록 안 줌 → 재귀적으로 가져오기
  • 북마크 미리보기 없음 → OG 메타데이터 스크래핑
  • 속성값 접근 귀찮음 → 타입별 추출 유틸

Notion API가 해주지 않는 것들을 하나씩 채워넣은 셈이다. 이러면 그냥 비공식 API 쓰는게 낫지않나 생각도 들었지만, 이미지 프록시 제외하고는 다 공식 API를 쓴 셈이다.

이제 이 데이터를 받아서 실제로 React 컴포넌트로 렌더링하는 건 다음 글에서 다뤄보겠다.

Reference

Introduction - Notion Docs

The reference is your key to a comprehensive understanding of the Notion API.

https://developers.notion.com/reference/intro
Introduction - Notion Docs
https://github.com/nodeca/probe-image-size

Open Graph protocol

The Open Graph protocol enables any web page to become a rich object in a social graph.

https://ogp.me/
Open Graph protocol