My Boundary As Much As I Experienced

야놀자 부트캠프 파이널 프로젝트 Percent Hotel 회고 4) 리액트 SEO 100점 만든 비법 공개합니다 본문

Projects/Yanolja Bootcamp's

야놀자 부트캠프 파이널 프로젝트 Percent Hotel 회고 4) 리액트 SEO 100점 만든 비법 공개합니다

Bumang 2024. 3. 6. 01:37

개선 전

 

 

개선 후, 100점!

 

 

 

React의 SEO 문제 해결을 위한 라이브러리

리액트는 SEO 점수를 잘 받기 힘들다고 보편적으로 인식되고 있다. 그 이유는 SPA 특유의 동작 방식 때문인데,

웹 크롤러가 크롤링해갈 HTML 파일이 비어있고, 라우팅을 모두 React router dom 같은 자바스크립트 라이브러리로 히스토리 객체에 동적으로 추가하여 이동한 것처럼 느껴지게 할 뿐이지 실제 html파일은 index.html 하나 뿐이기 때문이다. (그러므로 페이지 별 메타 태그를 설정할 수 없다. 하나밖에 없으니..)

이를 해결하기 위해 여러가지 방법들이 고안 되었는데 그 중 가장 유명한 것이 react-helmet-asyncreact-snap이다.

 

리액트 헬멧 라이브러리란?

리액트 헬멧은 리액트 앱에 jsx 컴포넌트 형식으로 채워넣는 방식을 사용하지만 실제 컴포넌트를 렌더하진 않는다.

이는 단지 자바스크립트를 통해 동적으로 html 메타 태그를 변화시키는 역할만을 수행한다.

 

다들 querySelector나 getElementBy... 등의 window API들이

body 태그 이하의 요소들만 선택할 수 있는줄 알고 있지만 meta 태그도 충분히 잡을 수 있다.

그래서 이런 방식으로 리액트 헬멧은 단지 DOM조작으로 메타 태그를 변화시켜 주는 것이다.

 

[[구현 방법]]

더보기

간단하다. Helmet 태그를 불러온 다음에 그 안에 원하는 메타 태그를 양껏 적으면 된다.

어떤 사람들은 페이지 별로 Helmet 태그와 meta 태그를 일일이 다 넣어주기도 한다.

그러나 나는 HelmetTag라는 컴포넌트를 만들어서 재사용 가능하게 만들고,

동적으로 바뀌면 좋겠는 메타 정보만 props로 받아 처리하였다.

 

import { PATH } from "@/constants/path";
import { Helmet } from "react-helmet-async"; 
// 조금 더 번들 사이즈가 적은 async 버전을 사용했다.
// 성능적으로는 차이가 없다고 하며, 본래 리액트 헬멧은 동기적으로 작동하나,
// 런타임 중 react-helmet-async 코드는 태스크 큐로 넘어간다 한다.

export const HelmetTag = ({ text }: { text: string }) => {
  return (
    <Helmet>
      <title>{text} | 퍼센트호텔</title> // 동적으로 바뀌고 싶은 곳만 바꾸면 되게 만들었다.
      <meta name="description" content="양도 거래? 취소 보다 빠른 거래!"></meta>

      <meta itemProp="name" content="퍼센트 호텔" />
      <meta itemProp="description" content="양도 거래? 취소 보다 빠른 거래!" />
      <meta itemProp="image" content="/icon-192.png" />

      <meta property="og:url" content={PATH.ROOT} /> // 동적으로 바뀌고 싶은 곳만 바꾸면 되게 만들었다.
      <meta property="og:type" content="website" />
      <meta property="og:title" content={text} /> // 동적으로 바뀌고 싶은 곳만 바꾸면 되게 만들었다.
      <meta property="og:site_name" content="퍼센트 호텔" />
      <meta property="og:image" content="/icon-192.png" />
      <meta
        property="og:description"
        content="양도 거래? 취소 보다 빠른 거래!"
      />

      <meta property="twitter:card" content="/icon-192.png" />
      <meta name="twitter:title" content={text} />
      <meta
        name="twitter:description"
        content="양도 거래? 취소 보다 빠른 거래!"
      />
      <meta name="twitter:image" content="/src/assets/logos/main_logo.svg" />
    </Helmet>
  );
};

 

 

 

그리고 이를 Router.ts에서 각 페이지 별로 HelmetTag를 제공하였다.

