Cloudflare Pages 끝 슬래시(/) 자동 리다이렉트 없애는 방법

Cloudflare Pages에 정적 사이트를 올리면 /x 주소가 /x/로 리다이렉트되는 경우가 있습니다. 원인인 x/index.html 구조를 빌드 후 x.html로 바꿔서 끝 슬래시 없는 주소를 그대로 유지한 방법을 정리합니다.

Cloudflare Pages에서 /x가 /x/로 리다이렉트되는 문제

이 블로그는 Cloudflare Pages에 올린 정적 사이트입니다. 정적 사이트란 서버가 요청마다 페이지를 만드는 대신, 빌드할 때 모든 페이지를 HTML 파일로 미리 만들어 두고 그 파일을 그대로 내려주는 사이트를 말합니다.

처음에는 Next.js의 정적 내보내기(export)로 만들었고, 글 주소는 https://sncstudio.net/win-acme-validation-port처럼 끝에 /가 없는 형태였습니다. 검색엔진에도 이 주소로 등록되어 있었습니다.

그런데 사이트를 React Router 8로 옮기고 배포했더니, 기존 주소로 들어가면 끝에 /가 붙은 주소(/win-acme-validation-port/)로 이동해 버렸습니다. 페이지는 잘 보이니 큰일이 아닌 것 같지만, 검색 노출 면에서는 문제가 됩니다.

  • 검색엔진에 등록된 모든 글 주소가 한 번씩 리다이렉트(다른 주소로 자동 이동)를 거치게 됩니다.
  • 검색엔진은 /x와 /x/를 서로 다른 주소로 봅니다. 그래서 이미 쌓인 검색 평가가 새 주소로 옮겨질 때까지 시간이 걸리고, 사이트맵·canonical(대표 주소 표시) 태그와 실제 주소가 어긋나게 됩니다.

그래서 주소는 예전처럼 끝 슬래시 없이 유지하기로 했습니다.

원인: Cloudflare Pages의 HTML 파일 경로 규칙

원인은 빌드 결과물의 파일 구조에 있었습니다. Cloudflare Pages는 요청한 주소와 경로가 맞는 HTML 파일을 찾아서 내려주는데, 파일 이름이 어떤 모양이냐에 따라 "정식 주소"를 다르게 정합니다1.

빌드 결과 파일Cloudflare Pages의 정식 주소다른 주소로 요청하면
contact.html/contact/contact.html → /contact로 이동
about/index.html/about//about → /about/로 이동

Next.js 정적 내보내기는 win-acme-validation-port.html처럼 첫 번째 형태로 파일을 만들었습니다. 반면 React Router의 prerender(빌드 시점에 페이지를 HTML로 미리 만드는 기능)는 win-acme-validation-port/index.html처럼 두 번째 형태로 만듭니다. 그래서 같은 주소인데도 Cloudflare Pages가 끝에 /가 붙은 쪽을 정식 주소로 보고 이동시킨 것입니다.

즉, 주소를 바꾸려면 Cloudflare 설정이 아니라 파일 이름을 바꾸면 됩니다.

빌드 후 x/index.html을 x.html로 평탄화하기

React Router 빌드가 끝난 뒤, 결과물 폴더를 돌면서 x/index.html을 x.html로 옮기는 스크립트를 하나 추가했습니다. 폴더 안에 있던 파일을 한 단계 위로 꺼내는 것이라 "평탄화"라고 부르겠습니다.

package.json의 빌드 명령 맨 끝에 이 스크립트(scripts/postbuild.ts)를 붙였습니다. 이 블로그는 bun으로 실행합니다.

"build": "bun scripts/contentlayer.ts && react-router build && bun scripts/postbuild.ts",

scripts/postbuild.ts의 전체 코드는 다음과 같습니다. React Router 결과물(build/client)을 Cloudflare Pages에 올릴 out 폴더로 복사한 다음 파일 이름을 바꿉니다.

import {cpSync, existsSync, readdirSync, renameSync, rmSync, mkdirSync, statSync} from "node:fs";
import path from "node:path";

const SRC = 'build/client';
const OUT = 'out';

rmSync(OUT, {recursive: true, force: true});
cpSync(SRC, OUT, {recursive: true});
rmSync(path.join(OUT, '__spa-fallback.html'), {force: true});

function walk(dir: string): string[] {
  return readdirSync(dir).flatMap(name => {
    const file = path.join(dir, name);
    return statSync(file).isDirectory() ? walk(file) : [file];
  });
}

function move(from: string, to: string) {
  if (from === to) return;
  if (existsSync(to)) throw new Error(`이미 있는 파일입니다: ${to}`);
  mkdirSync(path.dirname(to), {recursive: true});
  renameSync(from, to);
}

