문서 읽는 데 54분 · H6

H-6: 번들 최적화 ② — 안 쓰는 것을 찾아내고 지킵니다

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

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

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

시작하기 전에 질문 하나 드릴게요. 우리가 직접 쓴 코드 중에서, 홈 화면을 열었을 때 한 줄도 실행되지 않는 코드는 몇 퍼센트일까요?

10%쯤? 20%쯤? 잠깐 생각해보시고 계속 읽어주세요.

지난 시간에 우리는 683 kB 짜리 번들을 열어서 안을 들여다봤습니다. 우리가 쓴 코드는 그중 6.8% 뿐이었고, 나머지는 React와 라우터와 추적 도구였는데, 마지막엔 이런 상태로 끝났습니다.

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

오늘은 그 이름을 알아내는데, 알아내고 나면 좀 허탈한 결론에 도달할 거예요. 미리 말씀드리면 찾아낸 것 대부분은 자를 수 없어서, 오늘의 후반부는 자르는 방법이 아니라 더 늘어나지 않게 지키는 방법입니다.

아, 맨 위 질문의 답은 44.7%입니다. 절반 가까이가 홈 화면에서는 한 줄도 안 돌아요. 왜 그런지도 오늘 확인합니다.

텍스트
 오늘의 여정

 1. 이름을 되찾는다       95 KiB 가 무엇이었나
 2. 비어 있는 내역        Lighthouse 는 왜 못 읽나
 3. 개발에만 있는 자국     재는 도구가 있는 곳과 줄일 것이 있는 곳
 4. 경고와 정지           빨간 글씨는 아무것도 안 막는다
 5. 그래서 무엇을 자르나   자를 것이 거의 없다는 결론

💡 오늘 수업의 핵심 — "안 쓰는 코드를 이름으로 찾아낼 수는 있지만, 찾아낸 것 대부분은 자를 수 없습니다. 그래서 줄이는 일보다 지키는 일이 중요합니다"


🎯 학습 목표

  • 브라우저가 실제로 실행한 코드를 재고, 지도를 이용해 원본 파일 이름으로 되돌립니다.
  • 도구가 알려준 이름을 그대로 믿기 전에 그 숫자가 말이 되는지 확인하는 습관을 들입니다.
  • 번들이 더 자라지 않게 막는 장치를 직접 만들고, 경고와 정지가 어떻게 다른지 구분합니다.

Step 1: "안 쓴 것을 이름으로 되돌립니다"

브라우저는 무엇이 돌았는지 알고 있습니다

지난 시간에 본 95 KiB는 "안 쓴 양" 이었습니다. 양은 알겠는데 정체를 모르니 손을 댈 수가 없어요.

다행히 브라우저는 그 답을 갖고 있습니다. 자바스크립트 엔진은 코드를 실행하면서 어느 함수가 실제로 불렸는지를 셀 수 있어요. 개발자 도구의 Coverage 패널이 보여주는 게 바로 그겁니다.

우리는 그걸 자동으로 받아오는 도구를 하나 만들어 뒀습니다. 크롬을 화면 없이 띄우고, 우리 배포본을 열고, 피드가 다 뜬 다음에 "무엇이 돌았니?" 하고 물어보는 도구예요.

먼저 배포본을 만들고 띄워둡니다.

Bash
npm run build
npm run preview

연습용 서버도 함께 띄워야 합니다. 사진이 안 오면 화면이 비어서, 안 도는 코드가 실제보다 많게 나오거든요.

그 다음 도구를 돌립니다.

Bash
node scratch/h3c-coverage.mjs

첫 번째 함정 — 구간은 겹쳐 있습니다

여기서 그냥 받은 대로 세면 이상한 답이 나옵니다. 처음 만들었을 때 "100% 실행됨"이 나왔어요. 안 쓰는 코드가 하나도 없다는 뜻인데, 그럴 리가 없죠.

원인은 엔진이 주는 자료의 모양에 있습니다. 실행 여부는 구간(range) 단위로 오는데, 이 구간들이 서로 겹쳐 있어요. 스크립트 전체를 감싸는 가장 바깥 구간은 당연히 "실행됨"이고, 그 안에 "한 번도 안 불림"인 함수가 들어 있습니다.

텍스트
 바깥 구간 (스크립트 전체)         실행됨
   ├─ 함수 A                      실행됨
   ├─ 함수 B                      안 불림    이게 진짜 답
   └─ 함수 C                      실행됨

바깥만 보면 전부 실행된 것처럼 보입니다. 그래서 가장 안쪽 구간이 이기게 해야 해요. 넓은 것부터 칠하고 좁은 것으로 덮어쓰면 자연스럽게 안쪽이 남습니다.

JavaScript
// apps/web-spa/scratch/h3c-coverage.mjs
function unusedSpans(functions, length) {
  const covered = new Uint8Array(length); // 1 = 실행됨
  const ranges = functions.flatMap((f) => f.ranges);
  // 넓은 것부터 칠하면 좁은 것(안쪽)이 나중에 덮어써서 이긴다.
  ranges.sort((a, b) => (b.endOffset - b.startOffset) - (a.endOffset - a.startOffset));
  for (const r of ranges) {
    const value = r.count > 0 ? 1 : 0;
    covered.fill(value, r.startOffset, Math.min(r.endOffset, length));
  }
  // ... 안 칠해진 구간을 모아 돌려준다
}

이름을 되찾습니다

"몇 번째 글자부터 몇 번째 글자까지가 안 돌았다"는 목록이 생겼는데, 배포본은 이름이 전부 뭉개져 있어서 그 자리가 무엇인지 알 수 없어요.

여기서 H-2에서 만들어둔 지도가 일을 합니다. 그때 우리는 뭉갠 코드를 원래 파일과 줄로 되돌리는 지도를 만들어 뒀죠. 그 지도에 "이 글자 위치가 원래 어느 파일이었니?" 하고 물으면 됩니다.

돌려보면 이렇게 나옵니다.

텍스트
  전체   681,925 B
  실행됨 263,805 B (38.7%)
  안 씀  418,120 B (61.3%)

  안 쓴 것의 정체:
    117,558 B  react-dom
     73,545 B  react-router
     58,043 B  @sentry/core
     32,631 B  @base-ui/react
     27,425 B  axios
     20,274 B  @tanstack/query-core
     19,288 B  @sentry/browser-utils
     16,148 B  @sentry/browser
     11,325 B  @stomp/stompjs
      3,447 B  src/routes/DmPage.tsx           우리 코드
      1,946 B  src/components/ui/dialog.tsx    우리 코드
      1,660 B  src/routes/PostDetailPage.tsx   우리 코드
      1,485 B  src/routes/ExplorePage.tsx      우리 코드
      1,154 B  src/lib/h3-vitals.ts            우리 코드
      1,154 B  src/components/PostComments.tsx  우리 코드

  └ 그중 우리 코드 23,238 B

95 KiB의 정체가 드디어 이름을 가졌고, 맨 위 질문의 답이 여기 있어요. 우리가 쓴 코드는 번들에서 51,985 B 인데, 그중 23,238 B가 홈 화면에서 한 줄도 안 돕니다. 44.7% 예요.

왜 그럴까요? 목록을 다시 보세요. DmPage·PostDetailPage·ExplorePage지금 화면에 없는 화면들입니다. 홈에 있는데 안 도는 게 아니라, 애초에 다른 주소의 화면인데 코드만 같이 실려 온 거예요.

⚠️ 두 번째 함정 — 저는 여기서 한 번 크게 틀렸습니다

처음 이 표를 뽑았을 때 이런 줄이 있었습니다.

텍스트
     10,147 B  src/routes/PostDetailPage.tsx

10 kB 면 우리 코드 중에 압도적으로 큰 값이라 "게시물 상세 화면이 범인이구나" 하고 넘어갈 뻔했어요. 그런데 한 가지가 걸렸습니다. PostDetailPage.tsx 원본 파일이 2,264 B 거든요.

압축해서 넣은 조각이 원본보다 4.5배 클 수는 없습니다. 뭔가 틀린 거예요.

원인은 이름을 붙이는 방법에 있었습니다. 안 쓴 구간 하나를 통째로 잡고 그 시작점 한 곳만 지도에 물어봤던 건데, 구간 하나가 여러 파일에 걸쳐 있으면 그 구간 전체가 경계에 서 있던 파일 하나의 몫으로 쌓입니다.

텍스트
 안 쓴 구간 하나
 ├──────────┬──────────┬──────────┤
           ·          ·          ·
 └ 시작점만 지도에 물어본다
    이 구간 전체가 여기 하나의 몫으로 쌓인다

 실제로는  앞칸 = PostDetailPage
           가운뎃칸 = 라이브러리
           뒷칸 = 또 다른 파일

