본문 바로가기

Front-End

리액트 개발자를 위한 서버사이드 렌더링 딥 다이브(SSR Deep Dive for React Developers)

반응형

본 게시물은 공부를 위해 아래 게시물을 손수 번역하며 작성한 게시물입니다.

일부 오역, 의역이 있을 수 있습니다.

https://www.developerway.com/posts/ssr-deep-dive-for-react-developers

 

SSR Deep Dive for React Developers

Explore step-by-step how Server-Side Rendering (SSR), pre-rendering, hydration, and Static Site Generation (SSG) work in React, their costs, performance impact, benefits, and trade-offs.

www.developerway.com

 

※ SSR, 프리-렌더링, 하이드레이션, 그리고 정적 사이트 생성(Static Site Generation, SSG)이 React에서 어떻게 구현되는지, 비용이 얼마나 드는지, 성능적인 효과가 어떤지, 장단점을 차근차근 알아가봅시다.

이전 글들*을 통해, 우리는 CSR(Client Side Rendering)의 두 가지 중요한 단점을 배웠습니다: 초기 로딩이 느리고, 자바스크립트가 없는 환경에선 동작하지 않는다. (*옮긴이는 이전 글들을 옮겨두지 않았습니다.)

 

이번 글에서, 우리는 이런 단점을 해결할 방법인, 새로운 렌더링 패턴인 SSR과 그 변형(프리렌더링, SSG)에 집중할겁니다. 예제 프로젝트를 준비해놨는데, 이를 통해 엄청 쉬운 프리렌더링으로 구현된 이쁜 사이트를 만들어보고, 이에 드는 비용, 그리고 무엇을 해결할 수 있는지 알아볼겁니다. 그리고는 같은 사이트를 제대로된 SSR로 구현해보고, 성능적 영향을 측정해보고, SSR의 비용을 얘기해보고, 웹사이트를 위한 SSG 구현을 빠르게 해봄으로써 마무리할겁니다. 재밌겠죠?

 

왜 JS 없는 환경은 중요한가

JS 없는(no-JavaScript) 환경에 대해 먼저 얘기해보죠. 이건 아마 가장 어이없는(Puzzling) 문제요소일겁니다. 요즘 누가 브라우저로 자바스크립트를 비활성화 하죠? 어디서든 자바스크립트는 활성화되어있고, 그거 없이는 꽤 많은 것이 동작하지 않을 것이며, 대부분은 JS를 비활성화하는게 뭔지조차 모를겁니다. 그렇지 않나요?

 

정답은 "사람들"이라는 단어에 있습니다. 더 정확하게는, 당신의 웹사이트를 접근할 수 있는게 "진짜 사람"뿐이 아니라는 겁니다. 이런 영역에 있는 두 주요 접근자들은 다음과 같습니다:

  • 검색엔진 로봇(크롤러), 특히 구글 크롤러
  • 다양한 소셜미디어와 메신저의 "미리보기" 기능

둘 다 비슷한 방식으로 동작합니다.

첫째, 그들은 어떤 방식으로든 당신 페이지의 URL을 얻습니다. 보통은 유저가 친구들에게 당신 페이지를 공유하려고 할때, 또는 탐색 로봇이 아무 생각없이 수백 수천만개의 접근 가능 페이지들을 크롤링할때 일어나게 됩니다. 그것들이 크롤러라고 불리는 이유기도 하죠.

 

둘째, 봇들은 서버에 요청을 보내 HTML을 받아옵니다. 브라우저가 시작할때 하듯이요.

 

셋째, 그 HTML로부터, 봇들은 정보를 추출하고 처리합니다. 검색 엔진들은 문자, 링크, 메타-태그 등을 뽑아내죠. 이를 기반으로, 검색 인덱스를 구성하고, 그 사이트가 "구글링 가능(googleable)"해지는 겁니다. 소셜 미디어의 미리보기 기능은 메타 태그들을 이용해 우리가 봐왔던 멋진 미리보기를 생성합니다. 큰 사진, 제목, 그리고 어떤 경우엔 짧은 설명까지 이용해서요.

 

그리고 마지막 네번째로... 사실 네번째가 없는 경우도 있습니다. 그게 전부인거죠. 자바스크립트 없이 순수한 HTML. 자바스크립트를 이용해 페이지를 제대로 렌더링한다는것은 실제 브라우저를 가동시켜서, JS를 로드하고, 페이지 생성 완료까지 기다려야된다는 걸 의미합니다. 자원과 시간 측면에서 꽤나 비용이 든다는거죠. 따라서 모든이가 그걸 다 할 수 있는건 아닙니다.

 

한 줄 요약:

소셜미디어의 미리보기 기능 = URL 얻음 -> 요청 보냄 -> HTML 받음 -> HTML 가공&처리함

 

예제를 통해 확인해볼 수 있습니다. 다운받아서 dependency들을 설치해보세요.

npm install

그리고 빌드하고 시작해보세요.

npm run build
npm run start

"Home"과 "Settings" 메뉴를 번갈아 왔다갔다 해보세요. 페이지의 제목이 바뀌는걸 볼 수 있습니다. "Home"에서는 "Study project: Home"이고, "Settings"에서는 "Study project: Settings"이죠.

예제 실행 화면 (옮긴이가 캡쳐함)

이 제목은 React를 이용해 이런 간단한 코드로 삽입되었습니다.

useEffect(() => {
  updateTitle('Study project: Home');
}, []);

그 안에는 이거 밖에 없죠.

export const updateTitle = (text: string) => {
  document.title = text;
};

그러나, 처음 로딩될때 잠시 깜빡이는 것 또한 봤을겁니다. 왜냐하면 이건 기본 제목이 "Vite + React + TS"로 되어있기 때문이고, 이건 index.html에 지정되어 있죠. 결론적으로는, 이게 제가 서버로부터 받는 제목이라는 겁니다.

 

이제, 웹사이트를 ngrok(또는 당신이 사용하는 비슷한 툴로) 바깥 세상에 선보여봅시다.

ngrok http 3000

당신이 원하는 소셜 미디어에 생성된 URL을 게시해 보세요. 생성된 미리보기에서 이전의 "Vite + React + TS" 제목을 볼 수 있을 겁니다. JavaScript는 로드되지 않았죠.

카카오톡으로 링크를 공유했을때 보이는 제목

그럼에도 불구하고, 이는 일부 봇에게는 해당되지 않습니다. 대부분의 유명 검색 엔진들은 자바스크립트를 기다립니다. 예를 들어, 구글은 두 개의 단계를 거치는데, (1) "순수" HTML을 파싱하고, (2) 페이지를 "렌더"큐에 넣은 다음, 브라우저를 실행해 거기서 사이트를 로드하고, 자바스크립트 렌더링을 기다려서 추출 가능한 모든것을 추출해내죠.

 

세 줄 요약:

구글 크롤러의 기능 = URL 얻음 -> 요청 보냄 -> HTML 받음 (이 다음 두 가지로 나눠짐)

(1) HTML 가공&처리

or

(2) 렌더 큐에 집어넣고 실제 브라우저로 자바스크립트 렌더 기다림 -> 렌더된 HTML 추출 -> HTML 가공&처리

 

