본문으로 건너뛰기
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
◆ ESSAY

실행하지 않는 JavaScript를 10MiB까지 늘려봤다

avatar
yceffort
2026-09-16 · 54분
54min
2026year
KOoriginal
javascriptperformancev8frontend

Table of Contents

  • 같은 10MiB인데 준비 시간이 다섯 배 차이 났다
  • 버튼 하나를 두고, 뒤에서 불러오는 코드만 바꿨다
    • 세 가지로 채운 같은 바이트
    • 파일 안에는 이런 함수를 넣었다
    • 스크립트 로딩과 버튼 입력을 함께 기록했다
  • Codespaces 안에서 CPU와 네트워크를 제한했다
  • 바이트가 만든 비용과 코드 형태가 만든 비용
  • 미호출 함수의 추가 비용은 CPU를 4배 늦춰도 거의 늘지 않았다
    • 그 작업이 메인 스레드에 있지 않았다
    • 호출하지 않은 함수도 힙에는 남았다
  • 호출하는 순간 일이 메인 스레드로 옮겨온다
  • 1.7초짜리 긴 작업이 있는데 입력 지연은 6ms였다
  • 느린 회선에서는 코드 형태보다 바이트 수가 앞섰다
    • 4Mbps는 초당 4MB가 아니다
    • 전송하는 것은 압축된 바이트다
    • Mbps가 높아져도 모든 기다림이 함께 줄지는 않는다
  • 캐시가 있으면 기다림이 달라졌다
  • 실제 라이브러리에서도 같은 갈림길이 있었다
    • 먼저 번들에 남는지가 갈린다
    • 남으면 실행된다
    • 분기로 막으면 절반쯤 줄어든다
    • 서버 전용 라이브러리는 실행조차 못 했다
  • 결론
  • 재지 않은 것
  • 실험 코드와 원본 데이터

같은 10MiB인데 준비 시간이 다섯 배 차이 났다

CPU를 4배 늦춘 브라우저에 10MiB짜리 JavaScript 파일을 넣었더니 스크립트가 준비될 때까지 432.4ms가 걸렸다. 파일 안의 함수는 한 번도 호출하지 않았다. 같은 10MiB를 선언 직후 전부 호출하도록 바꾸자 2,254.8ms가 됐다. 내려받는 바이트는 같고 코드가 하는 일만 달랐는데 다섯 배가 벌어졌다.

그러면 앞의 432.4ms는 전부 호출하지 않는 함수 때문일까. 그렇지 않았다. 절반가량은 파일이 10MiB라서 드는 값이고, 브라우저가 함수 선언으로 처리하느라 늘어난 몫은 나머지 절반이다. 양쪽을 갈라 보려고 같은 바이트를 주석으로만 채운 파일을 대조군으로 함께 쟀다.

트리 셰이킹이 잘되는 라이브러리를 쓰라는 이야기를 자주 한다. 필요한 기능만 가져오고 쓰지 않는 코드는 번들에서 빼라는 말이다. 그런데 이 조언에는 성격이 다른 두 가지가 섞여 있다고 생각한다. 하나는 바이트가 줄어서 생기는 이득이고, 다른 하나는 코드가 줄어서 생기는 이득이다. 앞의 것은 어떤 바이트를 지우든 따라오고, 뒤의 것은 코드를 지워야 얻는다.

그래서 화면은 그대로 두고, 버튼이 쓰지 않는 파일만 128KiB에서 10MiB까지 늘려 봤다. 선언만 하고 호출하지 않는 함수, 선언 직후 전부 호출하는 함수, 그리고 두 비용을 분해하려고 같은 바이트를 주석으로만 채운 대조군까지 세 가지다.

측정해 보니 비용은 세 군데에 나뉘어 청구됐다. 바이트 자체가 만드는 전송 대기, 함수 선언을 훑느라 생기는 백그라운드 작업과 힙, 그리고 호출되는 순간 메인 스레드로 옮겨오는 컴파일과 실행이다. 이 셋은 사용자의 환경에 따라 전혀 다르게 커진다. 미호출 함수가 추가한 시간은 CPU를 4배 늦춰도 MiB당 22.1ms에서 21.4ms로 거의 변하지 않은 반면, 초기화 코드는 61.1ms에서 200.7ms로 3.3배가 됐다.

버튼 하나를 두고, 뒤에서 불러오는 코드만 바꿨다

실험 페이지는 버튼 하나가 있는 HTML이다. 버튼을 누르면 화면의 숫자가 하나 증가한다. 추가 JavaScript는 이 버튼의 동작에 관여하지 않는다. 페이지를 먼저 연 뒤 같은 출처의 외부 스크립트를 하나 추가하고, 그동안 버튼이 얼마나 늦게 반응하는지 측정했다.

특정 라이브러리를 교체하면 크기와 함께 의존성, 초기화, 화면 구조까지 바뀐다. 그래서 이번에는 미사용 코드를 직접 생성했다. 트리 셰이킹을 켜고 끈 실제 라이브러리 번들을 비교한 수치가 아니라, 쓰지 않는 코드가 최종 파일에 남아 전달된 상황을 단순하게 만든 실험이다. React나 다른 프레임워크는 넣지 않았다.

세 가지로 채운 같은 바이트

코드 형태5MiB 파일 이름파일 안에서 하는 일
주석comment-5120.js함수처럼 생긴 텍스트를 큰 주석으로 감쌌다
미호출 함수functions-5120.js함수를 선언하고 그대로 뒀다
최상위 초기화initialized-5120.js함수를 선언한 직후 호출하고 결과를 보관했다

주석 파일은 배포될 법한 코드가 아니라 계측을 위한 대조군이다. 여러 MiB의 주석을 실제로 내보내는 상황을 재현하려던 것이 아니다. 같은 양의 텍스트를 전달하되 함수 선언으로 처리하지 않는 경우가 필요했다. 압축 후 크기를 보면 이 역할에 잘 맞았다. 10MiB 기준으로 주석은 gzip 1,215,401바이트, 미호출 함수는 1,215,390바이트다. 차이가 11바이트이므로 회선에서 오가는 양은 사실상 같다고 봐도 된다.

다만 대조군이라고 비용이 0인 것은 아니다. 주석도 내려받아야 하고 브라우저는 그 소스를 훑는다. 카운터 초기화와 마지막 마커는 주석 밖에 뒀다.

주석을 지우는 일과 사용하지 않는 함수를 지우는 일은 난이도가 다르다. 후자는 코드가 실제로 쓰이는지와 부수 효과가 있는지를 판단해야 한다. esbuild의 설명도 이 판단을 돕는 sideEffects와 순수 함수 호출 주석을 별도로 다룬다. 이 글의 관심사는 그 판단에 실패해서 빌드 후에도 남아 브라우저로 전달된 함수다.

파일 안에는 이런 함수를 넣었다

다음은 생성한 파일의 첫 번째 함수다. 읽기 쉽게 줄바꿈과 공백을 넣었다. 실제 파일에서는 각 함수를 한 줄로 생성한다.

globalThis.__unusedCalls = 0
globalThis.__unusedData = []

function feature_000000(input) {
  globalThis.__unusedCalls++
  const scale = 1
  const label = 'feature_91b4d142823f7d20c5f08df6'
  const value = (input.value ?? 0) * scale

  return {
    id: 0,
    label,
    value,
    enabled: value > scale,
    tags: [label, 'group_0'],
  }
}

