개요
저번 블로그에서 MDX 라이브러리를 사용하고 있었지만, 코드펜 임베딩, 그리고 rich-text 내부의 하이라이트된 부분 처리하기 등등 너무다 다양한 상황에 대한 스타일링을 처리하기 어려웠어요. MarkDown으로 표현된 내용을 노션에서 작성된 것 같이 스타일링 하기 위해서는 너무 많은 부분을 직접 구현해야된다는 것을 깨달았어요.
하지만 직접 구현하기도 힘든 점이 그런 다양한 경우의 수를 MDX로 구분할 수 없다는 점이였어요. 여러가지 한계로 인해서 react-notion-x 라이브러리로 갈아타게 되었어요.
react-notion-x 라이브러리

react-notion-x라이브러리는 주간 다운로드가 2만7천회가 넘는 아주 핫한 라이브러리에요. 그리고 최근 업데이트는
2025-08-30
일 기준 한 달 전이였어요. 그만큼 인기있는 라이브러리이지만, 구현하면서 깨달은 점은 역시 모든 라이브러리가 완벽할 수 없다는 것을 깨달았어요.React-Notion-X 라이브러리의 이슈 사항
저는 이 라이브러리를 사용하면서 한 가지 드러나는 문제점과 내재된 문제점이 존재한다는 것을 알게 되었어요. 물론 문제라는 것이 제가 노션을 사용하는 방식에서 문제가 되는 부분인 것이지, 다른 분 들께는 문제가 아닐 수도 있을것 같아요.
이번 블로그에서는 드러나는 문제점에 대해서 다루어보고, 내재된 문제점에 대해서는 다음에 다루어 보도록 할게요
react-notion-x가 이미지를 다루는 방식
아무래도 직접 사진들을 저장하고 사용하는 방식이 아니라서 노션이 어떻게 이미지를 다루는지, 또
react-notino-x
는 사진을 어떻게 다루는 지 알아야 했어요. 노션이 이미지를 어떻게 다루는 지는 지난 블로그에 집중적으로 다루었으니 참고 부탁드려요.
이해를 돕기 위해서 아주 간단하게 블로그의 내용을 말씀드릴게요. 노션에서 업로드 된 이미지의 원본 URL을 확인해 보면
www.notion.so/image/attchment?
방식의 URL을 사용해요. 그리고 해당 URL을 주소에 입력하면 바로 s3 signedURL
로 리다이렉트되어서 사진을 반환해줘요.노션에서 사용되는 이미지 주소가 s3 signedURL로 리다이렉트됨

이 것은 약간의 버그같은 것이기도 한데, 기존 노션에서 api로 받게 되는 사진 주소는 일반적으로 signedURL로 제공받게 되어있어요. 하지만
www.notion.so/image/attachment
를 사용하게 되면 만료시간이 없는 링크가 되어버린다는 사실을 알게되었어요. (만료시간이 없다는 뜻은 마음껏 nextjs의 캐싱전략을 사용해도 된다는 뜻이기도 하죠)하지만 해당 접두어의 이미지들은 만료시간없는 링크로 사용할 수 있었지만,
gif
는 전체 재생이 안된다는 문제점이 있었다는 것을 저번 블로그에서 다루었었지요.한 마디로 말하자면,
gif
파일은 s3 signedUrl
을 원본 그대로 사용해야한다는 점이 핵심이에요.react-notion-x 라이브러리가 이미지를 사용하는 방식
react-notion-x 라이브러리 제작자 역시 노션의
www.notion.so/image/attachment
접두어가 붙은 링크의 버그를 알고 있었던것 같아요. 라이브러리를 확인했더니, 똑같은 코멘트가 있었어요. 
그렇기 때문에 해당 패키지는 모든 이미지 URL을
www.notion.so/image/attachment
접두어를 붙여 변환하는 로직을 사용하고 있었어요. 아래 사진은 node_modules/react-notion-x
폴더에서 이미지 url 매핑 로직에 console.log
로 url이 어떻게 변환이 되는지 확인해보았어요. node_modules/reac-notion-x/build/index.js

