본문 바로가기

Client/Next.js

<FE> Next.js 13 으로 알아보는 FE 렌더링 방식 (SSR vs RSC)

1. 소개

 FE 개발자라면 CSR(Client-Side Rendering)과 SSR(Server-Side Rendering)이라는 용어는 매우 익숙할 것이다. 이번 포스팅에서는 이와 같은 렌더링 방식에 대해 다룰 예정이다. 최근에 Server Components가 Next.js 13의 출시와 함께 등장하면서 렌더링 방식이 획기적으로 변했기 때문에 지금까지의 렌더링 방식의 변화과정과 RSC(React Server Components)와 SSR을 Next.js를 통해 비교해볼 것이다. 결론부터 확인하고싶다면 "3.비교" 부분부터 봐도 좋다.

 

* 렌더링 방식의 변화

 비교적 최근 FE에서 자주 볼 수 있는 렌더링 방식의 변화과정이다. 리액트와 같은 도구의 등장으로 CSR(Client-Side Rendering)이라는 용어가 등장하였고, 이후 Next.js 와 함께 SSR(Server-Side Rendering)이라는 용어가 등장한다. 하지만 실제로 SSR은 이전부터 존재한 방식이다. 우리는 이 시기부터 서버컴포넌트가 등장한 현재까지 확대하여 살펴보자.

 

 

 고전적인 웹페이지 렌더링 방식(js를 사용하지 않는 더 이전은 다루지 않음)은 MPA(Multi-Page Application)방식이다. 특정 사이트 example.com에 접속하면 html, css, js 를 순서대로 받아와서 화면을 보여준다. example.com/about 으로 이동하면 다시 html, css, js를 순서대로 받아온다. 전체 페이지를 재생성하므로 서버 부하가 있고, 페이지 이동마다 사용자는 초기에 흰 화면을 보게된다. 원래 완성되어있던 html일수도있고, 템플릿 라이브러리와 같은 것을 통해 그때그때 html을 만들어서 클라이언트에게 전달하기도 하므로 SSR이라고 볼 수 있다.

 

 이후 리액트와 같은 도구의 등장은 SPA(Single-Page Application)을 가능하게 하였다. 이는 CSR(Client-Side Rendering)이라는 렌더링 방식이 등장했기 때문이다. CSR은 서버에서 html을 받고 js를 사용하여 웹페이지를 동적으로 렌더링하는 방식이다. 자칫 js 파일이 클 경우 초기 로딩시간이 좀 더 길 수 있으나, 한 번 페이지가 로드되면 example.com/about으로 이동하더라도 새로고침되지 않으므로 UX가 부드럽다.

 

<html lang="en">
<body>
  <div id="root"></div>
  <script src="./dist/app.js"></script>
</body>
</html>

 

 CSR 방식에서의 초기 html은 주로 위의 파일과 같다. 따라서 SEO에 대한 문제가 생긴다. 검색 엔진 크롤러가 위와 같은 것을 발견하면 페이지가 어떤 정보를 갖고 있는지 알 수 없다. 그래서 Next.js 와 같은 도구들은 이같은 문제를 SSR로 해결한다. 다시 고전으로 돌아가 example.com 을 요청하면 완성된 html을 제공하는 것이다. 이 때, js를 받아와 하이드레이션이라는 기술로 CSR도 가능하게 한다. 인터렉션이 가능해지고 이후부터는 example.com/about으로 이동하더라도 html을 다시 받아오지 않아 새로고침 현상이 일어나지 않는다.

 

 이렇게까지 봤을 때, 이제 문제는 없어보이는데 RSC는 어떤 문제를 해결한 것일까. 먼저 요청 위치가 바뀜으로써 얻는 이득이 있다. 기존 SSR은 당연하게도 페이지 단위로 그린다. next.js에서는 SSR방식(SSG, SSR, ISR)중 SSG를 권장한다.(각각에 대한 자세한 설명은 생략하겠다.) SSG는 번들시점에 html을 생성하는데 만약 페이지내부에서 자주 바뀌는 데이터가 있다면 SSR(getServerSideProps)를 해야할 것이다. 매번 html을 그려야하므로 서버의 부하가 커진다. 하지만 서버컴포넌트는 컴포넌트 단위로 방식을 결정할 수 있다. 특정 컴포넌트는 캐시된 데이터만 사용하고, 어떤 컴포넌트는 늘 새로 그리게 하는 등 적절하게 서버의 부하를 조절할 수 있다. 이 외에도 TTI(Time to Interactive)와 번들사이즈 등도 개선할 수 있다. 이 부분은 본문에서 별도로 다루도록 하겠다. 지금은 서버컴포넌트가 어떻게 그려지는지 와닿지않을 수도 있다. 아래에서 직접 개발하고 화면을 통해 확인한다면 서버컴포넌트가 어떻게 SSR을 이뤄내는지 알 수 있을 것이다.

 

