iamian

개발과 기술에 대한 생각과 경험을 공유합니다.

BlogPostsBooksAbout

© 2026 ian. All rights reserved.

📖Work Essay·2/2단계
100%

큰 번들에서 시작한 포탈 성능 최적화 회고

쓰리빌리언 포탈은 사용자가 검사 상품을 주문하고 결제한 뒤, 진행 상태와 결과를 확인하는 React 기반 SPA다. /order는 그 흐름이 시작되는 핵심 페이지다. 국내외에서 모바일로도 접근하는 서비스라 느린 네트워크에서 첫 화면이 언제 나타나는지가 중요했다.

2025년 4월부터 2026년 9월까지, 번들 크기에서 출발해 import 그래프, 요청 순서, 첫 렌더 경로로 이어진 최적화의 여정을 기록해보았다. 그사이 엔트리 JavaScript는 gzip 4.35MB에서 235.76KB가 됐고, RUM을 도입한 직후인 2026년 4월과 비교하면 9월 /order의 FCP는 6.17초에서 1.33초로 내려갔다.

처음 상태: 4.35MB 단일 번들과 흰 화면

2025년 4월, 가장 먼저 눈에 들어온 건 JavaScript 파일 하나였다. 4월 4일 빌드 로그를 기준으로 원본은 약 12.8MB, gzip으로도 약 4.35MB였다.

산출물원본 크기gzip 크기
JavaScript 엔트리12,814.85kB4,347.49kB
CSS186.46kB72.13kB

코드 스플리팅이 되어 있지 않아 첫 화면에 필요한 코드와 나중에 사용할 코드가 한 파일에 묶여 있었다. 사용자는 어느 화면으로 들어오든 이 파일을 내려받아야 했다. 브라우저가 거대한 번들을 다운로드하고 해석해 React의 첫 화면을 그릴 때까지는 흰 화면을 보며 기다려야 했다.

주문 페이지를 대상으로 남긴 2025년 4월 15일 모바일 Lighthouse 결과도 좋지 않았다. Performance는 35점, FCP는 약 67.1초, LCP는 약 75.8초였다.

운영 URL에 네트워크와 CPU 제한을 걸어 측정한 값이므로 모든 사용자가 75초를 기다렸다는 뜻은 아니다. 그래도 느린 환경에서 초기 로딩 시간이 크게 늘어난다는 경고로는 충분했다.

무엇으로 측정했나

성능은 한 가지 숫자로 설명되지 않았다. 빌드 결과는 코드가 어떻게 나뉘었는지 보여주고, Lighthouse로는 통제된 환경에서 변경 전후를 비교하도록 했다. 실제 사용자가 겪은 결과는 RUM으로 확인했다.

자료확인한 내용해석할 때 주의한 점
빌드 로그·Bundle Visualizer파일 크기, 청크 수, 정적 import 관계파일이 작아져도 초기 요청 전체가 같은 비율로 줄었다는 뜻은 아님
Lighthouse 로컬 랩같은 제한 조건에서 변경 전후 FCP·LCP·TBT 비교실행마다 편차가 있어 여러 번 측정한 평균으로 판단
Datadog RUM실제 사용자의 페이지별 FCP·LCP·INP·CLS기간마다 사용자·기기·네트워크와 상위 페이지 구성이 달라질 수 있음

개별 작업은 Lighthouse와 빌드 결과로 검증했다. 여러 개선이 쌓인 뒤 운영에서 어떤 변화가 있었는지는 RUM으로 봤다. 조건이 다른 측정값끼리는 개선율을 더하지 않았다.

2025년 — 소극적인 최적화

2025년의 최적화는 소극적이었다. 성능만 따로 붙잡고 있기보다, 눈에 띄는 문제가 보이면 흔히 알려진 방법으로 그때그때 고치는 정도였다. 무거운 라이브러리와 데이터를 초기 번들에서 걷어내고, 라우트 단위로 코드 스플리팅을 진행했다.

