logoStephen's 기술블로그

포스트 검색

제목, 태그로 포스트를 검색해보세요

내가 Notion API 페이지 이미지 렌더링 구현한 방법

내가 Notion API 페이지 이미지 렌더링 구현한 방법
NextJS
성훈 김
2025년 8월 16일
목차
 

개요

현재 나의 블로그는 Nextjs 프레임워크를 활용해서 notion API로 노션의 게시물을 블로그 페이지로 서빙하고 있다. 난 거의 모든 노트를 노션에 정리하고 블로깅도 관리하고 있어서, 노션이랑 연동된 블로그가 있었으면 좋겠다고 생각하고 있었는데, notion api에 대해서 처음 알았을 때는 나의 블로그에 대한 고민을 모두 해결해 줄 수 있을 것만 같았다. 하지만 구현하면서 깨달은 점은 노션 페이지의 컨텐츠를 불러오는 데 있어서 여러가지 까다로운 점이 존재함을 알았다.
 

뭐가 문제 였는가?

Nextjs 장점을 십분 활용하고 그리고 블로그의 특징인 자주 업데이트 되지 않는 다는 점 때문에 첫 페이지 항목들과 메타데이터는 모두 태그기반 캐시를 사용해서 사용자 경험을 끌어올리고 싶었다.
 
그런데 페이지를 다 배포했더니 얼마 되지 않아 이미지가 나오지 않는 다는 것을 확인했다. 그리고 개발자 도구를 열어서 확인해보니 403에러를 반환하고 있었다. 그리고 여러 곳에서 이 이슈에 다루고 있는 것을 확인했다.
 
Notion API 컨텐츠 403 이슈 토론
403 error for images
 
 
이유는 Notion API로 데이터를 요청하면 페이지 내부에 사진이나 영상들을 어디서든 접근 가능한 링크를 주는 것이 아니라, 1시간만 접근 가능S3 presignedUrl을 반환했기 때문이다. 반환 형태는 아래 코드와 같다.
 
