Life is connecting the dots .

[프로젝트] 리스티웨이브 SEO 개선하기(Dynamic Metadata, Sitemap, Robots.txt) (+. 9월까지 변화) 본문

Programming/Next.js

[프로젝트] 리스티웨이브 SEO 개선하기(Dynamic Metadata, Sitemap, Robots.txt) (+. 9월까지 변화)

soyeori 2024. 5. 19. 23:15

올해 2월 28일에 런칭한 웹어플리케이션 [리스티웨이브]는 단발성 프로젝트로 끝나지 않고, 지금까지도 꾸준히 회의를 통해 기능을 업데이트하고, 사용자의 피드백을 받아서 사용성을 개선하고 있다. 또한, 최근에는 구글에 영어로 listywave를 검색하면 최상단에 노출되는 점을 발견했다.🤩

리스티웨이브가 궁금하다면 👀

 

프로젝트에 Next.js를 선택하면서 기대했던 점 중에 하나는 바로 Next.js의 Metadata API를 사용해서 향상된 SEO를 적용할 수 있다는 점이었는데 기대만큼 잘 적용된 것 같다. Next.js의 Metadata의 상세 적용 방법이 궁금하다면 아래 이전 포스팅에서 확인할 수 있다.

 

2023.08.18 - [Programming/Next.js] - SEO 향상을 위한 Metadata API 적용하기

 

SEO 향상을 위한 Metadata API 적용하기

Next.js는 최적화(Optimizations)를 위해 기본적으로 제공되는 기능들이 있다. 공식문서를 참고하여 간략하게 소개하자면 먼저, 빌트인 컴포넌트를 사용하여 UI를 최적화하기 위해 복잡하게 구성해야

soyeori.tistory.com

✅ 문제 상황

한 가지 아쉬운 점은 구글에 한글로 [리스티웨이브]를 검색했을 때는 상위 노출이 안된다는 점과 모든 페이지에서 동일한 메타데이터가 보이는 점이다. 사용자의 모든 리스트를 보여주는 피드페이지의 메타데이터 타이틀을 커스텀 해 놓긴 했지만, 사용자별로 데이터를 다르게 한 것은 아니었다. 작성한 리스트 제목, 사용자 프로필 등으로 메타데이터를 커스텀한다면 추후 사용자 유치에도 도움이 될 것 같아 SEO 개선 작업을 진행해 보기로 했다.

개선하고 싶은 부분 및 목표

👉 구글 검색엔진에 리스티웨이브 한글 검색 상위 노출

👉 리스트 아이템이 검색엔진에 노출되어 리스티웨이브로 유입할 수 있도록 개선하기

✅ 시도한 작업

1️⃣ 기존 메타태그 수정

영어로 listywave를 검색하면 상위에 노출되는데 왜 한글은 아닐까? 를 생각해 보았는데 메타데이터를 설정할 때 한글 명칭을 적지 않았던 점이 원인이 될 수 있을 것 같았다. 최상위 layout 컴포넌트에서 설정한 메타데이터의 제목에 한글을 추가하여 수정해 주었다.

import type { Metadata } from 'next';

export const metadata: Metadata = {
  //  Template Object
  title: {
    template: '%s | ListyWave',
    default: 'ListyWave | 리스티웨이브', // 한글 제목 추가,
  },
  ...,
  openGraph: {
    ...,
  },
};

2️⃣ 사용자 프로필과 상세페이지에 적절한 메타데이터 적용 (Dynamic Metadata로 변경)

사용자 본인이 작성한 모든 리스트, 콜라보 리스트를 볼 수 있는 [마이 리스트 페이지], [콜라보 리스트 페이지]에서는 다음과 같이 Static Metadata를 설정해 주고 있었다. 

// 마이리스트 페이지
export const metadata: Metadata = {
  title: 'My List',
  description: '나의 취향을 기록한 나만의 리스트 입니다.',
};

// 콜라보리스트 페이지
export const metadata: Metadata = {
  title: 'Collabo List',
  description: '콜라보레이터와 함께 기록한 리스트 입니다.',
};

 

이 부분을 사용자별로 다르게 보여주기 위해 Dynamic Metadata로 변경하는 작업을 진행했다. 사용자 프로필 정보를 받아와서 닉네임을 title로 활용하고, 해당페이지를 공유할 때 커스텀해서 보여주기 위해 og 데이터의 description, image를 수정해 주었다. 참고로 동일한 경로 안에서 metadata object와 generateMetadata 함수를 모두 export 할 수 없으므로 선택해서 사용해야 한다. 

