정적 블로그 체감 속도 최적화 — Speculation Rules, Stale-While-Revalidate, 웹폰트 제거

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 를 적용했습니다. 지원하지 않는 브라우저에서는 일반 네비게이션으로 동작합니다.

지원 현황 (2026-04 기준)

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 의 배경과 현재

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-faceunicode-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 가 현재 프로덕션의 실측값입니다.

Created : 26/04/17
Designed by