xiyo.dev 를 가능한 한 빠른 블로그로 만드는 것이 이번 작업의 목표였습니다. 먼저 SvelteKit 런타임 기반 호버 프리로드를 걷어내고 Speculation Rules 로 바꿨습니다. 이어 _headers 에 SWR 정책을 적용했고, 검증 과정에서 드러난 Cloudflare 헤더 직렬화 문제를 수정했습니다. 마지막으로 첫 방문 전송량에서 비중이 컸던 웹폰트를 제거했습니다.
이 블로그는 SvelteKit adapter-static + Cloudflare Workers Static Assets + csr = false 로 구성되어 있습니다. 런타임 JavaScript 가 없습니다. 메뉴 토글은 HTML Popover API, 페이지 간 전환은 CSS @view-transition 으로 처리합니다.
측정 조건과 실측 요약
실제 프로덕션 xiyo.dev 를 Chrome DevTools Slow 3G 에뮬레이션 + 375 × 812 모바일 뷰포트 (iPhone 사이즈) 에서 playwright 로 녹화했습니다.
| 메트릭 | Before (적용 전, 일부 추정) | After (현재 프로덕션) |
|---|---|---|
| 메인 CSS raw | 195 KB | 40 KB |
| 메인 CSS gzip | ~61 KB | 7.4 KB |
| 빌드 산출물 woff2 파일 수 | 210 | 0 |
| 빌드 산출물 폰트 용량 | 7.5 MB | 0 |
| 홈 첫 방문 총 전송량 | ~230 KB (추정) | 8.7 KB |
| 홈 DOMContentLoaded (Slow 3G) | 수 초대 (추정) | 510 ms |
_headers immutable 실제 적용 max-age |
60 초 (무력화) | 31 536 000 초 (1 년) |
CSS 와 폰트 수치는 변경 전 빌드 산출물의 실측값입니다. Slow 3G 로딩 시간은 Before 재측정을 생략한 추정치입니다. 이하 본문에서 세 변경을 순서대로 정리합니다.
1부. Speculation Rules
SvelteKit 런타임 프리로드에서 Speculation Rules 로의 전환
초기에는 SvelteKit 클라이언트 런타임을 이용해 호버 시점의 프리로드를 수행했습니다. <body> 에 붙는 기본 속성이 이를 담당합니다.
<body data-sveltekit-preload-data="hover">
이 속성은 SvelteKit 런타임이 해석해 호버된 링크의 다음 페이지 데이터를 선로드합니다. 다만 프리로드 하나만을 위해 클라이언트 런타임 전체를 유지할 이유가 약하다고 판단했습니다. 메뉴 토글은 HTML Popover API, 페이지 전환은 CSS @view-transition 으로 대체 가능한 상태였고, nojs.club 등록을 목표로 두면서 런타임 전체 제거의 동기가 분명해졌습니다.
csr = false 로 런타임 JavaScript 를 제거했습니다. 그 결과 data-sveltekit-preload-data 는 더 이상 해석되지 않습니다. 그래서 대체 수단으로 Speculation Rules API 를 적용했습니다. 지원하지 않는 브라우저에서는 일반 네비게이션으로 동작합니다.
Speculation Rules 는 실사용 기준으로 Chromium 계열 중심입니다. Firefox 는 prefetch · prerender 모두 아직 구현 중이며 (Bugzilla 1969396, 1969838), Safari 는 WebKit / Safari Technology Preview 에서 same-origin prefetch 작업이 진행됐지만 stable 채널 shipping 여부는 공식 릴리즈 노트로 확인하지 못했습니다. 비 Chromium 환경에서는 일반 네비게이션으로 폴백하는 progressive enhancement 성격입니다.
Speculation Rules — 브라우저 표준 프리로드
Chromium 기반 브라우저는 JavaScript 없이 해석하는 선언적 JSON 규칙으로 프리로드를 지시할 수 있습니다.
<script type="speculationrules">
{
"prefetch": [...],
"prerender": [...]
}
</script>
prefetch 와 prerender 의 차이
| 모드 | 수행 범위 | 클릭 시 동작 | 메모리 비용 |
|---|---|---|---|
| prefetch | HTML 바이트만 디스크 캐시에 저장 | 파싱 · CSS · 이미지 다운로드를 클릭 이후 수행 | 낮음 |
| prerender | 숨겨진 프로세스에서 DOM · CSS · JS · paint 까지 완료 | 브라우저가 prerender 된 문서를 활성화. PerformanceNavigationTiming.activationStart 가 활성화 시점을 가리킴 |
페이지당 수십~수백 MB |
prerender 는 완성된 문서 트리를 메모리에 보유하고, 클릭 시점에 윈도우를 해당 컨텍스트로 전환합니다. 활성화 이후 첫 페인트까지의 지연은 실측상 수 ms 수준입니다.
eagerness 옵션별 트리거 조건
| 값 | 트리거 (데스크톱) | 터치 기기 |
|---|---|---|
conservative |
pointerdown |
pointerdown |
moderate (기본값) |
호버 200 ms 이상 유지 또는 pointerdown |
pointerdown |
eager |
링크가 뷰포트 진입 즉시 | 동일 |
immediate |
페이지 로드와 함께 매치되는 모든 링크 | 동일 |
moderate 는 호버 의도가 확인된 링크만 프리로드하므로 대역폭 낭비가 적습니다. 터치 기기에는 호버가 없으므로 pointerdown 이 적용됩니다.
이 블로그의 구성
포스트 경로는 수가 많아 전체를 prerender 대상으로 지정하면 메모리 비용이 커집니다. 반면 루트 · /about · /portfolio 같은 소수의 랜딩 페이지는 이미지가 무겁고 대상이 제한적이어서 prerender 효과가 큽니다. 두 경로 그룹을 다음과 같이 분리했습니다.
<script type="speculationrules">
{
"prefetch": [
{
"where": {
"and": [
{ "href_matches": "/posts/*" },
{ "not": { "href_matches": "/nav" } }
]
},
"eagerness": "moderate"
}
],
"prerender": [
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/posts/*" } },
{ "not": { "href_matches": "/nav" } }
]
},
"eagerness": "moderate"
}
]
}
</script>
/nav 는 popover dialog 전용 라우트로 사전 로드가 불필요해 양쪽 규칙에서 제외했습니다.
이 블로그는 서버가 prerender 해 둔 정적 HTML 을 그대로 서빙합니다. 클라이언트 측 hydration 이 없으므로 활성화 즉시 화면이 교체됩니다. 구현 방식만 놓고 보면 AMP 가 제시했던 즉시 전환 경험과 닮아 있습니다.
AMP (Accelerated Mobile Pages) 는 Google 이 2015 년 발표한 모바일 웹 프레임워크입니다. Google Search 는 AMP 페이지를 AMP Viewer · Signed Exchange 경로로 빠르게 노출할 수 있었고, AMP Viewer 경로에서는 google.com/amp/... 형태 URL 이 노출되었습니다. 해당 경로는 퍼블리셔 도메인이 주소창에서 가려진다는 점에서 플랫폼 종속 비판을 받았습니다. 2021 년 Google 은 Page Experience 업데이트 로 Top Stories 등 검색 기능에서 AMP 를 더 이상 필수 요건으로 두지 않았습니다. 이후 퍼블리셔 채택 규모는 축소 추세이나, 정확한 이탈 시점은 공식 1 차 출처로 확인하지 못했습니다.
AMP 가 제공한 경험 — 사전 렌더링된 문서의 즉시 표시 — 은 Speculation Rules 가 퍼블리셔 원 URL 과 브라우저 표준 위에서 같은 효과를 제공합니다.
2부. Stale-While-Revalidate
동작
Cache-Control 헤더에 두 지시자를 함께 지정합니다.
Cache-Control: public, max-age=0, stale-while-revalidate=604800
응답은 다음과 같이 3 구간으로 해석됩니다.
max-age구간 — 캐시에서 즉시 반환. 이 예시에서는 0 초.stale-while-revalidate구간 (604 800 초 = 7 일) — 캐시의 만료된 응답을 즉시 반환하고 백그라운드에서 원본을 재검증.- 위 두 구간 이후 — 원본 재검증을 동기적으로 수행하는 일반 응답.
결과적으로 재방문자는 7 일 이내에는 네트워크 대기 없이 이전 응답을 받고, 다음 방문에 최신 버전이 반영됩니다.
경로별 정책
Cloudflare Workers Static Assets 는 static/_headers 파일을 네이티브로 지원합니다.
| 경로 | Cache-Control | 의도 |
|---|---|---|
/_app/immutable/* |
max-age=31536000, immutable |
해시 URL, 1 년 불변 |
/posts/*, /en-us/posts/*, /ja-jp/posts/* |
max-age=0, SWR 7일 |
재방문 즉시 반환 + 백그라운드 갱신 |
*.png, *.jpg, *.webp, *.avif, *.svg, *.ico |
max-age 7일, SWR 30일 |
갱신 빈도 낮음 |
/feed.xml, /sitemap.xml |
max-age 1시간, SWR 1일 |
크롤러 대상 XML |
/, /about, /portfolio, /nav, /404, 로케일 루트 |
max-age 60초, SWR 1시간 |
신규 글 반영 지연 제한 |
문제점 — /* fallback 이 immutable 을 무력화시킨 케이스
초기 _headers 구성에는 다음과 같은 캐치올 규칙이 포함되어 있었습니다.
/*
Cache-Control: public, max-age=60, stale-while-revalidate=3600
wrangler dev --local 에서 curl -I 로 경로별 응답을 확인하다가 헤더가 의도와 다르게 직렬화되는 것을 확인했습니다.
$ curl -I http://localhost:8787/_app/immutable/chunks/B4d5FnLc.js
Cache-Control: public, max-age=31536000, immutable, public, max-age=60, stale-while-revalidate=3600
Cloudflare Workers Static Assets 문서 는 _headers 의 동작을 이렇게 정의합니다. 복수 규칙에 매치되는 요청은 모든 규칙의 헤더를 상속하며, 동일 헤더가 중복되면 값이 콤마로 결합됩니다. Cloudflare Pages 도 같은 규칙입니다. 다만 _headers 는 Worker 코드나 Pages Functions 가 직접 생성한 응답에는 적용되지 않습니다. 이 블로그는 정적 에셋만 서빙하므로 이 규칙이 그대로 적용됩니다.
RFC 9111 §4.2.1 은 동일 캐시 지시자가 중복될 때 구현이 첫 값을 사용하거나 응답 전체를 stale 로 간주하도록 규정합니다. 어느 쪽이든 1 년 캐시를 기대하고 걸어 둔 immutable 은 표준적으로 신뢰할 수 없는 상태가 됩니다. immutable 키워드도 그 응답에서 동일 헤더의 다른 지시자와 충돌해 실질적 효과를 잃습니다. 이미지 (max-age=604800 + max-age=60) 와 포스트 (max-age=0 + max-age=60) 도 같은 구조적 문제에 노출되어 의도한 캐시 정책이 보장되지 않았습니다.
해결 — 경로당 규칙 1 개 보장
/* fallback 을 제거하고 HTML 페이지를 명시적으로 열거했습니다. 설계 의도는 _headers 파일 상단 주석에 기록했습니다.
# 매치되는 모든 규칙의 동일 헤더는 콤마로 연결됩니다.
# 경로당 정확히 하나의 규칙에만 매치되도록 구성해야 중복 지시자로
# `immutable` · 장기 캐시 정책이 무효화되지 않습니다. 따라서 `/*` fallback 은 사용하지 않습니다.
/_app/immutable/*
Cache-Control: public, max-age=31536000, immutable
/posts/*
Cache-Control: public, max-age=0, stale-while-revalidate=604800
# ... 이미지, feed, 로케일별 HTML 규칙 ...
/
Cache-Control: public, max-age=60, stale-while-revalidate=3600
/about
Cache-Control: public, max-age=60, stale-while-revalidate=3600
/portfolio
Cache-Control: public, max-age=60, stale-while-revalidate=3600
수정 후 각 경로의 응답 헤더는 단일 값으로 정상화됩니다.
$ curl -I https://xiyo.dev/_app/immutable/assets/0.D9SxcKrv.css
Cache-Control: public, max-age=31536000, immutable
$ curl -I https://xiyo.dev/posts/<slug>
Cache-Control: public, max-age=0, stale-while-revalidate=604800
$ curl -I https://xiyo.dev/
Cache-Control: public, max-age=60, stale-while-revalidate=3600
3부. 웹폰트 제거
배경 — CSS 195 KB 의 구성 분석
Speculation Rules 와 SWR 반영 이후 남은 주요 전송량을 분석했습니다. 메인 CSS 파일의 raw 크기는 195 KB 였습니다. Tailwind v4 컴파일 결과로는 이례적인 크기입니다. 빌드 산출물에 포함된 woff2 파일 구성은 다음과 같았습니다.
| 패밀리 | subset 수 (이 빌드 관찰값) | 합계 |
|---|---|---|
| Pretendard Variable | 92 | 2.9 MB |
| Pretendard JP Variable | 117 | 4.6 MB |
| Neo둥근모 Code | 1 | 39 KB |
| 전체 | 210 | 7.5 MB |
CSS 195 KB 중 상당 부분이 위 210 개 파일을 참조하는 @font-face 선언과 unicode-range 지정이었습니다.
Pretendard Variable — 길형진 (orioncactus) 이 2021 년 공개한 오픈소스 웹폰트입니다. Apple system-ui 환경과 시각적으로 호환되도록 설계됐고, variable weight 45 ~ 920 구간과 동적 서브셋 (dynamic subset) 을 지원합니다.
Pretendard JP Variable — 위 프로젝트의 일본어 환경용 변형 입니다. 일본어 텍스트 환경에 맞춘 자형을 제공하며, OpenType ss05 feature 로 한국형 자형을 선택할 수 있습니다.
Neo둥근모 Code — 한글 도트 폰트 "둥근모꼴" 계보를 현대 유니코드로 이식한 neodgm 패밀리의 코딩용 변형으로, Neo둥근모에 코딩용 ligature 가 추가된 글꼴 입니다. 이 블로그에서는 코드 블록 표시용으로 사용되고 있었습니다.
표의 subset 수는 공식 배포 스펙이 아니라 이 빌드에서 관찰된 파일 수입니다.
subset 시스템은 실제 다운로드량을 줄이지만 CSS 크기는 줄이지 않는다
Pretendard 는 유니코드 범위별 subset 으로 분할되어 있고, 브라우저는 @font-face 의 unicode-range 선언에 따라 페이지 내 등장하는 글자 범위의 subset 만 다운로드합니다. 개별 방문자가 210 개 파일 전체를 받지는 않습니다.
그러나 @font-face 선언 자체는 여전히 CSS 안에 포함되어 있습니다. woff2 실제 다운로드 여부와 무관하게 CSS 파싱 비용과 첫 방문 시 전송 바이트는 그대로 발생합니다. 재방문은 SWR 과 immutable 정책으로 0 요청이지만, 첫 방문자는 195 KB raw CSS 를 그대로 받는 상태였습니다.
디자인 요소로서의 폰트 vs 속도 — 속도를 택했다
폰트는 본문 타이포의 통일성을 결정하는 디자인 요소로, 블로그 전체의 시각적 정체성에 직접 영향을 줍니다. Pretendard 의 일관된 자소 폭과 높이 비율은 이 블로그의 기본 레이아웃을 설계할 때 전제했던 타이포 기반이기도 합니다. 디자인 손실이 있다는 점은 분명했습니다.
반면 런타임 JavaScript 를 제거한 뒤에도 7.5 MB 웹폰트를 유지하는 구성은 방침상 일관되지 않았습니다. 체감 속도를 최우선 목표로 둔 이상, 디자인 동질성보다 로딩 성능을 우선했습니다.
src/app.css 에서 폰트 @import 세 줄을 삭제했고, --font-sans 와 --font-mono 는 OS 기본 스택으로 바꿨습니다. package.json 에서 pretendard, pretendard-jp, @kfonts/neodgm-code 도 제거했습니다. csr = false 결정과 같은 성격입니다 — 해당 계층에 대안을 얹는 대신 계층 자체를 들어냅니다.
결과
| 항목 | Before | After |
|---|---|---|
| 메인 CSS raw | 195 KB | 40 KB (−79%) |
| 메인 CSS gzip | ~61 KB | 7.4 KB (−88%) |
| 빌드 woff2 파일 수 | 210 | 0 |
| 첫 방문 폰트 네트워크 요청 | 가변 (unicode-range 의존) |
0 |
Trade-off
운영체제에 따라 기본으로 표시되는 한글 폰트가 달라집니다.
| OS | 렌더링되는 한글 폰트 |
|---|---|
| macOS · iOS | Apple SD Gothic Neo |
| Windows | Malgun Gothic |
| Android · ChromeOS | Noto Sans CJK KR |
- 포기: Pretendard 의 일관된 자소 폭과 시각적 정체성, 프로젝트 전반의 타이포 동질성
- 획득: FOIT/FOUT 부재 (OS 폰트는 즉시 사용 가능), 메인 CSS gzip 88% 감소, 빌드 산출물 7.5 MB 감소, Tailwind 가 처리하는 CSS 입력 축소로 빌드 시간 단축
마무리
세 변경은 "추가" 가 아니라 "제거" 로 일관됩니다. SvelteKit preload 속성은 런타임과 함께 걷어 내고 Speculation Rules 로 대체했고, _headers 의 /* fallback 을 제거해 경로당 단일 규칙을 보장했고, 웹폰트 의존성을 전부 지워 첫 방문 CSS 를 1/5 로 줄였습니다. Slow 3G · 모바일 뷰포트 기준 홈 첫 방문 전송량 8.7 KB, DOMContentLoaded 510 ms 가 현재 프로덕션의 실측값입니다.