Server Component와 Client Component를 함께 구성하는 법

작성일:2026.08.13|수정일:2026.08.14|조회수:2

Server Component와 Client Component를 함께 구성하는 법

이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Server and Client Component Composition in Practice 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.

React Server Components는 data fetching을 서버에 남기고 client-side JavaScript를 줄일 수 있다는 점에서 큰 장점을 제공한다. 하지만 많은 개발자가 dismiss button이나 animation 같은 단순한 상호작용을 추가하려고 server component를 client component로 바꾸면서 이 장점을 쉽게 잃어버린다.

이 글에서는 client component와 server component를 효과적으로 compose하는 방법을 살펴본다. 책임을 명확히 유지하고, performance를 최적화하고, 재사용 가능한 component를 만드는 pattern을 다룰 것이다. 또한 server component와 함께 Suspense를 전략적으로 사용해 부드럽고 성능 좋은 사용자 경험을 만드는 방법도 살펴본다.

React Server Components란 무엇인가?

먼저 React Server Components가 무엇인지 짧게 복습해보자. React Server Components(RSC)는 서버에서 render되고, client에는 render된 결과만 전송된다. 전통적인 SSR과 달리 browser에서 실행되지 않는다.

TSX
// This runs on the server onlyasync function ServerComponent() {  const data = await fetch('https://api.example.com/data');  return <div>{data.title}</div>;}

Server Component에는 다음과 같은 장점이 있다.

좋다. 이제 이 글의 핵심으로 들어가보자.

핵심 패턴

간단히 데이터를 가져오는 server component가 있다고 해보자.

TSX
async function ServerComponent() {  const data = await getData();  return <div>{data}</div>;}

이 컴포넌트가 무엇을 하고, 어떤 책임을 갖는지는 명확하다.

이제 이 UI를 dismiss할 수 있게 state를 추가하고 싶다고 해보자. 단순한 일이다. 한 가지 방법은 컴포넌트에 'use client'를 붙이고, visibility를 state로 관리한 뒤, data를 prop으로 내려주는 것이다.

TSX
'use client';function ServerComponentTurnedClient({ data }) {  const [visible, setVisible] = useState(true);  if (!visible) return null;  return (    <div>      {data}      <button onClick={() => setVisible(false)}>Dismiss</button>    </div>  );}

또는 client에서 data fetching을 하기 위해 useSuspenseQuery 같은 API를 사용할 수도 있다.

TSX
'use client';function ServerComponentTurnedClient() {  const { data } = useSuspenseQuery({    queryKey: ['data'],    queryFn: getData,  });  const [visible, setVisible] = useState(true);  if (!visible) return null;  return (    <div>      {data}      <button onClick={() => setVisible(false)}>Dismiss</button>    </div>  );}

또는 use 같은 API를 쓰거나, Next.js에서 data fetching을 해결하는 다른 방법을 선택할 수도 있다. 다만 이 글의 초점은 거기에 있지 않으므로 자세히 들어가지는 않는다.

문제가 보이는가? ServerComponentTurnedClient는 이제 client component가 되었고, data fetching보다 더 많은 책임을 가진다. state management와 UI rendering까지 함께 처리한다.

이 문제를 피하기 위해 따라야 할 핵심 pattern은 다음과 같다. ServerComponent를 client component로 바꾸는 대신, state와 UI interaction을 처리하는 client wrapper의 child로 넘긴다.

TSX
'use client';function ClientWrapper({ children }) {  const [visible, setVisible] = useState(true);  if (!visible) return null;  return (    <div>      {children}      <button onClick={() => setVisible(false)}>Dismiss</button>    </div>  );}

이제 이렇게 사용할 수 있다.

TSX
function Page() {  return (    <ClientWrapper>      <ServerComponent />    </ClientWrapper>  );}

이 방식에서는 ServerComponent가 server component로 남아 data fetching만 책임진다. client component는 state와 UI rendering만 처리한다. 책임이 명확하게 나뉘고, server component의 compositional benefit도 유지되며, browser로 보내야 하는 client-side JavaScript도 최소화된다. 두 component는 이제 자유롭게 compose할 수 있고, 서로 다른 context에서도 재사용할 수 있다.

예시 1: Motion Wrapper

간단한 예시부터 보자. Motion animation을 붙이고 싶지만, server component의 data fetching에는 영향을 주고 싶지 않다. animation 때문에 server component 전체를 client component로 바꾸는 대신, animation만 처리하는 client component로 감싸면 된다.

