• SPA 에서 SEO 적용하기 :: 마이구미
    React 2021. 1. 24. 00:27
    반응형
    이 글은 SPA(Single Page Application) 에서 SEO 를 적용하는 방법을 다룬다.
    SSR 가 아닌 CSR 로 구현된 페이지에서 SEO 적용은 까다롭다.
    이를 위한 방안으로 re-rendering 이라고 불리는 방식이 존재한다.
    * 동적 라우터를 위한 방식을 알아본다. ex) product/:id

     

    대부분의 BtoC 서비스라면, SEO 는 필수적이다.

    우리는 원하는 페이지들이 구글, 네이버, 다음과 같은 검색 서비스에 노출되길 원할 것이고,

     

     

    카카오톡, 페이스북과 같은 서비스에 공유될 때 원하는 썸네일, 텍스트 등을 노출하길 원한다.

     

     

    이를 위해서는 html 의 요소인 <title> 태그와 <meta> 태그를 활용해야한다.

     

    <html>
        <title>Hi!</title>
        <meta name="title" content="" />
        <meta name="description" content="" />
        <meta property="og:url" content="" />
        <meta property="og:type" content="website" />
        <meta property="og:title" content="" />
        <meta property="og:description" content="" />
        <meta property="og:image" content="" />
    </html

     

    위와 같은 태그들을 통해 위처럼 검색엔진 노출이나 공유할 때 원하는 정보를 인식하여 노출할 수 있게 된다.

    태그들을 활용해서 알아서 잘 노출되었으면 좋겠지만,

    SSR(서버 사이드 렌더링) 가 아닌 CSR(클라이언트 사이드 렌더링) 방식으로 구현했을 때 이슈가 발생한다.

    개인적인 의견으로는 SSR 와 CSR 을 선택하는 가장 큰 영향을 주는 것이 SEO 라고 본다.

    * React, Vue 등을 사용해 CSR 방식으로 구현을 하곤한다.

     

    CSR 방식으로 구현하면 왜 문제가 되는가?

     

    우선 SSR 과 CSR 을 비교해본다.

    각 페이지에 해당하는 /, /a, /b 가 있다고 가정한다.

     

     

    서버는 매번 새로운 HTML 파일을 응답하여 이를 바탕으로 매번 새로운 페이지를 그려준다.

    각 페이지는 서로 다른 HTML 파일을 의미한다.

     

     

    반면 CSR 의 경우에는 여러개의 페이지로 존재하고 보이더라도, 내부적으로는 1개의 HTML 파일이다.

    클라이언트가 서버는 Ajax 와 같은 요청을 서버에 보내고 서버는 JSON 형태의 데이터를 내려준다.

    그리고 클라이언트에서 응답 데이터를 기반으로 새로운 페이지가 아닌 그 부분에 대해서만 업데이트한다.

     

    이를 기반으로 크롤러 입장에서 바라보자.

    크롤러는 SSR 방식의 각 페이지에 접근하면 각 페이지의 html 정보를 각각 수집하게 된다.

     

     

    반면 CSR 방식의 각 페이지에 접근한다면, 각 페이지에 대한 정보는 1개의 HTML 파일인 루트 페이지의 html 정보만을 수집하게 된다.

    즉, 모든 페이지들은 크롤러 입장에서는 하나의 루트 페이지(/)로 인식할 뿐이다.

     

     

    쉬운 이해를 위해 직접 쉽게 확인해볼 수 있다.

    CSR 방식으로 구현된 프로젝트에서 다음을 실행해보자.

     

    • "/" 페이지에서 우클릭 => "페이지 소스 보기" 클릭 => HTML 코드 확인.
    • "/a" 페이지에서 우클릭 => "페이지 소스 보기" 클릭 => HTML 코드 확인.
    • "/b" 페이지에서  우클릭 => "페이지 소스 보기" 클릭 => HTML 코드 확인.
    • "/c" 페이지에서  우클릭 => "페이지 소스 보기" 클릭 => HTML 코드 확인.

     

    모두 똑같은 파일이 나올 것이다.

    이로 인해, 모든 페이지는 하나의 HTML 파일의 정보를 바라보고 있어, 검색서비스 노출 및 SNS 공유 기능 등이 원하는대로 동작할 수 없다.

    크롤러가 왜 루트 페이지의 데이터만을 수집하는 지를 확인할 수 있다.

    * 검색서비스마다 검색엔진의 동작은 모두 다르다. 구글의 경우 스크립트를 실행하고 비동기 요청에 의한 처리까지 분석할 수 있다.

     

    어떻게 특정 페이지의 정보를 수집할 수 있게 하는가?

     

    SSR 처럼 각 원하는 페이지에 해당하는 html 파일과 같은 형태가 존재해야만, 기대했던 대로 크롤러가 수집한다.

    즉, 각 라우터에 해당하는 index.html 파일을 만들어주면 된다.

    React 를 기준으로 react-helmetreact-snap 를 함께 사용할 수 있다.

    react-helmet 을 통해 메타 태그를 셋팅하고, react-snap 을 통해 index.html 을 생성한다.

     

    <Route path="/a" />
    <Route path="/b" />
    <Route path="/c" />

     

    정적 라우터인 경우에는 쉽게 적용해서 사용할 수 있다.

     

    • /a/index.html
    • /b/index.html
    • /c/index.html

     

    위와 같은 경로에 HTML 파일이 생성된다.

    하지만 위와 같은 형태의 라우터는 정적 라우터라고 표현해본다.

    그렇다면, 동적 라우터도 처리가 가능한가?

     

    <Route path="/content/:id" />

     

     

    react-snap 와 같은 라이브러리들은 내부적으로 자동으로 브라우저를 제어해서 해당하는 페이지가 로드된 후에 크롤링해서 index.html 를 생성한다.

    이러한 흐름에서 a, b, c 라는 페이지는 명확하게 알 수 있지만, 아시다시피 현재로선 /:id 는 id 가 무엇이고, 얼마나 있는지 전혀 알 수가 없다.

    동적 라우터는 어떤 URL 이 존재하는지 파악할 수 없기에 위 방식으로는 불가능하다.

     

    동적 라우터에 대한 처리는 어떻게 해야하는가?

     

    :id 에 해당하는 데이터들을 위한 별도의 API 나 파일과 같은 형태가 필요하다.

    그 데이터를 기반으로 지금껏 얘기했던 같은 이유로 index.html 파일을 생성해주면 된다.

     

    여기서는 prerender-spa-plugin 을 활용한다.

     

     

    친절하게 각 환경에서의 예제를 볼 수 있다.

    웹팩을 기반으로 빌드하는 시점에 각 동적 페이지들을 로드한 후, 그것을 기반으로 크롤링하여 index.html 을 생성하는 방식이다.

     

    prerender-spa-plugin 은 어떻게 사용하는가?

     

    웹팩 플러그인이라고 생각하면 된다.

     

    new PrerenderSPAPlugin({
        "routes": [],
        "staticDir": path.join(__dirname, "build"),
        "postProcess"(renderedRoute) {},
        "renderer": new PrerenderSPAPlugin.PuppeteerRenderer({
            "args": ["--no-sandbox", "--disable-setuid-sandbox"],
            "headless": false
        })
    })
    

     

     

    index.html 을 생성할 페이지에 해당하는 데이터들을 "routes" 에 넣어주면 된다.

    ex) "/content/1", "/content/2", ...

    예를 들어 다음과 같다.

     

    1. 빌드하는 시점에 쉘 스크립트를 실행한다.
    2. 쉘 스크립트는 API 를 호출하여 동적 페이지에 대한 데이터를 가져온다.
    3. 데이터를 기반으로 파일을 생성한다.
    4. 생성된 파일을 읽어서 PrerenderSPAPlugin 의 routes 에 넣어준다.

     

    // shell script
    OUTPUT=$(curl --location --request GET 'https://api.com/seo')
    echo "$OUTPUT" > seo.json

     

    // seo.json
    [
        {
            "route": "/content/1",
            "title": "",
            "description": "",
            "imageUrl": "",
            "url": ""
        },
        {
            "route": "/content/2",
            "title": "",
            "description": "",
            "imageUrl": "",
            "url": ""
        }
    ]
    

     

    // config-overrides.js
    const routes = require('./seo.json');
    
    module.exports = (config, env) => {
        if (env === 'production') {
            config.plugins = config.plugins.concat([
                new PrerenderSPAPlugin({
                    routes: [].concat(routes.map(item => item.route)),
                    staticDir: path.join(__dirname, 'build'),
                    postProcess(renderedRoute) {
                        let { html, route } = renderedRoute;
                        const { title, description, imageUrl, url } = routes.find(
                            item => item.route === route
                        );
                        const metaData =
                            `<title>${title}</title>` +
                            `<meta name="title" content="${title}" />` +
                            `<meta name="description" content="${description}" />` +
                            `<meta property="og:url" content="${url}" />` +
                            `<meta property="og:type" content="website" />` +
                            `<meta property="og:title" content="${title}" />` +
                            `<meta property="og:description" content="${description}" />` +
                            `<meta property="og:image" content="${imageUrl}" />` +
                            `<meta property="twitter:card" content="${imageUrl}" />` +
                            `<meta property="twitter:url" content="${url}" />` +
                            `<meta property="twitter:title" content="${title}" />` +
                            `<meta property="twitter:description" content="${description}" />` +
                            `<meta property="twitter:image" content="${imageUrl}" />`;
    
                        const start = html.indexOf('<head>') + '<head>'.length;
                        html = html.slice(0, start) + metaData + html.slice(start);
                        renderedRoute.html = html;
                        return renderedRoute;
                    },
                    renderer: new PrerenderSPAPlugin.PuppeteerRenderer({
                        maxConcurrentRoutes: 2,
                        args: ['--no-sandbox', '--disable-setuid-sandbox'],
                        headless: false
                    })
                })
            ]);
        }
    
        return config;
    };

     

    동적 URL 리스트를 통해 각 HTML 파일에 맞는 데이터를 웹팩 쪽에서 처리하는 모습이다.

    * 처음에는 react-helmet 을 통해 메타태그를 셋팅하고 PrerenderSPAPlugin 가 페이지를 긁어서 생성하려고 했다.

    때론 랜더링 시점이 맞지 않는 경우도 발생했고, 내부 로직에서 분리할 수 있으면 하는게 낫겠다 생각했다.

     

    결과적으로는 빌드 시 빌드된 파일들과 함께 셋팅된 각 index.html 들이 함께 생성된다.

     

     

    위와 같은 형태로 배포가 된다면, 각 페이지에 해당하는 HTML 파일을 가지게 된다.

    이로써, 각 페이지는 검색엔진이나 공유하는 기능 등에서 원하는 데이터를 포함하고 있다.

     

    또 하나의 이슈

     

    마지막으로 작업해줘야하는 문제가 한가지 남아있다.

    /content/3/index.html 형태로 파일이 존재하기 때문에 /content/3/index.html 로 접근해야한다.

    /content/3 이러한 형태로 크롤러가 접근하거나 링크를 공유할 때 문제가 발생한다.

     

    이를 위해 /content/3 으로 접근 시 /content/3/index.html 의 리소스를 내려주는 작업을 해줘야한다.

    예를 들어 CloudFront 의 경우에는 Lambda@Edge 통해 쉽게 해결할 수 있다.

     

    'use strict';
    
    exports.handler = (event, context, callback) => {
        /*
         * Expand S3 request to have index.html if it ends in /
         */
        const request = event.Records[0].cf.request;
        if ((request.uri !== "/") /* Not the root object, which redirects properly */
            && (request.uri.endsWith("/") /* Folder with slash */
                || (request.uri.lastIndexOf(".") < request.uri.lastIndexOf("/")) /* Most likely a folder, it has no extension (heuristic) */
                )) {
            if (request.uri.endsWith("/"))
                request.uri = request.uri.concat("index.html");
            else
                request.uri = request.uri.concat("/index.html");
        }
        callback(null, request);
    };

     

    잘 배포가 되었다면, metatags.io/ 과 같은 서비스를 통해 수집 여부를 파악할 수 있다.

     

     

    마무리

     

    매번 동적 페이지가 추가될때마다 그에 맞는 HTML 파일을 생성하는 방식이다.

    이러한 큰 틀에서 효율적인 방식을 점차 적용해나가면 되나 결국은 SSR 방식에 비해서는 다소 매끄럽지 못하다.

    많은 동적 페이지를 요구하고 SEO 최적화가 필요한 서비스라면 CSR 방식은 다소 무리라고 판단된다.

     

    * 잘못된 내용이나 다른 방안이 있다면, 알려주시면 감사할 따름입니다.

    반응형

    댓글

Designed by Tistory.