함수 안에는 숫자 계산, 문자열, 조건식, 객체와 배열 생성이 있다. 느리게 만들기 위한 대기 루프는 넣지 않았다. 함수 이름은 feature_000000, feature_000001처럼 증가시켰다. label은 번호의 SHA-256 앞 24자리로 만들고, scale, id, group 값도 번호에 따라 바꿨다. 같은 빈 함수를 복제하거나 공백만 채운 파일은 아니다.

미호출 조건에서는 이런 함수들을 선언만 하고 호출하지 않는다. 따라서 __unusedCalls는 0이어야 하고, __unusedData는 빈 배열로 남아야 한다. 객체와 배열을 만드는 코드가 함수 본문에 있어도 이 조건에서는 실행되지 않는다.

초기화 조건에서는 함수 선언 바로 뒤에 다음 호출문을 붙였다.

globalThis.__unusedData.push(feature_000000({value: 0}))

다음 함수는 {value: 1}로 호출하고, 입력값은 함수 번호를 131로 나눈 나머지로 정했다. 만들어진 객체는 __unusedData에 쌓이지만 버튼이나 화면에서 사용하지 않는다. 사용자가 기능을 이용하지 않아도 파일을 읽자마자 초기화가 실행되는 상황이다.

파일 이름의 숫자는 KiB다. functions-5120.js는 정확히 5MiB이며 함수 22,666개가 들어 있다. 호출문도 바이트를 차지하므로 initialized-5120.js에는 18,120개가 들어간다. 함수 수를 고정하지 않고 최종 파일의 바이트 수를 맞춘 비교다. 함수를 하나씩 추가하다 목표 크기를 넘기기 직전에 멈추고, 남은 바이트는 파일 끝의 짧은 주석으로 채웠다. 마지막에는 평가가 끝난 위치를 기록하는 performance.mark('payload-evaluated')를 넣었다. 공통 계측 코드와 이 마커도 목표 바이트 수에 포함된다.

번들러를 거치지 않고 생성한 파일을 그대로 제공했다. 트리 셰이킹으로 없어지지 않게 하기 위해서다. 브라우저가 받은 압축 해제 후 바이트 수가 생성한 파일과 일치하는지 검사했고, 미호출 조건에서는 본문 안의 호출 카운터가 매번 0인지 확인했다. 별도 커버리지 측정에서도 5MiB 파일의 함수 22,666개 가운데 호출된 함수는 0개였다.

여기서 실행하지 않았다는 말은 함수 본문을 호출하지 않았다는 뜻이다. 최상위 함수 선언을 처리하는 작업까지 없다는 뜻은 아니다.

스크립트 로딩과 버튼 입력을 함께 기록했다

한 번의 측정은 다음 순서로 진행했다.

  1. 새 브라우저 컨텍스트에서 같은 HTML을 열고 requestAnimationFrame 두 번을 기다렸다. 버튼의 입력 핸들러는 이때 이미 등록돼 있다.
  2. 메인 스레드 작업 시간과 JS 힙 사용량을 읽은 뒤, 외부 스크립트를 동적으로 추가했다.
  3. 스크립트가 준비되는 동안 브라우저 밖의 Node.js에서 CDP로 버튼 위치에 입력을 보냈다.
  4. 스크립트의 load 이벤트가 발생하면 준비 시각과 브라우저 지표를 기록했다. 로딩 중 보낸 입력의 처리를 확인하고 150ms를 기다린 뒤, 버튼을 세 번 더 눌렀다.
  5. 호출 수와 수신 바이트 수를 검사하고, 입력 시각과 긴 작업, 리소스 타이밍을 원본 데이터에 저장했다.

페이지에서 스크립트를 넣는 코드의 핵심은 다음과 같다. state는 페이지의 측정 기록 객체다. 아래는 상태 표시와 오류 처리를 생략한 부분이며, 전체 코드는 실험 서버 소스에 있다.

window.startBenchmark = (url) => {
  state.start = performance.now()
  state.done = new Promise((resolve) => {
    const script = document.createElement('script')
    script.src = url
    script.onload = () => {
      state.ready = performance.now()
      resolve()
    }
    document.head.append(script)
  })
  return state.start
}

state.ready - state.start가 이 글에서 말하는 준비 시간이다. type="module"을 지정하지 않은 classic script이며, HTML 파싱이나 페이지 이동 전체를 재는 실험은 아니다.

버튼에서는 pointerdown 이벤트의 타임스탬프와 핸들러 진입 시각을 기록했다. 실제 핸들러는 아래처럼 입력을 저장한 뒤 버튼 숫자를 증가시킨다.

document.querySelector('#counter').addEventListener('pointerdown', (event) => {
  state.clicks.push({
    timestamp: event.timeStamp,
    handledAt: performance.now(),
    inputDelayMs: performance.now() - event.timeStamp,
    isTrusted: event.isTrusted,
    phase: state.phase,
  })
  document.querySelector('#counter').textContent = 'Clicks: ' + ++count
})

입력은 페이지 안의 setTimeout이나 button.click()으로 만들지 않았다. 페이지의 메인 스레드가 바쁜 동안에도 입력을 보내기 위해, Playwright를 실행하는 Node.js에서 CDP의 Input.dispatchMouseEvent를 사용했다. 마우스 누름과 해제 이벤트에 전송 시각을 명시했고, 페이지에서 받은 이벤트의 isTrusted도 확인했다.

본 측정은 시작 응답을 받은 뒤 약 25ms와 100ms에 입력을 보냈다. 이후 별도 실험에서는 준비가 끝날 때까지 50ms 간격으로 보냈다. 고정 시점의 입력만으로 놓친 구간이 있었기 때문인데, 그 결과는 뒤에서 다룬다. 모든 시점은 실제 주입 시각을 함께 기록했으므로 타이머가 정확히 예정 시각에 실행됐다고 가정하지 않았다.

Codespaces 안에서 CPU와 네트워크를 제한했다

실험 코드 작성과 반복 실행에는 코딩 에이전트를 사용했다. 브라우저와 서버는 모두 GitHub Codespaces 안에서 실행했고, 로컬 Mac에서는 결과 집계와 그래프 생성을 했다. Codespaces의 포트를 로컬 브라우저에서 열어 측정한 것은 아니다.

측정일은 2026년 9월 16일이다. 2 vCPU, 메모리 8GB의 Ubuntu 24.04.4 VM에서 Chrome for Testing 153.0.8010.12를 headless로 실행했다. Playwright는 1.63.0, Node.js는 24.20.0이었다.

조건CPU파일 전달
제한 없음VM의 기본 성능VM 내부 통신, 압축 없음
CPU 감속CDP로 4배 감속VM 내부 통신, 압축 없음
CPU 감속과 느린 네트워크CDP로 4배 감속gzip, 다운로드 4Mbps, 지연 설정 80ms

이 환경의 약점은 미리 밝혀 두는 편이 좋겠다. vCPU가 2개뿐인 VM 안에서 브라우저와 서버, Playwright, 입력을 주입하는 Node.js가 함께 돌아간다. 거기에 CPU 4배 감속을 걸면 측정 대상과 측정 도구가 얼마 없는 코어를 나눠 쓰게 된다. 특히 입력 지연은 VM 밖이 아니라 같은 VM의 Node.js에서 보낸 시각을 기준으로 하므로, 호스트가 바쁘면 주입 자체가 밀릴 수 있다. 아래의 입력 지연 값은 실기기의 체감 지연으로 읽지 말고 이 환경에서 관측한 값으로 읽는 편이 좋겠다.