- generateMetadata 함수 적용하기

Next.js 공식문서를 참고하여 작성했으며, generateMetadata 함수는 서버 컴포넌트에서만 지원된다는 점을 알고 사용해야 한다. 또한, 공식문서의 샘플코드처럼 fetch API를 사용하면 generateMetadata, generateStaticParams, Layouts, Pages, and Server Components의 동일한 데이터에 대해 자동으로 메모한다.

 

하지만 프로젝트에서는 데이터를 fetch해오기 위해 axios를 사용하고 있었고, fetch 함수의 장점을 활용할 수 있는 부분이 당장은 없다고 판단해서 해당 페이지는 axios를 사용하여 데이터를 가져왔다. 추후 몇몇 컴포넌트를 서버 컴포넌트로 변경하는 리팩토링 작업에서 fetch 함수를 활용해 보려고 한다.

import { Metadata, ResolvingMetadata } from 'next';

interface MyListPageProps {
  params: { userId: number };
};

export async function generateMetadata({ params }: MyListPageProps, parent: ResolvingMetadata): Promise<Metadata> {
  const userId = params.userId;
  const { data } = await axiosInstance.get<UserType>(`/users/${userId}`);
  
  console.log(data); // data response
  console.log(await parent); // 상위에 설정된 메타데이터 정보

  // optionally access and extend (rather than replace) parent metadata
  const previousImages = (await parent).openGraph?.images || [];

  return {
    title: {
      absolute: `${data.nickname}'s Mylist`,
    },
    description: `${data.nickname}님의 취향을 기록한 리스트입니다.`,
    openGraph: {
      description: `${data.description || `${data.nickname}님의 취향을 기록한 리스트입니다.`}`,
      images: [`${data.profileImageUrl}`, ...previousImages],
    },
  };
}

 

위의 코드처럼 generateMetadata 함수를 활용하여 메타데이터를 동적인 값으로 설정해 주었다. 이제는 사용자별로 닉네임과 프로필 소개 데이터가 메타데이터로 설정되어, 검색엔진에도 페이지의 정확한 title을 부여할 수 있고, og에도 상세한 정보가 담길 수 있었다.

- 상세 리스트 페이지에 적용하기

마찬가지로 각각의 리스트를 확인할 수 있는 리스트 상세페이지에서도 동적으로 리스트 데이터 정보를 메타데이터로 넣어주는 작업을 진행했고, 최종 메타데이터를 상수 파일로 분리하여 여러 곳에서 데이터를 재사용 가능하도록 만들었다.

import { Metadata, ResolvingMetadata } from 'next';

interface ListDetailProps {
  params: { listId: number };
}

export async function generateMetadata({ params }: ListDetailProps, parent: ResolvingMetadata): Promise<Metadata> {
  const listId = params.listId;
  const { data } = await axiosInstance.get<ListDetailType>(`/lists/${listId}`);
  const { title, ownerNickname, collaborators, description, items } = data;

  const previousImages = (await parent).openGraph?.images || [];
  const listType = collaborators.length === 0 ? 'Mylist' : 'CollaboList';

  return {
    title: {
      absolute: `${ownerNickname}'s ${listType} - ${data.title}`,
    },
    description: `${description}`,
    authors: [{ name: `${ownerNickname}` }],
    openGraph: {
      title: `${title} By.${ownerNickname}`,
      description: `${description || `${ownerNickname}님의 취향을 기록한 리스트입니다.`}`,
      url: `https://listywave.com/list/${listId}`,
      type: 'website',
      images: [`${items[0].imageUrl}`, ...previousImages],
    },
  };
}

3️⃣ sitemap 설정

그다음으로 sitemap을 설정해 보기로 했다. 사이트맵은 이름 그대로 사이트에 있는 페이지, 동영상 및 기타 파일과 각 관계에 관한 정보를 제공하는 파일이다. 구글과 같은 검색엔진은 이 파일을 읽고 사이트를 더 효율적으로 크롤링한다. 그렇기 때문에 사이트맵은 내가 사이트에서 중요하다고 생각하는 페이지와 파일을 검색엔진에 알리는 역할을 한다고 생각하면 된다.

 

