문서 읽는 데 46분 · H5

H-5: 번들 최적화 ① — 번들 안을 들여다봅니다

목차 45
전체 59강 중 55강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구 튜터입니다.

지난 시간 마지막에 Lighthouse가 우리에게 이런 말을 하고 있었죠.

텍스트
 unused-javascript      Est savings of 95 KiB

받아는 갔는데 안 쓰는 자바스크립트가 95 KiB 나 된다는 뜻이었는데, 그때 우리는 이 줄을 읽기만 하고 넘어갔어요. 오늘은 그 안으로 들어가 보려는데, 막상 들어가려니 문제가 하나 있습니다. 우리는 지금 번들 안에 뭐가 들었는지 모릅니다.

C-8에서 우리는 첫 화면에 받는 코드를 600 KB에서 500 KB로 줄였습니다. 회원가입 화면을 떼어냈죠. 그때 우리가 본 것은 딱 두 숫자였어요 — 줄이기 전 600, 줄인 뒤 500. 안에 무엇이 들어 있어서 600 인지는 한 번도 안 봤습니다.

그 사이에 앱은 계속 자랐습니다. 실시간 통로가 들어왔고, 오류 추적이 들어왔고, 지난 시간엔 속도 추적까지 켰어요. 지금은 683 kB입니다.

오늘은 이 683 kB를 열어봅니다. 열어보면 아마 예상과 다를 거예요.

텍스트
 오늘의 여정

 1. 그림을 켠다          683 kB 가 무엇으로 되어 있나
 2. 그 숫자를 믿으면      1,749,780 B 라는데 파일은 783,433 B
 3. 주 청크 안           우리가 쓴 코드는 6.8%
 4. 나눠진 청크          C-8 의 판단이 옳았다는 증거
 5. 95 KiB 를 다시 잰다   조건을 바꾸면 109 KiB

💡 오늘 수업의 핵심 — "번들의 크기는 빌드가 알려주지만, 그 안에 무엇이 들었는지는 따로 봐야 알 수 있습니다"


🎯 학습 목표

  • 번들 안을 그림으로 들여다보는 도구를 붙이고, 그 도구가 결과물을 바꾸지 않는다는 것을 직접 확인합니다.
  • 그 도구가 주는 숫자에서 무엇이 참이고 무엇이 거짓인지 가려 읽습니다.
  • 우리 앱의 번들이 실제로 무엇으로 채워져 있는지 비율로 파악하고, 지난 시간들의 결정이 남긴 흔적을 그 안에서 찾아냅니다.

Step 1: "번들 안을 처음 들여다봅니다"

지금 우리가 아는 것은 숫자 두 개뿐입니다

빌드를 한번 돌려볼게요.

Bash
npm run build
텍스트
dist/assets/index-DeAUX3ft.css      44.00 kB │ gzip:   8.86 kB
dist/assets/SignUpPage-BtzFAyfm.js  100.42 kB │ gzip:  30.42 kB
dist/assets/index-DyuRQYAk.js       683.01 kB │ gzip: 221.72 kB

빌드가 알려주는 것은 여기까지입니다. 파일이 몇 개고 각각 몇 바이트인지, 딱 그뿐이라 우리가 알고 싶은 것은 다른 거예요.

683 kB 안에 뭐가 들어 있지?

이 질문에 빌드는 답을 안 해줍니다. 그리고 이 질문에 답하지 못하면 "줄이자"는 말은 그냥 구호예요. 어디를 줄일지 모르니까요.

그림을 그려주는 도구가 있습니다

번들 안을 들여다보는 도구를 붙여봅시다.

Bash
npm install -D rollup-plugin-visualizer
텍스트
added 62 packages

62개가 딸려옵니다. 꽤 많죠. 이게 사용자에게 가는 건 아닌지 걱정되실 텐데, 그건 잠시 뒤에 직접 확인해볼 것이니 먼저 빌드 설정부터 끼워보겠습니다.

TypeScript
// apps/web-spa/vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
TypeScript
    sentryVitePlugin({ telemetry: false }),
    // 번들 안에 무엇이 들어 있는지 그림으로 그려준다.
    //
    // 평소 빌드에는 안 끼운다 — 1 MB 가 넘는 HTML 을 매번 만들 이유가 없다.
    // 볼 일이 있을 때만 ANALYZE=1 을 붙여 부른다 (npm run build:analyze).
    //
    // 이 플러그인은 결과물을 재기만 하고 바꾸지 않는다.
    // 붙인 빌드와 안 붙인 빌드의 배포본 파일 이름(해시)이 같은지 대조해보면 확인된다.
    process.env.ANALYZE
      ? visualizer({
          filename: 'scratch/h3b-treemap.html',
          template: 'treemap',
          gzipSize: true,
          brotliSize: true,
        })
      : null,

process.env.ANALYZE라는 조건을 하나 걸어뒀습니다. 이게 없으면 빌드할 때마다 1 MB가 넘는 HTML 파일이 하나씩 생기는데, 볼 일이 있을 때만 만들면 되니 부르는 이름을 따로 하나 만들어둡니다.

JSON
// apps/web-spa/package.json
"build": "tsc --noEmit && vite build",
"build:analyze": "ANALYZE=1 npm run build",

그림을 켜봅니다

Bash
npm run build:analyze

빌드가 끝나면 scratch/h3b-treemap.html이 생깁니다. 브라우저로 열어보세요. 네모들이 크기별로 깔린 그림이 나옵니다. 큰 네모일수록 번들에서 많은 자리를 차지하는 거예요. 마우스를 올리면 이름과 크기가 뜹니다.

이런 그림을 트리맵(treemap)이라고 부릅니다. 전체를 사각형 하나로 놓고, 그 안을 비율대로 쪼개어 채우는 방식이에요.

재는 도구가 결과물을 바꾸지는 않았을까요

여기서 한 번 멈춰야 합니다. 지난 시간에 우리는 속도를 재려고 추적을 켰다가 번들이 61 kB 늘어나는 것을 봤어요. 재는 행위가 재려는 대상을 바꾼 겁니다.

이 도구도 그럴까요? 확인하는 방법이 있습니다. 같은 코드를 두 번 빌드해서 결과물 파일 이름을 대조하는 거예요. 파일 이름 뒤에 붙은 저 글자들은 내용을 요약한 값이라, 내용이 한 글자라도 달라지면 같이 달라집니다.

텍스트
 평소 빌드    index-DyuRQYAk.js · SignUpPage-BtzFAyfm.js      그림 없음
 ANALYZE=1    index-DyuRQYAk.js · SignUpPage-BtzFAyfm.js      그림 1.3 MB

완전히 같습니다. 이 도구는 다 만들어진 결과물을 옆에서 구경하고 그림을 그릴 뿐, 결과물에는 손을 안 댑니다. 아까 딸려온 62개도 마찬가지예요. 그것들은 빌드하는 우리 컴퓨터에서만 돌고 사용자에게는 한 바이트도 안 갑니다.

⚠️ 이 대조를 할 때 주의할 것이 하나 있습니다. 반드시 같은 커밋에서 두 번 빌드하세요. 커밋을 하나 하면 그 번호가 번들에 들어가기 때문에, 코드를 한 글자도 안 고쳐도 파일 이름이 달라집니다. 커밋을 사이에 두고 대조하면 "도구가 결과물을 바꿨다"는 잘못된 결론이 나와요.

💡 한 줄 정리

번들 안을 보려면 도구를 따로 붙여야 하고, 그 도구는 볼 때만 켜면 되며, 이 도구는 결과물을 재기만 하지 바꾸지 않습니다 — 두 번 빌드해서 파일 이름을 대조하면 확인됩니다.

🙋 학생 질문 — "62개나 설치했는데 정말 사용자한테 안 가나요?"

안 가는데, 그 이유가 우리가 지금까지 배운 것과 이어져요.

D-3에서 우리는 'use client'가 그은 선이 import를 따라 내려간다는 것을 봤습니다. 번들에 실리는 기준은 "설치했느냐"가 아니라 "우리 코드가 그것을 import 하느냐"예요.

vite.config.ts는 빌드를 어떻게 할지 알려주는 설명서입니다. 우리 앱의 화면 코드가 아니에요. src/main.tsx에서 시작하는 import 사슬을 아무리 따라가도 vite.config.ts에는 못 닿으니, 번들러가 그것을 결과물에 담을 이유가 없어요.

C-4에서도 비슷한 것을 봤습니다. 데모 파일을 하나 만들어뒀는데 앱 어디서도 안 불러서 빌드 결과물이 해시까지 똑같았죠. eslint 는 우리가 쓴 모든 파일을 보고, 번들러는 import로 이어진 것만 봅니다.

그러니 devDependencies가 아무리 늘어나도 사용자가 받는 크기와는 별개예요. 늘어나는 것은 우리 컴퓨터의 디스크와 설치 시간입니다.


Step 2: "그 숫자를 그대로 옮기면 틀립니다"

