Published on

Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo

Authors

서버 컴포넌트 기반의 Next.js App Router는 "렌더링"이라는 단어가 가리키는 범위가 넓습니다. 같은 증상(페이지가 자주 다시 그려짐, 서버 로그가 폭증, API 호출이 중복)이라도 원인이 RSC 렌더(서버)인지, 클라이언트 리렌더(브라우저)인지, 혹은 데이터 캐시 미스인지에 따라 처방이 완전히 달라집니다.

이 글은 App Router에서 렌더링 폭증을 마주했을 때, RSC 캐시와 useMemo를 축으로 원인을 분해하고 재현 가능한 방식으로 진단/해결하는 흐름을 제공합니다.

1) 먼저 용어부터: 무엇이 "렌더링 폭증"인가

App Router에서 흔히 섞여 말하는 이벤트를 분리하면 진단이 쉬워집니다.

1-1. RSC 렌더(서버에서 React Server Components 실행)

  • app/.../page.tsx 같은 서버 컴포넌트 트리가 서버에서 실행됨
  • 이 과정에서 fetch 호출이 발생하고, 그 결과가 RSC payload로 스트리밍됨
  • 서버 비용/TTFB/API 호출량에 직접 영향

1-2. 클라이언트 리렌더(브라우저에서 Client Components 재실행)

  • "use client" 컴포넌트의 state 변경, props 변경, context 변경 등으로 발생
  • INP, Long Task, UI 버벅임에 영향
  • 서버 로그 폭증과는 별개로 발생할 수 있음

1-3. 네비게이션/리프레시가 유발하는 재요청

  • router.refresh()
  • revalidatePath, revalidateTag
  • search params 변경
  • no-store로 인해 매번 서버 렌더가 강제되는 경우

폭증을 잡으려면 “서버 렌더가 많아서 문제인지” vs “클라 리렌더가 많아서 문제인지”를 먼저 갈라야 합니다.

2) 증상별로 가장 먼저 확인할 체크리스트

2-1. 서버 로그가 폭증한다

  • 동일 URL 요청이 많아진 것인지
  • 요청 수는 정상인데, 요청당 fetch가 중복되는지
  • fetch가 캐시되지 않아 매 요청마다 백엔드를 치는지

2-2. UI가 깜빡이거나 입력이 느려진다

  • Client Component 트리가 과도하게 커졌는지
  • useMemo/useCallback이 없는 상태에서 무거운 계산이 렌더마다 반복되는지
  • context provider가 상단에 있고 value가 매번 새로 생성되는지

2-3. 페이지 이동할 때마다 서버가 다시 돈다

  • cookies()/headers()/searchParams 사용으로 동적 렌더링이 강제되는지
  • fetch(..., { cache: "no-store" }) 또는 revalidate: 0이 박혀 있는지

3) RSC 캐시: "캐시"가 여러 겹이라는 점이 핵심

App Router에서 캐시는 보통 아래를 함께 의미합니다.

  • Data Cache: 서버의 fetch 결과 캐시(Next가 확장한 fetch)
  • Full Route Cache: 라우트 결과를 통째로 캐시(정적 최적화된 경우)
  • Router Cache(클라이언트): 클라에서 세그먼트/flight 결과를 재사용

렌더링 폭증의 상당수는 “원래 캐시될 줄 알았는데 동적으로 전환되어 캐시가 꺼짐” 또는 “캐시 키가 달라져서 매번 미스”에서 옵니다.

3-1. 동적 렌더링이 강제되는 대표 트리거

다음 중 하나라도 서버 컴포넌트 경로에서 사용하면 정적 최적화가 깨질 수 있습니다.

  • cookies()
  • headers()
  • 사용자별 데이터를 서버에서 바로 읽는 패턴
  • fetchcache: "no-store"
  • revalidate: 0

아래처럼 headers()를 읽는 순간, 요청마다 달라질 수 있으니 Next는 안전하게 동적으로 처리하려고 합니다.

// app/products/page.tsx
import { headers } from "next/headers";

export default async function Page() {
  const h = headers();
  const ua = h.get("user-agent");

  // 이 페이지는 요청마다 달라질 수 있다고 판단되어
  // 정적/캐시 최적화가 깨질 가능성이 큼
  return <pre>{ua}</pre>;
}

해결책은 "정말로 동적이어야 하는 부분"만 클라로 밀거나, 서버에서 사용자별 변수를 읽더라도 캐시 계층을 명시적으로 설계하는 것입니다.