시기발견한 문제진행한 작업
5월lodash 전체 import로 불필요한 코드 포함import 경로 수정, 재발 방지 ESLint 규칙 추가
5~6월검색 폼에서 사용하는 대용량 증상·유전자 데이터가 화면 코드와 연결dynamic import 적용, React Query로 로딩·캐싱 통합
7월운영 번들에 MSW 포함개발·테스트 환경에서만 실행하도록 분기, Sentry 번들 최적화
8월앱 수정 시 코어 라이브러리도 같은 파일로 배포React·React Router 등을 vendor 청크로 분리
11월여러 페이지 코드가 초기 로딩에 포함라우트 코드 스플리팅, 페이지별 스켈레톤 추가

코드 스플리팅 뒤에는 화면을 이동할 때 빈 화면이 보였다. lazy loading과 함께 페이지별 스켈레톤을 넣었다. 다만 이 시기에는 같은 조건으로 남긴 전후 자료가 부족해 각 변경의 효과를 숫자로 설명하기 어렵다.

2026년 2분기 — 본격적인 시작

그렇게 1년 가까이 조금씩 고쳐 나가던 중, 2026년 3월쯤 성능 수치가 갑자기 치솟는 걸 확인했다. 팀이 커지면서 코드베이스에 쌓이는 코드가 빠르게 늘었고, 그만큼 번들도 조용히 불어난 게 아닐까 추측했다.

조금씩 고치는 방식으로는 따라잡을 수 없겠다는 생각이 들었다. 팀도 같은 문제를 느꼈고, 성능 개선은 2026년 2분기 팀 OKR 중 하나로 들어갔다. 그 과제를 내가 맡게 됐다. 이때부터 본격적으로 작업을 시작했고, 감이 아니라 측정 결과를 기준으로 변경을 판단하기로 했다.

코드를 떼어내는 작업과 별개로, 애초에 포함되는 의존성의 비용을 줄이는 작업도 이때 함께 시작했다. 내가 직접 맡은 작업은 아니지만, 팀에서는 Amplify처럼 크고 오래된 라이브러리를 업그레이드하거나 더 가벼운 대안으로 교체했다.

측정 체계 구축과 비필수 코드 지연

시기진행한 작업목적 또는 결과
3월Datadog RUM 연동실제 사용자 환경의 페이지별 성능 확인
4월PR별 번들 크기 리포트 자동화청크 크기 변화와 gzip 500kB 초과 경고 확인
4월Datadog·Sentry SDK 분리 및 지연 로딩첫 화면에 필요하지 않은 모니터링 코드 분리
4월react-select·날짜 선택 위젯 lazy 전환구현을 .impl.tsx로 분리하고 기존 진입점을 lazy 래퍼로 변경
4월회원가입·상품별 커스텀 주문 UI lazy 전환상품별 UI 디스패처를 lazy 맵으로 변경, 해당 작업 기준 메인 gzip 767 → 706KB

PR마다 번들 크기를 보이게 만들기

번들은 한 번 줄여도 다시 커진다. 기능 PR 하나가 무거운 import를 추가해도 리뷰에서는 코드만 보이고 크기 변화는 보이지 않는다. 그래서 측정을 사람의 기억에 맡기지 않고 PR 화면으로 끌어왔다.

포탈 코드가 바뀐 PR이 올라오면 GitHub Actions가 프로덕션 빌드를 돌리고, 결과를 PR 코멘트로 남긴다. 코멘트에는 세 가지가 보인다.

  • 경고: gzip 500kB를 넘는 청크
  • 의존성 Top 10: 번들에 포함된 라이브러리를 크기순으로 정렬한 상위 10개
  • 청크 목록: 청크별 번들 크기

측정값이 대시보드가 아니라 리뷰하는 자리에 있어야 실제로 보게 된다.

첫 화면에 필요 없는 서드파티 코드를 늦게 실행하기

Datadog과 Sentry는 운영에 필요한 도구지만, 모니터링 SDK 전체가 첫 화면보다 먼저 실행될 필요는 없었다. 게다가 둘 다 꽤 무거웠다. 이 코드가 전부 메인 번들에 들어 있었다.

Datadog의 초기화와 사용자 설정, 오류 전송 코드를 동적 import로 바꾸고 Sentry Replay도 기본 초기화 이후에 불러왔다. 라우트 추적에 필요한 createBrowserRouter는 정적 import로 남겨 관측 기능이 끊기지 않게 했다.

핵심만 남기면 이런 코드다.

