🏷️ 블로그를 만들게 된 계기

내가 올해 초에 Next.js로 만들었던 Blog가 있다.
하지만 글을 관리하기에 너무 불편해서 Notion으로 이전하였다.
왜 이전하였는지 궁금하다면? ➡️ 
노션으로 개발 블로그를 시작한 계기
 
노션으로 관리하면서 글을 편하게 잘 작성하게 되었지만,
이게 무료 버전에서는 검색 엔진 인덱싱 안되어서 블로그에 자연 유입이 불가능하였다.
 
내가 공부하면서 정리한 글이기도 하지만, 남들이 보기 좋은 글을 쓰는 것도 개발자에게 중요한 역량이라 생각한다.
그래서 글을 노션 API로 불러오는 블로그를 내가 직접 배포해보려고 한다.
 

🏷️ 심화 기능

평범한 블로그를 넘는 기능들을 추가해주고 싶었다.
게시글을 옵시디언처럼 관련된 글들을 보여주기
게시글 무한 스크롤
다른 사람들도 clone 해서 사용할 수 있도록

🏷️ 설계

  1. 어떤 프레임워크를 사용하는 것이 좋을까?
    1. Next.js
  1. 어떤 상태 관리 라이브러리를 사용하는 것이 좋을까?
  1. storybook 사용해보기
  1. test 코드 작성하기
  1. 최적화 해보기

