배포만 하면 블로그 글이 하나도 안 보이던 버그: Nuxt Content와 Vercel의 /tmp 문제
로컬에서는 멀쩡한데 Vercel에 올리면 queryCollection()이 빈 배열만 반환하던 버그를 추적한 과정. 원인은 @nuxt/content가 Vercel의 읽기 전용 파일시스템을 지원하기 전 버전에 고정돼 있었던 것.
배포만 하면 블로그 글이 하나도 안 보이던 버그: Nuxt Content와 Vercel의 /tmp 문제
어느 날 갑자기 배포된 블로그에서 글 목록이 통째로 안 보이게 됐다. 에러도 없고, 빌드도 멀쩡히 끝났는데, 그냥 글이 0개로 뜸. 로컬에서 똑같은 빌드 결과물을 그대로 실행해보면 글이 전부 정상적으로 나왔다.
똑같은 코드인데 어디서 실행하느냐에 따라 결과가 다르다 — 이건 로직 버그가 아니라 실행 환경 차이에서 나는 버그라는 신호다. 추적한 과정을 정리한다.
증상
<script setup>
const { data: posts } = await useAsyncData("blog-post-list", async () => {
const result = await queryCollection("blog").all();
return result;
}, { default: () => [] });
</script>
- 로컬에서 npm run build 후 node .output/server/index.mjs로 직접 실행 → 글 전부 정상 표시
- Vercel에 배포된 실제 URL 접속 → posts가 항상 빈 배열. 에러도 없고 로그에 경고도 없음
캐시 문제인지부터 확인했다. 응답 헤더에 x-vercel-cache: MISS, age: 0 — 캐시된 옛 응답이 아니라 매번 새로 실행되는 서버에서 진짜로 빈 값을 반환하고 있었다.
하나씩 지워가며 확인
빌드 결과물 자체는 완전히 동일한데 환경별로 동작이 다르다는 건, 코드나 설정 문제가 아니라 "어디서 실행되는가" 와 관련된 무언가라는 뜻이다.
- content.config.ts가 빠진 것도 아니었다 — 정상적으로 설정돼 있었음
- 페이지 코드 버그도 아니었다 — 동일한 빌드가 로컬에서는 전체 글을 정상적으로 그려냄
- CDN에 캐싱된 옛 응답도 아니었다 — 헤더로 확인(MISS, age: 0)
남는 건 로컬 실행과 Vercel 서버리스 함수 실행 사이에 진짜로 다른 단 하나, 파일시스템 접근 권한이었다.
원인: @nuxt/content v3의 SQLite DB는 Vercel에서 /tmp가 필요한데, 3.0.0은 그걸 몰랐다
@nuxt/content v3는 빌드 시점에 마크다운을 SQLite 데이터베이스로 색인해두고, 런타임에 better-sqlite3(네이티브 모듈)로 그걸 읽는다. 그런데 Vercel에 배포된 함수의 파일시스템은 /tmp를 제외한 모든 경로가 읽기 전용이다. 콘텐츠 모듈이 이 SQLite 파일을 /tmp 바깥에서 읽거나 쓰려고 하면, 에러를 던지지 않고 그냥 조용히 실패한다 — queryCollection()이 에러 대신 빈 값을 반환하는 이유다.
이 정확한 상황을 고치는 수정(Vercel에서 /tmp를 쓰도록 하는 패치)은 @nuxt/content v3.1.1에서 들어갔다(nuxt/content#3108). 이 프로젝트는 @nuxt/content: "^3.0.0"에 고정돼 있었는데, 이건 그 수정이 들어가기 전에 나온 v3의 첫 번째 릴리스였다.
버그의 전체 그림이 이거다: 프로젝트 초기에 콘텐츠 모듈을 그 당시 최신인 v3.0.0으로 깔아놓고 그 이후로 한 번도 안 올렸는데, 그 버전엔 Vercel 파일시스템 대응이 아예 없었던 것. 로컬에는 그런 제약이 없으니 깨진 경로로도 아무 문제 없이 동작해서, 배포 환경에서만 터지는 이런 유형의 버그가 오래 숨어있을 수 있었던 것.
해결
패치된 버전으로 올리고, 그 과정에서 같이 걸리는 것들까지 정리.
- "@nuxt/content": "^3.0.0",
+ "@nuxt/content": "^3.16.1",
- "nuxt": "^3.15.2",
+ "nuxt": "^3.21.11",
- "@nuxtjs/sitemap": "^7.2.9",
+ "@nuxtjs/sitemap": "^8.6.1",
이렇게 멀리 올리면서 같이 걸린 것들:
- @nuxt/content@3.16.1은 nuxt >=3.19.0(또는 ^4.1.0)을 요구한다 — Nuxt 버전이 낮으면 에러 대신 경고만 띄우고 모듈을 조용히 비활성화해버린다.
- 최신 @nuxt/content는 better-sqlite3를 더 이상 내부에 번들하지 않는다 — 명시적으로 설치해야 한다. 안 하면 nuxt prepare가 Nuxt Content requires better-sqlite3 module to operate 에러로 멈춘다.
- @nuxtjs/sitemap의 예전 콘텐츠 연동(asSitemapCollection)이 @nuxt/content 런타임에서 queryCollectionWithEvent를 가져다 쓰는데, 최신 콘텐츠 버전에서 이게 이름이 바뀌었다/없어졌다. @nuxtjs/sitemap도 같이 최신으로 올려야 빌드가 통과한다.
검증
배포 환경에서만 터지는 버그라, "로컬에서 빌드된다"만으로는 증거가 안 된다. 업그레이드 후:
- npm run build 후 실제 빌드 결과물을 로컬에서 직접 실행해서 글 개수가 실제 글 파일 개수와 일치하는지 확인
- 배포하고, 라이브 응답 헤더에서 x-vercel-cache: MISS / 새로운 age를 확인해서 캐시된(여전히 빈) 응답을 보고 있는 게 아님을 확인
- 배포된 /sitemap.xml에 글이 전부 들어있는지 확인 (정적 라우트 몇 개만 있는 게 아니라)
정리
queryCollection()(또는 SQLite 파일 기반의 다른 Nuxt 모듈)이 로컬에서는 되는데 Vercel에서만 에러 없이 빈 값만 돌려준다면, 로직보다 파일시스템 제약을 먼저 의심하자. 그리고 그 모듈이 자기 자신의 Vercel 대응 수정보다 더 오래된 버전에 고정돼 있는지도 확인하자. "로컬에선 되는데"는 캐시 문제인 경우만큼이나, 파일시스템 권한 문제인 경우도 많다.