블로그에 SEO 적용하기 (w. Next.js)

2/3/2025FE

오랜만에 블로그를 새단장하며 이전에 Next.js로 마이그레이션하며 미처 추가하지 못했던 SEO 관련해서 설정해보고자 합니다.

SEO?

서재응 아님 주의

SEO (Search Engine Optimization)는 검색엔진 최적화의 줄임말로, 웹사이트가 검색 결과에 더 잘 보이도록 최적화하는 과정입니다.
흔히 SEO라고 하면 시맨틱 태그 사용, 사이트맵 설정 등 여러 가지 방식이 따라오곤 하는데, SEO는 검색 순위를 개선하는 과정 자체이기 때문에 각각의 방식은 SEO를 잘 적용하기 위해 사용하는 하나의 예시일 뿐입니다.


보통 검색 엔진은 사이트를 크롤링하며 찾은 콘텐츠의 색인을 생성하는데, 우리가 검색해서 보는 목록은 그 색인에서 뽑아낸 콘텐츠입니다. 이 크롤러는 일정한 규칙을 따라가므로 해당 규칙을 잘 지킨다면 사이트가 검색 결과의 최상위에 노출될 가능성이 높아지는 것입니다.1


저는 구글 서치 콘솔에만 블로그를 등록해둘 예정이라 Googlebot 관련하여 SEO 관련 가이드를 참고했습니다.

Google이 내 콘텐츠를 찾을 수 있도록 하기

구글은 콘텐츠를 잘 찾을 수 있는 방식으로 세 가지를 제안합니다.

  1. 이미 색인이 등록된 콘텐츠에 내 콘텐츠를 링크로 걸어두기
  2. 사이트맵 제출하기
  3. Google이 사용자와 같은 방식으로 내 페이지를 인식할 수 있는지 확인하기

1번은 제가 혼자서 할 수 있는 부분이 아니고, 3번은 구글서치콘솔에 등록해야 알 수 있는 부분이기에 2번의 방식을 설정을 통해 적용해보기로 합니다.

사이트맵 제출하기

Next.js에서는 기본적으로 사이트맵을 만들기 위한 방법으로 직접 1. sitemap 파일을 추가하는 방식2. sitemap을 만드는 코드를 작성하는 방식 두 가지를 소개합니다.
제 블로그는 간단한 형태만 띄고 있기에 추가해야 할 링크가 많지는 않으나, 매번 글을 작성할 때마다 sitemap을 건드리는 건 개발자로서 용납할 수 없는 부분이겠죠. 자고로 참된 개발자는 자동화를 해야합니다. (단호)

저는 그래서 정적 사이트맵 파일을 넣기보다는 자동화하여 알아서 배포할 때마다 새로운 사이트맵을 만드는 방식을 사용했습니다.


Next.js에서는 SSR 페이지에 한정하여 Node.js의 모듈을 사용할 수 있습니다. (기본적으로 CSR을 적용하지 않았다면 SSR이니까 대부분은 사용할 수 있겠죠?)
때문에 Next.js로 구현한 블로그에서도 fs 모듈을 사용하여 편하게 마크다운 형식으로 작성한 파일의 내용을 읽어오고 있었는데, 비슷하게 fs 모듈을 사용해서 모든 파일의 메타데이터를 읽어와 그 메타데이터를 사용하여 사이트맵을 동적으로 생성할 수 있도록 만들어보려고 합니다.

블로그 글의 메타데이터 목록 읽어오기

저는 블로그 글 파일을 아래와 같은 구조로 관리하고 있는데요.

📦public
 ┣ 📂posts
 ┃ ┣ 📂FE
 ┃ ┃ ┣ 📜2022-10-15-ts-loader-vs-babel-loader-vs-esbuild-loader.md
 ┃ ┃ ┣ 📜2023-06-01-졸업프로젝트.md
 ┃ ┃ ┣ 📜2023-06-22-Vite가-뭘까.md
 ┃ ┃ ┗ 📜2025-02-03-블로그에-SEO-적용하기.md
 ┃ ┗ 📂회고
 ┗ ┃ ┗ 📜2025-01-23-2024년-회고.md

