1. 웹 성능 최적화는 왜 필요할까
웹 개발을 하다보면서 성능에 관련된 이슈는 끊이질 않는다. 나도 처음에는 ‘기능만 잘 동작하면 되는거 아니야?’라는 생각했다. 하지만 프론트엔드 기술이 고도화되면서 성능 최적화가 된 사이트와 아닌 사이트의 사용감의 차이는 엄청나다. 실제로 일을 하면서 성능 최적화가 되지 않아 UX, DX 모두 필요한 부분들이 많았다.
구글이 주장하는 핵심은 ‘성능이 저하되면 사용자가 떠나고 매출이 감소한다’이다.
•
3초 이상: 32% 이탈률
•
5초 이상: 90% 이탈률
•
6초 이상: 106% 이탈률
•
10초 이상: 123% 이탈률
따라서 웹 성능을 최적화해서 서비스 사용자에게 더 나은 사용자 경험을 제공할 필요가 있다.
1-1. 성능 최적화 구성
1.
로딩 성능
•
웹 페이지에 필요한 리소스들을 다운로드 할 떄의 성능을 말한다.
•
예를 들어 고화질 이미지가 포함되어 있을 때, 느린 인터넷 환경에서는 이 이미지가 매우 늦게 표시될 것이다. 마찬가지로 HTML, JS, CSS 파일이 크기가 크다면 다운로드 시간이 오래 걸려 사용자에게 웹 페이지가 느리게 표시될 것이다. 따라서 리소스 수를 줄이거나 크기를 줄이는 것이다. 그 밖에는 코드를 분할하여 다운로드하거나 리소스에 우선순위를 매겨 중요한 리소스를 먼저 다운로드 받는다.
2.
렌더링 성능
•
렌더링 성능에 크게 영향을 주는 것은 자바스크립이며, 코드를 얼마나 효율적으로 작성했는지에 따라 화면에 그려지는 속도와 사용자 인터렉션의 자연스러운 정도가 달라진다. 즉, 브라우저의 동작 원리나 사용하는 프레임워크의 라이프사이클 등 웹 개발의 기본 지식을 이해해야 한다.
1-2. 주요 수치들
•
First Contentful Paint(FCP): 브라우저가 DOM 콘텐츠의 첫 번째 부분을 렌더링하는데 걸리는 시간 지표. 페이지에 진입하여 첫 콘텐츠가 뜨기까지의 시간.
•
Speed Index(SI): 페이지 로드 중에 콘텐츠가 시각적으로 표시되는 속도를 나타내는 지표. A, B의 사이트가 전체 화면이 4초라는 동일한 시간이 걸려도 A의 일부 콘텐츠가 먼저 렌더링 된다면 더 빨리 로드된 것으로 계산된다.
•
Largest Contentful Paint(LCP): 페이지가 로드될 때 화면 내에 있는 가장 큰 이미지나 텍스트 요소가 렌더링되기까지 걸리는 시간을 나타내는 지표.
•
Time to Interactive(TTI): TTI는 사용자가 페이지와 상호 작용이 가능한 시점까지 걸리는 시간을 측정한 지표.
•
Total Blocking Time(TBT): 페이지가 클릭, 키보드 입력 등의 사용자 입력에 응답하지 않도록 차단된 시간을 총합한 지표. 측정은 FCP, TTI 사이의 시간 동안 일어나며 메인 스레드를 독점하여 다른 동작을 방해하는 작업에 걸린 시간을 합한다.
•
Cumulative Layout Shift(CLS): 페이지 로드 과정에서 발생하는 예기치 못한 레이아웃 이동을 측정한 지표. 레이아웃 이동이란 화면상에서 요소의 위치나 크기가 순간적으로 변하는 것을 말한다.
Lighthouse 툴을 사용해 웹 페이지를 진단할 수 있다.
•
진단은 로드 속도와 직접적인 관계는 없지만 성능과 관련된 기타 정보를 보여준다.
2. 이미지 사이즈 최적화
비효율적인 이미지를 분석한다. 예를 들어 이미지의 사이즈가 400px로 사용되는데 실제 이미지 크기가 1200px이라면 비효율적이다. 따라서 적절한 이미지 사이즈로 변경한다면 이미지당 용량을 줄일 수 있기 때문에 로드에 소용되는 시간을 단축할 수 있다. 정확한 수치 값은 Lighthouse로 측정이 가능하다.
그리고 CSS에서 120px로 설정된 이미지라도, 고해상도(예: Retina 디스플레이) 환경에서는 2배의 픽셀 밀도를 요구하므로 240px 이상의 원본 이미지가 필요.
2-1. 이미지 CDN
이미지 CDN을 이용해 줄이는 방법이 있다. 기본적인 CDN 기능과 더불어 이미지를 사용자에게 보내기 전에 특정 형태로 가공하여 전해주는 기능이다.
https://images.unsplash.com/photo-1542435503-956c469947f6?w=240&h=240&q=80&fm=jpg&fit=crop
Plain Text
복사
3. 병목 코드 최적화
퍼포먼스 탭 활용
3-1. CPU 차트, 네트워크, 스크린샷
시간에 따라 CPU가 어떤 작업에 리소스를 사용하고 있는지 비율로 보여준다.
•
자바스크립트는 노란색
•
렌더링/레이아웃 작업은 보라색
•
페인팅 작업은 초록색
•
시스템 작업은 회색
빨간색 선은 병목이 발생하는 지점을 의미한다. 특정 작업이 메인 스레드를 오랫동안 잡아 두고 있다는 뜻이다. 네트워크 차트는 진한 막대가 우선순위가 높은 네트워크 리소스를 의미한다.
3-2. 네트워크
•
초기 연결 시간, 요청을 보낸 시점부터 응답을 기다리는 시점, 다운로드 시간, 메인 스레드의 작업 시간을 표시해준다.
3-3. Frame, Timings, Main
•
Frame 섹션은 화면의 변화가 있을 때마다 스크린샷을 찍어 보여준다.
•
Timings 섹션은 User Timing API를 통해 기록된 정보를 기록한다.
4. 코드 분할, 지연 로딩, 사전로딩
•
코드 분할이란 페이별로 코드를 분리하는 것. 하나의 파일을 여러 개의 파일로 쪼개는 방법이다.
•
분할된 코드는 사용자가 서비스를 이용하는 중 해당 코드가 필요해지는 시점에 로드되어 실행된다. 이를 지연로딩이라고 한다.
•
lazy 함수는 동적 import를 호출하여 그 결과로 반환되는 Promise를 받아 처리. 이 방식으로 코드 분할(Code Splitting)이 이루어지며, 필요한 컴포넌트가 요청될 때만 로드.
•
lazy 함수로 반환된 컴포넌트는 반드시 <Suspense>로 감싸서 렌더링해야 합니다. Suspense는 로딩 중일 때 fallback UI를 제공하며, 로딩이 완료되면 해당 컴포넌트를 렌더링.
•
컴포넌트의 코드 분할을 통해 지연 로딩이 적용되었고, 추가로 사전 로딩까지 적용한 코드. 사전 로딩은 컴포넌트가 필요하기 전에 미리 로드하도록 설정하여, 실제로 필요할 때 더 빠르게 로드
import React, { Suspense, useEffect } from 'react';
// 동적 import로 컴포넌트를 로드
const LazyComponent = React.lazy(() => import('./LazyComponent'));
// 컴포넌트가 필요하기 전에 미리 로드하도록 사전 로딩을 설정
const preloadedComponent = import('./LazyComponent');
function MyComponent() {
// 사전 로딩이 완료된 후에 렌더링
useEffect(() => {
preloadedComponent.then(() => {
console.log('컴포넌트가 미리 로드되었습니다.');
});
}, []);
return (
<Suspense fallback={<div>로딩 중...</div>}>
<LazyComponent />
</Suspense>
);
}
export default MyComponent;
JavaScript
복사
5. 이미지 지연 로딩, 사전 로딩
5-1. 이미지 사전 로딩
•
만약 이미지 사전 로딩이 필요하다면, 아래와 같이 미리 다운로드를 해준다.
useEffect(() => {
const component = import('./components/ImageModal');
const image = new Image();
image.src =
'이미지 URL 입력';
}, []);
JavaScript
복사
5-2. 이미지 지연 로딩
•
Intersection Observer를 이용한 이미지 지연 로딩
root: 기준이 되는 영역을 설정 (뷰포트 혹은 특정 요소)
rootMargin: 기준 영역의 범위를 확장하거나 축소
threshold: 요소가 얼마나 보였을 때 콜백을 호출할지 설정
이미지 src 값을 dataset으로 넣어줘서 초기로딩을 막아준다.
import React, { useEffect } from 'react';
function Card(props) {
const imgRef = React.useRef(null);
useEffect(() => {
const options = {};
const callback = (entries, observer) => {
console.log('🚀 ~ callback ~ entries, observer:', entries, observer);
};
const observer = new IntersectionObserver(callback, options);
observer.observe(imgRef.current);
}, []);
return (
<div className='Card text-center'>
<img
ref={imgRef}
src={props.image}
/>
<div className='p-5 font-semibold text-gray-700 text-xl md:text-lg lg:text-xl keep-all'>
{props.children}
</div>
</div>
);
}
export default Card;
JavaScript
복사
5-3. 이미지 사이즈 최적화
이미지 지연로딩으로 인해 최적화가 되었지만, 이미지 지연이 좀 느린 것이 문제. 이미지 최적화를 해보자.
5-3-1. 이미지 포맷 종류
•
AVIF는 최신 브라우저와 고급 압축을 지원하는 경우 이상적.
•
WebP는 브라우저 호환성이 중요한 경우.
•
JPEG는 전통적인 사진 이미지에, PNG는 투명도가 필요한 경우에 적합.
•
SVG는 벡터 이미지에 사용.
5-3-2. 이미지 사이즈
2-1에서 봤던 것처럼 CSS 사이즈에 맞춰서 2배 사이즈로 최적화를 해주자.
5-3-3. 이미지 호환성을 위한 picture 태그
•
아래에서 picture 태그를 확인.
<picture>
<source
data-srcset={props.webp}
type='image/webp'
/>
<img
ref={imgRef}
data-src={props.image}
/>
</picture>
HTML
복사
const imgRef = React.useRef(null);
useEffect(() => {
const options = {};
const callback = (entries, observer) => {
entries.forEach((entry) => {
console.log('🚀 ~ entries.forEach ~ entry:', entry);
if (entry.isIntersecting) {
const target = entry.target;
const previousSibling = target.previousSibling;
target.src = target.dataset.src;
previousSibling.srcset = previousSibling.dataset.srcset;
observer.unobserve(entry.target);
}
});
};
const observer = new IntersectionObserver(callback, options);
observer.observe(imgRef.current);
return () => {
observer.disconnect();
};
}, []);
JavaScript
복사
6. 캐시 최적화
웹에서 사용하는 캐시는 크게 두 가지로 구분한다.
•
메모리 캐시: 메모리에 저장하는 방식이다.
•
디스크 캐시: 파일 형태로 디스크에 저장하는 방식이다.
어떤 캐시를 사용할지는 직접 제어할 수 없다. 브라우저가 사용 빈도나 파일 크기에 따라 특정 알고리즘에 의해 알아서 처리하기 때문이다. cache-control은 서버에서 설정되며, 이를 통해 브라우저는 해당 리소스를 얼마나 캐시할지 판단한다.
6-1. Cache-Control
•
no-cache: 캐시를 사용하기 전 서버에 검사 후 사용
◦
사용 전에 서버에 캐시된 리소스를 사용해도 되는지 한 번 체크하는 옵션.
•
no-store: 캐시 사용 안함
•
public: 모든 환경에서 캐시 사용 가능
•
private: 브라우저 환경에서만 캐시 사용, 외부 캐시 서버에서는 사용 불가
•
max-age: 캐시 유효 시간
서버에서 파일별 캐시 적용. HTML은 늘 최신 상태를 유지해야 한다.
const header = {
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
} else if (path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.webp')) {
res.setHeader('Cache-Control', 'public, max-age=31536000');
} else {
res.setHeader('Cache-Control', 'public, max-age=31536000');
}
},
};
JavaScript
복사