CPU 4배 감속도 이 VM의 성능에 상대적인 설정이며, 특정 휴대전화와 같도록 보정한 값은 아니다. 네트워크 제한이 없다고 하면 서버와 브라우저가 같은 Codespace 안에서 통신했다는 뜻이고, 전송과 리소스 처리 비용이 완전히 0이라는 뜻은 아니다. 파일과 gzip 결과는 미리 만들어 서버 메모리에 올려 뒀다.

파일 크기는 압축 전 기준으로 128KiB, 512KiB, 1MiB, 2MiB, 5MiB, 10MiB다. 1MiB는 1,048,576바이트다. 추가 함수가 없는 기준선에는 공통 계측 코드 101바이트만 넣었다. 세 환경에서 기준선을 포함한 57개 조건을 각각 15번, 총 855번 측정했다. 조건 순서는 매 반복마다 섞었고 브라우저 측정을 병렬로 돌리지는 않았다.

본 측정에서는 매번 새 브라우저 컨텍스트와 고유한 URL을 사용하고 HTTP 캐시를 껐다. 브라우저 프로세스까지 매번 재시작하지는 않았다. 표는 중앙값, 그래프의 음영은 가운데 50% 범위인 IQR이다. 재방문과 반복 입력, 내부 트레이스는 별도 실험으로 분리했다.

이 글에서 준비 시간은 외부 스크립트를 추가한 순간부터 그 스크립트의 load 이벤트까지다. 페이지 이동이나 첫 화면 표시를 재는 지표가 아니다. 버튼은 그전에 이미 보이고 동작한다.

바이트가 만든 비용과 코드 형태가 만든 비용

먼저 네트워크 제한 없이 파일 크기만 늘렸다. CPU 4배 감속 조건의 준비 시간 중앙값이다.

추가 파일 크기주석미호출 함수최상위 초기화
기준선 101B24.4ms24.4ms24.4ms
128KiB32.3ms35.8ms86.8ms
512KiB59.8ms78.6ms193.6ms
1MiB63.1ms123.2ms305.7ms
2MiB80.2ms169.2ms524.2ms
5MiB161.9ms289.1ms1,151.4ms
10MiB209.5ms432.4ms2,254.8ms

함수 본문을 한 번도 호출하지 않았는데 준비 시간은 늘었다. 여기까지는 예상한 결과다. 눈여겨볼 것은 같은 행의 주석 값이다. 10MiB에서 미호출 함수의 432.4ms 가운데 209.5ms는 같은 바이트의 주석에서도 나온다. 즉 절반가량은 "함수라서" 든 비용이 아니라 "10MiB라서" 든 비용이다.

작은 크기 구간에서는 이 차이를 진지하게 읽지 않는 편이 좋겠다. 128KiB 조건의 중앙값 95% 구간은 미호출 함수가 31.9ms에서 58.3ms, 주석이 24.7ms에서 35.5ms로 서로 겹친다. 512KiB에서도 아직 겹치고, 이 조건에서 두 형태의 구간이 완전히 갈라지는 것은 1MiB부터다.

회색이 주석, 파란색이 미호출 함수, 주황색이 최상위 초기화다. 각 패널은 세로축 범위가 다르므로 패널끼리 높이를 비교하지 않는 편이 좋다. 오른쪽 패널에서 회색과 파란색이 거의 붙어 있는 것이 이 그래프의 요점이다.

세 형태 모두 크기가 커질수록 완만한 직선에 가깝게 늘었다. 0에서 10MiB 구간의 단순 선형 회귀 R²는 0.945에서 0.9999 사이였고, 5MiB에서 10MiB로 갈 때 미호출 함수의 준비 시간은 CPU 감속 조건에서 1.50배, 초기화 코드는 1.96배였다. 적어도 이 구간에서는 크기가 커질수록 비용이 폭발적으로 뛰는 곡선을 관측하지 못했다. 관측 범위 밖이나 다른 기기에서도 선형이라는 증명은 아니고, 지수 모델을 통계적으로 기각한 것도 아니다.

미호출 함수의 추가 비용은 CPU를 4배 늦춰도 거의 늘지 않았다

각 형태의 기울기에서 주석의 기울기를 빼면, 코드 형태가 바이트 위에 얹은 시간만 남는다.

조건주석 (ms/MiB)미호출 함수그중 추가분최상위 초기화그중 추가분
제한 없음13.0735.1622.0974.1761.10
CPU 4배 감속18.3739.7521.38219.11200.75
CPU 감속과 느린 네트워크251.33263.3111.98434.84183.51

CPU를 4배 늦췄는데 미호출 함수의 추가분은 22.09ms에서 21.38ms로 오히려 소폭 줄었다. 반면 초기화 코드의 추가분은 61.10ms에서 200.75ms로 3.3배가 됐다.

코드 형태가 주석 대조군 위에 얹은 준비 시간ms/MiB
미호출 함수최상위 초기화
제한 없음
22.09
61.1+176.6%
CPU 4배 감속
21.38
200.75+839.0%
CPU 감속과 느린 네트워크
11.98
183.51+1431.8%

이 비교가 이번 실험에서 가장 하고 싶은 이야기다. "저사양 기기에서 더 비싸진다"는 설명은 미호출 선언이 아니라 초기화 코드에 해당한다. 미호출 선언이 더하는 시간은 CPU 성능에 거의 반응하지 않았다.

그 작업이 메인 스레드에 있지 않았다

이유를 확인하려고 5MiB 조건에서 별도 트레이스를 수집했다.

V8은 모든 함수 본문을 처음부터 같은 방식으로 처리하지 않는다. 나중에 필요할 함수의 본문은 가볍게 사전 파싱하고, 본격적인 파싱과 컴파일을 미룰 수 있다. 이때도 문법과 스코프에 필요한 정보를 확인해야 하므로 함수 본문을 통째로 건너뛰는 것은 아니다. V8의 lazy parsing 설명이 이 과정을 다룬다.

트레이스에는 그 설명대로 기록돼 있었다. 미호출 조건에서 V8.PreParse가 ThreadPoolForegroundWorker에 22,666회 기록됐고, 이는 파일에 넣은 함수 수와 정확히 같다. 같은 스레드의 v8.parseOnBackground는 실험 파일을 가리켰고 그 안의 파싱 구간은 242.8ms였다. 같은 크기의 주석 파일에서는 127.7ms였다. 한편 메인 스레드의 EvaluateScript는 미호출 함수가 31.8ms, 주석이 6.0ms였다.

함수 본문 호출은 0회였지만 함수들을 훑는 작업은 있었고, 그 상당 부분이 메인 스레드 밖에 기록됐다. 그래서 CPU 4배 감속이 준비 시간에 그대로 반영되지 않은 것으로 보인다. 감속은 메인 스레드에만 걸리는 설정이 아니지만, 전송과 백그라운드 처리가 겹쳐 진행되는 구간에서는 어느 한쪽이 느려져도 전체가 비례해서 늘지 않는다.

실제로 메인 스레드 작업 시간만 떼어 보면 감속이 거의 그대로 반영된다. 10MiB 미호출 함수의 메인 스레드 작업 시간은 제한 없는 조건에서 28.6ms, CPU 4배 감속에서 122.6ms로 4.3배였다. 같은 구간의 준비 시간은 358.5ms에서 432.4ms로 1.21배에 그쳤다. 준비 시간의 병목이 메인 스레드가 아니었다는 뜻이다.