🏷️ 고민

  1. post card와 portfolio card를 같은 모양이지만 다른 크기로 만들려고 하는데,이건 기본적인 card 컴포넌트에서 각각 새로운 컴포넌트를 생성해주어야할지, 아니면 variant를 다르게 해야할지 고민이 된다.
     

    🏷️ Notion 데이터 불러오기

    먼저 노션 api key를 발급받아야 한다. 🔽 아래 블로그를 참고하였다.
     
    그리고 🔽 노션의 공식 문서를 통해서 api의 end point에 접근해보았다.
     

    데이터베이스 접근하기

    curl -X GET 'https://api.notion.com/v1/databases/{$database_id}' \ -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \ -H 'Notion-Version: 2022-06-28' \ -H "Content-Type: application/json" \ }'
    처음에 이렇게 데이터베이스의 정보를 GET으로 가져오면 내 글들을 모두 가져올 수 있을 줄 알았다.
    그러나 데이터베이스의 properties 위주로 가져왔다.
    내가 원하는 것은 게시글들이기 때문에 좀 더 알아보았다.
     
    curl -X POST 'https://api.notion.com/v1/databases/{$database_id}/query' \ -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \ -H 'Notion-Version: 2022-06-28' \ -H "Content-Type: application/json" \ --data '{ "filter": { "property": "Task completed", "checkbox": { "equals": true } } }'
    데이터들을 가져오기 위해서는 이렇게 end point에 query만 추가하여 post 요청을 보내면 되었다.
    데이터들에 filter를 추가할 수 있는데 예를 들어, 게시글의 properties 중 ‘상태’가 ‘완료’인 글만 가져오고 싶다면
    { "filter": { "property": "상태", "status": { "equals": "완료" } } }
    위와 filter를 정해주면 된다.
     

    Response

    하나의 글에 대한 데이터도 다음과 같이 너무 길었다.
    여기서 내가 필요한 데이터만 골라보자.
    { "object": "page", "id": "21c60608-ac17-80d8-bccc-e1768b4018c1", "created_time": "2025-06-24T11:23:00.000Z", "last_edited_time": "2025-07-01T12:31:00.000Z", "created_by": { "object": "user", "id": "7443bcca-21f2-41a6-8639-895efed0149d" }, "last_edited_by": { "object": "user", "id": "7443bcca-21f2-41a6-8639-895efed0149d" }, "cover": { "type": "file", "file": { "url": "https://prod-files-secure.s3.us-west-2.amazonaws.com/3015db7a-50ac-4a05-9ced-f48c154c06ae/9c7a1bb8-3fbe-4ce9-8982-16dc6d45ad38/%E1%84%8C%E1%85%A1%E1%84%83%E1%85%A9%E1%86%BC_%E1%84%86%E1%85%AE%E1%86%AB%E1%84%89%E1%85%A5%E1%84%92%E1%85%AA_1920x1080.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466U2JLWOY6%2F20250716%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250716T015128Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEDkaCXVzLXdlc3QtMiJIMEYCIQCt4N1xrmBypYkK6ZHyopyZYck%2BmdrsUMi%2BbNzSpxePfwIhAOFK530%2FpG7MhwXeIjP%2BR55xuvuX4qOfOHMek6P3RhU4Kv8DCFEQABoMNjM3NDIzMTgzODA1Igx5WY%2B8FXRVtiSr5J0q3AO06jeAf7JrAAM37b7FXElFjRJr1bRreGkHFgIEHNDSLiSv6RNAY6pTsK9Gr17lZuDdkcWxv7iU24w1vQ1ajuV22jqkwTHp%2BJgNK%2F5fCeonUkLaMCZdz7s2nCEDT5lPRhasRUKIy7gj23oPXjQSWzLIiTpUfIhcVcnClRCGHftppf9xzn0n8Ni95%2FjV8WGev7SfTJfqD12CsmzPq8rGTmKILkS3ZxVq1ZvSDnmpoNtT7tjioUzEe3S%2BJCgR2A6ZEjeUb1OwVPxE0zTBuBxkOlpISGiUHO9YMWmulVqIybqknGpjzVl6WUrCn22frHjYvEZKUqUrsAbu3WaLXkZF8P7aJ%2BmOvombxva2GCm%2BK2qVVA0Q3X68zYRnl%2Bauw2V4N4OEE7jT9OsH3ATVgVlEHVgY2aE4hPoxG77Zsg28DsSW8%2Bf5AROuJUKESGlaqLBgd1RbaG%2B2o3izeRoASbTCDPXWyiOrAFtRY2O2LP5VWABk%2B7V%2FMrnbapx%2B2l60A2sElj0Zk%2B6%2Ff%2BrWyG%2BVG3LgW9qJWTjngEUkQ13Hmm38M7jw4pKmXeU%2BTRNyRqHrF4Wm5nRE1QndaXtTBTQnRVH1TUPZItx5uTDgoWVrjF0qSzXOVyT5Gm82kaBBXOG9YTCg4NvDBjqkAZiHDc3%2FxbaraCR3r54MyEFWJS5TP6hG9N%2FmUJEebXpUeOaJNqZ1fGUQy16daX%2BCKZDMt84Oz3piW94icjcXXfHoqdTluRdl2BhP0Kr9Iw2noj8WjIGr1NOZBiEoDnm5mFOOns4xsC7oUivIPmMpYb9Anc7ySwhISNrEQ3L76J1s99PbYIfTieRoydM1m4tBikb8wLNFtAZ%2BInV%2BaIuxshaOlqrs&X-Amz-Signature=71b464f62f4ab85302325f048350374cde2c4d5c12fda68b080ec0cb798ed04b&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject", "expiry_time": "2025-07-16T02:51:28.369Z" } }, "icon": null, "parent": { "type": "database_id", "database_id": "21b60608-ac17-80cb-9399-c98acd83522c" }, "archived": false, "in_trash": false, "properties": { "태그": { "id": "Ni%3D_", "type": "multi_select", "multi_select": [ { "id": "fae0440e-526d-4f97-872f-289a9b12249a", "name": "Frontend", "color": "yellow" }, { "id": "4ee43b74-9d94-4784-9256-b97c46c1ee99", "name": "Library", "color": "orange" }, { "id": "418c6a5a-1d66-4f67-924a-1582597bafba", "name": "회고", "color": "gray" } ] }, "PIN": { "id": "N%7DKD", "type": "select", "select": null }, "관련 글": { "id": "VP_%7C", "type": "relation", "relation": [], "has_more": false }, "상태": { "id": "ht%5ED", "type": "status", "status": { "id": "31e04847-8acb-4d8c-861e-605a37384683", "name": "완료", "color": "green" } }, "작성일": { "id": "xeD~", "type": "date", "date": { "start": "2025-06-25T11:20:00.000+09:00", "end": null, "time_zone": null } }, "이름": { "id": "title", "type": "title", "title": [ { "type": "text", "text": { "content": "토스가 궁금해 한 Git-Docgen 회고", "link": null }, "annotations": { "bold": false, "italic": false, "strikethrough": false, "underline": false, "code": false, "color": "default" }, "plain_text": "토스가 궁금해 한 Git-Docgen 회고", "href": null } ] } }, "url": "https://www.notion.so/Git-Docgen-21c60608ac1780d8bccce1768b4018c1", "public_url": "https://jblog623.notion.site/Git-Docgen-21c60608ac1780d8bccce1768b4018c1" },
    • id
    • cover - url (커버사진 불러오기)
    • properties
      • 태그
      • PIN
      • 관련글
      • 작성일
      • 이름
     

    글 내용 가져오기

    얘는 end point로 page를 사용하면 되는 줄 알았으나,
    데이터베이스처럼 pages의 정보들과 properties만 가져오는 것이 끝이었다.
    내용을 좀 더 찾아보니 blocks end point에 children 경로로 요청해야 한다.
    https://api.notion.com/v1/blocks/{$pages_id}/children

    Response

    response를 보니깐 모든 한 줄 한 줄이 블록으로 처리 되어있어서 굉장히 긴 json 형태이다.
    특히, 블록 타입마다 데이터 형태들이 달라서 처리해줄 것이 많아 보였다..!
     
    문제는 노션 api로 한 번에 가져올 수 있는 블록의 수 100가 최대이다.
    다행히 남은 블록들이 있으면 has_more, next_cursor id값이 반환되어서 이것을 이용하면 될 것 같다.
    has_more=true라면, start_cursor 값에 next_cursor id 값을 넣어서 요청을 보내면 될 것 같다.
    https://api.notion.com/v1/blocks/{$pages_id}/children?start_cursor=21d60608-ac17-80be-9cae-c242fc211274

    🏷️ 디자인

    위의 방법으로 notion의 데이터를 가져올 수 있었다.
    그러나 response된 값들은 블록(노션의 한 줄) 별로 return되었다.
    블록의 타입마다 새로 컴포넌트를 만들고 디자인해야하고 이런 구조를 작성하는 것은 시간도 너무 오래 걸린다.
    react-notion-x 라이브러리를 사용하면, 데이터의 구조를 html 형태로 복원해주었다.
    디자인은 CSS를 import했는데, 마음에 들지 않아 html의 class명을 직접 보며 내가 재선언해주었다.
     
    나는 디자이너가 아니다보니 최대한 노션 디자인 그대로 구현하고자 했다.
    개발 블로그는 화려함보다는 직관적인 UX/UI가 중요하다 생각했다.
    notion image

    🏷️ 트러블 슈팅

    1️⃣ 이미지 로딩 실패

    Notion의 첨부된 image들의 presigned url을 못가져와 로딩이 안되는 문제가 있었다.
    이 문제는
    느려서 이미지 로딩 불가…? (feat. presigned url)
    여기에 글을 자세히 작성해놓았다.
     

    2️⃣ 배포환경에서 Notion Render 안됨

    위에서 노션 글 디자인 입히기에서 언급한 react-notion-x 가 배포 환경에서 잘 작동하지 않았다.
    notion image
    이렇게 계속 로딩화면에서 멈춰있다.
     
    이 문제는 디버깅도 되지 않아서 너무 힘들었다.
    에러도 뜨지 않고 네트워크창을 확인해도 아래와 같이 1개뿐이다.
    notion image
     
    직접 코드를 한줄 한줄 주석 처리해보면서 찾아냈다..!!!
    import { getData } from "@/lib/notion"; export default async function Page({ params }: PostPageProps) { const { pageId } = await params; // getData가 제대로 작동하지 않고 있음! const data = await getData(pageId); const headerData = await fetchNotionPageQuery(pageId); const header = extractHeaderData(headerData as unknown as NotionPageHeader); return ( <main className="bg-[#1D1D1D]"> <PostHeader header={header} /> <Renderer recordMap={data} rootPageId={pageId} /> </main> ); }
    보면 주석 아래에 getData를 배포 환경에서 제대로 못가져오고 있었다 🫠
    getData는 공식 사이트에서 알려준 코드를 내 lib 폴더에 새로 만들어둔 함수이다.
    import { NotionAPI } from 'notion-client' const notion = new NotionAPI() const recordMap = await notion.getPage('067dd719a912471ea9a3ac10710e7fdf')
    getPage는 notion에서 제공하는 라이브러리의 함수인데.. 왜 배포 환경에서는 안되고 있을까?
    디버깅도 안되어서 notion-client의 issue들을 확인해본 결과 아래 링크에서 주요 내용을 확인할 수 있었다.
    Next.js cannot render in production environment.
    Infinite loop in getPage, getAllPages with collections since 7.4
    fix::notion-client - Replace `ky` with `ofetch` || fix::react-notion-x - LazyImageFull.tsx updated
    • 증상
      • 프로덕션에서 getPage가 끝나지 않음(로딩만 지속)
    • 원인
      • notion-client가 내부적으로 사용하는 ky가 Next 15 환경에서 네이티브 fetch 조합과 충돌
        • 따라서 요청이 무한 대기
      • json 옵션이 제대로 전송되지 않아 loadPageChunk가 400을 반환했다.
     

    어떻게 고쳤나

    • ky를 프로젝트 로컬 shim(ofetch 기반) 으로 대체해 충돌을 우회
    • next.config.ts: Webpack alias로 ky → src/lib/ky-shim.ts 강제 매핑
     

    왜 효과가 있었나

    • ofetch는 Node/Edge 모두에서 안정적으로 동작하며, ky의 문제 구간을 회피하였다.
    🔽 아래 글에서 더 자세히 확인할 수 있다.
     

    3️⃣ 데이터 새로고침 안됨

    Vercel에서 Cache > Data 삭제를 해야 새로운 글을 불러올 수 있게 된다.
     

    🏷️ 최적화

    1️⃣ 글을 들어갈 때, 커버 이미지 하나만 늦게 불러와서 글 전체가 느리게 로딩되는 문제가 있다.
    💡
    Cover Image 컴포넌트만 suspense로 따로 처리했다.
     
    2️⃣ SEO 측면에서 pageId를 slug 형태로 만들고 싶다.
    지금 내 pageId는 uuid 형태라 SEO 관점에서 그닥 유리하진 않다.
     

    Next Step

    slug화 하기
    Vercel Data Cache 조절해보기