// router.ts
const AppRouter = () => {
  const routes = createBrowserRouter([
    {
      path: PATH.ROOT,
      element: <App />,
      errorElement: (
        <Layout isHeaderOn={true} isBottomNavOn={false}>
          <NotFound />
        </Layout>
      ),
      children: [
        {
          path: "",
          element: (
            <Layout isHeaderOn={false} isBottomNavOn={true}>
              <HelmetTag text="메인" /> // 이렇게 페이지 별로 HelmetTag 하나만 제공해주면 끝!
              <ApiErrorBoundary>
                <Suspense fallback={<LoadingFallback />}>
                  <Lazy.Home />
                </Suspense>
              </ApiErrorBoundary>
            </Layout>
          ),
        },
        ...

 

 

 

 

 

 

그러나 오르지 않는 SEO 점수...

 

실제 배포한 후 메타 태그가 동적으로 바뀌며 페이지가 바뀌는 것을 잘 감지하였다. 그러나 SEO 점수는 오르지 않았다.

그 원인이 뭘까? 조사를 해보니 바로, 웹크롤러나 로봇은 html파일을 크롤링하는 것이기 때문에 script 영역에 있는 헬멧이

html 태그를 조작하기 전에 가져간다는 것이다.

 

약간 사기맞은 느낌이 들었다. 그렇다면 SEO점수를 올리기 위해 사용한다는 말은 모두 거짓일까?

 

 

React Snap으로 pre-render 구현하기

실제 다른 사람들의 사용 예시를 봐도 react-helmet 자체는 이동 시 메타 태그 바뀌는걸 유저에게 보여줄 수 있는걸로만 만족할 때 사용하라 한다. 그러나 웹 크롤러에게 실제로 노출될려면 full SSR 앱을 만들거나, 적어도 바뀐 페이지의 메타 태그를 헬멧으로 조작하고, 이를 pre-render해줘서 웹 크롤러가 발견할 수 있도록 도와주는 도구가 추가적으로 필요하다.

 

이를 위해 react-snap 이라는 라이브러리를 추가적으로 사용해줘야 한다.

 

[[구현 방법]]

더보기

 1. pacakage.json에 script에 postbuild 항목을 이렇게 추가해준다.

그러면 빌드가 끝난 후 react-snap 실행되어 route 별로 그에 맞는 meta tag 설정을 반영해주는 작업을 해준다. 

...
...
"scripts": {
  ...
  ...
  "postbuild": "react-snap"
}
...

 

 2. route에 추가시키고 싶은 path와 제외시키고 싶은 path를 선택할 수 있다.

"reactSnap": {
    "include": [
      // 프리렌더링 되기 원하는 path 를 작성한다.
    ],
    "exclued": [
      // 프리렌더링에서 제외할 path 를 작성한다.
    ]
  },

 

 

3. 마지막으로 ReactDOM의 hydrateRoot로 정보를 보충해준 채로 pre-render되게 설정한다.

hydrateRoot는 완전한 SSR은 아니다.

빈껍데기인 정적 index.html 파일에 '스크립트 내용까지 추가 렌더링시킨 뒤' pre-render시켜주는 훅이다.

(이름에서 알 수 있다시피 수분보충에 비유된다..)

 

hydrate의 정의:

React 애플리케이션을 서버에서 렌더링할 때 초기 상태가 포함된 정적 HTML 컨텐츠를 생성합니다. 이 HTML 컨텐츠는 클라이언트로 전송되어 클라이언트 측 JavaScript에 의해 하이드레이트됩니다. 하이드레이션은 이벤트 핸들러를 첨부하고 초기 HTML 응답에서 직렬화된 상태를 다시 첨부하는 것을 포함합니다.

const rootElement = document.getElementById("root");
if (rootElement?.hasChildNodes()) {
  ReactDOM.hydrateRoot(
    rootElement,
    <QueryClientProvider client={queryClient}>
      <ThemeProvider theme={theme}>
        <GlobalStyle />
        <HelmetProvider>
          <AppRouter />
        </HelmetProvider>
      </ThemeProvider>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>,
  );
} else {
  ReactDOM.createRoot(document.getElementById("root")!).render(
    <QueryClientProvider client={queryClient}>
      <ThemeProvider theme={theme}>
        <GlobalStyle />
        <HelmetProvider>
          <AppRouter />
        </HelmetProvider>
      </ThemeProvider>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>,
  );
}

 

 

 

마무리 회고

이러한 hydrateRoot를 통해 라이트하우스 SEO 점수를 100점을 받을 수 있었다.

이제는 Next.js가 대세가 되어가서 서버 컴포넌트로 더욱 손쉽게 해결할 수 있겠지만,

기존 React앱에서 SEO 점수를 올릴 수 있는 방법이 없진 않다는 것을 알 수 있었다.

"React니까 원래 SEO가 잘 안 돼"라고 하면 안 될 것 같다😄