프로젝트 중 Next.js에서 서버 컴포넌트인 최상단의 페이지 컴포넌트에서, 클라이언트 컴포넌트인 자식 컴포넌트에게 데이터를 어떻게 넘겨줄 수 있을지에 대한 고민을 했다. 우선 전역 상태관리로 관리하는 데이터는 서버 컴포넌트에서 불러올 수 없다. (!!)
그럼 첫 번째 방법은, React Server Component Payload (RSC)를 활용하는 것이다.
import ClientComponent from './client-component';
import { fetchUserData } from './data-fetching';
export default async function ServerComponent() {
const userData = await fetchUserData();
return <ClientComponent userData={userData} />;
}
이렇게 서버 컴포넌트에서 api를 호출하는 함수를 실행시켜서 props로 넘겨준다. 가장 기본적인? 방법이다.
그럼 저 데이터를 활용하는 서버 컴포넌트를 띄울 때마다 매번 api를 호출해야되나?
서버 컴포넌트 간에 데이터를 공유할 수 있는 방법도 있다. 바로 React cache를 활용하는 것이다.
이 함수는 오직 서버 컴포넌트에서만 작동하며, 클라이언트 컴포넌트에서는 사용할 수 없다.
// utils/user-data.js
import { cache } from 'react';
export const getUserData = cache(async () => {
// 데이터 가져오기 로직
return userData;
});
// 서버 컴포넌트 1
import { getUserData } from '../utils/user-data';
export default async function ServerComponent1() {
const userData = await getUserData();
// ...
}
// 서버 컴포넌트 2
import { getUserData } from '../utils/user-data';
export default async function ServerComponent2() {
const userData = await getUserData();
// ...
}
이 방법을 사용하면 여러 서버 컴포넌트에서 동일한 데이터를 가져올 때 중복 요청을 방지할 수 있다. 여러 컴포넌트에서 같은 데이터를 요청해도 실제 네트워크 요청은 한 번만 발생한다.
다른 방법으로는 Next.js의 fetch 사용 시 데이터 캐싱 방법이 있다. Next.js에서는 fetch를 사용하면 자동으로 데이터를 캐싱하는데, revalidate 옵션을 사용해서 데이터의 유효 시간을 설정할 수 있다. 설정된 시간이 지나면 데이터가 stale 상태로 변경된다.
// 캐싱을 활용한 fetch
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // 1시간마다 재검증
});
return res.json();
}
그리고 next.tags 옵션을 사용하면 관련 데이터를 그룹화해 필요할 때 한 번에 다시 가져올 수 있다. 새 게시물을 생성하는 등 특정 이벤트가 발생할 때 관련 데이터를 모두 다시 가져오는 데 유용하다.
// 태그 지정
async function fetchPosts() {
const response = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
});
return response.json();
}
// 서버 액션에서 태그 기반 재검증
import { revalidateTag } from 'next/cache';
export async function updatePost() {
// 데이터 업데이트 로직...
// 'posts' 태그가 지정된 모든 데이터 재검증
revalidateTag('posts');
return { success: true };
}
그리고 아래와 같이 경로 기반으로도 가능하다.
import { revalidatePath } from 'next/cache';
export async function updatePost() {
// 데이터 업데이트 로직...
// '/blog' 경로와 관련된 모든 데이터 재검증
revalidatePath('/blog');
return { success: true };
}
또 다른 방법으로는 unstable_cache API를 활용한 데이터베이스 쿼리 결과 캐싱이 있다. 이 방법은 Next.js 14에서 도입되었다.
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
// 데이터베이스 쿼리 결과 캐싱
const getCachedPosts = unstable_cache(
async () => {
console.log('데이터베이스 쿼리 실행'); // 캐시 미스 시에만 실행됨
return db.posts.findMany();
},
['posts-cache'], // 캐시 키
{
revalidate: 3600, // 1시간 캐시
tags: ['posts'] // 캐시 태그
}
);
export default async function BlogPage() {
const posts = await getCachedPosts();
return <PostList posts={posts} />;
}
unstable_cache의 주요 매개변수는 아래와 같다.
1. fetchData: 캐싱할 데이터를 가져오는 비동기 함수
2. keyParts: 캐시 키를 구성하는 문자열 배열 (캐시 항목을 고유하게 식별하는 데 사용)
3. options: 캐싱 동작을 제어하는 옵션 객체
- revalidate: 캐시 유효 시간(초)
- tags: 캐시 항목에 연결할 태그 배열
함수 인자에 따라 다른 캐시 결과를 얻고 싶을 땐 아래와 같이 사용할 수도 있다. 풀스택 프레임워크답다.
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
// 사용자 ID에 따라 다른 데이터 캐싱
const getCachedUserPosts = unstable_cache(
async (userId) => {
console.log(`사용자 ${userId}의 게시물 조회`);
return db.posts.findMany({ where: { authorId: userId } });
},
['user-posts'], // 기본 캐시 키
{
revalidate: 60, // 1분 캐시
tags: ['user-posts']
}
);
export default async function UserPostsPage({ params }) {
const { userId } = params;
// userId가 함수에 전달되어 해당 사용자의 데이터만 캐싱
const posts = await getCachedUserPosts(userId);
return <PostList posts={posts} />;
}
데이터를 새롭게 가져오고 싶을 땐 아래와 같이 캐시를 무효화하면 된다.
import { revalidateTag } from 'next/cache';
export async function createPost(formData) {
'use server';
// 새 게시물 생성 로직...
// 'posts' 태그가 지정된 모든 캐시 항목 무효화
revalidateTag('posts');
// 'user-posts' 태그가 지정된 모든 캐시 항목 무효화
revalidateTag('user-posts');
return { success: true };
}
fetch 캐싱은 HTTP 요청에 적합하고, unstable_cache는 데이터베이스 쿼리와 같은 비 HTTP 데이터 소스에 적합하다.
지금까지는 React-Query를 이용하지 않고도 Next.js의 내장 데이터를 효율적으로 관리할 수 있는 방법을 알아보았다.
그럼 React-Query를 이용해서 서버 컴포넌트 -> 클라이언트 컴포넌트로 데이터를 전달하는 방법은 무엇이 있을까?
바로 prefetch를 사용해서 dehydrate를 한 후 hydrationBoundary로 클라이언트 컴포넌트를 감싸는 것이다.
그럼 그 클라이언트 컴포넌트에서는 프리페칭된 데이터를 별도의 api 호출 없이 즉시 사용할 수 있다.
이 방법은 서버 컴포넌트와 클라이언트 컴포넌트의 장점을 모두 활용하는 하이브리드 접근법이다.
// app/posts/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import PostList from './post-list' // 클라이언트 컴포넌트
// 데이터 가져오기 함수
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const queryClient = new QueryClient()
// 서버에서 데이터 프리페칭
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
무한 스크롤같은 경우, 첫 페이지만 서버에서 프리페칭하고 클라이언트에서 추가 페이지를 선택적으로 로드할 수 있도록 하는 방식을 이용해 최적화할 수 있다.
그런데 서버 컴포넌트에서는 useQuery와 같은 훅을 직접 사용할 수 없기 때문에(클라이언트에서만 작동) 필요시 위에서 봤던 React cache를 함께 이용할 수 있다.
React Query를 서버 컴포넌트와 함께 사용할 때는, 서버 컴포넌트는 주로 데이터 프리페칭에 사용하고, 실제 상태 관리와 데이터 조작은 클라이언트 컴포넌트에서 처리하는 것이 좋다. 이렇게 하면 서버 렌더링의 성능 이점과 클라이언트 측 상태 관리의 유연성을 모두 활용할 수 있게 된다.
참고
'Next.js' 카테고리의 다른 글
[Next.js & React-Query] 서버 컴포넌트에서 QueryClient 호출하기 (1) | 2025.04.28 |
---|---|
[Next.js] 프로젝트 초기 세팅을 해보자! (0) | 2025.04.06 |
React는 라이브러리, Next.js는 프레임워크? (0) | 2025.03.23 |
[리팩토링]Next.js 프로젝트 리팩토링 기록 정리 (0) | 2025.02.27 |