그림은 예쁜데 옮겨 적기가 불편합니다

트리맵은 마우스를 올려야 하나씩 보입니다. 전체를 표로 한 번에 보고 싶을 때가 있어요. 그림 파일 안에는 그 숫자들이 데이터로 들어 있으니, 그걸 꺼내 읽는 짧은 도구를 하나 만들어둡시다.

JavaScript
// apps/web-spa/scratch/h3b-treemap-read.mjs
// ── 그림 파일에서 데이터 덩어리만 꺼낸다 ─────────────────────────────
const html = readFileSync(TREEMAP, 'utf8');
const start = html.indexOf('{', html.indexOf('const data = '));
Bash
node scratch/h3b-treemap-read.mjs

이 도구를 만들면서 확인해야 할 게 하나 생겼는데, 바로 여기서 나오는 숫자가 무슨 숫자인지입니다.

첫 번째 함정 — 압축 전 크기입니다

도구 맨 아래에 이런 줄을 넣어뒀습니다.

텍스트
  그림의 모듈 합계 (압축 전) : 1,749,780 B
  실제 배포본 파일 합계       : 783,433 B    2.23배

트리맵이 말하는 모듈들을 전부 더하면 1,749,780 바이트인데, 실제로 디스크에 있는 파일들을 더하면 783,433 바이트예요. 2.23배 차이납니다.

왜 그럴까요. 트리맵이 재는 것은 각 모듈이 번들에 들어갈 때의 코드 길이입니다. 그 뒤에 번들러가 하는 일이 아직 남아 있어요 — 변수 이름을 한 글자로 줄이고, 공백과 줄바꿈을 없애고, 안 쓰는 코드를 걷어냅니다. 그 과정을 거치고 나면 절반 이하로 줄어들죠.

그러니 트리맵에서 react-dom 459,831 B를 보고 그대로 옮기면 안 됩니다. 전체 파일이 683 kB 인데 한 꾸러미가 459 kB라는 말이 되어버려요. 앞뒤가 안 맞습니다.

두 번째 함정 — 압축 후 크기도 더하면 안 됩니다

"그럼 gzip 크기를 보면 되겠네요?"라고 생각하실 수 있습니다. 트리맵 설정에 gzipSize: true를 켜뒀으니 모듈마다 gzip 크기도 나오는데, 그것도 더하면 안 돼요.

텍스트
  그림의 모듈별 gzip 합계     : 494,418 B
  실제 배포본 gzip 합계       : 249,617 B    1.98배

또 2배 가까이 차이납니다. 이번엔 이유가 다릅니다.

gzip은 반복되는 것을 찾아서 줄이는 방식입니다. 긴 글에서 같은 단어가 여러 번 나오면 두 번째부터는 "앞에 나온 그것"이라고 짧게 적는데, 모듈을 하나씩 따로 압축하면 모듈 A와 모듈 B에 똑같이 들어 있는 표현을 각자 한 번씩 적게 됩니다. 한 파일로 합쳐서 압축하면 두 번째는 짧게 적을 수 있었을 텐데요.

텍스트
 따로 압축      A 안의 반복만 줄인다 + B 안의 반복만 줄인다
 합쳐서 압축    A 와 B 사이의 반복까지 줄인다    이쪽이 실제

결국 모듈별 gzip을 더한 값은 항상 실제보다 크게 나옵니다.

세 번째 함정 — 도구가 말하는 번들러가 우리 번들러가 아닙니다

도구 맨 아래 줄을 하나 더 봅시다.

텍스트
  그림이 말하는 번들러 : rollup 4.23.0
  실제 우리 번들러     : rolldown (package.json 의 vite 8 이 안에서 쓴다)

트리맵 데이터에는 번들러 버전이 적혀 있는데, 거기엔 rollup 4.23.0이라고 나오지만 우리가 쓰는 것은 Vite 8이고, Vite 8이 안에서 쓰는 번들러는 Rolldown이에요. 설치된 것을 확인해보면 1.1.5입니다.

거짓말을 하는 건 아닙니다. Rolldown은 Rollup의 플러그인들을 그대로 쓸 수 있게 만들어졌고, 그래서 플러그인이 "번들러 버전 알려줘"라고 물으면 "Rollup으로 치면 4.23.0 쯤 되는 것"이라고 답하도록 되어 있어요. 이 도구는 그 답을 그대로 받아 적은 겁니다.

학생이 이 줄을 보고 "우리 프로젝트는 rollup 4.23을 쓴다" 고 적으면 틀립니다. 도구가 말하는 것과 실제가 다를 수 있어요.

그럼 무엇을 믿나요

정리하면 이렇습니다.

텍스트
 트리맵에서 참인 것     비율 — 무엇이 무엇보다 몇 배 큰가
 트리맵에서 못 믿을 것   크기 — 실제 사용자가 받는 바이트 수
 크기를 알려면          빌드 출력의 파일 크기를 본다

트리맵은 지도입니다. 어디가 넓은지 알려주는 데는 정확하지만, 실제 거리를 재려면 다른 자를 써야 해요.

💡 한 줄 정리

트리맵의 숫자는 압축 전 값이고 모듈별 압축 크기는 더할 수 없습니다. 여기서 가져다 쓸 것은 비율이고, 실제 크기는 빌드 출력에서 가져옵니다.

🙋 학생 질문 — "그럼 꾸러미별로 압축 후 크기를 아는 방법은 아예 없나요?"

정확히 알 방법은 없는데, 그건 도구의 한계가 아니라 gzip이라는 방식 자체의 성질이에요.

압축된 파일 안에서 "이 부분은 react-dom 몫"이라고 딱 잘라 말할 수가 없습니다. 방금 본 것처럼 gzip은 파일 전체에 걸쳐 반복을 찾아 줄이거든요. react-dom이 쓴 표현을 react-router가 짧게 참조하고 있을 수도 있어요. 그 절약분은 누구 몫일까요? 나눌 방법이 없어서, 실무에서는 이렇게 씁니다.

  • 비율은 트리맵에서 — 무엇이 큰지 순서를 본다
  • 크기는 빌드 출력에서 — 전체가 몇 바이트인지 본다
  • 줄었는지는 전후 비교로 — 뭔가 고치고 다시 빌드해서 전체 크기가 얼마나 달라졌는지 본다

세 번째가 가장 정직한 방법입니다. "이 꾸러미를 빼면 얼마나 줄어드나"가 궁금하면, 추정하지 말고 실제로 빼서 빌드해보는 거예요. 그 숫자만이 확실합니다.


Step 3: "우리가 쓴 코드는 7% 도 안 됩니다"

주 청크를 열어봅니다

준비가 됐으니 이제 실제로 읽어봅시다. 도구를 돌리면 청크별로 표가 나옵니다. 먼저 큰 쪽부터요.

텍스트
=== assets/index-DyuRQYAk.js — 압축 전 1,547,670 B ===
   29.7%     459,831 B  react-dom
   13.0%     200,806 B  react-router
   12.2%     189,335 B  @sentry/core
    7.8%     121,214 B  @base-ui/react
    6.8%     105,848 B  우리 코드 
    6.5%     100,903 B  axios
    4.4%      67,806 B  @sentry/browser-utils
    4.2%      65,348 B  @sentry/browser
    4.2%      65,084 B  @tanstack/query-core
    3.6%      55,996 B  tailwind-merge
    2.3%      35,540 B  @stomp/stompjs

화살표가 붙은 줄을 보세요.

우리가 지금까지 쓴 모든 코드가 번들의 6.8% 입니다.

컴포넌트도, 훅도, 라우트도, 스토어도, API 계층도 전부 합쳐서요. 나머지 93% 는 우리가 설치한 라이브러리들입니다.

처음 이걸 보면 좀 허탈합니다. 몇 주 동안 그렇게 많이 썼는데 7% 도 안 된다니요. 그런데 이게 요즘 프론트엔드 앱의 보통 모습이고, 이 사실이 중요한 이유가 있어요.

번들을 줄이고 싶다면 우리 코드를 아무리 다듬어도 소용이 없습니다. 우리 코드를 절반으로 줄여도 전체는 3.4% 줄어들 뿐이에요. 줄일 것이 있다면 나머지 93% 쪽에 있습니다.

맨 위 두 줄은 못 건드립니다

react-dom이 29.7% 로 1위입니다. 이건 React로 화면을 그리는 일 자체의 값이에요. 이걸 줄이려면 React를 안 쓰는 수밖에 없는데, 그건 앱을 다시 만드는 것과 같습니다.

react-router 13.0% 도 비슷합니다. 주소로 화면을 나누는 일을 우리가 직접 짜면 저것보다 작게 만들 수는 있겠지만, C-1에서 봤듯이 뒤로 가기·주소 공유·중첩 껍데기까지 제대로 하려면 결국 비슷한 크기가 됩니다.

