Vercel Blob의 consistent reads: 최신값·충돌 방지·캐시는 서로 다른 문제다
AI 에이전트가 방금 갱신한 memory.json을 다시 읽었는데 이전 상태로 행동한다면 모델의 추론부터 의심하기 쉽다. 그러나 원인은 저장소 앞 CDN일 수 있다. Vercel은 7월 14일 private Blob의 get()과 presignUrl()에 useCache: false를 추가해, 같은 pathname을 덮어쓴 직후에도 최신 쓰기를 읽는 경로를 공식화했다. 중요한 점은 ‘consistent’라는 이름이 모든 일관성 문제를 해결한다는 뜻이 아니라는 것이다. 최신성, 동시 쓰기, 접근 제어, 브라우저 캐시는 여전히 서로 다른 계약이다.
무엇이 바뀌었나: CDN을 우회하는 최신 읽기 경로
공식 변경 로그에 따르면 새로운 pathname에 처음 쓴 Blob은 기존 캐시 항목이 없으므로 기본 읽기도 최신 쓰기를 즉시 반영한다. 문제가 생기는 지점은 allowOverwrite: true로 같은 pathname을 덮어쓸 때다. 기본 읽기는 Vercel CDN을 통과하며 이전 버전을 최대 60초 동안 반환할 수 있다.
최신값이 반드시 필요하면 private storage의 get(pathname, { access: 'private', useCache: false })를 사용한다. presignUrl()도 같은 옵션을 지원하며, SDK 바깥에서는 private Blob URL에 cache=0 쿼리를 붙이는 방식으로 동작한다. 이 기능을 사용하려면 공식 변경 로그 기준 @vercel/blob@2.6.1 이상이 필요하다.
대가는 명확하다. CDN을 우회하므로 cached read보다 느리고 Fast Origin Transfer가 발생한다. 따라서 모든 읽기에 무조건 끄는 스위치가 아니라, 방금 쓴 상태가 다음 판단의 입력이 되는 경로에 선택적으로 적용해야 한다.
왜 중요한가: Blob이 파일 저장소에서 상태 저장소처럼 쓰이고 있다
이미지나 배포 artifact처럼 한번 쓰고 pathname을 바꾸지 않는 객체는 CDN 캐시와 잘 맞는다. 반면 agent memory, session transcript, latest report, workflow checkpoint, mutable JSON manifest는 같은 이름을 반복해서 덮어쓰며 다음 단계가 즉시 그 결과를 읽는다. 이 순간 Blob은 단순 미디어 저장소가 아니라 작은 상태 저장소처럼 동작한다.
stale read는 조용히 실패한다. HTTP 500도 없고 JSON 파싱도 성공하지만, 에이전트가 이미 완료한 작업을 반복하거나 사용자가 방금 저장한 설정이 되돌아온 것처럼 보일 수 있다. 운영 지표가 ‘요청 성공률’만 보면 정상이고, 상태 version이나 ETag를 보지 않으면 원인을 모델·UI·DB로 잘못 돌리기 쉽다.
따라서 저장 API 선택보다 먼저 객체를 immutable asset, mutable state, derived snapshot으로 분류해야 한다. immutable asset은 versioned pathname과 긴 캐시가 적합하고, mutable state는 최신 읽기·충돌 제어·복구 정책이 필요하다. derived snapshot은 오래된 값이 허용되는 시간 예산을 명시할 수 있다.
첫 번째 계약: freshness는 useCache: false로 좁게 보장한다
쓰기 직후 검증, 에이전트 다음 step, 사용자의 저장 완료 화면처럼 read-after-write가 업무 정합성에 직접 영향을 주는 지점만 origin read로 만든다. 목록 화면, 과거 transcript 열람, 변경이 드문 보고서처럼 수십 초의 지연이 허용되는 읽기는 기본 CDN 경로를 유지한다.
API 이름을 그대로 여러 곳에 흩뿌리기보다 readLatestState()와 readCachedSnapshot() 같은 도메인 함수를 둔다. 호출자가 저장소 옵션을 추측하지 않고 ‘최신성이 필요한가’를 코드에서 드러내게 한다. 로그에는 pathname 전체나 민감한 내용을 남기지 말고 version, ETag, readMode, writeAt, observedAt을 구조화해 stale read를 탐지한다.
또한 public Blob과 혼동하면 안 된다. 이번 보장은 private storage의 SDK 읽기 경로에 관한 것이다. public Blob을 브라우저가 직접 읽으면 CDN뿐 아니라 브라우저 캐시도 관여한다. 공식 문서는 public asset을 덮어쓰기보다 새 pathname과 versioned URL을 쓰는 방식을 권장한다.
두 번째 계약: consistent read는 lost update를 막지 않는다
두 worker가 같은 memory.json을 읽고 각각 수정한 뒤 순서대로 덮어쓰면, useCache: false는 마지막 결과를 빨리 보여줄 뿐 먼저 저장한 변경을 복구하지 않는다. 최신성은 읽기의 문제이고, lost update는 쓰기 경쟁의 문제다.
Vercel Blob은 ETag 기반 conditional write를 제공한다. head()나 get()에서 본 ETag를 put(..., { allowOverwrite: true, ifMatch: etag })에 전달하면, 그 사이 다른 writer가 수정했을 때 BlobPreconditionFailedError가 발생한다. 애플리케이션은 재읽기 후 병합하거나, 작업을 재시도하거나, 사용자에게 충돌을 보여줘야 한다.
여러 에이전트가 한 transcript나 checkpoint를 수정한다면 append-only event log, writer별 파일, versioned snapshot + pointer처럼 쓰기 소유권을 나누는 구조도 검토해야 한다. 하나의 mutable JSON에 모든 상태를 몰아넣는 편의가 충돌·복구·감사의 비용으로 돌아올 수 있다.
세 번째 계약: origin freshness와 사용자 캐시는 별개다
private Blob은 보통 Function route가 사용자를 인증한 뒤 get()으로 가져와 stream한다. Blob origin에서 최신값을 얻어도 route response에 잘못된 Cache-Control을 붙이면 브라우저가 이전 응답을 계속 보여줄 수 있다. 저장소 내부 freshness와 사용자에게 전달되는 freshness는 두 단계다.
Vercel 문서는 private content에 Cache-Control: private, no-cache를 권장한다. 브라우저가 로컬에 보관하되 매번 재검증하고, ETag와 If-None-Match를 전달하면 변경이 없을 때 304로 비용을 줄일 수 있다. token·금융·PII처럼 디스크 저장 자체를 피해야 하는 값은 private, no-store가 적합하다.
인증도 캐시 정책과 붙여 생각해야 한다. 문서는 private Blob 전달 route에서 middleware에만 의존하지 말고 get() 호출 바로 옆에서 권한을 확인하라고 권고한다. useCache: false는 권한 검사를 추가하지 않으며, presigned URL의 scope와 만료 정책도 별도로 검토해야 한다.
커뮤니티 신호: 사람들이 원한 것은 고정 URL이지만 얻은 것은 mutable state였다
Vercel Community의 Blob SDK 피드백에서는 random suffix를 끄면 DB에 전체 URL을 저장하지 않고 pathname을 재구성할 수 있다는 편의와, 같은 URL을 덮어쓸 때 edge·browser cache 때문에 이전 avatar가 보인다는 문제가 함께 논의됐다. 이 글은 현재 기능의 사실 근거가 아니라, 개발자가 왜 overwrite를 선택하고 어디서 혼동하는지 보여주는 audience signal이다.
핵심 오해는 ‘같은 URL이 편하다’와 ‘같은 URL이 최신 상태를 즉시 가리킨다’를 같은 요구로 보는 것이다. 사람에게 공유할 asset URL은 immutable하게 versioning하는 편이 단순하다. 반대로 시스템 내부의 latest pointer는 고정 pathname이 유용할 수 있지만, 그 순간 freshness budget과 origin cost, concurrency control을 명시해야 한다.
좋은 설계 질문은 ‘캐시를 켤까 끌까’가 아니다. 이 객체가 콘텐츠인가 상태인가, stale 허용 시간은 얼마인가, writer가 몇 개인가, 충돌 시 무엇이 source of truth인가를 먼저 답하는 것이다.
오늘 적용할 실무 체크리스트
1. private Blob pathname을 immutable asset, mutable state, derived snapshot으로 분류한다.
2. 같은 pathname을 allowOverwrite: true로 쓰는 모든 코드를 검색하고 stale 허용 시간을 기록한다.
3. agent memory, session checkpoint, 저장 직후 검증처럼 최신성이 필수인 읽기만 useCache: false로 전환한다.
4. @vercel/blob가 2.6.1 이상인지 확인하고 CI·runtime lockfile을 함께 갱신한다.
5. writer가 둘 이상이면 ETag와 ifMatch를 적용하고 충돌 재시도·병합 정책을 테스트한다.
6. route auth를 get() 가까이에 두고, 브라우저 응답은 private, no-cache 또는 민감도에 따라 no-store로 설정한다.
7. version, ETag, readMode, writeAt, observedAt을 관측하고 내용 원문이나 token은 로그에 남기지 않는다.
8. 테스트에서 write v1 → cached read → overwrite v2 → latest read 순서를 재현하고 v2가 보이는지 검증한다.
9. origin bypass 비율과 Fast Origin Transfer를 모니터링해 correctness 경로가 전체 트래픽으로 번지지 않게 한다.
10. public media와 장기 보관 파일은 새 pathname 또는 random suffix를 우선해 캐시 무효화 문제를 피한다.
리스크와 반론
‘항상 최신값이 정답’은 아니다. dashboard 통계나 과거 report처럼 조금 오래된 snapshot이 허용되는 읽기까지 origin으로 보내면 latency와 비용만 늘어난다. consistency level은 데이터마다 다른 SLO여야 한다.
반대로 ‘최대 60초면 기다리면 된다’는 판단도 workflow에는 위험할 수 있다. 다음 step이 이전 checkpoint를 기반으로 외부 API를 중복 호출하거나 결제를 재시도한다면 60초는 UX 문제가 아니라 정확성·비용·idempotency 문제다.
마지막으로 Blob을 mutable coordination store로 계속 확장할수록 DB의 transaction, row lock, query, schema 같은 기능이 그리워진다. 작은 file-shaped state에는 Blob이 적합할 수 있지만, 다중 writer·부분 업데이트·강한 transaction이 필요해지면 Postgres나 전용 state store가 더 단순한 선택일 수 있다.
최신 읽기와 충돌 방지를 함께 쓰는 최소 예시
import { get, put, BlobPreconditionFailedError } from '@vercel/blob';
const current = await get('agents/memory.json', {
access: 'private',
useCache: false,
});
try {
await put('agents/memory.json', nextMemory, {
access: 'private',
allowOverwrite: true,
ifMatch: current.blob.etag,
});
} catch (error) {
if (error instanceof BlobPreconditionFailedError) {
// Re-read, merge, or surface the conflict.
}
throw error;
}최신 읽기와 조건부 쓰기는 짝이지만 역할은 다르다. 충돌 시 무조건 재시도하지 말고 재읽기·병합·중단 중 도메인 정책을 선택한다.
Vercel Blob 상태 읽기 체크포인트
✓private Blob pathname을 immutable asset, mutable state, derived snapshot으로 분류한다.
✓같은 pathname을 allowOverwrite: true로 쓰는 모든 코드를 검색하고 stale 허용 시간을 기록한다.
✓agent memory, session checkpoint, 저장 직후 검증처럼 최신성이 필수인 읽기만 useCache: false로 전환한다.
✓@vercel/blob가 2.6.1 이상인지 확인하고 CI·runtime lockfile을 함께 갱신한다.
✓writer가 둘 이상이면 ETag와 ifMatch를 적용하고 충돌 재시도·병합 정책을 테스트한다.
✓route auth를 get() 가까이에 두고, 브라우저 응답은 private, no-cache 또는 민감도에 따라 no-store로 설정한다.
✓version, ETag, readMode, writeAt, observedAt을 관측하고 내용 원문이나 token은 로그에 남기지 않는다.
✓테스트에서 write v1 → cached read → overwrite v2 → latest read 순서를 재현하고 v2가 보이는지 검증한다.
✓origin bypass 비율과 Fast Origin Transfer를 모니터링해 correctness 경로가 전체 트래픽으로 번지지 않게 한다.
✓public media와 장기 보관 파일은 새 pathname 또는 random suffix를 우선해 캐시 무효화 문제를 피한다.
정리
useCache: false는 private Blob을 상태처럼 쓸 때 필요한 마지막 쓰기 읽기 도구다. 그러나 그것은 freshness만 해결한다. ETag/ifMatch로 concurrency를, route-local auth로 access를, Cache-Control과 versioned URL로 delivery cache를 각각 설계해야 한다. 네 계약을 분리하면 ‘에이전트가 기억을 잃었다’는 모호한 장애를 측정 가능한 저장소 문제로 바꿀 수 있다.