본문으로 건너뛰기
yceffort
PostsSeriesTagsAbout🧪 Research
EN

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
◆ ESSAY
--min
--year
KOoriginal

mailMail icongithubtwitter
yceffort
•
© 2026
•
https://yceffort.kr
BACK TO INDEX
◆ SERIES · 서비스 워커 캐싱 딥다이브

서비스 워커 경유 비용 실측: GA4가 답하지 못한 대조군을 랩에서 만들기

avatar
yceffort
2026-08-28 · 81분
81min
2026year
KOoriginal
web-performanceservice-workerpwanextjs
시리즈

서비스 워커 캐싱 딥다이브

3 / 3
목록 보기
  1. 01서비스 워커 캐싱의 동작 원리: 프록시, 라이프사이클, 다섯 가지 전략
  2. 02서비스 워커 캐싱 적용기: App Router의 함정들과 GA4 실측
  3. 03서비스 워커 경유 비용 실측: GA4가 답하지 못한 대조군을 랩에서 만들기
서비스 워커 캐싱 적용기: App Router의 함정들과 GA4 실측

Table of Contents

  • 3주를 기다리면 모일 줄 알았다
  • 첫 번째 시도: 평균 대신 백분위수
  • 두 번째 시도: 대조군을 만든다
    • 설계: 한 회차의 모양
    • 네트워크 조건은 프록시로 만들었다
    • 함정 네 가지
  • 결과: 비용이 보이지 않는다
    • 정적 자산: Cache Storage 대 HTTP 캐시
    • 하드 리로드와 소프트 내비게이션
    • 그 차이의 정체: 지표에 잡히지 않는 트래픽
  • preload를 꺼서 비용의 위치를 찾다
  • 세 번째 시도: 기기를 느리게
  • 간극을 남기며
  • 그래서 성능에는 도움이 되는가
  • 언제, 어떻게 쓰면 되는가

3주를 기다리면 모일 줄 알았다

2편에는 확정하지 못한 숫자가 하나 남아 있었다. 하드 리로드는 서비스 워커를 우회하므로 navigation_type이 reload인 표본은 워커 경유 여부만 다른 깨끗한 대조군이 되는데, 2주치를 모아 보니 no 51ms(12건)와 yes 595ms(64건)였다. 워커 경유 비용이 500ms 안팎이라는 정황으로 읽히기는 하지만 12건으로 단정할 수는 없어서, 몇 주 더 쌓이면 다시 보겠다고 적어 두었다.

그 몇 주를 기다리기 전에 확인해 둘 것이 하나 있었다. 지금 속도라면 표본이 얼마나 쌓이는지다. 8월 13일부터 28일까지 16일 동안 TTFB 이벤트를 navigation_type과 워커 경유 여부로 나누면 다음과 같다(싱가포르발 크롤러 트래픽은 제외했고, 이 글의 GA4 수치는 전부 그렇다).

구간16일 합계하루 평균3주 뒤 예상
navigate, yes70043.8약 920
navigate, no66141.3약 870
back-forward, yes26316.4약 345
reload, yes654.1약 85
reload, no120.8약 16
back-forward, no80.5약 11

기다리려던 계획을 접은 것은 이 표를 보고 나서다. 하드 리로드를 하는 방문자가 하루에 한 명이 안 되니, 3주를 더 기다려도 16건이고 6주를 기다려도 30건대에 머문다. 게다가 12건이 12번의 관측도 아니다. dateHourMinute까지 펼쳐 보면 12건 중 7건이 8월 22일 13시 12분과 14분과 16분 세 버킷에 몰려 있는데, 계측이 제대로 도는지 확인하려고 내가 5분 동안 하드 리로드를 되풀이한 흔적이다. 같은 프로필의 나머지 한 건까지 합치면 12건 중 8건이 내 브라우저에서 나왔다. 서로 다른 방문으로 세면 16일 동안 6번이고, 서로 다른 프로필로 세면 5개다. 하루 0.8건이 아니라 사흘에 한 번 꼴이고, 그 절반 이상이 대조군을 확인하러 들어간 내 트래픽이다. 표본이 적은 이유가 관측 기간이 짧아서가 아니라 그런 행동을 하는 사람이 원래 드물어서라면, 시간을 더 준다고 채워질 것 같지는 않았다. 하드 리로드가 워커를 우회한다는 점 덕분에 대조군이 만들어지는데, 같은 행동이 드물다는 점 때문에 그 대조군이 비어 있는 셈이다.

그렇다면 표본이 넉넉한 navigate끼리 비교하면 되지 않겠느냐는 생각이 자연스럽게 따라오는데, 이 비교는 다른 이유로 막힌다. 8월 27일과 28일 이틀치 TTFB에서 no는 95건 중 87건이 신규 방문자였고, yes는 100건 중 67건이 재방문자였다. 워커를 거치지 않은 내비게이션은 대부분 첫 방문이고 워커를 거친 내비게이션은 대부분 재방문이니, 두 집단의 차이에는 워커 비용과 함께 신규와 재방문의 차이(연결 상태, 기기와 지역 구성, 읽는 글, 브라우저 캐시의 상태)가 섞여 있다. 이 구성은 방문자가 훨씬 늘어도 달라지지 않을 테니, 이 문제도 시간이 해결해 주지는 않을 것이다. 방문자를 무작위로 갈라 한쪽에만 워커를 등록하는 A/B 실험이라면 풀 수 있겠지만, 읽으러 온 사람의 브라우저를 실험 장치로 쓰는 것은 이 블로그에서 하고 싶은 일이 아니었다.

그래서 이 편은 "그러면 어떻게 잴 것인가"를 붙잡은 기록이 됐다. 실사용자 데이터에서 한 번 더 뽑아 보고, 거기서는 얻을 수 없는 것을 확인한 뒤, 대조군을 랩에서 직접 만들었다. 결론을 앞에 모아 두지는 않았다. 세 번의 시도가 각각 어디까지 갔고 어디서 멈췄는지가 이 글에서 하고 싶은 이야기이기 때문이다.

첫 번째 시도: 평균 대신 백분위수

2편의 정산은 전부 평균이었다. GA4 Data API는 지표 합계와 이벤트 수만 주므로 평균 말고는 계산할 것이 없고, 그 평균이 글 하나의 41.7초짜리 극단값에 끌려간 사례를 2편에서 이미 겪었다. 백분위수가 필요했다.

우회로는 단순하다. 이벤트를 dateHourMinute, 워커 경유 여부, navigation_type, 기기 유형, 도시로 쪼개면 이 블로그 정도 트래픽에서는 행 대부분이 이벤트 1건짜리가 된다. 8월 27일과 28일의 다섯 지표 전체가 688행이었고 그중 657행이 1건이었다. 1건짜리 행의 합계는 그 이벤트의 값 자체이니, 행을 늘어놓고 정렬하면 근사 p50과 p75가 나온다. 조회 골격은 다음과 같다.

const [res] = await client.runReport({
  property,
  dateRanges: [{startDate: '2026-08-13', endDate: '2026-08-28'}],
  dimensions: [
    'customEvent:sw_controlled',
    'customEvent:navigation_type',
    'deviceCategory',
    'dateHourMinute',
    'city',
  ].map((name) => ({name})),
  metrics: [{name: 'eventCount'}, {name: 'eventValue'}],
  dimensionFilter: {
    filter: {fieldName: 'eventName', stringFilter: {value: 'TTFB'}},
  },
  limit: 100000,
})

// 행 대부분이 eventCount 1이라, 행의 평균을 건수만큼 복제해 늘어놓으면 근사 분포가 된다
const values = []
for (const row of res.rows) {
  const n = Number(row.metricValues[0].value)
  const mean = Number(row.metricValues[1].value) / n
  for (let i = 0; i < n; i++) values.push(mean)
}
values.sort((a, b) => a - b)
const p50 = values[Math.floor(values.length * 0.5)]

2건 이상인 행은 평균값을 건수만큼 복제하므로 정확한 백분위수는 아니다. 그래도 극단값 하나가 전체를 끌고 가는 일은 막아 준다. 참고로 web-vitals가 보내는 value 파라미터는 커스텀 측정항목이 아니라 표준 eventValue로 조회해야 한다(customEvent:value로 물으면 INVALID_ARGUMENT가 돌아온다).

이 방법으로 16일치를 다시 집계하면 그림이 2편과 꽤 달라진다.

TTFB p50 (ms)noyes차이
navigate, 데스크톱148 (542건)182 (567건)+34
navigate, 모바일147 (119건)183 (131건)+36
reload, 데스크톱56 (11건)131 (35건)+75

평균으로 500ms였던 reload 대조가 p50으로는 75ms이고, 교란이 섞여 있다는 유보를 달아야 하는 navigate 대조는 기기 유형과 무관하게 35ms 안팎이다. 다만 reload의 75ms에는 앞에서 본 버스트가 들어 있다. 데스크톱 no 11건에서 버스트 7건을 빼면 12, 20, 25, 64ms 네 건이 남고, 중앙값을 어떻게 잡든 20ms대라 yes 131ms와의 격차는 오히려 100ms를 넘는다. 격차가 더 크다는 결론이 아니라 크기를 말할 수 없다는 뜻이다. yes의 꼬리를 대표하던 833ms도 8월 22일 15시 28분의 이벤트 하나이고, 프로필이 방금 본 버스트와 같으니 이것도 내 것일 가능성이 크다.

2편의 "재방문자 TTFB +525ms"는 이 관점에서 다시 읽어야 한다. 워커가 모든 사용자에게 500ms를 얹은 것이 아니라, 대부분의 사용자에게는 수십 ms를 얹고 일부 사용자에게 훨씬 큰 값을 얹었으며 그 꼬리가 평균을 끌어올렸다고 보는 편이 데이터에 가깝다. 그 "수십 ms"에는 나중에 단서를 하나 더 달아야 하는데, 이 표의 +34/+36ms가 워커가 만든 지연인지부터가 확실하지 않다. 함정 절의 네 번째에서 다시 다룬다.

같은 방법을 2편에서 새로 심은 LCP 단계 분해에도 적용해 봤다. 수정된 sw_controlled 판정이 배포된 뒤인 8월 27일과 28일 이틀치, navigate 내비게이션만이다.

LCP 단계 p50 (ms)no (79건)yes (36건)
LCP1,220752
lcp_ttfb99159
리소스 로드 지연00
리소스 로드 시간00
요소 렌더 지연1,026494

