← Lecture Note
M9실습45분 · ~12 슬라이드

Vercel Blob 영속 저장소

DB 없이 KV 패턴으로 버틴다

학습 목표

Blob 을 KV 처럼 쓰는 wrapper 와 prefix 격리, 대용량 direct upload 를 익힌다.

지금까지 만든 AI Agent는 요청이 끝나면 데이터가 사라집니다. 사용자 파일, STT 원본 오디오, 프로젝트 메타데이터를 영속적으로 저장하려면 Vercel Blob이 필요합니다. 이 모듈에서는 Blob을 KV처럼 추상화하는 wrapper를 직접 만들고, 대용량 파일을 서버를 우회해 안전하게 올리는 방법까지 익힙니다.


@vercel/blob 설치 + Storage Dashboard

pnpm add @vercel/blob

Vercel Dashboard → Storage → Create Database → Blob을 생성하면 BLOB_READ_WRITE_TOKEN 환경변수가 자동으로 프로젝트에 연결됩니다. .env.local에도 동일한 값을 복사해 넣어야 로컬에서 동작합니다.

: vercel env pull .env.local 한 줄로 모든 환경변수를 로컬에 내려받을 수 있습니다. 매번 복사할 필요가 없습니다.

Blob은 S3 호환 객체 스토리지입니다. 키는 경로처럼 생긴 문자열(projects/abc/meta.json)이고 값은 바이너리 또는 텍스트입니다. KV 데이터베이스가 아니기 때문에, JSON을 직렬화하고 역직렬화하는 얇은 wrapper가 필요합니다.


KV Wrapper 구현

아래 네 함수(kvPutText, kvGetJson, kvList, kvDelete)가 이 강의 전체에서 사용하는 표준 인터페이스입니다.

// lib/blob-kv.ts
import { put, get, list, del } from "@vercel/blob";

const TOKEN = process.env.BLOB_READ_WRITE_TOKEN!;

/** JSON 객체를 Blob에 저장 */
export async function kvPutText(key: string, value: unknown): Promise<string> {
  const body = JSON.stringify(value);
  const { url } = await put(key, body, {
    access: "public",
    token: TOKEN,
    contentType: "application/json",
    addRandomSuffix: false, // 키를 덮어써야 하므로 반드시 false
  });
  return url;
}

/** Blob에서 JSON 역직렬화 */
export async function kvGetJson<T = unknown>(key: string): Promise<T | null> {
  try {
    const res = await get(key, { token: TOKEN });
    if (!res) return null;
    const text = await res.text();
    return JSON.parse(text) as T;
  } catch {
    return null;
  }
}

/** prefix로 시작하는 키 목록 */
export async function kvList(prefix: string) {
  const { blobs } = await list({ prefix, token: TOKEN });
  return blobs;
}

/** 키 삭제 */
export async function kvDelete(url: string) {
  await del(url, { token: TOKEN });
}

주의: addRandomSuffix: false를 빠뜨리면 같은 키에 업데이트할 때마다 새 URL이 생겨 데이터가 중복 적재됩니다. 반드시 명시하세요.


Prefix 격리 전략

멀티테넌트 서비스에서는 데이터를 테넌트별로 격리해야 합니다. 이 강의는 경로 기반 prefix로 격리합니다.

용도Blob 키 예시
프로젝트 메타데이터projects/<projectId>/meta.json
STT 원본 오디오projects/<projectId>/audio/<uploadId>.webm
RAG 청크 인덱스projects/<projectId>/rag/chunks.json
보고서projects/<projectId>/reports/<date>.md

kvList("projects/abc123/") 한 번으로 특정 프로젝트의 모든 파일을 열거할 수 있습니다. 서로 다른 프로젝트의 데이터는 prefix가 달라 자연스럽게 분리됩니다.


Direct Client Upload (1 GB 서버 우회)

Next.js API Route는 기본 요청 바디 한도가 4 MB입니다. 대용량 오디오나 PDF를 올릴 때 서버를 경유하면 이 한도에 걸립니다. Vercel Blob의 client upload 패턴은 서버가 토큰만 발급하고 클라이언트가 Blob에 직접 업로드합니다.

