Nextjs13) Server Components & Client Components

김명성·2023년 5월 12일
3

Nextjs13이 업데이트되며 모든 컴포넌트의 디폴트가 서버 컴포넌트가 되었습니다.

그렇다면 기존에 사용하던 방식과 어떻게 달라졌는지, 사용자와 상호작용이 필요한 부분은 어떻게 처리해야 하는지 알아보겠습니다.

이해를 돕기 위해 먼저 누구나 알고있는 랜더링 환경을 환기해보고 넘어가도록 하겠습니다.


랜더링 환경 (CSR , SSR)

랜더링은 크게 클라이언트 측에서 진행하는 랜더링 (CSR)과 서버 측에서 진행하는 랜더링(SSR) 2가지로 나눌 수 있습니다.

클라이언트는 사용자가 사용하는 장치의 브라우저를 의미합니다.
클라이언트는 서버에 요청을 보낸 뒤 응답을 받아 사용자가 상호작용할 수 있는 인터페이스로 변환합니다.

여기서 서버란 어플리케이션 코드를 저장하고, 클라이언트로부터의 요청을 받아 요청 내 작업을 처리한 후 응답 메세지를 반환하는 주체입니다.


컴포넌트 수준(Component-Level) 렌더링

React18 이전 주요 렌더링 방식은 어플리케이션의 렌더링을 전부 클라이언트에서 수행하는 방식이었습니다.

Next.js는 애플리케이션을 페이지별로 분할한 뒤 서버에서 사전 렌더링(Pre-rendering)하여 HTML을 생성한 뒤 클라이언트로 보내며 어느 정도 성능 개선을 이루었지만, 여전히 HTML을 상호작용 가능하게 만들기 위해서는 클라이언트에 추가적인 자바스크립트가 필요하게 되었습니다.

이제 Nextjs13에서는 서버 컴포넌트와 클라이언트 컴포넌트를 사용하여 컴포넌트 단위의 레벨로 클라이언트와 서버 양쪽에서 렌더링할 수 있게 되어 렌더링 환경을 개발자가 스스로 선택할 수 있습니다.

기본적으로 App router는 서버 컴포넌트를 사용하며, 이를 통해 서버에서 컴포넌트를 쉽게 렌더링하고, 클라이언트로 전송되는 자바스크립트 코드를 줄일 수 있습니다.


SSR 내 정적(Static) 및 동적(Dynamic) 렌더링

리액트 컴포넌트를 사용한 클라이언트 측 및 서버 측 렌더링 외에도, Next.js는 정적 및 동적 렌더링을 통해 서버에서 렌더링을 최적화할 수 있는 옵션을 제공합니다.

정적 렌더링(Static Rendering)

정적 렌더링에서는 서버컴포넌트, 클라이언트컴포넌트 모두 빌드 시 서버에서 사전 렌더링될 수 있습니다.(SSG)

랜더링 결과는 캐싱되어 이후 요청에서 재사용되며 캐싱된 결과를 재검증할 수도 있습니다. (ISR)

정적 렌더링이 진행될 때 라우터를 구성하는 서버 및 클라이언트 컴포넌트는 서로 다르게 렌더링됩니다

클라이언트 컴포넌트는 컴포넌트가 갖고 있는 HTML과 JSON이 사전 렌더링되어 서버에서 캐싱되며 캐싱된 결과는 Hydration을 위해 클라이언트로 전송됩니다.

서버 컴포넌트는 React에 의해 서버에서 렌더링되며 해당 페이로드(data)는 HTML을 생성하는 데 사용됩니다.

렌더링된 페이로드(data)는 클라이언트 컴포넌트를 수화하는 데에도 사용되므로 클라이언트에는 JavaScript가 필요하지 않습니다.

동적 렌더링(Dynamic Rendering)

동적 렌더링에서는 서버 및 클라이언트 컴포넌트가 요청 시 서버에서 렌더링됩니다. 이 작업의 결과는 캐싱되지 않습니다.
이는 페이지 라우터에서 SSR 메서드인 getServerSideProps와 동등합니다


캐싱이라는 단어가 자주 등장하는데요,
nextjs13의 핵심이기도 한 캐싱에 대해 조금 더 알아보겠습니다.
(핵심이라는 것은 주관적인 생각입니다)


Cache