여기서 트레이스의 시간은 참고용으로만 썼다. 세부 컴파일 이벤트를 켜면 계측 자체가 무거워진다. 5MiB 초기화 진단 실행의 준비 시간은 약 3,070ms로, 계측을 끈 본 측정 중앙값 1,151ms와 크게 달랐다. 트레이스와 커버리지 실행은 855회의 본 통계에 섞지 않았고, 이벤트 횟수는 그대로 쓰되 시간은 대표값으로 쓰지 않았다.

호출하지 않은 함수도 힙에는 남았다

메인 스레드를 오래 붙잡지 않는다고 비용이 없는 것은 아니다. CPU 4배 감속, 네트워크 제한 없는 5MiB 조건에서 준비 전후의 JSHeapUsedSize 차이를 보면 이렇다.

5MiB 파일JS 힙 증가소스 1바이트당
주석0.06MiB0.012B
미호출 함수4.52MiB0.90B
최상위 초기화13.28MiB2.66B

5MiB의 주석은 힙을 거의 늘리지 않았는데, 같은 5MiB를 함수 선언으로 채우면 4.52MiB가 늘었다. 함수 22,666개로 나누면 하나당 약 209바이트다. 10MiB 파일에서도 함수 45,286개에 8.90MiB로 하나당 약 206바이트가 나와 값이 일관됐다.

호출하지 않은 함수가 소스 크기와 비슷한 양의 힙을 차지한다는 뜻이다. 이 비용은 CPU 성능과 무관하게 모든 기기에 동일하게 붙고, 메모리가 빠듯한 기기일수록 불리해진다. 저사양 환경을 걱정한다면 미호출 함수에 관해서는 CPU보다 메모리를 근거로 삼는 편이 정확하다고 생각한다.

다만 이 값은 강제 GC 없이 읽은 차이이므로 계속 유지되는 메모리나 브라우저 전체 메모리를 뜻하지 않는다. 주석의 힙 증가가 작다는 사실로 "5MiB 소스를 담아 두는 메모리가 어디에도 없다"고 말할 수도 없다.

호출하는 순간 일이 메인 스레드로 옮겨온다

같은 5MiB, CPU 4배 감속, 네트워크 제한 없는 조건의 값이다.

코드 형태준비 시간메인 스레드 작업 시간긴 작업의 50ms 초과분 합계
주석161.9ms53.5ms0ms
미호출 함수289.1ms81.5ms0ms
최상위 초기화1,151.4ms915.3ms799ms

메인 스레드 작업 시간은 스크립트를 넣기 직전부터 준비 완료를 확인한 직후까지 CDP의 TaskDuration 증가량으로 구했다. 계측과 버튼 처리 등의 작업도 포함하며, 백그라운드 스레드의 작업은 포함하지 않는다. 순수한 JavaScript 파싱 시간을 뜻하지는 않는다.

미호출 함수는 289.1ms를 기다리는 동안 메인 스레드를 81.5ms 썼고, 50ms를 넘는 긴 작업의 초과분은 0이었다. 작업이 없었다는 뜻은 아니고, 개별 작업이 50ms를 넘기지 않았다는 뜻이다. 이 합계는 실험 구간에서 계산했으므로 Lighthouse의 TBT와는 구간이 다르다.

초기화 조건은 915.3ms로 미호출 조건의 약 11.2배다. 파일 크기를 늘리지 않고 코드가 하는 일만 바꿨는데 이만큼 차이가 났다.

트레이스를 보면 그 시간이 어디로 갔는지가 비교적 분명하다. 5MiB 초기화 조건의 메인 스레드 EvaluateScript는 2,043.3ms였고, 그 안에서 V8.CompileCode가 18,146회, 합계 1,671.9ms로 기록됐다. V8.ParseFunction은 18,144회에 447.2ms, V8.CompileIgnition은 18,151회에 310.0ms였다. 같은 파일의 미호출 조건에서 메인 스레드의 V8.ParseFunction은 22회뿐이었다.

미뤄 뒀던 컴파일이 호출되는 순간 한꺼번에 돌아온 셈이다. 사전 파싱은 백그라운드에서 끝났지만, 실제로 함수를 실행하려면 본문을 다시 파싱하고 바이트코드를 만들어야 하고 그 일은 메인 스레드에서 일어난다. V8의 컴파일 과정 설명도 이 지연 컴파일과 다운로드 중첩을 다룬다. 이벤트 횟수에는 계측 코드도 섞여 있으므로 전부 실험 함수와 일대일로 대응시키지는 않았다.

초기화 비용이 컴파일만은 아니다. 18,120번의 함수 실행과 그만큼의 객체 생성, 배열 보관도 여기 들어 있다. 힙이 13.28MiB 늘어난 것이 그 흔적이다. 그리고 이 조건의 함수 수는 18,120개로 미호출 파일의 22,666개보다 적다. 같은 함수에 호출만 더한 비교가 아니라, 같은 바이트 예산을 선언으로 채울 때와 선언과 호출과 객체 생성으로 채울 때의 차이다.

실제 라이브러리가 최상위에서 이만큼 일하는 경우는 드물다. 이 조건은 파일을 읽자마자 실행되는 코드가 늘어나면 비용이 어느 방향으로 움직이는지 보려고 극단까지 밀어 본 것에 가깝다. 다만 방향 자체는 규모와 무관하게 유효하다고 생각한다. 선언은 미룰 수 있고 실행은 미룰 수 없다.

1.7초짜리 긴 작업이 있는데 입력 지연은 6ms였다

처음에는 스크립트를 넣은 뒤 약 25ms와 100ms 시점에 입력을 보내도록 했다. 초기 로딩 중에 버튼이 얼마나 늦게 반응하는지 보려는 의도였다.

그런데 CPU 4배 감속, 10MiB 초기화 조건에서 약 25ms 시점의 입력 지연 중앙값은 6.2ms였다. 같은 조건의 가장 긴 작업은 중앙값 1,739ms였다. 이 두 숫자를 놓고 보면 입력 계측이 고장 난 것처럼 보인다.

문제는 입력 시점이었다. 로딩 초기에 입력을 한 번 보내면, 그 입력을 처리한 뒤에 시작하는 긴 작업은 놓칠 수 있다. 그 순간 빠르게 반응했다는 사실이 이후에도 계속 반응할 수 있었다는 뜻은 아니었다.

그래서 별도로 준비가 끝날 때까지 50ms 간격으로 입력을 보내는 실험을 했다. CPU 4배 감속과 네트워크 제한 없는 환경을 유지하고, 10개 조건을 각각 10번, 총 100번 측정했다.

코드 형태크기로딩 중 관측한 최대 입력 지연의 중앙값준비 완료 후 입력 지연 중앙값
주석5MiB7.8ms2.5ms
미호출 함수5MiB19.9ms2.5ms
최상위 초기화5MiB838.5ms2.8ms
주석10MiB8.3ms2.6ms
미호출 함수10MiB40.5ms2.6ms
최상위 초기화10MiB1,709.5ms2.9ms

로딩 중 값은 각 실행에서 관측한 최대 지연을 먼저 구하고, 그 최대값 10개의 중앙값을 낸 것이다. 준비 후 값은 실행마다 입력 세 번의 중앙값을 구한 뒤 실행 간 중앙값을 냈다. 집계 방식이 다르므로 두 열로 개선 배수를 계산할 수는 없다.

