
# 번역을 무시하게 만드는 canonical 실수

agentready.md는 경로 앞에 언어를 붙이는 방식으로 열 개 언어를 게시합니다. 모든 페이지가 `/about`, `/es/about`, `/ja/about`을 비롯해 열 개의 주소를 가집니다. 그 아홉 개 번역본이 몇 달 동안 `<head>`에 똑같은 한 줄을 내보내고 있었습니다.

```html
<link rel="canonical" href="https://agentready.md/about">
```

`/es/about`은 검색엔진에 이렇게 말하고 있었던 셈입니다. 나는 영어 페이지의 중복본이니 그쪽을 색인하라고. `/ja/about`도 같은 말을 했고, 나머지 일곱 개도 마찬가지였습니다. 열 개 언어 중 색인될 수 있는 건 하나뿐이었습니다.

## canonical이 실제로 선언하는 것

`rel="canonical"`은 어느 URL이 더 깔끔한지에 대한 힌트가 아닙니다. 이 페이지와 내가 가리키는 페이지는 같은 페이지이고, 색인에 들어갈 쪽은 가리킨 쪽이라는 주장입니다. 링크도, 순위 신호도, 색인 항목 자체도 모두 대상 쪽으로 넘어갑니다. 이 페이지는 경쟁에서 빠집니다.

`/product?color=blue`가 `/product`를 가리킬 때는 바로 그 동작이 필요합니다. 번역본에서는 정반대입니다. 스페인어 페이지는 영어 페이지의 중복본이 아니라 다른 독자를 위한 다른 페이지이고, 스페인어 검색에서 스스로 순위를 얻어야 합니다.

## 왜 그렇게 오래 살아남았나

우리 라우터는 라우팅 전에 언어 접두사를 떼어냅니다. 그래서 `/es/about` 요청은 핸들러에 `/about`으로 도착하고 언어는 따로 전달됩니다. 라우트를 한 번만 선언해도 모든 언어에서 동작하니 설계 자체는 합리적이지만, `request.url`에서 접두사가 사라지고 `<head>` 파셜은 바로 그 벌거벗은 경로로 canonical을 만들고 있었습니다.

영어에서는 이 버그가 올바른 결과를 냅니다. 영어에는 접두사가 없기 때문입니다. 자기 언어로 개발하면서 보는 페이지는 전부 정상이고, 깨지는 건 읽지 않는 아홉 개 언어뿐입니다.

수정은 URL을 출력하기 전에 접두사를 되돌려 놓는 호출 하나입니다.

```ejs
<%# request.url에서는 /es/ 접두사가 이미 사라졌다 — localizedUrl이 되돌린다. %>
<% const canonicalHref = localizedUrl(canonicalPath); %>
<link rel="canonical" href="<%= baseUrl %><%= canonicalHref %>">
```

## 규칙: 모든 언어 버전은 자기 자신을 canonical로 선언한다

언제나 자기 참조 canonical입니다. `/es/about`은 `/es/about`을, `/ja/about`은 `/ja/about`을 선언합니다. 번역본에 예외는 없습니다. 두 언어 버전이 거의 같더라도, 단어 세 개만 다른 같은 제품 페이지라도 결론은 그대로입니다. 언어가 다른 것은 중복이 아닙니다.

## hreflang: 모든 버전이 모든 버전을 나열한다

canonical은 어떤 페이지를 색인할지 정하고, `hreflang`은 색인된 페이지 중 어느 것을 어떤 독자에게 보여줄지 정합니다. 둘 다 필요하며, `hreflang`에는 자주 틀리는 규칙이 두 가지 있습니다.

**모든 버전이 자기 자신을 포함해 모든 버전을 나열한다.** 열 개 언어라면 열 개 페이지 각각에 자기 참조를 포함한 `alternate` 링크가 열 개씩 들어갑니다. 이 자기 참조 누락이 나머지는 멀쩡한 묶음에서 가장 흔한 결함입니다.

**링크는 서로를 가리켜야 한다.** 영어 페이지가 스페인어 페이지를 지목했는데 스페인어 페이지가 되돌려 지목하지 않으면, 검색엔진은 그 관계를, 흔히 묶음 전체를 버립니다. 검증할 수 없기 때문입니다. 누구나 남의 번역본이라고 주장할 수 있고, 그것을 확인해 줄 수 있는 건 상대 페이지뿐입니다.

열 군데를 손으로 관리하지 말고 목록 하나에서 생성하세요.

