React의 SEO 최적화 방법: React-Helmet-Async & Prerender

SEO란 무엇인가

Search Engine Optimization의 약어이며, 웹사이트가 검색 결과에 더 잘보이도록 최적화하는 과정입니다. 

검색엔진은 웹을 크롤링하면서 페이지에서 페이지로 링크를 따라가고, 찾은 콘텐츠의 색인을 생성합니다. 검색 결과로 나온 결과가 색인으로 만들어둔 콘텐츠인 것이죠. 

SPA란 무엇인가

SPA는 Single Page Application의 약어로, 하나의 html 파일에서 구동되는 웹 애플리케이션 형태의 페이지입니다. 유저는 브라우저에 한 번만 요청하여 페이지 전체를 로드합니다. 그 이후에는 유저 요청에 의해 필요한 데이터만을 업데이트하여 페이지를 계속 변경합니다.

SPA의 단점: SEO가 어렵다.

검색 엔진의 크롤러는 링크를 타고 페이지를 돌아다니면서 웹 문서(HTML 파일)을 읽습니다. 이러한 작업을 통해 단일한 url 페이지마다 index를 만들어 둡니다. 검색을 통해 나온 결과가 index해둔 페이지인 것이죠. 하지만 SPA는 웹 문서 파일은 하나지만 여러가지 뷰를 보여주므로 문제가 됩니다. 심지어 대부분의 React SPA는 빈 index.html만을 크롤링하게 됩니다. 

하지만 SPA의 장점은 있기 때문에, 점차 이를 개선해나갈 수 있는 방법들이 고안되었습니다.

 

SPA의 SEO 방법

1) history API 활용

사용자의 기록을 로깅해주는 history API의 `pushState` 메서드를 통해 SEO 문제를 해결할 수 있습니다. 

`pushState` 메서드는 인자로 `데이터 상태 객체`, `빈문자열`, `표시할 URL`을 인자로 받습니다. 

`pushState` 메소드가 호출되면 전달된 url이 브라우저의 url 창에 표시됩니다. 즉, 코드를 통해 url을 바꿀 수 있다는 점이 핵심입니다.

 

SPA에서 구분이 필요한 페이지로 진입할 때마다 pushState 메서드로 주소를 바꿔주면 해당 페이지를 새 콘텐츠가 있는 새 페이지로 인식한 검색엔진 봇이 해당 페이지를 크롤링하고 index를 생성합니다. 이 방법을 통해 SPA는 여러가지 페이지를 가진 것처럼 검색엔진에 표시될 수 있습니다. 하지만 `pushState` 메서드는 페이지만 바꿀 뿐만 아니라 페이지 데이터 간 데이터도 전송해야 하는데, 이 때 전송가능한 데이터는 직렬화된 데이터만 포함할 수 있어 데이터 양에 제한이 있습니다. 

이는 SPA가 복잡한 기능을 가지고 있으며 많은 state (유저 로그인 정보 등)를 가진 경우에는 연속성있게 페이지를 연결할 수 없습니다.

 

2) SSR 라이브러리 활용

Next.js, Remix와 같은 SSR 프레임워크를 활용하여 SSR을 활용한 SPA를 만들 수 있습니다. 

 

3) 라이브러리 활용

`head 태그`는 검색 결과에 영향을 미칠 수 있는 `meta 태그` 등의 중요한 SEO 요소들이 있기 때문에 페이지마다 달라지도록 해야하지만 SPA는 `head 태그`를 업데이트할 수 없어 SEO에 취약합니다.

 

1️⃣ React Helmet Async

`<Helmet>` 컴포넌트는 React라는 웹 애플리케이션에서 유저가 앱을 조작할 때마다 `head 태그`를 업데이트해주는 기능을 가지고 있습니다. 

 

동적으로 `head 태그`에 내용을 추가해도 검색 봇은 바뀐 내용을 크롤링하지 못할 수 있습니다. 

모든 검색 엔진이 JS 파일을 수집하지도 않으며, JS 파일 해석 비용이 HTML 파일 해석 비용보다 비싼 작업이기 때문입니다.

 