리소스 두 줄이 0인 것은 이 블로그의 LCP 요소가 대부분 글 제목 h1(텍스트)이기 때문이고, 그래서 LCP는 사실상 TTFB와 렌더 지연의 합이다. 워커를 거친 쪽은 TTFB 단계가 60ms 느리고 렌더 지연은 절반이다. 워커의 비용은 첫 바이트 앞에 있고 워커의 이득은 그 뒤(정적 자산과 폰트)에 있다는 2편의 그림과 맞는다. lcp_ttfb는 web-vitals가 TTFB로 보내는 것과 같은 responseStart를 읽으므로, 앞 표에 달아 둔 단서가 이 60ms에도 그대로 붙는다. 다만 이 표의 yes는 대부분 재방문자이고 재방문자라면 HTTP 캐시에도 같은 자산이 있었을 테니, 렌더 지연의 절반이 Cache Storage 덕분인지 "캐시가 차 있는 재방문" 덕분인지는 이 표로는 가를 수 없다. 이 질문은 랩에서 다시 나온다.

그런데 이 집계에도 한계가 곧 드러났다. 8월 23일에 navigation preload를 배포했으니 그 전후로 yes의 TTFB가 움직였는지 보고 싶었는데, 일별 p50을 그려 보니 배포 효과를 읽을 해상도가 아니었다.

하루 표본이 12건에서 78건 사이라 일별 p50이 42ms에서 521ms 사이를 오간다. 백분위수는 평균의 극단값 문제를 풀어 주지만 표본 크기 문제는 풀어 주지 않는다. 실사용자 데이터에서 얻을 수 있는 것은 여기까지였다. 워커 경유 비용의 크기는 수십 ms 안팎이라는 것, 그 비용이 첫 바이트 앞에 있다는 것, 그리고 그 안에서 preload 같은 개별 조치의 효과를 가려내는 것은 이 트래픽으로는 무리라는 것.

두 번째 시도: 대조군을 만든다

실사용자 데이터의 두 문제(대조군이 안 생긴다, 생겨도 교란된다)는 랩에서는 정의상 존재하지 않는다. 같은 기기, 같은 네트워크 조건에서 워커만 켰다 껐다 하면 되기 때문이다. 대신 랩에는 랩의 문제가 있다. 조건을 실제와 얼마나 비슷하게 만들었는가, 그리고 그 조건에서 나온 숫자가 실사용자 데이터와 어떻게 이어지는가. 앞의 것은 설계로 답할 수 있었지만, 뒤의 것은 이 글을 거의 다 쓰고 나서야 답이 나왔다.

측정 환경: Apple M5 MacBook, macOS 26.5.2, Playwright 1.62.1이 내려받은 Chrome for Testing 151.0.7922.34. 대상은 이 블로그를 next build 후 next start(Next.js 16.3.1)로 띄운 로컬 프로덕션 서버이며, 서비스 워커는 측정 당시의 배포본과 같은 sw.js(코드 v4)다(이 글 뒤쪽에서 그 v4의 결함 하나를 찾아 고쳤고, 본문의 표들은 고치기 전 기준이다). 네트워크 조건은 뒤에 설명할 프록시로 만들었다. 조건당 25회(CPU 스로틀 조건은 10회) 반복했고, 본문의 수치는 별도 표기가 없으면 p50이다. 이 절의 랩 측정은 8월 28일과 29일에 했고, 뒤에 나오는 프로덕션 도메인 측정은 9월 1일 macOS 26.6.2에서 따로 돌렸다. 측정 스크립트와 원본 데이터는 저장소의 apps/blog/scripts/sw-lab/에 있다.

설계: 한 회차의 모양

한 회차는 빈 브라우저 프로필에서 시작해 다섯 번의 이동을 한다.

  1. 홈에 들어간다. 워커를 허용한 조건에서는 여기서 등록과 활성화가 일어나고, navigator.serviceWorker.ready와 controllerchange를 기다린 뒤 프리캐시가 끝나도록 잠시 둔다.
  2. 글 A로 하드 내비게이션한다. 워커는 방금 활성화됐으니 웜 상태다.
  3. 브라우저를 완전히 종료하고 같은 프로필로 다시 연 뒤 글 B로 하드 내비게이션한다. 워커의 콜드 기동이다.
  4. 같은 페이지를 CDP의 Page.reload({ignoreCache: true})로 하드 리로드한다. 실사용자 데이터의 reload, no에 해당하는, 워커를 우회하는 내비게이션이다.
  5. 홈으로 돌아가 글 링크를 클릭한다. 소프트 내비게이션과 그 앞의 ?_rsc= 프리페치 요청들을 PerformanceResourceTiming으로 수집한다.

글 A와 글 B는 다른 글이라(길이도 다르다) 웜과 콜드를 세로로 비교하는 것은 의미가 없고, 같은 구간 안에서 조건끼리 가로로 비교하는 것이 이 설계의 용도다. 각 이동에서 페이지 안에서 다음 값을 읽어 온다.

const [nav] = performance.getEntriesByType('navigation')
const fcp = performance.getEntriesByName('first-contentful-paint')[0]
return {
  workerStart: nav.workerStart,
  fetchStart: nav.fetchStart,
  responseStart: nav.responseStart, // TTFB
  fcp: fcp?.startTime,
  lcp: window.__lcp, // addInitScript로 심어 둔 PerformanceObserver가 갱신한다
  controlled: !!navigator.serviceWorker?.controller,
}

워커 기동 시간은 fetchStart - workerStart다. workerStart는 내비게이션을 처리하려고 워커를 기동하기 시작한 시각(이미 떠 있으면 fetch 이벤트를 보내기 직전)이고 fetchStart는 그 뒤 실제 fetch가 시작된 시각이라, 둘의 차이가 워커가 준비되기까지 기다린 시간이 된다1. 1편에서 웜 기동 2ms를 잰 것과 같은 계산이다.

조건은 다섯 가지다. 워커 없음(Playwright의 serviceWorkers: 'block'), 워커 있음(배포본 그대로, navigation preload 켜짐), 워커 있음이되 preload를 끈 것, 그리고 앞의 두 조건을 CPU 6배 스로틀(Emulation.setCPUThrottlingRate) 아래서 되풀이한 것. preload를 끄는 변형은 측정 동안만 sw.js의 한 줄을 디스크에서 바꿔치기한 것이다.

sed -i '' 's|self.registration.navigationPreload?.enable(),|undefined,|' \
  apps/blog/public/sw.js

next start는 public/ 파일을 요청 시점에 읽으므로 재빌드가 필요 없고, 회차마다 새 프로필에서 워커를 새로 등록하니 바꿔치기한 파일이 곧바로 설치된다. 측정이 끝나면 git checkout으로 되돌린다.

콜드 기동을 만드는 3번 단계는 코드로 보면 별것 없다.

await context.close()
;({context, page, cdp} = await open(userDataDir))
await page.goto(BASE + POST_COLD)

같은 userDataDir로 launchPersistentContext를 다시 부르는 것뿐인데, 여기에 오기까지 한 번 돌아왔다. 그 이야기는 함정 절에서 한다.

네트워크 조건은 프록시로 만들었다

처음에는 DevTools의 네트워크 스로틀(Network.emulateNetworkConditions)을 쓰려고 했다. 그런데 이 설정은 CDP 세션이 붙은 페이지 타깃에 적용되는 것이고, 서비스 워커는 별도 타깃이며 navigation preload 요청은 브라우저가 워커를 대신해 보내는 것이다. 워커의 요청에 스로틀이 걸리지 않으면 워커 조건만 빠른 네트워크를 쓰는 셈이 되어 비교가 무너진다. 걸리는지 아닌지를 확인하는 대신, 확인이 필요 없는 쪽을 택했다. next start 앞에 Node로 30줄짜리 프록시를 두고 모든 응답을 거기서 늦췄다.

const RATE = (4 * 1000 * 1000 * 0.9) / 8 // 450,000 bytes/s
const ONE_WAY = 75 / 2 // ms
let nextFree = 0

// 이 조각이 회선에서 다 도착했을 시각에 쓴다. 첫 조각도 전송 시간만큼 늦춘다
function paced(res, chunk) {
  const now = performance.now()
  const start = Math.max(now, nextFree)
  nextFree = start + (chunk.length / RATE) * 1000
  return new Promise((r) =>
    setTimeout(() => {
      res.write(chunk)
      r()
    }, nextFree - now),
  )
}

http
  .createServer((req, res) => {
    setTimeout(() => {
      const up = http.request(
        {port: 3000, path: req.url, method: req.method, headers: req.headers},
        (u) => {
          setTimeout(async () => {
            res.writeHead(u.statusCode, u.headers)
            res.flushHeaders() // 헤더는 즉시 보내고 본문만 속도를 조절한다
            for await (const chunk of u) {
              for (let o = 0; o < chunk.length; o += 4096) {
                await paced(res, chunk.subarray(o, o + 4096))
              }
            }
            res.end()
          }, ONE_WAY)
        },
      )
      req.pipe(up)
    }, ONE_WAY)
  })
  .listen(3100)

요청을 받으면 편도 지연만큼 기다렸다가 업스트림으로 넘기고, 응답 헤더를 받으면 다시 편도 지연 뒤에 내려보내며, 본문은 4KB 조각으로 잘라 전역 토큰 버킷(nextFree)으로 450KB/s에 맞춰 흘린다. 버킷이 전역이라 동시에 열린 응답들이 대역폭을 나눠 쓴다. 모바일 4G를 염두에 두고 고른 값(왕복 75ms, 다운로드 4Mbps의 90%)이고 DevTools의 Fast 4G 프리셋(다운로드 9Mbps의 90%, 목표 왕복 60ms)보다는 느린데, 절대값보다 중요한 것은 페이지, 워커, preload가 전부 같은 프록시를 지난다는 점이다. 업로드는 속도를 조절하지 않았다. 측정 대상이 전부 GET이라 요청 본문이 없다.

위 코드의 flushHeaders() 한 줄은 처음에 없었고, 그 상태로 시운전한 워커 없는 조건의 TTFB가 205ms였다. 왕복 75ms에 서버 시간을 더해도 90ms 언저리여야 했다. Node의 res.writeHead()는 헤더를 즉시 내보내지 않고 첫 write()까지 붙들고 있어서, 첫 조각의 전송 시간이 TTFB에 얹혀 있었던 것이다. 시운전 당시의 프록시는 본문을 쪼개지 않고 통째로 한 번에 썼으니 그 첫 조각이 압축된 HTML 55KB 전부였고, 450KB/s에서 122ms다. 75에 122를 더하면 205ms 언저리가 된다.

위에 실은 코드는 4KB 서브청킹까지 들어간 최종본이라 같은 숫자가 그대로 나오지는 않는다는 점은 밝혀 둬야겠다. 서브청킹이 있으면 헤더는 첫 4KB를 쓰는 순간에 나가니 얹히는 것은 122ms가 아니라 9ms고, next start가 HTML을 스트리밍으로 내려주는 탓에 업스트림의 첫 조각 자체가 작아서 그 9ms조차 재현되지 않았다. 최종 코드에서 flushHeaders()만 빼고 curl로 재 보면 time_starttransfer가 양쪽 다 79ms 안팎이다. 그래도 한 줄은 남겨 뒀다. TTFB가 업스트림이 본문을 어떻게 쪼개 주느냐에 달리지 않게 해 두는 편이 낫기 때문이다. 어느 쪽이든 그 상태로 본 측정을 돌렸다면 워커 유무와 무관한 100ms를 두고 해석을 시작할 뻔했다. 프록시를 믿기 전에 프록시부터 재야 했다.