원본 이미지 url에서 변환된 이미지 url 콘솔 로그
// 원본 이미지 주소 👉👉👉👉👉👉source https://file.notion.so/f/f/b4216657-966f-4c29-ae8c-42f6c4adb66d/5de621d9-ab90-4ede-aae4-526de18d77ec/image.png?table=block&id=25f9c76c-6cb4-80b5-9e75-cb062450ed3c&spaceId=b4216657-966f-4c29-ae8c-42f6c4adb66d&expirationTimestamp=1756742400000&signature=g57sq_Icg1-9EaPi95LMru0KAYAityFG2il_HlU826k // 변환된 이미지 주소 - 만료시간이 없음 👉👉👉👉👉👉src https://www.notion.so/image/attachment%3A5de621d9-ab90-4ede-aae4-526de18d77ec%3Aimage.png?table=block&id=25f9c76c-6cb4-80b5-9e75-cb062450ed3c&cache=v2
콘솔로그를 확인해보면 어떻게 이미지 url이 변환이 되는지 확인이 되시죠?
만약 노션 사용자가 gif를 사용할 일이 거의 없다면 이 부분을 굳이 수정하지 않아도 될 것 같지만, 저의 경우에는 블로그에
gif
를 아주 많이 사용하기 때문에 꼭 수정을 했어야 했어요. 이 부분을 해결하기 위한 여러가지 방법을 적용해보게 되었어요. ( 사실 이것 저것 해보다가 제가 만족스럽지 않아서 계속 바꿔보다가 이르게된 결론이에요 )Patch-package를 사용해서 gif로직 수정
patch-package
라이브러리는 배포된 패키지의 수정사항이 필요한 경우 버전을 유지하고 커스터마이징을 용이하게해주는 라이브러리에요. 이거에 대한 내용은 따로 설명하기로 하고, 이것으로 라이브러리를 어떻게 수정했는 지 한 번 알아보도록 할게요. patch-package 라이브러리 사용법은 총 세 가지 단계를 따르면되요.
- 의존성 설치
npm i patch-package
- package.json에 script 추가
"scripts": { "postinstall": "patch-package" }
이 스크립트가 추가가 되어야,
npm i
로 패키지를 설치하고 후처리 작업으로 패칭작업을 하게 되어요.- 패칭 실행
npx patch-package <패키지이름>
작업이 완료되면 해당명령어로 패칭을 하면 되요!
패키지 수정 작업
그럼 본격적으로 react-notion-x 패키지를 수정해보도록 할게요.
gif파일이 아닐 때만 url 매핑 사용하도록 수정
// node_modules/react-notion-x/index.js 787번째 라인 else if (block.type === "image") { if (!source.includes(".gif")) { if (source.includes("file.notion.so")) { source = (_k = (_j = (_i = block.properties) == null ? void 0 : _i.source) == null ? void 0 : _j[0]) == null ? void 0 : _k[0]; } } const src = mapImageUrl(source, block);
따라서 위와같이 ‘.gif’가 아닐 때만 로직이 그대로 적용이 되게 수정했어요. 하지만 여기서 끝나는 문제는 아니였어요.
react-notion-x
라이브러리는 유기적으로 연결되어 있는 형제 패키지들이 존재하는데, 그 중 notion-utils
라는 라이브러리 역시 수정해줘야 했어요. 여기서 주의할 건 notion공식 라이브러리가 아니에요! 공식라이브러리 이름은 notionhq
에요!node_modules/notion-utils/

notion-utils라이브러리 내부에
defaultMapImageUrl
함수에서 .gif
일 경우에 url
원본을 바로 리턴할 수 있게 해줘야 되요. 수정을 다했으면 patch-package로 수정사항을 commit해야되요. ( patch-package너무 좋은거 같아요.)
수정완료후 패치 명령어
> npx patch-package react-notion-x > npx patch-package notion-utils
패치를 완료하면 아래와 같이 패치파일이 생겨요.

앞으로 빌드할 때 버전을 유지하면서 커스터 마이징된 버전을 사용할 수 있게 해준답니다.
렌더링 완성까지의 과정
위의 방법으로 인해
gif
짤림 현상은 해결이 되었지만, 그게 끝이 아니였어요. 해당 코드를 가지고 사용자 경험을 끌어올릴 수 있는 방법을 찾아야 했죠. 첫 번째 방법: generateStaticProps
로 전체 페이지 캐싱
처음에는 무작정 전체 페이지를 캐싱해보기로 했었어요.

확실히 전체 페이지를 캐싱하니까, 즉각적인 반응속도 때문에 너무나도 만족스러웠어요. 생각보다 디테일 페이지 구현이 빨리 끝났다고 좋아하고 있었죠. 하지만 문제는 그 다음날 일어났어요. 캐싱된 gif는 만료시간이 존재했기 때문에 그 다음날 열어보니
gif
이미지에 접근할 수 없었어요
두 번째 방법 : CSR
두 번째 방법을 선택할 때 고민이 되었던 것은 다음과 같았어요.
- gif를 내부 라우트 핸들러로 요청하게끔 url을 수정해서 사용하자.
- 매번 새로 url를 요청해서 사용하도록 CSR로 가자
첫 번째 옵션으로 gif를 내부 라우터로 요청하게끔 하자는 뜻은,
app/api/image-proxy
같은 라우트 핸들러를 만들어서 사용한다는 뜻이죠. 추가적으로 storage를 운용하겠다는 뜻이에요. 그렇게하기 위해서 react-notion-x 라이브러리에서 이미지 url 매핑을할 때 gif이미지의 src의 접두어로
‘api/image-proxy?url=${imageURL}’
같이 사용하고, 첫 요청시에만 signedUrl을 사용하고 그 뒤 요청부터는 캐싱된 것을 반환하겠다는 생각이었죠. 하지만 조금 껄끄러웠던 것은, 노션 사용료를 주며 데이터베이스처럼 사용하고 있는데 따로 데이터베이스를 사용하며 정적 파일들을 쌓아가고 싶지 않았어요. 그래서 그냥 CSR로 렌더링으로 사용자 경험이 그렇게 나쁘지 않다면, 그걸로 가자라는 생각이 들었어요.
CSR이 가져다 주는 미세한 차이

