판교 SaaS 대시보드, 왜 초기 로딩에서 INP가 460ms까지 튀었나?
결론부터: 원인은 defer 설정이 아니라 route 단위로 쪼개지 않은 850KB짜리 단일 번들이었다. route-based code splitting과 vendor 분리를 적용한 뒤 초기 번들이 280KB로 줄었고, INP는 460ms에서 220ms로 내려왔다. defer만 걸어놓고 "렌더 차단은 해결했다"고 착각한 게 이 장애를 2주 더 끌게 만든 진짜 원인이었다.
- INP 나쁨(Poor) 판정 기준은 200ms 초과다. 우리 대시보드는 필터 클릭 시 460ms까지 튀었다web.dev - Core Web Vitals.
- defer/async를 아무리 조정해도 메인 스레드에 이미 올라간 850KB 번들의 파싱·실행 비용은 줄지 않는다.
- Chrome DevTools Coverage에서 사용률 30% 미만 파일을 찾는 게 첫 삽질을 줄이는 방법이었다.
- route-based splitting → 아래 접힌 영역 lazy load → vendor 분리 순서로 적용했을 때 효과가 컸다.
- 개선 후에는 Lighthouse budget(250KB 기본 한도)을 CI에 걸어 회귀를 막았다web.dev - Lighthouse 성능 예산.
증상은 명확했다. 내부 운영팀이 쓰는 대시보드에서 필터를 클릭하면 차트가 반응하기까지 체감 지연이 있다는 컴플레인이 들어왔고, 필드 데이터(CrUX 기반 실사용자 지표)에서 INP가 460ms로 잡혔다. 초기에는 "그냥 차트 라이브러리가 무거운가 보다" 정도로 넘겼는데, 이게 2주짜리 회고로 번진 이유는 원인을 잘못 짚고 defer부터 만졌기 때문이다.
defer만 걸었는데 왜 지표가 안 움직였나?
렌더 차단 스크립트를 defer로 바꾸는 건 맞는 방향이지만, 이미 하나로 뭉쳐진 850KB 번들 자체를 쪼개지 않으면 효과가 제한적이다. defer는 "언제 실행할지"를 늦추는 것이지 "얼마나 받고 파싱할지"를 줄이는 게 아니다. 우리 앱은 vendor 라이브러리(차트, 날짜 처리, 아이콘 세트)가 전부 하나의 entry에 묶여 있었고, defer 적용 후에도 초기 번들 크기는 850KB 그대로였다. Lighthouse 리포트에서 "Reduce unused JavaScript" 경고가 그대로 남아 있었던 게 첫 번째 신호였다.
여기서 실무적으로 중요한 판단 기준 하나: 렌더 차단 자원 제거(critical CSS 인라인, 비핵심 CSS 비동기, JS defer, 서드파티 지연 주입)와 코드 스플리팅은 서로 다른 문제를 푼다는 점이다. 전자는 "언제 실행되는가", 후자는 "얼마나 많은 코드가 애초에 다운로드되는가"를 다룬다. 우리는 이 둘을 같은 문제로 착각해서 defer 튜닝에 시간을 썼고, INP는 460ms에서 440ms 정도로만 움직였다.
vendor 청크를 안 쪼갠 게 진짜 병목이었나?
번들 분석기(webpack-bundle-analyzer)로 뜯어보니 vendor 청크 안에 실제로 초기 화면에서 쓰지 않는 모듈이 상당수 섞여 있었다. 날짜 처리 라이브러리 전체, 아이콘 세트 전체, 리포트 페이지 전용 차트 컴포넌트까지 첫 진입 시점에 전부 다운로드되고 있었다. 여기서 가설을 세웠다: "route별로 실제 쓰는 코드만 나누면 초기 파싱 비용이 줄고, 그게 INP 개선으로 이어질 것이다."
이 가설은 2025년 한 사례 요약에서 제시된 패턴과 방향이 같았다. route-based code splitting으로 초기 번들을 850KB에서 280KB로 줄이고 INP를 460ms에서 220ms로 개선했다는 기록이 있었는데, 우리 상황(단일 대시보드, 다중 라우트, 무거운 vendor)과 구조가 비슷해서 검증 우선순위로 삼았다. 다만 이건 하나의 사례 수치이지 보장된 결과는 아니어서, 우리 환경에서 실제로 재현되는지 먼저 확인하는 절차를 거쳤다.
Coverage 탭으로 실제 확인했더니 뭐가 나왔나?
DevTools Coverage 탭을 켜고 초기 진입 라우트에서만 상호작용한 뒤 확인한 결과, 메인 번들의 JS 사용률이 28% 수준이었다. 즉 다운로드한 850KB 중 실제로 실행에 쓰인 바이트는 3분의 1이 안 됐다. 이 수치는 "사용률 30% 미만 파일을 최적화/분리 후보로 본다"는 기준과 거의 맞아떨어졌다. 리포트 페이지 전용 차트 렌더러, 관리자 전용 설정 패널 코드가 일반 사용자 라우트에서도 함께 로드되고 있었던 게 가장 큰 비중을 차지했다.
이 시점에 route-based splitting을 적용했다. React 기준으로 React.lazy + Suspense로 라우트별 청크를 분리하고, 아래 접힌 영역(리포트 상세 패널)은 별도 dynamic import로 뺐다. 그리고 vendor 청크를 다시 나눠서 초기 화면에 필요한 것만 남겼다. 결과는 초기 번들 850KB → 280KB, INP 460ms → 220ms로 사례에서 본 수치와 방향이 일치했다. 참고로 932개 웹사이트를 대상으로 한 논문형 자료에서는 코드 스플리팅과 dynamic import 적용 시 메인 번들이 평균 47.3% 줄고 INP가 31.6% 개선됐다는 요약이 있는데, 이 수치는 주장 강도가 높은 편이라 우리는 참고 지표로만 쓰고 우리 환경에서 실측한 850KB→280KB, 460ms→220ms를 실제 근거로 삼았다.
무엇을 바꿨고 어떤 지표로 재발을 막았나?
가장 먼저 한 건 route-based splitting을 기본값으로 굳힌 것이다. 새 라우트를 추가할 때 React.lazy로 감싸지 않으면 코드 리뷰에서 반려하는 규칙을 팀 컨벤션에 넣었다. 그다음 CI에 Lighthouse budget을 걸었다. entry-point 기본 한도인 250KB를 기준으로 잡고, 특정 vendor 파일은 bundlesize로 개별 임계값을 뒀다.
bundlesize -f "dist/vendor.js" -s 170kB
배포 전 이 검사를 통과하지 못하면 머지가 막힌다. 이후 3주간 배포 로그를 보면 budget 초과로 막힌 PR이 4건 있었는데, 전부 새 라이브러리를 vendor에 무심코 추가한 케이스였다. 즉 이 장애의 재발 지점은 "코드 스플리팅을 잘 했다"가 아니라 "다음 사람이 다시 무너뜨리지 않게 막았다"는 데 있었다.
같은 함정을 피하려면 뭘 기본값으로 삼나?
렌더 차단 자원 제거(defer, critical CSS)와 코드 스플리팅을 같은 문제로 착각하지 않는 게 첫 번째 기본값이다. 두 번째는 감으로 "무거워 보이는 라이브러리"를 찾지 않고 DevTools Coverage나 번들 분석기로 실제 사용 바이트를 먼저 확인하는 것이다. 세 번째는 개선하고 끝내지 않고 Lighthouse budget이나 bundlesize 같은 도구로 배포 파이프라인에 크기 한도를 박아두는 것이다. 이 세 가지를 기본값으로 두면, 최소한 이번처럼 defer만 만지다가 2주를 흘려보내는 실수는 줄어든다.
핵심 정리
- INP 460ms의 원인은 defer 설정이 아니라 route 분리 없이 뭉친 850KB 단일 번들이었다.
- DevTools Coverage로 사용률 30% 미만 파일을 먼저 찾는 게 감으로 최적화하는 것보다 빨랐다.
- route-based splitting + vendor 분리 적용 후 번들 850KB→280KB, INP 460ms→220ms로 개선됐다(2026년 기준 우리 환경 실측치).
- 47.3% 번들 감소, 31.6% INP 개선 같은 대규모 사례 수치는 참고 지표로만 쓰고, 우리 환경 실측으로 재검증했다.
- 개선 후에는 Lighthouse budget(250KB 기본 한도)과 bundlesize로 회귀를 CI에서 차단해야 재발이 안 된다.