그러나, 이 과정은 자바스크립트에 전적으로 의존하는 웹사이트 인덱싱이 "느리고 비싸다"는 것을 의미합니다.

 

따라서 만일 당신의 웹사이트가 다음과 같다면:

  • 최대한 많은 검색 엔진에 최대한 빨리 노출되는 것이 중요하다.
  • 소셜 미디어에 공유되는 것이 중요하고, 그 과정에서 보기 좋아야한다.

서버가 "제대로 된" HTML을 보내주는 것이 중요할겁니다. 첫 요청에 모든 중요한 정보를 담아서 말이죠.

그런 웹사이트들에 대한 전형적인 예시는 다음과 같습니다:

  • 읽기-우선 웹사이트 (다양한 형태의 블로그, 문서, 지식 기반, 포럼, Q&A, 뉴스 등등)
  • 다양한 형태의 쇼핑몰 사이트
  • 랜딩 페이지 (처음 도달하는 페이지)
  • 등등 World Wide Web에서 검색가능한 대부분의 것들.

그렇다는건 "기존 방식으로(classic)" 클라이언트 단에서 렌더링되던 SPA(Single Page Application)가, 그것도 첫 HTML 응답에선 빈 div를 가지는 그런 상황이, 여기엔 적합하지 않다는 겁니다.

 

그러나, 이 때문에 우리가 화나서 리액트를 던져버려야한다는건 아닙니다. 우리가 시도해볼 수 있는 방법이 있습니다.

서버 프리랜더링

이것을 위해서, 우린 서버에 공식을 도입해야합니다. 현재, 예제 프로젝트에선 이렇게 생겼습니다.

app.get('/*', async (c) => {
  const html = fs
    .readFileSync(path.join(dist, 'index.html'))
    .toString();

  return c.html(html);
});

서버가 어떤 요청을 받으면, 서버는 그저 index.html을 읽어서 빌드 과정을 미리 거치고, 문자열로 바꿔서, 요청한 누군가에게 보냅니다. 이게 기본적으로 SPA 호스팅을 지원하는 플랫폼들이 해주는 것입니다. 이와 같은 플랫폼을 직접 제어하거나 수정하는 것은 아닙니다.

 

그러나, "자바스크립트 없음" 문제를 해결하기 위해, 우리는 이제 서버를 수정해야합니다. 다행히 조금만 고치면됩니다. 우리가 다루는게 고작 문자열이라는 것이 문제를 간단하게 만들어줍니다. 왜냐하면 아무도 우리가 응답 전에 문자열 수정하는 것을 못막거든요. 예시로 현재 제목을 찾아 "Study project"라고 바꿔봅시다.

app.get('/*', async (c) => {
  const html = fs
    .readFileSync(path.join(dist, 'index.html'))
    .toString();

  const modifiedHTML = html.replace(
    '<title>Vite + React + TS</title>',
    `<title>Study project</title>`,
  );

  return c.html(html);
});

조금은 나아졌지만, 현실에서 제목은 매 페이지마다 바뀌어야 합니다. 저렇게 정적(static)으로 두는 것은 의미가 없습니다.

운 좋게도, 각 서버는 요청이 어디서 왔는지 항상 정확히 알고 있습니다. 제가 사용하는 프레임워크(Hono)에서는 c.req.path를 이용해서 추출이 가능합니다.

이후에는, 그 경로를 기반으로 다른 제목을 만들어낼 수 있죠.

app.get('/*', async (c) => {
  const html = fs
    .readFileSync(path.join(dist, 'index.html'))
    .toString();

  const title = getTitleFromPath(pathname);

  const modifiedHTML = html.replace(
    '<title>Vite + React + TS</title>',
    `<title>${title}</title>`,
  );

  return c.html(html);
});

getTitleFromPath 는 다음과 같습니다.

const getTitleFromPath = (pathname: string) => {
  let title = 'Study project';

  if (pathname.startsWith('/settings')) {
    title = 'Study project: Settings';
  } else if (pathname === '/login') {
    title = 'Study project: Login';
  }

  return title;
};

현실에서, 이건 실제 페이지에 맞춰 존재하거나 페이지 코드 그 자체에서 추출되어야합니다. 그렇지 않다면, 곧 동기화되지 않을겁니다. 그러나 예제 프로젝트에선, 이것으로 충분합니다.

이것을 이쁘게 만들기 위해 하는 마지막 한 가지가 있습니다: index.html 파일에서, <title>Vite + React + TS</title>을 <title>{{title}}</title> 같은 걸로 바꾸고, 템플릿화 하는겁니다.

<html lang="en">
  <head>
    <title>{{ title }}</title>
  </head>
  ...
</html>;

// on the server then do this instead:
const modifiedHTML = html.replace('{{title}}', title);

나중에, 필요하다면 우리는 이것을 템플릿화하는 언어로 바꿀 수 있습니다.

 

그리고, 물론, 우리는 title 태그에만 국한되지 않습니다. <head>에 있는 모든 정보를 이렇게 프리랜더링 가능하죠. 이것은 우리가 소셜미디어 미리보기 기능에 생긴 "자바스크립트 없음" 문제를 비교적 쉽고 저렴하게 해결할 수 있게 합니다. 미리보기 기능엔 이정도면 충분하죠. 대부분의 미리보기는 Open Graph Protocol에 의존하는데, 이는 그저 <meta> 태그 덩어리와 정보들 뿐입니다.

 

우리는 메타 태그들뿐 아니라 심지어 전체 페이지를 프리랜더할 수도 있습니다! 그러나 그건 아래에 있는 별도의 SSR 부분에서 다룰것이고, 배울게 많습니다.

더보기

도전 과제

1. backend 폴더에서 index 파일의 내용을 backend/pre-rendering-index.ts 파일 내용으로 바꿔보세요.

2. src/index.html 파일에서 title 태그의 내용을 {{ title }} 로 바꿔보세요.

3. frontend와 backend 코드를 리팩토링해서 소셜미디어 공유에 필요한 메타 태그들을 지원하도록 해보세요.(목록은 여기 있습니다)

4. 프로젝트를 빌드하고, ngrok으로 다시 배포한다음, 각 페이지(로그인, 홈, 설정)을 공유해보세요. 미리보기는 잘 동작하고 각 페이지의 내용을 보여주어야 합니다.

5. 보너스 문제: 당신은 클라이언트와 서버간 메타 태그 정보가 중복되지 않는프로젝트를 어떻게 리펙토링하시겠습니까?

서버를 프리랜더링하는 비용

앞서 제가 메타 태그들을 프리랜더링하는 비용은 상대적으로 저렴하다고 했습니다. 근데, 이게 정확히 무슨 말일까요? 이걸 도입하기 전 가격과 노력에 비교했을 때 얼마나 싼걸까요?

유감스럽게도, 완벽히 정적인 SPA와 비교했을 때 좋은 점이 없습니다. 간단한 프리랜더링 스크립트를 추가함으로써, 제가 이미 소개한 두 개의 문제와 더불어, 앞으로 소개할 "복잡함 증가"라는 문제를 가져왔죠.

어디에 배포하죠?