로딩 중 관측한 최대 입력 지연의 중앙값ms
5MiB10MiB
주석
7.8
8.3+6.4%
미호출 함수
19.9
40.5+103.5%
최상위 초기화
838.5
1,709.5+103.9%

파일을 두 배로 키웠을 때 주석 대조군은 6.4% 늘어나는 데 그쳤지만, 미호출 함수는 103.5%, 초기화 코드는 103.9% 늘었다. 바이트만 늘린 조건에서는 로딩 중 지연이 거의 그대로였고, 코드 형태가 들어간 두 조건에서는 크기에 비례해 늘었다. 10MiB 초기화 조건에서 로딩 중 관측한 최대 입력 지연의 중앙값은 약 1.7초였고, 약 25ms 시점의 입력 하나만으로는 보이지 않던 지연이 드러났다. 미호출 함수도 10MiB에서 40.5ms까지 밀린 입력이 관측됐지만, 같은 바이트의 주석이 8.3ms인 것과 비교하면 절대값 자체가 작다.

준비가 끝난 뒤에는 세 형태 모두 3ms 안팎이었다. 큰 파일을 한 번 로드했다는 이유만으로 이 간단한 버튼이 계속 1초씩 늦게 반응하지는 않았다.

여기서 얻을 것은 미호출 코드에 관한 사실보다 측정 방법에 관한 사실에 가깝다. 로딩 중 반응성을 볼 때 특정 시점의 클릭 한 번으로 재면, 긴 작업이 있어도 없는 것처럼 보이는 결과가 나올 수 있다.

느린 회선에서는 코드 형태보다 바이트 수가 앞섰다

CPU 4배 감속에 gzip, 다운로드 4Mbps, 지연 설정 80ms를 적용하면 순서가 달라진다.

코드 형태5MiB 준비 시간10MiB 준비 시간
주석1,351.2ms2,613.5ms
미호출 함수1,400.2ms2,732.2ms
최상위 초기화2,254.1ms4,445.0ms

미호출 함수 10MiB가 2,732.2ms인데, 같은 바이트의 주석도 2,613.5ms다. 함수라서 늘어난 몫은 118.7ms로 전체의 4.5%다. 앞의 loopback 조건에서 절반가량을 차지하던 차이가 여기서는 잡음에 가까워졌다. 회선이 느려지면 무엇을 보냈는지보다 얼마나 보냈는지가 시간을 결정한다.

이 시간을 읽으려면 파일 크기와 회선 속도의 단위부터 맞춰야 한다.

4Mbps는 초당 4MB가 아니다

Mbps는 초당 메가비트이고, MB/s는 초당 메가바이트다. 소문자 b와 대문자 B 사이에 8배 차이가 있다. 1바이트가 8비트이므로 이번에 설정한 4Mbps를 바이트로 환산하면 다음과 같다. NIST의 단위 정의에서도 비트와 바이트, 십진 접두어와 이진 접두어를 구분한다.

4Mbps = 4,000,000bit/s
      = 500,000B/s
      = 0.5MB/s
      ≈ 488.28KiB/s

100Mbps도 바이트로 바꾸면 초당 12.5MB다. 회선의 Mbps 숫자를 그대로 파일의 MB에 대입하면 전송 시간을 8배 짧게 계산하게 된다. 이번 측정 스크립트의 downloadThroughput에도 초당 바이트 수인 500000을 넣었다.

MB와 MiB에도 차이가 있다. 1MB는 1,000,000바이트, 1MiB는 1,048,576바이트다. 그래서 10MiB를 압축 없이 초당 500,000바이트로 보낸다는 단순 계산은 약 20.97초가 된다. 이 20.97초는 압축 없는 4Mbps 조건을 따로 측정한 결과가 아니라, 바이트 수를 속도로 나눈 계산값이다.

그런데 실제 준비 시간은 약 2.7초였다. 이유는 이번 네트워크 조건에서 10MiB 그대로를 전송하지 않았기 때문이다.

전송하는 것은 압축된 바이트다

미호출 함수 10MiB의 gzip 본문은 정확히 1,215,390바이트였다. 약 1.22MB, 이진 단위로는 약 1.16MiB다. 이 크기를 4Mbps로 보내는 본문 전송 시간은 단순 계산으로 약 2.43초가 된다.

1,215,390B ÷ 500,000B/s = 2.43078s

다음 표의 전송 계산은 지연이나 부가 처리를 제외한 값이다. 실제 요청 시간은 Resource Timing의 duration, 준비 시간은 스크립트를 추가한 시점부터 load까지이며 각각 15회 중앙값이다.

미호출 함수의 압축 전 크기gzip 본문 크기4Mbps 본문 전송 계산실제 요청 시간실제 준비 시간
5MiB608,575B1.217초1.303초1.400초
10MiB1,215,390B2.431초2.531초2.732초

4Mbps를 설정해도 본문 전송 계산과 실제 준비 시간이 같지는 않았다. 이번에는 지연 설정 80ms가 있었고 브라우저의 리소스 처리와 스크립트 처리도 있었다. 일부 작업은 전송과 겹친다. 따라서 표의 준비 시간에서 전송 계산을 뺀 나머지를 전부 파싱 시간이라고 부를 수는 없다.

압축 전후 크기를 확인할 때는 Resource Timing의 encodedBodySize와 decodedBodySize를 구분할 수 있다. 전자는 gzip 등의 압축을 풀기 전 본문 크기, 후자는 압축을 푼 뒤 본문 크기다. transferSize도 함께 기록할 수 있지만, 이를 회선에서 오간 모든 패킷의 정확한 바이트 수로 보기는 어렵다. Resource Timing 명세는 헤더의 실제 크기 대신 정해진 값을 더하는 방식과 캐시 처리를 정의한다.

이번 파일의 압축률은 다른 번들에 그대로 적용할 수 없다. 규칙적으로 생성한 함수라서 gzip이 잘 듣는 편이고, 10MiB가 1.16MiB로 줄어 11.6%까지 내려갔다. 실제 번들은 이보다 덜 줄어든다. 다운로드 비용을 계산하려면 해당 응답의 압축 후 바이트 수를 직접 확인하는 편이 정확하다.

Mbps가 높아져도 모든 기다림이 함께 줄지는 않는다

회선의 Mbps는 한 번의 JavaScript 요청이 처음부터 끝까지 유지하는 처리량을 보장하지 않는다. 실제 처리량은 네트워크 혼잡과 다른 전송의 영향을 받고, 응답을 기다리는 지연은 전송률과 다른 값이다. Cloudflare의 네트워크 설명도 대역폭, 실제 처리량, 지연을 별도로 구분한다.

이번처럼 서버와 브라우저가 같은 VM에 있는 실험은 인터넷상의 서버까지 연결하는 과정을 그대로 재현하지 않는다. 4Mbps와 80ms는 통제한 설정값이다. 실제 사용자의 회선 요금제나 속도 측정 결과만 보고 이 표와 같은 로딩 시간을 기대할 수는 없다.

빠른 다운로드가 초기화 작업까지 없애 주지도 않는다. 이번 5MiB 초기화 파일은 gzip 후 약 576.91KiB로, 미호출 함수의 594.31KiB보다 오히려 작았다. 그런데 느린 네트워크 조건의 준비 시간은 초기화 코드가 약 2.25초, 미호출 함수가 약 1.40초였다. 전송할 바이트가 더 적어도 실행할 일이 많으면 늦게 준비될 수 있었다.

