본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 21. 10:10

Next.js 16의 hreflang: 번역된 페이지를 Google 검색 결과에서 조용히 삭제하는 3가지 실수

요약

Next.js 16 환경에서 다국어 SEO를 위한 hreflang 설정 시 발생하는 세 가지 주요 실수를 다룹니다. 상호적이지 않은 클러스터, 404 오류 URL 참조, 잘못된 canonical 설정이 검색 결과 제외를 유발할 수 있음을 경고합니다.

핵심 포인트

  • hreflang 설정은 반드시 모든 언어 페이지 간 양방향(reciprocal)으로 연결되어야 함
  • 존재하지 않는 URL이나 404 오류를 가리키는 hreflang은 Google 신뢰도를 떨어뜨림
  • canonical 태그가 페이지 자체가 아닌 마스터 페이지를 가리키지 않도록 주의
  • Next.js 16 Metadata API의 alternates 필드를 활용한 효율적인 구현 패턴 제시

요약 (TL;DR) — 동일한 페이지를 여러 언어로 배포하는 경우, hreflang은 Google에게 "이 페이지들은 서로의 번역본이며 중복 콘텐츠가 아니다"라고 알려주는 역할을 합니다. 세 가지 실수가 Google이 귀하의 설정을 무시하게(또는 적극적으로 불이익을 주게) 만듭니다: 상호적이지 않은 (non-reciprocal) 클러스터, 404 오류가 발생하는 URL을 가리키는 hreflang, 그리고 페이지 자체가 아닌 영어 마스터 (English master) 페이지를 가리키는 canonical. 이 중 어떤 것도 에러를 발생시키지 않습니다. 빌드에 실패하지도 않습니다. 오직 렌더링된 <head>를 읽어야만 발견할 수 있습니다. 다음은 이 세 가지를 모두 피할 수 있는 Next.js 16 Metadata API 패턴입니다.

다국어 SEO(Multilingual SEO)에는 잔인한 특성이 있습니다. 바로 실패 모드가 '침묵'이라는 점입니다. 번역된 페이지는 잘 렌더링되고, 빌드는 성공하며, TypeScript도 문제없지만 — Google은 조용히 귀하의 프랑스어 페이지가 영어 페이지의 중복이라고 판단하여 검색 결과에서 제외해 버립니다. 어디에서도 에러가 발생하지 않습니다. 이 포스트는 제가 진작에 가졌더라면 좋았을 체크리스트입니다.

전체 과정에서 가상의 example.com을 사용하겠습니다. 이 패턴은 프레임워크 의존성이 낮습니다. i18n 라이브러리 없이, 오직 Next.js 16 Metadata API의 alternates 필드와 작은 헬퍼 함수만을 사용합니다.

구조: 하나의 헬퍼, 페이지별 로케일 세트

Metadata API의 alternates.languages<link rel="alternate" hreflang="..."> 태그를 자동으로 렌더링해 줍니다. 작은 헬퍼 함수를 사용하면 일관성을 유지할 수 있습니다:

// lib/hreflang.ts
type Locale = "en" | "fr" | "es" | "de" | "nl" | "ar";

...

페이지별 사용 예시:

