고양이hyebin
대대적인 블로그 개선 작업 (Notion API 연동)
대대적인 블로그 개선 작업 (Notion API 연동)
September 26, 2025

들어가며

Image

블로그를 전면적으로 마이그레이션했습니다! 기존의 정적 mdx 파일 관리 방식에서 notion api 연동과 isr방식으로 전환했는데요! 이렇게 바꾸게 된 이유와 과정을 회고해보겠습니다.

개발 블로그 플랫폼을 이리저리 옮겨다니다가 한동안 네이버 블로그에 정착했었어요. 네이버 블로그를 선택한 큰 이유는 빠르게 접근이 가능하며 깔끔한 레이아웃으로 공부한 기록을 바로 바로 정리하고 확인할 수 있었죠.

하지만 아주 큰 단점이 있었습니다. 네이버 블로그는 마크다운이 지원되지 않았어요. 또 코드를 복붙하는데 굉장히 번거로웠죠. 코드를 넣으려면 코드 블럭 추가 후 코드를 복사해서 코드 블럭에 붙여넣기로 넣어야 했습니다.

처음에는 하나씩 옮겨 붙여넣었지만 양이 많아질수록 번거로움이 생겼고, 점차 손이 안가더라구요 🥺 그래서 다시 옮길 블로그를 이곳 저곳 찾아보다가 제가 만들어뒀던 마크다운 블로그를 다시 들여다보게되었습니다. 👀

기존 방식의 문제점

마크다운 블로그가 손에 잘 안가게 되었던 이유는 복잡한 글 업로드 방식때문이었어요. 이전 블로그에서는 글을 업로드하기위해 mdx 파일로 만들어서 커밋을 해야만 업로드 되었어요. 코드 안에서 이미지와 mdx를 관리하는게 굉장히 무겁고 귀찮은 작업이었습니다.

내가 자주쓰는 어딘가에서 그냥 글을 불러오는 방식은 없을까? 하다가 노션이라는 아이디어를 얻게되었죠! 노션이라는 아이디어를 시작으로 블로그를 전면적으로 수정하게되었습니다.

마이그레이션 여정

1차 시도 : 노션에 있는 글을 자동으로 MDX로 생성하면 되잖아?

기존에는 모든 글을 정적으로 관리했어요. mdx 파일 추가 후 커밋하면 깃액션이 돌면서 블로그에 업로드 되는 방식이었죠.

하지만 ! 노션 이라는 아이디어를 얻게 되었고 “노션에 있는 글을 자동으로 mdx로 생성하면 되잖아?” 라는 생각으로 작업을 진행했어요.

프로세스는 이렇습니다. Notion 작성후 커밋하면 깃 액션에서 노션에 글을 읽어와 mdx로 바꿔주는 스크립트가 돌면서 노션의 글이 MDX 변환되어 생성되고, 이를 다시 빌드하여 배포하는 방식으로 개선했습니다.

의도는 노션에 있는 글을 가져다가 자동으로 mdx로 넣자! 였지만 … 매번 동기화 스크립트 실행을 해야하고, 빌드 시 매번 도는 스크립트로 많은 리소스를 소모하게 되면서 시간이 길어졌어요. 또한 매번 모든 파일을 동기화 할 수 없으니 파일을 추가/ 변경 하기 위해서 액션 전 어떤 글인지 파악하기 위해 slug도 추가로 입력해야했죠.

mdx파일을 직접 생성하지 않아도 되었지만, 다른점은 개선되지 않았고 오히려 더 느려졌어요.

2차 시도: 아예 자동화로 가보자. SSG → ISR 전면 개선

사용자가 페이지 요청을 했을때만 노션에서 해당 글을 가져오면 되지 않을까? 그리고 글이 자주 바뀌지 않을텐데 캐싱 하면 되지 않을까? 하는 아이디어로 2차 개선 작업에 들어갔습니다.

힌트는 ISR이었습니다.

💡
ISR?

ISR은 정적 사이트 생성(SSG)을 개선한 방식으로, 빌드 후에도 페이지를 점진적으로 재생성할 수 있게 합니다. 재 검증하는 프로세스가 있어 revalidate: 60 설정 시, 60초가 지난 후 첫 번째 요청이 들어오면 먼저 기존 캐시된 페이지를 사용자에게 제공 후 동시에 백그라운드에서 새 페이지를 재생성하여 재생성이 완료되면 다음 요청부터 새 버전 제공하는 방식입니다.

첫 방문자에 의해 데이터를 한번 가져오면 캐싱이 되는거죠! 저는 revalidate: 3600으로 설정하여 1시간마다 재검증하도록 했습니다. 이렇게 하니 5분 걸리던 빌드 시간을 30초로 크게 단축시킬 수 있었어요.

로컬에서 개발할 필요 없이 노션 데이터로 블로그 업로드가 가능해진 거예요.

핵심 기능 구현

태그 시스템 도입

노션 데이터로 관리가 쉬워지자 어렵지 않게 태그 시스템도 추가했습니다. 노션에 태그 속성을 추가하여, 태그만 써주면 되는거죠. 글을 분류하기도 쉽고 전체적으로 어떤 주제의 글을 써왔는지 직관적으로 알 수 있었죠.