첫 번째 문제는, 이제 앱을 어디에 배포해야되냐는 겁니다. 변경 전에는, 오랜 시간동안 호스팅 비용 0원으로 호스팅을 요지할 수 있었습니다 - 오늘날 정적 자원을 호스팅하는건 매우 저렴합니다. 이제는, 서버가 필요합니다. 그리고 이건 보통 저렴하지 않죠.

 

여기엔 보통 두 가지 해결책이 존재합니다.

 

정적 자원을 제공하는 호스팅 제공자의 서버리스 기능들을 이용할 수 있습니다: Cloudflare Workers, Netlify Functions, Vercel Functions, Amazon Lambdas 등등. 대부분의 정적 자원 호스팅 제공자들은 여러 형태로 이를 가지고 있습니다.

 

이 방식의 장점은 우리가 여전히 서버와 그 유지보수에 신경쓸 필요가 없다는 겁니다. 이런 클라우드 기능들은 제공자들이 우리에게 주는 작은 서버와 같습니다. 우리가 할 일은 코드를 작성하는 것이고, 마법처럼 그저 동작합니다. 모든 것은 그들이 관리합니다. 공부용 프로젝트, 일부 틈새 프로젝트, 여정의 초창기에 있는 프로젝트, 그리고 바이러스 특성을 가지지 않은 프로젝트들에게, 클라우드 기능은 최적의 선택일 겁니다.

 

클라우드 기능들은 대체로 구성 및 배포가 매우 쉽고, 사용량당 가격이 책정되며, 실제로 엔드포인트에 도달할 때(= 특정 주소나 API가 호출될 때) 사용됩니다. 실수로 주말에 컨테이너를 실행해두어서 예상치 못한 가격이 청구될 일도 없죠.

 

단점은, "사용량 당 가격"입니다. 웹 사이트가 유명할수록, 사용량이 제한양을 초과할 가능성이 높아집니다. 저는 프로젝트가 HackerNew나 TikTok에서 유명해지더니 갑작스럽게 방문자가 평소 수백에서 수백만으로 늘어, 눈 떠보니 5000달러가 찍힌 청구서를 받게되었다는 무서운 이야기를 들어본 적이 있습니다. 따라서, 사용량 제한을 설정하고, 사용량을 면밀히 모니터링하고, 이런 상황이 오면 어떻게 할지 계획을 세워두는 것은 서버리스 해결책을 사용할때 필수적입니다.

 

만일 서버리스 기능을 사용할 수 없다면, 당신은 작은 node (또는 다른 어떤것으로든) 서버를 구성해서 아무 클라우드 플랫폼에 배포해도 됩니다. (AWS, Azure, Digital Ocean 등등)

 

이 방법이 가지는 장점은, 모든걸 당신이 통제할 수 있다는 겁니다. 한 해결책으로부터 다른 해결책으로 옮겨가는 것에는 코드 변경이 필요하지 않습니다. 일부 벤더 종속적인 서버리스 함수와는 다르게 말입니다. 가격은 대체로 더 예상 가능하고, 더 쉬우며, 사용량이 늘어도 더 낮습니다. 또한, 당신은 당신이 원하는 기술 스택을 아무거나 사용해도 되는데, 이는 일반적으로 매우 제한적인 서버리스 기능과는 대조적입니다.

 

단점은 장점과 정확히 일치합니다. 모든 것이 당신에게 달려있습니다. 당신은 CPU/메모리 사용량을 모니터링해야합니다. 관찰 가능성(observability)에 대해 걱정해야하고, 스케일링에 대해 걱정해야합니다. 메모리 누수는 밤새 당신을 괴롭힐 겁니다.

 

그리고 당신은 지리학적 지역에 대해서도 고려해야 합니다. 이전의 순수한 SPA 앱에 어떠한 종류의 서버든 도입함으로써 생기는 두번째 문제로 이끌것이기 때문이죠.

서버를 가짐으로써 생기는 성능 영향

초기 로딩 아티클, 그리고 레이턴시 및 CDN이 초기 로딩에 끼치는 영향을 기억하시나요? 메타 태그들만을 프리랜더링하는 기초적인 서버만을 도입하더라도, 서버는 모든 초기 로딩 요청에 필수적이고 캐시되지 않고 피할 수 없는 왕복(요청-응답)을 일으키게 됩니다. 유저가 새롭든 아니든 상관없이 말입니다.

 

저는 방금 SPA의 초기 로딩 성능을, 처음에도 그렇게 좋진 않았지만, 더 나쁘게 만들었습니다. 그리고 서버가 정확히 어디에 배포되느냐에 따라 얼마나 더 나빠질지가 결정됩니다.

 

만일 그게 서버리스 기능들 중 하나로 배포되었다면, 그닥 나쁘지 않을 가능성이 있습니다. 일부 제공자들은 그 기능들을 "끝단에" 실행할 수 있기 때문입니다. 즉, 이 기능들은 엔드 유저에 가까운 곳의 다른 서버들에 분산되어있습니다. 정적 자원들을 위한 CDN과 꽤 유사하죠. 이런 경우, 레이턴시는 최소화되고, 성능 감소도 최소화될 것입니다.

 

그러나, 만일 혼자 관리하는 서버를 쓴다면, 분산 네트워크의 장점이 없습니다. 한 특정 지역에 배포할 것입니다. 따라서, 지구 반대편에 있는 사용자는 성능 감소의 영향을 정말로 느낄겁니다.

 

만일 이 영향이 크다면, 어떻게든 대응해야합니다. 복잡한 캐싱 전략을 준비하고, 다른 지역에도 배포하는 등 말이죠. 근본적으로, 이건 더 이상 단순히 서버 없는 프론트엔드 앱이 아닙니다. 이제는 풀스택이나 백엔드-우선인 앱인거죠.

Vercel/Netlify에 Next.js

머릿속에 떠오르는 의문이 하나 있습니다: "난 그저 Next.js로 프론트엔드 짜서 Vercel/Netlify로 배포중인데, 이런것까지 알아야해?"

 

이에 대한 답변은, "불행하게도, 네. 당신은 벗어날 수 없습니다." 라는 겁니다. 왜냐하면, 이것이 Next.js-우선 호스팅 제공자들이 기본적으로 하는 일이기 때문입니다: 그들은 당신의 앱을 JS 파일들과 작은 서버리스 기능들 꾸러미로 변환합니다. 이건 당신의 제어와 상관없이 일어나고, 심지어 당신이 모르게 일어나죠.

 

따라서 당신이 Next.js 프로젝트를 "정적"으로 내보내도록 명시적으로 설정해놨다고 하더라도, "서버를 프리랜더링 함으로써 생기는 비용"은 생겨납니다.

더보기

도전 과제

1. 만일 당신이 Vercel/Netlify 같은 서버리스 플랫폼에 "네이티브하게" 배포된(즉, 원클릭 배포된) Next.js 앱이 있다고 할때, 얼마나 많은 기능들이 그것을 위해 생겼는지 확인해보세요.

2. 그것들은 "끝단의(edge)" 기능들인가요 아니면 평범한 기능들인가요? 당신은 그것들을 위해 사용량이 얼마나 책정되는지 확인 가능한가요? 당신이 제한을 넘기전 웹사이트는 얼마나 많은 방문자를 수용할 수 있나요?