async function initializeMonitoring() {
  const { datadogRum } = await import('@datadog/browser-rum');

  datadogRum.init(datadogConfig);
}

Sentry.init(sentryConfig);

void import('@sentry/react').then(({ replayIntegration }) => {
  Sentry.addIntegration(replayIntegration());
});

처음부터 이렇게 범위를 나눈 건 아니다. 처음에는 Datadog과 Sentry 패키지를 통째로 별도 청크로 떼어냈다. 그런데 SDK 내부에는 React와 맞물려 동작하는 어댑터도 함께 들어 있었다. 이 어댑터까지 모니터링 청크로 넘어가자 청크 간 의존성이 꼬였고, React가 로드되어야 할 시점에 제대로 로드되지 않았다. 결국 React와 연결된 부분은 그대로 두고, 첫 화면에 필요 없는 초기화·전송 코드만 늦추는 쪽으로 범위를 좁혔다.

Datadog·Sentry를 분리하자 당시 메인 번들이 7.1MB에서 5.91MB로 줄었다. 여기서 정한 원칙은 분명했다. 서드파티 스크립트를 무조건 늦추기보다 첫 화면과 관측 정확도에 필요한 코드만 남겼다.

소비처를 바꾸지 않고 무거운 위젯 분리하기

공용 컴포넌트를 쓰는 곳을 모두 고치면 변경 범위가 너무 커진다. 그래서 기존 진입점은 두고 실제 구현만 .impl.tsx로 옮겼다. 진입점은 아래처럼 lazy 래퍼로 바꿨다.

// Select.tsx — 외부에 공개된 기존 진입점
const SelectImpl = lazy(() =>
  import('./Select.impl').then(({ Select }) => ({ default: Select })),
);

export function Select(props: SelectProps) {
  return (
    <Suspense fallback={<SelectSkeleton />}>
      <SelectImpl {...props} />
    </Suspense>
  );
}

사용하는 쪽에서는 예전처럼 Select를 import한다. 실제 구현과 그 안의 react-select만 컴포넌트가 화면에 나타날 때 내려받는다. 테스트에서는 .impl.tsx를 직접 불러오도록 mock해 Suspense 없이 동기적으로 렌더했다.

Claude Code goal로 돌린 31번의 LCP 실험

4월까지는 번들에서 코드를 떼어내는 작업이 중심이었다. 그런데 RUM에서 본 주문 페이지의 LCP는 여전히 7초대였다. 6월에는 이 LCP를 직접 겨냥했다.

어떻게 줄일 수 있을지 Claude와 대화를 많이 했다. Claude는 여러 가설을 내놓았지만, 그중 우리 서비스에 현실적으로 적용할 수 있는 것을 가려내는 건 내 몫이었다. 고른 가설도 실제로 효과가 있는지는 적용하고 재봐야 알 수 있었다. 그래서 가설 하나를 적용하고 측정하는 실험을 반복하기로 했고, 이 루프를 Claude Code의 goal 기능으로 관리했다.

대화가 길어져도 판단 기준이 흐려지지 않도록 목표를 주문 페이지의 LCP를 XX초 이하로 낮춘다로 잡았다. "LCP를 개선한다"보다 끝이 분명한 문장이 필요했다. 측정 방법과 코드를 남길 조건도 goal에 함께 적었다.

당시 goal을 요약하면 다음과 같다.

목표: 주문 페이지의 LCP를 XX초 이하로 낮춘다.

반복 절차:
1. 한 번에 하나의 가설만 코드에 적용한다.
2. 프로덕션 빌드로 Lighthouse를 9회 실행한다.
3. 평균값을 직전 커밋과 비교한다.
4. 개선되고 기능 회귀가 없으면 커밋한다.
5. 악화하거나 측정 노이즈 안에 있으면 되돌린다.

Lighthouse는 실행할 때마다 편차가 커서, 한 번 측정한 값으로는 무관한 변경도 효과가 있는 것처럼 보였다. 그래서 9회 평균을 기준으로 삼았다.

이렇게 해두니 새 아이디어가 생겨도 여러 변경을 한꺼번에 섞지 않게 됐다. Claude Code는 한 가지 가설을 적용하고, 빌드하고, 반복 측정한 뒤 기준에 못 미치면 되돌렸다. 같은 과정을 31번 반복한 끝에 실제로 지표를 움직인 5개만 남겼다.