구글 공식문서에 따르면 사이트맵이 필요한 경우는 다음과 같다.

- 사이트 크기가 큰 경우

- 연결되는 외부 링크가 많지 않은 새로운 사이트

- 미디어 콘텐츠(동영상, 이미지)가 많거나 구글 뉴스에 표시되는 사이트

 

리스티웨이브 사이트는 규모가 작은 편이고, 한 페이지에 접속해도 연결되는 링크를 따라갈 수 있다. 하지만 계속해서 새로운 콘텐츠가 리스트에 생성되는 서비스이고, 사이트맵이 잘 작성되어 있으면 검색엔진 노출에도 영향을 줄 것 같아서 만들어 놓기로 했다.

 

Next.js 공식문서에 따르면 사이트맵을 설정하는 방법은 두 가지로 한 가지 사이트맵을 만드는 방법인 sitemap.xml 파일로 만드는 방법과 generateSitemaps 함수를 사용하여 다수의 사이트맵을 생성하는 방법이 있다. Next.js에서는 sitemap.(ts | js) 파일을 생성하면 sitemap 형식으로 output을 만들어 준다.

아직 규모가 작은 사이트이므로 generateSitemaps 함수를 사용할 필요가 없다고 생각했고, 우선 url이 변하지 않는 메인페이지, 인트로페이지, 탐색페이지를 sitemap.ts를 생성해서 만들어주었다.

// app/sitemap.ts

import type { MetadataRoute } from 'next';
import DOMAIN_URL from '@/lib/constants/domain';

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: DOMAIN_URL,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 1,
    },
    {
      url: `${DOMAIN_URL}/intro`,
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 0.8,
    },
    {
      url: `${DOMAIN_URL}/search`,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.8,
    },
  ];
}

 

사이트맵이 정상적으로 설정되었다면 아래와 같은 결과를 얻을 수 있다. (현재는 개발 단계이므로 localhost에서 확인한 결과)

// sitemap.xml 파일

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://listywave.com</loc>
	<lastmod>2024-05-19T08:36:04.350Z</lastmod>
	<changefreq>weekly</changefreq>
	<priority>1</priority>
  </url>
  <url>
	<loc>https://listywave.com/intro</loc>
	<lastmod>2024-05-19T09:39:05.527Z</lastmod>
	<changefreq>yearly</changefreq>
	<priority>0.8</priority>
  </url>
  <url>
	<loc>https://listywave.com/search</loc>
	<lastmod>2024-05-19T09:39:05.527Z</lastmod>
	<changefreq>weekly</changefreq>
	<priority>0.8</priority>
  </url>
</urlset>

- 동적인 데이터로 sitemap 생성하기

이렇게 3개의 페이지만 사이트맵을 설정하니 아쉬운 느낌이어서 그다음으로 중요한 페이지가 뭘까 고민했다. 리스티웨이브에서는 메인페이지에 트렌딩리스트라는 콜렉트를 가장 많이 받은 리스트 10개를 보여주고 있다. 콜렉트를 많이 받았다는 의미는 사용자의 관심을 많이 받고 있고, 그만큼 사이트에서 중요한 의미를 가진 리스트라는 생각이 들었다. 사이트맵에 해당 트렌딩 리스트를 클릭했을 때 연결되는 상세페이지를 넣어주면 구글 검색엔진이 효율적으로 크롤링할 것으로 예상했다. (*참고로 콜렉트란 리스트를 북마크 한다는 의미이다.)

트랜딩리스트 🌊

 

sitemap.ts파일에 트렌딩리스트를 가져오는 API를 호출하고, 받아온 리스폰스의 리스트 아이디로 리스트 상세 url을 만들어서 최종 10개의 사이트맵 객체가 추가로 담기도록 코드를 수정했다. 리스폰스를 받아올 때까지 기다려야 하므로 사이트맵 타입을 Promise<T>로 수정해 주었다.

// app/sitemap.ts

import type { MetadataRoute } from 'next';
import DOMAIN_URL from '@/lib/constants/domain';
import getTrendingLists from './_api/explore/getTrendingLists';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const result = await getTrendingLists(); // 추가

  const trendingLists: MetadataRoute.Sitemap = result.map((list) => ({ // 추가
    url: `${DOMAIN_URL}/list/${list.id}`,
    lastModified: new Date(),
    changeFrequency: 'weekly',
    priority: 0.6,
  }));
  
  return [
    {
      url: DOMAIN_URL,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 1,
    },
    {
      url: `${DOMAIN_URL}/intro`,
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 0.8,
    },
    {
      url: `${DOMAIN_URL}/search`,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.8,
    },
    ...trendingLists, // 추가
  ];
}

 