공식문서에 게시된 페이지 컨텐츠의 반환 형태
TypeScript
{
  "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시간의 제한시간이 있었다.
 
노션에서 직접 이미지 주소를 확인한 링크
Plain Text
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로 반환 받는 형태
TypeScript
{
  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 변환 함수
TypeScript
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을 변환한 링크 → 함수결과
TypeScript
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라이브러리를 사용하면서 생긴 애로사항

위의 경우에는 커버 이미지인 경우에는 링크가 유효했지만, 문제는 또 나타났다. 커버이미지의 경우 blockIdpageId로 사용하면 된다는 것을 확인했는데, 페이지 컨텐츠 내부의 이미지는 MDXRemote 라이브러리로 markdown으로 변환해서 렌더링 하고 있었기 때문이다.
 
페이지 내부 컨텐츠는 markdown으로 변환해서 렌더링하고 있었다.
페이지 내부 컨텐츠는 markdown으로 변환해서 렌더링하고 있었다.
 
근데 MDXRemote를 포기하기도 어려웠던 것이, 스타일링과 코드블럭 스타일링이 서로 의존하고 있었기 때문에, 이걸 포기한다면 코드블럭 스타일링까지 해야될 거 같아서 엄청나게 일을 키우는 것 처럼 느껴졌다.
 
핵심은 페이지 내부의 이미지는 blockId를 사용하지 않고 렌더링하고 있었기 때문인데, 이는 계속 만료시간이 있는 링크를 사용해야한다는 뜻이였고 즉, 캐시전략을 활용하지 못한다는 뜻이였다.
 
따라서 markdown으로 변환한 값이랑 변환하지 않은 값을 확인해서 같은 부분을 매칭할 수 있다면 blockId를 가져올수 있을 것 같았다.
 
notion-to-md 라이브러리가 변환 전 /변환후 데이터의 차이
TypeScript
// 변환되지 않은 MdBlock - (notion-to-md 라이브러리)
{
  type: 'image',
  blockId: '2469c76c-6cb4-803f-8359-f8f93d85307d',
  parent: '![image.png](https://prod-files-secure.s3.us-west-2.amazonaws.com/b4216657-966f-4c29-ae8c-42f6c4adb66d/1d08e583-cefe-4020-9e5c-8d5ff662ced1/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466ZISWZVF4%2F20250818%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250818T103245Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEFoaCXVzLXdlc3QtMiJIMEYCIQDnLdvZCTYJ%2B%2Fdk%2BQM5nMlm2GDPdJ0JwoX%2B89DJ%2ByaL4gIhALJ3CztHgdFnMACTmH0Lut6hzTbsU0lVHuOc6h4g%2B73JKogECKP%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igz2CGLs2T9et9h8sj4q3ANMC8hJBtgBI%2BWxfXRM1k0bGh2QG5Imo%2F31%2BIOr4xlOcPJ4MwChY940TCll2GXxXiwkK9%2Brd230LwAjazloWffRE8HGkOJvRGO0cznzdrJ3gyQyKKErsTJmn0GL6q3e0Zz8hFX48i%2FJvpe%2B7i9WzHQ0sjzIO6eaHUFnLNEaAOnzdE4As1yxkhYaMNI3QR9WGx1tl59ooOnGrL%2BathhuSxCcyB3wQr%2Fb4xlyKGX%2F4x8V0xs18%2FPLXjuyyXMbneAqZ8P68BcmV9oVUrG71IJQ23MSG0%2F3%2FDiMtCB1mY%2FqPwEAHVXLBt%2FGmNDhW%2FSdqzPFxmsQAbaXWOQQcGg7hPKFTWF5qxOI3yU1S33VNbx3gvWv3JywZnTes8l%2Bm8%2FKg%2FdB6aIxQKY%2Fl1EcTXHOn%2FpLKsG2EBD8EZvA7NdUfnxamgiLZclE75eO7w9eJKPKXPZLhsBwfcwiT5IDyN%2BLORJCArc9YhqituVzSQ5DKMElq0Nc1477YEVztYuGidw%2FGfGw6K6%2Fbf5sEc%2F8gu2lClv1HWRCPyDG%2FbxwEjJ4skUsY5XMoBQ4lX6pyp7Eo2LSNSchhXjDu9cA9on9%2BVJ6kqdEnsvYcrIY0qAAqSE7MZiorQeQV0BN4QjomHghAHDSgDC074vFBjqkASWGHVFE1Rhm2Z9dsgTJ%2FcpCFBNiqPJP90eO%2BBucy%2F8doxnR%2FZI8GJmug3rFZNjajHPQNQqwMQwyS8uVi50eSH6aFvvawQochTiejitHjl64vqkbL7%2BUTVMoQ6xi1iC45zDfCWleRSJFp%2B6AUQO7vM5WQIQ8ytGcz%2FojgoXiE5DiewLAR2f1S6c7zhGEmSEsNLDxXjXpnyIE4%2FC2lj8bF0%2FrqPGD&X-Amz-Signature=7f9f153f8becfb8f703eddc0836eab6577f7f02bfa610a7dc09041bb4d4a8eb5&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject)',
  children: []
},

// 변환 후 렌더링 하고 있었던 이미지 파일 경로
![9.png](https://prod-files-secure.s3.us-west-2.amazonaws.com/b4216657-966f-4c29-ae8c-42f6c4adb66d/8ff71f9b-9efd-4074-a169-606d2e5b5372/9.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466ZISWZVF4%2F20250818%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250818T103245Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEFoaCXVzLXdlc3QtMiJIMEYCIQDnLdvZCTYJ%2B%2Fdk%2BQM5nMlm2GDPdJ0JwoX%2B89DJ%2ByaL4gIhALJ3CztHgdFnMACTmH0Lut6hzTbsU0lVHuOc6h4g%2B73JKogECKP%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1Igz2CGLs2T9et9h8sj4q3ANMC8hJBtgBI%2BWxfXRM1k0bGh2QG5Imo%2F31%2BIOr4xlOcPJ4MwChY940TCll2GXxXiwkK9%2Brd230LwAjazloWffRE8HGkOJvRGO0cznzdrJ3gyQyKKErsTJmn0GL6q3e0Zz8hFX48i%2FJvpe%2B7i9WzHQ0sjzIO6eaHUFnLNEaAOnzdE4As1yxkhYaMNI3QR9WGx1tl59ooOnGrL%2BathhuSxCcyB3wQr%2Fb4xlyKGX%2F4x8V0xs18%2FPLXjuyyXMbneAqZ8P68BcmV9oVUrG71IJQ23MSG0%2F3%2FDiMtCB1mY%2FqPwEAHVXLBt%2FGmNDhW%2FSdqzPFxmsQAbaXWOQQcGg7hPKFTWF5qxOI3yU1S33VNbx3gvWv3JywZnTes8l%2Bm8%2FKg%2FdB6aIxQKY%2Fl1EcTXHOn%2FpLKsG2EBD8EZvA7NdUfnxamgiLZclE75eO7w9eJKPKXPZLhsBwfcwiT5IDyN%2BLORJCArc9YhqituVzSQ5DKMElq0Nc1477YEVztYuGidw%2FGfGw6K6%2Fbf5sEc%2F8gu2lClv1HWRCPyDG%2FbxwEjJ4skUsY5XMoBQ4lX6pyp7Eo2LSNSchhXjDu9cA9on9%2BVJ6kqdEnsvYcrIY0qAAqSE7MZiorQeQV0BN4QjomHghAHDSgDC074vFBjqkASWGHVFE1Rhm2Z9dsgTJ%2FcpCFBNiqPJP90eO%2BBucy%2F8doxnR%2FZI8GJmug3rFZNjajHPQNQqwMQwyS8uVi50eSH6aFvvawQochTiejitHjl64vqkbL7%2BUTVMoQ6xi1iC45zDfCWleRSJFp%2B6AUQO7vM5WQIQ8ytGcz%2FojgoXiE5DiewLAR2f1S6c7zhGEmSEsNLDxXjXpnyIE4%2FC2lj8bF0%2FrqPGD&X-Amz-Signature=31000b791d0dc9b986471e684499fec1d8effd4d450c36ae47025867027c59c8&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject)
 
notion-to-md라이브러리를 사용해서 받는 값이랑 markdown으로 변환한 값이랑 다행히 겹치는 부분이 있었기 때문에, 해당 부분으로 MdBlock객체의 blockId를 가져오는 일이 가능해졌다. 그래서 MDXRemotecomponentConfig설정의 img태그에 해당되는 부분에서 매칭함수를 정의하고 매칭되는 객체의 blockId를 넘길 수 있었다.
 
MDXRemote컴포넌트의 componentConfig설정 중의 img태그 스타일링 하는 부분
TypeScript
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
TypeScript
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}
      />
    </>
  );
}
 
