호진방 블로그
학습

리액트에서의 SSR, SEO 처리 방법

# 리액트에서는 SSR, SEO 처리를 어떻게 해야 할까요?

2023년 12월 07일

개요

최근 Next.js를 이용하여 프로젝트를 진행하면서 서버 사이드 렌더링(SSR)과 검색 엔진 최적화(SEO)에 대해 깊이 공부하고 이를 실제로 적용해볼 수 있는 시간을 가졌다. Next.js의 SSR과 SEO 처리 방법에 대한 이해는 높아졌지만, 같은 리액트 생태계 내에서 이를 사용하는 리액트에서는 어떻게 처리하는지 궁금증이 생겼다. 특히, SSR과 SEO 처리가 과연 Next.js만의 전유물인것인지에 대해 알아보고 싶었다. 리액트에서의 SSR 및 SEO 처리 방법을 알아보며 어떠한 불편함이 있고 해당 불편함을 개선하기 위해 Next.js가 프레임워크적 성격으로 해당 불편함을 보완하고 있는지 Next.js의 강점을 느껴보는 시간을 갖고자 한다.

이 글에서는 리액트에서 SSR과 SEO를 어떻게 구현할 수 있는지에 대해 알아보고, 이를 통해 Next.js와의 차이점과 장점을 비교해보고자 한다. 리액트를 사용하면서도 SSR과 SEO에 대해 잘 이해하고 이를 리액트에서도 적절히 활용할 수 있도록 하는것이 목표이다.

React SSR 시작해보기

리액트에서 SSR을 구현하려면, 서버 사이드 렌더링 말 그대로 서버가 필요하다. 주로 Express와 같은 Node.js 서버와 함께 사용된다. 구현방법은 아래 순서대로 진행된다.

1. 프로젝트 초기 설정

먼저, 프로젝트 디렉토리를 생성하고 필요한 패키지를 설치한다.

필요한 패키지 설치
npm install react react-dom express react-dom-server @babel/core @babel/preset-env @babel/preset-react babel-loader
 

기존 vite를 사용하여 리액트를 설치하는 것이 아닌, npm을 사용하여 리액트와 리액트 dom, Express 서버, 그리고 SSR을 구현하기 위한 Babel과 관련 패키지를 설치한다.

💡 vite init을 통해 리액트를 설치하는 방식과 매우 달라서 관련 자료를 찾아보니, Vite를 사용하면 Babel 설정을 직접 관리할 필요 없이 기본적으로 ESBuild를 사용하여 최신 JavaScript 및 JSX 구문을 쉽게 사용할 수 있다고 한다.


2. Babel 설정

SSR을 위해 Babel을 설정해준다. Babel은 최신 자바스크립트 기능과 JSX를 컴파일할 때 사용되는데 babel.config.js 파일을 프로젝트 루트에 생성하고 다음과 같이 작성한다.

babel.config.js
module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
};

해당 설정은 1번에서 설치한 @babel/preset-env,@babel/preset-react을 사용하여 Babel이 ES6+와 JSX 문법을 컴파일할 수 있도록 해준다.

3. 리액트 컴포넌트 작성

SSR할 리액트 기본 컴포넌트를 작성한다. src/App.js 파일을 생성하고 다음과 같이 작성한다. API 호출까진 매우 어려울것 같아서, 간단한 h1컴포넌트를 출력하는것을 목표로 했다.

src/App.js
import React from 'react';
 
const App = () => {
  return (
    <div>
      <h1> 리액트로 SSR을 구현해보자! </h1>
    </div>
  );
};
 
export default App;

4. 웹팩 설정 (서버 번들)

SSR을 위해 서버와 클라이언트 번들을 생성하는 웹팩 설정 파일을 만든다. 먼저, webpack.server.js 파일을 생성하고 다음과 같이 작성한다.

webpack.server.js
const path = require('path');
 
module.exports = {
  entry: './server.js', // 서버 엔트리 파일 지정
  target: 'node', // 서버 사이드 실행을 위해 target을 node로 설정
  output: {
    path: path.resolve('dist'),
    filename: 'server.js',
  },
  module: {
    rules: [
      {
        test: /\\.js$/, // 모든 js 파일을 대상으로 babel-loader를 적용
        use: 'babel-loader',
        exclude: /node_modules/,
      },
    ],
  },
};

path 모듈 로드

const path = require('path');

엔트리 파일 (entry)

entry: './server.js',

타겟 (target)

target: 'node',
module: {
  rules: [
    {
      test: /\.js$/,
      use: 'babel-loader',
      exclude: /node_modules/,
    },
  ];
}

5. Express 서버 설정