압축은 내려받는 양을 줄인다. 브라우저는 압축을 푼 소스를 처리하며, 그 안의 초기화 코드는 여전히 실행한다. 그래서 압축 전 크기, 압축 후 전송량, 메인 스레드 작업 시간을 함께 볼 필요가 있다.

같은 조건에서 10MiB 미호출 함수의 메인 스레드 작업 시간은 186.9ms였다. 2.7초를 기다렸다는 말과 2.7초 동안 입력이 막혔다는 말은 다르다. 이 실험의 버튼은 추가 파일과 독립적이어서 먼저 동작했다. 실제 기능이 해당 스크립트를 기다려야 한다면, 메인 스레드가 비어 있는 동안에도 그 기능의 사용 가능 시점은 늦어진다.

캐시가 있으면 기다림이 달라졌다

재방문도 따로 측정했다. 같은 URL로 세 번 준비 방문한 뒤 네 번째 방문을 측정했고, 여섯 조건을 각각 10번 실행했다. 측정한 60회 모두 추가 스크립트의 Resource Timing transferSize가 0이었다.

CPU 4배 감속, 10MiB 미호출 함수의 준비 시간은 네트워크 제한 없는 조건에서 첫 측정 중앙값 432.4ms, 캐시를 허용한 재방문 중앙값 71.0ms였다. 느린 네트워크에서는 2,732.2ms에서 71.1ms로 줄었다. 첫 측정은 조건당 15회, 재방문은 10회다.

HTTP 캐시와 브라우저의 코드 재사용을 함께 허용한 결과이므로, 감소분 전체를 V8 코드 캐시만의 효과라고 분리할 수는 없다. V8의 코드 캐시 설명에서도 HTTP 응답을 재사용하는 것과 컴파일 결과를 재사용하는 것을 구분한다.

앞에서 본 입력 시점의 함정이 여기서 다시 나타났다. 네트워크 제한 없는 10MiB 미호출 조건에서 약 25ms 시점의 입력 지연은 첫 측정 5.6ms, 재방문 45.5ms였다. 스크립트 준비는 훨씬 빨라졌는데 입력 하나만 보면 더 느려진 것처럼 보인다. 로딩 과정의 작업 시점이 바뀌면 고정 시점의 입력이 겹치는 작업도 달라지므로, 이 숫자만으로 캐시가 반응성을 악화시켰다고 결론 내릴 수는 없었다.

실제 라이브러리에서도 같은 갈림길이 있었다

여기까지는 직접 만든 코드로 잰 값이다. 초기화 조건은 함수 18,120개를 선언 직후 전부 호출하는 극단이었고, 실제 라이브러리가 최상위에서 그만큼 일하는 경우는 드물다. 그래서 같은 축을 실제 라이브러리로 다시 재봤다.

이 절의 측정은 앞의 855회와 환경이 다르다. Codespaces가 아니라 로컬 macOS에서 잰 값이므로 앞 표와 같은 줄에 놓고 비교하지 않는다. 비교하는 대상은 이 절 안의 조건들끼리다.

버튼이 있는 페이지는 그대로 두고, 주입하는 파일만 esbuild 0.25.12로 번들한 실제 라이브러리로 바꿨다. minify와 format: 'iife'를 켜고 process.env.NODE_ENV를 production으로 고정했다. 라이브러리는 msw 2.12.4, lodash 4.17.21, moment 2.30.1, winston 3.19.0이다.

먼저 번들에 남는지가 갈린다

같은 라이브러리를 같은 방식으로 쓰는데, 분기 조건이 빌드 타임에 정해지는지 아닌지로 결과가 갈렸다.

// 번들러가 조건을 접을 수 있다. 블록 전체가 dead code가 된다.
if (process.env.API_MOCKING === 'enabled') {
  globalThis.__libResult = [
    http.get('/api/quote', () => HttpResponse.json({price: 1})),
  ]
}

// 번들러는 실행해 보기 전까지 값을 알 수 없다. msw 전체가 남는다.
if (globalThis.__API_MOCKING === 'enabled') {
  globalThis.__libResult = [
    http.get('/api/quote', () => HttpResponse.json({price: 1})),
  ]
}
조건번들 원본gzip기준선 대비
기준선332B226B0B
msw, 빌드 타임 분기332B226B0B
msw, 런타임 분기191,818B68,045B+191,486B
msw, 실제로 사용191,753B68,018B+191,421B
lodash, 쓰지 않음73,799B26,960B+73,467B
lodash, 사용73,824B26,966B+73,492B
moment, 쓰지 않음62,199B20,320B+61,867B
moment, 전체 로케일372,883B81,078B+372,551B
winston, 쓰지 않음161,969B44,419B+161,637B
라이브러리가 얹은 번들 바이트B
압축 전gzip 후
msw, 빌드 타임 분기
332
226-31.9%
msw, 런타임 분기
191,818
68,045-64.5%
lodash, 쓰지 않음
73,799
26,960-63.5%
moment, 쓰지 않음
62,199
20,320-67.3%
moment, 전체 로케일
372,883
81,078-78.3%
winston, 쓰지 않음
161,969
44,419-72.6%

빌드 타임 상수로 감싼 msw는 기준선과 바이트가 같다. 흔적도 남지 않았다. 런타임 값으로 감싸면 191,818바이트가 통째로 남는다. 코드가 하는 일은 똑같고 분기 조건만 다른데 압축 후로도 68KB가 갈렸다.

lodash와 moment는 import만 하고 쓰지 않아도 거의 전부 남았다. 쓰는 조건과의 차이가 각각 25바이트, 34바이트다. CommonJS 모듈이라 번들러가 부수 효과 없음을 확정하지 못하기 때문이다.

여기서 눈여겨볼 쌍이 있다. msw의 런타임 분기 조건과 실제 사용 조건은 바이트가 65만 차이 난다. 앞에서 합성 코드로 만들었던 "같은 바이트, 다른 코드 형태"가 실제 라이브러리에서 생긴 셈이다.

남으면 실행된다

번들에 남은 msw는 분기가 false라서 핸들러를 하나도 만들지 않는다. 그러면 선언만 남은 상태일까. 커버리지를 재보니 그렇지 않았다.

조건함수 수실행된 함수실행된 바이트비율
기준선82225B67.8%
msw, 런타임 분기32341136,177B71.0%
msw, 실제로 사용32449136,929B71.4%
lodash, 쓰지 않음6248520,485B27.8%
lodash, 사용6249020,926B28.3%
moment, 쓰지 않음3673715,632B25.1%
moment, 전체 로케일75444268,440B72.0%

실행된 바이트는 V8이 보고한 범위 중 실행 횟수가 0인 구간의 합집합을 전체에서 뺀 값이다. 함수가 아닌 데이터 리터럴은 실행된 쪽으로 계산되므로, 로케일 데이터가 많은 조건에서 비율이 높게 나온다.

쓰지 않아도 실행되는 함수 수개
쓰지 않음사용
msw
41
49+19.5%
lodash
85
90+5.9%
moment
37
68+83.8%

분기가 false인 msw에서 함수 41개가 실행됐다. 실제로 핸들러를 만드는 조건의 49개와 8개 차이다. lodash는 쓰지 않아도 85개, moment는 37개가 돌았다.

이것이 이 실험에서 가장 놓치기 쉬운 부분이라고 생각한다. import는 선언을 가져오는 문장이 아니라 모듈을 평가하는 문장이다. 분기는 그 뒤의 호출을 막을 뿐이고, 모듈 최상위에서 객체를 만들고 레지스트리에 등록하는 일은 이미 끝나 있다. 앞의 합성 실험에서 만든 "선언만 있고 최상위 실행이 없는 파일"은 실제 라이브러리에서 좀처럼 보기 어려운 형태였던 셈이다.

