서버 사이드 리액트

가은·2025년 3월 17일
0

클라이언트 사이드 렌더링의 한계

1. 검색 엔진 최적화(SEO)

클라이언트 사이드 렌더링은 일부 검색 엔진의 크롤러가 자바스크립트를 실행하지 않으며, 자바스크립트를 실행하는 크롤러도 예상대로 실행되지 않을 수 있다.

기존 웹 애플리케이션에서는 사용자나 검색 엔진 크롤러가 페이지를 요청하면 서버가 해당 페이지의 HTML을 렌더링해 다시 전송한다. 크롤러는 HTML에 모든 컨텐츠(마크업)를 쉽게 읽고 색인을 생성할 수 있다.
하지만 리액트와 같은 클라이언트 사이드 렌더링 애플리케이션은 서버가 거의 비어있는 HTML 파일을 전송하게 되어 컨텐츠를 읽는데에 불리하다.

거의 비어있는 HTML의 유일한 역할은 동일한 서버 혹인 기능을 수행하는 대체 서버에 있는 별도의 파일에서 자바스크립트를 읽어 들이는 것이다.

2. 성능

클라이언트에서 렌더링되면 느린 네트워크나 낮은 성능 기기에서 성능 문제를 겪을 수 있다.
콘텐츠를 렌더링하기 전에 자바스크립트를 다운로드하고, 구문 분석과 실행까지 해야한다.
콘텐츠 렌더링의 지연은 사용자의 참여율과 이탈률에 직접적인 영향을 미치는데, 이는 검색 엔진의 페이지 순위에 부정적 영향을 미칠 수 있다.

CPU 가용성이 낮은 저전력 기기의 경우 자바스크립트를 실행하는 기기의 처리 능력이 부족해 애플리케이션이 느리게 동작하거나 응답을 못할 수 있다.

서버에서 자바스크립트를 먼저 실행하고 최소한의 데이터나 마크업을 클라이언트로 전송하면, 저전력 클라이언트가 많은 작업을 수행할 필요가 없어지므로 사용자 환경을 개선시킬 수 있다.

클라이언트 애플리케이션에선 웹 애플리케이션을 표시하기 위해 브라우저가 다운로드, 파싱, 실행을 해야하는 자바스크립트 때문에 초기 페이지 로드가 차단되는 네트워크 폭포가 발생한다.

이는 서버 사이드 렌더링을 사용하면 해결이 가능하다.
필요한 데이터를 가져와서 서버에서 렌더링했기 때문에 처음 페이지를 로딩하는 순간부터 사용자에게 정보를 줄 수 있다.

리액트는 기본적으로 DOM 전체를 제어하므로 클라이언트 전용 애플리케이션은 리액트 없이 사용자 인터페이스를 그릴 수 없기 때문에, 모든 파일을 읽어 들일 때까지 기다릴 수 밖에 없다.

3. 보안

클라이언트 사이드 렌더링은 애플리케이션의 모든 코드가 클라이언트의 브라우저로 다운로드 되어 크로스 사이트 요청 위조(CSRF) 같은 공격에 취약하다.
서버 사이드를 통해 신뢰할 수 있는 출처의 서버에서 클라이언트로 적절한 CSRF 방지 토큰을 전송하고, 클라이언트에서 다시 서버로 토큰을 포함하여 제출시켜 중간 검증을 추가할 수 있다.

위와 같은 한계들을 서버 렌더링에서 보완할 수 있다.

  • 최초의 의미있는 페인트가 완성되는 시간 단축
  • 웹 애플리케이션의 접근성 개선
  • 웹 애플리케이션의 SEO 개선
  • 웹 애플리케이션의 보안 향상

그러나 서버에서 렌더링된 HTML은 정적이며 자바스크립트를 읽어 들이지 않은 상태이기에 상호 작용이 부족하다. 동적 기능을 활성화하려면, 필요한 자바스크립트를 정적 HTML에 공급해야하는데 이를 하이드레이션 이라고 한다.

하이드레이션

하이드레이션? 서버에서 생성되어 클라이언트로 전송되는 정적 HTML에 이벤트 리스너와 여러 자바스크립트 기능을 추가하는 프로세스를 의미하는 용어

하이드레이션의 목적은 브라우저가 서버 렌더링 애플리케이션을 읽어 들인 후 여기에 상호 작용을 추가해서 사용자에게 빠르고 원활한 경험을 제공하는 것이다.

리액트에서 하이드레이션은 클라이언트가 서버에서 렌더링된 리액트 애플리케이션을 다운로드 한 후 발생하고 이후 클라이언트 번들 로딩, 이벤트 리스너 추가 순으로 진행된다.

하이드레이션 프로세스가 완료되면 애플리케이션은 필요에 따라 사용자와 상호작용하고, 데이터를 가져오고, DOM을 업데이트한다.

리액트의 서버 렌더링 API

renderToString

서버에서 리액트 컴포넌트를 HTML 문자열로 렌더링할 때 사용한다.
동기식으로 동작하며 완전히 렌더링된 HTML 문자열을 반환한다. 반환받은 HTML 문자열은 클라이언트에 응답으로 보낼 수 있다.

renderToString은 성능, SEO, 접근성을 개선하기 위해 서버에서 리액트 애플리케이션을 렌더링할 때 주로 사용된다.

react-dom/server 패키지에서 함수를 가져와서 리액트 컴포넌트를 인수로 전달하고 호출하여 사용한다.

import React from "react";
import { renderToString } from "react-dom/server";

function App() {
  return (
    <div>안녕하세요</div>
  )
};

const html = renderToString(<App />);
console.log(html);