TSX
// components/ui/MotionWrappers.tsx'use client';import { motion, HTMLMotionProps } from 'framer-motion';export function MotionDiv(props: HTMLMotionProps<'div'>) {  return <motion.div {...props}>{props.children}</motion.div>;}

server component에서는 이렇게 사용한다.

TSX
import { MotionDiv } from '@/components/ui/MotionWrappers';async function ServerComponent() {  const data = await getData();  return (    <MotionDiv initial={{ opacity: 0 }} animate={{ opacity: 1 }}>      {data}    </MotionDiv>  );}

예시 2: “Show More” Component

product category 목록을 렌더링하는 간단한 component가 있다고 하자.

TSX
async function CategoryList() {  const categories = await getCategories();  return (    <ul>      {categories.map((category) => (        <li key={category.id}>{category.name}</li>      ))}    </ul>  );}

category가 너무 많아 처음에는 몇 개만 보여주고, 사용자가 더 볼 수 있게 toggle하고 싶다.

CategoryList를 client component로 바꾸는 대신, “Show More” logic만 처리하는 재사용 가능한 ShowMore client component를 만들 수 있다. CategoryList는 server component로 남고, data fetching은 서버에 유지된다.

여기서는 특정 개수의 child만 toggle해야 하므로 조금 창의적인 접근이 필요하다. React.Children API를 사용해보자. 우리는 ShowMore 자식 요소와 표시할 초기 항목 수를 인수로 받아 "더 보기" 로직을 처리하는 컴포넌트를 만들 수 있다 .

TSX
// components/ui/ShowMore.jsx'use client';export default function ShowMore({ children, initial = 5 }) {  const [expanded, setExpanded] = useState(false);  const items = expanded ? children : Children.toArray(children).slice(0, initial);  const remaining = Children.count(children) - initial;  return (    <div>      <div>{items}</div>      {remaining > 0 && (        <div>          <button onClick={() => setExpanded(!expanded)}>            {expanded ? 'Show Less' : `Show More (${remaining})`}          </button>        </div>      )}    </div>  );}

CategoryList에서는 이렇게 쓸 수 있다.

TSX
import ShowMore from '@/components/ui/ShowMore';async function CategoryList() {  const categories = await getCategories();  return (    <ShowMore initial={5}>      {categories.map((category) => (        <div key={category.id}>{category.name}</div>      ))}    </ShowMore>  );}

이제 server와 client의 책임은 분리되어 있고, code도 깔끔하다. ShowMore는 다른 곳에서도 쓸 수 있는 UI component가 되고, CategoryList는 data fetching에 집중한다. 물론 실제 앱에서는 empty category 같은 edge case도 다뤄야 한다.

예시 3: Automatic Scroller

server에서 message를 가져와 렌더링하는 chat box server component가 있다고 해보자.

TSX
async function Chat() {  const messages = await getMessages();  return (    <div className="chat-container">      {messages.map((message) => (        <div key={message.id}>{message.text}</div>      ))}    </div>  );}

새 message가 추가될 때 chat bottom으로 자동 scroll하고 싶다면 어떻게 해야 할까? chat component를 client component로 바꾸고, ref를 반환하는 useAutoScroll custom hook을 사용할 수 있다. 하지만 그러면 이 component는 다시 data fetching과 UI rendering을 모두 책임지게 된다. data fetching도 바꿔야 하고, message를 client에서 불필요하게 hydrate하게 된다.

대신 이 logic만 처리하는 재사용 가능한 AutoScroller component를 만들 수 있다.

TSX
// components/ui/AutoScroller.jsx'use client';export default function AutoScroller({ children, className }) {  const ref = useRef<HTMLDivElement>(null);  useEffect(() => {    const mutationObserver = new MutationObserver(() => {      if (ref.current) {        ref.current.scroll({ behavior: 'smooth', top: ref.current.scrollHeight });      }    });    if (ref.current) {      mutationObserver.observe(ref.current, {        childList: true,        subtree: true,      });    }  }, []);  return (    <div ref={ref} className={className}>      {children}    </div>  );}

chat component에서는 이렇게 사용한다.

TSX
import AutoScroller from '/@/components/ui/AutoScroller';async function Chat() {  const messages = await getMessages();  return (    <AutoScroller className="chat-container">      {messages.map((message) => (        <div key={message.id}>{message.text}</div>      ))}    </AutoScroller>  );}

