현록

Next.js App Router에서 검색 날짜와 사이트맵 관리하기

블로그 글을 최신화하면 검색결과에도 수정된 날짜가 보이면 좋다.
하지만 날짜를 한 곳만 바꾼다고 검색결과가 바로 바뀌는 것은 아니다.
사용자에게 보이는 날짜, 메타데이터, 구조화 데이터, 사이트맵이 서로 다른 날짜를 말하면 검색엔진이 페이지의 최신성을 해석하기도 어려워진다.

Next.js App Router에서는 이 흐름을 비교적 단순하게 연결할 수 있다.
글의 원본 메타데이터에서 발행일과 수정일을 관리하고, 페이지 메타데이터와 JSON-LD와 sitemap이 같은 날짜 기준을 바라보게 만들면 된다.

검색 날짜의 기준

Google은 검색결과에 표시되는 날짜를 보장하지 않는다.
사용자에게 보이는 날짜와 구조화 데이터의 날짜가 일관되면 날짜를 찾고 처리하는 데 도움이 된다고 설명한다.
따라서 목표는 특정 날짜 표시를 강제로 만드는 것이 아니라, 페이지가 스스로 일관된 신호를 제공하게 만드는 것이다.

기술 블로그에서는 보통 세 가지 날짜를 나눠두면 관리하기 쉽다.
date는 최초 발행일이다.
updatedAt은 내용을 의미 있게 고친 날짜다.
reviewedAt은 오래된 글을 검토했지만 내용은 크게 바꾸지 않은 날짜다.

검색엔진에 넘길 최신성 기준은 이 순서로 잡을 수 있다.

export function getPostFreshnessDate(metadata: {
  date: string
  updatedAt?: string
  reviewedAt?: string
}) {
  return metadata.updatedAt ?? metadata.reviewedAt ?? metadata.date
}

이렇게 기준을 함수 하나로 고정해두면 페이지와 사이트맵이 서로 다른 판단을 하지 않는다.
글 상세 화면에는 발행일과 수정일 또는 검토일을 함께 보여주고, 기계가 읽는 값에는 같은 최신성 기준을 전달한다.

generateMetadata의 modifiedTime

App Router의 동적 페이지에서는 generateMetadata로 글마다 다른 메타데이터를 만들 수 있다.
Next.js 문서 기준으로 generateMetadata는 route params나 외부 데이터처럼 동적인 정보에 따라 Metadata 객체를 반환할 때 사용한다.

블로그 상세 페이지에서는 slug로 글을 찾고, Open Graph의 publishedTimemodifiedTime을 채운다.

import type { Metadata } from 'next'

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params
  const item = await getPostBySlug(decodeURIComponent(slug))

  if (!item) {
    return {}
  }

  return {
    title: item.metadata.title,
    description: item.metadata.excerpt,
    openGraph: {
      title: item.metadata.title,
      description: item.metadata.excerpt,
      type: 'article',
      publishedTime: item.metadata.date,
      modifiedTime: getPostFreshnessDate(item.metadata),
    },
  }
}

여기서 중요한 점은 modifiedTime을 무조건 오늘 날짜로 넣지 않는 것이다.
실제로 글을 고쳤거나 검토한 날짜만 반영해야 한다.
날짜는 검색 노출을 위한 장식이 아니라 페이지 상태를 설명하는 값이다.

JSON-LD의 dateModified

JSON-LD는 검색엔진이 페이지의 구조를 이해하도록 돕는 structured data 형식이다.
Next.js 문서는 layout.jspage.js에서 <script type="application/ld+json"> 형태로 렌더링하는 방식을 안내한다.
Google도 Article 계열 structured data에서 datePublisheddateModified를 날짜 신호로 사용할 수 있다고 설명한다.

블로그 글이라면 BlogPosting 타입을 사용하고, dateModified에는 같은 최신성 기준을 넣는다.

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: item.metadata.title,
  description: item.metadata.excerpt,
  url: `${SITE_URL}/posts/${encodeURIComponent(item.slug)}`,
  datePublished: item.metadata.date,
  dateModified: getPostFreshnessDate(item.metadata),
  author: {
    '@type': 'Person',
    name: '안도현',
  },
}

직접 문자열을 조립하지 않고 JSON.stringify 계열로 직렬화하는 편이 안전하다.
사용자 입력이 섞일 수 있는 프로젝트라면 < 같은 문자를 이스케이프해서 script 안에서 의도치 않은 HTML이 열리지 않게 해야 한다.

function serializeJsonLd(value: unknown) {
  return JSON.stringify(value).replace(/</g, '\\u003c')
}

이 값도 화면에 보이는 수정일과 어긋나면 안 된다.
본문에는 “수정 6월 29일”이라고 보이는데 JSON-LD에는 다른 날짜가 들어가면, 페이지가 보내는 신호가 흐려진다.

sitemap lastModified 구성

Next.js App Router에서는 app/sitemap.ts 파일로 sitemap을 코드에서 생성할 수 있다.
공식 문서의 MetadataRoute.Sitemap 타입을 사용하면 URL 배열을 반환하는 방식으로 sitemap.xml을 만들 수 있다.

블로그에서는 모든 게시글을 읽어서 lastModified에 최신성 기준 날짜를 넣는다.