3. 만일 당신이 "평범한" 기능과 "끝단의" 기능을 모두 갖고 있다면, 어느 기능이 무엇을 위한 것인지, 그리고 당신의 배포된 프로젝트에 끼치는 영향이 무엇인지 대응시킬 수 있나요?

서버의 전체 페이지를 프리랜더링하기(SSR)

프리랜더링에 대해 좀 더 이야기해보죠. 앞선 내용에서, 우리는 메타 태그들만 프리랜더링했습니다. 왜냐하면 기존의 문자열을 다른 문자열로 바꾸는건 쉬웠기 때문이죠. 하지만 <head> 태그 너머를 다루기 전에 우리를 멈춘 이유는 뭐였을까요? 우리 서버가 보낸 HTML 페이지의 <body> 태그 내용을 한번 봐보죠.

<body>
  <div id="root"></div>
  <script type="module" src="./main.tsx"></script>
</body>

클라이언트 사이드 렌더링이 어떻게 동작하는지 기억하시나요? 스크립트가 다운로드되고 처리되면, React는 root 요소를 가져와 생성된 DOM 요소를 삽입합니다. 그렇다면 빈 div 말고 어떤 내용이 있는 div를 안에 넣는다면 어떻게 될까요? 큰 빨간 블록을 만들어보죠.

<div id="root">
  <div style="background:red;width:100px;height:100px;">
    Big Red Block
  </div>
</div>

index.html 에 추가하고, 빌드해서, 실행해봅시다. 더 나은 관찰을 위해 캐시를 삭제하고 CPU와 네트워크 속도를 낮추는 걸 잊지 마세요!

 

당신이 페이지를 새로고침하면, 찰나의 크고 빨간 블록이 스쳐지나가는게 보일겁니다. 그리고는 평범한 대시보드 페이지로 바뀌겠지요. 한 가지 좋은 소식은 그 빨간 블록이 남아있지 않다는 겁니다 - 분명히, React는 "root" div를 그 안에 무언가 넣기 전에 한 번 비운다는거죠. 또는 위에 덮어씌우던가요. 이 글에선 그닥 중요하지 않습니다.

 

두 번째 좋은 소식은 성능 그래프에서 나옵니다. 지금 녹화해두세요. 결과는 다음과 같을 겁니다.

출처: https://www.developerway.com/assets/ssr-deep-dive-for-react-developers/3.red-block-performance-20250212-055120.png

이것들의 순서와 타이밍에 특별히 주의해봅시다.

 

초반에는 우리가 봐왔던 그래프와 동일합니다. 먼저, 서버로부터 HTML을 기다리고 이는 "main" 섹션에서 파란 HTML 파싱 블록으로 나타나죠. 그게 CSS와 JS(각각 "Network" 안에 노란색과 보라색 블록)의 다운로드를 일으킵니다.

 

그러나 CSS 다운로드가 끝나면, 다른 일이 일어나기 시작합니다. 먼저, 파란색 HTML 블록과 동일한 레벨에 보라색 "레이아웃" 블록이 다소 더 길게 표시됩니다. 이건 전에 일어난적 없어요! 이게 끝난 직후에, FCP(First Contentful Paint)가 발생합니다. 그러나 위의 JS 막대는 여전히 로딩중이죠! 이후로는 기존처럼 계속됩니다. JS 로딩이 끝나고, 처리되고, 페인트 되고, LCP(Large Contentful Paint)가 발생하죠.

 

여기서 프레임들의 스크린샷이 나오는 최상단 섹션에 마우스를 올리면, FCP와 LCP 사이의 간격이 바로 우리의 빅 레드 블록이 페이지에 존재했던 시기임을 알 수 있습니다. FCP와 LCP 사이 간격은 500ms였습니다. FCP는 800ms, LCP는 1.3초 였죠.

 

이 500ms가 클라이언트 사이드 렌더링의 초기 로딩을 위한 큰 비용으로 보입니다. 이건 너무 커요! 만일 제가 어떻게든 관리해서 LCP에서 500ms를 줄인다면, 40% 향상시킨겁니다! 이걸로 승진할지도요?

 

다행히, 모든것은 가능한 얘기입니다. React는 전체 앱을 프리랜더링할 수 있는 몇가지 방법을 제시하고 이로써 우린 이론적으로 사용가능합니다. 예를 들어, "renderToString" 이라는 것을 이용해 공식문서를 따라하면 우리 앱을 문자열로 렌더링할 수 있죠.

const App = () => <div>React app</div>;

// somewhere on the server
const html = renderToString(<App />); // the output will be <div>React app</div>

우리가 이미 서버에 문자열로 처리하고 있었기 때문에, 이건 완벽해 보입니다. 제가 해야할 건 빈 "root" div를 이 함수의 반환값으로 대체하는거죠. 메타 태그를 그렇게 했던것처럼 말이죠. 해볼까요?

 

backend/index.ts에 가봅시다. 그리고는 이전에 했던 모든 변경사항을 지우고, 주석 처리된 코드를 찾으세요.

// return c.html(preRenderApp(html));

주석을 푸세요. 성능을 다시 한번 녹화해봅시다. 결과는 다음과 같을 겁니다.

출처: https://www.developerway.com/assets/ssr-deep-dive-for-react-developers/4.content-pre-rendering-20250212-055231.png

차이가 즉시 보입니다: FCP와 LCP가 동시에 일어났어요. React가 생성한 main JS가 실행되기 전 그리고 심지어 JS가 로딩도 되기 전에 말이죠. 이건 내용물 프리랜더링이 효과가 있다는걸 의미합니다!🎉 행복하네요☀️☺️. 스크린샷의 맨 위에 마우스를 올리면 이쁜 대시보드가 실제로 그 이후 나타났다는 것을 볼 수 있죠.

 

그러나, 아주 조금 이상한 점이 있습니 - FCP는 제가 약속한 것보다 늦었어요. 800ms에 보길 원했지만 실제론 900ms 근처였죠. 모든 성과에서 반복되는 교훈이 있습니다: 정확한 수치를 미리 약속하지 마세요😅. 하지만 제 100ms 어디갔죠?

 

가장 먼저, 좌측 최상단, "Network" 섹션을 보면, 서버로 보낸 첫 요청을 볼 수 있습니다. 파랗게 칠해진 막대가 보이시나요? 이게 우리 HTML 내용이 다운로드 되는겁니다. 우리는 더 많은 요소를 보내고 있습니다. 빈 <div>뿐이 아니죠. 블록 위에 마우스를 올려 정확한 수치를 보세요 - 100ms의 1/3 정도가 다운로드 되는데 쓰일 겁니다.

 

그리고 또, "Parse HTML" 작업 뒤에 오는 보라색 "Layout" 블록을 보세요. 더 길어보이지 않나요? 정확한 숫자를 보기 위해 마우스를 올려보면 - 100ms의 2/3 정도가 여기 있습니다. 브라우저는 추가적인 HTML을 다운 받았을 뿐더러, 그 많은 요소들을 그리기 전 위치 계산하기도 해야했던 겁니다.

 

