지식

hreflang를 사용해 페이지의 모든 언어 및 국가 버전 찾기

hreflang는 페이지의 모든 현지화 버전을 나열합니다. 상위 사이트들이 이를 어떻게 사용하는지, 무엇이 깨지는지, 그리고 일부 버전이 왜 특정 국가 내부에서만 열리는지 확인했습니다.

James Meadow

James Meadow

2026년 10월 1일 · 9 분 소요

본문 시작 (frontmatter 제목은 번역하지 않음):

시장별로 가격, 콘텐츠 또는 가용성을 비교한다면, 첫 번째 문제는 모든 시장에서 동일한 페이지를 찾는 것이다. URL 패턴을 추측하는 방법은 일부 사이트에서는 통하지만 대부분의 사이트에서는 실패한다. 어떤 사이트는 /de/를 사용하고, 다른 사이트는 de.example.com을, 또 다른 사이트는 쿼리 파라미터를, 네 번째 사이트는 국가별로 별도의 도메인을 사용한다.

많은 사이트가 이미 그 답을 공개하고 있다. 사이트가 검색 엔진을 위해 추가하는 hreflang 주석은 페이지의 모든 언어 및 국가 버전과 그 위치를 나열한다. 이를 읽으면 단 한 번의 가져오기로 전체 세트를 얻을 수 있다. 이 가이드는 hreflang의 작동 방식, 상위 사이트들이 이를 어떻게 사용하는지 확인한 결과, 그리고 올바른 URL을 가지고 있는 것만으로는 항상 올바른 페이지를 볼 수 없는 이유를 설명한다.

핵심 요약

  • 2026년 10월 1일 기준, 우리가 가져온 상위 사이트 홈페이지 253개 중 82개(32%)가 hreflang 대체 버전을 선언했으며, 사이트당 중앙값 17.5개 버전, 최대 157개였다.
  • 주석이 달린 페이지를 한 번 가져오면 URL 패턴을 추측할 필요 없이 해당 페이지의 모든 현지화 URL을 얻을 수 있다.
  • 주석이 항상 깔끔한 것은 아니다. 82개 사이트 중 12개가 Google이 지원하지 않는 코드를 하나 이상 사용했다. 예를 들어 en-uk, es-419, 밑줄이 들어간 en_GB, 언어 앞에 국가가 오는 경우 등이다.
  • 우리가 확인한 대체 버전 중 86%는 Google이 요구하는 대로 곧바로 다시 연결되었다. 나머지 대부분은 동일 페이지의 다른 URL을 가리키거나 리디렉션되었다.
  • 일부 버전은 올바른 위치에서만 열린다. 한 뉴스 사이트의 미국 홈페이지와 한 마켓플레이스의 독일 스토어프런트는 루마니아에서의 연결을 다른 곳으로 리디렉션했으며, 미국과 독일의 출구를 통해서는 정상적으로 로드되었다.

hreflang의 작동 방식

페이지는 <head>에 대체 버전을 나열한다:

<link rel="alternate" hreflang="en" href="https://example.com/" />
<link rel="alternate" hreflang="de" href="https://example.com/de/" />
<link rel="alternate" hreflang="pt-BR" href="https://example.com/pt/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />

각 값은 언어 코드이며, 선택적으로 스크립트나 지역이 뒤따른다. de는 어디서든 독일어를, en-GB는 영국의 영어를, zh-Hant는 번체 중국어를 나타낸다. x-default는 이 중 어느 것에도 해당하지 않는 방문자를 위한 대체 버전을 지정한다. Google의 문서는 대부분의 사이트가 따르는 규칙을 정한다:

  • 언어는 ISO 639-1 코드를, 스크립트는 ISO 15924를, 지역은 ISO 3166-1 alpha-2를 사용한다. 지역 코드 단독으로는 유효하지 않으며, 라틴 아메리카 스페인어를 나타내는 es-419와 같이 이 표준을 벗어나는 코드는 지원되지 않는다.
  • URL은 https://를 포함하여 완전한 형태여야 한다.
  • 각 버전은 자기 자신과 다른 모든 버전을 나열해야 하며, 두 페이지가 서로를 가리키지 않으면 주석이 무시될 수 있다.
  • 동일한 정보는 HTML, HTTP Link 헤더(PDF에 유용함), 또는 사이트맵에서도 제공될 수 있다.