import type { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPosts()

  return [
    {
      url: SITE_URL,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 1,
    },
    ...posts.map((post) => ({
      url: `${SITE_URL}/posts/${encodeURIComponent(post.slug)}`,
      lastModified: new Date(getPostFreshnessDate(post.metadata)),
      changeFrequency: 'monthly' as const,
      priority: 0.8,
    })),
  ]
}

lastModifiedupdatedAt, reviewedAt, date 순서를 그대로 따른다.
새 글은 발행일이 lastmod가 되고, 기존 글을 크게 수정하면 updatedAt이 lastmod가 된다.
내용을 유지하면서 최신 문서 기준으로 검토만 했다면 reviewedAt을 lastmod로 사용할 수 있다.

정적 export를 쓰는 블로그라면 빌드 산출물의 sitemap.xml까지 확인하는 습관이 좋다.
코드에서 의도한 날짜와 실제 배포 파일의 날짜가 같은지 보는 것이 마지막 점검이다.

robots의 sitemap 위치

검색엔진이 sitemap 위치를 쉽게 찾도록 robots.ts에서도 sitemap URL을 노출한다.
Next.js는 app/robots.ts에서 MetadataRoute.Robots 객체를 반환하는 방식을 제공한다.

import type { MetadataRoute } from 'next'

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

이 설정은 모든 크롤러에게 페이지 접근을 허용하고 sitemap 위치를 알려준다.
비공개 경로나 관리자 경로가 있다면 disallow를 함께 설계해야 하지만, 공개 블로그라면 단순한 형태로 충분하다.

배포 후 확인

배포 전에 콘텐츠 검증과 빌드를 통과시키고, 빌드 결과의 sitemap을 확인한다.
정적 산출물이 있는 프로젝트라면 out/sitemap.xml에서 신규 글 URL과 lastmod를 찾는다.

npm run validate:content
npm run check:links
npm run check:content-quality
npm run check:assets
npm run build

배포 후에는 실제 도메인의 sitemap도 확인한다.

curl -fsSL https://example.com/sitemap.xml

확인할 값은 많지 않다.
신규 글 URL이 들어갔는지 본다.
기존 글을 수정했다면 lastmod가 수정 날짜로 바뀌었는지 본다.
글 상세 페이지의 화면 날짜, Open Graph modifiedTime, JSON-LD dateModified, sitemap lastModified가 같은 기준을 쓰는지 본다.

정리

검색 날짜 관리는 검색엔진을 속이는 작업이 아니다.
페이지가 언제 발행됐고 언제 의미 있게 바뀌었는지 일관되게 표현하는 작업이다.

Next.js App Router에서는 generateMetadata, JSON-LD script, sitemap.ts, robots.ts를 연결하면 이 흐름을 한 프로젝트 안에서 관리할 수 있다.
핵심은 날짜를 여러 곳에서 따로 계산하지 않는 것이다.
updatedAt → reviewedAt → date 같은 기준을 먼저 정하고, 사람이 보는 날짜와 기계가 읽는 날짜가 같은 기준을 공유하게 만들면 된다.

참고 자료

관련 포스트
Next.js 16 Cache Components와 use cache 정리 thumbnail
Next.js 16 Cache Components와 use cache 정리
Next.js 16에서 도입된 Cache Components와 use cache directive를 기준으로 App Router 캐싱 모델의 변화, cacheLife, cacheTag, revalidateTag 사용 흐름을 정리합니다.
Next.js App Router에서 레이아웃 사용하기 thumbnail
Next.js App Router에서 레이아웃 사용하기
Next.js App Router에서 app/layout.tsx, page.tsx, 중첩 layout을 사용해 공통 UI를 구성하는 방법을 정리합니다. Pages Router 시절의 수동 Layout 컴포넌트 패턴과 어떤 점이 다른지도 함께 봅니다.
next image blurDataURL 직접 부여하기 thumbnail
next image blurDataURL 직접 부여하기
Next.js의 Image 컴포넌트에서 blur placeholder를 사용할 때 자동 생성되는 경우와 직접 blurDataURL을 만들어야 하는 경우를 정리합니다. 정적 import, public 경로, 원격 이미지, 빌드 시점 생성 전략을 함께 다룹니다.
Next.js에서 path alias 설정하기 (feat. @/components) thumbnail
Next.js에서 path alias 설정하기 (feat. @/components)
오늘은 Next.js에서 import 시 복잡한 relative path 대신 absolute path 사용을 위한 설정법을 알아봅시다. file path를 상대경로로 지정하다보면 유지보수면에서도 복잡하고, path를 지정할 때마다 경로가 헷갈려서 발생하는 오류는 덤입니다. 이럴 때 path에 대한 alias를 설정하면 코드는 확 깔끔해질 것입니다.  아래 예제를 통해서 따라해봅시다.
Next.js에서 레이아웃 사용하기 thumbnail
Next.js에서 레이아웃 사용하기
홈페이지를 구성할 때, 우리는 대체로 네비게이션과 푸터, 플로팅 버튼 등을 포함합니다. 개발단계에서 이들 컴포넌트를 페이지마다 일일이 import하는 것은 매우 비효율적인 일입니다. 만약 이들처럼 대부분의 페이지에서 보여줘야될 내용이 있다면, 레이아웃 컴포넌트를 통해 손쉽게 유지보수할 수 있을 것입니다.