Vercel Blob list() 초과 메일 한 통이 만든 CDC 캐시 파이프라인

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

TL;DR

  • "IDE 없이 글 쓰고 싶다" → MDX를 Vercel Blob으로 옮기고 Blog-Admin을 만들었다
  • 파일 목록 / 에디터+프리뷰 / 이미지 업로드 / draft 토글 / RBAC까지 붙였더니, 어느 날 무료 티어 초과 메일이 왔다
  • 원인은 업로드가 아니라 list() 호출. 목록 UI는 생각보다 list()를 많이 때린다
  • 이미 RBAC용으로 Postgres를 쓰고 있어서, BlobFile 캐시 테이블 + CDC sync를 붙여 해결했다
  • Blog는 DB에 직접 안 붙고, Admin의 RPC로 캐시된 목록만 받아온다. 페이지는 ISR + 온디맨드 revalidate.

퇴사하고 나서 가장 먼저 든 생각

퇴사하고 나서 가장 먼저 든 생각은 "이제 뭘 하지?"였다.

회사 일에서 빠져나온 시간과 에너지를 어디에 쓸지 고민하다 보니, 자연스럽게 "사이드 프로젝트 하나 해볼까?" 같은 생각들이 떠올랐다.

근데 곰곰이 생각해보면, 진짜로 나를 괴롭히던 건 새 서비스의 부재가 아니라 매일같이 쓰는 내 블로그의 불편함이었다.

매번 글을 쓸 때마다:

  • 노트북을 켠다
  • VS Code를 연다
  • content/posts/DEV/ 폴더에 새 MDX 파일을 만든다
  • Front matter를 작성한다 (title, date, tags...)
  • 본문을 쓴다
  • 로컬에서 npm run dev로 미리보기 확인
  • Git add, commit, push
  • Vercel 배포를 기다린다 (2~3분)
  • 배포된 페이지 확인
  • 오타 발견하면 다시 1번부터...

"퇴근길 지하철에서 폰으로 글을 좀 다듬고 싶은데, 이걸 하려면 결국 노트북을 꺼내야 하네."

이걸 매번 반복하다 보니, 글쓰기가 취미라기보다는 **"배포 파이프라인을 돌리는 개발 작업"**에 더 가까웠다.

특히 짜증났던 순간들:

  • 밤 11시에 침대에 누워서 "아, 저 부분 표현 좀 고쳐야겠는데..." 생각나면 결국 다시 일어나서 노트북을 켜야 했다
  • 카페에서 아이패드로 블로그 초안을 쓰다가, 결국 "집 가서 컴퓨터로 옮겨 쓰자" 하고 포기했다
  • 통근 시간에 머릿속으로 글 구조를 다 짜놨는데, 집에 오면 60%는 까먹고 있었다

그래서 거창한 새로운 사이드 프로젝트 대신, **"내가 매일 쓰는 블로그를 제대로 고도화해 보자"**는 쪽으로 방향을 틀었다.


시작: "글을 코드에서 떼어내고 싶었다"

처음에는 아주 평범한 구조였다.

content/posts/
  ├── DEV/
  │   ├── nextjs-ssr-caching.mdx
  │   └── typescript-generics.mdx
  └── REACT/
      └── react-hooks.mdx

빌드 시점에 fs.readFileSync()로 읽어서 페이지 생성

이 방식은 단순하고 강력하다. 많은 개발자 블로그가 이렇게 운영되고, 나도 처음엔 만족했다.

하지만 "글을 쓰는 경험"만 놓고 보면 단점이 명확했다.

파일시스템 기반 블로그의 문제점

1. IDE가 필수다

  • 글을 수정하려면 VS Code나 Vim을 켜야 한다
  • 마크다운 프리뷰를 보려면 별도 플러그인이 필요하다
  • 이미지 업로드는... 수동으로 /public/images/에 넣고 경로를 직접 입력해야 한다

2. 수정 → 배포가 한 묶음이다

  • 오타 하나를 고치더라도 Git 커밋이 필요하다
  • 커밋 히스토리가 "fix typo", "fix typo again", "really fix typo" 같은 걸로 더럽혀진다
  • 배포를 기다리는 2~3분 동안 다른 작업으로 컨텍스트 스위칭이 일어난다

3. 글쓰기의 속도보다 배포 파이프라인의 속도가 지배한다

  • 10초 만에 고칠 수 있는 오타를 고치는 데 5분이 걸린다
  • "일단 써놓고 나중에 다듬자"는 마인드가 생긴다 (그리고 나중은 안 온다)

그래서 Vercel Blob으로

그래서 포스트를 Vercel Blob에 두면 어떨까 생각했다.

  • 어디서든 업로드/수정할 수 있고 (폰, 태블릿, 남의 컴퓨터...)
  • Blog는 "파일을 읽기만" 하면 되고
  • 배포는 코드가 바뀔 때만 하면 된다

초기 구상은 이랬다:

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

Blog App (Next.js)
  - 빌드/런타임에 Blob에서 파일 읽기
  - ISR로 페이지 캐싱
  - 파일 변경되면 revalidate

어디서 파일 업로드?
  → 처음엔 Vercel CLI로 수동 업로드
  → 곧 "관리 UI가 필요하겠구나..." 깨달음

블로그 어드민: 내가 필요해서 만든 기능들

Blob으로 옮기는 순간, "관리 UI"가 필요해진다.

처음엔 간단한 업로드 페이지만 만들려고 했는데, 막상 시작하니 **"진짜 편한 글쓰기 도구"**를 만들고 싶어졌다.

어드민에서 내가 꼭 필요하다고 생각했던 기능은 이 정도였다.

1. 파일 목록

현재 올라가 있는 글/초안/파일들을 한눈에 보고 싶다.

  • 테이블 형태로 파일명, 크기, 업로드 날짜 표시
  • 검색 기능 (제목이나 태그로 찾기)
  • 필터링 (카테고리별, draft 상태별)
  • 페이지네이션 (파일이 많아질 걸 대비)

처음엔 "그냥 Blob의 list() API 호출해서 보여주면 되겠지" 생각했다. 이게 나중에 큰 함정이 될 줄 몰랐다...

2. 마크다운 파서

MD/MDX를 바로 렌더링해서 구조를 보고 싶다.

"저장하기 전에 이게 어떻게 보이는지" 알고 싶었다.

  • 제목 계층(H1, H2, H3)이 제대로 구성됐는지
  • 코드 블록이 제대로 렌더링되는지
  • 이미지 경로가 깨지진 않았는지

