들어가며

블로그를 전면적으로 마이그레이션했습니다! 기존에는 글을 작성할 때마다 코드 파일을 직접 수정하고 배포해야 했는데요, 이제는 노션에서 글을 쓰기만 하면 자동으로 블로그에 반영되도록 개선했습니다. 이렇게 바꾸게 된 이유와 과정을 회고해보겠습니다.
개발 블로그 플랫폼을 이리저리 옮겨다니다가 한동안 네이버 블로그에 정착했었어요. 네이버 블로그를 선택한 큰 이유는 빠르게 접근이 가능하며 깔끔한 레이아웃으로 공부한 기록을 바로 바로 정리하고 확인할 수 있었죠.
하지만 아주 큰 단점이 있었습니다. 네이버 블로그는 마크다운이 지원되지 않았어요. 🥺 그래서 코드를 복붙하는데 굉장히 번거로웠죠. 코드 블럭 추가 후 코드를 복사해서 붙여넣기로 넣어야 했습니다.
처음에는 하나씩 옮겨 붙여넣었지만 양이 많아질수록 번거로움이 생겼고, 점차 손이 안가더라구요 🥺 그래서 다시 옮길 블로그를 이곳 저곳 찾아보다가 제가 만들어뒀던 마크다운 블로그를 다시 들여다보게되었습니다. 👀
기존 방식의 문제점
기존 마크다운 블로그가 손에 잘 안가게 되었던 이유는 복잡한 글 업로드 방식때문이었어요. 이전 블로그에서는 글을 업로드하기위해 mdx 파일로 만들어서 커밋을 해야만 업로드 되었어요. 코드 안에서 이미지와 mdx를 관리하는게 굉장히 무겁고 귀찮은 작업이었습니다.
‘내가 자주쓰는 어딘가에서 그냥 글을 불러오는 방식은 없을까?’ 생각하다가 노션이라는 아이디어를 얻게되었어요.
마이그레이션 여정
1차 시도 “노션에 있는 글을 자동으로 MDX로 생성하면 되잖아?”
기존에는 모든 글을 정적으로 관리했어요. mdx 파일 추가 후 커밋하면 깃액션이 돌면서 블로그에 업로드 되는 방식이었죠.
하지만 “노션에 있는 글을 자동으로 mdx로 생성하면 되잖아?” 라는 생각으로 작업을 진행했어요. 🏃🏻♀️
프로세스는 이렇습니다. 노션에 글을 작성후 커밋하면 깃 액션에서 노션에 글을 읽어와 mdx로 바꿔주는 스크립트가 돌고, 노션의 글이 mdx로 변환되어 생성됩니다. 이를 다시 빌드하여 배포하는 방식으로 개선했습니다.
의도는 노션에 있는 글을 가져다가 자동으로 mdx에 만들자! 였지만 … 매번 동기화 스크립트 실행을 해야하고, 빌드 시 매번 도는 스크립트로 많은 리소스를 소모하게 되면서 시간이 오래걸렸어요. 또한 파일을 추가/ 변경 하기 위해 액션 전 slug도 추가로 입력해야했죠.
mdx파일을 직접 생성하지 않아도 되었지만, 근본적인 불편함을 해결할 수 없었습니다.
2차 시도: 아예 자동화로 가보자. SSG → ISR 전면 개선
“사용자가 페이지 요청을 했을때만 노션에서 해당 글을 가져오면 되지 않을까? 그리고 글이 자주 바뀌지 않을텐데 캐싱 하면 되지 않을까?” 하는 생각으로 2차 개선 작업에 들어갔습니다.
힌트는 ISR이었습니다.
빌드 시 페이지를 미리 만들어두고, 정해진 시간마다 백그라운드에서 자동으로 갱신하는 방식이에요.
사용자는 항상 빠른 정적 페이지를 보면서도, 콘텐츠는 자동으로 최신화됩니다.
예: revalidate: 3600 설정 시 1시간마다 페이지 재생성
첫 방문자에 의해 데이터를 한번 가져오면 캐싱이 되는거죠! 저는 revalidate: 3600으로 설정하여 1시간마다 재검증하도록 했습니다.
로컬에서 개발할 필요 없이 노션 데이터로 블로그 업로드가 가능해진 거예요.
핵심 기능 구현
export const revalidate = ISR_TIME;
// generateStaticParams 추가 - ISR을 위한 정적 경로 생성
export async function generateStaticParams() {
const logs = await getNotionLogs();
return logs.map((log) => ({
date: log.slug,
}));
}generateStaticParams()로 빌드 타임에 모든 페이지를 미리 생성하고 (SSG),revalidate옵션으로 1시간마다 백그라운드에서 페이지를 재생성하도록 설정했습니다.
이 둘의 조합이 바로 ISR 입니다!
빌드 결과를 분석해볼까요?
Route (app) Size First Load JS
├ ● /log/[date] 2.74 kB 315 kB
├ ├ /log/what-about-rss
├ ├ /log/solving-nextjs-server-issues
├ └ [+24 more paths] 👈 27개 로그 페이지
├ ● /post/[slug] 2.74 kB 315 kB
├ ├ /post/upgrade-blog
├ └ [+6 more paths] 👈 9개 포스트 페이지
└ ● /tag/[tag] 2.45 kB 91 kB
└ [+7 more paths] 👈 10개 태그 페이지
● (SSG) automatically generated as static HTML + JSON- /log/[date] - 27개 로그 페이지 정적 생성
- /post/[slug] - 9개 포스트 페이지 정적 생성
- /tag/[tag] - 10개 태그 페이지 정적 생성
페이지들이 (SSG) = Static Site Generation 로 잘 생성이 되었습니다.
추가 기능 구현
이미지 만료 문제 발생
노션에 이미지를 업로드를 하면 잘 보입니다! 여기까지는 문제가 없는 줄 알았어요. 그런데 일정 시간이 지나버리니 이미지 URL이 만료되는 문제가 발생했습니다.
이는 Notion의 이미지가 Presigned URL 방식이기 때문입니다. Presigned URL은 일시적으로만 접근 가능한 임시 URL이에요. 만약 만료된 URL이 브라우저나 CDN에 의해 캐시되면 서버에서 새로운 URL을 발급해도 여전히 이전 URL을 참조하기 때문에 이미지가 계속 깨져 보이게 됩니다. 이때 새로고침 하면, 즉 캐시를 없애면 다시 보이게 되죠. 매번 새로고침을 해야하는 블로그라니, 사용자 경험 측면에서 치명적이었습니다.
그래서 제가 생각한 방법은 세가지 였는데요.
- 1.노션을 웹으로 게시 후 웹으로 노션 이미지를 가져오기
- 2.이미지 만료 시 fallback으로 다시 가져오기
- 3.S3로 이미지 업로드 후 관리
아무래도 1번과 2번은 비용도 들지 않고 노션 데이터로 해결이 가능했습니다. 작은 규모에 개인 프로젝트에 적합해 보였죠.
노션에서 직접 이미지를 웹으로 게시 후 가져오는 방법으로 시도했어요. 하지만 웹 이미지를 가져오는 과정에서 노션 의존성이 매우 커지는 문제가 있었습니다. 웹 게시를 취소 후 다시 게시하면 ID 코드가 달라져 매번 재 배포가 필요했거든요.
그래서 이미지 만료 시 fallback으로 다시 가져오는 방식으로 변경했습니다. 그런데 이번에는 이미지를 실시간으로 다시 가져와서 로딩하는 시간이 꽤 걸렸죠.