2️⃣ React Snap

주요 영역은 검색 엔진 봇이 HTML 만으로 콘텐츠를 해석할 수 있게 구성하는 것이 좋으며, 이를 해결해주는 것이 React Snap 입니다.

React Snap은 변화하는 HTML 파일을 스냅샷을 찍듯이 고유한 HTML 파일로 변환하여 앱을 빌드해줍니다. 즉, 설정한 페이지별로 HTML 파일을 생성하기 때문에, 기존의 CSR를 활용한 SPA가 가지는 단점을 개선할 수 있게 됩니다

이런 방식을 Prerendering이라고 하며, 이 방법을 통해서 CSR 방식으로 개발이 완료된 앱의 SEO를 개선할 수 있습니다. 다만 업데이트가 2024년 기준으로 5년전에 이뤄졌기 때문에 이를 사용하는 것엔 주의가 필요합니다. 

 

4) 동적 렌더링 

동적 렌더링을 사용하려면 웹 서버에서 크롤러를 감지해야 합니다. 크롤러의 요청은 Renderer로 라우팅되고, 사용자의 요청은 정상적으로 제공됩니다. 동적 렌더러는 페이지별로 적합한 콘텐츠 버전을 제공하도록 설정할 수 있습니다. 

다만, 이는 임시방편이며 SSR을 사용하여 문제를 해결하는 것이 중요하다고 합니다.

 

 

SEO 최적화를 위한 태그 / 방법들

메타 태그

브라우저와 검색 엔진을 사용할 수 있도록 웹 문서의 정보를 포함하고 있으며, 메타 태그는 웹페이지의 요약이므로 중요한 태그라고 볼 수 있습니다. 

`meta` 태그가 제공하는 메타 데이터는 4가지 유형 중 하나입니다.

속성명 내용
name 전체 페이지에 적용되는 문서 레벨 메타 데이터를 제공
http-equiv 유사한 이름의 HTTP 헤더가 제공하는 정보와 동일한 "프래그마 지시문"이 됨
charset 문서 인코딩에 사용한 문자 인코딩을 나타내는 "문자 집합 선언"이 됨
itemprop "사용자 정의 메타데이터"를 제공

 

`meta` 태그는 `name` 특성을 메타데이터 이름으로, `content` 특성을 값으로 하여 문서 메타데이터를 키-값 쌍 형태로 제공할 때 사용할 수 있습니다.

HTML 명세가 정의하는 표준 메타데이터 `name`는 다음과 같습니다.

이름명 내용
application-name 웹 페이지에서 구동 중인 애플리케이션의 이름

⚠️ 단순한 웹페이지는 `title` 태그로 처리해야 함
author 문서 저작자
description 페이지에 대한 짧고 정확한 요약
여러 브라우저는 즐겨찾기 페이지의 기본 설명 값으로 `description` 메타데이터를 사용
generator 페이지를 생성한 소프트웨어의 식별자
keywords 페이지의 콘텐츠와 관련된, 쉼표로 구분한 키워드 목록
referrer 문서에서 시작하는 요청의 HTTP Referer 헤더를 통제
theme-color 사용자 인터페이스를 표시할 때 사용해야 할 색상에 대한 힌트

 

OpenGraph(OG) 메타 태그

MDN에 따르면 Open Graph Data는 Facebook이 웹 사이트에 더 풍부한 메타 데이터를 제공하기 위해 발명한 메타 데이터 프로토콜입니다. 필수적인 4가지 속성은 다음과 같습니다.

속성 내용
og:title Graph에 표현되어야 할 타이틀
og:type 웹 페이지 유형 (ex, article, movie)
og:image Graph와 함께 표현되는 이미지 URL 
og:url Graph가 나타내는 대표 url

 

OG 메타데이터의 사용 예시는 다음과 같습니다.

<html prefix="og: https://ogp.me/ns#">
<head>
<title>The Rock (1996)</title>
<meta property="og:title" content="The Rock" />
<meta property="og:type" content="video.movie" />
<meta property="og:url" content="https://www.imdb.com/title/tt0117500/" />
<meta property="og:image" content="https://ia.media-imdb.com/images/rock.jpg" />
...
</head>
...
</html>

 