처음엔 간단한 마크다운 라이브러리를 쓰려다가, "어차피 블로그에서 쓰는 파이프라인과 똑같이 해야 정확하지 않나?" 싶어서 @repo/contentprocessMarkdown()을 그대로 가져다 썼다.

덕분에 프리뷰가 100% 정확해졌다. 어드민에서 본 게 그대로 블로그에 올라간다.

3. 마크다운 편집 + 프리뷰

브라우저에서 바로 글을 쓰고, 양쪽에 프리뷰 띄워보기. 진짜 이게 제일 중요했다.

초기 UI 구성:

┌──────────────┬──────────────┐
│              │              │
│   에디터     │   프리뷰     │
│   (좌측)     │   (우측)     │
│              │              │
│  - Textarea  │ - 실시간     │
│  - 신택스    │   렌더링     │
│    하이라이트│ - 스크롤     │
│              │   싱크       │
└──────────────┴──────────────┘

처음엔 간단한 <textarea>로 시작했다가, 점점 욕심이 나서:

  • Monaco Editor 도입 (VS Code와 같은 편집 경험)
  • 실시간 프리뷰 (타이핑할 때마다 debounce로 렌더링)
  • 스크롤 싱크 (왼쪽 스크롤하면 오른쪽도 따라감)
  • 단축키 지원 (Cmd+S로 저장, Cmd+B로 볼드 등)

여기까지 왔을 때 생각했다. "이거 진짜 내가 매일 쓰고 싶은 도구가 됐네."

4. 이미지 업로드

스크린샷, 다이어그램, 코드 캡처 등을 바로 끌어다 쓰고 싶다.

기존 방식의 문제:

1. 스크린샷 찍기
2. Finder에서 /public/images/ 폴더 열기
3. 파일 이름 바꾸기 (screenshot-1.png → meaningful-name.png)
4. MDX 파일에 경로 직접 입력
   ![image](/images/meaningful-name.png)
5. 오타 나면... 다시 수정

새로운 방식:

