프론트엔드 렌더링 패러다임의 변화와 의미(ft. RSC, Streaming SSR, PPR)
렌더링 패턴은 DOM을 언제, 어디서 생성할지를 결정하며, 선택된 전략에 따라 사용자 경험에 직접적인 영향을 미치기 때문에 프론트엔드 개발에서 매우 중요한 요소이다.
React와 같은 현대 웹 라이브러리의 등장으로, 프론트엔드의 렌더링 방식은 많은 발전을 이루었다. 최근 렌더링 패턴의 발전을 지켜보며 가장 크게 느낀 점은, 렌더링 단위가 페이지 수준에서, 컴포넌트 수준으로 변화하고 있다는 것이다. 사실 프론트엔드 개발이 페이지 단위에서 컴포넌트 단위로 전환된 지는 꽤 오래되었다. 우리는 더 이상 페이지 단위가 아닌 컴포넌트 단위로 작업한다. 그러나 렌더링 패턴은 오랫동안 페이지 단위에 머물러 있었다.
React 18에 등장한 RSC와 Suspense의 조합으로 스트리밍 SSR 아키텍처를 구축하거나, Nest.js의 실험적 기능인 PPR을 사용하면, 컴포넌트별로 렌더링 패턴을 적용할 수 있다. 한 페이지에 하나의 렌더링 패턴을 사용하는 것이 아니라, 컴포넌트별로 최적화된 렌더링 패턴을 사용하면 사용자에게 최적의 경험을 제공하고, 렌더링 퍼포먼스를 크게 향상할 수 있다
렌더링 패턴은 단순히 렌더링 과정뿐만 아니라 개발 방식에도 영향을 준다 특히 RSC의 등장으로 프론트엔드 개발은 이전보다 더 많은 점을 고려해야 하며, 섬세함이 필요해졌다. RSC의 등장은 이제 프론트엔드에 더 복잡하면서도 완성도 있는 아키텍처가 필요함을 시사하고 있습니다.
필자는 최신 프레임워크를 통해 RSC, PPR 등을 사이드 프로젝트에서 사용해보며 이들이 프론트엔드에 어떤 영향을 미칠지 판단했다. 그 결과, 지금이 프론트엔드가 한 단계 더 도약하여 새로운 패러다임을 맞이하는 시점이라고 평가한다.
새로운 패러다임을 맞이하기 위해서는 준비가 필요하다. 따라서 이번 포스팅에서는 React를 중심으로, React의 등장부터 최근까지의 렌더링 패턴의 변화를 시간 순으로 살펴보고자 한다. 각 렌더링 패턴의 의미와 한계를 살펴보고, 왜 새로운 렌더링 패턴이 등장했는지를 이해하면 다음 패러다임을 준비하는 데 분명 도움이 될 것이다
1. CSR
클라이언트 사이드 렌더링(Client-Side Rendering, 이하 CSR)은 말 그대로 클라이언트 측에서 DOM을 렌더링 하는 전략이다. 리액트가 처음 등장했을 때는 대부분의 설정이 SPA와 순수한 CSR 렌더링 패턴을 결합한 형태였다. 아마 다음과 같은 HTML 파일을 본 적이 있을 것이다.
<!DOCTYPE html>
// 이제는 추억이 된 react 초기 html 파일
<html>
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
`bundle.js` 스크립트에는 React, 서드파티 종속성, 그리고 개발자의 코드 등 애플리케이션을 마운트하고 실행하는 데 필요한 모든 요소가 포함되어 있다. 스크립트가 다운로드되고 파싱이 완료되면, React가 즉시 실행되어 비어 있는 `<div id=root>`에 DOM을 그리게 된다. 이러한 방식으로 클라이언트에서 자바스크립트를 실행하여 DOM을 렌더링 하는 것을 CSR이라고 한다.
초기 React와 같이 SPA와 순수한 CSR로 애플리케이션을 구축하면 렌더링은 다음과 같은 과정으로 이뤄진다
의미와 한계
과거에는 페이지를 이동할 때마다 사용자가 빈 흰 화면을 보게 되는 문제가 있었다. 초기 React의 SPA + CSR 방식은 페이지 이동 시 데스크톱 앱처럼 자연스럽게 전환되어 사용자 경험 측면에서 분명 혁신적이었다.
하지만 CSR의 한계는 분명하다. 위 다이어그램에서도 드러난 CSR의 가장 치명적인 문제는 매우 큰 자바스크립트 번들을 다운로드하고 파싱하고 실행이 완료되어야 DOM이 형성되기 때문에, 사용자가 첫 번째 UI를 보게 되는 시간인 FP(First Paint) 시간이 매우 길다는 것이다. 이 기간 동안 사용자는 텅 빈 화면을 봐야 하기 때문에 사용자 경험 측면에서 매우 치명적이다.
이와 더불어 애플리케이션의 정보가 동적으로 형성되기 때문에 SEO 최적화가 어렵다는 것이 CSR의 큰 단점으로 뽑히기도 했다. 또한, 자바스크립트 파일 실행이 전부 끝나야만 데이터 패칭을 할 수 있어 사용자가 콘텐츠가 채워진 페이지를 보는 시간인 LCP(Largest Contentful Paint)가 매우 느린 편이다.
순수한 CSR의 한계를 정리해 보면 다음과 같다.
- 매우 큰 번들 크기
- FP가 굉장히 느림
- SEO 최적화가 어려움
- 비효율적인 데이터 패칭으로 LCP가 느림
필자가 여기서 단순히 CSR이 아닌 “순수한 CSR”이라고 부르는 이유는 CSR이 말 그대로 클라이언트 측에서 DOM을 렌더링 하는 것을 의미하기 때문이다. 사실상 React로 DOM을 업데이트 한다면 CSR인 것이다. 여기서 순수한 CSR이라는 것은 첫 렌더링 시 다른 렌더링 패턴과 결합하지 않고 오직 CSR로만 렌더링하는 것을 의미한다.
2. SSR
초기 서버 사이드 렌더링(Server-Side Rendering, 이하 SSR)은 클라이언트 사이드 렌더링(CSR)의 초기 로딩 시간문제를 해결하기 위해 등장했다. SSR은 서버에서 애플리케이션의 네비게이션이나 레이아웃 같은 기본 구조(Shell)를 렌더링하여 완성된 HTML을 클라이언트에 전송해, 사용자가 초기 로딩 시 텅 빈 화면을 보지 않도록 한다
SSR에서는 하이드레이션(Hydration)이라는 흥미로운 단계가 추가된다. 하이드레이션은 서버에서 생성된 HTML이 사용자의 상호작용을 처리할 수 있도록 만드는 과정으로, 마치 건조한 HTML에 수분을 공급하는 것과 같다. 이를 위해 SSR은 여전히 자바스크립트 번들을 다운로드해야 한다. 다운로드가 완료되면, 리액트는 가상의 UI 스케치를 구성하고 이를 실제 DOM에 맞추면서 이벤트 핸들러를 붙이고 이펙트를 실행한다.
의미와 한계
초기 SSR은 CSR의 초기 로딩 시간 문제를 해결했다. 분명 텅 빈 화면을 보는 것보다는 애플리케이션의 무언가라도 보는 게 더 낫다. 그리고 이 과정에서 자연스럽게 CSR의 문제인 SEO도 해결되었다.
하지만 사용자는 껍데기가 아닌 핵심 콘텐츠를 보기 위해 애플리케이션에 방문한다. SSR에서는 비효율적인 데이터 패칭이 더욱 두드러지는데, 이는 데이터 패칭을 위해 클라이언트와 서버가 두 번 통신하기 때문이다. 두 번째 요청 없이 첫 번째 요청에서 데이터 패칭을 수행할 수는 없을까? 아무튼 비효율적인 데이터 패칭 방식은 여전하므로 SSR에서도 LCP는 여전히 느리다. 따라서 궁극적인 문제는 해결되지 않았다고 볼 수 있다.
초기 SSR의 한계
- 여전히 번들 크기가 큼
- 비효율적인 데이터 패칭으로 LCP가 느림
SSG와 ISR
SSR이 런타임에 동적으로 HTML을 렌더링 하는 것과 달리, 정적 사이트 생성(Static Site Generation, 이하 SSG)은 빌드 타임에 HTML을 렌더링하는 전략이다. 사용자 맞춤 콘텐츠가 아니거나 데이터 변경이 자주 일어나지 않는다면, 매번 HTML을 렌더링 할 필요 없이 한 번만 렌더링 하고 저장해 두었다가 요청 시에 제공하는 것이 더 효율적일 것이다. 이러한 개념을 구현한 것이 바로 SSG이다
증분 정적 재생성(Incremental Static Regeneration, 이하 ISR)은 SSG와 SSR의 장점을 결합한 것으로, 특정 주기나 트리거에 따라 런타임에서도 정적 사이트를 재생성하는 방법이다. 따라서 데이터 변경이 있을 때마다 다시 빌드할 필요가 없다.
SSG와 ISR은 마치 캐시 전략과 비슷하게 동작한다. 비록 현대에 인터렉티브 한 애플리케이션이 많이 등장했지만, 여전히 대부분의 사이트는 상호작용이 크지 않은 정적인 사이트들이다. 이런 상황에서 SSG와 ISR은 적합한 렌더링 패턴이라고 할 수 있다.
반면, 현대 애플리케이션이라 할지라도 한 페이지에 특정 부분은 정적인 컴포넌트일 수 있다. 이런 부분에 SSG나 ISR 렌더링 패턴을 사용하면 사용자 경험을 더 끌어올릴 수 있지 않을까? 이러한 생각을 구현한 것이 추후 설명할 PPR(Partial Prerendering)라 볼 수 있다.
3. React Server Components with SSR
다음 렌더링 패턴은 리액트 서버 컴포넌트(React Server Components, 이하 RSC)와 SSR을 결합한 렌더링 패턴이다. RSC는 React 18에서 새로 등장한 개념으로, 오직 서버에서만 실행되는 컴포넌트이다.
서버에서만 실행된다는 것은 두 가지 의미를 가진다. 첫째, 서버 측에서 데이터베이스나 API에 쿼리 하여 콘텐츠를 가져온 후 완전한 컴포넌트를 렌더링 한다는 것이다. 둘째, 클라이언트에서 다시 렌더링 되지 않는다는 것으로, 기존의 React API(State, Effect)와 호환되지 않을 뿐 아니라 RSC는 JS 번들에 포함되지 않고 하이드레이션도 하지도 않는다.
이전 렌더링 패턴들과 가장 큰 차이점은 데이터 패칭을 수행하고 쉘이 아닌 완전한 애플리케이션을 구축하는 과정이 서버-클라이언트 간 첫 번째 요청에서 이루어진다는 것이다. 따라서 LCP가 상당히 빨라졌다. 물론 여전히 상호작용성을 추가하기 위해 번들을 다운로드하고 하이드레이션 하는 과정이 필요하다.
또 헷갈리면 안 되는 점은 RSC가 도입되었어도 여전히 클라이언트 컴포넌트가 존재한다는 것이다. 또 헷갈리면 안 되는 점은 클라이언트 컴포넌트는 클라이언트에서 렌더링 되는 것이 아니라 서버에서 렌더링 된다는 점이다
의미와 한계
잠깐, 위 다이어그램을 보면 의아한 점이 있다. LCP는 개선되었지만, FP와 TTI는 오히려 SSR보다 느려진 것이 아닌가? 사용자가 콘텐츠가 채워진 화면을 빠르게 볼 수 있다는 것은 물론 큰 장점이지만, FP와 TTI를 포기해야 한다면 정말 RSC를 사용하는 것이 좋은 선택일까?
사실, 위 렌더링 프로세스는 과거 React와 같은 SPA 라이브러리가 등장하기 전, Java 진영의 JSP나 JS 진영의 Pug 같은 템플릿 엔진을 사용해 웹을 구축했던 시절과 유사하다. RSC라는 거창한 용어를 사용하고 있지만, 그 시절과 큰 차이가 없어 보인다. 정말로 RSC는 React가 없던 시절로 회귀하는 것일까?
정답은 ‘땡’이다. 과거 서버 사이드 템플릿 엔진을 통해 웹을 만들던 시절과 RSC의 가장 큰 차이점은 렌더링 단위가 페이지가 아닌 컴포넌트라는 점이다. 템플릿 엔진은 페이지 전체를 서버에서 렌더링해야 했지만, RSC는 특정 컴포넌트만 서버에서 완전히 렌더링 할 수 있다. RSC 패러다임의 진정한 의미는 다음 렌더링 패턴인 스트리밍 SSR 아키텍처를 통해 더욱 명확해진다.
4. Streaming SSR
React Server Components with SSR 전략의 단점은 모든 렌더링 과정이 워터폴(waterfall) 방식으로 진행된다는 점이다. 즉, 렌더링, 번들 다운로드, 하이드레이션이 "all or nothing" 방식으로 동작하여, 모든 작업이 완료되어야 다음 단계로 넘어갈 수 있다.
하지만 현대 애플리케이션은 컴포넌트 단위로 나뉘어 있으며, 데이터 패칭 시간이 짧고 사용자에게 빨리 보여줘야 하는 컴포넌트도 존재한다. 하지만 기존의 렌더링 방식에서는 모든 컴포넌트의 렌더링이 끝나야 다음 단계로 넘어갈 수 있다. 즉, 데이터 요청이 오래 걸리는 컴포넌트가 있다면 해당 컴포넌트가 작업을 마칠 때까지 사용자가 기다려야 한다는 것이다. 사용자가 모든 페이지 UI가 보이기 전에 특정 컴포넌트를 보고 상호작용할 수 있게 할 방법은 없을까?
이를 가능하게 하기 위해서는 스트리밍 HTML과 선택적 하이드레이션 두 가지 기능이 필요하다. 스트리밍 HTML은 특정 컴포넌트의 데이터 패칭을 기다리지 않고 먼저 HTML을 전송한 후, 데이터 패칭이 완료되면 해당 컴포넌트를 스트리밍 하여 사용자에게 보내주는 기능이다. 선택적 하이드레이션은 모든 HTML과 JS 코드가 다운로드되기 전에 가능한 한 빨리 하이드레이션을 시작하는 방식으로, 사용자가 상호작용을 우선시하는 컴포넌트부터 하이드레이션을 진행할 수 있다.
React 18은 Suspense와 통합하여 스트리밍 HTML과 선택적 하이드레이션 기능을 사용할 수 있다. 이러한 기능은 React에 기존에 없던 "동시성"을 부여한다고 볼 수 있다. 이러한 기능과 RSC를 통합하면 렌더링 과정에서 “병렬성”을 활용해 렌더링 퍼포먼스를 비약적으로 향상할 수 있다.
기존의 SSR과 마찬가지로 애플리케이션의 기본 구조(Shell)를 서버에서 렌더링 한 후 클라이언트로 전송하면, 클라이언트는 하이드레이션을 수행한다. 그러나 기존 SSR과 달리, 클라이언트가 하이드레이션을 수행하는 동안 서버는 병렬적으로 데이터 패칭을 진행하고, 컴포넌트에 콘텐츠를 채워 완성된 상태로 클라이언트에 전송한다. 클라이언트는 이를 받아서 전달받은 컴포넌트에 하이드레이션을 진행한다.
컴포넌트 관점에서 보면 이 차이가 명확해진다. 위 그림에서 볼 수 있듯이, 각 컴포넌트는 독립적으로 렌더링 과정을 거친다. 즉, Component A는 Component B를 기다리지 않고 독립적으로 렌더링을 진행할 수 있다.
의미와 한계
Streaming SSR은 컴포넌트별 독립적인 렌더링을 가능하게 하는 최초의 아키텍처다. 이는 리액트에 기존에 불가능했던 동시성과 병렬성 개념을 도입해 사용자 경험과 렌더링 퍼포먼스를 비약적으로 끌어올렸다. 사용자는 모든 페이지가 준비되지 않아도 원하는 컴포넌트의 콘텐츠를 보고 상호작용할 수 있다. 하이드레이션과 데이터 패칭이 병렬적으로 진행되기 때문에 FP, TTI, LCP 모두 앞선 렌더링 패턴들에 비해 우수한 퍼포먼스를 낼 수 있다.
Streaming SSR을 구현하기 위해서는 뒤에서 매우 복잡한 작업들이 필요하기 때문에, 현재는 React와 특정 프레임워크를 함께 사용해야 구현이 가능하다. React가 출시된 지 10년이 된 지금, Streaming SSR은 React를 기반으로 한 프론트엔드 생태계의 발전을 상징한다고 볼 수 있다.
Streaming SSR은 매우 강력한 아키텍처지만, 몇 가지 아쉬운 점이 있다. 첫째로, 개발자 경험(DX)이다. RSC가 존재하더라도 React의 근본은 여전히 클라이언트 컴포넌트이다. 따라서 클라이언트에서의 데이터 패칭이 여전히 필요할 수 있다. 하지만 현재 시점에서는 서버 측 데이터 패칭과 클라이언트 측 데이터 패칭을 하이브리드 방식으로 사용하는 표준이 없다. 심지어 Next.js App Router 문서에는 클라이언트 데이터 패칭에 대한 언급조차 없다.
기존처럼 SWR이나 React-Query와 같은 데이터 패칭 라이브러리를 사용할 수 있지 않냐고 생각할 수 있지만, 필자의 경험 상 RSC와 이러한 라이브러리를 함께 사용하면 애플리케이션의 복잡성에 비해 개발 복잡성이 크게 증가하는 것을 체감할 수 있었다. 또한, React는 여전히 데이터 패칭 라이브러리와 Suspense를 결합하여 사용하는 것을 권장하지 않고 있다. React 팀은 use 훅을 대안으로 제시하고 있는 듯하지만, 이는 아직 Canary 버전에만 존재하기 때문에 빠른 안정화가 필요하다.
두 번째로, 스트리밍 SSR은 컴포넌트별 독립적인 렌더링을 가능하게 하는 아키텍처다. 그러나 여전히 페이지에 포함된 모든 컴포넌트는 동적으로 렌더링 되어야 한다. 애플리케이션의 특정 컴포넌트 중에는 정적으로 렌더링 되어도 괜찮은 컴포넌트가 분명 있을 것이다. 그러나 현재 스트리밍 SSR에서는 이것이 불가능하다.
5. PPR
부분 사전 렌더링(Partial Prerendering, 이하 PPR)은 앞서 설명한 렌더링 패턴들과는 달리 아직 정식으로 구현되지는 않았으며, Next.js 14에서 실험적으로 적용해 볼 수 있다. 앞서 Streaming SSR에서 컴포넌트별로 독립적인 렌더링 패턴을 사용할 수 있는 가능성을 확인했다. PPR은 한 발짝 더 나아가 컴포넌트별로 런타임에 동적으로 렌더링 할지, 빌드 타임에 정적으로 생성할지를 결정하는 렌더링 패턴이다.
위 다이어그램에서 볼 수 있듯이 애플리케이션의 기본 구조(Shell)는 가장 쉽게 정적으로 생성할 수 있는 영역이다. 하지만 다이어그램에는 나타나지 않았지만, 애플리케이션의 콘텐츠 또한 정적으로 생성할 수 있는 부분이 분명히 존재한다. 예를 들어, 제품 세부 정보가 그럴 수 있습니다. SSG의 단점은 ISR을 적용하여 보완한다. 이처럼 PPR은 한 페이지 내에서 동적 렌더링과 정적 생성을 조합하여 렌더링 퍼포먼스를 Streaming SSR보다 한 단계 더 끌어올릴 수 있다
의미와 한계
Next.js 측은 PPR이 기존의 모든 렌더링 패턴의 장점만을 결합하여 모든 면에서 우수하기 때문에, 앞으로 웹 애플리케이션의 기본 렌더링 패턴이 될 것이라고 주장하고 있다. 즉, 트레이드오프가 없는 완벽한 최적화 전략이라는 것이다.
PPR의 가장 아름다운 점은 구현을 위해 추가 코드가 필요하지 않다는 점이다. 만약 Suspense를 사용해 Streaming SSR을 구현했다면, PPR의 기능을 활성화하는 것만으로도 Next.js는 Fetch의 캐시 정보를 활용해 PPR을 구현할 수 있다.
PPR의 한계는 아직 알 수 없다. PPR이 프로덕션 레벨로 올라가고 많은 경험이 쌓이면 모르겠지만, 아직 실험 단계에 있기 때문이다. PPR을 통해 렌더링 패턴은 이제 완전히 페이지 단위에서 컴포넌트 단위로 변경되었다고 볼 수 있다. 이는 컴포넌트별로 최적화된 렌더링 패턴을 취해 사용자에게 최적의 경험을 제공할 수 있다는 의미이지만, 그만큼 컴포넌트별로 신경을 써야 한다는 말이기도 하다. 따라서 컴포넌트를 의미 있게 분리하고, 선언적으로 작성하며, 의존성을 관리하는 중요성이 커졌다. 프론트엔드 개발자에게 요구되는 사항이 많아져 진입 장벽이 높아졌다는 생각이 든다.
마무리하며
이번 포스팅에서는 CSR부터 PPR까지 7가지 렌더링 패턴을 살펴보았다. 이 외에도 여러 렌더링 패턴이 존재하지만, 일부는 과도기에 등장한 변형 패턴으로, 대부분 이 7가지 렌더링 범주 안에 포함되기 때문에 생략했다. 최근 렌더링 패턴의 변화는 기존의 페이지 단위에서 컴포넌트 단위로의 전환을 일관되게 보여주고 있다. 이러한 변화는 프론트엔드 개발을 더 편리하게 만드는 동시에, 더 복잡하게 만드는 아이러니를 가지고 온다고 생각한다.
프론트엔드 개발은 훨씬 더 쉬워졌다. 과거에는 PPR 같은 렌더링 패턴을 구현하려면 거대 엔터프라이즈급 기업만이 가능했을 것이다. 그러나 이제는 프레임워크만 사용하면 누구나 이러한 기능을 구현할 수 있게 되었다. 또한 RSC와 Next.js App 라우터를 통해 프론트엔드 프레임워크만으로도 충분히 풀스택 개발이 가능할 정도로 아키텍처가 발전했다.
프론트엔드 개발이 더욱 어려워졌다. RSC와 Suspense를 조합해 Streaming SSR을 구현하려면, 컴포넌트를 의미 있게 분리하고 선언적으로 작성하며, 의존성을 잘 관리해야 한다. RSC 컴포넌트를 잘 활용하려면 단순히 프론트엔드 지식뿐만 아니라 풀스택적인 지식도 필요하다. 또한, 컴포넌트별로 최적화된 렌더링 패턴을 적용하려면 렌더링 프로세스와 도메인에 대한 깊은 이해가 필요하다.
개인적으로 나는 이 변화가 매우 즐겁다. 개발자가 주도적으로 할 수 있는 일이 많아졌기 때문이다. 또한, 복잡한 렌더링 패턴을 구성하는 데 시간을 낭비하지 않고 애플리케이션의 가장 중요한 부분인 비즈니스 로직에 집중할 수 있게 된 점도 매우 만족스럽다. 무엇보다 중요한 것은, 지금이 변화의 시작에 불과하다는 것이다. 앞으로 프론트엔드 진영이 어떻게 변할지 기대되고 설레는 마음으로 받아들이고자 한다.
'개발 이야기' 카테고리의 다른 글
Polymorphic Component를 만들어보자(ft.타입스크립트) (38) | 2024.08.04 |
---|---|
복잡한 애플리케이션을 위한 프론트엔드 아키텍처 (33) | 2024.07.20 |
프론트엔드, 서버로부터 독립을 선포하다(2) - 뷰 모델로 데이터 모델 의존성 줄이기 (35) | 2024.06.30 |
프론트엔드, 서버로부터 독립을 선포하다(1) - MSW로 개발 및 테스트 의존성 줄이기 (34) | 2024.06.26 |
지속 가능하고 효율적인 코드 리뷰를 하는 방법 (33) | 2024.06.17 |