React Server Component

김재한·2024년 1월 2일
1

Next 14

목록 보기
1/5
post-thumbnail

서론

React 18 버전에서 새로 나타난 Server Component 에 호기심이 생긴 와중에

Next.js 13.4 버전에서부터는 Server Component 가 기본 컴포넌트로 채택되면서

이렇게 한번 정리해보고자 한다.

CSR & SSR & SSG 등은 컴포넌트 렌더링 종류이고, Client Component & Server Component 는 컴포넌트 종류라는걸 혼동하지 말자 ❗️

Client Component

React 18 에서 서버 컴포넌트가 등장하면서 기존에 사용했던 컴포넌트들을 클라이언트 컴포넌트 라고 부른다.

클라이언트 컴포넌트 도 서버에서 pre-render 된 이후

클라이언트에서 자바스크립트가 실행되면서 Interactive하게 렌더링(Hydrate) 된다.

'use client'; // 이렇게 선언
 
import { useState } from 'react';
 
export default function Counter() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

App router에는 서버 컴포넌트가 기본이기 때문에 사용하려면 최상단에 "use client"를 선언해 주어야 한다.

☝🏻 "use client"
이 파일의 컴포넌트들은 클라이언트 컴포넌트이며, 클라이언트에서 다시 렌더링 할 수 있도록 JS번들에 포함해야 한다. 라는 것을 리액트에 알리는 역할을 한다.

클라이언트 컴포넌트 는 상태가 바뀜에 따라 자식 컴포넌트들도

리렌더링이 가능해야 하기 때문에 자식 컴포넌트들은

'use client' 를 입력하지 않더라도 클라이언트 컴포넌트로 취급된다.

💡 Client Component 는 리프 노드로 구성하는게 성능상 좋다

leaf Node

클라이언트 컴포넌트는 리렌더링이 가능해야하기 때문에 JS번들에 소스를 포함시켜 용량이 늘어나고

렌더링 속도 또한 서버 컴포넌트 에 비해 느리다. 😅

부모 컴포넌트가 클라이언트일 경우, 자식 컴포넌트 모두 클라이언트 컴포넌트로 취급하기 때문에

리렌더링이 필요한 부분만 클라이언트 컴포넌트로 구성한다.

Server Component

서버 컴포넌트가 등장한 배경에는 성능 향상과 getServerSideProps(SSR) 의 단점을 보완하기 위해서이다.

[ getServerSideProps 보완점 ]

  1. getServerSideProps 는 루트 컴포넌트에서만 실행되기 때문에, 데이터가 보여지는 특정 컴포넌트에서만 사용할 수 없다. ( Props Drilling )

  2. Next.js, Gatsby, Remix 등 주요 프레임워크가 각자의 접근 방식을 갖고있기 때문에 표준화 되어있지 않다.

  3. 클라이언트에서 하이드레이션 할 필요가 없지만, 모든 컴포넌트가 리렌더링 된다.

🚨 한가지 주의해야 할 사항은 서버 컴포넌트는 한 번 렌더링되면 라우터 이동시에는 리렌더링이 발생하지 않는다.
( 데이터 변경x, url이동이나 새로고침 시 데이터 변경 )

따라서, 상황에 따라 변경이 필요한 부분은 클라이언트 컴포넌트로 만들어주어야 한다.

Server Component
이제는 페이지 단위로 SSR을 해왔다면 이제는 서버 컴포넌트와 클라리언트 컴포넌트를 적절하게 사용해 성능을 향상시킬 수 있다. 😃

기존 getServerSideProps & getStaticProps 함수는 루트 컴포넌트에서만 실행되기 때문에 페이지 단위로 SSR, SSG 가 적용된다.

❗️마지막으로 서버컴포넌트는 Next.js 13.4 이상 버전에서만 사용이 가능하다.

Server Component 동작 방식

동작 방식을 알아두면 좋을 것 같아 찾아보던 중 좋은 글을 찾아 옮겨 적어본다.

아래는 Client Component(blue)Server Component(yellow)이 함께 사용된 것을 트리화 시킨 것이다.

RSC 동작 방식

사용자가 페이지를 띄우기 위해 서버에 요청을 하면, 내부에서는 컴포넌트 트리를 root에서부터 Serialization 한 JSON 형태로 재구성 시킨다.

노드를 탐색하면서 클라이언트 컴포넌트 를 만나게 되면 서버에서는 해석하지 않고 이 자리는 클라이언트 컴포넌트가 렌더링 되는 위치입니다 라는 PlaceHolder를 대신 배치한다.

아래는 직렬화 된것을 도식화 시킨것이다.

이렇게 도출된 결과를 Stream 형태 로 클라이언트가 전달받게 되고