데이터 수집에서는 세 번째 규칙이 유용하다. 모든 버전이 다른 모든 버전을 나열해야 하므로, 그중 하나만으로도 전체 세트의 완전한 지도가 된다.

상위 사이트에서 발견한 것

우리는 Tranco 인기 사이트 순위 상위 500개 도메인의 홈페이지를 가져왔다. 253개의 고유 사이트가 HTML 홈페이지를 반환했으며, 나머지는 콘텐츠 네트워크, API 호스트, 홈페이지가 없는 기타 도메인, 또는 이미 집계된 사이트의 중복이었다.

측정 항목결과
hreflang 주석이 있는 홈페이지253개 중 82개 (32%)
주석이 달린 페이지당 대체 버전 수중앙값 17.5, 최대 157
x-default가 있는 페이지82개 중 63개
HTTP Link 헤더로 hreflang을 전송하는 홈페이지0
지원되지 않는 코드가 하나 이상 있는 사이트82개 중 12개
상대 URL을 사용하는 사이트2
동일 코드를 두 번 나열한 사이트6

지원되지 않는 코드는 몇 가지 패턴으로 나뉘었다:

패턴예시사이트 수
언어 앞에 국가, 추가로 지역 레이블mx-es, cz-cs, emea_africa-en1 (코드 48개)
하이픈 대신 밑줄en_GB, de_DE1 (코드 25개)
ISO 3166-1이 아닌 지역 코드en-uk, en-eu, sq-xk3
라틴 아메리카 스페인어es-4192
ISO 639-1이 아닌 언어 코드ceb, skr, 그리고 히브리어의 구식 표기인 iw3
임의로 만든 코드tc, zh-FT, ms-en3

일부 사이트는 여러 행에 걸쳐 나타난다. 국가가 먼저 오는 패턴은 파서에게 가장 위험한데, 이러한 코드 중 몇 개는 반대 순서로도 우연히 유효하기 때문이다. ca는 카탈루냐어의 코드이고, id는 인도네시아어, th는 태국어의 코드이므로, ca-en은 캐나다용 영어가 아니라 카탈루냐어로 읽힌다. 코드만 보고 시장을 추론해서는 안 되며, 반드시 타당성을 확인해야 한다.

반환 링크

주석이 달린 각 홈페이지에서 최대 2개의 대체 버전을 가져와 총 155개를 수집했고, 각각이 홈페이지로 다시 연결되는지 확인했다.

결과대체 버전 수
곧바로 다시 연결됨133 (86%)
동일 페이지의 다른 URL로 다시 연결됨9
다른 곳으로 리디렉션됨9
실제로 해당 페이지를 나열하지 않음4, 두 사이트에서

두 번째 행이 미묘한 부분이다. 여러 사이트에서, 우리가 도달한 홈페이지는 한 URL에 있었지만 세트는 다른 URL을 지정했다. / 대 /en/, /homepage 또는 /home.html, 또는 언어 파라미터가 없는 URL 대 있는 URL. 주석 자체는 일관되었다. 단지 우리의 진입점이 사이트가 정식으로 여기는 URL이 아니었을 뿐이다. 수집할 때는 우연히 도달한 URL이 아니라 세트 자체가 사용하는 URL로 각 버전을 식별해야 한다.

올바른 URL만으로는 항상 충분하지 않다

리디렉션은 가장 시사하는 바가 큰 부분이었다. 우리는 리디렉션되는 각 대체 버전을 루마니아에서 직접, 그리고 대상 국가의 Shifter 출구를 통해, 영어와 현지 Accept-Language 헤더 각각으로 다시 가져왔다:

사이트 유형대체 버전루마니아에서대상 국가에서결정 요인
뉴스 사이트미국 홈페이지국제판으로 리디렉션됨미국 출구를 통해 로드됨위치
마켓플레이스독일 스토어프런트글로벌 사이트로 리디렉션됨독일 출구를 통해 로드됨위치
화상 통화 공급업체일본 홈페이지일본어 Accept-Language로만 로드됨일본 출구를 통해 동일하게 로드됨언어
게임 퍼블리셔아랍어 홈페이지리디렉션됨사우디 출구를 통해서도 리디렉션됨어느 쪽도 아님: 나열되어 있으나 접근 불가

