배포만 하면 블로그 글이 하나도 안 보이던 버그: 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도 같이 최신으로 올려야 빌드가 통과한다.

검증

배포 환경에서만 터지는 버그라, "로컬에서 빌드된다"만으로는 증거가 안 된다. 업그레이드 후:

  1. npm run build 후 실제 빌드 결과물을 로컬에서 직접 실행해서 글 개수가 실제 글 파일 개수와 일치하는지 확인
  2. 배포하고, 라이브 응답 헤더에서 x-vercel-cache: MISS / 새로운 age를 확인해서 캐시된(여전히 빈) 응답을 보고 있는 게 아님을 확인
  3. 배포된 /sitemap.xml에 글이 전부 들어있는지 확인 (정적 라우트 몇 개만 있는 게 아니라)

정리

queryCollection()(또는 SQLite 파일 기반의 다른 Nuxt 모듈)이 로컬에서는 되는데 Vercel에서만 에러 없이 빈 값만 돌려준다면, 로직보다 파일시스템 제약을 먼저 의심하자. 그리고 그 모듈이 자기 자신의 Vercel 대응 수정보다 더 오래된 버전에 고정돼 있는지도 확인하자. "로컬에선 되는데"는 캐시 문제인 경우만큼이나, 파일시스템 권한 문제인 경우도 많다.