RSC를 중심으로 앱 라우터(App Router) 렌더링 정복하기

19 min read

Next.js의 App Router 기반으로 블로그를 개발하면서 렌더링과 관련된 글들을 많이 살펴보았습니다. 상위에 랭크된 게시글들을 보면 대부분 SSR, SSG, ISR과 같은 렌더링 전략에 대한 개념을 다루고 있습니다. 현재 App Router에서도 이러한 렌더링 방식이 개념적으로는 존재하지만, 대부분의 글은 Page Router를 기점으로 설명하거나 개념을 혼용하고 있는 경우가 많았습니다. 따라서 이 글에서는 Next.js의 기본적인 렌더링 방식을 살펴보고 Page Router에서 App Router로 넘어오면서 변화한 렌더링 방식과 핵심적인 차이점을 자세히 알아보고자 합니다.

Next.js의 탄생 배경

Next.js는 React에서 파생되었으며 React를 기반으로 동작하는 기술입니다. 그렇기때문에 Next.js를 React의 메타 프레임워크(Meta Framwork)라고도 많이 불리곤 합니다. React 만으로도 이미 충분하다 생각했는데 Next.js 제작자는 React에 어떤 문제를 해결하기 위해 이러한 기술을 개발하게 되었을까요?

초기 웹의 역사

먼저 머나먼 과거로 돌아가 JSP(JavaServer Pages)로 웹을 개발하던 시대로 가보겠습니다. 초기에 JSP는 웹 콘텐츠를 보여주는데 목적을 두고 페이지 내에서 특별히 이벤트 처리와 같은 상호작용이 필요하지 않았습니다. 그렇기 때문에 단순히 HTML 코드에 Java 코드를 넣어 서버에서 실행시킨 후 동적으로 생성된 최종 HTML을 클라이언트에 전달하여 웹 페이지를 렌더링합니다. 마치 지금 Next.js의 Server Side Rendering과도 유사한 맥락으로 동작한 것입니다.

그러나 웹 애플리케이션이 계속 발전하면서 웹 페이지 내에서 더 풍부한 사용자 인터랙션에 대한 요구가 커져갔습니다. 예시로 Apple 초기 웹페이지의 경우 특별한 인터랙션없이 정보 전달만을 목적으로 하는 반면 Facebook의 초기 웹페이지는 로그인, 게시글 작성 등과 같은 다양한 상호작용이 필요합니다.

img_5.png

이때 대표적으로 이를 해결하기 위해 탄생한 것이 AJAX(Asynchronous Javascript and XML) 입니다. AJAX 없던 시절엔 로그인을 하기 위해 Form Data를 제출하면 서버에서 처리하는 동안 화면이 멈추게 되고 이후 완전히 새로운 페이지를 로드해야했습니다. 그러나 이후 AJAX를 통해 서버 요청을 비동기로 처리함으로써 로딩 중에도 사용자와 상호작용(ex. Loading Spinner)이 가능하며 페이지 전체가 아닌 특정 부분만 업데이트할 수 있게 되었습니다.

CSR(Client Side Rendering)

이렇듯 웹 페이지 내에 사용자와의 인터랙션과 더 많은 기능을 수행하고 페이지가 늘어남에 따라 점차적으로 현대의 CSR 방식 렌더링 아키텍처가 자리잡게 되었습니다. 대표적으로 React는 현대 웹 개발에서 가장 많이 사용되는 자바스크립트 라이브러리로 CSR 방식으로 동작합니다.

React는 애플리케이션 빌드 과정에서 Webpack과 같은 도구에 의해 자바스크립트 번들로 변환됩니다. 이후 사용자가 웹 사이트에 접속하면 브라우저는 서버로부터 빈 깡통 html(index.html)을 다운로드한 뒤 React 코드가 포함된 자바스크립트 번들을 다운로드합니다.

이러한 React의 CSR 방식은 한번 로딩 후 페이지 전환이 빠르고 부드럽다는 장점이 있지만 몇 가지 단점들 또한 존재하는데요. 대표적으로 1)초기 로딩 시간 증가2)SEO에 불리하다는 단점이 있습니다.

CSR - 초기 로딩 속도

먼저 React는 초기에 모든 페이지에 대한 자바스크립트 번들을 다운로드하기 때문에 페이지가 많고 복잡할 수록 다운로드해야할 번들의 수와 번들의 크기가 커지게 됩니다. 이 때문에 서비스의 규모가 커질수록 유저는 초기에 오랜 시간동안 빈 화면을 바라봐야만 합니다. 이처럼 콘텐츠가 화면에 렌더링되어 유저가 확인할 수 있는 시점까지 걸리는 시간을 FCP(First Contentful Paint) 라 하며 React는 FCP에 있어 성능이 좋지 못하다라고 얘기할 수 있습니다.