* Next.js

 본문으로 들어가기 전에 Next.js를 간단히 짚고 넘어가겠다. Next.js는 리액트를 위해 만든 오픈소스 자바스크립트 웹 프레임워크이다. 다양한 기능이 있지만 그 중 가장 큰 기능은 SSR(Server-Side Rendering)이다. 위에서 언급했듯이 SSR은 html을 생성하여 주는 것이기 때문에 당연하게도 페이지 단위로 이뤄졌다.

서버컴포넌트로만 이루어져있다면 클라이언트 측에서는 JS가 필요없다.

하지만 React.js 18 버전의 개발당시 RSC(React Server Component)라는 개념이 등장하였고, 컴포넌트 단위의 서버 생성 가능성이 열렸다. 극단적으로 본다면 클라이언트에서는 JS가 필요없는 상황이 생긴 것이다. 참고로 RSC는 개발과정에서 Versel과 리액트 팀이 함께 참여한 것으로 알고 있고, 기술적인 이슈로 Next.js에 Server Components라는 이름으로 추가되었다. React 공식문서에서도 최신 리액트 기능을 사용하려면 Next.js를 사용할 것을 권장하고 있으므로 사실상 React를 사용하기 위한 표준이라고 볼 수 있겠다.

 

2. 개발

 

 Next.js의 공식문서는 위와 같이 두 가지 버전으로 제공된다. 13 이전의 버전을 사용하고 있다면 Pages 라우터 방식을 사용하고 있는 것이다. 리액트의 최신 기능을 사용하려면 App Router를 사용하라고 권장한다. 실제로 서버컴포넌트를 사용하려면 App Router를 사용해야한다. npx create-next-app@latest 를 터미널에 입력하여 next.js 프로젝트를 셋업할 수 있고, 셋업시 나오는 아래의 질문을 통해 라우터 방식을 선택할 수 있다.

 

What is your project named? todo-project
...
Use App Router (recommended)? No / Yes
...

 

Page Router방식을 통해 기존 SSR방식을 구현해보고, App Router 방식을 통해 RSC를 구현하여 비교해보자. 여기서 만들어볼 프로젝트는 아래와 같다. home(/) 과 투두리스트(/todos) 페이지가 있고, 할일을 클릭하면 상세페이지 (/todos/[id]) 에서 설명이 보인다.

 

 

 프로젝트 코드는 필자의 깃허브에 공개해두었고, 각 api 요청 (/api/todos & /api/todos/[id])은 포스트맨의 목서버에 구축하였다.

 

* Pages Router - SSR

 

 프로젝트 구조는 위와 같다. next.js의 페이지 기반 라우팅은 파일기반으로 라우팅되므로 pages/index.tsx 는 '/' 페이지를 의미한다. todos/index.tsx 는 '/todos' 를 의미하고, todos/[id].tsx 는 '/todos/1' 과 같은 페이지를 의미한다. _app.tsx은 레이아웃을 담당한다.

 

 

 먼저 index.tsx_app.tsx 를 살펴보면 위와 같다. 특별한 것은 없고 네비게이션 바를 레이아웃으로 추가하였다. 그 외에는 기본 설정이므로 넘어가겠다.

 

 

 todos 페이지는 위와 같다. SSR을 사용하기위해 getServerSideProps를 활용하였다. props를 활용하여 TodoList 컴포넌트에 전달하여 그릴 수 있도록 한다.

 

 

 TodoList 컴포넌트는 배열로 된 Props를 받아 map을 활용하여 li태그와 Link태그로 리스트를 그린다. 

 

 

 다음은 상세페이지다. 마찬가지로 getServerSideProps를 사용하도록 하였다. [id]의 값을 꺼내기 위해 next의 context를 활용할 수 있다. context.params에서 파라미터를 꺼내어 활용할 수 있다.

 

