안녕하세요, 이상혁입니다

React SSR 쌩으로 구현하기 😎 - 현대적인 서버사이드 렌더링의 비밀

React SSR 쌩으로 구현하기 😎 - 현대적인 서버사이드 렌더링의 비밀

2025년 7월 31일

SSR에 눈을 돌리게 된 계기

회사에서 신규 프로젝트를 진행할 때 React + Vite 조합으로 웹 애플리케이션을 구현하고 있다. Vue, Svelte 등도 추천받은 경우가 많았지만 여전히 React를 선택했다. 솔직히 40% 정도는 익숙함 때문에 선택한 것도 있었지만, 나름의 근거가 있었다.

  • UI, 상태관리, 유틸리티 등 외부 라이브러리를 이용할 때 React는 거의 대부분 지원한다
  • 기존에 활용하고 있는 디자인 시스템(@mui/joy 기반)도 React 컴포넌트로 구현된 상태다
  • 다른 팀원이 프로젝트에 합류했을 때 상대적으로 적응이 빠른 환경이다(기술 스택 시장성과 연관)
  • 컴포넌트 주도 개발(CDD)에 익숙한 상황으로 기획 및 디자인에서도 컴포넌트 개념으로 의견 조율한다

어느 날 회사 팀원분께서 새로운 기술 스택을 선정하면서 다른 프로젝트에 적용했는데, 그게 바로 Next.js였다. 똑같은 React 코드였지만 “이 페이지는 SSR, SSG, ISR로 처리했어요”, “파일 기반으로 라우팅할 수 있어서 React Router가 필요없어요” 이런 이야기를 듣고 뭔가 멋있고 개발하기 재미있겠다는 생각이 들었다.

[사진 1] SEO의 특징을 설명

[사진 1] SEO의 특징을 설명

하지만 “SEO 때문에 SSR 프레임워크를 이용하는 거지, 그게 아니면 그냥 React + Vite로 구현하세요”라는 이야기도 듣게 되면서, ‘꼭 검색 엔진 노출에 최적화할 필요 없이 그냥 SSR을 이용하면 안 될까?’라는 생각을 가지게 되었다. 이를 계기로 본격적으로 SSR(Server Side Rendering)을 알아보게 되었다.

SSR는 무엇이고 CSR랑 무슨 차이

서버 사이드 렌더링(SSR)은 서버에서 HTML을 만들어 준다. 여기서 많이 대조하는 개념이 클라이언트 사이드 렌더링(CSR)이며, CSR은 브라우저에서 자바스크립트 파일을 가지고 HTML을 만들어 준다. 사용자 입장에서는 이게 SSR인지 CSR인지 육안으로 구분하기 어렵지만, 개발자는 프론트엔드 프로젝트에서 “index.html”을 보면 SSR인지 아닌지 알 수 있다.

그림처럼 HTML Body 태그에 아무것도 없으면 CSR, 뭔가 태그들이 담겨져 있으면 SSR라고 쉽게 추론할 수 있으며, CSR 및 SSR의 동작 방식은 아래와 같다.

[사진 2] Client Side Rendering에 대한 처리 흐름

[사진 2] Client Side Rendering에 대한 처리 흐름

CSR

  1. 브라우저는 네트워크를 통해 껍데기 수준의 HTML 파일을 다운로드 받는다.
  2. 껍데기 안에 나머지 HTML 태그들을 채우기 위해서 CSS, 자바스크립트 파일들을 다운로드 받고 실행한다.
  3. 본격적으로 화면이 보여지면서 사용자에게 필요한 정보들을 네트워크를 통해서 가져오고 화면이 갱신된다.

[사진 3] Server Side Rendering에 대한 처리 흐름

[사진 3] Server Side Rendering에 대한 처리 흐름

SSR

  1. 브라우저는 네트워크를 통해 완성된 HTML 파일을 다운로드 받는다.
  2. 본격적으로 화면이 보여지면서 다른 정적 리소스(JS, CSS)가 이어서 적용된다.

