서버 컴포넌트, 클라이언트 컴포넌트, 렌더링 각각에 대해서 살펴봤으니 이번에는 비교를 해볼 필요가 있다!
React Application을 만들때 어느 부분을 서버에서 렌더링 되도록 하고 어느 부분을 클라이언트에서 렌더링 되도록 할건지 고민하는 과정이 필요하게 된다.
따라서 이번에는 서버 컴포넌트와 클라이언트 컴포넌트의 구성 패턴에 대해서 비교해보고 언제 어떤것을 사용하면 좋을지에 대해서도 살펴보자!
위 사진으로 아주 명확하게 정리되어 있다!
짧게 요약하자면 Event Listener
or React Hooks
or Browser API's
를 사용하면 클라이언트 컴포넌트이고, 그렇지 않으면 서버 컴포넌트다.
따라서 유저와 Interaction하는 코드가 없다면 서버 컴포넌트이고, 서버 컴포는터는 그래서 빌드할때 Pre-Rendered될 수 있고 Hydraion할 필요가 없다는 소리다.
반대로 클라이언트 컴포넌트가 Client Device's Browses에서 Hydration이 되어야 하는 이유 이기도 하다.
서버 컴포넌트의 경우 CSR을 선택하기 이전에, 서버에서 data fetching 혹은 DB 조회 혹은 백엔드 서비스와 같은 작업을 할 수 있다.
서버에서 Data Fetching을 할때 해당 데이터를 서로 다른 여러 컴포넌트간에 데이터를 공유해야하는 경우가 있다.
예를 들어서 동일한 데이터에 의존하는 레이아웃과 페이지가 있을 수 있고, 아니면 메인 페이지와 사이드바에 동시에 보여줄수도 있고?
여튼 이럴때 React Context나 데이터를 porps로 전달하는 방법을 사용하지 않아도 된다! (애초에 서버 컴포넌트에서 React Context는 사용 못하긴 함ㅋㅋ)
그럼 어찌하느냐? 그냥 fetch
함수를 사용하거나 React의 cache
함수를 사용해서 데이터를 fetch하면 끝난다.
서로 다른 컴포넌트에서 동일한 데이터를 fetch 하면 불필요한거 아닌가...? 라고 생각할 수 있지만 우리의 NextJS는 그냥 fetch 함수를 사용하면 자동적으로 데이터를 Cached로 만든다.
즉 다른 컴포넌트에서 연속적으로 동일한 API를 호출해서 데이터를 불러오려고 해도, 초기 1회만 실제 데이터를 불러오는데 사용하고 그 이후에는 Cacehd된 데이터를 바로 사용가능하다는 소리!
경우에 따라서 원치않게 Cached가 되는 경우도 존재하는데, 이럴때는 revalidate 관련 함수를 사용하거나, Cached되지 않도록 옵션을 추가하자.
자바스크립트 모듈은 서버 컴포넌트 및 클라이언트 컴포넌트 모듈간에 서로 공유될 수 있기때문에, 서버에서만 실행되도록 의도한 코드가 내 의도와 다르게 클라이언트에게 몰래 들어갈 수 있다.
아래 코드를 살펴보자!
// lib/data.ts
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
언뜻 보면 getData
함수가 서버와 클라이언트에서 모두 작동하는것 처럼 보인다.
하지만 getData
함수는 서버에서만 실행되도록 작성된 API_KEY
가 포함되어 있다.
환경변수 API_KEY
에 NEXT_PUBLIC
접두사가 붙지 않았기 때문에 서버에서만 접근할수 있는 전용 변수다.
근데 환경변수가 클라이언트로 유출되는걸 방지하기 위해서 NextJS가 환경 변수를 빈 문자열로 바꿔버린다! (똑똑한놈)
결과적으로 클라이언트에서 getData
함수를 가져와서 실행하더라도 정상적인 작동은 되지 않게된다.
그리고 환경변수를 공개하면 클라이언트에서 getData
함수가 작동하지만, 민감한 정보를 클라이언트에게 노출하게 된다. ㅠㅠ
이런식의 의도하지 않은 클라이언트가 서버코드를 사용하는걸 방지하기 위해서 서버 전용 패키지를 다운로드해서 사용할수 있다.
npm install server-only
해당 패키지를 설치한 후 아래와 같이 추가해주면 된다.
import 'server-only'
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
위와같이 코드를 작성하면 빌드할때 getData
함수를 가져오는 모든 클라이언트 컴포넌트는 해당 함수는 서버에서만 작동될 수 있다는걸 알게되고 빌드할때 에러가 발생하게된다.
서버 컴포넌트는 리액트의 새로운 기능이기 때문에 3rd party 패키지들이 이를 완벽하게 지원하지 못할 수 있다.
따라서 useState
와 같은 훅을 사용하는 외부 패키지를 사용할때는 "use client" 지시어를 사용하는 클라이언트 컴포넌트에서는 정상동작이 가능하다.
BUT!
해당 패키지를 서버 컴포넌트에서 사용하고 싶을때가 있는데 이럴때는 에러가 발생하게 된다. 왜냐하면 외부 패키지는 서버 컴포넌트에서는 사용이 불가능한 피쳐들을 사용하기 때문에!
따라서 이럴때는 약간의 수정이 필요한데, 아래와 같이 3rd 패키지를 클라이언트 컴포넌트로 만들어서 서버 컴포넌트에서 사용할 수 있다.
// app/carousel.tsx
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
// app/page.tsx
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>View pictures</p>
{/* Works, since Carousel is a Client Component */}
<Carousel />
</div>
)
}
하지만 대부분의 3rd party 패키지들은 클라이언트 컴포넌트에서 사용되기 때문에 위와같이 래핑해서 서버 컴포넌트에서 사용하는 일은 많지 않을듯 싶다.
일반적으로 Context Provider는 현재의 테마와 같이 Global한 상태관리를 위해서 Application의 루트 근처에서 렌더링 된다.
하지만 React Context는 서버 컴포넌트에서 지원되지 않기때문에, 서버 컴포넌트에서 Context를 사용하려고 하면 오류가 발생한다.
이럴때는 Context를 사용하는 부분을 클라이언트 컴포넌트로 만들고, 서버 컴포넌트를 클라이언트 컴포넌트의 child로 넘겨주면 된다.
// app/theme-provider.tsx
'use client'
import { createContext } from 'react'
export const ThemeContext = createContext({})
export default function ThemeProvider({
children,
}: {
children: React.ReactNode
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
// app/layout.tsx
import ThemeProvider from './theme-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
클라이언트 자바스크립트 번들 사이즈를 줄이기 위해서 클라이언트 컴포넌트를 컴포넌트 트리의 아래 부분으로 옮기는걸 권장하고 있다.
To reduce the Client JavaScript bundle size, we recommend moving Client Components down your component tree.
예를 들어서 로고와 링크와 같은 정적인 요소와 검색창과 같은 인터렉티브한 요소가 함께있는 레이아웃이 있을 수 있다.
이럴때 전체적인 레이아웃은 서버 컴포넌트로 계속 유지하고, 인터렉티브한 로직이 있는 부분을 클라이언트 컴포넌트로 만든다. (ex <SearchBar />
)
이는 레이아웃의 모든 구성요소, 즉 렌더링 하고자 하는 화면의 모든 컴포넌트의 자바스크립트를 클라이언트에게 보낼 필요가 없다는걸 의미한다.
// 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>
</>
)
}
서버 컴포넌트에서 data fetch를 했다면, 받아온 데이터를 props로 클라이언트 컴포넌트에게 내려주고 싶을때가 있다. (사실 매우 많지?ㅋㅋ)
Props를 서버에서 클라이언트 컴포넌트로 넘겨주기 위해서는 리액트의 serializable이 필요하다.
뭔 소리여...?? 라고 생각할 수 있는데 전혀 어려운게 아니다.
서버(서버 컴포넌트 아니면 서버 액션등등)에서 클라이언트 컴포넌트로 props를 넘겨줄때, 넘겨줄 수 있는 형태의 props가 제한된다는 소리다.
근데 위 사진을 통해서도 볼 수 있듯이, 거의 대부분의 데이터 타입들 모두 서버에서 클라이언트 컴포넌트로 props 형태로 넘겨줄 수 있으니 걱정안해도 될듯!
서버 컴포넌트인 루트 레이아웃부터 시작해서 "use client"
지시어를 추가해서 클라이언트 컴포넌트 특정 하위 트리를 렌더링할 수 있다.
해당 클라이언트 하위트리(루트 레이아웃 자식인 클라이언트 컴포넌트를 말하는듯?)에서 서버 컴포넌트를 중첩할수도 있고(child로 넘겨주면 됨ㅇㅇ) 서버액션을 호출할수도 있다(쌉가능하지).
하지만 이떄 몇가지 염두해둬야할 주의사항이 있다. 헉
Request-Response LifeCycle 동안 작성한 자바스크립트 코드는 서버에서 클라이언트로 움직인다.
이때 서버에 있는 데이터나 리소스 접근이 필요하게되면, 서버에게 새로운 request를 보내게 된다. (코드 대충 짜면 불필요한 짓거리를 또 하게 될 수 있겠군?)
서버에 새로운 Reqeust가 발생하면, 클라이언트 컴포넌트 내부에 중첩된 클라이언트 컴포넌트를 포함해서 서버 컴포넌트가 먼저 렌더링 된다.
렌더링된 결과는(RSC Payload) 클라이언트 컴포넌트 참조 위치가 포함된다. (하이드레이션 필요할거 아냐)
그런다음 클라이언트에서 React는 RSC Payload를 사용해서 서버와 클라이언트 컴포넌트를 단일 트리로 조정한다.
(근데 생각해보면 당연한듯?? 서버에서 HTML로 렌더링한걸 내려주고, 클라이언트에서 RSC Payload를 참고해서 DOM 업데이트하고 Dydration 하니까?)
클라이언트 컴포넌트는 서버 컴포넌트가 렌더링된 이후에 렌더링 되기 때문에, 서버 컴포넌트를 클라이언트 컴포넌트한테 import할 수 없다. (왜냐하면 서버에 다시 새로운 요청이 필요함)
대신 서버 컴포넌트를 클라이언트 컴포넌트를 Props로 전달할 수 있다.
'use client'
// You cannot import a Server Component into a Client Component.
import ServerComponent from './Server-Component'
export default function ClientComponent({
children,
}: {
children: React.ReactNode
}) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ServerComponent />
</>
)
}
일반적으로 공통적인 패턴은 React childeren props를 이용해서 클라이언트 컴포넌트한테 전달해준다.
아래와 같이 <ClientComponent>
는 children
props를 낼럼낼럼 받는다.
// app/client-component.tsx
'use client'
import { useState } from 'react'
export default function ClientComponent({
children,
}: {
children: React.ReactNode
}) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}
컴포넌트 인지 감수성을 이용해서 생각해보면 <ClientComponent>
입장에서는 자식으로 넘겨받은 컴포넌트가 서버 컴포넌트로 채워질거라는걸 모른다.
부모로써 <ClientComponent>
컴포넌트가 갖고있는 유일한 책임은 자식 컴포넌트가 최종적으로 어디에 배치되어서 렌더링될지를 결정하는 일이라는거다. (자식 컴포넌트가 금쪽이가 되지 않기 위해서...)
여튼 부모인 서버 컴포넌트(page.tsx) 에서는 <ClientComponent>
그리고 <ServerComponent>
모두 가지고와서 <ServerComponent>
를 <ClientComponent>
의 자식으로 전달할 수 있다.
// app/page.tsx
// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ClientComponent from './client-component'
import ServerComponent from './server-component'
// Pages in Next.js are Server Components by default
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
이런식으로 접근하면 <ServerComponent>
와 <ClientComponent>
가 서로 분리되어서 독립적으로 렌더링될 수 있다.
위 코드의 경우는 <ClientCompoent>
가 클라이언트에서 렌더링되기 이전에 자식 컴포넌트인<ServerComponent>
가 서버에서 렌더링 될 수 있다!
근데 그도 그럴것이 원래 자식이 부모보다 먼저 렌더링이 되니까, 그 부분을 잘 이해했다면 어렵지 않게 이해할수 있을것 같기는 하다.
기본적으로 모든 컴포넌트를 Server Component로 만들고, 클라이언트 단의 유저 인터랙션 처리나 React Hook 사용이 필요할때만 Client Component를 추가해주면 좋다.
왜? 클라이언트가 다운로드 받아야할 자바스크립트의 양도 줄어드니까, 번들의 사이즈가 작고 다운로드도 빠르고 실행도 빨리 되겠지?
공식 문서에서는 아래와 같은 경우에는 Server Component 사용을 권장하고 있다.
위 사진을 보면 대부분의 컴포넌트가 유저 인터렉션이 필요하지 않은 부분들이다.
따라서 위와같이 부분적으로 유저와 인터랙션이 필요한 컴포넌트들만 Client Component로 만들어 준다.
그외에
<a>
태그를 이용한 단순한 링크를 담는 컨테이너에 불과하다.따라서 이 부분들은 서버 컴포넌트로 만들면 된다.