Vercel Blob list() 2,600건 → 240건으로 줄인 CDC 캐시 이야기
📑 목차 보기
TL;DR
- 문제: Vercel Blob
list()2,600건/월 호출 → 무료 티어(2,000건) 초과 - 원인: 파일 목록 UI가 페이지 로드/검색/페이징마다
list()호출 - 해결: Postgres CDC 캐시로 99% 절감 (2,600 → 240건/월)
- 결과: 응답 속도 10배 개선 (500ms → 50ms), 비용 절감
- 핵심: 목록은 DB(Postgres), 본문은 Blob, 페이지는 ISR
퇴사 후, 블로그를 고도화하기로 했다
퇴사하고 나서 가장 먼저 든 생각은 "이제 뭘 하지?"였다.
거창한 새 프로젝트보다, 매일 쓰는 블로그의 불편함을 먼저 해결하고 싶었다.
매번 반복되는 불편한 루틴
1. VS Code 열기
2. content/posts/DEV/ 폴더에 MDX 파일 생성
3. Front matter 작성 (title, date, tags...)
4. 본문 작성
5. Git add, commit, push
6. Vercel 배포 대기 (2~3분)
7. 오타 발견 → 1번부터 다시...
특히 짜증났던 순간:
- 밤 11시 침대에서 "저 표현 좀 고쳐야겠는데..." → 결국 노트북 켜야 함
- 카페에서 아이패드로 초안 쓰다가 → "집 가서 컴퓨터로 옮겨 쓰자" 포기
- 통근 시간에 머릿속으로 구조 짰는데 → 집 오면 60% 까먹음
그래서 결심했다.
"내가 매일 쓰는 블로그를 제대로 고도화해 보자."
파일시스템에서 Vercel Blob으로
처음엔 평범한 구조였다.
content/posts/
├── DEV/nextjs-ssr.mdx
└── REACT/hooks.mdx
빌드 시: fs.readFileSync() → 페이지 생성
문제점
- IDE 필수 - 글 수정하려면 VS Code 켜야 함
- 배포 의존성 - 오타 하나 고치려고 Git 커밋 + 배포 (5분)
- 느린 피드백 - "일단 써놓고 나중에 다듬자" → 나중은 안 옴
해결: Vercel Blob
Vercel Blob Storage
└── posts/DEV/my-post.mdx
✅ 어디서든 업로드/수정 (폰, 태블릿, 브라우저)
✅ Blog는 읽기만
✅ 배포는 코드 변경 시에만
Blog-Admin: 핵심 기능 3개
Blob으로 옮기자마자 필요해진 것: 관리 UI
1. 파일 목록

현재 올라간 글/이미지를 테이블 형태로 보여줌
- 검색 (제목/태그)
- 필터 (카테고리별)
- 페이지네이션
구현 (apps/blog-admin/src/app/dashboard/files/page.tsx):
export default async function FilesPage() {
const { blobs } = await list({ prefix: 'posts/' }) // ← 이게 문제였다
return <FileTable files={blobs} />
}
2. 마크다운 편집 + 프리뷰
┌──────────────┬──────────────┐
│ 에디터 │ 프리뷰 │
│ │ │
│ - Monaco │ - 실시간 │
│ - 신택스 │ 렌더링 │
│ 하이라이트 │ - 스크롤 싱크│
└──────────────┴──────────────┘
핵심: Blog와 같은 파이프라인 사용 (@repo/content)
- 프리뷰 = 실제 렌더링 결과 100% 동일
- 저장 전에 이미지/코드 블록 확인 가능
3. 이미지 업로드
Before (파일시스템):
1. 스크린샷 찍기
2. /public/images/ 폴더에 복사
3. 파일명 수동 변경
4. 경로 직접 입력: 
After (Blob):
1. Drag & Drop
2. 자동 업로드 → URL 받기
3. 본문에 자동 삽입
글쓰기 속도가 2배 빨라졌다.
그리고 어느 날, 메일이 왔다
한 달쯤 지나고, Vercel에서 메일 한 통이 왔다.
제목: ⚠️ Vercel Blob Free Tier usage exceeded
You've used 2,347 / 2,000 operations this month.
Upgrade to continue using Blob Storage.
순간 머릿속이 하얘졌다.
"업로드는 한 달에 20번도 안 했는데... 2,347건??"
대시보드를 열어봤다.