함정 네 가지

프록시 말고도 측정을 무효로 만들 뻔한 것이 네 가지 있었다. 앞의 세 가지는 랩을 만드는 동안 만났고, 마지막 하나는 랩을 다 돌리고 이 글의 결론까지 써 놓은 뒤에 알았다.

route()는 HTTP 캐시를 끈다. 로컬 서버라도 페이지는 프로덕션 측정 ID로 GA4에 이벤트를 보내므로 애널리틱스 요청을 막아야 했고, Playwright의 context.route()로 막았다. 그런데 워커 없는 조건에서 웜 내비게이션인데도 정적 자산 33개가 매번 전부 재다운로드됐다. next start가 /_next/static/에 cache-control: public, max-age=31536000, immutable을 내려주는 것을 curl로 확인했으니 헤더 문제는 아니었다. Playwright 문서에 적혀 있듯 라우팅을 켜면 HTTP 캐시가 비활성화된다2. 워커 조건은 자산을 Cache Storage에서 꺼내니 영향이 없고 워커 없는 조건만 불리해지는, 하필 비교를 한쪽으로 기울이는 함정이었다. 차단을 인터셉션 없이 DNS 매핑으로 바꿨다.

const context = await chromium.launchPersistentContext(userDataDir, {
  serviceWorkers: COND === 'nosw' ? 'block' : 'allow',
  args: [
    '--host-resolver-rules=MAP *.google-analytics.com 127.0.0.1, MAP *.googletagmanager.com 127.0.0.1',
  ],
})

그러자 웜 내비게이션의 재다운로드는 33개 중 15개(그 글에서 처음 쓰는 청크들)로, 재기동 후 콜드는 37개 중 4개로 줄었다. 디스크 캐시가 브라우저 재기동을 넘어 살아남는다는 확인이기도 해서, 뒤의 콜드 비교가 "캐시가 빈 상태 대 찬 상태"가 아니라는 근거가 됐다.

stopAllWorkers로는 콜드가 안 된다. 콜드 기동을 만들려고 처음에는 CDP의 ServiceWorker.stopAllWorkers를 썼다(브라우저 세션이 아니라 페이지 세션에서만 받아 준다). 워커의 runningStatus는 분명 stopped가 됐는데, 다음 내비게이션에서 잰 기동 시간이 1.6ms였다. 같은 렌더러 프로세스가 살아 있는 상태에서 스크립트만 다시 올리는 것이라 사실상 웜이다. 사용자가 며칠 만에 돌아와 워커가 처음 뜨는 상황에 가까운 것은 브라우저 프로세스 자체를 종료했다가 같은 프로필로 다시 여는 쪽이어서, 회차마다 재기동하는 비용을 감수했다. 1편에서 "DevTools가 붙어 있으면 콜드를 재현할 수 없다"고 적었는데, DevTools를 떼도 정지 명령만으로는 안 된다는 것을 여기서 배웠다.

워커를 지난 응답은 transferSize가 다르다. 워커가 응답한 내비게이션의 transferSize는 압축 전 본문 크기(236,137)로 찍히고, 같은 페이지를 워커 없이 받으면 압축 크기(55,576)로 찍힌다. 워커를 지난 정적 자산은 0이다. 처음에는 프록시의 속도 조절이 안 걸린 줄 알았는데 집계 방식의 차이였다. 워커 유무를 가로질러 바이트 수를 비교하는 것은 이 값으로는 안 되고, 위의 "33개 중 15개" 같은 적중 수도 워커 없는 조건에서만 셀 수 있다.

랩과 실사용자 데이터가 같은 순간을 재고 있지 않았다. 첫 번째 시도의 navigate +34/+36ms와 잠시 뒤에 나올 랩의 한 자릿수 ms를 나는 같은 종류의 값으로 놓고 견주고 있었는데, 두 값이 재는 순간이 서로 달랐다.

responseStart는 스펙상 firstInterimResponseStart가 0이 아니면 그 값을 돌려주고, 0일 때만 finalResponseHeadersStart를 돌려준다3. Chrome 115가 responseStart를 최종 헤더 쪽으로 옮겼다가 호환성 문제로 Chrome 133에서 되돌리면서 이 정의가 됐다4. 즉 103 Early Hints를 보내는 사이트에서 responseStart는 최종 헤더가 도착한 시각이 아니라 103이 도착한 시각이다.

yceffort.kr은 103을 보낸다. 이걸 확인하는 데 한 번 헛걸음했는데, 그냥 curl을 던지면 200만 보이기 때문이다. sec-fetch-mode: navigate를 붙여 내비게이션 요청으로 보이게 해야 103이 나온다. Chrome UA나 accept: text/html은 없어도 되고, sec-fetch-dest: document만으로는 나오지 않는다. 내용은 server: Vercel과 x-vercel-id 두 줄뿐이고 link 헤더도 없으니 프리로드로 쓰이는 것도 아니다. 엣지가 요청을 받았다는 신호가 전부다.

문제는 워커를 거치면 이 값이 달라진다는 것이다. 프로덕션 도메인에 Playwright를 붙여, 시간대 변동을 상쇄하려고 회차마다 워커를 막은 조건과 허용한 조건을 번갈아 12쌍 돌렸다(24회 전부 x-vercel-cache: HIT이었다. 워커를 지난 응답은 nextHopProtocol이 빈 문자열이라, h2로 찍힌 것은 워커 없는 12회뿐이다). serviceWorkers: 'block'으로 연 쪽은 firstInterimResponseStart 중앙 8.8ms에 finalResponseHeadersStart 중앙 46.0ms였고 responseStart는 앞의 값을 가져갔다. 워커가 제어한 쪽은 12번 모두 controller를 잡은 상태에서 firstInterimResponseStart 중앙 36.3ms였고, finalResponseHeadersStart는 12번 다 0이었다. 워커 경로에서는 interim 타이밍이 전달되지 않고 최종 헤더 시각이 interim 슬롯에 들어가는 것으로 보인다. 워커 없는 쪽은 103 도착 시각을, 워커 있는 쪽은 최종 헤더 도착 시각을 같은 이름으로 보고하고 있었던 셈이다. 최종 헤더끼리 맞춰 놓으면 36.3 대 46.0으로 워커 쪽이 오히려 10ms 가까이 빠른데, 두 조건이 지나는 경로 자체가 다르니 이 차이도 개선으로 해석해서는 안 되고, "+35ms는 아니다"까지만 말할 수 있겠다.

103만 다르게 두고 통제 실험도 해 봤다. 103을 보내는 서버와 안 보내는 서버를 세우고 양쪽 다 최종 헤더는 40ms 뒤에 내려보내도록 고정한 뒤, 워커 유무를 가로질러 responseStart를 읽었다. 조건당 15회다.

조건responseStart p50범위
SW 없음, 103 보냄1.4ms0.5~1.8ms
SW 없음, 103 없음42.3ms41.2~43.5ms
SW, 103 보냄43.9ms42.3~45.3ms
SW, 103 없음43.3ms41.8~44.5ms
SW(preload 끔), 103 보냄42.7ms41.5~44.9ms

103이 없으면 워커 유무의 차이는 1ms다. 관측되던 41ms는 전부 정의 차이였다. 103을 한 번도 보내지 않은 조건에서도 워커 제어 아래서는 interim 슬롯이 40ms대로 채워지는 것, 그리고 finalResponseHeadersStart가 워커 없는 30회에서는 한 번도 0이 아니었고 워커가 제어한 45회에서는 전부 0이었다는 것도 이 실험에서 나온다.

크기도 맞는다. 위 프로덕션 12회에서 103과 최종 헤더 사이는 23ms에서 55ms 사이였고(한 번은 304ms까지 튀었다), curl --trace-time으로 따로 5번 재면 37~66ms였다. 회선과 시각에 따라 이만큼 흔들리지만 첫 번째 시도의 navigate +34/+36ms와 같은 자릿수라는 것은 분명하다. lcp_ttfb의 +60ms도 web-vitals가 같은 responseStart를 읽으므로 같은 아티팩트를 탄다. 적용 범위도 좁지 않다. 8월 13일부터 28일까지 TTFB 이벤트 2,014건 중 Chromium 계열이 1,774건(88.1%)이고, Chrome 1,657건 중 133 이상이 1,544건(93.2%)이다.

그리고 랩에서는 이 차이가 애초에 생길 수 없었다. 위의 셰이핑 프록시는 순수 node http로 res.writeHead()와 res.flushHeaders()만 하고, 업스트림 http.request가 올려 주는 information 이벤트를 아래로 전달하지 않는다. 103이 원천적으로 없으니 랩에서는 워커 조건과 워커 없는 조건이 둘 다 최종 헤더 시각을 쟀고, 그래서 두 조건의 차이가 5ms 아래로 나왔다. 헤더를 언제 내보내는지를 그렇게 따졌으면서 정작 103을 전달할 생각은 못 했다.

이 발견으로 뒤에 나올 결론이 바뀐다. 그대로 두면 랩과 실사용자 데이터 사이의 간극이 지연으로 읽히므로, 마지막 절에서 다시 정리한다.

결과: 비용이 보이지 않는다

다섯 조건의 결과를 한 표에 모으면 다음과 같다.

조건구간워커 기동TTFBFCPLCP
워커 없음웜 / 콜드 / 리로드081 / 82 / 81116 / 120 / 356528 / 584 / 376
워커 + preload웜 / 콜드 / 리로드0 / 2 / 082 / 83 / 81120 / 120 / 360524 / 580 / 380
워커, preload 끔웜 / 콜드 / 리로드0 / 3 / 082 / 94 / 82124 / 128 / 356520 / 588 / 384
워커 없음, CPU 6배웜 / 콜드 / 리로드080 / 82 / 83204 / 260 / 600896 / 1228 / 1080
워커 + preload, CPU 6배웜 / 콜드 / 리로드0 / 3 / 083 / 84 / 81204 / 260 / 460868 / 1060 / 1124

(단위 ms, p50. 리로드 행은 워커 조건에서도 워커를 우회하므로 기동이 0이고, 실제로 그 회차의 workerStart는 0, controller는 null로 찍혔다.)