FCP는 절대 가볍게 볼 지표가 아닙니다. 당장 저와 같은 한국인만 해도 웹사이트 로딩이 2초를 넘어갈 경우 혈압이 같이 상승하기 시작합니다. 실제 많은 연구에서도 FCP와 같은 렌더링 지표가 악화됨에 따라 유저 이탈률이 증가한다는 사실을 보여주고 있으며 예시로 BBC 사이트가 로드되는 데 1초가 더 걸릴 때 마다 사용자 이탈률이 10% 증가한다고 합니다.

CSR - SEO

검색 엔진 크롤러의 경우 HTML 문서를 먼저 다운로드하여 분석합니다. (구글의 경우 Javascript 실행 능력이 향상되었지만 모든 검색엔진이 이런 능력을 갖춘 것은 아닙니다) 또한 자바스크립트 실행 능력을 갖추었다 하더라도 크롤러는 제한된 시간 내에 페이지를 분석하기 때문에 모든 단계가 완료되기 전 크롤링을 종료할 수 있습니다.

사전 렌더링(Pre-rendering)

Next.js는 위와 같은 React의 CSR의 문제점을 해결하기 위해 사전 렌더링(Pre-rendering) 을 제공합니다. 사전 렌더링은 React와 같이 모든 페이지에 대한 자바스크립트 번들을 미리 로드하는 것이 아닌 진입한 페이지에 대해 필요한 번들만을 로드합니다. 이때 자바스크립트 번들을 브라우저가 아닌 서버 사이드에서 실행하여 브라우저로 렌더링된 HTML을 전달합니다.

Info

첫 번째 단계에서는 서버의 자바스크립트 엔진이 React 컴포넌트를 실행하여 완전한 HTML로 변환합니다. 이 HTML은 모든 콘텐츠를 포함하지만 아직 사용자와 상호작용할 수 없는 정적인 상태입니다. 이후 클라이언트에서 두 번째 자바스크립트 실행 과정을 통해 기존 HTML에 이벤트 리스너가 부착되며 점진적으로 상호작용 기능이 활성화됩니다. 이 과정이 마치 건조한 HTML에 생명력(상호작용성)을 부여하는 물을 공급하는 것과 같다 하여 하이드레이션(Hydration) 이라고 부릅니다.

Next.js는 이러한 사전 렌더링을 통해 FCP와 같은 초기 렌더링 속도와 SEO를 개선하고자 하였습니다. 그러나 위와 같은 사전 렌더링 메커니즘 내에서도 몇 가지 문제가 존재하였는데요. 이제 본격적으로 초기 라우터 형태인 Page Router가 가진 문제점과 이를 해결하고자 제시한 React Server Component에 대해 알아보겠습니다.

App Router 렌더링

페이지 라우터(Page Router)의 문제점

페이지 라우터는 기본적으로 모든 컴포넌트가 클라이언트 컴포넌트로 동작합니다. getServerSideProps, getStaticProps등의 Next.js에서 제공하는 메서드를 기반으로 서버 렌더링을 구현해야 합니다. 이러한 페이지 라우터를 기반으로 아래와 같은 컴포넌트 트리가 존재한다고 가정해보겠습니다. 이때 페이지 내에는 useState 혹은 버튼 클릭 이벤트 리스너와 같은 상호작용이 필요한 컴포넌트와 이같은 상호작용이 필요하지 않은 컴포넌트가 각각 존재합니다.

component_tree.png

페이지 라우터에서는 앞서 사전 렌더링에서 살펴보았듯이 위와 같은 렌더링 트리에 대해 페이지 내 모든 컴포넌트를 HTML로 변환하여 사용자에게 전달합니다. 그러나 이 과정에서 중요한 기술적 한계가 존재합니다. 바로 상호작용이 필요한 컴포넌트와 그렇지 않은 정적 컴포넌트를 구분하지 않고 모든 컴포넌트의 JavaScript 코드를 단일 번들로 클라이언트에 전달하는 것입니다. 이로 인해 하이드레이션 과정에서 브라우저는 불필요하게 많은 JavaScript를 파싱하고 실행해야 합니다.

page_router_js_bundle.png

