주니어도 할 수 있는 Next.js SEO - robots.txt와 sitemap.xml 자동 생성하기

bluestragglr·2020년 7월 19일
29
post-thumbnail

본 포스트는 개발과 배포에 대한 지식은 가지고 있지만 아직 조금 낯설고 어려운 주니어 개발자들을 대상으로 작성되었습니다. Circle CI, GitHub Actions같은 CI(Continuous Integration) 툴과 자동화 배포에 대한 간단한 이해가 필요합니다.

일련의 과정에서 CI를 이용해 구축된 자동 배포 시스템이 필요합니다. 자동화 배포 구축 방법이 궁금하다면 https://velog.io/@bluestragglr/Github-Action으로-배포-자동화하기 에 있는 내용을 따라 해 보시면 금방 아실 수 있을 거에요!

무엇을 할 수 있나요?

본 포스팅을 끝까지 따라가시면 검색엔진에 sitemap을 동적으로 자동으로 생성 및 제출할 수 있습니다. 크롤링을 허용하는 robots.txt 또한 간단하게 포함됩니다.

이 때, 모든 페이지를 재귀적으로 탐색하면서 동적으로 사이트맵을 생성하지는 않습니다. 해당 작업을 진행한 웹페이지(코드잇 웹페이지)가 유료 회원들만 접근할 수 있는 페이지와 어드민 페이지를 포함하고 있기 때문입니다. 조금 더 구체적으로는, 아래와 같은 방법으로 사이트맵을 생성합니다.

  • next.js 폴더 구조로부터 정적인 sitemap 생성
  • Google Analytics에서 높은 유입률을 보였던 커뮤니티 게시물의 전체 목록을 받아 동적인 sitemap 생성
  • 두 사이트맵을 gzip 압축 후 병합

쇼핑몰의 경우 모든 아이템 페이지 리스트로부터 사이트맵을 생성할 수도 있고, 블로그의 경우 모든 포스팅 목록으로부터 사이트맵을 생성하는 등 응용이 가능할 것입니다.

그걸 왜 하는데요?

만든 웹페이지가 검색에 좀 더 잘 노출될 수 있도록 하기 위함입니다. 사이트맵을 제출하면 검색엔진 크롤러가 좀 더 많은 페이지를 쉽게 탐색할 수 있습니다.

당장 웹에서 사이트맵을 자동 생성해 주는 페이지를 이용한다고 해 봅시다. 사이트맵 정보 없이 크롤링 한 결과, 아래와 같이 61개의 페이지만이 사이트맵에 포함되었습니다. 크롤러가 61개의 페이지에밖에 접근하지 못한 셈입니다. (위 이미지처럼 10000개가 넘는 페이지가 존재하는데도 불구하구요!)

구체적인 작업 순서

아래와 같은 순서로 진행하여 CI를 통한 자동 배포 단계에서 자동으로 sitemap을 생성하여 포함시킬 수 있는 구조를 만듭니다.

  1. robots.txt 생성 스크립트 작성
  2. 정적 sitemap 생성 스크립트 작성
  3. 동적 sitemap 생성 스크립트 작성
  4. sitemap 압축 및 병합 스크립트 작성
  5. 쉘 스크립트 작성하기
  6. Circle CI에서 쉘 스크립트 실행하기

파일 배치

아래에서 다루는 모든 스크립트 파일은 Next.js 프로젝트의 루트에 /script 폴더를 만들어 해당 폴더 아래에 위치하도록 했습니다. 즉, 아래와 같은 모습으로 구성하였습니다.

robots.txt 생성하기

가장 간단한 robots.txt 생성부터 시작해 봅시다. robots.txt는 웹 크롤러가 웹페이지를 수집하는 규칙을 담고 있는 파일입니다. 저는 아래와 같은 robots.txt를 생성하려고 합니다.

User-agent: *
Disallow: /[MY_ADMIN_PAGE_DIR]*/

이 때, [MY_ADMIN_PAGE_DIR] 에는 어드민 페이지와 같이 금지하고 싶은 경로를 입력합니다. 반대로, 특정 페이지만 허용하고 싶은 경우에는 Allow: /allowed-page*/ 처럼 쓸 수 있습니다.

아래와 같이 파일 입출력 모듈인 fs를 이용하여 구현할 수 있습니다.

// robots.js
const fs = require('fs')

const generatedSitemap = `
User-agent: *
Disallow: /[MY_ADMIN_PAGE_DIR]*/
`

fs.writeFileSync('../public/robots.txt', generatedSitemap, 'utf8')

