들어가며
나는 서비스에서 가장 중요한 건 서비스의 기능이라 생각한다. 그 기능이 사용자가 느끼던 불편함을 시원하게 해결해주는가?가 사용자들이 서비스를 이용하게 만들고, 계속 찾아오게 만든다고 생각한다. 그래서 나는 서비스를 만들 때, 매일매일 그 서비스를 직접 이용하는 사용자가 되어 불편한 점들을 찾고, 개선하고는 한다.
서비스의 기능 다음으로 중요한 건 사용자 경험이라 생각한다. 사용자 경험(UX, User eXperience)이란 사용자가 서비스를 사용하면서 느끼는 전반적인 총체적인 경험을 말하며 성능, 사용성, 유용성, 접근성 등이 있다. 이 중에서 사용자 경험에 가장 큰 영향을 미치는건 성능이라 생각한다. 아무리 뛰어난 사용성을 갖춘 서비스를 만들었다고 하더라도 서비스를 이용하기 위해서 서비스를 접속하는데 로딩만 3초가 걸린다? 이미 사용자는 떠나가있을 것이다.
로딩이 3초를 넘어가면 사용자는 떠난다. 나부터도 그렇다. 모바일이라면 더하다. 좋은 기능도 사용자가 보기 전에 떠나면 소용없다.
이번 글에서는 성능을 개선하기 위한 방법을 크게 로딩과 렌더링 두 가지로 나누어서 이야기를 해보고자 한다.
로딩 성능 개선하기
로딩 성능 개선은 크게 두 갈래다. 요청하는 리소스의 크기를 줄이거나, 요청 횟수 자체를 줄이거나. 둘 다 결국 오가는 데이터를 줄이자는 거다.
요청 크기 줄이기
- 텍스트 컨텐츠 (JavaScript, CSS)
웹 애플리케이션의 JavaScript와 CSS 파일은 개발할 때는 읽기 좋게 작성하지만, 사용자에게 전달할 때는 최대한 압축해서 보내는 게 좋다.
1. minify : 공백, 줄바꿈, 주석 제거
코드 압축의 가장 기본으로 코드에서 불필요한 공백, 줄바꿈, 주석을 모두 제거하는 방식인데, 가독성은 떨어지지만 파일 크기는 확실히 줄어든다.




위 예시를 보면 bundle.js 파일이 minify 적용 전 970KB에서 적용 후 218KB로 줄어든 걸 확인할 수 있다. 약 77%나 감소한 셈이다.
2. Uglify : 변수명이나 함수명을 짧게 변경
Uglify는 한 단계 더 나아가서 변수명이나 함수명을 짧게 바꾼다. getUserInformation이 a가 되고, currentUserData가 b가 되는 식이다. 파일 크기를 더 줄이면서 부수적으로 코드의 가독성을 낮춰 보안성도 향상시키는 효과가 있다.
3. Gzip과 Brotli : 텍스트 압축 알고리즘
Gzip은 HTTP 프로토콜에서 데이터를 압축하는 표준 방식으로 거의 모든 브라우저에서 지원한다. Brotli는 Google이 개발한 더 최신 알고리즘으로 Gzip보다 평균 20-30% 더 높은 압축률을 제공한다. 다만 브라우저 지원 범위가 상대적으로 좁은 편이니 Brotli를 우선 제공하되 Gzip을 폴백으로 설정하는 게 좋다.
4. Tree Shaking : 사용하지 않는 코드를 제거
예를 들어 lodash 라이브러리에서 debounce 함수 하나만 쓰는데 전체 라이브러리를 불러온다면 낭비다. Webpack이나 Rollup 같은 모던 번들러는 ES6 모듈의 정적 구조를 분석해서 실제로 사용되는 코드만 번들에 포함시킨다. 이를 위해서는 import _ from 'lodash' 대신 import { debounce } from 'lodash-es' 처럼 명명된 import를 사용해야 한다.
- 이미지
이미지는 웹페이지에서 가장 큰 용량을 차지하는 리소스 중 하나다. 그만큼 최적화의 여지도 크다는 뜻이다.
1. 이미지 포맷
이미지 포맷마다 장단점이 있어서 용도에 맞게 선택하는 게 중요하다.
| 포맷 | 압축 방식 | 장점 | 단점 | 적합한 용도 |
|---|---|---|---|---|
| PNG | 무손실 | 투명도 지원, 높은 화질 유지, 세부 묘사 우수 | 파일 크기가 큼 | 로고, 아이콘, 텍스트가 포함된 이미지, 투명 배경 필요 시 |
| JPEG | 손실 | 작은 파일 크기, 대부분의 기기에서 지원 | 투명도 미지원, 압축 시 화질 저하 | 사진, 복잡한 이미지, 용량 제한이 있는 웹페이지 |
| WebP | 손실/무손실 | JPEG보다 25-34% 작은 크기, PNG보다 26% 작은 크기, 투명도 지원 | 일부 구형 브라우저 호환성 문제 | 웹사이트, 모바일 앱, 빠른 로딩이 필요한 곳 |
| AVIF | 손실/무손실 | WebP보다 20% 더 작은 크기, 뛰어난 압축률, HDR 지원 | 브라우저 호환성 제한적, 인코딩 시간이 긺 | 고화질 필요하면서 용량 절감이 중요한 경우, 최신 웹 플랫폼 |
WebP와 AVIF가 성능은 가장 뛰어나지만, 그만큼 최신 브라우저에서만 지원된다. 그래서 브라우저 호환성을 항상 고려해야한다. caniuse.com에서 webp와 avif의 브라우저 버전 별 지원 여부를 확인할 수 있다.