31번째 루프쯤에서 Claude는 "더 이상 시도할 수 있는 개선 방법이 없습니다"라며 손을 들었다. 마지막 즈음에는 SSR로 전환하자는 제안까지 나왔다. 당연히 불가능한 이야기였다.

채택한 변경해결하려던 문제
웹폰트 CSS 비차단 로딩폰트 stylesheet가 첫 렌더를 차단
LCP hero 이미지 preload배경 이미지가 늦게 발견됨
modulepreload 폴리필 비활성화지원 브라우저에서 불필요한 폴리필 포함
react-helmet-async 제거불필요한 의존성 부담
에셋 인라인 기준 16KB 적용작은 에셋의 개별 요청 비용

결과: 이 작업의 로컬 랩에서 LCP 약 8.5%, TBT 약 73% 감소. 단계별 수치는 동일한 기준에서 누적 비교한 결과이며, 다른 작업의 개선율과 합산하지 않았다.

실패하거나 되돌린 시도:

시도만난 이슈대응
에셋 인라인 기준 32KBLCP 약 2,067ms 악화더 많은 데이터를 인라인하는 비용을 확인하고 16KB 유지
manualChunks 분리 변경p_ is not a constructor 런타임 오류변경 되돌림
experimentalMinChunkSize 적용청크 순환 의존성변경 되돌림
treeshake preset smallest 적용필요한 부수효과 코드 제거로 앱 오류변경 되돌림

끝까지 남은 Vite 설정은 아래 두 가지다. 작은 에셋을 인라인해 요청 수를 줄이되, 32KB까지 올리면 메인 번들이 무거워졌다. 측정 결과가 더 좋았던 16KB를 경계로 삼았다.

export default defineConfig({
  build: {
    modulePreload: {
      polyfill: false,
    },
    assetsInlineLimit: 16 * 1024,
  },
});

코드 스플리팅이 늘자 배포 직후 예전 청크를 요청해 실패하는 경우도 생겼다. 재시도 래퍼(lazyWithRetry, importWithRetry)를 만들어, 한 번 재시도하고 그래도 실패하면 새로고침하도록 했다.

측정값을 흔들던 봇 트래픽 걸러내기

2분기 작업을 마치고 Datadog RUM을 다시 봤을 때, 이상할 정도로 튀는 값들이 있었다. 원인을 따라가 보니 접속 네트워크 유형(geo.as.type)이 hosting인 세션이었다. 사람이 아니라 데이터센터에서 들어오는 봇이었다. 봇의 특성인지, 사양이 낮은 환경에서 접속하는 형태가 보였다.

이런 세션이 섞이면 실제 사용자 경험과 상관없이 지표가 나빠 보인다. 그래서 Datadog에서 이 세션들을 제외하도록 필터를 걸었다.

2026년 3분기 — 로딩 경로 다듬기

최초 상호작용까지 GA4 로드 미루기

첫 화면에서 미리 받을 필요가 없던 리소스에는 GA4도 있었다. 약 162KB인 gtag.js를 사용자가 처음 클릭하거나 키를 누를 때 불러오도록 바꿨다. 그전에는 이벤트를 쌓아두는 작은 큐만 만들어 둬서 초기 이벤트도 잃지 않았다.

작은 import 하나가 UI 전체를 불러오던 문제

라우터에 필요한 건 계산 함수 하나였는데, import는 페이지의 최상위 배럴을 통하고 있었다. 이 배럴이 lib과 ui를 모두 다시 내보내면서 주문폼 UI까지 정적 의존 그래프에 딸려 들어왔다.

// @pages/order/raw-data/index.ts
export * from './lib';
export * from './ui';

// Before: 최상위 배럴을 통과하며 UI까지 연결
import { getRawDataSteps } from '@pages/order/raw-data';

// After: 계산 로직이 있는 경로만 직접 참조
import { getRawDataSteps } from '@pages/order/raw-data/lib';