여기에서 FE, 회고에 해당하는 부분은 카테고리인데, 먼저 이 카테고리 목록을 가져오겠습니다.

import { readdirSync } from 'fs';
import path from 'path';

const categories = readdirSync(path.resolve(process.cwd(), 'public', 'posts'));

그럼 이렇게 가져온 카테고리 폴더를 돌면서 파일을 읽어오면 모든 글에 대한 메타데이터 목록을 만들어낼 수 있습니다.

import { readdirSync } from 'fs';
import path from 'path';

const metadata = categories.reduce((accMetadata, category) => {
  const categoryMetadata = readdirSync(
    path.resolve(process.cwd(), 'public', 'posts', category),
  ).map(fileName => readMetadata(category, fileName));

  return [...accMetadata, ...categoryMetadata];
}, []);

마크다운 파일의 메타데이터를 읽어오는 부분은 gray-matter라는 라이브러리를 사용하고 있는데, 이건 별로 중요하지 않은 부분이라 생략하겠습니다. 만약 마크다운 파일에서 메타데이터를 읽어오는 코드가 궁금하시면 제 깃허브 코드를 참고해주세요!


이렇게 하면 모든 블로그 글 파일의 메타데이터를 읽어오는 코드가 아래와 같이 만들어집니다.

import { readdirSync } from 'fs';
import path from 'path';

const readAllPostMetadata = () => {
  const categories = readdirSync(path.resolve(process.cwd(), 'public', 'posts'));

  return categories.reduce((accMetadata, category) => {
    const categoryMetadata = readdirSync(
      path.resolve(process.cwd(), 'public', 'posts', category),
    ).map(fileName => readMetadata(category, fileName));

    return [...accMetadata, ...categoryMetadata];
  }, []);
};
동적 사이트맵 생성하기

사이트맵 자동화를 위해서는 정적 사이트맵 파일을 자동화하는 것보다 코드를 사용하여 매 빌드마다 알아서 생성하도록 하는 편이 좀 더 깔끔할 것 같아서 사이트맵을 생성하는 코드를 작성할 겁니다.
공식에서 소개하고 있다시피 코드를 사용하여 사이트맵을 생성할 때는 app/sitemap.ts 파일 내에 사이트맵을 생성하는 코드를 작성합니다. (app router 기준)


제가 사이트맵에 등록해야 할 경로는 블로그 메인과 블로그 글 이렇게 두 가지입니다. 이 두 페이지의 사이트맵을 생성하는 코드는 아래와 같이 작성할 수 있습니다.

import type { MetadataRoute } from 'next';

const baseUrl = 'https://lah1203.vercel.app';

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = readAllPostsMetadata();

  return [
    {
      url: baseUrl,
      lastModified: new Date().toISOString().split('T')[0],
      changeFrequency: 'daily',
    },
    ...posts.map(post => ({
      url: `${baseUrl}/post/${post.category}/${post.fileName.split('.')[0]}`,
      lastModified: post.date.toISOString().split('T')[0],
      changeFrequency: 'daily',
      priority: 0.7,
      images: [post.thumbnail],
    })),
  ];
}

저는 블로그 링크를 굳이 숨길 필요는 없어서 상수화시켜 적용했는데요. 필요하다면 환경 변수로 관리하여 숨겨도 좋을 것 같습니다.


저렇게 작성한 후 빌드하면 블로그 링크/sitemap.xml로 접속하여 생성된 사이트맵을 볼 수 있습니다. 예를 들면, 제 블로그의 사이트맵은 아래와 같이 생성됩니다. (일부 잘라냄)

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
  <script />
  <url>
    <loc>https://lah1203.vercel.app</loc>
    <lastmod>2025-02-03</lastmod>
    <changefreq>daily</changefreq>
  </url>
  <url>
    <loc>https://lah1203.vercel.app/post/FE/2025-02-03-블로그에-SEO-적용하기</loc>
    <image:image>
      <image:loc>https://github.com/user-attachments/assets/b6067f3b-f4f3-4516-9429-0b3997c3550d</image:loc>
    </image:image>
    <lastmod>2025-02-03</lastmod>
    <changefreq>daily</changefreq>
    <priority>0.7</priority>
  </url>
  <url>
    <loc>https://lah1203.vercel.app/post/회고/2025-01-23-2024년-회고</loc>
    <image:image>
      <image:loc>https://github.com/user-attachments/assets/e89a20c2-fc49-4b86-a0b8-e1426d9cc8fa</image:loc>
    </image:image>
    <lastmod>2025-01-23</lastmod>
    <changefreq>daily</changefreq>
    <priority>0.7</priority>
  </url>