1. 스크린샷을 에디터에 Drag & Drop
2. 자동으로 Blob에 업로드
3. URL을 받아서 본문에 자동 삽입
   ![image](https://blob.vercel-storage.com/...)
4. 끝!

이게 얼마나 편한지 직접 써보기 전엔 몰랐다. 글 쓰는 속도가 2배는 빨라진 느낌이었다.

추가로 만든 기능:

  • 이미지 피커: 이미 업로드한 이미지 목록에서 선택
  • 썸네일 프리뷰: 큰 이미지도 작게 보여줌
  • 복사 버튼: URL을 클릭 한 번에 복사
  • 삭제 기능: 안 쓰는 이미지 정리

5. 게시글 드래프트 토글

"아직 비공개", "이제 공개해도 됨" 상태를 어드민에서 켜고 끄기.

---
title: "작성 중인 글"
draft: true   # ← 이 토글 하나로
---

블로그에서는 draft: true면 목록에서 숨기고, 어드민에서는 "초안" 배지 붙여서 보여준다.

토글 한 번에:

  • 블로그에서 숨김/표시
  • /api/revalidate 자동 호출
  • 목록 페이지 즉시 갱신

**"글을 쓰다가 중간에 나가서 밥 먹고 와도, 독자한테는 안 보인다"**는 심리적 안정감이 생겼다.

6. RBAC (Role-Based Access Control)

나 혼자 쓰더라도, 나중을 생각하면 역할 기반 권한은 처음부터 설계해 두고 싶었다.

처음엔 "혼자 쓸 건데 권한이 무슨 소용?" 싶었는데, 막상 만들어 보니:

1. 보안 강화

  • Google OAuth로 로그인
  • 세션 기반 인증 (Auth.js)
  • CSRF 토큰 검증

2. 역할 분리 (미래를 위해)

enum Role {
  SUPER_ADMIN = "SUPER_ADMIN",  // 모든 권한
  ADMIN       = "ADMIN",         // 글 작성/수정/삭제
  GUEST       = "GUEST"          // 읽기만 가능
}

지금은 혼자 쓰지만, 나중에 누군가와 블로그를 공동 운영하거나, 게스트 포스트를 받을 때 유용할 것 같다.

3. 감사 로그

누가 언제 무엇을 했는지 기록:

{
  userId: "user_123",
  action: "POST_PUBLISH",
  resource: "/posts/DEV/my-post.mdx",
  timestamp: "2024-12-17T10:30:00Z"
}

실수로 글을 삭제했을 때 "누가, 언제, 왜" 삭제했는지 추적할 수 있다.


여기까지는 정말 잘 굴러갔다

어드민을 붙이고 한 2주 정도 쓰면서 진짜 만족스러웠다.

  • 침대에서 폰으로 글 초안 작성
  • 출퇴근길에 태블릿으로 글 다듬기
  • 카페에서 노트북으로 최종 발행

무료 티어로도 충분했고, **"이제 IDE 없이도 글을 쓸 수 있다"**는 목표도 달성했다.

Vercel Blob 무료 티어 제한:

  • ✅ 스토리지: 10GB (충분함, 글 텍스트는 KB 단위)
  • ✅ 다운로드: 100GB/월 (충분함, 트래픽 많지 않음)
  • ⚠️ Operations: 2,000건/월 (이게 문제였다...)

그리고 어느 날, 메일이 왔다

어드민을 만들고 한 달쯤 지났을 때, Vercel에서 메일이 하나 왔다.

vercel_limit_operation_email.png
vercel_limit_operation_email.png

Vercel Blob Free Tier usage exceeded...

You've used 2,347 / 2,000 operations this month.

Upgrade to continue using Blob Storage.

순간 머릿속에 떠오른 건 한 가지였다.

"아… list()가 벌써 2,000건이 넘었구나."

처음엔 믿기지 않았다. 업로드는 한 달에 고작 10~20번 정도인데, 어떻게 2,000건이 넘었을까?


list()가 그렇게 많이 나갔을까

Blob의 비용/쿼터는 업로드/다운로드만 생각하고 있으면 놓치기 쉽다.

실제로는 목록 조회(list()) 가 운영을 터뜨린다.

내가 놓친 것: UI 패턴과 API 호출의 상관관계

문제는 내가 붙인 기능 자체가 아니라 **"그 기능이 자연스럽게 만드는 호출 패턴"**이었다.

파일 목록 페이지

// 이 페이지를 열 때마다...
export default async function FilesPage() {
  const { blobs } = await list({ prefix: 'posts/' })  // ← list() 1회
  return <FileTable files={blobs} />
}
  • 페이지 열 때마다 → list() 1번
  • 새로고침하면 → list() 1번
  • 다른 탭 갔다가 돌아오면 → list() 1번 (브라우저가 refetch)

하루에 파일 목록을 10번만 봐도 10번 호출된다.

검색 기능

// 검색어를 바꿀 때마다...
const handleSearch = async (query: string) => {
  const { blobs } = await list({
    prefix: `posts/${query}`  // ← list() 1회
  })
  setFilteredFiles(blobs)
}
  • "next" 입력 → list() 1번
  • "nextjs" 수정 → list() 1번
  • 오타 고침 → list() 1번

검색어 한 번 제대로 입력할 때까지 3~4번 호출된다.

이미지 피커

// 이미지 피커를 열 때마다...
const openImagePicker = async () => {
  const { blobs } = await list({ prefix: 'images/' })  // ← list() 1회
  setImages(blobs)
}
  • 글 쓰다가 이미지 넣을 때 → list() 1번
  • 다른 이미지로 바꿀 때 → list() 1번
  • 창 닫았다가 다시 열 때 → list() 1번

글 하나 쓰면서 이미지 피커를 5번 정도 여닫는다.

페이지네이션

// 다음 페이지로 넘길 때마다...
const loadNextPage = async (cursor: string) => {
  const { blobs } = await list({
    prefix: 'posts/',
    cursor  // ← list() 1회
  })
  setFiles(prev => [...prev, ...blobs])
}
  • 1페이지 → list() 1번
  • 2페이지 → list() 1번
  • 3페이지 → list() 1번

파일이 100개면 페이지 5개 = 5번 호출.

개발 중에는 더 심하다

그리고 이건 "개발 중"에는 더 심해진다.

실제로 내가 어드민 UI를 다듬을 때:

10:00 - 파일 목록 페이지 디자인 수정
        → 저장, 새로고침 (list() ×10)

10:30 - 검색 기능 테스트
        → 여러 검색어 입력해보기 (list() ×20)

11:00 - 이미지 피커 레이아웃 수정
        → 열고 닫기 반복 (list() ×15)

11:30 - 페이지네이션 버그 수정
        → 다음/이전 버튼 클릭 테스트 (list() ×10)

12:00 - 전체 플로우 테스트
        → 처음부터 끝까지 사용해보기 (list() ×30)

점심시간 2시간 동안: list() 85번 호출

개발 중 하루에 200~300번 호출은 금방이었다.

계산해보니 답이 나왔다

하루 평균 list() 호출:
  - 파일 목록 페이지: 10회
  - 검색 기능: 15회
  - 이미지 피커: 10회
  - 페이지네이션: 5회
  - 기타 (개발/테스트): 20회
  ────────────────────
  총합: 60회/일

한 달 (30일): 60 × 30 = 1,800회

여기에 개발 중 테스트:
  - 주말 작업 (4일): 200회/일 = 800회
  ────────────────────
  1,800 + 800 = 2,600회/월

아, 그래서 2,000건을 넘긴 거구나.

결국 업로드는 가끔인데, list()는 매일, 매번 나간다.

깨달음

그래서 결론은 간단했다.

Blob은 파일의 Source of Truth로 두되, 목록 조회는 캐시가 필요하다.

파일의 실체는 Blob에 두지만, 목록 데이터는 어딜가 빠르게 조회할 수 있는 곳에 캐싱해야 한다.

그게 바로 CDC(Change Data Capture) 패턴이다.


해결: Blob CDC(Change Data Capture) 캐시

CDC를 검색하면서 처음엔 "뭐 이렇게 복잡한 걸..." 싶었다.

Kafka, Debezium, Event Sourcing... 온갖 어려운 용어들이 쏟아졌다.

근데 핵심 아이디어는 단순했다:

소스 데이터의 변경사항을 감지해서, 다른 곳에 복제한다.

내 경우엔:

  • 소스: Vercel Blob Storage (파일의 진실)
  • 복제본: Postgres (메타데이터 캐시)
  • 변경 감지: Hook + Periodic Sync

이미 RBAC를 만들면서 Postgres(Neon) + Prisma가 있었다.

"어차피 DB가 있으니, Blob 목록을 거기에 캐싱하면 되겠는데?"

처음엔 간단한 캐싱으로 시작했다가, 점점 제대로 된 CDC 파이프라인으로 발전했다.

CDC의 핵심 원칙

여기서 CDC를 떠올렸다.

1. Source of Truth는 Blob (파일 실체)

  • 모든 파일은 Blob에 저장된다
  • Blob을 삭제하면 파일이 사라진다
  • Blob이 진실이고, DB는 그림자다

2. Postgres는 Blob의 Mirror (메타데이터 캐시)

  • Blob의 메타데이터만 복제한다 (url, pathname, size, uploadedAt...)
  • 본문은 복제하지 않는다 (너무 크고, 필요 없음)
  • 목록 조회는 DB에서 한다 (빠르고 유연함)

3. 읽기는 DB에서, 쓰기는 Blob에서

  • 파일 업로드: Blob에 쓰고 → DB에 메타 기록
  • 파일 목록: DB에서 읽기 (list() 호출 X)
  • 파일 본문: Blob URL로 다운로드 (필요할 때만)

이렇게 하면 list() 호출을 대폭 줄일 수 있다.


아키텍처 (핵심만)

전체 구조를 그림으로 그려보면:

flowchart TB
  subgraph Admin["Blog-Admin (Next.js)"]
    A1["put()/del() → Blob"]
    A2["CDC Hook (즉시 DB 반영)"]
    A3["Periodic Sync (5분마다 체크)"]
    A4["RPC: GET /api/v1/blob-files"]
  end

  subgraph Blob["Vercel Blob Storage"]
    B1["posts/**/*.mdx"]
    B2["images/**/*.png"]
  end

  subgraph DB["Postgres (Neon)"]
    D1["BlobFile 캐시 테이블"]
    D2["(url, pathname, size, uploadedAt, isDeleted, syncedAt)"]
  end

  subgraph Blog["Blog (Next.js)"]
    C1["RPC로 캐시된 목록 fetch"]
    C2["Blob URL로 본문 다운로드"]
    C3["ISR (60초) + On-demand revalidate"]
  end

  Admin -->|"1. put/del"| Blob
  Admin -->|"2. Hook (즉시 DB 반영)"| DB
  Admin -->|"3. list() (5분마다 최대 1회)"| Blob
  Admin -->|"4. Sync 결과 DB에 반영"| DB
  Blog -->|"5. GET 캐시된 목록"| Admin
  Blog -->|"6. fetch(url) 본문"| Blob

핵심 3줄

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

    • RPC로 캐시된 목록만 받아온다
    • Blob URL로 본문만 다운로드한다
  2. Admin만 list()를 호출하되, 5분마다 최대 1회로 제한

    • needsSync() 함수로 마지막 sync 시간 체크
    • 5분 이내면 DB 캐시만 조회
  3. 목록은 DB에서, 본문은 Blob URL에서 가져온다

    • 목록 조회는 빠르게 (Postgres ~50ms)
    • 본문 다운로드는 필요할 때만 (ISR 시점)

구현 딥다이브

자, 이제 실제로 어떻게 구현했는지 하나씩 뜯어보자.

1) BlobFile 캐시 모델