// app/es/widgets/page.tsx
export const metadata: Metadata = {
  alternates: buildHreflang("/widgets", ["en", "fr", "es"], "es"),
...

availableLocales 인자가 핵심입니다. 이는 모든 페이지에서 동일하지 않으며, 이를 잘못 설정하는 것이 실수 #2입니다. 실제로 존재하는 언어들 중에서 페이지별로 선택하십시오:

페이지존재하는 로케일
/widgetsen, fr, es
...

표를 채우기 위해 모든 페이지를 모든 언어로 번역하지 마세요. 각 시장에 맞는 페이지를 번역하고 — 오직 그 페이지들만 선언하십시오.

실수 #1: 상호적이지 않은 (non-reciprocal) hreflang (클러스터 파괴자)

Google의 규칙은 양방향(bidirectional)입니다. 만약 페이지 A가 "나의 스페인어 버전은 B입니다"라고 말한다면, B도 반드시 "나의 영어 버전은 A입니다"라고 말해야 합니다. 만약 링크가 한 방향으로만 연결된다면, Google은 단순히 그 연결(edge) 하나만 무시하는 것이 아니라 클러스터(cluster) 전체를 신뢰하지 않습니다.

전형적인 사례: 클러스터 내에 세 개의 페이지가 있는데, 그중 두 개는 ["en", "fr", "es"]를 선언하고, 세 번째 페이지는 fr을 누락한 채 ["en", "es"]만 선언하는 경우입니다.

// app/es/widgets/page.tsx — 오류 발생 (BROKEN)
buildHreflang("/widgets", ["en", "es"], "es")
//                               ^^^^ "fr"이 누락됨, 하지만 /fr/widgets가 존재하며 이곳을 가리키고 있음

/fr/widgets/es/widgets를 가리키지만, /es/widgets는 다시 가리키지 않습니다. 상호적이지 않은(Non-reciprocal) 상태가 되면 → Google은 이 번역 페이지들을 서로 경쟁하는 관련 없는 중복 페이지로 취급합니다. 에러나 경고도 발생하지 않습니다.

유일하고 확실한 방어책은 다음과 같습니다: 모든 hreflang 선언을 grep으로 검색하여 클러스터 전체에서 로케일(locale) 배열이 동일한지 확인하십시오. 모든 페이지가 동일한 배열을 가져야 합니다.

실수 #2: 404 오류가 발생하는 URL에 hreflang 선언하기

신호를 놓치는 것보다 더 나쁜 상황입니다. 이것은 명백한 페널티(penalty)입니다. Google의 문서에는 명시되어 있습니다: 404 오류가 발생하거나, 리다이렉트(redirect)되거나, noindex 처리된 URL을 가리키는 hreflang 어노테이션(annotation)은 유효하지 않습니다. 유효하지 않은 어노테이션이 충분히 많아지면 Google은 귀하의 설정 전체를 신뢰하지 않게 됩니다.

함정은 의욕만 앞선 "지원되는 로케일(supported locales)" 목록입니다:

// 시한폭탄 같은 코드
const SUPPORTED = ["en", "fr", "es", "de", "nl"]; // ...하지만 /de/*는 아직 존재하지 않음

누군가 이 목록을 사이트맵(sitemap)이나 언어 전환기(language switcher)에 연결하는 날에는, 404 오류의 벽을 향해 <link hreflang="de"> 태그를 쏘아 올리게 됩니다.

규칙: "지원되는 로케일" 목록은 결코 의욕만 앞서서는 안 됩니다. 이는 당신이 구축할 계획이 아니라, 실제로 200 상태 코드를 반환하는 페이지에 매핑되어야 합니다. 만약 /de/widgets가 존재하지 않는다면, 해당 페이지의 hreflang에 de가 나타나서는 안 됩니다. 그것으로 끝입니다.

실수 #3: 번역 페이지를 색인에서 제외시키는 canonical 설정

가장 미묘한 실수입니다. /fr/widgets에서 <link rel="canonical">은 어디를 가리켜야 할까요?

직관적이지만 틀린 답: 영어 마스터 페이지인 /widgets를 가리키는 것입니다. "같은 페이지니까요, 그렇죠?"

아닙니다. /fr/widgets에서 /widgets로 향하는 canonical (표준 URL)은 Google에게 다음과 같이 말하는 것과 같습니다: "프랑스어 페이지는 중복된 페이지입니다. 대신 영어 페이지를 인덱싱(index)하세요." 당신은 방금 Google에게 프랑스어 페이지를 색인에서 제외(deindex)해달라고 요청한 셈입니다.

Canonical과 hreflang은 서로 다른 역할을 수행합니다:

  • canonical (표준 URL) = 자기 참조적(self-referential). /fr/widgets의 canonical은 /fr/widgets를 가리켜야 합니다. ("이 URL이 URL의 권위 있는 버전입니다.")
  • hreflang = 언어적 형제 관계(language siblings)를 선언합니다.

이것이 바로 헬퍼(helper) 함수가 영어 경로가 아닌 selfLocale로부터 canonical을 도출하는 이유입니다.

확인 방법 (아무런 오류도 발생하지 않기 때문에)

이 모든 사항은 TypeScript, 빌드(build), 그리고 next dev 단계에서는 보이지 않습니다. 유일한 진실의 원천(source of truth)은 프로덕션 빌드(production build)의 렌더링된 HTML입니다. 따라서 테스트는 유닛 테스트(unit test)가 아니라, next start를 대상으로 한 curl 명령이어야 합니다:

next build && next start -p 3100 &

for p in es/widgets widgets fr/widgets; do
...

Google이 신뢰하는 형태는 다음과 같습니다: 클러스터 내의 모든 페이지가 동일한 hreflang 세트를 보고하고, 각 canonical자기 자신을 가리키는 것입니다:

/es/widgets
  canonical: https://example.com/es/widgets
  hreflang:  en, fr, es, x-default
...

동일한 hreflang 배열, 그리고 자기 참조적인 canonical. 만약 페이지들이 서로 다른 hreflang 세트를 보고하고 있다면, 그것이 바로 실수 #1입니다.

보너스: 자동 번역하지 말고 원어(native)로 작성하세요

만약 그 뒤에 있는 페이지들이 기계 번역으로 엉망이 된 상태라면, hreflang 설정은 아무런 가치가 없습니다. Google은 이를 감지하는 데 능숙해졌으며, 독자들은 단 두 문장 만에 이를 느낍니다. 타겟팅할 만큼 중요한 시장이라면, 해당 언어로 직접 작성하세요. 위에서 다룬 기술적인 배선(wiring)은 쉬운 20%에 불과하며, 원어 콘텐츠(native copy)가 나머지 80%입니다.

또한, 현지화된 두 페이지의 의미가 인접해 있다면 (예: "무료" 페이지와 "프로" 페이지), 모든 로케일(locale)에서 이를 차별화해야 합니다. 그렇지 않으면 키워드 카니발라이제이션(keyword cannibalization, 키워드 자기잠식)을 새로운 언어로 그대로 수출하는 꼴이 됩니다.

빠른 체크리스트

  • 클러스터 내 모든 페이지의 hreflang 배열이 동일함 (상호 관계 형성, reciprocal)
  • 모든 hreflang URL이 200 상태 코드를 반환함 (404 / 리다이렉트 / noindex 대상이 아님)
  • 모든 현지화된 페이지의 canonical(표준 URL)이 영어 마스터 페이지가 아닌 자기 자신을 가리킴
  • x-default가 기본 언어(보통 영어)를 가리킴
  • 소스 코드가 아닌 프로덕션 빌드의 **렌더링된 <head>**를 통해 검증함

실제 상호 관계 클러스터 사례 보기

이론은 쉽습니다. 실제 사례를 직접 curl로 확인해 보세요. 다음은 각각 자기 참조적 canonical(self-referential canonical)을 가지며 상호적인 hreflang 클러스터를 공유하는 실제 라이브 페이지들입니다. 위에서 언급한 curl 스니펫을 이 중 아무 곳에나 실행해 보면, 세트 전체에서 동일한 hreflang 라인이 반복되는 것을 확인할 수 있습니다.

두 클러스터가 서로 다른 로케일 세트를 선언하고 있다는 점에 주목하세요. 이것이 바로 실전에서 피해야 할 실수 #2입니다. 즉, 각 페이지는 실제로 존재하는 언어들만 목록에 포함합니다.

저는 영어, 프랑스어, 스페인어, 독일어, 네덜란드어, 아랍어로 페이지를 배포하는 무료 AI 랜딩 페이지 빌더PageStrike를 만들고 있습니다. 그래서 이 체크리스트는 값진 경험을 통해 얻은 결과물입니다. 만약 여러분이 로케일 매트릭스(locale matrix)를 다르게 처리한다면(페이지별 인자 대신 사이트맵 기반 또는 Edge-Config 기반 방식 등), 댓글로 알려주세요. 페이지별 접근 방식은 페이지 수가 적을 때는 깔끔하지만, 70개까지 확장될 수 있을지는 확신할 수 없습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0