이 문제를 고치려고 구간 안에서 원본 파일이 바뀌는 자리마다 잘라서 나눠 주도록 했습니다. 고치고 나니 PostDetailPage.tsx는 1,660 B로 내려갔고, 그 옆에 가려져 있던 DmPage.tsx 3,447 B가 드러났어요.

고친 답이 맞는지는 세 가지로 확인했습니다.

  • 합계가 그대로입니다. 418,120 B라는 총량은 고치기 전과 똑같아요. 나누는 방법만 바뀌었으니 당연합니다.
  • 여섯 파일이 전부 원본 소스보다 작아졌습니다. 압축했으니 그래야 정상이죠.
  • 지난 시간에 쓴 트리맵과 비율이 맞습니다. 완전히 다른 도구인데도요.
꾸러미 트리맵(지난 시간) 커버리지(오늘)
react-dom 29.7% 26.3%
react-router 13.0% 13.9%
@sentry/* 합계 21.2% 21.4%
우리 코드 6.8% 7.6%

트리맵은 압축하기 전 모듈을 세고, 커버리지는 압축한 뒤 글자를 셉니다. 재는 단계도 방법도 다른데 꾸러미마다 몇 퍼센트 안에서 맞아요. 서로 다른 두 길이 같은 곳에 도착하면 그건 꽤 믿을 만한 신호입니다.

💡 한 줄 정리

브라우저가 실행한 코드를 재고 지도로 되돌리면 안 쓰는 코드가 이름을 갖는데, 도구가 준 이름을 그대로 믿기 전에 그 숫자가 원본 파일 크기와 견줘 말이 되는지 한 번은 확인해야 합니다.

🙋 학생 질문 — "튜터님, 그럼 개발자 도구 Coverage 패널을 쓰면 되지 않나요? 왜 굳이 도구를 만들었어요?"

좋은 질문이에요. 실제로 Coverage 패널을 열어서 눈으로 보는 게 첫 단계로는 제일 편합니다. 여러분도 꼭 한 번 열어보세요.

패널은 파일 단위로만 보여준다는 한계가 있어요. 우리 번들은 파일이 하나뿐이라 "이 파일의 61%가 안 쓰였습니다" 까지밖에 안 나와요. 그 안에서 어느 라이브러리인지 갈라 보려면 지도를 거쳐야 하는데, 그 작업을 손으로 하긴 어렵습니다.

한 가지 더 짚을 게 있어요. 눈으로 보면 위에서 본 그 실수를 못 잡습니다. 숫자를 표로 뽑아놓고 원본 파일 크기와 나란히 놓아야 "어? 이게 원본보다 크네?"가 보여요.


Step 2: "그 내역이 왜 비어 있을까요"

Lighthouse도 같은 것을 재줍니다

사실 우리가 방금 한 일을 Lighthouse도 해줍니다. unused-javascript 항목이 그거예요. 게다가 소스맵을 찾을 수 있으면 원본 파일별 내역까지 보여줍니다.

우리 앱에 돌려볼게요.

Bash
npx lighthouse http://localhost:4173/ --only-audits=unused-javascript --view

결과입니다.

텍스트
 Reduce unused JavaScript      Est savings of 95 KiB
   index-*.js      95 KiB 안 씀 / 214 KiB

95 KiB. 지난 시간에 본 그 숫자가 그대로 나오는데, 그 아래 원본 파일별 내역이 있어야 할 자리가 완전히 비어 있어요. 한 줄도 없습니다.

비어 있는 이유는 우리가 만들었습니다

Lighthouse가 못하는 게 아닙니다. 지도를 못 찾은 거예요.

H-2에서 우리가 무슨 결정을 했는지 떠올려보세요. 소스맵을 만들되 배포본이 그 지도를 가리키는 주석을 안 남기도록 했습니다. 지도에는 우리 소스가 통째로 들어 있어서, 배포본 옆에 그대로 올리면 누구나 내려받을 수 있으니까요.

Lighthouse는 그 주석을 따라가서 지도를 찾습니다. 주석이 없으니 찾을 수가 없죠.

정말 그것 때문인지 확인해봅시다. 설정을 잠깐 바꿔서 주석을 남기게 하고 다시 재보면요.

TypeScript
// apps/web-spa/vite.config.ts
  build: {
    sourcemap: true,   // 확인용으로 잠깐만. 다시 'hidden' 으로 되돌립니다
  },
텍스트
 Reduce unused JavaScript      Est savings of 95 KiB
   index-*.js      95 KiB 안 씀 / 214 KiB
     21,150 B  react-dom/cjs/react-dom-client.production.js
     12,844 B  react-router/dist/production/lib/router/router.js
      2,925 B  @stomp/stompjs/bundles/stomp.umd.js
      2,211 B  @base-ui/react/.../FloatingFocusManager.mjs
      1,827 B  react-router/dist/production/lib/dom/lib.js

다섯 줄이 생겼습니다. 원인이 확인됐어요.

여기서 하나 짚고 갈 게 있습니다. 절감 표시는 95 KiB로 양쪽이 똑같습니다. 소스맵을 켜도 "얼마나"는 안 바뀌고 "어디서"만 생겼어요. 재는 값이 달라진 게 아니라 읽을 수 있게 된 겁니다.

그런데 되돌리는 건 답이 아닙니다

내역이 보이니 좋아 보이죠. 그냥 true로 두면 안 될까요?

안 됩니다. H-2에서 hidden을 고른 이유가 그대로 살아 있거든요. 주석을 남긴다는 건 "지도가 여기 있습니다"라고 알려주는 것이고, 지도를 배포본 옆에 올리면 우리 소스 전체가 공개되니까, 실험이 끝나면 되돌립니다.

TypeScript
    sourcemap: 'hidden',

진짜 답은 Step 1에서 이미 했습니다. 지도를 남에게 보여주는 대신, 우리 기계 안에서 우리가 직접 되돌리는 거예요. 지도는 우리 손에 있고 배포본만 그것을 안 가리킬 뿐입니다.

⚠️ 두 숫자를 나란히 놓으면 안 됩니다

Lighthouse는 95 KiB 안 씀 / 214 KiB라고 했습니다. 44% 네요. 그런데 우리 도구는 61.3% 라고 했어요. 왜 다를까요?

두 가지가 다릅니다.

단위가 다릅니다. Lighthouse의 214 KiB는 실제로 네트워크를 타고 온 크기, 즉 압축된 크기입니다. 우리 도구의 681,925 B는 압축을 푼 뒤의 글자 수예요. 같은 파일인데 재는 자가 다릅니다.

재는 창이 다릅니다. Lighthouse는 느린 기기와 느린 네트워크를 흉내 내면서 훨씬 오래 지켜봅니다. 그 사이에 재연결이나 재요청 같은 코드가 더 돌아요. 오래 볼수록 "안 쓴 것"은 줄어듭니다.

둘 다 맞는 숫자이지만, 나란히 놓고 "어느 쪽이 진짜냐"를 따지면 안 됩니다. 물어본 질문이 서로 다르니까요.

💡 한 줄 정리

Lighthouse의 내역이 비어 있던 것은 도구의 한계가 아니라 우리가 지난 시간에 내린 결정의 결과입니다. 그 결정은 그대로 두고, 지도를 읽는 일은 우리 기계 안에서 합니다.

🙋 학생 질문 — "튜터님, 지도를 올리되 비밀번호를 걸거나 하면 안 되나요?"

실무에서 자주 쓰는 방법이 정확히 그겁니다. 오류 추적 서비스에 지도를 따로 올려두는 방식이요. H-2에서 이름표(Debug ID) 이야기를 했던 게 바로 그 준비였는데, 그렇게 하면 서비스 안에서는 뭉개진 스택이 원래 줄로 보이고 바깥 사람은 지도에 접근할 수 없습니다. 우리는 계정을 안 만들어서 거기까지는 안 갔지만, 실무에 나가면 이 방식을 쓰게 될 거예요.

Lighthouse는 그 지도를 못 봅니다. 브라우저가 접근할 수 있는 곳에만 있어야 하니까요. 그러니 "Lighthouse에서도 내역을 보고 싶다"와 "소스를 감추고 싶다"는 어느 정도는 같이 가기 어려운 요구입니다. 둘 중 무엇이 더 중요한지는 팀이 정할 문제예요.


Step 3: "재는 도구는 개발 서버에만 있습니다"

React도 자기 자국을 남깁니다

번들 이야기에서 잠깐 벗어나 볼게요. 성능을 볼 때 쓰는 도구가 하나 더 있습니다.

React 19.2부터 개발자 도구 Performance 패널에 React 전용 트랙이 생겼습니다. 어느 컴포넌트를 그리는 데 시간이 얼마나 걸렸는지, 어떤 갱신이 무엇 때문에 일어났는지를 React가 직접 표시해줘요.

이게 어떻게 동작하는지 세어봅시다. 개발 서버를 띄우고 재보면요.

Bash
node scratch/h3c-react-tracks.mjs http://localhost:5173/
텍스트
 http://localhost:5173/
   사진 10/10
   performance.measure 자국 154개
      18회  Update
      18회  PostCard
      18회  Card
      17회  Mount
      15회  PostImage
      15회  CardContent
      10회  PostBody
      10회  LikeButton

컴포넌트 이름이 그대로 보입니다. PostCard를 18번 그렸고 LikeButton을 10번 그렸다는 걸 React가 알려주고 있어요. C-8에서 우리가 손으로 세던 것을 이제 React가 이름표까지 붙여서 줍니다.

배포본에서는 하나도 없습니다

이제 같은 것을 배포본에서 재봅니다. 주소만 바꿔서요.

Bash
node scratch/h3c-react-tracks.mjs http://localhost:4173/
텍스트
 http://localhost:4173/
   사진 10/10
   performance.measure 자국 0개

0개입니다.

여기서 중요한 게 하나 있어요. 사진 10/10이 같이 찍혀 있죠. 화면이 텅 비어서 그릴 게 없었던 게 아니라, 피드가 다 뜬 상태에서 재도 0이라는 뜻입니다. 이걸 확인 안 하면 "측정이 잘못됐나?" 하고 헤매게 됩니다.

이건 우리 설정 탓이 아닙니다. React 공식 문서가 이렇게 적어뒀어요.

이 트랙을 만드는 계측은 약간의 부담을 더하기 때문에, 배포용 빌드에서는 기본적으로 꺼져 있습니다.

측정하는 코드 자체가 느리게 만드니까, 사용자에게 나가는 판에서는 빼는 게 맞습니다. 합리적인 결정이에요.

그런데 이게 좀 곤란합니다

정리해봅시다.

텍스트
 개발 서버    자국 백 몇십 개      재기는 좋은데 이 판은 사용자에게 안 나간다
 배포본       자국 0개             줄여야 할 판인데 잴 수가 없다

재는 도구는 개발 서버에 있고, 줄여야 할 번들은 배포본에 있습니다. 이 어긋남이 낯익지 않으신가요? H-4에서 우리 노트북에서 잰 숫자와 사용자 쪽 숫자가 달랐던 것과 같은 모양이고, 이번엔 렌더링 쪽에서 같은 일이 벌어지는 만큼 개발 서버에서 본 숫자는 어디를 볼지 정하는 데 쓰고, 실제로 줄었는지는 배포본을 다시 빌드해서 확인해야 합니다. 개발 서버에서 빨라진 것처럼 보인다고 사용자도 빨라진 건 아니에요.

한 가지 덧붙이면, 자국의 개수는 잴 때마다 조금씩 달라집니다. 저는 144개, 148개, 154개를 봤어요. 무엇을 클릭했는지에 따라 갱신이 더 일어나니까요. 흔들리지 않는 건 배포본의 0 뿐입니다.

💡 한 줄 정리

React가 그려주는 자국은 개발 서버에만 있습니다. 어디를 손볼지 정하는 데는 그 자국을 쓰되, 실제로 나아졌는지는 배포본을 다시 만들어서 확인해야 합니다.

🙋 학생 질문 — "튜터님, 배포본에서도 꼭 재보고 싶으면 방법이 없나요?"

있습니다. React는 배포용에 가깝지만 계측은 살아 있는 별도의 빌드를 react-dom/profiling이라는 이름으로 따로 제공하는데, 이건 "배포본"이 아니라 "배포본을 흉내 낸 측정용 판"입니다. 계측이 들어 있으니 진짜 배포본보다는 느리고 조금 더 커요. 그러니 이걸로 잰 절대 시간을 사용자가 겪는 시간이라고 말하면 안 됩니다.

쓰임새는 이렇습니다. 개발 서버에서만 재면 개발 전용 검사들 때문에 실제보다 훨씬 느리게 나오는데, 이 판은 그 검사들이 빠져 있어서 어느 컴포넌트가 상대적으로 무거운지를 훨씬 정확하게 비교할 수 있어요. 절대값이 아니라 순위를 볼 때 씁니다.


Step 4: "경고는 아무것도 막아주지 않습니다"

지금도 빨간 글씨가 뜨고 있습니다

여기까지 우리는 안 쓰는 코드를 찾아냈습니다. 그런데 잠깐 다른 걸 보죠. 빌드할 때마다 나오던 이 메시지, 기억하시나요?

텍스트
 (!) Some chunks are larger than 500 kB after minification.

C-9에서 실시간 통로를 넣으면서 다시 나타난 뒤로 계속 떠 있습니다. 지금 우리 번들은 683 kB 니까 한참 넘었는데, 이 메시지가 뜬 다음에 무슨 일이 일어날까요?

Bash
npm run build
echo $?
텍스트
 0

아무 일도 안 일어납니다. 빌드는 성공했고, 종료 코드는 0이고, 배포 파이프라인이 있었다면 그대로 다음 단계로 넘어갔을 거예요.

이게 경고의 성질입니다. 알려주기만 하고 막지는 않아요. 그리고 사람은 매번 뜨는 빨간 글씨에 아주 빠르게 익숙해집니다. 세 번쯤 보고 나면 눈에 안 들어와요.

막으려면 직접 만들어야 합니다

번들 크기의 상한을 정하고, 넘으면 빌드를 세우는 장치를 만들어봅시다. 빌드가 실패하면 아무도 그냥 지나칠 수 없어요.

Vite의 플러그인으로 만들 수 있습니다. D-10에서 빌드가 끝난 뒤에 일하는 플러그인을 한 번 만들어봤죠. 그때 쓴 두 가지를 그대로 씁니다.

TypeScript
// apps/web-spa/vite.config.ts
// 첫 화면에서 받는 자바스크립트의 gzip 상한.
// 오늘 값(219,470 B)에 5,530 B 여유를 뒀다. 딱 맞추면 한 줄만 더해도 빨개지고,
// 너무 넉넉하면 넘은 줄 모르고 지나간다.
const ENTRY_GZIP_BUDGET = 225_000;

function bundleBudget(budgetBytes: number): Plugin {
  return {
    name: 'bundle-budget',
    apply: 'build',
    closeBundle() {
      const dir = fileURLToPath(new URL('./dist/assets', import.meta.url));
      const entry = readdirSync(dir).find((file) => /^index-.*\.js$/.test(file));
      if (!entry) throw new Error('첫 화면 자바스크립트를 못 찾았습니다.');

      const gzipBytes = gzipSync(readFileSync(join(dir, entry))).length;
      const line = `${entry} gzip ${gzipBytes.toLocaleString()} B / 예산 ${budgetBytes.toLocaleString()} B`;
      if (gzipBytes > budgetBytes) {
        const over = gzipBytes - budgetBytes;
        throw new Error(`번들 예산 초과 — ${line} (${over.toLocaleString()} B 넘었습니다)`);
      }
      console.log(`[예산] ${line}`);
    },
  };
}

apply: 'build'는 빌드할 때만 끼라는 뜻입니다. 이게 없으면 개발 서버를 띄울 때도, 테스트를 돌릴 때도 끼어들어요. closeBundle은 파일을 다 쓴 뒤에 불립니다.

플러그인 목록 맨 앞에 넣고 빌드해봅니다.

텍스트
 [예산] index-CikMUxha.js gzip 219,470 B / 예산 225,000 B
 (!) Some chunks are larger than 500 kB after minification.

한 화면에 둘이 같이 있는 게 오늘의 대비입니다. 아래 경고는 여전히 아무것도 안 막고, 위의 우리 장치는 넘었는지를 실제로 판단합니다.

넘으면 정말 멈추는지 확인합니다

만들어놓고 "잘 되겠지" 하면 안 됩니다. 일부러 넘겨봐야 해요. 상한을 잠깐 200,000으로 낮춰서 빌드하면요.

텍스트
 Error: 번들 예산 초과 — index-CikMUxha.js gzip 219,470 B / 예산 200,000 B (19,470 B 넘었습니다)
   plugin: 'bundle-budget'
   hook: 'closeBundle'
Bash
echo $?
텍스트
 1

종료 코드 1. 이제 배포 파이프라인이 여기서 멈춥니다. 확인했으면 상한을 원래대로 돌려놓으세요.

⚠️ 어느 숫자를 예산으로 삼을까요

여기서 헷갈리는 게 하나 나옵니다. 빌드 출력에는 이렇게 찍혀요.

텍스트
 dist/assets/index-CikMUxha.js    683.01 kB │ gzip: 221.72 kB

221.72 kB 라는데 우리 장치는 219,470 B라고 합니다. 같은 파일인데 2 kB 넘게 차이가 나요.

처음엔 압축 강도 문제인가 싶었습니다. 그런데 강도를 최대로 올려도 219,178 B라 오히려 더 줄어들어요. CSS 파일도 8,844 vs 8,860으로 어긋나고요.

이유는 압축하는 프로그램이 서로 다르기 때문입니다. 빌드 도구는 자기가 품고 있는 압축기로 재고, 우리 장치는 Node가 기본으로 주는 압축기로 잽니다. 같은 gzip이라도 구현이 다르면 결과가 몇 퍼센트 안에서 갈려요.

어느 쪽이 틀린 건 아닙니다. 중요한 건 하나를 골라서 계속 그것으로 재는 것이에요. 빌드 출력 숫자를 보고 예산을 정한 다음 우리 장치로 재면, 처음부터 1% 어긋난 채로 굳습니다.

지난 시간에 트리맵 숫자를 가려 읽어야 했던 것과 같은 이야기입니다. 도구가 주는 숫자에는 항상 "무엇을 어떻게 잰 것인가"가 붙어 있어요.

💡 한 줄 정리

경고는 알려줄 뿐 막지 않습니다. 번들이 더 자라지 않게 하려면 상한을 넘었을 때 빌드를 실제로 세우는 장치를 직접 만들어야 하고, 그 상한은 한 가지 방법으로 잰 숫자로 정해야 합니다.

🙋 학생 질문 — "튜터님, 빌드가 실패하면 dist 폴더도 안 만들어지나요?"

아니요, 만들어집니다. 이게 좀 헷갈리는 부분이에요.

closeBundle은 이름 그대로 번들을 다 닫은 뒤에 불립니다. 파일은 이미 디스크에 다 쓰여 있고, 우리가 던지는 오류는 그 뒤에 나오는 만큼, 우리가 세우는 것은 파일이 만들어지는 일이 아니라 그 뒤로 이어지는 일입니다. 배포 파이프라인은 보통 "빌드 명령이 0으로 끝났으면 다음 단계"처럼 되어 있어서, 종료 코드가 1이면 배포가 안 됩니다. 우리가 막는 건 그 지점이에요.

파일 자체를 안 만들고 싶다면 더 앞 단계에서 끼어들어야 하는데, 그러면 "얼마나 넘었는지"를 알려주기 어려워집니다. 넘은 양을 정확히 알려주는 쪽이 고치는 데 훨씬 도움이 돼요.


Step 5: "그래서 무엇을 자를까요"

C-8의 세 질문을 다시 꺼냅니다

자를 차례입니다. 목록은 있으니까요. C-8에서 정해둔 대로, 아무 데나 자르면 안 되고 세 가지를 먼저 물어봐야 하죠.

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

그때 우리가 만든 표는 이랬습니다.

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

오늘 얻은 숫자로 이 표를 채워봅시다. 탐색 화면은 1,485 B, 게시물 상세는 1,660 B가 안 돌고 있어요. "무거운 게 없어서 이득이 작다" 던 판단이 숫자로 맞았습니다. 그때는 감으로 적은 줄이었는데 이제 근거가 생겼네요.

그런데 표에 없는 화면이 있습니다

목록에서 우리 코드 중 제일 큰 게 뭐였죠?

텍스트
      3,447 B  src/routes/DmPage.tsx    우리 코드 중 최대

DM 화면입니다. 그런데 위의 표를 다시 보세요. DM 화면이 없어요.

빠뜨린 게 아닙니다. C-8에서 저 표를 만들 때는 DM 화면이 존재하지 않았습니다. 쪽지 기능은 C-10에서 만들었으니까요. 표는 그때의 화면 목록에 대해서는 완전했어요.

그 뒤로 화면이 하나 늘었는데, 아무도 그 표를 다시 꺼내보지 않았습니다. 그리고 지금 그게 우리 코드 중 가장 큰 안 쓰는 덩어리가 되어 있어요.

판단표는 만든 시점의 목록에 대해서만 참입니다. 앱이 자라면 표는 가만히 있어도 낡습니다.

세 질문을 던져봅니다

늦었지만 지금이라도 물어보죠.

하나. 얼마나 많은 사람이 여나요? 소수입니다. 피드를 보러 온 사람 대부분은 쪽지함에 안 들어가요.

둘. 그 화면만 쓰는 무거운 것이 있나요? 없습니다. 쪽지는 실시간 통로를 쓰는데, 그 통로는 C-9에서 앱 전체가 쓰도록 만들었어요. DM 화면만 쓰는 라이브러리는 없습니다.

셋. 잠깐 기다려도 되나요? 괜찮습니다. 화면을 아예 바꾸는 이동이라 0.2초쯤은 안 거슬려요.

두 번째가 "없다" 니까 이득이 작을 것 같습니다. 그런데 짐작하지 말고 실제로 해봅시다. C-8에서도 그랬듯이요.

tsx
// apps/web-spa/src/routes/routes.ts
const DmPage = lazy(() =>
  import('./DmPage').then((module) => ({ default: module.DmPage })),
);
텍스트
 쪼개기 전   index 683.01 kB
 쪼갠 뒤     index 679.63 kB  +  DmPage 청크 4.11 kB

주 청크에서 3.38 kB가 빠졌습니다. 압축하고 나면 1.1 kB예요.

여기서 재밌는 게 하나 있습니다. Step 1의 커버리지가 DmPage.tsx를 3,447 B라고 했는데, 실제로 쪼개보니 3,380 B가 빠졌어요. 67 바이트 차이입니다. 도구가 말한 숫자를 실제 행동으로 확인한 셈이죠. 이렇게 맞춰보면 그 도구를 얼마나 믿어도 되는지 알게 됩니다.

결론은 되돌리기입니다

1.1 kB를 얻고 무엇을 내나요? 쪽지함을 여는 사람에게 왕복 한 번이 추가됩니다. 전체 221 kB 중 1.1 kB 면 0.5% 예요.

C-8에서 게시물 상세를 쪼개봤다가 되돌린 것과 같은 상황이라 되돌리는데, 그럼 이 실험은 헛수고였을까요? 아닙니다. 값어치는 쪼개는 데 있지 않고 표에 그 줄을 적어두는 데 있습니다.

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

다음에 누가 "쪽지함은 쪼개면 좋지 않아요?"라고 물으면 이 줄을 보여주면 됩니다. 재봤고, 1.1 kB 였고, 그래서 안 했다고요.

제일 큰 덩어리는 이미 답이 나와 있습니다

우리 코드 말고 목록 위쪽을 볼까요.

텍스트
     32,631 B  @base-ui/react

32 kB가 홈 화면에서 안 돕니다. 우리 코드 전체보다 커요. 이건 뭘까요?

모달 창을 만드는 코드입니다. 피드에서 "댓글 N개 모두 보기"를 누르면 열리는 그 창이요. 코드는 실려 있는데 아무도 안 눌렀으니 안 도는 겁니다. 이걸 쪼개면 32 kB를 아낄 수 있을 것 같은데, C-8의 셋째 질문이 뭐였죠?

반대로 하트를 누르면 열리는 모달 같은 건 그렇지 않습니다.

C-8이 이미 답해뒀습니다. 누르는 즉시 열려야 하는 것을 쪼개면, 누른 뒤에 파일을 받아오느라 멈칫합니다. 화면 이동은 0.2초를 기다려도 괜찮지만, 버튼을 눌렀는데 아무 일도 안 일어나는 0.2초는 앱이 고장 난 것처럼 느껴지는데, 이건 "32 kB를 포기하고 대신 안 거슬리기를 산다"로 정리하면 될까요?

잠깐요. 그렇게 말하려면 쪼갰을 때 정말 32 kB가 빠지는지를 먼저 확인해야 합니다. 오늘 우리가 만든 표는 "안 돌았다"를 알려주지 "이 화면만 쓴다"를 알려주지는 않아요. 안 도는 코드를 다른 곳에서도 쓰고 있다면, 쪼개도 그 코드는 주 청크에 그대로 남습니다.

이건 오늘 과제로 남겨둘게요. 직접 쪼개서 재보시면 아마 놀라실 텐데, 그 결과가 C-8의 둘째 질문("그 화면만 쓰는 무거운 것이 있나")이 왜 "무거운"이 아니라 "그 화면만 쓰는"에 방점이 있는지를 알려줍니다.

자를 게 거의 없습니다

정리해봅시다. 61.3% 가 안 도는데, 자를 수 있는 건 이 정도입니다.

텍스트
 react-dom · react-router      바닥이라 못 자른다
 @sentry/*                     자르면 H-1 과 H-4 가 산 것을 되판다
 @base-ui/react                자르면 누를 때 멈칫한다
 우리 화면들                    쪼개도 1 kB 남짓

좀 허탈하지만, 이게 오늘의 진짜 결론입니다. 번들은 이미 자를 만큼 자른 상태이고, 남은 것은 대부분 값을 치르고 산 것들이라서, Step 4가 중요해집니다. 더 줄일 수 없다면, 최소한 모르는 사이에 늘어나지는 않게 해야 하니까요. 683 kB는 우리가 하나씩 이유를 알고 쌓은 숫자입니다. 다음에 누가 라이브러리를 하나 더 넣을 때, 그게 얼마짜리인지 알고 넣게 하는 것 — 예산 장치가 하는 일이 그겁니다.

💡 한 줄 정리

안 쓰는 코드를 다 찾아내도 자를 수 있는 것은 얼마 안 됩니다. 남은 것 대부분은 값을 치르고 산 것들이라, 줄이는 일보다 모르는 사이에 늘지 않게 지키는 일이 더 중요합니다.

🙋 학생 질문 — "튜터님, 그럼 애초에 라이브러리를 적게 쓰는 게 답 아닌가요?"

방향은 맞습니다. 다만 "적게"의 기준을 어디에 둘지가 어렵죠.

오늘 목록을 다시 보세요. @sentry/* 21%를 뺄 수 있는데, 그러면 사용자 화면에서 오류가 나도 우리가 모르게 되고 속도가 느려져도 알 수 없게 돼요. H-1과 H-4에서 그걸 얻으려고 일부러 넣은 겁니다.

@base-ui/react를 빼면 모달을 직접 만들어야 하는데, E-6에서 봤듯이 그 안에는 포커스를 가두는 것부터 낭독기 처리까지 여섯 줄짜리 일이 들어 있습니다. 직접 만들면 코드는 줄겠지만 그중 몇 개는 빠뜨리게 될 거예요.

그래서 질문을 이렇게 바꾸는 게 좋습니다. "적게 쓰자"가 아니라 "넣을 때 값을 알고 넣자"로요. 오늘 만든 예산 장치가 그 질문을 강제합니다. 새 라이브러리를 넣었는데 상한을 넘으면, 그 자리에서 "이게 그만한 값어치가 있나?"를 한 번은 생각하게 되니까요.


마무리

오늘 배운 핵심 세 가지

💡 하나. 안 쓰는 코드는 이름으로 찾아낼 수 있습니다. 브라우저가 실행한 코드를 재고 지도로 되돌리면 되는데, 도구가 준 이름을 그대로 믿기 전에 그 숫자가 원본 파일 크기와 견줘 말이 되는지 확인하세요. 저는 그걸 안 해서 한 번 크게 틀렸습니다.

💡 둘. 재는 도구와 줄일 대상은 다른 곳에 있습니다. Lighthouse는 우리가 지도를 감췄기 때문에 내역을 못 읽고, React의 자국은 개발 서버에만 있습니다. 어디를 볼지는 그 도구들로 정하되, 나아졌는지는 배포본으로 확인해야 합니다.

💡 셋. 자를 게 거의 없어서 지키는 일이 중요합니다. 61.3%가 안 도는데 자를 수 있는 건 1 kB 남짓이었고, 나머지는 전부 값을 치르고 산 것들이라 경고 대신 실제로 빌드를 세우는 장치를 만들었습니다.

다음 시간 예고

번들 이야기는 여기서 닫습니다. C-8에서 600 kB를 500 kB로 줄인 것부터 시작해서, 지난 시간에 안을 들여다보고, 오늘 이름을 찾고 상한을 걸어두는 데까지 왔어요.

지금 우리 앱은 이런 상태입니다. 화면이 있고, 서버와 이야기하고, 실시간으로 갱신되고, 로그인이 있고, 오류와 속도를 우리가 알아채고, 번들이 더 자라지 않게 막혀 있습니다. 처음 index.html 하나로 시작한 것치고는 꽤 멀리 왔죠.

여기서 더 가고 싶다면 커리큘럼에 몇 갈래가 더 준비돼 있습니다. 하나는 우리 앱을 여러 나라 말로 내보내는 일이에요. 글자만 바꾸면 될 것 같지만, 날짜와 숫자를 쓰는 방식이 나라마다 다르고 글자 길이가 달라지면서 화면이 깨지기도 합니다. 다른 하나는 여러 팀이 한 화면을 나눠 만드는 방법입니다. 지금은 우리 혼자 이 앱을 다 만들었지만, 회사가 커지면 피드 팀과 쪽지 팀이 따로 배포하고 싶어지거든요.

둘 다 오늘까지 쌓아온 것 위에 서 있습니다. 특히 오늘 만든 예산 장치는 저 두 갈래에서 훨씬 중요해져요. 팀이 늘고 언어가 늘면 번들은 아무도 안 보는 사이에 자라니까요.


과제

[탐구] 다른 화면에서 재보세요

오늘 우리는 홈 화면에서만 커버리지를 쟀는데, 그래서 ExplorePageDmPage가 "안 쓰는 코드"로 잡혔죠. 탐색 화면에서 재면 어떻게 될까요? scratch/h3c-coverage.mjsPAGE_URLhttp://localhost:4173/explore로 바꿔서 돌려보세요.

  • ExplorePage.tsx는 목록에서 사라지나요? 사라진다면 왜일까요?
  • 전체 안 쓰는 비율(61.3%)은 올라갈까요 내려갈까요? 재보기 전에 먼저 예상해보고, 그 다음 확인하세요.
  • 이 결과를 보고 나면 "안 쓰는 코드 61.3%"라는 문장에 무엇을 덧붙여야 정확해질까요?

⚠️ 마지막 질문이 핵심입니다. 오늘 우리가 인용한 숫자에 빠져 있는 조건이 하나 있어요.

[구현] CSS에도 상한을 걸어보세요

Step 4에서 만든 장치는 자바스크립트만 봅니다. 그런데 CSS도 사용자가 받는 파일이죠. 지금 44 kB 고요.

bundleBudget을 고쳐서 CSS 파일에도 상한을 걸어보고, 반드시 일부러 넘겨서 빌드가 멈추는 것까지 확인하세요.

  • 두 파일을 각각 다른 상한으로 검사하려면 함수를 어떻게 바꿔야 할까요?
  • CSS 하나가 넘고 JS는 안 넘었을 때, 오류 메시지만 보고 어느 쪽이 문제인지 바로 알 수 있나요?
  • 넘은 파일이 둘일 때 첫 번째에서 바로 멈추는 게 좋을까요, 둘 다 알려주고 멈추는 게 좋을까요?

[구현] 모달을 쪼개면 정말 32 kB가 빠질까요

Step 5에서 남겨둔 숙제입니다. @base-ui/react 32,631 B가 홈에서 안 도는데, 모달을 쪼개면 그만큼 빠질까요?

PostBody에서 PostModallazy로 바꿔 빌드하고 숫자를 확인한 뒤, 반드시 되돌리세요.

  • 주 청크에서 몇 kB가 빠지나요? 32,631 B와 견줘보고, 차이가 크다면 그 이유를 찾아보세요.
  • 힌트: src/components/ui/dialog.tsxPostModal 말고 또 누가 쓰고 있는지 찾아보세요. @base-ui/react를 쓰는 파일도 세어보시고요.
  • 이 결과를 보고 나면, C-8의 둘째 질문 "그 화면만 쓰는 무거운 것이 있나"에서 "그 화면만 쓰는""무거운" 중 어느 쪽이 더 중요한 조건인가요?

⚠️ 쪼개려고 보면 걸리는 게 하나 더 있습니다. 모달을 여는 버튼이 PostModal 안에 들어 있어요. 그래서 기다리는 동안 무엇을 보여줄지도 함께 정해야 합니다.

[탐구] 압축기가 다르면 얼마나 다를까요

Step 4에서 빌드 출력의 gzip(221.72 kB)과 우리 장치의 gzip(219,470 B)이 다르다는 걸 봤습니다.

여기에 하나를 더 얹어보죠. Node에는 brotliCompressSync라는 다른 압축 방식도 있습니다. 우리 번들을 그걸로 압축하면 얼마가 나오는지 재보세요.

  • gzip과 brotli 중 어느 쪽이 작나요? 차이가 몇 퍼센트인가요?
  • 실제 서버는 둘 중 무엇을 쓸까요? 우리가 그걸 정할 수 있나요?
  • 그렇다면 예산을 어느 압축 방식으로 재는 게 맞을까요? 정답이 하나가 아닐 수도 있습니다.

생각해볼 주제

1. 낡은 판단표

Step 5에서 우리는 C-8이 만든 표에 화면 하나가 빠져 있는 걸 발견했습니다. 표가 틀렸던 게 아니라 그 뒤에 앱이 자란 거였죠.

이런 일은 판단표에만 생기는 게 아닙니다. 팀이 정한 규칙, 문서에 적어둔 기준, 코드 리뷰 체크리스트 — 전부 만든 시점의 세상에 맞춰 만들어지는데, "낡았으니 다시 보자"를 언제 해야 할까요? 매번 하면 아무것도 못 하고, 안 하면 오늘 같은 일이 생깁니다. 이 판단을 사람의 기억에 맡기지 않으려면 무엇을 만들어둘 수 있을까요?

2. 상한을 정하는 사람

Step 4에서 우리는 상한을 225,000 B로 정했는데, 오늘 값에 5 kB 정도 여유를 준 숫자예요. 이 숫자는 누가 정해야 하고, 나중에 누군가 기능을 넣다가 이 상한에 걸렸을 때 그 사람은 무엇을 할 수 있을까요? 상한을 올리는 것도 선택지에 있어야 할까요, 아니면 절대 못 올리게 막아야 할까요?

상한을 올릴 수 있게 두면 걸릴 때마다 올려서 아무것도 못 막게 되고, 못 올리게 하면 정말 필요한 기능도 막힙니다. 이 사이에서 어떻게 균형을 잡을 수 있을까요?

3. 측정할 수 없는 것

오늘 Step 3에서 우리는 이상한 상황을 만났습니다. 재는 도구는 개발 서버에 있는데, 줄여야 할 대상은 배포본에 있었죠. 배포본에서는 아예 잴 수가 없었고요.

이건 React만의 이야기가 아닙니다. 측정 도구를 붙이면 측정 대상이 달라지는 일은 아주 흔해요. 오류 추적을 켜면 번들이 커지고, 속도 추적을 켜면 조금 느려집니다. H-4에서 우리가 61 kB를 내고 산 것이 정확히 그거였죠.

그렇다면 "재는 행위가 재려는 대상을 바꾼다"는 상황에서 우리는 무엇을 믿어야 하고, 어떤 경우에 측정을 포기하는 것이 오히려 옳은 선택일까요?

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안입니다. 직접 해보신 뒤에 펼쳐서 맞춰보세요.


🎯 [과제 1 예시답안] 다른 화면에서 재보세요

채점 포인트

항목 배점 확인 내용
ExplorePage가 목록에서 사라진 것을 확인하고 이유를 설명 6 그 화면을 열었으니 이제 실제로 실행된다
비율을 재기 전에 예상해보고 결과와 견줌 7 내려갈 것 같은데 올라간다
올라간 이유를 설명 7 탐색 화면이 홈보다 단순해서 안 도는 코드가 더 많다
"61.3%" 에 빠져 있던 조건을 짚음 5 「홈 화면에서」가 빠지면 무의미한 숫자다
합계 25

풀이 예시

주소만 바꿔서 다시 돌립니다.

Bash
node scratch/h3c-coverage.mjs http://localhost:4173/explore
텍스트
  전체   681,925 B
  실행됨 240,213 B (35.2%)
  안 씀  441,712 B (64.8%)

  안 쓴 것의 정체:
    122,942 B  react-dom
     73,318 B  react-router
     58,043 B  @sentry/core
     37,077 B  @base-ui/react
      ...
      3,447 B  src/routes/DmPage.tsx           우리 코드
      2,322 B  src/components/ui/dialog.tsx    우리 코드
      1,945 B  src/components/ui/card.tsx      우리 코드
      1,660 B  src/routes/PostDetailPage.tsx   우리 코드
      1,577 B  src/components/PostModal.tsx    우리 코드

ExplorePage.tsx가 사라졌습니다. 그 화면을 열었으니 코드가 실제로 돌았고, 안 쓰는 목록에서 빠진 거예요. 당연한 결과입니다.

그런데 비율은 61.3% 에서 64.8% 로 올라갔습니다. 화면 하나가 실행됐으니 안 쓰는 게 줄어들 것 같은데 반대로 늘었어요.

이유는 목록의 새 얼굴들이 말해줍니다. PostModal·ui/card가 새로 올라왔죠. 홈 피드는 카드를 열 장 그리면서 PostCard·PostBody·LikeButton·PostComments 같은 기계를 전부 돌립니다. 탐색 화면은 사진을 격자로 늘어놓기만 해요. 탐색 화면이 홈보다 훨씬 단순해서, 실행되는 코드 자체가 적습니다.

그래서 "안 쓰는 코드가 몇 퍼센트"라는 문장에는 반드시 조건이 붙어야 합니다.

텍스트
 (X)  우리 번들은 61.3% 가 안 쓰는 코드입니다
 (O)  홈 화면을 열었을 때 61.3% 가 안 돌았습니다

자주 나오는 실수

비율이 올라간 걸 보고 "측정이 잘못됐나?" 하고 도구를 의심하는 경우가 많습니다. 그럴 때 확인할 것은 출력 맨 위의 사진 10/10이에요. 화면이 제대로 떴는데 나온 숫자라면 도구가 아니라 우리 예상이 틀린 겁니다.

💡 튜터의 한마디

이 과제의 진짜 목적은 숫자 하나를 더 얻는 게 아니라, 오늘 우리가 인용한 61.3% 가 한 화면에서 잰 값이라는 걸 몸으로 아는 겁니다. 실무에서 번들 보고서를 공유할 때 이 조건을 안 붙이면, 받아 본 사람이 전혀 다른 결론을 내립니다.


🎯 [과제 2 예시답안] CSS에도 상한을 걸어보세요

채점 포인트

항목 배점 확인 내용
파일마다 다른 상한을 걸 수 있는 형태로 고침 8 목록을 받아 도는 모양
일부러 넘겨서 빌드가 멈추는 것까지 확인 7 만들고 안 깨보면 확인한 게 아니다
오류 메시지에 어느 파일인지 드러남 5 이름표를 붙였는가
넘은 것이 여럿일 때 어떻게 할지 정하고 근거를 댐 5 한 번에 다 알려주는 쪽을 권장
합계 25

풀이 예시

상한을 하나만 받던 것을 목록으로 바꿉니다.

TypeScript
// apps/web-spa/vite.config.ts
const BUDGETS = [
  { label: '첫 화면 자바스크립트', match: /^index-.*\.js$/, maxGzipBytes: 225_000 },
  { label: '스타일', match: /^index-.*\.css$/, maxGzipBytes: 10_000 },
];

function bundleBudget(budgets: typeof BUDGETS): Plugin {
  return {
    name: 'bundle-budget',
    apply: 'build',
    closeBundle() {
      const dir = fileURLToPath(new URL('./dist/assets', import.meta.url));
      const files = readdirSync(dir);
      const over: string[] = [];

      for (const budget of budgets) {
        const file = files.find((name) => budget.match.test(name));
        if (!file) throw new Error(`${budget.label} 파일을 못 찾았습니다.`);

        const gzipBytes = gzipSync(readFileSync(join(dir, file))).length;
        const line = `${budget.label} ${file} gzip ${gzipBytes.toLocaleString()} B / 예산 ${budget.maxGzipBytes.toLocaleString()} B`;
        if (gzipBytes > budget.maxGzipBytes) {
          over.push(`  ${line} (${(gzipBytes - budget.maxGzipBytes).toLocaleString()} B 넘었습니다)`);
        } else {
          console.log(`[예산] ${line}`);
        }
      }

      if (over.length > 0) {
        throw new Error(`번들 예산 초과 ${over.length}건\n${over.join('\n')}`);
      }
    },
  };
}

통과할 때:

텍스트
 [예산] 첫 화면 자바스크립트 index-Bu1l6ZK3.js gzip 219,471 B / 예산 225,000 B
 [예산] 스타일 index-DeAUX3ft.css gzip 8,844 B / 예산 10,000 B

둘 다 일부러 낮춰서 넘겨보면:

텍스트
 Error: 번들 예산 초과 2건
   첫 화면 자바스크립트 index-Bu1l6ZK3.js gzip 219,471 B / 예산 200,000 B (19,471 B 넘었습니다)
   스타일 index-DeAUX3ft.css gzip 8,844 B / 예산 8,000 B (844 B 넘었습니다)

종료 코드는 1입니다.

넘은 것이 여럿일 때 어떻게 할까? 위 코드는 모아서 한 번에 알려줍니다. 첫 번째에서 바로 멈추면, 고치고 다시 돌렸을 때 두 번째가 나와서 또 고쳐야 해요. 빌드가 몇 분 걸리는 프로젝트라면 이 차이가 꽤 큽니다.

자주 나오는 실수

match/\.css$/처럼 느슨하게 쓰면 나중에 CSS 파일이 늘었을 때 엉뚱한 걸 잡습니다. 그리고 find는 첫 번째 하나만 돌려주니, 파일이 여럿이 되는 순간 조용히 하나만 검사하게 돼요. 지금은 각각 하나뿐이라 안 드러나지만, 화면을 쪼개기 시작하면 바로 문제가 됩니다.

💡 튜터의 한마디

"일부러 넘겨서 확인하라"를 배점에 넣은 이유가 있습니다. 막아주는 코드는 평소에 아무 일도 안 하는 게 정상이라, 고장 나 있어도 티가 안 나요. 조용한 것이 잘 돌고 있다는 뜻인지, 아예 안 돌고 있다는 뜻인지는 한 번 깨봐야만 갈립니다.


🎯 [과제 3 예시답안] 모달을 쪼개면 정말 32 kB가 빠질까요

채점 포인트

항목 배점 확인 내용
실제로 쪼개서 빌드하고 숫자를 확인 6 32,631 B와 크게 다르다
ui/dialog를 또 누가 쓰는지 찾아냄 7 ConfirmDialog
왜 안 빠지는지 설명 7 다른 곳도 쓰는 코드는 주 청크에 남는다
둘째 질문의 방점이 어디인지 답함 5 "무거운" 이 아니라 "그 화면만 쓰는"
합계 25

풀이 예시

PostBody에서 모달을 늦게 받아오도록 바꿉니다. 여는 버튼이 모달 안에 있으니 기다리는 동안 보여줄 것도 함께 정해야 해요.

tsx
// apps/web-spa/src/components/PostBody.tsx
const PostModal = lazy(() =>
  import('./PostModal').then((m) => ({ default: m.PostModal })),
);

// ... 렌더 안에서
<Suspense fallback={<p className="px-3 pt-1 pb-3 text-sm text-faint">댓글 불러오는 중…</p>}>
  <PostModal ... />
</Suspense>

빌드 결과입니다.

텍스트
 dist/assets/PostModal-BWhQDvq3.js      2.15 kB │ gzip: 1.18 kB
 dist/assets/index-Be4BfKrj.js        681.75 kB │ gzip: 221.36 kB
텍스트
 쪼개기 전   683.01 kB   gzip 219,471 B
 쪼갠 뒤     681.75 kB   gzip 219,130 B
 ─────────────────────────────────────
 빠진 것     1.26 kB     gzip    341 B

32,631 B를 기대했는데 1.26 kB입니다. 스무 배 넘게 차이가 나요.

이유는 힌트를 따라가면 나옵니다.

텍스트
 src/components/ConfirmDialog.tsx    ./ui/dialog 를 쓴다
 src/components/PostModal.tsx        ./ui/dialog 를 쓴다

ConfirmDialog는 C-4에서 만든 댓글 삭제 확인 상자입니다. 앱 전체에 하나 떠 있고 항상 함께 실려요. 모달 기계를 PostModal 혼자 쓰는 게 아닙니다. 그러니 PostModal을 떼어내도 그 기계는 주 청크에 그대로 남습니다.

@base-ui/react를 쓰는 파일을 세어보면 더 분명해집니다.

텍스트
 src/components/ui/dialog.tsx
 src/components/ui/avatar.tsx
 src/components/ui/button.tsx

아바타와 버튼은 피드 어디에나 있죠. 이 꾸러미는 애초에 뗄 수 있는 게 아니었습니다.

그래서 둘째 질문의 방점은 "그 화면만 쓰는" 쪽입니다. 무겁기만 하고 여기저기서 쓰는 것은 쪼개도 안 빠져요. 회원가입이 잘 빠졌던 건 폼 도구와 검사 도구를 거기서만 썼기 때문입니다.

자주 나오는 실수

커버리지 표를 "이만큼 지울 수 있다"로 읽는 것이 가장 흔한 실수입니다. 그 표는 "이만큼 안 돌았다"만 말해요. 안 돌았다는 것과 여기서만 쓴다는 것은 완전히 다른 이야기입니다.

💡 튜터의 한마디

오늘 세 번의 실험에서 예상과 결과가 이렇게 갈렸습니다.

텍스트
 DmPage      3,447 B 예상    3,380 B 빠짐    거의 맞음
 PostModal  32,631 B 예상    1,260 B 빠짐    스무 배 어긋남

앞엣것은 그 파일 안에만 있는 코드였고, 뒤엣것은 여러 곳이 나눠 쓰는 코드였어요. 도구가 준 숫자를 행동의 근거로 삼기 전에 한 번은 실제로 해보고 맞춰보는 것 — 오늘 배운 것 중에 실무에서 가장 자주 쓰게 될 습관입니다.


🎯 [과제 4 예시답안] 압축기가 다르면 얼마나 다를까요

채점 포인트

항목 배점 확인 내용
brotli로 재고 gzip과 견줌 7 14~15% 작다
서버가 무엇을 쓸지는 우리가 못 정한다는 것을 짚음 7 브라우저와 서버가 협상한다
예산을 어느 쪽으로 잴지 근거를 대고 고름 8 정답이 하나가 아니다
압축 방식과 무관한 지표도 있다는 것을 언급 3 압축 전 크기
합계 25

풀이 예시

JavaScript
const { gzipSync, brotliCompressSync } = require('node:zlib');
const fs = require('node:fs');
const file = fs.readdirSync('dist/assets').find((f) => f.startsWith('index-') && f.endsWith('.js'));
const bytes = fs.readFileSync('dist/assets/' + file);
console.log('원본  ', bytes.length);
console.log('gzip  ', gzipSync(bytes).length);
console.log('brotli', brotliCompressSync(bytes).length);
텍스트
 원본     683,011 B
 gzip     219,470 B   (원본의 32.1%)
 brotli   187,543 B   (원본의 27.5%)

brotli가 31,927 B 작습니다. gzip 대비 14.5% 예요.

서버는 무엇을 쓸까요? 우리가 정하는 게 아닙니다. 브라우저가 요청할 때 "나는 gzip도 되고 brotli도 됩니다"라고 알려주고, 서버가 그중에서 고릅니다. 요즘 브라우저는 대부분 brotli를 받을 수 있어서, 서버가 지원하면 실제 전송은 187 kB 쪽에 가까워요.

그렇다면 우리 상한 225,000은 아무도 실제로 받지 않는 숫자입니다. gzip으로 재고 있으니까요.

그럼 brotli로 바꿔야 할까요? 여기서 정답이 갈립니다.

  • gzip 으로 재는 쪽: 최악의 경우를 잡습니다. brotli를 못 받는 환경에서도 이만큼은 보장된다는 뜻이에요. 보수적이고, 넘었을 때 진짜로 넘은 겁니다.
  • brotli 로 재는 쪽: 대부분의 사용자가 실제로 받는 양에 가깝습니다. 다만 서버 설정이 바뀌면 우리 숫자와 현실이 어긋나요.
  • 압축 전 크기로 재는 쪽: 압축 방식과 무관합니다. 브라우저가 압축을 풀고 나서 파싱하고 실행하는 비용은 압축 전 크기를 따라가니까, 이쪽이 더 정직한 지표라는 의견도 있어요.

어느 쪽이든 한 가지를 골라 계속 그것으로 재는 것이 제일 중요합니다. 오늘 Step 4에서 본 것처럼, 같은 gzip 안에서도 구현이 다르면 2 kB가 갈리니까요.

자주 나오는 실수

"brotli가 작으니까 상한도 낮추자" 하고 225,000을 190,000으로 바꾸는 경우가 있습니다. 그런데 검사는 여전히 gzip으로 하고 있으면 219,470이 190,000을 넘어서 빌드가 바로 실패합니다. 재는 방법과 상한 숫자는 항상 짝으로 움직여야 해요.

💡 튜터의 한마디

압축 이야기가 좀 지엽적으로 느껴질 수 있는데, 실무에서 이걸로 논쟁이 자주 붙습니다. "우리 번들 200 kB예요"라는 말이 사람마다 다른 숫자를 가리키거든요. 압축 전인지 후인지, 후라면 무슨 방식인지를 안 붙이면 같은 앱을 두고 세 배 차이 나는 숫자가 오갑니다.


🤔 [생각해볼 주제 1] 낡은 판단표

문제 상황 요약

C-8에서 만든 판단표에 화면 하나가 빠져 있었습니다. 표가 틀린 게 아니라 그 뒤에 앱이 자란 거였어요. 이런 일은 판단표뿐 아니라 팀 규칙·문서·체크리스트 어디에나 생깁니다. 그럼 "다시 볼 때"를 어떻게 정해야 할까요?

튜터의 가이드 및 해설

먼저 왜 이게 어려운지부터 봅시다. 낡은 문서의 문제는 낡았다는 신호가 안 온다는 겁니다. 코드는 컴파일이 안 되거나 검사가 빨개지면서 스스로 알려줘요. 문서는 조용히 틀린 채로 남아 있고, 읽는 사람은 그걸 참이라고 믿습니다.

실무에서 쓰는 방법은 대략 세 갈래입니다.

하나. 시간으로 정한다. 분기마다 한 번씩 다시 본다는 식이죠. 간단하지만 잘 안 지켜집니다. 그리고 바쁠 때 제일 먼저 밀려요.

둘. 사건에 붙인다. "화면을 새로 만들면 그 표를 갱신한다"처럼 작업 절차에 끼워 넣습니다. 새 화면을 만드는 사람이 표를 아예 모를 수 있다는 게 약점이에요.

셋. 숫자가 대신 말하게 한다. 이게 오늘 우리가 한 방식입니다. 표를 사람이 기억할 필요 없이, 커버리지를 돌리면 "우리 코드 중 이게 제일 크게 안 돕니다"가 목록으로 나와요. DmPage는 우리가 찾아간 게 아니라 도구가 위로 올려준 겁니다.

세 번째가 제일 튼튼합니다. 사람의 기억에 기대는 대신 현실을 다시 재게 만드니까요. 다만 도구를 아무도 안 돌리면 소용없으니, 여기에 두 번째를 겹치는 게 좋습니다. 배포 전에 자동으로 한 번 돌리고 결과를 남기는 식으로요.

한 가지 덧붙이면, 문서에 만든 날짜와 그때의 전제를 적어두는 것만으로도 꽤 달라집니다. C-8의 표에 "화면 네 개 기준, 2026-08 작성"이 적혀 있었다면, 다섯 번째 화면을 만든 사람이 그 줄을 보고 한 번은 멈칫했을 거예요.

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

"문서가 낡는 건 못 막습니다. 대신 낡았다는 걸 사람이 아니라 도구가 알아채게 만듭니다. 저희는 판단 기준을 문서로만 두지 않고, 그 판단에 쓰인 숫자를 다시 뽑는 도구를 함께 둡니다. 그러면 현실이 바뀌었을 때 문서를 안 봐도 숫자가 먼저 달라져 있습니다."


🤔 [생각해볼 주제 2] 상한을 정하는 사람

문제 상황 요약

번들 상한을 225,000 B로 정했습니다. 누가 이 숫자를 정해야 할까요? 그리고 나중에 누군가 기능을 넣다가 걸렸을 때, 상한을 올리는 것도 선택지에 있어야 할까요?

튜터의 가이드 및 해설

먼저 상한을 못 올리게 막는 쪽을 생각해봅시다. 언뜻 단호해서 좋아 보이는데, 실제로는 금방 무너집니다. 정말 필요한 기능이 막히면 사람들은 규칙을 우회할 방법을 찾아요. 검사를 잠깐 끄거나, 예외 목록에 파일을 추가하거나, 아예 다른 저장소로 옮깁니다. 막을 수 없는 것을 막으려 들면 규칙이 우회당하고, 우회당한 규칙은 없느니만 못해요.

반대로 아무나 올릴 수 있게 두면 걸릴 때마다 숫자만 올라갑니다. 몇 달 뒤엔 상한이 400 kB가 되어 있고 아무것도 안 막게 되죠.

실무에서 자리 잡은 방식은 올리는 것을 허용하되 그 일을 눈에 띄게 만드는 것입니다.

상한 숫자를 코드에 두면 그것을 바꾸는 일이 코드 변경이 됩니다. 리뷰에 올라오고, 누군가는 "왜 올리죠?" 하고 물어요. 그 질문 한 번이 이 장치의 핵심입니다. 막는 게 목적이 아니라 결정을 의식하게 만드는 게 목적이거든요.

그러니 상한은 "넘으면 안 되는 선"이 아니라 "넘으려면 설명해야 하는 선"으로 두는 게 맞습니다. 팀에서 정하되, 올릴 때 근거를 남기게 하는 거죠. 오늘 우리가 만든 오류 메시지가 몇 바이트 넘었는지를 정확히 알려주는 것도 그래서입니다. "19,471 B 넘었습니다"는 "이 기능이 19 kB 짜리다"라는 뜻이고, 그 숫자가 있어야 값어치를 따질 수 있어요.

숫자 자체는 오늘 값에서 출발하는 게 무난합니다. 이상적인 목표치를 잡으면 처음부터 빨간 상태라 아무도 안 봐요. 지금 값에 조금 여유를 두고 시작해서, 줄어들면 그때 상한도 같이 내리는 편이 낫습니다.

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

"번들 상한은 넘으면 안 되는 선이 아니라 넘으려면 설명해야 하는 선으로 뒀습니다. 올리는 것 자체를 막으면 규칙이 우회당하더라고요. 대신 상한을 코드에 두고 초과량을 정확히 알려주게 해서, 올리는 순간 리뷰에 올라오고 '이 기능이 19 kB 짜리인데 그만한 값이 있나'를 팀이 한 번은 이야기하게 만들었습니다."


🤔 [생각해볼 주제 3] 측정할 수 없는 것

문제 상황 요약

재는 도구는 개발 서버에 있는데 줄여야 할 대상은 배포본에 있었습니다. 측정 도구를 붙이면 대상이 달라지는 일은 흔해요. 그렇다면 무엇을 믿어야 하고, 언제 측정을 포기하는 게 옳을까요?

튜터의 가이드 및 해설

이 문제를 정리하려면 측정값을 두 종류로 나눠 보는 게 좋습니다.

절대값과 순위입니다.

계측을 붙이면 절대값은 거의 항상 오염됩니다. 개발 서버에서 잰 렌더 시간은 배포본보다 훨씬 느려요. 개발 전용 검사가 잔뜩 돌고 있으니까요. 이 숫자를 "우리 앱은 이만큼 걸립니다"로 쓰면 틀립니다.

그런데 순위는 대체로 살아남습니다. 개발 서버에서 PostCard가 다른 컴포넌트보다 다섯 배 오래 걸렸다면, 배포본에서도 대체로 제일 무거워요. 계측 부담이 모든 컴포넌트에 비슷하게 얹히기 때문입니다.

그래서 실무의 기본 태도는 이렇습니다. "어디를 볼지"는 오염된 도구로 정하고, "나아졌는지"는 진짜 판에서 확인한다. 오늘 Step 3의 결론이 정확히 이거였고, H-4에서 실험실 측정과 사용자 측정을 갈랐던 것도 같은 이야기입니다.

측정을 포기하는 게 옳은 경우도 분명히 있습니다. 판단 기준은 "측정으로 얻는 것이 측정 때문에 잃는 것보다 큰가"예요.

H-4에서 우리는 속도 추적을 켜면서 61 kB를 냈습니다. 그만한 값이 있다고 판단했죠. 사용자가 실제로 겪는 속도를 아는 것이 61 kB보다 중요했으니까요.

반대로 모든 요청을 다 기록하는 설정은 대부분의 팀이 안 씁니다. 표본을 줄여도 알고 싶은 건 거의 다 알 수 있는데, 다 기록하면 비용과 부담이 몇 배로 뛰거든요. 전부 재는 것과 충분히 재는 것은 다릅니다.

한 가지 더. 아예 못 재는 것도 있습니다. 사용자가 화면을 보고 느끼는 답답함 같은 건 어떤 도구로도 숫자가 안 나와요. 그럴 땐 측정을 억지로 만들어내는 것보다 재지 못한다는 걸 인정하고 다른 근거를 쓰는 편이 낫습니다. 사용자 문의라든가, 직접 써보는 것이라든가요. 숫자가 없다고 판단을 못 하는 건 아닙니다.

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

"계측은 대상을 바꾸기 때문에 절대값은 잘 안 믿습니다. 대신 순위는 살아남더라고요. 그래서 어디를 손볼지는 개발 환경 도구로 정하고, 실제로 나아졌는지는 배포본을 다시 만들어서 확인합니다. 그리고 재는 비용이 얻는 것보다 크면 측정을 안 하는 것도 선택지에 둡니다. 전부 재는 것과 충분히 재는 것은 다르니까요."

전체 목록 리액트