이제 커버이미지와 페이지 컨텐츠의 이미지가 잘 렌더링 되는 것 확인
notion image
 
이제 커버이미지와 페이지 내부의 이미지도 URL를 조합해서 권한 이슈없이 렌더링을 할 수 있게되었다.
 

Gif는 자동 이미지 최적화 안됨 문제

그리고 또다른 문제를 발견했다. 자동이미지 최적화는 gif를 다 재생하지 못한다는 점이였다. 앞부분 2초정도만 나오고 무한 루프를 돌고 있었다.
 
notion image
 
gif이미지는 자동이미지 최적화가 안되는 것을 확인할 수 있었고, 그리고 온전히 화질을 유지하면서 플레이하기 위해서는 Image 컴포넌트의 upoptimized=true 설정이 활성화되어야 했다.
 
이를 위해서 gif를 처리하기 위해서는 내부 api route로 이미지 프록시를 사용해서 fetch 캐시옵션 / 브라우저 캐시 헤더 설정을 활용해서 서버단에서도 그리고 클라이언트의 브라우저 단에서도 이중 캐시 전략을 사용하는 좋겠다는 생각이 들었다.
 
이미지 프록시 api 라우트
TypeScript
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
chugueUpdated Sep 9, 2025
 

추가 업데이트

배포후 하루가 지나니까 gif가 알수 없는 이유로 전체 재생이 되지 않고 앞부분만 재생되고 있었다. 조합된 url을 사용한 모든 gif가 일부부만 계속 루프를 도는 상황이 발생했고, signature가 포함된 url이 아니면 풀로 재생되지 않았다. 따라서 gif는 동적 생성된 url를 사용할 수 밖에 없었다. 즉, 페이지를 다이나믹하게 렌더링 해야했다.
 
물론 전체 캐싱으로 아주빠른 UX를 제공해주지 못했지만, Suspense경계로 Image만 Client Component로 렌더링하면서 최대한 딜레이를 느끼지 않도록 설계하는 방향으로 마무리 하였다.
 
 

참고한 자료