teklog

서버 컴포넌트와 App 라우팅 적응기 1

2024/01/08

n°43

category : React

사이드 프로젝트를 React 18과 Next.js 14의 app 라우터를 사용해 개발하였다. 사이드를 시작한 이유는 여러가지가 있었지만 무엇보다 프론트엔드, 특히 리액트와 Next.js을 중심으로 찾아온 변화가 궁금했다. 직접 사용해보고 더 깊게 알고 싶었기 때문에 새로운 기술을 도입하여 프로젝트를 진행하게 되었다. 사용한 뒤 알게 된 내용들을 간략히 정리해본다.



기존의 서버사이드 방식과 서버 컴포넌트의 차이들


기존의 서버사이드 렌더링 방식과 서버 컴포넌트를 비교한 간략한 리스트를 통해 차이점을 파악해보자.


서버사이드 렌더링 (SSR, SSG, ISR)페이지 전체의 렌더링 전략

React 서버 컴포넌트: 페이지 내 특정 부분(컴포넌트)들이 서버 또는 클라이언트에서 렌더링되는지 결정할 수 있음


SSR (getServerSideProps):


  • 범위: 각 요청마다 전체 페이지가 서버에서 렌더링
  • 사용 용도: 실시간 데이터가 필요한 페이지에 적합
  • 성능: 각 요청마다 렌더링되기 때문에 다소 느릴 수 있지만, 최신 데이터를 보장


SSG (정적 사이트 생성 getStaticProps):


  • 범위: 전체 페이지가 빌드 시에 미리 렌더링되어 정적 HTML로 제공
  • 사용 용도: 자주 변경되지 않는 컨텐츠가 있는 페이지에 이상적
  • 성능: 정적 파일을 제공하기 때문에 로드 시간 면에서 가장 빠름


ISR (증분 정적 재생성 getStaticProps):


  • 범위: SSG와 유사하지만, 페이지를 설정된 간격이나 요청에 따라 다시 생성할 수 있음
  • 사용 용도: 주기적으로 변경되는 컨텐츠에 유용하며, 정적과 동적 컨텐츠의 장점을 살릴 수 있음
  • 성능: SSG의 속도 장점과 SSR의 서버 데이터의 최신 상태라는 장점을 결합


React 서버 컴포넌트:


  • 범위: 페이지 내 특정 컴포넌트들이 서버에서 렌더링됨
  • 사용 용도: 인터랙티브하지 않거나 계산이 많이 필요한 컴포넌트에 적합. 클라이언트 측 상호작용이 필요하지 않을 때
  • 성능: 클라이언트로 보내는 자바스크립트의 양을 줄임. 서버에서 렌더링을 처리함으로써 전체 성능 향상
  • 어떤 부분이 클라이언트 측의 동적인 상호작용과 렌더링을 필요로 하고, 어떤 부분을 효율적으로 서버에서 렌더링할 수 있는지 전략적으로 결정할 수 있게됨.
  • 이 렌더링 방식의 세분화는 클라이언트 측의 부하를 줄이고 서버 자원을 더 효과적으로 활용할 수 있게 하여 성능을 최적화하는 데 도움이 됨


버 컴포넌트 구성 패턴


img


Next.js 공식 문서에서 어느 상황에서 서버/클라이언트 컴포넌트를 사용해야하는지 잘 설명하고 있다. 서버/클라이언트 구분이 낯설고 불필요하게 느껴질 수 있지만, 개인적으로 컴포넌트에 대한 구분이 명확해지면서 설계 패턴 또한 한층 더 분명해지는 느낌을 받았다. 이는 프론트 코드에서 복잡성이 증가하게되는 원인이 주로 서버 데이터와 연관되기 때문이다. api의 호출 시점, 데이터의 형태, 분기 처리 등 서버 데이터 상태와 UI 상태를 동시에 효과적으로 관리해야한다. 신경써야할 것들이 많아질 수록 코드는 복잡해지며, 컴포넌트 자체도 UI만 표시하는 역할에서 점차 많은 역할을 떠안게 된다. 서버 컴포넌트는 코드가 구조적으로 복잡해지는 상황을 통제하기 위한 효율적인 대안으로 보인다.


서버 컴포넌트를 통해 서버 데이터를 호출 및 가공하여 데이터를 상속하고, 이를 상속받은 클라이언트 컴포넌트는 UI의 상태와 이벤트 로직을 관리한다. UI의 상태를 관리하는 useState, useEffect와 같은 훅들과 이벤트 핸들러처럼 사용자의 인터랙션을 전제로 하는 컴포넌트는 모두 클라이언트 컴포넌트로 볼 수 있다. 이러한 구분을 통해 더 명확한 관심사의 분리를 이룰 수 있다. 


기본적으로 서버 컴포넌트 자체에 함수형 컴포넌트에 async를 붙인다는 큰 차이가 있다. 이를 감안하면 서버 컴포넌트와 클라이언트 컴포넌트는 다음과 단순한 규칙을 따르게 된다.


  1. 서버 컴포넌트는 최상단에서 사용된다
  2. 클라이언트 컴포넌트는 서버 컴포넌트를 import하여 return에서 호출할 수 없다.
  3. 클라이언트 컴포넌트는 {props.children}으로 서버 컴포넌트를 감쌀 수 있다.


Next.js 공식문서의 서버와 클라이언트 구성 패턴에는 이러한 새로운 제약을 활용하는 방법을 설명하고 있다. 이 중 앞서 서버 컴포넌트 사용 시 "클라이언트로 보내는 자바스크립트의 양을 줄인다"는 내용이 있었는데, 다음과 같은 간단한 패턴을 통해 이룰 수 있다. 클라이언트 컴포넌트를 렌더링 트리 아래로 옮기기Moving Client Components Down the Tree. 이는 앞서 얘기한 관심사 분리의 이점과도 일맥상통하는 부분이 있다.