이런 이유로 시간이 추가된거죠. 하지만 여전히 가치가 있지 않나요? 저는 LCP 시간을 400ms 줄였고 초기 로딩 성능을 30% 향상 시켰죠! 그리고 여기 또 다른 멋진 점이 있어요: JS를 비활성화하고 페이지를 새로고침 해보세요. 대시보드가 여전히 있죠! 그리고 심지어 링크들도 동작합니다. 전체 리로드를 해야되긴 하지만요.

 

이부분은 SSR을 보람차게 만드는 부분입니다. 이제, 당신이 페이지에 접근하게 하고 싶은 모든 검색 엔진과 로봇은 JS 로딩 없이 모든걸 볼겁니다. 성능 향상은 덤이구요. 불안정하긴 하지만요.

SSR은 초기 로딩을 악화시킬 수 있다

불안정한 것, 그건 성능에 있어서 만능 해결책이 없기 때문입니다. 만일 누군가 SSR이 당신의 SPA 앱의 초기 로딩을 100% 향상 시켜준다고 한다면, 실수하는 겁니다. 이제 당신은 네트워크 상태, CSR, SSR이 동작하는지 알죠. SSR이 LCP를 악화시키는 상황에 대해 생각해볼 수 있습니까?

 

다음과 같습니다.

 

CPU 쓰로틀링을 끄고, 당신의 기기가 빨라지게 해보세요. 네트워크를 최대한 느리게 하구요. 저에게는, 기본적인 크롬 3G면 효과가 있지만, 당신은 더 느려야 될 수도 있습니다 - 당신의 기기가 얼마나 빠른지에 따라 다릅니다. "캐시 비활성화"를 해제하세요. 저는 이 CSS/JS 파일들이 브라우저 메모리로부터 오길 바랍니다.

 

이제 LCP를 프리랜더링 있을 때 그리고 없을 때 측정해봅시다.

 

저는 결과가 다음과 같았습니다. 프리랜더링 없이, "SPA"모드에선, LCP가 2.13 초였습니다. 프리랜더링을 할때, "SSR"모드에선, 2.62초였죠. 거의 500ms 느렸어요!

 

성능 차트들은 이 상황을 보는데 도움을 줍니다. "SPA" 모드는 다음과 같죠.

출처: https://www.developerway.com/assets/ssr-deep-dive-for-react-developers/5.slow-network-fast-cpu-spa-20250212-224305.png

처음에, 네트워크 섹션에서 서버의 응답을 기다리는 기이이이인  블록(2초)이 있습니다. 이게 느린 네트워크 연결의 레이턴시죠. 그리고는 거의 바로 JS와 CSS에 접근합니다: 그들은 네트워크가 아닌, 브라우저 캐시에서 왔습니다. 추가로, HTML 내용을 거의 바로 다운받습니다 - 빈 div죠. 그리고는 평범하고 꽤 빠르게, CPU는 느려지지 않았기 때문에, JS가 실행됩니다. 이게 우리 React가 페이지를 만드는 과정입니다. 그리고는 페이지가 보이죠.

 

이제 같은 네트워크/CPU 상황에서 SSR 모드입니다.

출처: https://www.developerway.com/assets/ssr-deep-dive-for-react-developers/6.slow-network-fast-cpu-ssr-20250212-225224.png

동일한 초기 로딩 시간 - 레이턴시는 어디 가지 않았습니다. 그리고는 HTML을 받기 시작하죠. 하지만 이번엔 내용이 많으므로, 다운로드에 기이이이이이이인 시간이 걸립니다 - 대역폭이 매우 줄었어요.

 

그리고 매우 흥미로운 부분이 있습니다: 내용이 다운되는 와중에, 우리는 메인섹션에서 활동의 급증을 볼 수 있습니다. 확대해서 마우스를 올려보세요 - 그건 대부분 레이아웃 작업입니다. 브라우저는 이미 CSS와 JS를 다운받아놔서 (캐시로부터), 레이아웃을 그리는데 필요한 모든 정보가 있는거죠. 아무리 작은 요소라도 말이에요. 실제로 그리는겁니다.

 

당신은 이 페이지에서 인터페이스들의 순차적인 생성을 수 있어야 합니다. 먼저 사이드바가 나타나고, 상단 네비게이션, 그리고 상단 차트, 그리고는 표가 나타나죠. 이 모든건 HTML의 순서로 정해져 있고 천천히 오게 됩니다. 이게 안 멋있으면 뭐가 멋있을까요?

 

이게 이상한 엣지 케이스 같지만, 그렇지 않습니다. 느린 네트워크 + 큰 레이턴시 + 빠른 노트북의 조합은 출장러들에게서 꽤 흔히 볼 수 있죠. 자연 사진 작가들이나, 여행 블로거들에게서도요. 그래서 만약 당신의 앱이 주로 특정 틈새 시장을 대상으로 하고 있고, 이미 SPA로 개발되어 있다면, SSR을 도입하려고 하는 것이 오히려 더 나쁜 결과를 초래할 수 있습니다.

 

물론, 아닐수도 있습니다. 이건 다운받는 HTML의 사이즈, 기기가 얼마나 빨리 다운받는지, 앱이 얼마나 많은 JS를 렌더해야하는지에 따라 다릅니다. 필수적으로, 이건 결국 두 가지로 귀결됩니다: 당신의 고객에 대해 알고, 측정, 측정, 또 측정이죠.

SSR과 하이드레이션(hydration)

내용물을 더 빨리 보여주는데 빠져서, 내용물이 로드되고 어떻게 되는지 탐구하길 잊고 있었네요.

 

큰 빨간 블록 특성을 기억하나요? 리액트가 자신의 요소들을 로드하고 생성한 후, "root" div의 내용물과 그 안을 전부 바꿔버렸죠. 큰 빨간 블록도 말이에요. 그런데 이상한 빨간 블록 대신 미래 페이지의 실제 HTML을 보낸다면 어떻게 될까요?

 

아무일도 일어나지 않습니다. 저는 리액트에게 이 내용이 중요하다고 말한적이 없고, 동일한 방식으로 동작할 겁니다: "root" div의 전체 내용을 비우고 자신의 것으로 바꾸겠죠. HTML 관점에서 완전 동일한 내용으로 일어나기 때문에, 우리는 눈으로 차이를 발견하지 못할겁니다.

 

하지만 우리는 성능 프로필에서 확인해볼 수 있죠. CPU와 네트워크를 늦추고 SSR 예제를 다시 한 번 녹화해봅시다. CSS와 JS가 수신되고 어떻게 되는지 집중해보세요.

출처: https://www.developerway.com/assets/ssr-deep-dive-for-react-developers/7.ssr-no-hydration-20250213-011313.png

