- Client-Side Rendering
- Server-Side Rendering
- Suspense in SSR
- React Server Component
1. Client-Side Rendering
1.1. SPA의 표준
CSR은 모든 렌더링 작업을 브라우저에서 수행하는 방식이다. 서버로부터는 최소한의 HTML과 JS 파일을 받아 브라우저가 이를 기반으로 UI를 동적으로 생성한다. CSR은 빠르게 SPA의 표준으로 자리 잡았으며, 페이지 전환 시 전체 페이지를 다시 로드하지 않고 필요한 부분만 업데이트함으로써 사용자 경험을 크게 향상했다.
1. Request: Browser(Client)가 요청을 보낸다.
2. 서버는 간단한 HTML 파일과 JS 파일을 client로 전송한다. 주로 <div id="root"></div>와 같은 최소한의 구조만 포함된다. (TTFB)
3. 초기 렌더링: Browser는 HTML, CSS를 다운로드하고 렌더링해서 초기 페이지 구조를 만든다. 사용자들은 이때 "빈 화면"을 보게 된다.
4. JS 처리: Browser는 HTML에 포함된 <script> 태그를 통해 JS를 다운로드하고 실행한다. 이때 react 같은 라이브러리가 실행되면서 UI가 생성된다.
5. Hydration: JS가 서버에서 전달된 HTML에 기능을 추가해서 페이지가 interactive해진다.
6. Fetching Data: Client는 API를 호출해서 필요한 데이터를 가져온다.
7. UI 완성: 이 데이터를 기반으로 UI가 완성되고, 이때 LCP(Largest Contentful Paint)가 측정된다.
1.2. CSR의 문제점
하지만 이 구조로 인해 다음과 같은 문제점들이 발생한다.
1. 초기 로딩 속도
CSR에서는 HTML, CSS 렌더링 후 JS 파일을 다운로드, 파싱, 실행해야만 interactive한 페이지를 볼 수 있다. 이 모든 과정이 순차적으로 이루어져야 하기 때문에 TTFB, LCP를 포함한 초기 로딩 성능을 저하한다. 이는 사용자가 사용하는 디바이스의 성능이 안좋거나 네트워크 연결 속도가 느릴수록 로딩 시간이 길어지기 때문에 저사양 디바이스에서 사용자 경험이 크게 떨어질 수 있다.
2. 사용자 경험
서버에서 최소한의 HTML만 제공되기 때문에 JS 실행 전까지는 빈 화면 또는 로딩 스피너만 보여주게 된다. 또한 대량의 데이터를 가져오는 애플리케이션에서는 data fetching 및 렌더링 과정이 길어져 사용자가 주요 콘텐츠를 보는 시간이 길다.
3. JS bundle size
JS는 클라이언트에서 다운받는다. 기능이 많아질수록 JS bundle size도 늘어나 브라우저가 이를 처리하는데 많은 시간이 소요된다.
4. SEO
검색 엔진 크롤러는 주로 서버에서 렌더링된 HTML 콘텐츠를 인덱싱한다. 하지만 CSR에서는 하나의 div 태그가 포함된 HTML만 제공하기 때문에 검색 순위가 낮아질 수 있다.
5. 성능 최적화
초기 렌더링 시 HTML 구조가 거의 없기 때문에 브라우저에서 효율적인 HTML 캐싱이 어렵다. 또한 data fetching, 상태관리, 컴포넌트 렌더링 등의 모든 작업을 클라이언트에서 처리하기 때문에 브라우저의 부하가 증가한다.
2. Server-Side Rendering
2.1. CSR의 문제를 어떻게 해결했나
CSR의 문제를 해결하기 위해 SSR이 등장했다. SSR은 서버에서 페이지를 렌더링한 후 완성된 full HTML을 브라우저로 보내는 방식이다. 이를 통해 CSR의 두 가지 문제를 해결한다.
- SEO 최적화: 서버에서 full HTML을 제공하므로, 검색 엔진 크롤러가 콘텐츠를 인덱싱하는게 수월해졌다.
- 사용자 경험: JS 실행 전까지 빈 화면을 봐야만 했던 CSR과 달리, SSR은 JS 실행 전에도 완성된 페이지를 볼 수 있다. (interactive하진 않지만..)
c.f) Hydration
Hydration은 SSR로 생성된 HTML이 브라우저에서 fully interactive하게 작동하도록 만드는 과정이다. 이 과정에서 React는 다음과 같은 작업을 수행한다.
1. 서버에서 전달된 static HTML을 기반으로 virtual DOM을 생성하여, 브라우저 메모리 내에서 컴포넌트 트리를 재구성한다.
2. HTML element에 JS 로직을 binding하여 동적인 동작이 가능하도록 준비한다.
3. 각 element에 click, mouseover와 같은 이벤트들을 처리할 수 있도록 event handler를 연결한다.
4. 애플리케이션 상태를 초기화하고, 서버에서 전달된 데이터와 클라이언트 측 로직을 동기화한다.
2.2. SSR의 문제점: All or Nothing waterfall
① 서버는 필요한 데이터를 모두 가져와야만 HTML 렌더링을 시작할 수 있다.
SSR에서 브라우저가 받을 HTML은 "완성된 HTML"이다. 즉, 어떤 페이지에서 DB나 다른 API fetching을 통해 데이터를 불러와 보여주는 부분이 있다면, 서버는 해당 데이터를 모두 포함한 완전한 HTML을 만들어야 한다는 뜻이다. 만약 데이터를 모두 가져오기 전에 HTML을 생성한다면? CSR처럼 빈 화면이 보이는 문제가 생길 수 있다.
SSR에서 HTML이 생성되는 과정은 ①필요한 데이터 가져오기 > ②데이터를 기반으로 컴포넌트 렌더링 >③완성된 HTML 반환 순서로 이루어진다. SSR은 동기적으로 작동하므로, 데이터를 가져오는 첫번째 단계가 끝나지 않으면 다음 단계로 넘어갈 수 없다.
② Hydrate를 시작하려면 모든 JS 파일이 로드된 상태여야 한다.
클라이언트는 모든 JS 파일을 다운로드하고 실행해야만 hydration을 시작할 수 있다. 왜냐하면 React는 컴포넌트 트리를 단계별로 hydrate하지 않고 트리 전체를 한 번에 처리하기 때문이다. Hydration 과정을 생각해보면, react는 컴포넌트 트리를 재구성하고 HTML element에 JS 로직을 binding 하는데, 이때 페이지 내 어느 한 부분이라도 JS 로직이 준비되지 않았다면 트리 전체의 hydration을 진행할 수 없는 것이다. (한번 시작하면 끝이므로 시작하기 전에 모든 준비가 완료되어야만 한다는 뜻)
③ 컴포넌트가 interactive해지기 위해서는 모든 hydration 과정이 끝나야 한다.
이것도 hydration이 한 번에 처리되는 것과 연관이 있다. 한 번 hydration이 시작되면 React는 컴포넌트 트리 전체를 순차적으로 작업하기 시작하고, 중간에 멈추거나 특정 컴포넌트만 먼저 처리할 수 없다. 또, hydration 과정에서 React는 JS의 주요 리소스를 할당해서 다른 JS 동작을 차단하기 때문에 Hydration이 완료되기 전까지는 애플리케이션의 다른 JavaScript 로직이 실행되지 않는다. 결론적으로 모든 부분의 hydration이 끝나기 전까지는 다음 단계, 즉 사용자와의 상호작용이 불가능하다.
SSR의 문제점 중 가장 큰 원인은 컴포넌트 트리 전체를 한 번에 Hydration 하는 방식에 있다. 이 방식은 애플리케이션의 특정 부분이 다른 부분보다 렌더링이 느릴 경우 굉장히 비효율적이다. 예를 들어 일부 컴포넌트가 데이터를 비동기로 가져오거나, 복잡한 로직을 처리해야 할 때, 해당 부분이 준비되지 않으면 전체 페이지의 Hydration이 지연된다.
이를 해결하기 위해 React 팀은 특정 부분만 먼저 렌더링하고 나머지는 비동기로 처리하는 접근법을 고민하기 시작했다. 이 아이디어를 구현하기 위해 등장한 게 바로 Suspense이다.
3. Suspense in SSR
3.1. Suspense가 해결하는 문제
1. HTML streaming
2. Selective hydration
1. HTML streaming on the server
기존 SSR에서 서버는 완성된 HTML을 한번에 보내기 위해 필요한 모든 데이터를 가져오고 전체 HTML을 완성한 후에 전송해야 했다. 하지만 HTML Streaming은 스트리밍 방식으로 준비된 부분부터 순차적으로 전송한다. 이를 통해 준비된 부분부터 즉시 렌더링할 수 있도록 하는 것이다.
<Layout>
<Header />
<Sidenav />
<Suspense fallback={<Spinner />}>
<MainContent />
</Suspense>
<Footer />
</Layout>
남은 문제: Hydration
HTML streaming으로 초기 렌더링 속도는 개선되었지만, hydration과 관련된 문제가 남아있다. React는 hydration을 시작하기 위해 전체 JS 파일이 로드되어야만 한다. 만약 MainContent가 JS 번들에서 큰 비중을 차지한다면, 해당 부분의 로드, 실행이 끝날 때까지 hydration의 딜레이가 발생한다. 그렇다면 HTML Streaming으로 인한 빠른 초기 렌더링이 의미가 없어질 수 있다.
해결: Code Splitting
이 문제를 해결하기 위해 Code Splitting을 활용할 수 있다. React는 React.lazy를 통해 Code Splitting을 지원한다. 이를 통해 기존 JS bundle에서 MainContent의 코드를 별도의 번들로 분리함으로써 MainContent가 필요없는 경우에 해당 코드가 로드되지 않아도 다른 부분의 JS는 독립적으로 실행될 수 있다. 그리고 MainContent가 준비되지 않은 동안 다른 컴포넌트의 hydration을 우선적으로 수행할 수 있다.
MainContent 준비되기 전
<div id="root">
<div class="layout">
<header>Header Content</header>
<aside>Sidenav Content</aside>
<div class="spinner">Loading...</div> <!-- Suspense의 fallback 콘텐츠 -->
<footer>Footer Content</footer>
</div>
</div>
<script src="/static/js/header.chunk.js" defer></script>
<script src="/static/js/sidenav.chunk.js" defer></script>
<script src="/static/js/footer.chunk.js" defer></script>
MainContent가 준비된 후 스트리밍으로 추가 전송
<main>MainContent Content</main> <!-- MainContent의 HTML -->
<script src="/static/js/maincontent.chunk.js" defer></script>
2. Selective hydration on the client
React가 기존 SSR에서 컴포넌트 트리 전체를 한 번에 hydrate했던 이유는 서버에서 생성된 HTML과 클라이언트에서 Hydration으로 재구성된 Virtual DOM 간의 일관성을 보장하기 위함이다. 그러나 React는 Suspense와 Selective Hydration을 통해도 이 일관성을 유지할 수 있는 메커니즘을 도입했기 때문에, 트리 전체를 한 번에 Hydration하지 않아도 문제가 발생하지 않는다.
① Suspense를 활용한 Boundary 설정
Suspense를 "경계"라고 생각해보자. 각 Suspense boundary 덕분에 컴포넌트 트리가 독립적인 단위로 분리될 수 있다. 이로 인해 React는 각 Suspense Boundary를 독립적으로 Hydration할 수 있게 되었고, 전체 트리를 한 번에 처리하지 않아도 문제가 발생하지 않는다. 일관성을 유지해야 하는 영역을 작은 단위로 나누어서 처리함으로써 전체 트리를 한 번에 hydration하지 않아도 문제가 발생하지 않는 것이다.
서버에서 제공한 HTML과 클라이언트에서 생성한 Virtual DOM이 일치하는지 확인할 때도, boundary 내의 컴포넌트만 일치 여부를 확인하기 때문에, 트리 전체가 아니라 필요한 부분만 동기화할 수 있다.
② 비동기 로딩, Suspense Fallback
비동기 데이터 로딩이나 컴포넌트의 준비 지연으로 발생할 수 있는 불일치 문제를 Suspense의 Fallback으로 해결한다. 예를 들어, MainContent가 준비되지 않았을 때 React는 MainContent의 HTML 대신 fallback 콘텐츠를 먼저 렌더링한다. 그 후 MainContent가 준비되면 해당 콘텐츠를 hydration해서 DOM과 virtial DOM의 일관성을 보장하는 것이다.
Selective Hydration을 통해 SSR의 세 번째 문제, 즉 모든 Hydration이 끝나야만 컴포넌트가 인터랙티브해지는 문제까지 해결된다. React는 가능한 부분부터 Hydration을 진행하고, 사용자의 요구에 따라 상호작용이 필요한 부분을 우선적으로 처리해서 초기 로딩 성능과 사용자 경험을 모두 개선할 수 있다. 이 과정은 React 내부에서 자동으로 이루어진다.
3.2. 그럼에도 해결되지 않은 문제점
SSR과 Suspense가 많은 문제를 해결했지만, 아직 해결되지 않은 근본적인 한계가 남아있다.
① Client가 다운받아야 하는 JS bundle size는 그대로다
HTML streaming으로 비동기로 전송할 수 있지만, 결국 웹페이지의 전체 JS 코드를 클라이언트에서 다운로드해야한다는 사실은 변하지 않는다. 애플리케이션에 기능이 추가될수록 클라이언트가 다운받아야 할 코드의 양도 점점 늘어나는 사실도 그대로다. 브라우저가 이 모든 데이터를 다운로드해야만 할까?
② 모든 컴포넌트가 hydration되고있다
지금까지의 방식에서는 모든 컴포넌트가 클라이언트 측에서 hydration 과정을 거쳐야 한다. 실제로는 모든 페이지가 interactive할 필요는 없는데도 말이다. 예를 들어 블로그같은 static한 콘텐츠는 hydration 없이도 충분히 제공 가능하다. 상호작용이 필요하지 않은 컴포넌트까지 hydration하는건 불필요한 리소스 낭비같은데 이를 막을 방법은 없을까?
③ 클라이언트가 역할인가
서버가 아무리 성능이 좋아도 React 애플리케이션의 주요 JS 실행은 여전히 사용자의 디바이스에서 이루어진다. 디바이스의 성능이 낮거나 네트워크 환경이 좋지 않은 경우 로딩 속도가 느려질 것이다. 그런데 이렇게 많은 작업을 굳이 디바이스에서 처리해야만 할까?
4. React Server Component (RSC)
지금까지 CSR, SSR, Suspense in SSR까지 발전하면서 해결되지 못한 문제는 다음과 같다.
1. Client가 다운받아야 하는 JS bundle 크기가 너무 크다
2. 모든 컴포넌트를 강제로 hydration하는건 비효율적이다
3. 클라이언트 디바이스에 작업 부담이 과도하다
이에 react 팀은 새로운 아키텍처를 생각해냈다. RSC는 기존의 렌더링 방식과는 근본적으로 다른 접근법이다.
1. Client측 JS bundle size 감소
- RSC는 컴포넌트를 서버에서 렌더링하고, 결과 HTML를 클라이언트로 전송한다.
- 클라이언트 측에서 JS를 실행하지 않음으로써 JS 번들 크기를 줄인다.
- 특히, 서버에서만 필요한 dependency같은건 클라이언트로 전송되지 않으므로, 애플리케이션이 훨씬 가벼워진다.
- Hydration 단계가 제거되면서 초기 로딩과 상호작용 속도도 개선된다.
2. Selective Hydration
- 기존 방식에서는 모든 컴포넌트를 강제로 hydration해야 했지만, RSC에서는 interaction이 필요한 컴포넌트에만 'use client'를 추가하여 선택적으로 hydration할 수 있다.
- interaction이 필요없는 정적 콘텐츠는 hydration 과정을 거치지 않으므로, 불필요한 리소스가 낭비되지 않는다.
3. Data fetching
- 서버에서 직접 데이터를 가져올 수 있다.
- 민감한 데이터가 서버에서만 처리되고 클라이언트로 노출되지 않는다.
- 기존 방식에서는 부모 컴포넌트의 데이터가 로드될 때까지 자식 컴포넌트의 데이터 로딩이 지연되는 waterfall 문제가 있었지만, RSC는 이러한 데이터 로딩 로직을 서버로 이동시켜 client-server 간 왕복을 최소화한다.
4. Caching
- 서버에서 렌더링된 결과를 캐싱하여 재사용할 수 있으므로, 요청마다 데이터를 다시 가져오거나 렌더링하지 않아도 된다.
5. 마치면서
CSR부터 SSR, Suspense를 통한 HTML streaming, selective hydration, 그리고 RSC까지 리액트 팀은 계속해서 이전 기술의 단점을 보완하는 새로운 아키텍처를 제안한다. 이번에 렌더링 전략을 공부하면서 리액트 팀이 어떤 문제를 해결하기 위해 어떤 고민을 거쳤는지 이해해보는게 새로웠고, 이를 통해 공식문서만 보고 그냥 따라쓰고 있었던 기술들이 어떤 이유로 등장했는지 이해할 수 있었다.
'Frontend > React,Next' 카테고리의 다른 글
[Next.js] Data Mutation: Server Actions (0) | 2024.07.16 |
---|---|
React의 최적화 전략 (0) | 2024.04.11 |
React의 원칙: Immutable Data Pattern (4) | 2024.04.11 |
프로젝트 5개 하고 돌아보는 React를 쓰는 이유 (3) | 2024.04.04 |