- Published on
Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo 오용
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그를 보면 특정 페이지 요청마다 동일한 쿼리와 동일한 컴포넌트 렌더가 반복되고, 클라이언트에서는 입력 한 번에 리스트 전체가 재렌더링되는 현상이 겹쳐 보이는 경우가 많습니다. Next.js App Router에서는 이 현상이 단순히 React.memo를 안 써서가 아니라, RSC(React Server Components) 캐시 정책과 Client Component 경계 설정, 그리고 useMemo/useCallback의 오용이 합쳐져 발생하는 경우가 많습니다.
이 글에서는 “왜 렌더가 폭증하는지”를 서버(RSC)와 클라이언트(React)로 나눠 원인별로 재현하고, 실제로 고칠 때 어떤 순서로 접근해야 하는지 정리합니다. 프론트 성능 분석 관점은 Chrome INP 폭증? Long Task 추적·분해 실전도 함께 보면 연결이 잘 됩니다.
1) App Router에서 말하는 “렌더”는 두 종류다
App Router에서 렌더링 폭증을 말할 때, 최소 두 레이어를 분리해야 합니다.
- 서버 측: RSC 렌더(서버 컴포넌트 실행) + 데이터 패칭(
fetch, DB 쿼리) - 클라이언트 측: Client Component 렌더(상태 변경, props 변경, context 변경)
서버 렌더가 폭증하면 보통 다음이 같이 나타납니다.
- 동일 요청에 대해 서버 컴포넌트가 매번 재실행
- 동일 API를 매번 호출(캐시 미적용)
revalidate: 0또는dynamic = "force-dynamic"로 강제 동적 처리
클라이언트 렌더가 폭증하면 보통 다음이 나타납니다.
- 입력/스크롤/타이머 한 번에 자식 전체가 재렌더
useMemo를 썼는데도 렌더 수가 줄지 않음useEffect의존성에 객체/함수가 들어가 매번 재실행
핵심은 “서버 캐시로 해결할 문제를 클라이언트 메모이제이션으로 풀려고 하거나”, 반대로 “클라이언트 렌더 문제를 RSC 캐시로 착각”하는 실수를 피하는 것입니다.
2) 서버(RSC) 렌더 폭증: 캐시가 깨지는 대표 패턴
2.1 fetch 기본 캐시를 스스로 무효화하는 경우
App Router의 서버 컴포넌트에서 fetch는 기본적으로 캐시가 적용될 수 있지만, 다음 옵션을 주면 사실상 요청마다 다시 가져옵니다.
cache: "no-store"next: { revalidate: 0 }- 페이지/레이아웃에서
export const dynamic = "force-dynamic"
예시(의도치 않은 폭증):
// app/products/page.tsx
export const dynamic = "force-dynamic";
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
cache: "no-store",
});
return res.json();
}
export default async function Page() {
const products = await getProducts();
return <pre>{JSON.stringify(products, null, 2)}</pre>;
}
위 조합은 “항상 최신 데이터”를 원할 때는 맞지만, 대부분의 목록 페이지에서는 과합니다. 결과적으로 트래픽이 조금만 늘어도 서버 렌더와 백엔드 호출이 같이 폭증합니다.
개선(타협 가능한 갱신 주기 도입):
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
return res.json();
}
여기서 중요한 포인트는 useMemo로는 이 문제를 절대 해결할 수 없다는 점입니다. 서버 요청 자체가 매번 새로 발생하면, 클라이언트에서 아무리 메모이제이션해도 서버 비용은 그대로입니다.
2.2 headers()/cookies() 사용으로 정적 최적화가 깨지는 경우
서버 컴포넌트에서 cookies()나 headers()를 읽는 순간, 해당 경로는 요청별로 달라질 수 있다고 판단되어 동적 처리로 전환되는 경우가 많습니다.
import { cookies } from "next/headers";
export default async function Page() {
const cookieStore = cookies();
const theme = cookieStore.get("theme")?.value;
// theme 읽는 것만으로도 캐시/정적 최적화에 영향
return <div>theme: {theme}</div>;
}
대안은 “정말로 요청별이어야 하는 부분”만 Client Component로 밀어내거나, 서버에서 읽되 캐시 전략을 명시하는 것입니다.
2.3 Server Component에서의 중복 패칭
서버 컴포넌트 트리 여러 곳에서 같은 데이터를 각각 가져오면, 렌더 1회당 쿼리가 N번 발생할 수 있습니다. Next.js는 동일 fetch에 대해 dedupe가 동작할 수 있지만, URL이 조금이라도 달라지거나 옵션이 다르면 다른 요청으로 취급됩니다.
- 쿼리스트링 순서가 다름
headers가 요청마다 다름cache옵션이 다름
해결책은 “데이터 패칭을 상위로 끌어올려 props로 내려주는 것”과 “요청 키를 완전히 동일하게 만드는 것”입니다.
3) 클라이언트 렌더 폭증: useMemo가 오히려 독이 되는 경우
useMemo는 마법이 아니라 “비싼 계산 결과를 의존성이 같을 때 재사용”하는 도구입니다. 렌더 자체를 막아주지 않습니다. 렌더를 막으려면 컴포넌트 경계에서 memo와 안정적인 props가 필요합니다.
3.1 useMemo 의존성에 매번 바뀌는 객체가 들어가는 경우
아래 코드는 흔히 “필터 객체를 메모이제이션했다”는 착각을 만듭니다.
"use client";
import { useMemo, useState } from "react";
export function List() {
const [q, setQ] = useState("");
const filter = useMemo(() => ({ q }), [{ q }]); // 잘못된 패턴
// filter는 매 렌더마다 새 객체가 될 가능성이 높고,
// 의존성 배열도 형태가 잘못되면 효과가 없다.
return (
<div>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<Child filter={filter} />
</div>
);
}
function Child({ filter }: { filter: { q: string } }) {
// filter가 바뀌었다고 판단되어 매번 렌더
return <div>{filter.q}</div>;
}
수정:
"use client";
import { memo, useMemo, useState } from "react";
export function List() {
const [q, setQ] = useState("");
const filter = useMemo(() => ({ q }), [q]);
return (
<div>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<Child filter={filter} />
</div>
);
}
const Child = memo(function Child({ filter }: { filter: { q: string } }) {
return <div>{filter.q}</div>;
});
하지만 여기서도 “입력할 때마다 q가 바뀌니 Child는 어차피 렌더”입니다. 즉, useMemo가 의미 있으려면 Child가 q 변화와 무관하게 비싼 계산을 하고 있고, 그 계산이 q와 무관한 경우여야 합니다.
3.2 useMemo로 비싼 계산을 감췄는데, 렌더 병목은 다른 곳인 경우
예를 들어 리스트 필터링이 비싸다고 생각해 useMemo를 붙였지만, 실제로는 다음이 병목일 수 있습니다.
- 리스트 아이템 컴포넌트가 너무 무겁고, key가 불안정
- 입력 이벤트마다 상태가 상위까지 올라가 전체 트리가 리렌더
useEffect가 매번 실행되며 state를 다시 set
이때는 React DevTools Profiler로 “무슨 컴포넌트가 실제로 오래 걸리는지” 먼저 확인해야 합니다. Long Task까지 이어진다면 Chrome INP 폭증? Long Task 추적·분해 실전의 방식처럼 브라우저 레벨에서 태스크를 쪼개 원인을 좁히는 게 빠릅니다.
3.3 useCallback도 마찬가지로 “의존성”이 핵심
useCallback을 남발하면 오히려 코드만 복잡해지고, 의존성이 매번 바뀌면 함수도 매번 새로 만들어집니다.
const onClick = useCallback(() => {
doSomething(selected);
}, [selected]);
selected가 자주 바뀌면 onClick도 자주 바뀌고, 그 함수가 props로 내려가면 자식 렌더를 다시 유발합니다. 이 문제는 보통 “상태 위치 조정(상태를 더 아래로)” 또는 “이벤트를 자식 내부로 이동”으로 해결하는 편이 낫습니다.
4) RSC 캐시와 Client Component 경계: 폭증을 만드는 구조적 실수
4.1 Client Component가 너무 위에 있으면 서버 캐시 이점을 잃는다
App Router에서는 Server Component가 기본이고, 필요한 곳만 Client Component로 두는 게 성능적으로 유리합니다. 그런데 레이아웃 최상단에 "use client"를 붙이면 하위 전체가 클라이언트로 내려가면서 RSC의 장점을 크게 잃습니다.
나쁜 예:
// app/layout.tsx
"use client";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html><body>{children}</body></html>;
}
개선 방향:
- 레이아웃은 서버로 유지
- 상호작용이 필요한 작은 위젯만 Client로 분리
- 데이터 패칭은 가능한 서버에서, UI 상태는 필요한 범위에서만
4.2 revalidateTag/revalidatePath를 남발해 캐시를 계속 깨는 경우
태그 기반 무효화는 강력하지만, 너무 넓게 무효화하면 트래픽이 몰릴 때마다 캐시가 계속 깨져 “항상 미스”처럼 동작할 수 있습니다.
- 태그를 엔티티 단위로 너무 크게 잡음(예:
products하나로 통일) - 특정 사용자 액션이 전체 목록 캐시를 계속 무효화
권장 패턴은 “태그를 더 잘게 쪼개고”, “정말 필요한 경로만 무효화”하는 것입니다.
5) 실전 진단 체크리스트: 어디서부터 볼 것인가
5.1 서버 쪽(요청마다 다시 도는지)부터 확인
- 동일 URL을 새로고침했을 때 서버 로그에서 컴포넌트/쿼리가 매번 실행되는가
fetch에cache: "no-store"또는revalidate: 0가 섞여 있는가dynamic = "force-dynamic"가 불필요하게 켜져 있는가cookies()/headers()사용이 캐시를 깨고 있지 않은가- 같은 데이터를 여러 컴포넌트에서 중복으로 가져오지 않는가
서버 비용이 폭증하는데 클라이언트에서 useMemo를 만지는 건, DB 데드락을 애플리케이션 재시도로만 덮는 것과 비슷한 접근이 됩니다. 병목이 서버면 서버 캐시/쿼리/동시성부터 잡아야 합니다. 데이터베이스 관점의 장애 진단 흐름은 PostgreSQL deadlock detected 진단·해결 9단계 같은 “원인 분해 순서”가 참고가 됩니다.
5.2 클라이언트 쪽(상태 변경 한 번에 어디가 다시 그려지는지) 확인
- React DevTools Profiler로 커밋마다 가장 오래 걸린 컴포넌트 확인
- 상위 Client Component 하나가 너무 많은 상태를 쥐고 있지 않은지 확인
- props로 내려가는 객체/함수의 참조 안정성 확인
useEffect가 매번 실행되며 state를 set하는 루프가 없는지 확인- 리스트라면 key 안정성, 가상 스크롤 도입 여부 검토
6) 패턴별 처방: “캐시”와 “메모이제이션”을 분리해서 적용
6.1 데이터는 서버에서 캐시, UI는 클라이언트에서 지역화
- 목록/상세 데이터: 서버 컴포넌트에서
fetch+revalidate로 캐시 - 검색어/필터/정렬 같은 UI 상태: 해당 위젯 컴포넌트 내부로 state 이동
예시 구조:
// app/products/page.tsx (Server Component)
import { ProductsClient } from "./products-client";
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function Page() {
const products = await getProducts();
return <ProductsClient initialProducts={products} />;
}
// app/products/products-client.tsx (Client Component)
"use client";
import { useMemo, useState } from "react";
type Product = { id: string; name: string };
export function ProductsClient({ initialProducts }: { initialProducts: Product[] }) {
const [q, setQ] = useState("");
const filtered = useMemo(() => {
const query = q.trim().toLowerCase();
if (!query) return initialProducts;
return initialProducts.filter((p) => p.name.toLowerCase().includes(query));
}, [initialProducts, q]);
return (
<section>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" />
<ul>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</section>
);
}
이 패턴의 장점은 다음과 같습니다.
- 서버는 60초 단위로 캐시되어 렌더/패칭 폭증을 완화
- 클라이언트는 검색어 입력 때만 필터링 비용 발생
useMemo는 “비싼 계산(필터링)”에만 제한적으로 사용
6.2 useMemo를 “렌더 최적화 만능”으로 쓰지 말 것
- 렌더를 줄이려면
memo와 props 안정성이 먼저 - 계산을 줄이려면
useMemo - 이벤트 핸들러 참조를 안정화하려면
useCallback이지만, 상태 위치 조정이 더 큰 효과를 내는 경우가 많음
7) 마무리: 폭증은 대개 경계 설정 문제다
App Router에서 렌더링 폭증은 보통 한 가지 원인으로만 터지지 않습니다.
- 서버에서는 RSC 캐시 경계가 깨져 요청마다 재실행
- 클라이언트에서는 상태가 상위에 몰려 한 번의 입력이 전체 렌더로 전파
- 그 와중에
useMemo/useCallback을 “안정성 없는 의존성”으로 남발해 효과는 없고 복잡도만 증가
해결 순서는 단순합니다.
- 서버에서 요청마다 다시 도는지(캐시 정책, 동적 전환 요인)부터 잡기
- Client Component 경계를 아래로 내리고, 상태 범위를 줄이기
- 마지막으로 정말 비싼 계산에만
useMemo를 제한적으로 적용하기
이 순서대로 접근하면 “렌더 폭증”이라는 모호한 증상이 서버 비용, 네트워크, 클라이언트 CPU 중 어디에서 발생하는지 분리되고, 최적화도 재현 가능하게 진행할 수 있습니다.