브라우저 호환성 문제를 해결하려면 picture 태그를 활용하면 된다. 브라우저가 지원하는 포맷 중 가장 최적화된 이미지를 자동으로 선택하게 할 수 있다.
<picture>
<!-- 1. 최신 AVIF 포맷 (지원하는 브라우저에서만 적용) -->
<source srcset="이미지.avif" type="image/avif">
<!-- 2. WebP 포맷 (AVIF 미지원 브라우저용) -->
<source srcset="이미지.webp" type="image/webp">
<!-- 3. 모든 브라우저에서 작동하는 폴백 이미지 (PNG/JPEG) -->
<img src="이미지.jpg" alt="이미지 설명" loading="lazy" width="800" height="600">
</picture>
picture 태그는 브라우저가 지원하는 첫 번째 source 요소를 선택해 표시하며, 지원하는 형식이 없을 경우 img 태그가 사용된다. 각 이미지 포맷에 대한 source 요소를 추가하면 브라우저 호환성을 확보하며 최적의 이미지 포맷을 제공할 수 있다.
2. 해상도 최적화
해상도란 이미지의 선명도나 디테일의 정도를 나타내는 지표이다. 웹에서는 주로 픽셀 단위로 표현되며 디바이스에 따라 필요한 해상도가 달라진다. 고해상도 이미지는 선명하지만 파일의 크기가 크기 때문에 반응형 이미지를 활용해 디바이스 크기에 맞는 적절한 이미지를 제공하는 것이 중요하다.
모바일에서는 작은 이미지를, 데스크톱에서는 큰 이미지를 제공하는 게 이상적이다. 모바일 화면에 데스크톱용 4K 이미지를 보여줄 필요는 없다. 이것도 picture 태그로 해결할 수 있다.
<picture>
<source
srcset="이미지-소형.avif 480w, 이미지-중형.avif 800w, 이미지-대형.avif 1200w"
type="image/avif"
sizes="(max-width: 600px) 480px, (max-width: 1024px) 800px, 1200px">
<source
srcset="이미지-소형.webp 480w, 이미지-중형.webp 800w, 이미지-대형.webp 1200w"
type="image/webp"
sizes="(max-width: 600px) 480px, (max-width: 1024px) 800px, 1200px">
<img
src="이미지-중형.jpg"
srcset="이미지-소형.jpg 480w, 이미지-중형.jpg 800w, 이미지-대형.jpg 1200w"
sizes="(max-width: 600px) 480px, (max-width: 1024px) 800px, 1200px"
alt="이미지 설명"
loading="lazy">
</picture>
srcset은 다양한 크기의 이미지 리소스를 나열하고, 각 이미지의 너비를 'w' 단위로 지정한다. sizes 속성은 브라우저가 어떤 조건에서 어떤 크기의 이미지를 사용할지 결정하는 규칙을 설정한다. 이렇게 함으로써 모바일 기기에는 작은 이미지를, 데스크톱에서는 큰 이미지를 효율적으로 제공할 수 있다.
3. 이미지 압축
이미지 압축은 원본 이미지의 화질을 유지하면서 파일 크기를 줄이는 과정이다. 이미지 최적화 도구(TinyPNG, ImageOptim, Squoosh 등)를 사용하면 육안으로 차이를 느끼기 어려운 수준으로 이미지 크기를 50-80% 줄일 수 있다.
내가 실제로 써본 도구 중에서는 Squoosh가 가장 괜찮았다. 웹에서 바로 사용할 수 있고, 압축 전후를 비교하면서 화질을 조절할 수 있어서 편하다. 빌드 파이프라인에 자동화하고 싶다면 imagemin 같은 라이브러리를 사용하면 된다.
- 폰트
- 폰트 포맷
폰트 파일도 은근히 용량을 많이 차지한다. 특히 한글 폰트는 글자 수가 많아서 더 그렇다.
| 포맷 | 특징 | 장점 | 단점 | 브라우저 지원 |
|---|---|---|---|---|
| TTF | 트루타입 폰트 | 높은 품질, 다양한 글리프 지원 | 파일 크기가 큼 | 대부분의 브라우저 지원 |
| OTF | 오픈타입 폰트 | 고급 타이포그래피 기능, TTF보다 발전된 형태 | 파일 크기가 큼 | 대부분의 브라우저 지원 |
| WOFF | 웹 오픈 폰트 포맷 | TTF/OTF를 압축, 메타데이터 추가 가능 | 구형 브라우저 일부 미지원 | IE9 이상, 모던 브라우저 |
| WOFF2 | 웹 오픈 폰트 포맷 2 | WOFF보다 30% 더 높은 압축률, 가장 작은 파일 크기 | 일부 구형 브라우저 미지원 | IE 미지원, Edge 14+, 대부분의 모던 브라우저 |
웹 폰트 최적화를 위해서는 현대적인 브라우저에서는 WOFF2를 우선 사용하고, 구형 브라우저를 위한 폴백으로 WOFF를 제공하는 것이 좋다.
2. 서브셋 폰트 : 전체 폰트에서 필요한 문자만 추출하여 사용하는 기술
특히 한글과 같이 글자 수가 많은 언어의 폰트 파일은 크기가 클 수밖에 없는데, 웹사이트에서 실제로 사용되는 글자만 포함한 폰트를 생성하면 파일 크기를 대폭 줄일 수 있다.
예를 들어, 웹사이트에서 자주 사용되는 한글 2,350자만 포함시키면 전체 11,172자를 포함하는 것보다 파일 크기를 약 80% 줄일 수 있다. 서브셋 폰트를 만들 때는 서비스에서 사용하는 텍스트를 분석해서 필요한 글자만 추출하는 게 중요하다. 구글 폰트는 자동으로 서브셋을 제공하고, 직접 만들고 싶다면 fonttools 같은 도구를 쓸 수 있다.
3. 다이나믹 폰트 : 필요한 글자만 동적으로 로드하는 기술
다이나믹 폰트를 통해 초기 로딩 시간을 크게 줄일 수 있다. 사용자가 보는 페이지에 필요한 글자만 먼저 불러오고, 다른 페이지로 이동하거나 새로운 콘텐츠가 표시될 때 추가 글자를 로드하는 방식이다. 이 방법은 특히 한글처럼 문자 집합이 큰 언어에서 효과적이다.
4. 폰트 프리로딩 : 웹 폰트 로딩 최적화 기술
페이지 렌더링 초기에 중요한 폰트 파일을 미리 로드하는 방법이다. 이를 통해 폰트 로딩 지연으로 인한 텍스트 깜빡임(FOUT) 현상을 줄이고 사용자 경험을 향상시킬 수 있다. 특히 페이지 로드 시 즉시 필요한 폰트에 적용하면 효과적이다.
<link rel="preload" href="myfont.woff2" as="font" type="font/woff2" crossorigin>
이 코드를 HTML의 <head> 태그 안에 넣어주면 브라우저가 페이지를 파싱하면서 폰트를 미리 다운로드한다. crossorigin 속성은 CORS 관련 이슈를 방지하기 위해 반드시 필요하다.
요청 횟수 줄이기
- 캐싱
- CDN 캐싱 : 사용자와 가까운 서버에 콘텐츠를 분산 저장하여 빠르게 제공하는 기술
이미지, CSS, JavaScript, 폰트, 문서 같은 정적 리소스를 전 세계 여러 데이터 센터에 복제해 두어 사용자가 가장 가까운 서버에서 콘텐츠를 다운로드할 수 있도록 할 수 있다.
서울에 있는 사용자가 미국 서버에서 이미지를 받는다면 수백 밀리초가 걸리지만, CDN을 쓰면 서울 데이터센터에서 받아와서 몇십 밀리초로 줄어든다. Cloudflare, AWS CloudFront, Fastly 같은 CDN 서비스들이 있다. 정적 리소스는 CDN에 올리는 게 좋다.
2. 브라우저 캐싱
브라우저 캐싱은 HTTP 헤더를 통해 제어한다. Cache-Control: max-age=31536000 같은 헤더를 설정하면 브라우저가 1년 동안 해당 리소스를 캐시한다. 물론 파일이 변경되었을 때를 대비해 파일명에 해시값을 넣어서 버전 관리를 하는 게 일반적이다. (app.a3f4b2.js 같은 식으로)
개인화된 REST API 같이 사용자 별로 다른 데이터를 제공해야하는 경우 CDN 캐싱이 아니라 브라우저 캐싱을 주로 활용한다.
3. JavaScript 캐싱
API 응답 데이터도 캐싱할 수 있다. React Query나 SWR 같은 라이브러리를 사용하면 서버 상태 관리와 캐싱을 쉽게 할 수 있다.
React Query를 사용하면 같은 쿼리 키로 요청했을 때 캐시된 데이터를 바로 반환하고, 백그라운드에서 새로운 데이터를 받아와 업데이트한다. 사용자는 즉각적인 응답을 보면서도 최신 데이터를 받을 수 있는 것이다.
const { data } = useQuery('todos', fetchTodos, {
staleTime: 60000, // 1분 동안은 캐시된 데이터를 fresh하다고 간주
cacheTime: 300000, // 5분 동안 캐시 유지
});
- 필요한 건 필요한 때에 호출하기 : Code Splitting, Dynamic Import, Lazy Loading
React.js같은 SPA를 사용하면 처음부터 모든 페이지의 모든 모듈을 불러온다. 하지만 A페이지에 접속할 때 B페이지의 내용을 로드할 필요가 없고, 아직 화면에 보여주지 않아도 되는 모달을 처음부터 로드할 필요가 없다.
Code Splitting은 현재 보여주지 않아도 되는 컴포넌트의 파일을 별도의 파일로 분리하는 것을 말한다. Dynamic Import는 동적으로 불러오는 것이고, 이렇게 지연시켜 로딩하는 것을 Lazy Loading이라 한다. 리액트에서는 lazy를 사용해 Dynamic Import할 수 있다.
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
이렇게 하면 HeavyComponent는 실제로 렌더링될 때만 로드된다. 초기 번들 크기를 크게 줄일 수 있어서, 특히 라우팅 단위로 Code Splitting을 하면 효과가 좋다.
이미지도 Lazy Loading을 적용할 수 있다. loading="lazy" 속성을 img 태그에 추가하면 뷰포트에 들어올 때만 이미지를 로드한다.
<img src="heavy-image.jpg" loading="lazy" alt="설명">
- 우선순위 조정하기
link 태그의 rel 속성을 통해 파일을 로드할 때의 우선순위를 지정할 수 있다.
- `preload`는 높은 우선순위로, 현재 페이지에서 곧 필요한 리소스를 미리 로드
- `prefetch`는 중간 우선순위로, 미래에 필요할 수 있는 리소스를 여유 있을 때 로드
- `preconnect`는 특정 도메인과 미리 연결만 맺어두는 것
<!-- 중요한 폰트는 preload -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<!-- 다음 페이지에서 사용할 스크립트는 prefetch -->
<link rel="prefetch" href="/js/next-page.js">
<!-- 외부 API 서버와는 미리 연결 -->
<link rel="preconnect" href="https://api.example.com">
이런 힌트들을 적절히 활용하면 브라우저가 더 똑똑하게 리소스를 로드할 수 있다. 정말 중요한 리소스에만 걸어야 한다. 남발하면 오히려 느려진다.
렌더링 성능 개선하기
메모이제이션
리액트에서 불필요한 리렌더링을 줄이는 가장 기본적인 방법이 메모이제이션이다.
- memo : 컴포넌트를 메모이제이션할 수 있으며, 부모 컴포넌트가 리렌더링되어도 Props가 변경되지 않으면 리렌더링되지 않는다.
import { memo } from 'react';
const ExpensiveComponent = memo(function ExpensiveComponent({ data }) {
// 복잡한 계산이나 렌더링 로직
return <div>{/* ... */}</div>;
});
부모가 리렌더링되더라도 data prop이 같으면 ExpensiveComponent는 리렌더링하지 않는다. 다만 props 비교는 얕은 비교로 이루어지기 때문에, 객체나 배열을 props로 넘길 때는 매 번 새롭게 참조값이 바뀌지 않도록 주의해야한다. 이러한 경우에는 2번에서 소개할 useMemo를 사용할 수 있다.
- useMemo : 값을 메모이제이션하며, 리렌더링 사이에 계산 결과를 캐싱해준다.
import { useMemo } from 'react';
function Component({ items }) {
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.value - b.value);
}, [items]);
return <List items={sortedItems} />;
}
items가 변경되지 않으면 정렬 연산을 다시 수행하지 않는다.
- useCallback : 함수를 메모이제이션하며, 리렌더링 사이에 함수 정의를 캐싱해준다.
import { useCallback } from 'react';
function ParentComponent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('Clicked!');
}, []); // 의존성 배열이 비어있으면 함수는 절대 재생성되지 않음
return <ChildComponent onClick={handleClick} />;
}
함수는 리렌더링될 때마다 새로 생성되는데, 이렇게 되면 해당 함수를 props로 받는 자식 컴포넌트가 memo로 감싸져 있어도 매번 리렌더링된다. useCallback으로 함수를 메모이제이션하면 이 문제를 해결할 수 있다.
일 나누기
- Web Worker
무거운 계산 작업이 메인 스레드에서 실행되면 UI가 멈춘다. 사용자가 버튼을 클릭해도 반응이 없고, 스크롤도 버벅거리는 최악의 경험을 주게 된다. 이럴 때 Web Worker를 사용하면 별도의 스레드에서 작업을 처리할 수 있다.
// worker.js
self.addEventListener('message', (e) => {
const result = heavyCalculation(e.data); // 무거운 계산
self.postMessage(result);
});
function heavyCalculation(data) {
// 복잡한 계산 로직
return processedData;
}
// main.js
const worker = new Worker('worker.js');
worker.postMessage(inputData);
worker.addEventListener('message', (e) => {
const result = e.data;
// 결과를 UI에 반영
});
이미지 처리, 대용량 데이터 파싱, 복잡한 수학 연산 같은 CPU 집약적인 작업을 Worker로 분리하면 메인 스레드가 자유로워져서 UI가 부드럽게 동작한다.
레이아웃 시프트
레이아웃 시프트는 사용자 경험을 크게 해치는 요소다. 글을 읽고 있는데 갑자기 광고가 로드되면서 화면이 밀려나거나, 버튼을 클릭하려는 순간 레이아웃이 바뀌어서 엉뚱한 걸 누르게 되는 경험. 나도 자주 겪는다.
레이아웃 시프트를 방지하는 핵심은 리소스가 로드되기 전에 공간을 미리 확보하는 것이다.
- 이미지와 비디오
이미지나 비디오에는 반드시 width와 height 속성을 명시해야 한다.
<!-- 좋은 예 -->
<img src="photo.jpg" width="800" height="600" alt="사진">
<!-- 나쁜 예 -->
<img src="photo.jpg" alt="사진">
width와 height를 명시하면 브라우저가 이미지가 로드되기 전에 정확한 공간을 확보한다. CSS로 반응형을 만들더라도 HTML 속성의 비율은 유지된다.
- 폰트 로딩
폰트가 로드되면서 텍스트가 깜빡이는 FOUT(Flash of Unstyled Text)나 FOIT(Flash of Invisible Text) 현상도 레이아웃 시프트를 일으킬 수 있다.
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 폴백 폰트를 먼저 보여주고 커스텀 폰트가 로드되면 교체 */
}
font-display: swap을 사용하면 폴백 폰트와 커스텀 폰트의 크기 차이로 인한 레이아웃 시프트가 발생할 수 있다. 이를 최소화하려면 폴백 폰트의 크기를 조정하는 size-adjust 같은 속성을 활용할 수 있다.
@font-face {
font-family: 'Fallback Font';
src: local('Arial');
size-adjust: 95%; /* 커스텀 폰트와 크기를 맞춤 */
}
- 동적 콘텐츠
광고나 배너 같은 동적 콘텐츠는 최소 높이를 미리 지정해두자.
.ad-container {
min-height: 250px; /* 광고가 로드되기 전에도 공간 확보 */
background: #f0f0f0; /* 로딩 중 배경색 */
}
- Skeleton UI
콘텐츠가 로드되는 동안 스켈레톤 UI를 보여주는 것도 좋은 방법이다. 실제 콘텐츠와 비슷한 구조의 플레이스홀더를 보여줌으로써 레이아웃 시프트를 없애고, 사용자에게 로딩 중이라는 피드백도 줄 수 있다.
function ProfileCard({ user }) {
if (!user) {
return (
<div className="skeleton">
<div className="skeleton-avatar"></div>
<div className="skeleton-text"></div>
<div className="skeleton-text short"></div>
</div>
);
}
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.bio}</p>
</div>
);
}
실제로 토스 증권, 당근, 봄봄, 오늘의 집 같은 서비스들이 이 방식을 적극 활용하고 있다. 사용자는 로딩이 끝나길 기다리면서도 페이지 구조를 미리 파악할 수 있어서 체감 성능이 훨씬 좋아진다.