Express 서버를 설정하고 리액트 컴포넌트를 서버 사이드에서 렌더링하도록 코드를 작성한다. server.js 파일을 생성하고 다음과 같이 작성하면 된다.

server.js
const express = require('express');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const App = require('./src/App').default;
 
//Express 앱 초기화
const app = express();
const port = 3000;
 
// 디렉토리 내의 정적 파일을 제공하기 위해 Express의 정적('dist') 미들웨어를 사용한다.
app.use(express.static('dist'));
 
//서버의 모든 GET 요청에 대해 실행
app.get('*', (req, res) => {
  const appString = ReactDOMServer.renderToString(<App />);
  const html = `
    <!DOCTYPE html>
    <html>
      <head>
        <title>SSR with React</title>
      </head>
      <body>
        <div id="root">${appString}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `;
  res.send(html);
});
 
app.listen(port, () => {
  console.log(`서버가 시작되었습니다 : ${port}`);
});

Express 서버를 설정하고, 모든 요청에 대해 리액트 컴포넌트를 서버 사이드에서 렌더링하여 HTML 응답을 생성한다.

6. 웹팩 설정 (클라이언트 번들)

클라이언트 사이드 번들을 생성하는 웹팩 설정 파일을 생성한다. webpack.client.js 파일을 생성하고 다음과 같이 작성한다.

const path = require('path');
 
module.exports = {
  entry: './src/client.js', // 클라이언트 엔트리 파일 지정
  output: {
    path: path.resolve('dist'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\\.js$/, // 모든 js 파일을 대상으로 babel-loader를 적용
        use: 'babel-loader',
        exclude: /node_modules/,
      },
    ],
  },
};

브라우저에서 로드되어 클라이언트 측에서 React 애플리케이션을 초기화하는 역할을 한다고 생각하면 된다.

src/client.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
 
ReactDOM.hydrate(<App />, document.getElementById('root'));

클라이언트 파일을 작성한다. src/client.js 파일을 생성하고 다음과 같이 작성한다.

7. 웹팩 실행 및 서버 시작

서버와 클라이언트 번들을 생성하기 위해 웹팩을 실행한다. 서버와 클라이언트 동시에 동작해야 하므로 package.json 스크립트를 수정하여 server단, client단 시작 빌드를 스크립트를 추가해야 한다.

"scripts": {
  "build:server": "webpack --config webpack.server.js",
  "build:client": "webpack --config webpack.client.js",
  "build": "npm run build:server && npm run build:client",
  "start": "node dist/server.js"
}
 
npm run build
 

이제 run build 통해 서버와 클라이언트 번들을 생성할 수 있다.

npm start

브라우저에서 http://localhost:3000에 접속하면 SSR이 적용된다.


느낀점 및 정리

우선, React로 SSR을 직접 구현하는 과정에서 초기 설정과 환경 구성이 매우 어려웠다. 기존에는 vite init을 사용하여 손쉽게 React를 설치하였는데, webpack부터 babel까지 직접 설치하려니 매우 불편했다.

Express와 같은 서버 라이브러리를 사용하여 React 애플리케이션을 서버에서 렌더링하도록 설정하는 과정 또한 상당히 어려웠고, 구글링하여 관련 자료를 찾아가며 설정할 수 밖에 없었지만, 해당 과정에서 웹팩과 바벨과 같은 빌드 도구들에 대한 이해도를 높일 수 있었다.

다양한 개발 환경 툴에 대해 알아보자.

React로 SSR을 직접 구현해본 후, 코드가 변경될 때마다 빌드를 해줘야 한다는점과 항시 서버단 클라이언트단이 동시에 켜져있어야 한다는점을 통해 Next.js와 같은 프레임워크가 제공하는 장점들을 직접 느낄 수 있었는데, Next App Router에서 자동으로 SSR이 적용된다는게 얼마나 편한건지 느낄 수 있었다. 감사합니다 Vercel팀

근데 Vercel 팀은 돈이 어디서 나길래 전세계 수많은 프로젝트의 빌드 변경사항 및 서버를 실시간으로 그것도 무료로 제공하는지 궁금하다.


React SEO 시작해보기

1. 기본적인 HTML head 작성

React 애플리케이션에서 head 태그를 작성하여 SEO처리를 할 수 있다.

index.html 수정

React 애플리케이션의 public/index.html 의 head에 기본적인 메타 데이터를 추가한다.

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>방호진의 페이지</title>
    <meta name="description" content="기본 설명" />
    ...
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

하지만 해당 방식은 전체적인 프로젝트 메타 데이터만 포함되어 있을 뿐 페이지별 동적인 메타 데이터를 변경하지 못한다.

페이지별 동적 SEO 적용

