김솔비 블로그
트러블 슈팅

Vercel ISR Writes 급증 원인 분석과 재생성 주기 재설계

10분 읽기

들어가며

9월 말 프롬피드 사이트맵을 검색엔진에 제출했다. 그 뒤 Vercel 사용량 화면을 열어 보니 ISR Writes 막대가 하루 4만 단위를 넘겨 있었다. Hobby 플랜의 한 달 포함량이 20만 단위이니, 이런 날이 닷새만 이어져도 한도를 다 쓴다.

DB 조회는 방문자 수와 무관하게 만들어 둔 상태였다. 그래서 처음에는 무엇이 쓰기를 만드는지 감이 오지 않았다. 페이지 크기와 재생성 주기를 곱해 쓰기량을 직접 계산해 보고 원인을 좁혔다. 그다음 경로마다 재생성 주기를 다시 정했다. 이 글은 그 과정과 적용 뒤 실제로 줄어든 양을 정리한 기록이다.

배경

Part 1에서 "방문자가 늘어도 DB 조회는 늘지 않는 구조"를 만들었다. 공개 화면은 전부 서버 캐시에 올린 두 번의 조회 결과로 그리고, 페이지 자체는 ISR(Incremental Static Regeneration)로 미리 만들어 둔다. DB 쪽 비용은 의도대로 고정됐다.

그런데 비용이 옮겨 갈 곳이 하나 남아 있었다. ISR 캐시에 쓰는 양이다. 프롬피드는 Vercel Hobby(무료) 플랜에서 운영하고, Hobby 플랜의 ISR Writes 포함량은 월 200,000 단위다. 1단위는 캐시에 쓰는 데이터 8KB다(Vercel 문서).

증상

9월 말 사이트맵을 제출하고 검색엔진에 등록한 뒤부터 사용량 그래프가 달라졌다.

지표값
ISR Writes (가장 많던 날)약 46,000 단위 (9/29), 약 39,000 단위 (10/2)
ISR Writes (9/27~10/6 하루 평균)약 17,000 단위
가장 많던 날 속도로 월 포함량 소진까지약 4.3일
응답 헤더x-vercel-cache: STALE 다수

방문자 행동(조회·복사)은 Supabase RPC로만 집계하고 revalidatePath·revalidateTag를 호출하지 않는다. 코드로 확인해 보니 캐시를 비우는 곳은 관리자의 저장·발행 액션뿐이었다. 즉 사용자 행동이 캐시를 계속 깨뜨리는 구조는 아니었다. 그렇다면 쓰기는 ISR의 시간 기반 재생성에서만 나온다.

원인 분석

1. ISR이 쓰기를 만드는 조건

revalidate = 300인 페이지는 다음처럼 동작한다.

  1. 마지막 생성 후 5분이 지나면 페이지가 "오래됨(stale)" 상태가 된다.
  2. 그 뒤 첫 요청에는 오래된 페이지를 응답하고(STALE), 뒤에서 페이지를 다시 만든다.
  3. 다시 만든 HTML과 RSC 페이로드 전체를 ISR 캐시에 쓴다. 이것이 ISR Write다.

5분이 지나도 요청이 없으면 쓰기도 없다. 그래서 사람이 적게 오던 시기에는 문제가 드러나지 않았다. 크롤러는 다르다. 사이트맵의 모든 주소를 고르게, 그리고 반복해서 방문한다. 모든 경로가 5분마다 한 번씩 요청되는 상태에 가까워진 것이다.

2. 페이지 크기로 쓰기량 계산하기

운영 환경에서 경로별 HTML + RSC 크기를 재서 단위로 바꿨다(8KB = 1단위).

