서버 컴포넌트와 App 라우팅 적응기 1
2024/01/08
n°43
category : React
☼
2024/01/08
n°43
category : React
사이드 프로젝트를 React 18과 Next.js 14의 app 라우터를 사용해 개발하였다. 사이드를 시작한 이유는 여러가지가 있었지만 무엇보다 프론트엔드, 특히 리액트와 Next.js을 중심으로 찾아온 변화가 궁금했다. 직접 사용해보고 더 깊게 알고 싶었기 때문에 새로운 기술을 도입하여 프로젝트를 진행하게 되었다. 사용한 뒤 알게 된 내용들을 간략히 정리해본다.
기존의 서버사이드 렌더링 방식과 서버 컴포넌트를 비교한 간략한 리스트를 통해 차이점을 파악해보자.
서버사이드 렌더링 (SSR, SSG, ISR): 페이지 전체의 렌더링 전략
React 서버 컴포넌트: 페이지 내 특정 부분(컴포넌트)들이 서버 또는 클라이언트에서 렌더링되는지 결정할 수 있음
SSR (getServerSideProps):
SSG (정적 사이트 생성 getStaticProps):
ISR (증분 정적 재생성 getStaticProps):
React 서버 컴포넌트:
Next.js 공식 문서에서 어느 상황에서 서버/클라이언트 컴포넌트를 사용해야하는지 잘 설명하고 있다. 서버/클라이언트 구분이 낯설고 불필요하게 느껴질 수 있지만, 개인적으로 컴포넌트에 대한 구분이 명확해지면서 설계 패턴 또한 한층 더 분명해지는 느낌을 받았다. 이는 프론트 코드에서 복잡성이 증가하게되는 원인이 주로 서버 데이터와 연관되기 때문이다. api의 호출 시점, 데이터의 형태, 분기 처리 등 서버 데이터 상태와 UI 상태를 동시에 효과적으로 관리해야한다. 신경써야할 것들이 많아질 수록 코드는 복잡해지며, 컴포넌트 자체도 UI만 표시하는 역할에서 점차 많은 역할을 떠안게 된다. 서버 컴포넌트는 코드가 구조적으로 복잡해지는 상황을 통제하기 위한 효율적인 대안으로 보인다.
서버 컴포넌트를 통해 서버 데이터를 호출 및 가공하여 데이터를 상속하고, 이를 상속받은 클라이언트 컴포넌트는 UI의 상태와 이벤트 로직을 관리한다. UI의 상태를 관리하는 useState, useEffect와 같은 훅들과 이벤트 핸들러처럼 사용자의 인터랙션을 전제로 하는 컴포넌트는 모두 클라이언트 컴포넌트로 볼 수 있다. 이러한 구분을 통해 더 명확한 관심사의 분리를 이룰 수 있다.
기본적으로 서버 컴포넌트 자체에 함수형 컴포넌트에 async를 붙인다는 큰 차이가 있다. 이를 감안하면 서버 컴포넌트와 클라이언트 컴포넌트는 다음과 단순한 규칙을 따르게 된다.
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를 동적으로 만들기 위해선 어떤 방식을 사용해야할까? 기타 등등의 생각을 더 정리할 필요가 느껴진다. 다음 글에서 더 깊게 살펴볼 예정이다.