첫 두 행이 두 번째 시도의 본론이다. 워커를 켜고 끄는 것으로 TTFB, FCP, LCP 어느 것도 p50에서 5ms 이상 움직이지 않았다. 브라우저를 완전히 재기동한 뒤의 콜드 기동은 2ms(25회 전부 2~3ms)였고, 웜 기동은 측정 해상도 아래였다. 1편에서 잰 웜 기동 2ms 안팎이 콜드에서도 크게 다르지 않다. 웜 25회의 TTFB가 워커 없음 79~83ms, 워커 있음 80~83ms 안에 전부 들어 있어서, 표본을 더 늘려도 이 기기에서 다른 답이 나올 것 같지는 않았다.

기대한 그림은 아니었다. 실사용자 데이터가 수십 ms를 가리키고 있었으니 랩에서도 그 근처가 나올 줄 알았다. 이 기기에서 이 워커의 경유 비용은 사실상 0이었다.

정적 자산: Cache Storage 대 HTTP 캐시

표에서 FCP 열이 조건과 무관하게 같은 것은 따로 짚을 가치가 있다. 워커 조건의 정적 자산이 전부 Cache Storage에서 나왔다고 보기 쉽지만, workerStart > 0(웜 33개 중 33개, 콜드 37개 중 37개)은 그 근거가 되지 못한다. 그 값이 0보다 크다는 것은 요청이 워커를 지났다는 뜻일 뿐이고, cacheFirst가 미스 나서 워커가 네트워크로 받아 온 자산도 마찬가지로 0보다 크다. 워커 조건의 transferSize가 전부 0인 것도 캐시 적중의 증거가 아니라 바로 앞 함정에서 본 집계 방식의 차이다.

세는 곳을 옮기면 직접 확인된다. 셰이핑 프록시에 붙인 카운터로 웜과 콜드 내비게이션에서 /_next/static/ 요청이 몇 개나 회선을 지나는지 세 보니 두 조건이 정확히 같았다. 웜은 양쪽 다 15개 206,490바이트, 콜드는 양쪽 다 4개 150,609바이트였고 URL 집합까지 같았다. 워커 조건의 웜 내비게이션은 그 글에서 처음 쓰는 청크를 cacheFirst 미스로 네트워크에 보냈고, 미스가 부른 fetch()는 그 아래의 HTTP 캐시를 그대로 썼다. Cache Storage가 실제로 응답한 것은 콜드였다. 웜에서 저장해 둔 자산들이 브라우저 재기동을 넘어 쓰였다.

두 조건이 회선에서 받아 온 것이 같은 상태에서 FCP는 웜 116ms 대 120ms, 콜드 120ms 대 120ms로 같다. 재방문자에게 한해서는 Cache Storage가 HTTP 캐시보다 빠르지 않다는 뜻이다. 둘 다 디스크에서 읽고, 둘 다 네트워크를 타지 않는다.

이 결과는 앞 절의 LCP 분해 표에 달아 둔 질문에 답하는데, 서로 다른 두 비교를 갈라 놓고 답해야 한다.

하나는 같은 기간 안의 sw_controlled 대조다. 실사용자 데이터에서 워커를 거친 쪽의 렌더 지연이 절반이었던 것, 16일치 FCP p50이 데스크톱 no 1,208ms 대 yes 616ms인 것이 여기 속한다. 이 비교는 no가 대부분 첫 방문이고 yes가 대부분 재방문이니, 차이의 대부분을 워커가 아니라 캐시가 비어 있는 첫 방문과 차 있는 재방문의 차이로 보는 것이 랩 결과와 맞는다.

다른 하나는 2편의 "재방문자 FCP -43%"인데, 여기에는 그 설명이 통하지 않는다. 1,463ms와 829ms는 양쪽 다 재방문자로 한정한 값이라 캐시가 비어 있는 첫 방문이 어느 쪽에도 없기 때문이다. 대신 랩에서 Cache Storage의 속도 이득이 확인되지 않았으니, 남는 설명은 두 기간 사이의 구성 변화다. 컷 위치를 v3 배포 직전까지 늘리면 -43%가 -27%가 된다는 것, 워커가 건드릴 수 없는 CLS가 같은 기간에 19% 움직였다는 것을 2편에 이미 유보로 달아 두었는데, 그 유보의 무게가 더 무거워진 셈이다.

어느 쪽이든 1편의 "재방문 성능이 목표라면 이 레이어는 답이 아닐 가능성이 높다"는 판단과는 어긋나지 않는다. 물론 Cache Storage의 가치는 속도가 아니라 오프라인에 있고, 그것은 이 표로 잰 것이 아니다.

하드 리로드와 소프트 내비게이션

하드 리로드 행은 실사용자 데이터의 reload 대조군과 맞물린다. 랩에서는 워커 조건이든 아니든 리로드 TTFB가 81ms로 같았다. 당연한 결과이지만(둘 다 워커를 우회한다) 실사용자의 reload, no 56ms 대 reload, yes 131ms를 읽을 때 기준이 된다. 다만 그 no는 앞에서 본 대로 사실상 내가 5분 동안 남긴 것을 재고 있어서, 여기서 기준으로 쓸 수 있는 것은 크기가 아니라 방향뿐이다. 차이가 있다면 yes가 워커를 거친 데서 온 것일 텐데, 랩에서는 그 경유 비용이 보이지 않았다. 그리고 네 번째 함정을 거치고 나면 그 차이가 지연이기는 한지도 확실하지 않다.

2편에서 워커가 가장 많은 일을 하는 곳으로 지목한 ?_rsc= 요청은 두 얼굴이었다. 홈에서 뷰포트에 들어온 링크들의 프리페치 325건은 duration p50이 워커 경유 81ms, 미경유 80ms로 사실상 같았고 첫 바이트까지도 양쪽 다 78ms였다. 워커 조건에서는 325건 전부 workerStart가 0보다 컸으니 지나간 것은 분명한데, 지나는 데 든 시간이 1ms다.

그런데 프리페치되지 않은 글을 클릭했을 때 나가는 RSC 요청은 달랐다. 25회 모두에서 워커 경유 175ms, 미경유 131ms로 44ms가 벌어졌다. 첫 바이트까지는 80ms 대 78ms로 같았으니 차이는 전부 본문을 받는 구간에서 났다. 이 측정에서 워커의 비용이 처음으로 보인 지점이다.

그 차이의 정체: 지표에 잡히지 않는 트래픽

본문을 받는 구간이 느려지는 이유로 응답을 복제해 캐시에 넣는 비용을 먼저 의심했지만, 2편의 설계를 떠올리면 더 그럴듯한 후보가 있었다. 소프트 내비게이션은 HTML을 남기지 않으므로, 이 워커는 실제 방문인 RSC 요청을 볼 때마다 배경에서 같은 경로의 HTML을 한 번 더 받고, 받은 HTML을 파싱해 본문 이미지까지 미리 받아 둔다. 그 요청들은 워커에서 나가기 때문에 페이지의 resource timing에는 잡히지 않는다. 페이지가 보지 못하는 것을 세려면 세는 곳을 옮겨야 해서, 앞의 셰이핑 프록시에 카운터를 달았다.

let stats = []

http.createServer((req, res) => {
  if (req.url === '/__stats') {
    // 지금까지 지나간 요청 목록을 돌려주고 비운다
    const body = JSON.stringify(stats)
    stats = []
    res.writeHead(200, {'content-type': 'application/json'})
    res.end(body)
    return
  }

  const rec = {url: req.url, bytes: 0}
  stats.push(rec)
  // ... 아래 본문 속도 조절 루프에서 rec.bytes += chunk.length
})

홈을 띄우고 카운터를 비운 다음, 글 링크를 한 번 클릭하고 8초를 기다렸다. 그동안 프록시를 지난 same-origin 트래픽과, 클릭으로 나간 그 글의 RSC 요청 하나의 시간을 함께 쟀다. 앞의 표들과는 다른 날 다른 글로 잰 별도 측정이라 절대값은 위와 조금 다르지만, 세 조건은 같은 글에서 연달아 잰 것이다(조건당 10회).

조건클릭 RSC duration요청 수바이트
워커 없음146ms36317,702
워커202ms41589,056
워커, 배경 저장 끔148ms36317,702

세 번째 줄에서 인과가 확정된다. handleRSC에서 savePageHTML을 부르는 한 줄만 지우고(RSC 응답 캐싱은 그대로 뒀다) 다시 재자, 요청 수와 바이트가 워커 없는 조건과 한 바이트도 다르지 않게 돌아왔고 56ms 중 54ms가 사라졌다. 남은 2ms가 워커를 지나는 비용이고, 나머지는 워커가 배경에서 하는 일이 같은 회선을 나눠 쓴 결과다.

늘어난 요청 5개의 내역은 이렇다.

늘어난 요청바이트
글 HTML(savePageHTML)55,276
/_next/image 썸네일 4개216,078

클릭하고 머무는 8초 동안 프록시를 지난 same-origin 트래픽이 310KB에서 575KB로 늘었다는 뜻인데, 늘어난 265KB는 성격이 다른 둘로 나뉜다.

글 HTML 55KB는 워커가 순증시키는 트래픽이다. 소프트 내비게이션에는 HTML이 없으므로 대조군은 이 바이트를 어떤 경우에도 받지 않는다.

썸네일 216KB는 순증이라기보다 앞당김이다. 이 4개는 목적지 글 아래에 붙는 관련 글 썸네일이고, 렌더하는 컴포넌트가 priority 없는 next/image라 기본값이 loading="lazy"다. 대조군이 이걸 안 받은 것은 워커가 없어서가 아니라 측정이 클릭 후 8초를 스크롤 없이 기다렸을 뿐이기 때문이다. 실제로 읽는 사람은 아래로 내려가고, 그러면 대조군도 이 이미지를 받는다. 다만 sizes="(min-width: 768px) 120px, 84px"에 맞는 훨씬 작은 변형으로 받는다. 워커가 w=3840을 받아 두는 것과는 여전히 큰 차이다.

그리고 이것은 글 하나짜리 표본이다. 조건당 10회가 전부 같은 글을 되풀이한 것이고, 하필 그 글에는 본문 이미지가 없어서 늘어난 요청 5개가 글 HTML과 링크 썸네일 4개뿐이었다. 본문 이미지가 있는 글이라면 워커가 그것까지 배경에서 받으므로 더 커지고, 외부 도메인 이미지를 쓰는 글이라면 그 요청은 프록시를 지나지 않아 이 숫자에서 아예 빠진다.

이미지 216KB에는 이 측정에서 드러난 결함이 하나 섞여 있다. 워커는 저장할 변형을 고를 때 srcset 후보 중 1080px에 가장 가까운 것을 고르도록 되어 있는데, 실제로 받은 것은 4개 다 w=3840이었다. HTML을 열어 보니 이유가 분명했다. Next.js가 내려준 속성 이름은 srcSet인데 추출 정규식이 (?:src|srcset)="라 대소문자가 걸려 이 속성을 통째로 놓치고, 남는 후보는 가장 큰 변형이 들어 있는 src 하나뿐이다. 화면에서 120px로 그려지는 썸네일을 3840px로 받아 둔 셈이다. HTML 속성 이름은 원래 대소문자를 가리지 않으니 브라우저는 아무 문제가 없었고, 정규식만 문제였다. 고치는 것은 플래그 한 글자였다.

