본문 바로가기

카테고리 없음

React에서 서버사이드 렌더링, 성능을 최적화 하는방법

이번엔 React에서의 서버 사이드 렌더링과 성능 최적화에 대해 이야기해보려고 합니다. 서버 사이드 렌더링이란, 서버에서 React 컴포넌트를 HTML로 렌더링하여 클라이언트에게 전달하는 방식입니다. 서버 사이드 렌더링의 장점은 다음과 같습니다.

- 초기 로딩 속도를 향상시킬 수 있습니다.

- 클라이언트에서 자바스크립트 파일을 다운로드하고 파싱하고 실행하는 시간을 줄일 수 있습니다.

- SEO(검색 엔진 최적화)를 향상시킬 수 있습니다.

- 검색 엔진 크롤러가 HTML을 쉽게 읽을 수 있습니다.

- 사용자 경험을 향상시킬 수 있습니다.

- 사용자가 화면에 내용이 보이는 시점을 당길 수 있습니다.

 

하지만 서버 사이드 렌더링에도 단점이 있습니다.

- 서버 부하가 증가할 수 있습니다.

- 서버에서 많은 렌더링 작업을 수행해야 합니다.

- 클라이언트와 서버의 동기화 문제가 발생할 수 있습니다.

- 클라이언트와 서버에서 같은 결과를 내기 위해 상태와 데이터를 일치시켜야 합니다.

- 이벤트 핸들러나 라이프사이클 메서드 등의 클라이언트 전용 기능을 사용할 수 없습니다.

- 서버에서는 이러한 기능들이 작동하지 않습니다.

 

그렇다면 React에서 서버 사이드 렌더링을 어떻게 구현할 수 있을까요? React에서는 ReactDOMServer라는 모듈을 제공합니다. 이 모듈은 React 컴포넌트를 HTML 문자열로 변환하는 함수들을 포함합니다.

다음과 같은 함수들이 있습니다.

- renderToString() React 컴포넌트를 HTML 문자열로 렌더링합니다. 이 함수는 동기적으로 작동하며, 데이터를 로딩하기 위해 추가적인 작업이 필요합니다.

- renderToStaticMarkup() React 컴포넌트를 HTML 문자열로 렌더링합니다. 이 함수는 renderToString()과 비슷하지만, React 관련 속성들을 추가하지 않습니다. 정적인 페이지를 만들 때 사용할 수 있습니다.

- renderToNodeStream() React 컴포넌트를 HTML 문자열로 렌더링하고, 그 결과를 Node.js 스트림으로 반환합니다. 이 함수는 비동기적으로 작동하며, 데이터를 로딩하기 위해 Suspense와 같은 기능을 사용할 수 있습니다.

- renderToStaticNodeStream() React 컴포넌트를 HTML 문자열로 렌더링하고, 그 결과를 Node.js 스트림으로 반환합니다. 이 함수는 renderToNodeStream()과 비슷하지만, React 관련 속성들을 추가하지 않습니다.

 

그럼 ReactDOMServer 모듈을 이용하여 간단한 서버 사이드 렌더링 애플리케이션을 만들어보겠습니다. 예시로, 사용자의 이름을 입력받아 환영 메시지를 보여주는 애플리케이션을 만들어보겠습니다.

 

먼저 애플리케이션의 주축이 되는 app.js 파일의 일부입니다.

// app.js

// 모듈 임포트
const express = require('express');
const fs = require('fs');
const path = require('path');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const App = require('./components/App');

// 앱 생성
const app = express();

// 정적 파일 미들웨어 설정
app.use(express.static(path.join(__dirname, 'public')));

// 메인 페이지 라우트
app.get('/', (req, res, next) => {
  try {
    // index.html 파일을 읽음
    const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
    // App 컴포넌트를 HTML 문자열로 렌더링함
    const appHtml = ReactDOMServer.renderToString(<App />);
    // index.html 파일에서 <!--app--> 부분을 App 컴포넌트의 HTML로 대체함
    const result = html.replace('<!--app-->', appHtml);
    // 응답으로 완성된 HTML을 보냄
    res.send(result);
  } catch (err) {
    next(err);
  }
});