왼쪽 상단에, 네트워크 섹션이 있고, 거기서 자원들이 다운로드되었습니다. CSS가 수신된 직후, 큰 보라색 "Layout" 섹션이 아래 있는 것을 봤습니다. 그때, 우리의 SSR된(SSR'd) 콘텐츠가 표시됩니다. 왼쪽 상단 JS 노란 블록의 로딩이 끝나면, 리액트가 동작하기 시작합니다. 다소 긴 작업(180ms)은 리액트가 UI를 구축하는 과정입니다. 오른쪽 하단에서, 다시 작은 'Layout' 블록을 볼 수 있습니다.

 

이건 우리가 익히 봐왔던 CSR의 전형적인 모습입니다. 리액트가 "root" div를 비우고 생성된걸 대신 삽입하는거죠. 그리고 이는 완전 불필요합니다. 리액트는 이미 DOM 요소들이 존재하고, 그들을 재사용할 수도 있었습니다. 물론, 그게 더 빠르겠죠.

 

이제 "하이드레이션"이라 불리는게 나옵니다. "하이드레이션"은 제가 앞서 원한걸 정확히 수행합니다 - 리액트에게 앞으로 만들 HTML이 페이지에 이미 있다는걸 보여줍니다. 따라서 리액트는 이미 존재하는 DOM 노드들을 재사용해서, 이벤트 리스너들을 붙이고, 내부적으로 필요한 기능 준비하고, 작업을 마치죠. 불필요한 컴포넌트를 처음부터 다시 마운트하지 않아요!

 

리액트에서 하이드레이션하는건 매우 쉽습니다. 이번에는요. 함수 하나만 호출하면 되죠. 우리가 해야할 건 그저 createRoot 엔트리포인트를 이렇게 바꿔주는겁니다.

hydrateRoot(
  document.getElementById('root')!,
  <StrictMode>
    <App />
  </StrictMode>,
);

이 코드를 src/main.tsx 에서 볼 수 있습니다 - creatRoot 부분을 주석 처리하고 하이드레이션 부분을 주석 해제하세요. 그리고 재빌드, 재시작하세요.

npm run build
npm run start

성능을 재측정해봅니다.

출처: https://www.developerway.com/assets/ssr-deep-dive-for-react-developers/8.ssr-with-hydration-20250213-013318.png

리액트 관련된 JS 실행에 더 이상 보라색이 없습니다. 그리고 이제 조금 더 빨라졌죠 - 180ms에서 142ms가 됐어요.

이전에 LCP가 발생했던걸 고려하면 중요해 보이지 않을 수 있어요. 하지만 항상 이 상태가 지속되지는 않을겁니다.

 

예를 들어, '네트워크 캐시 비활성화'를 해제하고 네트워크 쓰로틀링을 제거하면서 CPU는 낮게 유지해 보세요. 빠른 인터넷을 사용하지만 느린 장치로 반복된 방문자를 에뮬레이션하는 겁니다. 제 경우, 하이드레이션이 없으면 FCP와 LCP가 분리되고 LCP는 JS 작업이 끝날 때까지 밀려납니다. 이 경우 LCP는 약 550ms입니다. 하이드레이션을 활성화하면 LCP가 FCP와 가까워지고, JS 작업이 시작되는 바로 그 시점인 약 280ms로 유지됩니다.

 

또한, 메인 스레드를 차단하는 문제와 이를 가능한 한 줄이는 것이 중요한데, 하이드레이션이 이 부분을 돕습니다. 또한, 하이드레이션은 단지 JS 리스너에 관한 것만이 아닙니다. 하이드레이션은 앱에 일부 초기 데이터를 가져와서 주입할 수 있게 해주므로, 로딩 스피너나 콘텐츠가 깜박이는 현상을 피할 수 있습니다. 그러나 그 부분은 아마 미래의 다른 글에서 다룰 것입니다.

SSR을 이렇게 구현해야 하나요?

이제 SSR은 특정 경우에 대해 매우 매우 유용하다는게 명백해졌습니다. 이걸 구현하는건 조금 하찮아 보이기에, 이런 생각이 들수도 있겠죠: 그냥 예제 프로젝트에 있는 코드 가져다가 내 SSR로 쓰면 되는거 아닌가?

 

블로그에서 흔히 나오지 않는 답변인데, "절대 안됩니다!"  이 방법은 공부의 목적, 즉 프리랜더링된 내용물이 어떻게 동작하는지 하나를 껐다 켰다 하면서 다양한 관점에서 살펴보는데 적합합니다.

 

그러나 사실 이건 전혀 하찮지 않습니다. 저는 이게 동작하게 하기 위해 제가 한 것들중 절반을 숨겨놨습니다. 절반은 아직 구현되지 않았습니다. 백엔드쪽이 너무 기본적이고 거의 구식이기(deprecated) 때문에 최신 리액트 기능을 지원하지 않는 것이죠.

 

가장 먼저, 여기 dev 서버에는 SSR이 없습니다. 따라서, 프로젝트를 즉시 재빌드하는 것 외에는 SSR을 디버깅할 방법이 없습니다. 이게 당신이 변경 사항을 적용할 때마다 재빌드해야하는 절반의 이유입니다. (다른 절반은 성능이 프로덕션 빌드에서 항상 측정되어야 하기 때문이고, 그것에 대해선 특별히 죄책감을 느끼지 않습니다.)

 

당신이 즉시 새로고침(hot reload) 같은 멋진 기능을 원한다면, 스스로 구현해야 합니다. 여기 Vite와 SSR을 통합시킬 수 있는 전체 명령어 모음이 있습니다. 그리고 그건 Vite이기 때문에, Webpack은 매우 다를 것이고 문서가 잘 정리되어있지 않을겁니다. 이외에도 다른 색다른 것이라면, 저는 어디서부터 시작할지도 모르겠습니다.

 

다음으로, 리액트 문서에서 보여주었던 이쁜 문장 const html = renderToString(<App />);은 가짜이고, 실제로는 동작하지 않을 겁니다. 문제는 여기 <App />에 있습니다. 이건 JSX이고, 리액트 코드를 작성할때 대부분 사용하는 방식이기 때문에, 지극히 정상처럼 보입니다. 하지만 이게 동작하는 이유는 순전히 당신의 빌드 시스템이 변형 과정을 거치기 때문이고, Babel(아닐 수도 있지만, 도구가 필요하긴 합니다.) 덕분일겁니다. "순수한" node 나 다른 서버 프레임워크는 이를 지원하지 않습니다.

 

backend/pre-render.ts를 보면 실제로 어떻게 구현되었는지 알 수 있습니다.

 

첫째로, 저는 변형된 App 코드를 Vite로부터 추출해냈습니다.

const { default: App } = await vite.ssrLoadModule('@/App');

만일 당신이 Webpack을 쓰는 중이라면, 직접 이를 위한 Babel 플러그인을 설정하고 등록해야할 겁니다. 즉, 가장 첫 단계에서부터 당신은 무슨 일이 일어나는지 이해하고 어떻게 구현해야할지 이해하고 있어야 함을 의미합니다.

 

두번째로, 실제 renderToString입니다

const reactHtml = renderToString(React.createElement(App, { ssrPath: path }));

여전히 공식문서와 비슷해 보이진 않네요 - 실제 백엔드 파일에서 JSX를 지원하는건 Vite에서 본 것과 동일하지 않습니다. 그럼에도 불구하고, 만일 당신이 문서를 읽는다면, renderToString이 데이터 스트리밍과 기다림을 지원하지 않는다는 걸 알게 될겁니다.

 

따라서, 실제로 제대로된 SSR을 구현하려면, 당신은 이런 새로운 기능들이 당신의 앱에 필요한지 아닌지 알고 있어야 합니다. 그리고 만일 필요하다면, 백엔드에서 어떻게 구현할지도요. 추천되는 방법들에 대해 여기 일부 문서들이 있고, 깃허브의 해당 주제에 대해 일부 논의가 된 쓰레드가 있습니다. 최소한 여기서부턴 시작하겠네요.

 

하지만 작업량은 엄청 많고, 언급했던 것들은 그저 시작에 불과합니다. 당신이 정신차릴때 쯤엔, 프로젝트는 3개월 쯤 밀려있고 스스로 Next.js를 구현하고 있을 겁니다. 왜 Next의 경쟁자가 별로 없는지 아시겠나요?

 

따라서, 당신이 이것을 위한 명백한 비즈니스적 이유가 있고, 시간, 자원, 그리고 지식 차원에서 많은 지원이 있다고 한들, 이미 존재하는 SSR 프레임워크를 사용하는게 쉬울겁니다. 여기서 백엔드 파트를 특별히 고려했던 건 그저 빙산의 일각일 뿐입니다. 프론트 파트를 고려한다고 하면 이 역시 엄청나게 복잡합니다.

SSR과 프론트엔드

앱의 사이즈와 그것이 SSR에 얼마나 최적화되어있는지에 따라, 백엔드 파트보다 더 복잡할 수도 있습니다. 네, 제대로 들은거 맞아요. 이게 제가 위에서 SSR을 구현하면서 숨겨둔 다른 하나입니다: 우리는 프론트엔드 코드도 바꿔야 합니다.

브라우저 API와 SSR

제가 브라우저로 보내는 HTML을 어떻게 얻었는지 기억하시나요? 저는 리액트의 renderToString을 이용해 문자열을 생성하고 다른 문자열에 집어넣었었죠. 이 과정 근처에는 브라우저가 없었고 앞으로도 그럴 일이 없을 것입니다.

 

따라서, 우리가 프론트엔드에서 부르곤 했던 브라우저 변수들을 호출한다면 어떻게 될거 같으세요? window.location, window.history, 그리고 document.getElementById 같은 것들 말이에요. 좋은 일은 없습니다. window, document 등은 전부 undefined가 됩니다. 그들을 전역스코프로 넣어줄 브라우저가 없습니다.

 

따라서 그 다음으로 리액트가 함수를 호출(즉, 요소를 렌더링)해서 직접 접근하려고 하면, window is not defined 에러가 나며 종료될 겁니다. 전체 앱이 터지는거죠. 그냥 터지는게 아닙니다. 서버 부분이 터집니다. 더 나쁘죠 - 프론트엔드가 에러를 캐치해 "우리는 작업중입니다. 여기 쿠키가 있어요." 를 담은 이쁜 화면을 보여줄 기회조차 없을겁니다. 에러 처리는 서버에서 해야하고, "서버" 에러 화면이 따로 있어야 할 겁니다.

더보기

도전 과제

1. 간단한 console.info(window.location);을 프론트엔드 코드 아무데나 넣어보세요. 예를 들면 src/App.tsx 같은 곳이요.

2. npm run build로 재빌드하고 재시작해보세요.

3. Internal Server Error 가 화면에 뜰겁니다.

4. 해결할 방법이 생각나시나요?

이를 해결할 전형적인 방법은 window (그리고 모든 다른) 전역 변수를 접근전에 선언하는 겁니다.

if (typeof window !== 'undefined') {
  // do something when the global window API is available
}

만일 당신이 frontend/utils/use-client-router.tsx 의 코드를 본다면, 제가 정확히 이렇게 해놨다는걸 알 수 있습니다. 그리고 실행 과정 중 언제든 window, document, 또는 어떤 것에든 접근해야할 때 매번 그랬죠.

useEffect와 SSR

use-client-router 얘기가 나온 김에, 자세히 보시면, useEffect 안에서는 typeof window를 안했다는걸 알 수 있습니다.

useEffect(() => {
  const handlePopState = () => {
    setPath(window.location.pathname);
  };
  window.addEventListener('popstate', handlePopState);
  return () =>
    window.removeEventListener('popstate', handlePopState);
}, []);

이건 왜냐하면 서버에서 실행될 때(즉, renderToString과 친구들을 통할 때), 리액트는 useEffect를 일으키지 않습니다. 그리고 useLayoutEffect도 그렇죠. 그런 훅들은 하이드레이션 이후 클라이언트에서만 동작할겁니다. 이런 특성에 대해 더 자세한 설명이 필요하다면 핵심 리액트 멤버들이 준비한 짧은 설명장황한 논의를 보시죠. 

 

따라서, 당신이 useEffect의 결과로 UI 변경을 하려고 한다면 분명히 알아두어야 합니다 - JS가 로드될 때 내용물이 "깜빡"이게 될겁니다.

선택적 SSR 렌더링은 "안돼요"

당신의 일부 코드가 브라우저 API에 너무 많이 종속적이어서, SSR 모드를 쓰지만 그 부분은 통채로 렌더링을 건너뛰는게 낫겠다 싶을 수도 있습니다. 따라서 그런 자연스러운 본능이 이런식으로 만들 수 있습니다.

const Component = () => {
  // don't render anything while in SSR mode
  if (typeof window === "undefined") return null;

  // render stuff when the Client mode kicks in
  return ...
}

안됩니다. 이건 동작하지 않을 겁니다. 또는, 정확히 말해서, 되긴 되지만 리액트를 혼란스럽게 할겁니다 - 리액트는 서버 코드가 만든 HTML과 클라이언트 코드가 만든 HTML이 정확히 일치할거라고 예상하기 때문입니다.

 

이는 너무 혼란스러워서 그냥 CSR 방식으로 돌아갈(fall back)겁니다 - "root" div의 전체 내용을 지우고 새롭게 생성된 요소들로 대체하는거죠. 하이드레이션은 전혀 일어나지 않았던 것처럼 동작할 것이고, 이로 인한 단점들이 따라올 겁니다.

 

이거 한번 해보세요:

  • frontend/pages/dashboard.tsx (또는 어디든 원하는 곳에) 다음과 같이 ClientOnlyButton을 만들어보세요.
const ClientOnlyButton = () => {
  if (typeof window === 'undefined') return null;
  return <button>Button</button>;
};
  • 페이지 아무데나 렌더 해보세요.
  • 평소처럼 재빌드하고 재시작해보세요.
  • 성능 프로필을 녹화해보세요. 하이드레이션이 구현되지 않았을 때랑 같은 모습이어야 합니다. 레이아웃 블록이 리액트 JS 작업 안에 있는 그런 모습이요.

하이드레이션이 사라진다면 당신이 운이 좋은겁니다. 가끔은 정말 이상한 레이아웃 버그를 보여주거나 웹사이트가 완전히 망가진 모습일 수 있습니다.

 

올바르게 하는 방법은 리액트의 라이프사이클에 의존해 SSR에 적합하지 않은 블록들을 숨기도록 하는겁니다. 이를 위해서는 상태를 도입해 컴포넌트가 마운트 되었는지 아닌지 관리해야 합니다.

const Component = () => {
  // initially it's not mounted
  const [isMounted, setIsMounted] = useState(false);
};

그러고는 이 상태를 컴포넌트가 마운트되면 true로 바꾸는 거죠. 즉, useEffect 안은 이렇습니다.

const Component = () => {
  // initially it's not mounted
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);
};

