블로그 운영을 하다가 어느 날 RSS 피드 자동 발견 링크(<link rel="alternate" type="application/rss+xml">)가 모든 포스트 상세 페이지에서 사라진 걸 발견했습니다. 홈에서는 멀쩡한데 포스트 페이지만 빈 자리. app/layout.tsx에 분명히 alternates를 박아 두었는데도 그랬습니다. 원인을 잡기까지 한참 헤맸는데, 결국은 Next.js metadata API가 nested 필드를 어떻게 합치는지에 대한 오해였습니다.

처음 의심한 곳은 layout이었다#

app/layout.tsx에는 다음과 같이 alternates를 설정해 두었습니다.

export const metadata: Metadata = {
  alternates: {
    canonical: absoluteUrl("/"),
    types: {
      "application/rss+xml": absoluteUrl("/feed.xml"),
    },
  },
};

이렇게 두면 모든 자식 페이지에서 <link rel="alternate" type="application/rss+xml" href="...">가 자동으로 따라 나올 거라 기대했습니다. RSS 자동 발견은 RSS 리더 호환성과 일부 검색엔진 색인에 직접 영향이 있어서 신경 써둔 부분이었습니다.

그런데 빌드 산출물을 열어 보니 홈에는 이 link가 있는데, 포스트 상세 페이지(/posts/[slug]/)에는 없었습니다. 첫 의심은 layout의 metadata 객체가 어떤 이유로 무시되는 거였는데, 디버깅해 보니 layout 자체는 정상이었습니다.

진짜 원인은 자식 segment의 alternates override#

자식 segment인 app/posts/[slug]/page.tsx에서 canonical URL만 페이지마다 다르게 두려고 alternates를 다시 export하고 있었습니다.

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);
  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: post.seo.canonicalUrl,
    },
  };
}

문제는 여기였습니다. Next.js 16 공식 문서가 명시한 동작은 다음과 같습니다.

Metadata objects exported from multiple segments in the same route are shallowly merged together to form the final metadata output of a route. Duplicate keys are replaced based on their ordering.

핵심은 shallowly merged. nested 필드 — alternates, openGraph, robots 같은 — 는 자식 segment가 한 번이라도 정의하면 부모의 같은 키가 통째로 대체됩니다. canonical만 바꾸려고 자식에서 alternates: { canonical: ... }를 export한 순간, 부모 layout이 박아 둔 types 필드도 같이 사라지는 겁니다.

deep merge라고 가정한 게 함정이었습니다. CSS나 React props처럼 자연스럽게 깊게 합쳐지는 영역과 다르게 metadata API는 한 단계만 합칩니다.

해결은 헬퍼 한 줄로 패턴을 강제하는 것#

가장 단순한 해결책은 alternates를 override하는 모든 페이지에서 항상 types도 같이 박아 주는 겁니다. 코드가 흩어지면 또 빠뜨릴 게 뻔하니, 헬퍼 함수로 한 줄에 묶었습니다.

export function siteAlternates(canonical: string) {
  return {
    canonical,
    types: {
      "application/rss+xml": absoluteUrl("/feed.xml"),
    },
  };
}

이제 alternates를 export하는 모든 segment에서 이 함수만 호출하면 됩니다. layout, 홈, about, 포스트 목록, 포스트 상세, 카테고리 페이지 — 전부 같은 헬퍼를 통해 alternates를 만듭니다.

// app/posts/[slug]/page.tsx
return {
  title: post.title,
  description: post.description,
  alternates: siteAlternates(post.seo.canonicalUrl),
};

자식 segment가 부모를 통째로 덮는다는 사실 자체는 바꿀 수 없습니다. 그러니 "덮어도 같은 결과가 나오도록" 헬퍼로 강제하는 쪽이 안전합니다. 이 한 줄짜리 헬퍼가 layout부터 카테고리 페이지까지 alternates 출력을 일관되게 유지해 줍니다.

비슷한 trap이 openGraph에도 있다#

같은 함정이 openGraph에도 있습니다. 자식 페이지에서 og:title만 다르게 쓰고 싶어 openGraph: { title }을 export하면, 부모가 박아 둔 og:image, og:type, og:locale 같은 게 다 같이 사라집니다.

이걸 방지하려면 마찬가지로 헬퍼 패턴을 쓰거나, 공유 필드를 별도 변수로 분리해서 매번 spread해 주는 방식이 있습니다. 공식 문서도 이 방식을 권장합니다.

const sharedOpenGraph = {
  type: "website" as const,
  locale: "ko_KR",
  siteName: siteConfig.name,
};

export const metadata: Metadata = {
  openGraph: {
    ...sharedOpenGraph,
    title: post.title,
    images: [{ url: post.coverImage }],
  },
};

...sharedOpenGraph를 빠뜨리는 순간 다시 깨지니, 이쪽도 결국 코드 컨벤션의 문제입니다. 공유 객체에 as const를 붙여 두면 spread할 때 타입 좁힘도 그대로 따라옵니다.

빌드 후 검증 항목으로 굳혔다#

다시 같은 함정에 빠지지 않으려고 블로그 SEO 설정 글에서 이미 정리한 빌드 후 30초 체크리스트에 한 줄을 추가했습니다.

  • 홈, 포스트 목록, 포스트 상세, 카테고리 페이지 모두 <link rel="alternate" type="application/rss+xml">을 포함하는가

빌드 산출물이 out/ 디렉터리로 떨어지는 정적 export 환경이라, grep 한 줄이면 빠르게 확인할 수 있습니다.

grep -r 'rel="alternate" type="application/rss\+xml"' out/ | wc -l

페이지 수와 매치되는 결과 라인 수가 같으면 정상입니다. 자동화된 회귀 테스트는 아니지만, RSS 자동 발견처럼 사용자 눈에 잘 안 띄는 영역의 silent regression을 잡는 데는 충분합니다.

정적 사이트는 렌더 시점에 모든 메타 태그가 HTML로 굳어지기 때문에 런타임 보정이 없습니다. 빌드 한 번이 잘못 나가면 RSS 리더, 검색엔진 alternate 디스커버리, 브라우저 페이지 액션이 동시에 어긋납니다. Next.js 16 metadata API는 강력하지만, "shallow merge" 한 줄이 만드는 영향이 생각보다 큽니다. nested 필드를 override할 때마다 헬퍼로 묶어 두는 패턴은 단순하지만 효과가 큽니다.