tree shaking이 알아서 걸러주지 않을까 싶었지만, 모듈 스코프에서 스타일을 생성하는 코드처럼 실제 부수효과가 있는 모듈이 섞여 있어 sideEffects: false를 일괄 적용하기 어려웠다. 그래서 import 경로를 좁혀 연결 자체를 끊었다. 경로를 좁히자 초기 진입 때 바로 로드되는 청크가 112개에서 48개로 줄었다.

같은 패턴은 곳곳에 있었다. 스켈레톤은 본체 페이지에서 스타일을 가져오느라 본체 코드까지 끌고 왔다. 공통 레이아웃 스타일을 별도 파일로 빼서 둘을 떼어냈다.

주문 진입을 가볍게 만들자, 이번에는 미뤄둔 비용이 주문 첫 단계로 옮겨갔다. 첫 단계가 주문 필드 25개를 모은 배럴을 통과하고 있었기 때문이다. 필요한 필드만 직접 경로로 가져오자 주문폼 진입 때 바로 로드되는 코드가 762KB에서 126KB로 줄었다. 다음 단계를 미리 받는 prefetch도 같은 배럴 탓에 여러 단계의 코드를 한꺼번에 받고 있었다. 배럴을 아예 없애고 재발 방지 규칙을 추가하자, 미리 받는 코드는 gzip 223KB에서 50KB가 됐다.

이 경험으로 lazy()를 추가했다고 코드 스플리팅이 끝나는 건 아니라는 걸 알았다. 경계 바깥의 정적 import가 내부 구현을 가리키면 떼어낸 코드는 다시 초기 번들로 돌아온다.

한 번 고친다고 끝나지도 않았다. 9월에는 다른 UI 변경으로 배송지 기능의 배럴 import가 엔트리에 다시 연결됐다. 직접 경로로 바꾸고 청크 배치를 조정하자, 엔트리와 정적 의존 청크의 합산 gzip 크기는 757.3KB에서 659.2KB로, 청크는 59개에서 40개로 줄었다.

요청 왕복과 첫 렌더 경로 단축

9월에는 파일 크기보다 로딩 순서를 더 자세히 봤다. 같은 바이트를 받아도 요청이 이어지는 순서와 초기화 대기 때문에 첫 화면은 늦어질 수 있었다.

청크를 순차적으로 발견하는 왕복 줄이기

네트워크 기록을 보다가 이상한 점을 발견했다. 561B짜리 파일과 26KB짜리 파일이 똑같이 약 180ms씩 걸리고 있었다. 크기는 50배 가까이 차이 나는데 시간은 같았다. 시간을 잡아먹는 건 전송량이 아니라 요청 왕복이었다. 브라우저는 청크 하나를 받아 해석한 뒤에야 다음에 필요한 청크를 알 수 있었고, 그때마다 왕복이 한 번씩 더 생겼다.

번들을 잘게 쪼개 둔 게 오히려 발목을 잡은 셈이었다. 화면을 그리려면 어차피 필요한 청크를 전부 받아야 하는데, 잘게 나뉜 만큼 요청이 늘어 네트워크 비용이 더 들었다.

원인을 따라가 보니 6월에 초기 FCP를 줄이려고 넣은 critical-only preload 필터가 동적 import에도 적용되고 있었다. lazy 라우트가 의존 청크의 preload 힌트를 받지 못한 것이다. 초기 HTML에는 기존 필터를 유지하고 동적 import에는 전체 의존 목록을 돌려주도록 hostType으로 분기했다.

resolveDependencies: (_filename, deps, { hostType }) =>
  hostType === 'html'
    ? deps.filter(criticalOnly) // 첫 진입: 필수 청크만
    : deps;                     // 화면 이동: 의존 청크를 함께 발견

전송 바이트는 그대로였지만 라우트 로딩 왕복은 6회에서 2회로, 소요 시간은 1,265ms에서 773ms로 줄었다.

탭을 눌러 두 워터폴을 비교해보자.

라우트 청크
의존 청크 A
의존 청크 B
의존 청크 C
의존 청크 D
의존 청크 E

왕복 6회 · 총 1,265ms

막대 길이는 도식, 왕복 수·총 시간은 측정값

청크를 받아 해석해야 다음 의존 청크를 알 수 있어 요청이 한 줄로 늘어선다.

첫 렌더를 막는 초기화 대기 제거