따라서 각 페이지마다 useEffect 를 사용하여 head의 메타 데이터를 동적으로 변경해야 한다.

import React, { useEffect } from 'react';
 
const AboutPage = () => {
  useEffect(() => {
    document.title = 'about 페이지'; // 페이지 제목 변경
    document.querySelector('meta[name="description"]').setAttribute('content', '어바웃 페이지 설명'); // 페이지 설명 변경
...
 
  return (
    <div>
      {/* 페이지 콘텐츠 */}
    </div>
  );
};
 
export default DynamicPage;

2. React Helmet 사용

React Helmet은 React 애플리케이션에서 동적으로 <head> 요소 내 메타 태그를 관리할 수 있게 해주는 라이브러리며 이를 통해 페이지의 meta 정보를 쉽게 제어할 수 있게 된다. 기존 index.html에서 직접 추가하는 방식이 아닌 React Helmet을 사용하여 SEO 처리를 해보자.

React Helmet 설치
npm install react-helmet

App.js
import React from 'react';
import { Helmet } from 'react-helmet';
 
function App() {
  return (
    <div className="App">
      <Helmet>
        <meta charSet="utf-8" />
        <title>방호진의 페이지</title>
        <meta name="description" content="방호진의 페이지입니다~" />
      </Helmet>
      <h1>Hello, React Helmet!</h1>
    </div>
  );
}
 
export default App;

기본적인 사용 예제는 위 코드와 같이 작성할 수 있다. <Helmet> 컴포넌트 내에 SEO와 관련된 메타 태그들을 설정해주면 된다.

React Helmet을 사용한 페이지별 동적 SEO 적용

useEffectReact Helmet을 사용하여 API를 통해 받아온 데이터를 통해 메타 태그를 각각의 페이지마다 동적으로 설정할 수 있다.

DynamicSEO.jsx
const DynamicSEO = ({ pageTitle, pageDescription, children }) => {
  const [title, setTitle] = useState('Loading...');
  const [description, setDescription] = useState('Loading description...');
 
  useEffect(() => {
    setTitle(pageTitle);
    setDescription(pageDescription);
  }, [pageTitle, pageDescription]);
 
  return (
    <div>
      <Helmet>
        <title>{title}</title>
        <meta name="description" content={description} />
      </Helmet>
      {children}
    </div>
  );
};
 
export default DynamicSEO;

DynamicSEO 컴포넌트를 생성하고 해당 컴포넌트에서 title과 description을 주입 받을 수 있도록하고, 페이지가 로드될 때 받아온 해당 데이터를 사용하여 meta data를 생성한다.

Home.js
import React from 'react';
import DynamicSEO from './DynamicSEO';
 
function Home() {
  return (
    <DynamicSEO pageTitle="홈페이지" pageDescription="홈페이지입니다">
      <h1>안녕하세요</h1>
      <p>방호진 페이지에 오신것을 환영합니다</p>
    </DynamicSEO>
  );
}
 
export default Home;

About.js
import React from 'react';
import DynamicSEO from './DynamicSEO';
 
function About() {
  return (
    <DynamicSEO pageTitle="About 페이지" pageDescription="About페이지 입니다">
      ...
    </DynamicSEO>
  );
}
 
export default About;

💡 이제 DynamicSEO 컴포넌트를 사용하여 각 페이지별로 SEO 메타 태그를 동적으로 설정할 수 있게 되었다. DynamicSEO 컴포넌트는 children을 받아서 자식 컴포넌트를 렌더링하며, titledescription을 props로 받아 Helmet을 통해 SEO 태그를 설정한다. 물론 직접적으로 Props로 문자열을 넘겨줄 수 있지만, API를 통해 데이터를 받아올때도 해당 데이터에서 title 혹은 description 을 가공하여 좀 더 데이터를 통한 동적인 SEO 설정 또한 가능하다.


느낀점 및 정리

React는 클라이언트 사이드 렌더링을 기본으로 하기 때문에 직접적인 SEO 처리 코드가 필요하다. 페이지의 메타 태그(<title>, <meta>)를 좀 더 쉽게 동적으로 관리하려면 React Helmet과 같은 라이브러리를 사용해야 했지만, 이는 곧 JSX 내에서 메타 데이터를 추가하고 관리하는데 있어 추가적인 코드 작업을 필요로 했다.

또한 React Helmet을 사용한다고 해도, 동적인 페이지의 SEO 설정은 일일이 Props로 <title>, <meta> 데이터를 직접 주입해야 했고, API를 사용하여 받아온 데이터를 title, description 에 맞게 가공해야 하는 불편함이 느껴졌다.


me
@banhogu
안녕하세요 배움을 나누며 함께 전진하는 1년차 주니어 개발자 방호진입니다.