이 함수는 리액트 엘리먼트의 트리를 탐색하고 이를 실제 DOM 엘리먼트에 해당하는 문자열 표현으로 변환한 다음, 전체 문자열을 결과로 반환하는 동작을 한다.

동기식 API이므로 대상이 되는 컴포넌트가 루트에서 몇 단계 깊이에 있다면, 처리하는 시간이 어느 정도 걸릴 수 있다. 그렇기 때문에 캐시와 같은 적절한 방지책이 없다면 이벤트 루프의 진행을 막고 시스템의 과부하를 일으킬 수 있다. 또한 컨텐츠가 많은 대규모 애플리케이션에서는 HTML 문자열 전체를 생성할 때까지 대기하게 된다.

renderToPipeableStream

리액트 18에 도입되었으며, 대규모 리액트 애플리케이션을 Node.js 스트림에 렌더링하는 효율적이고 유연한 방법이다.
응답 객체로 파이프할 수 있는 스트림을 반환하며, HTML이 렌더링되는 방식을 세밀하게 제어할 수 있어서 쉽게 다른 Node.js 스트림과 통합할 수 있다.
또한 리액트의 suspense을 완벽하게 지원한다.

스트림이기 때문에 네트워크를 통해 스트리밍할 수 있으며, HTML 청크를 클라이언트에 비동기적으로 전송할 수 있어서 네트워크 지연없는 점진적인 데이터 전달이 가능하다.

스트림(stream)? 실제의 입력이나 출력이 표현된 데이터의 이상화된 흐름
스트림은 운영체제에 의해 생성되는 가상의 연결 고리를 의미하며 중간 매개자 역할을 한다.

// server.js

const express = require("express");
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");

const App = require("./src/App");

const app = express();

app.use(express.static(path.join(__dirname, "build")));

app.get("*", (req, res) => {
  // 시작 부분 변경
  const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
	// 데이터를 패치하기 전에 앱이 준비되는 경우
	onShellReady: () => {
      // 서버가 HTML을 보낼 거라고 클라이언트에 통지
      res.setHeader("Content-Type", "text/html");
      pipe(res);  // 리액트 스트림의 출력 결과를 응답 스트림에 파이프
    }
  });
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

renderToPipeableStream도 선언적으로 기술된 리액트 엘리먼트의 트리를 인수로 받는다.
단, 변환 결과는 HTML 문자열이 아닌 Node.js 스트림이 된다.
스트림을 사용하면 청크 단위로 점진적으로 처리한다. 이러한 접근 방식은 메모리에 모두 읽어 들일 수 없거나 네트워크를 통해 한번에 전송하지 못하는 데이터를 다룰 때 유용하다.

Node.js 스트림? 출발지에서 목적지로 흐르는 데이터의 흐름
Node.js 스트림은 데이터 흐름의 특성과 방향에 따라 읽기 가능 스트림, 쓰기 가능 스트림, 양방향 스트림, 변환 스트림으로 분류된다.
참고: https://jeonghwan-kim.github.io/node/2017/07/03/node-stream-you-need-to-know.html

리액트의 renderToPipeableStream은 첫 번쩨 바이트 시간(TTFB) 지표를 개선하기 위해 리액트 컴포넌트를 쓰기 가능 스트림으로 스트리밍한다. 이를 통해 HTML 마크업 전체가 생성될 때까지 서버가 기다렸다가 전송하는 것이 아닌, HTML 응답 청크가 준비되는 즉시 전송을 시작해 전반적인 지연 시간을 줄인다.

간단하게 정리하자면, 기본적으로 이 함수는 데이터에 의존적인 리액트 컴포넌트가 준비될 때까지 대기한 후 준비가 완료되면 Suspense 폴백을 서버에서 렌더링된 컴포넌트로 대체한다. 특정한 형식의 데이터를 가진 주석 노드를 사용해 컴포넌트의 구조를 파악하고, 그에 따라 DOM을 조작한다. 이 과정은 서버에 있는 HTML에 인라인으로 포함되기 때문에 renderToPipeableStream을 사용해 데이터를 스트리밍하고 브라우저가 준비된 UI를 렌더링할 수 있다.

renderToReadableStream

Node.js 스트림은 주로 파일 입출력, 네트워크 입출력, 종단간 스트리밍을 다루는 서버환경에서 작동하도록 설계되었으며 이벤트 등을 활용해 스트림의 흐름을 관리하고 데이터를 처리한다.

이는 브라우저 스트림으로 웹 브라우저 내의 클라이언트 환경에서 작동하도록 설계되었다.
주로 네트워크 요청, 미디어 스트리밍, 브라우저의 데이터 처리 작업에서 스트리밍 데이터를 처리한다. Node.js 스트림과 달리 브라우저 스트림은 read(), write(), pipeThrough() 등의 메서드를 사용해 데이터 흐름을 제어하고 스트리밍된 데이터를 처리한다. 또 보다 표준화된 promise 기반 API를 제공한다.

const readableStream = new ReadableStream({
  start(controller) {
	controller.enqueue("hello, ");
    controller.enqueue("world");
    controller.close();
  },
});

const reader = readableStream.getReader();

async function readAllChunks(streamReader) {
  let result = "";
  while (true) {
    const { done, value } = await streamReader.read();
    if (done) {
      break;
    }
    result += value;
  }
  return result;
}

readAllChunks(reader).then((text) => {
  console.log(text);
});

이러한 API 들을 제공해도 Next.js 나 Remix와 같은 기존 프레임워크를 사용하는 것이 더 낫다 ㅎ

profile
일이 재밌게 진행 되겠는걸?

0개의 댓글