CSR이 SEO에 불리한 이유는 ’껍데기 수준의 HTML’ 때문이다. 껍데기니까 HTML 메타(Meta) 태그가 없을 수도 있으며, 가령 사전에 메타 태그를 채웠더라도 같은 웹 애플리케이션 내에 다른 페이지에서는 똑같은 태그가 적용되어 동적으로 바꾸기 힘들다.

예전에는 검색 엔진의 크롤러들이 자바스크립트를 실행하지 않고 완성된 HTML만 읽었기 때문에 SSR이 SEO에 유리했다. 하지만 현재는 Google을 비롯한 주요 검색 엔진들이 자바스크립트를 실행할 수 있어서 CSR도 SEO가 가능하다. 다만 자바스크립트 실행에 시간이 걸리고 모든 검색 엔진이 완벽하게 지원하는 것은 아니어서, 여전히 SSR이 SEO에 더 유리하다고 볼 수 있다.

[사진 4] GPT의 SSR, CSR에 대한 사용자 경험 분석

[사진 4] GPT의 SSR, CSR에 대한 사용자 경험 분석

다시, SEO를 제외한 나머지 부분들을 생각하면 완성된 HTML을 주는 게 빠른 게 아닌가 싶어서 SSR이 무조건 좋은 게 아닌가라고 생각이 들었다. 하지만 만약 SSR 서버의 성능이 내 컴퓨터보다 안 좋고 서버 위치가 남극에 있다면 또 말이 달라질 수 있다.

우리나라는 인터넷 속도가 다른 나라에 비해 빠르고 요즘 다 최신 스마트폰을 써서 좋은 성능을 갖췄으니까 “CSR이 좋겠구나”라고 또 생각하는 순간, CSR의 근본적인 단점들이 떠올랐다. 전 세계적으로 보면 아직도 저사양 디바이스를 사용하는 사용자가 많고, 느린 네트워크 환경에서는 CSR의 초기 로딩 시 빈 화면이 보이는 시간이 길어져서 사용자 경험이 나빠진다. 결국 완벽하게 좋은 방식은 없고, 타겟 사용자의 환경과 서비스 특성에 따라 적절한 선택을 해야 한다는 결론에 도달했다.

[사진 5] 해당 내용에 대한 GPT의 정리

[사진 5] 해당 내용에 대한 GPT의 정리

프론트엔드 프레임워크의 흐름으로 파악한 현대적인 SSR

사내에 입사했을 때 관리 시스템이 Thymeleaf로 구축되어 있었다. Spring Boot 서버에서 템플릿 엔진을 통해 HTML을 생성하여 페이지를 제공하는 방식이었다. 얼핏 들어보면 Next.js와 같은 SSR 기반으로 똑같아 보이는데, 왜 사람들이 Thymeleaf, PHP, JSP 같은 전통적인 프레임워크 대신 Next.js를 선택하는지 그 차이점이 궁금했다.

@Controller public class UserController { @GetMapping("/users/{id}") public String getUserProfile(@PathVariable Long id, Model model) { User user = userService.findById(id); List<Post> posts = postService.findByUserId(id); model.addAttribute("user", user); model.addAttribute("posts", posts); return "user-profile"; } }

Thymeleaf의 동작 방식을 살펴보면, View를 생성하기 전에 사용자에게 제공할 데이터를 미리 준비하고 전달한다. 사용자가 정해진 경로에 접속하면 해당 HTML 템플릿에 데이터를 매핑하여 완성된 HTML을 클라이언트에게 제공하는 구조다.

<!-- user-profile.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title th:text="${user.name} + '님의 프로필'">프로필</title> </head> <body> <div class="profile"> <h1 th:text="${user.name}">사용자 이름</h1> <p th:text="${user.email}">이메일</p> <img th:src="${user.profileImage}" alt="프로필 사진" /> </div> <div class="posts"> <h2>작성한 글</h2> <div th:each="post : ${posts}" class="post-item"> <h3 th:text="${post.title}">글 제목</h3> <p th:text="${post.content}">글 내용</p> <span th:text="${#dates.format(post.createdAt, 'yyyy-MM-dd')}" >작성일</span > </div> </div> <!-- 다른 페이지로 이동하는 링크 --> <a th:href="@{/users/{id}/edit(id=${user.id})}">프로필 수정</a> </body> </html>