Prisma 모델은 아주 정직하게 Blob 메타데이터를 담는다.

model BlobFile {
  id          String   @id @default(cuid())
  url         String   @unique        // Blob URL (고유키)
  pathname    String                  // 파일 경로 (posts/DEV/my-post.mdx)
  size        BigInt                  // 파일 크기 (bytes)
  uploadedAt  DateTime                // Blob 업로드 시각
  contentType String?                 // MIME type (text/markdown, image/png...)

  syncedAt    DateTime @default(now())      // 처음 DB에 동기화된 시각
  lastChecked DateTime @default(now())      // 마지막으로 존재 확인한 시각
  isDeleted   Boolean  @default(false)      // Soft delete 플래그

  @@index([pathname])      // 경로로 검색
  @@index([isDeleted])     // 활성 파일만 필터
  @@index([lastChecked])   // sync 체크용
}

왜 이런 필드들이 필요한가?

url: Blob의 고유 식별자

https://cdjji6662nxq4ixq.public.blob.vercel-storage.com/posts/DEV/my-post-abc123.mdx

URL이 같으면 같은 파일이다. @unique 제약으로 중복 방지.

pathname: 논리적 경로

posts/DEV/my-post.mdx

URL은 해시가 붙어서 지저분하지만, pathname은 깔끔하다. 검색/필터에 사용.

isDeleted: Soft delete 플래그

여기서 중요한 건 isDeleted다.

Blob에서 파일이 삭제되면 DB에서도 삭제해도 되지만, 나는 soft delete를 택했다.

왜냐하면:

1. 운영 중 "사라진 파일"을 추적할 수 있고

-- 최근 삭제된 파일 목록
SELECT pathname, lastChecked
FROM BlobFile
WHERE isDeleted = true
ORDER BY lastChecked DESC
LIMIT 10;

실수로 글을 지웠을 때, "언제 사라졌는지" 알 수 있다.

2. 실수로 지웠을 때 원인 파악이 쉬워지고

-- 특정 파일이 언제 삭제됐는지
SELECT *
FROM BlobFile
WHERE pathname LIKE '%my-important-post%';

-- 결과:
-- isDeleted: true
-- lastChecked: 2024-12-17 14:30:00
-- → "오후 2시 30분에 삭제됐구나"

3. sync 로직이 단순해진다

Hard delete하면:

// Blob에 없으면 DB에서 삭제?
// → DB에서 찾을 수 없어서 비교 불가능
// → 별도 로그 테이블 필요

// Soft delete하면:
// → 그냥 isDeleted = true 플래그만 세우면 됨
// → 나중에 복구도 가능 (Blob에 다시 업로드하고 isDeleted = false)

4. 복구 옵션

만약 Blob에 다시 같은 파일이 업로드되면:

await prisma.blobFile.update({
  where: { url },
  data: { isDeleted: false, lastChecked: new Date() }
})

"삭제됐던 파일이 부활했다"를 감지할 수 있다.

2) sync 알고리즘: list()는 딱 한 번만

CDC의 핵심은 "변경사항을 감지"하는 것이다.

Blob Storage는 binlog나 event stream을 제공하지 않으니, 폴링 방식으로 직접 감지한다.

전체 흐름

  1. list()로 Blob 전체 목록을 가져온다 (여기서만 1회!)
  2. DB의 isDeleted=false 목록과 diff를 낸다
  3. added / deleted / existing으로 나눈다
  4. DB에 반영한다 (추가/삭제/업데이트)

개념 코드