이처럼 하이드레이션이 완료된 후 사용자가 실제 페이지와 상호작용할 수 있게 되기까지의 시간을 TTI(Time to Interactive) 라고 합니다. 이는 FCP와 마찬가지로 Core Web Vitals의 중요한 성능 지표중 하나로 기존 Page Router에선 TTI가 지연될 수 있다는 문제가 존재합니다.

RSC(React Server Component)

위와 같은 한계를 극복하기 위해 등장한 것이 바로 RSC(React Server Component)입니다. RSC는 용어 그대로 "서버"에서 실행되는 컴포넌트입니다. 따라서 기존 React에서 동작하는 컴포넌트는 자연스럽게 클라이언트 컴포넌트, RSC는 서버 컴포넌트로 분리하여 이해할 수 있습니다.

페이지 라우터(Page Router)와는 달리 앱 라우터(App Router)에선 모든 컴포넌트가 기본적으로 서버 컴포넌트로 동작합니다. 따라서 컴포넌트 내에서 console.log()를 호출해도 브라우저가 아닌 서버 로그창에서 해당 로그를 확인할 수 있습니다.

export default function PostPage() {
  console.log('this is logged on server!');
  ... 
}

또한 async 키워드를 통해 컴포넌트 함수가 Promise를 반환할 수 있습니다. 따라서 서버 API를 선언할 필요없이 데이터베이스에 직접 접근하여 데이터를 비동기적으로 가져올 수 있습니다.

export default async function PostPage() {
  const allPosts = await getAllPosts();
 
  return (
      <div>
        {allPosts.map(post => (
          <PostItem key={post.id} post={post} />
        ))}
      </div>
  );
}

RSC Rendering

RSC를 보다 이해하기 위해 서버 컴포넌트의 렌더링 과정을 살펴보겠습니다. Next.js는 페이지 요청 시 React API를 활용하여 서버 컴포넌트 트리를 렌더링하고 모든 서버 컴포넌트를 실행합니다. 이때 서버 컴포넌트 렌더링의 결과물을 토대로 RSC Payload를 생성합니다.

RSC Payload란 서버 컴포넌트의 렌더링 결과를 직렬화하여 특별한 형태로 변환한 데이터를 의미합니다. RSC Payload는 아래와 같은 세 가지의 데이터를 포함하고 있습니다.

  1. 서버 컴포넌트의 렌더링 결과
  2. 연결된 클라이언트의 컴포넌트 위치 및 관련 자바스크립트 파일 참조
  3. 클라이언트 컴포넌트에게 전달하는 Props 값 :::

이후 Next.js는 이렇게 만든 RSC Payload와 클라이언트 컴포넌트의 자바스크립트 명령어 뭉치인 Instructions를 이용하여 초기 HTML을 생성합니다.

그렇다면 여기서 한 가지 의문점이 생길 수 있습니다. RSC Payload는 도대체 왜 만드는거고 어디에서 사용하는걸까요? 이를 위해 뒤이어 발생하는 클라이언트 렌더링 과정을 살펴보겠습니다.

먼저 브라우저는 앞서 서버로부터 생성되어 전달받은 HTML을 화면에 표시합니다. (이때 페이지는 유저에게 보이지만 상호작용은 불가능합니다.) 이후 상호작용을 활성화하기에 앞서 React 내부적으로 클라이언트 컴포넌트와 서버 컴포넌트 트리를 RSC Payload를 이용하여 재조정(Reconcile)하게 됩니다. RSC Payload에 포함된 클라이언트 컴포넌트의 위치와 JS 파일 참조 등을 활용하여 이를 React 엘리먼트 트리로 변환하고 가상 DOM 트리를 구성하는 것입니다. 이렇게 DOM 업데이트를 마치고나면 각 클라이언트 컴포넌트에 대해 useState와 같은 React의 이벤트 처리를 활성화하는 하이드레이션 과정을 거치게 됩니다.

rsc_js_bundle.png

즉, 정리하면 앞서 페이지 라우터의 경우 하이드레이션 과정에서 상호작용이 포함되지 않은 컴포넌트도 불필요하게 하이드레이션 과정에 포함되어야 했습니다. 그러나 앱 라우터에선 상호작용이 필요하지 않은 컴포넌트를 서버 컴포넌트로써 구현하고 서버 컴포넌트의 경우 초기에 한 번만 렌더링 과정을 거치도록 하였습니다. 따라서 하이드레이션 과정에선 서버 컴포넌트 렌더링 과정에서 생성된 RSC Payload를 기반으로 클라이언트 컴포넌트에 대해 필요한 부분만 자바스크립트 번들을 로드하고 하이드레이션 과정을 거침으로써 TTI를 단축할 수 있습니다.

ReactNext.js

댓글 0