4) 폭증 진단 1단계: 서버에서 무엇이 몇 번 호출되는지 계측

렌더링 폭증은 감으로 잡으면 오래 걸립니다. 최소한 아래 2가지를 찍어보면 방향이 선명해집니다.

  • RSC 렌더가 몇 번 도는지
  • fetch가 요청당 몇 번 나가는지

4-1. 서버 컴포넌트에서 호출 카운트 로그

서버 컴포넌트는 서버에서 실행되므로 단순 로그도 힌트가 됩니다.

// app/dashboard/page.tsx
export default async function Page() {
  console.log("[RSC] dashboard page render", Date.now());

  return (
    <main>
      <h1>Dashboard</h1>
    </main>
  );
}

개발 환경에서는 HMR/리액트 엄격 모드 영향 등으로 로그가 더 많이 보일 수 있습니다. 반드시 **프로덕션 빌드(next build + next start)**에서도 재현되는지 확인하세요.

4-2. fetch 래퍼로 중복 호출 확인

// lib/fetcher.ts
export async function fetchJSON(input: string, init?: RequestInit) {
  console.log("[fetch]", input, init?.cache, (init as any)?.next);
  const res = await fetch(input, init);
  if (!res.ok) throw new Error("fetch failed");
  return res.json();
}

이걸로 동일 요청이 cache 설정 없이 반복되는지, 또는 search params 때문에 URL이 미묘하게 달라져 캐시 미스가 나는지 확인합니다.

5) 폭증 진단 2단계: fetch 캐시와 revalidate를 명시적으로 설계

5-1. 기본 원칙

  • 서버에서 동일 데이터가 반복 호출된다면, 먼저 fetch 캐시를 의심
  • 사용자별 데이터는 캐시를 공유하면 안 되므로 no-store가 맞지만, 그 경우 폭증을 감수할 수밖에 없음
  • 사용자별이 아닌 공용 데이터라면 revalidate 또는 tag 기반 무효화를 설계

5-2. revalidate로 주기적 갱신

// app/news/page.tsx
import { fetchJSON } from "@/lib/fetcher";

export default async function Page() {
  const items = await fetchJSON("https://api.example.com/news", {
    next: { revalidate: 60 },
  });

  return (
    <ul>
      {items.map((it: any) => (
        <li key={it.id}>{it.title}</li>
      ))}
    </ul>
  );
}

이렇게 하면 60초 동안은 Data Cache를 재사용하고, 이후에 백그라운드로 갱신되는 형태를 기대할 수 있습니다.

5-3. tag 기반 무효화로 "업데이트 시점"에만 갱신

// lib/products.ts
export async function getProducts() {
  const res = await fetch("https://api.example.com/products", {
    next: { tags: ["products"], revalidate: 3600 },
  });
  if (!res.ok) throw new Error("failed");
  return res.json();
}

그리고 관리자 업데이트 후 서버 액션이나 API 라우트에서 무효화:

// app/admin/actions.ts
"use server";

import { revalidateTag } from "next/cache";

export async function invalidateProducts() {
  revalidateTag("products");
}

이 패턴은 "평소에는 캐시"하고 "변경되었을 때만" 확실히 갱신하므로, 렌더링 폭증(정확히는 백엔드 호출 폭증)을 가장 실용적으로 줄입니다.

6) unstable_cache로 RSC 내부 연산/DB 접근을 캐시하기

fetch가 아닌 DB 쿼리, SDK 호출, 무거운 계산도 폭증의 원인이 됩니다. 이때 unstable_cache를 고려합니다.

// lib/userStats.ts
import { unstable_cache } from "next/cache";

async function queryUserStats(userId: string) {
  // DB 또는 외부 SDK 호출
  return { userId, score: 42 };
}

export const getUserStatsCached = unstable_cache(
  async (userId: string) => queryUserStats(userId),
  ["user-stats"],
  { revalidate: 120 }
);

주의점:

  • 사용자별 캐시는 키 설계를 신중히 해야 합니다. 위 예시는 userId가 캐시 키에 반영되도록 호출 인자를 포함한 형태로 사용해야 합니다.
  • 캐시가 꼬이거나 stale mismatch가 의심되면, 앞서 링크한 RSC 캐시 가이드를 함께 확인하는 것이 좋습니다.

7) 클라이언트 리렌더 폭증: useMemo는 "계산"을, 아키텍처는 "전파"를 줄인다

서버 캐시를 잡았는데도 UI가 느리다면, 이제는 Client Component 리렌더를 봐야 합니다.

