---
title: '실무에서 <em>성능 개선</em>을 진행하는 순서: 준비, 관측, 대응, 검증, 측정'
tags:
  - web-performance
  - software-engineering
  - essay
published: true
date: 2026-10-26 17:59:00
updated: 2026-10-26 18:28:51
description: '성능 개선은 기법보다 순서가 먼저라고 생각한다. 지원 범위와 의존성을 먼저 정리하고, 기법보다 먼저 재고, 무엇부터 고칠지 정하고, 원인이 남의 코드나 인프라에 있을 때 대응을 고르고, 깨뜨리지 않았음을 증명해 지키는 장치로 남기고, 숫자를 등급으로 나눠 보고하는 실무의 흐름을 정리했다.'
art:
  undraw: route-planning
---

## Table of Contents

## 기법보다 순서가 먼저다

최근 두 달 동안 한 서비스의 성능을 개선하면서, 기법을 적용하는 데 쓴 시간은 생각보다 짧았다. 쓰지 않는 코드를 지우고, 무거운 모듈을 나중에 불러오고, 중복 의존성을 합치는 것보다 그 앞뒤의 일이 오래 걸렸다. 무엇이 무거운지 찾고, 원인이 내 코드에 없을 때 어떻게 할지 정하고, 개선 효과를 숫자로 확인하는 데 대부분의 시간을 썼다.

첫 진입 JavaScript 전송량은 운영에서 40.1% 줄었다. 다만 이 번들 개선으로 사용자가 더 빠르게 느끼는지는 아직 운영 지표로 확인하지 못했다. 기존 계측으로는 그 변화를 판단하기 어려워 수집 경로를 다시 만들고 있다. 1차 번들 개선의 속도 효과는 샌드박스나 로컬에서 측정한 값으로 설명한다.

뒤이은 2차 개선은 프레임워크 업그레이드와 번들러 전환, CSS 감량, 배너 등장 시 레이아웃 이동 보정, 숫자 애니메이션 개선 등을 함께 배포한 10월 릴리스였다. 이때는 기존 대시보드에서도 CLS p75가 절반 가까이 줄었고, FCP와 LCP도 내려가기 시작했다. 숫자 애니메이션을 바꾼 뒤 로컬에서 측정한 메인 스레드 점유율은 85.8%에서 49.6%로 줄었다. 운영 지표는 배포 직후에만 살펴봤으므로, 시간이 지난 뒤에도 이 변화가 얼마나 유지되는지는 아직 확인하지 못했다.

사내에서는 서비스가 빨라졌다는 반응을 꽤 받았지만, 운영 지표로 확인한 결과와는 구분했다. 외부 SDK를 다시 조립한 경험은 FEConf 2026 발표로도 이어졌다.

이 글에서는 그 경험을 바탕으로 성능 개선을 진행하는 순서와 각 단계에서 했던 판단을 정리했다. 『프런트엔드 성능 최적화 Deep Dive』에서 기법 하나하나를 다뤘다면, 여기서는 그 기법을 언제, 어떤 근거로 선택하는지 이야기하려고 한다. 세부 내용은 각 절에 연결한 글에서 더 자세히 다뤘다.

전체 흐름은 아래와 같다.

<LiveDemo src="/demos/performance-workflow/flow.html" title="지원 범위와 의존성을 정리하고, 원인의 위치에 따라 대응을 고른 뒤 검증과 측정으로 모이는 성능 개선 흐름" aspectRatio="840 / 1032" minHeight={887} />

지원 범위를 정하고 의존성과 서버 코드 유입을 정리한 다음, 운영 대시보드를 점검하고 조건을 고정해 랩에서 측정한다. 관측 중에 새 중복이나 서버 코드 유입이 드러나면 다시 정리한다. 원인을 찾으면 무엇이 문제인지와 누구의 코드인지를 구분해 우선순위를 정한다. 내 코드에는 원인에 맞는 기법을 적용하고, 남의 코드나 인프라에 원인이 있으면 어떻게 고칠 수 있을지부터 판단한다. 변경 후에는 동작을 검증하고 재발 방지 장치를 마련한다. 측정 결과를 보고하고 배포 뒤 운영 계측을 다시 확인하면, 그 결과를 바탕으로 다음 작업을 정할 수 있다.

이 순서에서는 앞에서 확인한 내용을 다음 판단에 쓴다. 지원 범위를 알아야 무엇을 지워도 되는지 판단할 수 있고, 측정 결과가 있어야 무엇부터 고칠지 정할 수 있다. 원인이 있는 위치까지 알아야 직접 고칠지, 다른 팀이나 벤더에 요청할지도 선택할 수 있다.

> 예시는 2026년 8월 말부터 10월까지 개선한, 앱 웹뷰에서 열리는 Next.js 서비스 하나의 사례다. 앱 웹뷰, 서버 렌더링, 모노레포라는 조건에서 나온 판단이라 정적 사이트나 클라이언트 렌더링만 하는 앱에는 그대로 맞지 않는 부분이 있을 수 있다. 다루는 범위도 주로 로딩 성능이고, 상호작용 성능(INP)은 일부만 다룬다. 운영 수치는 CDN이 내려보낸 바이트와 RUM 집계다. 샌드박스 수치는 iPhone 16 Pro Max 에뮬레이션, CPU 4배 감속, 캐시를 비운 첫 진입에서 기록한 Chrome 트레이스의 중앙값이다. 집계 방식이 다른 값은 본문에 따로 표시했다. 외부 벤더와 일부 패키지 이름은 가명이다.

## 성능을 보기 전에 정리할 것

Performance 탭을 열기 전에 지원 브라우저와 설치된 의존성부터 살펴본다. 무엇을 제거해도 되는지 판단하려면 지원 범위를 알아야 하고, 제거할 대상을 찾으려면 어떤 패키지가 설치돼 있는지 알아야 하기 때문이다. 서버 전용 코드의 클라이언트 유입도 의존성을 살피면서 함께 확인한다.

지원 범위는 처음에 정해 두고 이후 판단의 기준으로 삼는다. 반면 의존성과 서버 코드 유입은 한 번 확인하고 끝나지 않는다. 관측 중에 새 중복이나 유입 경로가 드러나면 다시 돌아와 점검하게 된다.

지원 범위가 정해져 있어야 폴리필을 지울지, 문법을 그대로 내보낼지, 외부 라이브러리를 고쳐서라도 구형 브라우저를 지원할지 판단할 수 있다. 중복 의존성을 정리하면 번들에서 같은 코드가 여러 번 잡히는 이유도 알 수 있다. 이때 구버전을 고정한 패키지까지 추적해 두면, 직접 고칠 수 있는 것과 소유 팀에 요청해야 하는 것을 구분할 수 있다. 요청이 어려운 패키지는 뒤에서 다른 대응을 고를 때 다시 살펴본다.

이렇게 정리한 결과는 다른 작업에도 쓸 수 있다. 지원 범위 설정은 팀의 다른 서비스와 공유하고, 정리된 의존성 덕분에 다음 분석에서는 중복부터 다시 추적하는 수고를 줄일 수 있다.

## 지원 범위를 정한다

