4.1 서버 사이드 렌더링이란?
4.1.1 싱글 페이지 애플리케이션의 세상
싱글 페이지 애플리케이션(Single Page Application, 이하 SPA)는 렌더링과 라우팅에 필요한 대부분의 기능을 서버가 아닌 브라우저의 JS에 의존하는 방식입니다. 최초 페이지에서 데이터를 모두 불러온 이후, 페이지 전환을 위한 모든 작업이 JS와 브라우저의 history.pushState와 history.replaceState로 이루어지게 됩니다. 이후에는 서버에서 HTML을 내려 받지 않고 하나의 페이지에서 모든 작업을 처리하기 때문에 SPA라고 부릅니다.
최초 로딩 이후에는 서버를 거쳐 리소스를 받아올 일이 적어 페이지 이동할 때마다 새로운 HTML 페이지를 다운로드 받던 기존의 방식보다 페이지 전환이 부드러워 더 나은 사용자 경험을 제공할 수 있게 합니다.
하지만, 이러한 패러다임의 변화로 너무 많은 양의 리소스가 JS를 통해서 처리되며, 사용자의 기기와 인터넷 속도 등의 환경이 월등히 개선되었음에도 불구하고 그만큼 웹 애플리케이션의 로딩 속도는 5년 전이나 지금이나 크게 차이가 없음이 드러났습니다.
인터넷 속도의 증가에 비해 웹 페이지의 로딩 속도가 오히려 예전보다 증가하는 현상이 나타나게 되었고, 이러한 추세에 SPA 위주의 패러다임에 의문을 갖는 웹 개발자들이 생겨났습니다.
4.1.2 서버 사이드 렌더링이란?
서버 사이드 렌더링(이하 SSR)은 하나의 페이지에 JS를 통해 동적으로 렌더링을 수행하는 SPA와 달리, 최초에 사용자에게 보여줄 페이지를 서버에서 렌더링해 빠르게 사용자에게 화면을 제공하는 방식입니다.
웹 페이지가 점점 느려지는 상황에 대한 원인을 SPA의 태생적인 한계에서 찾아 개선하고자 기존의 방식을 도입하고자 하는 움직임이 나타나게 되었습니다.
클라이언트 사이드 렌더링(이하 CSR)에 비해, SSR은 서버에서 렌더링의 모든 과정을 수행하기 때문에 사용자 기기의 성능에 영향을 비교적 덜 받게 됩니다.
서버 사이드 렌더링의 장점
SSR은 CSR 대비 몇 가지 장점을 갖습니다.
- 최초 페이지 진입 속도가 비교적 빠름: 최초 유의미한 정보를 그리는 시간(FCP)이 더 빠를 수 있습니다.
- 검색 엔진, SNS 공유 등 메타데이터 제공에 유리: 검색 엔진 로봇은 단순히 HTML에 담긴 오픈 그래프(Open Graph) 메타(meta) 태그 정보를 기반으로 페이지의 검색 정보를 가져오고 이를 바탕으로 검색 엔진에 저장하기 때문에, 이러한 정보가 부족한 CSR에서는 SEO가 어렵습니다.
- 누적 레이아웃 이동이 적음: SPA에서는 뒤늦게 추가되는 정보에 의해 레이아웃이 밀리는 현상이 발생하기도 합니다.
- 사용자의 기기 성능에서 "비교적" 자유로움
- 보안에 좀 더 안전: JAM 스택 프로젝트는 애플리케이션의 모든 활동이 브라우저에 노출되기에, 정상적인 비즈니스 로직을 거치지 않은 상황에서 인증이나 API가 호출되는 것에 대한 처리가 필요합니다. 인증이나 민감한 작업을 서버에서 수행하고 결과만 브라우저에 제공해 보안 위협을 피할 수 있습니다.
서버 사이드 렌더링의 단점
- 코드 작성 시 항상 서버를 고려해야 함: 브라우저 환경의
window,localStorage와 같은 객체에 접근할 수 없습니다. 외부에서 의존하고 있는 라이브러리도 서버에 대한 고려가 되어 있지 않은 경우, 대안을 찾거나 클라이언트에서 실행될 수 있도록 처리해야 합니다. - 적절한 서버 구축이 필요
- 서비스 지연에 따른 문제: 최초 렌더링에 지연이 발생하면, 스피너와 같이 작업이 진행 중이라는 정보를 제공할 수 있는 SPA와 달리 렌더링 작업이 끝날 때까지 어떤 정보도 제공해 줄 수 없어 좋지 않은 사용자 경험을 제공할 수 있게 됩니다.
4.1.3 SPA과 SSR, 둘 중 어떤 걸 선택해야 할까?
웹 페이지의 설계와 목적, 우선순위에 따라 적절히 두 방법론을 적용하는 것이 현명한 방법입니다.
LAMP(Linux, Apache, MySQL, PHP/Python) 스택처럼 모든 페이지를 각각 빌드하는 SSR 방식의 멀티 페이지 애플리케이션에 대한 저자의 의견:
가장 뛰어난 싱글 페이지 애플리케이션은 가장 뛰어난 멀티 페이지 애플리케이션보다 낫다.
일례로 앞서 예제로 보여준 Gmail과 같이 완성도가 매우 뛰어난 싱글 페이지 애플리케이션이 있다고 가정해 보자. 최초 페이지 진입 시에 보여줘야할 정보만 최적화해 요청해서 렌더링하고, 이미지와 같은 중요성이 떨어지는 리소스는 게으른 로딩으로 렌더링에 방해되지 않도록 처리했으며, 코드 분할(code splitting, 사용자에게 필요한 코드만 나눠서 번들링하는 기법) 또한 칼같이 지켜서 불필요한 자바스크립트 리소스의 다운로드 및 실행을 방지했다. 라우팅이 발생하면 변경이 필요한 HTML 영역만 교체해 사용자의 피로감을 최소화했다. 모든 것이 완벽하다. 멀티 페이지 애플리케이션 또한 마찬가지로 엄청난 최적화를 가미했다 하더라도 싱글 페이지 애플리케이션이 가진 브라우저 API와 자바스크립트를 활용한 라우팅을 기반으로 한 매끄러운 라우팅보다 뛰어난 성능을 보여줄 수는 없을 것이다.
평균적인 싱글 페이지 애플리케이션은 평균적인 멀티 페이지 애플리케이션보다 느리다.
멀티 페이지 애플리케이션은 매번 서버에 렌더링 요청을 하고, 서버는 안정적인 리소스를 기반으로 매 요청마다 비슷한 성능의 렌더링을 수행할 것이다. 그러나 일반적인 싱글 페이지 애플리케이션은 렌더링과 라우팅에 최적화가 돼 있지 않다면 사용자 기기에 따라 성능이 들쑥날쑥하고, 적절한 성능 최적화도 돼 있지 않을 가능성이 높으므로 멀티 페이지 애플리케이션 대비 성능이 아쉬울 가능성이 크다. 그리고 이러한 최적화는 매우 어렵다. 페이지 전환 시에 필요한 리소스와 공통으로 사용하는 리소스로 분류하고 이에 따른 다운로드나 렌더링 우선순위 전략을 잘 수립해 서비스하기란 매우 어렵다. 따라서 평균적인 노력을 기울여서 동일한 서비스를 만든다면 서버에서 렌더링되는 멀티 페이지 애플리케이션이 더 우위에 있을 수 있다. 심지어 최근에는 멀티 페이지 애플리케이션에서 발생하는 라우팅으로 인한 문제를 해결하기 위한 다양한 API가 브라우저에 추가되고 있다. 이러한 기법은 모두 싱글 페이지 애플리케이션에서 구현 가능한 것이지만 완벽하게 구현하려면 자바스크립트뿐만 아니라 CSS 등의 도움을 받아야 하고 상당한 노력을 기울여야 한다. 그러나 평균적인 노력으로, 평균적인 사용자 경험을 제공한다고 가정한다면 별도의 최적화를 거쳐야 하는 싱글 페이지 애플리케이션보다 서버에서 렌더링되는 멀티 페이지 애플리케이션이 더 나은 경험을 제공한다고 볼 수 있다.
back forward cache(bfcache): 브라우저 앞으로 가기, 뒤로가기 실행 시 캐시된 페이지를 보여주는 기법
Shared Element Transitions: 페이지 라우팅이 일어났을 때 두 페이지에 동일 요소가 있다면 해당 콘텍스트를 유지해 부드럽게 전환되게 하는 기법
페인트 홀딩(Paint Holding): 같은 출처(origin)에서 라우팅이 일어날 경우 화면을 잠깐 하얗게 띄우는 대신 이전 페이지의 모습을 잠깐 보여주는 기법
현대의 SSR은 LAMP 스택처럼 모든 페이지 빌드를 서버에서 하지는 않습니다. CSR과 SSR의 장점을 모두 취한 방식으로 작동하는 것이 일반적입니다.
최초 웹사이트 진입에는 SSR로 완성된 HTML 제공받고, 이후 라우팅에서는 서버에서 받은 JS 번들을 기반으로 클라이언트에서 SPA처럼 동작합니다.
Next.js나 Remix 등의 현대적인 SSR 프레임워크는 모두 이러한 방식으로 작동합니다.
4.2 서버 사이드 렌더링을 위한 리액트 API 살펴보기
Node.js 같은 서버 환경에서만 실행할 수 있습니다. react-dom/server에서 SSR을 위한 메서드를 제공하고 있습니다.
4.2.1 renderToString
인수로 넘겨받은 리액트 컴포넌트를 렌더링해 HTML 문자열로 반환하는 함수입니다. SSR을 구현하는 데 가장 기초적인 API로서 최초의 페이지를 HTML로 먼저 렌더링하는 데 사용됩니다.
import ReactDOMServer from "react-dom/server";
function ChildrenComponent({ fruits }: { fruits: Array<string> }) {
useEffect(() => {
console.log(fruits);
}, [fruits]);
function handleClick() {
console.log("hello");
}
return (
<ul>
{fruits.map((fruit) => (
<li key={fruit} onClick={handleClick}>
{fruit}
</li>
))}
</ul>
);
}
function SampleComponent() {
return (
<>
<div>hello</div>
<ChildrenComponent fruits={["apple", "banana", "peach"]} />
</>
);
}
const result = ReactDOMServer.renderToString(
React.createElement("div", { id: "root" }, <SampleComponent />)
);
result에는 다음과 같이 HTML 코드의 문자열이 담기게 됩니다.
<div id="root" data-reactroot="">
<div>hello</div>
<ul>
<li>apple</li>
<li>banana</li>
<li>peach</li>
</ul>
</div>
내부의 훅이나 이벤트 핸들러는 결과물에서 제외됩니다. 이는 의도된 것으로, renderToString이 인수로 주어진 리액트 컴포넌트를 빠르게 브라우저가 렌더링할 HTML을 제공하는 데 목적이 있는 함수이기 때문입니다.
💡 리액트의 SSR은 단순히 '최초 HTML 페이지를 빠르게 그려주는 데' 목적이 있습니다.
data-reactroot는 리액트 컴포넌트의 루트 엘리먼트가 무엇인지 식별하는 역할을 합니다. 이 속성은 JS 실행을 위한 hydrate 함수에서 루트를 식별하는 기준점이 됩니다. 모든 리액트 프로젝트의 루트 엘리먼트에서 확인할 수 있습니다.
4.2.2 renderToStaticMarkup
renderToString과 매우 유사한 함수지만, 차이점은 data-reactroot와 같은 리액트에서만 사용하는 추가적인 DOM 속성을 만들지 않는다는 점입니다. 리액트에서만 사용하는 속성을 제거해 약간이라도 HTML의 크기를 줄일 수 있습니다.
아래는 함수에 전과 동일한 인수를 전달했을 때의 결과입니다.
<div id="root">
<div>hello</div>
<ul>
<li>apple</li>
<li>banana</li>
<li>peach</li>
</ul>
</div>
또한, hydrate를 수행하면 에러가 발생합니다. 이는 renderToStaticMarkup이 순수 HTML을 반환하기 떄문입니다.
블로그 글, 상품의 약관 정보와 같이 아무런 브라우저 액션이 없는 정적인 내용만 필요한 경우에 유용합니다.
4.2.3 renderToNodeStream
renderToString과 결과물이 완전히 동일하지만 두 가지 차이점이 있습니다.
- 브라우저에서는 사용 불가능
먼저 살펴본 API와 달리 브라우저서는 사용이 불가능합니다. Node.js 환경에 의존하고 있는 함수입니다.
- 결과물의 타입이 Node.js의
ReadableStream
ReadableStream은 utf-8로 인코딩된 바이트 스트림으로 Node.js, Deno, Bun 같은 서버 환경에서만 사용 가능합니다. ReadableStream 자체는 브라우저에서도 사용 가능하나 브라우저 환경에서 만들 수는 없습니다.
스트림: 데이터를 청크(chunk) 단위로 분할해 조금씩 로드하는 방식
renderToString의 결과물의 크기가 큰 경우, Node.js가 실행되는 서버에 큰 부담이 될 수 있습니다. renderToNodeStream은 HTML을 여러 청크로 분리하여 받아와 Node.js 서버의 부담을 덜어 줍니다. 대부분의 리액트 SSR 프레임워크는 모두 renderToNodeStream을 채택하고 있습니다.
4.2.4 renderToStaticNodeStream
renderToString의 renderToStaticMarkup과 마찬가지로 renderToNodeStream의 결과물에서 hydrate가 필요없는 순수 HTML 마크업 결과물이 필요할 때 사용합니다.
4.2.5 hydrate
renderToString 또는 renderToNodeStream으로 생성된 HTML에 JS 핸들러나 이벤트를 붙일 때 사용합니다.
hydrate와 비슷한 함수로 ReactDom.render 메서드가 있습니다. render 메서드는 컴포넌트와 HTML 요소를 인수로 받는데, 두 인수를 바탕으로 HTML의 요소에 해당 컴포넌트를 렌더링하며, 동시에 이벤트 핸들러를 붙입니다. render는 클라이언트에서만 실행되는 렌더링과 이벤트 핸들러 추가 등 리액트 기반 온전한 웹페이지를 만드는 데 필요한 모든 작업을 수행합니다.
import * as ReactDOM from "react-dom";
import App from "./App";
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
hydrate는 기본적으로 이미 렌더링된 HTML이 있다는 가정하에, 이벤트를 붙이는 작업만 실행합니다.
import * as ReactDOM from "react-dom";
import App from "./App";
// containerId를 가리키는 element는 서버에서 렌더링된 HTML의 특정 위치를 의미한다.
const element = document.getElementById(containerId);
// 해당 element를 기준으로 리액트 이벤트 핸들러를 붙인다.
ReactDOM.hydrate(<App />, element);
hydrate는 서버에서 제공한 HTML이 클라이언트의 결과물과 같을 것을 기대하고 실행됩니다. 렌더링 결과물과 HTML의 불일치가 발생하면 에러를 발생시킵니다. 불일치할 경우 hydrate가 렌더링한 결과물을 기준으로 웹페이지를 그리기 때문에 애플리케이션이 중단되거나 하지는 않습니다. 하지만 올바른 사용법이 아니며, 사실상 서버, 클라이언트 두 번 렌더링하게 되어 SSR의 장점이 사라지게 됩니다.
Date 객체와 같이 요소에 빈번하게 값이 변경되며 불가피하게 불일치가 발생할 경우, 해당 요소에 suppressHydrationWarning을 추가해 경고를 끌 수 있지만 권장되지 않습니다.
'라이브러리 > React' 카테고리의 다른 글
| [모던 리액트 Deep Dive] 7장 크롬 개발자 도구를 활용한 애플리케이션 분석 (0) | 2025.06.25 |
|---|---|
| [모던 리액트 Deep Dive] 6장 리액트 개발 도구로 디버깅하기 (0) | 2025.06.25 |
| [모던 리액트 Deep Dive] 5장 리액트와 상태 관리 라이브러리 (0) | 2025.06.25 |
| [모던 리액트 Deep Dive] 3장 리액트의 훅 깊게 살펴보기 (0) | 2025.06.08 |
| [모던 리액트 Deep Dive] 2장 리액트 핵심 요소 깊게 살펴보기 (2) | 2025.06.04 |