이러한 전통적인 SSR 방식은 단순한 정보 제공에는 문제가 없었지만, 몇 가지 한계점이 존재했다. 페이지 간 이동 시 발생하는 깜빡거림 현상(Page Flicker)과 동적인 기능을 구현하기 위해 DOM을 직접 조작해야 하는 복잡성이 대표적인 문제였다. 이러한 문제들은 사용자 경험(UX)과 개발자 경험(DX)을 모두 저하시켰다.

[사진 6] SPA의 3대장 React, Vue, Svelte

[사진 6] SPA의 3대장 React, Vue, Svelte

이런 한계를 극복하기 위해 React, Vue, Angular 같은 SPA(Single Page Application) 프레임워크가 등장했다. SPA는 깜빡임 없는 부드러운 사용자 경험을 제공할 수 있게 되었고, Virtual DOM과 컴포넌트 기반 아키텍처의 도입으로 JavaScript를 통한 동적 기능과 복잡한 UI 구현이 훨씬 쉬워졌다. 이 시점이 바로 프론트엔드라는 직무가 본격적으로 주목받기 시작한 때가 아닐까 싶다.

하지만 SPA가 널리 사용되기 시작하면서, 이 역시 새로운 문제점들이 드러나게 되었다. 검색 엔진 최적화(SEO)에 취약한 구조와 JavaScript 코드가 많아진 만큼 최초 페이지 로딩 시간이 길어지는 단점이 있었다. 특히 SEO가 중요한 서비스들은 어쩔 수 없이 다시 서버 사이드 렌더링을 고려해야 하는 상황이 되었다.

[사진 7] 현대 SSR 프레임워크 Next,js, Nuxt.js, SvelteKit

[사진 7] 현대 SSR 프레임워크 Next,js, Nuxt.js, SvelteKit

이렇게 기존 SSR의 장점과 SPA의 장점을 모두 취하고 싶다는 요구가 생기면서, Next.js, Nuxt.js, SvelteKit 같은 현대적인 서버 사이드 렌더링 프레임워크가 등장했다. 이들은 하이브리드 방식을 채택했다. 최초 웹사이트 진입 시에는 SSR 방식으로 서버에서 완성된 HTML을 제공하고, 그 이후 페이지 간 라우팅에서는 클라이언트에서 JavaScript를 통해 SPA처럼 작동하게 만든 것이다. 이를 하이드레이션(Hydration) 이라고 부른다.

[사진 8] Next.js의 Hydration 처리 과정

[사진 8] Next.js의 Hydration 처리 과정

대적인 SSR로 인해 React를 많이 사용하는 환경에서는 RSC(React Server Components)라는 개념까지 등장하게 되었고, 각 프론트엔드 프레임워크들도 최신 버전에서 SSR 기능 및 대응을 지원하기 시작했다. 하지만 이러한 변화가 100% 긍정적인 반응을 얻고 있는 것은 아니다.

Next.js로 인해 React의 복잡성이 더욱 증폭되었다는 의견이나, 서버 사이드 관련 테스트 및 유지보수에 더 많은 시간이 소요되어 오히려 불편하다는 개발자들이 많다. 전통적인 클라이언트 사이드 렌더링에 비해 서버와 클라이언트 간의 경계를 관리해야 하고, 하이드레이션 불일치 문제를 디버깅하는 것도 쉽지 않은 일이다.

나 역시 기술 트렌드가 빠르게 변화하기 때문에, 새로운 기술을 적당히 학습하면서 필요에 따라 활용하는 마인드를 가지고 있다. 모든 새로운 기술을 즉시 도입하기보다는, 프로젝트의 요구사항과 팀의 상황을 고려해서 신중하게 선택하는 것이 중요하다고 생각한다.

React만으로 SSR 구현하며 내부 동작 파악하기