async function syncBlobToDatabase() {
  // 1. Blob 전체 목록 가져오기
  const { blobs } = await list()  // ← 여기서만 list() 1회!

  // 2. DB 현재 캐시 가져오기
  const dbFiles = await prisma.blobFile.findMany({
    where: { isDeleted: false }
  })

  // 3. Set으로 변환 (빠른 검색)
  const blobUrls = new Set(blobs.map(b => b.url))
  const dbUrls = new Set(dbFiles.map(f => f.url))

  // 4. 신규 파일: Blob에는 있지만 DB에는 없음
  const newFiles = blobs.filter(b => !dbUrls.has(b.url))

  // 5. 삭제된 파일: DB에는 있지만 Blob에는 없음
  const deletedUrls = [...dbUrls].filter(url => !blobUrls.has(url))

  // 6. 기존 파일: 둘 다 있음 (타임스탬프만 업데이트)
  const existingFiles = blobs.filter(b => dbUrls.has(b.url))

  // 7. DB에 반영
  await prisma.$transaction([
    // 신규 파일 추가
    ...newFiles.map(blob =>
      prisma.blobFile.create({
        data: {
          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,
    total: blobs.length
  }
}

왜 트랜잭션을 쓰는가?

await prisma.$transaction([...])

Sync 중간에 오류가 나면:

  • 트랜잭션 있으면: 전체 롤백, DB는 이전 상태 유지
  • 트랜잭션 없으면: 일부만 반영돼서 DB가 꼬임

예를 들어, 파일 100개 중 50개만 추가되고 에러 나면:

트랜잭션 O: 0 추가 (롤백)
트랜잭션 X: 50 추가, 50 누락 (불일치)

성능 최적화

파일이 수백 개가 되면 ...map()이 느려질 수 있다.

Prisma의 createMany를 쓰면 더 빠르다:

// Before: 100번의 개별 쿼리
...newFiles.map(blob => prisma.blobFile.create({ data: blob }))

// After: 1번의 배치 쿼리
prisma.blobFile.createMany({
  data: newFiles.map(blob => ({
    url: blob.url,
    pathname: blob.pathname,
    size: blob.size,
    uploadedAt: blob.uploadedAt,
    contentType: blob.contentType,
  }))
})

중요한 게 하나 더 있다

sync는 "스케줄러"가 아니라 "필요할 때만" 돈다.

처음엔 cron처럼 5분마다 자동으로 돌리려고 했다:

// ❌ 이렇게 하지 않음
setInterval(syncBlobToDatabase, 5 * 60 * 1000)

하지만 이렇게 하면:

  • 서버가 여러 대면 sync가 중복 실행된다
  • 아무도 어드민을 안 쓸 때도 계속 돈다
  • 불필요한 DB/Blob 부하

대신 "필요할 때만" 돌리는 게이트를 만들었다.

3) needsSync(): 호출량에 상한을 둔다

lastChecked를 보고 "마지막 sync 이후 5분이 지났는지"만 판단한다.

async function needsSync(): Promise<boolean> {
  // 1. 가장 최근에 체크한 파일 찾기
  const lastSync = await prisma.blobFile.findFirst({
    orderBy: { lastChecked: 'desc' },
    select: { lastChecked: true }
  })

  // 2. 한 번도 sync 안 했으면 true
  if (!lastSync) return true

  // 3. 5분 지났는지 체크
  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
  return lastSync.lastChecked < fiveMinutesAgo
}

사용 예시

// 파일 목록 페이지
export async function GET() {
  // 필요하면 sync (5분마다 최대 1회)
  if (await needsSync()) {
    await syncBlobToDatabase()
  }

  // DB에서 목록 조회 (빠름!)
  const files = await prisma.blobFile.findMany({
    where: { isDeleted: false },
    orderBy: { uploadedAt: 'desc' }
  })

  return Response.json({ files })
}

이 덕분에

Admin에서 파일 목록을 100번 새로고침해도, list()최대 5분에 1번만 탄다.

10:00 - 파일 목록 열기 → needsSync() = truesync 실행
10:01 - 새로고침 → needsSync() = false → DB만 조회
10:02 - 새로고침 → needsSync() = false → DB만 조회
10:03 - 새로고침 → needsSync() = false → DB만 조회
10:04 - 새로고침 → needsSync() = false → DB만 조회
10:05 - 새로고침 → needsSync() = truesync 실행

5분 동안 몇 번을 새로고침해도 sync는 1번만 실행된다.

4) 훅: 업로드/삭제 직후 캐시 즉시 반영

주기 sync만 있으면 "작성 직후 목록이 안 보이는" 순간이 생긴다.

10:00:00 - 글 업로드 (Blob에 저장됨)
10:00:01 - 파일 목록 열기 → 안 보임! (아직 sync 전)
10:05:00 - sync 실행 → 이제야 보임

5분이나 기다려야 한다니... 사용자 경험 최악이다.

그래서 put()/del() 성공 직후 DB 캐시를 바로 갱신하는 훅을 붙였다.

업로드 훅

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) {
    // 훅 실패해도 업로드는 성공
    console.error('CDC hook failed:', e)
  }
}

삭제 훅

async function onBlobDelete(url: string) {
  try {
    await prisma.blobFile.update({
      where: { url },
      data: {
        isDeleted: true,
        lastChecked: new Date()
      }
    })
  } catch (e) {
    console.error('CDC delete hook failed:', e)
  }
}

사용 예시

// Admin 업로드 코드
export async function POST(request: Request) {
  const formData = await request.formData()
  const file = formData.get('file') as File

  // 1. Blob에 업로드
  const blob = await put(pathname, file, { access: 'public' })

  // 2. DB 캐시 즉시 반영 (non-blocking)
  await onBlobUpload(blob).catch(console.error)

  // 3. 성공 응답
  return Response.json({ url: blob.url })
}

Non-blocking 설계가 중요한 이유

실패해도 업로드는 성공해야 한다.

만약 blocking으로 하면:

// ❌ 나쁜 예
const blob = await put(pathname, file, { access: 'public' })
await onBlobUpload(blob)  // DB 오류 나면 업로드도 실패?

DB가 잠깐 다운됐을 때:

  • Blob 업로드는 성공 (파일은 저장됨)
  • DB 훅이 실패 (에러 던짐)
  • 사용자한테 "업로드 실패" 에러

실제로는 업로드 성공했는데 실패했다고 표시된다. 최악의 UX다.

대신 non-blocking으로:

// ✅ 좋은 예
const blob = await put(pathname, file, { access: 'public' })
await onBlobUpload(blob).catch(console.error)  // 실패해도 OK
  • Blob 업로드 성공
  • DB 훅 실패 (로그만 찍음)
  • 사용자한테 "업로드 성공" 표시
  • 다음 periodic sync 때 DB에 반영됨

최악의 경우 5분 늦게 목록에 보이지만, 파일은 안전하게 저장된다.

5) Blog는 어떻게 캐시 목록을 가져오나: Hono RPC

Blog가 DB에 직접 붙으면 편하긴 하다.

// ✅ 간단함
const files = await prisma.blobFile.findMany(...)

하지만 운영/보안/배포를 생각하면 별로였다.

문제점 1: DB 크리덴셜 노출

Blog에 DB 연결 정보를 넣어야 한다:

# Blog 앱 환경변수
DATABASE_URL=postgresql://user:password@db.neon.tech/blog
DIRECT_URL=postgresql://user:password@db-direct.neon.tech/blog
  • Admin과 Blog가 같은 DB 계정 공유
  • Blog가 해킹당하면 Admin DB도 위험
  • 권한 분리 불가능

문제점 2: 마이그레이션 지옥

Prisma Client를 Blog에도 설치해야 한다:

// Blog package.json
{
  "dependencies": {
    "@prisma/client": "^5.0.0"
  },
  "scripts": {
    "postinstall": "prisma generate"
  }
}
  • Admin에서 스키마 변경하면 Blog도 재배포
  • 마이그레이션 순서 꼬이면 오류
  • DB 버전 관리 복잡도 증가

문제점 3: 책임 분리 위반

Blog의 책임:

  • 글 렌더링
  • SEO 최적화
  • 사용자 경험

DB 관리는 Admin의 책임인데, Blog가 직접 DB에 붙으면:

  • Blog가 DB 상태에 의존
  • Admin 독립적으로 배포 불가능
  • 모놀리스화

해결: Hono RPC

그래서 Admin이 "캐시 목록 API"를 제공하고, Blog는 그걸 읽는다.

Blog App
   ↓ HTTP Request (Hono RPC)
   ↓
Admin API (/api/v1/blob-files)
   ↓ Prisma Query
   ↓
Postgres (BlobFile 테이블)

Admin: RPC 엔드포인트

// apps/blog-admin/src/rpc/routes/blob-files.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()

