서버 사이드 렌더링
리액트 API, CSR & SSR
자바스크립트가 없는 환경이 중요한 이유
웹은 본질적으로 HTML,CSS,JS로 구성되어 있어요. 하지만 모든 사용자가 자바스크립트를 활성화한 환경에서 웹을 경험하는 것은 아니에요. 자바스크립트가 비활성화되거나 제한적인 환경에서는 사이트가 제대로 동작하지 않을 수 있어요. 따라서 기본적인 컨텐츠가 자바스크립트 없이도 노출되어야 하며, 이를 위해 서버 사이드 렌더링과 사전 렌더링이 중요해진 거에요
서버 사전 렌더링
서버 사전 렌더링은 사용자의 요청 전에 서버가 HTML을 미리 만들어 두는 과정이에요 서버가 클라이언트에 완성된 HTML을 보내면, 브라우저는 빠르게 콘텐츠를 보여줄 수 있어요. 이 방식은 SEO에 유리하고 네트워크 환경이 느릴 경우에도 빠른 초기 로딩을 보장받을 수 있어요.
사전 렌더링 서버 비용
하지만 서버에서 매번 페이지를 렌더링하는 것은 서버 자원과 비용이 많이 들어요 특히 트래픽이 많거나 복잡한 페이지일수록 렌더링 비용이 커지는 거에요. 이 때문에, 사전 렌더링과 SSG 같은 전략을 적절히 조합해서 서버 비용을 최소화 하는 것이 중요해요.
그리고 SSR을 구현할 때는 서버와 클라이언트 간 데이터 일관성, 렌더링 성능, 하이드레이션 문제를 고려해야 해요. 예를 들면 SSR 환경에서는 브라우저 전용 API가 존재하지 않으므로, 이를 직접 호출하면 당연히 렌더링 오류가 발생해요.
따라서 이러한 API 사용은 클라이언트 전용 코드로 분리하거나, useEffect
같은 클라이언트 전용 훅 내부에서 호출해야해요. 여기서 알아둬야 할 것은 useEffect
는 클라이언트에서만 실행되는 React 훅으로 SSR 시에는 실행되지 않는다는 점이에요
또, 브라우저 환경에서만 가능한 UI 요소를 SSR에서 렌더링하려 하면 오류가 발생해요. 따라서 SSR 시점에는 조건부 렌더링이나 client 전용 컴포넌트를 사용해야 해요
SSR과 하이드레이션
SSR로 서버에서 렌더링된 HTML은 클라이언트에서 React가 활성화되며 하이드레이션 과정을 거쳐요 하이드레이션은 서버에서 렌더링된 HTML에 React의 이벤트 바인딩과 상태를 연결하는 과정이에요. 이 과정에서 자바스크립트 에러가 발생하면 UI가 비정상적으로 동작할 수 있어요.
좀 더 자세하게 설명하면 하이드레이션이란 서버에서 미리 렌더링한 정적인 HTML 페이지에 자바스크립트 로직과 이벤트 핸들러를 연결해서 인터랙티브한 React 앱으로 복원하는 과정을 말해요
서버에서 renderToString() 등을 이용해 HTML 을 생성
HTML이 클라이언트(브라우저)에 전달됨 -> 사용자 눈에는 완전한 페이지로 보여짐.
클라이언트에서 hydrate() 함수로 React 앱 초기화 -> 서버에서 보낸 파일을 받아, 이벤트 핸들러를 붙이는 작업
hydrate() VS render() 는 이런 차이가 있어요.
render() 브라우저에서 DOM을 새로 그립니다. (ReactDOM.render(, rootEl)) = hydrate() 서버에서 만들어진 HTML을 그대로 사용하면서 React 앱으로 복원합니다. (ReactDOM.hydrate(, rootEl))
hydrate()를 안 쓰고 render()를 사용하면 서버에서 받은 HTML을 덮어씌우므로 성능 낭비가 발생해요
React 서버 사이드 렌더링 주요 API
renderToString
React 컴포넌트를 받아서 서버에서 HTML 문자열로 변환해주는 함수에요 서버 사이드 렌더링의 가장 기본이 되는 API로, 서버가 클라이언트에 초기 HTML을 제공할 때 사용해요. 즉, 서버가 클라이언트에 초기 HTML을 제공할 때 사용하는 API 라고 생각하면 돼요. 완전한 HTML 문자열을 결과물로 반환하고, React의 (data-reactroot) 등이 포함되어 있어 클라이언트에서 React가 이를 인식하고 동작할 수 있도록 도와줘요
여기서 말하는 React의 data-reactroot 이란 리액트가 클라이언트에서 하이드레이션을 할 때, 즉 서버에서 렌더링된 HTML을 리액트 앱으로 인식하고 연결할 수 있도록 도와주는 마커에요
리액트는 클라이언트에서 DOM을 직접 생성하기도 하지만, 서버에서 미리 렌더링한 HTML을 받아오는 SSR 에서도 작동할 수 있어야해요 이때 리액트에게 필요한 정보는 어디서부터 내가 렌더링 한 HTML인지 알아야하고, 그 기준점을 제공해주는 것이 data-reactroot 에요
<div id="root" data-reactroot="">
<h1>Hello, world!</h1>
</div>
이 HTML은 서버에서 renderToString() 으로 만든 문서에요. 클라이언트에서 React가 hydrate() 를 호출할 때 이 data-reactroot 속성을 보고 하이드레이션을 수행할 수 있어요
renderToNodeStream
renderToString
과 거의 동일한 HTML 결과를 내지만, 결과물을 문자열이 아닌 Node.js 스트림 형태로 반환해요.
즉, HTML을 점진적으로 스트리밍할 수 있어, 초기 서버 응답 속도를 높이고 네트워크 사용을 최적화 할 수 있어요. 하지만 Node.js 환경에서만 사용이 가능하며 브라우저에서는 이 기능을 사용할 수 없어요
renderToStaticNodeStream
renderToNodeStream
의 정적 버전으로, React 전용 속성 없이 순수한 HTML 스트림을 반환해요. 즉, 하이드레이션이 필요없는 순수 HTML을 스트리밍해서 클라이언트에 전달할 때 사용해요renderToStaticMarkup
과 같은 맥락이며, 서버에서 대용량 정적 HTML을 스트리밍 할 때 유용하게 사용할 수 있어요
SSR의 이점
CSR과 달리 모든 HTML이 서버에서 미리 생성되기 때문에 SEO 측면에서 웹 크롤러가 이를 크롤링하는데 유리해요.
FCP와 LCP가 '꽤' 빨라요 따라서 사용자에게 CSR 앱과 달리 빈 화면을 보여주지 않을 수 있어요
반면에 SSR의 문제점도 존재해요.
페이지에 대한 데이터 요구사항이 있는 경우, 모든 요청을 서버에서 기다렸다가 렌더링해야 하므로 느린 TTFB가 발생할 수 있어요. 사용자가 빈 화면을 보지 않는다는 장점이 있지만, 로딩 스피너를 계속 봐야하는 상황이 발생할 수도 있어요. 이러한 문제점은 최적화되지 않은 서버 코드 또는 많은 동시 서버 요청 등 여러 이유로 발생할 수 있어요.
마지막으로 초기 로드가 빠르다는 장점이 있지만, 사용자는 여전히 페이지가 모든 자바스크립트를 다운로드하고 페이지가 리하이드레이션된 뒤 상호작용이 가능할 때까지 기다려야 해요
새로운 렌더링 패턴
리액트 팀은 CSR과 SSR의 문제점을 해결하기 위해 새로운 렌더링 패턴을 꾸준히 연구하고있어요.
스트리밍 SSR
브라우저의 좋은 점 중 하나는 HTTP 스트림을 통해서 HTML을 수신할 수 있다는 점이에요 스트리밍을 사용하면 웹 서버가 무기한으로 열려있을 수 있는 단일 HTTP 연결을 통해 클라이언트에 데이터를 보낼 수 있어요. 즉, 네트워크를 통해 여러 청크로 데이터를 로드할 수 있으며, 이는 렌더링과 동시에 순서없이 로드돼요
스트리밍의 동작 방식은 다음과 같아요
Streaming SSR은 HTML을 조각(chunk) 단위로 서버에서 점진적으로 클라이언트에 보내는 방식이에요 서버는 전체 데이터를 기다리지 않고, 준비된 부분부터 HTML로 스트리밍해 브라우저에 전달해요
데이터 처리 방식
모든 데이터를 전부 받아온 뒤 HTML 생성
준비된 데이터를 조각 단위로 바로 스트리밍
TTFB (Time to First Byte)
느림 (데이터 fetch까지 기다림)
빠름 (HTML shell 먼저 전송 가능)
렌더링 방식
전체 페이지 렌더링 후 전송
부분 렌더링 → 전송 → 점진적 하이드레이션
스트리밍 SSR의 동작 흐름
서버는 페이지의 shell(기본 레이아웃)을 우선적으로 렌더링해요.
데이터를 기다릴 필요 없이, 준비된 컴포넌트 조각(chunk 단위) 형태로 클라이언트에 전송해요.
이후 데이터가 준비되면 해당하는 HTML 조각을 클라이언트에 스트리밍을 전송해요.
브라우저는 이 조각들을 순서에 상관ㅇ벗이 점진적으로 렌더링해요
Suspense 가 이 안에서 하는 역할
<Suspense fallback={<LoadingSkeleton />}>
<ProductList />
</Suspense>
서버는 ProductList 데이터를 아직 못 받았으면, LoadingSkeleton 을 먼저 전송해요.
이후 ProductList 가 준비되면 해당 컴포넌트의 HTML 조각을 클라이언트에 추가 스트리밍해요
다시 Streaming 렌더링얘기로 넘어가자면,
스트리밍 렌더링은 리액트 18에서 새롭게 등장한 것이 아니라 사실 리액트 16부터 존재했으나, 리액트 16에서는 renderToString() 과는 다른 renderToNodeStream
이라는 API가 존재했고, 이 API는 HTTP 스트림을 통해 렌더링을 수행했어요.
반면에 리액트 18에서의 스트리밍 SSR은 renderToNodeStream API를 deprecate 시키고, renderToPipeableStream
이란 새로운 API를 소개했어요. 이 API는 Suspense 로 몇 가지 새로운 기능을 가능하게 했는데, Suspense에서 앞서 봤던 SSR의 단계를 독립적으로 수행 가능한 더 작은 독립 단위로 나눌 수 있게 되었어요
이게 가능한 것은 Suspense에 추가된 두 가지 주요 기능 때문인데,
서버에서의 스트리밍 HTML
클라이언트에서의 선택적 하이드레이션
앞서 말했듯이 리액트 18이전의 SSR 은 전부 아니면 전부(all or nothing) 방식으로 접근하였어요. 페이지에 필요한 모든 데이터를 페칭한 뒤 HTML을 생성하고 클라이언트에 보내야 하는거죠.
하지만 HTTP 스트리밍 방식은 그렇지 않아요
리액트 18에서는 로드 시간이 오래 걸리거나 화면에 즉시 나타내 주지 않아도 되는 컴포넌트를 Suspense 로 래핑할 수 있는데,
예를 들어 페이지에 Comments
컴포넌트가 있다고 가정해볼게요.
이 Comments
는 데이터를 불러오는데 시간이 오래걸리는 API와 연결되어 있는거죠
<Suspense fallback={<LoadingSpinner />}>
<Comments />
</Suspense>
이렇게 하면 초기 HTML에는 Comments가 없어요 대신, fallback으로 지정한 **스피너(로딩 컴포넌트)**가 먼저 브라우저에 렌더링돼요
그리고 서버에서는 백그라운드에서 Comments 데이터를 계속 불러오고,
준비가 되면, React는 HTML 조각을 인라인 스크립트와 함께 해당 위치에 동적으로 삽입하는거에요
🎯 그 결과?
서버가 모든 데이터를 다 기다릴 필요가 없습니다.
준비된 부분은 먼저 보내고, 느린 부분은 나중에 스트리밍해서 삽입합니다.
따라서 사용자는 페이지의 나머지 부분을 먼저 보고 상호작용할 수 있어, UX가 훨씬 좋아집니다.
선택적 하이드레이션
HTML이 스트리밍 되더라도 페이지에 대한 전체 자바스크립트가 다운로드 되지 않는 한 사용자와의 상호작용은 이루어지지 않아요 이런 부분을 방지하기 위해 선택적 하이드레이션이 필요해요
클라이언트 사이드 렌더링 중 페이지의 큰 번들을 피하는 방법 중 한 가지는 React.lazy
를 통한 코드 스플리팅이었어요 앱의 특정 부분이 동기식으로 로드될 필요가 없을 때 번들러가 이를 별도의 스크립트 태그로 분할해줘요
React.lazy
의 한계는 서버 사이드 렌더링에서 작동하지 않는 점이었지만, 리액트 18에서는 <Suspense>
를 사용해 HTML을 스트리밍 하는 것 뿐만 아니라 나머지 앱에 대한 하이드레이션을 차단하지 않게 할 수 있어요
이제 React.lazy는 서버에서 바로 사용할 수 있어요.
를 지연 로드되는 컴포넌트에 래핑 하면 이 컴포넌트가 스트리밍을 원한다는 것을 알려줄 뿐만 아니라 로 래핑 된 컴포넌트가 스트리밍 되는 경우에도 나머지의 하이드레이션을 허용하는 거죠.
이런 점은 기존 서버 사이드 렌더링에서 봤던 두 번째 문제를 해결해요. 더 이상 하이드레이션을 시작하기 전에 모든 자바스크립트가 다운로드될 때까지 기다릴 필요가 없게 되었어요
Last updated