Next.js 16 Cache Component 종합 사용기 - PPR부터 use cache까지 직접 사용하고 풀어보자
대망의 Nextjs 16도래
이번에 주된 내용으로는 아무래도 ReactCompiler, Cache Component, PPR의 정식 지원이 주된 내용이 아닌가 싶다.
마침 시간이 나서 오늘 하루 열심히 16버전으로 새로운 블로그를 개발하다가 느낀점을 적어보려한다
React, Vercel팀의 현재 개발 방향
내가 개발에 발을 담그기 시작한 2022년경, 그때는 SEO개념도 모르고
nextjs, nuxtjs를 보면서 서버사이드 렌더링이니 캐싱이니 뭐니 하면서 의문을 가진적이 있다
2023년 중순에 nextjs를 접하고
"server를 따로 안띄워도 된다고 ...?" 라는 혁명적인.. 그런 호감을 가지고 nextjs를 시작했고
2024년에는 app router가 본격적으로 올라오면서 getServerSideProps가 비동기 컴포넌트로 넘어오고 ..
이제 2025년 말, Cache Component를 보니 방향성이 보이기 시작했다
아마 React, Next팀들은 데이터와 인터렉션의 완벽한 분리가 목표가 아닌가 싶다
- Data, Caching 관련 로직은 Server에서 담당하여 서빙한다
- React 19에서 hydrateRoot가 개선되면서, 서버에서 렌더링된 HTML에 필요한 부분만 선택적으로 hydration하는 방향으로 발전
- 이에 따라 더욱더 동적인 rendering 보장!
이런느낌이 매우강하다
실제로 이번에 nextjs 16으로 개발하면서 특히 많이 느낀 것 같다.
이 이후에는 대체 뭐가 나올지 궁금하긴한데 .. 뭐 일단 cache component에 익숙해지기로 해보자
PPR + Cache Component
PPR의 개념자체는 이해하고 있었지만 이걸 어떻게 캐싱하고 어떻게 서빙하는지에 대해서
프론트 코드로는 어떻게 풀어내는지 매우 궁금했었다
(사실 15버전부터 가능했는데 시간이 없어서 못했다 흑흑..)
일단 기존 코드를 확인해보자
기존 서버사이드 렌더링 페이지
import { GetPostListParams } from '@entities/post'
import { PostList } from '@widgets/post/post-list'
import { FC, Suspense } from 'react'
interface HomeProps {
searchParams: Promise<GetPostListParams>
}
const Home: FC<HomeProps> = async ({ searchParams }) => {
const posts = await getPostList(await searchParams)
return (
<main className='px-2 lg:px-0'>
<PostList posts={posts} />
</main>
)
}
export default Home
import { type Post } from '@entities/post'
import { PostCard } from '@features/post'
import { FC } from 'react'
interface PostListProps {
posts: Post[]
}
export const PostList: FC<PostListProps> = ({ posts }) => {
return (
<section className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
{posts.map((post) => (
<PostCard key={post.postId} post={{ ...post, description: '' }} />
))}
</section>
)
}
아주 간단한 PostList를 서버사이드 렌더링으로 뿌리는 코드이다.
Home이라는 home.tsx는 app/ 폴더에 있으며 페이지로 작동한다.
극히 .. 평범한 SSR이 적용된 기본 예시라고 볼수있는데 이제 Cache Component로 가면 구조를 약간 달리해야한다.
Cache Component가 결합된 SSR페이지
import { GetPostListParams } from '@entities/post'
import { PostList } from '@widgets/post/post-list'
import { FC, Suspense } from 'react'
interface HomeProps {
searchParams: Promise<GetPostListParams>
}
const Home: FC<HomeProps> = async ({ searchParams }) => {
return (
<main className='px-2 lg:px-0'>
<Suspense>
<PostList searchParams={searchParams} />
</Suspense>
</main>
)
}
export default Home
import { GetPostListParams } from '@entities/post'
import { getPostList } from '@entities/post/post.repository'
import { PostCard } from '@features/post'
import { FC } from 'react'
interface PostListProps {
searchParams: Promise<GetPostListParams>
}
export const PostList: FC<PostListProps> = async ({ searchParams }) => {
const posts = await getPostList(await searchParams) // 함수에는 'use cache' 지시문이 적용되어있음
return (
<section className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
{posts.map((post) => (
<PostCard key={post.postId} post={{ ...post, description: '' }} />
))}
</section>
)
}
이렇게 변하게 된다.
그래서 차이가 ?
아마 코드만 보고 차이를 알아차리긴 조금 힘들수도있다
"그냥 예전 서버사이드 페이지에서 변수를 그냥 내려서 변수안에서 캡슐화한거아니야?"
라고 생각했다면 매우 정확하다 사실 이게 다인 개념이다.
먼저 두 컴포넌트를 정리하고 넘어가보자
기존 SSR 방식
- 사용자 요청
- searchParams await (페이지 블로킹)
- getPostList 실행 (페이지 블로킹)
- 모든 데이터 준비 완료 → HTML 전송
- 사용자가 페이지 확인
- 총 대기 시간: DB 쿼리 시간 + HTML 생성 시간
Cache Component 방식
- 사용자 요청
- 페이지 shell 즉시 HTML 전송 (searchParams 기다리지 않음)
- Suspense fallback 표시
- PostList 컴포넌트만 별도로 스트리밍 전송
- PostList 영역만 교체
- 사용자는 즉시 페이지 구조를 보고, 데이터만 기다림
- 캐시 구성 요소를 활성화하면 Next.js는 기본적으로 모든 경로를 동적 으로 처리합니다.
이런 이야기가 있다
개인적으로 개발하면서 저 말이 무슨말인가 자세히 생각해보니
- Page가 렌더링 되는 시점에서 알수있는 정보들 (동적인 정보들) 을 처리하기위해
정적인 자원에서는 동적인 정보를 쓰지 말라는 뜻으로 해석할 수 있다
이정도로 해석되더라.
예를들어서 예전의 서버사이드 렌더링 방식에서는
페이지의 렌더링 시점이 되어야만 알 수 있는 headers, searchParams, cookie 등은
페이지 진입점부터 await로 페이지 최상단에서 로드되어 전파되었다
하지만 이제 이 방식을 완벽하게 틀어버리겠다는 말이다
내가 searchParams를 예시로 가져온 이유가 여기있다. 이전 15버전부터 들어온 내용으로
useSearchParams를 사용한 컴포넌트는 "Suspense"를 사용하여 해당 컴포넌트 상태를 렌더링 시점에 정하라는 지침이 있었다
이걸 이제 서버사이드에서도 활용하여 시점을 컴포넌트 안으로 강제하고 렌더링된
컴포넌트에 대한 caching 상태를 표시하겠다는 뜻이다
왜 이런 식으로 렌더링하게 변하게 되었는가 ! 하면 또 공식문서에 그 이유가 나와있다
- Cache Components가 출시되기 전에 Next.js는 전체 페이지를 자동으로 정적으로 최적화하려고 했는데 , 이로 인해 동적 코드를 추가할 때 예기치 않은 동작이 발생할 수 있었습니다.
허허..
15에 너무 익숙해진 나는 예기치 않은 동작을 일으킬수 없는 상태가 되어버렸지만.. (사실 어디서 나는지도 모르겠다)
이제부터는 구조적으로 이런 문제를 잡겠다는 next팀의 의지도 보인다.
그래서 caching은 어떻게하는데 ?
구조적으로 cache에대한 이해는 끝났다 생각하고, 이제 cache적용을 해야한다
총 3가지의 캐싱방법이 있는데 가장 먼저
위에서 언급한 const posts = await getPostList(await searchParams) 이 구문 ..
실제로 함수를 보면 이러하다
함수 캐싱
export const getPostList = async (props: GetPostListParams) => {
'use cache'
cacheTag(...QUERY_KEY.POST.LIST(props)
let query = db
.select()
.from(posts)
.leftJoin(categories, eq(posts.categoryId, categories.categoryId))
.$dynamic()
// (...)
return await query.orderBy(desc(posts.createdAt)).limit(limit).offset(offset)
}
이와같이 함수 자체를 캐싱하는 방법이다
원래는 unstable_cache를 이용하던 방식인데
이제 'use cache' 지시문이 사용가능해지면서 저런식으로 바뀌는 것이다.
장점:
- 파라미터별로 자동 캐싱 (캐시 키 자동 생성)
- 여러 컴포넌트에서 재사용 가능
- 가장 유연함
단점:
- 데이터만 캐싱됨 (HTML은 매번 생성)
사용 케이스:
- 동적 파라미터에 따라 다른 데이터 fetch
- DB 쿼리, API 호출 등 데이터 소스 캐싱
- 내 블로그처럼 searchParams 기반 데이터
다음으로는 컴포넌트 레벨의 캐싱도 가능하다
컴포넌트 캐싱
export const CategoryNav = async () => {
'use cache' // ← 컴포넌트 전체 캐싱
cacheLife('days')
const categories = await getCategoryList()
return (
<nav className="flex gap-2">
{categories.map(category => (
<Link key={category.id} href={`/?category=${category.id}`}>
{category.name}
</Link>
))}
</nav>
)
}
장점:
- 렌더링 결과(HTML)까지 캐싱
- 함수 호출 + HTML 생성 모두 생략
단점:
- runtime data (searchParams, cookies 등) 사용 불가
- props가 바뀌면 새로운 캐시 엔트리 생성
사용 케이스:
- 네비게이션 바
- 푸터
- 사이드바 (사용자 정보 없는)
- 완전 정적인 UI 컴포넌트
마지막으로 페이지 전체 caching이 있다
페이지 캐싱
'use cache' // ← 파일 맨 위
import { cacheLife } from 'next/cache'
const AboutPage = async () => {
cacheLife('weeks')
const teamMembers = await getTeamMembers() // 정적 데이터
return (
<main>
<h1>About Us</h1>
<section>
{teamMembers.map(member => (
<div key={member.id}>
<h2>{member.name}</h2>
<p>{member.role}</p>
</div>
))}
</section>
</main>
)
}
export default AboutPage
장점:
- 페이지 전체가 정적 HTML로 캐싱
- 가장 빠른 응답 속도
- CDN 캐싱 가능
단점:
- runtime data 완전 불가
- searchParams, cookies, headers 사용 시 에러
- 동적 라우팅 불가
사용 케이스:
- About 페이지
- Contact 페이지
- 약관/정책 페이지
- 완전 정적인 랜딩 페이지
이렇게 정리가 되지 않을까 싶다.
마지막으로 ..
이번 패치는 아마 매우 호불호가 많이 갈리지 않을까 싶다.
개인적으로는 신기술에 대한 거부감이 아예 없어서
"히히 또 새로운거 나왔다 !! 꿀잼 !!" 하면서 뒤도안보고 시작했지만
아마 올해 새로 FE를 시작했다던가.. 아니면 신기술이 조금 거북한 사람들에게는 불호로 다가오지 않을까 싶다
뭐.. 그것이 FE니 악으로 깡으로 버티는게 맞긴하지만서도 .. 흑흑..
이직 준비로 코테나 준비해야지.. 해야지 하다가
신기술이 너무 재밌어서 이렇게 블로그 글을 하나 끄적여보고 ..
이상 꿀잼 nextjs16 체험기였다
댓글