React 19 cache()로 Server Component waterfall 피하기
작성일:2026.08.18|수정일:2026.08.18|조회수:2

이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Avoiding Server Component Waterfall Fetching with React 19 cache() 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.
cache() API는 React 19에서 release된 새로운 기능이다. 이 글에서는 Next.js App Router에서 이 API를 살펴보고, React Server Components를 사용할 때 data coupling을 줄이고 data를 preload해 performance를 최적화하며 waterfall fetching을 피하는 데 어떻게 사용할 수 있는지 알아본다.
React 19 Cache API
React 19의 cache() API는 data fetch나 computation의 결과를 cache할 수 있게 해준다. React Server Components와 함께 사용하기 위한 API이며, data fetch에 대한 per-render caching/memoization을 제공한다. 특히 여러 component에서 같은 데이터를 fetch할 때 data coupling을 줄이는 데 유용하다. 자세한 내용은 문서를 참고하면 된다.
전형적인 예시는 getUser 같은 함수다.
const getUser = cache(async (userId: string) => { return db.getUser(userId);});여러 server component에서 getUser를 호출하는 일은 흔하다. cache()를 사용하면 같은 data를 여러 번 fetch하지 않고 return value를 공유할 수 있다.
여러 component에서 같은 데이터를 fetch하고 있다면 cache() API 사용을 고려해야 한다. local fetching은 component를 uncoupled하게 유지하게 해주고, cache()는 두 leaf component가 같은 데이터를 필요로 하는지 신경 쓰지 않아도 되게 해준다. cache()가 없다면 duplicate work를 피하려고 data fetching을 더 위쪽 component로 hoist해야 한다. 하지만 그렇게 하면 composition이 깨지고 component 사이에 coupling이 생긴다. 그래서 cache() API가 강력하다.
Next.js에서 dynamic metadata를 만들 때도 흔히 사용할 수 있다. page의 generateMetadata 안에서 data를 fetch한다면, 같은 page에서 같은 data를 다시 fetch하지 않도록 data fetching function을 cache()로 감싸고 싶을 수 있다.
하지만 cache() API는 preload pattern과 함께 data를 preload하는 데도 사용할 수 있다. 이제 server fetch waterfall을 피하는 방법을 보자.
Use Case
Next.js App Router에서 PostsPage server component가 있다고 해보자. URL parameter를 받고, async server component인 Post component를 렌더링한다. 또한 Post가 data를 fetch하는 동안 fallback loading state를 보여주기 위해 Suspense를 사용한다.
export default async function PostPage({ params }: { params: Promise<{ postId: string }> }) { const { postId } = await params; return ( <div> <h1>Post: {postId}</h1> <Suspense fallback={<div>Loading post...</div>}> <Post postId={postId} /> </Suspense> </div> );}Post component는 특정 post를 async로 fetch하고, 그 post의 comments list를 또 다른 suspense boundary 안에서 렌더링한다.
async function Post({ postId }: { postId: string }) { const post = await getPost(postId); return ( <div className="rounded border-2 border-blue-500 p-4"> <h2>Title: {post?.title}</h2> Post comments: <Suspense fallback={<div>Loading comments...</div>}> <Comments postId={postId} /> </Suspense> </div> );}Comments component는 post의 comments를 async로 fetch한다.
async function Comments({ postId }: { postId: string }) { const comments = await getComments(postId); return ( <div className="rounded border-2 border-slate-500 p-4"> <h2>Comments</h2> <ul> {comments.map(comment => { return <li key={comment.id}>{comment.body}</li>; })} </ul> </div> );}Post와 Comments는 둘 다 server component이고, 둘 다 자기 데이터를 async로 fetch한다. 각 component가 자기 data와 UI를 함께 책임지므로 composition 측면에서는 좋다. 하지만 Comments component는 Post component가 await getPost(postId)를 끝내기 전까지 data fetching을 시작할 수 없다. Comments는 Post가 가져온 data에 의존하지 않는데도 Post 안에 있기 때문에 block된다. 이로 인해 fetch waterfall이 생긴다.
React Router v7이나 TanStack Start 같은 framework는 loader pattern으로 이 문제를 해결한다. route에 필요한 data를 모두 fetch하고 preload할 수 있게 하기 때문이다. 하지만 Next.js에는 이런 automatic optimization이 없다.
Naive Approach
먼저 waterfall 문제를 Posts component 쪽으로 data fetching을 hoist하고 Promise.all()로 post와 comments를 병렬 fetch하는 방식으로 해결해보자.
async function Post({ postId }: { postId: string }) { const [post, comments] = await Promise.all([getPost(postId), getComments(postId)]); return ( <div className="rounded border-2 border-blue-500 p-4"> <h2>Title: {post?.title}</h2> Post comments: <Comments comments={comments} /> </div> );}흔한 접근이고 잘 동작한다. 하지만 data coupling을 만든다. PostsPage 또는 Post component가 이제 두 data fetching function을 모두 알아야 한다. 나중에 Comments component를 제거한다면, 그 component를 제거하는 것뿐 아니라 Post 안의 comments fetching도 함께 제거해야 한다. 또한 comments가 post보다 느리다면 Post component는 comments fetch가 끝날 때까지 block된다.
만약 layout까지 hoist하게 되면, Promise.all() 안의 data가 모두 fetch될 때까지 전체 page rendering을 block할 수도 있다.
Solution
cache() API는 data fetch 결과를 cache할 수 있으므로, Comments component를 위한 data preload에 사용할 수 있다. data fetching function이 다음과 같다고 해보자.
const getPost = async (postId: string) => { await new Promise(resolve => setTimeout(resolve, 1000)); return [ { body: 'This is the first post on this blog.', id: 1, title: 'Hello World', }, // ... ].find(post => { return post.id.toString() === postId; });};const getComments = async (postId: string) => { await new Promise(resolve => setTimeout(resolve, 1000)); return [ { body: 'This is the first comment on this blog.', id: 1, postId: 1, }, // ... ].filter(comment => { return comment.postId.toString() === postId; });};앱을 실행하면 먼저 Post component의 Suspense fallback이 보이고, 그 다음 Comments component가 data fetching을 시작하면서 자기 fallback을 보여준다. 예시에서는 Post render에 1초, Comments render에 또 1초가 걸린다.
이제 cache() API를 사용해 Comments component의 data를 preload해보자. 먼저 getComments를 cache()로 감싼다.
const getComments = cache(async (postId: string) => { await new Promise(resolve => setTimeout(resolve, 1000)); return [ { body: 'This is the first comment on this blog.', id: 1, postId: 1, }, // ... ].filter(comment => { return comment.postId.toString() === postId; });});이제 더 위쪽 component에서 data fetch를 trigger할 수 있다. 필요한 argument가 있는 곳이면 어디든 가능하다. 예를 들어 PostsPage에서 이렇게 할 수 있다.
export default async function PostPage({ params }: { params: Promise<{ postId: string }> }) { const { postId } = await params; // Prefetch the comments, but don't await the promise, so it doesn't block rendering getComments(postId); return ( <div> <h1>Post: {postId}</h1> <Suspense fallback={<div>Loading post...</div>}> <Post postId={postId} /> </Suspense> </div> );}또는 Post component 안에서 할 수도 있다.
export async function Post({ postId }: { postId: string }) { // Prefetch the comments, but don't await the promise, so it doesn't block rendering getComments(postId); const post = await getPost(postId); // The suspense boundary around <Comments> will not be visible, because the await has already completed return ( <div className="rounded border-2 border-blue-500 p-4"> <h2>Title: {post?.title}</h2> Post comments: <Suspense fallback={<div>Loading comments...</div>}> <Comments postId={postId} /> </Suspense> </div> );}중요한 것은 promise를 await하지 않는 것이다. await하면 PostsPage rendering을 block한다.
이제 Comments component는 PostPage 또는 Post에서 이미 trigger한 preloaded data를 재사용할 수 있다. 예시에서는 두 promise가 모두 1초 뒤 resolve되므로, Post component가 render된 직후 Comments도 waterfall 없이 거의 바로 render된다.
원문의 code example은 GitHub에 있고, Vercel에 deploy되어 있다.
Additional notes
preload pattern을 추가할 때는 refactoring 중 숨은 data coupling이 생길 수 있다는 점을 기억해야 한다. cache() API로 data를 preload하고 있는데 나중에 component tree를 refactor하면서 deep child를 삭제하면, 사용되지 않는 preloading data fetch가 남을 수 있다. data fetch가 그것을 사용하는 component와 직접 묶여 있지 않기 때문이다. 최악의 경우, 어디에서도 쓰지 않는 preload fetch가 남는다.
이 hidden coupling에 대한 해결책
Next.js 문서에서도 언급하듯, 좋은 practice는 prefetch가 필요한 component에서 data fetching function의 copy를 export하고 preloadComments 같은 이름을 붙이는 것이다.
export const preloadComments = (id: string) => { void getComments(id);};export async function Comments({ postId }: { postId: string }) { const comments = await getComments(postId); return ( <div className="rounded border-2 border-slate-500 p-4"> <h2>Comments</h2> <ul> {comments.map(comment => { return <li key={comment.id}>{comment.body}</li>; })} </ul> </div> );}이제 Post component에서 raw getComments 대신 preload function을 사용할 수 있다.
export async function Post({ postId }: { postId: string }) { preloadComments(postId); const post = await getPost(postId); return ( <div className="rounded border-2 border-blue-500 p-4"> <h2>Title: {post?.title}</h2> Post comments: <Suspense fallback={<div>Loading comments...</div>}> <Comments postId={postId} /> </Suspense> </div> );}이렇게 하면 refactoring할 때 이것이 Comments component를 위한 prefetching function이라는 점이 분명해진다.
어느 쪽이든 preload pattern을 추가할 때는 한 번 생각해야 한다. 미리 모든 곳에 넣기보다 specific performance problem을 해결하기 위해 사용하자. refactor할 때도 preload가 여전히 필요한지 확인해야 한다.
또 하나 기억할 점은 Next.js에서 fetch() API를 사용할 때 data가 이미 render 단위로 cached/memoized된다는 것이다. 따라서 fetch() API로 같은 데이터를 여러 component에서 가져오거나 preload하는 경우라면 굳이 cache()로 감쌀 필요가 없다. cache() API는 주로 database를 직접 호출하거나, custom data fetching function 또는 computation을 실행할 때 유용하다.
Key Takeaways
cache()API는 data fetch나 computation의 결과를 cache할 수 있게 하며, data fetch에 대한 per-render caching/memoization을 제공한다.cache()API는 data coupling을 줄이고 component composition을 유지하는 데 사용할 수 있다.- 여러 component에서 같은 data를 fetch하고 있다면, data fetching이 이미
fetch()API를 사용하고 있는 경우가 아니라면cache()API를 고려해볼 만하다. cache()API는 data preload에도 사용할 수 있다. 깊은 component가 preloaded data를 재사용하게 하여 data fetching waterfall을 피하고 performance를 높일 수 있다. preload function을await하지 않는 것을 잊지 말자.- preload pattern을 어디에, 언제 적용할지 신중하게 생각해야 component hierarchy에 불필요한 복잡성을 만들지 않는다.
결론
이 글에서는 React cache()를 사용해 data coupling을 줄이고, data를 preload하며, performance를 최적화하고 waterfall fetching을 피하는 방법을 살펴보았다.
이 주제에 대해 통찰 있는 논의를 나눠준 Robin Wieruch와 Sam Selikoff에게 감사한다고 원문은 덧붙인다.
cache() API와 그 사용법을 이해하는 데 이 글이 도움이 되었기를 바란다. 질문이나 의견이 있다면 Aurora Scharff에게 알려주고, 더 많은 업데이트를 보고 싶다면 그녀의 X를 팔로우하면 된다. Happy coding! 🚀
댓글
댓글을 불러오는 중...