더 많은 속성을 알고자 한다면 링크를 클릭해보시면 좋을 것 같습니다.

Twitter에서도 유사한 형태의 독점적인 자체 메타데이터를 가지고 있습니다.

 

robots.txt

크롤러가 사이트에서 엑세스할 수 있는 URL을 검색엔진 크롤러에 알려줍니다. 이 파일은 주로 요청으로 인해 사이트가 오버로드되는 것을 방지하기 위해 사용합니다. 웹페이지가 Google에 표시되는 것을 방지하기 위한 메커니즘이 아니며, Google에 표시되지 않도록 하려면 `noindex`로 색인 생성을 차단해야 합니다.

더 자세히 알고싶다면 관련된 링크를 접속해보세요

 

sitemap.xml

사이트에 있는 페이지, 동영상 및 기타 파일과 각 관계에 관한 정보를 제공하는 파일입니다. 검색엔진은 사이트맵 파일을 읽고, 사이트를 효율적으로 크롤링합니다.

사이트맵을 통해 사이트에서 중요하다고 생각하는 페이지와 파일을 검색엔진에 알리고, 중요 정보를 제공합니다. 즉, 크고 복잡한 사이트나 전문화된 파일의 크롤링을 개선할 수 있습니다.

구글에서 이야기하는 사이트맵이 필요한 사이트
1️⃣ 사이트 크기가 큰 경우
2️⃣ 연결되는 외부 링크가 많지 않은 새로운 사이트
3️⃣ 리치 미디어 콘텐츠가 많거나 Google 뉴스에 표시되는 사이트

사이트 맵이 필요없는 사이트
1️⃣ 크기가 작은 사이트
2️⃣ 내부적으로 긴밀히 연결된 사이트
3️⃣ 검색결과에 표시하려는 미디어 파일(동영상, 이미지) 또는 뉴스페이지가 많지 않음

`React-Helmet-Async` 패키지를 사용하여 직접 실습해보도록 하겠습니다.

 

1️⃣ `React-Helmet-Async` 패키지를 설치해보겠습니다.

npm i react-helmet-async

 

2️⃣ React App을 <HelmetProvider>컴포넌트로 감쌉니다.

const router = createBrowserRouter([
  { path: "/", element: <Home /> },
  { path: "counter", element: <Counter /> },
  { path: "/:id", element: <OtherPage /> },
]);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <HelmetProvider>
      <RouterProvider router={router} />
    </HelmetProvider>
  </React.StrictMode>
);

 

3️⃣ 각 페이지 별로 `<Helmet>`컴포넌트를 사용하여 title과 meta 태그 등을 통해 원하는 페이지 정보를 검색엔진에 노출시키도록 합니다.

// Home.tsx
export default function Home() {
  return (
    <>
      <Helmet>
        <title>SEO 최적화 테스트</title>
        <meta name="description" content="SEO 최적화 테스트" />
        {/* Open Graph */}
        <meta property="og:title" content="SEO 최적화 테스트" />
        <meta
          property="og:description"
          content="SEO 최적화 테스트 연습입니다."
        />
        <meta property="og:image" content="https://via.placeholder.com/1200" />
        <meta property="og:url" content="https://www.google.com" />
        <meta property="og:type" content="website" />
        {/* Twitter */}
        <meta property="twitter:title" content="SEO 최적화 테스트" />
        <meta
          property="twitter:description"
          content="SEO 최적화 테스트 연습입니다."
        />
        <meta
          property="twitter:image"
          content="https://via.placeholder.com/1200"
        />
        <meta property="twitter:card" content="summary_large_image" />
      </Helmet>
      <div>Home</div>
      <Link to="/counter">Counter</Link>
    </>
  );
}