CSR을 사용하고, 이제 이미지가 블록킹이되는게 없어서 마음은 엄청편해졌어요. 하지만 사용하면 사용할 수록 로딩바가 잠시 보이는 게 조금 거슬리더군요.
그래서 어떻게 하면 캐싱과 이미지 만료시간의 조화를 이룰 수 있을까에 대한 고민이 계속 되었어요.
결론
여러가지 방법을 고민하다가 아 캐시무효화를 하루치를 설정하면 어떨까 생각이 들었어요. 이건 약간의 트릭같은 거라고도 할 수 있을 것 같은데요. 하지만
사용자 경험
과 성능
과 추가 데이터베이스 운용 사이에서의 균형
을 잡아주는 방법이라고 생각했어요.
방법은 아주 간단했어요.
revalidate
시간을 signedURL
에 맞게 하루치를 설정하는 것이였어요. 노션 데이터를 불러오는 요청
getPostByIdQuery: async (id: string): Promise<Result<notionType.ExtendedRecordMap>> => { try { const cachedFn = unstable_cache( async () => { return await notionAPI.getPage(id); }, [`post-${id}`], { tags: [`post-${id}`, `all-posts`], revalidate: 60 * 60 * 24, } ); const result = await cachedFn(); if (!result) { return { success: false, error: new Error('Post not found'), }; } return { success: true, data: result as unknown as notionType.ExtendedRecordMap, }; } catch (error) { console.log(error); return { success: false, error: error as Error, }; } },
이 방법은 배포를 하면 하루동안은 어떠한 고민도 하지 않아도 되지만, Next의 ISR의 동작방식인
Stale While Revalidate
라는 특성 때문에 사용자 경험에서 크게 마이너스가 될 것 같지 않다는 결론이었어요. SWC방식은 오래된 데이터를 먼저 보여주고, 그 다음 요청시에 새 데이터로 갈아끼운다는 뜻이죠. 하지만 이 방식으로 인해서 한 페이지가 요청이 하루가 지나게 되면, 위에서 봤던 것과 같이 gif파일만
no-image
가 뜨게 될거에요. 하지만 이 부분은 의도적으로 인용하기로 한거죠.왜냐하면 CSR은 내용자체가 블로킹이 되어서 로딩바가 계속 보이게 되면
‘아- 이 사이트 왜 이렇게 느리지?’
하면서 사이트를 탓할 가능성이 높아요. 하지만 내용이 다 나오는데, 이미지 한 두개가 나오지 않으면, 사용자는 본인의 네트워크를 탓할 가능성이 높아요. 다들 사이트를 보면서 잘 안나오면 새로고침들 하시잖아요? 그래서 모든 페이지와 데이터를 캐싱해서 보여주기 위해서 추가적인 복잡도를 의도적으로 인용함으로서, 사이트 성능에 대한 blame을 어느 정도 완화하자는 방법인 것이죠.
물론 이런 방법에 대해서 호불호가 있을 수 있지만, 이 방법이 균형을 이루는 방법이라고 생각했고, 현재로서는 만족하는 중이에요. 🙂
이상 긴글 읽어주셔서 감사하고, 앞으로도 여러가지 구현 중에 고민이 되었던 것들을 나누고 해결책을 공유하며 성장하는 개발자 stephen이 되도록 노력하겠습니다 감사합니다 🙂
추가 업데이트
배포후 하루가 지나고 확인 해보니, gif가 안나오더라구요!
generateStaticProps
를 사용해서 캐싱을 하게 되면 HTML은 CDN서버에 위치하기 때문에, getPostByIdQuery
까지 닿지 않는 다는 것이 문제였어요. 저랑 비슷한 방법으로 페이지를 서빙을 하시려면
generateStaticProps
함수가 있는 폴더 상단에 segment config
로 ISR설정을 해주셔야 되요 !export const revalidate = 86400;
이 코드를
page.tsx
컴포넌트 최 상단에 추가해주시면 됩니다. 🙂