리액트 라우터와 서버 컴포넌트(RSC)의 첫 만남, 직접 써본 후기

요즘 리액트 생태계에서 가장 뜨거운 감자를 꼽으라면 단연 '리액트 서버 컴포넌트(React Server Components, 이하 RSC)'일 텐데요.

얼마 전 리믹스(Remix) 팀이 리액트 라우터(React Router)에서 RSC를 어떻게 활용할지에 대한 구체적인 그림을 공유하면서 개발자 커뮤니티가 한껏 달아올랐습니다.

그리고 드디어, 우리도 직접 리액트 라우터 환경에서 RSC를 실험해 볼 수 있게 되었습니다.

이미 RSC에 대해 어느 정도 들어보셨겠지만, 막상 이걸 리액트 라우터에 적용한다고 하니 "굳이 왜?"라는 질문이 먼저 떠오르실 겁니다.

기존의 loader 함수만으로도 데이터 fetching을 충분히 잘 해왔으니까요.

그래서 오늘은 이 RSC가 왜 필요한지, 리액트 라우터와 만났을 때 어떤 변화가 생기는지, 그리고 제 개인 블로그를 직접 RSC로 마이그레이션하면서 느꼈던 생생한 첫인상을 공유해 보려고 합니다.

도대체 왜 RSC를 써야 할까?

가장 근본적인 질문부터 시작해 보죠.

RSC를 도입하면 우리에게 어떤 이점이 있을까요.

가장 핵심적인 장점 세 가지를 꼽을 수 있습니다.

1. 프론트엔드 번들 사이즈의 혁신적인 감소

RSC는 이름 그대로 '서버에서만' 렌더링되는 컴포넌트거든요.

클라이언트, 즉 사용자의 브라우저로 전송되는 것은 컴포넌트 코드가 아니라, 렌더링 된 결과를 담은 직렬화된 데이터(HTML과 유사한 형태)뿐입니다.

이 말은 곧, 서버 컴포넌트의 코드와 그 컴포넌트가 사용하는 모든 라이브러리들이 클라이언트 측 자바스크립트 번들에 포함되지 않는다는 의미입니다.

예를 들어, 데이터베이스 클라이언트나 무거운 날짜 처리 라이브러리처럼 이전에는 서버에서만 사용해야 했던 의존성들을 이제는 리액트 컴포넌트 안에서 직접 사용할 수 있게 됩니다.

이는 사용자 경험에 치명적인 영향을 주는 초기 로딩 속도를 획기적으로 개선할 수 있는, 그야말로 '게임 체인저'입니다.

2. 백엔드와의 완벽한 통합

RSC를 사용하면 컴포넌트 안에서 데이터베이스 쿼리나 파일 시스템 접근 같은 백엔드 리소스를 직접 호출할 수 있는데요.

API 레이어를 하나 더 만들 필요 없이, 마치 백엔드 코드처럼 데이터를 가져와 UI를 그릴 수 있게 되는 겁니다.

이는 프론트엔드와 백엔드 간의 경계를 허물고, 훨씬 더 긴밀하고 효율적인 개발을 가능하게 합니다.

더 이상 API 엔드포인트를 만들고, fetchaxios로 호출하고, 응답을 처리하는 번거로운 과정을 거치지 않아도 되는 거죠.

3. 네트워크 지연 시간(Latency) 최소화

데이터를 가져오는 로직과 UI를 렌더링하는 로직이 물리적으로 같은 서버에 위치하게 되면서, 불필요한 네트워크 왕복을 줄일 수 있습니다.

기존 방식에서는 클라이언트가 서버에 데이터를 요청하고, 서버가 데이터베이스에 다시 요청하고, 그 결과를 받아 클라이언트에 전달하는 여러 단계를 거쳐야 했거든요.

하지만 RSC를 사용하면 서버에서 필요한 모든 데이터를 한 번에 가져와 UI를 렌더링한 후, 그 최종 결과물만 클라이언트에 스트리밍 방식으로 보내주기 때문에 사용자가 콘텐츠를 보는 데까지 걸리는 시간을 크게 단축할 수 있습니다.

리액트 라우터와 만난 RSC, 무엇이 달라졌나

물론 아직 모든 것이 불안정한 실험 단계라는 점을 감안해야 하는데요.

그래도 공식 문서를 통해 엿본 미래의 모습은 꽤나 흥미로웠습니다.

제 블로그를 직접 마이그레이션하면서 발견한 몇 가지 중요한 변화는 다음과 같습니다.

가장 눈에 띄는 변화는 Vite 설정입니다.

기존에는 리액트 라우터를 Vite 플러그인으로 사용했지만, 이제는 @vitejs/plugin-rsc/plugin를 사용하고 3개의 새로운 진입점(entry) 파일을 설정해야 합니다.

  • entry.browser.tsx: 서버에서 생성된 HTML을 클라이언트에서 '하이드레이션(hydrate)'하고, 하이드레이션 이후의 서버 액션을 지원합니다.

  • entry.rsc.tsx: RSC 페이로드를 생성하는 역할을 합니다.

  • entry.ssr.tsx: 서버로 들어오는 요청을 처리하고, 가져온 RSC 페이로드를 이용해 최종 HTML을 생성합니다.

라우팅 설정 방식도 RSCRouteConfig라는 새로운 형태를 사용하게 되는데요.

이 부분은 아직 저도 깊게 파고들지는 못했지만, 기존과는 다른 접근 방식이 필요해 보입니다.

마이그레이션 과정 자체는 생각보다 순조로웠습니다.

Vite 설정을 업데이트하고, 새로운 진입점 파일들을 추가하고, 홈페이지 라우트를 RSC 라우트로 변환하는 작업은 금방 끝났거든요.