두 사이트는 방문자의 위치에 따라 결정되었으며, 마켓플레이스는 언어와 무관하게 그러했다. 따라서 이들 자신의 주석은 다른 곳의 방문자가 열 수 없는 페이지를 가리키고 있었다. 한 사이트는 위치와 무관하게 언어에 따라 결정되었다. 그리고 한 사이트는 우리가 시도한 모든 조합에서 리디렉션되는 버전을 나열했는데, 이는 이를 바탕으로 시장 비교를 구축하기 전에 알아둘 가치가 있다.

바로 이 지점에서 hreflang과 출구 위치가 만난다. 주석은 어떤 버전이 존재하고 어디에 있는지 알려주지만, 이를 충실하게 수집하려면 현지 방문자처럼 해당 국가에서 해당 언어로 각 버전을 요청해야 한다. 이는 프록시 지역, 시간대, 로케일 일치시키기에서 다룬 내용이다. 그렇지 않으면 국제판을 독일 레이블 아래 기록하게 될 위험이 있다.

코드

아래 모듈은 페이지의 HTML 대체 버전을 읽고, 지원되지 않는 코드와 상대 URL을 표시하며, 반환 링크를 확인하여 실패하는 각 대체 버전이 왜 실패하는지 보고한다:

from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urlunsplit

import requests
from babel import Locale

# Google accepts ISO 639-1 languages, ISO 15924 scripts and ISO 3166-1 alpha-2 regions.
LANGUAGES = {code for code in Locale("en").languages if len(code) == 2}
NOT_ISO_3166 = {"AC", "CP", "CQ", "DG", "EA", "EU", "EZ", "IC", "QO", "TA", "UN", "XA", "XB", "XK", "ZZ"}
REGIONS = {code for code in Locale("en").territories if code.isalpha()} - NOT_ISO_3166
SCRIPTS = set(Locale("en").scripts)


class AlternateLinks(HTMLParser):
    """Collect <link rel="alternate" hreflang="..."> elements from a page's markup."""

    def __init__(self):
        super().__init__()
        self.links = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "link" and "alternate" in (a.get("rel") or "").lower().split() and a.get("hreflang") and a.get("href"):
            self.links.append((a["hreflang"].strip(), a["href"].strip()))


def valid_hreflang(code):
    """True for x-default, a language, or language-region / language-script(-region) codes."""
    if code.lower() == "x-default":
        return True
    parts = code.split("-")
    if parts[0].lower() not in LANGUAGES:
        return False
    rest = parts[1:]
    if rest and rest[0].title() in SCRIPTS:
        rest = rest[1:]
    if rest and rest[0].upper() in REGIONS:
        rest = rest[1:]
    return not rest


def hreflang_map(url, session=None, timeout=20):
    """Fetch a page and return its declared alternates as {hreflang: absolute URL}, plus any problems found."""
    session = session or requests.Session()
    response = session.get(url, timeout=timeout)
    parser = AlternateLinks()
    parser.feed(response.text)  # link tags are normally ASCII, so the decoding choice rarely matters here
    alternates, problems = {}, []
    for code, href in parser.links:
        if not href.startswith(("http://", "https://")):
            problems.append(f"relative URL for {code}: {href}")
        if not valid_hreflang(code):
            problems.append(f"invalid code: {code}")
        alternates[code] = urljoin(response.url, href)
    return response.url, alternates, problems


def normalise(url):
    """Compare URLs without default ports, host case or a trailing slash getting in the way."""
    parts = urlsplit(url)
    port = f":{parts.port}" if parts.port and parts.port not in (80, 443) else ""
    return urlunsplit((parts.scheme.lower(), (parts.hostname or "") + port, parts.path.rstrip("/") or "/", parts.query, ""))


def check_return_links(url, alternates, session=None, timeout=20):
    """Fetch each alternate and report why it breaks the return-link rule. An empty result means all link back."""
    session = session or requests.Session()
    target = normalise(url)
    problems = {}
    for code, alternate in alternates.items():
        if normalise(alternate) == target:
            continue
        final, back, _ = hreflang_map(alternate, session, timeout)
        if normalise(final) != normalise(alternate):
            problems[code] = f"redirects to {final}"
        elif not back:
            problems[code] = "has no hreflang annotations"
        elif target not in {normalise(u) for u in back.values()}:
            problems[code] = "does not link back"
    return problems

