Server Component 알아보기 (1) - CSR, SSR, 그리고 서버 컴포넌트?
😤 면접 때 받은 질문, ‘서버 컴포넌트가 어떤 식으로 렌더링되는지 아시나요?’
어제(22일) 기준으로 현재까지 면접 본 곳 중에서 가장 좋다고 생각되는 곳에서 면접을 보고 왔다.
역시 이번에도 내 지식의 밑천이 너무 쉽게 드러났고, 그로 인해서 집에 돌아와서 이불을 덮고 눈물을 찔찔 짰다, 아이고, 자신감을 왜케 잃어버렸나, 내 자신!
그렇게 이불킥 좀 하고 정신차린 뒤, 면접에서 나왔던 질문들을 되짚어보던 중에 기억에 남던 질문이 하나 있었다.
서버 컴포넌트가 어떤 식으로 렌더링이 되는지 아시나요?
사실 Next.js를 선택했던 이유는 SEO와 초기 렌더링 개선을 위해서였다.
이를 위해 서버 컴포넌트를 통해 데이터를 받아와서 반영한다고만 생각했지, 이 데이터를 어떻게 반영하는지도 모를 뿐더러 애초에 서버 컴포넌트를 쓰는 방법이 뭔지, 더 나아가 서버 컴포넌트가 왜 생겼고, 무엇을 위해서 존재하는지 생각해본 적이 없다.
덕분에 면접 때 이런 질문들에 격침을 당할 수 밖에 없었고, 이번 기회에 Next.js에서 제대로 쓰기 위해서 서버 컴포넌트에 대해서 좀 더 알아야겠다 싶어 정리해보기로 했다.
그 첫 내용으로 서버 컴포넌트가 생겨나기까지 과정을 적어봤으며, 아래 글의 내용을 최대한 가져와 이해한 내용을 정리해봤다.
Where do React Server Components fit in the history of web development?
(부족하거나 틀린 내용에 관한 의견은 언제든지 감사하게 받겠습니다.)
해당 글은 React를 기준으로 웹 페이지 개발 변천사에 대한 대략적인 이야기를 다루고 있다. 다른 프레임워크나 라이브러리에 대한 이야기는 하지 않으므로 참고하길 바란다.
👵 할아버지, 할머니 어렸을 적 들려주신 정적 웹 페이지 시대
서버 컴포넌트를 이야기하기 위해서는 React를 통해서 구현되는 SPA, 단일 웹페이지 애플리케이션(Single Page Application)가 무엇이고 왜 등장했는지, 그리고 그 한계가 무엇인지를 짚고 가야한다.
예전의 웹 페이지는 지금처럼 동적이지 않고 HTML과 CSS를 이용한 정적인 웹페이지만을 담당했다.
브라우저가 GET 요청을 보내면 웹 서버는 HTML 파일을 반환, 브라우저는 이를 파싱하고 렌더 트리를 구축하고, 배치하여 화면에 렌더링시키는 게 일반적이었다.
🕹️ JavaScript와 함께 동적 웹 페이지의 시대가 개막
시간이 지나면서 사람들의 요구 사항은 당연히 늘어나고, 자연스럽게 이 웹 페이지에서 특정 동작 등을 처리할 수 있기를 바라게 된다.
상호작용의 요구에 따라 HTML과 CSS에 이어 웹 페이지의 동작을 담당하는 JavaScript가 탄생하고 웹 페이지에 적용하게 되었다.
이 때까지만 해도 JavaScript에서의 동작은 Form 유효성 검사, Hover 등의 이미지 변경 등과 같은 페이지 상호작용이나 애니메이션 처리 등에만 쓰였다.
데이터 처리 등과 같은 동작은 여전히 서버에서 다 담당하게 됐다.
🚢 Ajax, 그리고 SPA
그리고 JavaScript의 적용으로 복잡한 웹 페이지를 만드는 게 가능해진 개발자들은 이윽고, Ajax(Asynchronous Javascript And Xml, ‘비동기식 자바스크립트와 xml’) 방식을 이용해 JavaScript와 서버 간의 직접적인 상호작용을 시도하게 된다.
이 방식으로 서버에서는 더 이상 html 파일을 반환할 필요 없이 데이터 만을 반환해서 넘겨주고, JavaScript에서는 받아온 데이터를 이용해 html 문서에 내용을 그려준다.
이 방법은 점점 사용성이 더더욱 커지면서 웹 생계에 엄청난 영향을 줬고, 이윽고 웹 어플리케이션, 즉 브라우저에서 이용 가능한 웹 프로그램이 쏟아지게 된다.
그리고 이러한 생태계에서 등장하게 된 어플리케이션 방식이 바로 SPA, 단일 페이지 어플리케이션이다.
클라이언트에서는 Ajax의 등장으로 데이터만을 서버에 받아와 반영할 수 있게 되면서, 필요할 때마다 HTML을 새로 받을 필요성이 없어졌다.
필요에 따라 변화가 필요한 부분만 새로 반영해주는 식으로, 렌더링을 최소화하거나 페이지를 전환할 필요성이 떨어지게 된다.
반면 서버 쪽에서는 그동안 html 문서 파일을 항상 보내줬으나, 더 이상 그러지 않아도 되므로 좀 더 다양한 방식의 데이터 방식들이나 통신 규칙들이 등장하게 된다.
SPA의 등장으로 웹의 생태계는 엄청 확장되었고 다양한 변화를 불러일으키게 됐으며, 다양한 라이브러리와 프레임워크가 쏟아지게 됐다.
Angular, Vue, 그리고 React 등이 생겨나기 시작한 건 이 때부며, 백엔드와 프론트엔드 사이의 경계가 생기기 시작한 것 또한 그러하다.
😖 SPA에서 발생하는 문제점들, 그리고 SSR 방식으로의 회귀
그러나 SPA 방식에서의 웹 어플리케이션의 구현은 몇 가지 문제점을 야기하게 된다.
서버 측에서 데이터를 받아서 화면에 적용되기 전까지, 어플리케이션 화면에는 아무것도 나타나지 않는다.
이는 검색 엔진에서 화면을 크롤링할 때 첫 화면에 아무것도 나오지 않아 색인 등을 하지 못해 검색 결과를 망가뜨릴 수 있는 중요한 원인이 된다.
SPA의 ‘대다수’는 CSR, Client Side Rendering 방식을 채용하고 있다. (SPA ≠ CSR이다!)
CSR의 방식은 다음과 같이 진행된다.
- 서버에서 HTML 파일을 가져온다.
- 웹 페이지에서 작동할 JavaScript를 가져와 파싱 후, 코드를 실행한다.
- 웹 페이지는 서버에 데이터를 요청하고 응답을 기다린다.
응답을 받으면 수신된 데이터를 기반으로 웹 페이지를 렌더링한다.
Client Side Rendering 과정을 설명한 이미지
이러한 방식에서는 SPA의 사이즈가 클 수록 JavaScript와 데이터를 받아오는 시간에 따라 오랜 로딩 시간을 가지게 되는데, 이용자들 입장에서는 불편함을 초래할 수 밖에 없게 된다.
이로 인해 고민에 빠진 개발자들은 이 방식을 해결하기 위해 이전까지 쓰였던 방식이었던 SSR, Server Side Rendering에 눈을 돌리기 시작했다.
↩️ 원본 회귀? Server Side Rendering으로 눈을 돌린 사람들
SSR 방식은 CSR 방식 이전까지 웹 페이지들에서 다 쓰이던 방식이었다.
작동 방식은 다음과 같다.
- 서버에서 HTML 파일을 가져온다.
- 사이트에서는 가져온 HTML 파일을 바로 화면에 렌더링하고(이 때의 상태를 Ready to Render 라고 부르는 듯하다.) JavaScript 파일을 가져오기 시작한다.
- JavaScript 파일을 다운 받는 동안에는 화면의 동작이 전부 정지된다. 단 이용자가 어떤 동작을 행하려고 하는지 그 입력 사항은 브라우저 측에서 기억해둔다.
JavaScript 파일들이 받아지면 이를 통해 받아온 데이터는 화면에 노출되기 시작할 것이며, 그 사이에 기억해둔 유저의 동작이 있다면 해당 동작을 실행해준다.
Server Side Rendering 과정을 설명한 이미지
React는 CSR 방식을 채택한 라이브러리이며 createRoot()
라는 함수를 이용해 id=”root”
가 있는 HTML 요소를 체크하고 해당 요소를 기준으로 React의 코드를 반영하고 있다.
그래서 아까도 말했지만 CSR 방식으로는 처음 렌더링 되는 텅 빈 html에 React로 만든 컴포넌트들이 그려지거나 핸들링 코드들이 적용되는 식으로 웹 페이지에 반영된다.
이런 React의 코드를 SSR 방식으로 쓰기 위해, React에서는 ReactDOMServer
라는 객체를 Node 서버에서 사용할 수 있도록 추가했다.
이 객체를 이용해서, 서버에서는 react의 root 컴포넌트가 렌더된 html 파일을 보내주고 브라우저는 이를 받아 html 내용을 화면에 출력한다.
// server/index.tsx import express from 'express'; import fs from 'fs'; import path from 'path'; import ReactDOMServer from 'react-dom/server'; import App from '../client/App'; const app = express(); // 클라이언트 사이드에서 빌드된 html을 읽어와 사용 const html = fs.readFileSync(path.resolve(__dirname, "../client/index.html"), "utf-8"); app.get("/", (req, res) => { // <App /> 을 렌더링 const renderString = ReactDOMServer.renderToString(<App />); // <div id="root"></div> 내부에 삽입 res.send(html.replace('<div id="root"></div>', `<div id="root">${renderString}</div>`)); }); // 위의 / 이외의 경로로 요청할 경우(js, css 등) // dist/client 폴더에 있는 파일들 제공 app.use("/", express.static("dist/client")); app.listen(3000, () => { console.log('Server is listening on port 3000'); });
자, 그렇다면 기존의 CSR 방식에서는 html 파일을 가져오고, JavaScript 파일을 가져와 코드를 배치하고 실행까지 시켜야 화면에 렌더링이 된다고 이야기했다.
그러나 SSR 방식에서 root 컴포넌트는 서버에서 이미 렌더링해서 화면에 띄운 상태가 되는데, React의 컴포넌트나 핸들링된 코드들을 어떤 식으로 배치해서 화면에 그려주고 동작시켜주는 걸까?
바로 Hydration, 수분 공급 또는 수화라는 방식이다.
이전 글에 정리한 적이 있었는데, 지금 돌이켜보니 이번 기회 덕분에 그 의미를 더 정확하게 알게된 것 같다.
SSR에선 html 파일을 먼저 가져와 화면에 그리고(Ready-to-render), 그 후 번들링된 JavaScript 파일을 가져와 배치한다고 이야기했다.
React에서는 이렇게 컴포넌트나 핸들링 코드들을 받아온 뒤, 콸콸콸 쏟아붓는다라는 의미에서 hydrateRoot()
를 이용해서 root 컴포넌트에 그 코드들을 쏟아붓듯이 배치한다.
조금 더 자세하게 이야기하자면, React에서 SSR 방식을 위해서 JavaScript와 데이터를 받은 뒤 페이지에 렌더링하기 전에, 현 페이지의 가상 DOM과 서버에서 생성된 DOM을 비교한다.
그 후, React에서는 비교가 완료된 DOM에 React와 상호작용을 가능하도록 하는 이벤트 핸들러를 붙이게함으로서 HTML에 React와 관련한 컴포넌트나 동작들을 가능하게 해준다.
이렇게 정적 HTML에 React 코드들을 콸콸 채워간다는 의미로 이 과정을 Hydration이라고 부르는 듯 하다.
아무튼 Hydration 방식의 등장으로 많은 프론트엔드 개발자들이 React를 SSR 방식으로 사용하기 위한 시도들이 곳곳에서 보이기 시작했다.
하지만, 그럼에도 이 방식을 이용하는 데에는 몇 가지 문제가 있었다.
🤔 하지만 이러한 SSR 방식에도 문제가 있었으니…
이런 식으로 SSR을 쓰기 위한 개발자들의 연구에도 불구하고 몇 가지 문제점을 맞이하게 된다.
Hydration으로 인한 렌더링 시간 증가
아무래도 HTML 파일을 미리 받아와서 화면을 그려내는 초기 렌더링 속도는 빨라질 수 있겠지만, 문제는 향후 React를 이용해서 그려내고자 하는 컴포넌트 내용들은 Hydration을 과정을 거쳐야 하기 때문에 DOM을 비교하고, 그 사항을 반영하기까지 시간이 걸릴 수 밖에 없다.
이 부분은 SSR 방식을 채용한 사람들에게 고민을 안기는 이슈 중 하나가 됐다.
불필요한 번들 사이즈의 증가
사실상 SSR 방식으로 HTML이나 JavaScript 코드들을 받아온다고 하면, React에서 컴포넌트를 그리기 보다는 HTML 파일 등에 요소를 배치하는 게 더 이득일 것이다.
어쨌든 SSR 방식으로 필요에 의해서 React에 컴포넌트를 그려내기는 한다지만, 이는 결과적으로 불필요한 번들 사이즈를 늘릴 수 밖에 없는 문제를 초래하게 된다.
이외에도 다른 이슈들이 있겠지만, 아무튼 React를 만든 사람들은 이런 부분에 머리를 맞대면서 고민을 하기 시작한다.
그리고, 그 해결책으로서 꺼낸 것이 서버 컴포넌트라는 카드였다.
💻 그리고 서버 컴포넌트의 등장
어찌됐던 Hydration의 영향으로 발생할 수 있는 문제들이 고민하던 React 측에서는 Hydration을 불필요할 때에 쓰지 않도록 하기 위한 서버 컴포넌트를 만들게 된다.
그리고 컴포넌트를 서버용, 클라이언트용으로 두 개를 쪼갠 뒤 그 역할을 각각 맡기려고 시도한다.
- 서버 컴포넌트
- 서버에서 단 한 번만 렌더링이 이루어진다.
- 클라이언트에서 사용되는 JavaScript 코드가 없다.
- 이 말은 즉, 사이드 이펙트나 동작 등이 필요한 코드를 적용할 수 없으므로 React Hook 또한 사용할 수 없다.
- 클라이언트 컴포넌트
- 여러 번 렌더링이 일어날 수 있다.
- 클라이언트 내에서 상호작용 하는 JavaScript 코드들이 배치되어 있다.
여기서 알아둬야 할 점은 서버 컴포넌트의 방식은 SSR, 그러니까 서버 사이드 렌더링과 다르다.
서버 컴포넌트의 코드들은 클라이언트로 전달되지 않는다. 엄밀히 말하자면 html 문서에 포함되어 보내진다고 보면 된다.
하지만, SSR은 html에 미리 렌더링될 root와 같은 요소만 담아질 뿐이지, 동작하는 모든 코드들은 사실상 JavaScript에 번들링되어 보내진다.
서버 컴포넌트에서는 각 컴포넌트에서 fetch 등을 통해서 데이터에 접근하는 게 가능하다. 하지만 SSR은 Next.js 기준으로 최상위 페이지에서만
getServerProps()
과 같은 값을 통해서 서버와 통신하여 데이터에 접근하게 된다.SSR의 경우에는 데이터가 갱신되어 리페칭하게 된다면 위에서 이야기했듯 최상위 페이지에서 데이터가 갱신되기 때문에 전체 페이지가 갱신되어 클라이언트 상태를 유지하는 게 힘들다.
서버 컴포넌트는 이와는 다르게 특정한 형태로 받아지게 되어 있어, 데이터가 갱신되어 리페칭되어도 영향을 받지 않는다. 클라이언트 상태를 그대로 유지할 수 있다는 장점이 있다.
서버 컴포넌트는 이런 형태의 데이터를 참조해 반영된다. / 출저: https://maxleiter.com/blog/creeperhost-api
여기까지 서버 컴포넌트가 탄생하게 된 배경을 적어봤다.
다음 글에서는 그래서 서버 컴포넌트는 어떤 식으로 렌더링이 되는지, 그리고 이를 가볍게 적용한 사례와 이점에 대해서 작성해보도록 하겠다.
🔖 참고 문서
Where do React Server Components fit in the history of web development?
[SSR] 서버사이드렌더링(2) - SSR 직접구현, ReactDOMServer
React SSR (서버 사이드 렌더링) 얕게 시작해보기 (React.hydrateRoot)
가장 쉬운 방법으로 리액트에서 서버사이드 렌더링 이해하기
새로 등장한 ‘리액트 서버 컴포넌트’ 이해하기 | 요즘IT
React Server Components – React