SvelteKit 정적 사이트의 404 페이지 함정

adapter-static + Cloudflare Workers + csr=false 환경에서 404 페이지를 만들 때 마주친 세 가지 함정과 해결 과정을 정리합니다.

이 블로그는 완전한 정적 사이트입니다. SvelteKit adapter-static으로 prerender하고, Cloudflare Workers Static Assets로 서빙하며, (main)/+layout.jsexport const csr = false를 두어 런타임 JS를 완전히 제거했습니다. 메뉴 토글까지도 네이티브 Popover API로 동작하기 때문에 클라이언트 자바스크립트 한 줄도 필요 없습니다.

이런 환경에서 "없는 페이지에 접근하면 예쁜 404를 보여주자"라는 단순한 요구사항을 구현하다가 무려 세 번의 함정을 차례로 밟았습니다. 같은 길을 가실 분들을 위해 정리합니다.

목표

사용자가 https://xiyo.dev/posts/없는글 접근
→ 사이트 헤더·메뉴·푸터가 그대로 있는 404 페이지가 HTTP 404로 응답
→ JS 없이도 완벽하게 스타일링된 화면

Cloudflare 쪽 기본 설정은 이미 되어 있었습니다.

# wrangler.toml
[assets]
directory = "./build"
html_handling = "auto-trailing-slash"
not_found_handling = "404-page"

not_found_handling = "404-page"는 빌드 결과물 안에 404.html이 있으면, 매칭되는 경로가 없을 때 그 파일 내용을 404 응답으로 돌려주는 옵션입니다. 그러면 SvelteKit이 build/404.html을 만들도록만 하면 끝… 이라고 생각했습니다.

함정 1: +error.svelte는 동작하지 않는다

가장 먼저 떠오른 건 SvelteKit의 표준 에러 라우트입니다. (main)/+error.svelte를 만들고 끝낼 수 있을 줄 알았습니다.

<!-- src/routes/(main)/+error.svelte -->
<script>
  import { page } from '$app/state';
</script>

<h1>{page.status}</h1>
<p>{page.error?.message}</p>

빌드를 돌렸더니 build/404.html이 만들어지지 않았습니다.

이유는 명확합니다. +error.svelte는 런타임에 발생한 에러를 렌더링하는 컴포넌트입니다. SSR 서버나 클라이언트 라우터가 라우트 매칭에 실패했을 때, 그 시점에 컴포넌트를 호출해서 화면을 그립니다.

하지만 이 사이트엔 SSR 서버가 없고, csr = false이기 때문에 클라이언트 라우터도 없습니다. 빌드 시점에 모든 페이지가 HTML 파일로 굳어버리는 구조에서, 런타임 에러 렌더링은 발동할 일 자체가 없습니다.

+error.svelte는 동적 SSR이나 SPA에서나 의미가 있습니다. 완전 정적 환경에서는 빌드 타임에 어떤 형태로든 404.html 파일이 생성되어야 합니다.

함정 2: fallback: '404.html' 은 SPA 셸을 생성한다

다음으로 시도한 건 adapter-staticfallback 옵션입니다.

// svelte.config.js
adapter: adapter({
  pages: 'build',
  assets: 'build',
  fallback: '404.html',
  strict: false  // fallback과 strict: true는 공존 불가
})

문서를 읽어보면 fallback 페이지는 prerender되지 않은 모든 라우트를 받아 처리하는 진입점입니다. 이름을 404.html로 두면 Cloudflare가 이 파일을 404 응답으로 사용해줄 것으로 기대했습니다.

빌드는 성공했고 build/404.html도 생겼습니다. 하지만 wrangler dev로 띄워서 없는 경로에 접근해보면 이런 화면이 나옵니다.

fallback 방식의 빈 페이지

fallback: '404.html' 로 생성된 SPA 셸. CSS도, 헤더도, 메뉴도 없고 텍스트만 덩그러니 떠 있습니다.

생성된 파일을 직접 들여다보면 무슨 일이 일어났는지 보입니다. 1.8KB짜리 빈 셸일 뿐입니다.

<!DOCTYPE html>
<html lang="ko-kr">
  <head>
    <link rel="modulepreload" href="/_app/immutable/entry/start.xxx.js">
    <link rel="modulepreload" href="/_app/immutable/entry/app.xxx.js">
  </head>
  <body data-sveltekit-preload-data="hover">
    <div>
      <script>
        Promise.all([
          import("/_app/immutable/entry/start.xxx.js"),
          import("/_app/immutable/entry/app.xxx.js")
        ]).then(([kit, app]) => kit.start(app, element));
      </script>
    </div>
  </body>
