React 개발자를 위한 SSR 심화 가이드
원문을 번역하고 사내 발표용으로 정리한 글입니다. 측정 화면과 단계별 실습, 전체 설명은 원문에 있으니 함께 보시길 권합니다. 원문: SSR deep dive for React developers (developerway.com) · 실습 저장소: developerway/ssr-deep-dive
앞선 초기 로딩 성능과 Flame Graph 글에서 클라이언트 사이드 렌더링의 약점 두 가지를 봤습니다. 초기 로드에 불리하다는 것, 그리고 JavaScript가 없으면 아무것도 아니라는 것. 이 글은 그 둘을 SSR과 그 변형으로 풀어보고, 대신 무엇을 지불하게 되는지를 봅니다.
왜 JavaScript 없는 환경을 신경 써야 하나
브라우저에서 JS를 끄는 사람은 요즘 거의 없습니다. 문제는 사이트에 접근하는 게 사람만이 아니라는 것입니다. 검색 엔진 크롤러와 메신저·SNS의 링크 미리보기는 URL로 요청을 보내 받은 HTML에서 텍스트·링크·메타 태그를 뽑고 대개 거기서 끝냅니다. JS 실행 결과까지 보려면 브라우저를 띄워야 하는데 비용이 커서 모든 봇이 그렇게 하지는 않습니다.
구글은 예외적으로 2단계 처리를 하지만, JS 의존이 큰 사이트는 인덱싱이 느려지고 크롤 예산이 제한될 수 있습니다.
그래서 검색 노출과 링크 공유가 중요한 사이트 — 블로그·문서·포럼·뉴스, 이커머스, 랜딩 페이지 — 라면 첫 응답 HTML에 중요한 정보가 들어 있어야 합니다. 빈 div를 보내는 고전적 CSR SPA는 여기서 나쁜 선택입니다.
1단계: 메타 태그만 프리렌더링하기
React를 버릴 필요는 없습니다. 서버를 하나 두면 됩니다.
빌드된 index.html을 읽어 그대로 돌려주는 서버가 있다고 할 때, 핵심은 그게 그냥 문자열이라는 점입니다. 돌려주기 전에 고치면 됩니다. 서버는 요청 경로를 아니까, 경로별 제목을 만들어 <title> 자리에 끼워 넣으면 됩니다.
title에만 해당하는 얘기가 아닙니다. <head> 안의 모든 것을 이렇게 프리렌더할 수 있습니다. 소셜 미리보기는 대부분 Open Graph <meta> 묶음만 보기 때문에, 이 정도로 “JavaScript 없음” 문제의 절반은 싸게 해결됩니다.
그 “싸다”의 실제 가격
완전히 정적인 SPA와 비교하면 두 가지가 새로 생깁니다.
첫째, 배포처가 달라집니다. 정적 호스팅으로 비용을 0에 가깝게 유지하던 것이 이제 서버를 요구합니다. 서버리스 함수는 유지보수 부담이 없고 배포가 쉽지만 사용량 과금이라 인기를 얻으면 청구서도 같이 옵니다. 직접 관리하는 서버는 통제권과 예측 가능한 비용을 주는 대신 모니터링·확장·메모리 누수가 전부 내 몫이 됩니다.
둘째, 성능이 나빠집니다. 메타 태그만 고치는 서버라도 모든 초기 로드에 캐시되지 않는 서버 왕복이 하나 추가됩니다. 원래도 좋지 않던 SPA의 초기 로드가 조금 더 나빠지고, 얼마나 나빠지는지는 서버 위치에 달렸습니다. 엣지 함수면 지연이 작지만 특정 리전 하나에 올린 서버는 지구 반대편 사용자가 그대로 체감합니다.
Next.js를 Vercel에 올리면 몰라도 되는 문제 아니냐는 질문이 나오는데, 답은 “피할 수 없다”입니다. 그 호스팅이 기본적으로 하는 일이 정확히 이것이기 때문입니다. 명시적으로 정적 내보내기를 설정하지 않았다면 위 비용은 그대로 적용됩니다.
2단계: 페이지 전체를 프리렌더링하기 (SSR)
<head>에서 멈출 이유도 없습니다. root div에 실제 마크업이 들어 있으면 브라우저는 JS를 기다리지 않고 그릴 수 있습니다. React가 제공하는 renderToString이 앱을 문자열로 만들어 줍니다.
원문의 측정 결과가 이 지점을 잘 보여줍니다.
- 프리렌더링을 켜면 FCP와 LCP가 같은 시점에 찍힙니다. React JS가 실행되기 전, 로드가 끝나기도 전입니다
- LCP가 1.3초에서 400ms 줄어 초기 로드가 30% 개선됐습니다
- JavaScript를 꺼도 화면이 그대로 있고 링크도 동작합니다(전체 새로고침을 일으키긴 합니다)
다만 FCP는 기대했던 800ms가 아니라 900ms쯤이었습니다. 늘어난 100ms의 약 1/3은 커진 HTML을 내려받는 시간, 나머지는 요소가 많아져 길어진 레이아웃 계산이었습니다. 성능에 관해 미리 숫자를 약속하면 안 되는 이유입니다.
그리고 SSR을 가치 있게 만드는 건 성능이 아닙니다. 봇이 JS 없이도 전부 볼 수 있게 되는 것이 본질이고, 성능 개선은 보너스입니다. 심지어 불안정한 보너스입니다.
SSR이 초기 로드를 더 나쁘게 만드는 경우
조건을 이렇게 바꾸면 뒤집힙니다. CPU는 빠르게, 네트워크는 가장 느리게, 캐시는 켜서 재방문자를 흉내 냅니다.
원문 측정에서 SPA 모드 LCP가 약 2.13초, SSR 모드가 약 2.62초로 SSR이 500ms 가까이 느렸습니다.
이유는 단순합니다. SPA 모드에서는 HTML이 빈 div라 즉시 내려오고 CSS·JS는 캐시에서 나오며 빠른 CPU가 React를 금방 실행합니다. 반면 SSR 모드에서는 커진 HTML을 좁은 대역폭으로 내려받는 데 오래 걸립니다. 그 다운로드 도중 브라우저는 도착한 조각부터 그리기 때문에, 인터페이스가 사이드바·네비게이션·차트·테이블 순으로 점진적으로 쌓이는 게 보입니다.
이게 희귀한 엣지 케이스는 아닙니다. 느린 네트워크 + 큰 지연 + 빠른 노트북은 출장자나 오지에 있는 사용자에게 흔한 조합입니다. 결국 두 가지로 귀결됩니다. 고객을 아는 것, 그리고 측정.
하이드레이션
콘텐츠가 빨리 보이는 데 만족하는 사이, 그 다음에 무슨 일이 벌어지는지를 빠뜨렸습니다.
React에게 이 HTML이 의미 있다고 말한 적이 없으므로 React는 root div를 통째로 지우고 자기가 만든 것으로 교체합니다. 결과가 같아서 눈에 안 보일 뿐, 성능 프로필에는 React가 UI를 새로 만드는 긴 작업이 그대로 남습니다. 전부 불필요한 일입니다. DOM은 이미 다 있으니까요.
여기서 하이드레이션이 들어옵니다. 이미 일치하는 HTML이 있다고 알려주면 React는 기존 노드를 재사용하고 이벤트 리스너만 붙입니다. 구현은 진입점에서 createRoot를 hydrateRoot로 바꾸는 정도입니다.
효과는 조건에 따라 크게 다릅니다. 원문 측정에서는 React 작업이 180ms에서 142ms로 줄었고, 캐시를 켜고 CPU만 느리게 둔 조건 — 빠른 인터넷에 느린 기기를 쓰는 재방문자 — 에서는 LCP가 약 550ms에서 280ms로 당겨졌습니다.
리스너를 붙이는 것 외에 초기 데이터를 함께 주입해 로딩 스피너나 콘텐츠 번쩍임을 없애는 용도로도 씁니다.
직접 구현해도 될까: 안 된다
원문의 대답은 단호합니다. 학습용으로 한 단계씩 켜보는 건 좋지만, 실제로는 전혀 간단하지 않습니다.
- 개발 서버용 SSR이 없습니다. 매번 다시 빌드하는 것 말고는 디버깅 수단이 없고, 핫 리로드를 원하면 직접 만들어야 합니다. Vite의 SSR 통합 지침만 해도 방대하고, Webpack이면 또 다릅니다
- 문서에 나오는
renderToString(<App />)은 그대로 돌지 않습니다. JSX는 빌드 단계가 있어야 동작하는데 순수 node에는 그게 없습니다. 실제 코드는 모듈을 SSR 로더로 불러와createElement로 넘기는 형태가 됩니다 renderToString은 스트리밍도, 데이터 대기도 지원하지 않습니다. 그게 필요하면renderToPipeableStream을 백엔드에 어떻게 얹을지까지 알아야 합니다
이걸 다 하다 보면 프로젝트는 3개월 뒤처지고 사실상 자기만의 Next.js를 만들고 있게 됩니다. 아주 타당한 사업적 이유와 시간·인력·전문성이 없다면 이미 있는 프레임워크를 쓰는 편이 낫습니다.
프론트엔드도 함께 바뀐다
백엔드는 퍼즐의 한 조각일 뿐이고, 앱 규모에 따라 프론트엔드 쪽이 더 복잡할 수 있습니다.
- 브라우저 API가 없습니다. 서버에는
window도document도 없어서 렌더링 중 접근하면 그대로 터지는데, 터지는 게 서버라 더 나쁩니다. 프론트엔드가 에러를 잡아 예쁜 화면을 보여줄 기회조차 없어서 서버 쪽에 에러 처리와 전용 에러 화면이 필요합니다 useEffect와useLayoutEffect는 서버에서 실행되지 않습니다. 하이드레이션 이후 클라이언트에서만 돕니다. 뒤집어 말하면useEffect결과로 UI가 바뀌는 구조는 JS 로드 시점에 콘텐츠 번쩍임을 만듭니다- 조건부 SSR 렌더링은 하면 안 됩니다.
typeof window === "undefined"로 서버에서만 건너뛰면 동작은 하지만, 서버와 클라이언트의 HTML이 달라져 React가 하이드레이션을 포기하고 CSR로 되돌아갑니다. 우리가 없애려던 바로 그 동작이고, 운이 나쁘면 레이아웃 버그까지 딸려 옵니다. 올바른 방법은 마운트 여부를 상태로 추적해 React 생명주기에 기대는 것입니다 - 서드파티 라이브러리는 도박입니다. 어떤 건 SSR에서 빼면 되고, 어떤 건 클라이언트에서 동적으로 가져와야 하고, 어떤 건 갈아타야 합니다. 상태 관리나 CSS-in-JS처럼 프로젝트의 토대가 SSR과 안 맞으면 특히 고통스럽습니다
3단계: 정적 사이트 생성(SSG)
서버 렌더링이 필요하고 프론트엔드 쪽 대가도 감당하기로 했다고 해봅시다. 그런데 사이트의 모든 정보가 정적이라면 — 사용자 생성 콘텐츠도, 권한도, 요청당 데이터 생성도 없다면 — 케이크를 가진 채로 먹을 수 있는 드문 경우가 됩니다.
질문은 이겁니다. 어차피 결과가 항상 같은데, 빌드 직후에 프리렌더를 끝내지 못할 이유가 무엇인가?
막을 게 없습니다. 평범하게 빌드한 뒤 빈 root div를 프리렌더 결과로 갈아끼우면 dist에 페이지별 HTML이 콘텐츠까지 채워진 채로 생깁니다. 서버가 하던 일 그대로인데 이제 서버가 필요 없습니다. CSR 앱처럼 어디든 올릴 수 있으면서 CSR의 단점은 없고, 검색 엔진과 소셜 공유가 즉시 제대로 동작합니다.
물론 손으로 할 일은 아닙니다. Next.js도 SSG를 지원하고, Gatsby와 Docusaurus가 있고, Astro는 최고의 성능을 내세웁니다.
정리
- SSR의 본질은 성능이 아니라 봇과 크롤러가 JS 없이도 콘텐츠를 볼 수 있게 하는 것이다. 성능 개선은 조건에 따라 뒤집히는 보너스다
- 메타 태그만 프리렌더해도 소셜·검색 문제의 절반은 싸게 풀린다. 대신 서버가 생기고, 초기 로드마다 캐시되지 않는 왕복이 붙는다
- 전체 SSR을 켰다면 하이드레이션은 선택이 아니다. 안 하면 React가 서버가 그린 DOM을 버리고 다시 만든다
- 직접 구현하지 마라. 프레임워크를 써라
- 모든 게 정적이라면 SSG가 답이다. 서버 없이 SSR의 이점만 가져간다