for (const file of walk(OUT)) {
  // 사이트맵 리소스 라우트도 클라이언트 내비게이션용 .data를 만드는데 쓰이지 않는다.
  if (file.endsWith('.xml.data')) {
    rmSync(file);
    continue;
  }
  const rel = path.relative(OUT, file);
  let target = rel.split(path.sep).map(decodeURIComponent).join(path.sep);
  if (path.basename(target) === 'index.html' && path.dirname(target) !== '.') {
    target = `${path.dirname(target)}.html`;
  }
  move(file, path.join(OUT, target));
}

// 비어 버린 디렉터리 정리
function prune(dir: string) {
  for (const name of readdirSync(dir)) {
    const sub = path.join(dir, name);
    if (statSync(sub).isDirectory()) prune(sub);
  }
  if (dir !== OUT && readdirSync(dir).length === 0) rmSync(dir, {recursive: true});
}
prune(OUT);

console.log(`[postbuild] ${SRC} → ${OUT}`);

핵심은 강조한 부분입니다. 하나씩 보면 다음과 같습니다.

index.html을 폴더 이름.html로 옮기기

파일 이름이 index.html이고 맨 위 폴더가 아니면, 그 폴더 이름에 .html을 붙인 이름으로 옮깁니다. win-acme-validation-port/index.html은 win-acme-validation-port.html이 되고, en/win-acme-validation-port/index.html은 en/win-acme-validation-port.html이 됩니다.

사이트 첫 화면인 맨 위의 index.html은 그대로 둡니다. 첫 화면 주소는 원래 /이므로 바꿀 필요가 없습니다.

퍼센트 인코딩된 폴더 이름 되돌리기

이 블로그는 카테고리와 태그 이름에 한글을 씁니다. 빌드 결과에서 한글 경로가 %EC%9D%B8...처럼 퍼센트 인코딩(URL에 쓸 수 없는 글자를 % 숫자로 바꾼 형태)으로 남는 경우가 있어서, decodeURIComponent로 원래 글자로 되돌립니다. Cloudflare Pages는 디코딩된 이름으로 파일을 찾기 때문입니다.

같은 이름이 있으면 빌드를 멈추기

move 함수는 옮길 자리에 이미 파일이 있으면 에러를 냅니다. 예를 들어 a.html과 a/index.html이 함께 있으면 둘 중 하나가 덮어써지는데, 조용히 덮어쓰면 어떤 페이지가 사라졌는지 알기 어렵습니다. 그래서 차라리 빌드를 실패시키도록 했습니다.

빈 폴더 정리

파일을 꺼내고 나면 빈 폴더가 남습니다. 마지막의 prune 함수가 비어 있는 폴더를 지웁니다. 폴더 안에 다른 파일(예: 영어 글이 들어 있는 en/)이 있으면 남겨 둡니다.

결과 확인

빌드 전후 파일 구조는 이렇게 바뀝니다.

# react-router build 결과 (build/client)
win-acme-validation-port/index.html
en/win-acme-validation-port/index.html

# postbuild 이후 (out)
win-acme-validation-port.html
en/win-acme-validation-port.html

배포한 뒤 curl -sI로 응답 헤더만 확인해 봤습니다. 끝 슬래시 없는 주소는 바로 200(정상)이 나옵니다.

$ curl -sI https://sncstudio.net/win-acme-validation-port
HTTP/2 200
content-type: text/html; charset=utf-8

반대로 끝에 /를 붙이거나 .html을 붙여서 요청하면, Cloudflare Pages가 308(영구 리다이렉트)로 끝 슬래시 없는 주소로 보내 줍니다. 원하던 대로 정식 주소가 /win-acme-validation-port 하나로 정리되었습니다.

$ curl -sI https://sncstudio.net/win-acme-validation-port/
HTTP/2 308
location: /win-acme-validation-port

$ curl -sI https://sncstudio.net/win-acme-validation-port.html
HTTP/2 308
location: /win-acme-validation-port

주의사항

  • 이 방법은 서버 없이 HTML 파일만 올리는 정적 배포일 때 쓸 수 있습니다. 요청마다 서버에서 페이지를 만드는 방식이라면 주소 처리는 그 서버 설정에서 해야 합니다.
  • 페이지 안의 링크도 끝 슬래시 없는 주소(/win-acme-validation-port)로 걸어야 합니다. 링크가 /x/ 형태면 누를 때마다 리다이렉트가 한 번씩 일어납니다.
  • 사이트맵과 canonical 태그의 주소도 같은 형태인지 함께 확인하는 것이 좋습니다.
  • 로컬에서 Cloudflare Pages와 같은 동작을 확인하려면 bunx wrangler pages dev out으로 out 폴더를 띄워 보면 됩니다.

참조

Footnotes

  1. https://developers.cloudflare.com/pages/configuration/serving-pages/ ↩

2026 © snc studio·개인정보처리방침