* App Router - RSC

 app 라우터 방식은 next.js 13버전에 새로 도입된 방식이며, 폴더기반으로 동작한다. 폴더 내에 page.tsx 가 페이지를 담당하며, layout.tsx는 레이아웃을, loading.tsx는 패치중 로딩ui를, error.tsx는 에러일 때의 ui를 담당한다.

 

 

 page.tsx에 작성했다는 점과 _app.tsx에서 layout.tsx로 바뀐 것을 제외하면 크게 다른 것은 없다. 다음으로 /todos 페이지를 살펴보자.

 

 

 /todos 페이지 코드에서 무언가 이상한 것을 느꼈는가? 요청하는 코드가 없어졌다. 기존에는 page에서만 서버관련 함수를 사용하여 props로 넘겼지만 곧바로 TodoList 컴포넌트를 사용하고 있다. 즉 TodoList에서 직접 api요청을하고 그리고 있다는 뜻이다. 해당 코드를 살펴보자.

 

 

 getTodos라는 비동기 함수를 만들었다. next13 버전에는 fetch를 사용할 수 있게 되었다. 모든 컴포넌트는 기본적으로 서버컴포넌트이므로 이러한 fetch를 사용할 수 있다. 이후 TodoList컴포넌트에서는 이 함수를 호출하여 response를 사용하기만 하면 된다. getServerSideProps보다 훨씬 직관적이고 쉽다.

 

 마찬가지로 /todos/[id] 페이지도 작성할 수 있다.

 

3. 비교

 이제 page 라우터 방식을 통해 기존의 SSR과 app 라우터 방식에서의 서버컴포넌트를 비교해보자. 참고로 원활한 비교를 위해 스타일링은 css-in-js가 아니라 module-css로 작성하였다. css-in-js로 작성하면 서버컴포넌트에서 js파일을 필요로 하기 때문이다. 물론 스타일코드만 받아오는 것이지만 학습 및 비교를 위해서는 js파일을 받아오는 차이를 확인하는 편이 좋다. 그리고 app라우터 에서의 fetch 쪽 로직을 수정한다.

 

 

 우리가 작성한 방식은 디폴트로 코드를 작성했으므로 페이지 라우터에서의 SSG와 같다. SSR로 변경하기위해 옵션을 no-store로 주어 getServerSideProps와의 비교를 용이하게 하였다.

  

* SSR

 먼저 각각 서버사이드렌더링이 잘 동작하고 있는지 확인해보자.

 

 각각 최초 메인페이지 진입시 html파일을 살펴보면 서버에서 잘 작성하여 전달했음을 확인할 수 있다. 다른 파일들은 아래의 '네트워크 요청'에서 다시 다뤄보도록 하겠다. 이제 이 상태에서 Link로 되어있는 todos 를 클릭하면 어떻게 될까. 예상했듯이 next.js에 의해 CSR로 동작한다. 즉 html은 다시 받아오지 않으며 새로고침도 일어나지 않는다. 단순히 추가적인 청크파일들만 받아오는 것을 볼 수 있을 것이다. 

 

 정말 페이지 이동 시에만 CSR로 동작하는 것이 맞는지, 혹시 메인페이지만 SSR인 것은 아닐지 확인해보자. /todos페이지로 링크가 아닌 직접 주소창에 입력하여 접근해보자. 위의 상태에서 새로고침을 해도 좋다.

 

 

 다른 페이지(/todos)에 최초 접근 혹은 새로 고침시에도 SSR이 정상적으로 동작하고 있다. 서버에서 api를 내부적으로 호출하여 html을 완성시킨후 보내고 있음을 확인할 수 있다. 결과적으로 서버컴포넌트도 기존과 동일하게 SSR이 동작하고 있음을 확인했다.

 