NextJs의 Caching (케싱)
Nextjs는 요청별로 또는 전체 경로 세그먼트에 대해 데이터 캐싱을 기본적으로 지원합니다.
Pre-request Caching

  1. fetch()
    기본적으로, 모든 fetch 요청은 자동으로 캐시되고 중복이 제거됩니다. 즉, 동일한 요청을 두 번 실행하면 두 번째 요청은 첫 번째 요청의 결과를 재사용합니다.
async function getComments() {
  // 요청 결과는 캐싱됩니다.
  const res = await fetch('https://...'); 
  return res.json();
}
// 첫번째 호출에는 당연히 캐싱되지 않습니다.
const comments = await getComments(); 

// 두번째 호출부터 캐싱 값을 컴포넌트 어디에서든지 사용할 수 있습니다.
const comments = await getComments(); 

fetch를 사용한 Request는 revalidate 옵션을 지정하여 요청의 재검증 주기를 제어할 수 있습니다.
(기본적으로 영구 저장)

export default async function Page() {
  // 10초마다 해당 데이터를 재검증합니다.
  const res = await fetch(
    'https://...',
    { next: { revalidate: 10 } });
  const data = res.json();
}

  1. cache()
    React의 cache 함수를 사용하면 요청을 캐싱하고 중복을 제거하여 cache 함수로 래핑된 함수의 호출 결과를 메모이제이션할 수 있습니다.
    동일한 인수로 호출되는 동일한 함수는 함수를 다시 실행하는 대신 캐시된 값을 재사용합니다.
// utils/getUser.ts
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
  const user = await db.user.findUnique({ id });
  return user;
});
// user/[id]/layout.tsx
import { getUser } from '@utils/getUser';
export default async function UserLayout({ params: { id } }) {
  const user = await getUser(id);
  // ...
}
// user[id]/page.tsx
import { getUser } from '@utils/getUser';
export default async function Page({params: { id }}){
  const user = await getUser(id);
}

위의 예시에서 getUser 함수는 두 번 호출되지만, 데이터베이스에는 한 번의 쿼리만 실행됩니다. 이는 getUser 함수가 cache로 래핑되어 있기 때문에 두 번째 요청은 첫 번째 요청의 결과를 재사용할 수 있기 때문입니다.

Q. 중복된 값은 컴포넌트의 props로 넘겨줘도 되지 않나요?
A. 데이터를 여러 컴포넌트 간에 props로 전달하는 대신 필요한 컴포넌트에서 직접 데이터를 가져오는 것을 Nextjs에서 권장합니다. 심지어 여러 컴포넌트에서 동일한 데이터를 요청하더라도 직접 데이터를 가져오는 것을 권장합니다.

또한 서버에서 사용하는 Data fetching function이 클라이언트에서 사용되지 않도록 하기 위해 Nextjs에서 제공하는 server-only package를 사용하는 것을 권장합니다.

::server-only package
서버에서 실행할 코드가 클라이언트에 들어가게 되어 의도하지 않은 사용을 방지하기 위해 server-only package를 사용하여 다른 개발자가 실수를 빌드 타임에 오류를 제공할 수 있습니다.


  1. Preload pattern with cache()
    Nextjs는 preload 패턴으로 data fetching을 수행하는 유틸함수 또는 컴포넌트에서 preload로 내보내는 것을 제안합니다.
// User.tsx (Server Component)
import { getUser } from '@utils/getUser'; 

export const preload = (id: string) => {
  void getUser(id);
};

export default async function User({ id }: { id: string }) {
  const result = await getUser(id);
}
// user/[id]/page.tsx
import User, { preload } from '@components/User';
export default async function Page({
  params: { id },
}: {
  params: { id: string };
}) {
  preload(id); // preload 사용
  const condition = await fetchCondition();
  return condition ? <User id={id} /> : null;
}

preload 패턴은 예시이며, 이 패턴에 사용되는 이름은 임의로 명명할 수 있습니다.
API가 아니라 패턴이기 떄문입니다.
preload 패턴은 선택 사항이며 사례별로 최적화하는 데 사용할 수 있습니다.
preload 패턴은 병렬 데이터 가져오기에 대한 추가 최적화이며 Promise를 props로 전달할 필요가 없이 패턴에 의존할 수 있습니다. (2번 cache()를 참조해주세요.)


