노션을 Headless CMS로 쓸 때 마주치는 1시간 만료 이미지 이슈(AWS S3 Presigned URL)와 완벽한 해결법

Astro와 노션(Notion)을 결합한 Headless CMS 파이프라인은 에디터의 작성 편의성과 정적 사이트(SSG)의 압도적인 웹 성능을 동시에 잡을 수 있는 매력적인 아키텍처입니다.
연동을 마치고 노션에서 글을 쓰고 배포를 누르면, 웹사이트에 제목과 본문뿐만 아니라 업로드한 이미지까지 시각적으로 완벽하게 노출됩니다.
하지만 기쁨도 잠시, 발행 후 정확히 1시간 정도가 지나면 멀쩡하던 이미지가 브라우저에서 물음표 아이콘(엑스박스)으로 깨지며 사라지는 기현상을 마주하게 됩니다.
노션을 CMS로 구축할 때 99%의 개발자가 겪게 되는 이 치명적인 이미지 만료 문제의 원인과, Astro 빌드 타임에서 이를 영구적으로 해결한 실무 파이프라인을 공유합니다.
1. 문제 현상: 방금 올린 이미지가 왜 1시간 뒤에 깨질까?
- 초기 상태: 노션에 이미지를 직접 첨부(파일 업로드)하고 사이트를 빌드하면 데스크톱과 모바일 화면 모두에서 이미지가 선명하게 잘 나옵니다.
- 1시간 경과 후: 페이지 새로고침 시 이미지가 로드되지 않고
403 Forbidden또는AccessDenied에러와 함께 깨진 이미지 아이콘이 출력됩니다.
개발자 도구(Network 탭)를 열어 이미지 URL을 확인해 보면 원인은 즉시 드러납니다. 이미지 주소가 다음과 같은 형태로 구성되어 있습니다.
[https://prod-files-secure.s3.us-west-2.amazonaws.com/.../image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=20260930T...&X-Amz-Expires=3600&X-Amz-Signature=](https://prod-files-secure.s3.us-west-2.amazonaws.com/.../image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=20260930T...&X-Amz-Expires=3600&X-Amz-Signature=)...
주소 파라미터 중 X-Amz-Expires=3600에 주목해야 합니다. 3600초, 즉 정확히 1시간짜리 시한부 URL입니다.
2. 근본적인 원인 분석
1) 노션의 보안 정책과 AWS S3 Presigned URL
노션에 사용자가 직접 첨부한 이미지와 파일은 노션이 관리하는 프라이빗 AWS S3 버킷에 저장됩니다. 보안상 외부에 버킷을 전체 공개(Public Read)할 수 없기 때문에, Notion API는 인증된 요청자에게만 "1시간 동안만 임시로 열람할 수 있는 서명된 주소(Presigned URL)"를 발급합니다.
2) SSG(정적 사이트 생성) 렌더링과의 충돌
- SSR(서버 사이드 렌더링) 방식이라면 방문자가 페이지를 새로고침할 때마다 매번 노션 API를 새로 호출해 최신 서명 URL을 받아오므로 문제가 겉으로 드러나지 않을 수 있습니다.
- 반면 Astro의 기본 동작인 SSG(정적 빌드)는 빌드 시점에 받아온 임시 URL을 정적 HTML 파일(
.html)에 그대로 '하드코딩(구워 넣기)'합니다. - 결국 빌드된 지 1시간이 지나면 HTML에 적힌 AWS S3 토큰이 만료되어 브라우저가 이미지를 영구적으로 불러올 수 없게 되는 것입니다.
3. 해결책: 빌드 타임 이미지 로컬 자산화 파이프라인
외부 이미지 호스팅(Cloudinary, Imgur 등)에 매번 수동으로 올리는 것은 에디터의 작성 경험을 해치는 반쪽짜리 해결책입니다. 노션의 에디터 환경을 그대로 살리려면, Astro가 빌드할 때 노션의 임시 이미지를 서버에서 다운로드하여 영구적인 정적 파일로 변환해야 합니다.
[노션 API 데이터 패칭]
│ (1시간 시한부 S3 URL 수신)
▼
[Astro 빌드 파이프라인 (Image Downloader 유틸)]
│ 1. Cover Image 및 본문 내 S3 URL 정규식 추출
│ 2. fetch로 원본 이미지 바이너리 다운로드
│ 3. public/images/notion/[slug]/ 폴더에 로컬 파일로 저장
│ 4. 본문 HTML 내 이미지 경로를 로컬 주소(/images/notion/...)로 치환
▼
[Cloudflare Pages 배포 아티팩트] 영구 보존되는 정적 자산으로 배포 완료
4. 실무 구현 가이드
1) 이미지 다운로더 유틸 작성 (src/lib/notionImageDownloader.ts)
Astro 빌드 시점에 Node.js의 fs 모듈과 fetch를 활용해 원본 이미지를 다운로드하고 경로를 치환하는 함수를 구현합니다. 중복 다운로드를 막기 위한 파일 존재 여부 체크도 포함합니다.
import fs from "node:fs";
import path from "node:path";
export async function downloadAndReplaceNotionImages(contentMarkdown: string, slug: string): Promise<string>{
const targetDir = path.join(process.cwd(), "public", "images", "notion", slug);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// 노션 S3 임시 URL 정규식 패턴 탐색
const s3UrlRegex = /https:\/\/prod-files-secure\.s3[^\s)"']+/g;
const matches = [...new Set(contentMarkdown.match(s3UrlRegex) || [])];
let updatedMarkdown = contentMarkdown;
for (let i = 0; i < matches.length; i++) {
const s3Url = matches[i];
// 확장자 추출 (.png, .jpg 등 기본 png 처리)
const urlObj = new URL(s3Url);
const ext = path.extname(urlObj.pathname) || ".png";
const fileName = `image-${i + 1}${ext}`;
const filePath = path.join(targetDir, fileName);
const localPublicUrl = `/images/notion/${slug}/${fileName}`;
try {
// 이미 다운로드된 파일이 없다면 다운로드 실행
if (!fs.existsSync(filePath)) {
const response = await fetch(s3Url);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
fs.writeFileSync(filePath, Buffer.from(arrayBuffer));
}
}
// 마크다운 내부의 임시 URL을 영구적인 로컬 정적 주소로 치환
updatedMarkdown = updatedMarkdown.replaceAll(s3Url, localPublicUrl);
} catch (error) {
console.error(`이미지 다운로드 실패 (${s3Url}):`, error);
}
}
return updatedMarkdown;
}
2) 커버 이미지(Cover Image) 처리
포스트 상단에 노출되는 대표 썸네일 역시 노션 첨부 파일(type === 'file')일 경우 동일한 방식으로 다운로드하여 영구 경로로 바인딩합니다.
// src/lib/notion.ts 내부 발췌
let coverImageUrl = "";
if (post.coverImage?.type === "file") {
const originalUrl = post.coverImage.file.url;
const ext = path.extname(new URL(originalUrl).pathname) || ".jpg";
const fileName = `cover${ext}`;
const saveDir = path.join(process.cwd(), "public", "images", "notion", slug);
if (!fs.existsSync(saveDir)) {
fs.mkdirSync(saveDir, { recursive: true });
}
const filePath = path.join(saveDir, fileName);
if (!fs.existsSync(filePath)) {
const res = await fetch(originalUrl);
if (res.ok) {
fs.writeFileSync(filePath, Buffer.from(await res.arrayBuffer()));
}
}
coverImageUrl = `/images/notion/${slug}/${fileName}`;
}
5. 결론 및 도입 효과
이 다운로드 파이프라인을 도입한 뒤 얻은 핵심 성과는 다음과 같습니다:
- 영구적인 안정성: 배포 후 몇 주, 몇 달이 지나도 이미지가 만료되지 않고 Cloudflare 엣지 CDN에서 초고속으로 서빙됩니다.
- 에디터 경험 극대화: 작성자는 외부 이미지 업로더를 거칠 필요 없이, 노션 편집기 화면에 이미지를 편하게 복사-붙여넣기(
Ctrl + V)하기만 하면 됩니다. - 완벽한 데이터 자산화: 노션 서비스에 장애가 생기거나 API 정책이 바뀌더라도, 이미 빌드된 정적 이미지 자산은 내 프로젝트의 소유(
public/images/...)로 안전하게 보존됩니다.
노션을 Headless CMS로 연동하여 정적 사이트를 빌드할 계획이라면, 첫날 반드시 이 이미지 다운로드 파이프라인을 가장 먼저 구축해 두는 것을 권장합니다.