React 서버 컴포넌트에서의 데이터 가져오기 및 캐싱 최적화
Wenhao Wang
Dev Intern · Leapcell

소개
끊임없이 진화하는 프론트엔드 개발 환경에서, 더 빠르고 효율적이며 사용자 친화적인 웹 애플리케이션을 구축하려는 노력은 여전히 중요합니다. 전통적인 클라이언트 측 렌더링(CSR)은 초기 페이지 로드 성능, SEO 및 복잡한 데이터 종속성과 관련된 문제에 직면하는 경우가 많습니다. 서버 측 렌더링(SSR)은 어느 정도 해결책을 제공하지만, 초기 렌더링 후에도 상당한 데이터 가져오기 책임을 클라이언트에 다시 위임하는 경우가 많습니다. 바로 여기서 React 서버 컴포넌트(RSC)가 혁신적인 패러다임으로 등장합니다. RSC는 렌더링 및 데이터 관리 방식을 새롭게 도입하여, 서버에 더 많은 렌더링 작업을 푸시하고 데이터 가져오기 및 캐싱에 대한 우리의 사고방식을 근본적으로 변화시킵니다. 이러한 변화는 상당한 성능 향상을 가져오고 애플리케이션 아키텍처를 단순화할 것을 약속하며, 현대 개발자가 그 의미를 이해하는 것이 중요합니다. 이 글에서는 RSC를 통해 가능해진 데이터 가져오기 패턴 및 캐싱 전략을 심층적으로 살펴보고, 그 강력함을 보여주기 위한 실용적인 통찰력과 코드 예제를 제공할 것입니다.
핵심 개념
RSC의 데이터 가져오기 및 캐싱에 대한 구체적인 내용을 살펴보기 전에, RSC에 필수적인 몇 가지 핵심 개념을 파악하는 것이 중요합니다.
-
React 서버 컴포넌트(RSC): 전적으로 서버에서 렌더링되는 RSC를 통해 개발자는 민감한 자격 증명을 클라이언트에 노출하지 않고도 데이터베이스나 파일 시스템과 같은 서버 측 리소스에 직접 액세스할 수 있습니다. 이 컴포넌트는 UI를 재구성하기 위한 지침을 포함하는 특수 페이로드(HTML이 직접 아님)로 렌더링됩니다. 이 컴포넌트는 가볍고 상태 또는 생명주기 메서드가 없으며, 요청될 때까지 클라이언트 측 상호 작용이 필요하지 않은 정적 및 동적 콘텐츠를 위해 설계되었습니다.
-
클라이언트 컴포넌트(CC): 브라우저에서 실행되는 전통적인 React 컴포넌트입니다. 브라우저 API, 상태 및 효과에 액세스할 수 있습니다. RSC 트리 내에서 사용될 때, 클라이언트에서 초기화될 플레이스홀더로 취급됩니다.
-
"use client"
지시문: 파일 상단에 있는 이 특수 주석은 컴포넌트를 클라이언트 컴포넌트로 명시적으로 표시합니다. 이 지시문 없이는 RSC 환경에서 React 애플리케이션 내의 모든 컴포넌트가 기본적으로 서버 컴포넌트로 간주됩니다. -
Suspense: 데이터 가져오기가 진행 중일 때와 같이 특정 조건이 충족될 때까지 UI의 일부 렌더링을 지연할 수 있도록 하는 React 기능입니다. RSC의 맥락에서 Suspense는 응답 스트리밍 및 비동기 데이터를 처리하는 데 중요한 역할을 합니다.
-
서버 액션: React에 도입된 새로운 기본 기능으로, 간단한 함수 호출을 통해 클라이언트 컴포넌트(또는 다른 서버 컴포넌트)에서 직접 서버 측 코드를 실행할 수 있습니다. 이를 통해 명시적인 API 호출 없이도 원활한 변경 패턴과 양식 제출이 가능합니다.
RSC에서의 데이터 가져오기 패턴
RSC는 데이터가 필요한 가장 가까운 컴포넌트 내에서 직접 데이터를 가져올 수 있도록 하여 데이터 가져오기 과정을 획기적으로 단순화합니다. 이는 컴포넌트가 순차적으로 데이터를 가져와 지연을 초래할 수 있는 클라이언트 측 가져오기에서 흔히 발생하는 "폭포수 문제"를 제거합니다.
직접 서버 측 데이터 액세스
가장 간단한 패턴은 서버 컴포넌트 내에서 직접 데이터를 가져오는 것입니다. RSC는 서버에서 실행되므로 서버 측 리소스에 직접 액세스할 수 있습니다.
// app/page.tsx (서버 컴포넌트) import { Product } from '@/lib/types'; import { getProducts } from '@/lib/db'; // 제품을 가져오는 서버 측 함수 export default async function HomePage() { const products: Product[] = await getProducts(); // 직접 서버 측 데이터 가져오기 return ( <div> <h1>Our Products</h1> <ul> {products.map((product) => ( <li key={product.id}>{product.name} - ${product.price}</li> ))} </ul> </div> ); } // lib/db.ts (예제 서버 측 데이터 가져오기) interface Product { id: string; name: string; price: number; } export async function getProducts(): Promise<Product[]> { // 데이터베이스 호출 시뮬레이션 await new Promise(resolve => setTimeout(resolve, 500)); return [ { id: '1', name: 'Laptop', price: 1200 }, { id: '2', name: 'Keyboard', price: 75 }, { id: '3', name: 'Mouse', price: 25 }, ]; }
이 예제에서 HomePage
서버 컴포넌트는 getProducts()
를 직접 호출하며, 이는 데이터베이스 쿼리 또는 내부 API 호출일 수 있습니다. 데이터는 컴포넌트가 렌더링되기 전에 엄격하게 서버에서 사용 가능합니다.
동 배치(Colocated) 데이터 가져오기
이 패턴은 각 서버 컴포넌트가 필요한 데이터만 가져오도록 허용함으로써 직접 서버 측 액세스를 확장합니다. 이 세분화된 데이터 가져오기는 과도한 가져오기를 방지하고 데이터 종속성 관리를 단순화합니다.
// app/products/[id]/page.tsx (서버 컴포넌트) import { Product } from '@/lib/types'; import { getProductById } from '@/lib/db'; import ProductDetailsClient from '@/components/ProductDetailsClient'; // 클라이언트 컴포넌트 export default async function ProductPage({ params }: { params: { id: string } }) { const product: Product | null = await getProductById(params.id); if (!product) { return <p>Product not found.</p>; } return ( <div> <h1>{product.name}</h1> <ProductDetailsClient product={product} /> {/* 서버에서 가져온 데이터를 클라이언트 컴포넌트에 전달 */} </div> ); } // components/ProductDetailsClient.tsx "use client"; import { Product } from '@/lib/types'; import { useState } from 'react'; interface ProductDetailsClientProps { product: Product; } export default function ProductDetailsClient({ product }: ProductDetailsClientProps) { const [quantity, setQuantity] = useState(1); return ( <div> <p>Price: ${product.price}</p> <p>{product.description}</p> <button onClick={() => setQuantity(q => q + 1)}>Add to Cart ({quantity})</button> </div> ); }
여기서 ProductPage
는 특정 제품 세부 정보를 서버에서 가져온 다음, 대화형 요소를 위해 해당 데이터를 ProductDetailsClient
에 전달합니다. ProductDetailsClient
자체는 데이터를 가져올 필요가 없습니다.
Suspense를 사용한 스트리밍
데이터를 가져오는 컴포넌트의 경우 렌더링에 시간이 걸립니다. Suspense를 사용하면 데이터가 가져와지는 동안 대체 UI를 표시하고, 준비되면 실제 콘텐츠를 스트리밍할 수 있습니다. 이는 애플리케이션의 인지 성능을 향상시킵니다.
// app/dashboard/page.tsx (서버 컴포넌트) import { Suspense } from 'react'; import UserProfile from '@/components/UserProfile'; import RecentOrders from '@/components/RecentOrders'; export default function DashboardPage() { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<p>Loading user profile...</p>}> <UserProfile /> </Suspense> <Suspense fallback={<p>Loading recent orders...</p>}> <RecentOrders /> </Suspense> </div> ); } // components/UserProfile.tsx (서버 컴포넌트) import { getUserData } from '@/lib/api'; // 서버 측 데이터 가져오기 export default async function UserProfile() { const user = await getUserData(); // 느린 데이터 가져오기 시뮬레이션 return ( <div> <h2>Welcome, {user.name}!</h2> <p>Email: {user.email}</p> </div> ); } // components/RecentOrders.tsx (서버 컴포넌트) import { getRecentOrders } from '@/lib/api'; // 서버 측 데이터 가져오기 export default async function RecentOrders() { const orders = await getRecentOrders(); // 느린 데이터 가져오기 시뮬레이션 return ( <div> <h2>Your Recent Orders</h2> <ul> {orders.map(order => ( <li key={order.id}>{order.item} - ${order.price}</li> ))} </ul> </div> ); } // lib/api.ts (예제 서버 측 API 호출) interface User { name: string; email: string; } interface Order { id: string; item: string; price: number; } export async function getUserData(): Promise<User> { await new Promise(resolve => setTimeout(resolve, 1500)); // 지연 시뮬레이션 return { name: 'Alice', email: 'alice@example.com' }; } export async function getRecentOrders(): Promise<Order[]> { await new Promise(resolve => setTimeout(resolve, 2000)); // 지연 시뮬레이션 return [ { id: 'a1', item: 'Book', price: 30 }, { id: 'a2', item: 'Pen', price: 5 }, ]; }
여기서 UserProfile
과 RecentOrders
는 데이터를 동시에 가져옵니다. Suspense
덕분에 초기에는 "Loading..." 메시지가 표시되고, 각 컴포넌트의 데이터가 준비되면 독립적으로 스트리밍됩니다.
RSC에서의 캐싱 전략
캐싱은 성능에 중요하며, RSC는 특히 fetch
API 사용과 React의 메모이제이션을 통해 React의 내장 캐싱 메커니즘과 매끄럽게 통합됩니다.
자동 요청 중복 제거 및 캐싱(fetch
사용)
서버 컴포넌트에서 네이티브 fetch
API를 사용할 때, React(특히 Next.js App Router와 같은 프레임워크)는 요청을 자동으로 중복 제거하고 응답을 캐시합니다. 즉, 서버에서 동일한 URL과 옵션으로 여러 컴포넌트에서 동일한 요청을 하면, fetch
는 실제로 한 번만 실행되고 결과를 공유합니다.
// lib/api.ts export async function fetchPosts() { // 동일한 URL과 옵션으로 여러 번 호출되면 중복이 제거됩니다. const res = await fetch('https://jsonplaceholder.typicode.com/posts'); if (!res.ok) throw new Error('Failed to fetch posts'); return res.json(); } // app/blog/page.tsx (서버 컴포넌트) import { fetchPosts } from '@/lib/api'; import PostsList from '@/components/PostsList'; export default async function BlogPage() { const posts = await fetchPosts(); // 이 호출은 캐시됩니다. return ( <div> <h1>Blog Posts</h1> <PostsList posts={posts} /> </div> ); } // app/components/RelatedPosts.tsx (서버 컴포넌트) import { Like } from '@/lib/types'; import { fetchPosts } from '@/lib/api'; // 이미 호출된 경우 캐시 히트 export default async function RelatedPosts() { const allPosts = await fetchPosts(); // 이 특정 호출은 캐시된 promise를 재사용합니다. // 관련 게시글을 필터링하는 로직 const relatedPosts = allPosts.slice(0, 3); return ( <div> <h2>Related Posts</h2> <ul> {relatedPosts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }
이 예제에서 BlogPage
와 RelatedPosts
(또는 다른 서버 컴포넌트)가 같은 렌더링 주기 내에서 fetchPosts()
를 호출하면, https://jsonplaceholder.typicode.com/posts
에 대한 실제 네트워크 요청은 한 번만 이루어집니다.
사용자 지정 데이터 가져오기 및 메모이제이션을 위한 cache()
fetch
를 사용하지 않는 데이터 가져오기(예: 직접 데이터베이스 호출, GraphQL 클라이언트 또는 기타 타사 라이비스)의 경우, React는 react
에서 cache()
함수를 제공하며, 이를 통해 서버에서 함수의 결과를 수동으로 중복 제거하고 캐시할 수 있습니다.
// lib/db.ts import { cache } from 'react'; import { User } from '@/lib/types'; // getUser 함수를 메모이제이션합니다. export const getUser = cache(async (userId: string): Promise<User | null> => { // 데이터베이스 호출 시뮬레이션 await new Promise(resolve => setTimeout(resolve, 300)); if (userId === '123') return { id: '123', name: 'Jane Doe', email: 'jane@example.com' }; return null; }); // app/profile/page.tsx (서버 컴포넌트) import { getUser } from '@/lib/db'; import UserProfileDetails from '@/components/UserProfileDetails'; export default async function ProfilePage() { const user = await getUser('123'); // 첫 번째 호출, 계산 및 캐시 if (!user) { return <p>User not found</p>; } return ( <div> <h1>Profile</h1> <UserProfileDetails user={user} /> <RecentActivity userId={user.id} /> </div> ); } // components/RecentActivity.tsx (서버 컴포넌트) import { getUser } from '@/lib/db'; // 같은 사용자 ID에 대한 캐시된 데이터를 재사용합니다. export default async function RecentActivity({ userId }: { userId: string }) { const user = await getUser(userId); // 이 호출은 userId '123'이 이미 가져와졌으면 캐시에서 검색합니다. if (!user) return null; return ( <div> <h2>Recent Activity for {user.name}</h2> {/* ... 사용자 활동 표시 ... */} </div> ); }
cache()
로 getUser
를 래핑함으로써, 같은 서버 렌더링 내에서 getUser('123')
에 대한 후속 호출은 데이터베이스 로직을 다시 실행하지 않고 메모이제이션된 결과를 검색합니다.
캐시된 데이터 재검증
캐싱은 효과적이지만, 데이터는 결국 오래됩니다. RSC와 통합되는 프레임워크는 캐시된 데이터를 재검증할 메커니즘을 제공하는 경우가 많습니다. 예를 들어, Next.js에서는 다음을 사용할 수 있습니다.
- 시간 기반 재검증: 전역
fetch
요청의 경우,revalidate
옵션(예:fetch('...', { next: { revalidate: 60 } })
)을 설정하면 지정된 초 후에 캐시가 재검증됩니다. - 요청 시 재검증:
revalidatePath
또는revalidateTag
함수를 사용하여 데이터 변경(예: 사용자가 프로필을 업데이트하여 캐시된 사용자 데이터를 무효화하는 경우) 후에 프로그래매틱 방식으로 특정 캐시된 데이터 항목을 무효화할 수 있습니다. 이는 종종 서버 액션 내에서 수행됩니다.
// app/actions.ts (서버 액션) "use server"; import { revalidatePath, revalidateTag } from 'next/cache'; import { updateUserInDb } from '@/lib/db'; export async function updateUser(formData: FormData) { const userId = formData.get('userId') as string; const newName = formData.get('name') as string; await updateUserInDb(userId, newName); // 사용자 프로필 페이지 및 'users' 태그가 지정된 모든 데이터에 대한 캐시 무효화 revalidatePath(`/profile/${userId}`); revalidateTag('users'); // '태그'가 지정된 데이터 가져오기에 대해 }
이를 통해 광범위한 캐싱이 적용된 후에도 변경 사항이 UI에 반영되도록 할 수 있습니다.
결론
React 서버 컴포넌트는 웹 애플리케이션 성능과 개발자 경험을 최적화하는 데 있어 중요한 도약입니다. 데이터 가져오기 및 렌더링의 상당 부분을 서버로 이동함으로써, RSC는 더 직접적이고 효율적인 데이터 액세스 패턴을 가능하게 하며, 클라이언트 측 번들 크기를 줄이고 초기 페이지 로드 시간을 개선합니다. fetch
중복 제거 및 cache()
API를 통한 지능형 캐싱 전략과 결합된 RSC를 통해 개발자는 빠르고 유지보수 가능한 고성능 애플리케이션을 구축할 수 있습니다. 서버 측의 힘과 클라이언트 측 상호 작용의 시너지는 풀스택 React 개발에 접근하는 방식을 재정의하며, RSC를 차세대 웹 애플리케이션의 기반으로 확고히 자리매김하게 합니다.