DEVELOPMENT NOTE3

캐시를 즉시 비우면서 300초 TTL을 남긴 이유

콘텐츠 동기화 뒤 캐시를 즉시 비우되 시간 기반 갱신도 유지한 이유

#nextjs#data-cache#revalidate-tag#content-sync#cache-invalidation#distributed-systems
아카이브로 돌아가기

데이터베이스가 최신이어도 화면은 늦었다

Git 변경은 자동으로 MySQL 조회 모델까지 반영됐지만 /blog는 바로 바뀌지 않을 수 있었다. Next.js의 서버 전용 client가 백엔드 응답을 Data Cache에 저장하고 있었기 때문이다. MySQL과 Spring API가 최신이어도 이전 응답이 남아 있으면 사용자는 예전 제목이나 featured 상태를 보게 된다.

당시 fetch는 revalidate: 300을 사용했다. 같은 요청은 캐시를 재사용하고 최대 300초 뒤 새 데이터를 가져왔다. 단순하고 장애에도 강했지만 동기화 완료 시각과 만료 시각은 관계가 없었다. TTL을 줄이면 stale 시간은 짧아져도 백엔드 요청이 늘고 두 생명주기는 여전히 연결되지 않는다.

경로가 아니라 데이터에 태그를 붙였다

무효화 방법으로 revalidateTag, revalidatePath, 두 방식의 병용과 no-store를 비교했다.

revalidatePath/blog만 있을 때는 단순하지만 페이지 목록과 Featured 요청이 늘면 관리할 경로도 늘어난다. Tag와 path를 함께 사용하면 같은 변경을 두 계약으로 표현해야 한다. no-store는 모든 요청을 백엔드에 보내 캐시의 장점을 포기한다.

여러 경로가 같은 글 데이터를 사용한다는 사실을 표현하기 위해 공통 houkago-posts tag를 선택했다. Page 0과 페이지 목록, featured=true 요청은 모두 같은 저수준 client를 지나므로 같은 tag를 받는다.

ts
const init: NextFetchRequestInit = {
  headers: { Accept: "application/json" },
  signal: options?.signal,
  next: {
    revalidate: options?.revalidate ?? DEFAULT_POST_REVALIDATE_SECONDS,
    tags: [BACKEND_POSTS_CACHE_TAG],
  },
};

DEFAULT_POST_REVALIDATE_SECONDS300, BACKEND_POSTS_CACHE_TAGhoukago-posts다.

즉시 무효화와 TTL을 함께 유지했다

두 정책은 역할이 다르다.

  • 정상 흐름에서는 Worker가 tag를 무효화해 이전 응답을 바로 제거한다.
  • Endpoint 설정이나 네트워크, Worker에 문제가 생겨도 300초 뒤에는 자연스럽게 갱신된다.

On-demand 무효화만 사용하면 그 호출 경로가 화면 갱신의 단일 실패 지점이 된다. 즉시성은 명시적인 신호가 담당하고, eventual recovery는 TTL이 담당하도록 했다.

내부 endpoint는 캐시만 제어했다

프런트엔드에 POST /api/internal/revalidate/posts를 추가했다. 일반 콘텐츠 API가 아니라 Worker가 Next.js Data Cache에 변경 신호를 보내는 내부 운영 경계다. Request body는 없고 Bearer token으로 인증한다.

Secret은 NEXT_PUBLIC_이 아닌 서버 전용 환경 변수 HOUKAGO_REVALIDATE_SECRET에 둔다. 두 값을 각각 SHA-256 digest로 만든 뒤 timingSafeEqual로 비교해 길이가 달라도 일정한 길이의 비교가 되게 했다.

조건응답
Secret이 없거나 공백임503 Service Unavailable
Authorization이 없거나 잘못됨401 Unauthorized
인증과 tag 무효화 성공200, { "revalidated": true }

모든 응답에는 Cache-Control: no-store를 붙인다. 인증 결과나 무효화 성공 자체가 캐시될 이유가 없기 때문이다. Secret과 Authorization header도 응답이나 로그에 남기지 않는다.

조회 모델을 확인한 뒤 캐시를 비웠다

Git push 직후 캐시를 지우면 동기화가 실패했는데도 이전 백엔드 응답을 다시 저장할 수 있다. 따라서 기준은 원본 변경이 아니라 공개 조회 경계까지 성공했다는 확인으로 정했다.

text
일회성 동기화 성공
  -> 목록·Featured·상세 API smoke 통과
  -> POST /api/internal/revalidate/posts
  -> houkago-posts tag 무효화

Worker는 네트워크 오류와 timeout, HTTP 429, 5xx에서 짧은 backoff로 최대 세 번 시도한다. 401403은 자격 증명이나 접근 정책 문제이므로 즉시 중단하고, 예상하지 못한 상태 코드도 재시도하지 않는다.

최종 무효화가 실패해도 이미 성공한 MySQL 변경을 되돌리지는 않는다. Worker는 backendSync=SUCCESS와 프런트엔드 경고를 따로 기록하고 작업을 succeeded로 마친다. 두 시스템을 하나의 트랜잭션처럼 취급할 수 없기 때문이다.

운영자는 별도 revalidate 명령으로 Git 갱신과 동기화 없이 캐시만 다시 비울 수 있다. 이 복구를 놓쳐도 300초 TTL이 남아 있다.

운영 환경에서 반영 시간을 확인했다

2026년 8월 12일에는 published 글의 description 한 건을 바꿔 전체 흐름을 관찰했다.

  • Push 약 4초 뒤 GitHub Webhook이 도착했다.
  • 약 22초에 UPDATED=1, TOUCHED=264로 동기화됐다.
  • 약 23초에 첫 무효화 요청이 HTTP 200으로 성공했다.
  • Push 후 47초 안에 백엔드 API와 새로운 /blog 요청에서 변경을 확인했다.

기존 posts 기반 Vercel 전체 배포가 끝나기 전에 /blog가 바뀌었다. 화면 갱신이 배포 결과 교체가 아니라 캐시 무효화로 일어났음을 확인한 것이다. App과 MySQL container도 재시작되지 않았다. 이 시간은 당시 네트워크와 작업 대기열, 265개 후보와 확인 간격의 영향을 받은 관찰값이며 SLA는 아니다.

단순한 태그가 넓은 범위를 지운다

houkago-posts는 글 하나만 바뀌어도 첫 페이지와 페이지 목록, Featured 캐시를 함께 지운다. 글이나 query별 tag보다 범위가 넓지만 호출 계약이 단순하고 누락될 조합이 적다. 당시 콘텐츠 규모와 변경 빈도에는 세밀함보다 안전한 전체 무효화가 적합했다. 트래픽과 경로가 커지면 목록·상세·slug별 tag로 나누고 mutation과 query의 관계를 따로 관리할 수 있다.

이 tag가 담당하는 화면은 다음과 같다.

  • /blog의 All Posts 첫 페이지와 pagination metadata
  • 같은 첫 페이지를 재사용하는 Recent Posts
  • featured=true로 별도 요청하는 Featured Posts
  • /blog/page/[page] 목록

글 상세와 MDX rendering, 검색, tag와 category, asset, sitemap, draft preview는 여전히 로컬 생성 데이터를 사용했다. 이 영역은 tag를 지워도 갱신되지 않으므로 posts 변경에 따른 Vercel 배포도 아직 유지했다.

캐시 문제를 해결한다는 것은 TTL을 없애는 일이 아니었다. 데이터가 확실히 준비된 시점에 즉시 신호를 보내고, 그 신호가 실패해도 시간이 지나면 복구되는 두 경계를 함께 두는 일이었다.