7-1. useMemo가 효과적인 케이스

  • 렌더마다 반복되는 무거운 계산(정렬, 필터, 그룹핑)
  • 의존성이 안정적인데 매번 새 배열/새 객체를 만들어 하위 컴포넌트가 같이 리렌더되는 경우
// app/products/ProductsClient.tsx
"use client";

import { useMemo, useState } from "react";

type Product = { id: string; name: string; price: number };

export function ProductsClient({ products }: { products: Product[] }) {
  const [q, setQ] = useState("");

  const filtered = useMemo(() => {
    const keyword = q.trim().toLowerCase();
    if (!keyword) return products;

    // 무거운 필터링/정렬이 있다고 가정
    return products
      .filter((p) => p.name.toLowerCase().includes(keyword))
      .sort((a, b) => a.price - b.price);
  }, [products, q]);

  return (
    <section>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      <ul>
        {filtered.map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </section>
  );
}

핵심은 useMemo가 "리렌더를 막는" 도구가 아니라, 리렌더는 하되 비싼 계산을 재사용하는 도구라는 점입니다.

7-2. useMemo로도 안 줄어드는 리렌더: context value 안정화

아래처럼 provider의 value를 매 렌더마다 새 객체로 만들면, 구독 중인 모든 컴포넌트가 연쇄 리렌더될 수 있습니다.

"use client";

import { createContext, useMemo, useState } from "react";

export const Ctx = createContext<{ count: number; inc: () => void } | null>(null);

export function Provider({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);

  const value = useMemo(
    () => ({ count, inc: () => setCount((c) => c + 1) }),
    [count]
  );

  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}

여기서도 포인트는 value의 참조 안정성입니다. 단, inc가 매번 새 함수가 되지 않게 하려면 useCallback을 함께 쓰는 설계가 더 깔끔한 경우가 많습니다.

8) App Router에서 자주 터지는 "폭증" 시나리오 5가지와 처방

8-1. 서버 컴포넌트에서 사용자별 헤더/쿠키를 읽고 공용 데이터까지 동적화

  • 증상: 모든 페이지가 동적으로 변해 캐시가 거의 안 먹음
  • 처방: 사용자별 정보는 클라에서 처리하거나, 사용자별 세그먼트만 분리

8-2. 공용 API인데 no-store가 박혀 있음

  • 증상: 요청당 API 호출이 항상 발생
  • 처방: next: { revalidate: N } 또는 tag 무효화

8-3. search params가 캐시 키를 흔듦

  • 증상: ?t=timestamp 같은 파라미터로 매번 캐시 미스
  • 처방: 의미 없는 파라미터 제거, 또는 서버에서 파라미터를 정규화

8-4. router.refresh() 남발

  • 증상: 입력/토글 같은 UI 이벤트마다 서버가 재렌더
  • 처방: UI state는 클라 state로 처리하고, 서버 동기화는 debounce 또는 mutation 이후 1회만 refresh

8-5. 클라에서 무거운 계산 + 큰 리스트 렌더

9) 재현 가능한 "진단 루틴" 요약

  1. 프로덕션 모드로 재현: next buildnext start
  2. 서버 로그로 RSC 렌더 횟수 확인
  3. fetch 래퍼로 동일 API가 몇 번 나가는지 확인
  4. 동적 렌더 트리거(cookies()/headers()/no-store) 탐지
  5. 공용 데이터는 revalidate 또는 tag 기반 무효화로 전환
  6. DB/SDK 호출은 unstable_cache로 방어
  7. 클라 리렌더는 useMemo로 계산을, context/value 설계로 전파를 줄임

10) 마무리: "캐시"와 "메모"는 서로 다른 층을 최적화한다

  • RSC 캐시(fetch/revalidate/tag/unstable_cache)는 서버 렌더 및 데이터 호출 비용을 줄입니다.
  • useMemo클라이언트 렌더에서의 계산 비용을 줄입니다.

둘을 같은 문제로 취급하면, 예를 들어 서버 호출 폭증을 useMemo로 해결하려고 하거나, 클라 INP 문제를 revalidate로 해결하려고 하면서 시간을 낭비하기 쉽습니다. 폭증을 마주했을 때는 먼저 “어느 층에서 폭증인가”를 분리하고, 그 층에 맞는 도구를 적용하는 것이 가장 빠른 길입니다.

RSC 캐시가 의도와 다르게 동작하거나 stale mismatch가 의심된다면, 케이스별로 더 깊게 들어간 정리도 함께 참고하세요.