애드센스 심사를 준비하며 dailbit.com의 빌드 결과물을 하나씩 열어 봤다. 블로그 목록 페이지(/blog/)의 HTML 머리 부분은 이랬다.
<link rel="canonical" href="https://dailbit.com/blog/"/>
<meta property="og:title" content="DailBit | Daily ideas, built bit by bit"/>
<meta property="og:url" content="https://dailbit.com/"/>
<meta property="og:image" content="https://dailbit.com/opengraph-image.png"/>
<meta property="og:image:width" content="630"/>
<meta property="og:image:height" content="630"/>
<meta name="twitter:title" content="DailBit | Daily ideas, built bit by bit"/>
캐노니컬은 /blog/인데 og:url은 홈(/)이다. og:title도 블로그가 아니라 사이트 전체 제목이다. 이 페이지를 SNS에 공유하면 미리보기가 홈 페이지처럼 보이고, 크롤러에게는 "이 페이지의 대표 주소가 무엇인가"에 대해 서로 다른 신호를 주게 된다. 코드에서 /blog/의 메타데이터는 분명히 따로 정의되어 있었다. 왜 이런 일이 생겼을까?
원인: 메타데이터는 "얕게" 병합된다
App Router에서 메타데이터는 루트 레이아웃부터 페이지까지 순서대로 평가되고 병합된다. 핵심은 이 병합이 얕은(shallow) 병합이라는 점이다. 최상위 필드 단위로 덮어쓰고, openGraph나 twitter 같은 중첩 객체 안까지 합치지 않는다.
당시 루트 레이아웃은 이랬다.
// app/layout.tsx (수정 전)
export const metadata: Metadata = {
metadataBase: new URL("https://dailbit.com"),
title: { default: "DailBit | mirr56 Web & App Developer", template: "%s | DailBit" },
description: "…",
keywords: ["mirr56", "웹 개발자", "앱 개발자", "React", "Next.js", /* … */],
alternates: { canonical: "/" },
openGraph: { type: "website", url: "/", siteName: "DailBit",
title: "DailBit | Daily ideas, built bit by bit", /* … */ },
twitter: { card: "summary_large_image", title: "DailBit | Daily ideas, built bit by bit" },
};
그리고 블로그 목록 페이지는 이랬다.
// app/blog/page.tsx (수정 전)
export const metadata: Metadata = {
title: "DailBit Notes | mirr56 기술 블로그",
description: "…",
alternates: { canonical: "/blog/" },
};
블로그 페이지는 title, description, alternates를 정의했으니 이 세 필드는 덮어써진다. 하지만 openGraph와 twitter는 정의하지 않았으므로 루트의 값이 그대로 상속된다. 루트의 openGraph.url이 /였으니, 블로그 페이지의 og:url도 /가 된 것이다. 루트에 홈 페이지 전용 값을 넣어 둔 것이 문제였다.
반대 경우: 정의하면 통째로 바뀐다
블로그 글 상세 페이지는 반대의 문제가 있었다.
// app/blog/[slug]/page.tsx (수정 전)
return {
title: `${meta.title} | DailBit Notes`,
description: meta.description,
alternates: { canonical: `/blog/${slug}/` },
openGraph: { type: "article", title: meta.title, description: meta.description,
url: `/blog/${slug}/`, publishedTime: meta.date },
};
여기서는 openGraph를 정의했으니 og:url은 맞게 나왔다. 대신 루트의 openGraph가 통째로 버려졌다. siteName, locale처럼 하위 페이지에서 다시 쓰지 않은 필드도 함께 사라진다. (og:image만은 뒤에서 설명할 파일 규칙 이미지가 따로 채워 주고 있었다.) 그리고 twitter는 정의하지 않았으니 루트의 사이트 기본 제목이 그대로 남았다. 같은 글을 공유했을 때 페이스북에는 글 제목이, 트위터 카드에는 사이트 제목이 나가는 상태였다.
정리하면 중첩 객체는 **"정의하지 않으면 통째로 상속, 정의하면 통째로 교체"**다. 둘 다 직관과 다르게 동작하기 쉽다.
덤으로 발견한 문제들
제목 중복. 루트의 title.template이 "%s | DailBit"인데, 상세 페이지 제목에 이미 | DailBit Notes를 붙이고 있었다. 결과적으로 <title>이 "React 웹 프레임워크 선택 가이드 | DailBit Notes | DailBit"가 되었다.
모든 페이지에 같은 keywords. 루트의 keywords도 상속되어, 개인정보처리방침 페이지에도 "React, Next.js, FastAPI"가 들어갔다. 구글은 오래전부터 meta keywords를 순위에 쓰지 않는다고 밝혔고, 모든 페이지에 같은 값이 들어가면 의미도 없다. 아예 지웠다.
정사각형 공유 이미지. app/opengraph-image.png 파일이 루트에 있으면 Next.js가 파일 규칙에 따라 모든 하위 경로의 og:image로 쓴다. 파일 기반 메타데이터는 metadata 객체보다 우선하기 때문에, 페이지에서 이미지를 지정해도 이 파일이 이긴다. 이 파일이 630×630이라 가로형 카드에서 잘려 보였다. 1200×630 이미지를 따로 만들고 이 파일은 지웠다. (한글 OG 이미지 빌드하기에서 자세히 다뤘다.)
해결: 루트에는 공통값만, 페이지마다 헬퍼로
원칙을 두 가지로 정했다.
- 루트 레이아웃에는 모든 페이지에 똑같이 맞는 값만 둔다.
metadataBase, 제목 템플릿, 사이트 인증 태그,robots정도다. 특정 URL을 가리키는canonical,og:url, 페이지 성격에 따라 달라지는og:title,og:image,twitter는 루트에 두지 않는다. - 모든 페이지는 같은 헬퍼로 메타데이터를 만든다. 중첩 객체를 매번 빠짐없이 채우는 일을 사람에게 맡기지 않는다.
// app/layout.tsx (수정 후)
// canonical·og:url·og:image·twitter는 하위 페이지로 그대로 상속되므로 여기서 지정하지 않는다.
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: { default: `${SITE_NAME} | 개발 기록과 직접 만든 웹·앱 도구`, template: `%s | ${SITE_NAME}` },
description: "…",
verification: { other: { "google-adsense-account": "ca-pub-…" } },
robots: { index: true, follow: true },
};
// lib/seo.ts
export function pageMetadata({ title, description, path, image = "/og/default.png",
type = "website", publishedTime, modifiedTime, tags, absoluteTitle }: PageMetaInput): Metadata {
const fullTitle = absoluteTitle ? title : `${title} | ${SITE_NAME}`;
const images = [{ url: image, width: 1200, height: 630, alt: fullTitle }];
return {
title: absoluteTitle ? { absolute: title } : title,
description,
alternates: { canonical: path },
openGraph: {
type, locale: "ko_KR", siteName: SITE_NAME, url: path,
title: fullTitle, description, images,
...(type === "article" ? { publishedTime, modifiedTime, authors: [AUTHOR.url], tags } : {}),
},
twitter: { card: "summary_large_image", title: fullTitle, description, images: [image] },
};
}
path 하나를 받아 canonical과 og:url을 같은 값에서 만든다. 두 값이 어긋날 수 있는 구조 자체를 없앤 것이다. openGraph와 twitter도 항상 함께 채운다. 페이지에서는 이렇게 쓴다.
// 정적 페이지
export const metadata = pageMetadata({
title: "DailBit Notes · 기술 블로그",
description: "제품을 만들고 운영하며 배운 기술 …",
path: "/blog/",
});
// 동적 페이지
export async function generateMetadata({ params }) {
const { slug } = await params;
const meta = getAllPosts().find((p) => p.slug === slug);
if (!meta) return {};
return pageMetadata({
title: meta.title, description: meta.description,
path: `/blog/${slug}/`, image: `/og/blog/${slug}.png`,
type: "article", publishedTime: meta.date, modifiedTime: meta.updated, tags: meta.tags,
});
}
제목 중복은 페이지에서 사이트 이름을 붙이지 않고 템플릿에 맡기는 것으로 정리했다. 홈처럼 템플릿을 적용하면 안 되는 페이지만 absoluteTitle: true를 쓴다.
루트에 둘 것과 페이지에 둘 것
같은 규칙은 다른 필드에도 그대로 적용된다. 이번 일을 계기로 필드별로 어디에 둘지 기준을 정리했다.
| 필드 | 위치 | 이유 |
|---|---|---|
metadataBase |
루트 | 모든 상대 URL의 기준이므로 한 번만 |
title.template |
루트 | 사이트 이름 붙이는 규칙을 한곳에서 |
verification, robots |
루트 | 사이트 전체에 같은 값 |
alternates.canonical |
페이지 | 페이지마다 주소가 다름 |
openGraph, twitter |
페이지(헬퍼) | 중첩 객체라 부분 상속이 안 됨 |
keywords |
사용 안 함 | 검색 순위에 쓰이지 않고 상속 시 의미 없음 |
alternates.languages |
페이지 | 다국어를 쓴다면 페이지별 대응 주소가 다름 |
판단 기준은 하나다. "이 값이 사이트의 모든 페이지에 그대로 맞는가?" 하나라도 예외가 있다면 루트가 아니라 페이지(또는 헬퍼)에 둔다. 루트에 둔 값은 언젠가 그 필드를 깜빡한 페이지로 조용히 새어 나간다.
빌드 결과로 확인하기
메타데이터 문제는 화면에 보이지 않아서, 코드만 보고는 놓치기 쉽다. 수정한 뒤에는 빌드 결과물의 HTML에서 직접 태그를 뽑아 확인했다.
for f in index.html blog/index.html blog/choosing-a-web-framework/index.html; do
echo "== $f"
grep -o '<link rel="canonical"[^>]*>\|<meta property="og:url"[^>]*>\|<meta name="twitter:title"[^>]*>' "out/$f"
done
수정 후 /blog/의 결과는 이렇다.
<link rel="canonical" href="https://dailbit.com/blog/"/>
<meta property="og:url" content="https://dailbit.com/blog/"/>
<meta property="og:image" content="https://dailbit.com/og/default.png"/>
<meta property="og:image:width" content="1200"/>
<meta name="twitter:title" content="DailBit Notes · 기술 블로그 | DailBit"/>
모든 페이지에서 canonical과 og:url이 같은 주소를 가리키고, 트위터 카드도 페이지마다 다른 제목을 갖게 되었다.
이 글을 다시 정리하면서 수정 전후의 커밋을 각각 새로 빌드해 같은 태그를 뽑아 나란히 놓아 보았다. 페이지별 메타데이터 헬퍼(lib/seo.ts)가 처음 들어간 커밋 a702553과 그 직전 커밋 4429a70이다. 두 커밋은 git archive로 따로 풀어서 next build했다. Next.js 16.3.5(webpack), Node v22.22.2, macOS 26.6.2 arm64(Apple M5 Pro), 2026-10-23 실행이다.

수정 전 빌드에서는 위에서 본 그대로 og:url이 홈을 가리키고 twitter:title이 사이트 기본 제목이었다. 글 상세 페이지(/blog/choosing-a-web-framework/)도 같은 방식으로 확인했는데, 수정 전에는 og:url과 canonical은 맞았지만 twitter:title이 사이트 기본 제목이었고, 수정 후에는 글 제목(React 웹 프레임워크 선택 가이드 | DailBit)으로 바뀌었다.
정리
- App Router 메타데이터는 최상위 필드 단위로 얕게 병합된다.
openGraph,twitter같은 중첩 객체는 정의하지 않으면 통째로 상속되고, 정의하면 통째로 교체된다. - 루트 레이아웃에 특정 페이지(대개 홈)의 URL이나 제목을 넣으면, 그 값이 메타데이터를 덜 정의한 모든 하위 페이지로 새어 나간다.
- 파일 기반 메타데이터(
opengraph-image.png등)는 객체 기반 설정보다 우선하고 하위 경로 전체에 적용된다. - 페이지별 메타데이터는 헬퍼 하나로 만들고,
canonical과og:url은 같은 입력에서 만든다. - 확인은 코드가 아니라 빌드 결과 HTML로 한다.