blog

Vue 리포트 페이지 성능 개선기: 컴포넌트 제거 샘플링으로 병목 찾기

Front-end / Vue#Vue#성능 최적화#Chrome DevTools#Lighthouse#LCP#번들 사이즈

사내 서비스의 리포트 편집 페이지가 진입할 때마다 수 초씩 멈추는 문제가 있었다. Vue DevTools의 Flame 그래프와 Chrome DevTools Performance 패널로 병목을 좁혀 간 과정을 정리했다. 측정값은 한 개발 환경에서 같은 조건으로 반복 측정한 것이라 절대값보다는 변화의 방향을 보는 용도로 읽어야 한다.

1. Flame 그래프로 본 첫 진단

진입 시 Flame 그래프를 보면, 리포트 본문을 감싸는 확대·축소 가능한 콘텐츠 컴포넌트(ZoomableContents)와 그 안의 페이지 컴포넌트들이 여러 번 patch되고 있었다.

항목개선 전개선 후
렌더링 사이클5회 이상 반복1회
ZoomableContents patch여러 번1회
styled 컴포넌트 호출많고 중복 호출 다수크게 줄어듦
i18n 관련 타임라인여러 번 병렬 실행거의 없음
컴포넌트 깊이깊고 복잡상대적으로 얕음
KeepAlive없음적용

최종적으로는 불필요한 리렌더링을 없애 렌더 사이클이 한 번에 끝나도록 만들었고, 탭 전환처럼 같은 화면을 오가는 구간에는 KeepAlive로 컴포넌트 상태를 캐싱했다. 아래는 그 원인을 찾아간 과정이다.

2. 최소 샘플링: 하나씩 빼 보기

원인을 추측하는 대신, 페이지를 구성하는 요소를 하나씩 제거하며 Performance 패널의 Summary(Scripting, Rendering, Painting, Idle, 총 시간)를 비교했다.

네트워크 제거

API 호출을 모두 더미 데이터로 바꿨다. 효과는 미미했다. 네트워크 지연이 주원인이 아니라는 것을 먼저 확인했다.

스타일 하나씩 제거

애니메이션, transition, position 계산처럼 렌더링에 영향을 주는 스타일부터 비활성화했다.

항목값변화
총 시간5,411ms거의 같음
Scripting1,325ms약 270ms 감소
Rendering27ms55ms에서 절반으로
Painting19ms거의 없음

스타일 계산 비용은 줄었지만 전체 시간에는 큰 영향이 없었다. 병목은 스크립트 쪽에 있었다.

컴포넌트 하나씩 제거

제거한 컴포넌트총 시간Scripting판단
편집 헤더5,520ms1,415ms (약 175ms 감소)보조적 비용
기본 정보 모달3,136ms1,327ms (약 260ms 감소)총 시간 크게 감소
템플릿 추가 모달5,750ms1,534ms영향 없음 (측정 오차 범위)
Report 영역4,781ms682ms (약 900ms 감소)핵심 병목
의심 영역 표시부2,971ms1,445ms총 시간 감소, Scripting은 그대로
메인 컨트롤3,036ms1,560ms영향 없음

Scripting이 가장 크게 줄어든 것은 Report 영역이었다. 그래서 Report 내부를 다시 같은 방식으로 쪼갰다.

Idle 시간은 메인 스레드가 일을 하지 않고 기다린 시간이다. 리소스 로딩이나 타이머를 기다리는 시간도 여기에 들어가므로, Idle이 크다고 바로 JS 병목이라고 볼 수는 없다. 모달 같은 컴포넌트를 뺐을 때 총 시간과 Idle이 함께 줄어든 것은, 그 컴포넌트가 불러오던 리소스나 지연 작업이 사라졌기 때문으로 해석했다.

Report 내부 샘플링

제거한 요소총 시간Scripting판단
PDF 생성 라이브러리(vue3-html2pdf)3,408ms2,187ms총 시간 약 2.3초 감소
미리보기(Preview)1,997ms969ms총 시간·Scripting 모두 크게 감소
컨트롤 섹션2,755ms1,627ms영향 작음