저의 경우, 어드민 페이지를 제외하고는 크롤러가 긁어가는 것을 허용하고자 하였기 때문에 위와 같은 스크립트를 작성하였습니다.

정적 페이지에 대한 Sitemap 생성하기

일련의 사이트맵 생성 관련 코드는 https://medium.com/spemer/next-js를-위한-sitemap-generator-만들기-10fc917d307e 포스트를 많이 참고했음을 밝힙니다.

우선 next 프로젝트의 폴더 구조를 바탕으로 정적인 페이지들의 sitemap을 만들어 봅시다.

// sitemap-common.js

// 패키지 설치
const fs = require('fs')
const globby = require('globby')
const prettier = require('prettier')

// 오늘 날짜 가져오기 & 도메인 설정
const getDate = new Date().toISOString()
const CODEIT_DOMAIN = 'https://www.codeit.kr'

// 
const formatted = sitemap => prettier.format(sitemap, { parser: 'html' });

(async () => {
// 포함할 페이지와 제외할 페이지 등록
  const pages = await globby([
    // include
    '../pages/**/*.tsx',
    '../pages/*.tsx',
    // exclude
    '!../pages/_app.tsx',
    '!../pages/_document.tsx',
    '!../pages/_error.tsx',
    '!../pages/admin/*.tsx',
    '!../pages/api/*.tsx',
    // (...중간 생략)
    '!../pages/**/[t_id]/*.tsx',
    '!../pages/**/[t_id]/**/*.tsx',
  ])

// 파일 경로를 도메인 형태로 변경
// ../pages/category/index.tsx -> https://wwww.codeit.kr/category
// ../pages/community/threads -> https://wwww.codeit.kr/community/threads
  const pagesSitemap = `
    ${pages
    .map(page => {
      const path = page
        .replace('../pages/', '')
        .replace('.tsx', '')
        .replace(/\/index/g, '')
      const routePath = path === 'index' ? '' : path
      return `
          <url>
            <loc>${CODEIT_DOMAIN}/${routePath}</loc>
            <lastmod>${getDate}</lastmod>
          </url>
        `
    })
    .join('')}

  const generatedSitemap = `
  <?xml version="1.0" encoding="UTF-8"?>
    <urlset
      xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
      ${pagesSitemap}
    </urlset>`

  const formattedSitemap = [formatted(generatedSitemap)]

  fs.writeFileSync('../public/sitemap/sitemap-common.xml', formattedSitemap, 'utf8')
})()

동적으로 주소가 생성되는 [c_slug], [id] 등의 패스를 제외하도록 하고 xml 형태에 맞게 경로를 파싱하여 xml 파일에 추가하였습니다.

동적 페이지에 대한 Sitemap 생성하기

동적 페이지의 경우, 직접 크롤링을 구현하는 것이 아니라 백엔드로부터 존재하는 페이지 목록을 받아오는 방식으로 개발하였습니다. 백엔드가 GraphQL을 사용하고 있어 axios와 GraphQL을 이용해 페이지 목록을 받아와 파싱했습니다.

// sitemap-posts.js

// 필요한 모듈 로드
const axios = require('axios')
const fs = require('fs')
const prettier = require('prettier')

// 오늘 날짜 가져오기 & 도메인 설정
const getDate = new Date().toISOString()
const CODEIT_DOMAIN = 'https://www.codeit.kr'

const formatted = sitemap => prettier.format(sitemap, { parser: 'html' });
(async () => {
  let response = []

	// axios를 이용해 post 리스트 가져오기
	// <API_DOAMIN>, <API_NAME> 등은 실제 값이 아닙니다!
  await axios({
    method: 'post',
    url: 'https://<API_DOMAIN>/<API_NAME>',
    headers: {
      'Content-Type': 'application/graphql',
      'Authorization': [------------]
    },
    data:
      `{
    <GraphQL_APINAME> (
      ...
    ){
      title
			...
    }
  }`
  }).then((res) => {
    response = res.data.data.GraphQL_APINAME
  })
  .catch((e) => {
    console.log(e.response.data)
  })

  const postList = []
	// 적절히 파싱
  response.forEach(post=> postList.push({seqId: post.seqId, title: post.title}))

	// 요것도 xml 구조에 맞게 파싱하여 재조립
  const postListSitemap = `
  ${postList
    .map(post => {
      return `
        <url>
          <loc>${`${CODEIT_DOMAIN}/community/threads/${post.seqId}`}</loc>
          <lastmod>${getDate}</lastmod>
        </url>`
    })
    .join('')}
`

  const generatedSitemap = `
	<?xml version="1.0" encoding="UTF-8"?>
  	<urlset
    xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
  >
    ${postListSitemap}
  </urlset>
`

  const formattedSitemap = [formatted(generatedSitemap)]

  fs.writeFileSync('../public/sitemap/sitemap-posts.xml', formattedSitemap, 'utf8')
})()

정적인 경우와 마찬가지로 가져온 목록을 파싱하여 적절한 형태로 바꿔줍니다.

사이트맵 압축 및 병합

우선 사이트맵을 .gz 파일로 압축합니다. 여러 파일을 하나의 sitemap 파일에서 불러오는 구조로 바꾸기 위해서입니다.

const zlib = require('zlib')
// public/sitemap 디렉토리 내부에 sitemap들이 있으므로, 해당 폴더에서 실행합니다.
const dirs = ['../public/sitemap']
const fs = require('fs')

// 경로에서 xml파일을 찾아 .gz파일로 압축. 같은 경로에 저장됩니다.
dirs.forEach((dir) => {
  fs.readdirSync(dir).forEach((file) => {
    if (file.endsWith('.xml') && file !== 'sitemap.xml') {
      // gzip
      const fileContents = fs.createReadStream(dir + '/' + file)
      const writeStream = fs.createWriteStream(dir + '/' + file + '.gz')
      const zip = zlib.createGzip()

      fileContents
        .pipe(zip)
        .on('error', (err) => console.error(err))
        .pipe(writeStream)
        .on('error', (err) => console.error(err))
    }
  })
})

그 다음, 압축된 .gz 파일을 참조할 수 있도록 해주는 sitemap.xml 파일을 생성합니다.

const fs = require('fs')
const globby = require("globby");
const prettier = require("prettier");

const getDate = new Date().toISOString();
const CODEIT_DOMAIN = 'https://www.codeit.kr'

const formatted = sitemap => prettier.format(sitemap, { parser: "html" });

// public/sitemap 내부의 모든 .gz 파일을 불러와 참조하도록 합니다.
(async () => {
  const pages = await globby(["../public/sitemap/*.gz"]);

  const sitemapIndex = `
    ${pages
    .map(page => {
      const path = page.replace("../public/", "");
      return `
          <sitemap>
            <loc>${`${CODEIT_DOMAIN}/${path}`}</loc>
            <lastmod>${getDate}</lastmod>
          </sitemap>`;
    })
    .join("")}
  `;

  const sitemap = `
    <?xml version="1.0" encoding="UTF-8"?>
    <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      ${sitemapIndex}
    </sitemapindex>
  `;

  const formattedSitemap = [formatted(sitemap)];

  fs.writeFileSync("../public/sitemap.xml", formattedSitemap, "utf8");
})();

여기까지 사이트맵을 생성하고 하나의 파일에서 참조할 수 있도록 하기까지의 과정이 마무리되었습니다. 이제 각 스크립트를 한번에 순서대로 실행할 수 있는 쉘 스크립트를 생성하고 CI에 연결 해 봅시다.

쉘 스크립트 작성하기

여러 개의 스크립트를 쉽게 실행하기 위해서 쉘 스크립트를 만들어 줍시다.

# generate-sitemap.sh

# 퍼블릭 폴더로 이동
cd public

# 기존에 있던 사이트맵 폴더를 제거하고 빈 디렉토리를 만듦
rm -rf sitemap
mkdir sitemap

# 스크립트 폴더로 이동해서 아래의 순서대로 실행
cd ..
cd script

# robots.txt 생성
node ./robots.js

# 정적 sitemap 생성
echo "정적 sitemap 생성중.."
node ./sitemap-common.js
echo "정적 sitemap 생성 완료!"

#동적 sitemap 생성
echo "동적 sitemap 조회 및 생성중.."
node ./sitemap-posts.js
echo "동적 sitemap 생성 완료!"

# sitemap 압축 및 병합
echo "sitemap gzip 압축중"
node ./sitemap-compress.js
node ./sitemap.js
echo "sitemap 압축 완료"

# Google 서치콘솔에 sitemap 업데이트 핑 전송
curl http://google.com/ping?sitemap=http://release.codeit.kr/sitemap.xml
echo "Google에 sitemap 핑 전송"

해당 쉘 스크립트를 로컬 환경에서 실행하여 테스트 해 봅시다. sh script/generate-sitemap.sh 커맨드로 쉘 스크립트가 정상적으로 실행되었다면 public 폴더 내부에 sitemap.xml/sitemap 디렉토리가 생성됩니다. /sitemap 디렉토리 안에는 sitemap-common.xml, sitemap-common.gz, sitemap-posts.xml, sitemap-posts.gz 네 개의 파일이 존재하게 됩니다.

CI 연결하기

마지막으로 대망의 자동화가 남았습니다. 배포가 자동화되어 있다는 가정하에 간단하게 한 줄만 추가하여 완성할 수 있습니다. 빌드된 파일에 robots.txtsitemap.xml이 포함되어야 하므로 꼭 빌드 직전에 이루어져야 한다는 점을 명심하세요!

배포가 자동화되어 있지 않다면 여기(https://velog.io/@bluestragglr/Github-Action으로-배포-자동화하기) 에서 자동화를 한번 시도해 보세요! 만약 자동화를 정 원하지 않는다면 배포하기 전에 위에 작성한 쉘 스크립트를 직접 실행하고 함께 업로드해도 괜찮습니다.

jobs:
  build-image-and-push:
    docker:
      - image: circleci/node:lts
    steps:
      - checkout
      - setup_remote_docker:
          docker_layer_caching: false
      - run:
          name: Settings Environment Variables
          command: | ...(보안상 이유로 생략)
      - run: sudo npm install -g yarn@latest --force
      - run: sudo npm install -g npm@latest
      - run: npm install --no-optinoal
	  # 바로 여기! npm install 이후, build 이전에 삽입합니다!
      - run: sh script/generate-sitemap.sh
      - run: npm run build
      - aws-cli/install
      - aws-cli/configure
      - aws-ecr/ecr-login
      - aws-ecr/build-image:
          repo: '${FRONT_1_REPO}'
          dockerfile: Dockerfile.front-1
          tag: '$VERSION,latest'
      - aws-ecr/push-image:
          repo: '${FRONT_1_REPO}'
          tag: '$VERSION,latest'

다른 코드는 신경 쓰지 않으셔도 괜찮습니다. 아까 작성한 쉘 스크립트를 "npm install 이후, npm build 이전"에 실행하도록 해 주세요. 작성한 스크립트가 npm을 통해 설치되는 모듈을 사용하기 때문에 npm install 이전에 실행하려고 하면 에러가 납니다. 빌드가 된 후에는 생성해 봐야 패키지에 sitemap과 robot이 포함되지 않으니 무용지물이구요!

잘 됐는지 확인하기

수고하셨습니다! 여기까지 오셨으면 아마 정상적으로 사이트맵 생성을 자동화하고 배포하는 환경이 구축되었을 것입니다. 확인은 간단합니다. 사용중인 구글 서치콘솔이나 네이버 서치어드바이져 등에서 sitemap.xml을 제출 해 보고 정상적으로 제출되는지를 확인하면 됩니다. 배포 후 제출이 가능하기까지 15분 정도 소요될 수도 있으니 혹시라도 잘 안되었다면 잠깐 기다렸다가 다시 확인해보시는 것도 방법입니다.

하지만 보통은 거의 바로 되기때문에 안된다면 뭔가 잘못된 것이 있는지 확인해보시는게 좋습니다.

수고하셨습니다 🥳

profile
디자인하는 프론트엔드 개발자입니다. 우아한형제들에서 일하고 있습니다.

9개의 댓글

comment-user-thumbnail
2020년 7월 19일

저도 프론트엔드 개발자로 취업을 생각하고 있는데,
정말 멋지십니다~~

1개의 답글
comment-user-thumbnail
2020년 8월 22일

잘 읽었습니다.

1개의 답글
comment-user-thumbnail
2020년 10월 16일

우선 좋은 글 너무 감사합니다!! 그런데 혹시 gz 압축을 하신 이유가 뭔지 알 수 있을 까요??? google봇이 읽어가는데 장점이 있나요??? 그리고 /sitemap.xml 파일에 바로 다 안적고 분기해서 링크로 남겨놀 경우 SEO에 손해는 없는지 궁금합니다...

1개의 답글
comment-user-thumbnail
2020년 12월 15일

세상에 이런 멋진글이

1개의 답글
comment-user-thumbnail
2022년 9월 21일

안녕하세요 좋은 글 감사합니다. 저의 경우엔 백엔드에서 GrapeQL 사용하고 있지 않은데 이런경우에 sitemap-posts.js 파일 내용에서
"GraphQL을 이용해 페이지 목록을 받아와 파싱" <- 이 처리는 어떻게 되야할까 궁금합니다.

답글 달기