분기로 막으면 절반쯤 줄어든다

CPU 4배 감속에서 조건마다 15번씩 잰 중앙값이다.

조건번들 원본준비 시간메인 스레드 작업힙 증가
기준선332B2.5ms2.5ms0.04MiB
msw, 빌드 타임 분기332B3.3ms2.9ms0.04MiB
msw, 런타임 분기191,818B11.3ms9.8ms1.09MiB
msw, 실제로 사용191,753B22.8ms21.5ms1.12MiB
lodash, 쓰지 않음73,799B18.4ms17.6ms1.03MiB
lodash, 사용73,824B19.2ms19.1ms1.03MiB
moment, 쓰지 않음62,199B8.9ms8.0ms0.23MiB
moment, 전체 로케일372,883B26.7ms25.5ms1.21MiB
로딩 중 메인 스레드 작업 시간ms
CPU 제한 없음CPU 4배 감속
msw, 빌드 타임 분기
0.9
2.9+222.2%
msw, 런타임 분기
3.3
9.8+197.0%
msw, 실제로 사용
6
21.5+258.3%
lodash, 쓰지 않음
4.6
17.6+282.6%
moment, 전체 로케일
5.9
25.5+332.2%

빌드 타임 상수로 감싼 조건은 기준선과 사실상 같다. 번들에서 사라졌으니 당연한 결과지만, 나머지 행과 나란히 놓으면 이 한 줄이 무엇을 아꼈는지가 보인다.

런타임 분기로 감싼 msw는 메인 스레드를 9.8ms 썼다. 실제로 핸들러를 만드는 조건의 21.5ms보다는 적으니 분기가 절반 넘게 막아 준 셈이지만, 지워 버린 조건의 2.9ms와 비교하면 7ms를 더 낸다. 힙은 1.09MiB와 1.12MiB로 거의 같았다. 객체를 만드는 일이 분기 안쪽이 아니라 모듈 평가 단계에서 대부분 끝났다는 뜻으로 읽힌다.

lodash는 쓰는 조건과 쓰지 않는 조건이 17.6ms와 19.1ms로 붙어 있다. 함수 하나를 부르든 하나도 부르지 않든 비용이 거의 같았다.

크기 순서와 비용 순서가 어긋나는 것도 눈에 띈다. lodash는 73,799바이트로 msw 런타임 분기 조건의 38%인데 메인 스레드 작업은 17.6ms 대 9.8ms로 1.8배다. 같은 바이트라도 모듈 평가 단계에서 무엇을 하느냐에 따라 비용이 갈린다는 앞의 결론이 여기서도 같은 방향으로 나왔다.

바이트당으로 환산하면 이 조건들은 모두 앞 실험의 미호출 함수 기울기보다 위쪽에 있다. 다만 환경이 다르고 기준선 자체가 열 배 가까이 빠른 기기에서 잰 값이므로, 두 실험의 기울기를 같은 자리에 놓고 빼지는 않았다.

서버 전용 라이브러리는 실행조차 못 했다

winston은 성격이 다르다. 브라우저에서 쓸 수 없는 코드가 클라이언트 번들에 실린 경우다.

esbuild로 브라우저용 번들을 만들면 util, os, events 같은 Node 내장 모듈을 찾지 못해 26개 오류로 빌드가 멈춘다. 그래서 webpack이 클라이언트 빌드에서 하던 것처럼 Node 내장을 빈 모듈로 대체하고 최소한의 process 폴리필을 넣어 번들을 만들었다. 실제 빌드 설정과 똑같지는 않지만, 빌드가 통과해서 번들에 실려 나가는 상황은 이렇게 만들어진다.

그렇게 만든 번들은 161,969바이트, gzip 후 44,419바이트였다. 브라우저에서 이 파일을 평가하면 함수 231개가 실행되다가 util.inherits is not a function에서 멈춘다. 내려받고, 압축을 풀고, 파싱하고, 3분의 1가량을 실행한 뒤 오류로 끝난다. 그 대가로 얻는 기능은 없다.

이 조건은 평가가 중간에 끊겨 준비 시간과 메인 스레드 작업 시간이 성립하지 않으므로 위 표에서 뺐다. 그래도 비용의 성격은 분명하다. 앞에서 본 세 가지 층 가운데 전송 비용만 온전히 지불하고 나머지는 중간에 버려진다.

결론

실행하지 않는 코드에도 비용은 있었다. 다만 한 덩어리가 아니라 세 군데에서 따로 청구된다. 내려받는 시간, 브라우저가 소스를 훑는 시간, 그리고 import하는 순간 실행되는 코드가 메인 스레드를 붙잡는 시간이다. 처음에 트리 셰이킹 조언에는 바이트가 줄어서 생기는 이득과 코드가 줄어서 생기는 이득이 섞여 있다고 했는데, 재보니 앞쪽이 훨씬 컸다.

실무 크기로 옮기면 그 차이가 분명해진다. 빌드 결과에 호출되지 않는 코드 300KiB가 남았다고 하자. CPU 4배 감속에서 준비 시간에 더해지는 몫은 약 6ms, 힙은 약 270KiB다. 그런데 같은 300KiB가 gzip으로 90KiB까지 줄어 4Mbps 회선을 지나면 본문 전송에만 약 180ms가 든다. 전송이 처리보다 30배 비싸다. 기울기를 선형으로 연장한 추정이고 압축률 30%도 가정이지만, 자릿수의 방향은 이번 측정 전체에서 일관됐다.

그래서 번들에 남은 미사용 코드를 볼 때는 이 순서가 낫다고 생각한다.

먼저 지울 수 있는지 본다. 지워지면 세 가지 비용이 모두 0이 된다. 실제 라이브러리에서 가장 큰 차이를 만든 것도 코드 형태가 아니라 번들러가 그 코드를 지울 수 있느냐였다. 같은 msw인데 빌드 타임 상수로 감싸면 332바이트, 런타임 값으로 감싸면 191,818바이트다. 분기 조건 하나가 191KB를 갈랐다. 서버에서만 쓰는 라이브러리라면 판단은 더 단순하다. 클라이언트 번들에 들어간 winston은 161,969바이트를 싣고도 브라우저에서는 함수 231개를 돌다가 터졌다. 내려받는 값은 다 치르고 얻는 기능은 없다.

못 지우면 다운로드 비용은 그대로 낸다. 실행을 막아도 파일은 내려간다. 이 몫은 회선이 느린 사용자에게 가장 크게 붙고, 코드를 지우든 주석을 지우든 똑같이 줄어든다. 느린 회선에서 10MiB를 들어낸 2.7초 가운데 코드 형태 덕분에 줄어든 몫은 0.12초뿐이었다.

마지막으로 남은 코드가 import될 때 무엇을 하는지 본다. 선언만 하고 끝나면 MiB당 21ms로 싸고 저사양 기기에서도 거의 커지지 않는다. 그 일이 메인 스레드 밖에서 끝나기 때문이다. 문제는 그런 코드가 드물다는 점이다. lodash는 import만 해도 함수 85개, moment는 37개를 실행했다. 최상위에서 실제로 일하는 코드는 CPU 4배 감속에서 MiB당 200.7ms였고, 로딩 중 입력이 1.7초까지 밀린 것도 이 조건뿐이었다.