</html>

fallback이 만드는 파일은 클라이언트에서 부트스트랩되는 SPA 진입점입니다. JS가 로드돼야 라우터가 깨어나고, 그제야 현재 URL을 보고 에러 페이지를 그립니다.

문제는 csr = false입니다. 이 사이트는 클라이언트 JS를 일절 실행하지 않도록 설정돼 있기 때문에, 부트스트랩 스크립트가 로드되더라도 SvelteKit 라우터가 활성화되지 않습니다. 결국 빈 div만 남습니다.

fallback은 SPA용 옵션입니다. csr = false로 정적 HTML을 고집하는 사이트와는 구조적으로 충돌합니다.

함정 3: /404 라우트와 숨은 경로 함정

세 번째 시도가 진짜 정답에 가까웠습니다. 그냥 /404 라우트를 만들면 prerender 결과로 build/404.html이 자동으로 생기지 않을까?

<!-- src/routes/(main)/404/+page.svelte -->
<script lang="ts">
  import { Card, CardBody } from '$lib/components/design/card';
  import { localizeHref } from '$lib/paraglide/runtime.js';
  import * as m from '$lib/paraglide/messages.js';
</script>

<Card>
  <CardBody class="flex flex-col items-center gap-6 py-16">
    <span class="text-hero font-black">404</span>
    <p class="text-xl font-bold">{m.pageNotFound()}</p>
    <a href={localizeHref('/')}>HOME</a>
  </CardBody>
</Card>

svelte.config.jsprerender.entries/404를 추가하면 빌드 타임에 build/404.html이 생성됩니다. fallback 방식과 달리 이 파일은 SPA 셸이 아니라 사이트 레이아웃·CSS가 모두 박힌 10KB짜리 진짜 HTML입니다.

 prerender: {
   entries: [
     '/',
     '/feed.xml',
     '/sitemap.xml',
     '/portfolio',
+    '/404'
   ]
 }

루트 경로 (/없는글) 에서 테스트하면 잘 동작합니다. 한국어가 기본 locale이라 그런지 일본어, 영어 deep URL도 헤더 글자만 한국어로 나올 뿐 레이아웃은 깔끔했습니다.

그런데 영어 deep URL 한 곳에서 묘한 화면이 나왔습니다.

locale deep URL에서 CSS가 깨진 404

/en-us/posts/없는글 에서 본 영어 404. 헤더 텍스트는 보이지만 카드도, 메뉴도, 색깔도 전부 사라졌습니다.

콘솔에는 이런 에러가 떠 있었습니다.

[ERROR] Failed to load resource: 404 (Not Found)
@ http://localhost:8787/en-us/_app/immutable/assets/0.xxx.css

CSS 파일을 /en-us/_app/... 경로에서 찾고 있었습니다. 진짜 위치는 /_app/... 인데요.

진짜 원인: 브라우저는 URL 기준으로 상대경로를 푼다

빌드된 파일을 들여다봤습니다. SvelteKit 2.0의 기본값인 paths.relative = true 환경에서 만들어진 HTML은 위치마다 다른 경로 형태를 가지고 있었습니다.

$ grep -oE '"[^"]*_app[^"]*"' build/index.html
"./_app/immutable/assets/0.xxx.css"

$ grep -oE '"[^"]*_app[^"]*"' build/posts/blog.html
"../_app/immutable/assets/0.xxx.css"

$ grep -oE '"[^"]*_app[^"]*"' build/404.html
"/_app/immutable/assets/0.xxx.css" 절대경로!

$ grep -oE '"[^"]*_app[^"]*"' build/en-us/404.html
"../_app/immutable/assets/0.xxx.css" 상대경로!

흥미롭습니다. 루트 404.html은 SvelteKit이 자동으로 절대경로를 박았는데, locale별 en-us/404.html, ja-jp/404.html은 일반 페이지처럼 상대경로입니다. 그래서 루트 404는 잘 보이고 locale 404만 깨졌던 겁니다.

여기서부터는 Cloudflare와 브라우저의 표준 동작을 이해해야 합니다.

Cloudflare는 리디렉트가 아니라 내부 rewrite

not_found_handling = "404-page"가 어떻게 동작하는지 Cloudflare 공식 문서는 명확합니다.