app.get(
  '/',
  zValidator('query', z.object({
    limit: z.coerce.number().default(1000),
    offset: z.coerce.number().default(0),
    search: z.string().optional(),
  })),
  async (c) => {
    const { limit, offset, search } = c.req.valid('query')

    // 필요하면 sync
    if (await needsSync()) {
      await syncBlobToDatabase()
    }

    // DB에서 조회
    const files = await prisma.blobFile.findMany({
      where: {
        isDeleted: false,
        pathname: search ? { contains: search } : undefined
      },
      take: limit,
      skip: offset,
      orderBy: { uploadedAt: 'desc' }
    })

    const total = await prisma.blobFile.count({
      where: { isDeleted: false }
    })

    return c.json({
      files,
      total,
      hasMore: offset + files.length < total
    })
  }
)

export default app

Blog: RPC 클라이언트

Blog 쪽 사용은 이렇게 단순해진다:

// apps/blog/src/lib/blob.ts
import { client } from './rpc'

export async function getBlobFiles() {
  const response = await client.api.v1['blob-files'].$get({
    query: { limit: 1000 }
  })

  if (!response.ok) {
    throw new Error('Failed to fetch blob files')
  }

  const { files } = await response.json()
  return files
}

Hono RPC의 장점

1. 타입 안전성

서버에서 정의한 타입이 클라이언트에 자동으로 공유된다:

// Admin (서버)
return c.json({
  files: BlobFile[],
  total: number,
  hasMore: boolean
})

// Blog (클라이언트)
const { files, total, hasMore } = await response.json()
//      ^? BlobFile[]  ^? number  ^? boolean

TypeScript가 자동으로 타입 체크한다.

2. API 문서 자동 생성

Zod 스키마에서 OpenAPI 문서 자동 생성:

// apps/blog-admin/src/contract/schemas/blob-files.ts
export const BlobFilesQuerySchema = z.object({
  limit: z.coerce.number()
    .min(1)
    .max(1000)
    .default(100)
    .describe('Maximum number of files to return'),
  offset: z.coerce.number()
    .min(0)
    .default(0)
    .describe('Number of files to skip'),
  search: z.string()
    .optional()
    .describe('Search term to filter by pathname')
})

이게 자동으로 OpenAPI 스펙이 된다.

3. 권한 분리

Blog는 "읽기 전용" 엔드포인트만 호출:

// Public endpoint (Blog용)
app.get('/', ...)  // 목록 조회만 가능

// Admin endpoint (인증 필요)
app.post('/admin/sync', requireAuth, ...)  // 수동 sync
app.delete('/admin/:id', requireAuth, ...)  // 파일 삭제

Blog가 해킹당해도 DB를 직접 수정할 수 없다.

6) 본문은 캐시하지 않는다: ISR이 대신한다

Blog는 목록만 DB에서 받고, 실제 본문은 Blob URL에서 다운로드한다.

// apps/blog/src/app/page.tsx
export default async function HomePage() {
  // 1. Admin에서 캐시된 목록 가져오기
  const blobFiles = await getBlobFiles()
  //    ^? { url, pathname, size, ... }[]

  // 2. 실제 콘텐츠는 Blob URL에서 다운로드
  const posts = await getAllPosts(blobFiles)
  //    ^? { title, date, html, ... }[]

  return <PostList posts={posts} />
}

export const revalidate = 60 // ISR 60초

왜 본문을 DB에 캐시하지 않나?

여기서 "본문까지 DB에 캐시해야 하나?" 고민이 생겼다.

// 이렇게 할 수도 있음
model BlobFile {
  url      String
  pathname String
  content  String  @db.Text  // ← 본문 저장?
}

하지만 안 했다. 이유:

1. ISR이 이미 캐시 역할을 한다

Next.js의 ISR(Incremental Static Regeneration):

  • 페이지는 빌드 시점에 정적 생성
  • revalidate 시간만큼 캐시됨 (60초)
  • 캐시 만료 후 재생성
export const revalidate = 60

// 첫 요청: 페이지 생성 (Blob 다운로드)
// 이후 60초: 캐시된 페이지 서빙 (다운로드 X)
// 60초 후: 백그라운드 재생성 (다운로드 1회)

즉, 본문 다운로드는 **"요청마다"가 아니라 "60초마다 최대 1회"**만 일어난다.

2. DB 용량 절약

MDX 본문은 수십 KB~수백 KB:

평균  하나: 50KB
 100개: 5MB
 1000개: 50MB

Postgres 무료 티어(Neon):

  • 스토리지: 3GB

본문까지 넣으면 금방 찬다. 메타데이터만 캐시하면:

파일 하나: ~500 bytes (url, pathname, size...)
100개: 50KB
1000개: 500KB

훨씬 여유롭다.

3. Blob이 더 빠를 수 있다

Blob은 CDN으로 서빙된다:

Vercel Blob → CloudFront CDN → 전 세계 엣지 캐시

지역별 엣지에서 서빙되니, 경우에 따라 DB보다 빠를 수 있다.

4. DB 부하 감소

본문을 DB에서 매번 읽으면:

SELECT content FROM BlobFile WHERE pathname = 'posts/DEV/my-post.mdx'
-- content 컬럼이 50KB면, 이 쿼리가 50KB 데이터 전송

글 많이 읽히는 날엔 DB 부하 증가.

Blob에서 읽으면 DB는 메타데이터만 서빙:

SELECT url FROM BlobFile WHERE pathname = 'posts/DEV/my-post.mdx'
-- url만 반환 (~200 bytes)

그다음엔 Blob URL로 직접 다운로드 (DB 거치지 않음).

실제 흐름

// packages/content/src/posts-blob.ts
export async function getPostBySlug(
  blobFiles: BlobFileInfo[],
  slug: string
) {
  // 1. 캐시된 목록에서 파일 찾기 (빠름!)
  const file = blobFiles.find(f =>
    f.pathname.includes(`posts/${slug}`)
  )

  if (!file) return null

  // 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,
    date: data.date,
    tags: data.tags,
    html,
  }
}

이 함수는 ISR 시점에만 호출된다 (60초마다 최대 1회).

평소엔 캐시된 HTML만 서빙하니, 다운로드 부하가 거의 없다.

7) 마지막 퍼즐: 온디맨드 revalidate

Admin에서 글을 수정하면 **"바로 반영"**되길 원했다.

ISR만 있으면:

10:00 - 글 수정 (Admin)
10:00 - Blog 방문 → 옛날 버전 (캐시 히트)
10:01 - Blog 방문 → 옛날 버전
...
11:00 - 캐시 만료 (60분 후) → 새 버전