다만, 예제 코드에 포함된 <ClientLayout> 컴포넌트의 역할이 조금 애매하게 느껴졌습니다.

그래서 저는 과감하게 이 레이아웃 컴포넌트를 제거하고, 에러 바운더리(Error Boundary)만 "use client" 지시어를 사용한 클라이언트 컴포넌트로 분리했는데요.

놀랍게도 아무 문제 없이 잘 작동했습니다.

아마 이 레이아웃 컴포넌트는 필수적인 요소라기보다는, 서버 컴포넌트와 클라이언트 컴포넌트를 구조적으로 분리하는 하나의 패턴을 보여주기 위한 예시가 아닐까 싶습니다.

기존 Loader 방식 vs RSC, 무엇을 선택해야 할까

이미 리액트 라우터나 리믹스를 사용해 오신 분들이라면 loader 함수의 개념이 익숙하실 텐데요.

라우트 모듈에서 loader 함수를 export하고, 그 안에서 비동기적으로 데이터를 가져와 반환하는 방식입니다.

export async function loader({ params }: Route.LoaderArgs) {
  const post = await db.getPost(params.id);
  return post;
}
 
export default function Post({ loaderData }: Route.ComponentProps) {
  const { title, description } = loaderData;
  return (
    <article>
      <h1>{title}</h1>
      <p>{description}</p>
    </article>
  )
}

이 방식은 앞으로도 오랫동안 유효할 겁니다.

하지만 RSC를 사용하면, 이제 컴포넌트 안에서 직접 데이터를 가져올 수 있게 됩니다.

export default async function Post() {
  const params = useParams(); // (가상의 훅, 실제 구현은 다를 수 있음)
  const post = await db.getPost(params.id);
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.description}</p>
    </article>
  )
}

솔직히 말해, 두 방식의 결과물은 동일합니다.

하지만 데이터를 다루는 방식과 생각의 흐름 자체가 완전히 달라지는, 일종의 패러다임 전환이라고 볼 수 있습니다.

데이터 로직과 뷰 로직이 하나의 컴포넌트 안에서 자연스럽게 결합되는 거죠.

앞으로 loader 함수의 역할은 데이터를 반환하는 것보다는, 응답 상태 코드(response status code)를 관리하거나 리다이렉션을 처리하는 등 메타데이터를 다루는 쪽으로 점점 더 집중되지 않을까 조심스럽게 예측해 봅니다.

RSC의 꽃, 스트리밍과 Suspense

RSC의 진정한 강력함은 Suspense와 결합될 때 드러나는데요.

RSC는 async/await를 컴포넌트 레벨에서 사용할 수 있기 때문에, 데이터 로딩이 오래 걸리는 컴포넌트의 렌더링을 스트리밍 방식으로 점진적으로 클라이언트에 보낼 수 있습니다.

// movies/page.tsx
export default async function MoviePage() {
  const lotrPromise = movieApi.fetchTheLordOfTheRingsExtendedTrilogy();
  return (
    <>
      <Suspense fallback={<ABeautifulLoader />}>
        <MovieClientPage moviePromise={lotrPromise} />
      </Suspense>
    </>
  )
}
 
// movies/page.client.tsx
"use client"
import { use } from 'react';

export function MovieClientPage({ moviePromise }) {
  const movieData = use(moviePromise);
  return <MovieDetail data={movieData} />;
}

위 예제에서는 영화 데이터를 가져오는 Promise를 클라이언트 컴포넌트에 prop으로 넘겨주고, 클라이언트 컴포넌트는 use 훅을 사용해 Promise가 완료될 때까지 기다립니다.

그동안 사용자는 Suspensefallback으로 지정된 로딩 컴포넌트를 보게 되죠.

이를 통해 전체 페이지가 로딩될 때까지 사용자가 흰 화면만 보고 있는 것이 아니라, 먼저 렌더링이 완료된 부분부터 점진적으로 화면을 보여줄 수 있어 사용자 경험을 크게 향상시킬 수 있습니다.

주의해야 할 점, RSC의 함정

RSC는 강력한 도구이지만, 우리가 익숙했던 방식과 너무나도 다르기 때문에 몇 가지 잠재적인 성능 이슈를 염두에 두어야 합니다.

  • 렌더링 블로킹: RSC는 실행이 완료될 때까지 내비게이션을 '블로킹'할 수 있습니다.

    따라서 느린 데이터 요청이 포함된 RSC는 반드시 Suspense 경계로 감싸주어야 합니다.

  • N+1 쿼리 문제: 서버 컴포넌트 안에서 루프를 돌며 자식 서버 컴포넌트를 렌더링하고, 각 자식 컴포넌트가 개별적으로 데이터를 fetching하는 실수를 저지르기 쉽습니다.

    이는 데이터베이스에 엄청난 부하를 주는 최악의 안티 패턴이므로, 데이터를 한 번에 가져와서 자식 컴포넌트에 prop으로 내려주는 방식을 사용해야 합니다.

결론, 모험을 즐길 준비가 되었다면

리액트 라우터의 RSC 지원은 아직 실험 단계이지만, 웹 개발의 미래가 어떤 모습일지 엿볼 수 있는 아주 흥미로운 시도입니다.

직접 경험해 본 바로는, 설정 과정이 비교적 간단했고 로컬 환경에서 빠르게 작동시키는 데 큰 어려움은 없었습니다.

물론 아직 불안정하고 언제든 바뀔 수 있지만, 새로운 기술에 대한 모험심이 있다면 로컬에서 한번쯤 시도해 볼 만한 충분한 가치가 있습니다.

분명 많은 것이 깨지고 예상대로 동작하지 않겠지만, 그 과정에서 얻는 배움은 훨씬 더 클 거라고 확신합니다.