성능 측정하기
성능 개선을 했다면 반드시 측정을 해야 한다. 체감이 아니라 실제 수치로 말이다.
- Lighthouse
크롬 개발자 도구에 내장된 Lighthouse는 웹 성능을 종합적으로 분석해준다. Performance, Accessibility, Best Practices, SEO 등 여러 지표를 측정하고 개선 방안도 제시한다.
개발자 도구를 열고 Lighthouse 탭에서 'Analyze page load' 버튼을 누르면 된다. 특히 주의깊게 봐야 할 지표는 다음과 같다.
- FCP (First Contentful Paint): 첫 콘텐츠가 화면에 나타나는 시간
- LCP (Largest Contentful Paint): 가장 큰 콘텐츠가 렌더링되는 시간
- TBT (Total Blocking Time): 메인 스레드가 차단된 총 시간
- CLS (Cumulative Layout Shift): 레이아웃 시프트 점수
2. Web Vitals
구글이 정의한 Core Web Vitals는 사용자 경험의 핵심 지표이다. LCP, FID(First Input Delay), CLS 세 가지로 구성되어 있고, 이 지표들이 좋으면 SEO 순위에도 긍정적인 영향을 준다.
실제 사용자 환경에서 측정하려면 web-vitals 라이브러리를 사용할 수 있다.
import { getCLS, getFID, getLCP } from 'web-vitals';
getCLS(console.log);
getFID(console.log);
getLCP(console.log);
이 데이터를 애널리틱스 도구로 보내서 실사용자 모니터링(RUM, Real User Monitoring)을 할 수 있다.
- React Developer Tools Profiler

