배포된 프로덕션에 대하여 검색 엔진 최적화(SEO)를 하는 작업은 유저 유입 비용 관련하여 매우 중요한 태스크이다. 오늘은 SEO 과정의 기초 중 하나인 사이트맵 작성을, 서버 프레임워크 없이 배포 중인 react에서 어떻게 설정하는지(특히 크롤링이 어려운 CSR react의 경우) 직접 작성해보도록 하자.
SPA이고, 대부분의 페이지 네비게이션은 js base인 react navigation을 사용하고 있기 때문에 자동 크롤링을 통한 사이트맵 생성이 불가능하다.
위와 같이 루트 페이지 이외에는 크롤링되지 않는다. 따라서 배포된 페이지에 대해 자동으로 사이트맵을 생성하는 것이 아니라, 개발 단계에서 사이트맵을 생성하거나 따로 사이트맵을 생성해서 띄우는 것이 좋아 보인다.
위 라이브러리는 리액트 라우터 형태로 작성되어 있는 path들에 대해 동적으로 사이트맵을 생성해주는 스크립트를 지원하며, 이를 통해 사이트맵을 자동으로 생성할 수 있다.
/router.jsx
import React from 'react';
import { Switch, Route } from 'react-router';
export default (
<Switch>
<Route path='/' />
<Route path='/about' />
<Route path='/projects' />
<Route path='/contacts' />
<Route path='/auth' />
<Route />
</Switch>
);
/sitemap-builder.js
require('babel-register')({
presets: ['es2015', 'react'],
});
const router = require('./router').default; // 위에서 작성한 라우터 파일 경로
const Sitemap = require('react-router-sitemap').default; // 사이트맵 라이브러리 임포트
(
new Sitemap(router)
.build('http://my-site.ru') // 앱 베이스 URL
.save('./sitemap.xml') // 사이트맵 저장할 경로
);
위와 같은 양식으로 두 개의 파일(라우터 파일, 사이트맵 생성용 스크립트 파일)을 작성한 뒤에 스크립트 실행을 위해 바벨 관련 라이브러리를 설치해준다.
$ npm install --save-dev babel-cli
$ npm install --save-dev babel-preset-es2015
$ npm install --save-dev babel-preset-react
$ npm install --save-dev babel-register
이제 위에서 작성한 sitemap-builder.js를 실행하는 명령어를 npm 예약어로 설정(package.json)하거나
/package.json
...
"scripts": {
...
"sitemap": "babel-node src/sitemap-builder.js",
"predeploy": "npm run sitemap"
}
...
아래와 같은 스크립트로 터미널에서 직접 사이트맵 생성을 시도하면
$ babel-node src/sitemap-builder.js
루트 폴더에 sitemap.xml이 생성된다.
…라고는 하는데,,
애초에 라이브러리를 다운받기 위한 스크립트에서 디펜던시 에러가 발생하는 것을 확인했다.
$npm install --save react-router-sitemap
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR!
npm ERR! While resolving: myApp
npm ERR! Found: react@18.2.0
npm ERR! node_modules/react
npm ERR! react@"^18.2.0" from the root project
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^15.1.0 || ^16.0.0" from react-router-sitemap@1.2.0
npm ERR! node_modules/react-router-sitemap
npm ERR! react-router-sitemap@"*" from the root project
npm ERR!
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
그냥 react 18.2.0을 사용하는 현재 프로젝트에서는 설치 자체가 안된다.
그렇다고 디펜던시를 무시하고 라이브러리를 설치하고 사용하기에는 리스크가 너무 크다고 느껴진다. 어떤 일이 벌어질 지 모르기 때문에… 그래서 해당 라이브러리 레포지토리의 이슈 탭을 확인해보니,
https://github.com/kuflash/react-router-sitemap/issues/122
다들 나와 같은 디펜던시 에러가 발생하고 있다는 것을 확인했다. 여러 글들을 찾아보니, 작년 11월까지 사용하시는 분들이 있는 걸로 보아서 아마 리액트 버전이 높아지면서 디펜던시 조건이 충족되지 않아 이제 사용이 어려워지게 되는 것으로 보인다.
https://www.npmjs.com/package/react-router-sitemap
개발 지원이 계속 이루어지지 않나? 싶어서 npm 페이지를 확인해 보니, last publish가 5 years ago이다…
애초에 해당 라이브러리에서 지원하는 react-router의 v4, 혹은 그 이하 버전은 이미 너무 옛날 버전의 react-router이다. 현재 react-router-dom은 2024년 4월 16일 기준 v6.4가 배포되어 있는 상태이기 때문에, 현재의 라우터 파일을 사이트맵으로 바로 변환하는 게 아니라, 그 시절 라우터 양식에 맞춰서 파일을 작성하고(위에서의 router.js), 이를 사이트맵으로 변환해주는 것이다.
사실상 해당 라이브러리를 사용하는 과정은, 애초에 지금 버전의 react-router에 대해서 자동으로 사이트맵을 생성해주는 것이 아니고, 임의의 사이트맵 생성용 데이터를 만들어서 그걸로 사이트맵을 만드는 과정이 되어버린 것이다. 물론 sitemap.xml 스탠다드의 업데이트가 있지는 않았을테니까, 사용하는데에 지장은 없다. 하지만 라이브러리 설치가 안된다…
그렇다면 두 가지 선택지가 있어 보인다.
첫 번째는 디펜던시를 무시하고 라이브러리를 설치해 라이브러리로 사이트맵 작성하기.
두 번째는 그냥 손으로 직접 사이트맵 작성하기
이다. 둘 다 시도해보자.
$ npm install --force --save react-router-sitemap
위 명령어로 그냥 냅다 라이브러리 설치를 해버렸다.
그리고 위에서의 flow대로 사이트맵 생성용 router파일을 작성하고, 사이트맵 생성용 스크립트를 작성한 뒤에 babel 확장 등을 설치하고, 명령어를 통해 사이트맵 생성을 시도해보자.
> babel-node sitemapBuilder.js
Warning: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.
module.exports=function(e){function t(n){if(r[n])return r[n].exports;var u=r[n]={exports:{},id:n,loaded:!1};return e[n].call(u.exports,u,u.exports,t),u.loaded=!0,u.exports}var r={};return t.m=e,t.c=r,t.p="",t(0)}([function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(1);Object.defineProperty(t,"default",{enumerable:!0,get:function(){return n(u).default}}),Object.defineProperty(t,"sitemapBuilder",{enumerable:!0,get:function(){return u.sitemapBuilder}}),Object.defineProperty(t,"routesParser",{enumerable:!0,get:function(){return u.routesParser}}),Object.defineProperty(t,"pathsFilter",{enumerable:!0,get:function(){return u.pathsFilter}}),Object.defineProperty(t,"paramsApplier",{enumerable:!0,get:function(){return u.paramsApplier}})},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}function u(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0}),t.pathsSplitter=t.sitemapBuilder=t.paramsApplier=t.pathsFilter=t.routesParser=t.routesCreater=void 0;var i=function(){function e(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}return function(t,r,n){return r&&e(t.prototype,r),n&&e(t,n),t}}(),a=r(2);Object.defineProperty(t,"routesCreater",{enumerable:!0,get:function(){return n(a).default}});var o=r(5);Object.defineProperty(t,"routesParser",{enumerable:!0,get:function(){return n(o).default}});var l=r(7);Object.defineProperty(t,"pathsFilter",{enumerable:!0,get:function(){return n(l).default}});var f=r(9);Object.defineProperty(t,"paramsApplier",{enumerable:!0,get:function(){return n(f).default}});var s=r(15);Object.defineProperty(t,"sitemapBuilder",{enumerable:!0,get:function(){return n(s).default}});var c=r(17);Object.defineProperty(t,"pathsSplitter",{enumerable:!0,get:function(){return n(c).default}});var d=r(18),p=n(d),h=r(19),v=n(h),m=r(16),g=n(m),y=n(a),_=n(o),b=n(l),P=n(f),O=n(c),j=n(s),M=function(){function e(t){if(u(this,e),!t)throw new Error("Need pass router in module");var r=(0,y.default)(t);return this.paths=(0,_.default)(r),this}return i(e,[{key:"filterPaths",value:function(e){return this.paths=(0,b.default)(this.paths,e.rules,e.isValid||!1),this}},{key:"applyParams",value:function(e){return this.paths=(0,P.default)(this.paths,e),this}},{key:"build",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=t.limitCountPaths,n=void 0===r?49999:r;return this.hostname=e,this.splitted=(0,O.default)(this.paths,n),this.sitemaps=this.splitted.map(function(t){return(0,j.default)(e,t)}),this}},{key:"save",value:function(e){var t=this,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"/",n=[];if(1===this.sitemaps.length)return p.default.writeFileSync(e,this.sitemaps[0].toString()),this;this.sitemaps.map(function(u,i){var a=e.replace(".xml","-"+i+".xml");p.default.writeFileSync(a,u.toString()),n.push(t.hostname+r+v.default.basename(a))});var u=g.default.buildSitemapIndex({urls:n,hostname:this.hostname});return p.default.writeFileSync(e,u),this}}]),e}();t.default=M},function(e,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n=r(3),u=function(e){return(0,n.isReactChildren)(e)?e=(0,n.createRoutesFromReactChildren)(e):e&&!Array.isArray(e)&&(e=[e]),e};t.default=u},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0}),t.createRoutesFromReactChildren=t.isReactChildren=t.isValidChild=void 0;var u=r(4),i=n(u),a=function(e){return null===e||i.default.isValidElement(e)},o=function(e){return a(e)||Array.isArray(e)&&e.every(a)},l=function e(t){var r=[],n=function(t){var r=t.type,n=Object.assign({},r.defaultProps,t.props);if(n.children){var u=e(n.children,n);u.length&&(n.childRoutes=u),delete n.children}return n};return i.default.Children.forEach(t,function(e){if(i.default.isValidElement(e))if(e.type.createRouteFromReactElement){var t=e.type.createRouteFromReactElement(e);t&&r.push(t)}else r.push(n(e))}),r};t.isValidChild=a,t.isReactChildren=o,t.createRoutesFromReactChildren=l},function(e,t){e.exports=require("react")},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(6),i=n(u),a=function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=Array.isArray(t);if(!n){var u=(0,i.default)(r,t.path),a=t.childRoutes,o=a&&a.length;if(!o)return[u];var l=e(a,u);return[u].concat(l)}return t.reduce(function(n,u){var a=(0,i.default)(r,t.path),o=e(u,a);return n.concat(o)},[])};t.default=a},function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return("/"+e+"/"+t).replace(new RegExp("/+","g"),"/").replace(new RegExp("^.*?|/$","g"),"")}},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(8),i=n(u);t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],r=arguments.length>2&&void 0!==arguments[2]&&arguments[2],n=(0,i.default)(e,t);return e=n.paths,t=n.rules,e.filter(function(n,u){if(n=n.trim(),!n.length)return!1;var i=e.indexOf(n)===u,a=t.some(function(e){return e.test(n)})===r;return i&&a})}},function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],r=Array.isArray(e),n=Array.isArray(t);return r||(e="string"==typeof e?[e]:[]),n||(t="string"==typeof t?[t]:[]),{paths:e,rules:t}}},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(10),i=n(u),a=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return!!t[e]};t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments[1];return t?e.reduce(function(e,r){return a(r,t)?e.concat((0,i.default)(r,t[r])):e.concat([r])},[]):e}},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(11),i=n(u);t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[];return t.map(function(t){return(0,i.default)(e,t)}).reduce(function(e,t){return e.concat(t)},[]).map(function(e){return e=e.replace(/\((.*:.*)\)/g,""),e=e.replace(/(\(|\))/g,"")})}},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(12),i=n(u);t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=Object.keys(t);return(0,i.default)([e],r,t)}},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(13),i=n(u),a=function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(!r.length)return t;var u=r.shift(),a=n[u];return t=(0,i.default)(t,u,a),e(t,r,n)};t.default=a},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(14),i=n(u);t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],n=new RegExp(":"+t);return r=Array.isArray(r)?r:[r],r.map(function(t){return(0,i.default)(e,n,t)}).reduce(function(e,t){return e.concat(t)})}},function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"";return e.map(function(e){return e.replace(t,r)})}},function(e,t,r){"use strict";function n(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var u=r(16),i=n(u);t.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"http://localhost",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[];return i.default.createSitemap({hostname:e,urls:t.map(function(e){return{url:e}})})}},function(e,t){e.exports=require("sitemap")},function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=function(e,t){return e.map(function(r,n){return n%t===0?e.slice(n,n+t):null}).filter(function(e){return e})};t.default=r},function(e,t){e.exports=require("fs")},function(e,t){e.exports=require("path")}]);
TypeError: Cannot read properties of undefined (reading 'createRouteFromReactElement')
와… 바벨 확장들도 모두 설치할 때 react 18.2에 대한 디펜던시 에러가 발생해서, —save-dev 플래그와 —force 플래그를 걸어서 설치한 뒤에 시도해보았다. 결과는 참담한 실패. react의 버전이 높아지면서 하위호환이 되지 않는 바람에 발생하는 오류로 예상된다. 겨우 사이트맵 생성하자고 모듈 소스 코드까지 까면서 트래블슈팅을 하는 건 너무 비효율적인 것 같다.
자, 위의 방법으로 사이트맵 작성 날먹하기가 모두 실패했으니 남은 방법은 당연히 사이트맵을 직접 작성해보는 거다. 사이트 맵은 단순 txt파일이나, xml파일, 심지어는 html로 이루어진 사이트맵도 존재한다.
우리는 그 중에서 가장 많이 쓰이는 xml파일을 작성해볼 것이다. 목표는 sitemap.xml 직접 작성하기와 이걸로 구글 네이버 제출하기 이다.
직접 작성하는 방식은 생각보다 어렵지 않다. 그냥 마크업으로 페이지 내의 패스들에 대한 메타정보들을 작성해주면 된다. sitemas.org에서 sitemal.xml의 작성 양식에 대해서 확인할 수 있다.
https://www.sitemaps.org/protocol.html
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://www.example.com/</loc>
<lastmod>2005-01-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>http://www.example.com/a</loc>
<lastmod>2005-01-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
위와 같은 형태로 작성하면 된다. 각 태그들에 대한 정보들은 아래와 같다.
이제 배포 중인 페이지에 해당 파일을 업로드하고, baseURL/sitemap.xml 등으로 접근이 가능한 지 확인한 후에, sitemap URL을 구글 서치 콘솔과 네이버 서치 어드바이저에 들어가서 업로드해주면 된다.