// 고친 뒤. Next.js는 srcSet으로 렌더하므로 i가 없으면 srcset 후보를 통째로 놓친다
const attrRe = /(?:src|srcset)="([^"]+)"/gi

고치고 같은 측정을 다시 돌리자 네 이미지는 의도대로 w=1080 변형으로 바뀌었는데, 아낀 바이트는 기대에 한참 못 미쳤다. 이미지 4개의 합계가 216,078에서 196,709로 9% 줄었고, 클릭 한 번의 전체 트래픽은 589,056에서 569,687로 3% 줄었다. 4개 중 하나는 1080px 변형이 3840px 변형보다 오히려 컸다(51,147 대 60,851). 이 썸네일들은 /api/og/art가 코드로 그리는 이미지라 색이 단순해서, 너비를 줄여도 인코딩 크기가 그만큼 줄지 않고 리샘플링 결과에 따라 뒤집히기도 하는 것으로 보인다. 결함은 진짜였지만 그 결함이 만든 낭비는 생각보다 작았다. 고친 뒤에도 8초 동안의 트래픽은 여전히 79% 늘어나는데, 그중 대조군이 끝내 받지 않는 몫은 글 HTML 55KB, 즉 17%다. 나머지는 독자가 스크롤하면 대조군도 받을 것을 워커가 미리, 그것도 화면에 필요한 것보다 큰 변형으로 받아 두는 몫이다. 진짜 비용은 잘못 고른 변형이 아니라, 글 하나를 열 때마다 글 HTML과 썸네일 4개를 배경에서 더 받는 구조 자체에 있었다.

지연으로는 드러나지 않고 오직 바이트로만 드러나는 종류의 결함이라, 프록시에서 세어 보기 전에는 2편을 쓰면서도 알아채지 못했다.

여기서 한 가지가 분명해진다. 이 워커의 비용이 가장 크게 나타나는 곳은 소프트 내비게이션인데, web-vitals가 보내는 지표는 하드 내비게이션을 단위로 보고되므로 첫 번째 시도에서 본 실사용자 데이터에는 이 구간이 아예 들어 있지 않다. 랩으로 옮기지 않았다면 이 비용은 어느 지표에도 나타나지 않았을 것이다.

preload를 꺼서 비용의 위치를 찾다

셋째 행에서 가장 많은 것을 알 수 있었다. preload를 끄자 콜드 내비게이션의 TTFB만 83ms에서 94ms로 11ms 밀렸고, 웜과 리로드는 그대로였다.

이 워커가 preload를 쓰는 방식은 1편에 나온 표준 형태 그대로다. sw.js의 내비게이션 경로는 networkFirst()를 타고, 그 안의 첫 줄이 preload 응답을 먼저 본다.

async function networkFirst(event, cacheName) {
  const {request} = event
  try {
    // 내비게이션이면 preload로 먼저 출발한 응답을 쓴다. 내비게이션이
    // 아니거나 preload 미지원이면 undefined로 resolve되어 fetch로 간다
    const response = (await event.preloadResponse) ?? (await fetch(request))
    if (response.ok) {
      event.waitUntil(putWithTrim(cacheName, request, response.clone()))
    }
    return response
  } catch {
    // ... 캐시와 오프라인 페이지 폴백
  }
}

11ms라는 크기가 흥미로운 것은 워커 기동이 3ms뿐이기 때문이다. navigation preload는 워커가 뜨기를 기다리지 않고 내비게이션 요청을 먼저 출발시키는 장치이니5, preload가 감춰 주는 시간은 기동 시간이어야 할 것 같은데 실제로는 그보다 8ms가 많다. 남는 8ms는 워커가 뜬 뒤에 오는 직렬 구간이다. fetch 이벤트를 워커 스레드에 디스패치하고, 핸들러가 event.respondWith()에 도달하고, 위 코드의 fetch(request)가 네트워크 서비스로 나가기까지. preload가 없으면 이 구간이 끝나야 요청이 출발하고, preload가 있으면 요청은 내비게이션 시작과 함께 출발해 워커는 event.preloadResponse로 이미 도착해 있는 응답을 집어 돌려주기만 한다.

웜 상태에서 차이가 0인 이유도 같은 그림으로 설명된다. 워커가 이미 떠 있고 이전 fetch에서 열어 둔 경로가 살아 있으면 디스패치와 fetch 출발이 1ms 안에 끝나므로 preload가 감출 것이 없다. 반대로 말하면 preload의 효과는 콜드 기동에서만, 그것도 기동 자체보다 그 뒤의 준비 구간에서 더 크게 난다. 2편에서는 preload를 켠 8월 23일이 그 편의 TTFB 대조 구간 한가운데에 들어 있다는 것을 밝히고, 켠 효과를 실사용자 데이터에서 가려낼 수 있는지는 이 편으로 미뤘다. 이 측정으로 얻은 답은 되찾을 수 있는 시간의 상한이 이 기기에서는 11ms라는 것이고, 그렇다면 가려낼 수 없다. 첫 번째 시도에서 8월 23일 배포의 효과가 실사용자 데이터에 안 보였던 것도 자연스럽다. 일별 p50이 수백 ms를 오가는 그래프에서 콜드 기동 한정 11ms 안팎을 읽을 수는 없다.

세 번째 시도: 기기를 느리게

11ms는 실사용자 데이터의 35ms에 못 미친다. 남은 후보 중 랩에서 만들 수 있는 것은 기기 속도였다. M5는 실사용자 기기 분포에서 가장 빠른 축에 들 테니, CPU를 6배 느리게 하면 워커 기동과 디스패치가 그만큼 늘어나 간극이 좁혀질 것이라는 가설이다.

가설을 시험하기 전에 확인할 것이 있었다. DevTools의 CPU 스로틀이 페이지의 메인 스레드만 늦추는지 워커 스레드까지 늦추는지 확신이 없었다. 판별은 기동 시간으로 했다. 스로틀 아래서 fetchStart - workerStart가 늘어나면 워커에도 걸리는 것이다. 결과는 최소 2ms, p50 3ms, p90 13ms, 최대 13ms로, 스로틀 없는 조건의 2~3ms보다 분명히 늘어났고 꼬리가 생겼으니 워커 스레드에도 걸린다고 볼 수 있었다.

그런데 그것이 전부였다. CPU 6배 조건에서 워커 유무에 따른 콜드 TTFB 차이는 82ms 대 84ms로 2ms였다. FCP는 260ms로 같았고, LCP는 10회 표본이라 흔들림이 커 방향을 말하기 어렵다(1,228 대 1,060으로 워커 쪽이 낮았지만 표본 밖의 이유를 배제할 수 없다). 스로틀이 페이지 쪽에는 확실히 걸렸다는 것은 FCP가 120ms에서 260ms로, 웜 LCP가 528ms에서 896ms로 늘어난 데서 보인다. 그 안에서도 워커의 몫은 한 자릿수에 머물렀다. 기동을 6배 느리게 해도 한 자릿수 ms에 머무는 워커는, 적어도 이 스로틀이 흉내 내는 종류의 느림으로는 35ms를 만들지 못했다.

간극을 남기며

세 번의 시도를 한 줄로 늘어놓으면 이렇게 된다. 실사용자 p50에서는 워커 경유가 navigate에서 +35ms이고, 랩에서는 기동 2ms에 preload가 감추는 직렬 구간 11ms이며, CPU를 6배 늦춰도 한 자릿수를 벗어나지 않는다. 그리고 그 +35ms는 지연이 아니라 103 Early Hints가 만든 측정 기준점 차이였다. 확인한 것과 확인하지 못한 것을 가르면 다음과 같다.

확인한 것부터 적는다. 두 조건을 같은 시점에 맞춰 놓고 보면 이 사이트에서 워커 경유 지연은 p50에서 관측되지 않는다. 랩의 5ms 아래와 실사용자 데이터의 35ms는 서로 다른 답이 아니라 서로 다른 기준점이었다. 랩 안에서 재는 한 이 워커의 경유 비용은 기동 자체보다 기동 뒤의 준비 구간이 크고, navigation preload는 그 전체를 병렬화해서 콜드 기동에서만 11ms를 돌려준다. 웜 상태의 비용은 측정되지 않는다. 대신 비용은 바이트에 있다. 글 하나를 클릭하고 머무는 8초 동안 같은 오리진 트래픽이 310KB에서 575KB로 늘고, 그 대역폭 경합이 클릭 요청에 50ms 안팎으로 되돌아온다. 그중 대조군이 끝내 받지 않는 것은 글 HTML 55KB뿐이고, 썸네일 216KB는 독자가 스크롤하면 대조군도 받을 것을 워커가 미리 그리고 더 큰 변형으로 받아 두는 몫이다. 어느 쪽이든 하드 내비게이션 단위로 보고되는 web-vitals에는 잡히지 않는다. 재방문자에게 Cache Storage는 HTTP 캐시보다 빠르지 않고, 그래서 같은 기간 안의 sw_controlled FCP 대조는 대부분 재방문 자체의 효과로 보는 편이 맞다. 그리고 실사용자 데이터에서 reload 대조군은 16일에 6번의 방문이 전부이고 12건 중 8건이 확인하러 들어간 내 트래픽이라 기다려도 모이지 않으며, navigate 대조군은 신규와 재방문의 차이가 섞여 있어 표본이 늘어도 깨끗해지지 않는다.

확인하지 못한 것도 세 가지다. 우선 꼬리다. 엣지가 MISS일 때 103과 최종 헤더 사이는 3초에서 5초까지 벌어지는데, RUM에서 각 요청의 엣지 캐시 상태를 조회할 수단이 없어 2편의 평균 +525ms 가운데 이 성분이 얼마인지는 가르지 못했다. 다음으로 Safari다. navigate p50이 no 126ms 대 yes 172ms(각 81건과 80건)로 Chromium과 비슷한 격차를 보이는데, Safari가 103을 어떻게 다루고 그 값이 responseStart에 어떻게 들어가는지는 확인하지 못했다. 마지막으로 실기기다. 랩은 로컬 서버라 CDN 경로가 없고, 실사용자 기기의 Cache Storage는 디스크 상태와 용량 압박을 겪으며, CPU 스로틀은 느린 기기의 메모리와 스토리지 지연까지 흉내 내지는 않는다. 그 환경을 M5 한 대로 재현하지 못한 것은 여전히 이 측정의 한계다.