서버에서 데이터를 가져와 ProductCard를 렌더링하는 component가 있다고 하자.

TSX
// product/ProductCards.jsxasync function ProductCards() {  const cardData = await getCardData();  return (    <div className="product-cards">      {cardData.map((card) => (        <ProductCard key={card.id} title={card.title} image={card.image} />      ))}    </div>  );}function ProductCard({ title, image }) {  // ...}

이제 여기에 interactive carousel 효과를 추가해보자. logic과 UI rendering을 처리하는 재사용 가능한 Carousel component를 만든다.

TSX
// components/ui/Carousel.jsx'use client';export default function Carousel({ children }) {  const items = Children.toArray(children);  const [i, setI] = useState(0);  return (    <div className="carousel">      <button onClick={() => setI(i === 0 ? items.length - 1 : i - 1)}>Prev</button>      <div>{items[i]}</div>      <button onClick={() => setI(i === items.length - 1 ? 0 : i + 1)}>Next</button>    </div>  );}

여기서도 Children API를 사용해 carousel item을 다룬다. 이 예시는 pattern을 설명하기 위한 단순화된 형태다.

ProductCards는 server component로 남아 데이터를 가져오고, server-rendered ProductCard children을 Carousel에 넘긴다.

TSX
// product/ProductCards.jsximport Carousel from '@/components/ui/Carousel';async function ProductCards() {  const cardData = await getCardData();  return (    <div className="product-cards">      <Carousel>        {cardData.map((card) => (          <ProductCard key={card.id} title={card.title} image={card.image} />        ))}      </Carousel>    </div>  );}

data fetching은 서버에 남고 carousel logic은 client에 남는다. 책임이 분리된다. Carousel은 다른 곳에서도 쓸 수 있는 UI component가 될 수 있고, 필요하다면 product-specific logic이나 styling을 처리하는 ProductCarousel wrapper를 따로 만들 수도 있다.

여기까지가 client component와 server component를 효과적으로 compose하는 몇 가지 예시다. 다음에 client-side interaction이 필요해질 때 server component를 client component로 바꾸기 전에, composition으로 해결할 수 있는지 먼저 보자.

이제 Suspense를 사용한 server component composition pattern으로 넘어가보자.

예시 5: Personalized Banner

사용자에게 discount 정보를 알려주는 personalized banner component가 있다고 해보자.

TSX
// Banner.jsxasync function PersonalizedBanner() {  const user = await getCurrentUser();  const discount = await getDiscountData(user.id);  return <div className="banner">Welcome back, {user.name}! You currently have {discount}% off your next purchase.</div>;}

server component에서 async call을 실행한다면 자연스럽게 Suspense로 감싸고 fallback UI를 제공하게 된다.

TSX
export default function Page() {  return (    <Suspense fallback={<BannerSkeleton />}>      <PersonalizedBanner />    </Suspense>  );}

이것은 data fetching 중 page rendering을 unblock하기 위해 중요하다. fallback이 최종 content와 같은 dimension을 가진다면 동작은 한다. 그렇지 않으면 CLS(Cumulative Layout Shift)가 생길 수 있다. 하지만 이 fallback은 사용자에게 의미 있는 정보를 제공하지 않는다. 더 나은 대안이 있을 수 있다.

여러 목적에 사용할 수 있는 generic GeneralBanner component를 만들고, personalized data와 compose해보자.

TSX
// Banner.jsxfunction GeneralBanner() {  return (    <div className="banner">      Sign up today for our newsletter and get 10% off your next purchase!      <Link href="/signup">        Sign up      </Link>    </div>  );}

사용자가 로그인하지 않았다면 PersonalizedBanner에서 generic banner를 반환한다.

TSX
// Banner.jsxasync function PersonalizedBanner() {  const user = await getCurrentUser();  if (!user) {    return <GeneralBanner />;  }  const discount = await getDiscountData(user.id);  return <div className="banner">Welcome back, {user.name}! You currently have {discount}% off your next purchase.</div>;}

page에서는 이들을 함께 compose한다.

TSX
export default function Page() {  return (    <Suspense fallback={<GeneralBanner />}>      <PersonalizedBanner />    </Suspense>  );}

async call이 실행되는 동안에는 GeneralBanner가 보이고, data가 준비되면 personalized banner가 렌더링된다. 사용자가 로그인하지 않았다면 그대로 GeneralBanner가 보인다.