인증 초기화 코드에는 isCognitoInitialized라는 플래그가 있었다. AuthContextProvider가 마운트된 뒤 effect에서 Amplify를 설정하고, 설정이 끝나기 전까지는 null을 반환했다. React가 실행된 뒤에도 첫 effect가 끝날 때까지 #root가 비어 있던 이유다. 주변 로직을 덜어내면 이런 구조였다.

// Before: AuthContextProvider.tsx
function AuthContextProvider({ countryCode, children }: Props) {
  const [isCognitoInitialized, setIsCognitoInitialized] = useState(false);

  useEffect(() => {
    configureAmplify(countryCode);
    setIsCognitoInitialized(true);
  }, [countryCode]);

  // Amplify 설정을 위해 첫 렌더 전체를 비운다.
  if (!isCognitoInitialized) return null;

  return (
    <AuthContext.Provider value={authContextValue}>
      {children}
    </AuthContext.Provider>
  );
}

Amplify 설정에 필요한 국가 코드는 쿠키에서 동기적으로 읽을 수 있었다. 설정을 Provider의 effect까지 미룰 이유가 없었다. 쿠키 읽기를 순수 함수로 분리하고, React를 시작하기 전에 설정이 끝나도록 엔트리로 옮겼다.

// After: main.tsx
const countryCode = getCountryCodeFromCookie();

configureAmplify(countryCode);

createRoot(document.getElementById('root')!).render(
  <AuthContextProvider>
    <App />
  </AuthContextProvider>,
);

이제 첫 컴포넌트부터 Amplify가 설정된 상태에서 렌더된다. isCognitoInitialized와 return null도 필요 없어졌다. 이 게이트는 인증 요청을 기다리는 장치가 아니라 첫 렌더를 한 번 건너뛰게 할 뿐이었다. 실제 인증 요청을 보호하는 isAuthLoading은 그대로 뒀다.

React 실행 전에도 로딩 화면 표시

마지막으로 JavaScript가 준비되기 전의 빈 화면을 다뤘다. HTML의 #root가 비어 있어 첫 페인트가 React의 첫 렌더까지 기다리고 있었다. index.html에 부트 셸을 넣어 그동안 로딩 화면을 보여줬다.

<div id="root">
  <div
    class="boot-shell"
    style="background-image: url('data:image/webp;base64,...')"
  >
    <img src="/assets/loading.webp" alt="" />
  </div>
</div>

첫 프레임은 data URI로 인라인해 추가 요청 없이 표시하고, 실제 애니메이션 이미지는 그 위에서 불러왔다. 제거 스크립트는 따로 두지 않았다. React가 처음 커밋할 때 #root 안의 부트 셸을 앱 화면으로 바꾸기 때문이다.

사용자가 보는 화면이 어떻게 달라졌는지 타임라인으로 보면 이렇다.

BeforeFCP 6,536ms
AfterFCP 1,620ms
HTML 수신흰 화면부트 셸React 화면FCP

FCP는 로컬 랩 측정값, 구간 길이는 도식. 부트 셸은 첫 페인트를 앞당길 뿐 React 화면이 준비되는 시점은 그대로다.

로컬 랩에서 FCP는 6,536ms에서 1,620ms로 약 75.2% 줄었다. 대신 HTML gzip이 약 3.2KB 늘었고, 애니메이션 리소스를 먼저 받으면서 실제 콘텐츠가 LCP인 일부 측정 대상에서는 LCP가 약 300ms 증가했다. 첫 시각적 피드백이 빨라진 것과 실제 콘텐츠가 준비되는 시간은 따로 봐야 했다.

2026년 9월에는 어디까지 달라졌나

각 작업은 기준 커밋과 측정 조건이 달라 개선율을 한데 더할 수 없다. 전체 변화는 처음과 마지막 빌드, 비교 조건을 맞춘 두 RUM 기간으로 나눠 봤다.

하나의 큰 번들에서 여러 로딩 단위로

항목2025년 4월 4일2026년 9월 28일변화
JavaScript 청크 수1개314개313개 증가
JavaScript 원본 총량12,814.85kB10,592.14kB약 17.3% 감소
JavaScript gzip 총량4,347.49kB3,181.91kB약 26.8% 감소
엔트리 JavaScript gzip4,347.49kB235.76kB약 94.6% 감소

