3 min read

Next.js 캐시 무효화 실전: Redis 태그 캐시와 재검증 전략

Next.js App Router에서 Redis 태그 캐시를 함께 사용해 캐시 적중률과 데이터 신선도를 동시에 확보하는 실무 설계 가이드

Next.js 캐시 무효화 실전: Redis 태그 캐시와 재검증 전략 thumbnail

도입

트래픽이 늘어나면 대부분의 Next.js 서비스는 같은 고민을 반복한다. "응답 속도를 위해 캐시를 강하게 걸면 데이터가 늦게 반영되고, 최신성을 우선하면 매 요청이 비싸진다." 특히 App Router 기반에서는 페이지/데이터/라우트 캐시가 섞여 동작하기 때문에, 단순 TTL 하나로 전체 문제를 해결하기 어렵다.

이 글은 **Next.js 재검증 API(revalidateTag, revalidatePath)**와 Redis 태그 캐시를 함께 사용해, 캐시 적중률과 데이터 신선도를 동시에 잡는 실무 패턴을 다룬다.

문제 정의

운영 환경에서 자주 발생하는 실패 패턴은 아래와 같다.

  • 콘텐츠 수정 후 일부 페이지만 오래된 데이터를 표시
  • 다수 페이지가 같은 엔티티를 참조하는데 무효화 범위를 좁히기 어려움
  • "전체 캐시 삭제" 방식으로 순간 부하 스파이크 발생
  • 캐시 키 규칙이 코드마다 달라 디버깅 난이도 증가

핵심은 "무효화 단위"를 키가 아니라 태그 기반 도메인 이벤트로 바꾸는 것이다.

개념 설명

캐시 계층을 분리해서 보기

계층목적대표 도구무효화 방식
Next.js 데이터/라우트 캐시SSR/ISR 비용 절감fetch cache, App Router cacherevalidateTag, revalidatePath
애플리케이션 캐시도메인 질의 재사용Rediskey/tag 매핑 삭제
CDN/엣지 캐시글로벌 응답 지연 최소화Vercel Edge/CDN헤더/재배포 기반 무효화

실무에서는 이 세 계층을 한 번에 지우지 않는다. 먼저 "도메인 태그"를 기준으로 애플리케이션 캐시를 지우고, 해당 태그를 참조하는 Next 캐시를 재검증하는 순서가 안전하다.

태그 설계 원칙

  1. 엔티티 단위 태그: post:123, author:42
  2. 리스트 단위 태그: post:list, post:tag:nextjs
  3. 파생 뷰 태그: home:featured, sidebar:popular

이렇게 나누면 "한 글 수정" 시 필요한 범위만 정확히 무효화할 수 있다.

코드 예시

예시 1: Redis 태그 캐시 저장/삭제 유틸

import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

function dataKey(key: string) {
  return `cache:data:${key}`;
}

function tagSetKey(tag: string) {
  return `cache:tag:${tag}`;
}

export async function setWithTags<T>(
  key: string,
  value: T,
  tags: string[],
  ttlSeconds: number,
) {
  const serialized = JSON.stringify(value);
  const dKey = dataKey(key);

  const tx = redis.multi();
  tx.set(dKey, serialized, "EX", ttlSeconds);

  for (const tag of tags) {
    tx.sadd(tagSetKey(tag), dKey);
    tx.expire(tagSetKey(tag), ttlSeconds);
  }

  await tx.exec();
}

export async function invalidateByTag(tag: string) {
  const setKey = tagSetKey(tag);
  const members = await redis.smembers(setKey);

  if (members.length === 0) {
    return 0;
  }

  const tx = redis.multi();
  tx.del(...members);
  tx.del(setKey);
  await tx.exec();

  return members.length;
}

포인트는 "데이터 키를 태그 set에 역인덱싱"해두는 것이다. 그래야 도메인 이벤트 하나로 관련 캐시만 제거할 수 있다.

예시 2: 변경 이벤트에서 Next 재검증까지 연결

import { revalidatePath, revalidateTag } from "next/cache";
import { invalidateByTag } from "@/lib/cache/tag-cache";

export async function onPostUpdated(postId: string, slug: string) {
  const tags = [
    `post:${postId}`,
    "post:list",
    "home:featured",
  ];

  // 1) 애플리케이션 캐시 무효화
  await Promise.all(tags.map((tag) => invalidateByTag(tag)));

  // 2) Next.js 캐시 재검증
  tags.forEach((tag) => revalidateTag(tag));

  // 3) 경로 단위 재검증 (fallback 안전장치)
  revalidatePath(`/posts/${slug}`);
  revalidatePath("/");
  revalidatePath("/posts");
}

revalidateTagrevalidatePath를 경쟁 관계로 보기보다, 태그 우선 + 경로 fallback 조합으로 운영하는 편이 장애 대응에 유리하다.

아키텍처 설명

Mermaid diagram rendering...

핵심은 "수정 이벤트 -> 태그 무효화 -> 재검증 -> 재적재"가 한 트랜잭션 흐름처럼 동작하도록 묶는 것이다.

트레이드오프 분석

선택장점단점권장 상황
TTL 중심 단순 캐시구현이 매우 단순최신성 보장 어려움낮은 변경 빈도 콘텐츠
태그 기반 정밀 무효화신선도/성능 균형 우수태그 설계 비용 필요운영형 서비스
전체 캐시 삭제즉시 일관성 확보순간 부하 급증긴급 장애 대응 시 임시

태그 방식의 비용은 "초기 모델링"에 집중된다. 하지만 한번 기준을 세우면 기능 추가 때마다 캐시 정책을 재발명하지 않아도 된다.

정리

Next.js 캐시 운영의 핵심은 캐시를 많이 두는 것이 아니라 정확하게 지우는 것이다. 실무에서 바로 적용하려면 아래 순서로 시작하면 된다.

  1. 도메인 태그 네이밍 규칙을 먼저 고정한다.
  2. Redis 태그 역인덱스를 구현한다.
  3. 변경 이벤트에서 invalidateByTag -> revalidateTag -> revalidatePath 순서를 표준화한다.
  4. 캐시 적중률과 재검증 지연 시간을 메트릭으로 관찰한다.

이 구조를 잡아두면, 트래픽이 늘어도 "빠르면서 최신인 응답"에 더 가깝게 운영할 수 있다.

함께 읽기

댓글