기억하세요: useEffect는 서버에서 실행되지 않고, 따라서 해당 상태는 웹사이트의 클라이언트 쪽 버전이 리액트에 의해 제대로 초기화되었을 때에만 true로 바뀝니다.

그리고 최종적으로, SSR에 적합하지 않은 내용을 렌더하게 됩니다.

const Component = () => {
  // initially it's not mounted
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  // don't render anything while in SSR mode
  if (!isMounted) return null;

  // render stuff when the Client mode kicks in
  return ...
}
더보기

도전 과제

1. 앞서 만든 ClientOnlyButton을 SSR에서 잘 동작하도록 다시 만들어보세요.

2. 재빌드하고 재시작해보세요.

3. 성능 프로필을 녹화해보세요. SSR했던 때처럼 돌아가있을겁니다.

써드파티 라이브러리들

당신의 모든 의존성(dependencies)이 SSR을 지원하진 않을겁니다. 항상 라이브러리들과의 도박입니다. 일부에 한해, 위에 말한 방법으로 비활성화(opt-out)할 수 있을겁니다. 일부는 번들러에 의해 거부될 것이고, 클라이언트쪽 JS가 로드되고 나서 동적으로 임포트해야 합니다. 일부는 아예 제거하고 더 SSR 친화적인 라이브러리로 교체해야 할 겁니다.

 