// 소개 페이지 라우트
app.get('/about', (req, res, next) => {
  try {
    // about.html 파일을 읽음
    const html = fs.readFileSync(path.join(__dirname, 'about.html'), 'utf8');
    // 응답으로 HTML을 보냄
    res.send(html);
  } catch (err) {
    next(err);
  }
});

// 서버 시작
app.listen(3000, () => console.log('Server listening on port 3000'));

다음은 메인 컴포넌트 파일인 components/App.js 파일의 일부코드입니다.

// components/App.js

// 모듈 임포트
const React = require('react');
const Greeting = require('./Greeting');

// App 컴포넌트 정의
class App extends React.Component {
  constructor(props) {
    super(props);
    // 상태 초기화
    this.state = {
      name: '',
      submitted: false,
    };
    // 이벤트 핸들러 바인딩
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  // 입력 값 변경 시 상태 업데이트하는 함수
  handleChange(e) {
    this.setState({
      name: e.target.value,
    });
  }

  // 폼 제출 시 상태 업데이트하는 함수
  handleSubmit(e) {
    e.preventDefault();
    this.setState({
      submitted: true,
    });
  }

  // 렌더링 함수
  render() {
    // 상태에서 이름과 제출 여부를 가져옴
    const { name, submitted } = this.state;
    return (
      <div>
        <h1>Server-side Rendering and Performance Optimization in React</h1>
        <p>This is a simple example of server-side rendering and performance optimization in React.</p>
        <form onSubmit={this.handleSubmit}>
          <label htmlFor="name">Enter your name:</label>
          <input type="text" id="name" value={name} onChange={this.handleChange} />
          <button type="submit">Submit</button>
        </form>
        {submitted && <Greeting name={name} />}
      </div>
    );
  }
}

// 컴포넌트 내보내기
module.exports = App;

이제 웹팩(Webpack)을 사용하여 클라이언트 사이드 파일을 번들링 하겠습니다.

// webpack.config.js

// 모듈 임포트
const path = require('path');

// 웹팩 설정 객체 내보내기
module.exports = {
  // 모드 설정 (개발 모드)
  mode: 'development',
  // 진입점 설정 (App 컴포넌트 파일)
  entry: './components/App.js',
  // 출력 설정
  output: {
    // 번들링된 파일의 이름
    filename: 'bundle.js',
    // 번들링된 파일의 경로 (public 디렉토리)
    path: path.join(__dirname, 'public'),
  },
  // 모듈 설정
  module: {
    // 규칙 배열
    rules: [
      {
        // 자바스크립트 파일에 적용할 규칙
        test: /\.js$/,
        // 바벨 로더를 사용하여 트랜스파일링함
        use: 'babel-loader',
        // node_modules 디렉토리는 제외함
        exclude: /node_modules/,
      },
    ],
  },
};

 

웹팩 설정 파일을 작성한 후, 터미널에서 npx webpack 명령어로 번들링을 수행할 수 있습니다. 번들링이 완료되면, public 디렉토리에 bundle.js 파일이 생성됩니다. 이 파일은 클라이언트 사이드에서 React 컴포넌트를 렌더링하기 위해 필요합니다.

 

이제, HTML 템플릿 파일들을 작성해보겠습니다. 다음은 index.html의 주요 코드입니다.

<!-- index.html -->

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>Server-side Rendering and Performance Optimization in React</title>
</head>
<body>
  <!-- 서버에서 렌더링된 App 컴포넌트의 HTML이 삽입될 부분 -->
  <div id="root"><!--app--></div>
  <!-- 클라이언트 사이드에서 React 컴포넌트를 렌더링하기 위한 스크립트 파일 -->
  <script src="/bundle.js"></script>
</body>
</html>

아래는 about.html 파일의 코드입니다.

<!-- about.html -->

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>About</title>
</head>
<body>
  <h1>About</h1>
  <p>This is a simple example of server-side rendering and performance optimization in React.</p>
  <p>Created by assistant.</p>
  <a href="/">Go back to main page</a>
</body>
</html>

이렇게 서버 사이드 렌더링 애플리케이션을 만들었습니다. 애플리케이션을 실행하고 테스트해보세요. 다음은 애플리케이션의 실행 방법입니다.

 

애플리케이션 실행 방법

1. 터미널에서 npm install 명령어로 필요한 모듈 설치

2. npx webpack 명령어로 클라이언트 사이드 자바스크립트 파일 번들링

3. node app.js 명령어로 애플리케이션 실행

 

애플리케이션을 실행한 후, 브라우저에서 http://localhost:3000/ 에 접속해보세요. 메인 페이지에는 이름을 입력할 수 있는 폼이 보입니다. 이때, 서버에서는 이미 App 컴포넌트를 HTML로 렌더링하여 보내주었기 때문에, 클라이언트에서는 bundle.js 파일을 다운로드하고 파싱하고 실행하는 시간을 줄일 수 있습니다. 또한, 소스 코드를 보면 HTML에 이미 렌더링된 내용이 들어있는 것을 확인할 수 있습니다. 이는 검색 엔진 크롤러가 HTML을 쉽게 읽을 수 있음을 의미합니다. 하지만, 이 애플리케이션은 완벽하지 않습니다. 예를 들어, 다음과 같은 문제점들이 있습니다.

 

- 서버에서 렌더링된 HTML과 클라이언트에서 렌더링된 HTML이 일치하지 않는 경우가 있습니다. 예를 들어, 사용자가 폼에 이름을 입력하고 제출하기 전에 다른 페이지로 이동하면, 서버에서는 이름이 없는 상태로 HTML을 렌더링하고, 클라이언트에서는 이름이 있는 상태로 HTML을 렌더링합니다. 이는 화면 깜빡임 현상을 발생시킬 수 있습니다.

- 서버에서 데이터를 로딩하는 경우에 대한 처리가 부족합니다. 예를 들어, 사용자의 이름을 데이터베이스에서 가져오는 경우

 

이제 서버 사이드렌더링의 문제들을 해결하는 방법을 알아보겠습니다.

서버에서 렌더링된 HTML과 클라이언트에서 렌더링된 HTML이 일치하지 않는 경우를 방지하기 위해서는, 서버와 클라이언트에서 같은 상태와 데이터를 사용해야 합니다. 이를 위해, 서버에서는 상태와 데이터를 HTML에 주입하고, 클라이언트에서는 HTML에서 상태와 데이터를 추출하여 사용할 수 있습니다. 예를 들어, 다음과 같은 코드를 추가할 수 있습니다.

// app.js

// ...

// 메인 페이지 라우트
app.get('/', (req, res, next) => {
  try {
    // index.html 파일을 읽음
    const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
    // App 컴포넌트를 HTML 문자열로 렌더링함
    const appHtml = ReactDOMServer.renderToString(<App />);
    // index.html 파일에서 <!--app--> 부분을 App 컴포넌트의 HTML로 대체함
    const result = html.replace('<!--app-->', appHtml);
    // 상태와 데이터를 HTML에 주입함
    const state = { name: 'assistant', submitted: true };
    const data = `<script>window.__STATE__ = ${JSON.stringify(state)}</script>`;
    const final = result.replace('<!--data-->', data);
    // 응답으로 완성된 HTML을 보냄
    res.send(final);
  } catch (err) {
    next(err);
  }
});

// ...

// components/App.js

// ...

// App 컴포넌트 정의
class App extends React.Component {
  constructor(props) {
    super(props);
    // 상태 초기화
    this.state = {
      name: '',
      submitted: false,
    };
    // 이벤트 핸들러 바인딩
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  // 컴포넌트가 마운트될 때 호출되는 함수
  componentDidMount() {
    // HTML에서 상태와 데이터를 추출하여 상태를 업데이트함
    const state = window.__STATE__;
    this.setState(state);
  }

  // ...
}

 

서버에서 데이터를 로딩하는 경우에 대한 처리를 하기 위해서는, 서버에서 비동기적으로 데이터를 가져온 후에 HTML을 렌더링하고 보내야 합니다. 이를 위해, ReactDOMServer 모듈의 renderToNodeStream() 함수와 React의 Suspense 컴포넌트를 사용할 수 있습니다. 다음과 같이 코드를 수정해보세요.

// app.js

// ...

// 메인 페이지 라우트
app.get('/', (req, res, next) => {
  try {
    // index.html 파일을 읽음
    const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
    // index.html 파일을 헤더와 바디로 분리함
    const parts = html.split('<!--app-->');
    // 헤더 부분을 응답으로 보냄
    res.write(parts[0]);
    // App 컴포넌트를 HTML 문자열로 렌더링하고 Node.js 스트림으로 반환함
    const stream = ReactDOMServer.renderToNodeStream(<App />);
    // 스트림을 파이프하여 응답으로 보냄
    stream.pipe(res, { end: false });
    // 스트림이 끝나면 바디 부분을 응답으로 보냄
    stream.on('end', () => {
      res.write(parts[1]);
      res.end();
    });
  } catch (err) {
    next(err);
  }
});

// ...

// components/App.js

// ...

// App 컴포넌트 정의
class App extends React.Component {
  constructor(props) {
    super(props);
    // 상태 초기화
    this.state = {
      name: '',
      submitted: false,
    };
    // 이벤트 핸들러 바인딩
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  // ...

  // 렌더링 함수
  render() {
    // 상태에서 이름과 제출 여부를 가져옴
    const { name, submitted } = this.state;
    return (
      <div>
        <h1>Server-side Rendering and Performance Optimization in React</h1>
        <p>This is a simple example of server-side rendering and performance optimization in React.</p>
        <form onSubmit={this.handleSubmit}>
          <label htmlFor="name">Enter your name:</label>
          <input type="text" id="name" value={name} onChange={this.handleChange} />
          <button type="submit">Submit</button>
        </form>
        {submitted && (
          // Suspense 컴포넌트로 데이터를 로딩하는 동안 보여줄 내용을 정의함
          <React.Suspense fallback={<div>Loading...</div>}>
            <Greeting name={name} />
          </React.Suspense>
        )}
      </div>
    );
  }
}

// ...

// components/Greeting.js

// ...

// Greeting 컴포넌트 정의
function Greeting(props) {
  // 속성에서 이름을 가져옴
  const { name } = props;
  // 데이터를 로딩하는 함수 (임시로 3초 후에 데이터를 반환하도록 함)
  const loadData = () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(`Hello, ${name}!`);
      }, 3000);
    });
  };
  // 데이터를 로딩하고 결과를 보여주는 컴포넌트 (useFetch라는 커스텀 훅을 사용함)
  const Data = () => {
    const data = useFetch(loadData);
    return <h2>{data}</h2>;
  };
  return (
    <div>
      <Data />
      <p>Welcome to the server-side rendering and performance optimization in React.</p>
    </div>
  );
}

