Next.js에서 동적 URL의 사이트맵 만들기 📄

yiyb0603·2022년 8월 20일
5

Next.js

목록 보기
1/1
post-thumbnail

안녕하세요! 정말 오랜만에 Velog 글을 적어보려고 합니다. 그동안 아 이거는 나중에 Velog 글 주제로 적어 봐야겠다! 라고 생각을 해놓고는 계속 미뤄왔었네요. 🥲🥲 그래서 앞으로 글을 적을때 회사 프로젝트를 진행하면서 알게되었던것들을 순서대로 적어보려고 합니다.

이번 시간에는 이전에 회사 프로젝트를 진행하면서 가장 핵심적인 액션중 하나인 동적 사이트맵 제작을 주제로 글을 적어보려고 합니다.

1. 사이트맵이 뭐야? 🧐

1-1. 검색엔진 최적화에서의 중요성

현대에는 다양한 검색엔진 사이트가 있습니다. 우리나라에서는 대표적으로 구글, 네이버, 다음을 뽑을 수 있습니다. 이 검색엔진에서는 다양한 콘텐츠들을 노출시키기 위해서 크롤러 봇이 각종 사이트들을 돌아다니면서 링크를 수집하게 됩니다.

현재 저희가 사용하고있는 Velog를 예시로 들어서 크롤러 봇의 행동과정을 설명해보겠습니다.

  1. 크롤러 봇이 https://velog.io 사이트로 진입
  2. 크롤러 봇이 사이트를 보면서 수집가능한 페이지인지 확인하기 위해서 각 글에 달린 링크들에 타고 들어가게 됩니다.
  3. 그리고, 트렌딩에 있는 대부분의 글은 수집가능한 페이지로 판단되면 글의 정보를 크롤러 봇이 수집하게 됩니다.
  4. 그리고 수집된 글들은 수일내에 검색엔진에서 검색이 가능한 상태로 노출되게 됩니다.

위의 과정은 크롤러 봇이 연결된 링크들을 통해서 페이지들을 수집하는 과정이였습니다.

하지만 크롤러 봇이 페이지를 수집하는 시간은 한정되어 있고, 한번에 수많은 페이지를 가져가기에는 무리가 있습니다. Velog 서비스의 경우, 수천 혹은 수만개의 글들이 트렌딩 탭에는 보이지 않은채로 등록되어 있을텐데 이를 연결된 링크들을 통해서 모두 수집이 가능할까요? 불가능합니다.

이러한 문제를 해결하기 위해서 크롤러 봇에게 사이트 크롤링 가이드 라인 맵을 제공해주는 사이트맵이 등장하게 되었습니다.

사이트맵은 검색엔진에 노출되어야하는 페이지의 링크들을 모아놓았으며, 크롤러 봇의 페이지 수집을 위한 지도 역할을 해줍니다.

Velog 서비스로 예시를 들면, 검색엔진에 노출되기 위해서 수집을 원하는 글들의 링크들을 모아놓은 사이트맵을 만들어 놓는다면 크롤러 봇은 이전에 연결된 링크들을 통한 페이지 수집 과정에서 사이트 내 노출이 안되었던 누락된 글들을 사이트맵을 통해서 수집 가능하도록 할 수 있게 됩니다.

이외의 사이트맵에 대한 정보를 알고싶다면 아래의 구글 사이트맵 가이드라인을 통해서 더 알아볼 수 있습니다.
https://developers.google.com/search/docs/advanced/sitemaps/overview?hl=ko

2. Next.js를 사용한 동적 사이트맵 구현

사이트맵에 등록할 페이지들이 정적인 페이지들에 대한 사이트맵의 경우에는 온라인 sitemap generator, npm 라이브러리 등 다양한 손쉬운 방법들이 검색하면 나오나, 동적인 URL에 대한 사이트맵의 경우에는 직접 구현해야하는 경우가 대다수 입니다.

저는 Next.js + TypeScript를 사용하여 동적 사이트맵을 구현해보도록 하겠습니다.

먼저 Next.js 프로젝트를 간단하게 만들어 봅시다.

npx create-next-app --typescript 를 실행후 터미널에서 묻는 질문들을 스텝별로 입력해주면 프로젝트가 생성됩니다.

프로젝트가 성공적으로 생성되고 나서 필요한 패키지들을 설치해줍시다!
저의 경우에는 아래의 패키지들을 설치해주었습니다.

yarn add axios @emotion/react dayjs