// Client Code // webpack.config.js const path = require("path"); module.exports = { entry: "./src/client.js", output: { path: path.resolve(__dirname, "dist"), filename: "bundle.js", }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: "babel-loader", options: { presets: ["@babel/preset-env", "@babel/preset-react"], }, }, }, ], }, }; // src/client.js import React from "react"; import { hydrateRoot } from "react-dom/client"; import App from "./App"; // 서버에서 렌더링된 HTML을 hydrate hydrateRoot(document.getElementById("root"), <App />); // src/App.js import React, { useState } from "react"; function App() { const [count, setCount] = useState(0); return ( <div> <h1>SSR with Pure React</h1> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> <button onClick={() => setCount(count - 1)}>Decrement</button> </div> ); } export default App;

클라이언트 코드에서는 특별한 작업 없이 일반적인 React 코드와 번들러 설정으로 진행했다. 다만, 여기서 평소에 잘 사용하지 않던 함수가 있었는데, hydrateRoot는 서버에서 완성된 HTML을 React 컴포넌트와 연결하여 앱을 사용할 수 있도록 하는 역할을 한다.

코드 레벨에서 해석하면, 서버에서 렌더링된 HTML과 App 컴포넌트의 구조를 비교하여 일치하면 이벤트 바인딩 등을 수행하는 방식이다.

// Server Code // server.js const express = require("express"); const React = require("react"); const { renderToString } = require("react-dom/server"); const path = require("path"); const fs = require("fs"); // Babel을 사용하여 JSX를 런타임에서 변환 require("@babel/register")({ presets: ["@babel/preset-env", "@babel/preset-react"], }); const App = require("./src/App").default; const app = express(); const PORT = 3000; const html = fs.readFileSync(path.resolve(__dirname, "./index.html"), "utf-8"); // 정적 파일 제공 app.use(express.static(path.join(__dirname, "dist"))); // 라우트 핸들러 app.get("*", (req, res) => { try { // React 컴포넌트를 HTML 문자열로 렌더링 const renderedString = renderToString(React.createElement(App)); // 완성된 HTML 응답 res.send( html.replace( '<div id="root"></div>', `<div id="root">${renderedString}</div>` ) ); } catch (error) { console.error("SSR Error:", error); res.status(500).send("Server Error"); } }); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });

서버의 역할은 완성된 HTML을 제공하는 것이기 때문에 간단한 Express 서버 코드에서 renderToString 함수를 확인할 수 있다. 이 함수의 역할은 React 컴포넌트를 HTML 문자열로 변환해주는 것이다.

renderToString 외에도 renderToStaticMarkup, renderToPipeableStream 등이 있으며, 일반적인 SSR에서는 기본적으로 renderToString을 사용한다. renderToStaticMarkup은 React의 내부 속성 없이 순수한 HTML만 생성하고, renderToPipeableStream은 스트리밍 방식으로 HTML을 전송할 때 사용한다.

직접 구현해보며 느낀 점들

솔직히 말하면 생각보다 복잡하지도, 특별하지도 않았다. React에서 SSR 처리를 위한 함수들을 제공하기 때문에, 이를 순수 JavaScript로 구현한다면 훨씬 복잡할 수 있겠지만, React의 도움으로 SSR 구성을 위한 직관적인 코드 작성이 가능했다.

그렇다고 해서 이것이 Next.js와 동일하다고 말할 수는 없다. 코드 스플리팅, 파일 기반 라우팅, 이미지 최적화, 자동 번들 최적화, 서버리스 배포 지원 등 프로덕션 환경에서 필요한 다양한 기능들을 모두 직접 구현해야 하기 때문이다. Next.js가 제공하는 것은 단순한 SSR이 아니라, 현대적인 웹 애플리케이션 개발에 필요한 종합적인 솔루션이라는 점을 실감할 수 있었다.

[사진 9] 렌더링 기술을 선정하기 위한 고민

[사진 9] 렌더링 기술을 선정하기 위한 고민

결국 중요한 것은 내 서비스가 어떤 성격인지, 어떤 사용자들이 이용할지, 그리고 팀과 회사의 상황을 종합적으로 고려해야 합리적인 기술 선택이 될 수 있다는 점이다. 단순히 트렌드를 따라가기보다는, 프로젝트의 요구사항과 팀의 역량에 맞는 적절한 수준의 기술을 선택하는 것이 더 현명한 접근이라고 생각한다.