그래서 성능에는 도움이 되는가

앞에서 잰 것은 전부 비용이다. 그런데 이득 쪽 숫자는 시리즈 내내 여러 번 인용해 놓고 따져본 적이 없다. 제목을 "비용 실측"으로 붙인 탓인데, 이대로 닫으면 절반만 보여주는 글이 된다.

그중 가장 큰 숫자는 2편의 재방문자 FCP 평균 1,463ms에서 829ms, -43%다. 앞 절에서 갈라 보았듯 이 비교는 양쪽 다 재방문자로 한정한 값이라 캐시가 비어 있는 첫 방문이 어디에도 없고, 그래서 신규와 재방문의 차이로는 설명되지 않는다. 그런데 랩에서 Cache Storage와 HTTP 캐시의 FCP가 같았으니 메커니즘으로 설명할 길도 남지 않는다. 2편에서 그 표에 달아 둔 유보(컷 위치를 v3 배포 직전까지 늘리면 -43%가 -27%가 된다는 것과 워커가 건드릴 수 없는 CLS가 같은 기간에 19% 움직였다는 것)가 이제는 유보가 아니라 주된 설명에 가깝다. 정적 자산을 Cache Storage에서 꺼내 온 덕에 재방문자 FCP가 좋아졌다는 해석은 여기서 철회하는 편이 맞다.

그렇다고 이득이 없다고 닫기에는 걸리는 것이 있었다. 랩은 로컬 서버라 CDN 왕복이 없다. 실사용자에게는 Cache Storage가 엣지까지 가는 왕복을 지워 주는 이득이 있을 수 있는데 그 구조는 랩에서 잴 수 없다. 그래서 네 번째 함정에서 쓴 짝비교를 프로덕션 도메인에 다시 붙여 이번에는 FCP와 LCP를 같이 모았다. 회차마다 워커를 막은 조건과 허용한 조건을 번갈아 25쌍, 웜과 콜드 둘 다, 엣지 HIT인 회차만 집계했다.

구간지표워커 없음워커차이
웜TTFB12.544.4+31.9
웜FCP96132+36
웜LCP p50460200-260
콜드TTFB45.478.7+33.3
콜드FCP136132-4
콜드LCP p50596528-68

(단위 ms, p50, 조건당 25회.)

TTFB 두 줄은 앞에서 본 Early Hints 아티팩트이니 지연이 아니다. 읽을 것은 나머지 네 줄이고, 그중 웜 LCP가 예상 밖이었다. 랩에서는 워커를 켜고 끄는 것으로 LCP가 5ms도 움직이지 않았는데 프로덕션에서는 절반 아래로 내려간 것으로 보였다. 이 -260ms는 그대로 쓸 수 없는 값이다. 25쌍에서 나온 진짜 p50이지만, p50으로 말해도 되는 분포가 아니었다.

웜 LCP 원자료를 정렬해 늘어놓으면 보인다.

워커 없음  168 180 184 188 192 200 200 240 356 448 448 452 460 460 464 500 516 520 520 532 536 536 540 548 628
워커       168 172 172 176 180 180 184 188 188 192 192 196 200 200 204 208 220 224 224 224 232 232 240 348 452

두 조건 다 약 160에서 240 사이의 빠른 무리와 약 450에서 550 사이의 느린 무리로 갈리고, 그 사이에 있는 것은 356과 348 두 회차뿐이다. 조건이 바꾸는 것은 LCP 값이 아니라 느린 무리에 떨어질 확률이고, 그렇다면 중앙값은 그 확률이 절반을 넘느냐 아니냐에 따라 두 무리 사이를 건너뛴다. 실제로 워커 없음 25회의 p50 460ms를 같은 25개에서 부트스트랩으로 다시 뽑으면 95% 구간이 240에서 520이다. 한 데이터가 빠른 무리와 느린 무리 양쪽을 다 답으로 내놓는다. 그래서 웜 LCP는 느린 무리의 비율로 봐야 한다. 350ms를 경계로 세면 웜은 워커 없음 17/25에 워커 1/25이고, 콜드는 24/25와 25/25다. 콜드는 양쪽 다 느린 무리에 붙어 있으니 596 대 528은 같은 무리 안의 차이이고, 조건이 가르는 것은 웜뿐이다.

먼저 내 측정 설계를 의심했다. 이 설계는 워커 조건만 홈에서 controllerchange를 기다리느라 3.5초를 더 머문다. 두 조건의 체류 시간을 맞추고 20쌍을 다시 돌려도, 조건 실행 순서를 뒤집어 12쌍을 더 돌려도 방향이 그대로였으니 설계 탓은 아니었다(p50으로는 460 대 200과 524 대 200인데, 방금 본 이유로 이 값들도 느린 무리의 비율이 반영된 결과로만 봐야 한다).

원인을 찾으려고 리소스 타이밍을 전부 떠 봤다. LCP 요소는 전 회차 글 제목 h1이고 폰트는 두 조건 모두 50ms 안팎에 다 도착하니 폰트는 아니다. 차이는 한 군데에 몰려 있었다. 글 페이지를 열면 헤더 내비게이션과 시리즈 목록과 태그 링크를 향해 ?_rsc= 프리페치가 31건 나간다. 경로로는 23개이고 그중 8개가 두 번씩 나가는데, 두 번이 같은 URL은 아니다. _rsc는 프리페치 관련 요청 헤더 4개(next-router-prefetch, next-router-segment-prefetch, next-router-state-tree, next-url)를 이어 붙여 해시한 값이라, 같은 경로라도 그 조합이 달라지면 다른 URL이 된다. 31건의 URL은 전부 서로 다르다. 워커가 없으면 이 31건이 전부 네트워크로 나가 345,639바이트를 받는다. 그중 한 건이 82,761바이트다. 워커를 켜면 같은 자리가 0바이트로 찍힌다.

이 0바이트를 캐시 적중으로 읽으면 워커가 31건을 Cache Storage에서 응답한다는 결론이 되는데, sw.js를 열어 보면 그럴 수가 없다.

async function handleRSC(event) {
  const {request} = event
  // 프리페치는 캐시하지 않는다: 실제로 방문한 페이지만 저장한다
  const isVisit = !isPrefetchRequest(request)
  if (isVisit) {
    event.waitUntil(savePageHTML(request, event.clientId))
  }

  try {
    const response = await fetch(request) // 프리페치도 여기로 간다
    if (isVisit && response.ok) {
      event.waitUntil(putWithTrim(RSC_CACHE, request, response.clone()))
    }
    return response
  } catch {
    // 캐시 조회는 네트워크가 던졌을 때만 들어온다
    const cache = await caches.open(RSC_CACHE)
    // ...
  }
}

프리페치는 isVisit이 거짓이라 저장 대상에서 아예 빠지고, cache.match()는 fetch()가 던졌을 때만 들어가는 catch 안에 있다. 이 워커는 프리페치를 Cache Storage에서 응답한 적이 없고, 켜져 있어도 31건을 그대로 네트워크로 보낸다. 0바이트는 함정 절과 정적 자산 절에서 두 번 본 그것, 워커를 지난 응답의 transferSize가 0으로 찍히는 집계 아티팩트다.

데이터로도 확인된다. 세 조건의 원본을 다시 집계하면 이렇다.

조건프리페치 건수transferSize 합계마지막 프리페치 응답이 끝난 시각
워커 없음31345,6391,115ms
워커3101,142ms
워커 없음, 차단180898ms

워커 조건도 12회 전부 31건이 나갔고, 마지막 응답이 끝난 시각이 1,142ms 대 1,115ms로 사실상 같다. 캐시에서 꺼냈다면 나올 수 없는 값이다. 차단 조건의 0바이트는 또 다른 이유로 0인데, 막힌 요청도 transferSize가 0으로 찍히기 때문이다. 앞 절에서 프리페치 325건이 워커를 지나는 데 1ms밖에 안 들고 duration이 미경유와 같았던 것도 이제 다르게 보인다. 빨라서가 아니라 그냥 통과했기 때문이다.

그러면 이 31건은 왜 페이지를 열 때마다 다시 나가는가. 브라우저 캐시가 이걸 재사용하지 못하는 이유는 응답 헤더에 있다.

cache-control: public, max-age=0, must-revalidate
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-nextjs-stale-time: 300

실제 브라우저가 글 페이지를 열 때 나가는 프리페치 31건의 응답에서 받은 것이고, 31건 전부 같은 cache-control이며 x-nextjs-stale-time: 300은 그중 25건에 있다. 같은 URL에 curl을 던지면 private, no-store가 돌아오는데, vary에 걸린 요청 헤더 세트를 재현하지 못한 응답이라 여기서 쓸 값은 브라우저 쪽이다.

no-store는 없으니 브라우저 캐시는 이 응답을 저장은 한다. 다만 max-age=0, must-revalidate라 쓸 때마다 서버에 재검증 왕복을 한 번 해야 하고, 그 왕복이 남는 이상 프리페치의 의미는 대부분 사라진다. 게다가 vary에 걸린 4개 가운데 next-router-state-tree는 출발한 페이지마다 값이 달라진다. 같은 대상 경로라도 어디서 이동해 왔느냐에 따라 캐시 항목이 갈리니, 저장은 되는데 다시 맞을 일이 드물다. 이 응답을 재사용하는 캐시가 아주 없는 것은 아니다. x-nextjs-stale-time: 300은 App Router가 자체 라우터 캐시에 5분 동안 들고 있겠다는 뜻인데, 그 캐시는 페이지를 새로 열면 사라지는 메모리 캐시다. 디스크에 남는 것은 Cache Storage뿐인데, Cache Storage의 저장과 조회 알고리즘에는 cache-control도 x-nextjs-stale-time도 나오지 않는다. 조회에서 보는 것은 메서드와 URL, 그리고 ignoreVary를 켜지 않았다면 vary다. 따라서 Cache Storage에 넣어 두더라도 vary가 갈리는 문제는 그대로 따라온다.

여기까지는 상관이다. 이 시리즈에서 상관을 인과로 읽었다가 되돌린 것이 세 번이라 한 번 더 갈라야 했다. 앞의 세 조건에 하나를 더해 넷으로 만들고 조건당 30회로 다시 돌렸다. 워커 없이 프리페치 그대로, 배포된 워커 그대로, 배포된 워커에서 handleRSC만 프리페치도 저장하고 조회하는 변형으로 갈아끼운 것, 그리고 워커 없이 프리페치만 막은 것이다. 차단은 CDP의 Network.setBlockedURLs로 했다. Playwright의 route()는 앞의 함정에서 본 대로 HTTP 캐시를 꺼서 워커 없는 조건만 불리해지니 쓸 수 없었고, 대신 Network 도메인 자체는 네 조건 모두에서 켜 두어 도메인 활성화를 상수로 만들었다. 회차마다 조건 실행 순서를 돌렸다.