이미지 만료 문제 발생

문제가 발생했어요. 노션에 이미지를 업로드를 하면 잘 보입니다! 여기까지는 문제가 없는 줄 알았죠. 근데 일정 시간이 지나버리니 URL이 만료되는 문제가 발생했습니다.

이는 Notion의 이미지가 Presigned URL 방식이기 때문입니다. Presigned URL은 일시적으로만 접근 가능한 임시 URL이에요. 만약 만료된 URL이 브라우저나 CDN에 의해 캐시되면 서버에서 새로운 URL을 발급해도 여전히 이전 URL을 참조하기 때문에 이미지가 계속 깨져 보이게 됩니다. 또한 Next Image 컴포넌트를 사용하면서 내부적으로 /_next/image!... 의 경로로 이미지를 최적화 하게 됩니다. url이 만료 될 경우 최적화도 실패하게 되고 엑박으로 표시가 됩니다. 이때 새로고침 하면, 즉 캐시를 없애면 다시 보이게 되죠.

제가 생각한 방법은 세가지 였는데요.

  1. 1.노션을 웹으로 게시 후 웹으로 노션 이미지를 가져오기
  2. 2.이미지 만료 시 fallback으로 다시 가져오기
  3. 3.s3로 이미지 업로드 후 관리

아무래도 1번과 2번은 비용도 들지 않고 노션 데이터로 해결이 가능했습니다. 작은 규모에 개인 프로젝트에는 적합해 보였죠. 노션을 웹으로 게시 후 이미지를 가져오려고 했지만 노션에 숨겨진 값들을 찾아내서 비교하고, 파일구조에 따라 url이 달라져 애를 먹었습니다. 또한 웹으로 게시 후 웹 이미지를 가져오는 과정에서 노션 의존성이 매우 커졌죠. (웹 게시를 취소 후 다시 게시하면 코드가 달라져 재 배포가 필요했습니다.) 그래서 이미지 만료 시 fallback으로 다시 가져오는 방향으로 바꿔봤지, 이미지를 다시 가져와서 로딩 하는 시간이 꽤 걸렸죠.

Image

노션의 이미지가 1시간마다 만료된다고 가정 했을 때, 블로그 이용자의 대부분은 만료된 이미지를 접할 것이며 fallback 로직이 돌것입니다. 소중한 방문자가 3초의 시간을 손해본다는 생각하면 속상한 문제입니다. 🥺

그래서 비용이 조금은 들더라도 안정적이고 유지보수가 적은 s3업로드 방향으로 진행했습니다. 월 1-2달러로 ux를 크게 개선할 수 있다면 투자할 가치가 있죠

자동 이미지 처리 시스템 구축

  1. 1.S3 URL을 미리 만들어놓기

제 전략을 일단 가져오자!입니다. 파일 이름으로 s3주소를 세팅해놓았습니다. 현재 노션에 저장된 이미지는 다음과 같은 형태의 주소입니다.

 https://prod-files-secure.s3.us-west-2.amazonaws.com///image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466QZVGOMU6%2F20250929%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250929T003641Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2lu….Signature=&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject

위 주소를 정규화하여 토큰 파라미터 제거합니다.

// URL을 정규화하여 토큰 파라미터 제거
function normalizeNotionUrl(url: string): string {
  try {
    const urlObj = new URL(url);
    // AWS S3 URL에서 토큰 관련 파라미터 제거
    urlObj.searchParams.delete("X-Amz-Algorithm");
    urlObj.searchParams.delete("X-Amz-Credential");
    urlObj.searchParams.delete("X-Amz-Date");
    urlObj.searchParams.delete("X-Amz-Expires");
    urlObj.searchParams.delete("X-Amz-Signature");
    urlObj.searchParams.delete("X-Amz-SignedHeaders");
    urlObj.searchParams.delete("X-Amz-Security-Token");

    // 파일 경로만 사용 (토큰 없이)
    return urlObj.protocol + "//" + urlObj.host + urlObj.pathname;
  } catch {
    return url;
  }
}

정규화된 url을 이용하여 해쉬값을 생성 후 s3에 업로드 할 url을 만듭니다.

// URL 해시로 고유한 파일명 생성
// MD5 해시를 사용하여 같은 이미지는 항상 같은 파일명을 갖도록 함
function generateFileName(notionUrl: string, slug?: string): string {
  const normalizedUrl = normalizeNotionUrl(notionUrl);
  const hash = crypto.createHash("md5").update(normalizedUrl).digest("hex");
  const ext = getFileExtension(notionUrl);

  if (slug) {
    return `notion-images/${slug}/${hash}.${ext}`;
  }

  return `notion-images/shared/${hash}.${ext}`;
}
//S3 URL만 생성
export function generateS3Url(notionUrl: string, slug?: string): string {
  // S3 설정이 없으면 fallback
  if (!BUCKET_NAME || !process.env.AWS_ACCESS_KEY_ID) {
    return "/jump.webp";
  }

  const fileName = generateFileName(notionUrl, slug);
  return `https://${BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${fileName}`;
}
  1. 1.이미지 로드 실패 시 s3에 이미지 업로드

