원문 1개로부터 ja/en/ko의 모든 언어 페이지를 기계적으로 생성하는 시스템을 구축하여, 골격 일치 여부(骨格照合), 미번역 잔존물 유무(未翻訳残留), hreflang 상호 참조 검사 등 모든 항목을 '녹색'으로 만들어 본 서비스에 공개했습니다. 그런데 공개 후 점검 과정에서, 번역 페이지의 canonical 태그가 일본어 버전을 가리키고 있다는 점과, 언어 버전이 존재함에도 불구하고 일본어로 연결되는 내부 링크가 각 언어별로 961개나 남아있었다는 사실을 알게 되었습니다. 이 두 가지 모두 육안으로는 드러나지 않지만, 검색 엔진 순위와 사이트 회유를 조용히 망가뜨리는 결함입니다. 본 기사에서는 원인 패턴 3가지와 점검 항목 2가지를 절차로 정리했습니다.
- 대상: 자사 웹사이트 (ja/en/ko의 3개 언어, 348 URL)
- 프레임워크: Astro (콘텐츠 컬렉션 사용)
- 운영 방식: 원문(일본어) 파일 1개를 수정하면, 나머지 언어 페이지를 AI 에이전트의 서브 에이전트가 병렬로 전개합니다.
- 공개 전 점검 항목 (기존에 존재하던 것):
- 골격 일치 여부 (동일한 디자인 및 구조인지)
- 미번역 잔존물 (번역 누락 부분이 없는지)
- hreflang 상호 참조
이 3가지가 모두 녹색(정상)이면 공개해도 좋다는 운영 방식이었습니다. 결론부터 말하자면, 이 3가지만으로는 충분하지 않았습니다.
생성 측에서는 일본어 페이지를 기반으로 각 언어의 페이지를 만들 때, **속성(attribute)으로 작성된 path="..."**을 해당 언어가 포함된 경로로 수정하고 있었습니다.
<!-- 생성 측이 수정했던 형태 (속성) -->
<Layout path="/posts/xxx">
문제는 경로가 이 '속성' 외의 형태로도 작성되어 있었다는 것입니다. 생성 시스템을 만들 때 가장 먼저 해야 할 일은 경로가 어떤 방식으로 출력될 수 있는지를 파악하는 것이었습니다. 나중에 알게 된 방식들은 다음 절차에 따라 처리했습니다.
기사, 실적, 카테고리 페이지에서는 canonical 태그가 **변수(variable)**로부터 생성되고 있었습니다.
---
// canonical의 근원이 되었던 변수
const path = `/posts/${slug}`;
...
생성 측은 속성의 path="..."만 수정했기 때문에, 이 변수는 일본어 경로 그대로 남아있게 되었습니다. 그 결과, 영어 페이지와 한국어 페이지 모두 canonical 태그가 일본어 버전을 가리키는 상태로 공개되었습니다.
이것이 함정이었으며, hreflang 검사는 녹색으로 유지되고 있었습니다. hreflang은 '다른 언어 버전이 여기에 있다'라는 상호 간의 선언이며, canonical은 '이 페이지의 정규 URL은 무엇이다'라는 선언입니다. 만약 canonical이 일본어 버전을 가리킨다면, 번역 페이지는 중복 콘텐츠로 취급되어 검색 결과에 노출되지 않는 상태가 됩니다. hreflang과 canonical은 별개의 검사 항목으로 다루어야 했습니다.
영어 기사 목록을 클릭하면 일본어 기사에 도착하는 상황이었습니다. 언어 버전이 존재함에도 불구하고 일본어로 연결되는 링크가 각 언어에서 961개나 있었습니다. 원인은 3가지 종류였습니다.
<a href={`/posts/${slug}`}>...</a>
href="..."와 같은 속성 형태를 전제로 한 정규 표현식으로는 이 작성 방식에 걸리지 않았습니다.
<Section moreHref="/posts">...</Section>
moreHref처럼 href로 끝나는 prop 이름은 \bhref라는 정규 표현식으로는 포착할 수 없습니다. 여기서 \b는 단어 경계(word boundary)를 의미하므로, more와 Href 사이에는 해당 경계가 존재하지 않아 일치하지 않습니다.
// 동작 확인용 (사고 예시)
/\bhref=/\.test('moreHref="/posts"'); // false: 단어 경계가 없어 일치하지 않음
번역자(서브 에이전트)는 일본어 본문을 복사하여 번역하기 때문에, 본문 내의 링크는 일본어 버전 그대로 남게 됩니다. 게다가 본문 HTML은 페이지 생성 과정에서 수정 대상이 아닙니다. 이곳은 생성 처리 과정 중이 아니라, 별도의 스크립트로 교체할 필요가 있었습니다.
①②는 컴포넌트 측 작성 방식의 문제이고, ③은 콘텐츠 측의 문제입니다. 수정하는 위치가 다릅니다. 분류하지 않고 하나의 정규 표현식으로 모두 고치려고 하면, 그중 하나라도 빠지게 됩니다.
| 증상 | 원인 | 기존 검사에서 감지되었는지 여부 |
|---|
| 번역 페이지의 canonical이 일본어 버전으로 지정됨 | const path = /posts/${slug}`` 변수가 수정 대상이 아님 | 감지 불가 (hreflang은 녹색) |
| 일본어로 연결되는 내부 링크 (각 언어별 961개) | ① 템플릿형 href ② moreHref 등 prop ③ 본문 HTML 내의 링크 | 감지 불가 (골격/누락은 녹색) |
골격 일치 검사는 '디자인이 동일한가'를, 미번역 잔존물 검사는 '번역 누락이 없는가'를 확인하는 검사입니다. 둘 다 페이지가 검색에 노출될 상태인지, 독자가 같은 언어 내에서 원활하게 이동할 수 있는지는 확인하지 않습니다. 확인하지 않는 이상, 녹색으로 나오는 것은 당연했습니다.
각 언어 페이지에서 canonical이 해당 페이지 자신의 URL을 가리키는지 확인합니다. 영어 페이지의 canonical이 영어 페이지 자신이어야 정상입니다.
// 개념만 최소화한 것 (실제 스크립트 그 자체는 아님)
// 각 페이지에 대해
if (canonicalUrl !== ownUrl) {
...
빌드 후의 dist 전체에서 href="/..."를 수집하여, 같은 언어 버전이 존재함에도 다른 언어를 가리키는 링크가 0개인지 조건을 겁니다. 소스가 아닌 빌드 후 출력을 보는 것이 핵심입니다. ①②③ 중 어떤 방식으로 작성되었든 최종 HTML에서는 href="/..."가 되기 때문에, 작성 방식의 차이를 흡수할 수 있습니다.
다만, 언어 전환 링크 (hreflang 속성 포함)는 제외합니다. 언어 전환 링크는 다른 언어를 가리키는 것이 역할이므로, 제외하지 않으면 전환 링크 자체가 NG로 잡힐 수 있습니다.
// 개념만 최소화한 것 (실제 스크립트 그 자체는 아님)
for (const link of linksInDist) {
if (link.hasHreflang) continue; // 언어 전환 링크는 대상 제외
...
본문 HTML 내의 링크(③)는 이 검사에서 감지한 후, 별도의 스크립트로 수정합니다.
번역용 컬렉션을 만들 때, 일본어 측 컬렉션의 schema를 works.schema!로 재사용하고 있었습니다.
// NG 형태 (이미지)
const worksEn = defineCollection({
schema: works.schema!,
...
이러한 작성 방식은 Astro가 data의 타입을 unknown으로 추론하게 만듭니다. 까다로운 점은, astro build는 통과하지만 astro check만 108건의 에러로 실패한다는 것입니다. 빌드가 통과하기 때문에 인지하는 것이 느려집니다.
대처 방법은 schema의 실체를 분리하여 공유하는 것입니다.
// OK 형태 (이미지)
const workSchema = z.object({
/* 공통 항목 */
...
컬렉션 객체에서 schema를 가져오는 것이 아니라, schema 자체를 변수로 만들어 양쪽에서 참조합니다.
- 다국어 검수는 '골격', '번역 누락'만으로는 부족하다. canonical의 자기 참조와 동일 언어 내 내부 링크를 독립적인 검사 항목으로 갖는다 - hreflang이 녹색이라도 canonical은 별개다. 변수로부터 조합된 canonical은 속성 수정만으로는 고칠 수 없다.
- 내부 링크 누락은 '템플릿형 href', 'href로 끝나는 prop 이름', '번역 본문 내의 링크' 3가지로 나누면 수정할 위치를 파악하기 쉽다.
- 내부 링크 검사는 소스가 아닌 dist의 HTML을 본다. 언어 전환 링크 (
hreflang 포함)는 제외한다 - schema는 works.schema!로 재사용하지 않고 실체를 분리하여 공유한다. astro build가 통과해도 astro check도 돌린다.