변형을 심는 데도 한 번 돌아갔다. route()로 /sw.js 응답을 바꿔치기하는 방법은 실측으로 폐기했다. Chrome은 내비게이션마다 워커 스크립트를 다시 받아 비교하는 soft update를 도는데, 그 요청이 Playwright route()로도 페이지 타깃 CDP Fetch로도 브라우저 타깃 CDP Fetch로도 가로챌 수 없어서 변형이 한두 번 만에 배포본으로 되돌아갔다. 대신 sw.js는 classic worker라 최상위 함수 선언이 워커 전역 객체의 프로퍼티가 된다. worker.evaluate()로 self.handleRSC만 갈아끼우면 두 워커 조건이 배포된 같은 파일을 그대로 돌리면서 그 함수 하나만 달라지고, route()를 아예 쓰지 않으니 캐시를 끄는 함정도 따라오지 않는다.

한 가지 더 맞춰 줘야 했다. _rsc URL은 출발 페이지에 따라 갈리므로, 변형이 캐시에서 응답하려면 같은 출발 페이지에서 같은 글로 한 번 다녀온 적이 있어야 한다. 그래서 네 조건 모두 글을 한 번 방문해 두고, HTTP 캐시와 RSC 캐시를 뺀 나머지 워커 캐시를 지운 뒤 두 번째 방문을 쟀다. 변형에게 가장 유리한 상황이라는 뜻이다.

조건FCP p50LCP p50느린 무리
워커 없음9644820/30, 67%
워커(배포본)10032014/30, 47%
워커(프리페치도 캐시)1041689/30, 30%
워커 없음, 프리페치 차단1282007/30, 23%

(단위 ms, 조건당 30회, 120회 전부 엣지 HIT. 두 무리의 경계는 앞과 같은 350ms이고, 324와 416 사이에는 아무 회차도 없다. LCP p50 열은 참고로만 두었다. 배포본의 320ms는 부트스트랩 95% 구간이 164에서 452라 같은 데이터가 두 무리를 다 답으로 낸다.)

변형이 정말로 캐시에서 응답했는지는 transferSize로 확인하지 않았다. 앞에서 본 집계 아티팩트 때문이다. 워커 안에 카운터를 두고 프리페치의 캐시 적중과 네트워크 fetch를 직접 셌다. 배포본은 적중 0에 미스 31에 네트워크 31이었고 변형은 적중 31에 미스 0에 네트워크 0이었으며, 30회 전부 그랬다. 리소스 타이밍으로도 확인된다. 프리페치 31건의 duration 합이 배포본 2,104ms 대 변형 30ms이고, 마지막 프리페치 응답이 끝난 시각이 1,224ms 대 863ms다.

읽을 것은 둘이다. 하나는 프리페치를 캐시에서 응답하면 아예 막은 것과 같은 자리에 도착한다는 것이다. 9/30과 7/30은 카이제곱으로 p = 0.77이라 구별되지 않는다. 프리페치 트래픽이 느린 무리를 만든다는 것은 워커 없음 20/30 대 차단 7/30에서 p = 0.002로 이미 갈렸고(차단 조건은 프리페치만 빼고 나머지가 워커 없는 조건과 같다), 변형은 그 트래픽을 네트워크에서 없애는 다른 방법도 같은 곳에 도착한다는 것을 보여준다.

다른 하나는 배포본의 자리다. 14/30은 워커 없음 20/30과 변형 9/30 사이에 있다. 배포본과 변형의 차이는 p = 0.29라 이 표본으로는 유의하지 않으니 크기를 말할 수 없고 방향만 남지만, 그 방향이 맞다면 이 워커는 얻을 수 있는 이득의 일부를 설계상 놓치고 있는 것이다. handleRSC가 프리페치를 저장도 조회도 하지 않기 때문인데, 그 가드는 2편에서 "읽지도 않은 글이 캐시에 쌓인다"는 이유로, 그리고 "오프라인에 저장됨" 표시의 의미가 망가진다는 이유로 일부러 넣은 것이다. 2편의 설계 결정 하나가 3편에서 성능 대가로 돌아온 셈이다.

남겨 둘 것이 세 가지 있다. 하나는 배포본이 프리페치를 한 건도 줄이지 않으면서 무엇을 해서 20/30을 14/30으로 만들었는지를 끝내 못 짚었다는 것이다. 바이트가 아닌 것은 확인됐고, 남는 추정은 요청들이 워커를 경유하면서 우선순위나 렌더와의 경합 양상이 달라진다는 것인데 리소스 타이밍으로는 가르지 못했다. 같은 표의 FCP 열이 그 추정의 방증으로 보이기는 한다. 느린 무리가 가장 잦은 조건의 FCP가 96ms로 4개 중 가장 좋고, 느린 무리가 가장 드문 두 조건은 104ms와 128ms로 오히려 나쁘다. 순수한 바이트 경합이라면 나오기 어려운 패턴이라 렌더 순서 문제로 보이지만, 이것도 추정이다.

둘은 느린 무리의 정체를 못 짚었다는 것이다. 의심할 만한 후보는 있었다. tailwind.css의 ::view-transition-old(root)와 ::view-transition-new(root)가 animation-duration: 0.3s이고 포스트 h1이 <ViewTransition>으로 감싸져 있으며, 관측된 계단이 약 310ms였다. 그래서 워커 유무와 뷰 트랜지션 유무를 2×2로 놓고 칸당 22회를 쟀다. startViewTransition을 스텁으로 갈아끼우면 스냅샷과 커밋 타이밍까지 함께 바뀌므로, 메커니즘은 그대로 두고 애니메이션 길이만 1ms로 덮었다. 조작은 걸렸다. 트랜지션은 네 칸 모두 세 번씩 그대로 일어났고 호출 간격 p50만 373ms에서 92ms로 줄었다. 그런데 느린 무리는 줄기는커녕 합산 34%에서 48%로 늘었다(p = 0.28이라 이 차이도 유의하지는 않다). 결정적인 것은 느린 무리의 위치다. 애니메이션을 끄면 세 번의 트랜지션이 300~400ms에 다 끝나는데 느린 무리의 LCP는 508~584ms이고, 켜면 마지막 호출이 830ms인데 느린 무리는 448~548ms다. 느린 무리는 트랜지션 일정과 무관하게 절대 시각 450~580ms에 고정돼 있었으니 이 후보는 기각했다.

그 2×2에서 얻은 단서가 하나 있다. 네 칸 전부에서 느린 무리 회차의 FCP가 오히려 빠르다(88~92ms 대 104~120ms). 두 무리를 가르는 것이 "느려서 늦다"가 아니라 첫 페인트에 h1이 들어갔느냐로 보인다는 뜻이다. 빠른 무리는 첫 페인트가 조금 늦되 h1을 포함해 FCP와 LCP가 거의 붙고, 느린 무리는 h1 앞에 다른 것이 먼저 그려져 FCP가 일찍 찍히고 h1은 두 번째 페인트로 밀린다. 그렇다면 남은 후보는 스트리밍과 하이드레이션 순서다. next.config.ts에 cacheComponents: true가 켜져 있고 포스트 라우트에 loading.tsx가 있으니 셸이 먼저 오고 본문이 나중에 붙는 구조이기는 하다. 다만 이 후보는 재 보지 않은 추정이다.

셋은 워커가 완전 차단만큼 깨끗하지는 않다는 것이다. 배포본에서도 30회 중 14회는 밀렸다. 요청을 그대로 내보내면서 앞당기는 것과 아예 요청하지 않는 것은 같지 않다.

31건이라는 수도 그대로 받아들일 수는 없다. 헤드리스 기본 뷰포트(1280x720)에서 뷰포트에 들어온 링크 수다. 화면이 작은 실사용자 기기에서는 처음에 더 적게 나가고 스크롤하면서 늘어난다. 크기를 이 숫자 그대로 가져갈 수는 없고 방향만 가져갈 수 있다.

앞에서 정산한 바이트 비용과 나란히 놓으면 부호가 한 방향이라는 것도 분명해진다. 랩에서 잰 것은 빈 프로필로 소프트 내비게이션을 한 번 하는 상황이었고, 거기서는 워커가 글 HTML과 썸네일 4개를 배경에서 더 받아 트래픽을 265KB 늘렸다. 방금 잰 것은 홈을 한 번 거친 뒤 하드 내비게이션을 하는 상황인데, 여기서는 워커가 프리페치 345KB를 줄이지도 늘리지도 않는다. 그대로 통과시킨다. 처음에는 이 두 장면을 부호가 반대인 수지로 정산하려고 했지만, 줄이는 쪽이 사라졌으니 정산할 것도 없다. 지금 설계대로라면 이 워커가 바이트에 하는 일은 늘리거나 같게 두거나 둘 중 하나다. 줄이는 쪽은 설계하기 나름이라는 것을 변형 조건에서 확인했다.

그러면 성능에는 도움이 되는가. 이 블로그에서는 된다. 웜 LCP가 느린 무리에 떨어지는 비율이 워커 없음 17/25에서 워커 1/25로 내려갔고, 체류 시간을 맞춘 20쌍과 순서를 뒤집은 12쌍, 그리고 다른 날 다시 돌린 30회짜리 네 조건(20/30 대 14/30)에서도 부호가 그대로였다. 크기는 실행마다 흔들리니 말할 수 없다. 관측으로 확실한 것은 방향까지다.

왜 되는지는 모른다. 2편에서 짚은 설명(정적 자산을 Cache Storage에 넣어 재방문 FCP를 줄인다)은 랩과 프로덕션 양쪽에서 철회했다. 그다음으로 붙여 본 설명(HTTP 캐시가 재검증 없이는 응답하지 못하는 RSC 프리페치를 워커가 대신 응답한다)은 코드와 카운터로 철회했다. 이 워커는 프리페치를 대신 응답하지 않는다. 대신 응답하는 변형이 더 내려간다는 것은 확인했지만, 배포본이 프리페치를 하나도 줄이지 않고도 내려가 있는 몫은 여전히 설명이 없다. 그 자리는 설명 없이 남겨 둔다.

그래서 1편에서 서비스 워커를 쓸 이유로 꼽은 세 가지 가운데 세 번째, "HTTP 캐시로는 표현할 수 없는 전략이 필요할 때"에 이 블로그가 해당하는지는 이제 반쯤 답이 있다. handleRSC를 프리페치도 저장하고 조회하도록 고친 변형은 프리페치 31건을 전부 캐시에서 응답했고, 느린 무리의 비율이 프리페치를 아예 차단한 조건과 구별되지 않는 곳까지 내려갔다. HTTP 캐시가 재검증 없이는 못 하는 일을 워커가 해낸 것이다. 다만 이 측정은 변형에게 가장 유리한 조건이었다. 같은 출발 페이지에서 같은 글로 두 번째 들어가는 상황이라 _rsc URL이 첫 방문과 같았기 때문이다. 실제 독자는 매번 다른 곳에서 들어오고 그때마다 next-router-state-tree가 달라지니, 적중률이 이만큼 나올지는 재지 않았다.