</urlset>

이렇게 하고 구글서치콘솔에 블로그를 등록하고 만든 sitemap url을 등록해두면 됩니다.

사이트 구성하기

마찬가지로 사이트를 논리적으로 구성하면 검색엔진과 사용자가 페이지의 내용을 유추하거나 이해하는 데 도움이 되기 때문에 초기에 미리 논리적으로 구성해두는 게 좋습니다.


구글은 사이트를 구성할 때 다음과 같은 방식을 추천합니다.

  1. 설명 URL 사용하기
  2. 사이트를 재미있고 유용하게 만들기
  3. Google 검색에 사이트가 표시되는 방식에 영향 미치기
  4. 사이트에 이미지 추가 및 최적화하기
  5. 동영상 최적화하기
  6. 웹사이트 홍보하기

여기에서는 설정을 통해 바로 적용해볼 수 있는 1, 3번을 다뤄보려고 합니다.

설명 URL 사용하기

사용자는 URL을 보고 자신에게 도움이 될 지 여부를 스스로 판단할 수 있습니다. 그렇기 때문에 사용자에게 도움이 될 만한 단어를 URL에 포함시키는 게 좋습니다. 임의의 식별자만 포함된 URL은 사용자에게 도움이 되지 않습니다.


예를 들면 다음과 같습니다.

good : https://www.example.com/pets/cats.html
bad : https://www.example.com/2/6772756D707920636174

또한 디렉터리에서 주제별로 유사한 페이지를 그룹화하는 것이 좋습니다.


사이트에 URL이 수천 개 이상 포함되어 있다면 콘텐츠를 구성하는 방식이 크롤링 및 색인 생성에 영향을 미칠 수 있는데, 특히 폴더를 사용하여 비슷한 주제를 그룹화하면 개별 디렉터리의 URL이 변경되는 빈도를 파악하는 데 도움이 됩니다.


예를 들어 자주 변경되지 않는 콘텐츠를 A, 자주 변경되는 콘텐츠를 B에 두었다면 구글은 이 정보를 학습하여 각 디렉터리를 서로 다른 빈도로 크롤링할 수 있습니다. 이때 sitemap의 changeFrequency를 떠올릴 수도 있겠으나, 이는 어디까지나 콘텐츠가 변경되는 빈도이지 크롤링해가는 빈도가 아니기 때문에 이 부분을 간과해서는 안됩니다.


위 두 가지 예시를 참고하여 저는 블로그 링크의 구조를 잡을 때 아래의 기준을 세웠습니다.

  • 모든 글은 /post 하위에 둘 것
  • 각 글의 URL에 카테고리와 제목을 포함할 것

그래서 제 블로그의 모든 글은 ${BASE_URL}/post/category/title 의 구조로 링크가 만들어집니다.

Google 검색에 사이트가 표시되는 방식에 영향 미치기

여기에서는 title과 description 같은 메타데이터의 중요성을 언급합니다.


일반적인 Google 검색 결과 페이지는 몇 가지 시각적 요소로 구성되어 있으며 사용자가 검색 결과를 통해 사이트를 방문할지 결정하는 데 도움을 주도록 영향을 미칠 수 있습니다. 이때 메타데이터를 적절하게 설정하면 검색엔진에서 이를 읽어 표시하므로, 사용자에게 하여금 콘텐츠에 대한 이해를 도울 수 있겠죠.


기본적으로 title, description만 수정한 상태에서 배포했을 때는 아래 이미지처럼 title, description 외에는 모두 default로 나옵니다.

구글 검색 결과