전체 코드 총량보다 엔트리 크기가 훨씬 큰 폭으로 줄었다. 필요한 코드를 한 번에 받는 대신 여러 시점에 나눠 받게 된 결과다.

그렇다고 엔트리 gzip이 94.6% 줄었다는 수치를 초기 전송량의 감소율로 볼 수는 없다. 엔트리가 정적으로 참조하는 청크와 preload 파일도 첫 로드에 참여한다. 청크 314개 역시 그 자체로 성과가 아니다. 어느 파일을 언제 요청하는지까지 함께 봐야 한다.

주요 진입 페이지의 실사용자 지표도 개선됐다

Datadog RUM에서는 2026년 4월 13~19일과 9월 24~30일을 비교했다. 두 기간 모두 7일이고, 국가 필터는 NOT South Korea로 같았다. 양쪽에 공통으로 나타난 페이지 가운데 주요 결과는 다음과 같다.

페이지LCP 4월 → 9월FCP 4월 → 9월
주문 진입 /order7.32초 → 3.12초6.17초 → 1.33초
홈 /4.12초 → 1.53초3.12초 → 1.58초
로그인 /signin2.80초 → 1.58초2.29초 → 0.64초
검사 목록 /my-tests/status/all2.59초 → 1.55초2.51초 → 1.55초
결제 완료 /payment-complete4.39초 → 3.82초2.29초 → 3.82초
주문 1단계 /order/:service/11.60초 → 1.68초1.53초 → 1.76초

주문 진입 페이지의 LCP는 약 57.4%, FCP는 약 78.4% 줄었다. 두 기간에 공통으로 잡힌 8개 페이지 가운데 LCP가 Good인 페이지는 2개에서 6개로 늘었다. FCP도 2개에서 6개로 늘었다.

표에는 RUM 화면에 표시된 페이지별 집계값을 옮겼다. 스크린샷만으로 집계 백분위 설정을 알 수 없어 특정 백분위 값이라고 쓰지는 않았다. 국가 필터가 같아도 두 기간의 사용자, 기기, 네트워크 구성은 달라질 수 있다. 개별 변경 하나의 효과를 증명하는 자료는 아니지만, 개선이 쌓인 뒤 주요 페이지의 운영 지표가 좋아진 흐름은 볼 수 있었다.

2025년 Lighthouse와 이 RUM 수치는 측정 방식과 시기가 달라 직접 비교하지 않았다.

모든 페이지가 함께 좋아지지는 않았다

모든 페이지가 좋아진 것은 아니다. 결제 완료 페이지의 FCP는 2.29초에서 3.82초로 늘었고 주문 1단계도 소폭 증가했다. 주문 진입 페이지 역시 큰 폭으로 개선됐지만 LCP 3.12초로 여전히 주의 등급이다.

과정에서 얻은 기준

  • 파일 분리와 초기 로딩 분리는 다르다. lazy 경계를 우회하는 정적 import까지 확인해야 한다.
  • 첫 진입과 다음 화면을 함께 측정한다. 첫 화면에서 미룬 비용이 다음 단계에 쌓일 수 있다.
  • 크기와 요청 순서를 함께 본다. 작은 파일도 순차 왕복이 많으면 로딩이 느려진다.
  • 첫 페인트와 콘텐츠 준비를 구분한다. 부트 셸로 FCP가 줄어도 실제 기능이 같은 만큼 빨라지는 것은 아니다.
  • 변경을 남기는 기준은 측정과 정상 동작이다. 효과가 불분명하거나 회귀가 생기면 되돌리고, 재발 방지 규칙을 함께 남긴다.

돌아보면 처음에는 4.35MB라는 크기만 봤다. 번들을 나누고 나서는 import 한 줄이 어떤 코드를 끌고 들어오는지, 브라우저가 청크를 몇 번에 걸쳐 발견하는지, React가 그리기 전에 무엇을 기다리는지까지 보게 됐다.

그 결과 엔트리는 gzip 235.76KB가 됐고 /order의 운영 FCP는 1.33초까지 내려왔다.