이건 SSR 친화적이지 못한 라이브러리가 당신의 프로젝트에 필수적일 때 뼈아플겁니다. 상태관리 라이브러리나 CSS-in-JS 라이브러리 같은 경우에요.

 

예를 들어, Material UI 아이콘들을 예제 프로젝트 어딘가에 써봅시다

// anywhere, for example in src/App.tsx
import { Star } from '@mui/icons-material';

function App() {
  // the rest of the code is the same
  return (
    <>
      ...
      <Star />
    </>
  );
}

재빌드하고 재시작하면, SSR이 망가진 모습이 보입니다.

[vite] (ssr) Error when evaluating SSR module @/App: deepmerge is not a function

즐거운 마음으로 고쳐볼 방법을 찾아봐요😬

정적 사이트 생성(Static Site Generation, SSG)

좋아요, 우리가 서버에서 렌더링된 "제대로 된"  페이지를 가져야 하고, 프론트엔드에서 생길 결과들에 대응할 준비가 되었다고 가정해봅시다. 예를 들어, 멋진 "마케팅용" 웹사이트를 구현하고 있습니다. 모든 검색 엔진에 의해 최대한 빨리 인덱싱되고 링크 공유가 가능한 모든 곳에서 공유되어야 하죠. 이게 이런 웹사이트의 핵심이죠.

 

추가로 웹사이트의 모든 정보가 "정적"이라고 가정해봅시다. 즉, 유저가 생성한 콘텐츠는 없고, 권한 요청이 없고, 요청마다 복잡한 데이터 생성할 필요가 없어요. 웹사이트는 상품을 설명하는 몇 개의 페이지와 개인정보 처리 약관 같은 몇 개의 약관 페이지, 그리고 일주일에 한 번 업데이트 되는 블로그가 전부입니다.

 

이 상황은 두 마리 토끼를 다 잡을 수 있는 흔치 않은 상황입니다. 우리는 이미 웹사이트를 프리랜더링하는게 상대적으로 쉽다는걸 압니다. 우리 앱에서 React.renderToString를 호출 (더 많이 아니면 더 적게) 하기만 하면 되는 문제죠.

 

따라서 여기서 드는 큰 의문은: 왜 npm run build 실행 직후에 React.renderToString을 빌드 중에 실행할 수 없는걸까요? 이론적으로, 우리는 어찌됐든 완전한 HTML 페이지를 브라우저로 프리랜더링해서 보내고 있습니다. 그리고 프리랜더링된 내용은 항상 동일하죠. 우린 미리 해둘 수 있습니다. 옛날에 했던 방식처럼 실제 HTML 파일들의 뭉치로 저장해두는 거죠. 그럼 우리가 "제대로 된" 서버를 가져야 한다는 고통에서도 벗어날 수 있습니다. 그렇지 않나요?

 

답변: 그렇지 말아야할 이유가 없습니다. 이렇게 해보세요

npm run build:ssg

이건 우리 웹사이트를 처음에 Vite로 평범하게 빌드하고는, 매우 원시적인 스크립트(backend/generate-static-pages.ts)를 실행해서 빈 <div id="root"> </div>를 renderToString에 의해 생성된 내용으로 바꿀 겁니다. 서버가 하는 것과 완벽히 같죠. 이제 더 이상 서버는 필요없습니다.

 

dist 폴더에 빌드된 파일을 살펴보죠. 두 개의 파일이 추가되어있을 겁니다: login.html 과 settings.html. 이 파일들 중 아무거나 열어본다면 <div id="root"> 가 내용물로 차있는걸 볼 수 있습니다.

 

이게 우리의 "정적인" 웹사이트이고, 어느 웹서버로든 시작할 수 있습니다.

npx serve dist

또는 어느 CSR된 앱 처럼 아무곳에나 올려도 됩니다. 이번에는 CSR의 단점이 없을 것이고, 모든 검색 엔진들은 바로 인덱싱할 수 있을 것이며, 소셜 미디어의 공유도 아름답게 동작할 겁니다.

 

정적 웹사이트들은 너무 좋아서 고유의 세 글자 약어가 따로 있을 정도입니다: SSG(Static Site Generation). 그리고 물론, 이를 만들어주는 프레임워크들도 다양하게 존재해서, 별도의 노동이 필요없습니다: Next.js도 SSG를 지원하고, Gatsby는 여전히 유명하며, 많은 사람들은 Docusaurus를 선호하고, Astro는 최상의 성능을 제공합니다. 이외에도 많습니다.


여전히 SSR에 대해선 이야기할게 많고, 이곳의 개념들은 더 쌓아가기 위한 기반일 뿐입니다. 그러나 희망적으로, 이 내용들은 유용하고, 당신이 다음번 결정을 내려야할 때 자신감을 줄겁니다: 우리는 다음 웹사이트를 만들때 SSR을 써야할까? 말까?


옮긴이의 후기

옮기느라 꽤 걸렸습니다. 분명 지피티를 사용하면 더욱 쉬웠겠지만, 한 글자 한 글자 옮기면서 글쓴이의 의도와 내용을 더 잘 파악할 수 있었던거 같습니다. 덕분에 막연히 알고 있던 서버사이드 렌더링의 장단점들을 실험적으로 체감할 수 있었던 것 같습니다. SSG도요 ㅎㅎ 글쓴이의 말대로 앞으로는 무조건 SSR로 만드는게 아니라 웹사이트의 목적에 따라 자신감을 가지고 선택하면 될거 같네요!

좋은 예제 프로젝트 만들고 친절히 설명해주신 Nadia Makarevich님께 너무나 감사드립니다.

반응형