TOOLS GAMES DEVLOG DOCS ABOUT CONTACT
HOME > DOCS > Web / Tools > Cloudflare Pages의 Clean URL(308 Redirect) 처리와 루트 절대 경로 표준화Handling Cloudflare Pages Clean URLs (308 Redirect) and Root-Relative Path Standardization
Web / Tools Intermediate Cloudflare Pages / Python 3

Cloudflare Pages의 Clean URL(308 Redirect) 처리와 루트 절대 경로 표준화Handling Cloudflare Pages Clean URLs (308 Redirect) and Root-Relative Path Standardization

Cloudflare Pages의 .html 확장자 자동 제거(308 Permanent Redirect) 동작으로 인한 상대 경로 파손과 검색엔진 색인 보류 문제를 해결하는 URL 정규화 기법입니다.Techniques for normalizing URLs on Cloudflare Pages to resolve broken relative paths and search indexing delays caused by automatic .html stripping (308 Permanent Redirect).

#Cloudflare #SEO #CleanURL #Redirect #WebArchitecture
핵심 개념Cloudflare Pages는 정적 호스팅 시 /docs/web/example.html로 들어온 요청을 확장자가 없는 /docs/web/example로 308 Permanent Redirect 처리합니다. 이때 내부 링크나 <link rel="canonical">, sitemap.xml에 .html 확장자가 남아 있거나 상대 경로(../)를 혼용하면, 검색엔진 크롤러가 '정식 주소가 리다이렉트됨'으로 판정하여 색인을 보류하고 정적 에셋 경로가 깨지는 문제가 발생합니다.
Key ConceptCloudflare Pages automatically issues a 308 Permanent Redirect from /docs/web/example.html to the extensionless /docs/web/example. If internal links, <link rel="canonical">, or sitemap.xml still include .html extensions or rely on relative paths (../), search engine crawlers flag the canonical URLs as redirected, delaying indexing and breaking static asset paths.

1. 디스크 파일명과 공개 Clean URL 분리 및 무결성 검증 스크립트1. Separating Disk Filenames from Public Clean URLs and Verification Script

정적 빌드 단계에서 디스크에 저장할 물리 파일 경로(OUTPUT_DIR / f"{slug}.html")와 외부에 노출할 논리 주소(/docs/{cat}/{slug})를 엄격히 분리하고, 빌드 직후 모든 HTML 내의 내부 링크와 canonical 태그를 전수 검사합니다.

During the static build step, strictly separate the physical file path on disk (OUTPUT_DIR / f"{slug}.html") from the public logical URL (/docs/{cat}/{slug}). After building, run an automated check across all generated HTML files to validate internal links and canonical tags.

verify_clean_urls.py
from __future__ import annotations
import re
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[1]
SITE_ORIGIN = "https://www.lysc.kr"

HREF_PATTERN = re.compile(r'(?:href|src|content)=["\']([^"\']+)["\']')
CANONICAL_PATTERN = re.compile(r'<link\s+rel="canonical"\s+href="([^"]+)"')

def check_html_file(html_path: Path) -> list[str]:
    """단일 HTML 파일 내 상대 경로 사용 여부와 .html 확장자 노출을 검증합니다."""
    errors: list[str] = []
    content = html_path.read_text(encoding="utf-8")

    # 1. Canonical URL에 .html 확장자가 포함되었는지 검사
    canonical_match = CANONICAL_PATTERN.search(content)
    if canonical_match:
        canon_url = canonical_match.group(1)
        if canon_url.endswith(".html"):
            errors.append(f"[CANONICAL] .html 확장자 포함 금지: {canon_url}")
        if not canon_url.startswith(SITE_ORIGIN):
            errors.append(f"[CANONICAL] 도메인 오리진 불일치: {canon_url}")

    # 2. 내부 링크에서 상대 경로(../) 및 .html 접미사 검사
    for match in HREF_PATTERN.finditer(content):
        url = match.group(1)
        if url.startswith(("http://", "https://", "mailto:", "#", "data:")):
            continue
        if url.startswith("./") or url.startswith("../"):
            errors.append(f"[REL-PATH] 상대 경로 대신 루트 절대 경로(/) 사용 필요: {url}")
        if url.startswith("/blog/posts/") and url.endswith(".html"):
            errors.append(f"[308-RISK] 내부 링크에 .html 확장자 노출: {url}")

    return errors
verify_clean_urls.py
from __future__ import annotations
import re
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[1]
SITE_ORIGIN = "https://www.lysc.kr"

HREF_PATTERN = re.compile(r'(?:href|src|content)=["\']([^"\']+)["\']')
CANONICAL_PATTERN = re.compile(r'<link\s+rel="canonical"\s+href="([^"]+)"')