// app/api/upload-token/route.ts  ← 토큰 발급 엔드포인트
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const body = (await request.json()) as HandleUploadBody;

  try {
    const jsonResponse = await handleUpload({
      body,
      request,
      onBeforeGenerateToken: async (pathname) => ({
        allowedContentTypes: ["audio/webm", "audio/mp4", "application/pdf"],
        tokenPayload: JSON.stringify({ pathname }),
        maximumSizeInBytes: 1_000_000_000, // 1 GB
      }),
      onUploadCompleted: async ({ blob, tokenPayload }) => {
        // 업로드 완료 후 메타데이터를 KV에 기록
        const { pathname } = JSON.parse(tokenPayload ?? "{}");
        const metaKey = pathname.replace(/\.[^.]+$/, "_meta.json");
        await kvPutText(metaKey, { url: blob.url, uploadedAt: new Date().toISOString() });
      },
    });
    return NextResponse.json(jsonResponse);
  } catch (error) {
    return NextResponse.json({ error: (error as Error).message }, { status: 400 });
  }
}

클라이언트에서는 @vercel/blob/clientupload() 함수를 호출합니다. 파일이 서버 메모리를 전혀 거치지 않고 Blob에 직접 전송됩니다.


자동 삭제: STT 완료 후 원본 오디오 제거

원본 오디오는 STT 처리 직후 삭제하는 것이 좋은 관행입니다. 비용 절감과 개인정보 최소화를 동시에 달성합니다.

// STT 처리 후 호출
async function transcribeAndClean(audioUrl: string): Promise<string> {
  // ElevenLabs Scribe v2 STT 호출 (강의 M8 참고)
  const transcript = await callScribeV2(audioUrl);

  // 원본 오디오 즉시 삭제
  await kvDelete(audioUrl);

  return transcript;
}

kvDelete는 Blob URL을 직접 받으므로, 업로드 완료 콜백에서 받은 blob.url을 그대로 넘기면 됩니다.


실습 (Lab)

목표: PDF 또는 오디오 파일을 direct upload로 올리고, 메타데이터를 KV에 영구 저장한 뒤 목록을 조회합니다.

단계별 안내

  1. 환경 준비

    • vercel env pull .env.localBLOB_READ_WRITE_TOKEN 확인
    • pnpm add @vercel/blob 설치
  2. KV wrapper 작성

    • lib/blob-kv.ts에 위의 kvPutText, kvGetJson, kvList, kvDelete 복사
  3. 업로드 토큰 API 작성

    • app/api/upload-token/route.ts 생성
    • onUploadCompleted에서 projects/<projectId>/audio/<filename>_meta.json 키로 메타데이터 저장
  4. 클라이언트 업로드 UI 작성

    • <input type="file" accept="audio/*,application/pdf" />
    • @vercel/blob/clientupload(pathname, file, { handleUploadUrl: "/api/upload-token" }) 호출
  5. 목록 조회 API 작성

    • GET /api/files?projectId=xxxkvList("projects/xxx/") 결과 반환
  6. 자동 삭제 검증

    • 업로드된 오디오 URL로 transcribeAndClean() 호출 후 Vercel Dashboard에서 파일이 사라졌는지 확인

체크포인트: 업로드 완료 후 kvList가 메타데이터 JSON을 반환하면 성공입니다. Blob 원본 오디오는 삭제되고 메타데이터만 남아 있어야 합니다.


핵심 정리

  • @vercel/blob은 S3 호환 객체 스토리지이며, JSON 직렬화 wrapper(kvPutText/kvGetJson/kvList/kvDelete)로 KV처럼 사용한다.
  • addRandomSuffix: false 옵션을 반드시 설정해야 같은 키 덮어쓰기(upsert)가 정상 동작한다.
  • Prefix 경로(projects/<id>/...)로 테넌트 또는 프로젝트 단위의 데이터 격리를 구현한다.
  • Direct client upload는 서버를 우회하므로 4 MB 한도 없이 최대 1 GB까지 업로드 가능하다.
  • STT 완료 직후 원본 오디오를 kvDelete로 삭제하면 비용 절감과 개인정보 최소화를 동시에 달성한다.
  • 모든 Blob 작업은 BLOB_READ_WRITE_TOKEN 하나로 인증되며, 이 토큰은 절대 클라이언트 코드에 노출해선 안 된다.
LAB · 실습

파일 업로드 + 메타데이터 영구 저장