Next.js 14의 unstable_cache
use cache, 제대로 알고 사용하자
서론
(Next.js 14를 기준으로 작성되었습니다. 15에선 use cache
지시어로 변경되었습니다.)
React의 cache
를 사용해 캐싱된 데이터를 받아올 때 예상처럼 동작하지 않았습니다.
문제의 원인은 제대로 공부하지 않고 적용해 생긴 당연한 결과였고, CMS clients를 사용할 때 어떤 캐싱 매커니즘을 사용해야 하는지에 대해 찾아보았습니다.
문제의 코드
//app/lib/api/notion.ts
import cache from 'react/cache'
export const getAllPost = cache(async () => {
const res = await notion.databases.query({
database_id: dbID,
})
const data = res.results.map((page: any) => ({
id: page.id,
title: page.properties.title.title[0].plain_text,
thumbnail: page.properties.thumbnail.files[0].file.url,
}))
return data
}
//app/component/home/action.ts
"use server"
import { getAllPost } from "@/services/notion"
import { NotionData } from "@/types/notion"
import { TagEnum, TagType } from "@/types/tags"
const itemsPerPage = 6
export async function fetchTagArticles({
tag,
page = 0,
}: {
tag: TagType
page?: number
}) {
const res = await getAllPost()
const filteredData = res.filter((post: NotionData) => {
if (tag.tagName === "전체보기") return true
if (tag.type === TagEnum.TAG && post.tag.includes(tag.tagName)) return true
if (tag.type === TagEnum.CATEGORY && post.category === tag.tagName)
return true
return false
})
const start = page * itemsPerPage
const end = start + itemsPerPage
const moreData = filteredData.slice(start, end)
return moreData
}
첫번째 함수를 두번째 함수에서 호출하여 사용하는 로직입니다.
getAllPost()는 generateStaticParams
에서 호출하기 때문에, 빌드 타임에 캐싱되어 이후 요청에서 캐싱된 데이터를 리턴받는줄 알았습니다.
notion client
를 사용하기 때문에, fetch
를 사용할 수 없어 react의 cache
를 사용하여 결과값을 캐시해 사용하려 했습니다.
두번째 함수인 fetchTagArticles()는 서버와 클라이언트측에서 모두 사용됩니다.
최초 요청시 해당 함수를 사용한 컴포넌트는 server
에서 렌더링됩니다.
이후 클라이언트에서 태그를 변경하거나, 추가 게시글을 요청할 때 첫번째 함수(getAllPost)를 통해 받아온 데이터를 필터링하여 리턴합니다.
또한 브라우저에서 요청할 경우, notion client
에서 CORS 에러가 발생하기 때문에 서버에서 요청할 수 밖에 없었습니다.
그렇기 때문에 server action
을 사용하여 클라이언트 컴포넌트가 서버에서 실행되는 비동기 함수를 호출할 수 있도록 만들었습니다.
됐다면 글을 쓰지 않았을 것 입니다..🤓
이미지 호스팅을 위해 사용하던 s3의 프리티어 사용량이 하루만에 85%을 초과했다고 메일이 왔습니다.
해당 함수를 호출하여 데이터를 fetch 해오는데 캐시된 데이터가 아니라 실제 요청에 대한 응답을 리턴받고 있었습니다..
문제 원인
원인은 단순했습니다. 알아보지 않고 사용한 것
React의 cache
는 함수 호출로 발생한 연산의 결과를 캐싱합니다.
요청시 사용한 인자를 사용하여 cache
함수를 호출할 때, 캐싱 된 데이터가 있는지 먼저 확인합니다.
캐싱된 데이터가 있다면, 그 결과를 반환합니다. 없다면, 매개변수와 함께 fn
을 호출하고 결과를 캐시에 저장하고 값을 반환해줍니다.
다만 각 서버 요청에 대해 모든 메모화된 함수의 캐시를 무효화한다.
- React 서버 컴포넌트는 클라이언트 요청마다 새로 HTML을 생성하며, 각 요청은 독립적인 상태로 처리됩니다.
- 따라서, 이전 요청에서 메모화된
cache
의 데이터는 새로운 요청에서 재사용되지 않습니다. - 각 요청은 고유한 캐시 컨텍스트를 가지며, 요청이 끝날 때 해당 캐시는 삭제됩니다.
- 새로운 요청이 들어오면 React는 이전 요청의 캐시를 무효화하고 새로운 요청의 데이터를 캐싱합니다.
cache
함수는 요청 범위(Request Scope) 내에서 작동합니다. 같은 요청 내에서는 동일한 입력 값에 대해 동일한 결과를 반환하지만, 다른 요청에서는 새로 캐시를 생성합니다.
약 5000ms 이후 로그를 출력하는 heavyFunc
함수를 만들어 예시를 들어 보겠습니다.
(HIT) 동일한 요청 범위 내의 함수 호출
Home
컴포넌트에서 컴포넌트 TestA
, TestB
를 호출합니다.
TestA
, TestB
에서는 동일한 인자로 cache
로 감싼 heavyFunc
를 호출합니다.
또한, TestB
컴포넌트에선 TestA
를 호출하기 때문에 heavyFunc
가 총 3번 실행됩니다.
//app/heavy.ts
export const heavyFunc = cache(async (num: number) => {
console.log(`Running heavy computation for ${num}...`)
const start = Date.now()
while (Date.now() - start < 5000) {
for (let i = 0; i < 1e6; i++) {}
}
return num * num
})
//app/compenents/TestA.tsx
export function TestA() {
const ret = heavyFunc(10)
return (
<div>
{ret}
</div>
)
}
//app/compenents/TestB.tsx
export function TestB() {
const ret = heavyFunc(10)
return (
<div>
{ret}
<TestA />
</div>
)
}
//app/page.tsx
export async function Home() {
return (
<div>
<TestA />
<TestB />
</div>
)
}
실행 결과
heavyFunc
함수를 3번 호출했기 때문에 cache
가 되지 않았다면 약 15000ms가 소요 됐을 것입니다.
하지만 동일한 요청 범위 내의 서버 요청이므로 heavyFunc
가 최초 호출된 이후 응답 데이터가 캐시되어 5000ms가 소요되었습니다. 로그도 1번 출력된 걸 확인할 수 있습니다.
heavyFunc
를 cache
로 감싸지 않으면 당연하게 15000ms가 걸리고, 로그 또한 함수를 호출한 만큼 출력됩니다.
(MISS) 상이한 요청 범위 내의 함수 호출
하지만 아래와 같은 코드에선 라우팅시 새로운 서버 요청으로 인해 총 10000ms가 소요되며, 로그도 2번 출력됩니다.
//app/abc/page.tsx
export function TestC() {
const ret = heavyFunc(10)
return (
<div>
{ret}
<TestB />
</div>
)
}
//app/page.tsx
export async function Home() {
return (
<div>
<TestA />
<TestB />
<Link href="/abc">go to abc</Link>
</div>
)
}
TestA
, TestB
에 대한 캐시는 /abc
로 라우팅 시 새로운 서버 요청으로 인해 무효화됩니다.
Link를 클릭하여 TestC
에 대한 새로운 요청을 보냈고, cache miss가 발생하여 다시 5000ms가 소요된 것입니다.
(TestC
내부에서 TestB
컴포넌트를 호출한 건에 대해선, 첫 요청의 응답이 캐싱되고 동일한 서버 요청이므로 캐싱된 데이터를 받습니다.)
동일한 서버 요청이 아닌 경우, 캐시가 무효화되기 때문에 매번 새로운 요청을 보내고 있던 것입니다.
해결 방법은 아니고 올바른 함수 사용하기
제가 cache
를 통해 코드에 적용하고 싶었던 건 2가지였습니다.
getAllPost()
의 결과값을 캐싱하고, 데이터가 변경 되었을 때 무효화시키기RCC
에서 서버에서 실행되는 비동기 함수를 요청하기
next/cache 의 unstable_cache
(use cache)
요구사항을 만족하기 위해 사용할 수 있는 올바른 함수를 발견했습니다.
(실험적인 기능이긴 하지만, react의 cache도 실험적인 기능인 건 마찬가지..)
unstable_cache는 데이터베이스 쿼리와 같은 비용이 많이 드는 작업의 결과를 캐싱하고 여러 요청에서 재사용할 수 있습니다.
이 API는 Next.js의 내장 데이터 캐시를 사용하여 요청과 배포 전반에 걸쳐 결과를 유지합니다.
notion client sdk
를 사용하기 때문에 사용하지 못한 fetch
의 데이터 캐시를 사용할 수 있도록 제공하는 개꿀 기능입니다.
사용법
(데이터캐시
사용법이랑 동일합니다.)
const data = unstable_cache(fetchData, keyParts, options)()
- *
fetchDataPromise
: 캐시하려는 데이터를 가져오는 비동기 함수.Promise
를 반환하는 함수여야 합니다. keyParts
: 캐시를 식별할 수 있는 키. 기본적으로 함수의 인자와 함수의 문자열화된 버전을 캐시 키로 사용합니다.options
: 캐시가 어떻게 동작하는지 제어하는 객체. 다음 속성을 포함합니다.tags
: 캐시 무효화를 제어하는 데 사용할 수 있는 태그 배열revalidate
: 캐시가 유효한 시간 : 캐시를 다시 검증해야 하는 초. 생략하면 무기한 캐시, 혹은revalidateTag()
,revalidatePath()
메서드가 호출될 때 까지 유효합니다.
코드에 적용
//app/service/notion.ts
import unstable_cache from 'next/cache'
export const getAllPost = unstable_cache(async () => {
const res = await notion.databases.query({
database_id: dbID,
})
const data = res.results.map((page: any) => ({
id: page.id,
title: page.properties.title.title[0].plain_text,
thumbnail: page.properties.thumbnail.files[0].file.url,
}))
return data
},
["posts"],
{ tags: ["posts"] },
)
unstable_cache
의 주요 장점은 빌드 시점에 캐싱된 데이터를 런타임에서도 활용할 수 있다는 점입니다.
위 코드의 getAllPost
함수는 정적 페이지 생성에 사용하는 generateStaticParams
와 Server Actions
모두에서 재사용됩니다.
노션 데이터베이스에서 가져오는 데이터는 자주 변경되지 않기 때문에, 프로젝트 빌드 시 최초 요청으로 데이터를 캐싱하여 사용하고 있습니다. 이후, 노션 데이터가 변경되었을 경우 태그 기반 ISR을 통해 최신 데이터를 반영하도록 구현하였습니다.
추가로 시도한 방법
그럼 server action
인 fetchTagArticles함수를 memoization
해서 최적화할 수 있을까….?
server action
은 각 요청마다 실행되며, 이는 서버에서 호출될 때마다 새로운 인스턴스에서 실행된다는 의미입니다.
fetchTagArticles 함수를 unstable_cache
를 사용하여 캐싱 해보았는데, 기존 요청에 대한 서버의 응답이 평균 7ms에서 250ms로 증가했습니다.
매 요청마다 새로운 인스턴스를 생성한 후 캐시가 존재하는지 확인하고, 없으면 해당 데이터를 캐싱하는 과정에서 오버헤드가 발생한 것입니다...
회고
검색이 늘 참 어려운 거 같습니다..만 정답은 늘 공식문서입니다.
매번 회고를 작성하면서 나는 결론이 RTFM이라니
게임 회사에 재직중인 동료 개발자분은 공식 문서대로 하면 제대로 동작하는 경우가 없다고 합니다.
중국 검색엔진에서 검색해야 제대로 된 글이 나온다는데, 어찌보면 웹 개발은 공식문서만 잘 읽으면 거진 해결되는 거 같습니다. (당연함)