60분을 기다려야 한다니...

그래서 Next.js의 On-demand Revalidation을 사용한다.

Blog: Revalidate API

// apps/blog/src/app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret')
  const path = request.nextUrl.searchParams.get('path')

  // 1. Secret 검증 (무단 호출 방지)
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 })
  }

  if (!path) {
    return Response.json({ error: 'Missing path' }, { status: 400 })
  }

  try {
    // 2. ISR 캐시 무효화
    revalidatePath(path)
    return Response.json({ revalidated: true, path })
  } catch (err) {
    return Response.json(
      { error: 'Revalidation failed' },
      { status: 500 }
    )
  }
}

Admin: Revalidate 호출

// apps/blog-admin/src/shared/lib/revalidate-blog.ts
export async function revalidateBlogPath(path: string) {
  const blogUrl = process.env.NEXT_PUBLIC_BLOG_URL
  const secret = process.env.REVALIDATION_SECRET

  const response = await fetch(
    `${blogUrl}/api/revalidate?secret=${secret}&path=${path}`,
    { method: 'POST' }
  )

  if (!response.ok) {
    throw new Error('Revalidation failed')
  }

  return response.json()
}

사용 예시

// Admin에서 글 저장 후
export async function POST(request: Request) {
  const formData = await request.formData()
  const file = formData.get('file') as File
  const slug = formData.get('slug') as string

  // 1. Blob에 업로드
  const blob = await put(pathname, file, { access: 'public' })

  // 2. DB 캐시 갱신
  await onBlobUpload(blob)

  // 3. Blog ISR 캐시 무효화
  await Promise.all([
    revalidateBlogPath(`/blog/${slug}`),  // 글 상세
    revalidateBlogPath('/'),              // 홈 목록
    revalidateBlogPath('/blog'),          // 블로그 목록
  ])

  return Response.json({ success: true })
}

전체 흐름

1. Admin에서 put()으로 Blob 업데이트
   ↓
2. CDC 훅으로 DB 캐시 즉시 갱신
   ↓
3. Blog /api/revalidate 호출 → ISR 캐시 무효화
   ↓
4. 다음 요청 시 최신 내용으로 ISR 재생성
   - getBlobFiles() → Admin RPC로 캐시된 목록
   - getPostBySlug() → Blob URL에서 본문 다운로드
   - processMarkdown() → HTML 생성
   ↓
5. 60초간 캐시됨 (다음 업데이트까지)

이제 글 수정 후 즉시 반영된다!


결과: 비용 99% 절감

CDC를 붙이고 나서, 정말로 list() 호출이 줄었을까?

Before CDC

하루 평균:
  - 파일 목록 페이지: 10회
  - 검색 기능: 15회
  - 이미지 피커: 10회
  - 페이지네이션: 5회
  - 개발/테스트: 20회
  ────────────────────
  총 60회/일

한 달 (30일): 1,800회
개발 주말 (4일): 800회
────────────────────
총합: 2,600회/월 → 무료 티어 초과!

→ 월 2,000+ ops 초과

After CDC

하루 평균:
  - 파일 목록: 0회 (DB 조회)
  - 검색: 0회 (DB 쿼리)
  - 이미지 피커: 0회 (DB 조회)
  - Periodic Sync: 24h ÷ 5min = 288회/일... 아니 잠깐!

어? 288회/이면 한 달에 8,640회 아닌가?

아니다. 여기서 착각하기 쉬운 부분:

needsSync()는 **"5분마다 체크"**가 아니라 **"5분 이내면 스킵"**이다.

실제론:

10:00 - 파일 목록 열기 → needsSync() = true → sync (list() 1회)
10:01 - 새로고침 → needsSync() = falseskip
10:02 - 검색 → needsSync() = falseskip
10:03 - 이미지 피커 → needsSync() = falseskip
10:04 - 새로고침 → needsSync() = falseskip
10:05 - 파일 목록 → needsSync() = true → sync (list() 1회)

5분 동안 sync는 최대 1번이다.

하루 24시간:

24시간 = 1,4401,440분 ÷ 5분 = 288번

하지만 이건 "24시간 내내 어드민을 쓸 때" 얘기.

현실적으로:

  • 하루 어드민 사용 시간: 2~3시간 (글 쓰기, 관리)
  • 3시간 = 180분
  • 180분 ÷ 5분 = 36번/일

한 달 (30일):

36/일 × 30일 = 1,080회/

어? 그래도 2,000건 넘잖아?

아니다. 또 착각:

needsSync()"어드민을 쓸 때만" 호출된다.

어드민 안 쓰는 날엔 sync가 0번이다.

실제 사용 패턴:

월요일: 글 작성 2시간 → 36회
화요일: 안 씀 → 0회
수요일: 이미지 정리 1시간 → 12회
목요일: 안 씀 → 0회
금요일: 글 수정 1시간 → 12회
주말: 안 씀 → 0회

주당: 60회
월 (4주): 240회

→ 월 240300 ops (무료 티어의 1215%)

실제 측정 결과

Vercel 대시보드에서 확인:

blob_storage_observability.png
blob_storage_observability.png

보시다시피:

  • Operations: 287 / 2,000 (14% 사용)
  • Store Size: 87MB (10GB 대비 1% 미만)
  • Data Transfer: 1.92GB (100GB/월 대비 2%)
  • Downloads: 293K

그래프를 보면 Operations(보라색 선) 가 CDC 도입 전 (1d ago 쪽)에는 150회 정도 피크를 치다가, 도입 후 (2m ago 쪽)에는 거의 0에 가깝게 떨어진 걸 볼 수 있다.

목표 달성!

추가로 좋아진 것들

1. 응답 속도 10배 개선

Before (Blob list()):
  - 평균: 500ms
  - 최대: 1,200ms (네트워크 느릴 때)

After (Postgres 쿼리):
  - 평균: 50ms
  - 최대: 150ms

→ 10배 빠름!

2. 검색/필터 확장

이제 DB 인덱스로 빠른 검색:

-- 태그로 검색
SELECT * FROM BlobFile
WHERE pathname LIKE '%react%'
AND isDeleted = false;

-- 크기로 필터
SELECT * FROM BlobFile
WHERE size > 100000  -- 100KB 이상
AND isDeleted = false;

-- 최근 업로드
SELECT * FROM BlobFile
ORDER BY uploadedAt DESC
LIMIT 10;

Blob list()로는 불가능했던 쿼리들이다.