이렇게 작성하고 나니 sitemap.xml에 트렌딩리스트 10개에 해당하는 상세페이지 url 사이트맵이 포함된 점을 확인할 수 있었다.

4️⃣ robots.txt

robots.txt 파일은 검색 엔진 크롤러에게 크롤러가 사이트에서 요청할 수 있는 페이지 또는 요청할 수 없는 페이지를 알려준다.

Next.js 프로젝트에서 robots.txt파일을 추가하는 방법은 app 디렉터리 root에서 static 파일을 생성하여 추가할 수 있다. 사이트맵에 포함된 페이지가 검색엔진 크롤러에게 알려질 수 있도록 다음과 같이 설정해 주었다. 이렇게 하면 Next.js가 robots.txt파일을 생성해 준다.

// app/robots.ts

import DOMAIN_URL from '@/lib/constants/domain';
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: `${DOMAIN_URL}/sitemap.xml`,
  };
}

 

지금까지 리스티웨이브 사이트 구글 검색엔진 노출을 위한 SEO를 개선하기 위해 다음과 같은 과정을 진행했다. 이번 기회에 검색엔진 최적화를 위해 메타데이터뿐만 아니라 sitemap, robots.txt도 새롭게 알게 되었고, Next.js에서 관련함수 또는 file 컨벤션으로 쉽게 작성할 수 있는 기능을 제공해 줘서 적용해 볼 수 있었다.

 

Before

- layout 컴포넌트에 기본적인 Static Metadata 설정

 

After

- Static Metadata에 한글 사이트 제목 추가

- 일부 페이지에 Dynamic Metadata를 설정하여 적절한 메타 데이터로 수정

- sitemap 설정 (동적인 데이터 받아와서 sitemap 추가하기)

- robots.txt 추가

 

이 글은 개선 과정을 담은 글로 여기서 마무리하겠지만, 수정한 코드 반영 후에 한 달 정도 추이를 살펴보고 목표에 얼마나 가까워졌는지 결과를 적어볼 계획이다. 

 

✨ 7월 ~ 9월 추이

해당 PR이 7월 초쯤 반영됨에 따라 약 두달 후인 오늘(9/12일) 구글 서치 콘솔과 구글 애널리스틱 보고서를 통해 변화가 있는지 확인해보았다. 우선 처음에 목표했던 한글 검색 상위 노출은 아직 달성하지 못했다. 이 부분은 다른 방법을 좀 더 찾아봐야 할 것 같다.

 

- 색인이 생성된 페이지 증가

반면에 색인이 생성된 페이지가 늘어남을 확인했다. 구글 웹 크롤러가 웹을 돌아다니면서 크롤링을 하고, 크롤링된 페이지가 저장되면 그 페이지를 색인(Indexing)하게 된다. 색인은 구글이 페이지의 내용을 검색 결과에 포함할 준비를 한다는 의미이고, 해당 페이지의 텍스트, 이미지, 메타데이터 등을 분석하여 페이지를 파악하기 때문에 robots.txt, sitemap, metadata가 색인되는 과정에 기여하고 있다고 볼 수 있다.

 

구글 서치 콘솔로 확인한 색인생성 페이지

 

- 리스트 아이템 제목을 검색하여 리스트로 유입

구글 서치 콘솔로 검색 유입을 확인해보니, 검색어로 "카레이지 히피스"라는 키워드가 있었다. 그 클릭이 일어났던 페이지는 156번 리스트였고, 그 리스트 아이템 중 하나가 바로 "카레이지 히피스"임을 확인했다. 비록 1건이지만 아이템 제목이 검색어로 걸리고 있는 것을 확인할 수 있었다. 더불어 해당 리스트의 게재 순위가 낮음에도 유입되는 점을 봤을 때 더 많은 아이템 및 리스트 제목을 검색어로 노출시킬 수 있는 방법을 고민해봐야 겠다는 생각이 들었다.

 

구글 서치 콘솔로 확인한 검색어, 참고로 카레이지 히피스는 자동차 카페이다.