Next.js App Router에서 루트 layout의 metadata에 canonical을 넣으면, 자기 alternates를 정하지 않은 모든 하위 페이지가 그 값을 그대로 물려받아 "내 대표 주소는 홈페이지"라고 선언하게 된다. canonical은 루트 레이아웃에 절대 두면 안 되는 값이다.
구글이 영삼넷 글을 잘 색인하지 않아서 사이트 전체를 점검하다가 찾은 문제다. 놀이터 페이지 약 240개가 구글 입장에선 홈페이지의 사본이었다. 원인은 루트 레이아웃 한 줄이었다. 같은 점검에서 나온 중복 글 정리와 사이트맵 lastmod 문제는 다른 글에서 따로 다룬다.
- 루트
layout.tsx에alternates: { canonical: 'https://youngsam.net/' }가 있었고, 자기 canonical이 없는 하위 페이지는 전부 이 값을 상속했다. - 놀이터(개발 도구, 미니게임, 코딩 챌린지 단계) 약 240개가 canonical=홈.
'use client'페이지는 metadata가 아예 없어 제목·설명까지 홈과 똑같았다. - 루트 canonical을 지우고, 공용 메타 헬퍼로 페이지마다 자기 주소 canonical과 고유 제목을 붙였다. 재점검에서 놀이터 250개와 전체 9,709개가 모두 정상이었다.
PART 01루트 레이아웃 한 줄이 한 일
처음 사이트를 세팅할 때 루트 레이아웃에 기본 메타데이터를 넣으면서 canonical도 같이 넣었다. 홈페이지 canonical을 어디엔가 지정해야 했고, 레이아웃이 제일 위에 있으니 거기 두면 되겠다고 생각했던 것 같다. 이게 실수였다.
// app/layout.tsx — 고치기 전
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://youngsam.net'),
title: { default: '영삼넷', template: '%s | 영삼넷' },
description: '개발 노트와 무료 프로그램',
alternates: { canonical: 'https://youngsam.net/' }, // 이 한 줄이 문제
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
)
}Next.js의 메타데이터는 레이아웃에서 페이지 쪽으로 내려가면서 합쳐진다. 하위 페이지가 어떤 필드를 정하지 않으면 부모의 값이 그대로 남는다. 그래서 자기 alternates를 따로 정하지 않은 페이지는 모두 루트의 canonical, 즉 홈페이지 주소를 자기 대표 주소로 내보냈다. 페이지 소스를 열면 <link rel="canonical" href="https://youngsam.net/">가 조용히 들어가 있다.
합치는 방식이 얕다는 것도 알아 둘 만하다. 하위 페이지가 alternates를 정하면 객체 전체가 바뀐다. 부모의 alternates.languages를 남기고 canonical만 바꾸는 식의 깊은 병합은 일어나지 않는다. 반대로 하위가 아무것도 안 정하면 부모 값이 통째로 내려온다. 이번 사고는 후자였다.
generateMetadata에서 자기 canonical을 정하고 있어서 문제가 없었다. 그런데 놀이터 쪽 결과를 보니 canonical 열이 전부 같은 값이었다. 홈페이지 주소였다. 개발 도구 20개, 미니게임 20개, 코딩 챌린지 단계 페이지까지 약 240개. 더 나쁜 건 'use client'로 만든 도구 페이지와 챌린지 단계 페이지였다. metadata를 아예 내보내지 않으니 제목과 설명까지 홈페이지와 똑같았다. 구글이 보기엔 주소만 다른 홈페이지 사본 240개였다.PART 02같은 점검에서 나온 증상들
canonical 상속 말고도 몇 가지가 같이 나왔다. 원인은 제각각이지만 전부 브라우저로 열어서는 눈에 안 띈다는 공통점이 있다.
사이트맵 404는 따로 짚을 만하다. 단계 주소를 숫자 반복으로 만들다 보니 실제 단계 수보다 많이 나갔다. 사이트맵에 404 주소를 넣는 건 구글에게 "우리 사이트맵은 믿을 게 못 된다"고 알려 주는 것과 같다. 타이핑 단계는 더 미묘했다. 실제 id가 snippet-1 형식인데 사이트맵은 숫자만 넣고 있었다.
PART 03루트에서 canonical을 빼고 페이지마다 자기 주소로
첫 번째 수정은 단순하다. 루트 레이아웃에서 canonical을 지웠다. 홈페이지 canonical은 app/page.tsx의 metadata에서 직접 지정했다. 루트 레이아웃에는 metadataBase, 제목 템플릿, 사이트 이름처럼 정말로 모든 페이지에 똑같이 맞는 값만 남겼다.
// app/layout.tsx — 고친 뒤
export const metadata: Metadata = {
metadataBase: new URL('https://youngsam.net'),
title: { default: '영삼넷', template: '%s | 영삼넷' },
description: '개발 노트와 무료 프로그램',
// canonical 없음 — 페이지마다 자기 주소를 직접 정한다
}
// app/page.tsx — 홈만 홈을 가리킨다
export const metadata: Metadata = {
alternates: { canonical: '/' },
}그다음은 놀이터 페이지들에 자기 메타데이터를 붙이는 일이다. 페이지가 수십 개라 하나씩 손으로 쓰면 또 빠뜨린다. 그래서 공용 헬퍼를 만들었다. 경로와 이름, 설명을 넣으면 자기 주소 canonical과 고유 제목, 설명, Open Graph까지 한 번에 만들어 준다.
// lib/playground-meta.ts
import type { Metadata } from 'next'
const SUFFIX = ' — 영삼넷 놀이터'
export function playgroundMeta(path: string, name: string, description: string): Metadata {
const title = name + SUFFIX
return {
title: { absolute: title },
description,
alternates: { canonical: path }, // 자기 자신
openGraph: { title, description, url: path },
}
}
export function stageMeta(base: string, challenge: string, stage: { id: string; no: number; title: string; goal: string }) {
return playgroundMeta(
base + '/' + stage.id,
challenge + ' ' + stage.no + '단계: ' + stage.title, // 예: 정규식 챌린지 5단계: 그룹 + 대체
stage.goal,
)
}단계 페이지는 generateMetadata에서 params로 단계를 찾아 stageMeta를 부른다. 그러면 "정규식 챌린지 5단계: 그룹 + 대체 — 영삼넷 놀이터" 같은 고유 제목이 나온다. 단계마다 제목이 다르니 검색 결과에서도 서로 구분된다.
PART 04'use client' 페이지에 메타데이터 주는 법
까다로운 건 클라이언트 컴포넌트였다. 놀이터 도구 대부분은 상호작용이 많아서 페이지 파일 맨 위에 'use client'가 붙어 있다. 그런데 Next.js에서 metadata 객체나 generateMetadata는 서버 컴포넌트에서만 내보낼 수 있다. 클라이언트 페이지에서 export해 봐야 적용되지 않는다. 이게 제목까지 홈과 같아진 이유였다.
해결책은 같은 폴더에 서버 컴포넌트인 layout.tsx를 하나 두는 것이다. 레이아웃은 children만 그대로 넘기고, metadata만 책임진다. 페이지 코드는 하나도 건드리지 않아도 된다.
// app/playground/<도구>/layout.tsx (서버 컴포넌트, 'use client' 없음) — 경로·이름은 예시
import { playgroundMeta } from '@/lib/playground-meta'
export const metadata = playgroundMeta(
'/playground/json-formatter',
'JSON 포매터',
'붙여 넣은 JSON을 정렬하고 문법 오류 위치를 알려 주는 브라우저 도구',
)
export default function Layout({ children }: { children: React.ReactNode }) {
return children
}
// 같은 폴더의 page.tsx는 그대로 'use client' 컴포넌트사이트맵도 같은 원칙으로 바꿨다. 단계 수를 숫자로 적어 두는 대신, 챌린지 페이지가 실제로 쓰는 단계 데이터 배열에서 주소를 만든다. 타이핑 단계도 그 배열의 id를 그대로 쓰니 snippet-1 형식이 자연스럽게 맞는다. 페이지와 사이트맵이 같은 데이터를 보니 어긋날 수가 없다.
목록 페이지 2쪽 이후는 자기 주소를 canonical로 두었다. 카테고리 파라미터는 유지하고 검색어와 정렬은 뺐다. 1쪽을 가리키면 깊은 쪽에 있는 오래된 글 링크를 찾아가는 힘이 약해진다. 구글은 rel=prev/next를 더 이상 색인 신호로 쓰지 않는다고 밝혔고, 각 페이지가 자기 자신을 canonical로 갖는 쪽을 권한다.
PART 05눈으로는 못 찾는다, 크롤러처럼 점검하라
이번 문제들은 하나같이 화면에는 드러나지 않았다. 놀이터 도구는 잘 돌아갔고, 챌린지도 잘 풀렸다. canonical은 head 안에 숨어 있고, 사이트맵의 404는 누가 그 주소를 직접 열기 전엔 모른다. 메타데이터 기본값이 조용히 상속되는 구조라서, 사람이 페이지를 하나씩 열어 보는 방식으로는 찾을 수가 없다.
그래서 점검을 스크립트로 만들었다. 사이트맵 인덱스를 따라가 모든 주소를 병렬로 요청하고 상태 코드, canonical, title을 뽑는다. 표준 라이브러리만으로 충분하다.
import re, urllib.request
from concurrent.futures import ThreadPoolExecutor
UA = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
LOC = re.compile(r'<loc>\s*([^<\s]+)\s*</loc>')
CANON = re.compile(r'<link[^>]+rel=["\']canonical["\'][^>]*href=["\']([^"\']+)', re.I)
TITLE = re.compile(r'<title[^>]*>(.*?)</title>', re.I | re.S)
def fetch(url):
req = urllib.request.Request(url, headers={'User-Agent': UA})
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, r.geturl(), r.read().decode('utf-8', 'replace')
except urllib.error.HTTPError as e:
return e.code, url, ''
def check(url):
status, final, html = fetch(url)
c, t = CANON.search(html), TITLE.search(html)
canon = c.group(1) if c else None
return url, status, canon, (t.group(1).strip() if t else ''), canon == url and final == url
_, _, index = fetch('https://youngsam.net/sitemap.xml')
urls = [u for sm in LOC.findall(index) for u in LOC.findall(fetch(sm)[2])]
with ThreadPoolExecutor(max_workers=16) as ex:
for url, status, canon, title, ok in ex.map(check, urls):
if status != 200 or not ok:
print(status, url, '-> canonical:', canon, '|', title)이 스크립트를 수정 후 다시 돌렸다. 놀이터 250개 모두 200, 자기 자신 canonical, 고유 제목이었다. 전체 9,709개도 모두 200에 자기 자신 canonical이었고, 예외로 걸린 하나도 찾아서 고쳤다.
- 루트 레이아웃에는 모든 페이지에 똑같이 맞는 값만 둔다. canonical은 절대 아니다.
- 하위 페이지가 alternates를 정하지 않으면 부모 값이 통째로 내려온다. 정하면 객체 전체가 바뀐다.
- 'use client' 페이지는 metadata를 내보낼 수 없으니 같은 폴더의 서버 layout.tsx로 준다.
- 사이트맵은 숫자 반복이 아니라 페이지가 실제로 쓰는 데이터에서 만든다.
- 눈으로 찾지 말고, 사이트맵 전수 점검 스크립트를 배포마다 돌린다.
자주 묻는 질문
Next.js에서 canonical을 layout에 넣으면 어떻게 되나요?
그 레이아웃 아래에서 자기 alternates를 정하지 않은 모든 페이지가 같은 canonical을 상속한다. 루트 레이아웃에 홈 주소를 넣었다면 그런 페이지는 전부 홈을 대표 주소로 선언하게 된다. canonical은 페이지마다 따로 지정해야 한다.
'use client' 페이지에서 metadata를 export할 수 있나요?
할 수 없다. metadata 객체와 generateMetadata는 서버 컴포넌트에서만 지원된다. 같은 폴더에 서버 컴포넌트인 layout.tsx를 두고 거기서 metadata를 내보내면 페이지 코드를 바꾸지 않고도 제목과 canonical을 줄 수 있다.
Next.js 메타데이터는 부모와 자식이 어떻게 합쳐지나요?
레이아웃에서 페이지 쪽으로 얕게 병합된다. 자식이 어떤 최상위 필드를 정하면 그 필드는 통째로 바뀌고, 정하지 않으면 부모 값이 그대로 남는다. 예를 들어 자식이 alternates를 정하면 부모의 alternates 안 다른 값도 함께 사라진다.
페이지네이션 2쪽 이후의 canonical은 1쪽으로 해야 하나요?
구글은 각 페이지가 자기 자신을 canonical로 갖는 쪽을 권한다. 2쪽 이후를 1쪽으로 가리키면 깊은 쪽에 있는 글 링크를 찾아가는 데 불리할 수 있다. 검색어나 정렬 같은 파라미터는 빼고, 페이지 번호와 카테고리처럼 내용을 결정하는 값만 남기면 된다.
사이트 전체 canonical을 한 번에 확인하는 방법이 있나요?
사이트맵의 모든 주소를 요청해서 HTML의 canonical 링크를 추출하고 요청한 주소와 비교하는 스크립트를 쓰면 된다. 표준 라이브러리만으로도 수천 개를 금방 본다. 배포 후 자동으로 돌리면 재발을 빨리 잡는다.

댓글 0