"Workers will serve the contents of the nearest 404.html file with a 404 Not Found status."

핵심은 "nearest""contents of the file" 입니다. Cloudflare는 요청 URL을 변경하지 않습니다. 302/301 같은 리디렉트가 아니라 내부 rewrite입니다. 사용자 브라우저의 주소창은 https://xiyo.dev/en-us/posts/없는글 그대로 유지되고, 응답 본문만 가장 가까운 디렉토리의 404.html 내용으로 채워집니다.

/en-us/posts/없는글에 접근하면 Cloudflare는 build/en-us/에서부터 위로 올라가며 404.html을 찾습니다. build/en-us/404.html이 있으니 이걸 서빙하고 끝입니다.

WHATWG URL 표준은 base URL 기준으로 상대경로를 푼다

브라우저는 RFC 3986 §5.2.3WHATWG URL Standard 를 따라 상대경로를 해석합니다. base URL은 문서 URL이고, 상대경로는 base URL의 마지막 슬래시 뒤를 잘라낸 디렉토리 위에 붙습니다.

Base URL: https://xiyo.dev/en-us/posts/없는글
Current dir: https://xiyo.dev/en-us/posts/    (마지막 세그먼트 제거)
Relative ref: ../_app/immutable/assets/0.xxx.css
Resolved: https://xiyo.dev/en-us/_app/immutable/assets/0.xxx.css

그래서 콘솔에 /en-us/_app/... 404가 떴던 겁니다. 파일이 있는 위치 (build/) 와 브라우저가 상상하는 base URL (/en-us/posts/없는글) 이 어긋나면, 상대경로는 100% 깨집니다.

해결: paths.relative = false

해법은 한 줄입니다. svelte.config.js에서 모든 자산을 root-relative 절대경로로 강제합니다.

 kit: {
+  paths: { relative: false },
   adapter: adapter({
     pages: 'build',
     assets: 'build',
     fallback: undefined,
     strict: true
   })
 }

paths.relative 는 SvelteKit 2.0에서 추가된 옵션이고 공식 문서에 이렇게 적혀 있습니다.

"If set to false, %sveltekit.assets% and references to build artifacts will always be root-relative paths."

즉 모든 HTML이 /_app/... 형태로 통일됩니다. 어떤 깊이의 URL에서 서빙되더라도 브라우저가 항상 도메인 루트 기준으로 자산을 찾기 때문에 깨질 수 없습니다.

다시 빌드해서 확인해보면:

$ grep -oE '"[^"]*_app[^"]*"' build/en-us/404.html
"/_app/immutable/assets/0.xxx.css" 절대경로

같은 deep URL에서 다시 테스트해봅니다.

paths.relative=false 적용 후 정상 렌더링

헤더, 카드, 메뉴, 푸터가 모두 정상. CSS가 제대로 로드되어 디자인 시스템이 살아났습니다.

부록: locale별 404가 자동으로 생기는 이유

Paraglide.js 기반 다국어를 쓰면 SvelteKit이 prerender 시점에 locale 변형을 자동으로 만들어줍니다. /404 라우트 하나만 entries에 추가했는데도 결과물은 세 개입니다.

build/404.html         ← 한국어 기본 locale
build/en-us/404.html   ← 영어
build/ja-jp/404.html   ← 일본어

Cloudflare의 "nearest 404.html" 규칙과 맞물려서, /ja-jp/posts/없는글 같은 일본어 deep URL은 자연스럽게 build/ja-jp/404.html이 서빙됩니다. 별도 설정 없이 i18n 404가 동작합니다.

정리

시도 결과 이유
+error.svelte 빌드 결과물 없음 런타임 렌더링이라 csr = false 환경에서 발동 불가
fallback: '404.html' 빈 SPA 셸 클라이언트 부트스트랩 필요, csr = false와 충돌
/404 prerender + 기본 paths locale 404에서 CSS 깨짐 locale 파일은 상대경로로 생성
/404 + paths.relative = false 정상 모든 HTML이 root-relative 절대경로

정적 사이트의 404 페이지는 단순해 보이지만, 파일이 생성되는 위치실제 서빙되는 URL이 어긋날 수 있다는 점을 항상 염두에 두어야 합니다. 브라우저가 base URL로 상대경로를 푸는 표준 동작을 알고 있으면 디버깅이 훨씬 빨라집니다.

같은 길을 걷는 분께 도움이 되길 바랍니다.

참고 문서

Created : 26. 04. 13.
Designed by