이미지 에러 시 에러처리 함수 컴포넌트를 구현합니다.

"use client";

import Image from "next/image";
import { useState, useCallback } from "react";

interface FallbackImageProps {
  src: string;
  alt: string;
  className?: string;
  notionUrl?: string;
  width?: number;
  height?: number;
}

interface UploadResponse {
  uploadedUrl: string;
}

const FALLBACK_IMAGE = "/jump.webp";

export function FallbackImage({
  src,
  alt,
  className,
  notionUrl,
  width = 800,
  height = 300,
}: FallbackImageProps) {
  const [currentSrc, setCurrentSrc] = useState(src);
  const [isUploading, setIsUploading] = useState(true);

  const uploadImage = useCallback(
    async (notionUrl: string, s3Url: string): Promise<string | null> => {
      try {
        const response = await fetch("/api/upload-image", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            notionUrl,
            s3Url: s3Url.split("?")[0],
          }),
        });

        if (!response.ok) return null;

        const result: UploadResponse = await response.json();
        return result.uploadedUrl && result.uploadedUrl !== FALLBACK_IMAGE
          ? result.uploadedUrl
          : null;
      } catch (error) {
        console.error("Failed to upload image:", error);
        return null;
      }
    },
    [],
  );

  const handleImageError = useCallback(async () => {
    if (notionUrl && !isUploading) {
      const uploadedUrl = await uploadImage(notionUrl, currentSrc);
      setIsUploading(false);
      if (uploadedUrl) {
        setCurrentSrc(uploadedUrl);
        return;
      }
    }

    setCurrentSrc(FALLBACK_IMAGE);
  }, [currentSrc, notionUrl, uploadImage, isUploading]);

  return (
    <Image
      src={isUploading ? FALLBACK_IMAGE : currentSrc}
      alt={alt}
      className={className}
      onError={handleImageError}
      width={width}
      height={height}
      onLoad={() => setIsUploading(false)}
    />
  );
}

이미지를 업로드 하는 Next.js API 라우트를 만듭니다. /api/upload-image

import { NextRequest, NextResponse } from "next/server";
import { uploadNotionImageToS3 } from "@/lib/s3";

export async function POST(request: NextRequest) {
  try {
    const { notionUrl, s3Url } = await request.json();

    if (!notionUrl) {
      return NextResponse.json({ error: "Missing notionUrl" }, { status: 400 });
    }

    console.log(`Uploading missing image: ${notionUrl} -> ${s3Url}`);

    // S3 URL에서 slug 추출
    let slug;
    if (s3Url && s3Url.includes("/notion-images/")) {
      const pathParts = s3Url.split("/notion-images/")[1]?.split("/");
      if (pathParts && pathParts.length > 1) {
        slug = decodeURIComponent(pathParts[0]);
      }
    }

    // S3에 업로드
    const uploadedUrl = await uploadNotionImageToS3(notionUrl, slug);

    return NextResponse.json({
      success: true,
      uploadedUrl,
      message: "Image uploaded successfully",
    });
  } catch (error) {
    console.error("Image upload error:", error);
    return NextResponse.json(
      { error: "Failed to upload image" },
      { status: 500 },
    );
  }
}
Image

s3로 캐싱된 이미지를 가져오기 때문에 로드 시간이 단축이 많이 되었습니다! (3초 → 20ms로)

성과

항목BeforeAfter개선
글 작성mdx 파일 직접 생성Notion에서 작성-
배포전체 재빌드 필요ISR 자동 갱신-
이미지 관리수동 업로드자동 S3 업로드-
빌드 시간5분30초⚡ 90% 단축
이미지 로딩3초20ms⚡ 99% 개선

추가하고싶은 기능

  1. 1.검색 기능

    현재 태그별 필터링만 가능한 상태인데, 전체 글에서 키워드 검색이 가능하면 좋을 것 같아요.

  2. 2.태그 기반 관련 포스팅 추천

    글 하단에 "이런 글도 읽어보세요" 섹션을 추가하고 싶어요.

  3. 3.라이트 모드 추가

    현재 다크모드만 지원하고 있는데, 밝은 라이트 모드도 추가해보고싶어요. 사용자 OS 테마 설정에 따라 자동으로 테마 적용되고 우측 상단에 토글을 추가할 계획이에요.

마무리

Image

이번 마이그레이션을 통해 개발자 경험과 사용자 경험 모두를 크게 개선할 수있었어요. 완벽보다 점진적 개선을 통해 문제점을 빠르게 찾고 실패를 통해 더 나은 방법을 찾을 수 있었습니다.

이제 노션을 통해 글쓰기에만 집중할 수 있게 되었고, 자동화된 이미지로 빠른 접근이 가능해졌습니다. 앞으로도 지속적인 개선을 통해 더 나은 블로그를 만들어 나가겠습니다! 🚀 기대해주세요 >.<