주의사항
Request가 캐싱되지 않는 경우도 있습니다.
1. 동적인 메서드(next/headers, Authorization, cookie headers)가 사용된 경우
2. fetch로 전달한 Request가 POST 요청인 경우
3. fetch에 revalidate: 0 또는 cache:'no-store'가 구성된 경우


이제 다시 Nextjs13의 컴포넌트 레벨에 대해 조금 더 깊게 알아보겠습니다.


서버 컴포넌트

서버 컴포넌트와 클라이언트 컴포넌트를 사용하면 개발자들은 서버와 클라이언트를 아우르는 애플리케이션을 구축할 수 있으며, 클라이언트 측 앱의 풍부한 상호작용성과 전통적인 서버 렌더링의 성능 향상을 결합할 수 있습니다.
React가 UI 구축에 대한 사고방식을 변경한 것처럼,
Nextjs13에서의 React Server Components는 서버와 클라이언트를 활용하는 하이브리드 애플리케이션을 구축하기 위한 새로운 개념적 모델입니다.

서버 컴포넌트에 대한 사고방식

페이지를 구성하는 컴포넌트를 작은 단위로 나누다보면, 작게 나뉜 대부분의 컴포넌트가 클라이언트 인터렉션과 상관 없는 UI 컴포넌트일 때가 많습니다. 이런 작은 UI 컴포넌트에 클라이언트 컴포넌트를 조합하여 더 많은 부분을 서버에서 랜더링되게 할 수 있습니다.

서버 컴포넌트의 장점

그렇다면 서버 컴포넌츠(Server Components)를 사용하는 이유는 무엇일까요?
클라이언트 컴포넌트(Client Components) 대비 어떤 장점이 있을까요?

장점만을 나열해보겠습니다.

  1. 클라이언트 JavaScript 번들 크기에 영향을 주었던 큰 종속성들을 서버에 완전히 남겨둘 수 있어 성능이 향상됩니다.
  2. 서버 컴포넌트를 사용하면 React의 강력함과 유연성을 갖춘 상태에서 PHP와 유사한 방식으로 React 애플리케이션을 작성할 수 있습니다.
  3. 서버 컴포넌트를 사용하면 초기 페이지 로드가 더 빨라집니다.
  4. 클라이언트 측의 JavaScript 번들 크기가 줄어듭니다.
  5. 클라이언트 측 런타임은 캐싱이 가능하며 크기가 예측 가능합니다. 추가적인 JavaScript는 클라이언트 컴포넌트를 통해 애플리케이션에서 클라이언트 측 상호작용이 사용될 때만 추가됩니다.

이처럼 서버 컴포넌트는 개발자들이 서버 인프라를 더 잘 활용할 수 있게 해줍니다.
Next.js에서 라우트가 로드되면 초기 HTML이 서버에서 렌더링되고 이후 브라우저에서 점진적으로 향상되며, 클라이언트는 서버로부터 인계 받은 어플리케이션에 상호작용을 추가할 수 있도록 Next.js와 React 클라이언트 런타임을 비동기적으로 로드합니다.

Nextjs13에서는 App Router 내의 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다.
이는 모두 자동으로 적용되며, 추가 작업 없이 훌륭한 성능을 제공합니다.


클라이언트 컴포넌트

Nextjs12 이하의 페이지 라우터를 사용할 때, 내부 컴포넌트가 항상 작동하는 방식으로 클라이언트 컴포넌트를 생각할 수 있습니다.

페이지 라우트 내 페이지를 제외한 컴포넌트는 일반적으로 React의 렌더링 방식을 따릅니다.
따라서, SSR을 위해 특별한 설정이나 로직이 없는 한, 기본적으로 CSR입니다.

// for client side rendering
'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>
  );
}

use client

use client는 서버 전용 코드와 클라이언트 코드 사이에 있으며
서버 전용에서 클라이언트 부분까지의 경계를 교차하는 컷오프 지점을 정의하기 위해 파일 맨 위에 배치됩니다.
use client가 파일에 정의되면 자식 Components를 포함하여 가져온 다른 모든 모듈은 클라이언트 번들의 일부로 간주됩니다.

즉, use client는 모든 파일에서 정의할 필요가 없으며 entry point가 되는 모듈에서 한 번만 정의하면 Client Component로 간주되는 모든 모듈을 가져올 수 있습니다.