def check_html_file(html_path: Path) -> list[str]:
    """Verify that a single HTML file avoids relative paths and exposed .html extensions."""
    errors: list[str] = []
    content = html_path.read_text(encoding="utf-8")

    # 1. Check whether the canonical URL contains a .html extension
    canonical_match = CANONICAL_PATTERN.search(content)
    if canonical_match:
        canon_url = canonical_match.group(1)
        if canon_url.endswith(".html"):
            errors.append(f"[CANONICAL] Must not end with .html: {canon_url}")
        if not canon_url.startswith(SITE_ORIGIN):
            errors.append(f"[CANONICAL] Domain origin mismatch: {canon_url}")

    # 2. Check internal links for relative paths (../) and .html suffixes
    for match in HREF_PATTERN.finditer(content):
        url = match.group(1)
        if url.startswith(("http://", "https://", "mailto:", "#", "data:")):
            continue
        if url.startswith("./") or url.startswith("../"):
            errors.append(f"[REL-PATH] Use root-relative path (/) instead of relative path: {url}")
        if url.startswith("/blog/posts/") and url.endswith(".html"):
            errors.append(f"[308-RISK] Exposed .html extension in internal link: {url}")

    return errors

2. URL 유형별 Cloudflare Pages 응답 및 경로 규칙 비교2. Comparison of Cloudflare Pages Responses by URL Format

좌우로 밀어서 표 전체 내용을 확인하세요
요청 경로 형태실제 매핑되는 디스크 파일Cloudflare Pages 응답권장 표준 표기
/docs/web/slug.html/docs/web/slug.html308 Permanent Redirect → /docs/web/slug사용 금지 (canonical 및 내부 링크에서 배제)
/docs/web/slug/docs/web/slug.html200 OK (즉시 서빙)개별 문서 및 포스트의 표준 Clean URL
/docs/index.html/docs/index.html308 Permanent Redirect → /docs/사용 금지 (디렉터리 루트 슬래시로 통일)
/docs//docs/index.html200 OK (즉시 서빙)섹션 허브 및 디렉터리 인덱스의 표준 URL
Swipe horizontally to view the full table
Request Path PatternMapped Disk FileCloudflare Pages ResponseRecommended Standard
/docs/web/slug.html/docs/web/slug.html308 Permanent Redirect → /docs/web/slugDo not use (exclude from canonical and internal links)
/docs/web/slug/docs/web/slug.html200 OK (Served directly)Standard Clean URL for individual docs and posts
/docs/index.html/docs/index.html308 Permanent Redirect → /docs/Do not use (normalize to trailing slash directory path)
/docs//docs/index.html200 OK (Served directly)Standard URL for section hubs and directory indexes

3. 실무 적용 및 주의사항3. Practical Considerations and Caveats

  • 루트 상대 경로(/) 전면 도입: Clean URL 환경에서는 브라우저가 /docs/web/slug를 디렉터리처럼 해석하거나 파일처럼 해석하는 기준이 끝 슬래시(/) 유무에 따라 달라지므로, CSS·JS·이미지는 항상 /css/theme.css처럼 루트 기준 경로로 참조해야 합니다.
  • 로컬 개발 서버와의 동작 일치: Python 기본 http.server는 확장자 없는 /docs/web/slug 요청을 찾지 못해 404를 반환하므로, 로컬 프리뷰 서버(serve.py)에서도 경로에 대응하는 .html 파일을 자동으로 찾아 서빙하도록 핸들러를 오버라이드해야 합니다.
  • 사이트맵과 Canonical의 1:1 일치: sitemap.xml의 <loc> 값과 각 페이지의 <link rel="canonical">, og:url이 글자 단위까지 동일해야 Google Search Console에서 리다이렉트 경고가 발생하지 않습니다.
  • Use Root-Relative Paths (/): Under Clean URLs, browser resolution of relative paths depends on whether a trailing slash is present, so always reference CSS, JS, and images from the root (e.g., /css/theme.css).
  • Parity with Local Preview Server: Python's default http.server returns 404 for extensionless /docs/web/slug paths, so override the request handler in your local serve.py to resolve .html files automatically.
  • Exact Match Across Sitemap and Canonical Tags: Ensure <loc> entries in sitemap.xml match each page's <link rel="canonical"> and og:url character-for-character to prevent redirect warnings in Google Search Console.

디스크의 물리 파일 확장자와 웹에 공개되는 논리 URL을 빌드 파이프라인에서 분리하면, 정적 호스팅의 308 리다이렉트 오버헤드를 줄이고 검색엔진 색인 효율을 안정적으로 유지할 수 있습니다.

Decoupling physical .html filenames from public extensionless URLs in the build pipeline eliminates unnecessary 308 redirects and keeps search engine indexing consistent.