함께 다운로드한 JS번들 을 참조해 Module Reference(Client Component)타입이 등장할 때마다 렌더링해 빈 공간을 채우고 Dom에 반영해 실제 화면에 보여지게 된다.

Server Component 장점

☝🏻 Zero Bundle Size

서버에서 이미 모두 실행된 후 직렬화된 JSON 형태로 전달되기 때문에 어떠한 bundle도 필요하지 않다.

컴포넌트 소스 뿐만 아니라 사용하는 외부 라이브러리의 경우에도 번들에 포함될 필요가 없다.

기존 SSR을 사용했을 경우 초기 로딩속도에 이점이 있을 뿐 CSR과 동일한 사이즈의 js 번들을 다운받아 Hydration 하기 때문에 CSR 대비 TTI(Time To Interactive)에 이점이 크진 않았지만

서버 컴포넌트 를 도입하면 다운받아야 하는 번들 사이즈가 줄어들게 되므로 TTI 개선에 기여할 수 있다.

✌🏻 No More getServerSideProps / getStaticProps

기존 Next.js에서는 위의 두 함수를 이용해 서버에 접근하고 page에 props를 통해 넘겨주는 구조였다.

이러한 구조로 실제 데이터를 사용하는 하위 컴포넌트에는 props drilling이 불가피했다.

반면 서버 컴포넌트그 자체가 서버에서 렌더링 되므로 data가 필요한 하위 컴포넌트에서 직접 data fetch가 가능 하다.

🤟🏻 Automatic Code Splitting

code splitting을 하기 위해서는 React.Lazydynamic import를 했어야 했다.

하지만 서버 컴포넌트 에서 클라이언트 컴포넌트를 import하게되면 자동으로 dynamic import가 적용된다.

동작 방식에서 설명했듯이, 서버에서 렌더링 과정(Serialization)에서는 클라이언트 컴포넌트가 실행되지 않고

클라이언트에서 직렬화된 Stream을 해석하면서 클라이언트 컴포넌트 타입을 발견하면 그 때 로딩한다.

🙌🏻 컴포넌트 단위 Refetch

SSR의 경우 완성된 html을 내려주기 때문에 작은 변경사항이 발생하면 페이지 전체를 새로 그려서 받아왔다.

하지만 Server Component는 최종 결과물이 직렬화된 스트림이기 때문에 클라이언트에서 해당 스트림을 해석해 VirtualDom을 형성하고 달라진 부분만 Refetch 시킨다.

따라서 화면에 변경사항이 생기면 변경된 부분만 선택적으로 반영된다.

Server Component vs SSR 😎

서버에서 렌더링 된다는 점을 고려하면 이 둘이 비슷하다고 생각할 수 있지만

일어나는 시점최종 산출물이 다를 뿐더러

SSR은 렌더링 기법이고 서버 컴포넌트는 컴포넌트의 종류로 완전 별개의 개념이다.

작성한 소스코드가 브라우저에 보여지려면

  1. 컴포넌트가 실행되어 데이터가 해석되어야 하고

  2. 해석된 데이터가 HTML로 변환되는 과정이 필요하다.

여기서 서버 컴포넌트는 전자에 관여하고 SSR은 후자에 관여한다.

즉, 서버 컴포넌트의 결과물은 직렬화된 스트림(JSON)이고 SSR은 html이다.

Server Component vs Client Component

서버 컴포넌트는 클라이언트에서 리렌더링을 시키지 않기 때문에

JS번들에 코드가 포함되어 있지 않아 용량이 적고 렌더링이 빠르다는 이점이 있어

가능하다면 서버 컴포넌트를 사용하는게 좋다

하지만, StateHooks 등 상황에 따라 클라이언트 컴포넌트를 적절하게 사용해야 한다. 😊
Server vs Client

사용 시점

[Server Component]

  • 데이터 Fetching
  • 백앤드 자원에(직접적으로) 접근하는 경우
  • 민감한 정보를 서버에서 유지(JWT, API Key 등등)하는 경우
  • Large Dependencies를 서버에서 유지하는 경우

[Client Component]

  • interactivity, event listener(onClick, onChange 등)
  • state 및 라이프사이클이 필요할 때 (hook)
  • browser-only API 사용하는 경우

이와같이 목적에 따라 알맞는 컴포넌트를 사용해야 한다.

컴포넌트 중첩해서 사용하기

클라이언트 컴포넌트서버 컴포넌트를 중첩해서 사용해야하는 상황이 많을 것이다.

  • 상위 컴포넌트에서 state 를 사용해야 하는데, 상위 컴포넌트를 클라이언트 컴포넌트로 만들 수 없는 경우

  • 서버 컴포넌트 에서 리렌더링이 필요한 부분에만 클라이언트 컴포넌트 를 적용하고 싶을 경우