우리 자신의 홈페이지에 대해 실행하면:

import requests

from hreflang import hreflang_map, check_return_links

session = requests.Session()
session.headers["User-Agent"] = "ExampleCollector/1.0 (+https://example.com/bot)"

url, alternates, problems = hreflang_map("https://shifter.io/", session)
for code, alternate in sorted(alternates.items()):
    print(code, alternate)
print("problems:", problems)
print("return-link problems:", check_return_links(url, alternates, session))
de https://shifter.io/de
en https://shifter.io/
es https://shifter.io/es
fr https://shifter.io/fr
ja https://shifter.io/ja
ko https://shifter.io/ko
pt-BR https://shifter.io/pt
x-default https://shifter.io/
zh-Hans https://shifter.io/cn
problems: []
return-link problems: {}

알아두어야 할 두 가지 한계가 있다. 이 코드는 HTML에 있는 주석만 읽으므로, HTTP 헤더나 사이트맵으로 이를 공개하는 사이트는 해당 부분도 읽어야 하며, 발견 소스로서의 사이트맵에서 사이트맵 쪽을 다룬다. 그리고 check_return_links는 주어진 모든 대체 버전을 가져오므로, 버전이 150개인 페이지에서는 일부를 샘플링하거나 요청 속도를 조절해야 한다.

실전 적용

  • 템플릿당 하나의 페이지부터 시작하라. 제품, 카테고리, 게시글 페이지는 보통 동일한 주석 패턴을 공유하므로, 각각 한 번씩 가져오면 전체 사이트가 시장을 어떻게 매핑하는지 알 수 있다.
  • 세트 자체의 URL로 버전을 식별하라. 각 대체 버전이 선언한 URL을 저장하고, 리디렉션을 잡음이 아니라 발견 사항으로 취급하라.
  • 각 버전을 현지 방문자처럼 가져오라. 해당 시장의 출구와 그 시장의 언어를 사용한 뒤, 리디렉션되지 않았는지 확인하라. 우리의 리디렉션 사례 4건 중 2건을 결정한 것은 위치였으며, 이는 가장 지오블로킹이 많이 되는 국가에서도 마찬가지다.
  • 코드를 신뢰하기 전에 검증하라. 순서가 뒤바뀌거나 임의로 만든 코드는 페이지를 잘못된 시장에 조용히 배정할 수 있다.
  • 수집한 데이터를 정규화하라. 모든 버전을 확보한 후에도 가격, 날짜, 숫자의 형식은 여전히 다르다. 이는 로케일 전반의 가격, 숫자, 날짜 정규화하기에서 다루며, 비교 자체는 지오 개인화 가격 탐지하기의 주제다.

결론

hreflang은 웹에서 페이지의 시장을 공개적으로 보여주는 지도에 가장 가까운 것이다. 상위 사이트의 3분의 1이 이를 제공하며, 한 번의 가져오기로 전체 세트를 드러내고, 세트 대부분은 제대로 서로 연결되어 있다.

하지만 이는 지도일 뿐 보장은 아니다. 코드가 틀릴 수 있고, 도달한 URL이 세트가 사용하는 URL이 아닐 수 있으며, 일부 버전은 올바른 위치나 올바른 언어를 가진 방문자에게만 열린다. 주석을 읽고, 검증하고, 현지 방문자처럼 각 버전을 가져오라.

출처 및 참고 자료

  • Google Search Central, 페이지의 현지화 버전을 Google에 알리기.
  • Tranco, 목록 Q2K34, 2026년 9월 1일부터 30일까지의 도메인 순위 집계.
  • Babel, 이 코드에서 언어, 스크립트, 지역 목록을 제공하는 CLDR 데이터.
  • 2026년 10월 1일 Shifter가 직접, 그리고 Shifter 출구를 통해 위 코드를 사용하여 가져온 홈페이지 및 대체 버전.

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.10/GB부터 이용해보세요.

시작하기