원인: list()가 생각보다 많이 나갔다
Blob의 비용 구조:
- ✅ 스토리지: 10GB (충분함)
- ✅ 다운로드: 100GB/월 (충분함)
- ⚠️ Operations: 2,000건/월 ← 여기서 터짐
"Operations"에는 put(), delete(), list() 가 모두 포함된다.
list() 호출 패턴 분석
파일 목록 페이지:
// 페이지 열 때마다
const { blobs } = await list({ prefix: 'posts/' }) // ← list() 1회
- 페이지 열기: 1번
- 새로고침: 1번
- 다른 탭 갔다 오기: 1번
- 하루 10번만 봐도 10번 호출
검색 기능:
const handleSearch = async (query: string) => {
const { blobs } = await list({ prefix: `posts/${query}` }) // ← list() 1회
}
- "next" 입력: 1번
- "nextjs" 수정: 1번
- 오타 고침: 1번
- 검색 한 번에 3~4번 호출
이미지 피커:
const openImagePicker = async () => {
const { blobs } = await list({ prefix: 'images/' }) // ← list() 1회
}
- 글 쓰다 이미지 넣기: 1번
- 다른 이미지로 교체: 1번
- 글 하나에 5번 정도 열기
페이지네이션:
const loadNextPage = async (cursor: string) => {
const { blobs } = await list({ cursor }) // ← list() 1회
}
- 1페이지: 1번
- 2페이지: 1번
- 파일 100개면 5페이지 = 5번
계산해보니...
하루 평균:
- 파일 목록: 10회
- 검색: 15회
- 이미지 피커: 10회
- 페이지네이션: 5회
- 개발/테스트: 20회
─────────────────
총: 60회/일
30일: 60 × 30 = 1,800회
개발 주말 (4일): 200회/일 × 4 = 800회
─────────────────
총합: 2,600회/월 ← 무료 티어 초과!
결론: 업로드는 가끔인데, list()는 매일 나간다.
해결: Postgres CDC 캐시
이미 RBAC 때문에 Postgres(Neon) + Prisma가 있었다.
"어차피 DB가 있으니, Blob 목록을 캐싱하면 되겠는데?"
CDC(Change Data Capture) 패턴
핵심 아이디어:
소스 데이터(Blob)의 변경사항을 감지해서, 복제본(Postgres)에 동기화한다.
- Source of Truth: Blob (파일 실체)
- Mirror: Postgres (메타데이터 캐시)
- 읽기: DB에서 (빠름, 유연함)
- 쓰기: Blob에 (원본 유지)
아키텍처
flowchart TB
subgraph Admin[Blog-Admin]
A1[put/del → Blob]
A2[CDC Hook 즉시 반영]
A3[Periodic Sync 30분마다]
A4[RPC API]
end
subgraph Blob[Vercel Blob Storage]
B1[posts/**/*.mdx]
B2[images/**]
end
subgraph DB[Postgres]
D1[BlobFile 캐시 테이블]
end
subgraph Blog[Blog]
C1[RPC로 목록 조회]
C2[Blob URL로 본문 다운]
C3[ISR 60초]
end
Admin -->|1. 업로드/삭제| Blob
Admin -->|2. Hook 즉시 DB 반영| DB
Admin -->|3. list 30분마다 1회| Blob
Admin -->|4. Sync 결과 DB 반영| DB
Blog -->|5. GET 캐시된 목록| Admin
Blog -->|6. fetch 본문| Blob
핵심 3원칙
-
Blog는
list()를 절대 호출하지 않는다- RPC로 캐시된 목록만 받음
-
Admin만
list()호출, 30분마다 최대 1회needsSync()게이트로 제어
-
목록은 DB, 본문은 Blob
- 빠른 검색 + CDN 다운로드
구현 상세
1) BlobFile 캐시 모델
위치: apps/blog-admin/prisma/schema.prisma:167
model BlobFile {
id String @id @default(cuid())
url String @unique // Blob URL
pathname String // 논리적 경로
size BigInt // 파일 크기
uploadedAt DateTime // Blob 업로드 시각
contentType String? // MIME type
syncedAt DateTime @default(now()) // DB 동기화 시각
lastChecked DateTime @default(now()) // 마지막 존재 확인
isDeleted Boolean @default(false) // Soft delete
@@index([pathname])
@@index([isDeleted])
@@index([lastChecked])
}
왜 Soft Delete?
- 실수로 삭제한 파일 추적 가능
- 복구 옵션 (Blob에 재업로드 시
isDeleted = false) - Sync 로직 단순화
2) Sync 알고리즘
위치: apps/blog-admin/src/lib/blob-cdc.ts:42
async function syncBlobToDatabase() {
// 1. Blob 전체 목록 (여기서만 list() 1회!)
const { blobs } = await list()
// 2. DB 현재 캐시
const dbFiles = await prisma.blobFile.findMany({
where: { isDeleted: false }
})
// 3. Diff 계산
const blobUrls = new Set(blobs.map(b => b.url))
const dbUrls = new Set(dbFiles.map(f => f.url))
const newFiles = blobs.filter(b => !dbUrls.has(b.url))
const deletedUrls = [...dbUrls].filter(url => !blobUrls.has(url))
const existingFiles = blobs.filter(b => dbUrls.has(b.url))
// 4. DB 반영 (트랜잭션)
await prisma.$transaction([
// 신규 추가
prisma.blobFile.createMany({
data: newFiles.map(blob => ({
url: blob.url,
pathname: blob.pathname,
size: blob.size,
uploadedAt: blob.uploadedAt,
contentType: blob.contentType,
}))
}),
// Soft delete
prisma.blobFile.updateMany({
where: { url: { in: deletedUrls } },
data: { isDeleted: true, lastChecked: new Date() }
}),
// lastChecked 업데이트
...existingFiles.map(blob =>
prisma.blobFile.update({
where: { url: blob.url },
data: { lastChecked: new Date() }
})
)
])
return {
added: newFiles.length,
deleted: deletedUrls.length,
updated: existingFiles.length,
}
}
3) needsSync(): 호출 제어
위치: apps/blog-admin/src/lib/blob-cdc.ts:120
async function needsSync(): Promise<boolean> {
const lastSync = await prisma.blobFile.findFirst({
orderBy: { lastChecked: 'desc' },
select: { lastChecked: true }
})
if (!lastSync) return true
// 환경변수로 간격 조절 (기본 30분)
const syncIntervalMs = env.BLOB_SYNC_INTERVAL_MINUTES * 60 * 1000
const threshold = new Date(Date.now() - syncIntervalMs)
return lastSync.lastChecked < threshold
}
효과:
10:00 - 파일 목록 열기 → needsSync() = true → sync
10:01 - 새로고침 → needsSync() = false → skip
10:02 - 검색 → needsSync() = false → skip
...
10:30 - 파일 목록 → needsSync() = true → sync
30분 동안 몇 번을 새로고침해도 sync는 1번만!
4) CDC Hook: 즉시 반영
위치: apps/blog-admin/src/lib/blob-cdc.ts:180
주기 sync만 있으면 "업로드 직후 목록에 안 보임" 문제 발생.
업로드 훅:
async function onBlobUpload(blob: PutBlobResult) {
try {
await prisma.blobFile.upsert({
where: { url: blob.url },
create: {
url: blob.url,
pathname: blob.pathname,
size: blob.size,
uploadedAt: blob.uploadedAt,
contentType: blob.contentType,
},
update: {
size: blob.size,
uploadedAt: blob.uploadedAt,
lastChecked: new Date(),
isDeleted: false, // 재업로드 = 복구
}
})
} catch (e) {
// 실패해도 업로드는 성공 (non-blocking)
console.error('CDC hook failed:', e)
}
}
사용 예시 (apps/blog-admin/src/app/actions/files.ts:407):
export async function createFile(formData: FormData) {
// 1. Blob 업로드
const blob = await put(pathname, file, {
access: 'public',
addRandomSuffix: false, // 중요: 덮어쓰기 가능
})
// 2. DB 즉시 반영 (non-blocking)
await onBlobUpload(blob).catch(console.error)
// 3. 성공 응답
return { url: blob.url }
}
5) Blog는 RPC로 목록 조회
Blog가 DB에 직접 붙으면:
- ❌ DB 크리덴셜 노출
- ❌ 마이그레이션 복잡도 증가
- ❌ 책임 분리 위반
해결: Admin이 Hono RPC API 제공
서버 (apps/blog-admin/src/rpc/routes/blob-files.ts:42):
app.get('/', async (c) => {
// 필요하면 sync
if (await needsSync()) {
await syncBlobToDatabase()
}
// DB 조회 (빠름!)
const files = await prisma.blobFile.findMany({
where: { isDeleted: false },
orderBy: { uploadedAt: 'desc' }
})
return c.json({ files })
})
클라이언트 (apps/blog/src/lib/blob.ts:12):
import { client } from './rpc'
export async function getBlobFiles() {
const response = await client.api.v1['blob-files'].$get({})
const { files } = await response.json()
return files
}
타입 안전성: Hono RPC로 서버 타입이 클라이언트에 자동 공유 ✨
6) 본문은 ISR이 캐시
위치: packages/content/src/posts-blob.ts:42
export async function getPostBySlug(
blobFiles: BlobFileInfo[],
slug: string
) {
// 1. 캐시된 목록에서 파일 찾기
const file = blobFiles.find(f => f.pathname.includes(`posts/${slug}`))
// 2. Blob URL에서 본문 다운로드
const response = await fetch(file.url)
const content = await response.text()
// 3. MDX 파싱
const { data, content: markdown } = matter(content)
const html = await processMarkdown(markdown)
return { slug, title: data.title, html, ... }
}
이 함수는 ISR 시점에만 호출됨 (60초마다 최대 1회)
본문까지 DB에 넣지 않은 이유:
- ✅ ISR이 이미 캐시 역할
- ✅ DB 용량 절약 (MDX는 KB~MB 단위)
- ✅ Blob CDN이 빠름
- ✅ DB 부하 감소
7) 온디맨드 Revalidate
Admin에서 글 수정 시 즉시 반영.
Blog API (apps/blog/src/app/api/revalidate/route.ts:8):
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret')
const path = request.nextUrl.searchParams.get('path')
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid' }, { status: 401 })
}
revalidatePath(path)
return Response.json({ revalidated: true })
}
Admin에서 호출 (apps/blog-admin/src/shared/lib/revalidate-blog.ts:4):
export async function revalidateBlogPath(path: string) {
await fetch(
`${env.NEXT_PUBLIC_BLOG_URL}/api/revalidate?secret=${secret}&path=${path}`,
{ method: 'POST' }
)
}
전체 흐름:
1. put() → Blob 업데이트
2. onBlobUpload() → DB 즉시 반영
3. revalidateBlogPath() → ISR 캐시 무효화
4. 다음 요청 → 최신 내용으로 재생성
결과: 99% 절감 + 10배 빠름
Before CDC
하루: 60회
월: 1,800회
개발 주말: 800회
─────────────
총: 2,600 ops/월 (무료 티어 초과)
After CDC
하루 Admin 사용: 3시간
3시간 ÷ 30분 = 6회
월 평균 사용일: 15일
─────────────
총: 90~240 ops/월 (무료 티어의 12%)
실제 측정 (Vercel 대시보드):
![Vercel Blob 사용량 그래프 - CDC 도입 전후 비교]
- Operations: 287 / 2,000 (14% 사용)
- 99% 절감 성공 ✅
응답 속도 개선
Before (Blob list()):
- P50: 480ms
- P95: 1,200ms
- P99: 2,100ms
After (Postgres 쿼리):
- P50: 52ms
- P95: 120ms
- P99: 180ms
→ P95 기준 10배 개선 ✅
추가 이득
1. 빠른 검색/필터:
-- 태그로 검색
SELECT * FROM BlobFile
WHERE pathname LIKE '%react%'
AND isDeleted = false;
-- 크기로 필터
SELECT * FROM BlobFile
WHERE size > 100000;
-- 최근 업로드 10개
SELECT * FROM BlobFile
ORDER BY uploadedAt DESC LIMIT 10;
2. 통계 집계:
-- 카테고리별 분포
SELECT
SPLIT_PART(pathname, '/', 2) as category,
COUNT(*) as count
FROM BlobFile
WHERE pathname LIKE 'posts/%'
GROUP BY category;
추가 기능들
CDC 구축 후 추가한 기능들:
1. Draft 토글
위치: apps/blog-admin/src/app/dashboard/files/page.tsx:120
---
title: "작성 중인 글"
draft: true # ← UI로 토글 가능
---
- Blog:
draft: true면 숨김 - Admin: 초안 배지로 표시
- 토글 시 자동 revalidate
2. RBAC
위치: apps/blog-admin/src/middleware.ts:15
enum Role {
SUPER_ADMIN = "SUPER_ADMIN", // 모든 권한
ADMIN = "ADMIN", // 글 작성/수정
GUEST = "GUEST" // 읽기만
}
- Auth.js + Google OAuth
- 세션 기반 인증
- 감사 로그
Trade-offs (받아들인 것들)
1. Eventually Consistent
Hook 실패 시:
10:00 - 글 업로드 (Blob 성공)
↓
onBlobUpload() 실패 (DB 장애)
↓
10:01 - 목록에 안 보임 (stale)
↓
10:30 - Periodic sync → 반영
하지만:
- 파일은 Blob에 존재 (URL 접근 가능)
- 5분~30분 후 자동 보정
- 개인 블로그에선 허용 가능
2. list() 완전 0은 아님
Admin은 여전히 30분마다 list() 호출.
완전 0으로 하려면:
- Webhook (Vercel Blob 미지원)
- Event stream (복잡도 급증)
실용적 선택:
- 상한을 두는 것으로 충분
- 무료 티어 14% 사용 (여유)
3. DB 의존성 추가
새로운 실패 지점:
- Postgres 다운 → Admin 다운
하지만:
- 어차피 RBAC용 DB 있었음
- Fallback 가능 (Blob
list()직접 호출)
당신도 CDC를 붙이고 싶다면
Prerequisites
- Postgres DB (Neon, Supabase, etc.)
- Prisma 설정
- Vercel Blob 사용 중
Step-by-step (30분)
1. Prisma 스키마 추가 (5분)
model BlobFile {
id String @id @default(cuid())
url String @unique
pathname String
size BigInt
uploadedAt DateTime
contentType String?
syncedAt DateTime @default(now())
lastChecked DateTime @default(now())
isDeleted Boolean @default(false)
@@index([pathname])
@@index([isDeleted])
@@index([lastChecked])
}
2. Migration 실행 (2분)
npx prisma migrate dev --name add_blob_cache
npx prisma generate
3. Sync 함수 작성 (10분)
위의 syncBlobToDatabase() 코드 복사
4. Hook 연결 (5분)
업로드 액션에 onBlobUpload() 추가
5. 환경변수 설정 (2분)
BLOB_SYNC_INTERVAL_MINUTES=30
6. 테스트 (6분)
# 파일 업로드 → DB 확인
psql $DATABASE_URL -c "SELECT * FROM BlobFile LIMIT 5;"
# list() 호출 줄었는지 Vercel 대시보드 확인
전체 코드: GitHub - bbakjun-blog
마무리
배운 것
1. 비용 최적화는 "측정"에서 시작
막연히 "비싸다"가 아니라, 정확히 "list() 2,600건"을 알아야 해결 가능.
2. 완벽한 설계보다 점진적 개선
파일시스템 → Blob → Admin → CDC... 한 번에 설계했으면 시작도 못 했을 것.
3. Trade-off는 명시적으로 받아들이기
Eventually consistent, DB 의존성... 완벽한 해결책은 없다.
4. 문서화는 미래의 나를 위한 투자
3개월 후 이 코드 보면 "왜?" 할 텐데, 이 글이 답이 된다.
한 줄 요약
파일은 Blob(원본), 목록은 Postgres(CDC), 페이지는 ISR(렌더 캐시)
다음 글 예고
- Vercel Blob CDC 파트 2: Full-text search with Postgres
tsvector - Admin 고도화: Monaco Editor + Real-time collaboration
- 비용 최적화 시리즈: Redis/Postgres/Blob 비용 분석
레포지토리
- 전체 코드: GitHub - bbakjun-blog
- 관련 문서: docs/vercel-storage-cdc-cache.md
질문/피드백은 댓글로 남겨주세요! 🙏