이 둘을 합치면 42.7% 예요. 번들의 절반 가까이가 "이 앱을 이렇게 만들기로 한 이상 어쩔 수 없는 것"입니다. 이걸 바닥이라고 부릅시다. 최적화 이야기를 할 때 바닥이 어디인지 아는 것이 중요해요. 바닥 아래로는 못 내려가니까요.

그런데 세 번째 줄이 눈에 띕니다

@sentry/core가 12.2% 입니다. 그리고 목록을 훑어보면 @sentry로 시작하는 것이 하나가 아니에요. 도구가 그걸 따로 세어줍니다.

텍스트
  (@sentry/* 합계 328,140 B = 21.2%)

번들의 5분의 1이 오류·속도 추적 도구입니다.

이게 어디서 왔는지 우리는 정확히 압니다. H-1에서 오류를 우리에게 오게 하려고 SDK를 들였고, 지난 시간에 속도까지 재려고 추적을 켰어요. 그때 번들이 61 kB 늘어나는 것을 보고 "이만한 값을 낼 만한가"를 이야기했었죠.

지금 보는 21.2% 는 그때 그 결정이 번들 안에 남긴 흔적입니다. 그때는 "61 kB가 늘었다"는 변화량으로만 봤는데, 지금은 전체에서 차지하는 몫으로 보고 있어요. 같은 사실을 다른 각도에서 보면 크기가 다르게 느껴집니다.

지금 이걸 어떻게 하자는 얘기는 아닙니다. 다만 번들을 줄여야 하는 상황이 오면, 바닥이 아니면서 가장 큰 덩어리가 여기라는 것은 알아둬야 해요.

모듈 하나가 3.6% 인 것도 있습니다

tailwind-merge가 55,996 B로 3.6% 인데, 이건 모듈 한 개예요. @sentry/core가 102개 모듈로 12.2% 인 것과 비교하면 밀도가 아주 높습니다.

E-6에서 우리가 cn()을 배울 때 만났던 그 도구예요. px-6p-4가 부딪히면 뒤엣것을 남기고 앞엣것을 지우는 일을 합니다. 그 판단을 하려면 Tailwind의 모든 클래스 이름과 그것들이 서로 어떤 관계인지를 알아야 하는데, 그 지식이 전부 코드 안에 표로 들어 있어서 파일 하나가 이만큼 큽니다.

💡 한 줄 정리

우리가 쓴 코드는 번들의 6.8% 이고 나머지는 라이브러리입니다. 그중 42.7% 는 React와 라우터라 못 줄이는 바닥이고, 21.2% 는 지난 두 시간에 우리가 직접 켠 추적 도구입니다.

🙋 학생 질문 — "react-dom이 30% 면 React를 버려야 한다는 뜻인가요?"

아닙니다. 그리고 이 질문이 최적화 이야기에서 가장 많이 어긋나는 곳이에요.

숫자가 크다는 것과 줄여야 한다는 것은 다른 말입니다. react-dom이 하는 일을 생각해보세요. 상태가 바뀌면 무엇이 달라졌는지 계산하고, 화면에서 그 부분만 고쳐 그리고, 이벤트를 붙이고, 중간에 터진 오류를 경계까지 올려보냅니다. B-2에서 우리가 useState 하나로 좋아요를 토글했을 때, 그 뒤에서 이 모든 일이 일어나고 있었어요.

이걸 직접 짠다고 생각해봅시다. 처음엔 훨씬 작겠지만, 화면이 늘어나고 상태가 얽히기 시작하면 결국 같은 문제들을 다시 풀게 되고 코드는 자라나며, 우리가 짠 것에는 몇 년간 수많은 앱이 밟아본 검증도 없습니다.

그래서 판단 기준은 "큰가"가 아니라 "그 크기만큼 일을 하는가"입니다.

  • react-dom 29.7% — 앱 전체가 이것 위에서 돕니다. 값을 합니다.
  • @sentry/* 21.2% — 오류와 속도를 알려줍니다. 값을 하는지는 팀이 판단할 문제예요.
  • 안 쓰는데 실려 있는 코드 — 이건 값을 안 합니다. 이게 진짜 줄일 대상이에요.

마지막 것이 바로 Lighthouse가 말하던 그 95 KiB입니다. Step 5에서 다시 만납니다.


Step 4: "나눠진 청크가 지난 판단을 확인해줍니다"

두 번째 표를 봅시다

도구는 청크마다 표를 냅니다. 두 번째 것도 보죠.

텍스트
=== assets/SignUpPage-BtzFAyfm.js — 압축 전 202,110 B ===
   60.1%     121,423 B  zod
   33.3%      67,253 B  react-hook-form
    4.4%       8,801 B  우리 코드 
    2.3%       4,633 B  @hookform/resolvers

이건 회원가입 화면 청크입니다. C-8에서 우리가 떼어낸 그것이에요.

숫자를 보세요. 이 청크의 93.4% 가 폼 도구와 검사 도구입니다. zodreact-hook-form@hookform/resolvers를 합치면 그렇게 돼요. 우리 코드는 4.4% 고요.

C-8에서 우리는 이걸 못 보고 결정했습니다

그때를 떠올려봅시다. 우리는 쪼갤지 말지를 세 가지로 물었어요.

텍스트
 하나. 그 화면을 여는 사람이 얼마나 되나
 둘.   그 화면만 쓰는 무거운 것이 있나
 셋.   잠깐 기다려도 되는 화면인가

회원가입은 셋 다 맞아떨어졌습니다. 처음 오는 사람만 보고, 폼 도구가 거기서만 쓰이고, 한참 입력할 화면이라 0.2초 기다려도 안 거슬리죠.

두 번째 질문 — "그 화면만 쓰는 무거운 것이 있나" — 에는 그때 어떻게 답했을까요? 추측했습니다. 폼 도구랑 검사 도구가 무거울 것 같으니 거기 있겠지 하고 짐작했고, 쪼갠 뒤에 전체가 100 kB 줄어든 것을 보고 나서야 "맞았네"라고 확인했어요.

지금은 다릅니다. 그 청크를 열어서 무엇이 들어갔는지 이름으로 보고 있어요. 93.4% 가 그 두 도구입니다. 추측이 맞았다는 게 아니라, 왜 맞았는지를 보고 있는 거예요.

이 표가 없으면 알 수 없는 것

한 가지 더 확인할 게 있습니다. 두 표를 나란히 놓고 zod를 찾아보세요.

텍스트
 주 청크         zod 없음
 회원가입 청크    zod 60.1%

zod가 주 청크에는 아예 없습니다. 이건 생각보다 중요한 확인이에요.

쪼갠다고 해서 항상 이렇게 깨끗하게 갈리는 건 아니거든요. 만약 다른 화면 어딘가에서 zod를 한 줄이라도 쓰고 있었다면, 번들러는 그걸 주 청크에도 넣어야 합니다. 그럼 회원가입 화면을 떼어냈는데도 zod는 첫 화면에 계속 실려 오는 거예요. 화면은 멀쩡하고 빌드도 성공하고, 아무도 안 알려줍니다.

우리는 그런 일이 안 일어났다는 것을 지금 눈으로 확인했습니다. 쪼갠 것이 정말로 떨어져 나갔는지는, 안을 들여다봐야만 알 수 있어요.

💡 한 줄 정리

떼어낸 청크의 93.4% 가 폼·검사 도구였습니다. C-8의 판단이 옳았다는 것이 이름으로 확인됐고, 더 중요하게는 그것들이 주 청크에 안 남았다는 것까지 확인됐습니다.

🙋 학생 질문 — "그럼 탐색이나 상세 화면도 쪼개면 더 좋지 않나요?"

C-8에서 이미 답을 냈던 질문인데, 지금은 근거를 하나 더 갖고 답할 수 있습니다.

그때 우리 결론은 이랬어요.

텍스트
 홈 (피드)       ✗   거의 모두가 첫 화면으로 봅니다
 회원가입        ○   소수만 보고, 무거운 것이 거기만 있습니다
 탐색            △   많이 보지만 무거운 게 없어 이득이 작습니다
 게시물 상세     ✗   피드에서 바로 넘어가는데 멈칫하면 거슬립니다

C-8 과제에서 상세 화면을 실제로 쪼개봤던 것 기억나시나요? 3.87 kB 줄었습니다. 그러고는 되돌렸어요. 가장 자주 오가는 길에 멈칫을 넣을 값이 아니었으니까요.

오늘 표가 그 판단에 근거를 하나 더 줍니다. 주 청크 목록을 다시 보세요. 상위권이 전부 react-dom·react-router·@sentry·axios 같은 모든 화면이 함께 쓰는 것들입니다. 어느 한 화면을 떼어내도 이것들은 주 청크에 그대로 남는데, 함께 쓰기 때문입니다.

쪼개서 이득을 보려면 "그 화면만 쓰는 무거운 것"이 있어야 하는데, 우리 앱에서 그런 경우는 회원가입 하나뿐이었던 겁니다. 그때는 이걸 결과로 확인했고, 지금은 이유로 보고 있어요.


Step 5: "그 95 KiB를 다시 재봅니다"

이제 처음 질문으로 돌아갑니다

번들 안을 봤으니 지난 시간의 그 줄로 돌아가봅시다.

텍스트
 unused-javascript      Est savings of 95 KiB

Lighthouse를 다시 돌려서 이 항목을 자세히 봅니다.

텍스트
 unused-javascript      Est savings of 95 KiB
   /assets/index-DyuRQYAk.js
     전송 크기   219,336 B
     안 쓰는 것   97,048 B   (44.3%)

주 청크를 받아왔는데 그중 44.3% 는 이번 방문에서 한 줄도 안 돌았다는 뜻입니다.

여기까지는 지난 시간에 본 것과 같아요. 그런데 오늘은 조건을 하나 바꿔서 다시 재보겠습니다.

연습용 서버를 끄고 재봅니다

우리 앱은 게시물을 :8090에서 가져오죠. 그 서버를 끄고 똑같이 Lighthouse를 돌려봅시다. 피드가 안 뜨는 상태가 되겠죠.

텍스트
 연습용 서버 켬     점수 79   안 쓰는 것 95 KiB (44.3%)   LCP 5.0s   CLS 0
 연습용 서버 끔     점수 94   안 쓰는 것 109 KiB (50.9%)  LCP 2.7s   CLS 0.001

각각 세 번씩 재서 세 번 다 같은 값이 나왔으니, 이건 흔들린 게 아니라 조건이 다르면 나오는 진짜 값이 다른 거예요.

두 줄을 나란히 보세요. 앱이 고장난 쪽이 이렇습니다.

  • 점수가 15점 더 좋습니다 (79 → 94)
  • 안 쓰는 코드는 14 KiB 더 많습니다 (95 → 109)

왜 이렇게 되나요

두 숫자 모두 설명이 됩니다.

점수가 좋아진 이유 — 우리 앱에서 가장 큰 그림은 피드 사진입니다. 서버가 없으면 사진이 아예 안 와서, 브라우저는 "가장 큰 것"으로 글자 몇 줄을 고르게 되고, 글자는 금방 그려지니 LCP가 5.0초에서 2.7초로 뚝 떨어집니다. 화면에 아무것도 안 나오는 앱이 가장 빠릅니다.

안 쓰는 코드가 늘어난 이유 — 피드가 안 뜨면 카드를 그리는 코드, 사진을 얹는 코드, 좋아요를 붙이는 코드가 전부 안 돕니다. 그것들은 번들에 실려 왔지만 이번 방문에서 한 줄도 안 돌았으니 "안 쓰는 것"으로 세어져요.

이게 왜 중요한가요

지난 시간에 우리는 CLS를 재면서 비슷한 것을 봤습니다. 사진이 안 오면 밀릴 것도 없어서 CLS가 0으로 나온다는 거요. 고쳐서 나온 0과 못 재서 나온 0은 글자가 완전히 같아서, 사진이 다 왔는지를 함께 찍기로 했습니다.

오늘 것은 같은 문제의 다른 얼굴입니다.

텍스트
 「안 쓰는 자바스크립트 95 KiB」

 번들의 성질처럼 들리지만          실은 그 방문의 성질이다
 파일이 같아도                     방문이 다르면 값이 다르다
 앱이 고장나면                     오히려 커진다

"안 쓰는 코드가 95 KiB"는 파일에 대한 사실이 아니라 그 한 번의 방문에 대한 사실입니다. 같은 파일을 다른 사람이 다른 화면에서 열면 다른 값이 나와요.

그래서 이 숫자를 팀에 공유할 때는 반드시 조건을 함께 적어야 합니다. "홈 화면을 로그인 안 한 상태로 열었을 때, 서버가 정상일 때" 처럼요. 조건 없는 숫자는 나중에 아무도 재현할 수 없습니다.

⚠️ 이 실험을 직접 해보실 때 주의할 게 하나 있습니다. 서버를 껐다고 생각했는데 안 꺼져 있는 경우가 많아요. 끄고 나서 주소를 한 번 열어보고 정말 응답이 없는지 확인한 다음에 재세요. 안 그러면 같은 조건을 두 번 재놓고 "차이가 없네"라고 결론 내리게 됩니다.

오늘 얻은 것과 아직 모르는 것

여기까지가 오늘입니다. 정리하면 이래요.

텍스트
 알게 된 것    번들이 무엇으로 채워져 있는지 (비율로)
              우리 코드는 6.8%, 바닥이 42.7%, 추적이 21.2%
              안 쓰는 것이 95 KiB 이고, 그 값은 조건에 매여 있다

 아직 모르는 것  그 95 KiB 가 「무엇」인지

트리맵은 실려 있는 것을 보여줍니다. Lighthouse는 안 쓰는 것이 얼마인지를 알려주지만, 둘을 겹쳐서 "안 쓰는 그것의 이름"을 알려주는 것은 아직 없습니다.

💡 한 줄 정리

안 쓰는 자바스크립트 95 KiB는 번들의 성질이 아니라 그 방문의 성질입니다. 앱을 고장 내면 점수는 좋아지고 안 쓰는 코드는 늘어나서, 이 숫자는 조건과 함께 적어야 합니다.

🙋 학생 질문 — "그럼 95와 109 중에 어느 쪽이 맞는 숫자인가요?"

둘 다 맞는데, 이 대답이 얄미워 보이지만 정말로 그래요.

Lighthouse는 거짓말을 하지 않았습니다. 두 번 다 실제로 브라우저를 띄우고, 실제로 어떤 코드가 돌았는지 세어서, 정확한 값을 냈어요. 다만 잰 대상이 달랐던 겁니다. 하나는 정상으로 도는 앱이고 하나는 데이터를 못 받는 앱이었죠.

그러니 질문을 바꿔야 합니다. "어느 숫자가 맞나"가 아니라 "내가 알고 싶은 것은 어느 쪽인가"로요.

우리가 알고 싶은 것은 "실제 사용자가 우리 앱을 정상적으로 쓸 때 얼마나 낭비되는가"이니, 답은 95 KiB예요. 서버를 끈 조건은 우리가 궁금해하는 상황이 아니니까요.

이건 지난 시간에 실험실 값과 현장 값을 이야기하면서 봤던 것과 같은 결입니다. 그때도 "같은 앱인데 조건이 다르면 다섯 배 차이난다, 어느 쪽도 거짓말이 아니다"라고 했죠. 재는 조건이 곧 질문이고, 질문을 정하지 않고 잰 숫자는 쓸 데가 없습니다.


마무리

오늘 배운 핵심 세 가지

💡 하나. 번들의 크기와 번들의 내용은 다른 도구로 봅니다. 빌드는 몇 바이트인지 알려주지만 안에 무엇이 들었는지는 안 알려줍니다. 트리맵을 붙여야 보이고, 그 도구는 결과물을 바꾸지 않아요 — 같은 커밋에서 두 번 빌드해 파일 이름을 대조하면 확인됩니다.

💡 둘. 도구가 주는 숫자는 가려 읽어야 합니다. 트리맵의 크기는 압축 전 값이라 실제의 2.23배이고, 모듈별 압축 크기는 더하면 안 되고, 번들러 이름조차 우리 것과 다르게 적혀 있었습니다. 여기서 가져다 쓸 것은 비율뿐이에요.

💡 셋. 우리가 쓴 코드는 번들의 6.8% 입니다. 42.7% 는 React와 라우터라는 바닥이고, 21.2% 는 지난 두 시간에 우리가 켠 추적 도구입니다. 번들을 줄이려면 우리 코드가 아니라 나머지 93% 를 봐야 해요.

다음 시간 예고

오늘 마지막에 이렇게 정리했었죠.

텍스트
 알게 된 것      안 쓰는 것이 95 KiB 다
 아직 모르는 것   그 95 KiB 가 무엇인지

다음 시간에는 그 이름을 알아냅니다. 배포본은 이름이 뭉개져 있어서 그대로는 못 읽지만, 우리에게는 H-2에서 만들어둔 지도가 있어요. 그걸로 되돌리면 안 쓰는 코드가 어느 파일에서 왔는지 이름으로 나오고, 우리가 쓴 파일 하나도 그 목록에 들어 있을 텐데, 거기서 조금 곤란한 것을 하나 만납니다. Lighthouse도 원래 그 내역을 보여줄 수 있는데, 우리 앱에서는 그 자리가 비어 있어요. 왜 비어 있는지, 그게 H-2에서 우리가 내린 결정과 무슨 상관인지 확인하게 됩니다.

마지막으로는 오늘 본 683 kB가 더 자라지 않게 지키는 방법을 만들어봅니다. 지금 빌드가 500 kB를 넘었다고 경고를 내고 있는데도 빌드는 멀쩡히 성공하고 있거든요. 경고는 알려줄 뿐 막아주지 않습니다.


과제

[구현] 압축 후 크기로 순위를 다시 매겨보세요

Step 2에서 모듈별 gzip 크기를 더하면 안 된다고 했습니다. 그런데 더하지 않고 순위를 매기는 데 쓰는 건 어떨까요?

scratch/h3b-treemap-read.mjs를 고쳐서, 압축 전 크기(renderedLength) 대신 압축 후 크기(gzipLength)로 정렬한 표를 뽑아보세요.

  • 상위 다섯 개의 순서가 바뀌나요? 바뀐다면 어느 꾸러미가 올라가고 어느 꾸러미가 내려가나요?
  • 순서가 바뀐 꾸러미가 있다면, 그 코드를 열어보고 왜 그런지 짐작해보세요. (힌트: 압축은 반복되는 것을 줄입니다. 어떤 코드가 유독 잘 줄어들까요?)
  • 이 표를 근거로 "이걸 줄이자"는 판단을 해도 될까요? 안 된다면 왜일까요?

⚠️ 마지막 질문이 이 과제의 핵심입니다. 순위가 더 정확해 보인다고 해서 그 숫자를 그대로 쓸 수 있다는 뜻은 아니에요.

[탐구] 모듈 한 개가 3.6% 인 이유

주 청크 표에서 tailwind-merge를 찾아보세요. 55,996 B로 3.6% 인데 모듈은 한 개입니다. 102개 모듈로 12.2% 를 차지하는 @sentry/core와 비교하면 밀도가 아주 달라요.

  • node_modules/tailwind-merge 안에서 실제로 번들에 들어간 파일을 찾아 열어보세요. 무엇이 그렇게 큰가요?
  • E-6에서 배운 cn()이 하는 일을 떠올려보면, 왜 이만한 크기가 필요한지 설명할 수 있나요?
  • 우리 앱이 cn()을 안 쓴다면 이걸 뺄 수 있을까요? 실제로 몇 군데에서 쓰고 있는지 세어보세요.

[구현] 추적 도구를 잠깐 빼보고 되돌리세요

Step 3에서 @sentry/*가 번들의 21.2% 라는 것을 봤습니다. 그럼 실제로 빼면 얼마나 줄어들까요? Step 2에서 "추정하지 말고 실제로 빼서 빌드해보라" 고 했으니 해봅시다.

src/main.tsxsrc/lib/monitoring.ts에서 Sentry를 걷어내고 빌드해서 전체 크기를 재본 다음, 반드시 되돌리세요.

  • 몇 kB 줄었나요? Step 3의 21.2% 에서 예상한 것과 비슷한가요, 다른가요? 다르다면 왜일까요?
  • 줄어든 만큼의 값으로 우리가 잃는 것은 무엇인가요? H-1과 지난 시간에 얻은 것을 구체적으로 적어보세요.
  • 여러분이 이 앱의 책임자라면 어떻게 하시겠어요? 정답은 없지만 근거는 있어야 합니다.

⚠️ 되돌린 뒤에 빌드를 한 번 더 돌려서 원래 크기로 돌아왔는지 확인하세요.

[탐구] 그림에 안 보이는 것

트리맵을 열어보면 자바스크립트 청크 두 개가 그려져 있습니다. 그런데 빌드 출력에는 이것 말고도 파일이 더 있었죠.

텍스트
dist/assets/index-DeAUX3ft.css                44.00 kB
dist/assets/geist-latin-wght-normal-*.woff2   29.40 kB
  • 이 파일들은 왜 트리맵에 안 나올까요? 도구 이름을 다시 읽어보면 힌트가 있습니다.
  • 44 kB 짜리 CSS 안에 무엇이 들어 있는지 알고 싶다면 어떻게 해야 할까요? E-1에서 배운 것을 떠올려보세요.
  • 사용자가 첫 화면에서 실제로 받는 바이트를 전부 더하면 얼마인가요? 자바스크립트만 보는 것과 얼마나 다른가요?

생각해볼 주제

1. 바닥이 어디인지 아는 것

오늘 우리는 react-domreact-router를 합친 42.7% 를 "바닥"이라고 불렀습니다. 이 앱을 이렇게 만들기로 한 이상 못 줄이는 부분이라는 뜻이었죠.

그런데 바닥이라는 말은 조건이 붙은 말입니다. "이렇게 만들기로 한 이상"이라는 조건이요.

성능 요구가 아주 빡빡한 프로젝트라면 그 조건 자체를 다시 물을 수도 있습니다. 반대로 대부분의 프로젝트에서는 그 질문을 하는 것 자체가 낭비고요. 이 갈림을 무엇으로 판단해야 할까요? 그리고 판단을 미루면 나중에 어떤 대가를 치르게 될까요?

2. 숫자가 사람을 움직이는 방향

Step 3에서 "우리 코드는 6.8%"라는 숫자를 봤을 때 어떤 기분이 드셨나요?

이 숫자를 팀에 공유한다고 해봅시다. 어떤 사람은 "그럼 우리 코드는 신경 쓸 필요 없네"라고 받아들일 수 있고, 어떤 사람은 "라이브러리를 줄여야겠다" 며 잘 돌아가는 것을 걷어내기 시작할 수도 있어요.

같은 숫자가 정반대의 행동을 부를 수 있습니다. 숫자를 공유할 때 무엇을 함께 줘야 이런 어긋남을 막을 수 있을까요? 오늘 우리가 그 숫자 옆에 붙여둔 것들을 떠올려보세요.

3. 고장난 앱이 더 빠릅니다

Step 5에서 확인한 것 중에 좀 불편한 게 있었습니다. 서버를 끄니까 점수가 79에서 94로 올랐어요. 화면에 아무것도 안 나오는 앱이 더 좋은 점수를 받은 겁니다.

이건 Lighthouse가 잘못 만들어진 걸까요? 아니면 우리가 점수를 잘못 쓰고 있는 걸까요?

한 걸음 더 가봅시다. 만약 팀의 목표가 "성능 점수 90점 이상"으로 정해져 있다면, 이 사실을 아는 개발자는 무엇을 할 수 있을까요? 그리고 그런 일이 안 일어나게 하려면 목표를 어떻게 세워야 할까요?

✅ 예시 답안정답 보기

과제 예시답안

🎯 [과제 1 예시답안] 압축 후 크기로 순위를 다시 매겨보세요

채점 포인트

항목 배점 확인
gzipLength로 정렬한 표를 실제로 뽑았는가 6 도구를 고쳐 돌린 결과가 있다
순위가 바뀐 꾸러미를 정확히 짚었는가 6 올라간 것 둘, 내려간 것 둘
왜 바뀌는지를 압축의 성질로 설명했는가 7 반복이 많은 코드가 잘 줄어든다
이 표를 근거로 판단하면 안 되는 이유를 댔는가 6 모듈별 압축 크기는 더할 수 없다
합계 25

풀이 예시

정렬 기준 한 줄만 바꾸면 됩니다.

JavaScript
// apps/web-spa/scratch/h3b-treemap-read.mjs
// 지금은 압축 전 크기 하나만 모으고 있다
  bag.set(key, (bag.get(key) ?? 0) + part.renderedLength);

// 정렬도 그 값으로 한다
  for (const [name, bytes] of [...bag].sort((a, b) => b[1] - a[1])) {

돌려보면 상위권 순서가 이렇게 갈립니다.

텍스트
 순위   압축 전 기준             압축 후 기준
  1     react-dom              react-dom
  2     react-router           @sentry/core      ⬆
  3     @sentry/core           react-router      ⬇
  4     @base-ui/react         우리 코드          ⬆
  5     우리 코드               @base-ui/react    ⬇

두 자리가 서로 바뀌었습니다. @sentry/corereact-router를 제치고 2위로 올라오고, 우리 코드가 @base-ui/react를 제치고 4위가 됩니다.

압축률을 같이 보면 이유가 보입니다.

텍스트
 react-dom        459,831 B  87,139 B   19.0%   가장 잘 줄어든다
 tailwind-merge    55,996 B  11,338 B   20.2%
 react-router     200,806 B  50,469 B   25.1%
 @sentry/core     189,335 B  67,933 B   35.9%
 우리 코드         105,848 B  44,008 B   41.6%   가장 안 줄어든다

react-dom은 5분의 1로 줄어드는데 우리 코드는 절반도 못 줄어듭니다. gzip은 반복되는 것을 찾아 줄이는 방식이니, 반복이 많은 코드일수록 잘 줄어듭니다.

react-dom이나 tailwind-merge는 비슷한 모양의 처리가 수백 번 반복되는 코드예요. 특히 tailwind-merge는 클래스 이름 표가 대부분이라 비슷한 문자열이 계속 나옵니다. 반면 우리 코드는 파일마다 다른 일을 합니다. 좋아요를 토글하는 코드와 쪽지를 보내는 코드는 안 닮았어요. 줄일 반복이 적어서, 몫 자체가 달라집니다.

텍스트
                  압축 전     압축 후
 우리 코드          6.84%     9.86%
 react-dom        29.71%    19.52%
 @sentry/*        21.20%    26.64%

압축 후로 보면 우리 코드의 몫이 1.4배가 됩니다. 사용자가 실제로 받는 것은 압축된 파일이니, 이쪽이 더 현실에 가깝다고 생각하기 쉽죠.

그런데 이 표를 근거로 판단하면 안 됩니다

여기가 이 과제의 핵심입니다. 순위는 참고할 수 있지만 숫자를 근거로 쓰면 안 돼요.

교안 Step 2에서 봤듯이 모듈별 gzip 크기는 각 모듈을 따로 압축해서 잰 값입니다. 실제로는 한 파일로 합쳐서 압축하니 모듈 사이의 반복까지 줄어들고, 그래서 따로 잰 값을 더하면 실제보다 훨씬 큽니다.

과제 3이 이걸 숫자로 증명해줍니다. @sentry/*를 실제로 빼봤을 때를 미리 보면 이렇습니다.

텍스트
 압축 전 비율로 예측    21.2%   실제 감소 21.6%    거의 맞았다
 압축 후 비율로 예측    26.6%   실제 감소 22.2%    4.4 포인트 과대 예측

압축 후 기준이 더 정확해 보였는데 오히려 더 크게 빗나갔습니다. 따로 압축한 값을 더하면서 실제로는 없었을 중복까지 각자 세었기 때문이에요.

자주 나오는 실수

압축 후 순위가 "더 정확한 표"라고 결론 내리는 경우가 많습니다. 정확한 것은 순서지 크기가 아니고, 정말로 얼마나 줄어드는지 알고 싶으면 추정하지 말고 실제로 빼서 빌드해봐야 합니다 — 그게 과제 3입니다.

💡 튜터의 한마디 — 도구가 주는 숫자마다 "이건 어떻게 잰 값이지?"를 한 번 물어보는 습관을 들이세요. 같은 도구 안에서도 어떤 칸은 믿을 수 있고 어떤 칸은 못 믿습니다. 오늘 우리가 본 것처럼요.

🎯 [과제 2 예시답안] 모듈 한 개가 3.6% 인 이유

채점 포인트

항목 배점 확인
번들에 들어간 실제 파일을 찾았는가 7 dist/bundle-mjs.mjs
무엇이 그렇게 큰지 내용으로 답했는가 6 클래스 이름과 그 관계의 표
cn()이 하는 일과 연결했는가 6 부딪히는 클래스를 판정하려면 표가 필요하다
실제로 몇 군데서 쓰는지 세어봤는가 6 정의 1 + 쓰는 곳 4
합계 25

풀이 예시

먼저 어느 파일이 번들에 들어갔는지 찾습니다. 꾸러미에는 보통 여러 형태가 함께 들어 있어요.

텍스트
 node_modules/tailwind-merge/dist/bundle-cjs.js    105,871 B
 node_modules/tailwind-merge/dist/bundle-mjs.mjs   105,606 B    이쪽이 들어갔다

우리 앱은 ESM으로 가져오니 bundle-mjs.mjs 쪽인데, 여기서 재미있는 게 하나 있어요.

텍스트
 디스크의 파일    105,606 B
 번들에 실린 것    55,996 B

절반 정도만 실렸습니다. 번들러가 우리가 실제로 쓰는 부분만 골라 담았기 때문이에요. 꾸러미 전체가 통째로 들어가는 게 아닙니다.

파일을 열어보면 3,378 줄인데, 대부분이 이런 목록입니다.

텍스트
'absolute'  'align-content'  'align-items'  'align-self'
'all-scroll'  'alpha'  'always'  ...

Tailwind의 클래스 이름들과, 그것들이 어느 갈래에 속하는지가 표로 들어 있습니다.

cn()과 연결하면

E-6에서 우리는 cn()이 이런 일을 하는 것을 봤습니다.

텍스트
 cn('px-6', 'p-4')     'p-4'          앞엣것을 지운다
 cn('p-4', 'px-6')     'p-4 px-6'     둘 다 남긴다

이 판단을 하려면 p-4px-6을 완전히 덮는다는 것을 알아야 합니다. 그러려면 모든 클래스가 어느 갈래에 속하고 어느 것이 어느 것을 포함하는지를 전부 알고 있어야 해요. 그 지식이 바로 저 표입니다.

즉 이 꾸러미의 크기는 낭비가 아니라 하는 일에 필요한 지식의 크기입니다. 교안 Step 3의 🙋 에서 이야기한 "큰가"가 아니라 "그 크기만큼 일을 하는가"의 좋은 예예요.

뺄 수 있을까요

세어보면 다섯 군데에 이름이 나오는데, 그중 하나는 cn()을 정의한 곳입니다.

텍스트
 src/lib/utils.ts                여기서 만든다
 src/components/ui/button.tsx    여기서부터 쓴다
 src/components/ui/card.tsx
 src/components/ui/dialog.tsx
 src/components/ui/avatar.tsx

쓰는 곳 넷이 전부 E-5에서 Shadcn을 들이면서 복사해온 컴포넌트입니다. 우리가 직접 만든 컴포넌트 중에는 이걸 쓰는 게 하나도 없어요.

빼려면 그 컴포넌트들을 전부 다시 써야 하고, 그러면 클래스가 부딪힐 때 뒤엣것이 이기게 하는 일을 우리가 직접 해야 합니다. 그 일을 제대로 하려면 결국 같은 표가 필요해요.

자주 나오는 실수

require.resolve('tailwind-merge')로 찾으면 bundle-cjs.js가 나옵니다. 그건 CommonJS 용이라 우리 번들에 들어간 파일이 아니에요. 어느 파일이 실제로 들어갔는지는 트리맵이 알려줍니다.

💡 튜터의 한마디 — 큰 꾸러미를 발견하면 "빼자"보다 "이게 뭘 하는 거지"를 먼저 물어보세요. 대개는 우리가 몰랐을 뿐 다 이유가 있고, 그 이유를 알고 나면 뺄지 말지가 저절로 정해집니다.

🎯 [과제 3 예시답안] 추적 도구를 잠깐 빼보고 되돌리세요

채점 포인트

항목 배점 확인
실제로 빼서 빌드하고 숫자를 쟀는가 7 683.01 → 535.55 kB
예측(21.2%)과 실제를 대조했는가 6 21.6% — 거의 맞았다
잃는 것을 구체적으로 적었는가 6 H-1·H-2·지난 시간에 얻은 것
되돌리고 원래 크기를 확인했는가 6 683.01 kB 복귀
합계 25

풀이 예시

src/lib/monitoring.ts를 같은 이름만 내보내는 껍데기로 바꾸고, src/main.tsx에서 Sentry를 걷어냅니다. 이렇게 하면 나머지 코드는 하나도 안 고쳐도 돼요.

TypeScript
// apps/web-spa/src/lib/monitoring.ts
// [실험] Sentry 를 걷어낸 판 — 재고 나면 되돌린다.
export function startMonitoring(): void {}
export function reportRouteError(_error: unknown): void {}

빌드하면 이렇게 나옵니다.

텍스트
 빼기 전    683.01 kB │ gzip 221.72 kB
 뺀 뒤      535.55 kB │ gzip 172.48 kB
 차이       -147.46 kB (-21.6%) │ gzip -49.24 kB (-22.2%)

예측과 실제

교안 Step 3에서 트리맵이 @sentry/*를 21.2%라고 했습니다. 실제로 빼보니 21.6% 줄었어요. 거의 맞았습니다.

이건 Step 2의 결론을 확인해줍니다. 트리맵의 크기는 못 믿지만 비율은 믿을 만하다는 것이요. 21.2% 라는 비율은 압축 전 값으로 계산한 건데도 실제 결과를 잘 맞혔습니다.

반대로 과제 1에서 본 압축 후 비율(26.6%)로 예측했다면 4.4 포인트 빗나갔을 겁니다. 따로 압축한 값을 더한 숫자라 실제보다 부풀려져 있으니까요.

잃는 것

147 kB는 적은 양이 아닌데, 그걸로 우리가 잃는 것을 하나씩 적어보면요.

  • H-1 — 화면이 터졌을 때 우리에게 오는 길. 이게 없으면 사용자가 오류 화면을 보고 그냥 나가고, 우리는 영영 모릅니다.
  • H-2 — 데이터를 못 가져와서 터진 경우까지 포함해서, 뭉개진 배포본을 원래 줄로 되돌려 읽는 것도 함께 사라집니다.
  • 지난 시간 — 실제 사용자들이 겪는 속도. 우리 노트북에서 잰 숫자만 남습니다.

중요한 게 하나 더 있는데, 이 셋은 앱이 잘 돌 때는 아무 값도 안 하는 것처럼 보입니다. 문제가 생겨야 값을 하기 때문에 "지금 아무 일도 안 일어나는데 147 kB 나 쓰네"라는 판단을 하기 쉽습니다.

그래서 어떻게 하나요

정답은 없지만 실무에서 흔한 갈래는 이렇습니다.

  • 그대로 둔다 — 사용자가 겪는 문제를 아는 것이 147 kB보다 비싸다고 보는 쪽. 대부분의 서비스가 여기입니다.
  • 필요한 것만 남긴다 — 오류 추적만 두고 속도 추적은 끄면 상당 부분이 줄어듭니다. 지난 시간에 켠 것이 61 kB 였죠.
  • 나중에 받는다 — 첫 화면에서는 안 받고, 화면이 다 뜬 뒤에 따로 받아오는 방법도 있습니다. C-8에서 회원가입 화면에 쓴 것과 같은 방식이에요.

세 번째가 얼핏 가장 좋아 보이지만 함정이 있습니다. 늦게 받으면 그 사이에 일어난 오류를 못 잡는 데다가, 첫 화면에서 터지는 오류가 가장 중요한 오류인 경우가 많습니다.

자주 나오는 실수

되돌리는 것을 잊는 경우가 가장 흔하니, 되돌린 뒤 빌드를 한 번 더 돌려서 683.01 kB로 돌아왔는지 꼭 확인하세요. ⚠️ 이때 파일 이름은 안 돌아올 수 있습니다 — 그 사이에 커밋을 했다면 이름이 달라집니다. 확인할 것은 이름이 아니라 크기예요.

💡 튜터의 한마디 — "빼면 얼마나 줄지"를 추정으로 말하는 사람과 실제로 재본 사람은 회의에서 무게가 다릅니다. 재는 데 3분 걸려요.

🎯 [과제 4 예시답안] 그림에 안 보이는 것

채점 포인트

항목 배점 확인
왜 CSS·폰트가 트리맵에 없는지 답했는가 7 이 도구는 JS 청크만 본다
CSS 안을 보는 다른 방법을 제시했는가 6 빌드된 CSS를 직접 읽는다
첫 화면 전체 바이트를 실제로 쟀는가 6 260,300 B
자바스크립트만 볼 때와의 차이를 짚었는가 6 사진이 훨씬 크다
합계 25

풀이 예시

도구 이름에 답이 있습니다. rollup-plugin-visualizer — 번들러 플러그인이에요. 번들러가 만드는 것은 자바스크립트 청크입니다.

  • CSS는 Tailwind 플러그인이 따로 모아서 만듭니다. 번들러의 모듈 그래프를 거치지 않아요.
  • 폰트는 아예 만들어지지도 않습니다. 이미 있는 파일이 그대로 복사되는 거예요.

그러니 이 도구가 안 보여주는 게 아니라 볼 수 있는 범위 밖입니다.

CSS 안을 보려면

E-1에서 배운 것을 떠올리면 됩니다. Tailwind는 우리 소스를 문자열로 훑어서 실제로 쓴 클래스만 CSS로 만들죠. 그러니 빌드된 CSS를 직접 열어보면 무엇이 들어갔는지 그대로 보이는데, 먼저 이것부터 만나게 됩니다.

Bash
wc -l dist/assets/index-*.css
텍스트
       2 dist/assets/index-DeAUX3ft.css

44 kB가 딱 두 줄입니다. 배포용 CSS는 줄바꿈을 전부 없앤 상태라, 줄 단위로 세는 명령은 여기서 아무 쓸모가 없어요. 규칙을 세려면 중괄호를 세야 합니다.

Bash
grep -o '{' dist/assets/index-*.css | wc -l          # 규칙 519개
grep -o '\.[a-zA-Z][a-zA-Z0-9_-]*' dist/assets/index-*.css | sort -u | wc -l   # 고유 클래스 218개

우리가 E-2에서 만든 토큰이 실제로 들어갔는지도 이렇게 확인됩니다.

Bash
grep -o -- '--color-[a-z-]*' dist/assets/index-*.css | sort -u
텍스트
--color-amber-  --color-black  --color-brand  --color-canvas  --color-danger
--color-danger-strong  --color-faint  ...

Tailwind가 기본으로 들고 있는 팔레트(amber·black)와 우리가 E-2에서 만든 이름(brand·canvas·danger·faint)이 섞여 나옵니다. 이름만 봐도 어느 쪽이 우리 것인지 구별되죠.

E-2에서 토큰을 선언만 하고 안 썼을 때 CSS 크기가 그대로였던 것도 같은 이유였어요. CSS는 쓴 만큼만 자랍니다.

첫 화면에서 실제로 받는 것

브라우저 개발자 도구의 네트워크 탭을 열고 새로고침하면 전부 나옵니다. 종류별로 모아보면 이래요.

텍스트
 우리 서버에서
   자바스크립트   220,011 B   (84.5%)
   글꼴            29,720 B   (11.4%)
   스타일           9,209 B    (3.5%)
   문서             1,203 B
   아이콘             157 B
   ────────────────────────
   합계           260,300 B

 바깥에서
   사진           668,561 B    우리 것의 2.6배

세 가지가 눈에 띕니다.

하나. 자바스크립트가 우리가 보내는 것의 84.5% 입니다. 그러니 오늘 우리가 자바스크립트만 들여다본 것은 틀린 선택이 아니었어요.

둘. 글꼴이 5개 중 1개만 옵니다. 빌드 출력에는 woff2가 5개 있는데 실제로는 geist-latin 하나만 내려받아요. 나머지는 키릴 문자나 베트남 문자용이고, 우리 화면에 그 글자가 없으니 브라우저가 아예 요청을 안 합니다.

셋. 사진이 668 kB입니다. 우리 서버에서 보내는 전부를 합친 것의 2.6배예요.

자바스크립트만 볼 때와의 차이

세 번째가 가장 중요합니다. 우리는 오늘 683 kB를 어떻게 줄일지 이야기했는데, 실제로 사용자의 회선을 채우는 것은 사진 쪽이 더 큽니다.

그렇다고 오늘 한 일이 헛된 건 아니에요. 둘은 성격이 다릅니다.

텍스트
 자바스크립트   받아서 실행해야 화면이 움직인다    늦으면 앱이 멈춰 있다
 사진          받아서 그리면 된다                늦어도 나머지는 쓸 수 있다

지난 시간에 사진 때문에 LCP가 5초였던 것을 떠올려보세요. 사진은 사진대로 다뤄야 할 문제이고, D-9에서 배운 방법들이 그쪽입니다.

자주 나오는 실수

빌드 출력에 woff2가 5개 찍히니 다 받는다고 계산하는 경우가 많습니다. 실제로 받는 것은 네트워크 탭을 봐야 알아요. 만들어진 것과 받아가는 것은 다릅니다.

💡 튜터의 한마디 — 최적화는 늘 "가장 큰 것부터"입니다. 그런데 무엇이 가장 큰지는 도구 하나만 봐서는 모르고, 도구마다 보는 범위가 다릅니다. 네트워크 탭 한 번이 트리맵보다 넓게 보여줄 때가 있어요.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] 바닥이 어디인지 아는 것

문제 상황 요약

react-domreact-router를 합친 42.7% 를 "바닥"이라고 불렀는데, 그 말에는 "이 앱을 이렇게 만들기로 한 이상"이라는 조건이 붙어 있었죠. 그 조건 자체를 언제 다시 물어야 할까요.

튜터의 가이드 및 해설

먼저 이 질문이 왜 어려운지부터 짚고 갑시다. 바닥을 다시 묻는 것은 기술 선택을 다시 하는 일이고 그건 거의 항상 앱을 다시 만드는 일이라서, 대부분의 경우 답은 "묻지 않는다"예요.

그렇다면 언제 물어야 할까요? 실무에서 이 질문이 정당해지는 조건은 꽤 좁습니다.

첫째, 성능이 제품의 핵심 지표일 때입니다. 광고 랜딩 페이지나 저사양 기기가 주 사용층인 서비스가 그렇습니다. 여기서는 100 kB 차이가 전환율로 바로 나타나요. 이런 곳에서는 애초에 React를 안 고르거나, 화면을 그리는 부분만 훨씬 작은 것으로 고릅니다.

둘째, 앱이 실제로 단순할 때입니다. 화면이 서너 개고 상태가 거의 없다면 라우터의 42.7% 중 절반은 우리가 안 쓰는 기능값입니다. 이 경우는 바닥을 낮출 여지가 실제로 있어요.

반대로 이 질문을 하면 안 되는 상황도 분명합니다. 앱이 이미 자랐고 팀이 그 도구에 익숙할 때예요. C-1에서 우리가 라우터를 들이면서 뒤로 가기·주소 공유·중첩 껍데기를 한꺼번에 얻었던 것을 떠올려보세요. 직접 짜면 처음엔 작지만 그 기능들을 하나씩 다시 만들게 되고, 결국 비슷한 크기에 검증만 없는 코드가 됩니다.

판단을 미루면 치르는 대가는 방향이 정해져 있습니다. 시간이 갈수록 바꾸는 비용만 오르고 얻는 이득은 그대로여서, 이 질문은 프로젝트 시작 시점에 한 번 제대로 하고 그 뒤로는 웬만하면 안 여는 것이 실무의 결론입니다. 시작할 때 30분 고민할 것을 2년 뒤에 3개월 들여 고치게 되니까요.

한 가지 더. 바닥을 아는 것 자체는 바닥을 바꾸지 않아도 값을 합니다. 줄일 수 없는 부분을 알면 줄일 수 있는 부분에 집중할 수 있어요. 오늘 우리가 42.7% 를 바닥으로 인정하고 나머지를 본 것처럼요.

🎯 면접관을 홀리는 핵심 멘트

"번들을 줄이는 일에서 가장 먼저 하는 건 줄일 수 없는 부분을 확정하는 겁니다. 저희 앱은 React와 라우터가 42.7% 였고, 그건 바닥으로 인정하고 시작했어요. 바닥을 다시 묻는 건 기술 선택을 다시 하는 일이라 프로젝트 시작 때 할 질문이지 중간에 열 질문이 아니라고 봅니다. 대신 바닥을 알고 나니 나머지 57% 중에 뭘 볼지가 분명해졌습니다."

🤔 [생각해볼 주제 2] 숫자가 사람을 움직이는 방향

문제 상황 요약

"우리 코드는 번들의 6.8%"라는 숫자를 팀에 공유합니다. 어떤 사람은 "그럼 우리 코드는 신경 쓸 필요 없네"로, 어떤 사람은 "라이브러리를 걷어내자"로 받아들일 수 있습니다. 같은 숫자가 정반대 행동을 부르는 것을 어떻게 막을까요.

튜터의 가이드 및 해설

이 문제의 뿌리는 숫자가 맥락 없이 돌아다니면 각자가 원하는 결론에 갖다 붙인다는 데 있습니다. 6.8% 라는 값 자체는 아무 주장도 안 해요. 주장은 읽는 사람이 만듭니다.

그래서 숫자를 공유할 때 함께 줘야 할 것들이 있습니다. 오늘 우리가 그 숫자 옆에 붙여둔 것들을 떠올려보면 정확히 그것들이에요.

첫째, 무엇을 잰 값인지입니다. 6.8% 는 압축 전 기준이고 주 청크에 한한 값입니다. 과제 1에서 봤듯이 압축 후로 재면 9.86% 가 돼요. 기준을 안 밝히면 나중에 다른 사람이 다른 숫자를 들고 와서 누가 맞는지 다투게 됩니다.

둘째, 그 숫자가 답하는 질문입니다. 6.8% 는 "번들을 줄이려면 어디를 봐야 하나"에 대한 답입니다. "우리 코드 품질이 어떤가" 나 "우리가 일을 얼마나 했나"에 대한 답이 아니에요. 질문을 안 밝히면 엉뚱한 질문의 답으로 쓰입니다. "우리 코드는 신경 쓸 필요 없네"가 정확히 그 경우예요 — 번들 크기 이야기를 코드 품질 이야기로 옮긴 겁니다.

셋째, 그 숫자가 못 말하는 것입니다. 6.8% 는 우리 코드가 느린지 빠른지는 전혀 안 알려줍니다. C-8에서 봤듯이 크기와 실행 속도는 다른 문제예요. 작은 코드가 매 렌더마다 무거운 계산을 할 수도 있습니다.

넷째, 다음에 할 일입니다. 숫자만 던지면 사람들이 각자 행동을 상상합니다. "그래서 우리는 안 쓰는 코드 95 KiB를 먼저 보기로 했다"까지 붙이면 상상할 여지가 없어져요.

두 번째 사람 — "라이브러리를 걷어내자"는 쪽 — 은 특히 위험합니다. 잘 돌아가는 것을 지우는 일이라 되돌리기 어렵고, 지운 뒤에야 그게 뭘 하고 있었는지 알게 되는 경우가 많아요. 과제 3에서 Sentry를 빼면 147 kB가 줄지만 그 대신 잃는 것을 하나하나 적어봤던 것처럼, 걷어내기 전에 "이게 뭘 하고 있었나"를 먼저 적게 하는 절차가 있으면 이런 사고가 줄어듭니다.

🎯 면접관을 홀리는 핵심 멘트

"성능 숫자를 공유할 때는 값만 주지 않고 네 가지를 같이 줍니다. 어떻게 잰 값인지, 그 숫자가 답하는 질문이 뭔지, 못 말하는 게 뭔지, 그래서 다음에 뭘 할 건지요. 예전에 '우리 코드는 번들의 7%'라는 걸 그냥 공유했다가 한쪽은 코드 품질 이야기로, 다른 쪽은 라이브러리 걷어내자는 이야기로 받아들이는 걸 봤습니다. 숫자는 주장을 안 하니까 읽는 사람이 주장을 만들더라고요."

🤔 [생각해볼 주제 3] 고장난 앱이 더 빠릅니다

문제 상황 요약

연습용 서버를 껐더니 성능 점수가 79에서 94로 올랐습니다. 화면에 아무것도 안 나오는 앱이 더 좋은 점수를 받은 거예요. 도구가 잘못된 걸까요, 우리가 잘못 쓰고 있는 걸까요.

튜터의 가이드 및 해설

도구는 잘못되지 않았습니다. Lighthouse가 재는 것은 "화면이 얼마나 빨리 그려지고 안정적인가"입니다. 그 질문에 대해서는 정확한 답을 냈어요. 사진이 안 오는 화면은 정말로 빨리 그려지고 정말로 안 밀립니다.

문제는 우리가 그 점수를 "앱이 좋은가"의 답으로 쓰고 있었다는 데 있습니다. 그건 도구가 답한 질문이 아니에요.

이건 지난 시간에 본 것의 연장입니다. 그때 우리는 실험실에서 잰 숫자가 현장 숫자와 다섯 배 차이 나는 것을 봤고, "어느 쪽도 거짓말이 아니고 재는 조건이 곧 질문"이라고 정리했죠. 오늘 것은 한 걸음 더 나갑니다. 조건이 나쁠수록 점수가 좋아지는 경우가 있다는 거예요.

이런 성질을 가진 지표를 목표로 삼으면 무슨 일이 생길까요. 여기서부터가 진짜 질문입니다.

목표가 "성능 점수 90점 이상"이라고 해봅시다. 이 사실을 아는 개발자가 점수를 올리는 방법은 여러 가지예요. 정직한 방법도 있지만, 화면에 덜 그리는 방법도 있습니다.

  • 첫 화면에서 사진을 안 보여주고 스크롤해야 나오게 한다
  • 목록을 10개에서 3개로 줄인다
  • 무거운 부분을 사용자가 버튼을 눌러야 뜨게 한다

셋 다 점수를 올립니다. 그리고 셋 다 사용자에게는 더 나쁜 앱 일 수 있어요. 아무도 규칙을 어기지 않았는데 제품이 나빠지는 겁니다.

이걸 막으려면 목표를 세우는 방식을 바꿔야 합니다.

하나. 점수가 아니라 지표를 목표로 둡니다. "90점" 대신 "LCP 2.5초 이하" 처럼요. 점수는 여러 지표를 가중치로 섞은 값이라 어디를 건드려 올렸는지 안 보입니다.

둘. 화면이 제대로 나왔는지를 함께 잽니다. 우리가 CLS를 재면서 "사진 10/10"을 같이 찍기로 한 것과 같은 방법이에요. 점수 옆에 "게시물 10개가 다 보였는가"가 붙어 있으면 3개로 줄여서 점수를 올리는 길이 막힙니다.

셋. 현장 값을 함께 봅니다. 지난 시간에 켠 것이 이 일을 합니다. 실제 사용자들이 겪는 숫자는 실험실 조건을 조작해서 만들 수 없어요.

정리하면 이렇습니다. 하나의 숫자를 목표로 삼으면 그 숫자를 올리는 가장 쉬운 길이 선택되고, 그 길이 제품을 좋게 만드는 길과 늘 같지는 않습니다. 그래서 성능 목표에는 항상 "무엇이 제대로 나왔는가"가 짝으로 붙어야 해요.

🎯 면접관을 홀리는 핵심 멘트

"성능 점수를 목표로 걸면 화면에 덜 그리는 게 가장 쉬운 방법이 됩니다. 저희는 실제로 API 서버를 끄니까 점수가 79에서 94로 오르는 걸 봤어요. 아무것도 안 나오는 앱이 제일 빠른 거죠. 그래서 목표를 점수 대신 개별 지표로 두고, 그 옆에 '콘텐츠가 다 나왔는가'를 같이 재도록 했습니다. 지표 하나만 걸면 사람은 그 지표를 올리지 제품을 올리지 않더라고요."

전체 목록 리액트