링크의 내용을 요약하자면, 사용자 상호작용이 있는 컴포넌트와 그것이 불필요한 컴포넌트를 분리하는 간단한 예시가 나온다. 즉 이벤트를 관리하는 UI 컴포넌트 (클라이언트)와 서버 컴포넌트를 분리하는 패턴이다. 예시에는 헤더에서 서버 데이터를 필요로 하지 않는 로고와 사용자가 입력 가능한 검색바를 클라이언트 컴포넌트를 이용해 분리한다. 이벤트가 일어나는 검색바는 클라이언트 컴포넌트로 변경한다. 이런 간단한 변경을 통해, 자바스크립트 번들 사이즈 줄이는 것이 가능해진다. 이 예시에서 클라이언트 컴포넌트인 검색바SearchBar를 제외한 나머지는 별도의 자바스크립트 없이 미리 렌더링되어 브라우저로 보내진다.

사실 예시에서 짚은 것은 헤더, 푸더의 위치를 잡는 layout 컴포넌트에 대한 이야기였는데, 동적인 ui 이벤트가 발생하지 않고 컴포넌트의 위치와 구성을 잡는 이같은 컴포넌트는 서버 컴포넌트를 사용해야한다. 그리고 App router를 사용한다면 기본적으로 layout.tsx를 활용할 수 있다. Layout.tsx는 Next.js의 page router의 _app.tsx와 유사하지만, 기본적으로 서버 컴포넌트라는 큰 차이점이 있다. _app.tsx가 모든 페이지의 렌더링 이전에 전역적으로 실행된다면, layout.tsx는 페이지 별로 생성 가능하며, app/layout.tsx의 RootLayout과 app/(page)/layout.tsx로 병행하여 사용할 수 있다.



서버 컴포넌트 적용 예시


아래의 코드는 사이드 프로젝트에 적용된 서버 컴포넌트 코드이다.


app/page.tsx
...

const Home = async () => {
  const queryClient = new QueryClient();
  const { userId, userNickname } = await getServerSession(authOptions)
    .then((session) => (session as UserSessionType) || {})
    .catch(() => ({ userId: "", userNickname: "" }));

  await Promise.all([
    queryClient.prefetchQuery({
      queryKey: ["todayPlaylists"],
      queryFn: getMainPageTodayPlaylists,
    }),
    queryClient.prefetchQuery({
      queryKey: ["timeline_playlists", userId],
      queryFn: () => getTimelinePlaylists(userId),
    }),
    queryClient.prefetchQuery({
      queryKey: ["mainPageFriendsPlaylists"],
      queryFn: () => getMainPageFriendsPlaylists(userId),
    }),
    queryClient.prefetchQuery({
      queryKey: ["recentPlayed", userId],
      queryFn: () => getRecentPlaylists(userId),
    }),
  ]);

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <MainTemplate userId={userId} userNickname={userNickname} />
    </HydrationBoundary>
  );
};


app/layout.tsx

...
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={notoSans.className}>
      <head>
        <meta
          name="google-site-verification"
          content={process.env.GOOGLE_SITE_VERIFICATION}
        />
      </head>
      <body>
        <RecoilRootProvider>
          <NextAuthSessionProvider>
            <ReactQueryClientProvider>
              <Header />
              <DarkModeContainer>
                <main className={`...`}>
                  <ReactQueryErrorBoundary isLayout={true}>
                    {children}
                  </ReactQueryErrorBoundary>
                </main>
              </DarkModeContainer>
              <Footer />
              <AuthUserNavigator />
              <CommonModalProvider />
            </ReactQueryClientProvider>
          </NextAuthSessionProvider>
        </RecoilRootProvider>
      </body>
    </html>
  );
}


Promise.all과 쿼리 클라이언트 프리페치를 통해 요청과 동시에 데이터를 캐싱한다. 이후 HydrationBoundary의 state props로 서버 데이터를 클라이언트 사이드에 전달한다. getStaticProps와 ServerSideProps를 사용했다면 코드는 더욱 길어졌을 것이다. 또한 서버사이드 관련한 캐싱에 신경을 많이 써야했을 것이다. 쿼리 클라이언트를 사용해 prefetch를 한 이유는, 호출하는 api가 사용자의 이벤트에 자주 변경될 수 있기 때문이다. 사용자가 라이크를 누르는 등의 mutation이 발생할 때마다 refetch가 필요하다. 물론 이 또한 리액트 쿼리를 사용하지 않고 router.refresh()를 통해 서버 사이드의 함수를 다시 호출할 수 있으나, Footer에 있는 컴포넌트를 통해 같은 데이터를 mutate할 수 있기 때문에 여기선 queryClient를 사용했다. 즉 페이지의 구성과 사용자 이벤트(mutation) 경우의 수에 따라 적합한 형태의 호출 방식을 적용한 결과다.


또한 이 코드에는 layout.tsx이 적용되어있는데, 이는 _app.tsx와 유사해보인다. 프로바이더 컴포넌트가 많은 이유는 QueryProvider, RecoilProvider처럼 클라이언트에서 사용되는 라이브러리가 전역에서 사용되어야 할 때, 클라이언트 컴포넌트를 서버 컴포넌트 상위에 두기 위해서다. 이를 통해 page.tsx는 전역 상태나 NextAuth의 세션에 접근할 수 있다.


그렇다면 서버 컴포넌트는 getStaticProps/ServerSideProps 어딘가에 해당하는 것인가? Path를 동적으로 만들기 위해선 어떤 방식을 사용해야할까? 기타 등등의 생각을 더 정리할 필요가 느껴진다. 다음 글에서 더 깊게 살펴볼 예정이다.