Trailing slash: URL 끝 슬래시, 붙일까 뗄까?

웹사이트를 만들다 보면 한 번쯤 이런 고민을 하게 되는데요. /about이라고 쓸까, /about/이라고 쓸까? 끝에 슬래시 하나 붙는 게 뭐 그리 대수일까 싶지만, 막상 운영해 보면 이 작은 문자가 검색 순위, 깨진 이미지, 끝나지 않는 리디렉션(redirect) 같은 문제로 돌아오곤 합니다. 😅
URL 끝에 붙는 이 슬래시를 트레일링 슬래시(trailing slash)라고 부릅니다. 결론부터 말씀드리면 이렇게 하면 됩니다.
- 빌드 결과물 모양에 맞춰 한쪽을 고릅니다.
about/index.html처럼 페이지마다 디렉터리를 만든다면 붙이고, 서버에서 렌더링하는 앱이라면 프레임워크 기본값을 따릅니다. - 반대 형태로 들어온 요청은 301이나 308로 영구 리디렉션합니다.
/assets/app.css처럼 확장자가 있는 파일 경로는 이 규칙에서 뺍니다.- 검색 엔진에 대표 주소를 알려 주는 표준 URL(canonical URL)과 사이트맵, 내부 링크도 모두 고른 형태로 적습니다.
이 글에서는 이 네 가지가 왜 필요한지 살펴보고, 주요 호스팅 플랫폼과 프레임워크에서 실제로 어떻게 설정하는지 정리해 보겠습니다.
슬래시 하나로 달라지는 것
먼저 슬래시가 단순한 취향 문제가 아니라는 걸 확인해 볼까요? 브라우저는 페이지 안의 상대 경로(relative path)를 현재 URL을 기준으로 해석하는데, 이때 마지막 슬래시가 기준 디렉터리를 결정합니다. 자바스크립트의 URL API로 확인해 보겠습니다.
console.log(new URL("./intro", "https://example.com/docs/guide").href);
console.log(new URL("./intro", "https://example.com/docs/guide/").href);
console.log(new URL("../api", "https://example.com/docs/guide").href);
console.log(new URL("../api", "https://example.com/docs/guide/").href);
https://example.com/docs/intro
https://example.com/docs/guide/intro
https://example.com/api
https://example.com/docs/api
같은 ./intro 링크인데 결과가 다르죠? 슬래시가 없으면 guide를 파일로 보고 그 부모인 /docs/를 기준으로 삼고, 슬래시가 있으면 guide/를 디렉터리로 보고 그 안을 기준으로 삼습니다. 그래서 /docs/guide/에서 잘 보이던 이미지가 /docs/guide로 들어오면 404가 나는 일이 생깁니다.
HTTP 입장에서도 /docs/guide와 /docs/guide/는 완전히 다른 리소스입니다. 서버가 둘을 같은 페이지로 응답해 주는 건 어디까지나 서버의 친절일 뿐, 프로토콜이 보장하는 동작이 아닙니다.
검색 엔진은 두 URL을 따로 본다
검색 엔진도 HTTP와 똑같이 봅니다. Google은 To slash or not to slash라는 글에서 슬래시가 있는 URL과 없는 URL을 별개로, 동등하게 취급한다고 밝혔습니다. 어느 쪽이 더 낫다는 기준은 없습니다.
예외는 도메인 바로 뒤의 루트 하나뿐입니다. https://example.com과 https://example.com/은 같은 URL입니다. URL 표준에서 빈 경로는 /로 정규화되기 때문입니다.
console.log(new URL("https://example.com").href); // https://example.com/
문제는 루트 아래의 페이지입니다. /about과 /about/이 둘 다 200 OK로 같은 내용을 돌려주면 검색 엔진 눈에는 똑같은 문서가 두 개 있는 셈입니다. 중복 콘텐츠(duplicate content)가 생기고, 외부 링크의 신호가 두 URL로 나뉘며, 크롤러는 같은 페이지를 두 번 긁습니다. 결국 Google이 둘 중 하나를 알아서 고르는데, 그게 내가 원한 쪽이라는 보장은 없습니다.
그래서 Google은 선호하는 형태 하나를 정하고 다른 형태는 리디렉션으로 보내라고 권합니다. 리디렉션을 걸 수 없다면 rel="canonical"로 대표 URL을 알려줍니다.
<link rel="canonical" href="https://example.com/about/" />
이렇게 대표로 지정하는 URL을 표준 URL이라고 합니다. Google의 중복 URL 통합 가이드에서는 리디렉션과 rel="canonical" 태그를 강한 신호로, 사이트맵을 약한 신호로 분류합니다. 그리고 이 신호들은 서로 맞아야 합니다. 서버는 /about을 /about/으로 보내는데 표준 URL과 사이트맵은 /about을 가리킨다면 Google은 어느 쪽을 믿어야 할지 헷갈립니다. 표준 URL 지정을 비롯한 메타 태그 전반은 메타 태그로 검색엔진 최적화하기에서 다뤘습니다.
리디렉션은 301이나 308로
리디렉션을 건다면 상태 코드도 골라야 합니다. 쓰이는 코드는 대부분 다음 네 가지입니다.
| 코드 | 의미 | 리디렉션 후 요청 |
|---|---|---|
| 301 | 영구 이동 | POST가 GET으로 바뀔 수 있음 |
| 302 | 임시 이동 | POST가 GET으로 바뀔 수 있음 |
| 307 | 임시 이동 | 메서드와 본문 유지 |
| 308 | 영구 이동 | 메서드와 본문 유지 |
슬래시 정규화는 “이 페이지의 진짜 주소는 저쪽”이라는 뜻이니 영구 이동인 301이나 308이 맞습니다. Google은 둘을 똑같이 취급하므로 SEO로는 차이가 없고, 308은 POST 요청의 메서드와 본문을 그대로 유지하니 폼 전송이나 API 호출이 섞인 경로라면 308이 더 안전합니다.
302나 307 같은 임시 리디렉션은 맞지 않습니다. Google의 리디렉션 문서에 따르면 Googlebot은 임시 리디렉션을 따라가긴 하지만, 목적지를 표준 URL로 삼는 신호로는 쓰지 않습니다. 그런데 뒤에서 보겠지만 Cloudflare Workers와 TanStack은 기본 리디렉션이 307입니다. 이런 환경에서는 표준 URL과 사이트맵, 내부 링크를 맞추는 일이 특히 중요합니다. 상태 코드 전반은 HTTP 상태 코드 안내서를 참고하세요.
어느 쪽을 고를까?
검색 엔진은 특정 형태를 더 좋아하지 않습니다. 하나로 정해서 일관되게 쓰기만 하면 되니, 빌드 결과물과 프레임워크가 이미 쓰고 있는 형태를 따르는 게 가장 덜 피곤합니다.
| 상황 | 추천 |
|---|---|
about/index.html 형태로 빌드하는 정적 사이트 | 붙이기 |
about.html 형태로 빌드하는 정적 사이트 | 떼기 |
| 서버에서 렌더링하는 앱 | 프레임워크 기본값 |
| 이미 운영 중인 사이트 | 지금 색인된 형태 유지 |
정적 사이트는 호스팅 플랫폼이 파일 구조를 보고 URL을 정리하니 빌드 결과물 모양에 맞추면 되고, 서버 렌더링 앱은 프레임워크 라우터가 먼저 판단하니 기본값을 따르면 됩니다. 다만 Astro처럼 기본값이 두 형태를 모두 받아 주는 경우는 하나로 정해야 합니다. 이미 운영 중이라면 규칙을 바꾸는 순간 모든 URL이 한꺼번에 리디렉션되니 지금 형태를 유지하세요.
어느 쪽을 고르든 확장자가 있는 파일 경로는 예외로 둬야 합니다. /assets/app.css가 /assets/app.css/로 리디렉션되면 스타일과 스크립트가 통째로 깨집니다. 대부분의 플랫폼은 이 예외를 알아서 처리하지만, 그렇지 않은 곳도 있어서 아래에서 따로 짚겠습니다.
호스팅 플랫폼별 동작
정적 사이트의 슬래시 처리는 대부분 호스팅 플랫폼이 결정합니다. 주요 플랫폼의 동작은 다음과 같습니다.
| 플랫폼 | 기본 동작 | 리디렉션 코드 | 설정 |
|---|---|---|---|
| GitHub Pages | 디렉터리 경로에 슬래시를 붙임 | 301 | 없음 |
| Cloudflare Pages | 파일 구조를 따름 | 308 | 없음 |
| Cloudflare Workers | 파일 구조를 따름 | 307 | assets.html_handling |
| Vercel | 두 형태 모두 200 | 없음 | trailingSlash |
GitHub Pages와 Cloudflare Pages는 설정이 없고 빌드 결과물 모양이 곧 URL 규칙이 됩니다. about/index.html은 /about/으로, contact.html은 /contact로 정리됩니다.
Cloudflare Workers에 정적 자산을 올린다면 wrangler.jsonc의 html_handling으로 고를 수 있습니다. 기본값인 auto-trailing-slash는 Pages처럼 파일 구조를 따르고, force-trailing-slash는 항상 붙이고, drop-trailing-slash는 항상 떼며, none은 아무것도 정리하지 않습니다.
{
"assets": {
"directory": "./dist/",
"html_handling": "force-trailing-slash",
},
}
다만 공식 문서에 나와 있듯 Workers의 리디렉션은 모두 307입니다.
Vercel은 vercel.json의 trailingSlash로 정합니다. true면 슬래시를 붙이고 false면 떼는데, 둘 다 308로 리디렉션합니다. true일 때도 /about/styles.css처럼 확장자가 있는 경로에는 슬래시를 붙이지 않습니다. 옵션을 지정하지 않으면 두 형태 모두 같은 내용을 돌려주는데, Vercel 문서도 중복 콘텐츠로 색인될 수 있다며 권장하지 않습니다. Vercel이라면 반드시 둘 중 하나를 지정하세요.
{
"trailingSlash": true
}
플랫폼마다 기본값이 이렇게 다르기 때문에, 플랫폼을 옮기면 URL 규칙이 조용히 바뀔 수 있습니다. 옮기기 전후로 주요 페이지 몇 개를 curl의 -I 옵션으로 요청해서 응답 헤더를 확인해 보는 게 좋습니다.
프레임워크별 설정
서버에서 렌더링하는 페이지는 요청이 라우터를 먼저 거치기 때문에 프레임워크 설정이 실제 응답을 결정합니다.
| 프레임워크 | 옵션 | 기본값 | 리디렉션 코드 | 파일 경로 예외 |
|---|---|---|---|---|
| Astro | trailingSlash | 'ignore' | GET은 301, 나머지 308 | 있음 |
| TanStack | trailingSlash | 'never' | 307 | 없음 |
| Next.js | trailingSlash | false | 308 | 있음 |
Astro는 'always', 'never', 'ignore' 중에서 고릅니다. 이 설정은 온디맨드 렌더링(on-demand rendering) 페이지에만 적용되고, 사전 렌더링(prerendering)된 정적 페이지는 호스팅 플랫폼이 처리합니다. 그래서 빌드 결과물 모양을 정하는 build.format과 짝을 맞춰야 합니다. directory면 'always', file이면 'never'입니다. 그렇다고 정적 사이트에서 이 설정이 쓸모없는 건 아닙니다. 개발 서버는 설정과 다른 형태의 요청에 경고 페이지를 띄워 슬래시가 빠진 내부 링크를 잡아 주고, @astrojs/sitemap은 이 설정에 맞춰 사이트맵 URL을 만들며, Vercel 같은 어댑터는 이 값을 플랫폼의 리디렉션 설정으로 옮겨 줍니다.
import { defineConfig } from "astro/config";
export default defineConfig({
trailingSlash: "always",
build: {
format: "directory",
},
});
TanStack Router는 라우터를 만들 때 trailingSlash를 'always', 'never', 'preserve' 중 하나로 정합니다. 이 옵션은 <Link>가 만드는 주소에도 적용되므로 내부 링크도 저절로 맞춰집니다. TanStack Start도 라우팅은 TanStack Router가 맡기 때문에 설정 위치가 같고, 요청마다 라우터를 새로 만들도록 getRouter() 함수를 내보냅니다.
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createRouter({
routeTree,
trailingSlash: "always",
});
}
TanStack을 쓸 때는 두 가지를 알고 있어야 합니다. 우선 TanStack Start 서버는 /about/처럼 설정과 다른 형태로 들어온 요청을 307로 리디렉션합니다. 그리고 Astro와 달리 확장자가 있는 경로를 예외로 두지 않습니다. 그래서 'always'를 쓴다면 파일 요청이 라우터까지 오지 않는 배포 환경인지 확인해야 합니다. Cloudflare Workers처럼 정적 파일을 앱보다 먼저 응답하는 플랫폼이라면 문제없습니다.
Next.js는 TanStack처럼 기본적으로 슬래시를 떼고, trailingSlash: true로 바꾸면 붙입니다. 다만 TanStack과 달리 어느 쪽이든 /file.txt 같은 정적 파일과 .well-known/ 아래 경로는 예외로 둡니다.
module.exports = {
trailingSlash: true,
};
TanStack Start를 Cloudflare Workers에 올릴 때
지금까지 플랫폼과 프레임워크를 따로 살펴봤는데요. 둘을 함께 쓰는 실전에서는 어떻게 설정하는지 알아볼까요? 예로 TanStack Start 앱을 Cloudflare Workers에 올리는 경우를 직접 확인해 보겠습니다.
먼저 Workers가 요청을 처리하는 순서부터 알아 두면 좋습니다. Workers는 기본적으로 요청 경로와 일치하는 정적 파일이 있는지 먼저 확인하고, 있으면 Worker 코드를 실행하지 않고 그 파일을 바로 응답합니다. 일치하는 파일이 없을 때만 요청이 Worker, 즉 TanStack Start의 라우터로 넘어갑니다.
그래서 이 조합에서는 슬래시를 정하는 곳이 두 군데 생깁니다. Worker 코드보다 먼저 실행되는 Workers의 정적 자산 처리(html_handling)와 Worker 안의 TanStack 라우터(trailingSlash)입니다. 둘이 다른 형태를 가리키면 라우터가 만드는 링크(<Link>)는 /about인데, 그 링크로 들어온 요청은 정적 자산 처리가 /about/으로 리디렉션해 버리는 식의 불일치가 생깁니다. 표준 URL이나 사이트맵에 라우터 기준으로 /about을 적어 두었다면 이것도 실제 응답과 어긋나게 됩니다.
가장 간단한 방법은 html_handling을 라우터의 trailingSlash와 같은 방향으로 맞춰 두는 것입니다.
TanStack trailingSlash | Workers html_handling |
|---|---|
'never' (기본값) | "drop-trailing-slash" |
'always' | "force-trailing-slash" |
'preserve' | 권장하지 않음 |
{
"main": "@tanstack/react-start/server-entry",
"assets": {
"html_handling": "drop-trailing-slash",
},
}
'preserve'는 들어온 형태를 그대로 두어 /about과 /about/이 모두 200으로 응답되니, 앞에서 본 중복 콘텐츠 문제가 생깁니다. 맞출 짝이 없는 셈이라 둘 중 하나를 고르는 게 좋습니다.
SSR만 쓴다면 정적 자산 폴더에는 JS와 이미지만 있어서 html_handling이 영향을 줄 HTML 파일이 없습니다. 페이지 요청은 모두 라우터가 처리하니 이 설정이 없어도 문제는 없습니다.
차이는 페이지를 사전 렌더링할 때 생깁니다. Start는 사전 렌더링한 페이지를 about/index.html 형태로 만드는데, 이 파일은 Worker보다 먼저 정적 자산으로 서빙됩니다. 그런데 html_handling 기본값은 이런 디렉터리 형태를 보고 /about을 /about/으로 보냅니다. 라우터가 'never'라면 정반대 방향이죠. 그래서 drop-trailing-slash로 바꿔 주어야 정적 페이지와 서버 페이지가 같은 형태가 됩니다. 처음부터 라우터와 같은 방향으로 맞춰 두면, 나중에 사전 렌더링을 켜더라도 신경 쓸 게 없습니다.
Astro를 Cloudflare Workers에 올릴 때
Astro도 Cloudflare 어댑터를 쓰면 같은 구조가 됩니다. 사전 렌더링된 페이지는 Workers의 정적 자산 처리가, prerender = false로 둔 온디맨드 렌더링 페이지는 Worker 안의 Astro가 응답합니다. 정적 페이지 /about과 온디맨드 페이지 /dash가 있는 앱을 기본 설정 그대로 올려 봤습니다.
/about 307 http://localhost:8791/about/
/about/ 200
/dash 200
/dash/ 200
/about은 Workers가 about/index.html을 보고 슬래시를 붙였는데, /dash는 두 형태 모두 200입니다. Astro의 기본값 'ignore'가 둘 다 받아 주기 때문인데요. 한 사이트 안에서 페이지마다 규칙이 다르고, 온디맨드 페이지에는 중복 콘텐츠까지 생긴 셈입니다.
Astro에서는 trailingSlash와 build.format의 짝을 맞추면 Workers는 기본값 그대로 둬도 됩니다. 앞에서 본 대로 build.format이 사전 렌더링 파일의 모양을 정하고, Workers는 그 모양을 따르기 때문입니다.
Astro trailingSlash | Astro build.format |
|---|---|
'always' | "directory" (기본값) |
'never' | "file" |
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
adapter: cloudflare(),
trailingSlash: "always",
});
'always'로 바꾸고 다시 요청해 보면 두 페이지가 같은 형태로 정리됩니다.
/about 307 http://localhost:8791/about/
/about/ 200
/dash 301 http://localhost:8791/dash/
/dash/ 200
반대로 'never'만 바꾸고 build.format을 그대로 두면 /dash는 슬래시를 떼는데 /about은 여전히 붙어서 다시 어긋납니다. 짝을 함께 바꿔야 하는 이유입니다. 참고로 리디렉션 코드는 Workers가 응답하는 정적 페이지는 307, Astro가 응답하는 온디맨드 페이지는 301로 서로 다르지만, 방향만 같으면 문제는 없습니다.
마치며
트레일링 슬래시는 정답을 고르는 문제가 아니라 일관성을 지키는 문제입니다. 지금 운영 중인 사이트가 있다면 다음 네 가지를 확인해 보세요.
- 반대 형태로 요청하면 리디렉션되는가(가능하면 301이나 308로)
- 리디렉션이 한 번에 끝나는가(
/about→/about/→/about처럼 루프를 돌지 않는가) - 표준 URL과 사이트맵, 내부 링크가 같은 형태를 가리키는가
- 확장자가 있는 파일 경로는 정규화에서 빠져 있는가
주요 페이지 몇 개를 슬래시를 붙이고 떼서 curl -I로 요청해 보면 금방 알 수 있습니다. 생각보다 많은 사이트가 두 형태 모두 200을 돌려주고 있을 겁니다. 🤔
상대 경로가 어떻게 해석되는지 더 정확히 알고 싶다면 RFC 3986의 참조 해석 절을 참고하세요.
This work is licensed under CC BY 4.0