본문 바로가기
Frontend2026년 10월 15일19분 읽기

Next.js 레이아웃 canonical 상속의 함정 — 놀이터 240개가 홈 중복이 된 이유

YS
김영삼
조회 19
Next.js 레이아웃 canonical 상속의 함정 — 놀이터 240개가 홈 중복이 된 이유

Next.js App Router에서 루트 layout의 metadata에 canonical을 넣으면, 자기 alternates를 정하지 않은 모든 하위 페이지가 그 값을 그대로 물려받아 "내 대표 주소는 홈페이지"라고 선언하게 된다. canonical은 루트 레이아웃에 절대 두면 안 되는 값이다.

구글이 영삼넷 글을 잘 색인하지 않아서 사이트 전체를 점검하다가 찾은 문제다. 놀이터 페이지 약 240개가 구글 입장에선 홈페이지의 사본이었다. 원인은 루트 레이아웃 한 줄이었다. 같은 점검에서 나온 중복 글 정리와 사이트맵 lastmod 문제는 다른 글에서 따로 다룬다.

3줄 요약
  • 루트 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만 바꾸는 식의 깊은 병합은 일어나지 않는다. 반대로 하위가 아무것도 안 정하면 부모 값이 통째로 내려온다. 이번 사고는 후자였다.

삽질 기록 · 글은 멀쩡했는데 놀이터가 통째로 홈이었다
사이트맵 인덱스 아래 자식 사이트맵 다섯 개에 들어 있는 주소 9,709개를 전부 구글봇 User-Agent로 요청해서 상태 코드, canonical, robots meta, title을 모았다. 노트나 뉴스 상세 페이지는 generateMetadata에서 자기 canonical을 정하고 있어서 문제가 없었다. 그런데 놀이터 쪽 결과를 보니 canonical 열이 전부 같은 값이었다. 홈페이지 주소였다. 개발 도구 20개, 미니게임 20개, 코딩 챌린지 단계 페이지까지 약 240개. 더 나쁜 건 'use client'로 만든 도구 페이지와 챌린지 단계 페이지였다. metadata를 아예 내보내지 않으니 제목과 설명까지 홈페이지와 똑같았다. 구글이 보기엔 주소만 다른 홈페이지 사본 240개였다.

PART 02같은 점검에서 나온 증상들

canonical 상속 말고도 몇 가지가 같이 나왔다. 원인은 제각각이지만 전부 브라우저로 열어서는 눈에 안 띈다는 공통점이 있다.

증상 → 원인 → 수정
증상원인수정
놀이터 약 240개 canonical=홈루트 layout의 alternates.canonical 상속루트에서 제거, 홈은 app/page.tsx에서 직접 지정, 페이지마다 자기 주소
도구·단계 페이지 제목이 홈과 같음'use client' 페이지는 metadata를 내보낼 수 없음같은 폴더에 layout.tsx를 두어 metadata 제공
사이트맵 단계 주소 61개가 404단계 수를 하드코딩(CLI·Git은 10단계뿐인데 30까지, 정규식은 10단계인데 20까지)실제 단계 데이터 배열에서 사이트맵 생성
타이핑 단계 주소 404실제 id는 snippet-1 형식인데 사이트맵은 숫자로 생성같은 데이터 배열의 id를 그대로 사용
목록 2쪽 이후 canonical이 1쪽목록 페이지 canonical에서 page 파라미터를 버림2쪽 이후는 자기 주소 canonical(카테고리 유지, 검색어·정렬 제외)
다국어 프로그램 제목이 이름뿐"BoltMemo | 영삼넷"처럼 이름만 템플릿에 넣음"이름 — 부제 | YOUNGSAM.NET" 형식으로 변경
약 240개
홈페이지를 canonical로 가리키던 놀이터 페이지
61개
사이트맵에 있지만 존재하지 않는 단계 주소(404)
9,709개
재점검에서 모두 200 + 자기 자신 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: '/' },
}
영삼
내 생각
루트 레이아웃에는 "모든 페이지에 맞는 값"만 둔다. 사이트 이름, 기본 언어, metadataBase 정도다. 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만 책임진다. 페이지 코드는 하나도 건드리지 않아도 된다.

클라이언트 페이지 옆에 두는 layout.tsx
// 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이었고, 예외로 걸린 하나도 찾아서 고쳤다.

영삼
내 생각
기술 SEO는 한 번 고치면 끝나는 일이 아니라고 본다. 새 페이지를 하나 추가하거나 레이아웃을 하나 바꾸는 배포마다 다시 깨질 수 있다. 페이지가 늘어날수록 피해 범위도 조용히 커진다. 그래서 점검은 사람이 기억해서 하는 일이 아니라 자동으로 돌아가는 일이어야 한다. 나는 이 스크립트를 배포 뒤에 돌려서 canonical이 자기 자신이 아닌 주소가 하나라도 나오면 알림을 받게 할 생각이다.
canonical 상속 사고에서 배운 것
  1. 루트 레이아웃에는 모든 페이지에 똑같이 맞는 값만 둔다. canonical은 절대 아니다.
  2. 하위 페이지가 alternates를 정하지 않으면 부모 값이 통째로 내려온다. 정하면 객체 전체가 바뀐다.
  3. 'use client' 페이지는 metadata를 내보낼 수 없으니 같은 폴더의 서버 layout.tsx로 준다.
  4. 사이트맵은 숫자 반복이 아니라 페이지가 실제로 쓰는 데이터에서 만든다.
  5. 눈으로 찾지 말고, 사이트맵 전수 점검 스크립트를 배포마다 돌린다.

자주 묻는 질문

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

아직 댓글이 없습니다.
Ctrl+Enter로 등록