PDF 생성 라이브러리를 뺐을 때 Scripting은 오히려 높게 측정됐지만 총 시간은 크게 줄었다. 단일 측정이라 Scripting 수치 자체는 신뢰하기 어려웠고, 총 시간 감소에 주목했다. 이 라이브러리는 내부에서 html2canvas와 jsPDF를 쓰며 DOM을 캔버스로 그리는 무거운 작업을 한다. 페이지가 열릴 때 함께 로드·초기화되면 진입 비용이 커진다.

미리보기 컴포넌트도 렌더링할 DOM이 많고 반복 연산이 있어 두 번째 핵심 병목이었다.

3. Report 최적화

PDF 품질(scale) 조정: 3 → 2

항목값
총 시간3,294ms
Scripting2,023ms (약 160ms 감소)

html2canvas의 scale은 가로·세로 배율이라, 3 → 2로 줄이면 처리할 픽셀 수가 9배에서 4배로, 약 44% 수준으로 줄어든다. 다만 PDF 품질과 맞바꾸는 조정이므로 출력물을 확인한 뒤 결정했다.

PDF 라이브러리 dynamic import

const generatePdf = async () => {
  const { default: html2pdf } = await import('html2pdf.js');
  // ...
};

PDF는 사용자가 저장 버튼을 누를 때만 필요하다. 동적 import로 바꾸자 페이지 진입 시 이 라이브러리를 파싱·실행하지 않게 되어 초기 로딩이 가벼워졌다(총 시간 3,255ms).

변하지 않는 영역에 v-once / v-memo

리포트 상단의 병원명·헤더처럼 진입 후 바뀌지 않는 영역을 분리하고 v-once, v-memo를 적용했다.

항목값
총 시간3,342ms
Scripting1,330ms
  • v-once: 처음 한 번만 렌더링하고 이후 반응형 값이 바뀌어도 다시 그리지 않는다. 고정 제목, 레이블 등에 쓴다.
  • v-memo="[deps]": 의존 값이 같으면 하위 트리의 업데이트를 건너뛴다. 조건이 바뀌면 다시 그린다.
<h1 v-once>{{ title }}</h1>

<div v-memo="[user.id]">
  {{ user.name }} <!-- user.id가 같으면 name이 바뀌어도 갱신 안 됨 -->
</div>

v-memo는 의존 배열에 빠진 값이 바뀌어도 화면이 갱신되지 않으므로, 의존 값을 정확히 넣어야 한다.

4. Lighthouse 점수 개선

배경 이미지 → img 태그

// Before: 배경 이미지로 표시
export const ReportImgWrapper = styled('div')`
  background: ${({ url }) => (url ? `url(${url}) black 50% / contain no-repeat` : '#dde2e8')};
`;
<!-- After -->
<img :src="isFetching ? placeholder : getImagePath(imagePath)" alt="리포트 이미지" loading="lazy" />

CSS 배경 이미지는 브라우저가 CSS를 해석한 뒤에야 발견하므로 늦게 요청되고, loading="lazy"나 fetchpriority 같은 제어도 할 수 없다. <img>로 바꾸면 이런 속성을 쓸 수 있고, 반응형 이미지(srcset)나 WebP·AVIF 적용도 쉬워진다.

주의할 점은 loading="lazy"를 LCP 이미지(첫 화면의 가장 큰 콘텐츠)에 쓰면 오히려 LCP가 늦어진다는 것이다. 첫 화면 밖 이미지에만 쓴다.

LCP 이미지에 fetchpriority="high"

용량이 큰 SVG 일러스트를 컴포넌트로 import하던 것을 <img>로 바꾸고, 첫 화면의 LCP 요소에는 높은 우선순위를 줬다.

<!-- Before: SVG를 컴포넌트로 import → JS 번들에 포함 -->
<IntroIllustration v-if="category === 'ST'" />