노션 이미지가 1시간마다 만료된다고 가정했을 때, 블로그 이용자 대부분은 만료된 이미지를 접하게 되고 fallback 로직이 동작할 것입니다. 소중한 방문자가 3초 이상 로딩을 기다려야 한다면, 이는 곧 이탈로 이어질 수밖에 없었습니다.🥺
그래서 비용이 조금은 들더라도 안정적이고 유지보수가 적은 S3업로드 방향으로 진행했습니다. 월 1-2달러로 ux를 크게 개선할 수 있다면 투자할 가치가 있었어요.
자동 이미지 처리 시스템 구축
핵심 전략: S3 URL을 먼저 만들어놓고, 없으면 그때 업로드하기
동작 방식은 이렇습니다
- 1.빌드 시 노션 이미지 URL → S3 URL로 변환 (실제 파일은 아직 없음)
- 2.브라우저가 S3 URL로 이미지 로드 시도
- 3.이미 S3에 있으면 → 즉시 로드
- 4.없으면 (404) → 노션에서 가져와서 S3에 업로드 후 표시
이렇게 하면 재방문자나 이미 처리된 이미지는 빠르게 로드되고, 새 이미지만 첫 로드 시에만 업로드 과정을 거치게 됩니다.
- 1.S3 URL 미리 생성하기
노션 이미지 URL은 다음과 같은 형태입니다:
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에는 만료되는 토큰이 포함되어 있어요. 토큰을 제거하고 순수한 파일 경로만 추출한 뒤, MD5 해싱으로 고유한 S3 경로를 생성합니다.
- 토큰 제거
// 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 해시로 고유한 파일명 생성
// 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 생성
//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}`;
}이렇게 생성된 S3 URL을 노션 데이터에 먼저 넣어둡니다. 실제 파일이 S3에 있는지 없는지는 나중에 확인하는 거죠.
- 1.없으면 그때 업로드하기
브라우저가 S3 URL로 이미지를 로드하다가 에러가 나면,
그제야 노션에서 원본을 가져와 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 },
);
}
}

