정적 사이트 URL 에서 trailing slash 지우기 — Next.js + Lambda@Edge 의 3단 합주
static export 로 배포한 블로그 URL 뒤에 붙는 / 가 거슬려서 없애려다 세 가지 서로 다른 원인을 만난 기록. trailingSlash 설정, Lambda@Edge redirect, 그리고 SPA client-side navigation 까지 한 세트로 해결한 이야기.
시작 — /ko/breathing/ 이 거슬려서
사이트를 한동안 돌려보다 주소창을 보면 이런 식이었습니다.
https://js2devlog.com/ko/breathing/
https://js2devlog.com/ko/devlog/supabase-integration/
모든 URL 뒤에 / 가 자동으로 붙어 있었습니다. 동작엔 문제없지만 공유 링크로 보내거나 북마크할 때 어쩐지 지저분해 보였습니다. 대부분의 모던 웹사이트 — GitHub, Vercel, Twitter — 는 URL 끝이 깔끔한데요.
"이거 어렵지 않겠지" 싶어 시작한 작업이, 의외로 세 가지 서로 다른 계층의 원인 을 하나씩 만나게 된 이야기입니다.
원인 1 — trailingSlash: true 설정
우선 next.config.ts 확인:
const nextConfig: NextConfig = {
output: isDev ? undefined : 'export',
trailingSlash: !isDev, // 프로덕션에서만 true
// ...
};
프로덕션 빌드에서만 trailingSlash: true 가 활성화되고 있었습니다.
왜 기본 true 로 해뒀나
이 블로그는 Next.js output: 'export' + S3 + CloudFront 조합. S3 정적 호스팅 특성상 /breathing 요청에 응답하려면 /breathing.html 파일이 있어야 하는데, App Router 는 기본적으로 /breathing/index.html 디렉토리 구조로 export 합니다.
그 구조와 URL 을 매칭시키려고 trailingSlash: true 를 넣어뒀던 거죠. /breathing/ 로 요청이 오면 S3 가 자연스럽게 /breathing/index.html 을 찾아서 반환.
시도 1 — 그냥 trailingSlash 제거?
처음엔 단순히 이 설정을 제거하면 될 줄 알았습니다. 하지만 빌드해보니 파일 구조가 완전히 달라졌습니다.
[이전: trailingSlash: true]
out/ko/breathing/index.html
out/ko/devlog/supabase-integration/index.html
[제거 후]
out/ko/breathing.html ← flat .html 파일!
out/ko/devlog/supabase-integration.html
Next.js App Router 는 trailingSlash 설정에 따라 파일 구조 자체가 바뀐다는 것을 그제야 알았습니다 ("App Router 는 항상 dir/index.html 구조" 라는 믿음은 잘못된 것이었습니다).
이제 결정:
- Option A: 파일 구조 flat (.html) 로 바꾸고 CloudFront 쪽 로직도 맞춰 재작성
- Option B: 파일 구조 그대로 두고, URL 만 CloudFront 에서 정리
Lambda@Edge 교체 + S3 파일 대거 변경 + 배포 순서 민감도를 고려해 Option B 선택. 파일 구조는 유지하고 URL 만 깔끔하게.
원인 2 — 엣지 레벨 redirect 가 필요
Option B 로 가면, 사용자가 직접 /ko/breathing/ 으로 요청을 보냈을 때 CloudFront 가 /ko/breathing 으로 301 redirect 해줘야 합니다. 기존 Lambda@Edge 함수에 블록 하나를 추가.
기존 함수 (locale prefix 추가 + index.html rewrite) 에:
// 🆕 추가된 블록
if (uri.length > 4 && uri.endsWith('/')) {
return {
status: '301',
statusDescription: 'Moved Permanently',
headers: {
location: [{ key: 'Location', value: uri.slice(0, -1) + qs }],
},
};
}
홈 (/ko/, /en/) 은 length > 4 조건으로 제외. 그 외 경로는 trailing slash 떼고 redirect.
배포 후 테스트:
curl -I "https://js2devlog.com/ko/breathing/"
# HTTP/2 301
# location: /ko/breathing ✓
주소창에 /ko/breathing/ 직접 입력 → 즉시 /ko/breathing 으로 갱신. 성공!
원인 3 — SPA client-side navigation
그런데 사이트에서 링크를 클릭해보니 이상했습니다.
주소창:
/ko/breathing/← 여전히/가 붙는다?
새로고침하면 / 가 사라지는데, 링크 클릭 시엔 계속 붙는 현상. 한참 추적한 끝에 깨달았습니다. 내부 링크 클릭은 HTTP 요청을 발생시키지 않습니다.
주소창 직접 입력 / 새로고침:
→ HTTP 요청 → CloudFront → Lambda@Edge → 301 → 깔끔 ✓
내부 <Link> 클릭:
→ React 가 메모리상에서 화면 전환 (client-side navigation)
→ HTTP 요청 없음 → Lambda@Edge 안 탐
→ history.pushState("/breathing/") ← / 가 박힌 채로 주소창 갱신
즉 Lambda@Edge 가 개입할 수 없는 영역이 SPA routing 이었습니다.
왜 Link 가 / 를 붙이는가
Next.js <Link> 컴포넌트는 빌드 시 trailingSlash 설정을 참조해서 생성된 HTML 의 <a href="..."> 에 자동으로 / 를 추가 합니다. trailingSlash: true 면 href="/breathing/", client 클릭 시 그 문자열 그대로 pushState.
Link 자체를 갈아치울 수도 있지만 (/ 떼는 wrapper 등), 프레임워크와 싸우는 방식이라 비추.
해결 — 작은 Client Component 로 history.replaceState
간단한 발상:
Client-side navigation 이 일어난 직후 에 브라우저 주소창을 정리하면 된다.
React 의 usePathname 으로 pathname 변화 감지 → useEffect 에서 trailing slash 제거 → history.replaceState 로 URL 갱신. React 리렌더 유발 없음, 리로드 없음, 순수 주소창 편집만.
// src/components/TrailingSlashCleanup.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
export default function TrailingSlashCleanup() {
const pathname = usePathname();
useEffect(() => {
if (pathname.length > 1 && pathname.endsWith('/')) {
const clean = pathname.replace(/\/$/, '') + window.location.search + window.location.hash;
window.history.replaceState(null, '', clean);
}
}, [pathname]);
return null;
}
Layout 에 한 번만 마운트:
// src/app/[locale]/layout.tsx
<SearchProvider>
<TrailingSlashCleanup /> {/* ← 추가 */}
{/* 기존 children */}
</SearchProvider>
그게 전부입니다. 11줄짜리 컴포넌트 하나로 모든 client-side navigation 에서 trailing slash 자동 제거.
동작 정리
| 경로 | 처리 |
|---|---|
주소창 /ko/breathing/ 직접 입력 | Lambda@Edge 가 301 redirect → /ko/breathing |
| 브라우저 새로고침 | 동일 (HTTP 재요청) |
내부 <Link> 클릭 | Next.js 가 /ko/breathing/ 로 pushState → TrailingSlashCleanup 이 replaceState 로 /ko/breathing 로 갱신 |
| 뒤로가기 | popstate → pathname 변화 → useEffect → replaceState |
세 가지 경로 모두 한 점 (/ko/breathing) 으로 수렴. 완전 통일.
성능
궁금했던 부분이라 정리해둡니다.
return null컴포넌트 → DOM 렌더 비용 0history.replaceState→ 리렌더·리로드 유발 없음 (순수 브라우저 API)- 조건 체크 + replaceState → ~0.1 ms
- 페이지 이동마다 1회. 체감 영향 제로
오히려 Lambda@Edge 301 을 내부 링크에도 매번 탔더라면 수십 ms 손해였을 것. Client-side 처리가 오히려 더 빠른 선택.
왜 이게 "3단 합주" 인가
| 단계 | 누가 처리 | 시점 |
|---|---|---|
| 1 | next.config.ts: trailingSlash: true | 빌드 시점 (파일 구조 결정) |
| 2 | Lambda@Edge 301 redirect | 초기 HTTP 요청 (외부 URL 입력·새로고침) |
| 3 | TrailingSlashCleanup client component | SPA 내부 이동 (클릭·popstate) |
세 계층이 각자 자기 영역을 책임지고, 합쳐서 한 가지 결과 (URL 뒤 / 없음) 를 만들어냅니다. 하나라도 빠지면 어떤 시나리오에서 / 가 남습니다.
회고
-
"한 줄 수정" 이라는 직감은 종종 틀립니다.
trailingSlash제거 한 줄로 될 줄 알았는데, 파일 구조·엣지·SPA 까지 세 계층 모두 건드려야 했습니다. -
각 계층의 역할을 정확히 이해하면 가장 저렴한 개입 지점을 고를 수 있습니다. 이번엔 파일 구조를 안 건드리고, Lambda@Edge 에 블록 하나 + 11줄 컴포넌트 하나로 해결. Option A (파일 구조 전환 + 대규모 Lambda 교체) 보다 훨씬 가벼움.
-
프레임워크와 싸우지 말고, 프레임워크가 남긴 빈틈을 채우자.
trailingSlash: true는 그대로 유지해 파일 구조 보존. Link 컴포넌트도 그대로. 대신 SPA routing 이후에 URL 을 정리하는 보조 레이어를 추가. 프레임워크 주흐름을 거스르지 않고 UX 만 개선한 패턴.
비슷한 static export + CDN + SPA 조합을 쓰시는 분이라면, 세 계층 모두 점검해보시길 권합니다. 한 계층만 고치면 새로고침 시엔 깔끔한데 클릭 이동 시엔 안 깔끔 같은 어설픈 상태에 빠지기 쉽습니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...