사이트맵은 xml 파일 형식으로 제작되어야 합니다. 그래서 페이지 URL 상으로는 ~~/*.xml 형식으로 나타나야 하는데요, pages 폴더를 통한 라우팅을 지원해주는 Next.js에서는 pages 폴더에 sitemap.xml.ts 라는 이름으로 파일을 만들어 주시면 됩니다

// pages/sitemap.xml.ts
import { NextPage } from 'next';

const SitemapPage: NextPage = () => {
  return null;
}

SitemapPage.getInitialProps = async (ctx) => {

}

export default SitemapPage;

사이트맵에 적힐 xml 내용들의 경우에는 getInitialProps 함수에서 구현되어 적용되기 때문에, Page 컴포넌트에서는 null 등 빈값을 리턴해주면 됩니다.

xml 내용을 통한 사이트맵 구현 전, 사이트맵의 xml 형식은 아래와 같습니다.

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>http://www.example.com/foo.html</loc>
    <lastmod>2018-06-04</lastmod>
  </url>
</urlset>

이외 사이트맵 xml 형식들은 아래의 링크를 참조해주세요.
https://developers.google.com/search/docs/advanced/sitemaps/build-sitemap?hl=ko

위의 xml 태그중, <url></url> 태그 사이에 들어가야 하는 내용은 검색엔진에 노출되어야 할 페이지의 정보를 담고 있어야 합니다.

<loc></loc>: 노출될 페이지의 URL
<lastmod></lastmod>: 페이지의 정보가 마지막으로 수정된 날짜 (YYYY-MM-DDTHH:mm:ssZ 형식)

<url></url> 태그로 들어가는 내용들은 코드상으로 반복문을 사용하여 문자열을 합친채로 기본 XML 템플릿 사이에 넣어주면 되겠죠? 👀 기본 XML 템플릿 사이에 문자열을 넣어주는 함수를 만들어줍시다.

const insideXMLString = (xmlContent: string): string => {
  return `<?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">
      ${xmlContent}
    </urlset>
  `;
}

이제 동적인 사이트맵에 뿌려줄 데이터 API를 사용해보려고 하는데요, 간단한 예시 구현을 하기 위해서 JSONPlaceholder API를 사용하여 글 목록을 불러오는 API를 연동해봅시다.

JSONPlaceholder의 글 목록을 불러오는 API 주소: https://jsonplaceholder.typicode.com/posts

위의 API는 페이징이 아닌, 모든 정보를 전달해주는 API라고 가정하고 사용하겠습니다.

🧐 Question: 만약 저희 서버에서 페이징으로 데이터를 주는 경우 어떻게 해야하나요?
📄 Answer: 저도 회사 프로젝트의 경우 처음에 서버에서 페이징으로 데이터를 주었는데, 사이트맵 구현을 위해서 백엔드 개발자분하고 상의를 한 후에, 사이트맵을 위한 검색엔진에 노출되는 조건 및 데이터 필드들을 내부에서 필터링 후 전체를 전달하는 API를 서버에서 새로 만들었습니다.

외부 API를 가져오는 함수를 repository/post.repository.ts 경로의 파일을 만들어서 작성해주겠습니다.

import axios, { AxiosInstance } from 'axios';

type Post = {
  id: string;
  title: string;
}

class PostRepository {
  private instance: AxiosInstance;

  constructor() {
    this.instance = axios.create({
      baseURL: 'https://jsonplaceholder.typicode.com',
    });
  }

  async fetchPosts() {
    const { data } = await this.instance.get<Post[]>('/posts');

    return data;
  }
}

export default new PostRepository();

이제 API 통신 함수를 pages/sitemap.xml.ts에서 사용해보도록 할까요?

import dayjs from 'dayjs';
import postRepository from '../repository/post.repository';

Sitemap.getInitialProps = async (ctx) => {
  const posts = await postRepository.fetchPosts();
  
  // 해당 API에서 updatedAt 등의 데이터가 없기에 현재 날짜 기준을 사용하여 임의로 지정해줍시다.
  const lastmod = dayjs().format('YYYY-MM-DDTHH:mm:ssZ');
  
  // 사이트맵에 등록될 페이지 xml 내용을 담을 문자열
  let pagesXML = '';
}

위의 코드에서 console.log(posts)를 찍어보면 아래와 같은 글 목록 정보들이 나옵니다.

불러온 글들이 약 100개 정도로 보이는데요, 이제 이 100개의 글 정보들을 xml로 담아줍시다 😁

import { NextPage } from 'next';
import dayjs from 'dayjs';
import postRepository from '../repository/post.repository';

const Sitemap: NextPage = () => {
  return null;
}

const insideXMLString = (xmlContent: string): string => {
  return `<?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">
      ${xmlContent}
    </urlset>
  `;
}

Sitemap.getInitialProps = async (ctx) => {
  const { res, req } = ctx;

  const posts = await postRepository.fetchPosts();
  console.log(posts);

  let pagesXML = '';

  const lastmod = dayjs().format('YYYY-MM-DDTHH:mm:ssZ');

  for (const { id } of posts) {
    // API의 데이터를 사용하여 사이트맵에 보여질 페이지의 URL 형식대로 작성해주시면 됩니다.
    // 저의 경우에는 /posts/{id} 형식의 페이지들을 사이트맵에 등록하기를 원하므로, 아래와 같이 작성해주었습니다.
    pagesXML += `
      <url>
        <loc>http://localhost:3000/posts/${id}</loc>
        <lastmod>${lastmod}</lastmod>
      </url>
    `;
  }

  // 기본 XML 템플릿 사이에 데이터들을 담은 XML 내용을 넣어줍시다. 
  const xmlContents = insideXMLString(pagesXML);

  if (res !== undefined) {
    // headers의 Content-Type을 text/xml로 설정해줍니다.
    res.setHeader('Content-Type', 'text/xml');

    // 완성된 XML 내용을 페이지에 노출될 수 있도록 write 함수를 사용합니다.
    res.write(xmlContents);

    res.end();
  }

  return {};
}

저는 /posts/{id} 형식의 페이지를 검색엔진에 노출됨을 원하기 때문에 pages/posts/[idx].tsx 경로에 파일을 만들어 간단하게 페이지를 만들어주었습니다.

import { css } from '@emotion/react';
import { useRouter } from 'next/router';

const PostPage = () => {
  const { query } = useRouter();
  
  const { idx } = query;

  return (
    <div css={container}>
      <p
        css={content}
      >
        {idx}번째 글입니다.
      </p>
    </div>
  );
}

const container = css`
  width: 100%;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
`;

const content = css`
  font-size: 2rem;
  font-weight: bold;
`;

export default PostPage;

http://localhost:3000/posts/2

http://localhost:3000/posts/10

이제 프로젝트를 실행하여 localhost:3000/sitemap.xml 경로로 들어가볼까요? 🐳

API로 불러왔던 글들의 정보들이 모두 사이트맵에 등록되었습니다. 짝짝짝 🎶

3. 사이트맵 참고사항 💡

위에서 만들어진 사이트맵은 구글의 경우에는 Google Search Console에다가 제작한 사이트맵과 사이트의 정보를 등록하면 되고, 네이버의 경우에는 네이버 서치어드바이저에다가 사이트맵과 사이트의 정보를 등록하면 각 검색엔진의 크롤러 봇이 사이트를 방문할 수 있습니다.

3-1. 사이트맵에 대한 각종 Q&A

기본적으로 google 검색 센터에 사이트맵에 대한 각종 정보들이 많이 담겨있습니다.

🧐 Question: 검색엔진에 노출시키기 위한 정보들이 글 정보들 말고 다른 분야들의 정보들도 함께 노출시키려고 합니다. 사이트맵을 분야별로 분리시킬 수 있나요?
📄 Answer: https://developers.google.com/search/docs/advanced/sitemaps/large-sitemaps?hl=ko 링크를 참고하여 Root 사이트맵 하위의 사이트맵들을 구현하면 됩니다. 구현 과정은 제가 설명드린 과정과 거의 같습니다.

🧐 Question: 사이트맵만 제대로 구현하면 검색엔진에 쉽게 노출되는건가요?
📄 Answer: 검색엔진에 노출되는 조건은 사이트맵 뿐만이 아닌 코어 웹 바이탈등의 성능 지표, 인식 가능한 HTML 구조도 중요합니다. 그리고 만약 각 페이지들의 초기 렌더링이 클라이언트 사이드 렌더링일 경우, 네이버와 다음의 크롤러 봇이 수집을 제대로 못할 가능성이 큽니다. (초기 HTML이 비어있는 이슈)

🧐 Question: 크롤러 봇이 사이트맵을 어느정도의 주기로 수집해가나요?
📄 Answer: 저희 회사 프로젝트의 경우에는 거의 매일 하루에 한번씩 사이트맵을 수집해가는것 같습니다. 초기의 데이터와 비교하여 추후에 누락되는 데이터에 대한 걱정은 없어도 됩니다. 만약 수집이 누락되는 경우에는 Robots.txt와 no-index 속성등이 올바르게 적용되어있는지를 우선적으로 확인하고, 문제가 없다면 구글의 경우에는 해당방법을 시도해보세요.

🧐 Question: 사이트에 아무 문제가 없는데 네이버의 검색엔진 미노출이 너무 심합니다.
📄 Answer: 저도 회사 프로젝트에서 이 문제를 오랫동안 겪었었는데요, 일단 네이버 고객센터에 문의를 해두는게 첫번째로 해야하는 일입니다.
그리고, 네이버 블로그와 카페 등에서 연결된 링크가 적어서, 혹은 중복된 콘텐츠라는 이유가 문제가 될 수도 있습니다. 만약 홍보가 가능한 소재의 페이지라면 링크를 사용하여 블로그에 작성해보는 등 시도해보는경우도 방법이 될 수 있습니다. (회사 프로젝트의 경우, 위처럼 홍보를 좀 하고나서 페이지가 점차 수집해나갔습니다.) 그러나 정확한 정답은 없습니다 😥😥

글에서 잘못된 부분이 있다면, 피드백 주시면 감사드리겠습니다! 😀 이상으로 글을 마치도록 하겠습니다. 감사합니다.

profile
블로그 이전: https://yiyb-blog.vercel.app

2개의 댓글

comment-user-thumbnail
알 수 없음
2022년 12월 13일
수정삭제

삭제된 댓글입니다.

1개의 답글