// useFetch 커스텀 훅 정의 (데이터를 로딩하고 캐싱하는 기능을 수행함)
function useFetch(fetcher) {
  // 상태 초기화
  const [data, setData] = React.useState(null);
  // 데이터를 로딩하는 함수
  const loadData = async () => {
    // fetcher 함수가 캐시에 있는지 확인함
    if (fetcher in cache) {
      // 캐시에 있으면 캐시에서 데이터를 가져옴
      const data = cache[fetcher];
      setData(data);
    } else {
      // 캐시에 없으면 fetcher 함수를 호출하여 데이터를 가져옴
      const data = await fetcher();
      setData(data);
      // 캐시에 fetcher 함수와 데이터를 저장함
      cache[fetcher] = data;
    }
  };
  // 컴포넌트가 마운트될 때 데이터를 로딩함
  React.useEffect(() => {
    loadData();
  }, []);
  // 데이터를 반환함
  return data;
}

// 캐시 객체 정의
const cache = {};

// ...

이렇게하면 서버사이드 렌더링 애플리케이션의 문제점을 개선한 서비스를 만들수있습니다. 이 다음에도 Next.js를 사용해서 더 개선할수있는 방법이 있습니다. 오늘은 여기까지하고 다음에는 Next.js를 사용한 애플리케이션을 소개하도록 하겠습니다. 그러면 모두 즐코하세요!