* 네트워크 요청

 

 위 이미지는 첫 메인페이지 진입후 todos링크를 클릭했을 때의 네트워크 탭이다. 페이지기반 방식부터 하나하나 살펴보자. 첫번째 부분은 todos 와 관련된 청크파일이다. 이 부분은 메인페이지에 진입했을 때 받아와졌다. next/Link는 기본적으로 프리로드가 활성화되어있다. 그래서 관련 청크파일이 받아와지는 것이다. 그 후 두번째 json파일은 /todos를 클릭했을 때 api요청이 이뤄지며 미리 있던 청크파일과 함께 CSR방식이 동작하게 된다. 마지막 부분은 "자세히 보기"가 Link이므로 /todos/[id] 도 프리로드된 부분이다.

 

 

 다음은 드디어 서버컴포넌트 부분이다. 파일 갯수가 기존의 SSR과는 확연하게 다르다. 스타일과 번들설정 때문에 일부 js파일이 있긴하지만 메인에서 한 번 받아온 것이 전부이다. 마찬가지로 Link때문에 최초에 todos로 시작하는 무언가를 받아왔다. 그러나 /todos로 이동했을 때 json 파일은 받아오지 않는다. 프리로드 때문에 1,2,3,4,5 id의 파일들이 받아와지는 것이 전부이다. 이 상태에서 자세히 보기를 아무거나 클릭하면 아래와 같다.

 

 

 놀랍게도 아무 파일도 받아오지 않고 페이지를 보여준다. 어떻게 이런일이 가능할까. rsc가 들어간 파일을 살펴보면 이 동작을 추측할 수 있다. 이 파일들은 json과 비슷한 형태로 되어 있으며 자신에 대한 정보를 갖고 있다. 내용을 봐서는 위치와 관련된 정보로 추측된다. 

 

 

 서버를 확인해보면 /todos 를 들어간 시점에서 프리로드될 때 이미 모든 요청을 보내고 이 정보를 바탕으로 rsc파일을 보낸 것이다. 그래서 서버컴포넌트는 페이지를 아무리 바꾸더라도 js파일이 필요없고, 심지어 api요청 자체를 감출 수 있다.

 

* 요청 위치

 

 코드를 작성할 때 이미 확인했던 내용이다. 페이지 라우팅 방식에서는 반드시 SSR관련 함수를 page에 작성해야한다. 하지만 서버컴포넌트는 컴포넌트에 서버 로직을 작성할 수 있다.

 

 이를 통해 얻는 장점은 크게 3가지이다. 첫째, 관심사의 분리가 가능해졌다. 페이지는 완전히 view로써의 역할만 수행하면 된다. 정말 비동기 로직이 필요한 곳에 비즈니스 로직을 작성하게되면서 코드를 깔끔하게 유지하고 추후 수정에도 유리하다. 둘째, props 드릴링 문제가 해결되었다. 기존에는 비동기로직을 페이지에작성하고 결과를 컴포넌트에 내려줘야했다. 위의 경우는 TodoList라는 곳에 한번만 내려주면 되지만 복잡한 컴포넌트라면 depth가 더 깊어지고 유지보수를 곤란하게 만들 수 있다.

 

 

 셋째, SSR 전략을 다양하게 세울 수 있다. 특정 페이지에 자주 바뀌는 상태와 그렇지 않은 상태가 혼재되어 있다고 가정하자. 그렇다면 페이지 기반에서는 SSR, SSG, ISR 중에서 어쩔 수 없이  SSR을 사용할 수 밖에 없다. 하지만 서버 컴포넌트라면 어떨까. 자주 바뀌어야 하는 컴포넌트는 캐시옵션을 no-store로 주고, 자주 바뀌지 않는다면 force-cacherevalidate를 적절히 줄 수 있을 것이다.

 

* 번들 사이즈

 마지막으로 비교할 내용은 번들 사이즈이다. 위의 프로젝트는 크고 복잡한 프로젝트가 아니라서 확연한 차이를 기대하기는 어렵다. 그렇다하더라도 전반적인 Size를 비교해보면 app기반이 좀더 사이즈가 작은 것은 알 수 있다. 왜 그럴까. 이유는 "서버" 컴포넌트이기 때문이다. 서버 컴포넌트는 서버에서만 렌더링 되는 컴포넌트이다. 즉 클라이언트 번들링에 포함되지 않는다.

 

 이는 TTI(Time to Interactive)에서 엄청난 강점을 갖는다. 기존의 SSR은 결국 JS에 모든 내용을 담아 클라이언트에 제공한다. 첫 html을 SPA보다 빨리 주기때문에 LCP, FCP는 빠를 수 있지만 결국 TTI는 SPA와 동일하다. 하지만 서버컴포넌트는 위에서 봤듯이 js가 아닌 json과 비슷한 데이터를 제공할 뿐이므로 훨씬 빠른 TTI를 가져갈 수 있다.

 

