React `cache()`와 Next.js `"use cache"` 실전 가이드 — App Router에서 서버 쿼리 중복을 없애는 두 가지 방법
대시보드 성능을 점검하다가 서버 로그에서 이상한 패턴을 발견한 적이 있습니다. 같은 페이지를 렌더링하는 동안 동일한 user 쿼리가 세 번 실행되고 있었습니다. <Header>, <Sidebar>, <ProfileWidget> 각각이 독립적으로 데이터베이스를 조회하고 있었던 것입니다. Redis를 붙이거나 인프라를 손댈 필요도 없이, 코드 레벨에서 바로 해결할 수 있는 문제였습니다.
이 글은 Next.js App Router를 사용하는 프론트엔드 개발자를 대상으로 합니다. Prisma 또는 유사한 ORM 사용 경험과 Server Component/Client Component의 기본 개념이 있다면 바로 적용할 수 있는 내용을 담았습니다.
이 글을 읽고 나면 React의 cache()와 Next.js의 "use cache" 디렉티브를 목적에 맞게 구분해 사용하고, 태그 기반 캐시 무효화로 불필요한 서버 부하를 줄이는 방법을 이해하게 됩니다.
핵심 개념
React cache() — 단일 요청 내 메모이제이션
cache()는 React 18에서 실험적으로 도입되어 React 19에서 안정 API로 확정된 함수입니다. Server Component 환경에서 함수 호출 결과를 메모이제이션하며, 같은 요청(렌더 트리) 안에서 동일한 인자로 호출된 함수의 결과를 재사용합니다. 데이터베이스 조회나 외부 API 호출처럼 비용이 큰 함수를 여러 컴포넌트가 동시에 요청해도, 실제 실행은 딱 한 번만 이루어집니다.
// lib/user.ts
import { cache } from 'react';
import { prisma } from '@/lib/db';
export const getUserById = cache(async (id: string) => {
console.log(`DB 쿼리 실행: ${id}`); // 같은 요청 내에서 한 번만 출력됩니다
return await prisma.user.findUnique({ where: { id } });
});이제 <UserAvatar>와 <UserProfile> 컴포넌트가 같은 페이지에서 동시에 getUserById('123')을 호출해도 DB 쿼리는 단 한 번만 실행됩니다.
메모이제이션(Memoization): 함수 호출 결과를 캐싱해두고, 동일한 입력이 들어오면 캐싱된 값을 반환하는 최적화 기법입니다.
cache()의 캐시는 서버 요청이 끝나면 자동으로 초기화되므로, 요청 간 데이터 누수 걱정 없이 사용할 수 있습니다.
객체를 인자로 넘길 때의 함정
cache()는 인자를 참조 동일성(reference equality) 으로 비교합니다. 매번 새로운 객체 리터럴을 인자로 넘기면 캐시가 전혀 적중하지 않습니다.
// ❌ 매 렌더마다 새 객체가 생성되어 캐시 미스 발생
const user = await getUser({ id: '123' });
// ✅ 원시값(string, number)을 인자로 사용하면 캐시가 정상 적중합니다
const user = await getUserById('123');캐시 함수의 인자는 가능하면 문자열이나 숫자 같은 원시 타입으로 설계하는 것을 권장합니다.
Next.js "use cache" — 요청을 넘나드는 서버 캐싱
"use cache" 디렉티브는 Next.js 15/16에서 안정화된 기능으로, "use server", "use client"처럼 파일 또는 함수·컴포넌트 최상단에 선언합니다. 해당 단위의 실행 결과를 요청 간에도 지속되는 캐시에 저장하며, 컴파일러가 인자·클로저 값을 자동으로 캐시 키에 포함하기 때문에 키 충돌을 직접 관리할 필요가 없습니다.
중요:
"use cache"는 Server Component와 서버 함수에서만 동작합니다. Client Component에서 선언하면 오류가 발생하므로, 서버-클라이언트 경계를 먼저 확인한 뒤 적용하는 것이 좋습니다.
// components/ProductCard.tsx
import { cacheLife, cacheTag } from 'next/cache';
async function ProductCard({ id }: { id: string }) {
'use cache';
cacheLife('hours'); // stale/revalidate/expire 프로파일 설정
cacheTag(`product-${id}`); // 태그 기반 선택적 무효화
const product = await fetchProduct(id);
return <div>{product.name}</div>;
}캐시 경계(Cache Boundary):
"use cache"가 선언된 컴포넌트나 함수의 경계를 말합니다. 이 경계 내부의 실행 결과 전체가 직렬화되어 캐시 저장소에 저장되며, 동일한 입력이 들어오면 실행 없이 캐시에서 바로 반환됩니다.
cacheLife 기본 프로파일 — stale / revalidate / expire 매핑
cacheLife()는 stale, revalidate, expire 세 값을 한꺼번에 설정하는 편의 API입니다. Next.js가 제공하는 기본 프로파일은 다음과 같습니다.
| 프로파일 | stale | revalidate | expire |
|---|---|---|---|
'seconds' |
0초 | 1초 | 1초 |
'minutes' |
5분 | 1분 | 1시간 |
'hours' |
15분 | 1시간 | 1일 |
'days' |
1시간 | 1일 | 1주 |
'weeks' |
1일 | 1주 | 1개월 |
'max' |
1일 | 1개월 | 무제한 |
기본 프로파일 외에 커스텀 프로파일도 정의할 수 있습니다.
// next.config.ts
const nextConfig = {
experimental: {
cacheLife: {
'product-list': {
stale: 60, // 1분
revalidate: 300, // 5분
expire: 3600, // 1시간
},
},
},
};두 API의 관계 — 상호 보완적인 레이어
두 API는 캐시 유효 범위가 완전히 다릅니다.
| 구분 | cache() |
"use cache" |
|---|---|---|
| 캐시 범위 | 단일 요청 내 (in-request) | 요청 간 지속 (cross-request) |
| 초기화 시점 | 요청 종료 시 자동 초기화 | revalidateTag 또는 TTL 만료 시 |
| 표준 여부 | React 공식 API (React 19 stable) | Next.js 전용 디렉티브 |
| 주요 목적 | 단일 요청 내 중복 함수 호출 제거 | 서버·엣지 레벨 지속 캐싱 |
| 사용 위치 | Server Component, 라이브러리 함수 | 서버 컴포넌트, 서버 함수 최상단 |
두 API를 함께 사용하면 더 효과적입니다. cache()로 요청 내 중복 호출을 제거하고, "use cache"로 요청 간 결과를 지속 캐싱하면 서버 부하를 두 단계에 걸쳐 최소화할 수 있습니다.
이제 이 두 개념이 실제로 어떻게 결합되는지 코드로 살펴보겠습니다.
실전 적용
예시 1: 여러 컴포넌트가 같은 데이터를 요청하는 경우 (cache())
대시보드 페이지에서 <Header>, <Sidebar>, <ProfileWidget> 세 컴포넌트가 모두 현재 사용자 정보를 필요로 하는 상황입니다. cache() 없이 구현하면 DB 쿼리가 세 번 실행됩니다.
// lib/auth.ts
import { cache } from 'react';
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/session';
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session?.userId) return null;
return await prisma.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, email: true, avatarUrl: true },
});
});// app/dashboard/page.tsx
import { Header } from '@/components/Header';
import { Sidebar } from '@/components/Sidebar';
import { ProfileWidget } from '@/components/ProfileWidget';
export default async function DashboardPage() {
return (
<div>
<Header /> {/* 내부에서 getCurrentUser() 호출 */}
<Sidebar /> {/* 내부에서 getCurrentUser() 호출 */}
<ProfileWidget /> {/* 내부에서 getCurrentUser() 호출 */}
</div>
// → 실제 DB 쿼리는 단 한 번만 실행됩니다
);
}| 포인트 | 설명 |
|---|---|
| 인자 없는 호출 | getCurrentUser()는 인자가 없으므로, 요청당 전역으로 한 번만 실행됩니다 |
| props drilling 제거 | 상위에서 user를 받아 내려줄 필요 없이 각 컴포넌트가 독립적으로 호출합니다 |
| 자동 캐시 정리 | 요청이 끝나면 캐시가 초기화되어 다음 요청에서 최신 데이터를 보장합니다 |
예시 2: 상품 페이지 캐싱과 선택적 무효화 ("use cache" + cacheTag)
상품 정보는 자주 바뀌지 않지만, 재고가 변경되면 해당 상품 캐시만 즉시 갱신이 필요한 상황입니다. cacheTag와 revalidateTag를 조합하면 전체 캐시를 날리지 않고 특정 상품만 정밀하게 무효화할 수 있습니다.
// components/ProductDetail.tsx
import { cacheLife, cacheTag } from 'next/cache';
import { fetchProduct } from '@/lib/api';
async function ProductDetail({ productId }: { productId: string }) {
'use cache';
cacheLife('hours'); // stale 15분, revalidate 1시간, expire 1일
cacheTag(`product-${productId}`); // 상품별 태그 부여
const product = await fetchProduct(productId);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>재고: {product.stock}개</span>
</article>
);
}// app/actions/inventory.ts
'use server';
import { revalidateTag } from 'next/cache';
import { prisma } from '@/lib/db';
export async function updateStock(productId: string, stock: number) {
await prisma.product.update({
where: { id: productId },
data: { stock },
});
// 해당 상품 캐시만 선택적으로 무효화됩니다
revalidateTag(`product-${productId}`);
}| 포인트 | 설명 |
|---|---|
cacheLife('hours') |
사전 정의된 프로파일로 stale/revalidate/expire를 한 번에 설정합니다 |
cacheTag(tag) |
렌더링 시 캐시 항목에 태그를 부여합니다 |
revalidateTag(tag) |
Server Action에서 해당 태그가 붙은 캐시 항목만 무효화합니다 |
예시 3: PPR 패턴으로 블로그 페이지 구성하기
정적 콘텐츠(본문, 제목)는 캐시에서 즉시 반환하고, 동적 콘텐츠(댓글, 조회수)는 Suspense로 스트리밍하는 Partial Prerendering(PPR) 패턴입니다.
Next.js 15부터 params가 Promise 타입으로 변경되었으므로 반드시 await로 처리해야 합니다. 이전 방식(params.slug 직접 접근)은 Next.js 15 이상에서 오류가 발생합니다.
// app/blog/[slug]/page.tsx
import { Suspense } from 'react';
import { CachedPostContent } from '@/components/CachedPostContent';
import { DynamicComments } from '@/components/DynamicComments';
import { ViewCounter } from '@/components/ViewCounter';
export default async function BlogPage({
params,
}: {
params: Promise<{ slug: string }>; // Next.js 15+: params는 Promise 타입
}) {
const { slug } = await params; // 반드시 await로 unwrap
return (
<>
{/* 정적 셸: 캐시에서 즉시 반환 */}
<CachedPostContent slug={slug} />
{/* 동적 콘텐츠: Suspense로 스트리밍 */}
<Suspense fallback={<p>댓글 불러오는 중...</p>}>
<DynamicComments slug={slug} />
</Suspense>
<Suspense fallback={<p>-</p>}>
<ViewCounter slug={slug} />
</Suspense>
</>
);
}// components/CachedPostContent.tsx
import { cacheLife, cacheTag } from 'next/cache';
import { fetchPost } from '@/lib/cms';
async function CachedPostContent({ slug }: { slug: string }) {
'use cache';
cacheLife('days');
cacheTag(`post-${slug}`);
const post = await fetchPost(slug);
return (
<main>
<h1>{post.title}</h1>
{/*
CMS에서 받은 HTML을 렌더링할 때는 XSS 위험에 주의가 필요합니다.
신뢰할 수 없는 출처의 콘텐츠라면 DOMPurify 같은 라이브러리로
sanitize한 후 사용하는 것을 권장합니다.
*/}
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</main>
);
}Partial Prerendering(PPR): 정적으로 렌더링 가능한 부분(셸)을 먼저 캐시에서 반환하고, 동적인 부분은 스트리밍으로 채워 넣는 Next.js의 하이브리드 렌더링 전략입니다.
"use cache"디렉티브와 결합하면 캐시된 정적 셸을 즉시 보여주고 동적 콘텐츠는 그 뒤에 채울 수 있어, 체감 로딩 속도를 크게 개선할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 선언적 캐싱 | 디렉티브 한 줄로 캐시 경계를 명시할 수 있어 코드 가독성이 향상됩니다 |
| 자동 캐시 키 생성 | 인자·클로저 값을 컴파일러가 자동으로 캐시 키에 포함하여 키 충돌 오류를 최소화합니다 |
| 세분화된 무효화 | cacheTag + revalidateTag로 특정 데이터만 선택적으로 갱신할 수 있습니다 |
| PPR 통합 | 정적 셸 즉시 반환 + 동적 스트리밍으로 체감 성능(LCP)을 개선할 수 있습니다 |
| 서버·클라이언트 통합 | 서버 캐시와 클라이언트 내비게이션 캐시가 단일 설정으로 연동됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 서버리스 환경 한계 | 인메모리 캐시는 인스턴스 간 공유되지 않아 캐시 미스가 잦을 수 있습니다 | next.config.ts의 cacheHandler로 Redis, Upstash 등 외부 캐시 스토어를 연결합니다 |
| 동적 값 접근 불가 | cookies(), searchParams 등 런타임 값은 "use cache" 내부에서 직접 접근할 수 없습니다 |
런타임 값을 인자로 전달하거나, 캐시 경계 밖에서 처리합니다 |
| 민감 데이터 노출 위험 | 퍼블릭 캐시에 사용자별 개인화 데이터가 포함될 수 있습니다 | 개인화 콘텐츠는 캐시 경계 밖으로 분리하거나, 사용자 ID를 cacheTag에 포함해 격리합니다 |
| 디버깅 복잡도 | 다층 캐시(메모리·원격·클라이언트) 적용 시 무효화 추적이 어려울 수 있습니다 | 태그를 일관된 네이밍 규칙(entity-id 형식)으로 관리합니다 |
| Next.js 전용 | "use cache" 디렉티브는 Next.js에 종속적이며 React 표준 API가 아닙니다 |
프레임워크 독립적인 코드에는 React 표준 cache()를 사용합니다 |
| 객체 인자의 캐시 미스 | cache()는 참조 동일성으로 인자를 비교하므로 객체 리터럴 인자는 캐시에 적중하지 않습니다 |
인자를 원시 타입(string, number)으로 설계합니다 |
실무에서 가장 흔한 실수
"use cache"내부에서cookies()나headers()를 직접 호출하는 경우: 런타임 요청 정보는 캐시 경계 내에서 접근할 수 없습니다. 이 값들을 캐시 경계 밖에서 읽어 인자로 전달하는 방식으로 분리하면 정상적으로 동작합니다.- 개인화 데이터에 퍼블릭 캐시를 적용하는 경우: 로그인한 사용자의 장바구니나 추천 상품을 퍼블릭 캐시에 저장하면 다른 사용자에게 노출될 위험이 있습니다. 개인화 콘텐츠는 캐시 경계 밖에서 렌더링하거나, 사용자 ID를
cacheTag에 포함해cacheTag(user-cart-${userId})형태로 격리하는 것이 좋습니다. cache()와"use cache"를 혼동하는 경우: 두 API는 캐시 유지 범위가 전혀 다릅니다.cache()는 요청이 끝나면 초기화되고,"use cache"는 설정한 TTL 또는revalidateTag호출 전까지 지속됩니다. 예를 들어 세션마다 달라져야 하는 개인화 데이터를"use cache"로 캐싱하면 의도치 않게 이전 사용자의 데이터가 노출될 수 있어, 용도에 맞게 구분해 사용하는 것이 중요합니다.
마치며
React cache()로 요청 내 중복 호출을 제거하고, Next.js "use cache"로 요청 간 캐싱을 선언하면, 코드 복잡도를 높이지 않고도 서버 렌더링 성능을 실질적으로 개선할 수 있습니다.
두 API를 동시에 도입할 필요는 없습니다. 작은 것부터 시작하는 것을 권장합니다.
지금 바로 시작할 수 있는 3단계:
cache()로 공통 데이터 함수부터 감싸는 것으로 시작합니다. 여러 컴포넌트에서 동일하게 호출되는getUserById,getConfig같은 함수에import { cache } from 'react'를 적용하고, 서버 로그에서 실행 횟수가 줄어드는 것을 확인할 수 있습니다.- 자주 변경되지 않는 컴포넌트에
"use cache"를 선언합니다. Next.js 15 이상이라면next.config.ts에experimental: { dynamicIO: true }를 추가한 뒤 (Next.js 16에서는 기본값으로 전환 예정), 글로벌 내비게이션이나 푸터 컴포넌트 최상단에'use cache'와cacheLife('days')를 선언하는 것으로 시작할 수 있습니다. cacheTag+revalidateTag로 데이터 변경 흐름을 연결합니다. 캐시 컴포넌트에cacheTag('my-data')를 추가하고, 데이터를 변경하는 Server Action에서revalidateTag('my-data')를 호출하면 정밀한 캐시 무효화 사이클이 완성됩니다.
더 나아가 탐색해볼 주제로는 Redis 또는 Upstash를 활용한 외부 캐시 핸들러 연결, cacheLife 커스텀 프로파일 정의, 그리고 태그 네이밍 전략을 팀 규약으로 표준화하는 방법 등이 있습니다.
다음 글: Partial Prerendering(PPR) 심층 분석 — 정적 셸과 동적 스트리밍을 조합해 First Contentful Paint를 극한까지 줄이는 방법
참고 자료
- React 공식 문서 — cache()
- Next.js 공식 문서 — use cache 디렉티브
- Next.js 공식 문서 — cacheLife
- Next.js 공식 문서 — cacheTag
- Next.js 공식 문서 — Cache Components 시작하기
- Next.js 공식 문서 — cacheComponents 설정
- Next.js 블로그 — Composable Caching
- Next.js 블로그 — Our Journey with Caching
- Vercel Academy — Cache Components for Instant and Fresh Pages
- LogRocket Blog — Cache components in Next.js
- Prismic Blog — Next.js Cache Components: 4 Ways to Balance Speed and Cost
- DEV Community — Next.js Caching Explained: Every Strategy You Need to Know
- Strapi Blog — Mastering Next.js 15 Caching: dynamicIO, "use cache" & More
- Next.js 16 공식 릴리즈 노트