확인은 브라우저 커버리지로 한다. 쓰지도 않는데 함수 수십 개가 실행되고 있다면 이미 세 번째 비용을 치르는 중이다. 지울 수 있다면 분기 조건부터 빌드 타임에 정해지게 바꾸고, 남겨야 한다면 코드 분할로 필요한 시점까지 미룰 수 있다. 어느 쪽이든 효과는 압축 후 전송량과 메인 스레드 작업 시간, 해당 기능을 누를 때의 입력 지연으로 확인하면 된다.

재지 않은 것

여기까지의 수치가 어디까지 유효한지 적어 둔다.

  • 로딩 패턴은 하나다. classic script를 동적으로 추가하는 경우만 봤다. ESM 모듈 그래프, React hydration, 실제 서비스 번들 전체는 재지 않았다.
  • 브라우저는 Chromium 하나, 기기는 데스크톱 두 대뿐이다. Safari와 Firefox, 모바일 실기기는 없다. CPU 4배 감속도 각 기기에 상대적인 설정이라 특정 단말의 성능을 재현하지 않는다.
  • 두 실험은 환경이 다르다. 합성 파일은 Codespaces에서, 라이브러리는 로컬 macOS에서 쟀다. 기울기를 서로 빼거나 같은 표에 올리지 않았다.
  • 라이브러리 네 개는 표본이 아니다. esbuild 한 가지 설정으로만 번들했으므로 webpack이나 Rollup에서는 결과가 달라질 수 있고, winston 조건의 Node 내장 모듈 대체도 실제 빌드 설정을 그대로 옮긴 것이 아니다.
  • gzip의 효과는 분리해서 재지 않았다. loopback은 압축 없이, 느린 네트워크는 gzip으로 보냈으므로 두 조건의 차이에는 압축과 회선 제한이 같이 들어 있다. 20.97초는 계산값이다.
  • 준비가 끝난 뒤는 짧게만 봤다. 시간이 지나며 생기는 메모리 압박이나 GC, 복잡한 화면의 상호작용은 관찰하지 않았고 힙도 강제 GC 없이 읽은 차이다.
  • 합성 파일은 규칙적으로 생성한 함수다. 압축률과 파싱 특성이 실제 코드와 다를 수 있고, 선형에 가깝다는 관찰도 0에서 10MiB 구간에 한정된다.

입력 지연은 브라우저 밖에서 보낸 시각이 기준이라 호스트의 스케줄링이 섞여 있다. 이 글에서 입력 지연을 절대값보다 조건 간 차이로만 읽은 이유다.

실험 코드와 원본 데이터

실험 코드와 원본 데이터는 이 블로그의 GitHub 저장소에서 확인할 수 있다. 생성기, 서버, 측정 스크립트, 실행 환경, 본 측정 855회, 반복 입력 100회, 재방문 60회와 준비 방문 기록, 별도 트레이스와 커버리지를 함께 관리한다. 예비 측정은 본 통계와 분리돼 있다. 수치만 확인하려면 집계 CSV를, 이 글에 싣지 않은 조건까지 보려면 전체 집계표를 보면 된다.

저장소를 복제하거나 GitHub Codespaces로 연 뒤, 저장소 루트에서 다음과 같이 실행할 수 있다. 원래 환경을 따라가려면 Codespaces 안에서 실행한다. 이미 저장된 결과를 덮어쓰지 않도록 새 실행 이름을 사용한다.

cd experiments/javascript-size
npm ci --ignore-scripts --no-audit --no-fund
npx playwright install --with-deps chromium
node scripts/generate.mjs
node scripts/measure.mjs --run=my-run --repetitions=15

실제 라이브러리를 잰 두 번째 실험은 따로 두었다. 엔트리와 번들 생성, 측정과 커버리지 수집이 들어 있고, 조건별 바이트와 해시는 fixtures/manifest.json에, 270회 실행은 results/main/raw.jsonl에 있다.

캐시와 반복 입력 실험, 집계 명령은 실험 README에 있다. 극단값을 임의로 제거하지 않았고, 실행별 값과 입력 시각을 원본에 남겼다. 중앙값의 95% bootstrap 구간도 집계에 함께 들어 있다. 반복 입력 실험에서 로딩 중 표본이 없었던 조건은 0ms로 채우지 않고 null로 남겼다. 브라우저 버전과 실행 스크립트의 SHA-256도 함께 기록했다.

관련 글

  • ◆ 프론트엔드 개발자가 알아야 할 쿠버네티스 · 6편
    #nodejs#kubernetes#v8

    Node.js 파드는 왜 그 크기인가: NODE_OPTIONS부터 파드 수까지 직접 재본 사이징

    같은 워크로드에 GC 튜닝 플래그 한 줄을 붙였더니 peak RSS가 201MB에서 593MB로 뛰었다. 살아있는 데이터는 그대로였다. 왜 그게 당연한 결과인지 V8 New Space를 직접 측정해 역추적하고, 프론트엔드 개발자가 Node.js 파드를 사이징하는 세 축을 정리한다.

    2026-08-03·84분
  • ◆ 블로그 성능 개선하기 · 3편
    #performance#web-vitals#nextjs

    블로그를 고친 뒤 첫 화면을 다시 재봤다

    블로그 마이그레이션 뒤 수식 글 LCP가 5.6초로 늘어난 원인을 추적했다. 일반 웹 글꼴을 실제 코드에서 제거하고 다시 빌드하자 수식 글 LCP는 5,650ms에서 2,352ms, FCP는 1,514ms에서 758ms로 줄었다. LCP 대상은 본문 문단에서 배너로 바뀌었다. 수식 전용 글꼴은 쓰는 글리프만 남긴 서브셋으로 바꿔 334KiB에서 26KiB가 됐다. 최초 비교 48회, 원인 대조 15회, 수정 전후 24회, 서브셋 전후 8회의 결과를 기록했다.

    2026-09-14·44분
  • ◆ 블로그 성능 개선하기 · 2편
    #rust#wasm#markdown

    블로그의 마크다운 파이프라인을 Rust/WASM으로 옮기기

    블로그의 remark/rehype 체인을 Rust로 옮기고 WASM으로 빌드해 Next.js 서버에 붙였다. 파싱과 HAST 생성에 이어 Oniguruma 하이라이트, MathML 수식, 이미지 크기와 MDX 속성 처리까지 한 호출로 묶었다. 메모리 전달과 해제, 기존 글의 호환성, WASI와 바이너리 배포를 구성하며 얻은 것과 감수한 비용을 기록했다.

    2026-09-14·61분
  • ◆ 블로그 성능 개선하기 · 1편
    #stylex#tailwind#css

    Tailwind를 StyleX로 옮기며 다시 따져본 성능

    블로그의 Tailwind 4를 StyleX로 옮겼다. 유틸리티만 옮긴 중간 상태에서는 CSS와 FCP가 줄었지만 전체 전송량은 늘었다. 전환 범위를 넓히자 홈 FCP가 59% 느려졌고, 작성 방식을 고친 뒤에도 격차가 남았다. 마지막 개선은 KaTeX 조건부 로딩과 본문 CSS 분리에서 나왔다. CSS 크기와 전체 전송량, 페인트 지표를 따로 비교한 기록이다. 블로그 성능 개선하기 시리즈의 첫 편이다.

    2026-09-14·51분

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

RSS 구독 →

yceffort — 프론트엔드 엔지니어입니다.

← Back to the blogIssue on GitHub →