Next.js 16에서 Cache Components 기능이 새롭게 등장했다.
어질어질했던 기존 캐싱 시스템이 드디어 정상화됐다고 생각한다.
최근에서야 이 기능을 접하게 됐는데, 내가 오랫동안 겪어온 문제를 깔끔하게 해결해줬다.
공식 문서를 기반으로 새로운 캐싱 전략을 정리하고, 이를 벳툴(VETOOL)에 어떻게 적용했는지 공유해보려 한다.
Cache Components가 해결하는 것
기존 Next.js의 캐싱은 페이지 단위로 정적/동적이 결정되는 구조였다.
일부 데이터만 동적으로 처리하고 싶어도 페이지 전체가 동적으로 빠져버리는 문제가 있었다.
Cache Components는 이 문제를 해결한다.
어떤 부분을 정적으로, 어떤 부분을 동적으로 처리할지 개발자가 명시적으로 결정할 수 있게 된 것이다.
export default function Page() {
return (
<>
{/* 정적 — 빌드 시 렌더링, 캐시에 포함 */}
<Header />
{/* 캐싱된 동적 — use cache로 캐싱, 정적 셸에 포함 */}
<BlogPosts />
{/* 순수 동적 — 매 요청마다 스트리밍 */}
<Suspense fallback={<p>Loading...</p>}>
<UserPreferences />
</Suspense>
</>
)
}
이 렌더링 방식을 Partial Prerendering (PPR) 이라고 한다.
정적 HTML을 먼저 즉시 전달하고, 동적 콘텐츠는 이후 스트리밍으로 채워 넣는 방식이다.
Cache Components를 활성화하면 PPR이 기본 동작이 된다.
Cache Components 활성화
next.config.ts에 옵션을 추가하는 것으로 활성화할 수 있다.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
사용법
use cache 지시어(directives)는 비동기 함수와 컴포넌트의 반환값을 캐싱한다.
두 가지 레벨에서 적용할 수 있다.
- 데이터 레벨: 데이터를 패칭하거나 연산하는 함수에 적용 (예:
getPosts(),getPost(post_id)) - UI 레벨: 컴포넌트 또는 페이지 전체에 적용 (예:
async function BlogPosts())
데이터 레벨 캐싱
"use server"
import { createClient } from "@/lib/supabase/server"
import { cacheLife } from 'next/cache'
export async function getPosts() {
"use cache"
cacheLife("days")
const supabase = await createClient()
const { data: posts, error } = await supabase
.from("posts")
.select("*")
.order("published_at", { ascending: false })
if (error) {
console.error("Error fetching posts:", error)
throw new Error(error.message)
}
return posts
}
- 인자(arguments)와 부모 스코프에서 참조된 값들은 자동으로 캐시 키의 일부가 된다.
즉,getPost(post_id)함수의 경우 각post_id값마다 별도의 캐시 엔트리가 생성된다. - 동일한 데이터를 여러 컴포넌트에서 사용할 때, 또는 UI와 독립적으로 데이터를 캐싱하고 싶을 때 유용하다.
UI 레벨 캐싱
import { cacheLife } from 'next/cache'
export default async function Page() {
'use cache'
cacheLife('hours')
const users = await db.query('SELECT * FROM users')
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
파일 최상단에
"use cache"를 추가하면 해당 파일에서 export된 모든 함수가 캐싱된다.
cacheLife 프로필
cacheLife로 캐싱 시간을 세밀하게 설정할 수 있다.
| cacheLife | Use Case | Update Frequency | stale | revalidate | expire |
|---|---|---|---|---|---|
default | updateTag(아래에 언급) 사용할 경우 | - | 5분 | 15분 | 없음 |
seconds | 주식 가격, 라이브 스코어 | 실시간 | 30초 | 1초 | 1분 |
minutes | 소셜 피드, 뉴스 | 자주 업데이트 | 5분 | 1분 | 1시간 |
hours | 상품 재고, 날씨 | 하루 여러 번 | 5분 | 1시간 | 1일 |
days | 블로그 포스트, 기사 | 매일 업데이트 | 5분 | 1일 | 1주 |
weeks | 팟캐스트, 뉴스레터 | 매주 업데이트 | 5분 | 1주 | 30일 |
max | 법적 페이지, 아카이브 콘텐츠 | 거의 변경 없음 | 5분 | 30일 | 1년 |
각 프로퍼티의 역할은 다음과 같다.
stale: 브라우저가 서버에 요청을 보내지 않고 클라이언트 캐시를 그대로 사용하는 시간revalidate: 이 시간이 지난 후 요청이 오면 캐시를 반환하면서 백그라운드에서 갱신을 트리거expire: 마지막 갱신으로부터 이 시간 동안 트래픽이 없으면 캐시 소멸
실제 시나리오 — getPosts + days 프로필
stale: 5분/revalidate: 1일/expire: 1주
| 경과 시간 | 상황 | 동작 | expire 타이머 |
|---|---|---|---|
| 빌드 시 | next build | getPosts 실행 & 캐시 저장 | D-7 시작 |
| 3분 후 접속 | stale 이내 | 브라우저 캐시에서 바로 반환, 서버 요청 없음 | D-7 진행 중 |
| 7시간 후 접속 | stale 초과, revalidate 이내 | 서버에 요청, 서버 캐시 반환, 갱신 없음 | D-7 진행 중 |
| 2일 후 접속 | revalidate 초과 | 서버 캐시 반환 + getPosts 백그라운드 갱신 | D-7 리셋 |
| 마지막 갱신으로부터 1주간 트래픽 없다가 첫 접속 | expire 만료 | 캐시 소멸 → getPosts 동기 실행 → 반환값을 보여줌 | D-7 재시작 |
Supabase에 적용하기
Cache Components를 Supabase에 그대로 적용하면 문제가 생긴다.
다음은 일반적인 Supabase 서버 클라이언트 생성 함수다.
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
export async function createClient() {
const cookieStore = await cookies() // 런타임 API
// ...
}
문제는 cookies()다. 쿠키는 사용자가 요청을 보내는 시점에만 알 수 있는 런타임 정보이기 때문에,
use cache 컨텍스트 안에서 호출하면 에러가 발생한다.
Route /dev used `cookies()` inside "use cache".
Accessing Dynamic data sources inside a cache scope is not supported.
If you need this data inside a cached function
use `cookies()` outside of the cached function
and pass the required dynamic data in as an argument.
이를 해결하려면 런타임 정보 없이 Supabase에 접근할 수 있는 시크릿 키(과거명칭은 Service Role Key) 가 필요하다.
시크릿 키는 RLS(Row Level Security)를 우회하기 때문에, 캐싱할 데이터가 공개 데이터이거나
별도의 접근 제어가 필요 없는 경우에 한해 사용해야 한다.
import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
export function createCacheClient() {
return createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!, // 시크릿 키 사용
)
}
이렇게 하면 쿠키 없이 Supabase에 접근할 수 있어 use cache와 함께 사용이 가능해진다.
VETOOL에 적용하기
벳툴에서는 동물병원마다 고유한 세팅값이 존재한다.
표시되는 글자 크기, 자주 사용하는 약물 목록 등인데, 한번 설정하면 거의 바뀌지 않는 데이터다.
기존에는 한 번의 요청에서 아래과 같이 세 개의 테이블에서 데이터를 함께 가져와야 했다.
icuSidebarPatients— 입원 중인 환자 데이터 (자주 변경)vetList— 수의사목록 데이터 (거의 변경 없음)basicHosSettings— 병원 기본 설정값 (거의 변경 없음)
따라서 RPC 함수로 세 개를 한 번에 가져오는 방식을 택했었다.
그러나 문제는 2, 3번이 거의 안 바뀜에도 매 요청마다 불필요하게 DB를 찌르고 있었다는 점이다.
거기다 realtime까지 붙어 있어서 서버 자원 낭비가 지속적으로 발생하는 구조였다.
use cache는 이 문제를 정확하게 해결해준다.
1번은 기존처럼 동적으로 두면서, 2번과 3번만 캐싱한 뒤 Promise.all로 병렬 요청하면 된다.
// 수의사 목록
export const getVetsList = async (hosId: string) => {
'use cache'
cacheTag(`vets-list-${hosId}`)
const supabase = createCacheClient()
const { data, error } = await supabase
.from('users')
.select('name, position, user_id, avatar_url, rank')
.match({ hos_id: hosId, is_vet: true })
.order('rank', { ascending: true })
if (error) {
const errorId = generateErrorId()
console.error(`[${errorId}]`, error.message)
redirect(`/error?errorId=${errorId}`)
}
return data
}
// 병원 기본 설정
export const getBasicHosSettings = async (hosId: string) => {
'use cache'
cacheTag(`basic-hos-settings-${hosId}`)
const supabase = createCacheClient()
const { data, error } = await supabase
.from('hospitals')
.select(`
group_list, icu_memo_names, show_orderer,
is_in_charge_system, vital_ref_range, order_font_size,
time_guidelines, show_tx_user, hos_injections, plan
`)
.eq('hos_id', hosId)
.single()
if (error) {
const errorId = generateErrorId()
console.error(`[${errorId}]`, error.message)
redirect(`/error?errorId=${errorId}`)
}
return data as BasicHosSettings
}
// Server Component에서 병렬 요청
export default async function IcuLayoutContent({ params, children }: Props) {
const { hos_id, target_date } = await params
const [icuSidebarPatients, vetList, basicHosSettings] = await Promise.all([
getIcuSidebarPatients(hos_id, target_date), // 동적
getVetsList(hos_id), // 캐싱
getBasicHosSettings(hos_id), // 캐싱
])
// ...
}
cacheLife를 생략하고(default, expire 없음) cacheTag를 사용하면 시간 기반이 아닌 이벤트 기반으로 캐시를 관리할 수 있다.
데이터가 실제로 변경되는 시점에 해당 캐시만 정확히 초기화하는 방식이다.
Tanstack Query(리액트쿼리)에서 queryKey를 설정하고 invalidateQueries로 무효화하는 패턴과 거의 동일하다.
Revalidation
캐시 초기화는 updateTag 함수에 캐시 키를 넘겨주면 끝이다.
import { updateTag } from 'next/cache'
export const deleteStaff = async (userId: string, hosId: string) => {
const supabase = await createClient()
// ... 직원 삭제 로직 => 수의사 리스트의 변동이 발생함 => 캐싱 초기화 필요함
updateTag(`vets-list-${hosId}`) // 해당 병원의 수의사 캐시만 초기화
}
마무리
벳툴은 특성상 정적인 페이지가 거의 없다. 기껏해야 페이지 제목 정도만 static하기 때문에 PPR의 이점을 크게 누리는 케이스는 아니다.
하지만 데이터 레벨 캐싱은 완전히 다른 이야기다.
"동적 페이지 안에서도 특정 데이터만 골라서 캐싱한다"는 게 핵심인데, Supabase처럼 자체 SDK를 사용하는 서드파티 라이브러리에서도 가능해진 것만으로도 충분히 혁신적이다.
(기존에는 Next.js가 오버라이딩한 fetch 함수를 통한 요청에서만 캐싱이 지원됐다)
불필요한 DB 요청이 줄었고, 실제로 체감할 수 있는 성능 향상을 확인했다.
Supabase를 쓰는 Next.js 프로젝트라면 반드시 도입을 검토해볼 만하다....