S3에 캐싱된 이미지를 가져오기 때문에 로드 시간이 단축이 많이 되었습니다! (3초 → 20ms로)
태그 시스템 도입
노션 데이터로 관리가 쉬워지자 어렵지 않게 태그 시스템도 추가했습니다. 노션에 태그 속성을 추가만 하면 되는거죠. 글을 분류하기도 쉽고 전체적으로 어떤 주제의 글을 써왔는지 직관적으로 알 수 있게 되었어요.
성과
| 항목 | Before | After | 개선 |
|---|---|---|---|
| 글 작성 | mdx 파일 직접 생성 | Notion에서 작성 | - |
| 배포 | 전체 재빌드 필요 | ISR 자동 갱신 | - |
| 이미지 관리 | 수동 업로드 | 자동 S3 업로드 | - |
| 이미지 로딩 | 3초 | 20ms | ⚡ 99% 개선 |
추가하고싶은 기능
- 1.검색 기능
현재 태그별 필터링만 가능한 상태인데, 전체 글에서 키워드 검색이 가능하면 좋을 것 같아요.
- 2.태그 기반 관련 포스팅 추천
글 하단에 "이런 글도 읽어보세요" 섹션을 추가하고 싶어요.
- 3.라이트 모드 추가
현재 다크모드만 지원하고 있는데, 밝은 라이트 모드도 추가해보고싶어요. 사용자 OS 테마 설정에 따라 자동으로 테마 적용되고 우측 상단에 토글을 추가할 계획이에요.
마무리

이번 마이그레이션을 통해 개발자 경험과 사용자 경험 모두를 크게 개선할 수있었어요. 완벽보다 점진적 개선을 통해 문제점을 빠르게 찾고 실패를 통해 더 나은 방법을 찾을 수 있었습니다.
이제 노션을 통해 글쓰기에만 집중할 수 있게 되었고, 자동화된 이미지로 빠른 접근이 가능해졌습니다. 앞으로도 지속적인 개선을 통해 더 나은 블로그를 만들어 나가겠습니다! 🚀 기대해주세요