```ejs
<% ['en','es','fr','de','pt','it','ja','zh','ko','ru'].forEach(l => { %>
<link rel="alternate" hreflang="<%= l %>" href="<%= baseUrl %><%= localizedUrl(canonicalPath, l) %>">
<% }) %>
<link rel="alternate" hreflang="x-default" href="<%= baseUrl %><%= canonicalPath %>">
```

한 버전으로 그 언어 사용자 전체를 감당한다면 언어 코드만(`es`) 쓰면 됩니다. 지역을 붙이는 것(`es-ES`, `pt-BR`)은 시장별로 정말 다른 버전을 낼 때뿐입니다. 없는 지역을 지어내면 묶음만 쓸데없이 쪼개집니다.

## x-default는 또 하나의 번역본이 아니다

`x-default`는 당신이 게시하지 않은 언어의 독자에게 무엇을 보여줄지 지정합니다. 누군가 네덜란드어로 검색했는데 네덜란드어 판이 없다면, 그 사람은 `x-default`가 가리키는 페이지를 받습니다.

'가장 중요한 언어'도 아니고 목록에 하나 더 얹는 항목도 아닙니다. 우리 `x-default`는 접두사 없는 영어 URL에 그대로 둡니다. 사이트 전체가 원래 그쪽으로 되돌아가기 때문입니다. 이것을 `/es/about` 같은 특정 언어 페이지로 돌리면, 번역이 없는 독자를 전부 스페인어 페이지로 보내게 되어 기본값보다 나빠집니다.

## 한 줄로 확인하기

이 실패는 터미널에서 전부 보입니다. 언어를 훑으면서 각 페이지가 무엇을 선언하는지 출력해 보세요.

```bash
for p in "" es/ fr/ de/ pt/ it/ ja/ zh/ ko/ ru/; do
  printf '%-4s ' "${p:-en}"
  curl -s "https://example.com/${p}about" | grep -o '<link rel="canonical"[^>]*>'
done
```

올바른 출력은 각 줄이 자기 경로를 가리킵니다.

```
en   <link rel="canonical" href="https://example.com/about">
es   <link rel="canonical" href="https://example.com/es/about">
fr   <link rel="canonical" href="https://example.com/fr/about">
```

열 줄이 같은 URL이면 그게 이 글의 버그입니다. 첫 페이지만이 아니라 손댄 템플릿 전부에 대해 돌려 보세요. canonical은 대개 레이아웃마다 만들어지므로, 레이아웃이 셋인 사이트는 둘만 맞을 수 있습니다.

## 이것이 깨지는 다른 경로들

**canonical에 추적 파라미터가 들어간다.** 요청 URL을 그대로 써서 canonical을 만들면, `?utm_source=newsletter`로 들어온 방문자는 `?utm_source=newsletter`를 가리키는 canonical을 받습니다. 공유될 때마다 스스로를 canonical이라 선언하는 새 URL이 생기고, 한 페이지에 URL이 끝없이 늘어납니다. 경로에서 만들고, 정말 다른 페이지를 가리키는 파라미터만, 예컨대 필터가 걸린 목록이나 페이지 번호만 정해진 순서로 되돌려 넣으세요. 그래야 한 페이지가 자기 이름을 두 가지로 쓰지 못합니다.

**canonical이 리다이렉트를 가리킨다.** `/es/about`이 `/es/about/`을 선언하고 그것이 301로 `/es/about`으로 되돌아온다면, 크롤러에게 풀어야 할 고리와 이 신호를 의심할 이유를 함께 준 셈입니다. `200`을 반환하는 URL을 가리키세요.

**두 개의 canonical이 서로 어긋난다.** HTML 안의 `<link>`와 HTTP 헤더 `Link: <...>; rel="canonical"`은 둘 다 유효하고, CDN과 플러그인과 리버스 프록시가 모두 이 헤더를 붙입니다. 둘이 어긋나면 결과는 정해져 있지 않고 대개 원하던 쪽도 아닙니다. 헤더도 확인하세요.

```bash
curl -sI https://example.com/es/about | grep -i "^link:"
```

아무것도 나오지 않는 게 좋은 답입니다. 직접 의도해서 넣은 경우가 아니라면요.

[사이트 분석하기](/ko) · [메타 태그 생성기](/ko/tools/meta-tags-generator) · [AI 준비 체크리스트](/ko/tools/ai-readiness-checklist)