리액트 애플리케이션이라면 React Developer Tools의 Profiler를 활용하자. 어떤 컴포넌트가 왜 리렌더링되는지, 렌더링에 얼마나 시간이 걸리는지 시각적으로 보여준다.
'Record' 버튼을 누르고 성능 문제가 있는 동작을 수행한 후 정지하면, 각 컴포넌트의 렌더링 시간과 빈도를 확인할 수 있다. 불필요하게 자주 렌더링되는 컴포넌트를 찾아서 메모이제이션을 적용하기 좋다.
맺으며
이 글에서 각 성능 개선을 하는 방법에 대해서 구체적으로 다루지는 않았다. 왜냐하면 각 방법이 번들러마다, 프레임워크마다 다르기 때문이다.
예를 들어 코드 스플리팅을 구현할 때 Webpack은 webpack.config.js에서 optimization.splitChunks를 통해 설정하지만, Vite는 vite.config.js에서 build.rollupOptions.output.manualChunks를 통해 설정한다. 트리 쉐이킹도 마찬가지다. Webpack에서는 mode: 'production'만 설정하면 자동으로 처리되고, Rollup과 esbuild에서는 별도 설정 없이 기본으로 지원된다. 이미지 최적화도 Next.js에서는 Image 컴포넌트만 사용하면 WebP 변환, 반응형 이미지 생성, lazy loading이 자동으로 처리되지만, 일반 React 프로젝트에서는 picture 태그를 직접 작성하고 이미지 포맷 변환도 수동으로 해야 한다. 폰트 최적화 역시 Next.js 13+에서는 next/font로 간단히 처리되지만, 다른 환경에서는 @font-face 설정부터 preload까지 직접 해야 한다.
즉 Webpack, Vite, Rollup, esbuild 같은 빌드 도구나 Next.js, Nuxt.js, SvelteKit 같은 프레임워크를 사용하는 한, 어떤 최적화 개념이 있고 그것이 무엇인지만 알아두는 것으로도 충분하다는 걸 의미한다. 구체적인 구현 방법은 사용하는 도구의 공식 문서를 참고하면 된다.
성능 최적화의 핵심은 어떤 문제가 있는지 파악하고, 어떤 개념으로 해결할 수 있는지 아는 것이다. 번들 크기가 크면 Code Splitting, 이미지가 느리면 포맷 최적화, 레이아웃이 밀리면 CLS. 문제와 개념을 연결할 수만 있으면 구체적인 방법은 문서에서 찾으면 된다.
또한 성능 개선은 한 번 하고 끝나는 작업이 아니다. 새로운 기능이 추가되고 코드베이스가 커지면서 성능은 자연스럽게 저하된다. 그래서 지속적인 모니터링이 중요하다. Lighthouse를 CI/CD 파이프라인에 통합해서 성능 점수가 일정 수준 이하로 떨어지면 배포를 막는다거나, web-vitals 라이브러리로 실제 사용자 데이터를 수집해서 주기적으로 분석하는 식으로 말이다.
성능 개선의 가장 큰 장벽은 기술적 난이도가 아니라 우선순위였던 거 같다. 새로운 기능 개발에 밀려서 성능 개선은 항상 뒤로 밀리기 쉽다. 하지만 성능이 나빠지면 사용자 이탈률이 올라가고, 결국 서비스의 성장을 가로막는다. 몇 초의 로딩 시간 단축이 전환율을 올릴 수 있고, 부드러운 인터랙션이 사용자 만족도를 크게 높일 수 있을 거라 생각한다.
실제로 우아한테크코스에서 진행한 최적화 작업의 전후 비교를 공유합니다.
- Before
- After
webpack 번들러를 기준으로 실제 적용한 구체적인 최적화 방법과 수치적인 개선 효과가 궁금하다면 성능 개선 일지를 참고하길 바랍니다.