3. 통계 집계

-- 전체 글 수
SELECT COUNT(*) FROM BlobFile
WHERE pathname LIKE 'posts/%'
AND isDeleted = false;

-- 카테고리별 분포
SELECT
  SPLIT_PART(pathname, '/', 2) as category,
  COUNT(*) as count
FROM BlobFile
WHERE pathname LIKE 'posts/%'
GROUP BY category;

-- 총 용량
SELECT SUM(size) / 1024 / 1024 as total_mb
FROM BlobFile
WHERE isDeleted = false;

대시보드에 통계 보여주기 쉬워졌다.


트레이드오프(운영 관점)

이 구조를 붙이면서 받아들인 트레이드오프도 있다.

1) Eventually Consistent

CDC는 "목록 캐시"다.

훅이 실패하거나 sync가 늦어지면 잠깐 stale할 수 있다.

시나리오: Hook 실패

10:00 - 글 업로드 (Blob에 저장 성공)
        ↓
        onBlobUpload() 실패 (DB 일시 장애)
        ↓
10:01 - 파일 목록 열기 → 새 글 안 보임!10:05 - Periodic sync 실행 → DB에 반영10:06 - 이제 보임

5분 동안 목록이 stale하다.

하지만 핵심은

  • 파일 자체는 Blob에 존재한다
  • URL을 알면 직접 접근 가능
  • 목록이 stale해도 "글이 사라지진 않는다"

현실적으로:

  • Hook은 거의 실패하지 않는다 (DB 장애 시에만)
  • 5분 간격 sync로 드리프트가 계속 보정된다
  • 최악의 경우 5분 늦게 보이는 정도

개인 블로그 수준에서는 충분히 감수할 만하다.

만약 정말 중요하면:

  • Sync 간격을 1분으로 줄이기
  • Webhook으로 실시간 반영 (Vercel Blob은 미지원)
  • 별도 메시지 큐 도입 (Redis, SQS...)

하지만 지금은 과도한 최적화다.

2) list() 호출량은 "완전 0"이 아니라 "상한을 둔다"

Blog에서 list()는 0이지만, Admin은 그래도 필요할 때 list()를 호출한다.

왜 완전 0으로 안 하나?

이론적으로 가능:

  • Webhook으로 Blob 변경 감지
  • Event stream으로 실시간 반영
  • list() 완전히 제거

하지만:

  • Vercel Blob은 Webhook 미지원
  • 직접 구현하려면 복잡도 급증
  • 무료 티어 충분히 확보됨 (14% 사용)

실용적 선택:

  • list() 호출을 "줄이는" 것으로 충분
  • 완벽한 0보다 실용적인 상한이 낫다

다만 "요청마다"가 아니라, 게이트(needsSync) + 훅(즉시 반영) 으로 충분히 눌렀다.

3) DB 의존성 추가

파일시스템 → Blob만 있으면 되던 구조에, 이제 Postgres가 필수가 됐다.

새로운 실패 지점

Before:
  Blob 다운 → Blog 다운 (단일 실패 지점)

After:
  Blob 다운 → Blog 다운
  Postgres 다운 → Admin 다운 (새 실패 지점 추가)

하지만

  • 어차피 RBAC용으로 이미 Postgres 쓰고 있었다
  • BlobFile 테이블 하나 추가한 것뿐
  • DB 장애 시에도 Blob 직접 접근은 가능 (fallback 구현 가능)

Fallback 구현 예시

export async function getBlobFiles() {
  try {
    // 1차: Admin RPC로 캐시된 목록
    const response = await client.api.v1['blob-files'].$get({})
    if (response.ok) {
      const { files } = await response.json()
      return files
    }
  } catch (e) {
    console.error('RPC failed, falling back to Blob list()', e)
  }

  // 2차: Blob list() 직접 호출 (느리지만 동작함)
  const { blobs } = await list({ prefix: 'posts/' })
  return blobs
}

DB가 다운돼도 Blog는 계속 돈다 (느리지만).


마무리

처음 목표는 단순했다.

"IDE 없이도 글을 쓰고 싶다."

근데 그 목표를 이루려면 "스토리지/목록/캐시/무효화"가 한 덩어리로 따라온다.

Vercel Blob 무료 티어 초과 메일은 그걸 강제로 깨닫게 만든 트리거였다.

한 줄 요약

파일은 Blob(원본), 목록은 Postgres(CDC 캐시), 페이지는 ISR(렌더 캐시) + 온디맨드 무효화

각 레이어의 역할

Blob: Source of Truth

  • 모든 파일의 실체
  • 변경 불가능한 URL
  • CDN으로 빠른 다운로드

Postgres: 목록 캐시

  • 메타데이터만 저장
  • 빠른 검색/필터/정렬
  • 통계 집계 가능

ISR: 렌더 캐시

  • 정적 페이지 캐싱
  • 60초마다 재생성
  • 온디맨드 무효화 지원

CDC: 동기화 파이프라인

  • Hook으로 즉시 반영
  • Periodic sync로 보정
  • Eventually consistent

돌이켜보면

아마 이걸 처음부터 **"완벽한 CMS를 설계하겠습니다"**라고 시작했으면, 복잡도에 질려서 손도 못 댔을 것 같다.

오히려 "필요한 만큼만 한 단계씩" 늘려가다 보니:

  1. 파일시스템 → Blob (IDE 없이 글쓰기)
  2. Blob → Admin (관리 UI)
  3. Admin → RBAC (권한 관리)
  4. Blob → CDC (비용 절감)
  5. CDC → ISR (성능 최적화)

결과적으로는 나름 밸런스 있는 구조가 된 느낌이다.

다음 단계는?

언제 또 프리 티어 알림 메일이 날아올지 모르지만, 적어도 이번에는:

  • 왜 초과됐는지 (list() 호출 패턴)
  • 어떻게 줄일 수 있는지 (CDC 캐시)
  • 이걸 계기로 뭘 더 개선할 수 있는지 (통계, 검색, 속도...)

아키텍처 관점에서 좀 더 선명하게 말할 수 있게 된 것 같다.

다음엔 아마:

  • Full-text Search: Postgres tsvector로 한글 검색
  • Version History: BlobFile에 버전 추적 추가
  • Asset CDN: 이미지를 Blob → CDN으로 프록시
  • Real-time Sync: Webhook 직접 구현?

하지만 지금은 이 정도면 충분하다.

과도한 최적화보다는 "필요할 때 필요한 만큼" 개선하는 게 낫다.

그게 이번 여정에서 배운 가장 큰 교훈이다.