들어가며
최근 애드센스+협찬을 할겸 기존 블로그는 폐쇄하고 AI 다국어 번역 자동화가 달린 개인 블로그 사이트를 만들고 있다.
이 블로그의 특징으로는, 서브도메인이 달라지면 자동으로 db에서 해당 도메인의 데이터가 로드된다는 것이다.
즉 서브도메인 주소만 바꾸면 ui는 그대로지만 내용물은 완전히 달라지며, 니치 블로그로서 역할을 아주 톡톡히 해내 글 작성 시 상위권에 검색될 확률이 매우매우 높다는 것이다!! 후후…
다만 나는 소시민이기에 비용은 최대한 아끼려고 하다보니 배포 스택을 서버리스 중심으로 잡아서 Firebase Hosting + Next.js로 결정했다. Vercel도 좋지만 트래픽·빌드·DB read가 쌓이면 개인 프로젝트치고는 신경 쓸 게 많아지더라.
어쨌든 서버리스로 해서 비용은 적게 나가겠지만, 그래도 더 아끼고자 캐싱 방법을 찾아봤더니 3중 캐시 + Push invalidation 구조를 발견해서 한 번 정리해본다.
일단 바쁘신 분들을 위해 요약본을 적어보자면,
- 유저 요청의 대부분은 Firebase Hosting CDN에서 끝낸다
- CDN을 못 맞추면 Hosting 오리진(Cloud Run + Next.js)의 ISR/Data Cache가 방어한다
- Firestore read는 캐시 miss가 연쇄로 터질 때만 — 평소엔 거의 안 나간다
- 어드민에서 글 쓰면 Push로 오리진 캐시는 즉시 비운다. CDN은 최대 24h eventual consistency를 감수한다.
1. 왜 이렇게까지 했는지
개인 블로그 특성상 트래픽은 들쭉날쭉하고, 실시간 뉴스 사이트처럼 글 올리자마자 전 세계에 0.1초 만에 반영될 필요는 없다. 반면 Firestore는 read 횟수당 과금이라, SSR/ISR 페이지마다 DB를 두드리면 한 달에 몇천 원이든 몇만 원이든 쓸데없이 나갈 수 있다.
처음에 생각했던 건 Firestore onSnapshot으로 서버가 변경을 감지하는 방식이었다. Realtime Listener면 편해 보이지만, Cloud Run은 scale-to-zero라 서버가 계속 떠 있지 않다. 억지로 폴링·리스너를 붙이면 커넥션 + read만 늘어난다. 개인 블로그에 이건 오버다.
그래서 방향을 바꿨다.
Pull(서버가 DB 감시) 대신 Push(어드민 쓰기 시 캐시 파기) 로 간다.
2. 서버리스 한계 — RAM 캐싱은 안 된다
보통 Node.js나 EC2 같은 상시 서버에서는 RAM 변수에 데이터를 넣어두고 캐싱한다. Cloud Run은 요청이 끝나면 인스턴스가 꺼지거나 새 컨테이너로 교체된다. 메모리 캐시도 같이 증발한다.
그래서 “서버리스에서 RAM에 캐싱하면 된다”는 건 정답이 아니다.
3. Next.js 캐시는 .next/cache — Cloud Run에서의 문제
Next.js는 캐시를 RAM이 아니라 파일 시스템에 쓴다. npm run build 하면 .next/cache 아래에 Data Cache·ISR HTML이 .json, .html 파일로 저장된다.
EC2처럼 디스크가 계속 붙어 있으면 여기까지로 끝이다. Cloud Run은 Scale-to-Zero 되면 컨테이너 + 내부 디스크가 통째로 사라진다. .next/cache가 컨테이너 내부에만 있으면 캐시 파일도 같이 증발하고, 새 인스턴스는 매번 Firestore까지 내려간다.
RAM만 피했다고 로컬 .next/cache에 두면, 서버리스에서는 똑같이 휘발이다.
4. Firebase Hosting — Next.js Shared Storage
위에서 .next/cache가 컨테이너 안에만 있으면 Scale-to-Zero 때 같이 날아간다고 했다. 그래서 컨테이너 밖에 캐시를 둬야 한다.
Firebase Hosting은 Next.js 오리진을 Cloud Run 위에 올려 주면서, Next.js Shared Storage라는 관리형 캐시 저장소 레이어도 함께 붙여 준다. 내부적으로는 Cloud Storage나 Cloud Filestore 기반의 분산 파일 시스템이고, 오리진이 .next/cache에 쓰는 ISR HTML·Data Cache 파일이 실제로는 여기에 저장된다.
앱 코드 입장에서는 여전히 .next/cache 폴더에 쓰는 것처럼 보이지만, 컨테이너가 꺼져도 이 레이어는 남는다. A 인스턴스가 Firestore에서 읽어 구워 둔 캐시를 B 인스턴스가 그대로 재사용할 수 있다.
5. 유저가 거치는 순서 — 3중 캐시
유저가 주소를 치고 들어오면, 요청은 가까운 레이어부터 순서대로 검사된다. Data Cache부터 보는 게 아니라, 이미 구워 둔 완성 HTML(CDN) 부터 확인한다. 위에서 맞추면 아래는 안 본다.
[ 유저 접속 ]
▼
1. CDN (Firebase Hosting) ── hit → [종료] ❌ revalidatePath 불가, TTL ~24h
▼ miss
2. ISR Cache (Shared Storage) ── hit → [종료] ✅ revalidatePath()
▼ miss
3. Data Cache ── hit → HTML 렌더 → [종료] ✅ revalidateTag()
▼ miss
4. Firestore 💰 ── read 과금
| 순서 | 레이어 | 저장 내용 | 무효화 |
|---|---|---|---|
| 1 | CDN | edge에 올라간 완성 HTML | TTL ~24h (직접 파기 불가) |
| 2 | ISR | Shared Storage의 구워 둔 HTML | revalidatePath() |
| 3 | Data | Firestore에서 읽어 둔 JSON | revalidateTag() |
어드민에서 글을 발행하면 Firestore에 저장한 뒤, Push로 2·3단 캐시를 즉시 비운다. 1단 CDN은 오리진 API로 건드릴 수 없어서 TTL 만료를 기다릴 수밖에 없다. Firestore read를 줄이려면 이 trade-off를 감수하는 셈이다.
async function onAdminPublish(slug: string) {
await saveToFirestore(/* ... */)
revalidateTag(`post:${slug}`)
revalidatePath(`/blog/${slug}`)
revalidatePath('/blog')
}
6. 트레이드오프 — CDN 24시간
어드민에서 글 고치면 오리진(2·3단)은 바로 비는데, CDN HTML은 s-maxage로 최대 24h 남을 수 있다. 처음엔 찜찜했지만, 개인 블로그는 Strong Consistency보다 Firestore read·origin hit 줄이기 쪽이 이득이라 받아들였다.
| Strong Consistency | 지금 | |
|---|---|---|
| CDN 반영 | 즉시 | 최대 ~24h |
| Firestore read | 무효화마다 가능 | 평소 거의 0 |
Edge case: Firestore 콘솔에서 직접 수정하면 Push invalidation이 안 탄다. 응급은 firebase deploy --only hosting으로 edge cache를 flush하면 된다.
7. Before & After
| 항목 | naive SSR | 지금 |
|---|---|---|
| 페이지 요청 시 Firestore | 매 요청 read 가능 | CDN/ISR/Data hit 시 0 |
| 어드민 발행 후 오리진 | — | 즉시 revalidate |
| 어드민 발행 후 CDN | — | 최대 24h stale |
| 캐시 저장 | RAM 또는 컨테이너 로컬 | .next/cache → Shared Storage (GCS·Filestore) |
| DB 변경 감지 | onSnapshot/폴링 | write 시 Push |
8. 정리
Firebase Hosting에는 Shared Storage 캐시 레이어가 붙어 있어서, Next.js 캐시 파일(.next/cache)이 컨테이너 밖에 남고 CDN → ISR/Data → Firestore 3중 캐싱 구조를 쓸 수 있게 된다.
운영하면서 어느 정도 트래픽이 나오면 그때 캐싱 시간을 조절해보고 더 효율적인 방안이 있는지 검색을 해봐야할 것 같다. 지금은 이정도면 충분할 듯??