이제 앞에서 본 pattern처럼 banner container로 감싸보자. styling과 dismiss 기능은 container가 처리하고, PersonalizedBannerGeneralBanner는 content를 처리한다.

TSX
// BannerContainer.jsx'use client';function BannerContainer({ children }: BannerContainerProps) {  const [visible, setVisible] = useState(true);  if (!visible) return null;  return (    <div className="banner">      {children}      <button onClick={() => setVisible(false)}>Dismiss</button>    </div>  );}

DiscountBanner를 default export로 만들고, BannerContainer 안에서 PersonalizedBanner를 감싼다.

TSX
// Banner.jsxexport default function DiscountBanner() {  return (    <BannerContainer>      <Suspense fallback={<GeneralBanner />}>        <PersonalizedBanner />      </Suspense>    </BannerContainer>  );}

page에서는 이렇게 사용한다.

TSX
// page.jsximport DiscountBanner from './DiscountBanner';export default function Page() {  return (    <div className="page">      <DiscountBanner />      {/* Other content */}    </div>  );}

아름답다.

예시 6: Product Page

server에서 product data를 가져와 렌더링하는 reusable Product component가 있다고 해보자.

TSX
async function Product({ productId }) {  const product = await getProductData(productId);  return (    <div className="product">      <h2>{product.name}</h2>      <p>{product.description}</p>      <p>Price: ${product.price}</p>    </div>  );}

modal view에서는 이 component만으로 충분하다. 하지만 single product page에서는 product details나 wishlist에 저장하는 action 같은 추가 정보가 필요할 수 있다.

추가 product data를 가져오는 ProductDetails component를 만든다.

TSX
async function ProductDetails({ productId }) {  const productDetails = await getProductDetails(productId);  return (    <div className="product-details">      <h3>Details</h3>      <p>{productDetails.details}</p>      <form action={saveToWishlist.bind(null, productId)}>        <button type="submit">Save to Wishlist</button>      </form>    </div>  );}

추가 product info는 Product component의 styling과 layout 안에 렌더링되어야 한다. 이를 위해 Productdetails prop을 노출하게 만들 수 있다.

TSX
async function Product({ productId, details }) {  const product = await getProduct(productId);  return (    <div className="product">      <h2>{product.name}</h2>      <p>{product.description}</p>      <p>Price: ${product.price}</p>      {details}    </div>  );}

modal view에서는 standard Product component를 그대로 사용한다.

TSX
// app/(.)product/[productId]/page.jsxexport default function ProductModal({ params }) {  return (    <div className="product-modal">      <Product productId={params.productId} />    </div>  );}

product page에서는 두 component를 함께 compose한다.

TSX
// app/product/[productId]/page.jsxexport default function ProductPage({ params }) {  return (    <div className="product-page">      <Product productId={params.productId} details={        <ProductDetails productId={params.productId} />      }>      </Product>    </div>  );}

data fetching 중 page rendering을 unblock하려면 Suspense로 감쌀 수 있다.

TSX
// app/product/[productId]/page.jsxexport default function ProductPage({ params }) {  return (    <div className="product-page">      <Suspense fallback={<ProductSkeleton />}>        <Product productId={params.productId} details={          <Suspense fallback={<ProductDetailsSkeleton />}>            <ProductDetails productId={params.id} />          </Suspense>        }>        </Product>      </Suspense>    </div>  );}

여기에 preload pattern을 조합해 product data를 미리 preload하면, server component composition을 효과적으로 활용하면서 performance까지 최적화한 product page를 만들 수 있다.

여기까지다. 이제 key takeaway를 정리해보자.

Key Takeaways

결론

이 글에서는 client component와 server component를 효과적으로 compose하는 방법을 살펴보았다. 책임을 명확히 유지하고, performance를 최적화하고, 재사용 가능한 component를 만드는 여러 예시를 보았다. data fetching과 UI rendering을 분리하는 핵심 pattern을 따르면 유지보수하기 쉽고 performance에 유리한 component를 만들 수 있다.

이 주제를 더 읽고 싶다면 Ryan TorontoTwofold Framework blog post on composable streaming with Suspense를 추천한다. Suspense와 server component composition을 더 advanced example과 함께 깊게 다룬다.

이 글이 server component composition을 이해하는 데 도움이 되었기를 바란다. 질문이나 의견이 있다면 Aurora Scharff에게 알려주고, 더 많은 업데이트를 보고 싶다면 그녀의 X를 팔로우하면 된다. Happy coding! 🚀

댓글

댓글을 불러오는 중...