만료되는 Notion 이미지를 Sharp로 영구 저장하기
Notion 이미지 URL 만료 문제 해결과 sharp 기반 최적화
들어가며
지금은 Obsidian으로 글을 쓰지만, 한동안 Notion을 SSoT로 두고 마크다운으로 동기화하는 구조로 블로그를 운영했다. SSoT(Single Source of Truth, 단일 진실 공급원)는 원본 데이터가 한 곳에만 존재하고 나머지는 거기서 파생된다는 개념인데, Notion에 글을 쓰면 그게 원본이고 레포의 마크다운 파일은 동기화된 복사본이었다.
그때 가장 골치 아팠던 부분은 이미지였다. Notion 이미지 URL은 만료 시간이 있어서 그대로 두면 글이 깨졌다. 여러 선택지를 두고 고민한 끝에, 동기화 시점에 이미지를 다운로드해서 최적화하고 영구적인 URL로 재업로드하는 파이프라인을 만드는 쪽으로 결정했다.
지금은 레거시가 된 코드지만 그때 고민했던 것들은 지금도 유효하다고 생각해서 정리하게 되었다. 어떤 라이브러리를 쓸지, 이미지를 어디에 저장할지, 같은 글을 여러 번 동기화해도 안전하게 만들려면 무엇이 필요한지 등이다.
Notion 이미지 URL 문제
Notion 이미지 URL은 이렇게 생겼다.
https://prod-files-secure.s3.us-west-2.amazonaws.com/...?X-Amz-Expires=3600...
이 링크에는 두 가지 문제가 있다.
- 만료 시간이 있다. URL에 서명이 포함되어 있어서 일정 시간이 지나면 접근할 수 없다.
- 원본 크기 그대로다. 4K 스크린샷을 올리면 4K 그대로 로드된다.
어떤 선택지가 있었나
만료되는 URL을 다루는 방법은 여러 가지였다.
- 빌드 타임에 매번 새 URL 받기: Notion API는 페이지를 fetch할 때마다 새 signed URL을 발급한다. 이론적으로는 빌드할 때마다 다시 받으면 만료 문제가 사라진다. 하지만 빌드 시간이 글 수에 비례해서 늘어나고, Notion API에 장애가 생기면 빌드 자체가 깨진다. 무엇보다 원본 크기 문제는 그대로 남는다.
- 이미지 프록시 서비스(Cloudinary 등)에 올리기: 원본 URL을 던지면 캐시·최적화·CDN까지 알아서 처리해준다. 가장 빠른 길이지만 외부 서비스 비용과 의존성이 추가되고, 만료 URL을 프록시가 어떻게 다루는지에 따라 안정성이 달라진다.
- 다운로드해서 영구 저장소에 올리기: 동기화 시점에 한 번만 처리하면 그 후로는 신경 쓸 게 없다. 동기화 스크립트가 좀 복잡해진다는 점을 감수하면, 빌드도 빠르고 외부 의존성도 최소화된다.
선택지를 찾아보고 정답은 없다는 생각이 들었다. 하지만 블로그라는 특성상 글을 한 번 쓰고 거의 안 고치는 식으로 가기 때문에 3번 방식이 가장 잘 맞을 것 같았다. 빌드 시간을 늘리고 싶지 않았고, 외부 서비스에 의존하지 않아서 개인적으로 만족스러웠다.
sharp로 이미지 처리
Node.js에서 이미지 처리는 sharp가 주로 사용된다. 내부적으로 libvips라는 C로 작성된 이미지 처리 라이브러리를 사용한다. ImageMagick 같은 전통적인 이미지 처리 라이브러리는 이미지 전체를 메모리에 올린 뒤 작업하지만, libvips는 픽셀 데이터를 작은 청크 단위로 흘려보내며 처리한다.
여기서 중요한 건 단순히 "나눠서 처리한다"가 아니라 "한 청크를 처리한 직후 즉시 메모리에서 버린다"는 점이다. 어느 순간에도 메모리에는 지금 처리 중인 한 조각만 존재한다. 이게 가능한 이유는 리사이즈나 컬러 필터 같은 연산이 출력 픽셀 하나를 계산할 때 입력의 작은 이웃 픽셀들만 필요로 하기 때문이다. 이미지 전체를 동시에 들고 있을 필요가 없다.
4K 이미지(3840 × 2160 × 4바이트)는 약 33MB를 차지하는데, ImageMagick은 원본과 출력을 둘 다 들고 있어야 해서 60MB 이상을 쓰는 반면, libvips는 청크 한 조각만 들고 있어서 보통 1-2MB 수준에 머무른다. 같은 작업에서 ImageMagick보다 4배 이상 빠르고 메모리는 훨씬 적게 쓴다고 알려져 있다.
API도 메서드 체이닝으로 처리 단계를 자연어처럼 이어 쓸 수 있다. 아래 코드처럼 '버퍼를 받아 1200px로 리사이즈하고, WebP로 변환한 뒤, 다시 버퍼로 돌려준다'는 흐름을 그대로 메서드 호출 순서로 표현할 수 있다.
import sharp from "sharp";
const optimizedBuffer = await sharp(Buffer.from(buffer))
.resize(1200, null, {
withoutEnlargement: true,
fit: "inside",
})
.webp({
quality: 85,
effort: 6,
})
.toBuffer();
최대 너비를 1200px로 제한하고, WebP 포맷으로 변환한 결과를 버퍼로 반환한다.
리사이징 전략
maxWidth: 1200으로 큰 변 기준 최대 너비를 제한한다. 본문 폭은 768px 정도지만 고밀도 디스플레이(HiDPI)에서 흐릿하지 않으려면 본문보다 큰 원본이 필요해서 1200으로 두었다. withoutEnlargement: true는 원본보다 크게 확대하지 않는다는 뜻이고, 작은 이미지를 1200px로 늘리면 픽셀이 깨지기 때문에 넣었다.
WebP 변환
모든 이미지를 WebP로 변환한다. JPEG보다 같은 화질에서 25-35% 작고, PNG처럼 투명도도 지원하고, 주요 브라우저에서 모두 동작한다. 압축률·투명도·호환성 세 가지를 모두 챙길 수 있는 포맷이라 골랐다.
effort: 6은 압축 효율과 속도를 조정하는 옵션이다. 총 0-6 범위이고, 높을수록 압축률이 좋지만 느리다. 빌드 시 한 번만 실행하므로 6으로 설정했다.
Vercel Blob Storage
최적화된 이미지를 어디에 저장할지 고민했다. Git 저장소에 넣으면 용량이 커지고, 외부 서비스(Cloudinary, S3)는 별도 설정이 필요하다.
Vercel Blob은 Vercel 프로젝트와 통합이 잘 되어 있어서 선택했다.
import { put } from "@vercel/blob";
const { url } = await put(webpFileName, optimizedBuffer, {
access: "public",
contentType: "image/webp",
});
반환된 URL은 영구적이고, CDN으로 서빙된다.
마크다운 내 이미지 자동 처리
글 동기화 시 마크다운을 스캔해서 Notion 이미지를 찾고, 최적화 후 URL을 교체한다.
async processImagesInMarkdown(markdown: string, postSlug: string) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let processedMarkdown = markdown;
// 모든 이미지 매치 수집
const matches: RegExpExecArray[] = [];
let match;
while ((match = imageRegex.exec(markdown)) !== null) {
matches.push(match);
}
for (const match of matches) {
const [fullMatch, altText, imageUrl] = match;
// 이미 처리된 이미지는 스킵
if (this.isVercelBlobUrl(imageUrl)) continue;
// Notion 이미지만 처리
if (this.isNotionImageUrl(imageUrl)) {
const result = await this.optimizeAndUploadImage(imageUrl, fileName, postSlug);
processedMarkdown = processedMarkdown.replace(
fullMatch,
``
);
}
}
return processedMarkdown;
}
정규식으로 마크다운 이미지 문법을 찾고, Notion URL인 경우에만 최적화 및 업로드를 수행한 뒤 URL을 교체한다.
멱등성 보장
같은 글을 여러 번 동기화해도 이미지가 중복 업로드되지 않는다.
private isVercelBlobUrl(url: string): boolean {
return url.includes('blob.vercel-storage.com');
}
이미 Vercel Blob URL이면 스킵한다. 한 번 처리된 이미지는 다시 처리하지 않는다.
하지만..
Obsidian으로 옮긴 이후로는 이미지 관리가 훨씬 단순해졌다. 이미지를 붙여넣으면 Custom Attachment Location 플러그인이 public/images/posts/{slug}/ 경로에 자동 저장하고, Next.js가 정적 파일로 서빙하므로 별도 파이프라인이 필요 없다. 기존 글의 Vercel Blob URL은 그대로 유지된다.
파이프라인 코드는 레거시가 됐지만 삭제하지 않고 남겨뒀다. Notion 연동이 필요한 다른 프로젝트에서 그대로 떼어다 쓸 수 있는 형태이기도 하고, 같은 문제를 다시 만났을 때 참고하기 위해서다.
정리
- Notion 이미지 URL은 서명 기반 만료 방식이라 동기화 시점에 로컬로 내려받아야 한다
sharp로 1200px 리사이즈 + WebP 변환을 적용하면 파일 크기를 크게 줄일 수 있다- Vercel Blob에 업로드하면 영구 URL과 CDN 서빙을 동시에 확보한다
- 이미 처리된 URL은 건너뛰는 조건을 두어 멱등성을 보장한다
- Obsidian으로 전환한 이후에는
public/images/로컬 저장 방식으로 대체되어 파이프라인은 레거시가 됐다