카테고리 없음

Next.js App Router 캐시 무효화 실전: revalidateTag·updateTag·router.refresh 패턴 이해와 적용 방법

benscript 2026. 6. 12. 12:25
반응형

왜 App Router 캐시 무효화가 초보자에게 어렵게 느껴질까

Next.js App Router는 자동 캐싱과 데이터 페칭을 결합해 성능을 높이지만, 화면이 바로 갱신되지 않거나 오래된 데이터가 남는 문제가 자주 나타납니다. 핵심은 어떤 캐시를 언제, 무엇으로 무효화할지 결정하는 것입니다.

예를 들어 게시글을 수정했는데 목록에는 반영되지 않거나, 서버 액션 후 상세 페이지만 바뀌고 헤더 카운트는 그대로일 때가 있습니다. 이는 revalidateTag, updateTag, router.refresh의 역할과 범위를 혼동해서 생기는 현상입니다.

이 글은 세 가지 패턴의 차이와 연결 지점을 실무 흐름에 맞춰 정리합니다. 초보자도 “어떤 요청에 어떤 태그를 붙이고, 어느 시점에 무엇을 갱신할지”를 빠르게 판단할 수 있도록 구체적 기준과 짧은 예시를 제공합니다.

결국 목표는 과도한 전역 새로고침 없이 필요한 데이터만 정확히 갱신하는 것입니다. 불필요한 API 호출과 깜빡임을 줄이고, 사용자에게 최신 상태를 안정적으로 보여주는 방법을 단계별로 익혀보겠습니다.

세 가지 무효화 축을 상황별로 이해하기

게시판 예시로 보면 흐름이 단순해집니다. 상세 페이지는 글 ID로, 목록은 카테고리로, 헤더 카운트는 전체 글 수로 데이터를 가져옵니다.

각 데이터는 서로 다른 “태그”로 캐시되어 있고, 어떤 변화가 생겼는지에 따라 알맞은 축만 깨뜨리면 됩니다.

revalidateTag는 “다음 요청부터 새로 받아와”에 가깝습니다. 글을 수정하면 detail:post-123, list:cat-news, count:all 같은 관련 태그를 선택적으로 무효화해 다음 렌더 때 최신 데이터가 내려오게 합니다.

updateTag는 서버 액션 결과가 즉시 UI에 반영돼야 할 때 적합합니다. 폼 제출 후 낙관적 업데이트처럼, 특정 태그를 구독하는 컴포넌트들이 새 데이터 스냅샷을 바로 참조하도록 신호를 보냅니다.

router.refresh는 현재 라우트 전체의 서버 컴포넌트 트리를 다시 그립니다. 범위가 넓어 비용이 크므로, 태그로 범위를 좁히기 어렵거나 레이아웃 단의 의존성이 얽혀 있을 때 최후 수단으로 고려합니다.

태그 먼저 잡아 무효화 혼란 줄이기

무효화는 데이터 소스 기준으로 태그를 먼저 설계하고 지점을 배치하면 단순해집니다. 핵심은 범위를 좁히고 “즉시 vs 다음 요청”을 분리하는 것입니다.

- 태그 설계: detail:post-{id}, list:cat-{slug}, count:all처럼 소스별로 구분
- fetch 연결: 서버 컴포넌트/라우트 핸들러 fetch 옵션에 태그 명시
- 변경 매핑: 생성·수정·삭제마다 영향 태그 표 작성
- 방식 선택: 즉시는 updateTag, 지연은 revalidateTag, 광범위는 router.refresh
- 정합성: 낙관적 UI 사용 시 최종 한 번만 refresh

아래 서버 액션은 필요한 태그만 무효화합니다.

'use server';
import { revalidateTag, unstable_updateTag as updateTag } from 'next/cache';

export async function savePost(formData: FormData) {
  const id = formData.get('id') as string;
  const category = formData.get('category') as string;
  updateTag(`detail:post-${id}`);
  revalidateTag(`list:cat-${category}`);
  revalidateTag('count:all');
  return { id, category };
}

폼 제출 뒤에는 트리를 한 번만 새로고침하세요. 중복 refresh는 깜빡임을 키웁니다.

'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { savePost } from './actions';

export function PostForm() {
  const router = useRouter();
  const [pending, start] = useTransition();
  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    start(async () => {
      await savePost(new FormData(e.currentTarget as HTMLFormElement));
      router.refresh();
    });
  };
  return <form onSubmit={onSubmit}><button disabled={pending}>저장</button></form>;
}

흔한 실수와 선택 기준 한 번에 정리

가장 잦은 실수는 태그를 넓게 잡아 전역에 가까운 재검증을 트리거하는 것입니다. 목록·상세·카운트를 같은 태그로 묶으면 작은 변경에도 전 라우트가 흔들립니다.

반대로 태그를 과도하게 잘게 나누면 실제 연관 데이터가 갱신되지 않아 불일치가 생깁니다. 변경 영향 범위를 기준으로 “묶을 것과 나눌 것”을 먼저 결정하세요.

updateTag로 즉시 반영했는데 클라이언트 캐시나 SWR/RTK Query 같은 별도 캐시를 함께 쓰면 화면이 엇갈릴 수 있습니다. 동일 데이터의 캐시 계층을 정리하고, 필요 시 한 번의 router.refresh로 최종 정합성을 맞추는 편이 안전합니다.

선택 기준은 간단합니다.
- 한 컴포넌트만 바꾸면 충분한가 → revalidateTag 또는 updateTag
- 사용자 피드백이 즉시 보여야 하는가 → updateTag
- 레이아웃·헤더 등 광범위 의존성이 얽혔는가 → router.refresh
- 서버 비용이 민감한가 → 태그 범위를 더 좁히고 지연 재검증 우선

서버 액션 안에서 revalidateTag와 router.refresh를 모두 호출하는 중복도 흔합니다. 태그로 충분히 국소화됐다면 refresh는 생략하고, 낙관적 UI 뒤 최종 동기화가 필요할 때만 한 번 호출하는 규율을 유지하세요.

오늘부터 적용할 캐시 무효화 체크리스트

App Router 캐시 무효화는 태그를 기준으로 범위를 좁히고, 즉시 반영과 지연 반영을 구분하면 단순해집니다. 핵심은 “어떤 요청이 어떤 태그를 소비하는지”를 먼저 명확히 하는 것입니다.

바로 실행할 다음 행동을 제안합니다.
- 데이터 소스별 태그 설계: detail:post-{id}, list:cat-{slug}, count:all처럼 구분
- fetch 연결 점검: 서버 컴포넌트·라우트 핸들러에 tags 옵션 명시
- 변경 매핑 표 작성: 생성/수정/삭제 시 영향 태그 나열
- 반영 방식 선택: 사용자 피드백 즉시 필요하면 updateTag, 다음 요청 반영이면 revalidateTag, 범위가 넓으면 router.refresh

필요한 경우에만 한 번의 전체 새로고침으로 정합성을 맞추고, SWR 등 클라이언트 캐시가 있다면 동일 데이터 계층을 정리하세요. 작은 단위부터 적용해 목록→상세→공통 UI 순서로 확장하면 시행착오를 줄일 수 있습니다.

반응형