성능 최적화는 한 번에 끝나는 작업이 아니었다. 번들은 기능이 쌓이는 만큼 다시 커지고, 한 번 고친 배럴 import도 다른 변경으로 되살아났다. 앞으로도 PR마다 번들 크기를 확인하고 RUM 지표를 지켜보면서, 나빠지는 지점을 그때그때 다듬어 갈 생각이다.

  • 처음 상태: 4.35MB 단일 번들과 흰 화면
  • 무엇으로 측정했나
  • 2025년 — 소극적인 최적화
  • 2026년 2분기 — 본격적인 시작
  • 측정 체계 구축과 비필수 코드 지연
  • PR마다 번들 크기를 보이게 만들기
  • 첫 화면에 필요 없는 서드파티 코드를 늦게 실행하기
  • 소비처를 바꾸지 않고 무거운 위젯 분리하기
  • Claude Code goal로 돌린 31번의 LCP 실험
  • 측정값을 흔들던 봇 트래픽 걸러내기
  • 2026년 3분기 — 로딩 경로 다듬기
  • 최초 상호작용까지 GA4 로드 미루기
  • 작은 import 하나가 UI 전체를 불러오던 문제
  • 요청 왕복과 첫 렌더 경로 단축
  • 청크를 순차적으로 발견하는 왕복 줄이기
  • 첫 렌더를 막는 초기화 대기 제거
  • React 실행 전에도 로딩 화면 표시
  • 2026년 9월에는 어디까지 달라졌나
  • 하나의 큰 번들에서 여러 로딩 단위로
  • 주요 진입 페이지의 실사용자 지표도 개선됐다
  • 모든 페이지가 함께 좋아지지는 않았다
  • 과정에서 얻은 기준
  • 처음 상태: 4.35MB 단일 번들과 흰 화면
  • 무엇으로 측정했나
  • 2025년 — 소극적인 최적화
  • 2026년 2분기 — 본격적인 시작
  • 측정 체계 구축과 비필수 코드 지연
  • PR마다 번들 크기를 보이게 만들기
  • 첫 화면에 필요 없는 서드파티 코드를 늦게 실행하기
  • 소비처를 바꾸지 않고 무거운 위젯 분리하기
  • Claude Code goal로 돌린 31번의 LCP 실험
  • 측정값을 흔들던 봇 트래픽 걸러내기
  • 2026년 3분기 — 로딩 경로 다듬기
  • 최초 상호작용까지 GA4 로드 미루기
  • 작은 import 하나가 UI 전체를 불러오던 문제
  • 요청 왕복과 첫 렌더 경로 단축
  • 청크를 순차적으로 발견하는 왕복 줄이기
  • 첫 렌더를 막는 초기화 대기 제거
  • React 실행 전에도 로딩 화면 표시
  • 2026년 9월에는 어디까지 달라졌나
  • 하나의 큰 번들에서 여러 로딩 단위로
  • 주요 진입 페이지의 실사용자 지표도 개선됐다
  • 모든 페이지가 함께 좋아지지는 않았다
  • 과정에서 얻은 기준
📚

Work Essay

  1. ✅1. 프론트엔드 번들 캐시버스팅을 구현해봤다
  2. 🔍2. 큰 번들에서 시작한 포탈 성능 최적화 회고
←프론트엔드 번들 캐시버스팅을 구현해봤다

관련 포스트

이 포스트와 관련된 다른 글들을 확인해보세요

큰 번들에서 시작한 포탈 성능 최적화 회고

gzip 4.35MB 단일 번들에서 시작해 import 그래프, 요청 순서, 첫 렌더 경로까지 따라가며 /order FCP를 6.17초에서 1.33초로 줄인 1년 반의 기록

2026년 10월 9일•5분
frontendperformancevite+2

프론트엔드 번들 캐시버스팅을 구현해봤다

배포 후 사용자가 이전 버전을 보는 문제를 해결하기 위해 클라이언트 레벨 캐시버스팅과 CloudFront Function 기반 인프라를 구축한 과정

2025년 12월 7일•5분
frontenddeploymentcache-busting+4

프론트엔드 UI 스타일 카탈로그 — 67개 스타일 실전 가이드

Glassmorphism부터 Neubrutalism까지, 67개 UI 스타일을 카테고리별로 정리하고 CSS 구현 핵심과 장단점을 비교했다.

2026년 4월 8일•9분
ui-uxcssdesign+2

Comments