Vercel Blob list() 2,600건 → 240건으로 줄인 CDC 캐시 이야기

18 min read로딩중...
by bbakjun
📑 목차 보기

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() → 페이지 생성

문제점

  1. IDE 필수 - 글 수정하려면 VS Code 켜야 함
  2. 배포 의존성 - 오타 하나 고치려고 Git 커밋 + 배포 (5분)
  3. 느린 피드백 - "일단 써놓고 나중에 다듬자" → 나중은 안 옴

해결: Vercel Blob

Vercel Blob Storage
  └── posts/DEV/my-post.mdx

✅ 어디서든 업로드/수정 (폰, 태블릿, 브라우저)
✅ Blog는 읽기만
✅ 배포는 코드 변경 시에만

Blog-Admin: 핵심 기능 3개

Blob으로 옮기자마자 필요해진 것: 관리 UI

1. 파일 목록

filelist-view.png
filelist-view.png

현재 올라간 글/이미지를 테이블 형태로 보여줌

  • 검색 (제목/태그)
  • 필터 (카테고리별)
  • 페이지네이션

구현 (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. 경로 직접 입력: ![](../../../public/images/...)

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건??"

대시보드를 열어봤다.

vercel_limit_operation_email.png
vercel_limit_operation_email.png


원인: 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원칙

  1. Blog는 list()를 절대 호출하지 않는다

    • RPC로 캐시된 목록만 받음
  2. Admin만 list() 호출, 30분마다 최대 1회

    • needsSync() 게이트로 제어
  3. 목록은 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() = truesync
10:01 - 새로고침 → needsSync() = false → skip
10:02 - 검색 → needsSync() = false → skip
...
10:30 - 파일 목록 → needsSync() = truesync

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 비용 분석

레포지토리

질문/피드백은 댓글로 남겨주세요! 🙏