지금까지의 프론트엔드 개발에서 데이터를 가져오는 전략은 페이지 렌더링 방식과 결부되어 변화해 왔습니다. 즉 SSR과 CSR을 종횡무진하는 중입니다. 지금은 데이터를 가져오는 방식에 따라 페이지, 데이터, 컴포넌트별로 다른 전략을 취하기도 합니다. 서버에서 가져온 데이터와 클라이언트에서 가져온 데이터의 성질이 컴포넌트로서 분리되면서 정적 데이터와 동적 데이터가 렌더링되는 경계가 생겼고(RSC, Hydration, 'use client'), 각 영역의 캐싱 전략은 서로 달라졌습니다.
Next.js 15에서는 정적이어도 되는 데이터는 서버 컴포넌트로 미리 만들어 빠르게 내려 보내고, 동적이어야 하는 데이터는 실행하지 않고 있다가 요청이 왔을 때 실행해 로드합니다. 이렇게 블로그 글 본문과 같이 인터랙션의 영향을 받지 않는 데이터는 정적으로 간주해 서버에서 빠르게 로드하고, 댓글이나 ‘좋아요’ 처럼 갱신이 필요한 데이터들은 클라이언트에서 동적으로 로드해 초기 로딩 시간을 줄이면서 사이트 성능을 향상시켰습니다.
예를 들어 이 블로그의 글 본문은 기본적으로 ISR로 만들어진 페이지지만, 사진이나 파일 데이터에 한해 부분적으로 CSR의 전략을 취하고 있습니다. 제공된 링크가 작동하지 않을 경우 해당 이미지나 파일 컴포넌트가 가진 Notion Block만 재요청해 변경된 링크를 적용해 이미지나 파일을 리로드하는 식입니다. 즉 서버에서 먼저 데이터를 가져오지만, 클라이언트에서도 데이터를 요청할 수 있습니다.
지금 이 섹션에서는 서버와 클라이언트의 렌더링, 정적과 동적 데이터라는 데이터의 성질을 혼합해서 이야기하고 있습니다. 대체로 서버에서는 정적, 클라이언트는 동적 데이터를 가져온다는 전제인데 어색하지 않게 보입니다(*).
반면 Next.js 16에서는 렌더링과 데이터의 층위를 각각 분리해서 생각합니다.
위 이야기를 렌더링과 데이터의 측면을 분리해 다시 살펴봅시다. 데이터를 ‘데이터의 변동성’이란 기준으로 분류해 보면 변하지 않는 정적 데이터와, 변할 수 있는 동적 데이터로 분류할 수 있습니다. 이에 대해 공식 문서의 표현을 빌리자면,
어떤 페이지가 정적 데이터로만 이루어져 있다면 로드 속도는 매우 빠를지 모르나 인터랙션이 힘들고 데이터를 실시간으로 갱신할 수 없습니다.
반면 완전히 동적이라면 페이지에 접속할 때마다 모든 데이터를 가져와야 하므로 로딩 속도가 느려집니다.
다만 Next.js는 다른 처리가 없다면 기본적으로 모든 페이지를 정적 데이터로 최적화하려고 하기 때문에 동적 영역의 코드가 혼합되면 여러 문제가 발생했습니다.
// "nested fetch problem", 최하위 캐시 정책이 모든 캐시 정책을 override
export default async function Page() {
const posts = await fetch('/api/posts', { cache: 'force-cache' }) // 정적
const user = await fetch('/api/user', { cache: 'no-store' }) // 동적
// 이렇게 되면 전체 페이지가 동적으로 전환된다.
...
}여러 개를 fetch할 때 캐시 옵션이 다르면 전체를 동적 페이지로 바꿔버린다든가, fetch 캐시가 SSR 전체 동작에 묶여 컨트롤이 난해하다든가… 기본적으로 동적 영역의 결과물을 정적 영역과 분리하는 것을 상정했기 때문에 그런 것 같습니다.
이런 문제를 개선한 것이 Next.js 16에 등장한 Cache Component 입니다.
Cache Components 기능이 활성화되면 Next.js는 모든 라우트를 기본적으로 동적인 것으로 간주합니다. 즉, 각 요청마다 최신 데이터를 기반으로 렌더링을 수행합니다. 하지만 대부분의 페이지는 정적 요소와 동적 요소가 혼합되어 있으며, 모든 동적 데이터가 매번 새로 불러올 필요는 없습니다. Cache Components를 사용하면 데이터나 UI의 일부를 캐시 가능한(cacheable) 영역으로 지정할 수 있습니다. 이렇게 지정된 부분은 페이지의 정적 섹션과 함께 사전 렌더링(pre-render) 단계에 포함됩니다. (출처: https://nextjs.org/docs/app/getting-started/cache-components 를 번역)
조금 더 간단한 말로 설명하면 캐시 컴포넌트는 함수 단위의 결과값을 캐싱해 정적 데이터로 만들어 버리는 것입니다. 데이터를 가져오는 함수, 그것을 받아 렌더링된 컴포넌트(컴포넌트도 함수니까요) 등이 모두 캐시 대상에 포함됩니다. 이렇게 캐시된 부분은 Shell 영역으로 지정되어, 빠른 초기 로딩을 돕습니다.
사용자가 특정 라우트에 요청을 보내면
Shell 영역의 데이터와 컴포넌트를 먼저 받습니다.
<Suspense> 로 감싸진 동적 영역들에는 fallback UI를 먼저 표시하고,
준비가 완료된 동적 영역만 병렬로 스트리밍 렌더해 UI가 교체됩니다.
<Suspense>로 감싸 동적 데이터로 간주해야 하는 데이터들은 기본적으로 ‘요청마다 바뀔 수 있는 데이터’입니다.
사용자별 요청 데이터
fetch(), db.query(…), connection()
'use cache' 디렉티브를 통해 캐시한 데이터
왜 Suspense로 감쌀까요? Suspense가 ‘언제 보여줄지’를 제어할 수 있는 도구이기 때문에 그렇습니다.
Suspense는 그래서 동적-정적 데이터를 다룬 컴포넌트의 경계선의 역할을 하지만 실제로 데이터를 동적으로 만들지는 않습니다. 다만 use cache의 경우 캐시한 데이터가 정적으로 간주되긴 하지만, 캐시 미스가 발생하면 서버 연산이 발생해 동적 데이터가 될 수 있습니다. 이럴 때는 Suspense 경계 안에 포함시켜 폴백을 제공해야 할 수 있습니다.
그렇다면 어떻게 동적 영역을 ‘병렬로 스트리밍 렌더’ 하는 것일까요?
아래와 같은 컴포넌트가 있다고 가정합시다.
return (
<>
<Suspense>
<SectionA /> {/* fetchA1, fetchA2 */}
</Suspense>
<Suspense>
<SectionB /> {/* fetchB1, fetchB2 */}
</Suspense>
</>
)이런 컴포넌트가 렌더링되어야 한다면, 서버에서 4개의 promise를 병렬로 실행하되, 각각 resolve 될 때마다 HTML을 생성해 예정된 자리로 붙입니다. 결과적으로 브라우저는 1개의 HTTP 요청을 하게 되고, 서버에서는 4개의 데이터를 받아온 뒤 그 결과를 실시간으로 브라우저에 제공하는 것입니다. 동적 영역의 구분을 위해 Suspense가 반드시 필요한 이유이기도 한데, 데이터 로드 결과에 따라 페이지 전체가 정지할 수도 있기 때문입니다.
//next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
}OPT-IN 기능이기 때문에 next.config.ts 에서 설정이 필요합니다. 그 후, 결과값을 캐시하고자 하는 함수에 아래와 같이 디렉티브를 삽입합니다:
import { cacheLife } from 'next/cache'
export const getPostMetaData = async (page_id: string): Promise<NotionPageMeta> => {
'use cache'
cacheLife('minutes') // swr 시간을 조정할 수 있는 api
const result = await notion.pages.retrieve({ page_id })
return result as NotionPageMeta
}또 이런 패턴도 가능합니다:
export async function CachedWrapper({ children }: { children: ReactNode }) {
'use cache'
return (
<div className="wrapper">
<header>Cached Header</header>
{children}
</div>
)
}
//출처: nextjs.org주의할 점으로, 캐시된 함수는 파라미터로 직렬화가 가능한 것들만 받을 수 있습니다. 예를 들어 클래스 인스턴스, 함수, Date 객체는 전달할 수 없습니다.
다만, 그것을 함수 내부에서 탐색하지 않는다면 사용할 수 있습니다. 예를 들어 위 CachedWrapper에서 ReactNode는 ‘렌더링될 수 있는 것들’을 모두 포괄한 것이고, 기본적으로는 plain object이지만 직렬화할 수 없습니다(**).
따라서 CachedWrapper로 컴포넌트를 감싼다고 해서 그 아래의 모든 children이 캐시되지는 않습니다. 필요한 모든 데이터나 컴포넌트를 따로 캐시해야 하는 것입니다. 즉 상향식 구조를 가지고 있습니다.
기존의 최적화 방법이 정확히 이것의 반대였기 때문입니다. 별도의 코드 없이는 모든 데이터를 정적으로 간주하려고 한 것이 하향식(Top-Down) 캐시 구조라고 할 수 있는데, 여기서 생긴 문제(캐시 전이, 페이지 단위로만 컨트롤 가능, …)들을 해결하고 캐시를 세분화하려고 한 것이 Next.js 16의 의도이기 때문입니다. 상/하향식 캐시 구조는 공식 용어는 아니고 제 표현입니다.
이렇게 이번에 출시될 예정의 Next.js 16의 주된 변경점에 대해 간단히 알아봤습니다. 보고 계시는 이 블로그는 이미 Next.js 16으로 마이그레이션한 상태입니다. 사용하다 보니 기존의 최적화 모델을 왜 반대로 갈아엎게 되었는지, 왜 'use client'와는 또 다른 'use cache'로 페이지 렌더와 데이터 캐싱의 층위를 굳이 명시적으로 구분했는지에 대해 생각해 볼 수 있었습니다. 저는 합리적이고 긍정적인 변화라고 느껴졌습니다.
Next.js에 대한 비판도 없지 않습니다.
blog.webf.zone
이런 글도 있는데요, 저도 이전 버전의 Next.js를 사용하면서 공감되는 부분이 있었습니다. 편안함을 명목으로 과도한 추상화를 제공해서 생기는 문제가 분명 있다고 생각했습니다. 그런 것들은 디버깅의 불편함으로 보통 드러났고요. 이제는 Next.js가 하나의 react 기반 런타임으로 보아야 하지 않느냐는 것에도 공감합니다. 프레임워크가 어디까지 해 주어야 하는지에 대한 고민을 하게 되네요.
이 글은 Next.js 16의 공식 문서를 읽고 개인의 관점에서 쓴 해설이자 독후감(?)입니다. 더 자세한 작동 원리와 API의 의도는 아래 링크들을 참고해 주세요:
nextjs.org
Learn how to use Cache Components and combine the benefits of static and dynamic...
youtube.com
YouTube에서 마음에 드는 동영상과 음악을 감상하고, 직접 만든 콘텐츠를 업로드하여 친구, 가족뿐 아니라 전 세계 사람들과 콘텐츠를 공유할 ...
* 서버에서도 물론 동적 데이터를 가져올 수 있지만, Next.js에서는 기본적으로 서버 컴포넌트에서 가져온 데이터를 별다른 처리가 없다면 캐시하려고 하고, Static Site를 기본 옵션으로 제공하고 있어 기본적으로 정적 데이터로 간주하려고 한다는 관점에서 썼습니다.
** bigint나 가장 아래의 Promise도 직렬화할 수 없는 데이터지만, 컴포넌트는 기본적으로 props로 함수를 받을 수 있기 때문이기도 하고, 컴포넌트간의 순환참조가 발생할 수도 있기 때문에 그렇습니다. 직렬화가 가능하려면 그래프의 사이클이 없어야 하기 때문입니다.