Client Component는 주로 클라이언트에서 렌더링되지만 Next.js를 사용하면 서버에서 Pre-rendering되고 클라이언트에서 hydration 될 수도 있습니다.

use client로 모듈을 정의하지 않으면 모든 Component는 Server Components 모듈 그래프의 일부이며 Server Component 모듈 그래프에 속한 모든 컴포넌트는 서버로부터 랜더링 됩니다.


서버 컴포넌트와 클라이언트 컴포넌트는 어떤 상황에서 사용해야 하나요 ?

서버 컴포넌트와 클라이언트 컴포넌트를 언제 사용해야하는지에 대한 결정을 단순화하려면 클라이언트 컴포넌트에 대한 사용 사례가 있을 때까지 서버 컴포넌트(앱 디렉터리의 기본값)를 사용하는 것이 좋습니다.

사용사례는 아래 표를 참조해주세요

사용사례

사례Server ComponentClient Component
Fetch Data가능불가능
백엔드 리소스에 직접 접근가능불가능
민감 정보를 서버에 보관(토큰, API 키 등)가능불가능
서버에 대한 큰 종속성 유지/ 클라이언트 측 JavaScript 감소가능불가능
상호작용 및 이벤트 리스너(ex:onClick) 추가불가능가능
상태와 라이프사이클 이팩트 사용(useState,useEffect, etc)불가능가능
브라우저 내장 api 사용불가능가능

Client Component Leaves

애플리케이션의 성능을 개선하려면 가능한 경우 클라이언트 컴포넌트를 서버 컴포넌트 트리의 리프로 이동하는 것이 좋습니다.

나무와 나뭇잎 관계를 생각하면 이해가 쉽습니다

예를 들어 정적 요소(예: 로고, 링크 등)가 있는 레이아웃과, 상태를 사용해야하는 검색창이 있을 수 있습니다.

전체 레이아웃을 클라이언트 컴포넌트로 만드는 대신 상호작용하는 로직을 클라이언트 컴포넌트(SearchBar)로 이동하고 레이아웃은 서버 컴포넌트로 유지합니다.
이는 레이아웃을 구성하는데 사용된 Javascript를 클라이언트에 보낼 필요가 없음을 의미합니다.

// SearchBar is a Client Component
import SearchBar from './searchbar';
// Logo is a Server Component
import Logo from './logo';
 
// Layout is a Server Component by default
export default function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  );
}

클라이언트 컴포넌트 및 서버 컴포넌트 구성의 랜더링 처리 순서

서버 컴포넌트 및 클라이언트 컴포넌트는 동일한 컴포넌트 트리에서 결합될 수 있습니다.

React의 렌더링 처리 순서는 다음과 같습니다.

1단계에서 서버 측의 React는 모든 서버 구성 요소를 렌더링합니다.
이 단계에서 클라이언트 컴포넌트는 Skip되지만, 클라이언트 컴포넌트 내부의 서버 컴포넌트는 랜더링합니다.
즉, 클라이언트 컴포넌트가 아직 로드되지 않은 상태에서 서버 컴포넌트만을 사용하여 초기 HTML을 생성합니다.

2단계에서는 서버에서 생성된 HTML과 함께 클라이언트 측의 React가 동작합니다. 클라이언트 컴포넌트와 서버 컴포넌트가 함께 렌더링됩니다. 이 때, 클라이언트 컴포넌트 내에 중첩된 서버 컴포넌트는 서버에서 이미 렌더링된 상태이므로 클라이언트 측에서는 해당 컴포넌트를 건너뜁니다. 클라이언트 컴포넌트는 초기 HTML에서 마운트된 상태로 시작하며, 서버 컴포넌트의 렌더링 결과와 병합됩니다.

2단계: 초기 HTML에서 마운트된 상태?
Next.js에서는 초기 페이지 로드 중에 위 단계에서 서버 컴포넌트의 렌더링된 결과와 클라이언트 컴포넌트가 모두 서버에서 HTML로 미리 렌더링되어 더 빠른 초기 페이지 로드를 생성합니다.


여기까지 Nextjs13의 Client Component와 Server Component에 대해 알아보았습니다.

최대한 공식문서와 블로그를 보고 의문이 드는 부분은 검색하여 채킹하였지만 잘못된 사실이 있다면 지적 부탁드리겠습니다.

1개의 댓글

comment-user-thumbnail
2023년 5월 23일

따봉입니다!

답글 달기