지원 브라우저는 문서로 남겨 둔다. 기준이 없으면 "이 폴리필을 지워도 되는가", "이 문법을 그대로 내보내도 되는가"를 매번 논의해야 한다. 팀이 함께 쓰는 [browserslist](https://github.com/browserslist/browserslist) 설정을 패키지로 만들면 빌드 타깃도 같은 기준에 맞출 수 있다. 실제로 이 설정은 지금 다른 서비스 4곳에서도 쓰고 있다.

타깃은 modern과 legacy로 나눠 두는 편이 좋다고 생각한다. modern은 프레임워크의 기본 지원 브라우저를 따르고, legacy는 실제 사용 환경을 기준으로 잡는다. 앱 웹뷰에는 업데이트가 멈춘 구형 Android WebView나 iOS 버전에 묶인 구형 Safari가 남아 있어, 프레임워크 기본값으로 모든 사용자를 지원하기 어려울 수 있다. legacy의 하한을 정할 때는 RUM이나 접속 로그에 남은 브라우저 분포를 근거로 삼는다.

이 기준은 빌드 설정을 마친 뒤에도 자주 쓰인다. 외부 SDK에서 폴리필을 제거할 때는 SDK가 쓰는 웹 API의 지원 하한을 타깃과 하나씩 대조한다. 무엇을 근거로 제거했는지 설명할 수 있어서 같은 논의를 반복할 필요가 줄어든다. 문법 타깃과 웹 API 지원은 별도로 확인해야 한다. ES2017로 빌드했다고 해서 `TextEncoder`나 Web Crypto를 쓸 수 있다는 보장은 없다.

설정을 적용한 것만으로 크기가 얼마나 줄었는지는 재지 않았다. 지원 범위는 크기를 줄이는 수단이라기보다 이후 판단의 기준이라서, 그 가치를 크기로 증명하려 하지 않아도 된다고 생각한다.

## 의존성을 분석하고 정리한다

의존성은 아래 순서로 본다.

1. **중복을 찾는다.** [pnpm why](https://pnpm.io/cli/why)로 같은 패키지가 몇 버전 설치돼 있는지 확인한다.
2. **중복이면 크기보다 먼저 정합성을 본다.** 같은 역할을 하는 객체가 런타임에 두 개 생기는지 확인한다. 내가 맡았던 서비스에서는 앱과 통신하는 브릿지 패키지가 두 버전으로 함께 설치돼 각자 네이티브 브릿지 인스턴스를 만들었고, 앱과의 통신이 엇갈렸다. 중복을 정리해 줄어든 First Load JS는 12kB에 불과했지만, 정리한 이유는 이 정합성 문제였다. 앱 웹뷰의 브릿지와 환경 분기를 정리한 과정은 [여러 웹뷰에서 하나의 웹 운영하기](/2026/09/one-web-on-multiple-webviews)에 따로 적었다.
3. **누가 구버전을 고정했는지 거슬러 올라간다.** 내 `package.json`에서 맞출 수 있는 버전은 바로 맞추고, 다른 패키지가 고정해 둔 것은 목록으로 남긴다. 그 목록은 소유 팀에 요청하거나, 뒤의 "원인이 남의 코드에 있을 때"에서 다른 방법을 고르는 데 쓴다.
4. **사용처를 센다.** 의존성을 더 가벼운 라이브러리로 바꾸기 전에, 무슨 함수를 몇 곳에서 쓰는지부터 센다. lodash-es에서 실제로 쓰는 함수가 `uniq` 하나뿐이라면 `Array.from(new Set())`으로 바꾸고 의존성 자체를 지우면 된다.
5. **순환 import도 이때 확인한다.** [dpdm](https://github.com/acrazing/dpdm) 같은 도구로 찾아 끊고, CI 검사로 다시 생기지 않게 막는다.

마지막 항목은 크기와 직접 관계가 없어 보이지만, 순환 import가 번들러의 모듈 병합과 얽히면 런타임 동작이 바뀔 수 있다. 모듈 스코프 싱글톤이 두 개가 된 사례를 [Next.js turbopack에서 싱글톤이 두 개가 됐다](/2026/08/turbopack-scope-hoisting-singleton-split)에 정리했다.

의존성을 정리하면서 가장 어려웠던 것은 peer 의존성이었다. 오래된 코드베이스에 여러 외부 패키지가 그때그때 추가돼, 패키지마다 요구하는 peer 버전 범위가 달랐다. 한 패키지를 올리면 다른 패키지의 peer 범위를 벗어나고, 이를 맞추려고 또 다른 패키지를 바꾸는 일이 반복됐다. 게다가 pnpm은 peer 의존성이 있는 패키지를 해석된 peer 조합마다 따로 설치한다([How peers are resolved](https://pnpm.io/how-peers-are-resolved)). 같은 버전이어도 peer 조합이 다르면 두 번 설치될 수 있다. lockfile에서 같은 버전 뒤에 서로 다른 peer 조합이 붙은 항목을 보면 이런 중복을 찾을 수 있다.

peer 의존성은 React나 앱 브릿지처럼 기준이 되는 패키지의 버전부터 정하고, 나머지를 거기에 맞추는 편이 낫다고 생각한다. 사내 패키지를 만드는 사람들에게도 실제로 테스트한 범위 안에서 peer 버전을 넓게 지정해 달라고 요청했다. 테스트한 메이저 버전을 `^17.0.0 || ^18.0.0 || ^19.0.0`처럼 나열하고, 검증하지 않은 미래 버전까지 허용하는 `>=`는 쓰지 않는 기준이다. 범위를 좁게 잡을수록 사용하는 서비스에서 버전 충돌을 겪을 가능성이 커진다. 이 기준은 교육 자료 [패키지 가이드 4탄: dependencies, devDependencies, peerDependencies](https://research.yceffort.kr/slides/best-practice-for-package-4)에 정리해 두었다.

overrides로 버전을 강제로 맞추는 것은 임시방편으로만 쓰고, 원인 패키지가 고쳐지면 지운다. 실제로 로그 어댑터 패키지를 새 버전으로 올린 뒤에야 SDK 버전을 강제로 맞추던 override를 지울 수 있었다. 그래도 모든 중복을 하나로 모으지는 못했다. react-intersection-observer와 user-agent 관련 패키지는 세 버전이 설치돼 있던 것을 두 버전으로 줄이는 데 그쳤다.

## 서버 코드가 클라이언트로 가고 있는지 확인한다

설치된 의존성을 살핀 다음에는 서버 전용 패키지가 클라이언트 번들에도 들어가는지 확인한다. Next.js처럼 서버와 클라이언트 코드가 한 저장소에 섞여 있으면, 서버용 로거나 HTTP 래퍼를 공용 모듈에서 가져오면서 클라이언트 번들에도 포함하기 쉽다.

클라이언트 청크를 번들 분석기로 열어 보면 이런 패키지를 찾을 수 있다. 내가 맡았던 서비스에서는 파일에 로그를 쓰는 서버용 로거(winston)가 클라이언트 번들에 들어 있었다. First Load 기준으로 256kB였고, 단일 항목 중 가장 컸다. Node 내장 모듈을 흉내 내는 buffer 폴리필도 함께 실려 있었다.

이런 경우에는 번들에서 패키지를 제외하는 설정과 클라이언트의 호출 경로를 함께 살펴야 한다.

1. **번들러 설정으로 클라이언트 빌드에서 뺀다.** webpack이라면 클라이언트 빌드에서만 해당 패키지의 alias를 `false`로 둔다.

   ```js:next.config.js
   webpack: (config, {isServer}) => {
     if (!isServer) {
       config.resolve.alias = {...config.resolve.alias, winston: false}
     }
     return config
   },
   ```

   Turbopack이라면 [`turbopack.resolveAlias`](https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#resolving-aliases)의 `browser` 조건을 쓴다. 클라이언트 빌드에서만 대체 모듈을 가리키게 하는 방식이다. 문서에는 `false` 대신 대체할 모듈 경로를 지정하는 형태만 나와 있으므로, 빈 모듈 파일을 하나 두고 그 경로를 지정하면 된다.

   ```js:next.config.js
   turbopack: {
     resolveAlias: {
       winston: {browser: './src/lib/empty-module.js'},
     },
   },
   ```

   ```js:src/lib/empty-module.js
   module.exports = {}
   ```

2. **클라이언트가 그 코드를 호출하지 않게 만든다.** 로거를 모듈 최상단에서 만들고 있으면 import하는 순간 생성 코드가 실행되어 번들러가 지울 수 없다. 처음 로깅할 때 만들도록 바꿔야 클라이언트가 그 코드에 도달하지 않는다. 번들에서 빼는 것과 호출하지 않게 만드는 것은 짝으로 생각하는 편이 좋다.
3. **진입점을 나눈다.** HTTP 클라이언트 래퍼처럼 서버와 클라이언트가 함께 쓰는 모듈은 서버용과 클라이언트용 진입점을 따로 둔다.
4. **다시 들어오지 않게 막는다.** 서버 전용 모듈에 [server-only](https://www.npmjs.com/package/server-only) 가드를 붙이면 클라이언트에서 import할 때 빌드가 실패한다. 여기서는 App Router의 Server Component를 기준으로 설명한다. 패키지 자체는 `react-server` 조건에서 빈 모듈로 해석되지만, [Next.js는 이 import를 내부에서 별도로 처리한다](https://nextjs.org/docs/app/getting-started/server-and-client-components#preventing-environment-poisoning). Pages Router에서도 페이지 코드와 API Routes의 빌드 처리가 다르므로, 사용 위치를 구분할 필요가 있다.

번들러 설정으로 우회하면 번들러를 바꿀 때 다시 손봐야 한다. 실제로 Turbopack으로 전환하면서 webpack alias 우회를 지우고 로거에 server-only 가드를 붙였다. 돌아보면 처음부터 서버 전용 모듈임을 가드로 명시해 두는 편이 좋았을 것이다.

## 랩보다 운영 대시보드를 먼저 본다

랩에서 측정하기 전에 운영 대시보드를 살펴봤다. 현재 사용자가 겪는 상태를 알아야 개선할 대상을 정할 수 있기 때문이다. 그런데 그래프는 멀쩡해 보여도 패널의 쿼리를 열어 보니 데이터가 잘못 표시되고 있었다.

| 패널         | 실제로 그리던 것                                                                                                                                                                                  | 그대로 믿으면                                                              |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| 표본 수      | 계속 증가하는 카운터가 아니라 오르내리는 값에 [increase()](https://prometheus.io/docs/prometheus/latest/querying/functions/#increase)를 걸어, 값이 줄 때마다 카운터가 초기화된 것으로 보고 더했다 | 표본 수가 부풀려져서, 배포 전후 기간의 표본 규모가 비슷한지조차 알 수 없다 |
| FCP p90, p95 | 이름과 달리 FCP가 아니라 documentLoad를 그렸다                                                                                                                                                    | FCP의 느린 사용자 구간이 그대로라고 잘못 판단한다                          |
| URL 필터     | 서비스 주소와 정확히 같은 값만 골랐다                                                                                                                                                             | 쿼리스트링이 붙은 진입(FCP 건수 기준 약 14%)이 통째로 빠진다               |

값 자체가 맞아도 판단에 쓰기 어려운 경우가 있었다. 히스토그램 경계가 1초에서 끝나 LCP의 63%가 마지막 칸에 몰렸고, p95는 아예 없었다. 파이프라인에서 미리 계산한 p50, p75, p90도 라우트나 OS로 나눠 다시 계산할 수 없었다. 진입 방식, 캐시 상태, 단말 성능을 구분할 정보가 없어, 번들 감소의 이득이 클 것으로 보이는 사용자(캐시가 빈 첫 진입, 느린 단말)만 따로 볼 수도 없었다.

평균이 p90보다 큰 것도 처음에는 이상해 보였다. 하지만 느린 소수의 값이 평균을 크게 높이는 동안 p90은 빠른 다수의 범위에 머물 수 있으므로, 이것만으로 계측 오류라고 볼 수는 없었다.

서비스를 개선하면서 수집 경로도 함께 고치기 시작했다. 미리 계산한 분위수 대신 구간별 건수(히스토그램)를 남기고, 사용자를 나눠 볼 조건도 수집할 때 넣기로 했다. 분위수끼리는 합칠 수 없지만 구간별 건수는 합칠 수 있으므로, 수집한 조건을 조합해 p75를 다시 계산할 수 있다. 조건에는 진입 방식([PerformanceNavigationTiming.type](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming/type)), 코어 수와 메모리로 나눈 단말 등급, [effectiveType](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType)으로 본 회선, HTML 캐시 여부를 넣었다. 값은 나중에 다시 집계할 수 있어도, 처음부터 수집하지 않은 조건은 복원할 수 없기 때문이다.

계측 상태에도 알림을 걸어 둘 필요가 있다. 분위수의 크기 순서가 뒤집히거나, 기대한 라벨 조합의 시리즈가 0건이 되거나, 표본 수가 갑자기 바뀌면 계측에 문제가 생겼는지 먼저 살펴볼 수 있다.

수집 경로를 고치는 데는 서비스 개선보다 시간이 더 걸렸다. 공용 인프라에 변경을 요청하고, 거절되면 우회 방법을 찾아야 했기 때문이다. 이 과정은 뒤의 "원인이 남의 인프라에 있을 때"에서 다룬다. 그동안 번들 개선의 효과는 조건을 고정한 랩(샌드박스)에서 측정했다. 배포 뒤 기존 대시보드의 원본 시계열을 [sum_over_time](https://prometheus.io/docs/prometheus/latest/querying/functions/#aggregation_over_time)으로 다시 집계해 봐도, 번들을 40% 줄인 배포 전후로 운영 FCP 평균은 594.0ms에서 584.1ms로 1.7%만 변했다.

이 결과만으로는 운영에서 개선 효과가 있었는지 판단하기 어렵다. 같은 빌드의 FCP도 랩에서는 1,119ms, 운영 평균은 594ms였다. 랩에서는 이득이 클 것으로 보이는 조건(캐시가 빈 첫 진입, 느린 단말)을 고정해 변경 전후를 비교하지만, 운영에서 그런 사용자는 전체의 일부다. 이들이 얻은 이득이 평균에서 희석됐을 가능성은 있어도, 실제로 그랬는지는 기존 계측으로 확인할 수 없었다.

새로 만든 대시보드도 같은 기준으로 점검했다. collector가 5분 넘게 갱신이 없는 시리즈의 누적값을 지우고 다시 시작해, `increase()`로 센 건수가 실제보다 적게 나왔다. 그래서 비율과 분위수로만 판단하고, 건수 패널에는 과소 집계된다는 문구를 붙였다.

2차 개선 뒤에는 기존 대시보드에서도 변화가 보였다. 7일 전 같은 시각과 비교하는 패널에서 배포 직후 CLS p75가 0.30 안팎에서 0.15 안팎으로 절반쯤 줄었다(CLS는 Android에서만 수집된다). FCP와 LCP도 배포 시각부터 7일 전 같은 시각의 값보다 낮아지기 시작했고, 서버 응답 시간(TTFB)은 그대로였다.

다만 배포 뒤 10여 분 동안 관측한 결과이고, 당시 FCP와 LCP의 차이는 1% 안팎이었다. 1차 배포의 운영 FCP 변화와 비슷한 크기여서, 관측 기간을 늘려도 이 차이가 유지되는지 다시 확인해야 한다.

2차 개선을 배포한 10월 릴리스에는 프레임워크 업그레이드와 번들러 전환, 아이콘 래퍼를 통한 코드 감량이 포함됐다. CSS 부분 import로 첫 진입 CSS를 줄인 작업(gzip 기준 29%)과 배너 대체 이미지 압축(286KB에서 58KB), 배너 등장 시 레이아웃 이동 보정도 함께 들어갔다. 서버 렌더링의 BE API 중복 호출 정리, 보유잔고 리렌더 제거, 숫자 애니메이션 개선까지 한 번에 배포했으므로 개별 변경이 얼마나 기여했는지는 나누지 않았다.

## 기법을 고르기 전에 잰다

번들 개선의 효과는 운영 지표로 판단하기 어려워 랩에서 측정했다. 먼저 단말 에뮬레이션, CPU 감속 배율, 캐시 상태를 고정했다. 트레이스마다 캐시 히트가 0건인지, 네트워크 스로틀이 실제로 적용됐는지도 확인했다. 이 조건들이 달라지면 변경 전후를 제대로 비교하기 어렵다.

이 조건에서 다음 항목을 살펴본다.

- **메인 스레드가 어디서 막히는지**: [Performance 패널](https://developer.chrome.com/docs/devtools/performance)로 본다. 예를 들어 로딩 2초 지점에 화면 계산이 0.5초 동안 몰리는 구간이나, 애니메이션 구간에서 프레임 사이가 74~116ms씩 비는 곳을 찾는다.
- **번들에 무엇이 어디서 들어왔는지**: [source-map-explorer](https://github.com/danvk/source-map-explorer)로 청크별 구성을 보고, 소스맵의 원본 경로를 따라 각 바이트가 어느 패키지에서 왔는지 센다. 디자인시스템 청크의 63%가 쓰이지 않는 코드라는 것도 이렇게 찾았다.
- **받은 코드가 실제로 실행되는지**: 크롬 [Coverage](https://developer.chrome.com/docs/devtools/coverage) 탭으로 본다. 다만 Coverage는 생성된 파일 단위라서 실행되지 않은 코드가 어느 원본 파일에서 왔는지 바로 연결되지 않는다.
- **로딩이 끝난 뒤에도 메인 스레드가 바쁜지**: 실시간 데이터를 그리는 화면에서는 로딩이 끝나도 작업이 계속된다. 데이터를 일정한 속도로 보내면서 Performance 패널에서 리렌더와 스타일 재계산을 살펴본다. 로컬에서 CPU를 4배 늦추고 보유 종목 13개에 시세 틱을 초당 10회씩 10초 동안 보냈을 때 메인 스레드 점유율은 85.8%였다. 그중 숫자 애니메이션의 매 프레임 스타일 재계산에 10초 중 4,016ms를 썼다. 이런 작업은 운영에서 INP의 입력 지연으로 나타날 수 있다.

Coverage에서 찾은 미실행 코드를 원본 파일과 연결하려고, 소스맵과 V8 커버리지를 합치는 분석기 [coldpath](https://github.com/yceffort/coldpath)를 만들었다. 첫 화면에서 실행된 코드와 상호작용할 때만 실행되는 코드를 원본 파일 단위로 나눠 볼 수 있다. 사용법과 분석 사례는 [블로그의 JavaScript가 언제 실행되는지 추적해보기](/2026/09/tracing-bundle-waste-with-v8-coverage-and-sourcemaps)에, 구현은 [V8 커버리지와 소스맵으로 번들 분석기 만들기](/2026/09/building-coldpath-v8-coverage-analyzer)에 정리했다. 번들러마다 import 정보가 다르게 남아서 생긴 분류 오류는 [번들러는 import를 어떻게 기억하는가](/2026/10/how-bundlers-remember-imports)에서 다뤘다.

관측 결과를 정리할 때는 원인의 성질과 위치를 구분한다. 중복 설치나 서버 코드 유입인지, 특정 환경에서만 필요한 코드가 모든 빌드에 포함되는지, 크지만 항상 필요하지는 않은 코드가 첫 청크에 실리는지를 먼저 본다. 이어서 그 원인이 내 코드에 있는지, 남의 코드나 인프라에 있는지 확인한다. 같은 문제여도 직접 고칠 수 있는 범위에 따라 대응이 달라진다. 돌아보면 시간을 많이 쓴 곳도 여기까지의 관측과 분류였고, 기법을 적용하는 시간은 오히려 짧았다.

## 무엇부터 고칠지 정한다

관측 결과를 정리하면 고칠 곳이 한꺼번에 여러 개 나온다. 모두 같은 비중으로 다루면 효과가 작은 일부터 시간을 쓰기 쉽다. 이번 작업에서는 첫 화면에 영향을 주는지, 병목이 어디인지, 어떤 비용을 줄일 수 있는지를 기준으로 순서를 정했다.

처음에는 범위를 첫 화면으로 좁혔다. 사용자가 처음 겪는 기다림은 화면이 뜨고 조작할 수 있게 되기까지의 시간이기 때문이다. 그래서 첫 번째 작업에서는 어보브 더 폴드(above the fold) 영역을 고쳤다. 상단 배너의 등장 애니메이션, 첫 로드에 실리는 JavaScript, 로딩 2초 지점에 몰리는 화면 계산을 대상으로 삼았다.

LCP는 단계별로 나눠 병목을 찾는다. LCP가 느려도 이미지 다운로드가 원인이라고 바로 판단하기는 어렵다. web-vitals [attribution 빌드](https://github.com/GoogleChrome/web-vitals/tree/v6.2.1#attribution-build)는 LCP를 TTFB, 리소스 요청이 시작되기까지의 지연, 리소스를 내려받는 시간, 받은 뒤 그려지기까지의 렌더 지연으로 나눠 준다. 느린 회선 조건의 개선 전 트레이스에서는 LCP 요소인 배너 이미지의 다운로드가 2.0초에 끝났다. 그 뒤 화면 코드(하이드레이션)를 기다리느라 LCP까지 7.5초가 더 걸렸다. 이 경우에는 JavaScript가 병목이었으므로 이미지 최적화보다 JavaScript를 줄이는 일이 먼저였다.

<BarCompare title="LCP 배너 이미지가 그려지기까지 (느린 4G, 5회 평균)" unit="초" before="개선 전" after="개선 후" rows="LCP 전체|9.52|7.67;배너 이미지 다운로드 완료|2.0|2.0;그 뒤 그려지기까지 대기|7.5|5.6" />

개선 후 대기 시간은 5.6초로 줄었다. 다만 5회 측정한 평균이므로 개선 폭은 참고값으로 봐야 한다. 프레임워크 업그레이드와 번들러 전환도 함께 들어간 릴리스여서 개별 변경의 기여는 나누지 않았다. 개선 전 트레이스에서 병목을 찾는 것과 개선 후 각 변경의 효과를 구분하는 것은 별개의 일이었다.

코드 크기가 같아도 실행 형태에 따라 비용은 달랐다. 실험에서는 호출하지 않는 함수 선언의 비용이 MiB당 약 21ms였고, CPU를 4배 늦춰도 거의 늘지 않았다. 반면 파일을 읽자마자 실행되는 초기화 코드는 MiB당 약 201ms의 메인 스레드 작업을 더했다([실행하지 않는 JavaScript를 10MiB까지 늘려봤다](/2026/09/unused-javascript-cost)). 이 결과를 바탕으로 첫 화면에서 실행되는 코드를 먼저 줄이고, 전송량 감소만 확인됐거나 CPU 비용을 줄인다는 근거가 없는 작업은 뒤로 미뤘다.

효과가 작은 접근은 중간에 접기도 했다. 외부 SDK의 크기를 줄일 때는 두 가지 가능성을 확인했다. 번들러가 부수 효과가 없다는 것을 증명하지 못해 코드가 남는 것인지, 실제로 쓰이는 코드여서 남는 것인지였다. 첫 번째 가능성을 확인하려고 배포본에 [PURE 주석](https://esbuild.github.io/api/#pure)을 강제로 넣었다. 상수 하나만 import한 번들은 74% 줄어서 주석 몇 줄로 해결할 수 있을 것처럼 보였지만, 실제로 클라이언트를 초기화하는 번들은 1%만 줄었다. 최소 예제의 결과만 봤다면 주석을 넣는 것으로 작업을 끝냈을 것이다.

주석을 추가하는 접근은 거기서 접고, 쓰지 않는 기능으로 가는 import를 끊는 방식으로 바꿨다. SDK를 직접 다시 조립하고 유지해야 했으므로, 그 비용을 감수할 수 있는 조건부터 따졌다. 조건은 뒤의 벤더 SDK 절에 적었다. 결과적으로 이벤트 전송용 엔트리의 gzip 크기를 69% 줄였다. 다만 웹 이벤트 수집 범위에서 측정한 결과이고, 벤더 버전이 바뀔 때마다 다시 검증해야 한다는 부담도 남았다.

발견하고도 고치지 않은 문제도 있었다. SDK를 다시 조립하면서 벤더 코드의 결함을 몇 건 찾았지만, 고치면 교체 전후 데이터의 의미가 달라져 그대로 두고 벤더에 전달했다. 실사용에서 효과가 작다고 확인됐거나, 개선을 유지하는 비용이 이득보다 커진다면 작업을 멈추는 것도 필요하다고 생각한다.

## 원인의 성질이 기법을 정한다

원인이 내 코드에 있으면 문제의 성질에 맞춰 기법을 고를 수 있다. 서버 코드 유입과 중복 설치를 정리한 뒤에는 관측에서 새로 찾은 문제를 같은 기준으로 살펴봤다.

| 성질                           | 확인할 것                      | 기법                 | 예                               |
| ------------------------------ | ------------------------------ | -------------------- | -------------------------------- |
| 특정 환경에서만 필요한 코드    | 조건이 빌드 타임 상수로 접히나 | 빌드 타임 상수로 DCE | mock 서버(msw), 개발 전용 검증기 |
| 크지만 항상 필요하지 않은 코드 | 첫 화면에 필요한가             | dynamic import       | lottie-web, swiper               |

기법을 고른 뒤에도 실제로 코드가 빠지는 조건은 따로 확인해야 했다.

DCE로 코드를 제거하려면 분기 조건을 빌드 타임 상수로 계산할 수 있어야 한다. 런타임 함수의 반환값으로 분기하면 번들러는 그 조건이 항상 거짓이라는 것을 증명할 수 없다. `process.env.NEXT_PUBLIC_APP_PHASE === 'mock'`처럼 빌드 타임에 문자열로 치환되는 조건을 쓰면 mock이 아닌 빌드에서 dynamic import까지 함께 지워진다. 운영 경로와 개발 전용 코드가 공유하는 객체도 살펴볼 필요가 있다. 객체 하나를 함께 쓰면서 모듈 전체가 포함될 수 있기 때문이다. 로그 라우팅이 개발 전용 검증기와 공유하던 zod 스키마를 분리했더니 첫 로드 JS가 86.5KiB 줄었다.

실행할 때 기능을 끄는 플래그를 넘겨도 공통 모듈 맨 위의 import가 남아 있으면 해당 코드는 번들에 포함된다. 이 경우에는 import 자체를 다른 파일로 옮겨 연결을 끊어야 한다. 앞의 PURE 주석 사례처럼 최소 예제와 실사용 결과도 따로 측정할 필요가 있다.

지연 로드는 전송량 감소와 CPU 시간 감소를 구분해서 본다. [next/dynamic](https://nextjs.org/docs/app/guides/lazy-loading)으로 코드를 나누면 초기 전송량은 줄지만, CPU 시간까지 줄어드는지는 코드의 형태에 따라 달라진다. swiper를 지연 로드로 분리했을 때 초기 로드에서 94KB(gzip 약 30.6KB)가 빠졌다. CPU 시간이 줄었다는 근거는 찾지 못해 효과는 전송량 감소로만 설명했다.

번들 외에 렌더링 과정도 살폈다. scale 애니메이션이 메인 스레드에서, opacity가 합성 스레드에서 처리되면 프레임이 밀릴 때 두 애니메이션이 어긋난다. `height: 0`에서 `auto`로 늘리는 트윈은 매 프레임 레이아웃을 다시 계산하게 만든다. 이 문제는 두 애니메이션을 같은 CSS transition으로 옮기고, 아래 콘텐츠가 밀리는 동작을 `transform`만 쓰는 FLIP으로 재생해 해결했다. 노출 로깅도 모션이 끝난 뒤로 미뤘다. 원본 모션과 대조하며 고친 과정은 [framer-motion 배너에서 프레임드랍 없애기](/2026/08/framer-motion-banner-frame-drop)에 적었다. 같은 가격의 틱 100회에 98회 발생하던 리렌더를 0회로 줄인 작업과 서버 렌더링의 BE API 중복 호출(수정 전 운영 로그 기준 40.8%)도 이 단계에서 찾아 정리했다.

첫 배포 뒤 운영에서 확인한 첫 진입 JavaScript 전송량은 839.9KB에서 503.3KB로 40.1% 줄었다. 샌드박스에서는 페이지를 연 뒤 5초간 메인 스레드 작업이 4,073ms에서 2,850ms로 30% 줄었다. 가장 크게 줄어든 것은 화면 계산 시간으로, 1,041ms에서 226ms가 됐다. 그려야 할 요소가 줄면서 로딩 2초 지점에 0.5초 동안 몰리던 계산도 작은 작업으로 나뉘었다.

## 원인이 남의 코드에 있을 때

문제를 추적하다 보면 다른 팀의 패키지나 외부 라이브러리까지 확인하게 된다. 원본에서 고치는 것이 좋겠지만, 이번에는 업스트림의 수정을 기다리기보다 직접 고친 경우가 더 많았다.

연락이 잘 닿지 않거나, 닿더라도 업스트림의 우선순위에서 밀렸다. 기다리는 동안에도 사용자는 쓰지 않는 코드를 계속 내려받았다. 어떤 코드가 왜 번들에 남는지 이미 측정해 둔 상태라, 그 내용을 설명하고 수정을 기다리는 것보다 직접 고치는 편이 빨랐다. 두 번째 개선은 프레임워크 업그레이드가 포함된 10월 릴리스에 맞춰야 해서 일정도 여유롭지 않았다.

직접 고칠 수 있었던 것은 원본으로 돌아갈 방법을 마련할 수 있었기 때문이다. 원본을 수정하지 않고 사본이나 래퍼, API를 유지한 포크를 만들면 문제가 생겼을 때 교체 전 상태로 되돌릴 수 있었다.

직접 관리할 코드를 늘리는 결정이므로, 대응을 고르기 전에 네 가지를 따져 봤다.

- 그 코드를 누가 소유하는가
- 원본을 고치면 변경 범위가 어디까지 번지는가
- 고쳐지기를 기다리는 동안 무엇을 계속 잃는가
- 문제가 생기면 원본으로 돌아갈 수 있는가

이번 작업에서 선택한 대응과 그 뒤에 남는 비용을 정리하면 다음과 같다.

| 상황                                              | 대응                        | 남는 비용                          |
| ------------------------------------------------- | --------------------------- | ---------------------------------- |
| 고치면 다른 서비스 전부에 영향이 가는 공용 패키지 | 필요한 코드만 복사          | 사본 유지, 이후 공용 패키지로 정리 |
| 다른 팀이 소유한 디자인시스템                     | 원본은 두고 사용분만 래퍼로 | 원본 변경 추적, 동일성 테스트      |
| 하나를 쓰려고 통째로 의존하는 패키지              | 내재화                      | 직접 유지                          |
| 개선 일정을 약속하지 않는 벤더 SDK                | 배포물에서 복원해 재조립    | 벤더 버전마다 재검증, 원복 경로    |
| 필요한 환경을 지원하지 않는 오픈소스              | API를 유지한 포크           | 업스트림 변경 추적                 |

방법은 달라도 원본을 보존하고 되돌릴 수 있게 준비한다는 점은 같았다.

### 사내 패키지는 고치기 어려우면 가져온다

공용 워크스페이스 패키지가 구버전을 고정하고 있고, 이를 바꾸면 다른 서비스까지 모두 영향을 받는 경우가 있다. 이런 변경은 서비스 하나의 성능 작업에서 감당하기 어려울 수 있다. 필요한 코드만 서비스 안으로 복사하고 워크스페이스 의존을 끊는 방법도 이때 고려할 수 있다. 같은 코드를 두 곳에서 관리하는 비용을 감수하되, 나중에 공용 패키지로 정리할 수 있게 두는 것이다. 워크스페이스 의존은 편하지만, 그 패키지가 정한 의존성 버전도 함께 받아들여야 한다.

다른 팀에서 관리하는 디자인시스템은 원본을 두고 로컬 래퍼를 만들었다. 아이콘을 `import *`로 모은 맵 형태로 내보내고 있어 `sideEffects: false`가 있어도 트리 셰이킹되지 않았다. 실제로는 13종만 쓰는데 126종이 번들에 실려 있었다. 필요한 13종을 named import로 다시 내보내고, 원본과 렌더링 마크업이 같은지 테스트했다. 원본을 직접 import하는 코드는 [no-restricted-imports](https://eslint.org/docs/latest/rules/no-restricted-imports)로 막았다. 이 변경으로 첫 진입 JS가 133KB 줄었다.

컴포넌트 하나 때문에 패키지 전체를 가져온다면 필요한 구현을 서비스 안으로 옮기는 것도 고려할 만하다. 패키지로 유지하는 이점이 없고, 의존성 버전을 직접 정하지 못하는 제약만 남는 경우다.

코드를 가져올 때는 원본과 동작이 같은지 확인하는 테스트를 붙이고, 원본 패키지가 다시 들어오지 않도록 린트로 막고, 가져온 버전을 명시한다. 복사한 뒤의 유지보수는 직접 맡아야 하므로, 원본으로 돌아가거나 공용 패키지로 정리할 수 있는 상태를 유지할 필요가 있다.

### 벤더 SDK는 근거를 들고 요청하고, 안 되면 다시 조립한다

벤더 SDK는 측정 근거를 정리해 먼저 개선을 요청했다. 답변에서 제안한 원인과 대안도 실제로 확인했다. 실험 플랫폼 SDK의 벤더는 최소 지원 브라우저를 보수적으로 잡아서 크기가 크다며 React용 SDK를 대안으로 권했다. 하지만 React SDK도 웹 SDK 전체를 다시 내보내고 있었고, 브라우저 타깃에 좌우되는 폴리필은 전체의 13.3%뿐이었다. 두 설명 모두 측정 결과와 맞지 않았다. 개선 일정도 정해지지 않았다는 답을 받았다.

기다리는 동안의 전송 비용을 줄이려고 SDK를 직접 다시 조립했다. 소스맵의 `sourcesContent`에 원본 전문이 있어 코드를 확보할 수 있었다. 이벤트와 실험군 배정 로직은 대시보드 숫자의 근거이므로 그대로 유지하고, 원본과 대조해 검증할 수 있어야 했다. 벤더 버전이 바뀔 때마다 다시 검증하는 비용도 감당해야 했다. 이 조건을 확인한 뒤 엔트리와 의존성 연결만 다시 구성했고, 이벤트 전송용 엔트리의 gzip 크기를 벤더 원본보다 69% 줄였다.

소스맵에서 원본을 복원하는 방법, import를 끊는 조립 구조, 검증 과정은 [외부 SDK를 뜯어서 내 맘대로 다시 만들기](/2026/08/rebuilding-a-vendor-sdk)와 FEConf 2026 발표 [외부 SDK를 다시 조립하기](https://research.yceffort.kr/slides/feconf-2026-vendor-sdk)에 자세히 정리했다.

### 오픈소스는 업스트림을 확인하고, API를 지키는 포크를 만든다

오픈소스가 필요한 환경을 지원하지 않으면 먼저 업스트림 이슈를 확인한다. 숫자 애니메이션 라이브러리 [number-flow](https://github.com/barvian/number-flow)는 최신 CSS 기능에 기대고 있어서 Chrome 125, Safari 17.2 미만에서는 애니메이션이 조용히 꺼졌다. 같은 요청([#131](https://github.com/barvian/number-flow/issues/131))과 제보([#164](https://github.com/barvian/number-flow/issues/164))가 열려 있었지만 해결되지 않은 상태라, API와 시각 결과를 유지한 채 구동부만 바꾸는 포크를 만들었다. API가 같으니 쓰는 코드는 바꾸지 않아도 되고, 포크를 직접 관리하니 성능 문제도 직접 고칠 수 있었다. 이후 0.2.0에서 애니메이션을 합성 스레드로 옮겨 스타일 재계산 시간을 81~85% 줄였다.

로컬에서는 CPU를 4배 늦추고, 보유 종목 13개에 시세 틱을 초당 10회씩 10초 동안 보내며 비교했다. 버전별 3회 평균 메인 스레드 점유율은 85.8%에서 49.6%로 줄었다. 보유 종목이 100개일 때는 84.3%에서 29.5%로 줄었다. CPU 감속 없이 숫자 하나를 초당 한 번 갱신하는 조건에서도 초당 메인 스레드 작업이 199ms에서 59ms로 70% 줄었다.

이 포크는 개인 공개 저장소에 만들었다. 원본이 MIT 라이선스의 오픈소스였고, 구형 브라우저 지원과 합성 스레드 성능 문제는 이 라이브러리를 쓰는 다른 사용자에게도 해당했다. 범용 라이브러리의 유지보수까지 회사가 맡게 할 이유는 없다고 생각했다. 사내 GitHub Actions 환경에 한계가 있어 공개 CI를 쓰는 편이 나았고, npm 배포와 Storybook도 그대로 사용할 수 있었다. 사내 서비스에서는 공개 npm 패키지를 설치하면 됐다.

API를 원본과 같게 유지해 업스트림에서 같은 문제를 해결하면 돌아갈 수도 있다. 포크 자체도 서비스 성능 작업보다 2주쯤 먼저 시작했다. 벤더 SDK는 사내 패키지로만 유지했다. 라이선스(ISC)는 허용적이었지만, 저장소를 공개하지 않은 벤더의 코드를 배포물에서 복원한 작업이어서 벤더와의 관계를 고려해 공개하지 않았다.

이식 과정은 [number-flow를 구형 브라우저로 이식하기](/2026/08/number-flow-fork-for-old-browsers)에, 합성 스레드로 옮긴 과정은 [number-flow 성능 개선기](/2026/10/number-flow-compositor-performance)에 있다.

## 원인이 남의 인프라에 있을 때

여러 팀이 함께 쓰는 관측 인프라는 서비스 코드처럼 직접 바꾸기 어렵다. 재현 가능한 요청서를 보내고, 요청이 받아들여지지 않을 때의 대안을 준비해야 했다. 그동안 서비스 코드에서 해결할 수 있는 방법도 찾아봤다.

이 단계가 가장 낯설었다. 이전에는 Sentry나 GA4처럼 SDK를 붙이면 대시보드까지 제공되는 도구로 충분했다. 이번에는 요청서를 쓰는 데도 OpenTelemetry collector와 Grafana Tempo, Mimir의 구조를 알아야 했다. tail sampling이나 delta 값을 누적값으로 바꾸는 처리를 하나씩 공부하면서 무엇을 요청할지, 요청이 거절되면 어떻게 우회할지 정리했다. 이 과정에서 배운 내용은 [Core Web Vitals 직접 수집하기](/series/cwv-collection) 시리즈에 담았다.

요청서는 받는 사람이 같은 결과를 재현할 수 있도록 쓰는 편이 좋다. "저장이 안 되는 것 같다"는 설명에 그치지 않고 조회한 쿼리와 시각, 결과를 함께 적는다. CWV를 span으로 보냈을 때 collector는 `200`으로 응답했는데 Tempo에서는 그 traceID가 9분 뒤에도 404였다면, 같은 페이지 로드의 다른 span은 조회된다는 사실까지 포함하는 식이다. 요청이 여러 개면 우선순위도 구분한다. 저장이 안 되는 문제는 P0, 메트릭 경로와 조회 범위는 P1, 보존 기간과 수집 한도 확인은 P2로 나눌 수 있다.

대안을 제시할 때는 특정 제품보다 필요한 역할을 먼저 적는 편이 좋다고 생각한다. 원본 이벤트 저장소, 히스토그램 저장소, 수신 endpoint처럼 요구사항을 나누면 기존 경로의 버킷만 넓히는 방법부터 자체 수집 서버까지 검토할 수 있다. FE 코드에서도 수집과 전송을 분리해 두면 전송 경로를 바꿀 때 수집 코드를 다시 쓰지 않아도 된다.

받아들여지지 않은 요청도 있었다. trace에서 메트릭을 만드는 spanmetrics는 리소스 부담 때문에 사용할 수 없었고, collector의 tail sampling 정책(15초를 기다린 뒤 span이 2개 미만인 trace를 버림)도 바꿀 수 없었다. 그래서 브라우저에서 히스토그램 메트릭을 직접 만들어 보내고, trace는 검증 환경에서 원인을 분석하는 데만 쓰도록 했다. 첫 지표가 나올 때 root span도 함께 보내 tail sampling에서 버려지지 않게 했다.

이 수집 경로는 샌드박스에서 동작하지만 운영에는 아직 반영하지 못했다. 이 글의 운영 RUM 수치는 모두 기존 경로에서 수집한 값이다.

플랫폼의 한계도 미리 확인해 둔다. iOS 웹뷰는 [LayoutShift](https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift)를 지원하지 않아 CLS가 수집되지 않고, LCP와 INP는 iOS 26.2 이상에서만 들어온다. web-vitals가 언제 값을 보고하는지, 페이지를 떠날 때 어떻게 보내는지, 공용 인프라에서 어디서 누락되는지는 [web-vitals는 언제 핵심 웹 지표를 보고하는가](/2026/10/web-vitals-report-timing), [페이지를 떠나는 순간 핵심 웹 지표를 보내기](/2026/10/cwv-send-on-page-leave), [앱 웹뷰의 핵심 웹 지표를 공용 관측 인프라로 보내기](/2026/10/cwv-to-tempo-and-mimir)에서 차례로 다뤘다.

## 줄였다보다 깨뜨리지 않았다를 먼저 증명한다

크기가 얼마나 줄었는지는 비교적 쉽게 측정할 수 있었다. 시간이 더 걸린 것은 동작이 바뀌지 않았는지 확인하는 일이었다. 산출물부터 원본과의 대조, 실제 실행 경로까지 확인 범위를 넓혔다.

1. **산출물을 직접 확인한다.** 지운 모듈이 운영 빌드의 청크에 남지 않았는지 검색한다. 어느 엔트리가 어떤 모듈을 가져오는지는 엔트리를 번들한 뒤 esbuild [metafile](https://esbuild.github.io/api/#metafile)의 import 관계로 확인한다. 소스의 사용처를 grep한 결과만으로는 무관해 보이던 모듈이 설정 로더를 거쳐 연결된 적이 있었다.
2. **원본과 대조한다.** 이전과 같은 동작이 목표라면 기대값을 손으로 적는 대신, 원본을 함께 실행해 같은 입력에서 나온 출력을 비교하는 편이 좋다. 벤더 SDK는 배포본을 별도 jsdom에서 함께 돌렸다. 아이콘 래퍼의 렌더링 마크업과 base64 대체 구현의 결과도 원본과 비교했다. base64는 패딩 경계 전부와 고정 시드 유니코드 2,000건을 확인했다.
3. **테스트가 오류를 잡는지도 확인한다.** 소스를 일부러 틀리게 바꿨을 때 테스트가 실패하는지 보는 뮤테이션 테스트를 돌렸다. 통과하던 테스트 중에는 이전 테스트가 남긴 `history.pushState` 패치 때문에 확인하려던 코드에 도달조차 하지 않는 것도 있었다. 코드의 실행 여부뿐 아니라 결과를 제대로 단언하는지도 확인해야 했다.
4. **실제 실행 경로를 돌린다.** 엔트리를 나누면 자동 이벤트나 페이지 이탈 시 전송처럼 직접 호출하지 않는 동작의 연결이 빠질 수 있다. 등록이 빠져도 에러는 나지 않고 이벤트만 조용히 사라지므로, 초기화와 라우팅, 페이지 이탈까지 실행해 실제 전송을 확인한다.

번들러를 바꾸는 것처럼 크기를 줄이려는 변경이 아니어도 런타임 동작은 바뀔 수 있다. Turbopack으로 전환한 뒤 모듈 스코프 싱글톤이 두 개가 된 사례는 [Next.js turbopack에서 싱글톤이 두 개가 됐다](/2026/08/turbopack-scope-hoisting-singleton-split)에 정리했다.

검증 결과에는 무엇을 어떻게 확인했는지도 적는다. 원본과 나란히 실행한 비교(대조), 우리 테스트에서 값을 단언한 결과(자체), 실행하지 못한 부분(미검증)을 구분했다. 모두 "확인했다"고만 적으면 근거의 차이를 알기 어렵다.

## 개선을 지키는 장치를 남긴다

번들 크기와 실행 시간을 줄여도 다음 PR에서 다시 늘어날 수 있다. 검증을 마친 변경에는 같은 문제가 다시 생기지 않도록 검사도 붙였다. 앞에서 설명한 장치를 모으면 다음과 같다.

| 다시 들어올 수 있는 것                | 막는 장치                                                           |
| ------------------------------------- | ------------------------------------------------------------------- |
| 순환 import                           | dpdm 검사를 CI에서 실행                                             |
| 래퍼를 거치지 않는 원본 패키지 import | ESLint `no-restricted-imports`                                      |
| 서버 전용 모듈의 클라이언트 유입      | `server-only` 가드(App Router, 클라이언트에서 import하면 빌드 실패) |
| 다시 조립한 SDK의 엔트리별 크기 증가  | 기준선 파일과 비교하는 측정 스크립트                                |
| 부분 import로 바꾼 CSS의 규칙 누락    | 원본 CSS와 비교하는 누락 검출 테스트                                |
| 원본 동작에서 벗어난 수정             | 원본 대조 테스트, 일부러 남긴 벤더 결함을 지키는 테스트             |
| 번들 크기와 첫 화면 실행 코드의 증가  | coldpath로 기준 빌드와 비교                                         |

원본 동작을 지키는 테스트에는 벤더의 결함을 그대로 유지하는 검사도 있다. SDK를 다시 조립하면서 남겨 둔 결함을 누군가 고치면, 교체 전후 데이터의 의미가 달라지기 때문이다. 그래서 결함을 수정한 코드를 일부러 주입했을 때 원본 대조 테스트가 실패하는지 확인했다. 이 검사에서 지키려는 것은 원본과 같은 동작이다.

번들 전체의 크기 변화는 서비스에 [coldpath](https://github.com/yceffort/coldpath)를 적용해 추적하고 있다. 기준 빌드와 새 빌드를 같은 방식으로 분석하면 원본 파일과 패키지 단위로 어디가 늘었는지, 어떤 모듈이 새로 들어왔는지 볼 수 있다. GitHub Action으로 PR마다 비교 결과를 댓글로 남기고, 바이트 예산을 넘으면 CI를 실패시키는 기능도 있다. 이를 이용하면 늘어난 이유를 리뷰에서 확인할 수 있다.

## 숫자는 등급을 나눠 말한다

같은 변경을 측정해도 도구와 조건에 따라 숫자가 크게 달라졌다. 보고할 때는 직접 확인한 값과 조건에 따라 달라지는 값, 가정으로 환산한 값을 구분했다.

| 구분 | 뜻                                     | 예                                                      |
| ---- | -------------------------------------- | ------------------------------------------------------- |
| 확정 | 운영에서 직접 센 값                    | CDN이 내려보낸 JS 바이트 -40.1%                         |
| 범위 | 계기나 조건에 따라 폭이 갈리는 값      | FCP -18~-44% (트레이스와 Performance API 스냅샷이 갈림) |
| 환산 | 확정값을 가정으로 바꾼 값, 실측이 아님 | 느린 4G에서 다운로드 -1.9초 (바이트를 대역폭으로 나눔)  |

수치를 비교하고 설명할 때는 다음 사항도 함께 확인했다.

- 이번 변경으로 크기가 바뀌지 않은 자산(예: CSS)을 대조군으로 함께 본다. 트래픽 구성처럼 다른 요인이 섞였는지 확인하는 데 도움이 된다. 비교 날짜는 배포 후 경과일을 기준으로 맞춘다.
- 측정 도구마다 값이 다르면 하나로 합치지 않고 범위로 보고한다. 확정하려면 도구 하나를 골라 조건을 확인하고 15회 이상 다시 측정한다.
- 설정한 조건이 실제로 적용됐는지도 확인한다. 크기가 같은 외부 자산의 다운로드 시간으로 실효 대역폭을 계산했더니 30Mbps 이상이어서 스로틀이 걸리지 않았음을 알게 된 적이 있다. 그 결과에서 줄어든 시간은 메인 스레드 작업의 감소로 해석해야 했다.
- 수집 과정에서 빠진 데이터가 있는지도 살핀다. 첫 운영 측정의 -46%는 수집 누락 때문에 부풀려진 값이었다. CDN 바이트로 다시 측정한 뒤 -40.1%로 고쳤다.
- 여러 변경을 함께 배포했다면 개별 효과를 나누어 말하지 않는다. 프레임워크 메이저 업그레이드와 번들러 전환이 같은 릴리스에 포함됐을 때가 그랬다. 시간 구간도 나눠 볼 필요가 있었다. LCP까지의 메인 스레드 작업은 17% 줄었지만 LCP 이후 2초는 86% 늘어, 합치면 비슷한 경우가 있었다. 전체 작업량이 줄지 않고 실행 시점만 뒤로 밀렸던 것이다.
- "수정 전 중복 호출 비율 40.8%"처럼 비율이 가리키는 대상과 시점을 적는다. 이 값을 수정 후 감소폭으로 설명해서는 안 된다.

조건이 다른 측정을 섞으면 안 되는 이유는 [블로그를 고친 뒤 첫 화면을 다시 재봤다](/2026/09/blog-performance-after-migrations)에서도 다뤘다.

## 보고는 사용자가 겪는 장면으로 바꾼다

기획자나 경영진에게 FCP와 TBT 수치만 보여 주면 무엇이 좋아졌는지 전달하기 어렵다. 보고서에서는 이 변화가 사용자의 경험과 어떻게 연결되는지 풀어 쓸 필요가 있다.

지표 이름을 풀어 쓰되, 그 지표로 확인할 수 없는 의미까지 붙이지 않으려 했다. FCP는 "무언가가 화면에 처음 그려진 시점", TBT는 "메인 스레드가 오래 막혀 입력 처리가 늦어질 수 있었던 시간의 합"으로 설명할 수 있다. DCL([DOMContentLoaded](https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event))은 HTML 파싱과 지연 실행 스크립트의 실행이 끝난 시점이다. 비동기 스크립트의 완료는 기다리지 않으므로, 사용자가 화면을 조작할 준비까지 끝났다는 뜻으로 설명하면 안 된다. 풀어 쓴 설명 옆에는 원래 지표 이름도 괄호로 남긴다.

수치를 설명할 때는 캐시가 빈 첫 진입인지, 느린 회선인지처럼 사용자가 겪는 상황을 함께 적었다. 효과가 작을 것으로 예상하는 사용자도 밝혀 두면 개선을 과대평가하지 않는 데 도움이 된다. 운영 지표로 아직 확인하지 못한 효과는 그 이유까지 먼저 설명했다. 그래야 대시보드가 그대로라는 이유로 실패했다고 판단하는 일을 줄일 수 있다.

실제 보고서를 이 원칙에 맞춰 줄여 쓰면 다음과 같다.

> **첫 화면 번들 정리 배포 결과**
>
> **요약**: 첫 진입에서 내려받는 JavaScript를 40% 줄였습니다. 다만 사용자 체감 효과는 아직 운영 지표로 확인하지 못했습니다(아래 설명).
>
> **확정된 변화** (운영 CDN 실측)
>
> - 내려받는 JavaScript: 839.9KB에서 503.3KB로 40.1% 감소
> - HTML 문서 크기: 약 10% 감소
>
> **사용자가 겪는 변화** (배포 전 테스트 환경 측정과 환산)
>
> - 캐시가 빈 첫 진입에서 빈 화면을 보는 시간이 0.2~0.5초 줄어듭니다. 측정 방식에 따라 폭이 다릅니다.
> - 회선이 느린 곳(1.44Mbps 기준)에서는 초기 JavaScript를 받는 시간이 최대 1.9초 줄어듭니다(전송량을 대역폭으로 나눈 값).
> - 로딩 중 스크롤이나 탭의 처리가 늦어질 수 있는 시간이 줄고, 저사양 단말일수록 효과가 클 것으로 예상합니다.
> - 최신 단말에 빠른 회선을 쓰는 사용자는 체감 차이가 작을 것으로 예상합니다.
>
> **현재 운영 지표로 효과를 확인하기 어려운 이유**: 기존 계측에는 첫 진입과 저사양 단말 사용자를 구분할 정보가 부족합니다. 랩에서 확인한 개선이 실제 운영에서도 나타나는지는 새 계측을 반영한 뒤 확인해야 합니다.
>
> **다음 단계**: 새 계측을 운영에 반영한 뒤 같은 기준으로 다시 확인합니다.

보고서에는 같은 전송량 감소가 회선 속도에 따라 얼마나 다른 시간 차이를 만드는지 그림으로도 넣었다.

<BarCompare title="초기 JavaScript를 다 받는 데 걸리는 시간 (전송량을 대역폭으로 나눈 환산값)" unit="ms" before="개선 전" after="개선 후" rows="느린 4G (1.44Mbps)|4778|2863;빠른 4G (8.1Mbps)|849|509" />

같은 336.6KB 감소를 시간으로 환산하면 느린 회선에서는 1.9초, 빠른 회선에서는 0.3초 차이가 난다. 계산에 사용한 대역폭은 Chrome DevTools 네트워크 프리셋의 실효값이다. 실제 다운로드 시간을 측정한 결과로 오해하지 않도록 그림 제목에도 환산값이라고 적었다.

이렇게 보고하면 운영에서 확인한 변화, 테스트 환경의 측정값, 가정에 따른 환산값을 구분해 전달할 수 있다. 효과가 작을 것으로 예상하는 사용자와 운영에서 더 확인해야 할 부분도 함께 남는다. 기존 대시보드로 판단하기 어려웠던 이유는 앞의 "랩보다 운영 대시보드를 먼저 본다"에서 설명했다.

## 기법은 짧았고, 판단은 길었다

첫 진입 JavaScript를 40% 줄이는 동안, 기법을 적용하는 데 쓴 시간은 생각보다 짧았다. 무엇을 제거해도 되는지 기준을 세우고, 기존 대시보드로 판단하기 어려운 효과를 랩에서 측정하고, 무엇부터 고칠지와 어디서 멈출지를 정하는 데 더 오래 걸렸다. 남의 코드와 인프라에 있는 원인을 어떻게 다룰지도 결정해야 했다. 코드를 바꾼 뒤에는 동작을 검증하고 재발 방지 장치를 마련하고, 측정 결과를 과장 없이 설명하는 일이 남았다.

다른 사람이 관리하는 코드를 직접 고칠 때는 특히 유지 비용을 생각하게 됐다. 누가 소유하는지, 어디까지 영향을 주는지, 기다리는 비용은 얼마인지, 문제가 생기면 되돌릴 수 있는지를 살폈다. 복사하거나 다시 조립하는 순간부터는 직접 유지해야 하므로, 그 비용까지 감당할 수 있는지 판단하는 것이 중요하다고 생각한다.

아직 남은 일은 번들을 줄인 효과가 실제 사용자에게도 나타났는지 운영 지표로 확인하는 것이다. 새 계측을 운영에 반영하면, 다음 작업에서는 랩에서 확인한 효과가 운영에서도 나타나는지 함께 살펴볼 수 있을 것이다.