4. 결론

 Server Components의 등장으로 FE의 렌더링 방식이 완전히 새로워진 것은 틀림없다. 극단적인 SSR이 가능해졌고, 이를 통해 JS파일을 거의 받지 않고도 다양한 페이지를 구성할 수 있게 되었다. 특히 서버로직으로 인해 SSR의 강점이 극대화되었다. api요청을 서버에서 한다는 것은 캐시 또한 서버에서 가질 수 있다는 것이다. 클라이언트에 캐시가 있다면 100만명의 요청이 있을 경우 DB에 100만번 요청을 하고 클라이언트에 캐시를 저장했다. 하지만 SSR과 서버컴포넌트는 100만명의 요청이 있더라도 DB에 1번만 요청하고 캐시해두면 된다. 기존에는 SSG, ISR, SSR에서 선택해야 하므로 캐시 전략에 제한이 있었다면 서버 컴포넌트는 다양한 전략으로 서버의 부하를 최소화하고 효율적인 어플리케이션을 구축할 수 있게 되었다.

 

 클라이언트 측에서도 다양한 이점을 갖게 되었다. 네트워크 요청이 매우 가벼워졌고, api요청을 모두 숨길 수 있게되어 보안상으로도 안전한 프로젝트를 만들 수 있게 되었다. 게다가 서버컴포넌트는 번들에 들어가지 않으므로 초기 렌더링 속도가 매우 빨라지게 되었다.

 

복잡해진 컴포넌트 트리 구조

 

 프로젝트 개발에서도 많은 변화가 생겼다. 페이지에 작성했던 로직을 컴포넌트로 뺄 수 있게되어 훨씬 유지보수에 유리한 코드를 작성할 수 있게 되었다. 하지만 서버컴포넌트로 인해 설계 단계에서 생각할 부분이 굉장히 많아졌다. 본문에서는 언급하지 않았으나 리액트의 훅(use...)이나 브라우저 API를 쓰는 컴포넌트는 클라이언트 컴포넌트로 만들어야한다. 이를 위해 컴포넌트를 더 정교하게 분리해야 한다. 

 

 이러한 상황은 마치 상태관리 라이브러리의 변화과정을 보는 것 같다. swr, react-query, RTKQuery 등의 등장으로 Client-Side State와 Server-Side State를 분리하여 관리하게 되었듯이 앞으로는 컴포넌트 자체를 분리하여 관리하는 것이다. 아직은 적절한 컨벤션이나 Best Practice가 등장하지 않았지만 추후 이런 것들이 잘 정립된다면 상태관리처럼 관심사의 분리를 통한 이점을 챙길 수 있을 뿐만 아니라 획기적인 FE 생태계가 열릴 것이라 확신한다.

 

 


참고

 

 

Docs | Next.js

Using App Router Features available in /app

nextjs.org

 

극한의 프론트엔드 성능최적화 1편 (Nextjs 13)

next13 어렵다 어려워

velog.io

 

실전에서 바로 쓰는 Next.js - YES24

Next.js의 모든 기능을 낱낱이 파헤치고, 온라인 상거래 사이트까지 직접 구축해보는 웹 개발 실전서이 책의 강점은 리액트-Next.js를 함께 사용하는 방법과 Next.js를 단독으로 사용할 수 있는 실질

www.yes24.com

 

Next.js 13의 Client Component 살펴보기

Next.js 13의 Client Component 살펴보기

mycodings.fly.dev

 

[번역] React Server vs Client Component in Next.js 13

원 글 링크Next.js 에서, 너는 리액트 서버 컴포넌트와 클라이언트 컴포넌트를 사용할 수 있다. app 경로에서 기본적으로 모든 컴포넌트들은 서버 컴포넌트이다. 이 게시글에서, 우리는 리액트 서

velog.io

 

GitHub - haesoo-y/study-nextjs: Study for Next

Study for Next. Contribute to haesoo-y/study-nextjs development by creating an account on GitHub.

github.com