이어서 1편의 다른 문장 하나도 좁혀야 한다. "목표가 재방문 성능 하나라면 이 레이어는 답이 아닐 가능성이 높다"고 쓰면서 근거로 "HTTP 캐시가 이미 해주는 일을 코드로 다시 만드는 셈"이라고 했다. 앞부분은 이 블로그에서 확인됐다. 정적 자산에 관한 한 Cache Storage는 HTTP 캐시보다 빠르지 않았다. 흔들리는 것은 "이미 해주는 일"이라는 전제다. App Router의 RSC 프리페치처럼 HTTP 캐시가 원래 잘 못 해주는 일이 있다는 것은 헤더로 확인했고, 그 자리를 워커가 메울 수 있다는 것도 한 번은 확인했다. 확인하지 못한 것은 그 이득이 실제 이동 패턴에서도 남느냐다.

이 결론이 어디까지 유효한지도 밝혀 둬야 한다. 이 블로그는 CDN이 이미 빠르고(엣지 HIT에서 최종 헤더까지 46ms다), 정적 자산에 immutable 캐시 헤더가 제대로 박혀 있고, LCP 요소가 텍스트다. 세 조건이 다르면 답도 다르다. 특히 원본 서버가 느리거나 CDN이 없는 사이트라면 Cache Storage가 지우는 왕복의 크기 자체가 달라져서 정적 자산에서도 이득이 날 수 있는데, 이 시리즈는 그런 환경을 한 번도 재지 않았다. 여기서 얻은 것은 "서비스 워커는 성능에 쓸모없다"가 아니라 "이 조건에서는 이득이 났고, 그 이득이 어디서 오는지는 아직 못 짚었다"이다.

마지막으로 하나 더 밝혀 둔다. 이 워커의 진짜 쓸모인 오프라인은 이 시리즈에서 처음부터 한 번도 재지 않았다. 성능 지표는 네트워크가 있을 때 얼마나 빠른가를 재는 잣대이고, 이 기능을 넣은 이유는 네트워크가 없을 때도 열리게 하는 것이었다. 세 편에 걸쳐 재고 되돌리고 다시 잰 숫자들이 전부 이 기능의 목적과는 조금 어긋난 잣대를 대고 있었던 셈이다. 그래도 무의미하지는 않다고 본다. 오프라인이 필요해서 넣는 것이라면 성능은 도입의 이유가 아니라 함께 지불하거나 돌려받는 값이고, 그 값이 어느 쪽으로 얼마인지는 알고 넣는 편이 낫다.

언제, 어떻게 쓰면 되는가

1편에서 도입 조건 세 가지를 꼽았을 때 그것은 전부 일반론이었다. 세 편을 재고 나니 그중 하나에는 적어도 진단법을 붙일 수 있게 됐다. HTTP 캐시로 표현할 수 없는 전략이 필요한 경우인데, 자기 사이트에 그런 요청이 있는지는 응답 헤더를 열어 보면 알 수 있다. 개발자 도구에서 페이지마다 반복해서 나가는 요청을 골라 두 줄을 본다. 먼저 cache-control이다. no-store면 브라우저 캐시는 그 응답을 저장하지도 않고, no-cache나 max-age=0, must-revalidate면 저장은 하되 쓸 때마다 서버에 다시 물어야 해서 왕복이 남는다. 다음은 vary다. 요청마다 값이 달라지는 헤더가 거기 걸려 있으면 저장된 항목에 다시 맞을 일이 드물어진다. 어느 쪽이든 그런 요청이 한 페이지에 수십 건씩 나간다면 이 블로그가 만난 것과 같은 상황이다. App Router의 RSC 프리페치는 no-store가 아니라 재검증과 vary 쪽이었다. 프레임워크가 자체 프리페치를 많이 쏘는 스택일수록 이 조항에 해당할 가능성이 높다. 다만 진단이 곧 해법은 아니다. 그 요청들을 서비스 워커로 메울 수 있는지는 워커를 그렇게 만든 다음에 다시 재야 알 수 있다. 이 블로그에 배포된 워커는 프리페치를 저장하지 않으니 이 진단에 해당하지 않고, 저장하도록 바꾼 변형은 한 번 재 본 것이 전부다.

정리하면 이렇다.

상황이 시리즈의 답
오프라인이 실제 요구사항이다쓴다. 오프라인을 만들 다른 방법이 없다
브라우저 캐시가 재사용하지 못하는 요청이 페이지마다 수십 건씩 나간다재 보고 정한다. 이 블로그는 웜 LCP가 느린 무리에 떨어지는 비율이 내려갔지만, 배포본이 그 요청을 대신 응답한 결과는 아니었다
네트워크가 불안정한 사용자가 많다쓰되 network-first에 타임아웃 폴백을 먼저 붙인다. 없으면 연결이 느릴 때 무한 로딩이 된다
정적 자산의 재방문 성능만 목표다쓰지 않는다. 파일명 해시와 immutable이면 충분하고, 랩과 프로덕션 양쪽에서 Cache Storage가 더 빠르지 않았다

쓰기로 했다면 1편 마지막에 적어 둔 비용 줄이는 방법에 하나를 보탠다. 배경에서 무언가를 더 받는 설계라면 그 바이트를 먼저 계산한다. 이 워커는 글 하나를 클릭할 때마다 글 HTML과 썸네일 4개를 더 받았고, 그것은 어떤 웹 지표에도 잡히지 않았다.

2편 끝에 적어 둔 500ms로 돌아가면, 그 숫자에는 세 가지가 섞여 있었다. 평균을 끌어올린 꼬리, reload 대조군의 절반 이상을 차지한 내 트래픽, 그리고 p50에 남은 +35ms를 만든 103 Early Hints의 기준점 차이. 셋을 빼고 나면 이 블로그에서 워커 경유 지연은 중앙값에서 잡히지 않는다. 랩에서 2ms였고, 프로덕션에서도 최종 헤더 기준으로는 워커 쪽이 느리지 않았다. 꼬리에 남은 것이 워커 몫인지는 아직 모른다.

대신 비용은 다른 곳에 있었다. 글 하나를 클릭할 때마다 워커가 배경에서 265KB를 더 받고, 그 대역폭 경합이 클릭 요청에 50ms 안팎으로 되돌아온다. 어떤 웹 지표에도 잡히지 않는 비용이라 프록시에서 바이트를 세기 전에는 있는 줄도 몰랐다. 이득도 있었다. 프로덕션 웜 LCP가 느린 무리에 떨어지는 비율이 17/25에서 1/25로 내려갔다. 다만 2편에서 그 이득의 이유로 짚은 정적 자산 캐싱은 랩과 프로덕션 양쪽에서 부정됐고, 그다음 후보인 RSC 프리페치 응답은 배포본이 하지 않는 일이었다. 프리페치까지 캐시에서 응답하는 변형이 차단 조건만큼 내려간다는 것까지는 확인했고, 그 자리를 막고 있던 것이 2편에서 넣은 저장 가드였다. 이 시리즈에서 유일하게 코드 수정으로 이어지는 결론이 그것이다.

이 편의 대부분은 워커가 아니라 측정에 들어갔다. 대조군은 기다려도 생기지 않았고, 만든 대조군도 여러 번 틀렸으며(프록시가 헤더를 붙들고 있었고, 두 조건이 다른 순간을 재고 있었다), 평균 대신 고른 백분위수는 이봉분포의 두 무리 사이를 건너뛰었다. 2편 끝에 "전후를 비교할 실사용자 지표 수집부터 갖추는 것이 순서"라고 적었는데, 여기에 하나를 덧붙인다. 실사용자 지표가 대조군을 주지 않으면 대조군을 만드는 비용까지가 그 기능의 비용이고, 만든 대조군의 두 조건이 같은 것을 재고 있는지 확인하는 것까지가 측정이다.


Footnotes

  1. PerformanceResourceTiming: workerStart, Resource Timing. 스펙은 이 값을 Fetch의 final service worker start time으로만 정의한다. 기동 중인 경우와 이미 떠 있는 경우를 갈라 놓은 것은 MDN 문서 쪽인데, 서비스 워커가 기동될 때는 기동 직전의 시각을, 이미 실행 중이면 fetch 이벤트 디스패치 직전의 시각을 돌려주며 워커를 거치지 않으면 0이라고 적고 있다. PerformanceNavigationTiming은 이 인터페이스를 상속한다. ↩

  2. browserContext.route(), Playwright 문서. "Enabling routing disables http cache."라고 명시되어 있다. ↩

  3. PerformanceResourceTiming: responseStart, Resource Timing. "The responseStart getter steps are to return this's firstInterimResponseStart if it is not 0; Otherwise this's finalResponseHeadersStart." ↩

  4. Chrome 133 릴리스 노트, "Revert responseStart and introduce firstResponseHeadersStart". Chrome 115가 TTFB에 쓰이는 responseStart의 의미를 최종 헤더로 바꿨다가, 다른 브라우저 및 도구와 어긋나는 호환성 문제 때문에 133에서 되돌리고 최종 헤더용 속성을 따로 두었다고 밝히고 있다. ↩

  5. NavigationPreloadManager, MDN. 워커 기동과 병렬로 내비게이션 요청을 출발시키는 메커니즘과, 워커가 그 응답을 받는 FetchEvent.preloadResponse를 설명한다. ↩

← 이전 편서비스 워커 캐싱 적용기: App Router의 함정들과 GA4 실측

관련 글

  • ◆ Next.js의 현주소
    #nextjs#web-performance#react

    Next.js의 성능은 충분히 빠른가

    벤치마크가 말해주는 불편한 진실

    2026-03-21·42분
  • ◆ Next.js의 현주소
    #nextjs#edge-computing#serverless

    Next.js Edge Runtime의 흥망성쇠

    Edge Middleware 야 잘 살고 있니?

    2026-03-16·25분
  • #nextjs#web-performance#caching

    Next.js 캐싱 가이드

    Next.js App Router의 4가지 캐시 레이어 알아보긔

    2025-12-24·71분
  • ◆ 웹 서비스 성능 분석
    #web-performance#nextjs#backend

    웹 서비스 성능 분석 (3)

    관심 가져주셔서 감사합니다. 🙇🏻‍♂️

    2025-07-01·56분

새 글을 놓치고 싶지 않으시다면 RSS로 구독해 주세요.

RSS 구독 →

yceffort — 프론트엔드 엔지니어입니다. 발표·기술 자문·기고 문의는 이곳에서 받고 있습니다.

← Back to the blogIssue on GitHub →