여기에서는 1. 블로그 로고가 보이지 않는다는 점, 2. Vercel로 설정된 기본 타이틀이 나온다는 점이 가장 먼저 보이네요.
메타데이터로 이미지와 타이틀을 설정해보겠습니다.

Next.js에서 기본 메타데이터 설정하기

Next.js에서는 정적 메타데이터를 설정할 때 metadata 객체를 만들어 export해도 인식할 수 있도록 제공하고 있습니다.
사이트의 기본 메타데이터를 동적으로 설정할 필요성은 못 느꼈기에 정적 메타데이터 설정 방식인 app/layout.tsx 에서 metadata 객체를 만들어 export하는 방식을 사용했습니다.

export const metadata = {
  title: '거북이로그',
  description: '프론트엔드 개발자 이아현의 블로그',
  openGraph: {
    type: 'website',
    title: '거북이로그',
    description: '프론트엔드 개발자 이아현의 블로그',
    locale: 'ko',
    url: 'https://lah1203.vercel.app',
    images: [
      {
        url: 'https://github.com/user-attachments/assets/21af14de-59cb-4848-a058-5476e4bcb2b5',
      },
    ],
  },
  authors: {
    url: 'https://github.com/LAH1203',
    name: '이아현',
  },
};

간단하게 기본 메타데이터만 설정했습니다. 가볍게만 설명해볼게요.

title, description

title과 description은 크롤러 봇이 사이트 정보를 수집해갈 때 공통적으로 수집해가는 가장 기본이 되는 정보입니다. title의 경우에는 실제 사이트의 상단에서 볼 수 있는 정보이기도 하죠.

openGraph

openGraph는 흔히 og 메타데이터라고 부르는 항목입니다. 다양한 매체를 통해 사이트의 링크를 공유할 때 아래 이미지처럼 카드 형식으로 사이트에 대한 정보가 뜨곤 하는데, 이때 많이들 사용하는 게 바로 이 og 메타데이터입니다.

카카오톡 링크 공유

Next.js에서는 openGraph를 설정하면 동일한 메타데이터 태그를 og:, twitter:로 각각 생성하여 넣어줍니다.

openGraph 적용 결과
author

author은 그냥 괜찮아보여서 넣어봤습니다. 이 사이트의 게시자가 누구인지를 명시하는 부분이에요.

Next.js에서 동적 메타데이터 설정하기

추가로 각 글에서는 동적으로 메타데이터가 설정되면 좋을 것 같다고 생각했습니다. 이를 위해 Next.js에서 제공하는 generateMetadata를 활용했습니다.

export const generateMetadata = async ({ params }) => {
  const metadata = await params;
  const category = decodeURIComponent(metadata.category);
  const fileName = `${decodeURIComponent(metadata.fileName)}.md`;
  const post = await readMetadata(category, fileName);

  if (!category || !fileName || !post) return null;

  const { title, description, thumbnail } = post;

  return {
    title: `${title} : 🐢`,
    description,
    openGraph: {
      type: 'article',
      title: `${title} : 🐢`,
      description,
      locale: 'ko',
      url: `https://lah1203.vercel.app/${metadata.category}/${metadata.fileName}`,
      images: [
        {
          url: thumbnail,
        },
      ],
    },
  };
};

위에서도 설명했다시피 각 블로그 글 페이지에서는 링크의 파라미터에서 카테고리, 파일명을 가지고 오고 있습니다. 그리고 이 정보를 활용하면 글의 메타데이터를 읽어올 수 있습니다.
그래서 generateMetadata에서는 읽어온 글의 메타데이터에서 필요한 정보만 활용하여 가공 후 반환합니다.


End?

이렇게 기본적인 설정을 마치고 현재 구글서치콘솔에 사이트를 등록해두었습니다.

이전에는 직접 만든 블로그의 사이트맵을 봇이 제대로 찾지 못하는 문제를 겪고 있었는데, 이번에도 그럴지는 좀 더 지켜봐야 할 것 같습니다. 제발 구글이 정신차려서 잘 읽어와줬으면 좋겠네요 🙈

Footnotes

  1. https://developer.mozilla.org/en-US/docs/Glossary/SEO