경로크기1회 재생성경로 수
/ (홈)0.94MB약 117단위1
/browse-cache/prompts (전체 목록)0.88MB약 110단위1
/c/* (카테고리)-약 80~90단위8
/p/* (상세)-약 37단위35
/tag/* (태그)-약 21단위25

사이트 전체를 한 번 다시 만들면 약 2,800단위다. 가장 많던 날의 약 4.6만 단위는 사이트 전체를 하루 16번쯤 다시 만든 양이다. 크롤러가 사이트를 하루 여러 차례 훑으면서, 들를 때마다 5분이 지난 페이지를 만났다고 보면 설명되는 숫자다.

여기에 데이터 캐시도 더해진다. 목록 메타데이터를 담은 unstable_cache가 10분마다 만료돼, 페이지보다 데이터 캐시가 먼저 다시 써지는 경우도 있었다.

3. 원인 정리

원인은 세 가지가 곱해진 결과였다.

  • 페이지가 크다. 홈과 목록은 카드 수백 개의 데이터를 RSC 페이로드에 담는다.
  • 재생성 주기가 짧다. 콘텐츠 성격과 상관없이 모든 공개 페이지가 5분이었다.
  • 크롤러가 모든 경로를 고르게 방문한다. 주기가 지날 때마다 거의 확실하게 요청이 온다.

트래픽은 앞으로 더 늘어야 하는 값이니 줄일 대상이 아니다. 페이지 크기는 줄일 수 있지만 구조 변경이 크다. 가장 효과가 크고 위험이 작은 변수는 재생성 주기였다.

검토한 선택지

선택지효과비용·위험판단
Pro 플랜으로 전환한도 문제 즉시 해소월 고정비. 쓰기량 자체는 그대로보류
크롤러 차단·제한 (robots.txt)쓰기 급감검색 유입을 포기하는 셈제외
순수 on-demand 재검증만 사용쓰기 최소화순위·조회 수처럼 시간에 따라 바뀌는 값이 멈춤일부만 적용
페이지 크기 축소 (페이로드 분할)1회 쓰기량 감소목록 구조 변경 필요, 작업량 큼다음 과제
콘텐츠 변경 주기에 맞춘 경로별 revalidate재생성 횟수 급감일부 숫자의 반영 지연채택

해결: 콘텐츠가 바뀌는 주기에 맞춘 재생성 주기

경로마다 "이 페이지의 내용은 실제로 얼마나 자주 바뀌는가?"를 물었다. 따져 보니 5분이 맞는 페이지는 하나도 없었다.

경로전후근거
/, /top5분1시간순위·화제 목록이 있어 시간에 따라 바뀜
/browse-cache/prompts, /browse-cache/c/*5분6시간가장 큰 페이지. 정렬용 숫자만 바뀜
/p/*, /tag/*, /series/*, /about5분1일관리자 저장 때만 바뀜 (그때 즉시 무효화)
/rss.xml, /api/search5분1시간목록 주기와 맞춤
unstable_cache 목록 메타10분1시간가장 짧은 페이지 주기와 맞춤
ts
// src/app/(site)/page.tsx // 순위·화제 목록이 있어 1시간마다. // 5분마다 다시 만들면 큰 페이지(HTML 0.6MB)가 크롤러 방문마다 다시 써진다 (ISR Writes) export const revalidate = 3600;
ts
// src/app/(site)/p/[slug]/page.tsx // 본문은 관리자가 저장할 때만 바뀌고(그때 actions.ts가 바로 비운다), // 조회·복사 수는 화면에서 따로 받아 온다(fetchLiveCounts) export const revalidate = 86400;

데이터 캐시 주기를 페이지 주기와 맞춘 이유

ts
// src/lib/data.ts // 목록·순위 페이지 중 가장 짧은 주기(1시간)와 맞춘다. // 더 짧으면 페이지보다 데이터 캐시가 먼저 자주 다시 써진다 (ISR Writes) const META_TTL = 3600;

데이터 캐시가 페이지보다 자주 만료되면, 페이지가 다시 만들어지지 않아도 데이터 캐시 쓰기가 따로 발생한다. 반대로 데이터 캐시가 페이지보다 길면, 페이지가 재생성돼도 오래된 데이터로 그린다. 가장 짧은 페이지 주기 = 데이터 캐시 주기로 맞추면 두 문제가 함께 사라진다.

바뀌지 않은 것

재생성 주기를 늘리면 "새 글이 안 보인다"는 문제가 생기기 쉽다. 이 부분은 원래 구조가 막아 준다.

  • 새 글·수정 반영: 관리자 저장·발행 액션이 revalidateTag(PUBLIC_DATA_TAG)와 revalidatePath("/", "layout")를 호출해 캐시를 즉시 비운다. 주기와 관계없이 바로 반영된다.
  • 실시간 숫자: 상세 화면의 조회·복사·저장 수는 페이지와 별도로 클라이언트에서 받아 온다(fetchLiveCounts). 페이지가 하루 동안 그대로여도 숫자는 실시간이다.
  • 동적 경로: 공유 이미지, 스토리 이미지, /api/sns/*, 검색어가 붙은 /prompts는 원래 ISR이 아니라 영향이 없다.

트레이드오프

감수한 것은 목록 카드에 보이는 숫자의 신선도다.

  • 목록 카드의 조회·복사 수는 최대 6시간 늦게 바뀐다.
  • 상세 화면 옆 '지금 급상승'은 최대 1일 늦게 바뀐다.

프롬피드에서 목록의 숫자는 정렬과 참고용이다. 사용자가 행동을 결정하는 정보는 상세 화면의 실시간 숫자다. 몇 시간 늦은 정렬은 사용자 경험에 거의 영향이 없지만, 무료 한도 초과는 서비스 운영 자체에 영향을 준다. 그래서 신선도를 내주고 한도를 지키는 쪽을 골랐다.

결과

계산상 기대치

모든 경로가 끊임없이 요청되는 최악의 경우를 기준으로 하루 재생성 횟수를 비교하면 다음과 같다.

경로전 (하루)후 (하루)
홈·TOP288회24회
전체·카테고리 목록288회4회
상세·태그·시리즈288회1회

여기에 관리자가 저장할 때마다 한 번씩 재생성이 더해진다. 계산만 보면 ISR Writes가 80~95% 줄어야 했다.

실제 사용량

변경은 10월 7일 오전 8시에 배포했다. 아래는 Vercel Usage의 최근 30일 ISR Writes 그래프다.

Vercel Usage의 최근 30일 ISR Writes. 9월 29일과 10월 2일에 높은 막대가 있고, 10월 7일 이후 낮아진다

그래프에서 읽은 하루 값(근삿값)으로 적용 전후를 비교했다. 10월 12일은 하루가 끝나지 않아 뺐다.

구간기간합계하루 평균가장 많던 날
사이트맵 제출 직후9/27~9/29 (3일)약 75,000약 25,000약 46,000
적용 전 1주9/30~10/6 (7일)약 98,000약 14,000약 39,000
적용 후10/7~10/11 (5일)약 31,000약 6,200약 10,000

적용 전 1주와 비교하면 하루 평균은 약 55%, 가장 많던 날은 약 75% 줄었다. 30일 합계는 204,447 단위로, 이미 월 포함량(200,000)을 조금 넘겼다.

계산상 기대치(80~95%)에는 못 미쳤다. 이유로 짚이는 것은 두 가지다.

  • 비교 구간이 고르지 않다. 적용 전에도 10월 5일은 약 1,500 단위로 낮았다. 크롤러가 언제 몰려오느냐에 따라 하루 값이 열 배 넘게 흔들린다. 5일치로는 효과를 정확히 말하기 어렵다.
  • 남은 쓰기의 출처를 아직 나누지 않았다. 적용 후에도 하루 2,000~10,000 단위가 남는다. 10월 8일에는 개발자 노트 작업으로 배포가 여러 번 있었고, 관리자 저장도 재생성을 만든다. 경로별로 어디서 나오는지는 아직 확인하지 못했다.

그래도 하루 4만 단위가 나오던 구조는 사라졌다. 다만 적용 후 하루 평균 6,200 단위를 한 달로 늘리면 약 19만 단위다. 포함량에 겨우 들어오는 수준이라 여유가 크지 않다.

검증

  • tsc --noEmit, vitest 35개, next build 통과
  • 빌드 결과의 경로별 재생성 주기 표기(1h / 6h / 1d) 확인
  • 배포 후 Vercel Usage의 ISR Writes 추이 확인 (위 표)

회고: 무엇을 배웠나

  1. ISR 비용은 "트래픽 × 주기 × 페이지 크기"의 곱이다. 셋 중 하나만 커도 괜찮지만, 크롤러가 들어오는 순간 세 값이 동시에 커진다. 검색 노출을 시작하는 시점이 곧 ISR 주기를 점검할 시점이다.
  2. 기본값을 그대로 복사하지 말 것. 모든 페이지에 붙어 있던 revalidate = 300은 처음 만든 페이지의 값을 옮겨 쓴 결과였다. 주기는 페이지마다 "내용이 언제 바뀌는가"를 묻고 정해야 한다.
  3. 즉시 무효화가 있으면 주기는 길어도 된다. 시간 기반 재생성은 "놓친 변경을 언젠가 반영하는 안전망"이고, 실제 반영은 on-demand 무효화가 맡는다. 이 역할 분담이 명확하면 주기를 과감하게 늘릴 수 있다.

다음 과제

  • 페이지 크기 줄이기: 홈과 목록이 RSC 페이로드에 카드 데이터를 통째로 싣는다. 첫 화면 분량만 서버에서 그리고 나머지는 나눠 받으면 1회 재생성 비용 자체가 줄어든다.
  • 남은 쓰기 나누기: 적용 후에도 남는 하루 수천 단위가 어느 경로·어떤 계기(크롤러, 배포, 관리자 저장)에서 나오는지 나눠 본다.
  • 사용량 경보: 월 포함량의 50%·80% 지점에서 알림을 받아, 이번처럼 그래프를 보고 나서야 알아채는 일을 줄인다.

프롬피드 개발기 시리즈: 전체 목차 · Part 1. 방문자 수와 무관한 DB 조회 구조