개요
현재 나의 블로그는 Nextjs 프레임워크를 활용해서 notion API로 노션의 게시물을 블로그 페이지로 서빙하고 있다. 난 거의 모든 노트를 노션에 정리하고 블로깅도 관리하고 있어서, 노션이랑 연동된 블로그가 있었으면 좋겠다고 생각하고 있었는데, notion api에 대해서 처음 알았을 때는 나의 블로그에 대한 고민을 모두 해결해 줄 수 있을 것만 같았다. 하지만 구현하면서 깨달은 점은 노션 페이지의 컨텐츠를 불러오는 데 있어서 여러가지 까다로운 점이 존재함을 알았다.
뭐가 문제 였는가?
Nextjs 장점을 십분 활용하고 그리고 블로그의 특징인 자주 업데이트 되지 않는 다는 점 때문에 첫 페이지 항목들과 메타데이터는 모두 태그기반 캐시를 사용해서 사용자 경험을 끌어올리고 싶었다.
그런데 페이지를 다 배포했더니 얼마 되지 않아 이미지가 나오지 않는 다는 것을 확인했다. 그리고 개발자 도구를 열어서 확인해보니 403에러를 반환하고 있었다. 그리고 여러 곳에서 이 이슈에 다루고 있는 것을 확인했다.
Notion API 컨텐츠 403 이슈 토론
403 error for images
이유는 Notion API로 데이터를 요청하면 페이지 내부에 사진이나 영상들을 어디서든 접근 가능한 링크를 주는 것이 아니라, 1시간만 접근 가능한
S3 presignedUrl
을 반환했기 때문이다. 반환 형태는 아래 코드와 같다. 공식문서에 게시된 페이지 컨텐츠의 반환 형태
{ "type": "file", "file": { "url": "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/...", "expiry_time": "2025-04-24T22:49:22.765Z" } }
그래서 캐시전략을 포기해야 되나? 라고 고민했지만, 그냥 서빙할 때 사용하는 notionAPI가 너무 느렸기 때문에 방법을 다시 찾아보기로 했다.
URL 변경시도
처음에 들었던 생각은 진짜 노션에 등록된 이미지의 주소를 확인하는 것이였다. 그리고 노션에서 이미지를 호출하는 것처럼 url을 다시 구성할 수 있다면,
s3 presignedUrl
을 받는 것이 아니라 바로 이미지에 접근 가능하지 않을까 생각했기 때문이다. 그래서 노션페이지에서 커버 이미지주소를 복사해서 확인한 링크와 api로 반환받는 같은 이미지 데이터의 url주소의 차이는 다음과 같았다. 물론
www.notion.so
가 호스트 네임인 경우에는 어디서든 이미지를 요청할 수 있는 링크이고, s3가 호스트인 경우에는 1시간의 제한시간이 있었다. 노션에서 직접 이미지 주소를 확인한 링크
https://www.notion.so/image/attachment%3A11249530-2037-441d-b35a-867fd166dd1b%3AGemini_Generated_Image_hntt6ihntt6ihntt.png?table=block&id=23d9c76c-6cb4-808d-b4bf-fc8eefc0fc3b&spaceId=b4216657-966f-4c29-ae8c-42f6c4adb66d&width=2000&userId=869b81e9-1584-40c0-9d5f-ef8cd70fdfbf&cache=v2 이미지가 정상적으로 반환되는 최소한 주소를 테스트한 url 👇👇👇 https://www.notion.so/image/attachment%3A11249530-2037-441d-b35a-867fd166dd1b%3AGemini_Generated_Image_hntt6ihntt6ihntt.png?table=block&id=23d9c76c-6cb4-808d-b4bf-fc8eefc0fc3b&spaceId=b4216657-966f-4c29-ae8c-42f6c4adb66d
1시간의 제한 시간을 가진 링크 - api로 반환 받는 형태
{ type: 'file', file: { url: 'https://prod-files-secure.s3.us-west-2.amazonaws.com/b4216657-966f-4c29-ae8c-42f6c4adb66d/11249530-2037-441d-b35a-867fd166dd1b/Gemini_Generated_Image_hntt6ihntt6ihntt.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466VV4CEXT2%2F20250817%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250817T131427Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEEIaCXVzLXdlc3QtMiJIMEYCIQDNuzJgtLlIgXj4DJnwsA0wKKGvjbD%2FT7ngAF3HbJ9hKwIhAOuv4orgoJwG8FBKZlSUfR%2BRJMOlID6ExnKmFiInpq8pKogECIv%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgyC0dnU%2FyqwhV%2B59ucq3APx0eHncZ66j%2FVJhBc1trrcW7xtjKAu429dgDxAeM%2FB0%2F8L8V80tcSNLYvHbCy4Kd2R7aoD95pcBCQC01LcLYcxKRQWXr9akf5DsZnIwUEM60lX3%2FylQwHamfkMNc3xYwzli%2F6xrT0MmxUU%2BUovIgQYlSlDn9xf%2Be6C%2FNVH1geQl7siZqeiO0KzRMR0Cu9rGjq0pqpYjkfVcDrF%2BhKYnIE7eUf2NMla5459o7YgVTg2zQ5iCNrIqKKK3fBHiXVrXVLFRnevKj57YuKsgzxbD0Ft9obJhXeRNff%2B98eIFPuj66F709G2twinVcu0E6R2T9ASSMdHnSJmJ%2FiLNVOwrqKlgciKAdKPU%2Fl1YTFR6A0Q5rD1%2BgrhDDxHwYq8vts60qCKUwwXJ%2BG5Ck0gasRU%2Br4DdglclN0zGZFdiiOWL3GeSWtTl1UEB7WSUqnt6yOOelZ0u4PRySvcaxY2qOls0ZIU8QODHbQ8fBkWW8P4TmRnOP7gOBSZToxIACYDnOAsDEOkvvmwh2Ydj7W%2BT1d7jlaMlF8mZisk468UHpksZiFtuwQkPGjNpV7Pdys8%2FLf2iraMvcTrn2EWOUNoUfl6GjpgIoAH2I4j%2Bqq47taCaimQTknflaSi%2BbEy4VvzKDCD0YbFBjqkAf31diPD6vzkEKu%2ByuhHtzZl7gCZggCxWeWMbdNlo1PP1qUaqsjCTDKybxiNRQDrEV4A%2FQq19cwKLoSu7Jq06ZnyZm63tmGW9F8R3y9Q%2FwQTNPDA3Nfa8JkvbKtW7Ph02v6fsHrFagJ%2Blb9PD%2FJHKhPNkX6hiU0XdGFX3n0Pvr9%2BbpXmVgNbHLwOZhFUHa7yDWVs2Btyz%2BAHGUXLSbvcmQZKbp97&X-Amz-Signature=932b47a4a74bad40316e55edffe93d05d01a99492ed5a032607c5d7ef6577501&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject', expiry_time: '2025-08-17T14:14:27.869Z' } }
위에 제한시간을 가진 s3링크를 분석해보면 핑크색으로 된 부분이
spaceId
/ attachmentId
/ 파일 리소스
이름임을 확인할 수 있었다. 그래서 다음과 같은 url 변환함수를 만들어 볼 수 있었다.커버이미지 s3 url 변환 함수
export const convertS3UrlToNotionUrl = (s3Url: string, pageId: string): string | null => { try { // 1. S3 URL 파싱하여 경로 정보 추출 const url = new URL(s3Url); const pathParts = url.pathname.split('/').filter((part) => part); const [spaceId, fileId, ...fileNameParts] = pathParts; const filename = fileNameParts.join('/'); // 2. 영구 URL의 인코딩된 경로 생성 const rawPath = `attachment:${fileId}:${filename}`; const encodedPath = encodeURIComponent(rawPath); // 3. 쿼리 파라미터 생성 const params = new URLSearchParams({ table: 'block', id: pageId, spaceId, }); // 4. 모든 요소를 조합하여 최종 URL 반환 return `https://www.notion.so/image/${encodedPath}?${params.toString()}`; } catch (error) { console.error('Error parsing the S3 URL:', error); return null; } };
s3 url을 변환한 링크 → 함수결과
https://www.notion.so/image/attachment%3A11249530-2037-441d-b35a-867fd166dd1b%3AGemini_Generated_Image_hntt6ihntt6ihntt.png?table=block&id=23d9c76c-6cb4-808d-b4bf-fc8eefc0fc3b&spaceId=b4216657-966f-4c29-ae8c-42f6c4adb66d
MDXRemote라이브러리를 사용하면서 생긴 애로사항
위의 경우에는 커버 이미지인 경우에는 링크가 유효했지만, 문제는 또 나타났다. 커버이미지의 경우
blockId
를 pageId
로 사용하면 된다는 것을 확인했는데, 페이지 컨텐츠 내부의 이미지는 MDXRemote
라이브러리로 markdown
으로 변환해서 렌더링 하고 있었기 때문이다.
근데 MDXRemote를 포기하기도 어려웠던 것이, 스타일링과 코드블럭 스타일링이 서로 의존하고 있었기 때문에, 이걸 포기한다면 코드블럭 스타일링까지 해야될 거 같아서 엄청나게 일을 키우는 것 처럼 느껴졌다.
핵심은 페이지 내부의 이미지는
blockId
를 사용하지 않고 렌더링하고 있었기 때문인데, 이는 계속 만료시간이 있는 링크를 사용해야한다는 뜻이였고 즉, 캐시전략을 활용하지 못한다는 뜻이였다.따라서 markdown으로 변환한 값이랑 변환하지 않은 값을 확인해서 같은 부분을 매칭할 수 있다면 blockId를 가져올수 있을 것 같았다.
notion-to-md 라이브러리가 변환 전 /변환후 데이터의 차이
// 변환되지 않은 MdBlock - (notion-to-md 라이브러리) { type: 'image', blockId: '2469c76c-6cb4-803f-8359-f8f93d85307d', parent: '', children: [] }, // 변환 후 렌더링 하고 있었던 이미지 파일 경로 
notion-to-md라이브러리를 사용해서 받는 값이랑 markdown으로 변환한 값이랑 다행히 겹치는 부분이 있었기 때문에, 해당 부분으로
MdBlock
객체의 blockId
를 가져오는 일이 가능해졌다. 그래서 MDXRemote
의 componentConfig
설정의 img
태그에 해당되는 부분에서 매칭함수를 정의하고 매칭되는 객체의 blockId
를 넘길 수 있었다.MDXRemote컴포넌트의 componentConfig설정 중의 img태그 스타일링 하는 부분
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => { if (!mdBlocks) { return <ImageFallback />; } const srcUrl = typeof props.src === 'string' ? props.src : ''; const matchingBlock = mdBlocks?.find((block) => { const urlMatch = block.parent?.match(/\]\((.*?)\)/); const blockUrl = urlMatch?.[1]; const cleanBlockUrl = blockUrl?.split('?')[0]; const cleanSrcUrl = srcUrl.split('?')[0]; return cleanBlockUrl === cleanSrcUrl; }); const pageId = matchingBlock?.blockId; if (!pageId) { return <ImageFallback />; } return ( <Suspense fallback={<ImageFallback />}> <DynamicImage {...props} pageId={pageId} /> </Suspense> ); },
MDX Remote 전체 코드
MDXRemote.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'; import rehypePrettyCode from 'rehype-pretty-code'; import CodeBlock from './CodeBlock'; import rehypeSanitize from 'rehype-sanitize'; import remarkGfm from 'remark-gfm'; import rehypeSlug from 'rehype-slug'; import BookmarkCard from './BookmarkCard'; import DynamicImage from './DynamicImage'; import { Suspense } from 'react'; import ImageFallback from './ImageFallback'; import { MdBlock } from 'notion-to-md/build/types'; interface MDXContentProps { source: string; mdBlocks: MdBlock[]; } const prettyCodeOptions = { theme: { dark: 'material-theme-ocean', light: 'aurora-x', }, }; export function MDXContent({ source, mdBlocks }: MDXContentProps) { const componentsConfig = { pre: CodeBlock, h1: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => ( <h1 className="group before-header-tag relative mt-20 scroll-m-20 py-0 text-[2.7rem] font-bold before:-left-15" {...props} > {children} </h1> ), h2: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => ( <h2 className="h2 before-header-tag mt-10 scroll-m-20 font-semibold before:-left-10" {...props} > {children} </h2> ), h3: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => ( <h3 className="h3 before-header-tag mt-15 mb-4 scroll-m-20 font-semibold before:-left-10" {...props} > {children} </h3> ), p: ({ children, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => { const content = props.content; if (content === '') { return <p className="flex h-20 w-full" />; } return ( <p className="text-md mt-10 mb-2 leading-[2] font-light tracking-wide text-gray-200 sm:text-xl" {...props} > {children} </p> ); }, blockquote: ({ children, ...props }: React.HTMLAttributes<HTMLQuoteElement>) => ( <blockquote className="border-primary bg-muted-foreground/10 mt-10 rounded-sm border-l-4 px-8 py-4 [&>*]:my-0" {...props} > {children} </blockquote> ), code: ({ children, ...props }: React.HTMLAttributes<HTMLElement>) => ( <code className="bg-muted relative rounded px-[0.5rem] py-[0.4rem] font-semibold text-white/80" {...props} > {children} </code> ), img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => { if (!mdBlocks) { return <ImageFallback />; } const srcUrl = typeof props.src === 'string' ? props.src : ''; const matchingBlock = mdBlocks?.find((block) => { const urlMatch = block.parent?.match(/\]\((.*?)\)/); const blockUrl = urlMatch?.[1]; const cleanBlockUrl = blockUrl?.split('?')[0]; const cleanSrcUrl = srcUrl.split('?')[0]; return cleanBlockUrl === cleanSrcUrl; }); const pageId = matchingBlock?.blockId; if (!pageId) { return <ImageFallback {...props} />; } return ( <Suspense fallback={<ImageFallback />}> <DynamicImage {...props} pageId={pageId} /> </Suspense> ); }, a: ({ href }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => { if (!href) return ( <span className="text-md leading-[2] font-light tracking-wider sm:text-xl"> 링크를 불러올 수 없습니다. </span> ); return <BookmarkCard href={href} />; }, li: ({ children, ...props }: React.HTMLAttributes<HTMLLIElement>) => ( <li className="ml-6 flex items-start lg:ml-8" {...props}> <span className="mt-[0.4rem] mr-4 text-[0.6rem] leading-[2] font-light sm:mt-[0.7rem]"> ● </span> <div className="text-md w-full min-w-0 flex-1 leading-[2] font-light tracking-wider [overflow-wrap:anywhere] whitespace-normal sm:text-xl [&_code]:break-all [&_figure]:w-full [&_figure]:max-w-full [&_ol]:min-w-0 [&_p]:mt-0 [&_p]:mb-0 [&_pre]:w-full [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_ul]:min-w-0"> {children} </div> </li> ), }; return ( <> <MDXRemote source={source} options={{ mdxOptions: { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeSlug, rehypeSanitize, [rehypePrettyCode, prettyCodeOptions]], }, }} components={componentsConfig} /> </> ); }
이제 커버이미지와 페이지 컨텐츠의 이미지가 잘 렌더링 되는 것 확인

이제 커버이미지와 페이지 내부의 이미지도 URL를 조합해서 권한 이슈없이 렌더링을 할 수 있게되었다.
Gif는 자동 이미지 최적화 안됨 문제
그리고 또다른 문제를 발견했다. 자동이미지 최적화는 gif를 다 재생하지 못한다는 점이였다. 앞부분 2초정도만 나오고 무한 루프를 돌고 있었다.

gif이미지는 자동이미지 최적화가 안되는 것을 확인할 수 있었고, 그리고 온전히 화질을 유지하면서 플레이하기 위해서는 Image 컴포넌트의
upoptimized=true
설정이 활성화되어야 했다. 이를 위해서 gif를 처리하기 위해서는 내부 api route로 이미지 프록시를 사용해서
fetch 캐시옵션
/ 브라우저 캐시 헤더 설정
을 활용해서 서버단에서도 그리고 클라이언트의 브라우저 단에서도 이중 캐시 전략을 사용하는 좋겠다는 생각이 들었다. 이미지 프록시 api 라우트
export async function GET(request: NextRequest): Promise<NextResponse<Result<ArrayBuffer>>> { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('url'); if (!imageUrl) { return new NextResponse('Image URL is required', { status: 400 }); } try { const urlObj = new URL(decodeURIComponent(imageUrl)); const isSafe = await isDomainSafe(urlObj.hostname); if (!isSafe) { return new NextResponse('Domain not allowed', { status: 403 }); } const response = await fetch(imageUrl, { cache: 'force-cache', next: { tags: ['image-proxy'], }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const imageBuffer = await response.arrayBuffer(); return new NextResponse(imageBuffer, { headers: { 'Content-Type': response.headers.get('content-type') || 'image/png', 'Cache-Control': 'public, max-age=86400', // 24시간 'Content-Length': imageBuffer.byteLength.toString(), }, }); } catch (error) { console.error('Image proxy error:', error); return new NextResponse('Failed to fetch image', { status: 500 }); } }
이렇게 캐시전략을 구성하면 fetch에 기억된 이미지버퍼를 바로 반환해주므로 이중 요청하는 것같은 딜레이는 줄일 수 있을 것 같았다.
내가 적용한 방법과 다른 사람들의 적용방법 비교
어떻게든 notion에 저장된 정적 데이터를 어떻게든 사용할려고 했던 이유는, 안그래도 유료로 쓰고 데이터베이스 저장소처럼 사용하고 있는 노션이였는데, 반드시 활용하고 싶었기 때문이다.
물론 어떤 분들은 다른 데이터베이스를 따로 운영하시면서 캐싱전략으로 UX를 끌어올리시는 것 같은데, 사실 그게 최고의 UX라 생각한다. 하지만 나는 비용부분에서도 약간의 균형을 잡고 싶었다. 그리고 많은 사람들이 403이슈를 가지고 있엇기 때문에, 이 방법을 적용해보시면 많은 부분이 해결되지 않을 까 생각한다.
그리고 완전히 사이트를 정적으로 렌더링하는 것 못지 않게 성능을 끌어올려볼 수 있는 다음과 같은 방법을 적용해 볼 수 있다.
- img 컴포넌트를 다이나믹하게 렌더링하고, suspense경계를 사용하여 이미지와 관련없는 부분은 바로 렌더링이 가능하게 한다.
- img 컴포넌트
loading=eager
속성으로 페이지 내부의 이미지들을 모두 병렬 요청할 수 있게 한다. 그래서 첫 글을 읽는동안 이미지는 모두 로드가 다 될 수 있도록 한다.
이러한 방법을 사용하여, 모든 페이지를 정적 서비스하기 위하고 403에러를 해결하기 위해서 추가적인 데이터베이스를 운용하는 수고를 줄일 수 있다고 생각한다.
참고 깃허브
notion-blog-nextjs
chugue • Updated Sep 9, 2025
추가 업데이트
배포후 하루가 지나니까 gif가 알수 없는 이유로 전체 재생이 되지 않고 앞부분만 재생되고 있었다.
조합된 url
을 사용한 모든 gif가 일부부만 계속 루프를 도는 상황이 발생했고, signature
가 포함된 url이 아니면 풀로 재생되지 않았다. 따라서 gif는 동적 생성된 url를 사용할 수 밖에 없었다. 즉, 페이지를 다이나믹하게 렌더링 해야했다.물론 전체 캐싱으로 아주빠른 UX를 제공해주지 못했지만, Suspense경계로 Image만 Client Component로 렌더링하면서 최대한 딜레이를 느끼지 않도록 설계하는 방향으로 마무리 하였다.