<!-- After: 별도 파일로 요청 -->
<img v-if="category === 'ST'" :src="introIllustrationUrl" alt="" fetchpriority="high" />
  • SVG를 컴포넌트로 import하면 마크업이 JS 번들에 들어가 초기 JS가 커진다. 큰 SVG가 많으면 번들이 크게 부풀어 오른다.
  • <img>로 바꾸면 번들에서 분리되어 별도 요청으로 처리되고, 브라우저 캐시를 재사용하며, preload·fetchpriority 같은 우선순위 제어도 가능하다.
  • 장식용 이미지라면 alt=""로 스크린 리더가 건너뛰게 한다.

5. 번들 사이즈 줄이기

vite-bundle-visualizer로 번들 구성을 확인했다.

가장 큰 파일: 로그인 Lottie JSON

로그인 화면의 Lottie 애니메이션 JSON이 번들에서 가장 큰 비중을 차지했다. import하던 것을 public/에 두고 런타임에 fetch하도록 바꿨다.

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { LottieAnimation } from 'lottie-web-vue';

const animationData = ref<object | null>(null);

onMounted(async () => {
  const response = await fetch('/lotties/login.json'); // public/lotties/login.json
  animationData.value = await response.json();
});
</script>

<template>
  <LottieAnimation v-if="animationData" :animation-data="animationData" auto-play loop />
</template>

무거운 라이브러리

  • PDF·캔버스 관련(html2pdf.js, jspdf, html2canvas): 동적 import로 필요한 시점에만 로드했다.
  • crypto-js: 전체를 import하면 모든 알고리즘이 번들에 들어간다. crypto-js는 CommonJS 패키지라 import { SHA256 } from 'crypto-js'처럼 이름으로 가져와도 tree-shaking이 되지 않는다. 필요한 모듈 경로를 직접 import한다.
// ❌ 전체 포함
import CryptoJS from 'crypto-js';

// ✅ 필요한 알고리즘만
import sha256 from 'crypto-js/sha256';

브라우저 내장 Web Crypto API(crypto.subtle.digest('SHA-256', data))로 대체할 수 있다면 의존성 자체를 없앨 수 있다.

  • 다국어 파일: 모든 언어 JSON이 기본 번들에 들어가 있었다. 현재 언어만 동적으로 불러오는 방식(lazy loading locale)은 다음 과제로 남겼다.

결과

Lottie JSON이 번들에서 사라졌고, 청크가 실제로 쓰이는 라우트·컴포넌트·라이브러리 중심으로 정리되었다.

6. 결과

항목개선 전개선 후
페이지 진입 총 시간(DevTools)약 5.7초약 3.3초
Scripting약 1.6~2.1초약 1.3초
LCP약 35초 (Lighthouse)약 1.8초 (DevTools)

LCP 수치는 측정 도구와 조건(Lighthouse는 느린 네트워크·CPU를 흉내 냄)이 달라 그대로 비교할 수는 없지만, LCP 이미지 처리와 번들 분리로 체감 속도가 크게 좋아진 것은 분명했다. JS 실행 시간과 메인 스레드 블로킹(약 2초)은 크게 줄지 않아 다음 개선 과제로 남았다.

배운 점

  1. 추측보다 제거 실험: 컴포넌트를 하나씩 빼며 측정하니, 처음 의심했던 곳(네트워크, 스타일)이 아닌 곳에서 핵심 병목이 나왔다.
  2. 한 번의 측정을 믿지 않기: 같은 조건에서도 수백 ms씩 흔들린다. 여러 번 측정해 경향을 보고, 수치가 이상하면 다시 잰다.
  3. 필요할 때만 불러오기: PDF 생성처럼 사용자 동작이 있어야 쓰는 기능은 동적 import 하나로 진입 비용을 크게 줄일 수 있다.
  4. 기능은 그대로: PDF 품질, 미리보기, 사용자 흐름은 바꾸지 않고 로딩·렌더링 시점만 조정했다.