// Counter.tsx
export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <>
      <Helmet>
        <title>카운터페이지</title>
        <meta name="description" content="카운터페이지" />
        {/* Open Graph */}
        <meta property="og:title" content="카운터페이지" />
        <meta property="og:description" content="카운터페이지입니다." />
        <meta property="og:image" content="https://via.placeholder.com/1200" />
        <meta property="og:url" content="https://www.google.com" />
        <meta property="og:type" content="website" />
        {/* Twitter */}
        <meta property="twitter:title" content="카운터페이지" />
        <meta property="twitter:description" content="카운터페이지입니다." />
        <meta
          property="twitter:image"
          content="https://via.placeholder.com/1200"
        />
        <meta property="twitter:card" content="summary_large_image" />
      </Helmet>
      <div>
        <span>Count: {count}</span>
        <button onClick={() => setCount(count + 1)}>Increase</button>
        <button onClick={() => setCount(count - 1)}>Decrease</button>
        <Link to="/">Home</Link>
      </div>
    </>
  );
}


// OtherPage.tsx
export default function OtherPage() {
  const { id } = useParams();
  return (
    <>
      <Helmet>
        <title>{id} 페이지</title>
        <meta name="description" content="동적 파라미터 테스트" />
        {/* Open Graph */}
        <meta property="og:title" content={`${id} 페이지`} />
        <meta property="og:description" content={`${id} 페이지`} />
        <meta property="og:image" content="https://via.placeholder.com/1200" />
        <meta property="og:url" content="https://www.google.com" />
        <meta property="og:type" content="website" />
        {/* Twitter */}
        <meta property="twitter:title" content="동적 파라미터 테스트" />
        <meta property="twitter:description" content={`${id} 페이지`} />
        <meta
          property="twitter:image"
          content="https://via.placeholder.com/1200"
        />
        <meta property="twitter:card" content="summary_large_image" />
      </Helmet>
      <h1>{id} Page</h1>
      <Link to="/">Home</Link>
    </>
  );
}

 

이렇게 메타데이터를 설정하고 나서 실제로 동작을 하면 아래 영상과 같이 잘 동작하게 됨을 볼 수 있습니다. 하지만, Vite+React+TS 가 나오고 나서 메타데이터로 설정한 데이터로 변환되는 것을 볼 수 있습니다.

 

0

실제 서버로 부터 넘어오는 index.html 파일은 다음과 같습니다.

title 태그에 주목해보세요...! 아무것도 변환되지 않았습니다

 

 

react-helmet-async 파일이 따로 넘어져 오네요

 

즉, 실제로 서버로부터 넘겨지는 SourcePage에는 <meta>나 <title>이 추가되지 않은 채로 넘어오고, Javascript가 실행될 때, 변환되는 것이죠. 실제로 react-helmet-async.js 파일이 넘어온 후에 변환됩니다. JS를 실행한 뒤에 수집하는 크롤러에게는 괜찮겠지만 index.html 파일을 크롤링하는 크롤러는 변환된 메타데이터를 제대로 수집하지 못할 것 같습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

이번엔 png 이미지를 Favicon Generator 사이트의 서비스를 이용하여 Favicon을 만들고, index.html 파일에 favicon을 추가해두었습니다. Favicon이 잘 변경되었네요.

 

 

OG가 제대로 동작하는 것 같지 않아 확인해본 결과, 배포 시 og로 설정한 링크는 절대 경로여야 한다고 한 을 발견했습니다. 때문에 절대 경로로 링크를 적용하고 배포했을 때 잘 동작할 줄 알았는데, 잘 안되었습니다.

이미지랑 설명 둘 다 적용이 안된다...

이는 각각의 페이지 별로 index.html이 없기 때문에 발생한 일인 것 같습니다. Prerender를 통해 한 번 해결해 보도록 해봅시다. 

Prerender를 적용시키기 위해 여러 방안을 고민해보게 되었습니다. React-Snap은 React18버전을 제대로 지원하지 않기 때문에 React의 버전을 17로 다운시켜야 한다는 단점이 있었습니다. 때문에, 다른 방법을 고안해보게 되었고 Puppeteer과 Prerender를 활용하여 이를 해결하였습니다. 

1️⃣ 패키지를 설치했습니다.

`npm install --save-dev puppeteer`