📢 결론부터 이야기 하면

  1. 서버 컴포넌트에서 클라이언트 컴포넌트를 사용하려면 import 해서 사용하면 되고

  2. 클라이언트 컴포넌트 에서 서버 컴포넌트 를 사용할 경우 는 import 해서 사용하면 안되고 {children} 형식으로 클라이언트 컴포넌트props 로 넘겨주어야 한다.

사용법

트위터 클론코딩에서 사용한 방법을 예로 들어보겠다.

위의 그림과 같이 home 페이지 안에 Tab, PostForm, TabDeciderSuspense 세 개의 컴포넌트가 자식으로 존재한다.

코드는 아래와 같다.

// Home 컴포넌트

export default async function Home(){
    const session = await auth()

    // <Tab/> 과 <PostForm/> 은 로딩 없이 바로 렌더링되고 아래 게시물 영역만 데이터 패칭 시 Loading 화면이 보여진다.
    return (
        <main className={style.main}>
            <TabProvider>
                <Tab/>
                <PostForm userInfo={session}/>
                <Suspense fallback={<Loading/>}>
                    <TabDeciderSuspense/>
                </Suspense>
            </TabProvider>
        </main>
    )
}

선택한 Tab 정보를 가지고 있을 State 가 필요한 상황이고, 이 값을 <Tab/><TabDeciderSuspense/> 에 공유해주어야 한다.

그러기 위해서는 <Home/> 을 클라이언트 컴포넌트로 만들어야 하는 문제가 발생한다.

이를 해결하기 위해 <TabProvider/>클라이언트 컴포넌트 로 만들어 State 를 선언하고

서버 컴포넌트들을 {children} 으로 전달했다.

💡 중요 ❗️

클라이언트 컴포넌트에 {children} 으로 전달되는 서버 컴포넌트들은 클라이언트 컴포넌트로 취급하지 않는다.

// TabProvider

"use client"

import {createContext, ReactNode, useState} from "react";

export const TabContext = createContext({
    tab:'rec',
    setTab:(value:'recommend' | 'follow') => {}
})

type Props ={children:ReactNode}
export default function TabProvider({children}: Props){
    const [tab, setTab] = useState('recommend')
    return (
        <TabContext.Provider value={{tab, setTab}}>
            {children}
        </TabContext.Provider>
    )
}

컨텍스트를 만들어 하위 컴포넌트에 tab 값을 공유한다.

// TabDeciderSuspense

export default async function TabDeciderSuspense(){
    const queryClient = new QueryClient
    await queryClient.prefetchInfiniteQuery({
        queryKey:['posts', 'recommends'],
        queryFn: getPostRecommend,
        initialPageParam: 0,
    })
    const dehydratedState = dehydrate(queryClient)

    return(
        // 서버 컴포넌트 이므로 서버 데이터(dehydratedState) 를 클라이언트로 Hydration 해준다.
        <HydrationBoundary state={dehydratedState}>
            <TabDecider/>
        </HydrationBoundary>
    )
}
//TabDecider

"use client";

export default function TabDecider() {
  const { tab } = useContext(TabContext);
  if (tab === 'recommend') {
    return <PostRecommends />
  }
  return <FollowingPosts />;
}

전달받은 tab 값에 따라 추천 포스트와, 팔로잉 포스트를 보여준다.

<PostRecommends/><FollowingPosts />클라이언트 컴포넌트 이다.

❌ 잘못된 예 )

'use client'; // << 이렇게 사용 x
 
// This pattern will **not** work!
// You cannot import a Server Component into a Client Component.
import ExampleServerComponent from './example-server-component';
 
export default function ExampleClientComponent({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ExampleServerComponent />
    </>
  );
}

Server에서 Client로 props 전달하기

전달하고자 하는 props는 Serialization 해야한다. 즉, Date, 함수, data 등의 값들을 직접적으로 전달할 수 없다.

💡 Serialization
Object 또는 Data Struct가 네트워크 또는 스토리지를 통한 전송에 적합한 형식으로 변환되는 프로세스 이다.
예를들어 Javascript에서 JSON.stringify()를 통해 JSON string으로 하는 과정이다. (JSON.parse는 역직렬화)

Third-party Packages

npm packages에 있는 클라이언트 전용 라이브러리들에는 아직 'use client'가 적용되어 있지 않기 때문에 이를 사용하기 위해서는 Client Component를 사용해야 한다.

참조
next 공식문서
@asdf99245
@2ast
@timosean
https://yozm.wishket.com/magazine/detail/2271/

0개의 댓글