`npm install --save-dev @prerenderer/renderer-puppeteer @prerenderer/rollup-plugin`

2️⃣ vite.config.ts 파일을 다음과 같이 설정하여 build 시 프리렌더링을 할 수 있도록 합니다.

import prerender from "@prerenderer/rollup-plugin";

export default defineConfig({
  plugins: [
    react(),
    prerender({
      routes: ["/", "/counter", "/otherpage/1", "/otherpage/2", "/otherpage/3"],
      renderer: "@prerenderer/renderer-puppeteer",
      server: {
        port: 3000,
        host: "localhost",
      },
      rendererOptions: {
        maxConcurrentRoutes: 1,
        renderAfterTime: 500,
      },
      postProcess(renderedRoute) {
        renderedRoute.html = renderedRoute.html
          .replace(/http:/i, "https:")
          .replace(
            /(https:\/\/)?(localhost|127\.0\.0\.1):\d*/i,
            "https://seo-optimization-test.netlify.app/"
          );
      },
    }),
  ],
});

 

이렇게 설정 하면 빌드를 할 때, routes에 설정한 경로대로 미리 렌더링을 진행하게 됩니다. 프리렌더링 결과는 다음과 같습니다.

 

프리렌더링된 html들, vite.config.ts에 설정했던 routes와 비교해보세요!

 

 

링크를 공유했을 때도, 저희가 설정한대로 잘 보이네요..! 문제를 해결했습니다

 

 

공부를 하다보니 React SEO에 대해 열심히 해결하고자 하신 분의 글을 보게 되었고, 이것도 나중에 적용해봐야겠다는 생각을 하고 마무리를 짓도록 하겠습니다..


출처

https://seo.tbwakorea.com/blog/how-to-seo-spa/

 

SPA SEO, 두 마리 토끼를 잡는 법

SPA 페이지는 검색 노출이 어렵다는 점 알고 계셨나요? 도대체 SPA가 무엇이기에 검색에 영향을 미칠까요? 이 글에서는 SPA가 무엇인지, 또 SPA 웹페이지를 SEO하려면 어떻게 해야 하는지 알아보겠습

seo.tbwakorea.com

https://www.ohmycrawl.com/technical-seo/react/

 

React SEO Best Practices - OhMyCrawl

Lets face it, React SEO sucks. Deep dive and learn how to leverage server-side rendering to make search engines love SPAs and modern web apps with free.....

www.ohmycrawl.com

https://www.freecodecamp.org/news/how-to-make-seo-friendly-react-apps/

 

How to Make React Apps SEO-Friendly – A Handbook for Beginners

When developing your web applications, you should always consider search engine optimization (SEO) techniques. Many things come into play when you're making sure your web application operates as intended and has an online presence. Search engines such as G

www.freecodecamp.org

https://lasbe.tistory.com/179

 

[React] SPA 사이드 프로젝트 검색 엔진 최적화(SEO) 끝장내기

⚡ SEO, 검색엔진 최적화 검색 엔진 최적화 (Search Engine Optimization)는 웹 사이트의 가시성을 향상시키고 검색 엔진 결과 페이지(SERP)에서 상위에 노출되도록 하는 노력을 의미합니다. 즉, 네이버나

lasbe.tistory.com

https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering?hl=ko

 

동적 렌더링을 사용하여 대처 | Google 검색 센터  |  문서  |  Google for Developers

동적 렌더링을 사용하면 크롤러에서 자바스크립트를 처리할 수 있습니다. 동적 렌더링을 구현하는 방법과 검색엔진 최적화의 이점을 알아보세요.

developers.google.com

https://developer.mozilla.org/ko/docs/Web/HTML/Element/meta/name

 

표준 메타데이터 이름 - HTML: Hypertext Markup Language | MDN

<meta> 요소는 name 특성을 메타데이터 이름으로, content 특성을 값으로 하여 문서 메타데이터를 이름-값 쌍의 형태로 제공할 때 사용할 수 있습니다.

developer.mozilla.org

https://ogp.me/

 

Open Graph protocol

The Open Graph protocol enables any web page to become a rich object in a social graph.

ogp.me